feat(game): add enemy spawning with wave system and chase AI

Red sphere enemies spawn at arena edges via a wave system that
escalates each round. Enemies use direct seek steering to chase
the player. Both shadow and color passes render enemies.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-25 17:41:34 +09:00
parent 5496525a7f
commit a7cd14f413
4 changed files with 144 additions and 6 deletions

View File

@@ -0,0 +1,26 @@
use voltex_math::Vec3;
pub struct Enemy {
pub position: Vec3,
pub speed: f32,
pub alive: bool,
}
impl Enemy {
pub fn new(position: Vec3) -> Self {
Self {
position,
speed: 3.0,
alive: true,
}
}
pub fn update(&mut self, player_pos: Vec3, dt: f32) {
let dir = player_pos - self.position;
let dir_xz = Vec3::new(dir.x, 0.0, dir.z);
if dir_xz.length_squared() > 0.01 {
let move_dir = dir_xz.normalize();
self.position = self.position + move_dir * self.speed * dt;
}
}
}

View File

@@ -2,12 +2,15 @@ use voltex_math::Vec3;
use voltex_platform::InputState;
use crate::player::Player;
use crate::projectile::Projectile;
use crate::enemy::Enemy;
use crate::wave::WaveSystem;
pub struct GameState {
pub player: Player,
pub projectiles: Vec<Projectile>,
pub enemies: Vec<Enemy>,
pub wave: WaveSystem,
pub score: u32,
pub wave: u32,
pub game_over: bool,
}
@@ -16,8 +19,9 @@ impl GameState {
Self {
player: Player::new(),
projectiles: Vec::new(),
enemies: Vec::new(),
wave: WaveSystem::new(),
score: 0,
wave: 1,
game_over: false,
}
}
@@ -42,5 +46,23 @@ impl GameState {
proj.update(dt);
}
self.projectiles.retain(|p| p.is_alive());
// Wave system spawns
let alive_count = self.enemies.iter().filter(|e| e.alive).count();
let spawns = self.wave.update(dt, alive_count);
for pos in spawns {
self.enemies.push(Enemy::new(pos));
}
// Update enemies (chase player)
let player_pos = self.player.position;
for enemy in &mut self.enemies {
if enemy.alive {
enemy.update(player_pos, dt);
}
}
// Remove dead enemies
self.enemies.retain(|e| e.alive);
}
}

View File

