@waica/engine 0.15.0 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -0
- package/dist/entity.js +4 -0
- package/dist/game-time.d.ts +189 -0
- package/dist/game-time.js +401 -0
- package/dist/game.d.ts +5 -1
- package/dist/game.js +12 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.js +1 -0
- package/dist/runtime-inspection.d.ts +19 -0
- package/dist/runtime-inspection.js +7 -0
- package/dist/scene.js +10 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -170,3 +170,26 @@ class PathFinder extends Component {
|
|
|
170
170
|
```
|
|
171
171
|
|
|
172
172
|
The return value still passes through the bounded safe projector; it is not serialized with arbitrary `toJSON()`. The package root exports the Runtime Snapshot, projection marker, metadata, control and activation types plus `RUNTIME_BRIDGE_PROTOCOL_VERSION` and `RUNTIME_PROJECTION_LIMITS`.
|
|
173
|
+
|
|
174
|
+
## game.time: simulated timers and tweens
|
|
175
|
+
|
|
176
|
+
Every `Game` owns a `time: GameTime` service — `after`, `every`, a single-number `tween`, and a `now` reader — advanced only by Simulation Steps (ADR 0014), never by the wall clock:
|
|
177
|
+
|
|
178
|
+
```ts
|
|
179
|
+
const stun = game.time.after(0.3, () => fsm.goto('idle'), { owner: entity })
|
|
180
|
+
game.time.every(1, () => game.stats.set('clock', game.time.now), { scope: 'session' })
|
|
181
|
+
game.time.tween({
|
|
182
|
+
from: 0, to: 1, seconds: 0.5, easing: 'quadOut',
|
|
183
|
+
onUpdate: (v) => { overlay.opacity = v },
|
|
184
|
+
onComplete: () => game.loadSceneByName('b'),
|
|
185
|
+
owner: entity,
|
|
186
|
+
})
|
|
187
|
+
stun.remaining // seconds left, counting down
|
|
188
|
+
game.time.now // seconds of Game Time since the Game started
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
- **`after(seconds, callback, options?)`** runs `callback` once, `seconds` of Game Time later (0 or a negative duration: the start of the next step). **`every(seconds, callback, options?)`** runs it every `max(seconds, 1/60)`, first one interval after creation, with no drift. **`tween(options)`** carries `from` to `to` over `seconds`, calling `onUpdate(value)` synchronously on creation and again on every later step, finishing with exactly `to` then `onComplete()`. Easing is `'linear'` (default), `'quadIn'`/`'quadOut'`/`'quadInOut'`, `'cubicIn'`/`'cubicOut'`/`'cubicInOut'`, `'sineInOut'`, or a custom `(t) => number`.
|
|
192
|
+
- All three return a `TimerHandle`: `cancel()`, `active`, `elapsed`, `remaining` (seconds of Game Time). `cancel()` — and owner/scene cancellation — leaves a tween's last applied value in place and never calls `onComplete`.
|
|
193
|
+
- **Step placement.** At the start of every Simulation Step, before the Component Update Schedule, `game.time` advances `now`, runs every due timer (due time, then creation order), then advances every tween that existed before that step (creation order). Work a callback creates is never run or advanced in that same step.
|
|
194
|
+
- **Scope.** A timer or tween is scene-scoped by default: `unloadScene()` and every scene load — including a Game's first — cancel it, running no callback. `{ scope: 'session' }` survives a scene change. `{ owner: entity }` cancels it immediately when that entity is destroyed, whatever its scope; an owner already dead at scheduling time yields an inactive handle. `game.dispose()` cancels everything in both scopes. This is the opposite default from `game.onUpdate`/`game.events` (ADR 0011), which survive a scene change by construction, and the same one `game.audio.play()` uses (ADR 0012) — see ADR 0017 for why timers follow audio's rule rather than the host-subscription one: a timer's callback almost always closes over the scene that scheduled it.
|
|
195
|
+
- **No Promises.** Nothing here returns one, and nothing is async — a `.then` continuation is not step-exact (it runs after the whole synchronous frame), which is exactly what `game.time` exists to avoid. Compose delays with `after`, not `await`.
|
package/dist/entity.js
CHANGED
|
@@ -55,6 +55,10 @@ export class Entity {
|
|
|
55
55
|
if (this.destroyed)
|
|
56
56
|
return;
|
|
57
57
|
this.destroyed = true;
|
|
58
|
+
// Immediately, before any onDestroy hook runs, whatever the scope
|
|
59
|
+
// (ADR 0017, CA-5) — `alive` is already false by the time any of this
|
|
60
|
+
// entity's own timers/tweens could observe it.
|
|
61
|
+
this.game.time.cancelOwnedBy(this);
|
|
58
62
|
for (const c of [...this.components])
|
|
59
63
|
c.onDestroy?.();
|
|
60
64
|
this.components.length = 0;
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/** The closed set of named Penner curves a Tween can ease through. */
|
|
2
|
+
export type EasingName = 'linear' | 'quadIn' | 'quadOut' | 'quadInOut' | 'cubicIn' | 'cubicOut' | 'cubicInOut' | 'sineInOut';
|
|
3
|
+
/** What `after`/`every`/`tween` hand back: see CA-6. */
|
|
4
|
+
export interface TimerHandle {
|
|
5
|
+
/** Idempotent and silent, even on an already-inactive handle. */
|
|
6
|
+
cancel(): void;
|
|
7
|
+
readonly active: boolean;
|
|
8
|
+
/** Seconds of Game Time, per-kind semantics in CA-6. Frozen once inactive. */
|
|
9
|
+
readonly elapsed: number;
|
|
10
|
+
/** Seconds of Game Time until the next due time; 0 once inactive. */
|
|
11
|
+
readonly remaining: number;
|
|
12
|
+
}
|
|
13
|
+
export interface TimerOptions {
|
|
14
|
+
/**
|
|
15
|
+
* Structural, not `instanceof Entity` (spec inference 19) — an object with
|
|
16
|
+
* a boolean `alive` qualifies, whatever else it is, so behaviors-test stub
|
|
17
|
+
* entities do too. An owner already dead at scheduling time yields an
|
|
18
|
+
* inactive handle that never runs, silently (CA-5) — a real Entity's
|
|
19
|
+
* `destroy()` cancels a live one the same way, whatever its scope.
|
|
20
|
+
* `alive` is read exactly once, right here at scheduling time; nothing
|
|
21
|
+
* polls it afterwards. Automatic cancellation of a live handle is driven
|
|
22
|
+
* entirely by `Entity.destroy()` calling `cancelOwnedBy()`, so an owner
|
|
23
|
+
* that is not a real `Entity` (never calls `destroy()`) only ever gets
|
|
24
|
+
* this one dead-at-scheduling check — its handle keeps firing even if
|
|
25
|
+
* something later flips its `alive` to false by hand.
|
|
26
|
+
*/
|
|
27
|
+
owner?: {
|
|
28
|
+
readonly alive: boolean;
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* `'session'` survives a scene change; anything else — including leaving
|
|
32
|
+
* it unset — means scene-scoped (ADR 0017), the opposite default from
|
|
33
|
+
* `game.onUpdate`/`game.events` (ADR 0011). Narrowed to the two literal
|
|
34
|
+
* values (mirroring `AudioPlayOptions.scope` in `audio/types.ts`) so a
|
|
35
|
+
* typo is a compile error instead of a silent downgrade to scene scope.
|
|
36
|
+
*/
|
|
37
|
+
scope?: 'scene' | 'session';
|
|
38
|
+
}
|
|
39
|
+
export interface TweenOptions {
|
|
40
|
+
from: number;
|
|
41
|
+
to: number;
|
|
42
|
+
/** <= 0 (a negative clamped to 0) or below one step: `from` on creation, `to` + onComplete next step. */
|
|
43
|
+
seconds: number;
|
|
44
|
+
/** A known name (default `'linear'`) or a custom `(t) => number`. */
|
|
45
|
+
easing?: EasingName | ((t: number) => number);
|
|
46
|
+
onUpdate: (value: number) => void;
|
|
47
|
+
onComplete?: () => void;
|
|
48
|
+
/**
|
|
49
|
+
* Structural, not `instanceof Entity` (spec inference 19) — an object with
|
|
50
|
+
* a boolean `alive` qualifies. `alive` is read once, at scheduling time,
|
|
51
|
+
* to reject an owner already dead; automatic cancellation afterwards is
|
|
52
|
+
* driven entirely by `Entity.destroy()` calling `cancelOwnedBy()`, never
|
|
53
|
+
* by polling `alive` again, so a non-`Entity` owner only ever gets that
|
|
54
|
+
* one dead-at-scheduling check.
|
|
55
|
+
*/
|
|
56
|
+
owner?: {
|
|
57
|
+
readonly alive: boolean;
|
|
58
|
+
};
|
|
59
|
+
/** Same as `TimerOptions.scope`: `'session'` survives a scene change, anything else means scene. */
|
|
60
|
+
scope?: 'scene' | 'session';
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Module-private key for the per-step advance pass. Not exported, so
|
|
64
|
+
* `time[ADVANCE]()` cannot be spelled outside this file — `advanceGameTime`
|
|
65
|
+
* (below, exported) is the only reachable entry point from project code.
|
|
66
|
+
*/
|
|
67
|
+
declare const ADVANCE: unique symbol;
|
|
68
|
+
/**
|
|
69
|
+
* The `game.time` service (CA-1..CA-9, CA-14): `after`, `every` and `tween`
|
|
70
|
+
* scheduled against Game Time (`now`), advanced only by Simulation Steps —
|
|
71
|
+
* never the wall clock, never synchronously, never while paused or not
|
|
72
|
+
* simulating. Constructible standalone, like `Stats`: a `Game` owns one via
|
|
73
|
+
* `game.time`, and a behaviors-test stub game can carry its own, advanced by
|
|
74
|
+
* the same engine-internal hook `Game` uses (`advanceGameTime`, exported
|
|
75
|
+
* below — not part of this class's documented API).
|
|
76
|
+
*/
|
|
77
|
+
export declare class GameTime {
|
|
78
|
+
private stepCount;
|
|
79
|
+
private nextId;
|
|
80
|
+
private timers;
|
|
81
|
+
private tweens;
|
|
82
|
+
/**
|
|
83
|
+
* Set whenever a timer/tween goes inactive since the matching array was
|
|
84
|
+
* last pruned, whether by firing/completing the per-step advance pass or
|
|
85
|
+
* by a plain `handle.cancel()` from outside it; consumed (and cleared) by
|
|
86
|
+
* the next `reclaim()` that actually reassigns that array (CA-3 perf: no
|
|
87
|
+
* reassignment when nothing died). Invariant: a flag is false only when
|
|
88
|
+
* its array holds no inactive entries — every path that clears a flag
|
|
89
|
+
* prunes that array first, and every path that deactivates an entry sets
|
|
90
|
+
* the matching flag. `reclaim()` relies on this to skip a prune safely.
|
|
91
|
+
*/
|
|
92
|
+
private timersDirty;
|
|
93
|
+
private tweensDirty;
|
|
94
|
+
/** 0 on a new GameTime; `N * SIMULATION_STEP` exactly after N steps — computed, never summed (CA-9). */
|
|
95
|
+
get now(): number;
|
|
96
|
+
/** Active timers plus active tweens across both scopes (CA-10). */
|
|
97
|
+
get pending(): number;
|
|
98
|
+
/** Smallest positive integer n s.t. n more steps fires/completes something; null when nothing is pending (CA-10). */
|
|
99
|
+
get nextInSteps(): number | null;
|
|
100
|
+
/** Shared traversal for `pending`: how many entries in `entries` are active. */
|
|
101
|
+
private countActive;
|
|
102
|
+
/** Shared traversal for `nextInSteps`: folds `stepsUntilDue` over every active entry, starting from `min`. */
|
|
103
|
+
private minStepsUntilDue;
|
|
104
|
+
/**
|
|
105
|
+
* Runs `callback` once, `seconds` of Game Time from now (0 or a negative
|
|
106
|
+
* duration: the start of the next step). Never synchronous (CA-1).
|
|
107
|
+
*/
|
|
108
|
+
after(seconds: number, callback: () => void, options?: TimerOptions): TimerHandle;
|
|
109
|
+
/**
|
|
110
|
+
* Runs `callback` every `max(seconds, SIMULATION_STEP)` of Game Time,
|
|
111
|
+
* first one interval after creation, with no drift: each due time is
|
|
112
|
+
* `start + n * interval`, computed directly rather than by repeated
|
|
113
|
+
* addition (CA-2).
|
|
114
|
+
*/
|
|
115
|
+
every(seconds: number, callback: () => void, options?: TimerOptions): TimerHandle;
|
|
116
|
+
/**
|
|
117
|
+
* Carries a single number from `from` to `to` over `seconds` of Game
|
|
118
|
+
* Time, reporting it through `onUpdate` on every step including creation
|
|
119
|
+
* (synchronous, before this returns — CA-7). Owner already dead, or any
|
|
120
|
+
* invalid input, skips even that first call.
|
|
121
|
+
*/
|
|
122
|
+
tween(options: TweenOptions): TimerHandle;
|
|
123
|
+
/**
|
|
124
|
+
* Internal: the start-of-step pass (CA-3) — advances `now`, then runs
|
|
125
|
+
* every due timer (due time, then creation order), then advances every
|
|
126
|
+
* tween that existed before this pass (creation order). Reachable only as
|
|
127
|
+
* `time[ADVANCE]()`, and `ADVANCE` is not exported, so `advanceGameTime()`
|
|
128
|
+
* (below, exported) is genuinely the only way in from project code. With
|
|
129
|
+
* nothing scheduled (the common case once a scene settles) this does no
|
|
130
|
+
* allocation beyond the step count itself; with timers but no tweens, the
|
|
131
|
+
* tween snapshot and `advanceTweens()` are skipped too.
|
|
132
|
+
*/
|
|
133
|
+
[ADVANCE](): void;
|
|
134
|
+
/**
|
|
135
|
+
* Engine-internal by convention, like `Game.removeEntity`: a public
|
|
136
|
+
* method, not access-controlled, that `Game.unloadScene()` and every
|
|
137
|
+
* scene load are expected to call, including the first (CA-4).
|
|
138
|
+
*/
|
|
139
|
+
cancelSceneScoped(): void;
|
|
140
|
+
/**
|
|
141
|
+
* Engine-internal by convention, like `Game.removeEntity`: a public
|
|
142
|
+
* method, not access-controlled, that `Entity.destroy()` (CA-5) is
|
|
143
|
+
* expected to be the only caller of. Nothing re-reads `owner.alive` on
|
|
144
|
+
* later steps; a non-`Entity` owner whose `alive` flips without ever
|
|
145
|
+
* going through a real `destroy()` call never reaches here, so its
|
|
146
|
+
* timers/tweens keep running (see `TimerOptions.owner`).
|
|
147
|
+
*/
|
|
148
|
+
cancelOwnedBy(owner: {
|
|
149
|
+
readonly alive: boolean;
|
|
150
|
+
}): void;
|
|
151
|
+
/**
|
|
152
|
+
* Engine-internal by convention, like `Game.removeEntity`: a public
|
|
153
|
+
* method, not access-controlled, that `Game.dispose()` is expected to
|
|
154
|
+
* call (CA-4).
|
|
155
|
+
*/
|
|
156
|
+
cancelAll(): void;
|
|
157
|
+
/**
|
|
158
|
+
* Prunes each array only when its dirty flag is set, then clears that
|
|
159
|
+
* flag — safe because of the invariant documented on `timersDirty`
|
|
160
|
+
* /`tweensDirty`: a false flag means the array already holds no inactive
|
|
161
|
+
* entries, so skipping the filter changes no observable behavior. Called
|
|
162
|
+
* both at the tail of the per-step advance pass and by
|
|
163
|
+
* `cancelSceneScoped`/`cancelOwnedBy`/`cancelAll`, which are also
|
|
164
|
+
* reachable with `simulate === false` (e.g. the editor's edit mode
|
|
165
|
+
* calling `loadScene` on every edit), when no step ever runs to reclaim
|
|
166
|
+
* otherwise.
|
|
167
|
+
*/
|
|
168
|
+
private reclaim;
|
|
169
|
+
private runDueTimers;
|
|
170
|
+
private advanceTweens;
|
|
171
|
+
private deactivate;
|
|
172
|
+
/** Smallest n >= 1 such that `n` more steps reaches `dueTime`, guarding against float noise near a step boundary. */
|
|
173
|
+
private stepsUntilDue;
|
|
174
|
+
private invalid;
|
|
175
|
+
private handleFor;
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Engine-internal: advances `time` by exactly one Simulation Step (CA-3).
|
|
179
|
+
* `Game.simulateStep()` calls this as its very first statement, so nothing
|
|
180
|
+
* advances with `simulate === false`, on a zero-step frame, or while the
|
|
181
|
+
* Runtime Bridge is paused and not stepping. A standalone `GameTime` — a
|
|
182
|
+
* behaviors-test stub game, or this package's own game-time.test.ts (CA-14)
|
|
183
|
+
* — must call this the same way. This is the only way in: the pass itself
|
|
184
|
+
* lives behind the module-private `ADVANCE` symbol key, unreachable from
|
|
185
|
+
* outside this file, so project code has no `advanceStep()`-shaped method
|
|
186
|
+
* to call directly and desynchronize Game Time with.
|
|
187
|
+
*/
|
|
188
|
+
export declare function advanceGameTime(time: GameTime): void;
|
|
189
|
+
export {};
|
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
import { SIMULATION_STEP, SIMULATION_TIME_EPSILON } from './fixed-step.js';
|
|
2
|
+
const INACTIVE_HANDLE = Object.freeze({
|
|
3
|
+
cancel() { },
|
|
4
|
+
active: false,
|
|
5
|
+
elapsed: 0,
|
|
6
|
+
remaining: 0,
|
|
7
|
+
});
|
|
8
|
+
const EASINGS = {
|
|
9
|
+
linear: (t) => t,
|
|
10
|
+
quadIn: (t) => t * t,
|
|
11
|
+
quadOut: (t) => 1 - (1 - t) * (1 - t),
|
|
12
|
+
quadInOut: (t) => (t < 0.5 ? 2 * t * t : 1 - ((-2 * t + 2) ** 2) / 2),
|
|
13
|
+
cubicIn: (t) => t * t * t,
|
|
14
|
+
cubicOut: (t) => 1 - (1 - t) ** 3,
|
|
15
|
+
cubicInOut: (t) => (t < 0.5 ? 4 * t * t * t : 1 - ((-2 * t + 2) ** 3) / 2),
|
|
16
|
+
sineInOut: (t) => -(Math.cos(Math.PI * t) - 1) / 2,
|
|
17
|
+
};
|
|
18
|
+
function resolveEasing(easing) {
|
|
19
|
+
if (easing === undefined)
|
|
20
|
+
return EASINGS.linear;
|
|
21
|
+
if (typeof easing === 'function')
|
|
22
|
+
return easing;
|
|
23
|
+
return Object.prototype.hasOwnProperty.call(EASINGS, easing) ? EASINGS[easing] : null;
|
|
24
|
+
}
|
|
25
|
+
function isValidOwner(owner) {
|
|
26
|
+
return (typeof owner === 'object' &&
|
|
27
|
+
owner !== null &&
|
|
28
|
+
typeof owner['alive'] === 'boolean');
|
|
29
|
+
}
|
|
30
|
+
/** `'session'` survives a scene change; any other value (including absent) means scene. */
|
|
31
|
+
function normalizeScope(scope) {
|
|
32
|
+
return scope === 'session' ? 'session' : 'scene';
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Module-private key for the per-step advance pass. Not exported, so
|
|
36
|
+
* `time[ADVANCE]()` cannot be spelled outside this file — `advanceGameTime`
|
|
37
|
+
* (below, exported) is the only reachable entry point from project code.
|
|
38
|
+
*/
|
|
39
|
+
const ADVANCE = Symbol('waica.gameTime.advance');
|
|
40
|
+
/**
|
|
41
|
+
* The `game.time` service (CA-1..CA-9, CA-14): `after`, `every` and `tween`
|
|
42
|
+
* scheduled against Game Time (`now`), advanced only by Simulation Steps —
|
|
43
|
+
* never the wall clock, never synchronously, never while paused or not
|
|
44
|
+
* simulating. Constructible standalone, like `Stats`: a `Game` owns one via
|
|
45
|
+
* `game.time`, and a behaviors-test stub game can carry its own, advanced by
|
|
46
|
+
* the same engine-internal hook `Game` uses (`advanceGameTime`, exported
|
|
47
|
+
* below — not part of this class's documented API).
|
|
48
|
+
*/
|
|
49
|
+
export class GameTime {
|
|
50
|
+
stepCount = 0;
|
|
51
|
+
nextId = 0;
|
|
52
|
+
timers = [];
|
|
53
|
+
tweens = [];
|
|
54
|
+
/**
|
|
55
|
+
* Set whenever a timer/tween goes inactive since the matching array was
|
|
56
|
+
* last pruned, whether by firing/completing the per-step advance pass or
|
|
57
|
+
* by a plain `handle.cancel()` from outside it; consumed (and cleared) by
|
|
58
|
+
* the next `reclaim()` that actually reassigns that array (CA-3 perf: no
|
|
59
|
+
* reassignment when nothing died). Invariant: a flag is false only when
|
|
60
|
+
* its array holds no inactive entries — every path that clears a flag
|
|
61
|
+
* prunes that array first, and every path that deactivates an entry sets
|
|
62
|
+
* the matching flag. `reclaim()` relies on this to skip a prune safely.
|
|
63
|
+
*/
|
|
64
|
+
timersDirty = false;
|
|
65
|
+
tweensDirty = false;
|
|
66
|
+
/** 0 on a new GameTime; `N * SIMULATION_STEP` exactly after N steps — computed, never summed (CA-9). */
|
|
67
|
+
get now() {
|
|
68
|
+
return this.stepCount * SIMULATION_STEP;
|
|
69
|
+
}
|
|
70
|
+
/** Active timers plus active tweens across both scopes (CA-10). */
|
|
71
|
+
get pending() {
|
|
72
|
+
return this.countActive(this.timers) + this.countActive(this.tweens);
|
|
73
|
+
}
|
|
74
|
+
/** Smallest positive integer n s.t. n more steps fires/completes something; null when nothing is pending (CA-10). */
|
|
75
|
+
get nextInSteps() {
|
|
76
|
+
return this.minStepsUntilDue(this.tweens, this.minStepsUntilDue(this.timers, null));
|
|
77
|
+
}
|
|
78
|
+
/** Shared traversal for `pending`: how many entries in `entries` are active. */
|
|
79
|
+
countActive(entries) {
|
|
80
|
+
let count = 0;
|
|
81
|
+
for (const entry of entries)
|
|
82
|
+
if (entry.active)
|
|
83
|
+
count += 1;
|
|
84
|
+
return count;
|
|
85
|
+
}
|
|
86
|
+
/** Shared traversal for `nextInSteps`: folds `stepsUntilDue` over every active entry, starting from `min`. */
|
|
87
|
+
minStepsUntilDue(entries, min) {
|
|
88
|
+
for (const entry of entries) {
|
|
89
|
+
if (!entry.active)
|
|
90
|
+
continue;
|
|
91
|
+
const steps = this.stepsUntilDue(entry.dueTime);
|
|
92
|
+
min = min === null ? steps : Math.min(min, steps);
|
|
93
|
+
}
|
|
94
|
+
return min;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Runs `callback` once, `seconds` of Game Time from now (0 or a negative
|
|
98
|
+
* duration: the start of the next step). Never synchronous (CA-1).
|
|
99
|
+
*/
|
|
100
|
+
after(seconds, callback, options = {}) {
|
|
101
|
+
if (!Number.isFinite(seconds))
|
|
102
|
+
return this.invalid('after(): seconds must be finite');
|
|
103
|
+
if (typeof callback !== 'function')
|
|
104
|
+
return this.invalid('after(): callback must be a function');
|
|
105
|
+
const owner = options.owner;
|
|
106
|
+
if (owner !== undefined && !isValidOwner(owner)) {
|
|
107
|
+
return this.invalid('after(): owner must be an object with a boolean "alive" property');
|
|
108
|
+
}
|
|
109
|
+
if (owner !== undefined && !owner.alive)
|
|
110
|
+
return INACTIVE_HANDLE;
|
|
111
|
+
const effective = Math.max(0, seconds);
|
|
112
|
+
const startTime = this.now;
|
|
113
|
+
const entry = {
|
|
114
|
+
id: this.nextId++,
|
|
115
|
+
kind: 'after',
|
|
116
|
+
scope: normalizeScope(options.scope),
|
|
117
|
+
owner,
|
|
118
|
+
active: true,
|
|
119
|
+
referenceStart: startTime,
|
|
120
|
+
dueTime: startTime + effective,
|
|
121
|
+
deactivatedAt: null,
|
|
122
|
+
callback,
|
|
123
|
+
};
|
|
124
|
+
this.timers.push(entry);
|
|
125
|
+
return this.handleFor(entry);
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Runs `callback` every `max(seconds, SIMULATION_STEP)` of Game Time,
|
|
129
|
+
* first one interval after creation, with no drift: each due time is
|
|
130
|
+
* `start + n * interval`, computed directly rather than by repeated
|
|
131
|
+
* addition (CA-2).
|
|
132
|
+
*/
|
|
133
|
+
every(seconds, callback, options = {}) {
|
|
134
|
+
if (!Number.isFinite(seconds))
|
|
135
|
+
return this.invalid('every(): seconds must be finite');
|
|
136
|
+
if (typeof callback !== 'function')
|
|
137
|
+
return this.invalid('every(): callback must be a function');
|
|
138
|
+
const owner = options.owner;
|
|
139
|
+
if (owner !== undefined && !isValidOwner(owner)) {
|
|
140
|
+
return this.invalid('every(): owner must be an object with a boolean "alive" property');
|
|
141
|
+
}
|
|
142
|
+
if (owner !== undefined && !owner.alive)
|
|
143
|
+
return INACTIVE_HANDLE;
|
|
144
|
+
const interval = Math.max(seconds, SIMULATION_STEP);
|
|
145
|
+
const startTime = this.now;
|
|
146
|
+
const entry = {
|
|
147
|
+
id: this.nextId++,
|
|
148
|
+
kind: 'every',
|
|
149
|
+
scope: normalizeScope(options.scope),
|
|
150
|
+
owner,
|
|
151
|
+
active: true,
|
|
152
|
+
referenceStart: startTime,
|
|
153
|
+
dueTime: startTime + interval,
|
|
154
|
+
deactivatedAt: null,
|
|
155
|
+
callback,
|
|
156
|
+
startTime,
|
|
157
|
+
intervalSeconds: interval,
|
|
158
|
+
occurrence: 1,
|
|
159
|
+
};
|
|
160
|
+
this.timers.push(entry);
|
|
161
|
+
return this.handleFor(entry);
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Carries a single number from `from` to `to` over `seconds` of Game
|
|
165
|
+
* Time, reporting it through `onUpdate` on every step including creation
|
|
166
|
+
* (synchronous, before this returns — CA-7). Owner already dead, or any
|
|
167
|
+
* invalid input, skips even that first call.
|
|
168
|
+
*/
|
|
169
|
+
tween(options) {
|
|
170
|
+
const { from, to, seconds, onUpdate, onComplete } = options;
|
|
171
|
+
if (!Number.isFinite(seconds))
|
|
172
|
+
return this.invalid('tween(): seconds must be finite');
|
|
173
|
+
if (!Number.isFinite(from))
|
|
174
|
+
return this.invalid('tween(): from must be finite');
|
|
175
|
+
if (!Number.isFinite(to))
|
|
176
|
+
return this.invalid('tween(): to must be finite');
|
|
177
|
+
if (typeof onUpdate !== 'function')
|
|
178
|
+
return this.invalid('tween(): onUpdate must be a function');
|
|
179
|
+
if (onComplete !== undefined && typeof onComplete !== 'function') {
|
|
180
|
+
return this.invalid('tween(): onComplete must be a function when present');
|
|
181
|
+
}
|
|
182
|
+
const ease = resolveEasing(options.easing);
|
|
183
|
+
if (!ease)
|
|
184
|
+
return this.invalid('tween(): easing must be a known name or a function');
|
|
185
|
+
const owner = options.owner;
|
|
186
|
+
if (owner !== undefined && !isValidOwner(owner)) {
|
|
187
|
+
return this.invalid('tween(): owner must be an object with a boolean "alive" property');
|
|
188
|
+
}
|
|
189
|
+
if (owner !== undefined && !owner.alive)
|
|
190
|
+
return INACTIVE_HANDLE;
|
|
191
|
+
const effective = Math.max(0, seconds);
|
|
192
|
+
const createdAt = this.now;
|
|
193
|
+
const entry = {
|
|
194
|
+
id: this.nextId++,
|
|
195
|
+
kind: 'tween',
|
|
196
|
+
scope: normalizeScope(options.scope),
|
|
197
|
+
owner,
|
|
198
|
+
active: true,
|
|
199
|
+
referenceStart: createdAt,
|
|
200
|
+
dueTime: createdAt + effective,
|
|
201
|
+
deactivatedAt: null,
|
|
202
|
+
from,
|
|
203
|
+
to,
|
|
204
|
+
seconds: effective,
|
|
205
|
+
ease,
|
|
206
|
+
onUpdate,
|
|
207
|
+
onComplete,
|
|
208
|
+
};
|
|
209
|
+
// Registers nothing if this throws (CA-7): the entry is only pushed after.
|
|
210
|
+
onUpdate(from);
|
|
211
|
+
this.tweens.push(entry);
|
|
212
|
+
return this.handleFor(entry);
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Internal: the start-of-step pass (CA-3) — advances `now`, then runs
|
|
216
|
+
* every due timer (due time, then creation order), then advances every
|
|
217
|
+
* tween that existed before this pass (creation order). Reachable only as
|
|
218
|
+
* `time[ADVANCE]()`, and `ADVANCE` is not exported, so `advanceGameTime()`
|
|
219
|
+
* (below, exported) is genuinely the only way in from project code. With
|
|
220
|
+
* nothing scheduled (the common case once a scene settles) this does no
|
|
221
|
+
* allocation beyond the step count itself; with timers but no tweens, the
|
|
222
|
+
* tween snapshot and `advanceTweens()` are skipped too.
|
|
223
|
+
*/
|
|
224
|
+
[ADVANCE]() {
|
|
225
|
+
this.stepCount += 1;
|
|
226
|
+
if (this.timers.length === 0 && this.tweens.length === 0)
|
|
227
|
+
return;
|
|
228
|
+
if (this.tweens.length === 0) {
|
|
229
|
+
this.runDueTimers();
|
|
230
|
+
}
|
|
231
|
+
else {
|
|
232
|
+
// Snapshot before any callback runs: a tween a due timer creates
|
|
233
|
+
// during this pass must not be advanced (or completed) until the
|
|
234
|
+
// next step. Skipped entirely above when there is nothing to snapshot.
|
|
235
|
+
const tweens = [...this.tweens];
|
|
236
|
+
this.runDueTimers();
|
|
237
|
+
this.advanceTweens(tweens);
|
|
238
|
+
}
|
|
239
|
+
this.reclaim();
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* Engine-internal by convention, like `Game.removeEntity`: a public
|
|
243
|
+
* method, not access-controlled, that `Game.unloadScene()` and every
|
|
244
|
+
* scene load are expected to call, including the first (CA-4).
|
|
245
|
+
*/
|
|
246
|
+
cancelSceneScoped() {
|
|
247
|
+
for (const timer of this.timers)
|
|
248
|
+
if (timer.scope !== 'session')
|
|
249
|
+
this.deactivate(timer);
|
|
250
|
+
for (const tween of this.tweens)
|
|
251
|
+
if (tween.scope !== 'session')
|
|
252
|
+
this.deactivate(tween);
|
|
253
|
+
this.reclaim();
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Engine-internal by convention, like `Game.removeEntity`: a public
|
|
257
|
+
* method, not access-controlled, that `Entity.destroy()` (CA-5) is
|
|
258
|
+
* expected to be the only caller of. Nothing re-reads `owner.alive` on
|
|
259
|
+
* later steps; a non-`Entity` owner whose `alive` flips without ever
|
|
260
|
+
* going through a real `destroy()` call never reaches here, so its
|
|
261
|
+
* timers/tweens keep running (see `TimerOptions.owner`).
|
|
262
|
+
*/
|
|
263
|
+
cancelOwnedBy(owner) {
|
|
264
|
+
for (const timer of this.timers)
|
|
265
|
+
if (timer.owner === owner)
|
|
266
|
+
this.deactivate(timer);
|
|
267
|
+
for (const tween of this.tweens)
|
|
268
|
+
if (tween.owner === owner)
|
|
269
|
+
this.deactivate(tween);
|
|
270
|
+
this.reclaim();
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Engine-internal by convention, like `Game.removeEntity`: a public
|
|
274
|
+
* method, not access-controlled, that `Game.dispose()` is expected to
|
|
275
|
+
* call (CA-4).
|
|
276
|
+
*/
|
|
277
|
+
cancelAll() {
|
|
278
|
+
for (const timer of this.timers)
|
|
279
|
+
this.deactivate(timer);
|
|
280
|
+
for (const tween of this.tweens)
|
|
281
|
+
this.deactivate(tween);
|
|
282
|
+
this.reclaim();
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Prunes each array only when its dirty flag is set, then clears that
|
|
286
|
+
* flag — safe because of the invariant documented on `timersDirty`
|
|
287
|
+
* /`tweensDirty`: a false flag means the array already holds no inactive
|
|
288
|
+
* entries, so skipping the filter changes no observable behavior. Called
|
|
289
|
+
* both at the tail of the per-step advance pass and by
|
|
290
|
+
* `cancelSceneScoped`/`cancelOwnedBy`/`cancelAll`, which are also
|
|
291
|
+
* reachable with `simulate === false` (e.g. the editor's edit mode
|
|
292
|
+
* calling `loadScene` on every edit), when no step ever runs to reclaim
|
|
293
|
+
* otherwise.
|
|
294
|
+
*/
|
|
295
|
+
reclaim() {
|
|
296
|
+
if (this.timersDirty) {
|
|
297
|
+
this.timers = this.timers.filter((timer) => timer.active);
|
|
298
|
+
this.timersDirty = false;
|
|
299
|
+
}
|
|
300
|
+
if (this.tweensDirty) {
|
|
301
|
+
this.tweens = this.tweens.filter((tween) => tween.active);
|
|
302
|
+
this.tweensDirty = false;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
runDueTimers() {
|
|
306
|
+
const due = this.timers.filter((timer) => timer.active && timer.dueTime <= this.now + SIMULATION_TIME_EPSILON);
|
|
307
|
+
if (due.length === 0)
|
|
308
|
+
return;
|
|
309
|
+
if (due.length > 1)
|
|
310
|
+
due.sort((a, b) => a.dueTime - b.dueTime || a.id - b.id);
|
|
311
|
+
for (const timer of due) {
|
|
312
|
+
if (!timer.active)
|
|
313
|
+
continue; // an earlier callback this same pass may have cancelled it
|
|
314
|
+
if (timer.kind === 'every') {
|
|
315
|
+
timer.referenceStart = timer.dueTime;
|
|
316
|
+
timer.occurrence += 1;
|
|
317
|
+
timer.dueTime = timer.startTime + timer.occurrence * timer.intervalSeconds;
|
|
318
|
+
}
|
|
319
|
+
else {
|
|
320
|
+
timer.active = false;
|
|
321
|
+
timer.deactivatedAt = timer.dueTime;
|
|
322
|
+
this.timersDirty = true;
|
|
323
|
+
}
|
|
324
|
+
timer.callback();
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
advanceTweens(tweens) {
|
|
328
|
+
for (const tween of tweens) {
|
|
329
|
+
if (!tween.active)
|
|
330
|
+
continue;
|
|
331
|
+
if (tween.dueTime <= this.now + SIMULATION_TIME_EPSILON) {
|
|
332
|
+
tween.active = false;
|
|
333
|
+
tween.deactivatedAt = tween.dueTime;
|
|
334
|
+
this.tweensDirty = true;
|
|
335
|
+
tween.onUpdate(tween.to);
|
|
336
|
+
tween.onComplete?.();
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
const elapsed = this.now - tween.referenceStart;
|
|
340
|
+
// Reaching 1 would mean elapsed >= seconds, i.e. now >= dueTime — the
|
|
341
|
+
// branch above always exits first in that case, so this is never
|
|
342
|
+
// clamped in practice.
|
|
343
|
+
const t = elapsed / tween.seconds;
|
|
344
|
+
tween.onUpdate(tween.from + (tween.to - tween.from) * tween.ease(t));
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
deactivate(entry) {
|
|
348
|
+
if (!entry.active)
|
|
349
|
+
return;
|
|
350
|
+
entry.active = false;
|
|
351
|
+
entry.deactivatedAt = this.now;
|
|
352
|
+
if (entry.kind === 'tween')
|
|
353
|
+
this.tweensDirty = true;
|
|
354
|
+
else
|
|
355
|
+
this.timersDirty = true;
|
|
356
|
+
}
|
|
357
|
+
/** Smallest n >= 1 such that `n` more steps reaches `dueTime`, guarding against float noise near a step boundary. */
|
|
358
|
+
stepsUntilDue(dueTime) {
|
|
359
|
+
const raw = (dueTime - this.now) / SIMULATION_STEP;
|
|
360
|
+
const n = Math.ceil(raw - SIMULATION_TIME_EPSILON / SIMULATION_STEP);
|
|
361
|
+
return Math.max(1, n);
|
|
362
|
+
}
|
|
363
|
+
invalid(message) {
|
|
364
|
+
console.warn(`[waica] ${message}`);
|
|
365
|
+
return INACTIVE_HANDLE;
|
|
366
|
+
}
|
|
367
|
+
handleFor(entry) {
|
|
368
|
+
const time = this;
|
|
369
|
+
return {
|
|
370
|
+
cancel() {
|
|
371
|
+
time.deactivate(entry);
|
|
372
|
+
},
|
|
373
|
+
get active() {
|
|
374
|
+
return entry.active;
|
|
375
|
+
},
|
|
376
|
+
get elapsed() {
|
|
377
|
+
const end = entry.deactivatedAt ?? time.now;
|
|
378
|
+
return Math.max(0, end - entry.referenceStart);
|
|
379
|
+
},
|
|
380
|
+
get remaining() {
|
|
381
|
+
if (!entry.active)
|
|
382
|
+
return 0;
|
|
383
|
+
return Math.max(0, entry.dueTime - time.now);
|
|
384
|
+
},
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
/**
|
|
389
|
+
* Engine-internal: advances `time` by exactly one Simulation Step (CA-3).
|
|
390
|
+
* `Game.simulateStep()` calls this as its very first statement, so nothing
|
|
391
|
+
* advances with `simulate === false`, on a zero-step frame, or while the
|
|
392
|
+
* Runtime Bridge is paused and not stepping. A standalone `GameTime` — a
|
|
393
|
+
* behaviors-test stub game, or this package's own game-time.test.ts (CA-14)
|
|
394
|
+
* — must call this the same way. This is the only way in: the pass itself
|
|
395
|
+
* lives behind the module-private `ADVANCE` symbol key, unreachable from
|
|
396
|
+
* outside this file, so project code has no `advanceStep()`-shaped method
|
|
397
|
+
* to call directly and desynchronize Game Time with.
|
|
398
|
+
*/
|
|
399
|
+
export function advanceGameTime(time) {
|
|
400
|
+
time[ADVANCE]();
|
|
401
|
+
}
|
package/dist/game.d.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { type SceneCameraJson } from './camera.js';
|
|
|
5
5
|
import type { Component } from './component.js';
|
|
6
6
|
import { Entity } from './entity.js';
|
|
7
7
|
import { Emitter } from './events.js';
|
|
8
|
+
import { GameTime } from './game-time.js';
|
|
8
9
|
import { Input, type InputBindings } from './input.js';
|
|
9
10
|
import { Pointer } from './pointer.js';
|
|
10
11
|
import { type SceneJson, type SceneRegistry, type SceneRenderJson } from './scene.js';
|
|
@@ -67,6 +68,8 @@ export declare class Game {
|
|
|
67
68
|
readonly ui: GameUi;
|
|
68
69
|
/** The audio mixer: channels, master, playback. See ADR 0012, ADR 0013. */
|
|
69
70
|
readonly audio: AudioSubsystem;
|
|
71
|
+
/** Simulated scheduling: `after`, `every`, `tween`, `now`. See ADR 0017. */
|
|
72
|
+
readonly time: GameTime;
|
|
70
73
|
/** Registry retained by loadScene for runtime prefab spawning. */
|
|
71
74
|
registry: SceneRegistry | null;
|
|
72
75
|
paramOverrides: ParamOverrides;
|
|
@@ -209,7 +212,8 @@ export declare class Game {
|
|
|
209
212
|
*/
|
|
210
213
|
private runFrame;
|
|
211
214
|
/**
|
|
212
|
-
* One Simulation Step:
|
|
215
|
+
* One Simulation Step: game.time's start-of-step pass (ADR 0017, CA-3)
|
|
216
|
+
* first, then the Component Update Schedule (ADR 0004) in full,
|
|
213
217
|
* collisions, the scene camera and the host's callbacks, every one of
|
|
214
218
|
* them handed exactly SIMULATION_STEP (CA-1); then the input frame ends.
|
|
215
219
|
*/
|
package/dist/game.js
CHANGED
|
@@ -6,6 +6,7 @@ import { resolveComponentUpdateSchedule } from './component-update-schedule.js';
|
|
|
6
6
|
import { Entity } from './entity.js';
|
|
7
7
|
import { Emitter } from './events.js';
|
|
8
8
|
import { consumeSimulationSteps, MAX_CHAINED_HOPS, SIMULATION_STEP, snapElapsedToStep, } from './fixed-step.js';
|
|
9
|
+
import { advanceGameTime, GameTime } from './game-time.js';
|
|
9
10
|
import { Input } from './input.js';
|
|
10
11
|
import { Pointer } from './pointer.js';
|
|
11
12
|
import { activeRuntimeBridgeHook, EngineRuntimeBridge, } from './runtime-bridge.js';
|
|
@@ -33,6 +34,8 @@ export class Game {
|
|
|
33
34
|
ui;
|
|
34
35
|
/** The audio mixer: channels, master, playback. See ADR 0012, ADR 0013. */
|
|
35
36
|
audio;
|
|
37
|
+
/** Simulated scheduling: `after`, `every`, `tween`, `now`. See ADR 0017. */
|
|
38
|
+
time = new GameTime();
|
|
36
39
|
/** Registry retained by loadScene for runtime prefab spawning. */
|
|
37
40
|
registry = null;
|
|
38
41
|
paramOverrides = {};
|
|
@@ -148,6 +151,9 @@ export class Game {
|
|
|
148
151
|
unloadScene() {
|
|
149
152
|
this.ui.unloadScene();
|
|
150
153
|
this.audio.unloadScene();
|
|
154
|
+
// Scene-scoped timers/tweens die here too (ADR 0017): before entities are
|
|
155
|
+
// destroyed below, so an owner's own destroy() cancellation is a no-op.
|
|
156
|
+
this.time.cancelSceneScoped();
|
|
151
157
|
// An explicit unload means "no scene": a swap queued earlier this frame
|
|
152
158
|
// would otherwise flush next frame and resurrect one.
|
|
153
159
|
this.pendingSceneLoad = null;
|
|
@@ -339,6 +345,9 @@ export class Game {
|
|
|
339
345
|
this.resizeObserver.disconnect();
|
|
340
346
|
this.ui.dispose();
|
|
341
347
|
this.audio.dispose();
|
|
348
|
+
// Cancels both scopes, including session-scoped work no entity owns
|
|
349
|
+
// (entity.destroy() below only ever reaches owned work) — ADR 0017.
|
|
350
|
+
this.time.cancelAll();
|
|
342
351
|
for (const entity of [...this.entities])
|
|
343
352
|
entity.destroy();
|
|
344
353
|
this.renderer.dispose();
|
|
@@ -429,11 +438,13 @@ export class Game {
|
|
|
429
438
|
}
|
|
430
439
|
}
|
|
431
440
|
/**
|
|
432
|
-
* One Simulation Step:
|
|
441
|
+
* One Simulation Step: game.time's start-of-step pass (ADR 0017, CA-3)
|
|
442
|
+
* first, then the Component Update Schedule (ADR 0004) in full,
|
|
433
443
|
* collisions, the scene camera and the host's callbacks, every one of
|
|
434
444
|
* them handed exactly SIMULATION_STEP (CA-1); then the input frame ends.
|
|
435
445
|
*/
|
|
436
446
|
simulateStep() {
|
|
447
|
+
advanceGameTime(this.time);
|
|
437
448
|
for (const entity of [...this.entities]) {
|
|
438
449
|
const schedule = this.componentUpdateSchedule(entity);
|
|
439
450
|
if (!schedule)
|
package/dist/index.d.ts
CHANGED
|
@@ -14,6 +14,8 @@ export { spritePlacement } from './sprite-placement.js';
|
|
|
14
14
|
export type { SpritePlacement, SpritePlacementInput } from './sprite-placement.js';
|
|
15
15
|
export { CAMERA_DEFAULTS, isCameraVelocityProvider, resolveSceneCamera, stepSceneCamera } from './camera.js';
|
|
16
16
|
export { SIMULATION_STEP, SIMULATION_TIME_EPSILON } from './fixed-step.js';
|
|
17
|
+
export { GameTime, advanceGameTime } from './game-time.js';
|
|
18
|
+
export type { EasingName, TimerHandle, TimerOptions, TweenOptions } from './game-time.js';
|
|
17
19
|
export type { SceneCameraJson, CameraLimitsJson, CameraVelocity, CameraVelocityProvider, ResolvedSceneCamera, } from './camera.js';
|
|
18
20
|
export { Entity } from './entity.js';
|
|
19
21
|
export { Component } from './component.js';
|
|
@@ -30,7 +32,7 @@ export type { PointerCamera, PointerDeps, PointerPick, PointerResolution } from
|
|
|
30
32
|
export { RUNTIME_BRIDGE_CAPABILITIES, RUNTIME_BRIDGE_PROTOCOL_VERSION, RUNTIME_BRIDGE_SYMBOL, RuntimeBridgeOperationError, } from './runtime-bridge.js';
|
|
31
33
|
export type { RuntimeBridge, RuntimeBridgeActivation, RuntimeControlRequest, RuntimeControlResult, RuntimeMetadata, RuntimeMode, } from './runtime-bridge.js';
|
|
32
34
|
export { RUNTIME_PROJECTION_LIMITS } from './runtime-inspection.js';
|
|
33
|
-
export type { ProjectedValue, ProjectionIssue, ProjectionMarker, ProjectionMarkerKind, RuntimeComponentSnapshot, RuntimeEntitySnapshot, RuntimeSnapshot, RuntimeSnapshotAudio, RuntimeSnapshotFilters, RuntimeTransformSnapshot, } from './runtime-inspection.js';
|
|
35
|
+
export type { ProjectedValue, ProjectionIssue, ProjectionMarker, ProjectionMarkerKind, RuntimeComponentSnapshot, RuntimeEntitySnapshot, RuntimeSnapshot, RuntimeSnapshotAudio, RuntimeSnapshotFilters, RuntimeSnapshotTime, RuntimeTransformSnapshot, } from './runtime-inspection.js';
|
|
34
36
|
export type { ArchetypeArt, ArchetypeManifest, BrowserArchetypeManifest, EntityTemplate, } from './archetype.js';
|
|
35
37
|
export { Stats } from './stats.js';
|
|
36
38
|
export type { StatValue } from './stats.js';
|
package/dist/index.js
CHANGED
|
@@ -6,6 +6,7 @@ export { projectIsometric, screenInputToLogical, unprojectIsometric } from './pr
|
|
|
6
6
|
export { spritePlacement } from './sprite-placement.js';
|
|
7
7
|
export { CAMERA_DEFAULTS, isCameraVelocityProvider, resolveSceneCamera, stepSceneCamera } from './camera.js';
|
|
8
8
|
export { SIMULATION_STEP, SIMULATION_TIME_EPSILON } from './fixed-step.js';
|
|
9
|
+
export { GameTime, advanceGameTime } from './game-time.js';
|
|
9
10
|
export { Entity } from './entity.js';
|
|
10
11
|
export { Component } from './component.js';
|
|
11
12
|
export { authoringDefaults } from './authoring-defaults.js';
|
|
@@ -65,6 +65,23 @@ export interface RuntimeSnapshotAudio {
|
|
|
65
65
|
channels: Record<string, AudioChannelState>;
|
|
66
66
|
playing: LiveSoundInfo[];
|
|
67
67
|
}
|
|
68
|
+
/**
|
|
69
|
+
* `game.time`'s pending work (CA-10), beside `audio`: `pending` counts
|
|
70
|
+
* active timers plus active tweens across both scopes; `nextInSteps` is the
|
|
71
|
+
* smallest positive integer n such that `step { frames: n }` makes an
|
|
72
|
+
* active timer run or an active tween complete, or null when `pending` is
|
|
73
|
+
* 0. Emitted unconditionally, like `audio` — never filtered, and never
|
|
74
|
+
* repeats `now` (metadata already carries `simulationTime`). That promise
|
|
75
|
+
* holds only while the Game is simulating: `runFrame` runs zero steps with
|
|
76
|
+
* `simulate === false` (`game.ts`), so on a Game that is paused AND not
|
|
77
|
+
* simulating, `nextInSteps` is still reported but stepping never consumes
|
|
78
|
+
* it — a Run Session starts with `simulate === true`, so this only matters
|
|
79
|
+
* if project code flips it.
|
|
80
|
+
*/
|
|
81
|
+
export interface RuntimeSnapshotTime {
|
|
82
|
+
pending: number;
|
|
83
|
+
nextInSteps: number | null;
|
|
84
|
+
}
|
|
68
85
|
export interface RuntimeSnapshot extends RuntimeMetadata {
|
|
69
86
|
stats: Record<string, StatValue>;
|
|
70
87
|
/** The live scene's name (its catalog key), or null with no scene loaded. */
|
|
@@ -72,6 +89,7 @@ export interface RuntimeSnapshot extends RuntimeMetadata {
|
|
|
72
89
|
entities: RuntimeEntitySnapshot[];
|
|
73
90
|
projectionIssues: ProjectionIssue[];
|
|
74
91
|
audio: RuntimeSnapshotAudio;
|
|
92
|
+
time: RuntimeSnapshotTime;
|
|
75
93
|
}
|
|
76
94
|
export declare const RUNTIME_PROJECTION_LIMITS: {
|
|
77
95
|
readonly depth: 5;
|
|
@@ -87,6 +105,7 @@ export declare class RuntimeInspector {
|
|
|
87
105
|
constructor(game: Game);
|
|
88
106
|
snapshot(metadata: RuntimeMetadata, filters?: RuntimeSnapshotFilters): RuntimeSnapshot;
|
|
89
107
|
private audioSnapshot;
|
|
108
|
+
private timeSnapshot;
|
|
90
109
|
private capSnapshot;
|
|
91
110
|
private idFor;
|
|
92
111
|
}
|
|
@@ -238,6 +238,7 @@ export class RuntimeInspector {
|
|
|
238
238
|
entities,
|
|
239
239
|
projectionIssues,
|
|
240
240
|
audio: this.audioSnapshot(),
|
|
241
|
+
time: this.timeSnapshot(),
|
|
241
242
|
});
|
|
242
243
|
}
|
|
243
244
|
audioSnapshot() {
|
|
@@ -251,6 +252,12 @@ export class RuntimeInspector {
|
|
|
251
252
|
playing: this.game.audio.liveSounds(),
|
|
252
253
|
};
|
|
253
254
|
}
|
|
255
|
+
timeSnapshot() {
|
|
256
|
+
return {
|
|
257
|
+
pending: this.game.time.pending,
|
|
258
|
+
nextInSteps: this.game.time.nextInSteps,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
254
261
|
capSnapshot(snapshot) {
|
|
255
262
|
if (utf8Bytes(JSON.stringify(snapshot)) <= RUNTIME_PROJECTION_LIMITS.snapshotBytes) {
|
|
256
263
|
return snapshot;
|
package/dist/scene.js
CHANGED
|
@@ -85,8 +85,17 @@ export function spawnFromJson(game, json, registry) {
|
|
|
85
85
|
* at a time.
|
|
86
86
|
*/
|
|
87
87
|
export function loadScene(game, scene, registry) {
|
|
88
|
-
if (game.registry)
|
|
88
|
+
if (game.registry) {
|
|
89
89
|
game.unloadScene();
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
// unloadScene() would otherwise be skipped on a Game's first load
|
|
93
|
+
// (F11), leaving a scene-scoped timer/tween created before it — a boot
|
|
94
|
+
// clock, say — to survive this load and die only at the next one
|
|
95
|
+
// instead. Cancel that slice of unloadScene()'s work directly; there is
|
|
96
|
+
// no scene, UI or audio yet for the rest of it to touch (ADR 0017, CA-4).
|
|
97
|
+
game.time.cancelSceneScoped();
|
|
98
|
+
}
|
|
90
99
|
game.registry = registry;
|
|
91
100
|
game.setSceneRender(scene.render);
|
|
92
101
|
for (const entityJson of scene.entities)
|