import { Component, Entity } from "../core/world"; import type { RPGVariables } from "../types"; import { action, component, ComponentTag, getComponentTags } from "../utils/decorators"; 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: Record; } type SlotDefinition = | string // generic slot | { slotName: string; type?: string }; // typed slot export type SlotInput = SlotDefinition | SlotDefinition[]; @component export class Equipment extends Component { #cachedVars: RPGVariables | null = null; constructor(...slots: SlotInput[]) { const record: Record = {}; for (const s of slots.flat()) { const slotName = typeof s === 'string' ? s : s.slotName; const type = typeof s === 'object' && s.type ? s.type : null; record[slotName] = { slotName, type, itemId: null, appliedEffectKeys: [] }; } super({ slots: record }); } #slot(slotName: string): SlotRecord | undefined { return this.state.slots[slotName]; } /** ItemId in the named slot, or null if empty. */ 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. * Returns `null` if no compatible empty slot exists. */ findCompatibleSlot(slotType: string): string | null { let genericFallback: string | null = null; for (const slot of Object.values(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 Object.values(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; let id = 0; for (const [key, component] of itemEntity) { if (!getComponentTags(component).has(ComponentTag.Equippable)) continue; const effectKey = `__equip_${slotName}_${key}_${id++}`; this.entity.clone(component, effectKey); 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 Object.values(this.state.slots)) { if (itemId) { result[slotName] = itemId; } } this.#cachedVars = result; return result; } }