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) <noreply@anthropic.com>
This commit is contained in:
2026-03-25 17:35:46 +09:00
parent 2f60ace70a
commit 5496525a7f
3 changed files with 144 additions and 7 deletions

View File

@@ -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<Projectile>,
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());
}
}

View File

@@ -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<Vec3> {
// 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();

View File

@@ -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
}
}