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:
26
examples/survivor_game/src/enemy.rs
Normal file
26
examples/survivor_game/src/enemy.rs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()));
|
||||
|
||||
66
examples/survivor_game/src/wave.rs
Normal file
66
examples/survivor_game/src/wave.rs
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user