@babylonjsmarket/arcade 0.3.11 → 0.3.12

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 (56) hide show
  1. package/dist/cli/eject.d.ts +24 -1
  2. package/dist/cli/eject.d.ts.map +1 -1
  3. package/dist/cli/eject.js +27 -22
  4. package/dist/cli/eject.js.map +1 -1
  5. package/package.json +3 -2
  6. package/src/Components/Animation/Animation.core.test.ts +11 -0
  7. package/src/Components/Animation/Animation.test.ts +26 -0
  8. package/src/Components/ArcCamera/ArcCamera.test.ts +168 -0
  9. package/src/Components/Bullet/Bullet.test.ts +223 -0
  10. package/src/Components/BulletPool/BulletPool.test.ts +50 -1
  11. package/src/Components/Bumper/Bumper.test.ts +18 -0
  12. package/src/Components/CameraFollow/CameraFollow.core.test.ts +19 -0
  13. package/src/Components/CameraFollow/CameraFollow.test.ts +92 -0
  14. package/src/Components/DirectionalLight/DirectionalLight.test.ts +48 -0
  15. package/src/Components/Enemy/Enemy.core.test.ts +20 -1
  16. package/src/Components/Enemy/Enemy.test.ts +456 -1
  17. package/src/Components/EnemySpawner/EnemySpawner.test.ts +155 -0
  18. package/src/Components/EntityPool/EntityPool.test.ts +95 -1
  19. package/src/Components/EnvironmentTexture/EnvironmentTexture.test.ts +31 -0
  20. package/src/Components/Flash/Flash.test.ts +44 -0
  21. package/src/Components/Flipper/Flipper.core.test.ts +9 -0
  22. package/src/Components/Flipper/Flipper.test.ts +63 -0
  23. package/src/Components/Health/Health.test.ts +184 -0
  24. package/src/Components/HealthBar/HealthBar.test.ts +158 -0
  25. package/src/Components/HemisphericLight/HemisphericLight.test.ts +36 -0
  26. package/src/Components/KeyboardMover/KeyboardMover.test.ts +120 -0
  27. package/src/Components/LineOfSight/LineOfSight.core.test.ts +26 -0
  28. package/src/Components/LineOfSight/LineOfSight.test.ts +38 -0
  29. package/src/Components/Mesh/Mesh.test.ts +201 -0
  30. package/src/Components/MeshPrimitive/MeshPrimitive.test.ts +222 -0
  31. package/src/Components/MouseHole/MouseHole.test.ts +40 -0
  32. package/src/Components/Movement/Movement.core.test.ts +50 -0
  33. package/src/Components/Movement/Movement.test.ts +73 -0
  34. package/src/Components/Obstacle/Obstacle.core.test.ts +82 -0
  35. package/src/Components/Obstacle/Obstacle.test.ts +72 -0
  36. package/src/Components/ObstacleField/ObstacleField.test.ts +8 -0
  37. package/src/Components/Physics/Physics.core.test.ts +48 -0
  38. package/src/Components/Physics/Physics.test.ts +101 -0
  39. package/src/Components/PinballBuilder/PinballBuilder.test.ts +129 -0
  40. package/src/Components/PinballBuilderInput/PinballBuilderInput.test.ts +76 -0
  41. package/src/Components/PinballCamera/PinballCamera.test.ts +39 -0
  42. package/src/Components/PinballLayout/PinballLayout.test.ts +82 -0
  43. package/src/Components/PinballTable/PinballTable.test.ts +91 -0
  44. package/src/Components/PlayerInput/PlayerInput.core.test.ts +56 -0
  45. package/src/Components/PlayerInput/PlayerInput.test.ts +139 -0
  46. package/src/Components/Plunger/Plunger.test.ts +55 -0
  47. package/src/Components/Score/Score.test.ts +60 -0
  48. package/src/Components/Scoreboard/Scoreboard.core.test.ts +12 -0
  49. package/src/Components/Scoreboard/Scoreboard.test.ts +36 -0
  50. package/src/Components/Shadow/Shadow.test.ts +69 -0
  51. package/src/Components/ShooterCamera/ShooterCamera.test.ts +11 -0
  52. package/src/Components/SkeletonAnimator/SkeletonAnimator.test.ts +182 -0
  53. package/src/Components/Skybox/Skybox.test.ts +83 -0
  54. package/src/Components/Spinner/Spinner.test.ts +30 -0
  55. package/src/Components/TwinStickShooter/TwinStickShooter.test.ts +105 -0
  56. package/templates/main.ts +27 -8
@@ -98,4 +98,124 @@ describe('KeyboardMover', () => {
98
98
  const move = renderer.calls.find((c) => c.method === 'setMeshPosition');
99
99
  expect(move).toBeUndefined();
100
100
  });
