@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.
- package/dist/cli/eject.d.ts +24 -1
- package/dist/cli/eject.d.ts.map +1 -1
- package/dist/cli/eject.js +27 -22
- package/dist/cli/eject.js.map +1 -1
- package/package.json +3 -2
- package/src/Components/Animation/Animation.core.test.ts +11 -0
- package/src/Components/Animation/Animation.test.ts +26 -0
- package/src/Components/ArcCamera/ArcCamera.test.ts +168 -0
- package/src/Components/Bullet/Bullet.test.ts +223 -0
- package/src/Components/BulletPool/BulletPool.test.ts +50 -1
- package/src/Components/Bumper/Bumper.test.ts +18 -0
- package/src/Components/CameraFollow/CameraFollow.core.test.ts +19 -0
- package/src/Components/CameraFollow/CameraFollow.test.ts +92 -0
- package/src/Components/DirectionalLight/DirectionalLight.test.ts +48 -0
- package/src/Components/Enemy/Enemy.core.test.ts +20 -1
- package/src/Components/Enemy/Enemy.test.ts +456 -1
- package/src/Components/EnemySpawner/EnemySpawner.test.ts +155 -0
- package/src/Components/EntityPool/EntityPool.test.ts +95 -1
- package/src/Components/EnvironmentTexture/EnvironmentTexture.test.ts +31 -0
- package/src/Components/Flash/Flash.test.ts +44 -0
- package/src/Components/Flipper/Flipper.core.test.ts +9 -0
- package/src/Components/Flipper/Flipper.test.ts +63 -0
- package/src/Components/Health/Health.test.ts +184 -0
- package/src/Components/HealthBar/HealthBar.test.ts +158 -0
- package/src/Components/HemisphericLight/HemisphericLight.test.ts +36 -0
- package/src/Components/KeyboardMover/KeyboardMover.test.ts +120 -0
- package/src/Components/LineOfSight/LineOfSight.core.test.ts +26 -0
- package/src/Components/LineOfSight/LineOfSight.test.ts +38 -0
- package/src/Components/Mesh/Mesh.test.ts +201 -0
- package/src/Components/MeshPrimitive/MeshPrimitive.test.ts +222 -0
- package/src/Components/MouseHole/MouseHole.test.ts +40 -0
- package/src/Components/Movement/Movement.core.test.ts +50 -0
- package/src/Components/Movement/Movement.test.ts +73 -0
- package/src/Components/Obstacle/Obstacle.core.test.ts +82 -0
- package/src/Components/Obstacle/Obstacle.test.ts +72 -0
- package/src/Components/ObstacleField/ObstacleField.test.ts +8 -0
- package/src/Components/Physics/Physics.core.test.ts +48 -0
- package/src/Components/Physics/Physics.test.ts +101 -0
- package/src/Components/PinballBuilder/PinballBuilder.test.ts +129 -0
- package/src/Components/PinballBuilderInput/PinballBuilderInput.test.ts +76 -0
- package/src/Components/PinballCamera/PinballCamera.test.ts +39 -0
- package/src/Components/PinballLayout/PinballLayout.test.ts +82 -0
- package/src/Components/PinballTable/PinballTable.test.ts +91 -0
- package/src/Components/PlayerInput/PlayerInput.core.test.ts +56 -0
- package/src/Components/PlayerInput/PlayerInput.test.ts +139 -0
- package/src/Components/Plunger/Plunger.test.ts +55 -0
- package/src/Components/Score/Score.test.ts +60 -0
- package/src/Components/Scoreboard/Scoreboard.core.test.ts +12 -0
- package/src/Components/Scoreboard/Scoreboard.test.ts +36 -0
- package/src/Components/Shadow/Shadow.test.ts +69 -0
- package/src/Components/ShooterCamera/ShooterCamera.test.ts +11 -0
- package/src/Components/SkeletonAnimator/SkeletonAnimator.test.ts +182 -0
- package/src/Components/Skybox/Skybox.test.ts +83 -0
- package/src/Components/Spinner/Spinner.test.ts +30 -0
- package/src/Components/TwinStickShooter/TwinStickShooter.test.ts +105 -0
- 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
|
});
|