@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,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Enemy AI System — runs AI state machine for all enemies each tick.
|
|
3
|
+
* Processes enemy shots via hitscan against players.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { ExperienceSystem, SystemContext } from '../../../../packages/core/src/index.js';
|
|
7
|
+
import { updateShooterEnemyAI, type AIUpdateContext } from '../ai/aiStateMachine.js';
|
|
8
|
+
import { getArenaObstacles } from '../arena/arenaGenerator.js';
|
|
9
|
+
import { hitscanRaycast, type HitscanTarget } from '../arena/hitscan.js';
|
|
10
|
+
import type { AABB } from '../arena/arenaTypes.js';
|
|
11
|
+
import type { ShooterSessionWithEnemies } from '../index.js';
|
|
12
|
+
|
|
13
|
+
export class EnemyAISystem implements ExperienceSystem {
|
|
14
|
+
readonly id = 'shooter-enemy-ai';
|
|
15
|
+
|
|
16
|
+
private obstacles: AABB[] = [];
|
|
17
|
+
|
|
18
|
+
init(session: unknown): void {
|
|
19
|
+
const s = session as ShooterSessionWithEnemies;
|
|
20
|
+
this.obstacles = getArenaObstacles(s.arena);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
update(session: unknown, dt: number, context: SystemContext): void {
|
|
24
|
+
const s = session as ShooterSessionWithEnemies;
|
|
25
|
+
const io = context.io as { sockets: { sockets: Map<string, { emit: (ev: string, data: unknown) => void }> } };
|
|
26
|
+
|
|
27
|
+
const aiCtx: AIUpdateContext = {
|
|
28
|
+
players: s.players,
|
|
29
|
+
obstacles: this.obstacles,
|
|
30
|
+
arena: s.arena,
|
|
31
|
+
enemyShots: [],
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
// Update all enemies
|
|
35
|
+
for (const enemy of s.enemies) {
|
|
36
|
+
updateShooterEnemyAI(enemy, dt, aiCtx);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Process enemy shots — raycast against players
|
|
40
|
+
for (const shot of aiCtx.enemyShots) {
|
|
41
|
+
const playerTargets: HitscanTarget[] = [];
|
|
42
|
+
for (const player of s.players.values()) {
|
|
43
|
+
if (player.hp <= 0) continue;
|
|
44
|
+
playerTargets.push({
|
|
45
|
+
entityId: player.entityId,
|
|
46
|
+
position: { x: player.body.x, y: player.body.y + 1, z: player.body.z },
|
|
47
|
+
radius: 0.5,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const result = hitscanRaycast(
|
|
52
|
+
shot.origin,
|
|
53
|
+
shot.direction,
|
|
54
|
+
100,
|
|
55
|
+
this.obstacles,
|
|
56
|
+
playerTargets,
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
if (result && result.entityId !== null) {
|
|
60
|
+
// Find and damage the player
|
|
61
|
+
for (const player of s.players.values()) {
|
|
62
|
+
if (player.entityId === result.entityId) {
|
|
63
|
+
player.hp = Math.max(0, player.hp - shot.damage);
|
|
64
|
+
|
|
65
|
+
// Notify the hit player
|
|
66
|
+
const sock = io.sockets?.sockets?.get(player.socketId);
|
|
67
|
+
if (sock) {
|
|
68
|
+
sock.emit('message', {
|
|
69
|
+
type: 'PLAYER_DAMAGED',
|
|
70
|
+
damage: shot.damage,
|
|
71
|
+
hp: player.hp,
|
|
72
|
+
maxHp: player.maxHp,
|
|
73
|
+
fromEntityId: shot.enemyId,
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// Player death
|
|
77
|
+
if (player.hp <= 0) {
|
|
78
|
+
sock.emit('message', {
|
|
79
|
+
type: 'PLAYER_DEAD',
|
|
80
|
+
waveReached: s.waveState.currentWave + 1,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Broadcast tracer VFX for enemy shot to all players
|
|
90
|
+
const endPoint = result
|
|
91
|
+
? result.hitPoint
|
|
92
|
+
: {
|
|
93
|
+
x: shot.origin.x + shot.direction.x * 100,
|
|
94
|
+
y: shot.origin.y + shot.direction.y * 100,
|
|
95
|
+
z: shot.origin.z + shot.direction.z * 100,
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
for (const player of s.players.values()) {
|
|
99
|
+
const sock = io.sockets?.sockets?.get(player.socketId);
|
|
100
|
+
if (sock) {
|
|
101
|
+
sock.emit('message', {
|
|
102
|
+
type: 'COMBAT_VFX',
|
|
103
|
+
vfxType: 'tracer',
|
|
104
|
+
origin: shot.origin,
|
|
105
|
+
endPoint,
|
|
106
|
+
hit: result !== null && result.entityId !== null,
|
|
107
|
+
isEnemy: true,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Entity Sync System — broadcasts player positions to all connected clients.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { ExperienceSystem, SystemContext } from '../../../../packages/core/src/index.js';
|
|
6
|
+
import type { ShooterSessionWithEnemies } from '../index.js';
|
|
7
|
+
|
|
8
|
+
export class ShooterEntitySyncSystem implements ExperienceSystem {
|
|
9
|
+
readonly id = 'shooter-entity-sync';
|
|
10
|
+
|
|
11
|
+
update(session: unknown, _dt: number, context: SystemContext): void {
|
|
12
|
+
const s = session as ShooterSessionWithEnemies;
|
|
13
|
+
const io = context.io as { sockets: { sockets: Map<string, { emit: (ev: string, data: unknown) => void }> } };
|
|
14
|
+
|
|
15
|
+
const entities: Array<{
|
|
16
|
+
entityId: number;
|
|
17
|
+
x: number;
|
|
18
|
+
y: number;
|
|
19
|
+
hp?: number;
|
|
20
|
+
maxHp?: number;
|
|
21
|
+
type: string;
|
|
22
|
+
z?: number;
|
|
23
|
+
worldY?: number;
|
|
24
|
+
facingAngle?: number;
|
|
25
|
+
}> = [];
|
|
26
|
+
|
|
27
|
+
for (const player of s.players.values()) {
|
|
28
|
+
entities.push({
|
|
29
|
+
entityId: player.entityId,
|
|
30
|
+
x: player.body.x,
|
|
31
|
+
y: player.body.z, // Map 3D Z to 2D Y for entity sync (convention)
|
|
32
|
+
z: player.body.y, // Height
|
|
33
|
+
worldY: player.body.y, // Three.js Y for renderer
|
|
34
|
+
hp: player.hp,
|
|
35
|
+
maxHp: player.maxHp,
|
|
36
|
+
type: 'player',
|
|
37
|
+
facingAngle: player.facingAngle,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Add enemies
|
|
42
|
+
for (const enemy of (s.enemies ?? [])) {
|
|
43
|
+
if (!enemy.alive) continue;
|
|
44
|
+
entities.push({
|
|
45
|
+
entityId: enemy.entityId,
|
|
46
|
+
x: enemy.body.x,
|
|
47
|
+
y: enemy.body.z,
|
|
48
|
+
z: enemy.body.y,
|
|
49
|
+
worldY: enemy.body.y,
|
|
50
|
+
hp: enemy.health,
|
|
51
|
+
maxHp: enemy.maxHealth,
|
|
52
|
+
type: enemy.enemyType,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Broadcast to all players in this session
|
|
57
|
+
for (const player of s.players.values()) {
|
|
58
|
+
const sock = io.sockets?.sockets?.get(player.socketId);
|
|
59
|
+
if (sock) {
|
|
60
|
+
sock.emit('message', {
|
|
61
|
+
type: 'ENTITY_SYNC',
|
|
62
|
+
tick: context.tick,
|
|
63
|
+
entities,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shooter Physics System — integrates Character Controller for player movement.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { ExperienceSystem, SystemContext, CharacterConfig, PhysicsBody } from '../../../../packages/core/src/index.js';
|
|
6
|
+
import { applyMovementInput, applyFriction, applyHorizontalMovement, updatePhysicsBody, snapToGround, DEFAULT_CHARACTER_CONFIG, DEFAULT_PHYSICS_CONFIG } from '../../../../packages/core/src/index.js';
|
|
7
|
+
import { resolveTerrainCollision } from '../../../../packages/core/src/physics/collision.js';
|
|
8
|
+
import type { Arena } from '../arena/arenaTypes.js';
|
|
9
|
+
import { getArenaGroundHeight } from '../arena/arenaGenerator.js';
|
|
10
|
+
import type { ShooterSessionWithEnemies } from '../index.js';
|
|
11
|
+
|
|
12
|
+
/** Shooter-tuned character config — responsive, fast. */
|
|
13
|
+
export const SHOOTER_CHARACTER_CONFIG: CharacterConfig = {
|
|
14
|
+
...DEFAULT_CHARACTER_CONFIG,
|
|
15
|
+
walkSpeed: 8,
|
|
16
|
+
sprintMultiplier: 1.5,
|
|
17
|
+
maxSpeed: 15,
|
|
18
|
+
friction: 30,
|
|
19
|
+
airFriction: 5,
|
|
20
|
+
airControl: 0.3,
|
|
21
|
+
acceleration: 50,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export class ShooterPhysicsSystem implements ExperienceSystem {
|
|
25
|
+
readonly id = 'shooter-physics';
|
|
26
|
+
|
|
27
|
+
private arena: Arena | null = null;
|
|
28
|
+
|
|
29
|
+
init(session: unknown): void {
|
|
30
|
+
this.arena = (session as ShooterSessionWithEnemies).arena;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
update(session: unknown, dt: number): void {
|
|
34
|
+
const s = session as ShooterSessionWithEnemies;
|
|
35
|
+
if (!this.arena) return;
|
|
36
|
+
|
|
37
|
+
const arena = this.arena;
|
|
38
|
+
const groundHeight = (x: number, z: number) => {
|
|
39
|
+
// Check if position is inside a cover block (acts as wall)
|
|
40
|
+
for (const cover of arena.covers) {
|
|
41
|
+
if (x >= cover.aabb.min.x && x <= cover.aabb.max.x &&
|
|
42
|
+
z >= cover.aabb.min.z && z <= cover.aabb.max.z) {
|
|
43
|
+
return cover.height; // Top of cover block
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return getArenaGroundHeight(arena, x, z);
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
for (const player of s.players.values()) {
|
|
50
|
+
const input = player.input;
|
|
51
|
+
|
|
52
|
+
// Apply character controller
|
|
53
|
+
applyMovementInput(player.body, input, dt, SHOOTER_CHARACTER_CONFIG);
|
|
54
|
+
applyFriction(player.body, dt, SHOOTER_CHARACTER_CONFIG);
|
|
55
|
+
applyHorizontalMovement(player.body, dt);
|
|
56
|
+
|
|
57
|
+
// Terrain collision (walls + cover)
|
|
58
|
+
resolveTerrainCollision(player.body, groundHeight);
|
|
59
|
+
|
|
60
|
+
// Clamp to arena bounds (prevent falling through outer walls)
|
|
61
|
+
const hw = arena.width / 2;
|
|
62
|
+
const hd = arena.depth / 2;
|
|
63
|
+
const margin = player.body.radius + 0.1;
|
|
64
|
+
if (player.body.x < -hw + margin) { player.body.x = -hw + margin; player.body.velocityX = 0; }
|
|
65
|
+
if (player.body.x > hw - margin) { player.body.x = hw - margin; player.body.velocityX = 0; }
|
|
66
|
+
if (player.body.z < -hd + margin) { player.body.z = -hd + margin; player.body.velocityZ = 0; }
|
|
67
|
+
if (player.body.z > hd - margin) { player.body.z = hd - margin; player.body.velocityZ = 0; }
|
|
68
|
+
|
|
69
|
+
// Gravity + ground
|
|
70
|
+
const flatGround = (x: number, z: number) => getArenaGroundHeight(arena, x, z);
|
|
71
|
+
updatePhysicsBody(player.body, dt, flatGround, DEFAULT_PHYSICS_CONFIG);
|
|
72
|
+
if (player.body.isGrounded) {
|
|
73
|
+
snapToGround(player.body, flatGround, DEFAULT_PHYSICS_CONFIG);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Also clamp enemies to arena bounds
|
|
78
|
+
for (const enemy of (s.enemies ?? [])) {
|
|
79
|
+
if (!enemy.alive) continue;
|
|
80
|
+
const hw = arena.width / 2;
|
|
81
|
+
const hd = arena.depth / 2;
|
|
82
|
+
const margin = enemy.body.radius + 0.1;
|
|
83
|
+
if (enemy.body.x < -hw + margin) { enemy.body.x = -hw + margin; enemy.body.velocityX = 0; }
|
|
84
|
+
if (enemy.body.x > hw - margin) { enemy.body.x = hw - margin; enemy.body.velocityX = 0; }
|
|
85
|
+
if (enemy.body.z < -hd + margin) { enemy.body.z = -hd + margin; enemy.body.velocityZ = 0; }
|
|
86
|
+
if (enemy.body.z > hd - margin) { enemy.body.z = hd - margin; enemy.body.velocityZ = 0; }
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wave Spawner System — spawns enemies in escalating waves.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { ExperienceSystem, SystemContext } from '../../../../packages/core/src/index.js';
|
|
6
|
+
import { createShooterEnemy, type ShooterEnemy } from '../ai/aiStateMachine.js';
|
|
7
|
+
import { getWaveDefinition, type WaveDefinition } from '../data/wave-definitions.js';
|
|
8
|
+
import type { ShooterSessionWithEnemies } from '../index.js';
|
|
9
|
+
|
|
10
|
+
export class WaveSpawnerSystem implements ExperienceSystem {
|
|
11
|
+
readonly id = 'shooter-wave-spawner';
|
|
12
|
+
|
|
13
|
+
update(session: unknown, dt: number, context: SystemContext): void {
|
|
14
|
+
const s = session as ShooterSessionWithEnemies;
|
|
15
|
+
|
|
16
|
+
// Don't run if no players
|
|
17
|
+
if (s.players.size === 0) return;
|
|
18
|
+
|
|
19
|
+
const ws = s.waveState;
|
|
20
|
+
|
|
21
|
+
if (!ws.waveActive) {
|
|
22
|
+
// Between waves — count down delay
|
|
23
|
+
ws.delayTimer -= dt;
|
|
24
|
+
if (ws.delayTimer <= 0) {
|
|
25
|
+
spawnWave(s);
|
|
26
|
+
}
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Wave active — check if all enemies are dead
|
|
31
|
+
const aliveCount = s.enemies.filter(e => e.alive).length;
|
|
32
|
+
if (aliveCount === 0) {
|
|
33
|
+
ws.waveActive = false;
|
|
34
|
+
const waveDef = getWaveDefinition(ws.currentWave);
|
|
35
|
+
ws.delayTimer = waveDef.delayAfterClear;
|
|
36
|
+
ws.currentWave++;
|
|
37
|
+
|
|
38
|
+
// Notify players
|
|
39
|
+
const io = context.io as { sockets: { sockets: Map<string, { emit: (ev: string, data: unknown) => void }> } };
|
|
40
|
+
for (const player of s.players.values()) {
|
|
41
|
+
const sock = io.sockets?.sockets?.get(player.socketId);
|
|
42
|
+
if (sock) {
|
|
43
|
+
sock.emit('message', {
|
|
44
|
+
type: 'WAVE_CLEAR',
|
|
45
|
+
waveNumber: ws.currentWave,
|
|
46
|
+
nextWaveIn: ws.delayTimer,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function spawnWave(s: ShooterSessionWithEnemies): void {
|
|
55
|
+
const waveDef = getWaveDefinition(s.waveState.currentWave);
|
|
56
|
+
|
|
57
|
+
// Clear dead enemies from previous wave
|
|
58
|
+
s.enemies = s.enemies.filter(e => e.alive);
|
|
59
|
+
|
|
60
|
+
// Collect spawn points — use arena spawns that are far from players
|
|
61
|
+
const spawnPoints = [...s.arena.spawnPoints];
|
|
62
|
+
|
|
63
|
+
let spawnIdx = 0;
|
|
64
|
+
for (const entry of waveDef.enemies) {
|
|
65
|
+
for (let i = 0; i < entry.count; i++) {
|
|
66
|
+
const spawn = spawnPoints[spawnIdx % spawnPoints.length];
|
|
67
|
+
// Offset slightly so enemies don't stack
|
|
68
|
+
const offset = (spawnIdx * 1.5) % 4 - 2;
|
|
69
|
+
const position = {
|
|
70
|
+
x: spawn.x + offset,
|
|
71
|
+
y: spawn.y,
|
|
72
|
+
z: spawn.z + offset,
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const enemy = createShooterEnemy(
|
|
76
|
+
s.nextEntityId++,
|
|
77
|
+
entry.type,
|
|
78
|
+
position,
|
|
79
|
+
s.nextEntityId * 31337 + s.waveState.currentWave,
|
|
80
|
+
);
|
|
81
|
+
s.enemies.push(enemy);
|
|
82
|
+
spawnIdx++;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
s.waveState.waveActive = true;
|
|
87
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Weapon System — hitscan shooting, cooldown, ammo, reload.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { ExperienceSystem, SystemContext } from '../../../../packages/core/src/index.js';
|
|
6
|
+
import { processShoot, updateWeaponState, startReload, DEFAULT_WEAPON_CONFIG } from '../data/weapon-config.js';
|
|
7
|
+
import type { WeaponConfig } from '../data/weapon-config.js';
|
|
8
|
+
import { hitscanRaycast } from '../arena/hitscan.js';
|
|
9
|
+
import { getArenaObstacles } from '../arena/arenaGenerator.js';
|
|
10
|
+
import type { AABB } from '../arena/arenaTypes.js';
|
|
11
|
+
import type { ShooterSessionWithEnemies, ShooterPlayer } from '../index.js';
|
|
12
|
+
import { damageEnemy } from '../ai/aiStateMachine.js';
|
|
13
|
+
import type { HitscanTarget } from '../arena/hitscan.js';
|
|
14
|
+
|
|
15
|
+
export class WeaponSystem implements ExperienceSystem {
|
|
16
|
+
readonly id = 'shooter-weapon';
|
|
17
|
+
|
|
18
|
+
private obstacles: AABB[] = [];
|
|
19
|
+
|
|
20
|
+
init(session: unknown): void {
|
|
21
|
+
const s = session as ShooterSessionWithEnemies;
|
|
22
|
+
this.obstacles = getArenaObstacles(s.arena);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
update(session: unknown, dt: number, context: SystemContext): void {
|
|
26
|
+
const s = session as ShooterSessionWithEnemies;
|
|
27
|
+
const io = context.io as { sockets: { sockets: Map<string, { emit: (ev: string, data: unknown) => void }> } };
|
|
28
|
+
|
|
29
|
+
for (const player of s.players.values()) {
|
|
30
|
+
const wasReloading = player.weapon.isReloading;
|
|
31
|
+
const prevAmmo = player.weapon.currentAmmo;
|
|
32
|
+
|
|
33
|
+
// Tick weapon timers
|
|
34
|
+
updateWeaponState(player.weapon, dt, DEFAULT_WEAPON_CONFIG);
|
|
35
|
+
|
|
36
|
+
// Process reload request
|
|
37
|
+
if (player.wantsReload) {
|
|
38
|
+
startReload(player.weapon, DEFAULT_WEAPON_CONFIG);
|
|
39
|
+
player.wantsReload = false;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Send WEAPON_STATE on reload start or reload completion
|
|
43
|
+
const reloadChanged = player.weapon.isReloading !== wasReloading || player.weapon.currentAmmo !== prevAmmo;
|
|
44
|
+
if (reloadChanged) {
|
|
45
|
+
const sock = io.sockets?.sockets?.get(player.socketId);
|
|
46
|
+
if (sock) {
|
|
47
|
+
sock.emit('message', {
|
|
48
|
+
type: 'WEAPON_STATE',
|
|
49
|
+
currentAmmo: player.weapon.currentAmmo,
|
|
50
|
+
maxAmmo: DEFAULT_WEAPON_CONFIG.maxAmmo,
|
|
51
|
+
isReloading: player.weapon.isReloading,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Process shoot intents
|
|
57
|
+
while (player.shootQueue.length > 0) {
|
|
58
|
+
const intent = player.shootQueue.shift()!;
|
|
59
|
+
const fired = processShoot(player.weapon, DEFAULT_WEAPON_CONFIG);
|
|
60
|
+
if (!fired) {
|
|
61
|
+
// Auto-reload when trying to shoot with empty magazine
|
|
62
|
+
if (player.weapon.currentAmmo <= 0 && !player.weapon.isReloading) {
|
|
63
|
+
startReload(player.weapon, DEFAULT_WEAPON_CONFIG);
|
|
64
|
+
}
|
|
65
|
+
break;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Build enemy targets for hitscan — two spheres per enemy (body + head)
|
|
69
|
+
const enemyTargets: HitscanTarget[] = [];
|
|
70
|
+
for (const e of (s.enemies ?? [])) {
|
|
71
|
+
if (!e.alive) continue;
|
|
72
|
+
// Body sphere (torso height, large radius)
|
|
73
|
+
enemyTargets.push({
|
|
74
|
+
entityId: e.entityId,
|
|
75
|
+
position: { x: e.body.x, y: e.body.y + 0.6, z: e.body.z },
|
|
76
|
+
radius: 0.8,
|
|
77
|
+
});
|
|
78
|
+
// Head sphere (higher, smaller)
|
|
79
|
+
enemyTargets.push({
|
|
80
|
+
entityId: e.entityId,
|
|
81
|
+
position: { x: e.body.x, y: e.body.y + 1.3, z: e.body.z },
|
|
82
|
+
radius: 0.5,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Raycast
|
|
87
|
+
const result = hitscanRaycast(
|
|
88
|
+
intent.origin,
|
|
89
|
+
intent.direction,
|
|
90
|
+
DEFAULT_WEAPON_CONFIG.range,
|
|
91
|
+
this.obstacles,
|
|
92
|
+
enemyTargets,
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
// Send weapon state update
|
|
96
|
+
const sock = io.sockets?.sockets?.get(player.socketId);
|
|
97
|
+
if (sock) {
|
|
98
|
+
sock.emit('message', {
|
|
99
|
+
type: 'WEAPON_STATE',
|
|
100
|
+
currentAmmo: player.weapon.currentAmmo,
|
|
101
|
+
maxAmmo: DEFAULT_WEAPON_CONFIG.maxAmmo,
|
|
102
|
+
isReloading: player.weapon.isReloading,
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// Send hit confirm / tracer VFX
|
|
106
|
+
const endPoint = result
|
|
107
|
+
? result.hitPoint
|
|
108
|
+
: {
|
|
109
|
+
x: intent.origin.x + intent.direction.x * DEFAULT_WEAPON_CONFIG.range,
|
|
110
|
+
y: intent.origin.y + intent.direction.y * DEFAULT_WEAPON_CONFIG.range,
|
|
111
|
+
z: intent.origin.z + intent.direction.z * DEFAULT_WEAPON_CONFIG.range,
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
sock.emit('message', {
|
|
115
|
+
type: 'COMBAT_VFX',
|
|
116
|
+
vfxType: 'tracer',
|
|
117
|
+
origin: intent.origin,
|
|
118
|
+
endPoint,
|
|
119
|
+
hit: result !== null && result.entityId !== null,
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
if (result?.entityId !== null && result?.entityId !== undefined) {
|
|
124
|
+
// Apply damage to enemy
|
|
125
|
+
const hitEnemy = (s.enemies ?? []).find(e => e.entityId === result.entityId);
|
|
126
|
+
let killed = false;
|
|
127
|
+
if (hitEnemy) {
|
|
128
|
+
killed = damageEnemy(hitEnemy, DEFAULT_WEAPON_CONFIG.damage);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
sock.emit('message', {
|
|
132
|
+
type: 'HIT_CONFIRM',
|
|
133
|
+
targetEntityId: result.entityId,
|
|
134
|
+
damage: DEFAULT_WEAPON_CONFIG.damage,
|
|
135
|
+
hitX: result.hitPoint.x,
|
|
136
|
+
hitY: result.hitPoint.y,
|
|
137
|
+
hitZ: result.hitPoint.z,
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
if (killed) {
|
|
141
|
+
// Notify all players of enemy death
|
|
142
|
+
for (const p of s.players.values()) {
|
|
143
|
+
const pSock = io.sockets?.sockets?.get(p.socketId);
|
|
144
|
+
if (pSock) {
|
|
145
|
+
pSock.emit('message', {
|
|
146
|
+
type: 'ENEMY_KILLED',
|
|
147
|
+
entityId: result.entityId,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"areas": [
|
|
3
|
+
{
|
|
4
|
+
"id": "overworld",
|
|
5
|
+
"type": "voxel",
|
|
6
|
+
"transitions": [
|
|
7
|
+
{ "tileType": "D", "targetArea": "dungeon", "spawnX": -1, "spawnY": -1 }
|
|
8
|
+
]
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
"id": "dungeon",
|
|
12
|
+
"type": "dungeon",
|
|
13
|
+
"transitions": [
|
|
14
|
+
{ "cellType": "transition", "targetArea": "overworld", "spawnX": -1, "spawnY": -1 }
|
|
15
|
+
]
|
|
16
|
+
}
|
|
17
|
+
]
|
|
18
|
+
}
|