feat(ui): reactive game store wrapping the engine
This commit is contained in:
@@ -0,0 +1,164 @@
|
|||||||
|
import { DuplicateGame } from '../../engine/game';
|
||||||
|
import { legalSyncedMoves, selectionHighlight, type SelectionHighlight } from '../../engine/legality';
|
||||||
|
import { ghosts } from '../../engine/ghosts';
|
||||||
|
import { evaluateStatus } from '../../engine/endgame';
|
||||||
|
import { serialize, deserialize } from '../../engine/notation';
|
||||||
|
import { PLAYER_BOARDS } from '../../engine/boards';
|
||||||
|
import type {
|
||||||
|
BoardId, Player, Square, SyncMove, HistoryEntry, GhostMarker, GameStatus,
|
||||||
|
} from '../../engine/types';
|
||||||
|
|
||||||
|
/** A plain, reactivity-friendly snapshot of everything the UI renders. */
|
||||||
|
export interface GameView {
|
||||||
|
/** Piece-placement FEN field per board. */
|
||||||
|
fen: Record<BoardId, string>;
|
||||||
|
currentPlayer: Player;
|
||||||
|
ply: number;
|
||||||
|
ghosts: GhostMarker[];
|
||||||
|
status: GameStatus;
|
||||||
|
history: HistoryEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildView(game: DuplicateGame): GameView {
|
||||||
|
return {
|
||||||
|
fen: {
|
||||||
|
NW: game.boards.NW.fen(),
|
||||||
|
NE: game.boards.NE.fen(),
|
||||||
|
SW: game.boards.SW.fen(),
|
||||||
|
SE: game.boards.SE.fen(),
|
||||||
|
},
|
||||||
|
currentPlayer: game.currentPlayer,
|
||||||
|
ply: game.ply,
|
||||||
|
ghosts: ghosts(game),
|
||||||
|
status: evaluateStatus(game),
|
||||||
|
history: [...game.history],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
class GameStore {
|
||||||
|
/** The authoritative live game — deliberately NOT a $state proxy. */
|
||||||
|
#game = new DuplicateGame();
|
||||||
|
|
||||||
|
/** Snapshot the UI renders. While scrubbing it reflects a past ply. */
|
||||||
|
view = $state<GameView>(buildView(this.#game));
|
||||||
|
/** The grabbed square, or null. */
|
||||||
|
selected = $state<Square | null>(null);
|
||||||
|
/** Triple-highlight for the grabbed piece, or null. */
|
||||||
|
highlight = $state<SelectionHighlight | null>(null);
|
||||||
|
/** A pawn move awaiting a promotion choice, or null. */
|
||||||
|
pendingPromotion = $state<{ from: Square; to: Square } | null>(null);
|
||||||
|
/** Ply currently being viewed; null means the live position. */
|
||||||
|
scrubPly = $state<number | null>(null);
|
||||||
|
|
||||||
|
get isScrubbing(): boolean {
|
||||||
|
return this.scrubPly !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Which boards belong to the player to move (for the turn glow). */
|
||||||
|
get activeBoards(): [BoardId, BoardId] {
|
||||||
|
return PLAYER_BOARDS[this.#game.currentPlayer];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Grab a piece: must be the current player's turn and a live (non-scrub) view. */
|
||||||
|
select(square: Square): void {
|
||||||
|
if (this.isScrubbing) return;
|
||||||
|
if (this.selected === square) { this.clearSelection(); return; }
|
||||||
|
this.selected = square;
|
||||||
|
this.highlight = selectionHighlight(this.#game, square);
|
||||||
|
}
|
||||||
|
|
||||||
|
clearSelection(): void {
|
||||||
|
this.selected = null;
|
||||||
|
this.highlight = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Attempt to play the grabbed piece to `to`. Opens the promotion dialog if needed. */
|
||||||
|
commitTo(to: Square): void {
|
||||||
|
const from = this.selected;
|
||||||
|
if (from === null || this.highlight === null) return;
|
||||||
|
if (!this.highlight.playable.includes(to)) return; // not a synchronized-legal square
|
||||||
|
|
||||||
|
const moves = legalSyncedMoves(this.#game).filter((m) => m.from === from && m.to === to);
|
||||||
|
if (moves.length === 0) return;
|
||||||
|
if (moves.some((m) => m.promotion)) {
|
||||||
|
this.pendingPromotion = { from, to };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.#apply(moves[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Finish a promotion started by commitTo. */
|
||||||
|
choosePromotion(piece: SyncMove['promotion']): void {
|
||||||
|
if (this.pendingPromotion === null) return;
|
||||||
|
this.#apply({ ...this.pendingPromotion, promotion: piece });
|
||||||
|
this.pendingPromotion = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelPromotion(): void {
|
||||||
|
this.pendingPromotion = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
#apply(move: SyncMove): void {
|
||||||
|
this.#game.applyMove(move);
|
||||||
|
this.clearSelection();
|
||||||
|
this.scrubPly = null;
|
||||||
|
this.view = buildView(this.#game);
|
||||||
|
}
|
||||||
|
|
||||||
|
undo(): void {
|
||||||
|
this.#game.undo();
|
||||||
|
this.clearSelection();
|
||||||
|
this.scrubPly = null;
|
||||||
|
this.view = buildView(this.#game);
|
||||||
|
}
|
||||||
|
|
||||||
|
newGame(): void {
|
||||||
|
this.#game = new DuplicateGame();
|
||||||
|
this.clearSelection();
|
||||||
|
this.pendingPromotion = null;
|
||||||
|
this.scrubPly = null;
|
||||||
|
this.view = buildView(this.#game);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Scrub the move history; null returns to the live position. */
|
||||||
|
scrubTo(ply: number | null): void {
|
||||||
|
this.clearSelection();
|
||||||
|
if (ply === null || ply >= this.#game.ply) {
|
||||||
|
this.scrubPly = null;
|
||||||
|
this.view = buildView(this.#game);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.scrubPly = ply;
|
||||||
|
this.view = buildView(new DuplicateGame(this.#game.history.slice(0, ply)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Manually declare a draw (provisional: insufficient material is not auto-detected). */
|
||||||
|
declareDraw(): void {
|
||||||
|
this.view = {
|
||||||
|
...this.view,
|
||||||
|
status: { state: 'draw', reason: 'manual', checks: [],
|
||||||
|
result: { N: 'draw', S: 'draw', E: 'draw', W: 'draw' } },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
save(): void {
|
||||||
|
const blob = new Blob([serialize(this.#game.history)], { type: 'application/json' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `duplicate-chess-${Date.now()}.json`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
async load(file: File): Promise<void> {
|
||||||
|
const history = deserialize(await file.text());
|
||||||
|
this.#game = new DuplicateGame(history);
|
||||||
|
this.clearSelection();
|
||||||
|
this.pendingPromotion = null;
|
||||||
|
this.scrubPly = null;
|
||||||
|
this.view = buildView(this.#game);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const gameStore = new GameStore();
|
||||||
Reference in New Issue
Block a user