@zylem/game-lib 0.6.0 → 0.6.3

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.
Files changed (54) hide show
  1. package/README.md +9 -16
  2. package/dist/actions.d.ts +30 -21
  3. package/dist/actions.js +628 -145
  4. package/dist/actions.js.map +1 -1
  5. package/dist/behavior/platformer-3d.d.ts +296 -0
  6. package/dist/behavior/platformer-3d.js +518 -0
  7. package/dist/behavior/platformer-3d.js.map +1 -0
  8. package/dist/behavior/ricochet-2d.d.ts +274 -0
  9. package/dist/behavior/ricochet-2d.js +394 -0
  10. package/dist/behavior/ricochet-2d.js.map +1 -0
  11. package/dist/behavior/screen-wrap.d.ts +86 -0
  12. package/dist/behavior/screen-wrap.js +195 -0
  13. package/dist/behavior/screen-wrap.js.map +1 -0
  14. package/dist/behavior/thruster.d.ts +10 -0
  15. package/dist/behavior/thruster.js +234 -0
  16. package/dist/behavior/thruster.js.map +1 -0
  17. package/dist/behavior/world-boundary-2d.d.ts +141 -0
  18. package/dist/behavior/world-boundary-2d.js +181 -0
  19. package/dist/behavior/world-boundary-2d.js.map +1 -0
  20. package/dist/behavior-descriptor-BWNWmIjv.d.ts +142 -0
  21. package/dist/{blueprints-BOCc3Wve.d.ts → blueprints-BWGz8fII.d.ts} +2 -2
  22. package/dist/camera-B5e4c78l.d.ts +468 -0
  23. package/dist/camera.d.ts +3 -2
  24. package/dist/camera.js +962 -166
  25. package/dist/camera.js.map +1 -1
  26. package/dist/composition-DrzFrbqI.d.ts +218 -0
  27. package/dist/{core-CZhozNRH.d.ts → core-DAkskq6Y.d.ts} +97 -65
  28. package/dist/core.d.ts +12 -6
  29. package/dist/core.js +4449 -1052
  30. package/dist/core.js.map +1 -1
  31. package/dist/{entities-BAxfJOkk.d.ts → entities-DC9ce_vx.d.ts} +154 -45
  32. package/dist/entities.d.ts +5 -2
  33. package/dist/entities.js +2505 -722
  34. package/dist/entities.js.map +1 -1
  35. package/dist/entity-BpbZqg19.d.ts +1100 -0
  36. package/dist/entity-types-DAu8sGJH.d.ts +26 -0
  37. package/dist/global-change-Dc8uCKi2.d.ts +25 -0
  38. package/dist/main.d.ts +472 -29
  39. package/dist/main.js +11877 -6124
  40. package/dist/main.js.map +1 -1
  41. package/dist/{stage-types-CD21XoIU.d.ts → stage-types-BFsm3qsZ.d.ts} +255 -26
  42. package/dist/stage.d.ts +11 -6
  43. package/dist/stage.js +3462 -491
  44. package/dist/stage.js.map +1 -1
  45. package/dist/thruster-DhRaJnoL.d.ts +172 -0
  46. package/dist/world-Be5m1XC1.d.ts +31 -0
  47. package/package.json +21 -4
  48. package/dist/behaviors.d.ts +0 -106
  49. package/dist/behaviors.js +0 -398
  50. package/dist/behaviors.js.map +0 -1
  51. package/dist/camera-CpbDr4-V.d.ts +0 -116
  52. package/dist/entity-COvRtFNG.d.ts +0 -395
  53. package/dist/moveable-B_vyA6cw.d.ts +0 -67
  54. package/dist/transformable-CUhvyuYO.d.ts +0 -67
package/dist/stage.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // src/lib/stage/zylem-stage.ts
2
- import { addComponent, addEntity, createWorld as createECS, removeEntity } from "bitecs";
3
- import { Color as Color7, Vector3 as Vector311 } from "three";
2
+ import { addEntity, createWorld as createECS, removeEntity } from "bitecs";
3
+ import { Color as Color9, Vector3 as Vector318 } from "three";
4
4
 
5
5
  // src/lib/collision/world.ts
6
6
  import RAPIER from "@dimforge/rapier3d-compat";
@@ -61,11 +61,11 @@ function getGlobals() {
61
61
  }
62
62
 
63
63
  // src/lib/entities/actor.ts
64
- import { ActiveCollisionTypes, ColliderDesc } from "@dimforge/rapier3d-compat";
65
- import { SkinnedMesh, Group as Group2, Vector3 } from "three";
64
+ import { ActiveCollisionTypes as ActiveCollisionTypes2, ColliderDesc as ColliderDesc2 } from "@dimforge/rapier3d-compat";
65
+ import { MeshStandardMaterial as MeshStandardMaterial2, Group as Group2, Vector3 as Vector37 } from "three";
66
66
 
67
67
  // src/lib/entities/entity.ts
68
- import { ShaderMaterial } from "three";
68
+ import { Mesh, ShaderMaterial, Group } from "three";
69
69
 
70
70
  // src/lib/systems/transformable.system.ts
