- kind 名做成顶边框标牌(legend,边框在文字处断开) - 去掉「开头」字(仅绿框)、去掉选择标题的项数 - 多出口节点每出口一行严格对齐右侧黄点 - 开头节点改为视口垂直居中(左侧) - 选择节点右栏选项改为可折叠,点开编辑单个 - 撤销/重做按钮(不可用时灰)+ R自动整理 + Enter加后继
324 lines
18 KiB
JavaScript
324 lines
18 KiB
JavaScript
// 右栏表单编辑:元信息 + 角色表 + 按 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(true); })));
|
||
} 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(true); })));
|
||
} 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 det = el("details", { class: "optdet" });
|
||
det.appendChild(el("summary", {}, ["选项 " + (i + 1) + ":" + (o.text || "(空)")]));
|
||
const ob = el("div", { class: "optbody" });
|
||
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(true); })));
|
||
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);
|
||
ob.appendChild(el("div", { class: "fld" }, [el("button", { class: "mini", onclick: () => { node.options.splice(i, 1); ctx.onChange(true); } }, ["删除此选项"])]));
|
||
det.appendChild(ob);
|
||
box.appendChild(det);
|
||
});
|
||
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(true); })),
|
||
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(true); })),
|
||
field("败 → lose", sel(node.lose, tgt, v => { node.lose = v; ctx.onChange(true); })),
|
||
]));
|
||
} 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(true); })));
|
||
} 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(true); })));
|
||
} 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(true); })));
|
||
} 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(true); })));
|
||
}
|
||
|
||
// 删除节点
|
||
host.appendChild(el("div", { class: "fld" }, [
|
||
el("button", { class: "mini", onclick: () => {
|
||
if (!confirm("删除节点 " + id + "?(若是直线中间节点,会自动把前后接上)")) return;
|
||
ctx.deleteNode(id);
|
||
} }, ["删除此节点"]),
|
||
]));
|
||
};
|
||
|
||
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 };
|
||
};
|
||
})();
|