剧情 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 + 白模重叠演出。
413 lines
19 KiB
JavaScript
413 lines
19 KiB
JavaScript
// 主控:鉴权 / 事件列表 / 加载保存 / 校验 / 状态 / 导入导出 / 试走。
|
||
(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, "&").replace(/</g, "<").replace(/>/g, ">"); }
|
||
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(); }
|
||
})();
|
||
})();
|