feat(game): add HUD, audio SFX, and game over/restart
Add IMGUI HUD overlay showing HP, wave, score, and enemy count. Display centered Game Over panel with final score and restart prompt. Add procedural sine-wave audio clips for shooting (440Hz) and enemy kills (220Hz) via voltex_audio. Player blinks white during invincibility. Press R to restart after game over. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -11,5 +11,7 @@ wgpu.workspace = true
|
|||||||
winit.workspace = true
|
winit.workspace = true
|
||||||
bytemuck.workspace = true
|
bytemuck.workspace = true
|
||||||
pollster.workspace = true
|
pollster.workspace = true
|
||||||
|
voltex_editor.workspace = true
|
||||||
|
voltex_audio.workspace = true
|
||||||
env_logger.workspace = true
|
env_logger.workspace = true
|
||||||
log.workspace = true
|
log.workspace = true
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ use voltex_renderer::{
|
|||||||
IblResources, pbr_texture_bind_group_layout, create_pbr_texture_bind_group,
|
IblResources, pbr_texture_bind_group_layout, create_pbr_texture_bind_group,
|
||||||
generate_sphere,
|
generate_sphere,
|
||||||
};
|
};
|
||||||
|
use voltex_editor::{UiContext, UiRenderer};
|
||||||
|
use voltex_audio::{AudioClip, AudioSystem};
|
||||||
use wgpu::util::DeviceExt;
|
use wgpu::util::DeviceExt;
|
||||||
|
|
||||||
use arena::{generate_arena, arena_model_matrices};
|
use arena::{generate_arena, arena_model_matrices};
|
||||||
@@ -32,6 +34,16 @@ use game::GameState;
|
|||||||
const ARENA_ENTITIES: usize = 5; // 1 floor + 4 obstacles
|
const ARENA_ENTITIES: usize = 5; // 1 floor + 4 obstacles
|
||||||
const MAX_ENTITIES: usize = 100; // pre-allocate for future enemies/projectiles
|
const MAX_ENTITIES: usize = 100; // pre-allocate for future enemies/projectiles
|
||||||
|
|
||||||
|
fn generate_sine(freq: f32, duration: f32, sample_rate: u32) -> AudioClip {
|
||||||
|
let samples = (sample_rate as f32 * duration) as usize;
|
||||||
|
let mut data = Vec::with_capacity(samples);
|
||||||
|
for i in 0..samples {
|
||||||
|
let t = i as f32 / sample_rate as f32;
|
||||||
|
data.push((t * freq * std::f32::consts::TAU).sin() * 0.3);
|
||||||
|
}
|
||||||
|
AudioClip::new(data, sample_rate, 1)
|
||||||
|
}
|
||||||
|
|
||||||
struct SurvivorApp {
|
struct SurvivorApp {
|
||||||
state: Option<AppState>,
|
state: Option<AppState>,
|
||||||
}
|
}
|
||||||
@@ -65,6 +77,11 @@ struct AppState {
|
|||||||
shadow_pass_buffer: wgpu::Buffer,
|
shadow_pass_buffer: wgpu::Buffer,
|
||||||
shadow_pass_bind_group: wgpu::BindGroup,
|
shadow_pass_bind_group: wgpu::BindGroup,
|
||||||
_ibl: IblResources,
|
_ibl: IblResources,
|
||||||
|
// UI
|
||||||
|
ui: UiContext,
|
||||||
|
ui_renderer: UiRenderer,
|
||||||
|
// Audio
|
||||||
|
audio: Option<AudioSystem>,
|
||||||
// Misc
|
// Misc
|
||||||
input: InputState,
|
input: InputState,
|
||||||
timer: GameTimer,
|
timer: GameTimer,
|
||||||
@@ -300,6 +317,22 @@ impl ApplicationHandler for SurvivorApp {
|
|||||||
&shadow_layout,
|
&shadow_layout,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ---- UI ----
|
||||||
|
let screen_w = gpu.config.width as f32;
|
||||||
|
let screen_h = gpu.config.height as f32;
|
||||||
|
let ui = UiContext::new(screen_w, screen_h);
|
||||||
|
let ui_renderer = UiRenderer::new(
|
||||||
|
&gpu.device,
|
||||||
|
&gpu.queue,
|
||||||
|
gpu.surface_format,
|
||||||
|
&ui.font,
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---- Audio ----
|
||||||
|
let shoot_clip = generate_sine(440.0, 0.05, 44100);
|
||||||
|
let kill_clip = generate_sine(220.0, 0.1, 44100);
|
||||||
|
let audio = AudioSystem::new(vec![shoot_clip, kill_clip]).ok();
|
||||||
|
|
||||||
self.state = Some(AppState {
|
self.state = Some(AppState {
|
||||||
window,
|
window,
|
||||||
gpu,
|
gpu,
|
||||||
@@ -323,6 +356,9 @@ impl ApplicationHandler for SurvivorApp {
|
|||||||
shadow_pass_buffer,
|
shadow_pass_buffer,
|
||||||
shadow_pass_bind_group,
|
shadow_pass_bind_group,
|
||||||
_ibl: ibl,
|
_ibl: ibl,
|
||||||
|
ui,
|
||||||
|
ui_renderer,
|
||||||
|
audio,
|
||||||
input: InputState::new(),
|
input: InputState::new(),
|
||||||
timer: GameTimer::new(60),
|
timer: GameTimer::new(60),
|
||||||
cam_aligned_size,
|
cam_aligned_size,
|
||||||
@@ -393,14 +429,20 @@ impl ApplicationHandler for SurvivorApp {
|
|||||||
let dt = state.timer.frame_dt();
|
let dt = state.timer.frame_dt();
|
||||||
state.input.begin_frame();
|
state.input.begin_frame();
|
||||||
|
|
||||||
|
// Restart on R when game over
|
||||||
|
if state.game.game_over && state.input.is_key_just_pressed(KeyCode::KeyR) {
|
||||||
|
state.game = GameState::new();
|
||||||
|
}
|
||||||
|
|
||||||
// Update game logic
|
// Update game logic
|
||||||
state.game.update(&state.input, dt);
|
let events = state.game.update(&state.input, dt);
|
||||||
|
|
||||||
// Mouse aim + shooting
|
// Mouse aim + shooting
|
||||||
let aspect = state.gpu.config.width as f32 / state.gpu.config.height as f32;
|
let aspect = state.gpu.config.width as f32 / state.gpu.config.height as f32;
|
||||||
let (mx, my) = state.input.mouse_position();
|
let (mx, my) = state.input.mouse_position();
|
||||||
let screen_w = state.gpu.config.width as f32;
|
let screen_w = state.gpu.config.width as f32;
|
||||||
let screen_h = state.gpu.config.height as f32;
|
let screen_h = state.gpu.config.height as f32;
|
||||||
|
let mut shot_fired = false;
|
||||||
if let Some(world_pos) =
|
if let Some(world_pos) =
|
||||||
mouse_to_world_xz(mx as f32, my as f32, screen_w, screen_h, &state.camera, aspect)
|
mouse_to_world_xz(mx as f32, my as f32, screen_w, screen_h, &state.camera, aspect)
|
||||||
{
|
{
|
||||||
@@ -409,11 +451,21 @@ impl ApplicationHandler for SurvivorApp {
|
|||||||
if aim_xz.length_squared() > 0.01 {
|
if aim_xz.length_squared() > 0.01 {
|
||||||
let aim_dir = aim_xz.normalize();
|
let aim_dir = aim_xz.normalize();
|
||||||
if state.input.is_mouse_button_pressed(MouseButton::Left) {
|
if state.input.is_mouse_button_pressed(MouseButton::Left) {
|
||||||
state.game.try_shoot(aim_dir);
|
shot_fired = state.game.try_shoot(aim_dir);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Play audio SFX
|
||||||
|
if let Some(ref audio) = state.audio {
|
||||||
|
if shot_fired {
|
||||||
|
audio.play(0, 0.3, false);
|
||||||
|
}
|
||||||
|
if events.enemies_killed > 0 {
|
||||||
|
audio.play(1, 0.5, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Camera follows player
|
// Camera follows player
|
||||||
state.camera.target = state.game.player.position;
|
state.camera.target = state.game.player.position;
|
||||||
|
|
||||||
@@ -482,8 +534,19 @@ impl ApplicationHandler for SurvivorApp {
|
|||||||
let (c, m, r) = state.materials[i];
|
let (c, m, r) = state.materials[i];
|
||||||
(state.models[i], c, m, r)
|
(state.models[i], c, m, r)
|
||||||
} else if i == ARENA_ENTITIES {
|
} else if i == ARENA_ENTITIES {
|
||||||
// Player: blue sphere
|
// Player: blue sphere (flash white when invincible)
|
||||||
(player_model, [0.2, 0.4, 1.0, 1.0], 0.3, 0.5)
|
let player_color = if state.game.player.invincible_timer > 0.0 {
|
||||||
|
// Blink effect
|
||||||
|
let blink = (state.game.player.invincible_timer * 10.0).sin();
|
||||||
|
if blink > 0.0 {
|
||||||
|
[1.0, 1.0, 1.0, 1.0]
|
||||||
|
} else {
|
||||||
|
[0.2, 0.4, 1.0, 1.0]
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
[0.2, 0.4, 1.0, 1.0]
|
||||||
|
};
|
||||||
|
(player_model, player_color, 0.3, 0.5)
|
||||||
} else if i < base_enemy_idx {
|
} else if i < base_enemy_idx {
|
||||||
// Projectile: yellow sphere
|
// Projectile: yellow sphere
|
||||||
let pi = i - ARENA_ENTITIES - 1;
|
let pi = i - ARENA_ENTITIES - 1;
|
||||||
@@ -696,6 +759,44 @@ impl ApplicationHandler for SurvivorApp {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== Pass 3: UI Overlay =====
|
||||||
|
{
|
||||||
|
let mouse_down = state.input.is_mouse_button_pressed(MouseButton::Left);
|
||||||
|
state.ui.begin_frame(mx as f32, my as f32, mouse_down);
|
||||||
|
|
||||||
|
// Top-left: game info
|
||||||
|
state.ui.text(&format!("HP: {}/{}", state.game.player.hp, state.game.player.max_hp));
|
||||||
|
state.ui.text(&format!("Wave: {}", state.game.wave.wave_number));
|
||||||
|
state.ui.text(&format!("Score: {}", state.game.score));
|
||||||
|
state.ui.text(&format!("Enemies: {}", state.game.enemies.len()));
|
||||||
|
|
||||||
|
if state.game.game_over {
|
||||||
|
state.ui.begin_panel(
|
||||||
|
"Game Over",
|
||||||
|
screen_w / 2.0 - 120.0,
|
||||||
|
screen_h / 2.0 - 60.0,
|
||||||
|
240.0,
|
||||||
|
120.0,
|
||||||
|
);
|
||||||
|
state.ui.text("GAME OVER");
|
||||||
|
state.ui.text(&format!("Final Score: {}", state.game.score));
|
||||||
|
state.ui.text("Press R to restart");
|
||||||
|
state.ui.end_panel();
|
||||||
|
}
|
||||||
|
|
||||||
|
state.ui.end_frame();
|
||||||
|
|
||||||
|
state.ui_renderer.render(
|
||||||
|
&state.gpu.device,
|
||||||
|
&state.gpu.queue,
|
||||||
|
&mut encoder,
|
||||||
|
&color_view,
|
||||||
|
&state.ui.draw_list,
|
||||||
|
screen_w,
|
||||||
|
screen_h,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
state.gpu.queue.submit(std::iter::once(encoder.finish()));
|
state.gpu.queue.submit(std::iter::once(encoder.finish()));
|
||||||
output.present();
|
output.present();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user