proxisim/src/player/mod.rs
2025-11-03 16:36:45 +01:00

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);
}