1
0
Fork 0
tsgames/src/common/rpg/systems/combat.ts

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