mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 22:01:09 +02:00
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:
parent
e94775de25
commit
954923334f
9 changed files with 107 additions and 91 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -18,7 +18,6 @@ export type GameEventType =
|
|||
| 'onDrop'
|
||||
| 'onTimer'
|
||||
| 'onHpBelow'
|
||||
| 'onNearItem'
|
||||
| 'onAreaEnter'
|
||||
| 'onCustomEvent'
|
||||
| 'onDayNight';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue