Files
story-edit-web/web/static/form.js
bia 188bfbbf7c 节点编辑三项增强
- 删除中间节点自动缝合:线性节点删除后把前驱接到其后继
- 撤销/重做:Ctrl+Z / Ctrl+Y(含连线、删除、移动、改字段,防抖快照)
- 开头节点(nodes[0]):绿色边框+「▶开头」标识,选中事件时自动定位到它
2026-06-08 18:39:04 +08:00

324 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 右栏表单编辑:元信息 + 角色表 + 按 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 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(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);
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(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 };
};
})();