From 5c46a531fa97800bf6763c37fb26f013ce1e8288 Mon Sep 17 00:00:00 2001 From: Pabloader Date: Fri, 8 May 2026 15:53:39 +0000 Subject: [PATCH] Improvements and refactor for real-world usage --- src/common/rpg/components/combat.ts | 8 +- src/common/rpg/components/equipment.ts | 11 +- src/common/rpg/components/experience.ts | 11 +- src/common/rpg/components/inventory.ts | 7 +- src/common/rpg/components/name.ts | 5 + src/common/rpg/core/world.ts | 51 +++++-- src/common/rpg/systems/combat.ts | 31 ++-- src/games/playground/index.tsx | 190 ++++++++++++++++++++++-- test/common/rpg/equipment.test.ts | 6 +- test/common/rpg/inventory.test.ts | 2 +- 10 files changed, 272 insertions(+), 50 deletions(-) create mode 100644 src/common/rpg/components/name.ts diff --git a/src/common/rpg/components/combat.ts b/src/common/rpg/components/combat.ts index 0d374d3..ba57596 100644 --- a/src/common/rpg/components/combat.ts +++ b/src/common/rpg/components/combat.ts @@ -3,10 +3,14 @@ import { component } from "../utils/decorators"; import { Stat } from "./stat"; @component -export class Defense extends Stat<{ damageType: string }> { } +export class Defense extends Stat<{ damageType?: string }> { } @component -export class Damage extends Stat<{ damageType: string, minDamage?: number, variance?: number }> { } +export class Damage extends Stat<{ damageType?: string, minDamage?: number, variance?: number }> { + get variance(): number { + return this.state.variance ?? 0; + } +} @component export class Crit extends Stat<{ chance: number }> { } diff --git a/src/common/rpg/components/equipment.ts b/src/common/rpg/components/equipment.ts index 5ffe963..f3743a2 100644 --- a/src/common/rpg/components/equipment.ts +++ b/src/common/rpg/components/equipment.ts @@ -1,4 +1,4 @@ -import { Component } from "../core/world"; +import { Component, Entity } from "../core/world"; import type { RPGVariables } from "../types"; import { action, component, ComponentTag, getComponentTags } from "../utils/decorators"; import { Effect } from "./effect"; @@ -60,10 +60,17 @@ export class Equipment extends Component { } /** ItemId in the named slot, or null if empty. */ - getItem(slotName: string): string | null { + getItemId(slotName: string): string | null { return this.#slot(slotName)?.itemId ?? null; } + getItem(slotName: string): Entity | undefined { + const id = this.getItemId(slotName); + if (!id) return undefined; + + return this.entity.world.getEntity(id); + } + /** * Find the first empty slot compatible with `slotType`. * Typed slots that match take priority over generic (untyped) slots. diff --git a/src/common/rpg/components/experience.ts b/src/common/rpg/components/experience.ts index 32dcd8d..2579c41 100644 --- a/src/common/rpg/components/experience.ts +++ b/src/common/rpg/components/experience.ts @@ -19,6 +19,11 @@ function xpForStep(spec: ThresholdSpec, level: number): number | null { return Math.floor(spec.base * Math.pow(spec.factor, level - 1)); } +export interface LevelUpEvent { + level: number; + prev: number; +} + @component export class Experience extends Component<{ xp: number; @@ -39,9 +44,13 @@ export class Experience extends Component<{ /** XP accumulated within the current level. */ @variable get xpInLevel(): number { return this.state.xp - this.state.xpAtLevel; } + get xpForNext(): number | null { + return xpForStep(this.state.spec, this.state.level); + } + /** XP remaining until the next level, or `null` at max level. */ get xpToNext(): number | null { - const needed = xpForStep(this.state.spec, this.state.level); + const needed = this.xpForNext; return needed === null ? null : needed - this.xpInLevel; } diff --git a/src/common/rpg/components/inventory.ts b/src/common/rpg/components/inventory.ts index 4205daf..e064b97 100644 --- a/src/common/rpg/components/inventory.ts +++ b/src/common/rpg/components/inventory.ts @@ -193,11 +193,11 @@ export class Inventory extends Component { * `slotName` specifies the equipment body slot (otherwise auto-detected by item type). */ @action - equip(arg: string | { itemId?: string; slotId?: SlotId; slotName?: string }): boolean { + equip(arg: string | { itemId?: string; slotId?: SlotId; slotName?: string } | Entity): boolean { const resolved = this.#resolveItem(arg); if (!resolved) return false; const { itemId, slotId } = resolved; - const slotName = typeof arg === 'object' ? arg.slotName : undefined; + const slotName = typeof arg === 'object' && 'slotName' in arg ? arg.slotName : undefined; if (this.getAmount(itemId, slotId) === 0) { console.warn(`[Inventory] equip: item '${itemId}' not in inventory`); @@ -262,9 +262,10 @@ export class Inventory extends Component { } /** Resolve an item reference, deriving `itemId` from slot contents if only `slotId` is given. */ - #resolveItem(arg?: string | { itemId?: string; slotId?: SlotId }): { itemId: string; slotId?: SlotId } | null { + #resolveItem(arg?: string | { itemId?: string; slotId?: SlotId } | Entity): { itemId: string; slotId?: SlotId } | null { if (!arg) return null; if (typeof arg === 'string') return { itemId: arg }; + if (arg instanceof Entity) return { itemId: arg.id }; if (arg.slotId !== undefined) { const contents = this.getSlotContents(arg.slotId); diff --git a/src/common/rpg/components/name.ts b/src/common/rpg/components/name.ts new file mode 100644 index 0000000..c6976bc --- /dev/null +++ b/src/common/rpg/components/name.ts @@ -0,0 +1,5 @@ +import { Component } from "../core/world"; +import { component } from "../utils/decorators"; + +@component +export class Name extends Component { } diff --git a/src/common/rpg/core/world.ts b/src/common/rpg/core/world.ts index 6ca6daa..a2fa476 100644 --- a/src/common/rpg/core/world.ts +++ b/src/common/rpg/core/world.ts @@ -4,15 +4,15 @@ import { ACTION_KEYS, getComponentName, STATE_KEYS, VARIABLE_KEYS } from '../uti interface EntityEvent { target: Entity; - data?: T; + data: T; } interface WorldEvent { - target: World; - data?: T; + target: World | Entity; + data: T; } -type EntityEventHandler = (event: EntityEvent) => void; -type WorldEventHandler = (event: WorldEvent) => void; +type EntityEventHandler = (event: EntityEvent) => void; +type WorldEventHandler = (event: WorldEvent) => void; export type EvalContext = Entity | World; @@ -105,6 +105,7 @@ type ComponentFilter = (component: T) => boolean; export class Entity { readonly #components = new Map(); + #destroyed = false; constructor( readonly id: string, @@ -112,6 +113,7 @@ export class Entity { ) { } add>(component: T, k?: string): T { + if (this.#destroyed) throw new Error('Entity has been destroyed'); const key = k ?? Symbol(); if (component == null) { @@ -127,6 +129,7 @@ export class Entity { } clone>(component: T, key: string): T { + if (this.#destroyed) throw new Error('Entity has been destroyed'); const clone = Object.create(component.constructor.prototype) as T; (clone as unknown as { state: unknown }).state = structuredClone(component.state); return this.add(clone, key); @@ -136,6 +139,7 @@ export class Entity { get>(ctor: Class, key?: string): T | undefined; get>(ctor: Class, filter: ComponentFilter): T | undefined; get>(ctorOrKey: Class | string, key?: string | ComponentFilter): T | undefined { + if (this.#destroyed) throw new Error('Entity has been destroyed'); if (typeof ctorOrKey === 'string') { return this.#components.get(ctorOrKey) as T | undefined; } @@ -143,7 +147,7 @@ export class Entity { const c = this.#components.get(key); return c instanceof ctorOrKey ? c as T : undefined; } - if (!key) { + if (!key) { for (const [k, c] of this.#components) { // prefer registered without key if (typeof k !== 'symbol') continue; @@ -162,6 +166,7 @@ export class Entity { } getAll>(ctor: Class): T[] { + if (this.#destroyed) throw new Error('Entity has been destroyed'); const result: T[] = []; for (const c of this.#components.values()) { if (c instanceof ctor) result.push(c as T); @@ -174,6 +179,7 @@ export class Entity { has>(ctor: Class, key: string): boolean; has>(ctor: Class, filter: ComponentFilter): boolean; has>(ctorOrKey: Class | string, key?: string | ComponentFilter): boolean { + if (this.#destroyed) throw new Error('Entity has been destroyed'); if (typeof ctorOrKey === 'string') { return this.#components.has(ctorOrKey); } @@ -195,6 +201,7 @@ export class Entity { remove>(ctor: Class, key: string): void; remove>(ctor: Class, filter: ComponentFilter): void; remove>(ctorOrKey: Class | T | string, key?: string | ComponentFilter): void { + if (this.#destroyed) throw new Error('Entity has been destroyed'); if (typeof ctorOrKey === 'string') { this.#removeByKey(ctorOrKey); return; @@ -216,6 +223,7 @@ export class Entity { } removeAll>(ctor: Class, filter?: ComponentFilter): void { + if (this.#destroyed) throw new Error('Entity has been destroyed'); for (const [k, c] of this.#components) { if (!(c instanceof ctor)) continue; if (typeof filter === 'function' && !filter(c)) continue; @@ -230,22 +238,27 @@ export class Entity { } emit(event: string, data?: unknown): void { + if (this.#destroyed) throw new Error('Entity has been destroyed'); this.world.emit(this.id, event, data); } emitGlobal(event: string, data?: unknown): void { + if (this.#destroyed) throw new Error('Entity has been destroyed'); this.world.emitGlobal(event, data); } - on(event: string, handler: EntityEventHandler): () => void { + on(event: string, handler: EntityEventHandler): () => void { + if (this.#destroyed) throw new Error('Entity has been destroyed'); return this.world.on(this.id, event, handler); } off(event: string, handler: EntityEventHandler): void { + if (this.#destroyed) throw new Error('Entity has been destroyed'); this.world.off(this.id, event, handler); } - once(event: string, handler: EntityEventHandler): () => void { + once(event: string, handler: EntityEventHandler): () => void { + if (this.#destroyed) throw new Error('Entity has been destroyed'); return this.world.once(this.id, event, handler); } @@ -253,6 +266,8 @@ export class Entity { this.world.destroyEntity(this); } + get destroyed() { return this.#destroyed; } + /** @internal */ [Symbol.iterator](): IterableIterator<[string, Component]> { return this.#components.values().map(c => [c.key, c]); @@ -262,6 +277,7 @@ export class Entity { _destroy(): void { for (const c of this.#components.values()) c.onRemove(); this.#components.clear(); + this.#destroyed = true; } } @@ -382,18 +398,21 @@ export class World { } emit(entityId: string, event: string, data?: unknown): void { + console.debug(`Emitting event ${event} for entity ${entityId}, data:`, data); const entity = this.getEntity(entityId); if (!entity) return; this.#handlers.get(`${entityId}\0${event}`)?.forEach(h => h({ target: entity, data })); + this.#globalHandlers.get(event)?.forEach(h => h({ target: entity, data })); } emitGlobal(event: string, data?: unknown): void { + console.debug(`Emitting global event ${event}, data:`, data); this.#globalHandlers.get(event)?.forEach(h => h({ target: this, data })); } - on(event: string, handler: WorldEventHandler): () => void; - on(entityId: string, event: string, handler: EntityEventHandler): () => void; - on(arg1: string, arg2: WorldEventHandler | string, arg3?: EntityEventHandler): () => void { + on(event: string, handler: WorldEventHandler): () => void; + on(entityId: string, event: string, handler: EntityEventHandler): () => void; + on(arg1: string, arg2: WorldEventHandler | string, arg3?: EntityEventHandler): () => void { if (typeof arg2 === 'string') { return this.#addHandler(this.#handlers, `${arg1}\0${arg2}`, arg3!); } @@ -414,12 +433,12 @@ export class World { } } - once(event: string, handler: WorldEventHandler): () => void; - once(entityId: string, event: string, handler: EntityEventHandler): () => void; - once(arg1: string, arg2: WorldEventHandler | string, arg3?: EntityEventHandler): () => void { + once(event: string, handler: WorldEventHandler): () => void; + once(entityId: string, event: string, handler: EntityEventHandler): () => void; + once(arg1: string, arg2: WorldEventHandler | string, arg3?: EntityEventHandler): () => void { if (typeof arg2 === 'string') { const original = arg3!; - const wrapped: EntityEventHandler = data => { this.#onceWrappers.delete(original); unsub(); original(data); }; + const wrapped: EntityEventHandler = data => { this.#onceWrappers.delete(original); unsub(); original(data); }; this.#onceWrappers.set(original, wrapped); const unsub = this.on(arg1, arg2, wrapped); return () => { this.#onceWrappers.delete(original); unsub(); }; @@ -431,7 +450,7 @@ export class World { return () => { this.#onceWrappers.delete(original); unsub(); }; } - #addHandler( + #addHandler | EntityEventHandler>( map: Map>, key: string, handler: T, diff --git a/src/common/rpg/systems/combat.ts b/src/common/rpg/systems/combat.ts index d542eba..b8e05c6 100644 --- a/src/common/rpg/systems/combat.ts +++ b/src/common/rpg/systems/combat.ts @@ -6,6 +6,13 @@ import { System, World } from "../core/world"; let hitEffectCounter = 0; +export interface HitEvent { + attackerId: string; + sourceId: string | null; + amount: number; + damageType?: string; +} + export class CombatSystem extends System { override update(world: World) { let random: Random | undefined; @@ -19,8 +26,8 @@ export class CombatSystem extends System { } let damageSum = 0; - const hitEvents: { attackerId: string; sourceId: string | null; amount: number; damageType: string }[] = []; - let lastHit: { attackerId: string; sourceId: string | null } | null = null; + const hitEvents: HitEvent[] = []; + let lastHit: HitEvent | null = null; for (const attack of target.getAll(Attacked)) { const { attackerId, sourceId } = attack.state; @@ -51,7 +58,7 @@ export class CombatSystem extends System { if (!random) { random = getWorldRandom(world); } - const variedDamage = random.use(r => r.nextInt(-variance, variance)); + const variedDamage = random.use(r => r.nextInt(-variance, variance + 1)); if (variedDamage > 0) { damageAmount += variedDamage; @@ -71,9 +78,13 @@ export class CombatSystem extends System { } } - const defense = target.get(Defense, (c) => c.state.damageType === damageType); - if (defense) { - damageAmount -= defense.value; + const typeDefense = target.get(Defense, (c) => c.state.damageType === damageType); + if (typeDefense) { + damageAmount -= typeDefense.value; + } + const generalDefense = target.get(Defense, (c) => c.state.damageType == null); + if (generalDefense) { + damageAmount -= generalDefense.value; } damageAmount = Math.max(0, minDamage, damageAmount); @@ -99,8 +110,8 @@ export class CombatSystem extends System { } damageSum += damageAmount; - hitEvents.push({ attackerId, sourceId, amount: damageAmount, damageType }); - lastHit = { attackerId, sourceId }; + lastHit = { attackerId, sourceId, amount: damageAmount, damageType }; + hitEvents.push(lastHit); } if (damageSum === 0) continue; @@ -109,11 +120,11 @@ export class CombatSystem extends System { health.update(-damageSum); for (const info of hitEvents) { - target.emit('hit', info); + target.emit('Combat.hit', info); } if (wasAlive && health.value <= 0 && lastHit) { - target.emit('kill', lastHit); + target.emit('Combat.killed', lastHit); } } } diff --git a/src/games/playground/index.tsx b/src/games/playground/index.tsx index 1ee9652..0b7c0af 100644 --- a/src/games/playground/index.tsx +++ b/src/games/playground/index.tsx @@ -1,17 +1,183 @@ -import { SeededRandom, ANIMAL_NAME_OPTIONS, FEMALE_NAME_OPTIONS, MALE_NAME_OPTIONS } from "@common/random"; +import { AlignHorizontal, TextDisplay, TextRegion, type ColorLike } from "@common/display/text"; +import { gameLoop } from "@common/game"; +import Input from "@common/input"; +import { Attacked, Damage } from "@common/rpg/components/combat"; +import { Equipment } from "@common/rpg/components/equipment"; +import { Experience, type LevelUpEvent } from "@common/rpg/components/experience"; +import { Inventory } from "@common/rpg/components/inventory"; +import { Item, Items } from "@common/rpg/components/item"; +import { Name } from "@common/rpg/components/name"; +import { getWorldRandom } from "@common/rpg/components/random"; +import { Health } from "@common/rpg/components/stat"; +import { Entity, World } from "@common/rpg/core/world"; +import { CombatSystem, type HitEvent } from "@common/rpg/systems/combat"; +import { capitalize } from "@common/utils"; -export default async function main() { - const rnd = new SeededRandom(42); +const createPlayer = (world: World, weapon: Entity): Entity => { + const player = world.createEntity(); + player.add(new Health({ value: 100, max: 100 })); + const inv = player.add(new Inventory()); + player.add(new Equipment('weapon')); + player.add(new Experience({ base: 50, factor: 1.5 })); + player.add(new Name("Player")); - for (let i = 0; i < 10; i++) { - console.log(rnd.nextName(MALE_NAME_OPTIONS)); + inv.add(weapon); + inv.equip(weapon); + + return player; +} + +const createEnemy = (world: World, level: number): Entity => { + const enemy = world.createEntity(); + const inv = enemy.add(new Inventory()); + enemy.add(new Equipment('weapon')); + + const random = getWorldRandom(world); + enemy.add(new Name(random.nextName({ maxLength: 7 }) + ' The Goblin')); + + const hp = random.nextInt(level * 10, level * 20); + enemy.add(new Health({ value: hp, max: hp })); + + const attack = random.nextInt(level * 3, level * 6); + + const weaponName = random.choice(['sword', 'axe', 'dagger', 'mace']); + const weapon = createWeapon(world, weaponName, attack, level); + + inv.add(weapon); + inv.equip(weapon); + + return enemy; +} + +const createWeapon = (world: World, id: string, damage: number, variance: number): Entity => { + const name = id.split(/[-_ ]+/).map(capitalize).join(' '); + const weapon = Items.register(world, `${id}_*`, name, { equippable: { slotType: 'weapon' } }); + + weapon.add(new Damage({ value: damage, variance })); + + return weapon; +} + +const drawEntity = (display: TextDisplay, entity: Entity, x: number, y: number, anchorH: AlignHorizontal) => { + const { value: hp, max: maxHp } = entity.get(Health)!; + let text = `HP: ${hp}/${maxHp}`; + + const xp = entity.get(Experience); + if (xp) { + text += `\nLevel: ${xp.level}`; + text += `\nXP: ${xp.xp}/${xp.xpForNext}`; } - console.log('------------------------------------'); - for (let i = 0; i < 10; i++) { - console.log(rnd.nextName(FEMALE_NAME_OPTIONS)); - } - console.log('------------------------------------'); - for (let i = 0; i < 10; i++) { - console.log(rnd.nextName(ANIMAL_NAME_OPTIONS)); + + const title = entity.get(Name)?.state ?? entity.id; + + display.drawStringInBox(text, x, y, { title, anchorH }); + + const weapon = entity.get(Equipment)!.getItem('weapon'); + + if (weapon) { + const { value: damage, variance } = weapon.get(Damage)!; + const minDamage = damage - variance; + const maxDamage = damage + variance; + display.drawStringInBox(`Damage: ${minDamage}-${maxDamage}`, x, y + (xp ? 5 : 3), { title: weapon.get(Item)!.name, anchorH }); } } + +export default gameLoop(() => { + const world = new World(); + + world.addSystem(new CombatSystem()); + const display = new TextDisplay(); + + const fists = createWeapon(world, 'fists', 7, 2); + const player = createPlayer(world, fists); + const enemy = createEnemy(world, 1); + + const log: TextRegion[] = []; + const state = { + world, + display, + player, + enemy, + needUpdate: true, + blocked: false, + log, + addLog(text: string, color?: ColorLike) { + log.push(new TextRegion(text, color)); + if (log.length > 10) { + log.shift(); + } + state.needUpdate = true; + } + }; + + world.on('Combat.hit', ({ target, data }) => { + if (target instanceof Entity) { + const targetName = target === player ? "Player" : target.get(Name)?.state ?? target.id; + state.addLog(`${targetName} hit by ${data.amount}`); + } + }); + + world.on('Combat.killed', ({ target }) => { + if (target instanceof Entity) { + target.destroy(); + player.get(Experience)!.award(10); + state.addLog("Enemy killed!"); + } + }); + + player.on('Experience.levelup', ({ data }) => { + const health = player.get(Health)!; + + health.applyModifier(Math.round(health.max! * 0.1), 'max'); + health.set(health.max!); + + state.addLog(`Player leveled up to level ${data.level}`); + }) + + return state; +}, (_dt, state) => { + if (Input.isReleased(Input.KeyCode.SPACE, Input.KeyCode.MOUSE_LEFT) && !state.blocked) { + const weapon = state.player.get(Equipment)!.getItem('weapon')!; + state.enemy.add(new Attacked({ attackerId: state.player.id, sourceId: weapon.id })); + state.blocked = true; + + setTimeout(() => { + if (state.enemy.destroyed) { + state.enemy = createEnemy(state.world, state.player.get(Experience)!.level); + } else { + const enemyWeapon = state.enemy.get(Equipment)!.getItem('weapon')!; + state.player.add(new Attacked({ attackerId: state.enemy.id, sourceId: enemyWeapon.id })); + } + state.blocked = false; + state.needUpdate = true; + }, 500); + + state.needUpdate = true; + } + + if (state.needUpdate) { + state.world.update(1); + + state.display.fillBox( + 0, + 0, + state.display.width, + state.display.height, + ' ', + ); + + drawEntity(state.display, state.player, 0, 0, AlignHorizontal.LEFT); + if (!state.enemy.destroyed) { + drawEntity(state.display, state.enemy, state.display.width - 1, 0, AlignHorizontal.RIGHT); + } + + for (let i = 0; i < state.log.length; i++) { + const entry = state.log[i]; + state.display.setRegion(entry, 0, state.display.height - state.log.length + i); + } + + state.needUpdate = false; + } + + state.display.update(); +}); \ No newline at end of file diff --git a/test/common/rpg/equipment.test.ts b/test/common/rpg/equipment.test.ts index d87cf66..802e311 100644 --- a/test/common/rpg/equipment.test.ts +++ b/test/common/rpg/equipment.test.ts @@ -26,7 +26,7 @@ describe('Equipment — equip', () => { const player = makePlayer(w); const result = player.get(Equipment)!.equip({ slotName: 'weapon', itemId: 'sword' }); expect(result).toBeTrue(); - expect(player.get(Equipment)!.getItem('weapon')).toBe('sword'); + expect(player.get(Equipment)!.getItemId('weapon')).toBe('sword'); }); it('returns false for unknown slot', () => { @@ -73,7 +73,7 @@ describe('Equipment — equip', () => { const eq = player.get(Equipment)!; eq.equip({ slotName: 'weapon', itemId: 'sword1' }); eq.equip({ slotName: 'weapon', itemId: 'sword2' }); - expect(eq.getItem('weapon')).toBe('sword2'); + expect(eq.getItemId('weapon')).toBe('sword2'); }); it("emits 'equip' event", () => { @@ -149,7 +149,7 @@ describe('Equipment — unequip', () => { const eq = player.get(Equipment)!; eq.equip({ slotName: 'weapon', itemId: 'sword' }); eq.unequip('weapon'); - expect(eq.getItem('weapon')).toBeNull(); + expect(eq.getItemId('weapon')).toBeNull(); }); it('unequip returns false on empty slot', () => { diff --git a/test/common/rpg/inventory.test.ts b/test/common/rpg/inventory.test.ts index 740e4ba..9217132 100644 --- a/test/common/rpg/inventory.test.ts +++ b/test/common/rpg/inventory.test.ts @@ -230,7 +230,7 @@ describe('Inventory — equip', () => { const inv = player.get(Inventory)!; inv.add({ itemId: 'sword', amount: 1 }); expect(inv.equip({ itemId: 'sword' })).toBeTrue(); - expect(player.get(Equipment)!.getItem('weapon')).toBe('sword'); + expect(player.get(Equipment)!.getItemId('weapon')).toBe('sword'); }); });