Compare commits
No commits in common. "30531bcd528d6de0a3c3a3f1dd79fab311e90648" and "8b93a732a7e7c28c84d2ccdcd28a467a286d5982" have entirely different histories.
30531bcd52
...
8b93a732a7
|
|
@ -1,41 +0,0 @@
|
||||||
import { component } from "../core/registry";
|
|
||||||
import { Component } from "../core/world";
|
|
||||||
import { action, variable } from "../utils/decorators";
|
|
||||||
|
|
||||||
@component
|
|
||||||
export class Cooldown extends Component<{
|
|
||||||
remaining: number;
|
|
||||||
duration: number;
|
|
||||||
}> {
|
|
||||||
constructor(duration: number) {
|
|
||||||
super({ remaining: duration, duration });
|
|
||||||
}
|
|
||||||
|
|
||||||
@variable('.')
|
|
||||||
get ready(): boolean {
|
|
||||||
return this.state.remaining <= 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
@action
|
|
||||||
reset(duration?: number): void {
|
|
||||||
if (duration != null) this.state.duration = duration;
|
|
||||||
this.state.remaining = this.state.duration;
|
|
||||||
}
|
|
||||||
|
|
||||||
@action
|
|
||||||
clear(): void {
|
|
||||||
if (this.state.remaining <= 0) return;
|
|
||||||
this.state.remaining = 0;
|
|
||||||
this.emit('ready');
|
|
||||||
}
|
|
||||||
|
|
||||||
@action
|
|
||||||
update(dt: number): void {
|
|
||||||
if (this.state.remaining <= 0) return;
|
|
||||||
this.state.remaining -= dt;
|
|
||||||
if (this.state.remaining <= 0) {
|
|
||||||
this.state.remaining = 0;
|
|
||||||
this.emit('ready');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,83 +0,0 @@
|
||||||
import { component } from "../core/registry";
|
|
||||||
import { Component, type EvalContext } from "../core/world";
|
|
||||||
import { evaluateCondition } from "../utils/conditions";
|
|
||||||
import { action, variable } from "../utils/decorators";
|
|
||||||
import { Stat } from "./stat";
|
|
||||||
|
|
||||||
@component
|
|
||||||
export class Effect extends Component<{
|
|
||||||
targetStat: string; // component key, e.g. 'health'
|
|
||||||
targetField: 'value' | 'max' | 'min';
|
|
||||||
delta: number;
|
|
||||||
duration: number | null; // null = permanent until removed
|
|
||||||
remaining: number | null; // countdown in seconds; null for condition-based/permanent
|
|
||||||
condition: string | null; // keep effect while true; remove when it becomes false
|
|
||||||
}> {
|
|
||||||
/** True while the effect's delta is applied to the target stat. */
|
|
||||||
@variable('.') active: boolean = false;
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
targetStat: string,
|
|
||||||
delta: number,
|
|
||||||
targetField?: 'value' | 'max' | 'min',
|
|
||||||
duration?: number,
|
|
||||||
condition?: string,
|
|
||||||
) {
|
|
||||||
super({
|
|
||||||
targetStat,
|
|
||||||
targetField: targetField ?? 'value',
|
|
||||||
delta,
|
|
||||||
duration: duration ?? null,
|
|
||||||
remaining: duration ?? null,
|
|
||||||
condition: condition ?? null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
override onAdd(): void {
|
|
||||||
const stat = this.entity.get(Stat, this.state.targetStat);
|
|
||||||
if (stat) {
|
|
||||||
stat.applyModifier(this.state.delta, this.state.targetField);
|
|
||||||
}
|
|
||||||
this.active = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
override onRemove(): void {
|
|
||||||
const stat = this.entity.get(Stat, this.state.targetStat);
|
|
||||||
if (stat) {
|
|
||||||
stat.removeModifier(this.state.delta, this.state.targetField);
|
|
||||||
}
|
|
||||||
this.active = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
@action
|
|
||||||
reset(duration?: number): void {
|
|
||||||
if (duration != null) {
|
|
||||||
this.state.duration = duration;
|
|
||||||
}
|
|
||||||
this.state.remaining = this.state.duration;
|
|
||||||
this.active = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Mark as expired. EffectSystem will remove the component, which reverses the delta. */
|
|
||||||
@action
|
|
||||||
clear(): void {
|
|
||||||
this.state.remaining = 0;
|
|
||||||
this.active = false;
|
|
||||||
this.emit('expired');
|
|
||||||
}
|
|
||||||
|
|
||||||
update(dt: number, ctx?: EvalContext): void {
|
|
||||||
if (this.state.remaining != null) {
|
|
||||||
this.state.remaining -= dt;
|
|
||||||
if (this.state.remaining <= 0) {
|
|
||||||
this.state.remaining = 0;
|
|
||||||
this.clear();
|
|
||||||
}
|
|
||||||
} else if (this.state.condition != null) {
|
|
||||||
if (!evaluateCondition(this.state.condition, ctx ?? this.context)) {
|
|
||||||
this.clear();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// permanent effect (no duration, no condition): nothing to do
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,164 +0,0 @@
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,69 +0,0 @@
|
||||||
import { component } from "../core/registry";
|
|
||||||
import { Component } from "../core/world";
|
|
||||||
import { action, variable } from "../utils/decorators";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* How much XP is required to advance from level N to N+1.
|
|
||||||
*
|
|
||||||
* - `number[]` — explicit table; `values[0]` = XP for level 1→2.
|
|
||||||
* Max level = `values.length + 1`. Past the end the entity is at max level.
|
|
||||||
* - `{ base, factor }` — geometric: XP[N] = floor(base * factor^(N-1)).
|
|
||||||
* No max level.
|
|
||||||
*/
|
|
||||||
export type ThresholdSpec = number[] | { base: number; factor: number };
|
|
||||||
|
|
||||||
function xpForStep(spec: ThresholdSpec, level: number): number | null {
|
|
||||||
if (Array.isArray(spec)) {
|
|
||||||
const idx = level - 1;
|
|
||||||
return idx < spec.length ? spec[idx] : null;
|
|
||||||
}
|
|
||||||
return Math.floor(spec.base * Math.pow(spec.factor, level - 1));
|
|
||||||
}
|
|
||||||
|
|
||||||
@component
|
|
||||||
export class Experience extends Component<{
|
|
||||||
xp: number;
|
|
||||||
level: number;
|
|
||||||
xpAtLevel: number; // total XP accumulated when current level was reached
|
|
||||||
spec: ThresholdSpec;
|
|
||||||
}> {
|
|
||||||
constructor(spec: ThresholdSpec) {
|
|
||||||
super({ xp: 0, level: 1, xpAtLevel: 0, spec });
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Current level, starting at 1. Primary variable for conditions: `experience >= 5`. */
|
|
||||||
@variable get level(): number { return this.state.level; }
|
|
||||||
|
|
||||||
/** Total accumulated XP (never resets). */
|
|
||||||
@variable('.') get xp(): number { return this.state.xp; }
|
|
||||||
|
|
||||||
/** XP accumulated within the current level. */
|
|
||||||
@variable get xpInLevel(): number { return this.state.xp - this.state.xpAtLevel; }
|
|
||||||
|
|
||||||
/** XP remaining until the next level, or `null` at max level. */
|
|
||||||
get xpToNext(): number | null {
|
|
||||||
const needed = xpForStep(this.state.spec, this.state.level);
|
|
||||||
return needed === null ? null : needed - this.xpInLevel;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Progress toward the next level as a 0–1 fraction. `1` at max level. */
|
|
||||||
@variable get progress(): number {
|
|
||||||
const needed = xpForStep(this.state.spec, this.state.level);
|
|
||||||
if (needed === null) return 1;
|
|
||||||
return Math.min(this.xpInLevel / needed, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Add XP and emit `'levelup'` with `{ level, prev }` for each level gained. */
|
|
||||||
@action
|
|
||||||
award(xp: number): void {
|
|
||||||
this.state.xp += xp;
|
|
||||||
while (true) {
|
|
||||||
const needed = xpForStep(this.state.spec, this.state.level);
|
|
||||||
if (needed === null) break;
|
|
||||||
if (this.xpInLevel < needed) break;
|
|
||||||
this.state.xpAtLevel += needed;
|
|
||||||
const prev = this.state.level++;
|
|
||||||
this.emit('levelup', { level: this.state.level, prev });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -3,7 +3,6 @@ import { action } from "../utils/decorators";
|
||||||
import { Component, type EvalContext } from "../core/world";
|
import { Component, type EvalContext } from "../core/world";
|
||||||
import { component } from "../core/registry";
|
import { component } from "../core/registry";
|
||||||
import { Stackable, Usable } from "./item";
|
import { Stackable, Usable } from "./item";
|
||||||
import { Equipment, Equippable } from "./equipment";
|
|
||||||
import { resolveVariables } from "../utils/variables";
|
import { resolveVariables } from "../utils/variables";
|
||||||
|
|
||||||
interface SlotRecord {
|
interface SlotRecord {
|
||||||
|
|
@ -13,8 +12,6 @@ interface SlotRecord {
|
||||||
}
|
}
|
||||||
|
|
||||||
interface InventoryState {
|
interface InventoryState {
|
||||||
infinite: boolean;
|
|
||||||
nextSlotId: number; // auto-increment counter for infinite mode
|
|
||||||
slots: SlotRecord[];
|
slots: SlotRecord[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -22,34 +19,14 @@ interface InventoryState {
|
||||||
export class Inventory extends Component<InventoryState> {
|
export class Inventory extends Component<InventoryState> {
|
||||||
#cachedVars: RPGVariables | null = null;
|
#cachedVars: RPGVariables | null = null;
|
||||||
|
|
||||||
/** Infinite inventory — grows on demand, no slot cap. */
|
constructor(slotDefs: Array<InventorySlotInput>) {
|
||||||
constructor();
|
super({
|
||||||
/** N generic slots, each limited only by item maxStack. */
|
slots: slotDefs.map(def => {
|
||||||
constructor(count: number);
|
const slotId = typeof def === 'object' ? def.slotId : def;
|
||||||
/** Named slots with optional per-slot stack limits (original behaviour). */
|
const limit = typeof def === 'object' ? def.limit : undefined;
|
||||||
constructor(slots: InventorySlotInput[]);
|
return { slotId, limit, contents: null };
|
||||||
constructor(input?: number | InventorySlotInput[]) {
|
}),
|
||||||
if (input === undefined) {
|
});
|
||||||
super({ infinite: true, nextSlotId: 0, slots: [] });
|
|
||||||
} else if (typeof input === 'number') {
|
|
||||||
super({
|
|
||||||
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 {
|
#slot(slotId: SlotId): SlotRecord | undefined {
|
||||||
|
|
@ -79,7 +56,6 @@ export class Inventory extends Component<InventoryState> {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Direct slot ───────────────────────────────────────────────────────
|
|
||||||
if (slotId !== undefined) {
|
if (slotId !== undefined) {
|
||||||
const slot = this.#slot(slotId);
|
const slot = this.#slot(slotId);
|
||||||
if (!slot) return false;
|
if (!slot) return false;
|
||||||
|
|
@ -89,70 +65,45 @@ export class Inventory extends Component<InventoryState> {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Finite inventory: two-phase (check → apply) ───────────────────────
|
// Two-phase: pre-check then apply
|
||||||
if (!this.state.infinite) {
|
|
||||||
let canFit = 0;
|
|
||||||
for (const slot of this.state.slots) {
|
|
||||||
if (slot.contents === null || slot.contents.itemId === itemId)
|
|
||||||
canFit += this.#roomFor(slot, itemId);
|
|
||||||
}
|
|
||||||
if (canFit < amount) return false;
|
|
||||||
|
|
||||||
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);
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.emit('add', { itemId, amount, slotIds });
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Infinite inventory: fill existing slots, then grow ─────────────────
|
|
||||||
let remaining = amount;
|
let remaining = amount;
|
||||||
const slotIds: SlotId[] = [];
|
|
||||||
|
|
||||||
for (const slot of this.state.slots) {
|
for (const slot of this.state.slots) {
|
||||||
if (slot.contents?.itemId === itemId && remaining > 0) {
|
if (slot.contents === null || slot.contents.itemId === itemId) {
|
||||||
|
remaining -= Math.min(this.#roomFor(slot, itemId), remaining);
|
||||||
|
if (remaining === 0) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (remaining > 0) return false;
|
||||||
|
|
||||||
|
// Apply — fill existing slots for this item first, then empty ones
|
||||||
|
remaining = amount;
|
||||||
|
const slotIds: SlotId[] = [];
|
||||||
|
for (const slot of this.state.slots) {
|
||||||
|
if (slot.contents?.itemId === itemId) {
|
||||||
const take = Math.min(this.#roomFor(slot, itemId), remaining);
|
const take = Math.min(this.#roomFor(slot, itemId), remaining);
|
||||||
if (take > 0) {
|
slot.contents.amount += take;
|
||||||
slot.contents!.amount += take;
|
remaining -= take;
|
||||||
remaining -= take;
|
slotIds.push(slot.slotId);
|
||||||
slotIds.push(slot.slotId);
|
if (remaining === 0) {
|
||||||
|
this.emit('add', { itemId, amount, slotIds });
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const slot of this.state.slots) {
|
for (const slot of this.state.slots) {
|
||||||
if (slot.contents === null && remaining > 0) {
|
if (slot.contents === null) {
|
||||||
const take = Math.min(this.#capFor(slot, itemId), remaining);
|
const take = Math.min(this.#capFor(slot, itemId), remaining);
|
||||||
slot.contents = { itemId, amount: take };
|
slot.contents = { itemId, amount: take };
|
||||||
remaining -= take;
|
remaining -= take;
|
||||||
slotIds.push(slot.slotId);
|
slotIds.push(slot.slotId);
|
||||||
|
if (remaining === 0) {
|
||||||
|
this.emit('add', { itemId, amount, slotIds });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.emit('add', { itemId, amount, slotIds });
|
return remaining === 0;
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
|
|
@ -176,71 +127,27 @@ export class Inventory extends Component<InventoryState> {
|
||||||
let remaining = amount;
|
let remaining = amount;
|
||||||
const slotIds: SlotId[] = [];
|
const slotIds: SlotId[] = [];
|
||||||
for (const slot of this.state.slots) {
|
for (const slot of this.state.slots) {
|
||||||
if (slot.contents?.itemId === itemId && remaining > 0) {
|
if (slot.contents?.itemId === itemId) {
|
||||||
const take = Math.min(slot.contents.amount, remaining);
|
const take = Math.min(slot.contents.amount, remaining);
|
||||||
slot.contents.amount -= take;
|
slot.contents.amount -= take;
|
||||||
if (slot.contents.amount === 0) slot.contents = null;
|
if (slot.contents.amount === 0) slot.contents = null;
|
||||||
remaining -= take;
|
remaining -= take;
|
||||||
slotIds.push(slot.slotId);
|
slotIds.push(slot.slotId);
|
||||||
|
if (remaining === 0) {
|
||||||
|
this.emit('remove', { itemId, amount, slotIds });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.emit('remove', { itemId, amount, slotIds });
|
return false;
|
||||||
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
|
@action
|
||||||
equip(arg: string | { itemId?: string; slotId?: SlotId; slotName?: string }): boolean {
|
async use(itemId?: string, ctx?: EvalContext): Promise<boolean> {
|
||||||
const resolved = this.#resolveItem(arg);
|
if (!itemId) return false;
|
||||||
if (!resolved) return false;
|
|
||||||
const { itemId, slotId } = resolved;
|
|
||||||
const slotName = typeof arg === 'object' ? arg.slotName : undefined;
|
|
||||||
|
|
||||||
if (this.getAmount(itemId, slotId) === 0) {
|
if (this.getAmount(itemId) === 0) {
|
||||||
console.warn(`[Inventory] equip: item '${itemId}' not in inventory`);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const equipment = this.entity.get(Equipment);
|
|
||||||
if (!equipment) {
|
|
||||||
console.warn(`[Inventory] equip: entity has no Equipment component`);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
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`);
|
console.warn(`[Inventory] use: item '${itemId}' not in inventory`);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
@ -257,29 +164,13 @@ export class Inventory extends Component<InventoryState> {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (usable.consumeOnUse) this.remove({ itemId, amount: 1, slotId });
|
if (usable.consumeOnUse) {
|
||||||
|
this.remove({ itemId, amount: 1 });
|
||||||
await usable.use(ctx ?? this.context);
|
|
||||||
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;
|
const resolvedCtx = ctx ?? { self: this.entity, world: this.entity.world };
|
||||||
}
|
await usable.use(resolvedCtx);
|
||||||
|
return true;
|
||||||
getSlotContents(slotId: SlotId): { itemId: string; amount: number } | null {
|
|
||||||
return this.#slot(slotId)?.contents ?? null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getAmount(itemId: string, slotId?: SlotId): number {
|
getAmount(itemId: string, slotId?: SlotId): number {
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,7 @@ import { Component } from "../core/world";
|
||||||
import { component } from "../core/registry";
|
import { component } from "../core/registry";
|
||||||
|
|
||||||
interface StatState {
|
interface StatState {
|
||||||
base: number;
|
value: number;
|
||||||
modifierSums: { value: number; max: number; min: number };
|
|
||||||
max: number | undefined;
|
max: number | undefined;
|
||||||
min: number | undefined;
|
min: number | undefined;
|
||||||
}
|
}
|
||||||
|
|
@ -12,67 +11,47 @@ interface StatState {
|
||||||
@component
|
@component
|
||||||
export class Stat extends Component<StatState> {
|
export class Stat extends Component<StatState> {
|
||||||
constructor(value: number, max?: number, min?: number) {
|
constructor(value: number, max?: number, min?: number) {
|
||||||
super({ base: value, modifierSums: { value: 0, max: 0, min: 0 }, max, min });
|
super({ value, max, min });
|
||||||
}
|
}
|
||||||
|
|
||||||
@variable('.') get value(): number {
|
@variable('.') get value(): number { return this.state.value; }
|
||||||
const effMin = this.min;
|
@variable get max(): number | undefined { return this.state.max; }
|
||||||
const effMax = this.max;
|
@variable get min(): number | undefined { return this.state.min; }
|
||||||
let v = this.state.base + this.state.modifierSums.value;
|
|
||||||
if (effMin != null) v = Math.max(effMin, v);
|
|
||||||
if (effMax != null) v = Math.min(v, effMax);
|
|
||||||
return v;
|
|
||||||
}
|
|
||||||
|
|
||||||
@variable get base(): number { return this.state.base; }
|
|
||||||
@variable get max(): number | undefined {
|
|
||||||
return this.state.max != null ? this.state.max + this.state.modifierSums.max : undefined;
|
|
||||||
}
|
|
||||||
@variable get min(): number | undefined {
|
|
||||||
return this.state.min != null ? this.state.min + this.state.modifierSums.min : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
@action
|
@action
|
||||||
update(amount: number) {
|
update(amount: number) {
|
||||||
this.set(this.state.base + amount);
|
this.set(this.state.value + amount);
|
||||||
}
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
set(value: number) {
|
set(value: number) {
|
||||||
const prev = this.value;
|
const prev = this.state.value;
|
||||||
this.state.base = value;
|
this.state.value = value;
|
||||||
const next = this.value;
|
if (this.state.min != null) {
|
||||||
if (prev !== next) {
|
this.state.value = Math.max(this.state.min, this.state.value);
|
||||||
this.emit('set', { prev, value: next });
|
}
|
||||||
|
if (this.state.max != null) {
|
||||||
|
this.state.value = Math.min(this.state.value, this.state.max);
|
||||||
|
}
|
||||||
|
if (prev !== this.state.value) {
|
||||||
|
this.emit('set', { prev, value: this.state.value });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
applyModifier(delta: number, field: 'value' | 'max' | 'min' = 'value'): void {
|
get current(): number {
|
||||||
const prev = this.value;
|
return this.state.value;
|
||||||
this.state.modifierSums[field] += delta;
|
|
||||||
const next = this.value;
|
|
||||||
if (prev !== next) this.emit('set', { prev, value: next });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
removeModifier(delta: number, field: 'value' | 'max' | 'min' = 'value'): void {
|
|
||||||
this.applyModifier(-delta, field);
|
|
||||||
}
|
|
||||||
|
|
||||||
get current(): number { return this.value; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@component
|
@component
|
||||||
export class Health extends Stat {
|
export class Health extends Stat {
|
||||||
|
constructor(value: number, max?: number, min = 0) {
|
||||||
|
super(value, max, min);
|
||||||
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
kill() {
|
kill() {
|
||||||
this.set(0);
|
this.set(0);
|
||||||
this.emit('killed');
|
this.emit('killed');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@component
|
|
||||||
export class Defense extends Stat { }
|
|
||||||
|
|
||||||
@component
|
|
||||||
export class Damage extends Stat { }
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
import { Cooldown } from "../components/cooldown";
|
|
||||||
import { System, type World } from "../core/world";
|
|
||||||
|
|
||||||
export class CooldownSystem extends System {
|
|
||||||
override update(world: World, dt: number): void {
|
|
||||||
for (const [, , cooldown] of world.query(Cooldown)) {
|
|
||||||
cooldown.update(dt);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
import { Effect } from "../components/effect";
|
|
||||||
import { System, type Entity, type World } from "../core/world";
|
|
||||||
|
|
||||||
export class EffectSystem extends System {
|
|
||||||
override update(world: World, dt: number): void {
|
|
||||||
const expired: [Entity, string][] = [];
|
|
||||||
|
|
||||||
for (const [entity, key, effect] of world.query(Effect)) {
|
|
||||||
effect.update(dt, entity.context);
|
|
||||||
if (!effect.active) {
|
|
||||||
expired.push([entity, key]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [entity, key] of expired) {
|
|
||||||
entity.remove(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Loading…
Reference in New Issue