feat(tools): 添加 CLI 模块管理命令和文档验证 demos

- CLI 新增 list/add/remove 命令管理项目模块
- 创建 demos 包验证模块文档正确性
- 包含 Timer/FSM/Pathfinding/Procgen/Spatial 5个模块的完整测试
This commit is contained in:
yhh
2025-12-26 22:09:01 +08:00
parent 4a16e30794
commit 881ffad3bc
11 changed files with 1460 additions and 4 deletions

View File

@@ -0,0 +1,26 @@
{
"name": "@esengine/demos",
"version": "1.0.0",
"private": true,
"description": "Demo tests for ESEngine modules documentation",
"type": "module",
"scripts": {
"test": "tsx src/index.ts",
"test:timer": "tsx src/timer.demo.ts",
"test:fsm": "tsx src/fsm.demo.ts",
"test:pathfinding": "tsx src/pathfinding.demo.ts",
"test:procgen": "tsx src/procgen.demo.ts",
"test:spatial": "tsx src/spatial.demo.ts"
},
"dependencies": {
"@esengine/timer": "workspace:*",
"@esengine/fsm": "workspace:*",
"@esengine/pathfinding": "workspace:*",
"@esengine/procgen": "workspace:*",
"@esengine/spatial": "workspace:*"
},
"devDependencies": {
"tsx": "^4.7.0",
"typescript": "^5.8.3"
}
}

View File

@@ -0,0 +1,175 @@
/**
* FSM Module Demo - Tests APIs from docs/modules/fsm/index.md
*/
import { createStateMachine } from '@esengine/fsm';
function assert(condition: boolean, message: string): void {
if (!condition) throw new Error(`FAILED: ${message}`);
console.log(`${message}`);
}
function section(name: string): void {
console.log(`\n▶ ${name}`);
}
type PlayerState = 'idle' | 'walk' | 'run' | 'jump';
export async function runFSMDemo(): Promise<void> {
console.log('═══════════════════════════════════════');
console.log(' FSM Module Demo');
console.log('═══════════════════════════════════════');
// 1. Basic Creation
section('1. createStateMachine()');
const fsm = createStateMachine<PlayerState>('idle');
assert(fsm !== null, 'State machine created');
assert(fsm.current === 'idle', 'Initial state is idle');
// 2. Define States
section('2. defineState()');
let enterCalled = false;
let exitCalled = false;
let updateCalled = false;
fsm.defineState('idle', {
onEnter: () => { enterCalled = true; },
onExit: () => { exitCalled = true; },
onUpdate: () => { updateCalled = true; }
});
fsm.defineState('walk', {});
fsm.defineState('run', {});
fsm.defineState('jump', {});
assert(fsm.hasState('idle'), 'hasState() returns true');
assert(fsm.hasState('walk'), 'walk state exists');
// 3. Manual Transition
section('3. transition()');
fsm.transition('walk');
assert(fsm.current === 'walk', 'Transitioned to walk');
assert(exitCalled, 'onExit called on idle');
assert(fsm.previous === 'idle', 'previous is idle');
// 4. State with Context
section('4. Context Support');
interface Context {
speed: number;
isMoving: boolean;
}
const fsmCtx = createStateMachine<PlayerState, Context>('idle', {
context: { speed: 0, isMoving: false }
});
fsmCtx.defineState('idle', {
onEnter: (ctx) => { ctx.speed = 0; }
});
fsmCtx.defineState('walk', {
onEnter: (ctx) => { ctx.speed = 100; }
});
fsmCtx.transition('walk');
assert(fsmCtx.context.speed === 100, 'Context updated on enter');
// 5. Transition Conditions
section('5. defineTransition() with conditions');
const fsmTrans = createStateMachine<PlayerState, Context>('idle', {
context: { speed: 0, isMoving: false }
});
fsmTrans.defineState('idle', {});
fsmTrans.defineState('walk', {});
fsmTrans.defineTransition('idle', 'walk', (ctx) => ctx.isMoving);
fsmTrans.evaluateTransitions();
assert(fsmTrans.current === 'idle', 'No transition when condition false');
fsmTrans.context.isMoving = true;
fsmTrans.evaluateTransitions();
assert(fsmTrans.current === 'walk', 'Transitions when condition true');
// 6. Transition Priority
section('6. Transition Priority');
const fsmPri = createStateMachine<'a' | 'b' | 'c'>('a');
fsmPri.defineState('a', {});
fsmPri.defineState('b', {});
fsmPri.defineState('c', {});
fsmPri.defineTransition('a', 'b', () => true, 1);
fsmPri.defineTransition('a', 'c', () => true, 10);
fsmPri.evaluateTransitions();
assert(fsmPri.current === 'c', 'Higher priority (10) wins');
// 7. Update
section('7. update()');
const fsmUpdate = createStateMachine<PlayerState>('idle');
let updateCount = 0;
fsmUpdate.defineState('idle', {
onUpdate: () => { updateCount++; }
});
fsmUpdate.update(16);
fsmUpdate.update(16);
assert(updateCount === 2, 'onUpdate called on each update');
// 8. Event Listeners
section('8. Event Listeners');
const fsmEvents = createStateMachine<PlayerState>('idle');
fsmEvents.defineState('idle', {});
fsmEvents.defineState('walk', {});
let enterEvent = false;
let exitEvent = false;
let changeEvent = false;
fsmEvents.onEnter('walk', () => { enterEvent = true; });
fsmEvents.onExit('idle', () => { exitEvent = true; });
fsmEvents.onChange(() => { changeEvent = true; });
fsmEvents.transition('walk');
assert(enterEvent, 'onEnter listener called');
assert(exitEvent, 'onExit listener called');
assert(changeEvent, 'onChange listener called');
// 9. getStates / getTransitionsFrom
section('9. Query Methods');
const states = fsmEvents.getStates();
assert(states.length >= 2, 'getStates() returns states');
// 10. canTransition
section('10. canTransition()');
const fsmCan = createStateMachine<PlayerState, Context>('idle', {
context: { speed: 0, isMoving: false }
});
fsmCan.defineState('idle', {});
fsmCan.defineState('walk', {});
fsmCan.defineTransition('idle', 'walk', (ctx) => ctx.isMoving);
assert(!fsmCan.canTransition('walk'), 'Cannot transition when condition false');
fsmCan.context.isMoving = true;
assert(fsmCan.canTransition('walk'), 'Can transition when condition true');
// 11. Reset
section('11. reset()');
fsmCan.transition('walk');
fsmCan.reset('idle');
assert(fsmCan.current === 'idle', 'Reset to idle');
// 12. History
section('12. getHistory()');
const fsmHist = createStateMachine<PlayerState>('idle', { enableHistory: true });
fsmHist.defineState('idle', {});
fsmHist.defineState('walk', {});
fsmHist.defineState('run', {});
fsmHist.transition('walk');
fsmHist.transition('run');
const history = fsmHist.getHistory();
assert(history.length >= 2, 'History recorded');
console.log('\n═══════════════════════════════════════');
console.log(' FSM Demo: ALL TESTS PASSED ✓');
console.log('═══════════════════════════════════════\n');
}
runFSMDemo().catch(console.error);