101
+
102
+ it('bridges DOM keydown/keyup events to the bus and moves', () => {
103
+ // onInitialize installs window listeners in jsdom; a real DOM event should
104
+ // emit the bus event and drive the mover.
105
+ const playerEntity = world.createEntity('Player');
106
+ const mesh = new MeshPrimitiveComponent({ primitive: 'capsule', position: [0, 0, 0] });
107
+ mesh.handle = renderer.createMesh(playerEntity.id, { kind: 'capsule' });
108
+ playerEntity.add(mesh);
109
+ playerEntity.add(new KeyboardMoverComponent({ speed: 10, faceMotion: false }));
110
+
111
+ window.dispatchEvent(new KeyboardEvent('keydown', { code: 'KeyW' }));
112
+ renderer.calls.length = 0;
113
+ world.update(0.1);
114
+ // No camera → fallback forward = -X.
115
+ const move = renderer.calls.find((c) => c.method === 'setMeshPosition');
116
+ expect(move).toBeDefined();
117
+ expect(move!.args[1]).toBeCloseTo(-1, 3);
118
+
119
+ // A real keyup stops it.
120
+ window.dispatchEvent(new KeyboardEvent('keyup', { code: 'KeyW' }));
121
+ renderer.calls.length = 0;
122
+ world.update(0.1);
123
+ expect(renderer.calls.find((c) => c.method === 'setMeshPosition')).toBeUndefined();
124
+ });
125
+
126
+ it('moves along the fallback forward (-X) when there is no camera', () => {
127
+ const playerEntity = world.createEntity('Player');
128
+ const mesh = new MeshPrimitiveComponent({ primitive: 'capsule', position: [0, 0, 0] });
129
+ mesh.handle = renderer.createMesh(playerEntity.id, { kind: 'capsule' });
130
+ playerEntity.add(mesh);
131
+ playerEntity.add(new KeyboardMoverComponent({ speed: 10, faceMotion: false }));
132
+
133
+ eventBus.emit('keyboard.keydown', { code: 'KeyW' });
134
+ renderer.calls.length = 0;
135
+ world.update(0.1);
136
+
137
+ const move = renderer.calls.find((c) => c.method === 'setMeshPosition');
138
+ expect(move).toBeDefined();
139
+ expect(move!.args[1]).toBeCloseTo(-1, 3);
140
+ expect(move!.args[3]).toBeCloseTo(0, 3);
141
+ });
142
+
143
+ it('moves back (+X) on S and left (+Z) on A', () => {
144
+ const camEntity = world.createEntity('Camera');
145
+ const arc = new ArcCameraComponent({ alpha: 0 });
146
+ arc.handle = renderer.createArcCamera(camEntity.id, arc.toSpec());
147
+ getArcCameraRuntime(arc).initialized = true;
148
+ camEntity.add(arc);
149
+
150
+ const playerEntity = world.createEntity('Player');
151
+ const mesh = new MeshPrimitiveComponent({ primitive: 'capsule', position: [0, 0, 0] });
152
+ mesh.handle = renderer.createMesh(playerEntity.id, { kind: 'capsule' });
153
+ playerEntity.add(mesh);
154
+ playerEntity.add(new KeyboardMoverComponent({ speed: 10, faceMotion: false }));
155
+
156
+ // S = back: forward is -X, so back is +X.
157
+ eventBus.emit('keyboard.keydown', { code: 'KeyS' });
158
+ renderer.calls.length = 0;
159
+ world.update(0.1);
160
+ let move = renderer.calls.find((c) => c.method === 'setMeshPosition');
161
+ expect(move).toBeDefined();
162
+ expect(move!.args[1]).toBeCloseTo(1, 3);
163
+ eventBus.emit('keyboard.keyup', { code: 'KeyS' });
164
+
165
+ // A = left: right is (0,0,-1), so left is +Z.
166
+ mesh.position[0] = 0; mesh.position[2] = 0;
167
+ eventBus.emit('keyboard.keydown', { code: 'KeyA' });
168
+ renderer.calls.length = 0;
169
+ world.update(0.1);
170
+ move = renderer.calls.find((c) => c.method === 'setMeshPosition');
171
+ expect(move).toBeDefined();
172
+ expect(move!.args[3]).toBeCloseTo(1, 3);
173
+ });
174
+
175
+ it('normalizes diagonal input so combined speed is not exaggerated', () => {
176
+ const camEntity = world.createEntity('Camera');
177
+ const arc = new ArcCameraComponent({ alpha: 0 });
178
+ arc.handle = renderer.createArcCamera(camEntity.id, arc.toSpec());
179
+ getArcCameraRuntime(arc).initialized = true;
180
+ camEntity.add(arc);
181
+
182
+ const playerEntity = world.createEntity('Player');
183
+ const mesh = new MeshPrimitiveComponent({ primitive: 'capsule', position: [0, 0, 0] });
184
+ mesh.handle = renderer.createMesh(playerEntity.id, { kind: 'capsule' });
185
+ playerEntity.add(mesh);
186
+ playerEntity.add(new KeyboardMoverComponent({ speed: 10, faceMotion: false }));
187
+
188
+ // W + D: raw velocity (-1,-1) has length sqrt(2) > 1, so it normalizes.
189
+ eventBus.emit('keyboard.keydown', { code: 'KeyW' });
190
+ eventBus.emit('keyboard.keydown', { code: 'KeyD' });
191
+ renderer.calls.length = 0;
192
+ world.update(0.1);
193
+
194
+ const move = renderer.calls.find((c) => c.method === 'setMeshPosition');
195
+ expect(move).toBeDefined();
196
+ const dx = move!.args[1] as number;
197
+ const dz = move!.args[3] as number;
198
+ // Per-axis ≈ speed*dt/sqrt(2); combined magnitude ≈ speed*dt (1 unit), not sqrt(2).
199
+ expect(Math.hypot(dx, dz)).toBeCloseTo(1, 3);
200
+ expect(Math.abs(dx)).toBeCloseTo(Math.SQRT1_2, 3);
201
+ expect(Math.abs(dz)).toBeCloseTo(Math.SQRT1_2, 3);
202
+ });
203
+
204
+ it('yaws the mesh to face motion when faceMotion is on', () => {
205
+ const playerEntity = world.createEntity('Player');
206
+ const mesh = new MeshPrimitiveComponent({ primitive: 'capsule', position: [0, 0, 0] });
207
+ mesh.handle = renderer.createMesh(playerEntity.id, { kind: 'capsule' });
208
+ playerEntity.add(mesh);
209
+ playerEntity.add(new KeyboardMoverComponent({ speed: 10, faceMotion: true }));
210
+
211
+ eventBus.emit('keyboard.keydown', { code: 'KeyW' });
212
+ renderer.calls.length = 0;
213
+ world.update(0.1);
214
+
215
+ const rot = renderer.calls.find((c) => c.method === 'setMeshRotation');
216
+ expect(rot).toBeDefined();
217
+ // Fallback forward is -X (vx=-1, vz=0) → yaw = atan2(-1, 0) = -PI/2.
218
+ expect(mesh.rotation[1]).toBeCloseTo(Math.atan2(-1, 0), 3);
219
+ expect(rot!.args[2]).toBeCloseTo(Math.atan2(-1, 0), 3);
220
+ });
101
221
  });
