diff --git a/src/components/beach-chess/BeachChess.tsx b/src/components/beach-chess/BeachChess.tsx new file mode 100644 index 000000000..d73accf95 --- /dev/null +++ b/src/components/beach-chess/BeachChess.tsx @@ -0,0 +1,608 @@ +// Beach Chess (/beach-chess) โ€” a day-at-the-beach chess set where you (the +// sunny daytime crew) play against the computer (the sunset tiki crew). +// You pick your difficulty (how rough the surf gets) and the "master" title +// you're playing under before the first coconut rolls. + +import { useCallback, useEffect, useMemo, useState } from 'react' +import { Board } from './Board' +import { + applyMove, + chooseAiMove, + createInitialPosition, + findKing, + getGameStatus, + legalMovesFrom, +} from './engine' +import { + BEACH_PIECES, + DIFFICULTIES, + MASTERS, + getDifficulty, + getMaster, +} from './theme' +import type { GameStatus, Move, PieceType, Position } from './engine' +import type { DifficultyId, MasterId } from './theme' + +type Phase = 'setup' | 'playing' + +interface HistoryEntry { + position: Position + move: Move +} + +interface PendingPromotion { + from: number + to: number +} + +const PROMO_CHOICES: Array = ['q', 'r', 'b', 'n'] + +export default function BeachChess() { + const [phase, setPhase] = useState('setup') + const [difficultyId, setDifficultyId] = useState('breezy') + const [masterId, setMasterId] = useState('novice') + + const [position, setPosition] = useState(createInitialPosition) + const [history, setHistory] = useState>([]) + const [selected, setSelected] = useState(null) + const [lastMove, setLastMove] = useState(null) + const [thinking, setThinking] = useState(false) + const [pendingPromotion, setPendingPromotion] = + useState(null) + + const difficulty = getDifficulty(difficultyId) + const master = getMaster(masterId) + + const status: GameStatus = useMemo(() => getGameStatus(position), [position]) + const gameOver = + status === 'checkmate' || status === 'stalemate' || status === 'draw' + + const legalTargets = useMemo( + () => (selected === null ? [] : legalMovesFrom(position, selected)), + [position, selected], + ) + + const checkSquare = useMemo(() => { + if (status !== 'check' && status !== 'checkmate') return null + return findKing(position.board, position.turn) + }, [position, status]) + + // Captured pieces, derived from the move history. + const captured = useMemo(() => { + const out: Record<'w' | 'b', Array> = { w: [], b: [] } + for (const { move } of history) { + if (move.captured) { + out[move.captured.color].push(move.captured.type) + } + } + return out + }, [history]) + + const commitMove = useCallback((prev: Position, move: Move) => { + setHistory((h) => [...h, { position: prev, move }]) + setPosition(applyMove(prev, move)) + setLastMove(move) + setSelected(null) + }, []) + + // Drive the computer (black) whenever it's its turn and the game is live. + useEffect(() => { + if (phase !== 'playing') return + if (position.turn !== 'b') return + if (gameOver) return + + let cancelled = false + setThinking(true) + // Defer so the player's move paints before we run the (blocking) search. + const timer = setTimeout(() => { + const move = chooseAiMove(position, difficulty.ai) + if (cancelled) return + setThinking(false) + if (move) commitMove(position, move) + }, 220) + + return () => { + cancelled = true + clearTimeout(timer) + } + }, [phase, position, gameOver, difficulty.ai, commitMove]) + + const handleSquareClick = useCallback( + (index: number) => { + if (phase !== 'playing' || gameOver || thinking) return + if (position.turn !== 'w') return // not the player's turn + + const piece = position.board[index] + + // Re-select one of your own pieces. + if (piece && piece.color === 'w') { + setSelected(index === selected ? null : index) + return + } + + if (selected === null) return + + const moves = legalMovesFrom(position, selected).filter( + (m) => m.to === index, + ) + if (moves.length === 0) { + setSelected(null) + return + } + + // A promotion offers four target pieces for the same square. + if (moves.length > 1 && moves.every((m) => m.promotion)) { + setPendingPromotion({ from: selected, to: index }) + return + } + + commitMove(position, moves[0]) + }, + [phase, gameOver, thinking, position, selected, commitMove], + ) + + const choosePromotion = useCallback( + (promotion: PieceType) => { + if (!pendingPromotion) return + const move = legalMovesFrom(position, pendingPromotion.from).find( + (m) => m.to === pendingPromotion.to && m.promotion === promotion, + ) + setPendingPromotion(null) + if (move) commitMove(position, move) + }, + [pendingPromotion, position, commitMove], + ) + + const startGame = useCallback(() => { + setPosition(createInitialPosition()) + setHistory([]) + setSelected(null) + setLastMove(null) + setThinking(false) + setPendingPromotion(null) + setPhase('playing') + }, []) + + const resetToSetup = useCallback(() => { + setPhase('setup') + setThinking(false) + setPendingPromotion(null) + }, []) + + // Undo takes back the last full round (your move + the computer's reply). + const undo = useCallback(() => { + if (thinking) return + setHistory((h) => { + if (h.length === 0) return h + // Walk back to the most recent position where it was the player's turn. + let next = [...h] + let restored = next[next.length - 1].position + next = next.slice(0, -1) + if (restored.turn !== 'w' && next.length > 0) { + restored = next[next.length - 1].position + next = next.slice(0, -1) + } + setPosition(restored) + setLastMove(next.length > 0 ? next[next.length - 1].move : null) + setSelected(null) + setPendingPromotion(null) + return next + }) + }, [thinking]) + + if (phase === 'setup') { + return ( + + ) + } + + return ( +
+
+
+ {/* Board column */} +
+ + + +
+ + {/* Side panel */} + +
+
+ + {/* Promotion picker */} + {pendingPromotion && ( + +
+

+ Promote your coconut! +

+

+ It reached the far shore โ€” pick its new form. +

+
+ {PROMO_CHOICES.map((type) => ( + + ))} +
+
+
+ )} + + {/* Game-over result */} + {gameOver && ( + + )} +
+ ) +} + +// --- Setup screen ---------------------------------------------------------- + +interface SetupScreenProps { + difficultyId: DifficultyId + masterId: MasterId + onDifficulty: (id: DifficultyId) => void + onMaster: (id: MasterId) => void + onStart: () => void +} + +function SetupScreen({ + difficultyId, + masterId, + onDifficulty, + onMaster, + onStart, +}: SetupScreenProps) { + return ( +
+
+
+
๐Ÿ–๏ธโ™Ÿ๏ธ๐ŸŒด
+