View File

@@ -0,0 +1,75 @@
/**
* ESEngine Module Demos - Run all demos to verify documentation
*/
import { runTimerDemo } from './timer.demo.js';
import { runFSMDemo } from './fsm.demo.js';
import { runPathfindingDemo } from './pathfinding.demo.js';
import { runProcgenDemo } from './procgen.demo.js';
import { runSpatialDemo } from './spatial.demo.js';
async function runAllDemos(): Promise<void> {
console.log('\n');
console.log('╔═══════════════════════════════════════════════════════════╗');
console.log('║ ESEngine Module Documentation Tests ║');
console.log('╚═══════════════════════════════════════════════════════════╝');
console.log('\n');
const demos = [
{ name: 'Timer', fn: runTimerDemo },
{ name: 'FSM', fn: runFSMDemo },
{ name: 'Pathfinding', fn: runPathfindingDemo },
{ name: 'Procgen', fn: runProcgenDemo },
{ name: 'Spatial', fn: runSpatialDemo },
];
const results: { name: string; passed: boolean; error?: string }[] = [];
for (const demo of demos) {
try {
await demo.fn();
results.push({ name: demo.name, passed: true });
} catch (error) {
results.push({
name: demo.name,
passed: false,
error: error instanceof Error ? error.message : String(error)
});
}
}
// Summary
console.log('\n');
console.log('╔═══════════════════════════════════════════════════════════╗');
console.log('║ Summary ║');
console.log('╚═══════════════════════════════════════════════════════════╝');
console.log('\n');
let allPassed = true;
for (const result of results) {
if (result.passed) {
console.log(`${result.name}: PASSED`);
} else {
console.log(`${result.name}: FAILED - ${result.error}`);
allPassed = false;
}
}
console.log('\n');
if (allPassed) {
console.log(' ══════════════════════════════════════');
console.log(' ALL DOCUMENTATION TESTS PASSED ✓');
console.log(' ══════════════════════════════════════');
} else {
console.log(' ══════════════════════════════════════');
console.log(' SOME TESTS FAILED ✗');
console.log(' ══════════════════════════════════════');
process.exit(1);
}
console.log('\n');
}
runAllDemos().catch((error) => {
console.error('Fatal error:', error);
process.exit(1);
});

