@waica/engine 0.19.0 → 0.19.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.
@@ -11,11 +11,13 @@ export function sheetFrameCount(sheet) {
11
11
  */
12
12
  export function locateFrame(sheets, frame) {
13
13
  let rest = Math.max(0, Math.floor(frame));
14
- for (let i = 0; i < sheets.length; i++) {
15
- const count = sheetFrameCount(sheets[i]);
14
+ let i = 0;
15
+ for (const sheet of sheets) {
16
+ const count = sheetFrameCount(sheet);
16
17
  if (rest < count || i === sheets.length - 1)
17
18
  return { sheet: i, frame: Math.min(rest, count - 1) };
18
19
  rest -= count;
20
+ i++;
19
21
  }
20
22
  return { sheet: 0, frame: 0 };
21
23
  }
@@ -28,7 +30,11 @@ const positive = (value) => typeof value === 'number' && Number.isFinite(value)
28
30
  export function sheetCell(imageWidth, imageHeight, cols, rows, frame, params = {}) {
29
31
  if (params.cells?.length) {
30
32
  const index = Math.min(Math.max(0, Math.floor(frame)), params.cells.length - 1);
31
- return params.cells[index];
33
+ const cell = params.cells[index];
34
+ // Invariant: the index is clamped into a non-empty cells array.
35
+ if (!cell)
36
+ throw new Error(`sheetCell: missing cell at clamped index ${index}`);
37
+ return cell;
32
38
  }
33
39
  const c = Math.max(1, Math.floor(cols));
34
40
  const r = Math.max(1, Math.floor(rows));
@@ -1,5 +1,6 @@
1
1
  import * as THREE from 'three';
2
2
  import { ThreeTextureBackend } from './texture-backend.js';
3
+ import { reportRejection } from '../report-rejection.js';
3
4
  /**
4
5
  * Not `base.clone()`: `Texture.copy` sets `needsUpdate`, which bumps the
5
6
  * shared Source's version — a GPU re-upload of the same image for every
@@ -52,10 +53,10 @@ export class AssetLoader {
52
53
  if (entry.outcome === null) {
53
54
  // Chained here, before the consumer chains its own continuation on
54
55
  // `settled`, so the clone is uploadable by the time that one runs.
55
- void entry.settled.then((outcome) => {
56
+ reportRejection(entry.settled.then((outcome) => {
56
57
  if (outcome === 'loaded')
57
58
  texture.version = entry.base.version;
58
- });
59
+ }), 'texture upload');
59
60
  }
60
61
  return { texture, settled: entry.settled };
61
62
  }
@@ -1,3 +1,4 @@
1
+ import { reportRejection } from '../report-rejection.js';
1
2
  /**
2
3
  * The real WebAudio implementation (ADR 0013's default). Builds a small
3
4
  * mixing graph — one GainNode per sound feeding a StereoPannerNode, feeding
@@ -90,13 +91,19 @@ export class WebAudioBackend {
90
91
  this.masterGain.gain.value = volume;
91
92
  }
92
93
  suspend() {
93
- void this.context?.suspend();
94
+ const suspending = this.context?.suspend();
95
+ if (suspending)
96
+ reportRejection(suspending, 'audio suspend');
94
97
  }
95
98
  resume() {
96
- void this.ensureContext()?.resume();
99
+ const resuming = this.ensureContext()?.resume();
100
+ if (resuming)
101
+ reportRejection(resuming, 'audio resume');
97
102
  }
98
103
  close() {
99
- void this.context?.close();
104
+ const closing = this.context?.close();
105
+ if (closing)
106
+ reportRejection(closing, 'audio close');
100
107
  this.context = null;
101
108
  this.masterGain = null;
102
109
  this.channelGains.clear();
@@ -120,11 +127,13 @@ export class WebAudioBackend {
120
127
  const existing = this.channelGains.get(channel);
121
128
  if (existing)
122
129
  return existing;
123
- // Safe: only called once ensureContext() has already produced a context.
124
- const context = this.context;
130
+ const { context, masterGain } = this;
131
+ // Invariant: only called once ensureContext() has produced the context and master gain.
132
+ if (!context || !masterGain)
133
+ throw new Error('ensureChannelGain called before ensureContext');
125
134
  const gain = context.createGain();
126
135
  gain.gain.value = this.effectiveChannelGain(channel);
127
- gain.connect(this.masterGain);
136
+ gain.connect(masterGain);
128
137
  this.channelGains.set(channel, gain);
129
138
  return gain;
130
139
  }
@@ -5,11 +5,11 @@ function isSerializable(value) {
5
5
  const kind = typeof value;
6
6
  if (kind === 'string' || kind === 'number' || kind === 'boolean')
7
7
  return true;
8
- if (kind !== 'object')
8
+ if (typeof value !== 'object')
9
9
  return false;
10
10
  if (Array.isArray(value))
11
11
  return true;
12
- const proto = Object.getPrototypeOf(value);
12
+ const proto = Reflect.getPrototypeOf(value);
13
13
  return proto === Object.prototype || proto === null;
14
14
  }
15
15
  /**
@@ -32,7 +32,7 @@ export function authoringDefaults(Class, onError) {
32
32
  }
33
33
  const transient = new Set(Class.transient ?? []);
34
34
  const keys = new Set(Object.keys(instance));
35
- for (let proto = Object.getPrototypeOf(instance); proto && proto !== Object.prototype; proto = Object.getPrototypeOf(proto)) {
35
+ for (let proto = Reflect.getPrototypeOf(instance); proto && proto !== Object.prototype; proto = Reflect.getPrototypeOf(proto)) {
36
36
  for (const [key, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(proto))) {
37
37
  if (descriptor.set)
38
38
  keys.add(key);
@@ -42,7 +42,7 @@ export function authoringDefaults(Class, onError) {
42
42
  for (const key of keys) {
43
43
  if (EXCLUDED_KEYS.has(key) || key.startsWith('_') || transient.has(key))
44
44
  continue;
45
- const value = instance[key];
45
+ const value = Reflect.get(instance, key);
46
46
  if (value === undefined || !isSerializable(value))
47
47
  continue;
48
48
  result[key] = value;
@@ -1,6 +1,9 @@
1
1
  import { SIMULATION_STEP, SIMULATION_TIME_EPSILON } from './fixed-step.js';
2
2
  import { resolveEasing } from './game-time.js';
3
- const COLORS = { black: '#000000', white: '#ffffff' };
3
+ const COLORS = new Map([
4
+ ['black', '#000000'],
5
+ ['white', '#ffffff'],
6
+ ]);
4
7
  /** Above the UI overlay (`ui.ts`, z-index 9000): a Fade covers the HUD too. */
5
8
  const FADE_Z = 9001;
6
9
  const FLASH_Z = 9002;
@@ -9,8 +12,9 @@ const LAYOUT = Symbol('waica.cameraEffects.layout');
9
12
  function normalizeColor(value) {
10
13
  if (typeof value !== 'string')
11
14
  return null;
12
- if (Object.prototype.hasOwnProperty.call(COLORS, value))
13
- return COLORS[value];
15
+ const named = COLORS.get(value);
16
+ if (named !== undefined)
17
+ return named;
14
18
  return /^#[0-9a-fA-F]{6}$/.test(value) ? value.toLowerCase() : null;
15
19
  }
16
20
  function validSeconds(value) {
@@ -74,34 +74,57 @@ export function collisionOverlap(a, b) {
74
74
  }
75
75
  const verticesA = collisionVertices(a);
76
76
  const verticesB = collisionVertices(b);
77
- for (let ai = 0; ai < verticesA.length; ai++) {
78
- const a1 = verticesA[ai];
79
- const a2 = verticesA[(ai + 1) % verticesA.length];
80
- for (let bi = 0; bi < verticesB.length; bi++) {
81
- const b1 = verticesB[bi];
82
- const b2 = verticesB[(bi + 1) % verticesB.length];
83
- if (segmentsCross(a1, a2, b1, b2))
84
- return true;
85
- }
86
- }
77
+ if (polygonEdgesCross(verticesA, verticesB))
78
+ return true;
87
79
  return hasInteriorPoint(verticesA, verticesB) || hasInteriorPoint(verticesB, verticesA);
88
80
  }
89
- function hasInteriorPoint(points, polygon) {
90
- for (let index = 0; index < points.length; index++) {
91
- const point = points[index];
92
- if (pointInPolygon(point, polygon))
81
+ /** Whether any closed-polygon edge of `polygonA` properly crosses one of `polygonB`. */
82
+ function polygonEdgesCross(polygonA, polygonB) {
83
+ let a1 = polygonA.at(-1);
84
+ if (a1 === undefined)
85
+ return false;
86
+ for (const a2 of polygonA) {
87
+ if (edgeCrossesPolygon(a1, a2, polygonB))
93
88
  return true;
94
- const next = points[(index + 1) % points.length];
95
- if (pointInPolygon([(point[0] + next[0]) / 2, (point[1] + next[1]) / 2], polygon)) {
89
+ a1 = a2;
90
+ }
91
+ return false;
92
+ }
93
+ function edgeCrossesPolygon(a1, a2, polygon) {
94
+ let b1 = polygon.at(-1);
95
+ if (b1 === undefined)
96
+ return false;
97
+ for (const b2 of polygon) {
98
+ if (segmentsCross(a1, a2, b1, b2))
96
99
  return true;
97
- }
100
+ b1 = b2;
98
101
  }
102
+ return false;
103
+ }
104
+ function hasInteriorPoint(points, polygon) {
105
+ if (hasVertexOrMidpointInside(points, polygon))
106
+ return true;
99
107
  const center = [
100
108
  points.reduce((sum, [x]) => sum + x, 0) / points.length,
101
109
  points.reduce((sum, [, y]) => sum + y, 0) / points.length,
102
110
  ];
103
111
  return pointInPolygon(center, polygon);
104
112
  }
113
+ /** Whether any vertex of `points`, or the midpoint of any of its closed-polygon edges, lies inside `polygon`. */
114
+ function hasVertexOrMidpointInside(points, polygon) {
115
+ let previous = points.at(-1);
116
+ if (previous === undefined)
117
+ return false;
118
+ for (const point of points) {
119
+ if (pointInPolygon(point, polygon))
120
+ return true;
121
+ if (pointInPolygon([(previous[0] + point[0]) / 2, (previous[1] + point[1]) / 2], polygon)) {
122
+ return true;
123
+ }
124
+ previous = point;
125
+ }
126
+ return false;
127
+ }
105
128
  function segmentsCross(a1, a2, b1, b2) {
106
129
  const ab1 = cross(a1, a2, b1);
107
130
  const ab2 = cross(a1, a2, b2);
@@ -114,15 +137,17 @@ function cross(a, b, point) {
114
137
  }
115
138
  function pointInPolygon(point, polygon) {
116
139
  let inside = false;
117
- for (let index = 0, previous = polygon.length - 1; index < polygon.length; previous = index++) {
118
- const a = polygon[index];
119
- const b = polygon[previous];
140
+ let b = polygon.at(-1);
141
+ if (b === undefined)
142
+ return inside;
143
+ for (const a of polygon) {
120
144
  if (pointOnSegment(point, a, b))
121
145
  return false;
122
146
  const crosses = (a[1] > point[1]) !== (b[1] > point[1]) &&
123
147
  point[0] < ((b[0] - a[0]) * (point[1] - a[1])) / (b[1] - a[1]) + a[0];
124
148
  if (crosses)
125
149
  inside = !inside;
150
+ b = a;
126
151
  }
127
152
  return inside;
128
153
  }
@@ -1,7 +1,17 @@
1
- import { type ComponentClass } from './component.js';
1
+ import { Component, type ComponentClass } from './component.js';
2
2
  import type { SceneRegistry } from './scene.js';
3
3
  /** The namespace returned by importing one project code module. */
4
4
  export type ComponentModule = Readonly<Record<string, unknown>>;
5
+ /** Whether `value` is a class that extends Component (the abstract base itself is not). */
6
+ export declare function isComponentClass(value: unknown): value is ComponentClass;
7
+ /**
8
+ * The class a live component was constructed from. TypeScript types
9
+ * `constructor` as Function; this narrows it for real instead of asserting.
10
+ * It checks the shape (a constructor with a static componentName), not
11
+ * `instanceof Component`, so a project that resolves a second copy of
12
+ * @waica/engine keeps working. Internal: not re-exported from the package entry.
13
+ */
14
+ export declare function componentClassOf(component: Component): ComponentClass;
5
15
  /**
6
16
  * Finds exported Component subclasses in project modules. Classes are keyed by
7
17
  * their stable componentName, not by the export name, so minification and
@@ -1,4 +1,29 @@
1
1
  import { Component } from './component.js';
2
+ /** Whether `value` is a class that extends Component (the abstract base itself is not). */
3
+ export function isComponentClass(value) {
4
+ if (typeof value !== 'function')
5
+ return false;
6
+ const prototype = Reflect.get(value, 'prototype');
7
+ return prototype instanceof Component;
8
+ }
9
+ /**
10
+ * The class a live component was constructed from. TypeScript types
11
+ * `constructor` as Function; this narrows it for real instead of asserting.
12
+ * It checks the shape (a constructor with a static componentName), not
13
+ * `instanceof Component`, so a project that resolves a second copy of
14
+ * @waica/engine keeps working. Internal: not re-exported from the package entry.
15
+ */
16
+ export function componentClassOf(component) {
17
+ const Class = component.constructor;
18
+ if (!hasComponentName(Class)) {
19
+ throw new TypeError('a component was not constructed from a Component class');
20
+ }
21
+ return Class;
22
+ }
23
+ /** A constructor carrying the static componentName every Component class declares. */
24
+ function hasComponentName(value) {
25
+ return typeof value === 'function' && typeof Reflect.get(value, 'componentName') === 'string';
26
+ }
2
27
  /**
3
28
  * Finds exported Component subclasses in project modules. Classes are keyed by
4
29
  * their stable componentName, not by the export name, so minification and
@@ -8,11 +33,9 @@ export function collectModuleComponents(modules, warn = console.warn) {
8
33
  const components = {};
9
34
  for (const module of modules) {
10
35
  for (const value of Object.values(module)) {
11
- if (typeof value !== 'function')
36
+ if (!isComponentClass(value))
12
37
  continue;
13
38
  const Class = value;
14
- if (!(Class.prototype instanceof Component))
15
- continue;
16
39
  // Without its own componentName a class inherits the base's, so nothing
17
40
  // could reference it from scene JSON. Silently skipping it looks like
18
41
  // the editor lost the file: say so instead.
@@ -32,6 +32,8 @@ export interface InvalidComponentUpdateSchedule {
32
32
  }
33
33
  export type ComponentUpdateScheduleResult = ValidComponentUpdateSchedule | InvalidComponentUpdateSchedule;
34
34
  export type ComponentUpdateRegistry = Readonly<Record<string, ComponentClass | undefined>>;
35
+ /** Whether instances of `Class` run each frame (its prototype defines onUpdate). */
36
+ export declare function implementsOnUpdate(Class: ComponentClass | undefined): boolean;
35
37
  /**
36
38
  * Resolves one entity's deterministic component update schedule without
37
39
  * constructing components or mutating either input.
@@ -4,8 +4,26 @@ function codeUnitCompare(left, right) {
4
4
  function registeredClass(registry, componentName) {
5
5
  return Object.hasOwn(registry, componentName) ? registry[componentName] : undefined;
6
6
  }
7
- function updates(Class) {
8
- return typeof Class?.prototype.onUpdate === 'function';
7
+ /** Whether instances of `Class` run each frame (its prototype defines onUpdate). */
8
+ export function implementsOnUpdate(Class) {
9
+ if (!Class)
10
+ return false;
11
+ const prototype = Reflect.get(Class, 'prototype');
12
+ return typeof prototype === 'object' && prototype !== null && typeof Reflect.get(prototype, 'onUpdate') === 'function';
13
+ }
14
+ /** Reads a Tarjan bookkeeping entry that the traversal has already recorded. */
15
+ function recorded(map, node, what) {
16
+ const value = map.get(node);
17
+ if (value === undefined)
18
+ throw new Error(`Component update cycle search has no ${what} for "${node}"`);
19
+ return value;
20
+ }
21
+ /** The first member of a strongly connected group, which always has at least two members. */
22
+ function firstMember(group) {
23
+ const [first] = group;
24
+ if (first === undefined)
25
+ throw new Error('Component update cycle group is empty');
26
+ return first;
9
27
  }
10
28
  function updateCycles(nodes, outgoing) {
11
29
  let nextIndex = 0;
@@ -23,21 +41,22 @@ function updateCycles(nodes, outgoing) {
23
41
  for (const dependent of [...(outgoing.get(node) ?? [])].sort(codeUnitCompare)) {
24
42
  if (!indices.has(dependent)) {
25
43
  visit(dependent);
26
- lowLinks.set(node, Math.min(lowLinks.get(node), lowLinks.get(dependent)));
44
+ lowLinks.set(node, Math.min(recorded(lowLinks, node, 'low link'), recorded(lowLinks, dependent, 'low link')));
27
45
  }
28
46
  else if (onStack.has(dependent)) {
29
- lowLinks.set(node, Math.min(lowLinks.get(node), indices.get(dependent)));
47
+ lowLinks.set(node, Math.min(recorded(lowLinks, node, 'low link'), recorded(indices, dependent, 'index')));
30
48
  }
31
49
  }
32
50
  if (lowLinks.get(node) !== indices.get(node))
33
51
  return;
34
52
  const group = [];
35
- while (stack.length > 0) {
36
- const member = stack.pop();
53
+ let member = stack.pop();
54
+ while (member !== undefined) {
37
55
  onStack.delete(member);
38
56
  group.push(member);
39
57
  if (member === node)
40
58
  break;
59
+ member = stack.pop();
41
60
  }
42
61
  if (group.length > 1)
43
62
  cycles.push(group.sort(codeUnitCompare));
@@ -46,19 +65,9 @@ function updateCycles(nodes, outgoing) {
46
65
  if (!indices.has(node))
47
66
  visit(node);
48
67
  }
49
- return cycles.sort((left, right) => codeUnitCompare(left[0], right[0]));
68
+ return cycles.sort((left, right) => codeUnitCompare(firstMember(left), firstMember(right)));
50
69
  }
51
- /**
52
- * Resolves one entity's deterministic component update schedule without
53
- * constructing components or mutating either input.
54
- */
55
- export function resolveComponentUpdateSchedule(componentNames, registry) {
56
- const present = new Set(componentNames);
57
- const nodes = [...present]
58
- .filter((name) => updates(registeredClass(registry, name)))
59
- .sort(codeUnitCompare);
60
- const outgoing = new Map(nodes.map((name) => [name, new Set()]));
61
- const indegree = new Map(nodes.map((name) => [name, 0]));
70
+ function duplicateComponentIssues(componentNames) {
62
71
  const issues = [];
63
72
  const counts = new Map();
64
73
  for (const name of componentNames)
@@ -74,9 +83,13 @@ export function resolveComponentUpdateSchedule(componentNames, registry) {
74
83
  cause: `Component "${componentName}" appears ${count} times on the same entity; component identity must be unique.`,
75
84
  });
76
85
  }
86
+ return issues;
87
+ }
88
+ function passiveDeclarerIssues({ present, registry }) {
89
+ const issues = [];
77
90
  for (const declarer of [...present].sort(codeUnitCompare)) {
78
91
  const Class = registeredClass(registry, declarer);
79
- if (Class?.updateAfter !== undefined && !updates(Class)) {
92
+ if (Class?.updateAfter !== undefined && !implementsOnUpdate(Class)) {
80
93
  issues.push({
81
94
  code: 'invalid-update-constraint',
82
95
  reason: 'passive-declarer',
@@ -86,65 +99,75 @@ export function resolveComponentUpdateSchedule(componentNames, registry) {
86
99
  });
87
100
  }
88
101
  }
89
- for (const declarer of nodes) {
90
- const Class = registeredClass(registry, declarer);
102
+ return issues;
103
+ }
104
+ /** Adds every valid updateAfter edge to the graph and returns the constraints that are not. */
105
+ function linkUpdateConstraints(graph, scope) {
106
+ const issues = [];
107
+ for (const declarer of graph.nodes) {
108
+ const Class = registeredClass(scope.registry, declarer);
91
109
  for (const target of new Set(Class?.updateAfter ?? [])) {
92
- if (!present.has(target)) {
93
- if (!registeredClass(registry, target)) {
94
- issues.push({
95
- code: 'invalid-update-constraint',
96
- reason: 'unknown-target',
97
- declarer,
98
- target,
99
- componentNames: [declarer, target],
100
- cause: `Component "${declarer}" declares updateAfter target "${target}", which is neither present nor registered.`,
101
- });
102
- }
110
+ const verdict = constraintVerdict({ declarer, target }, graph, scope);
111
+ if (verdict === 'ignore')
103
112
  continue;
104
- }
105
- if (target === declarer) {
106
- issues.push({
107
- code: 'invalid-update-constraint',
108
- reason: 'self-edge',
109
- declarer,
110
- target,
111
- componentNames: [declarer],
112
- cause: `Component "${declarer}" cannot declare itself in updateAfter.`,
113
- });
114
- continue;
115
- }
116
- if (!outgoing.has(target)) {
117
- issues.push({
118
- code: 'invalid-update-constraint',
119
- reason: 'passive-target',
120
- declarer,
121
- target,
122
- componentNames: [declarer, target],
123
- cause: `Component "${declarer}" declares updateAfter target "${target}", but "${target}" does not implement onUpdate.`,
124
- });
113
+ if (verdict !== 'link') {
114
+ issues.push(verdict);
125
115
  continue;
126
116
  }
127
- const readers = outgoing.get(target);
128
- if (readers.has(declarer))
117
+ const readers = graph.outgoing.get(target);
118
+ if (!readers || readers.has(declarer))
129
119
  continue;
130
120
  readers.add(declarer);
131
- indegree.set(declarer, (indegree.get(declarer) ?? 0) + 1);
121
+ graph.indegree.set(declarer, (graph.indegree.get(declarer) ?? 0) + 1);
132
122
  }
133
123
  }
134
- for (const componentNames of updateCycles(nodes, outgoing)) {
135
- issues.push({
136
- code: 'component-update-cycle',
137
- componentNames,
138
- cause: `Component update cycle among ${componentNames.map((name) => `"${name}"`).join(', ')}.`,
139
- });
124
+ return issues;
125
+ }
126
+ /**
127
+ * Whether `declarer` may update after `target`: an edge to link, a
128
+ * registered-but-absent target to ignore, or the invalid-constraint issue.
129
+ */
130
+ function constraintVerdict({ declarer, target }, graph, { present, registry }) {
131
+ if (!present.has(target)) {
132
+ if (registeredClass(registry, target))
133
+ return 'ignore';
134
+ return {
135
+ code: 'invalid-update-constraint',
136
+ reason: 'unknown-target',
137
+ declarer,
138
+ target,
139
+ componentNames: [declarer, target],
140
+ cause: `Component "${declarer}" declares updateAfter target "${target}", which is neither present nor registered.`,
141
+ };
140
142
  }
141
- if (issues.length > 0)
142
- return { ok: false, issues };
143
+ if (target === declarer) {
144
+ return {
145
+ code: 'invalid-update-constraint',
146
+ reason: 'self-edge',
147
+ declarer,
148
+ target,
149
+ componentNames: [declarer],
150
+ cause: `Component "${declarer}" cannot declare itself in updateAfter.`,
151
+ };
152
+ }
153
+ if (graph.outgoing.has(target))
154
+ return 'link';
155
+ return {
156
+ code: 'invalid-update-constraint',
157
+ reason: 'passive-target',
158
+ declarer,
159
+ target,
160
+ componentNames: [declarer, target],
161
+ cause: `Component "${declarer}" declares updateAfter target "${target}", but "${target}" does not implement onUpdate.`,
162
+ };
163
+ }
164
+ /** Kahn's order over the graph, always taking the code-unit-smallest ready component first. */
165
+ function updateOrder({ nodes, outgoing, indegree }) {
143
166
  const ready = nodes.filter((name) => indegree.get(name) === 0);
144
167
  const order = [];
145
- while (ready.length > 0) {
146
- ready.sort(codeUnitCompare);
147
- const next = ready.shift();
168
+ ready.sort(codeUnitCompare);
169
+ let next = ready.shift();
170
+ while (next !== undefined) {
148
171
  order.push(next);
149
172
  for (const dependent of [...(outgoing.get(next) ?? [])].sort(codeUnitCompare)) {
150
173
  const remaining = (indegree.get(dependent) ?? 0) - 1;
@@ -152,6 +175,39 @@ export function resolveComponentUpdateSchedule(componentNames, registry) {
152
175
  if (remaining === 0)
153
176
  ready.push(dependent);
154
177
  }
178
+ ready.sort(codeUnitCompare);
179
+ next = ready.shift();
155
180
  }
156
- return { ok: true, order, issues: [] };
181
+ return order;
182
+ }
183
+ /**
184
+ * Resolves one entity's deterministic component update schedule without
185
+ * constructing components or mutating either input.
186
+ */
187
+ export function resolveComponentUpdateSchedule(componentNames, registry) {
188
+ const present = new Set(componentNames);
189
+ const scope = { present, registry };
190
+ const nodes = [...present]
191
+ .filter((name) => implementsOnUpdate(registeredClass(registry, name)))
192
+ .sort(codeUnitCompare);
193
+ const graph = {
194
+ nodes,
195
+ outgoing: new Map(nodes.map((name) => [name, new Set()])),
196
+ indegree: new Map(nodes.map((name) => [name, 0])),
197
+ };
198
+ const issues = [
199
+ ...duplicateComponentIssues(componentNames),
200
+ ...passiveDeclarerIssues(scope),
201
+ ...linkUpdateConstraints(graph, scope),
202
+ ];
203
+ for (const cycle of updateCycles(nodes, graph.outgoing)) {
204
+ issues.push({
205
+ code: 'component-update-cycle',
206
+ componentNames: cycle,
207
+ cause: `Component update cycle among ${cycle.map((name) => `"${name}"`).join(', ')}.`,
208
+ });
209
+ }
210
+ if (issues.length > 0)
211
+ return { ok: false, issues };
212
+ return { ok: true, order: updateOrder(graph), issues: [] };
157
213
  }
@@ -3,6 +3,7 @@ import { Component } from '../component.js';
3
3
  import { ClipPlayer } from '../animation/clip-player.js';
4
4
  import { locateFrame, sheetCell } from '../animation/sheet.js';
5
5
  import { spritePlacement } from '../sprite-placement.js';
6
+ import { reportRejection } from '../report-rejection.js';
6
7
  const clampAnchor = (value) => Math.min(1, Math.max(0, value));
7
8
  /**
8
9
  * Sprite animated from one or more spritesheets. The main sheet is the
@@ -215,13 +216,13 @@ export class AnimatedSprite extends Component {
215
216
  if (!url)
216
217
  return new THREE.Texture();
217
218
  const { texture, settled } = this.game.assets.texture(url);
218
- void settled.then((outcome) => {
219
+ reportRejection(settled.then((outcome) => {
219
220
  if (!this.texs.includes(texture))
220
221
  return;
221
222
  if (outcome === 'failed')
222
223
  this.failedSheets.add(texture);
223
224
  this.applyFrame();
224
- });
225
+ }), 'sheet texture settle');
225
226
  return texture;
226
227
  }
227
228
  showFrame(index) {
@@ -1,6 +1,7 @@
1
1
  import * as THREE from 'three';
2
2
  import { Component } from '../component.js';
3
3
  import { spritePlacement } from '../sprite-placement.js';
4
+ import { reportRejection } from '../report-rejection.js';
4
5
  const clampAnchor = (value) => Math.min(1, Math.max(0, value));
5
6
  /**
6
7
  * Textured or flat-color quad. In the unified pipeline, a 2D sprite is a
@@ -118,10 +119,10 @@ export class Sprite extends Component {
118
119
  }
119
120
  material.map = texture;
120
121
  material.color.set(0xffffff);
121
- void settled.then((outcome) => {
122
+ reportRejection(settled.then((outcome) => {
122
123
  if (outcome === 'failed')
123
124
  this.dropFailedTexture(texture);
124
- });
125
+ }), 'sprite texture settle');
125
126
  }
126
127
  this.mesh = new THREE.Mesh(this.createGeometry(), material);
127
128
  this.mesh.position.z = this.layer * 0.01;
@@ -115,5 +115,11 @@ export declare class Tilemap extends Component implements SolidSource {
115
115
  private dropFailedTexture;
116
116
  private makeMaterial;
117
117
  private rebuildGeometry;
118
+ /** The loaded sheet image's size, or one pixel per sheet cell until it arrives. */
119
+ private sheetSize;
120
+ /** The four corners of a cell's quad, projected for the Game, on this layer's depth. */
121
+ private pushTileQuad;
122
+ /** The UVs of a tile's sheet cell, in the quad's corner order. */
123
+ private tileUvs;
118
124
  private rebuildSolids;
119
125
  }