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) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-30 15:06:51 +02:00
parent 45a17188e1
commit ad82a83f20
3 changed files with 308 additions and 3 deletions

View file

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

View file

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

View file

@ -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<typeof setInterval>;
let timeUpdateInterval: ReturnType<typeof setInterval>;
@ -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 @@
</div>
{/if}
<!-- Trade UI -->
{#if showTradeUI}
<div class="absolute inset-0 z-50 flex items-center justify-center bg-black/60">
<div class="w-[420px] rounded-xl bg-gray-900/95 p-5 shadow-2xl backdrop-blur">
<div class="mb-4 flex items-center justify-between">
<h3 class="text-sm font-bold text-emerald-400">Merchant's Wares</h3>
<button
class="text-xs text-gray-500 hover:text-white"
onclick={() => {
showTradeUI = false;
engine?.dialog.close();
dialogActive = false;
}}>Close</button
>
</div>
<div class="flex max-h-80 flex-col gap-2 overflow-y-auto">
{#each MERCHANT_OFFERS as offer}
{@const rarityColor =
offer.rarity === 'rare'
? 'border-blue-500'
: offer.rarity === 'uncommon'
? 'border-green-500'
: 'border-gray-600'}
<div
class="flex items-center justify-between rounded-lg border {rarityColor} bg-gray-800/80 p-3"
>
<div>
<div class="text-sm font-medium text-white">{offer.name}</div>
<div class="text-[10px] text-gray-400">{offer.description}</div>
<div class="mt-1 flex gap-2 text-[10px] text-gray-500">
{#if offer.damage > 0}<span>{offer.damage}</span>{/if}
<span>{offer.range}</span>
<span>{offer.speed}</span>
<span>🛡 {offer.durabilityMax}</span>
{#if offer.element !== 'neutral'}<span class="capitalize text-yellow-400"
>{offer.element}</span
>{/if}
</div>
</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}
onclick={async () => {
const item = createItem(offer.name, generateTradeSprite(), {
damage: offer.damage,
range: offer.range,
speed: offer.speed,
durabilityMax: offer.durabilityMax,
durabilityCurrent: offer.durabilityMax,
element: offer.element as any,
rarity: offer.rarity as any,
particle: offer.particle,
sound: 'hit_default',
});
const slot = inventory.addItem(item);
if (slot >= 0) {
inventory.selectSlot(slot);
await saveItem(item);
await saveInventory(PLAYER_ID, inventory.slots, inventory.heldSlot);
}
}}
>
{inventory.isFull ? 'Full' : 'Buy'}
</button>
</div>
{/each}
</div>
</div>
</div>
{/if}
<!-- Loot pickup hint -->
{#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}
<div class="absolute bottom-36 left-1/2 z-30 -translate-x-1/2">
<button
class="rounded-lg bg-yellow-600/90 px-4 py-2 text-sm text-white shadow-lg backdrop-blur hover:bg-yellow-500"
onclick={async () => {
if (inventory.isFull) return;
const item = createItem(nearby.loot.name, generateTradeSprite(), {
damage: nearby.loot.damage,
range: nearby.loot.range,
speed: nearby.loot.speed,
durabilityMax: nearby.loot.durabilityMax,
durabilityCurrent: nearby.loot.durabilityMax,
element: nearby.loot.element as any,
rarity: nearby.loot.rarity as any,
particle: nearby.loot.particle,
sound: 'hit_default',
});
inventory.addItem(item);
await saveItem(item);
await saveInventory(PLAYER_ID, inventory.slots, inventory.heldSlot);
groundItems = groundItems.filter((g) => g !== nearby);
}}
>
Pick up {nearby.loot.name} (E)
</button>
</div>
{/if}
{/if}
{#if showSpriteEditor}
<div class="absolute inset-0 z-50 flex items-center justify-center bg-black/70">
<SpriteEditor