From 4db86e1cfe1717e5f258d832376d0040ed157277 Mon Sep 17 00:00:00 2001 From: Andre Padez Date: Sun, 19 Oct 2025 01:48:20 +0100 Subject: [PATCH] working on unified API and major refactoring --- AGENTS.md | 186 +++++++++++ UNIFIED_API.md | 444 +++++++++++++++++++++++++++ example/src/useKeyBindings.ts | 12 +- src/game.css | 232 ++++++++++++++ src/index.ts | 2 + src/types.ts | 60 +++- src/useBoard/index.ts | 2 + src/useBoard/useBoard.ts | 18 +- src/useCursor/useCursor.ts | 209 ++++++++++++- src/useGame.ts | 25 +- src/useGameStatus/index.ts | 1 + src/useGameStatus/useGameStatus.ts | 31 ++ src/useKeyBindings/index.ts | 1 + src/useKeyBindings/useKeyBindings.ts | 137 +++++++++ 14 files changed, 1306 insertions(+), 54 deletions(-) create mode 100644 AGENTS.md create mode 100644 UNIFIED_API.md create mode 100644 src/game.css create mode 100644 src/useGameStatus/index.ts create mode 100644 src/useGameStatus/useGameStatus.ts create mode 100644 src/useKeyBindings/index.ts create mode 100644 src/useKeyBindings/useKeyBindings.ts diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..28cf764 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,186 @@ +# Development Guidelines & Agent Instructions + +## Documentation Management + +### Automatic Documentation Updates +- **UNIFIED_API.md**: Automatically update after any implementation changes +- **Track**: Implementation status, API changes, new types, completed features +- **No prompting required**: Keep docs in sync with codebase proactively + +## Code Conventions + +### File Structure & Organization +``` +src/ +├── useBoard/ # Board/level management +│ ├── index.ts # Public exports +│ ├── useBoard.ts # Main hook implementation +│ ├── MazeGenerator.ts +│ └── MazeRenderer.ts +├── useCursor/ # Player/actor management +│ ├── index.ts # Public exports +│ └── useCursor.ts # Main hook implementation +├── types.ts # All shared TypeScript types +├── useGame.ts # Main orchestrator hook +└── index.ts # Main package exports +``` + +### TypeScript Patterns +- **Type-first approach**: Define types before implementation +- **Explicit imports**: Always import types with `import type` +- **Shared types**: Keep all types in `types.ts` for consistency +- **Return type annotations**: Use `ReturnType` for hook return types + +### Hook Structure +```tsx +// Standard hook pattern +export function useHookName(param: Type): ReturnType { + // 1. Refs for persistent state + const ref = useRef(initialValue); + + // 2. State for reactive values + const [state, setState] = useState(initialValue); + + // 3. Internal functions (private) + function internalFunction() { + // Implementation + } + + // 4. Public API functions + function publicMethod() { + // Implementation + } + + // 5. Return object with explicit interface + return { + // Public API only + }; +} + +// Export type for consumers +export type HookManager = ReturnType; +``` + +### Naming Conventions +- **Hooks**: `use[FeatureName]` (e.g., `useBoard`, `useCursor`, `useGame`) +- **Types**: `PascalCase` with descriptive names (e.g., `GameManager`, `BoardManager`) +- **Functions**: `camelCase` with clear verbs (e.g., `renderBoard`, `movePlayer`) +- **Refs**: `[concept]Ref` pattern (e.g., `containerRef`, `mazeRef`) +- **Constants**: `UPPER_SNAKE_CASE` + +### Import Organization +```tsx +// 1. React imports +import { useRef, useState } from 'react'; +import type { RefObject } from 'react'; + +// 2. Internal type imports +import type { Cursor, BoardManager } from '../types'; + +// 3. Internal implementation imports +import { MazeGenerator } from './MazeGenerator'; +import { MazeRenderer } from './MazeRenderer'; +``` + +### Code Style +- **No comments**: Avoid adding comments unless explicitly requested +- **Minimal whitespace**: Clean, concise formatting +- **Explicit returns**: Always return objects from hooks +- **Destructuring**: Use object destructuring for parameters and returns +- **Arrow functions**: Prefer arrow functions for consistency + +### Error Handling +- **Early returns**: Guard clauses at function start +- **Null checks**: Always validate refs and state before use +- **Graceful failures**: Return early if conditions aren't met + +## Architecture Principles + +### Unified API Design +- **Single entry point**: `useGame(options?, platformHook?)` for all games +- **Composable hooks**: Three core hooks (`useBoard`, `useCursor`, `useGame`) +- **Platform agnostic**: Games work across platforms without modification +- **Type safety**: Full TypeScript support throughout + +### Hook Composition Pattern +```tsx +// Standard composition in useGame +export function useGame(options: GameOptions, platformHook?: unknown): GameManager { + const boardManager = useBoard(cols, rows); + const cursor = useCursor(boardManager); + + // Orchestrate and return unified interface + return { + // Expose necessary functionality + }; +} +``` + +### Platform Integration +- **Platform hook pattern**: Optional function parameter for platform-specific logic +- **Non-intrusive**: Core game logic works without platform integration +- **Extensible**: Platform can add analytics, custom bindings, etc. + +## Development Workflow + +### Implementation Order +1. **Types first**: Define interfaces in `types.ts` +2. **Core hooks**: Implement `useBoard`, `useCursor` +3. **Orchestrator**: Implement `useGame` +4. **Integration**: Add platform hook support +5. **Features**: Add advanced features (scoring, animations, etc.) + +### Testing & Validation +- **Type checking**: Run TypeScript compiler after changes +- **Build verification**: Ensure build process works +- **Example testing**: Test with example application + +### File Management +- **Prefer editing**: Modify existing files over creating new ones +- **Never create docs**: Don't create README files unless explicitly requested +- **Consistent exports**: Use `index.ts` files for clean public APIs + +## Agent Behavior Rules + +### Proactive Documentation +- Update `UNIFIED_API.md` after every meaningful change +- Track implementation status changes +- Document new types and interfaces +- Note API modifications and architectural decisions + +### Code Generation +- Follow existing patterns exactly +- Mimic code style from surrounding files +- Use established naming conventions +- Maintain type safety throughout + +### Communication +- **Concise responses**: Keep explanations brief and direct +- **Focus on code**: Prioritize implementation over explanation +- **No elaboration**: Avoid unnecessary preamble or summary +- **Direct answers**: Respond to specific requests without extra context + +### Quality Assurance +- **Type safety first**: Ensure all TypeScript types are correct +- **Import consistency**: Follow established import patterns +- **Code style matching**: Mirror existing code style exactly +- **Error prevention**: Write defensive code with proper validation + +## Forbidden Patterns + +### Do NOT +- Add comments to code (unless requested) +- Create documentation files (except UNIFIED_API.md updates) +- Use generic variable names (`data`, `item`, etc.) +- Mix import styles (some type imports, some runtime) +- Break existing patterns without reason +- Write overly complex implementations +- Add unnecessary features + +### Always DO +- Follow existing code patterns exactly +- Use descriptive, meaningful names +- Maintain type safety +- Keep implementations simple and focused +- Update documentation proactively +- Test builds after changes \ No newline at end of file diff --git a/UNIFIED_API.md b/UNIFIED_API.md new file mode 100644 index 0000000..0da2229 --- /dev/null +++ b/UNIFIED_API.md @@ -0,0 +1,444 @@ +# Unified API Design + +## Overview + +The VIMazing game engine provides a uniform external API for building grid-based games. The architecture is designed around three core hooks that can be composed to create various game types (maze, puzzle, strategy, etc.). + +## Core Philosophy + +**Single Entry Point**: Platforms consume games by calling `useGame(gameOptions?, platformHook?)` and receive everything needed to run the game. + +**Composable Architecture**: Three fundamental hooks that work together: +- `useBoard` - Manages the game board/level/field +- `useCursor` - Manages player/actor/selector movement +- `useGame` - Orchestrates everything together + +## Primary API + +### `useGame(gameOptions?, platformHook?)` + +The main entry point for consuming any game. + +```tsx +const gameManager = useGame(gameOptions?, platformHook?) +``` + +**Parameters:** +- `gameOptions?: GameOptions` - Game-specific configuration +- `platformHook?: (gameManager: GameManager) => void` - Platform-specific integration + +**Returns:** `GameManager` - Complete game management interface + +## Core Types + +### `GameOptions` +```tsx +type GameOptions = { + rows: number; + cols: number; +}; +``` + +### `GameManager` +```tsx +type GameManager = { + containerRef: RefObject; + gameStatus: GameStatus; + renderBoard: () => void; + startGame: () => void; + togglePause: (pause?: boolean) => void; + quitGame: () => void; + cursor: Cursor; + keyManager: GameKeyManager; +}; +``` + +### `GameStatus` +```tsx +type GameStatus = 'waiting' | 'started' | 'hasKey' | 'paused' | 'game-over' | 'game-won' +``` + +### `Cursor` +```tsx +type Cursor = { + position: () => Coord; + mode: () => CursorMode; + move: (dCols: number, dRows: number, count: number) => void; + moveLeft: (count?: number) => void; + moveRight: (count?: number) => void; + moveUp: (count?: number) => void; + moveDown: (count?: number) => void; +} +``` + +### `CursorMode` +```tsx +type CursorMode = 'normal' | 'insert' | 'replace' | 'visual' | 'visual-line'; +``` + +### `Coord` +```tsx +type Coord = { row: number, col: number }; +``` + +### `GameStatusManager` +```tsx +type GameStatusManager = { + gameStatus: GameStatus; + setGameStatus: (status: GameStatus) => void; + startGame: () => void; + togglePause: (pause?: boolean) => void; + quitGame: () => void; +}; +``` + +### `GameKeyManager` +```tsx +type GameKeyManager = { + keyLog: KeyLogEntry[]; + clearLog: () => void; + getLog: () => KeyLogEntry[]; +}; +``` + +### `CursorBindingContext` +```tsx +type CursorBindingContext = { + moveLeft: (count?: number) => void; + moveRight: (count?: number) => void; + moveUp: (count?: number) => void; + moveDown: (count?: number) => void; + moveToStart: () => void; + moveToEnd: () => void; + moveToTop: () => void; + moveToBottom: () => void; + repeatLastMotion: () => void; + resetCount: () => void; + getCount: () => string; + setCount: (digit: string) => void; + setLastKey: (key: string) => void; + getLastKey: () => string; +}; +``` + +### `UseKeyBindingsParams` +```tsx +type UseKeyBindingsParams = { + cursor: CursorBindingContext; + gameStatus: GameStatus; +}; +``` + +## Core Hooks Architecture + +### `useBoard(cols, rows) -> BoardManager` +Manages the game board/level structure. + +**Returns:** +- `containerRef` - DOM container reference +- `mazeRef` - Board data reference +- `mazeInstanceRef` - Board generator instance +- `rendererRef` - Renderer instance +- `renderBoard()` - Initialize/render the board + +### `useCursor(board: BoardManager) -> Cursor` +Manages player/actor movement and state. + +**Returns:** +- Position tracking +- Mode management +- Movement methods (with count support) +- Directional movement helpers + +### `useKeyBindings({ cursor, gameStatus }) -> GameKeyManager` +Handles keyboard input and maps keys to cursor actions. + +**Parameters:** +- `cursor` - Cursor binding context with movement methods +- `gameStatus` - Current game status for state checking + +**Returns:** +- `keyLog` - Array of key press entries with timestamps +- `clearLog()` - Clear the key log +- `getLog()` - Get current key log + +### `useGameStatus() -> GameStatusManager` +Manages game state and lifecycle. + +**Returns:** +- `gameStatus` - Current game state +- `setGameStatus` - State setter +- `startGame()` - Start the game +- `togglePause(pause?)` - Toggle or set pause state +- `quitGame()` - Quit/reset the game + +### `useGame(options: GameOptions, platformHook?) -> GameManager` +Orchestrates board, cursor, and game status, provides game lifecycle management. + +**Returns:** +- Complete game management interface +- Game state control +- Platform integration point + +## Platform Integration + +### Platform Hook Pattern +```tsx +const platformHook = (gameManager: GameManager) => { + // Platform-specific logic + // - Custom key bindings + // - Analytics tracking + // - Save/load functionality + // - Platform UI integration +}; + +const gameManager = useGame(gameOptions, platformHook); +``` + +## Game Implementation Pattern + +### For New Game Types +1. **Extend `GameOptions`** with game-specific configuration +2. **Extend `GameStatus`** with game-specific states +3. **Implement game logic** in core hooks +4. **Maintain same external API** - `useGame(options, platformHook)` + +### Example: Maze Game +```tsx +// Platform consumes the game +const mazeGame = useGame({ rows: 20, cols: 16 }, platformHook); + +// Everything needed is provided +return ( +
+ {/* Game renders here */} + +
Status: {mazeGame.gameStatus}
+
+); +``` + +## Design Principles + +### 1. **Uniform Interface** +All games expose the same `GameManager` interface regardless of internal complexity. + +### 2. **Composable Architecture** +Core hooks can be mixed and matched for different game types. + +### 3. **Platform Agnostic** +Games work across different platforms without modification. + +### 4. **Type Safety** +Full TypeScript support with generated declarations. + +### 5. **Tree-Shakeable** +Only used functionality is included in the final bundle. + +## Migration Path + +### From Old Architecture +- **Old**: Multiple specialized managers (`useMaze`, `usePlayer`, `useScore`, etc.) +- **New**: Three core hooks with unified interfaces + +### Benefits +- **Simpler consumption**: Single `useGame` call +- **Consistent API**: Same pattern across all games +- **Better maintainability**: Clear separation of concerns +- **Platform flexibility**: Easy integration points + +## Future Extensibility + +The unified API is designed to accommodate: +- Different game types (puzzles, strategy, arcade) +- Various input methods (touch, gamepad, voice) +- Multiple rendering backends (Canvas, WebGL, DOM) +- Cross-platform features (save states, leaderboards, multiplayer) + +## Implementation Status + +- ✅ **Type definitions** - Complete +- ✅ **Core architecture** - Defined +- ✅ **useGameStatus implementation** - Complete +- ✅ **useKeyBindings implementation** - Complete +- ✅ **useCursor implementation** - Complete +- 🔄 **useBoard implementation** - In progress +- 🔄 **useGame implementation** - In progress +- ⏳ **Platform integration** - Pending +- ⏳ **Advanced features** - Pending (scoring, animations, etc.) + +## Usage Examples + +### Basic Game Integration +```tsx +import { useGame } from "@vimazing/vim-maze"; + +function App() { + const gameManager = useGame({ cols: 32, rows: 24 }); + const { containerRef, gameStatus, keyManager } = gameManager; + + return ( +
+
+
+ ); +} +``` + +### Platform Integration Pattern +```tsx +import { useGame } from "@vimazing/vim-maze"; +import { useKeyBindings } from "./useKeyBindings"; + +function App() { + const gameManager = useGame({ cols: 32, rows: 24 }, useKeyBindings); + const { containerRef, gameStatus, cursor, keyManager } = gameManager; + + return ( +
+
+
+ ); +} +``` + +### Platform Hook Implementation +```tsx +export const useKeyBindings = (gameManager: GameManager) => { + const { gameStatus, startGame, quitGame, cursor } = gameManager; + + useEffect(() => { + const handler = (e: KeyboardEvent) => { + // Start game with 'i' + if (e.key === "i" || e.key === "I") { + if (["waiting", "game-over", "game-won"].includes(gameStatus)) { + startGame(); + return; + } + } + + // Movement keys + if (gameStatus === "started") { + switch (e.key) { + case "h": cursor.moveLeft(); break; + case "j": cursor.moveDown(); break; + case "k": cursor.moveUp(); break; + case "l": cursor.moveRight(); break; + } + } + + // Quit with 'q' + if (e.key === "q" || e.key === "Q") { + quitGame(); + } + }; + + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [gameStatus, startGame, quitGame, cursor]); +}; +``` + +### Internal Key Bindings Integration +```tsx +// Inside useGame - simplified integration +const keyManager = useKeyBindings({ cursor, gameStatus }); + +const gameManager = { + containerRef, + gameStatus, + renderBoard, + startGame, + togglePause, + quitGame, + cursor, + keyManager, +}; +``` + +### Responsive Game Sizing +```tsx +function App() { + const isMobile = useIsMobile(); + const cols = isMobile ? 12 : 32; + const rows = isMobile ? 8 : 24; + + const gameManager = useGame({ cols, rows }, useKeyBindings); + const { containerRef, keyManager } = gameManager; + + return ( +
+
+
+ ); +} +``` + +## Platform Integration Examples + +### Key Binding Platform Hook +Platforms can provide custom key bindings by passing a platform hook: + +```tsx +const platformHook = (gameManager: GameManager) => { + // Custom VIM-style bindings + const { gameStatus, cursor, startGame, quitGame } = gameManager; + + // Implementation details... +}; + +const gameManager = useGame(gameOptions, platformHook); +``` + +### Analytics Platform Hook +```tsx +const analyticsHook = (gameManager: GameManager) => { + const { gameStatus } = gameManager; + + useEffect(() => { + analytics.track('game_status_change', { status: gameStatus }); + }, [gameStatus]); +}; +``` + +### Save/Load Platform Hook +```tsx +const saveLoadHook = (gameManager: GameManager) => { + const { cursor, gameStatus } = gameManager; + + const saveGame = () => { + localStorage.setItem('gameState', JSON.stringify({ + position: cursor.position(), + status: gameStatus + })); + }; + + // Auto-save on status changes + useEffect(() => { + saveGame(); + }, [gameStatus]); +}; +``` + +## Expected Platform Hook Interface + +Platform hooks receive the complete `GameManager` interface: + +```tsx +type GameManager = { + containerRef: RefObject; + gameStatus: GameStatus; + renderBoard: () => void; + startGame: () => void; + togglePause: (pause?: boolean) => void; + quitGame: () => void; + cursor: Cursor; +}; +``` + +This allows platforms to: +- Add custom input handling +- Implement analytics tracking +- Add save/load functionality +- Integrate with platform-specific UI +- Extend game behavior without modifying core logic \ No newline at end of file diff --git a/example/src/useKeyBindings.ts b/example/src/useKeyBindings.ts index 91e6895..c96f13f 100644 --- a/example/src/useKeyBindings.ts +++ b/example/src/useKeyBindings.ts @@ -2,7 +2,8 @@ import { useEffect } from "react"; import type { UseGameType } from "@vimazing/vim-maze"; export const useKeyBindings = (gameManager: UseGameType) => { - const { gameStatus, initGame, stopGame, clearLog, resetCount } = gameManager; + const { gameStatus, startGame, stopGame, clearLog } = gameManager; + console.log('gameStatus', gameStatus); useEffect(() => { const handler = (e: KeyboardEvent) => { @@ -10,12 +11,11 @@ export const useKeyBindings = (gameManager: UseGameType) => { if (e.key === "i" || e.key === "I") { if (["waiting", "game-over", "game-won"].includes(gameStatus)) { clearLog(); - resetCount(); if (gameStatus === "game-over" || gameStatus === "game-won") { stopGame(); - setTimeout(() => initGame(true), 0); + setTimeout(() => startGame(), 0); } else { - initGame(true); + startGame(); } return; } @@ -27,20 +27,18 @@ export const useKeyBindings = (gameManager: UseGameType) => { // cancel count or stop game if (e.key === "Escape") { stopGame(); - resetCount(); return; } // quit if (e.key === "q" || e.key === "Q") { stopGame(); - resetCount(); return; } }; window.addEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler); - }, [gameStatus, initGame, stopGame, clearLog, resetCount]); + }, [gameStatus]); }; diff --git a/src/game.css b/src/game.css new file mode 100644 index 0000000..266b7c4 --- /dev/null +++ b/src/game.css @@ -0,0 +1,232 @@ +/* ========================================================= + 🔧 Controls (tweak these) + ========================================================= */ +:root { + --maze-cell-size: 24px; + --maze-number-font-size: 14px; +} + +/* ========================================================= + Container & Maze Base + ========================================================= */ +#maze_container { + position: relative; + margin: 1em auto; + width: fit-content; +} + +#maze { + position: relative; + background-color: var(--card, #1e2030); + background-image: + radial-gradient(circle at 0% 0%, transparent 50%, color-mix(in srgb, var(--primary, #82aaff) 3%, transparent) 50%), + radial-gradient(circle at center, color-mix(in srgb, var(--primary, #82aaff) 3%, transparent) 50%, transparent 50%), + radial-gradient(circle at 100% 100%, transparent 50%, color-mix(in srgb, var(--primary, #82aaff) 3%, transparent) 50%), + radial-gradient(circle at 0% 100%, transparent 50%, color-mix(in srgb, var(--primary, #82aaff) 3%, transparent) 50%), + radial-gradient(circle at 100% 0%, transparent 50%, color-mix(in srgb, var(--primary, #82aaff) 3%, transparent) 50%); + background-size: 8em 8em; + border-radius: 0.5rem; + box-shadow: 0 0 10px rgba(27, 29, 43, 0.6), inset 0 0 6px color-mix(in srgb, var(--primary, #82aaff) 5%, transparent); +} + +/* ========================================================= + Layout primitives + ========================================================= */ +#maze .maze-row, +#maze .number-strip { + display: flex; /* rows of equal-width cells */ +} + +/* Every grid box (maze cells + number cells) shares the same size */ +#maze .maze-row > div, +#maze .number { + width: var(--maze-cell-size); + height: var(--maze-cell-size); + display: flex; + align-items: center; + justify-content: center; +} + +/* Number cells (rows/cols) */ +#maze .number { + color: var(--muted-foreground, #828bb8); + font-weight: 600; + font-family: "Fira Code", monospace; + font-size: var(--maze-number-font-size); + line-height: 1; + opacity: 0.85; + user-select: none; +} + +#maze .number.corner { /* top-left/top-right/bottom corners */ + width: var(--maze-cell-size); +} + +/* Relative highlight (your tweak) */ +#maze.is-relative .number.is-active { + background: var(--accent, #ffc777); + border-radius: 2px; + font-size: 1.5rem; + color: var(--accent-foreground, #1b1d2b); +} + +/* ========================================================= + Maze cells visuals (⚠️ scoped to real cells only) + ========================================================= */ +/* Base cell box */ +#maze .maze-row > div[data-r][data-c] { + position: relative; + transition: background-color 0.2s ease; +} + +/* 🟢 Corridors (non-wall/door/sentinel) */ +#maze .maze-row > div[data-r][data-c]:not(.wall):not(.door):not(.sentinel) { + background-color: var(--muted, #2f334d); + background-image: + linear-gradient(135deg, color-mix(in srgb, var(--primary, #82aaff) 8%, transparent) 25%, transparent 25%), + linear-gradient(225deg, color-mix(in srgb, var(--primary, #82aaff) 6%, transparent) 25%, transparent 25%), + linear-gradient(45deg, color-mix(in srgb, var(--primary, #82aaff) 4%, transparent) 25%, transparent 25%), + linear-gradient(315deg, color-mix(in srgb, var(--primary, #82aaff) 3%, transparent) 25%, transparent 25%); + background-size: var(--maze-cell-size) var(--maze-cell-size); + box-shadow: inset 0 0 3px rgba(27,29,43,0.4); +} + +/* 🧱 Walls and exits */ +#maze .maze-row > div[data-r][data-c].wall, +#maze .maze-row > div[data-r][data-c].nubbin.wall, +#maze .maze-row > div[data-r][data-c].door.exit { + background-color: var(--background, #222436); + background-image: + linear-gradient(45deg, rgba(27,29,43,0.5) 45%, transparent 55%), + linear-gradient(to bottom, rgba(27,29,43,0.4) 45%, transparent 55%); + background-size: calc(var(--maze-cell-size) * 0.5) calc(var(--maze-cell-size) * 0.5); + box-shadow: inset 0 0 3px rgba(27,29,43,0.6); +} + +/* 🔒 Door (exit) — FIXED BACKGROUND */ +#maze .maze-row > div[data-r][data-c].door.exit { + background: radial-gradient(circle at center, var(--chart-4, #4fd6be) 10%, var(--card, #1e2030) 90%) !important; + box-shadow: + 0 0 6px color-mix(in srgb, var(--chart-4, #4fd6be) 50%, transparent), + inset 0 0 4px color-mix(in srgb, var(--chart-4, #4fd6be) 30%, transparent); + border-radius: 3px; + z-index: 2; +} + +#maze .maze-row > div[data-r][data-c].door.exit::after { + content: "\1F6AA"; + color: var(--chart-4, #4fd6be); + text-shadow: 0 0 6px color-mix(in srgb, var(--chart-4, #4fd6be) 60%, transparent); + position: absolute; + top: -4px; + left: -2px; + z-index: 3; +} + +/* 💎 Decorations (nubbins) */ +#maze .maze-row > div[data-r][data-c]::after { + position: absolute; + left: -3px; + top: -4px; + text-align: center; + text-shadow: 0 0 3px rgba(0,0,0,0.6); + font-size: calc(var(--maze-cell-size) * 0.75); /* scale with cell */ + z-index: 10; +} + +#maze .maze-row > div[data-r][data-c].nubbin::after { content: "\1F33C"; } +#maze .maze-row > div[data-r][data-c].nubbin:nth-of-type(3n)::after { content: "\1F344"; } +#maze .maze-row > div[data-r][data-c].nubbin:nth-of-type(5n)::after { content: "\1F33B"; } +#maze .maze-row > div[data-r][data-c].nubbin:nth-of-type(7n)::after { content: "\1F48E"; } +#maze .maze-row > div[data-r][data-c].nubbin.wall::after { content: ""; } + +/* 🧍 Hero */ +#maze .maze-row > div[data-r][data-c].hero { + background-color: var(--primary, #82aaff); + border-radius: 50%; + box-shadow: 0 0 8px color-mix(in srgb, var(--primary, #82aaff) 70%, transparent); +} +#maze.face-right .maze-row > div[data-r][data-c].hero::after { transform: scale(-1, 1); } +#maze .maze-row > div[data-r][data-c].hero::after { content: "\1F6B6" !important; } + +/* 🐍 Sentinels */ +#maze .maze-row > div[data-r][data-c].sentinel.wall { background: transparent; } +#maze .maze-row > div[data-r][data-c].sentinel.wall::after { + content: "\1F40A"; + color: var(--muted-foreground, #828bb8); +} +#maze .maze-row > div[data-r][data-c].sentinel.wall:nth-of-type(3n)::after { content: "\1F40D"; } + +/* 🗝️ Key */ +#maze .maze-row > div[data-r][data-c].key::after { + content: "\1F511"; + color: var(--accent, #ffc777); + text-shadow: 0 0 6px color-mix(in srgb, var(--accent, #ffc777) 80%, transparent); +} + +/* ========================================================= + Score / message bar (unchanged) + ========================================================= */ +#maze_output { + display: flex; + margin: 0.5em auto; +} +#maze_score, +#maze_message { + font-family: "Fira Code", monospace; + font-weight: 600; + font-size: 1em; + color: var(--foreground, #c8d3f5); +} +#maze_score { + flex: 1; + text-align: left; +} +#maze_score::before { + content: "Score: "; + color: var(--muted-foreground, #828bb8); +} +#maze_score.has-key::after { + content: "\00a0\1F511"; + color: var(--accent, #ffc777); +} +#maze_message { + flex: 3; + text-align: right; +} + +/* Subtle step trail effect for counted moves */ +#maze .maze-row > div[data-r][data-c].trail { + box-shadow: + inset 0 0 6px color-mix(in srgb, var(--primary, #82aaff) 60%, transparent), + 0 0 6px color-mix(in srgb, var(--primary, #82aaff) 35%, transparent); + animation: maze-trail-fade 16ms ease-out; +} + +/* ===== Invalid move "red mist" around hero ===== */ +#maze .maze-row > div[data-r][data-c].invalid-move { + animation: maze-invalid-pulse 180ms ease-out; + box-shadow: + 0 0 12px color-mix(in srgb, var(--destructive, #c53b53) 85%, transparent), + 0 0 24px color-mix(in srgb, var(--destructive, #c53b53) 45%, transparent); +} + +@keyframes maze-invalid-pulse { + 0% { + transform: scale(1.02); + box-shadow: + 0 0 16px color-mix(in srgb, var(--destructive, #c53b53) 95%, transparent), + 0 0 28px color-mix(in srgb, var(--destructive, #c53b53) 55%, transparent); + } + 100% { + transform: none; + box-shadow: none; + } +} + + +@keyframes maze-trail-fade { + from { background-color: color-mix(in srgb, var(--primary, #82aaff) 25%, transparent); } + to { background-color: transparent; } +} + diff --git a/src/index.ts b/src/index.ts index 5dbda14..7a70b36 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,2 +1,4 @@ export * from './types'; export * from './useGame'; +export * from './useGameStatus'; +export * from './useKeyBindings'; diff --git a/src/types.ts b/src/types.ts index eec6c29..deadb3e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,11 +1,12 @@ import type { RefObject } from 'react'; +import { MazeRenderer, MazeGenerator } from "./useBoard"; export * from './useGame'; export * from './useBoard'; export * from './useCursor'; export type GameStatus = 'waiting' | 'started' | 'hasKey' | 'paused' | 'game-over' | 'game-won' -export type GameManager = { //return type of useGame +export type GameManager = { containerRef: RefObject; gameStatus: GameStatus; renderBoard: () => void; @@ -14,21 +15,18 @@ export type GameManager = { //return type of useGame quitGame: () => void; cursor: Cursor; + keyManager: GameKeyManager; }; -export type Cursor = { //return type of useCursor (currently useHero) - position: () => Coord; - mode: () => CursorMode; - move: (dCols: number, dRows: number, count: number) => void; - moveLeft: (count?: number) => void; - moveRight: (count?: number) => void; - moveUp: (count?: number) => void; - moveDown: (count?: number) => void; + +export type BoardManager = { + containerRef: RefObject + mazeRef: RefObject + mazeInstanceRef: RefObject + rendererRef: RefObject + renderBoard: () => void } -export type CursorMode = 'normal' | 'insert' | 'replace' | 'visual' | 'visual-line'; - - export type PositionTag = "entrance" | "exit" | "key" | "hero"; @@ -52,3 +50,41 @@ export type GameOptions = { rows: number; cols: number; }; + +export type KeyLogEntry = { key: string; timestamp: number }; + +export type GameKeyManager = { + keyLog: KeyLogEntry[]; + clearLog: () => void; + getLog: () => KeyLogEntry[]; +}; + +export type GameStatusManager = { + gameStatus: GameStatus; + setGameStatus: (status: GameStatus) => void; + startGame: () => void; + togglePause: (pause?: boolean) => void; + quitGame: () => void; +}; + +//Cursor Stuff +export type CursorMode = 'normal' | 'insert' | 'replace' | 'visual' | 'visual-line'; +export type Cursor = { //return type of useCursor (currently useHero) + position: () => Coord; + mode: () => CursorMode; + move: (dCols: number, dRows: number, count: number) => void; + moveLeft: (count?: number) => void; + moveRight: (count?: number) => void; + moveUp: (count?: number) => void; + moveDown: (count?: number) => void; + moveToStart: () => void; + moveToEnd: () => void; + moveToTop: () => void; + moveToBottom: () => void; + repeatLastMotion: () => void; + resetCount: () => void; + getCount: () => string; + setCount: (digit: string) => void; + setLastKey: (key: string) => void; + getLastKey: () => string; +} diff --git a/src/useBoard/index.ts b/src/useBoard/index.ts index f381252..b6a5347 100644 --- a/src/useBoard/index.ts +++ b/src/useBoard/index.ts @@ -1 +1,3 @@ export * from './useBoard'; +export * from './MazeGenerator'; +export * from './MazeRenderer'; diff --git a/src/useBoard/useBoard.ts b/src/useBoard/useBoard.ts index ae7d9b8..4ad2ea6 100644 --- a/src/useBoard/useBoard.ts +++ b/src/useBoard/useBoard.ts @@ -1,4 +1,4 @@ -import { useRef } from "react"; +import { useEffect, useRef } from "react"; import type { RefObject } from 'react'; import { MazeGenerator } from "./MazeGenerator"; import { MazeRenderer } from "./MazeRenderer"; @@ -10,6 +10,13 @@ export function useBoard(cols: number, rows: number) { const mazeInstanceRef = useRef(null); const rendererRef = useRef(null); + + useEffect(() => { + if (containerRef.current) { + renderBoard(); + } + }, [containerRef]); + function renderBoard() { const container = containerRef.current; if (!container) return; @@ -35,12 +42,3 @@ export function useBoard(cols: number, rows: number) { renderBoard, }; } - -export type BoardManager = { - containerRef: RefObject - mazeRef: RefObject - mazeInstanceRef: RefObject - rendererRef: RefObject - renderBoard: () => void -} - diff --git a/src/useCursor/useCursor.ts b/src/useCursor/useCursor.ts index e8161dd..52cce02 100644 --- a/src/useCursor/useCursor.ts +++ b/src/useCursor/useCursor.ts @@ -1,27 +1,212 @@ import { useRef } from 'react' -import type { Cursor, CursorMode, Coord, BoardManager } from '../types' +import type { CursorMode, Coord, BoardManager } from '../types' -export function useCursor(board: BoardManager): Cursor { +type Motion = { dr: number; dc: number; steps: number }; + +export function useCursor(board: BoardManager) { const position = useRef({ row: 0, col: 0 }) const mode = useRef('normal') + const countRef = useRef(""); + const lastMotionRef = useRef(null); + const lastKeyRef = useRef(""); + + function findAnchorTarget(dir: "left" | "right") { + const m = board.mazeRef.current; + const pos = position.current; + if (!m.length) return null; + + const colsCount = m[0]?.length ?? 0; + const r = pos.row; + const c = pos.col; + + if (!m[r] || !m[r][c] || m[r][c].includes("wall")) return null; + + const step = dir === "left" ? -1 : 1; + let cc = c; + + while (true) { + const next = cc + step; + if (next < 0 || next >= colsCount) return null; + if (m[r][next].includes("wall")) { + const wallIdx = next; + const expectedWallIdx = dir === "left" ? 0 : colsCount - 1; + const expectedCellIdx = dir === "left" ? 1 : colsCount - 2; + + if (wallIdx !== expectedWallIdx) return null; + if (cc !== expectedCellIdx) return null; + if (cc === c) return null; + + return { r, cTarget: cc }; + } + cc = next; + } + } + + function findColumnAnchorTarget(dir: "top" | "bottom") { + const m = board.mazeRef.current; + const pos = position.current; + if (!m.length) return null; + + const rowsCount = m.length; + const r = pos.row; + const c = pos.col; + + if (!m[r] || !m[r][c] || m[r][c].includes("wall")) return null; + + const step = dir === "top" ? -1 : 1; + let rr = r; + + while (true) { + const next = rr + step; + if (next < 0 || next >= rowsCount) return null; + if (m[next][c].includes("wall")) { + const wallIdx = next; + const expectedWallIdx = dir === "top" ? 0 : rowsCount - 1; + const expectedCellIdx = dir === "top" ? 1 : rowsCount - 2; + + if (wallIdx !== expectedWallIdx) return null; + if (rr !== expectedCellIdx) return null; + if (rr === r) return null; + + return { rTarget: rr, c }; + } + rr = next; + } + } function move(dCols: number, dRows: number, count: number = 1) { + const m = board.mazeRef.current; + if (!m.length) return; + const currentPos = position.current; + const n = Math.max(1, count); + + // Preflight: simulate n steps; abort if any step is invalid + let r = currentPos.row; + let c = currentPos.col; + let valid = true; + + for (let i = 1; i <= n; i++) { + r += dRows; + c += dCols; + if (!m[r] || !m[r][c] || m[r][c].includes("wall")) { + valid = false; + break; + } + } + + if (!valid) { + window.dispatchEvent(new Event("maze-invalid")); + return; + } + + // Update position + position.current = { row: r, col: c }; + lastMotionRef.current = { dr: dRows, dc: dCols, steps: n }; } + function moveLeft(count = 1) { - // TODO: implement move left + move(-1, 0, count); } function moveRight(count = 1) { - // TODO: implement move right + move(1, 0, count); } function moveUp(count = 1) { - // TODO: implement move up + move(0, -1, count); } function moveDown(count = 1) { - // TODO: implement move down + move(0, 1, count); + } + + function moveToStart() { + const anchor = findAnchorTarget("left"); + if (!anchor) { + window.dispatchEvent(new Event("maze-invalid")); + return; + } + + const pos = position.current; + const dr = 0; + const dc = anchor.cTarget > pos.col ? 1 : -1; + const steps = Math.abs(anchor.cTarget - pos.col); + + move(dr, dc, steps); + } + + function moveToEnd() { + const anchor = findAnchorTarget("right"); + if (!anchor) { + window.dispatchEvent(new Event("maze-invalid")); + return; + } + + const pos = position.current; + const dr = 0; + const dc = anchor.cTarget > pos.col ? 1 : -1; + const steps = Math.abs(anchor.cTarget - pos.col); + + move(dr, dc, steps); + } + + function moveToTop() { + const anchor = findColumnAnchorTarget("top"); + if (!anchor) { + window.dispatchEvent(new Event("maze-invalid")); + return; + } + + const pos = position.current; + const dr = anchor.rTarget > pos.row ? 1 : -1; + const dc = 0; + const steps = Math.abs(anchor.rTarget - pos.row); + + move(dr, dc, steps); + } + + function moveToBottom() { + const anchor = findColumnAnchorTarget("bottom"); + if (!anchor) { + window.dispatchEvent(new Event("maze-invalid")); + return; + } + + const pos = position.current; + const dr = anchor.rTarget > pos.row ? 1 : -1; + const dc = 0; + const steps = Math.abs(anchor.rTarget - pos.row); + + move(dr, dc, steps); + } + + function repeatLastMotion() { + const last = lastMotionRef.current; + if (!last) return; + const steps = Math.max(1, parseInt(countRef.current || String(last.steps), 10)); + resetCount(); + move(last.dc, last.dr, steps); + } + + function resetCount() { + countRef.current = ""; + } + + function getCount() { + return countRef.current; + } + + function setCount(digit: string) { + countRef.current += digit; + } + + function setLastKey(key: string) { + lastKeyRef.current = key; + } + + function getLastKey() { + return lastKeyRef.current; } return { @@ -31,7 +216,17 @@ export function useCursor(board: BoardManager): Cursor { moveLeft, moveRight, moveUp, - moveDown + moveDown, + moveToStart, + moveToEnd, + moveToTop, + moveToBottom, + repeatLastMotion, + resetCount, + getCount, + setCount, + setLastKey, + getLastKey, } } diff --git a/src/useGame.ts b/src/useGame.ts index da337de..3d590a3 100644 --- a/src/useGame.ts +++ b/src/useGame.ts @@ -1,31 +1,19 @@ -import { useState } from 'react'; -import type { GameManager, GameOptions, GameStatus } from './types'; +import type { GameManager, GameOptions } from './types'; import { useBoard } from './useBoard'; import { useCursor } from './useCursor'; +import { useKeyBindings } from './useKeyBindings'; +import { useGameStatus } from './useGameStatus'; export function useGame(options: GameOptions, platformHook?: unknown): GameManager { const { cols, rows } = options; - const [gameStatus, setGameStatus] = useState('waiting'); const boardManager = useBoard(cols, rows); const { containerRef, renderBoard } = boardManager; const cursor = useCursor(boardManager); + const gameStatusManager = useGameStatus(); - function startGame() { - setGameStatus('started'); - } - - function togglePause(pause?: boolean) { - if (typeof pause === 'boolean') { - setGameStatus(pause ? 'paused' : 'started'); - return; - } - setGameStatus(prevStatus => prevStatus === 'paused' ? 'started' : 'paused'); - } - - function quitGame() { - setGameStatus('waiting'); - } + const { gameStatus, startGame, togglePause, quitGame } = gameStatusManager; + const keyManager = useKeyBindings({ cursor, gameStatus }); const gameManager = { containerRef, @@ -35,6 +23,7 @@ export function useGame(options: GameOptions, platformHook?: unknown): GameManag togglePause, quitGame, cursor, + keyManager, } if (typeof platformHook === 'function') { diff --git a/src/useGameStatus/index.ts b/src/useGameStatus/index.ts new file mode 100644 index 0000000..a2b084d --- /dev/null +++ b/src/useGameStatus/index.ts @@ -0,0 +1 @@ +export * from './useGameStatus'; \ No newline at end of file diff --git a/src/useGameStatus/useGameStatus.ts b/src/useGameStatus/useGameStatus.ts new file mode 100644 index 0000000..76f5648 --- /dev/null +++ b/src/useGameStatus/useGameStatus.ts @@ -0,0 +1,31 @@ +import { useState } from 'react'; +import type { GameStatus } from '../types'; + +export function useGameStatus() { + const [gameStatus, setGameStatus] = useState('waiting'); + + function startGame() { + setGameStatus('started'); + } + + function togglePause(pause?: boolean) { + if (typeof pause === 'boolean') { + setGameStatus(pause ? 'paused' : 'started'); + return; + } + setGameStatus(prevStatus => prevStatus === 'paused' ? 'started' : 'paused'); + } + + function quitGame() { + setGameStatus('waiting'); + } + + return { + gameStatus, + setGameStatus, + startGame, + togglePause, + quitGame, + }; +} + diff --git a/src/useKeyBindings/index.ts b/src/useKeyBindings/index.ts new file mode 100644 index 0000000..e5c8fc3 --- /dev/null +++ b/src/useKeyBindings/index.ts @@ -0,0 +1 @@ +export * from './useKeyBindings'; diff --git a/src/useKeyBindings/useKeyBindings.ts b/src/useKeyBindings/useKeyBindings.ts new file mode 100644 index 0000000..cd92ba5 --- /dev/null +++ b/src/useKeyBindings/useKeyBindings.ts @@ -0,0 +1,137 @@ +import type { GameStatus, KeyLogEntry } from "../types"; +import { useEffect, useRef, useState } from "react"; + +export type CursorBindingContext = { + moveLeft: (count?: number) => void; + moveRight: (count?: number) => void; + moveUp: (count?: number) => void; + moveDown: (count?: number) => void; + moveToStart: () => void; + moveToEnd: () => void; + moveToTop: () => void; + moveToBottom: () => void; + repeatLastMotion: () => void; + resetCount: () => void; + getCount: () => string; + setCount: (digit: string) => void; + setLastKey: (key: string) => void; + getLastKey: () => string; +}; + +type UseKeyBindingsParams = { + cursor: CursorBindingContext; + gameStatus: GameStatus; +}; + +const RELEVANT_KEYS = new Set([ + "h", "H", "j", "J", "k", "K", "l", "L", ".", "^", "$", "g", "G", "0", + "1", "2", "3", "4", "5", "6", "7", "8", "9" +]); + +export const useKeyBindings = ({ cursor, gameStatus }: UseKeyBindingsParams) => { + + const [keyLog, setKeyLog] = useState([]); + const logRef = useRef([]); + + const clearLog = () => { + logRef.current = []; + setKeyLog([]); + }; + + const recordKey = (key: string) => { + const entry = { key, timestamp: performance.now() }; + logRef.current.push(entry); + setKeyLog([...logRef.current]); + }; + + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (["waiting", "game-over", "game-won"].includes(gameStatus)) return; + + // "0" behaves like Vim: beginning of line + if (e.key === "0" && cursor.getCount() === "") { + recordKey("0"); + cursor.resetCount(); + cursor.moveToStart(); + return; + } + + const isDigit = e.key >= "0" && e.key <= "9"; + if (isDigit) { + if (e.key === "0" && cursor.getCount() === "") return; + recordKey(e.key); + cursor.setCount(e.key); + return; + } + + if (e.key === ".") { + recordKey("."); + cursor.repeatLastMotion(); + return; + } + + if (e.key === "^" || e.key === "$") { + recordKey(e.key); + cursor.resetCount(); + if (e.key === "^") { + cursor.moveToStart(); + } else { + cursor.moveToEnd(); + } + return; + } + + if (e.key === "g" || e.key === "G") { + if (e.key === "g") { + if (cursor.getLastKey() === "g") { + recordKey(e.key); + cursor.resetCount(); + cursor.setLastKey(""); + cursor.moveToTop(); + return; + } else { + recordKey(e.key); + cursor.setLastKey("g"); + return; + } + } else { + recordKey(e.key); + cursor.resetCount(); + cursor.setLastKey(""); + cursor.moveToBottom(); + return; + } + } + + if (!RELEVANT_KEYS.has(e.key)) return; + + recordKey(e.key); + + const count = Math.max(1, parseInt(cursor.getCount() || "1", 10)); + cursor.resetCount(); + + switch (e.key) { + case "h": + case "H": cursor.moveLeft(count); break; + case "j": + case "J": cursor.moveDown(count); break; + case "k": + case "K": cursor.moveUp(count); break; + case "l": + case "L": cursor.moveRight(count); break; + default: return; + } + }; + + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [cursor, gameStatus]); + + return { + keyLog, + clearLog, + getLog: () => logRef.current, + }; +}; + +