Skip to content
Snippets Groups Projects
Select Git revision
  • d13819210680870d3a75fc8d040a2d9f3a039207
  • main default protected
2 results

settingsMenu.js

Blame
  • settingsMenu.js 19.63 KiB
    import { useState, useEffect } from 'react';
    import { useSelector, useDispatch } from 'react-redux';
    import { useLocation, useNavigate, Navigate } from 'react-router-dom';
    import PropTypes from 'prop-types';
    
    import {
      useClientIdentity,
      useSeekingFlag,
      seek,
      unseek,
      invite,
      useOnSeekReady,
      useOnInvitationMade,
    } from '../../client.js';
    
    import { Modal, createModalOpener } from '../../widgets/modal.js';
    import { Menu, MenuText, MenuButton } from '../../widgets/menu.js';
    import { Form } from '../../widgets/form.js';
    import { ButtonBar, Button } from '../../widgets/buttonBar.js';
    import { Toggle } from '../../widgets/toggle.js';
    
    import styles from './settingsMenu.module.css';
    import editIcon from '../../icons/edit.svg';
    import backIcon from '../../icons/back.svg';
    import forwardIcon from '../../icons/forward.svg';
    import thinkingIcon from '../../icons/thinking.svg';
    
    import {
      selectDefaultTimeControlFlagsBySlot,
      selectDefaultFastestTimeControlsBySlot,
      selectDefaultSlowestTimeControlsBySlot,
      selectDefaultPlayersBySlot,
      setDefaultTimeControlFlag,
      setDefaultFastestTimeControls,
      setDefaultSlowestTimeControls,
      setDefaultPlayers,
    } from './defaultSettingsSlice.js';
    import {
      selectTreesBySlot,
      loadTree,
    } from './treeSlotsSlice.js';
    import {
      selectPlayers,
      selectTimeControls,
      selectCompensations,
      selectGames,
      selectRootPositions,
      setPlayer,
      setPlayers,
      synchronizeNetworkGame,
      setTimeControls,
      setCompensation,
      rotateBoardFor,
      deleteGame,
    } from '../play/gameTreesSlice.js';
    import {
      selectKnights,
    } from '../preferences/piecesSlice.js';
    
    import {
      HUMAN_PLAYER,
      REMOTE_PLAYER,
      formatHandicap,
      formatAsText,
      formatAsHTML,
      AVAILABLE_LOCAL_PLAYERS,
      AVAILABLE_REMOTE_PLAYERS,
      getOriginalDefaultPlayers,
    } from '../play/playerTypes.js';
    
    const SECONDS_PER_MINUTE = 60;
    const ORIGINAL_DEFAULT_TIME_CONTROL_FLAG = false;
    const ORIGINAL_DEFAULT_FASTEST_TIME_CONTROLS = 480;
    const ORIGINAL_DEFAULT_SLOWEST_TIME_CONTROLS = 720;
    
    const HANDICAP_LIMIT = 11;
    const HANDICAPS = [...Array(HANDICAP_LIMIT).keys()];
    
    function playersEqual(left, right) {
      const properties = new Set();
      for (const property of Object.getOwnPropertyNames(left)) {
        properties.add(property);
      }
      for (const property of Object.getOwnPropertyNames(right)) {
        properties.add(property);
      }
      properties.delete('handicap');
      for (const property of properties) {
        if (left[property] !== right[property]) {
          return false;
        }
      }
      return true;
    }
    
    function PlayerTypeSubMenu(props) {
      const treeName = useSelector(selectTreesBySlot)[props.slot];
      const players = useSelector(selectPlayers)[treeName];
      const player = players[props.playerIndex];
    
      const navigate = useNavigate();
      const dispatch = useDispatch();
    
      const menuButtons = [];
      for (const option of props.remote ? AVAILABLE_REMOTE_PLAYERS : AVAILABLE_LOCAL_PLAYERS) {
        const optionDescription = formatAsText(option);
        const marker = playersEqual(option, player) ? ' ✓' : '';
        const onClick = () => {
          const oldPlayer = players[props.playerIndex];
          const newPlayers = [...players];
          newPlayers[props.playerIndex] = {
            ...player,
            ...option,
          };
          if (props.remote && newPlayers.length >= 2) {
            let localPlayerCount = 0;
            for (const newPlayer of newPlayers) {
              if (newPlayer.type === HUMAN_PLAYER) {
                ++localPlayerCount;
              }
            }
            if (localPlayerCount === 0) {
              console.assert(
                oldPlayer.type === HUMAN_PLAYER,
                'Unexpectedly found zero local players after an unrelated user action.',
              );
              newPlayers[props.playerIndex === 0 ? 1 : 0] = oldPlayer;
            } else if (localPlayerCount > 1) {
              console.assert(
                oldPlayer.type !== HUMAN_PLAYER && option.type === HUMAN_PLAYER,
                'Unexpectedly found mutliple local players after an unrelated user action.',
              );
              for (let index = 0; index < newPlayers.length; ++index) {
                if (index !== props.playerIndex && newPlayers[index].type === HUMAN_PLAYER) {
                  newPlayers[index] = oldPlayer;
                }
              }
            }
          }
          dispatch(setPlayers({
            treeName,
            players: newPlayers,
          }));
          // eslint-disable-next-line no-magic-numbers
          navigate(props.remote ? -1 : -2);
        };
        menuButtons.push(
          <MenuButton key={optionDescription} onClick={onClick}>
            {optionDescription}{marker}
          </MenuButton>,
        );
      }
    
      return (
        <Modal subpath={props.subpath} altText={props.title}>
          <Menu>
            <MenuText>
              {props.title}
            </MenuText>
            {menuButtons}
          </Menu>
        </Modal>
      );
    }
    
    PlayerTypeSubMenu.propTypes = {
      subpath: PropTypes.string.isRequired,
      title: PropTypes.string.isRequired,
      slot: PropTypes.string.isRequired,
      playerIndex: PropTypes.number.isRequired,
      remote: PropTypes.bool.isRequired,
    };
    
    function HandicapSubMenu(props) {
      const treeName = useSelector(selectTreesBySlot)[props.slot];
      const players = useSelector(selectPlayers)[treeName];
      const player = players[props.playerIndex];
    
      const navigate = useNavigate();
      const dispatch = useDispatch();
    
      const menuButtons = [];
      for (const option of HANDICAPS) {
        const optionDescription = formatHandicap(option);
        const marker = option === player.handicap || (option === 0 && player.handicap === undefined) ? ' ✓' : '';
        const onClick = () => {
          dispatch(setPlayer({
            treeName,
            playerIndex: props.playerIndex,
            player: {
              ...player,
              handicap: option,
            },
          }));
          // eslint-disable-next-line no-magic-numbers
          navigate(-2);
        };
        menuButtons.push(
          <MenuButton key={optionDescription} onClick={onClick}>
            {optionDescription}{marker}
          </MenuButton>,
        );
      }
    
      return (
        <Modal subpath={props.subpath} altText={props.title}>
          <Menu>
            <MenuText>
              {props.title}
            </MenuText>
            {menuButtons}
          </Menu>
        </Modal>
      );
    }
    
    HandicapSubMenu.propTypes = {
      subpath: PropTypes.string.isRequired,
      title: PropTypes.string.isRequired,
      slot: PropTypes.string.isRequired,
      playerIndex: PropTypes.number.isRequired,
    };
    
    function PlayerMenu(props) {
      const location = useLocation();
      const navigate = useNavigate();
    
      const playerName = `Player ${props.playerIndex + 1}`;
      const playerTypeTitle = `Change ${playerName}'s Type and/or Assistance`;
      const playerTypeSubpath = `playerType${props.playerIndex + 1}`;
      const handicapTitle = `Change ${playerName}'s Handicap`;
      const handicapSubpath = `handicap${props.playerIndex + 1}`;
    
      return (
        <>
          <Modal subpath={props.subpath} altText={'Piece Settings'}>
            <Menu>
              <MenuText>
                {`Change ${playerName}`}
              </MenuText>
              <MenuButton onClick={createModalOpener(location, navigate, playerTypeSubpath)}>
                {playerTypeTitle}
              </MenuButton>
              <MenuButton onClick={createModalOpener(location, navigate, handicapSubpath)}>
                {handicapTitle}
              </MenuButton>
            </Menu>
          </Modal>
          <PlayerTypeSubMenu
            subpath={playerTypeSubpath}
            title={playerTypeTitle}
            slot={props.slot}
            playerIndex={props.playerIndex}
            remote={false} />
          <HandicapSubMenu
            subpath={handicapSubpath}
            title={handicapTitle}
            slot={props.slot}
            playerIndex={props.playerIndex} />
        </>
      );
    }
    
    PlayerMenu.propTypes = {
      subpath: PropTypes.string.isRequired,
      slot: PropTypes.string.isRequired,
      playerIndex: PropTypes.number.isRequired,
    };
    
    function PlayerButton(props) {
      const treeName = useSelector(selectTreesBySlot)[props.slot];
      const players = useSelector(selectPlayers)[treeName];
      const player = players[props.playerIndex];
      const game = useSelector(selectGames)[treeName];
      const knights = useSelector(selectKnights);
    
      const navigate = useNavigate();
      const location = useLocation();
    
      if (game === undefined || players === undefined) {
        return null;
      }
    
      const image = knights.get(props.playerIndex);
      // eslint-disable-next-line no-irregular-whitespace
      const playerName = `Player ${props.playerIndex + 1}`;
      const description = formatAsHTML(player);
      const subpath = `player${props.playerIndex + 1}`;
      const onEdit = createModalOpener(location, navigate, subpath);
    
      return (
        <>
          <MenuButton className={styles.player} disabled={props.disabled} onClick={onEdit} onContextMenu={onEdit}>
            <img className={styles.icon} src={image} alt={playerName} />
            <span className={styles.description}>{description}</span>
            <img className={styles['edit-icon']} src={editIcon} alt={'Edit'} />
          </MenuButton>
          { props.disabled ? null : props.remote ?
            <PlayerTypeSubMenu
              subpath={subpath}
              title={`Change ${playerName}'s Type`}
              slot={props.slot}
              playerIndex={props.playerIndex}
              remote={true} /> :
            <PlayerMenu
              subpath={subpath}
              slot={props.slot}
              playerIndex={props.playerIndex} /> }
        </>
      );
    }
    
    PlayerButton.propTypes = {
      slot: PropTypes.string.isRequired,
      playerIndex: PropTypes.number.isRequired,
      remote: PropTypes.bool.isRequired,
      disabled: PropTypes.bool.isRequired,
    };
    
    function SettingsMenuControls(props) {
      const clientIdentity = useClientIdentity();
      const seeking = useSeekingFlag();
    
      const defaultTimeControlFlag = useSelector(selectDefaultTimeControlFlagsBySlot)[props.slot];
      const defaultFastestTimeControls = useSelector(selectDefaultFastestTimeControlsBySlot)[props.slot];
      const defaultSlowestTimeControls = useSelector(selectDefaultSlowestTimeControlsBySlot)[props.slot];
      const defaultPlayers = useSelector(selectDefaultPlayersBySlot)[props.slot];
      const treeName = useSelector(selectTreesBySlot)[props.slot];
      const players = useSelector(selectPlayers)[treeName] || [];
      const timeControls = useSelector(selectTimeControls)[treeName];
      const compensated = useSelector(selectCompensations)[treeName];
      const game = useSelector(selectGames)[treeName];
      const rootPosition = useSelector(selectRootPositions)[treeName];
    
      const wantSeek = players.some((player) => player?.type === REMOTE_PLAYER && player.bySeek);
      const wantInvite = players.some((player) => player?.type === REMOTE_PLAYER && player.byInvite);
    
      const [disabled, setDisabled] = useState(false);
      const [storedTimeControlFlag, setStoredTimeControlFlag] = useState(defaultTimeControlFlag || props.remote);
      const inputTimeControlFlag =
        storedTimeControlFlag !== undefined ? storedTimeControlFlag : ORIGINAL_DEFAULT_TIME_CONTROL_FLAG;
      const [storedFastestTimeControls, setStoredFastestTimeControls] = useState(defaultFastestTimeControls);
      const inputFastestTimeControls =
        storedFastestTimeControls !== undefined ? storedFastestTimeControls : ORIGINAL_DEFAULT_FASTEST_TIME_CONTROLS;
      const [storedSlowestTimeControls, setStoredSlowestTimeControls] = useState(defaultSlowestTimeControls);
      const inputSlowestTimeControls =
        storedSlowestTimeControls !== undefined ? storedSlowestTimeControls : ORIGINAL_DEFAULT_SLOWEST_TIME_CONTROLS;
      const [colorless, setColorless] = useState(true);
    
      const dispatch = useDispatch();
      const navigate = useNavigate();
    
      useEffect(() => {
        if (defaultTimeControlFlag === undefined) {
          dispatch(setDefaultTimeControlFlag({
            slot: props.slot,
            timeControlFlag: ORIGINAL_DEFAULT_TIME_CONTROL_FLAG,
          }));
        }
        if (storedTimeControlFlag === undefined) {
          setStoredTimeControlFlag(inputTimeControlFlag);
        }
      }, [props.slot, defaultTimeControlFlag, storedTimeControlFlag, inputTimeControlFlag, dispatch]);
      useEffect(() => {
        if (defaultFastestTimeControls === undefined) {
          dispatch(setDefaultFastestTimeControls({
            slot: props.slot,
            timeControls: ORIGINAL_DEFAULT_FASTEST_TIME_CONTROLS,
          }));
        }
        if (storedFastestTimeControls === undefined) {
          setStoredFastestTimeControls(inputFastestTimeControls);
        }
      }, [props.slot, defaultFastestTimeControls, storedFastestTimeControls, inputFastestTimeControls, dispatch]);
      useEffect(() => {
        if (defaultSlowestTimeControls === undefined) {
          dispatch(setDefaultSlowestTimeControls({
            slot: props.slot,
            timeControls: ORIGINAL_DEFAULT_SLOWEST_TIME_CONTROLS,
          }));
        }
        if (storedSlowestTimeControls === undefined) {
          setStoredSlowestTimeControls(inputSlowestTimeControls);
        }
      }, [props.slot, defaultSlowestTimeControls, storedSlowestTimeControls, inputSlowestTimeControls, dispatch]);
      useEffect(() => {
        const expectedTimeControls = inputTimeControlFlag ? inputSlowestTimeControls : Infinity;
        if (timeControls !== expectedTimeControls) {
          dispatch(setTimeControls({
            treeName,
            timeControls: expectedTimeControls,
          }));
        }
      });
      useEffect(() => {
        if (game !== undefined && (players === undefined || players.includes(null))) {
          if (defaultPlayers === undefined) {
            dispatch(setDefaultPlayers({
              slot: props.slot,
              players: getOriginalDefaultPlayers(game, props.remote),
            }));
          } else {
            dispatch(setPlayers({
              treeName,
              players: defaultPlayers,
            }));
          }
        }
      });
    
      const onToggleTimeControls = (toggleState) => {
        setStoredTimeControlFlag(toggleState);
        dispatch(setTimeControls({
          treeName,
          timeControls: toggleState ? inputSlowestTimeControls : Infinity,
        }));
      };
      const onChangeFastestTimeControls = (event) => {
        const newTimeControls = Math.max(event.target.value, 1) * SECONDS_PER_MINUTE;
        setStoredFastestTimeControls(newTimeControls);
        setStoredSlowestTimeControls(Math.max(inputSlowestTimeControls, newTimeControls));
      };
      const onChangeSlowestTimeControls = (event) => {
        const newTimeControls = Math.max(event.target.value, 1) * SECONDS_PER_MINUTE;
        setStoredFastestTimeControls(Math.min(inputFastestTimeControls, newTimeControls));
        setStoredSlowestTimeControls(newTimeControls);
        if (inputTimeControlFlag) {
          dispatch(setTimeControls({
            treeName,
            timeControls: newTimeControls,
          }));
        }
      };
      const onToggleCompensation = (toggleState) => {
        dispatch(setCompensation({
          treeName,
          compensated: toggleState,
        }));
      };
      const onToggleColorless = (toggleState) => {
        setColorless(toggleState);
      };
    
      const onBack = () => {
        dispatch(deleteGame({
          treeName,
        }));
        navigate(-1);
      };
      const onForward = () => {
        dispatch(setDefaultTimeControlFlag({
          slot: props.slot,
          timeControlFlag: inputTimeControlFlag,
        }));
        dispatch(setDefaultFastestTimeControls({
          slot: props.slot,
          timeControls: inputFastestTimeControls,
        }));
        dispatch(setDefaultSlowestTimeControls({
          slot: props.slot,
          timeControls: inputSlowestTimeControls,
        }));
        dispatch(setDefaultPlayers({
          slot: props.slot,
          players,
        }));
        if (props.remote) {
          if (wantSeek) {
            seek(
              inputFastestTimeControls,
              inputSlowestTimeControls,
              colorless ? undefined : players.findIndex((player) => player.type === REMOTE_PLAYER),
            ).catch(() => {});
          } else if (wantInvite) {
            const setup = game.deserializePosition(rootPosition.serialization);
            const dragons = [];
            for (let x = 0; x < game.boardWidth; ++x) {
              for (let y = 0; y < game.boardHeight; ++y) {
                if (setup.getColorAndPieceType(x, y)[1] === game.dragon) {
                  dragons.push(game.prettifyPoint(x, y));
                }
              }
            }
            setDisabled(true);
            invite(dragons, players, inputSlowestTimeControls).catch(() => setDisabled(false));
          } else {
            console.assert(false, 'Tried to start a network game without using `seek` or `invite`.');
          }
        } else {
          dispatch(rotateBoardFor({
            treeName,
            playerType: HUMAN_PLAYER,
          }));
          navigate(props.to);
        }
      };
      const onUnseek = () => {
        unseek().catch(() => {});
      };
    
      const onRemoteGameReady = (event) => {
        dispatch(deleteGame({
          treeName,
        }));
        const newTreeName = `${props.slot}/${event.gameDescription.networkCode}`;
        dispatch(synchronizeNetworkGame({
          treeName: newTreeName,
          ...event.gameDescription,
        }));
        dispatch(loadTree({
          slot: props.slot,
          treeName: newTreeName,
        }));
        navigate(props.to);
      };
      useOnSeekReady(onRemoteGameReady);
      useOnInvitationMade(onRemoteGameReady);
    
      if (props.remote && clientIdentity === undefined) {
        return (
          <Navigate to={props.backTo} />
        );
      }
    
      if (seeking) {
        return (
          <Form>
            <p className={styles.message}>
              <strong>
                <img src={thinkingIcon} alt="" />&nbsp;Seeking a network opponent…
              </strong>
            </p>
            <p className={styles.message}>
              If the game does begin not soon, then there might not be anyone currently looking for a game matching the
              settings you chose.  In that case, you can cancel the seek and try again with different settings.
            </p>
            <ButtonBar>
              <Button text={'Cancel'} onClick={onUnseek} />
            </ButtonBar>
          </Form>
        );
      }
    
      const playerButtons = players.map(
        (player, playerIndex) => player !== null ?
          <PlayerButton
            key={playerIndex}
            slot={props.slot}
            playerIndex={playerIndex}
            remote={props.remote}
            disabled={disabled} /> :
          null,
      );
    
      return (
        <>
          <div className={styles.rules}>
            <Toggle
              on={inputTimeControlFlag}
              onChange={onToggleTimeControls}
              disabled={disabled || props.remote}>
              Use Time Controls:{' '}
              { wantSeek ?
                <>
                  <input
                    type="number"
                    min={1}
                    value={Math.floor(inputFastestTimeControls / SECONDS_PER_MINUTE)}
                    onChange={onChangeFastestTimeControls}
                    disabled={!inputTimeControlFlag} />–
                </> : null }
              <input
                type="number"
                min={1}
                value={Math.floor(inputSlowestTimeControls / SECONDS_PER_MINUTE)}
                onChange={onChangeSlowestTimeControls}
                disabled={disabled || !inputTimeControlFlag} />
              {' '}Minute(s) per Side
              { props.remote ? null : ' (Not Counting Animations)' }
            </Toggle>
            <Toggle
              on={compensated || props.remote}
              onChange={onToggleCompensation}
              disabled={disabled || props.remote}>
              Adjust Central Dragon to Compensate for First-Player Advantage
            </Toggle>
            { props.remote ?
              <Toggle
                on={wantSeek && colorless}
                onChange={onToggleColorless}
                disabled={disabled || !wantSeek}>
                Allow Automatic Pairing to Rearrange Players
              </Toggle> : null }
          </div>
          <div className={styles.players}>
            <Menu>
              {playerButtons}
            </Menu>
          </div>
          <ButtonBar>
            <Button image={backIcon} altText={'Back'} onClick={onBack} />
            <Button image={forwardIcon} altText={'Begin Game'} disabled={disabled} onClick={onForward} />
          </ButtonBar>
        </>
      );
    }
    
    SettingsMenuControls.propTypes = {
      slot: PropTypes.string.isRequired,
      remote: PropTypes.bool.isRequired,
      backTo: PropTypes.string.isRequired,
      to: PropTypes.string.isRequired,
    };
    
    export function SettingsMenu(props) {
      useEffect(() => () => unseek().catch(() => {}));
      return (
        <SettingsMenuControls slot={props.slot} remote={props.remote} backTo={props.backTo} to={props.to} />
      );
    }
    
    SettingsMenu.propTypes = {
      slot: PropTypes.string.isRequired,
      remote: PropTypes.bool.isRequired,
      backTo: PropTypes.string.isRequired,
      to: PropTypes.string.isRequired,
    };