From c0c09375bc80a051fc3ec5fe5476cc6aad35e50b Mon Sep 17 00:00:00 2001 From: Francois Date: Wed, 8 Oct 2025 09:35:18 +0900 Subject: [PATCH] feat: add platformer physics helper --- PROJECT_DESCRIPTION.md | 4 +- README.md | 4 +- ROADMAP.md | 2 +- docs/index.d.ts | 73 ++++++++ examples/platformerPhysics.ts | 31 ++++ src/gameplay/platformerPhysics.ts | 278 ++++++++++++++++++++++++++++++ src/index.ts | 19 ++ tests/index.test.ts | 2 + tests/platformerPhysics.test.ts | 80 +++++++++ 9 files changed, 488 insertions(+), 5 deletions(-) create mode 100644 examples/platformerPhysics.ts create mode 100644 src/gameplay/platformerPhysics.ts create mode 100644 tests/platformerPhysics.test.ts diff --git a/PROJECT_DESCRIPTION.md b/PROJECT_DESCRIPTION.md index 8e24945..d586f5d 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` | `util/deltaTime.ts`, `util/fixedTimestep.ts`, `gameplay/camera2D.ts`, `gameplay/particleSystem.ts`, `gameplay/spriteAnimation.ts`, `gameplay/tween.ts` | `examples/deltaTime.ts`, `examples/fixedTimestep.ts`, `examples/camera2D.ts`, `examples/particleSystem.ts`, `examples/spriteAnimation.ts`, `examples/tween.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` | | 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. +- **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. - **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 77d5bed..32cab89 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` | `util/deltaTime.ts`, `util/fixedTimestep.ts`, `gameplay/camera2D.ts`, `gameplay/particleSystem.ts`, `gameplay/spriteAnimation.ts`, `gameplay/tween.ts` | `examples/deltaTime.ts`, `examples/fixedTimestep.ts`, `examples/camera2D.ts`, `examples/particleSystem.ts`, `examples/spriteAnimation.ts`, `examples/tween.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` | | 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/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/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 ad11393..2c468a5 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -55,7 +55,7 @@ - [x] Particle system with configurable emitters - [x] Sprite animation controller (frame timing, events) - [x] Tween/lerp utility for smooth interpolation - - [ ] Platformer physics helper (gravity, coyote time, jump buffering) + - [x] Platformer physics helper (gravity, coyote time, jump buffering) - [ ] Top-down movement helper (8-direction) - [ ] Tile map renderer helpers (chunking, layering, collision tags) - [ ] Shadowcasting field-of-view utilities and minimap helpers diff --git a/docs/index.d.ts b/docs/index.d.ts index 7493af5..e22126a 100644 --- a/docs/index.d.ts +++ b/docs/index.d.ts @@ -1223,6 +1223,79 @@ export interface TweenSystem { */ export function createTweenSystem(options?: TweenSystemOptions): TweenSystem; +/** + * Platformer physics configuration options. + * Use for: tuning acceleration, gravity, and jump responsiveness. + * Import: gameplay/platformerPhysics.ts + */ +export interface PlatformerPhysicsOptions { + acceleration: number; + deceleration: number; + maxSpeed: number; + gravity: number; + jumpVelocity: number; + maxFallSpeed?: number; + airControl?: number; + coyoteTime?: number; + jumpBufferTime?: number; + jumpCutMultiplier?: number; +} + +/** + * Platformer character state snapshot. + * Use for: feeding into collision systems and rendering. + * Import: gameplay/platformerPhysics.ts + */ +export interface PlatformerCharacterState { + position: Vector2D; + velocity: Vector2D; + onGround: boolean; +} + +/** + * Platformer player input. + * Use for: representing movement axis and jump presses. + * Import: gameplay/platformerPhysics.ts + */ +export interface PlatformerInput { + move: number; + jump: boolean; +} + +/** + * Platformer update payload. + * Use for: advancing physics with delta time and collision info. + * Import: gameplay/platformerPhysics.ts + */ +export interface PlatformerUpdateOptions { + delta: number; + input: PlatformerInput; + onGround: boolean; +} + +/** + * Platformer physics controller API. + * Use for: updating motion, resetting state, and retuning options. + * Import: gameplay/platformerPhysics.ts + */ +export interface PlatformerController { + update(options: PlatformerUpdateOptions): PlatformerCharacterState; + getState(): PlatformerCharacterState; + reset(state?: Partial): void; + setOptions(options: Partial): void; +} + +/** + * Creates a platformer physics controller with coyote time and jump buffering. + * Use for: responsive side-scroller movement and jump handling. + * Performance: O(1) per update. + * Import: gameplay/platformerPhysics.ts + */ +export function createPlatformerController( + options: PlatformerPhysicsOptions, + initialState?: PlatformerCharacterState +): PlatformerController; + /** * Least recently used cache. * Use for: memoizing responses, data loaders, pagination caches. diff --git a/examples/platformerPhysics.ts b/examples/platformerPhysics.ts new file mode 100644 index 0000000..79fc353 --- /dev/null +++ b/examples/platformerPhysics.ts @@ -0,0 +1,31 @@ +import { createPlatformerController } from '../src/index.js'; + +const controller = createPlatformerController({ + acceleration: 40, + deceleration: 30, + maxSpeed: 6, + gravity: 30, + jumpVelocity: 12, + coyoteTime: 0.1, + jumpBufferTime: 0.1, +}); + +let onGround = true; + +for (let frame = 0; frame < 10; frame += 1) { + const input = { + move: frame < 5 ? 1 : 0, + jump: frame === 0, + }; + + const state = controller.update({ + delta: 1 / 60, + input, + onGround, + }); + + // Simulate leaving the ground after the first update. + onGround = frame === 0; + + console.log(`frame ${frame}: pos=(${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/platformerPhysics.ts b/src/gameplay/platformerPhysics.ts new file mode 100644 index 0000000..545134f --- /dev/null +++ b/src/gameplay/platformerPhysics.ts @@ -0,0 +1,278 @@ +import type { Vector2D } from '../types.js'; + +export interface PlatformerCharacterState { + position: Vector2D; + velocity: Vector2D; + onGround: boolean; +} + +export interface PlatformerInput { + move: number; + jump: boolean; +} + +export interface PlatformerUpdateOptions { + delta: number; + input: PlatformerInput; + onGround: boolean; +} + +export interface PlatformerPhysicsOptions { + acceleration: number; + deceleration: number; + maxSpeed: number; + gravity: number; + jumpVelocity: number; + maxFallSpeed?: number; + airControl?: number; + coyoteTime?: number; + jumpBufferTime?: number; + jumpCutMultiplier?: number; +} + +export interface PlatformerController { + update(options: PlatformerUpdateOptions): PlatformerCharacterState; + getState(): PlatformerCharacterState; + reset(state?: Partial): void; + setOptions(options: Partial): void; +} + +interface InternalOptions { + acceleration: number; + deceleration: number; + maxSpeed: number; + gravity: number; + jumpVelocity: number; + maxFallSpeed: number; + airControl: number; + coyoteTime: number; + jumpBufferTime: number; + jumpCutMultiplier: number; +} + +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 clamp(value: number, min: number, max: number): number { + if (value < min) return min; + if (value > max) return max; + return value; +} + +function normalizeOptions(options: PlatformerPhysicsOptions): InternalOptions { + assertFinite(options.acceleration, 'acceleration'); + assertFinite(options.deceleration, 'deceleration'); + assertFinite(options.maxSpeed, 'maxSpeed'); + assertFinite(options.gravity, 'gravity'); + assertFinite(options.jumpVelocity, 'jumpVelocity'); + 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.'); + } + if (options.jumpVelocity <= 0) { + throw new Error('jumpVelocity must be greater than 0.'); + } + + const maxFallSpeedRaw = options.maxFallSpeed ?? Infinity; + if (maxFallSpeedRaw !== Infinity) { + assertFinite(maxFallSpeedRaw, 'maxFallSpeed'); + if (maxFallSpeedRaw <= 0) { + throw new Error('maxFallSpeed must be greater than 0.'); + } + } + const maxFallSpeed = maxFallSpeedRaw; + + const airControl = options.airControl ?? 0.6; + assertFinite(airControl, 'airControl'); + if (airControl < 0) { + throw new Error('airControl must be >= 0.'); + } + + const coyoteTime = options.coyoteTime ?? 0.1; + assertFinite(coyoteTime, 'coyoteTime'); + if (coyoteTime < 0) { + throw new Error('coyoteTime must be >= 0.'); + } + + const jumpBufferTime = options.jumpBufferTime ?? 0.1; + assertFinite(jumpBufferTime, 'jumpBufferTime'); + if (jumpBufferTime < 0) { + throw new Error('jumpBufferTime must be >= 0.'); + } + + const jumpCutMultiplier = options.jumpCutMultiplier ?? 0.5; + assertFinite(jumpCutMultiplier, 'jumpCutMultiplier'); + if (jumpCutMultiplier < 0 || jumpCutMultiplier > 1) { + throw new Error('jumpCutMultiplier must be between 0 and 1.'); + } + + return { + acceleration: options.acceleration, + deceleration: options.deceleration, + maxSpeed: options.maxSpeed, + gravity: options.gravity, + jumpVelocity: options.jumpVelocity, + maxFallSpeed, + airControl, + coyoteTime, + jumpBufferTime, + jumpCutMultiplier, + }; +} + +function cloneState(state: PlatformerCharacterState): PlatformerCharacterState { + return { + position: { x: state.position.x, y: state.position.y }, + velocity: { x: state.velocity.x, y: state.velocity.y }, + onGround: state.onGround, + }; +} + +/** + * Creates a platformer physics controller with gravity, coyote time, and jump buffering. + * Useful for: responsive side-scroller characters with forgiving jump controls. + */ +export function createPlatformerController( + options: PlatformerPhysicsOptions, + initialState: PlatformerCharacterState = { + position: { x: 0, y: 0 }, + velocity: { x: 0, y: 0 }, + onGround: false, + } +): PlatformerController { + let config = normalizeOptions(options); + const baseline = cloneState(initialState); + const state: PlatformerCharacterState = cloneState(initialState); + + let coyoteTimer = state.onGround ? config.coyoteTime : 0; + let jumpBufferTimer = 0; + let previousJumpPressed = false; + + function setOptions(partial: Partial): void { + config = normalizeOptions({ + acceleration: partial.acceleration ?? config.acceleration, + deceleration: partial.deceleration ?? config.deceleration, + maxSpeed: partial.maxSpeed ?? config.maxSpeed, + gravity: partial.gravity ?? config.gravity, + jumpVelocity: partial.jumpVelocity ?? config.jumpVelocity, + maxFallSpeed: partial.maxFallSpeed ?? config.maxFallSpeed, + airControl: partial.airControl ?? config.airControl, + coyoteTime: partial.coyoteTime ?? config.coyoteTime, + jumpBufferTime: partial.jumpBufferTime ?? config.jumpBufferTime, + jumpCutMultiplier: partial.jumpCutMultiplier ?? config.jumpCutMultiplier, + }); + } + + function update({ delta, input, onGround }: PlatformerUpdateOptions): PlatformerCharacterState { + assertFinite(delta, 'delta'); + if (delta < 0) { + throw new Error('delta must be >= 0.'); + } + if (!input) { + throw new Error('input is required.'); + } + + const move = clamp(input.move ?? 0, -1, 1); + const jumpPressed = Boolean(input.jump); + const jumpPressedThisFrame = jumpPressed && !previousJumpPressed; + const jumpReleasedThisFrame = !jumpPressed && previousJumpPressed; + previousJumpPressed = jumpPressed; + + if (jumpPressedThisFrame) { + jumpBufferTimer = config.jumpBufferTime; + } else { + jumpBufferTimer = Math.max(0, jumpBufferTimer - delta); + } + + if (onGround) { + coyoteTimer = config.coyoteTime; + state.onGround = true; + } else { + coyoteTimer = Math.max(0, coyoteTimer - delta); + state.onGround = false; + } + + const airMultiplier = state.onGround ? 1 : config.airControl; + const acceleration = config.acceleration * airMultiplier; + const deceleration = config.deceleration * airMultiplier; + + const targetSpeed = move * config.maxSpeed; + if (targetSpeed !== 0) { + const speedDiff = targetSpeed - state.velocity.x; + const direction = Math.sign(speedDiff); + const amount = acceleration * delta; + if (Math.abs(speedDiff) <= amount) { + state.velocity.x = targetSpeed; + } else { + state.velocity.x += direction * amount; + } + } else if (state.velocity.x !== 0) { + const direction = Math.sign(state.velocity.x); + const amount = deceleration * delta; + if (Math.abs(state.velocity.x) <= amount) { + state.velocity.x = 0; + } else { + state.velocity.x -= direction * amount; + } + } + + const canJump = jumpBufferTimer > 0 && coyoteTimer > 0; + if (canJump) { + state.velocity.y = -config.jumpVelocity; + state.onGround = false; + coyoteTimer = 0; + jumpBufferTimer = 0; + } + + if (jumpReleasedThisFrame && state.velocity.y < 0) { + state.velocity.y *= config.jumpCutMultiplier; + } + + state.velocity.y += config.gravity * delta; + if (state.velocity.y > config.maxFallSpeed) { + state.velocity.y = config.maxFallSpeed; + } + + state.position.x += state.velocity.x * delta; + state.position.y += state.velocity.y * delta; + + return cloneState(state); + } + + function getState(): PlatformerCharacterState { + return cloneState(state); + } + + function reset(partial: Partial = {}): void { + const sourcePosition = partial.position ?? baseline.position; + const sourceVelocity = partial.velocity ?? baseline.velocity; + const sourceGround = partial.onGround ?? baseline.onGround; + + state.position.x = sourcePosition.x; + state.position.y = sourcePosition.y; + state.velocity.x = sourceVelocity.x; + state.velocity.y = sourceVelocity.y; + state.onGround = sourceGround; + coyoteTimer = state.onGround ? config.coyoteTime : 0; + jumpBufferTimer = 0; + previousJumpPressed = false; + } + + return { + update, + getState, + reset, + setOptions, + }; +} + +/** @internal */ +export const __internals = { + normalizeOptions, + clamp, +}; diff --git a/src/index.ts b/src/index.ts index e8c4e34..b141b66 100644 --- a/src/index.ts +++ b/src/index.ts @@ -96,6 +96,7 @@ export const examples = { createParticleSystem: 'examples/particleSystem.ts', createSpriteAnimation: 'examples/spriteAnimation.ts', createTweenSystem: 'examples/tween.ts', + createPlatformerController: 'examples/platformerPhysics.ts', }, ai: { seek: 'examples/steering.ts', @@ -551,6 +552,24 @@ export type { TweenStatus, } from './gameplay/tween.js'; +/** + * Platformer physics controller with coyote time and jump buffering. + * + * Example file: examples/platformerPhysics.ts + */ +export { createPlatformerController } from './gameplay/platformerPhysics.js'; + +/** + * Platformer physics options, state, and input types. + */ +export type { + PlatformerPhysicsOptions, + PlatformerController, + PlatformerCharacterState, + PlatformerInput, + PlatformerUpdateOptions, +} from './gameplay/platformerPhysics.js'; + // ============================================================================ // 🔍 SEARCH & STRING UTILITIES diff --git a/tests/index.test.ts b/tests/index.test.ts index d31c410..73561f1 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -30,6 +30,7 @@ describe('package entry point', () => { expect(examples.gameplay.createParticleSystem).toBe('examples/particleSystem.ts'); 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.search.Trie).toBe('examples/search.ts'); expect(examples.pathfinding.buildNavMesh).toBe('examples/navMesh.ts'); }); @@ -103,6 +104,7 @@ describe('package entry point', () => { | 'createParticleSystem' | 'createSpriteAnimation' | 'createTweenSystem' + | 'createPlatformerController' >(); }); }); diff --git a/tests/platformerPhysics.test.ts b/tests/platformerPhysics.test.ts new file mode 100644 index 0000000..bb7b1f0 --- /dev/null +++ b/tests/platformerPhysics.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest'; + +import { createPlatformerController } from '../src/index.js'; + +describe('createPlatformerController', () => { + const baseOptions = { + acceleration: 40, + deceleration: 35, + maxSpeed: 6, + gravity: 30, + jumpVelocity: 12, + coyoteTime: 0.1, + jumpBufferTime: 0.1, + jumpCutMultiplier: 0.5, + } as const; + + it('accelerates horizontally and clamps speed', () => { + const controller = createPlatformerController(baseOptions, { + position: { x: 0, y: 0 }, + velocity: { x: 0, y: 0 }, + onGround: true, + }); + + let state = controller.update({ + delta: 0.016, + input: { move: 1, jump: false }, + onGround: true, + }); + expect(state.velocity.x).toBeGreaterThan(0); + + for (let i = 0; i < 200; i += 1) { + state = controller.update({ delta: 0.016, input: { move: 1, jump: false }, onGround: true }); + } + expect(Math.abs(state.velocity.x)).toBeLessThanOrEqual(baseOptions.maxSpeed + 1e-3); + }); + + it('allows jumps during coyote time after leaving ground', () => { + const controller = createPlatformerController(baseOptions, { + position: { x: 0, y: 0 }, + velocity: { x: 0, y: 0 }, + onGround: true, + }); + + controller.update({ delta: 0.016, input: { move: 0, jump: false }, onGround: true }); + controller.update({ delta: 0.05, input: { move: 0, jump: false }, onGround: false }); + const state = controller.update({ delta: 0.016, input: { move: 0, jump: true }, onGround: false }); + + expect(state.velocity.y).toBeLessThan(0); + expect(state.onGround).toBe(false); + }); + + it('buffers jump input before touching ground', () => { + const controller = createPlatformerController(baseOptions, { + position: { x: 0, y: 5 }, + velocity: { x: 0, y: 5 }, + onGround: false, + }); + + controller.update({ delta: 0.016, input: { move: 0, jump: true }, onGround: false }); + controller.update({ delta: 0.05, input: { move: 0, jump: true }, onGround: false }); + const state = controller.update({ delta: 0.016, input: { move: 0, jump: true }, onGround: true }); + + expect(state.velocity.y).toBeLessThan(0); + expect(state.onGround).toBe(false); + }); + + it('applies jump cut when jump is released early', () => { + const controller = createPlatformerController(baseOptions, { + position: { x: 0, y: 0 }, + velocity: { x: 0, y: 0 }, + onGround: true, + }); + + const jumpState = controller.update({ delta: 0.016, input: { move: 0, jump: true }, onGround: true }); + expect(jumpState.velocity.y).toBeLessThan(0); + + const cutState = controller.update({ delta: 0.016, input: { move: 0, jump: false }, onGround: false }); + expect(cutState.velocity.y).toBeGreaterThan(jumpState.velocity.y); + }); +});