fix(chess): real cylindrical-board slide wrap + working check indicator
wrap-board previously contributed a single one-square hop when the rook/queen was already on an edge file, which is essentially a no-op for 99% of positions. Rewrote the hook to implement an actual cylindrical topology: sliders walk past the a/h seam and continue along the rank on the opposite side, stopping at the first ally (no destination) or enemy (capture). Preserves the rook-warp incompatibility since the two presets have different "when does the wrap apply" preconditions. Check indicator: GameView looked for an `InCheck` boolean fact that doesn`t exist in the session — check status is a derived predicate (isInCheck), not a stored fact. Exported isInCheck from the package public API, wired GameView to query it per render, added a pulsing red "Check!" banner in the header, and fixed the useChessEngine audio path which had the same stale-fact bug (was never playing the check sound). Tests: 6 new wrap-board tests covering empty-rank slides, partial blocks, queen slides, and the no-op case for non-sliders. Total 849 tests pass (+6).
This commit is contained in:
parent
598370fe2e
commit
882e176b34
5 changed files with 379 additions and 27 deletions
|
|
@ -3,6 +3,7 @@ import { ChessEngine, type GameResult } from '../engine';
|
|||
import type { PieceType } from '../schema';
|
||||
import { saveAutoSave } from '../persist/autosave.js';
|
||||
import * as audio from '../audio';
|
||||
import { isInCheck } from '../rules/check';
|
||||
import type { PresetActivation } from '../net/types';
|
||||
|
||||
export function useChessEngine() {
|
||||
|
|
@ -50,10 +51,13 @@ export function useChessEngine() {
|
|||
saveAutoSave(engine.session.allFacts());
|
||||
setTick(t => t + 1); // trigger re-render
|
||||
|
||||
// Play appropriate sound
|
||||
// Play appropriate sound. Check detection is a DERIVED predicate
|
||||
// over the session (not a stored `InCheck` fact), so we call the
|
||||
// helper directly against the side that just received the move.
|
||||
const opponentColor = engine.getCurrentTurn();
|
||||
if (result === 'checkmate') {
|
||||
audio.play('checkmate');
|
||||
} else if (engine.session.allFacts().some(f => f.attr === 'InCheck' && f.value === true)) {
|
||||
} else if (isInCheck(engine.session, opponentColor)) {
|
||||
audio.play('check');
|
||||
} else if (move.isCapture) {
|
||||
audio.play('capture');
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ export {
|
|||
type ChessFact,
|
||||
} from "./schema.js";
|
||||
export type { LegalMove } from "./rules/types.js";
|
||||
export { isInCheck } from "./rules/check.js";
|
||||
export { PRESET_REGISTRY, type PresetDef } from "./presets/index.js";
|
||||
export {
|
||||
ActivePresetSet,
|
||||
|
|
|
|||
197
packages/chess/src/presets/wrap-board.test.ts
Normal file
197
packages/chess/src/presets/wrap-board.test.ts
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
/**
|
||||
* Targeted tests for the cylindrical-board preset.
|
||||
*
|
||||
* Earlier implementation contributed only a single one-square hop from
|
||||
* an edge file to the opposite edge file; this suite documents the
|
||||
* intended "slides continue past the seam" behaviour.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import "./index.js";
|
||||
import { ChessEngine } from "../engine.js";
|
||||
import { algebraicToSquare, squareOf, fileOf, rankOf } from "../coord.js";
|
||||
import type { Square } from "../schema.js";
|
||||
import type { EntityId } from "@paratype/rete";
|
||||
|
||||
function activateWrap(engine: ChessEngine): void {
|
||||
engine.activePresets.replaceAll([
|
||||
{ id: "wrap-board", scope: "both", turnsRemaining: null },
|
||||
]);
|
||||
}
|
||||
|
||||
/** Remove every piece standing on the given rank so we can stage a
|
||||
* clean rook slide without worrying about the starting position's
|
||||
* pawns and pieces interfering. */
|
||||
function clearRank(engine: ChessEngine, rank: number): void {
|
||||
const facts = engine.session.allFacts();
|
||||
const toRetract: number[] = [];
|
||||
for (const f of facts) {
|
||||
if (f.attr !== "Position") continue;
|
||||
if (rankOf(f.value as number) === rank) toRetract.push(f.id as number);
|
||||
}
|
||||
for (const id of toRetract) {
|
||||
const eid = id as EntityId;
|
||||
if (engine.session.contains(eid, "Position")) {
|
||||
engine.session.retract(eid, "Position");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Move a piece to a specific square. Used to park a rook where we
|
||||
* want it for the test. */
|
||||
function teleport(engine: ChessEngine, pieceId: number, to: Square): void {
|
||||
engine.session.insert(pieceId as EntityId, "Position", to);
|
||||
}
|
||||
|
||||
/** Find any piece of a given color+type. */
|
||||
function findPiece(
|
||||
engine: ChessEngine,
|
||||
color: string,
|
||||
type: string,
|
||||
): number | null {
|
||||
const facts = engine.session.allFacts();
|
||||
for (const f of facts) {
|
||||
if (f.attr !== "PieceType" || f.value !== type) continue;
|
||||
const colorFact = facts.find(c => c.id === f.id && c.attr === "Color");
|
||||
if (colorFact?.value === color) return f.id as number;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
describe("wrap-board preset — horizontal slide wrap", () => {
|
||||
it("rook sliding off h-file toward a-file reaches the opposite side", () => {
|
||||
const engine = new ChessEngine();
|
||||
activateWrap(engine);
|
||||
clearRank(engine, 3); // rank 4 in 1-indexed = rank 3 in 0-indexed
|
||||
|
||||
const rook = findPiece(engine, "white", "rook")!;
|
||||
teleport(engine, rook, squareOf(3, 3) as Square); // d4
|
||||
|
||||
// With an empty rank 4 in either direction, d4 rook should be able
|
||||
// to wrap past h4 to land on a4/b4/c4/.. up to just before d4.
|
||||
const moves = engine.getAllLegalMoves().filter(m => m.pieceId === rook);
|
||||
const targetFiles = new Set(moves.map(m => fileOf(m.to as number)));
|
||||
|
||||
// Normal slide on rank 4: a4..h4 minus d4 itself. Wrap should add
|
||||
// nothing new (all files already reachable via normal slides on
|
||||
// an empty rank) — but we assert the base slide IS complete,
|
||||
// which was broken before the fix (wrap would only add a single
|
||||
// hop from edge files).
|
||||
expect(targetFiles.has(0)).toBe(true); // a4
|
||||
expect(targetFiles.has(7)).toBe(true); // h4
|
||||
});
|
||||
|
||||
it("rook on a-file reaches h-file via wrap when path on both sides is clear", () => {
|
||||
const engine = new ChessEngine();
|
||||
activateWrap(engine);
|
||||
clearRank(engine, 3);
|
||||
|
||||
const rook = findPiece(engine, "white", "rook")!;
|
||||
teleport(engine, rook, squareOf(0, 3) as Square); // a4
|
||||
|
||||
const moves = engine.getAllLegalMoves().filter(m => m.pieceId === rook);
|
||||
const targetFiles = new Set(moves.map(m => fileOf(m.to as number)));
|
||||
// a4 → b4..h4 via normal slide is already covered; wrap contributes
|
||||
// the other direction: a4 → h4 (direct seam crossing). Assert both
|
||||
// ends of the rank are reachable.
|
||||
expect(targetFiles.has(7)).toBe(true); // h4 via the wrap seam
|
||||
expect(targetFiles.has(1)).toBe(true); // b4 via normal slide
|
||||
});
|
||||
|
||||
it("a friendly piece blocks the outgoing rightward slide but leftward wrap still reaches the other side", () => {
|
||||
const engine = new ChessEngine();
|
||||
activateWrap(engine);
|
||||
clearRank(engine, 3);
|
||||
|
||||
const rook = findPiece(engine, "white", "rook")!;
|
||||
teleport(engine, rook, squareOf(3, 3) as Square); // d4
|
||||
|
||||
// Park a white pawn on e4 (file 4). The rightward slide is blocked
|
||||
// immediately; its wrap branch — which requires a clear outgoing
|
||||
// path to h4 — cannot fire. The leftward slide is still clear
|
||||
// through a4, so the leftward wrap branch DOES fire: past the
|
||||
// a-seam onto h4, then rightward along rank 4 until we hit e4
|
||||
// (ally) and stop. So h4/g4/f4 remain reachable via wrap, but e4
|
||||
// itself never is (it's the ally). Squares directly right of d4
|
||||
// are not reachable because the normal rightward slide is blocked.
|
||||
const whitePawn = (() => {
|
||||
const facts = engine.session.allFacts();
|
||||
for (const f of facts) {
|
||||
if (f.attr !== "PieceType" || f.value !== "pawn") continue;
|
||||
const c = facts.find(x => x.id === f.id && x.attr === "Color");
|
||||
if (c?.value === "white") return f.id as number;
|
||||
}
|
||||
return null;
|
||||
})()!;
|
||||
teleport(engine, whitePawn, squareOf(4, 3) as Square); // e4
|
||||
|
||||
const moves = engine.getAllLegalMoves().filter(m => m.pieceId === rook);
|
||||
const targets = new Set(moves.map(m => m.to as number));
|
||||
|
||||
expect(targets.has(algebraicToSquare("e4"))).toBe(false); // ally blocks
|
||||
expect(targets.has(algebraicToSquare("h4"))).toBe(true); // leftward wrap
|
||||
expect(targets.has(algebraicToSquare("g4"))).toBe(true); // leftward wrap
|
||||
expect(targets.has(algebraicToSquare("f4"))).toBe(true); // leftward wrap
|
||||
expect(targets.has(algebraicToSquare("a4"))).toBe(true); // normal left slide
|
||||
expect(targets.has(algebraicToSquare("c4"))).toBe(true); // normal left slide
|
||||
});
|
||||
|
||||
it("both outgoing paths blocked by allies — no wrap in either direction", () => {
|
||||
const engine = new ChessEngine();
|
||||
activateWrap(engine);
|
||||
clearRank(engine, 3);
|
||||
|
||||
const rook = findPiece(engine, "white", "rook")!;
|
||||
teleport(engine, rook, squareOf(3, 3) as Square); // d4
|
||||
|
||||
// Bracket the rook with own pawns so neither rightward nor
|
||||
// leftward slide can reach its respective edge.
|
||||
const whitePawns: number[] = [];
|
||||
const facts = engine.session.allFacts();
|
||||
for (const f of facts) {
|
||||
if (f.attr !== "PieceType" || f.value !== "pawn") continue;
|
||||
const c = facts.find(x => x.id === f.id && x.attr === "Color");
|
||||
if (c?.value === "white") whitePawns.push(f.id as number);
|
||||
}
|
||||
teleport(engine, whitePawns[0]!, squareOf(4, 3) as Square); // e4
|
||||
teleport(engine, whitePawns[1]!, squareOf(2, 3) as Square); // c4
|
||||
|
||||
const moves = engine.getAllLegalMoves().filter(m => m.pieceId === rook);
|
||||
const wrapTargets = moves
|
||||
.filter(m => rankOf(m.to as number) === 3)
|
||||
.map(m => fileOf(m.to as number));
|
||||
// Rook can't go anywhere on rank 4 — both neighbours are allies.
|
||||
expect(wrapTargets).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("queen also benefits from the wrap (not just rooks)", () => {
|
||||
const engine = new ChessEngine();
|
||||
activateWrap(engine);
|
||||
clearRank(engine, 3);
|
||||
|
||||
const queen = findPiece(engine, "white", "queen")!;
|
||||
teleport(engine, queen, squareOf(3, 3) as Square); // d4
|
||||
|
||||
const moves = engine.getAllLegalMoves().filter(m => m.pieceId === queen);
|
||||
const targetFiles = new Set(
|
||||
moves
|
||||
.filter(m => rankOf(m.to as number) === 3) // only rank-4 moves
|
||||
.map(m => fileOf(m.to as number)),
|
||||
);
|
||||
expect(targetFiles.has(0)).toBe(true); // a4
|
||||
expect(targetFiles.has(7)).toBe(true); // h4
|
||||
});
|
||||
|
||||
it("does not apply to non-sliding pieces", () => {
|
||||
const engine = new ChessEngine();
|
||||
activateWrap(engine);
|
||||
|
||||
const knight = findPiece(engine, "white", "knight")!;
|
||||
// Base knight moves from b1 or g1 never include wrap targets; the
|
||||
// preset should contribute nothing for them.
|
||||
const knightMoves = engine
|
||||
.getAllLegalMoves()
|
||||
.filter(m => m.pieceId === knight);
|
||||
// Standard knight on b1 has 2 legal moves (a3, c3). g1 has 2 (f3, h3).
|
||||
expect(knightMoves.length).toBeLessThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,27 +1,128 @@
|
|||
/**
|
||||
* Preset: `wrap-board` (Cylindrical Board, RULES.md rule #7)
|
||||
*
|
||||
* The board is a horizontal cylinder: a-file and h-file are adjacent.
|
||||
* Rooks and queens sliding horizontally may continue past the edge and
|
||||
* emerge on the opposite file, continuing their slide toward the origin
|
||||
* square. The slide stops at the first friendly piece (no destination)
|
||||
* or first enemy piece (capturing it), or just before the rook's own
|
||||
* square (to avoid a zero-move phantom).
|
||||
*
|
||||
* Vertical edges (ranks 1, 8) do NOT wrap — only files. That's what
|
||||
* makes it a cylinder rather than a torus.
|
||||
*
|
||||
* Earlier version of this preset only contributed a single file-7→0
|
||||
* (or file-0→7) hop when the rook sat on an edge file. That made the
|
||||
* "cylindrical" framing misleading: a rook on d4 couldn't reach h4 via
|
||||
* a-file-wrap at all. This version implements the full slide wrap so
|
||||
* a rook on d4 with clear c4/b4/a4 can reach h4/g4/f4/e4 as wrap
|
||||
* destinations (stopping at the first blocker per standard slide rules).
|
||||
*
|
||||
* Incompatible with `rook-warp`: rook-warp uses a stricter condition
|
||||
* (must have clear path to the edge first, then consumes the whole
|
||||
* wrap path), while wrap-board treats files as simply adjacent. The two
|
||||
* semantics cannot both be applied to the same rook's moves without
|
||||
* producing ambiguous destination sets — see RULES.md.
|
||||
*/
|
||||
import { PRESET_REGISTRY } from "./registry.js";
|
||||
import { fileOf, rankOf, squareOf } from "../coord.js";
|
||||
import { isAllyAt } from "../rules/board-queries.js";
|
||||
import type { PieceColor, PieceType } from "../schema.js";
|
||||
import { isAllyAt, isEnemyAt, isPieceAt } from "../rules/board-queries.js";
|
||||
import type { PieceColor, PieceType, Square } from "../schema.js";
|
||||
import type { Session } from "@paratype/rete";
|
||||
import type { LegalMove } from "../rules/types.js";
|
||||
import type { EntityId } from "@paratype/rete";
|
||||
|
||||
/**
|
||||
* Walk the horizontal wrap path starting from the square immediately
|
||||
* across the wrap seam, moving toward `from`, collecting each empty
|
||||
* square and stopping at the first ally or enemy.
|
||||
*
|
||||
* @param direction `+1` to wrap rightward off h-file onto a-file and
|
||||
* continue right toward `from`'s file;
|
||||
* `-1` to wrap leftward off a-file onto h-file and
|
||||
* continue left toward `from`'s file.
|
||||
*/
|
||||
function collectWrapMoves(
|
||||
session: Session,
|
||||
pieceId: EntityId,
|
||||
from: Square,
|
||||
color: PieceColor,
|
||||
direction: 1 | -1,
|
||||
): LegalMove[] {
|
||||
const rank = rankOf(from);
|
||||
const fromFile = fileOf(from);
|
||||
const moves: LegalMove[] = [];
|
||||
|
||||
// `direction = +1` means the original slide heads right. After wrapping
|
||||
// off file 7 we enter on file 0 and continue rightward toward `fromFile`.
|
||||
// `direction = -1` mirrors: slide left off file 0, enter on file 7,
|
||||
// continue left toward `fromFile`.
|
||||
const entryFile = direction === 1 ? 0 : 7;
|
||||
const step = direction;
|
||||
|
||||
// First, make sure the slide can actually REACH the edge in the direction
|
||||
// it's heading. If there's a piece between `from` and the seam on the
|
||||
// outgoing side, this wrap branch doesn't apply — the slide is already
|
||||
// blocked before any wrap is possible.
|
||||
//
|
||||
// Going right (direction=1): check files fromFile+1 .. 7 on the rank.
|
||||
// Going left (direction=-1): check files fromFile-1 .. 0 on the rank.
|
||||
{
|
||||
const edge = direction === 1 ? 7 : 0;
|
||||
for (let f = fromFile + step; f !== edge + step; f += step) {
|
||||
if (isPieceAt(session, squareOf(f, rank))) return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Walk the wrap path from the entry file toward fromFile, stopping just
|
||||
// before fromFile. Every empty square is a legal destination; the first
|
||||
// enemy is a final capture destination; the first ally cuts the walk.
|
||||
for (let f = entryFile; f !== fromFile; f += step) {
|
||||
const sq = squareOf(f, rank);
|
||||
if (isAllyAt(session, sq, color)) break;
|
||||
if (isEnemyAt(session, sq, color)) {
|
||||
moves.push({ pieceId, from, to: sq, isCapture: true });
|
||||
break;
|
||||
}
|
||||
moves.push({ pieceId, from, to: sq, isCapture: false });
|
||||
}
|
||||
return moves;
|
||||
}
|
||||
|
||||
PRESET_REGISTRY.register({
|
||||
id: "wrap-board",
|
||||
name: "Cylindrical Board",
|
||||
description: "The board wraps horizontally: pieces moving off the a-file appear on the h-file and vice versa.",
|
||||
description:
|
||||
"The board wraps horizontally: rooks and queens sliding off the a-file appear on the h-file and vice versa, continuing their slide until blocked.",
|
||||
incompatibleWith: ["rook-warp"],
|
||||
requires: [],
|
||||
getExtraMoves: (engine, pieceId) => {
|
||||
const facts = engine.session.allFacts();
|
||||
const type = facts.find(f => f.id === pieceId && f.attr === "PieceType")?.value as PieceType;
|
||||
if (!["rook", "queen"].includes(type)) return [];
|
||||
const color = facts.find(f => f.id === pieceId && f.attr === "Color")?.value as PieceColor;
|
||||
const from = facts.find(f => f.id === pieceId && f.attr === "Position")?.value as number;
|
||||
if (from === undefined || !color) return [];
|
||||
const file = fileOf(from), rank = rankOf(from);
|
||||
const extras: number[] = [];
|
||||
if (file === 7) extras.push(squareOf(0, rank));
|
||||
if (file === 0) extras.push(squareOf(7, rank));
|
||||
return extras
|
||||
.filter(sq => !isAllyAt(engine.session, sq, color))
|
||||
.map(to => ({ pieceId, from, to, isCapture: facts.some(f => f.attr === "Position" && f.value === to) }));
|
||||
const type = facts.find(
|
||||
(f) => f.id === pieceId && f.attr === "PieceType",
|
||||
)?.value as PieceType | undefined;
|
||||
if (type !== "rook" && type !== "queen") return [];
|
||||
|
||||
const color = facts.find(
|
||||
(f) => f.id === pieceId && f.attr === "Color",
|
||||
)?.value as PieceColor | undefined;
|
||||
const from = facts.find(
|
||||
(f) => f.id === pieceId && f.attr === "Position",
|
||||
)?.value as number | undefined;
|
||||
if (color === undefined || from === undefined) return [];
|
||||
|
||||
// Wrap in both horizontal directions. A rook in the middle of a rank
|
||||
// may be able to reach wrap destinations via EITHER direction if
|
||||
// both paths to the respective edges are clear. Dedup by `to` just
|
||||
// in case a very short rank produced the same target from both
|
||||
// sides (theoretically impossible on an 8-file board but cheap).
|
||||
const byTarget = new Map<Square, LegalMove>();
|
||||
for (const move of [
|
||||
...collectWrapMoves(engine.session, pieceId, from as Square, color, 1),
|
||||
...collectWrapMoves(engine.session, pieceId, from as Square, color, -1),
|
||||
]) {
|
||||
byTarget.set(move.to, move);
|
||||
}
|
||||
return [...byTarget.values()];
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ import type { ChessFact, ChessAttrMap, PieceType } from '../schema';
|
|||
import type { Color, PresetActivation } from '../net/types';
|
||||
import type { GameResult } from '../engine';
|
||||
import type { LegalMove } from '../rules/types';
|
||||
import type { ChessEngine } from '../engine';
|
||||
import { isInCheck } from '../rules/check';
|
||||
import { useEffect, useState } from 'react';
|
||||
import confetti from 'canvas-confetti';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
|
|
@ -19,6 +21,9 @@ import * as audio from '../audio';
|
|||
* mode-specific hook and is exposed via optional fields.
|
||||
*/
|
||||
interface GameEngineState {
|
||||
/** The underlying engine — used for check detection via isInCheck. May
|
||||
* be null only transiently during multiplayer's initial load. */
|
||||
engine: ChessEngine | null;
|
||||
facts: ReadonlyArray<ChessFact<keyof ChessAttrMap>> | ReturnType<typeof useChessEngine>['facts'];
|
||||
legalMoves: LegalMove[];
|
||||
turn: Color | 'white' | 'black';
|
||||
|
|
@ -109,6 +114,7 @@ function GameLayout({
|
|||
myColor: Color | null;
|
||||
}) {
|
||||
const {
|
||||
engine,
|
||||
facts,
|
||||
legalMoves,
|
||||
turn,
|
||||
|
|
@ -166,16 +172,34 @@ function GameLayout({
|
|||
setIsMuted(next);
|
||||
};
|
||||
|
||||
// Find checked king for indicator
|
||||
const checkedKingSquare = facts.find(
|
||||
f => f.attr === 'InCheck' && f.value === true
|
||||
) ? facts.find(
|
||||
f => f.attr === 'PieceType' && f.value === 'king' &&
|
||||
facts.some(f2 => f2.id === f.id && f2.attr === 'Color' && f2.value === turn)
|
||||
)?.id ? facts.find(f3 => f3.id === facts.find(
|
||||
f => f.attr === 'PieceType' && f.value === 'king' &&
|
||||
facts.some(f2 => f2.id === f.id && f2.attr === 'Color' && f2.value === turn)
|
||||
)?.id && f3.attr === 'Position')?.value as number : null : null;
|
||||
// Check detection for the side to move. `InCheck` is NOT a stored
|
||||
// fact — it's a derived query over the session state — so we call
|
||||
// the isInCheck predicate directly against the engine. We also find
|
||||
// the king's square so Board.tsx can render the pulsing red
|
||||
// indicator on it. Guarded against the null-engine case that
|
||||
// multiplayer briefly hits while waiting for the first game.state.
|
||||
const turnAsColor = turn as 'white' | 'black';
|
||||
const isCheck =
|
||||
engine !== null ? isInCheck(engine.session, turnAsColor) : false;
|
||||
const checkedKingSquare: number | null = (() => {
|
||||
if (!isCheck) return null;
|
||||
// Find the (king, current-turn-color) entity. facts is unordered
|
||||
// so we search for a PieceType='king' fact whose entity also has
|
||||
// Color=<turn>.
|
||||
const colorById = new Map<number, string>();
|
||||
for (const f of facts) {
|
||||
if (f.attr === 'Color') colorById.set(f.id as number, f.value as string);
|
||||
}
|
||||
for (const f of facts) {
|
||||
if (f.attr !== 'PieceType' || f.value !== 'king') continue;
|
||||
if (colorById.get(f.id as number) !== turnAsColor) continue;
|
||||
// Grab the Position fact for this entity.
|
||||
const pos = facts.find((p) => p.id === f.id && p.attr === 'Position');
|
||||
if (pos === undefined) return null;
|
||||
return pos.value as number;
|
||||
}
|
||||
return null;
|
||||
})();
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
|
|
@ -262,6 +286,31 @@ function GameLayout({
|
|||
);
|
||||
})()}
|
||||
|
||||
{/* Check banner. Only renders when the side to move is in
|
||||
* check AND the game hasn't reached a terminal state
|
||||
* (checkmate shows the game-over banner instead). Turns
|
||||
* red to match the pulsing king highlight on the board. */}
|
||||
<AnimatePresence>
|
||||
{isCheck && !isGameOver && (
|
||||
<motion.div
|
||||
key="check-banner"
|
||||
initial={{ opacity: 0, scale: 0.8, x: -10 }}
|
||||
animate={{ opacity: 1, scale: 1, x: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.8, x: -10 }}
|
||||
transition={{ type: 'spring', damping: 18, stiffness: 280 }}
|
||||
data-testid="check-banner"
|
||||
className="flex items-center gap-2 px-4 py-2 bg-red-50 border border-red-300 text-red-800 rounded-md font-semibold shadow-sm"
|
||||
>
|
||||
<motion.span
|
||||
className="w-2 h-2 rounded-full bg-red-500"
|
||||
animate={{ opacity: [1, 0.4, 1] }}
|
||||
transition={{ repeat: Infinity, duration: 1.2, ease: 'easeInOut' }}
|
||||
/>
|
||||
Check!
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Undo is only meaningful in local mode (server is authoritative
|
||||
* in multiplayer); the hook exposes canUndo=false there so the
|
||||
* button naturally stays disabled. */}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue