From 019cabd6903dce6ebe38c6b2cda7885c93f25aaa Mon Sep 17 00:00:00 2001 From: Anuraj Jit Saikia Date: Mon, 17 Aug 2026 16:32:47 +0530 Subject: [PATCH] feat(arcade): fill snake stage with more 20px cells Scored runs were locked to 34x18, which letterboxed a small white board inside the cabinet. Keep the original 20px cell size and grow the grid to the measured stage instead. --- frontend/src/ui/arcade/SnakeGame.test.tsx | 57 ++++++++++++++++++++ frontend/src/ui/arcade/SnakeGame.tsx | 61 +++++++--------------- frontend/src/ui/arcade/snake/stage.test.ts | 34 ++++++++++++ frontend/src/ui/arcade/snake/stage.ts | 39 ++++++++++++++ 4 files changed, 150 insertions(+), 41 deletions(-) create mode 100644 frontend/src/ui/arcade/SnakeGame.test.tsx create mode 100644 frontend/src/ui/arcade/snake/stage.test.ts create mode 100644 frontend/src/ui/arcade/snake/stage.ts diff --git a/frontend/src/ui/arcade/SnakeGame.test.tsx b/frontend/src/ui/arcade/SnakeGame.test.tsx new file mode 100644 index 0000000..731ed1c --- /dev/null +++ b/frontend/src/ui/arcade/SnakeGame.test.tsx @@ -0,0 +1,57 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import SnakeGame from "./SnakeGame"; +import { CELL_CSS, EDGE_INSET } from "./snake/stage"; + +function mockBox(el: HTMLElement, w: number, h: number): void { + Object.defineProperty(el, "clientWidth", { configurable: true, value: w }); + Object.defineProperty(el, "clientHeight", { configurable: true, value: h }); + el.getBoundingClientRect = () => + ({ + x: 0, + y: 0, + left: 0, + top: 0, + right: w, + bottom: h, + width: w, + height: h, + toJSON: () => ({}), + }) as DOMRect; +} + +describe("SnakeGame stage fill", () => { + afterEach(() => { + cleanup(); + }); + + it("starts a scored run at the measured stage size, not a fixed 34x18", () => { + const cols = 50; + const rows = 28; + const w = cols * CELL_CSS + 2 * EDGE_INSET; + const h = rows * CELL_CSS + 2 * EDGE_INSET; + + const { container } = render( + {}} + onGameOver={() => {}} + />, + ); + const root = container.querySelector(".arcade-snake-root"); + if (!(root instanceof HTMLElement)) throw new Error("snake root missing"); + mockBox(root, w, h); + fireEvent(window, new Event("resize")); + + fireEvent.click(screen.getByRole("button", { name: /^normal$/i })); + + const canvas = container.querySelector("canvas"); + if (!(canvas instanceof HTMLCanvasElement)) throw new Error("canvas missing"); + expect(canvas.style.width).toBe(`${cols * CELL_CSS}px`); + expect(canvas.style.height).toBe(`${rows * CELL_CSS}px`); + expect(canvas.width).toBe(cols * CELL_CSS); + expect(canvas.height).toBe(rows * CELL_CSS); + }); +}); diff --git a/frontend/src/ui/arcade/SnakeGame.tsx b/frontend/src/ui/arcade/SnakeGame.tsx index 17c9adc..74ec20e 100644 --- a/frontend/src/ui/arcade/SnakeGame.tsx +++ b/frontend/src/ui/arcade/SnakeGame.tsx @@ -1,8 +1,6 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { bus, type ArcadeBusEvent } from "../../game/eventBus"; import { - DEFAULT_SNAKE_HEIGHT, - DEFAULT_SNAKE_WIDTH, initSnake, snakeInput, snakeFrame, @@ -20,34 +18,21 @@ import { renderHeading, } from "./snake/interp"; import { renderSnake } from "./snake/render"; +import { + CELL_CSS, + EDGE_INSET, + MIN_COLS, + MIN_ROWS, + boardForStage, + type SnakeBoard, +} from "./snake/stage"; import { useReducedMotion } from "../reducedMotionBridge"; import type { ArcadeGameProps } from "./gameTypes"; const MAX_DPR = 2; -/** - * FIXED cell size in CSS px (the original game's grid). Cells are NEVER scaled - * to fit the stage — that just reads as "zoomed in". The board fills the stage - * by GRID SIZE instead: more cells, same 20px each. - */ -const CELL_CSS = 20; -/** Smallest playable board if the stage is tiny. */ -const MIN_COLS = 20; -const MIN_ROWS = 12; -/** - * Minimum letterbox per side. The stage surface clips with a 10px border - * radius; anything closer than ~3px to its edge gets a bite taken out of the - * corner cells, which players read as "the end cell is cut off". - */ -const EDGE_INSET = 4; const DIFFICULTY_KEY = "arcadeSnakeDifficulty"; const DIFFICULTIES: readonly Difficulty[] = ["easy", "normal", "hard"]; -/** Board size in whole cells for the current stage box. */ -interface Board { - readonly cols: number; - readonly rows: number; -} - interface SnakeMotion { readonly previous: SnakeState["body"]; readonly current: SnakeState["body"]; @@ -55,9 +40,7 @@ interface SnakeMotion { readonly snap: boolean; } -const INITIAL_BOARD: Board = { cols: MIN_COLS, rows: MIN_ROWS }; -/** Fixed dimensions keep globally ranked Snake runs comparable. */ -const SCORE_BOARD: Board = { cols: DEFAULT_SNAKE_WIDTH, rows: DEFAULT_SNAKE_HEIGHT }; +const INITIAL_BOARD: SnakeBoard = { cols: MIN_COLS, rows: MIN_ROWS }; /** Reducer domain event → bus event name (glue only — no game rules). */ const BUS_FOR_EVENT: Readonly>> = { @@ -132,12 +115,12 @@ export default function SnakeGame({ const bestScoreRef = useRef(bestScore); const pausedRef = useRef(paused); /** Latest measurement of the stage; only adopted between runs. */ - const measuredRef = useRef(INITIAL_BOARD); + const measuredRef = useRef(INITIAL_BOARD); /** Board the canvas backing store is currently sized for (mirror of state). */ - const sizedRef = useRef(INITIAL_BOARD); + const sizedRef = useRef(INITIAL_BOARD); const phaseRef = useRef<"menu" | "play">("menu"); /** Board the canvas is currently sized for (frozen while a run is live). */ - const [board, setBoard] = useState(INITIAL_BOARD); + const [board, setBoard] = useState(INITIAL_BOARD); const [phase, setPhase] = useState<"menu" | "play">("menu"); const [difficulty, setDifficulty] = useState(readStoredDifficulty); @@ -240,13 +223,8 @@ export default function SnakeGame({ const measure = useCallback(() => { const root = rootRef.current; if (!root) return; - const boxW = root.clientWidth - 2 * EDGE_INSET; - const boxH = root.clientHeight - 2 * EDGE_INSET; - if (boxW < 1 || boxH < 1) return; - const next: Board = { - cols: Math.max(MIN_COLS, Math.floor(boxW / CELL_CSS)), - rows: Math.max(MIN_ROWS, Math.floor(boxH / CELL_CSS)), - }; + const next = boardForStage(root.clientWidth, root.clientHeight); + if (!next) return; measuredRef.current = next; layout(); if (phaseRef.current !== "menu") return; @@ -292,13 +270,14 @@ export default function SnakeGame({ if (pausedRef.current) return; writeStoredDifficulty(d); setDifficulty(d); - // Scored runs always use one board size; CSS layout handles letterboxing - // on narrow/fullscreen surfaces without changing leaderboard potential. - setBoard(SCORE_BOARD); + // Fill the cabinet with whole 20px cells. Rules, speed, and scoring + // stay the same; only the playfield grows to the measured stage. + const next = measuredRef.current; + setBoard(next); stateRef.current = initSnake(seed, { difficulty: d, - width: SCORE_BOARD.cols, - height: SCORE_BOARD.rows, + width: next.cols, + height: next.rows, }); const initial = stateRef.current; motionRef.current = { diff --git a/frontend/src/ui/arcade/snake/stage.test.ts b/frontend/src/ui/arcade/snake/stage.test.ts new file mode 100644 index 0000000..4dea165 --- /dev/null +++ b/frontend/src/ui/arcade/snake/stage.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { + CELL_CSS, + EDGE_INSET, + MIN_COLS, + MIN_ROWS, + boardForStage, +} from "./stage"; + +describe("boardForStage", () => { + it("returns null when the stage has no box yet", () => { + expect(boardForStage(0, 0)).toBeNull(); + expect(boardForStage(2, 400)).toBeNull(); + }); + + it("floors to whole 20px cells and never scales the cell size", () => { + const wide = 34 * CELL_CSS + 2 * EDGE_INSET + 19; + const tall = 18 * CELL_CSS + 2 * EDGE_INSET + 19; + expect(boardForStage(wide, tall)).toEqual({ cols: 34, rows: 18 }); + }); + + it("grows the board when the stage is larger, still at 20px cells", () => { + const wide = 52 * CELL_CSS + 2 * EDGE_INSET; + const tall = 30 * CELL_CSS + 2 * EDGE_INSET; + expect(boardForStage(wide, tall)).toEqual({ cols: 52, rows: 30 }); + }); + + it("does not drop below the playable minimum", () => { + expect(boardForStage(MIN_COLS * CELL_CSS, MIN_ROWS * CELL_CSS)).toEqual({ + cols: MIN_COLS, + rows: MIN_ROWS, + }); + }); +}); diff --git a/frontend/src/ui/arcade/snake/stage.ts b/frontend/src/ui/arcade/snake/stage.ts new file mode 100644 index 0000000..8bfa39a --- /dev/null +++ b/frontend/src/ui/arcade/snake/stage.ts @@ -0,0 +1,39 @@ +/** + * Snake stage geometry. The playfield fills the cabinet with more cells at a + * FIXED 20 CSS-px size. Cells are never scaled up to fit; that reads as zoom. + */ + +/** Original game cell size in CSS pixels. Never scaled up to fill the stage. */ +export const CELL_CSS = 20; +/** Smallest playable board if the stage is tiny. */ +export const MIN_COLS = 20; +export const MIN_ROWS = 12; +/** + * Minimum letterbox per side. The stage surface clips with a 10px border + * radius; anything closer than ~3px to its edge gets a bite taken out of the + * corner cells, which players read as "the end cell is cut off". + */ +export const EDGE_INSET = 4; + +/** Board size in whole cells for the current stage box. */ +export interface SnakeBoard { + readonly cols: number; + readonly rows: number; +} + +/** + * How many whole FIXED-size cells fit in the stage. Returns null when the + * box has not been measured yet (jsdom / hidden). + */ +export function boardForStage( + clientWidth: number, + clientHeight: number, +): SnakeBoard | null { + const boxW = clientWidth - 2 * EDGE_INSET; + const boxH = clientHeight - 2 * EDGE_INSET; + if (boxW < 1 || boxH < 1) return null; + return { + cols: Math.max(MIN_COLS, Math.floor(boxW / CELL_CSS)), + rows: Math.max(MIN_ROWS, Math.floor(boxH / CELL_CSS)), + }; +}