Files
story-edit-web/web/static/app.js
邓雨鹏 021080dd56 feat(timeline): P2 并行编排——scene 多轨编辑器 + 白模重叠预览
剧情 Timeline P2 前端 + 共享内核(与 SGame 源真同步):
- ir_core/IR_SCHEMA/样张:scene v0.3 + scene 校验 + 导出 gate(D3),与 SGame 仓逐字一致
- timeline.js:appendScene 按 authored start 铺多轨 clip(自然重叠预览),move from 同 actor 跨轨续连(D4);
  drawStage 改逐 actor 查对话→多人气泡同时计时;导出 _clipDur 纯函数;show() 加 startId 参;常量加 CAMERA_DUR
- scene_edit.js(新):演出段编辑模态——拖 clip 改 start(吸附 0.1s)、拖右缘改 dur、增删 clip/轨道、
  选中属性条精确编辑、客户端轻量 lint(镜像 validate.py)、▶ 预览此段(复用播放核)
- graph.js:scene 节点(KIND_CN/summary/nodeInner 列轨道)+双击进编辑模态
- form.js:右栏 renderScene 精确数值编辑(轨道/clip 的 start/dur/kind/目标)+打开编辑器按钮
- app.py export:捕获 CompileError 并入 report(scene 被拦时不再 500)
- test_scene.js:离线 10 断言全过(重叠确凿/晚 1.5s 起步/from 续连);gitignore 忽略本地 _localdemo.db

待浏览器目测拖拽编辑落 IR + 白模重叠演出。
2026-06-13 22:34:29 +08:00

413 lines
19 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.

