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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
This commit is contained in:
Krzysztof Rudnicki 2026-08-17 10:05:37 +02:00
parent 5ccaeca890
commit e67dfb185d
3 changed files with 252 additions and 207 deletions

View File

@ -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<number, number[]>();
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
);
}

View File

@ -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);
}

View File

@ -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<number, number[]>();
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<HTMLCanvasElement | null>,
@ -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);