From 3f4d8d4e902ac81d89b8fdca75cb3467af24ccb6 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Mon, 17 Aug 2026 10:05:37 +0200 Subject: [PATCH] bucket_catch: split usePuzzleGameLoop.ts 386 -> 194 + two modules puzzleGeometry.ts (127) board constants, ScheduledPiece, and the Union-Find interval clustering in assignXPositions that makes a 100% score achievable; plus aabbCollision puzzleRender.ts (110) the four canvas draw functions Both were module-private, so nothing outside the hook sees either seam. One thing was reshaped rather than moved: the inline clearRect+gradient in the tick became a named drawBackdrop, so the render module owns every canvas call. That is why coverage goes 583/217/97/534 -> 584/217/98/535 -- one statement, one function, branches unchanged. eslint reports 45 errors, exactly as it did before the split, which is the check that matters here: the extraction added none. Those 45 are pre-existing and were unmasked by the previous commit. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY --- .../frontend/src/hooks/puzzleGeometry.ts | 127 ++++++++++ .../frontend/src/hooks/puzzleRender.ts | 110 +++++++++ .../frontend/src/hooks/usePuzzleGameLoop.ts | 222 ++---------------- ...et-catch-puzzle-loop-split-2026-08-17.json | 56 +++++ 4 files changed, 308 insertions(+), 207 deletions(-) create mode 100644 bucket_catch/packages/frontend/src/hooks/puzzleGeometry.ts create mode 100644 bucket_catch/packages/frontend/src/hooks/puzzleRender.ts create mode 100644 docs/superpowers/evidence/bucket-catch-puzzle-loop-split-2026-08-17.json diff --git a/bucket_catch/packages/frontend/src/hooks/puzzleGeometry.ts b/bucket_catch/packages/frontend/src/hooks/puzzleGeometry.ts new file mode 100644 index 00000000..491988bb --- /dev/null +++ b/bucket_catch/packages/frontend/src/hooks/puzzleGeometry.ts @@ -0,0 +1,127 @@ +import type { PuzzlePiece } from "../types"; + +/** + * Board geometry and piece scheduling for the puzzle game. + * + * The interesting part is `assignXPositions`, which is what makes a 100% score + * always achievable: pieces that arrive at the basket at overlapping times are + * clustered so one basket position covers all of them. + */ + +export const BASKET_HALF_WIDTH = 60; +export const BASKET_HEIGHT = 40; +export const PIECE_HALF_W = 48; +export const PIECE_HALF_H = 48; +export const BASKET_Y_OFFSET = 80; +export const MIN_SPEED = 3; +export const SPEED_RANGE = 3; +/** y-half-extent of the catch zone: BASKET_HEIGHT/2 + PIECE_HALF_H */ +export const CATCH_HALF = BASKET_HEIGHT / 2 + PIECE_HALF_H; // 68 px +/** x-half-extent: basket catches a piece within this horizontal distance */ +export const CATCH_RANGE = BASKET_HALF_WIDTH + PIECE_HALF_W; // 108 px +/** Frames between consecutive piece spawn times — controls pacing. */ +export const SPAWN_GAP = 20; + +export interface ScheduledPiece { + piece: PuzzlePiece; + spawnFrame: number; + speed: number; + x: number; +} + +/** + * Computes when each piece enters/exits the catch zone, groups pieces + * whose windows overlap (Union-Find), and assigns x positions so that + * every group fits within one basket-width — guaranteeing 100% is achievable. + */ +export function assignXPositions( + scheduled: ScheduledPiece[], + basketY: number, + canvasW: number, +): void { + const n = scheduled.length; + const parent = Array.from({ length: n }, (_, i) => i); + + const find = (start: number): number => { + let root = start; + while (parent[root] !== root) root = parent[root]; + let i = start; + while (parent[i] !== root) { + const next = parent[i]; + parent[i] = root; + i = next; + } + return root; + }; + + const union = (a: number, b: number): void => { + parent[find(a)] = find(b); + }; + + // Catch window for each piece: the frame range when it's at basket height. + const enters = scheduled.map( + (s) => s.spawnFrame + (basketY - CATCH_HALF + PIECE_HALF_H) / s.speed, + ); + const exits = scheduled.map( + (s) => s.spawnFrame + (basketY + CATCH_HALF + PIECE_HALF_H) / s.speed, + ); + + // Union all pairs whose windows overlap — they arrive simultaneously. + for (let i = 0; i < n; i++) { + for (let j = i + 1; j < n; j++) { + if (enters[i] <= exits[j] && enters[j] <= exits[i]) { + union(i, j); + } + } + } + + // Collect groups. + const groups = new Map(); + for (let i = 0; i < n; i++) { + const root = find(i); + const g = groups.get(root); + if (g !== undefined) { + g.push(i); + } else { + groups.set(root, [i]); + } + } + + // maxOffset: pieces within ±maxOffset of cluster center are all reachable + // from a single basket position (|piece.x - basketX| < CATCH_RANGE iff + // basketX = centerX and |piece.x - centerX| ≤ maxOffset < CATCH_RANGE). + const maxOffset = BASKET_HALF_WIDTH; // 60 px + const minX = PIECE_HALF_W; + const maxX = canvasW - PIECE_HALF_W; + + for (const indices of groups.values()) { + if (indices.length === 1) { + // Solo piece: any position across the full canvas width. + scheduled[indices[0]].x = minX + Math.random() * (maxX - minX); + } else { + // Multi-piece group: cluster within ±maxOffset of a shared center. + const centerMin = PIECE_HALF_W + maxOffset; + const centerMax = canvasW - PIECE_HALF_W - maxOffset; + const centerX = + centerMin < centerMax + ? centerMin + Math.random() * (centerMax - centerMin) + : canvasW / 2; + for (const idx of indices) { + scheduled[idx].x = + centerX + (Math.random() * 2 - 1) * maxOffset; + } + } + } +} + +export function aabbCollision( + fx: number, + fy: number, + bx: number, + by: number, +): boolean { + return ( + Math.abs(fx - bx) < CATCH_RANGE && + Math.abs(fy - by) < BASKET_HEIGHT / 2 + PIECE_HALF_H + ); +} diff --git a/bucket_catch/packages/frontend/src/hooks/puzzleRender.ts b/bucket_catch/packages/frontend/src/hooks/puzzleRender.ts new file mode 100644 index 00000000..8193303a --- /dev/null +++ b/bucket_catch/packages/frontend/src/hooks/puzzleRender.ts @@ -0,0 +1,110 @@ +import type { FallingPuzzleItem } from "../types"; +import { + BASKET_HALF_WIDTH, + BASKET_HEIGHT, + PIECE_HALF_H, + PIECE_HALF_W, +} from "./puzzleGeometry"; + +/** + * Canvas drawing for the puzzle game. + * + * Nothing here reads or writes game state — each function takes what it needs + * and paints. Keeping the rendering separate from the tick means the loop in + * `usePuzzleGameLoop` reads as scheduling and collision, not as drawing calls. + */ + +export function drawBasket( + ctx: CanvasRenderingContext2D, + x: number, + y: number, +): void { + const h = BASKET_HEIGHT; + ctx.save(); + ctx.strokeStyle = "#f472b6"; + ctx.lineWidth = 4; + ctx.lineJoin = "round"; + ctx.beginPath(); + ctx.moveTo(x - BASKET_HALF_WIDTH, y - h / 2); + ctx.lineTo(x - BASKET_HALF_WIDTH, y + h / 2); + ctx.lineTo(x + BASKET_HALF_WIDTH, y + h / 2); + ctx.lineTo(x + BASKET_HALF_WIDTH, y - h / 2); + ctx.stroke(); + ctx.strokeStyle = "#e879f9"; + ctx.lineWidth = 6; + ctx.beginPath(); + ctx.moveTo(x - BASKET_HALF_WIDTH - 6, y - h / 2); + ctx.lineTo(x + BASKET_HALF_WIDTH + 6, y - h / 2); + ctx.stroke(); + ctx.restore(); +} + +export function drawPiece( + ctx: CanvasRenderingContext2D, + item: FallingPuzzleItem, + img: HTMLImageElement | undefined, + caught: boolean, +): void { + const w = PIECE_HALF_W * 2; + const h = PIECE_HALF_H * 2; + const x = item.x - PIECE_HALF_W; + const y = item.y - PIECE_HALF_H; + + ctx.save(); + ctx.globalAlpha = caught ? 0.35 : 1; + + if (img?.complete && img.naturalWidth > 0) { + ctx.drawImage(img, x, y, w, h); + } else { + ctx.fillStyle = "rgba(129, 140, 248, 0.5)"; + ctx.fillRect(x, y, w, h); + } + + ctx.strokeStyle = caught ? "#34d399" : "#f472b6"; + ctx.lineWidth = caught ? 3 : 2; + ctx.strokeRect(x, y, w, h); + ctx.restore(); +} + +export function drawHUD( + ctx: CanvasRenderingContext2D, + canvas: HTMLCanvasElement, + caught: number, + total: number, + piecesDone: number, +): void { + ctx.save(); + ctx.fillStyle = "rgba(0,0,0,0.45)"; + ctx.roundRect(canvas.width - 160, 12, 148, 36, 8); + ctx.fill(); + ctx.fillStyle = "#a5b4fc"; + ctx.font = "bold 14px monospace"; + ctx.textAlign = "right"; + ctx.textBaseline = "middle"; + ctx.fillText(`✅ ${caught} / ${total}`, canvas.width - 20, 30); + ctx.restore(); + + const barW = 200; + const barX = (canvas.width - barW) / 2; + ctx.save(); + ctx.fillStyle = "rgba(0,0,0,0.4)"; + ctx.roundRect(barX, 12, barW, 8, 4); + ctx.fill(); + ctx.fillStyle = "#818cf8"; + ctx.roundRect(barX, 12, barW * (piecesDone / total), 8, 4); + ctx.fill(); + ctx.restore(); +} + +/** Paints the backdrop gradient the pieces fall against. */ +export function drawBackdrop( + ctx: CanvasRenderingContext2D, + canvas: HTMLCanvasElement, +): void { + ctx.clearRect(0, 0, canvas.width, canvas.height); + const grad = ctx.createLinearGradient(0, 0, 0, canvas.height); + grad.addColorStop(0, "#0f0c29"); + grad.addColorStop(1, "#302b63"); + ctx.fillStyle = grad; + ctx.fillRect(0, 0, canvas.width, canvas.height); +} diff --git a/bucket_catch/packages/frontend/src/hooks/usePuzzleGameLoop.ts b/bucket_catch/packages/frontend/src/hooks/usePuzzleGameLoop.ts index fde9be4f..6696e20c 100644 --- a/bucket_catch/packages/frontend/src/hooks/usePuzzleGameLoop.ts +++ b/bucket_catch/packages/frontend/src/hooks/usePuzzleGameLoop.ts @@ -1,206 +1,17 @@ import { useCallback, useEffect, useRef, useState, type RefObject } from "react"; import type { FallingPuzzleItem, PuzzlePiece, PuzzleGameResult } from "../types"; import { sliceImage } from "../lib/sliceImage"; - -const BASKET_HALF_WIDTH = 60; -const BASKET_HEIGHT = 40; -const PIECE_HALF_W = 48; -const PIECE_HALF_H = 48; -const BASKET_Y_OFFSET = 80; -const MIN_SPEED = 3; -const SPEED_RANGE = 3; -/** y-half-extent of the catch zone: BASKET_HEIGHT/2 + PIECE_HALF_H */ -const CATCH_HALF = BASKET_HEIGHT / 2 + PIECE_HALF_H; // 68 px -/** x-half-extent: basket catches a piece within this horizontal distance */ -const CATCH_RANGE = BASKET_HALF_WIDTH + PIECE_HALF_W; // 108 px -/** Frames between consecutive piece spawn times — controls pacing. */ -const SPAWN_GAP = 20; - -interface ScheduledPiece { - piece: PuzzlePiece; - spawnFrame: number; - speed: number; - x: number; -} - -/** - * Computes when each piece enters/exits the catch zone, groups pieces - * whose windows overlap (Union-Find), and assigns x positions so that - * every group fits within one basket-width — guaranteeing 100% is achievable. - */ -function assignXPositions( - scheduled: ScheduledPiece[], - basketY: number, - canvasW: number, -): void { - const n = scheduled.length; - const parent = Array.from({ length: n }, (_, i) => i); - - const find = (start: number): number => { - let root = start; - while (parent[root] !== root) root = parent[root]; - let i = start; - while (parent[i] !== root) { - const next = parent[i]; - parent[i] = root; - i = next; - } - return root; - }; - - const union = (a: number, b: number): void => { - parent[find(a)] = find(b); - }; - - // Catch window for each piece: the frame range when it's at basket height. - const enters = scheduled.map( - (s) => s.spawnFrame + (basketY - CATCH_HALF + PIECE_HALF_H) / s.speed, - ); - const exits = scheduled.map( - (s) => s.spawnFrame + (basketY + CATCH_HALF + PIECE_HALF_H) / s.speed, - ); - - // Union all pairs whose windows overlap — they arrive simultaneously. - for (let i = 0; i < n; i++) { - for (let j = i + 1; j < n; j++) { - if (enters[i] <= exits[j] && enters[j] <= exits[i]) { - union(i, j); - } - } - } - - // Collect groups. - const groups = new Map(); - for (let i = 0; i < n; i++) { - const root = find(i); - const g = groups.get(root); - if (g !== undefined) { - g.push(i); - } else { - groups.set(root, [i]); - } - } - - // maxOffset: pieces within ±maxOffset of cluster center are all reachable - // from a single basket position (|piece.x - basketX| < CATCH_RANGE iff - // basketX = centerX and |piece.x - centerX| ≤ maxOffset < CATCH_RANGE). - const maxOffset = BASKET_HALF_WIDTH; // 60 px - const minX = PIECE_HALF_W; - const maxX = canvasW - PIECE_HALF_W; - - for (const indices of groups.values()) { - if (indices.length === 1) { - // Solo piece: any position across the full canvas width. - scheduled[indices[0]].x = minX + Math.random() * (maxX - minX); - } else { - // Multi-piece group: cluster within ±maxOffset of a shared center. - const centerMin = PIECE_HALF_W + maxOffset; - const centerMax = canvasW - PIECE_HALF_W - maxOffset; - const centerX = - centerMin < centerMax - ? centerMin + Math.random() * (centerMax - centerMin) - : canvasW / 2; - for (const idx of indices) { - scheduled[idx].x = - centerX + (Math.random() * 2 - 1) * maxOffset; - } - } - } -} - -function aabbCollision( - fx: number, - fy: number, - bx: number, - by: number, -): boolean { - return ( - Math.abs(fx - bx) < CATCH_RANGE && - Math.abs(fy - by) < BASKET_HEIGHT / 2 + PIECE_HALF_H - ); -} - -function drawBasket( - ctx: CanvasRenderingContext2D, - x: number, - y: number, -): void { - const h = BASKET_HEIGHT; - ctx.save(); - ctx.strokeStyle = "#f472b6"; - ctx.lineWidth = 4; - ctx.lineJoin = "round"; - ctx.beginPath(); - ctx.moveTo(x - BASKET_HALF_WIDTH, y - h / 2); - ctx.lineTo(x - BASKET_HALF_WIDTH, y + h / 2); - ctx.lineTo(x + BASKET_HALF_WIDTH, y + h / 2); - ctx.lineTo(x + BASKET_HALF_WIDTH, y - h / 2); - ctx.stroke(); - ctx.strokeStyle = "#e879f9"; - ctx.lineWidth = 6; - ctx.beginPath(); - ctx.moveTo(x - BASKET_HALF_WIDTH - 6, y - h / 2); - ctx.lineTo(x + BASKET_HALF_WIDTH + 6, y - h / 2); - ctx.stroke(); - ctx.restore(); -} - -function drawPiece( - ctx: CanvasRenderingContext2D, - item: FallingPuzzleItem, - img: HTMLImageElement | undefined, - caught: boolean, -): void { - const w = PIECE_HALF_W * 2; - const h = PIECE_HALF_H * 2; - const x = item.x - PIECE_HALF_W; - const y = item.y - PIECE_HALF_H; - - ctx.save(); - ctx.globalAlpha = caught ? 0.35 : 1; - - if (img?.complete && img.naturalWidth > 0) { - ctx.drawImage(img, x, y, w, h); - } else { - ctx.fillStyle = "rgba(129, 140, 248, 0.5)"; - ctx.fillRect(x, y, w, h); - } - - ctx.strokeStyle = caught ? "#34d399" : "#f472b6"; - ctx.lineWidth = caught ? 3 : 2; - ctx.strokeRect(x, y, w, h); - ctx.restore(); -} - -function drawHUD( - ctx: CanvasRenderingContext2D, - canvas: HTMLCanvasElement, - caught: number, - total: number, - piecesDone: number, -): void { - ctx.save(); - ctx.fillStyle = "rgba(0,0,0,0.45)"; - ctx.roundRect(canvas.width - 160, 12, 148, 36, 8); - ctx.fill(); - ctx.fillStyle = "#a5b4fc"; - ctx.font = "bold 14px monospace"; - ctx.textAlign = "right"; - ctx.textBaseline = "middle"; - ctx.fillText(`✅ ${caught} / ${total}`, canvas.width - 20, 30); - ctx.restore(); - - const barW = 200; - const barX = (canvas.width - barW) / 2; - ctx.save(); - ctx.fillStyle = "rgba(0,0,0,0.4)"; - ctx.roundRect(barX, 12, barW, 8, 4); - ctx.fill(); - ctx.fillStyle = "#818cf8"; - ctx.roundRect(barX, 12, barW * (piecesDone / total), 8, 4); - ctx.fill(); - ctx.restore(); -} +import { + BASKET_Y_OFFSET, + MIN_SPEED, + PIECE_HALF_H, + SPAWN_GAP, + SPEED_RANGE, + aabbCollision, + assignXPositions, + type ScheduledPiece, +} from "./puzzleGeometry"; +import { drawBackdrop, drawBasket, drawHUD, drawPiece } from "./puzzleRender"; /** * Puzzle game loop. @@ -210,6 +21,9 @@ function drawHUD( * spatial cluster the basket can cover in one position — guaranteeing 100% * is always achievable. Multiple pieces fall simultaneously, creating an * exciting hectic visual while remaining fair. + * + * The clustering itself lives in `puzzleGeometry.ts` and the canvas drawing in + * `puzzleRender.ts`; what remains here is the per-frame tick. */ export function usePuzzleGameLoop( canvasRef: RefObject, @@ -261,13 +75,7 @@ export function usePuzzleGameLoop( const frame = frameRef.current; frameRef.current = frame + 1; - ctx.clearRect(0, 0, canvas.width, canvas.height); - - const grad = ctx.createLinearGradient(0, 0, 0, canvas.height); - grad.addColorStop(0, "#0f0c29"); - grad.addColorStop(1, "#302b63"); - ctx.fillStyle = grad; - ctx.fillRect(0, 0, canvas.width, canvas.height); + drawBackdrop(ctx, canvas); const basketX = basketXRef.current; drawBasket(ctx, basketX, basketY); diff --git a/docs/superpowers/evidence/bucket-catch-puzzle-loop-split-2026-08-17.json b/docs/superpowers/evidence/bucket-catch-puzzle-loop-split-2026-08-17.json new file mode 100644 index 00000000..cb64a3c0 --- /dev/null +++ b/docs/superpowers/evidence/bucket-catch-puzzle-loop-split-2026-08-17.json @@ -0,0 +1,56 @@ +{ + "intent": "Bring bucket_catch/packages/frontend/src/hooks/usePuzzleGameLoop.ts (386 lines) under the repo-wide 250-line cap with no change to how the puzzle game plays or looks.", + "scope": [ + "bucket_catch/packages/frontend/src/hooks/usePuzzleGameLoop.ts — reduced to the hook and its per-frame tick", + "bucket_catch/packages/frontend/src/hooks/puzzleGeometry.ts — new: board constants, ScheduledPiece, assignXPositions, aabbCollision", + "bucket_catch/packages/frontend/src/hooks/puzzleRender.ts — new: drawBasket, drawPiece, drawHUD, drawBackdrop", + "Non-goal: the 45 pre-existing eslint errors in this package, fixed neither here nor in the preceding baseline commit", + "Non-goal: splitting usePuzzleGameLoop.test.ts (332 lines), which is a separate commit" + ], + "changes": [ + "Extracted the board geometry and piece scheduling — the constants, the ScheduledPiece shape, the Union-Find interval clustering in assignXPositions that guarantees a 100% score is achievable, and aabbCollision — into puzzleGeometry.ts.", + "Extracted the four canvas drawing functions into puzzleRender.ts. All were module-private, so no consumer of the hook sees either seam.", + "Lifted the inline backdrop painting (clearRect plus the two-stop linear gradient) out of the tick into a named drawBackdrop, so the rendering module owns every canvas call. This is the one place code was reshaped rather than moved verbatim, and it is why the coverage totals rise by exactly one statement and one function." + ], + "verification": [ + { + "command": "npm run coverage", + "result": "pass", + "evidence": "14 test files, 145 tests passed; Statements 100% (584/584), Branches 100% (217/217), Functions 100% (98/98), Lines 100% (535/535). Against the 583/217/97/534 baseline that is +1 statement and +1 function — exactly the extracted drawBackdrop — with branches unchanged, so no conditional left test reach. The 100% thresholds in vite.config.ts are still enforced and still met." + }, + { + "command": "npm run build (tsc -b && vite build)", + "result": "pass", + "evidence": "Exit 0. Confirms the two new modules typecheck and bundle; this package's build was itself only repaired in the immediately preceding commit." + }, + { + "command": "npx eslint src, compared against the pre-split count", + "result": "fail", + "evidence": "45 errors — identical to the 45 measured before this split. This is the discriminating check: the count did not rise, so the extraction introduced no new lint violation, and the two new files carry only errors that travelled with the moved code. The 45 are pre-existing and out of scope, as recorded in the baseline commit." + }, + { + "command": "wc -l on the three files", + "result": "pass", + "evidence": "usePuzzleGameLoop.ts 194, puzzleGeometry.ts 127, puzzleRender.ts 110 — all three under the 250-line cap, down from a single 386-line file." + }, + { + "command": "grep for the new imports in the hook", + "result": "pass", + "evidence": "4 references to puzzleGeometry/puzzleRender in usePuzzleGameLoop.ts, confirming the split is actually wired rather than leaving orphaned duplicate modules — a state this file briefly passed through when an editor hook reverted it, and which the line count alone would not have distinguished from success." + }, + { + "command": "pre-commit run --files ", + "result": "pass", + "evidence": "All configured hooks pass on the three touched files." + } + ], + "risks": [ + "drawBackdrop is new code rather than moved code, so it is the only part of this change not proven identical by construction. It is covered by the existing tests and the canvas mock, and the clearRect/gradient calls it makes are byte-identical to the inline ones it replaced.", + "puzzleGeometry.ts exports constants that were previously module-private, widening the surface. They are plain numbers describing board dimensions, so there is no invariant an external consumer could break.", + "This package has no CI and is not covered by pre-commit, so the local coverage, build and eslint runs above are the only gate on this change." + ], + "rollback": [ + "git revert the commit — one edited file and two added files, with no data, config or schema migration.", + "After rollback, re-run npm run coverage; expect 145 tests passing with totals returning to 583/217/97/534 at 100%." + ] +}