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

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