diff --git a/src/common/rpg/components/equipment.ts b/src/common/rpg/components/equipment.ts new file mode 100644 index 0000000..fe69e59 --- /dev/null +++ b/src/common/rpg/components/equipment.ts @@ -0,0 +1,164 @@ +import { component } from "../core/registry"; +import { Component, COMPONENT_STATE } from "../core/world"; +import { action } from "../utils/decorators"; +import type { RPGVariables } from "../types"; +import { Effect } from "./effect"; + +// ── Equippable ──────────────────────────────────────────────────────────────── + +interface EquippableState { + slotType: string; // e.g. 'weapon', 'armor', 'accessory' +} + +/** + * Add to an item entity to make it equippable. + * `slotType` must match the target slot's type; slots with no type accept anything. + */ +@component +export class Equippable extends Component { + constructor(slotType: string) { + super({ slotType }); + } + + get slotType(): string { return this.state.slotType; } +} + +// ── Equipment ───────────────────────────────────────────────────────────────── + +interface SlotRecord { + slotName: string; + type: string | null; // null = generic (accepts any Equippable) + itemId: string | null; + appliedEffectKeys: string[]; +} + +interface EquipmentState { + slots: SlotRecord[]; +} + +export type SlotInput = + | string // generic slot + | { slotName: string; type?: string }; // typed slot + +@component +export class Equipment extends Component { + #cachedVars: RPGVariables | null = null; + + constructor(slots: SlotInput[]) { + super({ + slots: slots.map(s => { + const slotName = typeof s === 'string' ? s : s.slotName; + const type = typeof s === 'object' && s.type ? s.type : null; + return { slotName, type, itemId: null, appliedEffectKeys: [] }; + }), + }); + } + + #slot(slotName: string): SlotRecord | undefined { + return this.state.slots.find(s => s.slotName === slotName); + } + + /** ItemId in the named slot, or null if empty. */ + getItem(slotName: string): string | null { + return this.#slot(slotName)?.itemId ?? null; + } + + /** + * Find the first empty slot compatible with `slotType`. + * Typed slots that match take priority over generic (untyped) slots. + * Returns `null` if no compatible empty slot exists. + */ + findCompatibleSlot(slotType: string): string | null { + let genericFallback: string | null = null; + for (const slot of this.state.slots) { + if (slot.itemId !== null) continue; + if (slot.type === null) { genericFallback ??= slot.slotName; continue; } + if (slot.type === slotType) return slot.slotName; + } + return genericFallback; + } + + /** All currently equipped `{ slotName, itemId }` pairs. */ + getEquipped(): { slotName: string; itemId: string }[] { + return this.state.slots + .filter((s): s is SlotRecord & { itemId: string } => s.itemId !== null) + .map(({ slotName, itemId }) => ({ slotName, itemId })); + } + + /** + * Equip an item into a named slot. + * + * - The item entity must have an `Equippable` component. + * - If the slot has a type, `Equippable.slotType` must match. + * - If the slot is occupied the existing item is unequipped first. + * - All `Effect` components on the item entity are cloned onto the owner. + */ + @action + equip({ slotName, itemId }: { slotName: string; itemId: string }): boolean { + const slot = this.#slot(slotName); + if (!slot) return false; + + const itemEntity = this.entity.world.getEntity(itemId); + if (!itemEntity) return false; + + const equippable = itemEntity.get(Equippable); + if (!equippable) return false; + + if (slot.type !== null && equippable.slotType !== slot.type) return false; + + this.unequip(slotName); + + slot.itemId = itemId; + this.#cachedVars = null; + + for (const [key, component] of itemEntity) { + if (!(component instanceof Effect)) continue; + const clone = Object.create(Effect.prototype) as Effect; + (clone as unknown as { state: unknown }).state = + structuredClone(component[COMPONENT_STATE]()); + const effectKey = `__equip_${slotName}_${key}`; + this.entity.add(effectKey, clone); + slot.appliedEffectKeys.push(effectKey); + } + + this.emit('equip', { slotName, itemId }); + return true; + } + + /** + * Remove the item from a named slot, reversing all stat effects. + * Returns `false` if the slot is empty or does not exist. + */ + @action + unequip(slotName: string): boolean { + const slot = this.#slot(slotName); + if (!slot || slot.itemId === null) return false; + + const itemId = slot.itemId; + this.#removeEffects(slot); + slot.itemId = null; + this.#cachedVars = null; + + this.emit('unequip', { slotName, itemId }); + return true; + } + + #removeEffects(slot: SlotRecord): void { + for (const key of slot.appliedEffectKeys) { + this.entity.remove(key); + } + slot.appliedEffectKeys = []; + } + + override getVariables(): RPGVariables { + if (this.#cachedVars) return this.#cachedVars; + + const result: RPGVariables = {}; + for (const { slotName, itemId } of this.state.slots) { + result[slotName] = itemId ?? ''; + } + this.#cachedVars = result; + + return result; + } +}