feat: character/NPC management system with spritesheet support
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>
This commit is contained in:
219
editor.html
219
editor.html
@@ -63,6 +63,23 @@ body { background: var(--bg); color: var(--text); font-family: 'Segoe UI', syste
|
||||
#status-bar span { font-family: monospace; }
|
||||
#zoom-info { margin-left: auto; }
|
||||
|
||||
/* Characters section */
|
||||
#char-section { border-bottom: 1px solid var(--border); max-height: 200px; overflow-y: auto; }
|
||||
#char-section h3 { padding: 4px 16px; font-size: 11px; color: var(--text2); text-transform: uppercase; letter-spacing: 0.5px; display: flex; align-items: center; gap: 6px; }
|
||||
#char-section h3 button { background: var(--bg); border: 1px solid var(--border); color: var(--accent); border-radius: 3px; padding: 1px 6px; cursor: pointer; font-size: 10px; }
|
||||
#char-section h3 button:hover { background: var(--accent); color: #000; }
|
||||
.char-drop-zone { margin: 4px 12px 8px; padding: 12px; border: 2px dashed var(--border); border-radius: 6px; text-align: center; font-size: 11px; color: var(--text2); cursor: pointer; transition: all 0.2s; }
|
||||
.char-drop-zone.dragover { border-color: var(--accent); background: rgba(0,229,153,0.08); color: var(--accent); }
|
||||
.char-card { display: flex; align-items: center; gap: 8px; padding: 5px 12px; cursor: pointer; border-left: 3px solid transparent; font-size: 12px; }
|
||||
.char-card:hover { background: rgba(255,255,255,0.04); }
|
||||
.char-card.selected { background: rgba(0,229,153,0.1); border-left-color: var(--accent); }
|
||||
.char-card canvas { border: 1px solid var(--border); border-radius: 2px; image-rendering: pixelated; flex-shrink: 0; }
|
||||
.char-card .char-info { flex: 1; overflow: hidden; }
|
||||
.char-card .char-name { color: var(--text); font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.char-card .char-meta { font-size: 10px; color: var(--text2); }
|
||||
.char-card .char-delete { background: none; border: none; color: var(--red); cursor: pointer; font-size: 14px; opacity: 0.5; padding: 2px 4px; }
|
||||
.char-card .char-delete:hover { opacity: 1; }
|
||||
|
||||
/* Toast */
|
||||
#toast { position: fixed; top: 16px; right: 16px; padding: 10px 18px; background: var(--accent); color: #000; border-radius: 6px; font-weight: 600; font-size: 13px; opacity: 0; transition: opacity 0.3s; pointer-events: none; z-index: 999; }
|
||||
#toast.show { opacity: 1; }
|
||||
@@ -195,6 +212,14 @@ body { background: var(--bg); color: var(--text); font-family: 'Segoe UI', syste
|
||||
<button class="tool-btn" data-tool="select" title="Click entity to select/move">🔍 Select</button>
|
||||
<button class="tool-btn danger" data-tool="delete" title="Click entity to delete">✕ Delete</button>
|
||||
</div>
|
||||
<div id="char-section">
|
||||
<h3>Characters <button id="btn-add-char" title="Add character from file">+ Add</button></h3>
|
||||
<div id="char-list"></div>
|
||||
<div class="char-drop-zone" id="char-drop-zone">
|
||||
Drop spritesheet PNG here<br><span style="font-size:10px;color:#555;">3 cols (still, walk1, walk2) × 4 rows (↓↑←→)</span>
|
||||
</div>
|
||||
<input type="file" id="char-file-input" accept="image/png" style="display:none;">
|
||||
</div>
|
||||
<div id="entity-section">
|
||||
<h3>Spawn</h3>
|
||||
<div id="spawn-list"></div>
|
||||
@@ -320,6 +345,8 @@ const mapConfigs = {
|
||||
|
||||
let currentMapId = 'lab';
|
||||
let mapData = {}; // { [mapId]: { walls: Set, spawn, npcs, exits, interactions } }
|
||||
let characters = {}; // { [charId]: { id, name, spritesheet (base64), frameW, frameH, img (HTMLImageElement) } }
|
||||
let selectedCharId = null;
|
||||
let selectedTool = 'wall';
|
||||
let selectedEntity = null; // { type, index }
|
||||
let isPainting = false;
|
||||
@@ -391,10 +418,122 @@ function init() {
|
||||
document.getElementById('btn-load-server').addEventListener('click', loadFromServer);
|
||||
document.getElementById('btn-do-bilink').addEventListener('click', createBiLink);
|
||||
|
||||
initCharacterUI();
|
||||
resizeCanvas();
|
||||
updateEntityList();
|
||||
}
|
||||
|
||||
// ==================== Character management ====================
|
||||
|
||||
function addCharacterFromFile(file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const base64 = e.target.result;
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
// Auto-detect frame size: assume 3 cols × 4 rows
|
||||
const frameW = Math.floor(img.width / 3);
|
||||
const frameH = Math.floor(img.height / 4);
|
||||
const id = file.name.replace(/\.png$/i, '').replace(/[^a-zA-Z0-9_-]/g, '_').toLowerCase();
|
||||
const name = id.replace(/[_-]/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
||||
|
||||
characters[id] = { id, name, spritesheet: base64, frameW, frameH, img };
|
||||
updateCharList();
|
||||
selectCharacter(id);
|
||||
toast(`Character "${name}" added (${frameW}×${frameH}px frames)`);
|
||||
};
|
||||
img.src = base64;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
function removeCharacter(charId) {
|
||||
delete characters[charId];
|
||||
if (selectedCharId === charId) selectedCharId = null;
|
||||
// Remove charId from NPCs that reference it
|
||||
for (const md of Object.values(mapData)) {
|
||||
for (const npc of (md.npcs || [])) {
|
||||
if (npc.charId === charId) delete npc.charId;
|
||||
}
|
||||
}
|
||||
updateCharList();
|
||||
updateEntityList();
|
||||
render();
|
||||
}
|
||||
|
||||
function selectCharacter(charId) {
|
||||
selectedCharId = charId;
|
||||
updateCharList();
|
||||
}
|
||||
|
||||
function updateCharList() {
|
||||
const container = document.getElementById('char-list');
|
||||
const ids = Object.keys(characters);
|
||||
if (ids.length === 0) {
|
||||
container.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = ids.map(id => {
|
||||
const c = characters[id];
|
||||
const sel = id === selectedCharId ? ' selected' : '';
|
||||
return `<div class="char-card${sel}" data-char-id="${id}">
|
||||
<canvas class="char-preview" data-char-id="${id}" width="32" height="32"></canvas>
|
||||
<div class="char-info">
|
||||
<div class="char-name">${c.name}</div>
|
||||
<div class="char-meta">${c.frameW}×${c.frameH}px · ${id}</div>
|
||||
</div>
|
||||
<button class="char-delete" data-char-id="${id}" title="Delete">✕</button>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
// Draw sprite previews (down-still frame)
|
||||
container.querySelectorAll('.char-preview').forEach(cvs => {
|
||||
const c = characters[cvs.dataset.charId];
|
||||
if (!c || !c.img) return;
|
||||
const pctx = cvs.getContext('2d');
|
||||
pctx.imageSmoothingEnabled = false;
|
||||
pctx.clearRect(0, 0, 32, 32);
|
||||
// Draw the down-still frame (row 0, col 0)
|
||||
pctx.drawImage(c.img, 0, 0, c.frameW, c.frameH, 0, 0, 32, 32);
|
||||
});
|
||||
|
||||
// Wire click events
|
||||
container.querySelectorAll('.char-card').forEach(card => {
|
||||
card.addEventListener('click', (e) => {
|
||||
if (e.target.classList.contains('char-delete')) return;
|
||||
selectCharacter(card.dataset.charId);
|
||||
});
|
||||
});
|
||||
container.querySelectorAll('.char-delete').forEach(btn => {
|
||||
btn.addEventListener('click', () => removeCharacter(btn.dataset.charId));
|
||||
});
|
||||
}
|
||||
|
||||
// Wire drag & drop + file input
|
||||
function initCharacterUI() {
|
||||
const dropZone = document.getElementById('char-drop-zone');
|
||||
const fileInput = document.getElementById('char-file-input');
|
||||
const addBtn = document.getElementById('btn-add-char');
|
||||
|
||||
dropZone.addEventListener('dragover', (e) => { e.preventDefault(); dropZone.classList.add('dragover'); });
|
||||
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('dragover'));
|
||||
dropZone.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.remove('dragover');
|
||||
for (const file of e.dataTransfer.files) {
|
||||
if (file.type === 'image/png') addCharacterFromFile(file);
|
||||
}
|
||||
});
|
||||
dropZone.addEventListener('click', () => fileInput.click());
|
||||
addBtn.addEventListener('click', () => fileInput.click());
|
||||
fileInput.addEventListener('change', () => {
|
||||
for (const file of fileInput.files) {
|
||||
if (file.type === 'image/png') addCharacterFromFile(file);
|
||||
}
|
||||
fileInput.value = '';
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== Load game data ====================
|
||||
|
||||
function loadCurrentGameData() {
|
||||
@@ -596,12 +735,22 @@ function render() {
|
||||
|
||||
// NPCs
|
||||
md.npcs.forEach((npc, i) => {
|
||||
ctx.fillStyle = 'rgba(200, 50, 255, 0.35)';
|
||||
ctx.fillRect(npc.x * TILE_PX, npc.y * TILE_PX, TILE_PX, TILE_PX);
|
||||
const sx = npc.x * TILE_PX, sy = npc.y * TILE_PX;
|
||||
const char = npc.charId ? characters[npc.charId] : null;
|
||||
if (char && char.img) {
|
||||
// Draw spritesheet frame: down-still (row 0, col 0)
|
||||
const dirRow = { down: 0, up: 1, left: 2, right: 3 };
|
||||
const row = dirRow[npc.facing] ?? 0;
|
||||
ctx.imageSmoothingEnabled = false;
|
||||
ctx.drawImage(char.img, 0, row * char.frameH, char.frameW, char.frameH, sx, sy, TILE_PX, TILE_PX);
|
||||
} else {
|
||||
ctx.fillStyle = 'rgba(200, 50, 255, 0.35)';
|
||||
ctx.fillRect(sx, sy, TILE_PX, TILE_PX);
|
||||
drawLabel(ctx, '👤', npc.x, npc.y);
|
||||
}
|
||||
ctx.strokeStyle = selectedEntity?.type === 'npc' && selectedEntity.index === i ? '#fff' : '#cc55ff';
|
||||
ctx.lineWidth = selectedEntity?.type === 'npc' && selectedEntity.index === i ? 2.5 : 1.5;
|
||||
ctx.strokeRect(npc.x * TILE_PX, npc.y * TILE_PX, TILE_PX, TILE_PX);
|
||||
drawLabel(ctx, '👤', npc.x, npc.y);
|
||||
ctx.strokeRect(sx, sy, TILE_PX, TILE_PX);
|
||||
});
|
||||
|
||||
// Tile coordinates (when zoomed in enough)
|
||||
@@ -686,11 +835,14 @@ function onMouseDown(e) {
|
||||
}
|
||||
updateEntityList(); updateProps(); render();
|
||||
break;
|
||||
case 'npc':
|
||||
md.npcs.push({ id: `npc_${Date.now()}`, x: tile.x, y: tile.y, facing: 'down', dialog: ['Hello!'] });
|
||||
case 'npc': {
|
||||
const npcData = { id: `npc_${Date.now()}`, x: tile.x, y: tile.y, facing: 'down', dialog: ['Hello!'] };
|
||||
if (selectedCharId && characters[selectedCharId]) npcData.charId = selectedCharId;
|
||||
md.npcs.push(npcData);
|
||||
selectedEntity = { type: 'npc', index: md.npcs.length - 1 };
|
||||
updateEntityList(); updateProps(); render();
|
||||
break;
|
||||
}
|
||||
case 'exit': {
|
||||
// Default to a different map than the current one
|
||||
const otherMaps = Object.keys(mapConfigs).filter(id => id !== currentMapId);
|
||||
@@ -893,9 +1045,10 @@ function updateEntityList() {
|
||||
? makeEntityItem('spawn', 0, '🏠', `Spawn`, md.spawn.x, md.spawn.y)
|
||||
: '<div style="padding:4px 16px;color:var(--text2);font-size:11px;">None (use 🏠 tool to place)</div>';
|
||||
|
||||
document.getElementById('npc-list').innerHTML = md.npcs.map((n, i) =>
|
||||
makeEntityItem('npc', i, '👤', n.id, n.x, n.y, '#cc55ff')
|
||||
).join('') || '<div style="padding:4px 16px;color:var(--text2);font-size:11px;">None</div>';
|
||||
document.getElementById('npc-list').innerHTML = md.npcs.map((n, i) => {
|
||||
const charLabel = n.charId && characters[n.charId] ? ` [${characters[n.charId].name}]` : '';
|
||||
return makeEntityItem('npc', i, '👤', n.id + charLabel, n.x, n.y, '#cc55ff');
|
||||
}).join('') || '<div style="padding:4px 16px;color:var(--text2);font-size:11px;">None</div>';
|
||||
|
||||
document.getElementById('exit-list').innerHTML = md.exits.map((e, i) => {
|
||||
const hasCoords = e.targetX != null && e.targetY != null;
|
||||
@@ -965,6 +1118,9 @@ function updateProps() {
|
||||
if (t === 'npc') {
|
||||
html += propText('ID', 'id', ent.id);
|
||||
html += propSelect('Facing', 'facing', ent.facing, ['down','up','left','right']);
|
||||
// Character selector
|
||||
const charOpts = ['(none)', ...Object.keys(characters)];
|
||||
html += propSelect('Character', 'charId', ent.charId || '(none)', charOpts);
|
||||
html += propTextarea('Dialog', 'dialog', (ent.dialog || []).join('\n'));
|
||||
} else if (t === 'exit') {
|
||||
// Show map options with both editor ID and game ID
|
||||
@@ -1036,7 +1192,10 @@ function applyPropChange(prop, value, inputType) {
|
||||
const ent = getSelectedEntityData();
|
||||
if (!ent) return;
|
||||
|
||||
if (prop === 'dialog') {
|
||||
if (prop === 'charId') {
|
||||
if (value === '(none)') delete ent.charId;
|
||||
else ent.charId = value;
|
||||
} else if (prop === 'dialog') {
|
||||
ent.dialog = value.split('\n').filter(l => l.trim());
|
||||
} else if (prop === 'requiredOutputs') {
|
||||
ent.requiredOutputs = value.split(',').map(Number);
|
||||
@@ -1136,7 +1295,10 @@ function generateMapsJS() {
|
||||
// NPCs
|
||||
out += ` npcs: [\n`;
|
||||
for (const n of md.npcs) {
|
||||
out += ` { id: '${n.id}', x: ${n.x}, y: ${n.y}, facing: '${n.facing}', dialog: ${JSON.stringify(n.dialog)} },\n`;
|
||||
let npcLine = ` { id: '${n.id}', x: ${n.x}, y: ${n.y}, facing: '${n.facing}'`;
|
||||
if (n.charId) npcLine += `, charId: '${n.charId}'`;
|
||||
npcLine += `, dialog: ${JSON.stringify(n.dialog)} },\n`;
|
||||
out += npcLine;
|
||||
}
|
||||
out += ` ],\n\n`;
|
||||
|
||||
@@ -1256,14 +1418,28 @@ function toast(msg) {
|
||||
async function saveToServer() {
|
||||
const code = generateMapsJS();
|
||||
try {
|
||||
// Save maps.js
|
||||
const res = await fetch('/api/maps', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ content: code })
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
// Save characters
|
||||
const charData = {};
|
||||
for (const [id, c] of Object.entries(characters)) {
|
||||
charData[id] = { id: c.id, name: c.name, spritesheet: c.spritesheet, frameW: c.frameW, frameH: c.frameH };
|
||||
}
|
||||
await fetch('/api/characters', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ characters: charData })
|
||||
});
|
||||
|
||||
if (data.ok) {
|
||||
toast(`Saved maps.js (${data.bytes} bytes)`);
|
||||
const charCount = Object.keys(charData).length;
|
||||
toast(`Saved maps.js (${data.bytes}b) + ${charCount} character(s)`);
|
||||
} else {
|
||||
toast('Error: ' + (data.error || 'Unknown'));
|
||||
}
|
||||
@@ -1279,11 +1455,26 @@ async function loadFromServer() {
|
||||
if (!data.content) { toast('No maps.js found on server'); return; }
|
||||
|
||||
// Parse the maps.js to extract wall data and entities
|
||||
// We parse the JS source to extract the wall objects and map definitions
|
||||
const src = data.content;
|
||||
parseAndLoadMapsJS(src);
|
||||
|
||||
// Load characters
|
||||
try {
|
||||
const charRes = await fetch('/api/characters');
|
||||
const charData = await charRes.json();
|
||||
if (charData.characters) {
|
||||
characters = {};
|
||||
for (const [id, c] of Object.entries(charData.characters)) {
|
||||
const img = new Image();
|
||||
img.src = c.spritesheet;
|
||||
characters[id] = { id: c.id, name: c.name, spritesheet: c.spritesheet, frameW: c.frameW, frameH: c.frameH, img };
|
||||
}
|
||||
updateCharList();
|
||||
}
|
||||
} catch (ce) { console.warn('[editor] Character load failed:', ce); }
|
||||
|
||||
updateEntityList(); updateProps(); render();
|
||||
toast('Loaded maps.js from server');
|
||||
toast('Loaded maps.js + characters from server');
|
||||
} catch (e) {
|
||||
toast('Load failed: ' + e.message);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user