mirror of
https://github.com/kuhyx/testsAndMisc-archive.git
synced 2026-11-10 20:27:04 +01:00
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:
parent
5ccaeca890
commit
e67dfb185d
127
bucket_catch/packages/frontend/src/hooks/puzzleGeometry.ts
Normal file
127
bucket_catch/packages/frontend/src/hooks/puzzleGeometry.ts
Normal 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
|
||||
);
|
||||
}
|
||||
110
bucket_catch/packages/frontend/src/hooks/puzzleRender.ts
Normal file
110
bucket_catch/packages/frontend/src/hooks/puzzleRender.ts
Normal 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);
|
||||
}
|
||||
@ -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);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user