From 08c2d40d23d066e21c19e0deae32a3aee4680d49 Mon Sep 17 00:00:00 2001 From: Andre Padez Date: Mon, 3 Nov 2025 22:57:41 +0000 Subject: [PATCH] feat: add collision detection between falling letters and ship - Detect collisions when letters reach ship's Y position - Both letter and ship explode with particle animations on collision - Ship explosion displays expanding particles in yellow, orange, and red - Letters explode with their existing animation system - After collision, letters clear and new letter spawns automatically - Game continues after collision (no game-over condition yet) - Collision threshold tuned for reliable detection (30px from ship center) - Added shipState tracking to GameStatusManager - Ship position continuously synced during game loop --- src/types.ts | 6 + src/useBoard/useBoard.ts | 207 +++++++++++++++++------------ src/useGame/useGame.ts | 18 ++- src/useGameStatus/useGameStatus.ts | 50 ++++++- 4 files changed, 186 insertions(+), 95 deletions(-) diff --git a/src/types.ts b/src/types.ts index 7925bcd..49c3608 100755 --- a/src/types.ts +++ b/src/types.ts @@ -21,6 +21,7 @@ export const getGamePhase = (status: GameStatus): GamePhase => export type PlayerPosition = { x: number }; // pixel position 0-800 export type LetterState = 'normal' | 'wrong' | 'exploding'; +export type ShipState = 'normal' | 'exploding'; export type FallingLetter = { id: string; @@ -95,6 +96,11 @@ export type GameStatusManager = { level: number; score: number; togglePause: () => void; + shipState: ShipState; + shipExplosionTime: number; + updatePlayerX: (x: number) => void; + handleTypedLetter: (letter: string) => void; + setGameLoopCallback: (callback: (deltaTime: number, letters: FallingLetter[]) => void) => void; }; // ============================================================================ diff --git a/src/useBoard/useBoard.ts b/src/useBoard/useBoard.ts index 7df8f25..40e29ad 100644 --- a/src/useBoard/useBoard.ts +++ b/src/useBoard/useBoard.ts @@ -1,5 +1,5 @@ import { useRef, useEffect } from "react"; -import type { PlayerPosition } from "../types"; +import type { PlayerPosition, ShipState } from "../types"; const CANVAS_WIDTH = 800; const CANVAS_HEIGHT = 600; @@ -8,6 +8,8 @@ const SPACESHIP_Y = CANVAS_HEIGHT - 40; export function useBoard() { const containerRef = useRef(null); const canvasRef = useRef(null); + const shipStateRef = useRef("normal"); + const shipExplosionTimeRef = useRef(0); // Setup canvas on mount useEffect(() => { @@ -25,7 +27,11 @@ export function useBoard() { canvasRef.current = canvas; }, []); - const renderBoard = (playerPosition: PlayerPosition, letters: any = []) => { + const renderBoard = (playerPosition: PlayerPosition, letters: any = [], shipState: ShipState = "normal", shipExplosionTime?: number) => { + if (shipExplosionTime !== undefined) { + shipExplosionTimeRef.current = shipExplosionTime; + } + shipStateRef.current = shipState; const canvas = canvasRef.current; if (!canvas) return; @@ -40,97 +46,126 @@ export function useBoard() { const spaceshipX = playerPosition.x; const spaceshipY = SPACESHIP_Y; - // Main body (sleek metallic hull) - ctx.fillStyle = "#4a90e2"; - ctx.beginPath(); - ctx.moveTo(spaceshipX, spaceshipY - 20); // nose - ctx.lineTo(spaceshipX - 12, spaceshipY + 8); - ctx.lineTo(spaceshipX + 12, spaceshipY + 8); - ctx.closePath(); - ctx.fill(); + if (shipStateRef.current === 'exploding') { + // Explosion animation + const elapsed = performance.now() - shipExplosionTimeRef.current; + const progress = Math.min(elapsed / 300, 1); + const scale = 1 + progress * 1.5; + const opacity = 1 - progress; - // Body highlight (lighter blue for 3D effect) - ctx.fillStyle = "#7bb3ff"; - ctx.beginPath(); - ctx.moveTo(spaceshipX, spaceshipY - 20); - ctx.lineTo(spaceshipX - 6, spaceshipY + 8); - ctx.lineTo(spaceshipX + 6, spaceshipY + 8); - ctx.closePath(); - ctx.fill(); + ctx.save(); + ctx.translate(spaceshipX, spaceshipY); + ctx.scale(scale, scale); + ctx.globalAlpha = opacity; - // Cockpit (glowing cyan) - const gradient = ctx.createRadialGradient(spaceshipX, spaceshipY - 8, 2, spaceshipX, spaceshipY - 8, 6); - gradient.addColorStop(0, "#00ffff"); - gradient.addColorStop(0.5, "#0088ff"); - gradient.addColorStop(1, "#004488"); - ctx.fillStyle = gradient; - ctx.beginPath(); - ctx.arc(spaceshipX, spaceshipY - 8, 5, 0, Math.PI * 2); - ctx.fill(); + // Explosion particles + for (let i = 0; i < 12; i++) { + const angle = (i / 12) * Math.PI * 2; + const distance = progress * 40; + const px = Math.cos(angle) * distance; + const py = Math.sin(angle) * distance; - // Wings (left) - ctx.fillStyle = "#2a5a8a"; - ctx.beginPath(); - ctx.moveTo(spaceshipX - 12, spaceshipY); - ctx.lineTo(spaceshipX - 22, spaceshipY + 8); - ctx.lineTo(spaceshipX - 12, spaceshipY + 8); - ctx.closePath(); - ctx.fill(); + ctx.fillStyle = i % 3 === 0 ? "#ffff00" : i % 3 === 1 ? "#ff6600" : "#ff0000"; + ctx.beginPath(); + ctx.arc(px, py, 4, 0, Math.PI * 2); + ctx.fill(); + } - // Wings (right) - ctx.beginPath(); - ctx.moveTo(spaceshipX + 12, spaceshipY); - ctx.lineTo(spaceshipX + 22, spaceshipY + 8); - ctx.lineTo(spaceshipX + 12, spaceshipY + 8); - ctx.closePath(); - ctx.fill(); + ctx.restore(); + } else { + // Normal spaceship drawing + // Main body (sleek metallic hull) + ctx.fillStyle = "#4a90e2"; + ctx.beginPath(); + ctx.moveTo(spaceshipX, spaceshipY - 20); // nose + ctx.lineTo(spaceshipX - 12, spaceshipY + 8); + ctx.lineTo(spaceshipX + 12, spaceshipY + 8); + ctx.closePath(); + ctx.fill(); - // Wing tips (glowing) - ctx.fillStyle = "#ff3366"; - ctx.beginPath(); - ctx.arc(spaceshipX - 22, spaceshipY + 8, 2, 0, Math.PI * 2); - ctx.fill(); - ctx.beginPath(); - ctx.arc(spaceshipX + 22, spaceshipY + 8, 2, 0, Math.PI * 2); - ctx.fill(); + // Body highlight (lighter blue for 3D effect) + ctx.fillStyle = "#7bb3ff"; + ctx.beginPath(); + ctx.moveTo(spaceshipX, spaceshipY - 20); + ctx.lineTo(spaceshipX - 6, spaceshipY + 8); + ctx.lineTo(spaceshipX + 6, spaceshipY + 8); + ctx.closePath(); + ctx.fill(); - // Engine exhaust (animated glow) - const engineGradient = ctx.createRadialGradient(spaceshipX, spaceshipY + 10, 0, spaceshipX, spaceshipY + 10, 10); - engineGradient.addColorStop(0, "rgba(255, 255, 0, 0.8)"); - engineGradient.addColorStop(0.3, "rgba(255, 150, 0, 0.6)"); - engineGradient.addColorStop(0.6, "rgba(255, 50, 50, 0.3)"); - engineGradient.addColorStop(1, "rgba(255, 0, 0, 0)"); - ctx.fillStyle = engineGradient; - ctx.beginPath(); - ctx.arc(spaceshipX, spaceshipY + 10, 8, 0, Math.PI * 2); - ctx.fill(); + // Cockpit (glowing cyan) + const gradient = ctx.createRadialGradient(spaceshipX, spaceshipY - 8, 2, spaceshipX, spaceshipY - 8, 6); + gradient.addColorStop(0, "#00ffff"); + gradient.addColorStop(0.5, "#0088ff"); + gradient.addColorStop(1, "#004488"); + ctx.fillStyle = gradient; + ctx.beginPath(); + ctx.arc(spaceshipX, spaceshipY - 8, 5, 0, Math.PI * 2); + ctx.fill(); - // Exhaust flames (triple) - ctx.fillStyle = "#ff9900"; - // Center flame - ctx.beginPath(); - ctx.moveTo(spaceshipX - 2, spaceshipY + 10); - ctx.lineTo(spaceshipX, spaceshipY + 22); - ctx.lineTo(spaceshipX + 2, spaceshipY + 10); - ctx.closePath(); - ctx.fill(); - - // Left flame - ctx.fillStyle = "#ff6600"; - ctx.beginPath(); - ctx.moveTo(spaceshipX - 8, spaceshipY + 10); - ctx.lineTo(spaceshipX - 6, spaceshipY + 18); - ctx.lineTo(spaceshipX - 4, spaceshipY + 10); - ctx.closePath(); - ctx.fill(); - - // Right flame - ctx.beginPath(); - ctx.moveTo(spaceshipX + 4, spaceshipY + 10); - ctx.lineTo(spaceshipX + 6, spaceshipY + 18); - ctx.lineTo(spaceshipX + 8, spaceshipY + 10); - ctx.closePath(); - ctx.fill(); + // Wings (left) + ctx.fillStyle = "#2a5a8a"; + ctx.beginPath(); + ctx.moveTo(spaceshipX - 12, spaceshipY); + ctx.lineTo(spaceshipX - 22, spaceshipY + 8); + ctx.lineTo(spaceshipX - 12, spaceshipY + 8); + ctx.closePath(); + ctx.fill(); + + // Wings (right) + ctx.beginPath(); + ctx.moveTo(spaceshipX + 12, spaceshipY); + ctx.lineTo(spaceshipX + 22, spaceshipY + 8); + ctx.lineTo(spaceshipX + 12, spaceshipY + 8); + ctx.closePath(); + ctx.fill(); + + // Wing tips (glowing) + ctx.fillStyle = "#ff3366"; + ctx.beginPath(); + ctx.arc(spaceshipX - 22, spaceshipY + 8, 2, 0, Math.PI * 2); + ctx.fill(); + ctx.beginPath(); + ctx.arc(spaceshipX + 22, spaceshipY + 8, 2, 0, Math.PI * 2); + ctx.fill(); + + // Engine exhaust (animated glow) + const engineGradient = ctx.createRadialGradient(spaceshipX, spaceshipY + 10, 0, spaceshipX, spaceshipY + 10, 10); + engineGradient.addColorStop(0, "rgba(255, 255, 0, 0.8)"); + engineGradient.addColorStop(0.3, "rgba(255, 150, 0, 0.6)"); + engineGradient.addColorStop(0.6, "rgba(255, 50, 50, 0.3)"); + engineGradient.addColorStop(1, "rgba(255, 0, 0, 0)"); + ctx.fillStyle = engineGradient; + ctx.beginPath(); + ctx.arc(spaceshipX, spaceshipY + 10, 8, 0, Math.PI * 2); + ctx.fill(); + + // Exhaust flames (triple) + ctx.fillStyle = "#ff9900"; + // Center flame + ctx.beginPath(); + ctx.moveTo(spaceshipX - 2, spaceshipY + 10); + ctx.lineTo(spaceshipX, spaceshipY + 22); + ctx.lineTo(spaceshipX + 2, spaceshipY + 10); + ctx.closePath(); + ctx.fill(); + + // Left flame + ctx.fillStyle = "#ff6600"; + ctx.beginPath(); + ctx.moveTo(spaceshipX - 8, spaceshipY + 10); + ctx.lineTo(spaceshipX - 6, spaceshipY + 18); + ctx.lineTo(spaceshipX - 4, spaceshipY + 10); + ctx.closePath(); + ctx.fill(); + + // Right flame + ctx.beginPath(); + ctx.moveTo(spaceshipX + 4, spaceshipY + 10); + ctx.lineTo(spaceshipX + 6, spaceshipY + 18); + ctx.lineTo(spaceshipX + 8, spaceshipY + 10); + ctx.closePath(); + ctx.fill(); + } // Draw falling letters with state effects ctx.font = "bold 32px monospace"; diff --git a/src/useGame/useGame.ts b/src/useGame/useGame.ts index 8bead80..58415dc 100755 --- a/src/useGame/useGame.ts +++ b/src/useGame/useGame.ts @@ -1,5 +1,5 @@ import { useState, useEffect } from "react"; -import type { GameManager, GameOptions, KeyLogEntry } from "../types"; +import type { GameManager, GameOptions, KeyLogEntry, FallingLetter } from "../types"; import { useBoard } from "../useBoard"; import { useCursor } from "../useCursor"; import { useGameStatus } from "../useGameStatus"; @@ -13,14 +13,18 @@ export function useGame( const boardManager = useBoard(); const cursorManager = useCursor(); + const gameStatusManager = useGameStatus(); // Wrap renderBoard to include player position const renderBoardWrapped = (letters: any = []) => { - boardManager.renderBoard(cursorManager.position(), letters); + boardManager.renderBoard(cursorManager.position(), letters, gameStatusManager.shipState, gameStatusManager.shipExplosionTime); }; // Game loop tick handler - const handleGameLoopTick = (deltaTime: number, letters: any) => { + const handleGameLoopTick = (deltaTime: number, letters: FallingLetter[]) => { + const playerPos = cursorManager.position(); + gameStatusManager.updatePlayerX(playerPos.x); + if (letters.length > 0) { const letter = letters[0]; cursorManager.setTargetX(letter.x); @@ -30,14 +34,18 @@ export function useGame( renderBoardWrapped(letters); }; - const gameStatusManager = useGameStatus(handleGameLoopTick); - const scoreManager = useScore(gameStatusManager); + // Set the game loop callback + useEffect(() => { + gameStatusManager.setGameLoopCallback(handleGameLoopTick); + }, [gameStatusManager, cursorManager, boardManager]); // Initial render on mount useEffect(() => { renderBoardWrapped(); }, []); + const scoreManager = useScore(gameStatusManager); + const gameManager: GameManager = { containerRef: boardManager.containerRef, renderBoard: renderBoardWrapped, diff --git a/src/useGameStatus/useGameStatus.ts b/src/useGameStatus/useGameStatus.ts index ae17e1e..26ce4e3 100755 --- a/src/useGameStatus/useGameStatus.ts +++ b/src/useGameStatus/useGameStatus.ts @@ -1,5 +1,5 @@ import { useState, useRef, useCallback } from "react"; -import type { GameStatus, FallingLetter } from "../types"; +import type { GameStatus, FallingLetter, ShipState } from "../types"; const LETTER_SPEED = 100; // pixels per second const SPAWN_INTERVAL = 1000; // ms between letter spawns @@ -7,13 +7,19 @@ const MAX_ACTIVE_LETTERS = 1; // Only one letter on screen at a time const MARGIN_LEFT = 50; // Safe margin from left edge const MARGIN_RIGHT = 50; // Safe margin from right edge const CANVAS_WIDTH = 800; +const SPACESHIP_Y = 600 - 40; // Position of the ship on canvas +const COLLISION_THRESHOLD = 30; // Distance to trigger collision export function useGameStatus(onGameLoopTick?: (deltaTime: number, letters: FallingLetter[]) => void) { const [gameStatus, setGameStatus] = useState("waiting"); + const [shipState, setShipState] = useState("normal"); + const [shipExplosionTime, setShipExplosionTime] = useState(0); const gameLoopRef = useRef(null); const lastFrameTimeRef = useRef(0); const lettersRef = useRef([]); const lastSpawnRef = useRef(0); + const playerXRef = useRef(CANVAS_WIDTH / 2); + const onGameLoopTickRef = useRef(onGameLoopTick); // Game loop const gameLoop = useCallback((currentTime: number) => { @@ -42,17 +48,41 @@ export function useGameStatus(onGameLoopTick?: (deltaTime: number, letters: Fall letter.y += (LETTER_SPEED * deltaTime) / 1000; }); + // Check for collisions with ship + lettersRef.current.forEach(letter => { + if (letter.state === 'normal') { + const letterY = letter.y; + const distanceFromShip = Math.abs(letterY - SPACESHIP_Y); + const horizontalDistance = Math.abs(letter.x - playerXRef.current); + + if (distanceFromShip < COLLISION_THRESHOLD && horizontalDistance < 30) { + // Collision detected + letter.state = 'exploding'; + letter.stateStartTime = currentTime; + setShipState('exploding'); + setShipExplosionTime(performance.now()); + + // Clear letters and reset spawn timer after explosion animation + setTimeout(() => { + lettersRef.current = []; + lastSpawnRef.current = performance.now(); + setShipState('normal'); + }, 300); + } + } + }); + // Remove letters that went off screen lettersRef.current = lettersRef.current.filter(letter => letter.y < 600); // Call tick callback if provided - if (onGameLoopTick) { - onGameLoopTick(deltaTime, lettersRef.current); + if (onGameLoopTickRef.current) { + onGameLoopTickRef.current(deltaTime, lettersRef.current); } // Continue loop gameLoopRef.current = requestAnimationFrame(gameLoop); - }, [onGameLoopTick]); + }, []); const startGame = useCallback(() => { lastFrameTimeRef.current = performance.now(); @@ -95,12 +125,24 @@ export function useGameStatus(onGameLoopTick?: (deltaTime: number, letters: Fall } }, []); + const updatePlayerX = useCallback((x: number) => { + playerXRef.current = x; + }, []); + + const setGameLoopCallback = useCallback((callback: (deltaTime: number, letters: FallingLetter[]) => void) => { + onGameLoopTickRef.current = callback; + }, []); + return { gameStatus, setGameStatus, startGame, quitGame, handleTypedLetter, + shipState, + shipExplosionTime, + updatePlayerX, + setGameLoopCallback, }; }