@@ -57,6 +57,11 @@ describe('inSightCone', () => {
57
57
  expect(inSightCone(0, 0, 1, 0, 10, 10, 40, 100)).toBe(false)
58
58
  expect(inSightCone(0, 0, 1, 0, 10, 0, 40, 100)).toBe(true)
59
59
  })
60
+
61
+ it('always sees a target essentially at the viewer', () => {
62
+ // dist < 1e-4 short-circuits, even with a narrow cone facing away.
63
+ expect(inSightCone(0, 0, 1, 0, 0, 0, 40, 100)).toBe(true)
64
+ })
60
65
  })
61
66
 
62
67
  describe('rayFanEndpoints', () => {
@@ -74,6 +79,12 @@ describe('rayFanEndpoints', () => {
74
79
  expect(ends[0]![1]).toBeCloseTo(-0.6, 5)
75
80
  expect(ends[2]![1]).toBeCloseTo(0.6, 5)
76
81
  })
82
+
83
+ it('returns a single endpoint at the from point when the target coincides', () => {
84
+ // dist < 1e-4 short-circuits to one endpoint (no perpendicular axis).
85
+ const ends = rayFanEndpoints(0, 0, 0, 0, 3, 0.6)
86
+ expect(ends).toEqual([[0, 0]])
87
+ })
77
88
  })
78
89
 
79
90
  describe('rayReaches', () => {
@@ -92,6 +103,21 @@ describe('rayReaches', () => {
92
103
  it('is blocked by a wall box across the line', () => {
93
104
  expect(rayReaches(0, 0, 10, 0, [], 0.6, [{ minX: 4, maxX: 6, minZ: -2, maxZ: 2 }])).toBe(false)
94
105
  })
106
+
107
+ it('handles a degenerate zero-length segment (from == to)', () => {
108
+ // segLen2 < 1e-8 → t = 0; occluder at the point is within radius → blocked.
109
+ expect(rayReaches(0, 0, 0, 0, [{ x: 0, z: 0 }], 0.6)).toBe(false)
110
+ })
111
+
112
+ it('clamps the projection to the segment start when the occluder is behind A', () => {
113
+ // Occluder just behind A: projection t < 0, clamped to 0, within radius → blocked.
114
+ expect(rayReaches(0, 0, 10, 0, [{ x: -0.1, z: 0 }], 0.6)).toBe(false)
115
+ })
116
+
117
+ it('clamps the projection to the segment end when the occluder is beyond B', () => {
118
+ // Occluder just past B: projection t > 1, clamped to 1, within radius → blocked.
119
+ expect(rayReaches(0, 0, 10, 0, [{ x: 10.1, z: 0 }], 0.6)).toBe(false)
120
+ })
95
121
  })
96
122
 
97
123
  describe('hasLineOfSight', () => {
@@ -77,4 +77,42 @@ describe('LineOfSightSystem', () => {
77
77
  world.update(0.016)
78
78
  expect(renderer.calls.some((c) => c.method === 'disposeLineSystem')).toBe(true)
79
79
  })
80
+
81
+ it('updates facing as the seer moves between frames', () => {
82
+ makePlayer([5, 0.8, 0])
83
+ const seer = makeSeer([0, 0.5, 0])
84
+ const m = seer.get(MeshPrimitiveComponent)!
85
+ world.update(0.016)
86
+ // Move the seer >1e-3 so the facing-update block runs on the next frame.
87
+ m.position[0] = 2
88
+ expect(() => world.update(0.016)).not.toThrow()
89
+ // Still in clear view of the player → acquired (no spurious loss).
90
+ expect(acquired).toContain(seer.id)
91
+ expect(lost).not.toContain(seer.id)
92
+ })
93
+
94
+ it('updates both clear and blocked line systems with debug rays on', () => {
95
+ // Player in view; a second seer sits squarely between this seer and the
96
+ // player, blocking its rays. With rays on we expect both line systems to be
97
+ // updated and made visible (clear for the unblocked seer, blocked for the
98
+ // occluded one).
99
+ makePlayer([10, 0.8, 0])
100
+ makeSeer([5, 0.5, 0]) // blocker between
101
+ makeSeer([0, 0.5, 0]) // blocked seer
102
+ makeSeer([9, 0.5, 0]) // clear seer right next to the player
103
+
104
+ eventBus.emit(LineOfSightInputEvents.SET_DEBUG_RAYS, { enabled: true })
105
+ renderer.calls.length = 0
106
+ world.update(0.016)
107
+
108
+ const updates = renderer.calls.filter((c) => c.method === 'updateLineSystem')
109
+ expect(updates.length).toBeGreaterThanOrEqual(2)
110
+ // Both visibility toggles fire true (each set has at least one segment).
111
+ const visTrue = renderer.calls.filter(
112
+ (c) => c.method === 'setLineSystemVisible' && c.args[1] === true,
113
+ )
114
+ expect(visTrue.length).toBeGreaterThanOrEqual(2)
115
+ // The updates carry non-degenerate segments (more than the single DEGENERATE seg).
116
+ expect(updates.some((c) => (c.args[1] as unknown[]).length >= 1)).toBe(true)
117
+ })
80
118
  })
@@ -56,6 +56,20 @@ describe('MeshComponent', () => {
56
56
  const copy = new MeshComponent(original.serialize());
57
57
  expect(copy.params).toEqual(original.params);
58
58
  });
59
+
60
+ it('falls back to the default for a malformed position object', () => {
61
+ const c = new MeshComponent({
62
+ position: { x: 1, y: 2 } as unknown as { x: number; y: number; z: number },
63
+ });
64
+ expect(c.params.position).toEqual([0, 0, 0]);
65
+ });
66
+
67
+ it('falls back to the default for a wrong-length position array', () => {
68
+ const c = new MeshComponent({
69
+ position: [1, 2] as unknown as [number, number, number],
70
+ });
71
+ expect(c.params.position).toEqual([0, 0, 0]);
72
+ });
59
73
  });
