1
0
Fork 0

Equipment

This commit is contained in:
Pabloader 2026-04-29 12:24:11 +00:00
parent 26e1fb2675
commit f766aee071
1 changed files with 164 additions and 0 deletions

View File

@ -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<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: SlotRecord[];
}
export type SlotInput =
| string // generic slot
| { slotName: string; type?: string }; // typed slot
@component
export class Equipment extends Component<EquipmentState> {
#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;
}
}