//! The `entity` module contains structures of all entities including players and enimies. use std::{ mem::take, time::{Duration, Instant}, }; use rand::Rng; use crate::{ Dungeon, astar, const_pos, map::Floor, player_input::PlayerInput, pos::{Direction, FPos, Pos}, rng::DungeonRng, }; /// `PLAYER_INVENTORY_SIZE` is the maximum size of the inventory pub const PLAYER_INVENTORY_SIZE: u16 = 5; /// `PLAYER_INVENTORY_SIZE_USIZE` is the maximum size of the inventory pub const PLAYER_INVENTORY_SIZE_USIZE: usize = PLAYER_INVENTORY_SIZE as usize; /// 'MIN_ROAM_DIST' and 'MAX_ROAM_DIST' are the enemy roam ranges pub const MIN_ROAM_DIST: u16 = 1; pub const MAX_ROAM_DIST: u16 = 4; /// 'ENEMY_VISION_RADIUS' is the range used to determine if the player is seen by an enemy pub const ENEMY_VISION_RADIUS: u16 = 10; /// 'IDLE_WAIT' is how long in seconds an enemy waits in the idle state pub const IDLE_WAIT: f32 = 1.; /// The `Item` type represents any item an entity may be using #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub enum Item { /// Heals health when used HeartFragment, /// Heals health when used HealthPotion, /// Increases speed temporarily SpeedPotion, /// Blows up a portion of the map Bomb, /// Blows up a larget portion of the map LargeBomb, } impl Item { pub fn consume(self, dungeon: &mut Dungeon) { match self { Self::HeartFragment => { dungeon.player.entity.heal(1); } Self::HealthPotion => { dungeon.player.entity.heal(3); } Self::SpeedPotion => { dungeon.player.potion_timer = Instant::now().checked_add(Duration::from_secs(5)); dungeon.player.entity.speed = dungeon.player.entity.speed.inc(); } Self::Bomb => { dungeon.floor.explode(dungeon.player.entity.pos, 1); } Self::LargeBomb => { dungeon.floor.explode(dungeon.player.entity.pos, 2); } } } /// Returns if this item is a bomb #[must_use] pub const fn is_bomb(self) -> bool { matches!(self, Self::Bomb | Self::LargeBomb) } /// Returns if this item is a potion #[must_use] pub const fn is_potion(self) -> bool { matches!(self, Self::HealthPotion | Self::SpeedPotion) } } /// Different speed entities can move #[repr(u8)] #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub enum EntitySpeed { Stopped = 0, Slow = 2, Normal = 3, Fast = 4, } impl EntitySpeed { /// Returns speed for this current time slice #[must_use] pub const fn per_frame(self, delta_time: f32) -> f32 { (self as u8) as f32 * delta_time } /// Increment to the next speed #[must_use] pub const fn inc(self) -> Self { match self { Self::Stopped => Self::Stopped, Self::Slow => Self::Normal, Self::Normal => Self::Fast, Self::Fast => Self::Fast, } } } /// The `EntityKind` represents what kind of entity this is. #[derive(Clone, Debug, Default, PartialEq)] pub enum EntityKind { /// The main player #[default] Player, Zombie(EnemyMoveState), /// An item (not in an inventory) on the floor of the dungeon Item(Item), } impl EntityKind { /// Returns the move speed value for this type of entity in tiles/s pub const fn initial_speed(&self) -> EntitySpeed { match &self { Self::Player => EntitySpeed::Normal, Self::Zombie(_) => EntitySpeed::Slow, _ => EntitySpeed::Stopped, } } /// Returns the known max/initial health pub const fn initial_health(&self) -> u32 { match self { Self::Player => 10, Self::Zombie(_) => 5, _ => 0, } } } /// The `EnemyMoveState` enum describes the state of an enemies movement, either Idle /// Roam, or Attack. Idle carries a `f32` value to remember time waited in idle. Both Roam /// and Attack carry a `Vec` to represent the sequence of moves to their desired locations. /// Attack also holds the desired location itself as a `Pos`. #[derive(Clone, Debug, PartialEq)] pub enum EnemyMoveState { Idle(f32), Roam(Vec), Attack(Vec, Pos), } impl EnemyMoveState { /// Creates an `EnemyMoveState::Idle` enum, initialized to a time accumlation of 0. pub const fn idle() -> Self { Self::Idle(0.0) } /// Creates an `EnemyMoveState::Attack` enum containing the list of steps for movement and the goal position. /// /// Will return `Some(EnemyMoveState::Attack)` if the player is within the vision radius and there is a valid path /// to the player. Returns `None` if the player is outside the radius or there is no valid path to the player. pub fn attack(starting_pos: Pos, player_pos: Pos, floor: &Floor) -> Option { if player_pos.manhattan(starting_pos) < ENEMY_VISION_RADIUS { let data = astar::astar(starting_pos, player_pos, floor)?; let mut path = data.0; path.reverse(); return Some(Self::Attack(path, player_pos)); } None } /// Creates an `EnemyMoveState::Roam` enum containing the list of steps for movement. /// /// Will randomly pick a direction and number of tiles within roaming range to move. /// If an invalid tile is selected 50 times in a row, `EnemyMoveState::Idle` is returned instead. pub fn roam(starting_pos: Pos, floor: &Floor, rng: &mut DungeonRng) -> Self { let mut loop_index = 0; loop { let dir = rng.random(); let dist = rng.random_range(MIN_ROAM_DIST..=MAX_ROAM_DIST); if let Some(p) = starting_pos.step_by(dir, dist) && !floor.get(p).is_wall() && let Some(data) = astar::astar(starting_pos, p, floor) { let mut path = data.0; path.reverse(); return Self::Roam(path); } // Safety check loop_index += 1; if loop_index >= 100 { return Self::idle(); } } } /// Returns an optional reference to the current path this entity is moving pub const fn moves(&self) -> Option<&[Pos]> { match self { Self::Idle(_) => None, Self::Roam(moves) => Some(moves.as_slice()), Self::Attack(moves, _) => Some(moves.as_slice()), } } } impl Default for EnemyMoveState { fn default() -> Self { Self::idle() } } /// The `Entity` kind represents the main player, or any other /// ai autonomous character that can move freely across the /// dungeon. #[derive(Clone, Debug, PartialEq)] pub struct Entity { /// The fixed grid position of the entity pub pos: Pos, /// The floating (real) current position of the entity pub fpos: FPos, /// Which direction this entity is facing pub dir: Direction, /// Which kind this entity is (along with entity kind specific data) pub kind: EntityKind, /// The amount of health this entity has (None if this Entity does not use health) pub health: u32, /// The current movement speed pub speed: EntitySpeed, /// The fixed grid position we are moving to pub moving_to: Option, } impl Entity { /// Creates a new `Entity` at a given `Pos`, `Direction`, and `EntityKind`. /// /// # Examples /// /// ``` /// use dungeon::{pos::{Pos, Direction}, entity::{Entity, EntityKind}}; /// /// let pos = Pos::new(0, 0).unwrap(); /// let dir = Direction::North; /// let kind = EntityKind::Player; /// let entity = Entity::new(pos, dir, kind); /// ``` #[must_use] pub const fn new(pos: Pos, dir: Direction, kind: EntityKind) -> Self { let fpos = FPos::from_pos(pos); let health = kind.initial_health(); let speed = kind.initial_speed(); Self { pos, fpos, dir, kind, health, speed, moving_to: None, } } /// Creates the Player version of the `Entity` /// /// # Examples /// /// ``` /// use dungeon::{pos::Pos, entity::Entity}; /// /// let pos = Pos::new(0, 0).unwrap(); /// let player = Entity::player(pos); /// ``` #[must_use] pub const fn player(pos: Pos) -> Self { let dir = Direction::East; let kind = EntityKind::Player; Self::new(pos, dir, kind) } /// Creates the Zombie version of the `Entity` /// /// # Examples /// /// ``` /// use dungeon::{pos::Pos, entity::Entity}; /// /// let pos = Pos::new(0, 0).unwrap(); /// let player = Entity::zombie(pos); /// ``` #[must_use] pub const fn zombie(pos: Pos) -> Self { let dir = Direction::East; let kind = EntityKind::Zombie(EnemyMoveState::idle()); Self::new(pos, dir, kind) } /// Returns the position in front of the entity pub const fn in_front(&self) -> Option { self.pos.step(self.dir) } /// Teleports a player directly to a `Pos` pub const fn teleport(&mut self, pos: Pos) { self.pos = pos; self.fpos = FPos::from_pos(pos); self.moving_to = None; } /// Heals an entity a set amount of HP pub fn heal(&mut self, amt: u32) { let max = self.kind.initial_health(); self.health = (self.health + amt).min(max); } /// Returns a reference to this entities current AI pub const fn get_ai(&self) -> Option<&EnemyMoveState> { match &self.kind { EntityKind::Zombie(ai) => Some(ai), _ => None, } } /// Returns if this entity is dead pub const fn is_dead(&self) -> bool { self.health == 0 && self.kind.initial_health() > 0 } /// Returns if the entity is alive (or does not have/use health) pub const fn is_alive(&self) -> bool { !self.is_dead() } } /// The current "weapon" level we have #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub enum Weapon { RustyKnife, ShiningBlade, GodlyBlade, } impl Weapon { /// Returns the number of hit points of damage this weapon does #[must_use] pub const fn attack_dmg(self) -> u32 { match self { Self::RustyKnife => 2, Self::ShiningBlade => 3, Self::GodlyBlade => 5, } } } /// The `Player` type represents the main player entity #[derive(Clone, Debug, PartialEq)] pub struct Player { pub entity: Entity, pub inventory: Vec, pub weapon: Weapon, // How long until we reset potion effects? pub potion_timer: Option, pub active_inv_slot: usize, } impl Player { /// Instantiates the game player at a given `Pos` /// /// # Examples /// /// ``` /// use dungeon::{pos::Pos, entity::Player}; /// /// let pos = Pos::new(1, 2).unwrap(); /// let player = Player::new(pos); /// ``` pub const fn new(pos: Pos) -> Self { let entity = Entity::player(pos); let inventory = vec![]; let weapon = Weapon::RustyKnife; let potion_timer = None; let active_inv_slot = 0; Self { entity, inventory, weapon, potion_timer, active_inv_slot, } } } impl Default for Player { fn default() -> Self { let pos = const_pos!(1, 1); Self::new(pos) } } struct Updater<'a> { floor: &'a Floor, rng: &'a mut DungeonRng, player_pos: Pos, input: PlayerInput, delta_time: f32, } impl Updater<'_> { fn update_entity(&mut self, entity: &mut Entity) { self.update_ai(entity); self.update_movement(entity); } fn update_ai(&mut self, entity: &mut Entity) { let mut kind = take(&mut entity.kind); match &mut kind { EntityKind::Player => { self.update_player_ai(entity); } EntityKind::Zombie(ai) => { self.update_enemy_ai(entity, ai); } _ => {} } entity.kind = kind; } /// Updates entity with player AI const fn update_player_ai(&self, entity: &mut Entity) { let Some(dir) = self.input.direction else { return; }; if entity.moving_to.is_some() { return; } entity.dir = dir; let Some(dest) = entity.pos.step(dir) else { return; }; if self.floor.get(dest).is_walkable() { entity.moving_to = Some(dest); } } /// Update entity with enemy AI fn update_enemy_ai(&mut self, entity: &mut Entity, ai: &mut EnemyMoveState) { use EnemyMoveState as State; // get the next position this entity may be moving to. // // we want to be able to short circut past pathed movement // if we generate a new path, without allowing the entity to move // off grid! thus this is the best position to start the new // movement from :) let next_pos = entity .in_front() .filter(|pos| self.floor.get(*pos).is_walkable()) .or(entity.moving_to) .unwrap_or(entity.pos); // check if player is in range if !matches!(ai, State::Attack { .. }) && let Some(m_state) = State::attack(next_pos, self.player_pos, self.floor) { *ai = m_state; entity.moving_to = Some(next_pos); return; } match ai { State::Idle(idle_accum) => { if *idle_accum < IDLE_WAIT { *ai = State::Idle(*idle_accum + self.delta_time); return; } *ai = State::roam(next_pos, self.floor, self.rng); entity.moving_to = Some(next_pos); } State::Roam(moves) => { if moves.is_empty() { *ai = State::idle(); return; } if entity.moving_to.is_none() { entity.moving_to = moves.pop(); } } State::Attack(moves, old_player_pos) => { if *old_player_pos != self.player_pos { *ai = State::attack(next_pos, self.player_pos, self.floor) .unwrap_or_default(); entity.moving_to = Some(next_pos); return; } *old_player_pos = self.player_pos; // Stop at one move left so the enemy is flush with the player tile if moves.len() == 1 { return; } while moves.last() == Some(&entity.pos) { moves.pop(); } if entity.moving_to.is_none() { entity.moving_to = moves.pop(); } } } } /// Update entity movement fn update_movement(&self, entity: &mut Entity) { let Some(dest) = entity.moving_to else { return }; let fdest = FPos::from_pos(dest); let current = entity.fpos; // we are far from the goal, so we need to move towards it let to_move = entity.speed.per_frame(self.delta_time); entity.fpos.move_towards_manhattan(fdest, to_move); if fdest.abs_diff(entity.fpos).magnitude() <= 0.1 { // we have reached our destination (close enough) entity.moving_to = None; entity.pos = dest; entity.fpos = fdest; } if let Some(dir) = current.dir_to(entity.fpos) { entity.dir = dir; } } } impl Dungeon { pub(crate) fn update_entities(&mut self, input: PlayerInput, delta_time: f32) { let mut updater = Updater { floor: &self.floor, rng: &mut self.game_rng, player_pos: self.player.entity.pos, input, delta_time, }; updater.update_entity(&mut self.player.entity); for entity in &mut self.entities { updater.update_entity(entity); if entity.is_dead() { // TODO: on entity death } } self.entities.retain(Entity::is_alive); } }