mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-17 02:19:41 +02:00
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:
parent
72da55d3d0
commit
45a17188e1
17 changed files with 2486 additions and 110 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
19
apps/manavoxel/apps/web/src/lib/editor/types.ts
Normal file
19
apps/manavoxel/apps/web/src/lib/editor/types.ts
Normal 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;
|
||||
}
|
||||
193
apps/manavoxel/apps/web/src/lib/engine/audio.ts
Normal file
193
apps/manavoxel/apps/web/src/lib/engine/audio.ts
Normal 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;
|
||||
}
|
||||
327
apps/manavoxel/apps/web/src/lib/engine/behavior.ts
Normal file
327
apps/manavoxel/apps/web/src/lib/engine/behavior.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
144
apps/manavoxel/apps/web/src/lib/engine/dialog.ts
Normal file
144
apps/manavoxel/apps/web/src/lib/engine/dialog.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
288
apps/manavoxel/apps/web/src/lib/engine/lighting.ts
Normal file
288
apps/manavoxel/apps/web/src/lib/engine/lighting.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
374
apps/manavoxel/apps/web/src/lib/engine/npc.ts
Normal file
374
apps/manavoxel/apps/web/src/lib/engine/npc.ts
Normal 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 = [];
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
9
apps/manavoxel/apps/web/src/routes/health/+server.ts
Normal file
9
apps/manavoxel/apps/web/src/routes/health/+server.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { json } from '@sveltejs/kit';
|
||||
|
||||
export function GET() {
|
||||
return json({
|
||||
status: 'ok',
|
||||
service: 'manavoxel-web',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue