Select Git revision
settingsMenu.js
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="" /> 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,
};