71
71
  import {
@@ -152,6 +152,9 @@ var BaseNode = class _BaseNode {
152
152
  markedForRemoval = false;
153
153
  /**
154
154
  * Lifecycle callback arrays - use onSetup(), onUpdate(), etc. to add callbacks
155
+ * Uses `any` for the type parameter to avoid invariance issues when subclasses
156
+ * are assigned to BaseNode references. Type safety is enforced by the public
157
+ * onSetup/onUpdate/etc. methods which are typed with `this`.
155
158
  */
156
159
  lifecycleCallbacks = {
157
160
  setup: [],
@@ -265,12 +268,16 @@ var BaseNode = class _BaseNode {
265
268
  if (typeof this._update === "function") {
266
269
  this._update(params);
267
270
  }
271
+ if (typeof this._tickActions === "function") {
272
+ this._tickActions(params.delta);
273
+ }
268
274
  for (const callback of this.lifecycleCallbacks.update) {
269
275
  callback(params);
270
276
  }
271
277
  this.children.forEach((child) => child.nodeUpdate(params));
272
278
  }
273
279
  nodeDestroy(params) {
280
+ if (this.markedForRemoval) return;
274
281
  this.children.forEach((child) => child.nodeDestroy(params));
275
282
  for (const callback of this.lifecycleCallbacks.destroy) {
276
283
  callback(params);
@@ -279,6 +286,12 @@ var BaseNode = class _BaseNode {
279
286
  this._destroy(params);
280
287
  }
281
288
  this.markedForRemoval = true;
289
+ for (const callback of this.lifecycleCallbacks.cleanup) {
290
+ callback(params);
291
+ }
292
+ if (typeof this._cleanup === "function") {
293
+ this._cleanup(params);
294
+ }
282
295
  }
283
296
  async nodeLoaded(params) {
284
297
  if (typeof this._loaded === "function") {
@@ -288,12 +301,13 @@ var BaseNode = class _BaseNode {
288
301
  callback(params);
289
302
  }
290
303
  }
291
- async nodeCleanup(params) {
304
+ nodeCleanup(params) {
305
+ this.children.forEach((child) => child.nodeCleanup?.(params));
292
306
  for (const callback of this.lifecycleCallbacks.cleanup) {
293
307
  callback(params);
294
308
  }
295
309
  if (typeof this._cleanup === "function") {
296
- await this._cleanup(params);
310
+ this._cleanup(params);
297
311
  }
298
312
  }
299
313
  // ─────────────────────────────────────────────────────────────────────────────
@@ -307,6 +321,422 @@ var BaseNode = class _BaseNode {
307
321
  }
308
322
  };
309
323
 
324
+ // ../../node_modules/.pnpm/mitt@3.0.1/node_modules/mitt/dist/mitt.mjs
325
+ function mitt_default(n) {
326
+ return { all: n = n || /* @__PURE__ */ new Map(), on: function(t, e) {
327
+ var i = n.get(t);
328
+ i ? i.push(e) : n.set(t, [e]);
329
+ }, off: function(t, e) {
330
+ var i = n.get(t);
331
+ i && (e ? i.splice(i.indexOf(e) >>> 0, 1) : n.set(t, []));
332
+ }, emit: function(t, e) {
333
+ var i = n.get(t);
334
+ i && i.slice().map(function(n2) {
335
+ n2(e);
336
+ }), (i = n.get("*")) && i.slice().map(function(n2) {
337
+ n2(t, e);
338
+ });
339
+ } };
340
+ }
341
+
342
+ // src/lib/events/event-emitter-delegate.ts
343
+ var EventEmitterDelegate = class {
344
+ emitter;
345
+ unsubscribes = [];
346
+ constructor() {
347
+ this.emitter = mitt_default();
348
+ }
349
+ /**
350
+ * Dispatch an event to all listeners.
351
+ */
352
+ dispatch(event, payload) {
353
+ this.emitter.emit(event, payload);
354
+ }
355
+ /**
356
+ * Subscribe to an event. Returns an unsubscribe function.
357
+ */
358
+ listen(event, handler) {
359
+ this.emitter.on(event, handler);
360
+ const unsub = () => this.emitter.off(event, handler);
361
+ this.unsubscribes.push(unsub);
362
+ return unsub;
363
+ }
364
+ /**
365
+ * Subscribe to all events.
366
+ */
367
+ listenAll(handler) {
368
+ this.emitter.on("*", handler);
369
+ const unsub = () => this.emitter.off("*", handler);
370
+ this.unsubscribes.push(unsub);
371
+ return unsub;
372
+ }
373
+ /**
374
+ * Clean up all subscriptions.
375
+ */
376
+ dispose() {
377
+ this.unsubscribes.forEach((fn) => fn());
378
+ this.unsubscribes = [];
379
+ this.emitter.all.clear();
380
+ }
381
+ };
382
+
383
+ // src/lib/events/zylem-events.ts
384
+ var zylemEventBus = mitt_default();
385
+
386
+ // src/lib/actions/capabilities/transform-store.ts
387
+ import { proxy as proxy2 } from "valtio";
388
+ function createTransformStore(initial) {
389
+ const defaultState = {
390
+ position: { x: 0, y: 0, z: 0 },
391
+ rotation: { x: 0, y: 0, z: 0, w: 1 },
392
+ velocity: { x: 0, y: 0, z: 0 },
393
+ angularVelocity: { x: 0, y: 0, z: 0 },
394
+ dirty: {
395
+ position: false,
396
+ rotation: false,
397
+ velocity: false,
398
+ angularVelocity: false
399
+ }
400
+ };
401
+ return proxy2({
402
+ ...defaultState,
403
+ ...initial,
404
+ // Ensure dirty flags are properly initialized even if partial initial state
405
+ dirty: {
406
+ ...defaultState.dirty,
407
+ ...initial?.dirty
408
+ }
409
+ });
410
+ }
411
+
412
+ // src/lib/actions/capabilities/moveable.ts
413
+ import { Vector3 } from "three";
414
+ function moveX(entity, delta) {
415
+ if (!entity.transformStore) return;
416
+ entity.transformStore.velocity.x = delta;
417
+ entity.transformStore.dirty.velocity = true;
418
+ }
419
+ function moveY(entity, delta) {
420
+ if (!entity.transformStore) return;
421
+ entity.transformStore.velocity.y = delta;
422
+ entity.transformStore.dirty.velocity = true;
423
+ }
424
+ function moveZ(entity, delta) {
425
+ if (!entity.transformStore) return;
426
+ entity.transformStore.velocity.z = delta;
427
+ entity.transformStore.dirty.velocity = true;
428
+ }
429
+ function moveXY(entity, deltaX, deltaY) {
430
+ if (!entity.transformStore) return;
431
+ entity.transformStore.velocity.x = deltaX;
432
+ entity.transformStore.velocity.y = deltaY;
433
+ entity.transformStore.dirty.velocity = true;
434
+ }
435
+ function moveXZ(entity, deltaX, deltaZ) {
436
+ if (!entity.transformStore) return;
437
+ entity.transformStore.velocity.x = deltaX;
438
+ entity.transformStore.velocity.z = deltaZ;
439
+ entity.transformStore.dirty.velocity = true;
440
+ }
441
+ function move(entity, vector) {
442
+ if (!entity.transformStore) return;
443
+ entity.transformStore.velocity.x += vector.x;
444
+ entity.transformStore.velocity.y += vector.y;
445
+ entity.transformStore.velocity.z += vector.z;
446
+ entity.transformStore.dirty.velocity = true;
447
+ }
448
+ function resetVelocity(entity) {
449
+ if (!entity.body) return;
450
+ entity.body.setLinvel(new Vector3(0, 0, 0), true);
451
+ entity.body.setLinearDamping(5);
452
+ }
453
+ function moveForwardXY(entity, delta, rotation2DAngle) {
454
+ const deltaX = Math.sin(-rotation2DAngle) * delta;
455
+ const deltaY = Math.cos(-rotation2DAngle) * delta;
456
+ moveXY(entity, deltaX, deltaY);
457
+ }
458
+ function getPosition(entity) {
459
+ if (!entity.body) return null;
460
+ return entity.body.translation();
461
+ }
462
+ function getVelocity(entity) {
463
+ if (!entity.body) return null;
464
+ return entity.body.linvel();
465
+ }
466
+ function setPosition(entity, x, y, z) {
467
+ if (!entity.body) return;
468
+ entity.body.setTranslation({ x, y, z }, true);
469
+ }
470
+ function setPositionX(entity, x) {
471
+ if (!entity.body) return;
472
+ const { y, z } = entity.body.translation();
473
+ entity.body.setTranslation({ x, y, z }, true);
474
+ }
475
+ function setPositionY(entity, y) {
476
+ if (!entity.body) return;
477
+ const { x, z } = entity.body.translation();
478
+ entity.body.setTranslation({ x, y, z }, true);
479
+ }
480
+ function setPositionZ(entity, z) {
481
+ if (!entity.body) return;
482
+ const { x, y } = entity.body.translation();
483
+ entity.body.setTranslation({ x, y, z }, true);
484
+ }
485
+ function wrapAroundXY(entity, boundsX, boundsY) {
486
+ const position2 = getPosition(entity);
487
+ if (!position2) return;
488
+ const { x, y } = position2;
489
+ const newX = x > boundsX ? -boundsX : x < -boundsX ? boundsX : x;
490
+ const newY = y > boundsY ? -boundsY : y < -boundsY ? boundsY : y;
491
+ if (newX !== x || newY !== y) {
492
+ setPosition(entity, newX, newY, 0);
493
+ }
494
+ }
495
+ function wrapAround3D(entity, boundsX, boundsY, boundsZ) {
496
+ const position2 = getPosition(entity);
497
+ if (!position2) return;
498
+ const { x, y, z } = position2;
499
+ const newX = x > boundsX ? -boundsX : x < -boundsX ? boundsX : x;
500
+ const newY = y > boundsY ? -boundsY : y < -boundsY ? boundsY : y;
501
+ const newZ = z > boundsZ ? -boundsZ : z < -boundsZ ? boundsZ : z;
502
+ if (newX !== x || newY !== y || newZ !== z) {
503
+ setPosition(entity, newX, newY, newZ);
504
+ }
505
+ }
506
+ function makeMoveable(entity) {
507
+ const moveable = entity;
508
+ if (!moveable.transformStore) {
509
+ moveable.transformStore = createTransformStore();
510
+ }
511
+ moveable.moveX = (delta) => moveX(entity, delta);
512
+ moveable.moveY = (delta) => moveY(entity, delta);
513
+ moveable.moveZ = (delta) => moveZ(entity, delta);
514
+ moveable.moveXY = (deltaX, deltaY) => moveXY(entity, deltaX, deltaY);
515
+ moveable.moveXZ = (deltaX, deltaZ) => moveXZ(entity, deltaX, deltaZ);
516
+ moveable.move = (vector) => move(entity, vector);
517
+ moveable.resetVelocity = () => resetVelocity(entity);
518
+ moveable.moveForwardXY = (delta, rotation2DAngle) => moveForwardXY(entity, delta, rotation2DAngle);
519
+ moveable.getPosition = () => getPosition(entity);
520
+ moveable.getVelocity = () => getVelocity(entity);
521
+ moveable.setPosition = (x, y, z) => setPosition(entity, x, y, z);
522
+ moveable.setPositionX = (x) => setPositionX(entity, x);
523
+ moveable.setPositionY = (y) => setPositionY(entity, y);
524
+ moveable.setPositionZ = (z) => setPositionZ(entity, z);
525
+ moveable.wrapAroundXY = (boundsX, boundsY) => wrapAroundXY(entity, boundsX, boundsY);
526
+ moveable.wrapAround3D = (boundsX, boundsY, boundsZ) => wrapAround3D(entity, boundsX, boundsY, boundsZ);
527
+ return moveable;
528
+ }
529
+
530
+ // src/lib/actions/capabilities/rotatable.ts
531
+ import { Euler, Vector3 as Vector32, MathUtils, Quaternion as Quaternion2 } from "three";
532
+ function rotateInDirection(entity, moveVector) {
533
+ if (!entity.body) return;
534
+ const rotate = Math.atan2(-moveVector.x, moveVector.z);
535
+ rotateYEuler(entity, rotate);
536
+ }
537
+ function rotateYEuler(entity, amount) {
538
+ rotateEuler(entity, new Vector32(0, -amount, 0));
539
+ }
540
+ function rotateEuler(entity, rotation2) {
541
+ if (!entity.group) return;
542
+ const euler = new Euler(rotation2.x, rotation2.y, rotation2.z);
543
+ entity.group.setRotationFromEuler(euler);
544
+ }
545
+ function rotateY(entity, delta) {
546
+ if (!entity.transformStore) return;
547
+ const halfAngle = delta / 2;
548
+ const deltaW = Math.cos(halfAngle);
549
+ const deltaY = Math.sin(halfAngle);
550
+ const q = entity.transformStore.rotation;
551
+ const newW = q.w * deltaW - q.y * deltaY;
552
+ const newX = q.x * deltaW + q.z * deltaY;
553
+ const newY = q.y * deltaW + q.w * deltaY;
554
+ const newZ = q.z * deltaW - q.x * deltaY;
555
+ entity.transformStore.rotation.w = newW;
556
+ entity.transformStore.rotation.x = newX;
557
+ entity.transformStore.rotation.y = newY;
558
+ entity.transformStore.rotation.z = newZ;
559
+ entity.transformStore.dirty.rotation = true;
560
+ }
561
+ function rotateX(entity, delta) {
562
+ if (!entity.transformStore) return;
563
+ const halfAngle = delta / 2;
564
+ const deltaW = Math.cos(halfAngle);
565
+ const deltaX = Math.sin(halfAngle);
566
+ const q = entity.transformStore.rotation;
567
+ const newW = q.w * deltaW - q.x * deltaX;
568
+ const newX = q.x * deltaW + q.w * deltaX;
569
+ const newY = q.y * deltaW + q.z * deltaX;
570
+ const newZ = q.z * deltaW - q.y * deltaX;
571
+ entity.transformStore.rotation.w = newW;
572
+ entity.transformStore.rotation.x = newX;
573
+ entity.transformStore.rotation.y = newY;
574
+ entity.transformStore.rotation.z = newZ;
575
+ entity.transformStore.dirty.rotation = true;
576
+ }
577
+ function rotateZ(entity, delta) {
578
+ if (!entity.transformStore) return;
579
+ const halfAngle = delta / 2;
580
+ const deltaW = Math.cos(halfAngle);
581
+ const deltaZ = Math.sin(halfAngle);
582
+ const q = entity.transformStore.rotation;
583
+ const newW = q.w * deltaW - q.z * deltaZ;
584
+ const newX = q.x * deltaW - q.y * deltaZ;
585
+ const newY = q.y * deltaW + q.x * deltaZ;
586
+ const newZ = q.z * deltaW + q.w * deltaZ;
587
+ entity.transformStore.rotation.w = newW;
588
+ entity.transformStore.rotation.x = newX;
589
+ entity.transformStore.rotation.y = newY;
590
+ entity.transformStore.rotation.z = newZ;
591
+ entity.transformStore.dirty.rotation = true;
592
+ }
593
+ function setRotationY(entity, y) {
594
+ if (!entity.transformStore) return;
595
+ const halfAngle = y / 2;
596
+ const w = Math.cos(halfAngle);
597
+ const yComponent = Math.sin(halfAngle);
598
+ entity.transformStore.rotation.w = w;
599
+ entity.transformStore.rotation.x = 0;
600
+ entity.transformStore.rotation.y = yComponent;
601
+ entity.transformStore.rotation.z = 0;
602
+ entity.transformStore.dirty.rotation = true;
603
+ }
604
+ function setRotationDegreesY(entity, y) {
605
+ if (!entity.body) return;
606
+ setRotationY(entity, MathUtils.degToRad(y));
607
+ }
608
+ function setRotationX(entity, x) {
609
+ if (!entity.transformStore) return;
610
+ const halfAngle = x / 2;
611
+ const w = Math.cos(halfAngle);
612
+ const xComponent = Math.sin(halfAngle);
613
+ entity.transformStore.rotation.w = w;
614
+ entity.transformStore.rotation.x = xComponent;
615
+ entity.transformStore.rotation.y = 0;
616
+ entity.transformStore.rotation.z = 0;
617
+ entity.transformStore.dirty.rotation = true;
618
+ }
619
+ function setRotationDegreesX(entity, x) {
620
+ if (!entity.body) return;
621
+ setRotationX(entity, MathUtils.degToRad(x));
622
+ }
623
+ function setRotationZ(entity, z) {
624
+ if (!entity.transformStore) return;
625
+ const halfAngle = z / 2;
626
+ const w = Math.cos(halfAngle);
627
+ const zComponent = Math.sin(halfAngle);
628
+ entity.transformStore.rotation.w = w;
629
+ entity.transformStore.rotation.x = 0;
630
+ entity.transformStore.rotation.y = 0;
631
+ entity.transformStore.rotation.z = zComponent;
632
+ entity.transformStore.dirty.rotation = true;
633
+ }
634
+ function setRotationDegreesZ(entity, z) {
635
+ if (!entity.body) return;
636
+ setRotationZ(entity, MathUtils.degToRad(z));
637
+ }
638
+ function setRotation(entity, x, y, z) {
639
+ if (!entity.transformStore) return;
640
+ const quat = new Quaternion2().setFromEuler(new Euler(x, y, z));
641
+ entity.transformStore.rotation.w = quat.w;
642
+ entity.transformStore.rotation.x = quat.x;
643
+ entity.transformStore.rotation.y = quat.y;
644
+ entity.transformStore.rotation.z = quat.z;
645
+ entity.transformStore.dirty.rotation = true;
646
+ }
647
+ function setRotationDegrees(entity, x, y, z) {
648
+ if (!entity.body) return;
649
+ setRotation(entity, MathUtils.degToRad(x), MathUtils.degToRad(y), MathUtils.degToRad(z));
650
+ }
651
+ function getRotation(entity) {
652
+ if (!entity.body) return null;
653
+ return entity.body.rotation();
654
+ }
655
+ function makeRotatable(entity) {
656
+ const rotatableEntity = entity;
657
+ if (!rotatableEntity.transformStore) {
658
+ rotatableEntity.transformStore = createTransformStore();
659
+ }
660
+ rotatableEntity.rotateInDirection = (moveVector) => rotateInDirection(entity, moveVector);
661
+ rotatableEntity.rotateYEuler = (amount) => rotateYEuler(entity, amount);
662
+ rotatableEntity.rotateEuler = (rotation2) => rotateEuler(entity, rotation2);
663
+ rotatableEntity.rotateX = (delta) => rotateX(entity, delta);
664
+ rotatableEntity.rotateY = (delta) => rotateY(entity, delta);
665
+ rotatableEntity.rotateZ = (delta) => rotateZ(entity, delta);
666
+ rotatableEntity.setRotationY = (y) => setRotationY(entity, y);
667
+ rotatableEntity.setRotationX = (x) => setRotationX(entity, x);
668
+ rotatableEntity.setRotationZ = (z) => setRotationZ(entity, z);
669
+ rotatableEntity.setRotationDegreesY = (y) => setRotationDegreesY(entity, y);
670
+ rotatableEntity.setRotationDegreesX = (x) => setRotationDegreesX(entity, x);
671
+ rotatableEntity.setRotationDegreesZ = (z) => setRotationDegreesZ(entity, z);
672
+ rotatableEntity.setRotationDegrees = (x, y, z) => setRotationDegrees(entity, x, y, z);
673
+ rotatableEntity.setRotation = (x, y, z) => setRotation(entity, x, y, z);
674
+ rotatableEntity.getRotation = () => getRotation(entity);
675
+ return rotatableEntity;
676
+ }
677
+
678
+ // src/lib/actions/capabilities/transformable.ts
679
+ function makeTransformable(entity) {
680
+ const withMovement = makeMoveable(entity);
681
+ const withRotation = makeRotatable(withMovement);
682
+ return withRotation;
683
+ }
684
+
685
+ // src/lib/entities/parts/collision-factories.ts
686
+ import {
687
+ ActiveCollisionTypes,
688
+ ColliderDesc,
689
+ RigidBodyDesc,
690
+ RigidBodyType
691
+ } from "@dimforge/rapier3d-compat";
692
+ import { Vector3 as Vector33 } from "three";
693
+ function isCollisionComponent(obj) {
694
+ return obj && obj.__kind === "collision";
695
+ }
696
+
697
+ // src/lib/entities/common.ts
698
+ import { Color, Vector3 as Vector34 } from "three";
699
+
700
+ // src/lib/graphics/shaders/vertex/object.shader.ts
701
+ var objectVertexShader = `
702
+ uniform vec2 uvScale;
703
+ varying vec2 vUv;
704
+
705
+ void main() {
706
+ vUv = uv;
707
+ vec4 mvPosition = modelViewMatrix * vec4( position, 1.0 );
708
+ gl_Position = projectionMatrix * mvPosition;
709
+ }
710
+ `;
711
+
712
+ // src/lib/graphics/shaders/standard.shader.ts
713
+ var fragment = `
714
+ uniform sampler2D tDiffuse;
715
+ varying vec2 vUv;
716
+
717
+ void main() {
718
+ vec4 texel = texture2D( tDiffuse, vUv );
719
+
720
+ gl_FragColor = texel;
721
+ }
722
+ `;
723
+ var standardShader = {
724
+ vertex: objectVertexShader,
725
+ fragment
726
+ };
727
+
728
+ // src/lib/entities/common.ts
729
+ var commonDefaults = {
730
+ position: new Vector34(0, 0, 0),
731
+ material: {
732
+ color: new Color("#ffffff"),
733
+ shader: standardShader
734
+ },
735
+ collision: {
736
+ static: false
737
+ }
738
+ };
739
+
310
740
  // src/lib/entities/entity.ts
311
741
  var GameEntity = class extends BaseNode {
312
742
  behaviors = [];
@@ -318,20 +748,187 @@ var GameEntity = class extends BaseNode {
318
748
  colliderDesc;
319
749
  collider;
320
750
  custom = {};
751
+ // Compound entity support: multiple colliders and meshes
752
+ /** All collider descriptions for this entity (including primary) */
753
+ colliderDescs = [];
754
+ /** All colliders attached to this entity's rigid body */
755
+ colliders = [];
756
+ /** Additional meshes for compound visual entities (added via add()) */
757
+ compoundMeshes = [];
321
758
  debugInfo = {};
322
759
  debugMaterial;
323
760
  collisionDelegate = {
324
761
  collision: []
325
762
  };
326
763
  collisionType;
327
- behaviorCallbackMap = {
328
- setup: [],
329
- update: [],
330
- destroy: [],
331
- collision: []
332
- };
764
+ // Instancing support
765
+ /** Batch key for instanced rendering (null if not instanced) */
766
+ batchKey = null;
767
+ /** Index within the instanced mesh batch */
768
+ instanceId = -1;
769
+ /** Whether this entity uses instanced rendering */
770
+ isInstanced = false;
771
+ // Event delegate for dispatch/listen API
772
+ eventDelegate = new EventEmitterDelegate();
773
+ // Behavior references (new ECS pattern)
774
+ behaviorRefs = [];
775
+ // Transform store for batched physics updates (auto-created in create())
776
+ transformStore;
777
+ // Movement & rotation methods are assigned at runtime by makeTransformable.
778
+ // The `implements` clause above ensures the type contract; declarations below
779
+ // satisfy the compiler while the constructor fills them in.
780
+ moveX;
781
+ moveY;
782
+ moveZ;
783
+ moveXY;
784
+ moveXZ;
785
+ move;
786
+ resetVelocity;
787
+ moveForwardXY;
788
+ getPosition;
789
+ getVelocity;
790
+ setPosition;
791
+ setPositionX;
792
+ setPositionY;
793
+ setPositionZ;
794
+ wrapAroundXY;
795
+ wrapAround3D;
796
+ rotateInDirection;
797
+ rotateYEuler;
798
+ rotateEuler;
799
+ rotateX;
800
+ rotateY;
801
+ rotateZ;
802
+ setRotationY;
803
+ setRotationX;
804
+ setRotationZ;
805
+ setRotationDegrees;
806
+ setRotationDegreesY;
807
+ setRotationDegreesX;
808
+ setRotationDegreesZ;
809
+ setRotation;
810
+ getRotation;
333
811
  constructor() {
334
812
  super();
813
+ this.transformStore = createTransformStore();
814
+ makeTransformable(this);
815
+ }
816
+ // ─────────────────────────────────────────────────────────────────────────────
817
+ // Composable add() - accepts Mesh, CollisionComponent, or child entities
818
+ // ─────────────────────────────────────────────────────────────────────────────
819
+ /**
820
+ * Add meshes, collision components, or child entities to this entity.
821
+ * Supports fluent chaining: `entity.add(boxMesh()).add(boxCollision())`.
822
+ *
823
+ * - `Mesh`: First mesh becomes the primary mesh; subsequent meshes are
824
+ * compound meshes grouped together.
825
+ * - `CollisionComponent`: First sets bodyDesc + colliderDesc; subsequent
826
+ * add extra colliders to the same rigid body.
827
+ * - `NodeInterface`: Added as a child entity (delegates to BaseNode.add).
828
+ */
829
+ add(...components) {
830
+ for (const component of components) {
831
+ if (component instanceof Mesh) {
832
+ this.addMeshComponent(component);
833
+ } else if (isCollisionComponent(component)) {
834
+ this.addCollisionComponent(component);
835
+ } else {
836
+ super.add(component);
837
+ }
838
+ }
839
+ return this;
840
+ }
841
+ addMeshComponent(mesh) {
842
+ if (!this.mesh) {
843
+ this.mesh = mesh;
844
+ if (!this.materials) {
845
+ this.materials = [];
846
+ }
847
+ if (mesh.material) {
848
+ const mat = mesh.material;
849
+ if (Array.isArray(mat)) {
850
+ this.materials.push(...mat);
851
+ } else {
852
+ this.materials.push(mat);
853
+ }
854
+ }
855
+ } else {
856
+ this.compoundMeshes.push(mesh);
857
+ if (!this.group) {
858
+ this.group = new Group();
859
+ this.group.add(this.mesh);
860
+ }
861
+ this.group.add(mesh);
862
+ }
863
+ }
864
+ addCollisionComponent(collision) {
865
+ if (!this.bodyDesc) {
866
+ this.bodyDesc = collision.bodyDesc;
867
+ this.colliderDesc = collision.colliderDesc;
868
+ this.colliderDescs.push(collision.colliderDesc);
869
+ const pos = this.options?.position ?? { x: 0, y: 0, z: 0 };
870
+ this.bodyDesc.setTranslation(pos.x, pos.y, pos.z);
871
+ } else {
872
+ this.colliderDescs.push(collision.colliderDesc);
873
+ }
874
+ }
875
+ // ─────────────────────────────────────────────────────────────────────────────
876
+ // Actions API -- entity-scoped, self-contained stateful actions
877
+ // ─────────────────────────────────────────────────────────────────────────────
878
+ _actions = [];
879
+ /**
880
+ * Run a fire-and-forget action. Auto-removed when done.
881
+ * @example `me.runAction(moveBy({ x: 10, duration: 0.3 }))`
882
+ */
883
+ runAction(action) {
884
+ this._actions.push(action);
885
+ return action;
886
+ }
887
+ /**
888
+ * Register a persistent action (throttle, onPress). Not removed when done.
889
+ * @example `const press = entity.action(onPress())`
890
+ */
891
+ action(action) {
892
+ action.persistent = true;
893
+ this._actions.push(action);
894
+ return action;
895
+ }
896
+ /**
897
+ * Tick all registered actions. Called automatically before user onUpdate callbacks.
898
+ *
899
+ * Resets velocity/angularVelocity accumulation before ticking so that
900
+ * actions can compose via `+=` without cross-frame build-up.
901
+ * (The existing move helpers like `moveXY` use `=` which doesn't accumulate,
902
+ * but action composition needs additive writes on a clean slate each frame.)
903
+ */
904
+ _tickActions(delta) {
905
+ if (this._actions.length === 0) return;
906
+ const store = this.transformStore;
907
+ store.velocity.x = 0;
908
+ store.velocity.y = 0;
909
+ store.velocity.z = 0;
910
+ store.dirty.velocity = false;
911
+ store.angularVelocity.x = 0;
912
+ store.angularVelocity.y = 0;
913
+ store.angularVelocity.z = 0;
914
+ store.dirty.angularVelocity = false;
915
+ for (let i = this._actions.length - 1; i >= 0; i--) {
916
+ const act = this._actions[i];
917
+ act.tick(this, delta);
918
+ if (act.done && !act.persistent) {
919
+ this._actions.splice(i, 1);
920
+ }
921
+ }
922
+ if (this._actions.length === 0) {
923
+ store.velocity.x = 0;
924
+ store.velocity.y = 0;
925
+ store.velocity.z = 0;
926
+ store.dirty.velocity = false;
927
+ store.angularVelocity.x = 0;
928
+ store.angularVelocity.y = 0;
929
+ store.angularVelocity.z = 0;
930
+ store.dirty.angularVelocity = false;
931
+ }
335
932
  }
336
933
  create() {
337
934
  const { position: setupPosition } = this.options;
@@ -353,36 +950,88 @@ var GameEntity = class extends BaseNode {
353
950
  return this;
354
951
  }
355
952
  /**
356
- * Entity-specific setup - runs behavior callbacks
953
+ * Use a behavior on this entity via typed descriptor.
954
+ * Behaviors will be auto-registered as systems when the entity is spawned.
955
+ * @param descriptor The behavior descriptor (import from behaviors module)
956
+ * @param options Optional overrides for the behavior's default options
957
+ * @returns BehaviorHandle with behavior-specific methods for lazy FSM access
958
+ */
959
+ use(descriptor, options) {
960
+ const behaviorRef = {
961
+ descriptor,
962
+ options: { ...descriptor.defaultOptions, ...options }
963
+ };
964
+ this.behaviorRefs.push(behaviorRef);
965
+ const baseHandle = {
966
+ getFSM: () => behaviorRef.fsm ?? null,
967
+ getOptions: () => behaviorRef.options,
968
+ ref: behaviorRef
969
+ };
970
+ const customMethods = descriptor.createHandle?.(behaviorRef) ?? {};
971
+ return {
972
+ ...baseHandle,
973
+ ...customMethods
974
+ };
975
+ }
976
+ /**
977
+ * Get all behavior references attached to this entity.
978
+ * Used by the stage to auto-register required systems.
979
+ */
980
+ getBehaviorRefs() {
981
+ return this.behaviorRefs;
982
+ }
983
+ /**
984
+ * Entity-specific setup - resets actions for a fresh stage session.
357
985
  * (User callbacks are handled by BaseNode's lifecycleCallbacks.setup)
358
986
  */
359
987
  _setup(params) {
360
- this.behaviorCallbackMap.setup.forEach((callback) => {
361
- callback({ ...params, me: this });
362
- });
988
+ for (let i = this._actions.length - 1; i >= 0; i--) {
989
+ const act = this._actions[i];
990
+ if (act.done && !act.persistent) {
991
+ this._actions.splice(i, 1);
992
+ } else {
993
+ act.reset();
994
+ }
995
+ }
363
996
  }
364
997
  async _loaded(_params) {
365
998
  }
366
999
  /**
367
- * Entity-specific update - updates materials and runs behavior callbacks
1000
+ * Entity-specific update - updates materials.
1001
+ * Transform changes are applied by the stage after all update callbacks complete.
368
1002
  * (User callbacks are handled by BaseNode's lifecycleCallbacks.update)
369
1003
  */
370
1004
  _update(params) {
371
1005
  this.updateMaterials(params);
372
- this.behaviorCallbackMap.update.forEach((callback) => {
373
- callback({ ...params, me: this });
374
- });
375
1006
  }
376
1007
  /**
377
- * Entity-specific destroy - runs behavior callbacks
378
- * (User callbacks are handled by BaseNode's lifecycleCallbacks.destroy)
1008
+ * Entity-specific destroy -- reserved for consumer game logic.
1009
+ * Engine-internal resource disposal runs in _cleanup() instead.
379
1010
  */
380
1011
  _destroy(params) {
381
- this.behaviorCallbackMap.destroy.forEach((callback) => {
382
- callback({ ...params, me: this });
383
- });
384
1012
  }
385
- async _cleanup(_params) {
1013
+ /**
1014
+ * Engine-internal resource cleanup -- runs automatically after destroy.
1015
+ * Disposes GPU/DOM resources (meshes, materials, debug material).
1016
+ *
1017
+ * Note: actions, collision callbacks, and behavior refs are intentionally
1018
+ * NOT cleared here -- they are registered by consumer code at module level
1019
+ * and must persist across stage reloads. Actions are reset in _setup().
1020
+ */
1021
+ _cleanup(_params) {
1022
+ this.disposeEvents();
1023
+ for (const m of this.compoundMeshes) {
1024
+ m.geometry?.dispose();
1025
+ if (Array.isArray(m.material)) {
1026
+ m.material.forEach((mat) => mat.dispose());
1027
+ } else {
1028
+ m.material?.dispose();
1029
+ }
1030
+ }
1031
+ this.compoundMeshes.length = 0;
1032
+ this.debugMaterial?.dispose();
1033
+ this.debugMaterial = void 0;
1034
+ this.group?.removeFromParent();
386
1035
  }
387
1036
  _collision(other, globals) {
388
1037
  if (this.collisionDelegate.collision?.length) {
@@ -391,25 +1040,6 @@ var GameEntity = class extends BaseNode {
391
1040
  callback({ entity: this, other, globals });
392
1041
  });
393
1042
  }
394
- this.behaviorCallbackMap.collision.forEach((callback) => {
395
- callback({ entity: this, other, globals });
396
- });
397
- }
398
- addBehavior(behaviorCallback) {
399
- const handler = behaviorCallback.handler;
400
- if (handler) {
401
- this.behaviorCallbackMap[behaviorCallback.type].push(handler);
402
- }
403
- return this;
404
- }
405
- addBehaviors(behaviorCallbacks) {
406
- behaviorCallbacks.forEach((callback) => {
407
- const handler = callback.handler;
408
- if (handler) {
409
- this.behaviorCallbackMap[callback.type].push(handler);
410
- }
411
- });
412
- return this;
413
1043
  }
414
1044
  updateMaterials(params) {
415
1045
  if (!this.materials?.length) {
@@ -430,99 +1060,754 @@ var GameEntity = class extends BaseNode {
430
1060
  info.eid = this.eid.toString();
431
1061
  return info;
432
1062
  }
1063
+ // ─────────────────────────────────────────────────────────────────────────────
1064
+ // Events API
1065
+ // ─────────────────────────────────────────────────────────────────────────────
1066
+ /**
1067
+ * Dispatch an event from this entity.
1068
+ * Events are emitted both locally and to the global event bus.
1069
+ */
1070
+ dispatch(event, payload) {
1071
+ this.eventDelegate.dispatch(event, payload);
1072
+ zylemEventBus.emit(event, payload);
1073
+ }
1074
+ /**
1075
+ * Listen for events on this entity instance.
1076
+ * @returns Unsubscribe function
1077
+ */
1078
+ listen(event, handler) {
1079
+ return this.eventDelegate.listen(event, handler);
1080
+ }
1081
+ /**
1082
+ * Clean up entity event subscriptions.
1083
+ */
1084
+ disposeEvents() {
1085
+ this.eventDelegate.dispose();
1086
+ }
433
1087
  };
434
1088
 
435
- // src/lib/core/entity-asset-loader.ts
436
- import { FBXLoader } from "three/addons/loaders/FBXLoader.js";
1089
+ // src/lib/core/asset-manager.ts
1090
+ import { LoadingManager, Cache } from "three";
1091
+
1092
+ // src/lib/core/loaders/texture-loader.ts
1093
+ import { TextureLoader, RepeatWrapping } from "three";
1094
+ var TextureLoaderAdapter = class {
1095
+ loader;
1096
+ constructor() {
1097
+ this.loader = new TextureLoader();
1098
+ }
1099
+ isSupported(url) {
1100
+ const ext = url.split(".").pop()?.toLowerCase();
1101
+ return ["png", "jpg", "jpeg", "gif", "webp", "bmp", "tga"].includes(ext || "");
1102
+ }
1103
+ async load(url, options) {
1104
+ const texture = await this.loader.loadAsync(url, (event) => {
1105
+ if (options?.onProgress && event.lengthComputable) {
1106
+ options.onProgress(event.loaded / event.total);
1107
+ }
1108
+ });
1109
+ if (options?.repeat) {
1110
+ texture.repeat.copy(options.repeat);
1111
+ }
1112
+ texture.wrapS = options?.wrapS ?? RepeatWrapping;
1113
+ texture.wrapT = options?.wrapT ?? RepeatWrapping;
1114
+ return texture;
1115
+ }
1116
+ /**
1117
+ * Clone a texture for independent usage
1118
+ */
1119
+ clone(texture) {
1120
+ const cloned = texture.clone();
1121
+ cloned.needsUpdate = true;
1122
+ return cloned;
1123
+ }
1124
+ };
1125
+
1126
+ // src/lib/core/loaders/gltf-loader.ts
437
1127
  import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
438
- var FBXAssetLoader = class {
439
- loader = new FBXLoader();
440
- isSupported(file) {
441
- return file.toLowerCase().endsWith("fbx" /* FBX */);
1128
+ var GLTFLoaderAdapter = class {
1129
+ loader;
1130
+ constructor() {
1131
+ this.loader = new GLTFLoader();
1132
+ }
1133
+ isSupported(url) {
1134
+ const ext = url.split(".").pop()?.toLowerCase();
1135
+ return ["gltf", "glb"].includes(ext || "");
1136
+ }
1137
+ async load(url, options) {
1138
+ if (options?.useAsyncFetch) {
1139
+ return this.loadWithAsyncFetch(url, options);
1140
+ }
1141
+ return this.loadMainThread(url, options);
1142
+ }
1143
+ /**
1144
+ * Load using native fetch + parseAsync
1145
+ * Both fetch and parsing are async, keeping the main thread responsive
1146
+ */
1147
+ async loadWithAsyncFetch(url, options) {
1148
+ try {
1149
+ const response = await fetch(url);
1150
+ if (!response.ok) {
1151
+ throw new Error(`Failed to fetch ${url}: ${response.status} ${response.statusText}`);
1152
+ }
1153
+ const buffer = await response.arrayBuffer();
1154
+ const gltf = await this.loader.parseAsync(buffer, url);
1155
+ return {
1156
+ object: gltf.scene,
1157
+ animations: gltf.animations,
1158
+ gltf
1159
+ };
1160
+ } catch (error) {
1161
+ console.error(`Async fetch GLTF load failed for ${url}, falling back to loader.load():`, error);
1162
+ return this.loadMainThread(url, options);
1163
+ }
442
1164
  }
443
- async load(file) {
1165
+ async loadMainThread(url, options) {
444
1166
  return new Promise((resolve, reject) => {
445
1167
  this.loader.load(
446
- file,
447
- (object) => {
448
- const animation = object.animations[0];
1168
+ url,
1169
+ (gltf) => {
449
1170
  resolve({
450
- object,
451
- animation
1171
+ object: gltf.scene,
1172
+ animations: gltf.animations,
1173
+ gltf
452
1174
  });
453
1175
  },
454
- void 0,
455
- reject
1176
+ (event) => {
1177
+ if (options?.onProgress && event.lengthComputable) {
1178
+ options.onProgress(event.loaded / event.total);
1179
+ }
1180
+ },
1181
+ (error) => reject(error)
456
1182
  );
457
1183
  });
458
1184
  }
1185
+ /**
1186
+ * Clone a loaded GLTF scene for reuse
1187
+ */
1188
+ clone(result) {
1189
+ return {
1190
+ object: result.object.clone(),
1191
+ animations: result.animations?.map((anim) => anim.clone()),
1192
+ gltf: result.gltf
1193
+ };
1194
+ }
459
1195
  };
460
- var GLTFAssetLoader = class {
461
- loader = new GLTFLoader();
462
- isSupported(file) {
463
- return file.toLowerCase().endsWith("gltf" /* GLTF */);
1196
+
1197
+ // src/lib/core/loaders/fbx-loader.ts
1198
+ import { FBXLoader } from "three/addons/loaders/FBXLoader.js";
1199
+ var FBXLoaderAdapter = class {
1200
+ loader;
1201
+ constructor() {
1202
+ this.loader = new FBXLoader();
1203
+ }
1204
+ isSupported(url) {
1205
+ const ext = url.split(".").pop()?.toLowerCase();
1206
+ return ext === "fbx";
1207
+ }
1208
+ async load(url, options) {
1209
+ if (options?.useAsyncFetch) {
1210
+ return this.loadWithAsyncFetch(url, options);
1211
+ }
1212
+ return this.loadMainThread(url, options);
1213
+ }
1214
+ /**
1215
+ * Load using native fetch + parse
1216
+ */
1217
+ async loadWithAsyncFetch(url, _options) {
1218
+ try {
1219
+ const response = await fetch(url);
1220
+ if (!response.ok) {
1221
+ throw new Error(`Failed to fetch ${url}: ${response.status} ${response.statusText}`);
1222
+ }
1223
+ const buffer = await response.arrayBuffer();
1224
+ const object = this.loader.parse(buffer, url);
1225
+ return {
1226
+ object,
1227
+ animations: object.animations || []
1228
+ };
1229
+ } catch (error) {
1230
+ console.error(`Async fetch FBX load failed for ${url}, falling back to loader.load():`, error);
1231
+ return this.loadMainThread(url, _options);
1232
+ }
464
1233
  }
465
- async load(file) {
1234
+ async loadMainThread(url, options) {
466
1235
  return new Promise((resolve, reject) => {
467
1236
  this.loader.load(
468
- file,
469
- (gltf) => {
1237
+ url,
1238
+ (object) => {
470
1239
  resolve({
471
- object: gltf.scene,
472
- gltf
1240
+ object,
1241
+ animations: object.animations || []
473
1242
  });
474
1243
  },
475
- void 0,
476
- reject
1244
+ (event) => {
1245
+ if (options?.onProgress && event.lengthComputable) {
1246
+ options.onProgress(event.loaded / event.total);
1247
+ }
1248
+ },
1249
+ (error) => reject(error)
477
1250
  );
478
1251
  });
479
1252
  }
480
- };
481
- var EntityAssetLoader = class {
482
- loaders = [
483
- new FBXAssetLoader(),
484
- new GLTFAssetLoader()
485
- ];
486
- async loadFile(file) {
487
- const loader = this.loaders.find((l) => l.isSupported(file));
488
- if (!loader) {
489
- throw new Error(`Unsupported file type: ${file}`);
490
- }
491
- return loader.load(file);
1253
+ /**
1254
+ * Clone a loaded FBX object for reuse
1255
+ */
1256
+ clone(result) {
1257
+ return {
1258
+ object: result.object.clone(),
1259
+ animations: result.animations?.map((anim) => anim.clone())
1260
+ };
492
1261
  }
493
1262
  };
494
1263
 
495
- // src/lib/entities/delegates/animation.ts
496
- import {
497
- AnimationMixer,
498
- LoopOnce,
499
- LoopRepeat
500
- } from "three";
501
- var AnimationDelegate = class {
502
- constructor(target) {
503
- this.target = target;
1264
+ // src/lib/core/loaders/obj-loader.ts
1265
+ import { OBJLoader } from "three/addons/loaders/OBJLoader.js";
1266
+ import { MTLLoader } from "three/addons/loaders/MTLLoader.js";
1267
+ var OBJLoaderAdapter = class {
1268
+ loader;
1269
+ mtlLoader;
1270
+ constructor() {
1271
+ this.loader = new OBJLoader();
1272
+ this.mtlLoader = new MTLLoader();
504
1273
  }
505
- _mixer = null;
506
- _actions = {};
507
- _animations = [];
508
- _currentAction = null;
509
- _pauseAtPercentage = 0;
510
- _isPaused = false;
511
- _queuedKey = null;
512
- _fadeDuration = 0.5;
1274
+ isSupported(url) {
1275
+ const ext = url.split(".").pop()?.toLowerCase();
1276
+ return ext === "obj";
1277
+ }
1278
+ async load(url, options) {
1279
+ if (options?.mtlPath) {
1280
+ await this.loadMTL(options.mtlPath);
1281
+ }
1282
+ return new Promise((resolve, reject) => {
1283
+ this.loader.load(
1284
+ url,
1285
+ (object) => {
1286
+ resolve({
1287
+ object,
1288
+ animations: []
1289
+ });
1290
+ },
1291
+ (event) => {
1292
+ if (options?.onProgress && event.lengthComputable) {
1293
+ options.onProgress(event.loaded / event.total);
1294
+ }
1295
+ },
1296
+ (error) => reject(error)
1297
+ );
1298
+ });
1299
+ }
1300
+ async loadMTL(url) {
1301
+ return new Promise((resolve, reject) => {
1302
+ this.mtlLoader.load(
1303
+ url,
1304
+ (materials) => {
1305
+ materials.preload();
1306
+ this.loader.setMaterials(materials);
1307
+ resolve();
1308
+ },
1309
+ void 0,
1310
+ (error) => reject(error)
1311
+ );
1312
+ });
1313
+ }
1314
+ /**
1315
+ * Clone a loaded OBJ object for reuse
1316
+ */
1317
+ clone(result) {
1318
+ return {
1319
+ object: result.object.clone(),
1320
+ animations: []
1321
+ };
1322
+ }
1323
+ };
1324
+
1325
+ // src/lib/core/loaders/audio-loader.ts
1326
+ import { AudioLoader } from "three";
1327
+ var AudioLoaderAdapter = class {
1328
+ loader;
1329
+ constructor() {
1330
+ this.loader = new AudioLoader();
1331
+ }
1332
+ isSupported(url) {
1333
+ const ext = url.split(".").pop()?.toLowerCase();
1334
+ return ["mp3", "ogg", "wav", "flac", "aac", "m4a"].includes(ext || "");
1335
+ }
1336
+ async load(url, options) {
1337
+ return new Promise((resolve, reject) => {
1338
+ this.loader.load(
1339
+ url,
1340
+ (buffer) => resolve(buffer),
1341
+ (event) => {
1342
+ if (options?.onProgress && event.lengthComputable) {
1343
+ options.onProgress(event.loaded / event.total);
1344
+ }
1345
+ },
1346
+ (error) => reject(error)
1347
+ );
1348
+ });
1349
+ }
1350
+ };
1351
+
1352
+ // src/lib/core/loaders/file-loader.ts
1353
+ import { FileLoader } from "three";
1354
+ var FileLoaderAdapter = class {
1355
+ loader;
1356
+ constructor() {
1357
+ this.loader = new FileLoader();
1358
+ }
1359
+ isSupported(_url) {
1360
+ return true;
1361
+ }
1362
+ async load(url, options) {
1363
+ const responseType = options?.responseType ?? "text";
1364
+ this.loader.setResponseType(responseType);
1365
+ return new Promise((resolve, reject) => {
1366
+ this.loader.load(
1367
+ url,
1368
+ (data) => resolve(data),
1369
+ (event) => {
1370
+ if (options?.onProgress && event.lengthComputable) {
1371
+ options.onProgress(event.loaded / event.total);
1372
+ }
1373
+ },
1374
+ (error) => reject(error)
1375
+ );
1376
+ });
1377
+ }
1378
+ };
1379
+ var JsonLoaderAdapter = class {
1380
+ fileLoader;
1381
+ constructor() {
1382
+ this.fileLoader = new FileLoaderAdapter();
1383
+ }
1384
+ isSupported(url) {
1385
+ const ext = url.split(".").pop()?.toLowerCase();
1386
+ return ext === "json";
1387
+ }
1388
+ async load(url, options) {
1389
+ const data = await this.fileLoader.load(url, { ...options, responseType: "json" });
1390
+ return data;
1391
+ }
1392
+ };
1393
+
1394
+ // src/lib/core/asset-manager.ts
1395
+ var AssetManager = class _AssetManager {
1396
+ static instance = null;
1397
+ // Caches for different asset types
1398
+ textureCache = /* @__PURE__ */ new Map();
1399
+ modelCache = /* @__PURE__ */ new Map();
1400
+ audioCache = /* @__PURE__ */ new Map();
1401
+ fileCache = /* @__PURE__ */ new Map();
1402
+ jsonCache = /* @__PURE__ */ new Map();
1403
+ // Loaders
1404
+ textureLoader;
1405
+ gltfLoader;
1406
+ fbxLoader;
1407
+ objLoader;
1408
+ audioLoader;
1409
+ fileLoader;
1410
+ jsonLoader;
1411
+ // Loading manager for progress tracking
1412
+ loadingManager;
1413
+ // Event emitter
1414
+ events;
1415
+ // Stats
1416
+ stats = {
1417
+ texturesLoaded: 0,
1418
+ modelsLoaded: 0,
1419
+ audioLoaded: 0,
1420
+ filesLoaded: 0,
1421
+ cacheHits: 0,
1422
+ cacheMisses: 0
1423
+ };
1424
+ constructor() {
1425
+ this.textureLoader = new TextureLoaderAdapter();
1426
+ this.gltfLoader = new GLTFLoaderAdapter();
1427
+ this.fbxLoader = new FBXLoaderAdapter();
1428
+ this.objLoader = new OBJLoaderAdapter();
1429
+ this.audioLoader = new AudioLoaderAdapter();
1430
+ this.fileLoader = new FileLoaderAdapter();
1431
+ this.jsonLoader = new JsonLoaderAdapter();
1432
+ this.loadingManager = new LoadingManager();
1433
+ this.loadingManager.onProgress = (url, loaded, total) => {
1434
+ this.events.emit("batch:progress", { loaded, total });
1435
+ };
1436
+ this.events = mitt_default();
1437
+ Cache.enabled = true;
1438
+ }
1439
+ /**
1440
+ * Get the singleton instance
1441
+ */
1442
+ static getInstance() {
1443
+ if (!_AssetManager.instance) {
1444
+ _AssetManager.instance = new _AssetManager();
1445
+ }
1446
+ return _AssetManager.instance;
1447
+ }
1448
+ /**
1449
+ * Reset the singleton (useful for testing)
1450
+ */
1451
+ static resetInstance() {
1452
+ if (_AssetManager.instance) {
1453
+ _AssetManager.instance.clearCache();
1454
+ _AssetManager.instance = null;
1455
+ }
1456
+ }
1457
+ // ==================== TEXTURE LOADING ====================
1458
+ /**
1459
+ * Load a texture with caching
1460
+ */
1461
+ async loadTexture(url, options) {
1462
+ return this.loadWithCache(
1463
+ url,
1464
+ "texture" /* TEXTURE */,
1465
+ this.textureCache,
1466
+ () => this.textureLoader.load(url, options),
1467
+ options,
1468
+ (texture) => {
1469
+ if (!options?.clone) return texture;
1470
+ const cloned = this.textureLoader.clone(texture);
1471
+ if (options.repeat) {
1472
+ cloned.repeat.copy(options.repeat);
1473
+ }
1474
+ if (options.wrapS !== void 0) {
1475
+ cloned.wrapS = options.wrapS;
1476
+ }
1477
+ if (options.wrapT !== void 0) {
1478
+ cloned.wrapT = options.wrapT;
1479
+ }
1480
+ return cloned;
1481
+ }
1482
+ );
1483
+ }
1484
+ // ==================== MODEL LOADING ====================
1485
+ /**
1486
+ * Load a GLTF/GLB model with caching
1487
+ */
1488
+ async loadGLTF(url, options) {
1489
+ return this.loadWithCache(
1490
+ url,
1491
+ "gltf" /* GLTF */,
1492
+ this.modelCache,
1493
+ () => this.gltfLoader.load(url, options),
1494
+ options,
1495
+ (result) => options?.clone ? this.gltfLoader.clone(result) : result
1496
+ );
1497
+ }
1498
+ /**
1499
+ * Load an FBX model with caching
1500
+ */
1501
+ async loadFBX(url, options) {
1502
+ return this.loadWithCache(
1503
+ url,
1504
+ "fbx" /* FBX */,
1505
+ this.modelCache,
1506
+ () => this.fbxLoader.load(url, options),
1507
+ options,
1508
+ (result) => options?.clone ? this.fbxLoader.clone(result) : result
1509
+ );
1510
+ }
1511
+ /**
1512
+ * Load an OBJ model with caching
1513
+ */
1514
+ async loadOBJ(url, options) {
1515
+ const cacheKey = options?.mtlPath ? `${url}:${options.mtlPath}` : url;
1516
+ return this.loadWithCache(
1517
+ cacheKey,
1518
+ "obj" /* OBJ */,
1519
+ this.modelCache,
1520
+ () => this.objLoader.load(url, options),
1521
+ options,
1522
+ (result) => options?.clone ? this.objLoader.clone(result) : result
1523
+ );
1524
+ }
1525
+ /**
1526
+ * Auto-detect model type and load
1527
+ */
1528
+ async loadModel(url, options) {
1529
+ const ext = url.split(".").pop()?.toLowerCase();
1530
+ switch (ext) {
1531
+ case "gltf":
1532
+ case "glb":
1533
+ return this.loadGLTF(url, options);
1534
+ case "fbx":
1535
+ return this.loadFBX(url, options);
1536
+ case "obj":
1537
+ return this.loadOBJ(url, options);
1538
+ default:
1539
+ throw new Error(`Unsupported model format: ${ext}`);
1540
+ }
1541
+ }
1542
+ // ==================== AUDIO LOADING ====================
1543
+ /**
1544
+ * Load an audio buffer with caching
1545
+ */
1546
+ async loadAudio(url, options) {
1547
+ return this.loadWithCache(
1548
+ url,
1549
+ "audio" /* AUDIO */,
1550
+ this.audioCache,
1551
+ () => this.audioLoader.load(url, options),
1552
+ options
1553
+ );
1554
+ }
1555
+ // ==================== FILE LOADING ====================
1556
+ /**
1557
+ * Load a raw file with caching
1558
+ */
1559
+ async loadFile(url, options) {
1560
+ const cacheKey = options?.responseType ? `${url}:${options.responseType}` : url;
1561
+ return this.loadWithCache(
1562
+ cacheKey,
1563
+ "file" /* FILE */,
1564
+ this.fileCache,
1565
+ () => this.fileLoader.load(url, options),
1566
+ options
1567
+ );
1568
+ }
1569
+ /**
1570
+ * Load a JSON file with caching
1571
+ */
1572
+ async loadJSON(url, options) {
1573
+ return this.loadWithCache(
1574
+ url,
1575
+ "json" /* JSON */,
1576
+ this.jsonCache,
1577
+ () => this.jsonLoader.load(url, options),
1578
+ options
1579
+ );
1580
+ }
1581
+ // ==================== BATCH LOADING ====================
1582
+ /**
1583
+ * Load multiple assets in parallel
1584
+ */
1585
+ async loadBatch(items) {
1586
+ const results = /* @__PURE__ */ new Map();
1587
+ const promises = items.map(async (item) => {
1588
+ try {
1589
+ let result;
1590
+ switch (item.type) {
1591
+ case "texture" /* TEXTURE */:
1592
+ result = await this.loadTexture(item.url, item.options);
1593
+ break;
1594
+ case "gltf" /* GLTF */:
1595
+ result = await this.loadGLTF(item.url, item.options);
1596
+ break;
1597
+ case "fbx" /* FBX */:
1598
+ result = await this.loadFBX(item.url, item.options);
1599
+ break;
1600
+ case "obj" /* OBJ */:
1601
+ result = await this.loadOBJ(item.url, item.options);
1602
+ break;
1603
+ case "audio" /* AUDIO */:
1604
+ result = await this.loadAudio(item.url, item.options);
1605
+ break;
1606
+ case "file" /* FILE */:
1607
+ result = await this.loadFile(item.url, item.options);
1608
+ break;
1609
+ case "json" /* JSON */:
1610
+ result = await this.loadJSON(item.url, item.options);
1611
+ break;
1612
+ default:
1613
+ throw new Error(`Unknown asset type: ${item.type}`);
1614
+ }
1615
+ results.set(item.url, result);
1616
+ } catch (error) {
1617
+ this.events.emit("asset:error", {
1618
+ url: item.url,
1619
+ type: item.type,
1620
+ error
1621
+ });
1622
+ throw error;
1623
+ }
1624
+ });
1625
+ await Promise.all(promises);
1626
+ this.events.emit("batch:complete", { urls: items.map((i) => i.url) });
1627
+ return results;
1628
+ }
1629
+ /**
1630
+ * Preload assets without returning results
1631
+ */
1632
+ async preload(items) {
1633
+ await this.loadBatch(items);
1634
+ }
1635
+ // ==================== CACHE MANAGEMENT ====================
1636
+ /**
1637
+ * Check if an asset is cached
1638
+ */
1639
+ isCached(url) {
1640
+ return this.textureCache.has(url) || this.modelCache.has(url) || this.audioCache.has(url) || this.fileCache.has(url) || this.jsonCache.has(url);
1641
+ }
1642
+ /**
1643
+ * Clear all caches or a specific URL
1644
+ */
1645
+ clearCache(url) {
1646
+ if (url) {
1647
+ this.textureCache.delete(url);
1648
+ this.modelCache.delete(url);
1649
+ this.audioCache.delete(url);
1650
+ this.fileCache.delete(url);
1651
+ this.jsonCache.delete(url);
1652
+ } else {
1653
+ this.textureCache.clear();
1654
+ this.modelCache.clear();
1655
+ this.audioCache.clear();
1656
+ this.fileCache.clear();
1657
+ this.jsonCache.clear();
1658
+ Cache.clear();
1659
+ }
1660
+ }
1661
+ /**
1662
+ * Get cache statistics
1663
+ */
1664
+ getStats() {
1665
+ return { ...this.stats };
1666
+ }
1667
+ // ==================== EVENTS ====================
1668
+ /**
1669
+ * Subscribe to asset manager events
1670
+ */
1671
+ on(event, handler) {
1672
+ this.events.on(event, handler);
1673
+ }
1674
+ /**
1675
+ * Unsubscribe from asset manager events
1676
+ */
1677
+ off(event, handler) {
1678
+ this.events.off(event, handler);
1679
+ }
1680
+ // ==================== PRIVATE HELPERS ====================
1681
+ /**
1682
+ * Generic cache wrapper for loading assets
1683
+ */
1684
+ async loadWithCache(url, type, cache, loader, options, cloner) {
1685
+ if (options?.forceReload) {
1686
+ cache.delete(url);
1687
+ }
1688
+ const cached = cache.get(url);
1689
+ if (cached) {
1690
+ this.stats.cacheHits++;
1691
+ this.events.emit("asset:loaded", { url, type, fromCache: true });
1692
+ const result = await cached.promise;
1693
+ return cloner ? cloner(result) : result;
1694
+ }
1695
+ this.stats.cacheMisses++;
1696
+ this.events.emit("asset:loading", { url, type });
1697
+ const promise = loader();
1698
+ const entry = {
1699
+ promise,
1700
+ loadedAt: Date.now()
1701
+ };
1702
+ cache.set(url, entry);
1703
+ try {
1704
+ const result = await promise;
1705
+ entry.result = result;
1706
+ this.updateStats(type);
1707
+ this.events.emit("asset:loaded", { url, type, fromCache: false });
1708
+ return cloner ? cloner(result) : result;
1709
+ } catch (error) {
1710
+ cache.delete(url);
1711
+ this.events.emit("asset:error", { url, type, error });
1712
+ throw error;
1713
+ }
1714
+ }
1715
+ updateStats(type) {
1716
+ switch (type) {
1717
+ case "texture" /* TEXTURE */:
1718
+ this.stats.texturesLoaded++;
1719
+ break;
1720
+ case "gltf" /* GLTF */:
1721
+ case "fbx" /* FBX */:
1722
+ case "obj" /* OBJ */:
1723
+ this.stats.modelsLoaded++;
1724
+ break;
1725
+ case "audio" /* AUDIO */:
1726
+ this.stats.audioLoaded++;
1727
+ break;
1728
+ case "file" /* FILE */:
1729
+ case "json" /* JSON */:
1730
+ this.stats.filesLoaded++;
1731
+ break;
1732
+ }
1733
+ }
1734
+ };
1735
+ var assetManager = AssetManager.getInstance();
1736
+
1737
+ // src/lib/core/entity-asset-loader.ts
1738
+ var EntityAssetLoader = class {
1739
+ /**
1740
+ * Load a model file (FBX, GLTF, GLB, OBJ) using the asset manager
1741
+ */
1742
+ async loadFile(file) {
1743
+ const ext = file.split(".").pop()?.toLowerCase();
1744
+ switch (ext) {
1745
+ case "fbx": {
1746
+ const result = await assetManager.loadFBX(file);
1747
+ return {
1748
+ object: result.object,
1749
+ animation: result.animations?.[0]
1750
+ };
1751
+ }
1752
+ case "gltf":
1753
+ case "glb": {
1754
+ const result = await assetManager.loadGLTF(file);
1755
+ return {
1756
+ object: result.object,
1757
+ gltf: result.gltf
1758
+ };
1759
+ }
1760
+ case "obj": {
1761
+ const result = await assetManager.loadOBJ(file);
1762
+ return {
1763
+ object: result.object
1764
+ };
1765
+ }
1766
+ default:
1767
+ throw new Error(`Unsupported file type: ${file}`);
1768
+ }
1769
+ }
1770
+ };
1771
+
1772
+ // src/lib/entities/delegates/animation.ts
1773
+ import {
1774
+ AnimationMixer,
1775
+ LoopOnce,
1776
+ LoopRepeat
1777
+ } from "three";
1778
+ var AnimationDelegate = class {
1779
+ constructor(target) {
1780
+ this.target = target;
1781
+ }
1782
+ _mixer = null;
1783
+ _actions = {};
1784
+ _animations = [];
1785
+ _currentAction = null;
1786
+ _pauseAtPercentage = 0;
1787
+ _isPaused = false;
1788
+ _queuedKey = null;
1789
+ _fadeDuration = 0.5;
513
1790
  _currentKey = "";
514
1791
  _assetLoader = new EntityAssetLoader();
515
1792
  async loadAnimations(animations) {
516
1793
  if (!animations.length) return;
517
1794
  const results = await Promise.all(animations.map((a) => this._assetLoader.loadFile(a.path)));
518
- this._animations = results.filter((r) => !!r.animation).map((r) => r.animation);
519
- if (!this._animations.length) return;
1795
+ const loadedAnimations = [];
1796
+ results.forEach((result, i) => {
1797
+ if (result.animation) {
1798
+ loadedAnimations.push({
1799
+ key: animations[i].key || i.toString(),
1800
+ clip: result.animation
1801
+ });
1802
+ }
1803
+ });
1804
+ if (!loadedAnimations.length) return;
1805
+ this._animations = loadedAnimations.map((a) => a.clip);
520
1806
  this._mixer = new AnimationMixer(this.target);
521
- this._animations.forEach((clip, i) => {
522
- const key = animations[i].key || i.toString();
1807
+ loadedAnimations.forEach(({ key, clip }) => {
523
1808
  this._actions[key] = this._mixer.clipAction(clip);
524
1809
  });
525
- this.playAnimation({ key: Object.keys(this._actions)[0] });
1810
+ this.playAnimation({ key: loadedAnimations[0].key });
526
1811
  }
527
1812
  update(delta) {
528
1813
  if (!this._mixer || !this._currentAction) return;
@@ -546,14 +1831,13 @@ var AnimationDelegate = class {
546
1831
  if (!this._mixer) return;
547
1832
  const { key, pauseAtPercentage = 0, pauseAtEnd = false, fadeToKey, fadeDuration = 0.5 } = opts;
548
1833
  if (key === this._currentKey) return;
1834
+ const action = this._actions[key];
1835
+ if (!action) return;
549
1836
  this._queuedKey = fadeToKey || null;
550
1837
  this._fadeDuration = fadeDuration;
551
1838
  this._pauseAtPercentage = pauseAtEnd ? 100 : pauseAtPercentage;
552
1839
  this._isPaused = false;
553
1840
  const prev = this._currentAction;
554
- if (prev) prev.stop();
555
- const action = this._actions[key];
556
- if (!action) return;
557
1841
  if (this._pauseAtPercentage > 0) {
558
1842
  action.setLoop(LoopOnce, Infinity);
559
1843
  action.clampWhenFinished = true;
@@ -561,10 +1845,10 @@ var AnimationDelegate = class {
561
1845
  action.setLoop(LoopRepeat, Infinity);
562
1846
  action.clampWhenFinished = false;
563
1847
  }
1848
+ action.reset().play();
564
1849
  if (prev) {
565
1850
  prev.crossFadeTo(action, fadeDuration, false);
566
1851
  }
567
- action.reset().play();
568
1852
  this._currentAction = action;
569
1853
  this._currentKey = key;
570
1854
  }
@@ -593,22 +1877,66 @@ var AnimationDelegate = class {
593
1877
  }
594
1878
  };
595
1879
 
596
- // src/lib/graphics/shaders/fragment/standard.glsl
597
- var standard_default = "uniform sampler2D tDiffuse;\nvarying vec2 vUv;\n\nvoid main() {\n vec4 texel = texture2D( tDiffuse, vUv );\n\n gl_FragColor = texel;\n}";
1880
+ // src/lib/graphics/material.ts
1881
+ import { Color as Color3, Vector2 as Vector22, Vector3 as Vector36 } from "three";
1882
+ import {
1883
+ MeshPhongMaterial,
1884
+ MeshStandardMaterial,
1885
+ RepeatWrapping as RepeatWrapping2,
1886
+ ShaderMaterial as ShaderMaterial2
1887
+ } from "three";
1888
+ import {
1889
+ MeshBasicNodeMaterial,
1890
+ MeshStandardNodeMaterial
1891
+ } from "three/webgpu";
1892
+
1893
+ // src/lib/core/utility/strings.ts
1894
+ function sortedStringify(obj) {
1895
+ const sortedObj = Object.keys(obj).sort().reduce((acc, key) => {
1896
+ acc[key] = obj[key];
1897
+ return acc;
1898
+ }, {});
1899
+ return JSON.stringify(sortedObj);
1900
+ }
1901
+ function shortHash(objString) {
1902
+ let hash = 0;
1903
+ for (let i = 0; i < objString.length; i++) {
1904
+ hash = Math.imul(31, hash) + objString.charCodeAt(i) | 0;
1905
+ }
1906
+ return hash.toString(36);
1907
+ }
1908
+
1909
+ // src/lib/graphics/material.ts
1910
+ import {
1911
+ uniform,
1912
+ uv,
1913
+ time,
1914
+ vec3,
1915
+ vec4,
1916
+ float,
1917
+ Fn
1918
+ } from "three/tsl";
1919
+ function isTSLShader(shader) {
1920
+ return "colorNode" in shader;
1921
+ }
1922
+ function isGLSLShader(shader) {
1923
+ return "fragment" in shader && "vertex" in shader;
1924
+ }
598
1925
 
599
1926
  // src/lib/entities/actor.ts
600
1927
  var actorDefaults = {
601
- position: { x: 0, y: 0, z: 0 },
1928
+ ...commonDefaults,
602
1929
  collision: {
603
1930
  static: false,
604
- size: new Vector3(0.5, 0.5, 0.5),
605
- position: new Vector3(0, 0, 0)
1931
+ size: new Vector37(0.5, 0.5, 0.5),
1932
+ position: new Vector37(0, 0, 0)
606
1933
  },
607
1934
  material: {
608
- shader: "standard"
1935
+ shader: standardShader
609
1936
  },
610
1937
  animations: [],
611
- models: []
1938
+ models: [],
1939
+ collisionShape: "capsule"
612
1940
  };
613
1941
  var ACTOR_TYPE = /* @__PURE__ */ Symbol("Actor");
614
1942
  var ZylemActor = class extends GameEntity {
@@ -624,21 +1952,26 @@ var ZylemActor = class extends GameEntity {
624
1952
  this.prependUpdate(this.actorUpdate.bind(this));
625
1953
  this.controlledRotation = true;
626
1954
  }
627
- async load() {
1955
+ /**
1956
+ * Initiates model and animation loading in background (deferred).
1957
+ * Call returns immediately; assets will be ready on subsequent updates.
1958
+ */
1959
+ load() {
628
1960
  this._modelFileNames = this.options.models || [];
629
- await this.loadModels();
630
- if (this._object) {
631
- this._animationDelegate = new AnimationDelegate(this._object);
632
- await this._animationDelegate.loadAnimations(this.options.animations || []);
633
- }
1961
+ this.loadModelsDeferred();
634
1962
  }
635
- async data() {
1963
+ /**
1964
+ * Returns current data synchronously.
1965
+ * May return null values if loading is still in progress.
1966
+ */
1967
+ data() {
636
1968
  return {
637
1969
  animations: this._animationDelegate?.animations,
638
- objectModel: this._object
1970
+ objectModel: this._object,
1971
+ collisionShape: this.options.collisionShape
639
1972
  };
640
1973
  }
641
- async actorUpdate(params) {
1974
+ actorUpdate(params) {
642
1975
  this._animationDelegate?.update(params.delta);
643
1976
  }
644
1977
  /**
@@ -669,26 +2002,79 @@ var ZylemActor = class extends GameEntity {
669
2002
  }
670
2003
  this._modelFileNames = [];
671
2004
  }
672
- async loadModels() {
2005
+ /**
2006
+ * Deferred loading - starts async load and updates entity when complete.
2007
+ * Called by synchronous load() method.
2008
+ */
2009
+ loadModelsDeferred() {
673
2010
  if (this._modelFileNames.length === 0) return;
2011
+ this.dispatch("entity:model:loading", {
2012
+ entityId: this.uuid,
2013
+ files: this._modelFileNames
2014
+ });
674
2015
  const promises = this._modelFileNames.map((file) => this._assetLoader.loadFile(file));
675
- const results = await Promise.all(promises);
676
- if (results[0]?.object) {
677
- this._object = results[0].object;
678
- }
679
- if (this._object) {
680
- this.group = new Group2();
681
- this.group.attach(this._object);
682
- this.group.scale.set(
683
- this.options.scale?.x || 1,
684
- this.options.scale?.y || 1,
685
- this.options.scale?.z || 1
686
- );
687
- }
2016
+ Promise.all(promises).then((results) => {
2017
+ if (results[0]?.object) {
2018
+ this._object = results[0].object;
2019
+ }
2020
+ let meshCount = 0;
2021
+ if (this._object) {
2022
+ this._object.traverse((child) => {
2023
+ if (child.isMesh) meshCount++;
2024
+ });
2025
+ this.group = new Group2();
2026
+ this.group.attach(this._object);
2027
+ this.group.scale.set(
2028
+ this.options.scale?.x || 1,
2029
+ this.options.scale?.y || 1,
2030
+ this.options.scale?.z || 1
2031
+ );
2032
+ this.applyMaterialOverrides();
2033
+ this._animationDelegate = new AnimationDelegate(this._object);
2034
+ this._animationDelegate.loadAnimations(this.options.animations || []).then(() => {
2035
+ this.dispatch("entity:animation:loaded", {
2036
+ entityId: this.uuid,
2037
+ animationCount: this.options.animations?.length || 0
2038
+ });
2039
+ });
2040
+ }
2041
+ this.dispatch("entity:model:loaded", {
2042
+ entityId: this.uuid,
2043
+ success: !!this._object,
2044
+ meshCount
2045
+ });
2046
+ });
688
2047
  }
689
2048
  playAnimation(animationOptions) {
690
2049
  this._animationDelegate?.playAnimation(animationOptions);
691
2050
  }
2051
+ /**
2052
+ * Apply material overrides from options to all meshes in the loaded model.
2053
+ * Only applies if material options are explicitly specified (not just defaults).
2054
+ */
2055
+ applyMaterialOverrides() {
2056
+ const materialOptions = this.options.material;
2057
+ if (!materialOptions || !materialOptions.color && !materialOptions.path) {
2058
+ return;
2059
+ }
2060
+ if (!this._object) return;
2061
+ this._object.traverse((child) => {
2062
+ if (child.isMesh) {
2063
+ const mesh = child;
2064
+ if (materialOptions.color) {
2065
+ const newMaterial = new MeshStandardMaterial2({
2066
+ color: materialOptions.color,
2067
+ emissiveIntensity: 0.5,
2068
+ lightMapIntensity: 0.5,
2069
+ fog: true
2070
+ });
2071
+ mesh.castShadow = true;
2072
+ mesh.receiveShadow = true;
2073
+ mesh.material = newMaterial;
2074
+ }
2075
+ }
2076
+ });
2077
+ }
692
2078
  get object() {
693
2079
  return this._object;
694
2080
  }
@@ -754,6 +2140,13 @@ var ZylemWorld = class {
754
2140
  }
755
2141
  const collider = this.world.createCollider(entity.colliderDesc, entity.body);
756
2142
  entity.collider = collider;
2143
+ entity.colliders = [collider];
2144
+ if (entity.colliderDescs?.length > 1) {
2145
+ for (let i = 1; i < entity.colliderDescs.length; i++) {
2146
+ const additionalCollider = this.world.createCollider(entity.colliderDescs[i], entity.body);
2147
+ entity.colliders.push(additionalCollider);
2148
+ }
2149
+ }
757
2150
  if (entity.controlledRotation || entity instanceof ZylemActor) {
758
2151
  entity.body.lockRotations(true, true);
759
2152
  entity.characterController = this.world.createCharacterController(0.01);
@@ -772,7 +2165,18 @@ var ZylemWorld = class {
772
2165
  }
773
2166
  }
774
2167
  destroyEntity(entity) {
775
- if (entity.collider) {
2168
+ if (entity.characterController) {
2169
+ try {
2170
+ entity.characterController.free();
2171
+ } catch {
2172
+ }
2173
+ entity.characterController = null;
2174
+ }
2175
+ if (entity.colliders?.length) {
2176
+ for (const collider of entity.colliders) {
2177
+ this.world.removeCollider(collider, true);
2178
+ }
2179
+ } else if (entity.collider) {
776
2180
  this.world.removeCollider(entity.collider, true);
777
2181
  }
778
2182
  if (entity.body) {
@@ -817,6 +2221,9 @@ var ZylemWorld = class {
817
2221
  continue;
818
2222
  }
819
2223
  this.world.contactsWith(gameEntity.body.collider(0), (otherCollider) => {
2224
+ if (!otherCollider) {
2225
+ return;
2226
+ }
820
2227
  const uuid = otherCollider._parent.userData.uuid;
821
2228
  const entity = dictionaryRef.get(uuid);
822
2229
  if (!entity) {
@@ -826,7 +2233,13 @@ var ZylemWorld = class {
826
2233
  gameEntity._collision(entity, state.globals);
827
2234
  }
828
2235
  });
2236
+ if (!gameEntity.body || gameEntity.markedForRemoval) {
2237
+ continue;
2238
+ }
829
2239
  this.world.intersectionsWith(gameEntity.body.collider(0), (otherCollider) => {
2240
+ if (!otherCollider) {
2241
+ return;
2242
+ }
830
2243
  const uuid = otherCollider._parent.userData.uuid;
831
2244
  const entity = dictionaryRef.get(uuid);
832
2245
  if (!entity) {
@@ -853,6 +2266,10 @@ var ZylemWorld = class {
853
2266
  this.collisionMap.clear();
854
2267
  this.collisionBehaviorMap.clear();
855
2268
  this._removalMap.clear();
2269
+ try {
2270
+ this.world?.free();
2271
+ } catch {
2272
+ }
856
2273
  this.world = void 0;
857
2274
  } catch {
858
2275
  }
@@ -862,17 +2279,22 @@ var ZylemWorld = class {
862
2279
  // src/lib/graphics/zylem-scene.ts
863
2280
  import {
864
2281
  Scene,
865
- Color as Color2,
2282
+ Color as Color4,
866
2283
  AmbientLight,
867
2284
  DirectionalLight,
868
- Vector3 as Vector32,
869
- TextureLoader,
870
- GridHelper
2285
+ Vector3 as Vector38,
2286
+ GridHelper,
2287
+ BoxGeometry,
2288
+ ShaderMaterial as ShaderMaterial3,
2289
+ Mesh as Mesh3,
2290
+ BackSide,
2291
+ Texture as Texture3
871
2292
  } from "three";
2293
+ import { MeshBasicNodeMaterial as MeshBasicNodeMaterial2 } from "three/webgpu";
872
2294
 
873
2295
  // src/lib/debug/debug-state.ts
874
- import { proxy as proxy2 } from "valtio";
875
- var debugState = proxy2({
2296
+ import { proxy as proxy3 } from "valtio";
2297
+ var debugState = proxy3({
876
2298
  enabled: false,
877
2299
  paused: false,
878
2300
  tool: "none",
@@ -901,7 +2323,9 @@ var ZylemScene = class {
901
2323
  type = "Scene";
902
2324
  _setup;
903
2325
  scene;
2326
+ /** @deprecated Use cameraManager instead */
904
2327
  zylemCamera;
2328
+ cameraManager = null;
905
2329
  containerElement = null;
906
2330
  update = () => {
907
2331
  };
@@ -909,61 +2333,159 @@ var ZylemScene = class {
909
2333
  _destroy;
910
2334
  name;
911
2335
  tag;
2336
+ // Skybox for background shaders (supports both GLSL ShaderMaterial and TSL MeshBasicNodeMaterial)
2337
+ skyboxMaterial = null;
912
2338
  constructor(id, camera, state2) {
913
2339
  const scene = new Scene();
914
- const isColor = state2.backgroundColor instanceof Color2;
915
- const backgroundColor = isColor ? state2.backgroundColor : new Color2(state2.backgroundColor);
2340
+ const isColor = state2.backgroundColor instanceof Color4;
2341
+ const backgroundColor = isColor ? state2.backgroundColor : new Color4(state2.backgroundColor);
916
2342
  scene.background = backgroundColor;
917
- if (state2.backgroundImage) {
918
- const loader = new TextureLoader();
919
- const texture = loader.load(state2.backgroundImage);
920
- scene.background = texture;
2343
+ console.log("ZylemScene state.backgroundShader:", state2.backgroundShader);
2344
+ if (state2.backgroundShader) {
2345
+ this.setupBackgroundShader(scene, state2.backgroundShader);
2346
+ } else if (state2.backgroundImage) {
2347
+ assetManager.loadTexture(state2.backgroundImage).then((texture) => {
2348
+ scene.background = texture;
2349
+ });
921
2350
  }
922
2351
  this.scene = scene;
923
2352
  this.zylemCamera = camera;
924
2353
  this.setupLighting(scene);
925
- this.setupCamera(scene, camera);
926
2354
  if (debugState.enabled) {
927
2355
  this.debugScene();
928
2356
  }
929
2357
  }
2358
+ /**
2359
+ * Create a large inverted box with the shader for skybox effect
2360
+ * Supports both GLSL (ShaderMaterial) and TSL (MeshBasicNodeMaterial) shaders
2361
+ */
2362
+ setupBackgroundShader(scene, shader) {
2363
+ scene.background = null;
2364
+ if (isTSLShader(shader)) {
2365
+ this.skyboxMaterial = new MeshBasicNodeMaterial2();
2366
+ this.skyboxMaterial.colorNode = shader.colorNode;
2367
+ if (shader.transparent) {
2368
+ this.skyboxMaterial.transparent = true;
2369
+ }
2370
+ this.skyboxMaterial.side = BackSide;
2371
+ this.skyboxMaterial.depthWrite = false;
2372
+ console.log("Skybox created with TSL shader");
2373
+ } else if (isGLSLShader(shader)) {
2374
+ const skyboxVertexShader = `
2375
+ varying vec2 vUv;
2376
+ varying vec3 vWorldPosition;
2377
+
2378
+ void main() {
2379
+ vUv = uv;
2380
+ vec4 worldPosition = modelMatrix * vec4(position, 1.0);
2381
+ vWorldPosition = worldPosition.xyz;
2382
+ vec4 pos = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
2383
+ gl_Position = pos.xyww; // Ensures depth is always 1.0 (farthest)
2384
+ }
2385
+ `;
2386
+ this.skyboxMaterial = new ShaderMaterial3({
2387
+ vertexShader: skyboxVertexShader,
2388
+ fragmentShader: shader.fragment,
2389
+ uniforms: {
2390
+ iTime: { value: 0 }
2391
+ },
2392
+ side: BackSide,
2393
+ // Render on inside of geometry
2394
+ depthWrite: false,
2395
+ // Don't write to depth buffer
2396
+ depthTest: true
2397
+ // But do test depth
2398
+ });
2399
+ console.log("Skybox created with GLSL shader");
2400
+ }
2401
+ const geometry = new BoxGeometry(1, 1, 1);
2402
+ const skybox = new Mesh3(geometry, this.skyboxMaterial);
2403
+ skybox.scale.setScalar(1e5);
2404
+ skybox.frustumCulled = false;
2405
+ scene.add(skybox);
2406
+ }
930
2407
  setup() {
931
2408
  if (this._setup) {
932
2409
  this._setup({ me: this, camera: this.zylemCamera, globals: getGlobals() });
933
2410
  }
934
2411
  }
935
2412
  destroy() {
936
- if (this.zylemCamera && this.zylemCamera.destroy) {
2413
+ if (this.cameraManager) {
2414
+ this.cameraManager.dispose();
2415
+ this.cameraManager = null;
2416
+ } else if (this.zylemCamera && this.zylemCamera.destroy) {
937
2417
  this.zylemCamera.destroy();
938
2418
  }
2419
+ if (this.skyboxMaterial) {
2420
+ this.skyboxMaterial.dispose();
2421
+ }
939
2422
  if (this.scene) {
2423
+ if (this.scene.background instanceof Texture3) {
2424
+ this.scene.background.dispose();
2425
+ this.scene.background = null;
2426
+ }
940
2427
  this.scene.traverse((obj) => {
941
2428
  if (obj.geometry) {
942
2429
  obj.geometry.dispose?.();
943
2430
  }
944
2431
  if (obj.material) {
945
- if (Array.isArray(obj.material)) {
946
- obj.material.forEach((m) => m.dispose?.());
947
- } else {
948
- obj.material.dispose?.();
2432
+ const materials = Array.isArray(obj.material) ? obj.material : [obj.material];
2433
+ for (const mat of materials) {
2434
+ if (mat.map) mat.map.dispose?.();
2435
+ if (mat.normalMap) mat.normalMap.dispose?.();
2436
+ if (mat.aoMap) mat.aoMap.dispose?.();
2437
+ if (mat.emissiveMap) mat.emissiveMap.dispose?.();
2438
+ if (mat.roughnessMap) mat.roughnessMap.dispose?.();
2439
+ if (mat.metalnessMap) mat.metalnessMap.dispose?.();
2440
+ if (mat.envMap) mat.envMap.dispose?.();
2441
+ if (mat.lightMap) mat.lightMap.dispose?.();
2442
+ if (mat.bumpMap) mat.bumpMap.dispose?.();
2443
+ if (mat.displacementMap) mat.displacementMap.dispose?.();
2444
+ if (mat.alphaMap) mat.alphaMap.dispose?.();
2445
+ mat.dispose?.();
949
2446
  }
950
2447
  }
2448
+ if (obj.isLight && obj.shadow?.map) {
2449
+ obj.shadow.map.dispose();
2450
+ }
951
2451
  });
2452
+ this.scene.clear();
952
2453
  }
953
2454
  }
954
2455
  /**
955
- * Setup camera with the scene
2456
+ * Setup camera with the scene.
2457
+ * Supports both legacy single camera and CameraManager modes.
956
2458
  */
957
- setupCamera(scene, camera) {
958
- if (camera.cameraRig) {
959
- scene.add(camera.cameraRig);
2459
+ setupCamera(scene, camera, rendererManager) {
2460
+ this.addCameraToScene(scene, camera);
2461
+ if (rendererManager) {
2462
+ camera.setup(scene, rendererManager);
960
2463
  } else {
961
- scene.add(camera.camera);
2464
+ camera.setupLegacy(scene);
962
2465
  }
963
- camera.setup(scene);
964
2466
  }
965
2467
  /**
966
- * Setup scene lighting
2468
+ * Setup with a CameraManager (multi-camera support).
2469
+ */
2470
+ async setupCameraManager(scene, cameraManager, rendererManager) {
2471
+ this.cameraManager = cameraManager;
2472
+ for (const camera of cameraManager.activeCameras) {
2473
+ this.addCameraToScene(scene, camera);
2474
+ }
2475
+ await cameraManager.setup(scene, rendererManager);
2476
+ }
2477
+ /**
2478
+ * Add a camera (rig or direct) to the scene graph.
2479
+ */
2480
+ addCameraToScene(scene, camera) {
2481
+ if (camera.cameraRig) {
2482
+ scene.add(camera.cameraRig);
2483
+ } else {
2484
+ scene.add(camera.camera);
2485
+ }
2486
+ }
2487
+ /**
2488
+ * Setup scene lighting
967
2489
  */
968
2490
  setupLighting(scene) {
969
2491
  const ambientLight = new AmbientLight(16777215, 2);
@@ -983,15 +2505,21 @@ var ZylemScene = class {
983
2505
  scene.add(directionalLight);
984
2506
  }
985
2507
  /**
986
- * Update renderer size - delegates to camera
2508
+ * Update renderer size - delegates to camera manager or camera
987
2509
  */
988
2510
  updateRenderer(width, height) {
989
- this.zylemCamera.resize(width, height);
2511
+ if (this.cameraManager) {
2512
+ for (const camera of this.cameraManager.allCameras) {
2513
+ camera.resize(width, height);
2514
+ }
2515
+ } else {
2516
+ this.zylemCamera.resize(width, height);
2517
+ }
990
2518
  }
991
2519
  /**
992
2520
  * Add object to scene
993
2521
  */
994
- add(object, position2 = new Vector32(0, 0, 0)) {
2522
+ add(object, position2 = new Vector38(0, 0, 0)) {
995
2523
  object.position.set(position2.x, position2.y, position2.z);
996
2524
  this.scene.add(object);
997
2525
  }
@@ -1005,6 +2533,22 @@ var ZylemScene = class {
1005
2533
  this.add(entity.mesh, entity.options.position);
1006
2534
  }
1007
2535
  }
2536
+ /**
2537
+ * Add an entity's group or mesh to the scene (for late-loaded models).
2538
+ * Uses entity's current body position if physics is active.
2539
+ */
2540
+ addEntityGroup(entity) {
2541
+ const position2 = entity.body ? new Vector38(
2542
+ entity.body.translation().x,
2543
+ entity.body.translation().y,
2544
+ entity.body.translation().z
2545
+ ) : entity.options.position;
2546
+ if (entity.group) {
2547
+ this.add(entity.group, position2);
2548
+ } else if (entity.mesh) {
2549
+ this.add(entity.mesh, position2);
2550
+ }
2551
+ }
1008
2552
  /**
1009
2553
  * Add debug helpers to scene
1010
2554
  */
@@ -1014,20 +2558,299 @@ var ZylemScene = class {
1014
2558
  const gridHelper = new GridHelper(size, divisions);
1015
2559
  this.scene.add(gridHelper);
1016
2560
  }
2561
+ /**
2562
+ * Update skybox shader uniforms (only applies to GLSL ShaderMaterial)
2563
+ * TSL shaders use the time node which auto-updates
2564
+ */
2565
+ updateSkybox(delta) {
2566
+ if (this.skyboxMaterial && this.skyboxMaterial instanceof ShaderMaterial3) {
2567
+ if (this.skyboxMaterial.uniforms?.iTime) {
2568
+ this.skyboxMaterial.uniforms.iTime.value += delta;
2569
+ }
2570
+ }
2571
+ }
2572
+ };
2573
+
2574
+ // src/lib/graphics/instance-manager.ts
2575
+ import { InstancedMesh, Matrix4, Vector3 as Vector39, Quaternion as Quaternion3 } from "three";
2576
+ var InstanceManager = class _InstanceManager {
2577
+ batches = /* @__PURE__ */ new Map();
2578
+ entityToBatch = /* @__PURE__ */ new Map();
2579
+ // entity UUID -> batch key
2580
+ scene = null;
2581
+ /** Default initial capacity for new batches */
2582
+ static DEFAULT_CAPACITY = 128;
2583
+ /** Factor to grow batch when full */
2584
+ static GROWTH_FACTOR = 2;
2585
+ /**
2586
+ * Set the scene to add instanced meshes to
2587
+ */
2588
+ setScene(scene) {
2589
+ this.scene = scene;
2590
+ }
2591
+ /**
2592
+ * Generate a batch key from configuration
2593
+ */
2594
+ static generateBatchKey(config) {
2595
+ const keyData = {
2596
+ geo: `${config.geometryType}:${sortedStringify(config.dimensions)}`,
2597
+ mat: config.materialPath || "none",
2598
+ shader: config.shaderType || "standard",
2599
+ color: config.colorHex ?? 16777215
2600
+ };
2601
+ return shortHash(sortedStringify(keyData));
2602
+ }
2603
+ /**
2604
+ * Register an entity with the instance manager
2605
+ * @returns The instance index, or -1 if registration failed
2606
+ */
2607
+ register(entity, geometry, material, batchKey) {
2608
+ let batch = this.batches.get(batchKey);
2609
+ if (!batch) {
2610
+ batch = this.createBatch(batchKey, geometry, material);
2611
+ }
2612
+ let index;
2613
+ if (batch.freeIndices.length > 0) {
2614
+ index = batch.freeIndices.pop();
2615
+ } else {
2616
+ index = batch.entities.length;
2617
+ if (index >= batch.capacity) {
2618
+ this.growBatch(batch);
2619
+ }
2620
+ batch.entities.push(null);
2621
+ }
2622
+ batch.entities[index] = entity;
2623
+ batch.entityMap.set(entity.uuid, index);
2624
+ this.entityToBatch.set(entity.uuid, batchKey);
2625
+ batch.instancedMesh.count = Math.max(batch.instancedMesh.count, index + 1);
2626
+ batch.activeIndices.push(index);
2627
+ batch.dirtyIndices.add(index);
2628
+ return index;
2629
+ }
2630
+ /**
2631
+ * Unregister an entity from the instance manager
2632
+ */
2633
+ unregister(entity) {
2634
+ const batchKey = this.entityToBatch.get(entity.uuid);
2635
+ if (!batchKey) return;
2636
+ const batch = this.batches.get(batchKey);
2637
+ if (!batch) return;
2638
+ const index = batch.entityMap.get(entity.uuid);
2639
+ if (index === void 0) return;
2640
+ batch.entities[index] = null;
2641
+ batch.entityMap.delete(entity.uuid);
2642
+ this.entityToBatch.delete(entity.uuid);
2643
+ batch.freeIndices.push(index);
2644
+ const idxInActive = batch.activeIndices.indexOf(index);
2645
+ if (idxInActive !== -1) {
2646
+ const last = batch.activeIndices.pop();
2647
+ if (idxInActive < batch.activeIndices.length) {
2648
+ batch.activeIndices[idxInActive] = last;
2649
+ }
2650
+ }
2651
+ batch.dirtyIndices.delete(index);
2652
+ const matrix = new Matrix4();
2653
+ matrix.makeScale(0, 0, 0);
2654
+ batch.instancedMesh.setMatrixAt(index, matrix);
2655
+ batch.instancedMesh.instanceMatrix.needsUpdate = true;
2656
+ }
2657
+ /**
2658
+ * Mark an entity's transform as dirty (needs syncing)
2659
+ */
2660
+ markDirty(entity) {
2661
+ const batchKey = this.entityToBatch.get(entity.uuid);
2662
+ if (!batchKey) return;
2663
+ const batch = this.batches.get(batchKey);
2664
+ if (!batch) return;
2665
+ const index = batch.entityMap.get(entity.uuid);
2666
+ if (index !== void 0) {
2667
+ batch.dirtyIndices.add(index);
2668
+ }
2669
+ }
2670
+ /**
2671
+ * Update all dirty instance transforms
2672
+ * Call this once per frame
2673
+ */
2674
+ /**
2675
+ * Update all active instance transforms
2676
+ * Call this once per frame
2677
+ */
2678
+ update() {
2679
+ const matrix = new Matrix4();
2680
+ const pos = new Vector39();
2681
+ const quat = new Quaternion3();
2682
+ const scale2 = new Vector39(1, 1, 1);
2683
+ for (const batch of this.batches.values()) {
2684
+ if (batch.activeIndices.length === 0) continue;
2685
+ let needsUpdate = false;
2686
+ if (batch.dirtyIndices.size > 0) {
2687
+ for (const index of batch.dirtyIndices) {
2688
+ this.updateInstanceMatrix(batch, index, matrix, pos, quat, scale2);
2689
+ }
2690
+ batch.dirtyIndices.clear();
2691
+ needsUpdate = true;
2692
+ }
2693
+ for (const index of batch.activeIndices) {
2694
+ const entity = batch.entities[index];
2695
+ if (entity && entity.body) {
2696
+ this.updateInstanceMatrix(batch, index, matrix, pos, quat, scale2);
2697
+ needsUpdate = true;
2698
+ }
2699
+ }
2700
+ if (needsUpdate) {
2701
+ batch.instancedMesh.instanceMatrix.needsUpdate = true;
2702
+ }
2703
+ }
2704
+ }
2705
+ updateInstanceMatrix(batch, index, matrix, pos, quat, scale2) {
2706
+ const entity = batch.entities[index];
2707
+ if (!entity) return;
2708
+ if (entity.body) {
2709
+ const translation = entity.body.translation();
2710
+ const rotation2 = entity.body.rotation();
2711
+ pos.set(translation.x, translation.y, translation.z);
2712
+ quat.set(rotation2.x, rotation2.y, rotation2.z, rotation2.w);
2713
+ matrix.compose(pos, quat, scale2);
2714
+ } else if (entity.mesh) {
2715
+ entity.mesh.updateMatrix();
2716
+ matrix.copy(entity.mesh.matrix);
2717
+ } else if (entity.group) {
2718
+ entity.group.updateMatrix();
2719
+ matrix.copy(entity.group.matrix);
2720
+ }
2721
+ batch.instancedMesh.setMatrixAt(index, matrix);
2722
+ }
2723
+ /**
2724
+ * Get batch info for an entity
2725
+ */
2726
+ getBatchInfo(entity) {
2727
+ const batchKey = this.entityToBatch.get(entity.uuid);
2728
+ if (!batchKey) return null;
2729
+ const batch = this.batches.get(batchKey);
2730
+ if (!batch) return null;
2731
+ const instanceId = batch.entityMap.get(entity.uuid);
2732
+ if (instanceId === void 0) return null;
2733
+ return { batchKey, instanceId };
2734
+ }
2735
+ /**
2736
+ * Get statistics about current batching
2737
+ */
2738
+ getStats() {
2739
+ let totalInstances = 0;
2740
+ const batches = [];
2741
+ for (const [key, batch] of this.batches) {
2742
+ const count = batch.entityMap.size;
2743
+ totalInstances += count;
2744
+ batches.push({ key, count, capacity: batch.capacity });
2745
+ }
2746
+ return { batchCount: this.batches.size, totalInstances, batches };
2747
+ }
2748
+ /**
2749
+ * Dispose all batches and release resources
2750
+ */
2751
+ dispose() {
2752
+ for (const batch of this.batches.values()) {
2753
+ if (this.scene) {
2754
+ this.scene.remove(batch.instancedMesh);
2755
+ }
2756
+ batch.instancedMesh.dispose();
2757
+ batch.geometry.dispose();
2758
+ }
2759
+ this.batches.clear();
2760
+ this.entityToBatch.clear();
2761
+ }
2762
+ /**
2763
+ * Create a new batch group
2764
+ */
2765
+ createBatch(key, geometry, material) {
2766
+ const capacity = _InstanceManager.DEFAULT_CAPACITY;
2767
+ const instancedMesh = new InstancedMesh(geometry, material, capacity);
2768
+ instancedMesh.count = 0;
2769
+ instancedMesh.frustumCulled = false;
2770
+ const hiddenMatrix = new Matrix4().makeScale(0, 0, 0);
2771
+ for (let i = 0; i < capacity; i++) {
2772
+ instancedMesh.setMatrixAt(i, hiddenMatrix);
2773
+ }
2774
+ instancedMesh.instanceMatrix.needsUpdate = true;
2775
+ const batch = {
2776
+ key,
2777
+ instancedMesh,
2778
+ geometry,
2779
+ material,
2780
+ entityMap: /* @__PURE__ */ new Map(),
2781
+ entities: [],
2782
+ freeIndices: [],
2783
+ activeIndices: [],
2784
+ dirtyIndices: /* @__PURE__ */ new Set(),
2785
+ capacity
2786
+ };
2787
+ this.batches.set(key, batch);
2788
+ if (this.scene) {
2789
+ this.scene.add(instancedMesh);
2790
+ }
2791
+ return batch;
2792
+ }
2793
+ /**
2794
+ * Grow a batch's capacity
2795
+ */
2796
+ growBatch(batch) {
2797
+ const newCapacity = batch.capacity * _InstanceManager.GROWTH_FACTOR;
2798
+ const newInstancedMesh = new InstancedMesh(batch.geometry, batch.material, newCapacity);
2799
+ newInstancedMesh.count = batch.instancedMesh.count;
2800
+ newInstancedMesh.frustumCulled = false;
2801
+ const matrix = new Matrix4();
2802
+ for (let i = 0; i < batch.capacity; i++) {
2803
+ batch.instancedMesh.getMatrixAt(i, matrix);
2804
+ newInstancedMesh.setMatrixAt(i, matrix);
2805
+ }
2806
+ const hiddenMatrix = new Matrix4().makeScale(0, 0, 0);
2807
+ for (let i = batch.capacity; i < newCapacity; i++) {
2808
+ newInstancedMesh.setMatrixAt(i, hiddenMatrix);
2809
+ }
2810
+ newInstancedMesh.instanceMatrix.needsUpdate = true;
2811
+ if (this.scene) {
2812
+ this.scene.remove(batch.instancedMesh);
2813
+ this.scene.add(newInstancedMesh);
2814
+ }
2815
+ batch.instancedMesh.dispose();
2816
+ batch.instancedMesh = newInstancedMesh;
2817
+ batch.capacity = newCapacity;
2818
+ }
1017
2819
  };
1018
2820
 
1019
2821
  // src/lib/stage/stage-state.ts
1020
- import { Color as Color3, Vector3 as Vector33 } from "three";
1021
- import { proxy as proxy3, subscribe as subscribe2 } from "valtio/vanilla";
1022
- var stageState = proxy3({
1023
- backgroundColor: new Color3(Color3.NAMES.cornflowerblue),
2822
+ import { Color as Color6, Vector3 as Vector311 } from "three";
2823
+ import { proxy as proxy4, subscribe as subscribe2 } from "valtio/vanilla";
2824
+
2825
+ // src/lib/core/utility/vector.ts
2826
+ import { Color as Color5 } from "three";
2827
+ import { Vector3 as Vector310 } from "@dimforge/rapier3d-compat";
2828
+ var ZylemBlueColor = new Color5("#0333EC");
2829
+ var Vec0 = new Vector310(0, 0, 0);
2830
+ var Vec1 = new Vector310(1, 1, 1);
2831
+
2832
+ // src/lib/stage/stage-state.ts
2833
+ var initialStageState = {
2834
+ backgroundColor: ZylemBlueColor,
2835
+ backgroundImage: null,
2836
+ backgroundShader: null,
2837
+ inputs: {
2838
+ p1: ["gamepad-1", "keyboard"],
2839
+ p2: ["gamepad-2", "keyboard"]
2840
+ },
2841
+ gravity: new Vector311(0, 0, 0),
2842
+ variables: {},
2843
+ entities: []
2844
+ };
2845
+ var stageState = proxy4({
2846
+ backgroundColor: new Color6(Color6.NAMES.cornflowerblue),
1024
2847
  backgroundImage: null,
1025
2848
  inputs: {
1026
2849
  p1: ["gamepad-1", "keyboard-1"],
1027
2850
  p2: ["gamepad-2", "keyboard-2"]
1028
2851
  },
1029
2852
  variables: {},
1030
- gravity: new Vector33(0, 0, 0),
2853
+ gravity: new Vector311(0, 0, 0),
1031
2854
  entities: []
1032
2855
  });
1033
2856
  var setStageBackgroundColor = (value) => {
@@ -1047,13 +2870,6 @@ function clearVariables(target) {
1047
2870
  variableProxyStore.delete(target);
1048
2871
  }
1049
2872
 
1050
- // src/lib/core/utility/vector.ts
1051
- import { Color as Color4 } from "three";
1052
- import { Vector3 as Vector34 } from "@dimforge/rapier3d-compat";
1053
- var ZylemBlueColor = new Color4("#0333EC");
1054
- var Vec0 = new Vector34(0, 0, 0);
1055
- var Vec1 = new Vector34(1, 1, 1);
1056
-
1057
2873
  // src/lib/stage/zylem-stage.ts
1058
2874
  import { subscribe as subscribe4 } from "valtio/vanilla";
1059
2875
 
@@ -1096,34 +2912,34 @@ import { nanoid as nanoid2 } from "nanoid";
1096
2912
 
1097
2913
  // src/lib/stage/stage-debug-delegate.ts
1098
2914
  import { Ray } from "@dimforge/rapier3d-compat";
1099
- import { BufferAttribute, BufferGeometry as BufferGeometry2, LineBasicMaterial as LineBasicMaterial2, LineSegments as LineSegments2, Raycaster, Vector2 } from "three";
2915
+ import { BufferAttribute, BufferGeometry as BufferGeometry3, LineBasicMaterial as LineBasicMaterial2, LineSegments as LineSegments2, Raycaster, Vector2 as Vector23 } from "three";
1100
2916
 
1101
2917
  // src/lib/stage/debug-entity-cursor.ts
1102
2918
  import {
1103
2919
  Box3,
1104
- BoxGeometry,
1105
- Color as Color5,
2920
+ BoxGeometry as BoxGeometry2,
2921
+ Color as Color7,
1106
2922
  EdgesGeometry,
1107
2923
  Group as Group3,
1108
2924
  LineBasicMaterial,
1109
2925
  LineSegments,
1110
- Mesh as Mesh2,
2926
+ Mesh as Mesh4,
1111
2927
  MeshBasicMaterial,
1112
- Vector3 as Vector35
2928
+ Vector3 as Vector312
1113
2929
  } from "three";
1114
2930
  var DebugEntityCursor = class {
1115
2931
  scene;
1116
2932
  container;
1117
2933
  fillMesh;
1118
2934
  edgeLines;
1119
- currentColor = new Color5(65280);
2935
+ currentColor = new Color7(65280);
1120
2936
  bbox = new Box3();
1121
- size = new Vector35();
1122
- center = new Vector35();
2937
+ size = new Vector312();
2938
+ center = new Vector312();
1123
2939
  constructor(scene) {
1124
2940
  this.scene = scene;
1125
- const initialGeometry = new BoxGeometry(1, 1, 1);
1126
- this.fillMesh = new Mesh2(
2941
+ const initialGeometry = new BoxGeometry2(1, 1, 1);
2942
+ this.fillMesh = new Mesh4(
1127
2943
  initialGeometry,
1128
2944
  new MeshBasicMaterial({
1129
2945
  color: this.currentColor,
@@ -1164,7 +2980,7 @@ var DebugEntityCursor = class {
1164
2980
  }
1165
2981
  this.bbox.getSize(this.size);
1166
2982
  this.bbox.getCenter(this.center);
1167
- const newGeom = new BoxGeometry(
2983
+ const newGeom = new BoxGeometry2(
1168
2984
  Math.max(this.size.x, 1e-6),
1169
2985
  Math.max(this.size.y, 1e-6),
1170
2986
  Math.max(this.size.z, 1e-6)
@@ -1195,7 +3011,7 @@ var DELETE_TOOL_COLOR = 16724787;
1195
3011
  var StageDebugDelegate = class {
1196
3012
  stage;
1197
3013
  options;
1198
- mouseNdc = new Vector2(-2, -2);
3014
+ mouseNdc = new Vector23(-2, -2);
1199
3015
  raycaster = new Raycaster();
1200
3016
  isMouseDown = false;
1201
3017
  disposeFns = [];
@@ -1207,20 +3023,39 @@ var StageDebugDelegate = class {
1207
3023
  maxRayDistance: options?.maxRayDistance ?? 5e3,
1208
3024
  addEntityFactory: options?.addEntityFactory ?? null
1209
3025
  };
1210
- if (this.stage.scene) {
1211
- this.debugLines = new LineSegments2(
1212
- new BufferGeometry2(),
1213
- new LineBasicMaterial2({ vertexColors: true })
1214
- );
1215
- this.stage.scene.scene.add(this.debugLines);
1216
- this.debugLines.visible = true;
1217
- this.debugCursor = new DebugEntityCursor(this.stage.scene.scene);
1218
- }
1219
3026
  this.attachDomListeners();
1220
3027
  }
3028
+ initDebugVisuals() {
3029
+ if (this.debugLines || !this.stage.scene) return;
3030
+ this.debugLines = new LineSegments2(
3031
+ new BufferGeometry3(),
3032
+ new LineBasicMaterial2({ vertexColors: true })
3033
+ );
3034
+ this.stage.scene.scene.add(this.debugLines);
3035
+ this.debugLines.visible = true;
3036
+ this.debugCursor = new DebugEntityCursor(this.stage.scene.scene);
3037
+ }
3038
+ disposeDebugVisuals() {
3039
+ if (this.debugLines && this.stage.scene) {
3040
+ this.stage.scene.scene.remove(this.debugLines);
3041
+ this.debugLines.geometry.dispose();
3042
+ this.debugLines.material.dispose();
3043
+ this.debugLines = null;
3044
+ }
3045
+ this.debugCursor?.dispose();
3046
+ this.debugCursor = null;
3047
+ }
1221
3048
  update() {
1222
- if (!debugState.enabled) return;
3049
+ if (!debugState.enabled) {
3050
+ if (this.debugLines) {
3051
+ this.disposeDebugVisuals();
3052
+ }
3053
+ return;
3054
+ }
1223
3055
  if (!this.stage.scene || !this.stage.world || !this.stage.cameraRef) return;
3056
+ if (!this.debugLines) {
3057
+ this.initDebugVisuals();
3058
+ }
1224
3059
  const { world, cameraRef } = this.stage;
1225
3060
  if (this.debugLines) {
1226
3061
  const { vertices, colors } = world.world.debugRender();
@@ -1278,13 +3113,7 @@ var StageDebugDelegate = class {
1278
3113
  dispose() {
1279
3114
  this.disposeFns.forEach((fn) => fn());
1280
3115
  this.disposeFns = [];
1281
- this.debugCursor?.dispose();
1282
- if (this.debugLines && this.stage.scene) {
1283
- this.stage.scene.scene.remove(this.debugLines);
1284
- this.debugLines.geometry.dispose();
1285
- this.debugLines.material.dispose();
1286
- this.debugLines = null;
1287
- }
3116
+ this.disposeDebugVisuals();
1288
3117
  }
1289
3118
  handleActionOnHit(hoveredUuid, origin, direction, toi) {
1290
3119
  const tool = getDebugTool();
@@ -1363,10 +3192,10 @@ var StageCameraDebugDelegate = class {
1363
3192
  };
1364
3193
 
1365
3194
  // src/lib/stage/stage-camera-delegate.ts
1366
- import { Vector2 as Vector24 } from "three";
3195
+ import { Vector2 as Vector28 } from "three";
1367
3196
 
1368
3197
  // src/lib/camera/zylem-camera.ts
1369
- import { PerspectiveCamera, Vector3 as Vector39, Object3D as Object3D6, OrthographicCamera, WebGLRenderer as WebGLRenderer3 } from "three";
3198
+ import { PerspectiveCamera as PerspectiveCamera2, Vector3 as Vector316, Object3D as Object3D8, OrthographicCamera } from "three";
1370
3199
 
1371
3200
  // src/lib/camera/perspective.ts
1372
3201
  var Perspectives = {
@@ -1378,15 +3207,21 @@ var Perspectives = {
1378
3207
  };
1379
3208
 
1380
3209
  // src/lib/camera/third-person.ts
1381
- import { Vector3 as Vector37 } from "three";
3210
+ import { Vector3 as Vector314 } from "three";
1382
3211
  var ThirdPersonCamera = class {
1383
3212
  distance;
1384
3213
  screenResolution = null;
1385
3214
  renderer = null;
1386
3215
  scene = null;
1387
3216
  cameraRef = null;
3217
+ /** Padding multiplier when framing multiple targets. Higher = more zoom out. */
3218
+ paddingFactor = 1.5;
3219
+ /** Minimum camera distance when multi-framing (prevents extreme zoom-in). */
3220
+ minDistance = 5;
3221
+ /** Lerp factor for camera position smoothing. */
3222
+ lerpFactor = 0.1;
1388
3223
  constructor() {
1389
- this.distance = new Vector37(0, 5, 8);
3224
+ this.distance = new Vector314(0, 5, 8);
1390
3225
  }
1391
3226
  /**
1392
3227
  * Setup the third person camera controller
@@ -1399,15 +3234,59 @@ var ThirdPersonCamera = class {
1399
3234
  this.cameraRef = camera;
1400
3235
  }
1401
3236
  /**
1402
- * Update the third person camera
3237
+ * Update the third person camera.
3238
+ * Handles 0, 1, and multi-target scenarios.
1403
3239
  */
1404
3240
  update(delta) {
1405
- if (!this.cameraRef.target) {
3241
+ if (!this.cameraRef) return;
3242
+ const targets = this.cameraRef.targets;
3243
+ if (targets.length === 0) {
3244
+ this.cameraRef.camera.lookAt(new Vector314(0, 0, 0));
1406
3245
  return;
1407
3246
  }
1408
- const desiredCameraPosition = this.cameraRef.target.group.position.clone().add(this.distance);
1409
- this.cameraRef.camera.position.lerp(desiredCameraPosition, 0.1);
1410
- this.cameraRef.camera.lookAt(this.cameraRef.target.group.position);
3247
+ if (targets.length === 1) {
3248
+ this.updateSingleTarget(targets[0]);
3249
+ return;
3250
+ }
3251
+ this.updateMultiTarget(targets);
3252
+ }
3253
+ /**
3254
+ * Classic single-target follow: lerp to target position + offset, lookAt target.
3255
+ */
3256
+ updateSingleTarget(target) {
3257
+ const useTarget = target.group?.position || new Vector314(0, 0, 0);
3258
+ const desiredCameraPosition = useTarget.clone().add(this.distance);
3259
+ this.cameraRef.camera.position.lerp(desiredCameraPosition, this.lerpFactor);
3260
+ this.cameraRef.camera.lookAt(useTarget);
3261
+ }
3262
+ /**
3263
+ * Multi-target framing: compute centroid, measure spread, zoom out to fit all.
3264
+ */
3265
+ updateMultiTarget(targets) {
3266
+ const centroid = new Vector314();
3267
+ for (const t of targets) {
3268
+ centroid.add(t.group.position);
3269
+ }
3270
+ centroid.divideScalar(targets.length);
3271
+ let maxDistFromCentroid = 0;
3272
+ for (const t of targets) {
3273
+ const dist = centroid.distanceTo(t.group.position);
3274
+ if (dist > maxDistFromCentroid) {
3275
+ maxDistFromCentroid = dist;
3276
+ }
3277
+ }
3278
+ const dynamicDistance = Math.max(maxDistFromCentroid * this.paddingFactor, this.minDistance);
3279
+ const offsetDirection = this.distance.clone().normalize();
3280
+ const desiredCameraPosition = centroid.clone().add(
3281
+ offsetDirection.multiplyScalar(dynamicDistance)
3282
+ );
3283
+ const baseLen = this.distance.length();
3284
+ if (baseLen > 0) {
3285
+ const heightRatio = this.distance.y / baseLen;
3286
+ desiredCameraPosition.y = centroid.y + dynamicDistance * heightRatio;
3287
+ }
3288
+ this.cameraRef.camera.position.lerp(desiredCameraPosition, this.lerpFactor);
3289
+ this.cameraRef.camera.lookAt(centroid);
1411
3290
  }
1412
3291
  /**
1413
3292
  * Handle resize events
@@ -1418,7 +3297,7 @@ var ThirdPersonCamera = class {
1418
3297
  }
1419
3298
  }
1420
3299
  /**
1421
- * Set the distance from the target
3300
+ * Set the distance offset from the target
1422
3301
  */
1423
3302
  setDistance(distance) {
1424
3303
  this.distance = distance;
@@ -1461,102 +3340,17 @@ var Fixed2DCamera = class {
1461
3340
  }
1462
3341
  };
1463
3342
 
1464
- // src/lib/camera/zylem-camera.ts
1465
- import { EffectComposer } from "three/examples/jsm/postprocessing/EffectComposer.js";
1466
-
1467
- // src/lib/graphics/render-pass.ts
1468
- import * as THREE from "three";
1469
-
1470
- // src/lib/graphics/shaders/vertex/standard.glsl
1471
- var standard_default2 = "varying vec2 vUv;\n\nvoid main() {\n vUv = uv;\n gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );\n}";
1472
-
1473
- // src/lib/graphics/render-pass.ts
1474
- import { WebGLRenderTarget } from "three";
1475
- import { Pass, FullScreenQuad } from "three/addons/postprocessing/Pass.js";
1476
- var RenderPass = class extends Pass {
1477
- fsQuad;
1478
- resolution;
1479
- scene;
1480
- camera;
1481
- rgbRenderTarget;
1482
- normalRenderTarget;
1483
- normalMaterial;
1484
- constructor(resolution, scene, camera) {
1485
- super();
1486
- this.resolution = resolution;
1487
- this.fsQuad = new FullScreenQuad(this.material());
1488
- this.scene = scene;
1489
- this.camera = camera;
1490
- this.rgbRenderTarget = new WebGLRenderTarget(resolution.x * 4, resolution.y * 4);
1491
- this.normalRenderTarget = new WebGLRenderTarget(resolution.x * 4, resolution.y * 4);
1492
- this.normalMaterial = new THREE.MeshNormalMaterial();
1493
- }
1494
- render(renderer, writeBuffer) {
1495
- renderer.setRenderTarget(this.rgbRenderTarget);
1496
- renderer.render(this.scene, this.camera);
1497
- const overrideMaterial_old = this.scene.overrideMaterial;
1498
- renderer.setRenderTarget(this.normalRenderTarget);
1499
- this.scene.overrideMaterial = this.normalMaterial;
1500
- renderer.render(this.scene, this.camera);
1501
- this.scene.overrideMaterial = overrideMaterial_old;
1502
- const uniforms = this.fsQuad.material.uniforms;
1503
- uniforms.tDiffuse.value = this.rgbRenderTarget.texture;
1504
- uniforms.tDepth.value = this.rgbRenderTarget.depthTexture;
1505
- uniforms.tNormal.value = this.normalRenderTarget.texture;
1506
- uniforms.iTime.value += 0.01;
1507
- if (this.renderToScreen) {
1508
- renderer.setRenderTarget(null);
1509
- } else {
1510
- renderer.setRenderTarget(writeBuffer);
1511
- }
1512
- this.fsQuad.render(renderer);
1513
- }
1514
- material() {
1515
- return new THREE.ShaderMaterial({
1516
- uniforms: {
1517
- iTime: { value: 0 },
1518
- tDiffuse: { value: null },
1519
- tDepth: { value: null },
1520
- tNormal: { value: null },
1521
- resolution: {
1522
- value: new THREE.Vector4(
1523
- this.resolution.x,
1524
- this.resolution.y,
1525
- 1 / this.resolution.x,
1526
- 1 / this.resolution.y
1527
- )
1528
- }
1529
- },
1530
- vertexShader: standard_default2,
1531
- fragmentShader: standard_default
1532
- });
1533
- }
1534
- dispose() {
1535
- try {
1536
- this.fsQuad?.dispose?.();
1537
- } catch {
1538
- }
1539
- try {
1540
- this.rgbRenderTarget?.dispose?.();
1541
- this.normalRenderTarget?.dispose?.();
1542
- } catch {
1543
- }
1544
- try {
1545
- this.normalMaterial?.dispose?.();
1546
- } catch {
1547
- }
1548
- }
1549
- };
1550
-
1551
3343
  // src/lib/camera/camera-debug-delegate.ts
1552
- import { Vector3 as Vector38 } from "three";
3344
+ import { Vector3 as Vector315 } from "three";
1553
3345
  import { OrbitControls } from "three/addons/controls/OrbitControls.js";
1554
3346
  var CameraOrbitController = class {
1555
3347
  camera;
1556
3348
  domElement;
3349
+ cameraRig = null;
3350
+ sceneRef = null;
1557
3351
  orbitControls = null;
1558
3352
  orbitTarget = null;
1559
- orbitTargetWorldPos = new Vector38();
3353
+ orbitTargetWorldPos = new Vector315();
1560
3354
  debugDelegate = null;
1561
3355
  debugUnsubscribe = null;
1562
3356
  debugStateSnapshot = { enabled: false, selected: [] };
@@ -1564,16 +3358,37 @@ var CameraOrbitController = class {
1564
3358
  savedCameraPosition = null;
1565
3359
  savedCameraQuaternion = null;
1566
3360
  savedCameraZoom = null;
1567
- constructor(camera, domElement) {
3361
+ savedCameraLocalPosition = null;
3362
+ // Saved debug camera state for restoration when re-entering debug mode
3363
+ savedDebugCameraPosition = null;
3364
+ savedDebugCameraQuaternion = null;
3365
+ savedDebugCameraZoom = null;
3366
+ savedDebugOrbitTarget = null;
3367
+ /** Whether user-configured orbital controls are enabled (independent of debug) */
3368
+ _userOrbitEnabled = false;
3369
+ constructor(camera, domElement, cameraRig) {
1568
3370
  this.camera = camera;
1569
3371
  this.domElement = domElement;
3372
+ this.cameraRig = cameraRig ?? null;
3373
+ }
3374
+ /**
3375
+ * Set the scene reference for adding/removing camera when detaching from rig.
3376
+ */
3377
+ setScene(scene) {
3378
+ this.sceneRef = scene;
1570
3379
  }
1571
3380
  /**
1572
- * Check if debug mode is currently active (orbit controls enabled).
3381
+ * Check if debug mode is currently active (orbit controls enabled via debug system).
1573
3382
  */
1574
3383
  get isActive() {
1575
3384
  return this.debugStateSnapshot.enabled;
1576
3385
  }
3386
+ /**
3387
+ * Check if any orbit controls are currently active (debug or user).
3388
+ */
3389
+ get isOrbitActive() {
3390
+ return this.debugStateSnapshot.enabled || this._userOrbitEnabled;
3391
+ }
1577
3392
  /**
1578
3393
  * Update orbit controls each frame.
1579
3394
  * Should be called from the camera's update loop.
@@ -1585,6 +3400,26 @@ var CameraOrbitController = class {
1585
3400
  }
1586
3401
  this.orbitControls?.update();
1587
3402
  }
3403
+ /**
3404
+ * Enable user-configured orbital controls (not debug mode).
3405
+ * These orbit controls persist until explicitly disabled.
3406
+ */
3407
+ enableUserOrbitControls() {
3408
+ this._userOrbitEnabled = true;
3409
+ if (!this.orbitControls) {
3410
+ this.enableOrbitControls();
3411
+ }
3412
+ }
3413
+ /**
3414
+ * Disable user-configured orbital controls.
3415
+ * Will not disable orbit controls if debug mode is active.
3416
+ */
3417
+ disableUserOrbitControls() {
3418
+ this._userOrbitEnabled = false;
3419
+ if (!this.debugStateSnapshot.enabled) {
3420
+ this.disableOrbitControls();
3421
+ }
3422
+ }
1588
3423
  /**
1589
3424
  * Attach a delegate to react to debug state changes.
1590
3425
  */
@@ -1626,11 +3461,17 @@ var CameraOrbitController = class {
1626
3461
  };
1627
3462
  if (state2.enabled && !wasEnabled) {
1628
3463
  this.saveCameraState();
3464
+ this.detachCameraFromRig();
1629
3465
  this.enableOrbitControls();
3466
+ this.restoreDebugCameraState();
1630
3467
  this.updateOrbitTargetFromSelection(state2.selected);
1631
3468
  } else if (!state2.enabled && wasEnabled) {
3469
+ this.saveDebugCameraState();
1632
3470
  this.orbitTarget = null;
1633
- this.disableOrbitControls();
3471
+ if (!this._userOrbitEnabled) {
3472
+ this.disableOrbitControls();
3473
+ }
3474
+ this.reattachCameraToRig();
1634
3475
  this.restoreCameraState();
1635
3476
  } else if (state2.enabled) {
1636
3477
  this.updateOrbitTargetFromSelection(state2.selected);
@@ -1716,76 +3557,488 @@ var CameraOrbitController = class {
1716
3557
  this.savedCameraZoom = null;
1717
3558
  }
1718
3559
  }
1719
- };
1720
-
1721
- // src/lib/camera/zylem-camera.ts
1722
- var ZylemCamera = class {
1723
- cameraRig = null;
1724
- camera;
1725
- screenResolution;
3560
+ /**
3561
+ * Save debug camera state when exiting debug mode.
3562
+ */
3563
+ saveDebugCameraState() {
3564
+ this.savedDebugCameraPosition = this.camera.position.clone();
3565
+ this.savedDebugCameraQuaternion = this.camera.quaternion.clone();
3566
+ if ("zoom" in this.camera) {
3567
+ this.savedDebugCameraZoom = this.camera.zoom;
3568
+ }
3569
+ if (this.orbitControls) {
3570
+ this.savedDebugOrbitTarget = this.orbitControls.target.clone();
3571
+ }
3572
+ }
3573
+ /**
3574
+ * Restore debug camera state when re-entering debug mode.
3575
+ */
3576
+ restoreDebugCameraState() {
3577
+ if (this.savedDebugCameraPosition) {
3578
+ this.camera.position.copy(this.savedDebugCameraPosition);
3579
+ }
3580
+ if (this.savedDebugCameraQuaternion) {
3581
+ this.camera.quaternion.copy(this.savedDebugCameraQuaternion);
3582
+ }
3583
+ if (this.savedDebugCameraZoom !== null && "zoom" in this.camera) {
3584
+ this.camera.zoom = this.savedDebugCameraZoom;
3585
+ this.camera.updateProjectionMatrix?.();
3586
+ }
3587
+ if (this.savedDebugOrbitTarget && this.orbitControls) {
3588
+ this.orbitControls.target.copy(this.savedDebugOrbitTarget);
3589
+ }
3590
+ }
3591
+ /**
3592
+ * Detach camera from its rig to allow free orbit movement in debug mode.
3593
+ * Preserves the camera's world position.
3594
+ */
3595
+ detachCameraFromRig() {
3596
+ if (!this.cameraRig || this.camera.parent !== this.cameraRig) {
3597
+ return;
3598
+ }
3599
+ this.savedCameraLocalPosition = this.camera.position.clone();
3600
+ const worldPos = new Vector315();
3601
+ this.camera.getWorldPosition(worldPos);
3602
+ this.cameraRig.remove(this.camera);
3603
+ if (this.sceneRef) {
3604
+ this.sceneRef.add(this.camera);
3605
+ }
3606
+ this.camera.position.copy(worldPos);
3607
+ }
3608
+ /**
3609
+ * Reattach camera to its rig when exiting debug mode.
3610
+ * Restores the camera's local position relative to the rig.
3611
+ */
3612
+ reattachCameraToRig() {
3613
+ if (!this.cameraRig || this.camera.parent === this.cameraRig) {
3614
+ return;
3615
+ }
3616
+ if (this.sceneRef && this.camera.parent === this.sceneRef) {
3617
+ this.sceneRef.remove(this.camera);
3618
+ }
3619
+ this.cameraRig.add(this.camera);
3620
+ if (this.savedCameraLocalPosition) {
3621
+ this.camera.position.copy(this.savedCameraLocalPosition);
3622
+ this.savedCameraLocalPosition = null;
3623
+ }
3624
+ }
3625
+ };
3626
+
3627
+ // src/lib/camera/renderer-manager.ts
3628
+ import { Vector2 as Vector25, WebGLRenderer as WebGLRenderer2 } from "three";
3629
+ import { WebGPURenderer } from "three/webgpu";
3630
+ import { EffectComposer } from "three/examples/jsm/postprocessing/EffectComposer.js";
3631
+
3632
+ // src/lib/graphics/render-pass.ts
3633
+ import * as THREE from "three";
3634
+ import { WebGLRenderTarget } from "three";
3635
+ import { Pass, FullScreenQuad } from "three/addons/postprocessing/Pass.js";
3636
+ var RenderPass = class extends Pass {
3637
+ fsQuad;
3638
+ resolution;
3639
+ scene;
3640
+ camera;
3641
+ rgbRenderTarget;
3642
+ normalRenderTarget;
3643
+ normalMaterial;
3644
+ constructor(resolution, scene, camera) {
3645
+ super();
3646
+ this.resolution = resolution;
3647
+ this.fsQuad = new FullScreenQuad(this.material());
3648
+ this.scene = scene;
3649
+ this.camera = camera;
3650
+ this.rgbRenderTarget = new WebGLRenderTarget(resolution.x * 4, resolution.y * 4);
3651
+ this.normalRenderTarget = new WebGLRenderTarget(resolution.x * 4, resolution.y * 4);
3652
+ this.normalMaterial = new THREE.MeshNormalMaterial();
3653
+ }
3654
+ render(renderer, writeBuffer) {
3655
+ renderer.setRenderTarget(this.rgbRenderTarget);
3656
+ renderer.render(this.scene, this.camera);
3657
+ const overrideMaterial_old = this.scene.overrideMaterial;
3658
+ renderer.setRenderTarget(this.normalRenderTarget);
3659
+ this.scene.overrideMaterial = this.normalMaterial;
3660
+ renderer.render(this.scene, this.camera);
3661
+ this.scene.overrideMaterial = overrideMaterial_old;
3662
+ const uniforms = this.fsQuad.material.uniforms;
3663
+ uniforms.tDiffuse.value = this.rgbRenderTarget.texture;
3664
+ uniforms.tDepth.value = this.rgbRenderTarget.depthTexture;
3665
+ uniforms.tNormal.value = this.normalRenderTarget.texture;
3666
+ uniforms.iTime.value += 0.01;
3667
+ if (this.renderToScreen) {
3668
+ renderer.setRenderTarget(null);
3669
+ } else {
3670
+ renderer.setRenderTarget(writeBuffer);
3671
+ }
3672
+ this.fsQuad.render(renderer);
3673
+ }
3674
+ material() {
3675
+ return new THREE.ShaderMaterial({
3676
+ uniforms: {
3677
+ iTime: { value: 0 },
3678
+ tDiffuse: { value: null },
3679
+ tDepth: { value: null },
3680
+ tNormal: { value: null },
3681
+ resolution: {
3682
+ value: new THREE.Vector4(
3683
+ this.resolution.x,
3684
+ this.resolution.y,
3685
+ 1 / this.resolution.x,
3686
+ 1 / this.resolution.y
3687
+ )
3688
+ }
3689
+ },
3690
+ vertexShader: standardShader.vertex,
3691
+ fragmentShader: standardShader.fragment
3692
+ });
3693
+ }
3694
+ dispose() {
3695
+ try {
3696
+ this.fsQuad?.dispose?.();
3697
+ } catch {
3698
+ }
3699
+ try {
3700
+ this.rgbRenderTarget?.dispose?.();
3701
+ this.normalRenderTarget?.dispose?.();
3702
+ } catch {
3703
+ }
3704
+ try {
3705
+ this.normalMaterial?.dispose?.();
3706
+ } catch {
3707
+ }
3708
+ }
3709
+ };
3710
+
3711
+ // src/lib/camera/renderer-manager.ts
3712
+ var DEFAULT_VIEWPORT = { x: 0, y: 0, width: 1, height: 1 };
3713
+ async function isWebGPUSupported() {
3714
+ if (!("gpu" in navigator)) return false;
3715
+ try {
3716
+ const adapter = await navigator.gpu.requestAdapter();
3717
+ return adapter !== null;
3718
+ } catch {
3719
+ return false;
3720
+ }
3721
+ }
3722
+ var RendererManager = class {
1726
3723
  renderer;
1727
3724
  composer;
3725
+ screenResolution;
3726
+ rendererType;
3727
+ _isWebGPU = false;
3728
+ _initialized = false;
3729
+ _sceneRef = null;
3730
+ constructor(screenResolution, rendererType = "webgl") {
3731
+ this.screenResolution = screenResolution || new Vector25(window.innerWidth, window.innerHeight);
3732
+ this.rendererType = rendererType;
3733
+ }
3734
+ /**
3735
+ * Check if the renderer has been initialized
3736
+ */
3737
+ get initialized() {
3738
+ return this._initialized;
3739
+ }
3740
+ /**
3741
+ * Check if using WebGPU renderer
3742
+ */
3743
+ get isWebGPU() {
3744
+ return this._isWebGPU;
3745
+ }
3746
+ /**
3747
+ * Initialize the renderer (must be called before rendering).
3748
+ * Async because WebGPU requires async initialization.
3749
+ */
3750
+ async initRenderer() {
3751
+ if (this._initialized) return;
3752
+ let useWebGPU = false;
3753
+ if (this.rendererType === "webgpu") {
3754
+ useWebGPU = true;
3755
+ } else if (this.rendererType === "auto") {
3756
+ useWebGPU = await isWebGPUSupported();
3757
+ }
3758
+ if (useWebGPU) {
3759
+ try {
3760
+ this.renderer = new WebGPURenderer({ antialias: true });
3761
+ await this.renderer.init();
3762
+ this._isWebGPU = true;
3763
+ console.log("RendererManager: Using WebGPU renderer");
3764
+ } catch (e) {
3765
+ console.warn("RendererManager: WebGPU init failed, falling back to WebGL", e);
3766
+ this.renderer = new WebGLRenderer2({ antialias: false, alpha: true });
3767
+ this._isWebGPU = false;
3768
+ }
3769
+ } else {
3770
+ this.renderer = new WebGLRenderer2({ antialias: false, alpha: true });
3771
+ this._isWebGPU = false;
3772
+ console.log("RendererManager: Using WebGL renderer");
3773
+ }
3774
+ this.renderer.setSize(this.screenResolution.x, this.screenResolution.y);
3775
+ if (this.renderer instanceof WebGLRenderer2) {
3776
+ this.renderer.shadowMap.enabled = true;
3777
+ }
3778
+ if (!this._isWebGPU) {
3779
+ this.composer = new EffectComposer(this.renderer);
3780
+ }
3781
+ this._initialized = true;
3782
+ }
3783
+ /**
3784
+ * Set the current scene reference for rendering.
3785
+ */
3786
+ setScene(scene) {
3787
+ this._sceneRef = scene;
3788
+ }
3789
+ /**
3790
+ * Setup post-processing render pass for a camera (WebGL only).
3791
+ */
3792
+ setupRenderPass(scene, camera) {
3793
+ if (this._isWebGPU || !this.composer) return;
3794
+ if (this.composer.passes.length > 0) {
3795
+ this.composer.passes.forEach((p) => {
3796
+ try {
3797
+ p.dispose?.();
3798
+ } catch {
3799
+ }
3800
+ });
3801
+ this.composer.passes.length = 0;
3802
+ }
3803
+ const renderResolution = this.screenResolution.clone().divideScalar(2);
3804
+ renderResolution.x |= 0;
3805
+ renderResolution.y |= 0;
3806
+ const pass = new RenderPass(renderResolution, scene, camera);
3807
+ this.composer.addPass(pass);
3808
+ }
3809
+ /**
3810
+ * Start the render loop. Calls the provided callback each frame.
3811
+ */
3812
+ startRenderLoop(onFrame) {
3813
+ this.renderer.setAnimationLoop((delta) => {
3814
+ onFrame(delta || 0);
3815
+ });
3816
+ }
3817
+ /**
3818
+ * Stop the render loop.
3819
+ */
3820
+ stopRenderLoop() {
3821
+ try {
3822
+ this.renderer.setAnimationLoop(null);
3823
+ } catch {
3824
+ }
3825
+ }
3826
+ /**
3827
+ * Render a scene from a single camera's perspective.
3828
+ * Sets the viewport based on the camera's viewport config.
3829
+ */
3830
+ renderCamera(scene, camera) {
3831
+ const vp = camera.viewport;
3832
+ const w = this.screenResolution.x;
3833
+ const h = this.screenResolution.y;
3834
+ const pixelX = Math.floor(vp.x * w);
3835
+ const pixelY = Math.floor(vp.y * h);
3836
+ const pixelW = Math.floor(vp.width * w);
3837
+ const pixelH = Math.floor(vp.height * h);
3838
+ if (this.renderer instanceof WebGLRenderer2) {
3839
+ this.renderer.setViewport(pixelX, pixelY, pixelW, pixelH);
3840
+ this.renderer.setScissor(pixelX, pixelY, pixelW, pixelH);
3841
+ this.renderer.setScissorTest(true);
3842
+ }
3843
+ if (this._isWebGPU) {
3844
+ this.renderer.render(scene, camera.camera);
3845
+ } else if (this.composer) {
3846
+ this.composer.render(0);
3847
+ }
3848
+ }
3849
+ /**
3850
+ * Render a scene from multiple cameras, each with their own viewport.
3851
+ * Cameras are rendered in order (first = bottom layer, last = top layer).
3852
+ */
3853
+ renderCameras(scene, cameras) {
3854
+ if (!scene || cameras.length === 0) return;
3855
+ if (this.renderer instanceof WebGLRenderer2) {
3856
+ this.renderer.setScissorTest(false);
3857
+ this.renderer.clear();
3858
+ }
3859
+ for (const cam of cameras) {
3860
+ this.renderCamera(scene, cam);
3861
+ }
3862
+ if (this.renderer instanceof WebGLRenderer2) {
3863
+ this.renderer.setScissorTest(false);
3864
+ }
3865
+ }
3866
+ /**
3867
+ * Simple single-camera render (backwards compatible).
3868
+ * Uses the full viewport for a single camera.
3869
+ */
3870
+ render(scene, camera) {
3871
+ if (this._isWebGPU) {
3872
+ this.renderer.render(scene, camera);
3873
+ } else if (this.composer) {
3874
+ this.composer.render(0);
3875
+ }
3876
+ }
3877
+ /**
3878
+ * Resize the renderer and update resolution.
3879
+ */
3880
+ resize(width, height) {
3881
+ this.screenResolution.set(width, height);
3882
+ this.renderer.setSize(width, height, false);
3883
+ if (this.composer) {
3884
+ this.composer.setSize(width, height);
3885
+ }
3886
+ }
3887
+ /**
3888
+ * Update renderer pixel ratio (DPR).
3889
+ */
3890
+ setPixelRatio(dpr) {
3891
+ const safe = Math.max(1, Number.isFinite(dpr) ? dpr : 1);
3892
+ this.renderer.setPixelRatio(safe);
3893
+ }
3894
+ /**
3895
+ * Get the DOM element for the renderer.
3896
+ */
3897
+ getDomElement() {
3898
+ return this.renderer.domElement;
3899
+ }
3900
+ /**
3901
+ * Dispose renderer, composer, and related resources.
3902
+ */
3903
+ dispose() {
3904
+ this.stopRenderLoop();
3905
+ try {
3906
+ this.composer?.passes?.forEach((p) => p.dispose?.());
3907
+ this.composer?.dispose?.();
3908
+ } catch {
3909
+ }
3910
+ try {
3911
+ this.renderer.dispose();
3912
+ } catch {
3913
+ }
3914
+ this._sceneRef = null;
3915
+ this._initialized = false;
3916
+ }
3917
+ };
3918
+
3919
+ // src/lib/camera/zylem-camera.ts
3920
+ var ZylemCamera = class {
3921
+ cameraRig = null;
3922
+ camera;
3923
+ screenResolution;
1728
3924
  _perspective;
1729
- target = null;
1730
- sceneRef = null;
1731
3925
  frustumSize = 10;
3926
+ rendererType;
3927
+ sceneRef = null;
3928
+ /** Name for camera manager lookup */
3929
+ name = "";
3930
+ /**
3931
+ * Viewport in normalized coordinates (0-1).
3932
+ * Default is fullscreen: { x: 0, y: 0, width: 1, height: 1 }
3933
+ */
3934
+ viewport = { ...DEFAULT_VIEWPORT };
3935
+ /**
3936
+ * Multiple targets for the camera to follow/frame.
3937
+ * Replaces the old single `target` property.
3938
+ */
3939
+ targets = [];
3940
+ /**
3941
+ * @deprecated Use `targets` array instead. This getter/setter is kept for backward compatibility.
3942
+ */
3943
+ get target() {
3944
+ return this.targets.length > 0 ? this.targets[0] : null;
3945
+ }
3946
+ set target(entity) {
3947
+ if (entity) {
3948
+ if (this.targets.length === 0) {
3949
+ this.targets.push(entity);
3950
+ } else {
3951
+ this.targets[0] = entity;
3952
+ }
3953
+ } else {
3954
+ this.targets = [];
3955
+ }
3956
+ }
1732
3957
  // Perspective controller delegation
1733
3958
  perspectiveController = null;
1734
- // Debug/orbit controls delegation
3959
+ // Orbit controls
1735
3960
  orbitController = null;
1736
- constructor(perspective, screenResolution, frustumSize = 10) {
3961
+ _useOrbitalControls = false;
3962
+ /** Reference to the shared renderer manager (set during setup) */
3963
+ _rendererManager = null;
3964
+ constructor(perspective, screenResolution, frustumSize = 10, rendererType = "webgl") {
1737
3965
  this._perspective = perspective;
1738
3966
  this.screenResolution = screenResolution;
1739
3967
  this.frustumSize = frustumSize;
1740
- this.renderer = new WebGLRenderer3({ antialias: false, alpha: true });
1741
- this.renderer.setSize(screenResolution.x, screenResolution.y);
1742
- this.renderer.shadowMap.enabled = true;
1743
- this.composer = new EffectComposer(this.renderer);
3968
+ this.rendererType = rendererType;
1744
3969
  const aspectRatio = screenResolution.x / screenResolution.y;
1745
3970
  this.camera = this.createCameraForPerspective(aspectRatio);
1746
3971
  if (this.needsRig()) {
1747
- this.cameraRig = new Object3D6();
3972
+ this.cameraRig = new Object3D8();
1748
3973
  this.cameraRig.position.set(0, 3, 10);
1749
3974
  this.cameraRig.add(this.camera);
1750
- this.camera.lookAt(new Vector39(0, 2, 0));
3975
+ this.camera.lookAt(new Vector316(0, 2, 0));
1751
3976
  } else {
1752
3977
  this.camera.position.set(0, 0, 10);
1753
- this.camera.lookAt(new Vector39(0, 0, 0));
3978
+ this.camera.lookAt(new Vector316(0, 0, 0));
1754
3979
  }
1755
3980
  this.initializePerspectiveController();
1756
- this.orbitController = new CameraOrbitController(this.camera, this.renderer.domElement);
1757
3981
  }
1758
3982
  /**
1759
- * Setup the camera with a scene
3983
+ * Setup the camera with a scene and renderer manager.
3984
+ * The renderer manager provides shared rendering infrastructure.
1760
3985
  */
1761
- async setup(scene) {
3986
+ async setup(scene, rendererManager) {
1762
3987
  this.sceneRef = scene;
1763
- let renderResolution = this.screenResolution.clone().divideScalar(2);
1764
- renderResolution.x |= 0;
1765
- renderResolution.y |= 0;
1766
- const pass = new RenderPass(renderResolution, scene, this.camera);
1767
- this.composer.addPass(pass);
1768
- if (this.perspectiveController) {
3988
+ if (rendererManager) {
3989
+ this._rendererManager = rendererManager;
3990
+ }
3991
+ if (this._rendererManager && !this._rendererManager.initialized) {
3992
+ await this._rendererManager.initRenderer();
3993
+ }
3994
+ if (this.perspectiveController && this._rendererManager) {
1769
3995
  this.perspectiveController.setup({
1770
3996
  screenResolution: this.screenResolution,
1771
- renderer: this.renderer,
3997
+ renderer: this._rendererManager.renderer,
1772
3998
  scene,
1773
3999
  camera: this
1774
4000
  });
1775
4001
  }
1776
- this.renderer.setAnimationLoop((delta) => {
1777
- this.update(delta || 0);
1778
- });
4002
+ if (this._rendererManager) {
4003
+ this.orbitController = new CameraOrbitController(
4004
+ this.camera,
4005
+ this._rendererManager.renderer.domElement,
4006
+ this.cameraRig
4007
+ );
4008
+ this.orbitController.setScene(scene);
4009
+ if (this._useOrbitalControls) {
4010
+ this.orbitController.enableUserOrbitControls();
4011
+ }
4012
+ }
1779
4013
  }
1780
4014
  /**
1781
- * Update camera and render
4015
+ * Legacy setup method for backward compatibility.
4016
+ * Creates a temporary RendererManager internally.
4017
+ * @deprecated Use setup(scene, rendererManager) instead.
4018
+ */
4019
+ async setupLegacy(scene) {
4020
+ if (!this._rendererManager) {
4021
+ this._rendererManager = new RendererManager(this.screenResolution, this.rendererType);
4022
+ await this._rendererManager.initRenderer();
4023
+ this._rendererManager.setupRenderPass(scene, this.camera);
4024
+ this._rendererManager.startRenderLoop((delta) => {
4025
+ this.update(delta);
4026
+ if (this._rendererManager && this.sceneRef) {
4027
+ this._rendererManager.render(this.sceneRef, this.camera);
4028
+ }
4029
+ });
4030
+ }
4031
+ await this.setup(scene, this._rendererManager);
4032
+ }
4033
+ /**
4034
+ * Update camera controllers (called each frame).
4035
+ * Does NOT render -- rendering is handled by RendererManager.
1782
4036
  */
1783
4037
  update(delta) {
1784
4038
  this.orbitController?.update();
1785
- if (this.perspectiveController && !this.isDebugModeActive()) {
4039
+ if (this.perspectiveController && !this.isDebugModeActive() && !this._useOrbitalControls) {
1786
4040
  this.perspectiveController.update(delta);
1787
4041
  }
1788
- this.composer.render(delta);
1789
4042
  }
1790
4043
  /**
1791
4044
  * Check if debug mode is active (orbit controls taking over camera)
@@ -1794,27 +4047,59 @@ var ZylemCamera = class {
1794
4047
  return this.orbitController?.isActive ?? false;
1795
4048
  }
1796
4049
  /**
1797
- * Dispose renderer, composer, controls, and detach from scene
4050
+ * Enable user-configured orbital controls (not debug mode).
1798
4051
  */
1799
- destroy() {
1800
- try {
1801
- this.renderer.setAnimationLoop(null);
1802
- } catch {
1803
- }
1804
- try {
1805
- this.orbitController?.dispose();
1806
- } catch {
4052
+ enableOrbitalControls() {
4053
+ this._useOrbitalControls = true;
4054
+ this.orbitController?.enableUserOrbitControls();
4055
+ }
4056
+ /**
4057
+ * Disable user-configured orbital controls.
4058
+ */
4059
+ disableOrbitalControls() {
4060
+ this._useOrbitalControls = false;
4061
+ this.orbitController?.disableUserOrbitControls();
4062
+ }
4063
+ /**
4064
+ * Whether user orbital controls are enabled.
4065
+ */
4066
+ get useOrbitalControls() {
4067
+ return this._useOrbitalControls;
4068
+ }
4069
+ /**
4070
+ * Add a target entity for the camera to follow/frame.
4071
+ */
4072
+ addTarget(entity) {
4073
+ if (!this.targets.includes(entity)) {
4074
+ this.targets.push(entity);
1807
4075
  }
1808
- try {
1809
- this.composer?.passes?.forEach((p) => p.dispose?.());
1810
- this.composer?.dispose?.();
1811
- } catch {
4076
+ }
4077
+ /**
4078
+ * Remove a target entity.
4079
+ */
4080
+ removeTarget(entity) {
4081
+ const index = this.targets.indexOf(entity);
4082
+ if (index !== -1) {
4083
+ this.targets.splice(index, 1);
1812
4084
  }
4085
+ }
4086
+ /**
4087
+ * Clear all targets.
4088
+ */
4089
+ clearTargets() {
4090
+ this.targets = [];
4091
+ }
4092
+ /**
4093
+ * Dispose camera resources (not the renderer -- that's managed by RendererManager).
4094
+ */
4095
+ destroy() {
1813
4096
  try {
1814
- this.renderer.dispose();
4097
+ this.orbitController?.dispose();
1815
4098
  } catch {
1816
4099
  }
1817
4100
  this.sceneRef = null;
4101
+ this.targets = [];
4102
+ this._rendererManager = null;
1818
4103
  }
1819
4104
  /**
1820
4105
  * Attach a delegate to react to debug state changes.
@@ -1823,13 +4108,11 @@ var ZylemCamera = class {
1823
4108
  this.orbitController?.setDebugDelegate(delegate);
1824
4109
  }
1825
4110
  /**
1826
- * Resize camera and renderer
4111
+ * Resize camera projection.
1827
4112
  */
1828
4113
  resize(width, height) {
1829
4114
  this.screenResolution.set(width, height);
1830
- this.renderer.setSize(width, height, false);
1831
- this.composer.setSize(width, height);
1832
- if (this.camera instanceof PerspectiveCamera) {
4115
+ if (this.camera instanceof PerspectiveCamera2) {
1833
4116
  this.camera.aspect = width / height;
1834
4117
  this.camera.updateProjectionMatrix();
1835
4118
  }
@@ -1838,111 +4121,362 @@ var ZylemCamera = class {
1838
4121
  }
1839
4122
  }
1840
4123
  /**
1841
- * Update renderer pixel ratio (DPR)
4124
+ * Set the viewport for this camera (normalized 0-1 coordinates).
4125
+ */
4126
+ setViewport(x, y, width, height) {
4127
+ this.viewport = { x, y, width, height };
4128
+ }
4129
+ /**
4130
+ * Create camera based on perspective type
4131
+ */
4132
+ createCameraForPerspective(aspectRatio) {
4133
+ switch (this._perspective) {
4134
+ case Perspectives.ThirdPerson:
4135
+ return this.createThirdPersonCamera(aspectRatio);
4136
+ case Perspectives.FirstPerson:
4137
+ return this.createFirstPersonCamera(aspectRatio);
4138
+ case Perspectives.Isometric:
4139
+ return this.createIsometricCamera(aspectRatio);
4140
+ case Perspectives.Flat2D:
4141
+ return this.createFlat2DCamera(aspectRatio);
4142
+ case Perspectives.Fixed2D:
4143
+ return this.createFixed2DCamera(aspectRatio);
4144
+ default:
4145
+ return this.createThirdPersonCamera(aspectRatio);
4146
+ }
4147
+ }
4148
+ /**
4149
+ * Initialize perspective-specific controller
4150
+ */
4151
+ initializePerspectiveController() {
4152
+ switch (this._perspective) {
4153
+ case Perspectives.ThirdPerson:
4154
+ this.perspectiveController = new ThirdPersonCamera();
4155
+ break;
4156
+ case Perspectives.Fixed2D:
4157
+ this.perspectiveController = new Fixed2DCamera();
4158
+ break;
4159
+ default:
4160
+ this.perspectiveController = new ThirdPersonCamera();
4161
+ }
4162
+ }
4163
+ createThirdPersonCamera(aspectRatio) {
4164
+ return new PerspectiveCamera2(75, aspectRatio, 0.1, 1e3);
4165
+ }
4166
+ createFirstPersonCamera(aspectRatio) {
4167
+ return new PerspectiveCamera2(75, aspectRatio, 0.1, 1e3);
4168
+ }
4169
+ createIsometricCamera(aspectRatio) {
4170
+ return new OrthographicCamera(
4171
+ this.frustumSize * aspectRatio / -2,
4172
+ this.frustumSize * aspectRatio / 2,
4173
+ this.frustumSize / 2,
4174
+ this.frustumSize / -2,
4175
+ 1,
4176
+ 1e3
4177
+ );
4178
+ }
4179
+ createFlat2DCamera(aspectRatio) {
4180
+ return new OrthographicCamera(
4181
+ this.frustumSize * aspectRatio / -2,
4182
+ this.frustumSize * aspectRatio / 2,
4183
+ this.frustumSize / 2,
4184
+ this.frustumSize / -2,
4185
+ 1,
4186
+ 1e3
4187
+ );
4188
+ }
4189
+ createFixed2DCamera(aspectRatio) {
4190
+ return this.createFlat2DCamera(aspectRatio);
4191
+ }
4192
+ // Movement methods
4193
+ moveCamera(position2) {
4194
+ if (this._perspective === Perspectives.Flat2D || this._perspective === Perspectives.Fixed2D) {
4195
+ this.frustumSize = position2.z;
4196
+ }
4197
+ if (this.cameraRig) {
4198
+ this.cameraRig.position.set(position2.x, position2.y, position2.z);
4199
+ } else {
4200
+ this.camera.position.set(position2.x, position2.y, position2.z);
4201
+ }
4202
+ }
4203
+ move(position2) {
4204
+ this.moveCamera(position2);
4205
+ }
4206
+ rotate(pitch, yaw, roll) {
4207
+ if (this.cameraRig) {
4208
+ this.cameraRig.rotateX(pitch);
4209
+ this.cameraRig.rotateY(yaw);
4210
+ this.cameraRig.rotateZ(roll);
4211
+ } else {
4212
+ this.camera.rotateX(pitch);
4213
+ this.camera.rotateY(yaw);
4214
+ this.camera.rotateZ(roll);
4215
+ }
4216
+ }
4217
+ /**
4218
+ * Check if this perspective type needs a camera rig
4219
+ */
4220
+ needsRig() {
4221
+ return this._perspective === Perspectives.ThirdPerson;
4222
+ }
4223
+ /**
4224
+ * Get the DOM element for the renderer.
4225
+ * @deprecated Access via RendererManager instead.
4226
+ */
4227
+ getDomElement() {
4228
+ if (this._rendererManager) {
4229
+ return this._rendererManager.getDomElement();
4230
+ }
4231
+ throw new Error("ZylemCamera: No renderer manager available. Call setup() first.");
4232
+ }
4233
+ /**
4234
+ * Get the renderer manager reference.
4235
+ */
4236
+ getRendererManager() {
4237
+ return this._rendererManager;
4238
+ }
4239
+ /**
4240
+ * Set the renderer manager reference (used by CameraManager during setup).
4241
+ */
4242
+ setRendererManager(manager) {
4243
+ this._rendererManager = manager;
4244
+ }
4245
+ // ─── Legacy compatibility methods ────────────────────────────────────────
4246
+ /**
4247
+ * @deprecated Renderer is now owned by RendererManager
4248
+ */
4249
+ get renderer() {
4250
+ return this._rendererManager?.renderer;
4251
+ }
4252
+ /**
4253
+ * @deprecated Composer is now owned by RendererManager
4254
+ */
4255
+ get composer() {
4256
+ return this._rendererManager?.composer;
4257
+ }
4258
+ /**
4259
+ * @deprecated Use RendererManager.setPixelRatio() instead
4260
+ */
4261
+ setPixelRatio(dpr) {
4262
+ this._rendererManager?.setPixelRatio(dpr);
4263
+ }
4264
+ };
4265
+
4266
+ // src/lib/camera/camera-manager.ts
4267
+ import { Vector2 as Vector27 } from "three";
4268
+ var CameraManager = class {
4269
+ /** Named camera registry */
4270
+ cameras = /* @__PURE__ */ new Map();
4271
+ /** Currently active cameras, ordered by render layer (first = bottom) */
4272
+ _activeCameras = [];
4273
+ /** Auto-created debug camera with orbit controls */
4274
+ _debugCamera = null;
4275
+ /** Reference to the shared renderer manager */
4276
+ _rendererManager = null;
4277
+ /** Scene reference */
4278
+ _sceneRef = null;
4279
+ /** Counter for auto-generated camera names */
4280
+ _autoNameCounter = 0;
4281
+ constructor() {
4282
+ }
4283
+ /**
4284
+ * Get the list of currently active cameras.
4285
+ */
4286
+ get activeCameras() {
4287
+ return this._activeCameras;
4288
+ }
4289
+ /**
4290
+ * Get the primary active camera (first in the active list).
4291
+ */
4292
+ get primaryCamera() {
4293
+ return this._activeCameras.length > 0 ? this._activeCameras[0] : null;
4294
+ }
4295
+ /**
4296
+ * Get the debug camera.
4297
+ */
4298
+ get debugCamera() {
4299
+ return this._debugCamera;
4300
+ }
4301
+ /**
4302
+ * Get all registered cameras.
4303
+ */
4304
+ get allCameras() {
4305
+ return Array.from(this.cameras.values());
4306
+ }
4307
+ /**
4308
+ * Add a camera to the manager.
4309
+ * If no name is provided, one is auto-generated.
4310
+ * The first camera added becomes the active camera.
4311
+ *
4312
+ * @param camera The ZylemCamera instance to add
4313
+ * @param name Optional name for lookup
4314
+ * @returns The assigned name
4315
+ */
4316
+ addCamera(camera, name) {
4317
+ const resolvedName = name || camera.name || `camera_${this._autoNameCounter++}`;
4318
+ camera.name = resolvedName;
4319
+ this.cameras.set(resolvedName, camera);
4320
+ if (this._activeCameras.length === 0) {
4321
+ this._activeCameras.push(camera);
4322
+ }
4323
+ return resolvedName;
4324
+ }
4325
+ /**
4326
+ * Remove a camera by name or reference.
4327
+ * Cannot remove the debug camera via this method.
4328
+ */
4329
+ removeCamera(nameOrRef) {
4330
+ let name;
4331
+ if (typeof nameOrRef === "string") {
4332
+ name = nameOrRef;
4333
+ } else {
4334
+ for (const [key, cam] of this.cameras.entries()) {
4335
+ if (cam === nameOrRef) {
4336
+ name = key;
4337
+ break;
4338
+ }
4339
+ }
4340
+ }
4341
+ if (!name) return false;
4342
+ const camera = this.cameras.get(name);
4343
+ if (!camera) return false;
4344
+ if (camera === this._debugCamera) {
4345
+ console.warn("CameraManager: Cannot remove the debug camera");
4346
+ return false;
4347
+ }
4348
+ this.cameras.delete(name);
4349
+ const activeIndex = this._activeCameras.indexOf(camera);
4350
+ if (activeIndex !== -1) {
4351
+ this._activeCameras.splice(activeIndex, 1);
4352
+ }
4353
+ return true;
4354
+ }
4355
+ /**
4356
+ * Set a camera as the primary active camera (replaces all active cameras
4357
+ * except additional viewport cameras).
4358
+ *
4359
+ * @param nameOrRef Camera name or reference to activate
4360
+ */
4361
+ setActiveCamera(nameOrRef) {
4362
+ const camera = this.resolveCamera(nameOrRef);
4363
+ if (!camera) {
4364
+ console.warn(`CameraManager: Camera not found: ${nameOrRef}`);
4365
+ return false;
4366
+ }
4367
+ const pipCameras = this._activeCameras.filter((c) => {
4368
+ return c !== this._activeCameras[0] && c.viewport.width < 1;
4369
+ });
4370
+ this._activeCameras = [camera, ...pipCameras];
4371
+ return true;
4372
+ }
4373
+ /**
4374
+ * Add a camera as an additional active camera (for split-screen or PiP).
4375
+ */
4376
+ addActiveCamera(nameOrRef) {
4377
+ const camera = this.resolveCamera(nameOrRef);
4378
+ if (!camera) return false;
4379
+ if (!this._activeCameras.includes(camera)) {
4380
+ this._activeCameras.push(camera);
4381
+ }
4382
+ return true;
4383
+ }
4384
+ /**
4385
+ * Remove a camera from the active render list (does not remove from registry).
4386
+ */
4387
+ deactivateCamera(nameOrRef) {
4388
+ const camera = this.resolveCamera(nameOrRef);
4389
+ if (!camera) return false;
4390
+ const index = this._activeCameras.indexOf(camera);
4391
+ if (index !== -1) {
4392
+ this._activeCameras.splice(index, 1);
4393
+ return true;
4394
+ }
4395
+ return false;
4396
+ }
4397
+ /**
4398
+ * Get a camera by name.
1842
4399
  */
1843
- setPixelRatio(dpr) {
1844
- const safe = Math.max(1, Number.isFinite(dpr) ? dpr : 1);
1845
- this.renderer.setPixelRatio(safe);
4400
+ getCamera(name) {
4401
+ return this.cameras.get(name) ?? null;
1846
4402
  }
1847
4403
  /**
1848
- * Create camera based on perspective type
4404
+ * Setup all cameras with the given scene and renderer manager.
4405
+ * Also creates the debug camera.
1849
4406
  */
1850
- createCameraForPerspective(aspectRatio) {
1851
- switch (this._perspective) {
1852
- case Perspectives.ThirdPerson:
1853
- return this.createThirdPersonCamera(aspectRatio);
1854
- case Perspectives.FirstPerson:
1855
- return this.createFirstPersonCamera(aspectRatio);
1856
- case Perspectives.Isometric:
1857
- return this.createIsometricCamera(aspectRatio);
1858
- case Perspectives.Flat2D:
1859
- return this.createFlat2DCamera(aspectRatio);
1860
- case Perspectives.Fixed2D:
1861
- return this.createFixed2DCamera(aspectRatio);
1862
- default:
1863
- return this.createThirdPersonCamera(aspectRatio);
4407
+ async setup(scene, rendererManager) {
4408
+ this._sceneRef = scene;
4409
+ this._rendererManager = rendererManager;
4410
+ this.createDebugCamera(rendererManager.screenResolution);
4411
+ for (const camera of this.cameras.values()) {
4412
+ camera.setRendererManager(rendererManager);
4413
+ await camera.setup(scene, rendererManager);
4414
+ }
4415
+ if (this._debugCamera) {
4416
+ this._debugCamera.setRendererManager(rendererManager);
4417
+ await this._debugCamera.setup(scene, rendererManager);
1864
4418
  }
1865
4419
  }
1866
4420
  /**
1867
- * Initialize perspective-specific controller
4421
+ * Update all active cameras' controllers.
1868
4422
  */
1869
- initializePerspectiveController() {
1870
- switch (this._perspective) {
1871
- case Perspectives.ThirdPerson:
1872
- this.perspectiveController = new ThirdPersonCamera();
1873
- break;
1874
- case Perspectives.Fixed2D:
1875
- this.perspectiveController = new Fixed2DCamera();
1876
- break;
1877
- default:
1878
- this.perspectiveController = new ThirdPersonCamera();
4423
+ update(delta) {
4424
+ for (const camera of this._activeCameras) {
4425
+ camera.update(delta);
1879
4426
  }
1880
4427
  }
1881
- createThirdPersonCamera(aspectRatio) {
1882
- return new PerspectiveCamera(75, aspectRatio, 0.1, 1e3);
1883
- }
1884
- createFirstPersonCamera(aspectRatio) {
1885
- return new PerspectiveCamera(75, aspectRatio, 0.1, 1e3);
1886
- }
1887
- createIsometricCamera(aspectRatio) {
1888
- return new OrthographicCamera(
1889
- this.frustumSize * aspectRatio / -2,
1890
- this.frustumSize * aspectRatio / 2,
1891
- this.frustumSize / 2,
1892
- this.frustumSize / -2,
1893
- 1,
1894
- 1e3
1895
- );
1896
- }
1897
- createFlat2DCamera(aspectRatio) {
1898
- return new OrthographicCamera(
1899
- this.frustumSize * aspectRatio / -2,
1900
- this.frustumSize * aspectRatio / 2,
1901
- this.frustumSize / 2,
1902
- this.frustumSize / -2,
1903
- 1,
1904
- 1e3
1905
- );
1906
- }
1907
- createFixed2DCamera(aspectRatio) {
1908
- return this.createFlat2DCamera(aspectRatio);
4428
+ /**
4429
+ * Render all active cameras through the renderer manager.
4430
+ */
4431
+ render(scene) {
4432
+ if (!this._rendererManager || this._activeCameras.length === 0) return;
4433
+ this._rendererManager.renderCameras(scene, this._activeCameras);
1909
4434
  }
1910
- // Movement methods
1911
- moveCamera(position2) {
1912
- if (this._perspective === Perspectives.Flat2D || this._perspective === Perspectives.Fixed2D) {
1913
- this.frustumSize = position2.z;
1914
- }
1915
- if (this.cameraRig) {
1916
- this.cameraRig.position.set(position2.x, position2.y, position2.z);
1917
- } else {
1918
- this.camera.position.set(position2.x, position2.y, position2.z);
4435
+ /**
4436
+ * Create a default third-person camera if no cameras have been added.
4437
+ */
4438
+ ensureDefaultCamera() {
4439
+ if (this.cameras.size === 0 || this._activeCameras.length === 0) {
4440
+ const screenRes = this._rendererManager?.screenResolution || new Vector27(window.innerWidth, window.innerHeight);
4441
+ const defaultCam = new ZylemCamera(Perspectives.ThirdPerson, screenRes);
4442
+ this.addCamera(defaultCam, "default");
4443
+ return defaultCam;
1919
4444
  }
4445
+ return this._activeCameras[0];
1920
4446
  }
1921
- move(position2) {
1922
- this.moveCamera(position2);
1923
- }
1924
- rotate(pitch, yaw, roll) {
1925
- if (this.cameraRig) {
1926
- this.cameraRig.rotateX(pitch);
1927
- this.cameraRig.rotateY(yaw);
1928
- this.cameraRig.rotateZ(roll);
1929
- } else {
1930
- this.camera.rotateX(pitch);
1931
- this.camera.rotateY(yaw);
1932
- this.camera.rotateZ(roll);
4447
+ /**
4448
+ * Dispose all cameras and cleanup.
4449
+ */
4450
+ dispose() {
4451
+ for (const camera of this.cameras.values()) {
4452
+ camera.destroy();
1933
4453
  }
4454
+ this._debugCamera?.destroy();
4455
+ this.cameras.clear();
4456
+ this._activeCameras = [];
4457
+ this._debugCamera = null;
4458
+ this._rendererManager = null;
4459
+ this._sceneRef = null;
1934
4460
  }
1935
4461
  /**
1936
- * Check if this perspective type needs a camera rig
4462
+ * Create the always-available debug camera with orbit controls.
1937
4463
  */
1938
- needsRig() {
1939
- return this._perspective === Perspectives.ThirdPerson;
4464
+ createDebugCamera(screenResolution) {
4465
+ this._debugCamera = new ZylemCamera(Perspectives.ThirdPerson, screenResolution);
4466
+ this._debugCamera.name = "__debug__";
4467
+ this._debugCamera.enableOrbitalControls();
1940
4468
  }
1941
4469
  /**
1942
- * Get the DOM element for the renderer
4470
+ * Resolve a camera from a name or reference.
1943
4471
  */
1944
- getDomElement() {
1945
- return this.renderer.domElement;
4472
+ resolveCamera(nameOrRef) {
4473
+ if (typeof nameOrRef === "string") {
4474
+ return this.cameras.get(nameOrRef) ?? null;
4475
+ }
4476
+ for (const cam of this.cameras.values()) {
4477
+ if (cam === nameOrRef) return cam;
4478
+ }
4479
+ return null;
1946
4480
  }
1947
4481
  };
1948
4482
 
@@ -1958,7 +4492,7 @@ var StageCameraDelegate = class {
1958
4492
  createDefaultCamera() {
1959
4493
  const width = window.innerWidth;
1960
4494
  const height = window.innerHeight;
1961
- const screenResolution = new Vector24(width, height);
4495
+ const screenResolution = new Vector28(width, height);
1962
4496
  return new ZylemCamera(Perspectives.ThirdPerson, screenResolution);
1963
4497
  }
1964
4498
  /**
@@ -1978,6 +4512,32 @@ var StageCameraDelegate = class {
1978
4512
  }
1979
4513
  return this.createDefaultCamera();
1980
4514
  }
4515
+ /**
4516
+ * Build a CameraManager from stage options.
4517
+ * Supports single camera (backward compatible) and multiple cameras.
4518
+ *
4519
+ * @param cameraOverride Optional camera override from game-level config
4520
+ * @param cameraWrappers Camera wrappers from stage options (can be single or array)
4521
+ * @returns A CameraManager populated with the resolved cameras
4522
+ */
4523
+ buildCameraManager(cameraOverride, ...cameraWrappers) {
4524
+ const manager = new CameraManager();
4525
+ if (cameraOverride) {
4526
+ manager.addCamera(cameraOverride, cameraOverride.name || "main");
4527
+ return manager;
4528
+ }
4529
+ const validWrappers = cameraWrappers.filter((w) => w !== void 0);
4530
+ if (validWrappers.length > 0) {
4531
+ for (const wrapper of validWrappers) {
4532
+ const cam = wrapper.cameraRef;
4533
+ manager.addCamera(cam, cam.name || void 0);
4534
+ }
4535
+ } else {
4536
+ const defaultCam = this.createDefaultCamera();
4537
+ manager.addCamera(defaultCam, "default");
4538
+ }
4539
+ return manager;
4540
+ }
1981
4541
  };
1982
4542
 
1983
4543
  // src/lib/stage/stage-loading-delegate.ts
@@ -2057,8 +4617,61 @@ var StageLoadingDelegate = class {
2057
4617
  }
2058
4618
  };
2059
4619
 
4620
+ // src/lib/stage/stage-entity-model-delegate.ts
4621
+ var StageEntityModelDelegate = class {
4622
+ scene = null;
4623
+ pendingEntities = /* @__PURE__ */ new Map();
4624
+ modelLoadedHandler = null;
4625
+ /**
4626
+ * Initialize the delegate with the scene reference and start listening.
4627
+ */
4628
+ attach(scene) {
4629
+ this.scene = scene;
4630
+ this.modelLoadedHandler = (payload) => {
4631
+ this.handleModelLoaded(payload.entityId, payload.success);
4632
+ };
4633
+ zylemEventBus.on("entity:model:loaded", this.modelLoadedHandler);
4634
+ }
4635
+ /**
4636
+ * Register an entity for observation.
4637
+ * When its model loads, the group will be added to the scene.
4638
+ */
4639
+ observe(entity) {
4640
+ this.pendingEntities.set(entity.uuid, entity);
4641
+ }
4642
+ /**
4643
+ * Unregister an entity (e.g., when removed before model loads).
4644
+ */
4645
+ unobserve(entityId) {
4646
+ this.pendingEntities.delete(entityId);
4647
+ }
4648
+ /**
4649
+ * Handle model loaded event - add group to scene if entity is pending.
4650
+ */
4651
+ handleModelLoaded(entityId, success) {
4652
+ const entity = this.pendingEntities.get(entityId);
4653
+ if (!entity || !success) {
4654
+ this.pendingEntities.delete(entityId);
4655
+ return;
4656
+ }
4657
+ this.scene?.addEntityGroup(entity);
4658
+ this.pendingEntities.delete(entityId);
4659
+ }
4660
+ /**
4661
+ * Cleanup all subscriptions and pending entities.
4662
+ */
4663
+ dispose() {
4664
+ if (this.modelLoadedHandler) {
4665
+ zylemEventBus.off("entity:model:loaded", this.modelLoadedHandler);
4666
+ this.modelLoadedHandler = null;
4667
+ }
4668
+ this.pendingEntities.clear();
4669
+ this.scene = null;
4670
+ }
4671
+ };
4672
+
2060
4673
  // src/lib/stage/stage-config.ts
2061
- import { Vector3 as Vector310 } from "three";
4674
+ import { Vector3 as Vector317 } from "three";
2062
4675
 
2063
4676
  // src/lib/core/utility/options-parser.ts
2064
4677
  function isBaseNode(item) {
@@ -2088,10 +4701,11 @@ function isEntityInput(item) {
2088
4701
 
2089
4702
  // src/lib/stage/stage-config.ts
2090
4703
  var StageConfig = class {
2091
- constructor(inputs, backgroundColor, backgroundImage, gravity, variables) {
4704
+ constructor(inputs, backgroundColor, backgroundImage, backgroundShader, gravity, variables) {
2092
4705
  this.inputs = inputs;
2093
4706
  this.backgroundColor = backgroundColor;
2094
4707
  this.backgroundImage = backgroundImage;
4708
+ this.backgroundShader = backgroundShader;
2095
4709
  this.gravity = gravity;
2096
4710
  this.variables = variables;
2097
4711
  }
@@ -2104,7 +4718,8 @@ function createDefaultStageConfig() {
2104
4718
  },
2105
4719
  ZylemBlueColor,
2106
4720
  null,
2107
- new Vector310(0, 0, 0),
4721
+ null,
4722
+ new Vector317(0, 0, 0),
2108
4723
  {}
2109
4724
  );
2110
4725
  }
@@ -2113,10 +4728,10 @@ function parseStageOptions(options = []) {
2113
4728
  let config = {};
2114
4729
  const entities = [];
2115
4730
  const asyncEntities = [];
2116
- let camera;
4731
+ const cameras = [];
2117
4732
  for (const item of options) {
2118
4733
  if (isCameraWrapper(item)) {
2119
- camera = item;
4734
+ cameras.push(item);
2120
4735
  } else if (isBaseNode(item)) {
2121
4736
  entities.push(item);
2122
4737
  } else if (isEntityInput(item) && !isBaseNode(item)) {
@@ -2129,30 +4744,78 @@ function parseStageOptions(options = []) {
2129
4744
  config.inputs ?? defaults.inputs,
2130
4745
  config.backgroundColor ?? defaults.backgroundColor,
2131
4746
  config.backgroundImage ?? defaults.backgroundImage,
4747
+ config.backgroundShader ?? defaults.backgroundShader,
2132
4748
  config.gravity ?? defaults.gravity,
2133
4749
  config.variables ?? defaults.variables
2134
4750
  );
2135
- return { config: resolvedConfig, entities, asyncEntities, camera };
4751
+ const camera = cameras.length > 0 ? cameras[0] : void 0;
4752
+ return { config: resolvedConfig, entities, asyncEntities, camera, cameras };
2136
4753
  }
2137
4754
 
4755
+ // src/lib/actions/capabilities/apply-transform.ts
4756
+ function applyTransformChanges(entity, store) {
4757
+ if (!entity.body) return;
4758
+ if (store.dirty.velocity) {
4759
+ entity.body.setLinvel(store.velocity, true);
4760
+ }
4761
+ if (store.dirty.rotation) {
4762
+ entity.body.setRotation(store.rotation, true);
4763
+ }
4764
+ if (store.dirty.angularVelocity) {
4765
+ entity.body.setAngvel(store.angularVelocity, true);
4766
+ }
4767
+ if (store.dirty.position) {
4768
+ const current = entity.body.translation();
4769
+ entity.body.setTranslation(
4770
+ {
4771
+ x: current.x + store.position.x,
4772
+ y: current.y + store.position.y,
4773
+ z: current.z + store.position.z
4774
+ },
4775
+ true
4776
+ );
4777
+ }
4778
+ }
4779
+
4780
+ // src/lib/core/vessel.ts
4781
+ var VESSEL_TYPE = /* @__PURE__ */ Symbol("vessel");
4782
+ var Vessel = class extends BaseNode {
4783
+ static type = VESSEL_TYPE;
4784
+ _setup(_params) {
4785
+ }
4786
+ async _loaded(_params) {
4787
+ }
4788
+ _update(_params) {
4789
+ }
4790
+ _destroy(_params) {
4791
+ }
4792
+ _cleanup(_params) {
4793
+ }
4794
+ create() {
4795
+ return this;
4796
+ }
4797
+ /**
4798
+ * Add one or more child entities to this vessel.
4799
+ * Overrides parent to support multiple arguments.
4800
+ * @returns this for chaining
4801
+ */
4802
+ add(...nodes) {
4803
+ for (const node of nodes) {
4804
+ super.add(node);
4805
+ }
4806
+ return this;
4807
+ }
4808
+ };
4809
+
2138
4810
  // src/lib/stage/zylem-stage.ts
2139
4811
  var STAGE_TYPE = "Stage";
2140
4812
  var ZylemStage = class extends LifeCycleBase {
2141
4813
  type = STAGE_TYPE;
2142
- state = {
2143
- backgroundColor: ZylemBlueColor,
2144
- backgroundImage: null,
2145
- inputs: {
2146
- p1: ["gamepad-1", "keyboard"],
2147
- p2: ["gamepad-2", "keyboard"]
2148
- },
2149
- gravity: new Vector311(0, 0, 0),
2150
- variables: {},
2151
- entities: []
2152
- };
4814
+ state = { ...initialStageState };
2153
4815
  gravity;
2154
4816
  world;
2155
4817
  scene;
4818
+ instanceManager = null;
2156
4819
  children = [];
2157
4820
  _childrenMap = /* @__PURE__ */ new Map();
2158
4821
  _removalMap = /* @__PURE__ */ new Map();
@@ -2164,16 +4827,24 @@ var ZylemStage = class extends LifeCycleBase {
2164
4827
  ecs = createECS();
2165
4828
  testSystem = null;
2166
4829
  transformSystem = null;
4830
+ behaviorSystems = [];
4831
+ registeredSystemKeys = /* @__PURE__ */ new Set();
2167
4832
  debugDelegate = null;
2168
4833
  cameraDebugDelegate = null;
2169
4834
  debugStateUnsubscribe = null;
2170
4835
  uuid;
2171
4836
  wrapperRef = null;
2172
4837
  camera;
4838
+ cameras = [];
2173
4839
  cameraRef = null;
4840
+ /** Camera manager for multi-camera support */
4841
+ cameraManagerRef = null;
4842
+ /** Shared renderer manager (injected by the game) */
4843
+ rendererManager = null;
2174
4844
  // Delegates
2175
4845
  cameraDelegate;
2176
4846
  loadingDelegate;
4847
+ entityModelDelegate;
2177
4848
  /**
2178
4849
  * Create a new stage.
2179
4850
  * @param options Stage options: partial config, camera, and initial entities or factories
@@ -2185,8 +4856,10 @@ var ZylemStage = class extends LifeCycleBase {
2185
4856
  this.uuid = nanoid2();
2186
4857
  this.cameraDelegate = new StageCameraDelegate(this);
2187
4858
  this.loadingDelegate = new StageLoadingDelegate();
4859
+ this.entityModelDelegate = new StageEntityModelDelegate();
2188
4860
  const parsed = parseStageOptions(options);
2189
4861
  this.camera = parsed.camera;
4862
+ this.cameras = parsed.cameras;
2190
4863
  this.children = parsed.entities;
2191
4864
  this.pendingEntities = parsed.asyncEntities;
2192
4865
  this.saveState({
@@ -2194,11 +4867,12 @@ var ZylemStage = class extends LifeCycleBase {
2194
4867
  inputs: parsed.config.inputs,
2195
4868
  backgroundColor: parsed.config.backgroundColor,
2196
4869
  backgroundImage: parsed.config.backgroundImage,
4870
+ backgroundShader: parsed.config.backgroundShader,
2197
4871
  gravity: parsed.config.gravity,
2198
4872
  variables: parsed.config.variables,
2199
4873
  entities: []
2200
4874
  });
2201
- this.gravity = parsed.config.gravity ?? new Vector311(0, 0, 0);
4875
+ this.gravity = parsed.config.gravity ?? new Vector318(0, 0, 0);
2202
4876
  }
2203
4877
  handleEntityImmediatelyOrQueue(entity) {
2204
4878
  if (this.isLoaded) {
@@ -2219,7 +4893,7 @@ var ZylemStage = class extends LifeCycleBase {
2219
4893
  }
2220
4894
  setState() {
2221
4895
  const { backgroundColor, backgroundImage } = this.state;
2222
- const color = backgroundColor instanceof Color7 ? backgroundColor : new Color7(backgroundColor);
4896
+ const color = backgroundColor instanceof Color9 ? backgroundColor : new Color9(backgroundColor);
2223
4897
  setStageBackgroundColor(color);
2224
4898
  setStageBackgroundImage(backgroundImage);
2225
4899
  setStageVariables(this.state.variables ?? {});
@@ -2230,14 +4904,39 @@ var ZylemStage = class extends LifeCycleBase {
2230
4904
  * @param id DOM element id for the renderer container
2231
4905
  * @param camera Optional camera override
2232
4906
  */
2233
- async load(id, camera) {
4907
+ async load(id, camera, rendererManager) {
2234
4908
  this.setState();
2235
- const zylemCamera = this.cameraDelegate.resolveCamera(camera, this.camera);
4909
+ if (rendererManager) {
4910
+ this.rendererManager = rendererManager;
4911
+ }
4912
+ if (this.rendererManager) {
4913
+ this.cameraManagerRef = this.cameraDelegate.buildCameraManager(
4914
+ camera,
4915
+ ...this.cameras
4916
+ );
4917
+ }
4918
+ const zylemCamera = this.cameraManagerRef?.primaryCamera ?? this.cameraDelegate.resolveCamera(camera, this.camera);
2236
4919
  this.cameraRef = zylemCamera;
2237
4920
  this.scene = new ZylemScene(id, zylemCamera, this.state);
2238
- const physicsWorld = await ZylemWorld.loadPhysics(this.gravity ?? new Vector311(0, 0, 0));
4921
+ const physicsWorld = await ZylemWorld.loadPhysics(this.gravity ?? new Vector318(0, 0, 0));
2239
4922
  this.world = new ZylemWorld(physicsWorld);
2240
4923
  this.scene.setup();
4924
+ if (this.cameraManagerRef && this.rendererManager) {
4925
+ await this.scene.setupCameraManager(this.scene.scene, this.cameraManagerRef, this.rendererManager);
4926
+ const primaryCam = this.cameraManagerRef.primaryCamera;
4927
+ if (primaryCam) {
4928
+ this.rendererManager.setupRenderPass(this.scene.scene, primaryCam.camera);
4929
+ }
4930
+ this.rendererManager.startRenderLoop((delta) => {
4931
+ this.cameraManagerRef?.update(delta);
4932
+ this.cameraManagerRef?.render(this.scene.scene);
4933
+ });
4934
+ } else {
4935
+ this.scene.setupCamera(this.scene.scene, zylemCamera);
4936
+ }
4937
+ this.entityModelDelegate.attach(this.scene);
4938
+ this.instanceManager = new InstanceManager();
4939
+ this.instanceManager.setScene(this.scene.scene);
2241
4940
  this.loadingDelegate.emitStart();
2242
4941
  await this.runEntityLoadGenerator();
2243
4942
  this.transformSystem = createTransformSystem(this);
@@ -2276,11 +4975,11 @@ var ZylemStage = class extends LifeCycleBase {
2276
4975
  * Runs the entity load generator, yielding to the event loop between loads.
2277
4976
  * This allows the browser to process events and update the UI in real-time.
2278
4977
  */
2279
- async runEntityLoadGenerator() {
4978
+ runEntityLoadGenerator() {
2280
4979
  const gen = this.entityLoadGenerator();
2281
4980
  for (const progress of gen) {
2282
4981
  this.loadingDelegate.emitProgress(`Loaded ${progress.name}`, progress.current, progress.total);
2283
- await new Promise((resolve) => setTimeout(resolve, 0));
4982
+ new Promise((resolve) => setTimeout(resolve, 0));
2284
4983
  }
2285
4984
  }
2286
4985
  _setup(params) {
@@ -2316,17 +5015,25 @@ var ZylemStage = class extends LifeCycleBase {
2316
5015
  return;
2317
5016
  }
2318
5017
  this.world.update(params);
2319
- this.transformSystem?.system(this.ecs);
5018
+ for (const system of this.behaviorSystems) {
5019
+ system.update(this.ecs, delta);
5020
+ }
2320
5021
  this._childrenMap.forEach((child, eid) => {
2321
5022
  child.nodeUpdate({
2322
5023
  ...params,
2323
5024
  me: child
2324
5025
  });
5026
+ if (child.transformStore) {
5027
+ applyTransformChanges(child, child.transformStore);
5028
+ }
2325
5029
  if (child.markedForRemoval) {
2326
5030
  this.removeEntityByUuid(child.uuid);
2327
5031
  }
2328
5032
  });
5033
+ this.transformSystem?.system(this.ecs);
5034
+ this.instanceManager?.update();
2329
5035
  this.scene.update({ delta });
5036
+ this.scene.updateSkybox(delta);
2330
5037
  }
2331
5038
  outOfLoop() {
2332
5039
  this.debugUpdate();
@@ -2339,11 +5046,17 @@ var ZylemStage = class extends LifeCycleBase {
2339
5046
  }
2340
5047
  /** Cleanup owned resources when the stage is destroyed. */
2341
5048
  _destroy(params) {
5049
+ for (const system of this.behaviorSystems) {
5050
+ system.destroy?.(this.ecs);
5051
+ }
5052
+ this.behaviorSystems = [];
5053
+ this.registeredSystemKeys.clear();
2342
5054
  this._childrenMap.forEach((child) => {
2343
5055
  try {
2344
5056
  child.nodeDestroy({ me: child, globals: getGlobals() });
2345
5057
  } catch {
2346
5058
  }
5059
+ clearVariables(child);
2347
5060
  });
2348
5061
  this._childrenMap.clear();
2349
5062
  this._removalMap.clear();
@@ -2358,10 +5071,17 @@ var ZylemStage = class extends LifeCycleBase {
2358
5071
  this.debugDelegate = null;
2359
5072
  this.cameraRef?.setDebugDelegate(null);
2360
5073
  this.cameraDebugDelegate = null;
5074
+ this.entityModelDelegate.dispose();
5075
+ this.instanceManager?.dispose();
5076
+ this.instanceManager = null;
5077
+ if (this.rendererManager) {
5078
+ this.rendererManager.stopRenderLoop();
5079
+ }
2361
5080
  this.isLoaded = false;
2362
5081
  this.world = null;
2363
5082
  this.scene = null;
2364
5083
  this.cameraRef = null;
5084
+ this.cameraManagerRef = null;
2365
5085
  this.transformSystem?.destroy(this.ecs);
2366
5086
  this.transformSystem = null;
2367
5087
  resetStageVariables();
@@ -2375,28 +5095,84 @@ var ZylemStage = class extends LifeCycleBase {
2375
5095
  if (!this.scene || !this.world) {
2376
5096
  return;
2377
5097
  }
5098
+ if (child instanceof Vessel) {
5099
+ child.create();
5100
+ for (const childEntity of child.getChildren()) {
5101
+ if (childEntity instanceof BaseNode) {
5102
+ await this.spawnEntity(childEntity);
5103
+ }
5104
+ }
5105
+ const vesselEid = addEntity(this.ecs);
5106
+ child.eid = vesselEid;
5107
+ child.nodeSetup({
5108
+ me: child,
5109
+ globals: getGlobals(),
5110
+ camera: this.scene.zylemCamera
5111
+ });
5112
+ this.addEntityToStage(child);
5113
+ return;
5114
+ }
2378
5115
  const entity = child.create();
2379
5116
  const eid = addEntity(this.ecs);
2380
5117
  entity.eid = eid;
2381
5118
  this.scene.addEntity(entity);
2382
- if (child.behaviors) {
2383
- for (let behavior of child.behaviors) {
2384
- addComponent(this.ecs, behavior.component, entity.eid);
2385
- const keys = Object.keys(behavior.values);
2386
- for (const key of keys) {
2387
- behavior.component[key][entity.eid] = behavior.values[key];
5119
+ if (typeof entity.getBehaviorRefs === "function") {
5120
+ for (const ref of entity.getBehaviorRefs()) {
5121
+ const key = ref.descriptor.key;
5122
+ if (!this.registeredSystemKeys.has(key)) {
5123
+ const system = ref.descriptor.systemFactory({ world: this.world, ecs: this.ecs, scene: this.scene });
5124
+ this.behaviorSystems.push(system);
5125
+ this.registeredSystemKeys.add(key);
2388
5126
  }
2389
5127
  }
2390
5128
  }
2391
5129
  if (entity.colliderDesc) {
2392
5130
  this.world.addEntity(entity);
2393
5131
  }
5132
+ for (const childNode of child.getChildren()) {
5133
+ if (childNode instanceof BaseNode) {
5134
+ await this.spawnEntity(childNode);
5135
+ }
5136
+ }
2394
5137
  child.nodeSetup({
2395
5138
  me: child,
2396
5139
  globals: getGlobals(),
2397
5140
  camera: this.scene.zylemCamera
2398
5141
  });
5142
+ this.tryRegisterInstance(entity);
2399
5143
  this.addEntityToStage(entity);
5144
+ this.entityModelDelegate.observe(entity);
5145
+ }
5146
+ /**
5147
+ * Try to register an entity for instanced rendering.
5148
+ * Batching is enabled by default unless explicitly disabled with batched: false.
5149
+ */
5150
+ tryRegisterInstance(entity) {
5151
+ if (!this.instanceManager) return;
5152
+ const options = entity.options;
5153
+ if (options?.batched !== true) return;
5154
+ if (!entity.mesh?.geometry || !entity.materials?.length) return;
5155
+ const geometry = entity.mesh.geometry;
5156
+ const material = entity.materials[0];
5157
+ const entityType = entity.constructor.type?.description || "unknown";
5158
+ const size = options.size || { x: 1, y: 1, z: 1 };
5159
+ const matOptions = options.material || {};
5160
+ const batchKey = InstanceManager.generateBatchKey({
5161
+ geometryType: entityType,
5162
+ dimensions: { x: size.x, y: size.y, z: size.z },
5163
+ materialPath: matOptions.path || null,
5164
+ shaderType: matOptions.shader ? "custom" : "standard",
5165
+ colorHex: matOptions.color?.getHex?.() || 16777215
5166
+ });
5167
+ const instanceId = this.instanceManager.register(entity, geometry, material, batchKey);
5168
+ if (instanceId >= 0) {
5169
+ entity.batchKey = batchKey;
5170
+ entity.instanceId = instanceId;
5171
+ entity.isInstanced = true;
5172
+ if (entity.mesh) {
5173
+ entity.mesh.visible = false;
5174
+ }
5175
+ }
2400
5176
  }
2401
5177
  buildEntityState(child) {
2402
5178
  if (child instanceof GameEntity) {
@@ -2446,6 +5222,21 @@ var ZylemStage = class extends LifeCycleBase {
2446
5222
  onLoading(callback) {
2447
5223
  return this.loadingDelegate.onLoading(callback);
2448
5224
  }
5225
+ /**
5226
+ * Register an ECS behavior system to run each frame.
5227
+ * @param systemOrFactory A BehaviorSystem instance or factory function
5228
+ * @returns this for chaining
5229
+ */
5230
+ registerSystem(systemOrFactory) {
5231
+ let system;
5232
+ if (typeof systemOrFactory === "function") {
5233
+ system = systemOrFactory({ world: this.world, ecs: this.ecs, scene: this.scene });
5234
+ } else {
5235
+ system = systemOrFactory;
5236
+ }
5237
+ this.behaviorSystems.push(system);
5238
+ return this;
5239
+ }
2449
5240
  /**
2450
5241
  * Remove an entity and its resources by its UUID.
2451
5242
  * @returns true if removed, false if not found or stage not ready
@@ -2455,6 +5246,10 @@ var ZylemStage = class extends LifeCycleBase {
2455
5246
  const mapEntity = this.world.collisionMap.get(uuid);
2456
5247
  const entity = mapEntity ?? this._debugMap.get(uuid);
2457
5248
  if (!entity) return false;
5249
+ this.entityModelDelegate.unobserve(uuid);
5250
+ if (entity.isInstanced && this.instanceManager) {
5251
+ this.instanceManager.unregister(entity);
5252
+ }
2458
5253
  this.world.destroyEntity(entity);
2459
5254
  if (entity.group) {
2460
5255
  this.scene.scene.remove(entity.group);
@@ -2486,6 +5281,35 @@ var ZylemStage = class extends LifeCycleBase {
2486
5281
  logMissingEntities() {
2487
5282
  console.warn("Zylem world or scene is null");
2488
5283
  }
5284
+ // ─── Camera management forwarding ─────────────────────────────────────
5285
+ /**
5286
+ * Add a camera to this stage's camera manager.
5287
+ */
5288
+ addCamera(camera, name) {
5289
+ if (!this.cameraManagerRef) {
5290
+ console.warn("ZylemStage: CameraManager not available. Ensure stage is loaded with a RendererManager.");
5291
+ return null;
5292
+ }
5293
+ return this.cameraManagerRef.addCamera(camera, name);
5294
+ }
5295
+ /**
5296
+ * Remove a camera from this stage's camera manager.
5297
+ */
5298
+ removeCamera(nameOrRef) {
5299
+ return this.cameraManagerRef?.removeCamera(nameOrRef) ?? false;
5300
+ }
5301
+ /**
5302
+ * Set the active camera by name or reference.
5303
+ */
5304
+ setActiveCamera(nameOrRef) {
5305
+ return this.cameraManagerRef?.setActiveCamera(nameOrRef) ?? false;
5306
+ }
5307
+ /**
5308
+ * Get a camera by name from the camera manager.
5309
+ */
5310
+ getCamera(name) {
5311
+ return this.cameraManagerRef?.getCamera(name) ?? null;
5312
+ }
2489
5313
  /** Resize renderer viewport. */
2490
5314
  resize(width, height) {
2491
5315
  if (this.scene) {
@@ -2526,17 +5350,62 @@ var ZylemStage = class extends LifeCycleBase {
2526
5350
  };
2527
5351
 
2528
5352
  // src/lib/camera/camera.ts
2529
- import { Vector2 as Vector26, Vector3 as Vector312 } from "three";
5353
+ import { Vector2 as Vector210, Vector3 as Vector319 } from "three";
2530
5354
  var CameraWrapper = class {
2531
5355
  cameraRef;
2532
5356
  constructor(camera) {
2533
5357
  this.cameraRef = camera;
2534
5358
  }
5359
+ // ─── Target management ──────────────────────────────────────────────────
5360
+ /**
5361
+ * Add a target entity for the camera to follow/frame.
5362
+ * With multiple targets, the camera auto-frames to include all of them.
5363
+ */
5364
+ addTarget(entity) {
5365
+ this.cameraRef.addTarget(entity);
5366
+ }
5367
+ /**
5368
+ * Remove a target entity from the camera.
5369
+ */
5370
+ removeTarget(entity) {
5371
+ this.cameraRef.removeTarget(entity);
5372
+ }
5373
+ /**
5374
+ * Clear all targets. Camera will look at world origin.
5375
+ */
5376
+ clearTargets() {
5377
+ this.cameraRef.clearTargets();
5378
+ }
5379
+ // ─── Orbital controls ───────────────────────────────────────────────────
5380
+ /**
5381
+ * Enable orbital controls for this camera.
5382
+ * Allows the user to orbit, pan, and zoom the camera.
5383
+ */
5384
+ enableOrbitalControls() {
5385
+ this.cameraRef.enableOrbitalControls();
5386
+ }
5387
+ /**
5388
+ * Disable orbital controls for this camera.
5389
+ */
5390
+ disableOrbitalControls() {
5391
+ this.cameraRef.disableOrbitalControls();
5392
+ }
5393
+ // ─── Viewport ───────────────────────────────────────────────────────────
5394
+ /**
5395
+ * Set the viewport for this camera (normalized 0-1 coordinates).
5396
+ * @param x Left edge (0 = left of canvas)
5397
+ * @param y Bottom edge (0 = bottom of canvas)
5398
+ * @param width Width as fraction of canvas
5399
+ * @param height Height as fraction of canvas
5400
+ */
5401
+ setViewport(x, y, width, height) {
5402
+ this.cameraRef.setViewport(x, y, width, height);
5403
+ }
2535
5404
  };
2536
5405
 
2537
5406
  // src/lib/stage/stage-default.ts
2538
- import { proxy as proxy4 } from "valtio/vanilla";
2539
- import { Vector3 as Vector313 } from "three";
5407
+ import { proxy as proxy5 } from "valtio/vanilla";
5408
+ import { Vector3 as Vector320 } from "three";
2540
5409
  var initialDefaults = {
2541
5410
  backgroundColor: ZylemBlueColor,
2542
5411
  backgroundImage: null,
@@ -2544,10 +5413,10 @@ var initialDefaults = {
2544
5413
  p1: ["gamepad-1", "keyboard"],
2545
5414
  p2: ["gamepad-2", "keyboard"]
2546
5415
  },
2547
- gravity: new Vector313(0, 0, 0),
5416
+ gravity: new Vector320(0, 0, 0),
2548
5417
  variables: {}
2549
5418
  };
2550
- var stageDefaultsState = proxy4({
5419
+ var stageDefaultsState = proxy5({
2551
5420
  ...initialDefaults
2552
5421
  });
2553
5422
  function getStageOptions(options) {
@@ -2569,6 +5438,25 @@ function getStageDefaultConfig() {
2569
5438
  };
2570
5439
  }
2571
5440
 
5441
+ // src/lib/input/input-presets.ts
5442
+ var INPUT_PLAYERS = ["p1", "p2", "p3", "p4", "p5", "p6", "p7", "p8"];
5443
+ function mergeInputConfigs(...configs) {
5444
+ const result = {};
5445
+ for (const config of configs) {
5446
+ for (const player of INPUT_PLAYERS) {
5447
+ const source = config[player];
5448
+ if (!source) continue;
5449
+ const target = result[player] ?? {};
5450
+ result[player] = {
5451
+ key: { ...target.key, ...source.key },
5452
+ mouse: { ...target.mouse, ...source.mouse },
5453
+ includeDefaults: source.includeDefaults ?? target.includeDefaults
5454
+ };
5455
+ }
5456
+ }
5457
+ return result;
5458
+ }
5459
+
2572
5460
  // src/lib/stage/stage.ts
2573
5461
  var Stage = class {
2574
5462
  wrappedStage;
@@ -2580,11 +5468,34 @@ var Stage = class {
2580
5468
  updateCallbacks = [];
2581
5469
  destroyCallbacks = [];
2582
5470
  pendingLoadingCallbacks = [];
5471
+ // Event delegate for dispatch/listen API
5472
+ eventDelegate = new EventEmitterDelegate();
5473
+ /** Per-stage input configuration overrides. Merged with game-level defaults on stage load. */
5474
+ inputConfig = null;
5475
+ /**
5476
+ * Callback set by the game to trigger input reconfiguration
5477
+ * when this stage's input config changes at runtime.
5478
+ * @internal
5479
+ */
5480
+ onInputConfigChanged = null;
2583
5481
  constructor(options) {
2584
5482
  this.options = options;
2585
5483
  this.wrappedStage = null;
2586
5484
  }
2587
- async load(id, camera) {
5485
+ /**
5486
+ * Set composable input configuration for this stage.
5487
+ * Multiple configs are deep-merged (later configs win on key conflicts).
5488
+ * If this stage is currently active, the change takes effect immediately.
5489
+ * @example stage.setInputConfiguration(useArrowsForAxes('p1'), useWASDForDirections('p2'));
5490
+ */
5491
+ setInputConfiguration(...configs) {
5492
+ this.inputConfig = mergeInputConfigs(...configs);
5493
+ if (this.onInputConfigChanged) {
5494
+ this.onInputConfigChanged();
5495
+ }
5496
+ return this;
5497
+ }
5498
+ async load(id, camera, rendererManager) {
2588
5499
  stageState.entities = [];
2589
5500
  const loadOptions = [...this.options, ...this._pendingEntities];
2590
5501
  this._pendingEntities = [];
@@ -2595,7 +5506,7 @@ var Stage = class {
2595
5506
  });
2596
5507
  this.pendingLoadingCallbacks = [];
2597
5508
  const zylemCamera = camera instanceof CameraWrapper ? camera.cameraRef : camera;
2598
- await this.wrappedStage.load(id, zylemCamera);
5509
+ await this.wrappedStage.load(id, zylemCamera, rendererManager);
2599
5510
  this.wrappedStage.onEntityAdded((child) => {
2600
5511
  const next = this.wrappedStage.buildEntityState(child);
2601
5512
  stageState.entities = [...stageState.entities, next];
@@ -2700,6 +5611,66 @@ var Stage = class {
2700
5611
  const entity = this.wrappedStage?.children.find((c) => c.name === name);
2701
5612
  return entity;
2702
5613
  }
5614
+ // ─────────────────────────────────────────────────────────────────────────────
5615
+ // Camera management
5616
+ // ─────────────────────────────────────────────────────────────────────────────
5617
+ /**
5618
+ * Add a camera to this stage.
5619
+ * @param camera The ZylemCamera or CameraWrapper to add
5620
+ * @param name Optional name for lookup
5621
+ */
5622
+ addCamera(camera, name) {
5623
+ const zylemCam = camera instanceof CameraWrapper ? camera.cameraRef : camera;
5624
+ return this.wrappedStage?.addCamera(zylemCam, name) ?? null;
5625
+ }
5626
+ /**
5627
+ * Remove a camera from this stage by name or reference.
5628
+ */
5629
+ removeCamera(nameOrRef) {
5630
+ if (nameOrRef instanceof CameraWrapper) {
5631
+ return this.wrappedStage?.removeCamera(nameOrRef.cameraRef) ?? false;
5632
+ }
5633
+ return this.wrappedStage?.removeCamera(nameOrRef) ?? false;
5634
+ }
5635
+ /**
5636
+ * Set the active camera by name or reference.
5637
+ */
5638
+ setActiveCamera(nameOrRef) {
5639
+ if (nameOrRef instanceof CameraWrapper) {
5640
+ return this.wrappedStage?.setActiveCamera(nameOrRef.cameraRef) ?? false;
5641
+ }
5642
+ return this.wrappedStage?.setActiveCamera(nameOrRef) ?? false;
5643
+ }
5644
+ /**
5645
+ * Get a camera by name from the camera manager.
5646
+ */
5647
+ getCamera(name) {
5648
+ return this.wrappedStage?.getCamera(name) ?? null;
5649
+ }
5650
+ // ─────────────────────────────────────────────────────────────────────────────
5651
+ // Events API
5652
+ // ─────────────────────────────────────────────────────────────────────────────
5653
+ /**
5654
+ * Dispatch an event from the stage.
5655
+ * Events are emitted both locally and to the global event bus.
5656
+ */
5657
+ dispatch(event, payload) {
5658
+ this.eventDelegate.dispatch(event, payload);
5659
+ zylemEventBus.emit(event, payload);
5660
+ }
5661
+ /**
5662
+ * Listen for events on this stage instance.
5663
+ * @returns Unsubscribe function
5664
+ */
5665
+ listen(event, handler) {
5666
+ return this.eventDelegate.listen(event, handler);
5667
+ }
5668
+ /**
5669
+ * Clean up stage resources including event subscriptions.
5670
+ */
5671
+ dispose() {
5672
+ this.eventDelegate.dispose();
5673
+ }
2703
5674
  };
2704
5675
  function createStage(...options) {
2705
5676
  const _options = getStageOptions(options);
@@ -2707,7 +5678,7 @@ function createStage(...options) {
2707
5678
  }
2708
5679
 
2709
5680
  // src/lib/stage/entity-spawner.ts
2710
- import { Euler, Quaternion as Quaternion3, Vector2 as Vector27 } from "three";
5681
+ import { Euler as Euler2, Quaternion as Quaternion5, Vector2 as Vector211 } from "three";
2711
5682
  function entitySpawner(factory) {
2712
5683
  return {
2713
5684
  spawn: async (stage, x, y) => {
@@ -2715,7 +5686,7 @@ function entitySpawner(factory) {
2715
5686
  stage.add(instance);
2716
5687
  return instance;
2717
5688
  },
2718
- spawnRelative: async (source, stage, offset = new Vector27(0, 1)) => {
5689
+ spawnRelative: async (source, stage, offset = new Vector211(0, 1)) => {
2719
5690
  if (!source.body) {
2720
5691
  console.warn("body missing for entity during spawnRelative");
2721
5692
  return void 0;
@@ -2724,8 +5695,8 @@ function entitySpawner(factory) {
2724
5695
  let rz = source._rotation2DAngle ?? 0;
2725
5696
  try {
2726
5697
  const r = source.body.rotation();
2727
- const q = new Quaternion3(r.x, r.y, r.z, r.w);
2728
- const e = new Euler().setFromQuaternion(q, "XYZ");
5698
+ const q = new Quaternion5(r.x, r.y, r.z, r.w);
5699
+ const e = new Euler2().setFromQuaternion(q, "XYZ");
2729
5700
  rz = e.z;
2730
5701
  } catch {
2731
5702
  }