From 63d5ae2c2549bf07f13c4cbe20768853b17df3e5 Mon Sep 17 00:00:00 2001 From: tolelom <98kimsungmin@naver.com> Date: Wed, 25 Mar 2026 17:46:41 +0900 Subject: [PATCH] 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) --- examples/survivor_game/Cargo.toml | 2 + examples/survivor_game/src/main.rs | 109 +++++++++++++++++++++++++++-- 2 files changed, 107 insertions(+), 4 deletions(-) diff --git a/examples/survivor_game/Cargo.toml b/examples/survivor_game/Cargo.toml index 363c90f..67c7e29 100644 --- a/examples/survivor_game/Cargo.toml +++ b/examples/survivor_game/Cargo.toml @@ -11,5 +11,7 @@ wgpu.workspace = true winit.workspace = true bytemuck.workspace = true pollster.workspace = true +voltex_editor.workspace = true +voltex_audio.workspace = true env_logger.workspace = true log.workspace = true diff --git a/examples/survivor_game/src/main.rs b/examples/survivor_game/src/main.rs index bf46e61..9d21c30 100644 --- a/examples/survivor_game/src/main.rs +++ b/examples/survivor_game/src/main.rs @@ -23,6 +23,8 @@ use voltex_renderer::{ IblResources, pbr_texture_bind_group_layout, create_pbr_texture_bind_group, generate_sphere, }; +use voltex_editor::{UiContext, UiRenderer}; +use voltex_audio::{AudioClip, AudioSystem}; use wgpu::util::DeviceExt; use arena::{generate_arena, arena_model_matrices}; @@ -32,6 +34,16 @@ use game::GameState; const ARENA_ENTITIES: usize = 5; // 1 floor + 4 obstacles 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 { state: Option, } @@ -65,6 +77,11 @@ struct AppState { shadow_pass_buffer: wgpu::Buffer, shadow_pass_bind_group: wgpu::BindGroup, _ibl: IblResources, + // UI + ui: UiContext, + ui_renderer: UiRenderer, + // Audio + audio: Option, // Misc input: InputState, timer: GameTimer, @@ -300,6 +317,22 @@ impl ApplicationHandler for SurvivorApp { &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 { window, gpu, @@ -323,6 +356,9 @@ impl ApplicationHandler for SurvivorApp { shadow_pass_buffer, shadow_pass_bind_group, _ibl: ibl, + ui, + ui_renderer, + audio, input: InputState::new(), timer: GameTimer::new(60), cam_aligned_size, @@ -393,14 +429,20 @@ impl ApplicationHandler for SurvivorApp { let dt = state.timer.frame_dt(); 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 - state.game.update(&state.input, dt); + let events = state.game.update(&state.input, dt); // Mouse aim + shooting let aspect = state.gpu.config.width as f32 / state.gpu.config.height as f32; let (mx, my) = state.input.mouse_position(); let screen_w = state.gpu.config.width as f32; let screen_h = state.gpu.config.height as f32; + let mut shot_fired = false; if let Some(world_pos) = 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 { let aim_dir = aim_xz.normalize(); 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 state.camera.target = state.game.player.position; @@ -482,8 +534,19 @@ impl ApplicationHandler for SurvivorApp { let (c, m, r) = state.materials[i]; (state.models[i], c, m, r) } else if i == ARENA_ENTITIES { - // Player: blue sphere - (player_model, [0.2, 0.4, 1.0, 1.0], 0.3, 0.5) + // Player: blue sphere (flash white when invincible) + 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 { // Projectile: yellow sphere 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())); output.present(); }