Third Person Game
A complete third-person action game template with character animation, combat, and camera control.
Complete Example
use nightshade::prelude::*; use nightshade::ecs::physics::commands::spawn_static_physics_cube_with_material; use nightshade::ecs::physics::RigidBodyType; struct ThirdPersonGame { player: Option<Entity>, camera: Option<Entity>, camera_target: Vec3, camera_distance: f32, camera_pitch: f32, camera_yaw: f32, player_state: PlayerState, attack_timer: f32, dodge_timer: f32, health: f32, } #[derive(Default, PartialEq)] enum PlayerState { #[default] Idle, Walking, Running, Attacking, Dodging, } impl Default for ThirdPersonGame { fn default() -> Self { Self { player: None, camera: None, camera_target: Vec3::zeros(), camera_distance: 5.0, camera_pitch: 0.3, camera_yaw: 0.0, player_state: PlayerState::Idle, attack_timer: 0.0, dodge_timer: 0.0, health: 100.0, } } } impl State for ThirdPersonGame { fn initialize(&mut self, world: &mut World) { self.setup_player(world); self.setup_camera(world); self.setup_level(world); self.setup_lighting(world); world.set_cursor_visible(false); world.set_cursor_locked(true); } fn run_systems(&mut self, world: &mut World) { let dt = world.resources.window.timing.delta_time; self.update_camera_input(world); self.update_player_movement(world, dt); self.update_player_state(world, dt); self.update_camera_position(world, dt); self.update_animations(world); run_physics_systems(world); sync_transforms_from_physics_system(world); update_animation_players(world, dt); } fn on_mouse_input(&mut self, world: &mut World, state: ElementState, button: MouseButton) { if state == ElementState::Pressed { match button { MouseButton::Left => self.attack(world), MouseButton::Right => self.dodge(world), _ => {} } } } } impl ThirdPersonGame { fn setup_player(&mut self, world: &mut World) { let controller_entity = world.spawn_entities( NAME | LOCAL_TRANSFORM | GLOBAL_TRANSFORM | LOCAL_TRANSFORM_DIRTY | CHARACTER_CONTROLLER | COLLIDER, 1, )[0]; world.core.set_name(controller_entity, Name("Player".to_string())); if let Some(controller) = world.core.get_character_controller_mut(controller_entity) { *controller = CharacterControllerComponent::new_capsule(0.6, 0.4); controller.max_speed = 4.0; controller.acceleration = 20.0; controller.jump_impulse = 8.0; } if let Some(collider) = world.core.get_collider_mut(controller_entity) { *collider = ColliderComponent::new_capsule(0.6, 0.4); } let model = spawn_cube_at(world, Vec3::zeros()); if let Some(transform) = world.core.get_local_transform_mut(model) { transform.translation = Vec3::new(0.0, -0.9, 0.0); transform.scale = Vec3::new(0.6, 1.8, 0.4); } set_material_with_textures(world, model, Material { base_color: [0.3, 0.5, 0.8, 1.0], roughness: 0.6, ..Default::default() }); world.core.set_parent(model, Parent(Some(controller_entity))); self.player = Some(controller_entity); } fn setup_camera(&mut self, world: &mut World) { let camera = world.spawn_entities( NAME | LOCAL_TRANSFORM | GLOBAL_TRANSFORM | LOCAL_TRANSFORM_DIRTY | CAMERA, 1, )[0]; world.core.set_name(camera, Name("Camera".to_string())); world.core.set_camera(camera, Camera { projection: Projection::Perspective(PerspectiveCamera { y_fov_rad: 60.0_f32.to_radians(), z_near: 0.1, z_far: Some(1000.0), aspect_ratio: None, }), smoothing: None, }); world.resources.active_camera = Some(camera); self.camera = Some(camera); } fn setup_level(&mut self, world: &mut World) { spawn_static_physics_cube_with_material( world, Vec3::zeros(), Vec3::new(200.0, 0.2, 200.0), Material { base_color: [0.2, 0.5, 0.2, 1.0], roughness: 0.9, ..Default::default() }, ); for index in 0..20 { let x = (index % 5) as f32 * 15.0 - 30.0; let z = (index / 5) as f32 * 15.0 - 30.0; let scale = 1.0 + (index as f32 * 0.3) % 1.5; spawn_static_physics_cube_with_material( world, Vec3::new(x, scale * 0.5, z), Vec3::new(scale, scale, scale), Material { base_color: [0.4, 0.4, 0.4, 1.0], roughness: 0.95, ..Default::default() }, ); } } fn setup_lighting(&mut self, world: &mut World) { spawn_sun(world); world.resources.graphics.ambient_light = [0.2, 0.2, 0.2, 1.0]; } fn update_camera_input(&mut self, world: &mut World) { let position_delta = world.resources.input.mouse.position_delta; let scroll = world.resources.input.mouse.wheel_delta; let sensitivity = 0.003; self.camera_yaw -= position_delta.x * sensitivity; self.camera_pitch -= position_delta.y * sensitivity; self.camera_pitch = self.camera_pitch.clamp(-1.2, 1.2); self.camera_distance -= scroll.y * 0.5; self.camera_distance = self.camera_distance.clamp(2.0, 15.0); } fn update_player_movement(&mut self, world: &mut World, dt: f32) { if self.player_state == PlayerState::Attacking || self.player_state == PlayerState::Dodging { return; } let Some(player) = self.player else { return }; let keyboard = &world.resources.input.keyboard; let mut move_input = Vec2::zeros(); if keyboard.is_key_pressed(KeyCode::KeyW) { move_input.y -= 1.0; } if keyboard.is_key_pressed(KeyCode::KeyS) { move_input.y += 1.0; } if keyboard.is_key_pressed(KeyCode::KeyA) { move_input.x -= 1.0; } if keyboard.is_key_pressed(KeyCode::KeyD) { move_input.x += 1.0; } let running = keyboard.is_key_pressed(KeyCode::ShiftLeft); if move_input.magnitude() > 0.0 { move_input = move_input.normalize(); let camera_forward = Vec3::new( self.camera_yaw.sin(), 0.0, self.camera_yaw.cos(), ); let camera_right = Vec3::new( self.camera_yaw.cos(), 0.0, -self.camera_yaw.sin(), ); let world_direction = camera_forward * -move_input.y + camera_right * move_input.x; if let Some(transform) = world.core.get_local_transform_mut(player) { let target_rotation = nalgebra_glm::quat_angle_axis( world_direction.x.atan2(world_direction.z), &Vec3::y(), ); transform.rotation = nalgebra_glm::quat_slerp( &transform.rotation, &target_rotation, dt * 10.0, ); } let speed = if running { 8.0 } else { 4.0 }; if let Some(controller) = world.core.get_character_controller_mut(player) { controller.velocity.x = world_direction.x * speed; controller.velocity.z = world_direction.z * speed; } self.player_state = if running { PlayerState::Running } else { PlayerState::Walking }; } else { if let Some(controller) = world.core.get_character_controller_mut(player) { controller.velocity.x = 0.0; controller.velocity.z = 0.0; } self.player_state = PlayerState::Idle; } if keyboard.is_key_pressed(KeyCode::Space) { if let Some(controller) = world.core.get_character_controller_mut(player) { if controller.grounded { controller.velocity.y = controller.jump_impulse; } } } } fn update_player_state(&mut self, world: &mut World, dt: f32) { if self.attack_timer > 0.0 { self.attack_timer -= dt; if self.attack_timer <= 0.0 { self.player_state = PlayerState::Idle; } } if self.dodge_timer > 0.0 { self.dodge_timer -= dt; if self.dodge_timer <= 0.0 { self.player_state = PlayerState::Idle; } } } fn update_camera_position(&mut self, world: &mut World, dt: f32) { let Some(player) = self.player else { return }; let Some(camera) = self.camera else { return }; if let Some(player_transform) = world.core.get_global_transform(player) { let target = player_transform.translation() + Vec3::new(0.0, 1.5, 0.0); self.camera_target = nalgebra_glm::lerp( &self.camera_target, &target, dt * 8.0, ); } let offset = Vec3::new( self.camera_yaw.sin() * self.camera_pitch.cos(), self.camera_pitch.sin(), self.camera_yaw.cos() * self.camera_pitch.cos(), ) * self.camera_distance; let camera_position = self.camera_target + offset; if let Some(transform) = world.core.get_local_transform_mut(camera) { transform.translation = camera_position; let direction = (self.camera_target - camera_position).normalize(); let pitch = (-direction.y).asin(); let yaw = direction.x.atan2(direction.z); transform.rotation = nalgebra_glm::quat_angle_axis(yaw, &Vec3::y()) * nalgebra_glm::quat_angle_axis(pitch, &Vec3::x()); } } fn update_animations(&mut self, world: &mut World) { let Some(player) = self.player else { return }; let children = world.resources.children_cache.get(&player).cloned().unwrap_or_default(); for child in children { if let Some(animation_player) = world.core.get_animation_player_mut(child) { let animation_name = match self.player_state { PlayerState::Idle => "idle", PlayerState::Walking => "walk", PlayerState::Running => "run", PlayerState::Attacking => "attack", PlayerState::Dodging => "dodge", }; if animation_player.current_animation() != Some(animation_name) { animation_player.blend_to(animation_name, 0.2); } } } } fn attack(&mut self, world: &mut World) { if self.player_state == PlayerState::Attacking || self.player_state == PlayerState::Dodging { return; } self.player_state = PlayerState::Attacking; self.attack_timer = 0.6; self.check_attack_hits(world); } fn check_attack_hits(&self, world: &mut World) { let Some(player) = self.player else { return }; if let Some(transform) = world.core.get_global_transform(player) { let attack_origin = transform.translation() + Vec3::new(0.0, 1.0, 0.0); let forward = transform.forward_vector(); let attack_range = 2.0; for entity in world.core.query_entities(GLOBAL_TRANSFORM) { if entity == player { continue; } if let Some(target_transform) = world.core.get_global_transform(entity) { let to_target = target_transform.translation() - attack_origin; let distance = to_target.magnitude(); let dot = forward.dot(&to_target.normalize()); if distance < attack_range && dot > 0.5 { self.apply_damage(world, entity, 25.0); } } } } } fn apply_damage(&self, world: &mut World, entity: Entity, damage: f32) { } fn dodge(&mut self, world: &mut World) { if self.player_state == PlayerState::Attacking || self.player_state == PlayerState::Dodging { return; } let Some(player) = self.player else { return }; self.player_state = PlayerState::Dodging; self.dodge_timer = 0.5; if let Some(transform) = world.core.get_local_transform(player) { let forward = transform.rotation * Vec3::new(0.0, 0.0, -1.0); if let Some(controller) = world.core.get_character_controller_mut(player) { controller.velocity.x = forward.x * 12.0; controller.velocity.z = forward.z * 12.0; } } } } fn main() -> Result<(), Box<dyn std::error::Error>> { nightshade::launch(ThirdPersonGame::default()) }
Key Systems
Orbit Camera
The camera orbits around the player using spherical coordinates:
#![allow(unused)] fn main() { let offset = Vec3::new( self.camera_yaw.sin() * self.camera_pitch.cos(), self.camera_pitch.sin(), self.camera_yaw.cos() * self.camera_pitch.cos(), ) * self.camera_distance; }
Mouse X controls yaw, mouse Y controls pitch, scroll controls distance.
Camera-Relative Movement
Player moves relative to where the camera is looking:
#![allow(unused)] fn main() { let camera_forward = Vec3::new( self.camera_yaw.sin(), 0.0, self.camera_yaw.cos(), ); let world_direction = camera_forward * -move_input.y + camera_right * move_input.x; }
Character Rotation
The character smoothly rotates to face movement direction:
#![allow(unused)] fn main() { transform.rotation = nalgebra_glm::quat_slerp( &transform.rotation, &target_rotation, dt * 10.0, ); }
Animation Blending
Animations blend smoothly when state changes:
#![allow(unused)] fn main() { animation_player.blend_to(animation_name, 0.2); }
State Machine
Simple state machine prevents conflicting actions:
#![allow(unused)] fn main() { if self.player_state == PlayerState::Attacking || self.player_state == PlayerState::Dodging { return; } }
Cargo.toml
[package]
name = "third-person-game"
version = "0.1.0"
edition = "2024"
[dependencies]
nightshade = { git = "...", features = ["engine", "wgpu", "physics"] }