diff --git a/PROJECT_DESCRIPTION.md b/PROJECT_DESCRIPTION.md index 7d879e8..415d522 100644 --- a/PROJECT_DESCRIPTION.md +++ b/PROJECT_DESCRIPTION.md @@ -38,7 +38,8 @@ npm run build | Grid pathfinding | `astar`, `dijkstra`, `jumpPointSearch`, `computeFlowField`, `buildNavMesh`, `findNavMeshPath`, `manhattanDistance`, `gridFromString` | `pathfinding/astar.ts`, `pathfinding/dijkstra.ts`, `pathfinding/jumpPointSearch.ts`, `pathfinding/flowField.ts`, `pathfinding/navMesh.ts` | `examples/astar.ts`, `examples/flowField.ts`, `examples/navMesh.ts` | | 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`, `createFixedTimestepLoop` | `util/*.ts` | `examples/requestDedup.ts`, `examples/virtualScroll.ts`, `examples/weightedAlias.ts`, `examples/objectPool.ts`, `examples/fisherYates.ts`, `examples/fixedTimestep.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` | `util/deltaTime.ts`, `util/fixedTimestep.ts`, `gameplay/camera2D.ts` | `examples/deltaTime.ts`, `examples/fixedTimestep.ts`, `examples/camera2D.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` | @@ -60,6 +61,7 @@ llm-algorithms/ │ ├── ai/ │ ├── data/ │ ├── geometry/ +│ ├── gameplay/ │ ├── graph/ │ ├── pathfinding/ │ ├── procedural/ @@ -91,11 +93,12 @@ Consistency between runtime code, documentation, and TypeScript declarations kee - **Pathfinding:** A*, Dijkstra, Jump Point Search, flow field integration, Manhattan heuristic, grid string parser. - **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. +- **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. - **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. -- **Geometry & visuals:** Convex hull, line intersection, point-in-polygon, easing presets, quadratic/cubic Bezier evaluation. +- **Geometry & visuals:** Convex hull, line intersection, point-in-polygon, Bresenham line rasterisation, easing presets, quadratic/cubic Bezier evaluation. - **AI behaviours:** Steering behaviours (seek, flee, arrive, pursue, wander), boids, behaviour trees, RVO crowd steering. --- diff --git a/README.md b/README.md index 0ee5dfe..0af1c04 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,8 @@ CDN usage: | Procedural generation | `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` | | 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`, `createDeltaTimeManager`, `createFixedTimestepLoop`, `fisherYatesShuffle` | `util/*.ts` | `examples/requestDedup.ts`, `examples/virtualScroll.ts`, `examples/weightedAlias.ts`, `examples/objectPool.ts`, `examples/deltaTime.ts`, `examples/fixedTimestep.ts`, `examples/fisherYates.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` | `util/deltaTime.ts`, `util/fixedTimestep.ts`, `gameplay/camera2D.ts` | `examples/deltaTime.ts`, `examples/fixedTimestep.ts`, `examples/camera2D.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` | @@ -52,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/search.ts`, `examples/graph.ts`, `examples/geometry.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/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 a92467f..72b8263 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -51,7 +51,7 @@ - [x] Fisher–Yates shuffle implementation - [x] Bresenham line / raster traversal helpers - Real-time systems: - - [ ] 2D camera system (smooth follow, dead zones, screen shake) + - [x] 2D camera system (smooth follow, dead zones, screen shake) - [ ] Particle system with configurable emitters - [ ] Sprite animation controller (frame timing, events) - [ ] Tween/lerp utility for smooth interpolation diff --git a/docs/index.d.ts b/docs/index.d.ts index 79ca7f2..04406fc 100644 --- a/docs/index.d.ts +++ b/docs/index.d.ts @@ -919,6 +919,94 @@ export interface FixedTimestepLoop { */ export function createFixedTimestepLoop(options: FixedTimestepOptions): FixedTimestepLoop; +// ============================================================================ +// 🕹️ GAMEPLAY SYSTEMS +// ============================================================================ + +/** + * Camera bounds limiting camera travel. + * Use for: constraining view to world dimensions. + * Import: gameplay/camera2D.ts + */ +export interface CameraBounds { + minX: number; + maxX: number; + minY: number; + maxY: number; +} + +/** + * Deadzone rectangle keeping targets centred only when they exit the buffer. + * Use for: platformer cameras, cinematic offsets. + * Import: gameplay/camera2D.ts + */ +export interface CameraDeadzone { + width: number; + height: number; +} + +/** + * Camera shake configuration. + * Use for: explosions, damage feedback, cinematic moments. + * Import: gameplay/camera2D.ts + */ +export interface CameraShakeOptions { + duration?: number; + magnitude: number; + frequency?: number; +} + +/** + * 2D camera configuration options. + * Use for: smooth follow cameras with bounds and dead zones. + * Import: gameplay/camera2D.ts + */ +export interface Camera2DOptions { + viewportWidth: number; + viewportHeight: number; + position?: Point; + bounds?: CameraBounds; + deadzone?: CameraDeadzone; + smoothing?: number; + random?: () => number; +} + +/** + * Camera update input. + * Use for: advancing the camera each frame with delta time and target. + * Import: gameplay/camera2D.ts + */ +export interface CameraUpdateOptions { + target: Point; + delta: number; +} + +/** + * 2D camera runtime API. + * Use for: retrieving view rects, configuring behaviour, triggering shake. + * Import: gameplay/camera2D.ts + */ +export interface Camera2D { + update(options: CameraUpdateOptions): Rect; + getView(): Rect; + getPosition(): Point; + getCenter(): Point; + setBounds(bounds?: CameraBounds): void; + setDeadzone(deadzone?: CameraDeadzone): void; + setSmoothing(value: number): void; + applyShake(options: CameraShakeOptions): void; + isShaking(): boolean; + reset(position?: Point): void; +} + +/** + * Creates a 2D camera with smoothing, dead zones, and screen shake support. + * Use for: side-scrollers, top-down games, cinematic sequences. + * Performance: O(1) per update. + * Import: gameplay/camera2D.ts + */ +export function createCamera2D(options: Camera2DOptions): Camera2D; + /** * Least recently used cache. * Use for: memoizing responses, data loaders, pagination caches. diff --git a/examples/camera2D.ts b/examples/camera2D.ts new file mode 100644 index 0000000..545a186 --- /dev/null +++ b/examples/camera2D.ts @@ -0,0 +1,22 @@ +import { createCamera2D } from '../src/index.js'; + +const camera = createCamera2D({ + viewportWidth: 16, + viewportHeight: 9, + deadzone: { width: 4, height: 2 }, + smoothing: 0.2, +}); + +let time = 0; +const target = { x: 0, y: 0 }; + +for (let i = 0; i < 5; i += 1) { + time += 1 / 60; + target.x = Math.cos(time) * 20; + target.y = Math.sin(time) * 5; + const view = camera.update({ target, delta: 1 / 60 }); + console.log(`frame ${i}:`, view); +} + +camera.applyShake({ magnitude: 1, duration: 0.3 }); +console.log('shake:', camera.update({ target, delta: 1 / 60 })); diff --git a/src/gameplay/camera2D.ts b/src/gameplay/camera2D.ts new file mode 100644 index 0000000..3038518 --- /dev/null +++ b/src/gameplay/camera2D.ts @@ -0,0 +1,341 @@ +import type { Point, Rect } from '../types.js'; + +export interface CameraBounds { + minX: number; + maxX: number; + minY: number; + maxY: number; +} + +export interface CameraDeadzone { + width: number; + height: number; +} + +export interface CameraShakeOptions { + /** Total duration of the shake in seconds. */ + duration?: number; + /** Maximum offset applied to the camera. */ + magnitude: number; + /** Oscillation frequency in Hz. */ + frequency?: number; +} + +export interface Camera2DOptions { + viewportWidth: number; + viewportHeight: number; + position?: Point; + bounds?: CameraBounds; + deadzone?: CameraDeadzone; + smoothing?: number; + random?: () => number; +} + +export interface CameraUpdateOptions { + target: Point; + /** Delta time in seconds. */ + delta: number; +} + +export interface Camera2D { + update(options: CameraUpdateOptions): Rect; + getView(): Rect; + getPosition(): Point; + getCenter(): Point; + setBounds(bounds?: CameraBounds): void; + setDeadzone(deadzone?: CameraDeadzone): void; + setSmoothing(value: number): void; + applyShake(options: CameraShakeOptions): void; + isShaking(): boolean; + reset(position?: Point): void; +} + +interface ShakeState { + duration: number; + magnitude: number; + frequency: number; + elapsed: number; + phaseX: number; + phaseY: number; +} + +function clamp(value: number, min: number, max: number): number { + if (value < min) return min; + if (value > max) return max; + return value; +} + +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 assertPoint(point: Point, label: string): void { + assertFinite(point.x, `${label}.x`); + assertFinite(point.y, `${label}.y`); +} + +function normalizeSmoothing(value: number | undefined): number { + if (value === undefined) { + return 0.2; + } + if (typeof value !== 'number' || Number.isNaN(value) || !Number.isFinite(value)) { + throw new Error('smoothing must be a finite number.'); + } + return clamp(value, 0, 1); +} + +function normalizeDeadzone(deadzone: CameraDeadzone | undefined, viewportWidth: number, viewportHeight: number): CameraDeadzone { + if (!deadzone) { + return { width: 0, height: 0 }; + } + assertFinite(deadzone.width, 'deadzone.width'); + assertFinite(deadzone.height, 'deadzone.height'); + const width = clamp(deadzone.width, 0, viewportWidth); + const height = clamp(deadzone.height, 0, viewportHeight); + return { width, height }; +} + +function validateBounds(bounds: CameraBounds): CameraBounds { + assertFinite(bounds.minX, 'bounds.minX'); + assertFinite(bounds.maxX, 'bounds.maxX'); + assertFinite(bounds.minY, 'bounds.minY'); + assertFinite(bounds.maxY, 'bounds.maxY'); + if (bounds.minX > bounds.maxX || bounds.minY > bounds.maxY) { + throw new Error('bounds min values must be <= max values.'); + } + return { + minX: bounds.minX, + maxX: bounds.maxX, + minY: bounds.minY, + maxY: bounds.maxY, + }; +} + +function resolveBoundsClamp(value: number, min: number, max: number, size: number): number { + const maxPos = max - size; + if (maxPos < min) { + return (min + max - size) / 2; + } + return clamp(value, min, maxPos); +} + +function computeSmoothingFactor(base: number, delta: number): number { + if (base <= 0) { + return 1; + } + if (base >= 1) { + return 1; + } + const clampedDelta = Math.max(delta, 0); + const factor = 1 - Math.pow(1 - base, clampedDelta * 60); + return clamp(factor, 0, 1); +} + +function createShakeState(options: CameraShakeOptions, random: () => number): ShakeState { + const duration = options.duration ?? 0.4; + const frequency = options.frequency ?? 18; + if (duration <= 0) { + throw new Error('shake duration must be greater than zero.'); + } + if (options.magnitude <= 0) { + throw new Error('shake magnitude must be greater than zero.'); + } + if (frequency <= 0) { + throw new Error('shake frequency must be greater than zero.'); + } + return { + duration, + frequency, + magnitude: options.magnitude, + elapsed: 0, + phaseX: random() * Math.PI * 2, + phaseY: random() * Math.PI * 2, + }; +} + +function updateShakes(shakes: ShakeState[], delta: number): Point { + let offsetX = 0; + let offsetY = 0; + for (let i = shakes.length - 1; i >= 0; i -= 1) { + const shake = shakes[i]; + shake.elapsed += delta; + if (shake.elapsed >= shake.duration) { + shakes.splice(i, 1); + continue; + } + const progress = shake.elapsed / shake.duration; + const damping = 1 - progress; + const angle = shake.phaseX + shake.elapsed * shake.frequency * Math.PI * 2; + const secondary = shake.phaseY + shake.elapsed * (shake.frequency * 0.9) * Math.PI * 2; + offsetX += Math.cos(angle) * shake.magnitude * damping; + offsetY += Math.sin(secondary) * shake.magnitude * damping; + } + return { x: offsetX, y: offsetY }; +} + +/** + * Creates a 2D camera system supporting smooth follow, dead zones, and screen shake. + * Useful for: side-scrollers, top-down games, and cinematic camera control. + */ +export function createCamera2D(options: Camera2DOptions): Camera2D { + const { + viewportWidth, + viewportHeight, + position, + bounds, + deadzone, + smoothing, + random = Math.random, + } = options; + + assertFinite(viewportWidth, 'viewportWidth'); + assertFinite(viewportHeight, 'viewportHeight'); + if (viewportWidth <= 0 || viewportHeight <= 0) { + throw new Error('viewport dimensions must be greater than zero.'); + } + if (typeof random !== 'function') { + throw new Error('random must be a function.'); + } + + const viewport = { width: viewportWidth, height: viewportHeight }; + let current = { + x: position?.x ?? 0, + y: position?.y ?? 0, + }; + let currentView: Rect = { x: current.x, y: current.y, width: viewport.width, height: viewport.height }; + let smoothingBase = normalizeSmoothing(smoothing); + let currentDeadzone = normalizeDeadzone(deadzone, viewport.width, viewport.height); + let currentBounds = bounds ? validateBounds(bounds) : undefined; + const shakes: ShakeState[] = []; + + function clampPosition(pos: Point): Point { + if (!currentBounds) { + return pos; + } + return { + x: resolveBoundsClamp(pos.x, currentBounds.minX, currentBounds.maxX, viewport.width), + y: resolveBoundsClamp(pos.y, currentBounds.minY, currentBounds.maxY, viewport.height), + }; + } + + function update(options: CameraUpdateOptions): Rect { + assertPoint(options.target, 'target'); + assertFinite(options.delta, 'delta'); + + let desiredX = current.x; + let desiredY = current.y; + + const zoneWidth = currentDeadzone.width; + const zoneHeight = currentDeadzone.height; + const zoneLeft = current.x + (viewport.width - zoneWidth) / 2; + const zoneRight = zoneLeft + zoneWidth; + const zoneTop = current.y + (viewport.height - zoneHeight) / 2; + const zoneBottom = zoneTop + zoneHeight; + + if (options.target.x < zoneLeft) { + desiredX -= zoneLeft - options.target.x; + } else if (options.target.x > zoneRight) { + desiredX += options.target.x - zoneRight; + } + + if (options.target.y < zoneTop) { + desiredY -= zoneTop - options.target.y; + } else if (options.target.y > zoneBottom) { + desiredY += options.target.y - zoneBottom; + } + + const clamped = clampPosition({ x: desiredX, y: desiredY }); + const factor = computeSmoothingFactor(smoothingBase, options.delta); + current.x += (clamped.x - current.x) * factor; + current.y += (clamped.y - current.y) * factor; + + const shakeOffset = updateShakes(shakes, options.delta); + currentView = { + x: current.x + shakeOffset.x, + y: current.y + shakeOffset.y, + width: viewport.width, + height: viewport.height, + }; + return currentView; + } + + function getView(): Rect { + return { ...currentView }; + } + + function getPosition(): Point { + return { x: current.x, y: current.y }; + } + + function getCenter(): Point { + return { + x: currentView.x + currentView.width / 2, + y: currentView.y + currentView.height / 2, + }; + } + + function setBounds(bounds?: CameraBounds): void { + currentBounds = bounds ? validateBounds(bounds) : undefined; + const clamped = clampPosition(current); + current.x = clamped.x; + current.y = clamped.y; + currentView = { x: current.x, y: current.y, width: viewport.width, height: viewport.height }; + } + + function setDeadzone(deadzone?: CameraDeadzone): void { + currentDeadzone = normalizeDeadzone(deadzone, viewport.width, viewport.height); + } + + function setSmoothing(value: number): void { + smoothingBase = normalizeSmoothing(value); + } + + function applyShake(options: CameraShakeOptions): void { + shakes.push(createShakeState(options, () => { + const r = random(); + if (typeof r !== 'number' || Number.isNaN(r) || !Number.isFinite(r)) { + throw new Error('random() must return a finite number.'); + } + return r; + })); + } + + function isShaking(): boolean { + return shakes.length > 0; + } + + function reset(position?: Point): void { + if (position) { + assertPoint(position, 'position'); + current = clampPosition({ x: position.x, y: position.y }); + } + shakes.length = 0; + currentView = { x: current.x, y: current.y, width: viewport.width, height: viewport.height }; + } + + return { + update, + getView, + getPosition, + getCenter, + setBounds, + setDeadzone, + setSmoothing, + applyShake, + isShaking, + reset, + }; +} + +/** @internal */ +export const __internals = { + clamp, + normalizeSmoothing, + normalizeDeadzone, + validateBounds, + resolveBoundsClamp, + computeSmoothingFactor, + updateShakes, +}; diff --git a/src/index.ts b/src/index.ts index e8bc0ab..a1c23b9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -87,9 +87,12 @@ export const examples = { calculateVirtualRange: 'examples/virtualScroll.ts', createWeightedAliasSampler: 'examples/weightedAlias.ts', createObjectPool: 'examples/objectPool.ts', - createDeltaTimeManager: 'examples/deltaTime.ts', fisherYatesShuffle: 'examples/fisherYates.ts', + }, + gameplay: { + createDeltaTimeManager: 'examples/deltaTime.ts', createFixedTimestepLoop: 'examples/fixedTimestep.ts', + createCamera2D: 'examples/camera2D.ts', }, ai: { seek: 'examples/steering.ts', @@ -427,6 +430,27 @@ export { createWeightedAliasSampler } from './util/weightedAlias.js'; */ export { createObjectPool } from './util/objectPool.js'; +/** + * Fisher–Yates shuffling utility for unbiased permutations. + * + * Example file: examples/fisherYates.ts + */ +export { fisherYatesShuffle } from './util/fisherYates.js'; + +/** + * Virtual scroll type exports to help define rendering contracts. + */ +export type { + VirtualRange, + VirtualItem, + VirtualScrollOptions, +} from './util/virtualScroll.js'; + + +// ============================================================================ +// 🕹️ GAMEPLAY SYSTEMS +// ============================================================================ + /** * Delta-time manager that clamps spikes and smooths frame durations. * @@ -435,11 +459,9 @@ export { createObjectPool } from './util/objectPool.js'; export { createDeltaTimeManager } from './util/deltaTime.js'; /** - * Fisher–Yates shuffling utility for unbiased permutations. - * - * Example file: examples/fisherYates.ts + * Delta-time manager types for smoothing configuration and runtime control. */ -export { fisherYatesShuffle } from './util/fisherYates.js'; +export type { DeltaTimeOptions, DeltaTimeManager } from './util/deltaTime.js'; /** * Fixed timestep loop for deterministic gameplay updates. @@ -449,18 +471,29 @@ export { fisherYatesShuffle } from './util/fisherYates.js'; export { createFixedTimestepLoop } from './util/fixedTimestep.js'; /** - * Virtual scroll type exports to help define rendering contracts. + * Fixed timestep loop option and runtime types. */ -export type { - VirtualRange, - VirtualItem, - VirtualScrollOptions, -} from './util/virtualScroll.js'; +export type { FixedTimestepOptions, FixedTimestepLoop } from './util/fixedTimestep.js'; /** - * Delta-time manager types for smoothing configuration and runtime control. + * 2D camera helper supporting smoothing, dead zones, and screen shake. + * + * Example file: examples/camera2D.ts */ -export type { DeltaTimeOptions, DeltaTimeManager } from './util/deltaTime.js'; +export { createCamera2D } from './gameplay/camera2D.js'; + +/** + * Camera system typed interfaces for configuration and updates. + */ +export type { + Camera2D, + Camera2DOptions, + CameraUpdateOptions, + CameraBounds, + CameraDeadzone, + CameraShakeOptions, +} from './gameplay/camera2D.js'; + // ============================================================================ // 🔍 SEARCH & STRING UTILITIES diff --git a/tests/camera2D.test.ts b/tests/camera2D.test.ts new file mode 100644 index 0000000..627dbb1 --- /dev/null +++ b/tests/camera2D.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { createCamera2D } from '../src/index.js'; + +describe('createCamera2D', () => { + it('follows a target without smoothing', () => { + const camera = createCamera2D({ viewportWidth: 10, viewportHeight: 10, smoothing: 0 }); + const view = camera.update({ target: { x: 20, y: 15 }, delta: 0.016 }); + expect(view).toEqual({ x: 15, y: 10, width: 10, height: 10 }); + }); + + it('keeps target within deadzone before moving', () => { + const camera = createCamera2D({ + viewportWidth: 12, + viewportHeight: 12, + smoothing: 0, + deadzone: { width: 4, height: 4 }, + }); + + camera.update({ target: { x: 6, y: 6 }, delta: 0.016 }); + expect(camera.getView().x).toBe(0); + + const moved = camera.update({ target: { x: 9, y: 6 }, delta: 0.016 }); + expect(moved.x).toBe(1); + }); + + it('applies smoothing gradually', () => { + const camera = createCamera2D({ viewportWidth: 10, viewportHeight: 10, smoothing: 0.2 }); + camera.update({ target: { x: 10, y: 0 }, delta: 0.016 }); + const position = camera.getPosition(); + expect(position.x).toBeGreaterThan(0); + expect(position.x).toBeLessThan(5); + }); + + it('clamps to bounds', () => { + const camera = createCamera2D({ + viewportWidth: 10, + viewportHeight: 10, + smoothing: 0, + bounds: { minX: 0, maxX: 25, minY: 0, maxY: 18 }, + }); + const view = camera.update({ target: { x: 50, y: 50 }, delta: 0.016 }); + expect(view).toEqual({ x: 15, y: 8, width: 10, height: 10 }); + }); + + it('applies deterministic screen shake and clears on reset', () => { + const random = vi.fn(() => 0.5); + const camera = createCamera2D({ viewportWidth: 10, viewportHeight: 10, smoothing: 0, random }); + camera.applyShake({ magnitude: 2, duration: 0.5, frequency: 1 }); + + const delta = 0.1; + const shaken = camera.update({ target: { x: 0, y: 0 }, delta }); + const position = camera.getPosition(); + + const duration = 0.5; + const magnitude = 2; + const frequency = 1; + const phase = Math.PI; + const progress = delta / duration; + const damping = 1 - progress; + const expectedOffsetX = Math.cos(phase + delta * frequency * Math.PI * 2) * magnitude * damping; + const expectedOffsetY = Math.sin(phase + delta * (frequency * 0.9) * Math.PI * 2) * magnitude * damping; + + expect(shaken.x - position.x).toBeCloseTo(expectedOffsetX, 5); + expect(shaken.y - position.y).toBeCloseTo(expectedOffsetY, 5); + expect(camera.isShaking()).toBe(true); + + camera.reset({ x: 0, y: 0 }); + expect(camera.isShaking()).toBe(false); + const resetView = camera.getView(); + expect(resetView).toEqual({ x: 0, y: 0, width: 10, height: 10 }); + }); +}); diff --git a/tests/index.test.ts b/tests/index.test.ts index 9ce2d7f..8b18a1c 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -23,8 +23,10 @@ describe('package entry point', () => { expect(examples.performance.createWeightedAliasSampler).toBe('examples/weightedAlias.ts'); expect(examples.performance.createObjectPool).toBe('examples/objectPool.ts'); expect(examples.performance.fisherYatesShuffle).toBe('examples/fisherYates.ts'); - expect(examples.performance.createFixedTimestepLoop).toBe('examples/fixedTimestep.ts'); expect(examples.performance.createWeightedAliasSampler).toBe('examples/weightedAlias.ts'); + expect(examples.gameplay.createDeltaTimeManager).toBe('examples/deltaTime.ts'); + expect(examples.gameplay.createFixedTimestepLoop).toBe('examples/fixedTimestep.ts'); + expect(examples.gameplay.createCamera2D).toBe('examples/camera2D.ts'); expect(examples.search.Trie).toBe('examples/search.ts'); expect(examples.pathfinding.buildNavMesh).toBe('examples/navMesh.ts'); }); @@ -38,6 +40,7 @@ describe('package entry point', () => { | 'ai' | 'data' | 'performance' + | 'gameplay' | 'graph' | 'geometry' | 'visual' @@ -87,9 +90,13 @@ describe('package entry point', () => { | 'calculateVirtualRange' | 'createWeightedAliasSampler' | 'createObjectPool' - | 'createDeltaTimeManager' | 'fisherYatesShuffle' + >(); + + expectTypeOf>().toEqualTypeOf< + | 'createDeltaTimeManager' | 'createFixedTimestepLoop' + | 'createCamera2D' >(); }); });