@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,292 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Procedural track generator for the Runner experience.
|
|
3
|
+
* Seed-based — same seed produces the same track.
|
|
4
|
+
* Streaming — generates ahead of the player, disposes behind.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { mulberry32 } from '../../../../packages/core/src/dungeon/prng.js';
|
|
8
|
+
import type { SegmentDef, PlacedSegment, SegmentCollectible, SegmentPowerup, SegmentObstacle, PlacedCollectible, PlacedPowerup, PlacedObstacle } from './segmentTypes.js';
|
|
9
|
+
import type { Lane, CollectibleType, PowerupType, ObstacleType } from './segmentTypes.js';
|
|
10
|
+
import {
|
|
11
|
+
STRAIGHT_SEGMENT,
|
|
12
|
+
GAP_SEGMENT,
|
|
13
|
+
RAMP_SEGMENT,
|
|
14
|
+
NARROW_LEFT_SEGMENT,
|
|
15
|
+
NARROW_RIGHT_SEGMENT,
|
|
16
|
+
NARROW_CENTER_SEGMENT,
|
|
17
|
+
} from './segmentTypes.js';
|
|
18
|
+
import { LANE_WIDTH } from './laneSystem.js';
|
|
19
|
+
|
|
20
|
+
export interface TrackGeneratorState {
|
|
21
|
+
seed: number;
|
|
22
|
+
/** Where the next segment starts (Z position) */
|
|
23
|
+
nextZ: number;
|
|
24
|
+
/** Segment counter */
|
|
25
|
+
nextIndex: number;
|
|
26
|
+
/** Currently active segments */
|
|
27
|
+
segments: PlacedSegment[];
|
|
28
|
+
/** Auto-incrementing ID for collectibles/powerups/obstacles */
|
|
29
|
+
nextItemId: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function createTrackGenerator(seed: number): TrackGeneratorState {
|
|
33
|
+
return { seed, nextZ: 0, nextIndex: 0, segments: [], nextItemId: 1 };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Generate segments until we have enough ahead of playerZ.
|
|
38
|
+
* Remove segments that are behind playerZ - bufferBehind.
|
|
39
|
+
*/
|
|
40
|
+
export function updateTrack(
|
|
41
|
+
state: TrackGeneratorState,
|
|
42
|
+
playerZ: number,
|
|
43
|
+
lookAhead: number = 200,
|
|
44
|
+
bufferBehind: number = 50,
|
|
45
|
+
): void {
|
|
46
|
+
// Generate ahead
|
|
47
|
+
while (state.nextZ < playerZ + lookAhead) {
|
|
48
|
+
const def = pickSegment(state.seed, state.nextIndex);
|
|
49
|
+
const startZ = state.nextZ;
|
|
50
|
+
|
|
51
|
+
// Place collectibles, powerups, obstacles with world positions
|
|
52
|
+
const collectibles: PlacedCollectible[] = [];
|
|
53
|
+
if (def.collectibles) {
|
|
54
|
+
for (const c of def.collectibles) {
|
|
55
|
+
collectibles.push({
|
|
56
|
+
id: state.nextItemId++,
|
|
57
|
+
type: c.type,
|
|
58
|
+
lane: c.lane,
|
|
59
|
+
x: c.lane * LANE_WIDTH,
|
|
60
|
+
y: 1,
|
|
61
|
+
z: startZ + c.offset * def.length,
|
|
62
|
+
collected: false,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
let powerup: PlacedPowerup | null = null;
|
|
68
|
+
if (def.powerup) {
|
|
69
|
+
powerup = {
|
|
70
|
+
id: state.nextItemId++,
|
|
71
|
+
type: def.powerup.type,
|
|
72
|
+
lane: def.powerup.lane,
|
|
73
|
+
x: def.powerup.lane * LANE_WIDTH,
|
|
74
|
+
y: 1.5,
|
|
75
|
+
z: startZ + def.powerup.offset * def.length,
|
|
76
|
+
collected: false,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const obstacles: PlacedObstacle[] = [];
|
|
81
|
+
if (def.obstacles) {
|
|
82
|
+
for (const o of def.obstacles) {
|
|
83
|
+
obstacles.push({
|
|
84
|
+
id: state.nextItemId++,
|
|
85
|
+
type: o.type,
|
|
86
|
+
lane: o.lane,
|
|
87
|
+
x: o.lane * LANE_WIDTH,
|
|
88
|
+
z: startZ + o.offset * def.length,
|
|
89
|
+
height: o.height,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const segment: PlacedSegment = {
|
|
95
|
+
def,
|
|
96
|
+
startZ,
|
|
97
|
+
endZ: startZ + def.length,
|
|
98
|
+
index: state.nextIndex,
|
|
99
|
+
collectibles,
|
|
100
|
+
powerup,
|
|
101
|
+
obstacles,
|
|
102
|
+
};
|
|
103
|
+
state.segments.push(segment);
|
|
104
|
+
state.nextZ += def.length;
|
|
105
|
+
state.nextIndex++;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Dispose behind
|
|
109
|
+
state.segments = state.segments.filter(s => s.endZ > playerZ - bufferBehind);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Pick a segment type based on difficulty (index).
|
|
114
|
+
* Higher index = more gaps, ramps, narrow sections.
|
|
115
|
+
*/
|
|
116
|
+
function pickSegment(seed: number, index: number): SegmentDef {
|
|
117
|
+
const rng = mulberry32(seed ^ (index * 31337));
|
|
118
|
+
// Consume a few values to decorrelate sequential indices
|
|
119
|
+
rng(); rng();
|
|
120
|
+
const roll = rng();
|
|
121
|
+
|
|
122
|
+
const difficulty = Math.min(index / 50, 1); // 0-1 over 50 segments
|
|
123
|
+
|
|
124
|
+
// First 3 segments are always straight (grace period)
|
|
125
|
+
if (index < 3) {
|
|
126
|
+
const def = { ...STRAIGHT_SEGMENT };
|
|
127
|
+
def.collectibles = generateCollectibles(rng, def, difficulty);
|
|
128
|
+
return def;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Probability distribution shifts with difficulty:
|
|
132
|
+
// Early: 70% straight, 15% gap, 10% ramp, 5% narrow
|
|
133
|
+
// Late: 20% straight, 25% gap, 20% ramp, 35% narrow
|
|
134
|
+
const straightChance = 0.70 - 0.50 * difficulty;
|
|
135
|
+
const gapChance = 0.15 + 0.10 * difficulty;
|
|
136
|
+
const rampChance = 0.10 + 0.10 * difficulty;
|
|
137
|
+
// narrowChance = remaining
|
|
138
|
+
|
|
139
|
+
let def: SegmentDef;
|
|
140
|
+
if (roll < straightChance) {
|
|
141
|
+
// Vary straight segment length slightly
|
|
142
|
+
const lengthVariation = 15 + Math.floor(rng() * 15);
|
|
143
|
+
def = { ...STRAIGHT_SEGMENT, length: lengthVariation };
|
|
144
|
+
} else if (roll < straightChance + gapChance) {
|
|
145
|
+
// Gap length grows slightly with difficulty
|
|
146
|
+
const gapLen = 4 + Math.floor(difficulty * 4);
|
|
147
|
+
def = { ...GAP_SEGMENT, gapLength: gapLen };
|
|
148
|
+
} else if (roll < straightChance + gapChance + rampChance) {
|
|
149
|
+
def = { ...RAMP_SEGMENT };
|
|
150
|
+
} else {
|
|
151
|
+
// Narrow — pick subtype
|
|
152
|
+
const narrowRoll = rng();
|
|
153
|
+
if (narrowRoll < 0.33) def = { ...NARROW_LEFT_SEGMENT };
|
|
154
|
+
else if (narrowRoll < 0.66) def = { ...NARROW_RIGHT_SEGMENT };
|
|
155
|
+
else def = { ...NARROW_CENTER_SEGMENT };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Place collectibles on most segments
|
|
159
|
+
def.collectibles = generateCollectibles(rng, def, difficulty);
|
|
160
|
+
|
|
161
|
+
// Place powerup rarely (decreasing with difficulty)
|
|
162
|
+
const powerupChance = 0.15 - 0.05 * difficulty;
|
|
163
|
+
if (rng() < powerupChance && def.type !== 'gap') {
|
|
164
|
+
def.powerup = generatePowerup(rng, def);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Place obstacles on straight/ramp segments (increasing with difficulty)
|
|
168
|
+
if (index >= 5 && (def.type === 'straight' || def.type === 'ramp')) {
|
|
169
|
+
const obstacleChance = 0.1 + 0.4 * difficulty;
|
|
170
|
+
if (rng() < obstacleChance) {
|
|
171
|
+
def.obstacles = generateObstacles(rng, def, difficulty);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return def;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Generate collectible placements for a segment */
|
|
179
|
+
function generateCollectibles(rng: () => number, def: SegmentDef, difficulty: number): SegmentCollectible[] {
|
|
180
|
+
const collectibles: SegmentCollectible[] = [];
|
|
181
|
+
// Skip gap segments (no collectibles in gap area)
|
|
182
|
+
if (def.type === 'gap') return collectibles;
|
|
183
|
+
|
|
184
|
+
// 60% chance of a coin line on the segment
|
|
185
|
+
if (rng() < 0.6) {
|
|
186
|
+
const availableLanes = ([-1, 0, 1] as Lane[]).filter((_, i) => def.lanes[i]);
|
|
187
|
+
if (availableLanes.length === 0) return collectibles;
|
|
188
|
+
const lane = availableLanes[Math.floor(rng() * availableLanes.length)];
|
|
189
|
+
const coinCount = 3 + Math.floor(rng() * 4); // 3-6 coins in a line
|
|
190
|
+
for (let i = 0; i < coinCount; i++) {
|
|
191
|
+
const offset = 0.2 + (i / coinCount) * 0.6; // spread over 20%-80% of segment
|
|
192
|
+
collectibles.push({ lane, offset, type: 'coin' });
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// 5% chance of a gem
|
|
197
|
+
if (rng() < 0.05) {
|
|
198
|
+
const availableLanes = ([-1, 0, 1] as Lane[]).filter((_, i) => def.lanes[i]);
|
|
199
|
+
if (availableLanes.length > 0) {
|
|
200
|
+
const lane = availableLanes[Math.floor(rng() * availableLanes.length)];
|
|
201
|
+
collectibles.push({ lane, offset: 0.5, type: 'gem' });
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return collectibles;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Generate a powerup for a segment */
|
|
209
|
+
function generatePowerup(rng: () => number, def: SegmentDef): SegmentPowerup {
|
|
210
|
+
const availableLanes = ([-1, 0, 1] as Lane[]).filter((_, i) => def.lanes[i]);
|
|
211
|
+
const lane = availableLanes[Math.floor(rng() * availableLanes.length)];
|
|
212
|
+
const types: PowerupType[] = ['speed-boost', 'low-gravity', 'magnet'];
|
|
213
|
+
const type = types[Math.floor(rng() * types.length)];
|
|
214
|
+
return { lane, offset: 0.5, type };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Generate obstacles for a segment */
|
|
218
|
+
function generateObstacles(rng: () => number, def: SegmentDef, difficulty: number): SegmentObstacle[] {
|
|
219
|
+
const obstacles: SegmentObstacle[] = [];
|
|
220
|
+
const availableLanes = ([-1, 0, 1] as Lane[]).filter((_, i) => def.lanes[i]);
|
|
221
|
+
if (availableLanes.length === 0) return obstacles;
|
|
222
|
+
|
|
223
|
+
// Place 1-2 obstacles, never blocking all lanes
|
|
224
|
+
const count = rng() < 0.3 + 0.3 * difficulty ? 2 : 1;
|
|
225
|
+
const maxBlocked = availableLanes.length - 1; // always leave at least one lane open
|
|
226
|
+
const usedLanes = new Set<Lane>();
|
|
227
|
+
|
|
228
|
+
for (let i = 0; i < Math.min(count, maxBlocked); i++) {
|
|
229
|
+
const freeLanes = availableLanes.filter(l => !usedLanes.has(l));
|
|
230
|
+
if (freeLanes.length <= 1) break; // don't block last open lane
|
|
231
|
+
const lane = freeLanes[Math.floor(rng() * freeLanes.length)];
|
|
232
|
+
usedLanes.add(lane);
|
|
233
|
+
|
|
234
|
+
const typeRoll = rng();
|
|
235
|
+
let type: ObstacleType;
|
|
236
|
+
let height: number;
|
|
237
|
+
if (typeRoll < 0.4) {
|
|
238
|
+
type = 'wall';
|
|
239
|
+
height = 3;
|
|
240
|
+
} else if (typeRoll < 0.7) {
|
|
241
|
+
type = 'low-barrier';
|
|
242
|
+
height = 1;
|
|
243
|
+
} else {
|
|
244
|
+
type = 'high-barrier';
|
|
245
|
+
height = 3;
|
|
246
|
+
}
|
|
247
|
+
obstacles.push({ lane, offset: 0.5, type, height });
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return obstacles;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Get the ground height for a given position on the track.
|
|
255
|
+
* Returns the height of the track surface, or null if off-track (gap or no lane).
|
|
256
|
+
*/
|
|
257
|
+
export function getTrackGroundHeight(
|
|
258
|
+
state: TrackGeneratorState,
|
|
259
|
+
x: number,
|
|
260
|
+
z: number,
|
|
261
|
+
laneWidth: number = 2,
|
|
262
|
+
): number | null {
|
|
263
|
+
// Find the segment containing this Z position
|
|
264
|
+
const segment = state.segments.find(s => z >= s.startZ && z < s.endZ);
|
|
265
|
+
if (!segment) return null;
|
|
266
|
+
|
|
267
|
+
// Check if we're on a valid lane
|
|
268
|
+
const laneIndex = Math.round(x / laneWidth) + 1; // -1->0, 0->1, 1->2
|
|
269
|
+
if (laneIndex < 0 || laneIndex > 2) return null;
|
|
270
|
+
if (!segment.def.lanes[laneIndex]) return null;
|
|
271
|
+
|
|
272
|
+
// Handle gap segments
|
|
273
|
+
if (segment.def.type === 'gap' && segment.def.gapLength) {
|
|
274
|
+
const segMid = (segment.startZ + segment.endZ) / 2;
|
|
275
|
+
const halfGap = segment.def.gapLength / 2;
|
|
276
|
+
if (z >= segMid - halfGap && z < segMid + halfGap) {
|
|
277
|
+
return null; // In the gap
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Handle ramp segments
|
|
282
|
+
if (segment.def.type === 'ramp' && segment.def.rampHeight) {
|
|
283
|
+
const progress = (z - segment.startZ) / segment.def.length;
|
|
284
|
+
// Triangle ramp: up to peak at 50%, back down
|
|
285
|
+
const rampProgress = progress < 0.5
|
|
286
|
+
? progress * 2
|
|
287
|
+
: (1 - progress) * 2;
|
|
288
|
+
return rampProgress * segment.def.rampHeight;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return 0; // Flat ground
|
|
292
|
+
}
|
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shooter Enemy AI — state machine with per-type behavior.
|
|
3
|
+
*
|
|
4
|
+
* States: idle → detect → chase → attack → retreat
|
|
5
|
+
* Pure functions operating on ShooterEnemy state.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { PhysicsBody } from '../../../../packages/core/src/physics/gravity.js';
|
|
9
|
+
import { applyMovementInput, applyFriction, applyHorizontalMovement } from '../../../../packages/core/src/physics/characterController.js';
|
|
10
|
+
import type { MovementInput } from '../../../../packages/core/src/physics/characterController.js';
|
|
11
|
+
import { updatePhysicsBody, snapToGround, DEFAULT_PHYSICS_CONFIG } from '../../../../packages/core/src/physics/gravity.js';
|
|
12
|
+
import { resolveTerrainCollision } from '../../../../packages/core/src/physics/collision.js';
|
|
13
|
+
import type { WeaponState } from '../data/weapon-config.js';
|
|
14
|
+
import { processShoot, updateWeaponState, createWeaponState } from '../data/weapon-config.js';
|
|
15
|
+
import type { EnemyTypeConfig, EnemyType } from '../data/enemy-types.js';
|
|
16
|
+
import { ENEMY_CONFIGS } from '../data/enemy-types.js';
|
|
17
|
+
import type { Vec3, AABB } from '../arena/arenaTypes.js';
|
|
18
|
+
import type { Arena } from '../arena/arenaTypes.js';
|
|
19
|
+
import { getArenaGroundHeight } from '../arena/arenaGenerator.js';
|
|
20
|
+
import { hasLineOfSight, distance3D } from './lineOfSight.js';
|
|
21
|
+
import { mulberry32 } from '../../../../packages/core/src/dungeon/prng.js';
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Enemy State
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
export type AIState = 'idle' | 'detect' | 'chase' | 'attack' | 'retreat';
|
|
28
|
+
|
|
29
|
+
export interface ShooterEnemy {
|
|
30
|
+
entityId: number;
|
|
31
|
+
enemyType: EnemyType;
|
|
32
|
+
config: EnemyTypeConfig;
|
|
33
|
+
aiState: AIState;
|
|
34
|
+
body: PhysicsBody;
|
|
35
|
+
weapon: WeaponState;
|
|
36
|
+
health: number;
|
|
37
|
+
maxHealth: number;
|
|
38
|
+
targetPlayerId: string | null;
|
|
39
|
+
stateTimer: number;
|
|
40
|
+
alive: boolean;
|
|
41
|
+
/** Seed for per-enemy randomness (accuracy spread, patrol directions) */
|
|
42
|
+
rngSeed: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function createShooterEnemy(
|
|
46
|
+
entityId: number,
|
|
47
|
+
type: EnemyType,
|
|
48
|
+
position: Vec3,
|
|
49
|
+
seed: number,
|
|
50
|
+
): ShooterEnemy {
|
|
51
|
+
const config = ENEMY_CONFIGS[type];
|
|
52
|
+
return {
|
|
53
|
+
entityId,
|
|
54
|
+
enemyType: type,
|
|
55
|
+
config,
|
|
56
|
+
aiState: 'idle',
|
|
57
|
+
body: {
|
|
58
|
+
x: position.x,
|
|
59
|
+
y: position.y,
|
|
60
|
+
z: position.z,
|
|
61
|
+
velocityX: 0,
|
|
62
|
+
velocityY: 0,
|
|
63
|
+
velocityZ: 0,
|
|
64
|
+
isGrounded: true,
|
|
65
|
+
radius: 0.5,
|
|
66
|
+
},
|
|
67
|
+
weapon: createWeaponState(config.weaponConfig),
|
|
68
|
+
health: config.health,
|
|
69
|
+
maxHealth: config.health,
|
|
70
|
+
targetPlayerId: null,
|
|
71
|
+
stateTimer: 0,
|
|
72
|
+
alive: true,
|
|
73
|
+
rngSeed: seed,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
// AI Update
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
export interface AIUpdateContext {
|
|
82
|
+
players: Map<string, { socketId: string; body: PhysicsBody; hp: number }>;
|
|
83
|
+
obstacles: AABB[];
|
|
84
|
+
arena: Arena;
|
|
85
|
+
/** Accumulated shoot intents from enemies this tick */
|
|
86
|
+
enemyShots: Array<{ enemyId: number; origin: Vec3; direction: Vec3; damage: number }>;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Update a single enemy's AI state and physics.
|
|
91
|
+
*/
|
|
92
|
+
export function updateShooterEnemyAI(
|
|
93
|
+
enemy: ShooterEnemy,
|
|
94
|
+
dt: number,
|
|
95
|
+
ctx: AIUpdateContext,
|
|
96
|
+
): void {
|
|
97
|
+
if (!enemy.alive) return;
|
|
98
|
+
|
|
99
|
+
enemy.stateTimer += dt;
|
|
100
|
+
updateWeaponState(enemy.weapon, dt, enemy.config.weaponConfig);
|
|
101
|
+
|
|
102
|
+
// Find nearest visible player
|
|
103
|
+
const nearestPlayer = findNearestVisiblePlayer(enemy, ctx);
|
|
104
|
+
|
|
105
|
+
switch (enemy.aiState) {
|
|
106
|
+
case 'idle':
|
|
107
|
+
updateIdle(enemy, dt, nearestPlayer, ctx);
|
|
108
|
+
break;
|
|
109
|
+
case 'detect':
|
|
110
|
+
updateDetect(enemy, dt, nearestPlayer, ctx);
|
|
111
|
+
break;
|
|
112
|
+
case 'chase':
|
|
113
|
+
updateChase(enemy, dt, nearestPlayer, ctx);
|
|
114
|
+
break;
|
|
115
|
+
case 'attack':
|
|
116
|
+
updateAttack(enemy, dt, nearestPlayer, ctx);
|
|
117
|
+
break;
|
|
118
|
+
case 'retreat':
|
|
119
|
+
updateRetreat(enemy, dt, nearestPlayer, ctx);
|
|
120
|
+
break;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Physics
|
|
124
|
+
applyFriction(enemy.body, dt, enemy.config.characterConfig);
|
|
125
|
+
applyHorizontalMovement(enemy.body, dt);
|
|
126
|
+
|
|
127
|
+
const groundFn = (x: number, z: number) => getArenaGroundHeight(ctx.arena, x, z);
|
|
128
|
+
const wallFn = (x: number, z: number) => {
|
|
129
|
+
for (const cover of ctx.arena.covers) {
|
|
130
|
+
if (x >= cover.aabb.min.x && x <= cover.aabb.max.x &&
|
|
131
|
+
z >= cover.aabb.min.z && z <= cover.aabb.max.z) {
|
|
132
|
+
return cover.height;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return getArenaGroundHeight(ctx.arena, x, z);
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
resolveTerrainCollision(enemy.body, wallFn);
|
|
139
|
+
updatePhysicsBody(enemy.body, dt, groundFn, DEFAULT_PHYSICS_CONFIG);
|
|
140
|
+
if (enemy.body.isGrounded) {
|
|
141
|
+
snapToGround(enemy.body, groundFn, DEFAULT_PHYSICS_CONFIG);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ---------------------------------------------------------------------------
|
|
146
|
+
// State handlers
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
148
|
+
|
|
149
|
+
function updateIdle(
|
|
150
|
+
enemy: ShooterEnemy,
|
|
151
|
+
_dt: number,
|
|
152
|
+
nearestPlayer: NearestPlayerResult | null,
|
|
153
|
+
_ctx: AIUpdateContext,
|
|
154
|
+
): void {
|
|
155
|
+
// Wander slightly (just stand)
|
|
156
|
+
if (nearestPlayer && nearestPlayer.distance <= enemy.config.detectionRadius) {
|
|
157
|
+
enemy.aiState = 'detect';
|
|
158
|
+
enemy.stateTimer = 0;
|
|
159
|
+
enemy.targetPlayerId = nearestPlayer.socketId;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function updateDetect(
|
|
164
|
+
enemy: ShooterEnemy,
|
|
165
|
+
_dt: number,
|
|
166
|
+
nearestPlayer: NearestPlayerResult | null,
|
|
167
|
+
_ctx: AIUpdateContext,
|
|
168
|
+
): void {
|
|
169
|
+
// Wait for reaction time
|
|
170
|
+
if (enemy.stateTimer >= enemy.config.reactionTime) {
|
|
171
|
+
if (nearestPlayer && nearestPlayer.distance <= enemy.config.attackRange && nearestPlayer.hasLos) {
|
|
172
|
+
enemy.aiState = 'attack';
|
|
173
|
+
} else if (nearestPlayer) {
|
|
174
|
+
enemy.aiState = 'chase';
|
|
175
|
+
} else {
|
|
176
|
+
enemy.aiState = 'idle';
|
|
177
|
+
}
|
|
178
|
+
enemy.stateTimer = 0;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function updateChase(
|
|
183
|
+
enemy: ShooterEnemy,
|
|
184
|
+
dt: number,
|
|
185
|
+
nearestPlayer: NearestPlayerResult | null,
|
|
186
|
+
ctx: AIUpdateContext,
|
|
187
|
+
): void {
|
|
188
|
+
if (!nearestPlayer) {
|
|
189
|
+
enemy.aiState = 'idle';
|
|
190
|
+
enemy.stateTimer = 0;
|
|
191
|
+
enemy.targetPlayerId = null;
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Check retreat
|
|
196
|
+
if (shouldRetreat(enemy)) {
|
|
197
|
+
enemy.aiState = 'retreat';
|
|
198
|
+
enemy.stateTimer = 0;
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// In attack range + LoS? Switch to attack
|
|
203
|
+
if (nearestPlayer.distance <= enemy.config.attackRange && nearestPlayer.hasLos) {
|
|
204
|
+
enemy.aiState = 'attack';
|
|
205
|
+
enemy.stateTimer = 0;
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Move toward player
|
|
210
|
+
moveToward(enemy, nearestPlayer.position, dt);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function updateAttack(
|
|
214
|
+
enemy: ShooterEnemy,
|
|
215
|
+
dt: number,
|
|
216
|
+
nearestPlayer: NearestPlayerResult | null,
|
|
217
|
+
ctx: AIUpdateContext,
|
|
218
|
+
): void {
|
|
219
|
+
if (!nearestPlayer) {
|
|
220
|
+
enemy.aiState = 'idle';
|
|
221
|
+
enemy.stateTimer = 0;
|
|
222
|
+
enemy.targetPlayerId = null;
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Check retreat
|
|
227
|
+
if (shouldRetreat(enemy)) {
|
|
228
|
+
enemy.aiState = 'retreat';
|
|
229
|
+
enemy.stateTimer = 0;
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Lost LoS or out of range? Chase
|
|
234
|
+
if (!nearestPlayer.hasLos || nearestPlayer.distance > enemy.config.attackRange * 1.2) {
|
|
235
|
+
enemy.aiState = 'chase';
|
|
236
|
+
enemy.stateTimer = 0;
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Try to shoot
|
|
241
|
+
tryEnemyShoot(enemy, nearestPlayer, ctx);
|
|
242
|
+
|
|
243
|
+
// Rusher: keep moving toward player while attacking
|
|
244
|
+
if (enemy.enemyType === 'rusher') {
|
|
245
|
+
moveToward(enemy, nearestPlayer.position, dt);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function updateRetreat(
|
|
250
|
+
enemy: ShooterEnemy,
|
|
251
|
+
dt: number,
|
|
252
|
+
nearestPlayer: NearestPlayerResult | null,
|
|
253
|
+
ctx: AIUpdateContext,
|
|
254
|
+
): void {
|
|
255
|
+
if (!nearestPlayer) {
|
|
256
|
+
enemy.aiState = 'idle';
|
|
257
|
+
enemy.stateTimer = 0;
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Move away from player
|
|
262
|
+
moveAway(enemy, nearestPlayer.position, dt);
|
|
263
|
+
|
|
264
|
+
// After retreating for 3 seconds, go back to chase
|
|
265
|
+
if (enemy.stateTimer > 3) {
|
|
266
|
+
enemy.aiState = 'chase';
|
|
267
|
+
enemy.stateTimer = 0;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// ---------------------------------------------------------------------------
|
|
272
|
+
// Helpers
|
|
273
|
+
// ---------------------------------------------------------------------------
|
|
274
|
+
|
|
275
|
+
interface NearestPlayerResult {
|
|
276
|
+
socketId: string;
|
|
277
|
+
position: Vec3;
|
|
278
|
+
distance: number;
|
|
279
|
+
hasLos: boolean;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function findNearestVisiblePlayer(
|
|
283
|
+
enemy: ShooterEnemy,
|
|
284
|
+
ctx: AIUpdateContext,
|
|
285
|
+
): NearestPlayerResult | null {
|
|
286
|
+
let nearest: NearestPlayerResult | null = null;
|
|
287
|
+
|
|
288
|
+
const eyePos = { x: enemy.body.x, y: enemy.body.y + 1.5, z: enemy.body.z };
|
|
289
|
+
|
|
290
|
+
for (const [socketId, player] of ctx.players) {
|
|
291
|
+
if (player.hp <= 0) continue;
|
|
292
|
+
const playerPos = { x: player.body.x, y: player.body.y + 1, z: player.body.z };
|
|
293
|
+
const dist = distance3D(eyePos, playerPos);
|
|
294
|
+
|
|
295
|
+
if (dist > enemy.config.detectionRadius) continue;
|
|
296
|
+
|
|
297
|
+
const hasLos = hasLineOfSight(eyePos, playerPos, ctx.obstacles);
|
|
298
|
+
|
|
299
|
+
if (!nearest || dist < nearest.distance) {
|
|
300
|
+
nearest = { socketId, position: playerPos, distance: dist, hasLos };
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
return nearest;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function moveToward(enemy: ShooterEnemy, target: Vec3, dt: number): void {
|
|
308
|
+
const dx = target.x - enemy.body.x;
|
|
309
|
+
const dz = target.z - enemy.body.z;
|
|
310
|
+
const len = Math.sqrt(dx * dx + dz * dz);
|
|
311
|
+
if (len < 0.5) return;
|
|
312
|
+
|
|
313
|
+
const input: MovementInput = {
|
|
314
|
+
x: dx / len,
|
|
315
|
+
z: dz / len,
|
|
316
|
+
sprint: enemy.enemyType === 'rusher',
|
|
317
|
+
glide: false,
|
|
318
|
+
};
|
|
319
|
+
applyMovementInput(enemy.body, input, dt, enemy.config.characterConfig);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function moveAway(enemy: ShooterEnemy, target: Vec3, dt: number): void {
|
|
323
|
+
const dx = enemy.body.x - target.x;
|
|
324
|
+
const dz = enemy.body.z - target.z;
|
|
325
|
+
const len = Math.sqrt(dx * dx + dz * dz);
|
|
326
|
+
if (len < 0.1) return;
|
|
327
|
+
|
|
328
|
+
const input: MovementInput = {
|
|
329
|
+
x: dx / len,
|
|
330
|
+
z: dz / len,
|
|
331
|
+
sprint: true,
|
|
332
|
+
glide: false,
|
|
333
|
+
};
|
|
334
|
+
applyMovementInput(enemy.body, input, dt, enemy.config.characterConfig);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function shouldRetreat(enemy: ShooterEnemy): boolean {
|
|
338
|
+
if (enemy.config.retreatThreshold === 0) return false;
|
|
339
|
+
return enemy.health / enemy.maxHealth <= enemy.config.retreatThreshold;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function tryEnemyShoot(
|
|
343
|
+
enemy: ShooterEnemy,
|
|
344
|
+
target: NearestPlayerResult,
|
|
345
|
+
ctx: AIUpdateContext,
|
|
346
|
+
): void {
|
|
347
|
+
const fired = processShoot(enemy.weapon, enemy.config.weaponConfig);
|
|
348
|
+
if (!fired) return;
|
|
349
|
+
|
|
350
|
+
// Calculate aim direction with accuracy spread
|
|
351
|
+
const eyePos = { x: enemy.body.x, y: enemy.body.y + 1.5, z: enemy.body.z };
|
|
352
|
+
const dx = target.position.x - eyePos.x;
|
|
353
|
+
const dy = target.position.y - eyePos.y;
|
|
354
|
+
const dz = target.position.z - eyePos.z;
|
|
355
|
+
const len = Math.sqrt(dx * dx + dy * dy + dz * dz);
|
|
356
|
+
if (len < 0.001) return;
|
|
357
|
+
|
|
358
|
+
// Add accuracy spread
|
|
359
|
+
const rng = mulberry32(enemy.rngSeed + Math.floor(enemy.stateTimer * 1000));
|
|
360
|
+
const spreadX = (rng() - 0.5) * 2 * enemy.config.accuracySpread;
|
|
361
|
+
const spreadY = (rng() - 0.5) * 2 * enemy.config.accuracySpread;
|
|
362
|
+
|
|
363
|
+
const dir = {
|
|
364
|
+
x: dx / len + spreadX,
|
|
365
|
+
y: dy / len + spreadY,
|
|
366
|
+
z: dz / len,
|
|
367
|
+
};
|
|
368
|
+
// Re-normalize
|
|
369
|
+
const dirLen = Math.sqrt(dir.x * dir.x + dir.y * dir.y + dir.z * dir.z);
|
|
370
|
+
dir.x /= dirLen;
|
|
371
|
+
dir.y /= dirLen;
|
|
372
|
+
dir.z /= dirLen;
|
|
373
|
+
|
|
374
|
+
ctx.enemyShots.push({
|
|
375
|
+
enemyId: enemy.entityId,
|
|
376
|
+
origin: eyePos,
|
|
377
|
+
direction: dir,
|
|
378
|
+
damage: enemy.config.weaponConfig.damage,
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Apply damage to an enemy. Returns true if enemy died.
|
|
384
|
+
*/
|
|
385
|
+
export function damageEnemy(enemy: ShooterEnemy, amount: number): boolean {
|
|
386
|
+
if (!enemy.alive) return false;
|
|
387
|
+
enemy.health -= amount;
|
|
388
|
+
if (enemy.health <= 0) {
|
|
389
|
+
enemy.health = 0;
|
|
390
|
+
enemy.alive = false;
|
|
391
|
+
return true;
|
|
392
|
+
}
|
|
393
|
+
return false;
|
|
394
|
+
}
|