//! Behavior contexts — one shared core, several event-specific faces.
//!
//! Every hook needs the same capabilities: write commands, read the world, roll dice, know
//! who fired, know the time. Factor that into `BehaviorCtx`; each specific context embeds it
//! and `Deref`s to it, so `c.cmd` / `c.query` / `c.rng` work flat no matter which context you
//! hold. Only the *tail* fields differ per event.
use std::collections::HashMap;
use macroquad::math::Vec2;
use crate::{
behavior::status::StatusBehavior, command::CommandBuffer, registry::DamageID, rng::Rng,
spatial::SpatialQuery, types::Source,
};
pub struct BehaviorCtx<'a> {
pub cmd: &'a mut CommandBuffer,
pub query: &'a SpatialQuery<'a>,
pub rng: &'a mut Rng,
pub source: Source,
pub now: f32,
}
/// One damage kind's contribution within a single hit, PoE-style: a flat base amount, a
/// pool of "increased/reduced" percentages that all sum together, and a running product of
/// "more/less" factors that each apply independently on top of the increased/reduced total.
///
/// `increased`/`reduced` mods are cheap and meant to stack freely (ten small +5% increased
/// mods behave like one +50%). `more`/`less` mods are meant to be rare and impactful (each
/// one is its own multiplier, so two "50% more" mods compound to 2.25x, not 2x) — that's the
/// whole point of keeping them as two separate accumulators instead of one.
///
/// `Default` is the single place "untouched" is defined for every field, so `flat`,
/// `increased`, and `more` all share the same `.entry(id).or_default()` init path — no
/// per-method special-casing, which is exactly what caused the earlier zeroing bug.
#[derive(Clone, Copy, Debug)]
pub struct DamageEffects {
flat: f32,
increased: f32,
more: f32,
}
impl Default for DamageEffects {
fn default() -> Self {
Self {
flat: 0.0,
increased: 0.0,
more: 1.0, // neutral for a running *product*, unlike the other two fields
}
}
}
impl DamageEffects {
/// `flat * (1 + increased) * more`, floored at 0 so reductions can zero out a hit but
/// never push it negative into healing.
pub fn compute(&self) -> f32 {
(self.flat * (1.0 + self.increased).max(0.0) * self.more).max(0.0)
}
}
#[derive(Default, Clone, Debug)]
pub struct DamageMap {
pub damage_map: HashMap<DamageID, DamageEffects>,
}
impl DamageMap {
/// Add flat base damage of `id`'s kind.
pub fn flat(&mut self, id: DamageID, amount: f32) -> &mut Self {
self.damage_map.entry(id).or_default().flat += amount;
self
}
/// PoE-style "increased/reduced": every mod on a kind sums into one pool before being
/// applied once. `0.2` = +20% increased, `-0.3` = -30% reduced. Order never matters.
pub fn increased(&mut self, id: DamageID, pct: f32) {
self.damage_map.entry(id).or_default().increased += pct;
}
/// PoE-style "more/less": each call is its own independent multiplicative factor,
/// applied on top of every other "more" mod rather than summed with them. `0.5` = 50%
/// more, `-0.2` = 20% less. Each factor is floored at 0 before multiplying in, so one
/// extreme "-150% less" zeroes the total instead of flipping the sign and un-zeroing it
/// when combined with another "less" mod.
pub fn more(&mut self, id: DamageID, pct: f32) {
let e = self.damage_map.entry(id).or_default();
e.more *= (1.0 + pct).max(0.0);
}
pub fn resolve(&self) -> f32 {
self.damage_map.values().map(DamageEffects::compute).sum()
}
}
pub struct HitContext<'a> {
pub core: BehaviorCtx<'a>,
pub hit_pos: Vec2,
pub normal: Vec2,
pub damage: DamageMap,
pub status: Vec<Box<dyn StatusBehavior>>,
}
impl<'a> HitContext<'a> {
pub fn new(core: BehaviorCtx<'a>, hit_pos: Vec2, normal: Vec2) -> HitContext<'a> {
HitContext {
core,
hit_pos,
normal,
damage: DamageMap::default(),
status: Vec::new(),
}
}
}
/// Firing geometry, available before anything is in flight: muzzle, aim direction, and the
/// locked target. Spawn-shaping behaviors (fan, multishot) live here.
pub struct FireContext<'a> {
pub core: BehaviorCtx<'a>,
pub origin: Vec2,
pub aim: Vec2,
pub target: Option<crate::spatial::EnemyRef>,
}
/// `dt` — and critically the *sim* dt, so in-flight behaviors freeze when a panel pauses the
/// game. UI uses real dt; this is the other half of the two-clock rule.
pub struct TickContext<'a> {
pub core: BehaviorCtx<'a>,
pub dt: f32,
}
/// `reason` lets a behavior trigger on one kind of death only (e.g. only on natural lifetime
/// expiry, not on consume-after-hit or fly-off-screen).
pub struct ExpireContext<'a> {
pub core: BehaviorCtx<'a>,
pub reason: ExpireReason,
}
/// Delivered to tower/enemy on-death hooks: where it died and who gets the kill.
pub struct DeathContext<'a> {
pub core: BehaviorCtx<'a>,
pub pos: Vec2,
pub killer: Source,
}
/// Generic bus delivery for SP passives — the `GameEvent` itself is the payload, so the
/// context is just the core.
pub struct EventContext<'a> {
pub core: BehaviorCtx<'a>,
}
#[derive(PartialEq, Eq)]
pub enum ExpireReason {
Lifetime,
OutOfBounds,
Consumed,
}
/// Deref glue, written once via macro instead of six hand-copied impls. This is what makes
/// `c.cmd.spawn_pool(...)` work directly on any context.
macro_rules! impl_ctx_deref {
($t:ident) => {
impl<'a> std::ops::Deref for $t<'a> {
type Target = BehaviorCtx<'a>;
fn deref(&self) -> &Self::Target {
&self.core
}
}
impl<'a> std::ops::DerefMut for $t<'a> {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.core
}
}
};
}
impl_ctx_deref!(HitContext);
impl_ctx_deref!(FireContext);
impl_ctx_deref!(TickContext);
impl_ctx_deref!(ExpireContext);
impl_ctx_deref!(DeathContext);
impl_ctx_deref!(EventContext);