From f8ce8f215f299b26a31775f0f7500a06b5ba2867 Mon Sep 17 00:00:00 2001 From: Halit Aksoy Date: Fri, 16 Oct 2026 14:58:56 +0300 Subject: [PATCH] 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. --- .../src/components/board/BoardView.css | 22 ++++++++++ .../src/components/board/BoardView.tsx | 5 ++- .../frontend/src/components/board/PitView.tsx | 3 ++ apps/frontend/src/routes/GamePage.tsx | 42 ++++++++++++++++++- apps/frontend/src/styles/game.css | 7 +++- docs/specs/tutorial/design.md | 5 ++- docs/specs/tutorial/tasks.md | 2 +- 7 files changed, 79 insertions(+), 7 deletions(-) diff --git a/apps/frontend/src/components/board/BoardView.css b/apps/frontend/src/components/board/BoardView.css index 246ad18..e72f0d9 100644 --- a/apps/frontend/src/components/board/BoardView.css +++ b/apps/frontend/src/components/board/BoardView.css @@ -165,6 +165,28 @@ 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) { .board { --pw: min(170px, calc((100cqw - 70px) / 2)); diff --git a/apps/frontend/src/components/board/BoardView.tsx b/apps/frontend/src/components/board/BoardView.tsx index 172f965..d88bd51 100644 --- a/apps/frontend/src/components/board/BoardView.tsx +++ b/apps/frontend/src/components/board/BoardView.tsx @@ -27,7 +27,9 @@ const BoardView: FunctionComponent<{ lastMoveIndex?: number; // Board index where the shown move's last stone landed. 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; +}> = ({ game, context, boardViewModel, revert, topName, bottomName, onPitSelect, canPlay, lastMoveIndex, landIndex, hints }) => { const { t } = useTranslation(); const [previewIndex, setPreviewIndex] = useState(undefined); const board = game.mancalaGame.board; @@ -45,6 +47,7 @@ const BoardView: FunctionComponent<{ preview: preview[index], land: previewLand !== undefined ? index === previewLand : index === landIndex, last: index === lastMoveIndex, + hint: hints?.[index], }); const topPits = revert ? board.player1Pits : board.player2Pits; diff --git a/apps/frontend/src/components/board/PitView.tsx b/apps/frontend/src/components/board/PitView.tsx index a55d968..4a389d1 100644 --- a/apps/frontend/src/components/board/PitView.tsx +++ b/apps/frontend/src/components/board/PitView.tsx @@ -11,6 +11,8 @@ export type PitMarks = { land?: boolean; // The last move was played from here. 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 { @@ -47,6 +49,7 @@ const PitView: FunctionComponent<{ {stoneCount} {!!marks.preview && {`+${marks.preview}`}} + {!!marks.hint && {marks.hint}} ); if (!onClick) { diff --git a/apps/frontend/src/routes/GamePage.tsx b/apps/frontend/src/routes/GamePage.tsx index cfd8a47..fbe538a 100644 --- a/apps/frontend/src/routes/GamePage.tsx +++ b/apps/frontend/src/routes/GamePage.tsx @@ -9,7 +9,7 @@ import LoadingComponent from '../components/LoadingComponent'; import PageContainer from '../components/PageContainer'; import AppHeader from '../components/headerbar/AppHeader'; 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 { GameMove, LoadingState, Game, GameUsersConnectionInfo } from "@mancala/core"; import { Theme, getBotLevel, getPlayerDisplayName } from '@mancala/core'; @@ -64,6 +64,8 @@ const GamePage: FunctionComponent<{ const [gameEnd, setGameEnd] = useState(undefined); // The rules over the game; the game goes on behind them. 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. const [gameOverClosed, setGameOverClosed] = useState(false); @@ -278,6 +280,34 @@ const GamePage: FunctionComponent<{ const moves = (mancalaGame?.history ?? []) as unknown as PlayedMove[]; 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 | 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 = {}; + 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 ? createBoardViewModel(getBoardAfterMove(moves, viewedMove!), context.themeManager.theme) : boardViewModel; @@ -351,6 +381,7 @@ const GamePage: FunctionComponent<{ const renderGame = () => { const board = mancalaGame!.board; + const lastMoveExplanation = getLastMoveExplanation(); const [topStore, bottomStore] = isPlayer2 ? [board.player1BankIndex(), board.player2BankIndex()] : [board.player2BankIndex(), board.player1BankIndex()]; @@ -373,6 +404,11 @@ const GamePage: FunctionComponent<{ {!isGameOver && } {getTurnText()} + {opponentBotLevel && ( + + )}
{playerCard(bottomLocatedUser, bottomStore, isSpectator ? "" : t("You"), true)}
@@ -391,7 +427,9 @@ const GamePage: FunctionComponent<{ lastMoveIndex={moves[(isReviewing ? viewedMove! : moves.length) - 1]?.moveIndex} topName={topLocatedUser.name} bottomName={bottomLocatedUser.name} - revert={isPlayer2} /> + revert={isPlayer2} + hints={getHints()} /> + {lastMoveExplanation &&

{lastMoveExplanation}

} {isGameOver && !gameOverClosed && (