575 lines
20 KiB
Rust
575 lines
20 KiB
Rust
use bevy_ecs::prelude::*;
|
|
use proxisim_models::{
|
|
bundle::{
|
|
armour::{ArmourBodyPart, ArmourBodyParts},
|
|
passive::{FactionUpgrades, Merits},
|
|
player::{
|
|
Attacker, BodyPart, ChooseWeapon, CombatTurns, Current, CurrentTarget, Defeated,
|
|
Defender, FightEndType, Level, MaxHealth, PartDamageBonus, Player, PlayerStrategy,
|
|
Weapons,
|
|
},
|
|
stat::{
|
|
AmmoControl, Clips, CritRate, DamageBonus, Defence, Dexterity, EffectiveStat, Health,
|
|
SimpleStatBundle, SimpleStatEffective, Speed, Strength, WeaponAccuracy,
|
|
},
|
|
weapon::{
|
|
Ammo, DamageStat, NeedsReload, NonTargeted, RateOfFire, Usable, Uses, Weapon,
|
|
WeaponSlot,
|
|
},
|
|
},
|
|
hierarchy::Children,
|
|
};
|
|
use rand::Rng as _;
|
|
|
|
use crate::{
|
|
FightStatus, Rng, Stages,
|
|
effect::Effects,
|
|
log,
|
|
log::Logger,
|
|
metrics::Metrics,
|
|
weapon::{DamageProcEffect, TurnTriggeredEffect, bonus::MultiTurnBonus},
|
|
};
|
|
|
|
pub mod stats;
|
|
pub mod status_effect;
|
|
|
|
fn select_weapon(
|
|
weapons: &Weapons,
|
|
slot: WeaponSlot,
|
|
reload: bool,
|
|
usable_q: &Query<(Has<NeedsReload>, Option<&Children>), With<Usable>>,
|
|
) -> Option<(Entity, Option<Children>)> {
|
|
let id = match slot {
|
|
WeaponSlot::Primary => weapons.primary?,
|
|
WeaponSlot::Secondary => weapons.secondary?,
|
|
WeaponSlot::Melee => weapons.melee?,
|
|
WeaponSlot::Temporary => weapons.temporary?,
|
|
WeaponSlot::Fists => weapons.fists,
|
|
WeaponSlot::Kick => weapons.kick,
|
|
};
|
|
|
|
let (needs_reload, children) = usable_q.get(id).ok()?;
|
|
|
|
if !reload && needs_reload {
|
|
None
|
|
} else {
|
|
Some((id, children.cloned()))
|
|
}
|
|
}
|
|
|
|
fn derive_max_health(
|
|
level_query: Query<(Entity, &Level, &Merits, &FactionUpgrades)>,
|
|
mut cmd: Commands,
|
|
) {
|
|
for (entity, level, merits, faction) in level_query.iter() {
|
|
let base_life = match level.0 {
|
|
1..=8 => 100 + (level.0 - 1) * 25,
|
|
9..=95 => 275 + (level.0 - 8) * 50,
|
|
96.. => 4625 + (level.0 - 95) * 75,
|
|
0 => unreachable!(),
|
|
};
|
|
|
|
let max_health =
|
|
((base_life as f32) * (1.0 + ((merits.life * 5 + faction.life) as f32) / 100.0)) as u16;
|
|
|
|
cmd.entity(entity).insert((
|
|
MaxHealth(max_health),
|
|
SimpleStatBundle::<Health>::new(max_health),
|
|
));
|
|
}
|
|
}
|
|
|
|
fn designate_first(
|
|
attacker_q: Query<Entity, With<Attacker>>,
|
|
defender_q: Query<Entity, With<Defender>>,
|
|
mut commands: Commands,
|
|
) {
|
|
let attacker = attacker_q.iter().next().unwrap();
|
|
let defender = defender_q.single().unwrap();
|
|
commands.entity(attacker).insert(Current);
|
|
commands.entity(defender).insert(CurrentTarget);
|
|
}
|
|
|
|
fn change_roles(
|
|
current_q: Query<Entity, (With<Current>, With<Player>)>,
|
|
target_q: Query<Entity, With<CurrentTarget>>,
|
|
mut commands: Commands,
|
|
) {
|
|
let current = current_q.single().unwrap();
|
|
let target = target_q.single().unwrap();
|
|
|
|
// TODO: Group fights
|
|
commands
|
|
.entity(current)
|
|
.remove::<Current>()
|
|
.insert(CurrentTarget);
|
|
// TODO: Distraction
|
|
commands
|
|
.entity(target)
|
|
.insert(Current)
|
|
.remove::<CurrentTarget>();
|
|
}
|
|
|
|
fn check_term_condition(
|
|
mut state: ResMut<FightStatus>,
|
|
defender_q: Query<(), (With<Defender>, Without<Defeated>)>,
|
|
attacker_q: Query<(), (With<Attacker>, Without<Defeated>)>,
|
|
) {
|
|
if defender_q.is_empty() || attacker_q.is_empty() {
|
|
*state = FightStatus::Over;
|
|
}
|
|
}
|
|
|
|
pub fn pick_action(
|
|
mut p_query: Query<
|
|
(Entity, &Weapons, &PlayerStrategy, &mut CombatTurns),
|
|
(With<Current>, With<Player>),
|
|
>,
|
|
target_q: Query<Entity, With<CurrentTarget>>,
|
|
usable_q: Query<(Has<NeedsReload>, Option<&Children>), With<Usable>>,
|
|
weapon_trigger_q: Query<&TurnTriggeredEffect>,
|
|
mut commands: Commands,
|
|
mut effects: Effects,
|
|
metrics: Res<Metrics>,
|
|
) {
|
|
let (current, weapons, strat, mut turns) = p_query.single_mut().unwrap();
|
|
let (weapon, children) = match strat {
|
|
PlayerStrategy::AlwaysFists => (weapons.fists, None),
|
|
PlayerStrategy::AlwaysKicks => select_weapon(weapons, WeaponSlot::Kick, true, &usable_q)
|
|
.unwrap_or_else(|| (weapons.fists, Default::default())),
|
|
PlayerStrategy::PrimaryMelee { reload } => {
|
|
select_weapon(weapons, WeaponSlot::Primary, *reload, &usable_q)
|
|
.or_else(|| select_weapon(weapons, WeaponSlot::Melee, true, &usable_q))
|
|
.unwrap_or_else(|| (weapons.fists, Default::default()))
|
|
}
|
|
PlayerStrategy::InOrder { order, reload } => order
|
|
.iter()
|
|
.find_map(|slot| select_weapon(weapons, *slot, *reload, &usable_q))
|
|
.unwrap_or_else(|| (weapons.fists, Default::default())),
|
|
};
|
|
metrics.increment_counter(Some(current), "turn", 1);
|
|
metrics.increment_counter(Some(weapon), "turn", 1);
|
|
|
|
commands.entity(weapon).insert(Current);
|
|
|
|
let target = target_q.single().unwrap();
|
|
|
|
if let Some(children) = children {
|
|
for effect in weapon_trigger_q.iter_many(children.get()) {
|
|
effect.trigger(&mut effects, current, target);
|
|
}
|
|
}
|
|
|
|
turns.0 += 1;
|
|
}
|
|
|
|
pub struct DamageSpread(rand_distr::Beta<f32>);
|
|
|
|
impl FromWorld for DamageSpread {
|
|
fn from_world(_world: &mut World) -> Self {
|
|
Self(rand_distr::Beta::new(3.0, 3.0).unwrap())
|
|
}
|
|
}
|
|
|
|
// NOTE: unfortunately this function can't really be split into smaller parts due to the existence
|
|
// of multi turn bonuses
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub fn use_damaging_weapon(
|
|
mut rng: ResMut<Rng>,
|
|
weapon_q: Query<
|
|
(
|
|
Entity,
|
|
&DamageStat,
|
|
&SimpleStatEffective<WeaponAccuracy>,
|
|
&SimpleStatEffective<DamageBonus>,
|
|
&SimpleStatEffective<CritRate>,
|
|
&Children,
|
|
Has<NonTargeted>,
|
|
),
|
|
(With<Weapon>, With<Current>, Without<NeedsReload>),
|
|
>,
|
|
player_q: Query<
|
|
(
|
|
Entity,
|
|
&EffectiveStat<Speed>,
|
|
&EffectiveStat<Strength>,
|
|
&SimpleStatEffective<CritRate>,
|
|
&SimpleStatEffective<WeaponAccuracy>,
|
|
&SimpleStatEffective<DamageBonus>,
|
|
Has<Attacker>,
|
|
),
|
|
(With<Player>, With<Current>),
|
|
>,
|
|
mut target_q: Query<
|
|
(
|
|
Entity,
|
|
&EffectiveStat<Dexterity>,
|
|
&EffectiveStat<Defence>,
|
|
&ArmourBodyParts,
|
|
&mut SimpleStatEffective<Health>,
|
|
),
|
|
With<CurrentTarget>,
|
|
>,
|
|
armour_q: Query<&ArmourBodyPart>,
|
|
(damage_proc_q, part_bonus_q): (Query<&DamageProcEffect>, Query<&PartDamageBonus>),
|
|
(mut ammo_q, mut temp_q): (
|
|
Query<(
|
|
&mut Ammo,
|
|
&SimpleStatEffective<Clips>,
|
|
&RateOfFire,
|
|
&SimpleStatEffective<AmmoControl>,
|
|
)>,
|
|
Query<&mut Uses>,
|
|
),
|
|
(mut logger, mut commands, dmg_spread, metrics, mut effects): (
|
|
Logger,
|
|
Commands,
|
|
Local<DamageSpread>,
|
|
Res<Metrics>,
|
|
Effects,
|
|
),
|
|
) {
|
|
let Ok((weapon, w_dmg, acc, dmg_bonus, crit, children, non_targeted)) = weapon_q.single()
|
|
else {
|
|
return;
|
|
};
|
|
let (player, player_spd, player_str, player_crit, acc_bonus, p_dmg_bonus, attacker) =
|
|
player_q.single().unwrap();
|
|
let (target, target_dex, target_def, armour_parts, mut health) = target_q.single_mut().unwrap();
|
|
|
|
if let Ok(mut uses) = temp_q.get_mut(weapon) {
|
|
uses.0 -= 1;
|
|
if uses.0 == 0 {
|
|
commands.entity(weapon).remove::<Usable>();
|
|
}
|
|
}
|
|
|
|
let spd_dex_ratio = (player_spd.value / target_dex.value).clamp(1.0 / 64.0, 64.0);
|
|
let base_hit_chance = if spd_dex_ratio < 1.0 {
|
|
0.5 * (8.0 * spd_dex_ratio.sqrt() - 1.0) / 7.0
|
|
} else {
|
|
1.0 - 0.5 * (8.0 / spd_dex_ratio.sqrt() - 1.0) / 7.0
|
|
};
|
|
|
|
let mut acc_eff = acc + acc_bonus;
|
|
|
|
let mut ammo = ammo_q
|
|
.get_mut(weapon)
|
|
.ok()
|
|
.map(|(ammo, clips, rof, ammo_ctrl)| {
|
|
let ammo_ctrl = 1.0 - (ammo_ctrl).value;
|
|
let rof_eff = ((rof.0[0] as f32) * ammo_ctrl)..((rof.0[1] as f32) * ammo_ctrl);
|
|
(ammo, clips, rof_eff)
|
|
});
|
|
|
|
enum MultiAttack {
|
|
Blindfire,
|
|
Rage(u16),
|
|
Fury { first_hit: bool },
|
|
DoubleTap { first_shot: bool },
|
|
}
|
|
|
|
let mut multi_attack_proc = None;
|
|
let mut defeated = false;
|
|
let crit = player_crit + crit;
|
|
|
|
let def_str_ratio = (target_def.value / player_str.value).clamp(1.0 / 32.0, 14.0);
|
|
let def_mitigation = if def_str_ratio < 1.0 {
|
|
0.5 * def_str_ratio.log(32.0) + 0.5
|
|
} else {
|
|
0.5 * def_str_ratio.log(14.0) + 0.5
|
|
};
|
|
|
|
let dmg_intrinsic = 7.0 * (player_str.value / 10.0).log10().powi(2)
|
|
+ 27.0 * (player_str.value / 10.0).log10()
|
|
+ 30.0;
|
|
|
|
loop {
|
|
let rounds = ammo.as_mut().map(|(ammo, clips, rof)| {
|
|
let rounds = (rng.random_range(rof.clone()).round() as u16).clamp(1, ammo.0);
|
|
metrics.increment_counter(Some(player), "rounds_fired", rounds.into());
|
|
metrics.increment_counter(Some(weapon), "rounds_fired", rounds.into());
|
|
ammo.0 -= rounds;
|
|
if ammo.0 == 0 {
|
|
if clips.value == 0 {
|
|
commands.entity(weapon).remove::<Usable>();
|
|
} else {
|
|
commands.entity(weapon).insert(NeedsReload);
|
|
}
|
|
}
|
|
rounds
|
|
});
|
|
|
|
let hit_chance = if base_hit_chance < 0.5 {
|
|
base_hit_chance + acc_eff.value * base_hit_chance
|
|
} else {
|
|
base_hit_chance + acc_eff.value * (1.0 - base_hit_chance)
|
|
};
|
|
|
|
if hit_chance <= 1.0 && !rng.random_bool(hit_chance as f64) {
|
|
log!(logger, "miss_target", {
|
|
weapon: weapon,
|
|
actor: player,
|
|
recipient: target,
|
|
rounds: rounds,
|
|
hit_chance: hit_chance,
|
|
});
|
|
metrics.increment_counter(Some(player), "miss", 1);
|
|
metrics.increment_counter(Some(weapon), "miss", 1);
|
|
|
|
if multi_attack_proc.is_none() {
|
|
return;
|
|
};
|
|
} else {
|
|
let body_part = if !non_targeted {
|
|
rng.sample(crit)
|
|
} else {
|
|
BodyPart::Stomach
|
|
};
|
|
|
|
let mult = match body_part {
|
|
BodyPart::Head | BodyPart::Heart | BodyPart::Throat => {
|
|
metrics.increment_counter(Some(player), "crit", 1);
|
|
metrics.increment_counter(Some(weapon), "crit", 1);
|
|
1.0
|
|
}
|
|
BodyPart::LeftHand
|
|
| BodyPart::RightHand
|
|
| BodyPart::LeftFoot
|
|
| BodyPart::RightFoot => 0.2,
|
|
BodyPart::LeftArm | BodyPart::RightArm | BodyPart::LeftLeg | BodyPart::RightLeg => {
|
|
1.0 / 3.5
|
|
}
|
|
BodyPart::Groin | BodyPart::Stomach | BodyPart::Chest => 1.0 / 1.75,
|
|
};
|
|
|
|
metrics.increment_counter(Some(player), "hit", 1);
|
|
metrics.increment_counter(Some(weapon), "hit", 1);
|
|
|
|
let armour_parts = armour_q.get(armour_parts.0[body_part.into()]).unwrap();
|
|
let piece = rng.sample(armour_parts);
|
|
let armour_mitigation = piece.map_or(0.0, |p| p.armour_value);
|
|
|
|
// NOTE: Proxima's simulator seems to have the damage spread be between 95% and 105%,
|
|
// but from my brief tests it seems that 100% to 110% lines up better, at least for h2h.
|
|
// It might be better to revivisit this detail later down the line and run more tests.
|
|
let dmg_spread = rng.sample(dmg_spread.0) / 10.0 + 1.0;
|
|
|
|
let mut dmg_bonus = dmg_bonus + p_dmg_bonus;
|
|
|
|
for part_bonus in part_bonus_q.iter_many(children.get()) {
|
|
if let Some(bonus) = part_bonus.dmg_bonus(body_part) {
|
|
dmg_bonus.value += bonus;
|
|
}
|
|
}
|
|
|
|
// TODO: special ammo
|
|
|
|
let dmg = dmg_intrinsic
|
|
* w_dmg.0
|
|
* (1.0 + dmg_bonus.value)
|
|
* (1.0 - armour_mitigation)
|
|
* (1.0 - def_mitigation)
|
|
* mult
|
|
* dmg_spread;
|
|
let dmg = dmg.round() as u32;
|
|
|
|
metrics.record_histogram(Some(player), "dmg", dmg);
|
|
metrics.record_histogram(Some(weapon), "dmg", dmg);
|
|
|
|
if dmg > 0 {
|
|
for effect in damage_proc_q.iter_many(children.get()) {
|
|
match *effect {
|
|
DamageProcEffect::MultiTurn { value, bonus } => {
|
|
if multi_attack_proc.is_some() {
|
|
continue;
|
|
}
|
|
let chance = (value / 100.0) as f64;
|
|
if chance > 1.0 || rng.random_bool(chance) {
|
|
match bonus {
|
|
MultiTurnBonus::Blindfire => {
|
|
multi_attack_proc = Some(MultiAttack::Blindfire)
|
|
}
|
|
MultiTurnBonus::Fury => {
|
|
multi_attack_proc =
|
|
Some(MultiAttack::Fury { first_hit: true })
|
|
}
|
|
MultiTurnBonus::Rage => {
|
|
multi_attack_proc =
|
|
Some(MultiAttack::Rage(rng.random_range(2..=8)))
|
|
}
|
|
MultiTurnBonus::DoubleTap => {
|
|
multi_attack_proc =
|
|
Some(MultiAttack::DoubleTap { first_shot: true })
|
|
}
|
|
};
|
|
metrics.increment_counter(Some(player), bonus.counter_label(), 1);
|
|
metrics.increment_counter(Some(weapon), bonus.counter_label(), 1);
|
|
}
|
|
}
|
|
DamageProcEffect::SelfEffect { value, bonus } => {
|
|
let chance = (value / 100.0) as f64;
|
|
if chance > 1.0 || rng.random_bool(chance) {
|
|
bonus.spawn(player, &mut effects);
|
|
}
|
|
}
|
|
DamageProcEffect::OpponentEffect { value, bonus } => {
|
|
let chance = (value / 100.0) as f64;
|
|
if chance > 1.0 || rng.random_bool(chance) {
|
|
bonus.spawn(target, &mut effects, &mut rng.0);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
let health_before = health.value;
|
|
|
|
health.value = health.value.saturating_sub(dmg as u16);
|
|
|
|
log!(logger, "hit_target", {
|
|
actor: player,
|
|
acc: acc_eff.value,
|
|
recipient: target,
|
|
weapon,
|
|
part: %body_part,
|
|
part_mult: mult,
|
|
rounds,
|
|
health_before: health_before,
|
|
health_after: health.value,
|
|
dmg,
|
|
dmg_spread,
|
|
dmg_intrinsic,
|
|
dmg_weapon: w_dmg.0,
|
|
armour_mitigation,
|
|
def_mitigation,
|
|
bonus_dmg: dmg_bonus.value,
|
|
hit_chance: hit_chance,
|
|
crit_rate: crit.value,
|
|
});
|
|
|
|
if health.value == 0 && !defeated {
|
|
defeated = true;
|
|
|
|
commands.entity(target).insert(Defeated);
|
|
log!(logger, "fight_end", {
|
|
actor: player,
|
|
recipient: target,
|
|
fight_end_type: %if attacker {
|
|
FightEndType::Victory
|
|
} else {
|
|
FightEndType::Loss
|
|
},
|
|
});
|
|
metrics.increment_counter(Some(player), "victory", 1);
|
|
}
|
|
}
|
|
|
|
// Technically only douple tap and blindfire have this condition, but we can run into with
|
|
// invalid bonus/weapon combinations without checking this for all bonuses
|
|
if ammo.as_ref().is_some_and(|(a, _, _)| a.0 == 0) {
|
|
break;
|
|
}
|
|
|
|
match multi_attack_proc {
|
|
Some(MultiAttack::Blindfire) => {
|
|
acc_eff.value -= 5.0 / 50.0;
|
|
// Prevent infinite loop if used on a melee
|
|
if ammo.is_none() {
|
|
break;
|
|
}
|
|
}
|
|
Some(MultiAttack::Fury { first_hit: true }) => {
|
|
multi_attack_proc = Some(MultiAttack::Fury { first_hit: false })
|
|
}
|
|
Some(MultiAttack::Rage(turns @ 1..)) => {
|
|
multi_attack_proc = Some(MultiAttack::Rage(turns - 1))
|
|
}
|
|
Some(MultiAttack::DoubleTap { first_shot: true }) => {
|
|
multi_attack_proc = Some(MultiAttack::DoubleTap { first_shot: false })
|
|
}
|
|
_ => break,
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn check_stalemate(
|
|
current_q: Query<(Entity, &CombatTurns, Has<Attacker>), (With<Current>, With<Player>)>,
|
|
target_q: Query<Entity, With<CurrentTarget>>,
|
|
other_attackers_q: Query<(), (With<Attacker>, Without<Current>)>,
|
|
mut state: ResMut<FightStatus>,
|
|
mut commands: Commands,
|
|
mut logger: Logger,
|
|
metrics: Res<Metrics>,
|
|
) {
|
|
let (current, current_turns, attacker) = current_q.single().unwrap();
|
|
if *state == FightStatus::Ongoing && current_turns.0 >= 25 && attacker {
|
|
commands.entity(current).insert(Defeated);
|
|
let target = target_q.single();
|
|
|
|
log!(logger, "fight_end", {
|
|
actor: current,
|
|
recipient: target,
|
|
fight_end_type: %FightEndType::Stalemate,
|
|
});
|
|
metrics.increment_counter(Some(current), "stalemate", 1);
|
|
|
|
if other_attackers_q.is_empty() {
|
|
*state = FightStatus::Over
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn restore_initial_state(
|
|
mut state: ResMut<FightStatus>,
|
|
mut player_q: Query<(Entity, &mut CombatTurns, Has<Attacker>)>,
|
|
mut commands: Commands,
|
|
) {
|
|
*state = FightStatus::Ongoing;
|
|
|
|
for (player, mut turns, attacker) in player_q.iter_mut() {
|
|
turns.0 = 0;
|
|
commands.entity(player).remove::<Defeated>();
|
|
if attacker {
|
|
commands
|
|
.entity(player)
|
|
.remove::<CurrentTarget>()
|
|
.insert(Current);
|
|
} else {
|
|
commands
|
|
.entity(player)
|
|
.remove::<Current>()
|
|
.insert(CurrentTarget);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn record_post_fight_stats(
|
|
player_q: Query<(Entity, &SimpleStatEffective<Health>)>,
|
|
metrics: Res<Metrics>,
|
|
) {
|
|
for (player, health) in player_q.iter() {
|
|
metrics.record_histogram(Some(player), "rem_health", health.value as u32);
|
|
}
|
|
}
|
|
|
|
pub(crate) fn configure(stages: &mut Stages) {
|
|
stats::configure(stages);
|
|
status_effect::configure(stages);
|
|
|
|
stages.add_event::<ChooseWeapon>();
|
|
stages.equip.add_systems(designate_first);
|
|
stages.pre_fight.add_systems(derive_max_health);
|
|
stages.pre_turn.add_systems(pick_action);
|
|
stages.turn.add_systems(use_damaging_weapon);
|
|
stages
|
|
.post_turn
|
|
.add_systems((check_term_condition, change_roles))
|
|
.add_systems(
|
|
check_stalemate
|
|
.after(check_term_condition)
|
|
.before(change_roles),
|
|
);
|
|
stages.post_fight.add_systems(record_post_fight_stats);
|
|
stages.restore.add_systems(restore_initial_state);
|
|
}
|