diff options
| author | historia <[not public]> | 2026-07-05 22:35:21 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-07-05 22:35:21 -0400 |
| commit | 94823b52168b44894fb5e9c960358ed02ebb122b (patch) | |
| tree | 3060799b89bb7edfa708c72bdd981107d40f4d09 /internal/admin/static/map.js | |
| parent | 843fa6db681e494bf46e191655323a40577542b5 (diff) | |
| download | thehouseoficarus-94823b52168b44894fb5e9c960358ed02ebb122b.tar.gz | |
feat: courses gui overhaul, add course tab to map, add hidden exits to give courses structure
Diffstat (limited to 'internal/admin/static/map.js')
| -rw-r--r-- | internal/admin/static/map.js | 535 |
1 files changed, 534 insertions, 1 deletions
diff --git a/internal/admin/static/map.js b/internal/admin/static/map.js index 740edf3..f0b4d87 100644 --- a/internal/admin/static/map.js +++ b/internal/admin/static/map.js @@ -200,6 +200,12 @@ function renderMap(data) { 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.hidden) { + // Hidden course-link exit: distinct color (map_course, ~#1B6FC0) and + // tighter dashed arrow style. + g += '<line x1="'+x1+'" y1="'+y1+'" x2="'+x2+'" y2="'+y2+'" stroke="#1B6FC0" stroke-width="5" opacity="0.75" stroke-dasharray="2,2" vector-effect="non-scaling-stroke"/>'; + return; + } var c = blendColors(fr.color, tr.color).replace('#',''); g += '<line x1="'+x1+'" y1="'+y1+'" x2="'+x2+'" y2="'+y2+'" stroke="#'+c+'" stroke-width="6" opacity="0.55"'+ (l.bidirectional ? '' : ' stroke-dasharray="4,3"')+' vector-effect="non-scaling-stroke"/>'; }); @@ -370,6 +376,7 @@ function selectRoom(id) { API.get('/api/rooms/' + id).then(function(data) { renderSidePanel(data); + loadRoomCourseData(id); }).catch(function(e) { notify('Failed to load room: '+e.message, 'error'); }); @@ -402,6 +409,7 @@ function renderSidePanel(data) { html += '<button class="panel-tab' + (currentPanelTab === 4 ? ' active' : '') + '" onclick="switchPanelTab(4)">Mobs</button>'; html += '<button class="panel-tab' + (currentPanelTab === 5 ? ' active' : '') + '" onclick="switchPanelTab(5)">Items</button>'; html += '<button class="panel-tab' + (currentPanelTab === 6 ? ' active' : '') + '" onclick="switchPanelTab(6)">Hazard</button>'; + html += '<button class="panel-tab' + (currentPanelTab === 7 ? ' active' : '') + '" onclick="switchPanelTab(7)">Course</button>'; html += '</div>'; html += '<div class="panel-tab-content">'; @@ -412,6 +420,16 @@ function renderSidePanel(data) { 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 += '</div>'; panel.innerHTML = html; @@ -477,6 +495,489 @@ function renderHazardTab(r) { 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 '<p style="color:#888;text-align:center;margin-top:30px">Loading course...</p>'; +} + +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 += '<div class="course-header">'; + h += '<h3 style="margin:0 0 4px 0">Step ' + (idx + 1) + ', Room #' + roomID + '</h3>'; + h += '<div style="font-size:11px;color:#aaa;margin-bottom:6px">(This Obstacle) — ' + esc(cd.course_name || cd.course_id || '') + '</div>'; + // Prev/Next buttons row + h += '<div style="display:flex;gap:6px;margin-bottom:6px">'; + if (prevRoom) { + h += '<button class="btn btn-sm btn-primary" style="flex:1" onclick="selectRoom(' + prevRoom + ')">← Step ' + idx + ' (#' + prevRoom + ')</button>'; + } + if (nextRoom) { + h += '<button class="btn btn-sm btn-primary" style="flex:1" onclick="selectRoom(' + nextRoom + ')">Step ' + (idx + 2) + ' (#' + nextRoom + ') →</button>'; + } + if (!prevRoom && !nextRoom) { + h += '<span style="font-size:11px;color:#888">Single-step course</span>'; + } + h += '</div>'; + h += '</div>'; + + // Obstacle details + h += '<div class="course-obstacle-section">'; + h += '<div class="form-group"><label>Verb</label><select id="courseObstacleVerb" onchange="courseAutoSave()">'; + OBSTACLE_VERBS.forEach(function(v) { + h += '<option value="' + v + '"' + (obstacle.verb === v ? ' selected' : '') + '>' + v + '</option>'; + }); + h += '</select></div>'; + h += '<div class="form-group"><label>XP</label><input id="courseObstacleXP" type="number" value="' + (obstacle.xp || 0) + '" oninput="courseAutoSave()" onchange="courseAutoSave()"></div>'; + h += '<div class="form-group"><label>Fail Damage (min, max)</label><div style="display:flex;gap:6px">'; + var fd = obstacle.fail_damage || [0,0]; + var fdMin = Array.isArray(fd) ? fd[0] : 0; + var fdMax = Array.isArray(fd) ? fd[1] : 0; + h += '<input id="courseObstacleFailMin" type="number" value="' + fdMin + '" style="flex:1" oninput="courseAutoSave()" onchange="courseAutoSave()">'; + h += '<input id="courseObstacleFailMax" type="number" value="' + fdMax + '" style="flex:1" oninput="courseAutoSave()" onchange="courseAutoSave()">'; + h += '</div></div>'; + h += '<div class="form-group"><label>Fail Chance % (blank = derived)</label>'; + var fcVal = (obstacle.fail_chance !== undefined && obstacle.fail_chance !== null) ? obstacle.fail_chance : ''; + h += '<input id="courseObstacleFailChance" type="text" value="' + escAttr(String(fcVal)) + '" placeholder="derived" oninput="courseAutoSave()" onchange="courseAutoSave()">'; + h += '<div style="font-size:10px;color:#888;margin-top:2px">Derived: 30% at req level, -1% per level above, clamp 5-60%. A number here overrides it.</div>'; + h += '</div>'; + h += '</div>'; + + // Phases section + h += '<div class="course-phases-section">'; + h += '<div class="section-divider">Phases (messages)</div>'; + h += '<div id="coursePhases"></div>'; + h += '<button class="btn btn-sm section-add-btn" id="addPhaseBtn" onclick="courseAddPhase()">+ Add Message</button>'; + h += '</div>'; + + h += '<div class="btn-row" style="margin-top:10px">'; + h += '<button class="btn btn-danger" onclick="courseDetach()">Remove from course</button>'; + h += '</div>'; + + // Collapsible course details (starts collapsed) + h += '<div class="course-details-card" style="margin-top:12px">'; + h += '<div class="obj-card-header" onclick="courseToggleDetails()">'; + h += '<span class="obj-card-arrow">' + (_courseDetailsCollapsed ? '▶' : '▼') + '</span>'; + h += '<span>Course Details</span>'; + h += '</div>'; + if (!_courseDetailsCollapsed) { + h += '<div class="obj-card-body">'; + h += '<div class="form-group"><label>Course Name</label><input id="courseName" value="' + escAttr(course.name || '') + '" oninput="courseAutoSave()" onchange="courseAutoSave()"></div>'; + h += '<div class="form-group"><label>Required Level</label><input id="courseRequiredLevel" type="number" value="' + (course.required_level || 0) + '" oninput="courseAutoSave()" onchange="courseAutoSave()"></div>'; + h += '<div class="form-group"><label>Start Room (hub / fail return)</label><input id="courseStartRoom" type="number" value="' + (course.start_room || 0) + '" oninput="courseAutoSave()" onchange="courseAutoSave()"></div>'; + h += '<div class="form-group"><label>Completion XP (lap bonus)</label><input id="courseCompletionXP" type="number" value="' + (course.completion_xp || 0) + '" oninput="courseAutoSave()" onchange="courseAutoSave()"></div>'; + h += '</div>'; + } + h += '</div>'; + + 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 = '<p style="color:#888;font-size:11px;margin:6px 0 12px">This room is not part of any course.</p>'; + h += '<div class="form-group"><label>Direction for new step exit</label>'; + h += '<select id="courseAttachDir"><option value="">auto</option>'; + HORIZONTAL_DIRS.forEach(function(d) { h += '<option value="' + d + '">' + d + '</option>'; }); + h += '</select></div>'; + h += '<div class="section-search">'; + h += '<input type="text" class="search-input" placeholder="Add to course..." oninput="searchCourses(this)">'; + h += '<div class="search-results" style="display:none"></div>'; + h += '</div>'; + h += '<button class="btn btn-sm section-add-btn" style="margin-top:8px" onclick="courseShowNew()">+ New Course</button>'; + 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 += '<div class="entry-row course-phase" data-idx="' + i + '">'; + h += '<div class="entry-fields">'; + h += '<textarea class="phase-msg" rows="2" placeholder="message" oninput="courseUpdatePhase(' + i + ')" onchange="courseUpdatePhase(' + i + ')" style="width:100%">' + esc(p.message || '') + '</textarea>'; + h += '<div style="display:flex;align-items:center;gap:6px;margin-top:4px">'; + h += '<label class="mini-label">Delay</label><input class="mini-input phase-delay" type="text" inputmode="numeric" value="' + (p.delay || 0) + '" oninput="courseUpdatePhase(' + i + ')" onchange="courseUpdatePhase(' + i + ')">'; + h += '<label class="mini-label" style="display:flex;align-items:center;gap:3px"><input type="radio" class="phase-fail" name="phaseFail" onchange="courseSetFailCheck(' + i + ')"' + (p.fail_check ? ' checked' : '') + '> Fail check here</label>'; + h += '</div>'; + h += '</div>'; + h += '<button class="btn btn-sm btn-danger" onclick="courseRemovePhase(' + i + ')">X</button>'; + h += '</div>'; + }); + 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 = '<div class="sr-item" style="color:#888">No results</div>'; + return; + } + results.style.display = 'block'; + results.innerHTML = items.map(function(r) { + return '<div class="sr-item" onclick="event.stopPropagation(); selectCourseResult(this)">' + esc(r.id) + ' <span style="color:#aaa">' + esc(r.name || '') + '</div>'; + }).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 = '<h3 style="margin-bottom:8px;font-size:13px">New Course (room #' + roomID + ' = step 1)</h3>'; + h += '<div class="form-group"><label>Course ID (filename)</label><input id="newCourseID" placeholder="e.g. vent_shaft" ></div>'; + h += '<div class="form-group"><label>Course Name</label><input id="newCourseName" placeholder="e.g. Ventilation Shaft Course"></div>'; + h += '<div class="form-group"><label>Required Level</label><input id="newCourseReqLevel" type="number" value="1"></div>'; + h += '<div class="form-group"><label>Start Room</label><input id="newCourseStartRoom" type="number" value="' + roomID + '"></div>'; + h += '<div class="form-group"><label>Completion XP</label><input id="newCourseCompletionXP" type="number" value="0"></div>'; + h += '<div class="form-group"><label>Verb</label><select id="newCourseVerb">'; + OBSTACLE_VERBS.forEach(function(v) { + h += '<option value="' + v + '"' + (v === 'climb' ? ' selected' : '') + '>' + v + '</option>'; + }); + h += '</select></div>'; + h += '<div class="form-group"><label>XP</label><input id="newCourseXP" type="number" value="0"></div>'; + h += '<div class="form-group"><label>Fail Damage (min, max)</label><div style="display:flex;gap:6px">'; + h += '<input id="newCourseFailMin" type="number" value="0" style="flex:1">'; + h += '<input id="newCourseFailMax" type="number" value="0" style="flex:1">'; + h += '</div></div>'; + h += '<div class="form-group"><label>Fail Chance % (blank = derived)</label><input id="newCourseFailChance" type="text" placeholder="derived"></div>'; + h += '<div class="form-group"><label>First message</label><textarea id="newCourseMsg" rows="2" placeholder="You approach the obstacle..."></textarea></div>'; + h += '<div class="btn-row" style="margin-top:10px">'; + h += '<button class="btn btn-primary" onclick="courseCreateConfirm()">Create</button>'; + h += '<button class="btn" onclick="courseCloseModal()">Cancel</button>'; + h += '</div>'; + + 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); }); } @@ -528,6 +1029,17 @@ function switchPanelTab(idx) { 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'); @@ -646,10 +1158,12 @@ function renderExitsSection(r) { 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 isConditional = !!(cond || bmsg || setFlags || setPlayerFlags); + var hidden = (typeof exit === 'object' && exit.hidden) ? exit.hidden : false; + var isConditional = !!(cond || bmsg || setFlags || setPlayerFlags || hidden); h += '<div class="exit-row" data-dir="' + d + '">'; h += '<span class="exit-dir"' + (isConditional ? ' style="color:#f55"' : '') + '>' + d + '</span>'; h += '<span class="exit-target" onclick="selectRoom(' + target + ')"># ' + target + '</span>'; + h += '<label style="font-size:11px;margin-left:4px;cursor:pointer;color:#aaa"><input type="checkbox" class="exit-hidden" style="vertical-align:middle"' + (hidden ? ' checked' : '') + ' onchange="toggleExitHidden(\'' + d + '\')"> Hidden</label>'; if (bmsg) h += '<span class="exit-meta">Blocked: ' + esc(bmsg) + '</span>'; if (cond) h += '<span class="exit-meta">Conditional</span>'; h += '<button class="btn btn-sm" onclick="editExitCondition(\'' + d + '\')">Edit</button>'; @@ -679,6 +1193,7 @@ function editExitCondition(dir) { var bmsg = exit.blocked_message || ''; var setFlags = exit.set_flags || null; var setPlayerFlags = exit.set_player_flags || null; + var hidden = exit.hidden || false; var flagsStr = ''; if (setFlags) flagsStr = Object.keys(setFlags).map(function(k) { return k + '=' + setFlags[k]; }).join(', '); @@ -697,6 +1212,7 @@ function editExitCondition(dir) { h += '<div class="form-group"><label>Set Flags (key=value, comma separated)</label><input id="exitFlags" value="' + escAttr(flagsStr) + '"></div>'; h += '<div class="form-group"><label>Set Player Flags (key=value, comma separated)</label><input id="exitPFlags" value="' + escAttr(pflagsStr) + '"></div>'; h += '<div class="form-group"><label>Blocked Message</label><textarea id="exitBmsg" rows="2">' + esc(bmsg) + '</textarea></div>'; + h += '<div class="form-group"><label><input type="checkbox" id="exitHidden"' + (hidden ? ' checked' : '') + '> Hidden</label></div>'; h += '<div style="margin-top:10px;border-top:1px solid var(--border);padding-top:8px">'; h += '<label style="font-size:11px;color:#aaa;text-transform:uppercase;letter-spacing:.5px">Conditions</label>'; @@ -834,6 +1350,8 @@ function saveExitCondition() { 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 flagsStr = document.getElementById('exitFlags'); var pflagsStr = document.getElementById('exitPFlags'); exit.set_flags = parseFlagInput(flagsStr ? flagsStr.value : ''); @@ -928,6 +1446,21 @@ function deleteExit(dir, target) { }).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(_) {} |
