1
0
Fork 0
tsgames/src/common/rpg/components/effect.ts

113 lines
3.7 KiB
TypeScript

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 {
}