feat(manavoxel): add inventory system with item creation from sprite editor

- Inventory class: 8 slots, hold/select, add/remove, rarity-based borders
- GameItem type with sprite data, properties, and rarity
- Inventory UI component with canvas-rendered item thumbnails (Svelte action)
- Rarity border colors (common→legendary), held item highlight
- Right-click to drop items, number keys 1-8 to select slots
- Sprite editor now creates GameItems and adds them to inventory
- Inventory bar always visible at bottom of screen

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-29 09:09:52 +02:00
parent 939bdbe45b
commit 5f187705e2
3 changed files with 181 additions and 2 deletions

View file

@ -0,0 +1,74 @@
<script lang="ts">
import { type Inventory, type GameItem, MAX_INVENTORY_SLOTS } from '$lib/engine/inventory';
let { inventory, onDrop = undefined as ((slot: number) => void) | undefined } = $props();
function drawSprite(canvas: HTMLCanvasElement, item: GameItem) {
const ctx = canvas.getContext('2d')!;
const { pixels, width: w, height: h } = item.sprite;
const scale = Math.min(32 / w, 32 / h);
ctx.clearRect(0, 0, 32, 32);
const offsetX = Math.floor((32 - w * scale) / 2);
const offsetY = Math.floor((32 - h * scale) / 2);
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const i = (y * w + x) * 4;
const a = pixels[i + 3];
if (a === 0) continue;
ctx.fillStyle = `rgba(${pixels[i]},${pixels[i + 1]},${pixels[i + 2]},${a / 255})`;
ctx.fillRect(offsetX + x * scale, offsetY + y * scale, Math.ceil(scale), Math.ceil(scale));
}
}
}
// Svelte action: render item into canvas on mount/update
function itemCanvas(node: HTMLCanvasElement, item: GameItem) {
drawSprite(node, item);
return {
update(newItem: GameItem) {
drawSprite(node, newItem);
},
};
}
const rarityBorder: Record<string, string> = {
common: 'border-gray-600',
uncommon: 'border-green-500',
rare: 'border-blue-500',
epic: 'border-purple-500',
legendary: 'border-yellow-500',
};
</script>
<div class="flex gap-1 rounded-lg bg-gray-800/90 p-1.5 backdrop-blur">
{#each { length: MAX_INVENTORY_SLOTS } as _, i}
{@const item = inventory.slots[i]}
{@const isHeld = inventory.heldSlot === i}
<button
class="relative h-10 w-10 rounded border-2 transition-all {isHeld
? 'scale-110 border-emerald-400 bg-emerald-900/50'
: item
? `${rarityBorder[item.rarity] ?? 'border-gray-600'} bg-gray-900 hover:bg-gray-800`
: 'border-gray-700/50 bg-gray-900/30'}"
onclick={() => inventory.selectSlot(i)}
oncontextmenu={(e) => {
e.preventDefault();
if (item) onDrop?.(i);
}}
title={item ? `${item.name} (${item.rarity}) - Right-click to drop` : `Slot ${i + 1}`}
>
{#if item}
<canvas
width={32}
height={32}
class="h-full w-full"
style="image-rendering: pixelated;"
use:itemCanvas={item}
></canvas>
{/if}
<span class="absolute -bottom-0.5 -right-0.5 text-[8px] text-gray-600">{i + 1}</span>
</button>
{/each}
</div>

View file

@ -0,0 +1,85 @@
import type { SpriteData } from '$lib/editor/sprite-editor.svelte';
import type { ItemProperties, Rarity, ElementType } from '@manavoxel/shared';
export interface GameItem {
id: string;
name: string;
sprite: SpriteData;
properties: ItemProperties;
rarity: Rarity;
}
const defaultProperties: ItemProperties = {
damage: 0,
range: 1,
speed: 1,
durabilityMax: 100,
durabilityCurrent: 100,
element: 'neutral',
rarity: 'common',
sound: 'hit_default',
particle: 'none',
};
let nextItemId = 1;
/** Create a new item from sprite data */
export function createItem(
name: string,
sprite: SpriteData,
partialProps?: Partial<ItemProperties>
): GameItem {
return {
id: `item_${nextItemId++}`,
name,
sprite,
properties: { ...defaultProperties, ...partialProps },
rarity: partialProps?.rarity ?? 'common',
};
}
export const MAX_INVENTORY_SLOTS = 8;
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
get heldItem(): GameItem | null {
if (this.heldSlot < 0 || this.heldSlot >= this.slots.length) return null;
return this.slots[this.heldSlot];
}
/** Add item to first empty slot. Returns slot index or -1 if full. */
addItem(item: GameItem): number {
const emptySlot = this.slots.findIndex((s) => s === null);
if (emptySlot === -1) return -1;
this.slots[emptySlot] = item;
return emptySlot;
}
/** Remove item from a slot */
removeItem(slot: number): GameItem | null {
if (slot < 0 || slot >= this.slots.length) return null;
const item = this.slots[slot];
this.slots[slot] = null;
if (this.heldSlot === slot) this.heldSlot = -1;
return item;
}
/** Select a slot to hold */
selectSlot(slot: number) {
if (slot < 0 || slot >= this.slots.length) return;
this.heldSlot = this.heldSlot === slot ? -1 : slot;
}
/** Check if inventory is full */
get isFull(): boolean {
return this.slots.every((s) => s !== null);
}
/** Count of items */
get count(): number {
return this.slots.filter((s) => s !== null).length;
}
}

View file

@ -5,6 +5,8 @@
import type { ToolType } from '$lib/editor/tools';
import SpriteEditor from '$lib/editor/sprite-editor.svelte';
import type { SpriteData } from '$lib/editor/sprite-editor.svelte';
import InventoryUI from '$lib/components/Inventory.svelte';
import { Inventory, createItem } from '$lib/engine/inventory';
let canvasContainer: HTMLDivElement;
let engine: GameEngine | null = $state(null);
@ -16,7 +18,8 @@
let currentFloor = $state(0);
let totalFloors = $state(1);
let showSpriteEditor = $state(false);
let createdItems: SpriteData[] = $state([]);
let inventory = $state(new Inventory());
let itemCounter = $state(0);
const tools: { id: ToolType; label: string; key: string }[] = [
{ id: 'brush', label: 'Brush', key: 'B' },
@ -217,6 +220,18 @@
</div>
{/if}
<!-- Inventory bar (bottom center, always visible) -->
{#if !showSpriteEditor}
<div class="pointer-events-auto absolute bottom-14 left-1/2 -translate-x-1/2">
<InventoryUI
{inventory}
onDrop={(slot) => {
inventory.removeItem(slot);
}}
/>
</div>
{/if}
<!-- Controls hint (bottom left) -->
<div class="pointer-events-auto absolute bottom-4 left-4">
<div class="rounded-lg bg-gray-800/60 px-3 py-1.5 text-[10px] text-gray-500 backdrop-blur">
@ -236,7 +251,12 @@
width={16}
height={32}
onSave={(data) => {
createdItems = [...createdItems, data];
itemCounter++;
const item = createItem(`Item ${itemCounter}`, data);
const slot = inventory.addItem(item);
if (slot >= 0) {
inventory.selectSlot(slot);
}
showSpriteEditor = false;
}}
onClose={() => (showSpriteEditor = false)}