From 2e02e30bc68c2bf25f8f6bebdbd829f9b8071e29 Mon Sep 17 00:00:00 2001 From: Timothy Warren Date: Mon, 3 Jan 2022 16:30:14 -0500 Subject: [PATCH] Complete Section 5.7, replacing the CombatStats component with a new Pools component for more flexibility --- src/components.rs | 35 ++++++--- src/damage_system.rs | 13 ++-- src/gamesystem.rs | 35 +++++++++ src/gui.rs | 13 ++-- src/inventory_system.rs | 7 +- src/main.rs | 11 +-- src/melee_combat_system.rs | 146 ++++++++++++++++++++++++------------- src/player.rs | 12 +-- src/raws/mob_structs.rs | 15 ++-- src/raws/rawmaster.rs | 56 ++++++++++++-- src/saveload_system.rs | 4 +- src/spawner.rs | 20 +++-- 12 files changed, 253 insertions(+), 114 deletions(-) diff --git a/src/components.rs b/src/components.rs index 38a1ac4..f13bdef 100644 --- a/src/components.rs +++ b/src/components.rs @@ -80,14 +80,6 @@ impl Name { #[derive(Component, Debug, Serialize, Deserialize, Clone, Default)] pub struct BlocksTile {} -#[derive(Component, Debug, ConvertSaveload, Clone)] -pub struct CombatStats { - pub max_hp: i32, - pub hp: i32, - pub defense: i32, - pub power: i32, -} - #[derive(Component, Debug, ConvertSaveload, Clone)] pub struct WantsToMelee { pub target: Entity, @@ -284,11 +276,36 @@ pub enum Skill { Magic, } -#[derive(Component, Debug, Serialize, Deserialize, Clone)] +#[derive(Component, Debug, Default, Serialize, Deserialize, Clone)] pub struct Skills { pub skills: HashMap, } +impl Skills { + pub fn new(level: i32) -> Self { + let mut skills = Skills::default(); + skills.skills.insert(Skill::Melee, level); + skills.skills.insert(Skill::Defense, level); + skills.skills.insert(Skill::Magic, level); + + skills + } +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct Pool { + pub max: i32, + pub current: i32, +} + +#[derive(Component, Debug, Serialize, Deserialize, Clone)] +pub struct Pools { + pub hit_points: Pool, + pub mana: Pool, + pub xp: i32, + pub level: i32, +} + // Serialization helper code. We need to implement ConvertSaveLoad for each type that contains an // Entity. diff --git a/src/damage_system.rs b/src/damage_system.rs index 548b8a6..1f4ee17 100644 --- a/src/damage_system.rs +++ b/src/damage_system.rs @@ -1,6 +1,6 @@ use ::specs::prelude::*; -use crate::components::{CombatStats, Name, Player, SufferDamage}; +use crate::components::{Name, Player, Pools, SufferDamage}; use crate::game_log::GameLog; use crate::{Map, Position, RunState}; @@ -8,7 +8,7 @@ pub struct DamageSystem {} impl<'a> System<'a> for DamageSystem { type SystemData = ( - WriteStorage<'a, CombatStats>, + WriteStorage<'a, Pools>, WriteStorage<'a, SufferDamage>, ReadStorage<'a, Position>, WriteExpect<'a, Map>, @@ -19,7 +19,7 @@ impl<'a> System<'a> for DamageSystem { let (mut stats, mut damage, positions, mut map, entities) = data; for (entity, mut stats, damage) in (&entities, &mut stats, &damage).join() { - stats.hp -= damage.amount.iter().sum::(); + stats.hit_points.current -= damage.amount.iter().sum::(); if let Some(pos) = positions.get(entity) { let idx = map.xy_idx(pos.x, pos.y); @@ -36,16 +36,15 @@ pub fn delete_the_dead(ecs: &mut World) { // Scope for the sake of the borrow checker { - let combat_stats = ecs.read_storage::(); + let combat_stats = ecs.read_storage::(); let players = ecs.read_storage::(); let names = ecs.read_storage::(); let entities = ecs.entities(); let mut log = ecs.write_resource::(); for (entity, stats) in (&entities, &combat_stats).join() { - if stats.hp < 1 { - let player = players.get(entity); - match player { + if stats.hit_points.current < 1 { + match players.get(entity) { None => { if let Some(victim_name) = names.get(entity) { log.append(format!("{} is dead", &victim_name.name)); diff --git a/src/gamesystem.rs b/src/gamesystem.rs index 06f83c4..2821759 100644 --- a/src/gamesystem.rs +++ b/src/gamesystem.rs @@ -1,3 +1,38 @@ +use crate::{Skill, Skills}; + pub fn attr_bonus(value: i32) -> i32 { (value - 10) / 2 // See: https://roll20.net/compendium/dnd5e/Ability%20Scores#content } + +pub fn player_hp_per_level(fitness: i32) -> i32 { + 10 + attr_bonus(fitness) +} + +pub fn player_hp_at_level(fitness: i32, level: i32) -> i32 { + player_hp_per_level(fitness) * level +} + +pub fn npc_hp(fitness: i32, level: i32) -> i32 { + let mut total = 1; + for _i in 0..level { + total += i32::max(1, 8 + attr_bonus(fitness)); + } + + total +} + +pub fn mana_per_level(intelligence: i32) -> i32 { + i32::max(1, 4 + attr_bonus(intelligence)) +} + +pub fn mana_at_level(intelligence: i32, level: i32) -> i32 { + mana_per_level(intelligence) * level +} + +pub fn skill_bonus(skill: Skill, skills: &Skills) -> i32 { + if skills.skills.contains_key(&skill) { + skills.skills[&skill] + } else { + -4 + } +} diff --git a/src/gui.rs b/src/gui.rs index da260da..cce6b91 100644 --- a/src/gui.rs +++ b/src/gui.rs @@ -2,7 +2,7 @@ use ::rltk::{Point, Rltk, VirtualKeyCode, RGB}; use ::specs::prelude::*; use crate::components::{ - CombatStats, HungerClock, HungerState, InBackpack, Name, Player, Position, Viewshed, + HungerClock, HungerState, InBackpack, Name, Player, Pools, Position, Viewshed, }; use crate::game_log::GameLog; use crate::rex_assets::RexAssets; @@ -18,13 +18,16 @@ pub fn draw_ui(ecs: &World, ctx: &mut Rltk) { RGB::named(rltk::BLACK), ); - let combat_stats = ecs.read_storage::(); + let combat_stats = ecs.read_storage::(); let players = ecs.read_storage::(); let hunger = ecs.read_storage::(); // Display player health for (_player, stats, hc) in (&players, &combat_stats, &hunger).join() { - let health = format!(" HP: {} / {} ", stats.hp, stats.max_hp); + let health = format!( + " HP: {} / {} ", + stats.hit_points.current, stats.hit_points.max + ); ctx.print_color( 12, 43, @@ -37,8 +40,8 @@ pub fn draw_ui(ecs: &World, ctx: &mut Rltk) { 29, 43, 51, - stats.hp, - stats.max_hp, + stats.hit_points.current, + stats.hit_points.max, RGB::named(rltk::RED), RGB::named(rltk::BLACK), ); diff --git a/src/inventory_system.rs b/src/inventory_system.rs index 1df1097..2b91b17 100644 --- a/src/inventory_system.rs +++ b/src/inventory_system.rs @@ -60,7 +60,7 @@ impl<'a> System<'a> for ItemUseSystem { ReadStorage<'a, Consumable>, ReadStorage<'a, ProvidesHealing>, ReadStorage<'a, InflictsDamage>, - WriteStorage<'a, CombatStats>, + WriteStorage<'a, Pools>, WriteStorage<'a, SufferDamage>, ReadStorage<'a, AreaOfEffect>, WriteStorage<'a, Confusion>, @@ -233,7 +233,10 @@ impl<'a> System<'a> for ItemUseSystem { for target in targets.iter() { if let Some(stats) = combat_stats.get_mut(*target) { - stats.hp = i32::min(stats.max_hp, stats.hp + healer.heal_amount); + stats.hit_points.current = i32::min( + stats.hit_points.max, + stats.hit_points.current + healer.heal_amount, + ); if entity == *player_entity { gamelog.append(format!( "You drink the {}, healing {} hp.", diff --git a/src/main.rs b/src/main.rs index 4dcdb38..ac2b0ad 100644 --- a/src/main.rs +++ b/src/main.rs @@ -417,15 +417,8 @@ impl State { self.generate_world_map(current_depth + 1); // Notify the player - let player_entity = self.ecs.fetch::(); let mut gamelog = self.ecs.fetch_mut::(); - gamelog.append("You descend to the next level, and take a moment to heal."); - - // Give them some health - let mut player_health_store = self.ecs.write_storage::(); - if let Some(player_health) = player_health_store.get_mut(*player_entity) { - player_health.hp = i32::max(player_health.hp, player_health.max_hp / 2); - } + gamelog.append("You descend to the next level."); } fn game_over_cleanup(&mut self) { @@ -508,7 +501,6 @@ fn main() -> ::rltk::BError { Monster, Name, BlocksTile, - CombatStats, WantsToMelee, SufferDamage, Item, @@ -544,6 +536,7 @@ fn main() -> ::rltk::BError { Quips, Attributes, Skills, + Pools, ); gs.ecs.insert(SimpleMarkerAllocator::::new()); diff --git a/src/melee_combat_system.rs b/src/melee_combat_system.rs index 326de17..61af954 100644 --- a/src/melee_combat_system.rs +++ b/src/melee_combat_system.rs @@ -1,11 +1,11 @@ -use ::rltk::RGB; +use ::rltk::{RandomNumberGenerator, RGB}; use ::specs::prelude::*; use crate::components::{ - CombatStats, DefenseBonus, Equipped, HungerClock, HungerState, MeleePowerBonus, Name, - SufferDamage, WantsToMelee, + Attributes, HungerClock, HungerState, Name, Pools, Skill, Skills, SufferDamage, WantsToMelee, }; use crate::game_log::GameLog; +use crate::gamesystem::skill_bonus; use crate::particle_system::ParticleBuilder; use crate::Position; @@ -18,14 +18,14 @@ impl<'a> System<'a> for MeleeCombatSystem { WriteExpect<'a, GameLog>, WriteStorage<'a, WantsToMelee>, ReadStorage<'a, Name>, - ReadStorage<'a, CombatStats>, + ReadStorage<'a, Attributes>, + ReadStorage<'a, Skills>, WriteStorage<'a, SufferDamage>, - ReadStorage<'a, MeleePowerBonus>, - ReadStorage<'a, DefenseBonus>, - ReadStorage<'a, Equipped>, WriteExpect<'a, ParticleBuilder>, ReadStorage<'a, Position>, ReadStorage<'a, HungerClock>, + ReadStorage<'a, Pools>, + WriteExpect<'a, RandomNumberGenerator>, ); fn run(&mut self, data: Self::SystemData) { @@ -34,47 +34,77 @@ impl<'a> System<'a> for MeleeCombatSystem { mut log, mut wants_melee, names, - combat_stats, + attributes, + skills, mut inflict_damage, - melee_power_bonuses, - defense_bonuses, - equipped, mut particle_builder, positions, hunger_clock, + pools, + mut rng, ) = data; - for (entity, wants_melee, name, stats) in - (&entities, &wants_melee, &names, &combat_stats).join() + for (entity, wants_melee, name, attacker_attributes, attacker_skills, attacker_pools) in ( + &entities, + &wants_melee, + &names, + &attributes, + &skills, + &pools, + ) + .join() { - if stats.hp > 0 { - let mut offensive_bonus = 0; - for (_item_entity, power_bonus, equipped_by) in - (&entities, &melee_power_bonuses, &equipped).join() - { - if equipped_by.owner == entity { - offensive_bonus += power_bonus.power; - } - } + // Are the attacker and defender alive? Only attack if they are + let target_pools = pools.get(wants_melee.target).unwrap(); + let target_attributes = attributes.get(wants_melee.target).unwrap(); + let target_skills = skills.get(wants_melee.target).unwrap(); + if attacker_pools.hit_points.current > 0 && target_pools.hit_points.current > 0 { + let target_name = names.get(wants_melee.target).unwrap(); + let natural_roll = rng.roll_dice(1, 20); + let attribute_hit_bonus = attacker_attributes.might.bonus; + let skill_hit_bonus = skill_bonus(Skill::Melee, &*attacker_skills); + let weapon_hit_bonus = 0; // TODO: Once weapons support this + let mut status_hit_bonus = 0; if let Some(hc) = hunger_clock.get(entity) { + // Well-Fed grants +1 if hc.state == HungerState::WellFed { - offensive_bonus += 1; + status_hit_bonus += 1; } } + let modified_hit_roll = natural_roll + + attribute_hit_bonus + + skill_hit_bonus + + weapon_hit_bonus + + status_hit_bonus; - let target_stats = combat_stats.get(wants_melee.target).unwrap(); - if target_stats.hp > 0 { - let target_name = names.get(wants_melee.target).unwrap(); + let base_armor_class = 10; + let armor_quickness_bonus = target_attributes.quickness.bonus; + let armor_skill_bonus = skill_bonus(Skill::Defense, &*target_skills); + let armor_item_bonus = 0; // TODO: Once armor supports this + let armor_class = + base_armor_class + armor_quickness_bonus + armor_skill_bonus + armor_item_bonus; - let mut defensive_bonus = 0; - for (_item_entity, defense_bonus, equipped_by) in - (&entities, &defense_bonuses, &equipped).join() - { - if equipped_by.owner == wants_melee.target { - defensive_bonus += defense_bonus.defense; - } - } + if natural_roll != 1 && (natural_roll == 20 || modified_hit_roll > armor_class) { + // Target hit! Until we support weapons, we're going with 1d4 + let base_damage = rng.roll_dice(1, 4); + let attr_damage_bonus = attacker_attributes.might.bonus; + let skill_damage_bonus = skill_bonus(Skill::Melee, &*attacker_skills); + let weapon_damage_bonus = 0; + + let damage = i32::max( + 0, + base_damage + + attr_damage_bonus + + skill_hit_bonus + + skill_damage_bonus + + weapon_damage_bonus, + ); + SufferDamage::new_damage(&mut inflict_damage, wants_melee.target, damage); + log.append(format!( + "{} hits {} for {} hp.", + &name.name, &target_name.name, damage + )); if let Some(pos) = positions.get(wants_melee.target) { particle_builder.request( @@ -86,23 +116,37 @@ impl<'a> System<'a> for MeleeCombatSystem { 200.0, ); } - - let damage = i32::max( - 0, - (stats.power + offensive_bonus) - (target_stats.defense + defensive_bonus), - ); - - if damage == 0 { - log.append(format!( - "{} is unable to hurt {}", - &name.name, &target_name.name - )); - } else { - log.append(format!( - "{} hits {}, for {} hp", - &name.name, &target_name.name, damage - )); - SufferDamage::new_damage(&mut inflict_damage, wants_melee.target, damage); + } else if natural_roll == 1 { + // Natural 1 miss + log.append(format!( + "{} considers attacking {}, but misjudges the timing.", + name.name, target_name.name + )); + if let Some(pos) = positions.get(wants_melee.target) { + particle_builder.request( + pos.x, + pos.y, + RGB::named(rltk::BLUE), + RGB::named(rltk::BLACK), + rltk::to_cp437('‼'), + 200.0, + ); + } + } else { + // Miss + log.append(format!( + "{} attacks {}, but can't connect", + name.name, target_name.name + )); + if let Some(pos) = positions.get(wants_melee.target) { + particle_builder.request( + pos.x, + pos.y, + RGB::named(rltk::CYAN), + RGB::named(rltk::BLACK), + rltk::to_cp437('‼'), + 200.0, + ); } } } diff --git a/src/player.rs b/src/player.rs index cb970e8..21c2049 100644 --- a/src/player.rs +++ b/src/player.rs @@ -4,8 +4,8 @@ use ::rltk::{Point, Rltk, VirtualKeyCode}; use ::specs::prelude::*; use crate::components::{ - BlocksTile, BlocksVisibility, CombatStats, Door, EntityMoved, HungerClock, HungerState, Item, - Monster, Player, Position, Renderable, Vendor, Viewshed, WantsToMelee, WantsToPickupItem, + BlocksTile, BlocksVisibility, Door, EntityMoved, HungerClock, HungerState, Item, Monster, + Player, Pools, Position, Renderable, Vendor, Viewshed, WantsToMelee, WantsToPickupItem, }; use crate::game_log::GameLog; use crate::{Bystander, Map, RunState, State, TileType}; @@ -15,7 +15,7 @@ pub fn try_move_player(delta_x: i32, delta_y: i32, ecs: &mut World) { let players = ecs.read_storage::(); let mut viewsheds = ecs.write_storage::(); let entities = ecs.entities(); - let combat_stats = ecs.read_storage::(); + let combat_stats = ecs.read_storage::(); let map = ecs.fetch::(); let mut wants_to_melee = ecs.write_storage::(); let mut entity_moved = ecs.write_storage::(); @@ -185,9 +185,9 @@ fn skip_turn(ecs: &mut World) -> RunState { } if can_heal { - let mut health_components = ecs.write_storage::(); - let player_hp = health_components.get_mut(*player_entity).unwrap(); - player_hp.hp = i32::min(player_hp.hp + 1, player_hp.max_hp); + let mut health_components = ecs.write_storage::(); + let pools = health_components.get_mut(*player_entity).unwrap(); + pools.hit_points.current = i32::min(pools.hit_points.current + 1, pools.hit_points.max); } RunState::PlayerTurn diff --git a/src/raws/mob_structs.rs b/src/raws/mob_structs.rs index 2c2ae77..1708fc7 100644 --- a/src/raws/mob_structs.rs +++ b/src/raws/mob_structs.rs @@ -1,3 +1,5 @@ +use std::collections::HashMap; + use ::serde::Deserialize; use super::item_structs::Renderable; @@ -7,19 +9,14 @@ pub struct Mob { pub name: String, pub renderable: Option, pub blocks_tile: bool, - pub stats: MobStats, pub vision_range: i32, pub ai: String, pub quips: Option>, pub attributes: MobAttributes, -} - -#[derive(Deserialize, Debug)] -pub struct MobStats { - pub max_hp: i32, - pub hp: i32, - pub power: i32, - pub defense: i32, + pub skills: Option>, + pub level: Option, + pub hp: Option, + pub mana: Option, } #[derive(Deserialize, Debug)] diff --git a/src/raws/rawmaster.rs b/src/raws/rawmaster.rs index 46b0672..1fddae3 100644 --- a/src/raws/rawmaster.rs +++ b/src/raws/rawmaster.rs @@ -3,6 +3,7 @@ use std::collections::{HashMap, HashSet}; use ::specs::prelude::*; use crate::components::*; +use crate::gamesystem::{mana_at_level, npc_hp}; use crate::random_table::RandomTable; use crate::raws::Raws; @@ -226,6 +227,8 @@ pub fn spawn_named_mob( }); } + let mut mob_fitness = 11; + let mut mob_int = 11; let mut attr = Attributes { might: Attribute::new(11), fitness: Attribute::new(11), @@ -237,27 +240,64 @@ pub fn spawn_named_mob( } if let Some(fitness) = mob_template.attributes.fitness { attr.fitness = Attribute::new(fitness); + mob_fitness = fitness; } if let Some(quickness) = mob_template.attributes.quickness { attr.quickness = Attribute::new(quickness); } if let Some(intelligence) = mob_template.attributes.intelligence { attr.intelligence = Attribute::new(intelligence); + mob_int = intelligence; } - eb = eb.with(attr); + let mob_level = if mob_template.level.is_some() { + mob_template.level.unwrap() + } else { + 1 + }; + let mob_hp = npc_hp(mob_fitness, mob_level); + let mob_mana = mana_at_level(mob_int, mob_level); + + let pools = Pools { + level: mob_level, + xp: 0, + hit_points: Pool { + current: mob_hp, + max: mob_hp, + }, + mana: Pool { + current: mob_mana, + max: mob_mana, + }, + }; + eb = eb.with(pools); + + let mut skills = Skills::new(1); + if let Some(mobskills) = &mob_template.skills { + for sk in mobskills.iter() { + match sk.0.as_str() { + "Melee" => { + skills.skills.insert(Skill::Melee, *sk.1); + } + "Defense" => { + skills.skills.insert(Skill::Defense, *sk.1); + } + "Magic" => { + skills.skills.insert(Skill::Magic, *sk.1); + } + _ => { + ::rltk::console::log(format!("Unknown skill referenced [{}]", sk.0)); + } + } + } + } + eb = eb.with(skills); + if mob_template.blocks_tile { eb = eb.with(BlocksTile {}); } - eb = eb.with(CombatStats { - max_hp: mob_template.stats.max_hp, - hp: mob_template.stats.hp, - power: mob_template.stats.power, - defense: mob_template.stats.defense, - }); - eb = eb.with(Viewshed { visible_tiles: Vec::new(), range: mob_template.vision_range, diff --git a/src/saveload_system.rs b/src/saveload_system.rs index 366f6ae..be26580 100644 --- a/src/saveload_system.rs +++ b/src/saveload_system.rs @@ -59,7 +59,6 @@ pub fn save_game(ecs: &mut World) { Monster, Name, BlocksTile, - CombatStats, SufferDamage, WantsToMelee, Item, @@ -94,6 +93,7 @@ pub fn save_game(ecs: &mut World) { Quips, Attributes, Skills, + Pools, ); } @@ -155,7 +155,6 @@ pub fn load_game(ecs: &mut World) { Monster, Name, BlocksTile, - CombatStats, SufferDamage, WantsToMelee, Item, @@ -190,6 +189,7 @@ pub fn load_game(ecs: &mut World) { Quips, Attributes, Skills, + Pools, ); } diff --git a/src/spawner.rs b/src/spawner.rs index 9287cde..9f71ae8 100644 --- a/src/spawner.rs +++ b/src/spawner.rs @@ -5,6 +5,7 @@ use ::specs::prelude::*; use ::specs::saveload::{MarkedBuilder, SimpleMarker}; use crate::components::*; +use crate::gamesystem::{mana_at_level, player_hp_at_level}; use crate::random_table::RandomTable; use crate::raws::{get_spawn_table_for_depth, spawn_named_entity, SpawnType, RAWS}; use crate::{Map, Rect, TileType}; @@ -25,12 +26,6 @@ pub fn player(ecs: &mut World, player_x: i32, player_y: i32) -> Entity { .with(Player {}) .with(Viewshed::default()) .with(Name::from("Player")) - .with(CombatStats { - max_hp: 30, - hp: 30, - defense: 2, - power: 5, - }) .with(HungerClock { state: HungerState::WellFed, duration: 20, @@ -41,6 +36,19 @@ pub fn player(ecs: &mut World, player_x: i32, player_y: i32) -> Entity { quickness: Attribute::new(11), intelligence: Attribute::new(11), }) + .with(Skills::new(1)) + .with(Pools { + hit_points: Pool { + current: player_hp_at_level(11, 1), + max: player_hp_at_level(11, 1), + }, + mana: Pool { + current: mana_at_level(11, 1), + max: mana_at_level(11, 1), + }, + xp: 0, + level: 1, + }) .marked::>() .build() }