all repos — emchess @ 6b60c743e0c7b7cfb599852d110f27b229626bcb

feat: arena HUD, eval bar, game-over screen, CRT theme

- VS-style HUD: face, captured pieces, material chip, life-bar clocks
- vertical eval bar fed by the engine after each move (queued, never
  interleaved with search)
- last-move arrow for engine moves, drawn on an SVG layer
- checkmate ceremony: board shake, fallen king, end screen with
  rematch / new opponent / menu
- opponent dossier with ASCII stat bars on the new-game screen
- CRT theme (phosphor green, scanlines) and pixel-weight buttons
- new sounds: start, castle, promotion, low-time tick
- drop tsc-emitted .js from src (noEmit) so dev always serves the .ts
- destroy board window listeners when a game ends
Pablo Murad pablo@pablomurad.com
Mon, 24 Aug 2026 00:47:47 -0300
commit

6b60c743e0c7b7cfb599852d110f27b229626bcb

parent

e3439f4fd1351a66738e364bb3c2a16038ca6e6a

M README.mdREADME.md

@@ -14,9 +14,11 @@ a Web Worker so the board never freezes. The engine is made to pause

before it moves, so it looks like it is thinking. Six opponents, from a careless beginner to a strong master. Four piece -sets, including a plain Unicode set for maximum clarity. Positions load -from the URL (?fen=...). Games export to PGN. Everything is stored in -the browser, and it works offline after the first load. +sets, including a plain Unicode set for maximum clarity. Two themes: +plain paper, or phosphor-green CRT with scanlines. An evaluation bar +and a captured-pieces row track the battle. Positions load from the +URL (?fen=...). Games export to PGN. Everything is stored in the +browser, and it works offline after the first load. REQUIREMENTS
D src/board/board.js

@@ -1,311 +0,0 @@

-const FILES = "abcdefgh"; -export class Board { - constructor(root, opts) { - this.opts = opts; - this.cells = new Map(); - this.pieces = new Map(); - this.selected = null; - this.lastMove = null; - this.drag = null; - this.onPointerDown = (e) => { - if (!this.opts.canMove()) - return; - const square = this.squareAt(e.clientX, e.clientY); - if (!square) - return; - if (this.selected && this.selected !== square) { - if (this.tryMove(this.selected, square)) - return; - } - const el = this.pieces.get(square); - if (el) { - this.select(square); - this.drag = { from: square, el, moved: false }; - el.classList.add("dragging"); - this.dragTo(el, e.clientX, e.clientY); - } - else { - this.clearHints(); - } - }; - this.onPointerMove = (e) => { - if (!this.drag) - return; - this.drag.moved = true; - this.dragTo(this.drag.el, e.clientX, e.clientY); - }; - this.onPointerUp = (e) => { - if (!this.drag) - return; - const { from, el, moved } = this.drag; - this.drag = null; - el.classList.remove("dragging"); - this.place(el, from); - if (!moved) - return; - const to = this.squareAt(e.clientX, e.clientY); - if (to && to !== from) - this.tryMove(from, to); - }; - this.root = root; - this.root.classList.add("board"); - this.buildGrid(); - this.root.addEventListener("pointerdown", this.onPointerDown); - window.addEventListener("pointermove", this.onPointerMove); - window.addEventListener("pointerup", this.onPointerUp); - } - buildGrid() { - this.root.replaceChildren(); - this.cells.clear(); - for (let rank = 8; rank >= 1; rank--) { - for (let f = 0; f < 8; f++) { - const file = this.opts.orientation === "w" ? f : 7 - f; - const r = this.opts.orientation === "w" ? rank : 9 - rank; - const square = (FILES[file] + r); - const cell = document.createElement("div"); - cell.className = "sq " + ((file + r) % 2 === 0 ? "light" : "dark"); - cell.dataset.square = square; - if (this.opts.coords) - this.addCoords(cell, file, r); - this.cells.set(square, cell); - this.root.appendChild(cell); - } - } - } - addCoords(cell, file, rank) { - const bottomRank = this.opts.orientation === "w" ? 1 : 8; - const edgeFile = this.opts.orientation === "w" ? 0 : 7; - if (rank === bottomRank) { - const c = document.createElement("span"); - c.className = "coord file"; - c.textContent = FILES[file]; - cell.appendChild(c); - } - if (file === edgeFile) { - const c = document.createElement("span"); - c.className = "coord rank"; - c.textContent = String(rank); - cell.appendChild(c); - } - } - setPosition(board) { - for (const el of this.pieces.values()) - el.remove(); - this.pieces.clear(); - for (const row of board) { - for (const cell of row) { - if (!cell) - continue; - const el = this.makePiece(cell.type, cell.color, cell.square); - this.pieces.set(cell.square, el); - this.root.appendChild(el); - } - } - this.clearHints(); - } - makePiece(type, color, square) { - const el = document.createElement("div"); - el.className = "piece " + color + (this.opts.set.traditional ? " traditional" : ""); - const glyph = document.createElement("span"); - glyph.className = "glyph"; - glyph.textContent = this.opts.set.glyphs[type]; - el.appendChild(glyph); - el.dataset.type = type; - this.place(el, square); - return el; - } - place(el, square) { - const { x, y } = this.xy(square); - el.style.transform = `translate(${x * 100}%, ${y * 100}%)`; - } - xy(square) { - const file = FILES.indexOf(square[0]); - const rank = Number(square[1]); - if (this.opts.orientation === "w") - return { x: file, y: 8 - rank }; - return { x: 7 - file, y: rank - 1 }; - } - // Animate a single applied move, including the special cases chess.js flags for us. - applyMove(move) { - const from = move.from; - const to = move.to; - const moving = this.pieces.get(from); - if (!moving) - return this.reconcile(move); - if (move.flags.includes("e")) { - const capSquare = (to[0] + from[1]); - this.capture(capSquare); - } - else if (move.captured) { - this.capture(to); - } - this.pieces.delete(from); - this.pieces.set(to, moving); - this.animateFor(moving, move.piece); - this.place(moving, to); - if (move.flags.includes("p") && move.promotion) { - const glyph = moving.querySelector(".glyph"); - if (glyph) - glyph.textContent = this.opts.set.glyphs[move.promotion]; - moving.dataset.type = move.promotion; - } - if (move.flags.includes("k")) - this.moveRook(move.color, "h", "f"); - if (move.flags.includes("q")) - this.moveRook(move.color, "a", "d"); - this.setLastMove(from, to); - this.clearHints(); - } - reconcile(move) { - this.setLastMove(move.from, move.to); - } - moveRook(color, fromFile, toFile) { - const rank = color === "w" ? "1" : "8"; - const from = (fromFile + rank); - const to = (toFile + rank); - const rook = this.pieces.get(from); - if (!rook) - return; - this.pieces.delete(from); - this.pieces.set(to, rook); - this.place(rook, to); - } - capture(square) { - const el = this.pieces.get(square); - if (!el) - return; - this.pieces.delete(square); - el.classList.add("captured"); - setTimeout(() => el.remove(), 220); - this.puff(square); - } - // Each piece carries a little of its own character: pawns hop, the king trudges. - animateFor(el, type) { - const ms = { p: 220, n: 340, b: 300, r: 340, q: 240, k: 400 }; - el.style.transitionDuration = (ms[type] ?? 280) + "ms"; - if (type === "n") { - el.classList.add("hop"); - setTimeout(() => el.classList.remove("hop"), ms.n); - } - } - puff(square) { - const p = document.createElement("div"); - p.className = "puff"; - p.textContent = "πŸ’¨"; - this.place(p, square); - this.root.appendChild(p); - setTimeout(() => p.remove(), 480); - } - setThreats(squares) { - for (const el of this.pieces.values()) - el.classList.remove("threat"); - for (const sq of squares) - this.pieces.get(sq)?.classList.add("threat"); - } - react(square, text) { - const b = document.createElement("div"); - b.className = "reaction"; - b.textContent = text; - this.place(b, square); - this.root.appendChild(b); - requestAnimationFrame(() => b.classList.add("rise")); - setTimeout(() => b.remove(), 900); - } - setLastMove(from, to) { - if (this.lastMove) { - for (const sq of this.lastMove) - this.cells.get(sq)?.classList.remove("last"); - } - this.lastMove = [from, to]; - this.cells.get(from)?.classList.add("last"); - this.cells.get(to)?.classList.add("last"); - } - setCheck(square) { - for (const cell of this.cells.values()) - cell.classList.remove("check"); - if (square) - this.cells.get(square)?.classList.add("check"); - } - select(square) { - this.clearHints(); - this.selected = square; - this.cells.get(square)?.classList.add("sel"); - for (const move of this.opts.legalTargets(square)) { - const cell = this.cells.get(move.to); - if (!cell) - continue; - const dot = document.createElement("div"); - dot.className = "dot" + (move.captured || move.flags.includes("e") ? " capture" : ""); - cell.appendChild(dot); - } - } - clearHints() { - if (this.selected) - this.cells.get(this.selected)?.classList.remove("sel"); - this.selected = null; - for (const cell of this.cells.values()) { - cell.querySelectorAll(".dot").forEach((d) => d.remove()); - } - } - tryMove(from, to) { - const legal = this.opts.legalTargets(from).find((m) => m.to === to); - if (!legal) - return false; - if (legal.flags.includes("p")) { - this.askPromotion(legal.color, to, (choice) => this.opts.onMove(from, to, choice)); - } - else { - this.opts.onMove(from, to); - } - return true; - } - squareAt(clientX, clientY) { - const rect = this.root.getBoundingClientRect(); - const col = Math.floor(((clientX - rect.left) / rect.width) * 8); - const row = Math.floor(((clientY - rect.top) / rect.height) * 8); - if (col < 0 || col > 7 || row < 0 || row > 7) - return null; - const file = this.opts.orientation === "w" ? col : 7 - col; - const rank = this.opts.orientation === "w" ? 8 - row : row + 1; - return (FILES[file] + rank); - } - dragTo(el, clientX, clientY) { - const rect = this.root.getBoundingClientRect(); - const size = rect.width / 8; - const x = clientX - rect.left - size / 2; - const y = clientY - rect.top - size / 2; - el.style.transform = `translate(${x}px, ${y}px)`; - } - askPromotion(color, at, done) { - const menu = document.createElement("div"); - menu.className = "promo"; - const { x, y } = this.xy(at); - menu.style.left = `${x * 12.5}%`; - menu.style.top = `${Math.min(y, 4) * 12.5}%`; - const order = ["q", "r", "b", "n"]; - for (const type of order) { - const b = document.createElement("button"); - b.textContent = this.opts.set.traditional - ? this.opts.set.glyphs[type] - : this.opts.set.glyphs[type]; - b.className = "piece-choice " + color; - b.onclick = () => { - menu.remove(); - done(type); - }; - menu.appendChild(b); - } - this.root.appendChild(menu); - } - setOrientation(o) { - this.opts.orientation = o; - this.buildGrid(); - } - setSet(set) { - this.opts.set = set; - } - setCoords(on) { - this.opts.coords = on; - this.buildGrid(); - } -}
M src/board/board.tssrc/board/board.ts

@@ -21,6 +21,7 @@ private pieces = new Map<Square, HTMLElement>();

private selected: Square | null = null; private lastMove: [Square, Square] | null = null; private drag: { from: Square; el: HTMLElement; moved: boolean } | null = null; + private arrowSvg!: SVGSVGElement; constructor(root: HTMLElement, private opts: BoardOptions) { this.root = root;

@@ -47,6 +48,11 @@ this.cells.set(square, cell);

this.root.appendChild(cell); } } + // The arrow layer sits above the squares and pieces, redrawn on every rebuild. + this.arrowSvg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + this.arrowSvg.setAttribute("viewBox", "0 0 8 8"); + this.arrowSvg.classList.add("arrows"); + this.root.appendChild(this.arrowSvg); } private addCoords(cell: HTMLElement, file: number, rank: number) {

