From 5496525a7f645669a4694cfa47b74c04b1edea2a Mon Sep 17 00:00:00 2001 From: tolelom <98kimsungmin@naver.com> Date: Wed, 25 Mar 2026 17:35:46 +0900 Subject: [PATCH] feat(game): add projectile shooting toward mouse aim Left click fires small yellow sphere projectiles from the player toward the mouse cursor position on the XZ plane. Includes fire cooldown (0.2s), projectile lifetime (2.0s), and ray-plane intersection for mouse aiming. Co-Authored-By: Claude Opus 4.6 (1M context) --- examples/survivor_game/src/game.rs | 19 ++++ examples/survivor_game/src/main.rs | 106 +++++++++++++++++++++-- examples/survivor_game/src/projectile.rs | 26 ++++++ 3 files changed, 144 insertions(+), 7 deletions(-) create mode 100644 examples/survivor_game/src/projectile.rs diff --git a/examples/survivor_game/src/game.rs b/examples/survivor_game/src/game.rs index 0673cb6..5b37c22 100644 --- a/examples/survivor_game/src/game.rs +++ b/examples/survivor_game/src/game.rs @@ -1,8 +1,11 @@ +use voltex_math::Vec3; use voltex_platform::InputState; use crate::player::Player; +use crate::projectile::Projectile; pub struct GameState { pub player: Player, + pub projectiles: Vec, pub score: u32, pub wave: u32, pub game_over: bool, @@ -12,16 +15,32 @@ impl GameState { pub fn new() -> Self { Self { player: Player::new(), + projectiles: Vec::new(), score: 0, wave: 1, game_over: false, } } + pub fn try_shoot(&mut self, aim_dir: Vec3) { + if self.player.fire_cooldown <= 0.0 { + let origin = self.player.position + Vec3::new(0.0, 0.5, 0.0); + self.projectiles.push(Projectile::new(origin, aim_dir, 20.0)); + self.player.fire_cooldown = 0.2; + } + } + pub fn update(&mut self, input: &InputState, dt: f32) { if self.game_over { return; } self.player.update(input, dt); + self.player.fire_cooldown = (self.player.fire_cooldown - dt).max(0.0); + + // Update projectiles and remove dead ones + for proj in &mut self.projectiles { + proj.update(dt); + } + self.projectiles.retain(|p| p.is_alive()); } } diff --git a/examples/survivor_game/src/main.rs b/examples/survivor_game/src/main.rs index fb8eb91..a69fc35 100644 --- a/examples/survivor_game/src/main.rs +++ b/examples/survivor_game/src/main.rs @@ -2,10 +2,11 @@ mod arena; mod camera; mod game; mod player; +mod projectile; use winit::{ application::ApplicationHandler, - event::WindowEvent, + event::{MouseButton, WindowEvent}, event_loop::{ActiveEventLoop, EventLoop}, keyboard::{KeyCode, PhysicalKey}, window::WindowId, @@ -393,15 +394,32 @@ impl ApplicationHandler for SurvivorApp { // Update game logic 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; + if let Some(world_pos) = + mouse_to_world_xz(mx as f32, my as f32, screen_w, screen_h, &state.camera, aspect) + { + let aim = world_pos - state.game.player.position; + let aim_xz = Vec3::new(aim.x, 0.0, aim.z); + 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); + } + } + } + // Camera follows player state.camera.target = state.game.player.position; - let aspect = state.gpu.config.width as f32 / state.gpu.config.height as f32; - - // Build per-frame model/material lists: arena entities + player + // Build per-frame model/material lists: arena entities + player + projectiles 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_entities = ARENA_ENTITIES + 1; // arena + player + let num_projectiles = state.game.projectiles.len().min(MAX_ENTITIES - ARENA_ENTITIES - 1); + let num_entities = ARENA_ENTITIES + 1 + num_projectiles; // ----- Compute light VP for shadows ----- let light_dir = Vec3::new(-1.0, -2.0, -1.0).normalize(); @@ -420,8 +438,13 @@ impl ApplicationHandler for SurvivorApp { for i in 0..num_entities { let model = if i < ARENA_ENTITIES { state.models[i] - } else { + } else if i == ARENA_ENTITIES { player_model + } else { + 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) }; let sp_uniform = ShadowPassUniform { light_vp_model: (light_vp * model).cols, @@ -449,9 +472,16 @@ impl ApplicationHandler for SurvivorApp { let (model, color, metallic, roughness) = if i < ARENA_ENTITIES { let (c, m, r) = state.materials[i]; (state.models[i], c, m, r) - } else { + } else if i == ARENA_ENTITIES { // Player: blue sphere (player_model, [0.2, 0.4, 1.0, 1.0], 0.3, 0.5) + } else { + // 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) }; let cam_uniform = CameraUniform { @@ -585,6 +615,10 @@ impl ApplicationHandler for SurvivorApp { } // Player sphere draw_shadow!(shadow_pass, state.player_mesh, ARENA_ENTITIES); + // Projectiles + for pi in 0..num_projectiles { + draw_shadow!(shadow_pass, state.player_mesh, ARENA_ENTITIES + 1 + pi); + } } // ===== Pass 2: Color (PBR) ===== @@ -632,6 +666,10 @@ impl ApplicationHandler for SurvivorApp { } // Player sphere draw_color!(render_pass, state.player_mesh, ARENA_ENTITIES); + // Projectiles + for pi in 0..num_projectiles { + draw_color!(render_pass, state.player_mesh, ARENA_ENTITIES + 1 + pi); + } } state.gpu.queue.submit(std::iter::once(encoder.finish())); @@ -649,6 +687,60 @@ impl ApplicationHandler for SurvivorApp { } } +fn mouse_to_world_xz( + mouse_x: f32, + mouse_y: f32, + screen_w: f32, + screen_h: f32, + camera: &QuarterViewCamera, + aspect: f32, +) -> Option { + // Convert mouse to NDC (-1..1) + let ndc_x = (mouse_x / screen_w) * 2.0 - 1.0; + let ndc_y = 1.0 - (mouse_y / screen_h) * 2.0; // flip Y + + let cam_pos = camera.eye_position(); + + // Construct ray direction from camera through screen point + let fov = 45.0_f32.to_radians(); + let half_h = (fov / 2.0).tan(); + let half_w = half_h * aspect; + + // Camera basis vectors + let forward = camera.target - cam_pos; + if forward.length_squared() < 1e-12 { + return None; + } + let forward = forward.normalize(); + let right_vec = forward.cross(Vec3::Y); + if right_vec.length_squared() < 1e-12 { + return None; + } + let right_vec = right_vec.normalize(); + let up = right_vec.cross(forward); + + let ray_dir_unnorm = forward + right_vec * (ndc_x * half_w) + up * (ndc_y * half_h); + if ray_dir_unnorm.length_squared() < 1e-12 { + return None; + } + let ray_dir = ray_dir_unnorm.normalize(); + + // Intersect with Y=0 plane + if ray_dir.y.abs() < 1e-6 { + return None; + } + let t = -cam_pos.y / ray_dir.y; + if t < 0.0 { + return None; + } + + Some(Vec3::new( + cam_pos.x + ray_dir.x * t, + 0.0, + cam_pos.z + ray_dir.z * t, + )) +} + fn main() { env_logger::init(); let event_loop = EventLoop::new().unwrap(); diff --git a/examples/survivor_game/src/projectile.rs b/examples/survivor_game/src/projectile.rs new file mode 100644 index 0000000..1a328ee --- /dev/null +++ b/examples/survivor_game/src/projectile.rs @@ -0,0 +1,26 @@ +use voltex_math::Vec3; + +pub struct Projectile { + pub position: Vec3, + pub velocity: Vec3, + pub lifetime: f32, +} + +impl Projectile { + pub fn new(origin: Vec3, direction: Vec3, speed: f32) -> Self { + Self { + position: origin, + velocity: direction * speed, + lifetime: 2.0, + } + } + + pub fn update(&mut self, dt: f32) { + self.position = self.position + self.velocity * dt; + self.lifetime -= dt; + } + + pub fn is_alive(&self) -> bool { + self.lifetime > 0.0 + } +}