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) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-29 14:07:46 +02:00
parent d71eade816
commit 3925019344
7 changed files with 1075 additions and 15 deletions

View file

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

View file

@ -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<string, unknown>;
}
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<LocalWorld>('worlds');
export const areaCollection = gameStore.collection<LocalArea>('areas');
export const itemCollection = gameStore.collection<LocalItem>('items');
export const inventoryCollection = gameStore.collection<LocalInventorySlot>('inventories');

View file

@ -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<LocalArea, 'id' | 'createdAt' | 'updatedAt'>; 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,
},
];

View file

@ -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<LocalWorld[]> {
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<LocalArea, 'id' | 'createdAt' | 'updatedAt'>; id: string }[]
): Promise<string> {
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<Material[]>(dbArea.palette, DEFAULT_MATERIALS),
entities: safeJsonParse<EntityDef[]>(dbArea.entities, []),
portals: safeJsonParse<PortalDef[]>(dbArea.portals, []),
spawnPoint: { x: dbArea.spawnX, y: dbArea.spawnY, floor: dbArea.spawnFloor },
};
}
function safeJsonParse<T>(json: string, fallback: T): T {
try {
return JSON.parse(json);
} catch {
return fallback;
}
}

View file

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

View file

@ -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<ToolType>('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 @@
<div class="relative h-screen w-screen overflow-hidden bg-gray-900">
<!-- PixiJS Canvas -->
<div bind:this={canvasContainer} class="game-canvas h-full w-full"></div>
<!-- Loading screen -->
{#if loading}
<div class="flex h-full w-full items-center justify-center">
<div class="text-center">
<div class="mb-3 text-2xl font-bold text-emerald-400">ManaVoxel</div>
<div class="text-sm text-gray-500">Loading world...</div>
</div>
</div>
{/if}
<!-- PixiJS Canvas -->
<div bind:this={canvasContainer} class="game-canvas h-full w-full" class:hidden={loading}></div>
<!-- HUD Overlay -->
<div class="pointer-events-none absolute inset-0">
@ -126,6 +159,12 @@
{/if}
</div>
<div class="flex gap-2">
<a
href="/worlds"
class="rounded-lg bg-gray-800/80 px-3 py-1.5 text-sm text-gray-400 backdrop-blur hover:bg-gray-700/80 hover:text-white"
>
Worlds
</a>
{#if isEditing}
<button
class="rounded-lg bg-blue-600/80 px-3 py-1.5 text-sm text-white backdrop-blur hover:bg-blue-500/80"

View file

@ -0,0 +1,175 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { gameStore } from '$lib/data/local-store';
import { getAllWorlds, createWorld, deleteWorld } from '$lib/data/world-loader';
import { WORLD_TEMPLATES } from '$lib/data/templates';
import type { LocalWorld } from '$lib/data/local-store';
let worlds = $state<LocalWorld[]>([]);
let showNewDialog = $state(false);
let newWorldName = $state('My World');
let selectedTemplate = $state('village');
let loading = $state(true);
onMount(async () => {
await gameStore.initialize();
worlds = await getAllWorlds();
loading = false;
});
async function handleCreate() {
const template = WORLD_TEMPLATES.find((t) => t.id === selectedTemplate);
if (!template) return;
const generated = template.generate();
const worldId = await createWorld(newWorldName, selectedTemplate, generated.areas);
showNewDialog = false;
goto(`/?world=${worldId}`);
}
async function handleDelete(worldId: string) {
await deleteWorld(worldId);
worlds = await getAllWorlds();
}
function handlePlay(worldId: string) {
goto(`/?world=${worldId}`);
}
</script>
<div class="flex min-h-screen flex-col bg-gray-950 text-white">
<!-- Header -->
<header class="flex items-center justify-between border-b border-gray-800 px-6 py-4">
<div class="flex items-center gap-3">
<h1 class="text-xl font-bold text-emerald-400">ManaVoxel</h1>
<span class="text-sm text-gray-500">My Worlds</span>
</div>
<button
class="rounded-lg bg-emerald-600 px-4 py-2 text-sm font-medium text-white hover:bg-emerald-500"
onclick={() => (showNewDialog = true)}
>
+ New World
</button>
</header>
<!-- Content -->
<main class="flex-1 p-6">
{#if loading}
<div class="py-20 text-center text-gray-500">Loading...</div>
{:else if worlds.length === 0}
<div class="py-20 text-center">
<div class="mb-4 text-4xl">🌍</div>
<div class="mb-2 text-lg text-gray-400">No worlds yet</div>
<div class="mb-6 text-sm text-gray-600">Create your first world to start building!</div>
<button
class="rounded-lg bg-emerald-600 px-6 py-2.5 text-sm font-medium text-white hover:bg-emerald-500"
onclick={() => (showNewDialog = true)}
>
Create World
</button>
</div>
{:else}
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{#each worlds as world}
<div
class="group rounded-xl border border-gray-800 bg-gray-900 p-4 transition hover:border-gray-700"
>
<div class="mb-3 flex items-center justify-between">
<h3 class="font-medium">{world.name}</h3>
<span
class="rounded-full px-2 py-0.5 text-[10px] {world.isPublished
? 'bg-emerald-900 text-emerald-400'
: 'bg-gray-800 text-gray-500'}"
>
{world.isPublished ? 'Published' : 'Draft'}
</span>
</div>
<div class="mb-3 text-xs text-gray-500">
{world.template} | {world.playCount} plays
</div>
{#if world.description}
<p class="mb-3 text-xs text-gray-600">{world.description}</p>
{/if}
<div class="flex gap-2">
<button
class="flex-1 rounded-lg bg-emerald-600 py-1.5 text-sm text-white hover:bg-emerald-500"
onclick={() => handlePlay(world.id)}
>
Play
</button>
<button
class="rounded-lg bg-gray-800 px-3 py-1.5 text-sm text-gray-400 hover:bg-gray-700 hover:text-red-400"
onclick={() => handleDelete(world.id)}
>
Del
</button>
</div>
</div>
{/each}
</div>
{/if}
</main>
</div>
<!-- New World Dialog -->
{#if showNewDialog}
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/70"
onclick={(e) => {
if (e.target === e.currentTarget) showNewDialog = false;
}}
role="dialog"
>
<div class="w-full max-w-lg rounded-xl bg-gray-900 p-6 shadow-2xl">
<h2 class="mb-4 text-lg font-bold text-white">New World</h2>
<!-- Name -->
<div class="mb-4">
<label class="mb-1 block text-sm text-gray-400">World Name</label>
<input
type="text"
bind:value={newWorldName}
class="w-full rounded-lg bg-gray-800 px-3 py-2 text-white outline-none focus:ring-1 focus:ring-emerald-500"
placeholder="My World"
/>
</div>
<!-- Template Selection -->
<div class="mb-6">
<label class="mb-2 block text-sm text-gray-400">Template</label>
<div class="grid grid-cols-2 gap-2 sm:grid-cols-3">
{#each WORLD_TEMPLATES as template}
<button
class="rounded-lg border-2 p-3 text-left transition {selectedTemplate === template.id
? 'border-emerald-500 bg-emerald-950/50'
: 'border-gray-700 bg-gray-800 hover:border-gray-600'}"
onclick={() => (selectedTemplate = template.id)}
>
<div class="mb-1 text-xl">{template.icon}</div>
<div class="text-sm font-medium text-white">{template.name}</div>
<div class="text-[10px] text-gray-500">{template.description}</div>
</button>
{/each}
</div>
</div>
<!-- Actions -->
<div class="flex justify-end gap-2">
<button
class="rounded-lg bg-gray-800 px-4 py-2 text-sm text-gray-400 hover:bg-gray-700"
onclick={() => (showNewDialog = false)}
>
Cancel
</button>
<button
class="rounded-lg bg-emerald-600 px-6 py-2 text-sm font-medium text-white hover:bg-emerald-500"
onclick={handleCreate}
>
Create
</button>
</div>
</div>
</div>
{/if}