import { Attacked, Crit, Damage, Defense } from "../components/combat"; import { Effect, EffectTemplate } from "../components/effect"; import { getWorldRandom, Random } from "../components/random"; import { Health } from "../components/stat"; import { System, World } from "../core/world"; let hitEffectCounter = 0; export interface HitEvent { attackerId: string; sourceId: string | null; amount: number; damageType?: string; } export class CombatSystem extends System { override update(world: World) { let random: Random | undefined; const rng = () => (random ??= getWorldRandom(world)); for (const [target] of world.query(Attacked)) { const healths = target.getAll(Health).sort((a, b) => b.priority - a.priority); if (healths.length === 0) { console.warn(`[CombatSystem] Target ${target.id} has no Health component`); for (const attack of target.getAll(Attacked)) target.remove(attack); continue; } const hitEvents: HitEvent[] = []; for (const attack of target.getAll(Attacked)) { const { attackerId, sourceId } = attack.state; target.remove(attack); const attacker = world.getEntity(attackerId); if (!attacker) { console.warn(`[CombatSystem] Attacker ${attackerId} not found`); continue; } const source = sourceId ? world.getEntity(sourceId) : attacker; if (!source) { console.warn(`[CombatSystem] Source ${sourceId} not found`); continue; } const damages = source.getAll(Damage); if (damages.length === 0) { console.warn(`[CombatSystem] No Damage on source ${source.id}`); continue; } // Crit rolls once per attack; guard so a value < 1 can't reduce damage. let critMult = 1; const crit = source.get(Crit); if (crit) { const roll = rng().use(r => r.nextFloat()); if (roll < crit.state.chance) critMult = Math.max(1, crit.value); } // Each Damage component contributes its own typed hit (multi-type weapons). for (const damage of damages) { const { damageType, minDamage = 0, variance = 0 } = damage.state; let damageAmount = damage.value; // symmetric variance: [-variance, +variance] if (variance > 0) { damageAmount += rng().use(r => r.nextInt(-variance, variance + 1)); } damageAmount *= critMult; // sum all matching-type + general defenses, so stacked armor accumulates let defense = 0; for (const d of target.getAll(Defense)) { if (d.state.damageType === damageType || d.state.damageType == null) { defense += d.value; } } damageAmount -= defense; damageAmount = Math.max(0, minDamage, damageAmount); hitEvents.push({ attackerId, sourceId, amount: damageAmount, damageType }); } // on-hit effects from source → target, once per attack for (const component of source.getAll(EffectTemplate)) { const s = component.state; target.add( new Effect({ target: s.target, targetKey: s.targetKey, delta: s.delta, targetField: s.targetField, duration: s.duration ?? undefined, permanent: s.permanent, gradual: s.gradual, condition: s.condition ?? undefined, stacking: s.stacking, tag: s.tag ?? undefined, }), `__hit_${source.id}_${component.key}_${hitEffectCounter++}`, ); } } if (hitEvents.length === 0) continue; let totalBefore = 0; for (const pool of healths) totalBefore += pool.value; for (const hit of hitEvents) { let remaining = hit.amount; let applied = 0; if (hit.damageType) { for (const pool of healths) { if (remaining <= 0) break; if (pool.value <= 0) continue; if (!pool.damageTypes.includes(hit.damageType)) continue; const take = Math.min(pool.value, remaining); pool.update(-take); remaining -= take; applied += take; } } if (remaining > 0) { for (const pool of healths) { if (remaining <= 0) break; if (pool.value <= 0) continue; if (pool.damageTypes.length > 0) continue; const take = Math.min(pool.value, remaining); pool.update(-take); remaining -= take; applied += take; } } hit.amount = applied; // report damage actually dealt, not pre-mitigation } const lastHit = hitEvents[hitEvents.length - 1] ?? null; for (const info of hitEvents) { target.emit('Combat.hit', info); } let totalAfter = 0; for (const pool of healths) totalAfter += pool.value; if (totalBefore > 0 && totalAfter <= 0 && lastHit) { target.emit('Combat.killed', lastHit); } } } }