@babylonjsmarket/arcade 0.3.10 → 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 (58) 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/dist/cli/index.js +6 -0
  6. package/dist/cli/index.js.map +1 -1
  7. package/package.json +3 -2
  8. package/src/Components/Animation/Animation.core.test.ts +11 -0
  9. package/src/Components/Animation/Animation.test.ts +26 -0
  10. package/src/Components/ArcCamera/ArcCamera.test.ts +168 -0
  11. package/src/Components/Bullet/Bullet.test.ts +223 -0
  12. package/src/Components/BulletPool/BulletPool.test.ts +50 -1
  13. package/src/Components/Bumper/Bumper.test.ts +18 -0
  14. package/src/Components/CameraFollow/CameraFollow.core.test.ts +19 -0
  15. package/src/Components/CameraFollow/CameraFollow.test.ts +92 -0
  16. package/src/Components/DirectionalLight/DirectionalLight.test.ts +48 -0
  17. package/src/Components/Enemy/Enemy.core.test.ts +20 -1
  18. package/src/Components/Enemy/Enemy.test.ts +456 -1
  19. package/src/Components/EnemySpawner/EnemySpawner.test.ts +155 -0
  20. package/src/Components/EntityPool/EntityPool.test.ts +95 -1
  21. package/src/Components/EnvironmentTexture/EnvironmentTexture.test.ts +31 -0
  22. package/src/Components/Flash/Flash.test.ts +44 -0
  23. package/src/Components/Flipper/Flipper.core.test.ts +9 -0
  24. package/src/Components/Flipper/Flipper.test.ts +63 -0
  25. package/src/Components/Health/Health.test.ts +184 -0
  26. package/src/Components/HealthBar/HealthBar.test.ts +158 -0
  27. package/src/Components/HemisphericLight/HemisphericLight.test.ts +36 -0
  28. package/src/Components/KeyboardMover/KeyboardMover.test.ts +120 -0
  29. package/src/Components/LineOfSight/LineOfSight.core.test.ts +26 -0
  30. package/src/Components/LineOfSight/LineOfSight.test.ts +38 -0
  31. package/src/Components/Mesh/Mesh.test.ts +201 -0
  32. package/src/Components/MeshPrimitive/MeshPrimitive.test.ts +222 -0
  33. package/src/Components/MouseHole/MouseHole.test.ts +40 -0
  34. package/src/Components/Movement/Movement.core.test.ts +50 -0
  35. package/src/Components/Movement/Movement.test.ts +73 -0
  36. package/src/Components/Obstacle/Obstacle.core.test.ts +82 -0
  37. package/src/Components/Obstacle/Obstacle.test.ts +72 -0
  38. package/src/Components/ObstacleField/ObstacleField.test.ts +8 -0
  39. package/src/Components/Physics/Physics.core.test.ts +48 -0
  40. package/src/Components/Physics/Physics.test.ts +101 -0
  41. package/src/Components/PinballBuilder/PinballBuilder.test.ts +129 -0
  42. package/src/Components/PinballBuilderInput/PinballBuilderInput.test.ts +76 -0
  43. package/src/Components/PinballCamera/PinballCamera.test.ts +39 -0
  44. package/src/Components/PinballLayout/PinballLayout.test.ts +82 -0
  45. package/src/Components/PinballTable/PinballTable.test.ts +91 -0
  46. package/src/Components/PlayerInput/PlayerInput.core.test.ts +56 -0
  47. package/src/Components/PlayerInput/PlayerInput.test.ts +139 -0
  48. package/src/Components/Plunger/Plunger.test.ts +55 -0
  49. package/src/Components/Score/Score.test.ts +60 -0
  50. package/src/Components/Scoreboard/Scoreboard.core.test.ts +12 -0
  51. package/src/Components/Scoreboard/Scoreboard.test.ts +36 -0
  52. package/src/Components/Shadow/Shadow.test.ts +69 -0
  53. package/src/Components/ShooterCamera/ShooterCamera.test.ts +11 -0
  54. package/src/Components/SkeletonAnimator/SkeletonAnimator.test.ts +182 -0
  55. package/src/Components/Skybox/Skybox.test.ts +83 -0
  56. package/src/Components/Spinner/Spinner.test.ts +30 -0
  57. package/src/Components/TwinStickShooter/TwinStickShooter.test.ts +105 -0
  58. package/templates/main.ts +27 -8
