From dff2ec564bc2f15ac31abb00202b278ac9030729 Mon Sep 17 00:00:00 2001 From: yhh <359807859@qq.com> Date: Tue, 23 Dec 2025 09:41:22 +0800 Subject: [PATCH] =?UTF-8?q?fix(core):=20IntervalSystem=E6=97=B6=E9=97=B4?= =?UTF-8?q?=E7=B4=AF=E5=8A=A0bug=E4=BF=AE=E5=A4=8D=20&=20=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0Core.paused=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修复 onCheckProcessing() 在 update/lateUpdate 被调用两次导致时间累加翻倍的问题 - 添加 Core.paused 游戏暂停文档(中/英文) - 新增 IntervalSystem 相关测试用例 --- docs/en/guide/time-and-timers.md | 402 ++++++++++++++++++ docs/guide/time-and-timers.md | 66 ++- packages/core/src/ECS/Systems/EntitySystem.ts | 29 +- .../tests/ECS/Systems/SystemTypes.test.ts | 122 +++++- 4 files changed, 611 insertions(+), 8 deletions(-) create mode 100644 docs/en/guide/time-and-timers.md diff --git a/docs/en/guide/time-and-timers.md b/docs/en/guide/time-and-timers.md new file mode 100644 index 00000000..de89d1f0 --- /dev/null +++ b/docs/en/guide/time-and-timers.md @@ -0,0 +1,402 @@ +# Time and Timer System + +The ECS framework provides a complete time management and timer system, including time scaling, frame time calculation, and flexible timer scheduling. + +## Time Class + +The Time class is the core of the framework's time management, providing all game time-related functionality. + +### Basic Time Properties + +```typescript +import { Time } from '@esengine/ecs-framework'; + +class GameSystem extends EntitySystem { + protected process(entities: readonly Entity[]): void { + // Get frame time (seconds) + const deltaTime = Time.deltaTime; + + // Get unscaled frame time + const unscaledDelta = Time.unscaledDeltaTime; + + // Get total game time + const totalTime = Time.totalTime; + + // Get current frame count + const frameCount = Time.frameCount; + + console.log(`Frame ${frameCount}, delta: ${deltaTime}s, total: ${totalTime}s`); + } +} +``` + +### Game Pause + +The framework provides two pause methods for different scenarios: + +#### Core.paused (Recommended) + +`Core.paused` is a **true pause** - when set, the entire game loop stops: + +```typescript +import { Core } from '@esengine/ecs-framework'; + +class PauseMenuSystem extends EntitySystem { + public pauseGame(): void { + // True pause - all systems stop executing + Core.paused = true; + console.log('Game paused'); + } + + public resumeGame(): void { + // Resume game + Core.paused = false; + console.log('Game resumed'); + } + + public togglePause(): void { + Core.paused = !Core.paused; + console.log(Core.paused ? 'Game paused' : 'Game resumed'); + } +} +``` + +#### Time.timeScale = 0 + +`Time.timeScale = 0` only makes `deltaTime` become 0, **systems still execute**: + +```typescript +class SlowMotionSystem extends EntitySystem { + public freezeTime(): void { + // Time freeze - systems still execute, just deltaTime = 0 + Time.timeScale = 0; + } +} +``` + +#### Comparison + +| Feature | `Core.paused = true` | `Time.timeScale = 0` | +|---------|---------------------|---------------------| +| System Execution | Completely stopped | Still running | +| CPU Overhead | Zero | Normal overhead | +| Time Updates | Stopped | Continues (deltaTime=0) | +| Timers | Stopped | Continues (but time doesn't advance) | +| Use Cases | Pause menu, game pause | Slow motion, bullet time effects | + +**Recommendations**: +- Pause menu, true game pause → Use `Core.paused = true` +- Slow motion, bullet time effects → Use `Time.timeScale` + +### Time Scaling + +The Time class supports time scaling for slow motion, fast forward, and other effects: + +```typescript +class TimeControlSystem extends EntitySystem { + public enableSlowMotion(): void { + // Set to slow motion (50% speed) + Time.timeScale = 0.5; + console.log('Slow motion enabled'); + } + + public enableFastForward(): void { + // Set to fast forward (200% speed) + Time.timeScale = 2.0; + console.log('Fast forward enabled'); + } + + public enableBulletTime(): void { + // Bullet time effect (10% speed) + Time.timeScale = 0.1; + console.log('Bullet time enabled'); + } + + public resumeNormalSpeed(): void { + // Resume normal speed + Time.timeScale = 1.0; + console.log('Normal speed resumed'); + } + + protected process(entities: readonly Entity[]): void { + // deltaTime is affected by timeScale + const scaledDelta = Time.deltaTime; // Affected by time scale + const realDelta = Time.unscaledDeltaTime; // Not affected by time scale + + for (const entity of entities) { + const movement = entity.getComponent(Movement); + if (movement) { + // Use scaled time for game logic updates + movement.update(scaledDelta); + } + + const ui = entity.getComponent(UIComponent); + if (ui) { + // UI animations use real time, not affected by game time scale + ui.update(realDelta); + } + } + } +} +``` + +### Time Check Utilities + +```typescript +class CooldownSystem extends EntitySystem { + private lastAttackTime = 0; + private lastSpawnTime = 0; + + constructor() { + super(Matcher.all(Weapon)); + } + + protected process(entities: readonly Entity[]): void { + // Check attack cooldown + if (Time.checkEvery(1.5, this.lastAttackTime)) { + this.performAttack(); + this.lastAttackTime = Time.totalTime; + } + + // Check spawn interval + if (Time.checkEvery(3.0, this.lastSpawnTime)) { + this.spawnEnemy(); + this.lastSpawnTime = Time.totalTime; + } + } + + private performAttack(): void { + console.log('Performing attack!'); + } + + private spawnEnemy(): void { + console.log('Spawning enemy!'); + } +} +``` + +## Core.schedule Timer System + +Core provides powerful timer scheduling functionality for creating one-time or repeating timers. + +### Basic Timer Usage + +```typescript +import { Core } from '@esengine/ecs-framework'; + +class GameScene extends Scene { + protected initialize(): void { + // Create one-time timers + this.createOneTimeTimers(); + + // Create repeating timers + this.createRepeatingTimers(); + + // Create timers with context + this.createContextTimers(); + } + + private createOneTimeTimers(): void { + // Execute once after 2 seconds + Core.schedule(2.0, false, null, (timer) => { + console.log('Executed after 2 second delay'); + }); + + // Show tip after 5 seconds + Core.schedule(5.0, false, this, (timer) => { + const scene = timer.getContext(); + scene.showTip('Game tip: 5 seconds have passed!'); + }); + } + + private createRepeatingTimers(): void { + // Execute every second + const heartbeatTimer = Core.schedule(1.0, true, null, (timer) => { + console.log(`Game heartbeat - Total time: ${Time.totalTime.toFixed(1)}s`); + }); + + // Save timer reference for later control + this.saveTimerReference(heartbeatTimer); + } + + private createContextTimers(): void { + const gameData = { score: 0, level: 1 }; + + // Add score every 2 seconds + Core.schedule(2.0, true, gameData, (timer) => { + const data = timer.getContext(); + data.score += 10; + console.log(`Score increased! Current score: ${data.score}`); + }); + } + + private saveTimerReference(timer: any): void { + // Can stop timer later + setTimeout(() => { + timer.stop(); + console.log('Timer stopped'); + }, 10000); // Stop after 10 seconds + } + + private showTip(message: string): void { + console.log('Tip:', message); + } +} +``` + +### Timer Control + +```typescript +class TimerControlExample { + private attackTimer: any; + private spawnerTimer: any; + + public startCombat(): void { + // Start attack timer + this.attackTimer = Core.schedule(0.5, true, this, (timer) => { + const self = timer.getContext(); + self.performAttack(); + }); + + // Start enemy spawn timer + this.spawnerTimer = Core.schedule(3.0, true, null, (timer) => { + this.spawnEnemy(); + }); + } + + public stopCombat(): void { + // Stop all combat-related timers + if (this.attackTimer) { + this.attackTimer.stop(); + console.log('Attack timer stopped'); + } + + if (this.spawnerTimer) { + this.spawnerTimer.stop(); + console.log('Spawn timer stopped'); + } + } + + public resetAttackTimer(): void { + // Reset attack timer + if (this.attackTimer) { + this.attackTimer.reset(); + console.log('Attack timer reset'); + } + } + + private performAttack(): void { + console.log('Performing attack'); + } + + private spawnEnemy(): void { + console.log('Spawning enemy'); + } +} +``` + +## Best Practices + +### 1. Use Appropriate Time Types + +```typescript +class MovementSystem extends EntitySystem { + protected process(entities: readonly Entity[]): void { + for (const entity of entities) { + const movement = entity.getComponent(Movement); + + // Use scaled time for game logic + movement.position.x += movement.velocity.x * Time.deltaTime; + + // Use real time for UI animations (not affected by game pause) + const ui = entity.getComponent(UIAnimation); + if (ui) { + ui.update(Time.unscaledDeltaTime); + } + } + } +} +``` + +### 2. Timer Management + +```typescript +class TimerManager { + private timers: any[] = []; + + public createManagedTimer(duration: number, repeats: boolean, callback: () => void): any { + const timer = Core.schedule(duration, repeats, null, callback); + this.timers.push(timer); + return timer; + } + + public stopAllTimers(): void { + for (const timer of this.timers) { + timer.stop(); + } + this.timers = []; + } + + public cleanupCompletedTimers(): void { + this.timers = this.timers.filter(timer => !timer.isDone); + } +} +``` + +### 3. Avoid Too Many Timers + +```typescript +// Avoid: Creating a timer for each entity +class BadExample extends EntitySystem { + protected onAdded(entity: Entity): void { + Core.schedule(1.0, true, entity, (timer) => { + // One timer per entity - poor performance + }); + } +} + +// Recommended: Manage time uniformly in the system +class GoodExample extends EntitySystem { + private lastUpdateTime = 0; + + protected process(entities: readonly Entity[]): void { + // Execute logic once per second + if (Time.checkEvery(1.0, this.lastUpdateTime)) { + this.processAllEntities(entities); + this.lastUpdateTime = Time.totalTime; + } + } + + private processAllEntities(entities: readonly Entity[]): void { + // Batch process all entities + } +} +``` + +### 4. Timer Context Usage + +```typescript +interface TimerContext { + entityId: number; + duration: number; + onComplete: () => void; +} + +class ContextualTimerExample { + public createEntityTimer(entityId: number, duration: number, onComplete: () => void): void { + const context: TimerContext = { + entityId, + duration, + onComplete + }; + + Core.schedule(duration, false, context, (timer) => { + const ctx = timer.getContext(); + console.log(`Timer for entity ${ctx.entityId} completed`); + ctx.onComplete(); + }); + } +} +``` + +The time and timer system is an essential tool in game development. Using these features correctly will make your game logic more precise and controllable. diff --git a/docs/guide/time-and-timers.md b/docs/guide/time-and-timers.md index 70b6c331..1047f4cf 100644 --- a/docs/guide/time-and-timers.md +++ b/docs/guide/time-and-timers.md @@ -30,6 +30,64 @@ class GameSystem extends EntitySystem { } ``` +### 游戏暂停 + +框架提供两种暂停方式,适用于不同场景: + +#### Core.paused(推荐) + +`Core.paused` 是**真正的暂停**,设置后整个游戏循环停止: + +```typescript +import { Core } from '@esengine/ecs-framework'; + +class PauseMenuSystem extends EntitySystem { + public pauseGame(): void { + // 真正暂停 - 所有系统停止执行 + Core.paused = true; + console.log('游戏已暂停'); + } + + public resumeGame(): void { + // 恢复游戏 + Core.paused = false; + console.log('游戏已恢复'); + } + + public togglePause(): void { + Core.paused = !Core.paused; + console.log(Core.paused ? '游戏已暂停' : '游戏已恢复'); + } +} +``` + +#### Time.timeScale = 0 + +`Time.timeScale = 0` 只是让 `deltaTime` 变为 0,**系统仍然在执行**: + +```typescript +class SlowMotionSystem extends EntitySystem { + public freezeTime(): void { + // 时间冻结 - 系统仍在执行,只是 deltaTime = 0 + Time.timeScale = 0; + } +} +``` + +#### 两种方式对比 + +| 特性 | `Core.paused = true` | `Time.timeScale = 0` | +|------|---------------------|---------------------| +| 系统执行 | ❌ 完全停止 | ✅ 仍在执行 | +| CPU 开销 | 零 | 正常开销 | +| Time 更新 | ❌ 停止 | ✅ 继续(deltaTime=0) | +| 定时器 | ❌ 停止 | ✅ 继续(但时间不走) | +| 适用场景 | 暂停菜单、游戏暂停 | 慢动作、时间冻结特效 | + +**推荐**: +- 暂停菜单、真正的游戏暂停 → 使用 `Core.paused = true` +- 慢动作、子弹时间等特效 → 使用 `Time.timeScale` + ### 时间缩放 Time 类支持时间缩放功能,可以实现慢动作、快进等效果: @@ -48,10 +106,10 @@ class TimeControlSystem extends EntitySystem { console.log('快进模式启用'); } - public pauseGame(): void { - // 暂停游戏(时间静止) - Time.timeScale = 0; - console.log('游戏暂停'); + public enableBulletTime(): void { + // 子弹时间效果(10%速度) + Time.timeScale = 0.1; + console.log('子弹时间启用'); } public resumeNormalSpeed(): void { diff --git a/packages/core/src/ECS/Systems/EntitySystem.ts b/packages/core/src/ECS/Systems/EntitySystem.ts index 194f2bf8..206ee539 100644 --- a/packages/core/src/ECS/Systems/EntitySystem.ts +++ b/packages/core/src/ECS/Systems/EntitySystem.ts @@ -135,6 +135,19 @@ export abstract class EntitySystem implements ISystemBase, IService { */ private _lastProcessEpoch: number = 0; + /** + * 当前帧是否应该处理 + * Whether this frame should be processed + * + * 由 update() 中的 onCheckProcessing() 决定,lateUpdate() 复用此结果。 + * 避免 onCheckProcessing() 被多次调用导致副作用重复执行(如 IntervalSystem 的时间累加)。 + * + * Determined by onCheckProcessing() in update(), reused by lateUpdate(). + * Prevents onCheckProcessing() being called multiple times causing side effects + * to execute repeatedly (e.g., IntervalSystem's time accumulation). + */ + private _shouldProcessThisFrame: boolean = false; + /** * 获取系统处理的实体列表 */ @@ -774,7 +787,11 @@ export abstract class EntitySystem implements ISystemBase, IService { * 更新系统 */ public update(): void { - if (!this._enabled || !this.onCheckProcessing()) { + // 检查是否应该处理,并缓存结果供 lateUpdate 使用 + // Check if should process and cache result for lateUpdate + this._shouldProcessThisFrame = this._enabled && this.onCheckProcessing(); + + if (!this._shouldProcessThisFrame) { return; } @@ -799,9 +816,17 @@ export abstract class EntitySystem implements ISystemBase, IService { /** * 后期更新系统 + * + * lateUpdate 复用 update 中 onCheckProcessing() 的结果, + * 避免 IntervalSystem 等子类的副作用被重复执行。 + * + * lateUpdate reuses the onCheckProcessing() result from update, + * preventing side effects in subclasses like IntervalSystem from executing repeatedly. */ public lateUpdate(): void { - if (!this._enabled || !this.onCheckProcessing()) { + // 复用 update() 中的检查结果,不再调用 onCheckProcessing() + // Reuse check result from update(), don't call onCheckProcessing() again + if (!this._shouldProcessThisFrame) { return; } diff --git a/packages/core/tests/ECS/Systems/SystemTypes.test.ts b/packages/core/tests/ECS/Systems/SystemTypes.test.ts index 8dfa4d8e..a633f479 100644 --- a/packages/core/tests/ECS/Systems/SystemTypes.test.ts +++ b/packages/core/tests/ECS/Systems/SystemTypes.test.ts @@ -60,6 +60,32 @@ class ConcreteIntervalSystem extends IntervalSystem { } } +// 用于独立测试的间隔系统(避免 Scene 的类型去重) +class IndependentIntervalSystem extends IntervalSystem { + public processCallCount = 0; + + constructor(interval: number) { + super(interval, Matcher.all(TestComponent)); + } + + protected override process(entities: Entity[]): void { + this.processCallCount++; + } +} + +// 用于长时间运行测试的间隔系统 +class LongRunIntervalSystem extends IntervalSystem { + public processCallCount = 0; + + constructor(interval: number) { + super(interval, Matcher.all(TestComponent)); + } + + protected override process(entities: Entity[]): void { + this.processCallCount++; + } +} + // 具体的处理系统实现 class ConcreteProcessingSystem extends ProcessingSystem { public processSystemCallCount = 0; @@ -207,16 +233,108 @@ describe('System Types - 系统类型测试', () => { Time.update(testInterval); intervalSystem.update(); expect(intervalSystem.processCallCount).toBe(1); - + // 再次触发需要等待完整间隔 Time.update(testInterval / 2); intervalSystem.update(); expect(intervalSystem.processCallCount).toBe(1); - + Time.update(testInterval / 2); intervalSystem.update(); expect(intervalSystem.processCallCount).toBe(2); }); + + test('update和lateUpdate同时调用时,onCheckProcessing只应执行一次副作用', () => { + // 这个测试验证修复:onCheckProcessing() 的副作用(时间累加)不应该在 lateUpdate 中重复执行 + // 模拟一帧内的完整调用流程 + + const initialProcessCount = intervalSystem.processCallCount; + + // 第一帧:时间不足,不应触发 + Time.update(testInterval / 2); + intervalSystem.update(); + intervalSystem.lateUpdate(); + expect(intervalSystem.processCallCount).toBe(initialProcessCount); + + // 第二帧:累计时间刚好达到间隔,应该触发一次 + Time.update(testInterval / 2); + intervalSystem.update(); + intervalSystem.lateUpdate(); + expect(intervalSystem.processCallCount).toBe(initialProcessCount + 1); + + // 第三帧:需要再等待完整间隔 + Time.update(testInterval / 2); + intervalSystem.update(); + intervalSystem.lateUpdate(); + expect(intervalSystem.processCallCount).toBe(initialProcessCount + 1); + + // 第四帧:再次达到间隔 + Time.update(testInterval / 2); + intervalSystem.update(); + intervalSystem.lateUpdate(); + expect(intervalSystem.processCallCount).toBe(initialProcessCount + 2); + }); + + test('lateUpdate应复用update的检查结果,不重复累加时间', () => { + // 这是用户报告的 bug 场景:5秒间隔,但实际触发不规律 + // 原因是 onCheckProcessing() 在 update 和 lateUpdate 中都被调用,导致时间累加了两次 + + // 核心验证:在 N*interval 时间内,触发次数应该接近 N 次 + // 如果 bug 存在(时间累加两次),触发次数会接近 2N 次 + + const initialCount = intervalSystem.processCallCount; + + // 模拟 50 帧,每帧 testInterval/5 (总共 10*testInterval) + for (let frame = 0; frame < 50; frame++) { + Time.update(testInterval / 5); + intervalSystem.update(); + intervalSystem.lateUpdate(); + } + + // 50帧 * (testInterval/5) = 10*testInterval,应该触发约 10 次 + // 如果 bug 存在(时间累加两次),会触发约 20 次 + const triggers = intervalSystem.processCallCount - initialCount; + + // 正常情况:触发 9-11 次 + // Bug 情况:触发 18-22 次 + expect(triggers).toBeGreaterThanOrEqual(9); + expect(triggers).toBeLessThanOrEqual(12); + }); + + test('精确间隔测试 - 验证触发间隔的一致性', () => { + // 验证触发间隔是稳定的,而不是忽大忽小 + + const initialCount = intervalSystem.processCallCount; + const frameTimes: number[] = []; + let framesSinceLastTrigger = 0; + + // 模拟 100 帧,每帧 testInterval/5 + for (let frame = 0; frame < 100; frame++) { + Time.update(testInterval / 5); + framesSinceLastTrigger++; + + const beforeCount = intervalSystem.processCallCount; + intervalSystem.update(); + intervalSystem.lateUpdate(); + + if (intervalSystem.processCallCount > beforeCount) { + frameTimes.push(framesSinceLastTrigger); + framesSinceLastTrigger = 0; + } + } + + // 100帧 * (testInterval/5) = 20*testInterval,应该触发约 20 次 + const totalTriggers = intervalSystem.processCallCount - initialCount; + expect(totalTriggers).toBeGreaterThanOrEqual(19); + expect(totalTriggers).toBeLessThanOrEqual(21); + + // 验证每次触发的帧间隔都接近 5 帧(因为 5 * testInterval/5 = testInterval) + // 如果 bug 存在,帧间隔会不稳定(有的 2-3 帧,有的 7-8 帧) + for (const frames of frameTimes) { + expect(frames).toBeGreaterThanOrEqual(4); + expect(frames).toBeLessThanOrEqual(6); + } + }); }); describe('ProcessingSystem - 处理系统', () => {