@@ -1,8 +1,10 @@
mod arena;
mod camera;
mod enemy;
mod game;
mod player;
mod projectile;
mod wave;
use winit::{
application::ApplicationHandler,
@@ -415,11 +417,13 @@ impl ApplicationHandler for SurvivorApp {
// Camera follows player
state.camera.target = state.game.player.position;
// Build per-frame model/material lists: arena entities + player + projectiles
// Build per-frame model/material lists: arena entities + player + projectiles + enemies
let player_pos = state.game.player.position;
let player_model = Mat4::translation(player_pos.x, player_pos.y + 0.5, player_pos.z);
let num_projectiles = state.game.projectiles.len().min(MAX_ENTITIES - ARENA_ENTITIES - 1);
let num_entities = ARENA_ENTITIES + 1 + num_projectiles;
let base_enemy_idx = ARENA_ENTITIES + 1 + num_projectiles;
let num_enemies = state.game.enemies.len().min(MAX_ENTITIES.saturating_sub(base_enemy_idx));
let num_entities = base_enemy_idx + num_enemies;
// ----- Compute light VP for shadows -----
let light_dir = Vec3::new(-1.0, -2.0, -1.0).normalize();
@@ -440,11 +444,16 @@ impl ApplicationHandler for SurvivorApp {
state.models[i]
} else if i == ARENA_ENTITIES {
player_model
} else {
} else if i < base_enemy_idx {
let pi = i - ARENA_ENTITIES - 1;
let p = &state.game.projectiles[pi];
Mat4::translation(p.position.x, p.position.y, p.position.z)
* Mat4::scale(0.3, 0.3, 0.3)
} else {
let ei = i - base_enemy_idx;
let e = &state.game.enemies[ei];
Mat4::translation(e.position.x, e.position.y, e.position.z)
* Mat4::scale(0.4, 0.4, 0.4)
};
let sp_uniform = ShadowPassUniform {
light_vp_model: (light_vp * model).cols,
@@ -475,13 +484,20 @@ impl ApplicationHandler for SurvivorApp {
} else if i == ARENA_ENTITIES {
// Player: blue sphere
(player_model, [0.2, 0.4, 1.0, 1.0], 0.3, 0.5)
} else {
} else if i < base_enemy_idx {
// Projectile: yellow sphere
let pi = i - ARENA_ENTITIES - 1;
let p = &state.game.projectiles[pi];
let m = Mat4::translation(p.position.x, p.position.y, p.position.z)
* Mat4::scale(0.3, 0.3, 0.3);
(m, [1.0, 0.9, 0.1, 1.0], 0.1, 0.4)
} else {
// Enemy: red sphere
let ei = i - base_enemy_idx;
let e = &state.game.enemies[ei];
let m = Mat4::translation(e.position.x, e.position.y, e.position.z)
* Mat4::scale(0.4, 0.4, 0.4);
(m, [1.0, 0.15, 0.1, 1.0], 0.2, 0.6)
};
let cam_uniform = CameraUniform {
@@ -619,6 +635,10 @@ impl ApplicationHandler for SurvivorApp {
for pi in 0..num_projectiles {
draw_shadow!(shadow_pass, state.player_mesh, ARENA_ENTITIES + 1 + pi);
}
// Enemies
for ei in 0..num_enemies {
draw_shadow!(shadow_pass, state.player_mesh, base_enemy_idx + ei);
}
}
// ===== Pass 2: Color (PBR) =====
@@ -670,6 +690,10 @@ impl ApplicationHandler for SurvivorApp {
for pi in 0..num_projectiles {
draw_color!(render_pass, state.player_mesh, ARENA_ENTITIES + 1 + pi);
}
// Enemies
for ei in 0..num_enemies {
draw_color!(render_pass, state.player_mesh, base_enemy_idx + ei);
}
}
state.gpu.queue.submit(std::iter::once(encoder.finish()));

View File

@@ -0,0 +1,66 @@
use voltex_math::Vec3;
pub struct WaveSystem {
pub wave_number: u32,
pub enemies_to_spawn: u32,
pub spawn_timer: f32,
pub wave_delay: f32,
pub between_spawn_delay: f32,
pub spawn_accumulator: f32,
}
impl WaveSystem {
pub fn new() -> Self {
Self {
wave_number: 1,
enemies_to_spawn: 3,
spawn_timer: 2.0, // 2s before first wave
wave_delay: 5.0,
between_spawn_delay: 0.3, // stagger spawns
spawn_accumulator: 0.0,
}
}
/// Returns spawn positions for this frame (if any)
pub fn update(&mut self, dt: f32, current_enemy_count: usize) -> Vec<Vec3> {
let mut spawns = Vec::new();
self.spawn_timer -= dt;
if self.spawn_timer <= 0.0 && self.enemies_to_spawn > 0 {
self.spawn_accumulator += dt;
while self.spawn_accumulator >= self.between_spawn_delay && self.enemies_to_spawn > 0 {
self.spawn_accumulator -= self.between_spawn_delay;
self.enemies_to_spawn -= 1;
spawns.push(random_edge_position(
self.wave_number * 100 + self.enemies_to_spawn + spawns.len() as u32,
));
}
}
// All enemies spawned and killed -> next wave
if self.enemies_to_spawn == 0 && current_enemy_count == 0 && self.spawn_timer <= 0.0 {
self.wave_number += 1;
self.enemies_to_spawn = 2 + self.wave_number;
self.spawn_timer = self.wave_delay;
self.spawn_accumulator = 0.0;
}
spawns
}
}
fn random_edge_position(seed: u32) -> Vec3 {
// Deterministic pseudo-random position on arena edge
let hash = seed.wrapping_mul(2654435761);
let side = hash % 4;
let t = ((hash >> 8) % 100) as f32 / 100.0; // 0..1
let pos = t * 18.0 - 9.0; // -9..9
match side {
0 => Vec3::new(pos, 0.5, -9.5), // north edge
1 => Vec3::new(pos, 0.5, 9.5), // south edge
2 => Vec3::new(-9.5, 0.5, pos), // west edge
_ => Vec3::new(9.5, 0.5, pos), // east edge
}
}