fix(spatial): 修复 GridAOI 可见性更新问题

- 修复 addObserver 时现有观察者无法检测到新实体的问题
- 修复实体远距离移动时观察者可见性未正确更新的问题
- 重构 demos 抽取公共测试工具
This commit is contained in:
yhh
2025-12-26 22:23:03 +08:00
parent 881ffad3bc
commit d66c18041e
9 changed files with 103 additions and 118 deletions

View File

@@ -0,0 +1,9 @@
---
"@esengine/cli": minor
---
feat(cli): 添加模块管理命令
- 新增 `list` 命令:按分类显示可用模块
- 新增 `add [modules...]` 命令:添加模块到项目,支持交互式选择
- 新增 `remove [modules...]` 命令:从项目移除模块,支持确认提示

View File

@@ -0,0 +1,8 @@
---
"@esengine/spatial": patch
---
fix(spatial): 修复 GridAOI 可见性更新问题
- 修复 `addObserver` 时现有观察者无法检测到新实体的问题
- 修复实体远距离移动时观察者可见性未正确更新的问题

View File

@@ -107,8 +107,13 @@ export class GridAOI<T> implements IAOIManager<T> {
this._observers.set(entity, data); this._observers.set(entity, data);
this._addToCell(cellKey, data); this._addToCell(cellKey, data);
// Initial visibility check // Initial visibility check for this observer
this._updateVisibility(data); this._updateVisibility(data);
// Notify other observers about this new entity
if (data.observable) {
this._updateObserversOfEntity(data);
}
} }
/** /**
@@ -398,40 +403,32 @@ export class GridAOI<T> implements IAOIManager<T> {
* @en Update other observers' visibility of an entity * @en Update other observers' visibility of an entity
*/ */
private _updateObserversOfEntity(movedData: AOIObserverData<T>): void { private _updateObserversOfEntity(movedData: AOIObserverData<T>): void {
const cellRadius = Math.ceil(this._getMaxViewRange() / this._cellSize) + 1; // Check all observers for visibility changes
const centerCell = this._getCellCoords(movedData.position); // This handles both: observers who can now see the entity (enter)
// and observers who could see it before but can't anymore (exit)
for (const [, otherData] of this._observers) {
if (otherData === movedData) continue;
for (let dx = -cellRadius; dx <= cellRadius; dx++) { const distSq = distanceSquared(otherData.position, movedData.position);
for (let dy = -cellRadius; dy <= cellRadius; dy++) { const wasVisible = otherData.visibleEntities.has(movedData.entity);
const cellKey = `${centerCell.x + dx},${centerCell.y + dy}`; const isVisible = distSq <= otherData.viewRangeSq;
const cell = this._cells.get(cellKey);
if (!cell) continue;
for (const otherData of cell) { if (isVisible && !wasVisible) {
if (otherData === movedData) continue; otherData.visibleEntities.add(movedData.entity);
this._emitEvent({
const distSq = distanceSquared(otherData.position, movedData.position); type: 'enter',
const wasVisible = otherData.visibleEntities.has(movedData.entity); observer: otherData.entity,
const isVisible = distSq <= otherData.viewRangeSq; target: movedData.entity,
position: movedData.position
if (isVisible && !wasVisible) { }, otherData);
otherData.visibleEntities.add(movedData.entity); } else if (!isVisible && wasVisible) {
this._emitEvent({ otherData.visibleEntities.delete(movedData.entity);
type: 'enter', this._emitEvent({
observer: otherData.entity, type: 'exit',
target: movedData.entity, observer: otherData.entity,
position: movedData.position target: movedData.entity,
}, otherData); position: movedData.position
} else if (!isVisible && wasVisible) { }, otherData);
otherData.visibleEntities.delete(movedData.entity);
this._emitEvent({
type: 'exit',
observer: otherData.entity,
target: movedData.entity,
position: movedData.position
}, otherData);
}
}
} }
} }
} }

View File

