From d1ea97b52464c277b555bfc0360986a77f3c2256 Mon Sep 17 00:00:00 2001 From: Francois Date: Wed, 8 Oct 2025 11:15:50 +0900 Subject: [PATCH 1/2] feat: add quest state machine helpers --- examples/quest.ts | 2 +- tests/index.test.ts | 1 + tests/questMachine.test.ts | 4 ++-- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/examples/quest.ts b/examples/quest.ts index 4ddcda5..433f3a6 100644 --- a/examples/quest.ts +++ b/examples/quest.ts @@ -1,6 +1,6 @@ import { createQuestMachine } from '../src/index.js'; -const quest = createQuestMachine<{ reputation: number; reward: number }, { hasItem?: boolean }>({ +const quest = createQuestMachine({ initial: 'start', context: { reputation: 0, reward: 0 }, states: [ diff --git a/tests/index.test.ts b/tests/index.test.ts index e402141..0bd1e60 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -114,6 +114,7 @@ describe('package entry point', () => { | 'createInventory' | 'calculateDamage' | 'createCooldownController' + | 'updateStatusEffects' | 'createQuestMachine' >(); }); diff --git a/tests/questMachine.test.ts b/tests/questMachine.test.ts index 2dfa6b1..622f1ca 100644 --- a/tests/questMachine.test.ts +++ b/tests/questMachine.test.ts @@ -6,7 +6,7 @@ describe('createQuestMachine', () => { it('advances states based on events and conditions', () => { const context = { reputation: 0, reward: 0 }; - const machine = createQuestMachine({ + const machine = createQuestMachine({ context, initial: 'start', states: [ @@ -50,7 +50,7 @@ describe('createQuestMachine', () => { }); it('serializes and resets state', () => { - const machine = createQuestMachine<{ progress: number }, unknown>({ + const machine = createQuestMachine({ context: { progress: 0 }, initial: 'start', states: [{ id: 'start' }, { id: 'end', terminal: true }], From 01532c2ab3482d2fd378f6e9212e63b368755f4c Mon Sep 17 00:00:00 2001 From: Francois Date: Wed, 8 Oct 2025 11:29:05 +0900 Subject: [PATCH 2/2] feat: add 2d lighting helpers --- README.md | 2 +- ROADMAP.md | 4 +- docs/index.d.ts | 62 ++++++++++++++++++ examples/lighting.ts | 14 ++++ examples/quest.ts | 2 +- src/gameplay/lighting.ts | 127 +++++++++++++++++++++++++++++++++++++ src/index.ts | 16 +++++ tests/index.test.ts | 2 +- tests/lighting.test.ts | 40 ++++++++++++ tests/questMachine.test.ts | 4 +- 10 files changed, 266 insertions(+), 7 deletions(-) create mode 100644 examples/lighting.ts create mode 100644 src/gameplay/lighting.ts create mode 100644 tests/lighting.test.ts diff --git a/README.md b/README.md index 47f815d..2c60d37 100644 --- a/README.md +++ b/README.md @@ -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/topDownMovement.ts`, `examples/tileMap.ts`, `examples/shadowcasting.ts`, `examples/inventory.ts`, `examples/combat.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/tileMap.ts`, `examples/shadowcasting.ts`, `examples/inventory.ts`, `examples/combat.ts`, `examples/quest.ts`, `examples/lighting.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 06cb5e0..fe5ebf8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -62,8 +62,8 @@ - **Systems for gameplay loops** - [x] Inventory system primitives (stacking, filtering, persistence hooks) - [x] Combat resolution helpers (cooldowns, damage formulas, status effects) - - [ ] Quest/dialog state machine utilities - - [ ] 2D lighting helpers (falloff, blending stubs) + - [x] Quest/dialog state machine utilities + - [x] 2D lighting helpers (falloff, blending stubs) - [ ] Wave spawner utilities for encounter pacing - [ ] Sound manager stubs (channel limiting, priority) - [ ] Input manager abstraction (keyboard/mouse/pad remapping) diff --git a/docs/index.d.ts b/docs/index.d.ts index 3b2df4e..2fa1ad9 100644 --- a/docs/index.d.ts +++ b/docs/index.d.ts @@ -1775,6 +1775,68 @@ export function createQuestMachine< TContext extends Record, TEvent = unknown >(options: QuestMachineOptions): QuestMachine; + +/** + * Lighting falloff mode identifiers. + * Use for: controlling light intensity attenuation. + * Import: gameplay/lighting.ts + */ +export type FalloffMode = 'linear' | 'quadratic' | 'smoothstep'; + +/** + * Point light definition for lighting grids. + * Use for: positioning lights with radius and color. + * Import: gameplay/lighting.ts + */ +export interface PointLight { + x: number; + y: number; + radius: number; + intensity?: number; + falloff?: FalloffMode; + color?: [number, number, number]; +} + +/** + * Lighting grid configuration. + * Use for: computing lightmaps for tile-based scenes. + * Import: gameplay/lighting.ts + */ +export interface LightingGridOptions { + width: number; + height: number; + tileSize: number; + ambient?: number; + lights: ReadonlyArray; + obstacles?: (x: number, y: number) => boolean; +} + +/** + * Lighting cell output containing intensity and blended color. + * Import: gameplay/lighting.ts + */ +export interface LightingCell { + light: number; + color: [number, number, number]; +} + +/** + * Lighting grid computation result. + * Import: gameplay/lighting.ts + */ +export interface LightingGridResult { + width: number; + height: number; + cells: LightingCell[]; +} + +/** + * Computes a lighting grid with point lights and ambient light. + * Use for: tile map lighting, fog-of-war, and shading overlays. + * Performance: O(width × height × lights). + * Import: gameplay/lighting.ts + */ +export function computeLightingGrid(options: LightingGridOptions): LightingGridResult; /** * Item insertion payload used by the inventory controller. * Use for: adding items with quantity and metadata. diff --git a/examples/lighting.ts b/examples/lighting.ts new file mode 100644 index 0000000..1c50df0 --- /dev/null +++ b/examples/lighting.ts @@ -0,0 +1,14 @@ +import { computeLightingGrid } from '../src/index.js'; + +const lighting = computeLightingGrid({ + width: 5, + height: 5, + tileSize: 16, + ambient: 0.2, + lights: [ + { x: 40, y: 40, radius: 64, intensity: 1, color: [1, 0.9, 0.7] }, + { x: 80, y: 16, radius: 48, intensity: 0.8, color: [0.6, 0.8, 1] }, + ], +}); + +console.log(lighting); diff --git a/examples/quest.ts b/examples/quest.ts index 433f3a6..4ddcda5 100644 --- a/examples/quest.ts +++ b/examples/quest.ts @@ -1,6 +1,6 @@ import { createQuestMachine } from '../src/index.js'; -const quest = createQuestMachine({ +const quest = createQuestMachine<{ reputation: number; reward: number }, { hasItem?: boolean }>({ initial: 'start', context: { reputation: 0, reward: 0 }, states: [ diff --git a/src/gameplay/lighting.ts b/src/gameplay/lighting.ts new file mode 100644 index 0000000..8a5912d --- /dev/null +++ b/src/gameplay/lighting.ts @@ -0,0 +1,127 @@ +export type FalloffMode = 'linear' | 'quadratic' | 'smoothstep'; + +export interface PointLight { + x: number; + y: number; + radius: number; + intensity?: number; + falloff?: FalloffMode; + color?: [number, number, number]; +} + +export interface LightingGridOptions { + width: number; + height: number; + tileSize: number; + ambient?: number; + lights: ReadonlyArray; + obstacles?: (x: number, y: number) => boolean; +} + +export interface LightingCell { + light: number; + color: [number, number, number]; +} + +export interface LightingGridResult { + width: number; + height: number; + cells: LightingCell[]; +} + +function assertPositive(value: number, label: string): void { + if (typeof value !== 'number' || Number.isNaN(value) || !Number.isFinite(value) || value <= 0) { + throw new Error(`${label} must be a positive finite number.`); + } +} + +function clamp(value: number, min: number, max: number): number { + if (value < min) return min; + if (value > max) return max; + return value; +} + +function falloff(distance: number, radius: number, mode: FalloffMode): number { + const t = clamp(distance / radius, 0, 1); + switch (mode) { + case 'linear': + return 1 - t; + case 'quadratic': + return 1 - t * t; + case 'smoothstep': + return 1 - (t * t * (3 - 2 * t)); + default: + return 1 - t; + } +} + +function blendColor(base: [number, number, number], add: [number, number, number], weight: number): [number, number, number] { + return [ + clamp(base[0] + add[0] * weight, 0, 1), + clamp(base[1] + add[1] * weight, 0, 1), + clamp(base[2] + add[2] * weight, 0, 1), + ]; +} + +function defaultColor(): [number, number, number] { + return [0, 0, 0]; +} + +/** + * Calculates a lighting grid for 2D tile maps with simple falloff. + * Useful for: top-down games, roguelike rendering, and fog-of-war overlays. + */ +export function computeLightingGrid(options: LightingGridOptions): LightingGridResult { + if (!Array.isArray(options.lights) || options.lights.length === 0) { + throw new Error('lights must contain at least one point light.'); + } + assertPositive(options.width, 'width'); + assertPositive(options.height, 'height'); + assertPositive(options.tileSize, 'tileSize'); + + const ambient = clamp(options.ambient ?? 0.1, 0, 1); + const obstacles = options.obstacles ?? (() => false); + + const cells: LightingCell[] = []; + + const lights = options.lights as ReadonlyArray; + + for (let y = 0; y < options.height; y += 1) { + for (let x = 0; x < options.width; x += 1) { + let light = ambient; + let color: [number, number, number] = defaultColor(); + color = blendColor(color, [ambient, ambient, ambient], 1); + + if (obstacles(x, y)) { + cells.push({ light, color }); + continue; + } + + const worldX = (x + 0.5) * options.tileSize; + const worldY = (y + 0.5) * options.tileSize; + + for (const point of lights) { + assertPositive(point.radius, 'light.radius'); + const dx = worldX - point.x; + const dy = worldY - point.y; + const distance = Math.sqrt(dx * dx + dy * dy); + if (distance > point.radius) { + continue; + } + const intensity = clamp(point.intensity ?? 1, 0, 10); + const mode = point.falloff ?? 'smoothstep'; + const percent = falloff(distance, point.radius, mode) * intensity; + light = clamp(light + percent, 0, 1); + color = blendColor(color, point.color ?? [1, 0.95, 0.8], percent); + } + + cells.push({ light, color }); + } + } + + return { + width: options.width, + height: options.height, + cells, + }; +} diff --git a/src/index.ts b/src/index.ts index 7ee2fc0..799bf60 100644 --- a/src/index.ts +++ b/src/index.ts @@ -104,6 +104,7 @@ export const examples = { calculateDamage: 'examples/combat.ts', createCooldownController: 'examples/combat.ts', createQuestMachine: 'examples/quest.ts', + computeLightingGrid: 'examples/lighting.ts', }, ai: { seek: 'examples/steering.ts', @@ -685,6 +686,21 @@ export type { QuestMachineSnapshot, } from './gameplay/questMachine.js'; +/** + * 2D lighting helpers for tile maps. + * + * Example file: examples/lighting.ts + */ +export { computeLightingGrid } from './gameplay/lighting.js'; + +export type { + LightingGridOptions, + LightingGridResult, + LightingCell, + PointLight, + FalloffMode, +} from './gameplay/lighting.js'; + // ============================================================================ // 🔍 SEARCH & STRING UTILITIES diff --git a/tests/index.test.ts b/tests/index.test.ts index 0bd1e60..71e90e4 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -114,8 +114,8 @@ describe('package entry point', () => { | 'createInventory' | 'calculateDamage' | 'createCooldownController' - | 'updateStatusEffects' | 'createQuestMachine' + | 'computeLightingGrid' >(); }); }); diff --git a/tests/lighting.test.ts b/tests/lighting.test.ts new file mode 100644 index 0000000..d6ec588 --- /dev/null +++ b/tests/lighting.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; + +import { computeLightingGrid } from '../src/index.js'; + +describe('computeLightingGrid', () => { + it('applies falloff and ambient light', () => { + const result = computeLightingGrid({ + width: 3, + height: 3, + tileSize: 1, + ambient: 0.1, + lights: [ + { x: 1.5, y: 1.5, radius: 2, intensity: 1, falloff: 'linear', color: [1, 0.8, 0.6] }, + ], + }); + + expect(result.cells).toHaveLength(9); + const center = result.cells[4]; + expect(center.light).toBeGreaterThan(0.9); + const corner = result.cells[0]; + expect(corner.light).toBeGreaterThan(0.1); + expect(corner.light).toBeLessThan(center.light); + }); + + it('respects obstacles', () => { + const result = computeLightingGrid({ + width: 3, + height: 3, + tileSize: 1, + ambient: 0, + lights: [{ x: 0.5, y: 0.5, radius: 3 }], + obstacles: (x, y) => x === 1 && y === 0, + }); + + const blocked = result.cells[1]; + expect(blocked.light).toBe(0); + const next = result.cells[2]; + expect(next.light).toBeGreaterThan(0); + }); +}); diff --git a/tests/questMachine.test.ts b/tests/questMachine.test.ts index 622f1ca..e0a89eb 100644 --- a/tests/questMachine.test.ts +++ b/tests/questMachine.test.ts @@ -6,7 +6,7 @@ describe('createQuestMachine', () => { it('advances states based on events and conditions', () => { const context = { reputation: 0, reward: 0 }; - const machine = createQuestMachine({ + const machine = createQuestMachine<{ reputation: number; reward: number }, { hasItem?: boolean }>({ context, initial: 'start', states: [ @@ -50,7 +50,7 @@ describe('createQuestMachine', () => { }); it('serializes and resets state', () => { - const machine = createQuestMachine({ + const machine = createQuestMachine<{ progress: number }, unknown>({ context: { progress: 0 }, initial: 'start', states: [{ id: 'start' }, { id: 'end', terminal: true }],