60
74
 
61
75
  describe('MeshSystem', () => {
@@ -193,6 +207,101 @@ describe('MeshSystem', () => {
193
207
  const disposeCalls = renderer.calls.filter((c) => c.method === 'disposeMesh');
194
208
  expect(disposeCalls).toHaveLength(1);
195
209
  });
210
+
211
+ it('creates core instances for entities that existed before the system mounted', () => {
212
+ const eb = new EventBus();
213
+ const r = new MockRendererAdapter();
214
+ const w = new World({ eventBus: eb, renderer: r, detectRaces: false });
215
+ const entity = w.createEntity();
216
+ const comp = new MeshComponent({ src: '/pre.glb' });
217
+ entity.add(comp);
218
+ // No system yet — instance not created.
219
+ expect(comp.instance).toBeNull();
220
+ w.addSystem(new MeshSystem(eb));
221
+ w.initialize(); // onInitialize walks pre-existing entities → ensureInstance
222
+ expect(comp.instance).not.toBeNull();
223
+ expect(comp.instance?.getParams().src).toBe('/pre.glb');
224
+ });
225
+
226
+ it('leaves the core in "loading" when no renderer is attached (unit-test path)', () => {
227
+ const eb = new EventBus();
228
+ const w = new World({ eventBus: eb }); // no renderer
229
+ w.addSystem(new MeshSystem(eb));
230
+ w.initialize();
231
+ const entity = w.createEntity();
232
+ const comp = new MeshComponent({ src: '/a.glb' });
233
+ entity.add(comp);
234
+ w.update(1 / 60);
235
+ // beginLoad ran + LOADING emitted, but with no renderer the load can't
236
+ // complete, so state stays loading and no loadMesh was recorded.
237
+ expect(comp.instance?.getState().state).toBe('loading');
238
+ });
239
+
240
+ it('LOAD event finds the entity via the world even before the query indexes it', async () => {
241
+ const entity = world.createEntity();
242
+ entity.add(new MeshComponent({ src: '/a.glb', autoLoad: false }));
243
+ // findEntityById falls through to world.getEntity for this id.
244
+ eventBus.emit(MeshInputEvents.LOAD, { entityId: entity.id, src: '/override.glb' });
245
+ await Promise.resolve();
246
+ await Promise.resolve();
247
+ const loadCalls = renderer.calls.filter((c) => c.method === 'loadMesh');
248
+ expect(loadCalls).toHaveLength(1);
249
+ // The src override flowed through to the resolved url.
250
+ expect(String(loadCalls[0].args[1] && (loadCalls[0].args[1] as { url: string }).url)).toContain(
251
+ '/override.glb',
252
+ );
253
+ });
254
+
255
+ it('LOAD event for an unknown entity id is a no-op', () => {
256
+ expect(() =>
257
+ eventBus.emit(MeshInputEvents.LOAD, { entityId: 'ghost' }),
258
+ ).not.toThrow();
259
+ expect(renderer.calls.some((c) => c.method === 'loadMesh')).toBe(false);
260
+ });
261
+
262
+ it('stringifies a non-Error rejection into the ERROR event message', async () => {
263
+ vi.spyOn(renderer, 'loadMesh').mockRejectedValueOnce('plain string failure');
264
+ const errorSpy = vi.fn();
265
+ eventBus.on(MeshEvents.ERROR, errorSpy);
266
+ const entity = world.createEntity();
267
+ entity.add(new MeshComponent({ src: '/x.glb' }));
268
+ world.update(1 / 60);
269
+ await Promise.resolve();
270
+ await Promise.resolve();
271
+ expect(errorSpy).toHaveBeenCalledWith(
272
+ expect.objectContaining({ entityId: entity.id, error: 'plain string failure' }),
273
+ );
274
+ });
275
+
276
+ it('does not dispatch a load while the component has no src yet', async () => {
277
+ const entity = world.createEntity();
278
+ entity.add(new MeshComponent()); // autoLoad true, but src === ''
279
+ world.update(1 / 60);
280
+ await Promise.resolve();
281
+ await Promise.resolve();
282
+ // dispatchLoad bails at the empty-src guard → no loadMesh, still unloaded.
283
+ expect(renderer.calls.some((c) => c.method === 'loadMesh')).toBe(false);
284
+ expect(entity.get(MeshComponent)!.instance?.getState().state).toBe('idle');
285
+
286
+ // Once a src arrives via LOAD, the load goes through.
287
+ eventBus.emit(MeshInputEvents.LOAD, { entityId: entity.id, src: '/late.glb' });
288
+ await Promise.resolve();
289
+ await Promise.resolve();
290
+ expect(renderer.calls.some((c) => c.method === 'loadMesh')).toBe(true);
291
+ });
292
+
293
+ it('follow with no sibling MeshPrimitive is a no-op (does not throw)', async () => {
294
+ const entity = world.createEntity();
295
+ // follow:true but NOT instanced and NO MeshPrimitive sibling.
296
+ entity.add(new MeshComponent({ src: '/a.glb', follow: true }));
297
+ world.update(1 / 60);
298
+ await Promise.resolve();
299
+ await Promise.resolve();
300
+ renderer.calls.length = 0;
301
+ expect(() => world.update(1 / 60)).not.toThrow();
302
+ // updateFollow returns early at the missing-sibling guard → no follow writes.
303
+ expect(renderer.calls.some((c) => c.method === 'setMeshRotation')).toBe(false);
304
+ });
196
305
  });
