Infinite inventory
This commit is contained in:
parent
8b93a732a7
commit
c2e9a597fa
|
|
@ -3,6 +3,7 @@ import { action } from "../utils/decorators";
|
|||
import { Component, type EvalContext } from "../core/world";
|
||||
import { component } from "../core/registry";
|
||||
import { Stackable, Usable } from "./item";
|
||||
import { Equipment, Equippable } from "./equipment";
|
||||
import { resolveVariables } from "../utils/variables";
|
||||
|
||||
interface SlotRecord {
|
||||
|
|
@ -12,6 +13,8 @@ interface SlotRecord {
|
|||
}
|
||||
|
||||
interface InventoryState {
|
||||
infinite: boolean;
|
||||
nextSlotId: number; // auto-increment counter for infinite mode
|
||||
slots: SlotRecord[];
|
||||
}
|
||||
|
||||
|
|
@ -19,15 +22,35 @@ interface InventoryState {
|
|||
export class Inventory extends Component<InventoryState> {
|
||||
#cachedVars: RPGVariables | null = null;
|
||||
|
||||
constructor(slotDefs: Array<InventorySlotInput>) {
|
||||
/** Infinite inventory — grows on demand, no slot cap. */
|
||||
constructor();
|
||||
/** N generic slots, each limited only by item maxStack. */
|
||||
constructor(count: number);
|
||||
/** Named slots with optional per-slot stack limits (original behaviour). */
|
||||
constructor(slots: InventorySlotInput[]);
|
||||
constructor(input?: number | InventorySlotInput[]) {
|
||||
if (input === undefined) {
|
||||
super({ infinite: true, nextSlotId: 0, slots: [] });
|
||||
} else if (typeof input === 'number') {
|
||||
super({
|
||||
slots: slotDefs.map(def => {
|
||||
infinite: false,
|
||||
nextSlotId: 0,
|
||||
slots: Array.from({ length: input }, (_, i) => ({
|
||||
slotId: i, limit: undefined, contents: null,
|
||||
})),
|
||||
});
|
||||
} else {
|
||||
super({
|
||||
infinite: false,
|
||||
nextSlotId: 0,
|
||||
slots: input.map(def => {
|
||||
const slotId = typeof def === 'object' ? def.slotId : def;
|
||||
const limit = typeof def === 'object' ? def.limit : undefined;
|
||||
return { slotId, limit, contents: null };
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#slot(slotId: SlotId): SlotRecord | undefined {
|
||||
return this.state.slots.find(s => s.slotId === slotId);
|
||||
|
|
@ -56,6 +79,7 @@ export class Inventory extends Component<InventoryState> {
|
|||
return false;
|
||||
}
|
||||
|
||||
// ── Direct slot ───────────────────────────────────────────────────────
|
||||
if (slotId !== undefined) {
|
||||
const slot = this.#slot(slotId);
|
||||
if (!slot) return false;
|
||||
|
|
@ -65,45 +89,70 @@ export class Inventory extends Component<InventoryState> {
|
|||
return true;
|
||||
}
|
||||
|
||||
// Two-phase: pre-check then apply
|
||||
let remaining = amount;
|
||||
// ── Finite inventory: two-phase (check → apply) ───────────────────────
|
||||
if (!this.state.infinite) {
|
||||
let canFit = 0;
|
||||
for (const slot of this.state.slots) {
|
||||
if (slot.contents === null || slot.contents.itemId === itemId) {
|
||||
remaining -= Math.min(this.#roomFor(slot, itemId), remaining);
|
||||
if (remaining === 0) break;
|
||||
if (slot.contents === null || slot.contents.itemId === itemId)
|
||||
canFit += this.#roomFor(slot, itemId);
|
||||
}
|
||||
}
|
||||
if (remaining > 0) return false;
|
||||
if (canFit < amount) return false;
|
||||
|
||||
// Apply — fill existing slots for this item first, then empty ones
|
||||
remaining = amount;
|
||||
let remaining = amount;
|
||||
const slotIds: SlotId[] = [];
|
||||
for (const slot of this.state.slots) {
|
||||
if (slot.contents?.itemId === itemId) {
|
||||
if (slot.contents?.itemId === itemId && remaining > 0) {
|
||||
const take = Math.min(this.#roomFor(slot, itemId), remaining);
|
||||
slot.contents.amount += take;
|
||||
slot.contents!.amount += take;
|
||||
remaining -= take;
|
||||
slotIds.push(slot.slotId);
|
||||
if (remaining === 0) {
|
||||
this.emit('add', { itemId, amount, slotIds });
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const slot of this.state.slots) {
|
||||
if (slot.contents === null) {
|
||||
if (slot.contents === null && remaining > 0) {
|
||||
const take = Math.min(this.#capFor(slot, itemId), remaining);
|
||||
slot.contents = { itemId, amount: take };
|
||||
remaining -= take;
|
||||
slotIds.push(slot.slotId);
|
||||
if (remaining === 0) {
|
||||
}
|
||||
}
|
||||
this.emit('add', { itemId, amount, slotIds });
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Infinite inventory: fill existing slots, then grow ─────────────────
|
||||
let remaining = amount;
|
||||
const slotIds: SlotId[] = [];
|
||||
|
||||
for (const slot of this.state.slots) {
|
||||
if (slot.contents?.itemId === itemId && remaining > 0) {
|
||||
const take = Math.min(this.#roomFor(slot, itemId), remaining);
|
||||
if (take > 0) {
|
||||
slot.contents!.amount += take;
|
||||
remaining -= take;
|
||||
slotIds.push(slot.slotId);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const slot of this.state.slots) {
|
||||
if (slot.contents === null && remaining > 0) {
|
||||
const take = Math.min(this.#capFor(slot, itemId), remaining);
|
||||
slot.contents = { itemId, amount: take };
|
||||
remaining -= take;
|
||||
slotIds.push(slot.slotId);
|
||||
}
|
||||
}
|
||||
while (remaining > 0) {
|
||||
const newSlot: SlotRecord = { slotId: this.state.nextSlotId++, limit: undefined, contents: null };
|
||||
this.state.slots.push(newSlot);
|
||||
const take = Math.min(this.#capFor(newSlot, itemId), remaining);
|
||||
newSlot.contents = { itemId, amount: take };
|
||||
remaining -= take;
|
||||
slotIds.push(newSlot.slotId);
|
||||
}
|
||||
|
||||
return remaining === 0;
|
||||
this.emit('add', { itemId, amount, slotIds });
|
||||
return true;
|
||||
}
|
||||
|
||||
@action
|
||||
|
|
@ -127,27 +176,71 @@ export class Inventory extends Component<InventoryState> {
|
|||
let remaining = amount;
|
||||
const slotIds: SlotId[] = [];
|
||||
for (const slot of this.state.slots) {
|
||||
if (slot.contents?.itemId === itemId) {
|
||||
if (slot.contents?.itemId === itemId && remaining > 0) {
|
||||
const take = Math.min(slot.contents.amount, remaining);
|
||||
slot.contents.amount -= take;
|
||||
if (slot.contents.amount === 0) slot.contents = null;
|
||||
remaining -= take;
|
||||
slotIds.push(slot.slotId);
|
||||
if (remaining === 0) {
|
||||
}
|
||||
}
|
||||
|
||||
this.emit('remove', { itemId, amount, slotIds });
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Equip an item from this inventory onto the entity's `Equipment` component.
|
||||
* `slotId` specifies which inventory slot to equip from (otherwise any slot is used).
|
||||
* `slotName` specifies the equipment body slot (otherwise auto-detected by item type).
|
||||
*/
|
||||
@action
|
||||
equip(arg: string | { itemId?: string; slotId?: SlotId; slotName?: string }): boolean {
|
||||
const resolved = this.#resolveItem(arg);
|
||||
if (!resolved) return false;
|
||||
const { itemId, slotId } = resolved;
|
||||
const slotName = typeof arg === 'object' ? arg.slotName : undefined;
|
||||
|
||||
if (this.getAmount(itemId, slotId) === 0) {
|
||||
console.warn(`[Inventory] equip: item '${itemId}' not in inventory`);
|
||||
return false;
|
||||
}
|
||||
|
||||
@action
|
||||
async use(itemId?: string, ctx?: EvalContext): Promise<boolean> {
|
||||
if (!itemId) return false;
|
||||
const equipment = this.entity.get(Equipment);
|
||||
if (!equipment) {
|
||||
console.warn(`[Inventory] equip: entity has no Equipment component`);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.getAmount(itemId) === 0) {
|
||||
let resolvedSlot = slotName;
|
||||
if (resolvedSlot === undefined) {
|
||||
const equippable = this.entity.world.getEntity(itemId)?.get(Equippable);
|
||||
if (!equippable) {
|
||||
console.warn(`[Inventory] equip: item '${itemId}' has no Equippable component`);
|
||||
return false;
|
||||
}
|
||||
const found = equipment.findCompatibleSlot(equippable.slotType);
|
||||
if (!found) {
|
||||
console.warn(`[Inventory] equip: no compatible slot for '${itemId}'`);
|
||||
return false;
|
||||
}
|
||||
resolvedSlot = found;
|
||||
}
|
||||
|
||||
return equipment.equip({ slotName: resolvedSlot, itemId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Use an item from this inventory.
|
||||
* `slotId` specifies which inventory slot to use from (otherwise any slot is used).
|
||||
*/
|
||||
@action
|
||||
async use(arg?: string | { itemId?: string; slotId?: SlotId }, ctx?: EvalContext): Promise<boolean> {
|
||||
const resolved = this.#resolveItem(arg);
|
||||
if (!resolved) return false;
|
||||
const { itemId, slotId } = resolved;
|
||||
|
||||
if (this.getAmount(itemId, slotId) === 0) {
|
||||
console.warn(`[Inventory] use: item '${itemId}' not in inventory`);
|
||||
return false;
|
||||
}
|
||||
|
|
@ -164,13 +257,29 @@ export class Inventory extends Component<InventoryState> {
|
|||
return false;
|
||||
}
|
||||
|
||||
if (usable.consumeOnUse) {
|
||||
this.remove({ itemId, amount: 1 });
|
||||
if (usable.consumeOnUse) this.remove({ itemId, amount: 1, slotId });
|
||||
|
||||
await usable.use(ctx ?? this.context);
|
||||
return true;
|
||||
}
|
||||
|
||||
const resolvedCtx = ctx ?? { self: this.entity, world: this.entity.world };
|
||||
await usable.use(resolvedCtx);
|
||||
return true;
|
||||
/** Resolve an item reference, deriving `itemId` from slot contents if only `slotId` is given. */
|
||||
#resolveItem(arg?: string | { itemId?: string; slotId?: SlotId }): { itemId: string; slotId?: SlotId } | null {
|
||||
if (!arg) return null;
|
||||
if (typeof arg === 'string') return { itemId: arg };
|
||||
|
||||
if (arg.slotId !== undefined) {
|
||||
const contents = this.getSlotContents(arg.slotId);
|
||||
if (!contents) return null;
|
||||
if (arg.itemId && contents.itemId !== arg.itemId) return null;
|
||||
return { itemId: contents.itemId, slotId: arg.slotId };
|
||||
}
|
||||
|
||||
return arg.itemId ? { itemId: arg.itemId } : null;
|
||||
}
|
||||
|
||||
getSlotContents(slotId: SlotId): { itemId: string; amount: number } | null {
|
||||
return this.#slot(slotId)?.contents ?? null;
|
||||
}
|
||||
|
||||
getAmount(itemId: string, slotId?: SlotId): number {
|
||||
|
|
|
|||
Loading…
Reference in New Issue