diff --git a/src/common/display/text.ts b/src/common/display/text.ts index 31f2184..2cfabae 100644 --- a/src/common/display/text.ts +++ b/src/common/display/text.ts @@ -3,13 +3,8 @@ import { randInt } from "@common/utils"; import { createCanvas } from './canvas'; export type IColorLike = string | number | Color; -export type IChar = [string, IColorLike?, IColorLike?]; -export type IRegion = IChar[][]; - -export interface ISpriteDefinition { - frames: IRegion[]; - animationPeriod?: number; -} +export type IChar = [string, IColorLike?, IColorLike?] | string; +export type IDefinedChar = [string, IColorLike, IColorLike]; export const randChar = (min = ' ', max = '~') => String.fromCharCode(randInt( @@ -56,6 +51,9 @@ export enum Color { WHITE = 0b1111, } +export const DEFAULT_FG = Color.WHITE; +export const DEFAULT_BG = Color.BLACK; + export enum Direction { NORTH = 0, EAST = 1, @@ -86,10 +84,21 @@ const CHAR_W = 8; const CHAR_H = 16; const NATIVE_FONT = `${CHAR_H}px "IBM VGA 8x16"`; const FALLBACK_FONT = `${CHAR_H}px monospace`; +const REGION_DATA = Symbol('TextRegion.data'); const colorToCSS = (c: IColorLike): string => typeof c === 'number' ? COLORS[c] : c as string; +const parseChar = (char: IChar): IDefinedChar => ( + typeof char === 'string' + ? [char, DEFAULT_FG, DEFAULT_BG] + : [ + char[0], + char[1] ?? DEFAULT_FG, + char[2] ?? DEFAULT_BG, + ] +); + export class TextDisplay { private chars: string[]; private fgs: IColorLike[]; @@ -103,7 +112,7 @@ export class TextDisplay { public width = GAME_WIDTH, public height = GAME_HEIGHT, parent?: HTMLCanvasElement, - letterboxColor: IColorLike = Color.BLACK, + letterboxColor: IColorLike = DEFAULT_BG, ) { this.letterboxColor = colorToCSS(letterboxColor); const canvas = parent ?? createCanvas(width * CHAR_W, height * CHAR_H); @@ -115,9 +124,9 @@ export class TextDisplay { this.ctx.textBaseline = 'top'; const size = width * height; - this.chars = Array.from({ length: size }, () => randChar('!')); - this.fgs = new Array(size).fill(COLORS[7]); - this.bgs = new Array(size).fill(COLORS[0]); + this.chars = new Array(size).fill(' '); + this.fgs = new Array(size).fill(DEFAULT_FG); + this.bgs = new Array(size).fill(DEFAULT_BG); window.addEventListener('resize', () => this.updateScale()); this.updateScale(); @@ -173,47 +182,97 @@ export class TextDisplay { private setCharRaw(x: number, y: number, char: string, fg: IColorLike, bg: IColorLike) { if (x < 0 || y < 0 || y >= this.height || x >= this.width || !char) return; const i = (y | 0) * this.width + (x | 0); - if (this.chars[i] === char && this.fgs[i] === fg && this.bgs[i] === bg) return; - this.chars[i] = char; - this.fgs[i] = fg; - this.bgs[i] = bg; - this.drawCell(x | 0, y | 0); + let dirty = false; + if (this.chars[i] !== char) { + this.chars[i] = char; + dirty = true; + } + if (this.fgs[i] !== fg) { + this.fgs[i] = fg; + dirty = true; + } + if (this.bgs[i] !== bg) { + this.bgs[i] = bg; + dirty = true; + } + if (dirty) { + this.drawCell(x | 0, y | 0); + } } - setChar(x: number, y: number, [char, fg = 'white', bg = 'black']: IChar = ['█']) { - this.setCharRaw(x, y, char, fg, bg); + setChar(x: number, y: number, char: IChar = '█') { + this.setCharRaw(x | 0, y | 0, ...parseChar(char)); } - getChar(x: number, y: number): IChar { + getChar(x: number, y: number): IDefinedChar { + x = x | 0; y = y | 0; if (x < 0 || y < 0 || y >= this.height || x >= this.width) { - return [' ', COLORS[0], COLORS[1]]; + return [' ', DEFAULT_FG, DEFAULT_BG]; } - const i = (y | 0) * this.width + (x | 0); - return [this.chars[i], this.fgs[i], this.bgs[i]]; + return [this.chars[y * this.width + x], this.fgs[y * this.width + x], this.bgs[y * this.width + x]]; } - setRegion(x: number, y: number, w: number, h: number, region: IRegion) { - for (let row = 0; row < h; row++) { - for (let col = 0; col < w; col++) { - const [char, fg = 'white', bg = 'black'] = region[row][col]; - this.setCharRaw(x + col, y + row, char, fg, bg); + setRegion(x: number, y: number, region: TextRegion) { + x = x | 0; + y = y | 0; + const { chars, fgs, bgs } = region[REGION_DATA]; + const rw = region.width; + const x0 = Math.max(0, x); + const y0 = Math.max(0, y); + const x1 = Math.min(this.width, x + rw); + const y1 = Math.min(this.height, y + region.height); + const copyW = x1 - x0; + if (copyW <= 0) return; + + const regColOffset = x0 - x; + for (let row = y0; row < y1; row++) { + const regRow = row - y; + const regBase = regRow * rw + regColOffset; + const dispBase = row * this.width + x0; + const regRowStr = chars[regRow]; + for (let i = 0; i < copyW; i++) { + this.chars[dispBase + i] = regRowStr[regColOffset + i]; + this.fgs[dispBase + i] = fgs[regBase + i]; + this.bgs[dispBase + i] = bgs[regBase + i]; + this.drawCell(x0 + i, row); } } } - getRegion(x: number, y: number, w: number, h: number) { - const region: IRegion = []; + getRegion(x: number, y: number, w: number, h: number): TextRegion { + x = x | 0; y = y | 0; w = w | 0; h = h | 0; + const rows: string[] = []; + const fgs: IColorLike[][] = []; + const bgs: IColorLike[][] = []; + for (let row = 0; row < h; row++) { - const line: IChar[] = []; + const dispRow = y + row; + let rowStr = ''; + const fgRow: IColorLike[] = []; + const bgRow: IColorLike[] = []; for (let col = 0; col < w; col++) { - line.push(this.getChar(x + col, y + row)); + const dispCol = x + col; + if (dispRow < 0 || dispRow >= this.height || dispCol < 0 || dispCol >= this.width) { + rowStr += ' '; + fgRow.push(DEFAULT_FG); + bgRow.push(DEFAULT_BG); + } else { + const i = dispRow * this.width + dispCol; + rowStr += this.chars[i]; + fgRow.push(this.fgs[i]); + bgRow.push(this.bgs[i]); + } } - region.push(line); + rows.push(rowStr); + fgs.push(fgRow); + bgs.push(bgRow); } - return region; + + return new TextRegion(rows, fgs, bgs); } - drawText(x: number, y: number, text: string, fg: IColorLike = Color.WHITE, bg: IColorLike = Color.BLACK) { + drawText(x: number, y: number, text: string, fg: IColorLike = DEFAULT_FG, bg: IColorLike = DEFAULT_BG) { + x = x | 0; y = y | 0; const lines = text.split('\n'); for (let row = 0; row < lines.length; row++) { const line = lines[row]; @@ -225,27 +284,35 @@ export class TextDisplay { } } - drawVLine(x: number, y1: number, y2: number, [char, fg = 'white', bg = 'black']: IChar = ['│']) { + drawVLine(x: number, y1: number, y2: number, char: IChar = '│') { + x = x | 0; y1 = y1 | 0; y2 = y2 | 0; if (y2 < y1) { const t = y2; y2 = y1; y1 = t; } + y1 = Math.max(0, y1); y2 = Math.min(this.height - 1, y2); if (x < 0 || x >= this.width) return; + + const ch = parseChar(char); for (let y = y1; y <= y2; y++) { - this.setCharRaw(x, y, char, fg, bg); + this.setCharRaw(x, y, ...ch); } } - drawHLine(x1: number, x2: number, y: number, [char, fg = 'white', bg = 'black']: IChar = ['─']) { + drawHLine(x1: number, x2: number, y: number, char: IChar = '─') { + x1 = x1 | 0; x2 = x2 | 0; y = y | 0; if (x2 < x1) { const t = x2; x2 = x1; x1 = t; } x1 = Math.max(0, x1); x2 = Math.min(this.width - 1, x2); if (y < 0 || y >= this.height) return; + + const ch = parseChar(char); for (let x = x1; x <= x2; x++) { - this.setCharRaw(x, y, char, fg, bg); + this.setCharRaw(x, y, ...ch); } } drawBox(x: number, y: number, width: number, height: number, options: IBoxOptions = {}) { + x = x | 0; y = y | 0; const { vertical = '│', horizontal = '─', @@ -253,8 +320,8 @@ export class TextDisplay { topRight = '┐', bottomLeft = '└', bottomRight = '┘', - fg = Color.WHITE, - bg = Color.BLACK, + fg = DEFAULT_FG, + bg = DEFAULT_BG, fill, title, } = options; @@ -280,9 +347,10 @@ export class TextDisplay { } drawTextInBox(x: number, y: number, text: string, options: IBoxOptions = {}) { + x = x | 0; y = y | 0; const { - fg = Color.WHITE, - bg = Color.BLACK, + fg = DEFAULT_FG, + bg = DEFAULT_BG, } = options; let width = 0; const lines = text.split('\n'); @@ -296,10 +364,75 @@ export class TextDisplay { this.drawText(x + 1, y + 1, text, fg, bg); } - fillBox(x: number, y: number, width: number, height: number, char: IChar = ['█']) { + fillBox(x: number, y: number, width: number, height: number, char: IChar = '█') { + x = x | 0; y = y | 0; for (let i = y; i < y + height; i++) { this.drawHLine(x, x + width - 1, i, char); } } } +export class TextRegion { + readonly #chars: string[]; // one string per row, all same length + readonly #fgs: IColorLike[]; + readonly #bgs: IColorLike[]; + + get width(): number { return this.#chars[0]?.length ?? 0; } + get height(): number { return this.#chars.length; } + + constructor(chars: IChar[][]); + constructor(chars: string, fg?: IColorLike | IColorLike[], bg?: IColorLike | IColorLike[]); + constructor(chars: string[], fg?: IColorLike | IColorLike[][], bg?: IColorLike | IColorLike[][]); + + constructor( + chars: IChar[][] | string | string[], + fg?: IColorLike | IColorLike[] | IColorLike[][], + bg?: IColorLike | IColorLike[] | IColorLike[][], + ) { + if (typeof chars === 'string') { + chars = chars.split('\n'); + } + if (chars.length === 0 || typeof chars[0] === 'string') { + const rows = chars as string[]; + const w = rows.reduce((m, r) => Math.max(m, r.length), 0); + const h = rows.length; + this.#chars = rows.map(r => r.padEnd(w, ' ')); + this.#fgs = Array(w * h).fill(DEFAULT_FG); + this.#bgs = Array(w * h).fill(DEFAULT_BG); + if (fg != null && !Array.isArray(fg)) this.#fgs.fill(fg); + if (bg != null && !Array.isArray(bg)) this.#bgs.fill(bg); + if (Array.isArray(fg) || Array.isArray(bg)) { + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + const i = y * w + x; + if (Array.isArray(fg)) this.#fgs[i] = (fg as IColorLike[][])[y]?.[x] ?? DEFAULT_FG; + if (Array.isArray(bg)) this.#bgs[i] = (bg as IColorLike[][])[y]?.[x] ?? DEFAULT_BG; + } + } + } + } else { + const ichars = chars as IChar[][]; + const h = ichars.length; + const w = ichars.reduce((m, r) => Math.max(m, r.length), 0); + this.#chars = ichars.map(row => row.map(ch => ch[0]).join('').padEnd(w, ' ')); + this.#fgs = Array(w * h).fill(DEFAULT_FG); + this.#bgs = Array(w * h).fill(DEFAULT_BG); + for (let y = 0; y < h; y++) { + for (let x = 0; x < ichars[y].length; x++) { + const ch = ichars[y][x]; + const i = y * w + x; + if (ch[1] != null) this.#fgs[i] = ch[1]; + if (ch[2] != null) this.#bgs[i] = ch[2]; + } + } + } + } + + get [REGION_DATA]() { + return { + chars: this.#chars, + fgs: this.#fgs, + bgs: this.#bgs, + }; + } +} \ No newline at end of file diff --git a/src/common/rpg/TODO.md b/src/common/rpg/TODO.md index 4327173..6f4bede 100644 --- a/src/common/rpg/TODO.md +++ b/src/common/rpg/TODO.md @@ -1,6 +1,6 @@ # RPG Engine — Remaining Work -## Deferred Combat Features +## Components ### Damage modifiers Stat-scaling, armor penetration, multipliers — a `DamageModifier` component on @@ -8,3 +8,15 @@ the attacker/source that CombatSystem folds into the final damage value. ### Crit / variance RNG layer on top of damage calculation (crit chance, crit multiplier, random range). + +### Rendering +- Sprite rendering system. + - Generic, use `resourceId` to link image. + - `framesCount` + - `currentFrame` + - `animationSpeed` +- Animation support for sprites. +- Different display systems + - TextDisplay + - BrickDisplay + - Canvas diff --git a/src/common/rpg/components/effect.ts b/src/common/rpg/components/effect.ts index ff6dd2c..304bbab 100644 --- a/src/common/rpg/components/effect.ts +++ b/src/common/rpg/components/effect.ts @@ -1,10 +1,12 @@ +import type { Class } from "@common/types"; import { Component, type EvalContext } from "../core/world"; import { evaluateCondition } from "../utils/conditions"; -import { action, component, ComponentTag, tag, variable } from "../utils/decorators"; +import { action, component, ComponentTag, getComponentMeta, getComponentName, tag, variable } from "../utils/decorators"; import { Stat } from "./stat"; abstract class BaseEffect extends Component<{ - targetStat: string; // component key, e.g. 'health' + 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 @@ -14,7 +16,8 @@ abstract class BaseEffect extends Component<{ tag: string | null; // discriminator for stacking; null = no stacking enforcement }> { constructor(opts: { - targetStat: string; + target?: Class>, + targetKey?: string; delta: number; targetField?: 'value' | 'max' | 'min'; duration?: number; @@ -23,7 +26,8 @@ abstract class BaseEffect extends Component<{ tag?: string; }) { super({ - targetStat: opts.targetStat, + target: getComponentName(opts.target ?? Stat)!, + targetKey: opts.targetKey, targetField: opts.targetField ?? 'value', delta: opts.delta, duration: opts.duration ?? null, @@ -49,29 +53,35 @@ export class Effect extends BaseEffect { if (stacking === 'unique' && siblings.length > 0) { // An effect with this tag is already active — discard the incoming one. - this.entity.remove(this.key); + this.entity.remove(this); return; } if (stacking === 'replace') { for (const old of siblings) { - this.entity.remove(old.key); + this.entity.remove(old); } } } - const stat = this.entity.get(Stat, this.state.targetStat); - if (stat) { - stat.applyModifier(this.state.delta, this.state.targetField); - this.active = true; + const component = getComponentMeta(this.state.target); + if (component) { + const stat = this.entity.get(component.ctor, this.state.targetKey); + if (stat instanceof Stat) { + stat.applyModifier(this.state.delta, this.state.targetField); + this.active = true; + } } } override onRemove(): void { if (!this.active) return; - const stat = this.entity.get(Stat, this.state.targetStat); - if (stat) { - stat.removeModifier(this.state.delta, this.state.targetField); + const component = getComponentMeta(this.state.target); + if (component) { + const stat = this.entity.get(component.ctor, this.state.targetKey); + if (stat instanceof Stat) { + stat.removeModifier(this.state.delta, this.state.targetField); + } } this.active = false; } @@ -94,10 +104,12 @@ export class Effect extends BaseEffect { 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(); + if (this.state.remaining > 0) { + 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)) { diff --git a/src/common/rpg/components/equipment.ts b/src/common/rpg/components/equipment.ts index f8b0e59..5ffe963 100644 --- a/src/common/rpg/components/equipment.ts +++ b/src/common/rpg/components/equipment.ts @@ -117,7 +117,7 @@ export class Equipment extends Component { if (!getComponentTags(component).has(ComponentTag.Equippable)) continue; const effectKey = `__equip_${slotName}_${key}_${id++}`; - this.entity.clone(effectKey, component); + this.entity.clone(component, effectKey); slot.appliedEffectKeys.push(effectKey); } diff --git a/src/common/rpg/components/inventory.ts b/src/common/rpg/components/inventory.ts index 5b6730b..810c9dc 100644 --- a/src/common/rpg/components/inventory.ts +++ b/src/common/rpg/components/inventory.ts @@ -1,4 +1,4 @@ -import { Component, type EvalContext } from "../core/world"; +import { Component, Entity, type EvalContext } from "../core/world"; import type { InventorySlotInput, RPGVariables, SlotId } from "../types"; import { action, component } from "../utils/decorators"; import { resolveVariables } from "../utils/variables"; @@ -66,8 +66,9 @@ export class Inventory extends Component { } @action - add({ itemId, amount, slotId }: { itemId: string; amount: number; slotId?: SlotId }): boolean { + add(arg: { itemId: string; amount: number; slotId?: SlotId } | Entity): boolean { this.#cachedVars = null; + const { itemId, amount = 1, slotId } = (arg instanceof Entity) ? { itemId: arg.id } : arg; if (amount < 0) return false; if (amount === 0) return true; diff --git a/src/common/rpg/components/sprite.ts b/src/common/rpg/components/sprite.ts new file mode 100644 index 0000000..0694631 --- /dev/null +++ b/src/common/rpg/components/sprite.ts @@ -0,0 +1,28 @@ +import { Component } from "../core/world"; +import { SpriteSystem } from "../systems/render/sprite"; +import { component } from "../utils/decorators"; + +@component +export class Sprite extends Component<{ + frames: string[], + currentFrame: number, + animationDelay: number, + animationCounter: number, +}> { + constructor(frame: string); + constructor(frames: string[], animationDelay?: number); + constructor(frames: string | string[], animationDelay: number = Infinity) { + super({ + frames: Array.isArray(frames) ? frames : [frames], + animationDelay, + animationCounter: 0, + currentFrame: 0, + }); + } + + override onAdd(): void { + if (Number.isFinite(this.state.animationDelay) && !this.world.hasSystem(SpriteSystem)) { + this.world.addSystem(new SpriteSystem()); + } + } +} \ No newline at end of file diff --git a/src/common/rpg/core/serialization.ts b/src/common/rpg/core/serialization.ts index 15db427..53162ab 100644 --- a/src/common/rpg/core/serialization.ts +++ b/src/common/rpg/core/serialization.ts @@ -1,4 +1,4 @@ -import { World, Entity, Component, WORLD_ENTITY_COUNTER } from './world'; +import { World, Entity, Component, WORLD_ENTITY_COUNTER, COMPONENT_KEY } from './world'; import { getComponentMeta, getComponentName, migrateState } from '../utils/decorators'; /** Increment this when the WorldData/EntityData structure itself changes incompatibly. */ @@ -7,7 +7,7 @@ const SCHEMA_VERSION = 1; interface ComponentData { type: 'component'; name: string; - key: string; + key: string | null; // null = was symbol-keyed (anonymous) version: number; state: unknown; } @@ -37,7 +37,8 @@ function serializeComponent(component: Component): ComponentData { ); } const meta = getComponentMeta(name)!; - return { type: 'component', name, key: component.key, version: meta.version, state: component.state }; + const key = typeof component[COMPONENT_KEY] === 'symbol' ? null : component.key; + return { type: 'component', name, key, version: meta.version, state: component.state }; } function serializeEntity(entity: Entity): EntityData { @@ -84,7 +85,12 @@ function deserializeComponent(data: ComponentData): Component { function deserializeEntity(data: EntityData, world: World): Entity { const entity = world.createEntity(data.id); for (const componentData of data.components) { - entity.add(componentData.key, deserializeComponent(componentData)); + const component = deserializeComponent(componentData); + if (componentData.key === null) { + entity.add(component); + } else { + entity.add(component, componentData.key); + } } return entity; } diff --git a/src/common/rpg/core/world.ts b/src/common/rpg/core/world.ts index 1ba7b9b..12a8a14 100644 --- a/src/common/rpg/core/world.ts +++ b/src/common/rpg/core/world.ts @@ -1,3 +1,4 @@ +import type { Class } from '@common/types'; import type { RPGActions, RPGVariables } from '../types'; import { ACTION_KEYS, getComponentName, STATE_KEYS, VARIABLE_KEYS } from '../utils/decorators'; @@ -10,7 +11,6 @@ interface WorldEvent { data?: T; } -type Class = abstract new (...args: any[]) => T; type EntityEventHandler = (event: EntityEvent) => void; type WorldEventHandler = (event: WorldEvent) => void; @@ -21,6 +21,7 @@ export interface EvalContext { /** Symbol used by Serialization to access World's entity counter. */ export const WORLD_ENTITY_COUNTER = Symbol('rpg.world.entityCounter'); +export const COMPONENT_KEY = Symbol('rpg.component.key'); export abstract class Component> { entity!: Entity; @@ -40,8 +41,11 @@ export abstract class Component> { ? getComponentName(this.constructor) ?? this.constructor.name : this._key; } + get [COMPONENT_KEY](): string | symbol { return this._key; } set key(key: string | symbol) { this._key = key; } + get world(): World { return this.entity.world; } + protected emit(event: string, data?: unknown): void { const componentKey = this.key; const componentName = getComponentName(this.constructor); @@ -118,11 +122,8 @@ export class Entity { return { self: this, world: this.world }; } - add>(component: T): T; - add>(key: string, component: T): T; - add>(keyOrComponent: string | T, comp?: T): T { - const key = typeof keyOrComponent === 'string' ? keyOrComponent : Symbol(); - const component = (keyOrComponent instanceof Component) ? keyOrComponent : comp; + add>(component: T, k?: string): T { + const key = k ?? Symbol(); if (component == null) { throw new Error(`Component must be an instance of Component`); @@ -136,15 +137,14 @@ export class Entity { return component; } - clone>(key: string, component: T): T { + clone>(component: T, key: string): T { const clone = Object.create(component.constructor.prototype) as T; (clone as unknown as { state: unknown }).state = structuredClone(component.state); - return this.add(key, clone); + return this.add(clone, key); } get>(key: string): T | undefined; - get>(ctor: Class): T | undefined; - get>(ctor: Class, key: string): T | undefined; + get>(ctor: Class, key?: string): T | undefined; get>(ctor: Class, filter: ComponentFilter): T | undefined; get>(ctorOrKey: Class | string, key?: string | ComponentFilter): T | undefined { if (typeof ctorOrKey === 'string') { @@ -154,6 +154,15 @@ export class Entity { const c = this.#components.get(key); return c instanceof ctorOrKey ? c as T : undefined; } + if (!key) { + for (const [k, c] of this.#components) { + // prefer registered without key + if (typeof k !== 'symbol') continue; + if (c instanceof ctorOrKey) { + return c as T; + } + } + } for (const c of this.#components.values()) { if (!(c instanceof ctorOrKey)) continue; if (typeof key === 'function' && !key(c)) continue; @@ -202,7 +211,7 @@ export class Entity { return; } if (ctorOrKey instanceof Component) { - this.#removeByKey(ctorOrKey.key); + this.#removeByKey(ctorOrKey[COMPONENT_KEY]); return; } if (typeof key === 'string') { @@ -330,7 +339,7 @@ export class World { for (const [key, component] of source) { const clone = Object.create(component.constructor.prototype) as Component; (clone as unknown as { state: unknown }).state = structuredClone(component.state); - target.add(key, clone); + target.add(clone, key); } return target; } @@ -368,6 +377,10 @@ export class World { } } + hasSystem(ctor: Class): boolean { + return this.#systems.some(system => system instanceof ctor); + } + update(dt: number) { for (const system of this.#systems) { system.update(this, dt); diff --git a/src/common/rpg/systems/combat.ts b/src/common/rpg/systems/combat.ts index 80c5d1d..91c275c 100644 --- a/src/common/rpg/systems/combat.ts +++ b/src/common/rpg/systems/combat.ts @@ -54,9 +54,8 @@ export class CombatSystem extends System { if (component instanceof EffectTemplate) { const s = component.state; target.add( - `__hit_${source.id}_${key}_${hitEffectCounter++}`, new Effect({ - targetStat: s.targetStat, + targetKey: s.targetKey, delta: s.delta, targetField: s.targetField, duration: s.duration ?? undefined, @@ -64,6 +63,7 @@ export class CombatSystem extends System { stacking: s.stacking, tag: s.tag ?? undefined, }), + `__hit_${source.id}_${key}_${hitEffectCounter++}`, ); } } diff --git a/src/common/rpg/systems/effect.ts b/src/common/rpg/systems/effect.ts index 411ffd0..8f767e9 100644 --- a/src/common/rpg/systems/effect.ts +++ b/src/common/rpg/systems/effect.ts @@ -1,14 +1,14 @@ import { Effect } from "../components/effect"; -import { System, type Entity, type World } from "../core/world"; +import { Component, System, type Entity, type World } from "../core/world"; export class EffectSystem extends System { override update(world: World, dt: number) { - const expired: [Entity, string][] = []; + const expired: [Entity, Component][] = []; - for (const [entity, key, effect] of world.query(Effect)) { + for (const [entity, , effect] of world.query(Effect)) { effect.update(dt, entity.context); if (effect.state.remaining !== null && effect.state.remaining <= 0) { - expired.push([entity, key]); + expired.push([entity, effect]); } } diff --git a/src/common/rpg/systems/render/sprite.ts b/src/common/rpg/systems/render/sprite.ts new file mode 100644 index 0000000..ca87cf3 --- /dev/null +++ b/src/common/rpg/systems/render/sprite.ts @@ -0,0 +1,15 @@ +import { Sprite } from "@common/rpg/components/sprite"; +import { System, World } from "@common/rpg/core/world"; + +export class SpriteSystem extends System { + override update(world: World, dt: number) { + for (const [, , sprite] of world.query(Sprite)) { + sprite.state.animationCounter += dt; + if (sprite.state.animationCounter >= sprite.state.animationDelay) { + const frameDiff = Math.floor(sprite.state.animationCounter / sprite.state.animationDelay); + sprite.state.animationCounter = sprite.state.animationCounter % sprite.state.animationDelay; + sprite.state.currentFrame = (sprite.state.currentFrame + frameDiff) % sprite.state.frames.length; + } + } + } +} diff --git a/src/common/rpg/systems/render/text.ts b/src/common/rpg/systems/render/text.ts new file mode 100644 index 0000000..d9d2662 --- /dev/null +++ b/src/common/rpg/systems/render/text.ts @@ -0,0 +1,24 @@ +import { TextRegion, TextDisplay } from "@common/display/text"; +import { Position } from "@common/rpg/components/position"; +import { Sprite } from "@common/rpg/components/sprite"; +import { System, World } from "@common/rpg/core/world"; +import { Resources } from "@common/rpg/utils/resources"; + +export class TextDisplaySystem extends System { + private readonly display: TextDisplay; + + constructor(display?: TextDisplay) { + super(); + this.display = display ?? new TextDisplay(); + } + + override update(world: World) { + for (const [, sprite, pos] of world.query(Sprite, Position)) { + const { frames, currentFrame } = sprite.state; + const { x, y } = pos.state; + + const data = Resources.get(TextRegion, frames[currentFrame]) ?? new TextRegion(frames[currentFrame]); + this.display.setRegion(x, y, data); + } + } +} \ No newline at end of file diff --git a/src/common/rpg/utils/decorators.ts b/src/common/rpg/utils/decorators.ts index 198b5fd..9dee4d8 100644 --- a/src/common/rpg/utils/decorators.ts +++ b/src/common/rpg/utils/decorators.ts @@ -85,7 +85,9 @@ function registerTags(ctor: ComponentConstructor, tags: Iterable(name: string): ComponentMeta | undefined { +export function getComponentMeta(component: string | Function | Component): ComponentMeta | undefined { + const name = typeof component === 'string' ? component : getComponentName(component); + if (!name) return undefined; return registry.get(name); } diff --git a/src/common/rpg/utils/resources.ts b/src/common/rpg/utils/resources.ts new file mode 100644 index 0000000..b34cffb --- /dev/null +++ b/src/common/rpg/utils/resources.ts @@ -0,0 +1,37 @@ +import type { Class } from "@common/types"; + +export namespace Resources { + const resources = new Map>(); + let resourceId = 0; + + export function get(id: string): T | undefined; + export function get(ctor: Class, id: string): T | undefined; + export function get(ctorOrId: Class | string, id?: string): T | undefined { + const ctor = typeof ctorOrId === 'string' ? undefined : ctorOrId; + id = typeof ctorOrId === 'string' ? ctorOrId : id; + if (id == null) return undefined; + if (ctor == null) { + for (const ns of resources.values()) { + if (ns.has(id)) { + return ns.get(id) as T; + } + } + return undefined + } + + return resources.get(ctor)?.get(id) as T; + } + + export function set(id: string, value: NonNullable): void { + const ctor = value.constructor; + const namespace: Map = resources.get(ctor) ?? new Map(); + namespace.set(id, value); + resources.set(ctor, namespace); + } + + export function add(value: NonNullable): string { + const id = `__resource_${resourceId++}`; + Resources.set(id, value); + return id; + } +} \ No newline at end of file diff --git a/src/common/types.ts b/src/common/types.ts index b670602..a823513 100644 --- a/src/common/types.ts +++ b/src/common/types.ts @@ -1,2 +1,4 @@ export type Key = keyof T; -export type Value = Key> = T[K]; \ No newline at end of file +export type Value = Key> = T[K]; + +export type Class = abstract new (...args: any[]) => T; \ No newline at end of file diff --git a/test/common/rpg/combat.test.ts b/test/common/rpg/combat.test.ts index 4e0e403..c7fda6d 100644 --- a/test/common/rpg/combat.test.ts +++ b/test/common/rpg/combat.test.ts @@ -17,11 +17,11 @@ describe('CombatSystem — damage', () => { it('reduces target health by damage value', () => { const w = world(); const sword = w.createEntity('sword'); - sword.add('dmg', new Damage({ value: 20, damageType: 'physical' })); + sword.add(new Damage({ value: 20, damageType: 'physical' })); w.createEntity('attacker'); const target = w.createEntity('target'); - target.add('health', new Health({ value: 100, min: 0 })); - target.add('atk', new Attacked({ attackerId: 'attacker', sourceId: 'sword' })); + target.add(new Health({ value: 100, min: 0 })); + target.add(new Attacked({ attackerId: 'attacker', sourceId: 'sword' })); w.update(1); expect(target.get(Health)!.value).toBe(80); }); @@ -29,12 +29,12 @@ describe('CombatSystem — damage', () => { it('defense on target reduces damage', () => { const w = world(); const sword = w.createEntity('sword'); - sword.add('dmg', new Damage({ value: 20, damageType: 'physical' })); + sword.add(new Damage({ value: 20, damageType: 'physical' })); w.createEntity('attacker'); const target = w.createEntity('target'); - target.add('health', new Health({ value: 100, min: 0 })); - target.add('armor', new Defense({ value: 8, damageType: 'physical' })); - target.add('atk', new Attacked({ attackerId: 'attacker', sourceId: 'sword' })); + target.add(new Health({ value: 100, min: 0 })); + target.add(new Defense({ value: 8, damageType: 'physical' })); + target.add(new Attacked({ attackerId: 'attacker', sourceId: 'sword' })); w.update(1); expect(target.get(Health)!.value).toBe(88); }); @@ -42,12 +42,12 @@ describe('CombatSystem — damage', () => { it('defense only applies to matching damage type', () => { const w = world(); const spell = w.createEntity('spell'); - spell.add('dmg', new Damage({ value: 20, damageType: 'fire' })); + spell.add(new Damage({ value: 20, damageType: 'fire' })); w.createEntity('attacker'); const target = w.createEntity('target'); - target.add('health', new Health({ value: 100, min: 0 })); - target.add('armor', new Defense({ value: 8, damageType: 'physical' })); - target.add('atk', new Attacked({ attackerId: 'attacker', sourceId: 'spell' })); + target.add(new Health({ value: 100, min: 0 })); + target.add(new Defense({ value: 8, damageType: 'physical' })); + target.add(new Attacked({ attackerId: 'attacker', sourceId: 'spell' })); w.update(1); expect(target.get(Health)!.value).toBe(80); }); @@ -55,12 +55,12 @@ describe('CombatSystem — damage', () => { it('minDamage is enforced when defense exceeds damage', () => { const w = world(); const sword = w.createEntity('sword'); - sword.add('dmg', new Damage({ value: 5, damageType: 'physical', minDamage: 3 })); + sword.add(new Damage({ value: 5, damageType: 'physical', minDamage: 3 })); w.createEntity('attacker'); const target = w.createEntity('target'); - target.add('health', new Health({ value: 100, min: 0 })); - target.add('armor', new Defense({ value: 10, damageType: 'physical' })); - target.add('atk', new Attacked({ attackerId: 'attacker', sourceId: 'sword' })); + target.add(new Health({ value: 100, min: 0 })); + target.add(new Defense({ value: 10, damageType: 'physical' })); + target.add(new Attacked({ attackerId: 'attacker', sourceId: 'sword' })); w.update(1); expect(target.get(Health)!.value).toBe(97); }); @@ -68,10 +68,10 @@ describe('CombatSystem — damage', () => { it('null sourceId falls back to Damage on attacker', () => { const w = world(); const attacker = w.createEntity('attacker'); - attacker.add('dmg', new Damage({ value: 15, damageType: 'physical' })); + attacker.add(new Damage({ value: 15, damageType: 'physical' })); const target = w.createEntity('target'); - target.add('health', new Health({ value: 100, min: 0 })); - target.add('atk', new Attacked({ attackerId: 'attacker', sourceId: null })); + target.add(new Health({ value: 100, min: 0 })); + target.add(new Attacked({ attackerId: 'attacker', sourceId: null })); w.update(1); expect(target.get(Health)!.value).toBe(85); }); @@ -79,12 +79,12 @@ describe('CombatSystem — damage', () => { it('multiple attacks in one tick accumulate', () => { const w = world(); const sword = w.createEntity('sword'); - sword.add('dmg', new Damage({ value: 10, damageType: 'physical' })); + sword.add(new Damage({ value: 10, damageType: 'physical' })); w.createEntity('a'); const target = w.createEntity('target'); - target.add('health', new Health({ value: 100, min: 0 })); - target.add('atk1', new Attacked({ attackerId: 'a', sourceId: 'sword' })); - target.add('atk2', new Attacked({ attackerId: 'a', sourceId: 'sword' })); + target.add(new Health({ value: 100, min: 0 })); + target.add(new Attacked({ attackerId: 'a', sourceId: 'sword' })); + target.add(new Attacked({ attackerId: 'a', sourceId: 'sword' })); w.update(1); expect(target.get(Health)!.value).toBe(80); }); @@ -92,11 +92,11 @@ describe('CombatSystem — damage', () => { it('Attacked components are removed after processing', () => { const w = world(); const sword = w.createEntity('sword'); - sword.add('dmg', new Damage({ value: 5, damageType: 'physical' })); + sword.add(new Damage({ value: 5, damageType: 'physical' })); w.createEntity('a'); const target = w.createEntity('target'); - target.add('health', new Health({ value: 100, min: 0 })); - target.add('atk', new Attacked({ attackerId: 'a', sourceId: 'sword' })); + target.add(new Health({ value: 100, min: 0 })); + target.add(new Attacked({ attackerId: 'a', sourceId: 'sword' })); w.update(1); expect(target.has(Attacked)).toBeFalse(); }); @@ -104,8 +104,8 @@ describe('CombatSystem — damage', () => { it('missing attacker entity skips attack gracefully', () => { const w = world(); const target = w.createEntity('target'); - target.add('health', new Health({ value: 100, min: 0 })); - target.add('atk', new Attacked({ attackerId: 'ghost', sourceId: null })); + target.add(new Health({ value: 100, min: 0 })); + target.add(new Attacked({ attackerId: 'ghost', sourceId: null })); w.update(1); expect(target.get(Health)!.value).toBe(100); }); @@ -114,8 +114,8 @@ describe('CombatSystem — damage', () => { const w = world(); w.createEntity('attacker'); const target = w.createEntity('target'); - target.add('health', new Health({ value: 100, min: 0 })); - target.add('atk', new Attacked({ attackerId: 'attacker', sourceId: 'gone' })); + target.add(new Health({ value: 100, min: 0 })); + target.add(new Attacked({ attackerId: 'attacker', sourceId: 'gone' })); w.update(1); expect(target.get(Health)!.value).toBe(100); }); @@ -125,8 +125,8 @@ describe('CombatSystem — damage', () => { w.createEntity('attacker'); w.createEntity('empty_source'); const target = w.createEntity('target'); - target.add('health', new Health({ value: 100, min: 0 })); - target.add('atk', new Attacked({ attackerId: 'attacker', sourceId: 'empty_source' })); + target.add(new Health({ value: 100, min: 0 })); + target.add(new Attacked({ attackerId: 'attacker', sourceId: 'empty_source' })); w.update(1); expect(target.get(Health)!.value).toBe(100); }); @@ -134,10 +134,10 @@ describe('CombatSystem — damage', () => { it('target with no Health component is skipped gracefully', () => { const w = world(); const sword = w.createEntity('sword'); - sword.add('dmg', new Damage({ value: 10, damageType: 'physical' })); + sword.add(new Damage({ value: 10, damageType: 'physical' })); w.createEntity('attacker'); const target = w.createEntity('target'); - target.add('atk', new Attacked({ attackerId: 'attacker', sourceId: 'sword' })); + target.add(new Attacked({ attackerId: 'attacker', sourceId: 'sword' })); w.update(1); // should not throw }); }); @@ -146,12 +146,12 @@ describe('CombatSystem — on-hit effects', () => { it('onHit effect is applied to target on hit', () => { const w = world(); const sword = w.createEntity('sword'); - sword.add('dmg', new Damage({ value: 10, damageType: 'physical' })); - sword.add('burn', new EffectTemplate({ targetStat: 'health', delta: -5, targetField: 'value', duration: 10 })); + sword.add(new Damage({ value: 10, damageType: 'physical' })); + sword.add(new EffectTemplate({ targetKey: 'health', delta: -5, targetField: 'value', duration: 10 })); w.createEntity('attacker'); const target = w.createEntity('target'); - target.add('health', new Health({ value: 100, min: 0 })); - target.add('atk', new Attacked({ attackerId: 'attacker', sourceId: 'sword' })); + target.add(new Health({ value: 100, min: 0 }), 'health'); + target.add(new Attacked({ attackerId: 'attacker', sourceId: 'sword' })); w.update(1); expect(target.get(Health)!.value).toBe(85); // 100 - 10 dmg - 5 burn modifier expect(target.getAll(Effect).length).toBe(1); @@ -160,21 +160,21 @@ describe('CombatSystem — on-hit effects', () => { it('onHit effect on weapon does not affect weapon itself', () => { const w = world(); const sword = w.createEntity('sword'); - sword.add('dmg', new Damage({ value: 10, damageType: 'physical' })); - sword.add('str', new Stat({ value: 50 })); - sword.add('drain', new EffectTemplate({ targetStat: 'str', delta: -99, targetField: 'value' })); + sword.add(new Damage({ value: 10, damageType: 'physical' })); + sword.add(new Stat({ value: 50 }), 'str'); + sword.add(new EffectTemplate({ targetKey: 'str', delta: -99, targetField: 'value' })); expect(sword.get(Stat, 'str')!.value).toBe(50); }); it('onHit effect expires on target after duration', () => { const w = world(); const sword = w.createEntity('sword'); - sword.add('dmg', new Damage({ value: 5, damageType: 'physical' })); - sword.add('burn', new EffectTemplate({ targetStat: 'health', delta: -10, targetField: 'value', duration: 2 })); + sword.add(new Damage({ value: 5, damageType: 'physical' })); + sword.add(new EffectTemplate({ targetKey: 'health', delta: -10, targetField: 'value', duration: 2 })); w.createEntity('attacker'); const target = w.createEntity('target'); - target.add('health', new Health({ value: 100, min: 0 })); - target.add('atk', new Attacked({ attackerId: 'attacker', sourceId: 'sword' })); + target.add(new Health({ value: 100, min: 0 }), 'health'); + target.add(new Attacked({ attackerId: 'attacker', sourceId: 'sword' })); w.update(1); const afterHit = target.get(Health)!.value; // 85 w.update(2); // burn expires @@ -187,9 +187,9 @@ describe('Effect stacking', () => { it('stack: allows multiple effects with the same tag', () => { const w = world(); const target = w.createEntity('target'); - target.add('health', new Health({ value: 100, min: 0 })); - target.add('e1', new Effect({ targetStat: 'health', delta: -5, stacking: 'stack', tag: 'poison' })); - target.add('e2', new Effect({ targetStat: 'health', delta: -5, stacking: 'stack', tag: 'poison' })); + target.add(new Health({ value: 100, min: 0 }), 'health'); + target.add(new Effect({ targetKey: 'health', delta: -5, stacking: 'stack', tag: 'poison' })); + target.add(new Effect({ targetKey: 'health', delta: -5, stacking: 'stack', tag: 'poison' })); expect(target.getAll(Effect).length).toBe(2); expect(target.get(Health)!.value).toBe(90); }); @@ -197,9 +197,9 @@ describe('Effect stacking', () => { it('unique: second effect with same tag is discarded', () => { const w = world(); const target = w.createEntity('target'); - target.add('health', new Health({ value: 100, min: 0 })); - target.add('e1', new Effect({ targetStat: 'health', delta: -5, stacking: 'unique', tag: 'poison' })); - target.add('e2', new Effect({ targetStat: 'health', delta: -5, stacking: 'unique', tag: 'poison' })); + target.add(new Health({ value: 100, min: 0 }), 'health'); + target.add(new Effect({ targetKey: 'health', delta: -5, stacking: 'unique', tag: 'poison' })); + target.add(new Effect({ targetKey: 'health', delta: -5, stacking: 'unique', tag: 'poison' })); expect(target.getAll(Effect).length).toBe(1); expect(target.get(Health)!.value).toBe(95); }); @@ -207,9 +207,9 @@ describe('Effect stacking', () => { it('unique: effects with different tags both apply', () => { const w = world(); const target = w.createEntity('target'); - target.add('health', new Health({ value: 100, min: 0 })); - target.add('e1', new Effect({ targetStat: 'health', delta: -5, stacking: 'unique', tag: 'poison' })); - target.add('e2', new Effect({ targetStat: 'health', delta: -3, stacking: 'unique', tag: 'burn' })); + target.add(new Health({ value: 100, min: 0 }), 'health'); + target.add(new Effect({ targetKey: 'health', delta: -5, stacking: 'unique', tag: 'poison' })); + target.add(new Effect({ targetKey: 'health', delta: -3, stacking: 'unique', tag: 'burn' })); expect(target.getAll(Effect).length).toBe(2); expect(target.get(Health)!.value).toBe(92); }); @@ -217,10 +217,10 @@ describe('Effect stacking', () => { it('replace: removes existing effect and applies new one', () => { const w = world(); const target = w.createEntity('target'); - target.add('health', new Health({ value: 100, min: 0 })); - target.add('e1', new Effect({ targetStat: 'health', delta: -5, stacking: 'replace', tag: 'poison' })); + target.add(new Health({ value: 100, min: 0 }), 'health'); + target.add(new Effect({ targetKey: 'health', delta: -5, stacking: 'replace', tag: 'poison' })); expect(target.get(Health)!.value).toBe(95); - target.add('e2', new Effect({ targetStat: 'health', delta: -10, stacking: 'replace', tag: 'poison' })); + target.add(new Effect({ targetKey: 'health', delta: -10, stacking: 'replace', tag: 'poison' })); expect(target.getAll(Effect).length).toBe(1); expect(target.get(Health)!.value).toBe(90); // old -5 reversed, new -10 applied }); @@ -228,9 +228,9 @@ describe('Effect stacking', () => { it('no tag: unique mode does not enforce limits', () => { const w = world(); const target = w.createEntity('target'); - target.add('health', new Health({ value: 100, min: 0 })); - target.add('e1', new Effect({ targetStat: 'health', delta: -5, stacking: 'unique' })); - target.add('e2', new Effect({ targetStat: 'health', delta: -5, stacking: 'unique' })); + target.add(new Health({ value: 100, min: 0 }), 'health'); + target.add(new Effect({ targetKey: 'health', delta: -5, stacking: 'unique' })); + target.add(new Effect({ targetKey: 'health', delta: -5, stacking: 'unique' })); expect(target.getAll(Effect).length).toBe(2); expect(target.get(Health)!.value).toBe(90); }); @@ -238,13 +238,13 @@ describe('Effect stacking', () => { it('unique onHit effect is discarded when same tag already on target', () => { const w = world(); const sword = w.createEntity('sword'); - sword.add('dmg', new Damage({ value: 0, damageType: 'physical' })); - sword.add('burn', new EffectTemplate({ targetStat: 'health', delta: -5, duration: 10, stacking: 'unique', tag: 'burn' })); + sword.add(new Damage({ value: 0, damageType: 'physical' })); + sword.add(new EffectTemplate({ targetKey: 'health', delta: -5, duration: 10, stacking: 'unique', tag: 'burn' })); w.createEntity('attacker'); const target = w.createEntity('target'); - target.add('health', new Health({ value: 100, min: 0 })); - target.add('pre', new Effect({ targetStat: 'health', delta: -5, stacking: 'unique', tag: 'burn' })); - target.add('atk', new Attacked({ attackerId: 'attacker', sourceId: 'sword' })); + target.add(new Health({ value: 100, min: 0 }), 'health'); + target.add(new Effect({ targetKey: 'health', delta: -5, stacking: 'unique', tag: 'burn' })); + target.add(new Attacked({ attackerId: 'attacker', sourceId: 'sword' })); w.update(1); expect(target.getAll(Effect).length).toBe(1); // onHit copy discarded expect(target.get(Health)!.value).toBe(95); @@ -255,13 +255,13 @@ describe('CombatSystem — events', () => { it("emits 'hit' on target with attack info", () => { const w = world(); const sword = w.createEntity('sword'); - sword.add('dmg', new Damage({ value: 10, damageType: 'fire' })); + sword.add(new Damage({ value: 10, damageType: 'fire' })); w.createEntity('attacker'); const target = w.createEntity('target'); - target.add('health', new Health({ value: 100, min: 0 })); + target.add(new Health({ value: 100, min: 0 })); const hits: unknown[] = []; target.on('hit', ({ data }) => hits.push(data)); - target.add('atk', new Attacked({ attackerId: 'attacker', sourceId: 'sword' })); + target.add(new Attacked({ attackerId: 'attacker', sourceId: 'sword' })); w.update(1); expect(hits.length).toBe(1); expect((hits[0] as any).damageType).toBe('fire'); @@ -273,13 +273,13 @@ describe('CombatSystem — events', () => { it("emits 'kill' when target health reaches zero", () => { const w = world(); const sword = w.createEntity('sword'); - sword.add('dmg', new Damage({ value: 999, damageType: 'physical' })); + sword.add(new Damage({ value: 999, damageType: 'physical' })); w.createEntity('attacker'); const target = w.createEntity('target'); - target.add('health', new Health({ value: 50, min: 0 })); + target.add(new Health({ value: 50, min: 0 })); const kills: unknown[] = []; target.on('kill', ({ data }) => kills.push(data)); - target.add('atk', new Attacked({ attackerId: 'attacker', sourceId: 'sword' })); + target.add(new Attacked({ attackerId: 'attacker', sourceId: 'sword' })); w.update(1); expect(kills.length).toBe(1); }); @@ -287,13 +287,13 @@ describe('CombatSystem — events', () => { it("does not emit 'kill' when target survives", () => { const w = world(); const sword = w.createEntity('sword'); - sword.add('dmg', new Damage({ value: 5, damageType: 'physical' })); + sword.add(new Damage({ value: 5, damageType: 'physical' })); w.createEntity('attacker'); const target = w.createEntity('target'); - target.add('health', new Health({ value: 100, min: 0 })); + target.add(new Health({ value: 100, min: 0 })); const kills: unknown[] = []; target.on('kill', ({ data }) => kills.push(data)); - target.add('atk', new Attacked({ attackerId: 'attacker', sourceId: 'sword' })); + target.add(new Attacked({ attackerId: 'attacker', sourceId: 'sword' })); w.update(1); expect(kills.length).toBe(0); }); @@ -301,14 +301,14 @@ describe('CombatSystem — events', () => { it("emits 'hit' per attack when multiple attacks land", () => { const w = world(); const sword = w.createEntity('sword'); - sword.add('dmg', new Damage({ value: 5, damageType: 'physical' })); + sword.add(new Damage({ value: 5, damageType: 'physical' })); w.createEntity('a'); const target = w.createEntity('target'); - target.add('health', new Health({ value: 100, min: 0 })); + target.add(new Health({ value: 100, min: 0 })); const hits: unknown[] = []; target.on('hit', ({ data }) => hits.push(data)); - target.add('atk1', new Attacked({ attackerId: 'a', sourceId: 'sword' })); - target.add('atk2', new Attacked({ attackerId: 'a', sourceId: 'sword' })); + target.add(new Attacked({ attackerId: 'a', sourceId: 'sword' })); + target.add(new Attacked({ attackerId: 'a', sourceId: 'sword' })); w.update(1); expect(hits.length).toBe(2); }); diff --git a/test/common/rpg/effect.test.ts b/test/common/rpg/effect.test.ts index 94ecc0c..65e8a65 100644 --- a/test/common/rpg/effect.test.ts +++ b/test/common/rpg/effect.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'bun:test'; import { World } from '@common/rpg/core/world'; -import { Stat } from '@common/rpg/components/stat'; +import { Health, Stat } from '@common/rpg/components/stat'; import { Effect, EffectTemplate } from '@common/rpg/components/effect'; import { EffectSystem } from '@common/rpg/systems/effect'; @@ -13,20 +13,20 @@ function world() { function withStat(value = 10, min?: number, max?: number) { const w = world(); const e = w.createEntity(); - e.add('str', new Stat({ value, min, max })); + e.add(new Stat({ value, min, max }), 'str'); return { w, e, stat: e.get(Stat, 'str')! }; } describe('Effect — onAdd / onRemove', () => { it('applies delta to stat on add', () => { const { e, stat } = withStat(10); - e.add('fx', new Effect({ targetStat: 'str', delta: 5 })); + e.add(new Effect({ targetKey: 'str', delta: 5 })); expect(stat.value).toBe(15); }); it('reverts delta on remove', () => { const { e, stat } = withStat(10); - e.add('fx', new Effect({ targetStat: 'str', delta: 5 })); + e.add(new Effect({ targetKey: 'str', delta: 5 }), 'fx'); e.remove('fx'); expect(stat.value).toBe(10); }); @@ -34,7 +34,7 @@ describe('Effect — onAdd / onRemove', () => { it('applies delta to max field', () => { const { e } = withStat(10, undefined, 20); const s = e.get(Stat, 'str')!; - e.add('fx', new Effect({ targetStat: 'str', delta: 10, targetField: 'max' })); + e.add(new Effect({ targetKey: 'str', delta: 10, targetField: 'max' }), 'fx'); expect(s.max).toBe(30); e.remove('fx'); expect(s.max).toBe(20); @@ -42,21 +42,21 @@ describe('Effect — onAdd / onRemove', () => { it('active is true after add', () => { const { e } = withStat(10); - e.add('fx', new Effect({ targetStat: 'str', delta: 1 })); + e.add(new Effect({ targetKey: 'str', delta: 1 }), 'fx'); expect(e.get(Effect, 'fx')!.active).toBeTrue(); }); it('no-op if target stat is missing', () => { const w = world(); const e = w.createEntity(); - expect(() => e.add('fx', new Effect({ targetStat: 'str', delta: 5 }))).not.toThrow(); + expect(() => e.add(new Effect({ targetKey: 'str', delta: 5 }))).not.toThrow(); }); }); describe('Effect — duration', () => { it('expires after duration ticks', () => { const { w, e, stat } = withStat(10); - e.add('fx', new Effect({ targetStat: 'str', delta: 5, duration: 2 })); + e.add(new Effect({ targetKey: 'str', delta: 5, duration: 2 })); expect(stat.value).toBe(15); w.update(1); @@ -69,16 +69,16 @@ describe('Effect — duration', () => { it('emits expired before removal', () => { const { w, e } = withStat(10); - e.add(new Effect({ targetStat: 'str', delta: 1, duration: 1 })); + e.add(new Effect({ targetKey: 'str', delta: 1, duration: 1 }), 'fx'); const events: string[] = []; - e.on('Effect.expired', () => events.push('expired')); + e.on('Effect(fx).expired', () => events.push('expired')); w.update(1); expect(events).toEqual(['expired']); }); it('reset() restarts timer', () => { const { w, e, stat } = withStat(10); - e.add('fx', new Effect({ targetStat: 'str', delta: 5, duration: 2 })); + e.add(new Effect({ targetKey: 'str', delta: 5, duration: 2 }), 'fx'); w.update(1.5); e.get(Effect, 'fx')!.reset(); w.update(1.5); // would have expired without reset @@ -88,7 +88,7 @@ describe('Effect — duration', () => { it('reset(duration) changes duration', () => { const { w, e } = withStat(10); - e.add('fx', new Effect({ targetStat: 'str', delta: 5, duration: 1 })); + e.add(new Effect({ targetKey: 'str', delta: 5, duration: 1 }), 'fx'); e.get(Effect, 'fx')!.reset(10); w.update(5); expect(e.has(Effect)).toBeTrue(); @@ -96,7 +96,7 @@ describe('Effect — duration', () => { it('clear() immediately expires effect', () => { const { w, e, stat } = withStat(10); - e.add('fx', new Effect({ targetStat: 'str', delta: 5, duration: 100 })); + e.add(new Effect({ targetKey: 'str', delta: 5, duration: 100 }), 'fx'); e.get(Effect, 'fx')!.clear(); w.update(0.01); expect(e.has(Effect)).toBeFalse(); @@ -107,7 +107,7 @@ describe('Effect — duration', () => { describe('Effect — permanent', () => { it('permanent effect is never removed by EffectSystem', () => { const { w, e, stat } = withStat(10); - e.add('fx', new Effect({ targetStat: 'str', delta: 5 })); + e.add(new Effect({ targetKey: 'str', delta: 5 })); w.update(100); expect(e.has(Effect)).toBeTrue(); expect(stat.value).toBe(15); @@ -117,20 +117,20 @@ describe('Effect — permanent', () => { describe('Effect — scope: onHit', () => { it('onAdd is a no-op for onHit scope', () => { const { e, stat } = withStat(10); - e.add('fx', new EffectTemplate({ targetStat: 'str', delta: 99 })); + e.add(new EffectTemplate({ targetKey: 'str', delta: 99 })); expect(stat.value).toBe(10); }); it('onRemove is a no-op for onHit scope', () => { const { e, stat } = withStat(10); - e.add('fx', new EffectTemplate({ targetStat: 'str', delta: 99 })); + e.add(new EffectTemplate({ targetKey: 'str', delta: 99 })); e.remove('fx'); expect(stat.value).toBe(10); }); it('EffectSystem does not tick or remove onHit effects', () => { const { w, e } = withStat(10); - e.add('fx', new EffectTemplate({ targetStat: 'str', delta: 5, duration: 1 })); + e.add(new EffectTemplate({ targetKey: 'str', delta: 5, duration: 1 })); w.update(10); expect(e.has(EffectTemplate)).toBeTrue(); }); @@ -139,16 +139,149 @@ describe('Effect — scope: onHit', () => { describe('Effect — multiple effects on same stat', () => { it('multiple effects stack additively', () => { const { e, stat } = withStat(10); - e.add('a', new Effect({ targetStat: 'str', delta: 3 })); - e.add('b', new Effect({ targetStat: 'str', delta: 7 })); + e.add(new Effect({ targetKey: 'str', delta: 3 })); + e.add(new Effect({ targetKey: 'str', delta: 7 })); expect(stat.value).toBe(20); }); it('removing one effect reverts only that delta', () => { const { e, stat } = withStat(10); - e.add('a', new Effect({ targetStat: 'str', delta: 3 })); - e.add('b', new Effect({ targetStat: 'str', delta: 7 })); + e.add(new Effect({ targetKey: 'str', delta: 3 }), 'a'); + e.add(new Effect({ targetKey: 'str', delta: 7 }), 'b'); e.remove('a'); expect(stat.value).toBe(17); }); }); + +describe('Effect — targetKey resolution', () => { + it('no targetKey finds a stat added without a key', () => { + const w = world(); + const e = w.createEntity(); + const stat = e.add(new Stat({ value: 10 })); // Symbol key + e.add(new Effect({ delta: 5 })); + expect(stat.value).toBe(15); + }); + + it('no targetKey falls back to a string-keyed stat when no anonymous stat exists', () => { + const { e, stat } = withStat(10); + e.add(new Effect({ delta: 5 })); + expect(stat.value).toBe(15); + }); + + it('no targetKey prefers the anonymous (symbol-keyed) stat over a named one', () => { + const w = world(); + const e = w.createEntity(); + const named = e.add(new Stat({ value: 10 }), 'str'); + const anon = e.add(new Stat({ value: 10 })); // Symbol key — should win + e.add(new Effect({ delta: 5 })); + expect(anon.value).toBe(15); + expect(named.value).toBe(10); + }); + + it('targetKey selects the correct stat among multiple', () => { + const w = world(); + const e = w.createEntity(); + const str = e.add(new Stat({ value: 10 }), 'str'); + const int = e.add(new Stat({ value: 5 }), 'int'); + e.add(new Effect({ targetKey: 'int', delta: 3 })); + expect(int.value).toBe(8); + expect(str.value).toBe(10); + }); + + it('wrong targetKey is a no-op and active stays false', () => { + const { e, stat } = withStat(10); + e.add(new Effect({ targetKey: 'dex', delta: 5 }), 'fx'); + expect(e.get(Effect, 'fx')!.active).toBeFalse(); + expect(stat.value).toBe(10); + }); +}); + +describe('Effect — target component class', () => { + it('target: Health targets a Health component by key', () => { + const w = world(); + const e = w.createEntity(); + const hp = e.add(new Health({ value: 100, min: 0 }), 'hp'); + e.add(new Effect({ target: Health, targetKey: 'hp', delta: 20 })); + expect(hp.value).toBe(120); + }); + + it('removing the effect reverts the delta on the Health component', () => { + const w = world(); + const e = w.createEntity(); + const hp = e.add(new Health({ value: 100, min: 0 }), 'hp'); + e.add(new Effect({ target: Health, targetKey: 'hp', delta: -30 }), 'fx'); + expect(hp.value).toBe(70); + e.remove('fx'); + expect(hp.value).toBe(100); + }); + + it('target mismatch (wrong class for given key) is a no-op', () => { + const { e, stat } = withStat(10); + e.add(new Effect({ target: Health, targetKey: 'str', delta: 5 }), 'fx'); + expect(e.get(Effect, 'fx')!.active).toBeFalse(); + expect(stat.value).toBe(10); + }); + + it('target: Health without targetKey applies only to Health, not Stat', () => { + const w = world(); + const e = w.createEntity(); + const str = e.add(new Stat({ value: 10 }), 'str'); + const hp = e.add(new Health({ value: 100, min: 0 }), 'hp'); + e.add(new Effect({ target: Health, delta: 20 })); + expect(hp.value).toBe(120); + expect(str.value).toBe(10); + }); + + it('target: Health without targetKey applies to anonymous Health when Stat also present', () => { + const w = world(); + const e = w.createEntity(); + const str = e.add(new Stat({ value: 10 }), 'str'); + const hp = e.add(new Health({ value: 100, min: 0 })); // Symbol key + e.add(new Effect({ target: Health, delta: -10 })); + expect(hp.value).toBe(90); + expect(str.value).toBe(10); + }); +}); + +describe('Effect — condition', () => { + it('condition-based effect persists while condition is true', () => { + const { w, e, stat } = withStat(10); + w.globals.buffed = 1; + e.add(new Effect({ targetKey: 'str', delta: 5, condition: '$.buffed > 0' })); + w.update(10); + expect(e.has(Effect)).toBeTrue(); + expect(stat.value).toBe(15); + }); + + it('condition-based effect is removed when condition becomes false', () => { + const { w, e, stat } = withStat(10); + w.globals.buffed = 1; + e.add(new Effect({ targetKey: 'str', delta: 5, condition: '$.buffed > 0' })); + w.globals.buffed = 0; + w.update(0.01); + expect(e.has(Effect)).toBeFalse(); + expect(stat.value).toBe(10); + }); +}); + +describe('Effect — clear() event count', () => { + it('clear() emits expired exactly once even after subsequent update ticks', () => { + const { w, e } = withStat(10); + e.add(new Effect({ targetKey: 'str', delta: 1, duration: 100 }), 'fx'); + const count = { n: 0 }; + e.on('Effect(fx).expired', () => count.n++); + e.get(Effect, 'fx')!.clear(); + w.update(0.01); + w.update(0.01); + expect(count.n).toBe(1); + }); + + it('natural duration expiry emits expired exactly once', () => { + const { w, e } = withStat(10); + e.add(new Effect({ targetKey: 'str', delta: 1, duration: 1 }), 'fx'); + const count = { n: 0 }; + e.on('Effect(fx).expired', () => count.n++); + w.update(2); + expect(count.n).toBe(1); + }); +}); diff --git a/test/common/rpg/equipment.test.ts b/test/common/rpg/equipment.test.ts index 092e107..d87cf66 100644 --- a/test/common/rpg/equipment.test.ts +++ b/test/common/rpg/equipment.test.ts @@ -8,13 +8,13 @@ function world() { return new World(); } function makeSword(w: World, id = 'sword') { const sword = w.createEntity(id); - sword.add('equippable', new Equippable('weapon')); + sword.add(new Equippable('weapon')); return sword; } function makePlayer(w: World, slots: ConstructorParameters[0] = { slotName: 'weapon', type: 'weapon' }) { const player = w.createEntity('player'); - player.add('str', new Stat({ value: 10 })); + player.add(new Stat({ value: 10 }), 'str'); player.add(new Equipment(slots)); return player; } @@ -52,7 +52,7 @@ describe('Equipment — equip', () => { it('returns false when slot type does not match Equippable.slotType', () => { const w = world(); const helmet = w.createEntity('helmet'); - helmet.add('equippable', new Equippable('armor')); + helmet.add(new Equippable('armor')); const player = makePlayer(w); // slot type = 'weapon' expect(player.get(Equipment)!.equip({ slotName: 'weapon', itemId: 'helmet' })).toBeFalse(); }); @@ -61,7 +61,7 @@ describe('Equipment — equip', () => { const w = world(); makeSword(w); const player = w.createEntity('player'); - player.add('equipment', new Equipment('slot1')); // generic slot + player.add(new Equipment('slot1')); // generic slot expect(player.get(Equipment)!.equip({ slotName: 'slot1', itemId: 'sword' })).toBeTrue(); }); @@ -91,7 +91,7 @@ describe('Equipment — equip-scope effects', () => { it('clones equip-scope Effect onto owner on equip', () => { const w = world(); const sword = makeSword(w); - sword.add('bonus', new Effect({ targetStat: 'str', delta: 5 })); // scope: equip (default) + sword.add(new Effect({ targetKey: 'str', delta: 5 })); // scope: equip (default) const player = makePlayer(w); player.get(Equipment)!.equip({ slotName: 'weapon', itemId: 'sword' }); expect(player.get(Stat, 'str')!.value).toBe(15); @@ -100,7 +100,7 @@ describe('Equipment — equip-scope effects', () => { it('does NOT clone onHit-scope Effect onto owner on equip', () => { const w = world(); const sword = makeSword(w); - sword.add('burn', new EffectTemplate({ targetStat: 'str', delta: 99 })); + sword.add(new EffectTemplate({ targetKey: 'str', delta: 99 })); const player = makePlayer(w); player.get(Equipment)!.equip({ slotName: 'weapon', itemId: 'sword' }); expect(player.get(Stat, 'str')!.value).toBe(10); // unaffected @@ -110,8 +110,8 @@ describe('Equipment — equip-scope effects', () => { it('clones multiple equip effects', () => { const w = world(); const sword = makeSword(w); - sword.add('a', new Effect({ targetStat: 'str', delta: 3 })); - sword.add('b', new Effect({ targetStat: 'str', delta: 7 })); + sword.add(new Effect({ targetKey: 'str', delta: 3 })); + sword.add(new Effect({ targetKey: 'str', delta: 7 })); const player = makePlayer(w); player.get(Equipment)!.equip({ slotName: 'weapon', itemId: 'sword' }); expect(player.get(Stat, 'str')!.value).toBe(20); @@ -120,8 +120,8 @@ describe('Equipment — equip-scope effects', () => { it('equip + onHit: only equip effects reach owner', () => { const w = world(); const sword = makeSword(w); - sword.add('passive', new Effect({ targetStat: 'str', delta: 5 })); - sword.add('burn', new EffectTemplate({ targetStat: 'str', delta: 99 })); + sword.add(new Effect({ targetKey: 'str', delta: 5 })); + sword.add(new EffectTemplate({ targetKey: 'str', delta: 99 })); const player = makePlayer(w); player.get(Equipment)!.equip({ slotName: 'weapon', itemId: 'sword' }); expect(player.get(Stat, 'str')!.value).toBe(15); @@ -133,7 +133,7 @@ describe('Equipment — unequip', () => { it('unequip reverts cloned effects', () => { const w = world(); const sword = makeSword(w); - sword.add('bonus', new Effect({ targetStat: 'str', delta: 5 })); + sword.add(new Effect({ targetKey: 'str', delta: 5 })); const player = makePlayer(w); const eq = player.get(Equipment)!; eq.equip({ slotName: 'weapon', itemId: 'sword' }); @@ -177,7 +177,7 @@ describe('Equipment — queries', () => { makeSword(w, 'sword1'); makeSword(w, 'sword2'); const player = w.createEntity('player'); - player.add('equipment', new Equipment( + player.add(new Equipment( { slotName: 'main', type: 'weapon' }, { slotName: 'off', type: 'weapon' }, )); @@ -192,7 +192,7 @@ describe('Equipment — queries', () => { it('findCompatibleSlot prefers typed slot over generic', () => { const w = world(); const player = w.createEntity('player'); - player.add('equipment', new Equipment( + player.add(new Equipment( 'generic', { slotName: 'weapon', type: 'weapon' }, )); @@ -203,7 +203,7 @@ describe('Equipment — queries', () => { it('findCompatibleSlot falls back to generic slot', () => { const w = world(); const player = w.createEntity('player'); - player.add('equipment', new Equipment('generic')); + player.add(new Equipment('generic')); const eq = player.get(Equipment)!; expect(eq.findCompatibleSlot('weapon')).toBe('generic'); }); @@ -211,7 +211,7 @@ describe('Equipment — queries', () => { it('findCompatibleSlot returns null when no compatible slot', () => { const w = world(); const player = w.createEntity('player'); - player.add('equipment', new Equipment({ slotName: 'armor', type: 'armor' })); + player.add(new Equipment({ slotName: 'armor', type: 'armor' })); const eq = player.get(Equipment)!; expect(eq.findCompatibleSlot('weapon')).toBeNull(); }); diff --git a/test/common/rpg/experience.test.ts b/test/common/rpg/experience.test.ts index fc0a475..ba2d3c1 100644 --- a/test/common/rpg/experience.test.ts +++ b/test/common/rpg/experience.test.ts @@ -5,7 +5,7 @@ import { Experience } from '@common/rpg/components/experience'; function withXp(spec: ConstructorParameters[0]) { const w = new World(); const e = w.createEntity(); - e.add('xp', new Experience(spec)); + e.add(new Experience(spec), 'xp'); return { e, xp: e.get(Experience, 'xp')! }; } diff --git a/test/common/rpg/inventory.test.ts b/test/common/rpg/inventory.test.ts index e40403b..740e4ba 100644 --- a/test/common/rpg/inventory.test.ts +++ b/test/common/rpg/inventory.test.ts @@ -61,7 +61,7 @@ describe('Inventory — infinite mode', () => { const w = world(); Items.register(w, 'coin', 'Coin', { maxStack: 99 }); const e = w.createEntity('player'); - e.add('inv', new Inventory()); + e.add(new Inventory()); e.get(Inventory)!.add({ itemId: 'coin', amount: 500 }); expect(e.get(Inventory)!.getAmount('coin')).toBe(500); }); @@ -70,7 +70,7 @@ describe('Inventory — infinite mode', () => { const w = world(); Items.register(w, 'coin', 'Coin', { maxStack: 10 }); const e = w.createEntity('player'); - e.add('inv', new Inventory()); + e.add(new Inventory()); e.get(Inventory)!.add({ itemId: 'coin', amount: 25 }); // 10 + 10 + 5 = 3 slots expect(e.get(Inventory)!.getItems().get('coin')).toBe(25); @@ -80,14 +80,14 @@ describe('Inventory — infinite mode', () => { const w = world(); Items.register(w, 'gem', 'Gem'); const e = w.createEntity('player'); - e.add('inv', new Inventory()); + e.add(new Inventory()); expect(e.get(Inventory)!.add({ itemId: 'gem', amount: 1 })).toBeTrue(); }); it('warns and returns false for unknown item', () => { const w = world(); const e = w.createEntity('player'); - e.add('inv', new Inventory()); + e.add(new Inventory()); expect(e.get(Inventory)!.add({ itemId: 'ghost', amount: 1 })).toBeFalse(); }); }); @@ -97,7 +97,7 @@ describe('Inventory — finite mode (count)', () => { const w = world(); Items.register(w, 'potion', 'Potion', { maxStack: 5 }); const e = w.createEntity('player'); - e.add('inv', new Inventory(2)); // 2 slots × 5 stack = 10 max + e.add(new Inventory(2)); // 2 slots × 5 stack = 10 max expect(e.get(Inventory)!.add({ itemId: 'potion', amount: 10 })).toBeTrue(); expect(e.get(Inventory)!.getAmount('potion')).toBe(10); }); @@ -106,7 +106,7 @@ describe('Inventory — finite mode (count)', () => { const w = world(); Items.register(w, 'potion', 'Potion', { maxStack: 5 }); const e = w.createEntity('player'); - e.add('inv', new Inventory(1)); // 1 slot × 5 = 5 max + e.add(new Inventory(1)); // 1 slot × 5 = 5 max expect(e.get(Inventory)!.add({ itemId: 'potion', amount: 6 })).toBeFalse(); expect(e.get(Inventory)!.getAmount('potion')).toBe(0); }); @@ -115,7 +115,7 @@ describe('Inventory — finite mode (count)', () => { const w = world(); Items.register(w, 'stone', 'Stone', { maxStack: 10 }); const e = w.createEntity('player'); - e.add('inv', new Inventory(2)); + e.add(new Inventory(2)); const inv = e.get(Inventory)!; inv.add({ itemId: 'stone', amount: 8 }); inv.add({ itemId: 'stone', amount: 5 }); // fills slot 0 to 10, slot 1 to 3 @@ -128,7 +128,7 @@ describe('Inventory — named slots', () => { const w = world(); Items.register(w, 'herb', 'Herb', { maxStack: 10 }); const e = w.createEntity('player'); - e.add('inv', new Inventory([{ slotId: 'pocket', limit: 10 }])); + e.add(new Inventory([{ slotId: 'pocket', limit: 10 }])); const inv = e.get(Inventory)!; expect(inv.add({ itemId: 'herb', amount: 3, slotId: 'pocket' })).toBeTrue(); expect(inv.getSlotContents('pocket')).toEqual({ itemId: 'herb', amount: 3 }); @@ -138,7 +138,7 @@ describe('Inventory — named slots', () => { const w = world(); Items.register(w, 'herb', 'Herb'); const e = w.createEntity('player'); - e.add('inv', new Inventory([{ slotId: 'pocket', limit: 10 }])); + e.add(new Inventory([{ slotId: 'pocket', limit: 10 }])); expect(e.get(Inventory)!.add({ itemId: 'herb', amount: 1, slotId: 'wallet' })).toBeFalse(); }); @@ -146,7 +146,7 @@ describe('Inventory — named slots', () => { const w = world(); Items.register(w, 'herb', 'Herb', { maxStack: 99 }); const e = w.createEntity('player'); - e.add('inv', new Inventory([{ slotId: 'slot', limit: 5 }])); + e.add(new Inventory([{ slotId: 'slot', limit: 5 }])); expect(e.get(Inventory)!.add({ itemId: 'herb', amount: 6, slotId: 'slot' })).toBeFalse(); }); }); @@ -156,7 +156,7 @@ describe('Inventory — remove', () => { const w = world(); Items.register(w, 'coin', 'Coin', { maxStack: 99 }); const e = w.createEntity('player'); - e.add('inv', new Inventory()); + e.add(new Inventory()); const inv = e.get(Inventory)!; inv.add({ itemId: 'coin', amount: 50 }); inv.remove({ itemId: 'coin', amount: 20 }); @@ -167,7 +167,7 @@ describe('Inventory — remove', () => { const w = world(); Items.register(w, 'coin', 'Coin'); const e = w.createEntity('player'); - e.add('inv', new Inventory()); + e.add(new Inventory()); const inv = e.get(Inventory)!; inv.add({ itemId: 'coin', amount: 1 }); expect(inv.remove({ itemId: 'coin', amount: 10 })).toBeFalse(); @@ -178,7 +178,7 @@ describe('Inventory — remove', () => { const w = world(); Items.register(w, 'gem', 'Gem'); const e = w.createEntity('player'); - e.add('inv', new Inventory([{ slotId: 'a' }, { slotId: 'b' }])); + e.add(new Inventory([{ slotId: 'a' }, { slotId: 'b' }])); const inv = e.get(Inventory)!; inv.add({ itemId: 'gem', amount: 1, slotId: 'a' }); inv.add({ itemId: 'gem', amount: 1, slotId: 'b' }); @@ -191,7 +191,7 @@ describe('Inventory — remove', () => { const w = world(); Items.register(w, 'herb', 'Herb'); const e = w.createEntity('player'); - e.add('inv', new Inventory([{ slotId: 's' }])); + e.add(new Inventory([{ slotId: 's' }])); const inv = e.get(Inventory)!; inv.add({ itemId: 'herb', amount: 1, slotId: 's' }); inv.remove({ itemId: 'herb', amount: 1, slotId: 's' }); @@ -204,7 +204,7 @@ describe('Inventory — getItems / getAmount', () => { const w = world(); Items.register(w, 'coin', 'Coin', { maxStack: 5 }); const e = w.createEntity('player'); - e.add('inv', new Inventory(3)); + e.add(new Inventory(3)); const inv = e.get(Inventory)!; inv.add({ itemId: 'coin', amount: 12 }); expect(inv.getItems().get('coin')).toBe(12); @@ -213,7 +213,7 @@ describe('Inventory — getItems / getAmount', () => { it('getAmount returns 0 for absent item', () => { const w = world(); const e = w.createEntity('player'); - e.add('inv', new Inventory()); + e.add(new Inventory()); expect(e.get(Inventory)!.getAmount('missing')).toBe(0); }); }); @@ -222,11 +222,11 @@ describe('Inventory — equip', () => { it('delegates to Equipment.equip', () => { const w = world(); const sword = Items.register(w, 'sword', 'Sword'); - sword.add('equippable', new Equippable('weapon')); + sword.add(new Equippable('weapon')); const player = w.createEntity('player'); - player.add('str', new Stat({ value: 10 })); - player.add('equipment', new Equipment({ slotName: 'weapon', type: 'weapon' })); - player.add('inv', new Inventory()); + player.add(new Stat({ value: 10 })); + player.add(new Equipment({ slotName: 'weapon', type: 'weapon' })); + player.add(new Inventory()); const inv = player.get(Inventory)!; inv.add({ itemId: 'sword', amount: 1 }); expect(inv.equip({ itemId: 'sword' })).toBeTrue(); @@ -238,8 +238,8 @@ describe('Inventory — use', () => { it('executes Usable actions', () => { const w = world(); const player = w.createEntity('player'); - player.add('str', new Stat({ value: 10 })); - player.add('inv', new Inventory()); + player.add(new Stat({ value: 10 }), 'str'); + player.add(new Inventory()); Items.register(w, 'potion', 'Health Potion', { usable: { actions: [{ type: 'Stat(str).update', arg: 5 }], consumeOnUse: false }, }); @@ -251,7 +251,7 @@ describe('Inventory — use', () => { it('consumes item when consumeOnUse is true', () => { const w = world(); const player = w.createEntity('player'); - player.add('inv', new Inventory()); + player.add(new Inventory()); Items.register(w, 'herb', 'Herb', { maxStack: 99, usable: { actions: [], consumeOnUse: true }, diff --git a/test/common/rpg/quest.test.ts b/test/common/rpg/quest.test.ts index 88b99cf..d1721db 100644 --- a/test/common/rpg/quest.test.ts +++ b/test/common/rpg/quest.test.ts @@ -22,7 +22,7 @@ function simpleQuest(id = 'q1', actions: Quest['stages'][0]['actions'] = []): Qu stages: [{ id: 'stage0', description: 'Do the thing', - objectives: [{ id: 'obj', description: 'Done?', condition: 'Variables(vars).done == true' }], + objectives: [{ id: 'obj', description: 'Done?', condition: 'Variables.done == true' }], actions, }], }; @@ -38,13 +38,13 @@ function twoStageQuest(id = 'q2'): Quest { { id: 'stage0', description: 'Step 1', - objectives: [{ id: 'obj0', description: 'Reach step 1', condition: 'Variables(vars).step >= 1' }], + objectives: [{ id: 'obj0', description: 'Reach step 1', condition: 'Variables.step >= 1' }], actions: [], }, { id: 'stage1', description: 'Step 2', - objectives: [{ id: 'obj1', description: 'Reach step 2', condition: 'Variables(vars).step >= 2' }], + objectives: [{ id: 'obj1', description: 'Reach step 2', condition: 'Variables.step >= 2' }], actions: [], }, ], @@ -53,7 +53,7 @@ function twoStageQuest(id = 'q2'): Quest { function makePlayer(w: World, quests: Quest[] = []) { const player = w.createEntity('player'); - const vars = player.add('vars', new Variables()); + const vars = player.add(new Variables()); const questLog = player.add(new QuestLog(quests)); return { player, vars, questLog }; } @@ -254,14 +254,14 @@ describe('QuestLog — availability', () => { it('quest with unsatisfied condition is not available', () => { const w = world(); - const quest: Quest = { ...simpleQuest(), conditions: ['Variables(vars).unlocked == true'] }; + const quest: Quest = { ...simpleQuest(), conditions: ['Variables.unlocked == true'] }; const { player, questLog } = makePlayer(w, [quest]); expect(questLog.isAvailable('q1', player.context)).toBeFalse(); }); it('quest with satisfied condition is available', () => { const w = world(); - const quest: Quest = { ...simpleQuest(), conditions: ['Variables(vars).unlocked == true'] }; + const quest: Quest = { ...simpleQuest(), conditions: ['Variables.unlocked == true'] }; const { player, vars, questLog } = makePlayer(w, [quest]); vars.set({ key: 'unlocked', value: true }); expect(questLog.isAvailable('q1', player.context)).toBeTrue(); @@ -357,8 +357,8 @@ describe('Quests.validate', () => { }); it('passes when action type is in the known actions list', () => { - const quest = simpleQuest('q', [{ type: 'Variables(vars).set' }]); - const errors = Quests.validate(quest, ['Variables(vars).set']); + const quest = simpleQuest('q', [{ type: 'Variables.set' }]); + const errors = Quests.validate(quest, ['Variables.set']); expect(errors).toHaveLength(0); }); @@ -405,7 +405,7 @@ describe('QuestSystem — objective completion', () => { it('runs stage actions before advancing', () => { const w = world(); // action sets vars.reward = true on the player entity - const quest = simpleQuest('q1', [{ type: 'Variables(vars).set', arg: { key: 'reward', value: true } }]); + const quest = simpleQuest('q1', [{ type: 'Variables.set', arg: { key: 'reward', value: true } }]); const { vars, questLog } = makePlayer(w, [quest]); questLog.start('q1'); @@ -462,9 +462,9 @@ describe('QuestSystem — fail conditions', () => { stages: [{ id: 'stage0', description: 'Do it', - objectives: [{ id: 'obj', description: 'Done?', condition: 'Variables(vars).done == true' }], + objectives: [{ id: 'obj', description: 'Done?', condition: 'Variables.done == true' }], actions: [], - failConditions: ['Variables(vars).failed == true'], + failConditions: ['Variables.failed == true'], }], }; const { vars, questLog } = makePlayer(w, [quest]); @@ -485,9 +485,9 @@ describe('QuestSystem — fail conditions', () => { stages: [{ id: 'stage0', description: 'Both', - objectives: [{ id: 'obj', description: 'Done?', condition: 'Variables(vars).done == true' }], + objectives: [{ id: 'obj', description: 'Done?', condition: 'Variables.done == true' }], actions: [], - failConditions: ['Variables(vars).done == true'], // same condition + failConditions: ['Variables.done == true'], // same condition }], }; const { vars, questLog } = makePlayer(w, [quest]); @@ -504,18 +504,18 @@ describe('QuestSystem — multiple quests', () => { it('tracks multiple quests independently', () => { const w = world(); const player = w.createEntity('player'); - const vars = player.add('vars', new Variables()); + const vars = player.add(new Variables()); const q1: Quest = { id: 'q1', title: 'Q1', description: '', - stages: [{ id: 's', description: '', objectives: [{ id: 'o', description: '', condition: 'Variables(vars).done1 == true' }], actions: [] }], + stages: [{ id: 's', description: '', objectives: [{ id: 'o', description: '', condition: 'Variables.done1 == true' }], actions: [] }], }; const q2: Quest = { id: 'q2', title: 'Q2', description: '', - stages: [{ id: 's', description: '', objectives: [{ id: 'o', description: '', condition: 'Variables(vars).done2 == true' }], actions: [] }], + stages: [{ id: 's', description: '', objectives: [{ id: 'o', description: '', condition: 'Variables.done2 == true' }], actions: [] }], }; - const log = player.add('questLog', new QuestLog([q1, q2])); + const log = player.add(new QuestLog([q1, q2])); log.start('q1'); log.start('q2'); diff --git a/test/common/rpg/serialization.test.ts b/test/common/rpg/serialization.test.ts new file mode 100644 index 0000000..2c6d2e3 --- /dev/null +++ b/test/common/rpg/serialization.test.ts @@ -0,0 +1,124 @@ +import { describe, it, expect } from 'bun:test'; +import { World, Component, COMPONENT_KEY } from '@common/rpg/core/world'; +import { component } from '@common/rpg/utils/decorators'; +import { Serialization } from '@common/rpg/core/serialization'; +import { Stat } from '@common/rpg/components/stat'; +import { Effect } from '@common/rpg/components/effect'; +import { EffectSystem } from '@common/rpg/systems/effect'; + +// ---------- helpers ---------- + +function roundtrip(world: World): World { + return Serialization.deserialize(Serialization.serialize(world)) as World; +} + +function isSymbolKeyed(world: World, entityId: string, ctor: new (...a: any[]) => Component): boolean { + const entity = world.getEntity(entityId)!; + for (const [, component] of entity) { + if (component instanceof ctor) { + return typeof component[COMPONENT_KEY] === 'symbol'; + } + } + return false; +} + +// ---------- named-key round-trip ---------- + +describe('Serialization — named key', () => { + it('preserves a string-keyed component', () => { + const w = new World(); + const e = w.createEntity('player'); + e.add(new Stat({ value: 42 }), 'str'); + const w2 = roundtrip(w); + expect(w2.getEntity('player')!.get(Stat, 'str')!.value).toBe(42); + }); + + it('preserves two components with different named keys', () => { + const w = new World(); + const e = w.createEntity('player'); + e.add(new Stat({ value: 10 }), 'str'); + e.add(new Stat({ value: 5 }), 'int'); + const w2 = roundtrip(w); + const e2 = w2.getEntity('player')!; + expect(e2.get(Stat, 'str')!.value).toBe(10); + expect(e2.get(Stat, 'int')!.value).toBe(5); + }); +}); + +// ---------- symbol-key round-trip ---------- + +describe('Serialization — anonymous (symbol) key', () => { + it('restores a symbol-keyed component as symbol-keyed', () => { + const w = new World(); + w.createEntity('e').add(new Stat({ value: 7 })); // no key → Symbol + const w2 = roundtrip(w); + expect(isSymbolKeyed(w2, 'e', Stat)).toBeTrue(); + }); + + it('restored symbol-keyed component is found by type lookup', () => { + const w = new World(); + const e = w.createEntity('e'); + e.add(new Stat({ value: 7 })); + const w2 = roundtrip(w); + expect(w2.getEntity('e')!.get(Stat)!.value).toBe(7); + }); + + it('two anonymous components of the same type both survive round-trip', () => { + const w = new World(); + const e = w.createEntity('e'); + e.add(new Stat({ value: 3 })); + e.add(new Stat({ value: 9 })); + const w2 = roundtrip(w); + const stats = w2.getEntity('e')!.getAll(Stat); + expect(stats.length).toBe(2); + expect(stats.map(s => s.value).sort()).toEqual([3, 9]); + }); + + it('anonymous and named component of the same type both survive round-trip', () => { + const w = new World(); + const e = w.createEntity('e'); + e.add(new Stat({ value: 10 }), 'str'); + e.add(new Stat({ value: 20 })); // Symbol key + const w2 = roundtrip(w); + const e2 = w2.getEntity('e')!; + expect(e2.get(Stat, 'str')!.value).toBe(10); + // anonymous one still reachable by type + const all = e2.getAll(Stat); + expect(all.length).toBe(2); + }); +}); + +// ---------- effect + stat round-trip ---------- + +describe('Serialization — Effect round-trip', () => { + it('multiple anonymous Effects both survive round-trip', () => { + const w = new World(); + w.addSystem(new EffectSystem()); + const e = w.createEntity('target'); + e.add(new Stat({ value: 10 }), 'str'); + e.add(new Effect({ targetKey: 'str', delta: 3 })); + e.add(new Effect({ targetKey: 'str', delta: 7 })); + + const w2 = roundtrip(w); + w2.addSystem(new EffectSystem()); + expect(w2.getEntity('target')!.getAll(Effect).length).toBe(2); + }); + + it('world globals survive round-trip', () => { + const w = new World(); + w.globals.score = 99; + w.globals.level = 3; + const w2 = roundtrip(w); + expect(w2.globals.score).toBe(99); + expect(w2.globals.level).toBe(3); + }); + + it('entity counter continues from saved value', () => { + const w = new World(); + w.createEntity(); + w.createEntity(); + const w2 = roundtrip(w); + const e = w2.createEntity(); + expect(e.id).toBe('entity_3'); + }); +}); diff --git a/test/common/rpg/stat.test.ts b/test/common/rpg/stat.test.ts index 02bc12d..3c89886 100644 --- a/test/common/rpg/stat.test.ts +++ b/test/common/rpg/stat.test.ts @@ -8,7 +8,7 @@ describe('Stat — value / base / modifiers', () => { it('value equals base when no modifiers', () => { const w = world(); const e = w.createEntity(); - e.add('s', new Stat({ value: 10 })); + e.add(new Stat({ value: 10 }), 's'); const s = e.get(Stat, 's')!; expect(s.value).toBe(10); expect(s.base).toBe(10); @@ -17,7 +17,7 @@ describe('Stat — value / base / modifiers', () => { it('set() changes base and value', () => { const w = world(); const e = w.createEntity(); - e.add('s', new Stat({ value: 5 })); + e.add(new Stat({ value: 5 }), 's'); const s = e.get(Stat, 's')!; s.set(20); expect(s.base).toBe(20); @@ -27,7 +27,7 @@ describe('Stat — value / base / modifiers', () => { it('update() adds to base', () => { const w = world(); const e = w.createEntity(); - e.add('s', new Stat({ value: 10 })); + e.add(new Stat({ value: 10 }), 's'); const s = e.get(Stat, 's')!; s.update(5); expect(s.value).toBe(15); @@ -36,7 +36,7 @@ describe('Stat — value / base / modifiers', () => { it('applyModifier shifts value without changing base', () => { const w = world(); const e = w.createEntity(); - e.add('s', new Stat({ value: 10 })); + e.add(new Stat({ value: 10 }), 's'); const s = e.get(Stat, 's')!; s.applyModifier(5); expect(s.base).toBe(10); @@ -46,7 +46,7 @@ describe('Stat — value / base / modifiers', () => { it('removeModifier reverts applyModifier', () => { const w = world(); const e = w.createEntity(); - e.add('s', new Stat({ value: 10 })); + e.add(new Stat({ value: 10 }), 's'); const s = e.get(Stat, 's')!; s.applyModifier(5); s.removeModifier(5); @@ -56,7 +56,7 @@ describe('Stat — value / base / modifiers', () => { it('multiple modifiers stack', () => { const w = world(); const e = w.createEntity(); - e.add('s', new Stat({ value: 10 })); + e.add(new Stat({ value: 10 }), 's'); const s = e.get(Stat, 's')!; s.applyModifier(3); s.applyModifier(7); @@ -66,7 +66,7 @@ describe('Stat — value / base / modifiers', () => { it('modifier on max field shifts effective max', () => { const w = world(); const e = w.createEntity(); - e.add('s', new Stat({ value: 10, max: 20 })); + e.add(new Stat({ value: 10, max: 20 }), 's'); const s = e.get(Stat, 's')!; s.applyModifier(10, 'max'); expect(s.max).toBe(30); @@ -75,7 +75,7 @@ describe('Stat — value / base / modifiers', () => { it('modifier on min field shifts effective min', () => { const w = world(); const e = w.createEntity(); - e.add('s', new Stat({ value: 10, min: 0 })); + e.add(new Stat({ value: 10, min: 0 }), 's'); const s = e.get(Stat, 's')!; s.applyModifier(5, 'min'); expect(s.min).toBe(5); @@ -84,7 +84,7 @@ describe('Stat — value / base / modifiers', () => { it('value is clamped to [min, max]', () => { const w = world(); const e = w.createEntity(); - e.add('s', new Stat({ value: 10, min: 0, max: 20 })); + e.add(new Stat({ value: 10, min: 0, max: 20 }), 's'); const s = e.get(Stat, 's')!; s.set(100); expect(s.value).toBe(20); @@ -95,7 +95,7 @@ describe('Stat — value / base / modifiers', () => { it('large positibe modifier clamps to max', () => { const w = world(); const e = w.createEntity(); - e.add('s', new Stat({ value: 10, max: 20 })); + e.add(new Stat({ value: 10, max: 20 }), 's'); const s = e.get(Stat, 's')!; s.applyModifier(100); expect(s.value).toBe(20); @@ -104,7 +104,7 @@ describe('Stat — value / base / modifiers', () => { it('large negative modifier clamps to min', () => { const w = world(); const e = w.createEntity(); - e.add('s', new Stat({ value: 10, min: 0 })); + e.add(new Stat({ value: 10, min: 0 }), 's'); const s = e.get(Stat, 's')!; s.applyModifier(-100); expect(s.value).toBe(0); @@ -113,7 +113,7 @@ describe('Stat — value / base / modifiers', () => { it("set() emits 'set' event with prev and value", () => { const w = world(); const e = w.createEntity(); - e.add('s', new Stat({ value: 10 })); + e.add(new Stat({ value: 10 }), 's'); const s = e.get(Stat, 's')!; const events: unknown[] = []; e.on('Stat(s).set', ({ data }) => events.push(data)); @@ -124,7 +124,7 @@ describe('Stat — value / base / modifiers', () => { it("set() does not emit 'set' when value unchanged", () => { const w = world(); const e = w.createEntity(); - e.add('s', new Stat({ value: 10, min: 0 })); + e.add(new Stat({ value: 10, min: 0 }), 's'); const s = e.get(Stat, 's')!; const events: unknown[] = []; e.on('Stat(s).set', ({ data }) => events.push(data)); @@ -138,7 +138,7 @@ describe('Health', () => { it('update reduces health', () => { const w = world(); const e = w.createEntity(); - e.add('health', new Health({ value: 100, min: 0 })); + e.add(new Health({ value: 100, min: 0 })); const h = e.get(Health)!; h.update(-30); expect(h.value).toBe(70); @@ -147,10 +147,10 @@ describe('Health', () => { it('update to zero triggers kill()', () => { const w = world(); const e = w.createEntity(); - e.add('health', new Health({ value: 10, min: 0 })); + e.add(new Health({ value: 10, min: 0 })); const h = e.get(Health)!; const killed: unknown[] = []; - e.on('Health(health).killed', () => killed.push(true)); + e.on('Health.killed', () => killed.push(true)); h.update(-10); expect(killed.length).toBe(1); expect(h.value).toBe(0); @@ -159,10 +159,10 @@ describe('Health', () => { it('kill() emits killed and sets value to 0', () => { const w = world(); const e = w.createEntity(); - e.add('health', new Health({ value: 50, min: 0 })); + e.add(new Health({ value: 50, min: 0 })); const h = e.get(Health)!; const killed: unknown[] = []; - e.on('Health(health).killed', () => killed.push(true)); + e.on('Health.killed', () => killed.push(true)); h.kill(); expect(killed.length).toBe(1); expect(h.value).toBe(0); @@ -171,10 +171,10 @@ describe('Health', () => { it('kill() does not emit killed twice for overkill', () => { const w = world(); const e = w.createEntity(); - e.add('health', new Health({ value: 10, min: 0 })); + e.add(new Health({ value: 10, min: 0 })); const h = e.get(Health)!; const killed: unknown[] = []; - e.on('Health(health).killed', () => killed.push(true)); + e.on('Health.killed', () => killed.push(true)); h.update(-999); expect(killed.length).toBe(1); }); @@ -182,10 +182,10 @@ describe('Health', () => { it('kill() does not emit killed twice for already killed entity', () => { const w = world(); const e = w.createEntity(); - e.add('health', new Health({ value: 10, min: 0 })); + e.add(new Health({ value: 10, min: 0 })); const h = e.get(Health)!; const killed: unknown[] = []; - e.on('Health(health).killed', () => killed.push(true)); + e.on('Health.killed', () => killed.push(true)); h.update(-999); h.update(-999); expect(killed.length).toBe(1); diff --git a/test/common/rpg/world.test.ts b/test/common/rpg/world.test.ts index 88b93da..8e71bdb 100644 --- a/test/common/rpg/world.test.ts +++ b/test/common/rpg/world.test.ts @@ -53,7 +53,7 @@ describe('World — entity management', () => { constructor() { super({}); } override onRemove() { removed.push('ok'); } } - e.add('t', new Tracked()); + e.add(new Tracked()); world.destroyEntity(e); expect(world.getEntity('e')).toBeUndefined(); expect(removed).toEqual(['ok']); @@ -73,39 +73,39 @@ describe('Entity — components', () => { it('add / get by key', () => { const world = new World(); const e = world.createEntity(); - e.add('counter', new Counter(5)); + e.add(new Counter(5), 'counter'); expect(e.get('counter')?.n).toBe(5); }); it('get by class', () => { const world = new World(); const e = world.createEntity(); - e.add('c', new Counter(3)); + e.add(new Counter(3)); expect(e.get(Counter)?.n).toBe(3); }); it('get by class and key', () => { const world = new World(); const e = world.createEntity(); - e.add('a', new Counter(1)); - e.add('b', new Counter(2)); + e.add(new Counter(1), 'a'); + e.add(new Counter(2), 'b'); expect(e.get(Counter, 'b')?.n).toBe(2); }); it('get by class and filter', () => { const world = new World(); const e = world.createEntity(); - e.add('a', new Counter(10)); - e.add('b', new Counter(20)); + e.add(new Counter(10)); + e.add(new Counter(20)); expect(e.get(Counter, c => c.n === 20)?.n).toBe(20); }); it('getAll returns all matching components', () => { const world = new World(); const e = world.createEntity(); - e.add('a', new Counter(1)); - e.add('b', new Counter(2)); - e.add('t', new Tag()); + e.add(new Counter(1)); + e.add(new Counter(2)); + e.add(new Tag()); const all = e.getAll(Counter); expect(all.length).toBe(2); expect(all.map(c => c.n).sort()).toEqual([1, 2]); @@ -114,7 +114,7 @@ describe('Entity — components', () => { it('has by key / by class / by class+key', () => { const world = new World(); const e = world.createEntity(); - e.add('c', new Counter()); + e.add(new Counter(), 'c'); expect(e.has('c')).toBeTrue(); expect(e.has('missing')).toBeFalse(); expect(e.has(Counter)).toBeTrue(); @@ -131,7 +131,7 @@ describe('Entity — components', () => { constructor() { super({}); } override onRemove() { removed.push(true); } } - e.add('r', new R()); + e.add(new R(), 'r'); e.remove('r'); expect(removed).toEqual([true]); expect(e.has('r')).toBeFalse(); @@ -141,7 +141,7 @@ describe('Entity — components', () => { const world = new World(); const e = world.createEntity(); const c = new Counter(); - e.add('c', c); + e.add(c, 'c'); e.remove(c); expect(e.has('c')).toBeFalse(); }); @@ -155,8 +155,8 @@ describe('Entity — components', () => { override onAdd() { events.push(`add:${this.state.id}`); } override onRemove() { events.push(`remove:${this.state.id}`); } } - e.add('k', new Ev('a')); - e.add('k', new Ev('b')); + e.add(new Ev('a'), 'k'); + e.add(new Ev('b'), 'k'); expect(events).toEqual(['add:a', 'remove:a', 'add:b']); }); @@ -164,7 +164,7 @@ describe('Entity — components', () => { const world = new World(); const e = world.createEntity('me'); const c = new Counter(); - e.add('mykey', c); + e.add(c, 'mykey'); expect(c.entity).toBe(e); expect(c.key).toBe('mykey'); }); @@ -175,8 +175,8 @@ describe('Entity — clone', () => { const world = new World(); const e = world.createEntity(); const orig = new Counter(7); - e.add('c', orig); - const clone = e.clone('d', orig); + e.add(orig, 'c'); + const clone = e.clone(orig, 'd'); clone.inc(); expect(orig.n).toBe(7); expect(clone.n).toBe(8); @@ -191,9 +191,9 @@ describe('Entity — clone', () => { override onAdd() { added.push(true); } } const a = new A(); - e.add('a', a); + e.add(a, 'a'); added.length = 0; - e.clone('b', a); + e.clone(a, 'b'); expect(added).toEqual([true]); }); }); @@ -202,7 +202,7 @@ describe('World — cloneEntity', () => { it('produces independent deep copy', () => { const world = new World(); const src = world.createEntity('src'); - src.add('c', new Counter(5)); + src.add(new Counter(5)); const copy = world.cloneEntity(src, 'copy'); copy.get(Counter)!.inc(); expect(src.get(Counter)!.n).toBe(5); @@ -216,8 +216,8 @@ describe('World — query', () => { const a = world.createEntity('a'); const b = world.createEntity('b'); world.createEntity('c'); - a.add('c', new Counter()); - b.add('c', new Counter()); + a.add(new Counter()); + b.add(new Counter()); const found = [...world.query(Counter)].map(([e]) => e.id); expect(found.sort()).toEqual(['a', 'b']); }); @@ -226,9 +226,9 @@ describe('World — query', () => { const world = new World(); const a = world.createEntity('a'); const b = world.createEntity('b'); - a.add('c', new Counter()); - a.add('t', new Tag()); - b.add('c', new Counter()); + a.add(new Counter()); + a.add(new Tag()); + b.add(new Counter()); const found = [...world.query(Counter, Tag)].map(([e]) => e.id); expect(found).toEqual(['a']); });