159 lines
6.1 KiB
TypeScript
159 lines
6.1 KiB
TypeScript
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);
|
|
}
|
|
}
|
|
}
|
|
}
|