// canvas.ts handles actually painting to the canvas specifically. export type Point = { x: number; y: number }; export type Size = { width: number; height: number }; export type BoardConfig = { rows: number; cols: number }; export type Emblem = "crown" | "skull"; export type Piece = { row: number; col: number; color: string; provisional: boolean; doomed: boolean; emblem?: Emblem; }; export type BoardLayout = { cellSize: number; marginX: number; marginY: number }; export type GridPoint = { row: number; col: number }; type Line = { from: Point; to: Point }; // A theme's colour contribution to the board -- colour only, no geometry or // opacity, so the theme list stays cheap to extend and can't break layout. export type Palette = { boardFill: string; line: string; stoneOutline: string; provisionalOutline: string; emblemFill: string; emblemStroke: string; }; const DOOMED_ALPHA = 0.35; const STONE_RADIUS_RATIO = 0.45; export const computeLayout = (size: Size, board: BoardConfig): BoardLayout => { const cellSize = Math.min( size.width / (board.cols + 1), size.height / (board.rows + 1), ); return { cellSize, marginX: (size.width - cellSize * (board.cols - 1)) / 2, marginY: (size.height - cellSize * (board.rows - 1)) / 2, }; }; export const gridPointToPixel = (layout: BoardLayout, row: number, col: number): Point => ({ x: layout.marginX + col * layout.cellSize, y: layout.marginY + row * layout.cellSize, }); export const stoneRadius = (layout: BoardLayout): number => layout.cellSize * STONE_RADIUS_RATIO; export const pixelToGridPoint = ( layout: BoardLayout, board: BoardConfig, point: Point, ): GridPoint | null => { const row = Math.round((point.y - layout.marginY) / layout.cellSize); const col = Math.round((point.x - layout.marginX) / layout.cellSize); if (row < 0 || row >= board.rows || col < 0 || col >= board.cols) { return null; } const snapped = gridPointToPixel(layout, row, col); const tolerance = layout.cellSize / 2; return Math.hypot(point.x - snapped.x, point.y - snapped.y) <= tolerance ? { row, col } : null; }; export const gridLines = (layout: BoardLayout, board: BoardConfig): Line[] => { const horizontal = Array.from({ length: board.rows }, (_, row) => ({ from: gridPointToPixel(layout, row, 0), to: gridPointToPixel(layout, row, board.cols - 1), })); const vertical = Array.from({ length: board.cols }, (_, col) => ({ from: gridPointToPixel(layout, 0, col), to: gridPointToPixel(layout, board.rows - 1, col), })); return [...horizontal, ...vertical]; }; const clearCanvas = (ctx: CanvasRenderingContext2D, size: Size): void => { ctx.clearRect(0, 0, size.width, size.height); }; const fillBackground = (ctx: CanvasRenderingContext2D, size: Size, palette: Palette): void => { ctx.fillStyle = palette.boardFill; ctx.fillRect(0, 0, size.width, size.height); }; const drawLine = (ctx: CanvasRenderingContext2D, line: Line, palette: Palette): void => { ctx.beginPath(); ctx.moveTo(line.from.x, line.from.y); ctx.lineTo(line.to.x, line.to.y); ctx.strokeStyle = palette.line; ctx.lineWidth = 1; ctx.stroke(); }; const drawGrid = ( ctx: CanvasRenderingContext2D, layout: BoardLayout, board: BoardConfig, palette: Palette, ): void => { gridLines(layout, board).forEach((line) => drawLine(ctx, line, palette)); }; // A simple 3-peak crown silhouette, hand-drawn rather than a text glyph: a // path fill/stroke gives exact control over both colours (crown gold, and a // stroke that guarantees contrast on every base colour including white), // whereas the emoji crown/skull glyphs render as fixed-colour bitmaps that // ignore fillStyle/strokeStyle entirely and can't be recoloured to match. const drawCrown = ( ctx: CanvasRenderingContext2D, center: Point, radius: number, palette: Palette, ): void => { const w = radius * 0.75; const baseY = center.y + radius * 0.45; const midY = center.y + radius * 0.05; const peakY = center.y - radius * 0.45; const centerPeakY = center.y - radius * 0.62; ctx.beginPath(); ctx.moveTo(center.x - w, baseY); ctx.lineTo(center.x - w, midY); ctx.lineTo(center.x - w * 0.66, peakY); ctx.lineTo(center.x - w * 0.33, midY); ctx.lineTo(center.x, centerPeakY); ctx.lineTo(center.x + w * 0.33, midY); ctx.lineTo(center.x + w * 0.66, peakY); ctx.lineTo(center.x + w, midY); ctx.lineTo(center.x + w, baseY); ctx.closePath(); ctx.fillStyle = palette.emblemFill; ctx.fill(); ctx.strokeStyle = palette.emblemStroke; ctx.lineWidth = 1; ctx.stroke(); }; const drawSkull = ( ctx: CanvasRenderingContext2D, center: Point, radius: number, palette: Palette, ): void => { const headRadius = radius * 0.5; const jawY = center.y + radius * 0.28; ctx.beginPath(); ctx.arc(center.x, center.y - radius * 0.08, headRadius, Math.PI, 0, false); ctx.lineTo(center.x + headRadius, jawY); ctx.lineTo(center.x - headRadius, jawY); ctx.closePath(); ctx.fillStyle = palette.emblemFill; ctx.fill(); ctx.strokeStyle = palette.emblemStroke; ctx.lineWidth = 1; ctx.stroke(); const eyeOffsetX = headRadius * 0.45; const eyeY = center.y - radius * 0.05; const eyeRadius = headRadius * 0.28; ctx.fillStyle = palette.emblemStroke; for (const side of [-1, 1]) { ctx.beginPath(); ctx.arc(center.x + side * eyeOffsetX, eyeY, eyeRadius, 0, Math.PI * 2); ctx.fill(); } ctx.beginPath(); ctx.moveTo(center.x, center.y + radius * 0.02); ctx.lineTo(center.x - headRadius * 0.15, center.y + radius * 0.18); ctx.lineTo(center.x + headRadius * 0.15, center.y + radius * 0.18); ctx.closePath(); ctx.fill(); }; const drawEmblem = ( ctx: CanvasRenderingContext2D, center: Point, radius: number, emblem: Emblem, palette: Palette, ): void => { if (emblem === "crown") { drawCrown(ctx, center, radius, palette); } else { drawSkull(ctx, center, radius, palette); } }; const drawStone = ( ctx: CanvasRenderingContext2D, center: Point, radius: number, piece: Piece, palette: Palette, ): void => { // Doomed pieces render translucent -- captured but still legally // occupying the board until endTurn resolves it -- via ctx.globalAlpha, // which scales every subsequent draw (fill, stroke, and the emblem if // any) uniformly, then gets restored so it never leaks into sibling // stones drawn afterward. const priorAlpha = ctx.globalAlpha; if (piece.doomed) { ctx.globalAlpha = priorAlpha * DOOMED_ALPHA; } ctx.beginPath(); ctx.arc(center.x, center.y, radius, 0, Math.PI * 2); ctx.fillStyle = piece.color; ctx.fill(); ctx.setLineDash(piece.provisional ? [4, 3] : []); ctx.strokeStyle = piece.provisional ? palette.provisionalOutline : palette.stoneOutline; ctx.lineWidth = piece.provisional ? 2 : 1; ctx.stroke(); ctx.setLineDash([]); if (piece.emblem) { drawEmblem(ctx, center, radius, piece.emblem, palette); } ctx.globalAlpha = priorAlpha; }; const drawPieces = ( ctx: CanvasRenderingContext2D, layout: BoardLayout, pieces: readonly Piece[], palette: Palette, ): void => { const radius = stoneRadius(layout); pieces.forEach((piece) => { drawStone(ctx, gridPointToPixel(layout, piece.row, piece.col), radius, piece, palette); }); }; export const renderBoard = ( ctx: CanvasRenderingContext2D, size: Size, board: BoardConfig, pieces: readonly Piece[], palette: Palette, ): void => { const layout = computeLayout(size, board); clearCanvas(ctx, size); fillBackground(ctx, size, palette); drawGrid(ctx, layout, board, palette); drawPieces(ctx, layout, pieces, palette); };