var CELL = 100; var currentZ = 0, currentDir = '', selectedRoom = null, mapData = null; var panX = 0, panY = 0, scale = 1; var dragging = false, startX = 0, startY = 0, prevX = 0, prevY = 0; var dragRoom = false; var linkDrag = false, linkFromId = null, linkTargetId = null, linkTargetDir = null, linkGhostDir = null; var linkDragOneWay = false; var ctrlHeld = false; var delOneWay = false; var delDrag = false, delFromId = null, delTargetId = null; var roomMap = {}, occupied = {}; var dirChangedByUser = false; var currentPanelTab = 0; var gridDirs = [ {dx:0,dy:-1,dir:'north'},{dx:0,dy:1,dir:'south'}, {dx:1,dy:0,dir:'east'},{dx:-1,dy:0,dir:'west'}, {dx:1,dy:-1,dir:'northeast'},{dx:-1,dy:-1,dir:'northwest'}, {dx:1,dy:1,dir:'southeast'},{dx:-1,dy:1,dir:'southwest'} ]; var oppositeDir = { north:'south', south:'north', east:'west', west:'east', northeast:'southwest', northwest:'southeast', southeast:'northwest', southwest:'northeast' }; var saveTimer = null; var upTarget = {}, downTarget = {}; var _mapMouseCleanup = false; function isCtrlKey(e) { return e && (e.key === 'Control' || e.code === 'ControlLeft' || e.code === 'ControlRight' || e.keyCode === 17); } function onMapMouseMove(e) { if (!dragging) return; var moved = Math.abs(e.clientX - startX) >= 4 || Math.abs(e.clientY - startY) >= 4; if (delFromId && moved && !delDrag) { delDrag = true; } if (delDrag && moved) { delOneWay = e.ctrlKey; updateDelDragTarget(e); showDelDragBadge(); return; } if (!dragRoom && !delDrag && !delFromId) { panX += e.clientX - prevX; panY += e.clientY - prevY; prevX = e.clientX; prevY = e.clientY; updateView(); } if (linkFromId && moved) { linkDrag = true; linkDragOneWay = e.ctrlKey; showLinkDragBadge(); updateLinkDragTarget(e); } } function onMapMouseUp(e) { if (!dragging) return; document.removeEventListener('mousemove', onMapMouseMove); document.removeEventListener('mouseup', onMapMouseUp); _mapMouseCleanup = false; var moved = Math.abs(e.clientX - startX) >= 4 || Math.abs(e.clientY - startY) >= 4; if (delFromId && !delDrag && !moved) { showRoomContextMenu(startX, startY, delFromId); clearDelHighlight(); dragging = false; dragRoom = false; delFromId = null; delTargetId = null; delDrag = false; if (document.activeElement && document.activeElement.blur) document.activeElement.blur(); return; } if (delDrag && delFromId && delTargetId) { deleteLink(delFromId, delTargetId, (delOneWay || e.ctrlKey) ? 'from' : ''); clearDelHighlight(); hideDragBadge(); dragging = false; dragRoom = false; delDrag = false; delFromId = null; delTargetId = null; delOneWay = false; if (document.activeElement && document.activeElement.blur) document.activeElement.blur(); return; } if (linkDrag && linkFromId) { var oneway = linkDragOneWay || e.ctrlKey; if (linkTargetId) { createLink(linkFromId, linkTargetId, oneway, linkTargetDir); } else if (linkGhostDir) { createRoom(linkFromId, linkGhostDir, oneway); } } else if (!moved && !delDrag) { if (e.target.classList.contains('rm')) { selectRoom(parseInt(e.target.getAttribute('data-id'))); } else if (e.target.classList.contains('ghost')) { createRoom(selectedRoom, e.target.getAttribute('data-dir'), e.ctrlKey); } } clearLinkHighlight(); clearDelHighlight(); hideDragBadge(); dragging = false; dragRoom = false; linkDrag = false; linkFromId = null; linkTargetId = null; linkTargetDir = null; linkGhostDir = null; linkDragOneWay = false; delDrag = false; delFromId = null; delTargetId = null; delOneWay = false; if (document.activeElement && document.activeElement.blur) document.activeElement.blur(); } function cancelMapDrag() { if (!dragging) return; document.removeEventListener('mousemove', onMapMouseMove); document.removeEventListener('mouseup', onMapMouseUp); _mapMouseCleanup = false; clearLinkHighlight(); clearDelHighlight(); hideDragBadge(); dragging = false; dragRoom = false; linkDrag = false; linkFromId = null; linkTargetId = null; linkTargetDir = null; linkGhostDir = null; linkDragOneWay = false; delDrag = false; delFromId = null; delTargetId = null; delOneWay = false; if (document.activeElement && document.activeElement.blur) document.activeElement.blur(); } function showDragBadge(text, oneway) { var badge = document.getElementById('linkModeBadge'); if (!badge) return; badge.textContent = text; badge.className = 'link-mode-badge' + (oneway ? ' oneway' : ''); badge.style.display = ''; } function hideDragBadge() { var badge = document.getElementById('linkModeBadge'); if (badge) badge.style.display = 'none'; } function showLinkDragBadge() { var label = linkDragOneWay ? 'One way link' : 'Two-way link'; var toLabel = linkTargetId ? linkTargetId : (linkGhostDir ? 'new' : null); if (linkFromId && toLabel) label += ' (#' + linkFromId + ' to #' + toLabel + ')'; showDragBadge(label, linkDragOneWay); } function showDelDragBadge() { var label = delOneWay ? 'One way remove' : 'Two-way remove'; if (delFromId && delTargetId) label += ' (#' + delFromId + ' to #' + delTargetId + ')'; showDragBadge(label, delOneWay); } function refreshDragMode(ctrlDown) { if (linkDrag && linkFromId) { linkDragOneWay = ctrlDown; showLinkDragBadge(); } if (delDrag && delFromId) { delOneWay = ctrlDown; showDelDragBadge(); } } function changeZ(dz) { if (dz === 0) currentZ = 0; else currentZ += dz; panX = 0; panY = 0; scale = 1; loadMap(); } function changeDir(dir) { currentDir = dir; dirChangedByUser = true; selectedRoom = null; panX = 0; panY = 0; scale = 1; loadMap(); } function zoomIn() { scale *= 1.1; if (scale > 5) scale = 5; updateView(); } function zoomOut() { scale *= 0.9; if (scale < 0.1) scale = 0.1; updateView(); } function loadMap() { $('#zLabel').textContent = 'Z=' + currentZ; var url = '/api/map?z=' + currentZ; if (currentDir) url += '&dir=' + encodeURIComponent(currentDir); var wasDirChange = dirChangedByUser; dirChangedByUser = false; return API.get(url).then(function(data) { mapData = data; var dirUpdated = false; if (!currentDir && data.dir) { currentDir = data.dir; dirUpdated = true; } if (dirUpdated || wasDirChange) { updateDirSelect(); } renderMap(data); renderDisconnectedList(data); var newRoomBtn = $('#newRoomBtn'); var newRoomSep = $('#newRoomSep'); if (newRoomBtn && newRoomSep) { var empty = !data.rooms || data.rooms.length === 0; newRoomBtn.style.display = empty ? '' : 'none'; newRoomSep.style.display = empty ? '' : 'none'; } if (wasDirChange && data.seed) { selectRoom(data.seed); } }).catch(function(e) { notify('Failed to load map: ' + e.message, 'error'); }); } function blendColors(colorA, colorB) { if (!colorA && !colorB) return '#666666'; if (!colorB) return xtermToCss(parseXtermIdx(colorA)); if (!colorA) return xtermToCss(parseXtermIdx(colorB)); var idxA = parseXtermIdx(colorA); var idxB = parseXtermIdx(colorB); var rgbA = xtermToRGB(idxA); var rgbB = xtermToRGB(idxB); var r = Math.round((rgbA[0] + rgbB[0]) / 2); var g = Math.round((rgbA[1] + rgbB[1]) / 2); var b = Math.round((rgbA[2] + rgbB[2]) / 2); return '#' + ((1 << 24) | (r << 16) | (g << 8) | b).toString(16).slice(1).toUpperCase(); } function parseXtermIdx(v) { if (!v) return 0; v = String(v).toUpperCase().replace(/[^0-9A-F]/g, ''); if (v.length >= 2) return parseInt(v.substring(0, 2), 16) || 0; return 0; } function renderMap(data) { var svg = $('#mapSvg'); var container = $('#mapContainer'); var W = container.clientWidth; var H = container.clientHeight; roomMap = {}; occupied = {}; upTarget = {}; downTarget = {}; var hasUp = {}, hasDown = {}; if (data.rooms) data.rooms.forEach(function(r) { roomMap[r.id] = r; occupied[r.x+','+r.y] = r.id; }); if (data.upLinks) data.upLinks.forEach(function(l) { upTarget[l.from] = l.to; hasUp[l.from] = true; }); if (data.downLinks) data.downLinks.forEach(function(l) { downTarget[l.from] = l.to; hasDown[l.from] = true; }); var g = ''; if (data.links) { data.links.forEach(function(l) { var fr = roomMap[l.from], tr = roomMap[l.to]; if (!fr || !tr) return; var x1 = fr.x * CELL + CELL/2, y1 = fr.y * CELL + CELL/2; var x2 = tr.x * CELL + CELL/2, y2 = tr.y * CELL + CELL/2; if (l.always_blocked) { g += ''; return; } if (l.hidden) { g += ''; return; } var c = blendColors(fr.color, tr.color).replace('#',''); g += ''; }); } var roomHTML = ''; if (data.rooms) { data.rooms.forEach(function(r) { var x = r.x * CELL + 8, y = r.y * CELL + 8, w = CELL-16, h = CELL-16; var fill = resolveColor(r.color); var txtColor = hexLuminance(fill) > 0.35 ? '#111' : '#fff'; var sel = selectedRoom === r.id; roomHTML += ''; var dimColor = hexLuminance(fill) > 0.35 ? 'rgba(0,0,0,0.45)' : 'rgba(255,255,255,0.4)'; roomHTML += '#'+r.id+''; roomHTML += wrapText(r.name, x+w/2, y+h/2+2, w, txtColor); if (hasUp[r.id] && upTarget[r.id]) { roomHTML += '\u2191'; } if (hasDown[r.id] && downTarget[r.id]) { roomHTML += '\u2193'; } if (sel) { var btnBW = 18; roomHTML += '+\u2191'; roomHTML += '+\u2193'; } }); } var ghostHTML = ''; if (selectedRoom && roomMap[selectedRoom]) { var sr = roomMap[selectedRoom]; gridDirs.forEach(function(d) { var nx = sr.x + d.dx, ny = sr.y + d.dy; var key = nx+','+ny; if (!occupied[key]) { ghostHTML += ''; ghostHTML += '+'; } }); } var innerHTML = g + roomHTML + ghostHTML; var vb = [(-panX - W/2)/scale, (-panY - H/2)/scale, W/scale, H/scale]; svg.setAttribute('viewBox', vb.join(' ')); svg.setAttribute('width', W); svg.setAttribute('height', H); svg.innerHTML = '' + innerHTML + ''; svg.oncontextmenu = function(e) { e.preventDefault(); }; if (!svg._ctxWired) { svg.addEventListener('contextmenu', function(e) { e.preventDefault(); e.stopPropagation(); }); svg._ctxWired = true; } if (!svg._mapKeysWired) { svg.setAttribute('tabindex', '0'); svg.addEventListener('keydown', function(e) { if (isCtrlKey(e)) { ctrlHeld = true; if (dragging) refreshDragMode(true); } }); svg.addEventListener('keyup', function(e) { if (isCtrlKey(e)) { ctrlHeld = false; if (dragging) refreshDragMode(false); } }); svg._mapKeysWired = true; } svg.onmousedown = function(e) { if (e.button === 2) e.preventDefault(); var onRoom = e.target.classList.contains('rm'); var onGhost = e.target.classList.contains('ghost'); var onUdBtn = e.target.classList.contains('ud-btn'); var onNavBtn = e.target.classList.contains('nav-btn'); if (onNavBtn) { e.stopPropagation(); var dz = parseInt(e.target.getAttribute('data-dz')); var tid = parseInt(e.target.getAttribute('data-target')); currentZ += dz; loadMap().then(function() { selectRoom(tid); }); return; } if (e.button === 2 && onRoom) { delFromId = parseInt(e.target.getAttribute('data-id')); delDrag = false; delTargetId = null; delOneWay = false; dragging = true; if (svg.focus) svg.focus(); startX = e.clientX; startY = e.clientY; prevX = e.clientX; prevY = e.clientY; dragRoom = true; linkFromId = null; linkDrag = false; linkTargetId = null; linkGhostDir = null; clearLinkHighlight(); document.addEventListener('mousemove', onMapMouseMove); document.addEventListener('mouseup', onMapMouseUp); _mapMouseCleanup = true; return; } if (e.button !== 0) return; if (onUdBtn) { e.stopPropagation(); var dir = e.target.getAttribute('data-dir'); createRoom(selectedRoom, dir); return; } dragRoom = onRoom || onGhost; dragging = true; if (svg.focus) svg.focus(); startX = e.clientX; startY = e.clientY; prevX = e.clientX; prevY = e.clientY; linkFromId = onRoom ? parseInt(e.target.getAttribute('data-id')) : null; linkDrag = false; linkTargetId = null; linkGhostDir = null; linkDragOneWay = onRoom ? (e.ctrlKey || ctrlHeld) : false; delDrag = false; delFromId = null; delTargetId = null; clearLinkHighlight(); document.addEventListener('mousemove', onMapMouseMove); document.addEventListener('mouseup', onMapMouseUp); _mapMouseCleanup = true; }; svg.onwheel = function(e) { e.preventDefault(); var zf = e.deltaY < 0 ? 1.1 : 0.9; scale *= zf; if (scale < 0.1) scale = 0.1; if (scale > 5) scale = 5; updateView(); }; } function wrapText(name, cx, cy, maxWidth, color) { if (!name) name = ''; color = color || '#fff'; var charW = 6; var maxLen = Math.max(4, Math.floor((maxWidth - 4) / charW)); var words = name.split(/\s+/); var lines = []; var cur = ''; words.forEach(function(w) { if (w.length > maxLen) { if (cur) { lines.push(cur); cur = ''; } for (var j = 0; j < w.length; j += maxLen) { lines.push(w.substring(j, j + maxLen)); } } else if (!cur) { cur = w; } else if ((cur + ' ' + w).length <= maxLen) { cur += ' ' + w; } else { lines.push(cur); cur = w; } }); if (cur) lines.push(cur); if (lines.length === 0) lines.push(''); if (lines.length > 3) lines = lines.slice(0, 3); var dy = -(lines.length - 1) * 6; return lines.map(function(l, i) { return ''+esc(l)+''; }).join(''); } function updateView() { var svg = $('#mapSvg'); if (!svg) return; var W = svg.parentElement.clientWidth; var H = svg.parentElement.clientHeight; var vb = [(-panX - W/2)/scale, (-panY - H/2)/scale, W/scale, H/scale]; svg.setAttribute('viewBox', vb.join(' ')); } function selectRoom(id) { if (saveTimer) { clearTimeout(saveTimer); saveTimer = null; } var prev = selectedRoom; selectedRoom = id; if (mapData && prev !== id) { renderMap(mapData); } API.get('/api/rooms/' + id).then(function(data) { renderSidePanel(data); loadRoomCourseData(id); }).catch(function(e) { notify('Failed to load room: '+e.message, 'error'); }); } var currentRoomData = null; var lastDcDir = null; function renderSidePanel(data) { var panel = $('#panelContent'); if (!data || !data.room) { panel.innerHTML = '

Room not found

'; return; } currentRoomData = data; var r = data.room; var file = data.file || ''; var objects = Array.isArray(r.objects) ? r.objects : []; var localObjs = objects.filter(function(o) { return !o.id; }); var refObjs = objects.filter(function(o) { return !!o.id; }); var spawns = Array.isArray(r.item_spawns) ? r.item_spawns : []; var mobs = Array.isArray(r.mobs) ? r.mobs : []; var html = '
'; html += ''; html += ''; html += ''; html += ''; html += ''; html += ''; html += ''; html += ''; html += '
'; html += '
'; if (currentPanelTab === 0) html += renderRoomTab(r, file); else if (currentPanelTab === 1) html += renderExitsTab(r); else if (currentPanelTab === 2) html += renderLocalTab(localObjs); else if (currentPanelTab === 3) html += renderObjectsTab(refObjs); else if (currentPanelTab === 4) html += renderMobsTab(mobs); else if (currentPanelTab === 5) html += renderItemsTab(spawns); else if (currentPanelTab === 6) html += renderHazardTab(r); else if (currentPanelTab === 7) { if (currentRoomData.course === undefined) { html += renderCourseTabLoading(); loadRoomCourseData(r.id); } else if (currentRoomData.course === null) { html += renderCourseTabEmpty(); } else { html += renderCourseTab(); } } html += '
'; panel.innerHTML = html; setTimeout(function() { var cf = document.querySelector('#panelContent .color-field'); if (cf) { bindColorField(cf); var ci = cf.querySelector('input'); if (ci) ci.addEventListener('input', function() { autoSave(); }); } loadRoomDirs(function(dirs) { var current = getRoomDirFromFile(file); var areaSel = $('#dirSelect'); if (areaSel && current) { areaSel.value = current; loadDisconnectedRooms(current); } var sel = $('#roomDir'); if (!sel) return; sel.innerHTML = ''; dirs.forEach(function(d) { var opt = document.createElement('option'); opt.value = d; opt.textContent = d || '(root)'; if (d === current) opt.selected = true; sel.appendChild(opt); }); }); }, 50); } function renderRoomTab(r, file) { var html = '
' + esc(file) + '
'; html += '
'; html += '
'; html += '
'; var descText = ''; if (Array.isArray(r.description)) { descText = r.description.map(function(d) { return d.text || ''; }).join('\n\n'); } else if (typeof r.description === 'string') { descText = r.description; } html += '
'; html += '
'; html += '
'; return html; } function renderExitsTab(r) { return renderExitsSection(r); } function renderHazardTab(r) { var html = ''; var current = r.hazard || ''; if (current) { html += '
' + esc(current) + '
'; } return html; } // ---- Course tab --------------------------------------------------------- var OBSTACLE_VERBS = ['scramble','jump','swing','balance','climb','crawl','vault','leap','slide']; function loadRoomCourseData(id) { if (!currentRoomData) return; // Mark as "fetching" (undefined) so switchPanelTab knows to show Loading // rather than the empty state, and so re-clicking the tab doesn't refetch. if (currentRoomData.course === undefined) { // first time for this room } else { currentRoomData.course = undefined; } API.get('/api/rooms/' + id + '/course').then(function(c) { currentRoomData.course = c; _coursePhases = null; if (currentPanelTab === 7 && selectedRoom === id) { var content = document.querySelector('.panel-tab-content'); if (content) content.innerHTML = renderCourseTab(); } }).catch(function() { if (currentRoomData) currentRoomData.course = null; }); } function renderCourseTabLoading() { return '

Loading course...

'; } var _courseDetailsCollapsed = true; function renderCourseTab() { var cd = currentRoomData && currentRoomData.course ? currentRoomData.course : null; if (!cd || cd === null) { return renderCourseTabEmpty(); } var h = ''; var obstacle = cd.obstacle || {}; var idx = cd.obstacle_index, total = cd.total_obstacles; var course = cd.course || {}; var prevRoom = idx > 0 ? (course.obstacles && course.obstacles[idx-1] && course.obstacles[idx-1].room_id) : 0; var nextRoom = idx < total - 1 ? (course.obstacles && course.obstacles[idx+1] && course.obstacles[idx+1].room_id) : 0; var roomID = currentRoomData.room.id; // Header: Step N, Room #X (This Obstacle) h += '
'; h += '

Step ' + (idx + 1) + ', Room #' + roomID + '

'; h += '
(This Obstacle) — ' + esc(cd.course_name || cd.course_id || '') + '
'; // Prev/Next buttons row h += '
'; if (prevRoom) { h += ''; } if (nextRoom) { h += ''; } if (!prevRoom && !nextRoom) { h += 'Single-step course'; } h += '
'; h += '
'; // Obstacle details h += '
'; h += '
'; h += '
'; h += '
'; var fd = obstacle.fail_damage || [0,0]; var fdMin = Array.isArray(fd) ? fd[0] : 0; var fdMax = Array.isArray(fd) ? fd[1] : 0; h += ''; h += ''; h += '
'; h += '
'; var fcVal = (obstacle.fail_chance !== undefined && obstacle.fail_chance !== null) ? obstacle.fail_chance : ''; h += ''; h += '
Derived: 30% at req level, -1% per level above, clamp 5-60%. A number here overrides it.
'; h += '
'; h += '
'; // Phases section h += '
'; h += '
Phases (messages)
'; h += '
'; h += ''; h += '
'; h += '
'; h += ''; h += '
'; // Collapsible course details (starts collapsed) h += '
'; h += '
'; h += '' + (_courseDetailsCollapsed ? '▶' : '▼') + ''; h += 'Course Details'; h += '
'; if (!_courseDetailsCollapsed) { h += '
'; h += '
'; h += '
'; h += '
'; h += '
'; h += '
'; } h += '
'; setTimeout(function() { renderCoursePhases(); }, 0); return h; } function courseToggleDetails() { _courseDetailsCollapsed = !_courseDetailsCollapsed; if (currentPanelTab === 7 && currentRoomData) { var content = document.querySelector('.panel-tab-content'); if (content) content.innerHTML = renderCourseTab(); } } var HORIZONTAL_DIRS = ['north','south','east','west','northeast','northwest','southeast','southwest']; function renderCourseTabEmpty() { var h = '

This room is not part of any course.

'; h += '
'; h += '
'; h += ''; h += ''; return h; } // ---- phases ------------------------------------------------------------- function getCoursePhases() { var c = currentRoomData && currentRoomData.course; if (!c) return []; var obs = c.obstacle || {}; var phases = obs.phases; if (!Array.isArray(phases)) { // build from legacy messages form var msgs = obs.messages || []; var tpp = obs.ticks_per_phase || 0; phases = msgs.map(function(m, i) { return { message: m, delay: i === 0 ? 0 : tpp, fail_check: (i === 1) }; }); } return phases; } var _coursePhases = null; function ensureCoursePhasesLoaded() { if (_coursePhases === null) { _coursePhases = getCoursePhases().slice(); } } function renderCoursePhases() { ensureCoursePhasesLoaded(); var container = $('#coursePhases'); if (!container) return; var h = ''; _coursePhases.forEach(function(p, i) { h += '
'; h += '
'; h += ''; h += '
'; h += ''; h += ''; h += '
'; h += '
'; h += ''; h += '
'; }); container.innerHTML = h; } function courseAddPhase() { ensureCoursePhasesLoaded(); _coursePhases.push({ message: '', delay: 0, fail_check: false }); renderCoursePhases(); courseAutoSave(); } function courseRemovePhase(i) { ensureCoursePhasesLoaded(); _coursePhases.splice(i, 1); renderCoursePhases(); courseAutoSave(); } function courseUpdatePhase(i) { ensureCoursePhasesLoaded(); if (i < 0 || i >= _coursePhases.length) return; var row = document.querySelector('.course-phase[data-idx="' + i + '"]'); if (!row) return; _coursePhases[i].message = row.querySelector('.phase-msg').value; _coursePhases[i].delay = parseFloat(row.querySelector('.phase-delay').value) || 0; courseAutoSave(); } function courseSetFailCheck(i) { ensureCoursePhasesLoaded(); _coursePhases.forEach(function(p, j) { p.fail_check = (j === i); }); renderCoursePhases(); courseAutoSave(); } // ---- save / detach ------------------------------------------------------ function courseSyncPhasesFromDOM() { ensureCoursePhasesLoaded(); var rows = document.querySelectorAll('.course-phase'); rows.forEach(function(row) { var i = parseInt(row.getAttribute('data-idx')); if (i < 0 || i >= _coursePhases.length) return; var msgEl = row.querySelector('.phase-msg'); var delayEl = row.querySelector('.phase-delay'); if (msgEl) _coursePhases[i].message = msgEl.value; if (delayEl) _coursePhases[i].delay = parseFloat(delayEl.value) || 0; }); } var _courseSaveTimer = null; function courseAutoSave() { if (_courseSaveTimer) clearTimeout(_courseSaveTimer); _courseSaveTimer = setTimeout(function() { courseSave(true); }, 600); } function courseSave(silent) { if (!currentRoomData || !currentRoomData.course) { if (!silent) notify('No course loaded', 'error'); return; } var c = currentRoomData.course; var course = c.course || {}; var courseID = c.course_id; if (!courseID) { if (!silent) notify('Missing course id', 'error'); return; } courseSyncPhasesFromDOM(); // Course-level fields (may be absent if the details section is collapsed; // fall back to existing course values). var nameEl = document.querySelector('#courseName'); var rlEl = document.querySelector('#courseRequiredLevel'); var srEl = document.querySelector('#courseStartRoom'); var cxEl = document.querySelector('#courseCompletionXP'); var name = nameEl ? nameEl.value : (course.name || ''); var reqLevel = rlEl ? (parseInt(rlEl.value) || 0) : (course.required_level || 0); var startRoom = srEl ? (parseInt(srEl.value) || 0) : (course.start_room || 0); var completionXP = cxEl ? (parseInt(cxEl.value) || 0) : (course.completion_xp || 0); var verbEl = document.querySelector('#courseObstacleVerb'); var xpEl = document.querySelector('#courseObstacleXP'); var fminEl = document.querySelector('#courseObstacleFailMin'); var fmaxEl = document.querySelector('#courseObstacleFailMax'); var fcEl = document.querySelector('#courseObstacleFailChance'); var verb = verbEl ? verbEl.value : (c.obstacle && c.obstacle.verb) || 'climb'; var xp = xpEl ? (parseInt(xpEl.value) || 0) : (c.obstacle && c.obstacle.xp) || 0; var failMin = fminEl ? (parseInt(fminEl.value) || 0) : (Array.isArray(c.obstacle && c.obstacle.fail_damage) ? c.obstacle.fail_damage[0] : 0); var failMax = fmaxEl ? (parseInt(fmaxEl.value) || 0) : (Array.isArray(c.obstacle && c.obstacle.fail_damage) ? c.obstacle.fail_damage[1] : 0); var fcStr = fcEl ? fcEl.value : (c.obstacle && (c.obstacle.fail_chance !== undefined && c.obstacle.fail_chance !== null) ? String(c.obstacle.fail_chance) : ''); var fc = fcStr === '' ? null : parseFloat(fcStr); if (fc !== null) { if (fc < 0) fc = 0; if (fc > 100) fc = 100; fc = fc / 100; } var obstacles = (course.obstacles || []).slice(); var newObs = { room_id: currentRoomData.room.id, verb: verb, xp: xp, fail_damage: [failMin, failMax] }; if (fc !== null) newObs.fail_chance = fc; newObs.phases = (_coursePhases || []).map(function(p) { var ph = { message: p.message || '', delay: p.delay || 0 }; if (p.fail_check) ph.fail_check = true; return ph; }); obstacles[c.obstacle_index] = newObs; var body = { name: name, required_level: reqLevel, start_room: startRoom, completion_xp: completionXP, obstacles: obstacles }; API.put('/api/courses/' + encodeURIComponent(courseID), body).then(function() { if (!silent) notify('Course ' + courseID + ' saved', 'success'); updateUndoBar(); // Refresh the cached course data WITHOUT re-rendering the panel, so the // builder's cursor isn't disrupted (auto-save uses silent=true). API.get('/api/rooms/' + currentRoomData.room.id + '/course').then(function(c) { currentRoomData.course = c; _coursePhases = null; }).catch(function() {}); }).catch(function(e) { if (!silent) notify('Course save failed: ' + extractError(e), 'error'); }); } function courseDetach() { if (!currentRoomData || !currentRoomData.course) return; var c = currentRoomData.course; var total = c.total_obstacles || 1; var msg = 'Remove room #' + currentRoomData.room.id + ' from course ' + c.course_id + '?'; if (total <= 1) msg = 'This is the last obstacle of course ' + c.course_id + '. Removing it will DELETE the course. Continue?'; if (!confirm(msg)) return; fetch('/api/rooms/' + currentRoomData.room.id + '/courses/detach', { method: 'POST', headers: {'Content-Type':'application/json'}, body: '{}', credentials: 'same-origin' }).then(function(r) { return r.json(); }).then(function(res) { if (res.error) { notify(res.error, 'error'); return; } notify(res.deleted ? ('Course ' + c.course_id + ' deleted') : ('Room removed from ' + c.course_id), 'success'); updateUndoBar(); _coursePhases = null; currentRoomData.course = null; loadRoomCourseData(currentRoomData.room.id); var content = document.querySelector('.panel-tab-content'); if (content && currentPanelTab === 7) content.innerHTML = renderCourseTab(); }).catch(function(e) { notify('Detach failed: ' + e.message, 'error'); }); } // ---- attach / new course ------------------------------------------------ function addRoomToCourse(courseID) { if (!currentRoomData || !currentRoomData.room) return; var roomID = currentRoomData.room.id; var dirSel = document.querySelector('#courseAttachDir'); var dir = dirSel ? dirSel.value : ''; fetch('/api/rooms/' + roomID + '/courses/attach', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ course_id: courseID, after_index: 999, direction: dir }), credentials: 'same-origin' }).then(function(r) { return r.json(); }).then(function(res) { if (res.error) { notify(res.error, 'error'); return; } notify('Added room #' + roomID + ' to course ' + courseID, 'success'); updateUndoBar(); _coursePhases = null; loadRoomCourseData(roomID); }).catch(function(e) { notify('Attach failed: ' + e.message, 'error'); }); } function searchCourses(input) { searchAttachShow(input, function(r) { addRoomToCourse(r.id); }); } function searchAttachShow(input, onSelect) { var val = input.value.trim(); var results = input.parentElement.querySelector('.search-results'); if (!val) { results.style.display = 'none'; results.innerHTML = ''; return; } API.get('/api/search?q=' + encodeURIComponent(val)).then(function(data) { var items = data.courses || []; if (items.length === 0) { results.style.display = 'block'; results.innerHTML = '
No results
'; return; } results.style.display = 'block'; results.innerHTML = items.map(function(r) { return '
' + esc(r.id) + ' ' + esc(r.name || '') + '
'; }).join(''); results._items = items; results._onSelect = onSelect; }).catch(function() { results.style.display = 'none'; }); } function selectCourseResult(el) { var results = el.parentElement; var idx = Array.prototype.indexOf.call(results.children, el); if (results._items && results._items[idx] && results._onSelect) { results._onSelect(results._items[idx]); results.style.display = 'none'; results.innerHTML = ''; var input = results.parentElement.querySelector('.search-input'); if (input) input.value = ''; } } function courseShowNew() { if (!currentRoomData || !currentRoomData.room) return; var roomID = currentRoomData.room.id; var overlay = document.createElement('div'); overlay.className = 'cm-overlay exit-overlay'; var menu = document.createElement('div'); menu.className = 'cm-menu exit-modal'; menu.style.cssText = 'position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);min-width:520px;max-width:90vw;max-height:85vh;overflow-y:auto;overflow-x:hidden;padding:14px;z-index:201'; var h = '

New Course (room #' + roomID + ' = step 1)

'; h += '
'; h += '
'; h += '
'; h += '
'; h += '
'; h += '
'; h += '
'; h += '
'; h += ''; h += ''; h += '
'; h += '
'; h += '
'; h += '
'; h += ''; h += ''; h += '
'; menu.innerHTML = h; document.body.appendChild(overlay); document.body.appendChild(menu); overlay.onclick = courseCloseModal; } function courseCloseModal() { var ov = document.querySelector('.exit-overlay'); if (ov) ov.remove(); var m = document.querySelector('.exit-modal'); if (m) m.remove(); } function courseCreateConfirm() { if (!currentRoomData || !currentRoomData.room) return; var roomID = currentRoomData.room.id; var id = val('#newCourseID'); if (!id) { notify('Course ID required', 'error'); return; } var name = val('#newCourseName'); var reqLevel = parseInt(val('#newCourseReqLevel')) || 0; var startRoom = parseInt(val('#newCourseStartRoom')) || 0; var completionXP = parseInt(val('#newCourseCompletionXP')) || 0; var verb = sel('#newCourseVerb'); var xp = parseInt(val('#newCourseXP')) || 0; var failMin = parseInt(val('#newCourseFailMin')) || 0; var failMax = parseInt(val('#newCourseFailMax')) || 0; var fcStr = val('#newCourseFailChance'); var fc = fcStr === '' ? null : parseFloat(fcStr); if (fc !== null) { if (fc < 0) fc = 0; if (fc > 100) fc = 100; fc = fc / 100; } var msg = val('#newCourseMsg') || ''; var obstacle = { room_id: roomID, verb: verb, xp: xp, fail_damage: [failMin, failMax], phases: [{ message: msg, delay: 0 }] }; if (fc !== null) obstacle.fail_chance = fc; var body = { id: id, name: name, required_level: reqLevel, start_room: startRoom, completion_xp: completionXP, obstacles: [obstacle] }; API.post('/api/courses', body).then(function(res) { if (res.error) { notify(res.error, 'error'); return; } notify('Course ' + id + ' created', 'success'); updateUndoBar(); courseCloseModal(); _coursePhases = null; loadRoomCourseData(roomID); }).catch(function(e) { notify('Create failed: ' + extractError(e), 'error'); }); } // tiny helpers used by the course tab function val(sel) { var el = document.querySelector(sel); return el ? el.value : ''; } function sel(sel) { var el = document.querySelector(sel); return el ? el.value : ''; } function searchHazards(input) { searchAndShow(input, 'hazards', function(r) { addHazard(r.id); }); } function addHazard(id) { if (!currentRoomData || !currentRoomData.room) return; currentRoomData.room.hazard = id; saveNow(); reRenderPanel(); } function clearHazard() { if (!currentRoomData || !currentRoomData.room) return; currentRoomData.room.hazard = ''; saveNow(); reRenderPanel(); } function updateRoomBlockTransport() { if (!currentRoomData || !currentRoomData.room) return; currentRoomData.room.block_transport = $('#roomBlockTransport').checked; saveNow(); } function getRoomDirFromFile(file) { var rel = (file || '').replace(/^data\/rooms\/?/, ''); var idx = rel.indexOf('/'); if (idx >= 0) { var subIdx = rel.indexOf('/', idx + 1); if (subIdx >= 0) return rel.substring(0, subIdx); return rel.substring(0, idx); } return ''; } function switchPanelTab(idx) { currentPanelTab = idx; if (!currentRoomData) return; var r = currentRoomData.room; var file = currentRoomData.file || ''; var objects = Array.isArray(r.objects) ? r.objects : []; var localObjs = objects.filter(function(o) { return !o.id; }); var refObjs = objects.filter(function(o) { return !!o.id; }); var spawns = Array.isArray(r.item_spawns) ? r.item_spawns : []; var mobs = Array.isArray(r.mobs) ? r.mobs : []; var html = ''; if (idx === 0) html = renderRoomTab(r, file); else if (idx === 1) html = renderExitsTab(r); else if (idx === 2) html = renderLocalTab(localObjs); else if (idx === 3) html = renderObjectsTab(refObjs); else if (idx === 4) html = renderMobsTab(mobs); else if (idx === 5) html = renderItemsTab(spawns); else if (idx === 6) html = renderHazardTab(r); else if (idx === 7) { _coursePhases = null; if (currentRoomData.course === undefined) { html = renderCourseTabLoading(); loadRoomCourseData(r.id); } else if (currentRoomData.course === null) { html = renderCourseTabEmpty(); } else { html = renderCourseTab(); } } var content = document.querySelector('.panel-tab-content'); if (content) content.innerHTML = html; var tabs = document.querySelectorAll('.panel-tab'); tabs.forEach(function(t, i) { t.classList.toggle('active', i === idx); }); if (idx === 0) { setTimeout(function() { var cf = document.querySelector('#panelContent .color-field'); if (cf) bindColorField(cf); loadRoomDirs(function(dirs) { var sel = $('#roomDir'); if (!sel) return; sel.innerHTML = ''; var current = getRoomDirFromFile(file); dirs.forEach(function(d) { var opt = document.createElement('option'); opt.value = d; opt.textContent = d || '(root)'; if (d === current) opt.selected = true; sel.appendChild(opt); }); }); }, 50); } } function renderLocalTab(localObjs) { var h = ''; localObjs.forEach(function(obj, i) { var descText = ''; if (Array.isArray(obj.description)) { descText = obj.description.map(function(d) { return d.text || ''; }).join('\n'); } else if (typeof obj.description === 'string') { descText = obj.description; } h += '
'; h += '
'; h += ''; h += ''; h += '
'; h += ''; h += ''; h += '
'; h += '
'; h += ''; h += '
'; }); h += ''; return h; } function renderObjectsTab(refObjs) { var h = ''; refObjs.forEach(function(obj, i) { h += '
'; h += '
' + esc(obj.id || '') + '
'; h += ''; h += ''; h += '
'; }); h += ''; return h; } function renderMobsTab(mobs) { var h = ''; mobs.forEach(function(mob, i) { h += '
'; h += '
' + esc(mob.id || '') + '
'; h += ''; h += ''; h += '
'; }); h += ''; return h; } function renderItemsTab(spawns) { var h = ''; spawns.forEach(function(spawn, i) { h += '
'; h += '
'; h += '
'; h += '' + esc(spawn.id || '') + ''; h += '
'; h += '
'; h += '
'; h += '
'; h += ''; h += ''; h += '
'; }); h += ''; return h; } function renderExitsSection(r) { var exits = r.exits || {}; var exitDirs = ['north','south','east','west','northeast','northwest','southeast','southwest','up','down']; var h = '

Exits

'; exitDirs.forEach(function(d) { var exit = exits[d]; if (!exit) return; var target = typeof exit === 'object' ? exit.room : exit; var cond = (typeof exit === 'object' && exit.condition) ? exit.condition : null; var bmsg = (typeof exit === 'object' && exit.blocked_message) ? exit.blocked_message : ''; var setFlags = (typeof exit === 'object' && exit.set_flags) ? exit.set_flags : null; var setPlayerFlags = (typeof exit === 'object' && exit.set_player_flags) ? exit.set_player_flags : null; var hidden = (typeof exit === 'object' && exit.hidden) ? exit.hidden : false; var alwaysBlocked = (typeof exit === 'object' && exit.always_blocked) ? exit.always_blocked : false; var isConditional = !!(cond || bmsg || setFlags || setPlayerFlags || hidden || alwaysBlocked); var isBidi = mapData && mapData.links && mapData.links.some(function(l) { return l.bidirectional && ((l.from === r.id && l.to === target) || (l.from === target && l.to === r.id)); }); h += '
'; h += '' + d + ''; h += '# ' + target + ''; if (alwaysBlocked) h += 'Impassable'; if (bmsg) h += 'Blocked: ' + esc(bmsg) + ''; if (cond) h += 'Conditional'; if (hidden) h += 'Hidden'; h += ''; if (isBidi) h += ''; h += ''; h += '
'; }); h += '
'; h += ''; h += ''; h += ''; h += ''; h += '
'; h += '
'; return h; } var editingExitDir = null; function editExitCondition(dir) { editingExitDir = dir; var exits = (currentRoomData && currentRoomData.room && currentRoomData.room.exits) || {}; var exit = exits[dir]; if (!exit) return; if (typeof exit !== 'object') { exit = {room: exit}; } var cond = exit.condition || null; var bmsg = exit.blocked_message || ''; var setFlags = exit.set_flags || null; var setPlayerFlags = exit.set_player_flags || null; var hidden = exit.hidden || false; var alwaysBlocked = exit.always_blocked || false; var flagsStr = ''; if (setFlags) flagsStr = Object.keys(setFlags).map(function(k) { return k + '=' + setFlags[k]; }).join(', '); var pflagsStr = ''; if (setPlayerFlags) pflagsStr = Object.keys(setPlayerFlags).map(function(k) { return k + '=' + setPlayerFlags[k]; }).join(', '); var parsed = parseCondition(cond); var overlay = document.createElement('div'); overlay.className = 'cm-overlay exit-overlay'; var menu = document.createElement('div'); menu.className = 'cm-menu exit-modal'; menu.style.cssText = 'position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);min-width:520px;max-width:90vw;max-height:85vh;overflow-y:auto;overflow-x:hidden;padding:14px;z-index:201'; var h = '

Exit: ' + esc(dir) + ' → #' + exit.room + '

'; h += '
'; h += '
'; h += '
'; h += '
'; h += '
'; h += '
'; h += ''; h += '
'; h += ''; h += '
'; h += ''; h += ''; h += ''; h += '
'; menu.innerHTML = h; document.body.appendChild(overlay); document.body.appendChild(menu); overlay.onclick = closeExitModal; menu._condClauses = parsed.clauses; menu._condMode = parsed.mode; renderConditionClauses(menu); } function parseCondition(cond) { var clauses = []; if (!cond || Object.keys(cond).length === 0) return {clauses: clauses, mode: 'all'}; var arr = cond.all_of; if (!arr || !Array.isArray(arr) || arr.length === 0) arr = cond.any_of; if (arr && Array.isArray(arr) && arr.length > 0) { arr.forEach(function(c) { var clause = extractClause(c); if (clause) clauses.push(clause); }); return {clauses: clauses, mode: 'all'}; } var clause = extractClause(cond); if (clause) clauses.push(clause); return {clauses: clauses, mode: 'all'}; } function extractClause(c) { var not = c.not === true; if (c.player_flag && c.player_flag !== '') return {type: 'player_flag', value: c.player_flag, not: not}; if (c.flag && c.flag !== '') return {type: 'flag', value: c.flag, not: not}; if (c.has_item && c.has_item !== '') return {type: 'has_item', value: c.has_item, not: not}; if (c.min_credits && c.min_credits !== 0) return {type: 'min_credits', value: String(c.min_credits), not: not}; if (c.value && typeof c.value === 'object' && c.value.key) return {type: 'value', value: c.value.key + '=' + (c.value.value || ''), not: not}; return null; } function renderConditionClauses(menu) { var container = menu.querySelector('#condClauses'); if (!container) return; var clauses = menu._condClauses || []; var h = ''; clauses.forEach(function(clause, i) { h += '
'; h += ''; if (clause.type === 'value') { var kv = clause.value ? clause.value.split('=', 2) : ['','']; var kn = (kv[0] || '').trim(); var kvv = (kv[1] || '').trim(); h += ''; h += '='; h += ''; } else { h += ''; } h += ''; h += ''; h += '
'; }); container.innerHTML = h; } function updateClauseData(el) { var clauseEl = el.closest('.cond-clause'); var menu = el.closest('.exit-modal'); if (!clauseEl || !menu) return; var idx = parseInt(clauseEl.getAttribute('data-idx')); var clause = menu._condClauses[idx]; if (!clause) return; clause.type = clauseEl.querySelector('.cond-type').value; clause.not = clauseEl.querySelector('.cond-not').checked; if (clause.type === 'value') { var k = clauseEl.querySelector('.cond-val-key'); var v = clauseEl.querySelector('.cond-val-val'); clause.value = (k ? k.value.trim() : '') + '=' + (v ? v.value.trim() : ''); } else { var val = clauseEl.querySelector('.cond-val'); clause.value = val ? val.value : ''; } } function addConditionClause() { var menu = document.querySelector('.exit-modal'); if (!menu) return; if (!menu._condClauses) menu._condClauses = []; menu._condClauses.push({type: 'player_flag', value: '', not: false}); renderConditionClauses(menu); } function removeConditionClause(btn) { var menu = btn.closest('.exit-modal'); var clauseEl = btn.closest('.cond-clause'); if (!menu || !clauseEl) return; var idx = parseInt(clauseEl.getAttribute('data-idx')); menu._condClauses.splice(idx, 1); renderConditionClauses(menu); } function closeExitModal() { editingExitDir = null; var ov = document.querySelector('.exit-overlay'); if (ov) ov.remove(); var m = document.querySelector('.exit-modal'); if (m) m.remove(); } function saveExitCondition() { if (!editingExitDir || !currentRoomData || !currentRoomData.room) return; var menu = document.querySelector('.exit-modal'); if (!menu) return; var r = currentRoomData.room; var exits = r.exits || {}; var exit = exits[editingExitDir]; if (!exit) return; if (typeof exit !== 'object') { exit = {room: exit}; exits[editingExitDir] = exit; r.exits = exits; } var bmsg = document.getElementById('exitBmsg'); exit.blocked_message = (bmsg && bmsg.value) ? bmsg.value : ''; var hiddenEl = document.getElementById('exitHidden'); exit.hidden = hiddenEl ? hiddenEl.checked : false; var alwaysBlockedEl = document.getElementById('exitAlwaysBlocked'); exit.always_blocked = alwaysBlockedEl ? alwaysBlockedEl.checked : false; var flagsStr = document.getElementById('exitFlags'); var pflagsStr = document.getElementById('exitPFlags'); exit.set_flags = parseFlagInput(flagsStr ? flagsStr.value : ''); exit.set_player_flags = parseFlagInput(pflagsStr ? pflagsStr.value : ''); var clauseEls = menu.querySelectorAll('.cond-clause'); clauseEls.forEach(function(el) { updateClauseData(el); }); var clauses = (menu._condClauses || []).filter(function(c) { return c.value && c.value !== '' && c.value !== '='; }); var condition = null; if (clauses.length === 1) { condition = buildSingleCondition(clauses[0]); } else if (clauses.length > 1) { condition = {all_of: clauses.map(function(c) { return buildSingleCondition(c); })}; } exit.condition = condition; r.exits = exits; autoSave(); closeExitModal(); reRenderPanel(); } function buildSingleCondition(clause) { var obj = {}; if (clause.not) obj.not = true; if (clause.type === 'value') { var parts = (clause.value || '').split('=', 2); obj.value = {key: (parts[0] || '').trim(), value: (parts[1] || '').trim()}; } else { obj[clause.type] = clause.value; } return obj; } function clearExitCondition() { var menu = document.querySelector('.exit-modal'); if (!menu) return; menu._condClauses = []; renderConditionClauses(menu); var bmsg = document.getElementById('exitBmsg'); if (bmsg) bmsg.value = ''; var ef = document.getElementById('exitFlags'); if (ef) ef.value = ''; var pf = document.getElementById('exitPFlags'); if (pf) pf.value = ''; var hx = document.getElementById('exitHidden'); if (hx) hx.checked = false; var ab = document.getElementById('exitAlwaysBlocked'); if (ab) ab.checked = false; } function parseFlagInput(val) { if (!val || !val.trim()) return null; var m = {}; val.split(',').forEach(function(pair) { var kv = pair.trim().split('='); if (kv.length === 2) { var v = kv[1].trim(); if (v === 'true') v = true; else if (v === 'false') v = false; else if (!isNaN(v)) v = parseFloat(v); m[kv[0].trim()] = v; } }); return Object.keys(m).length > 0 ? m : null; } function addExit() { if (!selectedRoom || !currentRoomData) return; var dir = $('#exitDirSelect').value; var target = parseInt($('#exitTargetID').value); if (!dir || !target) { notify('Enter target room ID', 'error'); return; } var reciprocalEl = $('#exitReciprocal'); var reciprocal = reciprocalEl ? reciprocalEl.checked : true; var body = {from: selectedRoom, to: target, dir: dir}; if (!reciprocal) body.oneway = true; API.post('/api/rooms/link', body).then(function(res) { if (res && res.error) { notify('Add exit failed: ' + res.error, 'error'); return; } notify('Exit added: ' + dir + ' #' + target + (reciprocal ? '' : ' (one-way)'), 'success'); updateUndoBar(); loadMap().then(function() { selectRoom(selectedRoom); }); }).catch(function(e) { notify('Add exit failed: ' + extractError(e), 'error'); }); } function deleteExit(dir, target) { if (!selectedRoom || !currentRoomData) return; fetch('/api/rooms/link', { method: 'DELETE', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({from: selectedRoom, to: target}), credentials: 'same-origin' }).then(function(r) { return r.json().then(function(j) { if (!r.ok) throw new Error(j.error || 'request failed'); if (j.error) throw new Error(j.error); return j; }); }).then(function() { notify('Removed exit ' + dir + ' to #' + target, 'success'); updateUndoBar(); loadMap().then(function() { selectRoom(selectedRoom); }); }).catch(function(e) { notify('Delete exit failed: ' + extractError(e), 'error'); }); } function toggleExitHidden(dir) { if (!currentRoomData || !currentRoomData.room) return; var exits = currentRoomData.room.exits || {}; var exit = exits[dir]; if (!exit) return; if (typeof exit !== 'object') { exit = {room: exit}; exits[dir] = exit; currentRoomData.room.exits = exits; } exit.hidden = !exit.hidden; autoSave(); reRenderPanel(); } function extractError(e) { var msg = e.message || 'unknown error'; try { var j = JSON.parse(msg); if (j.error) msg = j.error; } catch(_) {} return msg; } function getRoomObjects() { if (!currentRoomData || !currentRoomData.room) return []; var objects = currentRoomData.room.objects || []; if (!Array.isArray(objects)) return []; return objects; } function setRoomObjects(objs) { if (!currentRoomData || !currentRoomData.room) return; currentRoomData.room.objects = objs; saveNow(); } function getRoomSpawns() { if (!currentRoomData || !currentRoomData.room) return []; var spawns = currentRoomData.room.item_spawns || []; if (!Array.isArray(spawns)) return []; return spawns; } function setRoomSpawns(spawns) { if (!currentRoomData || !currentRoomData.room) return; currentRoomData.room.item_spawns = spawns; saveNow(); } function getRoomMobs() { if (!currentRoomData || !currentRoomData.room) return []; var mobs = currentRoomData.room.mobs || []; if (!Array.isArray(mobs)) return []; return mobs; } function setRoomMobs(mobs) { if (!currentRoomData || !currentRoomData.room) return; currentRoomData.room.mobs = mobs; saveNow(); } function addLocalObject() { var objs = getRoomObjects(); objs.push({name: '', description: [{text: ''}], aliases: [], hidden: false}); setRoomObjects(objs); reRenderPanel(); } function updateLocalObject(i) { var objs = getRoomObjects(); var localObjs = objs.filter(function(o) { return !o.id; }); if (i < 0 || i >= localObjs.length) return; var row = document.querySelector('.panel-tab-content .entry-row[data-idx="' + i + '"]'); if (!row) return; var nameEl = row.querySelector('.local-name'); var descEl = row.querySelector('.local-desc'); var aliasesEl = row.querySelector('.local-aliases'); var hiddenEl = row.querySelector('.local-hidden'); localObjs[i].name = nameEl ? nameEl.value : ''; var descVal = descEl ? descEl.value : ''; if (descVal) { localObjs[i].description = descVal.split('\n').map(function(line) { return {text: line}; }); } else { localObjs[i].description = []; } if (aliasesEl && aliasesEl.value) { localObjs[i].aliases = aliasesEl.value.split(',').map(function(s) { return s.trim(); }).filter(function(s) { return s; }); } else { localObjs[i].aliases = []; } localObjs[i].hidden = hiddenEl ? hiddenEl.checked : false; var refObjs = objs.filter(function(o) { return !!o.id; }); setRoomObjects(localObjs.concat(refObjs)); } function removeLocalObject(i) { var objs = getRoomObjects(); var localObjs = objs.filter(function(o) { return !o.id; }); if (i < 0 || i >= localObjs.length) return; localObjs.splice(i, 1); var refObjs = objs.filter(function(o) { return !!o.id; }); setRoomObjects(localObjs.concat(refObjs)); reRenderPanel(); } function removeRefObject(i) { var objs = getRoomObjects(); var refObjs = objs.filter(function(o) { return !!o.id; }); if (i < 0 || i >= refObjs.length) return; refObjs.splice(i, 1); var localObjs = objs.filter(function(o) { return !o.id; }); setRoomObjects(localObjs.concat(refObjs)); reRenderPanel(); } function addRefObject(id) { var objs = getRoomObjects(); objs.push({id: id}); setRoomObjects(objs); reRenderPanel(); } function searchRefObjects(input) { searchAndShow(input, 'objects', function(r) { addRefObject(r.id); }); } function removeSpawn(i) { var spawns = getRoomSpawns(); spawns.splice(i, 1); setRoomSpawns(spawns); reRenderPanel(); } function updateSpawn(i) { var spawns = getRoomSpawns(); if (i < 0 || i >= spawns.length) return; var row = document.querySelector('.panel-tab-content .entry-row[data-idx="' + i + '"]'); if (!row) return; var qtyEl = row.querySelector('.spawn-qty'); var respawnEl = row.querySelector('.spawn-respawn'); if (qtyEl) spawns[i].quantity = parseInt(qtyEl.value) || 0; if (respawnEl) spawns[i].respawn_ticks = parseInt(respawnEl.value) || 0; setRoomSpawns(spawns); } function addSpawn(id) { var spawns = getRoomSpawns(); spawns.push({id: id, quantity: 1, respawn_ticks: 0}); setRoomSpawns(spawns); reRenderPanel(); } function searchItemSpawns(input) { searchAndShow(input, 'items', function(r) { addSpawn(r.id); }); } function removeMob(i) { var mobs = getRoomMobs(); mobs.splice(i, 1); setRoomMobs(mobs); reRenderPanel(); } function addMob(id) { var mobs = getRoomMobs(); mobs.push({id: id}); setRoomMobs(mobs); reRenderPanel(); } function searchMobs(input) { searchAndShow(input, 'mobs', function(r) { addMob(r.id); }); } function searchAndShow(input, type, onSelect) { var val = input.value.trim(); var results = input.parentElement.querySelector('.search-results'); if (!val) { results.style.display = 'none'; results.innerHTML = ''; results._selectedIndex = -1; return; } API.get('/api/search?q=' + encodeURIComponent(val)).then(function(data) { var items = []; if (type === 'objects' && data.objects) items = data.objects; if (type === 'items' && data.items) items = data.items; if (type === 'mobs' && data.mobs) items = data.mobs; if (type === 'hazards' && data.hazards) items = data.hazards; if (items.length === 0) { results.style.display = 'block'; results.innerHTML = '
No results
'; return; } results.style.display = 'block'; results.innerHTML = items.map(function(r) { return '
' + esc(r.id) + ' ' + esc(r.name || '') + '
'; }).join(''); results._items = items; results._onSelect = onSelect; results._selectedIndex = -1; }).catch(function() { results.style.display = 'none'; }); input._searchKeySetup = input._searchKeySetup || (function() { input.addEventListener('keydown', function(e) { if (results.style.display === 'none' || !results.children.length) return; if (results._selectedIndex === undefined) results._selectedIndex = -1; if (e.key === 'ArrowDown') { e.preventDefault(); results._selectedIndex = Math.min(results._selectedIndex + 1, results.children.length - 1); updateSearchHighlight(results); } else if (e.key === 'ArrowUp') { e.preventDefault(); results._selectedIndex = Math.max(results._selectedIndex - 1, 0); updateSearchHighlight(results); } else if (e.key === 'Enter') { if (results._selectedIndex >= 0) { e.preventDefault(); selectSearchResult(results.children[results._selectedIndex]); } } else if (e.key === 'Escape') { results.style.display = 'none'; results._selectedIndex = -1; } }); return true; })(); } function selectSearchResult(el) { var results = el.parentElement; var idx = Array.prototype.indexOf.call(results.children, el); if (results._items && results._items[idx] && results._onSelect) { results._onSelect(results._items[idx]); results.style.display = 'none'; results.innerHTML = ''; var input = results.parentElement.querySelector('.search-input'); if (input) input.value = ''; } } function updateSearchHighlight(results) { for (var i = 0; i < results.children.length; i++) { results.children[i].classList.toggle('active', i === results._selectedIndex); if (i === results._selectedIndex) results.children[i].scrollIntoView({block: 'nearest'}); } } function openEditor(type, id) { window.open('/editor/' + type + '#' + id, '_blank'); } function reRenderPanel() { if (!currentRoomData) return; renderSidePanel(currentRoomData); } function autoSave() { if (window._colorPickerActive) return; if (saveTimer) clearTimeout(saveTimer); saveTimer = setTimeout(function() { doSavePanel(); }, 600); } function saveNow() { if (window._colorPickerActive) return; if (saveTimer) clearTimeout(saveTimer); saveTimer = null; doSavePanel(); } async function doSavePanel() { var id = selectedRoom; if (!id) return; try { var data = await API.get('/api/rooms/' + id); var r = data.room || {}; r.id = id; var el = $('#roomName'); if (el) r.name = el.value; el = $('#roomColor'); if (el) r.color = el.value; el = $('#roomDesc'); if (el && el.value) r.description = [{text: el.value}]; el = $('#roomBlockTransport'); if (el) r.block_transport = el.checked; if (currentRoomData && currentRoomData.room) { r.objects = currentRoomData.room.objects; r.item_spawns = currentRoomData.room.item_spawns; r.mobs = currentRoomData.room.mobs; r.exits = currentRoomData.room.exits; r.hazard = currentRoomData.room.hazard || ''; if (r.block_transport === undefined) r.block_transport = currentRoomData.room.block_transport; } el = $('#roomID'); var newID = el ? parseInt(el.value) || id : id; var dirEl = $('#roomDir'); var newDir = dirEl ? dirEl.value : null; var currentDir = (data.file || '').replace(/^data\/rooms\/?/, '').split('/')[0] || ''; if (dirEl && newDir !== currentDir) { await API.post('/api/rooms/move', {id: id, dir: newDir}); } if (newID !== id) { await API.post('/api/rooms/rename', {id: id, new_id: newID}); selectedRoom = newID; id = newID; } await API.put('/api/rooms/' + id, r); notify('Room #' + id + ' saved', 'success'); updateUndoBar(); await loadMap(); selectRoom(id); } catch (e) { notify('Save failed: ' + extractError(e), 'error'); } } function deleteRoom(id) { var cb = document.getElementById('confirmDelete'); if (!cb || cb.checked) { if (!confirm('Delete room #' + id + '?')) return; } if (mapData && mapData.rooms) { var neighbors = []; if (mapData.links) { mapData.links.forEach(function(l) { if (l.from === id && l.to !== id) neighbors.push(l.to); if (l.to === id && l.from !== id) neighbors.push(l.from); }); } if (mapData.upLinks) { mapData.upLinks.forEach(function(l) { if (l.from === id && l.to !== id) neighbors.push(l.to); if (l.to === id && l.from !== id) neighbors.push(l.from); }); } if (mapData.downLinks) { mapData.downLinks.forEach(function(l) { if (l.from === id && l.to !== id) neighbors.push(l.to); if (l.to === id && l.from !== id) neighbors.push(l.from); }); } if (neighbors.length > 1) { var remaining = mapData.rooms.filter(function(r) { return r.id !== id; }); var adj = {}; remaining.forEach(function(r) { adj[r.id] = []; }); if (mapData.links) { mapData.links.forEach(function(l) { if (l.from === id || l.to === id) return; if (adj[l.from]) adj[l.from].push(l.to); if (adj[l.to]) adj[l.to].push(l.from); }); } if (mapData.upLinks) { mapData.upLinks.forEach(function(l) { if (l.from === id || l.to === id) return; if (adj[l.from]) adj[l.from].push(l.to); if (adj[l.to]) adj[l.to].push(l.from); }); } if (mapData.downLinks) { mapData.downLinks.forEach(function(l) { if (l.from === id || l.to === id) return; if (adj[l.from]) adj[l.from].push(l.to); if (adj[l.to]) adj[l.to].push(l.from); }); } if (remaining.length > 0) { var visited = {}, queue = [remaining[0].id]; visited[remaining[0].id] = true; while (queue.length > 0) { var cur = queue.shift(); (adj[cur] || []).forEach(function(n) { if (!visited[n]) { visited[n] = true; queue.push(n); } }); } if (Object.keys(visited).length < remaining.length) { if (!confirm('Deleting room #'+id+' will split the map into disconnected areas. Continue?')) return; } } } } API.del('/api/rooms/' + id).then(function() { notify('Room #' + id + ' deleted', 'success'); updateUndoBar(); selectedRoom = null; $('#panelContent').innerHTML = '

Room deleted

'; loadMap(); }).catch(function(e) { notify('Delete failed: ' + extractError(e), 'error'); }); } function createRoom(fromID, dir, oneway) { API.get('/api/next-room-id?from=' + fromID).then(function(r) { var newID = r.id; var name = 'Room #' + newID; var body = { name: name, link_from: fromID, link_dir: dir }; if (oneway) body.link_oneway = true; var srcRoom = roomMap[fromID]; if (srcRoom && srcRoom.color) body.color = srcRoom.color; API.post('/api/rooms', body).then(function(resp) { var createdId = (resp && resp.room && resp.room.id) || newID; notify('Room #' + createdId + ' created', 'success'); updateUndoBar(); loadMap().then(function() { selectRoom(createdId); }); }).catch(function(e) { notify('Create failed: ' + extractError(e), 'error'); }); }); } function showRoomContextMenu(x, y, id) { var overlay = document.createElement('div'); overlay.className = 'cm-overlay'; // Closing the menu also kills any open submenu and pending hide timer. var closeAll = function() { if (overlay._subHideTimer) { clearTimeout(overlay._subHideTimer); overlay._subHideTimer = null; } overlay.remove(); menu.remove(); }; overlay.onclick = closeAll; overlay.addEventListener('contextmenu', function(e) { e.preventDefault(); e.stopPropagation(); var cx = e.clientX, cy = e.clientY; closeAll(); var el = document.elementFromPoint(cx, cy); while (el && el !== document.body && !(el.classList && el.classList.contains('rm'))) { el = el.parentElement; } if (el && el.classList && el.classList.contains('rm')) { var rid = parseInt(el.getAttribute('data-id')); if (rid) showRoomContextMenu(cx, cy, rid); } }); var menu = document.createElement('div'); menu.className = 'cm-menu'; menu.style.left = x + 'px'; menu.style.top = y + 'px'; menu.oncontextmenu = function(e) { e.preventDefault(); }; var delBtn = document.createElement('button'); delBtn.textContent = 'Delete Room'; delBtn.className = 'danger'; delBtn.onclick = function() { closeAll(); deleteRoom(id); }; menu.appendChild(delBtn); menu.appendChild(buildSubmenuItem('Insert Room', id, 'insert', closeAll, overlay, menu)); menu.appendChild(buildSubmenuItem('Remove Room', id, 'remove', closeAll, overlay, menu)); var selBtn = document.createElement('button'); selBtn.textContent = 'Room Details'; selBtn.onclick = function() { closeAll(); selectRoom(id); }; menu.appendChild(selBtn); document.body.appendChild(overlay); document.body.appendChild(menu); // Clamp inside the viewport so the menu never spills off-screen. clampMenuToViewport(menu); } // buildSubmenuItem constructs a parent menu row ("Insert Room"/"Remove Room") // that carries a nested submenu of the room's horizontal exits. The submenu is // revealed by both hover (Windows-style: mouseenter with a small dismiss delay // on mouseleave) and click (keyboard/mobile accessibility), mirroring how // Windows cascading menus behave. closeAll closes the whole context menu; // overlay carries the shared hide-timer slot. function buildSubmenuItem(label, id, mode, closeAll, overlay, menu) { var dirs = roomHorizontalDirs(id); var empty = dirs.length === 0; var item = document.createElement('div'); item.className = 'cm-item-has-sub' + (empty ? ' disabled' : ''); var btn = document.createElement('button'); btn.textContent = label; var arrow = document.createElement('span'); arrow.className = 'cm-arrow'; arrow.textContent = '\u25B6'; item.appendChild(btn); item.appendChild(arrow); if (empty) { // Still surface the "no exits" reason on click so it's discoverable. btn.onclick = function() { notify(mode === 'insert' ? 'No horizontal exits to insert into from #' + id : 'No horizontal exits to remove from #' + id, 'error'); }; return item; } var sub = document.createElement('div'); sub.className = 'cm-submenu'; var title = document.createElement('div'); title.className = 'cm-title'; title.textContent = mode === 'insert' ? 'Insert Room' : 'Remove Room'; sub.appendChild(title); dirs.forEach(function(d) { var opt = document.createElement('button'); opt.textContent = dirLabel(d); (function(dir) { opt.onclick = function(e) { e.stopPropagation(); closeAll(); if (mode === 'insert') insertRoom(id, dir); else removeRoom(id, dir); }; })(d); sub.appendChild(opt); }); item.appendChild(sub); var open = false; function showSub() { if (overlay._subHideTimer) { clearTimeout(overlay._subHideTimer); overlay._subHideTimer = null; } // Only one submenu open at a time. var sibs = menu.querySelectorAll('.cm-submenu.open'); sibs.forEach(function(s) { if (s !== sub) { s.classList.remove('open'); s.classList.remove('flip-left'); s.classList.remove('flip-up'); } }); sub.classList.add('open'); flipSubmenu(sub); open = true; } function hideSubSoon() { if (overlay._subHideTimer) clearTimeout(overlay._subHideTimer); overlay._subHideTimer = setTimeout(function() { sub.classList.remove('open'); sub.classList.remove('flip-left'); sub.classList.remove('flip-up'); open = false; overlay._subHideTimer = null; }, 250); } item.addEventListener('mouseenter', showSub); item.addEventListener('mouseleave', hideSubSoon); // Keep the submenu alive while the cursor is inside it (crosses the gap). sub.addEventListener('mouseenter', function() { if (overlay._subHideTimer) { clearTimeout(overlay._subHideTimer); overlay._subHideTimer = null; } }); sub.addEventListener('mouseleave', hideSubSoon); btn.onclick = function(e) { e.stopPropagation(); if (open) { sub.classList.remove('open'); sub.classList.remove('flip-left'); sub.classList.remove('flip-up'); open = false; } else { showSub(); } }; return item; } // roomHorizontalDirs returns the room's outward horizontal exits on the current // z-level, in gridDirs order, derived from mapData.links. Empty when the room // has no horizontal exits to insert into / remove from. function roomHorizontalDirs(id) { if (!mapData || !mapData.links) return []; var horizontal = {}; gridDirs.forEach(function(d) { horizontal[d.dir] = true; }); var seen = {}; var dirs = []; mapData.links.forEach(function(l) { if (l.from === id && horizontal[l.dir] && !seen[l.dir]) { seen[l.dir] = true; dirs.push(l.dir); } else if (l.to === id && l.bidirectional && horizontal[l.dir]) { var opp = oppositeDir[l.dir]; if (opp && !seen[opp]) { seen[opp] = true; dirs.push(opp); } } }); var ordered = []; gridDirs.forEach(function(d) { if (seen[d.dir]) ordered.push(d.dir); }); return ordered; } // flipSubmenu toggles flip classes so the submenu stays on-screen when the // parent is near the right or bottom edge of the viewport. function flipSubmenu(sub) { sub.classList.remove('flip-left'); sub.classList.remove('flip-up'); var rect = sub.getBoundingClientRect(); if (rect.right > window.innerWidth - 4) sub.classList.add('flip-left'); if (rect.bottom > window.innerHeight - 4) sub.classList.add('flip-up'); } // clampMenuToViewport nudges a top-level context menu inside the viewport. function clampMenuToViewport(menu) { var rect = menu.getBoundingClientRect(); if (rect.right > window.innerWidth - 4) { menu.style.left = Math.max(4, window.innerWidth - rect.width - 4) + 'px'; } if (rect.bottom > window.innerHeight - 4) { menu.style.top = Math.max(4, window.innerHeight - rect.height - 4) + 'px'; } } // dirLabel turns a direction key ("north", "northeast", ...) into a display // label ("North", "Northeast", ...). Falls back to title-casing the input. function dirLabel(d) { var labels = { north: 'North', south: 'South', east: 'East', west: 'West', northeast: 'Northeast', northwest: 'Northwest', southeast: 'Southeast', southwest: 'Southwest', up: 'Up', down: 'Down' }; return labels[d] || d.charAt(0).toUpperCase() + d.slice(1); } // insertRoom calls the admin insert endpoint to push a new room into id's dir // exit, then refreshes the map and selects the new room. The backend fills in // the default name ("Room #") when none is supplied. function insertRoom(fromID, dir) { API.post('/api/rooms/insert', { from: fromID, dir: dir, name: '' }).then(function(r) { var nid = r && r.room && r.room.id; notify('Inserted room #' + nid + ' to the ' + dirLabel(dir), 'success'); updateUndoBar(); loadMap().then(function() { if (nid) selectRoom(nid); }); }).catch(function(e) { notify('Insert failed: ' + extractError(e), 'error'); }); } // removeRoom calls the admin remove endpoint to delete id's dir exit's room // and pull the far room back, then refreshes the map. function removeRoom(fromID, dir) { if (!confirm('Remove the room to the ' + dirLabel(dir) + ' of #' + fromID + ' and pull the far end back?')) return; API.post('/api/rooms/remove', { from: fromID, dir: dir }).then(function(r) { var pulled = r && r.pulled_to; notify('Removed room; far end pulled to #' + pulled, 'success'); updateUndoBar(); loadMap().then(function() { selectRoom(fromID); }); }).catch(function(e) { notify('Remove failed: ' + extractError(e), 'error'); }); } function mouseToGrid(e) { var svgEl = $('#mapSvg'); if (!svgEl) return null; var rect = svgEl.getBoundingClientRect(); var mx = e.clientX - rect.left; var my = e.clientY - rect.top; mx = Math.max(0, Math.min(mx, rect.width - 1)); my = Math.max(0, Math.min(my, rect.height - 1)); var vb = svgEl.getAttribute('viewBox'); if (!vb) return null; var parts = vb.split(' '); var vbX = parseFloat(parts[0]), vbY = parseFloat(parts[1]); var vbW = parseFloat(parts[2]), vbH = parseFloat(parts[3]); var wx = vbX + mx * vbW / rect.width; var wy = vbY + my * vbH / rect.height; return { x: Math.floor(wx / CELL), y: Math.floor(wy / CELL) }; } function gridDeltaToDir(dx, dy) { for (var i = 0; i < gridDirs.length; i++) { if (gridDirs[i].dx === dx && gridDirs[i].dy === dy) return gridDirs[i].dir; } return null; } function updateLinkDragTarget(e) { clearLinkHighlight(); linkTargetId = null; linkTargetDir = null; linkGhostDir = null; var fromRoom = mapData.rooms ? mapData.rooms.find(function(r){return r.id === linkFromId;}) : null; if (!fromRoom) return; var gp = mouseToGrid(e); if (!gp) return; var neighbor = occupied[gp.x+','+gp.y]; var gdx = gp.x - fromRoom.x; var gdy = gp.y - fromRoom.y; if (Math.abs(gdx) > 1 || Math.abs(gdy) > 1) return; if (gdx === 0 && gdy === 0) return; var dir = gridDeltaToDir(gdx, gdy); if (!dir) return; if (neighbor && neighbor !== linkFromId) { linkTargetId = neighbor; linkTargetDir = dir; linkGhostDir = null; applyLinkHighlight(linkFromId, linkTargetId); } else if (!neighbor) { linkGhostDir = dir; linkTargetId = null; applyGhostLinkHighlight(fromRoom, gp.x, gp.y); } else { linkGhostDir = null; } } function applyGhostLinkHighlight(fromRoom, gx, gy) { clearLinkHighlight(); linkTargetId = null; var fromEl = document.querySelector('.rm[data-id="'+linkFromId+'"]'); if (!fromEl) return; fromEl.setAttribute('stroke', '#0ff'); fromEl.setAttribute('stroke-width', '3'); var g = document.querySelector('#mapSvg g'); if (!g) return; var svgNS = 'http://www.w3.org/2000/svg'; var line = document.createElementNS(svgNS, 'line'); line.id = '__hl_line'; line.setAttribute('x1', fromRoom.x * CELL + CELL/2); line.setAttribute('y1', fromRoom.y * CELL + CELL/2); line.setAttribute('x2', gx * CELL + CELL/2); line.setAttribute('y2', gy * CELL + CELL/2); line.setAttribute('stroke', '#0ff'); line.setAttribute('stroke-width', '3'); line.setAttribute('stroke-dasharray', '6,3'); line.setAttribute('pointer-events', 'none'); g.appendChild(line); } function applyLinkHighlight(fromId, toId) { clearLinkHighlight(); var fromEl = document.querySelector('.rm[data-id="'+fromId+'"]'); var toEl = document.querySelector('.rm[data-id="'+toId+'"]'); var fromRoom = roomMap[fromId]; var toRoom = roomMap[toId]; if (!fromEl || !toEl || !fromRoom || !toRoom) return; fromEl.setAttribute('stroke', '#0ff'); fromEl.setAttribute('stroke-width', '3'); toEl.setAttribute('stroke', '#f0f'); toEl.setAttribute('stroke-width', '3'); var g = document.querySelector('#mapSvg g'); if (!g) return; var svgNS = 'http://www.w3.org/2000/svg'; var line = document.createElementNS(svgNS, 'line'); line.id = '__hl_line'; line.setAttribute('x1', fromRoom.x * CELL + CELL/2); line.setAttribute('y1', fromRoom.y * CELL + CELL/2); line.setAttribute('x2', toRoom.x * CELL + CELL/2); line.setAttribute('y2', toRoom.y * CELL + CELL/2); line.setAttribute('stroke', '#f0f'); line.setAttribute('stroke-width', '3'); line.setAttribute('stroke-dasharray', '6,3'); line.setAttribute('pointer-events', 'none'); g.appendChild(line); } function clearLinkHighlight() { var line = document.getElementById('__hl_line'); if (line) line.remove(); if (linkFromId) { var el = document.querySelector('.rm[data-id="'+linkFromId+'"]'); if (el) { el.setAttribute('stroke', selectedRoom === linkFromId ? '#fff' : (el.getAttribute('fill') || '#3a3a6a')); el.setAttribute('stroke-width', selectedRoom === linkFromId ? '3' : '1.5'); } } if (linkTargetId) { var el = document.querySelector('.rm[data-id="'+linkTargetId+'"]'); if (el) { el.setAttribute('stroke', selectedRoom === linkTargetId ? '#fff' : (el.getAttribute('fill') || '#3a3a6a')); el.setAttribute('stroke-width', selectedRoom === linkTargetId ? '3' : '1.5'); } } } function createLink(fromId, toId, oneway, dir) { var body = {from: fromId, to: toId}; if (oneway) body.oneway = true; if (dir) body.dir = dir; API.post('/api/rooms/link', body).then(function(res) { if (res && res.error) { notify('Link failed: ' + res.error, 'error'); return; } notify('Linked rooms #'+fromId+' '+toId + (oneway ? ' (one-way)' : ''), 'success'); updateUndoBar(); loadMap().then(function() { selectRoom(selectedRoom); }); }).catch(function(e) { notify('Link failed: ' + extractError(e), 'error'); }); } function updateDelDragTarget(e) { clearDelHighlight(); delTargetId = null; var fromRoom = mapData.rooms ? mapData.rooms.find(function(r){return r.id === delFromId;}) : null; if (!fromRoom) return; var gp = mouseToGrid(e); if (!gp) return; var neighbor = occupied[gp.x+','+gp.y]; if (!neighbor || neighbor === delFromId) return; var gdx = gp.x - fromRoom.x; var gdy = gp.y - fromRoom.y; if (Math.abs(gdx) > 1 || Math.abs(gdy) > 1) return; if (gdx === 0 && gdy === 0) return; if (!gridDeltaToDir(gdx, gdy)) return; delTargetId = neighbor; applyDelHighlight(delFromId, delTargetId); } function applyDelHighlight(fromId, toId) { clearDelHighlight(); var fromEl = document.querySelector('.rm[data-id="'+fromId+'"]'); var toEl = document.querySelector('.rm[data-id="'+toId+'"]'); var fromRoom = roomMap[fromId]; var toRoom = roomMap[toId]; if (!fromEl || !toEl || !fromRoom || !toRoom) return; fromEl.setAttribute('stroke', '#f55'); fromEl.setAttribute('stroke-width', '3'); toEl.setAttribute('stroke', '#f55'); toEl.setAttribute('stroke-width', '3'); var g = document.querySelector('#mapSvg g'); if (!g) return; var svgNS = 'http://www.w3.org/2000/svg'; var line = document.createElementNS(svgNS, 'line'); line.id = '__dl_line'; line.setAttribute('x1', fromRoom.x * CELL + CELL/2); line.setAttribute('y1', fromRoom.y * CELL + CELL/2); line.setAttribute('x2', toRoom.x * CELL + CELL/2); line.setAttribute('y2', toRoom.y * CELL + CELL/2); line.setAttribute('stroke', '#f55'); line.setAttribute('stroke-width', '3'); line.setAttribute('stroke-dasharray', '6,3'); line.setAttribute('pointer-events', 'none'); g.appendChild(line); } function clearDelHighlight() { var line = document.getElementById('__dl_line'); if (line) line.remove(); if (delFromId) { var el = document.querySelector('.rm[data-id="'+delFromId+'"]'); if (el) { el.setAttribute('stroke', selectedRoom === delFromId ? '#fff' : (el.getAttribute('fill') || '#3a3a6a')); el.setAttribute('stroke-width', selectedRoom === delFromId ? '3' : '1.5'); } } if (delTargetId) { var el = document.querySelector('.rm[data-id="'+delTargetId+'"]'); if (el) { el.setAttribute('stroke', selectedRoom === delTargetId ? '#fff' : (el.getAttribute('fill') || '#3a3a6a')); el.setAttribute('stroke-width', selectedRoom === delTargetId ? '3' : '1.5'); } } } function deleteLink(fromId, toId, side) { var body = {from: fromId, to: toId}; if (side) body.side = side; fetch('/api/rooms/link', { method: 'DELETE', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body), credentials: 'same-origin' }).then(function(r) { return r.json().then(function(j) { if (!r.ok) throw new Error(j.error || 'unlink failed'); if (j.error) throw new Error(j.error); return j; }); }).then(function() { notify('Removed link #'+fromId+' '+toId + (side ? ' (' + side + ' side only)' : ''), 'success'); updateUndoBar(); loadMap().then(function() { selectRoom(selectedRoom); }); }).catch(function(e) { notify('Delete link failed: ' + extractError(e), 'error'); }); } function renderDisconnectedList(data) { var panel = $('#disconnectedPanel'); var list = $('#disconnectedList'); if (!panel || !list) return; var dc = data.disconnected || []; if (dc.length === 0) { panel.style.display = 'none'; return; } panel.style.display = 'flex'; dc.sort(function(a, b) { return a.id - b.id; }); list.innerHTML = dc.map(function(r) { return '
' + '#' + r.id + '' + '' + esc(r.name || '') + '' + '
'; }).join(''); } function loadDisconnectedRooms(dir) { if (dir === lastDcDir) return; lastDcDir = dir; var url = '/api/map?z=' + currentZ; if (dir) url += '&dir=' + encodeURIComponent(dir); API.get(url).then(function(data) { renderDisconnectedList(data); }); } function showDcContextMenu(e, id) { var overlay = document.createElement('div'); overlay.className = 'cm-overlay'; var closeAll = function() { overlay.remove(); menu.remove(); }; overlay.onclick = closeAll; overlay.addEventListener('contextmenu', function(ev) { ev.preventDefault(); ev.stopPropagation(); var cx = ev.clientX, cy = ev.clientY; closeAll(); var el = document.elementFromPoint(cx, cy); if (el) el.dispatchEvent(new MouseEvent('contextmenu', {bubbles:true, cancelable:true, clientX:cx, clientY:cy, button:2})); }); var menu = document.createElement('div'); menu.className = 'cm-menu'; menu.style.left = e.clientX + 'px'; menu.style.top = e.clientY + 'px'; menu.oncontextmenu = function(ev) { ev.preventDefault(); }; var delBtn = document.createElement('button'); delBtn.textContent = 'Delete Room'; delBtn.className = 'danger'; delBtn.onclick = function() { closeAll(); deleteRoom(id); }; menu.appendChild(delBtn); var selBtn = document.createElement('button'); selBtn.textContent = 'Room Details'; selBtn.onclick = function() { closeAll(); selectRoom(id); }; menu.appendChild(selBtn); document.body.appendChild(overlay); document.body.appendChild(menu); } function initPanelResize() { var handle = document.createElement('div'); handle.className = 'panel-resize-handle'; var panel = $('#sidePanel'); if (!panel) return; panel.appendChild(handle); panel.style.width = Math.min(800, window.innerWidth / 3) + 'px'; var startX, startW; handle.addEventListener('mousedown', function(e) { e.preventDefault(); startX = e.clientX; startW = panel.offsetWidth; document.body.style.userSelect = 'none'; document.body.style.cursor = 'col-resize'; document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); }); function onMove(e) { var newW = startW + (startX - e.clientX); if (newW < 300) newW = 300; if (newW > 800) newW = 800; panel.style.width = newW + 'px'; updateView(); } function onUp() { document.body.style.userSelect = ''; document.body.style.cursor = ''; document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); updateView(); } } function esc(s) { if (!s) return ''; return String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); } function escAttr(s) { if (!s) return ''; return String(s).replace(/&/g,'&').replace(/"/g,'"'); } window.refreshPage = function() { loadMap().then(function() { if (selectedRoom) selectRoom(selectedRoom); }); }; function loadRoomDirs(cb) { API.get('/api/room-dirs').then(function(dirs) { var sel = $('#dirSelect'); if (sel && sel.options.length === 0) { dirs.forEach(function(d) { var opt = document.createElement('option'); opt.value = d; opt.textContent = d || '(root)'; sel.appendChild(opt); }); } updateDirSelect(); if (cb) cb(dirs); }); } function updateDirSelect() { var sel = $('#dirSelect'); if (sel && currentDir !== undefined) { sel.value = currentDir; } } function showRenameDir() { if (currentDir === '') { notify('Cannot rename root', 'error'); return; } hideNewDir(); $('#renameDirBtn').classList.add('active'); var form = $('#renameDirForm'); form.style.display = 'flex'; var base = currentDir.includes('/') ? currentDir.split('/').pop() : currentDir; var input = $('#renameDirInput'); input.placeholder = base; input.value = ''; input.focus(); } function cancelRenameDir() { $('#renameDirForm').style.display = 'none'; $('#renameDirBtn').classList.remove('active'); } function confirmRenameDir() { var newName = $('#renameDirInput').value.trim(); if (!newName) { notify('Enter a directory name', 'error'); return; } if (newName.indexOf('/') !== -1 || newName.indexOf('\\') !== -1 || newName === '.' || newName === '..') { notify('Invalid directory name', 'error'); return; } API.post('/api/room-dirs/rename', {dir: currentDir, new_name: newName}).then(function(r) { if (r.error) { notify(r.error, 'error'); return; } notify('Renamed to ' + newName, 'success'); cancelRenameDir(); currentDir = r.new_dir; reloadRoomDirs(function() { changeDir(r.new_dir); }); }).catch(function(e) { notify('Rename failed: ' + e.message, 'error'); }); } function showNewDir() { hideRenameDir(); $('#newDirBtn').classList.add('active'); var form = $('#newDirForm'); form.style.display = 'flex'; var input = $('#newDirInput'); input.value = ''; input.focus(); } function cancelNewDir() { $('#newDirForm').style.display = 'none'; $('#newDirBtn').classList.remove('active'); } function confirmNewDir() { var name = $('#newDirInput').value.trim(); if (!name) { notify('Enter a directory name', 'error'); return; } if (name.indexOf('/') !== -1 || name.indexOf('\\') !== -1 || name === '.' || name === '..') { notify('Invalid directory name', 'error'); return; } API.post('/api/room-dirs/create', {name: name}).then(function(r) { if (r.error) { notify(r.error, 'error'); return; } notify('Created ' + name, 'success'); cancelNewDir(); currentDir = name; reloadRoomDirs(function() { changeDir(name); }); }).catch(function(e) { notify('Create failed: ' + e.message, 'error'); }); } function onDirInputKey(event, type) { if (event.key === 'Enter') { event.preventDefault(); if (type === 'rename') confirmRenameDir(); else if (type === 'new') confirmNewDir(); } else if (event.key === 'Escape') { event.preventDefault(); if (type === 'rename') cancelRenameDir(); else if (type === 'new') cancelNewDir(); } } function createEmptyRoom() { API.post('/api/rooms/new-empty', {dir: currentDir}).then(function(r) { if (r.error) { notify(r.error, 'error'); return; } notify('Created room ' + r.room.id, 'success'); loadMap().then(function() { if (r.room && r.room.id) selectRoom(r.room.id); }); }).catch(function(e) { notify('Create room failed: ' + e.message, 'error'); }); } function hideRenameDir() { $('#renameDirForm').style.display = 'none'; $('#renameDirBtn').classList.remove('active'); } function hideNewDir() { $('#newDirForm').style.display = 'none'; $('#newDirBtn').classList.remove('active'); } function reloadRoomDirs(cb) { API.get('/api/room-dirs').then(function(dirs) { var sel = $('#dirSelect'); if (sel) { sel.innerHTML = ''; dirs.forEach(function(d) { var opt = document.createElement('option'); opt.value = d; opt.textContent = d || '(root)'; sel.appendChild(opt); }); } updateDirSelect(); if (cb) cb(dirs); }); } document.addEventListener('DOMContentLoaded', function() { loadRoomDirs(function() { loadMap(); }); initPanelResize(); window.addEventListener('keydown', function(e) { if (isCtrlKey(e)) { ctrlHeld = true; if (dragging) refreshDragMode(true); } }); window.addEventListener('keyup', function(e) { if (isCtrlKey(e)) { ctrlHeld = false; if (dragging) refreshDragMode(false); } }); });