From 954923334fa2cb89488ca84c1511206f86e1eb89 Mon Sep 17 00:00:00 2001 From: Till JS Date: Wed, 1 Apr 2026 21:04:25 +0200 Subject: [PATCH] feat(manavoxel): clean up dead code, add portal keys, fix triggers, implement gold economy Remove unused multiplayer protocol types, script/wasmBinary/capabilities fields, and onNearItem trigger. Implement portal requiresKey gate, fix onDayNight and onTouch triggers to fire for all inventory items, and add gold-based trading economy with NPC kill rewards and merchant purchasing. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/manavoxel/CLAUDE.md | 14 ++--- .../apps/web/src/lib/data/world-loader.ts | 25 +++++++-- .../web/src/lib/editor/trigger-editor.svelte | 1 - .../apps/web/src/lib/engine/behavior.ts | 1 - .../apps/web/src/lib/engine/dialog.ts | 17 +++++- .../manavoxel/apps/web/src/lib/engine/game.ts | 41 ++++++++++----- .../web/src/lib/engine/inventory.svelte.ts | 6 +++ .../apps/web/src/routes/+page.svelte | 41 +++++++++++---- apps/manavoxel/packages/shared/src/types.ts | 52 ------------------- 9 files changed, 107 insertions(+), 91 deletions(-) diff --git a/apps/manavoxel/CLAUDE.md b/apps/manavoxel/CLAUDE.md index e1af7f14d..9d93d845a 100644 --- a/apps/manavoxel/CLAUDE.md +++ b/apps/manavoxel/CLAUDE.md @@ -125,7 +125,7 @@ GameEventBus → BehaviorRuntime → Action Executors └── setVariable/sendEvent ``` -### Triggers (10 types) +### Triggers (9 types) | Trigger | Fires when | |---------|-----------| @@ -137,8 +137,7 @@ GameEventBus → BehaviorRuntime → Action Executors | `onHpBelow` | Player HP drops below threshold (with param check) | | `onAreaEnter` | Player enters a portal | | `onCustomEvent` | Fired by sendEvent action | -| `onNearItem` | Item proximity (not yet wired) | -| `onDayNight` | Day/night change (not yet implemented) | +| `onDayNight` | Day/night change | ### Actions (11 implemented) @@ -266,8 +265,9 @@ NPCs are spawned from EntityDef entries in area data. Place them via the NPC too - **Base64 encoding** — binary data (pixelData, spriteData) encoded for Dexie storage - **Svelte 5 runes** — `$state`, `$derived`, `$effect` for reactive UI state -## Not Yet Implemented +## Economy System -- **Multiplayer** — 10+ message types defined, no WebSocket server -- **Some triggers** — onNearItem not wired to game events yet -- **Trading UI** — Merchant dialog opens but trade screen not yet implemented +- **Gold** — Earned from defeating NPCs (hostile: 5-15g, guard: 10-25g) +- **Merchants** — Buy items with gold, prices shown on buy button +- **Persistence** — Gold saved/loaded with inventory in IndexedDB +- **HUD** — Gold counter shown in top bar during gameplay diff --git a/apps/manavoxel/apps/web/src/lib/data/world-loader.ts b/apps/manavoxel/apps/web/src/lib/data/world-loader.ts index 8afbaa6b5..72986b71b 100644 --- a/apps/manavoxel/apps/web/src/lib/data/world-loader.ts +++ b/apps/manavoxel/apps/web/src/lib/data/world-loader.ts @@ -149,7 +149,8 @@ export async function deleteItem(itemId: string): Promise { export async function saveInventory( playerId: string, slots: (GameItem | null)[], - heldSlot: number + heldSlot: number, + gold: number = 0 ): Promise { // Clear existing inventory for this player const existing = await inventoryCollection.getAll({ playerId }); @@ -167,7 +168,19 @@ export async function saveInventory( itemId: item.id, slot: i, quantity: 1, - instanceData: JSON.stringify({ heldSlot }), + instanceData: JSON.stringify({ heldSlot, gold }), + }); + } + + // Save gold even if inventory is empty + if (slots.every((s) => s === null)) { + await inventoryCollection.insert({ + id: `${playerId}_meta`, + playerId, + itemId: '', + slot: -1, + quantity: 0, + instanceData: JSON.stringify({ heldSlot, gold }), }); } } @@ -175,20 +188,22 @@ export async function saveInventory( /** Load inventory from IndexedDB */ export async function loadInventory( playerId: string -): Promise<{ slots: (string | null)[]; heldSlot: number }> { +): Promise<{ slots: (string | null)[]; heldSlot: number; gold: number }> { const dbSlots = await inventoryCollection.getAll({ playerId }); const slots: (string | null)[] = Array(8).fill(null); let heldSlot = -1; + let gold = 0; for (const dbSlot of dbSlots) { if (dbSlot.slot >= 0 && dbSlot.slot < slots.length) { slots[dbSlot.slot] = dbSlot.itemId; } - const data = safeJsonParse<{ heldSlot?: number }>(dbSlot.instanceData, {}); + const data = safeJsonParse<{ heldSlot?: number; gold?: number }>(dbSlot.instanceData, {}); if (data.heldSlot !== undefined) heldSlot = data.heldSlot; + if (data.gold !== undefined) gold = data.gold; } - return { slots, heldSlot }; + return { slots, heldSlot, gold }; } function dbItemToGameItem(dbItem: LocalItem): GameItem { diff --git a/apps/manavoxel/apps/web/src/lib/editor/trigger-editor.svelte b/apps/manavoxel/apps/web/src/lib/editor/trigger-editor.svelte index ac565672a..2bb7b2613 100644 --- a/apps/manavoxel/apps/web/src/lib/editor/trigger-editor.svelte +++ b/apps/manavoxel/apps/web/src/lib/editor/trigger-editor.svelte @@ -16,7 +16,6 @@ { value: 'onDrop', label: 'Item dropped' }, { value: 'onTimer', label: 'Every X seconds' }, { value: 'onHpBelow', label: 'HP below X' }, - { value: 'onNearItem', label: 'Item nearby' }, { value: 'onAreaEnter', label: 'Entering area' }, { value: 'onCustomEvent', label: 'Custom event' }, { value: 'onDayNight', label: 'Day/Night change' }, diff --git a/apps/manavoxel/apps/web/src/lib/engine/behavior.ts b/apps/manavoxel/apps/web/src/lib/engine/behavior.ts index 6fade7150..2566e81d8 100644 --- a/apps/manavoxel/apps/web/src/lib/engine/behavior.ts +++ b/apps/manavoxel/apps/web/src/lib/engine/behavior.ts @@ -18,7 +18,6 @@ export type GameEventType = | 'onDrop' | 'onTimer' | 'onHpBelow' - | 'onNearItem' | 'onAreaEnter' | 'onCustomEvent' | 'onDayNight'; diff --git a/apps/manavoxel/apps/web/src/lib/engine/dialog.ts b/apps/manavoxel/apps/web/src/lib/engine/dialog.ts index 665724f6c..c4f0e022a 100644 --- a/apps/manavoxel/apps/web/src/lib/engine/dialog.ts +++ b/apps/manavoxel/apps/web/src/lib/engine/dialog.ts @@ -20,7 +20,7 @@ export interface DialogOption { export interface TradeOffer { item: GameItem; - cost: number; // durability points from held item as "currency" + cost: number; // gold cost } // ─── NPC Dialog Templates ───────────────────────────────────── @@ -148,7 +148,7 @@ export class DialogManager { export interface MerchantOffer { name: string; description: string; - cost: number; // durability points from held item + cost: number; // gold cost damage: number; range: number; speed: number; @@ -296,6 +296,19 @@ const GUARD_LOOT: LootDrop[] = [ }, ]; +/** Gold reward ranges per NPC type */ +const GOLD_REWARDS: Record = { + hostile: { min: 5, max: 15 }, + guard: { min: 10, max: 25 }, +}; + +/** Roll gold reward for a defeated NPC */ +export function rollGold(npcBehavior: string): number { + const reward = GOLD_REWARDS[npcBehavior]; + if (!reward) return 0; + return reward.min + Math.floor(Math.random() * (reward.max - reward.min + 1)); +} + /** Roll loot for a defeated NPC. Returns null if nothing drops. */ export function rollLoot(npcBehavior: string): LootDrop | null { const table = npcBehavior === 'guard' ? GUARD_LOOT : HOSTILE_LOOT; diff --git a/apps/manavoxel/apps/web/src/lib/engine/game.ts b/apps/manavoxel/apps/web/src/lib/engine/game.ts index 001ddede3..048369611 100644 --- a/apps/manavoxel/apps/web/src/lib/engine/game.ts +++ b/apps/manavoxel/apps/web/src/lib/engine/game.ts @@ -247,9 +247,9 @@ export class GameEngine { const dnResult = this.dayNight.update(); this.lighting.setAmbient(this.dayNight.ambientLevel); - // Fire onDayNight trigger - if (dnResult.changed && this.inventory?.heldItem) { - this._fireItemEvent('onDayNight', this.inventory.heldItem); + // Fire onDayNight trigger for all inventory items + if (dnResult.changed) { + this._fireEventForAllItems('onDayNight'); } } @@ -290,10 +290,15 @@ export class GameEngine { this._currentFloor ); if (portal && this.input.isKeyDown('KeyE')) { - this.areaManager.enterPortal(portal, this.player); - // Fire onAreaEnter for held item - if (this.inventory?.heldItem) { - this._fireItemEvent('onAreaEnter', this.inventory.heldItem); + // Check if portal requires a key item + if (portal.requiresKey && !this.inventory?.hasItem(portal.requiresKey)) { + this.showMessage('You need a key to enter here.'); + } else { + this.areaManager.enterPortal(portal, this.player); + // Fire onAreaEnter for held item + if (this.inventory?.heldItem) { + this._fireItemEvent('onAreaEnter', this.inventory.heldItem); + } } } @@ -372,9 +377,9 @@ export class GameEngine { this.onStateChange?.(); } - // Fire onTouch for held item when touching NPCs - if (npcResult.touchingNpcs.length > 0 && this.inventory?.heldItem) { - this._fireItemEvent('onTouch', this.inventory.heldItem); + // Fire onTouch for all inventory items when touching NPCs + if (npcResult.touchingNpcs.length > 0) { + this._fireEventForAllItems('onTouch'); } // Item-use damages NPCs in range @@ -405,7 +410,7 @@ export class GameEngine { if (died) { playSound('explosion'); this.particles.spawn('shatter', target.worldX, target.worldY); - this._showMessage(`Defeated ${target.behavior} NPC!`); + this.showMessage(`Defeated ${target.behavior} NPC!`); this.onNpcDeath?.(target.worldX, target.worldY, target.behavior); } } @@ -526,6 +531,14 @@ export class GameEngine { this.eventBus.emit(event); } + /** Fire an event for all items in inventory (for passive triggers like onDayNight, onTouch) */ + private _fireEventForAllItems(type: import('./behavior').GameEventType) { + if (!this.inventory) return; + for (const item of this.inventory.slots) { + if (item) this._fireItemEvent(type, item); + } + } + /** Create an ActionContext for behavior execution */ private _createActionContext(item: import('./inventory.svelte').GameItem): ActionContext { return { @@ -559,7 +572,7 @@ export class GameEngine { this.camera.shake(intensity, duration); }, showMessage: (text: string) => { - this._showMessage(text); + this.showMessage(text); }, item, }; @@ -634,13 +647,13 @@ export class GameEngine { const slotIdx = this.inventory.slots.findIndex((s) => s?.id === item.id); if (slotIdx >= 0) this.inventory.removeItem(slotIdx); } - this._showMessage(`${item.name} broke!`); + this.showMessage(`${item.name} broke!`); } } } /** Show a floating message on screen */ - private _showMessage(text: string) { + showMessage(text: string) { if (this._messageText) { this._messageText.destroy(); } diff --git a/apps/manavoxel/apps/web/src/lib/engine/inventory.svelte.ts b/apps/manavoxel/apps/web/src/lib/engine/inventory.svelte.ts index acaf69ecd..3c44a6d62 100644 --- a/apps/manavoxel/apps/web/src/lib/engine/inventory.svelte.ts +++ b/apps/manavoxel/apps/web/src/lib/engine/inventory.svelte.ts @@ -45,6 +45,7 @@ export const MAX_EQUIPMENT_SLOTS = 1; // Held item export class Inventory { slots: (GameItem | null)[] = $state(Array(MAX_INVENTORY_SLOTS).fill(null)); heldSlot: number = $state(-1); // -1 = nothing held + gold: number = $state(0); /** Called when an item is added to inventory */ onPickup: ((item: GameItem) => void) | null = null; @@ -81,6 +82,11 @@ export class Inventory { this.heldSlot = this.heldSlot === slot ? -1 : slot; } + /** Check if an item with the given ID is in inventory */ + hasItem(itemId: string): boolean { + return this.slots.some((s) => s !== null && s.id === itemId); + } + /** Check if inventory is full */ get isFull(): boolean { return this.slots.every((s) => s !== null); diff --git a/apps/manavoxel/apps/web/src/routes/+page.svelte b/apps/manavoxel/apps/web/src/routes/+page.svelte index 7054b1289..52cfd7433 100644 --- a/apps/manavoxel/apps/web/src/routes/+page.svelte +++ b/apps/manavoxel/apps/web/src/routes/+page.svelte @@ -10,7 +10,13 @@ import TriggerEditor from '$lib/editor/trigger-editor.svelte'; import { Inventory, createItem, type GameItem } from '$lib/engine/inventory.svelte'; import { gameStore } from '$lib/data/local-store'; - import { MERCHANT_OFFERS, rollLoot, type MerchantOffer, type LootDrop } from '$lib/engine/dialog'; + import { + MERCHANT_OFFERS, + rollLoot, + rollGold, + type MerchantOffer, + type LootDrop, + } from '$lib/engine/dialog'; import { loadWorld, getAllWorlds, @@ -130,6 +136,7 @@ if (savedInventory.heldSlot >= 0) { inventory.heldSlot = savedInventory.heldSlot; } + inventory.gold = savedInventory.gold; itemCounter = savedItems.length; const e = new GameEngine(canvasContainer, worldData ?? undefined); @@ -138,8 +145,13 @@ engine = e; loading = false; - // Loot drops when NPCs die + // Gold + loot drops when NPCs die e.onNpcDeath = (npcX, npcY, behavior) => { + const gold = rollGold(behavior); + if (gold > 0) { + inventory.gold += gold; + e.showMessage(`+${gold} gold`); + } const loot = rollLoot(behavior); if (loot) { groundItems = [...groundItems, { x: npcX, y: npcY, loot }]; @@ -235,7 +247,7 @@ } engine.destroy(); } - saveInventory(PLAYER_ID, inventory.slots, inventory.heldSlot); + saveInventory(PLAYER_ID, inventory.slots, inventory.heldSlot, inventory.gold); if (keydownHandler) window.removeEventListener('keydown', keydownHandler); }; }); @@ -278,6 +290,9 @@
HP: {engine.player.hp}/{engine.player.maxHp}
+
+ {inventory.gold}g +
{ + if (inventory.gold < offer.cost) return; + inventory.gold -= offer.cost; const item = createItem(offer.name, generateTradeSprite(), { damage: offer.damage, range: offer.range, @@ -608,11 +625,17 @@ if (slot >= 0) { inventory.selectSlot(slot); await saveItem(item); - await saveInventory(PLAYER_ID, inventory.slots, inventory.heldSlot); + await saveInventory( + PLAYER_ID, + inventory.slots, + inventory.heldSlot, + inventory.gold + ); } }} > - {inventory.isFull ? 'Full' : 'Buy'} + {#if inventory.isFull}Full{:else if inventory.gold < offer.cost}Need {offer.cost}g{:else}Buy + ({offer.cost}g){/if}
{/each} @@ -647,7 +670,7 @@ }); inventory.addItem(item); await saveItem(item); - await saveInventory(PLAYER_ID, inventory.slots, inventory.heldSlot); + await saveInventory(PLAYER_ID, inventory.slots, inventory.heldSlot, inventory.gold); groundItems = groundItems.filter((g) => g !== nearby); }} > @@ -671,7 +694,7 @@ } // Persist item and inventory to IndexedDB await saveItem(item); - await saveInventory(PLAYER_ID, inventory.slots, inventory.heldSlot); + await saveInventory(PLAYER_ID, inventory.slots, inventory.heldSlot, inventory.gold); showSpriteEditor = false; }} onClose={() => (showSpriteEditor = false)} diff --git a/apps/manavoxel/packages/shared/src/types.ts b/apps/manavoxel/packages/shared/src/types.ts index caee92d0b..da1b174b8 100644 --- a/apps/manavoxel/packages/shared/src/types.ts +++ b/apps/manavoxel/packages/shared/src/types.ts @@ -130,10 +130,7 @@ export interface Item { resolution: number; // 0.01 for detail items properties: ItemProperties; behavior: TriggerAction[]; - script?: string; - wasmBinary?: Uint8Array; rarity: Rarity; - capabilities: string[]; isPublished: boolean; createdAt?: string; updatedAt?: string; @@ -153,52 +150,3 @@ export interface InventorySlot { updatedAt?: string; deletedAt?: string; } - -// ─── Network Protocol ─────────────────────────────────────── - -export type ClientMessage = - | { type: 'join'; worldId: string; areaId: string } - | { type: 'move'; x: number; y: number; direction: number } - | { type: 'setPixel'; x: number; y: number; floor: number; material: number } - | { type: 'useItem'; itemId: string; targetX: number; targetY: number } - | { type: 'enterPortal'; portalId: string } - | { type: 'chat'; message: string } - | { type: 'ping' }; - -export type ServerMessage = - | { type: 'welcome'; playerId: string; areaState: Area; players: PlayerState[] } - | { type: 'playerJoin'; player: PlayerState } - | { type: 'playerLeave'; playerId: string } - | { type: 'playerMove'; playerId: string; x: number; y: number; direction: number } - | { - type: 'pixelChanged'; - x: number; - y: number; - floor: number; - material: number; - playerId: string; - } - | { type: 'itemUsed'; playerId: string; itemId: string; effects: Effect[] } - | { type: 'areaTransition'; areaId: string; areaState: Area; players: PlayerState[] } - | { type: 'chat'; playerId: string; name: string; message: string } - | { type: 'error'; message: string } - | { type: 'pong' }; - -export interface PlayerState { - id: string; - name: string; - x: number; - y: number; - floor: number; - direction: number; // 0=up, 1=right, 2=down, 3=left - heldItemId?: string; - hp: number; - maxHp: number; -} - -export interface Effect { - type: 'damage' | 'heal' | 'particle' | 'sound' | 'pixelDestroy'; - x: number; - y: number; - params: Record; -}