diff --git a/PROJECT_DESCRIPTION.md b/PROJECT_DESCRIPTION.md index d586f5d..b545320 100644 --- a/PROJECT_DESCRIPTION.md +++ b/PROJECT_DESCRIPTION.md @@ -39,7 +39,7 @@ npm run build | Procedural textures & terrain | `perlin`, `perlin3D`, `simplex2D`, `simplex3D`, `worley`, `worleySample`, `waveFunctionCollapse`, `cellularAutomataCave`, `poissonDiskSampling`, `computeVoronoiDiagram`, `diamondSquare`, `generateLSystem`, `generateBspDungeon`, `generateRecursiveMaze`, `generatePrimMaze`, `generateKruskalMaze`, `generateWilsonMaze`, `generateAldousBroderMaze`, `generateRecursiveDivisionMaze` | `procedural/*.ts` | `examples/simplex.ts`, `examples/worley.ts`, `examples/waveFunctionCollapse.ts`, `examples/cellularAutomata.ts`, `examples/poissonDisk.ts`, `examples/voronoi.ts`, `examples/diamondSquare.ts`, `examples/lSystem.ts`, `examples/dungeonBsp.ts`, `examples/mazeRecursive.ts`, `examples/mazePrim.ts`, `examples/mazeKruskal.ts`, `examples/mazeWilson.ts`, `examples/mazeAldous.ts`, `examples/mazeDivision.ts` | | Spatial queries & collision | `Quadtree`, `aabbCollision`, `aabbIntersection`, `satCollision`, `circleRayIntersection`, `sweptAABB` | `spatial/*.ts` | `examples/sat.ts` | | Web performance & UI throttling | `debounce`, `throttle`, `LRUCache`, `memoize`, `deduplicateRequest`, `clearRequestDedup`, `calculateVirtualRange`, `createWeightedAliasSampler`, `createObjectPool`, `fisherYatesShuffle` | `util/*.ts` | `examples/requestDedup.ts`, `examples/virtualScroll.ts`, `examples/weightedAlias.ts`, `examples/objectPool.ts`, `examples/fisherYates.ts` | -| Gameplay systems | `createDeltaTimeManager`, `createFixedTimestepLoop`, `createCamera2D`, `createParticleSystem`, `createSpriteAnimation`, `createTweenSystem`, `createPlatformerController` | `util/deltaTime.ts`, `util/fixedTimestep.ts`, `gameplay/camera2D.ts`, `gameplay/particleSystem.ts`, `gameplay/spriteAnimation.ts`, `gameplay/tween.ts`, `gameplay/platformerPhysics.ts` | `examples/deltaTime.ts`, `examples/fixedTimestep.ts`, `examples/camera2D.ts`, `examples/particleSystem.ts`, `examples/spriteAnimation.ts`, `examples/tween.ts`, `examples/platformerPhysics.ts` | +| Gameplay systems | `createDeltaTimeManager`, `createFixedTimestepLoop`, `createCamera2D`, `createParticleSystem`, `createSpriteAnimation`, `createTweenSystem`, `createPlatformerController`, `createTopDownController` | `util/deltaTime.ts`, `util/fixedTimestep.ts`, `gameplay/camera2D.ts`, `gameplay/particleSystem.ts`, `gameplay/spriteAnimation.ts`, `gameplay/tween.ts`, `gameplay/platformerPhysics.ts`, `gameplay/topDownMovement.ts` | `examples/deltaTime.ts`, `examples/fixedTimestep.ts`, `examples/camera2D.ts`, `examples/particleSystem.ts`, `examples/spriteAnimation.ts`, `examples/tween.ts`, `examples/platformerPhysics.ts`, `examples/topDownMovement.ts` | | Text & search | `fuzzySearch`, `fuzzyScore`, `Trie`, `binarySearch`, `levenshteinDistance` | `search/*.ts` | `examples/search.ts` | | Data transforms & diffing | `diff`, `deepClone`, `groupBy`, `diffJson`, `applyJsonDiff` | `data/*.ts` | `examples/jsonDiff.ts` | | Graph traversal | `graphBFS`, `graphDFS`, `topologicalSort` | `graph/traversal.ts` | `examples/graph.ts` | @@ -94,7 +94,7 @@ Consistency between runtime code, documentation, and TypeScript declarations kee - **Procedural:** 2D/3D Perlin, Worley noise, Wave Function Collapse tile synthesis. - **Spatial:** Quadtree, AABB helpers, SAT convex polygon collision. - **Performance utilities:** Debounce, throttle, LRU cache, memoize, request deduplication, virtual scrolling, weighted alias sampling, object pooling, Fisher–Yates shuffle. -- **Gameplay systems:** Delta-time manager, fixed timestep loop, 2D camera with smoothing and shake, particle system with configurable emitters, sprite animation controller with frame events, tween system with easing and repeats, platformer physics helper with coyote time and jump buffering. +- **Gameplay systems:** Delta-time manager, fixed timestep loop, 2D camera with smoothing and shake, particle system with configurable emitters, sprite animation controller with frame events, tween system with easing and repeats, platformer physics helper with coyote time and jump buffering, top-down movement controller with acceleration and drag. - **Search:** Fuzzy search + scoring, Trie-based autocomplete, binary search, Levenshtein distance. - **Data tools:** Diff operations (LCS), deep clone, groupBy, JSON diff/patch helpers. - **Graph:** BFS distance map, DFS traversal, topological sort. diff --git a/README.md b/README.md index 32cab89..b279fbd 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ CDN usage: | Spatial queries & collision | `Quadtree`, `aabbCollision`, `aabbIntersection`, `satCollision`, `circleRayIntersection`, `sweptAABB` | `spatial/*.ts` | `examples/sat.ts` | | AI behaviours & crowds | `seek`, `flee`, `arrive`, `pursue`, `wander`, `updateBoids`, `BehaviorTree`, `rvoStep` | `ai/steering.ts`, `ai/boids.ts`, `ai/behaviorTree.ts`, `ai/rvo.ts` | `examples/steering.ts`, `examples/boids.ts`, `examples/rvo.ts` | | Web performance & UI | `debounce`, `throttle`, `LRUCache`, `memoize`, `deduplicateRequest`, `clearRequestDedup`, `calculateVirtualRange`, `createWeightedAliasSampler`, `createObjectPool`, `fisherYatesShuffle` | `util/*.ts` | `examples/requestDedup.ts`, `examples/virtualScroll.ts`, `examples/weightedAlias.ts`, `examples/objectPool.ts`, `examples/fisherYates.ts` | -| Gameplay systems | `createDeltaTimeManager`, `createFixedTimestepLoop`, `createCamera2D`, `createParticleSystem`, `createSpriteAnimation`, `createTweenSystem`, `createPlatformerController` | `util/deltaTime.ts`, `util/fixedTimestep.ts`, `gameplay/camera2D.ts`, `gameplay/particleSystem.ts`, `gameplay/spriteAnimation.ts`, `gameplay/tween.ts`, `gameplay/platformerPhysics.ts` | `examples/deltaTime.ts`, `examples/fixedTimestep.ts`, `examples/camera2D.ts`, `examples/particleSystem.ts`, `examples/spriteAnimation.ts`, `examples/tween.ts`, `examples/platformerPhysics.ts` | +| Gameplay systems | `createDeltaTimeManager`, `createFixedTimestepLoop`, `createCamera2D`, `createParticleSystem`, `createSpriteAnimation`, `createTweenSystem`, `createPlatformerController`, `createTopDownController` | `util/deltaTime.ts`, `util/fixedTimestep.ts`, `gameplay/camera2D.ts`, `gameplay/particleSystem.ts`, `gameplay/spriteAnimation.ts`, `gameplay/tween.ts`, `gameplay/platformerPhysics.ts`, `gameplay/topDownMovement.ts` | `examples/deltaTime.ts`, `examples/fixedTimestep.ts`, `examples/camera2D.ts`, `examples/particleSystem.ts`, `examples/spriteAnimation.ts`, `examples/tween.ts`, `examples/platformerPhysics.ts`, `examples/topDownMovement.ts` | | Search & text | `fuzzySearch`, `fuzzyScore`, `Trie`, `binarySearch`, `levenshteinDistance` | `search/*.ts` | `examples/search.ts` | | Data & diff pipelines | `diff`, `deepClone`, `groupBy`, `diffJson`, `applyJsonDiff` | `data/*.ts` | `examples/jsonDiff.ts` | | Graph algorithms | `graphBFS`, `graphDFS`, `topologicalSort` | `graph/traversal.ts` | `examples/graph.ts` | @@ -53,7 +53,7 @@ npm run size # Enforce bundle size budget - Milestone 0.2 next targets crowd-flow integrations (RVO + flow fields) and behaviour-tree decorators for richer AI control. - Milestone 0.4 plans a procedural + gameplay systems toolkit (Wave Function Collapse, dungeon suite, L-systems, game loop, camera, particles, inventory, combat, save/load, and more). -Examples live under `examples/` and can be executed with `tsx`/`ts-node` or compiled for the browser. See `examples/astar.ts`, `examples/flowField.ts`, `examples/navMesh.ts`, `examples/cellularAutomata.ts`, `examples/poissonDisk.ts`, `examples/voronoi.ts`, `examples/diamondSquare.ts`, `examples/lSystem.ts`, `examples/dungeonBsp.ts`, `examples/mazeRecursive.ts`, `examples/mazePrim.ts`, `examples/mazeKruskal.ts`, `examples/mazeWilson.ts`, `examples/mazeAldous.ts`, `examples/mazeDivision.ts`, `examples/steering.ts`, `examples/boids.ts`, `examples/requestDedup.ts`, `examples/deltaTime.ts`, `examples/fixedTimestep.ts`, `examples/camera2D.ts`, `examples/particleSystem.ts`, `examples/spriteAnimation.ts`, `examples/tween.ts`, `examples/platformerPhysics.ts`, `examples/search.ts`, `examples/graph.ts`, `examples/geometry.ts`, `examples/bresenham.ts`, `examples/visual.ts`, `examples/sat.ts`, `examples/simplex.ts`, and `examples/worley.ts` for quick starts. The `examples` registry exported from `src/index.ts` provides a typed index you can traverse programmatically. +Examples live under `examples/` and can be executed with `tsx`/`ts-node` or compiled for the browser. See `examples/astar.ts`, `examples/flowField.ts`, `examples/navMesh.ts`, `examples/cellularAutomata.ts`, `examples/poissonDisk.ts`, `examples/voronoi.ts`, `examples/diamondSquare.ts`, `examples/lSystem.ts`, `examples/dungeonBsp.ts`, `examples/mazeRecursive.ts`, `examples/mazePrim.ts`, `examples/mazeKruskal.ts`, `examples/mazeWilson.ts`, `examples/mazeAldous.ts`, `examples/mazeDivision.ts`, `examples/steering.ts`, `examples/boids.ts`, `examples/requestDedup.ts`, `examples/deltaTime.ts`, `examples/fixedTimestep.ts`, `examples/camera2D.ts`, `examples/particleSystem.ts`, `examples/spriteAnimation.ts`, `examples/tween.ts`, `examples/platformerPhysics.ts`, `examples/topDownMovement.ts`, `examples/search.ts`, `examples/graph.ts`, `examples/geometry.ts`, `examples/bresenham.ts`, `examples/visual.ts`, `examples/sat.ts`, `examples/simplex.ts`, and `examples/worley.ts` for quick starts. The `examples` registry exported from `src/index.ts` provides a typed index you can traverse programmatically. ## Contributing 1. Fork the repository. diff --git a/ROADMAP.md b/ROADMAP.md index 2c468a5..0d2e651 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -56,7 +56,7 @@ - [x] Sprite animation controller (frame timing, events) - [x] Tween/lerp utility for smooth interpolation - [x] Platformer physics helper (gravity, coyote time, jump buffering) - - [ ] Top-down movement helper (8-direction) + - [x] Top-down movement helper (8-direction) - [ ] Tile map renderer helpers (chunking, layering, collision tags) - [ ] Shadowcasting field-of-view utilities and minimap helpers - **Systems for gameplay loops** diff --git a/docs/index.d.ts b/docs/index.d.ts index e22126a..b2f863c 100644 --- a/docs/index.d.ts +++ b/docs/index.d.ts @@ -1296,6 +1296,73 @@ export function createPlatformerController( initialState?: PlatformerCharacterState ): PlatformerController; +/** + * Top-down movement options. + * Use for: configuring acceleration, deceleration, and max speed for 2D characters. + * Import: gameplay/topDownMovement.ts + */ +export interface TopDownMovementOptions { + acceleration: number; + deceleration: number; + maxSpeed: number; + drag?: number; + normalizeDiagonal?: boolean; +} + +/** + * Top-down movement state snapshot. + * Use for: rendering and collision updates. + * Import: gameplay/topDownMovement.ts + */ +export interface TopDownState { + position: Vector2D; + velocity: Vector2D; + facing: Vector2D; +} + +/** + * Top-down movement input axes. + * Use for: feeding directional input (-1..1). + * Import: gameplay/topDownMovement.ts + */ +export interface TopDownInput { + x: number; + y: number; +} + +/** + * Top-down movement update payload. + * Use for: advancing the controller with delta time and current input. + * Import: gameplay/topDownMovement.ts + */ +export interface TopDownUpdateOptions { + delta: number; + input: TopDownInput; +} + +/** + * Top-down movement controller API. + * Use for: updating state, resetting, and retuning movement parameters. + * Import: gameplay/topDownMovement.ts + */ +export interface TopDownController { + update(options: TopDownUpdateOptions): TopDownState; + getState(): TopDownState; + reset(state?: Partial): void; + setOptions(options: Partial): void; +} + +/** + * Creates a top-down movement controller with acceleration and damping. + * Use for: eight-direction movement in action or RPG games. + * Performance: O(1) per update. + * Import: gameplay/topDownMovement.ts + */ +export function createTopDownController( + options: TopDownMovementOptions, + initialState?: TopDownState +): TopDownController; + /** * Least recently used cache. * Use for: memoizing responses, data loaders, pagination caches. diff --git a/examples/topDownMovement.ts b/examples/topDownMovement.ts new file mode 100644 index 0000000..4c23d41 --- /dev/null +++ b/examples/topDownMovement.ts @@ -0,0 +1,17 @@ +import { createTopDownController } from '../src/index.js'; + +const controller = createTopDownController({ + acceleration: 20, + deceleration: 18, + maxSpeed: 6, + drag: 0.1, +}); + +for (let frame = 0; frame < 10; frame += 1) { + const angle = (frame / 10) * Math.PI * 2; + const input = { x: Math.cos(angle), y: Math.sin(angle) }; + const state = controller.update({ delta: 1 / 30, input }); + console.log( + `frame ${frame}: position=(${state.position.x.toFixed(2)}, ${state.position.y.toFixed(2)}) velocity=(${state.velocity.x.toFixed(2)}, ${state.velocity.y.toFixed(2)})` + ); +} diff --git a/src/gameplay/topDownMovement.ts b/src/gameplay/topDownMovement.ts new file mode 100644 index 0000000..97215fb --- /dev/null +++ b/src/gameplay/topDownMovement.ts @@ -0,0 +1,217 @@ +import type { Vector2D } from '../types.js'; + +export interface TopDownState { + position: Vector2D; + velocity: Vector2D; + facing: Vector2D; +} + +export interface TopDownInput { + x: number; + y: number; +} + +export interface TopDownUpdateOptions { + delta: number; + input: TopDownInput; +} + +export interface TopDownMovementOptions { + acceleration: number; + deceleration: number; + maxSpeed: number; + drag?: number; + normalizeDiagonal?: boolean; +} + +export interface TopDownController { + update(options: TopDownUpdateOptions): TopDownState; + getState(): TopDownState; + reset(state?: Partial): void; + setOptions(options: Partial): void; +} + +interface InternalOptions { + acceleration: number; + deceleration: number; + maxSpeed: number; + drag: number; + normalizeDiagonal: boolean; +} + +function assertFinite(value: number, label: string): void { + if (typeof value !== 'number' || Number.isNaN(value) || !Number.isFinite(value)) { + throw new Error(`${label} must be a finite number.`); + } +} + +function normalizeOptions(options: TopDownMovementOptions): InternalOptions { + assertFinite(options.acceleration, 'acceleration'); + assertFinite(options.deceleration, 'deceleration'); + assertFinite(options.maxSpeed, 'maxSpeed'); + if (options.acceleration <= 0 || options.deceleration <= 0) { + throw new Error('acceleration and deceleration must be greater than 0.'); + } + if (options.maxSpeed <= 0) { + throw new Error('maxSpeed must be greater than 0.'); + } + const drag = options.drag ?? 0; + assertFinite(drag, 'drag'); + if (drag < 0) { + throw new Error('drag must be >= 0.'); + } + const normalizeDiagonal = options.normalizeDiagonal ?? true; + + return { + acceleration: options.acceleration, + deceleration: options.deceleration, + maxSpeed: options.maxSpeed, + drag, + normalizeDiagonal, + }; +} + +function cloneState(state: TopDownState): TopDownState { + return { + position: { x: state.position.x, y: state.position.y }, + velocity: { x: state.velocity.x, y: state.velocity.y }, + facing: { x: state.facing.x, y: state.facing.y }, + }; +} + +function magnitude(x: number, y: number): number { + return Math.hypot(x, y); +} + +function normalize(x: number, y: number): Vector2D { + const length = magnitude(x, y); + if (length === 0) { + return { x: 0, y: 0 }; + } + return { x: x / length, y: y / length }; +} + +/** + * Creates a top-down movement controller with acceleration and directional damping. + * Useful for: twin-stick or tile-based characters needing eight-direction movement. + */ +export function createTopDownController( + options: TopDownMovementOptions, + initialState: TopDownState = { + position: { x: 0, y: 0 }, + velocity: { x: 0, y: 0 }, + facing: { x: 1, y: 0 }, + } +): TopDownController { + let config = normalizeOptions(options); + const baseline = cloneState(initialState); + const state: TopDownState = cloneState(initialState); + + function setOptions(partial: Partial): void { + config = normalizeOptions({ + acceleration: partial.acceleration ?? config.acceleration, + deceleration: partial.deceleration ?? config.deceleration, + maxSpeed: partial.maxSpeed ?? config.maxSpeed, + drag: partial.drag ?? config.drag, + normalizeDiagonal: partial.normalizeDiagonal ?? config.normalizeDiagonal, + }); + } + + function update({ delta, input }: TopDownUpdateOptions): TopDownState { + assertFinite(delta, 'delta'); + if (delta < 0) { + throw new Error('delta must be >= 0.'); + } + if (!input) { + throw new Error('input is required.'); + } + + let moveX = input.x ?? 0; + let moveY = input.y ?? 0; + if (!Number.isFinite(moveX) || !Number.isFinite(moveY)) { + throw new Error('input values must be finite numbers.'); + } + + if (config.normalizeDiagonal) { + const magnitudeInput = magnitude(moveX, moveY); + if (magnitudeInput > 1) { + moveX /= magnitudeInput; + moveY /= magnitudeInput; + } + } + + const hasInput = Math.abs(moveX) > 1e-3 || Math.abs(moveY) > 1e-3; + + if (hasInput) { + const direction = normalize(moveX, moveY); + state.velocity.x += direction.x * config.acceleration * delta; + state.velocity.y += direction.y * config.acceleration * delta; + + const speed = magnitude(state.velocity.x, state.velocity.y); + if (speed > config.maxSpeed) { + const normalized = normalize(state.velocity.x, state.velocity.y); + state.velocity.x = normalized.x * config.maxSpeed; + state.velocity.y = normalized.y * config.maxSpeed; + } + + state.facing.x = direction.x; + state.facing.y = direction.y; + } else { + const speed = magnitude(state.velocity.x, state.velocity.y); + if (speed > 0) { + const decelAmount = config.deceleration * delta; + if (speed <= decelAmount) { + state.velocity.x = 0; + state.velocity.y = 0; + } else { + const normalized = normalize(state.velocity.x, state.velocity.y); + const newSpeed = speed - decelAmount; + state.velocity.x = normalized.x * newSpeed; + state.velocity.y = normalized.y * newSpeed; + } + } + } + + if (config.drag > 0) { + const dragFactor = Math.max(0, 1 - config.drag * delta); + state.velocity.x *= dragFactor; + state.velocity.y *= dragFactor; + } + + state.position.x += state.velocity.x * delta; + state.position.y += state.velocity.y * delta; + + return cloneState(state); + } + + function getState(): TopDownState { + return cloneState(state); + } + + function reset(partial: Partial = {}): void { + const position = partial.position ?? baseline.position; + const velocity = partial.velocity ?? baseline.velocity; + const facing = partial.facing ?? baseline.facing; + + state.position.x = position.x; + state.position.y = position.y; + state.velocity.x = velocity.x; + state.velocity.y = velocity.y; + state.facing.x = facing.x; + state.facing.y = facing.y; + } + + return { + update, + getState, + reset, + setOptions, + }; +} + +/** @internal */ +export const __internals = { + normalizeOptions, + normalize, + magnitude, +}; diff --git a/src/index.ts b/src/index.ts index b141b66..e15be3b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -97,6 +97,7 @@ export const examples = { createSpriteAnimation: 'examples/spriteAnimation.ts', createTweenSystem: 'examples/tween.ts', createPlatformerController: 'examples/platformerPhysics.ts', + createTopDownController: 'examples/topDownMovement.ts', }, ai: { seek: 'examples/steering.ts', @@ -570,6 +571,24 @@ export type { PlatformerUpdateOptions, } from './gameplay/platformerPhysics.js'; +/** + * Top-down movement controller for 8-direction navigation. + * + * Example file: examples/topDownMovement.ts + */ +export { createTopDownController } from './gameplay/topDownMovement.js'; + +/** + * Top-down movement configuration, state, and input types. + */ +export type { + TopDownMovementOptions, + TopDownController, + TopDownState, + TopDownInput, + TopDownUpdateOptions, +} from './gameplay/topDownMovement.js'; + // ============================================================================ // 🔍 SEARCH & STRING UTILITIES diff --git a/tests/index.test.ts b/tests/index.test.ts index 73561f1..8dc003d 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -31,6 +31,7 @@ describe('package entry point', () => { expect(examples.gameplay.createSpriteAnimation).toBe('examples/spriteAnimation.ts'); expect(examples.gameplay.createTweenSystem).toBe('examples/tween.ts'); expect(examples.gameplay.createPlatformerController).toBe('examples/platformerPhysics.ts'); + expect(examples.gameplay.createTopDownController).toBe('examples/topDownMovement.ts'); expect(examples.search.Trie).toBe('examples/search.ts'); expect(examples.pathfinding.buildNavMesh).toBe('examples/navMesh.ts'); }); @@ -105,6 +106,7 @@ describe('package entry point', () => { | 'createSpriteAnimation' | 'createTweenSystem' | 'createPlatformerController' + | 'createTopDownController' >(); }); }); diff --git a/tests/topDownMovement.test.ts b/tests/topDownMovement.test.ts new file mode 100644 index 0000000..5933e9d --- /dev/null +++ b/tests/topDownMovement.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest'; + +import { createTopDownController } from '../src/index.js'; + +describe('createTopDownController', () => { + const options = { + acceleration: 20, + deceleration: 18, + maxSpeed: 5, + drag: 0, + normalizeDiagonal: true, + } as const; + + it('accelerates towards max speed and maintains facing', () => { + const controller = createTopDownController(options); + let state = controller.update({ delta: 0.016, input: { x: 1, y: 0 } }); + expect(state.velocity.x).toBeGreaterThan(0); + expect(state.facing.x).toBeCloseTo(1, 5); + + for (let i = 0; i < 200; i += 1) { + state = controller.update({ delta: 0.016, input: { x: 1, y: 0 } }); + } + expect(Math.abs(state.velocity.x)).toBeLessThanOrEqual(options.maxSpeed + 1e-3); + expect(Math.abs(state.velocity.y)).toBeLessThan(1e-3); + }); + + it('decelerates to a stop when no input is provided', () => { + const controller = createTopDownController(options); + controller.update({ delta: 0.016, input: { x: 1, y: 0 } }); + controller.update({ delta: 0.5, input: { x: 1, y: 0 } }); + + let state = controller.update({ delta: 0.16, input: { x: 0, y: 0 } }); + expect(Math.abs(state.velocity.x)).toBeLessThan(options.maxSpeed); + + for (let i = 0; i < 20; i += 1) { + state = controller.update({ delta: 0.1, input: { x: 0, y: 0 } }); + } + expect(state.velocity.x).toBeCloseTo(0, 5); + expect(state.velocity.y).toBeCloseTo(0, 5); + }); + + it('normalizes diagonal input to avoid faster movement', () => { + const controller = createTopDownController(options); + const state = controller.update({ delta: 1, input: { x: 1, y: 1 } }); + const speed = Math.hypot(state.velocity.x, state.velocity.y); + expect(speed).toBeLessThanOrEqual(options.maxSpeed + 1e-3); + }); + + it('reset restores baseline state', () => { + const controller = createTopDownController(options, { + position: { x: 5, y: -3 }, + velocity: { x: 2, y: 1 }, + facing: { x: 0, y: 1 }, + }); + + controller.update({ delta: 0.5, input: { x: -1, y: 0 } }); + controller.reset(); + const state = controller.getState(); + expect(state.position).toEqual({ x: 5, y: -3 }); + expect(state.velocity).toEqual({ x: 2, y: 1 }); + expect(state.facing).toEqual({ x: 0, y: 1 }); + }); +});