From 39250193441366cecf52890101c19268834778c4 Mon Sep 17 00:00:00 2001 From: Till JS Date: Sun, 29 Mar 2026 14:07:46 +0200 Subject: [PATCH] feat(manavoxel): add local persistence and world templates Local-First Persistence (Dexie.js): - gameStore via @manacore/local-store: worlds, areas, items, inventories - Guest seed data: demo village with street + 2-story house - World loader bridge: converts between DB format and engine format - Base64 encoding for pixel data (Dexie-compatible) - Auto-loads first world on startup, supports ?world= URL param World Templates + "New World" UI: - 5 templates: Village, Dungeon, Arena, House, Empty - Each template generates pre-built areas with pixel data - /worlds route: browse own worlds, create new, delete - New World dialog: name input + template selection grid - Navigation: "Worlds" button in HUD links to world browser - Game engine accepts world data from DB instead of hardcoded demo Co-Authored-By: Claude Opus 4.6 (1M context) --- .../apps/web/src/lib/data/guest-seed.ts | 212 ++++++++++ .../apps/web/src/lib/data/local-store.ts | 120 ++++++ .../apps/web/src/lib/data/templates.ts | 387 ++++++++++++++++++ .../apps/web/src/lib/data/world-loader.ts | 112 +++++ .../manavoxel/apps/web/src/lib/engine/game.ts | 39 +- .../apps/web/src/routes/+page.svelte | 45 +- .../apps/web/src/routes/worlds/+page.svelte | 175 ++++++++ 7 files changed, 1075 insertions(+), 15 deletions(-) create mode 100644 apps/manavoxel/apps/web/src/lib/data/guest-seed.ts create mode 100644 apps/manavoxel/apps/web/src/lib/data/local-store.ts create mode 100644 apps/manavoxel/apps/web/src/lib/data/templates.ts create mode 100644 apps/manavoxel/apps/web/src/lib/data/world-loader.ts create mode 100644 apps/manavoxel/apps/web/src/routes/worlds/+page.svelte diff --git a/apps/manavoxel/apps/web/src/lib/data/guest-seed.ts b/apps/manavoxel/apps/web/src/lib/data/guest-seed.ts new file mode 100644 index 000000000..b513c198f --- /dev/null +++ b/apps/manavoxel/apps/web/src/lib/data/guest-seed.ts @@ -0,0 +1,212 @@ +/** + * Guest seed data: A small demo village that appears on first visit. + * Generates worlds, areas, and starter items for the guest experience. + */ + +import { DEFAULT_MATERIALS, MATERIAL_AIR } from '@manavoxel/shared'; +import { encodeBytes, type LocalWorld, type LocalArea, type LocalItem } from './local-store'; + +// Cached result so guest seed is only generated once +let cached: { worlds: LocalWorld[]; areas: LocalArea[]; items: LocalItem[] } | null = null; + +export function generateGuestWorld() { + if (cached) return cached; + + const worldId = 'guest-world-001'; + const streetId = 'guest-street-001'; + const houseId = 'guest-house-001'; + + // ─── Street (50m × 30m at 10cm) ───────────────────────── + + const streetW = 500; + const streetH = 300; + const streetPixels = new Uint8Array(streetW * streetH * 2); + const streetView = new DataView(streetPixels.buffer); + + for (let y = 0; y < streetH; y++) { + for (let x = 0; x < streetW; x++) { + let mat = MATERIAL_AIR; + + // Border + if (x === 0 || x === streetW - 1 || y === 0 || y === streetH - 1) mat = 1; + // Road + else if (y >= 130 && y <= 170) mat = 12; + // Grass patches + else if ((y > 170 || y < 130) && Math.random() < 0.25) mat = 3; + // House 1 (brick) + else if (x >= 40 && x <= 80 && y >= 40 && y <= 80) { + if (x === 40 || x === 80 || y === 40 || y === 80) mat = 8; + else if (y === 80 && x >= 56 && x <= 64) + mat = 0; // door + else if (y === 45 && (x === 50 || x === 55 || x === 65 || x === 70)) mat = 9; + } + // House 2 (wood) + else if (x >= 120 && x <= 180 && y >= 30 && y <= 90) { + if (x === 120 || x === 180 || y === 30 || y === 90) mat = 4; + else if (y === 90 && x >= 145 && x <= 155) mat = 0; + else if (y === 40 && (x === 135 || x === 150 || x === 165)) mat = 9; + } + // House 3 (stone) + else if (x >= 250 && x <= 310 && y >= 50 && y <= 100) { + if (x === 250 || x === 310 || y === 50 || y === 100) mat = 1; + else if (y === 100 && x >= 275 && x <= 285) mat = 0; + } + // Well (center of village) + else if (Math.abs(x - 150) <= 4 && Math.abs(y - 150) <= 4) { + if (Math.abs(x - 150) === 4 || Math.abs(y - 150) === 4) mat = 12; + else mat = 7; // water + } + // Trees + else if (x === 350 && y >= 80 && y <= 95) mat = 4; + else if (Math.abs(x - 350) + Math.abs(y - 75) <= 7 && y < 82) mat = 13; + else if (x === 400 && y >= 100 && y <= 115) mat = 4; + else if (Math.abs(x - 400) + Math.abs(y - 95) <= 6 && y < 102) mat = 13; + // Torches + else if (y === 128 && x % 30 === 15) mat = 10; + else if (y === 172 && x % 30 === 15) mat = 10; + + if (mat !== 0) streetView.setUint16((y * streetW + x) * 2, mat, true); + } + } + + // ─── House Interior (12m × 8m at 5cm = 240 × 160, 2 floors) ─ + + const houseW = 240; + const houseH = 160; + const houseFloors = 2; + const housePixels = new Uint8Array(houseW * houseH * houseFloors * 2); + const houseView = new DataView(housePixels.buffer); + + function setHousePixel(floor: number, x: number, y: number, mat: number) { + const offset = floor * houseW * houseH; + const idx = (offset + y * houseW + x) * 2; + if (idx + 1 < housePixels.length) houseView.setUint16(idx, mat, true); + } + + // Ground floor + for (let y = 0; y < houseH; y++) { + for (let x = 0; x < houseW; x++) { + if (x === 0 || x === houseW - 1 || y === 0 || y === houseH - 1) setHousePixel(0, x, y, 1); + else if (x > 2 && x < houseW - 3 && y > 2 && y < houseH - 3) setHousePixel(0, x, y, 5); + if (y === houseH - 1 && x >= 55 && x <= 65) setHousePixel(0, x, y, 0); // door + if (y === 0 && (x === 40 || x === 80 || x === 120 || x === 160 || x === 200)) + setHousePixel(0, x, y, 9); + } + } + // Table + for (let y = 50; y <= 75; y++) + for (let x = 80; x <= 130; x++) + if (y === 50 || y === 75 || x === 80 || x === 130) setHousePixel(0, x, y, 4); + // Fireplace + for (let y = 20; y <= 50; y++) + for (let x = 190; x <= 225; x++) { + if (y === 20 || x === 190 || x === 225) setHousePixel(0, x, y, 8); + else if (y >= 38 && y <= 45 && x >= 200 && x <= 215) setHousePixel(0, x, y, 10); + } + // Stairs + for (let y = 120; y <= 140; y++) for (let x = 190; x <= 210; x++) setHousePixel(0, x, y, 14); + + // Upper floor + for (let y = 0; y < houseH; y++) { + for (let x = 0; x < houseW; x++) { + if (x === 0 || x === houseW - 1 || y === 0 || y === houseH - 1) setHousePixel(1, x, y, 1); + else if (x > 2 && x < houseW - 3 && y > 2 && y < houseH - 3) setHousePixel(1, x, y, 5); + } + } + // Bed + for (let y = 25; y <= 65; y++) + for (let x = 20; x <= 60; x++) { + if (y === 25 || y === 65 || x === 20 || x === 60) setHousePixel(1, x, y, 4); + else setHousePixel(1, x, y, 15); + } + // Stairs back + for (let y = 120; y <= 140; y++) for (let x = 190; x <= 210; x++) setHousePixel(1, x, y, 14); + + // ─── Assemble Data ────────────────────────────────────── + + const worlds: LocalWorld[] = [ + { + id: worldId, + name: 'Demo Village', + description: 'A small village to explore and build in', + creatorId: 'guest', + isPublished: false, + playCount: 0, + startAreaId: streetId, + template: 'village', + settings: {}, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + ]; + + const paletteJson = JSON.stringify(DEFAULT_MATERIALS); + + const areas: LocalArea[] = [ + { + id: streetId, + worldId, + name: 'Marktplatz', + type: 'street', + resolution: 0.1, + width: streetW, + height: streetH, + floors: 1, + pixelData: encodeBytes(streetPixels), + palette: paletteJson, + entities: '[]', + portals: JSON.stringify([ + { + id: 'portal-to-house', + x: 60, + y: 80, + floor: 0, + targetAreaId: houseId, + targetX: 60, + targetY: 150, + targetFloor: 0, + }, + ]), + spawnX: 60, + spawnY: 150, + spawnFloor: 0, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + { + id: houseId, + worldId, + name: 'Haus am Marktplatz', + type: 'interior', + resolution: 0.05, + width: houseW, + height: houseH, + floors: houseFloors, + pixelData: encodeBytes(housePixels), + palette: paletteJson, + entities: '[]', + portals: JSON.stringify([ + { + id: 'portal-to-street', + x: 60, + y: houseH - 1, + floor: 0, + targetAreaId: streetId, + targetX: 60, + targetY: 82, + targetFloor: 0, + }, + ]), + spawnX: 60, + spawnY: houseH - 10, + spawnFloor: 0, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + ]; + + const items: LocalItem[] = []; + + cached = { worlds, areas, items }; + return cached; +} diff --git a/apps/manavoxel/apps/web/src/lib/data/local-store.ts b/apps/manavoxel/apps/web/src/lib/data/local-store.ts new file mode 100644 index 000000000..7db8903c3 --- /dev/null +++ b/apps/manavoxel/apps/web/src/lib/data/local-store.ts @@ -0,0 +1,120 @@ +/** + * ManaVoxel — Local-First Data Layer + * + * All world data lives in IndexedDB via Dexie.js. + * Worlds, areas, items persist across browser sessions. + * Syncs to server via mana-sync when authenticated. + */ + +import { createLocalStore, type BaseRecord } from '@manacore/local-store'; +import { generateGuestWorld } from './guest-seed'; + +// ─── Types ────────────────────────────────────────────────── + +export interface LocalWorld extends BaseRecord { + name: string; + description: string; + creatorId: string; + isPublished: boolean; + playCount: number; + startAreaId: string; + template: string; + settings: Record; +} + +export interface LocalArea extends BaseRecord { + worldId: string; + name: string; + type: 'street' | 'interior'; + resolution: number; + width: number; + height: number; + floors: number; + pixelData: string; // Base64-encoded Uint8Array (Dexie can't store Uint8Array in indexes) + palette: string; // JSON stringified Material[] + entities: string; // JSON stringified EntityDef[] + portals: string; // JSON stringified PortalDef[] + spawnX: number; + spawnY: number; + spawnFloor: number; +} + +export interface LocalItem extends BaseRecord { + creatorId: string; + name: string; + description: string; + spriteData: string; // Base64-encoded RGBA Uint8Array + spriteWidth: number; + spriteHeight: number; + animationFrames: number; + resolution: number; + properties: string; // JSON stringified ItemProperties + behavior: string; // JSON stringified TriggerAction[] + rarity: string; + isPublished: boolean; +} + +export interface LocalInventorySlot extends BaseRecord { + playerId: string; + itemId: string; + slot: number; + quantity: number; + instanceData: string; // JSON stringified +} + +// ─── Encoding Helpers ─────────────────────────────────────── + +export function encodeBytes(data: Uint8Array): string { + let binary = ''; + for (let i = 0; i < data.length; i++) { + binary += String.fromCharCode(data[i]); + } + return btoa(binary); +} + +export function decodeBytes(base64: string): Uint8Array { + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; +} + +// ─── Store ────────────────────────────────────────────────── + +const SYNC_SERVER_URL = import.meta.env.PUBLIC_SYNC_SERVER_URL || 'http://localhost:3050'; + +export const gameStore = createLocalStore({ + appId: 'manavoxel', + collections: [ + { + name: 'worlds', + indexes: ['creatorId', 'isPublished', 'name', 'template'], + guestSeed: () => generateGuestWorld().worlds, + }, + { + name: 'areas', + indexes: ['worldId', 'type', '[worldId+name]'], + guestSeed: () => generateGuestWorld().areas, + }, + { + name: 'items', + indexes: ['creatorId', 'rarity', 'isPublished', 'name'], + guestSeed: () => generateGuestWorld().items, + }, + { + name: 'inventories', + indexes: ['playerId', '[playerId+slot]', 'itemId'], + }, + ], + sync: { + serverUrl: SYNC_SERVER_URL, + }, +}); + +// Typed collection accessors +export const worldCollection = gameStore.collection('worlds'); +export const areaCollection = gameStore.collection('areas'); +export const itemCollection = gameStore.collection('items'); +export const inventoryCollection = gameStore.collection('inventories'); diff --git a/apps/manavoxel/apps/web/src/lib/data/templates.ts b/apps/manavoxel/apps/web/src/lib/data/templates.ts new file mode 100644 index 000000000..c2a7c1792 --- /dev/null +++ b/apps/manavoxel/apps/web/src/lib/data/templates.ts @@ -0,0 +1,387 @@ +/** + * World templates: pre-built area layouts for "New World" creation. + * Each template generates one or more areas with pixel data. + */ + +import { DEFAULT_MATERIALS, MATERIAL_AIR } from '@manavoxel/shared'; +import { encodeBytes, type LocalArea } from './local-store'; + +export interface WorldTemplate { + id: string; + name: string; + description: string; + icon: string; + generate: () => { + areas: { area: Omit; id: string }[]; + }; +} + +function makePixels( + w: number, + h: number, + floors: number, + fill: (view: DataView, floor: number, x: number, y: number) => number +): Uint8Array { + const data = new Uint8Array(w * h * floors * 2); + const view = new DataView(data.buffer); + for (let f = 0; f < floors; f++) { + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + const mat = fill(view, f, x, y); + if (mat !== 0) { + const offset = f * w * h; + view.setUint16((offset + y * w + x) * 2, mat, true); + } + } + } + } + return data; +} + +const paletteJson = JSON.stringify(DEFAULT_MATERIALS); + +// ─── Village Template ─────────────────────────────────────── + +function generateVillage() { + const streetId = crypto.randomUUID(); + const houseId = crypto.randomUUID(); + + const streetW = 400, + streetH = 250; + const streetPixels = makePixels(streetW, streetH, 1, (_v, _f, x, y) => { + if (x === 0 || x === streetW - 1 || y === 0 || y === streetH - 1) return 1; + if (y >= 110 && y <= 140) return 12; // road + if ((y > 140 || y < 110) && Math.random() < 0.2) return 3; // grass + // House outlines + if (x >= 30 && x <= 70 && y >= 30 && y <= 70) { + if (x === 30 || x === 70 || y === 30 || y === 70) return 8; + if (y === 70 && x >= 46 && x <= 54) return 0; + return 0; + } + if (x >= 100 && x <= 150 && y >= 25 && y <= 75) { + if (x === 100 || x === 150 || y === 25 || y === 75) return 4; + if (y === 75 && x >= 120 && x <= 130) return 0; + return 0; + } + // Well + if (Math.abs(x - 200) <= 3 && Math.abs(y - 125) <= 3) { + if (Math.abs(x - 200) === 3 || Math.abs(y - 125) === 3) return 12; + return 7; + } + // Trees + if (x === 300 && y >= 60 && y <= 75) return 4; + if (Math.abs(x - 300) + Math.abs(y - 55) <= 6 && y < 62) return 13; + // Torches + if (y === 108 && x % 25 === 12) return 10; + return 0; + }); + + const houseW = 200, + houseH = 140; + const housePixels = makePixels(houseW, houseH, 1, (_v, _f, x, y) => { + if (x === 0 || x === houseW - 1 || y === 0 || y === houseH - 1) return 1; + if (x > 2 && x < houseW - 3 && y > 2 && y < houseH - 3) return 5; + if (y === houseH - 1 && x >= 45 && x <= 55) return 0; + if (y === 0 && (x === 40 || x === 80 || x === 120 || x === 160)) return 9; + return 0; + }); + + return { + areas: [ + { + id: streetId, + area: { + worldId: '', + name: 'Dorfplatz', + type: 'street' as const, + resolution: 0.1, + width: streetW, + height: streetH, + floors: 1, + pixelData: encodeBytes(streetPixels), + palette: paletteJson, + entities: '[]', + portals: JSON.stringify([ + { + id: crypto.randomUUID(), + x: 50, + y: 70, + floor: 0, + targetAreaId: houseId, + targetX: 50, + targetY: 130, + targetFloor: 0, + }, + ]), + spawnX: 50, + spawnY: 125, + spawnFloor: 0, + }, + }, + { + id: houseId, + area: { + worldId: '', + name: 'Bauernhaus', + type: 'interior' as const, + resolution: 0.05, + width: houseW, + height: houseH, + floors: 1, + pixelData: encodeBytes(housePixels), + palette: paletteJson, + entities: '[]', + portals: JSON.stringify([ + { + id: crypto.randomUUID(), + x: 50, + y: houseH - 1, + floor: 0, + targetAreaId: streetId, + targetX: 50, + targetY: 72, + targetFloor: 0, + }, + ]), + spawnX: 50, + spawnY: houseH - 10, + spawnFloor: 0, + }, + }, + ], + }; +} + +// ─── Dungeon Template ─────────────────────────────────────── + +function generateDungeon() { + const id = crypto.randomUUID(); + const w = 300, + h = 300; + const pixels = makePixels(w, h, 1, (_v, _f, x, y) => { + // Outer stone walls + if (x <= 2 || x >= w - 3 || y <= 2 || y >= h - 3) return 1; + // Stone floor everywhere + let mat = 12; + // Corridors (cross shape) + const inHCorridor = y >= 140 && y <= 160; + const inVCorridor = x >= 140 && x <= 160; + if (!inHCorridor && !inVCorridor) { + // Rooms in corners + if (x >= 20 && x <= 120 && y >= 20 && y <= 120) { + if (x === 20 || x === 120 || y === 20 || y === 120) return 1; + if (y === 120 && x >= 65 && x <= 75) return 0; // door + } + if (x >= 180 && x <= 280 && y >= 20 && y <= 120) { + if (x === 180 || x === 280 || y === 20 || y === 120) return 1; + if (y === 120 && x >= 225 && x <= 235) return 0; + } + if (x >= 20 && x <= 120 && y >= 180 && y <= 280) { + if (x === 20 || x === 120 || y === 180 || y === 280) return 1; + if (y === 180 && x >= 65 && x <= 75) return 0; + } + if (x >= 180 && x <= 280 && y >= 180 && y <= 280) { + if (x === 180 || x === 280 || y === 180 || y === 280) return 1; + if (y === 180 && x >= 225 && x <= 235) return 0; + } + // Walls between rooms and corridor + if ( + !inHCorridor && + !inVCorridor && + !(x >= 20 && x <= 120 && y >= 20 && y <= 120) && + !(x >= 180 && x <= 280 && y >= 20 && y <= 120) && + !(x >= 20 && x <= 120 && y >= 180 && y <= 280) && + !(x >= 180 && x <= 280 && y >= 180 && y <= 280) + ) { + return 1; // wall filler + } + } + // Torches in corridors + if (inHCorridor && y === 142 && x % 20 === 10) return 10; + if (inVCorridor && x === 142 && y % 20 === 10) return 10; + return mat; + }); + + return { + areas: [ + { + id, + area: { + worldId: '', + name: 'Dungeon Eingang', + type: 'interior' as const, + resolution: 0.1, + width: w, + height: h, + floors: 1, + pixelData: encodeBytes(pixels), + palette: paletteJson, + entities: '[]', + portals: '[]', + spawnX: 150, + spawnY: 150, + spawnFloor: 0, + }, + }, + ], + }; +} + +// ─── Arena Template ───────────────────────────────────────── + +function generateArena() { + const id = crypto.randomUUID(); + const w = 250, + h = 250; + const cx = 125, + cy = 125, + radius = 100; + const pixels = makePixels(w, h, 1, (_v, _f, x, y) => { + const dist = Math.sqrt((x - cx) ** 2 + (y - cy) ** 2); + if (dist > radius + 3) return 0; + if (dist > radius) return 1; // wall ring + if (dist > radius - 2) return 6; // sand ring + return 6; // sand floor + }); + + return { + areas: [ + { + id, + area: { + worldId: '', + name: 'Arena', + type: 'street' as const, + resolution: 0.1, + width: w, + height: h, + floors: 1, + pixelData: encodeBytes(pixels), + palette: paletteJson, + entities: '[]', + portals: '[]', + spawnX: cx, + spawnY: cy, + spawnFloor: 0, + }, + }, + ], + }; +} + +// ─── Empty Template ───────────────────────────────────────── + +function generateEmpty() { + const id = crypto.randomUUID(); + const w = 300, + h = 200; + const pixels = makePixels(w, h, 1, (_v, _f, x, y) => { + if (x === 0 || x === w - 1 || y === 0 || y === h - 1) return 1; + return 0; + }); + + return { + areas: [ + { + id, + area: { + worldId: '', + name: 'Main', + type: 'street' as const, + resolution: 0.1, + width: w, + height: h, + floors: 1, + pixelData: encodeBytes(pixels), + palette: paletteJson, + entities: '[]', + portals: '[]', + spawnX: w / 2, + spawnY: h / 2, + spawnFloor: 0, + }, + }, + ], + }; +} + +// ─── House Template ───────────────────────────────────────── + +function generateHouse() { + const id = crypto.randomUUID(); + const w = 200, + h = 160; + const pixels = makePixels(w, h, 2, (_v, f, x, y) => { + if (x === 0 || x === w - 1 || y === 0 || y === h - 1) return 1; + if (x > 2 && x < w - 3 && y > 2 && y < h - 3) return 5; + if (f === 0 && y === h - 1 && x >= 45 && x <= 55) return 0; // door + if (y === 0 && (x === 40 || x === 100 || x === 160)) return 9; // windows + // Stairs + if (x >= 170 && x <= 190 && y >= 120 && y <= 140) return 14; + return 0; + }); + + return { + areas: [ + { + id, + area: { + worldId: '', + name: 'Haus', + type: 'interior' as const, + resolution: 0.05, + width: w, + height: h, + floors: 2, + pixelData: encodeBytes(pixels), + palette: paletteJson, + entities: '[]', + portals: '[]', + spawnX: 50, + spawnY: h - 10, + spawnFloor: 0, + }, + }, + ], + }; +} + +// ─── Export All Templates ─────────────────────────────────── + +export const WORLD_TEMPLATES: WorldTemplate[] = [ + { + id: 'village', + name: 'Village', + description: 'A small village with street, houses, well, and trees', + icon: '🏘️', + generate: generateVillage, + }, + { + id: 'dungeon', + name: 'Dungeon', + description: 'A cross-shaped dungeon with four rooms and corridors', + icon: '🏰', + generate: generateDungeon, + }, + { + id: 'arena', + name: 'Arena', + description: 'A circular sand arena for PvP battles', + icon: '⚔️', + generate: generateArena, + }, + { + id: 'house', + name: 'House', + description: 'A two-story house to furnish and decorate', + icon: '🏠', + generate: generateHouse, + }, + { + id: 'empty', + name: 'Empty', + description: 'A blank canvas with stone borders — build anything', + icon: '📄', + generate: generateEmpty, + }, +]; diff --git a/apps/manavoxel/apps/web/src/lib/data/world-loader.ts b/apps/manavoxel/apps/web/src/lib/data/world-loader.ts new file mode 100644 index 000000000..ca34276a7 --- /dev/null +++ b/apps/manavoxel/apps/web/src/lib/data/world-loader.ts @@ -0,0 +1,112 @@ +/** + * Bridge between Dexie.js local-store and the PixiJS game engine. + * Loads/saves world data from IndexedDB, converts between DB format and engine format. + */ + +import { + worldCollection, + areaCollection, + itemCollection, + decodeBytes, + encodeBytes, + type LocalWorld, + type LocalArea, +} from './local-store'; +import type { Area, PortalDef, EntityDef, Material } from '@manavoxel/shared'; +import { DEFAULT_MATERIALS } from '@manavoxel/shared'; + +/** Load a world and all its areas from IndexedDB */ +export async function loadWorld(worldId: string): Promise<{ + world: LocalWorld; + areas: Area[]; +} | null> { + const world = await worldCollection.get(worldId); + if (!world) return null; + + const dbAreas = await areaCollection.getAll({ worldId }); + const areas = dbAreas.map(dbAreaToEngineArea); + + return { world, areas }; +} + +/** Get all worlds from IndexedDB */ +export async function getAllWorlds(): Promise { + return worldCollection.getAll(); +} + +/** Save an area's pixel data back to IndexedDB */ +export async function saveAreaPixels(areaId: string, pixelData: Uint8Array) { + await areaCollection.update(areaId, { + pixelData: encodeBytes(pixelData), + updatedAt: new Date().toISOString(), + }); +} + +/** Create a new world with areas in IndexedDB */ +export async function createWorld( + name: string, + template: string, + areas: { area: Omit; id: string }[] +): Promise { + const worldId = crypto.randomUUID(); + const startAreaId = areas[0]?.id ?? ''; + + await worldCollection.insert({ + id: worldId, + name, + description: '', + creatorId: 'local', + isPublished: false, + playCount: 0, + startAreaId, + template, + settings: {}, + }); + + for (const { area, id } of areas) { + await areaCollection.insert({ + ...area, + id, + worldId, + }); + } + + return worldId; +} + +/** Delete a world and all its areas */ +export async function deleteWorld(worldId: string) { + const areas = await areaCollection.getAll({ worldId }); + for (const area of areas) { + await areaCollection.delete(area.id); + } + await worldCollection.delete(worldId); +} + +// ─── Converters ───────────────────────────────────────────── + +function dbAreaToEngineArea(dbArea: LocalArea): Area { + return { + id: dbArea.id, + worldId: dbArea.worldId, + name: dbArea.name, + type: dbArea.type, + resolution: dbArea.resolution, + width: dbArea.width, + height: dbArea.height, + floors: dbArea.floors, + pixelData: decodeBytes(dbArea.pixelData), + palette: safeJsonParse(dbArea.palette, DEFAULT_MATERIALS), + entities: safeJsonParse(dbArea.entities, []), + portals: safeJsonParse(dbArea.portals, []), + spawnPoint: { x: dbArea.spawnX, y: dbArea.spawnY, floor: dbArea.spawnFloor }, + }; +} + +function safeJsonParse(json: string, fallback: T): T { + try { + return JSON.parse(json); + } catch { + return fallback; + } +} diff --git a/apps/manavoxel/apps/web/src/lib/engine/game.ts b/apps/manavoxel/apps/web/src/lib/engine/game.ts index 840cc6550..b4d5fe870 100644 --- a/apps/manavoxel/apps/web/src/lib/engine/game.ts +++ b/apps/manavoxel/apps/web/src/lib/engine/game.ts @@ -66,7 +66,10 @@ export class GameEngine { return this._areaName; } - constructor(container: HTMLDivElement) { + constructor( + container: HTMLDivElement, + worldData?: { world: { startAreaId: string }; areas: import('@manavoxel/shared').Area[] } + ) { this._container = container; this.app = new Application(); this._worldContainer = new Container(); @@ -78,10 +81,13 @@ export class GameEngine { this.areaManager = new AreaManager(this._worldContainer); this.particles = new ParticleSystem(this._worldContainer); - this._init(); + this._init(worldData); } - private async _init() { + private async _init(worldData?: { + world: { startAreaId: string }; + areas: import('@manavoxel/shared').Area[]; + }) { await this.app.init({ resizeTo: this._container, background: '#1a1a2e', @@ -99,13 +105,22 @@ export class GameEngine { this.app.stage.addChild(this._fadeOverlay); this._fadeOverlay.visible = false; - // Generate demo areas - const street = generateDemoStreet(); - const interiorId = street.portals[0]?.targetAreaId; - const interior = generateDemoInterior(interiorId!, street.id); + // Load areas: from DB if available, otherwise generate demo + let startAreaId: string; - this.areaManager.registerArea(street); - this.areaManager.registerArea(interior); + if (worldData && worldData.areas.length > 0) { + for (const area of worldData.areas) { + this.areaManager.registerArea(area); + } + startAreaId = worldData.world.startAreaId; + } else { + const street = generateDemoStreet(); + const interiorId = street.portals[0]?.targetAreaId; + const interior = generateDemoInterior(interiorId!, street.id); + this.areaManager.registerArea(street); + this.areaManager.registerArea(interior); + startAreaId = street.id; + } // Area change callback this.areaManager.onAreaChanged = (loaded) => { @@ -126,15 +141,15 @@ export class GameEngine { }; // Load starting area - const loaded = this.areaManager.loadArea(street.id); + const loaded = this.areaManager.loadArea(startAreaId); if (loaded) { this.tilemap = loaded.tilemap; this._areaName = loaded.data.name; this.player = new Player( this._worldContainer, this.tilemap, - street.spawnPoint.x, - street.spawnPoint.y + loaded.data.spawnPoint.x, + loaded.data.spawnPoint.y ); this.camera.setPosition(this.player.worldX, this.player.worldY); } diff --git a/apps/manavoxel/apps/web/src/routes/+page.svelte b/apps/manavoxel/apps/web/src/routes/+page.svelte index bacfd9b9a..c48f9e013 100644 --- a/apps/manavoxel/apps/web/src/routes/+page.svelte +++ b/apps/manavoxel/apps/web/src/routes/+page.svelte @@ -9,9 +9,12 @@ 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'; + import { gameStore } from '$lib/data/local-store'; + import { loadWorld, getAllWorlds } from '$lib/data/world-loader'; let canvasContainer: HTMLDivElement; let engine: GameEngine | null = $state(null); + let loading = $state(true); let isEditing = $state(false); let selectedMaterial = $state(1); let activeTool = $state('brush'); @@ -35,10 +38,29 @@ const materials = DEFAULT_MATERIALS.filter((m) => m.id !== MATERIAL_AIR); - onMount(() => { - const e = new GameEngine(canvasContainer); + onMount(async () => { + // Initialize local-first database (creates tables, seeds guest data) + await gameStore.initialize(); + + // Check for world ID in URL, otherwise load first world + const params = new URLSearchParams(window.location.search); + const requestedWorldId = params.get('world'); + + let worldData = null; + if (requestedWorldId) { + worldData = await loadWorld(requestedWorldId); + } + if (!worldData) { + const worlds = await getAllWorlds(); + if (worlds.length > 0) { + worldData = await loadWorld(worlds[0].id); + } + } + + const e = new GameEngine(canvasContainer, worldData ?? undefined); e.inventory = inventory; engine = e; + loading = false; e.onStateChange = () => { isEditing = e.isEditing; @@ -94,7 +116,18 @@
-
+ + {#if loading} +
+
+
ManaVoxel
+
Loading world...
+
+
+ {/if} + + +
@@ -126,6 +159,12 @@ {/if}
+ + Worlds + {#if isEditing} + + + +
+ {#if loading} +
Loading...
+ {:else if worlds.length === 0} +
+
🌍
+
No worlds yet
+
Create your first world to start building!
+ +
+ {:else} +
+ {#each worlds as world} +
+
+

{world.name}

+ + {world.isPublished ? 'Published' : 'Draft'} + +
+
+ {world.template} | {world.playCount} plays +
+ {#if world.description} +

{world.description}

+ {/if} +
+ + +
+
+ {/each} +
+ {/if} +
+
+ + +{#if showNewDialog} +
{ + if (e.target === e.currentTarget) showNewDialog = false; + }} + role="dialog" + > +
+

New World

+ + +
+ + +
+ + +
+ +
+ {#each WORLD_TEMPLATES as template} + + {/each} +
+
+ + +
+ + +
+
+
+{/if}