feat(manavoxel): complete game engine with behavior system, NPCs, lighting, and dialog

Major systems added to ManaVoxel:
- Behavior runtime: EventBus + 10 triggers + 11 action executors
- Item persistence: save/load items, inventory, area pixels to IndexedDB
- NPC system: 4 types (hostile/passive/merchant/guard), patrol/chase/attack AI
- Lighting: darkness overlay with emissive material light sources
- Day/night cycle: time-based ambient lighting on streets
- Sound system: 8 synthesized Web Audio API presets
- Sprite animation: multi-frame support in editor with play/stop
- Dialog system: NPC interaction with text bubbles and options
- Item properties: range, speed, durability, element all functional
- Health endpoint for Docker, durability bar in inventory UI

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-30 15:04:07 +02:00
parent 72da55d3d0
commit 45a17188e1
17 changed files with 2486 additions and 110 deletions

View file

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

View file

@ -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
}
</script>
<div class="flex gap-1 rounded-lg bg-gray-800/90 p-1.5 backdrop-blur">
@ -74,6 +82,15 @@
style="image-rendering: pixelated;"
use:itemCanvas={item}
></canvas>
{@const ratio = item.properties.durabilityCurrent / item.properties.durabilityMax}
{#if ratio < 1}
<div class="absolute bottom-0.5 left-0.5 right-0.5 h-[2px] rounded-full bg-gray-700/80">
<div
class="h-full rounded-full"
style="width: {ratio * 100}%; background-color: {durabilityColor(ratio)}"
></div>
</div>
{/if}
{/if}
<span class="absolute -bottom-0.5 -right-0.5 text-[8px] text-gray-600">{i + 1}</span>
</button>

View file

@ -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<void> {
const existing = await itemCollection.get(item.id);
const localItem: Partial<LocalItem> & { 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<GameItem[]> {
const dbItems = await itemCollection.getAll();
return dbItems.map(dbItemToGameItem);
}
/** Delete an item from IndexedDB */
export async function deleteItem(itemId: string): Promise<void> {
await itemCollection.delete(itemId);
}
/** Save inventory state to IndexedDB */
export async function saveInventory(
playerId: string,
slots: (GameItem | null)[],
heldSlot: number
): Promise<void> {
// 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<ItemProperties>(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<TriggerAction[]>(dbItem.behavior, []),
};
}
// ─── Converters ─────────────────────────────────────────────
function dbAreaToEngineArea(dbArea: LocalArea): Area {

View file

@ -1,5 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte';
import type { SpriteData } from './types';
// Props
let {
@ -10,23 +11,21 @@
onClose = undefined as (() => void) | undefined,
} = $props();
export interface SpriteData {
pixels: Uint8Array; // RGBA flat array
width: number;
height: number;
}
// State
let canvas: HTMLCanvasElement;
let ctx: CanvasRenderingContext2D;
let previewCanvas: HTMLCanvasElement;
let previewCtx: CanvasRenderingContext2D;
let pixels: Uint8Array = $state(new Uint8Array(width * height * 4)); // RGBA
const frameSize = width * height * 4;
let frames: Uint8Array[] = $state([new Uint8Array(frameSize)]); // RGBA per frame
let currentFrame = $state(0);
let currentColor = $state('#FF4444');
let currentTool = $state<'brush' | 'eraser' | 'fill' | 'pipette'>('brush');
let isDrawing = $state(false);
let zoom = $state(8); // Each sprite pixel = 8 screen pixels
let animPlaying = $state(false);
let animInterval: ReturnType<typeof setInterval> | null = null;
// Color palette
const palette = [
@ -61,23 +60,23 @@
let redoStack: Uint8Array[] = $state([]);
function saveUndo() {
undoStack = [...undoStack, new Uint8Array(pixels)];
undoStack = [...undoStack, new Uint8Array(frames[currentFrame])];
if (undoStack.length > 30) undoStack = undoStack.slice(-30);
redoStack = [];
}
function undo() {
if (undoStack.length === 0) return;
redoStack = [...redoStack, new Uint8Array(pixels)];
pixels = undoStack[undoStack.length - 1];
redoStack = [...redoStack, new Uint8Array(frames[currentFrame])];
frames[currentFrame] = undoStack[undoStack.length - 1];
undoStack = undoStack.slice(0, -1);
renderCanvas();
}
function redo() {
if (redoStack.length === 0) return;
undoStack = [...undoStack, new Uint8Array(pixels)];
pixels = redoStack[redoStack.length - 1];
undoStack = [...undoStack, new Uint8Array(frames[currentFrame])];
frames[currentFrame] = redoStack[redoStack.length - 1];
redoStack = redoStack.slice(0, -1);
renderCanvas();
}
@ -91,17 +90,19 @@
}
function getPixel(x: number, y: number): [number, number, number, number] {
const p = frames[currentFrame];
const i = (y * width + x) * 4;
return [pixels[i], pixels[i + 1], pixels[i + 2], pixels[i + 3]];
return [p[i], p[i + 1], p[i + 2], p[i + 3]];
}
function setPixel(x: number, y: number, r: number, g: number, b: number, a: number) {
if (x < 0 || x >= width || y < 0 || y >= height) return;
const p = frames[currentFrame];
const i = (y * width + x) * 4;
pixels[i] = r;
pixels[i + 1] = g;
pixels[i + 2] = b;
pixels[i + 3] = a;
p[i] = r;
p[i + 1] = g;
p[i + 2] = b;
p[i + 3] = a;
}
function colorsMatch(a: [number, number, number, number], b: [number, number, number, number]) {
@ -239,46 +240,92 @@
// Mirror operations
function mirrorH() {
saveUndo();
const newPixels = new Uint8Array(pixels.length);
const src = frames[currentFrame];
const newPixels = new Uint8Array(src.length);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const srcI = (y * width + x) * 4;
const dstI = (y * width + (width - 1 - x)) * 4;
newPixels[dstI] = pixels[srcI];
newPixels[dstI + 1] = pixels[srcI + 1];
newPixels[dstI + 2] = pixels[srcI + 2];
newPixels[dstI + 3] = pixels[srcI + 3];
newPixels[dstI] = src[srcI];
newPixels[dstI + 1] = src[srcI + 1];
newPixels[dstI + 2] = src[srcI + 2];
newPixels[dstI + 3] = src[srcI + 3];
}
}
pixels = newPixels;
frames[currentFrame] = newPixels;
renderCanvas();
}
function mirrorV() {
saveUndo();
const newPixels = new Uint8Array(pixels.length);
const src = frames[currentFrame];
const newPixels = new Uint8Array(src.length);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const srcI = (y * width + x) * 4;
const dstI = ((height - 1 - y) * width + x) * 4;
newPixels[dstI] = pixels[srcI];
newPixels[dstI + 1] = pixels[srcI + 1];
newPixels[dstI + 2] = pixels[srcI + 2];
newPixels[dstI + 3] = pixels[srcI + 3];
newPixels[dstI] = src[srcI];
newPixels[dstI + 1] = src[srcI + 1];
newPixels[dstI + 2] = src[srcI + 2];
newPixels[dstI + 3] = src[srcI + 3];
}
}
pixels = newPixels;
frames[currentFrame] = newPixels;
renderCanvas();
}
function clearAll() {
saveUndo();
pixels = new Uint8Array(width * height * 4);
frames[currentFrame] = new Uint8Array(frameSize);
renderCanvas();
}
// Frame management
function addFrame() {
// Duplicate current frame
frames = [...frames, new Uint8Array(frames[currentFrame])];
currentFrame = frames.length - 1;
renderCanvas();
}
function removeFrame() {
if (frames.length <= 1) return;
frames = frames.filter((_, i) => i !== currentFrame);
if (currentFrame >= frames.length) currentFrame = frames.length - 1;
renderCanvas();
}
function prevFrame() {
currentFrame = (currentFrame - 1 + frames.length) % frames.length;
renderCanvas();
}
function nextFrame() {
currentFrame = (currentFrame + 1) % frames.length;
renderCanvas();
}
function togglePlayAnimation() {
if (animPlaying) {
if (animInterval) clearInterval(animInterval);
animInterval = null;
animPlaying = false;
} else {
animPlaying = true;
animInterval = setInterval(() => {
currentFrame = (currentFrame + 1) % frames.length;
renderPreview();
}, 150); // ~6.7 FPS
}
}
function handleSave() {
onSave?.({ pixels: new Uint8Array(pixels), width, height });
// Concatenate all frames into a single pixel buffer
const totalPixels = new Uint8Array(frameSize * frames.length);
for (let i = 0; i < frames.length; i++) {
totalPixels.set(frames[i], i * frameSize);
}
onSave?.({ pixels: totalPixels, width, height, frames: frames.length });
}
// Keyboard shortcuts
@ -302,13 +349,22 @@
previewCtx = previewCanvas.getContext('2d')!;
if (initialData) {
pixels = new Uint8Array(initialData);
// Split initial data into frames
const numFrames = Math.max(1, Math.floor(initialData.length / frameSize));
frames = [];
for (let i = 0; i < numFrames; i++) {
frames.push(new Uint8Array(initialData.slice(i * frameSize, (i + 1) * frameSize)));
}
currentFrame = 0;
}
renderCanvas();
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('keydown', handleKeyDown);
if (animInterval) clearInterval(animInterval);
};
});
</script>
@ -349,6 +405,41 @@
></canvas>
</div>
<!-- Frames -->
<div class="rounded border border-gray-700 bg-gray-950 p-2">
<div class="mb-1 flex items-center justify-between text-xs text-gray-500">
<span>Frame {currentFrame + 1}/{frames.length}</span>
<button
class="rounded px-1.5 py-0.5 text-[10px] {animPlaying
? 'bg-red-600 text-white'
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'}"
onclick={togglePlayAnimation}
>
{animPlaying ? 'Stop' : 'Play'}
</button>
</div>
<div class="flex gap-1">
<button
class="rounded bg-gray-700 px-2 py-0.5 text-xs text-gray-300 hover:bg-gray-600"
onclick={prevFrame}>←</button
>
<button
class="rounded bg-gray-700 px-2 py-0.5 text-xs text-gray-300 hover:bg-gray-600"
onclick={nextFrame}>→</button
>
<button
class="rounded bg-emerald-700 px-2 py-0.5 text-xs text-white hover:bg-emerald-600"
onclick={addFrame}>+</button
>
{#if frames.length > 1}
<button
class="rounded bg-red-700 px-2 py-0.5 text-xs text-white hover:bg-red-600"
onclick={removeFrame}></button
>
{/if}
</div>
</div>
<!-- Tools -->
<div class="flex flex-wrap gap-1">
{#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}

View file

@ -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.

View file

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

View file

@ -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<string, SoundPreset> = {
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;
}

View file

@ -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<string, unknown>;
}
// ─── 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<GameEventType, EventListener[]>();
private _timers = new Map<string, number>(); // itemId → accumulated frames
private _variables = new Map<string, unknown>(); // 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<string, unknown> }[],
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<string, unknown> }[],
ctx: ActionContext
) {
for (const action of actions) {
this._executeAction(action, ctx);
}
}
private _executeAction(
action: { type: string; params: Record<string, unknown> },
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;
}
}
}
}

View file

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

View file

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

View file

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

View file

@ -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<ItemProperties>
partialProps?: Partial<ItemProperties>,
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;
}

View file

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

View file

@ -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<string, string> = {
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 = [];
}
}

View file

@ -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<string, Chunk>();
@ -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);

View file

@ -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<GameItem | null>(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<typeof setInterval>;
let timeUpdateInterval: ReturnType<typeof setInterval>;
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);
};
});
</script>
@ -151,6 +243,13 @@
<div class="rounded-lg bg-gray-800/80 px-3 py-1.5 text-xs text-gray-300 backdrop-blur">
HP: {engine.player.hp}/{engine.player.maxHp}
</div>
<div
class="rounded-lg px-3 py-1.5 text-xs backdrop-blur {isNight
? 'bg-indigo-900/80 text-indigo-300'
: 'bg-gray-800/80 text-yellow-300'}"
>
{timeString}
</div>
{/if}
{#if isEditing}
<div class="rounded-lg bg-emerald-600/80 px-2 py-1 text-xs text-white backdrop-blur">
@ -201,22 +300,49 @@
</button>
{/each}
<!-- Brush size -->
<div class="mt-2 rounded-lg bg-gray-800/80 px-3 py-2 text-xs text-white backdrop-blur">
<div class="mb-1 text-gray-400">Size: {brushSize}px</div>
<div class="flex gap-1">
{#each [1, 3, 5, 7] as size}
<button
class="rounded px-2 py-0.5 transition {brushSize === size
? 'bg-emerald-600'
: 'bg-gray-700 hover:bg-gray-600'}"
onclick={() => engine?.setBrushSize(size)}
>
{size}
</button>
{/each}
<!-- Brush size (only for paint tools) -->
{#if activeTool !== 'npc'}
<div class="mt-2 rounded-lg bg-gray-800/80 px-3 py-2 text-xs text-white backdrop-blur">
<div class="mb-1 text-gray-400">Size: {brushSize}px</div>
<div class="flex gap-1">
{#each [1, 3, 5, 7] as size}
<button
class="rounded px-2 py-0.5 transition {brushSize === size
? 'bg-emerald-600'
: 'bg-gray-700 hover:bg-gray-600'}"
onclick={() => engine?.setBrushSize(size)}
>
{size}
</button>
{/each}
</div>
</div>
</div>
{/if}
<!-- NPC type selector (when NPC tool active) -->
{#if activeTool === 'npc'}
<div class="mt-2 rounded-lg bg-gray-800/80 px-3 py-2 text-xs text-white backdrop-blur">
<div class="mb-1 text-gray-400">NPC Type</div>
<div class="flex flex-col gap-1">
{#each npcTypes as npc}
<button
class="flex items-center gap-2 rounded px-2 py-1 transition {selectedNpcType ===
npc.value
? 'bg-gray-600 ring-1 ring-white'
: 'bg-gray-700 hover:bg-gray-600'}"
onclick={() => {
selectedNpcType = npc.value;
engine?.setNpcBehavior(npc.value);
}}
>
<span class="h-2 w-2 rounded-full" style="background-color: {npc.color}"></span>
{npc.label}
</button>
{/each}
</div>
<div class="mt-2 text-[10px] text-gray-500">Click on map to place</div>
</div>
{/if}
<!-- Undo/Redo -->
<div class="mt-2 flex gap-1">
@ -270,8 +396,9 @@
<div class="pointer-events-auto absolute bottom-14 left-1/2 -translate-x-1/2">
<InventoryUI
{inventory}
onDrop={(slot) => {
onDrop={async (slot) => {
inventory.removeItem(slot);
await saveInventory(PLAYER_ID, inventory.slots, inventory.heldSlot);
}}
onInspect={(item) => {
editingItem = item;
@ -326,8 +453,10 @@
{#if showPropertyPanel}
<PropertyPanel
item={editingItem}
onUpdate={(updated) => {
onUpdate={async (updated) => {
editingItem = updated;
await saveItem(updated);
engine?.registerItemBehaviors();
}}
onClose={() => {
showPropertyPanel = false;
@ -338,10 +467,14 @@
{#if showTriggerEditor && editingItem}
<TriggerEditor
behaviors={[]}
onUpdate={(behaviors) => {
// 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}
<!-- Sprite Editor Modal -->
<!-- Dialog UI -->
{#if dialogActive && dialogLine}
<div class="absolute bottom-24 left-1/2 z-50 -translate-x-1/2">
<div class="w-96 rounded-xl bg-gray-900/95 p-4 shadow-2xl backdrop-blur">
<div class="mb-2 text-xs font-bold text-emerald-400">{dialogLine.speaker}</div>
<div class="mb-3 text-sm text-gray-200">{dialogLine.text}</div>
<div class="flex gap-2">
{#each dialogLine.options ?? [{ label: 'Close', action: 'close' }] as option}
<button
class="rounded-lg bg-gray-700 px-4 py-1.5 text-xs text-white transition hover:bg-gray-600"
onclick={() => {
if (engine) {
const result = engine.dialog.selectOption(option as any);
dialogActive = engine.dialog.active;
dialogLine = engine.dialog.currentLine;
if (result === 'trade') {
// TODO: Open trade UI
engine.dialog.close();
dialogActive = false;
}
}
}}
>
{option.label}
</button>
{/each}
</div>
</div>
</div>
{/if}
{#if showSpriteEditor}
<div class="absolute inset-0 z-50 flex items-center justify-center bg-black/70">
<SpriteEditor
width={16}
height={32}
onSave={(data) => {
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)}

View file

@ -0,0 +1,9 @@
import { json } from '@sveltejs/kit';
export function GET() {
return json({
status: 'ok',
service: 'manavoxel-web',
timestamp: new Date().toISOString(),
});
}