@@ -184,16 +190,66 @@ for (const el of this.pieces.values()) el.classList.remove("threat");

for (const sq of squares) this.pieces.get(sq)?.classList.add("threat"); } - react(square: Square, text: string) { + react(square: Square, text: string, stay = false) { const b = document.createElement("div"); - b.className = "reaction"; + b.className = "reaction" + (stay ? " stay" : ""); b.textContent = text; this.place(b, square); this.root.appendChild(b); + if (stay) return; requestAnimationFrame(() => b.classList.add("rise")); setTimeout(() => b.remove(), 900); } + // An arrow from the engine's last move, so the eye catches what just happened. + setArrow(from: Square, to: Square) { + this.clearArrow(); + const a = this.xy(from); + const b = this.xy(to); + const x1 = a.x + 0.5; + const y1 = a.y + 0.5; + const x2 = b.x + 0.5; + const y2 = b.y + 0.5; + const len = Math.hypot(x2 - x1, y2 - y1); + if (len === 0) return; + const ux = (x2 - x1) / len; + const uy = (y2 - y1) / len; + const px = -uy; + const py = ux; + const ex = x2 - ux * 0.36; // arrowhead tip, short of the piece's center + const ey = y2 - uy * 0.36; + const ns = "http://www.w3.org/2000/svg"; + const line = document.createElementNS(ns, "line"); + line.setAttribute("x1", String(x1 + ux * 0.18)); + line.setAttribute("y1", String(y1 + uy * 0.18)); + line.setAttribute("x2", String(ex - ux * 0.24)); + line.setAttribute("y2", String(ey - uy * 0.24)); + line.setAttribute("class", "arrow"); + const head = document.createElementNS(ns, "polygon"); + head.setAttribute( + "points", + `${ex},${ey} ${ex - ux * 0.3 + px * 0.17},${ey - uy * 0.3 + py * 0.17} ${ex - ux * 0.3 - px * 0.17},${ey - uy * 0.3 - py * 0.17}` + ); + head.setAttribute("class", "arrow"); + this.arrowSvg.append(line, head); + } + + clearArrow() { + this.arrowSvg?.replaceChildren(); + } + + // The beaten king keels over; the board flinches with him. + defeat(square: Square) { + this.pieces.get(square)?.classList.add("defeated"); + this.root.classList.add("shake"); + setTimeout(() => this.root.classList.remove("shake"), 600); + } + + clearDefeat() { + for (const el of this.pieces.values()) el.classList.remove("defeated"); + this.root.querySelectorAll(".reaction.stay").forEach((r) => r.remove()); + } + setLastMove(from: Square, to: Square) { if (this.lastMove) { for (const sq of this.lastMove) this.cells.get(sq)?.classList.remove("last");

@@ -320,6 +376,13 @@

setOrientation(o: Color) { this.opts.orientation = o; this.buildGrid(); + } + + // The board listens on window for drags; drop those listeners when a game ends, + // or every rematch would pile another dead board onto the window. + destroy() { + window.removeEventListener("pointermove", this.onPointerMove); + window.removeEventListener("pointerup", this.onPointerUp); } setSet(set: PieceSet) {
D src/board/pieces.js

@@ -1,32 +0,0 @@

-// One fixed glyph per piece type, so a pawn always reads as a pawn. -const SETS = { - kingdom: { - id: "kingdom", - name: "Kingdom", - traditional: false, - glyphs: { k: "🀴", q: "πŸ‘Έ", b: "πŸ§™", n: "πŸ‡", r: "🏰", p: "πŸ§‘" }, - }, - forest: { - id: "forest", - name: "Forest", - traditional: false, - glyphs: { k: "🦁", q: "🦊", b: "πŸ¦‰", n: "🦌", r: "🌳", p: "🐿️" }, - }, - gothic: { - id: "gothic", - name: "Gothic", - traditional: false, - glyphs: { k: "πŸ§›", q: "πŸ§›β€β™€οΈ", b: "πŸ§™β€β™‚οΈ", n: "🐺", r: "🏰", p: "🧟" }, - }, - minimal: { - id: "minimal", - name: "Minimal", - traditional: true, - // Color comes from the .w / .b classes, not the glyph. - glyphs: { k: "β™š", q: "β™›", b: "♝", n: "β™ž", r: "β™œ", p: "β™Ÿ" }, - }, -}; -export const PIECE_SETS = Object.values(SETS); -export function getSet(id) { - return SETS[id] ?? SETS.kingdom; -}
D src/game/analysis.js

@@ -1,73 +0,0 @@

-const VALUE = { p: 1, n: 3, b: 3, r: 5, q: 9, k: 100 }; -export function pieceValue(type) { - return VALUE[type] ?? 0; -} -export const PIECE_NAME = { - p: "pawn", - n: "knight", - b: "bishop", - r: "rook", - q: "queen", - k: "king", -}; -// Static exchange evaluation on an occupied square. Returns the material the owner -// stands to lose if the enemy starts capturing there (positive = a real loss). -export function exchangeLoss(game, square) { - const victim = game.get(square); - if (!victim) - return 0; - const owner = victim.color; - const enemy = owner === "w" ? "b" : "w"; - const attackers = valuesOf(game, game.attackers(square, enemy)); - const defenders = valuesOf(game, game.attackers(square, owner)); - if (attackers.length === 0) - return 0; - return swap(pieceValue(victim.type), attackers, defenders); -} -function valuesOf(game, squares) { - return squares - .map((sq) => pieceValue(game.get(sq).type)) - .sort((a, b) => a - b); -} -// Classic swap-off: least valuable piece captures first, each side may stop when -// continuing would lose material. Returns net material for the attacking side. -function swap(target, attackers, defenders) { - const gains = [target]; - let onSquare = attackers[0]; - let a = 1; - let d = 0; - let depth = 1; - let defenderToMove = true; - while (true) { - if (defenderToMove) { - if (d >= defenders.length) - break; - gains[depth] = onSquare - gains[depth - 1]; - onSquare = defenders[d++]; - } - else { - if (a >= attackers.length) - break; - gains[depth] = onSquare - gains[depth - 1]; - onSquare = attackers[a++]; - } - depth++; - defenderToMove = !defenderToMove; - } - for (let i = depth - 1; i > 0; i--) { - gains[i - 1] = -Math.max(-gains[i - 1], gains[i]); - } - return gains[0]; -} -// Every piece of `color` the opponent can win material from, worst first. -export function threats(game, color) { - const out = []; - for (const { square, type } of game.pieces(color)) { - if (type === "k") - continue; - const loss = exchangeLoss(game, square); - if (loss > 0) - out.push({ square, type, loss }); - } - return out.sort((a, b) => b.loss - a.loss); -}
D src/game/clock.js

@@ -1,87 +0,0 @@

-export const TIME_CONTROLS = [ - { id: "none", label: "no clock", base: 0, inc: 0 }, - { id: "1+0", label: "1 + 0 bullet", base: 60, inc: 0 }, - { id: "3+2", label: "3 + 2 blitz", base: 180, inc: 2 }, - { id: "5+0", label: "5 + 0 blitz", base: 300, inc: 0 }, - { id: "10+0", label: "10 + 0 rapid", base: 600, inc: 0 }, - { id: "15+10", label: "15 + 10 rapid", base: 900, inc: 10 }, - { id: "30+0", label: "30 + 0 classical", base: 1800, inc: 0 }, -]; -export function timeControl(id, custom) { - if (id === "custom" && custom) { - return { id: "custom", label: "custom", base: custom.base, inc: custom.inc }; - } - return TIME_CONTROLS.find((t) => t.id === id) ?? TIME_CONTROLS[0]; -} -export class Clock { - constructor(tc, onTick, onFlag) { - this.tc = tc; - this.onTick = onTick; - this.onFlag = onFlag; - this.turn = null; - this.last = 0; - this.flagged = null; - this.tick = () => { - if (!this.turn || this.flagged) - return; - const now = performance.now(); - this.remaining[this.turn] -= now - this.last; - this.last = now; - if (this.remaining[this.turn] <= 0) { - this.remaining[this.turn] = 0; - this.flagged = this.turn; - this.turn = null; - this.stop(); - this.onTick(this.remaining.w, this.remaining.b); - this.onFlag(this.flagged); - return; - } - this.onTick(this.remaining.w, this.remaining.b); - }; - this.remaining = { w: tc.base * 1000, b: tc.base * 1000 }; - this.incMs = tc.inc * 1000; - } - get enabled() { - return this.tc.base > 0; - } - start(side) { - if (!this.enabled) - return; - this.turn = side; - this.last = performance.now(); - this.run(); - } - // The side that just moved gets the increment; the other side's clock starts running. - press(mover) { - if (!this.enabled || this.flagged) - return; - this.remaining[mover] += this.incMs; - this.turn = mover === "w" ? "b" : "w"; - this.last = performance.now(); - this.onTick(this.remaining.w, this.remaining.b); - this.run(); - } - pause() { - this.stop(); - this.turn = null; - } - stop() { - if (this.timer) { - clearInterval(this.timer); - this.timer = undefined; - } - } - run() { - if (!this.timer) - this.timer = setInterval(this.tick, 100); - } -} -export function formatTime(ms) { - if (ms < 0) - ms = 0; - if (ms < 10000) - return (Math.floor(ms / 100) / 10).toFixed(1); - const s = Math.ceil(ms / 1000); - const m = Math.floor(s / 60); - return m + ":" + String(s % 60).padStart(2, "0"); -}
M src/game/clock.tssrc/game/clock.ts

@@ -45,6 +45,11 @@ get enabled() {

return this.tc.base > 0; } + // The initial time in ms, so the UI can render how much of the clock is left. + get baseMs() { + return this.tc.base * 1000; + } + start(side: Color) { if (!this.enabled) return; this.turn = side;
D src/game/coach.js

@@ -1,77 +0,0 @@

-import { threats, exchangeLoss, pieceValue, PIECE_NAME } from "./analysis"; -// Warn before a move that drops material the opponent can simply take. Returns the -// piece at risk, or null if the move is fine. Checks are skipped β€” a check usually -// changes the tactics. -export function blunderCheck(game, from, to, promotion, human) { - const test = game.clone(); - const move = test.play(from, to, promotion); - if (!move || test.inCheck()) - return null; - const gained = move.captured ? pieceValue(move.captured) : 0; - const worst = threats(test, human)[0]; - if (worst && worst.loss - gained >= 2) - return worst; - return null; -} -export function assess(cp, mate) { - if (mate !== null) { - return mate > 0 ? `You have a forced mate in ${mate}.` : `You are getting mated in ${Math.abs(mate)}.`; - } - if (cp === null) - return "The position is unclear."; - const p = cp / 100; - if (p >= 2.5) - return "You have a winning advantage."; - if (p >= 0.9) - return "You stand a little better."; - if (p > -0.9) - return "The position is about equal."; - if (p > -2.5) - return "You stand worse."; - return "You are in serious trouble."; -} -export function explainPosition(game, human, cp, mate) { - const enemy = human === "w" ? "b" : "w"; - const mine = threats(game, human); - const theirs = threats(game, enemy); - let detail; - if (game.inCheck() && game.turn === human) { - detail = "Your king is in check, so answer that first."; - } - else if (mine[0] && mine[0].loss >= 2) { - detail = `Your ${PIECE_NAME[mine[0].type]} on ${mine[0].square} is hanging.`; - } - else if (theirs[0] && theirs[0].loss >= 2) { - detail = `You can win the ${PIECE_NAME[theirs[0].type]} on ${theirs[0].square}.`; - } - else { - detail = developmentHint(game, human); - } - return assess(cp, mate) + " " + detail; -} -// Explain, in plain terms, why the last move lost ground. Returns null when there is -// nothing concrete to say. -export function explainMistake(before, after, move, human) { - const beforeHanging = new Set(threats(before, human).map((t) => t.square)); - const newHang = threats(after, human).find((t) => !beforeHanging.has(t.square) && t.loss >= 2); - if (newHang) { - return `That left your ${PIECE_NAME[newHang.type]} on ${newHang.square} attacked and underdefended.`; - } - const movedLoss = exchangeLoss(after, move.to); - if (movedLoss >= 2) { - return `Your ${PIECE_NAME[move.piece]} walked into a loss on ${move.to}.`; - } - return null; -} -function developmentHint(game, human) { - const rank = human === "w" ? "1" : "8"; - const home = game - .pieces(human) - .filter((p) => (p.type === "n" || p.type === "b") && p.square[1] === rank); - if (home.length >= 2) - return "Bring your knights and bishops into the game."; - const enemy = human === "w" ? "b" : "w"; - if (game.inCheck() && game.turn === enemy) - return "You are giving check."; - return "Nothing is loose. Look for a plan and improve your worst piece."; -}
D src/game/engine.js

@@ -1,183 +0,0 @@

-// A real same-origin URL (not a blob), so Stockfish resolves its .wasm next to itself. -// The server must send the .wasm as application/wasm. -const ENGINE_URL = new URL(`${import.meta.env.BASE_URL}stockfish/stockfish-18-lite-single.js`, location.href).href; -export class Engine { - constructor() { - this.listeners = []; - this.worker = new Worker(ENGINE_URL); - this.worker.onmessage = (e) => { - const line = typeof e.data === "string" ? e.data : String(e.data?.data ?? ""); - for (const fn of this.listeners) - fn(line); - }; - this.ready = this.boot(); - } - send(cmd) { - this.worker.postMessage(cmd); - } - waitFor(token, timeout = 0) { - return new Promise((resolve, reject) => { - let timer; - const fn = (line) => { - if (line.includes(token)) { - this.listeners = this.listeners.filter((l) => l !== fn); - if (timer) - clearTimeout(timer); - resolve(); - } - }; - this.listeners.push(fn); - if (timeout > 0) { - timer = setTimeout(() => { - this.listeners = this.listeners.filter((l) => l !== fn); - reject(new Error("engine timeout waiting for " + token)); - }, timeout); - } - }); - } - async boot() { - this.send("uci"); - await this.waitFor("uciok", 20000); - this.send("isready"); - await this.waitFor("readyok", 20000); - } - async configure(p) { - try { - await this.ready; - } - catch { - return; - } - this.send("setoption name Skill Level value " + p.skill); - this.send("setoption name MultiPV value " + Math.max(1, p.multipv)); - this.send("isready"); - await this.waitFor("readyok", 8000).catch(() => { }); - } - // Returns a move in UCI form, never before the personality's think floor. Falls back - // to a legal move if the engine is unavailable, so the game can never hang. - async chooseMove(fen, p, legal) { - const started = performance.now(); - const floor = p.think[0] + Math.random() * (p.think[1] - p.think[0]); - let move; - let alive = true; - try { - await this.ready; - } - catch { - alive = false; - } - if (!alive) { - move = randomOf(legal); - } - else if (legal.length && Math.random() < p.blunderChance) { - move = randomOf(legal); - } - else { - move = (await this.search(fen, p)) ?? randomOf(legal); - } - const elapsed = performance.now() - started; - if (elapsed < floor) - await sleep(floor - elapsed); - return move; - } - search(fen, p) { - return new Promise((resolve) => { - const candidates = new Map(); - let done = false; - const finish = (m) => { - if (done) - return; - done = true; - clearTimeout(timer); - this.listeners = this.listeners.filter((l) => l !== fn); - resolve(m); - }; - const fn = (line) => { - if (line.startsWith("info")) { - const pv = line.match(/multipv (\d+).*? pv (\w+)/); - if (pv) - candidates.set(Number(pv[1]), pv[2]); - } - else if (line.startsWith("bestmove")) { - const best = line.split(" ")[1]; - finish(best === "(none)" ? null : pick(candidates, best, p.multipv)); - } - }; - const timer = setTimeout(() => finish(null), p.movetime + 4000); - this.listeners.push(fn); - this.send("setoption name Skill Level value " + p.skill); - this.send("setoption name MultiPV value " + Math.max(1, p.multipv)); - this.send("position fen " + fen); - this.send("go movetime " + p.movetime); - }); - } - // Full-strength evaluation for the coach, from the side-to-move's point of view. - async evaluate(fen, movetime = 500) { - try { - await this.ready; - } - catch { - return { cp: null, mate: null }; - } - return new Promise((resolve) => { - let cp = null; - let mate = null; - let done = false; - const finish = () => { - if (done) - return; - done = true; - clearTimeout(timer); - this.listeners = this.listeners.filter((l) => l !== fn); - resolve({ cp, mate }); - }; - const fn = (line) => { - if (line.startsWith("info")) { - const s = line.match(/score (cp|mate) (-?\d+)/); - if (s) { - if (s[1] === "cp") { - cp = Number(s[2]); - mate = null; - } - else { - mate = Number(s[2]); - cp = null; - } - } - } - else if (line.startsWith("bestmove")) { - finish(); - } - }; - const timer = setTimeout(finish, movetime + 4000); - this.listeners.push(fn); - this.send("setoption name Skill Level value 20"); - this.send("setoption name MultiPV value 1"); - this.send("position fen " + fen); - this.send("go movetime " + movetime); - }); - } - destroy() { - this.send("quit"); - this.worker.terminate(); - } -} -function pick(candidates, best, multipv) { - if (multipv <= 1 || candidates.size <= 1) - return best; - const weights = [0.62, 0.26, 0.09, 0.03]; - const roll = Math.random(); - let acc = 0; - for (let i = 1; i <= Math.min(multipv, candidates.size); i++) { - acc += weights[i - 1] ?? 0.02; - if (roll < acc && candidates.has(i)) - return candidates.get(i); - } - return best; -} -function randomOf(moves) { - return moves.length ? moves[Math.floor(Math.random() * moves.length)] : null; -} -function sleep(ms) { - return new Promise((r) => setTimeout(r, ms)); -}
D src/game/personalities.js

@@ -1,77 +0,0 @@

-export const PERSONALITIES = [ - { - id: "beginner", - face: "🌱", - name: "The Beginner", - blurb: "Plays slowly and makes clear mistakes.", - skill: 0, - movetime: 120, - think: [700, 1400], - blunderChance: 0.28, - multipv: 4, - contempt: 0, - }, - { - id: "patient", - face: "🐒", - name: "The Patient", - blurb: "Defensive, avoids risks, trades when it can.", - skill: 4, - movetime: 200, - think: [800, 1500], - blunderChance: 0.1, - multipv: 3, - contempt: -30, - }, - { - id: "trickster", - face: "🦊", - name: "The Trickster", - blurb: "Hunts for forks, traps and quick attacks.", - skill: 8, - movetime: 300, - think: [600, 1200], - blunderChance: 0.06, - multipv: 2, - contempt: 40, - }, - { - id: "guardian", - face: "πŸ›‘οΈ", - name: "The Guardian", - blurb: "Values safety and a solid structure.", - skill: 12, - movetime: 400, - think: [700, 1300], - blunderChance: 0.03, - multipv: 1, - contempt: -20, - }, - { - id: "aggressor", - face: "πŸ”₯", - name: "The Aggressor", - blurb: "Attacks early and accepts sacrifices.", - skill: 15, - movetime: 500, - think: [500, 1100], - blunderChance: 0.02, - multipv: 1, - contempt: 80, - }, - { - id: "master", - face: "πŸ‘οΈ", - name: "The Master", - blurb: "Strong and precise. Shows no mercy.", - skill: 20, - movetime: 1000, - think: [500, 900], - blunderChance: 0, - multipv: 1, - contempt: 0, - }, -]; -export function getPersonality(id) { - return PERSONALITIES.find((p) => p.id === id) ?? PERSONALITIES[0]; -}
D src/game/state.js

@@ -1,117 +0,0 @@

-import { Chess } from "chess.js"; -export class Game { - constructor(fen) { - this.chess = new Chess(fen); - } - get fen() { - return this.chess.fen(); - } - get turn() { - return this.chess.turn(); - } - board() { - return this.chess.board(); - } - legalFrom(square) { - return this.chess.moves({ square, verbose: true }); - } - legalTargets(square) { - return this.legalFrom(square); - } - allMoves() { - return this.chess.moves({ verbose: true }); - } - legalUci() { - return this.allMoves().map((m) => m.from + m.to + (m.promotion ?? "")); - } - // Accepts a UCI-style from/to (plus promotion) and returns the applied move, or null. - play(from, to, promotion) { - try { - return this.chess.move({ from, to, promotion: promotion ?? "q" }); - } - catch { - return null; - } - } - playUci(uci) { - const from = uci.slice(0, 2); - const to = uci.slice(2, 4); - const promo = uci.length > 4 ? uci[4] : undefined; - return this.play(from, to, promo); - } - undo() { - return this.chess.undo(); - } - history() { - return this.chess.history({ verbose: true }); - } - inCheck() { - return this.chess.inCheck(); - } - get(square) { - return this.chess.get(square); - } - attackers(square, color) { - return this.chess.attackers(square, color); - } - clone() { - return new Game(this.fen); - } - pieces(color) { - const out = []; - for (const row of this.chess.board()) { - for (const cell of row) { - if (cell && cell.color === color) - out.push({ square: cell.square, type: cell.type }); - } - } - return out; - } - // The square of the side-to-move's king, used to paint the check marker. - kingSquare(color) { - for (const row of this.chess.board()) { - for (const cell of row) { - if (cell && cell.type === "k" && cell.color === color) - return cell.square; - } - } - return null; - } - outcome() { - if (!this.chess.isGameOver()) - return { over: false }; - if (this.chess.isCheckmate()) { - return { over: true, reason: "checkmate", winner: this.turn === "w" ? "b" : "w" }; - } - if (this.chess.isStalemate()) - return { over: true, reason: "stalemate" }; - if (this.chess.isInsufficientMaterial()) - return { over: true, reason: "insufficient" }; - if (this.chess.isThreefoldRepetition()) - return { over: true, reason: "repetition" }; - if (this.chess.isDrawByFiftyMoves?.()) - return { over: true, reason: "fifty" }; - return { over: true, reason: "draw" }; - } - pgn() { - return this.chess.pgn(); - } - loadPgn(pgn) { - try { - this.chess.loadPgn(pgn); - return true; - } - catch { - return false; - } - } - load(fen) { - try { - this.chess.load(fen); - return true; - } - catch { - return false; - } - } -}
D src/main.js

@@ -1,188 +0,0 @@

-import "./style.css"; -import { PERSONALITIES, getPersonality } from "./game/personalities"; -import { PIECE_SETS } from "./board/pieces"; -import { TIME_CONTROLS, timeControl } from "./game/clock"; -import { loadPrefs, savePrefs } from "./util/storage"; -import { fenFromUrl, routeFromUrl, goto } from "./util/url"; -const app = document.querySelector("#app"); -let prefs = loadPrefs(); -function main() { - window.addEventListener("hashchange", render); - render(); -} -function render() { - prefs = loadPrefs(); - const route = routeFromUrl(); - const startFen = fenFromUrl(); - if (route === "play" || startFen) - return renderSetup(startFen); - if (route === "settings") - return renderSettings(); - if (route === "about") - return renderAbout(); - renderHome(); -} -function renderHome() { - app.innerHTML = ` - <div class="home"> - <pre class="logo" aria-label="Emchess">β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•—β–ˆβ–ˆβ–ˆβ•— β–ˆβ–ˆβ–ˆβ•— β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•—β–ˆβ–ˆβ•— β–ˆβ–ˆβ•—β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•—β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•—β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•— -β–ˆβ–ˆβ•”β•β•β•β•β•β–ˆβ–ˆβ–ˆβ–ˆβ•— β–ˆβ–ˆβ–ˆβ–ˆβ•‘β–ˆβ–ˆβ•”β•β•β•β•β•β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•”β•β•β•β•β•β–ˆβ–ˆβ•”β•β•β•β•β•β–ˆβ–ˆβ•”β•β•β•β•β• -β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•— β–ˆβ–ˆβ•”β–ˆβ–ˆβ–ˆβ–ˆβ•”β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•‘β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•— β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•—β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•— -β–ˆβ–ˆβ•”β•β•β• β–ˆβ–ˆβ•‘β•šβ–ˆβ–ˆβ•”β•β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•”β•β•β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•”β•β•β• β•šβ•β•β•β•β–ˆβ–ˆβ•‘β•šβ•β•β•β•β–ˆβ–ˆβ•‘ -β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•—β–ˆβ–ˆβ•‘ β•šβ•β• β–ˆβ–ˆβ•‘β•šβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•—β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•—β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•‘β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•‘ -β•šβ•β•β•β•β•β•β•β•šβ•β• β•šβ•β• β•šβ•β•β•β•β•β•β•šβ•β• β•šβ•β•β•šβ•β•β•β•β•β•β•β•šβ•β•β•β•β•β•β•β•šβ•β•β•β•β•β•β•</pre> - <p class="kicker">a quiet game of kings</p> - <nav class="menu"> - <button data-go="play">play</button> - <button data-go="settings">settings</button> - <button data-go="about">about</button> - </nav> - </div>`; - app.querySelectorAll("[data-go]").forEach((b) => { - b.onclick = () => goto(b.dataset.go); - }); -} -function renderSetup(startFen) { - let side = prefs.side; - let personality = prefs.personality; - let clockId = prefs.clock; - const opponentRows = PERSONALITIES.map((p) => ` - <button class="row" data-id="${p.id}" aria-pressed="${p.id === personality}"> - <span class="cur"> </span> - <span class="face">${p.face}</span> - <span class="name">${p.name}</span> - <span class="blurb">${p.blurb}</span> - <span class="leader"></span> - </button>`).join(""); - const timeButtons = [...TIME_CONTROLS, { id: "custom", label: "custom" }] - .map((t) => `<button data-tc="${t.id}" aria-pressed="${t.id === clockId}">${t.label}</button>`) - .join(""); - app.innerHTML = ` - <div class="topbar"> - <h2>new game</h2> - <button class="back" data-back>← menu</button> - </div> - <div class="setup"> - <div class="field"> - <label>your side</label> - <div class="toggle" id="sides"> - <button data-side="w" aria-pressed="${side === "w"}">white</button> - <button data-side="b" aria-pressed="${side === "b"}">black</button> - <button data-side="random" aria-pressed="${side === "random"}">random</button> - </div> - </div> - <div class="field"> - <label>opponent</label> - <div class="pick" id="opps">${opponentRows}</div> - </div> - <div class="field"> - <label>time</label> - <div class="toggle" id="times">${timeButtons}</div> - <div class="custom ${clockId === "custom" ? "" : "hidden"}" id="custom"> - <label>min <input id="cbase" type="number" min="1" max="180" value="${Math.round(prefs.clockBase / 60)}"></label> - <label>+sec <input id="cinc" type="number" min="0" max="60" value="${prefs.clockInc}"></label> - </div> - </div> - <div class="field"> - <label>start from (optional)</label> - <textarea id="load" class="load" rows="2" placeholder="Paste a FEN or PGN">${startFen ?? ""}</textarea> - <div class="soft" id="loaderr"></div> - </div> - <button class="start" id="start">start</button> - </div>`; - app.querySelector("[data-back]").onclick = () => goto("home"); - pressGroup("#sides button", (b) => (side = b.dataset.side)); - pressGroup("#opps .row", (b) => (personality = b.dataset.id)); - pressGroup("#times button", (b) => { - clockId = b.dataset.tc; - app.querySelector("#custom").classList.toggle("hidden", clockId !== "custom"); - }); - app.querySelector("#start").onclick = async () => { - const base = (Number(app.querySelector("#cbase").value) || 5) * 60; - const inc = Number(app.querySelector("#cinc").value) || 0; - const text = app.querySelector("#load").value; - prefs = { ...prefs, side, personality, clock: clockId, clockBase: base, clockInc: inc }; - savePrefs(prefs); - const human = side === "random" ? (Math.random() < 0.5 ? "w" : "b") : side; - const tc = timeControl(clockId, { base, inc }); - const { startGame } = await import("./play"); - const err = await startGame(human, getPersonality(personality), tc, text); - if (err) { - const box = app.querySelector("#loaderr"); - if (box) - box.textContent = err; - } - }; -} -function pressGroup(selector, onPick) { - const buttons = app.querySelectorAll(selector); - buttons.forEach((b) => { - b.onclick = () => { - onPick(b); - buttons.forEach((c) => c.setAttribute("aria-pressed", String(c === b))); - }; - }); -} -function renderSettings() { - const setRows = PIECE_SETS.map((s) => `<button data-set="${s.id}" aria-pressed="${s.id === prefs.pieceSet}">${s.name.toLowerCase()}</button>`).join(""); - app.innerHTML = ` - <div class="topbar"> - <h2>settings</h2> - <button class="back" data-back>← menu</button> - </div> - <div class="setup"> - <div class="field"> - <label>pieces</label> - <div class="toggle" id="sets">${setRows}</div> - </div> - <div class="field"> - <label>board</label> - <div class="toggle"> - <button data-flag="coords" aria-pressed="${prefs.coords}">coordinates</button> - <button data-flag="sound" aria-pressed="${prefs.sound}">sound</button> - <button data-flag="reactions" aria-pressed="${prefs.reactions}">reactions</button> - </div> - </div> - <div class="field"> - <label>coach by default</label> - <div class="toggle"> - <button data-flag="threats" aria-pressed="${prefs.threats}">threats</button> - <button data-flag="blunder" aria-pressed="${prefs.blunder}">blunder warning</button> - </div> - </div> - </div>`; - app.querySelector("[data-back]").onclick = () => goto("home"); - app.querySelectorAll("#sets button").forEach((b) => { - b.onclick = () => { - prefs = { ...prefs, pieceSet: b.dataset.set }; - savePrefs(prefs); - app.querySelectorAll("#sets button").forEach((c) => c.setAttribute("aria-pressed", String(c === b))); - }; - }); - app.querySelectorAll("[data-flag]").forEach((b) => { - const key = b.dataset.flag; - b.onclick = () => { - prefs = { ...prefs, [key]: !prefs[key] }; - savePrefs(prefs); - b.setAttribute("aria-pressed", String(prefs[key])); - }; - }); -} -function renderAbout() { - app.innerHTML = ` - <div class="topbar"> - <h2>about</h2> - <button class="back" data-back>← menu</button> - </div> - <div class="about"> - <p>Emchess is a small chess game you play in your browser. No accounts, no ads, nothing to install. Just a board, a good opponent, and some quiet.</p> - <div class="card"> - Emchess<br> - Created by Pablo Murad<br> - pablomurad[at]pm[dot]me - </div> - <p class="kill">Kill the king β€” MMXXVI</p> - </div>`; - app.querySelector("[data-back]").onclick = () => goto("home"); -} -main();
M src/main.tssrc/main.ts

@@ -1,6 +1,6 @@

import "./style.css"; import type { Color } from "./game/state"; -import { PERSONALITIES, getPersonality } from "./game/personalities"; +import { PERSONALITIES, getPersonality, type Personality } from "./game/personalities"; import { PIECE_SETS } from "./board/pieces"; import { TIME_CONTROLS, timeControl } from "./game/clock"; import { loadPrefs, savePrefs, type Prefs } from "./util/storage";

@@ -10,8 +10,13 @@ const app = document.querySelector<HTMLDivElement>("#app")!;

let prefs = loadPrefs(); function main() { + applyTheme(); window.addEventListener("hashchange", render); render(); +} + +function applyTheme() { + document.documentElement.dataset.theme = prefs.theme; } function render() {

@@ -83,6 +88,7 @@ <div class="field">

<label>opponent</label> <div class="pick" id="opps">${opponentRows}</div> </div> + <div class="dossier" id="dossier"></div> <div class="field"> <label>time</label> <div class="toggle" id="times">${timeButtons}</div>

@@ -102,7 +108,11 @@

app.querySelector<HTMLButtonElement>("[data-back]")!.onclick = () => goto("home"); pressGroup("#sides button", (b) => (side = b.dataset.side as Prefs["side"])); - pressGroup("#opps .row", (b) => (personality = b.dataset.id!)); + pressGroup("#opps .row", (b) => { + personality = b.dataset.id!; + renderDossier(getPersonality(personality)); + }); + renderDossier(getPersonality(personality)); pressGroup("#times button", (b) => { clockId = b.dataset.tc!; app.querySelector("#custom")!.classList.toggle("hidden", clockId !== "custom");

@@ -135,6 +145,31 @@ };

}); } +// A character-select dossier: the opponent's face, pitch, and three arcade stat bars. +function statBar(v: number): string { + const n = Math.max(0, Math.min(10, Math.round(v * 10))); + return "β–“".repeat(n) + "β–‘".repeat(10 - n); +} + +function renderDossier(p: Personality) { + const el = app.querySelector("#dossier"); + if (!el) return; + const power = p.skill / 20; + const speed = 1 - (p.movetime - 120) / (1000 - 120); + const chaos = p.blunderChance / 0.28; + el.innerHTML = ` + <span class="d-face">${p.face}</span> + <div class="d-body"> + <div class="d-name">${p.name}</div> + <div class="d-blurb">${p.blurb}</div> + <div class="d-stats"> + <span>power ${statBar(power)}</span> + <span>speed ${statBar(speed)}</span> + <span>chaos ${statBar(chaos)}</span> + </div> + </div>`; +} + function renderSettings() { const setRows = PIECE_SETS.map( (s) => `<button data-set="${s.id}" aria-pressed="${s.id === prefs.pieceSet}">${s.name.toLowerCase()}</button>`

@@ -151,6 +186,13 @@ <label>pieces</label>

<div class="toggle" id="sets">${setRows}</div> </div> <div class="field"> + <label>theme</label> + <div class="toggle" id="themes"> + <button data-theme="paper" aria-pressed="${prefs.theme === "paper"}">paper</button> + <button data-theme="crt" aria-pressed="${prefs.theme === "crt"}">crt</button> + </div> + </div> + <div class="field"> <label>board</label> <div class="toggle"> <button data-flag="coords" aria-pressed="${prefs.coords}">coordinates</button>

@@ -174,6 +216,15 @@ b.onclick = () => {

prefs = { ...prefs, pieceSet: b.dataset.set as Prefs["pieceSet"] }; savePrefs(prefs); app.querySelectorAll("#sets button").forEach((c) => c.setAttribute("aria-pressed", String(c === b))); + }; + }); + + app.querySelectorAll<HTMLButtonElement>("#themes button").forEach((b) => { + b.onclick = () => { + prefs = { ...prefs, theme: b.dataset.theme as Prefs["theme"] }; + savePrefs(prefs); + applyTheme(); + app.querySelectorAll("#themes button").forEach((c) => c.setAttribute("aria-pressed", String(c === b))); }; });
D src/play.js

