@@ -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('') || '
None
';
- document.getElementById('exit-list').innerHTML = md.exits.map((e, i) =>
- makeEntityItem('exit', i, 'πͺ', `β ${e.targetMap}`, e.x, e.y, '#55ff55')
- ).join('') || '
None
';
+ 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('') || '
None
';
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 += '
β οΈ Set target X/Y! Every exit needs explicit coordinates.
';
+ }
} 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();
diff --git a/js/world/gameMode.js b/js/world/gameMode.js
index 8b69bc6..a6119e0 100644
--- a/js/world/gameMode.js
+++ b/js/world/gameMode.js
@@ -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;
}
diff --git a/js/world/maps.js b/js/world/maps.js
index 661618b..9428cca 100644
--- a/js/world/maps.js
+++ b/js/world/maps.js
@@ -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: [
diff --git a/js/world/worldState.js b/js/world/worldState.js
index 43551fe..a56fca6 100644
--- a/js/world/worldState.js
+++ b/js/world/worldState.js
@@ -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: