@aethervtt/shard-runtime 0.0.1
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/LICENSE +21 -0
- package/README.md +3 -0
- package/dist/app.d.ts +113 -0
- package/dist/app.js +348 -0
- package/dist/control.d.ts +21 -0
- package/dist/control.js +41 -0
- package/dist/display.d.ts +19 -0
- package/dist/display.js +48 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +45 -0
- package/dist/log.d.ts +44 -0
- package/dist/log.js +68 -0
- package/dist/plugin.d.ts +18 -0
- package/dist/plugin.js +6 -0
- package/dist/runners.d.ts +23 -0
- package/dist/runners.js +87 -0
- package/dist/state.d.ts +20 -0
- package/dist/state.js +47 -0
- package/dist/time.d.ts +34 -0
- package/dist/time.js +19 -0
- package/package.json +33 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Patrick Dias
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
package/dist/app.d.ts
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { type ComponentDef, type ResourceDef, type ScheduleLabel, type SystemConfig, type SystemDef, type SystemDescription, type SystemSetConfig, World } from '@aethervtt/shard-core';
|
|
2
|
+
import type { Plugin } from './plugin.js';
|
|
3
|
+
import type { Runner } from './runners.js';
|
|
4
|
+
import { type StateDef } from './state.js';
|
|
5
|
+
export interface AppOptions {
|
|
6
|
+
/** FixedUpdate rate. Default 60. */
|
|
7
|
+
fixedHz?: number;
|
|
8
|
+
/** Most FixedUpdate runs per frame; excess time is dropped. Default 5. */
|
|
9
|
+
maxFixedSteps?: number;
|
|
10
|
+
/** Clock for profiling, in ms. Defaults to `performance.now`. */
|
|
11
|
+
now?: () => number;
|
|
12
|
+
/** Seed for the `GlobalRng` resource. Default 0. */
|
|
13
|
+
seed?: number;
|
|
14
|
+
}
|
|
15
|
+
export interface AppDescription {
|
|
16
|
+
plugins: {
|
|
17
|
+
name: string;
|
|
18
|
+
dependencies: string[];
|
|
19
|
+
}[];
|
|
20
|
+
schedules: {
|
|
21
|
+
name: string;
|
|
22
|
+
systems: SystemDescription[];
|
|
23
|
+
}[];
|
|
24
|
+
}
|
|
25
|
+
type SystemInput = SystemDef<unknown> | SystemConfig;
|
|
26
|
+
/**
|
|
27
|
+
* A protocol method a plugin contributes (`physics.raycast`). The protocol server serves every
|
|
28
|
+
* app method next to its built-in ones, so packages expose themselves without the protocol
|
|
29
|
+
* importing them.
|
|
30
|
+
*/
|
|
31
|
+
export interface AppMethod {
|
|
32
|
+
/** `<area>.<verb>`, e.g. `physics.raycast`. */
|
|
33
|
+
name: string;
|
|
34
|
+
description: string;
|
|
35
|
+
/** Parameter schema (`defineSchema`); the protocol validates params against it. */
|
|
36
|
+
params: ComponentDef;
|
|
37
|
+
handler(ctx: {
|
|
38
|
+
app: App;
|
|
39
|
+
world: World;
|
|
40
|
+
}, params: Record<string, unknown>): unknown;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Owns the world, plugins, and schedules, and runs frames. Hosts drive it with a runner
|
|
44
|
+
* (animation frame, headless, or manual `update` calls).
|
|
45
|
+
*/
|
|
46
|
+
export declare class App {
|
|
47
|
+
readonly world: World;
|
|
48
|
+
readonly fixedHz: number;
|
|
49
|
+
private readonly maxFixedSteps;
|
|
50
|
+
private readonly now;
|
|
51
|
+
private readonly plugins;
|
|
52
|
+
private readonly pending;
|
|
53
|
+
private readonly schedules;
|
|
54
|
+
private readonly systemNames;
|
|
55
|
+
private readonly states;
|
|
56
|
+
private readonly profiler;
|
|
57
|
+
private runner;
|
|
58
|
+
private initialized;
|
|
59
|
+
private startupDone;
|
|
60
|
+
private accumulator;
|
|
61
|
+
private readonly frameListeners;
|
|
62
|
+
private readonly appMethods;
|
|
63
|
+
/** The plugin whose `build` is running, so registrations can be attributed to it. */
|
|
64
|
+
private building;
|
|
65
|
+
private readonly owned;
|
|
66
|
+
constructor(options?: AppOptions);
|
|
67
|
+
/** Adds plugins. They build in dependency order during `init()`. */
|
|
68
|
+
addPlugin(...plugins: Plugin[]): this;
|
|
69
|
+
/** Adds protocol methods (see `AppMethod`). A second method with a taken name throws. */
|
|
70
|
+
addMethod(...methods: AppMethod[]): this;
|
|
71
|
+
/** Methods plugins added, in the order they were added. */
|
|
72
|
+
get methods(): readonly AppMethod[];
|
|
73
|
+
addSystems(schedule: ScheduleLabel, ...systems: SystemInput[]): this;
|
|
74
|
+
configureSets(schedule: ScheduleLabel, ...sets: SystemSetConfig[]): this;
|
|
75
|
+
private ownedBy;
|
|
76
|
+
/** A registered system's run function, by name (hot reload compares them). */
|
|
77
|
+
systemRun(name: string): ((...args: never[]) => unknown) | undefined;
|
|
78
|
+
/** Names of the systems a plugin registered. */
|
|
79
|
+
systemsOf(plugin: string): string[];
|
|
80
|
+
/**
|
|
81
|
+
* Removes a plugin from a running app: every system and set config it registered, and the plugin
|
|
82
|
+
* itself. Resources and entities stay. For hot reload; returns the removed system names.
|
|
83
|
+
*/
|
|
84
|
+
unloadPlugin(name: string): string[];
|
|
85
|
+
/**
|
|
86
|
+
* Builds a plugin into an app that's already running (hot reload). Its systems set up on their
|
|
87
|
+
* first run. Startup systems it adds don't run: startup already happened.
|
|
88
|
+
*/
|
|
89
|
+
loadPlugin(plugin: Plugin): Promise<void>;
|
|
90
|
+
insertResource<T>(def: ResourceDef<T>, value: T): this;
|
|
91
|
+
/** Registers a state machine. `OnEnter(initial)` runs at the start of the first frame. */
|
|
92
|
+
initState<T extends string>(state: StateDef<T>, initial?: NoInfer<T>): this;
|
|
93
|
+
setRunner(runner: Runner): this;
|
|
94
|
+
/** Builds plugins in dependency order, then awaits their `ready` hooks. */
|
|
95
|
+
init(): Promise<void>;
|
|
96
|
+
/** Initializes, then hands control to the runner (manual if none was set). */
|
|
97
|
+
run(): Promise<void>;
|
|
98
|
+
/** Runs one frame. `delta` is in seconds. */
|
|
99
|
+
update(delta: number): void;
|
|
100
|
+
/** Called after every frame with the number of frames completed. Returns an unsubscribe function. */
|
|
101
|
+
onFrame(listener: (frame: number) => void): () => void;
|
|
102
|
+
/**
|
|
103
|
+
* Runs any stepped frames requested through `AppControl` right now, at the fixed delta. For hosts
|
|
104
|
+
* without a frame loop (headless servers); loop-driven runners step one frame per tick instead.
|
|
105
|
+
*/
|
|
106
|
+
pump(): void;
|
|
107
|
+
runSchedule(label: ScheduleLabel): void;
|
|
108
|
+
describe(): AppDescription;
|
|
109
|
+
private schedule;
|
|
110
|
+
private applyStateTransitions;
|
|
111
|
+
private dependencyError;
|
|
112
|
+
}
|
|
113
|
+
export {};
|
package/dist/app.js
ADDED
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
import {
|
|
2
|
+
First,
|
|
3
|
+
FixedUpdate,
|
|
4
|
+
Last,
|
|
5
|
+
PostUpdate,
|
|
6
|
+
PreUpdate,
|
|
7
|
+
Profiler,
|
|
8
|
+
ProfilerResource,
|
|
9
|
+
Rng,
|
|
10
|
+
Schedule,
|
|
11
|
+
ShardError,
|
|
12
|
+
Startup,
|
|
13
|
+
Update,
|
|
14
|
+
World
|
|
15
|
+
} from "@aethervtt/shard-core";
|
|
16
|
+
import { AppControl, AppControlResource } from "./control.js";
|
|
17
|
+
import { Log, LogResource } from "./log.js";
|
|
18
|
+
import { OnEnter, OnExit } from "./state.js";
|
|
19
|
+
import { DisplayRate, FixedTime, GlobalRng, Time } from "./time.js";
|
|
20
|
+
const FRAME_SCHEDULES = [Startup, First, PreUpdate, FixedUpdate, Update, PostUpdate, Last];
|
|
21
|
+
const EPSILON = 1e-9;
|
|
22
|
+
const TimePlugin = {
|
|
23
|
+
name: "core/time",
|
|
24
|
+
build(app) {
|
|
25
|
+
app.insertResource(Time, { delta: 0, elapsed: 0, frame: 0 });
|
|
26
|
+
app.insertResource(FixedTime, { step: 1 / app.fixedHz, elapsed: 0, alpha: 0, steps: 0 });
|
|
27
|
+
app.insertResource(DisplayRate, { hz: 60, periodMs: 1e3 / 60, source: "assumed" });
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
class App {
|
|
31
|
+
world = new World();
|
|
32
|
+
fixedHz;
|
|
33
|
+
maxFixedSteps;
|
|
34
|
+
now;
|
|
35
|
+
plugins = /* @__PURE__ */ new Map();
|
|
36
|
+
pending = [];
|
|
37
|
+
schedules = /* @__PURE__ */ new Map();
|
|
38
|
+
systemNames = /* @__PURE__ */ new Set();
|
|
39
|
+
states = [];
|
|
40
|
+
profiler = new Profiler();
|
|
41
|
+
runner;
|
|
42
|
+
initialized = false;
|
|
43
|
+
startupDone = false;
|
|
44
|
+
accumulator = 0;
|
|
45
|
+
frameListeners = /* @__PURE__ */ new Set();
|
|
46
|
+
appMethods = /* @__PURE__ */ new Map();
|
|
47
|
+
/** The plugin whose `build` is running, so registrations can be attributed to it. */
|
|
48
|
+
building;
|
|
49
|
+
owned = /* @__PURE__ */ new Map();
|
|
50
|
+
constructor(options = {}) {
|
|
51
|
+
this.fixedHz = options.fixedHz ?? 60;
|
|
52
|
+
this.maxFixedSteps = options.maxFixedSteps ?? 5;
|
|
53
|
+
this.now = options.now ?? (() => performance.now());
|
|
54
|
+
this.world.insertResource(ProfilerResource, this.profiler);
|
|
55
|
+
this.world.insertResource(GlobalRng, new Rng(options.seed ?? 0));
|
|
56
|
+
const log = new Log();
|
|
57
|
+
log.now = () => this.world.tryResource(Time)?.elapsed ?? 0;
|
|
58
|
+
this.world.insertResource(LogResource, log);
|
|
59
|
+
this.world.insertResource(AppControlResource, new AppControl());
|
|
60
|
+
this.addPlugin(TimePlugin);
|
|
61
|
+
}
|
|
62
|
+
// --- building --------------------------------------------------------------
|
|
63
|
+
/** Adds plugins. They build in dependency order during `init()`. */
|
|
64
|
+
addPlugin(...plugins) {
|
|
65
|
+
for (const plugin of plugins) {
|
|
66
|
+
if (this.plugins.has(plugin.name)) {
|
|
67
|
+
throw new ShardError("app/duplicate-plugin", `Plugin "${plugin.name}" was added twice`);
|
|
68
|
+
}
|
|
69
|
+
this.plugins.set(plugin.name, plugin);
|
|
70
|
+
this.pending.push(plugin);
|
|
71
|
+
}
|
|
72
|
+
return this;
|
|
73
|
+
}
|
|
74
|
+
/** Adds protocol methods (see `AppMethod`). A second method with a taken name throws. */
|
|
75
|
+
addMethod(...methods) {
|
|
76
|
+
for (const method of methods) {
|
|
77
|
+
if (this.appMethods.has(method.name)) {
|
|
78
|
+
throw new ShardError("app/duplicate-method", `Method "${method.name}" was added twice`);
|
|
79
|
+
}
|
|
80
|
+
this.appMethods.set(method.name, method);
|
|
81
|
+
}
|
|
82
|
+
return this;
|
|
83
|
+
}
|
|
84
|
+
/** Methods plugins added, in the order they were added. */
|
|
85
|
+
get methods() {
|
|
86
|
+
return [...this.appMethods.values()];
|
|
87
|
+
}
|
|
88
|
+
addSystems(schedule, ...systems) {
|
|
89
|
+
const target = this.schedule(schedule);
|
|
90
|
+
for (const system of systems) {
|
|
91
|
+
const name = "system" in system ? system.system.name : system.name;
|
|
92
|
+
if (this.systemNames.has(name)) {
|
|
93
|
+
throw new ShardError("app/duplicate-system", `A system named "${name}" was already added`, {
|
|
94
|
+
hint: "System names must be unique across all schedules."
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
this.systemNames.add(name);
|
|
98
|
+
target.add(system);
|
|
99
|
+
this.ownedBy()?.systems.push({ schedule, name });
|
|
100
|
+
}
|
|
101
|
+
return this;
|
|
102
|
+
}
|
|
103
|
+
configureSets(schedule, ...sets) {
|
|
104
|
+
const target = this.schedule(schedule);
|
|
105
|
+
for (const set of sets) {
|
|
106
|
+
target.configureSet(set);
|
|
107
|
+
this.ownedBy()?.sets.push({ schedule, config: set });
|
|
108
|
+
}
|
|
109
|
+
return this;
|
|
110
|
+
}
|
|
111
|
+
ownedBy() {
|
|
112
|
+
if (this.building === void 0) return void 0;
|
|
113
|
+
let owned = this.owned.get(this.building);
|
|
114
|
+
if (!owned) {
|
|
115
|
+
owned = { systems: [], sets: [] };
|
|
116
|
+
this.owned.set(this.building, owned);
|
|
117
|
+
}
|
|
118
|
+
return owned;
|
|
119
|
+
}
|
|
120
|
+
/** A registered system's run function, by name (hot reload compares them). */
|
|
121
|
+
systemRun(name) {
|
|
122
|
+
for (const schedule of this.schedules.values()) {
|
|
123
|
+
for (const config of schedule.ordered()) {
|
|
124
|
+
if (config.system.name === name) return config.system.run;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return void 0;
|
|
128
|
+
}
|
|
129
|
+
/** Names of the systems a plugin registered. */
|
|
130
|
+
systemsOf(plugin) {
|
|
131
|
+
return this.owned.get(plugin)?.systems.map((s) => s.name) ?? [];
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Removes a plugin from a running app: every system and set config it registered, and the plugin
|
|
135
|
+
* itself. Resources and entities stay. For hot reload; returns the removed system names.
|
|
136
|
+
*/
|
|
137
|
+
unloadPlugin(name) {
|
|
138
|
+
const owned = this.owned.get(name);
|
|
139
|
+
this.plugins.delete(name);
|
|
140
|
+
this.owned.delete(name);
|
|
141
|
+
if (!owned) return [];
|
|
142
|
+
const bySchedule = /* @__PURE__ */ new Map();
|
|
143
|
+
const entry = (label) => {
|
|
144
|
+
let e = bySchedule.get(label);
|
|
145
|
+
if (!e) {
|
|
146
|
+
e = { systems: /* @__PURE__ */ new Set(), sets: /* @__PURE__ */ new Set() };
|
|
147
|
+
bySchedule.set(label, e);
|
|
148
|
+
}
|
|
149
|
+
return e;
|
|
150
|
+
};
|
|
151
|
+
for (const s of owned.systems) entry(s.schedule).systems.add(s.name);
|
|
152
|
+
for (const s of owned.sets) entry(s.schedule).sets.add(s.config);
|
|
153
|
+
for (const [label, e] of bySchedule) this.schedules.get(label)?.remove(e.systems, e.sets);
|
|
154
|
+
for (const s of owned.systems) this.systemNames.delete(s.name);
|
|
155
|
+
return owned.systems.map((s) => s.name);
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Builds a plugin into an app that's already running (hot reload). Its systems set up on their
|
|
159
|
+
* first run. Startup systems it adds don't run: startup already happened.
|
|
160
|
+
*/
|
|
161
|
+
async loadPlugin(plugin) {
|
|
162
|
+
if (this.plugins.has(plugin.name)) {
|
|
163
|
+
throw new ShardError("app/duplicate-plugin", `Plugin "${plugin.name}" is already loaded`, {
|
|
164
|
+
hint: "Unload it first (unloadPlugin)."
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
this.plugins.set(plugin.name, plugin);
|
|
168
|
+
this.building = plugin.name;
|
|
169
|
+
try {
|
|
170
|
+
plugin.build(this);
|
|
171
|
+
} finally {
|
|
172
|
+
this.building = void 0;
|
|
173
|
+
}
|
|
174
|
+
await plugin.ready?.(this);
|
|
175
|
+
}
|
|
176
|
+
insertResource(def, value) {
|
|
177
|
+
this.world.insertResource(def, value);
|
|
178
|
+
return this;
|
|
179
|
+
}
|
|
180
|
+
/** Registers a state machine. `OnEnter(initial)` runs at the start of the first frame. */
|
|
181
|
+
initState(state, initial = state.values[0]) {
|
|
182
|
+
this.world.insertResource(state.resource, { current: initial, next: void 0 });
|
|
183
|
+
this.states.push({ state, entered: false });
|
|
184
|
+
return this;
|
|
185
|
+
}
|
|
186
|
+
setRunner(runner) {
|
|
187
|
+
this.runner = runner;
|
|
188
|
+
return this;
|
|
189
|
+
}
|
|
190
|
+
/** Builds plugins in dependency order, then awaits their `ready` hooks. */
|
|
191
|
+
async init() {
|
|
192
|
+
if (this.initialized) return;
|
|
193
|
+
const built = [];
|
|
194
|
+
const builtNames = /* @__PURE__ */ new Set();
|
|
195
|
+
while (this.pending.length > 0) {
|
|
196
|
+
const index = this.pending.findIndex(
|
|
197
|
+
(p) => (p.dependencies ?? []).every((d) => builtNames.has(d))
|
|
198
|
+
);
|
|
199
|
+
if (index === -1) throw this.dependencyError(builtNames);
|
|
200
|
+
const [plugin] = this.pending.splice(index, 1);
|
|
201
|
+
this.building = plugin.name;
|
|
202
|
+
try {
|
|
203
|
+
plugin.build(this);
|
|
204
|
+
} finally {
|
|
205
|
+
this.building = void 0;
|
|
206
|
+
}
|
|
207
|
+
built.push(plugin);
|
|
208
|
+
builtNames.add(plugin.name);
|
|
209
|
+
}
|
|
210
|
+
for (const plugin of built) await plugin.ready?.(this);
|
|
211
|
+
this.initialized = true;
|
|
212
|
+
}
|
|
213
|
+
/** Initializes, then hands control to the runner (manual if none was set). */
|
|
214
|
+
async run() {
|
|
215
|
+
await this.init();
|
|
216
|
+
await this.runner?.(this);
|
|
217
|
+
}
|
|
218
|
+
// --- running ---------------------------------------------------------------
|
|
219
|
+
/** Runs one frame. `delta` is in seconds. */
|
|
220
|
+
update(delta) {
|
|
221
|
+
if (!this.initialized) {
|
|
222
|
+
throw new ShardError("app/not-initialized", "App.update() was called before init()", {
|
|
223
|
+
hint: "Await app.init() (or app.run()) first."
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
const world = this.world;
|
|
227
|
+
if (!this.startupDone) {
|
|
228
|
+
this.runSchedule(Startup);
|
|
229
|
+
this.startupDone = true;
|
|
230
|
+
}
|
|
231
|
+
this.applyStateTransitions();
|
|
232
|
+
world.updateEvents();
|
|
233
|
+
const time = world.resource(Time);
|
|
234
|
+
time.delta = delta;
|
|
235
|
+
time.elapsed += delta;
|
|
236
|
+
this.runSchedule(First);
|
|
237
|
+
this.runSchedule(PreUpdate);
|
|
238
|
+
const fixed = world.resource(FixedTime);
|
|
239
|
+
this.accumulator += delta;
|
|
240
|
+
let steps = 0;
|
|
241
|
+
while (this.accumulator + EPSILON >= fixed.step && steps < this.maxFixedSteps) {
|
|
242
|
+
fixed.elapsed += fixed.step;
|
|
243
|
+
this.runSchedule(FixedUpdate);
|
|
244
|
+
this.accumulator = Math.max(0, this.accumulator - fixed.step);
|
|
245
|
+
steps++;
|
|
246
|
+
}
|
|
247
|
+
if (this.accumulator + EPSILON >= fixed.step) this.accumulator %= fixed.step;
|
|
248
|
+
fixed.steps = steps;
|
|
249
|
+
fixed.alpha = Math.min(1, this.accumulator / fixed.step);
|
|
250
|
+
this.runSchedule(Update);
|
|
251
|
+
this.runSchedule(PostUpdate);
|
|
252
|
+
this.runSchedule(Last);
|
|
253
|
+
time.frame++;
|
|
254
|
+
for (const listener of this.frameListeners) listener(time.frame);
|
|
255
|
+
}
|
|
256
|
+
/** Called after every frame with the number of frames completed. Returns an unsubscribe function. */
|
|
257
|
+
onFrame(listener) {
|
|
258
|
+
this.frameListeners.add(listener);
|
|
259
|
+
return () => this.frameListeners.delete(listener);
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Runs any stepped frames requested through `AppControl` right now, at the fixed delta. For hosts
|
|
263
|
+
* without a frame loop (headless servers); loop-driven runners step one frame per tick instead.
|
|
264
|
+
*/
|
|
265
|
+
pump() {
|
|
266
|
+
const control = this.world.resource(AppControlResource);
|
|
267
|
+
while (control.pendingSteps > 0) {
|
|
268
|
+
try {
|
|
269
|
+
this.update(1 / this.fixedHz);
|
|
270
|
+
} catch (err) {
|
|
271
|
+
this.world.resource(LogResource).error(err);
|
|
272
|
+
control.paused = true;
|
|
273
|
+
control.pausedByError = true;
|
|
274
|
+
control.abort(err);
|
|
275
|
+
throw err;
|
|
276
|
+
}
|
|
277
|
+
control.stepped();
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
runSchedule(label) {
|
|
281
|
+
const schedule = this.schedules.get(label);
|
|
282
|
+
if (schedule && schedule.size > 0) {
|
|
283
|
+
schedule.run(this.world, { now: this.now, profiler: this.profiler });
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
// --- introspection ---------------------------------------------------------
|
|
287
|
+
describe() {
|
|
288
|
+
const order = [
|
|
289
|
+
...FRAME_SCHEDULES.filter((l) => this.schedules.has(l)),
|
|
290
|
+
...[...this.schedules.keys()].filter((l) => !FRAME_SCHEDULES.includes(l))
|
|
291
|
+
];
|
|
292
|
+
return {
|
|
293
|
+
plugins: [...this.plugins.values()].map((p) => ({
|
|
294
|
+
name: p.name,
|
|
295
|
+
dependencies: [...p.dependencies ?? []]
|
|
296
|
+
})),
|
|
297
|
+
schedules: order.map((label) => ({
|
|
298
|
+
name: label.name,
|
|
299
|
+
systems: this.schedules.get(label).describe()
|
|
300
|
+
}))
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
// --- internals -------------------------------------------------------------
|
|
304
|
+
schedule(label) {
|
|
305
|
+
let schedule = this.schedules.get(label);
|
|
306
|
+
if (!schedule) {
|
|
307
|
+
schedule = new Schedule(label);
|
|
308
|
+
this.schedules.set(label, schedule);
|
|
309
|
+
}
|
|
310
|
+
return schedule;
|
|
311
|
+
}
|
|
312
|
+
applyStateTransitions() {
|
|
313
|
+
for (const driver of this.states) {
|
|
314
|
+
const value = this.world.resource(driver.state.resource);
|
|
315
|
+
if (!driver.entered) {
|
|
316
|
+
driver.entered = true;
|
|
317
|
+
this.runSchedule(OnEnter(driver.state, value.current));
|
|
318
|
+
}
|
|
319
|
+
const next = value.next;
|
|
320
|
+
value.next = void 0;
|
|
321
|
+
if (next === void 0 || next === value.current) continue;
|
|
322
|
+
this.runSchedule(OnExit(driver.state, value.current));
|
|
323
|
+
value.current = next;
|
|
324
|
+
this.runSchedule(OnEnter(driver.state, next));
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
dependencyError(built) {
|
|
328
|
+
for (const plugin of this.pending) {
|
|
329
|
+
for (const dep of plugin.dependencies ?? []) {
|
|
330
|
+
if (!this.plugins.has(dep)) {
|
|
331
|
+
return new ShardError(
|
|
332
|
+
"app/missing-plugin",
|
|
333
|
+
`Plugin "${plugin.name}" depends on "${dep}", which was never added`,
|
|
334
|
+
{ hint: `Add the "${dep}" plugin before calling init().` }
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
const stuck = this.pending.map((p) => p.name).filter((n) => !built.has(n));
|
|
340
|
+
return new ShardError(
|
|
341
|
+
"app/plugin-cycle",
|
|
342
|
+
`Plugins depend on each other in a cycle: ${stuck.join(", ")}`
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
export {
|
|
347
|
+
App
|
|
348
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Frame control shared by every runner: pause, resume, and step exactly N frames. Stepped frames
|
|
3
|
+
* use the fixed delta, so seeded runs stay deterministic under stepping.
|
|
4
|
+
*/
|
|
5
|
+
export declare class AppControl {
|
|
6
|
+
paused: boolean;
|
|
7
|
+
/** Set when a failing frame paused the app, so a fix (e.g. a hot reload) can resume it. */
|
|
8
|
+
pausedByError: boolean;
|
|
9
|
+
/** Stepped frames ever requested / ever run. Both only grow; the difference is what's pending. */
|
|
10
|
+
private requested;
|
|
11
|
+
private completed;
|
|
12
|
+
private waiters;
|
|
13
|
+
get pendingSteps(): number;
|
|
14
|
+
/** Pauses the app and queues `frames` stepped frames; resolves once they've run. */
|
|
15
|
+
step(frames: number): Promise<void>;
|
|
16
|
+
/** A stepped frame threw: drops the pending steps and rejects everyone waiting on them. */
|
|
17
|
+
abort(error: unknown): void;
|
|
18
|
+
/** @internal Runners call this after running one stepped frame. */
|
|
19
|
+
stepped(): void;
|
|
20
|
+
}
|
|
21
|
+
export declare const AppControlResource: import("@aethervtt/shard-core").ResourceDef<AppControl>;
|
package/dist/control.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { defineResource } from "@aethervtt/shard-core";
|
|
2
|
+
class AppControl {
|
|
3
|
+
paused = false;
|
|
4
|
+
/** Set when a failing frame paused the app, so a fix (e.g. a hot reload) can resume it. */
|
|
5
|
+
pausedByError = false;
|
|
6
|
+
/** Stepped frames ever requested / ever run. Both only grow; the difference is what's pending. */
|
|
7
|
+
requested = 0;
|
|
8
|
+
completed = 0;
|
|
9
|
+
waiters = [];
|
|
10
|
+
get pendingSteps() {
|
|
11
|
+
return this.requested - this.completed;
|
|
12
|
+
}
|
|
13
|
+
/** Pauses the app and queues `frames` stepped frames; resolves once they've run. */
|
|
14
|
+
step(frames) {
|
|
15
|
+
this.paused = true;
|
|
16
|
+
if (frames <= 0) return Promise.resolve();
|
|
17
|
+
this.requested += frames;
|
|
18
|
+
const target = this.requested;
|
|
19
|
+
return new Promise((resolve, reject) => this.waiters.push({ target, resolve, reject }));
|
|
20
|
+
}
|
|
21
|
+
/** A stepped frame threw: drops the pending steps and rejects everyone waiting on them. */
|
|
22
|
+
abort(error) {
|
|
23
|
+
this.completed = this.requested;
|
|
24
|
+
for (const w of this.waiters.splice(0)) w.reject(error);
|
|
25
|
+
}
|
|
26
|
+
/** @internal Runners call this after running one stepped frame. */
|
|
27
|
+
stepped() {
|
|
28
|
+
if (this.pendingSteps === 0) return;
|
|
29
|
+
this.completed++;
|
|
30
|
+
while (this.waiters.length > 0 && this.waiters[0].target <= this.completed) {
|
|
31
|
+
this.waiters.shift().resolve();
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
const AppControlResource = defineResource("core/AppControl", {
|
|
36
|
+
description: "Pause, resume, and step the app. Runners honor it every frame."
|
|
37
|
+
});
|
|
38
|
+
export {
|
|
39
|
+
AppControl,
|
|
40
|
+
AppControlResource
|
|
41
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** Refresh rates displays ship with. A measurement within 4% of one snaps to it. */
|
|
2
|
+
export declare const COMMON_RATES: number[];
|
|
3
|
+
/** The nearest common rate within 4%, else the measurement rounded to a whole Hz. */
|
|
4
|
+
export declare function snapRate(hz: number): number;
|
|
5
|
+
/** The refresh rate a run of frame intervals (ms) shows: their median, snapped. */
|
|
6
|
+
export declare function rateFromIntervals(intervals: ArrayLike<number>): number;
|
|
7
|
+
/**
|
|
8
|
+
* Watches frame intervals after the startup probe. An app that can't keep up only ever shows
|
|
9
|
+
* slower intervals than the display's, so only faster ones count: a run of them (the window moved
|
|
10
|
+
* to a faster screen, or ProMotion left its idle rate) raises the rate. Allocates nothing.
|
|
11
|
+
*/
|
|
12
|
+
export declare class RefreshMeter {
|
|
13
|
+
periodMs: number;
|
|
14
|
+
private readonly run;
|
|
15
|
+
private count;
|
|
16
|
+
constructor(periodMs: number);
|
|
17
|
+
/** One frame interval in ms. Returns the new rate in Hz when it rose, else 0. */
|
|
18
|
+
sample(intervalMs: number): number;
|
|
19
|
+
}
|
package/dist/display.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
const COMMON_RATES = [24, 30, 48, 50, 60, 72, 75, 85, 90, 100, 120, 144, 165, 180, 240, 360];
|
|
2
|
+
function snapRate(hz) {
|
|
3
|
+
let best = Math.round(hz);
|
|
4
|
+
let error = 0.04;
|
|
5
|
+
for (const rate of COMMON_RATES) {
|
|
6
|
+
const e = Math.abs(hz - rate) / rate;
|
|
7
|
+
if (e < error) {
|
|
8
|
+
error = e;
|
|
9
|
+
best = rate;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
return best;
|
|
13
|
+
}
|
|
14
|
+
function rateFromIntervals(intervals) {
|
|
15
|
+
const sorted = Array.from(intervals).sort((a, b) => a - b);
|
|
16
|
+
const median = sorted[sorted.length >> 1];
|
|
17
|
+
return snapRate(1e3 / median);
|
|
18
|
+
}
|
|
19
|
+
const RUN = 20;
|
|
20
|
+
class RefreshMeter {
|
|
21
|
+
periodMs;
|
|
22
|
+
run = new Float64Array(RUN);
|
|
23
|
+
count = 0;
|
|
24
|
+
constructor(periodMs) {
|
|
25
|
+
this.periodMs = periodMs;
|
|
26
|
+
}
|
|
27
|
+
/** One frame interval in ms. Returns the new rate in Hz when it rose, else 0. */
|
|
28
|
+
sample(intervalMs) {
|
|
29
|
+
if (intervalMs < 2 || intervalMs > this.periodMs * 0.85) {
|
|
30
|
+
this.count = 0;
|
|
31
|
+
return 0;
|
|
32
|
+
}
|
|
33
|
+
this.run[this.count++] = intervalMs;
|
|
34
|
+
if (this.count < RUN) return 0;
|
|
35
|
+
this.count = 0;
|
|
36
|
+
this.run.sort();
|
|
37
|
+
const hz = snapRate(1e3 / this.run[RUN >> 1]);
|
|
38
|
+
if (1e3 / hz >= this.periodMs * 0.95) return 0;
|
|
39
|
+
this.periodMs = 1e3 / hz;
|
|
40
|
+
return hz;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
export {
|
|
44
|
+
COMMON_RATES,
|
|
45
|
+
RefreshMeter,
|
|
46
|
+
rateFromIntervals,
|
|
47
|
+
snapRate
|
|
48
|
+
};
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { App, type AppDescription, type AppMethod, type AppOptions } from './app.js';
|
|
2
|
+
export { AppControl, AppControlResource } from './control.js';
|
|
3
|
+
export { COMMON_RATES, RefreshMeter, rateFromIntervals, snapRate } from './display.js';
|
|
4
|
+
export { Log, type LogEntry, type LogLevel, LogResource } from './log.js';
|
|
5
|
+
export { definePlugin, type Plugin } from './plugin.js';
|
|
6
|
+
export { type AnimationFrameOptions, animationFrameRunner, type HeadlessOptions, headlessRunner, type Runner, } from './runners.js';
|
|
7
|
+
export { defineState, inState, OnEnter, OnExit, type StateDef, type StateValue, setState, } from './state.js';
|
|
8
|
+
export { DisplayRate, type DisplayRateData, FixedTime, type FixedTimeData, GlobalRng, Time, type TimeData, } from './time.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { App } from "./app.js";
|
|
2
|
+
import { AppControl, AppControlResource } from "./control.js";
|
|
3
|
+
import { COMMON_RATES, RefreshMeter, rateFromIntervals, snapRate } from "./display.js";
|
|
4
|
+
import { Log, LogResource } from "./log.js";
|
|
5
|
+
import { definePlugin } from "./plugin.js";
|
|
6
|
+
import {
|
|
7
|
+
animationFrameRunner,
|
|
8
|
+
headlessRunner
|
|
9
|
+
} from "./runners.js";
|
|
10
|
+
import {
|
|
11
|
+
defineState,
|
|
12
|
+
inState,
|
|
13
|
+
OnEnter,
|
|
14
|
+
OnExit,
|
|
15
|
+
setState
|
|
16
|
+
} from "./state.js";
|
|
17
|
+
import {
|
|
18
|
+
DisplayRate,
|
|
19
|
+
FixedTime,
|
|
20
|
+
GlobalRng,
|
|
21
|
+
Time
|
|
22
|
+
} from "./time.js";
|
|
23
|
+
export {
|
|
24
|
+
App,
|
|
25
|
+
AppControl,
|
|
26
|
+
AppControlResource,
|
|
27
|
+
COMMON_RATES,
|
|
28
|
+
DisplayRate,
|
|
29
|
+
FixedTime,
|
|
30
|
+
GlobalRng,
|
|
31
|
+
Log,
|
|
32
|
+
LogResource,
|
|
33
|
+
OnEnter,
|
|
34
|
+
OnExit,
|
|
35
|
+
RefreshMeter,
|
|
36
|
+
Time,
|
|
37
|
+
animationFrameRunner,
|
|
38
|
+
definePlugin,
|
|
39
|
+
defineState,
|
|
40
|
+
headlessRunner,
|
|
41
|
+
inState,
|
|
42
|
+
rateFromIntervals,
|
|
43
|
+
setState,
|
|
44
|
+
snapRate
|
|
45
|
+
};
|
package/dist/log.d.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
|
|
2
|
+
export interface LogEntry {
|
|
3
|
+
/** Monotonic sequence number, for subscribers that resume. */
|
|
4
|
+
seq: number;
|
|
5
|
+
level: LogLevel;
|
|
6
|
+
message: string;
|
|
7
|
+
/** App time in seconds when logged. */
|
|
8
|
+
time: number;
|
|
9
|
+
code?: string;
|
|
10
|
+
path?: string;
|
|
11
|
+
hint?: string;
|
|
12
|
+
/** Where in project source an error was thrown: `scripts/main.ts:42:7`. */
|
|
13
|
+
source?: string;
|
|
14
|
+
data?: unknown;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Structured log, kept as a ring buffer. Errors from systems, the GPU, and shaders land here, so
|
|
18
|
+
* tools (and agents) can read what happened without scraping a console.
|
|
19
|
+
*/
|
|
20
|
+
export declare class Log {
|
|
21
|
+
readonly capacity: number;
|
|
22
|
+
private readonly entries;
|
|
23
|
+
private readonly listeners;
|
|
24
|
+
private seq;
|
|
25
|
+
/** App time source; set by the app. */
|
|
26
|
+
now: () => number;
|
|
27
|
+
/**
|
|
28
|
+
* Adds fields to logged errors. Hosts that bundle project code set this to map stack frames back
|
|
29
|
+
* to project source (`source`).
|
|
30
|
+
*/
|
|
31
|
+
annotate: ((error: unknown) => Partial<LogEntry> | undefined) | undefined;
|
|
32
|
+
constructor(capacity?: number);
|
|
33
|
+
log(level: LogLevel, message: string, extra?: Partial<Omit<LogEntry, 'seq' | 'level' | 'message' | 'time'>>): LogEntry;
|
|
34
|
+
debug(message: string, data?: unknown): LogEntry;
|
|
35
|
+
info(message: string, data?: unknown): LogEntry;
|
|
36
|
+
warn(message: string, data?: unknown): LogEntry;
|
|
37
|
+
/** Logs any thrown value; ShardErrors keep their code, path, and hint. */
|
|
38
|
+
error(error: unknown): LogEntry;
|
|
39
|
+
/** The last `count` entries at or above `level`, oldest first. */
|
|
40
|
+
tail(count?: number, level?: LogLevel): LogEntry[];
|
|
41
|
+
errors(count?: number): LogEntry[];
|
|
42
|
+
subscribe(listener: (entry: LogEntry) => void): () => void;
|
|
43
|
+
}
|
|
44
|
+
export declare const LogResource: import("@aethervtt/shard-core").ResourceDef<Log>;
|
package/dist/log.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { defineResource, ShardError } from "@aethervtt/shard-core";
|
|
2
|
+
const LEVELS = { debug: 0, info: 1, warn: 2, error: 3 };
|
|
3
|
+
class Log {
|
|
4
|
+
capacity;
|
|
5
|
+
entries = [];
|
|
6
|
+
listeners = /* @__PURE__ */ new Set();
|
|
7
|
+
seq = 0;
|
|
8
|
+
/** App time source; set by the app. */
|
|
9
|
+
now = () => 0;
|
|
10
|
+
/**
|
|
11
|
+
* Adds fields to logged errors. Hosts that bundle project code set this to map stack frames back
|
|
12
|
+
* to project source (`source`).
|
|
13
|
+
*/
|
|
14
|
+
annotate = void 0;
|
|
15
|
+
constructor(capacity = 500) {
|
|
16
|
+
this.capacity = capacity;
|
|
17
|
+
}
|
|
18
|
+
log(level, message, extra = {}) {
|
|
19
|
+
const entry = { seq: this.seq++, level, message, time: this.now(), ...extra };
|
|
20
|
+
this.entries.push(entry);
|
|
21
|
+
if (this.entries.length > this.capacity) this.entries.shift();
|
|
22
|
+
for (const listener of this.listeners) listener(entry);
|
|
23
|
+
return entry;
|
|
24
|
+
}
|
|
25
|
+
debug(message, data) {
|
|
26
|
+
return this.log("debug", message, data === void 0 ? {} : { data });
|
|
27
|
+
}
|
|
28
|
+
info(message, data) {
|
|
29
|
+
return this.log("info", message, data === void 0 ? {} : { data });
|
|
30
|
+
}
|
|
31
|
+
warn(message, data) {
|
|
32
|
+
return this.log("warn", message, data === void 0 ? {} : { data });
|
|
33
|
+
}
|
|
34
|
+
/** Logs any thrown value; ShardErrors keep their code, path, and hint. */
|
|
35
|
+
error(error) {
|
|
36
|
+
const extra = this.annotate?.(error) ?? {};
|
|
37
|
+
const own = error?.source;
|
|
38
|
+
if (typeof own === "string" && !extra.source) extra.source = own;
|
|
39
|
+
if (error instanceof ShardError) {
|
|
40
|
+
return this.log("error", error.message, {
|
|
41
|
+
code: error.code,
|
|
42
|
+
path: error.path,
|
|
43
|
+
hint: error.hint,
|
|
44
|
+
...extra
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
return this.log("error", error instanceof Error ? error.message : String(error), extra);
|
|
48
|
+
}
|
|
49
|
+
/** The last `count` entries at or above `level`, oldest first. */
|
|
50
|
+
tail(count = 50, level = "debug") {
|
|
51
|
+
const min = LEVELS[level];
|
|
52
|
+
return this.entries.filter((e) => LEVELS[e.level] >= min).slice(-count);
|
|
53
|
+
}
|
|
54
|
+
errors(count = 20) {
|
|
55
|
+
return this.tail(count, "error");
|
|
56
|
+
}
|
|
57
|
+
subscribe(listener) {
|
|
58
|
+
this.listeners.add(listener);
|
|
59
|
+
return () => this.listeners.delete(listener);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
const LogResource = defineResource("core/Log", {
|
|
63
|
+
description: "Structured log ring buffer: system, GPU, and shader errors land here."
|
|
64
|
+
});
|
|
65
|
+
export {
|
|
66
|
+
Log,
|
|
67
|
+
LogResource
|
|
68
|
+
};
|
package/dist/plugin.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { App } from './app.js';
|
|
2
|
+
export interface Plugin {
|
|
3
|
+
/** Unique name, used for dependencies (`physics3d`, `core/time`). */
|
|
4
|
+
readonly name: string;
|
|
5
|
+
readonly dependencies?: readonly string[];
|
|
6
|
+
/**
|
|
7
|
+
* The modules (`import * as components from './components'`) or values whose definitions and
|
|
8
|
+
* registrations this plugin needs at runtime. Referencing them keeps them in a tree-shaken bundle
|
|
9
|
+
* (every package is `sideEffects: false`), so a scene that names a component finds it whenever the
|
|
10
|
+
* plugin that provides it is installed.
|
|
11
|
+
*/
|
|
12
|
+
readonly provides?: readonly object[];
|
|
13
|
+
/** Registers resources, systems, and other plugins. Runs in dependency order. */
|
|
14
|
+
build(app: App): void;
|
|
15
|
+
/** Optional async setup (e.g. loading WASM), awaited before Startup. */
|
|
16
|
+
ready?(app: App): Promise<void> | void;
|
|
17
|
+
}
|
|
18
|
+
export declare function definePlugin(plugin: Plugin): Plugin;
|
package/dist/plugin.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { App } from './app.js';
|
|
2
|
+
/** Drives an initialized app. The app doesn't know which runner it has. */
|
|
3
|
+
export type Runner = (app: App) => void | Promise<void>;
|
|
4
|
+
export interface HeadlessOptions {
|
|
5
|
+
frames: number;
|
|
6
|
+
/** Seconds per frame. Default 1/60. A fixed delta makes runs reproducible. */
|
|
7
|
+
delta?: number;
|
|
8
|
+
}
|
|
9
|
+
/** Runs N frames as fast as possible with a fixed delta. For the CLI and tests. */
|
|
10
|
+
export declare function headlessRunner(options: HeadlessOptions): Runner;
|
|
11
|
+
export interface AnimationFrameOptions {
|
|
12
|
+
/** Longest delta passed to update, in seconds, e.g. after the tab was hidden. Default 0.25. */
|
|
13
|
+
maxDelta?: number;
|
|
14
|
+
/** Stops the loop when aborted. */
|
|
15
|
+
signal?: AbortSignal;
|
|
16
|
+
/**
|
|
17
|
+
* Time a few idle frames before the first update to measure the display's refresh rate into
|
|
18
|
+
* DisplayRate (about 12 refreshes: 0.1 s at 120 Hz). Default true.
|
|
19
|
+
*/
|
|
20
|
+
measureRefresh?: boolean;
|
|
21
|
+
}
|
|
22
|
+
/** Runs a frame per `requestAnimationFrame`. For the browser and Studio. */
|
|
23
|
+
export declare function animationFrameRunner(options?: AnimationFrameOptions): Runner;
|
package/dist/runners.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { AppControlResource } from "./control.js";
|
|
2
|
+
import { RefreshMeter, rateFromIntervals } from "./display.js";
|
|
3
|
+
import { LogResource } from "./log.js";
|
|
4
|
+
import { DisplayRate } from "./time.js";
|
|
5
|
+
function headlessRunner(options) {
|
|
6
|
+
const delta = options.delta ?? 1 / 60;
|
|
7
|
+
return (app) => {
|
|
8
|
+
for (let i = 0; i < options.frames; i++) app.update(delta);
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
const PROBE_INTERVALS = 12;
|
|
12
|
+
function probeRefresh() {
|
|
13
|
+
return new Promise((resolve) => {
|
|
14
|
+
if (document.hidden) return resolve(0);
|
|
15
|
+
const intervals = new Float64Array(PROBE_INTERVALS);
|
|
16
|
+
let last = -1;
|
|
17
|
+
let n = 0;
|
|
18
|
+
const tick = (t) => {
|
|
19
|
+
if (document.hidden) return resolve(0);
|
|
20
|
+
if (last >= 0) intervals[n++] = t - last;
|
|
21
|
+
last = t;
|
|
22
|
+
if (n < PROBE_INTERVALS) requestAnimationFrame(tick);
|
|
23
|
+
else resolve(rateFromIntervals(intervals));
|
|
24
|
+
};
|
|
25
|
+
requestAnimationFrame(tick);
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
function animationFrameRunner(options = {}) {
|
|
29
|
+
const maxDelta = options.maxDelta ?? 0.25;
|
|
30
|
+
return async (app) => {
|
|
31
|
+
const display = app.world.tryResource(DisplayRate);
|
|
32
|
+
if (display && options.measureRefresh !== false) {
|
|
33
|
+
const hz = await probeRefresh();
|
|
34
|
+
if (hz > 0) {
|
|
35
|
+
display.hz = hz;
|
|
36
|
+
display.periodMs = 1e3 / hz;
|
|
37
|
+
display.source = "measured";
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
const meter = new RefreshMeter(display?.periodMs ?? 1e3 / 60);
|
|
41
|
+
return new Promise((resolve) => {
|
|
42
|
+
let last;
|
|
43
|
+
let handle = 0;
|
|
44
|
+
const onVisibility = () => {
|
|
45
|
+
if (document.hidden) last = void 0;
|
|
46
|
+
};
|
|
47
|
+
const frame = (timestamp) => {
|
|
48
|
+
const delta = last === void 0 ? 0 : Math.min((timestamp - last) / 1e3, maxDelta);
|
|
49
|
+
if (display && last !== void 0) {
|
|
50
|
+
const hz = meter.sample(timestamp - last);
|
|
51
|
+
if (hz > 0) {
|
|
52
|
+
display.hz = hz;
|
|
53
|
+
display.periodMs = 1e3 / hz;
|
|
54
|
+
display.source = "measured";
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
last = timestamp;
|
|
58
|
+
const control = app.world.resource(AppControlResource);
|
|
59
|
+
try {
|
|
60
|
+
if (!control.paused) app.update(delta);
|
|
61
|
+
else if (control.pendingSteps > 0) {
|
|
62
|
+
app.update(1 / app.fixedHz);
|
|
63
|
+
control.stepped();
|
|
64
|
+
}
|
|
65
|
+
} catch (err) {
|
|
66
|
+
app.world.resource(LogResource).error(err);
|
|
67
|
+
control.paused = true;
|
|
68
|
+
control.pausedByError = true;
|
|
69
|
+
control.abort(err);
|
|
70
|
+
console.error(err);
|
|
71
|
+
}
|
|
72
|
+
handle = requestAnimationFrame(frame);
|
|
73
|
+
};
|
|
74
|
+
document.addEventListener("visibilitychange", onVisibility);
|
|
75
|
+
options.signal?.addEventListener("abort", () => {
|
|
76
|
+
cancelAnimationFrame(handle);
|
|
77
|
+
document.removeEventListener("visibilitychange", onVisibility);
|
|
78
|
+
resolve();
|
|
79
|
+
});
|
|
80
|
+
handle = requestAnimationFrame(frame);
|
|
81
|
+
});
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
export {
|
|
85
|
+
animationFrameRunner,
|
|
86
|
+
headlessRunner
|
|
87
|
+
};
|
package/dist/state.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { type Condition, type ResourceDef, type ScheduleLabel, type World } from '@aethervtt/shard-core';
|
|
2
|
+
export interface StateValue<T extends string> {
|
|
3
|
+
current: T;
|
|
4
|
+
/** Requested transition, applied at the start of the next frame. */
|
|
5
|
+
next: T | undefined;
|
|
6
|
+
}
|
|
7
|
+
export interface StateDef<T extends string> {
|
|
8
|
+
readonly kind: 'state';
|
|
9
|
+
readonly name: string;
|
|
10
|
+
readonly values: readonly T[];
|
|
11
|
+
readonly resource: ResourceDef<StateValue<T>>;
|
|
12
|
+
readonly enter: ReadonlyMap<T, ScheduleLabel>;
|
|
13
|
+
readonly exit: ReadonlyMap<T, ScheduleLabel>;
|
|
14
|
+
}
|
|
15
|
+
export declare function defineState<const T extends string>(name: string, values: readonly T[]): StateDef<T>;
|
|
16
|
+
export declare function OnEnter<T extends string>(state: StateDef<T>, value: NoInfer<T>): ScheduleLabel;
|
|
17
|
+
export declare function OnExit<T extends string>(state: StateDef<T>, value: NoInfer<T>): ScheduleLabel;
|
|
18
|
+
export declare function inState<T extends string>(state: StateDef<T>, value: NoInfer<T>): Condition;
|
|
19
|
+
/** Requests a transition. It applies at the start of the next frame (OnExit, then OnEnter). */
|
|
20
|
+
export declare function setState<T extends string>(world: World, state: StateDef<T>, value: NoInfer<T>): void;
|
package/dist/state.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import {
|
|
2
|
+
condition,
|
|
3
|
+
defineResource,
|
|
4
|
+
defineSchedule,
|
|
5
|
+
ShardError
|
|
6
|
+
} from "@aethervtt/shard-core";
|
|
7
|
+
function defineState(name, values) {
|
|
8
|
+
if (values.length === 0) {
|
|
9
|
+
throw new ShardError("app/invalid-state", `State "${name}" needs at least one value`);
|
|
10
|
+
}
|
|
11
|
+
return {
|
|
12
|
+
kind: "state",
|
|
13
|
+
name,
|
|
14
|
+
values,
|
|
15
|
+
resource: defineResource(name, { description: `Current ${name} state` }),
|
|
16
|
+
enter: new Map(values.map((v) => [v, defineSchedule(`${name}:enter:${v}`)])),
|
|
17
|
+
exit: new Map(values.map((v) => [v, defineSchedule(`${name}:exit:${v}`)]))
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
function OnEnter(state, value) {
|
|
21
|
+
return state.enter.get(value) ?? invalid(state, value);
|
|
22
|
+
}
|
|
23
|
+
function OnExit(state, value) {
|
|
24
|
+
return state.exit.get(value) ?? invalid(state, value);
|
|
25
|
+
}
|
|
26
|
+
function inState(state, value) {
|
|
27
|
+
if (!state.values.includes(value)) invalid(state, value);
|
|
28
|
+
return condition(`inState(${state.name}=${value})`, (world) => {
|
|
29
|
+
return world.tryResource(state.resource)?.current === value;
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
function setState(world, state, value) {
|
|
33
|
+
if (!state.values.includes(value)) invalid(state, value);
|
|
34
|
+
world.resource(state.resource).next = value;
|
|
35
|
+
}
|
|
36
|
+
function invalid(state, value) {
|
|
37
|
+
throw new ShardError("app/invalid-state", `"${value}" is not a value of state "${state.name}"`, {
|
|
38
|
+
hint: `Use one of: ${state.values.join(", ")}.`
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
export {
|
|
42
|
+
OnEnter,
|
|
43
|
+
OnExit,
|
|
44
|
+
defineState,
|
|
45
|
+
inState,
|
|
46
|
+
setState
|
|
47
|
+
};
|
package/dist/time.d.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { type Rng } from '@aethervtt/shard-core';
|
|
2
|
+
export interface TimeData {
|
|
3
|
+
/** Seconds since the previous frame. */
|
|
4
|
+
delta: number;
|
|
5
|
+
/** Seconds since the app started. */
|
|
6
|
+
elapsed: number;
|
|
7
|
+
/** Frames completed, starting at 0 during the first frame. */
|
|
8
|
+
frame: number;
|
|
9
|
+
}
|
|
10
|
+
export interface FixedTimeData {
|
|
11
|
+
/** The fixed step in seconds (1 / hz). */
|
|
12
|
+
readonly step: number;
|
|
13
|
+
/** Simulated seconds advanced by FixedUpdate. */
|
|
14
|
+
elapsed: number;
|
|
15
|
+
/** Leftover accumulated time as a fraction of a step, for interpolating rendering. */
|
|
16
|
+
alpha: number;
|
|
17
|
+
/** FixedUpdate runs in the current frame. */
|
|
18
|
+
steps: number;
|
|
19
|
+
}
|
|
20
|
+
export declare const Time: import("@aethervtt/shard-core").ResourceDef<TimeData>;
|
|
21
|
+
export declare const FixedTime: import("@aethervtt/shard-core").ResourceDef<FixedTimeData>;
|
|
22
|
+
export interface DisplayRateData {
|
|
23
|
+
/** Refresh rate of the display the app runs on, in Hz. */
|
|
24
|
+
hz: number;
|
|
25
|
+
/** One refresh, in ms. */
|
|
26
|
+
periodMs: number;
|
|
27
|
+
/**
|
|
28
|
+
* measured: the frame runner timed it (at startup, raised later if frames come faster).
|
|
29
|
+
* assumed: 60 Hz, for headless runs and before a measurement.
|
|
30
|
+
*/
|
|
31
|
+
source: 'measured' | 'assumed';
|
|
32
|
+
}
|
|
33
|
+
export declare const DisplayRate: import("@aethervtt/shard-core").ResourceDef<DisplayRateData>;
|
|
34
|
+
export declare const GlobalRng: import("@aethervtt/shard-core").ResourceDef<Rng>;
|
package/dist/time.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { defineResource } from "@aethervtt/shard-core";
|
|
2
|
+
const Time = defineResource("core/Time", {
|
|
3
|
+
description: "Frame time. Read delta in Update systems."
|
|
4
|
+
});
|
|
5
|
+
const FixedTime = defineResource("core/FixedTime", {
|
|
6
|
+
description: "Fixed-step time. Read step in FixedUpdate systems; alpha for interpolation."
|
|
7
|
+
});
|
|
8
|
+
const DisplayRate = defineResource("core/DisplayRate", {
|
|
9
|
+
description: "The display's refresh rate, measured by the frame runner (60 Hz assumed headless). Frame budgets that should match the screen read periodMs."
|
|
10
|
+
});
|
|
11
|
+
const GlobalRng = defineResource("core/GlobalRng", {
|
|
12
|
+
description: "The app root random stream, seeded from AppOptions.seed. Fork it per system."
|
|
13
|
+
});
|
|
14
|
+
export {
|
|
15
|
+
DisplayRate,
|
|
16
|
+
FixedTime,
|
|
17
|
+
GlobalRng,
|
|
18
|
+
Time
|
|
19
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@aethervtt/shard-runtime",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"sideEffects": false,
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"default": "./dist/index.js"
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"dist",
|
|
15
|
+
"LICENSE",
|
|
16
|
+
"README.md"
|
|
17
|
+
],
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"@aethervtt/shard-core": "0.0.1"
|
|
20
|
+
},
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "git+https://github.com/aethervtt/shard.git",
|
|
24
|
+
"directory": "packages/runtime"
|
|
25
|
+
},
|
|
26
|
+
"publishConfig": {
|
|
27
|
+
"access": "public",
|
|
28
|
+
"provenance": true
|
|
29
|
+
},
|
|
30
|
+
"shard": {
|
|
31
|
+
"buildSha": "d6c5c6aef4675575c878b02cf2c8f738b4190229"
|
|
32
|
+
}
|
|
33
|
+
}
|