Phase 3: Express backend, SQLite persistence, auto-save
- server/db.ts: sql.js with migration system (hex_maps, hexes, hex_features) - server/routes/maps.ts: CRUD for hex maps - server/routes/hexes.ts: Bulk hex upsert, region load, sparse storage - server/index.ts: Express 5, CORS, tile serving, SPA fallback - src/data/api-client.ts: Frontend HTTP client for all API endpoints - src/main.ts: Auto-save with 1s debounce, load map state on startup - Port 3002 (Kiepenkerl uses 3001) - Graceful fallback when API unavailable (works without server too) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -5,7 +5,7 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "concurrently \"vite\" \"tsx watch server/index.ts\"",
|
"dev": "concurrently \"vite --host 127.0.0.1 --port 5174\" \"tsx watch server/index.ts\"",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
|
|||||||
113
server/db.ts
Normal file
113
server/db.ts
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import initSqlJs, { type Database } from 'sql.js';
|
||||||
|
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
|
||||||
|
import { resolve, dirname } from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const DB_PATH = process.env.DB_PATH || resolve(__dirname, '..', 'data', 'hexifyer.db');
|
||||||
|
|
||||||
|
let db: Database;
|
||||||
|
|
||||||
|
interface Migration {
|
||||||
|
name: string;
|
||||||
|
up: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MIGRATIONS: Migration[] = [
|
||||||
|
{
|
||||||
|
name: '001-initial-schema',
|
||||||
|
up() {
|
||||||
|
db.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS hex_maps (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
image_width INTEGER NOT NULL DEFAULT 8000,
|
||||||
|
image_height INTEGER NOT NULL DEFAULT 12000,
|
||||||
|
tile_url TEXT NOT NULL DEFAULT '/tiles/{z}/{x}/{y}.jpg',
|
||||||
|
min_zoom INTEGER NOT NULL DEFAULT 0,
|
||||||
|
max_zoom INTEGER NOT NULL DEFAULT 6,
|
||||||
|
hex_size REAL NOT NULL DEFAULT 48.0,
|
||||||
|
origin_x REAL NOT NULL DEFAULT 0.0,
|
||||||
|
origin_y REAL NOT NULL DEFAULT 0.0,
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT DEFAULT (datetime('now'))
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
db.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS hexes (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
map_id INTEGER NOT NULL,
|
||||||
|
q INTEGER NOT NULL,
|
||||||
|
r INTEGER NOT NULL,
|
||||||
|
base_terrain TEXT NOT NULL DEFAULT 'plains',
|
||||||
|
updated_at TEXT DEFAULT (datetime('now')),
|
||||||
|
FOREIGN KEY (map_id) REFERENCES hex_maps(id) ON DELETE CASCADE,
|
||||||
|
UNIQUE(map_id, q, r)
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
db.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS hex_features (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
hex_id INTEGER NOT NULL,
|
||||||
|
terrain_id TEXT NOT NULL,
|
||||||
|
edge_mask INTEGER NOT NULL,
|
||||||
|
FOREIGN KEY (hex_id) REFERENCES hexes(id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
db.run('CREATE INDEX IF NOT EXISTS idx_hexes_map_coord ON hexes(map_id, q, r)');
|
||||||
|
db.run('CREATE INDEX IF NOT EXISTS idx_hex_features_hex ON hex_features(hex_id)');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function runMigrations(): void {
|
||||||
|
db.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||||
|
name TEXT PRIMARY KEY,
|
||||||
|
applied_at TEXT DEFAULT (datetime('now'))
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
const applied = new Set<string>();
|
||||||
|
const rows = db.exec('SELECT name FROM schema_migrations');
|
||||||
|
if (rows.length > 0) {
|
||||||
|
for (const row of rows[0].values) applied.add(row[0] as string);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const m of MIGRATIONS) {
|
||||||
|
if (applied.has(m.name)) continue;
|
||||||
|
console.log(`[db] Running migration: ${m.name}`);
|
||||||
|
m.up();
|
||||||
|
db.run('INSERT INTO schema_migrations (name) VALUES (?)', [m.name]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function initDb(): Promise<Database> {
|
||||||
|
const SQL = await initSqlJs();
|
||||||
|
|
||||||
|
if (existsSync(DB_PATH)) {
|
||||||
|
const buf = readFileSync(DB_PATH);
|
||||||
|
db = new SQL.Database(buf);
|
||||||
|
console.log(`[db] Loaded existing database from ${DB_PATH}`);
|
||||||
|
} else {
|
||||||
|
db = new SQL.Database();
|
||||||
|
console.log('[db] Created new database');
|
||||||
|
}
|
||||||
|
|
||||||
|
runMigrations();
|
||||||
|
saveDb();
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveDb(): void {
|
||||||
|
mkdirSync(dirname(DB_PATH), { recursive: true });
|
||||||
|
const data = db.export();
|
||||||
|
writeFileSync(DB_PATH, Buffer.from(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDb(): Database {
|
||||||
|
return db;
|
||||||
|
}
|
||||||
52
server/index.ts
Normal file
52
server/index.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import cors from 'cors';
|
||||||
|
import { resolve, dirname } from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import { existsSync } from 'fs';
|
||||||
|
import { initDb } from './db.js';
|
||||||
|
import mapsRouter from './routes/maps.js';
|
||||||
|
import hexesRouter from './routes/hexes.js';
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const PORT = Number(process.env.PORT) || 3002;
|
||||||
|
const DIST_DIR = resolve(__dirname, '..', 'dist');
|
||||||
|
const TILES_DIR = resolve(__dirname, '..', 'tiles');
|
||||||
|
const PUBLIC_TILES_DIR = resolve(__dirname, '..', 'public', 'tiles');
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
await initDb();
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
app.use(cors());
|
||||||
|
app.use(express.json({ limit: '10mb' }));
|
||||||
|
|
||||||
|
// API routes
|
||||||
|
app.use('/api/v1/maps', mapsRouter);
|
||||||
|
app.use('/api/v1/maps', hexesRouter);
|
||||||
|
|
||||||
|
// Serve tiles (check multiple locations)
|
||||||
|
const tilesPath = existsSync(TILES_DIR) ? TILES_DIR : PUBLIC_TILES_DIR;
|
||||||
|
if (existsSync(tilesPath)) {
|
||||||
|
app.use('/tiles', express.static(tilesPath, { maxAge: '30d', immutable: true }));
|
||||||
|
console.log(`[server] Serving tiles from ${tilesPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serve static frontend
|
||||||
|
if (existsSync(DIST_DIR)) {
|
||||||
|
app.use(express.static(DIST_DIR));
|
||||||
|
// SPA fallback (Express 5 syntax)
|
||||||
|
app.get('/{*splat}', (_req, res) => {
|
||||||
|
res.sendFile(resolve(DIST_DIR, 'index.html'));
|
||||||
|
});
|
||||||
|
console.log(`[server] Serving frontend from ${DIST_DIR}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
app.listen(PORT, () => {
|
||||||
|
console.log(`[server] Hexifyer running on http://localhost:${PORT}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(err => {
|
||||||
|
console.error('Failed to start:', err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
141
server/routes/hexes.ts
Normal file
141
server/routes/hexes.ts
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { getDb, saveDb } from '../db.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// Get all hexes for a map (bulk load)
|
||||||
|
router.get('/:mapId/hexes', (req, res) => {
|
||||||
|
const db = getDb();
|
||||||
|
const mapId = req.params.mapId;
|
||||||
|
|
||||||
|
const hexRows = db.exec(
|
||||||
|
'SELECT id, q, r, base_terrain FROM hexes WHERE map_id = ?',
|
||||||
|
[mapId],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hexRows.length === 0) {
|
||||||
|
res.json([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hexIds = hexRows[0].values.map(row => row[0] as number);
|
||||||
|
const hexes: any[] = [];
|
||||||
|
|
||||||
|
// Fetch features for all hexes
|
||||||
|
const featureMap = new Map<number, any[]>();
|
||||||
|
if (hexIds.length > 0) {
|
||||||
|
// Batch query — SQLite doesn't have great IN with params for large sets,
|
||||||
|
// so we query all features for this map's hexes
|
||||||
|
const featureRows = db.exec(
|
||||||
|
`SELECT hf.hex_id, hf.terrain_id, hf.edge_mask
|
||||||
|
FROM hex_features hf
|
||||||
|
JOIN hexes h ON hf.hex_id = h.id
|
||||||
|
WHERE h.map_id = ?`,
|
||||||
|
[mapId],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (featureRows.length > 0) {
|
||||||
|
for (const row of featureRows[0].values) {
|
||||||
|
const hexId = row[0] as number;
|
||||||
|
if (!featureMap.has(hexId)) featureMap.set(hexId, []);
|
||||||
|
featureMap.get(hexId)!.push({
|
||||||
|
terrainId: row[1],
|
||||||
|
edgeMask: row[2],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const row of hexRows[0].values) {
|
||||||
|
const hexId = row[0] as number;
|
||||||
|
hexes.push({
|
||||||
|
q: row[1],
|
||||||
|
r: row[2],
|
||||||
|
base: row[3],
|
||||||
|
features: featureMap.get(hexId) ?? [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(hexes);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Bulk upsert hexes
|
||||||
|
router.put('/:mapId/hexes', (req, res) => {
|
||||||
|
const db = getDb();
|
||||||
|
const mapId = req.params.mapId;
|
||||||
|
const hexes: Array<{
|
||||||
|
q: number;
|
||||||
|
r: number;
|
||||||
|
base: string;
|
||||||
|
features: Array<{ terrainId: string; edgeMask: number }>;
|
||||||
|
}> = req.body;
|
||||||
|
|
||||||
|
if (!Array.isArray(hexes)) {
|
||||||
|
res.status(400).json({ error: 'Expected array of hex updates' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
db.run('BEGIN TRANSACTION');
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const hex of hexes) {
|
||||||
|
// Upsert the hex
|
||||||
|
db.run(
|
||||||
|
`INSERT INTO hexes (map_id, q, r, base_terrain, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, datetime('now'))
|
||||||
|
ON CONFLICT(map_id, q, r)
|
||||||
|
DO UPDATE SET base_terrain = excluded.base_terrain, updated_at = datetime('now')`,
|
||||||
|
[mapId, hex.q, hex.r, hex.base],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get the hex id
|
||||||
|
const idRows = db.exec(
|
||||||
|
'SELECT id FROM hexes WHERE map_id = ? AND q = ? AND r = ?',
|
||||||
|
[mapId, hex.q, hex.r],
|
||||||
|
);
|
||||||
|
const hexId = idRows[0].values[0][0] as number;
|
||||||
|
|
||||||
|
// Replace features
|
||||||
|
db.run('DELETE FROM hex_features WHERE hex_id = ?', [hexId]);
|
||||||
|
for (const feature of hex.features) {
|
||||||
|
if (feature.edgeMask === 0) continue;
|
||||||
|
db.run(
|
||||||
|
'INSERT INTO hex_features (hex_id, terrain_id, edge_mask) VALUES (?, ?, ?)',
|
||||||
|
[hexId, feature.terrainId, feature.edgeMask],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update map timestamp
|
||||||
|
db.run("UPDATE hex_maps SET updated_at = datetime('now') WHERE id = ?", [mapId]);
|
||||||
|
|
||||||
|
db.run('COMMIT');
|
||||||
|
saveDb();
|
||||||
|
res.json({ ok: true, count: hexes.length });
|
||||||
|
} catch (err) {
|
||||||
|
db.run('ROLLBACK');
|
||||||
|
res.status(500).json({ error: String(err) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete a hex (reset to default)
|
||||||
|
router.delete('/:mapId/hexes/:q/:r', (req, res) => {
|
||||||
|
const db = getDb();
|
||||||
|
const { mapId, q, r } = req.params;
|
||||||
|
|
||||||
|
const idRows = db.exec(
|
||||||
|
'SELECT id FROM hexes WHERE map_id = ? AND q = ? AND r = ?',
|
||||||
|
[mapId, q, r],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (idRows.length > 0 && idRows[0].values.length > 0) {
|
||||||
|
const hexId = idRows[0].values[0][0];
|
||||||
|
db.run('DELETE FROM hex_features WHERE hex_id = ?', [hexId]);
|
||||||
|
db.run('DELETE FROM hexes WHERE id = ?', [hexId]);
|
||||||
|
saveDb();
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
85
server/routes/maps.ts
Normal file
85
server/routes/maps.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { getDb, saveDb } from '../db.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// List all maps
|
||||||
|
router.get('/', (_req, res) => {
|
||||||
|
const db = getDb();
|
||||||
|
const rows = db.exec('SELECT * FROM hex_maps ORDER BY updated_at DESC');
|
||||||
|
if (rows.length === 0) {
|
||||||
|
res.json([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const maps = rows[0].values.map(row => rowToMap(rows[0].columns, row));
|
||||||
|
res.json(maps);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get single map
|
||||||
|
router.get('/:id', (req, res) => {
|
||||||
|
const db = getDb();
|
||||||
|
const rows = db.exec('SELECT * FROM hex_maps WHERE id = ?', [req.params.id]);
|
||||||
|
if (rows.length === 0 || rows[0].values.length === 0) {
|
||||||
|
res.status(404).json({ error: 'Map not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.json(rowToMap(rows[0].columns, rows[0].values[0]));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create map
|
||||||
|
router.post('/', (req, res) => {
|
||||||
|
const db = getDb();
|
||||||
|
const { name, image_width, image_height, tile_url, min_zoom, max_zoom, hex_size, origin_x, origin_y } = req.body;
|
||||||
|
db.run(
|
||||||
|
`INSERT INTO hex_maps (name, image_width, image_height, tile_url, min_zoom, max_zoom, hex_size, origin_x, origin_y)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[name ?? 'Untitled', image_width ?? 8000, image_height ?? 12000,
|
||||||
|
tile_url ?? '/tiles/{z}/{x}/{y}.jpg', min_zoom ?? 0, max_zoom ?? 6,
|
||||||
|
hex_size ?? 48, origin_x ?? 0, origin_y ?? 0],
|
||||||
|
);
|
||||||
|
const idRows = db.exec('SELECT last_insert_rowid()');
|
||||||
|
const id = idRows[0].values[0][0];
|
||||||
|
saveDb();
|
||||||
|
res.status(201).json({ id });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update map
|
||||||
|
router.put('/:id', (req, res) => {
|
||||||
|
const db = getDb();
|
||||||
|
const { name, hex_size, origin_x, origin_y } = req.body;
|
||||||
|
const sets: string[] = [];
|
||||||
|
const params: any[] = [];
|
||||||
|
|
||||||
|
if (name !== undefined) { sets.push('name = ?'); params.push(name); }
|
||||||
|
if (hex_size !== undefined) { sets.push('hex_size = ?'); params.push(hex_size); }
|
||||||
|
if (origin_x !== undefined) { sets.push('origin_x = ?'); params.push(origin_x); }
|
||||||
|
if (origin_y !== undefined) { sets.push('origin_y = ?'); params.push(origin_y); }
|
||||||
|
|
||||||
|
if (sets.length === 0) {
|
||||||
|
res.status(400).json({ error: 'No fields to update' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
sets.push("updated_at = datetime('now')");
|
||||||
|
params.push(req.params.id);
|
||||||
|
|
||||||
|
db.run(`UPDATE hex_maps SET ${sets.join(', ')} WHERE id = ?`, params);
|
||||||
|
saveDb();
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete map
|
||||||
|
router.delete('/:id', (req, res) => {
|
||||||
|
const db = getDb();
|
||||||
|
db.run('DELETE FROM hex_maps WHERE id = ?', [req.params.id]);
|
||||||
|
saveDb();
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
function rowToMap(columns: string[], values: any[]) {
|
||||||
|
const obj: any = {};
|
||||||
|
columns.forEach((col, i) => { obj[col] = values[i]; });
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default router;
|
||||||
68
src/data/api-client.ts
Normal file
68
src/data/api-client.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
export interface MapInfo {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
image_width: number;
|
||||||
|
image_height: number;
|
||||||
|
tile_url: string;
|
||||||
|
min_zoom: number;
|
||||||
|
max_zoom: number;
|
||||||
|
hex_size: number;
|
||||||
|
origin_x: number;
|
||||||
|
origin_y: number;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HexData {
|
||||||
|
q: number;
|
||||||
|
r: number;
|
||||||
|
base: string;
|
||||||
|
features: Array<{ terrainId: string; edgeMask: number }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BASE = '/api/v1';
|
||||||
|
|
||||||
|
async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
||||||
|
const res = await fetch(`${BASE}${path}`, {
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.text();
|
||||||
|
throw new Error(`API ${res.status}: ${body}`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listMaps(): Promise<MapInfo[]> {
|
||||||
|
return request('/maps');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMap(id: number): Promise<MapInfo> {
|
||||||
|
return request(`/maps/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createMap(data: Partial<MapInfo>): Promise<{ id: number }> {
|
||||||
|
return request('/maps', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateMap(id: number, data: Partial<MapInfo>): Promise<void> {
|
||||||
|
await request(`/maps/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadHexes(mapId: number): Promise<HexData[]> {
|
||||||
|
return request(`/maps/${mapId}/hexes`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveHexes(mapId: number, hexes: HexData[]): Promise<void> {
|
||||||
|
await request(`/maps/${mapId}/hexes`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(hexes),
|
||||||
|
});
|
||||||
|
}
|
||||||
64
src/main.ts
64
src/main.ts
@@ -15,6 +15,7 @@ import {
|
|||||||
enforceEdgeConstraints,
|
enforceEdgeConstraints,
|
||||||
applyConstraintActions,
|
applyConstraintActions,
|
||||||
} from '../core/edge-connectivity.js';
|
} from '../core/edge-connectivity.js';
|
||||||
|
import * as api from './data/api-client.js';
|
||||||
|
|
||||||
// --- State ---
|
// --- State ---
|
||||||
const hexMap = new HexMap();
|
const hexMap = new HexMap();
|
||||||
@@ -23,6 +24,8 @@ let selectedTerrain: TerrainType | null = null;
|
|||||||
let selectedHex: AxialCoord | null = null;
|
let selectedHex: AxialCoord | null = null;
|
||||||
let hexSize = 48;
|
let hexSize = 48;
|
||||||
const origin = { x: 0, y: 0 };
|
const origin = { x: 0, y: 0 };
|
||||||
|
let currentMapId: number | null = null;
|
||||||
|
let saveTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
// --- Init Map ---
|
// --- Init Map ---
|
||||||
const map = initMap('map');
|
const map = initMap('map');
|
||||||
@@ -41,7 +44,7 @@ hexLayer.addTo(map);
|
|||||||
const sidebarEl = document.getElementById('sidebar')!;
|
const sidebarEl = document.getElementById('sidebar')!;
|
||||||
const { toolbar, terrainPicker, hexInspector, settings } = createSidebar(sidebarEl);
|
const { toolbar, terrainPicker, hexInspector, settings } = createSidebar(sidebarEl);
|
||||||
|
|
||||||
const toolbarUI = createToolbar(toolbar, (mode) => {
|
createToolbar(toolbar, (mode) => {
|
||||||
currentMode = mode;
|
currentMode = mode;
|
||||||
terrainPickerUI.setMode(mode);
|
terrainPickerUI.setMode(mode);
|
||||||
});
|
});
|
||||||
@@ -76,6 +79,28 @@ function rebuildHexLayer(showGrid: boolean, opacity: number) {
|
|||||||
reattachInteraction();
|
reattachInteraction();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Auto-save (debounced) ---
|
||||||
|
function scheduleSave() {
|
||||||
|
if (!currentMapId) return;
|
||||||
|
if (saveTimeout) clearTimeout(saveTimeout);
|
||||||
|
saveTimeout = setTimeout(async () => {
|
||||||
|
if (!currentMapId || !hexMap.dirty) return;
|
||||||
|
try {
|
||||||
|
const data = hexMap.serialize();
|
||||||
|
await api.saveHexes(currentMapId, data.hexes.map(h => ({
|
||||||
|
q: h.q,
|
||||||
|
r: h.r,
|
||||||
|
base: h.base,
|
||||||
|
features: h.features.map(f => ({ terrainId: f.terrainId, edgeMask: f.edgeMask })),
|
||||||
|
})));
|
||||||
|
hexMap.markClean();
|
||||||
|
console.log(`[save] Saved ${data.hexes.length} hexes`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[save] Failed:', err);
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
// --- Interaction handlers ---
|
// --- Interaction handlers ---
|
||||||
|
|
||||||
function handleSelect(event: HexClickEvent) {
|
function handleSelect(event: HexClickEvent) {
|
||||||
@@ -90,6 +115,7 @@ function handlePaint(event: HexClickEvent) {
|
|||||||
hexLayer.redraw();
|
hexLayer.redraw();
|
||||||
selectedHex = event.coord;
|
selectedHex = event.coord;
|
||||||
hexInspectorUI.update(selectedHex, hexMap.getTerrain(selectedHex));
|
hexInspectorUI.update(selectedHex, hexMap.getTerrain(selectedHex));
|
||||||
|
scheduleSave();
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleFeature(event: HexClickEvent) {
|
function handleFeature(event: HexClickEvent) {
|
||||||
@@ -112,6 +138,7 @@ function handleFeature(event: HexClickEvent) {
|
|||||||
hexLayer.setSelectedHex(selectedHex);
|
hexLayer.setSelectedHex(selectedHex);
|
||||||
hexLayer.redraw();
|
hexLayer.redraw();
|
||||||
hexInspectorUI.update(selectedHex, hexMap.getTerrain(selectedHex));
|
hexInspectorUI.update(selectedHex, hexMap.getTerrain(selectedHex));
|
||||||
|
scheduleSave();
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Hex Interaction ---
|
// --- Hex Interaction ---
|
||||||
@@ -121,13 +148,11 @@ function reattachInteraction() {
|
|||||||
detachInteraction?.();
|
detachInteraction?.();
|
||||||
detachInteraction = attachHexInteraction(
|
detachInteraction = attachHexInteraction(
|
||||||
map, hexSize, origin,
|
map, hexSize, origin,
|
||||||
// Click handler
|
|
||||||
(event) => {
|
(event) => {
|
||||||
if (currentMode === 'select') handleSelect(event);
|
if (currentMode === 'select') handleSelect(event);
|
||||||
else if (currentMode === 'paint') handlePaint(event);
|
else if (currentMode === 'paint') handlePaint(event);
|
||||||
else if (currentMode === 'feature') handleFeature(event);
|
else if (currentMode === 'feature') handleFeature(event);
|
||||||
},
|
},
|
||||||
// Drag-paint handler (only active in paint mode)
|
|
||||||
(event) => {
|
(event) => {
|
||||||
if (currentMode === 'paint') handlePaint(event);
|
if (currentMode === 'paint') handlePaint(event);
|
||||||
},
|
},
|
||||||
@@ -135,3 +160,36 @@ function reattachInteraction() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
reattachInteraction();
|
reattachInteraction();
|
||||||
|
|
||||||
|
// --- Load or create map from API ---
|
||||||
|
async function init() {
|
||||||
|
try {
|
||||||
|
const maps = await api.listMaps();
|
||||||
|
if (maps.length > 0) {
|
||||||
|
currentMapId = maps[0].id;
|
||||||
|
console.log(`[init] Loading map "${maps[0].name}" (id: ${currentMapId})`);
|
||||||
|
} else {
|
||||||
|
const result = await api.createMap({ name: 'Default Map' });
|
||||||
|
currentMapId = result.id;
|
||||||
|
console.log(`[init] Created new map (id: ${currentMapId})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load existing hexes
|
||||||
|
const hexes = await api.loadHexes(currentMapId);
|
||||||
|
if (hexes.length > 0) {
|
||||||
|
for (const hex of hexes) {
|
||||||
|
hexMap.setTerrain({ q: hex.q, r: hex.r }, {
|
||||||
|
base: hex.base,
|
||||||
|
features: hex.features,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
hexMap.markClean();
|
||||||
|
hexLayer.redraw();
|
||||||
|
console.log(`[init] Loaded ${hexes.length} hexes`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[init] API not available, running without persistence:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
init();
|
||||||
|
|||||||
@@ -16,11 +16,11 @@ export default defineConfig({
|
|||||||
allowedHosts: ['hexifyer.davoryn.de'],
|
allowedHosts: ['hexifyer.davoryn.de'],
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://localhost:3001',
|
target: 'http://localhost:3002',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
},
|
},
|
||||||
'/tiles': {
|
'/tiles': {
|
||||||
target: 'http://localhost:3001',
|
target: 'http://localhost:3002',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user