1
0
Fork 0

Compare commits

..

5 Commits

Author SHA1 Message Date
Pabloader 30531bcd52 XP 2026-04-29 12:24:29 +00:00
Pabloader cb2d1a099f Effects 2026-04-29 12:24:20 +00:00
Pabloader f766aee071 Equipment 2026-04-29 12:24:11 +00:00
Pabloader 26e1fb2675 Cooldown 2026-04-29 12:24:00 +00:00
Pabloader c2e9a597fa Infinite inventory 2026-04-29 12:23:49 +00:00
8 changed files with 583 additions and 67 deletions

View File

@ -0,0 +1,41 @@
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');
}
}
}

View File

@ -0,0 +1,83 @@
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
}
}

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

View File

@ -0,0 +1,69 @@
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 12.
* 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 01 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 });
}
}
}

View File

@ -3,6 +3,7 @@ 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 {
@ -12,6 +13,8 @@ interface SlotRecord {
} }
interface InventoryState { interface InventoryState {
infinite: boolean;
nextSlotId: number; // auto-increment counter for infinite mode
slots: SlotRecord[]; slots: SlotRecord[];
} }
@ -19,14 +22,34 @@ interface InventoryState {
export class Inventory extends Component<InventoryState> { export class Inventory extends Component<InventoryState> {
#cachedVars: RPGVariables | null = null; #cachedVars: RPGVariables | null = null;
constructor(slotDefs: Array<InventorySlotInput>) { /** Infinite inventory — grows on demand, no slot cap. */
super({ constructor();
slots: slotDefs.map(def => { /** N generic slots, each limited only by item maxStack. */
const slotId = typeof def === 'object' ? def.slotId : def; constructor(count: number);
const limit = typeof def === 'object' ? def.limit : undefined; /** Named slots with optional per-slot stack limits (original behaviour). */
return { slotId, limit, contents: null }; constructor(slots: InventorySlotInput[]);
}), 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 {
@ -56,6 +79,7 @@ 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;
@ -65,45 +89,70 @@ export class Inventory extends Component<InventoryState> {
return true; return true;
} }
// Two-phase: pre-check then apply // ── Finite inventory: two-phase (check → apply) ───────────────────────
let remaining = amount; if (!this.state.infinite) {
for (const slot of this.state.slots) { let canFit = 0;
if (slot.contents === null || slot.contents.itemId === itemId) { for (const slot of this.state.slots) {
remaining -= Math.min(this.#roomFor(slot, itemId), remaining); if (slot.contents === null || slot.contents.itemId === itemId)
if (remaining === 0) break; canFit += this.#roomFor(slot, itemId);
} }
} if (canFit < amount) return false;
if (remaining > 0) return false;
// Apply — fill existing slots for this item first, then empty ones let remaining = amount;
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;
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) { if (slot.contents?.itemId === itemId && remaining > 0) {
const take = Math.min(this.#roomFor(slot, itemId), remaining); const take = Math.min(this.#roomFor(slot, itemId), remaining);
slot.contents.amount += take; if (take > 0) {
remaining -= take; slot.contents!.amount += take;
slotIds.push(slot.slotId); remaining -= take;
if (remaining === 0) { slotIds.push(slot.slotId);
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) { if (slot.contents === null && remaining > 0) {
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);
}
return remaining === 0; this.emit('add', { itemId, amount, slotIds });
return true;
} }
@action @action
@ -127,27 +176,71 @@ 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) { if (slot.contents?.itemId === itemId && remaining > 0) {
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;
}
} }
} }
return false; 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 @action
async use(itemId?: string, ctx?: EvalContext): Promise<boolean> { equip(arg: string | { itemId?: string; slotId?: SlotId; slotName?: string }): boolean {
if (!itemId) return false; 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) === 0) { if (this.getAmount(itemId, slotId) === 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;
} }
@ -164,13 +257,29 @@ export class Inventory extends Component<InventoryState> {
return false; return false;
} }
if (usable.consumeOnUse) { if (usable.consumeOnUse) this.remove({ itemId, amount: 1, slotId });
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 };
} }
const resolvedCtx = ctx ?? { self: this.entity, world: this.entity.world }; return arg.itemId ? { itemId: arg.itemId } : null;
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 {

View File

@ -3,7 +3,8 @@ import { Component } from "../core/world";
import { component } from "../core/registry"; import { component } from "../core/registry";
interface StatState { interface StatState {
value: number; base: number;
modifierSums: { value: number; max: number; min: number };
max: number | undefined; max: number | undefined;
min: number | undefined; min: number | undefined;
} }
@ -11,47 +12,67 @@ 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({ value, max, min }); super({ base: value, modifierSums: { value: 0, max: 0, min: 0 }, max, min });
} }
@variable('.') get value(): number { return this.state.value; } @variable('.') get value(): number {
@variable get max(): number | undefined { return this.state.max; } const effMin = this.min;
@variable get min(): number | undefined { return this.state.min; } const effMax = this.max;
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.value + amount); this.set(this.state.base + amount);
} }
@action @action
set(value: number) { set(value: number) {
const prev = this.state.value; const prev = this.value;
this.state.value = value; this.state.base = value;
if (this.state.min != null) { const next = this.value;
this.state.value = Math.max(this.state.min, this.state.value); if (prev !== next) {
} 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 });
} }
} }
get current(): number { applyModifier(delta: number, field: 'value' | 'max' | 'min' = 'value'): void {
return this.state.value; const prev = this.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 { }

View File

@ -0,0 +1,10 @@
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);
}
}
}

View File

@ -0,0 +1,19 @@
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);
}
}
}