+ Beach Chess +

+

+ Coconuts, dolphins and bikini queens take on the tiki crew. Set the + surf, claim your title, and play the computer โ€” move the sunny crew + up the beach. +

+
+ +
+

+ How rough is the surf? (difficulty) +

+
+ {DIFFICULTIES.map((d) => { + const active = d.id === difficultyId + return ( + + ) + })} +
+
+ +
+

+ What kind of master are you? +

+
+ {MASTERS.map((m) => { + const active = m.id === masterId + return ( + + ) + })} +
+
+ +
+ +
+
+
+ ) +} + +// --- Supporting UI --------------------------------------------------------- + +function RivalBar({ + label, + sublabel, + captured, + side, + thinking, +}: { + label: string + sublabel: string + captured: Array + side: 'w' | 'b' + thinking: boolean +}) { + return ( +
+
+
+ {label} + {thinking && ( + + reading the wavesโ€ฆ + + )} +
+
+ {sublabel} +
+
+
+ {captured.map((type, i) => ( + + {BEACH_PIECES[side][type].emoji} + + ))} +
+
+ ) +} + +function StatusCard({ + status, + turn, + thinking, + master, +}: { + status: GameStatus + turn: 'w' | 'b' + thinking: boolean + master: ReturnType +}) { + let headline: string + let tone = 'text-slate-700 dark:text-slate-200' + + if (status === 'checkmate') { + const youWon = turn === 'b' + headline = youWon ? 'Checkmate โ€” you win! ๐Ÿ†' : 'Checkmate โ€” tide wins ๐ŸŒŠ' + tone = youWon ? 'text-teal-600' : 'text-red-500' + } else if (status === 'stalemate') { + headline = 'Stalemate โ€” dead calm' + } else if (status === 'draw') { + headline = 'Draw โ€” not enough crew left' + } else if (status === 'check') { + headline = turn === 'w' ? 'Youโ€™re in check! ๐ŸŒŠ' : 'Tiki king in check!' + tone = 'text-amber-600' + } else if (thinking) { + headline = 'Tiki crew is plottingโ€ฆ' + } else { + headline = turn === 'w' ? 'Your move' : 'Tiki crewโ€™s move' + } + + return ( +
+
+ {master.emoji} {master.title} +
+
{headline}
+
+ ) +} + +function Legend() { + return ( +
+
+ Your crew +
+
+ {(['k', 'q', 'r', 'b', 'n', 'p'] as Array).map((t) => ( +
+ {BEACH_PIECES.w[t].emoji} + + {BEACH_PIECES.w[t].label.replace(/ \(.*\)/, '')} + +
+ ))} +
+
+ ) +} + +function Overlay({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ) +} + +function ResultOverlay({ + status, + turn, + master, + onPlayAgain, + onNewSetup, +}: { + status: GameStatus + turn: 'w' | 'b' + master: ReturnType + onPlayAgain: () => void + onNewSetup: () => void +}) { + const youWon = status === 'checkmate' && turn === 'b' + const youLost = status === 'checkmate' && turn === 'w' + + let emoji = '๐Ÿค' + let title = 'Dead calm' + let message = 'A drawn game โ€” the tide refused to break either way.' + + if (youWon) { + emoji = '๐Ÿ†' + title = 'You rule the beach!' + message = `Checkmate. ${master.title} sends the tiki crew packing.` + } else if (youLost) { + emoji = '๐ŸŒŠ' + title = 'Swept out to sea' + message = 'The tiki crew found checkmate. Regroup and ride again.' + } else if (status === 'stalemate') { + emoji = '๐Ÿ˜ฎโ€๐Ÿ’จ' + title = 'Stalemate' + message = 'No legal moves left, but no one is in check. Dead calm.' + } + + return ( + +
+
{emoji}
+

{title}

+

{message}

+
+ + +
+
+
+ ) +} diff --git a/src/components/beach-chess/Board.tsx b/src/components/beach-chess/Board.tsx new file mode 100644 index 000000000..a2fe2e81b --- /dev/null +++ b/src/components/beach-chess/Board.tsx @@ -0,0 +1,115 @@ +// The 8x8 Beach Chess board. Pure presentation: it renders squares and pieces +// and reports clicks; all rules and state live in BeachChess.tsx. + +import { fileOf, rankOf } from './engine' +import { BEACH_PIECES } from './theme' +import type { Board as BoardType, Move } from './engine' + +interface BoardProps { + board: BoardType + selected: number | null + legalTargets: Array + lastMove: Move | null + checkSquare: number | null + onSquareClick: (index: number) => void + disabled: boolean +} + +const FILES = 'abcdefgh' + +export function Board({ + board, + selected, + legalTargets, + lastMove, + checkSquare, + onSquareClick, + disabled, +}: BoardProps) { + const targetSet = new Set(legalTargets.map((m) => m.to)) + + return ( +
+
+ {board.map((piece, index) => { + const file = fileOf(index) + const rank = rankOf(index) + const isLight = (file + rank) % 2 === 0 + const isSelected = selected === index + const isTarget = targetSet.has(index) + const isCaptureTarget = isTarget && piece !== null + const isLastFrom = lastMove?.from === index + const isLastTo = lastMove?.to === index + const isCheck = checkSquare === index + + // Sand for light squares, turquoise shallows for dark squares. + const base = isLight + ? 'bg-[#f7e9c6] dark:bg-[#e9d6a8]' + : 'bg-[#6fc2c0] dark:bg-[#4fa3a6]' + + return ( + + ) + })} +
+
+ ) +} diff --git a/src/components/beach-chess/engine.ts b/src/components/beach-chess/engine.ts new file mode 100644 index 000000000..f05ed9bca --- /dev/null +++ b/src/components/beach-chess/engine.ts @@ -0,0 +1,727 @@ +// A small, self-contained chess engine for Beach Chess (/beach-chess). +// +// Board layout: a flat 64-element array. Index = rank * 8 + file, where +// rank 0 is the 8th rank (top of the screen, the computer's back rank) and +// rank 7 is the 1st rank (bottom, the player's back rank). file 0 = a-file. +// White (the player) sits at the bottom and moves toward lower indices. + +export type Color = 'w' | 'b' +export type PieceType = 'p' | 'n' | 'b' | 'r' | 'q' | 'k' + +export interface Piece { + type: PieceType + color: Color +} + +export type Square = Piece | null +export type Board = Array + +export interface CastlingRights { + wK: boolean + wQ: boolean + bK: boolean + bQ: boolean +} + +export interface Position { + board: Board + turn: Color + castling: CastlingRights + /** Target square an en-passant capture would land on, or null. */ + epSquare: number | null +} + +export interface Move { + from: number + to: number + /** Promotion piece type when a pawn reaches the last rank. */ + promotion?: PieceType + /** 'K' for king-side, 'Q' for queen-side; present only for castling moves. */ + castle?: 'K' | 'Q' + /** True when this move is an en-passant capture. */ + enPassant?: boolean + /** The piece removed by this move (for capture, incl. en passant). */ + captured: Square +} + +export type GameStatus = + | 'playing' + | 'check' + | 'checkmate' + | 'stalemate' + | 'draw' + +export function otherColor(color: Color): Color { + return color === 'w' ? 'b' : 'w' +} + +export function fileOf(index: number): number { + return index % 8 +} + +export function rankOf(index: number): number { + return Math.floor(index / 8) +} + +export function squareName(index: number): string { + const files = 'abcdefgh' + const file = files[fileOf(index)] + const rank = 8 - rankOf(index) + return `${file}${rank}` +} + +/** The standard opening position with the player (white) at the bottom. */ +export function createInitialPosition(): Position { + const board: Board = new Array(64).fill(null) + + const backRank: Array = ['r', 'n', 'b', 'q', 'k', 'b', 'n', 'r'] + + for (let file = 0; file < 8; file++) { + board[0 * 8 + file] = { type: backRank[file], color: 'b' } // rank 8 + board[1 * 8 + file] = { type: 'p', color: 'b' } // rank 7 + board[6 * 8 + file] = { type: 'p', color: 'w' } // rank 2 + board[7 * 8 + file] = { type: backRank[file], color: 'w' } // rank 1 + } + + return { + board, + turn: 'w', + castling: { wK: true, wQ: true, bK: true, bQ: true }, + epSquare: null, + } +} + +function cloneBoard(board: Board): Board { + // Pieces are immutable in practice; a shallow copy of the array is enough + // because we always replace slots rather than mutating a Piece object. + return board.slice() +} + +// --- Move generation ------------------------------------------------------- + +const KNIGHT_DELTAS: Array<[number, number]> = [ + [1, 2], + [2, 1], + [2, -1], + [1, -2], + [-1, -2], + [-2, -1], + [-2, 1], + [-1, 2], +] + +const KING_DELTAS: Array<[number, number]> = [ + [1, 0], + [1, 1], + [0, 1], + [-1, 1], + [-1, 0], + [-1, -1], + [0, -1], + [1, -1], +] + +const BISHOP_DIRS: Array<[number, number]> = [ + [1, 1], + [1, -1], + [-1, 1], + [-1, -1], +] + +const ROOK_DIRS: Array<[number, number]> = [ + [1, 0], + [-1, 0], + [0, 1], + [0, -1], +] + +function onBoard(file: number, rank: number): boolean { + return file >= 0 && file < 8 && rank >= 0 && rank < 8 +} + +function indexFrom(file: number, rank: number): number { + return rank * 8 + file +} + +/** + * Is `square` attacked by any piece of color `by` in `board`? + * Used for check detection and castling legality. + */ +export function isSquareAttacked( + board: Board, + square: number, + by: Color, +): boolean { + const file = fileOf(square) + const rank = rankOf(square) + + // Pawn attacks. A `by`-colored pawn attacks `square` if it sits one rank + // "behind" (in its forward direction) on a diagonal. + // White moves toward lower ranks (up), so white pawns attacking `square` + // are on rank+1; black pawns are on rank-1. + const pawnRank = by === 'w' ? rank + 1 : rank - 1 + for (const df of [-1, 1]) { + const f = file + df + if (onBoard(f, pawnRank)) { + const p = board[indexFrom(f, pawnRank)] + if (p && p.color === by && p.type === 'p') return true + } + } + + // Knight attacks. + for (const [df, dr] of KNIGHT_DELTAS) { + const f = file + df + const r = rank + dr + if (onBoard(f, r)) { + const p = board[indexFrom(f, r)] + if (p && p.color === by && p.type === 'n') return true + } + } + + // King attacks. + for (const [df, dr] of KING_DELTAS) { + const f = file + df + const r = rank + dr + if (onBoard(f, r)) { + const p = board[indexFrom(f, r)] + if (p && p.color === by && p.type === 'k') return true + } + } + + // Sliding attacks: bishops/queens on diagonals. + for (const [df, dr] of BISHOP_DIRS) { + let f = file + df + let r = rank + dr + while (onBoard(f, r)) { + const p = board[indexFrom(f, r)] + if (p) { + if (p.color === by && (p.type === 'b' || p.type === 'q')) return true + break + } + f += df + r += dr + } + } + + // Sliding attacks: rooks/queens on ranks and files. + for (const [df, dr] of ROOK_DIRS) { + let f = file + df + let r = rank + dr + while (onBoard(f, r)) { + const p = board[indexFrom(f, r)] + if (p) { + if (p.color === by && (p.type === 'r' || p.type === 'q')) return true + break + } + f += df + r += dr + } + } + + return false +} + +export function findKing(board: Board, color: Color): number { + for (let i = 0; i < 64; i++) { + const p = board[i] + if (p && p.type === 'k' && p.color === color) return i + } + return -1 +} + +export function isInCheck(board: Board, color: Color): boolean { + const king = findKing(board, color) + if (king === -1) return false + return isSquareAttacked(board, king, otherColor(color)) +} + +function addPawnMoves( + position: Position, + from: number, + moves: Array, +): void { + const { board } = position + const piece = board[from]! + const color = piece.color + const file = fileOf(from) + const rank = rankOf(from) + const dir = color === 'w' ? -1 : 1 // white moves toward rank 0 (up) + const startRank = color === 'w' ? 6 : 1 + const promoRank = color === 'w' ? 0 : 7 + + const pushPawnMove = (to: number, captured: Square, enPassant = false) => { + if (rankOf(to) === promoRank) { + for (const promotion of ['q', 'r', 'b', 'n'] as Array) { + moves.push({ from, to, promotion, captured }) + } + } else { + moves.push({ from, to, captured, enPassant: enPassant || undefined }) + } + } + + // Single push. + const oneRank = rank + dir + if (onBoard(file, oneRank)) { + const one = indexFrom(file, oneRank) + if (!board[one]) { + pushPawnMove(one, null) + // Double push from the starting rank. + if (rank === startRank) { + const two = indexFrom(file, rank + 2 * dir) + if (!board[two]) { + moves.push({ from, to: two, captured: null }) + } + } + } + } + + // Captures (including en passant). + for (const df of [-1, 1]) { + const f = file + df + const r = rank + dir + if (!onBoard(f, r)) continue + const to = indexFrom(f, r) + const target = board[to] + if (target && target.color !== color) { + pushPawnMove(to, target) + } else if (to === position.epSquare) { + // En passant: the captured pawn sits beside the moving pawn. + const capturedIndex = indexFrom(f, rank) + pushPawnMove(to, board[capturedIndex], true) + } + } +} + +function addStepMoves( + board: Board, + from: number, + deltas: Array<[number, number]>, + moves: Array, +): void { + const piece = board[from]! + const file = fileOf(from) + const rank = rankOf(from) + for (const [df, dr] of deltas) { + const f = file + df + const r = rank + dr + if (!onBoard(f, r)) continue + const to = indexFrom(f, r) + const target = board[to] + if (!target || target.color !== piece.color) { + moves.push({ from, to, captured: target ?? null }) + } + } +} + +function addSlidingMoves( + board: Board, + from: number, + dirs: Array<[number, number]>, + moves: Array, +): void { + const piece = board[from]! + const file = fileOf(from) + const rank = rankOf(from) + for (const [df, dr] of dirs) { + let f = file + df + let r = rank + dr + while (onBoard(f, r)) { + const to = indexFrom(f, r) + const target = board[to] + if (!target) { + moves.push({ from, to, captured: null }) + } else { + if (target.color !== piece.color) { + moves.push({ from, to, captured: target }) + } + break + } + f += df + r += dr + } + } +} + +function addCastlingMoves( + position: Position, + from: number, + moves: Array, +): void { + const { board, castling } = position + const piece = board[from]! + const color = piece.color + const enemy = otherColor(color) + + // King must not currently be in check. + if (isSquareAttacked(board, from, enemy)) return + + const backRank = color === 'w' ? 7 : 0 + const kingHome = indexFrom(4, backRank) + if (from !== kingHome) return + + const canK = color === 'w' ? castling.wK : castling.bK + const canQ = color === 'w' ? castling.wQ : castling.bQ + + // King-side: squares f and g must be empty and unattacked; rook on h. + if (canK) { + const f = indexFrom(5, backRank) + const g = indexFrom(6, backRank) + const rookSq = indexFrom(7, backRank) + const rook = board[rookSq] + if ( + !board[f] && + !board[g] && + rook && + rook.type === 'r' && + rook.color === color && + !isSquareAttacked(board, f, enemy) && + !isSquareAttacked(board, g, enemy) + ) { + moves.push({ from, to: g, castle: 'K', captured: null }) + } + } + + // Queen-side: squares b, c, d empty; c and d unattacked; rook on a. + if (canQ) { + const d = indexFrom(3, backRank) + const c = indexFrom(2, backRank) + const b = indexFrom(1, backRank) + const rookSq = indexFrom(0, backRank) + const rook = board[rookSq] + if ( + !board[d] && + !board[c] && + !board[b] && + rook && + rook.type === 'r' && + rook.color === color && + !isSquareAttacked(board, d, enemy) && + !isSquareAttacked(board, c, enemy) + ) { + moves.push({ from, to: c, castle: 'Q', captured: null }) + } + } +} + +/** All pseudo-legal moves for the side to move (may leave the king in check). */ +function generatePseudoLegalMoves(position: Position): Array { + const { board, turn } = position + const moves: Array = [] + for (let i = 0; i < 64; i++) { + const piece = board[i] + if (!piece || piece.color !== turn) continue + switch (piece.type) { + case 'p': + addPawnMoves(position, i, moves) + break + case 'n': + addStepMoves(board, i, KNIGHT_DELTAS, moves) + break + case 'b': + addSlidingMoves(board, i, BISHOP_DIRS, moves) + break + case 'r': + addSlidingMoves(board, i, ROOK_DIRS, moves) + break + case 'q': + addSlidingMoves(board, i, [...BISHOP_DIRS, ...ROOK_DIRS], moves) + break + case 'k': + addStepMoves(board, i, KING_DELTAS, moves) + addCastlingMoves(position, i, moves) + break + } + } + return moves +} + +/** + * Apply a move and return the resulting position. Does not mutate the input. + */ +export function applyMove(position: Position, move: Move): Position { + const board = cloneBoard(position.board) + const piece = board[move.from]! + const color = piece.color + + // Move the piece. + board[move.from] = null + board[move.to] = move.promotion ? { type: move.promotion, color } : piece + + // En passant: remove the pawn that was passed. + if (move.enPassant) { + const capturedIndex = indexFrom(fileOf(move.to), rankOf(move.from)) + board[capturedIndex] = null + } + + // Castling: move the rook too. + if (move.castle) { + const backRank = rankOf(move.to) + if (move.castle === 'K') { + board[indexFrom(5, backRank)] = board[indexFrom(7, backRank)] + board[indexFrom(7, backRank)] = null + } else { + board[indexFrom(3, backRank)] = board[indexFrom(0, backRank)] + board[indexFrom(0, backRank)] = null + } + } + + // Update castling rights. + const castling: CastlingRights = { ...position.castling } + if (piece.type === 'k') { + if (color === 'w') { + castling.wK = false + castling.wQ = false + } else { + castling.bK = false + castling.bQ = false + } + } + // Rook moved from or captured on a corner. + const clearRights = (sq: number) => { + if (sq === indexFrom(7, 7)) castling.wK = false + if (sq === indexFrom(0, 7)) castling.wQ = false + if (sq === indexFrom(7, 0)) castling.bK = false + if (sq === indexFrom(0, 0)) castling.bQ = false + } + clearRights(move.from) + clearRights(move.to) + + // New en-passant target: set only on a double pawn push. + let epSquare: number | null = null + if ( + piece.type === 'p' && + Math.abs(rankOf(move.to) - rankOf(move.from)) === 2 + ) { + epSquare = indexFrom( + fileOf(move.from), + (rankOf(move.from) + rankOf(move.to)) / 2, + ) + } + + return { + board, + turn: otherColor(color), + castling, + epSquare, + } +} + +/** Legal moves for the side to move (king safety enforced). */ +export function generateLegalMoves(position: Position): Array { + const pseudo = generatePseudoLegalMoves(position) + const legal: Array = [] + for (const move of pseudo) { + const next = applyMove(position, move) + if (!isInCheck(next.board, position.turn)) { + legal.push(move) + } + } + return legal +} + +export function legalMovesFrom(position: Position, from: number): Array { + return generateLegalMoves(position).filter((m) => m.from === from) +} + +export function getGameStatus(position: Position): GameStatus { + const legal = generateLegalMoves(position) + const inCheck = isInCheck(position.board, position.turn) + if (legal.length === 0) { + return inCheck ? 'checkmate' : 'stalemate' + } + // Draw by insufficient material (K vs K, K+minor vs K). + if (isInsufficientMaterial(position.board)) return 'draw' + return inCheck ? 'check' : 'playing' +} + +function isInsufficientMaterial(board: Board): boolean { + const pieces = board.filter(Boolean) as Array + if (pieces.length <= 2) return true // just the two kings + if (pieces.length === 3) { + // King + single knight or bishop vs king. + return pieces.some((p) => p.type === 'n' || p.type === 'b') + } + return false +} + +// --- Evaluation & AI ------------------------------------------------------- + +const PIECE_VALUES: Record = { + p: 100, + n: 320, + b: 330, + r: 500, + q: 900, + k: 20000, +} + +// Piece-square tables from white's perspective (index 0 = a8 ... 63 = h1). +// They nudge the engine toward sensible development and center control. +// prettier-ignore +const PST: Record> = { + p: [ + 0, 0, 0, 0, 0, 0, 0, 0, + 50, 50, 50, 50, 50, 50, 50, 50, + 10, 10, 20, 30, 30, 20, 10, 10, + 5, 5, 10, 25, 25, 10, 5, 5, + 0, 0, 0, 20, 20, 0, 0, 0, + 5, -5,-10, 0, 0,-10, -5, 5, + 5, 10, 10,-20,-20, 10, 10, 5, + 0, 0, 0, 0, 0, 0, 0, 0, + ], + n: [ + -50,-40,-30,-30,-30,-30,-40,-50, + -40,-20, 0, 0, 0, 0,-20,-40, + -30, 0, 10, 15, 15, 10, 0,-30, + -30, 5, 15, 20, 20, 15, 5,-30, + -30, 0, 15, 20, 20, 15, 0,-30, + -30, 5, 10, 15, 15, 10, 5,-30, + -40,-20, 0, 5, 5, 0,-20,-40, + -50,-40,-30,-30,-30,-30,-40,-50, + ], + b: [ + -20,-10,-10,-10,-10,-10,-10,-20, + -10, 0, 0, 0, 0, 0, 0,-10, + -10, 0, 5, 10, 10, 5, 0,-10, + -10, 5, 5, 10, 10, 5, 5,-10, + -10, 0, 10, 10, 10, 10, 0,-10, + -10, 10, 10, 10, 10, 10, 10,-10, + -10, 5, 0, 0, 0, 0, 5,-10, + -20,-10,-10,-10,-10,-10,-10,-20, + ], + r: [ + 0, 0, 0, 0, 0, 0, 0, 0, + 5, 10, 10, 10, 10, 10, 10, 5, + -5, 0, 0, 0, 0, 0, 0, -5, + -5, 0, 0, 0, 0, 0, 0, -5, + -5, 0, 0, 0, 0, 0, 0, -5, + -5, 0, 0, 0, 0, 0, 0, -5, + -5, 0, 0, 0, 0, 0, 0, -5, + 0, 0, 0, 5, 5, 0, 0, 0, + ], + q: [ + -20,-10,-10, -5, -5,-10,-10,-20, + -10, 0, 0, 0, 0, 0, 0,-10, + -10, 0, 5, 5, 5, 5, 0,-10, + -5, 0, 5, 5, 5, 5, 0, -5, + 0, 0, 5, 5, 5, 5, 0, -5, + -10, 5, 5, 5, 5, 5, 0,-10, + -10, 0, 5, 0, 0, 0, 0,-10, + -20,-10,-10, -5, -5,-10,-10,-20, + ], + k: [ + -30,-40,-40,-50,-50,-40,-40,-30, + -30,-40,-40,-50,-50,-40,-40,-30, + -30,-40,-40,-50,-50,-40,-40,-30, + -30,-40,-40,-50,-50,-40,-40,-30, + -20,-30,-30,-40,-40,-30,-30,-20, + -10,-20,-20,-20,-20,-20,-20,-10, + 20, 20, 0, 0, 0, 0, 20, 20, + 20, 30, 10, 0, 0, 10, 30, 20, + ], +} + +/** Static evaluation in centipawns, from the perspective of `color`. */ +function evaluate(board: Board, color: Color): number { + let score = 0 + for (let i = 0; i < 64; i++) { + const piece = board[i] + if (!piece) continue + const value = PIECE_VALUES[piece.type] + // For black, read the piece-square table from the mirrored square. + const pstIndex = piece.color === 'w' ? i : 63 - i + const positional = PST[piece.type][pstIndex] + const total = value + positional + score += piece.color === color ? total : -total + } + return score +} + +/** Order moves so captures are searched first โ€” big win for alpha-beta. */ +function orderMoves(moves: Array): Array { + return moves.slice().sort((a, b) => { + const aScore = a.captured ? PIECE_VALUES[a.captured.type] : 0 + const bScore = b.captured ? PIECE_VALUES[b.captured.type] : 0 + return bScore - aScore + }) +} + +function negamax( + position: Position, + depth: number, + alpha: number, + beta: number, + rootColor: Color, +): number { + if (depth === 0) { + return evaluate(position.board, position.turn) + } + + const moves = generateLegalMoves(position) + if (moves.length === 0) { + // Checkmate (bad) or stalemate (neutral) for the side to move. + if (isInCheck(position.board, position.turn)) { + return -100000 - depth // prefer faster mates + } + return 0 + } + + let best = -Infinity + for (const move of orderMoves(moves)) { + const next = applyMove(position, move) + const score = -negamax(next, depth - 1, -beta, -alpha, rootColor) + if (score > best) best = score + if (best > alpha) alpha = best + if (alpha >= beta) break // beta cut-off + } + return best +} + +export interface AiSettings { + /** Search depth (plies). Higher is stronger and slower. */ + depth: number + /** + * 0 = always best move. Higher values let the engine occasionally pick a + * slightly weaker move, making easier levels feel more human/beatable. + */ + randomness: number +} + +/** + * Choose a move for the side to move. Returns null if there are no legal moves. + */ +export function chooseAiMove( + position: Position, + settings: AiSettings, +): Move | null { + const moves = generateLegalMoves(position) + if (moves.length === 0) return null + + const rootColor = position.turn + const scored = orderMoves(moves).map((move) => { + const next = applyMove(position, move) + const score = -negamax( + next, + settings.depth - 1, + -Infinity, + Infinity, + rootColor, + ) + return { move, score } + }) + + scored.sort((a, b) => b.score - a.score) + + if (settings.randomness <= 0) { + return scored[0].move + } + + // Gather moves within `randomness` centipawns of the best, then pick one at + // random. Easier levels get a wider window and so blunder more often. + const bestScore = scored[0].score + const candidates = scored.filter( + (s) => bestScore - s.score <= settings.randomness, + ) + const pick = candidates[Math.floor(Math.random() * candidates.length)] + return pick.move +} diff --git a/src/components/beach-chess/theme.ts b/src/components/beach-chess/theme.ts new file mode 100644 index 000000000..9b0f142da --- /dev/null +++ b/src/components/beach-chess/theme.ts @@ -0,0 +1,127 @@ +// Beach-themed presentation layer for Beach Chess. +// Maps the abstract chess pieces onto a day-at-the-beach cast, and defines +// the difficulty levels and the "what kind of master are you?" personas. + +import type { AiSettings, Color, PieceType } from './engine' + +export interface BeachPiece { + emoji: string + label: string +} + +// Each side gets its own cast so the two armies read differently at a glance: +// the player is the sunny daytime crew, the computer is the sunset/tiki crew. +export const BEACH_PIECES: Record> = { + w: { + k: { emoji: '๐Ÿ„', label: 'Beach King (surfer)' }, + q: { emoji: '๐Ÿ‘™', label: 'Beach Queen (bikini)' }, + r: { emoji: '๐Ÿฐ', label: 'Sandcastle (rook)' }, + b: { emoji: 'โ›ฑ๏ธ', label: 'Beach Umbrella (bishop)' }, + n: { emoji: '๐Ÿฌ', label: 'Dolphin (knight)' }, + p: { emoji: '๐Ÿฅฅ', label: 'Coconut (pawn)' }, + }, + b: { + k: { emoji: '๐Ÿ•บ', label: 'Tiki King (beach dancer)' }, + q: { emoji: '๐Ÿ’ƒ', label: 'Tiki Queen (hula dancer)' }, + r: { emoji: '๐Ÿ—ฟ', label: 'Tiki Totem (rook)' }, + b: { emoji: '๐ŸŒด', label: 'Palm Tree (bishop)' }, + n: { emoji: '๐Ÿฆ€', label: 'Crab (knight)' }, + p: { emoji: '๐Ÿš', label: 'Seashell (pawn)' }, + }, +} + +export type DifficultyId = 'calm' | 'breezy' | 'choppy' | 'stormy' + +export interface Difficulty { + id: DifficultyId + name: string + blurb: string + emoji: string + ai: AiSettings +} + +// Difficulty is driven by search depth (strength) plus a "randomness" window +// that lets easier levels pick slightly sub-optimal moves โ€” so beginners can +// actually win, while the hardest level always plays its best line. +export const DIFFICULTIES: Array = [ + { + id: 'calm', + name: 'Calm Shallows', + blurb: 'Gentle waves. The tide often drifts the wrong way.', + emoji: '๐Ÿ–๏ธ', + ai: { depth: 1, randomness: 180 }, + }, + { + id: 'breezy', + name: 'Breezy Bay', + blurb: 'A light sea breeze. Plays a sensible, casual game.', + emoji: '๐ŸŒŠ', + ai: { depth: 2, randomness: 90 }, + }, + { + id: 'choppy', + name: 'Choppy Surf', + blurb: 'Rough water. Punishes loose moves and hangs nothing.', + emoji: '๐ŸŒฌ๏ธ', + ai: { depth: 3, randomness: 25 }, + }, + { + id: 'stormy', + name: 'Stormy Reef', + blurb: 'Full gale. Searches deep and always plays its best.', + emoji: 'โ›ˆ๏ธ', + ai: { depth: 4, randomness: 0 }, + }, +] + +export type MasterId = 'novice' | 'shell' | 'tiki' | 'wave' | 'grandmaster' + +export interface Master { + id: MasterId + title: string + blurb: string + emoji: string +} + +// Purely cosmetic rank the player picks for themselves โ€” shown in the HUD and +// in the victory/defeat screens to give each game a bit of flavor. +export const MASTERS: Array = [ + { + id: 'novice', + title: 'Sandcastle Novice', + blurb: 'Still learning which way the coconuts roll.', + emoji: '๐Ÿ–๏ธ', + }, + { + id: 'shell', + title: 'Shell Seeker', + blurb: 'Collects small advantages one tide pool at a time.', + emoji: '๐Ÿš', + }, + { + id: 'tiki', + title: 'Tiki Tactician', + blurb: 'Sets cunning traps between the palm trees.', + emoji: '๐ŸŒด', + }, + { + id: 'wave', + title: 'Wave Rider', + blurb: 'Rides momentum and crashes through defenses.', + emoji: '๐Ÿ„', + }, + { + id: 'grandmaster', + title: 'Grandmaster of the Shore', + blurb: 'Commands the whole beach. Fears no tide.', + emoji: '๐Ÿ‘‘', + }, +] + +export function getDifficulty(id: DifficultyId): Difficulty { + return DIFFICULTIES.find((d) => d.id === id) ?? DIFFICULTIES[1] +} + +export function getMaster(id: MasterId): Master { + return MASTERS.find((m) => m.id === id) ?? MASTERS[0] +} diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index c86675182..1762fed6e 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -15,6 +15,7 @@ import { Route as AccountRouteImport } from './routes/account' import { Route as AdminRouteRouteImport } from './routes/admin/route' import { Route as AdsRouteImport } from './routes/ads' import { Route as ApplicationStarterRouteImport } from './routes/application-starter' +import { Route as BeachChessRouteImport } from './routes/beach-chess' import { Route as BlogRouteImport } from './routes/blog' import { Route as BrandGuideRouteImport } from './routes/brand-guide' import { Route as BuilderRouteImport } from './routes/builder' @@ -354,6 +355,11 @@ const ApplicationStarterRoute = ApplicationStarterRouteImport.update({ path: '/application-starter', getParentRoute: () => rootRouteImport, } as any) +const BeachChessRoute = BeachChessRouteImport.update({ + id: '/beach-chess', + path: '/beach-chess', + getParentRoute: () => rootRouteImport, +} as any) const BlogRoute = BlogRouteImport.update({ id: '/blog', path: '/blog', @@ -2011,6 +2017,7 @@ export interface FileRoutesByFullPath { '/account': typeof AccountRouteWithChildren '/ads': typeof AdsRoute '/application-starter': typeof ApplicationStarterRouteWithChildren + '/beach-chess': typeof BeachChessRoute '/blog': typeof BlogRouteWithChildren '/brand-guide': typeof BrandGuideRoute '/builder': typeof BuilderRoute @@ -2324,6 +2331,7 @@ export interface FileRoutesByFullPath { export interface FileRoutesByTo { '/': typeof IndexRoute '/ads': typeof AdsRoute + '/beach-chess': typeof BeachChessRoute '/brand-guide': typeof BrandGuideRoute '/builder': typeof BuilderRoute '/chat-access': typeof ChatAccessRoute @@ -2632,6 +2640,7 @@ export interface FileRoutesById { '/account': typeof AccountRouteWithChildren '/ads': typeof AdsRoute '/application-starter': typeof ApplicationStarterRouteWithChildren + '/beach-chess': typeof BeachChessRoute '/blog': typeof BlogRouteWithChildren '/brand-guide': typeof BrandGuideRoute '/builder': typeof BuilderRoute @@ -2950,6 +2959,7 @@ export interface FileRouteTypes { | '/account' | '/ads' | '/application-starter' + | '/beach-chess' | '/blog' | '/brand-guide' | '/builder' @@ -3263,6 +3273,7 @@ export interface FileRouteTypes { to: | '/' | '/ads' + | '/beach-chess' | '/brand-guide' | '/builder' | '/chat-access' @@ -3570,6 +3581,7 @@ export interface FileRouteTypes { | '/account' | '/ads' | '/application-starter' + | '/beach-chess' | '/blog' | '/brand-guide' | '/builder' @@ -3888,6 +3900,7 @@ export interface RootRouteChildren { AccountRoute: typeof AccountRouteWithChildren AdsRoute: typeof AdsRoute ApplicationStarterRoute: typeof ApplicationStarterRouteWithChildren + BeachChessRoute: typeof BeachChessRoute BlogRoute: typeof BlogRouteWithChildren BrandGuideRoute: typeof BrandGuideRoute BuilderRoute: typeof BuilderRoute @@ -4076,6 +4089,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApplicationStarterRouteImport parentRoute: typeof rootRouteImport } + '/beach-chess': { + id: '/beach-chess' + path: '/beach-chess' + fullPath: '/beach-chess' + preLoaderRoute: typeof BeachChessRouteImport + parentRoute: typeof rootRouteImport + } '/blog': { id: '/blog' path: '/blog' @@ -7082,6 +7102,7 @@ const rootRouteChildren: RootRouteChildren = { AccountRoute: AccountRouteWithChildren, AdsRoute: AdsRoute, ApplicationStarterRoute: ApplicationStarterRouteWithChildren, + BeachChessRoute: BeachChessRoute, BlogRoute: BlogRouteWithChildren, BrandGuideRoute: BrandGuideRoute, BuilderRoute: BuilderRoute, diff --git a/src/routes/beach-chess.tsx b/src/routes/beach-chess.tsx new file mode 100644 index 000000000..b6eef5bdf --- /dev/null +++ b/src/routes/beach-chess.tsx @@ -0,0 +1,47 @@ +import { createFileRoute } from '@tanstack/react-router' +import { lazy, Suspense } from 'react' +// /beach-chess โ€” a beach-themed chess game: you vs. the computer. + +// Lazy load the game so the chess engine stays out of the main chunk. +const BeachChess = lazy(() => import('~/components/beach-chess/BeachChess')) + +export const Route = createFileRoute('/beach-chess')({ + component: BeachChessPage, + head: () => ({ + meta: [ + { + title: 'Beach Chess | TanStack', + }, + { + name: 'description', + content: + 'Play beach-themed chess against the computer โ€” coconuts, dolphins and bikini queens vs. the tiki crew. Pick your difficulty and your master title.', + }, + ], + }), + staticData: { + // Self-contained full-screen game: hide the global TanStack navbar so its + // links don't pull the player back out to the rest of the site. + showNavbar: false, + }, +}) + +function LoadingScreen() { + return ( +
+
+
+

Raking the sandโ€ฆ

+

Setting up the beach

+
+
+ ) +} + +function BeachChessPage() { + return ( + }> + + + ) +}