149 lines
5.1 KiB
TypeScript
149 lines
5.1 KiB
TypeScript
import type { Class } from "@common/types";
|
|
import { Component, type EvalContext } from "../core/world";
|
|
import { evaluateCondition } from "../utils/conditions";
|
|
import { action, component, ComponentTag, getComponentMeta, getComponentName, tag, variable } from "../utils/decorators";
|
|
import { Stat } from "./stat";
|
|
|
|
abstract class BaseEffect extends Component<{
|
|
target: string; // component class ID, e.g. 'Stat'
|
|
targetKey?: string; // component key, e.g. 'health'
|
|
targetField: 'value' | 'max' | 'min';
|
|
delta: number;
|
|
duration: number | null; // null = permanent until removed
|
|
gradual: boolean; // true if delta should be applied slowly over duration
|
|
permanent: boolean; // true if effect should not be removed by clear()
|
|
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
|
|
active: boolean; // true while the delta is currently applied to the target stat
|
|
}> {
|
|
constructor(opts: {
|
|
target?: Class<Component<any>> | string,
|
|
targetKey?: string;
|
|
delta: number;
|
|
targetField?: 'value' | 'max' | 'min';
|
|
duration?: number;
|
|
gradual?: boolean;
|
|
permanent?: boolean;
|
|
condition?: string;
|
|
stacking?: 'stack' | 'unique' | 'replace';
|
|
tag?: string;
|
|
}) {
|
|
super({
|
|
target: typeof opts.target === 'string'
|
|
? opts.target
|
|
: getComponentName(opts.target ?? Stat)!,
|
|
targetKey: opts.targetKey,
|
|
targetField: opts.targetField ?? 'value',
|
|
delta: opts.delta,
|
|
duration: opts.duration ?? null,
|
|
gradual: opts.gradual ?? false,
|
|
permanent: opts.permanent ?? false,
|
|
remaining: opts.duration ?? null,
|
|
condition: opts.condition ?? null,
|
|
stacking: opts.stacking ?? 'stack',
|
|
tag: opts.tag ?? null,
|
|
active: false,
|
|
});
|
|
|
|
if (opts.gradual && opts.duration == null) {
|
|
throw new Error('Effect cannot be gradual without a duration');
|
|
}
|
|
|
|
if (opts.duration != null && opts.duration <= 0) {
|
|
throw new Error('Effect duration must be greater than zero');
|
|
}
|
|
|
|
if (opts.permanent && opts.duration != null) {
|
|
throw new Error('Effect cannot be both permanent and have a duration');
|
|
}
|
|
}
|
|
}
|
|
|
|
@tag(ComponentTag.Equippable)
|
|
@component
|
|
export class Effect extends BaseEffect {
|
|
/** True while the delta is applied. Backed by `state` so it survives serialize/clone. */
|
|
@variable('.') get active(): boolean { return this.state.active; }
|
|
set active(v: boolean) { this.state.active = v; }
|
|
|
|
override onAdd(): void {
|
|
const { stacking, tag } = this.state;
|
|
|
|
if (this.state.permanent && this.state.condition) {
|
|
if (!evaluateCondition(this.state.condition, this.entity)) {
|
|
this.entity.remove(this);
|
|
return;
|
|
}
|
|
}
|
|
|
|
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);
|
|
return;
|
|
}
|
|
|
|
if (stacking === 'replace') {
|
|
for (const old of siblings) {
|
|
this.entity.remove(old);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (this.state.gradual) {
|
|
this.active = true;
|
|
} else {
|
|
this.applyDelta(this.state.delta);
|
|
}
|
|
|
|
if (this.state.permanent) {
|
|
this.entity.remove(this); // permanent effects are removed immediately
|
|
}
|
|
}
|
|
|
|
override onRemove(): void {
|
|
if (!this.active) return;
|
|
this.active = false;
|
|
|
|
if (this.state.permanent || this.state.gradual) return;
|
|
|
|
this.applyDelta(-this.state.delta);
|
|
this.active = false; // applyDelta sets active to true again
|
|
}
|
|
|
|
@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');
|
|
}
|
|
|
|
applyDelta(delta: number) {
|
|
const component = getComponentMeta(this.state.target);
|
|
if (component) {
|
|
const stat = this.entity.get(component.ctor, this.state.targetKey);
|
|
if (stat instanceof Stat) {
|
|
stat.applyModifier(delta, this.state.targetField);
|
|
this.active = true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@component
|
|
export class EffectTemplate extends BaseEffect {
|
|
}
|