Something went wrong. Try again.
A Go-like game designed to be playe by two-to-eight players competing for territory!
Something went wrong. Try again.
19 kB · 511 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512// ui.ts translates between app/game state and the visual language of the DOM and// canvas: user input into game operations, and game state into whatever the view needs.// Also owns the CSS-variable mapping for theme chrome and the theme preference in// localStorage -- both DOM/storage concerns, not theme.ts's pure data.
import * as canvas from "./canvas.js";import * as game from "./game.js";import * as history from "./history.js";import * as theme from "./theme.js";
export type PanelElements = { playerList: HTMLElement; status: HTMLElement; endTurn: HTMLButtonElement; concede: HTMLButtonElement; saveTranscript: HTMLButtonElement; log: HTMLElement;};
const buildPlayerRow = ( player: game.Player, colors: theme.PlayerColors, score: number, isActive: boolean, isEliminated: boolean, isConceded: boolean,): HTMLElement => { const row = document.createElement("div"); row.className = "player-row"; if (isActive) { row.classList.add("active"); } if (isEliminated) { row.classList.add("eliminated"); } if (isConceded) { row.classList.add("conceded"); }
const swatch = document.createElement("span"); swatch.className = "player-swatch"; swatch.style.background = colors.stone;
const name = document.createElement("span"); name.className = "player-name"; // Conceded takes precedence over eliminated: it's permanent, and the // eliminated state is meaningless once the player's stones -- including // the base elimination depends on -- are gone. name.textContent = isConceded ? `${player.name} (conceded)` : isEliminated ? `${player.name} (out)` : player.name;
const scoreEl = document.createElement("span"); scoreEl.className = "player-score"; scoreEl.textContent = String(score);
row.append(swatch, name); // A DOM text glyph is fine here -- the "don't use glyphs" note in canvas.ts // is about ctx.fillText ignoring fillStyle, which doesn't apply to CSS // `color`. The flag is panel-only: a conceded player's pieces are removed // from the board, so there's no piece left to draw an emblem on. if (isConceded) { const flag = document.createElement("span"); flag.className = "player-flag"; flag.textContent = "⚑"; row.append(flag); } row.append(scoreEl); return row;};
// Rebuilt from scratch on every render rather than diffed -- four players is// a handful of elements, and rebuilding keeps "render is a pure function of// state" literally true. The End Turn button lives outside this element so// that its one-time listener (see attachEndTurn) never gets dropped.const renderPlayerList = ( listEl: HTMLElement, state: game.BoardState, eliminated: readonly boolean[], activeTheme: theme.Theme,): void => { listEl.replaceChildren( ...state.players.map((player, index) => buildPlayerRow( player, theme.playerColors(activeTheme, index), state.scores[index], index === state.activePlayer, eliminated[index], state.conceded[index], )), );};
const statusText = ( state: game.BoardState, result: game.GameResult, viewingTurn: number | null, botStatus: string | null,): string => { if (viewingTurn !== null) { return `Viewing turn ${viewingTurn} — read-only`; } // Checked before the result branches: while a bot's animated turn is // playing out, botStatus is what the panel should read, even though the // underlying result is still "playing" (or briefly stale mid-animation). if (botStatus !== null) { return botStatus; } if (result.kind === "won") { return `${state.players[result.player].name} wins!`; } if (result.kind === "draw") { return "Draw"; } if (game.isOpeningTurn(state) && game.remaining(state) > 0) { return `Opening: place all ${state.placementsPerTurn} beside your home base.`; } const budget = game.placementBudget(state); const placed = budget - game.remaining(state); const bonus = game.bonusPlacements(state); return bonus > 0 ? `${placed} of ${budget} placed (+${bonus} from cuts)` : `${placed} of ${budget} placed`;};
const buildLogDot = (color: string): HTMLElement => { const dot = document.createElement("span"); dot.className = "log-dot"; dot.style.background = color; return dot;};
const buildLogChip = (count: number, color: string): HTMLElement => { const chip = document.createElement("span"); chip.className = "log-chip"; chip.append(buildLogDot(color), document.createTextNode(String(count))); return chip;};
const buildLogRow = ( frame: history.Frame, viewing: number | null, activeTheme: theme.Theme,): HTMLElement => { const row = document.createElement("button"); row.type = "button"; row.className = "log-row"; row.dataset.index = String(frame.turn); if (viewing === frame.turn || (viewing === null && frame.pending)) { row.classList.add("selected"); } if (frame.pending) { row.classList.add("pending"); }
const label = document.createElement("span"); label.className = "log-label"; label.textContent = `Turn ${frame.turn}`; label.style.color = theme.playerColors(activeTheme, frame.player).stone;
const placedChip = buildLogChip(frame.placed, theme.playerColors(activeTheme, frame.player).stone);
const capturedChips = frame.captured.map((entry) => buildLogChip(entry.count, theme.playerColors(activeTheme, entry.player).stone));
row.append(label, placedChip, ...capturedChips); return row;};
// Captures scrollTop before rebuilding (replaceChildren, same "render is a// pure function of state" reasoning as the player list) and restores it// afterward -- unless a turn just ended while live, in which case it// follows the newest row instead of yanking the reader back down mid-scrub.const renderLog = ( listEl: HTMLElement, frameList: readonly history.Frame[], viewing: number | null, activeTheme: theme.Theme,): void => { const priorScrollTop = listEl.scrollTop; const priorCount = listEl.childElementCount;
listEl.replaceChildren(...frameList.map((frame) => buildLogRow(frame, viewing, activeTheme)));
if (frameList.length > priorCount && viewing === null) { listEl.scrollTop = listEl.scrollHeight; } else { listEl.scrollTop = priorScrollTop; }};
export const attachLog = (listEl: HTMLElement, onView: (index: number) => void): void => { listEl.addEventListener("click", (event) => { const row = (event.target as Element).closest(".log-row"); if (row instanceof HTMLElement && row.dataset.index !== undefined) { onView(Number(row.dataset.index)); } });};
// game.gameStatus runs the underlying flood fill once and hands back both// the per-player elimination flags and the derived result, rather than each// of the three things below independently re-deriving one or the other.//// botStatus/isLocalTurn default to their human-solo-game values (null,// true) so every existing call site keeps working unchanged. disabled is a// display detail, not the real guard against a human playing a bot's turn// -- that guard lives in handleClick and main.ts's endHumanTurn, per// separation-of-concerns.md boundary rule 3 (the window-level Enter/Space// listener in attachEndTurn bypasses `disabled` entirely).export const showPanel = ( elements: PanelElements, record: history.History, activeTheme: theme.Theme, botStatus: string | null = null, isLocalTurn: boolean = true,): void => { const state = history.visible(record); const { eliminated, result } = game.gameStatus(state); renderPlayerList(elements.playerList, state, eliminated, activeTheme); elements.status.textContent = statusText(state, result, record.viewing, botStatus); const disabled = result.kind !== "playing" || history.isViewingPast(record) || !isLocalTurn; elements.endTurn.disabled = disabled; elements.concede.disabled = disabled; renderLog(elements.log, history.frames(record), record.viewing, activeTheme);};
// eliminatedPlayers runs one flood fill per player; computed once here and// reused across every piece, rather than once per piece (which would run a// full elimination check -- itself N flood fills -- for every stone on the// board).export const toCanvasPieces = (state: game.BoardState, activeTheme: theme.Theme): canvas.Piece[] => { const eliminated = game.eliminatedPlayers(state); const doomed = new Set(game.pendingCaptures(state)); return state.pieces.map((piece) => { const colors = theme.playerColors(activeTheme, piece.player); return { row: piece.position.row, col: piece.position.col, color: piece.isHomeBase ? colors.home : colors.stone, provisional: game.isRetractable(state, piece), doomed: doomed.has(piece), emblem: piece.isHomeBase ? (eliminated[piece.player] ? "skull" : "crown") : undefined, }; });};
export const handleClick = ( state: game.BoardState, layout: canvas.BoardLayout, board: canvas.BoardConfig, point: canvas.Point, isLocalTurn: boolean,): game.BoardState | null => { // Checked first, before any board/game logic: a human can never play a // bot's turn by clicking during it. isLocalTurn is the real guard here; // showPanel's `disabled` on the End Turn/Concede buttons is a display // detail that doesn't cover clicks on the board itself. if (!isLocalTurn) { return null; } const position = canvas.pixelToGridPoint(layout, board, point); if (!position) { return null; } // Try retract first, fall back to place. This can no longer branch on // "is there a piece here" the way it used to: a doomed cell already has a // piece (the victim) but the click should place an overlay there, not // attempt to retract a piece the active player never placed. `game.pieceAt` // always resolves to that victim, never a same-cell overlay (see game.ts), // so it can't tell these cases apart either -- retract's own legality // check is the only thing that can. // // Not a `??` trick: this is ui.ts asking game.ts's rules layer both // questions -- "is this retractable" and, only if not, "is this // placeable" -- and taking whichever one answers, rather than encoding a // rule ("occupied means retract") in the translation layer itself. return game.retract(state, position) ?? game.place(state, position);};
const clientPointToCanvasPoint = (canvasEl: HTMLCanvasElement, event: MouseEvent): canvas.Point => { const rect = canvasEl.getBoundingClientRect(); return { x: event.clientX - rect.left, y: event.clientY - rect.top };};
export const attachInteraction = ( canvasEl: HTMLCanvasElement, board: canvas.BoardConfig, getLayout: () => canvas.BoardLayout, getState: () => game.BoardState, isLocalTurn: () => boolean, onState: (next: game.BoardState) => void,): void => { canvasEl.addEventListener("click", (event) => { const point = clientPointToCanvasPoint(canvasEl, event); const next = handleClick(getState(), getLayout(), board, point, isLocalTurn()); if (next) { onState(next); } });};
const isEndTurnKey = (key: string): boolean => key === "Enter" || key === " ";
// Any focused form control -- not just the End Turn button itself -- should// keep Enter/Space for its own native behaviour (opening a <select>, editing// an <input>) rather than have this window-level listener steal the// keystroke and end the turn instead. Subsumes the original button-only// check.const isFocusedFormControl = (element: Element | null): boolean => element instanceof HTMLSelectElement || element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement || element instanceof HTMLButtonElement;
// A <dialog>'s showModal() makes the rest of the page inert -- no pointer// events, no focus, no tab stops out there -- but that inertness does NOT// extend to window-level listeners: a keydown dispatched on any element// inside the dialog (even non-focusable body text, which throws focus onto// the <dialog> itself) still bubbles through document to window like any// other keydown. isFocusedFormControl alone can't be trusted to catch this// -- it only holds while focus happens to sit on a button/input/etc, and a// click on plain text inside an open dialog breaks that. This checks the// actual thing that matters instead.const isModalOpen = (): boolean => document.querySelector("dialog[open]") !== null;
// Takes an onEndTurn callback rather than calling game.endTurn itself:// main.ts needs to see the board BEFORE endTurn runs, to build that turn's// transcript.TurnRecord (endTurn deletes the captured pieces and advances// the turn, destroying the evidence a per-rule capture breakdown needs --// see transcript.ts). main.ts owns the actual game.endTurn call and the// applyState/log-push that follow it.export const attachEndTurn = ( button: HTMLButtonElement, onEndTurn: () => void,): void => { button.addEventListener("click", onEndTurn);
window.addEventListener("keydown", (event) => { // button.disabled suppresses the button's own click event but has no // effect on this window-level listener, and a disabled button can never // become document.activeElement -- so without this check, Enter/Space // would keep ending turns after a win even with the button greyed out. // isModalOpen() is the load-bearing guard against any open dialog (see // its comment above) -- isFocusedFormControl is kept alongside it for // ordinary form controls (a <select>, an <input>) outside any dialog. if ( button.disabled || !isEndTurnKey(event.key) || isFocusedFormControl(document.activeElement) || isModalOpen() ) { return; } event.preventDefault(); onEndTurn(); });};
// Enabled at all times, unlike End Turn/Concede -- a long or stuck game// (see docs/bots.md: captures free up cells, so boardIsFull can take a// long while) should never lose its transcript to a closed tab just// because the game hasn't formally ended yet.export const attachSaveTranscript = (button: HTMLButtonElement, onSave: () => void): void => { button.addEventListener("click", onSave);};
// Native <dialog> + showModal gives a focus trap and Escape-to-cancel for// free, and makes the background inert to pointer events and focus -- but// NOT to window-level keydown listeners (see isModalOpen above, which is// what actually keeps attachEndTurn's Enter/Space handler from firing behind// this overlay). Confirm is never dismissed by backdrop click -- a// destructive action requires an explicit Cancel or Escape -- so no backdrop// listener is added.export const attachConcede = ( button: HTMLButtonElement, dialog: HTMLDialogElement, confirmButton: HTMLButtonElement, cancelButton: HTMLButtonElement, getState: () => game.BoardState, onState: (next: game.BoardState) => void,): void => { const nameEl = dialog.querySelector<HTMLElement>("[data-concede-name]");
button.addEventListener("click", () => { if (nameEl) { nameEl.textContent = game.currentPlayer(getState()).name; } dialog.showModal(); // Cancel, not Confirm, gets focus: a stray Enter should cancel, not // concede. cancelButton.focus(); });
confirmButton.addEventListener("click", () => { dialog.close(); const next = game.concede(getState()); if (next) { onState(next); } });
cancelButton.addEventListener("click", () => { dialog.close(); });};
// Shows the saved-transcript confirmation, filling in the path the server// reported. A separate imperative function rather than folded into// attachSaveTranscript's click listener, since the trigger here is an// async fetch's success -- main.ts's saveTranscript -- not a click.export const showTranscriptSaved = (dialog: HTMLDialogElement, path: string): void => { const pathEl = dialog.querySelector<HTMLElement>("[data-transcript-path]"); if (pathEl) { pathEl.textContent = path; } dialog.showModal();};
// Unlike attachConcede, nothing here is destructive, so a backdrop click// dismisses it too -- a click whose target is the dialog element itself,// not any of its children, since native <dialog> has no separate backdrop// click event of its own.export const attachTranscriptSavedDialog = ( dialog: HTMLDialogElement, okButton: HTMLButtonElement,): void => { okButton.addEventListener("click", () => dialog.close()); dialog.addEventListener("click", (event) => { if (event.target === dialog) { dialog.close(); } });};
// --- Theme: CSS variables, chrome application, picker, persistence -------
// The variable names are a DOM concern, so this mapping lives here rather// than in theme.ts, which stays pure data.export const cssVariables = (activeTheme: theme.Theme): Readonly<Record<string, string>> => ({ "--bb-page-bg": activeTheme.chrome.pageBg, "--bb-panel-bg": activeTheme.chrome.panelBg, "--bb-text": activeTheme.chrome.text, "--bb-muted-text": activeTheme.chrome.mutedText, "--bb-title-accent": activeTheme.chrome.titleAccent, "--bb-swatch-border": activeTheme.chrome.swatchBorder, "--bb-button-bg": activeTheme.chrome.buttonBg, "--bb-button-text": activeTheme.chrome.buttonText, "--bb-button-border": activeTheme.chrome.buttonBorder, "--bb-danger": activeTheme.chrome.danger, "--bb-danger-text": activeTheme.chrome.dangerText, "--bb-success": activeTheme.chrome.success, "--bb-success-text": activeTheme.chrome.successText, // Sourced from the board palette rather than duplicating the hex in // Chrome: this is the same gold the crown and skull emblems use, so one // gold per theme stays the single source of truth. "--bb-flag": activeTheme.board.emblemFill,});
export const applyChrome = (root: HTMLElement, activeTheme: theme.Theme): void => { Object.entries(cssVariables(activeTheme)).forEach(([name, value]) => { root.style.setProperty(name, value); });};
// The option list is static (theme.themes never changes at runtime), so it's// populated once at attach rather than rebuilt on every theme change.export const attachThemePicker = ( select: HTMLSelectElement, current: theme.Theme, onTheme: (next: theme.Theme) => void,): void => { select.replaceChildren( ...theme.themes.map((candidate) => { const option = document.createElement("option"); option.value = candidate.id; option.textContent = candidate.name; return option; }), ); select.value = current.id; select.addEventListener("change", () => { const next = theme.byId(select.value); if (next) { onTheme(next); } });};
const THEME_STORAGE_KEY = "bit-blossom.theme";
// localStorage throws in some private-browsing and embedded contexts, on// read as well as write, so both directions are wrapped.export const storedThemeId = (): string | null => { try { return localStorage.getItem(THEME_STORAGE_KEY); } catch { return null; }};
export const storeThemeId = (id: string): void => { try { localStorage.setItem(THEME_STORAGE_KEY, id); } catch { // Ignored: persistence is a convenience, not a requirement. }};