From aff5ccecbf87c60258b1f8ae39e687faa6060926 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Mon, 3 Nov 2025 22:22:11 +0000 Subject: [PATCH] feat: implement pixel-based spaceship animation with 100ms movement duration Refactor spaceship positioning from column-based to pixel-based coordinates with time-based interpolation, enabling smooth animation to letter positions in exactly 100ms regardless of distance. Fixes coordinate mismatch that prevented spaceship from moving to spawned letters. --- src/types.ts | 2 +- src/useBoard/useBoard.ts | 6 ++- src/useCursor/useCursor.ts | 65 +++++++++++++++++++++++++----- src/useGame/useGame.ts | 22 +++++++--- src/useGameStatus/useGameStatus.ts | 3 +- 5 files changed, 77 insertions(+), 21 deletions(-) diff --git a/src/types.ts b/src/types.ts index 8418663..4dffbe1 100755 --- a/src/types.ts +++ b/src/types.ts @@ -18,7 +18,7 @@ export const getGamePhase = (status: GameStatus): GamePhase => // Game-Specific Types // ============================================================================ -export type PlayerPosition = { x: number }; // 0-9 for 10 columns +export type PlayerPosition = { x: number }; // pixel position 0-800 export type FallingLetter = { id: string; letter: string; diff --git a/src/useBoard/useBoard.ts b/src/useBoard/useBoard.ts index 44f98cd..65c3934 100644 --- a/src/useBoard/useBoard.ts +++ b/src/useBoard/useBoard.ts @@ -36,9 +36,11 @@ export function useBoard() { ctx.fillStyle = "#000"; ctx.fillRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT); - // Draw spaceship at player position - const spaceshipX = (playerPosition.x / 10) * CANVAS_WIDTH + CANVAS_WIDTH / 20; + // Draw spaceship at player position (already in pixels) + const spaceshipX = playerPosition.x; const spaceshipY = SPACESHIP_Y; + + console.log('Rendering spaceship at X:', spaceshipX); // Spaceship body (triangle pointing up) ctx.fillStyle = "#00ff00"; diff --git a/src/useCursor/useCursor.ts b/src/useCursor/useCursor.ts index e542333..b696252 100755 --- a/src/useCursor/useCursor.ts +++ b/src/useCursor/useCursor.ts @@ -1,21 +1,27 @@ -import { useState } from "react"; -import type { PlayerPosition } from "../types"; +import { useRef } from "react"; -const BOARD_WIDTH = 10; // 10 columns -const START_POSITION = 4; // Start at center (column 4-5) +const CANVAS_WIDTH = 800; +const START_PIXEL_X = CANVAS_WIDTH / 2; // Start at center (400px) +const MOVEMENT_DURATION_MS = 100; // Time to reach target regardless of distance export function useCursor() { - const [position, setPosition] = useState({ x: START_POSITION }); + const pixelXRef = useRef(START_PIXEL_X); + const targetXRef = useRef(START_PIXEL_X); + const startXRef = useRef(START_PIXEL_X); + const movementStartTimeRef = useRef(0); const moveLeft = (count: number = 1) => { - setPosition(p => ({ x: Math.max(0, p.x - count) })); + const newX = Math.max(0, pixelXRef.current - (count * 80)); + pixelXRef.current = newX; + targetXRef.current = newX; }; const moveRight = (count: number = 1) => { - setPosition(p => ({ x: Math.min(BOARD_WIDTH - 1, p.x + count) })); + const newX = Math.min(CANVAS_WIDTH, pixelXRef.current + (count * 80)); + pixelXRef.current = newX; + targetXRef.current = newX; }; - // Typing game: hjkl only moves left/right, not up/down const moveUp = () => { // No vertical movement in typing game }; @@ -25,11 +31,15 @@ export function useCursor() { }; const moveToStart = () => { - setPosition({ x: 0 }); + const newX = 0; + pixelXRef.current = newX; + targetXRef.current = newX; }; const moveToEnd = () => { - setPosition({ x: BOARD_WIDTH - 1 }); + const newX = CANVAS_WIDTH; + pixelXRef.current = newX; + targetXRef.current = newX; }; const moveToTop = () => { @@ -44,8 +54,39 @@ export function useCursor() { // TODO: Implement repeat for typing game }; + const setTargetX = (pixelX: number) => { + if (targetXRef.current !== pixelX) { + startXRef.current = pixelXRef.current; + targetXRef.current = pixelX; + movementStartTimeRef.current = performance.now(); + } + }; + + const moveTowardTarget = (_deltaTime: number) => { + const targetPixelX = targetXRef.current; + const startPixelX = startXRef.current; + const currentPixelX = pixelXRef.current; + + if (currentPixelX === targetPixelX) { + return; + } + + const elapsedTime = performance.now() - movementStartTimeRef.current; + const progress = Math.min(elapsedTime / MOVEMENT_DURATION_MS, 1); + + const newX = startPixelX + (targetPixelX - startPixelX) * progress; + pixelXRef.current = newX; + + console.log('moveTowardTarget:', { currentPixelX, targetPixelX, progress, newX }); + + if (progress >= 1) { + pixelXRef.current = targetPixelX; + console.log('Reached target:', targetPixelX); + } + }; + return { - position: () => position, + position: () => ({ x: pixelXRef.current }), mode: () => 'normal' as const, moveLeft, moveRight, @@ -56,6 +97,8 @@ export function useCursor() { moveToTop, moveToBottom, repeatLastMotion, + setTargetX, + moveTowardTarget, }; } diff --git a/src/useGame/useGame.ts b/src/useGame/useGame.ts index d6d6838..1727944 100755 --- a/src/useGame/useGame.ts +++ b/src/useGame/useGame.ts @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback } from "react"; +import { useState, useEffect } from "react"; import type { GameManager, GameOptions, KeyLogEntry } from "../types"; import { useBoard } from "../useBoard"; import { useCursor } from "../useCursor"; @@ -15,14 +15,24 @@ export function useGame( const cursorManager = useCursor(); // Wrap renderBoard to include player position - const renderBoardWrapped = useCallback((letters: any = []) => { + const renderBoardWrapped = (letters: any = []) => { boardManager.renderBoard(cursorManager.position(), letters); - }, [boardManager, cursorManager]); + }; // Game loop tick handler - const handleGameLoopTick = useCallback((_deltaTime: number, letters: any) => { + const handleGameLoopTick = (deltaTime: number, letters: any) => { + // If a letter just spawned, move ship to it + if (letters.length > 0) { + const letter = letters[0]; + console.log('Setting target to:', letter.x); + cursorManager.setTargetX(letter.x); + } + + // Move spaceship towards target + cursorManager.moveTowardTarget(deltaTime); + renderBoardWrapped(letters); - }, [renderBoardWrapped]); + }; const gameStatusManager = useGameStatus(handleGameLoopTick); const scoreManager = useScore(gameStatusManager); @@ -30,7 +40,7 @@ export function useGame( // Initial render on mount useEffect(() => { renderBoardWrapped(); - }, [renderBoardWrapped]); + }, []); const gameManager: GameManager = { containerRef: boardManager.containerRef, diff --git a/src/useGameStatus/useGameStatus.ts b/src/useGameStatus/useGameStatus.ts index 0b36992..2d230f4 100755 --- a/src/useGameStatus/useGameStatus.ts +++ b/src/useGameStatus/useGameStatus.ts @@ -21,11 +21,12 @@ export function useGameStatus(onGameLoopTick?: (deltaTime: number, letters: Fall 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 randomY = Math.random() * 150; // Between 0-150 (top half of screen) const newLetter: FallingLetter = { id: `${currentTime}-${randomX}`, letter: randomLetter, x: randomX, - y: 0, + y: randomY, }; lettersRef.current.push(newLetter); lastSpawnRef.current = currentTime;