Component tags
This commit is contained in:
parent
6568e1e8d9
commit
a340c50f57
|
|
@ -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 {
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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,7 +51,7 @@ 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++}`,
|
||||||
|
|
@ -66,6 +66,7 @@ export class CombatSystem extends System {
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
damageSum += damageAmount;
|
damageSum += damageAmount;
|
||||||
hitEvents.push({ attackerId, sourceId, amount: damageAmount, damageType });
|
hitEvents.push({ attackerId, sourceId, amount: damageAmount, damageType });
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 }));
|
||||||
|
|
|
||||||
|
|
@ -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();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue