Skip to content

Commit a67bc4c

Browse files
committed
Add configurable animation speed setting
1 parent c1d10d5 commit a67bc4c

11 files changed

Lines changed: 280 additions & 71 deletions

File tree

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@ Kid-friendly chess PWA built with React 19 + TypeScript + Vite + vite-plugin-pwa
44

55
## Features (v0.1)
66
- Two-player pass-and-play with auto-flip
7-
- Play the bot with 10 difficulty levels (built-in minimax for 1–5, Stockfish WASM hook for 6–10)
7+
- Play the bot with 20 difficulty levels in standard chess (built-in minimax for 1-4, external Stockfish API for 5-20 with fallback)
8+
- Portal Chess bot remains local and supports levels 1-10
89
- Per-move timer with configurable seconds (10/30/60/120/off). Timeout forfeits **that move only** — game continues.
910
- Tap a piece to see where it can go — legal squares highlighted, captures ringed in red
1011
- Full undo / takeback stack
@@ -28,7 +29,6 @@ npm test
2829
Pushed to `main` → GitHub Pages via `.github/workflows/deploy.yml`. Update `base` in `vite.config.ts` if the repo name changes.
2930

3031
## Roadmap
31-
- Wire Stockfish WASM into `src/engine/bot/stockfish.ts` for bot levels 6–10
3232
- Mate-in-1/2 puzzles + daily puzzle
3333
- Mascot coach with contextual commentary
3434
- Board themes & kid-friendly piece sets

src/GameContext.tsx

Lines changed: 38 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@ type Mode =
2424
| { kind: "portal"; opponent: "two-player" | { kind: "bot"; level: number }; creator: PieceType; adjacencyRule?: boolean; portalMax?: number };
2525
export interface Players { w: string; b: string; }
2626

27+
interface NewGameOptions {
28+
timerSeconds?: number;
29+
}
30+
2731
/** Build the initial state for Portal Chess (creator-type portals). */
2832
function portalInitialState(creator: PieceType, adjacencyRule = false, portalMax = 1): GameState {
2933
const s = initialState();
@@ -50,8 +54,10 @@ interface GameCtx {
5054

5155
select(sq: Square | null): void;
5256
tryMove(from: Square, to: Square, promotion?: "Q" | "R" | "B" | "N", portalTo?: Square): boolean;
57+
lastMoveReplayNonce: number;
58+
replayLastMove(): void;
5359
undo(): void;
54-
newGame(mode: Mode, players?: Partial<Players>): void;
60+
newGame(mode: Mode, players?: Partial<Players>, opts?: NewGameOptions): void;
5561
forfeit(): void;
5662

5763
// puzzles
@@ -139,6 +145,7 @@ export function GameProvider({ children }: { children: ReactNode }) {
139145
const [isBotThinking, setIsBotThinking] = useState(false);
140146
const [hint, setHint] = useState<Move | null>(null);
141147
const [paused, setPaused] = useState(false);
148+
const [lastMoveReplayNonce, setLastMoveReplayNonce] = useState(0);
142149
const stateRef = useRef(state);
143150
useEffect(() => { stateRef.current = state; }, [state]);
144151
// Track latest store for callbacks that need the freshest settings.
@@ -245,12 +252,19 @@ export function GameProvider({ children }: { children: ReactNode }) {
245252
try {
246253
// Compute the bot move and ensure enough time has elapsed so the
247254
// human's own animation has time to play before the board re-renders
248-
// with the bot's response. Teleport animation is 2.0s total
249-
// (demat + gap + remat). Slide animation is ~650ms.
255+
// with the bot's response. Delay scales with animation-speed setting
256+
// so replay/slow modes remain visually readable.
250257
const prevMove = state.history[state.history.length - 1];
251-
const minThinkMs = prevMove?.isPortalEntry ? 2100 : 750;
258+
const speed = store.settings.animationSpeed;
259+
const thinkDelays =
260+
speed === "very-slow"
261+
? { slide: 1650, teleport: 3800 }
262+
: speed === "slow"
263+
? { slide: 1200, teleport: 2900 }
264+
: { slide: 900, teleport: 2200 };
265+
const minThinkMs = prevMove?.isPortalEntry ? thinkDelays.teleport : thinkDelays.slide;
252266
const t0 = performance.now();
253-
const move = await chooseBotMove(state, lvl);
267+
const move = await chooseBotMove(state, lvl, { allowExternal: mode.kind === "bot" });
254268
const elapsed = performance.now() - t0;
255269
if (elapsed < minThinkMs) {
256270
await new Promise((r) => setTimeout(r, minThinkMs - elapsed));
@@ -267,7 +281,7 @@ export function GameProvider({ children }: { children: ReactNode }) {
267281
}
268282
})();
269283
return () => { cancelled = true; };
270-
}, [state, mode, result.kind, paused]);
284+
}, [state, mode, result.kind, paused, store.settings.animationSpeed]);
271285

