From a6f066d738010e956657d408a4747851debbbfe6 Mon Sep 17 00:00:00 2001 From: Andre Padez Date: Sun, 19 Oct 2025 15:29:01 +0100 Subject: [PATCH] scoreManager implemented flawlessly --- UNIFIED_API.md => UNIFIED_API_OLD.md | 0 example/src/App.tsx | 47 +++++++++- src/types.ts | 3 + src/useGame.ts | 5 +- src/useScore/index.ts | 4 + src/useScore/useScore.ts | 135 +++++++++++++++++++++++++++ src/useScore/useScorePaths.ts | 44 +++++++++ src/useScore/useScoreTime.ts | 33 +++++++ src/useScore/useTimer.ts | 48 ++++++++++ 9 files changed, 316 insertions(+), 3 deletions(-) rename UNIFIED_API.md => UNIFIED_API_OLD.md (100%) create mode 100644 src/useScore/index.ts create mode 100644 src/useScore/useScore.ts create mode 100644 src/useScore/useScorePaths.ts create mode 100644 src/useScore/useScoreTime.ts create mode 100644 src/useScore/useTimer.ts diff --git a/UNIFIED_API.md b/UNIFIED_API_OLD.md similarity index 100% rename from UNIFIED_API.md rename to UNIFIED_API_OLD.md diff --git a/example/src/App.tsx b/example/src/App.tsx index 9167d4b..1ef555e 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -11,10 +11,52 @@ function App() { const rows = isMobile ? 8 : 24; const gameManager = useGame({ cols, rows }, useKeyBindings); - const { containerRef, gameStatus } = gameManager; + const { containerRef, gameStatus, scoreManager } = gameManager; + const { timeValue, keystrokes, efficiency, optimalSteps, finalScore } = scoreManager; + + const formatTime = (ms: number) => { + const seconds = Math.floor(ms / 1000); + const minutes = Math.floor(seconds / 60); + const secs = seconds % 60; + return `${minutes}:${secs.toString().padStart(2, '0')}`; + }; return ( -
+
+

VIMazing vim-maze example

+
+ {(gameStatus === "started" || gameStatus === "has-key" || gameStatus === "game-won") && ( +
+
+
Time
+
{formatTime(timeValue)}
+
+
+
Keystrokes
+
{keystrokes}
+
+
+
Efficiency
+
150 ? 'text-red-500' : efficiency > 100 ? 'text-yellow-500' : 'text-green-500'}`}> + {efficiency}% +
+
+
+
Optimal
+
{optimalSteps}
+
+ {gameStatus === "game-won" && finalScore && ( + <> +
+
+
Final Score
+
{finalScore}
+
+ + )} +
+ )} +
@@ -22,6 +64,7 @@ function App() { {gameStatus === "waiting" &&

Press space to start

} {gameStatus === "started" &&

Use hjkl to change direction • Press q to quit

} {gameStatus === "game-over" &&

Game Over! Press space to restart

} + {gameStatus === "game-won" &&

You Won! Press space to play again

}
); diff --git a/src/types.ts b/src/types.ts index a4564e7..cf5bae9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,8 +1,10 @@ import type { RefObject } from 'react'; import { MazeRenderer, MazeGenerator } from "./useBoard"; +import type { ScoreManager } from './useScore'; export * from './useGame'; export * from './useBoard'; export * from './useCursor'; +export * from './useScore'; export type GameStatus = 'waiting' | 'started' | 'has-key' | 'paused' | 'game-over' | 'game-won' @@ -12,6 +14,7 @@ export type GameManager = { cursor: CursorManager; hero: HeroManager; renderer: HeroRenderer; + scoreManager: ScoreManager; } & GameStatusManager & GameKeyManager; diff --git a/src/useGame.ts b/src/useGame.ts index affd817..7fbdf16 100644 --- a/src/useGame.ts +++ b/src/useGame.ts @@ -3,6 +3,7 @@ import { useBoard } from './useBoard'; import { useCursor } from './useCursor'; import { useGameStatus } from './useGameStatus'; import { useHeroRender } from './useCursor'; +import { useScore } from './useScore'; export function useGame(options: GameOptions, platformHook?: unknown): GameManager { const { cols, rows } = options; @@ -13,8 +14,9 @@ export function useGame(options: GameOptions, platformHook?: unknown): GameManag const hero = cursor.hero; const renderer = useHeroRender({ gameStatus: gameStatusManager.gameStatus, board, hero }); + const scoreManager = useScore({ board, hero, gameStatusManager, keyManager }); - const gameManager = { + const gameManager: GameManager = { containerRef, ...keyManager, ...gameStatusManager, @@ -22,6 +24,7 @@ export function useGame(options: GameOptions, platformHook?: unknown): GameManag cursor, hero, renderer, + scoreManager, } if (typeof platformHook === 'function') { diff --git a/src/useScore/index.ts b/src/useScore/index.ts new file mode 100644 index 0000000..81500d3 --- /dev/null +++ b/src/useScore/index.ts @@ -0,0 +1,4 @@ +export * from './useScore'; +export * from './useScorePaths'; +export * from './useScoreTime'; +export * from './useTimer'; diff --git a/src/useScore/useScore.ts b/src/useScore/useScore.ts new file mode 100644 index 0000000..6740f7d --- /dev/null +++ b/src/useScore/useScore.ts @@ -0,0 +1,135 @@ +import { useEffect, useRef, useState } from "react"; +import { useTimer } from "./useTimer"; +import { useScoreTime } from "./useScoreTime"; +import { useScorePaths } from "./useScorePaths"; +import type { BoardManager, GameStatusManager, UseHeroType } from "../types"; + +type UseScoreParams = { + board: BoardManager; + hero: UseHeroType; + gameStatusManager: GameStatusManager; + keyManager?: { keyLog: any[] }; +}; + +export function useScore({ board, hero, gameStatusManager, keyManager }: UseScoreParams) { + const timer = useTimer(); + const { gameStatus, setGameStatus } = gameStatusManager; + const { timeValue, startTimer, stopTimer, resetTimer } = timer; + + const { + entranceToKey = 0, + entranceToExit = 0, + heroToKey = 0, + heroToExit = 0, + } = useScorePaths({ board, heroPos: hero.heroPos }); + + const distToKey = entranceToKey || heroToKey; + const distToExit = entranceToExit || heroToExit; + + const [finalScore, setFinalScore] = useState(null); + const optimalRef = useRef(0); + const keystrokes = keyManager ? keyManager.keyLog.length : 0; + + useScoreTime({ gameStatus, timer }); + + useEffect(() => { + const maze = board.mazeInstanceRef.current; + if (!maze) return; + + const k = maze.getDistance("entrance", "key"); + const e = maze.getDistance("key", "exit"); + const total = k + e; + + if (Number.isFinite(total) && total > 0) { + optimalRef.current = total; + } + }, [gameStatus, board.mazeInstanceRef]); + + const optimalSteps = optimalRef.current; + + const efficiency = + optimalSteps > 0 ? Math.round((keystrokes / optimalSteps) * 100) : 0; + + const [logicFirst, setLogicFirst] = useState(null); + const [logicLast, setLogicLast] = useState(null); + + useEffect(() => { + if (gameStatus === "started") { + setLogicFirst(null); + setLogicLast(null); + setPlayerSteps(0); + prevPosRef.current = null; + } + }, [gameStatus]); + + useEffect(() => { + const onTick = (e: Event) => { + const t = + (e as CustomEvent<{ t?: number }>).detail?.t ?? performance.now(); + setLogicFirst((prev) => prev ?? t); + setLogicLast(t); + }; + window.addEventListener("maze-logic-tick", onTick as EventListener); + return () => + window.removeEventListener("maze-logic-tick", onTick as EventListener); + }, []); + + const [, setPlayerSteps] = useState(0); + const prevPosRef = useRef<{ row: number; col: number } | null>(null); + + useEffect(() => { + const pos = hero.heroPos; + if (!pos) { + prevPosRef.current = null; + return; + } + + if (!["started", "has-key"].includes(gameStatus)) { + prevPosRef.current = pos; + return; + } + + const prev = prevPosRef.current; + prevPosRef.current = pos; + if (!prev) return; + + const delta = Math.abs(pos.row - prev.row) + Math.abs(pos.col - prev.col); + if (delta > 0) setPlayerSteps((s) => s + delta); + }, [gameStatus, hero.heroPos]); + + useEffect(() => { + if (gameStatus === "started" && efficiency > 150) { + setGameStatus("game-over"); + } + }, [efficiency, gameStatus, setGameStatus]); + + useEffect(() => { + if (gameStatus !== "game-won") return; + + const logicMs = + logicFirst != null && logicLast != null + ? Math.max(1, logicLast - logicFirst) + : timeValue; + + const optimalMs = optimalSteps * 200; + const ratio = Math.max(0.1, optimalMs / logicMs); + const score = Math.min(100000, Math.round(ratio * 100000)); + + setFinalScore(score); + }, [gameStatus, logicFirst, logicLast, timeValue, optimalSteps]); + + return { + timeValue, + startTimer, + stopTimer, + resetTimer, + distToKey, + distToExit, + keystrokes, + optimalSteps, + efficiency, + finalScore, + }; +} + +export type ScoreManager = ReturnType; diff --git a/src/useScore/useScorePaths.ts b/src/useScore/useScorePaths.ts new file mode 100644 index 0000000..f06243c --- /dev/null +++ b/src/useScore/useScorePaths.ts @@ -0,0 +1,44 @@ +import { useEffect, useState } from "react"; +import type { BoardManager } from "../types"; + +type UseScorePathsParams = { + board: BoardManager; + heroPos: { row: number; col: number } | null; +}; + +export function useScorePaths({ board, heroPos }: UseScorePathsParams) { + const { mazeInstanceRef } = board; + + const [distances, setDistances] = useState({ + entranceToKey: Infinity, + entranceToExit: Infinity, + heroToKey: Infinity, + heroToExit: Infinity, + }); + + useEffect(() => { + const maze = mazeInstanceRef.current; + if (!maze) return; + + setDistances((prev) => ({ + ...prev, + entranceToKey: maze.getDistance("entrance", "key"), + entranceToExit: maze.getDistance("entrance", "exit"), + })); + }, [mazeInstanceRef]); + + useEffect(() => { + const maze = mazeInstanceRef.current; + if (!maze || !heroPos) return; + + setDistances((prev) => ({ + ...prev, + heroToKey: maze.getDistance("hero", "key", heroPos), + heroToExit: maze.getDistance("hero", "exit", heroPos), + })); + }, [heroPos, mazeInstanceRef]); + + return distances; +} + +export type UseScorePathsReturn = ReturnType; diff --git a/src/useScore/useScoreTime.ts b/src/useScore/useScoreTime.ts new file mode 100644 index 0000000..0127a77 --- /dev/null +++ b/src/useScore/useScoreTime.ts @@ -0,0 +1,33 @@ +import { useEffect, useRef } from "react"; +import type { UseTimerReturn } from "./useTimer"; +import type { GameStatus } from "../types"; + +type UseScoreTimeParams = { + gameStatus: GameStatus; + timer: UseTimerReturn; +}; + +export function useScoreTime({ gameStatus, timer }: UseScoreTimeParams) { + const prevStatusRef = useRef(null); + const { startTimer, stopTimer, resetTimer } = timer; + + useEffect(() => { + prevStatusRef.current = gameStatus; + + switch (gameStatus) { + case "started": + resetTimer(); + startTimer(); + break; + + case "has-key": + break; + + case "game-over": + case "game-won": + case "waiting": + stopTimer(); + break; + } + }, [gameStatus]); +} diff --git a/src/useScore/useTimer.ts b/src/useScore/useTimer.ts new file mode 100644 index 0000000..29afc99 --- /dev/null +++ b/src/useScore/useTimer.ts @@ -0,0 +1,48 @@ +import { useState, useRef, useEffect } from "react"; + +export function useTimer() { + const [timeValue, setTimeValue] = useState(0); + const startTimeRef = useRef(null); + const frameRef = useRef(null); + const runningRef = useRef(false); + + const startTimer = () => { + if (runningRef.current) return; + runningRef.current = true; + startTimeRef.current = performance.now(); + + const tick = () => { + if (!runningRef.current) return; + const now = performance.now(); + const elapsed = now - (startTimeRef.current ?? now); + setTimeValue(elapsed); + frameRef.current = requestAnimationFrame(tick); + }; + + frameRef.current = requestAnimationFrame(tick); + }; + + const stopTimer = () => { + runningRef.current = false; + if (frameRef.current) { + cancelAnimationFrame(frameRef.current); + frameRef.current = null; + } + }; + + const resetTimer = () => { + stopTimer(); + setTimeValue(0); + startTimeRef.current = null; + }; + + useEffect(() => { + return () => { + if (frameRef.current) cancelAnimationFrame(frameRef.current); + }; + }, []); + + return { timeValue, startTimer, stopTimer, resetTimer }; +} + +export type UseTimerReturn = ReturnType;