@@ -1,396 +0,0 @@

-import { Game } from "./game/state"; -import { Board } from "./board/board"; -import { Engine } from "./game/engine"; -import { getSet } from "./board/pieces"; -import { Clock, formatTime } from "./game/clock"; -import { threats, PIECE_NAME } from "./game/analysis"; -import { explainPosition, explainMistake, blunderCheck } from "./game/coach"; -import { sound } from "./util/sound"; -import { loadPrefs, savePrefs, saveGame, clearGame } from "./util/storage"; -import { goto } from "./util/url"; -const app = document.querySelector("#app"); -let prefs = loadPrefs(); -let engine = null; -// Entry point for the game screen. Loaded on demand so the home page stays tiny. -export async function startGame(human, personality, tc, text) { - prefs = loadPrefs(); - const initial = parseInitial(text); - if (initial === "invalid") - return "That is not a valid FEN or PGN."; - await new PlayController(human, personality, tc, initial).mount(); - return null; -} -function parseInitial(text) { - const t = text.trim(); - if (!t) - return null; - const probe = new Game(); - if (probe.load(t)) - return { fen: t }; - if (probe.loadPgn(t)) - return { pgn: t }; - return "invalid"; -} -class PlayController { - constructor(human, personality, tc, initial) { - this.human = human; - this.personality = personality; - this.thinking = false; - this.timeLoss = null; - this.threatsOn = prefs.threats; - this.blunderOn = prefs.blunder; - this.beforeHuman = null; - this.afterHuman = null; - this.humanMove = null; - this.pendingBlunder = null; - this.onTick = (w, b) => { - this.setClock("w", w); - this.setClock("b", b); - }; - this.onFlag = (side) => { - this.timeLoss = side; - this.clock.stop(); - if (prefs.sound) - sound.end(); - this.update(); - }; - this.game = new Game(initial?.fen); - if (initial?.pgn) - this.game.loadPgn(initial.pgn); - this.view = human; - this.clock = new Clock(tc, this.onTick, this.onFlag); - if (!engine) - engine = new Engine(); - } - async mount() { - const opp = this.human === "w" ? "b" : "w"; - const clocksHtml = this.clock.enabled - ? `<div class="clocks" id="clocks"> - <div class="clock" data-c="${opp}"><span class="who">opponent</span><span class="time" id="clk-${opp}">--</span></div> - <div class="clock" data-c="${this.human}"><span class="who">you</span><span class="time" id="clk-${this.human}">--</span></div> - </div>` - : ""; - app.innerHTML = ` - <div class="topbar"> - <h2>${this.personality.face} ${this.personality.name}</h2> - <button class="back" data-back>← menu</button> - </div> - <div class="play"> - <div class="board-wrap"><div id="board"></div></div> - <aside class="panel"> - ${clocksHtml} - <div class="status" id="status"></div> - <div class="assist" id="assist"> - <button id="t-threats" aria-pressed="${this.threatsOn}">threats</button> - <button id="t-blunder" aria-pressed="${this.blunderOn}">blunder</button> - <button id="explain">explain</button> - <button id="why">why?</button> - </div> - <div class="coach" id="coach"></div> - <div class="moves"><ol id="movelist"></ol></div> - <div class="controls"> - <button id="undo">undo</button> - <button id="flip">flip</button> - <button id="pgn">copy pgn</button> - <button id="new">new game</button> - </div> - </aside> - </div>`; - app.querySelector("[data-back]").onclick = () => { - this.clock.stop(); - clearGame(); - goto("home"); - }; - this.board = new Board(app.querySelector("#board"), { - orientation: this.view, - set: getSet(prefs.pieceSet), - coords: prefs.coords, - legalTargets: (sq) => (this.canMove() ? this.game.legalTargets(sq) : []), - canMove: () => this.canMove(), - onMove: (from, to, promo) => this.onHumanMove(from, to, promo), - }); - this.board.setPosition(this.game.board()); - this.wireControls(); - this.refreshCheck(); - this.update(); - if (engine) - await engine.configure(this.personality); - if (this.clock.enabled) - this.clock.start(this.game.turn); - if (this.game.turn !== this.human) - this.aiMove(); - } - wireControls() { - app.querySelector("#undo").onclick = () => this.undo(); - app.querySelector("#flip").onclick = () => { - this.view = this.view === "w" ? "b" : "w"; - this.board.setOrientation(this.view); - this.board.setPosition(this.game.board()); - this.refreshCheck(); - this.refreshThreats(); - }; - app.querySelector("#pgn").onclick = (e) => { - navigator.clipboard?.writeText(this.game.pgn()); - flash(e.target, "copied", "copy pgn"); - }; - app.querySelector("#new").onclick = () => { - this.clock.stop(); - clearGame(); - goto("play"); - }; - const threatsBtn = app.querySelector("#t-threats"); - threatsBtn.onclick = () => { - this.threatsOn = !this.threatsOn; - prefs = { ...prefs, threats: this.threatsOn }; - savePrefs(prefs); - threatsBtn.setAttribute("aria-pressed", String(this.threatsOn)); - this.refreshThreats(); - }; - const blunderBtn = app.querySelector("#t-blunder"); - blunderBtn.onclick = () => { - this.blunderOn = !this.blunderOn; - prefs = { ...prefs, blunder: this.blunderOn }; - savePrefs(prefs); - blunderBtn.setAttribute("aria-pressed", String(this.blunderOn)); - }; - app.querySelector("#explain").onclick = () => this.explain(); - app.querySelector("#why").onclick = () => this.why(); - if (this.clock.enabled) - app.querySelector("#undo").disabled = true; - } - canMove() { - return (!this.thinking && - !this.pendingBlunder && - !this.timeLoss && - this.game.turn === this.human && - !this.game.outcome().over); - } - onHumanMove(from, to, promo) { - if (this.blunderOn) { - const risk = blunderCheck(this.game, from, to, promo, this.human); - if (risk) { - this.pendingBlunder = { from, to, promo }; - this.askBlunder(PIECE_NAME[risk.type], risk.square); - return; - } - } - this.commitHuman(from, to, promo); - } - commitHuman(from, to, promo) { - this.beforeHuman = this.game.clone(); - const move = this.game.play(from, to, promo); - if (!move) - return; - this.humanMove = move; - this.afterHuman = this.game.clone(); - this.setCoach(""); - this.afterMove(move); - if (!this.game.outcome().over && !this.timeLoss) - this.aiMove(); - } - async aiMove() { - if (!engine || this.game.outcome().over || this.timeLoss) - return; - this.thinking = true; - this.showThinking(); - const uci = await engine.chooseMove(this.game.fen, this.personality, this.game.legalUci()); - this.thinking = false; - if (!uci || this.timeLoss) - return this.update(); - const move = this.game.playUci(uci); - if (move) - this.afterMove(move); - else - this.update(); - } - afterMove(move) { - this.board.applyMove(move); - if (this.clock.enabled) - this.clock.press(move.color); - this.playSound(move); - this.showReactions(move); - this.refreshCheck(); - this.refreshThreats(); - this.update(); - this.persist(); - if (this.game.outcome().over) { - this.clock.stop(); - if (prefs.sound) - sound.end(); - } - } - undo() { - if (this.thinking || this.clock.enabled) - return; - this.game.undo(); - if (this.game.turn !== this.human) - this.game.undo(); - this.pendingBlunder = null; - this.setCoach(""); - this.board.setPosition(this.game.board()); - this.refreshCheck(); - this.refreshThreats(); - this.update(); - this.persist(); - } - playSound(move) { - if (!prefs.sound) - return; - if (move.captured) - sound.capture(); - else - sound.move(); - if (this.game.inCheck()) - sound.check(); - } - showReactions(move) { - if (!prefs.reactions) - return; - if (move.flags.includes("p")) - this.board.react(move.to, "✨"); - if (this.game.inCheck()) { - const king = this.game.kingSquare(this.game.turn); - if (king) - this.board.react(king, "❗"); - } - } - refreshCheck() { - this.board.setCheck(this.game.inCheck() ? this.game.kingSquare(this.game.turn) : null); - } - refreshThreats() { - if (this.threatsOn) - this.board.setThreats(threats(this.game, this.human).map((t) => t.square)); - else - this.board.setThreats([]); - } - async explain() { - if (!engine) - return; - this.setCoach("thinking…"); - const { cp, mate } = await engine.evaluate(this.game.fen); - const forHuman = this.game.turn === this.human ? 1 : -1; - const cpH = cp === null ? null : cp * forHuman; - const mateH = mate === null ? null : mate * forHuman; - this.setCoach(explainPosition(this.game, this.human, cpH, mateH)); - } - why() { - if (!this.beforeHuman || !this.afterHuman || !this.humanMove) { - this.setCoach("Make a move first, then ask."); - return; - } - const reason = explainMistake(this.beforeHuman, this.afterHuman, this.humanMove, this.human); - this.setCoach(reason ?? "That move was fine β€” nothing was hanging."); - } - askBlunder(name, square) { - this.setCoach(""); - const coach = app.querySelector("#coach"); - coach.innerHTML = `This move leaves your ${name} on ${square} for the taking. - <div class="assist" style="margin-top:6px"> - <button id="playanyway">play it anyway</button> - <button id="takeback">take it back</button> - </div>`; - coach.querySelector("#playanyway").onclick = () => { - const p = this.pendingBlunder; - this.pendingBlunder = null; - coach.textContent = ""; - this.commitHuman(p.from, p.to, p.promo); - }; - coach.querySelector("#takeback").onclick = () => { - this.pendingBlunder = null; - coach.textContent = ""; - }; - } - setCoach(text) { - const coach = app.querySelector("#coach"); - if (coach) - coach.textContent = text; - } - setClock(c, ms) { - const el = document.getElementById("clk-" + c); - if (!el) - return; - el.textContent = formatTime(ms); - const box = el.parentElement; - box.classList.toggle("running", this.game.turn === c && this.canRun()); - box.classList.toggle("low", ms < 10000 && ms > 0); - box.classList.toggle("flag", this.timeLoss === c); - } - canRun() { - return !this.timeLoss && !this.game.outcome().over; - } - showThinking() { - const status = app.querySelector("#status"); - if (status) - status.innerHTML = `<span class="thinking">${this.personality.face} is thinking…</span>`; - } - update() { - this.renderStatus(); - this.renderMoves(); - const undo = app.querySelector("#undo"); - if (undo && !this.clock.enabled) - undo.disabled = this.game.history().length === 0; - } - renderStatus() { - const status = app.querySelector("#status"); - if (!status) - return; - if (this.timeLoss) { - status.textContent = - this.timeLoss === this.human ? "You lost on time." : "Your opponent lost on time. You win."; - return; - } - const outcome = this.game.outcome(); - if (outcome.over) { - status.textContent = describeOutcome(outcome, this.human); - return; - } - const dot = `<span class="turn-dot ${this.game.turn}"></span>`; - status.innerHTML = dot + (this.game.turn === this.human ? "Your move" : "Waiting…"); - } - renderMoves() { - const list = app.querySelector("#movelist"); - if (!list) - return; - const history = this.game.history(); - let html = ""; - for (let i = 0; i < history.length; i += 2) { - const no = i / 2 + 1; - const white = history[i]?.san ?? ""; - const black = history[i + 1]?.san ?? ""; - const wHere = i === history.length - 1 ? " here" : ""; - const bHere = i + 1 === history.length - 1 ? " here" : ""; - html += `<li><span class="no">${no}.</span><span class="san${wHere}">${white}</span><span class="san${bHere}">${black}</span></li>`; - } - list.innerHTML = html; - list.parentElement.scrollTop = list.parentElement.scrollHeight; - } - persist() { - if (this.game.outcome().over || this.timeLoss) { - clearGame(); - return; - } - saveGame({ - pgn: this.game.pgn(), - side: this.human, - personality: this.personality.id, - savedAt: Date.now(), - }); - } -} -function flash(btn, on, off) { - btn.textContent = on; - setTimeout(() => (btn.textContent = off), 1200); -} -function describeOutcome(outcome, human) { - if (outcome.reason === "checkmate") { - return outcome.winner === human ? "Checkmate. You win." : "Checkmate. You lose."; - } - const draws = { - stalemate: "Stalemate. It's a draw.", - insufficient: "Draw β€” not enough material to mate.", - repetition: "Draw by repetition.", - fifty: "Draw by the fifty-move rule.", - draw: "The game is a draw.", - }; - return draws[outcome.reason] ?? "The game is over."; -}
M src/play.tssrc/play.ts

