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
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+19
-9
@@ -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)
|
||||
|
||||
@@ -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<GameMode, number> = {
|
||||
@@ -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<GameStatus>("waiting");
|
||||
const [shipState, setShipState] = useState<ShipState>("normal");
|
||||
const [shipExplosionTime, setShipExplosionTime] = useState(0);
|
||||
@@ -27,6 +27,7 @@ export function useGameStatus(onGameLoopTick?: (deltaTime: number, letters: Fall
|
||||
const gameLoopRef = useRef<number | null>(null);
|
||||
const lastFrameTimeRef = useRef<number>(0);
|
||||
const lettersRef = useRef<FallingLetter[]>([]);
|
||||
const lasersRef = useRef<Laser[]>([]);
|
||||
const lastSpawnRef = useRef<number>(0);
|
||||
const playerXRef = useRef<number>(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;
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user