Compare commits

..
10 Commits
Author SHA1 Message Date
pastilhas 76523e293e 2.3.1 2025-11-02 17:43:10 +00:00
pastilhas 9968c924b9 feat: add version to gameInfo export
- gameInfo now includes version from package.json
- Platform can access game version via gameInfo.version
- Useful for version checking and compatibility validation
2025-11-02 17:42:47 +00:00
pastilhas 6ad2119b62 2.3.0 2025-11-02 17:41:13 +00:00
pastilhas 7b0b8ee0aa feat: add runtime gameInfo enrichment with gameOver condition
- Add gameOverCondition to GameManager for runtime tracking
- Create enrichGameInfo() function to merge runtime condition with static gameInfo
- Add formatGameOverCondition() utility to stringify boolean/function conditions
- Returns enriched info with:
  - condition: formatted string representation
  - type: 'none' | 'static' | 'function'
- Platform can call enrichGameInfo(gameManager) to get complete game configuration
- Handles all condition types: undefined, boolean, function
- Truncates long function strings at 200 chars
2025-11-02 17:20:21 +00:00
pastilhas afce474e0d Revert "feat: add intermediate efficiency calculation for real-time feedback"
This reverts commit 7352eff726.
2025-11-02 17:16:10 +00:00
pastilhas 7352eff726 feat: add intermediate efficiency calculation for real-time feedback
- While keystrokes ≤ optimalSteps: show progress (keystrokes/optimal * 100)
  - Players see efficiency climbing from 0→100% as they play well
  - Provides real-time feedback during navigation
- After keystrokes > optimalSteps: show degradation (optimal/keystrokes * 100)
  - Efficiency smoothly decreases below 100%
- Examples with 100 optimal steps:
  - At 25 keystrokes: 25% (progress)
  - At 50 keystrokes: 50% (halfway)
  - At 100 keystrokes: 100% (optimal reached!)
  - At 120 keystrokes: 83% (slightly over)
  - At 200 keystrokes: 50% (double the optimal)
2025-11-02 17:15:38 +00:00
pastilhas c1f578be42 fix: correct efficiency calculation direction
- Efficiency now starts at 100% (perfect play) and decreases toward 0% (bad play)
- Formula: (optimalSteps / keystrokes) * 100
- Clamped to 0-100 range
- Examples:
  - 100 keystrokes / 100 optimal = 100% (perfect)
  - 120 keystrokes / 100 optimal = 83% (good)
  - 200 keystrokes / 100 optimal = 50% (fair)
  - 500 keystrokes / 100 optimal = 20% (poor)
