Add drag & drop spritesheet upload in editor, character registry in sprites.js, character selector for NPCs, sprite rendering on editor canvas, server API for character persistence, and game-side character loading via characterLoader.js. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
170 lines
6.3 KiB
JavaScript
170 lines
6.3 KiB
JavaScript
// Lightweight static file server + editor API for saving maps.js
|
|
// Used in production Docker container
|
|
|
|
const http = require('http');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
const PORT = process.env.PORT || 80;
|
|
const STATIC_DIR = path.join(__dirname, 'public');
|
|
const MAPS_FILE = path.join(STATIC_DIR, 'js', 'world', 'maps.js');
|
|
const CHARS_FILE = path.join(STATIC_DIR, 'data', 'characters.json');
|
|
|
|
const MIME = {
|
|
'.html': 'text/html',
|
|
'.css': 'text/css',
|
|
'.js': 'application/javascript',
|
|
'.json': 'application/json',
|
|
'.png': 'image/png',
|
|
'.jpg': 'image/jpeg',
|
|
'.svg': 'image/svg+xml',
|
|
'.ico': 'image/x-icon',
|
|
'.woff2': 'font/woff2',
|
|
};
|
|
|
|
const server = http.createServer((req, res) => {
|
|
// CORS headers for editor
|
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, OPTIONS');
|
|
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
|
|
|
if (req.method === 'OPTIONS') {
|
|
res.writeHead(204);
|
|
res.end();
|
|
return;
|
|
}
|
|
|
|
// === API: GET /api/maps — read maps.js source ===
|
|
if (req.method === 'GET' && req.url === '/api/maps') {
|
|
fs.readFile(MAPS_FILE, 'utf-8', (err, data) => {
|
|
if (err) {
|
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ error: 'Failed to read maps.js' }));
|
|
return;
|
|
}
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ content: data }));
|
|
});
|
|
return;
|
|
}
|
|
|
|
// === API: PUT /api/maps — write maps.js source ===
|
|
if (req.method === 'PUT' && req.url === '/api/maps') {
|
|
let body = '';
|
|
req.on('data', chunk => { body += chunk; });
|
|
req.on('end', () => {
|
|
try {
|
|
const { content } = JSON.parse(body);
|
|
if (!content || typeof content !== 'string') {
|
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ error: 'Missing content field' }));
|
|
return;
|
|
}
|
|
// Backup before overwrite
|
|
const backup = MAPS_FILE + '.bak';
|
|
if (fs.existsSync(MAPS_FILE)) {
|
|
fs.copyFileSync(MAPS_FILE, backup);
|
|
}
|
|
fs.writeFileSync(MAPS_FILE, content, 'utf-8');
|
|
console.log(`[server] maps.js saved (${content.length} bytes)`);
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ ok: true, bytes: content.length }));
|
|
} catch (e) {
|
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ error: e.message }));
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
|
|
// === API: GET /api/maps/json — parse current map data as JSON ===
|
|
if (req.method === 'GET' && req.url === '/api/maps/json') {
|
|
fs.readFile(MAPS_FILE, 'utf-8', (err, data) => {
|
|
if (err) {
|
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ error: 'Failed to read maps.js' }));
|
|
return;
|
|
}
|
|
// Extract JSON-serializable data from the JS source
|
|
// This is a best-effort parser for the generated maps.js format
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ source: data }));
|
|
});
|
|
return;
|
|
}
|
|
|
|
// === API: GET /api/characters — read character data ===
|
|
if (req.method === 'GET' && req.url === '/api/characters') {
|
|
fs.readFile(CHARS_FILE, 'utf-8', (err, data) => {
|
|
if (err) {
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ characters: {} }));
|
|
return;
|
|
}
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(data);
|
|
});
|
|
return;
|
|
}
|
|
|
|
// === API: PUT /api/characters — write character data ===
|
|
if (req.method === 'PUT' && req.url === '/api/characters') {
|
|
let body = '';
|
|
req.on('data', chunk => { body += chunk; });
|
|
req.on('end', () => {
|
|
try {
|
|
const parsed = JSON.parse(body);
|
|
// Ensure data dir exists
|
|
const dataDir = path.dirname(CHARS_FILE);
|
|
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
|
|
fs.writeFileSync(CHARS_FILE, JSON.stringify(parsed, null, 2), 'utf-8');
|
|
const count = Object.keys(parsed.characters || {}).length;
|
|
console.log(`[server] characters.json saved (${count} characters)`);
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ ok: true, count }));
|
|
} catch (e) {
|
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ error: e.message }));
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
|
|
// === Static file serving ===
|
|
let filePath = path.join(STATIC_DIR, req.url === '/' ? 'index.html' : req.url);
|
|
// Prevent directory traversal
|
|
if (!filePath.startsWith(STATIC_DIR)) {
|
|
res.writeHead(403);
|
|
res.end('Forbidden');
|
|
return;
|
|
}
|
|
|
|
const ext = path.extname(filePath).toLowerCase();
|
|
const contentType = MIME[ext] || 'application/octet-stream';
|
|
|
|
fs.readFile(filePath, (err, data) => {
|
|
if (err) {
|
|
if (err.code === 'ENOENT') {
|
|
res.writeHead(404);
|
|
res.end('Not found');
|
|
} else {
|
|
res.writeHead(500);
|
|
res.end('Server error');
|
|
}
|
|
return;
|
|
}
|
|
// Cache static assets
|
|
if (ext === '.png' || ext === '.jpg' || ext === '.woff2') {
|
|
res.setHeader('Cache-Control', 'public, max-age=86400');
|
|
}
|
|
res.writeHead(200, { 'Content-Type': contentType });
|
|
res.end(data);
|
|
});
|
|
});
|
|
|
|
server.listen(PORT, () => {
|
|
console.log(`[server] Logic Gates running on port ${PORT}`);
|
|
console.log(`[server] Static: ${STATIC_DIR}`);
|
|
console.log(`[server] Maps file: ${MAPS_FILE}`);
|
|
});
|