// 右栏表单编辑:元信息 + 角色表 + 按 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", "scene"]; const SCENE_CLIP_KINDS = ["move", "dialogue", "narration", "anim", "camera", "wait"]; // ---- 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; } // 带下拉建议的文本框(datalist):用于场景名等可复用又可自由输入的字段 function txtDatalist(val, listId, options, oninput) { const i = el("input", { type: "text", value: val == null ? "" : val, list: listId, placeholder: "输入或选择场景名" }); i.oninput = () => oninput(i.value); const dl = el("datalist", { id: listId }); (options || []).forEach(o => dl.appendChild(el("option", { value: o }))); return el("span", { class: "with-datalist" }, [i, dl]); } 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); }))); // 所属场景:海选审核第一列的分组维度,手动归类(可复用已有场景名)。改后保存即生效。 const scenes = [...new Set((window.App && window.App.events ? window.App.events.map(e => e.scene) : []).filter(Boolean))].sort(); host.appendChild(field("所属场景(海选分组)", txtDatalist(ir.scene, "scene-datalist", scenes, v => { ir.scene = 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); }))); } else if (node.kind === "scene") { renderScene(host, ir, node, ctx, tgt); } // 删除节点 host.appendChild(el("div", { class: "fld" }, [ el("button", { class: "mini", onclick: () => { if (!confirm("删除节点 " + id + "?(若是直线中间节点,会自动把前后接上)")) return; ctx.deleteNode(id); } }, ["删除此节点"]), ])); }; // scene 内 clip 唯一 id function newClipId(node) { let i = 1, id; const has = x => (node.tracks || []).some(tk => (tk.clips || []).some(c => c.id === x)); do { id = "c" + i++; } while (has(id)); return id; } // ========== 演出段 scene 右栏(精确数值编辑,与画布拖拽互补)========== function renderScene(host, ir, node, ctx, tgt) { if (!node.tracks) node.tracks = []; const actorSlots = [{ value: "P1", label: "P1 玩家" }].concat((ir.roles || []).map(r => ({ value: r.slot, label: r.slot + " " + r.name }))); host.appendChild(el("div", { class: "fld" }, [ el("button", { class: "mini primary", onclick: () => ctx.editScene && ctx.editScene(node.id) }, ["🎬 打开时间线编辑器(拖拽编排)"]), ])); host.appendChild(el("div", { class: "row2" }, [ field("时长 duration(可选,缺省=自动)", num(node.duration, v => { if (v == null) delete node.duration; else node.duration = v; ctx.onChange(false); })), field("出口 next", sel(node.next, tgt, v => { node.next = v; ctx.onChange(true); })), ])); const box = el("div", { class: "subbox" }); box.appendChild(el("div", { class: "hd" }, [ el("span", {}, ["轨道 tracks"]), el("span", {}, [ el("button", { class: "mini", onclick: () => { node.tracks.push({ id: "tk" + node.tracks.length, actor: "P1", clips: [] }); ctx.onChange(true); } }, ["+演员轨"]), el("button", { class: "mini", onclick: () => { node.tracks.push({ id: "tkcam" + node.tracks.length, role: "camera", clips: [] }); ctx.onChange(true); } }, ["+镜头轨"]), ]), ])); (node.tracks || []).forEach((tk, ti) => { const isCam = tk.role === "camera"; const tbox = el("div", { class: "fld optdet-like" }); tbox.appendChild(el("div", { class: "row2" }, [ isCam ? el("span", { class: "node-id" }, ["🎬 镜头轨"]) : sel(tk.actor || "P1", actorSlots, v => { tk.actor = v; ctx.onChange(false); }), el("span", {}, [ el("button", { class: "mini", onclick: () => { const c = { id: newClipId(node), kind: isCam ? "camera" : "move", start: 0 }; if (isCam) c.focus = (ctx.pointNames || [])[0] || ""; else c.to = (ctx.pointNames || [])[0] || ""; (tk.clips = tk.clips || []).push(c); ctx.onChange(true); } }, ["+clip"]), el("button", { class: "mini", onclick: () => { if (confirm("删除该轨道?")) { node.tracks.splice(ti, 1); ctx.onChange(true); } } }, ["删轨"]), ]), ])); (tk.clips || []).forEach((c, ci) => { const kinds = isCam ? ["camera"] : SCENE_CLIP_KINDS.filter(k => k !== "camera"); const row = el("div", { class: "se-cliprow" }, [ sel(c.kind, kinds.map(k => ({ value: k, label: k })), v => { c.kind = v; ctx.onChange(true); }), field("start", num(c.start, v => { c.start = v == null ? 0 : Math.max(0, v); ctx.onChange(false); })), field("dur", num(c.dur, v => { if (v == null) delete c.dur; else c.dur = v; ctx.onChange(false); })), ]); if (c.kind === "move") row.appendChild(field("to", sel(c.to, pointOpts(ir, ctx, c.to), v => { c.to = v; ctx.onChange(false); }))); else if (c.kind === "camera") row.appendChild(field("focus", sel(c.focus, pointOpts(ir, ctx, c.focus), v => { c.focus = v; ctx.onChange(false); }))); else if (c.kind === "dialogue" || c.kind === "narration") row.appendChild(field("文本", txt(c.text, v => { c.text = v; ctx.onChange(false); }))); else if (c.kind === "anim") row.appendChild(field("ani", txt(c.ani, v => { c.ani = v; ctx.onChange(false); }))); row.appendChild(el("button", { class: "mini", onclick: () => { tk.clips.splice(ci, 1); ctx.onChange(true); } }, ["✕"])); tbox.appendChild(row); }); if (!(tk.clips || []).length) tbox.appendChild(el("div", { class: "empty" }, ["无 clip"])); box.appendChild(tbox); }); if (!node.tracks.length) box.appendChild(el("div", { class: "empty" }, ["无轨道——点「+演员轨/+镜头轨」"])); host.appendChild(box); } 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 }; }; })();