@@ -2,7 +2,7 @@ import { Game, type Color, type Square, type Move } from "./game/state";

import { Board } from "./board/board"; import { Engine } from "./game/engine"; import type { Personality } from "./game/personalities"; -import { getSet } from "./board/pieces"; +import { getSet, type PieceType } from "./board/pieces"; import { Clock, formatTime, type TimeControl } from "./game/clock"; import { threats, PIECE_NAME } from "./game/analysis"; import { explainPosition, explainMistake, blunderCheck } from "./game/coach";

@@ -15,6 +15,11 @@

const app = document.querySelector<HTMLDivElement>("#app")!; let prefs = loadPrefs(); let engine: Engine | null = null; +let liveBoard: Board | null = null; + +const PIECE_VALUE: Record<string, number> = { p: 1, n: 3, b: 3, r: 5, q: 9 }; +const START_COUNT: Record<string, number> = { p: 8, n: 2, b: 2, r: 2, q: 1 }; +const GRAVEYARD_ORDER: PieceType[] = ["q", "r", "b", "n", "p"]; // Entry point for the game screen. Loaded on demand so the home page stays tiny. export async function startGame(

@@ -26,6 +31,8 @@ ): Promise<string | null> {

prefs = loadPrefs(); const initial = parseInitial(text); if (initial === "invalid") return "That is not a valid FEN or PGN."; + liveBoard?.destroy(); + liveBoard = null; await new PlayController(human, personality, tc, initial).mount(); return null; }

