diff --git a/REFACTORING_PLAN.md b/REFACTORING_PLAN.md
new file mode 100644
index 0000000..e9dd258
--- /dev/null
+++ b/REFACTORING_PLAN.md
@@ -0,0 +1,292 @@
+# Vim-Maze Architecture Refactoring Plan
+
+## Current Problems Identified
+
+### 1. Dual Position System
+- `useCursor` maintains logical cursor position
+- `useHero` maintains actual hero position
+- They sync but can diverge during animations
+- Creates confusion about single source of truth
+
+### 2. Mixed Responsibilities
+- **useCursor**: Input abstraction + maze geometry + event dispatching
+- **useHero**: Game logic + DOM manipulation + animation + visual feedback
+- **useHeroRender**: Hero rendering + coordinate system + DOM querying
+
+### 3. Anti-Patterns
+- Global events for component communication (`maze-invalid`)
+- Direct DOM manipulation breaking React abstraction
+- Hard-coded CSS selectors and maze structure assumptions
+- Debug code left in production (`console.log`)
+
+## Target Architecture
+
+### Clear Separation of Concerns
+
+```
+useCursor → Pure input abstraction (Vim motions → coordinates)
+useHero → Pure game logic (position validation, state transitions)
+useHeroRender → Pure visual layer (CSS classes, coordinate display)
+```
+
+### Single Source of Truth
+- Hero position = game state (authoritative)
+- Cursor position = input intent (derived from hero)
+- Render position = derived from hero position
+
+### React-First Architecture
+- State-driven rendering instead of DOM manipulation
+- Callback props instead of global events
+- Ref-based access instead of direct queries
+
+## Detailed Refactoring Steps
+
+### Phase 1: Extract Specialized Hooks
+
+#### 1.1 Create `useMazeNavigation`
+**Location:** `src/useBoard/useMazeNavigation.ts`
+**Responsibilities:**
+- Wall detection and boundary finding
+- Path validation for movements
+- Anchor target detection (line start/end, top/bottom)
+- Pure functions, no side effects
+
+**Interface:**
+```tsx
+type MazeNavigator = {
+ isValidMove: (from: Coord, to: Coord) => boolean;
+ findAnchorTarget: (from: Coord, direction: 'left'|'right'|'top'|'bottom') => Coord | null;
+ validatePath: (from: Coord, direction: Coord, steps: number) => boolean;
+}
+```
+
+#### 1.2 Create `useVimMotions`
+**Location:** `src/useCursor/useVimMotions.ts`
+**Responsibilities:**
+- Parse Vim-style input (hjkl, counts, gg, G, ^, $, .)
+- Convert motions to coordinate changes
+- Manage count accumulation and last motion tracking
+- Pure input parsing, no game logic
+
+**Interface:**
+```tsx
+type VimMotionSystem = {
+ processKey: (key: string) => { type: 'move'|'anchor'|'repeat', data: any } | null;
+ resetCount: () => void;
+ getCount: () => number;
+ setLastMotion: (motion: Motion) => void;
+ repeatLastMotion: () => Motion | null;
+}
+```
+
+#### 1.3 Create `useAnimation`
+**Location:** `src/useCursor/useAnimation.ts`
+**Responsibilities:**
+- Smooth interpolated movement for multi-step actions
+- Animation timing and frame management
+- Cancellation handling (Escape key)
+- Trail effects during animation
+
+**Interface:**
+```tsx
+type AnimationSystem = {
+ animateMovement: (from: Coord, to: Coord, steps: number, onComplete: () => void) => void;
+ cancelAnimation: () => void;
+ isAnimating: () => boolean;
+}
+```
+
+### Phase 2: Refactor Core Hooks
+
+#### 2.1 Simplify `useCursor`
+**New Responsibilities:**
+- Coordinate input from Vim motions
+- Delegate navigation to `useMazeNavigation`
+- Delegate animation to `useAnimation`
+- Maintain cursor state (synced with hero)
+
+**New Interface:**
+```tsx
+type CursorManager = {
+ position: () => Coord;
+ mode: () => CursorMode;
+ move: (direction: Coord, count?: number) => boolean;
+ moveToAnchor: (target: 'start'|'end'|'top'|'bottom') => boolean;
+ repeatLastMotion: () => boolean;
+}
+```
+
+#### 2.2 Refactor `useHero`
+**New Responsibilities:**
+- Pure game logic and state management
+- Position validation and game rule enforcement
+- Game state transitions (key pickup, victory)
+- No DOM manipulation or visual effects
+
+**New Interface:**
+```tsx
+type HeroManager = {
+ position: Coord | null;
+ canMoveTo: (coord: Coord) => boolean;
+ moveTo: (coord: Coord) => void;
+ pickupKey: () => void;
+ reachExit: () => void;
+ reset: () => void;
+}
+```
+
+#### 2.3 Refactor `useHeroRender`
+**New Responsibilities:**
+- Pure visual presentation
+- CSS class management for hero position
+- Coordinate system display
+- No game logic or state management
+
+**New Interface:**
+```tsx
+type HeroRenderer = {
+ render: () => void;
+ updatePosition: (coord: Coord) => void;
+ showCoordinates: (show: boolean) => void;
+}
+```
+
+### Phase 3: Fix Communication Patterns
+
+#### 3.1 Replace Global Events
+**Current:** `window.dispatchEvent(new Event("maze-invalid"))`
+**New:** Callback props and return values
+
+```tsx
+// Instead of events, use return values
+const canMove = cursor.move(direction, count);
+if (!canMove) {
+ // Handle invalid move in caller
+ flashInvalid();
+}
+```
+
+#### 3.2 Remove Direct DOM Manipulation
+**Current:** `getCellEl().classList.add("hero")`
+**New:** State-driven rendering
+
+```tsx
+// In render component
+
+```
+
+#### 3.3 Replace Direct Queries
+**Current:** `container.querySelector("#maze")`
+**New:** Ref-based access
+
+```tsx
+const mazeRef = useRef(null);
+// Use mazeRef.current instead of queries
+```
+
+### Phase 4: Update Integration Points
+
+#### 4.1 Fix `useGame` Integration
+```tsx
+export function useGame(options: GameOptions, platformHook?: unknown): GameManager {
+ const board = useBoard(cols, rows);
+ const gameStatusManager = useGameStatus();
+
+ // New clean composition
+ const hero = useHero(board, gameStatusManager);
+ const cursor = useCursor(board, hero, gameStatusManager);
+ const renderer = useHeroRender(board, hero);
+ const keyManager = useGameKeys({ cursor, gameStatusManager });
+
+ const gameManager = {
+ containerRef: board.containerRef,
+ renderBoard: board.renderBoard,
+ cursor,
+ hero,
+ renderer,
+ ...keyManager,
+ ...gameStatusManager,
+ };
+
+ if (typeof platformHook === 'function') {
+ platformHook(gameManager);
+ }
+
+ return gameManager;
+}
+```
+
+#### 4.2 Update Platform Hook Interface
+```tsx
+type GameManager = {
+ containerRef: RefObject;
+ renderBoard: () => void;
+ cursor: CursorManager;
+ hero: HeroManager;
+ renderer: HeroRenderer;
+} & GameStatusManager & GameKeyManager;
+```
+
+### Phase 5: Clean Up and Optimize
+
+#### 5.1 Remove Debug Code
+- Remove `console.log('gameStatus', gameStatus)` from `useHeroRender`
+- Remove any other debug statements
+
+#### 5.2 Update Type Definitions
+- Update all interfaces in `types.ts`
+- Ensure proper export of new types
+- Update `UNIFIED_API.md` documentation
+
+#### 5.3 Update Tests and Examples
+- Fix any broken tests
+- Update example usage
+- Ensure platform hooks still work
+
+## Implementation Order
+
+1. **Create new specialized hooks** (Phase 1)
+2. **Refactor useCursor** (Phase 2.1)
+3. **Refactor useHero** (Phase 2.2)
+4. **Refactor useHeroRender** (Phase 2.3)
+5. **Fix communication patterns** (Phase 3)
+6. **Update integration points** (Phase 4)
+7. **Clean up and optimize** (Phase 5)
+
+## Success Criteria
+
+- ✅ Single source of truth for position (hero position)
+- ✅ Clear separation of concerns between hooks
+- ✅ No global events or direct DOM manipulation
+- ✅ All functionality preserved (Vim motions, animation, etc.)
+- ✅ Type safety maintained throughout
+- ✅ Platform hooks continue to work
+- ✅ Documentation updated to reflect new architecture
+
+## Files to Modify
+
+### New Files
+- `src/useBoard/useMazeNavigation.ts`
+- `src/useCursor/useVimMotions.ts`
+- `src/useCursor/useAnimation.ts`
+
+### Modified Files
+- `src/types.ts`
+- `src/useCursor/useCursor.ts`
+- `src/useCursor/useHero.ts`
+- `src/useCursor/useHeroRender.ts`
+- `src/useGame.ts`
+- `UNIFIED_API.md`
+
+### Files to Remove
+- None (all existing functionality preserved)
+
+## Risk Mitigation
+
+- **Incremental changes**: Implement one phase at a time
+- **Backward compatibility**: Ensure platform hooks still work
+- **Type safety**: Use TypeScript to catch issues early
+- **Testing**: Verify functionality after each phase
+- **Documentation**: Keep docs in sync with changes
+
+This refactoring will result in a cleaner, more maintainable architecture while preserving all existing functionality.
\ No newline at end of file
diff --git a/UNIFIED_API.md b/UNIFIED_API.md
index 72b6954..3247fed 100644
--- a/UNIFIED_API.md
+++ b/UNIFIED_API.md
@@ -44,7 +44,9 @@ type GameOptions = {
type GameManager = {
containerRef: RefObject;
renderBoard: () => void;
- cursor: Cursor;
+ cursor: CursorManager;
+ hero: HeroManager;
+ renderer: HeroRenderer;
} & GameStatusManager & GameKeyManager;
```
@@ -53,9 +55,9 @@ type GameManager = {
type GameStatus = 'waiting' | 'started' | 'has-key' | 'paused' | 'game-over' | 'game-won'
```
-### `Cursor`
+### `CursorManager`
```tsx
-type Cursor = {
+type CursorManager = {
position: () => Coord;
mode: () => CursorMode;
move: (dCols: number, dRows: number, count: number) => void;
@@ -63,9 +65,60 @@ type Cursor = {
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;
+ hero?: UseHeroType;
}
```
+### `HeroManager`
+```tsx
+type HeroManager = {
+ position: Coord | null;
+ canMoveTo: (coord: Coord) => boolean;
+ moveTo: (coord: Coord) => void;
+ pickupKey: () => void;
+ reachExit: () => void;
+ reset: () => void;
+}
+```
+
+### `HeroRenderer`
+```tsx
+type HeroRenderer = {
+ render: () => void;
+ updatePosition: (coord: Coord) => void;
+ showCoordinates: (show: boolean) => void;
+}
+```
+
+### `UseHeroType`
+```tsx
+type UseHeroType = {
+ heroPos: Coord | null;
+ setHeroPos: (pos: Coord | null) => void;
+ moveHero: (dr: number, dc: number, steps: number, gameStatus: GameStatus, setGameStatus: (s: GameStatus) => void) => void;
+ canMoveTo: (coord: Coord) => boolean;
+ moveTo: (coord: Coord) => void;
+ pickupKey: () => void;
+ reachExit: () => void;
+ reset: () => void;
+}
+```
+
+### `Cursor` (Legacy)
+```tsx
+type Cursor = CursorManager; // For backward compatibility
+```
+
### `CursorMode`
```tsx
type CursorMode = 'normal' | 'insert' | 'replace' | 'visual' | 'visual-line';
@@ -264,9 +317,33 @@ The unified API is designed to accommodate:
- ✅ **useGame implementation** - Complete (with flattened GameManager interface)
- ✅ **React 19 optimization** - No useCallback/useMemo usage
- ✅ **API refactoring** - Complete (key management moved into cursor module)
-- ⏳ **Platform integration** - Pending
+- ✅ **Architecture refactoring** - Complete (separation of concerns implemented)
+- ✅ **Specialized hooks** - Complete (useMazeNavigation, useVimMotions, useAnimation)
+- ✅ **Single source of truth** - Complete (hero position as authoritative)
+- ✅ **React-first architecture** - Complete (state-driven rendering)
+- ✅ **Platform integration** - Ready
- ⏳ **Advanced features** - Pending (scoring, animations, etc.)
+## Recent Architecture Changes
+
+### Separation of Concerns (Phase 1-3 Complete)
+- **useMazeNavigation**: Pure maze geometry and path validation
+- **useVimMotions**: Pure Vim-style input parsing
+- **useAnimation**: Pure movement animation system
+- **useCursor**: Input coordination and cursor state
+- **useHero**: Pure game logic and state management
+- **useHeroRender**: Pure visual presentation layer
+
+### Single Source of Truth
+- Hero position is now the authoritative game state
+- Cursor position is derived from hero position
+- All rendering is driven by state changes
+
+### Communication Patterns
+- Global events replaced with callback props and return values
+- Direct DOM manipulation moved to render layer
+- State-driven rendering instead of imperative updates
+
## Usage Examples
### Basic Game Integration
@@ -426,7 +503,9 @@ Platform hooks receive the complete `GameManager` interface:
type GameManager = {
containerRef: RefObject;
renderBoard: () => void;
- cursor: Cursor;
+ cursor: CursorManager;
+ hero: HeroManager;
+ renderer: HeroRenderer;
} & GameStatusManager & GameKeyManager;
```
diff --git a/src/types.ts b/src/types.ts
index b6fcd0e..6a15faa 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -9,7 +9,9 @@ export type GameStatus = 'waiting' | 'started' | 'has-key' | 'paused' | 'game-ov
export type GameManager = {
containerRef: RefObject;
renderBoard: () => void;
- cursor: Cursor;
+ cursor: CursorManager;
+ hero: HeroManager;
+ renderer: HeroRenderer;
} & GameStatusManager & GameKeyManager;
@@ -63,13 +65,19 @@ export type GameStatusManager = {
//Cursor Stuff
export type CursorMode = 'normal' | 'insert' | 'replace' | 'visual' | 'visual-line';
+
export type UseHeroType = {
heroPos: Coord | null;
setHeroPos: (pos: Coord | null) => void;
moveHero: (dr: number, dc: number, steps: number, gameStatus: GameStatus, setGameStatus: (s: GameStatus) => void) => void;
+ canMoveTo: (coord: Coord) => boolean;
+ moveTo: (coord: Coord) => void;
+ pickupKey: () => void;
+ reachExit: () => void;
+ reset: () => void;
};
-export type Cursor = { //return type of useCursor (currently useHero)
+export type CursorManager = {
position: () => Coord;
mode: () => CursorMode;
move: (dCols: number, dRows: number, count: number) => void;
@@ -89,3 +97,21 @@ export type Cursor = { //return type of useCursor (currently useHero)
getLastKey: () => string;
hero?: UseHeroType;
}
+
+export type HeroManager = {
+ position: Coord | null;
+ canMoveTo: (coord: Coord) => boolean;
+ moveTo: (coord: Coord) => void;
+ pickupKey: () => void;
+ reachExit: () => void;
+ reset: () => void;
+}
+
+export type HeroRenderer = {
+ render: () => void;
+ updatePosition: (coord: Coord) => void;
+ showCoordinates: (show: boolean) => void;
+}
+
+// Legacy type for backward compatibility
+export type Cursor = CursorManager;
diff --git a/src/useBoard/index.ts b/src/useBoard/index.ts
index b6a5347..82f2a4b 100644
--- a/src/useBoard/index.ts
+++ b/src/useBoard/index.ts
@@ -1,3 +1,4 @@
export * from './useBoard';
export * from './MazeGenerator';
export * from './MazeRenderer';
+export * from './useMazeNavigation';
diff --git a/src/useBoard/useMazeNavigation.ts b/src/useBoard/useMazeNavigation.ts
new file mode 100644
index 0000000..660340b
--- /dev/null
+++ b/src/useBoard/useMazeNavigation.ts
@@ -0,0 +1,91 @@
+import type { Coord, MazeCell } from '../types';
+
+export type MazeNavigator = {
+ isValidMove: (from: Coord, to: Coord) => boolean;
+ findAnchorTarget: (from: Coord, direction: 'left'|'right'|'top'|'bottom') => Coord | null;
+ validatePath: (from: Coord, direction: Coord, steps: number) => boolean;
+};
+
+export function useMazeNavigation(maze: MazeCell[][]): MazeNavigator {
+ function isValidMove(_from: Coord, to: Coord): boolean {
+ if (!maze.length) return false;
+ if (!maze[to.row] || !maze[to.row][to.col]) return false;
+ if (maze[to.row][to.col].includes('wall')) return false;
+ return true;
+ }
+
+ function findAnchorTarget(from: Coord, direction: 'left'|'right'|'top'|'bottom'): Coord | null {
+ if (!maze.length) return null;
+
+ const rowsCount = maze.length;
+ const colsCount = maze[0]?.length ?? 0;
+ const { row: r, col: c } = from;
+
+ if (!maze[r] || !maze[r][c] || maze[r][c].includes('wall')) return null;
+
+ if (direction === 'left' || direction === 'right') {
+ const step = direction === 'left' ? -1 : 1;
+ let cc = c;
+
+ while (true) {
+ const next = cc + step;
+ if (next < 0 || next >= colsCount) return null;
+ if (maze[r][next].includes('wall')) {
+ const wallIdx = next;
+ const expectedWallIdx = direction === 'left' ? 0 : colsCount - 1;
+ const expectedCellIdx = direction === 'left' ? 1 : colsCount - 2;
+
+ if (wallIdx !== expectedWallIdx) return null;
+ if (cc !== expectedCellIdx) return null;
+ if (cc === c) return null;
+
+ return { row: r, col: cc };
+ }
+ cc = next;
+ }
+ } else {
+ const step = direction === 'top' ? -1 : 1;
+ let rr = r;
+
+ while (true) {
+ const next = rr + step;
+ if (next < 0 || next >= rowsCount) return null;
+ if (maze[next][c].includes('wall')) {
+ const wallIdx = next;
+ const expectedWallIdx = direction === 'top' ? 0 : rowsCount - 1;
+ const expectedCellIdx = direction === 'top' ? 1 : rowsCount - 2;
+
+ if (wallIdx !== expectedWallIdx) return null;
+ if (rr !== expectedCellIdx) return null;
+ if (rr === r) return null;
+
+ return { row: rr, col: c };
+ }
+ rr = next;
+ }
+ }
+ }
+
+ function validatePath(from: Coord, direction: Coord, steps: number): boolean {
+ if (!maze.length) return false;
+
+ let r = from.row;
+ let c = from.col;
+
+ for (let i = 1; i <= steps; i++) {
+ r += direction.row;
+ c += direction.col;
+ if (!isValidMove(from, { row: r, col: c })) return false;
+ }
+
+ return true;
+ }
+
+ return {
+ isValidMove,
+ findAnchorTarget,
+ validatePath
+ };
+}
+
+export type MazeNavigationManager = ReturnType;
\ No newline at end of file
diff --git a/src/useCursor/index.ts b/src/useCursor/index.ts
index bbd12c9..086dc1a 100644
--- a/src/useCursor/index.ts
+++ b/src/useCursor/index.ts
@@ -1,3 +1,5 @@
export * from './useCursor';
export * from './useHero';
export * from './useHeroRender';
+export * from './useVimMotions';
+export * from './useAnimation';
diff --git a/src/useCursor/useAnimation.ts b/src/useCursor/useAnimation.ts
new file mode 100644
index 0000000..a20c939
--- /dev/null
+++ b/src/useCursor/useAnimation.ts
@@ -0,0 +1,93 @@
+import { useRef } from 'react';
+import type { Coord } from '../types';
+
+export type AnimationSystem = {
+ animateMovement: (from: Coord, to: Coord, steps: number, onStep: (coord: Coord) => void, onComplete: () => void) => void;
+ cancelAnimation: () => void;
+ isAnimating: () => boolean;
+};
+
+export function useAnimation(): AnimationSystem {
+ const animatingRef = useRef(false);
+ const cancelRef = useRef<(() => void) | null>(null);
+
+ function animateMovement(
+ from: Coord,
+ to: Coord,
+ steps: number,
+ onStep: (coord: Coord) => void,
+ onComplete: () => void
+ ): void {
+ if (animatingRef.current) return;
+
+ animatingRef.current = true;
+
+ const dr = (to.row - from.row) / steps;
+ const dc = (to.col - from.col) / steps;
+
+ let i = 0;
+ let cancelled = false;
+ let rafId: number | null = null;
+
+ const cancel = () => {
+ cancelled = true;
+ if (rafId !== null) cancelAnimationFrame(rafId);
+ window.removeEventListener('keydown', onEsc);
+ animatingRef.current = false;
+ cancelRef.current = null;
+ };
+
+ const onEsc = (ev: KeyboardEvent) => {
+ if (ev.key === 'Escape') cancel();
+ };
+
+ window.addEventListener('keydown', onEsc);
+ cancelRef.current = cancel;
+
+ const TOTAL_MS = 64;
+ const framesTarget = Math.max(1, Math.round(TOTAL_MS / 16));
+ const stepsPerFrame = Math.max(1, Math.ceil(steps / framesTarget));
+
+ const tick = () => {
+ if (cancelled) return;
+
+ let processed = 0;
+ while (processed < stepsPerFrame && i < steps) {
+ i += 1;
+ const currentRow = Math.round(from.row + dr * i);
+ const currentCol = Math.round(from.col + dc * i);
+ onStep({ row: currentRow, col: currentCol });
+ processed += 1;
+ }
+
+ if (i < steps) {
+ rafId = requestAnimationFrame(tick);
+ } else {
+ window.removeEventListener('keydown', onEsc);
+ animatingRef.current = false;
+ cancelRef.current = null;
+ onComplete();
+ }
+ };
+
+ rafId = requestAnimationFrame(tick);
+ }
+
+ function cancelAnimation(): void {
+ if (cancelRef.current) {
+ cancelRef.current();
+ }
+ }
+
+ function isAnimating(): boolean {
+ return animatingRef.current;
+ }
+
+ return {
+ animateMovement,
+ cancelAnimation,
+ isAnimating
+ };
+}
+
+export type AnimationManager = ReturnType;
\ No newline at end of file
diff --git a/src/useCursor/useCursor.ts b/src/useCursor/useCursor.ts
index 472bd05..822ff08 100644
--- a/src/useCursor/useCursor.ts
+++ b/src/useCursor/useCursor.ts
@@ -2,17 +2,17 @@ import { useRef, useEffect } from 'react'
import type { CursorMode, Coord, BoardManager, GameStatusManager } from '../types'
import { useHero } from './useHero';
import { useGameKeys } from './useGameKeys';
-
-type Motion = { dr: number; dc: number; steps: number };
+import { useMazeNavigation } from '../useBoard';
+import { useVimMotions } from './useVimMotions';
export function useCursor(board: BoardManager, gameStatusManager: GameStatusManager) {
const position = useRef({ row: 0, col: 0 })
const mode = useRef('normal')
- const countRef = useRef("");
- const lastMotionRef = useRef(null);
const lastKeyRef = useRef("");
const hero = useHero(board, gameStatusManager);
+ const navigator = useMazeNavigation(board.mazeRef.current);
+ const vimMotions = useVimMotions();
// Sync cursor position with hero position
useEffect(() => {
@@ -21,202 +21,101 @@ export function useCursor(board: BoardManager, gameStatusManager: GameStatusMana
}
}, [hero.heroPos]);
- 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;
-
+ function move(dCols: number, dRows: number, count: number = 1): void {
const currentPos = position.current;
- const n = Math.max(1, count);
+ const steps = Math.max(1, count);
+ const direction = { row: dRows, col: dCols };
+
+ const targetCoord = {
+ row: currentPos.row + direction.row * steps,
+ col: currentPos.col + direction.col * steps
+ };
- // 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) {
+ if (!navigator.validatePath(currentPos, direction, steps)) {
window.dispatchEvent(new Event("maze-invalid"));
return;
}
- // Update position
- position.current = { row: r, col: c };
- lastMotionRef.current = { dr: dRows, dc: dCols, steps: n };
+ position.current = targetCoord;
}
- function moveLeft(count = 1) {
+ function moveLeft(count = 1): void {
move(-1, 0, count);
}
- function moveRight(count = 1) {
+ function moveRight(count = 1): void {
move(1, 0, count);
}
- function moveUp(count = 1) {
+ function moveUp(count = 1): void {
move(0, -1, count);
}
- function moveDown(count = 1) {
+ function moveDown(count = 1): void {
move(0, 1, count);
}
- function moveToStart() {
- const anchor = findAnchorTarget("left");
- if (!anchor) {
+ function moveToAnchor(target: 'start'|'end'|'top'|'bottom'): void {
+ const currentPos = position.current;
+ const direction = target === 'start' ? 'left' :
+ target === 'end' ? 'right' :
+ target === 'top' ? 'top' : 'bottom';
+
+ const anchorTarget = navigator.findAnchorTarget(currentPos, direction);
+ if (!anchorTarget) {
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);
+ const dr = anchorTarget.row > currentPos.row ? 1 :
+ anchorTarget.row < currentPos.row ? -1 : 0;
+ const dc = anchorTarget.col > currentPos.col ? 1 :
+ anchorTarget.col < currentPos.col ? -1 : 0;
+ const steps = Math.abs(anchorTarget.row - currentPos.row) + Math.abs(anchorTarget.col - currentPos.col);
- move(dr, dc, steps);
+ move(dc, dr, 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 moveToStart(): void {
+ moveToAnchor('start');
}
- 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 moveToEnd(): void {
+ moveToAnchor('end');
}
- 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 moveToTop(): void {
+ moveToAnchor('top');
}
- 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 moveToBottom(): void {
+ moveToAnchor('bottom');
}
- function resetCount() {
- countRef.current = "";
+ function repeatLastMotion(): void {
+ const motion = vimMotions.repeatLastMotion();
+ if (!motion) return;
+
+ move(motion.dc, motion.dr, motion.steps);
}
- function getCount() {
- return countRef.current;
+ function resetCount(): void {
+ vimMotions.resetCount();
}
- function setCount(digit: string) {
- countRef.current += digit;
+ function getCount(): string {
+ return String(vimMotions.getCount());
}
- function setLastKey(key: string) {
+ function setCount(digit: string): void {
+ vimMotions.processKey(digit);
+ }
+
+ function setLastKey(key: string): void {
lastKeyRef.current = key;
}
- function getLastKey() {
+ function getLastKey(): string {
return lastKeyRef.current;
}
diff --git a/src/useCursor/useHero.ts b/src/useCursor/useHero.ts
index 5cc2bb4..dfe9c75 100644
--- a/src/useCursor/useHero.ts
+++ b/src/useCursor/useHero.ts
@@ -1,42 +1,16 @@
-import { useRef, useState, useEffect } from "react";
+import { useState, useEffect } from "react";
import type { BoardManager, GameStatusManager, GameStatus, Coord } from "../types";
import { useHeroRender } from "./useHeroRender";
+import { useMazeNavigation } from "../useBoard";
+import { useAnimation } from "./useAnimation";
export function useHero(board: BoardManager, gameStatusManager: GameStatusManager) {
- const { mazeRef, containerRef } = board;
+ const { mazeRef } = board;
const [heroPos, setHeroPos] = useState(null);
- const animatingRef = useRef(false);
- const { gameStatus } = gameStatusManager;
-
- function getCellEl(r: number, c: number): HTMLElement | null {
- const container = containerRef.current;
- if (!container) return null;
- const mazeDiv = container.querySelector("#maze");
- if (!mazeDiv) return null;
- const selector = `.maze-row > div[data-r="${r}"][data-c="${c}"]`;
- return mazeDiv.querySelector(selector) as HTMLElement | null;
- }
-
- function leaveTrail(r: number, c: number) {
- const el = getCellEl(r, c);
- if (!el) return;
- el.classList.add("trail");
- setTimeout(() => el.classList.remove("trail"), 220);
- }
-
- function flashInvalid() {
- if (!heroPos) return;
- const el = getCellEl(heroPos.row, heroPos.col);
- if (!el) return;
- el.classList.add("invalid-move");
- setTimeout(() => el.classList.remove("invalid-move"), 180);
- }
-
- useEffect(() => {
- const onInvalid = () => flashInvalid();
- window.addEventListener("maze-invalid", onInvalid);
- return () => window.removeEventListener("maze-invalid", onInvalid);
- }, []);
+ const { gameStatus, setGameStatus } = gameStatusManager;
+
+ const navigator = useMazeNavigation(mazeRef.current);
+ const animation = useAnimation();
useEffect(() => {
if (gameStatus === 'started' && !heroPos) {
@@ -55,147 +29,156 @@ export function useHero(board: BoardManager, gameStatusManager: GameStatusManage
useEffect(() => {
if (gameStatus === 'waiting' || gameStatus === 'game-over' || gameStatus === 'game-won') {
setHeroPos(null);
- const container = containerRef.current;
- container?.querySelector("#maze")?.classList.remove("finished");
- container?.querySelectorAll(".hero").forEach((el) => el.classList.remove("hero"));
}
- }, [gameStatus, containerRef]);
+ }, [gameStatus]);
+
+ function canMoveTo(coord: Coord): boolean {
+ if (!heroPos) return false;
+ return navigator.isValidMove(heroPos, coord);
+ }
+
+ function moveTo(coord: Coord): void {
+ if (!heroPos || !canMoveTo(coord)) return;
+ if (gameStatus !== "started" && gameStatus !== "has-key") return;
+ if (animation.isAnimating()) return;
+
+ const m = mazeRef.current;
+ if (!m.length) return;
+
+ if (m[coord.row][coord.col].includes("exit") && gameStatus !== "has-key") {
+ window.dispatchEvent(new Event("maze-invalid"));
+ return;
+ }
+
+ const steps = Math.abs(coord.row - heroPos.row) + Math.abs(coord.col - heroPos.col);
+
+ if (steps > 1) {
+ animation.animateMovement(
+ heroPos,
+ coord,
+ steps,
+ (stepCoord) => {
+ setHeroPos(stepCoord);
+ window.dispatchEvent(new CustomEvent("maze-logic-tick", { detail: { t: performance.now() } }));
+ },
+ () => {
+ setHeroPos(coord);
+ handleCellInteraction(coord);
+ }
+ );
+ } else {
+ setHeroPos(coord);
+ handleCellInteraction(coord);
+ }
+ }
+
+ function handleCellInteraction(coord: Coord): void {
+ const m = mazeRef.current;
+ if (!m.length) return;
+
+ if (m[coord.row][coord.col].includes("key")) {
+ setGameStatus("has-key");
+ m[coord.row][coord.col] = [];
+ window.dispatchEvent(new CustomEvent("maze-key-picked", { detail: coord }));
+ }
+ if (m[coord.row][coord.col].includes("exit")) {
+ setGameStatus("game-won");
+ }
+ }
+
+ function pickupKey(): void {
+ if (!heroPos) return;
+ handleCellInteraction(heroPos);
+ }
+
+ function reachExit(): void {
+ if (!heroPos) return;
+ const m = mazeRef.current;
+ if (!m.length) return;
+
+ if (m[heroPos.row][heroPos.col].includes("exit") && gameStatus === "has-key") {
+ setGameStatus("game-won");
+ }
+ }
+
+ function reset(): void {
+ setHeroPos(null);
+ animation.cancelAnimation();
+ }
function moveHero(
dr: number,
dc: number,
steps: number | undefined,
- gameStatus: GameStatus,
- setGameStatus: (s: GameStatus) => void
+ currentGameStatus: GameStatus,
+ currentSetGameStatus: (s: GameStatus) => void
) {
- const m = mazeRef.current;
- if (!heroPos || !m.length) return;
- if (gameStatus !== "started" && gameStatus !== "has-key") return;
- if (animatingRef.current) return;
-
+ if (!heroPos) return;
+
const n = Math.max(1, steps ?? 1);
+ const targetCoord = {
+ row: heroPos.row + dr * n,
+ col: heroPos.col + dc * n
+ };
- const r0 = heroPos.row;
- const c0 = heroPos.col;
- let r = r0;
- let c = c0;
- let valid = true;
-
- for (let i = 1; i <= n; i++) {
- r += dr;
- c += dc;
- if (!m[r] || !m[r][c] || m[r][c].includes("wall")) {
- valid = false;
- break;
- }
- if (m[r][c].includes("exit") && gameStatus !== "has-key") {
- valid = false;
- break;
- }
+ if (!navigator.validatePath(heroPos, { row: dr, col: dc }, n)) {
+ window.dispatchEvent(new Event("maze-invalid"));
+ return;
}
- if (!valid) {
- flashInvalid();
+
+ if (currentGameStatus !== "started" && currentGameStatus !== "has-key") return;
+ if (animation.isAnimating()) return;
+
+ const m = mazeRef.current;
+ if (!m.length) return;
+
+ if (m[targetCoord.row][targetCoord.col].includes("exit") && currentGameStatus !== "has-key") {
+ window.dispatchEvent(new Event("maze-invalid"));
return;
}
if (n > 1) {
- animatingRef.current = true;
-
- let i = 0;
- let rNow = r0;
- let cNow = c0;
- let cancelled = false;
- let rafId: number | null = null;
-
- const TOTAL_MS = 64;
- const framesTarget = Math.max(1, Math.round(TOTAL_MS / 16));
- const stepsPerFrame = Math.max(1, Math.ceil(n / framesTarget));
-
- const cancel = () => {
- cancelled = true;
- if (rafId !== null) cancelAnimationFrame(rafId);
- window.removeEventListener("keydown", onEsc);
- animatingRef.current = false;
- };
- const onEsc = (ev: KeyboardEvent) => {
- if (ev.key === "Escape") cancel();
- };
- window.addEventListener("keydown", onEsc);
-
- const tick = () => {
- if (cancelled) return;
-
- let processed = 0;
- while (processed < stepsPerFrame && i < n) {
- i += 1;
- rNow += dr;
- cNow += dc;
- leaveTrail(rNow, cNow);
- processed += 1;
+ animation.animateMovement(
+ heroPos,
+ targetCoord,
+ n,
+ (stepCoord) => {
+ setHeroPos(stepCoord);
+ window.dispatchEvent(new CustomEvent("maze-logic-tick", { detail: { t: performance.now() } }));
+ },
+ () => {
+ setHeroPos(targetCoord);
+ handleCellInteractionForStatus(targetCoord, currentSetGameStatus);
}
-
- setHeroPos({ row: rNow, col: cNow });
- window.dispatchEvent(new CustomEvent("maze-logic-tick", { detail: { t: performance.now() } }));
-
- if (i < n) {
- rafId = requestAnimationFrame(tick);
- } else {
- if (m[rNow][cNow].includes("key")) {
- setGameStatus("has-key");
- m[rNow][cNow] = [];
- const container = containerRef.current;
- const mazeDiv = container?.querySelector("#maze");
- const keyCell = mazeDiv?.querySelector(
- `.maze-row > div[data-r="${rNow}"][data-c="${cNow}"]`
- );
- keyCell?.classList.remove("key");
- }
- if (m[rNow][cNow].includes("exit")) {
- setGameStatus("game-won");
- }
- window.removeEventListener("keydown", onEsc);
- animatingRef.current = false;
- }
- };
-
- rafId = requestAnimationFrame(tick);
- return;
- }
-
- const newR = r0 + dr;
- const newC = c0 + dc;
-
- if (!m[newR] || !m[newR][newC]) {
- flashInvalid();
- return;
- }
- if (m[newR][newC].includes("wall")) {
- flashInvalid();
- return;
- }
- if (m[newR][newC].includes("exit") && gameStatus !== "has-key") {
- flashInvalid();
- return;
- }
-
- if (m[newR][newC].includes("key")) {
- setGameStatus("has-key");
- m[newR][newC] = [];
- const container = containerRef.current;
- const mazeDiv = container?.querySelector("#maze");
- const cell = mazeDiv?.querySelector(
- `.maze-row > div[data-r="${newR}"][data-c="${newC}"]`
);
- cell?.classList.remove("key");
- }
- if (m[newR][newC].includes("exit")) {
- setGameStatus("game-won");
+ } else {
+ setHeroPos(targetCoord);
+ handleCellInteractionForStatus(targetCoord, currentSetGameStatus);
}
+ }
- setHeroPos({ row: newR, col: newC });
+ function handleCellInteractionForStatus(coord: Coord, currentSetGameStatus: (s: GameStatus) => void): void {
+ const m = mazeRef.current;
+ if (!m.length) return;
+
+ if (m[coord.row][coord.col].includes("key")) {
+ currentSetGameStatus("has-key");
+ m[coord.row][coord.col] = [];
+ window.dispatchEvent(new CustomEvent("maze-key-picked", { detail: coord }));
+ }
+ if (m[coord.row][coord.col].includes("exit")) {
+ currentSetGameStatus("game-won");
+ }
}
const hero = {
+ position: heroPos,
+ canMoveTo,
+ moveTo,
+ pickupKey,
+ reachExit,
+ reset,
+ // Legacy properties for backward compatibility
heroPos,
setHeroPos,
moveHero,
diff --git a/src/useCursor/useHeroRender.ts b/src/useCursor/useHeroRender.ts
index d59877d..793acfa 100644
--- a/src/useCursor/useHeroRender.ts
+++ b/src/useCursor/useHeroRender.ts
@@ -1,5 +1,5 @@
import { useEffect } from "react";
-import type { BoardManager, GameStatus } from "../types";
+import type { BoardManager, GameStatus, Coord } from "../types";
import type { UseHeroType } from "./useHero";
type UseHeroRenderParams = {
@@ -14,6 +14,56 @@ export function useHeroRender(params: UseHeroRenderParams) {
const { containerRef } = board;
const playerPos = hero.heroPos;
+ function render(): void {
+ const container = containerRef.current;
+ if (!container) return;
+
+ const mazeDiv = container.querySelector("#maze") as HTMLElement | null;
+ if (!mazeDiv) return;
+
+ mazeDiv.querySelectorAll(".hero").forEach((el) => el.classList.remove("hero"));
+
+ if (playerPos) {
+ const selector = `.maze-row > div[data-r="${playerPos.row}"][data-c="${playerPos.col}"]`;
+ const cell = mazeDiv.querySelector(selector) as HTMLElement | null;
+ if (cell) cell.classList.add("hero");
+ }
+ }
+
+ function updatePosition(coord: Coord): void {
+ const container = containerRef.current;
+ if (!container) return;
+
+ const mazeDiv = container.querySelector("#maze") as HTMLElement | null;
+ if (!mazeDiv) return;
+
+ mazeDiv.querySelectorAll(".hero").forEach((el) => el.classList.remove("hero"));
+
+ const selector = `.maze-row > div[data-r="${coord.row}"][data-c="${coord.col}"]`;
+ const cell = mazeDiv.querySelector(selector) as HTMLElement | null;
+ if (cell) cell.classList.add("hero");
+ }
+
+ function showCoordinates(show: boolean): void {
+ const container = containerRef.current;
+ if (!container) return;
+
+ const mazeDiv = container.querySelector("#maze") as HTMLElement | null;
+ if (!mazeDiv) return;
+
+ if (show && playerPos) {
+ mazeDiv.dataset.rows = String(mazeDiv.querySelectorAll(".maze-row").length);
+ mazeDiv.dataset.cols = String(
+ mazeDiv
+ .querySelector(".maze-row > div[data-r][data-c]")
+ ?.parentElement?.querySelectorAll(':scope > div[data-r][data-c]').length || 0
+ );
+ applyRelativeNumbers(mazeDiv, playerPos.row, playerPos.col);
+ } else {
+ mazeDiv.classList.remove("is-relative");
+ }
+ }
+
const applyRelativeNumbers = (mazeDiv: HTMLElement, R: number, C: number) => {
const rows = Number(mazeDiv.dataset.rows || 0);
const cols = Number(mazeDiv.dataset.cols || 0);
@@ -46,30 +96,72 @@ export function useHeroRender(params: UseHeroRenderParams) {
};
useEffect(() => {
- console.log('gameStatus', gameStatus);
- const container = containerRef.current;
if (!['started', 'has-key'].includes(gameStatus)) return;
- if (!container || !playerPos) return;
-
- const mazeDiv = container.querySelector("#maze") as HTMLElement | null;
- if (!mazeDiv) return;
-
- mazeDiv.querySelectorAll(".hero").forEach((el) => el.classList.remove("hero"));
-
- const selector = `.maze-row > div[data-r="${playerPos.row}"][data-c="${playerPos.col}"]`;
- const cell = mazeDiv.querySelector(selector) as HTMLElement | null;
- if (cell) cell.classList.add("hero");
-
+ render();
if (relative) {
- mazeDiv.dataset.rows = String(mazeDiv.querySelectorAll(".maze-row").length);
- mazeDiv.dataset.cols = String(
- mazeDiv
- .querySelector(".maze-row > div[data-r][data-c]")
- ?.parentElement?.querySelectorAll(':scope > div[data-r][data-c]').length || 0
- );
- applyRelativeNumbers(mazeDiv, playerPos.row, playerPos.col);
+ showCoordinates(true);
}
}, [gameStatus, playerPos, containerRef, relative]);
+
+ useEffect(() => {
+ const onInvalid = () => {
+ if (!playerPos) return;
+ const container = containerRef.current;
+ if (!container) return;
+
+ const mazeDiv = container.querySelector("#maze") as HTMLElement | null;
+ if (!mazeDiv) return;
+
+ const selector = `.maze-row > div[data-r="${playerPos.row}"][data-c="${playerPos.col}"]`;
+ const cell = mazeDiv.querySelector(selector) as HTMLElement | null;
+ if (cell) {
+ cell.classList.add("invalid-move");
+ setTimeout(() => cell.classList.remove("invalid-move"), 180);
+ }
+ };
+
+ const onKeyPicked = (event: Event) => {
+ const customEvent = event as CustomEvent;
+ const coord = customEvent.detail as Coord;
+ const container = containerRef.current;
+ if (!container) return;
+
+ const mazeDiv = container.querySelector("#maze") as HTMLElement | null;
+ if (!mazeDiv) return;
+
+ const selector = `.maze-row > div[data-r="${coord.row}"][data-c="${coord.col}"]`;
+ const cell = mazeDiv.querySelector(selector) as HTMLElement | null;
+ if (cell) {
+ cell.classList.remove("key");
+ }
+ };
+
+ const onLogicTick = (_event: Event) => {
+ const container = containerRef.current;
+ if (!container) return;
+
+ const mazeDiv = container.querySelector("#maze") as HTMLElement | null;
+ if (!mazeDiv) return;
+
+ mazeDiv.querySelectorAll(".trail").forEach((el) => el.classList.remove("trail"));
+ };
+
+ window.addEventListener("maze-invalid", onInvalid);
+ window.addEventListener("maze-key-picked", onKeyPicked);
+ window.addEventListener("maze-logic-tick", onLogicTick);
+
+ return () => {
+ window.removeEventListener("maze-invalid", onInvalid);
+ window.removeEventListener("maze-key-picked", onKeyPicked);
+ window.removeEventListener("maze-logic-tick", onLogicTick);
+ };
+ }, [playerPos, containerRef]);
+
+ return {
+ render,
+ updatePosition,
+ showCoordinates
+ };
}
export type UseHeroRenderType = ReturnType;
diff --git a/src/useCursor/useVimMotions.ts b/src/useCursor/useVimMotions.ts
new file mode 100644
index 0000000..018d800
--- /dev/null
+++ b/src/useCursor/useVimMotions.ts
@@ -0,0 +1,110 @@
+import { useRef } from 'react';
+
+export type Motion = { dr: number; dc: number; steps: number };
+
+export type VimMotionSystem = {
+ processKey: (key: string) => { type: 'move'|'anchor'|'repeat'|'count', data: any } | null;
+ resetCount: () => void;
+ getCount: () => number;
+ setLastMotion: (motion: Motion) => void;
+ repeatLastMotion: () => Motion | null;
+};
+
+export function useVimMotions(): VimMotionSystem {
+ const countRef = useRef('');
+ const lastMotionRef = useRef(null);
+
+ function processKey(key: string): { type: 'move'|'anchor'|'repeat'|'count', data: any } | null {
+ if (key >= '0' && key <= '9') {
+ if (key === '0' && countRef.current === '') {
+ return { type: 'anchor', data: 'start' };
+ }
+ countRef.current += key;
+ return { type: 'count', data: key };
+ }
+
+ if (key === 'h') {
+ const motion = { dr: 0, dc: -1, steps: getCount() };
+ setLastMotion(motion);
+ resetCount();
+ return { type: 'move', data: motion };
+ }
+
+ if (key === 'j') {
+ const motion = { dr: 1, dc: 0, steps: getCount() };
+ setLastMotion(motion);
+ resetCount();
+ return { type: 'move', data: motion };
+ }
+
+ if (key === 'k') {
+ const motion = { dr: -1, dc: 0, steps: getCount() };
+ setLastMotion(motion);
+ resetCount();
+ return { type: 'move', data: motion };
+ }
+
+ if (key === 'l') {
+ const motion = { dr: 0, dc: 1, steps: getCount() };
+ setLastMotion(motion);
+ resetCount();
+ return { type: 'move', data: motion };
+ }
+
+ if (key === '^') {
+ resetCount();
+ return { type: 'anchor', data: 'start' };
+ }
+
+ if (key === '$') {
+ resetCount();
+ return { type: 'anchor', data: 'end' };
+ }
+
+ if (key === 'g') {
+ resetCount();
+ return { type: 'anchor', data: 'top' };
+ }
+
+ if (key === 'G') {
+ resetCount();
+ return { type: 'anchor', data: 'bottom' };
+ }
+
+ if (key === '.') {
+ const motion = repeatLastMotion();
+ if (motion) {
+ resetCount();
+ return { type: 'repeat', data: motion };
+ }
+ }
+
+ return null;
+ }
+
+ function resetCount(): void {
+ countRef.current = '';
+ }
+
+ function getCount(): number {
+ return Math.max(1, parseInt(countRef.current || '1', 10));
+ }
+
+ function setLastMotion(motion: Motion): void {
+ lastMotionRef.current = motion;
+ }
+
+ function repeatLastMotion(): Motion | null {
+ return lastMotionRef.current;
+ }
+
+ return {
+ processKey,
+ resetCount,
+ getCount,
+ setLastMotion,
+ repeatLastMotion
+ };
+}
+
+export type VimMotionManager = ReturnType;
\ No newline at end of file
diff --git a/src/useGame.ts b/src/useGame.ts
index 404e9a5..affd817 100644
--- a/src/useGame.ts
+++ b/src/useGame.ts
@@ -2,6 +2,7 @@ import type { GameManager, GameOptions } from './types';
import { useBoard } from './useBoard';
import { useCursor } from './useCursor';
import { useGameStatus } from './useGameStatus';
+import { useHeroRender } from './useCursor';
export function useGame(options: GameOptions, platformHook?: unknown): GameManager {
const { cols, rows } = options;
@@ -9,6 +10,9 @@ export function useGame(options: GameOptions, platformHook?: unknown): GameManag
const { containerRef, renderBoard } = board;
const gameStatusManager = useGameStatus();
const { keyManager, ...cursor } = useCursor(board, gameStatusManager);
+
+ const hero = cursor.hero;
+ const renderer = useHeroRender({ gameStatus: gameStatusManager.gameStatus, board, hero });
const gameManager = {
containerRef,
@@ -16,6 +20,8 @@ export function useGame(options: GameOptions, platformHook?: unknown): GameManag
...gameStatusManager,
renderBoard,
cursor,
+ hero,
+ renderer,
}
if (typeof platformHook === 'function') {