From 8d093f2c85d879e30664f2b4d254f31e13439ee0 Mon Sep 17 00:00:00 2001 From: Andre Padez Date: Tue, 4 Nov 2025 17:57:24 +0000 Subject: [PATCH] feat: add laser shooting effects and ship tracking for words mode - Add green lasers that hit target on correct typing - Add red lasers that miss (left/right) on incorrect typing - Implement laser beam visual with gradient and glow effects - Add impact burst animation at laser end point - Ship now tracks each character in words mode - Creates dynamic targeting system for all game modes --- src/types.ts | 11 +++++ src/useBoard/useBoard.ts | 67 ++++++++++++++++++++++++++++-- src/useGame/useGame.ts | 28 +++++++++---- src/useGameStatus/useGameStatus.ts | 53 ++++++++++++++++++++--- 4 files changed, 141 insertions(+), 18 deletions(-) diff --git a/src/types.ts b/src/types.ts index 286f073..58308a0 100755 --- a/src/types.ts +++ b/src/types.ts @@ -24,6 +24,17 @@ export type PlayerPosition = { x: number }; // pixel position 0-800 export type LetterState = 'normal' | 'wrong' | 'exploding'; export type ShipState = 'normal' | 'exploding'; +export type Laser = { + id: string; + startX: number; // Ship position + startY: number; // Ship Y + endX: number; // Target position (offset left/right for misses) + endY: number; // Target Y + startTime: number; + duration: number; // How long the laser is visible + hit: boolean; // True for hits (not implemented yet), false for misses +}; + export type FallingLetter = { id: string; letter: string; diff --git a/src/useBoard/useBoard.ts b/src/useBoard/useBoard.ts index 5d869dd..09a0ea2 100644 --- a/src/useBoard/useBoard.ts +++ b/src/useBoard/useBoard.ts @@ -1,5 +1,5 @@ import { useRef, useEffect } from "react"; -import type { PlayerPosition, ShipState, GameMode } from "../types"; +import type { PlayerPosition, ShipState, GameMode, Laser } from "../types"; const CANVAS_WIDTH = 800; const CANVAS_HEIGHT = 600; @@ -47,7 +47,7 @@ export function useBoard() { canvasRef.current = canvas; }, []); - const renderBoard = (playerPosition: PlayerPosition, letters: any = [], shipState: ShipState = "normal", shipExplosionTime?: number, gameMode: GameMode = 'letters') => { + const renderBoard = (playerPosition: PlayerPosition, letters: any = [], shipState: ShipState = "normal", shipExplosionTime?: number, gameMode: GameMode = 'letters', lasers: Laser[] = []) => { if (shipExplosionTime !== undefined) { shipExplosionTimeRef.current = shipExplosionTime; } @@ -187,6 +187,57 @@ export function useBoard() { ctx.fill(); } + // Draw lasers + lasers.forEach((laser: Laser) => { + const progress = (performance.now() - laser.startTime) / laser.duration; + if (progress <= 1) { + // Laser beam effect + ctx.save(); + + // Create gradient for laser beam + const gradient = ctx.createLinearGradient(laser.startX, laser.startY, laser.endX, laser.endY); + + if (laser.hit) { + // Green laser for hits (future feature) + gradient.addColorStop(0, "rgba(0, 255, 0, 0.8)"); + gradient.addColorStop(0.5, "rgba(100, 255, 100, 1)"); + gradient.addColorStop(1, "rgba(0, 255, 0, 0.3)"); + } else { + // Red laser for misses + gradient.addColorStop(0, "rgba(255, 0, 0, 0.8)"); + gradient.addColorStop(0.5, "rgba(255, 100, 100, 1)"); + gradient.addColorStop(1, "rgba(255, 0, 0, 0.3)"); + } + + // Draw laser beam with fade effect + ctx.strokeStyle = gradient; + ctx.lineWidth = 3 * (1 - progress * 0.5); // Gets thinner as it fades + ctx.globalAlpha = 1 - progress * 0.7; // Fades out + + ctx.beginPath(); + ctx.moveTo(laser.startX, laser.startY); + ctx.lineTo(laser.endX, laser.endY); + ctx.stroke(); + + // Add glow effect + ctx.shadowBlur = 10; + ctx.shadowColor = laser.hit ? "#00ff00" : "#ff0000"; + ctx.stroke(); + + // Draw impact burst at the end + if (progress > 0.5) { + const burstProgress = (progress - 0.5) * 2; + const burstSize = 10 * burstProgress; + ctx.fillStyle = laser.hit ? "rgba(0, 255, 0, 0.5)" : "rgba(255, 0, 0, 0.5)"; + ctx.beginPath(); + ctx.arc(laser.endX, laser.endY, burstSize, 0, Math.PI * 2); + ctx.fill(); + } + + ctx.restore(); + } + }); + // Draw falling letters with state effects const isSentenceMode = gameMode === 'sentences' || gameMode === 'paragraphs'; const fontSize = isSentenceMode ? 20 : 32; @@ -400,6 +451,9 @@ export function useBoard() { const startX = letterX - totalWidth / 2; let currentX = startX; + // For words mode, calculate position of current character + const isWordMode = gameMode === 'words'; + // Draw completed characters in green if (charIndex > 0) { ctx.fillStyle = "#00ff00"; @@ -410,8 +464,13 @@ export function useBoard() { // Draw current character in yellow ctx.fillStyle = "#ffff00"; - ctx.fillText(fullText[charIndex], currentX, letter.y); - currentX += ctx.measureText(fullText[charIndex]).width; + const currentChar = fullText[charIndex]; + // Store position for ship tracking in words mode + if (isWordMode) { + letter.currentCharX = currentX + ctx.measureText(currentChar).width / 2; + } + ctx.fillText(currentChar, currentX, letter.y); + currentX += ctx.measureText(currentChar).width; // Draw remaining characters in white if (charIndex + 1 < fullText.length) { diff --git a/src/useGame/useGame.ts b/src/useGame/useGame.ts index 7ca6bd3..1133d2f 100755 --- a/src/useGame/useGame.ts +++ b/src/useGame/useGame.ts @@ -1,5 +1,5 @@ import { useState, useEffect, useRef } from "react"; -import type { GameManager, GameOptions, KeyLogEntry, FallingLetter, GameMode } from "../types"; +import type { GameManager, GameOptions, KeyLogEntry, FallingLetter, GameMode, Laser } from "../types"; import { useBoard } from "../useBoard"; import { useCursor } from "../useCursor"; import { useGameStatus } from "../useGameStatus"; @@ -17,24 +17,34 @@ export function useGame( const cursorManager = useCursor(); const gameStatusManager = useGameStatus(undefined, gameMode); - // Wrap renderBoard to include player position - const renderBoardWrapped = (letters: any = []) => { - boardManager.renderBoard(cursorManager.position(), letters, gameStatusManager.shipState, gameStatusManager.shipExplosionTime, gameModeRef.current); + // Wrap renderBoard to include player position and lasers + const renderBoardWrapped = (letters: any = [], lasers: Laser[] = []) => { + boardManager.renderBoard(cursorManager.position(), letters, gameStatusManager.shipState, gameStatusManager.shipExplosionTime, gameModeRef.current, lasers); }; // Game loop tick handler - const handleGameLoopTick = (deltaTime: number, letters: FallingLetter[]) => { + const handleGameLoopTick = (deltaTime: number, letters: FallingLetter[], lasers: Laser[] = []) => { const playerPos = cursorManager.position(); gameStatusManager.updatePlayerX(playerPos.x); - // For words/letters, auto-track to falling letters - // For sentences/paragraphs, track to current character position - if (gameModeRef.current !== 'sentences' && gameModeRef.current !== 'paragraphs') { + // Handle ship tracking based on game mode + if (gameModeRef.current === 'letters') { + // Letters mode: track to letter position (original behavior) if (letters.length > 0) { const letter = letters[0]; cursorManager.setTargetX(letter.x); } + cursorManager.moveTowardTarget(deltaTime); + } else if (gameModeRef.current === 'words') { + // Words mode: track to current character within word + if (letters.length > 0) { + const letter = letters[0]; + // Use stored character position or fall back to word center + const targetX = letter.currentCharX || letter.x; + cursorManager.setTargetX(targetX); + } + cursorManager.moveTowardTarget(deltaTime); } else { // For sentences: track to current character position @@ -49,7 +59,7 @@ export function useGame( cursorManager.moveTowardTarget(deltaTime); } - renderBoardWrapped(letters); + renderBoardWrapped(letters, lasers); }; // Update gameMode when it changes (without recreating gameStatus state) diff --git a/src/useGameStatus/useGameStatus.ts b/src/useGameStatus/useGameStatus.ts index 1629160..de84434 100755 --- a/src/useGameStatus/useGameStatus.ts +++ b/src/useGameStatus/useGameStatus.ts @@ -1,5 +1,5 @@ import { useState, useRef } from "react"; -import type { GameStatus, FallingLetter, ShipState, GameMode } from "../types"; +import type { GameStatus, FallingLetter, ShipState, GameMode, Laser } from "../types"; import { getRandomWord } from "../wordLists"; const LETTER_SPEEDS: Record = { @@ -16,7 +16,7 @@ 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, gameMode: GameMode = 'letters') { +export function useGameStatus(onGameLoopTick?: (deltaTime: number, letters: FallingLetter[], lasers: Laser[]) => void, gameMode: GameMode = 'letters') { const [gameStatus, setGameStatus] = useState("waiting"); const [shipState, setShipState] = useState("normal"); const [shipExplosionTime, setShipExplosionTime] = useState(0); @@ -27,6 +27,7 @@ export function useGameStatus(onGameLoopTick?: (deltaTime: number, letters: Fall const gameLoopRef = useRef(null); const lastFrameTimeRef = useRef(0); const lettersRef = useRef([]); + const lasersRef = useRef([]); const lastSpawnRef = useRef(0); const playerXRef = useRef(CANVAS_WIDTH / 2); const onGameLoopTickRef = useRef(onGameLoopTick); @@ -94,10 +95,15 @@ export function useGameStatus(onGameLoopTick?: (deltaTime: number, letters: Fall // Remove letters that went off screen lettersRef.current = lettersRef.current.filter(letter => letter.y < 600); + + // Remove expired lasers + lasersRef.current = lasersRef.current.filter(laser => + currentTime - laser.startTime < laser.duration + ); - // Call tick callback if provided + // Call tick callback if provided (now with lasers) if (onGameLoopTickRef.current) { - onGameLoopTickRef.current(deltaTime, lettersRef.current); + onGameLoopTickRef.current(deltaTime, lettersRef.current, lasersRef.current); } // Continue loop @@ -135,6 +141,23 @@ export function useGameStatus(onGameLoopTick?: (deltaTime: number, letters: Fall setScore(prev => prev + 1); setCorrect(prev => prev + 1); + // Create a laser that hits the target (green laser) + const targetX = currentLetter.currentCharX || currentLetter.x; + const targetY = currentLetter.y; + + const hitLaser: Laser = { + id: `laser-${performance.now()}`, + startX: playerXRef.current, + startY: SPACESHIP_Y - 20, // From ship's nose + endX: targetX, + endY: targetY, + startTime: performance.now(), + duration: 150, // Slightly faster for hits + hit: true + }; + + lasersRef.current.push(hitLaser); + const charIndex = (currentLetter.charIndex ?? 0) + 1; const fullText = currentLetter.fullText ?? currentLetter.letter; @@ -184,6 +207,26 @@ export function useGameStatus(onGameLoopTick?: (deltaTime: number, letters: Fall setScore(prev => Math.max(0, prev - 1)); setMissed(prev => prev + 1); + // Create a laser that misses (shoots left or right) + const missDirection = Math.random() > 0.5 ? 1 : -1; // Random left or right + const missOffset = 30 + Math.random() * 50; // 30-80 pixels off target + + const targetX = currentLetter.currentCharX || currentLetter.x; + const targetY = currentLetter.y; + + const newLaser: Laser = { + id: `laser-${performance.now()}`, + startX: playerXRef.current, + startY: SPACESHIP_Y - 20, // From ship's nose + endX: targetX + (missOffset * missDirection), + endY: targetY, + startTime: performance.now(), + duration: 200, // 200ms visibility + hit: false + }; + + lasersRef.current.push(newLaser); + setTimeout(() => { if (currentLetter.state === 'wrong') { currentLetter.state = 'normal'; @@ -196,7 +239,7 @@ export function useGameStatus(onGameLoopTick?: (deltaTime: number, letters: Fall playerXRef.current = x; }; - const setGameLoopCallback = (callback: (deltaTime: number, letters: FallingLetter[]) => void) => { + const setGameLoopCallback = (callback: (deltaTime: number, letters: FallingLetter[], lasers: Laser[]) => void) => { onGameLoopTickRef.current = callback; };