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:
@@ -2,7 +2,7 @@ import { useEffect } from "react";
|
||||
import type { GameManager } from "../../src";
|
||||
|
||||
export const useKeyBindings = (gameManager: GameManager) => {
|
||||
const { gameStatus, renderBoard, startGame, quitGame, clearKeyLog } = gameManager;
|
||||
const { gameStatus, renderBoard, startGame, quitGame, clearKeyLog, handleTypedLetter } = gameManager;
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (ev: KeyboardEvent) => {
|
||||
@@ -29,10 +29,17 @@ export const useKeyBindings = (gameManager: GameManager) => {
|
||||
quitGame();
|
||||
return;
|
||||
}
|
||||
|
||||
// handle letter typing during game
|
||||
if (gameStatus === "started") {
|
||||
if (ev.key.length === 1 && /[a-zA-Z]/.test(ev.key)) {
|
||||
handleTypedLetter(ev.key);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [gameStatus]);
|
||||
}, [gameStatus, handleTypedLetter]);
|
||||
};
|
||||
|
||||
|
||||
+7
-1
@@ -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;
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -50,6 +50,7 @@ export function useGame(
|
||||
keyLog,
|
||||
clearKeyLog: () => setKeyLog([]),
|
||||
getKeyLog: () => [...keyLog],
|
||||
handleTypedLetter: gameStatusManager.handleTypedLetter,
|
||||
};
|
||||
|
||||
if (typeof platformHook === 'function') {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user