1
0
Fork 0

Component tags

This commit is contained in:
Pabloader 2026-04-30 21:05:52 +00:00
parent 6568e1e8d9
commit a340c50f57
7 changed files with 79 additions and 55 deletions

View File

@ -1,30 +1,24 @@
import { Component, type EvalContext } from "../core/world"; import { Component, type EvalContext } from "../core/world";
import { evaluateCondition } from "../utils/conditions"; import { evaluateCondition } from "../utils/conditions";
import { action, component, variable } from "../utils/decorators"; import { action, component, ComponentTag, tag, variable } from "../utils/decorators";
import { Stat } from "./stat"; import { Stat } from "./stat";
@component abstract class BaseEffect extends Component<{
export class Effect extends Component<{
targetStat: string; // component key, e.g. 'health' targetStat: string; // component key, e.g. 'health'
targetField: 'value' | 'max' | 'min'; targetField: 'value' | 'max' | 'min';
delta: number; delta: number;
duration: number | null; // null = permanent until removed duration: number | null; // null = permanent until removed
remaining: number | null; // countdown in seconds; null for condition-based/permanent remaining: number | null; // countdown in seconds; null for condition-based/permanent
condition: string | null; // keep effect while true; remove when it becomes false condition: string | null; // keep effect while true; remove when it becomes false
scope: 'equip' | 'onHit'; // 'equip' = live modifier on owner; 'onHit' = template applied to attack target
stacking: 'stack' | 'unique' | 'replace'; stacking: 'stack' | 'unique' | 'replace';
tag: string | null; // discriminator for stacking; null = no stacking enforcement tag: string | null; // discriminator for stacking; null = no stacking enforcement
}> { }> {
/** True while the effect's delta is applied to the target stat. */
@variable('.') active: boolean = false;
constructor(opts: { constructor(opts: {
targetStat: string; targetStat: string;
delta: number; delta: number;
targetField?: 'value' | 'max' | 'min'; targetField?: 'value' | 'max' | 'min';
duration?: number; duration?: number;
condition?: string; condition?: string;
scope?: 'equip' | 'onHit';
stacking?: 'stack' | 'unique' | 'replace'; stacking?: 'stack' | 'unique' | 'replace';
tag?: string; tag?: string;
}) { }) {
@ -35,15 +29,19 @@ export class Effect extends Component<{
duration: opts.duration ?? null, duration: opts.duration ?? null,
remaining: opts.duration ?? null, remaining: opts.duration ?? null,
condition: opts.condition ?? null, condition: opts.condition ?? null,
scope: opts.scope ?? 'equip',
stacking: opts.stacking ?? 'stack', stacking: opts.stacking ?? 'stack',
tag: opts.tag ?? null, 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 { override onAdd(): void {
if (this.state.scope === 'onHit') return;
const { stacking, tag } = this.state; const { stacking, tag } = this.state;
if (tag != null && stacking !== 'stack') { if (tag != null && stacking !== 'stack') {
@ -95,7 +93,6 @@ export class Effect extends Component<{
} }
update(dt: number, ctx: EvalContext): void { update(dt: number, ctx: EvalContext): void {
if (this.state.scope === 'onHit') return;
if (this.state.remaining != null) { if (this.state.remaining != null) {
this.state.remaining -= dt; this.state.remaining -= dt;
if (this.state.remaining <= 0) { if (this.state.remaining <= 0) {
@ -110,3 +107,7 @@ export class Effect extends Component<{
// permanent effect (no duration, no condition): nothing to do // permanent effect (no duration, no condition): nothing to do
} }
} }
@component
export class EffectTemplate extends BaseEffect {
}

View File

@ -1,6 +1,6 @@
import { Component } from "../core/world"; import { Component } from "../core/world";
import type { RPGVariables } from "../types"; import type { RPGVariables } from "../types";
import { action, component } from "../utils/decorators"; import { action, component, ComponentTag, getComponentTags } from "../utils/decorators";
import { Effect } from "./effect"; import { Effect } from "./effect";
// ── Equippable ──────────────────────────────────────────────────────────────── // ── Equippable ────────────────────────────────────────────────────────────────
@ -114,7 +114,7 @@ export class Equipment extends Component<EquipmentState> {
let id = 0; let id = 0;
for (const [key, component] of itemEntity) { for (const [key, component] of itemEntity) {
if (!(component instanceof Effect) || component.state.scope === 'onHit') continue; if (!getComponentTags(component).has(ComponentTag.Equippable)) continue;
const effectKey = `__equip_${slotName}_${key}_${id++}`; const effectKey = `__equip_${slotName}_${key}_${id++}`;
this.entity.clone(effectKey, component); this.entity.clone(effectKey, component);

View File

@ -1,5 +1,5 @@
import { Attacked, Damage, Defense } from "../components/combat"; import { Attacked, Damage, Defense } from "../components/combat";
import { Effect } from "../components/effect"; import { Effect, EffectTemplate } from "../components/effect";
import { Health } from "../components/stat"; import { Health } from "../components/stat";
import { System, World } from "../core/world"; import { System, World } from "../core/world";
@ -51,20 +51,21 @@ export class CombatSystem extends System {
// Apply on-hit effects from source onto target // Apply on-hit effects from source onto target
for (const [key, component] of source) { for (const [key, component] of source) {
if (!(component instanceof Effect) || component.state.scope !== 'onHit') continue; if (component instanceof EffectTemplate) {
const s = component.state; const s = component.state;
target.add( target.add(
`__hit_${source.id}_${key}_${hitEffectCounter++}`, `__hit_${source.id}_${key}_${hitEffectCounter++}`,
new Effect({ new Effect({
targetStat: s.targetStat, targetStat: s.targetStat,
delta: s.delta, delta: s.delta,
targetField: s.targetField, targetField: s.targetField,
duration: s.duration ?? undefined, duration: s.duration ?? undefined,
condition: s.condition ?? undefined, condition: s.condition ?? undefined,
stacking: s.stacking, stacking: s.stacking,
tag: s.tag ?? undefined, tag: s.tag ?? undefined,
}), }),
); );
}
} }
damageSum += damageAmount; damageSum += damageAmount;

View File

@ -61,22 +61,44 @@ interface MigrationEntry {
fn: MigrationFn; fn: MigrationFn;
} }
export enum ComponentTag {
/** Equipment system clones component with this tag onto the owner when equipped. */
Equippable,
}
const registry = new Map<string, ComponentMeta<any>>(); const registry = new Map<string, ComponentMeta<any>>();
const reverseRegistry = new Map<ComponentConstructor<any>, string>(); const nameRegistry = new Map<ComponentConstructor<any>, string>();
const tagsRegistry = new Map<ComponentConstructor<any>, Set<ComponentTag>>();
/** migrations[name][fromVersion] → { toVersion, fn } */ /** migrations[name][fromVersion] → { toVersion, fn } */
const migrations = new Map<string, Map<number, MigrationEntry>>(); const migrations = new Map<string, Map<number, MigrationEntry>>();
function register<T>(name: string, ctor: ComponentConstructor<T>, version: number): void { function register<T>(name: string, ctor: ComponentConstructor<T>, version: number): void {
registry.set(name, { ctor, version }); registry.set(name, { ctor, version });
reverseRegistry.set(ctor, name); nameRegistry.set(ctor, name);
}
function registerTags<T>(ctor: ComponentConstructor<T>, tags: Iterable<ComponentTag>): void {
const set = tagsRegistry.get(ctor) ?? new Set();
for (const tag of tags) {
set.add(tag);
}
tagsRegistry.set(ctor, set);
} }
export function getComponentMeta<T>(name: string): ComponentMeta<T> | undefined { export function getComponentMeta<T>(name: string): ComponentMeta<T> | undefined {
return registry.get(name); return registry.get(name);
} }
export function getComponentName(ctor: Function): string | undefined { export function getComponentName(ctor: Function | Component<any>): string | undefined {
return reverseRegistry.get(ctor as ComponentConstructor<any>); return nameRegistry.get(
(typeof ctor === 'function' ? ctor : ctor.constructor) as ComponentConstructor<any>
);
}
export function getComponentTags(ctor: Function | Component<any>): Set<ComponentTag> {
return new Set(tagsRegistry.get(
(typeof ctor === 'function' ? ctor : ctor.constructor) as ComponentConstructor<any>
) ?? []);
} }
/** /**
@ -152,3 +174,9 @@ export function component<T>(
// Used as bare @component // Used as bare @component
register(String(ctx!.name), nameOrTargetOrOptions, 0); register(String(ctx!.name), nameOrTargetOrOptions, 0);
} }
export function tag<T>(...tags: ComponentTag[]): ComponentDecorator<T> {
return (target: ComponentConstructor<T>) => {
registerTags(target, tags);
}
}

View File

@ -1,10 +1,10 @@
import { describe, it, expect } from 'bun:test'; import { Attacked, Damage, Defense } from '@common/rpg/components/combat';
import { World } from '@common/rpg/core/world'; import { Effect, EffectTemplate } from '@common/rpg/components/effect';
import { Health, Stat } from '@common/rpg/components/stat'; import { Health, Stat } from '@common/rpg/components/stat';
import { Damage, Defense, Attacked } from '@common/rpg/components/combat'; import { World } from '@common/rpg/core/world';
import { Effect } from '@common/rpg/components/effect';
import { CombatSystem } from '@common/rpg/systems/combat'; import { CombatSystem } from '@common/rpg/systems/combat';
import { EffectSystem } from '@common/rpg/systems/effect'; import { EffectSystem } from '@common/rpg/systems/effect';
import { describe, expect, it } from 'bun:test';
function world() { function world() {
const w = new World(); const w = new World();
@ -147,7 +147,7 @@ describe('CombatSystem — on-hit effects', () => {
const w = world(); const w = world();
const sword = w.createEntity('sword'); const sword = w.createEntity('sword');
sword.add('dmg', new Damage({ value: 10, damageType: 'physical' })); sword.add('dmg', new Damage({ value: 10, damageType: 'physical' }));
sword.add('burn', new Effect({ targetStat: 'health', delta: -5, targetField: 'value', duration: 10, scope: 'onHit' })); sword.add('burn', new EffectTemplate({ targetStat: 'health', delta: -5, targetField: 'value', duration: 10 }));
w.createEntity('attacker'); w.createEntity('attacker');
const target = w.createEntity('target'); const target = w.createEntity('target');
target.add('health', new Health({ value: 100, min: 0 })); target.add('health', new Health({ value: 100, min: 0 }));
@ -162,7 +162,7 @@ describe('CombatSystem — on-hit effects', () => {
const sword = w.createEntity('sword'); const sword = w.createEntity('sword');
sword.add('dmg', new Damage({ value: 10, damageType: 'physical' })); sword.add('dmg', new Damage({ value: 10, damageType: 'physical' }));
sword.add('str', new Stat({ value: 50 })); sword.add('str', new Stat({ value: 50 }));
sword.add('drain', new Effect({ targetStat: 'str', delta: -99, targetField: 'value', scope: 'onHit' })); sword.add('drain', new EffectTemplate({ targetStat: 'str', delta: -99, targetField: 'value' }));
expect(sword.get(Stat, 'str')!.value).toBe(50); expect(sword.get(Stat, 'str')!.value).toBe(50);
}); });
@ -170,7 +170,7 @@ describe('CombatSystem — on-hit effects', () => {
const w = world(); const w = world();
const sword = w.createEntity('sword'); const sword = w.createEntity('sword');
sword.add('dmg', new Damage({ value: 5, damageType: 'physical' })); sword.add('dmg', new Damage({ value: 5, damageType: 'physical' }));
sword.add('burn', new Effect({ targetStat: 'health', delta: -10, targetField: 'value', duration: 2, scope: 'onHit' })); sword.add('burn', new EffectTemplate({ targetStat: 'health', delta: -10, targetField: 'value', duration: 2 }));
w.createEntity('attacker'); w.createEntity('attacker');
const target = w.createEntity('target'); const target = w.createEntity('target');
target.add('health', new Health({ value: 100, min: 0 })); target.add('health', new Health({ value: 100, min: 0 }));
@ -239,7 +239,7 @@ describe('Effect stacking', () => {
const w = world(); const w = world();
const sword = w.createEntity('sword'); const sword = w.createEntity('sword');
sword.add('dmg', new Damage({ value: 0, damageType: 'physical' })); sword.add('dmg', new Damage({ value: 0, damageType: 'physical' }));
sword.add('burn', new Effect({ targetStat: 'health', delta: -5, duration: 10, scope: 'onHit', stacking: 'unique', tag: 'burn' })); sword.add('burn', new EffectTemplate({ targetStat: 'health', delta: -5, duration: 10, stacking: 'unique', tag: 'burn' }));
w.createEntity('attacker'); w.createEntity('attacker');
const target = w.createEntity('target'); const target = w.createEntity('target');
target.add('health', new Health({ value: 100, min: 0 })); target.add('health', new Health({ value: 100, min: 0 }));

View File

@ -1,7 +1,7 @@
import { describe, it, expect } from 'bun:test'; import { describe, it, expect } from 'bun:test';
import { World } from '@common/rpg/core/world'; import { World } from '@common/rpg/core/world';
import { Stat } from '@common/rpg/components/stat'; import { Stat } from '@common/rpg/components/stat';
import { Effect } from '@common/rpg/components/effect'; import { Effect, EffectTemplate } from '@common/rpg/components/effect';
import { EffectSystem } from '@common/rpg/systems/effect'; import { EffectSystem } from '@common/rpg/systems/effect';
function world() { function world() {
@ -117,28 +117,22 @@ describe('Effect — permanent', () => {
describe('Effect — scope: onHit', () => { describe('Effect — scope: onHit', () => {
it('onAdd is a no-op for onHit scope', () => { it('onAdd is a no-op for onHit scope', () => {
const { e, stat } = withStat(10); const { e, stat } = withStat(10);
e.add('fx', new Effect({ targetStat: 'str', delta: 99, scope: 'onHit' })); e.add('fx', new EffectTemplate({ targetStat: 'str', delta: 99 }));
expect(stat.value).toBe(10); expect(stat.value).toBe(10);
}); });
it('onRemove is a no-op for onHit scope', () => { it('onRemove is a no-op for onHit scope', () => {
const { e, stat } = withStat(10); const { e, stat } = withStat(10);
e.add('fx', new Effect({ targetStat: 'str', delta: 99, scope: 'onHit' })); e.add('fx', new EffectTemplate({ targetStat: 'str', delta: 99 }));
e.remove('fx'); e.remove('fx');
expect(stat.value).toBe(10); expect(stat.value).toBe(10);
}); });
it('EffectSystem does not tick or remove onHit effects', () => { it('EffectSystem does not tick or remove onHit effects', () => {
const { w, e } = withStat(10); const { w, e } = withStat(10);
e.add('fx', new Effect({ targetStat: 'str', delta: 5, duration: 1, scope: 'onHit' })); e.add('fx', new EffectTemplate({ targetStat: 'str', delta: 5, duration: 1 }));
w.update(10); w.update(10);
expect(e.has(Effect)).toBeTrue(); expect(e.has(EffectTemplate)).toBeTrue();
});
it('active stays false for onHit scope', () => {
const { e } = withStat(10);
e.add('fx', new Effect({ targetStat: 'str', delta: 5, scope: 'onHit' }));
expect(e.get(Effect, 'fx')!.active).toBeFalse();
}); });
}); });

View File

@ -1,7 +1,7 @@
import { describe, it, expect } from 'bun:test'; import { describe, it, expect } from 'bun:test';
import { World } from '@common/rpg/core/world'; import { World } from '@common/rpg/core/world';
import { Stat } from '@common/rpg/components/stat'; import { Stat } from '@common/rpg/components/stat';
import { Effect } from '@common/rpg/components/effect'; import { Effect, EffectTemplate } from '@common/rpg/components/effect';
import { Equipment, Equippable } from '@common/rpg/components/equipment'; import { Equipment, Equippable } from '@common/rpg/components/equipment';
function world() { return new World(); } function world() { return new World(); }
@ -100,7 +100,7 @@ describe('Equipment — equip-scope effects', () => {
it('does NOT clone onHit-scope Effect onto owner on equip', () => { it('does NOT clone onHit-scope Effect onto owner on equip', () => {
const w = world(); const w = world();
const sword = makeSword(w); const sword = makeSword(w);
sword.add('burn', new Effect({ targetStat: 'str', delta: 99, scope: 'onHit' })); sword.add('burn', new EffectTemplate({ targetStat: 'str', delta: 99 }));
const player = makePlayer(w); const player = makePlayer(w);
player.get(Equipment)!.equip({ slotName: 'weapon', itemId: 'sword' }); player.get(Equipment)!.equip({ slotName: 'weapon', itemId: 'sword' });
expect(player.get(Stat, 'str')!.value).toBe(10); // unaffected expect(player.get(Stat, 'str')!.value).toBe(10); // unaffected
@ -121,7 +121,7 @@ describe('Equipment — equip-scope effects', () => {
const w = world(); const w = world();
const sword = makeSword(w); const sword = makeSword(w);
sword.add('passive', new Effect({ targetStat: 'str', delta: 5 })); sword.add('passive', new Effect({ targetStat: 'str', delta: 5 }));
sword.add('burn', new Effect({ targetStat: 'str', delta: 99, scope: 'onHit' })); sword.add('burn', new EffectTemplate({ targetStat: 'str', delta: 99 }));
const player = makePlayer(w); const player = makePlayer(w);
player.get(Equipment)!.equip({ slotName: 'weapon', itemId: 'sword' }); player.get(Equipment)!.equip({ slotName: 'weapon', itemId: 'sword' });
expect(player.get(Stat, 'str')!.value).toBe(15); expect(player.get(Stat, 'str')!.value).toBe(15);