mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-15 00:41:09 +02:00
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:
parent
939bdbe45b
commit
5f187705e2
3 changed files with 181 additions and 2 deletions
74
apps/manavoxel/apps/web/src/lib/components/Inventory.svelte
Normal file
74
apps/manavoxel/apps/web/src/lib/components/Inventory.svelte
Normal 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>
|
||||
85
apps/manavoxel/apps/web/src/lib/engine/inventory.ts
Normal file
85
apps/manavoxel/apps/web/src/lib/engine/inventory.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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)}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue