1
0
Fork 0
tsgames/src/common/rpg/components/equipment.ts

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;
}
}