feat: add typing mechanics with visual feedback and safe margins

Implement core typing game mechanics with keyboard input handling. Correct
letters trigger explosion animation with radiating particles and spawn new
letter. Wrong letters flash red for immediate feedback. Add 50px margins on
both edges to prevent spaceship and letters from appearing at screen borders.
This commit is contained in:
2025-11-03 22:38:10 +00:00
parent 04332effa6
commit 32045c5473
6 changed files with 101 additions and 13 deletions
+7 -1
View File
@@ -19,11 +19,16 @@ export const getGamePhase = (status: GameStatus): GamePhase =>
// ============================================================================
export type PlayerPosition = { x: number }; // pixel position 0-800
export type LetterState = 'normal' | 'wrong' | 'exploding';
export type FallingLetter = {
id: string;
letter: string;
x: number; // 0-800 pixel position
y: number; // 0-600 pixel position
state: LetterState;
stateStartTime?: number; // For animation timing
};
// ============================================================================
@@ -126,7 +131,8 @@ export type GameManager = {
clearKeyLog: () => void;
getKeyLog: () => KeyLogEntry[];
// TODO: Add game-specific additions
// Game-specific
handleTypedLetter: (letter: string) => void;
};
// ============================================================================
+46 -5
View File
@@ -132,13 +132,54 @@ export function useBoard() {
ctx.closePath();
ctx.fill();
// Draw falling letters
ctx.fillStyle = "#ffffff";
ctx.font = "24px monospace";
// Draw falling letters with state effects
ctx.font = "bold 32px monospace";
ctx.textAlign = "center";
ctx.textBaseline = "top";
ctx.textBaseline = "middle";
letters.forEach((letter: any) => {
ctx.fillText(letter.letter, letter.x, letter.y);
if (letter.state === 'wrong') {
// Red glow flash
ctx.shadowBlur = 20;
ctx.shadowColor = "#ff0000";
ctx.fillStyle = "#ff4444";
ctx.fillText(letter.letter, letter.x, letter.y);
ctx.shadowBlur = 0;
} else if (letter.state === 'exploding') {
// Explosion effect
const elapsed = performance.now() - (letter.stateStartTime || 0);
const progress = Math.min(elapsed / 300, 1);
const scale = 1 + progress * 2;
const opacity = 1 - progress;
ctx.save();
ctx.translate(letter.x, letter.y);
ctx.scale(scale, scale);
ctx.globalAlpha = opacity;
// Explosion particles
for (let i = 0; i < 8; i++) {
const angle = (i / 8) * Math.PI * 2;
const distance = progress * 30;
const px = Math.cos(angle) * distance;
const py = Math.sin(angle) * distance;
ctx.fillStyle = i % 2 === 0 ? "#ffff00" : "#ff6600";
ctx.beginPath();
ctx.arc(px, py, 3, 0, Math.PI * 2);
ctx.fill();
}
// Letter fading
ctx.fillStyle = "#ffffff";
ctx.fillText(letter.letter, 0, 0);
ctx.restore();
} else {
// Normal state
ctx.fillStyle = "#ffffff";
ctx.fillText(letter.letter, letter.x, letter.y);
}
});
};
+6 -4
View File
@@ -1,6 +1,8 @@
import { useRef } from "react";
const CANVAS_WIDTH = 800;
const MARGIN_LEFT = 50;
const MARGIN_RIGHT = 50;
const START_PIXEL_X = CANVAS_WIDTH / 2; // Start at center (400px)
const MOVEMENT_DURATION_MS = 100; // Time to reach target regardless of distance
@@ -11,13 +13,13 @@ export function useCursor() {
const movementStartTimeRef = useRef<number>(0);
const moveLeft = (count: number = 1) => {
const newX = Math.max(0, pixelXRef.current - (count * 80));
const newX = Math.max(MARGIN_LEFT, pixelXRef.current - (count * 80));
pixelXRef.current = newX;
targetXRef.current = newX;
};
const moveRight = (count: number = 1) => {
const newX = Math.min(CANVAS_WIDTH, pixelXRef.current + (count * 80));
const newX = Math.min(CANVAS_WIDTH - MARGIN_RIGHT, pixelXRef.current + (count * 80));
pixelXRef.current = newX;
targetXRef.current = newX;
};
@@ -31,13 +33,13 @@ export function useCursor() {
};
const moveToStart = () => {
const newX = 0;
const newX = MARGIN_LEFT;
pixelXRef.current = newX;
targetXRef.current = newX;
};
const moveToEnd = () => {
const newX = CANVAS_WIDTH;
const newX = CANVAS_WIDTH - MARGIN_RIGHT;
pixelXRef.current = newX;
targetXRef.current = newX;
};
+1
View File
@@ -50,6 +50,7 @@ export function useGame(
keyLog,
clearKeyLog: () => setKeyLog([]),
getKeyLog: () => [...keyLog],
handleTypedLetter: gameStatusManager.handleTypedLetter,
};
if (typeof platformHook === 'function') {
+32 -1
View File
@@ -4,6 +4,9 @@ import type { GameStatus, FallingLetter } from "../types";
const LETTER_SPEED = 100; // pixels per second
const SPAWN_INTERVAL = 1000; // ms between letter spawns
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;
export function useGameStatus(onGameLoopTick?: (deltaTime: number, letters: FallingLetter[]) => void) {
const [gameStatus, setGameStatus] = useState<GameStatus>("waiting");
@@ -20,13 +23,15 @@ export function useGameStatus(onGameLoopTick?: (deltaTime: number, letters: Fall
// Spawn new letter if enough time has passed and no letters active
if (currentTime - lastSpawnRef.current > SPAWN_INTERVAL && lettersRef.current.length < MAX_ACTIVE_LETTERS) {
const randomLetter = String.fromCharCode(65 + Math.floor(Math.random() * 26)); // A-Z
const randomX = Math.random() * 800;
const spawnWidth = CANVAS_WIDTH - MARGIN_LEFT - MARGIN_RIGHT;
const randomX = MARGIN_LEFT + Math.random() * spawnWidth;
const randomY = Math.random() * 150; // Between 0-150 (top half of screen)
const newLetter: FallingLetter = {
id: `${currentTime}-${randomX}`,
letter: randomLetter,
x: randomX,
y: randomY,
state: 'normal',
};
lettersRef.current.push(newLetter);
lastSpawnRef.current = currentTime;
@@ -65,11 +70,37 @@ export function useGameStatus(onGameLoopTick?: (deltaTime: number, letters: Fall
}
}, []);
const handleTypedLetter = useCallback((typedLetter: string) => {
if (lettersRef.current.length === 0) return;
const currentLetter = lettersRef.current[0];
if (typedLetter.toUpperCase() === currentLetter.letter) {
currentLetter.state = 'exploding';
currentLetter.stateStartTime = performance.now();
setTimeout(() => {
lettersRef.current = [];
lastSpawnRef.current = 0;
}, 300);
} else {
currentLetter.state = 'wrong';
currentLetter.stateStartTime = performance.now();
setTimeout(() => {
if (currentLetter.state === 'wrong') {
currentLetter.state = 'normal';
}
}, 200);
}
}, []);
return {
gameStatus,
setGameStatus,
startGame,
quitGame,
handleTypedLetter,
};
}