Files
story-edit-web/web/static/app.js
邓雨鹏 603f78b77f feat(pointview): 新增「场景/点位」页签——正交俯视真实场景底图 + 点位精确叠加
第三个页签(与海选审核/演出配置平级),只读查看每个点位集里各点的真实
位置/朝向,配 move.to/camera.focus 时对照用,不必回 Unity 翻 json。

- pointview.js: 独立白模点位查看器(按 kind 上色/朝向箭头/悬停坐标/侧栏清单);
  有底图则把正交俯视真实场景图当画布底图、点位按 shot.bounds 线性投上去
  (像素级对齐家具),带显隐开关;无底图回退黑底白模。
- app.py: /api/pointsets 给有底图的点位集附 shot{url,bounds};新增
  /sceneshot/{name}.png 路由(防目录穿越)。
- Dockerfile/compose: 加 STORY_SCENESHOTS_DIR(/sceneshots) env + 挂载点与注释。

底图由 SGame 仓新增 Editor 工具「剧情场景俯视抓拍」产出
({name}.png + {name}.shot.json,map-local 覆盖范围)。
2026-06-14 11:13:45 +08:00

443 lines
21 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=演出配置 / points=场景点位
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");
$("mode-points").classList.toggle("active", m === "points");
$("wrap").classList.toggle("hidden", m !== "review");
$("perform-wrap").classList.toggle("hidden", m !== "perform");
$("points-wrap").classList.toggle("hidden", m !== "points");
$("review-toolbar").style.display = m === "review" ? "" : "none";
document.body.classList.toggle("perform-mode", m === "perform"); // 切背景色调
document.body.classList.toggle("points-mode", m === "points");
Timeline.stop();
if (m === "points") PointView.clear();
if (m === "perform") performLoadList();
if (m === "points") pointsLoadList();
}
$("mode-review").onclick = () => setMode("review");
$("mode-perform").onclick = () => setMode("perform");
$("mode-points").onclick = () => setMode("points");
// ---------- 场景点位页:点位集列表 + 只读白模查看器 ----------
let pointsCurrent = null;
function pointsLoadList() {
const names = Object.keys(App.pointsets || {}).sort();
const host = $("points-set-list"); host.innerHTML = "";
if (!names.length) { host.innerHTML = '<div class="empty" style="padding:14px">未找到任何点位集StreamingAssets/Story/PointSets/*.points.json。</div>'; return; }
names.forEach(name => {
const ps = App.pointsets[name] || {};
const cnt = (ps.anchors || ps.points || []).length;
const d = document.createElement("div");
d.className = "ev" + (name === pointsCurrent ? " sel" : "");
d.innerHTML = '<div class="t">' + esc(name) + '</div><div class="g">'
+ (ps.mapId ? '地图 ' + esc(ps.mapId) + ' · ' : '') + cnt + ' 个点</div>';
d.onclick = () => pointsSelect(name);
host.appendChild(d);
});
}
function pointsSelect(name) {
pointsCurrent = name;
pointsLoadList();
$("points-empty").style.display = "none";
PointView.show($("points-view"), name, App.pointsets[name] || {});
}
// ---------- 演出配置页:已确认事件列表 + 内嵌白模预览 ----------
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(); }
})();
})();