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 0000000..491988b --- /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 0000000..8193303 --- /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 fde9be4..6696e20 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);