197
306
 
198
307
  describe('MeshSystem — instanced clone + follow', () => {
@@ -302,6 +411,25 @@ describe('MeshSystem — instanced clone + follow', () => {
302
411
  expect(yawed).toBe(true);
303
412
  });
304
413
 
414
+ it('re-acquiring a non-following instanced entity reveals the clone without touching follow state', async () => {
415
+ const entity = world.createEntity('Enemy');
416
+ // instanced but follow:false → no followState is ever created.
417
+ entity.add(new MeshComponent({ src: 'enemy.glb', instanced: true }));
418
+ entity.add(new MeshPrimitiveComponent({ primitive: 'sphere', position: [0, 1, 0] }));
419
+
420
+ world.update(1 / 60);
421
+ await Promise.resolve();
422
+ await Promise.resolve();
423
+ world.update(1 / 60); // instantiate
424
+
425
+ entity.active = false;
426
+ renderer.calls.length = 0;
427
+ // Re-acquire: fs is undefined so the prevX/prevZ reseed is skipped (295 false).
428
+ entity.active = true;
429
+ expect(callsOf('setModelVisible').some((c) => c.args[1] === true)).toBe(true);
430
+ expect(callsOf('instantiateModel')).toHaveLength(0);
431
+ });
432
+
305
433
  it('parked (active=false) entity hides the clone but keeps it; true destroy disposes it', async () => {
306
434
  const parked = world.createEntity('ParkedEnemy');
307
435
  parked.add(new MeshComponent({ src: 'enemy.glb', instanced: true }));
@@ -329,6 +457,79 @@ describe('MeshSystem — instanced clone + follow', () => {
329
457
  expect(callsOf('disposeModel')[0].args[0]).toBe(destroyed.id);
330
458
  });
331
459
 
460
+ it('faceMode "proxy" copies the proxy yaw (+yawOffset) instead of inferring from movement', async () => {
461
+ const entity = world.createEntity('Player');
462
+ entity.add(
463
+ new MeshComponent({
464
+ src: 'player.glb',
465
+ instanced: true,
466
+ follow: true,
467
+ faceMode: 'proxy',
468
+ yawOffset: Math.PI / 4,
469
+ position: [0, 0.5, 0],
470
+ }),
471
+ );
472
+ const mp = new MeshPrimitiveComponent({ primitive: 'capsule', position: [0, 1, 0] });
473
+ entity.add(mp);
474
+
475
+ world.update(1 / 60);
476
+ await Promise.resolve();
477
+ await Promise.resolve();
478
+ world.update(1 / 60); // instantiate
479
+
480
+ // Aim the proxy without moving it — proxy faceMode reads rotation[1] directly.
481
+ mp.rotation[1] = Math.PI / 2;
482
+ world.update(1 / 60);
483
+
484
+ const rots = callsOf('setMeshRotation');
485
+ const expectedYaw = Math.PI / 2 + Math.PI / 4;
486
+ expect(rots.some((c) => c.args[1] === 0 && c.args[2] === expectedYaw && c.args[3] === 0)).toBe(
487
+ true,
488
+ );
489
+ });
490
+
491
+ it('instanced load leaves the core in "loading" when no renderer is attached', () => {
492
+ const eb = new EventBus();
493
+ const w = new World({ eventBus: eb }); // no renderer
494
+ w.addSystem(new MeshSystem(eb));
495
+ w.initialize();
496
+ const entity = w.createEntity('Enemy');
497
+ const comp = new MeshComponent({ src: 'enemy.glb', instanced: true });
498
+ entity.add(comp);
499
+ w.update(1 / 60);
500
+ // beginLoad ran (LOADING fired) but dispatchInstancedLoad returns at the
501
+ // no-renderer guard before requesting any template.
502
+ expect(comp.instance?.getState().state).toBe('loading');
503
+ });
504
+
505
+ it('retries the template load on a later frame after the first attempt rejects', async () => {
506
+ const fail = vi
507
+ .spyOn(renderer, 'loadModelTemplate')
508
+ .mockRejectedValueOnce(new Error('404'));
509
+
510
+ const entity = world.createEntity('Enemy');
511
+ entity.add(new MeshComponent({ src: 'enemy.glb', instanced: true }));
512
+
513
+ // First tick requests the template; the promise rejects → templateRequested
514
+ // is rolled back so a retry can happen.
515
+ world.update(1 / 60);
516
+ // Flush the rejected microtask so the .catch rolls back templateRequested.
517
+ await new Promise<void>((resolve) => setTimeout(resolve, 0));
518
+ expect(fail).toHaveBeenCalledTimes(1);
519
+ expect(callsOf('instantiateModel')).toHaveLength(0);
520
+
521
+ // Next frame: the template load is attempted again (the spy counts every
522
+ // invocation, including the once-rejected one and the retry).
523
+ world.update(1 / 60);
524
+ await new Promise<void>((resolve) => setTimeout(resolve, 0));
525
+ expect(fail.mock.calls.length).toBeGreaterThanOrEqual(2);
526
+
527
+ // And now it can instantiate.
528
+ world.update(1 / 60);
529
+ expect(callsOf('instantiateModel')).toHaveLength(1);
530
+ expect(entity.get(MeshComponent)!.instance?.getState().state).toBe('loaded');
531
+ });
532
+
332
533
  it('re-acquiring a parked instanced entity reveals the kept clone without re-instantiating', async () => {
333
534
  const entity = world.createEntity('Enemy');
334
535
  entity.add(new MeshComponent({ src: 'enemy.glb', instanced: true, follow: true }));
@@ -38,6 +38,80 @@ describe('MeshPrimitiveComponent (core)', () => {
38
38
  expect(c.position).toEqual([4, 5, 6]);
39
39
  });
40
40
 
41
+ it('falls back to [0,0,0] for a malformed position object (missing numeric x/y/z)', () => {
42
+ // A {x,y} object (no z, or non-numeric) is not Vec3-like → fallback, not NaN.
43
+ const c = new MeshPrimitiveComponent({
44
+ position: { x: 1, y: 2 } as unknown as { x: number; y: number; z: number },
45
+ });
46
+ expect(c.position).toEqual([0, 0, 0]);
47
+ });
48
+
49
+ it('falls back to the default for a wrong-length array position', () => {
50
+ const c = new MeshPrimitiveComponent({
51
+ position: [1, 2] as unknown as [number, number, number],
52
+ });
53
+ expect(c.position).toEqual([0, 0, 0]);
54
+ });
55
+
56
+ it('coerces non-numeric array entries to 0 in position', () => {
57
+ const c = new MeshPrimitiveComponent({
58
+ position: ['a', 2, null] as unknown as [number, number, number],
59
+ });
60
+ expect(c.position).toEqual([0, 2, 0]);
61
+ });
62
+
63
+ it('maps PBR texture material keys onto the material spec and ignores ambientColor', () => {
64
+ const c = new MeshPrimitiveComponent({
65
+ material: {
66
+ albedoTexture: '/a.png',
67
+ normalTexture: '/n.png',
68
+ roughnessTexture: '/r.png',
69
+ ambientTexture: '/ao.png',
70
+ metallic: 0.4,
71
+ roughness: 0.6,
72
+ uScale: 2,
73
+ vScale: 3,
74
+ ambientColor: [0.1, 0.1, 0.1],
75
+ },
76
+ });
77
+ expect(c.material?.albedoTexture).toBe('/a.png');
78
+ expect(c.material?.normalTexture).toBe('/n.png');
79
+ expect(c.material?.roughnessTexture).toBe('/r.png');
80
+ expect(c.material?.ambientTexture).toBe('/ao.png');
81
+ expect(c.material?.metallic).toBe(0.4);
82
+ expect(c.material?.roughness).toBe(0.6);
83
+ expect(c.material?.uScale).toBe(2);
84
+ expect(c.material?.vScale).toBe(3);
85
+ // ambientColor is a legacy key with no destination on the spec.
86
+ expect((c.material as Record<string, unknown>).ambient).toBeUndefined();
87
+ });
88
+
89
+ it('serialize round-trips a textured PBR material', () => {
90
+ const original = new MeshPrimitiveComponent({
91
+ material: { albedoTexture: '/a.png', metallic: 0.2, roughness: 0.8, uScale: 4 },
92
+ });
93
+ const copy = new MeshPrimitiveComponent(original.serialize());
94
+ expect(copy.material?.albedoTexture).toBe('/a.png');
95
+ expect(copy.material?.metallic).toBe(0.2);
96
+ expect(copy.material?.roughness).toBe(0.8);
97
+ expect(copy.material?.uScale).toBe(4);
98
+ });
99
+
100
+ it('serialize yields undefined material when none is set', () => {
101
+ const c = new MeshPrimitiveComponent();
102
+ expect(c.serialize().material).toBeUndefined();
103
+ });
104
+
105
+ it('handle getter/setter round-trips through the shared runtime map', () => {
106
+ const c = new MeshPrimitiveComponent();
107
+ expect(c.handle).toBeUndefined();
108
+ const fake = { __h: 1 } as unknown as NonNullable<typeof c.handle>;
109
+ c.handle = fake;
110
+ expect(c.handle).toBe(fake);
111
+ c.handle = undefined;
112
+ expect(c.handle).toBeUndefined();
113
+ });
114
+
41
115
  it('accepts color shorthand, mapping to material.diffuse', () => {
42
116
  const c = new MeshPrimitiveComponent({ color: [1, 0, 0] });
43
117
  expect(c.material?.diffuse).toEqual([1, 0, 0]);
@@ -302,6 +376,154 @@ describe('MeshPrimitiveSystem — adapter integration', () => {
302
376
  }).not.toThrow();
303
377
  });
304
378
 
379
+ it('SET_SCALE is accepted but a no-op (scale not yet in the adapter surface)', () => {
380
+ const entity = world.createEntity();
381
+ entity.add(new MeshPrimitiveComponent());
382
+ renderer.calls.length = 0;
383
+ expect(() =>
384
+ eventBus.emit(MeshPrimitiveInputEvents.SET_SCALE, {
385
+ entityId: entity.id, x: 2, y: 2, z: 2,
386
+ }),
387
+ ).not.toThrow();
388
+ // No adapter method exists for scale yet.
389
+ expect(renderer.calls.length).toBe(0);
390
+ });
391
+
392
+ it('CREATE event applies every dimensional param it carries', () => {
393
+ const entity = world.createEntity();
394
+ const comp = new MeshPrimitiveComponent({ primitive: 'box', autoCreate: false });
395
+ entity.add(comp);
396
+ renderer.calls.length = 0;
397
+ eventBus.emit(MeshPrimitiveInputEvents.CREATE, {
398
+ entityId: entity.id,
399
+ primitive: 'cylinder',
400
+ width: 4,
401
+ height: 5,
402
+ depth: 6,
403
+ diameter: 7,
404
+ });
405
+ expect(comp.primitive).toBe('cylinder');
406
+ expect(comp.width).toBe(4);
407
+ expect(comp.height).toBe(5);
408
+ expect(comp.depth).toBe(6);
409
+ expect(comp.diameter).toBe(7);
410
+ expect(renderer.calls.some((c) => c.method === 'createMesh')).toBe(true);
411
+ });
412
+
413
+ it('CREATE event for an unknown entity is a silent no-op', () => {
414
+ renderer.calls.length = 0;
415
+ expect(() =>
416
+ eventBus.emit(MeshPrimitiveInputEvents.CREATE, { entityId: 'ghost', primitive: 'sphere' }),
417
+ ).not.toThrow();
418
+ expect(renderer.calls.length).toBe(0);
419
+ });
420
+
421
+ it('SET_COLOR / SET_VISIBLE / DISPOSE for unknown entities are silent no-ops', () => {
422
+ renderer.calls.length = 0;
423
+ expect(() => {
424
+ eventBus.emit(MeshPrimitiveInputEvents.SET_COLOR, { entityId: 'ghost', r: 1, g: 0, b: 0 });
425
+ eventBus.emit(MeshPrimitiveInputEvents.SET_VISIBLE, { entityId: 'ghost', visible: false });
426
+ eventBus.emit(MeshPrimitiveInputEvents.DISPOSE, { entityId: 'ghost' });
427
+ }).not.toThrow();
428
+ expect(renderer.calls.length).toBe(0);
429
+ });
430
+
431
+ it('DISPOSE on an entity whose mesh was never created is a no-op (no event)', () => {
432
+ const entity = world.createEntity();
433
+ const comp = new MeshPrimitiveComponent({ autoCreate: false });
434
+ entity.add(comp);
435
+ const disposedSpy = vi.fn();
436
+ eventBus.on(MeshPrimitiveEvents.DISPOSED, disposedSpy);
437
+ renderer.calls.length = 0;
438
+ eventBus.emit(MeshPrimitiveInputEvents.DISPOSE, { entityId: entity.id });
439
+ expect(renderer.calls.some((c) => c.method === 'disposeMesh')).toBe(false);
440
+ expect(disposedSpy).not.toHaveBeenCalled();
441
+ });
442
+
443
+ it('SET_COLOR merges into an existing material rather than dropping other keys', () => {
444
+ const entity = world.createEntity();
445
+ const comp = new MeshPrimitiveComponent({
446
+ material: { diffuseColor: [1, 1, 1], alpha: 0.5 },
447
+ });
448
+ entity.add(comp);
449
+ renderer.calls.length = 0;
450
+ eventBus.emit(MeshPrimitiveInputEvents.SET_COLOR, { entityId: entity.id, r: 0, g: 1, b: 0 });
451
+ expect(comp.material?.diffuse).toEqual([0, 1, 0]);
452
+ expect(comp.material?.alpha).toBe(0.5); // preserved
453
+ });
454
+
455
+ it('SET_COLOR creates a material when the component had none', () => {
456
+ const entity = world.createEntity();
457
+ const comp = new MeshPrimitiveComponent(); // no material
458
+ entity.add(comp);
459
+ expect(comp.material).toBeUndefined();
460
+ renderer.calls.length = 0;
461
+ eventBus.emit(MeshPrimitiveInputEvents.SET_COLOR, { entityId: entity.id, r: 0.2, g: 0.4, b: 0.6 });
462
+ expect(comp.material?.diffuse).toEqual([0.2, 0.4, 0.6]);
463
+ expect(renderer.calls.some((c) => c.method === 'setMeshColor')).toBe(true);
464
+ });
465
+
466
+ it('recreating via CREATE disposes the prior handle before making a new mesh', () => {
467
+ const entity = world.createEntity();
468
+ const comp = new MeshPrimitiveComponent({ primitive: 'box' }); // autoCreate → already made
469
+ entity.add(comp);
470
+ expect(comp.handle).toBeDefined();
471
+ renderer.calls.length = 0;
472
+ // Re-create over an existing handle exercises the dispose-then-create path.
473
+ eventBus.emit(MeshPrimitiveInputEvents.CREATE, { entityId: entity.id, primitive: 'sphere' });
474
+ expect(renderer.calls.some((c) => c.method === 'disposeMesh')).toBe(true);
475
+ expect(renderer.calls.some((c) => c.method === 'createMesh')).toBe(true);
476
+ expect(comp.primitive).toBe('sphere');
477
+ });
478
+
479
+ it('CREATED carries the raw mesh object when the Babylon adapter exposes getMeshObject', () => {
480
+ // The Babylon adapter has a non-interface getMeshObject for back-compat
481
+ // consumers (ArcCamera/Shadow). Stub it here to exercise that branch.
482
+ const rawMesh = { name: 'rawBabylonMesh' };
483
+ (renderer as unknown as { getMeshObject: (h: unknown) => unknown }).getMeshObject = () => rawMesh;
484
+ const spy = vi.fn();
485
+ eventBus.on(MeshPrimitiveEvents.CREATED, spy);
486
+ const entity = world.createEntity();
487
+ entity.add(new MeshPrimitiveComponent({ primitive: 'box' }));
488
+ expect(spy).toHaveBeenCalledWith(expect.objectContaining({ mesh: rawMesh }));
489
+ });
490
+
491
+ it('events targeting an existing entity that lacks a MeshPrimitiveComponent are no-ops', () => {
492
+ // The entity is real (world.getEntity finds it) but carries no component,
493
+ // so every handler short-circuits at its `if (!comp) return` guard.
494
+ const bare = world.createEntity();
495
+ renderer.calls.length = 0;
496
+ expect(() => {
497
+ eventBus.emit(MeshPrimitiveInputEvents.CREATE, { entityId: bare.id, primitive: 'sphere' });
498
+ eventBus.emit(MeshPrimitiveInputEvents.SET_POSITION, { entityId: bare.id, x: 1, y: 2, z: 3 });
499
+ eventBus.emit(MeshPrimitiveInputEvents.SET_COLOR, { entityId: bare.id, r: 1, g: 0, b: 0 });
500
+ eventBus.emit(MeshPrimitiveInputEvents.SET_VISIBLE, { entityId: bare.id, visible: false });
501
+ eventBus.emit(MeshPrimitiveInputEvents.DISPOSE, { entityId: bare.id });
502
+ }).not.toThrow();
503
+ expect(renderer.calls.length).toBe(0);
504
+ });
505
+
506
+ it('removing an entity whose mesh was never created disposes nothing', () => {
507
+ const entity = world.createEntity();
508
+ entity.add(new MeshPrimitiveComponent({ autoCreate: false }));
509
+ renderer.calls.length = 0;
510
+ world.removeEntity(entity); // active destroy, but rt.handle is undefined
511
+ expect(renderer.calls.some((c) => c.method === 'disposeMesh')).toBe(false);
512
+ });
513
+
514
+ it('createPrimitive defers when no renderer is attached', () => {
515
+ const eb = new EventBus();
516
+ const w = new World({ eventBus: eb }); // no renderer
517
+ const entity = w.createEntity();
518
+ const comp = new MeshPrimitiveComponent({ primitive: 'sphere' });
519
+ entity.add(comp);
520
+ w.addSystem(new MeshPrimitiveSystem(eb));
521
+ w.initialize();
522
+ // Creation is deferred without a renderer; the component is still inspectable.
523
+ expect(comp.isCreated).toBe(false);
524
+ expect(comp.handle).toBeUndefined();
525
+ });
526
+
305
527
  it('onUpdate is a no-op', () => {
306
528
  expect(() => world.update(16)).not.toThrow();
307
529
  });