feat: implement scoring system and win condition (Phase 50)
- Add useTimer hook for elapsed time tracking - Add useScore hook with comprehensive metrics: - Total keystrokes (all keys) - Edit keystrokes (only editing keys) - Optimal keystrokes (empty cells × 3) - Efficiency percentage (optimal/edit × 100) - Final score (70% efficiency + 30% time-based) - Win condition detects board completion only in normal mode - Track all keystrokes with timestamps in useGame - Update example app with full scoreboard display - Expose boardRef in BoardManager for state checking - Adjust easy mode to 5 removed cells for testing
This commit is contained in:
+142
-4
@@ -982,7 +982,7 @@ Back in normal mode (blue cursor)
|
|||||||
- ✅ All validation rules working correctly
|
- ✅ All validation rules working correctly
|
||||||
|
|
||||||
### Next Steps
|
### Next Steps
|
||||||
- Validation and conflict detection
|
|
||||||
- Win condition checking
|
- Win condition checking
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -1086,11 +1086,149 @@ Cell loses .user-entered, can use 'i' again
|
|||||||
- ✅ All erase commands working correctly
|
- ✅ All erase commands working correctly
|
||||||
|
|
||||||
### Next Steps
|
### Next Steps
|
||||||
- Validation and conflict detection
|
|
||||||
- Win condition checking
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Iteration 12: [Pending]
|
## Iteration 12: Phase 50 - Scoring System and Win Condition
|
||||||
|
|
||||||
|
**Date**: 2025-01-22
|
||||||
|
|
||||||
|
### Goals
|
||||||
|
- Implement complete scoring system based on vim-maze patterns
|
||||||
|
- Track time, keystrokes, and efficiency
|
||||||
|
- Detect win condition when puzzle is complete
|
||||||
|
- Display all metrics in example app
|
||||||
|
|
||||||
|
### Tasks Completed
|
||||||
|
|
||||||
|
#### 1. Created useTimer Hook
|
||||||
|
**File: `src/useScore/useTimer.ts`**
|
||||||
|
- Tracks elapsed time using `requestAnimationFrame`
|
||||||
|
- Methods: `startTimer()`, `stopTimer()`, `resetTimer()`
|
||||||
|
- Returns `timeValue` in milliseconds
|
||||||
|
- Direct copy from vim-maze (works perfectly as-is)
|
||||||
|
|
||||||
|
#### 2. Created useScore Hook
|
||||||
|
**File: `src/useScore/useScore.ts`**
|
||||||
|
|
||||||
|
**Metrics Tracked:**
|
||||||
|
- **Total keystrokes**: All keys pressed during gameplay
|
||||||
|
- **Edit keystrokes**: Only editing keys (i, r, c, x, d, Delete, Escape, Backspace, 1-9)
|
||||||
|
- **Optimal keystrokes**: Empty cells × 3 (i + digit + Escape minimum)
|
||||||
|
- **Efficiency**: `(optimal / edit) × 100` - 100% = perfect, no mistakes
|
||||||
|
- **Final score**: Weighted combination of efficiency (70%) and time (30%)
|
||||||
|
|
||||||
|
**Key Logic:**
|
||||||
|
```typescript
|
||||||
|
const EDIT_KEYS = ['i', 'r', 'c', 'x', 'd', 'Delete', 'Escape', 'Backspace', '1', '2', '3', '4', '5', '6', '7', '8', '9'];
|
||||||
|
|
||||||
|
const totalKeystrokes = keyLog.length;
|
||||||
|
const editKeystrokes = keyLog.filter(entry => EDIT_KEYS.includes(entry.key)).length;
|
||||||
|
const optimalKeystrokes = emptyCells * 3;
|
||||||
|
const efficiency = (optimalKeystrokes / editKeystrokes) * 100;
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. Win Condition Detection
|
||||||
|
**Trigger:** Board has no empty cells (no '.' characters) AND cursor is in normal mode
|
||||||
|
|
||||||
|
**Why check normal mode?**
|
||||||
|
- Prevents premature win detection while still typing
|
||||||
|
- User must exit edit mode (Escape or auto-exit from r/c)
|
||||||
|
- Gives clean transition to game-won state
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
```typescript
|
||||||
|
useEffect(() => {
|
||||||
|
if (gameStatus !== 'started') return;
|
||||||
|
if (cursorMode !== 'normal') return; // Wait until edit mode exits
|
||||||
|
|
||||||
|
const boardStr = board.boardRef.current;
|
||||||
|
const hasEmptyCells = boardStr.includes('.');
|
||||||
|
|
||||||
|
if (!hasEmptyCells) {
|
||||||
|
setGameStatus('game-won');
|
||||||
|
}
|
||||||
|
}, [board.boardRendered, gameStatus, cursorMode]);
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4. Keystroke Tracking in useGame
|
||||||
|
**Implementation:**
|
||||||
|
- Add keyboard listener during 'started' state
|
||||||
|
- Log all keystrokes with timestamp
|
||||||
|
- Clear log when game starts
|
||||||
|
- Pass to useScore for metrics calculation
|
||||||
|
|
||||||
|
#### 5. Updated Example App Scoreboard
|
||||||
|
**Displays:**
|
||||||
|
- Time (formatted as mm:ss)
|
||||||
|
- Total Keys (all keystrokes)
|
||||||
|
- Edit Keys (shown as "current / optimal")
|
||||||
|
- Efficiency (percentage)
|
||||||
|
- Final Score (shown only when game-won)
|
||||||
|
|
||||||
|
**Layout:** Clean, monospace display with dividers between sections
|
||||||
|
|
||||||
|
#### 6. Adjusted Difficulty for Testing
|
||||||
|
- Easy mode: 5 removed cells (was 40, then 10, now 5)
|
||||||
|
- Default difficulty: Easy (for faster testing)
|
||||||
|
|
||||||
|
### Technical Details
|
||||||
|
|
||||||
|
**Score Calculation:**
|
||||||
|
```typescript
|
||||||
|
const efficiencyScore = Math.max(0, Math.min(100, efficiency));
|
||||||
|
const timeScore = Math.max(0, 100 - (timeMs / 1000 / 60) * 10);
|
||||||
|
const finalScore = Math.round((efficiencyScore * 0.7 + timeScore * 0.3) * 1000);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Efficiency Interpretation:**
|
||||||
|
- 100% = Perfect play (only optimal keystrokes used)
|
||||||
|
- 90% = Excellent (few corrections)
|
||||||
|
- 70% = Good (some mistakes)
|
||||||
|
- <50% = Many corrections/mistakes
|
||||||
|
|
||||||
|
**Timer Lifecycle:**
|
||||||
|
```
|
||||||
|
waiting → [start game] → started (timer starts)
|
||||||
|
↓
|
||||||
|
started → [board complete + normal mode] → game-won (timer stops)
|
||||||
|
↓
|
||||||
|
game-won → [quit] → waiting (timer resets)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Types Added
|
||||||
|
|
||||||
|
**ScoreManager Type:**
|
||||||
|
```typescript
|
||||||
|
export type ScoreManager = {
|
||||||
|
timeValue: number;
|
||||||
|
startTimer: () => void;
|
||||||
|
stopTimer: () => void;
|
||||||
|
resetTimer: () => void;
|
||||||
|
totalKeystrokes: number;
|
||||||
|
editKeystrokes: number;
|
||||||
|
optimalKeystrokes: number;
|
||||||
|
efficiency: number;
|
||||||
|
finalScore: number | null;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**BoardManager Updates:**
|
||||||
|
- Added `boardRef: RefObject<string>` to expose board state
|
||||||
|
- Cleaned up return object (removed internal refs)
|
||||||
|
|
||||||
|
### Build Status
|
||||||
|
- ✅ TypeScript compiles cleanly
|
||||||
|
- ✅ All metrics calculating correctly
|
||||||
|
- ✅ Win condition triggers only when exiting edit mode
|
||||||
|
- ✅ Scoreboard displays all values properly
|
||||||
|
|
||||||
|
### Next Steps
|
||||||
|
- Polish and final testing
|
||||||
|
- Documentation updates
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Iteration 13: [Pending]
|
||||||
|
|
||||||
*To be filled as implementation progresses...*
|
*To be filled as implementation progresses...*
|
||||||
|
|||||||
+15
-6
@@ -5,10 +5,10 @@ import type { Difficulty } from "../../src/types";
|
|||||||
import '../../src/game.css';
|
import '../../src/game.css';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [difficulty, setDifficulty] = useState<Difficulty>('medium');
|
const [difficulty, setDifficulty] = useState<Difficulty>('easy');
|
||||||
const gameManager = useGame({ difficulty }, useKeyBindings);
|
const gameManager = useGame({ difficulty }, useKeyBindings);
|
||||||
const { containerRef, gameStatus, scoreManager } = gameManager;
|
const { containerRef, gameStatus, scoreManager } = gameManager;
|
||||||
const { timeValue, keystrokes, finalScore } = scoreManager;
|
const { timeValue, totalKeystrokes, editKeystrokes, optimalKeystrokes, efficiency, finalScore } = scoreManager;
|
||||||
|
|
||||||
const formatTime = (ms: number) => {
|
const formatTime = (ms: number) => {
|
||||||
const seconds = Math.floor(ms / 1000);
|
const seconds = Math.floor(ms / 1000);
|
||||||
@@ -66,16 +66,25 @@ function App() {
|
|||||||
|
|
||||||
<div className="mx-auto my-4 w-fit bg-muted rounded-lg p-4">
|
<div className="mx-auto my-4 w-fit bg-muted rounded-lg p-4">
|
||||||
{(gameStatus === "started" || gameStatus === "game-won") && (
|
{(gameStatus === "started" || gameStatus === "game-won") && (
|
||||||
<div className="flex gap-8 text-sm font-mono items-center">
|
<div className="flex gap-6 text-sm font-mono items-center">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-muted-foreground">Time</div>
|
<div className="text-muted-foreground">Time</div>
|
||||||
<div className="text-lg font-bold">{formatTime(timeValue)}</div>
|
<div className="text-lg font-bold">{formatTime(timeValue)}</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="w-px h-12 bg-muted-foreground/20" />
|
||||||
<div>
|
<div>
|
||||||
<div className="text-muted-foreground">Keystrokes</div>
|
<div className="text-muted-foreground">Total Keys</div>
|
||||||
<div className="text-lg font-bold">{keystrokes}</div>
|
<div className="text-lg font-bold">{totalKeystrokes}</div>
|
||||||
</div>
|
</div>
|
||||||
{gameStatus === "game-won" && finalScore && (
|
<div>
|
||||||
|
<div className="text-muted-foreground">Edit Keys</div>
|
||||||
|
<div className="text-lg font-bold">{editKeystrokes} / {optimalKeystrokes}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-muted-foreground">Efficiency</div>
|
||||||
|
<div className="text-lg font-bold">{efficiency}%</div>
|
||||||
|
</div>
|
||||||
|
{gameStatus === "game-won" && finalScore !== null && (
|
||||||
<>
|
<>
|
||||||
<div className="w-px h-12 bg-muted-foreground/20" />
|
<div className="w-px h-12 bg-muted-foreground/20" />
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
|
|||||||
+3
-5
@@ -1,11 +1,13 @@
|
|||||||
import type { RefObject } from 'react';
|
import type { RefObject } from 'react';
|
||||||
import type { GameStatus, GameStatusManager } from './useGameStatus/types';
|
import type { GameStatus, GameStatusManager } from './useGameStatus/types';
|
||||||
import type { CursorManager } from './useCursor/types';
|
import type { CursorManager } from './useCursor/types';
|
||||||
|
import type { ScoreManager } from './useScore/types';
|
||||||
|
|
||||||
export type Difficulty = 'easy' | 'medium' | 'hard';
|
export type Difficulty = 'easy' | 'medium' | 'hard';
|
||||||
|
|
||||||
export type { GameStatus, GameStatusManager };
|
export type { GameStatus, GameStatusManager };
|
||||||
export type { CursorManager, Coord } from './useCursor/types';
|
export type { CursorManager, Coord } from './useCursor/types';
|
||||||
|
export type { ScoreManager } from './useScore/types';
|
||||||
|
|
||||||
export type GameOptions = {
|
export type GameOptions = {
|
||||||
difficulty?: Difficulty;
|
difficulty?: Difficulty;
|
||||||
@@ -17,11 +19,7 @@ export type GameManager = {
|
|||||||
containerRef: RefObject<HTMLDivElement | null>;
|
containerRef: RefObject<HTMLDivElement | null>;
|
||||||
renderBoard: () => void;
|
renderBoard: () => void;
|
||||||
cursor: CursorManager;
|
cursor: CursorManager;
|
||||||
scoreManager: {
|
scoreManager: ScoreManager;
|
||||||
timeValue: number;
|
|
||||||
keystrokes: number;
|
|
||||||
finalScore: number | null;
|
|
||||||
};
|
|
||||||
keyLog: KeyLogEntry[];
|
keyLog: KeyLogEntry[];
|
||||||
clearKeyLog: () => void;
|
clearKeyLog: () => void;
|
||||||
getKeyLog: () => KeyLogEntry[];
|
getKeyLog: () => KeyLogEntry[];
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
export type Difficulty = 'easy' | 'medium' | 'hard';
|
export type Difficulty = 'easy' | 'medium' | 'hard';
|
||||||
|
|
||||||
const CELLS_TO_REMOVE = {
|
const CELLS_TO_REMOVE = {
|
||||||
easy: 40,
|
easy: 5,
|
||||||
medium: 50,
|
medium: 50,
|
||||||
hard: 60,
|
hard: 60,
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { Difficulty } from '../types';
|
|||||||
|
|
||||||
export type BoardManager = {
|
export type BoardManager = {
|
||||||
containerRef: RefObject<HTMLDivElement | null>;
|
containerRef: RefObject<HTMLDivElement | null>;
|
||||||
|
boardRef: RefObject<string>;
|
||||||
boardRendered: number;
|
boardRendered: number;
|
||||||
generateBoard: (difficulty: Difficulty) => void;
|
generateBoard: (difficulty: Difficulty) => void;
|
||||||
renderBoard: () => void;
|
renderBoard: () => void;
|
||||||
|
|||||||
@@ -56,11 +56,7 @@ export function useBoard() {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
containerRef,
|
containerRef,
|
||||||
generatorRef,
|
|
||||||
rendererRef,
|
|
||||||
boardRef,
|
boardRef,
|
||||||
solutionRef,
|
|
||||||
initialBoardRef,
|
|
||||||
boardRendered,
|
boardRendered,
|
||||||
generateBoard,
|
generateBoard,
|
||||||
renderBoard,
|
renderBoard,
|
||||||
|
|||||||
+21
-5
@@ -4,6 +4,7 @@ import { useBoard } from './useBoard/useBoard';
|
|||||||
import { useGameStatus } from './useGameStatus';
|
import { useGameStatus } from './useGameStatus';
|
||||||
import { useCursor } from './useCursor';
|
import { useCursor } from './useCursor';
|
||||||
import { useCursorRender } from './useCursor/useCursorRender';
|
import { useCursorRender } from './useCursor/useCursorRender';
|
||||||
|
import { useScore } from './useScore';
|
||||||
|
|
||||||
export function useGame(options: GameOptions = {}, platformHook?: unknown): GameManager {
|
export function useGame(options: GameOptions = {}, platformHook?: unknown): GameManager {
|
||||||
const { difficulty = 'easy' } = options;
|
const { difficulty = 'easy' } = options;
|
||||||
@@ -19,8 +20,27 @@ export function useGame(options: GameOptions = {}, platformHook?: unknown): Game
|
|||||||
const cursorPosition = cursor.position();
|
const cursorPosition = cursor.position();
|
||||||
const cursorMode = cursor.mode();
|
const cursorMode = cursor.mode();
|
||||||
|
|
||||||
|
const scoreManager = useScore({ board, gameStatusManager, keyLog, cursorMode });
|
||||||
|
|
||||||
useCursorRender(containerRef, cursorPosition, cursorMode, gameStatus, boardRendered);
|
useCursorRender(containerRef, cursorPosition, cursorMode, gameStatus, boardRendered);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (gameStatus !== 'started') return;
|
||||||
|
|
||||||
|
function handleKeyDown(e: KeyboardEvent) {
|
||||||
|
setKeyLog(prev => [...prev, { key: e.key, timestamp: Date.now() }]);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [gameStatus]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (gameStatus === 'started') {
|
||||||
|
setKeyLog([]);
|
||||||
|
}
|
||||||
|
}, [gameStatus]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
renderBoard();
|
renderBoard();
|
||||||
}, []);
|
}, []);
|
||||||
@@ -39,11 +59,7 @@ export function useGame(options: GameOptions = {}, platformHook?: unknown): Game
|
|||||||
renderBoard,
|
renderBoard,
|
||||||
...gameStatusManager,
|
...gameStatusManager,
|
||||||
cursor,
|
cursor,
|
||||||
scoreManager: {
|
scoreManager,
|
||||||
timeValue: 0,
|
|
||||||
keystrokes: 0,
|
|
||||||
finalScore: null,
|
|
||||||
},
|
|
||||||
keyLog,
|
keyLog,
|
||||||
clearKeyLog: () => setKeyLog([]),
|
clearKeyLog: () => setKeyLog([]),
|
||||||
getKeyLog: () => [...keyLog],
|
getKeyLog: () => [...keyLog],
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export { useScore } from './useScore';
|
||||||
|
export { useTimer } from './useTimer';
|
||||||
|
export type { ScoreManager, TimerManager } from './types';
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
export type TimerManager = {
|
||||||
|
timeValue: number;
|
||||||
|
startTimer: () => void;
|
||||||
|
stopTimer: () => void;
|
||||||
|
resetTimer: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ScoreManager = {
|
||||||
|
timeValue: number;
|
||||||
|
startTimer: () => void;
|
||||||
|
stopTimer: () => void;
|
||||||
|
resetTimer: () => void;
|
||||||
|
totalKeystrokes: number;
|
||||||
|
editKeystrokes: number;
|
||||||
|
optimalKeystrokes: number;
|
||||||
|
efficiency: number;
|
||||||
|
finalScore: number | null;
|
||||||
|
};
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { useTimer } from './useTimer';
|
||||||
|
import type { BoardManager } from '../useBoard/types';
|
||||||
|
import type { GameStatusManager } from '../useGameStatus/types';
|
||||||
|
import type { ScoreManager } from './types';
|
||||||
|
|
||||||
|
type UseScoreParams = {
|
||||||
|
board: BoardManager;
|
||||||
|
gameStatusManager: GameStatusManager;
|
||||||
|
keyLog: { key: string; timestamp: number }[];
|
||||||
|
cursorMode: 'normal' | 'edit';
|
||||||
|
};
|
||||||
|
|
||||||
|
const EDIT_KEYS = ['i', 'r', 'c', 'x', 'd', 'Delete', 'Escape', 'Backspace', '1', '2', '3', '4', '5', '6', '7', '8', '9'];
|
||||||
|
|
||||||
|
export function useScore({ board, gameStatusManager, keyLog, cursorMode }: UseScoreParams): ScoreManager {
|
||||||
|
const timer = useTimer();
|
||||||
|
const { gameStatus, setGameStatus } = gameStatusManager;
|
||||||
|
const { timeValue, startTimer, stopTimer, resetTimer } = timer;
|
||||||
|
|
||||||
|
const [finalScore, setFinalScore] = useState<number | null>(null);
|
||||||
|
const optimalRef = useRef<number>(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (gameStatus === 'started') {
|
||||||
|
startTimer();
|
||||||
|
} else if (gameStatus === 'waiting') {
|
||||||
|
resetTimer();
|
||||||
|
setFinalScore(null);
|
||||||
|
} else if (gameStatus === 'game-won') {
|
||||||
|
stopTimer();
|
||||||
|
}
|
||||||
|
}, [gameStatus]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (gameStatus !== 'started') return;
|
||||||
|
|
||||||
|
const boardStr = board.boardRef.current;
|
||||||
|
if (!boardStr) return;
|
||||||
|
|
||||||
|
const emptyCells = (boardStr.match(/\./g) || []).length;
|
||||||
|
optimalRef.current = emptyCells * 3;
|
||||||
|
}, [gameStatus, board.boardRef]);
|
||||||
|
|
||||||
|
const totalKeystrokes = keyLog.length;
|
||||||
|
const editKeystrokes = keyLog.filter(entry => EDIT_KEYS.includes(entry.key)).length;
|
||||||
|
const optimalKeystrokes = optimalRef.current;
|
||||||
|
|
||||||
|
const efficiency = optimalKeystrokes > 0
|
||||||
|
? Math.round((optimalKeystrokes / editKeystrokes) * 100)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (gameStatus !== 'started') return;
|
||||||
|
if (cursorMode !== 'normal') return;
|
||||||
|
|
||||||
|
const boardStr = board.boardRef.current;
|
||||||
|
if (!boardStr) return;
|
||||||
|
|
||||||
|
const hasEmptyCells = boardStr.includes('.');
|
||||||
|
if (!hasEmptyCells) {
|
||||||
|
setGameStatus('game-won');
|
||||||
|
}
|
||||||
|
}, [board.boardRendered, gameStatus, cursorMode, board.boardRef, setGameStatus]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (gameStatus !== 'game-won') return;
|
||||||
|
|
||||||
|
const timeMs = timeValue;
|
||||||
|
const efficiencyScore = Math.max(0, Math.min(100, efficiency));
|
||||||
|
const timeScore = Math.max(0, 100 - (timeMs / 1000 / 60) * 10);
|
||||||
|
const score = Math.round((efficiencyScore * 0.7 + timeScore * 0.3) * 1000);
|
||||||
|
|
||||||
|
setFinalScore(score);
|
||||||
|
}, [gameStatus, timeValue, efficiency]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
timeValue,
|
||||||
|
startTimer,
|
||||||
|
stopTimer,
|
||||||
|
resetTimer,
|
||||||
|
totalKeystrokes,
|
||||||
|
editKeystrokes,
|
||||||
|
optimalKeystrokes,
|
||||||
|
efficiency,
|
||||||
|
finalScore,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { useState, useRef, useEffect } from 'react';
|
||||||
|
import type { TimerManager } from './types';
|
||||||
|
|
||||||
|
export function useTimer(): TimerManager {
|
||||||
|
const [timeValue, setTimeValue] = useState(0);
|
||||||
|
const startTimeRef = useRef<number | null>(null);
|
||||||
|
const frameRef = useRef<number | null>(null);
|
||||||
|
const runningRef = useRef(false);
|
||||||
|
|
||||||
|
function startTimer() {
|
||||||
|
if (runningRef.current) return;
|
||||||
|
runningRef.current = true;
|
||||||
|
startTimeRef.current = performance.now();
|
||||||
|
|
||||||
|
function 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopTimer() {
|
||||||
|
runningRef.current = false;
|
||||||
|
if (frameRef.current) {
|
||||||
|
cancelAnimationFrame(frameRef.current);
|
||||||
|
frameRef.current = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetTimer() {
|
||||||
|
stopTimer();
|
||||||
|
setTimeValue(0);
|
||||||
|
startTimeRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (frameRef.current) cancelAnimationFrame(frameRef.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { timeValue, startTimer, stopTimer, resetTimer };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user