diff options
Diffstat (limited to 'internal/admin/static/mobeditor.js')
| -rw-r--r-- | internal/admin/static/mobeditor.js | 532 |
1 files changed, 532 insertions, 0 deletions
diff --git a/internal/admin/static/mobeditor.js b/internal/admin/static/mobeditor.js new file mode 100644 index 0000000..a65e578 --- /dev/null +++ b/internal/admin/static/mobeditor.js @@ -0,0 +1,532 @@ +// mobeditor.js — Card-based mob editor following the object/item pattern. +var mobData = null; +var mobID = null; +var expandedCards = {}; +var addedSections = {}; + +var MOB_SECTIONS = [ + { + id: 'core', label: 'Core', always: true, + fields: [ + ['name', 'Name', 'text', 'spaceport attendant'], + ['description', 'Description', 'textarea', 'A lanky man in a wrinkled grey uniform.'], + ], + inline: [ + {label:'Idle Descriptions', key:'idle_descriptions', type:'tags'}, + {label:'Combat Descriptions', key:'combat_descriptions', type:'tags'}, + ] + }, + { + id: 'combat', label: 'Combat Stats', + detect: function(d) { return d.attack || d.strength || d.defense || d.hp; }, + fields: [ + ['attack', 'Attack', 'number', '', 'narrow'], + ['strength', 'Strength', 'number', '', 'narrow'], + ['defense', 'Defense', 'number', '', 'narrow'], + ['hp', 'HP', 'number', '', 'narrow'], + ['ranged', 'Ranged', 'number', '', 'narrow'], + ['science', 'Science', 'number', '', 'narrow'], + ['speed', 'Speed', 'number', '', 'narrow'], + ], + inline: [ + {label:'Bonuses', path:'bonuses', fields:[ + ['attack_bonus', 'Atk Bonus', 'number', '', 'narrow'], + ['strength_bonus', 'Str Bonus', 'number', '', 'narrow'], + ['attack_type', 'Atk Type', 'select', 'stab|slash|crush|ranged|science'], + ]}, + {label:'Defenses', path:'defenses', fields:[ + ['stab_defense', 'Stab', 'number', '', 'narrow'], + ['slash_defense', 'Slash', 'number', '', 'narrow'], + ['crush_defense', 'Crush', 'number', '', 'narrow'], + ['science_defense', 'Science', 'number', '', 'narrow'], + ['ranged_defense', 'Ranged', 'number', '', 'narrow'], + ['weakness', 'Weakness', 'text'], + ]}, + ] + }, + { + id: 'behavior', label: 'Behavior', + detect: function(d) { return d.aggressive || d.protected || d.unique || d.respawn_ticks || d.kind; }, + fields: [ + ['aggressive', 'Aggressive', 'checkbox'], + ['protected', 'Protected', 'checkbox'], + ['unique', 'Unique', 'checkbox'], + ['respawn_ticks', 'Respawn Ticks', 'number', '', 'narrow'], + ['kind', 'Kind', 'select', 'combat|task'], + ] + }, + { + id: 'steal', label: 'Steal', + detect: function(d) { return d.steal_table || d.steal_level || d.steal_speed || d.steal_xp; }, + fields: [ + ['steal_table', 'Table ID', 'search', '', '', null, 'drops'], + ['steal_level', 'Level', 'number', '', 'narrow'], + ['steal_xp', 'XP', 'number', '', 'narrow'], + ['steal_speed', 'Speed', 'number', '', 'narrow'], + ] + }, + { + id: 'assassin', label: 'Assassin', + detect: function(d) { return d.assassin_level || d.finishing_blow || d.damage_without || d.size; }, + fields: [ + ['assassin_level', 'Level', 'number', '', 'narrow'], + ['finishing_blow', 'Finishing Blow', 'text'], + ['damage_without', 'Damage Without', 'text'], + ['size', 'Size', 'text'], + ] + }, + { + id: 'task', label: 'Task', + detect: function(d) { return d.kind === 'task'; }, + fields: [ + ['verb', 'Verb', 'text', 'work'], + ['progress_noun', 'Progress Noun', 'text', 'construction'], + ['complete_message', 'Complete Message', 'text'], + ] + }, + { + id: 'talk', label: 'Talk', path: 'talk', + detect: function(d) { return d.talk && d.talk.nodes; }, + fields: [] + }, + { + id: 'drops', label: 'Drops', path: 'drops', + detect: function(d) { return d.drops && (d.drops.remains || (d.drops.loot && d.drops.loot.length > 0)); }, + fields: [ + ['remains', 'Remains', 'text', 'bones'], + ], + subtables: [ + {label:'Loot', key:'loot', fields:[ + ['item_id', 'Item ID', 'search', '', '', null, 'items'], + ['table', 'Table', 'search', '', '', null, 'drops'], + ['weight', 'Weight', 'number', '', 'narrow'], + ['level', 'Level', 'number', '', 'narrow'], + ['xp', 'XP', 'number', '', 'narrow'], + ['quantity', 'Quantity', 'number', '', 'narrow'], + ]} + ] + }, +]; + +function mobCardPath(sec) { return ced_cardPath(sec); } +function mobGetVal(data, sec, key) { return ced_getVal(data, sec, key); } +function mobFieldID(sec, key, idx) { return ced_fieldID(sec, key, idx); } +function mobFieldIDSub(sec, subKey, idx, fieldKey) { return ced_fieldIDSub(sec, subKey, idx, fieldKey); } + +function initMobEditor() { + editorType = 'mobs'; + editorFields = []; + loadList(); + if (window.location.hash) loadMob(window.location.hash.substring(1)); +} + +window.onTalkTreeChange = function(data) { + mobData.talk = data.talk; +}; + +function loadMob(id) { + mobID = id; + currentID = id; + window.location.hash = id; + renderList(''); + API.get('/api/mobs/' + encodeURIComponent(id)).then(function(data) { + mobData = data; + renderMobEditor(id, data); + }).catch(function(e) { + $('#editorMain').innerHTML = '<p style="color:var(--danger)">Failed to load: ' + esc(e.message) + '</p>'; + }); +} + +function renderMobEditor(id, data) { + var html = '<h2>Mob: ' + esc(id) + '</h2>'; + html += '<div class="tabs"><button class="tab active" onclick="switchTab(event,\'fields\')">Fields</button>'; + html += '<button class="tab" onclick="switchTab(event,\'yaml\')">Raw YAML</button></div>'; + + html += '<div class="tab-content" id="tabFields">'; + html += '<div class="obj-fields-wrap">'; + + MOB_SECTIONS.forEach(function(sec) { + if (!sec.always && sec.detect && !sec.detect(data) && !addedSections[sec.id]) return; + html += renderMobCard(sec, data); + }); + + html += '</div>'; + + html += '<div class="add-section-bar">'; + html += '<select id="addSectionSelect">'; + html += '<option value="">+ Add Section...</option>'; + MOB_SECTIONS.forEach(function(sec) { + if (sec.always) return; + if (sec.detect && sec.detect(data)) return; + if (addedSections[sec.id]) return; + html += '<option value="' + sec.id + '">' + esc(sec.label) + '</option>'; + }); + html += '</select>'; + html += '<button class="btn btn-primary btn-sm" onclick="mobAddSection()">Add</button>'; + html += '</div>'; + + html += '</div>'; + + html += '<div class="tab-content" id="tabYaml" style="display:none">'; + html += '<pre>' + esc(data._raw || JSON.stringify(data, null, 2)) + '</pre>'; + html += '</div>'; + + html += '<div class="btn-row">'; + html += '<button class="btn btn-primary" onclick="saveMob()">Save</button>'; + html += '<button class="btn" onclick="duplicateMob()">Duplicate</button>'; + html += '<button class="btn btn-danger" onclick="deleteMob()">Delete Mob</button>'; + html += '</div>'; + + $('#editorMain').innerHTML = html; + ced_setupSearchFields(); + setupMobKindChange(); + + MOB_SECTIONS.forEach(function(sec) { + if (sec.id === 'talk' && data.talk && data.talk.nodes) { + renderTalkTree(data); + } + }); +} + +function renderCurrent() { + if (!mobData || !mobID) return; + renderMobEditor(mobID, mobData); +} + +// ---- Card rendering ---- + +function renderMobCard(sec, data) { + var collapsed = expandedCards[sec.id] === false; + var h = '<div class="obj-card" data-card="' + sec.id + '">'; + h += '<div class="obj-card-header" onclick="toggleMobCard(\'' + sec.id + '\')">'; + h += '<span class="obj-card-arrow">' + (collapsed ? '▶' : '▼') + '</span>'; + h += '<span class="obj-card-label">' + sec.label + '</span>'; + if (!sec.always) { + h += '<button class="btn btn-sm btn-danger obj-card-remove" onclick="event.stopPropagation();removeMobSection(\'' + sec.id + '\')">X</button>'; + } + h += '</div>'; + if (!collapsed) { + h += '<div class="obj-card-body">'; + + if (sec.id === 'core') { + h += renderMobCoreCard(data); + } else if (sec.subtables) { + h += '<div class="obj-card-grid">'; + sec.fields.forEach(function(f) { + h += renderMobField(sec, f, data, -1); + }); + h += '</div>'; + + if (sec.inline) { + sec.inline.forEach(function(il) { + if (il.type === 'tags') { + h += ced_renderTags(sec, il, data); + } else { + h += '<div class="obj-inline-group">'; + h += '<span class="obj-inline-label">' + esc(il.label) + '</span>'; + h += '<div class="obj-card-grid" style="margin-top:4px">'; + il.fields.forEach(function(f) { + h += renderMobField(sec, f, data, -1, il.path); + }); + h += '</div>'; + h += '</div>'; + } + }); + } + + sec.subtables.forEach(function(st) { + h += renderMobSubtable(sec, st, data); + }); + } else { + h += '<div class="obj-card-grid">'; + sec.fields.forEach(function(f) { + h += renderMobField(sec, f, data, -1); + }); + h += '</div>'; + + if (sec.inline) { + sec.inline.forEach(function(il) { + if (il.type === 'tags') { + h += ced_renderTags(sec, il, data); + } else { + h += '<div class="obj-inline-group">'; + h += '<span class="obj-inline-label">' + esc(il.label) + '</span>'; + h += '<div class="obj-card-grid" style="margin-top:4px">'; + il.fields.forEach(function(f) { + h += renderMobField(sec, f, data, -1, il.path); + }); + h += '</div>'; + h += '</div>'; + } + }); + } + + if (sec.id === 'talk') { + h += '<div id="talktree"></div>'; + } + } + h += '</div>'; + } + h += '</div>'; + return h; +} + +function renderMobCoreCard(data) { + var sec = MOB_SECTIONS[0]; + var h = '<div class="obj-core-row">'; + h += renderMobField(sec, sec.fields[0], data, -1, null, 'flex:1'); + h += '</div>'; + h += renderMobField(sec, sec.fields[1], data, -1); + sec.inline.forEach(function(il) { + h += ced_renderTags(sec, il, data); + }); + return h; +} + +function renderMobField(sec, f, data, idx, subPath, optStyle) { + return ced_renderField(sec, f, data, idx, subPath, optStyle, mobCardPath, mobFieldID, mobGetVal); +} + +// ---- Subtable rendering ---- + +function renderMobSubField(sec, stKey, idx, f, val) { + var fid = mobFieldIDSub(sec, stKey, idx, f[0]); + val = val === undefined || val === null ? '' : val; + var extraCls = ''; + if (f[4] === 'narrow') extraCls = ' obj-narrow'; + else if (f[4] === 'grow') extraCls = ' obj-grow'; + var defaultPh = ''; + if (f[3]) defaultPh = ' placeholder="' + escAttr(f[3]) + '"'; + if (f[2] === 'search') { + var entity = f[6] || 'items'; + var ph = f[3] ? ' placeholder="' + escAttr(f[3]) + '"' : ' placeholder="Search ' + entity + '..."'; + return '<label class="obj-field obj-search' + extraCls + '"><span>' + esc(f[1]) + '</span><div style="position:relative;width:100%"><input id="' + fid + '" value="' + escAttr(String(val)) + '" class="search-input" data-search-type="' + entity + '"' + ph + '><div class="search-results" style="display:none"></div></div></label>'; + } + if (f[2] === 'checkbox') { + return '<label class="obj-field obj-check' + extraCls + '"><input type="checkbox" id="' + fid + '"' + (val ? ' checked' : '') + '> <span>' + esc(f[1]) + '</span></label>'; + } + return '<label class="obj-field' + extraCls + '"><span>' + esc(f[1]) + '</span><input id="' + fid + '" value="' + escAttr(String(val)) + '"' + defaultPh + '></label>'; +} + +function renderMobSubtable(sec, st, data) { + var p = mobCardPath(sec); + var sectionRoot = p ? (mobData[p] || {}) : mobData; + var raw = sectionRoot[st.key]; + var arr; + if (st.single) { + arr = raw ? [raw] : []; + } else if (Array.isArray(raw)) { + arr = raw; + } else if (raw && typeof raw === 'object') { + arr = [raw]; + } else { + arr = []; + } + var h = '<div class="obj-subtable">'; + h += '<div class="obj-subtable-header">' + esc(st.label) + '</div>'; + arr.forEach(function(item, idx) { + h += '<div class="obj-sub-row">'; + st.fields.forEach(function(f) { + var val = item[f[0]]; + h += renderMobSubField(sec, st.key, idx, f, val); + }); + if (!st.single) { + h += '<button class="btn btn-sm btn-danger obj-sub-remove" style="margin-bottom:3px" onclick="removeMobSubRow(\'' + sec.id + '\',\'' + st.key + '\',' + idx + ')">X</button>'; + } + h += '</div>'; + }); + if (!st.single || arr.length === 0) { + h += '<button class="btn btn-sm obj-sub-add" onclick="addMobSubRow(\'' + sec.id + '\',\'' + st.key + '\')">+ Add Loot</button>'; + } + h += '</div>'; + return h; +} + +// ---- Card toggle / section add-remove ---- + +function toggleMobCard(id) { + expandedCards[id] = expandedCards[id] === false ? true : false; + renderCurrent(); +} + +function removeMobSection(id) { + var sec = MOB_SECTIONS.find(function(s) { return s.id === id; }); + if (!sec) return; + if (sec.path) { + delete mobData[sec.path]; + } else { + if (sec.fields) sec.fields.forEach(function(f) { delete mobData[f[0]]; }); + if (sec.inline) sec.inline.forEach(function(il) { if (il.key) delete mobData[il.key]; }); + if (sec.subtables) sec.subtables.forEach(function(st) { delete mobData[st.key]; }); + } + delete addedSections[id]; + renderCurrent(); +} + +function mobAddSection() { + var sel = $('#addSectionSelect'); + var id = sel.value; + if (!id) return; + var sec = MOB_SECTIONS.find(function(s) { return s.id === id; }); + if (!sec) return; + + if (sec.path) { + if (sec.id === 'drops') { + mobData[sec.path] = {remains:'', loot:[]}; + } else if (sec.id === 'talk') { + mobData[sec.path] = {nodes:{start:{message:'',options:[]}}}; + } else { + mobData[sec.path] = {}; + } + } + addedSections[id] = true; + expandedCards[id] = true; + renderCurrent(); +} + +// ---- Subtable row add/remove ---- + +function addMobSubRow(cardID, subKey) { + var sec = MOB_SECTIONS.find(function(s) { return s.id === cardID; }); + if (!sec) return; + ced_addSubRow(sec, subKey, mobData, mobCardPath); + renderCurrent(); +} + +function removeMobSubRow(cardID, subKey, idx) { + var sec = MOB_SECTIONS.find(function(s) { return s.id === cardID; }); + if (!sec) return; + ced_removeSubRow(sec, subKey, idx, mobData, mobCardPath); + renderCurrent(); +} + +// ---- Kind change handler (show/hide task fields) ---- + +function setupMobKindChange() { + var kindSel = document.getElementById(mobFieldID(MOB_SECTIONS.find(function(s){return s.id==='behavior';}), 'kind', -1)); + if (!kindSel) return; + kindSel.addEventListener('change', function() { + if (kindSel.value === 'task') { + mobData.kind = 'task'; + if (!mobData.verb) mobData.verb = ''; + if (!mobData.progress_noun) mobData.progress_noun = ''; + if (!mobData.complete_message) mobData.complete_message = ''; + addedSections['task'] = true; + expandedCards['task'] = true; + renderCurrent(); + } else { + mobData.kind = ''; + delete addedSections['task']; + renderCurrent(); + } + }); +} + +// ---- Save ---- + +function saveMob() { + if (!ced_validateFields(MOB_SECTIONS, mobFieldID, mobFieldIDSub)) return; + + var out = {}; + + MOB_SECTIONS.forEach(function(sec) { + if (sec.id === 'talk') { + if (mobData.talk) out.talk = mobData.talk; + return; + } + + var sectionObj = {}; + var hasValues = sec.always; + + if (sec.fields) { + hasValues = ced_saveSectionFields(sec, sectionObj, mobCardPath, mobFieldID) || hasValues; + } + + if (sec.subtables) { + sec.subtables.forEach(function(st) { + if (ced_saveSubtable(sec, st, sectionObj, mobCardPath, mobFieldIDSub)) hasValues = true; + }); + } + + if (sec.inline) { + if (ced_saveInlineFields(sec, sectionObj, mobCardPath, mobFieldID)) hasValues = true; + } + + if (sec.always) { + Object.keys(sectionObj).forEach(function(k) { out[k] = sectionObj[k]; }); + } else if (hasValues && sec.path) { + out[sec.path] = sectionObj; + } else if (hasValues && !sec.path) { + Object.keys(sectionObj).forEach(function(k) { out[k] = sectionObj[k]; }); + } + }); + + ced_cleanOutput(out); + out.id = mobID; + + API.put('/api/mobs/' + encodeURIComponent(mobID), out).then(function(resp) { + notify('Mob ' + mobID + ' saved', 'success'); + updateUndoBar(); + if (resp && resp._raw) mobData._raw = resp._raw; + }).catch(function(e) { notify('Save failed: ' + e.message, 'error'); }); +} + +// ---- Duplicate ---- + +function duplicateMob() { + var baseID = mobID + '_copy'; + var newID = baseID; + var counter = 2; + while (true) { + var exists = false; + for (var i = 0; i < allIDs.length; i++) { + if (allIDs[i] === newID) { exists = true; break; } + } + if (!exists) break; + newID = baseID + '_' + counter; + counter++; + } + + var copy = JSON.parse(JSON.stringify(mobData)); + delete copy._raw; + delete copy._path; + copy.id = newID; + + API.post('/api/mobs', {id: newID}).then(function() { + return API.put('/api/mobs/' + encodeURIComponent(newID), copy); + }).then(function() { + notify('Duplicated as ' + newID, 'success'); + updateUndoBar(); + loadList(); + loadMob(newID); + }).catch(function(e) { notify('Duplicate failed: ' + e.message, 'error'); }); +} + +// ---- Delete ---- + +function deleteMob() { + if (!confirm('Delete mob "' + mobID + '"?')) return; + API.del('/api/mobs/' + encodeURIComponent(mobID)).then(function() { + notify('Mob ' + mobID + ' deleted', 'success'); + updateUndoBar(); + mobID = null; + currentID = null; + mobData = null; + $('#editorMain').innerHTML = '<p style="color:#888;text-align:center;margin-top:60px">Deleted</p>'; + loadList(); + }).catch(function(e) { notify('Delete failed: ' + e.message, 'error'); }); +} + +// ---- editor.js compatibility ---- + +function loadItem(id) { loadMob(id); } +function createNew() { + var name = prompt('Mob ID:'); + if (!name) return; + API.post('/api/mobs', {id: name}).then(function() { + notify('Mob ' + name + ' created', 'success'); + updateUndoBar(); + loadList(); + loadMob(name); + }).catch(function(e) { notify('Create failed: ' + e.message, 'error'); }); +} |