@@ -0,0 +1,223 @@
1
+ /**
2
+ * BulletSystem flight + collision tests. Bullets are normally pooled, but the
3
+ * System only needs live entities carrying a BulletComponent + MeshPrimitive,
4
+ * so these wire them directly and drive `world.update` to exercise the per-frame
5
+ * integration, lifetime/arena expiry, wall blocking, and enemy-hit path.
6
+ */
7
+ import { describe, it, expect, beforeEach } from 'vitest';
8
+ import { EventBus, World, MockRendererAdapter } from '@babylonjsmarket/ecs';
9
+ import { BulletComponent, BulletSystem, BulletEvents } from './Bullet';
10
+ import { MeshPrimitiveComponent } from '../MeshPrimitive/MeshPrimitive.core';
11
+ import { HealthInputEvents } from '../Health/Health';
12
+ import { ObstacleComponent } from '../Obstacle/Obstacle.core';
13
+
14
+ describe('BulletSystem', () => {
15
+ let eventBus: EventBus;
16
+ let world: World;
17
+ let renderer: MockRendererAdapter;
18
+
19
+ beforeEach(() => {
20
+ eventBus = new EventBus();
21
+ renderer = new MockRendererAdapter();
22
+ world = new World({ eventBus, renderer, detectRaces: false });
23
+ world.addSystem(new BulletSystem(eventBus));
24
+ world.initialize();
25
+ });
26
+
27
+ function makeBullet(pos: [number, number, number], extra: Record<string, unknown> = {}) {
28
+ const entity = world.createEntity();
29
+ const mesh = new MeshPrimitiveComponent({ primitive: 'sphere', diameter: 0.3, position: pos });
30
+ mesh.handle = renderer.createMesh(entity.id, mesh.toPrimitiveSpec());
31
+ entity.add(mesh);
32
+ entity.add(new BulletComponent({ direction: [1, 0, 0], speed: 10, lifetime: 1.2, ...extra }));
33
+ return entity;
34
+ }
35
+
36
+ function makeEnemy(pos: [number, number, number], diameter = 1.2) {
37
+ const entity = world.createEntity();
38
+ const mesh = new MeshPrimitiveComponent({ primitive: 'sphere', diameter, position: pos });
39
+ mesh.handle = renderer.createMesh(entity.id, mesh.toPrimitiveSpec());
40
+ entity.add(mesh);
41
+ entity.addTag('enemy');
42
+ return entity;
43
+ }
44
+
45
+ function makeWall(pos: [number, number, number], halfWidth: number, halfDepth: number, blocksBullets: boolean) {
46
+ const entity = world.createEntity();
47
+ entity.add(new MeshPrimitiveComponent({ primitive: 'box', position: pos }));
48
+ entity.add(new ObstacleComponent({ halfWidth, halfDepth, blocksBullets, blocksSight: blocksBullets }));
49
+ entity.addTag('obstacle');
50
+ return entity;
51
+ }
52
+
53
+ // A freshly-added bullet is a "newborn" skipped exactly once, so burn a frame.
54
+ function warmup() {
55
+ world.update(0.016);
56
+ }
57
+
58
+ it('advances a live bullet along its direction and commits the position', () => {
59
+ const bullet = makeBullet([0, 0.5, 0]);
60
+ warmup();
61
+ world.update(0.1); // 10 u/s * 0.1 = +1 in X
62
+
63
+ const m = bullet.get(MeshPrimitiveComponent)!;
64
+ expect(m.position[0]).toBeCloseTo(1, 5);
65
+ const committed = renderer.calls.some(
66
+ (c) => c.method === 'setMeshPosition' && c.args[0] === m.handle,
67
+ );
68
+ expect(committed).toBe(true);
69
+ });
70
+
71
+ it('expires a bullet once its lifetime runs out (released)', () => {
72
+ const bullet = makeBullet([0, 0.5, 0], { lifetime: 0.05, speed: 0 });
73
+ const id = bullet.id;
74
+ warmup();
75
+ world.update(0.1); // lifetime 0.05 - 0.1 <= 0 → expired → removed
76
+
77
+ expect(world.getEntity(id)).toBeUndefined();
78
+ });
79
+
80
+ it('expires a bullet that leaves the arena bounds', () => {
81
+ const bullet = makeBullet([0.9, 0.5, 0], {
82
+ arenaHalfWidth: 1,
83
+ arenaHalfDepth: 1,
84
+ speed: 10,
85
+ direction: [1, 0, 0],
86
+ });
87
+ const id = bullet.id;
88
+ warmup();
89
+ world.update(0.1); // x → 1.9 > arenaHalfWidth 1 → out of bounds → expired
90
+
91
+ expect(world.getEntity(id)).toBeUndefined();
92
+ });
93
+
94
+ it('expires a bullet that leaves the arena along the depth (Z) axis', () => {
95
+ const bullet = makeBullet([0, 0.5, 0.9], {
96
+ arenaHalfWidth: 1,
97
+ arenaHalfDepth: 1,
98
+ speed: 10,
99
+ direction: [0, 0, 1],
100
+ });
101
+ const id = bullet.id;
102
+ warmup();
103
+ world.update(0.1); // z → 1.9 > arenaHalfDepth 1 → out of bounds → expired
104
+
105
+ expect(world.getEntity(id)).toBeUndefined();
106
+ });
107
+
108
+ it('skips an enemy that has no MeshPrimitive when testing hits', () => {
109
+ // An enemy-tagged entity with no mesh must be skipped (the `!em` guard),
110
+ // and a normal enemy behind it still gets hit.
111
+ const ghost = world.createEntity();
112
+ ghost.addTag('enemy'); // no MeshPrimitive
113
+ const enemy = makeEnemy([1, 0.5, 0]);
114
+ const bullet = makeBullet([0.5, 0.5, 0], { speed: 5, direction: [1, 0, 0] });
115
+ let hits = 0;
116
+ eventBus.on(BulletEvents.HIT, () => hits++);
117
+ warmup();
118
+ world.update(0.1); // x: 0.5 → 1.0, lands on the real enemy
119
+
120
+ expect(hits).toBe(1);
121
+ void ghost;
122
+ void enemy;
123
+ });
124
+
125
+ it('stops at a full-height wall, shielding enemies behind it', () => {
126
+ // Wall straddling x=2; an enemy behind it at x=5. The bullet should be
127
+ // expired on the wall crossing before it can hit the enemy.
128
+ makeWall([2, 0.5, 0], 0.5, 2, true);
129
+ const enemy = makeEnemy([5, 0.5, 0]);
130
+ const bullet = makeBullet([0, 0.5, 0], { speed: 40, direction: [1, 0, 0] });
131
+ const id = bullet.id;
132
+ let damaged = false;
133
+ eventBus.on(HealthInputEvents.DAMAGE, () => (damaged = true));
134
+ warmup();
135
+ world.update(0.1); // x travels 0 → 4, crossing the wall segment at x≈1.5..2.5
136
+
137
+ expect(world.getEntity(id)).toBeUndefined(); // stopped by the wall
138
+ expect(damaged).toBe(false); // enemy behind it was shielded
139
+ void enemy;
140
+ });
141
+
142
+ it('passes through low cover that does not block bullets', () => {
143
+ // blocksBullets:false → the wall is filtered out of bulletWalls, so the
144
+ // bullet flies through and hits the enemy beyond it.
145
+ makeWall([2, 0.5, 0], 0.5, 2, false);
146
+ const enemy = makeEnemy([3, 0.5, 0]);
147
+ // Land the bullet exactly on the enemy this frame (speed*dt = 3) — the hit
148
+ // test is a point overlap at the bullet's committed position.
149
+ const bullet = makeBullet([0, 0.5, 0], { speed: 30, direction: [1, 0, 0] });
150
+ const hits: unknown[] = [];
151
+ eventBus.on(BulletEvents.HIT, (d) => hits.push(d));
152
+ warmup();
153
+ world.update(0.1); // x: 0 → 3, lands on the enemy past the low cover
154
+
155
+ expect(hits.length).toBe(1);
156
+ void enemy;
157
+ });
158
+
159
+ it('emits HIT (with shot direction) then DAMAGE on enemy overlap, and expires', () => {
160
+ const enemy = makeEnemy([1, 0.5, 0]);
161
+ const bullet = makeBullet([0.5, 0.5, 0], { speed: 10, direction: [1, 0, 0], damage: 3 });
162
+ const id = bullet.id;
163
+ const order: string[] = [];
164
+ let hit: Record<string, unknown> | undefined;
165
+ eventBus.on(BulletEvents.HIT, (d: Record<string, unknown>) => {
166
+ order.push('hit');
167
+ hit = d;
168
+ });
169
+ let damage: Record<string, unknown> | undefined;
170
+ eventBus.on(HealthInputEvents.DAMAGE, (d: Record<string, unknown>) => {
171
+ order.push('damage');
172
+ damage = d;
173
+ });
174
+ warmup();
175
+ world.update(0.1); // x: 0.5 → 1.5, sweeps onto the enemy at x=1
176
+
177
+ expect(order).toEqual(['hit', 'damage']); // HIT before DAMAGE
178
+ expect(hit).toMatchObject({ entityId: enemy.id, damage: 3, dirX: 1, dirZ: 0 });
179
+ expect(damage).toMatchObject({ entityId: enemy.id, damage: 3 });
180
+ expect(world.getEntity(id)).toBeUndefined(); // bullet consumed on hit
181
+ });
182
+
183
+ it('ignores parked and dying enemies (no hit on a corpse)', () => {
184
+ const parked = makeEnemy([1, 0.5, 0]);
185
+ parked.active = false; // pooled / inactive
186
+ const dying = makeEnemy([1, 0.5, 1]);
187
+ dying.addTag('dying');
188
+ const bullet = makeBullet([0.8, 0.5, 0.5], { speed: 4, direction: [0, 0, 0] });
189
+ let hits = 0;
190
+ eventBus.on(BulletEvents.HIT, () => hits++);
191
+ warmup();
192
+ world.update(0.1);
193
+
194
+ // Neither the parked nor the dying enemy can be hit.
195
+ expect(hits).toBe(0);
196
+ void parked;
197
+ void dying;
198
+ });
199
+
200
+ it('uses a fallback hit radius when the enemy mesh has no diameter', () => {
201
+ // diameter 0 → the system falls back to 0.6, widening the hit test.
202
+ const enemy = makeEnemy([1.4, 0.5, 0], 0);
203
+ const bullet = makeBullet([1, 0.5, 0], { speed: 0, direction: [1, 0, 0] });
204
+ let hits = 0;
205
+ eventBus.on(BulletEvents.HIT, () => hits++);
206
+ warmup();
207
+ world.update(0.1);
208
+ // dist 0.4 <= radius(0.3)+fallback(0.6)=0.9 → hit.
209
+ expect(hits).toBe(1);
210
+ void enemy;
211
+ });
212
+
213
+ it('skips bullets with no mesh handle', () => {
214
+ const entity = world.createEntity();
215
+ const mesh = new MeshPrimitiveComponent({ primitive: 'sphere', diameter: 0.3 });
216
+ // No handle assigned → the per-bullet guard skips it.
217
+ entity.add(mesh);
218
+ entity.add(new BulletComponent({ direction: [1, 0, 0], speed: 10 }));
219
+ warmup();
220
+ // Should not throw; the handle-less bullet is simply skipped.
221
+ expect(() => world.update(0.1)).not.toThrow();
222
+ });
223
+ });
@@ -7,7 +7,7 @@ import {
7
7
  BulletPoolInputEvents,
8
8
  } from './BulletPool';
