@auraindustry/aurajs 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.
@@ -0,0 +1,4314 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
3
+ import vm from 'node:vm';
4
+
5
+ import { bundleProject } from './bundler.mjs';
6
+
7
+ export class HeadlessTestError extends Error {
8
+ constructor(message, details = {}) {
9
+ super(message);
10
+ this.name = 'HeadlessTestError';
11
+ this.details = details;
12
+ }
13
+ }
14
+
15
+ export function parseTestArgs(args) {
16
+ const parsed = {
17
+ file: 'src/test.js',
18
+ width: 1280,
19
+ height: 720,
20
+ frames: 1,
21
+ };
22
+
23
+ let positionalSet = false;
24
+
25
+ for (let i = 0; i < args.length; i += 1) {
26
+ const token = args[i];
27
+ if (token === '--width' && args[i + 1]) {
28
+ parsed.width = parsePositiveInt(args[i + 1], '--width');
29
+ i += 1;
30
+ continue;
31
+ }
32
+ if (token === '--height' && args[i + 1]) {
33
+ parsed.height = parsePositiveInt(args[i + 1], '--height');
34
+ i += 1;
35
+ continue;
36
+ }
37
+ if (token === '--frames' && args[i + 1]) {
38
+ parsed.frames = parseNonNegativeInt(args[i + 1], '--frames');
39
+ i += 1;
40
+ continue;
41
+ }
42
+ if (token.startsWith('--')) {
43
+ throw new HeadlessTestError(`Unknown test option: ${token}`);
44
+ }
45
+ if (!positionalSet) {
46
+ parsed.file = token;
47
+ positionalSet = true;
48
+ continue;
49
+ }
50
+ throw new HeadlessTestError(`Unexpected argument: ${token}`);
51
+ }
52
+
53
+ return parsed;
54
+ }
55
+
56
+ export async function runHeadlessTest(options = {}) {
57
+ const projectRoot = resolve(options.projectRoot || process.cwd());
58
+ const entryFile = resolve(projectRoot, options.file || 'src/test.js');
59
+ const width = options.width ?? 1280;
60
+ const height = options.height ?? 720;
61
+ const frames = options.frames ?? 1;
62
+
63
+ if (!existsSync(entryFile)) {
64
+ throw new HeadlessTestError(`Test file not found: ${entryFile}`);
65
+ }
66
+
67
+ const bundle = bundleProject({
68
+ projectRoot,
69
+ mode: 'test',
70
+ entryFile,
71
+ outFile: resolve(projectRoot, '.aura/test/test.bundle.js'),
72
+ });
73
+
74
+ const testState = {
75
+ failures: [],
76
+ passes: 0,
77
+ drawCalls: 0,
78
+ audioCalls: 0,
79
+ logs: [],
80
+ };
81
+
82
+ const aura = createHeadlessAura({ width, height, testState });
83
+ const context = vm.createContext(createRuntimeContext(aura, testState));
84
+
85
+ const source = readFileSync(bundle.outFile, 'utf8');
86
+ const script = new vm.Script(source, {
87
+ filename: bundle.outFile,
88
+ displayErrors: true,
89
+ });
90
+
91
+ try {
92
+ script.runInContext(context, { timeout: 5000 });
93
+ await executeLifecycle(aura, frames);
94
+ } catch (error) {
95
+ throw new HeadlessTestError(`Headless test execution failed: ${error.message}`, {
96
+ stack: error.stack,
97
+ });
98
+ }
99
+
100
+ if (testState.failures.length > 0) {
101
+ throw new HeadlessTestError(`Headless test failed with ${testState.failures.length} assertion error(s).`, {
102
+ failures: [...testState.failures],
103
+ });
104
+ }
105
+
106
+ return {
107
+ ok: true,
108
+ entryFile,
109
+ bundle,
110
+ frames,
111
+ width,
112
+ height,
113
+ passes: testState.passes,
114
+ drawCalls: testState.drawCalls,
115
+ audioCalls: testState.audioCalls,
116
+ };
117
+ }
118
+
119
+ function createRuntimeContext(aura, testState) {
120
+ const consoleShim = {
121
+ log: (...args) => {
122
+ testState.logs.push(['log', ...args]);
123
+ console.log(...args);
124
+ },
125
+ warn: (...args) => {
126
+ testState.logs.push(['warn', ...args]);
127
+ console.warn(...args);
128
+ },
129
+ error: (...args) => {
130
+ testState.logs.push(['error', ...args]);
131
+ console.error(...args);
132
+ },
133
+ };
134
+
135
+ return {
136
+ aura,
137
+ console: consoleShim,
138
+ Math,
139
+ Date,
140
+ setTimeout,
141
+ clearTimeout,
142
+ setInterval,
143
+ clearInterval,
144
+ performance: { now: () => Date.now() },
145
+ };
146
+ }
147
+
148
+ function createHeadlessAura({ width, height, testState }) {
149
+ const drawNoop = () => {
150
+ testState.drawCalls += 1;
151
+ };
152
+
153
+ const makeVec2 = (x = 0, y = 0) => ({ x: Number(x), y: Number(y) });
154
+ const vec2 = function vec2(x = 0, y = 0) {
155
+ return makeVec2(x, y);
156
+ };
157
+ vec2.create = makeVec2;
158
+ vec2.add = (a, b) => ({ x: a.x + b.x, y: a.y + b.y });
159
+ vec2.sub = (a, b) => ({ x: a.x - b.x, y: a.y - b.y });
160
+ vec2.scale = (v, scalar) => ({ x: v.x * scalar, y: v.y * scalar });
161
+ vec2.normalize = (v) => {
162
+ const magnitude = Math.hypot(v.x, v.y);
163
+ if (magnitude === 0) return { x: 0, y: 0 };
164
+ return { x: v.x / magnitude, y: v.y / magnitude };
165
+ };
166
+ vec2.dot = (a, b) => a.x * b.x + a.y * b.y;
167
+ vec2.distance = (a, b) => Math.hypot(b.x - a.x, b.y - a.y);
168
+ vec2.lerp = (a, b, t) => ({
169
+ x: a.x + (b.x - a.x) * t,
170
+ y: a.y + (b.y - a.y) * t,
171
+ });
172
+ Object.defineProperty(vec2, 'length', {
173
+ value: (v) => Math.hypot(v.x, v.y),
174
+ writable: true,
175
+ configurable: true,
176
+ });
177
+ vec2.len = (v) => vec2.length(v);
178
+
179
+ const makeVec3 = (x = 0, y = 0, z = 0) => ({ x: Number(x), y: Number(y), z: Number(z) });
180
+ const vec3 = function vec3(x = 0, y = 0, z = 0) {
181
+ return makeVec3(x, y, z);
182
+ };
183
+ vec3.create = makeVec3;
184
+ vec3.add = (a, b) => ({ x: a.x + b.x, y: a.y + b.y, z: a.z + b.z });
185
+ vec3.sub = (a, b) => ({ x: a.x - b.x, y: a.y - b.y, z: a.z - b.z });
186
+ vec3.scale = (v, scalar) => ({ x: v.x * scalar, y: v.y * scalar, z: v.z * scalar });
187
+ vec3.dot = (a, b) => a.x * b.x + a.y * b.y + a.z * b.z;
188
+ vec3.cross = (a, b) => ({
189
+ x: a.y * b.z - a.z * b.y,
190
+ y: a.z * b.x - a.x * b.z,
191
+ z: a.x * b.y - a.y * b.x,
192
+ });
193
+ vec3.normalize = (v) => {
194
+ const magnitude = Math.hypot(v.x, v.y, v.z);
195
+ if (magnitude === 0) return { x: 0, y: 0, z: 0 };
196
+ return { x: v.x / magnitude, y: v.y / magnitude, z: v.z / magnitude };
197
+ };
198
+ vec3.distance = (a, b) => Math.hypot(b.x - a.x, b.y - a.y, b.z - a.z);
199
+ vec3.lerp = (a, b, t) => ({
200
+ x: a.x + (b.x - a.x) * t,
201
+ y: a.y + (b.y - a.y) * t,
202
+ z: a.z + (b.z - a.z) * t,
203
+ });
204
+ Object.defineProperty(vec3, 'length', {
205
+ value: (v) => Math.hypot(v.x, v.y, v.z),
206
+ writable: true,
207
+ configurable: true,
208
+ });
209
+
210
+ const isFiniteNumber = (value) => typeof value === 'number' && Number.isFinite(value);
211
+ const parseRect = (value) => (
212
+ value
213
+ && isFiniteNumber(value.x)
214
+ && isFiniteNumber(value.y)
215
+ && isFiniteNumber(value.w)
216
+ && isFiniteNumber(value.h)
217
+ ? value
218
+ : null
219
+ );
220
+ const parsePoint = (value) => (
221
+ value
222
+ && isFiniteNumber(value.x)
223
+ && isFiniteNumber(value.y)
224
+ ? value
225
+ : null
226
+ );
227
+ const parseCircle = (value) => (
228
+ value
229
+ && isFiniteNumber(value.x)
230
+ && isFiniteNumber(value.y)
231
+ && isFiniteNumber(value.radius)
232
+ ? value
233
+ : null
234
+ );
235
+
236
+ const collisionRectRect = (a, b) => a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y;
237
+ const collisionRectPoint = (rect, point) => (
238
+ point.x >= rect.x
239
+ && point.x <= rect.x + rect.w
240
+ && point.y >= rect.y
241
+ && point.y <= rect.y + rect.h
242
+ );
243
+ const collisionCircleCircle = (a, b) => Math.hypot(b.x - a.x, b.y - a.y) <= a.radius + b.radius;
244
+ const collisionCirclePoint = (circle, point) => Math.hypot(point.x - circle.x, point.y - circle.y) <= circle.radius;
245
+ const collisionCircleRect = (circle, rect) => {
246
+ const nearestX = Math.max(rect.x, Math.min(circle.x, rect.x + rect.w));
247
+ const nearestY = Math.max(rect.y, Math.min(circle.y, rect.y + rect.h));
248
+ return Math.hypot(circle.x - nearestX, circle.y - nearestY) <= circle.radius;
249
+ };
250
+
251
+ const collision = {
252
+ rectRect: (a, b) => {
253
+ const lhs = parseRect(a);
254
+ const rhs = parseRect(b);
255
+ return !!lhs && !!rhs && collisionRectRect(lhs, rhs);
256
+ },
257
+ rectPoint: (rect, point) => {
258
+ const parsedRect = parseRect(rect);
259
+ const parsedPoint = parsePoint(point);
260
+ return !!parsedRect && !!parsedPoint && collisionRectPoint(parsedRect, parsedPoint);
261
+ },
262
+ circleCircle: (a, b) => {
263
+ const lhs = parseCircle(a);
264
+ const rhs = parseCircle(b);
265
+ return !!lhs && !!rhs && collisionCircleCircle(lhs, rhs);
266
+ },
267
+ circlePoint: (circle, point) => {
268
+ const parsedCircle = parseCircle(circle);
269
+ const parsedPoint = parsePoint(point);
270
+ return !!parsedCircle && !!parsedPoint && collisionCirclePoint(parsedCircle, parsedPoint);
271
+ },
272
+ circleRect: (circle, rect) => {
273
+ const parsedCircle = parseCircle(circle);
274
+ const parsedRect = parseRect(rect);
275
+ return !!parsedCircle && !!parsedRect && collisionCircleRect(parsedCircle, parsedRect);
276
+ },
277
+ rectRectBatch: (rect, rects) => {
278
+ const parsedRect = parseRect(rect);
279
+ if (!parsedRect || !Array.isArray(rects)) return [];
280
+ return rects.map((candidate) => {
281
+ const parsedCandidate = parseRect(candidate);
282
+ return !!parsedCandidate && collisionRectRect(parsedRect, parsedCandidate);
283
+ });
284
+ },
285
+ rectPointBatch: (rect, points) => {
286
+ const parsedRect = parseRect(rect);
287
+ if (!parsedRect || !Array.isArray(points)) return [];
288
+ return points.map((candidate) => {
289
+ const parsedCandidate = parsePoint(candidate);
290
+ return !!parsedCandidate && collisionRectPoint(parsedRect, parsedCandidate);
291
+ });
292
+ },
293
+ circleCircleBatch: (circle, circles) => {
294
+ const parsedCircle = parseCircle(circle);
295
+ if (!parsedCircle || !Array.isArray(circles)) return [];
296
+ return circles.map((candidate) => {
297
+ const parsedCandidate = parseCircle(candidate);
298
+ return !!parsedCandidate && collisionCircleCircle(parsedCircle, parsedCandidate);
299
+ });
300
+ },
301
+ circlePointBatch: (circle, points) => {
302
+ const parsedCircle = parseCircle(circle);
303
+ if (!parsedCircle || !Array.isArray(points)) return [];
304
+ return points.map((candidate) => {
305
+ const parsedCandidate = parsePoint(candidate);
306
+ return !!parsedCandidate && collisionCirclePoint(parsedCircle, parsedCandidate);
307
+ });
308
+ },
309
+ circleRectBatch: (circle, rects) => {
310
+ const parsedCircle = parseCircle(circle);
311
+ if (!parsedCircle || !Array.isArray(rects)) return [];
312
+ return rects.map((candidate) => {
313
+ const parsedCandidate = parseRect(candidate);
314
+ return !!parsedCandidate && collisionCircleRect(parsedCircle, parsedCandidate);
315
+ });
316
+ },
317
+ };
318
+
319
+ const debugInspectorState = {
320
+ enabled: false,
321
+ frameCount: 0,
322
+ elapsedSeconds: 0,
323
+ };
324
+
325
+ const debugInspectorSnapshot = () => ({
326
+ enabled: debugInspectorState.enabled,
327
+ frameCount: debugInspectorState.frameCount,
328
+ frame: {
329
+ fps: 60,
330
+ deltaSeconds: 1 / 60,
331
+ elapsedSeconds: debugInspectorState.elapsedSeconds,
332
+ },
333
+ window: {
334
+ width,
335
+ height,
336
+ pixelRatio: 1,
337
+ },
338
+ queues: {
339
+ draw2dPending: 0,
340
+ debugOverlayPending: 0,
341
+ },
342
+ });
343
+
344
+ const collide = {
345
+ rects: (x1, y1, w1, h1, x2, y2, w2, h2) => collision.rectRect(
346
+ { x: x1, y: y1, w: w1, h: h1 },
347
+ { x: x2, y: y2, w: w2, h: h2 },
348
+ ),
349
+ circles: (x1, y1, r1, x2, y2, r2) => collision.circleCircle(
350
+ { x: x1, y: y1, radius: r1 },
351
+ { x: x2, y: y2, radius: r2 },
352
+ ),
353
+ pointRect: (px, py, rx, ry, rw, rh) => collision.rectPoint(
354
+ { x: rx, y: ry, w: rw, h: rh },
355
+ { x: px, y: py },
356
+ ),
357
+ pointCircle: (px, py, cx, cy, cr) => collision.circlePoint(
358
+ { x: cx, y: cy, radius: cr },
359
+ { x: px, y: py },
360
+ ),
361
+ rectCircle: (rx, ry, rw, rh, cx, cy, cr) => collision.circleRect(
362
+ { x: cx, y: cy, radius: cr },
363
+ { x: rx, y: ry, w: rw, h: rh },
364
+ ),
365
+ };
366
+
367
+ let nextEcsEntityId = 1;
368
+ const ecsEntities = new Set();
369
+ const ecsComponentStores = new Map();
370
+ const ecsSystems = [];
371
+ const sortEcsSystems = () => {
372
+ ecsSystems.sort((a, b) => (a.order - b.order) || a.name.localeCompare(b.name));
373
+ };
374
+ const normalizeEntityId = (value) => (
375
+ Number.isInteger(value) && value > 0 ? value : null
376
+ );
377
+ const normalizeComponentName = (value) => (
378
+ typeof value === 'string' && value.length > 0 ? value : null
379
+ );
380
+ const normalizeSystemName = (value) => (
381
+ typeof value === 'string' && value.length > 0 ? value : null
382
+ );
383
+
384
+ let nextScene3dNodeId = 1;
385
+ const scene3dNodes = new Map();
386
+ const scene3dIsValidNodeId = (value) => Number.isInteger(value) && value > 0;
387
+ const scene3dCloneVec3 = (value, fallback) => ({
388
+ x: Number.isFinite(value?.x) ? Number(value.x) : fallback.x,
389
+ y: Number.isFinite(value?.y) ? Number(value.y) : fallback.y,
390
+ z: Number.isFinite(value?.z) ? Number(value.z) : fallback.z,
391
+ });
392
+ const scene3dNormalizeTransform = (input) => {
393
+ const src = (input && typeof input === 'object') ? input : {};
394
+ return {
395
+ position: scene3dCloneVec3(src.position, { x: 0, y: 0, z: 0 }),
396
+ rotation: scene3dCloneVec3(src.rotation, { x: 0, y: 0, z: 0 }),
397
+ scale: scene3dCloneVec3(src.scale, { x: 1, y: 1, z: 1 }),
398
+ };
399
+ };
400
+ const scene3dCloneTransform = (transform) => ({
401
+ position: { ...transform.position },
402
+ rotation: { ...transform.rotation },
403
+ scale: { ...transform.scale },
404
+ });
405
+ const scene3dSortedChildren = (node) => [...node.children].sort((a, b) => a - b);
406
+ const scene3dDetachFromParent = (node) => {
407
+ if (node.parentId == null) return;
408
+ const parent = scene3dNodes.get(node.parentId);
409
+ if (parent) parent.children.delete(node.id);
410
+ node.parentId = null;
411
+ };
412
+ const scene3dWouldCreateCycle = (nodeId, parentId) => {
413
+ let cursor = parentId;
414
+ while (cursor != null) {
415
+ if (cursor === nodeId) return true;
416
+ const cursorNode = scene3dNodes.get(cursor);
417
+ if (!cursorNode) break;
418
+ cursor = cursorNode.parentId;
419
+ }
420
+ return false;
421
+ };
422
+ const scene3dComposeWorldTransform = (nodeId) => {
423
+ const chain = [];
424
+ let cursor = scene3dNodes.get(nodeId);
425
+ while (cursor) {
426
+ chain.push(cursor);
427
+ if (cursor.parentId == null) break;
428
+ cursor = scene3dNodes.get(cursor.parentId);
429
+ }
430
+ chain.reverse();
431
+
432
+ const world = {
433
+ position: { x: 0, y: 0, z: 0 },
434
+ rotation: { x: 0, y: 0, z: 0 },
435
+ scale: { x: 1, y: 1, z: 1 },
436
+ };
437
+
438
+ for (const node of chain) {
439
+ world.position.x += node.local.position.x * world.scale.x;
440
+ world.position.y += node.local.position.y * world.scale.y;
441
+ world.position.z += node.local.position.z * world.scale.z;
442
+ world.rotation.x += node.local.rotation.x;
443
+ world.rotation.y += node.local.rotation.y;
444
+ world.rotation.z += node.local.rotation.z;
445
+ world.scale.x *= node.local.scale.x;
446
+ world.scale.y *= node.local.scale.y;
447
+ world.scale.z *= node.local.scale.z;
448
+ }
449
+ return world;
450
+ };
451
+ const scene3dNormalizeRaycastVec3 = (value) => {
452
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
453
+ const x = Number(value.x);
454
+ const y = Number(value.y);
455
+ const z = Number(value.z);
456
+ if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) return null;
457
+ return { x, y, z };
458
+ };
459
+ const scene3dNormalizeRaycastArgs = (originOrOptions, maybeDirection, maybeOptions) => {
460
+ let origin = null;
461
+ let direction = null;
462
+ let options = null;
463
+ if (
464
+ originOrOptions
465
+ && typeof originOrOptions === 'object'
466
+ && !Array.isArray(originOrOptions)
467
+ && (
468
+ Object.prototype.hasOwnProperty.call(originOrOptions, 'origin')
469
+ || Object.prototype.hasOwnProperty.call(originOrOptions, 'direction')
470
+ )
471
+ ) {
472
+ origin = scene3dNormalizeRaycastVec3(originOrOptions.origin);
473
+ direction = scene3dNormalizeRaycastVec3(originOrOptions.direction);
474
+ options = originOrOptions;
475
+ } else {
476
+ origin = scene3dNormalizeRaycastVec3(originOrOptions);
477
+ direction = scene3dNormalizeRaycastVec3(maybeDirection);
478
+ options = maybeOptions;
479
+ }
480
+ if (!origin || !direction) return { ok: false, reason: 'invalid_raycast_args' };
481
+ const length = Math.hypot(direction.x, direction.y, direction.z);
482
+ if (!Number.isFinite(length) || length <= 1e-9) return { ok: false, reason: 'invalid_raycast_args' };
483
+ let maxDistance = 1000;
484
+ if (options && typeof options === 'object' && !Array.isArray(options) && Object.prototype.hasOwnProperty.call(options, 'maxDistance')) {
485
+ maxDistance = Number(options.maxDistance);
486
+ }
487
+ if (!Number.isFinite(maxDistance) || !(maxDistance > 0)) return { ok: false, reason: 'invalid_raycast_args' };
488
+ return {
489
+ ok: true,
490
+ origin,
491
+ direction: { x: direction.x / length, y: direction.y / length, z: direction.z / length },
492
+ maxDistance,
493
+ };
494
+ };
495
+ const scene3d = {
496
+ createNode: (initialTransform = null) => {
497
+ const id = nextScene3dNodeId++;
498
+ scene3dNodes.set(id, {
499
+ id,
500
+ parentId: null,
501
+ children: new Set(),
502
+ local: scene3dNormalizeTransform(initialTransform),
503
+ });
504
+ return id;
505
+ },
506
+ removeNode: (nodeId) => {
507
+ if (!scene3dIsValidNodeId(nodeId)) return false;
508
+ const node = scene3dNodes.get(nodeId);
509
+ if (!node) return false;
510
+ scene3dDetachFromParent(node);
511
+ for (const childId of scene3dSortedChildren(node)) {
512
+ const child = scene3dNodes.get(childId);
513
+ if (child) child.parentId = null;
514
+ }
515
+ scene3dNodes.delete(nodeId);
516
+ return true;
517
+ },
518
+ setParent: (nodeId, parentId) => {
519
+ if (!scene3dIsValidNodeId(nodeId)) return false;
520
+ const node = scene3dNodes.get(nodeId);
521
+ if (!node) return false;
522
+ if (parentId == null) {
523
+ scene3dDetachFromParent(node);
524
+ return true;
525
+ }
526
+ if (!scene3dIsValidNodeId(parentId) || parentId === nodeId) return false;
527
+ const parent = scene3dNodes.get(parentId);
528
+ if (!parent) return false;
529
+ if (scene3dWouldCreateCycle(nodeId, parentId)) return false;
530
+ scene3dDetachFromParent(node);
531
+ node.parentId = parentId;
532
+ parent.children.add(nodeId);
533
+ return true;
534
+ },
535
+ getParent: (nodeId) => {
536
+ if (!scene3dIsValidNodeId(nodeId)) return null;
537
+ const node = scene3dNodes.get(nodeId);
538
+ if (!node) return null;
539
+ return node.parentId;
540
+ },
541
+ setLocalTransform: (nodeId, transform) => {
542
+ if (!scene3dIsValidNodeId(nodeId)) return false;
543
+ const node = scene3dNodes.get(nodeId);
544
+ if (!node || !transform || typeof transform !== 'object') return false;
545
+ node.local = scene3dNormalizeTransform(transform);
546
+ return true;
547
+ },
548
+ getLocalTransform: (nodeId) => {
549
+ if (!scene3dIsValidNodeId(nodeId)) return null;
550
+ const node = scene3dNodes.get(nodeId);
551
+ if (!node) return null;
552
+ return scene3dCloneTransform(node.local);
553
+ },
554
+ getWorldTransform: (nodeId) => {
555
+ if (!scene3dIsValidNodeId(nodeId)) return null;
556
+ if (!scene3dNodes.has(nodeId)) return null;
557
+ return scene3dComposeWorldTransform(nodeId);
558
+ },
559
+ traverse: (rootOrCallback, maybeCallback) => {
560
+ let rootId = null;
561
+ let callback = maybeCallback;
562
+ if (typeof rootOrCallback === 'function') {
563
+ callback = rootOrCallback;
564
+ } else if (rootOrCallback != null) {
565
+ rootId = rootOrCallback;
566
+ }
567
+ if (typeof callback !== 'function') return false;
568
+ if (rootId != null && (!scene3dIsValidNodeId(rootId) || !scene3dNodes.has(rootId))) {
569
+ return false;
570
+ }
571
+
572
+ const walk = (nodeId) => {
573
+ const node = scene3dNodes.get(nodeId);
574
+ if (!node) return;
575
+ callback(
576
+ nodeId,
577
+ scene3dComposeWorldTransform(nodeId),
578
+ scene3dCloneTransform(node.local),
579
+ node.parentId,
580
+ );
581
+ for (const childId of scene3dSortedChildren(node)) {
582
+ walk(childId);
583
+ }
584
+ };
585
+
586
+ if (rootId != null) {
587
+ walk(rootId);
588
+ return true;
589
+ }
590
+
591
+ const roots = [...scene3dNodes.values()]
592
+ .filter((node) => node.parentId == null)
593
+ .map((node) => node.id)
594
+ .sort((a, b) => a - b);
595
+ for (const nodeId of roots) {
596
+ walk(nodeId);
597
+ }
598
+ return true;
599
+ },
600
+ queryRaycast: (originOrOptions, maybeDirection, maybeOptions) => {
601
+ const parsed = scene3dNormalizeRaycastArgs(originOrOptions, maybeDirection, maybeOptions);
602
+ if (!parsed.ok) {
603
+ return { ok: false, reason: parsed.reason };
604
+ }
605
+ const hits = [];
606
+ const nodeIds = [...scene3dNodes.keys()].sort((a, b) => a - b);
607
+ for (const nodeId of nodeIds) {
608
+ const world = scene3dComposeWorldTransform(nodeId);
609
+ const radius = Math.max(
610
+ 0.05,
611
+ (Math.max(
612
+ Math.abs(world.scale.x),
613
+ Math.abs(world.scale.y),
614
+ Math.abs(world.scale.z),
615
+ ) * 0.5),
616
+ );
617
+ const ox = parsed.origin.x - world.position.x;
618
+ const oy = parsed.origin.y - world.position.y;
619
+ const oz = parsed.origin.z - world.position.z;
620
+ const b = 2 * ((ox * parsed.direction.x) + (oy * parsed.direction.y) + (oz * parsed.direction.z));
621
+ const c = (ox * ox) + (oy * oy) + (oz * oz) - (radius * radius);
622
+ const discriminant = (b * b) - (4 * c);
623
+ if (!Number.isFinite(discriminant) || discriminant < 0) continue;
624
+ const root = Math.sqrt(discriminant);
625
+ let toi = (-b - root) / 2;
626
+ if (toi < 0) toi = (-b + root) / 2;
627
+ if (!Number.isFinite(toi) || toi < 0 || toi > parsed.maxDistance) continue;
628
+ hits.push({
629
+ nodeId,
630
+ toi: Number(toi.toFixed(6)),
631
+ point: {
632
+ x: Number((parsed.origin.x + (parsed.direction.x * toi)).toFixed(6)),
633
+ y: Number((parsed.origin.y + (parsed.direction.y * toi)).toFixed(6)),
634
+ z: Number((parsed.origin.z + (parsed.direction.z * toi)).toFixed(6)),
635
+ },
636
+ radius: Number(radius.toFixed(6)),
637
+ });
638
+ }
639
+ hits.sort((a, b) => (a.toi - b.toi) || (a.nodeId - b.nodeId));
640
+ return {
641
+ ok: true,
642
+ reason: null,
643
+ hit: hits.length > 0,
644
+ hitCount: hits.length,
645
+ firstHit: hits.length > 0 ? hits[0] : null,
646
+ hits,
647
+ maxDistance: Number(parsed.maxDistance.toFixed(6)),
648
+ };
649
+ },
650
+ };
651
+
652
+ const createSceneHelper = () => {
653
+ let lifecycleSeq = 1;
654
+ let nextTimedEventId = 1;
655
+ let globalTicks = 0;
656
+ let activeScene = null;
657
+ const sceneDefinitions = new Map();
658
+ const timedEvents = new Map();
659
+ const lifecycleTrace = [];
660
+
661
+ const isPlainObject = (value) => !!value && typeof value === 'object' && !Array.isArray(value);
662
+ const normalizeSceneKey = (value) => {
663
+ if (typeof value !== 'string') return null;
664
+ const trimmed = value.trim();
665
+ return trimmed.length > 0 ? trimmed : null;
666
+ };
667
+ const resultOk = (reasonCode, extra = {}) => ({ ok: true, reasonCode, ...extra });
668
+ const resultErr = (reasonCode, extra = {}) => ({ ok: false, reasonCode, ...extra });
669
+ const cloneValue = (value) => {
670
+ if (value == null) return value;
671
+ if (Array.isArray(value)) return value.map((entry) => cloneValue(entry));
672
+ if (typeof value !== 'object') return value;
673
+ const out = {};
674
+ for (const key of Object.keys(value)) out[key] = cloneValue(value[key]);
675
+ return out;
676
+ };
677
+ const callbackContext = (sceneRecord) => ({
678
+ key: sceneRecord.key,
679
+ state: sceneRecord.state,
680
+ data: sceneRecord.data,
681
+ updates: sceneRecord.updates,
682
+ elapsed: sceneRecord.elapsed,
683
+ });
684
+ const recordLifecycle = (sceneKey, phase) => {
685
+ lifecycleTrace.push({
686
+ seq: lifecycleSeq++,
687
+ sceneKey,
688
+ phase,
689
+ });
690
+ };
691
+ const invokeSceneCallback = (sceneRecord, phase, ...args) => {
692
+ const callback = sceneRecord.callbacks[phase];
693
+ if (typeof callback !== 'function') return;
694
+ recordLifecycle(sceneRecord.key, phase);
695
+ try {
696
+ callback(callbackContext(sceneRecord), ...args);
697
+ } catch {
698
+ // Preserve deterministic progression when callbacks throw.
699
+ }
700
+ };
701
+ const sortedSceneKeys = () => [...sceneDefinitions.keys()].sort((a, b) => a.localeCompare(b));
702
+ const sortedEventIds = () => [...timedEvents.keys()].sort((a, b) => a - b);
703
+ const snapshotEvent = (event) => ({
704
+ eventId: event.id,
705
+ delay: event.delay,
706
+ interval: event.interval,
707
+ remaining: event.remaining,
708
+ repeat: event.repeat,
709
+ paused: event.paused,
710
+ dispatchCount: event.dispatchCount,
711
+ sceneKey: event.sceneKey,
712
+ createdTick: event.createdTick,
713
+ });
714
+ const resolveEvent = (eventId) => {
715
+ if (!Number.isInteger(eventId) || eventId <= 0) {
716
+ return { event: null, reasonCode: 'invalid_event_id' };
717
+ }
718
+ const event = timedEvents.get(eventId);
719
+ if (!event) {
720
+ return { event: null, reasonCode: 'missing_event' };
721
+ }
722
+ return { event, reasonCode: null };
723
+ };
724
+ const normalizeSceneDefinition = (definition) => {
725
+ if (!isPlainObject(definition)) {
726
+ return { callbacks: null, reasonCode: 'invalid_scene_definition' };
727
+ }
728
+ const callbacks = {};
729
+ for (const phase of ['preload', 'create', 'update', 'shutdown']) {
730
+ if (!Object.prototype.hasOwnProperty.call(definition, phase)) continue;
731
+ const callback = definition[phase];
732
+ if (callback != null && typeof callback !== 'function') {
733
+ return { callbacks: null, reasonCode: 'invalid_scene_callbacks' };
734
+ }
735
+ callbacks[phase] = callback;
736
+ }
737
+ return { callbacks, reasonCode: null };
738
+ };
739
+ const startInternal = (sceneKey, data = null, mode = 'start') => {
740
+ const key = normalizeSceneKey(sceneKey);
741
+ if (key == null) return resultErr('invalid_scene_key');
742
+ const definition = sceneDefinitions.get(key);
743
+ if (!definition) return resultErr('missing_scene_definition');
744
+
745
+ if (activeScene) {
746
+ const previous = activeScene;
747
+ activeScene = null;
748
+ invokeSceneCallback(previous, 'shutdown', mode === 'switch' ? 'switch' : 'start');
749
+ }
750
+
751
+ activeScene = {
752
+ key,
753
+ callbacks: definition.callbacks,
754
+ state: {},
755
+ data: cloneValue(data),
756
+ updates: 0,
757
+ elapsed: 0,
758
+ lastDt: 0,
759
+ startedTick: globalTicks,
760
+ };
761
+ invokeSceneCallback(activeScene, 'preload');
762
+ invokeSceneCallback(activeScene, 'create');
763
+ return resultOk(mode === 'switch' ? 'scene_switched' : 'scene_started', {
764
+ activeScene: key,
765
+ });
766
+ };
767
+ const advanceTimedEvents = (dt) => {
768
+ const due = [];
769
+ for (const eventId of sortedEventIds()) {
770
+ const event = timedEvents.get(eventId);
771
+ if (!event || event.paused) continue;
772
+ event.remaining -= dt;
773
+ while (event.remaining <= 1e-9) {
774
+ event.dispatchCount += 1;
775
+ due.push({
776
+ id: event.id,
777
+ sceneKey: event.sceneKey,
778
+ callback: event.callback,
779
+ dispatchCount: event.dispatchCount,
780
+ });
781
+ if (!event.repeat) {
782
+ timedEvents.delete(event.id);
783
+ break;
784
+ }
785
+ event.remaining += event.interval;
786
+ }
787
+ }
788
+ due.sort((a, b) => a.id - b.id);
789
+ for (const entry of due) {
790
+ try {
791
+ entry.callback({
792
+ eventId: entry.id,
793
+ sceneKey: entry.sceneKey,
794
+ dispatchCount: entry.dispatchCount,
795
+ });
796
+ } catch {
797
+ // Preserve deterministic progression when callbacks throw.
798
+ }
799
+ }
800
+ return due;
801
+ };
802
+
803
+ const scene = {
804
+ define(sceneKey, definition) {
805
+ const key = normalizeSceneKey(sceneKey);
806
+ if (key == null) return resultErr('invalid_scene_key');
807
+ const parsed = normalizeSceneDefinition(definition);
808
+ if (!parsed.callbacks) return resultErr(parsed.reasonCode || 'invalid_scene_definition');
809
+ const replaced = sceneDefinitions.has(key);
810
+ sceneDefinitions.set(key, {
811
+ key,
812
+ callbacks: parsed.callbacks,
813
+ });
814
+ return resultOk('scene_defined', { sceneKey: key, replaced });
815
+ },
816
+ register(sceneKey, definition) {
817
+ return scene.define(sceneKey, definition);
818
+ },
819
+ start(sceneKey, data = null) {
820
+ return startInternal(sceneKey, data, 'start');
821
+ },
822
+ switchTo(sceneKey, data = null) {
823
+ return startInternal(sceneKey, data, 'switch');
824
+ },
825
+ update(dt) {
826
+ const stepDt = Number(dt);
827
+ if (!Number.isFinite(stepDt) || !(stepDt > 0)) {
828
+ return resultErr('invalid_dt');
829
+ }
830
+ if (!activeScene) {
831
+ return resultErr('no_active_scene');
832
+ }
833
+
834
+ globalTicks += 1;
835
+ const firedEvents = advanceTimedEvents(stepDt);
836
+ const current = activeScene;
837
+ current.updates += 1;
838
+ current.elapsed += stepDt;
839
+ current.lastDt = stepDt;
840
+ invokeSceneCallback(current, 'update', stepDt);
841
+ return resultOk('scene_updated', {
842
+ activeScene: activeScene ? activeScene.key : null,
843
+ firedEvents: firedEvents.length,
844
+ firedEventIds: firedEvents.map((entry) => entry.id),
845
+ });
846
+ },
847
+ getState() {
848
+ return {
849
+ activeScene: activeScene ? activeScene.key : null,
850
+ sceneCount: sceneDefinitions.size,
851
+ scenes: sortedSceneKeys(),
852
+ tick: globalTicks,
853
+ active: activeScene ? {
854
+ key: activeScene.key,
855
+ updates: activeScene.updates,
856
+ elapsed: activeScene.elapsed,
857
+ lastDt: activeScene.lastDt,
858
+ startedTick: activeScene.startedTick,
859
+ state: cloneValue(activeScene.state),
860
+ data: cloneValue(activeScene.data),
861
+ } : null,
862
+ lifecycle: lifecycleTrace.map((entry) => ({ ...entry })),
863
+ timedEvents: sortedEventIds()
864
+ .map((eventId) => timedEvents.get(eventId))
865
+ .filter(Boolean)
866
+ .map((event) => snapshotEvent(event)),
867
+ };
868
+ },
869
+ schedule(delaySeconds, callback, options = {}) {
870
+ const delay = Number(delaySeconds);
871
+ if (!Number.isFinite(delay) || !(delay > 0)) {
872
+ return resultErr('invalid_delay');
873
+ }
874
+ if (typeof callback !== 'function') {
875
+ return resultErr('invalid_callback');
876
+ }
877
+ const normalizedOptions = options == null ? {} : options;
878
+ if (!isPlainObject(normalizedOptions)) {
879
+ return resultErr('invalid_event_options');
880
+ }
881
+
882
+ let repeat = false;
883
+ if (Object.prototype.hasOwnProperty.call(normalizedOptions, 'repeat')) {
884
+ if (typeof normalizedOptions.repeat !== 'boolean') {
885
+ return resultErr('invalid_repeat_flag');
886
+ }
887
+ repeat = normalizedOptions.repeat;
888
+ }
889
+
890
+ let interval = delay;
891
+ if (Object.prototype.hasOwnProperty.call(normalizedOptions, 'interval')) {
892
+ interval = Number(normalizedOptions.interval);
893
+ if (!Number.isFinite(interval) || !(interval > 0)) {
894
+ return resultErr('invalid_interval');
895
+ }
896
+ }
897
+
898
+ let paused = false;
899
+ if (Object.prototype.hasOwnProperty.call(normalizedOptions, 'paused')) {
900
+ if (typeof normalizedOptions.paused !== 'boolean') {
901
+ return resultErr('invalid_paused_flag');
902
+ }
903
+ paused = normalizedOptions.paused;
904
+ }
905
+
906
+ const event = {
907
+ id: nextTimedEventId++,
908
+ delay,
909
+ interval,
910
+ remaining: delay,
911
+ repeat,
912
+ paused,
913
+ callback,
914
+ dispatchCount: 0,
915
+ sceneKey: activeScene ? activeScene.key : null,
916
+ createdTick: globalTicks,
917
+ };
918
+ timedEvents.set(event.id, event);
919
+ return resultOk('scene_event_scheduled', { eventId: event.id });
920
+ },
921
+ pauseEvent(eventId) {
922
+ const resolved = resolveEvent(eventId);
923
+ if (!resolved.event) return resultErr(resolved.reasonCode);
924
+ resolved.event.paused = true;
925
+ return resultOk('scene_event_paused', {
926
+ eventId: resolved.event.id,
927
+ paused: true,
928
+ });
929
+ },
930
+ resumeEvent(eventId) {
931
+ const resolved = resolveEvent(eventId);
932
+ if (!resolved.event) return resultErr(resolved.reasonCode);
933
+ resolved.event.paused = false;
934
+ return resultOk('scene_event_resumed', {
935
+ eventId: resolved.event.id,
936
+ paused: false,
937
+ });
938
+ },
939
+ cancelEvent(eventId) {
940
+ const resolved = resolveEvent(eventId);
941
+ if (!resolved.event) return resultErr(resolved.reasonCode);
942
+ timedEvents.delete(resolved.event.id);
943
+ return resultOk('scene_event_cancelled', {
944
+ eventId: resolved.event.id,
945
+ cancelled: true,
946
+ });
947
+ },
948
+ getEventState(eventId) {
949
+ const resolved = resolveEvent(eventId);
950
+ if (!resolved.event) return null;
951
+ return snapshotEvent(resolved.event);
952
+ },
953
+ };
954
+
955
+ return scene;
956
+ };
957
+ const scene = createSceneHelper();
958
+
959
+ let nextTilemapId = 1;
960
+ const tilemaps = new Map();
961
+ const tilemapIsPosInt = (value) => Number.isInteger(value) && value > 0;
962
+ const tilemapIsNonNegInt = (value) => Number.isInteger(value) && value >= 0;
963
+ const tilemapFiniteOr = (value, fallback) => (Number.isFinite(value) ? Number(value) : fallback);
964
+ const tilemapIntOr = (value, fallback) => (Number.isInteger(value) ? Number(value) : fallback);
965
+ const tilemapFail = (reason, detail = '') => {
966
+ const suffix = detail ? `: ${detail}` : '';
967
+ throw new Error(`aura.tilemap.import: ${reason}${suffix}`);
968
+ };
969
+ const tilemapResolveRawMap = (source) => {
970
+ if (source && typeof source === 'object') return source;
971
+ if (typeof source === 'string') {
972
+ const trimmed = source.trim();
973
+ if (trimmed.startsWith('{')) {
974
+ try {
975
+ return JSON.parse(trimmed);
976
+ } catch (error) {
977
+ tilemapFail('invalid-json', String(error));
978
+ }
979
+ }
980
+ if (aura?.assets && typeof aura.assets.loadJson === 'function') {
981
+ return aura.assets.loadJson(source);
982
+ }
983
+ if (aura?.assets && typeof aura.assets.json === 'function') {
984
+ return aura.assets.json(source);
985
+ }
986
+ tilemapFail('unsupported-source-string', 'assets.loadJson/json unavailable');
987
+ }
988
+ tilemapFail('invalid-source', 'expected object or string');
989
+ };
990
+ const tilemapResolveTilesetForGid = (tilesets, gid) => {
991
+ let selected = null;
992
+ for (const tileset of tilesets) {
993
+ if (tileset.firstGid <= gid) {
994
+ selected = tileset;
995
+ } else {
996
+ break;
997
+ }
998
+ }
999
+ if (!selected) return null;
1000
+ const localIndex = gid - selected.firstGid;
1001
+ if (selected.tileCount != null && localIndex >= selected.tileCount) {
1002
+ return null;
1003
+ }
1004
+ return selected;
1005
+ };
1006
+ const tilemapNormalizePropertyValue = (value, contextLabel) => {
1007
+ if (value == null) return null;
1008
+ if (typeof value === 'string' || typeof value === 'boolean') return value;
1009
+ if (typeof value === 'number' && Number.isFinite(value)) return Number(value);
1010
+ tilemapFail('invalid-property-value', `${contextLabel} must be string/number/boolean/null`);
1011
+ };
1012
+ const tilemapNormalizeProperties = (rawProperties, contextLabel) => {
1013
+ if (rawProperties == null) return {};
1014
+ const normalized = {};
1015
+ if (Array.isArray(rawProperties)) {
1016
+ for (let i = 0; i < rawProperties.length; i += 1) {
1017
+ const entry = rawProperties[i];
1018
+ if (!entry || typeof entry !== 'object') {
1019
+ tilemapFail('invalid-property-entry', `${contextLabel}.properties[${i}] must be an object`);
1020
+ }
1021
+ const name = typeof entry.name === 'string' ? entry.name.trim() : '';
1022
+ if (!name) {
1023
+ tilemapFail('invalid-property-name', `${contextLabel}.properties[${i}].name must be a non-empty string`);
1024
+ }
1025
+ if (Object.hasOwn(normalized, name)) {
1026
+ tilemapFail('duplicate-property-name', `${contextLabel}.properties[${i}] duplicate name ${name}`);
1027
+ }
1028
+ normalized[name] = tilemapNormalizePropertyValue(entry.value, `${contextLabel}.properties[${i}].value`);
1029
+ }
1030
+ return Object.keys(normalized).sort().reduce((acc, key) => {
1031
+ acc[key] = normalized[key];
1032
+ return acc;
1033
+ }, {});
1034
+ }
1035
+ if (typeof rawProperties !== 'object') {
1036
+ tilemapFail('invalid-properties', `${contextLabel}.properties must be an object or array`);
1037
+ }
1038
+ const keys = Object.keys(rawProperties).sort();
1039
+ for (const key of keys) {
1040
+ if (typeof key !== 'string' || key.length === 0) {
1041
+ tilemapFail('invalid-property-name', `${contextLabel}.properties contains an invalid key`);
1042
+ }
1043
+ normalized[key] = tilemapNormalizePropertyValue(rawProperties[key], `${contextLabel}.properties.${key}`);
1044
+ }
1045
+ return normalized;
1046
+ };
1047
+ const tilemapNormalizeObjectPoints = (rawPoints, contextLabel) => {
1048
+ if (!Array.isArray(rawPoints) || rawPoints.length === 0) {
1049
+ tilemapFail('invalid-object-points', `${contextLabel} must be a non-empty array`);
1050
+ }
1051
+ return rawPoints.map((point, pointIndex) => {
1052
+ if (!point || typeof point !== 'object') {
1053
+ tilemapFail('invalid-object-point', `${contextLabel}[${pointIndex}] must be an object`);
1054
+ }
1055
+ const x = tilemapFiniteOr(point.x, Number.NaN);
1056
+ const y = tilemapFiniteOr(point.y, Number.NaN);
1057
+ if (!Number.isFinite(x) || !Number.isFinite(y)) {
1058
+ tilemapFail('invalid-object-point', `${contextLabel}[${pointIndex}] x/y must be finite`);
1059
+ }
1060
+ return { x, y };
1061
+ });
1062
+ };
1063
+ const tilemapNormalizeTilesets = (rawTilesets, mapTileWidth, mapTileHeight) => {
1064
+ if (!Array.isArray(rawTilesets) || rawTilesets.length === 0) {
1065
+ tilemapFail('invalid-tilesets', 'expected non-empty tilesets array');
1066
+ }
1067
+ const normalized = [];
1068
+ for (let i = 0; i < rawTilesets.length; i += 1) {
1069
+ const raw = rawTilesets[i];
1070
+ if (!raw || typeof raw !== 'object') {
1071
+ tilemapFail('invalid-tileset', `tileset[${i}] must be an object`);
1072
+ }
1073
+ const firstGid = Number(raw.firstgid);
1074
+ if (!tilemapIsPosInt(firstGid)) {
1075
+ tilemapFail('invalid-tileset-firstgid', `tileset[${i}].firstgid must be a positive integer`);
1076
+ }
1077
+ const image = typeof raw.image === 'string' && raw.image.length > 0 ? raw.image : null;
1078
+ if (!image) {
1079
+ tilemapFail('invalid-tileset-image', `tileset[${i}].image must be a non-empty string`);
1080
+ }
1081
+ const tileWidth = tilemapIsPosInt(raw.tilewidth) ? Number(raw.tilewidth) : mapTileWidth;
1082
+ const tileHeight = tilemapIsPosInt(raw.tileheight) ? Number(raw.tileheight) : mapTileHeight;
1083
+ const columns = Number(raw.columns);
1084
+ if (!tilemapIsPosInt(columns)) {
1085
+ tilemapFail('invalid-tileset-columns', `tileset[${i}].columns must be a positive integer`);
1086
+ }
1087
+ const tileCount = raw.tilecount == null
1088
+ ? null
1089
+ : (tilemapIsPosInt(raw.tilecount) ? Number(raw.tilecount) : null);
1090
+ if (raw.tilecount != null && tileCount == null) {
1091
+ tilemapFail('invalid-tileset-tilecount', `tileset[${i}].tilecount must be a positive integer`);
1092
+ }
1093
+ normalized.push({
1094
+ firstGid,
1095
+ image,
1096
+ tileWidth,
1097
+ tileHeight,
1098
+ columns,
1099
+ tileCount,
1100
+ margin: tilemapIsNonNegInt(raw.margin) ? Number(raw.margin) : 0,
1101
+ spacing: tilemapIsNonNegInt(raw.spacing) ? Number(raw.spacing) : 0,
1102
+ properties: tilemapNormalizeProperties(raw.properties, `tilesets[${i}]`),
1103
+ });
1104
+ }
1105
+ normalized.sort((a, b) => a.firstGid - b.firstGid);
1106
+ for (let i = 1; i < normalized.length; i += 1) {
1107
+ if (normalized[i - 1].firstGid === normalized[i].firstGid) {
1108
+ tilemapFail('duplicate-tileset-firstgid', String(normalized[i].firstGid));
1109
+ }
1110
+ }
1111
+ return normalized;
1112
+ };
1113
+ const tilemapNormalizeObjectLayer = (rawLayer, layerIndex, mapWidth, mapHeight, tilesets) => {
1114
+ const objectContext = `layers[${layerIndex}]`;
1115
+ if (rawLayer.compression) {
1116
+ tilemapFail('unsupported-layer-compression', `${objectContext} compression is not supported`);
1117
+ }
1118
+ if (rawLayer.encoding && rawLayer.encoding !== 'csv') {
1119
+ tilemapFail('unsupported-layer-encoding', `${objectContext} encoding must be omitted or csv`);
1120
+ }
1121
+ if (rawLayer.objects != null && !Array.isArray(rawLayer.objects)) {
1122
+ tilemapFail('invalid-object-layer-objects', `${objectContext}.objects must be an array when provided`);
1123
+ }
1124
+ const rawObjects = Array.isArray(rawLayer.objects) ? rawLayer.objects : [];
1125
+ const normalizedObjects = [];
1126
+ for (let objectIndex = 0; objectIndex < rawObjects.length; objectIndex += 1) {
1127
+ const rawObject = rawObjects[objectIndex];
1128
+ const context = `${objectContext}.objects[${objectIndex}]`;
1129
+ if (!rawObject || typeof rawObject !== 'object') {
1130
+ tilemapFail('invalid-object', `${context} must be an object`);
1131
+ }
1132
+ if (rawObject.template != null) {
1133
+ tilemapFail('unsupported-object-template', `${context}.template is not supported`);
1134
+ }
1135
+ if (rawObject.text != null) {
1136
+ tilemapFail('unsupported-object-text', `${context}.text is not supported`);
1137
+ }
1138
+
1139
+ const id = rawObject.id == null ? (objectIndex + 1) : Number(rawObject.id);
1140
+ if (!tilemapIsNonNegInt(id)) {
1141
+ tilemapFail('invalid-object-id', `${context}.id must be a non-negative integer`);
1142
+ }
1143
+ const gid = rawObject.gid == null ? 0 : Number(rawObject.gid);
1144
+ if (!tilemapIsNonNegInt(gid)) {
1145
+ tilemapFail('invalid-object-gid', `${context}.gid must be a non-negative integer`);
1146
+ }
1147
+ if (gid > 0 && !tilemapResolveTilesetForGid(tilesets, gid)) {
1148
+ tilemapFail('unmapped-object-gid', `${context}.gid ${gid} has no matching tileset`);
1149
+ }
1150
+
1151
+ const x = tilemapFiniteOr(rawObject.x, Number.NaN);
1152
+ const y = tilemapFiniteOr(rawObject.y, Number.NaN);
1153
+ if (!Number.isFinite(x) || !Number.isFinite(y)) {
1154
+ tilemapFail('invalid-object-position', `${context}.x/y must be finite numbers`);
1155
+ }
1156
+ const width = rawObject.width == null ? 0 : tilemapFiniteOr(rawObject.width, Number.NaN);
1157
+ const height = rawObject.height == null ? 0 : tilemapFiniteOr(rawObject.height, Number.NaN);
1158
+ if (!Number.isFinite(width) || !Number.isFinite(height) || width < 0 || height < 0) {
1159
+ tilemapFail('invalid-object-size', `${context}.width/height must be finite numbers >= 0`);
1160
+ }
1161
+ const rotation = rawObject.rotation == null ? 0 : tilemapFiniteOr(rawObject.rotation, Number.NaN);
1162
+ if (!Number.isFinite(rotation)) {
1163
+ tilemapFail('invalid-object-rotation', `${context}.rotation must be finite`);
1164
+ }
1165
+
1166
+ const hasPolygon = Array.isArray(rawObject.polygon);
1167
+ const hasPolyline = Array.isArray(rawObject.polyline);
1168
+ if (hasPolygon && hasPolyline) {
1169
+ tilemapFail('invalid-object-shape', `${context} cannot include both polygon and polyline`);
1170
+ }
1171
+ const properties = tilemapNormalizeProperties(rawObject.properties, context);
1172
+ normalizedObjects.push({
1173
+ id,
1174
+ name: typeof rawObject.name === 'string' ? rawObject.name : '',
1175
+ className: typeof rawObject.class === 'string'
1176
+ ? rawObject.class
1177
+ : (typeof rawObject.type === 'string' ? rawObject.type : ''),
1178
+ type: typeof rawObject.type === 'string' ? rawObject.type : '',
1179
+ x,
1180
+ y,
1181
+ width,
1182
+ height,
1183
+ rotation,
1184
+ visible: rawObject.visible !== false,
1185
+ gid,
1186
+ point: rawObject.point === true,
1187
+ ellipse: rawObject.ellipse === true,
1188
+ polygon: hasPolygon ? tilemapNormalizeObjectPoints(rawObject.polygon, `${context}.polygon`) : null,
1189
+ polyline: hasPolyline ? tilemapNormalizeObjectPoints(rawObject.polyline, `${context}.polyline`) : null,
1190
+ properties,
1191
+ _seq: objectIndex,
1192
+ });
1193
+ }
1194
+ normalizedObjects.sort((a, b) => (a.id - b.id) || (a._seq - b._seq));
1195
+ const objects = normalizedObjects.map((entry) => {
1196
+ const shape = entry.polygon ? 'polygon'
1197
+ : (entry.polyline ? 'polyline'
1198
+ : (entry.point ? 'point'
1199
+ : (entry.ellipse ? 'ellipse'
1200
+ : (entry.gid > 0 ? 'tile' : 'rectangle'))));
1201
+ return {
1202
+ id: entry.id,
1203
+ name: entry.name,
1204
+ className: entry.className,
1205
+ type: entry.type,
1206
+ x: entry.x,
1207
+ y: entry.y,
1208
+ width: entry.width,
1209
+ height: entry.height,
1210
+ rotation: entry.rotation,
1211
+ visible: entry.visible,
1212
+ gid: entry.gid,
1213
+ point: entry.point,
1214
+ ellipse: entry.ellipse,
1215
+ polygon: entry.polygon,
1216
+ polyline: entry.polyline,
1217
+ properties: entry.properties,
1218
+ shape,
1219
+ };
1220
+ });
1221
+ return {
1222
+ index: layerIndex,
1223
+ name: typeof rawLayer.name === 'string' && rawLayer.name.length > 0 ? rawLayer.name : `layer_${layerIndex}`,
1224
+ width: tilemapIsPosInt(rawLayer.width) ? Number(rawLayer.width) : mapWidth,
1225
+ height: tilemapIsPosInt(rawLayer.height) ? Number(rawLayer.height) : mapHeight,
1226
+ visible: rawLayer.visible !== false,
1227
+ opacity: Number.isFinite(rawLayer.opacity) ? Number(rawLayer.opacity) : 1,
1228
+ offsetX: tilemapFiniteOr(rawLayer.offsetx, 0),
1229
+ offsetY: tilemapFiniteOr(rawLayer.offsety, 0),
1230
+ drawOrder: typeof rawLayer.draworder === 'string' && rawLayer.draworder.length > 0
1231
+ ? rawLayer.draworder
1232
+ : 'topdown',
1233
+ properties: tilemapNormalizeProperties(rawLayer.properties, objectContext),
1234
+ objects,
1235
+ };
1236
+ };
1237
+ const tilemapNormalizeLayers = (rawLayers, mapWidth, mapHeight, tilesets) => {
1238
+ if (!Array.isArray(rawLayers) || rawLayers.length === 0) {
1239
+ tilemapFail('invalid-layers', 'expected non-empty layers array');
1240
+ }
1241
+ const tileLayers = [];
1242
+ const objectLayers = [];
1243
+ for (let i = 0; i < rawLayers.length; i += 1) {
1244
+ const raw = rawLayers[i];
1245
+ if (!raw || typeof raw !== 'object') {
1246
+ tilemapFail('invalid-layer', `layers[${i}] must be an object`);
1247
+ }
1248
+ if (raw.type === 'objectgroup') {
1249
+ objectLayers.push(tilemapNormalizeObjectLayer(raw, i, mapWidth, mapHeight, tilesets));
1250
+ continue;
1251
+ }
1252
+ if (raw.type !== 'tilelayer') continue;
1253
+ if (raw.compression) {
1254
+ tilemapFail('unsupported-layer-compression', `layers[${i}] compression is not supported`);
1255
+ }
1256
+ if (raw.encoding && raw.encoding !== 'csv') {
1257
+ tilemapFail('unsupported-layer-encoding', `layers[${i}] encoding must be omitted or csv`);
1258
+ }
1259
+ const width = tilemapIsPosInt(raw.width) ? Number(raw.width) : mapWidth;
1260
+ const height = tilemapIsPosInt(raw.height) ? Number(raw.height) : mapHeight;
1261
+ if (!Array.isArray(raw.data) || raw.data.length !== width * height) {
1262
+ tilemapFail('invalid-layer-data', `layers[${i}] data length must equal width*height`);
1263
+ }
1264
+ const data = new Array(raw.data.length);
1265
+ for (let index = 0; index < raw.data.length; index += 1) {
1266
+ const gid = Number(raw.data[index]);
1267
+ if (!tilemapIsNonNegInt(gid)) {
1268
+ tilemapFail('invalid-layer-gid', `layers[${i}] gid at index ${index} must be a non-negative integer`);
1269
+ }
1270
+ if (gid > 0 && !tilemapResolveTilesetForGid(tilesets, gid)) {
1271
+ tilemapFail('unmapped-layer-gid', `layers[${i}] gid ${gid} has no matching tileset`);
1272
+ }
1273
+ data[index] = gid;
1274
+ }
1275
+ const properties = tilemapNormalizeProperties(raw.properties, `layers[${i}]`);
1276
+ const layerQueryEnabled = raw.solid === true || raw.collision === true || properties.solid === true;
1277
+ tileLayers.push({
1278
+ index: i,
1279
+ name: typeof raw.name === 'string' && raw.name.length > 0 ? raw.name : `layer_${i}`,
1280
+ width,
1281
+ height,
1282
+ data,
1283
+ solid: layerQueryEnabled,
1284
+ collision: raw.collision === true,
1285
+ queryEnabled: layerQueryEnabled,
1286
+ properties,
1287
+ visible: raw.visible !== false,
1288
+ opacity: Number.isFinite(raw.opacity) ? Number(raw.opacity) : 1,
1289
+ offsetX: tilemapFiniteOr(raw.offsetx, 0),
1290
+ offsetY: tilemapFiniteOr(raw.offsety, 0),
1291
+ });
1292
+ }
1293
+ if (tileLayers.length === 0) {
1294
+ tilemapFail('missing-tile-layers', 'expected at least one tilelayer');
1295
+ }
1296
+ return { tileLayers, objectLayers };
1297
+ };
1298
+ const tilemapNormalizeMap = (rawMap) => {
1299
+ if (!rawMap || typeof rawMap !== 'object') {
1300
+ tilemapFail('invalid-map', 'expected object map payload');
1301
+ }
1302
+ const mapWidth = Number(rawMap.width);
1303
+ const mapHeight = Number(rawMap.height);
1304
+ const tileWidth = Number(rawMap.tilewidth);
1305
+ const tileHeight = Number(rawMap.tileheight);
1306
+ if (!tilemapIsPosInt(mapWidth) || !tilemapIsPosInt(mapHeight)) {
1307
+ tilemapFail('invalid-map-size', 'width and height must be positive integers');
1308
+ }
1309
+ if (!tilemapIsPosInt(tileWidth) || !tilemapIsPosInt(tileHeight)) {
1310
+ tilemapFail('invalid-map-tile-size', 'tilewidth and tileheight must be positive integers');
1311
+ }
1312
+ const tilesets = tilemapNormalizeTilesets(rawMap.tilesets, tileWidth, tileHeight);
1313
+ const layers = tilemapNormalizeLayers(rawMap.layers, mapWidth, mapHeight, tilesets);
1314
+ const solidLayerNames = new Set();
1315
+ const appendSolidLayerNames = (value) => {
1316
+ if (!Array.isArray(value)) return;
1317
+ for (const entry of value) {
1318
+ if (typeof entry === 'string' && entry.length > 0) {
1319
+ solidLayerNames.add(entry);
1320
+ }
1321
+ }
1322
+ };
1323
+ appendSolidLayerNames(rawMap.solidLayerNames);
1324
+ appendSolidLayerNames(rawMap.solidLayers);
1325
+ const normalizedMap = {
1326
+ width: mapWidth,
1327
+ height: mapHeight,
1328
+ tileWidth,
1329
+ tileHeight,
1330
+ tilesets,
1331
+ layers: layers.tileLayers,
1332
+ objectLayers: layers.objectLayers,
1333
+ properties: tilemapNormalizeProperties(rawMap.properties, 'map'),
1334
+ solidLayerNames: [...solidLayerNames],
1335
+ };
1336
+ for (const layer of normalizedMap.layers) {
1337
+ if (solidLayerNames.has(layer.name)) {
1338
+ layer.queryEnabled = true;
1339
+ }
1340
+ }
1341
+ return normalizedMap;
1342
+ };
1343
+ const tilemapResolveMap = (mapId) => {
1344
+ const id = tilemapIntOr(mapId, -1);
1345
+ if (id <= 0) return null;
1346
+ return tilemaps.get(id) || null;
1347
+ };
1348
+ const tilemapResolveLayerRef = (map, layerRef) => {
1349
+ if (!map) return null;
1350
+ if (typeof layerRef === 'number' && Number.isInteger(layerRef)) {
1351
+ if (layerRef < 0 || layerRef >= map.layers.length) return null;
1352
+ return { layer: map.layers[layerRef], layerIndex: layerRef };
1353
+ }
1354
+ if (typeof layerRef === 'string') {
1355
+ const layerIndex = map.layers.findIndex((layer) => layer.name === layerRef);
1356
+ if (layerIndex < 0) return null;
1357
+ return { layer: map.layers[layerIndex], layerIndex };
1358
+ }
1359
+ return null;
1360
+ };
1361
+ const tilemapResolveLayerMutationTarget = (mapId, layerRef) => {
1362
+ const map = tilemapResolveMap(mapId);
1363
+ if (!map) {
1364
+ return { ok: false, error: 'invalid tilemap handle', reasonCode: 'invalid_map_handle' };
1365
+ }
1366
+ const resolved = tilemapResolveLayerRef(map, layerRef);
1367
+ if (!resolved) {
1368
+ return { ok: false, error: 'invalid tilemap layer', reasonCode: 'invalid_layer_ref' };
1369
+ }
1370
+ return {
1371
+ ok: true,
1372
+ map,
1373
+ mapId: tilemapIntOr(mapId, -1),
1374
+ layer: resolved.layer,
1375
+ layerIndex: resolved.layerIndex,
1376
+ };
1377
+ };
1378
+ const tilemapMutationFailResult = (error, reasonCode) => ({
1379
+ ok: false,
1380
+ error,
1381
+ reasonCode,
1382
+ mutatedTiles: 0,
1383
+ changedTiles: 0,
1384
+ });
1385
+ const tilemapMutationOkResult = (payload = {}) => ({
1386
+ ok: true,
1387
+ error: null,
1388
+ reasonCode: null,
1389
+ ...payload,
1390
+ });
1391
+ const tilemapParseBooleanFlag = (value) => (typeof value === 'boolean' ? value : null);
1392
+ const tilemapParseNonNegGid = (value) => {
1393
+ const gid = Number(value);
1394
+ if (!tilemapIsNonNegInt(gid)) return null;
1395
+ return gid;
1396
+ };
1397
+ const tilemapParseRegionArgs = (args, offset = 0) => {
1398
+ const value = args[offset];
1399
+ let x = null;
1400
+ let y = null;
1401
+ let w = null;
1402
+ let h = null;
1403
+ let nextIndex = offset;
1404
+ if (value && typeof value === 'object') {
1405
+ x = Number(value.x);
1406
+ y = Number(value.y);
1407
+ w = Number(value.w);
1408
+ h = Number(value.h);
1409
+ nextIndex = offset + 1;
1410
+ } else {
1411
+ x = Number(args[offset]);
1412
+ y = Number(args[offset + 1]);
1413
+ w = Number(args[offset + 2]);
1414
+ h = Number(args[offset + 3]);
1415
+ nextIndex = offset + 4;
1416
+ }
1417
+ if (!Number.isInteger(x) || !Number.isInteger(y) || !Number.isInteger(w) || !Number.isInteger(h) || w <= 0 || h <= 0) {
1418
+ return null;
1419
+ }
1420
+ return { x, y, w, h, nextIndex };
1421
+ };
1422
+ const tilemapApplyRegionMutation = (layer, region, mutateCell) => {
1423
+ const startX = Math.max(0, region.x);
1424
+ const startY = Math.max(0, region.y);
1425
+ const endX = Math.min(layer.width, region.x + region.w);
1426
+ const endY = Math.min(layer.height, region.y + region.h);
1427
+ let mutatedTiles = 0;
1428
+ let changedTiles = 0;
1429
+
1430
+ for (let y = startY; y < endY; y += 1) {
1431
+ for (let x = startX; x < endX; x += 1) {
1432
+ mutatedTiles += 1;
1433
+ const index = (y * layer.width) + x;
1434
+ const previousGid = layer.data[index];
1435
+ const nextGid = mutateCell(previousGid, x, y);
1436
+ if (nextGid === previousGid) continue;
1437
+ layer.data[index] = nextGid;
1438
+ changedTiles += 1;
1439
+ }
1440
+ }
1441
+
1442
+ return {
1443
+ mutatedTiles,
1444
+ changedTiles,
1445
+ requestedRegion: { x: region.x, y: region.y, w: region.w, h: region.h },
1446
+ appliedRegion: {
1447
+ x: startX,
1448
+ y: startY,
1449
+ w: Math.max(0, endX - startX),
1450
+ h: Math.max(0, endY - startY),
1451
+ },
1452
+ };
1453
+ };
1454
+ const tilemapBuildViewRect = (options) => {
1455
+ if (!options || typeof options !== 'object') return null;
1456
+ if (options.cull === false) return null;
1457
+ const camera = (options.camera && typeof options.camera === 'object')
1458
+ ? options.camera
1459
+ : ((options.view && typeof options.view === 'object') ? options.view : null);
1460
+ if (!camera) return null;
1461
+ const widthValue = tilemapFiniteOr(camera.width, Number.NaN);
1462
+ const heightValue = tilemapFiniteOr(camera.height, Number.NaN);
1463
+ if (!(widthValue > 0) || !(heightValue > 0)) return null;
1464
+ return {
1465
+ x: tilemapFiniteOr(camera.x, 0),
1466
+ y: tilemapFiniteOr(camera.y, 0),
1467
+ width: widthValue,
1468
+ height: heightValue,
1469
+ };
1470
+ };
1471
+ const tilemapIntersects = (x, y, w, h, view) => {
1472
+ if (!view) return true;
1473
+ return x < (view.x + view.width)
1474
+ && (x + w) > view.x
1475
+ && y < (view.y + view.height)
1476
+ && (y + h) > view.y;
1477
+ };
1478
+ const tilemapDrawLayerInternal = (map, mapId, layer, layerIndex, options) => {
1479
+ const includeHidden = options && options.includeHidden === true;
1480
+ const originX = options && Number.isFinite(options.x) ? Number(options.x) : 0;
1481
+ const originY = options && Number.isFinite(options.y) ? Number(options.y) : 0;
1482
+ const view = tilemapBuildViewRect(options);
1483
+ const stats = {
1484
+ mapId,
1485
+ layerName: layer.name,
1486
+ layerIndex,
1487
+ consideredTiles: 0,
1488
+ drawnTiles: 0,
1489
+ culledTiles: 0,
1490
+ };
1491
+ if (!includeHidden && (!layer.visible || layer.opacity <= 0)) {
1492
+ return stats;
1493
+ }
1494
+ for (let y = 0; y < layer.height; y += 1) {
1495
+ for (let x = 0; x < layer.width; x += 1) {
1496
+ const gid = layer.data[(y * layer.width) + x];
1497
+ if (!gid || gid <= 0) continue;
1498
+ stats.consideredTiles += 1;
1499
+ const drawX = originX + layer.offsetX + (x * map.tileWidth);
1500
+ const drawY = originY + layer.offsetY + (y * map.tileHeight);
1501
+ if (!tilemapIntersects(drawX, drawY, map.tileWidth, map.tileHeight, view)) {
1502
+ stats.culledTiles += 1;
1503
+ continue;
1504
+ }
1505
+ const tileset = tilemapResolveTilesetForGid(map.tilesets, gid);
1506
+ if (!tileset) continue;
1507
+ const localIndex = gid - tileset.firstGid;
1508
+ const frameX = tileset.margin + ((localIndex % tileset.columns) * (tileset.tileWidth + tileset.spacing));
1509
+ const frameY = tileset.margin + (Math.floor(localIndex / tileset.columns) * (tileset.tileHeight + tileset.spacing));
1510
+ stats.drawnTiles += 1;
1511
+ if (aura?.draw2d && typeof aura.draw2d.sprite === 'function') {
1512
+ aura.draw2d.sprite(
1513
+ { path: tileset.image },
1514
+ drawX,
1515
+ drawY,
1516
+ {
1517
+ width: map.tileWidth,
1518
+ height: map.tileHeight,
1519
+ frameX,
1520
+ frameY,
1521
+ frameW: tileset.tileWidth,
1522
+ frameH: tileset.tileHeight,
1523
+ },
1524
+ );
1525
+ }
1526
+ }
1527
+ }
1528
+ return stats;
1529
+ };
1530
+ const tilemap = {
1531
+ import: (source) => {
1532
+ const rawMap = tilemapResolveRawMap(source);
1533
+ const normalizedMap = tilemapNormalizeMap(rawMap);
1534
+ const id = nextTilemapId++;
1535
+ tilemaps.set(id, normalizedMap);
1536
+ return id;
1537
+ },
1538
+ unload: (mapId) => {
1539
+ const id = tilemapIntOr(mapId, -1);
1540
+ if (id <= 0) return false;
1541
+ return tilemaps.delete(id);
1542
+ },
1543
+ getInfo: (mapId) => {
1544
+ const map = tilemapResolveMap(mapId);
1545
+ if (!map) return null;
1546
+ const objectLayerCount = Array.isArray(map.objectLayers) ? map.objectLayers.length : 0;
1547
+ const objectCount = objectLayerCount > 0
1548
+ ? map.objectLayers.reduce((sum, layer) => sum + (Array.isArray(layer.objects) ? layer.objects.length : 0), 0)
1549
+ : 0;
1550
+ return {
1551
+ mapId: tilemapIntOr(mapId, -1),
1552
+ width: map.width,
1553
+ height: map.height,
1554
+ tileWidth: map.tileWidth,
1555
+ tileHeight: map.tileHeight,
1556
+ layerCount: map.layers.length,
1557
+ objectLayerCount,
1558
+ objectCount,
1559
+ tilesetCount: map.tilesets.length,
1560
+ mapPropertyCount: map.properties ? Object.keys(map.properties).length : 0,
1561
+ };
1562
+ },
1563
+ drawLayer: (mapId, layerRef, options = {}) => {
1564
+ const map = tilemapResolveMap(mapId);
1565
+ if (!map) return null;
1566
+ const resolved = tilemapResolveLayerRef(map, layerRef);
1567
+ if (!resolved) return null;
1568
+ return tilemapDrawLayerInternal(map, tilemapIntOr(mapId, -1), resolved.layer, resolved.layerIndex, options);
1569
+ },
1570
+ draw: (mapId, options = {}) => {
1571
+ const map = tilemapResolveMap(mapId);
1572
+ if (!map) return null;
1573
+ const includeHidden = options && options.includeHidden === true;
1574
+ const layers = [];
1575
+ const layerOrder = [];
1576
+ for (let i = 0; i < map.layers.length; i += 1) {
1577
+ const layer = map.layers[i];
1578
+ if (!includeHidden && (!layer.visible || layer.opacity <= 0)) continue;
1579
+ const layerStats = tilemapDrawLayerInternal(map, tilemapIntOr(mapId, -1), layer, i, options);
1580
+ layers.push(layerStats);
1581
+ layerOrder.push(layer.name);
1582
+ }
1583
+ let consideredTiles = 0;
1584
+ let drawnTiles = 0;
1585
+ let culledTiles = 0;
1586
+ for (const layerStats of layers) {
1587
+ consideredTiles += layerStats.consideredTiles;
1588
+ drawnTiles += layerStats.drawnTiles;
1589
+ culledTiles += layerStats.culledTiles;
1590
+ }
1591
+ return {
1592
+ mapId: tilemapIntOr(mapId, -1),
1593
+ layerOrder,
1594
+ consideredTiles,
1595
+ drawnTiles,
1596
+ culledTiles,
1597
+ layers,
1598
+ };
1599
+ },
1600
+ setRegion: (mapId, layerRef, ...args) => {
1601
+ const target = tilemapResolveLayerMutationTarget(mapId, layerRef);
1602
+ if (!target.ok) return tilemapMutationFailResult(target.error, target.reasonCode);
1603
+ const region = tilemapParseRegionArgs(args, 0);
1604
+ if (!region) return tilemapMutationFailResult('invalid tilemap region args', 'invalid_region_args');
1605
+ const gid = tilemapParseNonNegGid(args[region.nextIndex]);
1606
+ if (gid == null) return tilemapMutationFailResult('invalid tile gid args', 'invalid_gid_args');
1607
+ if (gid > 0 && !tilemapResolveTilesetForGid(target.map.tilesets, gid)) {
1608
+ return tilemapMutationFailResult('invalid tile gid args', 'unmapped_layer_gid');
1609
+ }
1610
+ const mutation = tilemapApplyRegionMutation(target.layer, region, () => gid);
1611
+ return tilemapMutationOkResult({
1612
+ mapId: target.mapId,
1613
+ layerName: target.layer.name,
1614
+ layerIndex: target.layerIndex,
1615
+ gid,
1616
+ ...mutation,
1617
+ });
1618
+ },
1619
+ removeRegion: (mapId, layerRef, ...args) => {
1620
+ const target = tilemapResolveLayerMutationTarget(mapId, layerRef);
1621
+ if (!target.ok) return tilemapMutationFailResult(target.error, target.reasonCode);
1622
+ const region = tilemapParseRegionArgs(args, 0);
1623
+ if (!region) return tilemapMutationFailResult('invalid tilemap region args', 'invalid_region_args');
1624
+ const mutation = tilemapApplyRegionMutation(target.layer, region, () => 0);
1625
+ return tilemapMutationOkResult({
1626
+ mapId: target.mapId,
1627
+ layerName: target.layer.name,
1628
+ layerIndex: target.layerIndex,
1629
+ gid: 0,
1630
+ ...mutation,
1631
+ });
1632
+ },
1633
+ replaceRegion: (mapId, layerRef, ...args) => {
1634
+ const target = tilemapResolveLayerMutationTarget(mapId, layerRef);
1635
+ if (!target.ok) return tilemapMutationFailResult(target.error, target.reasonCode);
1636
+ const region = tilemapParseRegionArgs(args, 0);
1637
+ if (!region) return tilemapMutationFailResult('invalid tilemap region args', 'invalid_region_args');
1638
+ const fromGid = tilemapParseNonNegGid(args[region.nextIndex]);
1639
+ const toGid = tilemapParseNonNegGid(args[region.nextIndex + 1]);
1640
+ if (fromGid == null || toGid == null) {
1641
+ return tilemapMutationFailResult('invalid replace gid args', 'invalid_replace_args');
1642
+ }
1643
+ if (toGid > 0 && !tilemapResolveTilesetForGid(target.map.tilesets, toGid)) {
1644
+ return tilemapMutationFailResult('invalid replace gid args', 'unmapped_layer_gid');
1645
+ }
1646
+ const mutation = tilemapApplyRegionMutation(
1647
+ target.layer,
1648
+ region,
1649
+ (previousGid) => (previousGid === fromGid ? toGid : previousGid),
1650
+ );
1651
+ return tilemapMutationOkResult({
1652
+ mapId: target.mapId,
1653
+ layerName: target.layer.name,
1654
+ layerIndex: target.layerIndex,
1655
+ fromGid,
1656
+ toGid,
1657
+ replacedTiles: mutation.changedTiles,
1658
+ ...mutation,
1659
+ });
1660
+ },
1661
+ setLayerFlags: (mapId, layerRef, flags = {}) => {
1662
+ const target = tilemapResolveLayerMutationTarget(mapId, layerRef);
1663
+ if (!target.ok) return tilemapMutationFailResult(target.error, target.reasonCode);
1664
+ if (!flags || typeof flags !== 'object') {
1665
+ return tilemapMutationFailResult('invalid layer flags args', 'invalid_layer_flags');
1666
+ }
1667
+ const hasVisible = Object.hasOwn(flags, 'visible');
1668
+ const hasCollision = Object.hasOwn(flags, 'collision');
1669
+ if (!hasVisible && !hasCollision) {
1670
+ return tilemapMutationFailResult('invalid layer flags args', 'invalid_layer_flags');
1671
+ }
1672
+
1673
+ const nextVisible = hasVisible ? tilemapParseBooleanFlag(flags.visible) : target.layer.visible;
1674
+ const nextCollision = hasCollision ? tilemapParseBooleanFlag(flags.collision) : (target.layer.queryEnabled !== false);
1675
+ if (hasVisible && nextVisible == null) {
1676
+ return tilemapMutationFailResult('invalid layer visibility flag', 'invalid_visibility_flag');
1677
+ }
1678
+ if (hasCollision && nextCollision == null) {
1679
+ return tilemapMutationFailResult('invalid layer collision flag', 'invalid_collision_flag');
1680
+ }
1681
+
1682
+ const changed = (target.layer.visible !== nextVisible)
1683
+ || ((target.layer.queryEnabled !== false) !== nextCollision);
1684
+ target.layer.visible = nextVisible;
1685
+ target.layer.collision = nextCollision;
1686
+ target.layer.queryEnabled = nextCollision;
1687
+
1688
+ return tilemapMutationOkResult({
1689
+ mapId: target.mapId,
1690
+ layerName: target.layer.name,
1691
+ layerIndex: target.layerIndex,
1692
+ visible: target.layer.visible,
1693
+ collision: target.layer.queryEnabled !== false,
1694
+ changed,
1695
+ mutatedTiles: 0,
1696
+ changedTiles: 0,
1697
+ });
1698
+ },
1699
+ setLayerVisibility: (mapId, layerRef, visible) => tilemap.setLayerFlags(
1700
+ mapId,
1701
+ layerRef,
1702
+ { visible },
1703
+ ),
1704
+ setLayerCollision: (mapId, layerRef, collision) => tilemap.setLayerFlags(
1705
+ mapId,
1706
+ layerRef,
1707
+ { collision },
1708
+ ),
1709
+ };
1710
+
1711
+ let nextAnimationTimelineId = 1;
1712
+ let nextAnimationListenerSeq = 1;
1713
+ const animationTimelines = new Map();
1714
+ const ANIMATION_PHASE_TRANSITION_COMPLETE = 0;
1715
+ const ANIMATION_PHASE_MARKER_EVENT = 1;
1716
+ const ANIMATION_PHASE_CLIP_COMPLETE = 2;
1717
+ const animationResultOk = (extra = {}) => ({ ok: true, reason: null, ...extra });
1718
+ const animationResultErr = (reason) => ({ ok: false, reason });
1719
+ const animationIsValidTimelineId = (value) => Number.isInteger(value) && value > 0;
1720
+ const animationNormalizeDuration = (value) => (
1721
+ Number.isFinite(value) && Number(value) > 0 ? Number(value) : null
1722
+ );
1723
+ const animationNormalizeTime = (value) => (
1724
+ Number.isFinite(value) && Number(value) >= 0 ? Number(value) : null
1725
+ );
1726
+ const animationNormalizeSpeed = (value) => (
1727
+ Number.isFinite(value) && Number(value) >= 0 ? Number(value) : null
1728
+ );
1729
+ const animationNormalizeOrder = (value) => (
1730
+ Number.isFinite(value) ? Number(value) : 0
1731
+ );
1732
+ const animationNormalizeTransitionDuration = (value) => (
1733
+ Number.isFinite(value) && Number(value) > 0 ? Number(value) : null
1734
+ );
1735
+ const animationNormalizeEventTag = (value) => (
1736
+ typeof value === 'string' && value.length > 0 ? value : null
1737
+ );
1738
+ const animationClampTime = (time, duration) => Math.max(0, Math.min(time, duration));
1739
+ const animationSortedTimelineIds = () => [...animationTimelines.keys()].sort((a, b) => a - b);
1740
+ const animationResolveTimeline = (timelineId) => {
1741
+ if (!animationIsValidTimelineId(timelineId)) {
1742
+ return { timeline: null, reason: 'invalid_timeline_id' };
1743
+ }
1744
+ const timeline = animationTimelines.get(timelineId);
1745
+ if (!timeline) {
1746
+ return { timeline: null, reason: 'missing_timeline' };
1747
+ }
1748
+ return { timeline, reason: null };
1749
+ };
1750
+ const animationTransitionSnapshot = (transition) => {
1751
+ if (!transition) return null;
1752
+ const blend = transition.duration > 0
1753
+ ? Math.max(0, Math.min(1, transition.elapsed / transition.duration))
1754
+ : 1;
1755
+ return {
1756
+ active: true,
1757
+ duration: transition.duration,
1758
+ elapsed: transition.elapsed,
1759
+ targetTime: transition.targetTime,
1760
+ blend,
1761
+ paused: transition.paused,
1762
+ };
1763
+ };
1764
+ const animationSnapshot = (timeline) => ({
1765
+ timelineId: timeline.id,
1766
+ duration: timeline.duration,
1767
+ time: timeline.time,
1768
+ normalizedTime: timeline.duration > 0 ? timeline.time / timeline.duration : 0,
1769
+ playing: timeline.playing,
1770
+ loop: timeline.loop,
1771
+ speed: timeline.speed,
1772
+ completed: timeline.completed,
1773
+ loops: timeline.loops,
1774
+ transition: animationTransitionSnapshot(timeline.transition),
1775
+ });
1776
+ const animationEnqueueCallbacks = (pending, timeline, phase, callbacks, payload) => {
1777
+ for (const callback of callbacks) {
1778
+ pending.push({
1779
+ timelineId: timeline.id,
1780
+ phase,
1781
+ order: callback.order,
1782
+ seq: callback.seq,
1783
+ fn: callback.fn,
1784
+ payload,
1785
+ });
1786
+ }
1787
+ };
1788
+ const animationSortPendingCallbacks = (pending) => {
1789
+ pending.sort((a, b) => (
1790
+ (a.timelineId - b.timelineId)
1791
+ || (a.phase - b.phase)
1792
+ || (a.order - b.order)
1793
+ || (a.seq - b.seq)
1794
+ ));
1795
+ };
1796
+ const animationDispatchPendingCallbacks = (pending) => {
1797
+ for (const entry of pending) {
1798
+ try {
1799
+ entry.fn(entry.payload);
1800
+ } catch (_) {
1801
+ // callback exceptions are swallowed to preserve deterministic progression
1802
+ }
1803
+ }
1804
+ };
1805
+ let nextAtlasId = 1;
1806
+ let nextAtlasClipId = 1;
1807
+ const animationAtlases = new Map();
1808
+ const animationAtlasClips = new Map();
1809
+ const ANIMATION_ATLAS_DEFAULT_FRAME_DURATION = 1 / 12;
1810
+ const ANIMATION_ATLAS_EPSILON = 1e-9;
1811
+ const ANIMATION_ATLAS_MAX_STEP_ITERATIONS = 2048;
1812
+ const animationIsPlainObject = (value) => (
1813
+ !!value && typeof value === 'object' && !Array.isArray(value)
1814
+ );
1815
+ const animationNormalizeAtlasImage = (value) => {
1816
+ if (typeof value !== 'string') return null;
1817
+ const trimmed = value.trim();
1818
+ return trimmed.length > 0 ? trimmed : null;
1819
+ };
1820
+ const animationNormalizeAtlasKey = (value) => (
1821
+ typeof value === 'string' && value.length > 0 ? value : null
1822
+ );
1823
+ const animationNormalizeAtlasFrameDuration = (value) => (
1824
+ Number.isFinite(value) && Number(value) > 0 ? Number(value) : null
1825
+ );
1826
+ const animationNormalizeAtlasFrameRect = (value) => {
1827
+ if (!animationIsPlainObject(value)) return null;
1828
+ const x = Number(value.x);
1829
+ const y = Number(value.y);
1830
+ const w = Number(value.w);
1831
+ const h = Number(value.h);
1832
+ if (![x, y, w, h].every((entry) => Number.isFinite(entry))) return null;
1833
+ if (x < 0 || y < 0 || w <= 0 || h <= 0) return null;
1834
+ return { x, y, w, h };
1835
+ };
1836
+ const animationParseAtlasFrameEntry = (entry) => {
1837
+ if (!animationIsPlainObject(entry)) return { ok: false, reason: 'invalid_atlas_frame_entry' };
1838
+ if (entry.rotated === true || entry.trimmed === true) {
1839
+ return { ok: false, reason: 'unsupported_atlas_frame_flags' };
1840
+ }
1841
+ const rectSource = animationIsPlainObject(entry.frame) ? entry.frame : entry;
1842
+ const rect = animationNormalizeAtlasFrameRect(rectSource);
1843
+ if (!rect) return { ok: false, reason: 'invalid_atlas_frame_rect' };
1844
+ const hasDuration = Object.prototype.hasOwnProperty.call(entry, 'duration');
1845
+ const duration = hasDuration
1846
+ ? animationNormalizeAtlasFrameDuration(entry.duration)
1847
+ : null;
1848
+ if (hasDuration && duration == null) {
1849
+ return { ok: false, reason: 'invalid_atlas_frame_duration' };
1850
+ }
1851
+ return { ok: true, rect, duration };
1852
+ };
1853
+ const animationParseAtlasFrames = (value) => {
1854
+ if (!animationIsPlainObject(value)) return { ok: false, reason: 'invalid_atlas_frames' };
1855
+ const entries = Object.entries(value);
1856
+ if (entries.length === 0) return { ok: false, reason: 'invalid_atlas_frames' };
1857
+ const frames = new Map();
1858
+ const frameKeys = [];
1859
+ for (const [rawKey, rawEntry] of entries) {
1860
+ const frameKey = animationNormalizeAtlasKey(rawKey);
1861
+ if (frameKey == null) return { ok: false, reason: 'invalid_atlas_frame_key' };
1862
+ const parsedEntry = animationParseAtlasFrameEntry(rawEntry);
1863
+ if (!parsedEntry.ok) return parsedEntry;
1864
+ frames.set(frameKey, {
1865
+ key: frameKey,
1866
+ x: parsedEntry.rect.x,
1867
+ y: parsedEntry.rect.y,
1868
+ w: parsedEntry.rect.w,
1869
+ h: parsedEntry.rect.h,
1870
+ duration: parsedEntry.duration,
1871
+ });
1872
+ frameKeys.push(frameKey);
1873
+ }
1874
+ return { ok: true, frames, frameKeys };
1875
+ };
1876
+ const animationParseAtlasClipFrames = (value, frames) => {
1877
+ if (!Array.isArray(value) || value.length === 0) {
1878
+ return { ok: false, reason: 'invalid_atlas_clip_frames' };
1879
+ }
1880
+ const keys = [];
1881
+ for (const rawEntry of value) {
1882
+ const key = animationNormalizeAtlasKey(rawEntry);
1883
+ if (key == null) return { ok: false, reason: 'invalid_atlas_clip_frames' };
1884
+ if (!frames.has(key)) return { ok: false, reason: 'missing_atlas_frame' };
1885
+ keys.push(key);
1886
+ }
1887
+ return { ok: true, keys };
1888
+ };
1889
+ const animationParseAtlasClips = (value, frames) => {
1890
+ if (value == null) return { ok: true, clips: new Map() };
1891
+ if (!animationIsPlainObject(value)) return { ok: false, reason: 'invalid_atlas_clips' };
1892
+ const clips = new Map();
1893
+ for (const [rawClipKey, rawClip] of Object.entries(value)) {
1894
+ const clipKey = animationNormalizeAtlasKey(rawClipKey);
1895
+ if (clipKey == null) return { ok: false, reason: 'invalid_atlas_clip_key' };
1896
+ if (!animationIsPlainObject(rawClip)) return { ok: false, reason: 'invalid_atlas_clip' };
1897
+ const frameParse = animationParseAtlasClipFrames(rawClip.frames, frames);
1898
+ if (!frameParse.ok) return frameParse;
1899
+ let frameDuration = null;
1900
+ if (Object.prototype.hasOwnProperty.call(rawClip, 'frameDuration')) {
1901
+ frameDuration = animationNormalizeAtlasFrameDuration(rawClip.frameDuration);
1902
+ if (frameDuration == null) return { ok: false, reason: 'invalid_atlas_clip_duration' };
1903
+ }
1904
+ const loop = Object.prototype.hasOwnProperty.call(rawClip, 'loop')
1905
+ ? rawClip.loop
1906
+ : true;
1907
+ if (typeof loop !== 'boolean') return { ok: false, reason: 'invalid_atlas_clip_loop' };
1908
+ clips.set(clipKey, {
1909
+ key: clipKey,
1910
+ frames: frameParse.keys,
1911
+ frameDuration,
1912
+ loop,
1913
+ });
1914
+ }
1915
+ return { ok: true, clips };
1916
+ };
1917
+ const animationResolveAtlas = (atlasId) => {
1918
+ if (!Number.isInteger(atlasId) || atlasId <= 0) {
1919
+ return { atlas: null, reason: 'invalid_atlas_id' };
1920
+ }
1921
+ const atlas = animationAtlases.get(atlasId);
1922
+ if (!atlas) return { atlas: null, reason: 'missing_atlas' };
1923
+ return { atlas, reason: null };
1924
+ };
1925
+ const animationResolveAtlasFrame = (atlas, frameKey) => {
1926
+ const normalizedFrameKey = animationNormalizeAtlasKey(frameKey);
1927
+ if (normalizedFrameKey == null) return { frame: null, reason: 'invalid_frame_key' };
1928
+ const frame = atlas.frames.get(normalizedFrameKey);
1929
+ if (!frame) return { frame: null, reason: 'missing_frame' };
1930
+ return { frame, reason: null };
1931
+ };
1932
+ const animationAtlasFrameSnapshot = (atlas, frameKey) => {
1933
+ const frame = atlas.frames.get(frameKey);
1934
+ if (!frame) return null;
1935
+ const duration = frame.duration != null
1936
+ ? frame.duration
1937
+ : ANIMATION_ATLAS_DEFAULT_FRAME_DURATION;
1938
+ return {
1939
+ atlasId: atlas.id,
1940
+ frameKey,
1941
+ image: atlas.image,
1942
+ frameX: frame.x,
1943
+ frameY: frame.y,
1944
+ frameW: frame.w,
1945
+ frameH: frame.h,
1946
+ duration,
1947
+ };
1948
+ };
1949
+ const animationResolveAtlasClip = (clipId) => {
1950
+ if (!Number.isInteger(clipId) || clipId <= 0) {
1951
+ return { clip: null, reason: 'invalid_clip_id' };
1952
+ }
1953
+ const clip = animationAtlasClips.get(clipId);
1954
+ if (!clip) return { clip: null, reason: 'missing_clip' };
1955
+ return { clip, reason: null };
1956
+ };
1957
+ const animationAtlasClipFrameDuration = (clip, frameKey) => {
1958
+ if (clip.frameDuration != null) return clip.frameDuration;
1959
+ const frame = clip.atlas.frames.get(frameKey);
1960
+ if (frame && frame.duration != null) return frame.duration;
1961
+ return ANIMATION_ATLAS_DEFAULT_FRAME_DURATION;
1962
+ };
1963
+ const animationAtlasClipSnapshot = (clip) => {
1964
+ if (!clip || clip.frameKeys.length === 0) return null;
1965
+ const boundedFrameIndex = Math.max(0, Math.min(clip.frameIndex, clip.frameKeys.length - 1));
1966
+ const frameKey = clip.frameKeys[boundedFrameIndex];
1967
+ const frame = clip.atlas.frames.get(frameKey);
1968
+ if (!frame) return null;
1969
+ const frameDuration = animationAtlasClipFrameDuration(clip, frameKey);
1970
+ return {
1971
+ clipId: clip.id,
1972
+ atlasId: clip.atlas.id,
1973
+ clipKey: clip.clipKey,
1974
+ image: clip.atlas.image,
1975
+ frameKey,
1976
+ frameIndex: boundedFrameIndex,
1977
+ frameCount: clip.frameKeys.length,
1978
+ frameX: frame.x,
1979
+ frameY: frame.y,
1980
+ frameW: frame.w,
1981
+ frameH: frame.h,
1982
+ frameDuration,
1983
+ elapsedInFrame: Math.max(0, Math.min(clip.elapsed, frameDuration)),
1984
+ playing: clip.playing,
1985
+ loop: clip.loop,
1986
+ completed: clip.completed,
1987
+ loops: clip.loops,
1988
+ };
1989
+ };
1990
+ const animation = {
1991
+ create: (options = {}) => {
1992
+ if (!options || typeof options !== 'object' || Array.isArray(options)) {
1993
+ return animationResultErr('invalid_options');
1994
+ }
1995
+
1996
+ const duration = animationNormalizeDuration(options.duration);
1997
+ if (duration == null) return animationResultErr('invalid_duration');
1998
+
1999
+ const time = Object.prototype.hasOwnProperty.call(options, 'time')
2000
+ ? animationNormalizeTime(options.time)
2001
+ : 0;
2002
+ if (time == null) return animationResultErr('invalid_time');
2003
+
2004
+ const loop = Object.prototype.hasOwnProperty.call(options, 'loop')
2005
+ ? options.loop
2006
+ : false;
2007
+ if (typeof loop !== 'boolean') return animationResultErr('invalid_loop_flag');
2008
+
2009
+ const speed = Object.prototype.hasOwnProperty.call(options, 'speed')
2010
+ ? animationNormalizeSpeed(options.speed)
2011
+ : 1;
2012
+ if (speed == null) return animationResultErr('invalid_speed');
2013
+
2014
+ const playing = Object.prototype.hasOwnProperty.call(options, 'playing')
2015
+ ? options.playing
2016
+ : false;
2017
+ if (typeof playing !== 'boolean') return animationResultErr('invalid_playing_flag');
2018
+
2019
+ const timelineId = nextAnimationTimelineId++;
2020
+ const timeline = {
2021
+ id: timelineId,
2022
+ duration,
2023
+ time: animationClampTime(time, duration),
2024
+ loop,
2025
+ speed,
2026
+ playing,
2027
+ completed: false,
2028
+ loops: 0,
2029
+ completionNotified: false,
2030
+ transition: null,
2031
+ completeCallbacks: [],
2032
+ eventCallbacks: [],
2033
+ };
2034
+
2035
+ if (!timeline.loop && timeline.time >= timeline.duration) {
2036
+ timeline.time = timeline.duration;
2037
+ timeline.completed = true;
2038
+ timeline.playing = false;
2039
+ timeline.completionNotified = true;
2040
+ }
2041
+
2042
+ animationTimelines.set(timelineId, timeline);
2043
+ return animationResultOk({ timelineId });
2044
+ },
2045
+ play: (timelineId) => {
2046
+ const resolved = animationResolveTimeline(timelineId);
2047
+ if (!resolved.timeline) return animationResultErr(resolved.reason);
2048
+
2049
+ const timeline = resolved.timeline;
2050
+ if (!timeline.loop && timeline.completed && timeline.time >= timeline.duration) {
2051
+ timeline.time = 0;
2052
+ timeline.completed = false;
2053
+ timeline.completionNotified = false;
2054
+ }
2055
+ timeline.playing = true;
2056
+ if (timeline.transition) timeline.transition.paused = false;
2057
+ return animationResultOk({ timelineId: timeline.id });
2058
+ },
2059
+ pause: (timelineId) => {
2060
+ const resolved = animationResolveTimeline(timelineId);
2061
+ if (!resolved.timeline) return animationResultErr(resolved.reason);
2062
+ const timeline = resolved.timeline;
2063
+ timeline.playing = false;
2064
+ if (timeline.transition) timeline.transition.paused = true;
2065
+ return animationResultOk({ timelineId: timeline.id });
2066
+ },
2067
+ resume: (timelineId) => {
2068
+ const resolved = animationResolveTimeline(timelineId);
2069
+ if (!resolved.timeline) return animationResultErr(resolved.reason);
2070
+ const timeline = resolved.timeline;
2071
+ timeline.playing = true;
2072
+ if (timeline.transition) timeline.transition.paused = false;
2073
+ return animationResultOk({ timelineId: timeline.id });
2074
+ },
2075
+ seek: (timelineId, time) => {
2076
+ const resolved = animationResolveTimeline(timelineId);
2077
+ if (!resolved.timeline) return animationResultErr(resolved.reason);
2078
+ const normalizedTime = animationNormalizeTime(time);
2079
+ if (normalizedTime == null) return animationResultErr('invalid_time');
2080
+
2081
+ const timeline = resolved.timeline;
2082
+ timeline.time = animationClampTime(normalizedTime, timeline.duration);
2083
+ timeline.transition = null;
2084
+ if (!timeline.loop && timeline.time >= timeline.duration) {
2085
+ timeline.completed = true;
2086
+ timeline.playing = false;
2087
+ timeline.completionNotified = true;
2088
+ } else {
2089
+ timeline.completed = false;
2090
+ timeline.completionNotified = false;
2091
+ }
2092
+ return animationResultOk({ timelineId: timeline.id, time: timeline.time });
2093
+ },
2094
+ transition: (timelineId, options = {}) => {
2095
+ const resolved = animationResolveTimeline(timelineId);
2096
+ if (!resolved.timeline) return animationResultErr(resolved.reason);
2097
+ if (!options || typeof options !== 'object' || Array.isArray(options)) {
2098
+ return animationResultErr('invalid_transition_options');
2099
+ }
2100
+
2101
+ const duration = animationNormalizeTransitionDuration(options.duration);
2102
+ if (duration == null) return animationResultErr('invalid_transition_duration');
2103
+ const targetTime = Object.prototype.hasOwnProperty.call(options, 'targetTime')
2104
+ ? animationNormalizeTime(options.targetTime)
2105
+ : resolved.timeline.time;
2106
+ if (targetTime == null) return animationResultErr('invalid_time');
2107
+ const eventTag = Object.prototype.hasOwnProperty.call(options, 'eventTag')
2108
+ ? animationNormalizeEventTag(options.eventTag)
2109
+ : null;
2110
+
2111
+ const timeline = resolved.timeline;
2112
+ timeline.transition = {
2113
+ startTime: timeline.time,
2114
+ targetTime: animationClampTime(targetTime, timeline.duration),
2115
+ duration,
2116
+ elapsed: 0,
2117
+ paused: !timeline.playing,
2118
+ eventTag,
2119
+ };
2120
+ timeline.completed = false;
2121
+ timeline.completionNotified = false;
2122
+ return animationResultOk({
2123
+ timelineId: timeline.id,
2124
+ targetTime: timeline.transition.targetTime,
2125
+ duration: timeline.transition.duration,
2126
+ });
2127
+ },
2128
+ onComplete: (timelineId, callback, order = 0) => {
2129
+ const resolved = animationResolveTimeline(timelineId);
2130
+ if (!resolved.timeline) return animationResultErr(resolved.reason);
2131
+ if (typeof callback !== 'function') return animationResultErr('invalid_callback');
2132
+ const listenerId = nextAnimationListenerSeq++;
2133
+ resolved.timeline.completeCallbacks.push({
2134
+ id: listenerId,
2135
+ fn: callback,
2136
+ order: animationNormalizeOrder(order),
2137
+ seq: listenerId,
2138
+ });
2139
+ return animationResultOk({ timelineId: resolved.timeline.id, listenerId });
2140
+ },
2141
+ onEvent: (timelineId, callback, order = 0) => {
2142
+ const resolved = animationResolveTimeline(timelineId);
2143
+ if (!resolved.timeline) return animationResultErr(resolved.reason);
2144
+ if (typeof callback !== 'function') return animationResultErr('invalid_callback');
2145
+ const listenerId = nextAnimationListenerSeq++;
2146
+ resolved.timeline.eventCallbacks.push({
2147
+ id: listenerId,
2148
+ fn: callback,
2149
+ order: animationNormalizeOrder(order),
2150
+ seq: listenerId,
2151
+ });
2152
+ return animationResultOk({ timelineId: resolved.timeline.id, listenerId });
2153
+ },
2154
+ setLoop: (timelineId, loop) => {
2155
+ const resolved = animationResolveTimeline(timelineId);
2156
+ if (!resolved.timeline) return animationResultErr(resolved.reason);
2157
+ if (typeof loop !== 'boolean') return animationResultErr('invalid_loop_flag');
2158
+
2159
+ const timeline = resolved.timeline;
2160
+ timeline.loop = loop;
2161
+ if (!timeline.loop && timeline.time >= timeline.duration) {
2162
+ timeline.time = timeline.duration;
2163
+ timeline.completed = true;
2164
+ timeline.playing = false;
2165
+ timeline.completionNotified = true;
2166
+ } else if (timeline.loop && timeline.time >= timeline.duration) {
2167
+ timeline.time = timeline.duration > 0 ? (timeline.time % timeline.duration) : 0;
2168
+ timeline.completed = false;
2169
+ timeline.completionNotified = false;
2170
+ }
2171
+ return animationResultOk({ timelineId: timeline.id, loop: timeline.loop });
2172
+ },
2173
+ setSpeed: (timelineId, speed) => {
2174
+ const resolved = animationResolveTimeline(timelineId);
2175
+ if (!resolved.timeline) return animationResultErr(resolved.reason);
2176
+ const normalizedSpeed = animationNormalizeSpeed(speed);
2177
+ if (normalizedSpeed == null) return animationResultErr('invalid_speed');
2178
+ resolved.timeline.speed = normalizedSpeed;
2179
+ return animationResultOk({ timelineId: resolved.timeline.id, speed: resolved.timeline.speed });
2180
+ },
2181
+ update: (dt) => {
2182
+ const stepDt = Number.isFinite(dt) && Number(dt) > 0 ? Number(dt) : null;
2183
+ if (stepDt == null) return animationResultErr('invalid_dt');
2184
+ const pendingCallbacks = [];
2185
+
2186
+ for (const timelineId of animationSortedTimelineIds()) {
2187
+ const timeline = animationTimelines.get(timelineId);
2188
+ if (!timeline) continue;
2189
+ if (timeline.transition) {
2190
+ if (
2191
+ timeline.playing &&
2192
+ !timeline.transition.paused &&
2193
+ timeline.speed > 0
2194
+ ) {
2195
+ const transitionAdvanced = stepDt * timeline.speed;
2196
+ if (transitionAdvanced > 0) {
2197
+ const transition = timeline.transition;
2198
+ transition.elapsed = Math.min(
2199
+ transition.duration,
2200
+ transition.elapsed + transitionAdvanced,
2201
+ );
2202
+ const blend = transition.duration > 0
2203
+ ? (transition.elapsed / transition.duration)
2204
+ : 1;
2205
+ timeline.time = animationClampTime(
2206
+ transition.startTime
2207
+ + ((transition.targetTime - transition.startTime) * blend),
2208
+ timeline.duration,
2209
+ );
2210
+ timeline.completed = false;
2211
+ if (blend >= 1) {
2212
+ timeline.transition = null;
2213
+ animationEnqueueCallbacks(
2214
+ pendingCallbacks,
2215
+ timeline,
2216
+ ANIMATION_PHASE_TRANSITION_COMPLETE,
2217
+ timeline.eventCallbacks,
2218
+ {
2219
+ type: 'transition_complete',
2220
+ timelineId: timeline.id,
2221
+ time: timeline.time,
2222
+ targetTime: transition.targetTime,
2223
+ tag: transition.eventTag,
2224
+ },
2225
+ );
2226
+ if (transition.eventTag != null) {
2227
+ animationEnqueueCallbacks(
2228
+ pendingCallbacks,
2229
+ timeline,
2230
+ ANIMATION_PHASE_MARKER_EVENT,
2231
+ timeline.eventCallbacks,
2232
+ {
2233
+ type: 'marker',
2234
+ timelineId: timeline.id,
2235
+ time: timeline.time,
2236
+ tag: transition.eventTag,
2237
+ },
2238
+ );
2239
+ }
2240
+ if (!timeline.loop && timeline.time >= timeline.duration) {
2241
+ timeline.completed = true;
2242
+ timeline.playing = false;
2243
+ if (!timeline.completionNotified) {
2244
+ timeline.completionNotified = true;
2245
+ animationEnqueueCallbacks(
2246
+ pendingCallbacks,
2247
+ timeline,
2248
+ ANIMATION_PHASE_CLIP_COMPLETE,
2249
+ timeline.completeCallbacks,
2250
+ animationSnapshot(timeline),
2251
+ );
2252
+ }
2253
+ }
2254
+ }
2255
+ }
2256
+ }
2257
+ if (timeline.transition) continue;
2258
+ }
2259
+ if (!timeline.playing || timeline.speed <= 0) continue;
2260
+
2261
+ const advanced = stepDt * timeline.speed;
2262
+ if (!(advanced > 0)) continue;
2263
+
2264
+ if (timeline.loop) {
2265
+ const next = timeline.time + advanced;
2266
+ const wraps = timeline.duration > 0
2267
+ ? Math.floor(next / timeline.duration)
2268
+ : 0;
2269
+ timeline.time = timeline.duration > 0
2270
+ ? (next % timeline.duration)
2271
+ : 0;
2272
+ if (wraps > 0) {
2273
+ timeline.loops += wraps;
2274
+ animationEnqueueCallbacks(
2275
+ pendingCallbacks,
2276
+ timeline,
2277
+ ANIMATION_PHASE_MARKER_EVENT,
2278
+ timeline.eventCallbacks,
2279
+ {
2280
+ type: 'loop',
2281
+ timelineId: timeline.id,
2282
+ loops: wraps,
2283
+ time: timeline.time,
2284
+ tag: null,
2285
+ },
2286
+ );
2287
+ }
2288
+ timeline.completed = false;
2289
+ timeline.completionNotified = false;
2290
+ continue;
2291
+ }
2292
+
2293
+ timeline.time = animationClampTime(timeline.time + advanced, timeline.duration);
2294
+ if (timeline.time >= timeline.duration) {
2295
+ timeline.completed = true;
2296
+ timeline.playing = false;
2297
+ if (!timeline.completionNotified) {
2298
+ timeline.completionNotified = true;
2299
+ animationEnqueueCallbacks(
2300
+ pendingCallbacks,
2301
+ timeline,
2302
+ ANIMATION_PHASE_CLIP_COMPLETE,
2303
+ timeline.completeCallbacks,
2304
+ animationSnapshot(timeline),
2305
+ );
2306
+ }
2307
+ } else {
2308
+ timeline.completed = false;
2309
+ timeline.completionNotified = false;
2310
+ }
2311
+ }
2312
+
2313
+ animationSortPendingCallbacks(pendingCallbacks);
2314
+ animationDispatchPendingCallbacks(pendingCallbacks);
2315
+ return animationResultOk({ dispatchedCallbacks: pendingCallbacks.length });
2316
+ },
2317
+ getState: (timelineId) => {
2318
+ const resolved = animationResolveTimeline(timelineId);
2319
+ if (!resolved.timeline) return null;
2320
+ return animationSnapshot(resolved.timeline);
2321
+ },
2322
+ registerAtlas: (payload) => {
2323
+ if (!animationIsPlainObject(payload)) return animationResultErr('invalid_atlas_payload');
2324
+ const image = animationNormalizeAtlasImage(payload.image);
2325
+ if (image == null) return animationResultErr('invalid_atlas_image');
2326
+ const parsedFrames = animationParseAtlasFrames(payload.frames);
2327
+ if (!parsedFrames.ok) return animationResultErr(parsedFrames.reason);
2328
+ const parsedClips = animationParseAtlasClips(payload.clips, parsedFrames.frames);
2329
+ if (!parsedClips.ok) return animationResultErr(parsedClips.reason);
2330
+
2331
+ const atlasId = nextAtlasId++;
2332
+ animationAtlases.set(atlasId, {
2333
+ id: atlasId,
2334
+ image,
2335
+ frames: parsedFrames.frames,
2336
+ frameKeys: parsedFrames.frameKeys,
2337
+ clips: parsedClips.clips,
2338
+ });
2339
+
2340
+ return animationResultOk({
2341
+ atlasId,
2342
+ frameCount: parsedFrames.frameKeys.length,
2343
+ clipCount: parsedClips.clips.size,
2344
+ });
2345
+ },
2346
+ resolveAtlasFrame: (atlasId, frameKey) => {
2347
+ const resolvedAtlas = animationResolveAtlas(atlasId);
2348
+ if (!resolvedAtlas.atlas) return animationResultErr(resolvedAtlas.reason);
2349
+ const resolvedFrame = animationResolveAtlasFrame(resolvedAtlas.atlas, frameKey);
2350
+ if (!resolvedFrame.frame) return animationResultErr(resolvedFrame.reason);
2351
+ const snapshot = animationAtlasFrameSnapshot(resolvedAtlas.atlas, resolvedFrame.frame.key);
2352
+ return animationResultOk(snapshot || {});
2353
+ },
2354
+ createAtlasClip: (options = {}) => {
2355
+ if (!animationIsPlainObject(options)) return animationResultErr('invalid_clip_options');
2356
+ const resolvedAtlas = animationResolveAtlas(options.atlasId);
2357
+ if (!resolvedAtlas.atlas) return animationResultErr(resolvedAtlas.reason);
2358
+ const atlas = resolvedAtlas.atlas;
2359
+
2360
+ const hasClipKey = Object.prototype.hasOwnProperty.call(options, 'clipKey');
2361
+ const hasFrames = Object.prototype.hasOwnProperty.call(options, 'frames');
2362
+ if (hasClipKey && hasFrames) return animationResultErr('invalid_clip_options');
2363
+
2364
+ let clipKey = null;
2365
+ let frameKeys = null;
2366
+ let frameDuration = null;
2367
+ let loop = true;
2368
+
2369
+ if (hasClipKey) {
2370
+ clipKey = animationNormalizeAtlasKey(options.clipKey);
2371
+ if (clipKey == null) return animationResultErr('invalid_clip_key');
2372
+ const clipMeta = atlas.clips.get(clipKey);
2373
+ if (!clipMeta) return animationResultErr('missing_clip');
2374
+ frameKeys = clipMeta.frames.slice();
2375
+ frameDuration = clipMeta.frameDuration;
2376
+ loop = clipMeta.loop;
2377
+ } else {
2378
+ const parsedFrames = animationParseAtlasClipFrames(options.frames, atlas.frames);
2379
+ if (!parsedFrames.ok) return animationResultErr(parsedFrames.reason);
2380
+ frameKeys = parsedFrames.keys;
2381
+ }
2382
+
2383
+ if (Object.prototype.hasOwnProperty.call(options, 'frameDuration')) {
2384
+ const normalizedFrameDuration = animationNormalizeAtlasFrameDuration(options.frameDuration);
2385
+ if (normalizedFrameDuration == null) return animationResultErr('invalid_clip_duration');
2386
+ frameDuration = normalizedFrameDuration;
2387
+ }
2388
+
2389
+ if (Object.prototype.hasOwnProperty.call(options, 'loop')) {
2390
+ if (typeof options.loop !== 'boolean') return animationResultErr('invalid_clip_loop');
2391
+ loop = options.loop;
2392
+ }
2393
+
2394
+ const playing = Object.prototype.hasOwnProperty.call(options, 'playing')
2395
+ ? options.playing
2396
+ : true;
2397
+ if (typeof playing !== 'boolean') return animationResultErr('invalid_playing_flag');
2398
+
2399
+ const frameIndex = Object.prototype.hasOwnProperty.call(options, 'frameIndex')
2400
+ ? options.frameIndex
2401
+ : 0;
2402
+ if (
2403
+ !Number.isInteger(frameIndex)
2404
+ || frameIndex < 0
2405
+ || frameIndex >= frameKeys.length
2406
+ ) {
2407
+ return animationResultErr('invalid_clip_frame_index');
2408
+ }
2409
+
2410
+ const clipId = nextAtlasClipId++;
2411
+ animationAtlasClips.set(clipId, {
2412
+ id: clipId,
2413
+ atlas,
2414
+ clipKey,
2415
+ frameKeys,
2416
+ frameDuration,
2417
+ frameIndex,
2418
+ elapsed: 0,
2419
+ playing,
2420
+ loop,
2421
+ completed: false,
2422
+ loops: 0,
2423
+ });
2424
+
2425
+ const clipState = animationAtlasClipSnapshot(animationAtlasClips.get(clipId));
2426
+ return animationResultOk({
2427
+ clipId,
2428
+ atlasId: atlas.id,
2429
+ frameKey: clipState ? clipState.frameKey : null,
2430
+ });
2431
+ },
2432
+ stepAtlasClip: (clipId, dt) => {
2433
+ const resolvedClip = animationResolveAtlasClip(clipId);
2434
+ if (!resolvedClip.clip) return animationResultErr(resolvedClip.reason);
2435
+
2436
+ const stepDt = Number.isFinite(dt) && Number(dt) > 0 ? Number(dt) : null;
2437
+ if (stepDt == null) return animationResultErr('invalid_dt');
2438
+
2439
+ const clip = resolvedClip.clip;
2440
+ if (clip.playing && !clip.completed) {
2441
+ let remaining = stepDt;
2442
+ let iterations = 0;
2443
+ while (remaining > ANIMATION_ATLAS_EPSILON && iterations < ANIMATION_ATLAS_MAX_STEP_ITERATIONS) {
2444
+ iterations += 1;
2445
+ const frameKey = clip.frameKeys[clip.frameIndex];
2446
+ const frameDuration = animationAtlasClipFrameDuration(clip, frameKey);
2447
+ const available = Math.max(ANIMATION_ATLAS_EPSILON, frameDuration - clip.elapsed);
2448
+ if (remaining + ANIMATION_ATLAS_EPSILON < available) {
2449
+ clip.elapsed += remaining;
2450
+ remaining = 0;
2451
+ break;
2452
+ }
2453
+
2454
+ remaining -= available;
2455
+ clip.elapsed = 0;
2456
+
2457
+ if (clip.frameIndex + 1 < clip.frameKeys.length) {
2458
+ clip.frameIndex += 1;
2459
+ clip.completed = false;
2460
+ continue;
2461
+ }
2462
+
2463
+ if (clip.loop) {
2464
+ clip.frameIndex = 0;
2465
+ clip.loops += 1;
2466
+ clip.completed = false;
2467
+ continue;
2468
+ }
2469
+
2470
+ clip.frameIndex = clip.frameKeys.length - 1;
2471
+ clip.playing = false;
2472
+ clip.completed = true;
2473
+ remaining = 0;
2474
+ break;
2475
+ }
2476
+ }
2477
+
2478
+ const clipState = animationAtlasClipSnapshot(clip);
2479
+ return animationResultOk({
2480
+ clipId: clip.id,
2481
+ frameKey: clipState ? clipState.frameKey : null,
2482
+ frameIndex: clipState ? clipState.frameIndex : null,
2483
+ loops: clip.loops,
2484
+ completed: clip.completed,
2485
+ });
2486
+ },
2487
+ getAtlasClipState: (clipId) => {
2488
+ const resolvedClip = animationResolveAtlasClip(clipId);
2489
+ if (!resolvedClip.clip) return null;
2490
+ return animationAtlasClipSnapshot(resolvedClip.clip);
2491
+ },
2492
+ };
2493
+
2494
+ let nextAnim2dMachineId = 1;
2495
+ let nextAnim2dCallbackSeq = 1;
2496
+ const anim2dClips = new Map();
2497
+ const anim2dMachines = new Map();
2498
+ const normalizeAnim2dName = (value) => (
2499
+ typeof value === 'string' && value.length > 0 ? value : null
2500
+ );
2501
+ const normalizeAnim2dFrames = (value) => {
2502
+ if (Array.isArray(value) && value.length > 0) return value.slice();
2503
+ if (Number.isInteger(value) && value > 0) {
2504
+ return Array.from({ length: value }, (_, index) => index);
2505
+ }
2506
+ return null;
2507
+ };
2508
+ const normalizeAnim2dFrameDuration = (value) => (
2509
+ Number.isFinite(value) && value > 0 ? Number(value) : (1 / 12)
2510
+ );
2511
+ const normalizeAnim2dOrder = (value) => (
2512
+ Number.isFinite(value) ? Number(value) : 0
2513
+ );
2514
+ const sortedAnim2dMachineIds = () => (
2515
+ [...anim2dMachines.keys()].sort((a, b) => a - b)
2516
+ );
2517
+ const anim2dSnapshot = (machine) => {
2518
+ if (!machine) return null;
2519
+ if (machine.currentState == null) {
2520
+ return {
2521
+ machineId: machine.id,
2522
+ state: null,
2523
+ frameIndex: 0,
2524
+ frame: null,
2525
+ completed: false,
2526
+ loops: machine.loops,
2527
+ frameDuration: 1 / 12,
2528
+ loop: true,
2529
+ };
2530
+ }
2531
+ const state = machine.states.get(machine.currentState);
2532
+ if (!state) {
2533
+ return {
2534
+ machineId: machine.id,
2535
+ state: machine.currentState,
2536
+ frameIndex: 0,
2537
+ frame: null,
2538
+ completed: false,
2539
+ loops: machine.loops,
2540
+ frameDuration: 1 / 12,
2541
+ loop: true,
2542
+ };
2543
+ }
2544
+ const boundedFrameIndex = Math.max(0, Math.min(machine.frameIndex, state.frames.length - 1));
2545
+ return {
2546
+ machineId: machine.id,
2547
+ state: machine.currentState,
2548
+ frameIndex: boundedFrameIndex,
2549
+ frame: state.frames[boundedFrameIndex],
2550
+ completed: machine.completed,
2551
+ loops: machine.loops,
2552
+ frameDuration: state.frameDuration,
2553
+ loop: state.loop,
2554
+ };
2555
+ };
2556
+
2557
+ let nextMaterialHandle = 1;
2558
+ const defaultMaterialState = Object.freeze({
2559
+ color: { r: 1, g: 1, b: 1, a: 1 },
2560
+ metallic: 0,
2561
+ roughness: 1,
2562
+ texture: null,
2563
+ });
2564
+ const materialStore = new Map([[0, { ...defaultMaterialState }]]);
2565
+ const normalizeMaterialHandle = (value) => (
2566
+ Number.isInteger(value) && value >= 0 ? value : null
2567
+ );
2568
+ const normalizeMaterialColor = (value) => {
2569
+ if (!value || typeof value !== 'object') return null;
2570
+ const { r, g, b, a = 1 } = value;
2571
+ if (![r, g, b, a].every((entry) => Number.isFinite(entry))) return null;
2572
+ return { r: Number(r), g: Number(g), b: Number(b), a: Number(a) };
2573
+ };
2574
+ const clampUnit = (value, fallback) => {
2575
+ if (!Number.isFinite(value)) return fallback;
2576
+ return Math.max(0, Math.min(1, Number(value)));
2577
+ };
2578
+ const getMaterialState = (handle) => {
2579
+ const normalized = normalizeMaterialHandle(handle);
2580
+ if (normalized == null) return null;
2581
+ return materialStore.get(normalized) || null;
2582
+ };
2583
+ const createMaterialState = (options = undefined) => {
2584
+ if (options === undefined) return { ...defaultMaterialState };
2585
+ if (options === null || typeof options !== 'object') {
2586
+ throw new TypeError('aura.material.create: options must be an object or undefined');
2587
+ }
2588
+ return {
2589
+ color: normalizeMaterialColor(options.color) || { ...defaultMaterialState.color },
2590
+ metallic: clampUnit(options.metallic, defaultMaterialState.metallic),
2591
+ roughness: clampUnit(options.roughness, defaultMaterialState.roughness),
2592
+ texture: typeof options.texture === 'string' && options.texture.length > 0 ? options.texture : null,
2593
+ };
2594
+ };
2595
+
2596
+ const createTilemapQueryBridge = () => {
2597
+ const isFiniteNumber = (value) => Number.isFinite(value);
2598
+ const toPositiveInteger = (value) => {
2599
+ if (!isFiniteNumber(value)) return null;
2600
+ const parsed = Math.floor(Number(value));
2601
+ return parsed > 0 ? parsed : null;
2602
+ };
2603
+ const toFinite = (value) => (
2604
+ isFiniteNumber(value) ? Number(value) : null
2605
+ );
2606
+ const normalizeLayerNameSet = (model) => {
2607
+ const names = new Set();
2608
+ const append = (value) => {
2609
+ if (!Array.isArray(value)) return;
2610
+ for (const entry of value) {
2611
+ if (typeof entry === 'string' && entry.length > 0) {
2612
+ names.add(entry);
2613
+ }
2614
+ }
2615
+ };
2616
+ append(model?.solidLayerNames);
2617
+ append(model?.solidLayers);
2618
+ return names;
2619
+ };
2620
+ const sortHits = (hits) => {
2621
+ hits.sort((a, b) => {
2622
+ if (a.layerIndex !== b.layerIndex) return a.layerIndex - b.layerIndex;
2623
+ if (a.y !== b.y) return a.y - b.y;
2624
+ if (a.x !== b.x) return a.x - b.x;
2625
+ if (a.layerName === b.layerName) return 0;
2626
+ return a.layerName < b.layerName ? -1 : 1;
2627
+ });
2628
+ return hits;
2629
+ };
2630
+ const normalizeModel = (model) => {
2631
+ if (!model || typeof model !== 'object') {
2632
+ return { ok: false, error: 'invalid tilemap model', reasonCode: 'invalid_model' };
2633
+ }
2634
+
2635
+ const width = toPositiveInteger(model.width);
2636
+ const height = toPositiveInteger(model.height);
2637
+ const tileWidth = toFinite(model.tileWidth ?? model.tilewidth);
2638
+ const tileHeight = toFinite(model.tileHeight ?? model.tileheight);
2639
+ if (width == null || height == null || tileWidth == null || tileHeight == null || tileWidth <= 0 || tileHeight <= 0) {
2640
+ return { ok: false, error: 'invalid tilemap model', reasonCode: 'invalid_model' };
2641
+ }
2642
+
2643
+ const solidNameSet = normalizeLayerNameSet(model);
2644
+ const cells = [];
2645
+ const seen = new Set();
2646
+ const pushCell = (x, y, layerIndex, layerName) => {
2647
+ if (!Number.isInteger(x) || !Number.isInteger(y)) return;
2648
+ if (x < 0 || y < 0 || x >= width || y >= height) return;
2649
+ const normalizedLayerIndex = Number.isInteger(layerIndex) ? layerIndex : 0;
2650
+ const normalizedLayerName = typeof layerName === 'string' && layerName.length > 0
2651
+ ? layerName
2652
+ : `layer-${normalizedLayerIndex}`;
2653
+ const key = `${normalizedLayerIndex}|${y}|${x}|${normalizedLayerName}`;
2654
+ if (seen.has(key)) return;
2655
+ seen.add(key);
2656
+ cells.push({
2657
+ x,
2658
+ y,
2659
+ layerIndex: normalizedLayerIndex,
2660
+ layerName: normalizedLayerName,
2661
+ worldX: x * tileWidth,
2662
+ worldY: y * tileHeight,
2663
+ worldW: tileWidth,
2664
+ worldH: tileHeight,
2665
+ });
2666
+ };
2667
+
2668
+ if (Array.isArray(model.solidCells)) {
2669
+ for (const entry of model.solidCells) {
2670
+ if (!entry || typeof entry !== 'object') continue;
2671
+ pushCell(
2672
+ Number(entry.x),
2673
+ Number(entry.y),
2674
+ Number(entry.layerIndex),
2675
+ entry.layerName,
2676
+ );
2677
+ }
2678
+ }
2679
+
2680
+ if (Array.isArray(model.layers)) {
2681
+ for (let layerIndex = 0; layerIndex < model.layers.length; layerIndex += 1) {
2682
+ const layer = model.layers[layerIndex];
2683
+ if (!layer || typeof layer !== 'object') continue;
2684
+ const layerName = typeof layer.name === 'string' && layer.name.length > 0
2685
+ ? layer.name
2686
+ : `layer-${layerIndex}`;
2687
+ const layerWidth = toPositiveInteger(layer.width) ?? width;
2688
+ const layerHeight = toPositiveInteger(layer.height) ?? height;
2689
+ if (layerWidth !== width || layerHeight !== height) continue;
2690
+
2691
+ const layerSolid = typeof layer.queryEnabled === 'boolean'
2692
+ ? layer.queryEnabled
2693
+ : (
2694
+ layer.solid === true
2695
+ || layer.collision === true
2696
+ || layer.properties?.solid === true
2697
+ || solidNameSet.has(layerName)
2698
+ );
2699
+ if (!layerSolid) continue;
2700
+ if (!Array.isArray(layer.data)) continue;
2701
+
2702
+ const maxEntries = Math.min(layer.data.length, width * height);
2703
+ for (let index = 0; index < maxEntries; index += 1) {
2704
+ const gid = Number(layer.data[index]);
2705
+ if (!isFiniteNumber(gid) || gid <= 0) continue;
2706
+ const x = index % width;
2707
+ const y = Math.floor(index / width);
2708
+ pushCell(x, y, layerIndex, layerName);
2709
+ }
2710
+ }
2711
+ }
2712
+
2713
+ sortHits(cells);
2714
+ return {
2715
+ ok: true,
2716
+ width,
2717
+ height,
2718
+ tileWidth,
2719
+ tileHeight,
2720
+ cells,
2721
+ };
2722
+ };
2723
+ const resolveQueryModelInput = (input) => {
2724
+ if (typeof input === 'number' && Number.isInteger(input)) {
2725
+ if (input <= 0) {
2726
+ return { ok: false, error: 'invalid tilemap handle', reasonCode: 'invalid_map_handle' };
2727
+ }
2728
+ const map = tilemapResolveMap(input);
2729
+ if (!map) {
2730
+ return { ok: false, error: 'invalid tilemap handle', reasonCode: 'invalid_map_handle' };
2731
+ }
2732
+ return { ok: true, model: map };
2733
+ }
2734
+ if (!input || typeof input !== 'object') {
2735
+ return { ok: false, error: 'invalid tilemap model', reasonCode: 'invalid_model' };
2736
+ }
2737
+ return { ok: true, model: input };
2738
+ };
2739
+ const parsePointArgs = (args, offset) => {
2740
+ const value = args[offset];
2741
+ if (value && typeof value === 'object') {
2742
+ const x = toFinite(value.x);
2743
+ const y = toFinite(value.y);
2744
+ if (x == null || y == null) return null;
2745
+ return { x, y };
2746
+ }
2747
+ const x = toFinite(args[offset]);
2748
+ const y = toFinite(args[offset + 1]);
2749
+ if (x == null || y == null) return null;
2750
+ return { x, y };
2751
+ };
2752
+ const parseAabbArgs = (args, offset) => {
2753
+ const value = args[offset];
2754
+ if (value && typeof value === 'object') {
2755
+ const x = toFinite(value.x);
2756
+ const y = toFinite(value.y);
2757
+ const w = toFinite(value.w);
2758
+ const h = toFinite(value.h);
2759
+ if (x == null || y == null || w == null || h == null) return null;
2760
+ return { x, y, w, h };
2761
+ }
2762
+ const x = toFinite(args[offset]);
2763
+ const y = toFinite(args[offset + 1]);
2764
+ const w = toFinite(args[offset + 2]);
2765
+ const h = toFinite(args[offset + 3]);
2766
+ if (x == null || y == null || w == null || h == null) return null;
2767
+ return { x, y, w, h };
2768
+ };
2769
+ const parseRayArgs = (args, offset) => {
2770
+ const value = args[offset];
2771
+ if (value && typeof value === 'object') {
2772
+ const x = toFinite(value.x);
2773
+ const y = toFinite(value.y);
2774
+ const dx = toFinite(value.dx);
2775
+ const dy = toFinite(value.dy);
2776
+ const maxDistance = toFinite(value.maxDistance ?? value.maxToi ?? 10000);
2777
+ if (x == null || y == null || dx == null || dy == null || maxDistance == null) return null;
2778
+ return { x, y, dx, dy, maxDistance };
2779
+ }
2780
+ const x = toFinite(args[offset]);
2781
+ const y = toFinite(args[offset + 1]);
2782
+ const dx = toFinite(args[offset + 2]);
2783
+ const dy = toFinite(args[offset + 3]);
2784
+ const maxDistance = toFinite(args[offset + 4] ?? 10000);
2785
+ if (x == null || y == null || dx == null || dy == null || maxDistance == null) return null;
2786
+ return { x, y, dx, dy, maxDistance };
2787
+ };
2788
+ const hitsAtTile = (normalizedModel, tileX, tileY) => {
2789
+ if (!Number.isInteger(tileX) || !Number.isInteger(tileY)) return [];
2790
+ return normalizedModel.cells.filter((cell) => cell.x === tileX && cell.y === tileY);
2791
+ };
2792
+ const failResult = (error, reasonCode) => ({
2793
+ ok: false,
2794
+ hit: false,
2795
+ hits: [],
2796
+ error,
2797
+ reasonCode,
2798
+ });
2799
+ const okResult = (payload) => ({
2800
+ ok: true,
2801
+ error: null,
2802
+ reasonCode: null,
2803
+ ...payload,
2804
+ });
2805
+
2806
+ const queryPoint = (mapOrModel, ...args) => {
2807
+ const resolvedModel = resolveQueryModelInput(mapOrModel);
2808
+ if (!resolvedModel.ok) return failResult(resolvedModel.error, resolvedModel.reasonCode);
2809
+ const normalizedModel = normalizeModel(resolvedModel.model);
2810
+ if (!normalizedModel.ok) return failResult(normalizedModel.error, normalizedModel.reasonCode);
2811
+ const point = parsePointArgs(args, 0);
2812
+ if (!point) return failResult('invalid point query args', 'invalid_point_args');
2813
+ const tileX = Math.floor(point.x / normalizedModel.tileWidth);
2814
+ const tileY = Math.floor(point.y / normalizedModel.tileHeight);
2815
+ const hits = hitsAtTile(normalizedModel, tileX, tileY);
2816
+ return okResult({ hit: hits.length > 0, hits, tileX, tileY });
2817
+ };
2818
+
2819
+ const queryAABB = (mapOrModel, ...args) => {
2820
+ const resolvedModel = resolveQueryModelInput(mapOrModel);
2821
+ if (!resolvedModel.ok) return failResult(resolvedModel.error, resolvedModel.reasonCode);
2822
+ const normalizedModel = normalizeModel(resolvedModel.model);
2823
+ if (!normalizedModel.ok) return failResult(normalizedModel.error, normalizedModel.reasonCode);
2824
+ const rect = parseAabbArgs(args, 0);
2825
+ if (!rect || rect.w <= 0 || rect.h <= 0) return failResult('invalid aabb query args', 'invalid_aabb_args');
2826
+
2827
+ const epsilon = 1e-9;
2828
+ const minTileX = Math.floor(rect.x / normalizedModel.tileWidth);
2829
+ const minTileY = Math.floor(rect.y / normalizedModel.tileHeight);
2830
+ const maxTileX = Math.floor((rect.x + rect.w - epsilon) / normalizedModel.tileWidth);
2831
+ const maxTileY = Math.floor((rect.y + rect.h - epsilon) / normalizedModel.tileHeight);
2832
+ const hits = normalizedModel.cells.filter((cell) => (
2833
+ cell.x >= minTileX
2834
+ && cell.x <= maxTileX
2835
+ && cell.y >= minTileY
2836
+ && cell.y <= maxTileY
2837
+ ));
2838
+ return okResult({
2839
+ hit: hits.length > 0,
2840
+ hits,
2841
+ range: { minTileX, minTileY, maxTileX, maxTileY },
2842
+ });
2843
+ };
2844
+
2845
+ const queryRay = (mapOrModel, ...args) => {
2846
+ const resolvedModel = resolveQueryModelInput(mapOrModel);
2847
+ if (!resolvedModel.ok) return failResult(resolvedModel.error, resolvedModel.reasonCode);
2848
+ const normalizedModel = normalizeModel(resolvedModel.model);
2849
+ if (!normalizedModel.ok) return failResult(normalizedModel.error, normalizedModel.reasonCode);
2850
+ const ray = parseRayArgs(args, 0);
2851
+ const directionMagnitude = ray ? Math.hypot(ray.dx, ray.dy) : 0;
2852
+ if (!ray || !isFiniteNumber(directionMagnitude) || directionMagnitude <= 0 || ray.maxDistance <= 0) {
2853
+ return failResult('invalid ray query args', 'invalid_ray_args');
2854
+ }
2855
+
2856
+ const dirX = ray.dx / directionMagnitude;
2857
+ const dirY = ray.dy / directionMagnitude;
2858
+ const step = Math.max(1e-4, Math.min(normalizedModel.tileWidth, normalizedModel.tileHeight) * 0.25);
2859
+ const steps = Math.max(1, Math.ceil(ray.maxDistance / step));
2860
+ for (let index = 0; index <= steps; index += 1) {
2861
+ const distance = Math.min(ray.maxDistance, index * step);
2862
+ const sampleX = ray.x + (dirX * distance);
2863
+ const sampleY = ray.y + (dirY * distance);
2864
+ const tileX = Math.floor(sampleX / normalizedModel.tileWidth);
2865
+ const tileY = Math.floor(sampleY / normalizedModel.tileHeight);
2866
+ const hits = hitsAtTile(normalizedModel, tileX, tileY);
2867
+ if (hits.length > 0) {
2868
+ return okResult({
2869
+ hit: true,
2870
+ hits,
2871
+ hitCell: hits[0],
2872
+ distance,
2873
+ point: { x: sampleX, y: sampleY },
2874
+ tileX,
2875
+ tileY,
2876
+ });
2877
+ }
2878
+ }
2879
+ return okResult({
2880
+ hit: false,
2881
+ hits: [],
2882
+ hitCell: null,
2883
+ distance: null,
2884
+ point: null,
2885
+ tileX: null,
2886
+ tileY: null,
2887
+ });
2888
+ };
2889
+
2890
+ return {
2891
+ queryPoint,
2892
+ queryAABB,
2893
+ queryRay,
2894
+ queryRaycast: queryRay,
2895
+ };
2896
+ };
2897
+ Object.assign(tilemap, createTilemapQueryBridge());
2898
+
2899
+ const clamp01 = (value) => {
2900
+ const numeric = Number(value);
2901
+ if (!Number.isFinite(numeric)) return 1;
2902
+ if (numeric <= 0) return 0;
2903
+ if (numeric >= 1) return 1;
2904
+ return numeric;
2905
+ };
2906
+ const toFinite = (value, fallback) => {
2907
+ const numeric = Number(value);
2908
+ return Number.isFinite(numeric) ? numeric : fallback;
2909
+ };
2910
+ const toPositive = (value, fallback) => {
2911
+ const numeric = Number(value);
2912
+ return Number.isFinite(numeric) && numeric > 0 ? numeric : fallback;
2913
+ };
2914
+ const isObject = (value) => value != null && typeof value === 'object';
2915
+
2916
+ let cameraBaseX = 0;
2917
+ let cameraBaseY = 0;
2918
+ let cameraBaseZoom = 1;
2919
+ let cameraBaseRotation = 0;
2920
+ let cameraShakeX = 0;
2921
+ let cameraShakeY = 0;
2922
+ let cameraFollowState = null;
2923
+ let cameraDeadzone = null;
2924
+ let cameraBounds = null;
2925
+ let nextCameraEffectId = 1;
2926
+ let nextCameraListenerId = 1;
2927
+ const cameraEffects = [];
2928
+ const cameraEffectListeners = [];
2929
+
2930
+ const applyCameraBounds = () => {
2931
+ if (!cameraBounds) return;
2932
+ const maxX = cameraBounds.x + cameraBounds.width;
2933
+ const maxY = cameraBounds.y + cameraBounds.height;
2934
+ cameraBaseX = Math.max(cameraBounds.x, Math.min(cameraBaseX, maxX));
2935
+ cameraBaseY = Math.max(cameraBounds.y, Math.min(cameraBaseY, maxY));
2936
+ };
2937
+ const normalizeFollowOptions = (options) => {
2938
+ if (options == null) {
2939
+ return {
2940
+ lerpX: 1,
2941
+ lerpY: 1,
2942
+ offsetX: 0,
2943
+ offsetY: 0,
2944
+ };
2945
+ }
2946
+ if (!isObject(options)) return null;
2947
+ return {
2948
+ lerpX: clamp01(options.lerpX),
2949
+ lerpY: clamp01(options.lerpY),
2950
+ offsetX: toFinite(options.offsetX, 0),
2951
+ offsetY: toFinite(options.offsetY, 0),
2952
+ };
2953
+ };
2954
+ const normalizeDeadzone = (value) => {
2955
+ const input = isObject(value) ? value : null;
2956
+ const zoneWidth = toFinite(input?.width, Number.NaN);
2957
+ const zoneHeight = toFinite(input?.height, Number.NaN);
2958
+ if (!(zoneWidth > 0) || !(zoneHeight > 0)) return null;
2959
+ return {
2960
+ x: toFinite(input?.x, 0),
2961
+ y: toFinite(input?.y, 0),
2962
+ width: zoneWidth,
2963
+ height: zoneHeight,
2964
+ };
2965
+ };
2966
+ const normalizeBounds = (value) => {
2967
+ const input = isObject(value) ? value : null;
2968
+ const zoneWidth = toFinite(input?.width, Number.NaN);
2969
+ const zoneHeight = toFinite(input?.height, Number.NaN);
2970
+ if (!(zoneWidth >= 0) || !(zoneHeight >= 0)) return null;
2971
+ return {
2972
+ x: toFinite(input?.x, 0),
2973
+ y: toFinite(input?.y, 0),
2974
+ width: zoneWidth,
2975
+ height: zoneHeight,
2976
+ };
2977
+ };
2978
+ const resolveFollowTarget = (target) => {
2979
+ let source = target;
2980
+ if (typeof source === 'function') {
2981
+ try {
2982
+ source = source();
2983
+ } catch {
2984
+ return null;
2985
+ }
2986
+ }
2987
+ if (!isObject(source)) return null;
2988
+ const x = toFinite(source.x, Number.NaN);
2989
+ const y = toFinite(source.y, Number.NaN);
2990
+ if (!Number.isFinite(x) || !Number.isFinite(y)) return null;
2991
+ return { x, y };
2992
+ };
2993
+ const emitCameraEffectEvent = (event) => {
2994
+ if (cameraEffectListeners.length === 0) return;
2995
+ const ordered = [...cameraEffectListeners].sort((a, b) => (a.order - b.order) || (a.id - b.id));
2996
+ for (const listener of ordered) {
2997
+ try {
2998
+ listener.callback(event);
2999
+ } catch {
3000
+ // Keep deterministic execution if listener throws.
3001
+ }
3002
+ }
3003
+ };
3004
+
3005
+ const camera = {};
3006
+ Object.defineProperties(camera, {
3007
+ x: {
3008
+ enumerable: true,
3009
+ configurable: false,
3010
+ get: () => cameraBaseX + cameraShakeX,
3011
+ set(value) {
3012
+ const numeric = Number(value);
3013
+ if (Number.isFinite(numeric)) cameraBaseX = numeric;
3014
+ },
3015
+ },
3016
+ y: {
3017
+ enumerable: true,
3018
+ configurable: false,
3019
+ get: () => cameraBaseY + cameraShakeY,
3020
+ set(value) {
3021
+ const numeric = Number(value);
3022
+ if (Number.isFinite(numeric)) cameraBaseY = numeric;
3023
+ },
3024
+ },
3025
+ zoom: {
3026
+ enumerable: true,
3027
+ configurable: false,
3028
+ get: () => cameraBaseZoom,
3029
+ set(value) {
3030
+ const numeric = Number(value);
3031
+ if (Number.isFinite(numeric) && numeric > 0) cameraBaseZoom = numeric;
3032
+ },
3033
+ },
3034
+ rotation: {
3035
+ enumerable: true,
3036
+ configurable: false,
3037
+ get: () => cameraBaseRotation,
3038
+ set(value) {
3039
+ const numeric = Number(value);
3040
+ if (Number.isFinite(numeric)) cameraBaseRotation = numeric;
3041
+ },
3042
+ },
3043
+ });
3044
+
3045
+ camera.getState = () => ({
3046
+ x: cameraBaseX + cameraShakeX,
3047
+ y: cameraBaseY + cameraShakeY,
3048
+ zoom: cameraBaseZoom,
3049
+ rotation: cameraBaseRotation,
3050
+ following: !!cameraFollowState,
3051
+ activeEffects: cameraEffects.length,
3052
+ deadzone: cameraDeadzone ? { ...cameraDeadzone } : null,
3053
+ bounds: cameraBounds ? { ...cameraBounds } : null,
3054
+ });
3055
+
3056
+ camera.follow = (target, options = null) => {
3057
+ if (!(typeof target === 'function' || isObject(target))) {
3058
+ return { ok: false, reasonCode: 'invalid_follow_target' };
3059
+ }
3060
+ const normalized = normalizeFollowOptions(options);
3061
+ if (!normalized) return { ok: false, reasonCode: 'invalid_follow_options' };
3062
+ cameraFollowState = {
3063
+ target,
3064
+ lerpX: normalized.lerpX,
3065
+ lerpY: normalized.lerpY,
3066
+ offsetX: normalized.offsetX,
3067
+ offsetY: normalized.offsetY,
3068
+ };
3069
+ return { ok: true, reasonCode: 'camera_follow_started' };
3070
+ };
3071
+
3072
+ camera.stopFollow = () => {
3073
+ const stopped = !!cameraFollowState;
3074
+ cameraFollowState = null;
3075
+ return { ok: true, stopped, reasonCode: 'camera_follow_stopped' };
3076
+ };
3077
+
3078
+ camera.setDeadzone = (value, maybeHeight) => {
3079
+ const normalized = isObject(value)
3080
+ ? normalizeDeadzone(value)
3081
+ : normalizeDeadzone({ width: value, height: maybeHeight });
3082
+ if (!normalized) return { ok: false, reasonCode: 'invalid_deadzone' };
3083
+ cameraDeadzone = normalized;
3084
+ return { ok: true, reasonCode: 'camera_deadzone_set' };
3085
+ };
3086
+
3087
+ camera.clearDeadzone = () => {
3088
+ cameraDeadzone = null;
3089
+ return { ok: true, reasonCode: 'camera_deadzone_cleared' };
3090
+ };
3091
+
3092
+ camera.setBounds = (xOrBounds, y, boundsWidth, boundsHeight) => {
3093
+ const normalized = isObject(xOrBounds)
3094
+ ? normalizeBounds(xOrBounds)
3095
+ : normalizeBounds({ x: xOrBounds, y, width: boundsWidth, height: boundsHeight });
3096
+ if (!normalized) return { ok: false, reasonCode: 'invalid_bounds' };
3097
+ cameraBounds = normalized;
3098
+ applyCameraBounds();
3099
+ return { ok: true, reasonCode: 'camera_bounds_set' };
3100
+ };
3101
+
3102
+ camera.clearBounds = () => {
3103
+ cameraBounds = null;
3104
+ return { ok: true, reasonCode: 'camera_bounds_cleared' };
3105
+ };
3106
+
3107
+ camera.pan = (x, y, options = null) => {
3108
+ const targetX = Number(x);
3109
+ const targetY = Number(y);
3110
+ if (!Number.isFinite(targetX) || !Number.isFinite(targetY)) {
3111
+ return { ok: false, reasonCode: 'invalid_pan_target' };
3112
+ }
3113
+ if (options != null && !isObject(options)) return { ok: false, reasonCode: 'invalid_pan_options' };
3114
+ const duration = toPositive(options?.duration, 0.25);
3115
+ if (!(duration > 0)) return { ok: false, reasonCode: 'invalid_pan_duration' };
3116
+ const effectId = nextCameraEffectId++;
3117
+ cameraEffects.push({
3118
+ id: effectId,
3119
+ type: 'pan',
3120
+ duration,
3121
+ elapsed: 0,
3122
+ startX: cameraBaseX,
3123
+ startY: cameraBaseY,
3124
+ targetX,
3125
+ targetY,
3126
+ });
3127
+ return { ok: true, effectId, reasonCode: 'camera_pan_started' };
3128
+ };
3129
+ camera.panTo = camera.pan;
3130
+
3131
+ camera.zoomTo = (value, options = null) => {
3132
+ const targetZoom = Number(value);
3133
+ if (!Number.isFinite(targetZoom) || !(targetZoom > 0)) {
3134
+ return { ok: false, reasonCode: 'invalid_zoom_target' };
3135
+ }
3136
+ if (options != null && !isObject(options)) return { ok: false, reasonCode: 'invalid_zoom_options' };
3137
+ const duration = toPositive(options?.duration, 0.25);
3138
+ if (!(duration > 0)) return { ok: false, reasonCode: 'invalid_zoom_duration' };
3139
+ const effectId = nextCameraEffectId++;
3140
+ cameraEffects.push({
3141
+ id: effectId,
3142
+ type: 'zoom',
3143
+ duration,
3144
+ elapsed: 0,
3145
+ startZoom: cameraBaseZoom,
3146
+ targetZoom,
3147
+ });
3148
+ return { ok: true, effectId, reasonCode: 'camera_zoom_started' };
3149
+ };
3150
+
3151
+ camera.rotateTo = (value, options = null) => {
3152
+ const targetRotation = Number(value);
3153
+ if (!Number.isFinite(targetRotation)) {
3154
+ return { ok: false, reasonCode: 'invalid_rotation_target' };
3155
+ }
3156
+ if (options != null && !isObject(options)) return { ok: false, reasonCode: 'invalid_rotation_options' };
3157
+ const duration = toPositive(options?.duration, 0.25);
3158
+ if (!(duration > 0)) return { ok: false, reasonCode: 'invalid_rotation_duration' };
3159
+ const effectId = nextCameraEffectId++;
3160
+ cameraEffects.push({
3161
+ id: effectId,
3162
+ type: 'rotate',
3163
+ duration,
3164
+ elapsed: 0,
3165
+ startRotation: cameraBaseRotation,
3166
+ targetRotation,
3167
+ });
3168
+ return { ok: true, effectId, reasonCode: 'camera_rotation_started' };
3169
+ };
3170
+
3171
+ camera.shake = (options = null) => {
3172
+ if (options == null) options = {};
3173
+ if (!isObject(options)) return { ok: false, reasonCode: 'invalid_shake_options' };
3174
+ const sharedIntensity = Number.isFinite(Number(options.intensity))
3175
+ ? Number(options.intensity)
3176
+ : null;
3177
+ const intensityX = toFinite(options.intensityX, sharedIntensity != null ? sharedIntensity : 6);
3178
+ const intensityY = toFinite(options.intensityY, sharedIntensity != null ? sharedIntensity : 6);
3179
+ if (!(intensityX >= 0) || !(intensityY >= 0)) {
3180
+ return { ok: false, reasonCode: 'invalid_shake_intensity' };
3181
+ }
3182
+ const duration = toPositive(options.duration, 0.3);
3183
+ if (!(duration > 0)) return { ok: false, reasonCode: 'invalid_shake_duration' };
3184
+ const frequency = toPositive(options.frequency, 30);
3185
+ if (!(frequency > 0)) return { ok: false, reasonCode: 'invalid_shake_frequency' };
3186
+ const effectId = nextCameraEffectId++;
3187
+ cameraEffects.push({
3188
+ id: effectId,
3189
+ type: 'shake',
3190
+ duration,
3191
+ elapsed: 0,
3192
+ intensityX,
3193
+ intensityY,
3194
+ frequency,
3195
+ seed: effectId * 0.61803398875,
3196
+ });
3197
+ return { ok: true, effectId, reasonCode: 'camera_shake_started' };
3198
+ };
3199
+
3200
+ camera.clearEffects = () => {
3201
+ const cleared = cameraEffects.length;
3202
+ cameraEffects.length = 0;
3203
+ cameraShakeX = 0;
3204
+ cameraShakeY = 0;
3205
+ return { ok: true, cleared, reasonCode: 'camera_effects_cleared' };
3206
+ };
3207
+
3208
+ camera.onEffectComplete = (callback, order = 0) => {
3209
+ if (typeof callback !== 'function') {
3210
+ return { ok: false, reasonCode: 'invalid_effect_callback' };
3211
+ }
3212
+ const listener = {
3213
+ id: nextCameraListenerId++,
3214
+ callback,
3215
+ order: Number.isFinite(Number(order)) ? Number(order) : 0,
3216
+ };
3217
+ cameraEffectListeners.push(listener);
3218
+ return { ok: true, listenerId: listener.id, reasonCode: 'camera_effect_listener_registered' };
3219
+ };
3220
+
3221
+ camera.offEffectComplete = (listenerId) => {
3222
+ if (!Number.isInteger(listenerId) || listenerId <= 0) return false;
3223
+ const index = cameraEffectListeners.findIndex((entry) => entry.id === listenerId);
3224
+ if (index < 0) return false;
3225
+ cameraEffectListeners.splice(index, 1);
3226
+ return true;
3227
+ };
3228
+
3229
+ camera.update = (dt) => {
3230
+ const delta = Number(dt);
3231
+ if (!Number.isFinite(delta) || !(delta > 0)) {
3232
+ return { ok: false, reasonCode: 'invalid_dt' };
3233
+ }
3234
+
3235
+ cameraShakeX = 0;
3236
+ cameraShakeY = 0;
3237
+
3238
+ if (cameraFollowState) {
3239
+ const targetPoint = resolveFollowTarget(cameraFollowState.target);
3240
+ if (targetPoint) {
3241
+ let targetX = targetPoint.x + cameraFollowState.offsetX;
3242
+ let targetY = targetPoint.y + cameraFollowState.offsetY;
3243
+
3244
+ if (cameraDeadzone) {
3245
+ const left = cameraBaseX + cameraDeadzone.x;
3246
+ const right = left + cameraDeadzone.width;
3247
+ const top = cameraBaseY + cameraDeadzone.y;
3248
+ const bottom = top + cameraDeadzone.height;
3249
+
3250
+ if (targetX < left) targetX = targetX - cameraDeadzone.x;
3251
+ else if (targetX > right) targetX = targetX - cameraDeadzone.x - cameraDeadzone.width;
3252
+ else targetX = cameraBaseX;
3253
+
3254
+ if (targetY < top) targetY = targetY - cameraDeadzone.y;
3255
+ else if (targetY > bottom) targetY = targetY - cameraDeadzone.y - cameraDeadzone.height;
3256
+ else targetY = cameraBaseY;
3257
+ }
3258
+
3259
+ cameraBaseX += (targetX - cameraBaseX) * cameraFollowState.lerpX;
3260
+ cameraBaseY += (targetY - cameraBaseY) * cameraFollowState.lerpY;
3261
+ }
3262
+ }
3263
+
3264
+ const completedEffects = [];
3265
+ for (const effect of cameraEffects) {
3266
+ effect.elapsed += delta;
3267
+ const progress = effect.duration <= 0 ? 1 : Math.min(effect.elapsed / effect.duration, 1);
3268
+ if (effect.type === 'pan') {
3269
+ cameraBaseX = effect.startX + ((effect.targetX - effect.startX) * progress);
3270
+ cameraBaseY = effect.startY + ((effect.targetY - effect.startY) * progress);
3271
+ } else if (effect.type === 'zoom') {
3272
+ cameraBaseZoom = effect.startZoom + ((effect.targetZoom - effect.startZoom) * progress);
3273
+ } else if (effect.type === 'rotate') {
3274
+ cameraBaseRotation = effect.startRotation + ((effect.targetRotation - effect.startRotation) * progress);
3275
+ } else if (effect.type === 'shake') {
3276
+ const amplitude = 1 - progress;
3277
+ const angle = (effect.seed + (effect.elapsed * effect.frequency)) * 6.283185307179586;
3278
+ cameraShakeX += Math.sin(angle) * effect.intensityX * amplitude;
3279
+ cameraShakeY += Math.cos(angle * 1.17) * effect.intensityY * amplitude;
3280
+ }
3281
+ if (progress >= 1) completedEffects.push(effect);
3282
+ }
3283
+
3284
+ if (completedEffects.length > 0) {
3285
+ for (const completed of completedEffects) {
3286
+ const index = cameraEffects.indexOf(completed);
3287
+ if (index >= 0) cameraEffects.splice(index, 1);
3288
+ }
3289
+ completedEffects.sort((a, b) => a.id - b.id);
3290
+ for (const completed of completedEffects) {
3291
+ emitCameraEffectEvent({
3292
+ type: 'effect_complete',
3293
+ effectType: completed.type,
3294
+ effectId: completed.id,
3295
+ reasonCode: 'camera_effect_complete',
3296
+ });
3297
+ }
3298
+ }
3299
+
3300
+ applyCameraBounds();
3301
+
3302
+ return {
3303
+ ok: true,
3304
+ reasonCode: 'camera_updated',
3305
+ x: cameraBaseX + cameraShakeX,
3306
+ y: cameraBaseY + cameraShakeY,
3307
+ zoom: cameraBaseZoom,
3308
+ rotation: cameraBaseRotation,
3309
+ following: !!cameraFollowState,
3310
+ activeEffects: cameraEffects.length,
3311
+ };
3312
+ };
3313
+
3314
+ const createHeadlessAudioSurface = () => {
3315
+ const normalizeHandle = (value) => {
3316
+ const numeric = Number(value);
3317
+ if (!Number.isFinite(numeric) || numeric <= 0 || Math.floor(numeric) !== numeric) {
3318
+ return null;
3319
+ }
3320
+ return numeric;
3321
+ };
3322
+ const normalizeBus = (value) => {
3323
+ if (typeof value !== 'string') return null;
3324
+ const trimmed = value.trim();
3325
+ return trimmed.length > 0 ? trimmed : null;
3326
+ };
3327
+ const normalizeDuration = (value) => {
3328
+ const numeric = Number(value);
3329
+ return Number.isFinite(numeric) && numeric > 0 ? numeric : null;
3330
+ };
3331
+ const clampAudioVolume = (value, fallback = 0) => {
3332
+ const numeric = Number(value);
3333
+ if (!Number.isFinite(numeric)) return fallback;
3334
+ if (numeric < 0) return 0;
3335
+ if (numeric > 1) return 1;
3336
+ return numeric;
3337
+ };
3338
+ const resultOk = (extra = {}) => ({ ok: true, reasonCode: null, ...extra });
3339
+ const resultErr = (reasonCode, extra = {}) => ({ ok: false, reasonCode, ...extra });
3340
+
3341
+ let nextHandle = 1;
3342
+ const rawTracks = new Map();
3343
+
3344
+ const rawPlay = () => {
3345
+ testState.audioCalls += 1;
3346
+ const handle = nextHandle;
3347
+ nextHandle += 1;
3348
+ rawTracks.set(handle, { paused: false, volume: 1 });
3349
+ return handle;
3350
+ };
3351
+ const rawStop = (handle) => {
3352
+ const normalized = normalizeHandle(handle);
3353
+ if (normalized == null) return;
3354
+ rawTracks.delete(normalized);
3355
+ };
3356
+ const rawPause = (handle) => {
3357
+ const normalized = normalizeHandle(handle);
3358
+ if (normalized == null) return;
3359
+ const track = rawTracks.get(normalized);
3360
+ if (track) track.paused = true;
3361
+ };
3362
+ const rawResume = (handle) => {
3363
+ const normalized = normalizeHandle(handle);
3364
+ if (normalized == null) return;
3365
+ const track = rawTracks.get(normalized);
3366
+ if (track) track.paused = false;
3367
+ };
3368
+ const rawSetVolume = (handle, volume) => {
3369
+ const normalized = normalizeHandle(handle);
3370
+ if (normalized == null) return;
3371
+ const track = rawTracks.get(normalized);
3372
+ if (!track) return;
3373
+ track.volume = clampAudioVolume(volume, track.volume);
3374
+ };
3375
+ const rawSetMasterVolume = (volume, audio) => {
3376
+ const numeric = Number(volume);
3377
+ if (!Number.isFinite(numeric)) return;
3378
+ audio.masterVolume = clampAudioVolume(numeric, audio.masterVolume);
3379
+ };
3380
+ const rawStopAll = () => {
3381
+ rawTracks.clear();
3382
+ };
3383
+
3384
+ const audio = {
3385
+ play: rawPlay,
3386
+ pause: rawPause,
3387
+ resume: rawResume,
3388
+ stop: rawStop,
3389
+ setVolume: rawSetVolume,
3390
+ setMasterVolume(volume) {
3391
+ rawSetMasterVolume(volume, audio);
3392
+ },
3393
+ stopAll: rawStopAll,
3394
+ masterVolume: 1,
3395
+ };
3396
+
3397
+ const DEFAULT_BUS = 'master';
3398
+ const busVolumes = new Map([[DEFAULT_BUS, 1]]);
3399
+ const tracks = new Map();
3400
+ const envelopes = [];
3401
+ let nextEnvelopeId = 1;
3402
+
3403
+ const sortedTrackHandles = () => [...tracks.keys()].sort((a, b) => a - b);
3404
+ const ensureBus = (bus) => {
3405
+ if (!busVolumes.has(bus)) busVolumes.set(bus, 1);
3406
+ };
3407
+ const busVolume = (bus) => busVolumes.get(bus) ?? 1;
3408
+ const effectiveTrackVolume = (track) => clampAudioVolume(track.baseVolume * busVolume(track.bus), 1);
3409
+ const applyTrackVolume = (handle) => {
3410
+ const track = tracks.get(handle);
3411
+ if (!track) return;
3412
+ track.effectiveVolume = effectiveTrackVolume(track);
3413
+ rawSetVolume(handle, track.effectiveVolume);
3414
+ };
3415
+ const applyBusVolume = (bus) => {
3416
+ for (const handle of sortedTrackHandles()) {
3417
+ const track = tracks.get(handle);
3418
+ if (!track || track.bus !== bus) continue;
3419
+ applyTrackVolume(handle);
3420
+ }
3421
+ };
3422
+ const removeTrackEnvelopes = (handle) => {
3423
+ for (let i = envelopes.length - 1; i >= 0; i -= 1) {
3424
+ const envelope = envelopes[i];
3425
+ if (envelope.kind === 'track' && envelope.handle === handle) {
3426
+ envelopes.splice(i, 1);
3427
+ }
3428
+ }
3429
+ };
3430
+ const removeBusEnvelopes = (bus) => {
3431
+ for (let i = envelopes.length - 1; i >= 0; i -= 1) {
3432
+ const envelope = envelopes[i];
3433
+ if (envelope.kind === 'bus' && envelope.bus === bus) {
3434
+ envelopes.splice(i, 1);
3435
+ }
3436
+ }
3437
+ };
3438
+ const dropTrack = (handle) => {
3439
+ tracks.delete(handle);
3440
+ removeTrackEnvelopes(handle);
3441
+ };
3442
+ const appendEnvelope = (envelope) => {
3443
+ envelope.id = nextEnvelopeId;
3444
+ nextEnvelopeId += 1;
3445
+ envelopes.push(envelope);
3446
+ return envelope.id;
3447
+ };
3448
+
3449
+ const rawPlayMethod = audio.play.bind(audio);
3450
+ const rawStopMethod = audio.stop.bind(audio);
3451
+ const rawPauseMethod = audio.pause.bind(audio);
3452
+ const rawResumeMethod = audio.resume.bind(audio);
3453
+ const rawSetVolumeMethod = audio.setVolume.bind(audio);
3454
+ const rawSetMasterVolumeMethod = audio.setMasterVolume.bind(audio);
3455
+ const rawStopAllMethod = audio.stopAll.bind(audio);
3456
+
3457
+ audio.play = (path, options = undefined) => {
3458
+ const normalizedOptions = options && typeof options === 'object' && !Array.isArray(options)
3459
+ ? options
3460
+ : null;
3461
+ const requestedVolume = normalizedOptions && Object.prototype.hasOwnProperty.call(normalizedOptions, 'volume')
3462
+ ? clampAudioVolume(normalizedOptions.volume, 1)
3463
+ : 1;
3464
+ const normalizedBus = normalizedOptions ? normalizeBus(normalizedOptions.bus) : null;
3465
+ const bus = normalizedBus || DEFAULT_BUS;
3466
+ ensureBus(bus);
3467
+
3468
+ let forwardedOptions = options;
3469
+ if (normalizedOptions) {
3470
+ forwardedOptions = { ...normalizedOptions, volume: requestedVolume };
3471
+ delete forwardedOptions.bus;
3472
+ }
3473
+
3474
+ const handle = forwardedOptions === undefined
3475
+ ? rawPlayMethod(path)
3476
+ : rawPlayMethod(path, forwardedOptions);
3477
+ const normalizedHandle = normalizeHandle(handle);
3478
+ if (normalizedHandle != null) {
3479
+ tracks.set(normalizedHandle, {
3480
+ baseVolume: requestedVolume,
3481
+ effectiveVolume: requestedVolume,
3482
+ bus,
3483
+ paused: false,
3484
+ });
3485
+ applyTrackVolume(normalizedHandle);
3486
+ }
3487
+ return handle;
3488
+ };
3489
+
3490
+ audio.stop = (handle) => {
3491
+ rawStopMethod(handle);
3492
+ const normalizedHandle = normalizeHandle(handle);
3493
+ if (normalizedHandle != null) {
3494
+ dropTrack(normalizedHandle);
3495
+ }
3496
+ };
3497
+
3498
+ audio.pause = (handle) => {
3499
+ rawPauseMethod(handle);
3500
+ const normalizedHandle = normalizeHandle(handle);
3501
+ if (normalizedHandle != null && tracks.has(normalizedHandle)) {
3502
+ tracks.get(normalizedHandle).paused = true;
3503
+ }
3504
+ };
3505
+
3506
+ audio.resume = (handle) => {
3507
+ rawResumeMethod(handle);
3508
+ const normalizedHandle = normalizeHandle(handle);
3509
+ if (normalizedHandle != null && tracks.has(normalizedHandle)) {
3510
+ tracks.get(normalizedHandle).paused = false;
3511
+ }
3512
+ };
3513
+
3514
+ audio.setVolume = (handle, volume) => {
3515
+ rawSetVolumeMethod(handle, volume);
3516
+ const normalizedHandle = normalizeHandle(handle);
3517
+ if (normalizedHandle == null) return;
3518
+ const track = tracks.get(normalizedHandle);
3519
+ if (!track) return;
3520
+ const numeric = Number(volume);
3521
+ if (!Number.isFinite(numeric)) return;
3522
+ track.baseVolume = clampAudioVolume(numeric, track.baseVolume);
3523
+ applyTrackVolume(normalizedHandle);
3524
+ };
3525
+
3526
+ audio.setMasterVolume = (volume) => {
3527
+ rawSetMasterVolumeMethod(volume);
3528
+ };
3529
+
3530
+ audio.stopAll = () => {
3531
+ rawStopAllMethod();
3532
+ tracks.clear();
3533
+ envelopes.length = 0;
3534
+ };
3535
+
3536
+ audio.setBusVolume = (bus, volume) => {
3537
+ const normalizedBus = normalizeBus(bus);
3538
+ if (!normalizedBus) return resultErr('invalid_bus');
3539
+ const numeric = Number(volume);
3540
+ if (!Number.isFinite(numeric)) return resultErr('invalid_volume');
3541
+ const clamped = clampAudioVolume(numeric, 1);
3542
+ ensureBus(normalizedBus);
3543
+ busVolumes.set(normalizedBus, clamped);
3544
+ applyBusVolume(normalizedBus);
3545
+ return resultOk({ bus: normalizedBus, volume: clamped });
3546
+ };
3547
+
3548
+ audio.assignBus = (handle, bus) => {
3549
+ const normalizedHandle = normalizeHandle(handle);
3550
+ if (normalizedHandle == null) return resultErr('invalid_track_handle');
3551
+ const track = tracks.get(normalizedHandle);
3552
+ if (!track) return resultErr('missing_track');
3553
+ const normalizedBus = normalizeBus(bus);
3554
+ if (!normalizedBus) return resultErr('invalid_bus');
3555
+ ensureBus(normalizedBus);
3556
+ track.bus = normalizedBus;
3557
+ applyTrackVolume(normalizedHandle);
3558
+ return resultOk({
3559
+ handle: normalizedHandle,
3560
+ bus: normalizedBus,
3561
+ effectiveVolume: track.effectiveVolume,
3562
+ });
3563
+ };
3564
+
3565
+ audio.fadeTrack = (handle, options = null) => {
3566
+ const normalizedHandle = normalizeHandle(handle);
3567
+ if (normalizedHandle == null) return resultErr('invalid_track_handle');
3568
+ const track = tracks.get(normalizedHandle);
3569
+ if (!track) return resultErr('missing_track');
3570
+ if (!options || typeof options !== 'object' || Array.isArray(options)) {
3571
+ return resultErr('invalid_fade_options');
3572
+ }
3573
+ const duration = normalizeDuration(options.duration);
3574
+ if (duration == null) return resultErr('invalid_duration');
3575
+ const targetVolume = Number(options.to);
3576
+ if (!Number.isFinite(targetVolume)) return resultErr('invalid_volume');
3577
+ const stopOnComplete = options.stopOnComplete === true;
3578
+ const target = clampAudioVolume(targetVolume, track.baseVolume);
3579
+ removeTrackEnvelopes(normalizedHandle);
3580
+ const envelopeId = appendEnvelope({
3581
+ kind: 'track',
3582
+ handle: normalizedHandle,
3583
+ start: track.baseVolume,
3584
+ end: target,
3585
+ duration,
3586
+ elapsed: 0,
3587
+ stopOnComplete,
3588
+ });
3589
+ return resultOk({ envelopeId });
3590
+ };
3591
+
3592
+ audio.fadeBus = (bus, options = null) => {
3593
+ const normalizedBus = normalizeBus(bus);
3594
+ if (!normalizedBus) return resultErr('invalid_bus');
3595
+ if (!busVolumes.has(normalizedBus)) return resultErr('missing_bus');
3596
+ if (!options || typeof options !== 'object' || Array.isArray(options)) {
3597
+ return resultErr('invalid_fade_options');
3598
+ }
3599
+ const duration = normalizeDuration(options.duration);
3600
+ if (duration == null) return resultErr('invalid_duration');
3601
+ const targetVolume = Number(options.to);
3602
+ if (!Number.isFinite(targetVolume)) return resultErr('invalid_volume');
3603
+ const target = clampAudioVolume(targetVolume, busVolume(normalizedBus));
3604
+ removeBusEnvelopes(normalizedBus);
3605
+ const envelopeId = appendEnvelope({
3606
+ kind: 'bus',
3607
+ bus: normalizedBus,
3608
+ start: busVolume(normalizedBus),
3609
+ end: target,
3610
+ duration,
3611
+ elapsed: 0,
3612
+ });
3613
+ return resultOk({ envelopeId });
3614
+ };
3615
+
3616
+ audio.crossfade = (fromHandle, toHandle, options = null) => {
3617
+ const normalizedFrom = normalizeHandle(fromHandle);
3618
+ if (normalizedFrom == null) return resultErr('invalid_from_track_handle');
3619
+ const normalizedTo = normalizeHandle(toHandle);
3620
+ if (normalizedTo == null) return resultErr('invalid_to_track_handle');
3621
+ const fromTrack = tracks.get(normalizedFrom);
3622
+ if (!fromTrack) return resultErr('missing_from_track');
3623
+ const toTrack = tracks.get(normalizedTo);
3624
+ if (!toTrack) return resultErr('missing_to_track');
3625
+ if (!options || typeof options !== 'object' || Array.isArray(options)) {
3626
+ return resultErr('invalid_crossfade_options');
3627
+ }
3628
+ const duration = normalizeDuration(options.duration);
3629
+ if (duration == null) return resultErr('invalid_duration');
3630
+
3631
+ const fromTargetRaw = Object.prototype.hasOwnProperty.call(options, 'fromVolume')
3632
+ ? Number(options.fromVolume)
3633
+ : 0;
3634
+ if (!Number.isFinite(fromTargetRaw)) return resultErr('invalid_from_volume');
3635
+ const toTargetRaw = Object.prototype.hasOwnProperty.call(options, 'toVolume')
3636
+ ? Number(options.toVolume)
3637
+ : toTrack.baseVolume;
3638
+ if (!Number.isFinite(toTargetRaw)) return resultErr('invalid_to_volume');
3639
+ const toStartRaw = Object.prototype.hasOwnProperty.call(options, 'toStartVolume')
3640
+ ? Number(options.toStartVolume)
3641
+ : 0;
3642
+ if (!Number.isFinite(toStartRaw)) return resultErr('invalid_to_start_volume');
3643
+
3644
+ const stopFrom = options.stopFrom !== false;
3645
+ const fromTarget = clampAudioVolume(fromTargetRaw, fromTrack.baseVolume);
3646
+ const toTarget = clampAudioVolume(toTargetRaw, toTrack.baseVolume);
3647
+ const toStart = clampAudioVolume(toStartRaw, 0);
3648
+
3649
+ toTrack.baseVolume = toStart;
3650
+ applyTrackVolume(normalizedTo);
3651
+
3652
+ removeTrackEnvelopes(normalizedFrom);
3653
+ removeTrackEnvelopes(normalizedTo);
3654
+ const fromEnvelopeId = appendEnvelope({
3655
+ kind: 'track',
3656
+ handle: normalizedFrom,
3657
+ start: fromTrack.baseVolume,
3658
+ end: fromTarget,
3659
+ duration,
3660
+ elapsed: 0,
3661
+ stopOnComplete: stopFrom,
3662
+ });
3663
+ const toEnvelopeId = appendEnvelope({
3664
+ kind: 'track',
3665
+ handle: normalizedTo,
3666
+ start: toStart,
3667
+ end: toTarget,
3668
+ duration,
3669
+ elapsed: 0,
3670
+ stopOnComplete: false,
3671
+ });
3672
+ return resultOk({ fromEnvelopeId, toEnvelopeId });
3673
+ };
3674
+
3675
+ audio.update = (dt) => {
3676
+ const delta = Number(dt);
3677
+ if (!Number.isFinite(delta) || !(delta > 0)) {
3678
+ return resultErr('invalid_dt');
3679
+ }
3680
+ if (envelopes.length === 0) {
3681
+ return resultOk({ advanced: 0, completed: 0, activeEnvelopes: 0 });
3682
+ }
3683
+
3684
+ const completed = [];
3685
+ envelopes.sort((a, b) => a.id - b.id);
3686
+
3687
+ for (const envelope of envelopes) {
3688
+ envelope.elapsed += delta;
3689
+ const progress = envelope.duration <= 0 ? 1 : Math.min(envelope.elapsed / envelope.duration, 1);
3690
+ const value = envelope.start + ((envelope.end - envelope.start) * progress);
3691
+
3692
+ if (envelope.kind === 'track') {
3693
+ const track = tracks.get(envelope.handle);
3694
+ if (!track) {
3695
+ completed.push(envelope);
3696
+ continue;
3697
+ }
3698
+ track.baseVolume = clampAudioVolume(value, track.baseVolume);
3699
+ applyTrackVolume(envelope.handle);
3700
+ } else if (envelope.kind === 'bus') {
3701
+ if (!busVolumes.has(envelope.bus)) {
3702
+ completed.push(envelope);
3703
+ continue;
3704
+ }
3705
+ busVolumes.set(envelope.bus, clampAudioVolume(value, busVolume(envelope.bus)));
3706
+ applyBusVolume(envelope.bus);
3707
+ }
3708
+
3709
+ if (progress >= 1) {
3710
+ completed.push(envelope);
3711
+ }
3712
+ }
3713
+
3714
+ completed.sort((a, b) => a.id - b.id);
3715
+ for (const envelope of completed) {
3716
+ const index = envelopes.indexOf(envelope);
3717
+ if (index >= 0) envelopes.splice(index, 1);
3718
+ if (envelope.kind === 'track' && envelope.stopOnComplete === true && tracks.has(envelope.handle)) {
3719
+ audio.stop(envelope.handle);
3720
+ }
3721
+ }
3722
+
3723
+ return resultOk({
3724
+ advanced: delta,
3725
+ completed: completed.length,
3726
+ activeEnvelopes: envelopes.length,
3727
+ });
3728
+ };
3729
+
3730
+ audio.clearEnvelopes = () => {
3731
+ const cleared = envelopes.length;
3732
+ envelopes.length = 0;
3733
+ return resultOk({ cleared });
3734
+ };
3735
+
3736
+ audio.getMixerState = () => ({
3737
+ buses: [...busVolumes.entries()]
3738
+ .sort((a, b) => a[0].localeCompare(b[0]))
3739
+ .map(([bus, volume]) => ({ bus, volume })),
3740
+ tracks: sortedTrackHandles()
3741
+ .map((handle) => {
3742
+ const track = tracks.get(handle);
3743
+ if (!track) return null;
3744
+ return {
3745
+ handle,
3746
+ bus: track.bus,
3747
+ baseVolume: track.baseVolume,
3748
+ effectiveVolume: track.effectiveVolume,
3749
+ paused: track.paused === true,
3750
+ };
3751
+ })
3752
+ .filter(Boolean),
3753
+ envelopes: [...envelopes]
3754
+ .sort((a, b) => a.id - b.id)
3755
+ .map((envelope) => ({
3756
+ id: envelope.id,
3757
+ kind: envelope.kind,
3758
+ handle: envelope.kind === 'track' ? envelope.handle : null,
3759
+ bus: envelope.kind === 'bus' ? envelope.bus : null,
3760
+ start: envelope.start,
3761
+ end: envelope.end,
3762
+ duration: envelope.duration,
3763
+ elapsed: envelope.elapsed,
3764
+ stopOnComplete: envelope.kind === 'track' ? envelope.stopOnComplete === true : false,
3765
+ })),
3766
+ nextEnvelopeId,
3767
+ });
3768
+
3769
+ return audio;
3770
+ };
3771
+
3772
+ const audio = createHeadlessAudioSurface();
3773
+
3774
+ const aura = {
3775
+ setup: null,
3776
+ update: null,
3777
+ draw: null,
3778
+ onResize: null,
3779
+ onFocus: null,
3780
+ onBlur: null,
3781
+ onQuit: null,
3782
+
3783
+ window: {
3784
+ width,
3785
+ height,
3786
+ pixelRatio: 1,
3787
+ fps: 60,
3788
+ setTitle: () => {},
3789
+ setSize: (w, h) => {
3790
+ aura.window.width = Number(w) || aura.window.width;
3791
+ aura.window.height = Number(h) || aura.window.height;
3792
+ },
3793
+ setFullscreen: () => {},
3794
+ getSize: () => ({ width: aura.window.width, height: aura.window.height }),
3795
+ getPixelRatio: () => aura.window.pixelRatio,
3796
+ getFPS: () => aura.window.fps,
3797
+ close: () => {},
3798
+ },
3799
+
3800
+ draw2d: {
3801
+ clear: drawNoop,
3802
+ rect: drawNoop,
3803
+ rectOutline: drawNoop,
3804
+ circle: drawNoop,
3805
+ circleOutline: drawNoop,
3806
+ line: drawNoop,
3807
+ triangle: drawNoop,
3808
+ image: drawNoop,
3809
+ sprite: drawNoop,
3810
+ text: drawNoop,
3811
+ push: drawNoop,
3812
+ pop: drawNoop,
3813
+ pushTransform: drawNoop,
3814
+ popTransform: drawNoop,
3815
+ translate: drawNoop,
3816
+ rotate: drawNoop,
3817
+ scale: drawNoop,
3818
+ },
3819
+
3820
+ draw3d: {
3821
+ mesh: drawNoop,
3822
+ },
3823
+
3824
+ material: {
3825
+ create: (options = undefined) => {
3826
+ const handle = nextMaterialHandle++;
3827
+ materialStore.set(handle, createMaterialState(options));
3828
+ return handle;
3829
+ },
3830
+ setColor: (handle, color) => {
3831
+ const material = getMaterialState(handle);
3832
+ if (!material) return;
3833
+ const normalized = normalizeMaterialColor(color);
3834
+ if (!normalized) return;
3835
+ material.color = normalized;
3836
+ },
3837
+ setTexture: (handle, texturePath) => {
3838
+ const material = getMaterialState(handle);
3839
+ if (!material) return;
3840
+ if (texturePath === null) {
3841
+ material.texture = null;
3842
+ return;
3843
+ }
3844
+ if (typeof texturePath !== 'string') {
3845
+ throw new Error('aura.material.setTexture: texturePath must be a string or null');
3846
+ }
3847
+ material.texture = texturePath.length > 0 ? texturePath : null;
3848
+ },
3849
+ setMetallic: (handle, metallic) => {
3850
+ const material = getMaterialState(handle);
3851
+ if (!material) return;
3852
+ material.metallic = clampUnit(metallic, material.metallic);
3853
+ },
3854
+ setRoughness: (handle, roughness) => {
3855
+ const material = getMaterialState(handle);
3856
+ if (!material) return;
3857
+ material.roughness = clampUnit(roughness, material.roughness);
3858
+ },
3859
+ setMetallicRoughness: (handle, metallic, roughness) => {
3860
+ const material = getMaterialState(handle);
3861
+ if (!material) return;
3862
+ material.metallic = clampUnit(metallic, material.metallic);
3863
+ material.roughness = clampUnit(roughness, material.roughness);
3864
+ },
3865
+ reset: (handle) => {
3866
+ const material = getMaterialState(handle);
3867
+ if (!material) return;
3868
+ material.color = { ...defaultMaterialState.color };
3869
+ material.metallic = defaultMaterialState.metallic;
3870
+ material.roughness = defaultMaterialState.roughness;
3871
+ material.texture = defaultMaterialState.texture;
3872
+ },
3873
+ clone: (handle) => {
3874
+ const material = getMaterialState(handle);
3875
+ if (!material) throw new Error('Invalid material handle');
3876
+ const newHandle = nextMaterialHandle++;
3877
+ materialStore.set(newHandle, {
3878
+ color: { ...material.color },
3879
+ metallic: material.metallic,
3880
+ roughness: material.roughness,
3881
+ texture: material.texture,
3882
+ });
3883
+ return newHandle;
3884
+ },
3885
+ unload: (handle) => {
3886
+ const normalized = normalizeMaterialHandle(handle);
3887
+ if (normalized == null || normalized === 0) return;
3888
+ materialStore.delete(normalized);
3889
+ },
3890
+ },
3891
+
3892
+ audio,
3893
+
3894
+ assets: {
3895
+ load: async () => {},
3896
+ image: (name) => ({ kind: 'image', name }),
3897
+ sound: (name) => ({ kind: 'sound', name }),
3898
+ mesh: (name) => ({ kind: 'mesh', name }),
3899
+ json: () => ({}),
3900
+ text: () => '',
3901
+ bytes: () => new Uint8Array(),
3902
+ },
3903
+
3904
+ input: {
3905
+ isDown: () => false,
3906
+ isPressed: () => false,
3907
+ isReleased: () => false,
3908
+ isKeyDown: (...args) => aura.input.isDown(...args),
3909
+ isKeyPressed: (...args) => aura.input.isPressed(...args),
3910
+ isKeyReleased: (...args) => aura.input.isReleased(...args),
3911
+ isMouseDown: (...args) => aura.input.mouse.isDown(...args),
3912
+ isMousePressed: (...args) => aura.input.mouse.isPressed(...args),
3913
+ isMouseReleased: (...args) => aura.input.mouse.isReleased(...args),
3914
+ getMousePosition: () => ({ x: aura.input.mouse.x, y: aura.input.mouse.y }),
3915
+ isGamepadConnected: () => Boolean(aura.input.gamepad.connected),
3916
+ mouse: {
3917
+ x: 0,
3918
+ y: 0,
3919
+ scroll: 0,
3920
+ isDown: () => false,
3921
+ isPressed: () => false,
3922
+ isReleased: () => false,
3923
+ },
3924
+ gamepad: {
3925
+ connected: false,
3926
+ axis: () => 0,
3927
+ isDown: () => false,
3928
+ isPressed: () => false,
3929
+ isReleased: () => false,
3930
+ rumble: () => {},
3931
+ },
3932
+ },
3933
+
3934
+ storage: {
3935
+ _store: new Map(),
3936
+ set(key, value) {
3937
+ this._store.set(key, value);
3938
+ },
3939
+ get(key, fallback = null) {
3940
+ return this._store.has(key) ? this._store.get(key) : fallback;
3941
+ },
3942
+ delete(key) {
3943
+ this._store.delete(key);
3944
+ },
3945
+ },
3946
+
3947
+ math: {
3948
+ lerp: (a, b, t) => a + (b - a) * t,
3949
+ clamp: (value, min, max) => Math.max(min, Math.min(max, value)),
3950
+ random: (...args) => {
3951
+ if (args.length === 0) {
3952
+ return Math.random();
3953
+ }
3954
+ if (args.length === 1) {
3955
+ return Math.random() * args[0];
3956
+ }
3957
+ return args[0] + Math.random() * (args[1] - args[0]);
3958
+ },
3959
+ randomInt: (min, max) => Math.floor(min + Math.random() * (max - min + 1)),
3960
+ randomFloat: (min, max) => min + Math.random() * (max - min),
3961
+ distance: (x1, y1, x2, y2) => Math.hypot(x2 - x1, y2 - y1),
3962
+ angle: (x1, y1, x2, y2) => Math.atan2(y2 - y1, x2 - x1),
3963
+ degToRad: (degrees) => (degrees * Math.PI) / 180,
3964
+ radToDeg: (radians) => (radians * 180) / Math.PI,
3965
+ PI: Math.PI,
3966
+ TAU: Math.PI * 2,
3967
+ },
3968
+
3969
+ timer: {
3970
+ after: (_seconds, callback) => {
3971
+ const id = Symbol('after');
3972
+ if (typeof callback === 'function') callback();
3973
+ return id;
3974
+ },
3975
+ every: (_seconds, callback) => {
3976
+ const id = Symbol('every');
3977
+ if (typeof callback === 'function') callback();
3978
+ return id;
3979
+ },
3980
+ cancel: () => {},
3981
+ getDelta: () => 1 / 60,
3982
+ getTime: () => 0,
3983
+ },
3984
+
3985
+ collision,
3986
+ collide,
3987
+
3988
+ ecs: {
3989
+ createEntity: () => {
3990
+ const id = nextEcsEntityId++;
3991
+ ecsEntities.add(id);
3992
+ return id;
3993
+ },
3994
+ removeEntity: (entityId) => {
3995
+ const id = normalizeEntityId(entityId);
3996
+ if (id == null || !ecsEntities.has(id)) return false;
3997
+ ecsEntities.delete(id);
3998
+ for (const store of ecsComponentStores.values()) {
3999
+ store.delete(id);
4000
+ }
4001
+ return true;
4002
+ },
4003
+ addComponent: (entityId, componentName, componentValue) => {
4004
+ const id = normalizeEntityId(entityId);
4005
+ const name = normalizeComponentName(componentName);
4006
+ if (id == null || name == null || !ecsEntities.has(id)) return false;
4007
+ if (!ecsComponentStores.has(name)) {
4008
+ ecsComponentStores.set(name, new Map());
4009
+ }
4010
+ ecsComponentStores.get(name).set(id, componentValue);
4011
+ return true;
4012
+ },
4013
+ getComponent: (entityId, componentName) => {
4014
+ const id = normalizeEntityId(entityId);
4015
+ const name = normalizeComponentName(componentName);
4016
+ if (id == null || name == null) return undefined;
4017
+ const store = ecsComponentStores.get(name);
4018
+ return store ? store.get(id) : undefined;
4019
+ },
4020
+ system: (name, fn, order = 0) => {
4021
+ const normalizedName = normalizeSystemName(name);
4022
+ if (normalizedName == null || typeof fn !== 'function') return false;
4023
+ const normalizedOrder = Number.isFinite(order) ? Number(order) : 0;
4024
+ const existing = ecsSystems.find((entry) => entry.name === normalizedName);
4025
+ if (existing) {
4026
+ existing.fn = fn;
4027
+ existing.order = normalizedOrder;
4028
+ } else {
4029
+ ecsSystems.push({ name: normalizedName, fn, order: normalizedOrder });
4030
+ }
4031
+ sortEcsSystems();
4032
+ return true;
4033
+ },
4034
+ run: (dt) => {
4035
+ for (const entry of ecsSystems) {
4036
+ entry.fn(dt);
4037
+ }
4038
+ },
4039
+ },
4040
+
4041
+ animation,
4042
+
4043
+ anim2d: {
4044
+ registerClip: (name, frames, options = {}) => {
4045
+ const clipName = normalizeAnim2dName(name);
4046
+ const normalizedFrames = normalizeAnim2dFrames(frames);
4047
+ if (clipName == null || normalizedFrames == null) return false;
4048
+ anim2dClips.set(clipName, {
4049
+ name: clipName,
4050
+ frames: normalizedFrames,
4051
+ frameDuration: normalizeAnim2dFrameDuration(options?.frameDuration),
4052
+ loop: options?.loop !== false,
4053
+ });
4054
+ return true;
4055
+ },
4056
+ createMachine: (initialState = null) => {
4057
+ const id = nextAnim2dMachineId++;
4058
+ anim2dMachines.set(id, {
4059
+ id,
4060
+ states: new Map(),
4061
+ currentState: normalizeAnim2dName(initialState),
4062
+ frameIndex: 0,
4063
+ elapsed: 0,
4064
+ completed: false,
4065
+ loops: 0,
4066
+ callbacks: [],
4067
+ });
4068
+ return id;
4069
+ },
4070
+ defineState: (machineId, stateName, clipOrFrames, options = {}) => {
4071
+ const id = normalizeEntityId(machineId);
4072
+ const normalizedStateName = normalizeAnim2dName(stateName);
4073
+ if (id == null || normalizedStateName == null) return false;
4074
+ const machine = anim2dMachines.get(id);
4075
+ if (!machine) return false;
4076
+
4077
+ let resolvedFrames = null;
4078
+ let resolvedFrameDuration = normalizeAnim2dFrameDuration(options?.frameDuration);
4079
+ let resolvedLoop = options?.loop !== false;
4080
+
4081
+ if (typeof clipOrFrames === 'string') {
4082
+ const clip = anim2dClips.get(clipOrFrames);
4083
+ if (!clip) return false;
4084
+ resolvedFrames = clip.frames.slice();
4085
+ resolvedFrameDuration = clip.frameDuration;
4086
+ resolvedLoop = clip.loop;
4087
+ if (Object.prototype.hasOwnProperty.call(options, 'frameDuration')) {
4088
+ resolvedFrameDuration = normalizeAnim2dFrameDuration(options.frameDuration);
4089
+ }
4090
+ if (Object.prototype.hasOwnProperty.call(options, 'loop')) {
4091
+ resolvedLoop = options.loop !== false;
4092
+ }
4093
+ } else {
4094
+ resolvedFrames = normalizeAnim2dFrames(clipOrFrames);
4095
+ if (resolvedFrames == null) return false;
4096
+ }
4097
+
4098
+ machine.states.set(normalizedStateName, {
4099
+ name: normalizedStateName,
4100
+ frames: resolvedFrames,
4101
+ frameDuration: resolvedFrameDuration,
4102
+ loop: resolvedLoop,
4103
+ });
4104
+ if (machine.currentState == null) {
4105
+ machine.currentState = normalizedStateName;
4106
+ }
4107
+ return true;
4108
+ },
4109
+ play: (machineId, stateName) => {
4110
+ const id = normalizeEntityId(machineId);
4111
+ const normalizedStateName = normalizeAnim2dName(stateName);
4112
+ if (id == null || normalizedStateName == null) return false;
4113
+ const machine = anim2dMachines.get(id);
4114
+ if (!machine) return false;
4115
+ if (!machine.states.has(normalizedStateName)) return false;
4116
+ machine.currentState = normalizedStateName;
4117
+ machine.frameIndex = 0;
4118
+ machine.elapsed = 0;
4119
+ machine.completed = false;
4120
+ machine.loops = 0;
4121
+ return true;
4122
+ },
4123
+ onComplete: (machineId, callback, order = 0) => {
4124
+ const id = normalizeEntityId(machineId);
4125
+ if (id == null || typeof callback !== 'function') return false;
4126
+ const machine = anim2dMachines.get(id);
4127
+ if (!machine) return false;
4128
+ machine.callbacks.push({
4129
+ fn: callback,
4130
+ order: normalizeAnim2dOrder(order),
4131
+ seq: nextAnim2dCallbackSeq++,
4132
+ });
4133
+ machine.callbacks.sort((a, b) => (a.order - b.order) || (a.seq - b.seq));
4134
+ return true;
4135
+ },
4136
+ getState: (machineId) => {
4137
+ const id = normalizeEntityId(machineId);
4138
+ if (id == null) return null;
4139
+ return anim2dSnapshot(anim2dMachines.get(id));
4140
+ },
4141
+ update: (dt) => {
4142
+ const stepDt = Number.isFinite(dt) && dt > 0 ? Number(dt) : 0;
4143
+ if (stepDt <= 0) return;
4144
+ for (const machineId of sortedAnim2dMachineIds()) {
4145
+ const machine = anim2dMachines.get(machineId);
4146
+ if (!machine || machine.currentState == null) continue;
4147
+ const state = machine.states.get(machine.currentState);
4148
+ if (!state || state.frames.length === 0) continue;
4149
+ machine.elapsed += stepDt;
4150
+ const frameDuration = normalizeAnim2dFrameDuration(state.frameDuration);
4151
+ while (machine.elapsed >= frameDuration) {
4152
+ machine.elapsed -= frameDuration;
4153
+ if (machine.frameIndex + 1 < state.frames.length) {
4154
+ machine.frameIndex += 1;
4155
+ continue;
4156
+ }
4157
+ if (state.loop) {
4158
+ machine.frameIndex = 0;
4159
+ machine.loops += 1;
4160
+ continue;
4161
+ }
4162
+ machine.frameIndex = state.frames.length - 1;
4163
+ if (!machine.completed) {
4164
+ machine.completed = true;
4165
+ const snapshot = anim2dSnapshot(machine);
4166
+ for (const callback of machine.callbacks) {
4167
+ try {
4168
+ callback.fn(snapshot);
4169
+ } catch {
4170
+ // Keep update loop deterministic even if callbacks throw.
4171
+ }
4172
+ }
4173
+ }
4174
+ break;
4175
+ }
4176
+ }
4177
+ },
4178
+ },
4179
+
4180
+ scene,
4181
+ scene3d,
4182
+ tilemap,
4183
+
4184
+ test: {
4185
+ assert(condition, message = 'Assertion failed') {
4186
+ if (!condition) {
4187
+ testState.failures.push(message);
4188
+ throw new Error(message);
4189
+ }
4190
+ testState.passes += 1;
4191
+ },
4192
+ equal(actual, expected, message = null) {
4193
+ if (!Object.is(actual, expected)) {
4194
+ const detail = message || `Expected ${JSON.stringify(expected)} but got ${JSON.stringify(actual)}`;
4195
+ testState.failures.push(detail);
4196
+ throw new Error(detail);
4197
+ }
4198
+ testState.passes += 1;
4199
+ },
4200
+ fail(message = 'Test failed') {
4201
+ testState.failures.push(message);
4202
+ throw new Error(message);
4203
+ },
4204
+ },
4205
+
4206
+ debug: {
4207
+ log: (...args) => console.log(...args),
4208
+ drawRect: () => {
4209
+ testState.drawCalls += 1;
4210
+ },
4211
+ drawCircle: () => {
4212
+ testState.drawCalls += 1;
4213
+ },
4214
+ drawText: () => {
4215
+ testState.drawCalls += 1;
4216
+ },
4217
+ cacheStats: () => ({
4218
+ assets: {
4219
+ entryCount: 0,
4220
+ estimatedBytes: 0,
4221
+ handleCount: 0,
4222
+ nextHandleId: 1,
4223
+ cacheHitCount: 0,
4224
+ cacheMissCount: 0,
4225
+ resetCount: 0,
4226
+ lastResetSequence: 0,
4227
+ },
4228
+ audio: {
4229
+ decodedCacheEntries: 0,
4230
+ decodedSampleCount: 0,
4231
+ activeHandleCount: 0,
4232
+ nextHandleId: 1,
4233
+ decodedCacheHitCount: 0,
4234
+ decodedCacheMissCount: 0,
4235
+ resetCount: 0,
4236
+ lastResetSequence: 0,
4237
+ },
4238
+ }),
4239
+ resetCaches: () => {},
4240
+ enableInspector: (enabled = true) => {
4241
+ debugInspectorState.enabled = enabled !== false;
4242
+ if (!debugInspectorState.enabled) {
4243
+ debugInspectorState.frameCount = 0;
4244
+ debugInspectorState.elapsedSeconds = 0;
4245
+ }
4246
+ return debugInspectorState.enabled;
4247
+ },
4248
+ inspectorStats: () => debugInspectorSnapshot(),
4249
+ __inspectorTick: (dt) => {
4250
+ if (!debugInspectorState.enabled) return;
4251
+ debugInspectorState.frameCount += 1;
4252
+ if (Number.isFinite(dt) && dt > 0) {
4253
+ debugInspectorState.elapsedSeconds += Number(dt);
4254
+ }
4255
+ },
4256
+ },
4257
+
4258
+ color: (...args) => ({ kind: 'color', args }),
4259
+ colors: {
4260
+ white: { r: 255, g: 255, b: 255, a: 255 },
4261
+ black: { r: 0, g: 0, b: 0, a: 255 },
4262
+ red: { r: 255, g: 0, b: 0, a: 255 },
4263
+ transparent: { r: 0, g: 0, b: 0, a: 0 },
4264
+ },
4265
+ camera,
4266
+ camera3d: { position: [0, 0, 0], fov: 60, lookAt: () => {} },
4267
+ light: { ambient: () => {}, directional: () => {}, point: () => {} },
4268
+ vec2,
4269
+ vec3,
4270
+ };
4271
+
4272
+ return aura;
4273
+ }
4274
+
4275
+ async function executeLifecycle(aura, frames) {
4276
+ if (typeof aura.setup === 'function') {
4277
+ await Promise.resolve(aura.setup());
4278
+ }
4279
+
4280
+ for (let i = 0; i < frames; i += 1) {
4281
+ const dt = 1 / 60;
4282
+ if (aura.debug && typeof aura.debug.__inspectorTick === 'function') {
4283
+ aura.debug.__inspectorTick(dt);
4284
+ }
4285
+ if (aura.anim2d && typeof aura.anim2d.update === 'function') {
4286
+ await Promise.resolve(aura.anim2d.update(dt));
4287
+ }
4288
+ if (aura.ecs && typeof aura.ecs.run === 'function') {
4289
+ await Promise.resolve(aura.ecs.run(dt));
4290
+ }
4291
+ if (typeof aura.update === 'function') {
4292
+ await Promise.resolve(aura.update(dt));
4293
+ }
4294
+ if (typeof aura.draw === 'function') {
4295
+ await Promise.resolve(aura.draw());
4296
+ }
4297
+ }
4298
+ }
4299
+
4300
+ function parsePositiveInt(value, flagName) {
4301
+ const parsed = Number(value);
4302
+ if (!Number.isFinite(parsed) || parsed <= 0 || !Number.isInteger(parsed)) {
4303
+ throw new HeadlessTestError(`${flagName} must be a positive integer.`);
4304
+ }
4305
+ return parsed;
4306
+ }
4307
+
4308
+ function parseNonNegativeInt(value, flagName) {
4309
+ const parsed = Number(value);
4310
+ if (!Number.isFinite(parsed) || parsed < 0 || !Number.isInteger(parsed)) {
4311
+ throw new HeadlessTestError(`${flagName} must be a non-negative integer.`);
4312
+ }
4313
+ return parsed;
4314
+ }