Files
story-edit-web/web/static/app.js
邓雨鹏 dbf857769e feat(timeline): P1 白模演出预览——时间线+俯视舞台播放
- /api/pointsets 增 anchors(含 pos/rot/kind/npc 坐标),保留 points 名字数组兼容下拉
- 新增 static/timeline.js:线性化主路径→按时长铺多轨时间线 + 2D 俯视白模舞台播放
  (走位插值/对话打字机/镜头俯视框示意/playhead/点击跳转);战斗/选择/随机仅标点不模拟
- 工具栏加「演出预览」按钮 + 预览遮罩 + 暗金主题样式
- 时长模型:对话=字数×打字机速度,走位=坐标距离÷速度,动画/镜头给缺省时长
- node 离线测试两样张(含 out_ref+fight+skip)逻辑全过;修掉结局重复渲染 bug

设计文档见主仓 docs/plans/2026-06-13-story-timeline-editor-and-whitebox-preview-design.md (P1 期)
2026-06-13 10:52:20 +08:00

375 lines
17 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,
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-timeline", "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),
});
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);
// ---------- 演出预览(白模时间线)----------
$("btn-timeline").onclick = () => Timeline.open(App.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),
});
(async function () {
try { const r = await fetch("/api/events?status=all"); if (r.status === 401) { showLogin(); return; } hideLogin(); init(); }
catch (e) { showLogin(); }
})();
})();