@@ -46,6 +53,7 @@ private clock: Clock;

private thinking = false; private view: Color; private timeLoss: Color | null = null; + private over = false; private threatsOn = prefs.threats; private blunderOn = prefs.blunder;

@@ -55,10 +63,15 @@ private afterHuman: Game | null = null;

private humanMove: Move | null = null; private pendingBlunder: { from: Square; to: Square; promo?: Promo } | null = null; + // Evaluations are queued so they never interleave with a search on the wire. + private evalQueue: Promise<unknown> = Promise.resolve(); + private evalToken = 0; + private lastTickSec = -1; + constructor( private human: Color, private personality: Personality, - tc: TimeControl, + private tc: TimeControl, initial: { fen?: string; pgn?: string } | null ) { this.game = new Game(initial?.fen);

@@ -70,12 +83,11 @@ }

async mount() { const opp = this.human === "w" ? "b" : "w"; - const clocksHtml = this.clock.enabled - ? `<div class="clocks" id="clocks"> - <div class="clock" data-c="${opp}"><span class="who">opponent</span><span class="time" id="clk-${opp}">--</span></div> - <div class="clock" data-c="${this.human}"><span class="who">you</span><span class="time" id="clk-${this.human}">--</span></div> - </div>` - : ""; + const glyphs = getSet(prefs.pieceSet).glyphs; + const clockHtml = (c: Color) => + this.clock.enabled + ? `<span class="clock" data-c="${c}"><span class="bar" id="bar-${c}"></span><span class="time" id="clk-${c}">--</span></span>` + : ""; app.innerHTML = ` <div class="topbar">

@@ -83,9 +95,30 @@ <h2>${this.personality.face} ${this.personality.name}</h2>

<button class="back" data-back>← menu</button> </div> <div class="play"> - <div class="board-wrap"><div id="board"></div></div> + <div class="arena"> + <div class="hud opp"> + <span class="face">${this.personality.face}</span> + <span class="who">${this.personality.name}</span> + <span class="captured" id="cap-${opp}"></span> + <span class="mat" id="mat-${opp}"></span> + ${clockHtml(opp)} + </div> + <div class="board-row"> + <div class="evalwrap"> + <div class="evalbar"><div class="evalfill" id="evalfill"></div></div> + <span class="evalnum" id="evalnum"></span> + </div> + <div class="board-wrap"><div id="board"></div></div> + </div> + <div class="hud you"> + <span class="face">${glyphs.k}</span> + <span class="who">you Β· ${this.human === "w" ? "white" : "black"}</span> + <span class="captured" id="cap-${this.human}"></span> + <span class="mat" id="mat-${this.human}"></span> + ${clockHtml(this.human)} + </div> + </div> <aside class="panel"> - ${clocksHtml} <div class="status" id="status"></div> <div class="assist" id="assist"> <button id="t-threats" aria-pressed="${this.threatsOn}">threats</button>

@@ -102,7 +135,8 @@ <button id="pgn">copy pgn</button>

<button id="new">new game</button> </div> </aside> - </div>`; + </div> + <div class="gameover hidden" id="gameover"></div>`; app.querySelector<HTMLButtonElement>("[data-back]")!.onclick = () => { this.clock.stop();

@@ -118,6 +152,7 @@ legalTargets: (sq) => (this.canMove() ? this.game.legalTargets(sq) : []),

canMove: () => this.canMove(), onMove: (from, to, promo) => this.onHumanMove(from, to, promo as Promo), }); + liveBoard = this.board; this.board.setPosition(this.game.board()); this.wireControls();

@@ -125,8 +160,11 @@ this.refreshCheck();

this.update(); if (engine) await engine.configure(this.personality); + if (prefs.sound) sound.start(); if (this.clock.enabled) this.clock.start(this.game.turn); - if (this.game.turn !== this.human) this.aiMove(); + if (this.game.outcome().over) this.showGameOver(); + else if (this.game.turn !== this.human) void this.aiMove().finally(() => this.refreshEval()); + else void this.refreshEval(); } private wireControls() {

@@ -198,8 +236,11 @@ if (!move) return;

this.humanMove = move; this.afterHuman = this.game.clone(); this.setCoach(""); + this.board.clearArrow(); this.afterMove(move); - if (!this.game.outcome().over && !this.timeLoss) this.aiMove(); + if (!this.game.outcome().over && !this.timeLoss) { + void this.aiMove().finally(() => this.refreshEval()); + } } private async aiMove() {

@@ -208,10 +249,13 @@ this.thinking = true;

this.showThinking(); const uci = await engine.chooseMove(this.game.fen, this.personality, this.game.legalUci()); this.thinking = false; + app.querySelector(".hud.opp")?.classList.remove("thinking"); if (!uci || this.timeLoss) return this.update(); const move = this.game.playUci(uci); - if (move) this.afterMove(move); - else this.update(); + if (move) { + this.board.setArrow(move.from as Square, move.to as Square); + this.afterMove(move); + } else this.update(); } private afterMove(move: Move) {

@@ -226,11 +270,15 @@ this.persist();

if (this.game.outcome().over) { this.clock.stop(); if (prefs.sound) sound.end(); + this.showGameOver(); } } private undo() { if (this.thinking || this.clock.enabled) return; + this.hideGameOver(); + this.board.clearDefeat(); + this.board.clearArrow(); this.game.undo(); if (this.game.turn !== this.human) this.game.undo(); this.pendingBlunder = null;

@@ -240,11 +288,14 @@ this.refreshCheck();

this.refreshThreats(); this.update(); this.persist(); + void this.refreshEval(); } private playSound(move: Move) { if (!prefs.sound) return; - if (move.captured) sound.capture(); + if (move.flags.includes("p")) sound.promote(); + else if (move.flags.includes("k") || move.flags.includes("q")) sound.castle(); + else if (move.captured) sound.capture(); else sound.move(); if (this.game.inCheck()) sound.check(); }

@@ -267,10 +318,48 @@ if (this.threatsOn) this.board.setThreats(threats(this.game, this.human).map((t) => t.square));

else this.board.setThreats([]); } + private runEngineEval(fen: string, movetime = 400): Promise<{ cp: number | null; mate: number | null }> { + const p = this.evalQueue.then(() => + engine ? engine.evaluate(fen, movetime) : { cp: null, mate: null } + ); + this.evalQueue = p.catch(() => {}); + return p; + } + + // The eval bar reads from White's point of view, no matter who sits where. + private async refreshEval() { + if (!engine || this.game.outcome().over || this.timeLoss) return; + const token = ++this.evalToken; + const fen = this.game.fen; + const stm = this.game.turn; + const { cp, mate } = await this.runEngineEval(fen); + if (token !== this.evalToken) return; + if (mate !== null) { + const m = stm === "w" ? mate : -mate; // from White's side of the board + this.renderEval(m > 0 ? 1 : -1, (m > 0 ? "#" : "-#") + Math.abs(m)); + } else if (cp !== null) { + const cpW = stm === "w" ? cp : -cp; + const score = (2 / Math.PI) * Math.atan(cpW / 350); + this.renderEval(score, (cpW >= 0 ? "+" : "") + (cpW / 100).toFixed(1)); + } + } + + private renderEval(score: number, label: string) { + const fill = app.querySelector<HTMLElement>("#evalfill"); + const num = app.querySelector<HTMLElement>("#evalnum"); + if (!fill || !num) return; + fill.style.height = Math.round(((score + 1) / 2) * 100) + "%"; + num.textContent = label; + } + private async explain() { if (!engine) return; + if (this.thinking) { + this.setCoach("One moment β€” the engine is busy with its move."); + return; + } this.setCoach("thinking…"); - const { cp, mate } = await engine.evaluate(this.game.fen); + const { cp, mate } = await this.runEngineEval(this.game.fen, 500); const forHuman = this.game.turn === this.human ? 1 : -1; const cpH = cp === null ? null : cp * forHuman; const mateH = mate === null ? null : mate * forHuman;

@@ -314,6 +403,16 @@

private onTick = (w: number, b: number) => { this.setClock("w", w); this.setClock("b", b); + const ms = this.human === "w" ? w : b; + if (prefs.sound && this.canRun() && this.game.turn === this.human && ms > 0 && ms <= 10000) { + const sec = Math.ceil(ms / 1000); + if (sec !== this.lastTickSec) { + this.lastTickSec = sec; + sound.tick(); + } + } else { + this.lastTickSec = -1; + } }; private setClock(c: Color, ms: number) {

@@ -324,6 +423,11 @@ const box = el.parentElement!;

box.classList.toggle("running", this.game.turn === c && this.canRun()); box.classList.toggle("low", ms < 10000 && ms > 0); box.classList.toggle("flag", this.timeLoss === c); + const bar = document.getElementById("bar-" + c); + if (bar) { + const pct = Math.max(0, Math.min(100, (ms / this.clock.baseMs) * 100)); + bar.style.width = pct + "%"; + } } private canRun(): boolean {

@@ -335,16 +439,19 @@ this.timeLoss = side;

this.clock.stop(); if (prefs.sound) sound.end(); this.update(); + this.showGameOver(); }; private showThinking() { const status = app.querySelector<HTMLElement>("#status"); if (status) status.innerHTML = `<span class="thinking">${this.personality.face} is thinking…</span>`; + app.querySelector(".hud.opp")?.classList.add("thinking"); } private update() { this.renderStatus(); this.renderMoves(); + this.renderMaterial(); const undo = app.querySelector<HTMLButtonElement>("#undo"); if (undo && !this.clock.enabled) undo.disabled = this.game.history().length === 0; }

@@ -366,6 +473,43 @@ const dot = `<span class="turn-dot ${this.game.turn}"></span>`;

status.innerHTML = dot + (this.game.turn === this.human ? "Your move" : "Waiting…"); } + // Fallen pieces line up next to the side that lost them; the leader gets a +N chip. + private renderMaterial() { + const set = getSet(prefs.pieceSet); + const count: Record<Color, Record<string, number>> = { + w: { p: 0, n: 0, b: 0, r: 0, q: 0 }, + b: { p: 0, n: 0, b: 0, r: 0, q: 0 }, + }; + for (const row of this.game.board()) { + for (const cell of row) { + if (cell && cell.type !== "k") count[cell.color][cell.type]++; + } + } + let valW = 0; + let valB = 0; + for (const t of GRAVEYARD_ORDER) { + valW += count.w[t] * PIECE_VALUE[t]; + valB += count.b[t] * PIECE_VALUE[t]; + } + for (const c of ["w", "b"] as Color[]) { + const el = app.querySelector<HTMLElement>("#cap-" + c); + if (el) { + let html = ""; + for (const t of GRAVEYARD_ORDER) { + const lost = START_COUNT[t] - count[c][t]; + const cls = set.traditional ? ` class="cap trad ${c}"` : ` class="cap"`; + for (let i = 0; i < lost; i++) html += `<span${cls}>${set.glyphs[t]}</span>`; + } + el.innerHTML = html; + } + const mat = app.querySelector<HTMLElement>("#mat-" + c); + if (mat) { + const diff = c === "w" ? valW - valB : valB - valW; + mat.textContent = diff > 0 ? "+" + diff : ""; + } + } + } + private renderMoves() { const list = app.querySelector<HTMLOListElement>("#movelist"); if (!list) return;

@@ -381,6 +525,79 @@ html += `<li><span class="no">${no}.</span><span class="san${wHere}">${white}</span><span class="san${bHere}">${black}</span></li>`;

} list.innerHTML = html; list.parentElement!.scrollTop = list.parentElement!.scrollHeight; + } + + private showGameOver() { + if (this.over) return; + this.over = true; + + let banner = "DRAW"; + let detail: string; + const outcome = this.game.outcome(); + if (this.timeLoss) { + banner = "FLAG FALL"; + detail = this.timeLoss === this.human ? "You lost on time." : "You win on time."; + } else if (outcome.over && outcome.reason === "checkmate") { + banner = "CHECKMATE"; + detail = outcome.winner === this.human ? "You win." : "You lose."; + const king = this.game.kingSquare(this.game.turn); + if (king) { + this.board.defeat(king); + this.board.react(king, "πŸ’€", true); + } + this.renderEval(outcome.winner === "w" ? 1 : -1, outcome.winner === "w" ? "1-0" : "0-1"); + } else { + if (outcome.over && outcome.reason === "stalemate") banner = "STALEMATE"; + detail = outcome.over ? describeOutcome(outcome, this.human) : "The game is over."; + } + + const moves = Math.ceil(this.game.history().length / 2); + const box = app.querySelector<HTMLElement>("#gameover")!; + box.innerHTML = ` + <div class="go-box"> + <pre class="go-banner">${banner}</pre> + <p class="go-detail">${detail}</p> + <p class="go-meta">${moves} moves Β· ${this.personality.face} ${this.personality.name}</p> + <div class="go-actions"> + <button class="btn" id="rematch">rematch</button> + <button class="btn" id="gonew">new opponent</button> + <button class="btn ghost" id="gomenu">menu</button> + </div> + <button class="go-pgn" id="gopgn">[ copy pgn ]</button> + </div>`; + + box.querySelector<HTMLButtonElement>("#rematch")!.onclick = () => { + this.clock.stop(); + clearGame(); + const next: Color = this.human === "w" ? "b" : "w"; + void startGame(next, this.personality, this.tc, ""); + }; + box.querySelector<HTMLButtonElement>("#gonew")!.onclick = () => { + this.clock.stop(); + clearGame(); + goto("play"); + }; + box.querySelector<HTMLButtonElement>("#gomenu")!.onclick = () => { + this.clock.stop(); + clearGame(); + goto("home"); + }; + box.querySelector<HTMLButtonElement>("#gopgn")!.onclick = (e) => { + navigator.clipboard?.writeText(this.game.pgn()); + flash(e.target as HTMLButtonElement, "[ copied ]", "[ copy pgn ]"); + }; + + // Let the final position land β€” the falling king, the skull β€” before the curtain. + setTimeout(() => box.classList.remove("hidden"), 700); + } + + private hideGameOver() { + this.over = false; + const box = app.querySelector<HTMLElement>("#gameover"); + if (box) { + box.classList.add("hidden"); + box.innerHTML = ""; + } } private persist() {
M src/style.csssrc/style.css

@@ -153,22 +153,90 @@ }

.start { margin-top: 10px; - font-size: 20px; + font-size: 18px; letter-spacing: 2px; - color: var(--ink); + background: var(--ink); + color: var(--paper); + padding: 10px 22px; + box-shadow: 3px 3px 0 var(--line); + transition: transform .06s ease, box-shadow .06s ease; } -.start::before { content: "> "; color: var(--accent); } -.start:hover { color: var(--accent); } -.start:hover::after { content: "_"; } +.start::before { content: "> "; } +.start:hover { transform: translate(-1px, -1px); box-shadow: 4px 4px 0 var(--line); } +.start:hover::after { content: " _"; } +.start:active { transform: translate(2px, 2px); box-shadow: 1px 1px 0 var(--line); } /* Play */ .play { display: grid; grid-template-columns: auto 240px; gap: 30px; align-items: start; } +.arena { display: flex; flex-direction: column; gap: 8px; } +.board-row { display: flex; gap: 10px; align-items: stretch; } @media (max-width: 720px) { .play { grid-template-columns: 1fr; } - :root { --sq: min(11.4vw, 62px); } + :root { --sq: min(10.6vw, 62px); } + .board-row { justify-content: center; } } +/* HUD β€” one row per combatant: face, name, their fallen, and their clock */ + +.hud { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; min-height: 26px; } +.hud .face { font-family: system-ui, "Segoe UI Emoji", sans-serif; font-size: 20px; display: inline-block; } +.hud .who { color: var(--ink); } +.hud .captured { display: flex; flex-wrap: wrap; gap: 1px; align-items: baseline; font-family: system-ui, "Segoe UI Emoji", sans-serif; font-size: 15px; line-height: 1; } +.hud .captured .cap.trad.w { color: #fbf7ec; text-shadow: 0 0 1px #000, 0 1px 1px rgba(0,0,0,.45); } +.hud .captured .cap.trad.b { color: #141109; } +.hud .mat { color: var(--accent); font-size: 14px; } +.hud.thinking .face { animation: bob 0.8s ease infinite; } +@keyframes bob { 50% { transform: translateY(-3px); } } + +/* Clock β€” the digits ride on a draining time bar */ + +.hud .clock { + position: relative; + margin-left: auto; + overflow: hidden; + display: inline-flex; + padding: 2px 10px; + border: 1px solid var(--line); + min-width: 7ch; + justify-content: flex-end; +} +.clock .bar { + position: absolute; + left: 0; top: 0; bottom: 0; + width: 100%; + background: var(--accent); + opacity: 0.16; + transition: width 0.1s linear; +} +.clock .time { position: relative; font-size: 20px; letter-spacing: 1px; } +.clock.running { border-color: var(--ink); } +.clock.low .time { color: var(--check); } +.clock.low { animation: pulse 0.5s step-end infinite; } +@keyframes pulse { 50% { border-color: var(--check); } } +.clock.flag { opacity: 0.5; } + +/* Eval bar β€” white's share of the position, from the bottom up */ + +.evalwrap { display: flex; flex-direction: column; align-items: center; gap: 4px; } +.evalbar { + width: 12px; + flex: 1; + border: 1px solid var(--line); + background: #1b1a15; + display: flex; + flex-direction: column; + justify-content: flex-end; +} +.evalfill { width: 100%; height: 50%; background: #f5f1e6; transition: height 0.45s ease; } +.evalnum { font-family: var(--small); font-size: 12px; color: var(--soft); } + +/* Last-move arrow */ + +.arrows { position: absolute; inset: 0; width: 100%; height: 100%; z-index: 4; pointer-events: none; } +.arrow { stroke: var(--accent); stroke-width: 0.22; stroke-linecap: round; fill: none; opacity: 0.55; } +polygon.arrow { fill: var(--accent); stroke: none; } + .board-wrap { width: calc(var(--sq) * 8); } .board { position: relative;

@@ -287,22 +355,6 @@ transition: transform .8s ease, opacity .8s ease;

} .reaction.rise { transform: translateY(-40%); opacity: 0; } -/* Clocks */ - -.clocks { display: flex; flex-direction: column; gap: 6px; } -.clock { - display: flex; - justify-content: space-between; - align-items: baseline; - padding: 6px 10px; - border: 1px solid var(--line); -} -.clock .who { color: var(--soft); font-size: 14px; } -.clock .time { font-size: 26px; letter-spacing: 1px; } -.clock.running { border-color: var(--ink); } -.clock.low .time { color: var(--check); } -.clock.flag { opacity: .5; } - /* Assist + coach */ .assist { display: flex; flex-wrap: wrap; gap: 4px 12px; }

@@ -349,3 +401,122 @@

.about { max-width: 560px; line-height: 26px; } .about .card { margin-top: 20px; padding-left: 14px; border-left: 2px solid var(--accent); } .about .kill { margin-top: 26px; color: var(--accent); letter-spacing: 1px; } + +/* Dossier β€” the selected opponent's character sheet */ + +.dossier { + display: flex; + gap: 14px; + align-items: flex-start; + border: 1px solid var(--line); + background: var(--panel); + padding: 12px 14px; + margin: -14px 0 30px; +} +.d-face { font-size: 34px; font-family: system-ui, "Segoe UI Emoji", sans-serif; line-height: 1; } +.d-name { color: var(--ink); } +.d-blurb { color: var(--soft); font-size: 14px; margin-bottom: 8px; } +.d-stats { display: flex; flex-direction: column; gap: 2px; font-size: 14px; color: var(--soft); } +.d-stats span { white-space: pre; } + +/* Buttons with some heft */ + +.btn { + font-family: inherit; + font-size: 16px; + letter-spacing: 1px; + background: var(--ink); + color: var(--paper); + padding: 8px 16px; + box-shadow: 3px 3px 0 var(--line); + transition: transform .06s ease, box-shadow .06s ease; +} +.btn:hover { transform: translate(-1px, -1px); box-shadow: 4px 4px 0 var(--line); } +.btn:active { transform: translate(2px, 2px); box-shadow: 1px 1px 0 var(--line); } +.btn.ghost { background: none; color: var(--soft); box-shadow: none; border: 1px solid var(--line); } +.btn.ghost:hover { color: var(--ink); border-color: var(--ink); transform: none; } + +/* The fallen king and the flinching board */ + +.piece.defeated .glyph { + filter: grayscale(1) brightness(.75); + transform: rotate(80deg); + transition: transform .4s ease, filter .4s ease; +} +.board.shake { animation: shake .5s ease; } +@keyframes shake { + 0%, 100% { transform: translate(0, 0); } + 20% { transform: translate(-6px, 2px); } + 40% { transform: translate(5px, -3px); } + 60% { transform: translate(-4px, -2px); } + 80% { transform: translate(3px, 2px); } +} +.reaction.stay { font-size: calc(var(--sq) * .56); } + +/* Game over β€” the curtain call */ + +.gameover { + position: fixed; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background: color-mix(in srgb, var(--paper) 82%, transparent); + z-index: 100; +} +.gameover.hidden { display: none; } +.go-box { + background: var(--paper); + border: 2px solid var(--ink); + box-shadow: 6px 6px 0 var(--line); + padding: 34px 44px; + text-align: center; + max-width: min(92vw, 480px); + animation: go-in .3s ease; +} +@keyframes go-in { from { transform: translateY(14px); opacity: 0; } } +.go-banner { + font-family: var(--mono); + font-size: clamp(24px, 6vw, 40px); + letter-spacing: 4px; + margin: 0 0 10px; + color: var(--ink); +} +.go-banner::after { content: "_"; color: var(--accent); animation: blink 1.1s step-end infinite; } +.go-detail { margin: 0 0 6px; font-size: 18px; } +.go-meta { color: var(--soft); margin: 0 0 22px; font-size: 14px; } +.go-actions { display: flex; gap: 10px; justify-content: center; flex-wrap: wrap; margin-bottom: 14px; } +.go-pgn { color: var(--soft); } +.go-pgn:hover { color: var(--ink); } + +/* CRT theme β€” phosphor green on glass, scanlines and all */ + +[data-theme="crt"] { + color-scheme: dark; + --paper: #0b0e08; + --panel: #0f140b; + --ink: #b6f2a3; + --soft: #5d8a52; + --line: #24381d; + --accent: #56d364; + --light-sq: #1a2b14; + --dark-sq: #0d170a; + --sel: #39512a; + --check: #e5534b; +} +[data-theme="crt"] body { text-shadow: 0 0 7px rgba(86, 211, 100, .3); } +[data-theme="crt"] body::after { + content: ""; + position: fixed; + inset: 0; + pointer-events: none; + z-index: 9999; + background: repeating-linear-gradient(0deg, rgba(0, 0, 0, .22) 0 1px, transparent 1px 3px); +} +[data-theme="crt"] .home .logo { text-shadow: 0 0 12px rgba(86, 211, 100, .6); } +[data-theme="crt"] .dot { background: rgba(182, 242, 163, .3); } +[data-theme="crt"] .dot.capture { background: none; border-color: rgba(182, 242, 163, .3); } +[data-theme="crt"] .sq .coord { color: rgba(182, 242, 163, .45); } +[data-theme="crt"] .sq.dark .coord { color: rgba(182, 242, 163, .55); } +[data-theme="crt"] .evalbar { border-color: var(--line); } +[data-theme="crt"] .piece.traditional.w { color: #e8f5e0; }
D src/util/sound.js

@@ -1,27 +0,0 @@

-// Short synthesized blips, so there are no audio files to ship. Silent until the -// first move, since browsers only allow audio after a user gesture. -let ctx = null; -function tone(freq, ms, gain) { - if (!ctx) - ctx = new (window.AudioContext || window.webkitAudioContext)(); - const osc = ctx.createOscillator(); - const vol = ctx.createGain(); - osc.type = "triangle"; - osc.frequency.value = freq; - vol.gain.value = gain; - osc.connect(vol).connect(ctx.destination); - const t = ctx.currentTime; - vol.gain.setValueAtTime(gain, t); - vol.gain.exponentialRampToValueAtTime(0.0001, t + ms / 1000); - osc.start(t); - osc.stop(t + ms / 1000); -} -export const sound = { - move: () => tone(220, 70, 0.05), - capture: () => tone(150, 110, 0.07), - check: () => tone(440, 130, 0.06), - end: () => { - tone(330, 160, 0.06); - setTimeout(() => tone(247, 220, 0.06), 120); - }, -};
M src/util/sound.tssrc/util/sound.ts

@@ -21,6 +21,21 @@ export const sound = {

move: () => tone(220, 70, 0.05), capture: () => tone(150, 110, 0.07), check: () => tone(440, 130, 0.06), + castle: () => { + tone(196, 70, 0.05); + setTimeout(() => tone(294, 90, 0.05), 70); + }, + promote: () => { + tone(523, 80, 0.06); + setTimeout(() => tone(659, 80, 0.06), 80); + setTimeout(() => tone(784, 150, 0.06), 160); + }, + start: () => { + tone(330, 80, 0.06); + setTimeout(() => tone(440, 80, 0.06), 90); + setTimeout(() => tone(554, 140, 0.06), 180); + }, + tick: () => tone(880, 30, 0.03), end: () => { tone(330, 160, 0.06); setTimeout(() => tone(247, 220, 0.06), 120);
D src/util/storage.js

@@ -1,59 +0,0 @@

-const DEFAULTS = { - pieceSet: "kingdom", - sound: true, - reactions: false, - coords: true, - personality: "beginner", - side: "w", - clock: "none", - clockBase: 300, - clockInc: 0, - threats: false, - blunder: false, -}; -const PREFS_KEY = "emchess.prefs"; -const SAVE_KEY = "emchess.save"; -export function loadPrefs() { - try { - const raw = localStorage.getItem(PREFS_KEY); - if (!raw) - return { ...DEFAULTS }; - return { ...DEFAULTS, ...JSON.parse(raw) }; - } - catch { - return { ...DEFAULTS }; - } -} -export function savePrefs(prefs) { - try { - localStorage.setItem(PREFS_KEY, JSON.stringify(prefs)); - } - catch { - /* private mode, no storage β€” the game still works, it just won't remember. */ - } -} -export function saveGame(game) { - try { - localStorage.setItem(SAVE_KEY, JSON.stringify(game)); - } - catch { - /* ignore */ - } -} -export function loadGame() { - try { - const raw = localStorage.getItem(SAVE_KEY); - return raw ? JSON.parse(raw) : null; - } - catch { - return null; - } -} -export function clearGame() { - try { - localStorage.removeItem(SAVE_KEY); - } - catch { - /* ignore */ - } -}
M src/util/storage.tssrc/util/storage.ts

@@ -2,6 +2,7 @@ import type { SetId } from "../board/pieces";

export interface Prefs { pieceSet: SetId; + theme: "paper" | "crt"; sound: boolean; reactions: boolean; coords: boolean;

@@ -16,6 +17,7 @@ }

const DEFAULTS: Prefs = { pieceSet: "kingdom", + theme: "paper", sound: true, reactions: false, coords: true,
D src/util/url.js

@@ -1,14 +0,0 @@

-// A position can travel in the URL as ?fen=... so a game or puzzle is shareable -// without any account or server. -export function fenFromUrl() { - const params = new URLSearchParams(location.search); - const fen = params.get("fen"); - return fen ? decodeURIComponent(fen) : null; -} -export function routeFromUrl() { - const hash = location.hash.replace(/^#\/?/, ""); - return hash || "home"; -} -export function goto(route) { - location.hash = `/${route}`; -}
M tsconfig.jsontsconfig.json

@@ -10,6 +10,7 @@ "noUnusedParameters": true,

"noFallthroughCasesInSwitch": true, "isolatedModules": true, "skipLibCheck": true, + "noEmit": true, "esModuleInterop": true, "resolveJsonModule": true, "types": []