feat: implement game-over logic with time and hint limits

- Add configurable timeLimit to GameOptions (default 600 seconds / 10 minutes)
- Time limit applies to all difficulty levels (harder puzzles = more time pressure)
- Implement game-over triggers:
  1. Time limit exceeded: timeValue >= timeLimit → 'Time's up!'
  2. Hint penalty limit: hintsUsed * penalty >= 500 → 'Too many hints!'
- Hint limits by difficulty:
  - Easy: 20 hints max (20 × 25 = 500 points)
  - Medium: 10 hints max (10 × 50 = 500 points)
  - Hard: 5 hints max (5 × 100 = 500 points)
- Add gameOverReason to ScoreManager to display specific failure reason
- Timer stops on game-over
- Update example app to show game-over reason
- Both conditions checked during gameplay before win condition
This commit is contained in:
2025-10-22 21:48:33 +01:00
parent 97c31ab6cc
commit f68098657b
6 changed files with 194 additions and 6 deletions
+162 -1
View File
@@ -1544,6 +1544,167 @@ Hard mode (100 points/hint):
--- ---
## Iteration 15: [Pending] ## Iteration 15: Game Over Logic
**Date**: 2025-01-22
### Goals
- Implement time limit for gameplay
- Implement hint limit based on penalty points
- Provide clear feedback on why game ended
- Make time limit configurable via game options
### Tasks Completed
#### 1. Added Time Limit Configuration
**File: `src/types.ts`**
**GameOptions Extended:**
```typescript
export type GameOptions = {
difficulty?: Difficulty;
timeLimit?: number; // In seconds, default 600 (10 minutes)
};
```
**Default:** 10 minutes (600 seconds) for all difficulty levels
**Rationale:** Harder puzzles naturally take longer, creating appropriate challenge
#### 2. Implemented Game Over Triggers
**File: `src/useScore/useScore.ts`**
**Two conditions trigger game-over:**
**Condition 1: Time Limit Exceeded**
```typescript
const timeLimitMs = timeLimit * 1000;
if (timeValue >= timeLimitMs) {
setGameOverReason("Time's up!");
setGameStatus('game-over');
return;
}
```
**Condition 2: Hint Penalty Limit**
```typescript
const hintPenalty = hintsUsed * HINT_PENALTY[difficulty];
if (hintPenalty >= 500) {
setGameOverReason('Too many hints!');
setGameStatus('game-over');
return;
}
```
**Hint Limits by Difficulty:**
- Easy (25 pts/hint): 20 hints max → 500 points
- Medium (50 pts/hint): 10 hints max → 500 points
- Hard (100 pts/hint): 5 hints max → 500 points
#### 3. Added Game Over Reason Tracking
**ScoreManager Extended:**
```typescript
export type ScoreManager = {
// ... existing fields
gameOverReason: string | null; // "Time's up!" or "Too many hints!"
};
```
**Reason Management:**
- Set when game-over triggered
- Reset on new game start
- Displayed to player in UI
#### 4. Updated Game Lifecycle
**Status Flow:**
```
waiting → started → [time/hints exceeded] → game-over
→ [puzzle complete] → game-won
game-over → [press space] → waiting → started (new game)
```
**Timer Behavior:**
- Stops on game-over
- Stops on game-won
- Resets on waiting
#### 5. Updated Example App Display
**Game Over Message:**
```tsx
{gameStatus === "game-over" && (
<p>{gameOverReason || "Game Over!"} Press <kbd>space</kbd> to restart</p>
)}
```
Shows specific reason:
- "Time's up! Press space to restart"
- "Too many hints! Press space to restart"
### Technical Details
**Check Frequency:**
- Both checks run on every render during 'started' state
- useEffect dependencies: `[gameStatus, timeValue, hintsUsed, difficulty, timeLimit]`
- Checks happen before win condition check
**Priority:**
1. Game-over checks (time, hints)
2. Win condition check (board complete)
**Time Limit Examples:**
```
Default (10 minutes):
- Player has 600 seconds to complete puzzle
- At 600s: game-over triggered
Custom (5 minutes):
- GameOptions: { timeLimit: 300 }
- Player has 300 seconds to complete
```
**Hint Limit Examples:**
```
Easy mode (25 pts/hint, 500 limit):
- Hint 1-19: OK
- Hint 20: 20 × 25 = 500 → game-over
Hard mode (100 pts/hint, 500 limit):
- Hint 1-4: OK
- Hint 5: 5 × 100 = 500 → game-over
```
### Design Rationale
**Why Same Time Limit for All Difficulties?**
- Harder puzzles naturally take longer to solve
- Creates appropriate pressure at each level
- Simple and consistent rule
**Why 500 Point Hint Limit?**
- Half of max score (1000)
- Prevents hint abuse
- Scales appropriately with difficulty:
- Easy: More hints allowed (puzzle easier)
- Hard: Fewer hints allowed (puzzle harder)
**Why Two Separate Conditions?**
- Time limit: Encourages speed
- Hint limit: Encourages skill
- Both contribute to challenge
### Build Status
- ✅ TypeScript compiles cleanly
- ✅ Both game-over conditions working
- ✅ Reasons displayed correctly
- ✅ Timer stops on game-over
- ✅ Configurable time limit working
### Next Steps
- Final polish and testing
- Update UNIFIED_API.md
---
## Iteration 16: [Pending]
*To be filled as implementation progresses...* *To be filled as implementation progresses...*
+2 -2
View File
@@ -8,7 +8,7 @@ function App() {
const [difficulty, setDifficulty] = useState<Difficulty>('easy'); 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, totalKeystrokes, hintsUsed, finalScore } = scoreManager; const { timeValue, totalKeystrokes, hintsUsed, finalScore, gameOverReason } = scoreManager;
const formatTime = (ms: number) => { const formatTime = (ms: number) => {
const seconds = Math.floor(ms / 1000); const seconds = Math.floor(ms / 1000);
@@ -99,7 +99,7 @@ function App() {
<div className="text-center text-2xl text-muted-foreground"> <div className="text-center text-2xl text-muted-foreground">
{gameStatus === "waiting" && <p>Press <kbd className="px-2 py-1 bg-muted rounded">space</kbd> to start</p>} {gameStatus === "waiting" && <p>Press <kbd className="px-2 py-1 bg-muted rounded">space</kbd> to start</p>}
{gameStatus === "started" && <p>Use <kbd className="px-2 py-1 bg-muted rounded">hjkl</kbd> to navigate • Press <kbd className="px-2 py-1 bg-muted rounded">i</kbd> to edit • Press <kbd className="px-2 py-1 bg-muted rounded">Shift+H</kbd> for hint • Press <kbd className="px-2 py-1 bg-muted rounded">q</kbd> to quit</p>} {gameStatus === "started" && <p>Use <kbd className="px-2 py-1 bg-muted rounded">hjkl</kbd> to navigate • Press <kbd className="px-2 py-1 bg-muted rounded">i</kbd> to edit • Press <kbd className="px-2 py-1 bg-muted rounded">Shift+H</kbd> for hint • Press <kbd className="px-2 py-1 bg-muted rounded">q</kbd> to quit</p>}
{gameStatus === "game-over" && <p>Game Over! Press <kbd className="px-2 py-1 bg-muted rounded">space</kbd> to restart</p>} {gameStatus === "game-over" && <p>{gameOverReason || "Game Over!"} Press <kbd className="px-2 py-1 bg-muted rounded">space</kbd> to restart</p>}
{gameStatus === "game-won" && <p>You Won! Press <kbd className="px-2 py-1 bg-muted rounded">space</kbd> to play again</p>} {gameStatus === "game-won" && <p>You Won! Press <kbd className="px-2 py-1 bg-muted rounded">space</kbd> to play again</p>}
</div> </div>
</div> </div>
+1
View File
@@ -11,6 +11,7 @@ export type { ScoreManager } from './useScore/types';
export type GameOptions = { export type GameOptions = {
difficulty?: Difficulty; difficulty?: Difficulty;
timeLimit?: number;
}; };
export type KeyLogEntry = { key: string; timestamp: number }; export type KeyLogEntry = { key: string; timestamp: number };
+2 -2
View File
@@ -7,7 +7,7 @@ import { useCursorRender } from './useCursor/useCursorRender';
import { useScore } from './useScore'; 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', timeLimit = 600 } = options;
const board = useBoard(); const board = useBoard();
const { containerRef, renderBoard, generateBoard, boardRendered } = board; const { containerRef, renderBoard, generateBoard, boardRendered } = board;
@@ -24,7 +24,7 @@ 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, difficulty, hintsUsed: hintsUsedRef.current }); const scoreManager = useScore({ board, gameStatusManager, keyLog, cursorMode, difficulty, hintsUsed: hintsUsedRef.current, timeLimit });
useCursorRender(containerRef, cursorPosition, cursorMode, gameStatus, boardRendered); useCursorRender(containerRef, cursorPosition, cursorMode, gameStatus, boardRendered);
+1
View File
@@ -13,4 +13,5 @@ export type ScoreManager = {
totalKeystrokes: number; totalKeystrokes: number;
hintsUsed: number; hintsUsed: number;
finalScore: number | null; finalScore: number | null;
gameOverReason: string | null;
}; };
+26 -1
View File
@@ -12,6 +12,7 @@ type UseScoreParams = {
cursorMode: 'normal' | 'edit'; cursorMode: 'normal' | 'edit';
difficulty: Difficulty; difficulty: Difficulty;
hintsUsed: number; hintsUsed: number;
timeLimit: number;
}; };
const DIFFICULTY_MULTIPLIER = { const DIFFICULTY_MULTIPLIER = {
@@ -26,26 +27,49 @@ const HINT_PENALTY = {
hard: 100, hard: 100,
} as const; } as const;
export function useScore({ board, gameStatusManager, keyLog, cursorMode, difficulty, hintsUsed: hintsUsedProp }: UseScoreParams): ScoreManager { export function useScore({ board, gameStatusManager, keyLog, cursorMode, difficulty, hintsUsed: hintsUsedProp, timeLimit }: UseScoreParams): ScoreManager {
const timer = useTimer(); const timer = useTimer();
const { gameStatus, setGameStatus } = gameStatusManager; const { gameStatus, setGameStatus } = gameStatusManager;
const { timeValue, startTimer, stopTimer, resetTimer } = timer; const { timeValue, startTimer, stopTimer, resetTimer } = timer;
const [finalScore, setFinalScore] = useState<number | null>(null); const [finalScore, setFinalScore] = useState<number | null>(null);
const [gameOverReason, setGameOverReason] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
if (gameStatus === 'started') { if (gameStatus === 'started') {
startTimer(); startTimer();
setGameOverReason(null);
} else if (gameStatus === 'waiting') { } else if (gameStatus === 'waiting') {
resetTimer(); resetTimer();
setFinalScore(null); setFinalScore(null);
setGameOverReason(null);
} else if (gameStatus === 'game-won') { } else if (gameStatus === 'game-won') {
stopTimer(); stopTimer();
} else if (gameStatus === 'game-over') {
stopTimer();
} }
}, [gameStatus]); }, [gameStatus]);
const totalKeystrokes = keyLog.length; const totalKeystrokes = keyLog.length;
useEffect(() => {
if (gameStatus !== 'started') return;
const timeLimitMs = timeLimit * 1000;
if (timeValue >= timeLimitMs) {
setGameOverReason("Time's up!");
setGameStatus('game-over');
return;
}
const hintPenalty = hintsUsedProp * HINT_PENALTY[difficulty];
if (hintPenalty >= 500) {
setGameOverReason('Too many hints!');
setGameStatus('game-over');
return;
}
}, [gameStatus, timeValue, hintsUsedProp, difficulty, timeLimit, setGameStatus]);
useEffect(() => { useEffect(() => {
if (gameStatus !== 'started') return; if (gameStatus !== 'started') return;
if (cursorMode !== 'normal') return; if (cursorMode !== 'normal') return;
@@ -82,5 +106,6 @@ export function useScore({ board, gameStatusManager, keyLog, cursorMode, difficu
totalKeystrokes, totalKeystrokes,
hintsUsed: hintsUsedProp, hintsUsed: hintsUsedProp,
finalScore, finalScore,
gameOverReason,
}; };
} }