@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
|
@@ -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'];
|