feat: bidirectional door system + editor bi-link tool

Replace spawn-based map transitions with explicit bidirectional door
links. Every exit now requires targetX/targetY — spawn is only used
for initial game start. Remove returnPoints stack from worldState.

Editor improvements:
- New "Bi-Link" tool creates paired exits on both maps at once
- Exit list shows target coordinates and warns if missing
- Canvas renders target info labels below exit tiles
- Properties panel handles game ID ↔ editor ID mapping

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jose Luis
2026-03-20 17:15:25 +01:00
parent 1d494d8ef3
commit f740d96fc0
4 changed files with 169 additions and 52 deletions

View File

@@ -91,7 +91,8 @@ body { background: var(--bg); color: var(--text); font-family: 'Segoe UI', syste
<button class="tool-btn" data-tool="erase" title="Click/drag to erase walls">🧹 Erase</button>
<button class="tool-btn" data-tool="spawn" title="Click to set player spawn">🏠 Spawn</button>
<button class="tool-btn" data-tool="npc" title="Click to place NPC">👤 NPC</button>
<button class="tool-btn" data-tool="exit" title="Click to place exit">🚪 Exit</button>
<button class="tool-btn" data-tool="exit" title="Click to place exit (one-way)">🚪 Exit</button>
<button class="tool-btn" data-tool="bilink" title="Click to create bidirectional door link" style="background:rgba(0,200,100,0.15)">🔗 Bi-Link</button>
<button class="tool-btn" data-tool="interaction" title="Click to place interaction">⚡ Interact</button>
<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>
@@ -144,6 +145,31 @@ body { background: var(--bg); color: var(--text); font-family: 'Segoe UI', syste
</div>
</div>
<!-- Bi-Link modal -->
<div class="modal-overlay" id="bilink-modal">
<div class="modal">
<h2>🔗 Create Bidirectional Door Link</h2>
<p style="color:var(--text2);font-size:12px;margin-bottom:12px;">
This creates an exit on the current map AND the matching return exit on the target map.
</p>
<div id="bilink-form" style="display:flex;flex-direction:column;gap:8px;">
<div class="prop-row"><label>From tile</label><span id="bilink-from" style="color:var(--accent);font-family:monospace;"></span></div>
<div class="prop-row"><label>Target Map</label><select id="bilink-target-map" style="flex:1;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:3px;padding:4px 6px;"></select></div>
<div class="prop-row"><label>Enter at X</label><input type="number" id="bilink-enter-x" value="0" style="width:60px;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:3px;padding:4px 6px;"></div>
<div class="prop-row"><label>Enter at Y</label><input type="number" id="bilink-enter-y" value="0" style="width:60px;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:3px;padding:4px 6px;"></div>
<hr style="border-color:var(--border);margin:4px 0;">
<p style="color:var(--text2);font-size:11px;">Return exit on target map (where the player exits back to this map):</p>
<div class="prop-row"><label>Exit tile X</label><input type="number" id="bilink-return-x" value="0" style="width:60px;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:3px;padding:4px 6px;"></div>
<div class="prop-row"><label>Exit tile Y</label><input type="number" id="bilink-return-y" value="0" style="width:60px;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:3px;padding:4px 6px;"></div>
<div class="prop-row"><label>Return to X</label><span id="bilink-return-to" style="color:var(--accent);font-family:monospace;"></span></div>
</div>
<div class="modal-actions">
<button onclick="closeModal('bilink-modal')">Cancel</button>
<button class="primary" id="btn-do-bilink">🔗 Create Link</button>
</div>
</div>
</div>
<!-- Import modal -->
<div class="modal-overlay" id="import-modal">
<div class="modal">
@@ -240,6 +266,7 @@ function init() {
document.getElementById('btn-copy-export').addEventListener('click', copyExport);
document.getElementById('btn-save-server').addEventListener('click', saveToServer);
document.getElementById('btn-load-server').addEventListener('click', loadFromServer);
document.getElementById('btn-do-bilink').addEventListener('click', createBiLink);
resizeCanvas();
updateEntityList();
@@ -403,12 +430,29 @@ function render() {
// Exits
md.exits.forEach((e, i) => {
const isSel = selectedEntity?.type === 'exit' && selectedEntity.index === i;
ctx.fillStyle = 'rgba(50, 255, 50, 0.35)';
ctx.fillRect(e.x * TILE_PX, e.y * TILE_PX, TILE_PX, TILE_PX);
ctx.strokeStyle = selectedEntity?.type === 'exit' && selectedEntity.index === i ? '#fff' : '#55ff55';
ctx.lineWidth = selectedEntity?.type === 'exit' && selectedEntity.index === i ? 2.5 : 1.5;
ctx.strokeStyle = isSel ? '#fff' : '#55ff55';
ctx.lineWidth = isSel ? 2.5 : 1.5;
ctx.strokeRect(e.x * TILE_PX, e.y * TILE_PX, TILE_PX, TILE_PX);
drawLabel(ctx, '🚪', e.x, e.y);
// Show target info label below the tile
if (scale >= 2) {
const label = `${e.targetMap}(${e.targetX ?? '?'},${e.targetY ?? '?'})`;
ctx.save();
ctx.font = `bold ${Math.max(8, scale * 2.5)}px monospace`;
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
ctx.fillStyle = 'rgba(0,0,0,0.7)';
const tw = ctx.measureText(label).width + 4;
const lx = e.x * TILE_PX + TILE_PX / 2;
const ly = (e.y + 1) * TILE_PX + 1;
ctx.fillRect(lx - tw / 2, ly, tw, scale * 3 + 2);
ctx.fillStyle = '#55ff55';
ctx.fillText(label, lx, ly + 1);
ctx.restore();
}
});
// Interactions
@@ -512,11 +556,20 @@ function onMouseDown(e) {
selectedEntity = { type: 'npc', index: md.npcs.length - 1 };
updateEntityList(); updateProps(); render();
break;
case 'exit':
md.exits.push({ x: tile.x, y: tile.y, targetMap: 'lab', targetX: 0, targetY: 0 });
case 'exit': {
// Default to a different map than the current one
const otherMaps = Object.keys(mapConfigs).filter(id => id !== currentMapId);
const defaultTarget = otherMaps[0] || currentMapId;
md.exits.push({ x: tile.x, y: tile.y, targetMap: defaultTarget, targetX: 0, targetY: 0 });
selectedEntity = { type: 'exit', index: md.exits.length - 1 };
updateEntityList(); updateProps(); render();
break;
}
case 'bilink': {
// Open bidirectional link dialog
openBiLinkDialog(tile.x, tile.y);
break;
}
case 'interaction':
md.interactions.push({ x: tile.x, y: tile.y, type: 'sign', label: 'Sign', dialog: ['...'] });
selectedEntity = { type: 'interaction', index: md.interactions.length - 1 };
@@ -546,7 +599,7 @@ function onMouseMove(e) {
if (md.walls.has(key)) info += 'Wall ';
if (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} `; });
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} `; });
document.getElementById('tile-info').textContent = info || '(empty)';
@@ -704,9 +757,12 @@ function updateEntityList() {
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('exit-list').innerHTML = md.exits.map((e, i) =>
makeEntityItem('exit', i, '🚪', `${e.targetMap}`, e.x, e.y, '#55ff55')
).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;
const coordStr = hasCoords ? `(${e.targetX},${e.targetY})` : '⚠️ NO COORDS';
const color = hasCoords ? '#55ff55' : '#ff5555';
return makeEntityItem('exit', i, '🚪', `${e.targetMap} ${coordStr}`, e.x, e.y, color);
}).join('') || '<div style="padding:4px 16px;color:var(--text2);font-size:11px;">None</div>';
document.getElementById('interaction-list').innerHTML = md.interactions.map((inter, i) =>
makeEntityItem('interaction', i, '⚡', `${inter.type}: ${inter.label}`, inter.x, inter.y, '#ffdd44')
@@ -771,9 +827,17 @@ function updateProps() {
html += propSelect('Facing', 'facing', ent.facing, ['down','up','left','right']);
html += propTextarea('Dialog', 'dialog', (ent.dialog || []).join('\n'));
} else if (t === 'exit') {
html += propSelect('Target Map', 'targetMap', ent.targetMap, Object.keys(mapConfigs));
html += propNum('Target X', 'targetX', ent.targetX);
html += propNum('Target Y', 'targetY', ent.targetY);
// Show map options with both editor ID and game ID
const mapOptions = Object.keys(mapConfigs);
// Handle game IDs like 'town' being stored as 'pallet-town' internally
const currentTarget = ent.targetMap === 'town' ? 'pallet-town' : ent.targetMap;
html += propSelect('Target Map', 'targetMap', currentTarget, mapOptions);
html += propNum('Target X', 'targetX', ent.targetX ?? 0);
html += propNum('Target Y', 'targetY', ent.targetY ?? 0);
// Warning if no target coords set
if (ent.targetX == null || ent.targetY == null) {
html += '<div style="color:var(--red);font-size:10px;padding:2px 0;">⚠️ Set target X/Y! Every exit needs explicit coordinates.</div>';
}
} else if (t === 'interaction') {
html += propSelect('Type', 'type', ent.type, ['sign','workshop','puzzle_door','terminal','door']);
html += propText('Label', 'label', ent.label || '');
@@ -818,6 +882,9 @@ function applyPropChange(prop, value, inputType) {
ent.dialog = value.split('\n').filter(l => l.trim());
} else if (prop === 'requiredOutputs') {
ent.requiredOutputs = value.split(',').map(Number);
} else if (prop === 'targetMap') {
// Store as game ID (pallet-town → town)
ent.targetMap = value === 'pallet-town' ? 'town' : value;
} else if (inputType === 'number' || prop === 'x' || prop === 'y' || prop === 'targetX' || prop === 'targetY') {
ent[prop] = parseInt(value) || 0;
} else {
@@ -1126,6 +1193,88 @@ function parseAndLoadMapsJS(src) {
}
}
// ==================== Bidirectional Link ====================
let bilinkSourceTile = null;
function openBiLinkDialog(tx, ty) {
bilinkSourceTile = { x: tx, y: ty };
// Populate target map dropdown (exclude current map)
const sel = document.getElementById('bilink-target-map');
sel.innerHTML = '';
for (const [id, cfg] of Object.entries(mapConfigs)) {
if (id === currentMapId) continue;
const opt = document.createElement('option');
opt.value = id;
opt.textContent = `${cfg.name} (${id})`;
sel.appendChild(opt);
}
document.getElementById('bilink-from').textContent = `(${tx}, ${ty}) on ${mapConfigs[currentMapId].name}`;
// Smart defaults: return tile = one tile below the source (in front of the door)
const returnX = tx;
const returnY = Math.min(ty + 1, mapConfigs[currentMapId].heightTiles - 1);
document.getElementById('bilink-return-to').textContent = `(${returnX}, ${returnY})`;
// Update return-to display when source changes
document.getElementById('bilink-enter-x').value = 0;
document.getElementById('bilink-enter-y').value = 0;
document.getElementById('bilink-return-x').value = 0;
document.getElementById('bilink-return-y').value = 0;
openModal('bilink-modal');
}
// btn-do-bilink wired after init below
function createBiLink() {
if (!bilinkSourceTile) return;
const targetMapId = document.getElementById('bilink-target-map').value;
const enterX = parseInt(document.getElementById('bilink-enter-x').value) || 0;
const enterY = parseInt(document.getElementById('bilink-enter-y').value) || 0;
const returnExitX = parseInt(document.getElementById('bilink-return-x').value) || 0;
const returnExitY = parseInt(document.getElementById('bilink-return-y').value) || 0;
// Return to one tile below the source door (in front of it)
const returnToX = bilinkSourceTile.x;
const returnToY = Math.min(bilinkSourceTile.y + 1, mapConfigs[currentMapId].heightTiles - 1);
// Game IDs: pallet-town → town
const toGameId = (id) => id === 'pallet-town' ? 'town' : id;
// 1. Create exit on current map: door → target interior
const currentMd = mapData[currentMapId];
currentMd.exits.push({
x: bilinkSourceTile.x,
y: bilinkSourceTile.y,
targetMap: toGameId(targetMapId),
targetX: enterX,
targetY: enterY
});
// 2. Create matching return exit on target map: interior exit → back to this door
const targetMd = mapData[targetMapId];
if (targetMd) {
targetMd.exits.push({
x: returnExitX,
y: returnExitY,
targetMap: toGameId(currentMapId),
targetX: returnToX,
targetY: returnToY
});
toast(`Bidirectional link created: ${currentMapId}${targetMapId}`);
} else {
toast(`Exit created on ${currentMapId} (target map ${targetMapId} not loaded)`);
}
closeModal('bilink-modal');
selectedEntity = { type: 'exit', index: currentMd.exits.length - 1 };
updateEntityList(); updateProps(); render();
}
// ==================== Boot ====================
init();

View File

@@ -1,5 +1,5 @@
// gameMode.js - Central coordinator: switches between World and Workshop modes
import { worldState, setPlayerPosition, warpToMap, solvePuzzle, isPuzzleSolved } from './worldState.js';
import { worldState, setPlayerPosition, warpToMap, isPuzzleSolved } from './worldState.js';
import { initWorldRenderer, startWorldLoop, stopWorldLoop } from './worldRenderer.js';
import { initWorldInput, destroyWorldInput, setInteractionHandler } from './worldInput.js';
import { getMap } from './maps.js';
@@ -112,39 +112,11 @@ function handleInteraction(event) {
}
case 'mapExit': {
// Every exit MUST have targetX/targetY — bidirectional door links.
// No spawn fallback. Spawn is only for the initial game start.
const { targetMap, targetX, targetY } = event.data;
const p = worldState.player;
// Save return point: where the player is NOW (one tile back from the exit)
// so when they leave the target map, they return here
worldState.returnPoints.push({
fromMap: worldState.currentMap,
fromX: p.x,
fromY: p.y
});
// If exit has explicit coordinates, use them
// Otherwise, check for a stored return point for the target map
if (targetX != null && targetY != null) {
warpToMap(targetMap, targetX, targetY);
} else {
// Pop the most recent return point for this map
const retIdx = worldState.returnPoints.findLastIndex(
rp => rp.fromMap === targetMap
);
if (retIdx >= 0) {
const ret = worldState.returnPoints[retIdx];
worldState.returnPoints.splice(retIdx, 1);
warpToMap(ret.fromMap, ret.fromX, ret.fromY);
} else {
// Fallback: use map spawn point
const destMap = getMap(targetMap);
const sx = destMap?.spawn?.x ?? 0;
const sy = destMap?.spawn?.y ?? 0;
warpToMap(targetMap, sx, sy);
}
}
console.log(`[gameMode] warped to ${worldState.currentMap} (${worldState.player.x}, ${worldState.player.y})`);
warpToMap(targetMap, targetX, targetY);
console.log(`[gameMode] warped to ${targetMap} (${targetX}, ${targetY})`);
break;
}

View File

@@ -32,9 +32,9 @@ const labMap = {
wallSet: buildWallSet(labWalls),
exits: [
// No targetX/targetY — uses stored return point (the door the player entered from)
{ x: 4, y: 11, targetMap: 'town' },
{ x: 5, y: 11, targetMap: 'town' },
// Bidirectional: these doors return to the specific town door (12,12 = tile in front of lab entrance)
{ x: 4, y: 11, targetMap: 'town', targetX: 12, targetY: 12 },
{ x: 5, y: 11, targetMap: 'town', targetX: 12, targetY: 12 },
],
npcs: [

View File

@@ -40,10 +40,6 @@ export const worldState = {
solvedPuzzles: [], // array of puzzleIds that have been solved
activePuzzle: null, // { puzzleId, requiredOutputs, doorX, doorY } or null when no puzzle active
// Return points — remembers where the player entered each map from
// Stack of { fromMap, fromX, fromY } — push on enter, pop on exit
returnPoints: [],
// Game flags
flags: {
// Examples: