174 lines
5.8 KiB
TypeScript
174 lines
5.8 KiB
TypeScript
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<EquippableState> {
|
|
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<string, SlotRecord>;
|
|
}
|
|
|
|
type SlotDefinition =
|
|
| string // generic slot
|
|
| { slotName: string; type?: string }; // typed slot
|
|
|
|
export type SlotInput = SlotDefinition | SlotDefinition[];
|
|
|
|
@component
|
|
export class Equipment extends Component<EquipmentState> {
|
|
#cachedVars: RPGVariables | null = null;
|
|
|
|
constructor(...slots: SlotInput[]) {
|
|
const record: Record<string, SlotRecord> = {};
|
|
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;
|
|
}
|
|
}
|