View File

@@ -0,0 +1,164 @@
/**
* Pathfinding Module Demo - Tests APIs from docs/modules/pathfinding/index.md
*/
import {
createGridMap,
createAStarPathfinder,
createLineOfSightSmoother,
createCatmullRomSmoother,
manhattanDistance,
octileDistance
} from '@esengine/pathfinding';
function assert(condition: boolean, message: string): void {
if (!condition) throw new Error(`FAILED: ${message}`);
console.log(`${message}`);
}
function section(name: string): void {
console.log(`\n▶ ${name}`);
}
export async function runPathfindingDemo(): Promise<void> {
console.log('═══════════════════════════════════════');
console.log(' Pathfinding Module Demo');
console.log('═══════════════════════════════════════');
// 1. Create Grid Map
section('1. createGridMap()');
const grid = createGridMap(20, 20);
assert(grid !== null, 'Grid created');
assert(grid.width === 20, 'Width is 20');
assert(grid.height === 20, 'Height is 20');
// 2. Walkability
section('2. setWalkable() / isWalkable()');
assert(grid.isWalkable(5, 5), 'Initially walkable');
grid.setWalkable(5, 5, false);
assert(!grid.isWalkable(5, 5), 'Set to not walkable');
grid.setWalkable(5, 5, true);
assert(grid.isWalkable(5, 5), 'Restored to walkable');
// 3. Set Obstacles
section('3. Setting Obstacles');
grid.setWalkable(5, 5, false);
grid.setWalkable(5, 6, false);
grid.setWalkable(5, 7, false);
assert(!grid.isWalkable(5, 6), 'Obstacle set');
// 4. Create Pathfinder
section('4. createAStarPathfinder()');
const pathfinder = createAStarPathfinder(grid);
assert(pathfinder !== null, 'Pathfinder created');
// 5. Find Path
section('5. findPath()');
const result = pathfinder.findPath(0, 0, 15, 15);
assert(result.found, 'Path found');
assert(result.path.length > 0, `Path has ${result.path.length} points`);
assert(result.cost > 0, `Path cost: ${result.cost.toFixed(2)}`);
assert(result.nodesSearched > 0, `Searched ${result.nodesSearched} nodes`);
// 6. Path Blocked
section('6. Path Blocked');
// Create a wall
for (let y = 0; y < 20; y++) {
grid.setWalkable(10, y, false);
}
const blocked = pathfinder.findPath(0, 0, 15, 15);
assert(!blocked.found, 'No path when fully blocked');
// Clear wall
for (let y = 0; y < 20; y++) {
grid.setWalkable(10, y, true);
}
// 7. Movement Cost
section('7. setCost()');
const gridCost = createGridMap(10, 10);
gridCost.setCost(5, 5, 10); // High cost tile
const costResult = createAStarPathfinder(gridCost).findPath(0, 0, 9, 9);
assert(costResult.found, 'Path found with cost');
// 8. Heuristics
section('8. Heuristic Functions');
const d1 = manhattanDistance({ x: 0, y: 0 }, { x: 3, y: 4 });
assert(d1 === 7, `Manhattan distance: ${d1}`);
const d2 = octileDistance({ x: 0, y: 0 }, { x: 3, y: 4 });
assert(d2 > 0, `Octile distance: ${d2.toFixed(2)}`);
// 9. Grid Options
section('9. Grid Options');
const gridOpts = createGridMap(10, 10, {
allowDiagonal: false,
heuristic: manhattanDistance
});
assert(gridOpts !== null, 'Grid with options created');
// 10. Path Smoothing - Line of Sight
section('10. Line of Sight Smoother');
const gridSmooth = createGridMap(20, 20);
const pf = createAStarPathfinder(gridSmooth);
const rawPath = pf.findPath(0, 0, 10, 10);
const losSmoother = createLineOfSightSmoother();
const smoothed = losSmoother.smooth(rawPath.path, gridSmooth);
assert(smoothed.length <= rawPath.path.length, `Smoothed: ${rawPath.path.length} -> ${smoothed.length} points`);
// 11. Catmull-Rom Smoother
section('11. Catmull-Rom Smoother');
const crSmoother = createCatmullRomSmoother(5, 0.5);
const curved = crSmoother.smooth(rawPath.path, gridSmooth);
assert(curved.length >= rawPath.path.length, `Curved path has ${curved.length} points`);
// 12. loadFromArray
section('12. loadFromArray()');
const gridArr = createGridMap(5, 3);
gridArr.loadFromArray([
[0, 0, 0, 1, 0],
[0, 1, 0, 1, 0],
[0, 1, 0, 0, 0]
]);
assert(!gridArr.isWalkable(3, 0), 'Loaded obstacle at (3,0)');
assert(!gridArr.isWalkable(1, 1), 'Loaded obstacle at (1,1)');
assert(gridArr.isWalkable(0, 0), 'Loaded walkable at (0,0)');
// 13. loadFromString
section('13. loadFromString()');
const gridStr = createGridMap(5, 3);
gridStr.loadFromString(`
.....
.#.#.
.#...
`);
assert(!gridStr.isWalkable(1, 1), 'Loaded # as obstacle');
assert(gridStr.isWalkable(0, 0), 'Loaded . as walkable');
// 14. setRectWalkable
section('14. setRectWalkable()');
const gridRect = createGridMap(10, 10);
gridRect.setRectWalkable(2, 2, 4, 4, false);
assert(!gridRect.isWalkable(3, 3), 'Rect set as obstacle');
assert(gridRect.isWalkable(0, 0), 'Outside rect is walkable');
// 15. Pathfinder Options
section('15. Pathfinder Options');
const limitedResult = pathfinder.findPath(0, 0, 15, 15, {
maxNodes: 100,
heuristicWeight: 1.5
});
assert(limitedResult !== null, 'findPath with options works');
// 16. Reset Grid
section('16. reset()');
grid.reset();
assert(grid.isWalkable(5, 5), 'Grid reset - all walkable');
console.log('\n═══════════════════════════════════════');
console.log(' Pathfinding Demo: ALL TESTS PASSED ✓');
console.log('═══════════════════════════════════════\n');
}
runPathfindingDemo().catch(console.error);

