import { Component, type EvalContext } from "../core/world"; import { evaluateCondition } from "../utils/conditions"; import { action, component, ComponentTag, tag, variable } from "../utils/decorators"; import { Stat } from "./stat"; abstract class BaseEffect extends Component<{ targetStat: string; // component key, e.g. 'health' targetField: 'value' | 'max' | 'min'; delta: number; duration: number | null; // null = permanent until removed remaining: number | null; // countdown in seconds; null for condition-based/permanent condition: string | null; // keep effect while true; remove when it becomes false stacking: 'stack' | 'unique' | 'replace'; tag: string | null; // discriminator for stacking; null = no stacking enforcement }> { constructor(opts: { targetStat: string; delta: number; targetField?: 'value' | 'max' | 'min'; duration?: number; condition?: string; stacking?: 'stack' | 'unique' | 'replace'; tag?: string; }) { super({ targetStat: opts.targetStat, targetField: opts.targetField ?? 'value', delta: opts.delta, duration: opts.duration ?? null, remaining: opts.duration ?? null, condition: opts.condition ?? null, stacking: opts.stacking ?? 'stack', tag: opts.tag ?? null, }); } } @tag(ComponentTag.Equippable) @component export class Effect extends BaseEffect { /** True while the effect's delta is applied to the target stat. */ @variable('.') active: boolean = false; override onAdd(): void { const { stacking, tag } = this.state; if (tag != null && stacking !== 'stack') { const siblings = this.entity.getAll(Effect).filter(e => e !== this && e.state.tag === tag); if (stacking === 'unique' && siblings.length > 0) { // An effect with this tag is already active — discard the incoming one. this.entity.remove(this.key); return; } if (stacking === 'replace') { for (const old of siblings) { this.entity.remove(old.key); } } } const stat = this.entity.get(Stat, this.state.targetStat); if (stat) { stat.applyModifier(this.state.delta, this.state.targetField); this.active = true; } } override onRemove(): void { if (!this.active) return; const stat = this.entity.get(Stat, this.state.targetStat); if (stat) { stat.removeModifier(this.state.delta, this.state.targetField); } this.active = false; } @action reset(duration?: number): void { if (duration != null) { this.state.duration = duration; } this.state.remaining = this.state.duration; this.active = true; } /** Mark as expired. EffectSystem will remove the component, which reverses the delta. */ @action clear(): void { this.state.remaining = 0; this.emit('expired'); } update(dt: number, ctx: EvalContext): void { if (this.state.remaining != null) { this.state.remaining -= dt; if (this.state.remaining <= 0) { this.state.remaining = 0; this.clear(); } } else if (this.state.condition != null) { if (!evaluateCondition(this.state.condition, ctx)) { this.clear(); } } // permanent effect (no duration, no condition): nothing to do } } @component export class EffectTemplate extends BaseEffect { }