9
9
  import { BulletComponent } from '../Bullet/Bullet';
10
- import { MeshPrimitiveSystem } from '../MeshPrimitive/MeshPrimitive';
10
+ import { MeshPrimitiveComponent, MeshPrimitiveSystem } from '../MeshPrimitive/MeshPrimitive';
11
11
 
12
12
  describe('BulletPoolComponent', () => {
13
13
  it('applies defaults', () => {
@@ -105,6 +105,55 @@ describe('BulletPoolSystem (World pool)', () => {
105
105
  expect(placed).toBe(true);
106
106
  });
107
107
 
108
+ it('applies a per-shot color override to the reused bullet mesh', () => {
109
+ makePool(4);
110
+ eventBus.emit(BulletPoolInputEvents.FIRE, { x: 0, y: 1, z: 0, dz: 1, color: [0.2, 0.4, 0.6] });
111
+
112
+ const bullet = live()[0]!;
113
+ const handle = bullet.get(MeshPrimitiveComponent)!.handle;
114
+ const colored = renderer.calls.some(
115
+ (c) =>
116
+ c.method === 'setMeshColor' &&
117
+ c.args[0] === handle &&
118
+ c.args[1] === 0.2 &&
119
+ c.args[2] === 0.4 &&
120
+ c.args[3] === 0.6,
121
+ );
122
+ expect(colored).toBe(true);
123
+ });
124
+
125
+ it('ignores FIRE before the pool has registered (guarded)', () => {
126
+ // No pool entity created yet → registered stays false.
127
+ eventBus.emit(BulletPoolInputEvents.FIRE, { x: 0, y: 1, z: 0, dz: 1 });
128
+ expect(allBullets().length).toBe(0);
129
+ });
130
+
131
+ it('FIRE is a no-op when the pool is exhausted (all slots live)', () => {
132
+ makePool(1);
133
+ eventBus.emit(BulletPoolInputEvents.FIRE, { x: 0, y: 1, z: 0, dz: 1 });
134
+ expect(live().length).toBe(1);
135
+ // A second shot recycles the oldest slot rather than allocating — still 1 live.
136
+ eventBus.emit(BulletPoolInputEvents.FIRE, { x: 5, y: 1, z: 0, dz: 1 });
137
+ expect(live().length).toBe(1);
138
+ expect(allBullets().length).toBe(1);
139
+ });
140
+
141
+ it('FIRE is a no-op for a zero-size pool (acquire returns nothing)', () => {
142
+ const entity = world.createEntity('Pool');
143
+ entity.add(new BulletPoolComponent({ poolSize: 0 }));
144
+ world.update(1 / 60); // registers an empty pool
145
+ eventBus.emit(BulletPoolInputEvents.FIRE, { x: 0, y: 1, z: 0, dz: 1 });
146
+ expect(live().length).toBe(0); // nothing to hand out → guarded
147
+ });
148
+
149
+ it('does not re-register the pool on a second tick', () => {
150
+ const ready = vi.fn();
151
+ eventBus.on(BulletPoolEvents.READY, ready);
152
+ makePool(2); // first tick registers + emits READY
153
+ world.update(1 / 60); // second tick: early-return on `registered`
154
+ expect(ready).toHaveBeenCalledTimes(1);
155
+ });
156
+
108
157
  it('removeEntity releases a fired bullet back to the pool (parked, reused)', () => {
109
158
  makePool(2);
110
159
  eventBus.emit(BulletPoolInputEvents.FIRE, { x: 0, y: 1, z: 0, dz: 1 });
@@ -109,4 +109,22 @@ describe('BumperSystem', () => {
109
109
  world.update(0.016);
110
110
  expect(hits).toHaveLength(1);
111
111
  });
112
+
113
+ it('skips a ball-tagged entity with no MeshPrimitiveComponent, still firing for a real ball', () => {
114
+ // branch 67: getEntitiesByTag('ball') returns the bare entity, but
115
+ // testBall bails (`if (!pm) return`). The real ball still scores.
116
+ makeBumper([0, 0, 0], { points: 100, ownerEntity: 'Player' });
117
+ const bare = world.createEntity();
118
+ bare.addTag('ball'); // tagged but no mesh
119
+ makeBall([0.5, 0, 0]); // real ball, inside the hit radius
120
+ world.update(0.016);
121
+ expect(hits).toHaveLength(1);
122
+ expect(hits[0].ballId).not.toBe(bare.id);
123
+ expect(scores).toEqual([{ ownerEntity: 'Player', points: 100 }]);
124
+ });
125
+
126
+ // Deliberately uncovered (defensive, unreachable here):
127
+ // - line 48 `if (!w) return` — world is always set on the system.
128
+ // - line 55 `if (!b || !bm) continue` — the query guarantees both
129
+ // BumperComponent + MeshPrimitive.
112
130
  });
@@ -73,4 +73,23 @@ describe('CameraFollow core', () => {
73
73
  const f = createCameraFollow();
74
74
  expect(f.getParams()).toEqual(DEFAULT_CAMERA_FOLLOW_PARAMS);
75
75
  });
76
+
77
+ it('reset() with no argument snaps back to the origin', () => {
78
+ const f = createCameraFollow({ smoothing: 100, offsetY: 0 }, { camX: 9, camY: 9, camZ: 9 });
79
+ f.reset();
80
+ const out = f.update({ targetX: 50, targetY: 50, targetZ: 50 }, 0);
81
+ expect(out.camX).toBe(0);
82
+ expect(out.camY).toBe(0);
83
+ expect(out.camZ).toBe(0);
84
+ });
85
+
86
+ it('reset() falls back to 0 for any axis omitted from the supplied position', () => {
87
+ const f = createCameraFollow({ smoothing: 100, offsetY: 0 }, { camX: 9, camY: 9, camZ: 9 });
88
+ // Only camX given; camY/camZ should default to 0.
89
+ f.reset({ camX: 7 } as { camX: number; camY: number; camZ: number });
90
+ const out = f.update({ targetX: 0, targetY: 0, targetZ: 0 }, 0);
91
+ expect(out.camX).toBe(7);
92
+ expect(out.camY).toBe(0);
93
+ expect(out.camZ).toBe(0);
94
+ });
76
95
  });
@@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach } from 'vitest';
2
2
  import { CameraFollowComponent, CameraFollowSystem } from './CameraFollow';
3
3
  import { ArcCameraComponent } from '../ArcCamera/ArcCamera';
4
4
  import { getArcCameraRuntime } from '../ArcCamera/ArcCamera.core';
5
+ import { MeshPrimitiveSystem, MeshPrimitiveComponent } from '../MeshPrimitive/MeshPrimitive';
5
6
  import { EventBus, World } from '@babylonjsmarket/ecs';
6
7
  import { MockRendererAdapter } from '@babylonjsmarket/ecs';
7
8
  import type { MeshHandle } from '@babylonjsmarket/ecs/renderer-types';
@@ -124,4 +125,95 @@ describe('CameraFollowSystem', () => {
124
125
  const target = renderer.cameraTargets.get(arcComp.handle!)!;
125
126
  expect(target[1]).toBeCloseTo(5, 2);
126
127
  });
128
+
129
+ it('does nothing while no ArcCamera handle exists in the world', () => {
130
+ // No ArcCamera at all → findArcCameraHandle returns undefined.
131
+ const fakeHandle = renderer.createMesh('Player', { kind: 'capsule' });
132
+ renderer.meshWorldPositions.set(fakeHandle as MeshHandle, [1, 1, 1]);
133
+ const followEntity = world.createEntity('Follow');
134
+ followEntity.add(new CameraFollowComponent({ target: 'Player' }));
135
+ eventBus.emit('meshprimitive.created', { entityId: 'Player', handle: fakeHandle });
136
+
137
+ renderer.calls.length = 0;
138
+ world.update(1 / 60);
139
+ expect(renderer.calls.some((c) => c.method === 'setCameraTarget')).toBe(false);
140
+ });
141
+
142
+ it('skips entities whose component carries no follow target', () => {
143
+ const arcEntity = world.createEntity('Camera');
144
+ const arcComp = new ArcCameraComponent();
145
+ arcComp.handle = renderer.createArcCamera(arcEntity.id, arcComp.toSpec());
146
+ getArcCameraRuntime(arcComp).initialized = true;
147
+ arcEntity.add(arcComp);
148
+
149
+ const followEntity = world.createEntity('Follow');
150
+ followEntity.add(new CameraFollowComponent()); // empty target ''
151
+
152
+ renderer.calls.length = 0;
153
+ world.update(1 / 60);
154
+ expect(renderer.calls.some((c) => c.method === 'setCameraTarget')).toBe(false);
155
+ });
156
+
157
+ it('picks up a mesh handle already created before the system initializes', () => {
158
+ // Build a fresh world where the target mesh exists BEFORE CameraFollowSystem
159
+ // is added — exercising the existing-entity scan in onInitialize.
160
+ const bus = new EventBus();
161
+ const r = new MockRendererAdapter();
162
+ const w = new World({ eventBus: bus, renderer: r });
163
+ w.addSystem(new MeshPrimitiveSystem(bus));
164
+
165
+ const arcEntity = w.createEntity('Camera');
166
+ const arcComp = new ArcCameraComponent();
167
+ arcComp.handle = r.createArcCamera(arcEntity.id, arcComp.toSpec());
168
+ getArcCameraRuntime(arcComp).initialized = true;
169
+ arcEntity.add(arcComp);
170
+
171
+ const player = w.createEntity('Player');
172
+ player.add(new MeshPrimitiveComponent());
173
+ const mp = player.get(MeshPrimitiveComponent)!;
174
+ r.meshWorldPositions.set(mp.handle as MeshHandle, [4, 0, 2]);
175
+
176
+ const follow = w.createEntity('Follow');
177
+ follow.add(new CameraFollowComponent({ target: 'Player', smoothing: 1000, offsetX: 0, offsetY: 0, offsetZ: 0 }));
178
+
179
+ // System added AFTER the mesh handle already exists.
180
+ w.addSystem(new CameraFollowSystem(bus));
181
+ w.initialize();
182
+
183
+ r.calls.length = 0;
184
+ w.update(1 / 60);
185
+ const setCalls = r.calls.filter((c) => c.method === 'setCameraTarget');
186
+ expect(setCalls.length).toBe(1);
187
+ const tgt = r.cameraTargets.get(arcComp.handle!)!;
188
+ expect(tgt[0]).toBeCloseTo(4, 3);
189
+ expect(tgt[2]).toBeCloseTo(2, 3);
190
+ });
191
+
192
+ it('no-ops on a renderer-less world', () => {
193
+ const bus = new EventBus();
194
+ const w = new World({ eventBus: bus });
195
+ w.addSystem(new CameraFollowSystem(bus));
196
+ w.initialize();
197
+ const e = w.createEntity('Follow');
198
+ e.add(new CameraFollowComponent({ target: 'Player' }));
199
+ expect(() => w.update(1 / 60)).not.toThrow();
200
+ });
201
+
202
+ it('clears its primed flag when an entity is removed', () => {
203
+ const arcEntity = world.createEntity('Camera');
204
+ const arcComp = new ArcCameraComponent();
205
+ arcComp.handle = renderer.createArcCamera(arcEntity.id, arcComp.toSpec());
206
+ getArcCameraRuntime(arcComp).initialized = true;
207
+ arcEntity.add(arcComp);
208
+
209
+ const fakeHandle = renderer.createMesh('Player', { kind: 'capsule' });
210
+ renderer.meshWorldPositions.set(fakeHandle as MeshHandle, [0, 0, 0]);
211
+ const followEntity = world.createEntity('Follow');
212
+ followEntity.add(new CameraFollowComponent({ target: 'Player' }));
213
+ eventBus.emit('meshprimitive.created', { entityId: 'Player', handle: fakeHandle });
214
+
215
+ world.update(1 / 60);
216
+ // Removing the follow entity must not throw and should clear primed state.
217
+ expect(() => world.removeEntity(followEntity)).not.toThrow();
218
+ });
127
219
  });