- Default 100% when no keystrokes yet
2025-11-02 17:13:44 +00:00
pastilhas d4eb926783 chore: remove debug console.log from useTimer 2025-11-02 17:10:19 +00:00
pastilhas b962ebf214 docs: add UNIFIED_API update recommendations
- Provide detailed before/after examples for all sections
- Document new gameOver pattern with flexible function-based conditions
- Explain migration path for games updating to this pattern
- Highlight benefits: pure data collection, separation of concerns, flexibility
- Guide for updating root UNIFIED_API.md to reflect vim-maze v2.3 changes
2025-11-02 17:09:21 +00:00
pastilhas 897cb5de13 docs: update gameInfo.ts to reflect scoring refactor
- Update gameOver section: removed hardcoded time limit, now uses custom gameOver condition
- Add examples of gameOver usage: time-based, efficiency-based, combined
- Update metrics: add optimalSteps and efficiency to tracked metrics
- Add note: ScoreManager provides metrics for flexible game-over/scoring logic
- Update objective: reflect custom game-over condition requirement
2025-11-02 17:08:54 +00:00
11 changed files with 458 additions and 104 deletions
+268
View File
@@ -0,0 +1,268 @@
# UNIFIED_API.md Update Recommendations
This document outlines the changes needed in `/vimazing/games/UNIFIED_API.md` to reflect the scoring refactor implemented in vim-maze v2.3.0.
## Summary of Changes
Vim-maze has implemented a new pattern for score management:
- **ScoreManager** is now a pure data collector (no score calculation)
- **Score calculation** moved to consumer (via utility function or custom logic)
- **Game-over conditions** are fully flexible and consumer-controlled
## Required Updates
### 1. Example Entry Point (Line ~100)
**OLD:**
```typescript
// vim-maze
useGame({ rows: 24, cols: 32, timeLimit: 600 })
```
**NEW:**
```typescript
// vim-maze (with custom game-over condition)
useGame({
rows: 24,
cols: 32,
gameOver: (sm) => sm.timeValue >= 600000 // 10 minutes in milliseconds
})
```
### 2. Score Manager Type (Line ~189-201)
**OLD:**
```typescript
type ScoreManager = {
// Required Timing
timeValue: number; // Current time in milliseconds
// Required Metrics
totalKeystrokes: number; // Total keys pressed
// Required Result
finalScore: number | null; // 0-1000 (null until game-won)
// Optional: Game-specific metrics
// [additional fields as needed]
};
```
**NEW:**
```typescript
type ScoreManager = {
// Required Timing
timeValue: number; // Current time in milliseconds
// Required Metrics
totalKeystrokes: number; // Total keys pressed
// Optional: Game-specific metrics
// [additional fields as needed]
// NOTE: finalScore removed - calculation moved to consumer
};
```
**Add note:**
```
**Important:** ScoreManager is now a pure data collector.
Score calculation has been moved to the consumer.
Games should provide a utility function like `calculateScore()`
or instructions for score calculation in their documentation.
```
### 3. GameOptions Examples (Line ~222-230)
**OLD:**
```typescript
// vim-maze
type GameOptions = {
rows: number;
cols: number;
timeLimit?: number;
};
```
**NEW:**
```typescript
// vim-maze
type GameOptions = {
rows: number;
cols: number;
gameOver?: boolean | ((scoreManager: ScoreManager) => boolean);
};
```
### 4. Scoring Implementation Section (Line ~257-265)
**OLD:**
```typescript
**vim-maze approach:**
```typescript
// Size-based: time + keystrokes × maze size
const baseScore = 1000 - timePenalty - keystrokePenalty;
const sizeMultiplier = max(1.0, (rows × cols) / 500);
const finalScore = min(1000, baseScore × sizeMultiplier);
```
```
**NEW:**
```typescript
**vim-maze approach (v2.3+):**
```typescript
// Pure data collection in ScoreManager
const scoreManager = useScore({ /* dependencies */ });
// scoreManager returns: { timeValue, keystrokes, optimalSteps, efficiency, ... }
// Score calculation moved to consumer
import { calculateMazeScore } from '@vimazing/vim-maze';
const finalScore = calculateMazeScore(scoreManager, mazeSize);
// Base: 1000 - (time/10) - (keystrokes/2)
// Then: multiply by size factor = max(1.0, (rows × cols) / 500)
// Final: clamp to 0-1000
```
```
### 5. Scoring Requirements Section (Line ~203-208)
**OLD:**
```
**Scoring Requirements:**
- Score range: 0-1000 (capped)
- Score calculated on game-won
- Based on time and keystrokes (minimum)
- Can include game-specific penalties/bonuses
```
**NEW:**
```
**Scoring Requirements:**
- Score range: 0-1000 (capped)
- Based on time and keystrokes (minimum)
- Can include game-specific penalties/bonuses
- **NEW:** Score calculation moved to consumer via utility function
- Games should provide `calculateScore()` or similar utility
- Games should document their scoring formula clearly
```
### 6. Game-Over Conditions Pattern (New Section)
Add a new subsection after "Scoring Requirements":
```markdown
### Game-Over Conditions
Starting with vim-maze v2.3+, games have full control over game-over conditions:
```typescript
type GameOptions = {
// ...game-specific options...
gameOver?: boolean | ((scoreManager: ScoreManager) => boolean);
};
```
**Examples:**
```typescript
// Static game-over
useGame({ rows: 24, cols: 32, gameOver: true })
// Time-based
useGame({
rows: 24,
cols: 32,
gameOver: (sm) => sm.timeValue >= 600000 // 10 minutes
})
// Efficiency-based
useGame({
rows: 24,
cols: 32,
gameOver: (sm) => sm.efficiency > 150 // >150% keystrokes
})
// Combined conditions
useGame({
rows: 24,
cols: 32,
gameOver: (sm) => sm.efficiency > 150 || sm.timeValue >= 600000
})
```
**Benefits:**
- No hardcoded game-over logic in libraries
- Consumers define win/loss conditions
- Flexible for tournaments, variations, custom rules
```
### 7. Hook Specifications - useScore Section (Line ~424+)
Find and update the useScore section:
**OLD:**
```typescript
export function useScore(/* dependencies */) {
// ...
const [timeValue, setTimeValue] = useState(0);
const [finalScore, setFinalScore] = useState<number | null>(null);
// Calculate score on game-won
useEffect(() => {
if (gameStatus === 'game-won') {
const score = calculateScore(timeValue, totalKeystrokes);
setFinalScore(Math.min(1000, Math.max(0, score)));
}
}, [gameStatus]);
return {
timeValue,
totalKeystrokes,
finalScore,
// Additional metrics
};
}
```
**NEW:**
```typescript
export function useScore(/* dependencies */) {
// Returns raw metrics only - no calculation
// Score calculation moved to consumer
return {
timeValue,
totalKeystrokes,
// Additional game-specific metrics
// finalScore removed - consumer responsibility
};
}
```
Add note:
```
**Change in v2.3+:** ScoreManager is now a pure data collector.
All score calculation has been moved to the consumer.
This provides flexibility and allows games to:
- Provide utility functions for standard scoring
- Allow consumers to implement custom scoring
- Keep the hook focused on metrics collection
```
## Migration Guide for Games
For games updating to this pattern:
1. Remove `finalScore` from `ScoreManager` type
2. Remove score calculation logic from `useScore` hook
3. Export a `calculateScore()` utility function
4. Update `gameOver` option to accept function receiving `scoreManager`
5. Remove `timeLimit` logic from `useScore` (delegate to consumer)
6. Document the new scoring approach in gameInfo
## Benefits of This Pattern
✅ **Pure Data Collection** - ScoreManager has no side effects
✅ **Separation of Concerns** - Scoring logic is independent
✅ **Flexibility** - Consumers control both scoring and game-over
✅ **Testability** - Scoring functions can be tested in isolation
✅ **Reusability** - Metrics available for any custom logic
✅ **Clarity** - Clear boundaries between collection and calculation
+42 -37
View File
@@ -1,4 +1,4 @@
import { useGame, version, calculateMazeScore } from "../../src";
import { useGame, version, calculateMazeScore, type ScoreManager } from "../../src";
import { useKeyBindings } from "./useKeyBindings";
import { useIsMobile } from "./useIsMobile";
import { useMemo } from "react";
@@ -9,7 +9,12 @@ function App() {
const cols = isMobile ? 12 : 32;
const rows = isMobile ? 8 : 24;
const gameManager = useGame({ cols, rows }, useKeyBindings);
const gameOver = (scroreManager: ScoreManager) => {
console.log('gameOver', scroreManager);
return scoreManager.efficiency < 70;
}
const gameManager = useGame({ cols, rows, gameOver }, useKeyBindings);
const { containerRef, gameStatus, scoreManager } = gameManager;
const { timeValue, keystrokes, optimalSteps, efficiency } = scoreManager;
@@ -30,41 +35,41 @@ function App() {
<div className="flex flex-col items-center justify-around min-h-[100vh]">
<h1 className="text-6xl">VIMazing vim-maze <small>v{version}</small> example</h1>
<div className="mx-auto my-4 w-fit bg-muted rounded-lg p-4">
{(gameStatus === "started" || gameStatus === "has-key" || gameStatus === "game-won") && (
<div className="flex gap-8 text-sm font-mono items-center">
<div>
<div className="text-muted-foreground">Time</div>
<div className="text-lg font-bold">{formatTime(timeValue)}</div>
</div>
<div>
<div className="text-muted-foreground">Keystrokes</div>
<div className="text-lg font-bold">{keystrokes}</div>
</div>
{optimalSteps > 0 && (
<>
<div className="w-px h-12 bg-muted-foreground/20" />
<div>
<div className="text-muted-foreground">Optimal Steps</div>
<div className="text-lg font-bold">{optimalSteps}</div>
</div>
<div>
<div className="text-muted-foreground">Efficiency</div>
<div className="text-lg font-bold">{efficiency}%</div>
</div>
</>
)}
{gameStatus === "game-won" && finalScore && (
<>
<div className="w-px h-12 bg-muted-foreground/20" />
<div className="text-center">
<div className="text-muted-foreground">Final Score</div>
<div className="text-3xl font-bold bg-gradient-to-r from-green-400 to-emerald-500 bg-clip-text text-transparent">{finalScore}</div>
</div>
</>
)}
</div>
)}
</div>
{(gameStatus === "started" || gameStatus === "has-key" || gameStatus === "game-won") && (
<div className="flex gap-8 text-sm font-mono items-center">
<div>
<div className="text-muted-foreground">Time</div>
<div className="text-lg font-bold">{formatTime(timeValue)}</div>
</div>
<div>
<div className="text-muted-foreground">Keystrokes</div>
<div className="text-lg font-bold">{keystrokes}</div>
</div>
{optimalSteps > 0 && (
<>
<div className="w-px h-12 bg-muted-foreground/20" />
<div>
<div className="text-muted-foreground">Optimal Steps</div>
<div className="text-lg font-bold">{optimalSteps}</div>
</div>
<div>
<div className="text-muted-foreground">Efficiency</div>
<div className="text-lg font-bold">{efficiency}%</div>
</div>
</>
)}
{gameStatus === "game-won" && finalScore && (
<>
<div className="w-px h-12 bg-muted-foreground/20" />
<div className="text-center">
<div className="text-muted-foreground">Final Score</div>
<div className="text-3xl font-bold bg-gradient-to-r from-green-400 to-emerald-500 bg-clip-text text-transparent">{finalScore}</div>
</div>
</>
)}
</div>
)}
</div>
<div className="relative mx-auto my-4 w-fit" id="maze_container">
<div ref={containerRef} className="relative" />
</div>
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@vimazing/vim-maze",
"version": "2.2.0",
"version": "2.3.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@vimazing/vim-maze",
"version": "2.2.0",
"version": "2.3.1",
"license": "MIT",
"devDependencies": {
"typescript": "~5.9.3"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@vimazing/vim-maze",
"version": "2.2.0",
"version": "2.3.1",
"description": "VIMazing VIM Maze engine — a lightweight, typed React hook set for maze-based games.",
"author": "André Padez <you@example.com>",
"repository": {
+54
View File
@@ -0,0 +1,54 @@
import type { GameManager } from './types';
import { gameInfo as baseGameInfo } from './gameInfo';
/**
* Format the gameOver condition as a readable string
*/
function formatGameOverCondition(condition: GameManager['gameOverCondition']): string {
if (condition === undefined) {
return 'None - game continues indefinitely';
}
if (condition === true) {
return 'Always (immediate game over)';
}
if (condition === false) {
return 'Never (game never ends)';
}
if (typeof condition === 'function') {
// Try to get the function source
const source = condition.toString();
// Clean up and format
const cleaned = source
.replace(/\s+/g, ' ')
.replace(/=>/, '→')
.trim();
return cleaned.length > 200 ? cleaned.substring(0, 197) + '...' : cleaned;
}
return 'Unknown condition';
}
/**
* Enrich gameInfo with runtime gameOver condition
* Returns the base gameInfo plus runtime condition info
*/
export function enrichGameInfo(gameManager: GameManager) {
const runtimeCondition = formatGameOverCondition(gameManager.gameOverCondition);
return {
...baseGameInfo,
gameOverRuntime: {
condition: runtimeCondition,
type: gameManager.gameOverCondition === undefined
? 'none'
: typeof gameManager.gameOverCondition === 'boolean'
? 'static'
: 'function',
},
};
}
export { formatGameOverCondition };
+83 -61
View File
@@ -1,4 +1,7 @@
import { version } from '../package.json';
export const gameInfo = {
version,
name: 'VIM Maze',
description: 'Navigate procedurally generated mazes with VIM-style controls',
@@ -57,56 +60,72 @@ export const gameInfo = {
],
},
scoring: {
formula: 'Base Score = 1000 - (time penalty) - (keystroke penalty)',
multiplier: 'Final Score = min(1000, Base Score × size multiplier)',
range: '0 - 1000 points',
penalties: [
{ factor: 'Time', formula: 'seconds / 10', example: '60 seconds = -6 points' },
{ factor: 'Keystrokes', formula: 'total keys / 2', example: '100 keys = -50 points' },
],
sizeMultiplier: {
formula: 'max(1.0, (rows × cols) / 500)',
description: 'Larger mazes earn higher potential scores',
examples: [
{ size: '16×24 (384 cells)', multiplier: '1.0x', description: 'Small maze, no bonus' },
{ size: '24×32 (768 cells)', multiplier: '1.54x', description: 'Medium maze' },
{ size: '32×48 (1536 cells)', multiplier: '3.07x', description: 'Large maze' },
],
},
examples: [
{
size: '16×24',
time: '60 seconds',
keystrokes: 80,
calculation: '1000 - 6 - 40 = 954 × 1.0',
score: '954 / 1000',
},
{
size: '32×48',
time: '180 seconds',
keystrokes: 200,
calculation: '1000 - 18 - 100 = 882 × 3.07',
score: '1000 / 1000 (capped)',
},
],
},
scoring: {
calculation: 'Call calculateMazeScore(scoreManager, mazeSize) to compute final score',
note: 'Score calculation moved to consumer - ScoreManager only provides raw metrics',
formula: 'Base Score = 1000 - (time penalty) - (keystroke penalty)',
multiplier: 'Final Score = min(1000, Base Score × size multiplier)',
range: '0 - 1000 points',
penalties: [
{ factor: 'Time', formula: 'seconds / 10', example: '60 seconds = -6 points' },
{ factor: 'Keystrokes', formula: 'total keys / 2', example: '100 keys = -50 points' },
],
sizeMultiplier: {
formula: 'max(1.0, (rows × cols) / 500)',
description: 'Larger mazes earn higher potential scores',
examples: [
{ size: '16×24 (384 cells)', multiplier: '1.0x', description: 'Small maze, no bonus' },
{ size: '24×32 (768 cells)', multiplier: '1.54x', description: 'Medium maze' },
{ size: '32×48 (1536 cells)', multiplier: '3.07x', description: 'Large maze' },
],
},
examples: [
{
size: '16×24',
time: '60 seconds',
keystrokes: 80,
calculation: '1000 - 6 - 40 = 954 × 1.0',
score: '954 / 1000',
},
{
size: '32×48',
time: '180 seconds',
keystrokes: 200,
calculation: '1000 - 18 - 100 = 882 × 3.07',
score: '1000 / 1000 (capped)',
},
],
},
gameOver: {
conditions: [
{
type: 'Time Limit',
trigger: '10 minutes (600 seconds) by default',
configurable: true,
message: 'Time ran out!',
description: 'Failed to reach exit with key in time',
},
],
note: 'No other game-over conditions - take your time and find the optimal path!',
},
gameOver: {
conditions: [
{
type: 'Custom Condition',
trigger: 'Defined via gameOver option in GameOptions',
configurable: true,
message: 'Game over!',
description: 'Can be a boolean or function that receives scoreManager metrics',
},
],
examples: [
{
description: 'Time-based (10 minutes)',
code: 'gameOver: (sm) => sm.timeValue >= 600000',
},
{
description: 'Efficiency-based (exceed 150%)',
code: 'gameOver: (sm) => sm.efficiency > 150',
},
{
description: 'Combined condition',
code: 'gameOver: (sm) => sm.efficiency > 150 || sm.timeValue >= 600000',
},
],
note: 'Game-over conditions are flexible and controlled by the game consumer. No built-in limits - define your own rules!',
},
mazeGeneration: {
algorithm: 'Depth-first search with backtracking',
@@ -123,19 +142,22 @@ export const gameInfo = {
],
},
metrics: {
tracked: [
{ metric: 'Time', unit: 'seconds', description: 'Elapsed time since game start' },
{ metric: 'Keystrokes', unit: 'count', description: 'Total keys pressed' },
{ metric: 'Distance to Key', unit: 'cells', description: 'Current shortest path to key' },
{ metric: 'Distance to Exit', unit: 'cells', description: 'Current shortest path to exit' },
],
displayed: 'Live during gameplay to help you track progress',
},
metrics: {
tracked: [
{ metric: 'Time', unit: 'milliseconds', description: 'Elapsed time since game start' },
{ metric: 'Keystrokes', unit: 'count', description: 'Total keys pressed' },
{ metric: 'Optimal Steps', unit: 'cells', description: 'Perfect path length (entrance → key → exit)' },
{ metric: 'Efficiency', unit: 'percentage', description: 'Keystroke efficiency ratio (100% = perfect)' },
{ metric: 'Distance to Key', unit: 'cells', description: 'Current shortest path to key' },
{ metric: 'Distance to Exit', unit: 'cells', description: 'Current shortest path to exit' },
],
available: 'All metrics available via scoreManager for use in scoring or game-over conditions',
displayed: 'Live during gameplay to help you track progress and make decisions',
},
objective: 'Navigate from entrance to key, then from key to exit, before time runs out.',
winCondition: 'Reach the exit cell while holding the key (key must be collected first).',
objective: 'Navigate from entrance to key, then from key to exit with a custom game-over condition.',
winCondition: 'Reach the exit cell while holding the key (key must be collected first).',
} as const;
export type GameInfo = typeof gameInfo;
+1
View File
@@ -3,6 +3,7 @@ export * from './useGame';
export * from './useGameStatus';
export { gameInfo } from './gameInfo';
export { calculateMazeScore } from './useScore';
export { enrichGameInfo, formatGameOverCondition } from './formatGameInfo';
import { version } from '../package.json';
export type { GameInfo } from './gameInfo';
export { version };
+1
View File
@@ -29,4 +29,5 @@ export type GameManager = {
hero: HeroManager;
renderer: HeroRenderManager;
scoreManager: ScoreManager;
gameOverCondition?: boolean | ((scoreManager: ScoreManager) => boolean);
} & GameStatusManager & GameKeyManager;
+1
View File
@@ -25,6 +25,7 @@ export function useGame(options: GameOptions, platformHook?: unknown): GameManag
hero,
renderer,
scoreManager,
gameOverCondition: gameOver,
}
if (typeof platformHook === 'function') {
+5 -1
View File
@@ -50,7 +50,11 @@ export function useScore({ board, hero, gameStatusManager, keyManager, gameOver
}, [gameStatus, board.mazeInstanceRef]);
const optimalSteps = optimalRef.current;
const efficiency = optimalSteps > 0 ? Math.round((keystrokes / optimalSteps) * 100) : 0;
// Efficiency: 100% = optimal play, 0% = worst case
// Formula: (optimalSteps / keystrokes) * 100, clamped to 0-100
const efficiency = optimalSteps > 0 && keystrokes > 0
? Math.max(0, Math.min(100, Math.round((optimalSteps / keystrokes) * 100)))
: 100;
// Check custom gameOver condition if provided
useEffect(() => {
-2
View File
@@ -54,7 +54,5 @@ export function useTimer(): TimerManager {
};
}, []);
console.log('timeValue', timeValue);
return { timeValue, startTimer, stopTimer, resetTimer };
}