diff --git a/apps/manavoxel/CLAUDE.md b/apps/manavoxel/CLAUDE.md
index 0ec7b236f..e1af7f14d 100644
--- a/apps/manavoxel/CLAUDE.md
+++ b/apps/manavoxel/CLAUDE.md
@@ -13,9 +13,42 @@
```
apps/manavoxel/
├── apps/
-│ └── web/ # SvelteKit + PixiJS client (@manavoxel/web)
+│ └── web/
+│ └── src/
+│ ├── lib/
+│ │ ├── engine/ # PixiJS game engine
+│ │ │ ├── game.ts # Main engine, game loop, event integration
+│ │ │ ├── tilemap.ts # Chunk-based renderer (32x32), auto-save dirty flag
+│ │ │ ├── camera.ts # Camera with lerp follow + shake effect
+│ │ │ ├── player.ts # Player movement, collision (8-point AABB)
+│ │ │ ├── input.ts # Keyboard + mouse input manager
+│ │ │ ├── particles.ts # 8 particle presets (sparks, fire, ice, etc.)
+│ │ │ ├── area-manager.ts # Area loading, portal transitions, floor switching
+│ │ │ ├── inventory.svelte.ts # Inventory (8 slots) + GameItem type + pickup/drop hooks
+│ │ │ ├── behavior.ts # Event bus + behavior runtime + action executors
+│ │ │ ├── audio.ts # Web Audio API sound system (8 synth presets)
+│ │ │ ├── npc.ts # NPC class + NPCManager (AI, combat, rendering)
+│ │ │ ├── lighting.ts # Lighting engine + day/night cycle
+│ │ │ └── dialog.ts # NPC dialog system + merchant trading
+│ │ ├── editor/ # World & item editing
+│ │ │ ├── tools.ts # Brush, eraser, fill, pipette, undo stack
+│ │ │ ├── sprite-editor.svelte # Pixel art editor (24 colors, mirror, zoom)
+│ │ │ ├── property-panel.svelte # Item stats: damage, range, speed, durability, element
+│ │ │ ├── trigger-editor.svelte # Behavior rule builder (WHEN/THEN/AND)
+│ │ │ └── types.ts # SpriteData interface
+│ │ ├── data/ # Local-first persistence
+│ │ │ ├── local-store.ts # Dexie collections + Base64 encoding
+│ │ │ ├── world-loader.ts # DB ↔ engine converters, item/inventory persistence
+│ │ │ ├── guest-seed.ts # Demo village + house
+│ │ │ └── templates.ts # 5 world templates
+│ │ └── components/ # UI components
+│ │ └── Inventory.svelte # Inventory bar with rarity colors
+│ └── routes/
+│ ├── +page.svelte # Main game page
+│ ├── worlds/ # World management
+│ └── health/ # Health endpoint for Docker
├── packages/
-│ └── shared/ # Shared types (@manavoxel/shared)
+│ └── shared/src/types.ts # Material, Area, Item, Network types (@manavoxel/shared)
├── package.json
└── CLAUDE.md
```
@@ -35,11 +68,11 @@ pnpm dev:web # Start web only
| Layer | Technology |
|-------|------------|
-| **Rendering** | PixiJS 8 (WebGL) |
+| **Rendering** | PixiJS 8 (WebGL), chunk-based tilemap |
| **UI** | SvelteKit 2, Svelte 5 (runes), Tailwind CSS 4 |
| **Local-First** | Dexie.js via @manacore/local-store |
-| **Auth** | Mana Core Auth (JWT) |
-| **i18n** | svelte-i18n (DE, EN, FR, ES, IT) |
+| **Auth** | Mana Core Auth (JWT), guest mode |
+| **PWA** | @vite-pwa/sveltekit |
## Zoom Levels
@@ -52,6 +85,189 @@ pnpm dev:web # Start web only
## Core Concepts
- **Areas**: Streets (10cm) and interiors (5cm) are separate pixel grids connected by portals
-- **Items**: Pixel sprites (1cm) with properties (sliders) and behaviors (trigger-actions)
-- **Floors**: Interiors have multiple floors, connected by stairs
+- **Items**: Pixel sprites (1cm) with properties and behaviors, persisted in IndexedDB
+- **Floors**: Interiors have multiple floors, connected by stairs (F key)
- **Local-First**: Everything works offline via Dexie.js, syncs via mana-sync
+
+## Data Model (IndexedDB)
+
+| Collection | Indexes | Purpose |
+|-----------|---------|---------|
+| `worlds` | creatorId, isPublished, name, template | World metadata + startAreaId |
+| `areas` | worldId, type, [worldId+name] | Pixel grid data (Base64 Uint16), portals, entities |
+| `items` | creatorId, rarity, isPublished, name | Sprite data, properties, behaviors |
+| `inventories` | playerId, [playerId+slot], itemId | Slot assignments per player |
+
+## Persistence
+
+- **Items** saved to IndexedDB on create/edit (sprite, properties, behaviors)
+- **Inventory** saved on item add/remove/drop and on page unload
+- **Area pixels** auto-saved every 10s when dirty (tilemap.isDirty flag)
+- **Worlds** persisted on create/delete via world-loader.ts
+
+## Behavior System
+
+Items can have programmable behaviors via the Trigger Editor:
+
+```
+WHEN [trigger] THEN [action] AND [action] ...
+```
+
+### Architecture
+
+```
+GameEventBus → BehaviorRuntime → Action Executors
+├── emit(event) ├── registerItem() ├── damage/heal
+├── on(type, fn) ├── match triggers ├── particle/sound
+├── tickTimer() ├── check conditions ├── setPixel/deletePixel
+└── get/setVariable() └── execute actions ├── teleport/message
+ ├── cameraShake
+ └── setVariable/sendEvent
+```
+
+### Triggers (10 types)
+
+| Trigger | Fires when |
+|---------|-----------|
+| `onUse` | Player presses Space with item held |
+| `onTouch` | Player collides with entity (not yet wired) |
+| `onPickup` | Item added to inventory |
+| `onDrop` | Item removed from inventory |
+| `onTimer` | Every X seconds (frame-based tick) |
+| `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) |
+
+### Actions (11 implemented)
+
+| Action | Effect |
+|--------|--------|
+| `damage` | Reduce player HP by amount (fires onHpBelow) |
+| `heal` | Restore player HP by amount |
+| `particle` | Spawn particle effect at facing direction |
+| `sound` | Play synthesized sound preset |
+| `setPixel` | Place material in radius at facing direction |
+| `deletePixel` | Destroy pixels in radius at facing direction |
+| `teleport` | Move player to x,y coordinates |
+| `message` | Show floating text for 3 seconds |
+| `setVariable` | Set a global game variable |
+| `sendEvent` | Fire a custom event (chains behaviors) |
+| `cameraShake` | Shake camera with intensity |
+
+### Default Behavior (no rules defined)
+
+Items without behaviors use properties directly:
+- **Sound** → play configured sound preset on use
+- **Damage ≥ 20** → destroy pixels in facing direction (radius = damage/30)
+- **Particle** → spawn configured particle (or element-based default)
+- **Element** → auto-selects particle: fire→fire_burst, ice→ice_shards, etc.
+- **Durability** → decreases per use, item breaks with shatter + sound at 0
+
+## Item Properties
+
+| Property | Range | Effect |
+|----------|-------|--------|
+| **Damage** | 0-100 | Pixel destruction radius, action damage amount |
+| **Range** | 1-10 | Effect distance: `10 + range * 3` pixels |
+| **Speed** | 1-10 | Cooldown: `30 / speed` frames (higher = faster) |
+| **Durability** | 1-200 | Uses before item breaks (-1 per use, shatter on 0) |
+| **Element** | neutral/fire/ice/poison/lightning | Auto-particle selection |
+| **Rarity** | common→legendary | Visual border color in inventory |
+| **Sound** | preset list | Synthesized via Web Audio API (8 presets) |
+| **Particle** | preset list | Overrides element-based default |
+
+## NPC System
+
+NPCs are spawned from EntityDef entries in area data. Place them via the NPC tool in editor mode.
+
+### NPC Types
+
+| Type | Color | AI Behavior |
+|------|-------|-------------|
+| `hostile` | Red | Patrol → Chase → Attack player on sight |
+| `passive` | Green | Idle, no aggression |
+| `merchant` | Yellow | Idle, no aggression (future: trading) |
+| `guard` | Blue | Patrol → Chase on sight |
+
+### AI States
+
+`idle` → `patrol` (wander ±40px from spawn) → `chase` (within 60px range) → `attack` (within 8px, deals contact damage)
+
+### Combat
+
+- NPCs have HP (30 hostile, 50 others) and deal contact damage (5 for hostile)
+- Items damage NPCs in facing direction based on item range
+- Dead NPCs show shatter particles and despawn
+- NPC damage triggers aggro (idle/patrol → chase)
+- Attack cooldown: ~1.5s between NPC attacks
+
+### Editor Placement
+
+- Select NPC tool (N key) in editor
+- Choose type (hostile/passive/merchant/guard)
+- Click on map to place
+- Entities auto-saved with area data every 10s
+
+## Lighting System
+
+- **Darkness overlay** with radial light sources using PixiJS Graphics
+- **Emissive materials** (Torch, Lava) auto-detected as light sources
+- **Interiors** are dark by default (ambient 0.2), streets follow day/night cycle
+- Light sources have radius, color, and intensity
+- Sampling every 4th pixel for performance
+
+## Day/Night Cycle
+
+- Time runs from 0.0 (midnight) → 0.25 (sunrise) → 0.5 (noon) → 0.75 (sunset) → 1.0 (midnight)
+- ~10 min real time = 1 full day cycle
+- Ambient light: 1.0 during day, 0.15 at night, smooth transitions
+- HUD shows current time (HH:MM format), blue at night, yellow during day
+- `onDayNight` trigger fires on day↔night transitions
+- Only affects streets (interiors have fixed ambient)
+
+## Sprite Animation
+
+- Items support multi-frame animation (stored as concatenated RGBA frames)
+- Sprite Editor: Add/Remove frames, navigate with ←/→, Play/Stop preview
+- New frame copies current frame (easy keyframe workflow)
+- `frames` field in SpriteData, persisted in IndexedDB via `animationFrames`
+
+## Dialog System
+
+- E key near non-hostile NPCs opens dialog
+- Dialog templates per NPC type (merchant, guard, passive)
+- Options with actions: close, trade, next
+- Passive NPCs have randomized flavor text
+- Merchant NPCs offer "Show wares" / "Maybe later"
+- Game input paused during dialog
+
+## Game Controls
+
+| Key | Game Mode | Editor Mode |
+|-----|-----------|-------------|
+| WASD/Arrows | Move player | Pan camera |
+| Space | Use held item | — |
+| E | Enter portal | — |
+| F | Switch floor | — |
+| Tab | Toggle editor | Toggle editor |
+| 1-9 | — | Select material |
+| B/E/G/I | — | Brush/Eraser/Fill/Pipette |
+| [ / ] | — | Brush size |
+| Ctrl+Z/Y | Undo/Redo | Undo/Redo |
+| Scroll | Zoom | Zoom |
+
+## Key Patterns
+
+- **SSR disabled** (`+layout.ts: ssr = false`) — pure client-side SPA
+- **Game loop** via `app.ticker.add()` — ~60fps update cycle
+- **Chunk rendering** — 32x32 pixel chunks, only dirty chunks re-render
+- **Base64 encoding** — binary data (pixelData, spriteData) encoded for Dexie storage
+- **Svelte 5 runes** — `$state`, `$derived`, `$effect` for reactive UI state
+
+## Not Yet Implemented
+
+- **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
diff --git a/apps/manavoxel/apps/web/src/lib/components/Inventory.svelte b/apps/manavoxel/apps/web/src/lib/components/Inventory.svelte
index b80ef0100..cac8aee33 100644
--- a/apps/manavoxel/apps/web/src/lib/components/Inventory.svelte
+++ b/apps/manavoxel/apps/web/src/lib/components/Inventory.svelte
@@ -9,13 +9,15 @@
function drawSprite(canvas: HTMLCanvasElement, item: GameItem) {
const ctx = canvas.getContext('2d')!;
- const { pixels, width: w, height: h } = item.sprite;
+ const { pixels, width: w, height: h, frames = 1 } = item.sprite;
+ const frameSize = w * h * 4;
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);
+ // Always draw first frame in inventory
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const i = (y * w + x) * 4;
@@ -44,6 +46,12 @@
epic: 'border-purple-500',
legendary: 'border-yellow-500',
};
+
+ function durabilityColor(ratio: number): string {
+ if (ratio > 0.6) return '#22c55e'; // green
+ if (ratio > 0.3) return '#eab308'; // yellow
+ return '#ef4444'; // red
+ }
@@ -74,6 +82,15 @@
style="image-rendering: pixelated;"
use:itemCanvas={item}
>
+ {@const ratio = item.properties.durabilityCurrent / item.properties.durabilityMax}
+ {#if ratio < 1}
+
+ {/if}
{/if}
{i + 1}
diff --git a/apps/manavoxel/apps/web/src/lib/data/world-loader.ts b/apps/manavoxel/apps/web/src/lib/data/world-loader.ts
index ca34276a7..8afbaa6b5 100644
--- a/apps/manavoxel/apps/web/src/lib/data/world-loader.ts
+++ b/apps/manavoxel/apps/web/src/lib/data/world-loader.ts
@@ -7,13 +7,25 @@ import {
worldCollection,
areaCollection,
itemCollection,
+ inventoryCollection,
decodeBytes,
encodeBytes,
type LocalWorld,
type LocalArea,
+ type LocalItem,
+ type LocalInventorySlot,
} from './local-store';
-import type { Area, PortalDef, EntityDef, Material } from '@manavoxel/shared';
+import type {
+ Area,
+ PortalDef,
+ EntityDef,
+ Material,
+ ItemProperties,
+ TriggerAction,
+ Rarity,
+} from '@manavoxel/shared';
import { DEFAULT_MATERIALS } from '@manavoxel/shared';
+import type { GameItem } from '$lib/engine/inventory.svelte';
/** Load a world and all its areas from IndexedDB */
export async function loadWorld(worldId: string): Promise<{
@@ -42,6 +54,17 @@ export async function saveAreaPixels(areaId: string, pixelData: Uint8Array) {
});
}
+/** Save an area's entity definitions back to IndexedDB */
+export async function saveAreaEntities(
+ areaId: string,
+ entities: import('@manavoxel/shared').EntityDef[]
+) {
+ await areaCollection.update(areaId, {
+ entities: JSON.stringify(entities),
+ updatedAt: new Date().toISOString(),
+ });
+}
+
/** Create a new world with areas in IndexedDB */
export async function createWorld(
name: string,
@@ -83,6 +106,117 @@ export async function deleteWorld(worldId: string) {
await worldCollection.delete(worldId);
}
+// ─── Item Persistence ──────────────────────────────────────
+
+/** Save a GameItem to IndexedDB */
+export async function saveItem(item: GameItem): Promise
{
+ const existing = await itemCollection.get(item.id);
+ const localItem: Partial & { id: string } = {
+ id: item.id,
+ creatorId: 'local',
+ name: item.name,
+ description: '',
+ spriteData: encodeBytes(item.sprite.pixels),
+ spriteWidth: item.sprite.width,
+ spriteHeight: item.sprite.height,
+ animationFrames: item.sprite.frames || 1,
+ resolution: 0.01,
+ properties: JSON.stringify(item.properties),
+ behavior: JSON.stringify(item.behaviors ?? []),
+ rarity: item.rarity,
+ isPublished: false,
+ };
+
+ if (existing) {
+ await itemCollection.update(item.id, localItem);
+ } else {
+ await itemCollection.insert(localItem as LocalItem);
+ }
+}
+
+/** Load all items from IndexedDB */
+export async function loadAllItems(): Promise {
+ const dbItems = await itemCollection.getAll();
+ return dbItems.map(dbItemToGameItem);
+}
+
+/** Delete an item from IndexedDB */
+export async function deleteItem(itemId: string): Promise {
+ await itemCollection.delete(itemId);
+}
+
+/** Save inventory state to IndexedDB */
+export async function saveInventory(
+ playerId: string,
+ slots: (GameItem | null)[],
+ heldSlot: number
+): Promise {
+ // Clear existing inventory for this player
+ const existing = await inventoryCollection.getAll({ playerId });
+ for (const slot of existing) {
+ await inventoryCollection.delete(slot.id);
+ }
+
+ // Save current slots
+ for (let i = 0; i < slots.length; i++) {
+ const item = slots[i];
+ if (!item) continue;
+ await inventoryCollection.insert({
+ id: `${playerId}_slot_${i}`,
+ playerId,
+ itemId: item.id,
+ slot: i,
+ quantity: 1,
+ instanceData: JSON.stringify({ heldSlot }),
+ });
+ }
+}
+
+/** Load inventory from IndexedDB */
+export async function loadInventory(
+ playerId: string
+): Promise<{ slots: (string | null)[]; heldSlot: number }> {
+ const dbSlots = await inventoryCollection.getAll({ playerId });
+ const slots: (string | null)[] = Array(8).fill(null);
+ let heldSlot = -1;
+
+ 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, {});
+ if (data.heldSlot !== undefined) heldSlot = data.heldSlot;
+ }
+
+ return { slots, heldSlot };
+}
+
+function dbItemToGameItem(dbItem: LocalItem): GameItem {
+ return {
+ id: dbItem.id,
+ name: dbItem.name,
+ sprite: {
+ pixels: decodeBytes(dbItem.spriteData),
+ width: dbItem.spriteWidth,
+ height: dbItem.spriteHeight,
+ frames: dbItem.animationFrames || 1,
+ },
+ properties: safeJsonParse(dbItem.properties, {
+ damage: 0,
+ range: 1,
+ speed: 1,
+ durabilityMax: 100,
+ durabilityCurrent: 100,
+ element: 'neutral',
+ rarity: 'common',
+ sound: 'hit_default',
+ particle: 'none',
+ }),
+ rarity: dbItem.rarity as Rarity,
+ behaviors: safeJsonParse(dbItem.behavior, []),
+ };
+}
+
// ─── Converters ─────────────────────────────────────────────
function dbAreaToEngineArea(dbArea: LocalArea): Area {
diff --git a/apps/manavoxel/apps/web/src/lib/editor/sprite-editor.svelte b/apps/manavoxel/apps/web/src/lib/editor/sprite-editor.svelte
index d1d152217..ad8a3778b 100644
--- a/apps/manavoxel/apps/web/src/lib/editor/sprite-editor.svelte
+++ b/apps/manavoxel/apps/web/src/lib/editor/sprite-editor.svelte
@@ -1,5 +1,6 @@
@@ -349,6 +405,41 @@
>
+
+
+
+ Frame {currentFrame + 1}/{frames.length}
+
+
+
+
+
+
+ {#if frames.length > 1}
+
+ {/if}
+
+
+
{#each [{ id: 'brush', label: 'Brush', key: 'B' }, { id: 'eraser', label: 'Eraser', key: 'E' }, { id: 'fill', label: 'Fill', key: 'G' }, { id: 'pipette', label: 'Pick', key: 'I' }] as tool}
diff --git a/apps/manavoxel/apps/web/src/lib/editor/tools.ts b/apps/manavoxel/apps/web/src/lib/editor/tools.ts
index 72742a9d2..1e600158e 100644
--- a/apps/manavoxel/apps/web/src/lib/editor/tools.ts
+++ b/apps/manavoxel/apps/web/src/lib/editor/tools.ts
@@ -67,7 +67,7 @@ export class UndoStack {
// ─── Editor Tools ───────────────────────────────────────────
-export type ToolType = 'brush' | 'eraser' | 'fill' | 'pipette' | 'box' | 'line';
+export type ToolType = 'brush' | 'eraser' | 'fill' | 'pipette' | 'box' | 'line' | 'npc';
/**
* Place a single pixel (or brush area), recording to undo stack.
diff --git a/apps/manavoxel/apps/web/src/lib/editor/types.ts b/apps/manavoxel/apps/web/src/lib/editor/types.ts
new file mode 100644
index 000000000..7913bfaf7
--- /dev/null
+++ b/apps/manavoxel/apps/web/src/lib/editor/types.ts
@@ -0,0 +1,19 @@
+/** Pixel sprite data for items — supports multi-frame animation */
+export interface SpriteData {
+ pixels: Uint8Array; // RGBA flat array (all frames concatenated)
+ width: number;
+ height: number;
+ frames: number; // number of animation frames (1 = static)
+}
+
+/** Get pixel data for a specific frame */
+export function getFramePixels(sprite: SpriteData, frame: number): Uint8Array {
+ const frameSize = sprite.width * sprite.height * 4;
+ const offset = frame * frameSize;
+ return sprite.pixels.slice(offset, offset + frameSize);
+}
+
+/** Get total frame count */
+export function getFrameCount(sprite: SpriteData): number {
+ return sprite.frames || 1;
+}
diff --git a/apps/manavoxel/apps/web/src/lib/engine/audio.ts b/apps/manavoxel/apps/web/src/lib/engine/audio.ts
new file mode 100644
index 000000000..5ca01a59d
--- /dev/null
+++ b/apps/manavoxel/apps/web/src/lib/engine/audio.ts
@@ -0,0 +1,193 @@
+/**
+ * ManaVoxel Sound System — Synthesized sounds via Web Audio API
+ *
+ * All sounds are procedurally generated, no audio files needed.
+ */
+
+let ctx: AudioContext | null = null;
+
+function getCtx(): AudioContext {
+ if (!ctx) ctx = new AudioContext();
+ if (ctx.state === 'suspended') ctx.resume();
+ return ctx;
+}
+
+type SoundPreset = (ac: AudioContext, time: number) => void;
+
+const PRESETS: Record
= {
+ hit_default(ac, t) {
+ // Short noise burst
+ const osc = ac.createOscillator();
+ const gain = ac.createGain();
+ osc.type = 'sawtooth';
+ osc.frequency.setValueAtTime(200, t);
+ osc.frequency.exponentialRampToValueAtTime(80, t + 0.1);
+ gain.gain.setValueAtTime(0.3, t);
+ gain.gain.exponentialRampToValueAtTime(0.001, t + 0.15);
+ osc.connect(gain).connect(ac.destination);
+ osc.start(t);
+ osc.stop(t + 0.15);
+ },
+
+ hit_sword(ac, t) {
+ // Metallic slash
+ const osc = ac.createOscillator();
+ const gain = ac.createGain();
+ osc.type = 'square';
+ osc.frequency.setValueAtTime(800, t);
+ osc.frequency.exponentialRampToValueAtTime(200, t + 0.08);
+ gain.gain.setValueAtTime(0.2, t);
+ gain.gain.exponentialRampToValueAtTime(0.001, t + 0.12);
+ osc.connect(gain).connect(ac.destination);
+ osc.start(t);
+ osc.stop(t + 0.12);
+ },
+
+ explosion(ac, t) {
+ // Low rumble + noise
+ const osc = ac.createOscillator();
+ const gain = ac.createGain();
+ osc.type = 'sawtooth';
+ osc.frequency.setValueAtTime(100, t);
+ osc.frequency.exponentialRampToValueAtTime(20, t + 0.4);
+ gain.gain.setValueAtTime(0.4, t);
+ gain.gain.exponentialRampToValueAtTime(0.001, t + 0.5);
+ osc.connect(gain).connect(ac.destination);
+ osc.start(t);
+ osc.stop(t + 0.5);
+
+ // High click
+ const osc2 = ac.createOscillator();
+ const gain2 = ac.createGain();
+ osc2.type = 'square';
+ osc2.frequency.setValueAtTime(400, t);
+ osc2.frequency.exponentialRampToValueAtTime(50, t + 0.1);
+ gain2.gain.setValueAtTime(0.3, t);
+ gain2.gain.exponentialRampToValueAtTime(0.001, t + 0.15);
+ osc2.connect(gain2).connect(ac.destination);
+ osc2.start(t);
+ osc2.stop(t + 0.15);
+ },
+
+ heal(ac, t) {
+ // Rising chime
+ const osc = ac.createOscillator();
+ const gain = ac.createGain();
+ osc.type = 'sine';
+ osc.frequency.setValueAtTime(400, t);
+ osc.frequency.linearRampToValueAtTime(800, t + 0.2);
+ gain.gain.setValueAtTime(0.2, t);
+ gain.gain.linearRampToValueAtTime(0.15, t + 0.1);
+ gain.gain.exponentialRampToValueAtTime(0.001, t + 0.4);
+ osc.connect(gain).connect(ac.destination);
+ osc.start(t);
+ osc.stop(t + 0.4);
+
+ // Second note
+ const osc2 = ac.createOscillator();
+ const gain2 = ac.createGain();
+ osc2.type = 'sine';
+ osc2.frequency.setValueAtTime(600, t + 0.1);
+ osc2.frequency.linearRampToValueAtTime(1000, t + 0.3);
+ gain2.gain.setValueAtTime(0.15, t + 0.1);
+ gain2.gain.exponentialRampToValueAtTime(0.001, t + 0.5);
+ osc2.connect(gain2).connect(ac.destination);
+ osc2.start(t + 0.1);
+ osc2.stop(t + 0.5);
+ },
+
+ whoosh(ac, t) {
+ // Fast sweep
+ const osc = ac.createOscillator();
+ const gain = ac.createGain();
+ osc.type = 'sine';
+ osc.frequency.setValueAtTime(300, t);
+ osc.frequency.exponentialRampToValueAtTime(1200, t + 0.05);
+ osc.frequency.exponentialRampToValueAtTime(100, t + 0.15);
+ gain.gain.setValueAtTime(0.2, t);
+ gain.gain.exponentialRampToValueAtTime(0.001, t + 0.2);
+ osc.connect(gain).connect(ac.destination);
+ osc.start(t);
+ osc.stop(t + 0.2);
+ },
+
+ pickup(ac, t) {
+ // Quick ascending blip
+ const osc = ac.createOscillator();
+ const gain = ac.createGain();
+ osc.type = 'sine';
+ osc.frequency.setValueAtTime(500, t);
+ osc.frequency.linearRampToValueAtTime(1200, t + 0.08);
+ gain.gain.setValueAtTime(0.2, t);
+ gain.gain.exponentialRampToValueAtTime(0.001, t + 0.15);
+ osc.connect(gain).connect(ac.destination);
+ osc.start(t);
+ osc.stop(t + 0.15);
+ },
+
+ break: function (ac, t) {
+ // Crunch/shatter
+ const osc = ac.createOscillator();
+ const gain = ac.createGain();
+ osc.type = 'sawtooth';
+ osc.frequency.setValueAtTime(300, t);
+ osc.frequency.exponentialRampToValueAtTime(30, t + 0.2);
+ gain.gain.setValueAtTime(0.35, t);
+ gain.gain.exponentialRampToValueAtTime(0.001, t + 0.25);
+ osc.connect(gain).connect(ac.destination);
+ osc.start(t);
+ osc.stop(t + 0.25);
+
+ const osc2 = ac.createOscillator();
+ const gain2 = ac.createGain();
+ osc2.type = 'square';
+ osc2.frequency.setValueAtTime(150, t + 0.02);
+ osc2.frequency.exponentialRampToValueAtTime(20, t + 0.15);
+ gain2.gain.setValueAtTime(0.2, t + 0.02);
+ gain2.gain.exponentialRampToValueAtTime(0.001, t + 0.2);
+ osc2.connect(gain2).connect(ac.destination);
+ osc2.start(t + 0.02);
+ osc2.stop(t + 0.2);
+ },
+
+ magic(ac, t) {
+ // Shimmer with vibrato
+ const osc = ac.createOscillator();
+ const gain = ac.createGain();
+ const lfo = ac.createOscillator();
+ const lfoGain = ac.createGain();
+
+ lfo.frequency.value = 12;
+ lfoGain.gain.value = 30;
+ lfo.connect(lfoGain).connect(osc.frequency);
+
+ osc.type = 'sine';
+ osc.frequency.setValueAtTime(600, t);
+ osc.frequency.linearRampToValueAtTime(900, t + 0.3);
+ gain.gain.setValueAtTime(0.2, t);
+ gain.gain.exponentialRampToValueAtTime(0.001, t + 0.5);
+ osc.connect(gain).connect(ac.destination);
+ osc.start(t);
+ lfo.start(t);
+ osc.stop(t + 0.5);
+ lfo.stop(t + 0.5);
+ },
+};
+
+/** Play a sound preset by name */
+export function playSound(name: string) {
+ const preset = PRESETS[name];
+ if (!preset) return;
+
+ try {
+ const ac = getCtx();
+ preset(ac, ac.currentTime);
+ } catch {
+ // Audio context may fail silently (e.g., no user gesture yet)
+ }
+}
+
+/** Check if a sound name is valid */
+export function isValidSound(name: string): boolean {
+ return name in PRESETS;
+}
diff --git a/apps/manavoxel/apps/web/src/lib/engine/behavior.ts b/apps/manavoxel/apps/web/src/lib/engine/behavior.ts
new file mode 100644
index 000000000..6fade7150
--- /dev/null
+++ b/apps/manavoxel/apps/web/src/lib/engine/behavior.ts
@@ -0,0 +1,327 @@
+/**
+ * Behavior Runtime — Event System + Trigger Evaluator + Action Executors
+ *
+ * Evaluates TriggerAction[] behaviors on items:
+ * Event fired → matching triggers found → conditions checked → actions executed
+ */
+
+import type { TriggerAction } from '@manavoxel/shared';
+import type { GameItem } from './inventory.svelte';
+import { playSound } from './audio';
+
+// ─── Event Types ──────────────────────────────────────────────
+
+export type GameEventType =
+ | 'onUse'
+ | 'onTouch'
+ | 'onPickup'
+ | 'onDrop'
+ | 'onTimer'
+ | 'onHpBelow'
+ | 'onNearItem'
+ | 'onAreaEnter'
+ | 'onCustomEvent'
+ | 'onDayNight';
+
+export interface GameEvent {
+ type: GameEventType;
+ item: GameItem;
+ playerX: number;
+ playerY: number;
+ playerDirection: number;
+ playerHp: number;
+ params?: Record;
+}
+
+// ─── Action Context (passed to executors) ─────────────────────
+
+export interface ActionContext {
+ // Player
+ playerX: number;
+ playerY: number;
+ playerDirection: number;
+ playerHp: number;
+ setPlayerHp: (hp: number) => void;
+ teleportPlayer: (x: number, y: number) => void;
+
+ // World
+ setPixel: (x: number, y: number, material: number) => void;
+ getPixel: (x: number, y: number) => number;
+ tileSize: number;
+
+ // Effects
+ spawnParticles: (type: string, x: number, y: number) => void;
+ shakeCamera: (intensity: number, duration: number) => void;
+ showMessage: (text: string) => void;
+
+ // Item being used
+ item: GameItem;
+}
+
+// ─── Event Bus ────────────────────────────────────────────────
+
+type EventListener = (event: GameEvent) => void;
+
+export class GameEventBus {
+ private _listeners = new Map();
+ private _timers = new Map(); // itemId → accumulated frames
+ private _variables = new Map(); // global game variables
+
+ on(type: GameEventType, listener: EventListener): () => void {
+ const list = this._listeners.get(type) ?? [];
+ list.push(listener);
+ this._listeners.set(type, list);
+ return () => {
+ const idx = list.indexOf(listener);
+ if (idx >= 0) list.splice(idx, 1);
+ };
+ }
+
+ emit(event: GameEvent) {
+ const listeners = this._listeners.get(event.type);
+ if (!listeners) return;
+ for (const listener of listeners) {
+ listener(event);
+ }
+ }
+
+ getVariable(name: string): unknown {
+ return this._variables.get(name);
+ }
+
+ setVariable(name: string, value: unknown) {
+ this._variables.set(name, value);
+ }
+
+ /** Track timer for an item, returns true when interval elapsed */
+ tickTimer(itemId: string, intervalFrames: number): boolean {
+ const current = (this._timers.get(itemId) ?? 0) + 1;
+ if (current >= intervalFrames) {
+ this._timers.set(itemId, 0);
+ return true;
+ }
+ this._timers.set(itemId, current);
+ return false;
+ }
+
+ clearTimers() {
+ this._timers.clear();
+ }
+
+ destroy() {
+ this._listeners.clear();
+ this._timers.clear();
+ this._variables.clear();
+ }
+}
+
+// ─── Behavior Runtime ─────────────────────────────────────────
+
+export class BehaviorRuntime {
+ private _eventBus: GameEventBus;
+ private _cleanup: (() => void)[] = [];
+
+ constructor(eventBus: GameEventBus) {
+ this._eventBus = eventBus;
+ }
+
+ /** Register an item's behaviors so they respond to events */
+ registerItem(item: GameItem, behaviors: TriggerAction[], getContext: () => ActionContext) {
+ for (const behavior of behaviors) {
+ const triggerType = behavior.trigger.type as GameEventType;
+
+ const unsub = this._eventBus.on(triggerType, (event) => {
+ // Only respond to events for this item
+ if (event.item.id !== item.id) return;
+
+ // Trigger-specific parameter checks
+ if (triggerType === 'onHpBelow') {
+ const threshold = Number(behavior.trigger.params.threshold ?? 50);
+ if (event.playerHp >= threshold) return;
+ }
+ if (triggerType === 'onCustomEvent') {
+ const expected = String(behavior.trigger.params.eventName ?? '');
+ const received = String(event.params?.eventName ?? '');
+ if (expected && expected !== received) return;
+ }
+
+ // Check conditions
+ if (behavior.conditions && !this._checkConditions(behavior.conditions, event)) return;
+
+ // Execute actions sequentially
+ const ctx = getContext();
+ this._executeActions(behavior.actions, ctx);
+ });
+
+ this._cleanup.push(unsub);
+ }
+ }
+
+ /** Unregister all behaviors */
+ destroy() {
+ for (const unsub of this._cleanup) unsub();
+ this._cleanup = [];
+ }
+
+ /** Check if HP dropped below any registered thresholds and fire onHpBelow */
+ private _checkHpBelow(newHp: number, ctx: ActionContext) {
+ this._eventBus.emit({
+ type: 'onHpBelow',
+ item: ctx.item,
+ playerX: ctx.playerX,
+ playerY: ctx.playerY,
+ playerDirection: ctx.playerDirection,
+ playerHp: newHp,
+ params: { currentHp: newHp },
+ });
+ }
+
+ private _checkConditions(
+ conditions: { type: string; params: Record }[],
+ event: GameEvent
+ ): boolean {
+ for (const condition of conditions) {
+ switch (condition.type) {
+ case 'hpAbove':
+ if (event.playerHp <= Number(condition.params.threshold ?? 0)) return false;
+ break;
+ case 'hpBelow':
+ if (event.playerHp >= Number(condition.params.threshold ?? 100)) return false;
+ break;
+ case 'hasVariable':
+ if (!this._eventBus.getVariable(String(condition.params.name ?? ''))) return false;
+ break;
+ case 'variableEquals': {
+ const val = this._eventBus.getVariable(String(condition.params.name ?? ''));
+ if (val !== condition.params.value) return false;
+ break;
+ }
+ }
+ }
+ return true;
+ }
+
+ private _executeActions(
+ actions: { type: string; params: Record }[],
+ ctx: ActionContext
+ ) {
+ for (const action of actions) {
+ this._executeAction(action, ctx);
+ }
+ }
+
+ private _executeAction(
+ action: { type: string; params: Record },
+ ctx: ActionContext
+ ) {
+ const effectDistance = 10 + ctx.item.properties.range * 3;
+ const dirOffsets = [
+ { dx: 0, dy: -1 }, // up
+ { dx: 1, dy: 0 }, // right
+ { dx: 0, dy: 1 }, // down
+ { dx: -1, dy: 0 }, // left
+ ];
+ const dir = dirOffsets[ctx.playerDirection] ?? dirOffsets[2];
+ const facingX = ctx.playerX + dir.dx * effectDistance;
+ const facingY = ctx.playerY + dir.dy * effectDistance;
+
+ switch (action.type) {
+ case 'damage': {
+ const amount = Number(action.params.amount ?? 10);
+ const newHp = Math.max(0, ctx.playerHp - amount);
+ ctx.setPlayerHp(newHp);
+ // Fire onHpBelow for all items that have threshold triggers
+ this._checkHpBelow(newHp, ctx);
+ break;
+ }
+
+ case 'heal': {
+ const amount = Number(action.params.amount ?? 10);
+ ctx.setPlayerHp(Math.min(100, ctx.playerHp + amount));
+ break;
+ }
+
+ case 'particle': {
+ const type = String(action.params.type ?? 'sparks');
+ ctx.spawnParticles(type, facingX, facingY);
+ break;
+ }
+
+ case 'sound': {
+ const name = String(action.params.name ?? 'hit_default');
+ playSound(name);
+ break;
+ }
+
+ case 'setPixel': {
+ const material = Number(action.params.material ?? 1);
+ const radius = Number(action.params.radius ?? 1);
+ const tileX = Math.floor(facingX / ctx.tileSize);
+ const tileY = Math.floor(facingY / ctx.tileSize);
+ for (let dy = -radius; dy <= radius; dy++) {
+ for (let dx = -radius; dx <= radius; dx++) {
+ if (Math.abs(dx) + Math.abs(dy) <= radius) {
+ ctx.setPixel(tileX + dx, tileY + dy, material);
+ }
+ }
+ }
+ break;
+ }
+
+ case 'deletePixel': {
+ const radius = Number(action.params.radius ?? 2);
+ const tileX = Math.floor(facingX / ctx.tileSize);
+ const tileY = Math.floor(facingY / ctx.tileSize);
+ for (let dy = -radius; dy <= radius; dy++) {
+ for (let dx = -radius; dx <= radius; dx++) {
+ if (Math.abs(dx) + Math.abs(dy) <= radius) {
+ ctx.setPixel(tileX + dx, tileY + dy, 0); // MATERIAL_AIR
+ }
+ }
+ }
+ break;
+ }
+
+ case 'teleport': {
+ const x = Number(action.params.x ?? 0);
+ const y = Number(action.params.y ?? 0);
+ ctx.teleportPlayer(x, y);
+ break;
+ }
+
+ case 'message': {
+ const text = String(action.params.text ?? '');
+ if (text) ctx.showMessage(text);
+ break;
+ }
+
+ case 'setVariable': {
+ const name = String(action.params.name ?? '');
+ if (name) this._eventBus.setVariable(name, action.params.value);
+ break;
+ }
+
+ case 'sendEvent': {
+ const eventName = String(action.params.eventName ?? '');
+ if (eventName) {
+ this._eventBus.emit({
+ type: 'onCustomEvent',
+ item: ctx.item,
+ playerX: ctx.playerX,
+ playerY: ctx.playerY,
+ playerDirection: ctx.playerDirection,
+ playerHp: ctx.playerHp,
+ params: { eventName },
+ });
+ }
+ break;
+ }
+
+ case 'cameraShake': {
+ const intensity = Number(action.params.intensity ?? 3);
+ ctx.shakeCamera(intensity, 15); // 15 frames ≈ 0.25s
+ break;
+ }
+ }
+ }
+}
diff --git a/apps/manavoxel/apps/web/src/lib/engine/camera.ts b/apps/manavoxel/apps/web/src/lib/engine/camera.ts
index e3ef85551..8fd3987e1 100644
--- a/apps/manavoxel/apps/web/src/lib/engine/camera.ts
+++ b/apps/manavoxel/apps/web/src/lib/engine/camera.ts
@@ -7,6 +7,8 @@ export class Camera {
private _scale = 1.5; // Start zoomed out more to see the village
private _minScale = 0.3;
private _maxScale = 6;
+ private _shakeIntensity = 0;
+ private _shakeDuration = 0;
get x() {
return this._x;
@@ -54,10 +56,26 @@ export class Camera {
};
}
+ /** Start a camera shake effect */
+ shake(intensity: number, durationFrames: number) {
+ this._shakeIntensity = intensity;
+ this._shakeDuration = durationFrames;
+ }
+
/** Apply camera transform to the world container */
update(screenWidth: number, screenHeight: number) {
- this._container.x = screenWidth / 2 - this._x * this._scale;
- this._container.y = screenHeight / 2 - this._y * this._scale;
+ let offsetX = 0;
+ let offsetY = 0;
+
+ if (this._shakeDuration > 0) {
+ offsetX = (Math.random() - 0.5) * this._shakeIntensity * 2;
+ offsetY = (Math.random() - 0.5) * this._shakeIntensity * 2;
+ this._shakeDuration--;
+ if (this._shakeDuration <= 0) this._shakeIntensity = 0;
+ }
+
+ this._container.x = screenWidth / 2 - this._x * this._scale + offsetX;
+ this._container.y = screenHeight / 2 - this._y * this._scale + offsetY;
this._container.scale.set(this._scale);
}
}
diff --git a/apps/manavoxel/apps/web/src/lib/engine/dialog.ts b/apps/manavoxel/apps/web/src/lib/engine/dialog.ts
new file mode 100644
index 000000000..94abc0ccc
--- /dev/null
+++ b/apps/manavoxel/apps/web/src/lib/engine/dialog.ts
@@ -0,0 +1,144 @@
+/**
+ * Dialog System — NPC interaction, text bubbles, merchant trading
+ */
+
+import type { GameItem } from './inventory.svelte';
+
+// ─── Dialog Types ─────────────────────────────────────────────
+
+export interface DialogLine {
+ speaker: string;
+ text: string;
+ options?: DialogOption[];
+}
+
+export interface DialogOption {
+ label: string;
+ action: 'close' | 'trade' | 'next';
+ nextIndex?: number;
+}
+
+export interface TradeOffer {
+ item: GameItem;
+ cost: number; // durability points from held item as "currency"
+}
+
+// ─── NPC Dialog Templates ─────────────────────────────────────
+
+export function getDialogForBehavior(behavior: string, npcName?: string): DialogLine[] {
+ const name = npcName ?? behavior;
+
+ switch (behavior) {
+ case 'merchant':
+ return [
+ {
+ speaker: name,
+ text: 'Welcome, traveler! Care to browse my wares?',
+ options: [
+ { label: 'Show me what you have', action: 'trade' },
+ { label: 'Maybe later', action: 'close' },
+ ],
+ },
+ ];
+
+ case 'guard':
+ return [
+ {
+ speaker: name,
+ text: 'Stay out of trouble. This area is under my watch.',
+ options: [{ label: 'Understood', action: 'close' }],
+ },
+ ];
+
+ case 'passive':
+ return [
+ {
+ speaker: name,
+ text: getRandomPassiveLine(),
+ options: [{ label: 'Goodbye', action: 'close' }],
+ },
+ ];
+
+ default:
+ return [
+ {
+ speaker: name,
+ text: '...',
+ options: [{ label: 'Leave', action: 'close' }],
+ },
+ ];
+ }
+}
+
+const passiveLines = [
+ "Nice weather today, isn't it?",
+ 'Have you explored the dungeon yet?',
+ 'I heard there are treasures in the caves...',
+ 'Be careful at night. Things get dangerous.',
+ 'The merchant has some good items if you need supplies.',
+ "I've been here for as long as I can remember.",
+ 'Watch out for the hostile creatures nearby.',
+];
+
+function getRandomPassiveLine(): string {
+ return passiveLines[Math.floor(Math.random() * passiveLines.length)];
+}
+
+// ─── Dialog State Manager ─────────────────────────────────────
+
+export class DialogManager {
+ private _active = false;
+ private _lines: DialogLine[] = [];
+ private _currentIndex = 0;
+ private _trading = false;
+ private _npcBehavior = '';
+
+ get active() {
+ return this._active;
+ }
+ get currentLine(): DialogLine | null {
+ if (!this._active || this._currentIndex >= this._lines.length) return null;
+ return this._lines[this._currentIndex];
+ }
+ get isTrading() {
+ return this._trading;
+ }
+
+ /** Start a dialog with an NPC */
+ open(behavior: string, name?: string) {
+ this._lines = getDialogForBehavior(behavior, name);
+ this._currentIndex = 0;
+ this._active = true;
+ this._trading = false;
+ this._npcBehavior = behavior;
+ }
+
+ /** Select a dialog option */
+ selectOption(option: DialogOption): 'close' | 'trade' | 'continue' {
+ switch (option.action) {
+ case 'close':
+ this.close();
+ return 'close';
+ case 'trade':
+ this._trading = true;
+ return 'trade';
+ case 'next':
+ if (option.nextIndex !== undefined) {
+ this._currentIndex = option.nextIndex;
+ } else {
+ this._currentIndex++;
+ }
+ if (this._currentIndex >= this._lines.length) {
+ this.close();
+ return 'close';
+ }
+ return 'continue';
+ }
+ }
+
+ close() {
+ this._active = false;
+ this._trading = false;
+ this._currentIndex = 0;
+ }
+}
diff --git a/apps/manavoxel/apps/web/src/lib/engine/game.ts b/apps/manavoxel/apps/web/src/lib/engine/game.ts
index 308b2532b..0e1142da9 100644
--- a/apps/manavoxel/apps/web/src/lib/engine/game.ts
+++ b/apps/manavoxel/apps/web/src/lib/engine/game.ts
@@ -1,4 +1,4 @@
-import { Application, Container, Graphics } from 'pixi.js';
+import { Application, Container, Graphics, Text, TextStyle } from 'pixi.js';
import { Camera } from './camera';
import { InputManager } from './input';
import { TilemapRenderer } from './tilemap';
@@ -8,6 +8,11 @@ import { AreaManager, generateDemoStreet, generateDemoInterior } from './area-ma
import { UndoStack, brushStroke, floodFill, pipette, type ToolType } from '$lib/editor/tools';
import { DEFAULT_MATERIALS, MATERIAL_AIR, type Material } from '@manavoxel/shared';
import type { Inventory } from './inventory.svelte';
+import { GameEventBus, BehaviorRuntime, type ActionContext, type GameEvent } from './behavior';
+import { playSound } from './audio';
+import { NPCManager, NPC } from './npc';
+import { LightingEngine, DayNightCycle } from './lighting';
+import { DialogManager } from './dialog';
export class GameEngine {
app: Application;
@@ -19,10 +24,18 @@ export class GameEngine {
areaManager: AreaManager;
particles: ParticleSystem;
inventory: Inventory | null = null;
+ eventBus: GameEventBus;
+ behaviorRuntime: BehaviorRuntime;
+ npcManager: NPCManager;
+ lighting: LightingEngine;
+ dayNight: DayNightCycle;
+ dialog: DialogManager;
private _container: HTMLDivElement;
private _worldContainer: Container;
private _fadeOverlay: Graphics;
+ private _messageText: Text | null = null;
+ private _messageTimer = 0;
private _initialized = false;
private _useItemCooldown = 0;
@@ -33,6 +46,7 @@ export class GameEngine {
private _brushSize = 1;
private _palette: Material[] = DEFAULT_MATERIALS;
private _painting = false;
+ private _npcBehavior: string = 'hostile'; // For NPC placement tool
// Area state
private _currentFloor = 0;
@@ -62,6 +76,9 @@ export class GameEngine {
get totalFloors() {
return this.areaManager.currentArea?.data.floors ?? 1;
}
+ get npcBehavior() {
+ return this._npcBehavior;
+ }
get areaName() {
return this._areaName;
}
@@ -80,6 +97,12 @@ export class GameEngine {
this.input = new InputManager(container);
this.areaManager = new AreaManager(this._worldContainer);
this.particles = new ParticleSystem(this._worldContainer);
+ this.eventBus = new GameEventBus();
+ this.behaviorRuntime = new BehaviorRuntime(this.eventBus);
+ this.npcManager = new NPCManager(this._worldContainer);
+ this.lighting = new LightingEngine(this.app.stage);
+ this.dayNight = new DayNightCycle();
+ this.dialog = new DialogManager();
this._init(worldData);
}
@@ -128,6 +151,15 @@ export class GameEngine {
this._currentFloor = loaded.currentFloor;
this._areaName = loaded.data.name;
+ // Spawn NPCs from area entities
+ this.npcManager.spawnFromEntities(loaded.data.entities, loaded.tilemap);
+
+ // Collect light sources and set ambient based on area type
+ this.lighting.collectLights(loaded.tilemap, this._palette);
+ if (loaded.data.type === 'interior') {
+ this.lighting.setAmbient(0.2); // Interiors are dark
+ }
+
// Recreate player in new area
this.player?.destroy();
this.player = new Player(
@@ -194,7 +226,41 @@ export class GameEngine {
// Cooldown tick
if (this._useItemCooldown > 0) this._useItemCooldown--;
+ // Update message overlay
+ if (this._messageText && this._messageTimer > 0) {
+ this._messageTimer--;
+ this._messageText.x = this.app.screen.width / 2;
+ this._messageText.y = this.app.screen.height - 80;
+ this._messageText.alpha = Math.min(1, this._messageTimer / 30);
+ if (this._messageTimer <= 0) {
+ this._messageText.destroy();
+ this._messageText = null;
+ }
+ }
+
this.camera.update(this.app.screen.width, this.app.screen.height);
+
+ // Day/Night cycle (only on streets)
+ const area = this.areaManager.currentArea;
+ if (area && area.data.type === 'street') {
+ 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);
+ }
+ }
+
+ // Render lighting overlay
+ this.lighting.render(
+ this.app.screen.width,
+ this.app.screen.height,
+ this.camera.x,
+ this.camera.y,
+ this.camera.scale
+ );
+ this.lighting.moveToTop();
}
private _updateGame() {
@@ -224,6 +290,34 @@ export class GameEngine {
);
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);
+ }
+ }
+
+ // Talk to nearby NPCs (E key, non-hostile only)
+ if (this.input.isKeyDown('KeyE') && !portal && !this.dialog.active) {
+ const nearNpc = this.npcManager.getNpcAt(
+ this.player.worldX,
+ this.player.worldY,
+ 15 * this.tilemap.tileSize
+ );
+ if (nearNpc && !nearNpc.isDead && nearNpc.behavior !== 'hostile') {
+ this.dialog.open(nearNpc.behavior);
+ playSound('pickup');
+ this.onStateChange?.();
+ }
+ }
+
+ // Don't process other input during dialog
+ if (this.dialog.active) {
+ // Camera still follows player
+ const lerpSpeed = 0.1;
+ const cx = this.camera.x + (this.player.worldX - this.camera.x) * lerpSpeed;
+ const cy = this.camera.y + (this.player.worldY - this.camera.y) * lerpSpeed;
+ this.camera.setPosition(cx, cy);
+ return;
}
// Check for stairs (floor switch via E key on stair tiles)
@@ -237,44 +331,90 @@ export class GameEngine {
// Use held item (Space key)
if (this.input.isKeyDown('Space') && this._useItemCooldown <= 0 && this.inventory) {
const heldItem = this.inventory.heldItem;
- if (heldItem && heldItem.properties.damage > 0) {
- this._useItemCooldown = 15; // ~0.25s cooldown
+ if (heldItem) {
+ this._useItemCooldown = Math.max(5, Math.round(30 / heldItem.properties.speed));
- // Spawn particle effect at player facing direction
- const dirOffsets = [
- { dx: 0, dy: -20 }, // up
- { dx: 20, dy: 0 }, // right
- { dx: 0, dy: 20 }, // down
- { dx: -20, dy: 0 }, // left
- ];
- const off = dirOffsets[this.player.direction] ?? dirOffsets[2];
- const effectX = this.player.worldX + off.dx;
- const effectY = this.player.worldY + off.dy;
+ // Fire onUse event — behaviors handle the effects
+ this._fireItemEvent('onUse', heldItem);
- // Spawn particles based on item properties
- const particleType = heldItem.properties.particle;
- if (particleType && particleType !== 'none') {
- this.particles.spawn(particleType, effectX, effectY);
- } else {
- this.particles.spawn('sparks', effectX, effectY);
+ // Default behavior if no behaviors defined: use item properties directly
+ if (!heldItem.behaviors || heldItem.behaviors.length === 0) {
+ this._defaultItemUse(heldItem);
}
+ }
+ }
- // Pixel destruction in facing direction
- if (heldItem.properties.damage >= 20) {
- const worldTileX = Math.floor(effectX / this.tilemap.tileSize);
- const worldTileY = Math.floor(effectY / this.tilemap.tileSize);
- const radius = Math.min(3, Math.floor(heldItem.properties.damage / 30));
- for (let dy = -radius; dy <= radius; dy++) {
- for (let dxx = -radius; dxx <= radius; dxx++) {
- if (Math.abs(dxx) + Math.abs(dy) <= radius) {
- this.tilemap.setPixel(worldTileX + dxx, worldTileY + dy, MATERIAL_AIR);
- }
- }
+ // Timer-based behaviors for held item
+ if (this.inventory?.heldItem) {
+ const heldItem = this.inventory.heldItem;
+ for (const behavior of heldItem.behaviors ?? []) {
+ if (behavior.trigger.type === 'onTimer') {
+ const seconds = Number(behavior.trigger.params.seconds ?? 1);
+ const intervalFrames = Math.round(seconds * 60);
+ if (
+ this.eventBus.tickTimer(`${heldItem.id}_${behavior.trigger.type}`, intervalFrames)
+ ) {
+ this._fireItemEvent('onTimer', heldItem);
}
}
}
}
+ // Update NPCs
+ const npcResult = this.npcManager.update(this.player.x, this.player.y, this._currentFloor);
+
+ // NPC contact damage to player
+ for (const npc of npcResult.attackingNpcs) {
+ this.player.hp = Math.max(0, this.player.hp - npc.damage);
+ playSound('hit_default');
+ this.particles.spawn('sparks', this.player.worldX, this.player.worldY);
+ this.onStateChange?.();
+ }
+
+ // Fire onTouch for held item when touching NPCs
+ if (npcResult.touchingNpcs.length > 0 && this.inventory?.heldItem) {
+ this._fireItemEvent('onTouch', this.inventory.heldItem);
+ }
+
+ // Item-use damages NPCs in range
+ if (this.input.isKeyDown('Space') && this._useItemCooldown <= 1 && this.inventory?.heldItem) {
+ const heldItem = this.inventory.heldItem;
+ if (heldItem.properties.damage > 0) {
+ const effectDistance = 10 + heldItem.properties.range * 3;
+ const dirOffsets = [
+ { dx: 0, dy: -effectDistance },
+ { dx: effectDistance, dy: 0 },
+ { dx: 0, dy: effectDistance },
+ { dx: -effectDistance, dy: 0 },
+ ];
+ const off = dirOffsets[this.player.direction] ?? dirOffsets[2];
+ const hitX = this.player.worldX + off.dx;
+ const hitY = this.player.worldY + off.dy;
+
+ const hitRange = (heldItem.properties.range + 2) * this.tilemap.tileSize;
+ const target = this.npcManager.getNpcAt(hitX, hitY, hitRange);
+ if (target) {
+ const died = target.takeDamage(heldItem.properties.damage);
+ playSound('hit_sword');
+ this.particles.spawn(
+ this._elementParticle(heldItem.properties.element),
+ target.worldX,
+ target.worldY
+ );
+ if (died) {
+ playSound('explosion');
+ this.particles.spawn('shatter', target.worldX, target.worldY);
+ this._showMessage(`Defeated ${target.behavior} NPC!`);
+ }
+ }
+ }
+ }
+
+ // Cleanup dead NPCs periodically
+ if (this._useItemCooldown === 0) {
+ this.npcManager.cleanupDead();
+ }
+
// Camera follows player smoothly
const lerpSpeed = 0.1;
const cx = this.camera.x + (this.player.worldX - this.camera.x) * lerpSpeed;
@@ -351,6 +491,11 @@ export class GameEngine {
}
}
break;
+ case 'npc':
+ if (this.input.justPressed) {
+ this._placeNpc(tileX, tileY);
+ }
+ break;
}
} else if (this._painting) {
// Mouse released: commit the undo batch
@@ -359,6 +504,205 @@ export class GameEngine {
}
}
+ // ─── Behavior Helpers ──────────────────────────────────
+
+ /** Fire a game event for an item and let behaviors handle it */
+ private _fireItemEvent(
+ type: import('./behavior').GameEventType,
+ item: import('./inventory.svelte').GameItem
+ ) {
+ if (!this.player) return;
+
+ const event: GameEvent = {
+ type,
+ item,
+ playerX: this.player.worldX,
+ playerY: this.player.worldY,
+ playerDirection: this.player.direction,
+ playerHp: this.player.hp,
+ };
+ this.eventBus.emit(event);
+ }
+
+ /** Create an ActionContext for behavior execution */
+ private _createActionContext(item: import('./inventory.svelte').GameItem): ActionContext {
+ return {
+ playerX: this.player?.worldX ?? 0,
+ playerY: this.player?.worldY ?? 0,
+ playerDirection: this.player?.direction ?? 2,
+ playerHp: this.player?.hp ?? 100,
+ setPlayerHp: (hp: number) => {
+ if (this.player) {
+ this.player.hp = hp;
+ this.onStateChange?.();
+ }
+ },
+ teleportPlayer: (x: number, y: number) => {
+ if (this.player) {
+ this.player.x = x;
+ this.player.y = y;
+ }
+ },
+ setPixel: (x: number, y: number, material: number) => {
+ this.tilemap.setPixel(x, y, material);
+ },
+ getPixel: (x: number, y: number) => {
+ return this.tilemap.getPixel(x, y);
+ },
+ tileSize: this.tilemap.tileSize,
+ spawnParticles: (type: string, x: number, y: number) => {
+ this.particles.spawn(type, x, y);
+ },
+ shakeCamera: (intensity: number, duration: number) => {
+ this.camera.shake(intensity, duration);
+ },
+ showMessage: (text: string) => {
+ this._showMessage(text);
+ },
+ item,
+ };
+ }
+
+ /** Map element type to default particle effect */
+ private _elementParticle(element: string): string {
+ switch (element) {
+ case 'fire':
+ return 'fire_burst';
+ case 'ice':
+ return 'ice_shards';
+ case 'poison':
+ return 'poison_cloud';
+ case 'lightning':
+ return 'lightning_bolt';
+ default:
+ return 'sparks';
+ }
+ }
+
+ /** Default item use when no behaviors are defined (backwards-compatible) */
+ private _defaultItemUse(item: import('./inventory.svelte').GameItem) {
+ if (!this.player) return;
+ if (item.properties.damage <= 0 && item.properties.particle === 'none') return;
+
+ // Range determines effect distance (range 1 = 10px, range 10 = 40px)
+ const effectDistance = 10 + item.properties.range * 3;
+ const dirOffsets = [
+ { dx: 0, dy: -effectDistance },
+ { dx: effectDistance, dy: 0 },
+ { dx: 0, dy: effectDistance },
+ { dx: -effectDistance, dy: 0 },
+ ];
+ const off = dirOffsets[this.player.direction] ?? dirOffsets[2];
+ const effectX = this.player.worldX + off.dx;
+ const effectY = this.player.worldY + off.dy;
+
+ // Play item sound
+ playSound(item.properties.sound || 'hit_default');
+
+ // Spawn particles — use element-based particles if no explicit particle set
+ const particleType = item.properties.particle;
+ if (particleType && particleType !== 'none') {
+ this.particles.spawn(particleType, effectX, effectY);
+ } else if (item.properties.damage > 0) {
+ this.particles.spawn(this._elementParticle(item.properties.element), effectX, effectY);
+ }
+
+ // Pixel destruction
+ if (item.properties.damage >= 20) {
+ const tileX = Math.floor(effectX / this.tilemap.tileSize);
+ const tileY = Math.floor(effectY / this.tilemap.tileSize);
+ const radius = Math.min(3, Math.floor(item.properties.damage / 30));
+ for (let dy = -radius; dy <= radius; dy++) {
+ for (let dx = -radius; dx <= radius; dx++) {
+ if (Math.abs(dx) + Math.abs(dy) <= radius) {
+ this.tilemap.setPixel(tileX + dx, tileY + dy, MATERIAL_AIR);
+ }
+ }
+ }
+ }
+
+ // Durability: reduce and break if depleted
+ if (item.properties.durabilityMax > 0) {
+ item.properties.durabilityCurrent = Math.max(0, item.properties.durabilityCurrent - 1);
+ if (item.properties.durabilityCurrent <= 0) {
+ // Item breaks — remove from inventory
+ playSound('break');
+ this.particles.spawn('shatter', this.player.worldX, this.player.worldY);
+ if (this.inventory) {
+ const slotIdx = this.inventory.slots.findIndex((s) => s?.id === item.id);
+ if (slotIdx >= 0) this.inventory.removeItem(slotIdx);
+ }
+ this._showMessage(`${item.name} broke!`);
+ }
+ }
+ }
+
+ /** Show a floating message on screen */
+ private _showMessage(text: string) {
+ if (this._messageText) {
+ this._messageText.destroy();
+ }
+ this._messageText = new Text({
+ text,
+ style: new TextStyle({
+ fontSize: 16,
+ fill: '#ffffff',
+ fontFamily: 'monospace',
+ dropShadow: { color: '#000000', blur: 4, distance: 1, angle: Math.PI / 4 },
+ }),
+ });
+ this._messageText.anchor.set(0.5, 1);
+ this.app.stage.addChild(this._messageText);
+ this._messageTimer = 180; // ~3 seconds
+ }
+
+ /** Register behaviors for all inventory items + wire inventory callbacks */
+ registerItemBehaviors() {
+ this.behaviorRuntime.destroy();
+ if (!this.inventory) return;
+
+ for (const item of this.inventory.slots) {
+ if (!item || !item.behaviors || item.behaviors.length === 0) continue;
+ this.behaviorRuntime.registerItem(item, item.behaviors, () =>
+ this._createActionContext(item)
+ );
+ }
+
+ // Wire inventory pickup/drop events
+ this.inventory.onPickup = (item) => {
+ this._fireItemEvent('onPickup', item);
+ };
+ this.inventory.onDrop = (item) => {
+ this._fireItemEvent('onDrop', item);
+ };
+ }
+
+ /** Place an NPC at tile position and add to area entities */
+ private _placeNpc(tileX: number, tileY: number) {
+ const area = this.areaManager.currentArea;
+ if (!area) return;
+
+ const entityDef: import('@manavoxel/shared').EntityDef = {
+ id: crypto.randomUUID(),
+ type: 'npc',
+ x: tileX,
+ y: tileY,
+ floor: this._currentFloor,
+ properties: {
+ behavior: this._npcBehavior,
+ hp: this._npcBehavior === 'hostile' ? 30 : 50,
+ damage: this._npcBehavior === 'hostile' ? 5 : 0,
+ },
+ };
+
+ // Add to area data + spawn
+ area.data.entities.push(entityDef);
+ this.npcManager.addNpc(entityDef, area.tilemap);
+
+ playSound('pickup');
+ this.onStateChange?.();
+ }
+
// ─── Public API for UI ──────────────────────────────────
toggleEditor() {
@@ -381,7 +725,16 @@ export class GameEngine {
this.onStateChange?.();
}
+ setNpcBehavior(behavior: string) {
+ this._npcBehavior = behavior;
+ this.onStateChange?.();
+ }
+
destroy() {
+ this.behaviorRuntime.destroy();
+ this.eventBus.destroy();
+ this.npcManager.clear();
+ this.lighting.destroy();
this.player?.destroy();
this.input.destroy();
this.app.destroy(true);
diff --git a/apps/manavoxel/apps/web/src/lib/engine/inventory.svelte.ts b/apps/manavoxel/apps/web/src/lib/engine/inventory.svelte.ts
index f428763c8..acaf69ecd 100644
--- a/apps/manavoxel/apps/web/src/lib/engine/inventory.svelte.ts
+++ b/apps/manavoxel/apps/web/src/lib/engine/inventory.svelte.ts
@@ -1,5 +1,5 @@
-import type { SpriteData } from '$lib/editor/sprite-editor.svelte';
-import type { ItemProperties, Rarity, ElementType } from '@manavoxel/shared';
+import type { SpriteData } from '$lib/editor/types';
+import type { ItemProperties, Rarity, ElementType, TriggerAction } from '@manavoxel/shared';
export interface GameItem {
id: string;
@@ -7,6 +7,7 @@ export interface GameItem {
sprite: SpriteData;
properties: ItemProperties;
rarity: Rarity;
+ behaviors: TriggerAction[];
}
const defaultProperties: ItemProperties = {
@@ -21,20 +22,20 @@ const defaultProperties: ItemProperties = {
particle: 'none',
};
-let nextItemId = 1;
-
/** Create a new item from sprite data */
export function createItem(
name: string,
sprite: SpriteData,
- partialProps?: Partial
+ partialProps?: Partial,
+ behaviors?: TriggerAction[]
): GameItem {
return {
- id: `item_${nextItemId++}`,
+ id: crypto.randomUUID(),
name,
sprite,
properties: { ...defaultProperties, ...partialProps },
rarity: partialProps?.rarity ?? 'common',
+ behaviors: behaviors ?? [],
};
}
@@ -45,6 +46,11 @@ export class Inventory {
slots: (GameItem | null)[] = $state(Array(MAX_INVENTORY_SLOTS).fill(null));
heldSlot: number = $state(-1); // -1 = nothing held
+ /** Called when an item is added to inventory */
+ onPickup: ((item: GameItem) => void) | null = null;
+ /** Called when an item is removed from inventory */
+ onDrop: ((item: GameItem) => void) | null = null;
+
get heldItem(): GameItem | null {
if (this.heldSlot < 0 || this.heldSlot >= this.slots.length) return null;
return this.slots[this.heldSlot];
@@ -55,6 +61,7 @@ export class Inventory {
const emptySlot = this.slots.findIndex((s) => s === null);
if (emptySlot === -1) return -1;
this.slots[emptySlot] = item;
+ this.onPickup?.(item);
return emptySlot;
}
@@ -64,6 +71,7 @@ export class Inventory {
const item = this.slots[slot];
this.slots[slot] = null;
if (this.heldSlot === slot) this.heldSlot = -1;
+ if (item) this.onDrop?.(item);
return item;
}
diff --git a/apps/manavoxel/apps/web/src/lib/engine/lighting.ts b/apps/manavoxel/apps/web/src/lib/engine/lighting.ts
new file mode 100644
index 000000000..d42ce6e02
--- /dev/null
+++ b/apps/manavoxel/apps/web/src/lib/engine/lighting.ts
@@ -0,0 +1,288 @@
+/**
+ * ManaVoxel Lighting System
+ *
+ * Renders a darkness overlay with radial light sources.
+ * Emissive materials (torch, lava) and placed lights cast light.
+ * Interiors are darker by default. Day/night cycle affects streets.
+ */
+
+import { Container, Graphics } from 'pixi.js';
+import type { TilemapRenderer } from './tilemap';
+import { CHUNK_SIZE, type Material } from '@manavoxel/shared';
+
+// ─── Light Source ─────────────────────────────────────────────
+
+export interface LightSource {
+ x: number; // world pixel position
+ y: number;
+ radius: number; // in world pixels
+ color: string; // hex color
+ intensity: number; // 0-1
+}
+
+// ─── Lighting Engine ──────────────────────────────────────────
+
+export class LightingEngine {
+ private _overlay: Graphics;
+ private _container: Container;
+ private _enabled = true;
+ private _ambientLight = 1.0; // 0 = total darkness, 1 = full bright
+ private _lights: LightSource[] = [];
+ private _dirty = true;
+
+ // Cache
+ private _lastAmbient = -1;
+ private _lastLightCount = -1;
+ private _width = 0;
+ private _height = 0;
+
+ get enabled() {
+ return this._enabled;
+ }
+ get ambientLight() {
+ return this._ambientLight;
+ }
+
+ constructor(stage: Container) {
+ this._container = stage;
+ this._overlay = new Graphics();
+ this._overlay.blendMode = 'multiply' as any;
+ stage.addChild(this._overlay);
+ }
+
+ /** Set ambient light level (0 = pitch dark, 1 = full bright) */
+ setAmbient(level: number) {
+ const clamped = Math.max(0, Math.min(1, level));
+ if (clamped !== this._ambientLight) {
+ this._ambientLight = clamped;
+ this._dirty = true;
+ }
+ }
+
+ /** Toggle lighting on/off */
+ toggle() {
+ this._enabled = !this._enabled;
+ this._overlay.visible = this._enabled;
+ this._dirty = true;
+ }
+
+ /** Collect light sources from tilemap emissive materials + extra lights */
+ collectLights(tilemap: TilemapRenderer, palette: Material[], extraLights?: LightSource[]) {
+ this._lights = [];
+
+ // Scan tilemap for emissive materials
+ const tileSize = tilemap.tileSize;
+ const w = tilemap.worldWidth;
+ const h = tilemap.worldHeight;
+
+ // Sample every 4th pixel for performance (emissive blocks are usually clustered)
+ for (let y = 0; y < h; y += 4) {
+ for (let x = 0; x < w; x += 4) {
+ const mat = tilemap.getPixel(x, y);
+ if (mat === 0) continue;
+ const material = palette[mat];
+ if (!material?.emissive) continue;
+
+ this._lights.push({
+ x: x * tileSize + tileSize * 2,
+ y: y * tileSize + tileSize * 2,
+ radius: (80 * tileSize) / 8, // ~80px at street zoom
+ color: material.color,
+ intensity: 0.9,
+ });
+ }
+ }
+
+ // Add extra lights (from entities, player torch, etc.)
+ if (extraLights) {
+ this._lights.push(...extraLights);
+ }
+
+ this._dirty = true;
+ }
+
+ /** Render the lighting overlay. Call once per frame if dirty. */
+ render(
+ screenWidth: number,
+ screenHeight: number,
+ cameraX: number,
+ cameraY: number,
+ cameraScale: number
+ ) {
+ if (!this._enabled) return;
+
+ // Only re-render if something changed
+ const needsUpdate =
+ this._dirty ||
+ this._width !== screenWidth ||
+ this._height !== screenHeight ||
+ this._lastAmbient !== this._ambientLight ||
+ this._lastLightCount !== this._lights.length;
+
+ if (!needsUpdate) return;
+
+ this._dirty = false;
+ this._width = screenWidth;
+ this._height = screenHeight;
+ this._lastAmbient = this._ambientLight;
+ this._lastLightCount = this._lights.length;
+
+ const g = this._overlay;
+ g.clear();
+
+ // If fully bright and no darkness needed, skip
+ if (this._ambientLight >= 0.95 && this._lights.length === 0) {
+ g.visible = false;
+ return;
+ }
+ g.visible = true;
+
+ // Draw dark overlay covering the whole screen
+ const darkness = 1 - this._ambientLight;
+ if (darkness > 0.01) {
+ const alpha = darkness * 0.85; // Max 85% darkness so it's never completely black
+ g.rect(0, 0, screenWidth, screenHeight);
+ g.fill({ color: 0x000000, alpha });
+ }
+
+ // Cut out light circles (draw bright circles that "erase" darkness)
+ // Using multiply blend: we draw white circles where lights are
+ for (const light of this._lights) {
+ // Convert world position to screen position
+ const screenX = (light.x - cameraX) * cameraScale + screenWidth / 2;
+ const screenY = (light.y - cameraY) * cameraScale + screenHeight / 2;
+ const screenRadius = light.radius * cameraScale;
+
+ // Skip if off-screen
+ if (
+ screenX + screenRadius < 0 ||
+ screenX - screenRadius > screenWidth ||
+ screenY + screenRadius < 0 ||
+ screenY - screenRadius > screenHeight
+ )
+ continue;
+
+ // Draw light as a bright circle that reduces the darkness
+ // Multiple concentric circles for gradient falloff
+ const steps = 5;
+ for (let i = steps; i >= 1; i--) {
+ const ratio = i / steps;
+ const r = screenRadius * ratio;
+ const alpha = light.intensity * (1 - ratio) * 0.15;
+ g.circle(screenX, screenY, r);
+ g.fill({ color: this._hexToNum(light.color), alpha });
+ }
+
+ // Core bright spot
+ g.circle(screenX, screenY, screenRadius * 0.3);
+ g.fill({ color: 0xffffff, alpha: light.intensity * 0.1 });
+ }
+ }
+
+ /** Move overlay to always be on top */
+ moveToTop() {
+ const parent = this._overlay.parent;
+ if (parent) {
+ parent.removeChild(this._overlay);
+ parent.addChild(this._overlay);
+ }
+ }
+
+ destroy() {
+ this._overlay.destroy();
+ }
+
+ private _hexToNum(hex: string): number {
+ return parseInt(hex.replace('#', ''), 16);
+ }
+}
+
+// ─── Day/Night Cycle ──────────────────────────────────────────
+
+export class DayNightCycle {
+ private _time = 0.35; // 0-1, where 0.25=sunrise, 0.5=noon, 0.75=sunset, 0=midnight
+ private _speed = 0.00002; // Time units per frame (~10 min real = 1 day)
+ private _paused = false;
+
+ /** Current time (0-1) */
+ get time() {
+ return this._time;
+ }
+
+ /** Is it currently "night" (for trigger purposes) */
+ get isNight() {
+ return this._time < 0.2 || this._time > 0.8;
+ }
+
+ /** Is it currently "day" */
+ get isDay() {
+ return this._time >= 0.25 && this._time <= 0.75;
+ }
+
+ /** Human-readable time string (HH:MM) */
+ get timeString(): string {
+ const hours = Math.floor(this._time * 24);
+ const minutes = Math.floor((this._time * 24 - hours) * 60);
+ return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`;
+ }
+
+ /** Get ambient light level based on time of day */
+ get ambientLevel(): number {
+ // Smooth curve: dark at night, bright during day
+ // 0.0 = midnight (dark), 0.25 = sunrise, 0.5 = noon (bright), 0.75 = sunset, 1.0 = midnight
+ const t = this._time;
+
+ if (t >= 0.25 && t <= 0.75) {
+ // Daytime: full brightness
+ return 1.0;
+ } else if (t > 0.75) {
+ // Sunset → midnight: 1.0 → 0.15
+ const progress = (t - 0.75) / 0.25;
+ return 1.0 - progress * 0.85;
+ } else if (t < 0.2) {
+ // Night: minimal light
+ return 0.15;
+ } else {
+ // Sunrise: 0.15 → 1.0
+ const progress = (t - 0.2) / 0.05;
+ return 0.15 + progress * 0.85;
+ }
+ }
+
+ /** Advance time by one frame. Returns true if day/night just changed. */
+ update(): { changed: boolean; becameNight: boolean; becameDay: boolean } {
+ if (this._paused) return { changed: false, becameNight: false, becameDay: false };
+
+ const wasNight = this.isNight;
+ const wasDay = this.isDay;
+
+ this._time = (this._time + this._speed) % 1;
+
+ const isNightNow = this.isNight;
+ const isDayNow = this.isDay;
+
+ return {
+ changed: wasNight !== isNightNow || wasDay !== isDayNow,
+ becameNight: !wasNight && isNightNow,
+ becameDay: !wasDay && isDayNow,
+ };
+ }
+
+ /** Set time directly (0-1) */
+ setTime(t: number) {
+ this._time = ((t % 1) + 1) % 1;
+ }
+
+ /** Set cycle speed */
+ setSpeed(speed: number) {
+ this._speed = speed;
+ }
+
+ togglePause() {
+ this._paused = !this._paused;
+ }
+
+ get paused() {
+ return this._paused;
+ }
+}
diff --git a/apps/manavoxel/apps/web/src/lib/engine/npc.ts b/apps/manavoxel/apps/web/src/lib/engine/npc.ts
new file mode 100644
index 000000000..fc6f7ef48
--- /dev/null
+++ b/apps/manavoxel/apps/web/src/lib/engine/npc.ts
@@ -0,0 +1,374 @@
+/**
+ * NPC System — Spawning, AI, Rendering, Combat
+ *
+ * NPCs are spawned from EntityDefs in area data.
+ * Each NPC has HP, a simple AI state machine, and collision detection.
+ */
+
+import { Container, Graphics } from 'pixi.js';
+import type { TilemapRenderer } from './tilemap';
+import type { EntityDef } from '@manavoxel/shared';
+import { playSound } from './audio';
+
+// ─── Constants ────────────────────────────────────────────────
+
+const NPC_WIDTH = 5;
+const NPC_HEIGHT = 7;
+const NPC_SPEED = 0.6;
+const CHASE_SPEED = 1.0;
+const CHASE_RANGE = 60; // pixels — NPC starts chasing player
+const ATTACK_RANGE = 8; // pixels — NPC deals contact damage
+const ATTACK_COOLDOWN = 90; // frames (~1.5s)
+const PATROL_PAUSE = 120; // frames to wait at patrol point
+const PATROL_DISTANCE = 40; // pixels to patrol from spawn
+
+// NPC type → color mapping
+const NPC_COLORS: Record = {
+ hostile: '#EF4444', // red
+ passive: '#22C55E', // green
+ merchant: '#EAB308', // yellow
+ guard: '#3B82F6', // blue
+};
+
+// ─── AI States ────────────────────────────────────────────────
+
+type AIState = 'idle' | 'patrol' | 'chase' | 'attack' | 'dead';
+
+// ─── NPC Class ────────────────────────────────────────────────
+
+export class NPC {
+ id: string;
+ x: number;
+ y: number;
+ spawnX: number;
+ spawnY: number;
+ floor: number;
+ hp: number;
+ maxHp: number;
+ damage: number;
+ direction = 2; // 0=up, 1=right, 2=down, 3=left
+ behavior: string; // 'hostile' | 'passive' | 'merchant' | 'guard'
+
+ state: AIState = 'idle';
+ private _sprite: Container;
+ private _body: Graphics;
+ private _hpBar: Graphics;
+ private _tilemap: TilemapRenderer;
+ private _patrolTimer = 0;
+ private _patrolDir = 1; // 1 or -1
+ private _attackCooldown = 0;
+ private _stateTimer = 0;
+ private _dead = false;
+
+ get worldX() {
+ return this.x * this._tilemap.tileSize;
+ }
+ get worldY() {
+ return this.y * this._tilemap.tileSize;
+ }
+ get isDead() {
+ return this._dead;
+ }
+
+ constructor(worldContainer: Container, tilemap: TilemapRenderer, def: EntityDef) {
+ this.id = def.id;
+ this.x = def.x;
+ this.y = def.y;
+ this.spawnX = def.x;
+ this.spawnY = def.y;
+ this.floor = def.floor;
+ this._tilemap = tilemap;
+
+ const props = def.properties ?? {};
+ this.hp = Number(props.hp ?? 30);
+ this.maxHp = this.hp;
+ this.damage = Number(props.damage ?? 5);
+ this.behavior = String(props.behavior ?? 'hostile');
+
+ // Determine initial AI state
+ this.state = this.behavior === 'passive' || this.behavior === 'merchant' ? 'idle' : 'patrol';
+
+ // Create sprite
+ this._sprite = new Container();
+ worldContainer.addChild(this._sprite);
+
+ const color = NPC_COLORS[this.behavior] ?? NPC_COLORS.hostile;
+
+ this._body = new Graphics();
+ this._body.roundRect(0, 0, NPC_WIDTH * tilemap.tileSize, NPC_HEIGHT * tilemap.tileSize, 2);
+ this._body.fill(color);
+ this._sprite.addChild(this._body);
+
+ // HP bar (above head)
+ this._hpBar = new Graphics();
+ this._sprite.addChild(this._hpBar);
+
+ this._updateSpritePosition();
+ this._drawHpBar();
+ }
+
+ /** Update NPC AI and movement each frame */
+ update(
+ playerX: number,
+ playerY: number,
+ playerFloor: number
+ ): { touching: boolean; attacking: boolean } {
+ if (this._dead) return { touching: false, attacking: false };
+ if (this._attackCooldown > 0) this._attackCooldown--;
+ this._stateTimer++;
+
+ const dx = playerX - this.x;
+ const dy = playerY - this.y;
+ const distToPlayer = Math.sqrt(dx * dx + dy * dy);
+ const sameFloor = playerFloor === this.floor;
+
+ // State transitions
+ switch (this.state) {
+ case 'idle':
+ if (this.behavior === 'hostile' && sameFloor && distToPlayer < CHASE_RANGE) {
+ this.state = 'chase';
+ this._stateTimer = 0;
+ }
+ break;
+
+ case 'patrol':
+ this._updatePatrol();
+ if (this.behavior === 'hostile' && sameFloor && distToPlayer < CHASE_RANGE) {
+ this.state = 'chase';
+ this._stateTimer = 0;
+ }
+ break;
+
+ case 'chase':
+ if (!sameFloor || distToPlayer > CHASE_RANGE * 1.5) {
+ // Lost the player → go back to patrol
+ this.state = 'patrol';
+ this._stateTimer = 0;
+ } else if (distToPlayer < ATTACK_RANGE) {
+ this.state = 'attack';
+ this._stateTimer = 0;
+ } else {
+ this._moveToward(playerX, playerY, CHASE_SPEED);
+ }
+ break;
+
+ case 'attack':
+ if (!sameFloor || distToPlayer > ATTACK_RANGE * 2) {
+ this.state = 'chase';
+ this._stateTimer = 0;
+ }
+ break;
+ }
+
+ this._updateSpritePosition();
+
+ // Check touching
+ const touching = sameFloor && distToPlayer < ATTACK_RANGE;
+
+ // Attack logic
+ let attacking = false;
+ if (this.state === 'attack' && touching && this._attackCooldown <= 0) {
+ this._attackCooldown = ATTACK_COOLDOWN;
+ attacking = true;
+ }
+
+ return { touching, attacking };
+ }
+
+ /** Take damage. Returns true if NPC died. */
+ takeDamage(amount: number): boolean {
+ if (this._dead) return false;
+ this.hp = Math.max(0, this.hp - amount);
+ this._drawHpBar();
+
+ if (this.hp <= 0) {
+ this._dead = true;
+ this._sprite.alpha = 0.3;
+ this.state = 'dead';
+ return true;
+ }
+
+ // Aggro on damage
+ if (this.state === 'idle' || this.state === 'patrol') {
+ this.state = 'chase';
+ }
+ return false;
+ }
+
+ destroy() {
+ this._sprite.destroy({ children: true });
+ }
+
+ // ─── AI Movement ──────────────────────────────────────────
+
+ private _updatePatrol() {
+ this._patrolTimer++;
+
+ if (this._patrolTimer < PATROL_PAUSE) return; // Wait at point
+
+ // Move along patrol axis (horizontal)
+ const targetX = this.spawnX + this._patrolDir * PATROL_DISTANCE;
+ const dx = targetX - this.x;
+
+ if (Math.abs(dx) < 1) {
+ // Reached patrol point → flip and pause
+ this._patrolDir *= -1;
+ this._patrolTimer = 0;
+ } else {
+ this._moveToward(this.x + Math.sign(dx) * 100, this.y, NPC_SPEED);
+ }
+ }
+
+ private _moveToward(targetX: number, targetY: number, speed: number) {
+ const dx = targetX - this.x;
+ const dy = targetY - this.y;
+ const dist = Math.sqrt(dx * dx + dy * dy);
+ if (dist < 0.5) return;
+
+ const nx = (dx / dist) * speed;
+ const ny = (dy / dist) * speed;
+
+ // Update direction
+ if (Math.abs(nx) > Math.abs(ny)) {
+ this.direction = nx > 0 ? 1 : 3;
+ } else {
+ this.direction = ny > 0 ? 2 : 0;
+ }
+
+ // Try X movement
+ const newX = this.x + nx;
+ if (!this._collides(newX, this.y)) {
+ this.x = newX;
+ }
+
+ // Try Y movement
+ const newY = this.y + ny;
+ if (!this._collides(this.x, newY)) {
+ this.y = newY;
+ }
+ }
+
+ private _collides(px: number, py: number): boolean {
+ const m = 0.2;
+ const l = px + m;
+ const r = px + NPC_WIDTH - m;
+ const t = py + m;
+ const b = py + NPC_HEIGHT - m;
+ return (
+ this._tilemap.isSolid(Math.floor(l), Math.floor(t)) ||
+ this._tilemap.isSolid(Math.floor(r), Math.floor(t)) ||
+ this._tilemap.isSolid(Math.floor(l), Math.floor(b)) ||
+ this._tilemap.isSolid(Math.floor(r), Math.floor(b))
+ );
+ }
+
+ // ─── Rendering ────────────────────────────────────────────
+
+ private _updateSpritePosition() {
+ this._sprite.x = this.x * this._tilemap.tileSize;
+ this._sprite.y = this.y * this._tilemap.tileSize;
+ }
+
+ private _drawHpBar() {
+ const g = this._hpBar;
+ const ts = this._tilemap.tileSize;
+ const barW = NPC_WIDTH * ts;
+ const barH = 2;
+ const barY = -4;
+
+ g.clear();
+
+ // Background
+ g.rect(0, barY, barW, barH);
+ g.fill('#333333');
+
+ // HP fill
+ const ratio = this.hp / this.maxHp;
+ const color = ratio > 0.6 ? '#22c55e' : ratio > 0.3 ? '#eab308' : '#ef4444';
+ g.rect(0, barY, barW * ratio, barH);
+ g.fill(color);
+ }
+}
+
+// ─── NPC Manager ──────────────────────────────────────────────
+
+export class NPCManager {
+ private _npcs: NPC[] = [];
+ private _worldContainer: Container;
+
+ get npcs(): readonly NPC[] {
+ return this._npcs;
+ }
+
+ constructor(worldContainer: Container) {
+ this._worldContainer = worldContainer;
+ }
+
+ /** Add a single NPC from an entity definition */
+ addNpc(def: EntityDef, tilemap: TilemapRenderer): NPC {
+ const npc = new NPC(this._worldContainer, tilemap, def);
+ this._npcs.push(npc);
+ return npc;
+ }
+
+ /** Spawn NPCs from area entity definitions */
+ spawnFromEntities(entities: EntityDef[], tilemap: TilemapRenderer) {
+ this.clear();
+
+ for (const def of entities) {
+ if (def.type !== 'npc') continue;
+ const npc = new NPC(this._worldContainer, tilemap, def);
+ this._npcs.push(npc);
+ }
+ }
+
+ /** Update all NPCs. Returns list of NPCs touching/attacking the player */
+ update(
+ playerX: number,
+ playerY: number,
+ playerFloor: number
+ ): { touchingNpcs: NPC[]; attackingNpcs: NPC[] } {
+ const touchingNpcs: NPC[] = [];
+ const attackingNpcs: NPC[] = [];
+
+ for (const npc of this._npcs) {
+ if (npc.isDead) continue;
+ const result = npc.update(playerX, playerY, playerFloor);
+ if (result.touching) touchingNpcs.push(npc);
+ if (result.attacking) attackingNpcs.push(npc);
+ }
+
+ return { touchingNpcs, attackingNpcs };
+ }
+
+ /** Find NPC at a world position (for targeting with items) */
+ getNpcAt(worldX: number, worldY: number, range: number): NPC | null {
+ let closest: NPC | null = null;
+ let closestDist = range;
+
+ for (const npc of this._npcs) {
+ if (npc.isDead) continue;
+ const dx = npc.worldX - worldX;
+ const dy = npc.worldY - worldY;
+ const dist = Math.sqrt(dx * dx + dy * dy);
+ if (dist < closestDist) {
+ closestDist = dist;
+ closest = npc;
+ }
+ }
+
+ return closest;
+ }
+
+ /** Remove dead NPCs from the list */
+ cleanupDead() {
+ const dead = this._npcs.filter((n) => n.isDead);
+ for (const npc of dead) npc.destroy();
+ this._npcs = this._npcs.filter((n) => !n.isDead);
+ }
+
+ clear() {
+ for (const npc of this._npcs) npc.destroy();
+ this._npcs = [];
+ }
+}
diff --git a/apps/manavoxel/apps/web/src/lib/engine/tilemap.ts b/apps/manavoxel/apps/web/src/lib/engine/tilemap.ts
index b0c44aace..9cf634f5a 100644
--- a/apps/manavoxel/apps/web/src/lib/engine/tilemap.ts
+++ b/apps/manavoxel/apps/web/src/lib/engine/tilemap.ts
@@ -17,6 +17,8 @@ interface Chunk {
export class TilemapRenderer {
readonly tileSize: number; // Screen pixels per world pixel (at 1x zoom)
+ /** True when pixel data has been modified since last save */
+ isDirty = false;
private _container: Container;
private _palette: Material[];
private _chunks = new Map();
@@ -130,6 +132,7 @@ export class TilemapRenderer {
setPixel(x: number, y: number, material: number) {
if (x < 0 || x >= this._worldWidth || y < 0 || y >= this._worldHeight) return;
this._setPixelRaw(x, y, material);
+ this.isDirty = true;
const key = this._chunkKey(Math.floor(x / CHUNK_SIZE), Math.floor(y / CHUNK_SIZE));
const chunk = this._chunks.get(key);
@@ -151,6 +154,21 @@ export class TilemapRenderer {
return chunk.pixels[ly * CHUNK_SIZE + lx];
}
+ /** Export pixel data as Uint8Array (Uint16 little-endian per pixel) */
+ exportPixelData(): Uint8Array {
+ const data = new Uint8Array(this._worldWidth * this._worldHeight * 2);
+ const view = new DataView(data.buffer);
+ for (let y = 0; y < this._worldHeight; y++) {
+ for (let x = 0; x < this._worldWidth; x++) {
+ const mat = this.getPixel(x, y);
+ if (mat !== MATERIAL_AIR) {
+ view.setUint16((y * this._worldWidth + x) * 2, mat, true);
+ }
+ }
+ }
+ return data;
+ }
+
/** Check if a world pixel is solid (for collision) */
isSolid(x: number, y: number): boolean {
const mat = this.getPixel(x, y);
diff --git a/apps/manavoxel/apps/web/src/routes/+page.svelte b/apps/manavoxel/apps/web/src/routes/+page.svelte
index ce569906c..5c5cc341d 100644
--- a/apps/manavoxel/apps/web/src/routes/+page.svelte
+++ b/apps/manavoxel/apps/web/src/routes/+page.svelte
@@ -4,13 +4,22 @@
import { DEFAULT_MATERIALS, MATERIAL_AIR } from '@manavoxel/shared';
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 type { SpriteData } from '$lib/editor/types';
import InventoryUI from '$lib/components/Inventory.svelte';
import PropertyPanel from '$lib/editor/property-panel.svelte';
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 { loadWorld, getAllWorlds } from '$lib/data/world-loader';
+ import {
+ loadWorld,
+ getAllWorlds,
+ saveItem,
+ loadAllItems,
+ saveInventory,
+ loadInventory,
+ saveAreaPixels,
+ saveAreaEntities,
+ } from '$lib/data/world-loader';
let canvasContainer: HTMLDivElement;
let engine: GameEngine | null = $state(null);
@@ -28,17 +37,39 @@
let editingItem = $state(null);
let inventory = $state(new Inventory());
let itemCounter = $state(0);
+ let timeString = $state('08:00');
+ let isNight = $state(false);
+ let dialogActive = $state(false);
+ let dialogLine = $state<{
+ speaker: string;
+ text: string;
+ options?: { label: string; action: string; nextIndex?: number }[];
+ } | null>(null);
const tools: { id: ToolType; label: string; key: string }[] = [
{ id: 'brush', label: 'Brush', key: 'B' },
{ id: 'eraser', label: 'Eraser', key: 'E' },
{ id: 'fill', label: 'Fill', key: 'G' },
{ id: 'pipette', label: 'Pick', key: 'I' },
+ { id: 'npc', label: 'NPC', key: 'N' },
];
+ const npcTypes = [
+ { value: 'hostile', label: 'Hostile', color: '#EF4444' },
+ { value: 'passive', label: 'Passive', color: '#22C55E' },
+ { value: 'merchant', label: 'Merchant', color: '#EAB308' },
+ { value: 'guard', label: 'Guard', color: '#3B82F6' },
+ ];
+ let selectedNpcType = $state('hostile');
+
const materials = DEFAULT_MATERIALS.filter((m) => m.id !== MATERIAL_AIR);
- onMount(async () => {
+ const PLAYER_ID = 'local-player';
+ let autoSaveInterval: ReturnType;
+ let timeUpdateInterval: ReturnType;
+ let keydownHandler: ((ev: KeyboardEvent) => void) | null = null;
+
+ async function initGame() {
// Initialize local-first database (creates tables, seeds guest data)
await gameStore.initialize();
@@ -57,8 +88,26 @@
}
}
+ // Load saved items and restore inventory
+ const savedItems = await loadAllItems();
+ const savedInventory = await loadInventory(PLAYER_ID);
+ const itemMap = new Map(savedItems.map((i) => [i.id, i]));
+
+ for (let i = 0; i < savedInventory.slots.length; i++) {
+ const itemId = savedInventory.slots[i];
+ if (itemId) {
+ const item = itemMap.get(itemId);
+ if (item) inventory.slots[i] = item;
+ }
+ }
+ if (savedInventory.heldSlot >= 0) {
+ inventory.heldSlot = savedInventory.heldSlot;
+ }
+ itemCounter = savedItems.length;
+
const e = new GameEngine(canvasContainer, worldData ?? undefined);
e.inventory = inventory;
+ e.registerItemBehaviors();
engine = e;
loading = false;
@@ -70,10 +119,35 @@
areaName = e.areaName;
currentFloor = e.currentFloor;
totalFloors = e.totalFloors;
+ timeString = e.dayNight.timeString;
+ isNight = e.dayNight.isNight;
+ dialogActive = e.dialog.active;
+ dialogLine = e.dialog.currentLine;
};
+ // Update time display every second
+ timeUpdateInterval = setInterval(() => {
+ if (!e.dayNight) return;
+ timeString = e.dayNight.timeString;
+ isNight = e.dayNight.isNight;
+ }, 1000);
+
+ // Auto-save area data every 10 seconds
+ autoSaveInterval = setInterval(async () => {
+ const area = e.areaManager.currentArea;
+ if (!area) return;
+ const tilemap = area.tilemap;
+ if (tilemap.isDirty) {
+ tilemap.isDirty = false;
+ const pixelData = tilemap.exportPixelData();
+ await saveAreaPixels(area.data.id, pixelData);
+ }
+ // Always save entities (lightweight)
+ await saveAreaEntities(area.data.id, area.data.entities);
+ }, 10_000);
+
// Keyboard shortcuts
- const onKey = (ev: KeyboardEvent) => {
+ keydownHandler = (ev: KeyboardEvent) => {
if (ev.target instanceof HTMLInputElement) return;
switch (ev.key.toLowerCase()) {
case 'tab':
@@ -92,6 +166,9 @@
case 'i':
e.setTool('pipette');
break;
+ case 'n':
+ e.setTool('npc');
+ break;
case '[':
e.setBrushSize(e.brushSize - 2);
break;
@@ -105,11 +182,26 @@
e.setMaterial(materials[num - 1].id);
}
};
- window.addEventListener('keydown', onKey);
+ window.addEventListener('keydown', keydownHandler);
+ }
+
+ onMount(() => {
+ initGame();
return () => {
- window.removeEventListener('keydown', onKey);
- e.destroy();
+ clearInterval(autoSaveInterval);
+ clearInterval(timeUpdateInterval);
+ // Save on exit
+ if (engine) {
+ const area = engine.areaManager.currentArea;
+ if (area && area.tilemap.isDirty) {
+ const pixelData = area.tilemap.exportPixelData();
+ saveAreaPixels(area.data.id, pixelData);
+ }
+ engine.destroy();
+ }
+ saveInventory(PLAYER_ID, inventory.slots, inventory.heldSlot);
+ if (keydownHandler) window.removeEventListener('keydown', keydownHandler);
};
});
@@ -151,6 +243,13 @@
HP: {engine.player.hp}/{engine.player.maxHp}
+
+ {timeString}
+
{/if}
{#if isEditing}
@@ -201,22 +300,49 @@
{/each}
-
-
-
Size: {brushSize}px
-
- {#each [1, 3, 5, 7] as size}
-
- {/each}
+
+ {#if activeTool !== 'npc'}
+
+
Size: {brushSize}px
+
+ {#each [1, 3, 5, 7] as size}
+
+ {/each}
+
-
+ {/if}
+
+
+ {#if activeTool === 'npc'}
+
+
NPC Type
+
+ {#each npcTypes as npc}
+
+ {/each}
+
+
Click on map to place
+
+ {/if}
@@ -270,8 +396,9 @@
{
+ onDrop={async (slot) => {
inventory.removeItem(slot);
+ await saveInventory(PLAYER_ID, inventory.slots, inventory.heldSlot);
}}
onInspect={(item) => {
editingItem = item;
@@ -326,8 +453,10 @@
{#if showPropertyPanel}
{
+ onUpdate={async (updated) => {
editingItem = updated;
+ await saveItem(updated);
+ engine?.registerItemBehaviors();
}}
onClose={() => {
showPropertyPanel = false;
@@ -338,10 +467,14 @@
{#if showTriggerEditor && editingItem}
{
- // Store behaviors on the item (extend GameItem type later)
- console.log('Behaviors updated:', behaviors);
+ behaviors={editingItem.behaviors ?? []}
+ onUpdate={async (behaviors) => {
+ if (!editingItem) return;
+ editingItem.behaviors = behaviors;
+ // Persist to IndexedDB
+ await saveItem(editingItem);
+ // Re-register behaviors in engine
+ engine?.registerItemBehaviors();
}}
onClose={() => {
showTriggerEditor = false;
@@ -353,18 +486,52 @@
{/if}
+
+ {#if dialogActive && dialogLine}
+
+
+
{dialogLine.speaker}
+
{dialogLine.text}
+
+ {#each dialogLine.options ?? [{ label: 'Close', action: 'close' }] as option}
+
+ {/each}
+
+
+
+ {/if}
+
{#if showSpriteEditor}
{
+ onSave={async (data) => {
itemCounter++;
const item = createItem(`Item ${itemCounter}`, data);
const slot = inventory.addItem(item);
if (slot >= 0) {
inventory.selectSlot(slot);
}
+ // Persist item and inventory to IndexedDB
+ await saveItem(item);
+ await saveInventory(PLAYER_ID, inventory.slots, inventory.heldSlot);
showSpriteEditor = false;
}}
onClose={() => (showSpriteEditor = false)}
diff --git a/apps/manavoxel/apps/web/src/routes/health/+server.ts b/apps/manavoxel/apps/web/src/routes/health/+server.ts
new file mode 100644
index 000000000..c0f6e4da9
--- /dev/null
+++ b/apps/manavoxel/apps/web/src/routes/health/+server.ts
@@ -0,0 +1,9 @@
+import { json } from '@sveltejs/kit';
+
+export function GET() {
+ return json({
+ status: 'ok',
+ service: 'manavoxel-web',
+ timestamp: new Date().toISOString(),
+ });
+}