@@ -28,6 +28,11 @@ describe('DirectionalLightComponent (core)', () => {
28
28
  expect(c.direction[2]).toBeCloseTo(0);
29
29
  });
30
30
 
31
+ it('falls back to straight-down for a zero-length direction', () => {
32
+ const c = new DirectionalLightComponent({ direction: [0, 0, 0] });
33
+ expect(c.direction).toEqual([0, -1, 0]);
34
+ });
35
+
31
36
  it('accepts full configuration', () => {
32
37
  const c = new DirectionalLightComponent({
33
38
  direction: [-1, -3, -1],
@@ -182,4 +187,47 @@ describe('DirectionalLightSystem — adapter integration', () => {
182
187
  world.removeEntity(entity);
183
188
  expect(renderer.calls.some((c) => c.method === 'disposeLight')).toBe(true);
184
189
  });
190
+
191
+ it('creates lights for entities that exist before the system initializes', () => {
192
+ const bus = new EventBus();
193
+ const r = new MockRendererAdapter();
194
+ const w = new World({ eventBus: bus, renderer: r });
195
+ const entity = w.createEntity();
196
+ entity.add(new DirectionalLightComponent({ intensity: 0.7 }));
197
+ // System added AFTER the entity exists → onInitialize scans and creates it.
198
+ w.addSystem(new DirectionalLightSystem(bus));
199
+ w.initialize();
200
+ const call = r.calls.find((c) => c.method === 'createDirectionalLight');
201
+ expect(call).toBeDefined();
202
+ expect((call!.args[1] as { intensity: number }).intensity).toBe(0.7);
203
+ });
204
+
205
+ it('does not emit when an unchanged light ticks (no snapshot drift)', () => {
206
+ const entity = world.createEntity();
207
+ entity.add(new DirectionalLightComponent());
208
+ renderer.calls.length = 0;
209
+ // Nothing mutated → onUpdate must not push any change to the adapter.
210
+ world.update(0.016);
211
+ expect(renderer.calls.some((c) => c.method === 'updateLightIntensity')).toBe(false);
212
+ });
213
+
214
+ it('no-ops on a renderer-less world (never creates a light)', () => {
215
+ const bus = new EventBus();
216
+ const w = new World({ eventBus: bus });
217
+ w.addSystem(new DirectionalLightSystem(bus));
218
+ w.initialize();
219
+ const entity = w.createEntity();
220
+ expect(() => entity.add(new DirectionalLightComponent())).not.toThrow();
221
+ expect(() => w.update(0.016)).not.toThrow();
222
+ });
223
+
224
+ it('skips the update loop entirely for an un-created (autoCreate:false) light', () => {
225
+ const entity = world.createEntity();
226
+ const comp = new DirectionalLightComponent({ autoCreate: false });
227
+ entity.add(comp);
228
+ comp.intensity = 5; // would emit if it had a handle/snapshot
229
+ renderer.calls.length = 0;
230
+ world.update(0.016);
231
+ expect(renderer.calls.some((c) => c.method === 'updateLightIntensity')).toBe(false);
232
+ });
185
233
  });
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from 'vitest';
2
- import { EnemyComponent, wanderHeading, allDeathClips, chooseDeathClip } from './Enemy.core';
2
+ import { EnemyComponent, wanderHeading, allDeathClips, chooseDeathClip, decideLocomotion } from './Enemy.core';
3
3
 
4
4
  describe('EnemyComponent', () => {
5
5
  it('applies defaults', () => {
@@ -78,6 +78,25 @@ describe('allDeathClips', () => {
78
78
  });
79
79
  });
80
80
 
81
+ describe('decideLocomotion', () => {
82
+ it('attacks within contact range', () => {
83
+ expect(decideLocomotion(0.5, 1, 'walk', 1.15)).toBe('attack');
84
+ // boundary: distance === contactRange is still attack (<=)
85
+ expect(decideLocomotion(1, 1, 'walk', 1.15)).toBe('attack');
86
+ });
87
+
88
+ it('walks once past the hysteresis-scaled exit threshold', () => {
89
+ // contactRange*hysteresis = 1.15; just beyond → walk
90
+ expect(decideLocomotion(1.2, 1, 'attack', 1.15)).toBe('walk');
91
+ });
92
+
93
+ it('holds the current state inside the dead-band (anti-flicker)', () => {
94
+ // Between contactRange (1) and contactRange*hysteresis (1.15): keep current.
95
+ expect(decideLocomotion(1.1, 1, 'attack', 1.15)).toBe('attack');
96
+ expect(decideLocomotion(1.1, 1, 'walk', 1.15)).toBe('walk');
97
+ });
98
+ });
99
+
81
100
  describe('chooseDeathClip', () => {
82
101
  const back = ['Shot_and_Blown_Back', 'Walking_Blow_Back'];
83
102
  const fwd = ['Fall_Forward'];