mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-15 04:01:09 +02:00
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:
parent
d71eade816
commit
3925019344
7 changed files with 1075 additions and 15 deletions
212
apps/manavoxel/apps/web/src/lib/data/guest-seed.ts
Normal file
212
apps/manavoxel/apps/web/src/lib/data/guest-seed.ts
Normal 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;
|
||||
}
|
||||
120
apps/manavoxel/apps/web/src/lib/data/local-store.ts
Normal file
120
apps/manavoxel/apps/web/src/lib/data/local-store.ts
Normal 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');
|
||||
387
apps/manavoxel/apps/web/src/lib/data/templates.ts
Normal file
387
apps/manavoxel/apps/web/src/lib/data/templates.ts
Normal 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,
|
||||
},
|
||||
];
|
||||
112
apps/manavoxel/apps/web/src/lib/data/world-loader.ts
Normal file
112
apps/manavoxel/apps/web/src/lib/data/world-loader.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
175
apps/manavoxel/apps/web/src/routes/worlds/+page.svelte
Normal file
175
apps/manavoxel/apps/web/src/routes/worlds/+page.svelte
Normal 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}
|
||||
Loading…
Add table
Add a link
Reference in a new issue