working on unified API and major refactoring
This commit is contained in:
@@ -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<typeof hookName>` for hook return types
|
||||||
|
|
||||||
|
### Hook Structure
|
||||||
|
```tsx
|
||||||
|
// Standard hook pattern
|
||||||
|
export function useHookName(param: Type): ReturnType {
|
||||||
|
// 1. Refs for persistent state
|
||||||
|
const ref = useRef<Type>(initialValue);
|
||||||
|
|
||||||
|
// 2. State for reactive values
|
||||||
|
const [state, setState] = useState<Type>(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<typeof useHookName>;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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
|
||||||
+444
@@ -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<HTMLDivElement | null>;
|
||||||
|
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 (
|
||||||
|
<div ref={mazeGame.containerRef}>
|
||||||
|
{/* Game renders here */}
|
||||||
|
<button onClick={mazeGame.startGame}>Start</button>
|
||||||
|
<div>Status: {mazeGame.gameStatus}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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 (
|
||||||
|
<div className="relative mx-auto my-4 w-fit">
|
||||||
|
<div ref={containerRef} className="relative" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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 (
|
||||||
|
<div className="relative mx-auto my-4 w-fit">
|
||||||
|
<div ref={containerRef} className="relative" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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 (
|
||||||
|
<div className="relative mx-auto my-4 w-fit">
|
||||||
|
<div ref={containerRef} className="relative" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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<HTMLDivElement | null>;
|
||||||
|
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
|
||||||
@@ -2,7 +2,8 @@ import { useEffect } from "react";
|
|||||||
import type { UseGameType } from "@vimazing/vim-maze";
|
import type { UseGameType } from "@vimazing/vim-maze";
|
||||||
|
|
||||||
export const useKeyBindings = (gameManager: UseGameType) => {
|
export const useKeyBindings = (gameManager: UseGameType) => {
|
||||||
const { gameStatus, initGame, stopGame, clearLog, resetCount } = gameManager;
|
const { gameStatus, startGame, stopGame, clearLog } = gameManager;
|
||||||
|
console.log('gameStatus', gameStatus);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = (e: KeyboardEvent) => {
|
const handler = (e: KeyboardEvent) => {
|
||||||
@@ -10,12 +11,11 @@ export const useKeyBindings = (gameManager: UseGameType) => {
|
|||||||
if (e.key === "i" || e.key === "I") {
|
if (e.key === "i" || e.key === "I") {
|
||||||
if (["waiting", "game-over", "game-won"].includes(gameStatus)) {
|
if (["waiting", "game-over", "game-won"].includes(gameStatus)) {
|
||||||
clearLog();
|
clearLog();
|
||||||
resetCount();
|
|
||||||
if (gameStatus === "game-over" || gameStatus === "game-won") {
|
if (gameStatus === "game-over" || gameStatus === "game-won") {
|
||||||
stopGame();
|
stopGame();
|
||||||
setTimeout(() => initGame(true), 0);
|
setTimeout(() => startGame(), 0);
|
||||||
} else {
|
} else {
|
||||||
initGame(true);
|
startGame();
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -27,20 +27,18 @@ export const useKeyBindings = (gameManager: UseGameType) => {
|
|||||||
// cancel count or stop game
|
// cancel count or stop game
|
||||||
if (e.key === "Escape") {
|
if (e.key === "Escape") {
|
||||||
stopGame();
|
stopGame();
|
||||||
resetCount();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// quit
|
// quit
|
||||||
if (e.key === "q" || e.key === "Q") {
|
if (e.key === "q" || e.key === "Q") {
|
||||||
stopGame();
|
stopGame();
|
||||||
resetCount();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener("keydown", handler);
|
window.addEventListener("keydown", handler);
|
||||||
return () => window.removeEventListener("keydown", handler);
|
return () => window.removeEventListener("keydown", handler);
|
||||||
}, [gameStatus, initGame, stopGame, clearLog, resetCount]);
|
}, [gameStatus]);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+232
@@ -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; }
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,2 +1,4 @@
|
|||||||
export * from './types';
|
export * from './types';
|
||||||
export * from './useGame';
|
export * from './useGame';
|
||||||
|
export * from './useGameStatus';
|
||||||
|
export * from './useKeyBindings';
|
||||||
|
|||||||
+48
-12
@@ -1,11 +1,12 @@
|
|||||||
import type { RefObject } from 'react';
|
import type { RefObject } from 'react';
|
||||||
|
import { MazeRenderer, MazeGenerator } from "./useBoard";
|
||||||
export * from './useGame';
|
export * from './useGame';
|
||||||
export * from './useBoard';
|
export * from './useBoard';
|
||||||
export * from './useCursor';
|
export * from './useCursor';
|
||||||
|
|
||||||
export type GameStatus = 'waiting' | 'started' | 'hasKey' | 'paused' | 'game-over' | 'game-won'
|
export type GameStatus = 'waiting' | 'started' | 'hasKey' | 'paused' | 'game-over' | 'game-won'
|
||||||
|
|
||||||
export type GameManager = { //return type of useGame
|
export type GameManager = {
|
||||||
containerRef: RefObject<HTMLDivElement | null>;
|
containerRef: RefObject<HTMLDivElement | null>;
|
||||||
gameStatus: GameStatus;
|
gameStatus: GameStatus;
|
||||||
renderBoard: () => void;
|
renderBoard: () => void;
|
||||||
@@ -14,21 +15,18 @@ export type GameManager = { //return type of useGame
|
|||||||
quitGame: () => void;
|
quitGame: () => void;
|
||||||
|
|
||||||
cursor: Cursor;
|
cursor: Cursor;
|
||||||
|
keyManager: GameKeyManager;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Cursor = { //return type of useCursor (currently useHero)
|
|
||||||
position: () => Coord;
|
export type BoardManager = {
|
||||||
mode: () => CursorMode;
|
containerRef: RefObject<HTMLDivElement | null>
|
||||||
move: (dCols: number, dRows: number, count: number) => void;
|
mazeRef: RefObject<MazeCell[][]>
|
||||||
moveLeft: (count?: number) => void;
|
mazeInstanceRef: RefObject<MazeGenerator | null>
|
||||||
moveRight: (count?: number) => void;
|
rendererRef: RefObject<MazeRenderer | null>
|
||||||
moveUp: (count?: number) => void;
|
renderBoard: () => void
|
||||||
moveDown: (count?: number) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CursorMode = 'normal' | 'insert' | 'replace' | 'visual' | 'visual-line';
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export type PositionTag = "entrance" | "exit" | "key" | "hero";
|
export type PositionTag = "entrance" | "exit" | "key" | "hero";
|
||||||
|
|
||||||
@@ -52,3 +50,41 @@ export type GameOptions = {
|
|||||||
rows: number;
|
rows: number;
|
||||||
cols: 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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1 +1,3 @@
|
|||||||
export * from './useBoard';
|
export * from './useBoard';
|
||||||
|
export * from './MazeGenerator';
|
||||||
|
export * from './MazeRenderer';
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import type { RefObject } from 'react';
|
import type { RefObject } from 'react';
|
||||||
import { MazeGenerator } from "./MazeGenerator";
|
import { MazeGenerator } from "./MazeGenerator";
|
||||||
import { MazeRenderer } from "./MazeRenderer";
|
import { MazeRenderer } from "./MazeRenderer";
|
||||||
@@ -10,6 +10,13 @@ export function useBoard(cols: number, rows: number) {
|
|||||||
const mazeInstanceRef = useRef<MazeGenerator | null>(null);
|
const mazeInstanceRef = useRef<MazeGenerator | null>(null);
|
||||||
const rendererRef = useRef<MazeRenderer | null>(null);
|
const rendererRef = useRef<MazeRenderer | null>(null);
|
||||||
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (containerRef.current) {
|
||||||
|
renderBoard();
|
||||||
|
}
|
||||||
|
}, [containerRef]);
|
||||||
|
|
||||||
function renderBoard() {
|
function renderBoard() {
|
||||||
const container = containerRef.current;
|
const container = containerRef.current;
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
@@ -35,12 +42,3 @@ export function useBoard(cols: number, rows: number) {
|
|||||||
renderBoard,
|
renderBoard,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export type BoardManager = {
|
|
||||||
containerRef: RefObject<HTMLDivElement | null>
|
|
||||||
mazeRef: RefObject<MazeCell[][]>
|
|
||||||
mazeInstanceRef: RefObject<MazeGenerator | null>
|
|
||||||
rendererRef: RefObject<MazeRenderer | null>
|
|
||||||
renderBoard: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|||||||
+202
-7
@@ -1,27 +1,212 @@
|
|||||||
import { useRef } from 'react'
|
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<Coord>({ row: 0, col: 0 })
|
const position = useRef<Coord>({ row: 0, col: 0 })
|
||||||
const mode = useRef<CursorMode>('normal')
|
const mode = useRef<CursorMode>('normal')
|
||||||
|
const countRef = useRef<string>("");
|
||||||
|
const lastMotionRef = useRef<Motion | null>(null);
|
||||||
|
const lastKeyRef = useRef<string>("");
|
||||||
|
|
||||||
|
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) {
|
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) {
|
function moveLeft(count = 1) {
|
||||||
// TODO: implement move left
|
move(-1, 0, count);
|
||||||
}
|
}
|
||||||
|
|
||||||
function moveRight(count = 1) {
|
function moveRight(count = 1) {
|
||||||
// TODO: implement move right
|
move(1, 0, count);
|
||||||
}
|
}
|
||||||
|
|
||||||
function moveUp(count = 1) {
|
function moveUp(count = 1) {
|
||||||
// TODO: implement move up
|
move(0, -1, count);
|
||||||
}
|
}
|
||||||
|
|
||||||
function moveDown(count = 1) {
|
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 {
|
return {
|
||||||
@@ -31,7 +216,17 @@ export function useCursor(board: BoardManager): Cursor {
|
|||||||
moveLeft,
|
moveLeft,
|
||||||
moveRight,
|
moveRight,
|
||||||
moveUp,
|
moveUp,
|
||||||
moveDown
|
moveDown,
|
||||||
|
moveToStart,
|
||||||
|
moveToEnd,
|
||||||
|
moveToTop,
|
||||||
|
moveToBottom,
|
||||||
|
repeatLastMotion,
|
||||||
|
resetCount,
|
||||||
|
getCount,
|
||||||
|
setCount,
|
||||||
|
setLastKey,
|
||||||
|
getLastKey,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+7
-18
@@ -1,31 +1,19 @@
|
|||||||
import { useState } from 'react';
|
import type { GameManager, GameOptions } from './types';
|
||||||
import type { GameManager, GameOptions, GameStatus } from './types';
|
|
||||||
import { useBoard } from './useBoard';
|
import { useBoard } from './useBoard';
|
||||||
import { useCursor } from './useCursor';
|
import { useCursor } from './useCursor';
|
||||||
|
import { useKeyBindings } from './useKeyBindings';
|
||||||
|
import { useGameStatus } from './useGameStatus';
|
||||||
|
|
||||||
export function useGame(options: GameOptions, platformHook?: unknown): GameManager {
|
export function useGame(options: GameOptions, platformHook?: unknown): GameManager {
|
||||||
const { cols, rows } = options;
|
const { cols, rows } = options;
|
||||||
const [gameStatus, setGameStatus] = useState<GameStatus>('waiting');
|
|
||||||
const boardManager = useBoard(cols, rows);
|
const boardManager = useBoard(cols, rows);
|
||||||
const { containerRef, renderBoard } = boardManager;
|
const { containerRef, renderBoard } = boardManager;
|
||||||
const cursor = useCursor(boardManager);
|
const cursor = useCursor(boardManager);
|
||||||
|
const gameStatusManager = useGameStatus();
|
||||||
|
|
||||||
function startGame() {
|
const { gameStatus, startGame, togglePause, quitGame } = gameStatusManager;
|
||||||
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 keyManager = useKeyBindings({ cursor, gameStatus });
|
||||||
|
|
||||||
const gameManager = {
|
const gameManager = {
|
||||||
containerRef,
|
containerRef,
|
||||||
@@ -35,6 +23,7 @@ export function useGame(options: GameOptions, platformHook?: unknown): GameManag
|
|||||||
togglePause,
|
togglePause,
|
||||||
quitGame,
|
quitGame,
|
||||||
cursor,
|
cursor,
|
||||||
|
keyManager,
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof platformHook === 'function') {
|
if (typeof platformHook === 'function') {
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './useGameStatus';
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import type { GameStatus } from '../types';
|
||||||
|
|
||||||
|
export function useGameStatus() {
|
||||||
|
const [gameStatus, setGameStatus] = useState<GameStatus>('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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './useKeyBindings';
|
||||||
@@ -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<KeyLogEntry[]>([]);
|
||||||
|
const logRef = useRef<KeyLogEntry[]>([]);
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
Reference in New Issue
Block a user