feat(frontend): show hints in games against the bot
With hints on, the player's pits show whether their move gives an extra turn or how many stones it takes, and a line under the board says what the last move did and why. A Hints switch appears only in games against the bot.
This commit is contained in:
parent
d444447425
commit
f8ce8f215f
@ -165,6 +165,28 @@
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hintchip {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
top: -10px;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
max-width: 120%;
|
||||||
|
padding: 2px 8px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--surface2);
|
||||||
|
color: var(--accent);
|
||||||
|
box-shadow: inset 0 0 0 1.5px var(--accent);
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 16px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
z-index: 3;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
@container (max-width: 600px) {
|
@container (max-width: 600px) {
|
||||||
.board {
|
.board {
|
||||||
--pw: min(170px, calc((100cqw - 70px) / 2));
|
--pw: min(170px, calc((100cqw - 70px) / 2));
|
||||||
|
|||||||
@ -27,7 +27,9 @@ const BoardView: FunctionComponent<{
|
|||||||
lastMoveIndex?: number;
|
lastMoveIndex?: number;
|
||||||
// Board index where the shown move's last stone landed.
|
// Board index where the shown move's last stone landed.
|
||||||
landIndex?: number;
|
landIndex?: number;
|
||||||
}> = ({ game, context, boardViewModel, revert, topName, bottomName, onPitSelect, canPlay, lastMoveIndex, landIndex }) => {
|
// What the bottom player's moves would do, by board index (hints in games against the bot).
|
||||||
|
hints?: Record<number, string>;
|
||||||
|
}> = ({ game, context, boardViewModel, revert, topName, bottomName, onPitSelect, canPlay, lastMoveIndex, landIndex, hints }) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [previewIndex, setPreviewIndex] = useState<number | undefined>(undefined);
|
const [previewIndex, setPreviewIndex] = useState<number | undefined>(undefined);
|
||||||
const board = game.mancalaGame.board;
|
const board = game.mancalaGame.board;
|
||||||
@ -45,6 +47,7 @@ const BoardView: FunctionComponent<{
|
|||||||
preview: preview[index],
|
preview: preview[index],
|
||||||
land: previewLand !== undefined ? index === previewLand : index === landIndex,
|
land: previewLand !== undefined ? index === previewLand : index === landIndex,
|
||||||
last: index === lastMoveIndex,
|
last: index === lastMoveIndex,
|
||||||
|
hint: hints?.[index],
|
||||||
});
|
});
|
||||||
|
|
||||||
const topPits = revert ? board.player1Pits : board.player2Pits;
|
const topPits = revert ? board.player1Pits : board.player2Pits;
|
||||||
|
|||||||
@ -11,6 +11,8 @@ export type PitMarks = {
|
|||||||
land?: boolean;
|
land?: boolean;
|
||||||
// The last move was played from here.
|
// The last move was played from here.
|
||||||
last?: boolean;
|
last?: boolean;
|
||||||
|
// What playing this pit would do, in games against the bot with hints on, e.g. "Extra turn".
|
||||||
|
hint?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getMarkClasses(marks: PitMarks): string {
|
export function getMarkClasses(marks: PitMarks): string {
|
||||||
@ -47,6 +49,7 @@ const PitView: FunctionComponent<{
|
|||||||
<Stones count={stoneCount} seed={index * 5.3 + 1} themeId={themeId} />
|
<Stones count={stoneCount} seed={index * 5.3 + 1} themeId={themeId} />
|
||||||
<span className={top ? "badge t" : "badge b"}>{stoneCount}</span>
|
<span className={top ? "badge t" : "badge b"}>{stoneCount}</span>
|
||||||
{!!marks.preview && <span className="pvchip">{`+${marks.preview}`}</span>}
|
{!!marks.preview && <span className="pvchip">{`+${marks.preview}`}</span>}
|
||||||
|
{!!marks.hint && <span className="hintchip">{marks.hint}</span>}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
if (!onClick) {
|
if (!onClick) {
|
||||||
|
|||||||
@ -9,7 +9,7 @@ import LoadingComponent from '../components/LoadingComponent';
|
|||||||
import PageContainer from '../components/PageContainer';
|
import PageContainer from '../components/PageContainer';
|
||||||
import AppHeader from '../components/headerbar/AppHeader';
|
import AppHeader from '../components/headerbar/AppHeader';
|
||||||
import { LeaveIcon, PlusIcon } from '../components/Icons';
|
import { LeaveIcon, PlusIcon } from '../components/Icons';
|
||||||
import { GameRecord, channel_on_game_update, channel_on_game_crashed, channel_on_game_end, channel_on_game_user_leave, channel_on_user_connection_change, channel_leave_game, channel_game_move, channel_listen_game_events, channel_unlisten_game_events, FinishedGameSummary, GameEndMessage, getBoardAfterMove, getSeat, PlayedMove } from '@mancala/core';
|
import { GameRecord, channel_on_game_update, channel_on_game_crashed, channel_on_game_end, channel_on_game_user_leave, channel_on_user_connection_change, channel_leave_game, channel_game_move, channel_listen_game_events, channel_unlisten_game_events, FinishedGameSummary, GameEndMessage, getBoardAfterMove, getSeat, PlayedMove, getHintsEnabled, setHintsEnabled, getMoveOutcome, explainMove } from '@mancala/core';
|
||||||
import { Context } from '../context/context';
|
import { Context } from '../context/context';
|
||||||
import { GameMove, LoadingState, Game, GameUsersConnectionInfo } from "@mancala/core";
|
import { GameMove, LoadingState, Game, GameUsersConnectionInfo } from "@mancala/core";
|
||||||
import { Theme, getBotLevel, getPlayerDisplayName } from '@mancala/core';
|
import { Theme, getBotLevel, getPlayerDisplayName } from '@mancala/core';
|
||||||
@ -64,6 +64,8 @@ const GamePage: FunctionComponent<{
|
|||||||
const [gameEnd, setGameEnd] = useState<GameEndMessage | undefined>(undefined);
|
const [gameEnd, setGameEnd] = useState<GameEndMessage | undefined>(undefined);
|
||||||
// The rules over the game; the game goes on behind them.
|
// The rules over the game; the game goes on behind them.
|
||||||
const [rulesOpen, setRulesOpen] = useState(false);
|
const [rulesOpen, setRulesOpen] = useState(false);
|
||||||
|
// Hints in games against the bot (docs/specs/tutorial, R3); remembered on the device.
|
||||||
|
const [hintsEnabled, setHintsEnabledState] = useState(() => getHintsEnabled(context.storage));
|
||||||
|
|
||||||
// Set when the player closes the game over card to look at the board.
|
// Set when the player closes the game over card to look at the board.
|
||||||
const [gameOverClosed, setGameOverClosed] = useState<boolean>(false);
|
const [gameOverClosed, setGameOverClosed] = useState<boolean>(false);
|
||||||
@ -278,6 +280,34 @@ const GamePage: FunctionComponent<{
|
|||||||
|
|
||||||
const moves = (mancalaGame?.history ?? []) as unknown as PlayedMove[];
|
const moves = (mancalaGame?.history ?? []) as unknown as PlayedMove[];
|
||||||
const isReviewing = viewedMove !== undefined && viewedMove < moves.length;
|
const isReviewing = viewedMove !== undefined && viewedMove < moves.length;
|
||||||
|
|
||||||
|
// Hints are only for games against the bot, never between people (R3.5).
|
||||||
|
const hintsOn = !!opponentBotLevel && hintsEnabled;
|
||||||
|
const toggleHints = () => {
|
||||||
|
setHintsEnabled(context.storage, !hintsEnabled);
|
||||||
|
setHintsEnabledState(!hintsEnabled);
|
||||||
|
};
|
||||||
|
const getHints = (): Record<number, string> | undefined => {
|
||||||
|
if (!hintsOn || !mancalaGame || isReviewing || hasOngoingAction) return undefined;
|
||||||
|
if (mancalaGame.turnPlayerId !== userKey || mancalaGame.state === "ended") return undefined;
|
||||||
|
const stones = mancalaGame.board.getStoneArray();
|
||||||
|
const first = isPlayer2 ? 7 : 0;
|
||||||
|
const hints: Record<number, string> = {};
|
||||||
|
for (let index = first; index < first + 6; index++) {
|
||||||
|
if (!stones[index]) continue;
|
||||||
|
const outcome = getMoveOutcome(stones, index);
|
||||||
|
if (outcome.capture) hints[index] = t("HintCaptures", { count: outcome.captured });
|
||||||
|
else if (outcome.extraTurn) hints[index] = t("HintExtraTurn");
|
||||||
|
}
|
||||||
|
return hints;
|
||||||
|
};
|
||||||
|
// What the last move did and why, under the board (R3.3).
|
||||||
|
const getLastMoveExplanation = (): string | undefined => {
|
||||||
|
if (!hintsOn || moves.length === 0 || isReviewing) return undefined;
|
||||||
|
const last = moves[moves.length - 1];
|
||||||
|
const outcome = getMoveOutcome(getBoardAfterMove(moves, moves.length - 1), last.moveIndex);
|
||||||
|
return explainMove(outcome, last.playerId === userKey ? "you" : "bot", t);
|
||||||
|
};
|
||||||
const shownBoardViewModel = isReviewing && mancalaGame
|
const shownBoardViewModel = isReviewing && mancalaGame
|
||||||
? createBoardViewModel(getBoardAfterMove(moves, viewedMove!), context.themeManager.theme)
|
? createBoardViewModel(getBoardAfterMove(moves, viewedMove!), context.themeManager.theme)
|
||||||
: boardViewModel;
|
: boardViewModel;
|
||||||
@ -351,6 +381,7 @@ const GamePage: FunctionComponent<{
|
|||||||
|
|
||||||
const renderGame = () => {
|
const renderGame = () => {
|
||||||
const board = mancalaGame!.board;
|
const board = mancalaGame!.board;
|
||||||
|
const lastMoveExplanation = getLastMoveExplanation();
|
||||||
const [topStore, bottomStore] = isPlayer2
|
const [topStore, bottomStore] = isPlayer2
|
||||||
? [board.player1BankIndex(), board.player2BankIndex()]
|
? [board.player1BankIndex(), board.player2BankIndex()]
|
||||||
: [board.player2BankIndex(), board.player1BankIndex()];
|
: [board.player2BankIndex(), board.player1BankIndex()];
|
||||||
@ -373,6 +404,11 @@ const GamePage: FunctionComponent<{
|
|||||||
{!isGameOver && <span className="dot" />}
|
{!isGameOver && <span className="dot" />}
|
||||||
{getTurnText()}
|
{getTurnText()}
|
||||||
</div>
|
</div>
|
||||||
|
{opponentBotLevel && (
|
||||||
|
<button className={"btn hints-toggle " + (hintsOn ? "pri" : "ghost")} aria-pressed={hintsOn} onClick={toggleHints}>
|
||||||
|
{t("Hints")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="a-me">{playerCard(bottomLocatedUser, bottomStore, isSpectator ? "" : t("You"), true)}</div>
|
<div className="a-me">{playerCard(bottomLocatedUser, bottomStore, isSpectator ? "" : t("You"), true)}</div>
|
||||||
<div className="a-board">
|
<div className="a-board">
|
||||||
@ -391,7 +427,9 @@ const GamePage: FunctionComponent<{
|
|||||||
lastMoveIndex={moves[(isReviewing ? viewedMove! : moves.length) - 1]?.moveIndex}
|
lastMoveIndex={moves[(isReviewing ? viewedMove! : moves.length) - 1]?.moveIndex}
|
||||||
topName={topLocatedUser.name}
|
topName={topLocatedUser.name}
|
||||||
bottomName={bottomLocatedUser.name}
|
bottomName={bottomLocatedUser.name}
|
||||||
revert={isPlayer2} />
|
revert={isPlayer2}
|
||||||
|
hints={getHints()} />
|
||||||
|
{lastMoveExplanation && <p className="hint-line" aria-live="polite">{lastMoveExplanation}</p>}
|
||||||
{isGameOver && !gameOverClosed && (
|
{isGameOver && !gameOverClosed && (
|
||||||
<GameOverCard
|
<GameOverCard
|
||||||
title={resultTitle ?? t("GameEnded")}
|
title={resultTitle ?? t("GameEnded")}
|
||||||
|
|||||||
@ -10,7 +10,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.a-opp { grid-area: opp; min-width: 0; }
|
.a-opp { grid-area: opp; min-width: 0; }
|
||||||
.a-turn { grid-area: turn; min-width: 0; }
|
.a-turn { grid-area: turn; min-width: 0; display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: 8px; }
|
||||||
.a-me { grid-area: me; justify-self: end; min-width: 0; }
|
.a-me { grid-area: me; justify-self: end; min-width: 0; }
|
||||||
.a-board { grid-area: board; position: relative; min-width: 0; }
|
.a-board { grid-area: board; position: relative; min-width: 0; }
|
||||||
.a-moves { grid-area: moves; width: 100%; max-width: 760px; justify-self: center; display: flex; flex-direction: column; gap: 16px; }
|
.a-moves { grid-area: moves; width: 100%; max-width: 760px; justify-self: center; display: flex; flex-direction: column; gap: 16px; }
|
||||||
@ -100,6 +100,11 @@
|
|||||||
|
|
||||||
.turn.mine { background: var(--accent); color: var(--onAccent); box-shadow: none; }
|
.turn.mine { background: var(--accent); color: var(--onAccent); box-shadow: none; }
|
||||||
|
|
||||||
|
/* Hints in games against the bot */
|
||||||
|
|
||||||
|
.hints-toggle { min-height: 36px; padding: 0 14px; font-size: 14px; }
|
||||||
|
.hint-line { margin: 14px 0 0; text-align: center; color: var(--muted); font-weight: 600; line-height: 1.45; }
|
||||||
|
|
||||||
/* Review banner, above the board */
|
/* Review banner, above the board */
|
||||||
|
|
||||||
.review {
|
.review {
|
||||||
|
|||||||
@ -123,8 +123,9 @@ Components:
|
|||||||
`explainMove` and "Try again", which resets the board.
|
`explainMove` and "Try again", which resets the board.
|
||||||
- `TutorialBanner` on the home page for newcomers (R4).
|
- `TutorialBanner` on the home page for newcomers (R4).
|
||||||
- Hints (R3): in `GamePage`, when the opponent is a bot and hints are on, `BoardView` gets a `hints` map
|
- Hints (R3): in `GamePage`, when the opponent is a bot and hints are on, `BoardView` gets a `hints` map
|
||||||
(pit → "Extra turn" / "Captures 5"), drawn as a chip on the pit like the move preview; below the board a line with
|
(pit → "Extra turn" / "Takes 5"), drawn as a chip above the pit; pits whose move does neither get no chip. Below
|
||||||
`explainMove` of the last move. A "Hints" switch in the game page's toolbar, only in bot games.
|
the board a line with `explainMove` of the last move. A "Hints" switch next to the turn indicator, only in bot
|
||||||
|
games; hidden while looking back at earlier moves.
|
||||||
- Practice game: the lesson list's last button sets hints on and goes to `/loby?bot=easy`.
|
- Practice game: the lesson list's last button sets hints on and goes to `/loby?bot=easy`.
|
||||||
|
|
||||||
## Mobile app
|
## Mobile app
|
||||||
|
|||||||
@ -10,7 +10,7 @@ Work through the tasks in order; each ends with its tests passing and a commit.
|
|||||||
- [x] 4. **Lessons**: `/learn` with progress, `LessonPage` with the local game, animation, praise, explanation and
|
- [x] 4. **Lessons**: `/learn` with progress, `LessonPage` with the local game, animation, praise, explanation and
|
||||||
reset, scripted opponent replies.
|
reset, scripted opponent replies.
|
||||||
- [x] 5. **Newcomers and practice game**: `TutorialBanner`, `markGamePlayed`, the practice game button.
|
- [x] 5. **Newcomers and practice game**: `TutorialBanner`, `markGamePlayed`, the practice game button.
|
||||||
- [ ] 6. **Hints**: hint chips on `BoardView`, the explanation line and the "Hints" switch in bot games on `GamePage`.
|
- [x] 6. **Hints**: hint chips on `BoardView`, the explanation line and the "Hints" switch in bot games on `GamePage`.
|
||||||
- [ ] 7. **Mobile rules**: `RulesScreen`, links from the help and game screens; type check and bundle.
|
- [ ] 7. **Mobile rules**: `RulesScreen`, links from the help and game screens; type check and bundle.
|
||||||
- [ ] 8. **End to end and docs**: the Chrome tests from `design.md`; `AGENTS.md` (specs list), `TODO.md` (out of scope
|
- [ ] 8. **End to end and docs**: the Chrome tests from `design.md`; `AGENTS.md` (specs list), `TODO.md` (out of scope
|
||||||
items).
|
items).
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user