@@ -3,22 +3,12 @@
*/ */
import { createStateMachine } from '@esengine/fsm'; import { createStateMachine } from '@esengine/fsm';
import { assert, section, demoHeader, demoFooter } from './utils.js';
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'; type PlayerState = 'idle' | 'walk' | 'run' | 'jump';
export async function runFSMDemo(): Promise<void> { export async function runFSMDemo(): Promise<void> {
console.log('═══════════════════════════════════════'); demoHeader('FSM Module Demo');
console.log(' FSM Module Demo');
console.log('═══════════════════════════════════════');
// 1. Basic Creation // 1. Basic Creation
section('1. createStateMachine()'); section('1. createStateMachine()');
@@ -167,9 +157,7 @@ export async function runFSMDemo(): Promise<void> {
const history = fsmHist.getHistory(); const history = fsmHist.getHistory();
assert(history.length >= 2, 'History recorded'); assert(history.length >= 2, 'History recorded');
console.log('\n═══════════════════════════════════════'); demoFooter('FSM Demo');
console.log(' FSM Demo: ALL TESTS PASSED ✓');
console.log('═══════════════════════════════════════\n');
} }
runFSMDemo().catch(console.error); runFSMDemo().catch(console.error);

View File

@@ -10,20 +10,10 @@ import {
manhattanDistance, manhattanDistance,
octileDistance octileDistance
} from '@esengine/pathfinding'; } from '@esengine/pathfinding';
import { assert, section, demoHeader, demoFooter } from './utils.js';
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> { export async function runPathfindingDemo(): Promise<void> {
console.log('═══════════════════════════════════════'); demoHeader('Pathfinding Module Demo');
console.log(' Pathfinding Module Demo');
console.log('═══════════════════════════════════════');
// 1. Create Grid Map // 1. Create Grid Map
section('1. createGridMap()'); section('1. createGridMap()');
@@ -156,9 +146,7 @@ export async function runPathfindingDemo(): Promise<void> {
grid.reset(); grid.reset();
assert(grid.isWalkable(5, 5), 'Grid reset - all walkable'); assert(grid.isWalkable(5, 5), 'Grid reset - all walkable');
console.log('\n═══════════════════════════════════════'); demoFooter('Pathfinding Demo');
console.log(' Pathfinding Demo: ALL TESTS PASSED ✓');
console.log('═══════════════════════════════════════\n');
} }
runPathfindingDemo().catch(console.error); runPathfindingDemo().catch(console.error);

View File

@@ -17,20 +17,10 @@ import {
weightedPick, weightedPick,
weightedPickFromMap weightedPickFromMap
} from '@esengine/procgen'; } from '@esengine/procgen';
import { assert, section, demoHeader, demoFooter } from './utils.js';
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> { export async function runProcgenDemo(): Promise<void> {
console.log('═══════════════════════════════════════'); demoHeader('Procgen Module Demo');
console.log(' Procgen Module Demo');
console.log('═══════════════════════════════════════');
// 1. Perlin Noise // 1. Perlin Noise
section('1. createPerlinNoise()'); section('1. createPerlinNoise()');
@@ -200,9 +190,7 @@ export async function runProcgenDemo(): Promise<void> {
const afterReset = rngReset.next(); const afterReset = rngReset.next();
assert(first === afterReset, 'Reset restores initial state'); assert(first === afterReset, 'Reset restores initial state');
console.log('\n═══════════════════════════════════════'); demoFooter('Procgen Demo');
console.log(' Procgen Demo: ALL TESTS PASSED ✓');
console.log('═══════════════════════════════════════\n');
} }
runProcgenDemo().catch(console.error); runProcgenDemo().catch(console.error);

View File

@@ -13,15 +13,7 @@ import {
distance, distance,
distanceSquared distanceSquared
} from '@esengine/spatial'; } from '@esengine/spatial';
import { assert, section, demoHeader, demoFooter } from './utils.js';
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 { interface Entity {
id: number; id: number;
@@ -29,9 +21,7 @@ interface Entity {
} }
export async function runSpatialDemo(): Promise<void> { export async function runSpatialDemo(): Promise<void> {
console.log('═══════════════════════════════════════'); demoHeader('Spatial Module Demo');
console.log(' Spatial Module Demo');
console.log('═══════════════════════════════════════');
// 1. Create Spatial Index // 1. Create Spatial Index
section('1. createGridSpatialIndex()'); section('1. createGridSpatialIndex()');
@@ -150,10 +140,6 @@ export async function runSpatialDemo(): Promise<void> {
aoi.addObserver(p1, { x: 100, y: 100 }, { viewRange: 200 }); aoi.addObserver(p1, { x: 100, y: 100 }, { viewRange: 200 });
aoi.addObserver(p2, { x: 150, y: 150 }, { 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 // 15. getEntitiesInView
section('15. getEntitiesInView()'); section('15. getEntitiesInView()');
const visible = aoi.getEntitiesInView(p1); const visible = aoi.getEntitiesInView(p1);
@@ -166,14 +152,11 @@ export async function runSpatialDemo(): Promise<void> {
// 17. updatePosition // 17. updatePosition
section('17. updatePosition()'); section('17. updatePosition()');
aoi.updatePosition(p2, { x: 1000, y: 1000 }); 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'); assert(!aoi.canSee(p1, p2), 'p1 cannot see p2 after move');
// 18. getObserversOf // 18. getObserversOf
section('18. getObserversOf()'); section('18. getObserversOf()');
aoi.updatePosition(p2, { x: 120, y: 120 }); aoi.updatePosition(p2, { x: 120, y: 120 });
aoi.updatePosition(p1, { x: 100, y: 100 }); // Refresh p1's visibility
const observers = aoi.getObserversOf(p2); const observers = aoi.getObserversOf(p2);
assert(observers.includes(p1), 'p1 observes p2'); assert(observers.includes(p1), 'p1 observes p2');
@@ -184,16 +167,13 @@ export async function runSpatialDemo(): Promise<void> {
eventCount++; eventCount++;
}); });
aoi.updatePosition(p2, { x: 2000, y: 2000 }); // Should trigger exit 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(p2, { x: 130, y: 130 }); // Should trigger enter
aoi.updatePosition(p1, { x: 100, y: 100 }); // Refresh p1
assert(eventCount >= 1, `Events triggered: ${eventCount}`); assert(eventCount >= 1, `Events triggered: ${eventCount}`);
// 20. updateViewRange // 20. updateViewRange
section('20. updateViewRange()'); section('20. updateViewRange()');
aoi.updateViewRange(p1, 50); aoi.updateViewRange(p1, 50);
aoi.updatePosition(p2, { x: 200, y: 200 }); 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'); assert(!aoi.canSee(p1, p2), 'Cannot see after view range reduced');
// 21. removeObserver // 21. removeObserver
@@ -238,9 +218,7 @@ export async function runSpatialDemo(): Promise<void> {
const dsq = distanceSquared({ x: 0, y: 0 }, { x: 3, y: 4 }); const dsq = distanceSquared({ x: 0, y: 0 }, { x: 3, y: 4 });
assert(dsq === 25, `Distance squared: ${dsq}`); assert(dsq === 25, `Distance squared: ${dsq}`);
console.log('\n═══════════════════════════════════════'); demoFooter('Spatial Demo');
console.log(' Spatial Demo: ALL TESTS PASSED ✓');
console.log('═══════════════════════════════════════\n');
} }
runSpatialDemo().catch(console.error); runSpatialDemo().catch(console.error);

View File

@@ -3,20 +3,10 @@
*/ */
import { createTimerService } from '@esengine/timer'; import { createTimerService } from '@esengine/timer';
import { assert, section, demoHeader, demoFooter } from './utils.js';
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> { export async function runTimerDemo(): Promise<void> {
console.log('═══════════════════════════════════════'); demoHeader('Timer Module Demo');
console.log(' Timer Module Demo');
console.log('═══════════════════════════════════════');
// 1. Basic Creation // 1. Basic Creation
section('1. createTimerService()'); section('1. createTimerService()');
@@ -111,9 +101,7 @@ export async function runTimerDemo(): Promise<void> {
const limited = createTimerService({ maxTimers: 2, maxCooldowns: 1 }); const limited = createTimerService({ maxTimers: 2, maxCooldowns: 1 });
assert(limited !== null, 'Created with config'); assert(limited !== null, 'Created with config');
console.log('\n═══════════════════════════════════════'); demoFooter('Timer Demo');
console.log(' Timer Demo: ALL TESTS PASSED ✓');
console.log('═══════════════════════════════════════\n');
} }
runTimerDemo().catch(console.error); runTimerDemo().catch(console.error);

View File

@@ -0,0 +1,41 @@
/**
* @zh Demo 测试工具函数
* @en Demo test utility functions
*/
/**
* @zh 断言条件为真,否则抛出错误
* @en Assert condition is true, otherwise throw error
*/
export function assert(condition: boolean, message: string): void {
if (!condition) throw new Error(`FAILED: ${message}`);
console.log(`${message}`);
}
/**
* @zh 打印测试章节标题
* @en Print test section header
*/
export function section(name: string): void {
console.log(`\n▶ ${name}`);
}
/**
* @zh 打印 Demo 开始标题
* @en Print demo start header
*/
export function demoHeader(name: string): void {
console.log('═══════════════════════════════════════');
console.log(` ${name}`);
console.log('═══════════════════════════════════════');
}
/**
* @zh 打印 Demo 结束标题
* @en Print demo end header
*/
export function demoFooter(name: string): void {
console.log('\n═══════════════════════════════════════');
console.log(` ${name}: ALL TESTS PASSED ✓`);
console.log('═══════════════════════════════════════\n');
}