Equipment
This commit is contained in:
parent
26e1fb2675
commit
f766aee071
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue