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(), + }); +}