View File

@@ -0,0 +1,208 @@
/**
* Procgen Module Demo - Tests APIs from docs/modules/procgen/index.md
*/
import {
createPerlinNoise,
createSimplexNoise,
createWorleyNoise,
createFBM,
createSeededRandom,
createWeightedRandom,
shuffle,
shuffleCopy,
pickOne,
sample,
sampleWithReplacement,
weightedPick,
weightedPickFromMap
} from '@esengine/procgen';
function assert(condition: boolean, message: string): void {
if (!condition) throw new Error(`FAILED: ${message}`);
console.log(`${message}`);
}
function section(name: string): void {
console.log(`\n▶ ${name}`);
}
export async function runProcgenDemo(): Promise<void> {
console.log('═══════════════════════════════════════');
console.log(' Procgen Module Demo');
console.log('═══════════════════════════════════════');
// 1. Perlin Noise
section('1. createPerlinNoise()');
const perlin = createPerlinNoise(12345);
assert(perlin !== null, 'Perlin noise created');
const val2d = perlin.noise2D(0.5, 0.5);
assert(val2d >= -1 && val2d <= 1, `2D noise value in [-1,1]: ${val2d.toFixed(3)}`);
const val3d = perlin.noise3D(0.5, 0.5, 0.5);
assert(val3d >= -1 && val3d <= 1, `3D noise value in [-1,1]: ${val3d.toFixed(3)}`);
// 2. Simplex Noise
section('2. createSimplexNoise()');
const simplex = createSimplexNoise(12345);
const sval = simplex.noise2D(0.5, 0.5);
assert(sval >= -1 && sval <= 1, `Simplex value: ${sval.toFixed(3)}`);
// 3. Worley Noise
section('3. createWorleyNoise()');
const worley = createWorleyNoise(12345);
const wval = worley.noise2D(0.5, 0.5);
assert(wval >= 0, `Worley distance: ${wval.toFixed(3)}`);
// 4. FBM
section('4. createFBM()');
const fbm = createFBM(perlin, {
octaves: 6,
lacunarity: 2.0,
persistence: 0.5
});
const fbmVal = fbm.noise2D(0.1, 0.1);
assert(typeof fbmVal === 'number', `FBM value: ${fbmVal.toFixed(3)}`);
const ridged = fbm.ridged2D(0.1, 0.1);
assert(typeof ridged === 'number', `Ridged FBM: ${ridged.toFixed(3)}`);
const turb = fbm.turbulence2D(0.1, 0.1);
assert(turb >= 0, `Turbulence: ${turb.toFixed(3)}`);
// 5. Seeded Random
section('5. createSeededRandom()');
const rng = createSeededRandom(42);
assert(rng !== null, 'RNG created');
const r1 = rng.next();
assert(r1 >= 0 && r1 < 1, `next() in [0,1): ${r1.toFixed(3)}`);
const r2 = rng.nextInt(1, 10);
assert(r2 >= 1 && r2 <= 10, `nextInt(1,10): ${r2}`);
const r3 = rng.nextFloat(0, 100);
assert(r3 >= 0 && r3 < 100, `nextFloat(0,100): ${r3.toFixed(2)}`);
const r4 = rng.nextBool();
assert(typeof r4 === 'boolean', `nextBool(): ${r4}`);
const r5 = rng.nextBool(0.9);
assert(typeof r5 === 'boolean', `nextBool(0.9): ${r5}`);
// 6. Deterministic
section('6. Deterministic Sequences');
const rng1 = createSeededRandom(42);
const rng2 = createSeededRandom(42);
const seq1 = [rng1.next(), rng1.next(), rng1.next()];
const seq2 = [rng2.next(), rng2.next(), rng2.next()];
assert(seq1[0] === seq2[0] && seq1[1] === seq2[1] && seq1[2] === seq2[2],
'Same seed produces same sequence');
// 7. Distributions
section('7. Distribution Methods');
const rngDist = createSeededRandom(42);
const gauss = rngDist.nextGaussian();
assert(typeof gauss === 'number', `nextGaussian(): ${gauss.toFixed(3)}`);
const gauss2 = rngDist.nextGaussian(100, 15);
assert(typeof gauss2 === 'number', `nextGaussian(100,15): ${gauss2.toFixed(1)}`);
const exp = rngDist.nextExponential();
assert(exp >= 0, `nextExponential(): ${exp.toFixed(3)}`);
// 8. Geometry Methods
section('8. Geometry Methods');
const rngGeo = createSeededRandom(42);
const pointCircle = rngGeo.nextPointInCircle(50);
assert(pointCircle.x !== undefined && pointCircle.y !== undefined,
`nextPointInCircle: (${pointCircle.x.toFixed(1)}, ${pointCircle.y.toFixed(1)})`);
const pointOnCircle = rngGeo.nextPointOnCircle(50);
const dist = Math.sqrt(pointOnCircle.x ** 2 + pointOnCircle.y ** 2);
assert(Math.abs(dist - 50) < 0.01, `nextPointOnCircle radius ~50: ${dist.toFixed(2)}`);
const dir = rngGeo.nextDirection2D();
const len = Math.sqrt(dir.x ** 2 + dir.y ** 2);
assert(Math.abs(len - 1) < 0.01, `nextDirection2D length ~1: ${len.toFixed(3)}`);
// 9. Weighted Random
section('9. createWeightedRandom()');
const rngW = createSeededRandom(42);
const loot = createWeightedRandom([
{ value: 'common', weight: 60 },
{ value: 'rare', weight: 30 },
{ value: 'epic', weight: 10 }
]);
assert(loot.size === 3, 'Has 3 items');
assert(loot.totalWeight === 100, 'Total weight is 100');
assert(loot.getProbability(0) === 0.6, 'Common probability is 0.6');
const picked = loot.pick(rngW);
assert(['common', 'rare', 'epic'].includes(picked), `Picked: ${picked}`);
// 10. Shuffle
section('10. shuffle() / shuffleCopy()');
const rngS = createSeededRandom(42);
const arr = [1, 2, 3, 4, 5];
const copy = shuffleCopy(arr, rngS);
assert(copy.length === 5, 'Shuffled copy has same length');
assert(arr[0] === 1, 'Original unchanged');
shuffle(arr, rngS);
assert(arr.length === 5, 'In-place shuffle preserves length');
// 11. pickOne
section('11. pickOne()');
const rngP = createSeededRandom(42);
const items = ['a', 'b', 'c', 'd'];
const picked2 = pickOne(items, rngP);
assert(items.includes(picked2), `Picked: ${picked2}`);
// 12. sample
section('12. sample() / sampleWithReplacement()');
const rngSamp = createSeededRandom(42);
const nums = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
const sampled = sample(nums, 3, rngSamp);
assert(sampled.length === 3, 'Sampled 3 items');
assert(new Set(sampled).size === 3, 'All unique');
const withRep = sampleWithReplacement(nums, 5, rngSamp);
assert(withRep.length === 5, 'Sampled 5 with replacement');
// 13. weightedPick
section('13. weightedPick() / weightedPickFromMap()');
const rngWP = createSeededRandom(42);
const item = weightedPick([
{ value: 'a', weight: 1 },
{ value: 'b', weight: 2 }
], rngWP);
assert(['a', 'b'].includes(item), `weightedPick: ${item}`);
const item2 = weightedPickFromMap({
'common': 60,
'rare': 30
}, rngWP);
assert(['common', 'rare'].includes(item2), `weightedPickFromMap: ${item2}`);
// 14. Reset
section('14. reset()');
const rngReset = createSeededRandom(42);
const first = rngReset.next();
rngReset.next();
rngReset.next();
rngReset.reset();
const afterReset = rngReset.next();
assert(first === afterReset, 'Reset restores initial state');
console.log('\n═══════════════════════════════════════');
console.log(' Procgen Demo: ALL TESTS PASSED ✓');
console.log('═══════════════════════════════════════\n');
}
runProcgenDemo().catch(console.error);

