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) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-01 21:04:25 +02:00
parent e94775de25
commit 954923334f
9 changed files with 107 additions and 91 deletions

View file

@ -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

View file

@ -149,7 +149,8 @@ export async function deleteItem(itemId: string): Promise<void> {
export async function saveInventory(
playerId: string,
slots: (GameItem | null)[],
heldSlot: number
heldSlot: number,
gold: number = 0
): Promise<void> {
// 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 {

View file

@ -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' },

View file

@ -18,7 +18,6 @@ export type GameEventType =
| 'onDrop'
| 'onTimer'
| 'onHpBelow'
| 'onNearItem'
| 'onAreaEnter'
| 'onCustomEvent'
| 'onDayNight';

View file

@ -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<string, { min: number; max: number }> = {
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;

View file

@ -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();
}

View file

@ -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);

View file

@ -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 @@
<div class="rounded-lg bg-gray-800/80 px-3 py-1.5 text-xs text-gray-300 backdrop-blur">
HP: {engine.player.hp}/{engine.player.maxHp}
</div>
<div class="rounded-lg bg-gray-800/80 px-3 py-1.5 text-xs text-yellow-400 backdrop-blur">
{inventory.gold}g
</div>
<div
class="rounded-lg px-3 py-1.5 text-xs backdrop-blur {isNight
? 'bg-indigo-900/80 text-indigo-300'
@ -433,7 +448,7 @@
{inventory}
onDrop={async (slot) => {
inventory.removeItem(slot);
await saveInventory(PLAYER_ID, inventory.slots, inventory.heldSlot);
await saveInventory(PLAYER_ID, inventory.slots, inventory.heldSlot, inventory.gold);
}}
onInspect={(item) => {
editingItem = item;
@ -591,8 +606,10 @@
</div>
<button
class="rounded-lg bg-emerald-600 px-3 py-1.5 text-xs text-white hover:bg-emerald-500 disabled:opacity-30"
disabled={inventory.isFull}
disabled={inventory.isFull || inventory.gold < offer.cost}
onclick={async () => {
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}
</button>
</div>
{/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)}

View file

@ -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<string, unknown>;
}