272286
// Auto-record result when game ends
273287
const recordedRef = useRef<number>(-1);
@@ -347,16 +361,27 @@ export function GameProvider({ children }: { children: ReactNode }) {
347361
return true;
348362
}, [state, paused]);
349363

364+
const replayLastMove = useCallback(() => {
365+
if (state.history.length === 0) return;
366+
setLastMoveReplayNonce((n) => n + 1);
367+
}, [state.history.length]);
368+
350369
const undo = useCallback(() => {
351370
// Undo twice if playing a bot and it's the human's turn (to remove bot reply + own move).
352371
dispatch({ type: "undo" });
353372
if (botLevelOf(mode) !== null) dispatch({ type: "undo" });
354373
setSelected(null);
355374
}, [mode]);
356375

357-
const newGame = useCallback((m: Mode, p?: Partial<Players>) => {
376+
const newGame = useCallback((m: Mode, p?: Partial<Players>, opts?: NewGameOptions) => {
358377
clearActiveSession();
359378
noTimerRef.current = false;
379+
if (opts?.timerSeconds !== undefined && opts.timerSeconds !== storeRef.current.settings.timerSeconds) {
380+
const updated = cloneStore(storeRef.current);
381+
updateSettings(updated, { timerSeconds: opts.timerSeconds });
382+
setStore(updated);
383+
storeRef.current = updated;
384+
}
360385
setMode(m);
361386
const defaultW = activeProfile?.name ?? "White";
362387
const defaultB =
@@ -370,11 +395,12 @@ export function GameProvider({ children }: { children: ReactNode }) {
370395
dispatch({ type: "new", initial: fresh });
371396
setSelected(null);
372397
setPaused(false);
398+
setLastMoveReplayNonce(0);
373399
// Force the clock to use the freshest timer setting — the caller often
374400
// updates settings immediately before calling newGame (e.g. the New Game
375401
// screen), so reading storeRef gives us the value they just picked
376402
// instead of the stale closure-captured one.
377-
const t = storeRef.current.settings.timerSeconds;
403+
const t = opts?.timerSeconds ?? storeRef.current.settings.timerSeconds;
378404
setTimeLeft(t > 0 ? t : Infinity);
379405
}, [activeProfile]);
380406

@@ -389,6 +415,7 @@ export function GameProvider({ children }: { children: ReactNode }) {
389415
setSelected(null);
390416
setHint(null);
391417
setPaused(false);
418+
setLastMoveReplayNonce(0);
392419
if (opts?.noTimer) setTimeLeft(Infinity);
393420
}, []);
394421

@@ -398,10 +425,10 @@ export function GameProvider({ children }: { children: ReactNode }) {
398425

399426
const requestHint = useCallback(() => {
400427
(async () => {
401-
const best = await chooseBotMove(state, 5);
428+
const best = await chooseBotMove(state, 5, { allowExternal: mode.kind === "bot" });
402429
if (best) setHint(best);
403430
})();
404-
}, [state]);
431+
}, [state, mode.kind]);
405432
const clearHint = useCallback(() => setHint(null), []);
406433

407434
const setActiveProfile = useCallback((id: string | null) => {
@@ -458,7 +485,7 @@ export function GameProvider({ children }: { children: ReactNode }) {
458485
const value: GameCtx = {
459486
store, activeProfile, state, mode, players, selected, legalFromSelected, timeLeft, isBotThinking, result,
460487
paused, togglePause,
461-
select, tryMove, undo, newGame, forfeit,
488+
select, tryMove, lastMoveReplayNonce, replayLastMove, undo, newGame, forfeit,
462489
loadPosition,
463490
recordPuzzleSolved, recordPuzzleAttempt,
464491
hint, requestHint, clearHint,

src/components/Board.tsx

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,21 @@ interface PendingPromo {
1919
}
2020

2121
export function Board({ flipped = false }: Props) {
22-
const { state, selected, legalFromSelected, select, tryMove, result, store, hint } = useGame();
22+
const {
23+
state,
24+
selected,
25+
legalFromSelected,
26+
select,
27+
tryMove,
28+
result,
29+
store,
30+
mode,
31+
hint,
32+
lastMoveReplayNonce
33+
} = useGame();
2334
const theme = store.settings.theme;
2435
const pieceSet = store.settings.pieceSet;
36+
const animationSpeed = store.settings.animationSpeed;
2537
const [pending, setPending] = useState<PendingPromo | null>(null);
2638

2739
const ranks = flipped ? [0, 1, 2, 3, 4, 5, 6, 7] : [7, 6, 5, 4, 3, 2, 1, 0];
@@ -56,10 +68,11 @@ export function Board({ flipped = false }: Props) {
5668
const wPortals = state.portals?.w ?? [];
5769
const bPortals = state.portals?.b ?? [];
5870
const isTeleportMove = !!lastMove?.isPortalEntry;
71+
const rotateBlackForFixedBoard = mode.kind === "two-player" && !store.settings.autoFlip;
5972

6073
return (
6174
<>
62-
<div className={`board board-theme-${theme} piece-set-${pieceSet}`}>
75+
<div className={`board board-theme-${theme} piece-set-${pieceSet} anim-speed-${animationSpeed}`}>
6376
{ranks.map((r) => (
6477
<div key={r} className="board-row">
6578
{files.map((f) => {
@@ -109,7 +122,7 @@ export function Board({ flipped = false }: Props) {
109122
["--slide-dx" as string]: `${sign * df * 100}%`,
110123
["--slide-dy" as string]: `${sign * dr * 100}%`
111124
};
112-
slideKey = `slide-${moveIndex}`;
125+
slideKey = `slide-${moveIndex}-${lastMoveReplayNonce}`;
113126
}
114127
const isRematerializeHere =
115128
isTeleportMove &&
@@ -138,7 +151,7 @@ export function Board({ flipped = false }: Props) {
138151
)}
139152
{piece && (
140153
<span
141-
key={isRematerializeHere ? `remat-${moveIndex}` : slideKey}
154+
key={isRematerializeHere ? `remat-${moveIndex}-${lastMoveReplayNonce}` : slideKey}
142155
className={
143156
isRematerializeHere
144157
? "piece-wrap piece-rematerialize"
@@ -148,20 +161,30 @@ export function Board({ flipped = false }: Props) {
148161
}
149162
style={slideStyle}
150163
>
151-
<Piece color={piece.color} type={piece.type} set={pieceSet} />
164+
<Piece
165+
color={piece.color}
166+
type={piece.type}
167+
set={pieceSet}
168+
rotate={rotateBlackForFixedBoard && piece.color === "b"}
169+
/>
152170
</span>
153171
)}
154172
{isDematerializeHere && lastMove && (
155173
<span
156-
key={`demat-${moveIndex}`}
174+
key={`demat-${moveIndex}-${lastMoveReplayNonce}`}
157175
className="piece-wrap piece-dematerialize"
158176
aria-hidden="true"
159177
>
160-
<Piece color={lastMove.color} type={lastMove.piece} set={pieceSet} />
178+
<Piece
179+
color={lastMove.color}
180+
type={lastMove.piece}
181+
set={pieceSet}
182+
rotate={rotateBlackForFixedBoard && lastMove.color === "b"}
183+
/>
161184
</span>
162185
)}
163186
{isLastTo && lastMove?.captured && store.settings.explodeOnCapture && (
164-
<span key={`boom-${moveIndex}`} className="boom" aria-hidden="true">
187+
<span key={`boom-${moveIndex}-${lastMoveReplayNonce}`} className="boom" aria-hidden="true">
165188
<span className="boom-core">💥</span>
166189
<span className="boom-bit b1"></span>
167190
<span className="boom-bit b2"></span>

src/components/Controls.tsx

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,19 @@
11
import { useGame } from "../GameContext";
22

33
export function Controls() {
4-
const { undo, newGame, mode, result, state, requestHint, hint, clearHint, paused, togglePause } = useGame();
4+
const {
5+
undo,
6+
newGame,
7+
mode,
8+
result,
9+
state,
10+
requestHint,
11+
hint,
12+
clearHint,
13+
paused,
14+
togglePause,
15+
replayLastMove
16+
} = useGame();
517
const statusText =
618
result.kind === "ongoing"
719
? paused
@@ -37,6 +49,13 @@ export function Controls() {
3749
>
3850
{hint ? "Hide hint" : "💡 Hint"}
3951
</button>
52+
<button
53+
onClick={replayLastMove}
54+
disabled={state.history.length === 0}
55+
title="Replay the latest move animation"
56+
>
57+
↺ Replay
58+
</button>
4059
<button onClick={() => undo()} disabled={state.history.length === 0}>Undo</button>
4160
<button onClick={() => newGame(mode)}>New Game</button>
4261
</div>

src/components/Piece.tsx

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,26 +12,28 @@ interface Props {
1212
color: Color;
1313
type: PieceType;
1414
set: PieceSet;
15+
rotate?: boolean;
1516
}
1617

17-
export function Piece({ color, type, set }: Props) {
18+
export function Piece({ color, type, set, rotate = false }: Props) {
19+
const faceClass = rotate ? "piece-face-180" : "";
1820
if (set === "classic") {
1921
return (
20-
<span className={`piece-glyph piece-${color}`}>
22+
<span className={`piece-glyph piece-${color} ${faceClass}`}>
2123
{GLYPH[color + type]}
2224
</span>
2325
);
2426
}
2527
if (set === "emoji") {
2628
return (
27-
<span className={`piece-mc piece-${color}`}>
29+
<span className={`piece-mc piece-${color} ${faceClass}`}>
2830
<MinecraftPiece color={color} type={type} />
2931
</span>
3032
);
3133
}
3234
// modern + neon both use SVG; neon gets a filter via CSS on the parent.
3335
return (
34-
<span className={`piece-svg piece-${color}`}>
36+
<span className={`piece-svg piece-${color} ${faceClass}`}>
3537
<PieceSVG color={color} type={type} />
3638
</span>
3739
);

src/engine/bot.ts

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -64,21 +64,27 @@ function minimax(state: GameState, depth: number, alpha: number, beta: number, m
6464
}
6565
}
6666

67+
interface BotOptions {
68+
allowExternal?: boolean;
69+
}
70+
6771
/**
68-
* Choose a move for the bot at a given difficulty (1-10).
69-
* Levels 1-5 use the built-in minimax with increasing depth and decreasing randomness.
70-
* Levels 6-10 delegate to Stockfish WASM (lazy-loaded) — falls back to depth-4 minimax
71-
* if Stockfish isn't available (e.g. offline first-load).
72+
* Choose a move for the bot at a given difficulty (1-20).
73+
* Levels 1-4 use the built-in minimax with increasing depth and decreasing randomness.
74+
* Levels 5-20 can delegate to an external Stockfish API when enabled; if unavailable,
75+
* fall back to local minimax.
7276
*/
73-
export async function chooseBotMove(state: GameState, level: number): Promise<Move | null> {
77+
export async function chooseBotMove(state: GameState, level: number, opts?: BotOptions): Promise<Move | null> {
7478
const moves = allLegalMoves(state);
7579
if (moves.length === 0) return null;
7680
const me = state.turn;
81+
const normalizedLevel = Math.max(1, Math.min(20, Math.round(level)));
82+
const canUseExternal = opts?.allowExternal !== false && !state.portals;
7783

78-
if (level >= 6) {
84+
if (canUseExternal && normalizedLevel >= 5) {
7985
try {
8086
const { stockfishBestMove } = await import("./bot/stockfish");
81-
const best = await stockfishBestMove(state, level);
87+
const best = await stockfishBestMove(state, normalizedLevel);
8288
if (best) return best;
8389
} catch {
8490
// fall through to local minimax
@@ -90,15 +96,15 @@ export async function chooseBotMove(state: GameState, level: number): Promise<Mo
9096
// Level 1: ~80% random, prefer simple captures otherwise.
9197
// Level 2: ~50% random, depth-1 search the rest.
9298
// Level 3: depth-2, occasional blunder.
93-
// Level 4: depth-3 clean.
94-
// Level 5: depth-3 + capture ordering.
95-
// Level 6-10 fallback: depth-4.
96-
const blunderChance = { 1: 0.8, 2: 0.5, 3: 0.15, 4: 0.03, 5: 0 }[level as 1 | 2 | 3 | 4 | 5] ?? 0;
97-
const depth = level <= 2 ? 1 : level <= 3 ? 2 : level <= 5 ? 3 : 4;
99+
// Level 4: depth-2 clean.
100+
// Level 5+: deterministic minimax fallback if external engine is unavailable.
101+
const blunderChanceByLevel: Record<number, number> = { 1: 0.8, 2: 0.5, 3: 0.15, 4: 0.03 };
102+
const blunderChance = blunderChanceByLevel[normalizedLevel] ?? 0;
103+
const depth = normalizedLevel <= 2 ? 1 : normalizedLevel <= 4 ? 2 : normalizedLevel <= 8 ? 3 : 4;
98104

99105
if (rng() < blunderChance) {
100106
const captures = moves.filter((m) => m.captured);
101-
if (level === 1 || captures.length === 0) return randomChoice(moves, rng);
107+
if (normalizedLevel === 1 || captures.length === 0) return randomChoice(moves, rng);
102108
return randomChoice(captures, rng);
103109
}
104110

0 commit comments

Comments
 (0)