init: 剧情事件协作 Web 编辑器独立仓(从 SGame/tools/event_authoring 拆出)
This commit is contained in:
252
web/static/app.js
Normal file
252
web/static/app.js
Normal file
@ -0,0 +1,252 @@
|
||||
// 主控:鉴权 / 事件列表 / 加载保存 / 校验 / 状态 / 导入导出 / 试走。
|
||||
(function () {
|
||||
const App = {
|
||||
dict: { conditions: {}, grants: {} },
|
||||
pointsets: {}, // name -> {mapId, points:[]}
|
||||
events: [],
|
||||
current: null, // 当前 group
|
||||
ir: null, // 工作副本
|
||||
status: null,
|
||||
selectedNode: null,
|
||||
dirty: false,
|
||||
by: localStorage.getItem("story_by") || "匿名",
|
||||
};
|
||||
window.App = App;
|
||||
|
||||
const $ = id => document.getElementById(id);
|
||||
async function api(path, opts) {
|
||||
const r = await fetch(path, Object.assign({ headers: { "Content-Type": "application/json" } }, opts));
|
||||
if (r.status === 401) { showLogin(); throw new Error("未授权"); }
|
||||
return r;
|
||||
}
|
||||
|
||||
// ---------- 鉴权 ----------
|
||||
function showLogin() { $("login").classList.remove("hidden"); $("login").style.display = "flex"; }
|
||||
function hideLogin() { $("login").style.display = "none"; }
|
||||
|
||||
$("login-btn").onclick = async () => {
|
||||
const pass = $("login-pass").value, name = $("login-name").value.trim();
|
||||
const r = await fetch("/api/login", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ password: pass }) });
|
||||
if (!r.ok) { $("login-err").textContent = "口令错误"; return; }
|
||||
if (name) { App.by = name; localStorage.setItem("story_by", name); }
|
||||
hideLogin(); init();
|
||||
};
|
||||
$("login-pass").onkeydown = e => { if (e.key === "Enter") $("login-btn").click(); };
|
||||
|
||||
// ---------- 初始化 ----------
|
||||
async function init() {
|
||||
$("who").textContent = "你:" + App.by;
|
||||
try {
|
||||
App.dict = await (await api("/api/dictionary")).json();
|
||||
App.pointsets = await (await api("/api/pointsets")).json();
|
||||
} catch (e) { return; }
|
||||
await loadList();
|
||||
}
|
||||
|
||||
// ---------- 列表 ----------
|
||||
async function loadList() {
|
||||
const status = $("filter-status").value;
|
||||
const r = await api("/api/events?status=" + encodeURIComponent(status));
|
||||
App.events = await r.json();
|
||||
renderList();
|
||||
}
|
||||
function renderList() {
|
||||
const q = $("search").value.trim().toLowerCase();
|
||||
const host = $("event-list"); host.innerHTML = "";
|
||||
const badge = { pending: ["b-pending", "待审"], confirmed: ["b-confirmed", "已确认"], discarded: ["b-discarded", "已丢弃"] };
|
||||
App.events
|
||||
.filter(e => !q || (e.title || "").toLowerCase().includes(q) || (e.group || "").toLowerCase().includes(q))
|
||||
.forEach(e => {
|
||||
const b = badge[e.status] || ["b-pending", e.status];
|
||||
const d = document.createElement("div");
|
||||
d.className = "ev" + (e.group === App.current ? " sel" : "");
|
||||
d.innerHTML = '<span class="badge ' + b[0] + '">' + b[1] + '</span>'
|
||||
+ '<div class="t">' + esc(e.title || e.group) + '</div>'
|
||||
+ '<div class="g">' + esc(e.group) + ' · ' + esc(e.updated_by || "") + ' ' + esc((e.updated_at || "").slice(5, 16)) + '</div>';
|
||||
d.onclick = () => selectEvent(e.group);
|
||||
host.appendChild(d);
|
||||
});
|
||||
if (!host.children.length) host.innerHTML = '<div class="empty" style="padding:14px">无事件,点「导入 IR」</div>';
|
||||
}
|
||||
$("filter-status").onchange = loadList;
|
||||
$("search").oninput = renderList;
|
||||
|
||||
// ---------- 选中事件 ----------
|
||||
async function selectEvent(group) {
|
||||
if (App.dirty && !confirm("当前事件有未保存修改,放弃并切换?")) return;
|
||||
const r = await api("/api/events/" + encodeURIComponent(group));
|
||||
const d = await r.json();
|
||||
App.current = group; App.ir = JSON.parse(JSON.stringify(d.ir));
|
||||
App.status = d.status; App.selectedNode = null; App.dirty = false;
|
||||
$("graph-empty").style.display = "none";
|
||||
["btn-save", "btn-validate", "btn-playtest", "btn-confirm", "btn-discard", "btn-addnode"].forEach(b => $(b).disabled = false);
|
||||
renderAll(true);
|
||||
renderList();
|
||||
updateDirty();
|
||||
}
|
||||
|
||||
const ctx = () => ({
|
||||
dict: App.dict,
|
||||
pointNames: (App.pointsets[(App.ir.stage || {}).point_set || App.ir.id] || {}).points || [],
|
||||
onChange: structural => { App.dirty = true; updateDirty(); drawTree(); if (structural) { FormUI.renderMeta(App.ir, ctx()); FormUI.renderNode(App.ir, App.selectedNode, ctx()); } },
|
||||
selectNode: id => { App.selectedNode = id; },
|
||||
});
|
||||
|
||||
function drawTree() {
|
||||
if (App.ir) renderTree(App.ir, {
|
||||
selected: App.selectedNode, onSelect: selectNode,
|
||||
onAddNext: addSuccessor, onDelete: deleteNode,
|
||||
});
|
||||
}
|
||||
function selectNode(id) { App.selectedNode = id; drawTree(); FormUI.renderNode(App.ir, id, ctx()); }
|
||||
|
||||
// 节点快捷按钮:加后继 / 删除
|
||||
function addSuccessor(id) {
|
||||
const r = FormUI.addSuccessor(App.ir, id);
|
||||
if (!r) return;
|
||||
if (!r.linked) alert("该战斗节点的「胜→win」「败→lose」出口都已占用,\n新节点已创建但未自动接线——请在右栏把胜或败指向它(id: " + r.id + ")。");
|
||||
App.dirty = true; selectNode(r.id); FormUI.renderMeta(App.ir, ctx()); updateDirty();
|
||||
}
|
||||
function deleteNode(id) {
|
||||
const isEnding = (App.ir.endings || []).some(e => e.id === id);
|
||||
if (!confirm("删除" + (isEnding ? "结局" : "节点") + " " + id + "?指向它的跳转需手动修复(校验会提示)。")) return;
|
||||
if (isEnding) App.ir.endings = (App.ir.endings || []).filter(e => e.id !== id);
|
||||
else App.ir.nodes = (App.ir.nodes || []).filter(n => n.id !== id);
|
||||
if (App.selectedNode === id) App.selectedNode = null;
|
||||
App.dirty = true; drawTree(); FormUI.renderMeta(App.ir, ctx());
|
||||
FormUI.renderNode(App.ir, App.selectedNode, ctx()); updateDirty();
|
||||
}
|
||||
function renderAll() { drawTree(); FormUI.renderMeta(App.ir, ctx()); FormUI.renderNode(App.ir, App.selectedNode, ctx()); }
|
||||
|
||||
function updateDirty() {
|
||||
$("btn-save").textContent = App.dirty ? "保存 *" : "保存";
|
||||
$("who").textContent = "你:" + App.by + (App.current ? (" | " + App.current + "(" + (App.status || "") + ")") : "");
|
||||
}
|
||||
|
||||
// ---------- 增节点 ----------
|
||||
$("btn-addnode").onclick = () => {
|
||||
if (!App.ir) return;
|
||||
const id = FormUI.newNode(App.ir);
|
||||
App.dirty = true; selectNode(id); FormUI.renderMeta(App.ir, ctx()); updateDirty();
|
||||
};
|
||||
|
||||
// ---------- 保存 ----------
|
||||
$("btn-save").onclick = async () => {
|
||||
if (!App.ir) return;
|
||||
const r = await api("/api/events/" + encodeURIComponent(App.current), { method: "PUT", body: JSON.stringify({ ir: App.ir, by: App.by }) });
|
||||
if (r.ok) { App.dirty = false; updateDirty(); await loadList(); }
|
||||
else alert("保存失败:" + (await r.text()));
|
||||
};
|
||||
|
||||
// ---------- 校验 ----------
|
||||
$("btn-validate").onclick = async () => {
|
||||
const r = await api("/api/validate", { method: "POST", body: JSON.stringify({ ir: App.ir }) });
|
||||
const d = await r.json();
|
||||
showValidate(d.errors || [], d.warnings || []);
|
||||
};
|
||||
function showValidate(errs, warns) {
|
||||
let h = "";
|
||||
if (!errs.length && !warns.length) h = '<div class="v-ok">✓ 校验通过,无错误无警告</div>';
|
||||
errs.forEach(e => h += '<div class="v-err">✗ ' + esc(e) + '</div>');
|
||||
warns.forEach(w => h += '<div class="v-warn">⚠ ' + esc(w) + '</div>');
|
||||
$("validate-body").innerHTML = h;
|
||||
$("validate-modal").classList.remove("hidden");
|
||||
}
|
||||
|
||||
// ---------- 状态 ----------
|
||||
async function setStatus(s) {
|
||||
const r = await api("/api/events/" + encodeURIComponent(App.current) + "/status", { method: "POST", body: JSON.stringify({ status: s, by: App.by }) });
|
||||
if (r.ok) { App.status = s; updateDirty(); await loadList(); }
|
||||
}
|
||||
$("btn-confirm").onclick = () => setStatus("confirmed");
|
||||
$("btn-discard").onclick = () => setStatus("discarded");
|
||||
|
||||
// ---------- 试走 ----------
|
||||
$("btn-playtest").onclick = () => Playtest.open(App.ir, App.dict);
|
||||
|
||||
// ---------- 导入 ----------
|
||||
let importFiles = []; // 当前已选文件
|
||||
function renderImportFiles() {
|
||||
const host = $("import-files"); host.innerHTML = "";
|
||||
importFiles.forEach((f, i) => {
|
||||
const d = document.createElement("div");
|
||||
d.className = "fileitem";
|
||||
d.innerHTML = '<span class="fn">' + esc(f.name) + '</span><span class="fsz">' + (f.size > 1024 ? (f.size / 1024).toFixed(1) + " KB" : f.size + " B") + '</span><button class="rm" title="移除">✕</button>';
|
||||
d.querySelector(".rm").onclick = () => { importFiles.splice(i, 1); renderImportFiles(); };
|
||||
host.appendChild(d);
|
||||
});
|
||||
}
|
||||
function addImportFiles(fileList) {
|
||||
for (const f of fileList) if (!importFiles.some(x => x.name === f.name && x.size === f.size)) importFiles.push(f);
|
||||
renderImportFiles();
|
||||
}
|
||||
$("btn-import").onclick = () => {
|
||||
importFiles = []; renderImportFiles();
|
||||
$("import-text").value = ""; $("import-result").textContent = "";
|
||||
$("import-modal").classList.remove("hidden");
|
||||
};
|
||||
$("import-drop").onclick = () => $("import-file").click();
|
||||
$("import-file").onchange = e => { addImportFiles(e.target.files); e.target.value = ""; };
|
||||
const drop = $("import-drop");
|
||||
["dragenter", "dragover"].forEach(ev => drop.addEventListener(ev, e => { e.preventDefault(); drop.classList.add("over"); }));
|
||||
["dragleave", "drop"].forEach(ev => drop.addEventListener(ev, e => { e.preventDefault(); drop.classList.remove("over"); }));
|
||||
drop.addEventListener("drop", e => { addImportFiles(e.dataTransfer.files); });
|
||||
|
||||
async function collectImportEvents() {
|
||||
const events = [];
|
||||
for (const f of importFiles) {
|
||||
let text;
|
||||
try { text = await f.text(); } catch (e) { throw new Error(f.name + ":读取失败"); }
|
||||
let data;
|
||||
try { data = JSON.parse(text); } catch (e) { throw new Error(f.name + ":JSON 解析失败 " + e.message); }
|
||||
(Array.isArray(data) ? data : [data]).forEach(x => events.push(x));
|
||||
}
|
||||
const pasted = $("import-text").value.trim();
|
||||
if (pasted) {
|
||||
let data;
|
||||
try { data = JSON.parse(pasted); } catch (e) { throw new Error("粘贴文本:JSON 解析失败 " + e.message); }
|
||||
(Array.isArray(data) ? data : [data]).forEach(x => events.push(x));
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
$("import-do").onclick = async () => {
|
||||
$("import-result").classList.add("err"); $("import-result").style.color = "";
|
||||
let events;
|
||||
try { events = await collectImportEvents(); } catch (e) { $("import-result").textContent = e.message; return; }
|
||||
if (!events.length) { $("import-result").textContent = "请先选择文件或粘贴 JSON"; return; }
|
||||
const r = await api("/api/import", { method: "POST", body: JSON.stringify({ events, by: App.by }) });
|
||||
const d = await r.json();
|
||||
$("import-result").textContent = "已导入 " + (d.saved || []).length + " 个" + ((d.errors || []).length ? "," + d.errors.join(";") : "");
|
||||
importFiles = []; renderImportFiles(); $("import-text").value = "";
|
||||
await loadList();
|
||||
};
|
||||
|
||||
// ---------- 导出 ----------
|
||||
$("btn-export").onclick = async () => {
|
||||
const r = await api("/api/export", { method: "POST", body: JSON.stringify({}) });
|
||||
if (r.ok) {
|
||||
const blob = await r.blob();
|
||||
const a = document.createElement("a"); a.href = URL.createObjectURL(blob);
|
||||
a.download = "story_export.zip"; a.click();
|
||||
} else {
|
||||
const d = await r.json().catch(() => ({}));
|
||||
let msg = d.error || "导出失败";
|
||||
if (d.report) { for (const g in d.report) { const e = d.report[g].errors; if (e.length) msg += "\n[" + g + "] " + e.join(";"); } }
|
||||
alert(msg);
|
||||
}
|
||||
};
|
||||
|
||||
// ---------- 遮罩关闭 ----------
|
||||
document.querySelectorAll(".modal-close").forEach(b => b.onclick = () => b.closest(".overlay").classList.add("hidden"));
|
||||
|
||||
// ---------- 工具 ----------
|
||||
function esc(s) { return String(s == null ? "" : s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">"); }
|
||||
window.addEventListener("resize", drawTree);
|
||||
|
||||
// ---------- 启动 ----------
|
||||
(async function () {
|
||||
try { const r = await fetch("/api/events?status=all"); if (r.status === 401) { showLogin(); return; } hideLogin(); init(); }
|
||||
catch (e) { showLogin(); }
|
||||
})();
|
||||
})();
|
||||
324
web/static/form.js
Normal file
324
web/static/form.js
Normal file
@ -0,0 +1,324 @@
|
||||
// 右栏表单编辑:元信息 + 角色表 + 按 kind 的节点表单 + grant/condition 编辑器。
|
||||
// ctx = { dict, pointNames:[], onChange(structural), selectNode(id) }
|
||||
// onChange(true) -> 结构变化(增删节点/选项、改 kind):调用方重画树 + 重渲表单
|
||||
// onChange(false) -> 仅字段值变化:调用方重画树(更新标签)
|
||||
|
||||
(function () {
|
||||
const NODE_KINDS = ["narration", "dialogue", "choice", "choice_once", "random",
|
||||
"fight", "move", "anim", "reward", "out_ref"];
|
||||
|
||||
// ---- DOM 小工具 ----
|
||||
function el(tag, attrs, kids) {
|
||||
const e = document.createElement(tag);
|
||||
if (attrs) for (const k in attrs) {
|
||||
if (k === "class") e.className = attrs[k];
|
||||
else if (k.startsWith("on")) e[k] = attrs[k];
|
||||
else if (attrs[k] != null) e.setAttribute(k, attrs[k]);
|
||||
}
|
||||
(kids || []).forEach(c => e.appendChild(typeof c === "string" ? document.createTextNode(c) : c));
|
||||
return e;
|
||||
}
|
||||
function field(label, input) { return el("div", { class: "fld" }, [el("label", {}, [label]), input]); }
|
||||
function txt(val, oninput) { const i = el("input", { type: "text", value: val == null ? "" : val }); i.oninput = () => oninput(i.value); return i; }
|
||||
function area(val, oninput) { const t = el("textarea", {}, [val || ""]); t.oninput = () => oninput(t.value); return t; }
|
||||
function num(val, oninput) { const i = el("input", { type: "number", value: val == null ? "" : val }); i.oninput = () => oninput(i.value === "" ? null : Number(i.value)); return i; }
|
||||
function sel(val, opts, onchange) {
|
||||
const s = el("select");
|
||||
opts.forEach(o => { const op = el("option", { value: o.value }, [o.label]); if (String(o.value) === String(val)) op.selected = true; s.appendChild(op); });
|
||||
s.onchange = () => onchange(s.value);
|
||||
return s;
|
||||
}
|
||||
|
||||
// ---- 选项来源 ----
|
||||
function targets(ir) {
|
||||
const ids = (ir.nodes || []).map(n => n.id).concat((ir.endings || []).map(e => e.id));
|
||||
return [{ value: "", label: "(无 / 留空)" }].concat(ids.map(i => ({ value: i, label: i })));
|
||||
}
|
||||
function slots(ir, withPlayer) {
|
||||
const list = (ir.roles || []).map(r => ({ value: r.slot, label: r.slot + " " + r.name }));
|
||||
return (withPlayer ? [] : []).concat(list);
|
||||
}
|
||||
function pointOpts(ir, ctx, cur) {
|
||||
const set = new Set((ctx.pointNames || []).concat((ir.roles || []).map(r => r.slot)));
|
||||
if (cur) set.add(cur);
|
||||
return [{ value: "", label: "(无)" }].concat([...set].map(p => ({ value: p, label: p })));
|
||||
}
|
||||
function grantKinds(ctx) { return Object.keys((ctx.dict || {}).grants || {}); }
|
||||
function condKinds(ctx) { return Object.keys((ctx.dict || {}).conditions || {}); }
|
||||
|
||||
// ---- grant 编辑器 ----
|
||||
function grantsEditor(ir, ctx, grants, onMut) {
|
||||
grants = grants || [];
|
||||
const box = el("div", { class: "subbox" });
|
||||
box.appendChild(el("div", { class: "hd" }, [
|
||||
el("span", {}, ["奖励 grants"]),
|
||||
el("button", { class: "mini", onclick: () => { grants.push({ kind: grantKinds(ctx)[0], value: 0 }); onMut(grants); } }, ["+"]),
|
||||
]));
|
||||
grants.forEach((g, i) => {
|
||||
const row = el("div", { class: "fld" });
|
||||
const head = el("div", { class: "row2" }, [
|
||||
sel(g.kind, grantKinds(ctx).map(k => ({ value: k, label: k })), v => { grants[i] = { kind: v, value: 0 }; onMut(grants); }),
|
||||
el("button", { class: "mini", onclick: () => { grants.splice(i, 1); onMut(grants); } }, ["删"]),
|
||||
]);
|
||||
row.appendChild(head);
|
||||
const form = ((ctx.dict.grants[g.kind]) || {}).form;
|
||||
const fields = el("div", { class: "row2" });
|
||||
if (form === "money") fields.appendChild(field("数值(±)", num(g.value, v => { g.value = v; onMut(grants, true); })));
|
||||
else if (form === "item") {
|
||||
fields.appendChild(field("道具ID", txt(g.item, v => { g.item = v; onMut(grants, true); })));
|
||||
fields.appendChild(field("数量", num(g.value, v => { g.value = v; onMut(grants, true); })));
|
||||
} else if (form === "friend") {
|
||||
fields.appendChild(field("对象", sel(g.target, slots(ir), v => { g.target = v; onMut(grants, true); })));
|
||||
fields.appendChild(field("数值", num(g.value, v => { g.value = v; onMut(grants, true); })));
|
||||
} else if (form === "join") {
|
||||
fields.appendChild(field("门派(对象)", sel(g.target, slots(ir), v => { g.target = v; onMut(grants, true); })));
|
||||
}
|
||||
if (fields.children.length) row.appendChild(fields);
|
||||
box.appendChild(row);
|
||||
});
|
||||
if (!grants.length) box.appendChild(el("div", { class: "empty" }, ["无"]));
|
||||
return box;
|
||||
}
|
||||
|
||||
// ---- condition 编辑器 ----
|
||||
function condEditor(ir, ctx, cond, setCond) {
|
||||
const box = el("div", { class: "subbox" });
|
||||
box.appendChild(el("div", { class: "hd" }, [
|
||||
el("span", {}, ["条件 condition"]),
|
||||
el("button", { class: "mini", onclick: () => setCond(cond ? null : { kind: condKinds(ctx)[0], op: ">=", value: 0 }) }, [cond ? "移除" : "+"]),
|
||||
]));
|
||||
if (cond) {
|
||||
const ops = Object.keys((ctx.dict.conditions[cond.kind] || {}).ops || { ">=": 1 });
|
||||
box.appendChild(el("div", { class: "row2" }, [
|
||||
sel(cond.kind, condKinds(ctx).map(k => ({ value: k, label: k })), v => { cond.kind = v; setCond(cond, true); }),
|
||||
sel(cond.op, ops.map(o => ({ value: o, label: o })), v => { cond.op = v; setCond(cond, true); }),
|
||||
num(cond.value, v => { cond.value = v; setCond(cond, true); }),
|
||||
]));
|
||||
}
|
||||
return box;
|
||||
}
|
||||
|
||||
// ========== 元信息 + 角色表 ==========
|
||||
window.FormUI = window.FormUI || {};
|
||||
FormUI.renderMeta = function (ir, ctx) {
|
||||
const host = document.getElementById("meta-edit");
|
||||
host.innerHTML = "";
|
||||
const psHint = (ctx.pointNames && ctx.pointNames.length)
|
||||
? ("点位集: " + ctx.pointNames.length + " 点") : "(无点位集,坐标校验降级警告)";
|
||||
host.appendChild(field("标题", txt(ir.title, v => { ir.title = v; ctx.onChange(false); })));
|
||||
host.appendChild(el("div", { class: "row2" }, [
|
||||
field("主题", txt(ir.theme, v => { ir.theme = v; ctx.onChange(false); })),
|
||||
field("规模", txt(ir.scale, v => { ir.scale = v; ctx.onChange(false); })),
|
||||
]));
|
||||
const stage = ir.stage || (ir.stage = {});
|
||||
host.appendChild(field("舞台 / " + psHint, txt(stage.type, v => { stage.type = v; ctx.onChange(false); })));
|
||||
|
||||
// 角色表
|
||||
const box = el("div", { class: "subbox" });
|
||||
box.appendChild(el("div", { class: "hd" }, [
|
||||
el("span", {}, ["角色表 roles"]),
|
||||
el("button", { class: "mini", onclick: () => {
|
||||
const n = (ir.roles || []).length;
|
||||
(ir.roles = ir.roles || []).push({ slot: "NP" + n, name: "新角色", archetype: "", camp: 0 });
|
||||
ctx.onChange(true);
|
||||
} }, ["+角色"]),
|
||||
]));
|
||||
(ir.roles || []).forEach((r, i) => {
|
||||
const row = el("div", { class: "fld" }, [
|
||||
el("div", { class: "row2" }, [
|
||||
txt(r.slot, v => { r.slot = v; ctx.onChange(false); }),
|
||||
txt(r.name, v => { r.name = v; ctx.onChange(false); }),
|
||||
]),
|
||||
el("div", { class: "row2" }, [
|
||||
txt(r.archetype, v => { r.archetype = v; ctx.onChange(false); }),
|
||||
sel(r.camp, [0, 1, 2].map(c => ({ value: c, label: "阵营" + c })), v => { r.camp = Number(v); ctx.onChange(false); }),
|
||||
el("button", { class: "mini", onclick: () => { ir.roles.splice(i, 1); ctx.onChange(true); } }, ["删"]),
|
||||
]),
|
||||
]);
|
||||
box.appendChild(row);
|
||||
});
|
||||
host.appendChild(box);
|
||||
};
|
||||
|
||||
// ========== 节点表单 ==========
|
||||
FormUI.renderNode = function (ir, id, ctx) {
|
||||
const host = document.getElementById("node-edit");
|
||||
host.innerHTML = "";
|
||||
if (!id) { host.appendChild(el("div", { class: "empty" }, ["点击中间任意节点进行编辑"])); return; }
|
||||
|
||||
const isEnding = (ir.endings || []).some(e => e.id === id);
|
||||
const node = isEnding ? (ir.endings.find(e => e.id === id))
|
||||
: (ir.nodes.find(n => n.id === id));
|
||||
if (!node) { host.appendChild(el("div", { class: "empty" }, ["节点已删除"])); return; }
|
||||
|
||||
const head = el("div", { class: "fld inline" }, [
|
||||
el("span", { class: "node-id" }, ["#" + id]),
|
||||
]);
|
||||
host.appendChild(head);
|
||||
|
||||
if (isEnding) { renderEnding(host, ir, node, ctx); return; }
|
||||
|
||||
// kind 切换
|
||||
host.appendChild(field("类型 kind", sel(node.kind, NODE_KINDS.map(k => ({ value: k, label: k })), v => {
|
||||
node.kind = v; ctx.onChange(true);
|
||||
})));
|
||||
|
||||
const mut = (s) => ctx.onChange(!!s ? false : true); // mut(true)=值改, 默认结构改
|
||||
const tgt = targets(ir);
|
||||
|
||||
if (node.kind === "narration") {
|
||||
host.appendChild(field("说话者(可选)", sel(node.speaker || "P1", [{ value: "P1", label: "P1 玩家" }].concat(slots(ir)), v => { node.speaker = v; mut(1); })));
|
||||
host.appendChild(field("文本", area(node.text, v => { node.text = v; mut(1); })));
|
||||
host.appendChild(field("下一步 next", sel(node.next, tgt, v => { node.next = v; ctx.onChange(false); })));
|
||||
} else if (node.kind === "dialogue") {
|
||||
host.appendChild(field("说话者 speaker", sel(node.speaker, [{ value: "P1", label: "P1 玩家" }].concat(slots(ir)), v => { node.speaker = v; mut(1); })));
|
||||
host.appendChild(field("镜头 camera(点位,可选)", sel(node.camera, pointOpts(ir, ctx, node.camera), v => { node.camera = v || undefined; mut(1); })));
|
||||
host.appendChild(field("文本", area(node.text, v => { node.text = v; mut(1); })));
|
||||
host.appendChild(field("下一步 next", sel(node.next, tgt, v => { node.next = v; ctx.onChange(false); })));
|
||||
} else if (node.kind === "choice" || node.kind === "choice_once") {
|
||||
const box = el("div", { class: "subbox" });
|
||||
box.appendChild(el("div", { class: "hd" }, [
|
||||
el("span", {}, ["选项 options"]),
|
||||
el("button", { class: "mini", onclick: () => { (node.options = node.options || []).push({ text: "新选项", goto: "" }); ctx.onChange(true); } }, ["+选项"]),
|
||||
]));
|
||||
(node.options || []).forEach((o, i) => {
|
||||
const ob = el("div", { class: "subbox" });
|
||||
ob.appendChild(el("div", { class: "hd" }, [
|
||||
el("span", {}, ["选项 " + (i + 1)]),
|
||||
el("button", { class: "mini", onclick: () => { node.options.splice(i, 1); ctx.onChange(true); } }, ["删"]),
|
||||
]));
|
||||
ob.appendChild(field("文本", txt(o.text, v => { o.text = v; ctx.onChange(false); })));
|
||||
ob.appendChild(field("跳转 goto", sel(o.goto, tgt, v => { o.goto = v; ctx.onChange(false); })));
|
||||
ob.appendChild(condEditor(ir, ctx, o.condition, (c, valOnly) => { if (c) o.condition = c; else delete o.condition; ctx.onChange(!valOnly); }));
|
||||
ob.appendChild(grantsEditor(ir, ctx, (o.reward || {}).grants, (gr, valOnly) => { o.reward = { grants: gr }; ctx.onChange(!valOnly); }));
|
||||
// skip
|
||||
const skBox = el("div", { class: "subbox" });
|
||||
skBox.appendChild(el("div", { class: "hd" }, [
|
||||
el("span", {}, ["押注跳过 skip"]),
|
||||
el("button", { class: "mini", onclick: () => { if (o.skip) delete o.skip; else o.skip = { node: "", reward: { grants: [] } }; ctx.onChange(true); } }, [o.skip ? "移除" : "+"]),
|
||||
]));
|
||||
if (o.skip) {
|
||||
skBox.appendChild(field("结算目标节点", sel(o.skip.node, tgt, v => { o.skip.node = v; ctx.onChange(false); })));
|
||||
skBox.appendChild(grantsEditor(ir, ctx, (o.skip.reward || {}).grants, (gr, valOnly) => { o.skip.reward = { grants: gr }; ctx.onChange(!valOnly); }));
|
||||
}
|
||||
ob.appendChild(skBox);
|
||||
box.appendChild(ob);
|
||||
});
|
||||
host.appendChild(box);
|
||||
} else if (node.kind === "random") {
|
||||
const box = el("div", { class: "subbox" });
|
||||
box.appendChild(el("div", { class: "hd" }, [
|
||||
el("span", {}, ["随机分支 branches"]),
|
||||
el("button", { class: "mini", onclick: () => { (node.branches = node.branches || []).push({ weight: 1, goto: "" }); ctx.onChange(true); } }, ["+分支"]),
|
||||
]));
|
||||
(node.branches || []).forEach((b, i) => {
|
||||
box.appendChild(el("div", { class: "row2" }, [
|
||||
field("权重", num(b.weight, v => { b.weight = v; ctx.onChange(false); })),
|
||||
field("跳转", sel(b.goto, tgt, v => { b.goto = v; ctx.onChange(false); })),
|
||||
el("button", { class: "mini", onclick: () => { node.branches.splice(i, 1); ctx.onChange(true); } }, ["删"]),
|
||||
]));
|
||||
});
|
||||
host.appendChild(box);
|
||||
} else if (node.kind === "fight") {
|
||||
host.appendChild(field("战斗类型", sel(node.fight_type, [{ value: 1, label: "1 击倒" }, { value: 2, label: "2 死斗" }], v => { node.fight_type = Number(v); ctx.onChange(false); })));
|
||||
host.appendChild(campPicker("我方 camp1", ir, node, "camp1", ctx, true));
|
||||
host.appendChild(campPicker("敌方 camp2", ir, node, "camp2", ctx, false));
|
||||
host.appendChild(el("div", { class: "row2" }, [
|
||||
field("胜 → win", sel(node.win, tgt, v => { node.win = v; ctx.onChange(false); })),
|
||||
field("败 → lose", sel(node.lose, tgt, v => { node.lose = v; ctx.onChange(false); })),
|
||||
]));
|
||||
} else if (node.kind === "move") {
|
||||
host.appendChild(field("移动者 actor", sel(node.actor, [{ value: "P1", label: "P1 玩家" }].concat(slots(ir)), v => { node.actor = v; mut(1); })));
|
||||
host.appendChild(field("目标点 to", sel(node.to, pointOpts(ir, ctx, node.to), v => { node.to = v; ctx.onChange(false); })));
|
||||
host.appendChild(field("模式 mode", sel(node.mode || "walk", [{ value: "walk", label: "walk 行走" }, { value: "teleport", label: "teleport 瞬移" }, { value: "remove", label: "remove 移除" }], v => { node.mode = v; ctx.onChange(true); })));
|
||||
if ((node.mode || "walk") === "walk") {
|
||||
host.appendChild(el("div", { class: "row2" }, [
|
||||
field("速度 speed", num(node.speed == null ? 6 : node.speed, v => { node.speed = v; ctx.onChange(false); })),
|
||||
field("动作 ani", txt(node.ani, v => { node.ani = v; ctx.onChange(false); })),
|
||||
]));
|
||||
}
|
||||
host.appendChild(field("下一步 next", sel(node.next, tgt, v => { node.next = v; ctx.onChange(false); })));
|
||||
} else if (node.kind === "anim") {
|
||||
host.appendChild(field("角色 actor", sel(node.actor, [{ value: "P1", label: "P1 玩家" }].concat(slots(ir)), v => { node.actor = v; mut(1); })));
|
||||
host.appendChild(el("div", { class: "row2" }, [
|
||||
field("动画 ani", txt(node.ani, v => { node.ani = v; ctx.onChange(false); })),
|
||||
field("朝向 angle(可选)", num(node.angle, v => { if (v == null) delete node.angle; else node.angle = v; ctx.onChange(false); })),
|
||||
]));
|
||||
host.appendChild(field("下一步 next", sel(node.next, tgt, v => { node.next = v; ctx.onChange(false); })));
|
||||
} else if (node.kind === "reward") {
|
||||
host.appendChild(grantsEditor(ir, ctx, node.grants, (gr, valOnly) => { node.grants = gr; ctx.onChange(!valOnly); }));
|
||||
host.appendChild(field("下一步 next", sel(node.next, tgt, v => { node.next = v; ctx.onChange(false); })));
|
||||
} else if (node.kind === "out_ref") {
|
||||
const seqs = (ir.sequences || []).map(s => ({ value: s.id, label: s.id }));
|
||||
host.appendChild(field("引用子序列 ref", sel(node.ref, [{ value: "", label: "(无)" }].concat(seqs), v => { node.ref = v; ctx.onChange(false); })));
|
||||
host.appendChild(field("出口接回 next", sel(node.next, tgt, v => { node.next = v; ctx.onChange(false); })));
|
||||
}
|
||||
|
||||
// 删除节点
|
||||
host.appendChild(el("div", { class: "fld" }, [
|
||||
el("button", { class: "mini", onclick: () => {
|
||||
if (!confirm("删除节点 " + id + "?指向它的跳转需手动修复(校验会提示)。")) return;
|
||||
ir.nodes = ir.nodes.filter(n => n.id !== id);
|
||||
ctx.selectNode(null); ctx.onChange(true);
|
||||
} }, ["删除此节点"]),
|
||||
]));
|
||||
};
|
||||
|
||||
function renderEnding(host, ir, e, ctx) {
|
||||
host.appendChild(field("结局摘要 summary", txt(e.summary, v => { e.summary = v; ctx.onChange(false); })));
|
||||
host.appendChild(field("结果 result", sel(e.result || "success", [
|
||||
{ value: "success", label: "success 成功" }, { value: "fail", label: "fail 失败" }, { value: "end", label: "end 中性" }], v => { e.result = v; ctx.onChange(false); })));
|
||||
host.appendChild(grantsEditor(ir, ctx, e.grants, (gr, valOnly) => { e.grants = gr; ctx.onChange(!valOnly); }));
|
||||
}
|
||||
|
||||
function campPicker(label, ir, node, key, ctx, withPlayer) {
|
||||
const cur = new Set(node[key] || []);
|
||||
const wrap = el("div", { class: "fld" }, [el("label", {}, [label])]);
|
||||
const pick = el("div", { class: "tag-pick" });
|
||||
const all = (withPlayer ? [{ slot: "P1", name: "玩家" }] : []).concat(ir.roles || []);
|
||||
all.forEach(r => {
|
||||
const cb = el("input", { type: "checkbox" }); cb.checked = cur.has(r.slot);
|
||||
cb.onchange = () => {
|
||||
const arr = new Set(node[key] || []);
|
||||
if (cb.checked) arr.add(r.slot); else arr.delete(r.slot);
|
||||
node[key] = [...arr]; ctx.onChange(false);
|
||||
};
|
||||
pick.appendChild(el("label", {}, [cb, r.slot]));
|
||||
});
|
||||
wrap.appendChild(pick);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
// 新建节点:分配唯一 id
|
||||
FormUI.newNode = function (ir) {
|
||||
let i = (ir.nodes || []).length + 1, id;
|
||||
do { id = "n" + i++; } while ((ir.nodes || []).some(n => n.id === id) || (ir.endings || []).some(e => e.id === id));
|
||||
(ir.nodes = ir.nodes || []).push({ id, kind: "dialogue", speaker: "P1", text: "新对话", next: "" });
|
||||
return id;
|
||||
};
|
||||
|
||||
// 在指定节点后追加一个新节点,并按其类型自动接线;返回新节点 id(结局/不存在返回 null)。
|
||||
// 线性类型(next):插入到当前与原后继之间;choice/random:追加一个选项/分支;fight:填空缺的胜/败出口。
|
||||
FormUI.addSuccessor = function (ir, id) {
|
||||
const node = (ir.nodes || []).find(n => n.id === id);
|
||||
if (!node) return null; // 结局节点等无后继
|
||||
const nid = FormUI.newNode(ir);
|
||||
const kind = node.kind;
|
||||
let linked = true;
|
||||
if (kind === "choice" || kind === "choice_once") {
|
||||
(node.options = node.options || []).push({ text: "新选项", goto: nid });
|
||||
} else if (kind === "random") {
|
||||
(node.branches = node.branches || []).push({ weight: 1, goto: nid });
|
||||
} else if (kind === "fight") {
|
||||
if (!node.win) node.win = nid;
|
||||
else if (!node.lose) node.lose = nid;
|
||||
else linked = false; // 胜败出口都已占用:新节点孤立
|
||||
} else {
|
||||
// 线性 next 类型:插入到当前与原后继之间,不破坏原有链路
|
||||
const fresh = ir.nodes.find(n => n.id === nid);
|
||||
fresh.next = node.next || "";
|
||||
node.next = nid;
|
||||
}
|
||||
return { id: nid, linked: linked };
|
||||
};
|
||||
})();
|
||||
115
web/static/index.html
Normal file
115
web/static/index.html
Normal file
@ -0,0 +1,115 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Story 协作编辑器 · M5</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- 登录遮罩 -->
|
||||
<div id="login" class="overlay">
|
||||
<div class="login-box">
|
||||
<h2>剧情事件协作编辑器</h2>
|
||||
<p class="hint">输入共享口令进入</p>
|
||||
<input id="login-pass" type="password" placeholder="共享口令" autocomplete="off">
|
||||
<input id="login-name" type="text" placeholder="你的昵称(用于记录改动者)">
|
||||
<button id="login-btn">进入</button>
|
||||
<div id="login-err" class="err"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<header>
|
||||
<h1>剧情事件协作编辑器 <span class="ver">M5</span></h1>
|
||||
<div class="toolbar">
|
||||
<button id="btn-save" class="primary" disabled>保存</button>
|
||||
<button id="btn-validate" disabled>校验</button>
|
||||
<button id="btn-playtest" disabled>试走</button>
|
||||
<span class="sep"></span>
|
||||
<button id="btn-confirm" disabled>确认</button>
|
||||
<button id="btn-discard" disabled>丢弃</button>
|
||||
<span class="sep"></span>
|
||||
<button id="btn-import">导入 IR</button>
|
||||
<button id="btn-export">导出 confirmed</button>
|
||||
<span class="who" id="who"></span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div id="wrap">
|
||||
<!-- 左:事件列表 -->
|
||||
<aside id="list-pane">
|
||||
<div class="filters">
|
||||
<select id="filter-status">
|
||||
<option value="all">全部</option>
|
||||
<option value="pending">待审</option>
|
||||
<option value="confirmed">已确认</option>
|
||||
<option value="discarded">已丢弃</option>
|
||||
</select>
|
||||
<input id="search" type="text" placeholder="搜索标题/group">
|
||||
</div>
|
||||
<div id="event-list"></div>
|
||||
</aside>
|
||||
|
||||
<!-- 中:分支树 -->
|
||||
<main id="graph-pane">
|
||||
<div id="graph"><svg id="svg"></svg><div id="layers"></div></div>
|
||||
<div id="graph-empty" class="empty-center">从左侧选择一个事件</div>
|
||||
</main>
|
||||
|
||||
<!-- 右:表单编辑 -->
|
||||
<section id="edit-pane">
|
||||
<div id="meta-edit"></div>
|
||||
<div class="sec-title">节点编辑 <button id="btn-addnode" class="mini" disabled>+节点</button></div>
|
||||
<div id="node-edit"><div class="empty">点击中间任意节点进行编辑</div></div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- 校验结果遮罩 -->
|
||||
<div id="validate-modal" class="overlay hidden">
|
||||
<div class="modal">
|
||||
<h3>校验结果</h3>
|
||||
<div id="validate-body"></div>
|
||||
<button class="modal-close">关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 导入遮罩 -->
|
||||
<div id="import-modal" class="overlay hidden">
|
||||
<div class="modal">
|
||||
<h3>导入 IR(选择一个或多个 JSON 文件)</h3>
|
||||
<div id="import-drop" class="drop">
|
||||
<p>点击选择文件,或把 .json 文件拖到这里</p>
|
||||
<p class="hint">每个文件可为单个事件对象,或事件数组;支持多选</p>
|
||||
<input id="import-file" type="file" accept=".json,application/json" multiple hidden>
|
||||
</div>
|
||||
<div id="import-files" class="filelist"></div>
|
||||
<details class="import-paste">
|
||||
<summary>或粘贴 JSON 文本</summary>
|
||||
<textarea id="import-text" placeholder='{"id":"QY_XXX", ...} 或 [ {...}, {...} ]'></textarea>
|
||||
</details>
|
||||
<div class="modal-actions">
|
||||
<button id="import-do" class="primary">导入</button>
|
||||
<button class="modal-close">取消</button>
|
||||
</div>
|
||||
<div id="import-result" class="err"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 试走遮罩 -->
|
||||
<div id="playtest-modal" class="overlay hidden">
|
||||
<div class="modal wide">
|
||||
<h3>剧本试走 <button class="modal-close" style="float:right">关闭</button></h3>
|
||||
<div id="pt-layout">
|
||||
<div id="pt-flow"></div>
|
||||
<div id="pt-ledger"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="tree.js"></script>
|
||||
<script src="form.js"></script>
|
||||
<script src="playtest.js"></script>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
171
web/static/playtest.js
Normal file
171
web/static/playtest.js
Normal file
@ -0,0 +1,171 @@
|
||||
// 剧本试走:从首节点走,点选项/掷随机/手选战斗胜负,模拟银两/道具/友好度账面。
|
||||
// out_ref 用返回栈进出子序列;skip 选项命中目标结局时按 skipReward 覆盖结算(对齐运行时)。
|
||||
|
||||
(function () {
|
||||
let IR, DICT, flow, ledger, state, retStack, pendingSkip;
|
||||
|
||||
function nameOf(s) { const r = (IR.roles || []).find(x => x.slot === s); return s === "P1" ? "玩家" : (r ? r.name : s); }
|
||||
function grantForm(kind) { return ((DICT.grants || {})[kind] || {}).form; }
|
||||
|
||||
function seqMap() { const m = {}; (IR.sequences || []).forEach(s => m[s.id] = s); return m; }
|
||||
function mainNode(id) { return (IR.nodes || []).find(n => n.id === id); }
|
||||
function ending(id) { return (IR.endings || []).find(e => e.id === id); }
|
||||
function nodeAt(loc) {
|
||||
if (!loc) return null;
|
||||
if (loc.seq) { const s = seqMap()[loc.seq]; return s && s.nodes.find(n => n.id === loc.id); }
|
||||
return mainNode(loc.id) || ending(loc.id);
|
||||
}
|
||||
function seqHas(seqId, id) { const s = seqMap()[seqId]; return s && s.nodes.some(n => n.id === id); }
|
||||
|
||||
function advance(loc) {
|
||||
const n = nodeAt(loc); const nx = n && n.next;
|
||||
if (nx) {
|
||||
if (loc.seq && seqHas(loc.seq, nx)) return { seq: loc.seq, id: nx };
|
||||
return { seq: null, id: nx };
|
||||
}
|
||||
if (loc.seq) { const ex = retStack.pop(); return ex ? { seq: null, id: ex } : null; }
|
||||
return null;
|
||||
}
|
||||
function enterSeq(seqId, exit) {
|
||||
retStack.push(exit || "");
|
||||
const s = seqMap()[seqId];
|
||||
if (!s || !(s.nodes || []).length) return null;
|
||||
return { seq: seqId, id: s.nodes[0].id };
|
||||
}
|
||||
|
||||
function applyGrants(grants, srcLabel) {
|
||||
(grants || []).forEach(g => {
|
||||
const f = grantForm(g.kind);
|
||||
if (f === "money") state.银两 += (g.value || 0);
|
||||
else if (f === "item") state.道具[g.item] = (state.道具[g.item] || 0) + (g.value || 0);
|
||||
else if (f === "friend") state.友好度[g.target] = (state.友好度[g.target] || 0) + (g.value || 0);
|
||||
else if (f === "join") state.入门.push(nameOf(g.target));
|
||||
});
|
||||
if (grants && grants.length) renderLedger();
|
||||
}
|
||||
|
||||
function condMet(c) {
|
||||
if (!c) return true;
|
||||
if (c.kind === "银两") {
|
||||
if (c.op === ">=") return state.银两 >= c.value;
|
||||
if (c.op === ">") return state.银两 > c.value;
|
||||
if (c.op === "<=") return state.银两 <= c.value;
|
||||
if (c.op === "<") return state.银两 < c.value;
|
||||
if (c.op === "==") return state.银两 === c.value;
|
||||
}
|
||||
return true; // 未知条件不拦
|
||||
}
|
||||
|
||||
// ---- 渲染 ----
|
||||
function add(cls, html) { const d = document.createElement("div"); d.className = "pt-step " + cls; d.innerHTML = html; flow.appendChild(d); flow.scrollTop = flow.scrollHeight; return d; }
|
||||
function esc(s) { return String(s == null ? "" : s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">"); }
|
||||
|
||||
function renderLedger() {
|
||||
let h = "<h4>账面</h4>";
|
||||
h += '<div class="lg">银两:<b>' + state.银两 + "</b></div>";
|
||||
const items = Object.entries(state.道具).filter(([, v]) => v);
|
||||
h += '<div class="lg">道具:' + (items.length ? items.map(([k, v]) => k + "×" + v).join(",") : "—") + "</div>";
|
||||
const fr = Object.entries(state.友好度).filter(([, v]) => v);
|
||||
h += '<div class="lg">友好度:' + (fr.length ? fr.map(([k, v]) => nameOf(k) + "+" + v).join(",") : "—") + "</div>";
|
||||
if (state.入门.length) h += '<div class="lg">入门:' + state.入门.join(",") + "</div>";
|
||||
ledger.innerHTML = h;
|
||||
}
|
||||
|
||||
function walk(loc) {
|
||||
let guard = 0;
|
||||
while (loc && guard++ < 500) {
|
||||
const n = nodeAt(loc);
|
||||
if (!n) { add("sys", "⚠ 断链:目标节点不存在,流程中断"); return; }
|
||||
const isEnd = !loc.seq && ending(loc.id);
|
||||
if (isEnd) {
|
||||
const e = n;
|
||||
let grants = e.grants;
|
||||
if (pendingSkip && pendingSkip.node === loc.id) { grants = pendingSkip.grants; add("sys", "(押注命中:结算改用 skip 彩头)"); }
|
||||
applyGrants(grants, "结局");
|
||||
const res = { success: "成功", fail: "失败", end: "中性" }[e.result || "success"];
|
||||
add("end", "★ 结局:" + esc(e.summary || loc.id) + "(" + res + ")");
|
||||
add("sys", "—— 试走结束 ——");
|
||||
return;
|
||||
}
|
||||
const k = n.kind;
|
||||
if (k === "narration") { add("spk", "<b>" + esc(nameOf(n.speaker || "P1")) + "</b>:" + esc(n.text)); loc = advance(loc); }
|
||||
else if (k === "dialogue") { add("spk", "<b>" + esc(nameOf(n.speaker)) + "</b>:" + esc(n.text)); loc = advance(loc); }
|
||||
else if (k === "move") { add("sys", "〔走位〕" + nameOf(n.actor) + " → " + (n.to || "")); loc = advance(loc); }
|
||||
else if (k === "anim") { add("sys", "〔动画〕" + nameOf(n.actor) + " " + (n.ani || "")); loc = advance(loc); }
|
||||
else if (k === "reward") { applyGrants(n.grants, "结算"); add("sys", "〔奖励结算〕"); loc = advance(loc); }
|
||||
else if (k === "out_ref") { add("sys", "〔进入子序列 " + n.ref + "〕"); loc = enterSeq(n.ref, n.next); }
|
||||
else if (k === "choice" || k === "choice_once") { renderChoice(n); return; }
|
||||
else if (k === "random") { renderRandom(n); return; }
|
||||
else if (k === "fight") { renderFight(n); return; }
|
||||
else { add("sys", "未知节点类型 " + k); loc = advance(loc); }
|
||||
}
|
||||
if (guard >= 500) add("sys", "⚠ 步数超限(疑似循环),中断");
|
||||
}
|
||||
|
||||
function choices(prompt, list) {
|
||||
const box = document.createElement("div"); box.className = "pt-step pt-choices";
|
||||
box.appendChild(Object.assign(document.createElement("div"), { className: "pt-q", textContent: prompt }));
|
||||
list.forEach(it => {
|
||||
const b = document.createElement("button");
|
||||
b.textContent = it.label; if (it.locked) b.className = "locked";
|
||||
b.onclick = () => { box.querySelectorAll("button").forEach(x => x.disabled = true); b.style.borderColor = "#e6c878"; it.act(); };
|
||||
box.appendChild(b);
|
||||
});
|
||||
flow.appendChild(box); flow.scrollTop = flow.scrollHeight;
|
||||
}
|
||||
|
||||
function renderChoice(n) {
|
||||
choices("请选择:", (n.options || []).map(o => {
|
||||
const ok = condMet(o.condition);
|
||||
let label = o.text;
|
||||
if (o.condition) label += " [需 " + o.condition.kind + o.condition.op + o.condition.value + (ok ? " ✓" : " ✗") + "]";
|
||||
if (o.skip) label += " 〔押注跳过〕";
|
||||
return {
|
||||
label, locked: !ok, act: () => {
|
||||
applyGrants((o.reward || {}).grants, "选项");
|
||||
if (o.skip) pendingSkip = { node: o.skip.node, grants: (o.skip.reward || {}).grants || [] };
|
||||
walk({ seq: null, id: o.goto });
|
||||
}
|
||||
};
|
||||
}));
|
||||
}
|
||||
|
||||
function renderRandom(n) {
|
||||
const total = (n.branches || []).reduce((s, b) => s + (b.weight || 0), 0) || 1;
|
||||
choices("随机分支(手选):", (n.branches || []).map((b, i) => ({
|
||||
label: "分支" + (i + 1) + "(权重 " + b.weight + ",约 " + Math.round(b.weight / total * 100) + "%)→ " + b.goto,
|
||||
act: () => walk({ seq: null, id: b.goto })
|
||||
})));
|
||||
}
|
||||
|
||||
function renderFight(n) {
|
||||
choices("战斗 vs " + (n.camp2 || []).map(nameOf).join("、") + " —— 手选结果:", [
|
||||
{ label: "胜 → " + n.win, act: () => walk({ seq: null, id: n.win }) },
|
||||
{ label: "败 → " + n.lose, act: () => walk({ seq: null, id: n.lose }) },
|
||||
]);
|
||||
}
|
||||
|
||||
function firstNode() {
|
||||
const indeg = {}; (IR.nodes || []).forEach(n => indeg[n.id] = 0);
|
||||
(IR.nodes || []).forEach(n => {
|
||||
const outs = [n.next].concat((n.options || []).map(o => o.goto), (n.branches || []).map(b => b.goto), n.kind === "fight" ? [n.win, n.lose] : []);
|
||||
outs.forEach(t => { if (t in indeg) indeg[t]++; });
|
||||
});
|
||||
const roots = (IR.nodes || []).filter(n => indeg[n.id] === 0);
|
||||
return (roots[0] || (IR.nodes || [])[0] || {}).id;
|
||||
}
|
||||
|
||||
window.Playtest = {
|
||||
open(ir, dict) {
|
||||
IR = ir; DICT = dict;
|
||||
flow = document.getElementById("pt-flow"); ledger = document.getElementById("pt-ledger");
|
||||
flow.innerHTML = ""; state = { 银两: 0, 道具: {}, 友好度: {}, 入门: [] };
|
||||
retStack = []; pendingSkip = null;
|
||||
renderLedger();
|
||||
document.getElementById("playtest-modal").classList.remove("hidden");
|
||||
const start = firstNode();
|
||||
if (!start) { add("sys", "没有可走的节点"); return; }
|
||||
walk({ seq: null, id: start });
|
||||
}
|
||||
};
|
||||
})();
|
||||
156
web/static/style.css
Normal file
156
web/static/style.css
Normal file
@ -0,0 +1,156 @@
|
||||
* { box-sizing: border-box; }
|
||||
body { margin:0; font-family:"Microsoft YaHei","PingFang SC",sans-serif;
|
||||
background:#161310; color:#e8e0d4; height:100vh; overflow:hidden;
|
||||
display:flex; flex-direction:column; }
|
||||
|
||||
/* ---- header / toolbar ---- */
|
||||
header { padding:10px 18px; background:#1f1a15; border-bottom:1px solid #3a322a;
|
||||
display:flex; align-items:center; gap:18px; flex:none; }
|
||||
header h1 { margin:0; font-size:18px; color:#e6c878; white-space:nowrap; }
|
||||
header h1 .ver { font-size:12px; color:#9a8f7e; }
|
||||
.toolbar { display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
|
||||
.toolbar .sep { width:1px; height:20px; background:#3a322a; margin:0 4px; }
|
||||
.toolbar .who { margin-left:10px; font-size:12px; color:#9a8f7e; }
|
||||
button { background:#3a3024; color:#e6c878; border:1px solid #5a4a32;
|
||||
padding:6px 13px; border-radius:5px; cursor:pointer; font-size:13px; }
|
||||
button:hover:not(:disabled) { background:#4a3d2c; }
|
||||
button:disabled { opacity:.4; cursor:not-allowed; }
|
||||
button.primary { background:#5a4a26; border-color:#8a7038; color:#f3dca0; }
|
||||
button.mini { padding:2px 8px; font-size:12px; }
|
||||
|
||||
/* ---- layout ---- */
|
||||
#wrap { display:flex; flex:1; min-height:0; }
|
||||
#list-pane { width:250px; background:#19150f; border-right:1px solid #3a322a;
|
||||
display:flex; flex-direction:column; flex:none; }
|
||||
.filters { padding:10px; display:flex; gap:6px; border-bottom:1px solid #3a322a; }
|
||||
.filters select, .filters input, #login input, #import-text {
|
||||
background:#241f18; color:#e8e0d4; border:1px solid #4a4030; border-radius:4px;
|
||||
padding:6px 8px; font-size:13px; }
|
||||
.filters select { flex:none; }
|
||||
.filters input { flex:1; min-width:0; }
|
||||
#event-list { overflow:auto; flex:1; }
|
||||
.ev { padding:9px 12px; border-bottom:1px solid #241f18; cursor:pointer; }
|
||||
.ev:hover { background:#221d16; }
|
||||
.ev.sel { background:#2a2316; border-left:3px solid #e6c878; padding-left:9px; }
|
||||
.ev .t { font-size:13.5px; color:#ddd3c2; }
|
||||
.ev .g { font-size:11px; color:#7a7264; margin-top:2px; }
|
||||
.ev .badge { float:right; font-size:11px; padding:1px 7px; border-radius:9px; }
|
||||
.b-pending { background:#3a3320; color:#d8c060; }
|
||||
.b-confirmed{ background:#1f3a24; color:#7ad88a; }
|
||||
.b-discarded{ background:#3a2020; color:#d88; }
|
||||
|
||||
#graph-pane { flex:1; position:relative; min-width:0; }
|
||||
#graph { position:absolute; inset:0; overflow:auto; padding:30px; }
|
||||
#svg { position:absolute; top:0; left:0; pointer-events:none; }
|
||||
#layers { position:relative; z-index:2; }
|
||||
.empty-center { position:absolute; inset:0; display:flex; align-items:center;
|
||||
justify-content:center; color:#6a6256; font-size:15px; }
|
||||
|
||||
#edit-pane { width:370px; background:#1c1813; border-left:1px solid #3a322a;
|
||||
overflow:auto; flex:none; }
|
||||
.sec-title { padding:10px 14px; font-size:13px; color:#9a8f7e;
|
||||
border-bottom:1px solid #3a322a; background:#19150f; letter-spacing:1px;
|
||||
display:flex; justify-content:space-between; align-items:center; }
|
||||
#meta-edit, #node-edit { padding:12px 14px; }
|
||||
.empty { color:#6a6256; font-size:13px; padding:8px 0; }
|
||||
|
||||
/* ---- nodes (tree) ---- */
|
||||
.node { background:#262019; border:1.5px solid #4a4030; border-radius:9px;
|
||||
padding:9px 12px; width:188px; cursor:pointer; transition:.15s;
|
||||
box-shadow:0 2px 6px rgba(0,0,0,.4); position:absolute; }
|
||||
.node:hover { border-color:#e6c878; transform:translateY(-2px); }
|
||||
.node.sel { border-color:#e6c878; box-shadow:0 0 0 2px rgba(230,200,120,.4); z-index:20; }
|
||||
.node-acts { position:absolute; display:flex; gap:4px; z-index:30; transform:translateX(-100%); }
|
||||
.nact { border:none; border-radius:5px; font-size:11px; line-height:1; padding:4px 6px;
|
||||
cursor:pointer; color:#161310; font-weight:bold; box-shadow:0 1px 3px rgba(0,0,0,.5); }
|
||||
.nact.add { background:#e6c878; }
|
||||
.nact.add:hover { background:#f0d68a; }
|
||||
.nact.del { background:#d87878; color:#fff; }
|
||||
.nact.del:hover { background:#e89090; }
|
||||
.node .k { font-size:11px; color:#b89a5a; font-weight:bold; }
|
||||
.node .t { font-size:13px; margin-top:3px; line-height:1.4; color:#ddd3c2; }
|
||||
.node.kind-ending { background:#3a2a17; border-color:#e0a850; }
|
||||
.node.kind-ending .k { color:#f2c463; }
|
||||
.node.kind-ending .rw { font-size:11.5px; color:#c9a86a; margin-top:4px;
|
||||
border-top:1px dashed #6a5630; padding-top:4px; }
|
||||
.node.kind-fight { border-color:#7a4a4a; background:#2a1c1c; }
|
||||
.node.kind-fight .k { color:#d87878; }
|
||||
.node.kind-out_ref { border-style:dashed; border-color:#7a7ad8; background:#1d1d2a; }
|
||||
.node.kind-out_ref .k { color:#9e9ef0; }
|
||||
.node.kind-choice, .node.kind-choice_once {
|
||||
background:#1d2840; border:none; padding-left:26px; padding-right:26px;
|
||||
clip-path: polygon(16px 0, calc(100% - 16px) 0, 100% 50%, calc(100% - 16px) 100%, 16px 100%, 0 50%); }
|
||||
.node.kind-choice .k, .node.kind-choice_once .k { color:#9ec0f0; }
|
||||
|
||||
/* ---- form ---- */
|
||||
.fld { margin:9px 0; }
|
||||
.fld > label { display:block; font-size:12px; color:#9a8f7e; margin-bottom:3px; }
|
||||
.fld input, .fld select, .fld textarea {
|
||||
width:100%; background:#241f18; color:#e8e0d4; border:1px solid #4a4030;
|
||||
border-radius:4px; padding:6px 8px; font-size:13px; }
|
||||
.fld textarea { min-height:60px; resize:vertical; font-family:inherit; }
|
||||
.fld.inline { display:flex; gap:6px; align-items:center; }
|
||||
.fld.inline > label { margin:0; flex:none; }
|
||||
.subbox { border:1px solid #3a322a; border-radius:6px; padding:8px; margin:8px 0;
|
||||
background:#19150f; }
|
||||
.subbox .hd { display:flex; justify-content:space-between; align-items:center;
|
||||
font-size:12px; color:#b89a5a; margin-bottom:6px; }
|
||||
.row2 { display:flex; gap:6px; }
|
||||
.row2 > * { flex:1; }
|
||||
.tag-pick { display:flex; flex-wrap:wrap; gap:5px; }
|
||||
.tag-pick label { font-size:12px; background:#241f18; border:1px solid #4a4030;
|
||||
padding:3px 8px; border-radius:11px; cursor:pointer; }
|
||||
.tag-pick input { margin-right:4px; }
|
||||
.node-id { color:#7a7264; font-size:11px; }
|
||||
|
||||
/* ---- overlays / modals ---- */
|
||||
.overlay { position:fixed; inset:0; background:rgba(0,0,0,.72); z-index:100;
|
||||
display:flex; align-items:center; justify-content:center; }
|
||||
.overlay.hidden { display:none; }
|
||||
.login-box, .modal { background:#221d16; border:1px solid #4a4030; border-radius:10px;
|
||||
padding:24px; width:380px; box-shadow:0 8px 30px rgba(0,0,0,.6); }
|
||||
.modal { width:520px; max-height:82vh; overflow:auto; }
|
||||
.modal.wide { width:760px; }
|
||||
.login-box h2, .modal h3 { margin:0 0 12px; color:#e6c878; }
|
||||
.login-box .hint { color:#9a8f7e; font-size:13px; margin:0 0 14px; }
|
||||
.login-box input { width:100%; margin-bottom:10px; }
|
||||
.login-box button { width:100%; }
|
||||
.err { color:#e08a7a; font-size:13px; margin-top:8px; min-height:18px; }
|
||||
.modal textarea { width:100%; min-height:160px; background:#19150f; color:#e8e0d4;
|
||||
border:1px solid #4a4030; border-radius:5px; padding:8px;
|
||||
font-family:monospace; font-size:12px; }
|
||||
.modal-actions { margin-top:10px; display:flex; gap:8px; }
|
||||
.drop { border:1.5px dashed #4a4030; border-radius:8px; padding:22px 12px; text-align:center;
|
||||
cursor:pointer; color:#bdb29c; transition:border-color .15s, background .15s; }
|
||||
.drop:hover { border-color:#6a5d40; background:#1c1810; }
|
||||
.drop.over { border-color:#e6c878; background:#241d10; color:#e6c878; }
|
||||
.drop p { margin:0; }
|
||||
.drop .hint { margin-top:6px; font-size:12px; color:#8a8068; }
|
||||
.filelist { margin-top:8px; }
|
||||
.fileitem { display:flex; align-items:center; gap:8px; padding:5px 8px; margin-top:5px;
|
||||
background:#19150f; border:1px solid #3a3326; border-radius:5px; font-size:13px; }
|
||||
.fileitem .fn { flex:1; color:#e8e0d4; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.fileitem .fsz { color:#8a8068; font-size:12px; }
|
||||
.fileitem .rm { background:none; border:none; color:#e08a7a; cursor:pointer; font-size:13px; padding:0 4px; }
|
||||
.import-paste { margin-top:10px; }
|
||||
.import-paste summary { cursor:pointer; color:#bdb29c; font-size:13px; user-select:none; }
|
||||
.import-paste textarea { margin-top:8px; min-height:110px; }
|
||||
.v-err { color:#e08a7a; margin:4px 0; font-size:13px; }
|
||||
.v-warn { color:#d8c060; margin:4px 0; font-size:13px; }
|
||||
.v-ok { color:#7ad88a; font-size:14px; }
|
||||
|
||||
/* ---- playtest ---- */
|
||||
#pt-layout { display:flex; gap:14px; }
|
||||
#pt-flow { flex:1; max-height:62vh; overflow:auto; }
|
||||
#pt-ledger { width:230px; flex:none; background:#19150f; border:1px solid #3a322a;
|
||||
border-radius:6px; padding:10px; font-size:13px; align-self:flex-start; }
|
||||
#pt-ledger h4 { margin:0 0 8px; color:#e6c878; font-size:13px; }
|
||||
#pt-ledger .lg { margin:4px 0; }
|
||||
.pt-step { background:#241f18; border-left:3px solid #4a4030; padding:7px 10px;
|
||||
margin:7px 0; border-radius:4px; font-size:13px; }
|
||||
.pt-step.spk { color:#cdddf0; }
|
||||
.pt-step.sys { color:#9a8f7e; font-style:italic; }
|
||||
.pt-step.end { border-left-color:#e0a850; color:#eccf95; }
|
||||
.pt-choices button { display:block; width:100%; text-align:left; margin:5px 0; }
|
||||
.pt-choices button.locked { opacity:.55; }
|
||||
.pt-q { color:#b89a5a; font-size:12px; margin:8px 0 4px; }
|
||||
157
web/static/tree.js
Normal file
157
web/static/tree.js
Normal file
@ -0,0 +1,157 @@
|
||||
// 分支树渲染(从 ir_to_html.py 的 TEMPLATE 抽出,加 onSelect 回调供编辑)。
|
||||
// 用法: renderTree(ir, { onSelect:id=>{}, selected:'n1' })
|
||||
|
||||
(function () {
|
||||
const ROW = 150, SP = 232, NW = 188;
|
||||
const COLOR = { next:"#6a6256", option:"#7aa0d8", random:"#a07ad8",
|
||||
win:"#7ac88a", lose:"#d87878", ref:"#9e9ef0" };
|
||||
|
||||
function roleNames(ir) {
|
||||
const m = {};
|
||||
(ir.roles || []).forEach(r => m[r.slot] = r.name + (r.archetype ? ("〔" + r.archetype + "〕") : ""));
|
||||
return m;
|
||||
}
|
||||
function nameOf(ir, names, s) { return (s === "P1" ? "玩家" : "") || names[s] || s; }
|
||||
|
||||
function grantStr(ir, names, gr) {
|
||||
if (gr.kind === "银两") return "银两 " + (gr.value > 0 ? "+" : "") + gr.value;
|
||||
if (gr.kind === "道具") return "道具 " + gr.item + " ×" + gr.value;
|
||||
if (gr.kind === "友好度") return nameOf(ir, names, gr.target) + " 友好度+" + gr.value;
|
||||
if (gr.kind === "入门") return nameOf(ir, names, gr.target) + " 加入门派";
|
||||
return JSON.stringify(gr);
|
||||
}
|
||||
|
||||
function summary(ir, names, n) {
|
||||
if (n.kind === "narration") return ["旁白", (n.text || "").slice(0, 28)];
|
||||
if (n.kind === "dialogue") return ["对话 · " + nameOf(ir, names, n.speaker), (n.text || "").slice(0, 24)];
|
||||
if (n.kind === "choice") return ["选择 (" + (n.options || []).length + "项)", (n.options || []).map(o => o.text).join(" / ").slice(0, 30)];
|
||||
if (n.kind === "choice_once") return ["一次性选择", (n.options || []).map(o => o.text).join(" / ").slice(0, 30)];
|
||||
if (n.kind === "random") return ["随机分支", (n.branches || []).length + " 路"];
|
||||
if (n.kind === "fight") return ["战斗", "vs " + (n.camp2 || []).map(s => nameOf(ir, names, s)).join("、")];
|
||||
if (n.kind === "move") return ["走位 · " + nameOf(ir, names, n.actor), "→ " + (n.to || "")];
|
||||
if (n.kind === "anim") return ["动画 · " + nameOf(ir, names, n.actor), n.ani || ""];
|
||||
if (n.kind === "reward") return ["奖励结算", ""];
|
||||
if (n.kind === "out_ref") return ["引用子序列", "→ " + (n.ref || "")];
|
||||
if (n.kind === "ending") return ["★ 结局", n.summary || ""];
|
||||
return [n.kind, ""];
|
||||
}
|
||||
|
||||
window.renderTree = function (ir, opts) {
|
||||
opts = opts || {};
|
||||
const names = roleNames(ir);
|
||||
const layersDiv = document.getElementById("layers");
|
||||
const svg = document.getElementById("svg");
|
||||
layersDiv.innerHTML = ""; svg.innerHTML = "";
|
||||
|
||||
// 节点 (含结局)
|
||||
const nodes = {};
|
||||
(ir.nodes || []).forEach(n => nodes[n.id] = Object.assign({ _end: false }, n));
|
||||
(ir.endings || []).forEach(e => nodes[e.id] = Object.assign({ _end: true, kind: "ending" }, e));
|
||||
|
||||
// 边
|
||||
const edges = [];
|
||||
const add = (u, v, type, label) => { if (v && nodes[v]) edges.push({ u, v, type, label: label || "" }); };
|
||||
(ir.nodes || []).forEach(n => {
|
||||
if (n.next) add(n.id, n.next, n.kind === "out_ref" ? "ref" : "next");
|
||||
(n.options || []).forEach(o => add(n.id, o.goto, "option", o.text));
|
||||
(n.branches || []).forEach(b => add(n.id, b.goto, "random", "权重" + (b.weight != null ? b.weight : "")));
|
||||
if (n.kind === "fight") { add(n.id, n.win, "win", "胜"); add(n.id, n.lose, "lose", "败"); }
|
||||
});
|
||||
|
||||
// 最长路径分层
|
||||
const layer = {}; Object.keys(nodes).forEach(id => layer[id] = 0);
|
||||
let changed = true, guard = 0;
|
||||
while (changed && guard++ < 999) {
|
||||
changed = false;
|
||||
edges.forEach(e => { if (layer[e.v] < layer[e.u] + 1) { layer[e.v] = layer[e.u] + 1; changed = true; } });
|
||||
}
|
||||
|
||||
// 子树居中布局
|
||||
const childMap = {}; Object.keys(nodes).forEach(id => childMap[id] = []);
|
||||
const indeg = {}; Object.keys(nodes).forEach(id => indeg[id] = 0);
|
||||
const seenE = new Set();
|
||||
edges.forEach(e => { const k = e.u + ">" + e.v; if (!seenE.has(k)) { seenE.add(k); childMap[e.u].push(e.v); indeg[e.v]++; } });
|
||||
let roots = Object.keys(nodes).filter(id => indeg[id] === 0);
|
||||
if (!roots.length) roots = [Object.keys(nodes)[0]];
|
||||
const xpos = {}; let nextX = 0; const vis = new Set();
|
||||
function assignX(id) {
|
||||
if (!id || vis.has(id)) return; vis.add(id);
|
||||
if (childMap[id].length === 0) { xpos[id] = nextX; nextX += SP; return; }
|
||||
childMap[id].forEach(assignX);
|
||||
const placed = childMap[id].map(c => xpos[c]).filter(v => v !== undefined);
|
||||
xpos[id] = placed.length ? (Math.min(...placed) + Math.max(...placed)) / 2 : (nextX += SP, nextX - SP);
|
||||
}
|
||||
roots.forEach(assignX);
|
||||
Object.keys(nodes).forEach(id => { if (xpos[id] === undefined) { xpos[id] = nextX; nextX += SP; } });
|
||||
|
||||
let maxX = 0, maxL = 0;
|
||||
Object.keys(nodes).forEach(id => {
|
||||
const n = nodes[id], [k, t] = summary(ir, names, n);
|
||||
const d = document.createElement("div");
|
||||
d.className = "node kind-" + n.kind + (id === opts.selected ? " sel" : "");
|
||||
d.id = "node-" + id;
|
||||
let inner = '<div class="k">' + k + '</div><div class="t">' + esc(t || id) + '</div>';
|
||||
if (n.kind === "ending") {
|
||||
const g = (n.grants && n.grants.length) ? n.grants.map(gr => grantStr(ir, names, gr)).join(",") : "无奖励";
|
||||
inner += '<div class="rw">' + esc(g) + '</div>';
|
||||
}
|
||||
d.innerHTML = inner;
|
||||
d.style.left = xpos[id] + "px"; d.style.top = (layer[id] * ROW) + "px";
|
||||
d.onclick = () => opts.onSelect && opts.onSelect(id);
|
||||
layersDiv.appendChild(d);
|
||||
// 选中节点:浮出快捷按钮。作为画布独立元素按坐标定位,避免被 choice 等节点的 clip-path 裁切。
|
||||
if (id === opts.selected) {
|
||||
const bar = document.createElement("div");
|
||||
bar.className = "node-acts";
|
||||
bar.style.left = (xpos[id] + NW + 2) + "px"; // 右缘对齐节点右上角
|
||||
bar.style.top = (layer[id] * ROW - 13) + "px";
|
||||
const mk = (cls, label, title, fn) => {
|
||||
const b = document.createElement("button");
|
||||
b.className = "nact " + cls; b.textContent = label; b.title = title;
|
||||
b.onclick = e => { e.stopPropagation(); fn(id); };
|
||||
return b;
|
||||
};
|
||||
if (opts.onAddNext && !n._end) bar.appendChild(mk("add", "+后继", "新建一个节点并自动接到这里", opts.onAddNext));
|
||||
if (opts.onDelete) bar.appendChild(mk("del", "✕", n._end ? "删除此结局" : "删除此节点", opts.onDelete));
|
||||
layersDiv.appendChild(bar);
|
||||
}
|
||||
maxX = Math.max(maxX, xpos[id]); maxL = Math.max(maxL, layer[id]);
|
||||
});
|
||||
layersDiv.style.width = (maxX + NW + 40) + "px";
|
||||
layersDiv.style.height = (maxL * ROW + 180) + "px";
|
||||
|
||||
drawEdges(edges);
|
||||
};
|
||||
|
||||
function drawEdges(edges) {
|
||||
const g = document.getElementById("graph"), svg = document.getElementById("svg");
|
||||
const gb = g.getBoundingClientRect();
|
||||
svg.setAttribute("width", g.scrollWidth); svg.setAttribute("height", g.scrollHeight);
|
||||
let h = '<defs>';
|
||||
Object.entries(COLOR).forEach(([k, c]) => {
|
||||
h += '<marker id="ar-' + k + '" markerWidth="9" markerHeight="9" refX="7" refY="3" orient="auto"><path d="M0,0 L7,3 L0,6 Z" fill="' + c + '"/></marker>';
|
||||
});
|
||||
h += '</defs>';
|
||||
edges.forEach(e => {
|
||||
const a = document.getElementById("node-" + e.u), b = document.getElementById("node-" + e.v);
|
||||
if (!a || !b) return;
|
||||
const ra = a.getBoundingClientRect(), rb = b.getBoundingClientRect();
|
||||
const x1 = ra.left - gb.left + g.scrollLeft + ra.width / 2, y1 = ra.bottom - gb.top + g.scrollTop;
|
||||
const x2 = rb.left - gb.left + g.scrollLeft + rb.width / 2, y2 = rb.top - gb.top + g.scrollTop;
|
||||
const c = COLOR[e.type] || "#6a6256", my = (y1 + y2) / 2;
|
||||
h += '<path d="M' + x1 + ',' + y1 + ' C' + x1 + ',' + my + ' ' + x2 + ',' + my + ' ' + x2 + ',' + y2 + '" stroke="' + c + '" stroke-width="2.2" fill="none" opacity="0.92" marker-end="url(#ar-' + e.type + ')"' + (e.type === "option" ? ' stroke-dasharray="7,4"' : '') + '/>';
|
||||
if (e.label) {
|
||||
const t = 0.8, mt = 1 - t;
|
||||
const lx = mt * mt * mt * x1 + 3 * mt * mt * t * x1 + 3 * mt * t * t * x2 + t * t * t * x2;
|
||||
const ly = mt * mt * mt * y1 + 3 * mt * mt * t * my + 3 * mt * t * t * my + t * t * t * y2;
|
||||
h += '<text x="' + lx + '" y="' + ly + '" fill="' + c + '" font-size="11.5" text-anchor="middle" stroke="#161310" stroke-width="3.5" paint-order="stroke" style="font-weight:bold">' + esc(e.label.slice(0, 12)) + '</text>';
|
||||
}
|
||||
});
|
||||
svg.innerHTML = h;
|
||||
}
|
||||
|
||||
function esc(s) { return String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">"); }
|
||||
window._treeGrantStr = grantStr;
|
||||
window._treeNameOf = nameOf;
|
||||
window._treeRoleNames = roleNames;
|
||||
})();
|
||||
Reference in New Issue
Block a user