@loonylabs/create-game 0.1.0
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/bin/create-game.js +213 -0
- package/package.json +18 -0
- package/template/.claude/skills/aigdtk-create-game-stories/SKILL.md +177 -0
- package/template/.claude/skills/aigdtk-create-game-stories/story-template.md +85 -0
- package/template/.claude/skills/aigdtk-implement-game-stories/SKILL.md +129 -0
- package/template/.claude/skills/aigdtk-new-game/SKILL.md +126 -0
- package/template/.claude/skills/aigdtk-shared/ascii-grammar.md +133 -0
- package/template/.claude/skills/aigdtk-shared/enemies.md +112 -0
- package/template/.claude/skills/aigdtk-shared/framework.md +93 -0
- package/template/.claude/skills/aigdtk-shared/visuals.md +125 -0
- package/template/apps/client/index.html +14 -0
- package/template/apps/client/package.json +31 -0
- package/template/apps/client/public/assets/audio/enemy_killed.wav +0 -0
- package/template/apps/client/src/components/App.svelte +290 -0
- package/template/apps/client/src/components/CraftingPanel.svelte +253 -0
- package/template/apps/client/src/components/DevPanel.svelte +180 -0
- package/template/apps/client/src/components/DungeonClearOverlay.svelte +53 -0
- package/template/apps/client/src/components/EquipmentPanel.svelte +191 -0
- package/template/apps/client/src/components/HealthBar.svelte +50 -0
- package/template/apps/client/src/components/Hub/BackToHubButton.svelte +37 -0
- package/template/apps/client/src/components/Hub/ExperienceCard.svelte +115 -0
- package/template/apps/client/src/components/Hub/Hub.svelte +88 -0
- package/template/apps/client/src/components/Inventory.svelte +174 -0
- package/template/apps/client/src/components/Runner/RunnerDeathScreen.svelte +182 -0
- package/template/apps/client/src/components/Runner/RunnerHUD.svelte +157 -0
- package/template/apps/client/src/components/Shooter/DamageNumbers.svelte +96 -0
- package/template/apps/client/src/components/Shooter/GameOverScreen.svelte +109 -0
- package/template/apps/client/src/components/Shooter/ShooterHUD.svelte +95 -0
- package/template/apps/client/src/components/SkillBar.svelte +146 -0
- package/template/apps/client/src/components/ToastSystem.svelte +158 -0
- package/template/apps/client/src/game.ts +918 -0
- package/template/apps/client/src/input.ts +92 -0
- package/template/apps/client/src/lib/audio/CombatDetector.test.ts +59 -0
- package/template/apps/client/src/lib/audio/CombatDetector.ts +53 -0
- package/template/apps/client/src/lib/audio/MusicManager.ts +137 -0
- package/template/apps/client/src/lib/audio/SoundManager.ts +59 -0
- package/template/apps/client/src/lib/audio/index.ts +9 -0
- package/template/apps/client/src/main.ts +32 -0
- package/template/apps/client/src/renderer/basecamp.ts +126 -0
- package/template/apps/client/src/renderer/dungeon.ts +250 -0
- package/template/apps/client/src/renderer/dungeonPortal.ts +73 -0
- package/template/apps/client/src/renderer/dungeonZone.ts +301 -0
- package/template/apps/client/src/renderer/entities.ts +197 -0
- package/template/apps/client/src/renderer/runnerTrack.ts +221 -0
- package/template/apps/client/src/renderer/shaders/SkyShader.ts +190 -0
- package/template/apps/client/src/renderer/shaders/TerrainShaderMaterial.ts +133 -0
- package/template/apps/client/src/renderer/shaders/floor.frag.glsl.ts +17 -0
- package/template/apps/client/src/renderer/shaders/shaderConfig.ts +18 -0
- package/template/apps/client/src/renderer/shaders/spawn.frag.glsl.ts +19 -0
- package/template/apps/client/src/renderer/shaders/terrain.frag.glsl.ts +314 -0
- package/template/apps/client/src/renderer/shaders/terrain.vert.glsl.ts +16 -0
- package/template/apps/client/src/renderer/shaders/wall.frag.glsl.ts +20 -0
- package/template/apps/client/src/renderer/shooterArena.ts +102 -0
- package/template/apps/client/src/renderer/voxelChunkStreamer.ts +79 -0
- package/template/apps/client/src/renderer/voxelMesh.ts +86 -0
- package/template/apps/client/src/renderer/voxelTerrain.ts +74 -0
- package/template/apps/client/src/socket.ts +268 -0
- package/template/apps/client/src/store.ts +74 -0
- package/template/apps/client/src/style.css +60 -0
- package/template/apps/client/tsconfig.json +11 -0
- package/template/apps/client/vite.config.ts +10 -0
- package/template/apps/client/vitest.config.ts +8 -0
- package/template/apps/experiences/diablo/index.ts +94 -0
- package/template/apps/experiences/diablo/systems/dungeonClearSystem.ts +60 -0
- package/template/apps/experiences/diablo/systems/enemyAISystem.ts +11 -0
- package/template/apps/experiences/diablo/systems/entitySyncSystem.ts +80 -0
- package/template/apps/experiences/diablo/systems/itemPickupSystem.ts +11 -0
- package/template/apps/experiences/diablo/systems/movementSystem.ts +13 -0
- package/template/apps/experiences/diablo/systems/physicsSystem.ts +92 -0
- package/template/apps/experiences/diablo/systems/transitionSystem.ts +105 -0
- package/template/apps/experiences/runner/data/runner-config.ts +54 -0
- package/template/apps/experiences/runner/index.ts +143 -0
- package/template/apps/experiences/runner/systems/collectibleSystem.ts +157 -0
- package/template/apps/experiences/runner/systems/deathSystem.ts +42 -0
- package/template/apps/experiences/runner/systems/entitySyncSystem.ts +59 -0
- package/template/apps/experiences/runner/systems/obstacleSystem.ts +91 -0
- package/template/apps/experiences/runner/systems/runnerPhysicsSystem.ts +82 -0
- package/template/apps/experiences/runner/systems/trackStreamSystem.ts +19 -0
- package/template/apps/experiences/runner/track/laneSystem.ts +53 -0
- package/template/apps/experiences/runner/track/segmentTypes.ts +141 -0
- package/template/apps/experiences/runner/track/trackGenerator.ts +292 -0
- package/template/apps/experiences/shooter/ai/aiStateMachine.ts +394 -0
- package/template/apps/experiences/shooter/ai/lineOfSight.ts +32 -0
- package/template/apps/experiences/shooter/arena/arenaGenerator.ts +101 -0
- package/template/apps/experiences/shooter/arena/arenaTypes.ts +49 -0
- package/template/apps/experiences/shooter/arena/hitscan.ts +101 -0
- package/template/apps/experiences/shooter/data/enemy-types.ts +108 -0
- package/template/apps/experiences/shooter/data/wave-definitions.ts +40 -0
- package/template/apps/experiences/shooter/data/weapon-config.ts +80 -0
- package/template/apps/experiences/shooter/index.ts +127 -0
- package/template/apps/experiences/shooter/systems/enemyAISystem.ts +113 -0
- package/template/apps/experiences/shooter/systems/entitySyncSystem.ts +68 -0
- package/template/apps/experiences/shooter/systems/shooterPhysicsSystem.ts +89 -0
- package/template/apps/experiences/shooter/systems/waveSpawnerSystem.ts +87 -0
- package/template/apps/experiences/shooter/systems/weaponSystem.ts +157 -0
- package/template/apps/game-data/src/areas/area-manifest.json +18 -0
- package/template/apps/game-data/src/assets/migration.test.ts +291 -0
- package/template/apps/game-data/src/audio/music-config.json +21 -0
- package/template/apps/game-data/src/audio/sound-config.json +11 -0
- package/template/apps/game-data/src/combat/action-types.ts +2 -0
- package/template/apps/game-data/src/combat/enemy-def.ts +12 -0
- package/template/apps/game-data/src/combat/hitboxes.ts +23 -0
- package/template/apps/game-data/src/dungeon/cell-types.ts +20 -0
- package/template/apps/game-data/src/dungeon/cell-visuals.ts +13 -0
- package/template/apps/game-data/src/dungeon/door-directions.ts +2 -0
- package/template/apps/game-data/src/enemies/enemy-defs.json +32 -0
- package/template/apps/game-data/src/equipment/slots.json +5 -0
- package/template/apps/game-data/src/events/event-defs.ts +20 -0
- package/template/apps/game-data/src/events/event-types.ts +10 -0
- package/template/apps/game-data/src/events/toast-config.json +49 -0
- package/template/apps/game-data/src/items/item-pool.json +13 -0
- package/template/apps/game-data/src/loot/item-pool.ts +14 -0
- package/template/apps/game-data/src/loot/rarities.ts +2 -0
- package/template/apps/game-data/src/physics/dungeon-physics-config.ts +12 -0
- package/template/apps/game-data/src/physics/jump-config.ts +17 -0
- package/template/apps/game-data/src/recipes/recipe-book.json +68 -0
- package/template/apps/game-data/src/rooms/room_basecamp.json +16 -0
- package/template/apps/game-data/src/rooms/room_corridor_ew.json +9 -0
- package/template/apps/game-data/src/rooms/room_corridor_ns.json +11 -0
- package/template/apps/game-data/src/rooms/room_crossroads.json +11 -0
- package/template/apps/game-data/src/rooms/room_dead_end.json +10 -0
- package/template/apps/game-data/src/rooms/room_staircase.json +12 -0
- package/template/apps/game-data/src/rooms/room_start.json +11 -0
- package/template/apps/game-data/src/skills/skill-book.json +20 -0
- package/template/apps/game-data/src/voxel/biome-terrain.ts +76 -0
- package/template/apps/game-data/src/voxel/materials.ts +45 -0
- package/template/apps/game-data/src/voxel/sandbox-terrain-config.ts +19 -0
- package/template/apps/game-data/src/world/area-config.ts +33 -0
- package/template/apps/game-data/src/world/biome-def.ts +15 -0
- package/template/apps/game-data/src/world/biomes.json +57 -0
- package/template/apps/game-data/src/world/movement.ts +2 -0
- package/template/apps/game-data/src/world/overworld-layout.test.ts +93 -0
- package/template/apps/game-data/src/world/overworld-layout.ts +127 -0
- package/template/apps/server/data/game.db +0 -0
- package/template/apps/server/package.json +30 -0
- package/template/apps/server/src/areaManager.ts +346 -0
- package/template/apps/server/src/db/client.ts +45 -0
- package/template/apps/server/src/db/schema.ts +40 -0
- package/template/apps/server/src/gameLoop.ts +267 -0
- package/template/apps/server/src/gameState.ts +3 -0
- package/template/apps/server/src/handlers/actionEvent.ts +55 -0
- package/template/apps/server/src/handlers/craftHandler.ts +59 -0
- package/template/apps/server/src/handlers/equipHandler.ts +73 -0
- package/template/apps/server/src/handlers/raycastHandler.ts +97 -0
- package/template/apps/server/src/handlers/skillHandler.ts +87 -0
- package/template/apps/server/src/handlers/terraformHandler.ts +74 -0
- package/template/apps/server/src/index.ts +597 -0
- package/template/apps/server/src/persistence.ts +135 -0
- package/template/apps/server/src/rooms.ts +20 -0
- package/template/apps/server/src/systems/dungeonPhysics.test.ts +32 -0
- package/template/apps/server/src/systems/dungeonPhysics.ts +16 -0
- package/template/apps/server/src/systems/enemyAI.ts +129 -0
- package/template/apps/server/src/systems/itemPickup.ts +31 -0
- package/template/apps/server/src/tests/areaManager.test.ts +77 -0
- package/template/apps/server/src/tests/diablo-experience.test.ts +60 -0
- package/template/apps/server/src/tests/runner-experience.test.ts +273 -0
- package/template/apps/server/src/tests/runner-powerups-scoring.test.ts +221 -0
- package/template/apps/server/src/tests/server.integration.test.ts +92 -0
- package/template/apps/server/src/tests/shooter-enemy-ai.test.ts +328 -0
- package/template/apps/server/src/tests/shooter-experience.test.ts +281 -0
- package/template/apps/server/src/tests/voxelChunkCache.test.ts +29 -0
- package/template/apps/server/src/tests/voxelSandbox.test.ts +133 -0
- package/template/apps/server/src/voxelChunkCache.ts +31 -0
- package/template/apps/server/src/voxelPlayerState.ts +23 -0
- package/template/apps/server/tsconfig.json +17 -0
- package/template/apps/server/vitest.config.ts +8 -0
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
import { describe, test, expect } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
createShooterEnemy,
|
|
4
|
+
updateShooterEnemyAI,
|
|
5
|
+
damageEnemy,
|
|
6
|
+
type AIUpdateContext,
|
|
7
|
+
type ShooterEnemy,
|
|
8
|
+
} from '../../../experiences/shooter/ai/aiStateMachine.js';
|
|
9
|
+
import { hasLineOfSight, distance3D } from '../../../experiences/shooter/ai/lineOfSight.js';
|
|
10
|
+
import { getWaveDefinition, WAVE_DEFINITIONS } from '../../../experiences/shooter/data/wave-definitions.js';
|
|
11
|
+
import { ENEMY_CONFIGS } from '../../../experiences/shooter/data/enemy-types.js';
|
|
12
|
+
import { generateArena, getArenaObstacles } from '../../../experiences/shooter/arena/arenaGenerator.js';
|
|
13
|
+
import { createPhysicsBody } from '../../../../packages/core/src/physics/gravity.js';
|
|
14
|
+
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Test Helpers
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
function createTestArena() {
|
|
20
|
+
return generateArena(42);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function createTestContext(
|
|
24
|
+
playerX: number = 10,
|
|
25
|
+
playerZ: number = 0,
|
|
26
|
+
playerHp: number = 100,
|
|
27
|
+
): AIUpdateContext {
|
|
28
|
+
const arena = createTestArena();
|
|
29
|
+
const players = new Map([
|
|
30
|
+
['p1', {
|
|
31
|
+
socketId: 'p1',
|
|
32
|
+
body: createPhysicsBody(playerX, 0, playerZ),
|
|
33
|
+
hp: playerHp,
|
|
34
|
+
}],
|
|
35
|
+
]);
|
|
36
|
+
return {
|
|
37
|
+
players,
|
|
38
|
+
obstacles: getArenaObstacles(arena),
|
|
39
|
+
arena,
|
|
40
|
+
enemyShots: [],
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
// Enemy Creation
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
describe('createShooterEnemy', () => {
|
|
49
|
+
test('creates enemy with correct type and health', () => {
|
|
50
|
+
const enemy = createShooterEnemy(1, 'rusher', { x: 0, y: 0, z: 0 }, 42);
|
|
51
|
+
expect(enemy.entityId).toBe(1);
|
|
52
|
+
expect(enemy.enemyType).toBe('rusher');
|
|
53
|
+
expect(enemy.health).toBe(ENEMY_CONFIGS.rusher.health);
|
|
54
|
+
expect(enemy.maxHealth).toBe(ENEMY_CONFIGS.rusher.health);
|
|
55
|
+
expect(enemy.alive).toBe(true);
|
|
56
|
+
expect(enemy.aiState).toBe('idle');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('creates different types with different configs', () => {
|
|
60
|
+
const rusher = createShooterEnemy(1, 'rusher', { x: 0, y: 0, z: 0 }, 42);
|
|
61
|
+
const sniper = createShooterEnemy(2, 'sniper', { x: 0, y: 0, z: 0 }, 42);
|
|
62
|
+
expect(rusher.health).not.toBe(sniper.health);
|
|
63
|
+
expect(rusher.config.attackRange).toBeLessThan(sniper.config.attackRange);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
// AI State Machine
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
describe('ShooterEnemyAI', () => {
|
|
72
|
+
test('idle enemy detects nearby player', () => {
|
|
73
|
+
const enemy = createShooterEnemy(1, 'rusher', { x: 0, y: 0, z: 0 }, 42);
|
|
74
|
+
const ctx = createTestContext(10, 0); // Player at 10 units away
|
|
75
|
+
|
|
76
|
+
// Run enough ticks to detect
|
|
77
|
+
for (let i = 0; i < 5; i++) {
|
|
78
|
+
updateShooterEnemyAI(enemy, 0.05, ctx);
|
|
79
|
+
}
|
|
80
|
+
expect(enemy.aiState).not.toBe('idle');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('idle enemy does not detect distant player', () => {
|
|
84
|
+
const enemy = createShooterEnemy(1, 'rusher', { x: -15, y: 0, z: -15 }, 42);
|
|
85
|
+
// Player far away (distance ~35, beyond rusher detection of 15)
|
|
86
|
+
const ctx = createTestContext(15, 15);
|
|
87
|
+
updateShooterEnemyAI(enemy, 0.05, ctx);
|
|
88
|
+
expect(enemy.aiState).toBe('idle');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('enemy transitions from detect to chase after reaction time', () => {
|
|
92
|
+
const enemy = createShooterEnemy(1, 'rusher', { x: 0, y: 0, z: 0 }, 42);
|
|
93
|
+
enemy.aiState = 'detect';
|
|
94
|
+
enemy.targetPlayerId = 'p1';
|
|
95
|
+
enemy.stateTimer = 0;
|
|
96
|
+
|
|
97
|
+
const ctx = createTestContext(10, 0);
|
|
98
|
+
|
|
99
|
+
// Tick past reaction time
|
|
100
|
+
for (let i = 0; i < 20; i++) {
|
|
101
|
+
updateShooterEnemyAI(enemy, 0.05, ctx);
|
|
102
|
+
}
|
|
103
|
+
expect(['chase', 'attack']).toContain(enemy.aiState);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('rusher chases toward player', () => {
|
|
107
|
+
const enemy = createShooterEnemy(1, 'rusher', { x: 0, y: 0, z: 0 }, 42);
|
|
108
|
+
enemy.aiState = 'chase';
|
|
109
|
+
enemy.targetPlayerId = 'p1';
|
|
110
|
+
|
|
111
|
+
const ctx = createTestContext(10, 0);
|
|
112
|
+
|
|
113
|
+
// Run several ticks
|
|
114
|
+
for (let i = 0; i < 10; i++) {
|
|
115
|
+
updateShooterEnemyAI(enemy, 0.05, ctx);
|
|
116
|
+
}
|
|
117
|
+
// Should have moved toward player (positive X)
|
|
118
|
+
expect(enemy.body.x).toBeGreaterThan(0);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('enemy switches to attack when in range with LoS', () => {
|
|
122
|
+
const enemy = createShooterEnemy(1, 'rusher', { x: 0, y: 0, z: 0 }, 42);
|
|
123
|
+
enemy.aiState = 'chase';
|
|
124
|
+
enemy.targetPlayerId = 'p1';
|
|
125
|
+
|
|
126
|
+
// Player very close (within attack range of 6)
|
|
127
|
+
const ctx = createTestContext(3, 0);
|
|
128
|
+
|
|
129
|
+
updateShooterEnemyAI(enemy, 0.05, ctx);
|
|
130
|
+
expect(enemy.aiState).toBe('attack');
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('patrol retreats at low HP', () => {
|
|
134
|
+
const enemy = createShooterEnemy(1, 'patrol', { x: 0, y: 0, z: 0 }, 42);
|
|
135
|
+
enemy.aiState = 'attack';
|
|
136
|
+
enemy.targetPlayerId = 'p1';
|
|
137
|
+
enemy.health = 5; // Very low, below 20% threshold
|
|
138
|
+
|
|
139
|
+
const ctx = createTestContext(5, 0);
|
|
140
|
+
updateShooterEnemyAI(enemy, 0.05, ctx);
|
|
141
|
+
expect(enemy.aiState).toBe('retreat');
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('rusher never retreats', () => {
|
|
145
|
+
const enemy = createShooterEnemy(1, 'rusher', { x: 0, y: 0, z: 0 }, 42);
|
|
146
|
+
enemy.aiState = 'attack';
|
|
147
|
+
enemy.targetPlayerId = 'p1';
|
|
148
|
+
enemy.health = 1;
|
|
149
|
+
|
|
150
|
+
const ctx = createTestContext(3, 0);
|
|
151
|
+
updateShooterEnemyAI(enemy, 0.05, ctx);
|
|
152
|
+
expect(enemy.aiState).not.toBe('retreat');
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test('enemy shoots when attacking and generates shot intent', () => {
|
|
156
|
+
const enemy = createShooterEnemy(1, 'rusher', { x: 0, y: 0, z: 0 }, 42);
|
|
157
|
+
enemy.aiState = 'attack';
|
|
158
|
+
enemy.targetPlayerId = 'p1';
|
|
159
|
+
|
|
160
|
+
// Player close with clear LoS (no obstacles between them)
|
|
161
|
+
const arena = generateArena(42);
|
|
162
|
+
const players = new Map([
|
|
163
|
+
['p1', { socketId: 'p1', body: createPhysicsBody(3, 0, 0), hp: 100 }],
|
|
164
|
+
]);
|
|
165
|
+
// Use empty obstacles so LoS is guaranteed
|
|
166
|
+
const ctx: AIUpdateContext = { players, obstacles: [], arena, enemyShots: [] };
|
|
167
|
+
|
|
168
|
+
// Collect all shots over multiple ticks
|
|
169
|
+
const allShots: typeof ctx.enemyShots = [];
|
|
170
|
+
for (let i = 0; i < 40; i++) {
|
|
171
|
+
ctx.enemyShots = [];
|
|
172
|
+
updateShooterEnemyAI(enemy, 0.05, ctx);
|
|
173
|
+
allShots.push(...ctx.enemyShots);
|
|
174
|
+
}
|
|
175
|
+
// Should have produced at least one shot
|
|
176
|
+
expect(allShots.length).toBeGreaterThan(0);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test('dead enemy is not updated', () => {
|
|
180
|
+
const enemy = createShooterEnemy(1, 'rusher', { x: 0, y: 0, z: 0 }, 42);
|
|
181
|
+
enemy.alive = false;
|
|
182
|
+
const initialState = enemy.aiState;
|
|
183
|
+
|
|
184
|
+
const ctx = createTestContext(3, 0);
|
|
185
|
+
updateShooterEnemyAI(enemy, 0.05, ctx);
|
|
186
|
+
expect(enemy.aiState).toBe(initialState);
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
// Damage
|
|
192
|
+
// ---------------------------------------------------------------------------
|
|
193
|
+
|
|
194
|
+
describe('damageEnemy', () => {
|
|
195
|
+
test('reduces health', () => {
|
|
196
|
+
const enemy = createShooterEnemy(1, 'rusher', { x: 0, y: 0, z: 0 }, 42);
|
|
197
|
+
const killed = damageEnemy(enemy, 10);
|
|
198
|
+
expect(killed).toBe(false);
|
|
199
|
+
expect(enemy.health).toBe(20); // 30 - 10
|
|
200
|
+
expect(enemy.alive).toBe(true);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test('kills when health reaches 0', () => {
|
|
204
|
+
const enemy = createShooterEnemy(1, 'rusher', { x: 0, y: 0, z: 0 }, 42);
|
|
205
|
+
const killed = damageEnemy(enemy, 30);
|
|
206
|
+
expect(killed).toBe(true);
|
|
207
|
+
expect(enemy.health).toBe(0);
|
|
208
|
+
expect(enemy.alive).toBe(false);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test('overkill sets health to 0', () => {
|
|
212
|
+
const enemy = createShooterEnemy(1, 'rusher', { x: 0, y: 0, z: 0 }, 42);
|
|
213
|
+
damageEnemy(enemy, 100);
|
|
214
|
+
expect(enemy.health).toBe(0);
|
|
215
|
+
expect(enemy.alive).toBe(false);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test('cannot damage dead enemy', () => {
|
|
219
|
+
const enemy = createShooterEnemy(1, 'rusher', { x: 0, y: 0, z: 0 }, 42);
|
|
220
|
+
enemy.alive = false;
|
|
221
|
+
const killed = damageEnemy(enemy, 10);
|
|
222
|
+
expect(killed).toBe(false);
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
// ---------------------------------------------------------------------------
|
|
227
|
+
// Line of Sight
|
|
228
|
+
// ---------------------------------------------------------------------------
|
|
229
|
+
|
|
230
|
+
describe('lineOfSight', () => {
|
|
231
|
+
test('clear LoS between two points', () => {
|
|
232
|
+
const result = hasLineOfSight(
|
|
233
|
+
{ x: 0, y: 1, z: 0 },
|
|
234
|
+
{ x: 10, y: 1, z: 0 },
|
|
235
|
+
[],
|
|
236
|
+
);
|
|
237
|
+
expect(result).toBe(true);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test('blocked by obstacle', () => {
|
|
241
|
+
const wall = { min: { x: 5, y: 0, z: -1 }, max: { x: 6, y: 3, z: 1 } };
|
|
242
|
+
const result = hasLineOfSight(
|
|
243
|
+
{ x: 0, y: 1, z: 0 },
|
|
244
|
+
{ x: 10, y: 1, z: 0 },
|
|
245
|
+
[wall],
|
|
246
|
+
);
|
|
247
|
+
expect(result).toBe(false);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test('LoS around obstacle', () => {
|
|
251
|
+
const wall = { min: { x: 5, y: 0, z: -1 }, max: { x: 6, y: 3, z: 1 } };
|
|
252
|
+
// Aim around the wall (Z offset)
|
|
253
|
+
const result = hasLineOfSight(
|
|
254
|
+
{ x: 0, y: 1, z: 5 },
|
|
255
|
+
{ x: 10, y: 1, z: 5 },
|
|
256
|
+
[wall],
|
|
257
|
+
);
|
|
258
|
+
expect(result).toBe(true);
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
describe('distance3D', () => {
|
|
263
|
+
test('calculates correct distance', () => {
|
|
264
|
+
expect(distance3D({ x: 0, y: 0, z: 0 }, { x: 3, y: 4, z: 0 })).toBeCloseTo(5);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
test('zero distance for same point', () => {
|
|
268
|
+
expect(distance3D({ x: 5, y: 5, z: 5 }, { x: 5, y: 5, z: 5 })).toBe(0);
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
// ---------------------------------------------------------------------------
|
|
273
|
+
// Wave Definitions
|
|
274
|
+
// ---------------------------------------------------------------------------
|
|
275
|
+
|
|
276
|
+
describe('WaveDefinitions', () => {
|
|
277
|
+
test('wave 0 has 3 rushers', () => {
|
|
278
|
+
const wave = getWaveDefinition(0);
|
|
279
|
+
expect(wave.enemies).toEqual([{ type: 'rusher', count: 3 }]);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
test('wave 1 has patrols and rushers', () => {
|
|
283
|
+
const wave = getWaveDefinition(1);
|
|
284
|
+
const total = wave.enemies.reduce((sum, e) => sum + e.count, 0);
|
|
285
|
+
expect(total).toBe(3);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
test('wave 2 has all types', () => {
|
|
289
|
+
const wave = getWaveDefinition(2);
|
|
290
|
+
const types = wave.enemies.map(e => e.type);
|
|
291
|
+
expect(types).toContain('sniper');
|
|
292
|
+
expect(types).toContain('patrol');
|
|
293
|
+
expect(types).toContain('rusher');
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
test('waves beyond defined ones scale up', () => {
|
|
297
|
+
const lastDefined = getWaveDefinition(WAVE_DEFINITIONS.length - 1);
|
|
298
|
+
const beyond = getWaveDefinition(WAVE_DEFINITIONS.length + 2);
|
|
299
|
+
const lastTotal = lastDefined.enemies.reduce((s, e) => s + e.count, 0);
|
|
300
|
+
const beyondTotal = beyond.enemies.reduce((s, e) => s + e.count, 0);
|
|
301
|
+
expect(beyondTotal).toBeGreaterThan(lastTotal);
|
|
302
|
+
});
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
// ---------------------------------------------------------------------------
|
|
306
|
+
// Enemy Type Configs
|
|
307
|
+
// ---------------------------------------------------------------------------
|
|
308
|
+
|
|
309
|
+
describe('EnemyTypeConfigs', () => {
|
|
310
|
+
test('rusher has high speed, low HP', () => {
|
|
311
|
+
expect(ENEMY_CONFIGS.rusher.health).toBeLessThan(ENEMY_CONFIGS.patrol.health);
|
|
312
|
+
expect(ENEMY_CONFIGS.rusher.characterConfig.walkSpeed).toBeGreaterThan(ENEMY_CONFIGS.patrol.characterConfig.walkSpeed);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
test('sniper has long range, low fire rate', () => {
|
|
316
|
+
expect(ENEMY_CONFIGS.sniper.attackRange).toBeGreaterThan(ENEMY_CONFIGS.rusher.attackRange);
|
|
317
|
+
expect(ENEMY_CONFIGS.sniper.weaponConfig.fireRate).toBeLessThan(ENEMY_CONFIGS.rusher.weaponConfig.fireRate);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
test('all configs have unique detection radii', () => {
|
|
321
|
+
const radii = [
|
|
322
|
+
ENEMY_CONFIGS.rusher.detectionRadius,
|
|
323
|
+
ENEMY_CONFIGS.patrol.detectionRadius,
|
|
324
|
+
ENEMY_CONFIGS.sniper.detectionRadius,
|
|
325
|
+
];
|
|
326
|
+
expect(new Set(radii).size).toBe(3);
|
|
327
|
+
});
|
|
328
|
+
});
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import { describe, test, expect } from 'vitest';
|
|
2
|
+
import { ShooterExperience, createShooterSession, addShooterPlayer, removeShooterPlayer } from '../../../experiences/shooter/index.js';
|
|
3
|
+
import { generateArena, getArenaGroundHeight, getArenaObstacles } from '../../../experiences/shooter/arena/arenaGenerator.js';
|
|
4
|
+
import { DEFAULT_ARENA_CONFIG } from '../../../experiences/shooter/arena/arenaTypes.js';
|
|
5
|
+
import { createWeaponState, processShoot, startReload, updateWeaponState, DEFAULT_WEAPON_CONFIG } from '../../../experiences/shooter/data/weapon-config.js';
|
|
6
|
+
import { hitscanRaycast } from '../../../experiences/shooter/arena/hitscan.js';
|
|
7
|
+
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
// ShooterExperience
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
|
|
12
|
+
describe('ShooterExperience', () => {
|
|
13
|
+
test('implements ExperienceDefinition', () => {
|
|
14
|
+
const exp = new ShooterExperience();
|
|
15
|
+
expect(exp.id).toBe('shooter');
|
|
16
|
+
expect(exp.name).toBeDefined();
|
|
17
|
+
expect(exp.description).toBeDefined();
|
|
18
|
+
expect(exp.defaultCameraMode).toBe('third-person');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('createSystems returns non-empty list', () => {
|
|
22
|
+
const exp = new ShooterExperience();
|
|
23
|
+
const systems = exp.createSystems();
|
|
24
|
+
expect(systems.length).toBe(5);
|
|
25
|
+
expect(systems.map(s => s.id)).toEqual([
|
|
26
|
+
'shooter-wave-spawner', 'shooter-enemy-ai', 'shooter-physics', 'shooter-weapon', 'shooter-entity-sync',
|
|
27
|
+
]);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('systems have unique ids', () => {
|
|
31
|
+
const exp = new ShooterExperience();
|
|
32
|
+
const ids = exp.createSystems().map(s => s.id);
|
|
33
|
+
expect(new Set(ids).size).toBe(ids.length);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
// Arena Generation
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
describe('ArenaGenerator', () => {
|
|
42
|
+
test('generates arena with correct dimensions', () => {
|
|
43
|
+
const arena = generateArena(42, DEFAULT_ARENA_CONFIG);
|
|
44
|
+
expect(arena.width).toBe(DEFAULT_ARENA_CONFIG.width);
|
|
45
|
+
expect(arena.depth).toBe(DEFAULT_ARENA_CONFIG.depth);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('generates 4 outer walls', () => {
|
|
49
|
+
const arena = generateArena(42);
|
|
50
|
+
expect(arena.walls).toHaveLength(4);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('generates spawn points in corners', () => {
|
|
54
|
+
const arena = generateArena(42);
|
|
55
|
+
expect(arena.spawnPoints.length).toBeGreaterThanOrEqual(4);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('generates cover blocks', () => {
|
|
59
|
+
const arena = generateArena(42);
|
|
60
|
+
expect(arena.covers.length).toBeGreaterThan(0);
|
|
61
|
+
expect(arena.covers.length).toBeLessThanOrEqual(DEFAULT_ARENA_CONFIG.coverCount);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('same seed produces same arena', () => {
|
|
65
|
+
const a1 = generateArena(42);
|
|
66
|
+
const a2 = generateArena(42);
|
|
67
|
+
expect(a1.covers.length).toBe(a2.covers.length);
|
|
68
|
+
expect(a1.covers.map(c => c.position)).toEqual(a2.covers.map(c => c.position));
|
|
69
|
+
expect(a1.spawnPoints).toEqual(a2.spawnPoints);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('different seeds produce different arenas', () => {
|
|
73
|
+
const a1 = generateArena(42);
|
|
74
|
+
const a2 = generateArena(999);
|
|
75
|
+
// Cover positions should differ (extremely unlikely to be identical)
|
|
76
|
+
const pos1 = a1.covers.map(c => `${c.position.x.toFixed(2)},${c.position.z.toFixed(2)}`).join('|');
|
|
77
|
+
const pos2 = a2.covers.map(c => `${c.position.x.toFixed(2)},${c.position.z.toFixed(2)}`).join('|');
|
|
78
|
+
expect(pos1).not.toBe(pos2);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('ground height returns 0 inside arena', () => {
|
|
82
|
+
const arena = generateArena(42);
|
|
83
|
+
expect(getArenaGroundHeight(arena, 0, 0)).toBe(0);
|
|
84
|
+
expect(getArenaGroundHeight(arena, 5, -5)).toBe(0);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('ground height returns null outside arena', () => {
|
|
88
|
+
const arena = generateArena(42);
|
|
89
|
+
expect(getArenaGroundHeight(arena, 100, 100)).toBeNull();
|
|
90
|
+
expect(getArenaGroundHeight(arena, -100, -100)).toBeNull();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('getArenaObstacles includes walls and covers', () => {
|
|
94
|
+
const arena = generateArena(42);
|
|
95
|
+
const obstacles = getArenaObstacles(arena);
|
|
96
|
+
expect(obstacles.length).toBe(arena.walls.length + arena.covers.length);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
// Shooter Session
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
describe('ShooterSession', () => {
|
|
105
|
+
test('createShooterSession creates session with arena', () => {
|
|
106
|
+
const session = createShooterSession(42);
|
|
107
|
+
expect(session.arena).toBeDefined();
|
|
108
|
+
expect(session.players.size).toBe(0);
|
|
109
|
+
expect(session.nextEntityId).toBe(1);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('addShooterPlayer adds player at spawn point', () => {
|
|
113
|
+
const session = createShooterSession(42);
|
|
114
|
+
const player = addShooterPlayer(session, 'sock1', 'p1');
|
|
115
|
+
expect(player.entityId).toBe(1);
|
|
116
|
+
expect(player.hp).toBe(100);
|
|
117
|
+
expect(player.weapon.currentAmmo).toBe(DEFAULT_WEAPON_CONFIG.maxAmmo);
|
|
118
|
+
expect(session.players.size).toBe(1);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('removeShooterPlayer removes player', () => {
|
|
122
|
+
const session = createShooterSession(42);
|
|
123
|
+
addShooterPlayer(session, 'sock1', 'p1');
|
|
124
|
+
removeShooterPlayer(session, 'sock1');
|
|
125
|
+
expect(session.players.size).toBe(0);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('multiple players get different spawn points', () => {
|
|
129
|
+
const session = createShooterSession(42);
|
|
130
|
+
const p1 = addShooterPlayer(session, 's1', 'p1');
|
|
131
|
+
const p2 = addShooterPlayer(session, 's2', 'p2');
|
|
132
|
+
expect(p1.body.x !== p2.body.x || p1.body.z !== p2.body.z).toBe(true);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
// Weapon System
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
describe('WeaponSystem', () => {
|
|
141
|
+
test('shooting decreases ammo', () => {
|
|
142
|
+
const state = createWeaponState(DEFAULT_WEAPON_CONFIG);
|
|
143
|
+
processShoot(state, DEFAULT_WEAPON_CONFIG);
|
|
144
|
+
expect(state.currentAmmo).toBe(DEFAULT_WEAPON_CONFIG.maxAmmo - 1);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test('cannot shoot during reload', () => {
|
|
148
|
+
const state = createWeaponState(DEFAULT_WEAPON_CONFIG);
|
|
149
|
+
state.isReloading = true;
|
|
150
|
+
const result = processShoot(state, DEFAULT_WEAPON_CONFIG);
|
|
151
|
+
expect(result).toBe(false);
|
|
152
|
+
expect(state.currentAmmo).toBe(DEFAULT_WEAPON_CONFIG.maxAmmo);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test('cannot shoot during cooldown', () => {
|
|
156
|
+
const state = createWeaponState(DEFAULT_WEAPON_CONFIG);
|
|
157
|
+
processShoot(state, DEFAULT_WEAPON_CONFIG); // First shot
|
|
158
|
+
const result = processShoot(state, DEFAULT_WEAPON_CONFIG); // Immediately after
|
|
159
|
+
expect(result).toBe(false);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test('cannot shoot with no ammo', () => {
|
|
163
|
+
const state = createWeaponState(DEFAULT_WEAPON_CONFIG);
|
|
164
|
+
state.currentAmmo = 0;
|
|
165
|
+
const result = processShoot(state, DEFAULT_WEAPON_CONFIG);
|
|
166
|
+
expect(result).toBe(false);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test('reload restores ammo after timer', () => {
|
|
170
|
+
const state = createWeaponState(DEFAULT_WEAPON_CONFIG);
|
|
171
|
+
state.currentAmmo = 0;
|
|
172
|
+
startReload(state, DEFAULT_WEAPON_CONFIG);
|
|
173
|
+
expect(state.isReloading).toBe(true);
|
|
174
|
+
// Simulate time passing
|
|
175
|
+
for (let i = 0; i < 100; i++) updateWeaponState(state, 0.05, DEFAULT_WEAPON_CONFIG);
|
|
176
|
+
expect(state.currentAmmo).toBe(DEFAULT_WEAPON_CONFIG.maxAmmo);
|
|
177
|
+
expect(state.isReloading).toBe(false);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test('fire cooldown expires over time', () => {
|
|
181
|
+
const state = createWeaponState(DEFAULT_WEAPON_CONFIG);
|
|
182
|
+
processShoot(state, DEFAULT_WEAPON_CONFIG);
|
|
183
|
+
expect(state.fireCooldown).toBeGreaterThan(0);
|
|
184
|
+
// Tick past cooldown
|
|
185
|
+
for (let i = 0; i < 20; i++) updateWeaponState(state, 0.05, DEFAULT_WEAPON_CONFIG);
|
|
186
|
+
expect(state.fireCooldown).toBe(0);
|
|
187
|
+
// Should be able to shoot again
|
|
188
|
+
const result = processShoot(state, DEFAULT_WEAPON_CONFIG);
|
|
189
|
+
expect(result).toBe(true);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test('cannot reload when already full', () => {
|
|
193
|
+
const state = createWeaponState(DEFAULT_WEAPON_CONFIG);
|
|
194
|
+
startReload(state, DEFAULT_WEAPON_CONFIG);
|
|
195
|
+
expect(state.isReloading).toBe(false);
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
// ---------------------------------------------------------------------------
|
|
200
|
+
// Hitscan Raycast
|
|
201
|
+
// ---------------------------------------------------------------------------
|
|
202
|
+
|
|
203
|
+
describe('hitscanRaycast', () => {
|
|
204
|
+
test('hits wall in front of player', () => {
|
|
205
|
+
const obstacles = [{ min: { x: 5, y: 0, z: -1 }, max: { x: 6, y: 3, z: 1 } }];
|
|
206
|
+
const result = hitscanRaycast(
|
|
207
|
+
{ x: 0, y: 1, z: 0 },
|
|
208
|
+
{ x: 1, y: 0, z: 0 },
|
|
209
|
+
100,
|
|
210
|
+
obstacles,
|
|
211
|
+
[],
|
|
212
|
+
);
|
|
213
|
+
expect(result).not.toBeNull();
|
|
214
|
+
expect(result!.hitPoint.x).toBeCloseTo(5);
|
|
215
|
+
expect(result!.entityId).toBeNull();
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test('misses when aiming away', () => {
|
|
219
|
+
const obstacles = [{ min: { x: 5, y: 0, z: -1 }, max: { x: 6, y: 3, z: 1 } }];
|
|
220
|
+
const result = hitscanRaycast(
|
|
221
|
+
{ x: 0, y: 1, z: 0 },
|
|
222
|
+
{ x: -1, y: 0, z: 0 },
|
|
223
|
+
100,
|
|
224
|
+
obstacles,
|
|
225
|
+
[],
|
|
226
|
+
);
|
|
227
|
+
expect(result).toBeNull();
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test('hits entity sphere', () => {
|
|
231
|
+
const entities = [{ entityId: 42, position: { x: 10, y: 1, z: 0 }, radius: 0.5 }];
|
|
232
|
+
const result = hitscanRaycast(
|
|
233
|
+
{ x: 0, y: 1, z: 0 },
|
|
234
|
+
{ x: 1, y: 0, z: 0 },
|
|
235
|
+
100,
|
|
236
|
+
[],
|
|
237
|
+
entities,
|
|
238
|
+
);
|
|
239
|
+
expect(result).not.toBeNull();
|
|
240
|
+
expect(result!.entityId).toBe(42);
|
|
241
|
+
expect(result!.distance).toBeCloseTo(9.5, 1);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test('returns closest hit (wall before entity)', () => {
|
|
245
|
+
const obstacles = [{ min: { x: 5, y: 0, z: -1 }, max: { x: 6, y: 3, z: 1 } }];
|
|
246
|
+
const entities = [{ entityId: 42, position: { x: 10, y: 1, z: 0 }, radius: 0.5 }];
|
|
247
|
+
const result = hitscanRaycast(
|
|
248
|
+
{ x: 0, y: 1, z: 0 },
|
|
249
|
+
{ x: 1, y: 0, z: 0 },
|
|
250
|
+
100,
|
|
251
|
+
obstacles,
|
|
252
|
+
entities,
|
|
253
|
+
);
|
|
254
|
+
expect(result).not.toBeNull();
|
|
255
|
+
expect(result!.entityId).toBeNull(); // Wall is closer
|
|
256
|
+
expect(result!.hitPoint.x).toBeCloseTo(5);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
test('respects max distance', () => {
|
|
260
|
+
const obstacles = [{ min: { x: 50, y: 0, z: -1 }, max: { x: 51, y: 3, z: 1 } }];
|
|
261
|
+
const result = hitscanRaycast(
|
|
262
|
+
{ x: 0, y: 1, z: 0 },
|
|
263
|
+
{ x: 1, y: 0, z: 0 },
|
|
264
|
+
10, // Max distance is 10, wall is at 50
|
|
265
|
+
obstacles,
|
|
266
|
+
[],
|
|
267
|
+
);
|
|
268
|
+
expect(result).toBeNull();
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test('returns null when nothing in range', () => {
|
|
272
|
+
const result = hitscanRaycast(
|
|
273
|
+
{ x: 0, y: 1, z: 0 },
|
|
274
|
+
{ x: 1, y: 0, z: 0 },
|
|
275
|
+
100,
|
|
276
|
+
[],
|
|
277
|
+
[],
|
|
278
|
+
);
|
|
279
|
+
expect(result).toBeNull();
|
|
280
|
+
});
|
|
281
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { describe, test, expect } from 'vitest';
|
|
2
|
+
import { VoxelChunkCache } from '../voxelChunkCache.js';
|
|
3
|
+
import { VOXEL_CHUNK_X, VOXEL_CHUNK_Y, VOXEL_CHUNK_Z } from '@loonylabs/gamedev-core';
|
|
4
|
+
|
|
5
|
+
describe('VoxelChunkCache', () => {
|
|
6
|
+
test('generates and caches chunks deterministically', () => {
|
|
7
|
+
const cache = new VoxelChunkCache(42, { baseHeight: 32 });
|
|
8
|
+
const c1 = cache.get(0, 0);
|
|
9
|
+
const c2 = cache.get(0, 0);
|
|
10
|
+
expect(c1).toBe(c2); // same reference = cached
|
|
11
|
+
expect(c1.density.length).toBe(VOXEL_CHUNK_X * VOXEL_CHUNK_Y * VOXEL_CHUNK_Z);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test('different chunk coords produce different chunks', () => {
|
|
15
|
+
const cache = new VoxelChunkCache(42, { baseHeight: 32 });
|
|
16
|
+
const c1 = cache.get(0, 0);
|
|
17
|
+
const c2 = cache.get(1, 0);
|
|
18
|
+
expect(c1).not.toBe(c2);
|
|
19
|
+
expect(c1.cx).toBe(0);
|
|
20
|
+
expect(c2.cx).toBe(1);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('getChunkFn returns chunks compatible with getTerrainHeight', () => {
|
|
24
|
+
const cache = new VoxelChunkCache(42, { baseHeight: 32 });
|
|
25
|
+
const chunk = cache.getChunkFn(0, 0);
|
|
26
|
+
expect(chunk).not.toBeNull();
|
|
27
|
+
expect(chunk!.density.length).toBe(VOXEL_CHUNK_X * VOXEL_CHUNK_Y * VOXEL_CHUNK_Z);
|
|
28
|
+
});
|
|
29
|
+
});
|