fix(chess): cylindrical board wraps every piece, plus eliminate piece image flash
wrap-board previously only wrapped rooks and queens horizontally, which meant knights, kings, bishops, and pawn captures couldn`t cross the file seam at all — a knight on h4 with cylindrical enabled had no wrap targets, contrary to the "horizontal cylinder" framing. Rewrote the hook to handle every piece type: knights/kings via mod-8 file offsets; bishops/queens/rooks via cylindrical ray walkers; pawn captures via mod-8 diagonal targets. Rank bounds still terminate walks (cylinder, not torus). Added 4 piece-type tests to cover the new cases. Piece image flash on remount: every move triggers a FLIP unmount at the source square and remount at the destination, producing a brand-new <img> element. Browsers don`t block paint on image load, so the alt text briefly rendered before the SVG decoded. Preload and eagerly-decode every piece SVG at module init to keep the decoded bitmap warm in the browser image cache, and set `decoding="sync"` plus empty alt on the Piece img so the alt never has a paint window. aria-label preserves the accessible name.
This commit is contained in:
parent
882e176b34
commit
60217adf97
4 changed files with 356 additions and 91 deletions
|
|
@ -29,3 +29,48 @@ export const pieceAssets = {
|
|||
pawn: blackPawn,
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Module-level array anchoring preloaded Image objects so the browser
|
||||
* GC doesn't collect them after the init block below completes. The
|
||||
* presence of a live reference is what keeps the decoded bitmap in
|
||||
* the browser's image cache.
|
||||
*/
|
||||
// Using HTMLImageElement[] rather than Image[] so this module can be
|
||||
// imported from non-DOM contexts (tests, server) without TS complaining.
|
||||
const PRELOADED_IMAGES: HTMLImageElement[] = [];
|
||||
|
||||
/**
|
||||
* Preload + eagerly decode every piece SVG at module init.
|
||||
*
|
||||
* Why: every move triggers a FLIP remount in Piece.tsx — the old
|
||||
* `<img>` tag unmounts at the source square and a brand-new one
|
||||
* mounts at the destination. Browsers don't block paint on image
|
||||
* load; even though Vite's bundled SVG is already in the HTTP cache,
|
||||
* the new `<img>` element shows its `alt` attribute (the "text
|
||||
* representation") for the one or two frames it takes to attach,
|
||||
* parse, and decode.
|
||||
*
|
||||
* Holding a warm `Image()` for every asset keeps the decoded bitmap
|
||||
* alive in the browser's image cache, so subsequent `<img src=…>`
|
||||
* attachments render the first painted frame from cache instead of
|
||||
* briefly falling back to alt text.
|
||||
*
|
||||
* Guarded by `typeof Image` so server-side imports (tests, the
|
||||
* headless server package) don't crash.
|
||||
*/
|
||||
if (typeof Image !== "undefined") {
|
||||
for (const byType of Object.values(pieceAssets)) {
|
||||
for (const url of Object.values(byType)) {
|
||||
const img = new Image();
|
||||
img.src = url;
|
||||
// decode() returns a promise that resolves once pixels are
|
||||
// ready. Swallow rejection — rare browsers without support still
|
||||
// render the image fine when it's actually used.
|
||||
if (typeof img.decode === "function") {
|
||||
img.decode().catch(() => {});
|
||||
}
|
||||
PRELOADED_IMAGES.push(img);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -181,17 +181,118 @@ describe("wrap-board preset — horizontal slide wrap", () => {
|
|||
expect(targetFiles.has(7)).toBe(true); // h4
|
||||
});
|
||||
|
||||
it("does not apply to non-sliding pieces", () => {
|
||||
it("knight on h-file gets wrap-around L-leaps to a-file", () => {
|
||||
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);
|
||||
// Park the knight on h3 (file 7, rank 2). With the cylinder, the
|
||||
// 8 knight offsets wrap file mod 8. From (file=7, rank=2) the
|
||||
// wrap-crossing targets are:
|
||||
// (+1,+2) → file 0, rank 4 → a5
|
||||
// (+2,+1) → file 1, rank 3 → b4
|
||||
// (+2,-1) → file 1, rank 1 → b2
|
||||
// (+1,-2) → file 0, rank 0 → a1
|
||||
// (Same-side targets f2, f4, g1, g5 come from the base rule.)
|
||||
clearRank(engine, 0);
|
||||
clearRank(engine, 1);
|
||||
clearRank(engine, 2);
|
||||
clearRank(engine, 3);
|
||||
clearRank(engine, 4);
|
||||
teleport(engine, knight, squareOf(7, 2) as Square); // h3
|
||||
|
||||
const moves = engine.getAllLegalMoves().filter(m => m.pieceId === knight);
|
||||
const targets = new Set(moves.map(m => m.to as number));
|
||||
expect(targets.has(algebraicToSquare("a5"))).toBe(true);
|
||||
expect(targets.has(algebraicToSquare("b4"))).toBe(true);
|
||||
expect(targets.has(algebraicToSquare("b2"))).toBe(true);
|
||||
expect(targets.has(algebraicToSquare("a1"))).toBe(true);
|
||||
});
|
||||
|
||||
it("knight on a-file gets wrap-around L-leaps to h-file", () => {
|
||||
// Regression: a knight near the left edge leaping across the seam.
|
||||
// b1 (file 1, rank 0) has one wrap-only target at h2:
|
||||
// (-2,+1) → file -1 = 7, rank 1 → h2.
|
||||
// Plus the standard a3, c3, d2 from the base rule.
|
||||
const engine = new ChessEngine();
|
||||
activateWrap(engine);
|
||||
clearRank(engine, 0);
|
||||
clearRank(engine, 1);
|
||||
clearRank(engine, 2);
|
||||
const knight = findPiece(engine, "white", "knight")!;
|
||||
teleport(engine, knight, squareOf(1, 0) as Square); // b1
|
||||
|
||||
const moves = engine.getAllLegalMoves().filter(m => m.pieceId === knight);
|
||||
const targets = new Set(moves.map(m => m.to as number));
|
||||
expect(targets.has(algebraicToSquare("h2"))).toBe(true); // -2,+1 wrap
|
||||
expect(targets.has(algebraicToSquare("a3"))).toBe(true); // -1,+2 base
|
||||
expect(targets.has(algebraicToSquare("c3"))).toBe(true); // +1,+2 base
|
||||
});
|
||||
|
||||
it("knight on h4 reaches a5 and b4 via the seam (user screenshot regression)", () => {
|
||||
// User reported: with cylindrical enabled, a knight on h4 had no
|
||||
// wrap moves into a-file territory.
|
||||
// From (file 7, rank 3):
|
||||
// (+1,+2) → file 0, rank 5 → a6
|
||||
// (+2,+1) → file 1, rank 4 → b5 ← b5 per user, close to b4 expectation
|
||||
// (+2,-1) → file 1, rank 2 → b3
|
||||
// (+1,-2) → file 0, rank 1 → a2
|
||||
// Note the user said "a5 and b4" but the strict knight geometry
|
||||
// from h4 actually produces a6/b5/b3/a2. The IMPORTANT thing is
|
||||
// that wrap-crossing targets exist at all — the old implementation
|
||||
// returned NONE. We assert on the real math.
|
||||
const engine = new ChessEngine();
|
||||
activateWrap(engine);
|
||||
for (let r = 0; r <= 6; r++) clearRank(engine, r);
|
||||
const knight = findPiece(engine, "white", "knight")!;
|
||||
teleport(engine, knight, squareOf(7, 3) as Square); // h4
|
||||
|
||||
const moves = engine.getAllLegalMoves().filter(m => m.pieceId === knight);
|
||||
const targets = new Set(moves.map(m => m.to as number));
|
||||
expect(targets.has(algebraicToSquare("a6"))).toBe(true);
|
||||
expect(targets.has(algebraicToSquare("b5"))).toBe(true);
|
||||
expect(targets.has(algebraicToSquare("b3"))).toBe(true);
|
||||
expect(targets.has(algebraicToSquare("a2"))).toBe(true);
|
||||
});
|
||||
|
||||
it("king on a-file can step onto h-file via the seam", () => {
|
||||
const engine = new ChessEngine();
|
||||
activateWrap(engine);
|
||||
// Clear rank 2 so the a2 square is empty and the king has empty
|
||||
// squares to walk onto. Also clear the king's home rank neighbours.
|
||||
clearRank(engine, 1);
|
||||
clearRank(engine, 0);
|
||||
|
||||
const king = findPiece(engine, "white", "king")!;
|
||||
teleport(engine, king, squareOf(0, 3) as Square); // a4
|
||||
clearRank(engine, 3);
|
||||
teleport(engine, king, squareOf(0, 3) as Square); // a4 again after rank clear
|
||||
|
||||
const moves = engine.getAllLegalMoves().filter(m => m.pieceId === king);
|
||||
const targets = new Set(moves.map(m => m.to as number));
|
||||
expect(targets.has(algebraicToSquare("h4"))).toBe(true); // west wrap
|
||||
expect(targets.has(algebraicToSquare("h5"))).toBe(true); // NW wrap
|
||||
expect(targets.has(algebraicToSquare("h3"))).toBe(true); // SW wrap
|
||||
});
|
||||
|
||||
it("bishop on a-file gets wrap-around diagonal moves to h-file", () => {
|
||||
const engine = new ChessEngine();
|
||||
activateWrap(engine);
|
||||
// Clear a diagonal path so the wrap is unobstructed.
|
||||
clearRank(engine, 1);
|
||||
clearRank(engine, 2);
|
||||
clearRank(engine, 3);
|
||||
|
||||
const bishop = findPiece(engine, "white", "bishop")!;
|
||||
teleport(engine, bishop, squareOf(0, 3) as Square); // a4
|
||||
|
||||
const moves = engine.getAllLegalMoves().filter(m => m.pieceId === bishop);
|
||||
const targets = new Set(moves.map(m => m.to as number));
|
||||
// Bishop on a4 walking up-left wraps: a4 -> h5 (file -1 = 7, rank 4)
|
||||
// Then g6 (file -2 = 6, rank 5) — but we didn't clear rank 5, so
|
||||
// stops at h5 or whatever blocker. Just assert h5 is reachable.
|
||||
expect(targets.has(algebraicToSquare("h5"))).toBe(true);
|
||||
// And down-left from a4: file -1 = 7, rank 2 → h3.
|
||||
expect(targets.has(algebraicToSquare("h3"))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,98 +2,210 @@
|
|||
* 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).
|
||||
* Every piece whose movement could "fall off" the left or right edge
|
||||
* instead emerges on the opposite side, continuing its geometry there.
|
||||
* Vertical edges (ranks 1 and 8) do NOT wrap — the board is a cylinder,
|
||||
* not a torus.
|
||||
*
|
||||
* Vertical edges (ranks 1, 8) do NOT wrap — only files. That's what
|
||||
* makes it a cylinder rather than a torus.
|
||||
* Concretely:
|
||||
* - Rooks / queen horizontals: slides continue past the seam along the
|
||||
* rank, stopping at the first blocker or capturing the first enemy.
|
||||
* - Bishops / queen diagonals: diagonal slides wrap in file but still
|
||||
* terminate when the rank leaves the board.
|
||||
* - Knights: the 8 L-offsets are computed with file `mod 8`, so a knight
|
||||
* on h4 can leap to a6/b5/b3/a2 as if file 8 ≡ file 0.
|
||||
* - Kings: all 8 adjacent offsets with file `mod 8`.
|
||||
* - Pawn captures: the two diagonal-forward capture squares wrap too.
|
||||
* - Pawn advances do NOT wrap — they move along files, not across the
|
||||
* file seam, so wrapping is irrelevant there.
|
||||
*
|
||||
* 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).
|
||||
* We implement this with a single `getExtraMoves` hook that, per piece,
|
||||
* computes the wrap-aware destinations and returns only the ones the
|
||||
* base FIDE rule could not already produce (because its file math is
|
||||
* clamped to [0,7]). The base rule's standard moves still run, so the
|
||||
* sum is the full cylindrical move set.
|
||||
*
|
||||
* 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.
|
||||
* Incompatible with `rook-warp`: rook-warp uses a stricter precondition
|
||||
* (the full path to the edge must be clear before any wrap is legal)
|
||||
* and applies only to rooks. Combining them would produce ambiguous
|
||||
* destination sets — see RULES.md for the full matrix.
|
||||
*/
|
||||
import { PRESET_REGISTRY } from "./registry.js";
|
||||
import { fileOf, rankOf, squareOf } from "../coord.js";
|
||||
import { isAllyAt, isEnemyAt, isPieceAt } from "../rules/board-queries.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 { Session, EntityId } from "@paratype/rete";
|
||||
import type { LegalMove } from "../rules/types.js";
|
||||
import type { EntityId } from "@paratype/rete";
|
||||
|
||||
/** Modulo that handles negative file deltas correctly (JS `%` returns
|
||||
* negative values for e.g. `-1 % 8`). */
|
||||
function wrapFile(file: number): number {
|
||||
return ((file % 8) + 8) % 8;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Walk a cylindrical ray with the given (fileDelta, rankDelta) step.
|
||||
* Files wrap mod 8; ranks terminate the walk when out of [0,7].
|
||||
* The starting square is never included. Stops at the first ally, or
|
||||
* captures and stops at the first enemy. If the walk loops back to
|
||||
* `from` (possible on a pure-horizontal ray since files wrap), it
|
||||
* stops without re-adding.
|
||||
*/
|
||||
function collectWrapMoves(
|
||||
function walkRay(
|
||||
session: Session,
|
||||
pieceId: EntityId,
|
||||
from: Square,
|
||||
color: PieceColor,
|
||||
direction: 1 | -1,
|
||||
fileDelta: number,
|
||||
rankDelta: number,
|
||||
): 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);
|
||||
const out: LegalMove[] = [];
|
||||
let file = fileOf(from) + fileDelta;
|
||||
let rank = rankOf(from) + rankDelta;
|
||||
// Safety cap: at most 8 file-wraps * 8 ranks = 64, pick 80 for margin.
|
||||
for (let step = 0; step < 80; step++) {
|
||||
if (rank < 0 || rank > 7) break;
|
||||
const sq = squareOf(wrapFile(file), rank);
|
||||
if (sq === from) break;
|
||||
if (isAllyAt(session, sq, color)) break;
|
||||
if (isEnemyAt(session, sq, color)) {
|
||||
moves.push({ pieceId, from, to: sq, isCapture: true });
|
||||
out.push({ pieceId, from, to: sq, isCapture: true });
|
||||
break;
|
||||
}
|
||||
moves.push({ pieceId, from, to: sq, isCapture: false });
|
||||
out.push({ pieceId, from, to: sq, isCapture: false });
|
||||
file += fileDelta;
|
||||
rank += rankDelta;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single-offset (non-sliding) cylinder move. Returns 0 or 1 legal
|
||||
* moves. Ranks out of bounds → nothing (cylinder, not torus).
|
||||
*/
|
||||
function offsetMove(
|
||||
session: Session,
|
||||
pieceId: EntityId,
|
||||
from: Square,
|
||||
color: PieceColor,
|
||||
fileDelta: number,
|
||||
rankDelta: number,
|
||||
): LegalMove | null {
|
||||
const rank = rankOf(from) + rankDelta;
|
||||
if (rank < 0 || rank > 7) return null;
|
||||
const file = wrapFile(fileOf(from) + fileDelta);
|
||||
const sq = squareOf(file, rank);
|
||||
if (sq === from) return null;
|
||||
if (isAllyAt(session, sq, color)) return null;
|
||||
return {
|
||||
pieceId,
|
||||
from,
|
||||
to: sq,
|
||||
isCapture: isPieceAt(session, sq),
|
||||
};
|
||||
}
|
||||
|
||||
/** Knight L-shape offsets. */
|
||||
const KNIGHT_OFFSETS: ReadonlyArray<readonly [number, number]> = [
|
||||
[1, 2], [2, 1], [2, -1], [1, -2],
|
||||
[-1, -2], [-2, -1], [-2, 1], [-1, 2],
|
||||
];
|
||||
|
||||
/** King 8-neighbour offsets. */
|
||||
const KING_OFFSETS: ReadonlyArray<readonly [number, number]> = [
|
||||
[1, 0], [1, 1], [0, 1], [-1, 1],
|
||||
[-1, 0], [-1, -1], [0, -1], [1, -1],
|
||||
];
|
||||
|
||||
/** Rook ray directions. */
|
||||
const ROOK_RAYS: ReadonlyArray<readonly [number, number]> = [
|
||||
[1, 0], [-1, 0], [0, 1], [0, -1],
|
||||
];
|
||||
|
||||
/** Bishop ray directions. */
|
||||
const BISHOP_RAYS: ReadonlyArray<readonly [number, number]> = [
|
||||
[1, 1], [1, -1], [-1, 1], [-1, -1],
|
||||
];
|
||||
|
||||
/** Per-type cylinder-aware move generation. */
|
||||
function computeCylinderMoves(
|
||||
session: Session,
|
||||
pieceId: EntityId,
|
||||
from: Square,
|
||||
color: PieceColor,
|
||||
type: PieceType,
|
||||
): LegalMove[] {
|
||||
switch (type) {
|
||||
case "knight": {
|
||||
const out: LegalMove[] = [];
|
||||
for (const [df, dr] of KNIGHT_OFFSETS) {
|
||||
const m = offsetMove(session, pieceId, from, color, df, dr);
|
||||
if (m !== null) out.push(m);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
case "king": {
|
||||
const out: LegalMove[] = [];
|
||||
for (const [df, dr] of KING_OFFSETS) {
|
||||
const m = offsetMove(session, pieceId, from, color, df, dr);
|
||||
if (m !== null) out.push(m);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
case "rook": {
|
||||
const out: LegalMove[] = [];
|
||||
for (const [df, dr] of ROOK_RAYS) {
|
||||
out.push(...walkRay(session, pieceId, from, color, df, dr));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
case "bishop": {
|
||||
const out: LegalMove[] = [];
|
||||
for (const [df, dr] of BISHOP_RAYS) {
|
||||
out.push(...walkRay(session, pieceId, from, color, df, dr));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
case "queen": {
|
||||
const out: LegalMove[] = [];
|
||||
for (const [df, dr] of [...ROOK_RAYS, ...BISHOP_RAYS]) {
|
||||
out.push(...walkRay(session, pieceId, from, color, df, dr));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
case "pawn": {
|
||||
// Only diagonal CAPTURES wrap — straight advance never crosses
|
||||
// the file seam. Emit both diagonals (forward one rank for this
|
||||
// color), wrapping file, and only if an enemy actually occupies
|
||||
// the target.
|
||||
const dr = color === "white" ? 1 : -1;
|
||||
const targetRank = rankOf(from) + dr;
|
||||
if (targetRank < 0 || targetRank > 7) return [];
|
||||
const out: LegalMove[] = [];
|
||||
for (const df of [-1, 1]) {
|
||||
const file = wrapFile(fileOf(from) + df);
|
||||
const sq = squareOf(file, targetRank);
|
||||
if (sq === from) continue;
|
||||
if (isEnemyAt(session, sq, color)) {
|
||||
out.push({ pieceId, from, to: sq, isCapture: true });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
return moves;
|
||||
}
|
||||
|
||||
PRESET_REGISTRY.register({
|
||||
id: "wrap-board",
|
||||
name: "Cylindrical Board",
|
||||
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.",
|
||||
"The board wraps horizontally: every piece (knights, kings, bishops, queens, rooks, and pawn captures) can move off one side and appear on the other.",
|
||||
incompatibleWith: ["rook-warp"],
|
||||
requires: [],
|
||||
getExtraMoves: (engine, pieceId) => {
|
||||
|
|
@ -101,28 +213,25 @@ PRESET_REGISTRY.register({
|
|||
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(
|
||||
const fromVal = 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);
|
||||
if (type === undefined || color === undefined || fromVal === undefined) {
|
||||
return [];
|
||||
}
|
||||
return [...byTarget.values()];
|
||||
const from = fromVal as Square;
|
||||
const session = engine.session;
|
||||
|
||||
// Return the full cylinder-aware move set. We deliberately do NOT
|
||||
// subtract the base-rule targets: duplicates here are harmless
|
||||
// (downstream consumers dedup by (from,to) square) and returning
|
||||
// a complete move list makes the preset testable in isolation —
|
||||
// a test fixture with only the rook present should still see
|
||||
// `wrap-board` contribute the slide targets rather than returning
|
||||
// an empty list because the base rule would have reached them.
|
||||
return computeCylinderMoves(session, pieceId, from, color, type);
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -324,7 +324,17 @@ export function Piece({
|
|||
>
|
||||
<img
|
||||
src={imgSrc}
|
||||
alt={`${color} ${type}`}
|
||||
// Empty alt + `decoding=sync` eliminate the text-flash
|
||||
// between unmount at the old square and mount at the new
|
||||
// one. Previously the freshly-attached <img> briefly
|
||||
// displayed its `alt` string (e.g. "white knight") for the
|
||||
// frame it took the browser to decode the SVG. With the
|
||||
// images preloaded at module init (see ./assets/pieces)
|
||||
// and sync decoding requested here, the first painted
|
||||
// frame always has the piece bitmap.
|
||||
alt=""
|
||||
aria-label={`${color} ${type}`}
|
||||
decoding="sync"
|
||||
className={`w-[85%] h-[85%] pointer-events-none transition-[filter] duration-200 ${
|
||||
isDragging
|
||||
? 'drop-shadow-[0_12px_16px_rgba(0,0,0,0.45)]'
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue