diff --git a/src/common/rpg/components/effect.ts b/src/common/rpg/components/effect.ts new file mode 100644 index 0000000..5134518 --- /dev/null +++ b/src/common/rpg/components/effect.ts @@ -0,0 +1,83 @@ +import { component } from "../core/registry"; +import { Component, type EvalContext } from "../core/world"; +import { evaluateCondition } from "../utils/conditions"; +import { action, variable } from "../utils/decorators"; +import { Stat } from "./stat"; + +@component +export class Effect 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 +}> { + /** True while the effect's delta is applied to the target stat. */ + @variable('.') active: boolean = false; + + constructor( + targetStat: string, + delta: number, + targetField?: 'value' | 'max' | 'min', + duration?: number, + condition?: string, + ) { + super({ + targetStat, + targetField: targetField ?? 'value', + delta, + duration: duration ?? null, + remaining: duration ?? null, + condition: condition ?? null, + }); + } + + override onAdd(): void { + 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 { + 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.active = false; + 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.context)) { + this.clear(); + } + } + // permanent effect (no duration, no condition): nothing to do + } +} diff --git a/src/common/rpg/components/stat.ts b/src/common/rpg/components/stat.ts index f402ab3..2fe630f 100644 --- a/src/common/rpg/components/stat.ts +++ b/src/common/rpg/components/stat.ts @@ -3,7 +3,8 @@ import { Component } from "../core/world"; import { component } from "../core/registry"; interface StatState { - value: number; + base: number; + modifierSums: { value: number; max: number; min: number }; max: number | undefined; min: number | undefined; } @@ -11,47 +12,67 @@ interface StatState { @component export class Stat extends Component { constructor(value: number, max?: number, min?: number) { - super({ value, max, min }); + super({ base: value, modifierSums: { value: 0, max: 0, min: 0 }, max, min }); } - @variable('.') get value(): number { return this.state.value; } - @variable get max(): number | undefined { return this.state.max; } - @variable get min(): number | undefined { return this.state.min; } + @variable('.') get value(): number { + const effMin = this.min; + const effMax = this.max; + let v = this.state.base + this.state.modifierSums.value; + if (effMin != null) v = Math.max(effMin, v); + if (effMax != null) v = Math.min(v, effMax); + return v; + } + + @variable get base(): number { return this.state.base; } + @variable get max(): number | undefined { + return this.state.max != null ? this.state.max + this.state.modifierSums.max : undefined; + } + @variable get min(): number | undefined { + return this.state.min != null ? this.state.min + this.state.modifierSums.min : undefined; + } @action update(amount: number) { - this.set(this.state.value + amount); + this.set(this.state.base + amount); } @action set(value: number) { - const prev = this.state.value; - this.state.value = value; - if (this.state.min != null) { - this.state.value = Math.max(this.state.min, this.state.value); - } - if (this.state.max != null) { - this.state.value = Math.min(this.state.value, this.state.max); - } - if (prev !== this.state.value) { - this.emit('set', { prev, value: this.state.value }); + const prev = this.value; + this.state.base = value; + const next = this.value; + if (prev !== next) { + this.emit('set', { prev, value: next }); } } - get current(): number { - return this.state.value; + applyModifier(delta: number, field: 'value' | 'max' | 'min' = 'value'): void { + const prev = this.value; + this.state.modifierSums[field] += delta; + const next = this.value; + if (prev !== next) this.emit('set', { prev, value: next }); } + + removeModifier(delta: number, field: 'value' | 'max' | 'min' = 'value'): void { + this.applyModifier(-delta, field); + } + + get current(): number { return this.value; } } @component export class Health extends Stat { - constructor(value: number, max?: number, min = 0) { - super(value, max, min); - } - @action kill() { this.set(0); this.emit('killed'); } } + +@component +export class Defense extends Stat { } + +@component +export class Damage extends Stat { } + diff --git a/src/common/rpg/systems/effectSystem.ts b/src/common/rpg/systems/effectSystem.ts new file mode 100644 index 0000000..67c6918 --- /dev/null +++ b/src/common/rpg/systems/effectSystem.ts @@ -0,0 +1,19 @@ +import { Effect } from "../components/effect"; +import { System, type Entity, type World } from "../core/world"; + +export class EffectSystem extends System { + override update(world: World, dt: number): void { + const expired: [Entity, string][] = []; + + for (const [entity, key, effect] of world.query(Effect)) { + effect.update(dt, entity.context); + if (!effect.active) { + expired.push([entity, key]); + } + } + + for (const [entity, key] of expired) { + entity.remove(key); + } + } +}