feat(game): add player movement with WASD and camera follow
Add blue sphere as the player with WASD movement on XZ plane, clamped to arena bounds. Camera now tracks player position. Pre-allocate dynamic UBO buffers for 100 entities to support future enemies and projectiles. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
27
examples/survivor_game/src/game.rs
Normal file
27
examples/survivor_game/src/game.rs
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
use voltex_platform::InputState;
|
||||||
|
use crate::player::Player;
|
||||||
|
|
||||||
|
pub struct GameState {
|
||||||
|
pub player: Player,
|
||||||
|
pub score: u32,
|
||||||
|
pub wave: u32,
|
||||||
|
pub game_over: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GameState {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
player: Player::new(),
|
||||||
|
score: 0,
|
||||||
|
wave: 1,
|
||||||
|
game_over: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update(&mut self, input: &InputState, dt: f32) {
|
||||||
|
if self.game_over {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.player.update(input, dt);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
mod arena;
|
mod arena;
|
||||||
mod camera;
|
mod camera;
|
||||||
|
mod game;
|
||||||
|
mod player;
|
||||||
|
|
||||||
use winit::{
|
use winit::{
|
||||||
application::ApplicationHandler,
|
application::ApplicationHandler,
|
||||||
@@ -16,13 +18,16 @@ use voltex_renderer::{
|
|||||||
ShadowMap, ShadowUniform, ShadowPassUniform, SHADOW_MAP_SIZE,
|
ShadowMap, ShadowUniform, ShadowPassUniform, SHADOW_MAP_SIZE,
|
||||||
create_shadow_pipeline, shadow_pass_bind_group_layout,
|
create_shadow_pipeline, shadow_pass_bind_group_layout,
|
||||||
IblResources, pbr_texture_bind_group_layout, create_pbr_texture_bind_group,
|
IblResources, pbr_texture_bind_group_layout, create_pbr_texture_bind_group,
|
||||||
|
generate_sphere,
|
||||||
};
|
};
|
||||||
use wgpu::util::DeviceExt;
|
use wgpu::util::DeviceExt;
|
||||||
|
|
||||||
use arena::{generate_arena, arena_model_matrices};
|
use arena::{generate_arena, arena_model_matrices};
|
||||||
use camera::QuarterViewCamera;
|
use camera::QuarterViewCamera;
|
||||||
|
use game::GameState;
|
||||||
|
|
||||||
const NUM_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
|
||||||
|
|
||||||
struct SurvivorApp {
|
struct SurvivorApp {
|
||||||
state: Option<AppState>,
|
state: Option<AppState>,
|
||||||
@@ -38,7 +43,9 @@ struct AppState {
|
|||||||
pbr_pipeline: wgpu::RenderPipeline,
|
pbr_pipeline: wgpu::RenderPipeline,
|
||||||
shadow_pipeline: wgpu::RenderPipeline,
|
shadow_pipeline: wgpu::RenderPipeline,
|
||||||
entities: Vec<RenderEntity>,
|
entities: Vec<RenderEntity>,
|
||||||
|
player_mesh: Mesh,
|
||||||
camera: QuarterViewCamera,
|
camera: QuarterViewCamera,
|
||||||
|
game: GameState,
|
||||||
// Color pass resources
|
// Color pass resources
|
||||||
camera_buffer: wgpu::Buffer,
|
camera_buffer: wgpu::Buffer,
|
||||||
light_buffer: wgpu::Buffer,
|
light_buffer: wgpu::Buffer,
|
||||||
@@ -132,6 +139,13 @@ impl ApplicationHandler for SurvivorApp {
|
|||||||
materials.push((entity.base_color, entity.metallic, entity.roughness));
|
materials.push((entity.base_color, entity.metallic, entity.roughness));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Player sphere mesh (radius 0.4, 16 sectors/stacks)
|
||||||
|
let (sphere_verts, sphere_indices) = generate_sphere(0.4, 16, 16);
|
||||||
|
let player_mesh = Mesh::new(&gpu.device, &sphere_verts, &sphere_indices);
|
||||||
|
|
||||||
|
// Game state
|
||||||
|
let game = GameState::new();
|
||||||
|
|
||||||
// Quarter-view camera
|
// Quarter-view camera
|
||||||
let camera = QuarterViewCamera::new();
|
let camera = QuarterViewCamera::new();
|
||||||
|
|
||||||
@@ -147,7 +161,7 @@ impl ApplicationHandler for SurvivorApp {
|
|||||||
// ---- Color pass buffers ----
|
// ---- Color pass buffers ----
|
||||||
let camera_buffer = gpu.device.create_buffer(&wgpu::BufferDescriptor {
|
let camera_buffer = gpu.device.create_buffer(&wgpu::BufferDescriptor {
|
||||||
label: Some("Camera Dynamic UBO"),
|
label: Some("Camera Dynamic UBO"),
|
||||||
size: (cam_aligned_size as usize * NUM_ENTITIES) as u64,
|
size: (cam_aligned_size as usize * MAX_ENTITIES) as u64,
|
||||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||||
mapped_at_creation: false,
|
mapped_at_creation: false,
|
||||||
});
|
});
|
||||||
@@ -160,7 +174,7 @@ impl ApplicationHandler for SurvivorApp {
|
|||||||
|
|
||||||
let material_buffer = gpu.device.create_buffer(&wgpu::BufferDescriptor {
|
let material_buffer = gpu.device.create_buffer(&wgpu::BufferDescriptor {
|
||||||
label: Some("Material Dynamic UBO"),
|
label: Some("Material Dynamic UBO"),
|
||||||
size: (mat_aligned_size as usize * NUM_ENTITIES) as u64,
|
size: (mat_aligned_size as usize * MAX_ENTITIES) as u64,
|
||||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||||
mapped_at_creation: false,
|
mapped_at_creation: false,
|
||||||
});
|
});
|
||||||
@@ -253,7 +267,7 @@ impl ApplicationHandler for SurvivorApp {
|
|||||||
let sp_layout = shadow_pass_bind_group_layout(&gpu.device);
|
let sp_layout = shadow_pass_bind_group_layout(&gpu.device);
|
||||||
let shadow_pass_buffer = gpu.device.create_buffer(&wgpu::BufferDescriptor {
|
let shadow_pass_buffer = gpu.device.create_buffer(&wgpu::BufferDescriptor {
|
||||||
label: Some("Shadow Pass Dynamic UBO"),
|
label: Some("Shadow Pass Dynamic UBO"),
|
||||||
size: (shadow_pass_aligned_size as usize * NUM_ENTITIES) as u64,
|
size: (shadow_pass_aligned_size as usize * MAX_ENTITIES) as u64,
|
||||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||||
mapped_at_creation: false,
|
mapped_at_creation: false,
|
||||||
});
|
});
|
||||||
@@ -289,7 +303,9 @@ impl ApplicationHandler for SurvivorApp {
|
|||||||
pbr_pipeline,
|
pbr_pipeline,
|
||||||
shadow_pipeline,
|
shadow_pipeline,
|
||||||
entities: render_entities,
|
entities: render_entities,
|
||||||
|
player_mesh,
|
||||||
camera,
|
camera,
|
||||||
|
game,
|
||||||
camera_buffer,
|
camera_buffer,
|
||||||
light_buffer,
|
light_buffer,
|
||||||
material_buffer,
|
material_buffer,
|
||||||
@@ -371,10 +387,22 @@ impl ApplicationHandler for SurvivorApp {
|
|||||||
|
|
||||||
WindowEvent::RedrawRequested => {
|
WindowEvent::RedrawRequested => {
|
||||||
state.timer.tick();
|
state.timer.tick();
|
||||||
|
let dt = state.timer.frame_dt();
|
||||||
state.input.begin_frame();
|
state.input.begin_frame();
|
||||||
|
|
||||||
|
// Update game logic
|
||||||
|
state.game.update(&state.input, dt);
|
||||||
|
|
||||||
|
// Camera follows player
|
||||||
|
state.camera.target = state.game.player.position;
|
||||||
|
|
||||||
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;
|
||||||
|
|
||||||
|
// Build per-frame model/material lists: arena entities + player
|
||||||
|
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
|
||||||
|
|
||||||
// ----- Compute light VP for shadows -----
|
// ----- Compute light VP for shadows -----
|
||||||
let light_dir = Vec3::new(-1.0, -2.0, -1.0).normalize();
|
let light_dir = Vec3::new(-1.0, -2.0, -1.0).normalize();
|
||||||
let light_pos = Vec3::ZERO - light_dir * 25.0;
|
let light_pos = Vec3::ZERO - light_dir * 25.0;
|
||||||
@@ -387,11 +415,16 @@ impl ApplicationHandler for SurvivorApp {
|
|||||||
let sp_aligned = state.shadow_pass_aligned_size as usize;
|
let sp_aligned = state.shadow_pass_aligned_size as usize;
|
||||||
|
|
||||||
// ----- Build shadow pass staging data -----
|
// ----- Build shadow pass staging data -----
|
||||||
let sp_total = sp_aligned * NUM_ENTITIES;
|
let sp_total = sp_aligned * num_entities;
|
||||||
let mut sp_staging = vec![0u8; sp_total];
|
let mut sp_staging = vec![0u8; sp_total];
|
||||||
for i in 0..NUM_ENTITIES {
|
for i in 0..num_entities {
|
||||||
|
let model = if i < ARENA_ENTITIES {
|
||||||
|
state.models[i]
|
||||||
|
} else {
|
||||||
|
player_model
|
||||||
|
};
|
||||||
let sp_uniform = ShadowPassUniform {
|
let sp_uniform = ShadowPassUniform {
|
||||||
light_vp_model: (light_vp * state.models[i]).cols,
|
light_vp_model: (light_vp * model).cols,
|
||||||
};
|
};
|
||||||
let bytes = bytemuck::bytes_of(&sp_uniform);
|
let bytes = bytemuck::bytes_of(&sp_uniform);
|
||||||
let offset = i * sp_aligned;
|
let offset = i * sp_aligned;
|
||||||
@@ -407,15 +440,23 @@ impl ApplicationHandler for SurvivorApp {
|
|||||||
let eye = state.camera.eye_position();
|
let eye = state.camera.eye_position();
|
||||||
let cam_pos = [eye.x, eye.y, eye.z];
|
let cam_pos = [eye.x, eye.y, eye.z];
|
||||||
|
|
||||||
let cam_total = cam_aligned * NUM_ENTITIES;
|
let cam_total = cam_aligned * num_entities;
|
||||||
let mat_total = mat_aligned * NUM_ENTITIES;
|
let mat_total = mat_aligned * num_entities;
|
||||||
let mut cam_staging = vec![0u8; cam_total];
|
let mut cam_staging = vec![0u8; cam_total];
|
||||||
let mut mat_staging = vec![0u8; mat_total];
|
let mut mat_staging = vec![0u8; mat_total];
|
||||||
|
|
||||||
for i in 0..NUM_ENTITIES {
|
for i in 0..num_entities {
|
||||||
|
let (model, color, metallic, roughness) = if i < ARENA_ENTITIES {
|
||||||
|
let (c, m, r) = state.materials[i];
|
||||||
|
(state.models[i], c, m, r)
|
||||||
|
} else {
|
||||||
|
// Player: blue sphere
|
||||||
|
(player_model, [0.2, 0.4, 1.0, 1.0], 0.3, 0.5)
|
||||||
|
};
|
||||||
|
|
||||||
let cam_uniform = CameraUniform {
|
let cam_uniform = CameraUniform {
|
||||||
view_proj: view_proj.cols,
|
view_proj: view_proj.cols,
|
||||||
model: state.models[i].cols,
|
model: model.cols,
|
||||||
camera_pos: cam_pos,
|
camera_pos: cam_pos,
|
||||||
_padding: 0.0,
|
_padding: 0.0,
|
||||||
};
|
};
|
||||||
@@ -423,7 +464,6 @@ impl ApplicationHandler for SurvivorApp {
|
|||||||
let offset = i * cam_aligned;
|
let offset = i * cam_aligned;
|
||||||
cam_staging[offset..offset + bytes.len()].copy_from_slice(bytes);
|
cam_staging[offset..offset + bytes.len()].copy_from_slice(bytes);
|
||||||
|
|
||||||
let (color, metallic, roughness) = state.materials[i];
|
|
||||||
let mat_uniform =
|
let mat_uniform =
|
||||||
MaterialUniform::with_params(color, metallic, roughness);
|
MaterialUniform::with_params(color, metallic, roughness);
|
||||||
let bytes = bytemuck::bytes_of(&mat_uniform);
|
let bytes = bytemuck::bytes_of(&mat_uniform);
|
||||||
@@ -492,6 +532,30 @@ impl ApplicationHandler for SurvivorApp {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Helper: draw a mesh at entity index i in shadow pass
|
||||||
|
macro_rules! draw_shadow {
|
||||||
|
($pass:expr, $mesh:expr, $idx:expr) => {
|
||||||
|
let offset = ($idx as u32) * state.shadow_pass_aligned_size;
|
||||||
|
$pass.set_bind_group(0, &state.shadow_pass_bind_group, &[offset]);
|
||||||
|
$pass.set_vertex_buffer(0, $mesh.vertex_buffer.slice(..));
|
||||||
|
$pass.set_index_buffer($mesh.index_buffer.slice(..), wgpu::IndexFormat::Uint32);
|
||||||
|
$pass.draw_indexed(0..$mesh.num_indices, 0, 0..1);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: draw a mesh at entity index i in color pass
|
||||||
|
macro_rules! draw_color {
|
||||||
|
($pass:expr, $mesh:expr, $idx:expr) => {
|
||||||
|
let cam_offset = ($idx as u32) * state.cam_aligned_size;
|
||||||
|
let mat_offset = ($idx as u32) * state.mat_aligned_size;
|
||||||
|
$pass.set_bind_group(0, &state.camera_light_bind_group, &[cam_offset]);
|
||||||
|
$pass.set_bind_group(2, &state.material_bind_group, &[mat_offset]);
|
||||||
|
$pass.set_vertex_buffer(0, $mesh.vertex_buffer.slice(..));
|
||||||
|
$pass.set_index_buffer($mesh.index_buffer.slice(..), wgpu::IndexFormat::Uint32);
|
||||||
|
$pass.draw_indexed(0..$mesh.num_indices, 0, 0..1);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// ===== Pass 1: Shadow =====
|
// ===== Pass 1: Shadow =====
|
||||||
{
|
{
|
||||||
let mut shadow_pass =
|
let mut shadow_pass =
|
||||||
@@ -515,18 +579,12 @@ impl ApplicationHandler for SurvivorApp {
|
|||||||
|
|
||||||
shadow_pass.set_pipeline(&state.shadow_pipeline);
|
shadow_pass.set_pipeline(&state.shadow_pipeline);
|
||||||
|
|
||||||
|
// Arena entities
|
||||||
for (i, entity) in state.entities.iter().enumerate() {
|
for (i, entity) in state.entities.iter().enumerate() {
|
||||||
let offset = (i as u32) * state.shadow_pass_aligned_size;
|
draw_shadow!(shadow_pass, entity.mesh, i);
|
||||||
shadow_pass
|
|
||||||
.set_bind_group(0, &state.shadow_pass_bind_group, &[offset]);
|
|
||||||
shadow_pass
|
|
||||||
.set_vertex_buffer(0, entity.mesh.vertex_buffer.slice(..));
|
|
||||||
shadow_pass.set_index_buffer(
|
|
||||||
entity.mesh.index_buffer.slice(..),
|
|
||||||
wgpu::IndexFormat::Uint32,
|
|
||||||
);
|
|
||||||
shadow_pass.draw_indexed(0..entity.mesh.num_indices, 0, 0..1);
|
|
||||||
}
|
}
|
||||||
|
// Player sphere
|
||||||
|
draw_shadow!(shadow_pass, state.player_mesh, ARENA_ENTITIES);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== Pass 2: Color (PBR) =====
|
// ===== Pass 2: Color (PBR) =====
|
||||||
@@ -568,27 +626,12 @@ impl ApplicationHandler for SurvivorApp {
|
|||||||
.set_bind_group(1, &state.pbr_texture_bind_group, &[]);
|
.set_bind_group(1, &state.pbr_texture_bind_group, &[]);
|
||||||
render_pass.set_bind_group(3, &state.shadow_bind_group, &[]);
|
render_pass.set_bind_group(3, &state.shadow_bind_group, &[]);
|
||||||
|
|
||||||
|
// Arena entities
|
||||||
for (i, entity) in state.entities.iter().enumerate() {
|
for (i, entity) in state.entities.iter().enumerate() {
|
||||||
let cam_offset = (i as u32) * state.cam_aligned_size;
|
draw_color!(render_pass, entity.mesh, i);
|
||||||
let mat_offset = (i as u32) * state.mat_aligned_size;
|
|
||||||
render_pass.set_bind_group(
|
|
||||||
0,
|
|
||||||
&state.camera_light_bind_group,
|
|
||||||
&[cam_offset],
|
|
||||||
);
|
|
||||||
render_pass.set_bind_group(
|
|
||||||
2,
|
|
||||||
&state.material_bind_group,
|
|
||||||
&[mat_offset],
|
|
||||||
);
|
|
||||||
render_pass
|
|
||||||
.set_vertex_buffer(0, entity.mesh.vertex_buffer.slice(..));
|
|
||||||
render_pass.set_index_buffer(
|
|
||||||
entity.mesh.index_buffer.slice(..),
|
|
||||||
wgpu::IndexFormat::Uint32,
|
|
||||||
);
|
|
||||||
render_pass.draw_indexed(0..entity.mesh.num_indices, 0, 0..1);
|
|
||||||
}
|
}
|
||||||
|
// Player sphere
|
||||||
|
draw_color!(render_pass, state.player_mesh, ARENA_ENTITIES);
|
||||||
}
|
}
|
||||||
|
|
||||||
state.gpu.queue.submit(std::iter::once(encoder.finish()));
|
state.gpu.queue.submit(std::iter::once(encoder.finish()));
|
||||||
|
|||||||
51
examples/survivor_game/src/player.rs
Normal file
51
examples/survivor_game/src/player.rs
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
use voltex_math::Vec3;
|
||||||
|
use voltex_platform::InputState;
|
||||||
|
use winit::keyboard::KeyCode;
|
||||||
|
|
||||||
|
pub struct Player {
|
||||||
|
pub position: Vec3,
|
||||||
|
pub speed: f32,
|
||||||
|
pub hp: i32,
|
||||||
|
pub max_hp: i32,
|
||||||
|
pub fire_cooldown: f32,
|
||||||
|
pub invincible_timer: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Player {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
position: Vec3::ZERO,
|
||||||
|
speed: 8.0,
|
||||||
|
hp: 3,
|
||||||
|
max_hp: 3,
|
||||||
|
fire_cooldown: 0.0,
|
||||||
|
invincible_timer: 0.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update(&mut self, input: &InputState, dt: f32) {
|
||||||
|
let mut dir = Vec3::ZERO;
|
||||||
|
if input.is_key_pressed(KeyCode::KeyW) {
|
||||||
|
dir.z -= 1.0;
|
||||||
|
}
|
||||||
|
if input.is_key_pressed(KeyCode::KeyS) {
|
||||||
|
dir.z += 1.0;
|
||||||
|
}
|
||||||
|
if input.is_key_pressed(KeyCode::KeyA) {
|
||||||
|
dir.x -= 1.0;
|
||||||
|
}
|
||||||
|
if input.is_key_pressed(KeyCode::KeyD) {
|
||||||
|
dir.x += 1.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if dir.length_squared() > 0.0 {
|
||||||
|
dir = dir.normalize();
|
||||||
|
}
|
||||||
|
|
||||||
|
self.position = self.position + dir * self.speed * dt;
|
||||||
|
|
||||||
|
// Clamp to arena bounds
|
||||||
|
self.position.x = self.position.x.clamp(-9.5, 9.5);
|
||||||
|
self.position.z = self.position.z.clamp(-9.5, 9.5);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user