fix(core): IntervalSystem时间累加bug修复 & 添加Core.paused文档

- 修复 onCheckProcessing() 在 update/lateUpdate 被调用两次导致时间累加翻倍的问题
- 添加 Core.paused 游戏暂停文档(中/英文)
- 新增 IntervalSystem 相关测试用例
This commit is contained in:
yhh
2025-12-23 09:41:22 +08:00
parent 2381919a5c
commit dff2ec564b
4 changed files with 611 additions and 8 deletions

View File

@@ -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<GameScene>();
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<typeof gameData>();
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<TimerControlExample>();
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<TimerContext>();
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.

View File

@@ -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 类支持时间缩放功能,可以实现慢动作、快进等效果: Time 类支持时间缩放功能,可以实现慢动作、快进等效果:
@@ -48,10 +106,10 @@ class TimeControlSystem extends EntitySystem {
console.log('快进模式启用'); console.log('快进模式启用');
} }
public pauseGame(): void { public enableBulletTime(): void {
// 暂停游戏(时间静止 // 子弹时间效果10%速度
Time.timeScale = 0; Time.timeScale = 0.1;
console.log('游戏暂停'); console.log('子弹时间启用');
} }
public resumeNormalSpeed(): void { public resumeNormalSpeed(): void {

View File

@@ -135,6 +135,19 @@ export abstract class EntitySystem implements ISystemBase, IService {
*/ */
private _lastProcessEpoch: number = 0; 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 { 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; 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 { public lateUpdate(): void {
if (!this._enabled || !this.onCheckProcessing()) { // 复用 update() 中的检查结果,不再调用 onCheckProcessing()
// Reuse check result from update(), don't call onCheckProcessing() again
if (!this._shouldProcessThisFrame) {
return; return;
} }

View File

@@ -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 { class ConcreteProcessingSystem extends ProcessingSystem {
public processSystemCallCount = 0; public processSystemCallCount = 0;
@@ -207,16 +233,108 @@ describe('System Types - 系统类型测试', () => {
Time.update(testInterval); Time.update(testInterval);
intervalSystem.update(); intervalSystem.update();
expect(intervalSystem.processCallCount).toBe(1); expect(intervalSystem.processCallCount).toBe(1);
// 再次触发需要等待完整间隔 // 再次触发需要等待完整间隔
Time.update(testInterval / 2); Time.update(testInterval / 2);
intervalSystem.update(); intervalSystem.update();
expect(intervalSystem.processCallCount).toBe(1); expect(intervalSystem.processCallCount).toBe(1);
Time.update(testInterval / 2); Time.update(testInterval / 2);
intervalSystem.update(); intervalSystem.update();
expect(intervalSystem.processCallCount).toBe(2); 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 - 处理系统', () => { describe('ProcessingSystem - 处理系统', () => {