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;
|
||||
}
|
||||
|
||||
.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));
|
||||
|
||||
@ -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<number, string>;
|
||||
}> = ({ game, context, boardViewModel, revert, topName, bottomName, onPitSelect, canPlay, lastMoveIndex, landIndex, hints }) => {
|
||||
const { t } = useTranslation();
|
||||
const [previewIndex, setPreviewIndex] = useState<number | undefined>(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;
|
||||
|
||||
@ -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<{
|
||||
<Stones count={stoneCount} seed={index * 5.3 + 1} themeId={themeId} />
|
||||
<span className={top ? "badge t" : "badge b"}>{stoneCount}</span>
|
||||
{!!marks.preview && <span className="pvchip">{`+${marks.preview}`}</span>}
|
||||
{!!marks.hint && <span className="hintchip">{marks.hint}</span>}
|
||||
</>
|
||||
);
|
||||
if (!onClick) {
|
||||
|
||||
@ -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<GameEndMessage | undefined>(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<boolean>(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<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
|
||||
? 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 && <span className="dot" />}
|
||||
{getTurnText()}
|
||||
</div>
|
||||
{opponentBotLevel && (
|
||||
<button className={"btn hints-toggle " + (hintsOn ? "pri" : "ghost")} aria-pressed={hintsOn} onClick={toggleHints}>
|
||||
{t("Hints")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="a-me">{playerCard(bottomLocatedUser, bottomStore, isSpectator ? "" : t("You"), true)}</div>
|
||||
<div className="a-board">
|
||||
@ -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 && <p className="hint-line" aria-live="polite">{lastMoveExplanation}</p>}
|
||||
{isGameOver && !gameOverClosed && (
|
||||
<GameOverCard
|
||||
title={resultTitle ?? t("GameEnded")}
|
||||
|
||||
@ -10,7 +10,7 @@
|
||||
}
|
||||
|
||||
.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-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; }
|
||||
@ -100,6 +100,11 @@
|
||||
|
||||
.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 {
|
||||
|
||||
@ -123,8 +123,9 @@ Components:
|
||||
`explainMove` and "Try again", which resets the board.
|
||||
- `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
|
||||
(pit → "Extra turn" / "Captures 5"), drawn as a chip on the pit like the move preview; below the board a line with
|
||||
`explainMove` of the last move. A "Hints" switch in the game page's toolbar, only in bot games.
|
||||
(pit → "Extra turn" / "Takes 5"), drawn as a chip above the pit; pits whose move does neither get no chip. Below
|
||||
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`.
|
||||
|
||||
## 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
|
||||
reset, scripted opponent replies.
|
||||
- [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.
|
||||
- [ ] 8. **End to end and docs**: the Chrome tests from `design.md`; `AGENTS.md` (specs list), `TODO.md` (out of scope
|
||||
items).
|
||||
|
||||
Loading…
Reference in New Issue
Block a user