diff --git a/PROJECT_DESCRIPTION.md b/PROJECT_DESCRIPTION.md index aa2bffd..a67d229 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`, `createTopDownController`, `createTileMapController`, `computeFieldOfView`, `createInventory` | `util/deltaTime.ts`, `util/fixedTimestep.ts`, `gameplay/camera2D.ts`, `gameplay/particleSystem.ts`, `gameplay/spriteAnimation.ts`, `gameplay/tween.ts`, `gameplay/platformerPhysics.ts`, `gameplay/topDownMovement.ts`, `gameplay/tileMap.ts`, `gameplay/shadowcasting.ts`, `gameplay/inventory.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/tileMap.ts`, `examples/shadowcasting.ts`, `examples/inventory.ts` | +| Gameplay systems | `createDeltaTimeManager`, `createFixedTimestepLoop`, `createCamera2D`, `createParticleSystem`, `createSpriteAnimation`, `createTweenSystem`, `createPlatformerController`, `createTopDownController`, `createTileMapController`, `computeFieldOfView`, `createInventory`, `calculateDamage`, `createCooldownController`, `updateStatusEffects`, `createQuestMachine` | `util/deltaTime.ts`, `util/fixedTimestep.ts`, `gameplay/camera2D.ts`, `gameplay/particleSystem.ts`, `gameplay/spriteAnimation.ts`, `gameplay/tween.ts`, `gameplay/platformerPhysics.ts`, `gameplay/topDownMovement.ts`, `gameplay/tileMap.ts`, `gameplay/shadowcasting.ts`, `gameplay/inventory.ts`, `gameplay/combat.ts`, `gameplay/questMachine.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/tileMap.ts`, `examples/shadowcasting.ts`, `examples/inventory.ts`, `examples/combat.ts`, `examples/quest.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, top-down movement controller with acceleration and drag, tile map renderer with chunking and collision tags, shadowcasting FOV utility, inventory system primitives. +- **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, tile map renderer with chunking and collision tags, shadowcasting FOV utility, inventory system primitives, combat helpers (damage/cooldowns/status), quest/dialog state machine. - **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 d7e7bc6..47f815d 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`, `createTopDownController`, `createTileMapController`, `computeFieldOfView`, `createInventory` | `util/deltaTime.ts`, `util/fixedTimestep.ts`, `gameplay/camera2D.ts`, `gameplay/particleSystem.ts`, `gameplay/spriteAnimation.ts`, `gameplay/tween.ts`, `gameplay/platformerPhysics.ts`, `gameplay/topDownMovement.ts`, `gameplay/tileMap.ts`, `gameplay/shadowcasting.ts`, `gameplay/inventory.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/tileMap.ts`, `examples/shadowcasting.ts`, `examples/inventory.ts` | +| Gameplay systems | `createDeltaTimeManager`, `createFixedTimestepLoop`, `createCamera2D`, `createParticleSystem`, `createSpriteAnimation`, `createTweenSystem`, `createPlatformerController`, `createTopDownController`, `createTileMapController`, `computeFieldOfView`, `createInventory`, `calculateDamage`, `createCooldownController`, `updateStatusEffects`, `createQuestMachine` | `util/deltaTime.ts`, `util/fixedTimestep.ts`, `gameplay/camera2D.ts`, `gameplay/particleSystem.ts`, `gameplay/spriteAnimation.ts`, `gameplay/tween.ts`, `gameplay/platformerPhysics.ts`, `gameplay/topDownMovement.ts`, `gameplay/tileMap.ts`, `gameplay/shadowcasting.ts`, `gameplay/inventory.ts`, `gameplay/combat.ts`, `gameplay/questMachine.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/tileMap.ts`, `examples/shadowcasting.ts`, `examples/inventory.ts`, `examples/combat.ts`, `examples/quest.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` | diff --git a/docs/index.d.ts b/docs/index.d.ts index 378accb..3b2df4e 100644 --- a/docs/index.d.ts +++ b/docs/index.d.ts @@ -1695,6 +1695,86 @@ export function updateStatusEffects( effects: ActiveStatusEffect[], delta: number ): ActiveStatusEffect[]; + +/** + * Quest state node definition. + * Use for: describing quest/dialog states with hooks. + * Import: gameplay/questMachine.ts + */ +export interface QuestStateNode> { + id: string; + terminal?: boolean; + onEnter?: (context: TContext, payload?: unknown) => void; + onExit?: (context: TContext, payload?: unknown) => void; +} + +/** + * Quest transition definition. + * Use for: wiring events to state transitions in quests/dialogs. + * Import: gameplay/questMachine.ts + */ +export interface QuestTransition< + TContext extends Record, + TEvent = unknown +> { + from: string; + to: string; + event: string; + condition?: (context: TContext, event: TEvent) => boolean; + action?: (context: TContext, event: TEvent) => void; +} + +/** + * Quest machine configuration options. + * Use for: instantiating a quest/dialog state machine. + * Import: gameplay/questMachine.ts + */ +export interface QuestMachineOptions< + TContext extends Record, + TEvent = unknown +> { + states: ReadonlyArray>; + transitions: ReadonlyArray>; + initial: string; + context: TContext; +} + +/** + * Quest machine snapshot payload. + * Use for: serialising quest progress. + * Import: gameplay/questMachine.ts + */ +export interface QuestMachineSnapshot> { + state: string; + context: TContext; +} + +/** + * Quest machine controller API. + * Use for: driving quest/dialog progression. + * Import: gameplay/questMachine.ts + */ +export interface QuestMachine< + TContext extends Record, + TEvent = unknown +> { + send(event: string, payload?: TEvent): boolean; + getState(): string; + getContext(): TContext; + isCompleted(): boolean; + reset(snapshot?: QuestMachineSnapshot): void; + toJSON(): QuestMachineSnapshot; +} + +/** + * Creates a quest/dialog state machine. + * Use for: branching dialogue, quest progression, narrative scripting. + * Import: gameplay/questMachine.ts + */ +export function createQuestMachine< + TContext extends Record, + TEvent = unknown +>(options: QuestMachineOptions): QuestMachine; /** * Item insertion payload used by the inventory controller. * Use for: adding items with quantity and metadata. diff --git a/examples/quest.ts b/examples/quest.ts new file mode 100644 index 0000000..4ddcda5 --- /dev/null +++ b/examples/quest.ts @@ -0,0 +1,37 @@ +import { createQuestMachine } from '../src/index.js'; + +const quest = createQuestMachine<{ reputation: number; reward: number }, { hasItem?: boolean }>({ + initial: 'start', + context: { reputation: 0, reward: 0 }, + states: [ + { id: 'start' }, + { + id: 'accepted', + onEnter: (ctx) => { + ctx.reputation += 5; + console.log('Quest accepted'); + }, + }, + { + id: 'completed', + terminal: true, + onEnter: (ctx) => { + ctx.reward = 150; + console.log('Quest completed!'); + }, + }, + ], + transitions: [ + { from: 'start', to: 'accepted', event: 'accept' }, + { + from: 'accepted', + to: 'completed', + event: 'turn-in', + condition: (_ctx, payload) => Boolean(payload?.hasItem), + }, + ], +}); + +quest.send('accept'); +quest.send('turn-in', { hasItem: true }); +console.log('Final context:', quest.getContext()); diff --git a/src/gameplay/questMachine.ts b/src/gameplay/questMachine.ts new file mode 100644 index 0000000..90a55bd --- /dev/null +++ b/src/gameplay/questMachine.ts @@ -0,0 +1,142 @@ +export interface QuestStateNode { + id: string; + terminal?: boolean; + onEnter?: (context: TContext, payload?: unknown) => void; + onExit?: (context: TContext, payload?: unknown) => void; +} + +export interface QuestTransition { + from: string; + to: string; + event: string; + condition?: (context: TContext, event: TEvent) => boolean; + action?: (context: TContext, event: TEvent) => void; +} + +export interface QuestMachineOptions< + TContext extends Record, + TEvent = unknown +> { + states: ReadonlyArray>; + transitions: ReadonlyArray>; + initial: string; + context: TContext; +} + +export interface QuestMachineSnapshot { + state: string; + context: TContext; +} + +export interface QuestMachine< + TContext extends Record, + TEvent = unknown +> { + send(event: string, payload?: TEvent): boolean; + getState(): string; + getContext(): TContext; + isCompleted(): boolean; + reset(snapshot?: QuestMachineSnapshot): void; + toJSON(): QuestMachineSnapshot; +} + +function deepClone(value: T): T { + return JSON.parse(JSON.stringify(value)) as T; +} + +export function createQuestMachine, TEvent = unknown>( + options: QuestMachineOptions +): QuestMachine { + if (!Array.isArray(options.states) || options.states.length === 0) { + throw new Error('states must contain at least one state.'); + } + if (!Array.isArray(options.transitions)) { + throw new Error('transitions must be an array.'); + } + + const stateMap = new Map>(); + const states = options.states as ReadonlyArray>; + for (const state of states) { + if (stateMap.has(state.id)) { + throw new Error(`Duplicate state id: ${state.id}`); + } + stateMap.set(state.id, state); + } + if (!stateMap.has(options.initial)) { + throw new Error(`Unknown initial state: ${options.initial}`); + } + + const transitionsByEvent = new Map[] >(); + const transitions = options.transitions as ReadonlyArray>; + for (const transition of transitions) { + if (!stateMap.has(transition.from)) { + throw new Error(`Transition references unknown state: ${transition.from}`); + } + if (!stateMap.has(transition.to)) { + throw new Error(`Transition references unknown state: ${transition.to}`); + } + const list = transitionsByEvent.get(transition.event) ?? []; + list.push(transition); + transitionsByEvent.set(transition.event, list); + } + + const initialContext = deepClone(options.context); + let context = deepClone(options.context); + let currentStateId = options.initial; + + stateMap.get(currentStateId)?.onEnter?.(context); + + function getStateNode(id: string): QuestStateNode { + const state = stateMap.get(id); + if (!state) { + throw new Error(`Unknown state: ${id}`); + } + return state; + } + + function send(event: string, payload?: TEvent): boolean { + const candidates = transitionsByEvent.get(event); + if (!candidates) { + return false; + } + + for (const transition of candidates) { + if (transition.from !== currentStateId) { + continue; + } + if (transition.condition && !transition.condition(context, payload as TEvent)) { + continue; + } + const previousState = getStateNode(currentStateId); + const nextState = getStateNode(transition.to); + + previousState.onExit?.(context, payload); + transition.action?.(context, payload as TEvent); + currentStateId = nextState.id; + nextState.onEnter?.(context, payload); + return true; + } + + return false; + } + + function reset(snapshot?: QuestMachineSnapshot): void { + const source = snapshot ? snapshot.context : initialContext; + context = deepClone(source); + const nextStateId = snapshot ? snapshot.state : options.initial; + if (!stateMap.has(nextStateId)) { + throw new Error(`Unknown state in snapshot: ${nextStateId}`); + } + currentStateId = nextStateId; + stateMap.get(currentStateId)?.onEnter?.(context); + } + + return { + send, + getState: () => currentStateId, + getContext: () => context, + isCompleted: () => Boolean(stateMap.get(currentStateId)?.terminal), + reset, + toJSON: () => ({ state: currentStateId, context: deepClone(context) }), + }; +} diff --git a/src/index.ts b/src/index.ts index 780232b..7ee2fc0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -103,6 +103,7 @@ export const examples = { createInventory: 'examples/inventory.ts', calculateDamage: 'examples/combat.ts', createCooldownController: 'examples/combat.ts', + createQuestMachine: 'examples/quest.ts', }, ai: { seek: 'examples/steering.ts', @@ -669,6 +670,21 @@ export type { ActiveStatusEffect, } from './gameplay/combat.js'; +/** + * Quest/dialog state machine utilities. + * + * Example file: examples/quest.ts + */ +export { createQuestMachine } from './gameplay/questMachine.js'; + +export type { + QuestStateNode, + QuestTransition, + QuestMachineOptions, + QuestMachine, + QuestMachineSnapshot, +} from './gameplay/questMachine.js'; + // ============================================================================ // 🔍 SEARCH & STRING UTILITIES diff --git a/tests/index.test.ts b/tests/index.test.ts index c350986..e402141 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -114,6 +114,7 @@ describe('package entry point', () => { | 'createInventory' | 'calculateDamage' | 'createCooldownController' + | 'createQuestMachine' >(); }); }); diff --git a/tests/questMachine.test.ts b/tests/questMachine.test.ts new file mode 100644 index 0000000..2dfa6b1 --- /dev/null +++ b/tests/questMachine.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest'; + +import { createQuestMachine } from '../src/index.js'; + +describe('createQuestMachine', () => { + it('advances states based on events and conditions', () => { + const context = { reputation: 0, reward: 0 }; + + const machine = createQuestMachine({ + context, + initial: 'start', + states: [ + { id: 'start' }, + { + id: 'accepted', + onEnter: (ctx) => { + ctx.reputation += 10; + }, + }, + { + id: 'completed', + terminal: true, + onEnter: (ctx) => { + ctx.reward = 100; + }, + }, + ], + transitions: [ + { + from: 'start', + to: 'accepted', + event: 'accept', + }, + { + from: 'accepted', + to: 'completed', + event: 'turn-in', + condition: (ctx, payload) => Boolean(payload?.hasItem) && ctx.reputation >= 10, + }, + ], + }); + + expect(machine.getState()).toBe('start'); + expect(machine.send('turn-in', { hasItem: true })).toBe(false); + expect(machine.send('accept')).toBe(true); + expect(machine.getState()).toBe('accepted'); + expect(machine.send('turn-in', { hasItem: true })).toBe(true); + expect(machine.isCompleted()).toBe(true); + expect(machine.getContext().reward).toBe(100); + }); + + it('serializes and resets state', () => { + const machine = createQuestMachine<{ progress: number }, unknown>({ + context: { progress: 0 }, + initial: 'start', + states: [{ id: 'start' }, { id: 'end', terminal: true }], + transitions: [ + { + from: 'start', + to: 'end', + event: 'finish', + action: (ctx) => { + ctx.progress = 1; + }, + }, + ], + }); + + machine.send('finish'); + const snapshot = machine.toJSON(); + expect(snapshot.state).toBe('end'); + expect(snapshot.context.progress).toBe(1); + + machine.reset(); + expect(machine.getState()).toBe('start'); + expect(machine.getContext().progress).toBe(0); + + machine.reset(snapshot); + expect(machine.getState()).toBe('end'); + expect(machine.getContext().progress).toBe(1); + }); +});