// 主控:鉴权 / 事件列表 / 加载保存 / 校验 / 状态 / 导入导出 / 试走。
(function () {
const App = {
dict: { conditions: {}, grants: {} },
pointsets: {}, // name -> {mapId, points:[]}
events: [],
current: null, // 当前 group
ir: null, // 工作副本
status: null,
selectedNode: null,
dirty: false,
mode: "review", // review=海选审核 / perform=演出配置
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;
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; }
const d = await r.json(); // 身份由服务端按口令认定(口令即身份)
App.by = d.user || "?"; localStorage.setItem("story_by", App.by);
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", "btn-autolayout", "btn-addsucc"].forEach(b => $(b).disabled = false);
renderAll(true);
GraphUI.focusStart(App.ir); // 定位到开头节点
snapReset(); // 初始化撤销栈
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();
if (structural) { GraphUI.render(App.ir, App.selectedNode); FormUI.renderMeta(App.ir, ctx()); FormUI.renderNode(App.ir, App.selectedNode, ctx()); }
else { GraphUI.updateLabel(App.ir, App.selectedNode); }
scheduleSnapshot();
},
selectNode: id => { App.selectedNode = id; },
deleteNode: id => deleteNode(id),
editScene: id => { App.selectedNode = id; SceneEdit.open(id, App.ir, ctx(), App.pointsets, App.dict); },
});
function selectNode(id) { App.selectedNode = id; GraphUI.select(id); 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; App.selectedNode = r.id;
GraphUI.render(App.ir, r.id); FormUI.renderMeta(App.ir, ctx()); FormUI.renderNode(App.ir, r.id, ctx()); updateDirty();
}
// 纯数据删除节点/结局
function removeFromIr(id) {
App.ir.nodes = (App.ir.nodes || []).filter(n => n.id !== id);
App.ir.endings = (App.ir.endings || []).filter(e => e.id !== id);
if (App.ir._layout) delete App.ir._layout[id];
if (App.selectedNode === id) App.selectedNode = null;
App.dirty = true; updateDirty();
}
// 线性节点的唯一后继(多出口/结局返回 null不缝合
function uniqueSuccessor(node) {
if (!node) return null;
const k = node.kind;
if (k === "choice" || k === "choice_once" || k === "random" || k === "fight") return null;
if ((App.ir.endings || []).some(e => e.id === node.id)) return null;
return node.next || null;
}
// 把所有指向 from 的跳转改接到 to
function retarget(from, to) {
(App.ir.nodes || []).forEach(n => {
if (n.next === from) n.next = to;
if (n.win === from) n.win = to;
if (n.lose === from) n.lose = to;
(n.options || []).forEach(o => { if (o.goto === from) o.goto = to; if (o.skip && o.skip.node === from) o.skip.node = to; });
(n.branches || []).forEach(b => { if (b.goto === from) b.goto = to; });
});
}
// 删除节点:若是线性中间节点,删除后把前驱缝合到它的后继
function deleteNode(id) {
const node = (App.ir.nodes || []).find(n => n.id === id);
const succ = uniqueSuccessor(node);
if (succ && succ !== id) retarget(id, succ);
removeFromIr(id);
renderAll(); scheduleSnapshot();
}
function renderAll() { GraphUI.render(App.ir, App.selectedNode); 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; App.selectedNode = id;
GraphUI.render(App.ir, id); FormUI.renderMeta(App.ir, ctx()); FormUI.renderNode(App.ir, id, 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);
// ---------- 模式切换:海选审核 / 演出配置 ----------
function setMode(m) {
App.mode = m;
$("mode-review").classList.toggle("active", m === "review");
$("mode-perform").classList.toggle("active", m === "perform");
$("wrap").classList.toggle("hidden", m !== "review");
$("perform-wrap").classList.toggle("hidden", m !== "perform");
$("review-toolbar").style.display = m === "review" ? "" : "none";
document.body.classList.toggle("perform-mode", m === "perform"); // 切背景色调
Timeline.stop();
if (m === "perform") performLoadList();
}
$("mode-review").onclick = () => setMode("review");
$("mode-perform").onclick = () => setMode("perform");
// ---------- 演出配置页:已确认事件列表 + 内嵌白模预览 ----------
let performCurrent = null;
async function performLoadList() {
let list;
try { list = await (await api("/api/events?status=confirmed")).json(); } catch (e) { return; }
const host = $("perform-list"); host.innerHTML = "";
if (!list.length) { host.innerHTML = '<div class="empty" style="padding:14px">还没有已确认的事件。去「海选审核」确认事件后再来配置演出。</div>'; return; }
list.forEach(e => {
const d = document.createElement("div");
d.className = "ev" + (e.group === performCurrent ? " sel" : "");
d.innerHTML = '<div class="t">' + esc(e.title || e.group) + '</div><div class="g">' + esc(e.group) + ' · ' + esc(e.updated_by || "") + '</div>';
d.onclick = () => performSelect(e.group);
host.appendChild(d);
});
}
async function performSelect(group) {
let d;
try { d = await (await api("/api/events/" + encodeURIComponent(group))).json(); } catch (e) { return; }
performCurrent = group;
performLoadList();
Timeline.show($("perform-main"), d.ir, App.dict, App.pointsets);
}
// ---------- 导入 ----------
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").textContent = "";
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();
importFiles = []; renderImportFiles(); $("import-text").value = "";
$("import-modal").classList.add("hidden"); // 导入完直接关闭
const ne = (d.errors || []).length;
toast("已导入 " + (d.saved || []).length + " 个事件" + (ne ? "" + ne + " 个跳过/出错" : ""));
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;"); }
let toastTimer = null;
function toast(msg) {
let el = $("toast");
if (!el) { el = document.createElement("div"); el.id = "toast"; document.body.appendChild(el); }
el.textContent = msg; el.classList.add("show");
clearTimeout(toastTimer);
toastTimer = setTimeout(() => el.classList.remove("show"), 2600);
}
// 未捕获错误 → toast 提示(便于同事发现并反馈,而不是默默白屏)
window.addEventListener("error", e => { try { toast("⚠ 页面出错:" + (e.message || "未知")); } catch (_) {} });
window.addEventListener("unhandledrejection", e => {
const m = String((e.reason && e.reason.message) || e.reason || "未知");
if (m.includes("未授权")) return;
try { toast("⚠ 操作失败:" + m); } catch (_) {}
});
// ---------- 撤销 / 重做 ----------
let undoStack = [], redoStack = [], snapTimer = null;
function updateUndoButtons() {
const u = $("btn-undo"), r = $("btn-redo");
if (u) u.disabled = undoStack.length < 2;
if (r) r.disabled = redoStack.length === 0;
}
function snapReset() { undoStack = App.ir ? [JSON.stringify(App.ir)] : []; redoStack = []; updateUndoButtons(); }
function flushSnapshot() {
if (!App.ir) return;
const cur = JSON.stringify(App.ir);
if (!undoStack.length || undoStack[undoStack.length - 1] !== cur) {
undoStack.push(cur); if (undoStack.length > 60) undoStack.shift(); redoStack = []; updateUndoButtons();
}
}
function scheduleSnapshot() { clearTimeout(snapTimer); snapTimer = setTimeout(flushSnapshot, 450); }
function restoreState(json) {
App.ir = JSON.parse(json); App.selectedNode = null; App.dirty = true;
renderAll(); updateDirty();
}
function undo() {
flushSnapshot();
if (undoStack.length < 2) return;
redoStack.push(undoStack.pop());
restoreState(undoStack[undoStack.length - 1]);
updateUndoButtons(); toast("已撤销");
}
function redo() {
if (!redoStack.length) return;
const s = redoStack.pop(); undoStack.push(s); restoreState(s);
updateUndoButtons(); toast("已重做");
}
$("btn-undo").onclick = undo;
$("btn-redo").onclick = redo;
// ---------- 快捷键Ctrl+Z/Y 撤销重做、R 自动整理、Enter 加后继 ----------
document.addEventListener("keydown", e => {
if (!App.ir) return;
const a = document.activeElement;
const inInput = a && (a.tagName === "INPUT" || a.tagName === "TEXTAREA");
if (e.ctrlKey || e.metaKey) {
if (inInput) return; // 输入框内的 Ctrl+Z 交给浏览器
const k = e.key.toLowerCase();
if (k === "z" && !e.shiftKey) { e.preventDefault(); undo(); }
else if (k === "y" || (k === "z" && e.shiftKey)) { e.preventDefault(); redo(); }
return;
}
if (e.altKey || inInput) return;
if (e.key.toLowerCase() === "r") { e.preventDefault(); doAutoLayout(); }
else if (e.key === "Enter" && App.selectedNode) { e.preventDefault(); addSuccessor(App.selectedNode); }
});
// ---------- 画布工具栏 ----------
function doAutoLayout() {
if (!App.ir) return;
App.ir._layout = null; // 清坐标 → render 时按自动布局重排
GraphUI.render(App.ir, App.selectedNode);
GraphUI.focusStart(App.ir);
App.dirty = true; updateDirty(); scheduleSnapshot(); toast("已自动整理");
}
$("btn-autolayout").onclick = doAutoLayout;
$("btn-addsucc").onclick = () => {
if (!App.ir || !App.selectedNode) { alert("先在画布上点选一个节点,再点「加后继」。"); return; }
addSuccessor(App.selectedNode);
};
// ---------- 启动 ----------
GraphUI.init("drawflow", {
onSelect: id => selectNode(id),
onMove: (id, x, y) => { if (!App.ir) return; (App.ir._layout = App.ir._layout || {})[id] = { x: x, y: y }; App.dirty = true; updateDirty(); scheduleSnapshot(); },
onConnect: () => { App.dirty = true; updateDirty(); FormUI.renderNode(App.ir, App.selectedNode, ctx()); scheduleSnapshot(); },
onDisconnect: () => { App.dirty = true; updateDirty(); FormUI.renderNode(App.ir, App.selectedNode, ctx()); scheduleSnapshot(); },
onDeleteSelected: id => deleteNode(id),
onEditScene: id => { App.selectedNode = id; GraphUI.select(id); SceneEdit.open(id, App.ir, ctx(), App.pointsets, App.dict); },
});
(async function () {
try { const r = await fetch("/api/events?status=all"); if (r.status === 401) { showLogin(); return; } hideLogin(); init(); }
catch (e) { showLogin(); }
})();
})();