Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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"] }