Skip to content

Commit 94057e0

Browse files
committed
Improve game safeguards and settings feedback
1 parent dedc0a5 commit 94057e0

5 files changed

Lines changed: 81 additions & 6 deletions

File tree

src/GameContext.tsx

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,15 @@ function botLevelOf(m: Mode): number | null {
8787
return null;
8888
}
8989

90+
function triggerHaptics(enabled: boolean, pattern: number | number[]) {
91+
if (!enabled || typeof navigator === "undefined" || typeof navigator.vibrate !== "function") return;
92+
try {
93+
navigator.vibrate(pattern);
94+
} catch {
95+
// Ignore unsupported or blocked vibration requests.
96+
}
97+
}
98+
9099
const Ctx = createContext<GameCtx | null>(null);
91100

92101
export function useGame(): GameCtx {
@@ -189,6 +198,32 @@ export function GameProvider({ children }: { children: ReactNode }) {
189198
soundedLenRef.current = state.history.length;
190199
}, [state, result, mode, store.settings.sound, store.settings.explodeOnCapture]);
191200

201+
const hapticsLenRef = useRef<number>(state.history.length);
202+
useEffect(() => {
203+
if (!store.settings.haptics) {
204+
hapticsLenRef.current = state.history.length;
205+
return;
206+
}
207+
if (state.history.length > hapticsLenRef.current) {
208+
const lastMove = state.history[state.history.length - 1];
209+
const isCheck = result.kind === "ongoing" && isInCheckNow(state);
210+
if (result.kind === "checkmate") {
211+
triggerHaptics(true, [60, 40, 90]);
212+
} else if (result.kind !== "ongoing") {
213+
triggerHaptics(true, [45, 30, 45]);
214+
} else if (isCheck) {
215+
triggerHaptics(true, [35, 25, 55]);
216+
} else if (lastMove?.captured) {
217+
triggerHaptics(true, 45);
218+
} else if (lastMove?.isPortalEntry) {
219+
triggerHaptics(true, [18, 20, 18]);
220+
} else {
221+
triggerHaptics(true, 20);
222+
}
223+
}
224+
hapticsLenRef.current = state.history.length;
225+
}, [state, result, store.settings.haptics]);
226+
192227
// Persist active session so a refresh / PWA relaunch resumes the game.
193228
useEffect(() => {
194229
if (result.kind !== "ongoing") {
@@ -372,6 +407,14 @@ export function GameProvider({ children }: { children: ReactNode }) {
372407
}, [mode]);
373408

374409
const newGame = useCallback((m: Mode, p?: Partial<Players>, opts?: NewGameOptions) => {
410+
if (
411+
result.kind === "ongoing" &&
412+
state.history.length > 0 &&
413+
typeof window !== "undefined" &&
414+
!window.confirm("Start a new game? Your current game will be replaced.")
415+
) {
416+
return;
417+
}
375418
clearActiveSession();
376419
noTimerRef.current = false;
377420
if (opts?.timerSeconds !== undefined && opts.timerSeconds !== storeRef.current.settings.timerSeconds) {
@@ -400,7 +443,7 @@ export function GameProvider({ children }: { children: ReactNode }) {
400443
// instead of the stale closure-captured one.
401444
const t = opts?.timerSeconds ?? storeRef.current.settings.timerSeconds;
402445
setTimeLeft(t > 0 ? t : Infinity);
403-
}, [activeProfile]);
446+
}, [activeProfile, result.kind, state.history.length]);
404447

405448
const forfeit = useCallback(() => dispatch({ type: "forfeit" }), []);
406449

src/components/Board.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { CSSProperties, useLayoutEffect, useState } from "react";
22
import { Move, Piece as PieceT, Square, squareName } from "../engine/board";
3-
import { findKing, inCheck } from "../engine/rules";
3+
import { findKing, inCheck, isSquareAttacked } from "../engine/rules";
44
import { playSound } from "../engine/sound";
55
import { useGame } from "../GameContext";
66
import { Piece } from "./Piece";
@@ -149,6 +149,10 @@ export function Board({ flipped = false }: Props) {
149149
const isHintTo = hint && hint.to.file === f && hint.to.rank === r;
150150
const isPortalW = wPortals.some((p) => p.file === f && p.rank === r);
151151
const isPortalB = bPortals.some((p) => p.file === f && p.rank === r);
152+
const isThreatened =
153+
!!piece &&
154+
store.settings.showThreats &&
155+
isSquareAttacked(state, sq, piece.color === "w" ? "b" : "w");
152156
const slideEnd = lastMove?.portalTo ?? lastMove?.to;
153157
const isSlideDestination =
154158
!!slideEnd && slideEnd.file === f && slideEnd.rank === r;
@@ -207,6 +211,7 @@ export function Board({ flipped = false }: Props) {
207211
aria-hidden="true"
208212
/>
209213
)}
214+
{isThreatened && <span className="threat-marker" aria-hidden="true">!</span>}
210215
{showCapturedGhost && lastMove?.captured && (
211216
<span className="captured-ghost" aria-hidden="true">
212217
<Piece

src/engine/__tests__/perft.test.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { describe, it, expect } from "vitest";
2-
import { initialState } from "../board";
3-
import { allLegalMoves, makeMove } from "../rules";
2+
import { initialState, parseFEN } from "../board";
3+
import { allLegalMoves, gameResult, makeMove } from "../rules";
44

55
function perft(state: ReturnType<typeof initialState>, depth: number): number {
66
if (depth === 0) return 1;
@@ -22,3 +22,11 @@ describe("perft from initial position", () => {
2222
expect(perft(initialState(), 3)).toBe(8902);
2323
});
2424
});
25+
26+
describe("game results", () => {
27+
it("detects stalemate when side to move has no legal moves and is not in check", () => {
28+
const state = parseFEN("7k/5Q2/6K1/8/8/8/8/8 b - - 0 1");
29+
expect(allLegalMoves(state)).toHaveLength(0);
30+
expect(gameResult(state)).toEqual({ kind: "stalemate" });
31+
});
32+
});

src/screens/SettingsScreen.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,10 +90,10 @@ export function SettingsScreen() {
9090
<section>
9191
<h3>Toggles</h3>
9292
<label><input type="checkbox" checked={s.sound} onChange={(e) => updateSetting("sound", e.target.checked)} /> Sound effects</label>
93-
<label><input type="checkbox" checked={s.haptics} onChange={(e) => updateSetting("haptics", e.target.checked)} /> Haptics (iPhone)</label>
93+
<label><input type="checkbox" checked={s.haptics} onChange={(e) => updateSetting("haptics", e.target.checked)} /> Vibration feedback (supported devices)</label>
9494
<label><input type="checkbox" checked={s.autoFlip} onChange={(e) => updateSetting("autoFlip", e.target.checked)} /> Auto-flip board in 2-player mode</label>
9595
<label><input type="checkbox" checked={s.rotateBlackPiecesFixedBoard} onChange={(e) => updateSetting("rotateBlackPiecesFixedBoard", e.target.checked)} /> Rotate black pieces 180° on fixed 2-player board</label>
96-
<label><input type="checkbox" checked={s.showThreats} onChange={(e) => updateSetting("showThreats", e.target.checked)} /> Show threatened pieces (learn mode)</label>
96+
<label><input type="checkbox" checked={s.showThreats} onChange={(e) => updateSetting("showThreats", e.target.checked)} /> Show threatened pieces</label>
9797
<label><input type="checkbox" checked={s.explodeOnCapture} onChange={(e) => updateSetting("explodeOnCapture", e.target.checked)} /> 💥 Explode pieces on capture</label>
9898
</section>
9999
</div>

src/styles.css

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,25 @@ section label { display: block; margin: 6px 0; }
310310
}
311311
@keyframes hint-spin { to { transform: rotate(360deg); } }
312312

313+
.threat-marker {
314+
position: absolute;
315+
top: 6px;
316+
right: 6px;
317+
z-index: 4;
318+
min-width: 18px;
319+
height: 18px;
320+
padding: 0 4px;
321+
border-radius: 999px;
322+
background: rgba(245, 158, 11, 0.95);
323+
color: #111827;
324+
font-size: 0.7rem;
325+
font-weight: 800;
326+
line-height: 18px;
327+
text-align: center;
328+
box-shadow: 0 0 0 2px rgba(15, 23, 42, 0.55);
329+
pointer-events: none;
330+
}
331+
313332
/* Promotion picker */
314333
.promo-picker h3 { margin: 0 0 12px; }
315334
.promo-buttons {

0 commit comments

Comments
 (0)