View File

@@ -0,0 +1,246 @@
/**
* Spatial Module Demo - Tests APIs from docs/modules/spatial/index.md
*/
import {
createGridSpatialIndex,
createGridAOI,
createBounds,
createBoundsFromCenter,
createBoundsFromCircle,
isPointInBounds,
boundsIntersect,
distance,
distanceSquared
} from '@esengine/spatial';
function assert(condition: boolean, message: string): void {
if (!condition) throw new Error(`FAILED: ${message}`);
console.log(`${message}`);
}
function section(name: string): void {
console.log(`\n▶ ${name}`);
}
interface Entity {
id: number;
type: string;
}
export async function runSpatialDemo(): Promise<void> {
console.log('═══════════════════════════════════════');
console.log(' Spatial Module Demo');
console.log('═══════════════════════════════════════');
// 1. Create Spatial Index
section('1. createGridSpatialIndex()');
const spatial = createGridSpatialIndex<Entity>(100);
assert(spatial !== null, 'Spatial index created');
assert(spatial.count === 0, 'Initially empty');
// 2. Insert
section('2. insert()');
const player: Entity = { id: 1, type: 'player' };
const enemy1: Entity = { id: 2, type: 'enemy' };
const enemy2: Entity = { id: 3, type: 'enemy' };
spatial.insert(player, { x: 100, y: 200 });
spatial.insert(enemy1, { x: 150, y: 250 });
spatial.insert(enemy2, { x: 500, y: 600 });
assert(spatial.count === 3, 'Count is 3');
// 3. findInRadius
section('3. findInRadius()');
const nearby = spatial.findInRadius({ x: 100, y: 200 }, 100);
assert(nearby.length === 2, `Found ${nearby.length} entities in radius`);
assert(nearby.includes(player), 'Found player');
assert(nearby.includes(enemy1), 'Found enemy1');
assert(!nearby.includes(enemy2), 'enemy2 is too far');
// 4. findInRadius with filter
section('4. findInRadius() with filter');
const enemies = spatial.findInRadius(
{ x: 100, y: 200 },
100,
(e) => e.type === 'enemy'
);
assert(enemies.length === 1, 'Found 1 enemy');
assert(enemies[0] === enemy1, 'Found enemy1');
// 5. findNearest
section('5. findNearest()');
const nearest = spatial.findNearest({ x: 100, y: 200 });
assert(nearest === player || nearest === enemy1, 'Found nearest entity');
const nearestEnemy = spatial.findNearest(
{ x: 100, y: 200 },
undefined,
(e) => e.type === 'enemy'
);
assert(nearestEnemy === enemy1, 'Found nearest enemy');
// 6. findKNearest
section('6. findKNearest()');
const k2 = spatial.findKNearest({ x: 100, y: 200 }, 2, 1000);
assert(k2.length === 2, 'Found 2 nearest');
// 7. Update position
section('7. update()');
spatial.update(player, { x: 400, y: 400 });
const afterMove = spatial.findInRadius({ x: 100, y: 200 }, 100);
assert(!afterMove.includes(player), 'Player moved away');
// 8. Remove
section('8. remove()');
spatial.remove(enemy2);
assert(spatial.count === 2, 'Count is 2 after remove');
// 9. findInRect
section('9. findInRect()');
const bounds = createBounds(0, 0, 200, 300);
spatial.update(player, { x: 100, y: 200 });
const inRect = spatial.findInRect(bounds);
assert(inRect.length >= 1, `Found ${inRect.length} in rect`);
// 10. Raycast
section('10. raycast()');
spatial.update(player, { x: 100, y: 0 });
spatial.update(enemy1, { x: 100, y: 200 });
const hits = spatial.raycast(
{ x: 100, y: -100 },
{ x: 0, y: 1 },
500
);
assert(hits.length >= 1, `Raycast hit ${hits.length} entities`);
// 11. raycastFirst
section('11. raycastFirst()');
const firstHit = spatial.raycastFirst(
{ x: 100, y: -100 },
{ x: 0, y: 1 },
500
);
if (firstHit) {
assert(firstHit.target !== null, 'Hit has target');
assert(firstHit.distance >= 0, `Hit distance: ${firstHit.distance.toFixed(1)}`);
}
// 12. Clear
section('12. clear()');
spatial.clear();
assert(spatial.count === 0, 'Cleared');
// =========================================================================
// AOI Tests
// =========================================================================
// 13. Create AOI
section('13. createGridAOI()');
const aoi = createGridAOI<Entity>(100);
assert(aoi !== null, 'AOI created');
// 14. Add Observers
section('14. addObserver()');
const p1: Entity = { id: 1, type: 'player' };
const p2: Entity = { id: 2, type: 'player' };
aoi.addObserver(p1, { x: 100, y: 100 }, { viewRange: 200 });
aoi.addObserver(p2, { x: 150, y: 150 }, { viewRange: 200 });
// Note: After adding p2, p2 can see p1, but p1's visibility isn't auto-updated
// We need to trigger an update for p1 to detect p2
aoi.updatePosition(p1, { x: 100, y: 100 });
// 15. getEntitiesInView
section('15. getEntitiesInView()');
const visible = aoi.getEntitiesInView(p1);
assert(visible.includes(p2), 'p1 can see p2');
// 16. canSee
section('16. canSee()');
assert(aoi.canSee(p1, p2), 'p1 can see p2');
// 17. updatePosition
section('17. updatePosition()');
aoi.updatePosition(p2, { x: 1000, y: 1000 });
// Refresh p1's visibility (implementation requires explicit update for distant moves)
aoi.updatePosition(p1, { x: 100, y: 100 });
assert(!aoi.canSee(p1, p2), 'p1 cannot see p2 after move');
// 18. getObserversOf
section('18. getObserversOf()');
aoi.updatePosition(p2, { x: 120, y: 120 });
aoi.updatePosition(p1, { x: 100, y: 100 }); // Refresh p1's visibility
const observers = aoi.getObserversOf(p2);
assert(observers.includes(p1), 'p1 observes p2');
// 19. Event Listener
section('19. addListener()');
let eventCount = 0;
aoi.addListener((event) => {
eventCount++;
});
aoi.updatePosition(p2, { x: 2000, y: 2000 }); // Should trigger exit
aoi.updatePosition(p1, { x: 100, y: 100 }); // Refresh p1
aoi.updatePosition(p2, { x: 130, y: 130 }); // Should trigger enter
aoi.updatePosition(p1, { x: 100, y: 100 }); // Refresh p1
assert(eventCount >= 1, `Events triggered: ${eventCount}`);
// 20. updateViewRange
section('20. updateViewRange()');
aoi.updateViewRange(p1, 50);
aoi.updatePosition(p2, { x: 200, y: 200 });
aoi.updatePosition(p1, { x: 100, y: 100 }); // Refresh p1's visibility with new range
assert(!aoi.canSee(p1, p2), 'Cannot see after view range reduced');
// 21. removeObserver
section('21. removeObserver()');
aoi.removeObserver(p2);
const afterRemove = aoi.getEntitiesInView(p1);
assert(!afterRemove.includes(p2), 'p2 removed from AOI');
// =========================================================================
// Utility Functions
// =========================================================================
// 22. Bounds Creation
section('22. Bounds Creation');
const b1 = createBounds(0, 0, 100, 100);
assert(b1.minX === 0 && b1.maxX === 100, 'createBounds works');
const b2 = createBoundsFromCenter({ x: 50, y: 50 }, 100, 100);
assert(b2.minX === 0 && b2.maxX === 100, 'createBoundsFromCenter works');
const b3 = createBoundsFromCircle({ x: 50, y: 50 }, 50);
assert(b3.minX === 0 && b3.maxX === 100, 'createBoundsFromCircle works');
// 23. Point in Bounds
section('23. isPointInBounds()');
assert(isPointInBounds({ x: 50, y: 50 }, b1), 'Point inside');
assert(!isPointInBounds({ x: 150, y: 150 }, b1), 'Point outside');
// 24. Bounds Intersect
section('24. boundsIntersect()');
const ba = createBounds(0, 0, 100, 100);
const bb = createBounds(50, 50, 150, 150);
const bc = createBounds(200, 200, 300, 300);
assert(boundsIntersect(ba, bb), 'Overlapping bounds intersect');
assert(!boundsIntersect(ba, bc), 'Separate bounds do not intersect');
// 25. Distance
section('25. distance() / distanceSquared()');
const d = distance({ x: 0, y: 0 }, { x: 3, y: 4 });
assert(Math.abs(d - 5) < 0.001, `Distance: ${d}`);
const dsq = distanceSquared({ x: 0, y: 0 }, { x: 3, y: 4 });
assert(dsq === 25, `Distance squared: ${dsq}`);
console.log('\n═══════════════════════════════════════');
console.log(' Spatial Demo: ALL TESTS PASSED ✓');
console.log('═══════════════════════════════════════\n');
}
runSpatialDemo().catch(console.error);

