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:
173
editor.html
173
editor.html
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user