From ad82a83f2019095959191f6f127d49044fc0f61f Mon Sep 17 00:00:00 2001 From: Till JS Date: Mon, 30 Mar 2026 15:06:51 +0200 Subject: [PATCH] feat(manavoxel): add merchant trading UI and NPC loot drops - Trade UI: merchant NPCs show 5 items (sword, wand, shard, herb, hammer) with stats preview, buy button adds item to inventory - Loot system: defeated hostile/guard NPCs roll loot table drops (bone club, poison fang, dark crystal, iron shield, guard sword) - Ground items: loot appears at NPC death position, pickup button when player is nearby - Placeholder sprite generator for trade/loot items Co-Authored-By: Claude Opus 4.6 (1M context) --- .../apps/web/src/lib/engine/dialog.ts | 163 ++++++++++++++++++ .../manavoxel/apps/web/src/lib/engine/game.ts | 2 + .../apps/web/src/routes/+page.svelte | 146 +++++++++++++++- 3 files changed, 308 insertions(+), 3 deletions(-) diff --git a/apps/manavoxel/apps/web/src/lib/engine/dialog.ts b/apps/manavoxel/apps/web/src/lib/engine/dialog.ts index 94abc0ccc..665724f6c 100644 --- a/apps/manavoxel/apps/web/src/lib/engine/dialog.ts +++ b/apps/manavoxel/apps/web/src/lib/engine/dialog.ts @@ -142,3 +142,166 @@ export class DialogManager { this._currentIndex = 0; } } + +// ─── Merchant Trade Offers ──────────────────────────────────── + +export interface MerchantOffer { + name: string; + description: string; + cost: number; // durability points from held item + damage: number; + range: number; + speed: number; + durabilityMax: number; + element: string; + rarity: string; + particle: string; +} + +export const MERCHANT_OFFERS: MerchantOffer[] = [ + { + name: 'Stone Sword', + description: 'A basic blade. Gets the job done.', + cost: 20, + damage: 25, + range: 3, + speed: 4, + durabilityMax: 80, + element: 'neutral', + rarity: 'common', + particle: 'sparks', + }, + { + name: 'Fire Wand', + description: 'Shoots bursts of flame.', + cost: 40, + damage: 35, + range: 6, + speed: 2, + durabilityMax: 50, + element: 'fire', + rarity: 'uncommon', + particle: 'fire_burst', + }, + { + name: 'Ice Shard', + description: 'Freezes on contact.', + cost: 40, + damage: 30, + range: 5, + speed: 3, + durabilityMax: 60, + element: 'ice', + rarity: 'uncommon', + particle: 'ice_shards', + }, + { + name: 'Healing Herb', + description: 'Restores 30 HP when used.', + cost: 15, + damage: 0, + range: 1, + speed: 5, + durabilityMax: 3, + element: 'neutral', + rarity: 'common', + particle: 'heal_glow', + }, + { + name: 'Thunder Hammer', + description: 'Devastating area damage.', + cost: 60, + damage: 60, + range: 4, + speed: 1, + durabilityMax: 40, + element: 'lightning', + rarity: 'rare', + particle: 'lightning_bolt', + }, +]; + +// ─── Loot Tables ────────────────────────────────────────────── + +export interface LootDrop { + name: string; + chance: number; // 0-1 + damage: number; + range: number; + speed: number; + durabilityMax: number; + element: string; + rarity: string; + particle: string; +} + +const HOSTILE_LOOT: LootDrop[] = [ + { + name: 'Bone Club', + chance: 0.4, + damage: 15, + range: 2, + speed: 3, + durabilityMax: 40, + element: 'neutral', + rarity: 'common', + particle: 'sparks', + }, + { + name: 'Poison Fang', + chance: 0.2, + damage: 20, + range: 2, + speed: 5, + durabilityMax: 30, + element: 'poison', + rarity: 'uncommon', + particle: 'poison_cloud', + }, + { + name: 'Dark Crystal', + chance: 0.1, + damage: 40, + range: 4, + speed: 2, + durabilityMax: 25, + element: 'lightning', + rarity: 'rare', + particle: 'lightning_bolt', + }, +]; + +const GUARD_LOOT: LootDrop[] = [ + { + name: 'Iron Shield', + chance: 0.3, + damage: 5, + range: 1, + speed: 2, + durabilityMax: 150, + element: 'neutral', + rarity: 'uncommon', + particle: 'sparks', + }, + { + name: 'Guard Sword', + chance: 0.15, + damage: 30, + range: 3, + speed: 3, + durabilityMax: 100, + element: 'neutral', + rarity: 'rare', + particle: 'sparks', + }, +]; + +/** 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; + + for (const drop of table) { + if (Math.random() < drop.chance) return drop; + } + return null; +} diff --git a/apps/manavoxel/apps/web/src/lib/engine/game.ts b/apps/manavoxel/apps/web/src/lib/engine/game.ts index 0e1142da9..001ddede3 100644 --- a/apps/manavoxel/apps/web/src/lib/engine/game.ts +++ b/apps/manavoxel/apps/web/src/lib/engine/game.ts @@ -54,6 +54,7 @@ export class GameEngine { // Callbacks for UI reactivity onStateChange: (() => void) | null = null; + onNpcDeath: ((npcX: number, npcY: number, behavior: string) => void) | null = null; get isEditing() { return this._editing; @@ -405,6 +406,7 @@ export class GameEngine { playSound('explosion'); this.particles.spawn('shatter', target.worldX, target.worldY); this._showMessage(`Defeated ${target.behavior} NPC!`); + this.onNpcDeath?.(target.worldX, target.worldY, target.behavior); } } } diff --git a/apps/manavoxel/apps/web/src/routes/+page.svelte b/apps/manavoxel/apps/web/src/routes/+page.svelte index 5c5cc341d..7054b1289 100644 --- a/apps/manavoxel/apps/web/src/routes/+page.svelte +++ b/apps/manavoxel/apps/web/src/routes/+page.svelte @@ -10,6 +10,7 @@ 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 { loadWorld, getAllWorlds, @@ -40,6 +41,8 @@ let timeString = $state('08:00'); let isNight = $state(false); let dialogActive = $state(false); + let showTradeUI = $state(false); + let groundItems = $state<{ x: number; y: number; loot: LootDrop }[]>([]); let dialogLine = $state<{ speaker: string; text: string; @@ -64,6 +67,30 @@ const materials = DEFAULT_MATERIALS.filter((m) => m.id !== MATERIAL_AIR); + /** Generate a simple colored sprite for trade/loot items */ + function generateTradeSprite(): import('$lib/editor/types').SpriteData { + const w = 16, + h = 32; + const pixels = new Uint8Array(w * h * 4); + // Draw a simple diamond shape + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + const cx = w / 2, + cy = h / 2; + const dist = Math.abs(x - cx) / cx + Math.abs(y - cy) / cy; + if (dist < 0.8) { + const i = (y * w + x) * 4; + const hue = (x * 17 + y * 31) % 256; + pixels[i] = 100 + (hue % 156); + pixels[i + 1] = 80 + ((hue * 3) % 176); + pixels[i + 2] = 120 + ((hue * 7) % 136); + pixels[i + 3] = 255; + } + } + } + return { pixels, width: w, height: h, frames: 1 }; + } + const PLAYER_ID = 'local-player'; let autoSaveInterval: ReturnType; let timeUpdateInterval: ReturnType; @@ -111,6 +138,14 @@ engine = e; loading = false; + // Loot drops when NPCs die + e.onNpcDeath = (npcX, npcY, behavior) => { + const loot = rollLoot(behavior); + if (loot) { + groundItems = [...groundItems, { x: npcX, y: npcY, loot }]; + } + }; + e.onStateChange = () => { isEditing = e.isEditing; selectedMaterial = e.selectedMaterial; @@ -502,9 +537,7 @@ dialogActive = engine.dialog.active; dialogLine = engine.dialog.currentLine; if (result === 'trade') { - // TODO: Open trade UI - engine.dialog.close(); - dialogActive = false; + showTradeUI = true; } } }} @@ -517,6 +550,113 @@ {/if} + + {#if showTradeUI} +
+
+
+

Merchant's Wares

+ +
+
+ {#each MERCHANT_OFFERS as offer} + {@const rarityColor = + offer.rarity === 'rare' + ? 'border-blue-500' + : offer.rarity === 'uncommon' + ? 'border-green-500' + : 'border-gray-600'} +
+
+
{offer.name}
+
{offer.description}
+
+ {#if offer.damage > 0}⚔ {offer.damage}{/if} + ↔ {offer.range} + ⚡ {offer.speed} + 🛡 {offer.durabilityMax} + {#if offer.element !== 'neutral'}{offer.element}{/if} +
+
+ +
+ {/each} +
+
+
+ {/if} + + + {#if groundItems.length > 0 && engine?.player} + {@const nearby = groundItems.find((g) => { + const dx = g.x - (engine?.player?.worldX ?? 0); + const dy = g.y - (engine?.player?.worldY ?? 0); + return Math.sqrt(dx * dx + dy * dy) < 80; + })} + {#if nearby} +
+ +
+ {/if} + {/if} + {#if showSpriteEditor}