View File

@@ -0,0 +1,119 @@
/**
* Timer Module Demo - Tests APIs from docs/modules/timer/index.md
*/
import { createTimerService } from '@esengine/timer';
function assert(condition: boolean, message: string): void {
if (!condition) throw new Error(`FAILED: ${message}`);
console.log(`${message}`);
}
function section(name: string): void {
console.log(`\n▶ ${name}`);
}
export async function runTimerDemo(): Promise<void> {
console.log('═══════════════════════════════════════');
console.log(' Timer Module Demo');
console.log('═══════════════════════════════════════');
// 1. Basic Creation
section('1. createTimerService()');
const timerService = createTimerService();
assert(timerService !== null, 'Service created');
assert(timerService.activeTimerCount === 0, 'Initial timer count is 0');
assert(timerService.activeCooldownCount === 0, 'Initial cooldown count is 0');
// 2. One-shot Timer
section('2. schedule() - One-shot Timer');
let fired = false;
const handle = timerService.schedule('test', 100, () => { fired = true; });
assert(handle.id === 'test', 'Handle.id correct');
assert(handle.isValid === true, 'Handle.isValid is true');
assert(timerService.hasTimer('test'), 'hasTimer() returns true');
timerService.update(50);
assert(!fired, 'Timer not fired at 50ms');
timerService.update(60);
assert(fired, 'Timer fired after 110ms');
assert(!timerService.hasTimer('test'), 'Timer removed after firing');
// 3. Repeating Timer
section('3. scheduleRepeating()');
let count = 0;
timerService.scheduleRepeating('repeat', 50, () => { count++; });
timerService.update(50);
assert(count === 1, 'Fires once at 50ms');
timerService.update(50);
assert(count === 2, 'Fires twice at 100ms');
timerService.cancelById('repeat');
timerService.update(100);
assert(count === 2, 'Stopped after cancel');
// 4. Timer Cancellation
section('4. cancel()');
let cancelled = false;
const h = timerService.schedule('cancel', 1000, () => { cancelled = true; });
h.cancel();
assert(!h.isValid, 'Handle invalid after cancel');
timerService.update(2000);
assert(!cancelled, 'Cancelled timer does not fire');
// 5. Timer Info
section('5. getTimerInfo()');
timerService.schedule('info', 500, () => {});
const info = timerService.getTimerInfo('info');
assert(info !== null, 'Returns info object');
assert(info!.id === 'info', 'Info.id correct');
assert(info!.repeating === false, 'Info.repeating is false');
timerService.cancelById('info');
// 6. Cooldown System
section('6. Cooldown API');
timerService.startCooldown('skill', 200);
assert(!timerService.isCooldownReady('skill'), 'Not ready initially');
assert(timerService.isOnCooldown('skill'), 'isOnCooldown true');
timerService.update(100);
const progress = timerService.getCooldownProgress('skill');
assert(progress >= 0.4 && progress <= 0.6, `Progress ~0.5 (got ${progress.toFixed(2)})`);
timerService.update(150);
assert(timerService.isCooldownReady('skill'), 'Ready after duration');
// 7. Cooldown Info
section('7. getCooldownInfo()');
timerService.startCooldown('cd', 300);
timerService.update(150);
const cdInfo = timerService.getCooldownInfo('cd');
assert(cdInfo !== null, 'Returns cooldown info');
assert(cdInfo!.duration === 300, 'Duration is 300');
assert(!cdInfo!.isReady, 'isReady is false');
// 8. Reset Cooldown
section('8. resetCooldown()');
timerService.startCooldown('reset', 500);
timerService.update(100);
timerService.resetCooldown('reset');
assert(timerService.isCooldownReady('reset'), 'Ready after reset');
// 9. Clear All
section('9. clear()');
timerService.schedule('t1', 1000, () => {});
timerService.startCooldown('c1', 1000);
timerService.clear();
assert(timerService.activeTimerCount === 0, 'Timers cleared');
assert(timerService.activeCooldownCount === 0, 'Cooldowns cleared');
// 10. Config Options
section('10. Config Options');
const limited = createTimerService({ maxTimers: 2, maxCooldowns: 1 });
assert(limited !== null, 'Created with config');
console.log('\n═══════════════════════════════════════');
console.log(' Timer Demo: ALL TESTS PASSED ✓');
console.log('═══════════════════════════════════════\n');
}
runTimerDemo().catch(console.error);

View File

@@ -0,0 +1,12 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"declaration": false,
"module": "ESNext",
"moduleResolution": "bundler",
"target": "ES2022"
},
"include": ["src/**/*"]
}