fix: make spawn optional — only required for initial map

Spawn can now be deleted in the editor (click same tile with Spawn tool,
use Delete tool, or press Delete key). Interior maps no longer have
spawn objects. The editor shows "None" when no spawn is set, and the
generated maps.js omits the spawn field for maps without one.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jose Luis
2026-03-20 17:20:19 +01:00
parent f740d96fc0
commit f8aa4e2eab
2 changed files with 44 additions and 25 deletions

View File

@@ -224,7 +224,7 @@ function init() {
// Init map data for all maps
for (const id of Object.keys(mapConfigs)) {
mapData[id] = { walls: new Set(), spawn: { x: 0, y: 0 }, npcs: [], exits: [], interactions: [] };
mapData[id] = { walls: new Set(), spawn: null, npcs: [], exits: [], interactions: [] };
}
// Load current game data
@@ -283,7 +283,7 @@ function loadCurrentGameData() {
11: [0,1,2,3,6,7,8,9]
};
mapData.lab.walls = wallDataToSet(labWalls);
mapData.lab.spawn = { x: 4, y: 10 };
mapData.lab.spawn = null; // No spawn — player enters via door from town
mapData.lab.npcs = [
{ id: 'professor', x: 5, y: 1, facing: 'down', dialog: ['Welcome to the Circuit Lab!','I\'m the Professor. We study logic gates here.','Use the workshop tables to design circuits.','Press TAB to open the Workshop anytime!'] }
];
@@ -345,12 +345,14 @@ function wallDataToSet(data) {
function switchMap(id) {
currentMapId = id;
selectedEntity = null;
// Center camera on spawn
// Center camera on spawn or map center
const md = mapData[id];
const cfg = mapConfigs[id];
if (md && cfg) {
camera.x = -(md.spawn.x * TILE_PX - canvas.width / 2 + TILE_PX / 2);
camera.y = -(md.spawn.y * TILE_PX - canvas.height / 2 + TILE_PX / 2);
const cx = md.spawn ? md.spawn.x : Math.floor(cfg.widthTiles / 2);
const cy = md.spawn ? md.spawn.y : Math.floor(cfg.heightTiles / 2);
camera.x = -(cx * TILE_PX - canvas.width / 2 + TILE_PX / 2);
camera.y = -(cy * TILE_PX - canvas.height / 2 + TILE_PX / 2);
}
updateEntityList();
updateProps();
@@ -419,14 +421,17 @@ function render() {
ctx.stroke();
}
// Spawn
const sp = md.spawn;
ctx.fillStyle = 'rgba(0, 229, 153, 0.4)';
ctx.fillRect(sp.x * TILE_PX, sp.y * TILE_PX, TILE_PX, TILE_PX);
ctx.strokeStyle = '#00e599';
ctx.lineWidth = 2;
ctx.strokeRect(sp.x * TILE_PX, sp.y * TILE_PX, TILE_PX, TILE_PX);
drawLabel(ctx, '🏠', sp.x, sp.y);
// Spawn (optional — only needed on the starting map)
if (md.spawn) {
const sp = md.spawn;
const spSel = selectedEntity?.type === 'spawn';
ctx.fillStyle = 'rgba(0, 229, 153, 0.4)';
ctx.fillRect(sp.x * TILE_PX, sp.y * TILE_PX, TILE_PX, TILE_PX);
ctx.strokeStyle = spSel ? '#fff' : '#00e599';
ctx.lineWidth = spSel ? 2.5 : 2;
ctx.strokeRect(sp.x * TILE_PX, sp.y * TILE_PX, TILE_PX, TILE_PX);
drawLabel(ctx, '🏠', sp.x, sp.y);
}
// Exits
md.exits.forEach((e, i) => {
@@ -548,8 +553,15 @@ function onMouseDown(e) {
render();
break;
case 'spawn':
md.spawn = { x: tile.x, y: tile.y };
updateEntityList(); render();
// Toggle: if spawn already at this tile, remove it; otherwise place/move
if (md.spawn && md.spawn.x === tile.x && md.spawn.y === tile.y) {
md.spawn = null;
selectedEntity = null;
} else {
md.spawn = { x: tile.x, y: tile.y };
selectedEntity = { type: 'spawn', index: 0 };
}
updateEntityList(); updateProps(); render();
break;
case 'npc':
md.npcs.push({ id: `npc_${Date.now()}`, x: tile.x, y: tile.y, facing: 'down', dialog: ['Hello!'] });
@@ -597,7 +609,7 @@ function onMouseMove(e) {
const key = `${tile.x},${tile.y}`;
let info = '';
if (md.walls.has(key)) info += 'Wall ';
if (md.spawn.x === tile.x && md.spawn.y === tile.y) info += 'Spawn ';
if (md.spawn && md.spawn.x === tile.x && md.spawn.y === tile.y) info += 'Spawn ';
md.npcs.forEach(n => { if (n.x === tile.x && n.y === tile.y) info += `NPC:${n.id} `; });
md.exits.forEach(e => { if (e.x === tile.x && e.y === tile.y) info += `Exit→${e.targetMap}(${e.targetX ?? '?'},${e.targetY ?? '?'}) `; });
md.interactions.forEach(i => { if (i.x === tile.x && i.y === tile.y) info += `${i.type}:${i.label} `; });
@@ -655,7 +667,8 @@ function onKeyDown(e) {
e.preventDefault();
const md = getData();
const { type, index } = selectedEntity;
if (type === 'npc') md.npcs.splice(index, 1);
if (type === 'spawn') md.spawn = null;
else if (type === 'npc') md.npcs.splice(index, 1);
else if (type === 'exit') md.exits.splice(index, 1);
else if (type === 'interaction') md.interactions.splice(index, 1);
selectedEntity = null;
@@ -731,13 +744,15 @@ function selectEntityAt(tx, ty) {
idx = md.interactions.findIndex(i => i.x === tx && i.y === ty);
if (idx >= 0) { selectedEntity = { type: 'interaction', index: idx }; updateEntityList(); updateProps(); return; }
// Spawn?
if (md.spawn.x === tx && md.spawn.y === ty) { selectedEntity = { type: 'spawn', index: 0 }; updateEntityList(); updateProps(); return; }
if (md.spawn && md.spawn.x === tx && md.spawn.y === ty) { selectedEntity = { type: 'spawn', index: 0 }; updateEntityList(); updateProps(); return; }
selectedEntity = null;
updateEntityList(); updateProps();
}
function deleteEntityAt(tx, ty) {
const md = getData();
// Spawn
if (md.spawn && md.spawn.x === tx && md.spawn.y === ty) { md.spawn = null; selectedEntity = null; updateEntityList(); updateProps(); return; }
let idx = md.npcs.findIndex(n => n.x === tx && n.y === ty);
if (idx >= 0) { md.npcs.splice(idx, 1); selectedEntity = null; updateEntityList(); updateProps(); return; }
idx = md.exits.findIndex(e => e.x === tx && e.y === ty);
@@ -751,7 +766,9 @@ function deleteEntityAt(tx, ty) {
function updateEntityList() {
const md = getData();
document.getElementById('spawn-list').innerHTML = makeEntityItem('spawn', 0, '🏠', `Spawn`, md.spawn.x, md.spawn.y);
document.getElementById('spawn-list').innerHTML = md.spawn
? 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')
@@ -799,7 +816,7 @@ function getSelectedEntityData() {
if (!selectedEntity) return null;
const md = getData();
switch (selectedEntity.type) {
case 'spawn': return md.spawn;
case 'spawn': return md.spawn || null;
case 'npc': return md.npcs[selectedEntity.index];
case 'exit': return md.exits[selectedEntity.index];
case 'interaction': return md.interactions[selectedEntity.index];
@@ -954,7 +971,9 @@ function generateMapsJS() {
out += ` image: '${imageKey}',\n`;
out += ` widthTiles: ${cfg.widthTiles},\n`;
out += ` heightTiles: ${cfg.heightTiles},\n`;
out += ` spawn: { x: ${md.spawn.x}, y: ${md.spawn.y} },\n`;
if (md.spawn) {
out += ` spawn: { x: ${md.spawn.x}, y: ${md.spawn.y} },\n`;
}
out += ` wallSet: buildWallSet(${varName(mapId)}Walls),\n\n`;
// Exits
@@ -1154,10 +1173,12 @@ function parseAndLoadMapsJS(src) {
mapData[mapId].walls = wallDataToSet(wallData);
}
// Parse spawn
// Parse spawn (optional — only present on initial map)
const spawnMatch = src.match(new RegExp(`${vName}Map[\\s\\S]*?spawn:\\s*\\{\\s*x:\\s*(\\d+)\\s*,\\s*y:\\s*(\\d+)`));
if (spawnMatch) {
mapData[mapId].spawn = { x: parseInt(spawnMatch[1]), y: parseInt(spawnMatch[2]) };
} else {
mapData[mapId].spawn = null;
}
// Parse NPCs - find the npcs array

View File

@@ -28,7 +28,7 @@ const labMap = {
image: 'map:lab',
widthTiles: 10,
heightTiles: 12,
spawn: { x: 4, y: 10 },
// No spawn — player enters via door from town
wallSet: buildWallSet(labWalls),
exits: [
@@ -110,7 +110,6 @@ const houseA1fMap = {
image: 'map:house-a-1f',
widthTiles: 8,
heightTiles: 8,
spawn: { x: 0, y: 0 },
wallSet: buildWallSet(houseA1fWalls),
exits: [
@@ -134,7 +133,6 @@ const route1Map = {
image: 'map:route-1',
widthTiles: 20,
heightTiles: 36,
spawn: { x: 0, y: 0 },
wallSet: buildWallSet(route1Walls),
exits: [