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.
7.7 kB · 251 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252// 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);};