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 覆盖范围)。
This commit is contained in:
2026-06-14 11:13:24 +08:00
parent 030f1ee34d
commit 603f78b77f
9 changed files with 414 additions and 4 deletions

View File

@ -21,8 +21,9 @@ COPY web ./web
# 注意镜像里不埋任何口令默认值。STORY_WEB_USERS名字:口令,…)必须运行时注入,
# 未配置时 app 启动即退出(拒绝弱口令裸奔)。
ENV STORY_DB_PATH=/data/story_events.db \
STORY_POINTSETS_DIR=/pointsets
RUN mkdir -p /data /pointsets
STORY_POINTSETS_DIR=/pointsets \
STORY_SCENESHOTS_DIR=/sceneshots
RUN mkdir -p /data /pointsets /sceneshots
EXPOSE 8787
WORKDIR /app/web

View File

@ -34,6 +34,9 @@ _DICT_PATH = os.path.join(_AUTHORING, "ir_dictionary.json")
# 点位集目录:容器内用 STORY_POINTSETS_DIR 指向挂载卷;本地默认指向项目 Assets。
_POINTSETS_DIR = os.environ.get("STORY_POINTSETS_DIR") or \
os.path.join(_PROJ, "Assets", "StreamingAssets", "Story", "PointSets")
# 场景俯视底图目录Unity「剧情场景俯视抓拍」产出 {name}.png + {name}.shot.json含覆盖的 map-local 范围)。
_SCENESHOTS_DIR = os.environ.get("STORY_SCENESHOTS_DIR") or \
os.path.join(_PROJ, "Assets", "StreamingAssets", "Story", "SceneShots")
_STATIC_DIR = os.path.join(_HERE, "static")
COOKIE = "story_auth"
@ -162,6 +165,25 @@ async def dictionary():
return json.load(f)
def _load_shot(name):
"""{name}.shot.json含俯视底图覆盖的 map-local 范围);图与 sidecar 都在才算有效。"""
try:
meta = os.path.join(_SCENESHOTS_DIR, name + ".shot.json")
png = os.path.join(_SCENESHOTS_DIR, name + ".png")
if not (os.path.isfile(meta) and os.path.isfile(png)):
return None
with open(meta, encoding="utf-8") as f:
m = json.load(f)
b = m.get("bounds")
if not (isinstance(b, list) and len(b) == 4):
return None
# 缓存击穿:附 png mtime图更新后前端能拿到新图
return {"url": "/sceneshot/" + name + ".png?v=" + str(int(os.path.getmtime(png))),
"bounds": b, "w": m.get("w"), "h": m.get("h")}
except Exception:
return None
@app.get("/api/pointsets")
async def pointsets():
out = {}
@ -188,11 +210,25 @@ async def pointsets():
for p in pts
],
}
shot = _load_shot(name)
if shot:
out[name]["shot"] = shot # 有正交俯视底图 → 场景/点位页叠真实场景
except Exception as e:
out[name] = {"error": str(e)}
return out
@app.get("/sceneshot/{name}.png")
async def sceneshot(name: str):
# 防目录穿越:只认纯文件名
if not name or "/" in name or "\\" in name or ".." in name:
return JSONResponse({"error": "bad name"}, status_code=400)
p = os.path.join(_SCENESHOTS_DIR, name + ".png")
if not os.path.isfile(p):
return JSONResponse({"error": "not found"}, status_code=404)
return FileResponse(p, media_type="image/png")
# ---------- 事件 CRUD ----------
@app.get("/api/events")
async def events(status: str = "all"):

View File

@ -49,3 +49,8 @@ services:
# 整个同步仓库盖在 /app只读web/ 前端+后端、ir_core、ir_dictionary.json 全部
# 来自 git 同步目录;镜像内的 COPY 副本只作无挂载时的兜底。路径按上面共享路径,一般不用改。
- "/SATA存储11/Docker/story-edit-web-src:/app:ro"
# 点位集 + 场景俯视底图(可选):这两个来自 SGame 仓 Assets/StreamingAssets/Story/
# 不在本同步仓里。要让线上「演出配置」有真实坐标、「场景/点位」有底图,把这两目录同步到
# NAS 后取消下面两行注释并改成实际路径(缺失时分别降级为:坐标校验警告 / 白模回退):
# - "/SATA存储11/Docker/story-assets/PointSets:/pointsets:ro"
# - "/SATA存储11/Docker/story-assets/SceneShots:/sceneshots:ro"

View File

@ -20,4 +20,7 @@ services:
# 点位集(可选):先不挂 = 坐标校验降级为警告,能正常用。
# 把 Assets/StreamingAssets/Story/PointSets 同步到 NAS 某目录后取消下一行注释并改成实际路径:
# - /vol1/docker/story/pointsets:/pointsets:ro
# 场景俯视底图(可选):先不挂 =「场景/点位」页回退黑底白模。
# 把 Assets/StreamingAssets/Story/SceneShots 同步到 NAS 某目录后取消下一行注释并改成实际路径:
# - /vol1/docker/story/sceneshots:/sceneshots:ro
restart: unless-stopped

View File

@ -22,6 +22,9 @@ services:
- ./data:/data
# 点位集(开发侧产出,只读引用;缺失时坐标校验降级为警告)
- ../../../Assets/StreamingAssets/Story/PointSets:/pointsets:ro
# 场景正交俯视底图Unity「剧情场景俯视抓拍」产出 {name}.png + {name}.shot.json
# 缺失时「场景/点位」页回退黑底白模
- ../../../Assets/StreamingAssets/Story/SceneShots:/sceneshots:ro
# 前端静态文件热挂载:改 static/* 后刷新浏览器即生效,无需重建镜像
- ./static:/app/web/static:ro
restart: unless-stopped

View File

@ -9,7 +9,7 @@
status: null,
selectedNode: null,
dirty: false,
mode: "review", // review=海选审核 / perform=演出配置
mode: "review", // review=海选审核 / perform=演出配置 / points=场景点位
by: localStorage.getItem("story_by") || "匿名",
};
window.App = App;
@ -196,20 +196,50 @@
// ---------- 试走 ----------
$("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;

View File

@ -25,6 +25,7 @@
<div class="mode-switch">
<button id="mode-review" class="mode-btn active">海选审核</button>
<button id="mode-perform" class="mode-btn">演出配置</button>
<button id="mode-points" class="mode-btn">场景/点位</button>
</div>
<div class="toolbar" id="review-toolbar">
<button id="btn-save" class="primary" disabled>保存</button>
@ -88,6 +89,18 @@
</main>
</div>
<!-- 场景/点位页(只读:看每个点位集里各点的真实位置/朝向,配 move.to 时对照用)-->
<div id="points-wrap" class="hidden">
<aside id="points-list-pane">
<div class="perform-listhead">场景点位集</div>
<div id="points-set-list"></div>
</aside>
<main id="points-main">
<div id="points-empty" class="empty-center">从左侧选择一个点位集,查看其中各点的位置与朝向</div>
<div id="points-view"></div>
</main>
</div>
<!-- 校验结果遮罩 -->
<div id="validate-modal" class="overlay hidden">
<div class="modal">
@ -136,6 +149,7 @@
<script src="playtest.js"></script>
<script src="timeline.js"></script>
<script src="scene_edit.js"></script>
<script src="pointview.js"></script>
<script src="app.js"></script>
</body>
</html>

269
web/static/pointview.js Normal file
View File

@ -0,0 +1,269 @@
// 场景/点位查看器(只读):把一个点位集里所有命名点画到 2D 俯视白模上,
// 标名字、按 kind 上色、画朝向箭头(Unity Y 欧拉角),悬停/选中显示 map-local 坐标。
// 用途:在网页里配 move.to / camera.focus 时,对照"这个点到底在场景哪儿、朝哪边"。
// 坐标口径与 timeline.js 一致:俯视取 (x, z)+z 向上rot=Unity eulerAngles.y(0=朝+Z)。
// 暴露 window.PointView = { show(host, name, pointset), clear() }。
(function () {
const KIND = {
player: { col: "#f0d890", label: "玩家起点" },
role: { col: "#7ec8e3", label: "NPC 站位" },
point: { col: "#b8a878", label: "走位/镜头点" },
"": { col: "#9a8f7e", label: "未分类" },
};
function kindOf(k) { return KIND[k] || KIND[""]; }
function esc(s) { return String(s == null ? "" : s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;"); }
let els = {}, cv, ctx, anchors = [], bounds = null, selected = null, hovered = null, ro = null;
let shot = null, bgImg = null, bgReady = false, showBg = true; // 正交俯视底图
const TEMPLATE =
'<div class="pv-head"></div>' +
'<div class="pv-body">' +
' <div class="pv-stagewrap"><canvas class="pv-stage"></canvas><div class="pv-tip hidden"></div></div>' +
' <div class="pv-side">' +
' <div class="pv-legend"></div>' +
' <div class="pv-listhead">点位清单(点击定位)</div>' +
' <div class="pv-list"></div>' +
' </div>' +
'</div>';
function show(host, name, ps) {
clear();
ps = ps || {};
anchors = (ps.anchors || []).slice();
selected = null; hovered = null;
shot = (ps.shot && Array.isArray(ps.shot.bounds) && ps.shot.bounds.length === 4) ? ps.shot : null;
bgImg = null; bgReady = false; showBg = true;
host.innerHTML = TEMPLATE;
els = {
host,
head: host.querySelector(".pv-head"),
stagewrap: host.querySelector(".pv-stagewrap"),
stage: host.querySelector(".pv-stage"),
tip: host.querySelector(".pv-tip"),
legend: host.querySelector(".pv-legend"),
list: host.querySelector(".pv-list"),
};
cv = els.stage; ctx = cv.getContext("2d");
const n = anchors.length;
els.head.innerHTML = "点位集 <b>" + esc(name) + "</b>" +
(ps.mapId ? "(地图 " + esc(ps.mapId) + "" : "") + " · " + n + " 个点" +
(shot ? ' · <span class="pv-bgtag"><input type="checkbox" id="pv-bgtoggle" checked>正交俯视底图</span>'
: ' · <span class="pv-nobg">无底图(在 Unity「剧情场景俯视抓拍」生成</span>');
if (!n) {
els.stagewrap.innerHTML = '<div class="pv-noanchor">该点位集没有坐标数据(可能是旧文件只有点名)。<br>请在 Unity 用「SGame/剧情点位集取点」补采坐标。</div>';
els.legend.innerHTML = ""; els.list.innerHTML = "";
return;
}
computeBounds();
buildLegend();
buildList();
// 底图异步加载ready 后重绘;勾选框切显隐
if (shot) {
bgImg = new Image();
bgImg.onload = () => { bgReady = true; draw(); };
bgImg.onerror = () => { bgReady = false; };
bgImg.src = shot.url;
const cb = host.querySelector("#pv-bgtoggle");
if (cb) cb.onchange = () => { showBg = cb.checked; draw(); };
}
// 悬停拾取最近点
cv.onmousemove = e => {
const r = cv.getBoundingClientRect();
const mx = (e.clientX - r.left) * (cv.width / r.width);
const my = (e.clientY - r.top) * (cv.height / r.height);
const hit = pickNearest(mx, my, 14);
if (hit !== hovered) { hovered = hit; draw(); }
if (hit) showTip(e.clientX - r.left, e.clientY - r.top, hit); else hideTip();
};
cv.onmouseleave = () => { if (hovered) { hovered = null; draw(); } hideTip(); };
cv.onclick = () => { if (hovered) selectPoint(hovered.name); };
// 自适应尺寸:跟随容器宽高重绘
ro = new ResizeObserver(() => resizeAndDraw());
ro.observe(els.stagewrap);
resizeAndDraw();
}
function clear() {
if (ro) { ro.disconnect(); ro = null; }
if (els.host) els.host.innerHTML = "";
els = {}; cv = ctx = null; anchors = []; bounds = null; selected = hovered = null;
}
function computeBounds() {
// 有底图:用底图覆盖的 map-local 范围当画布范围,点位投影才与底图像素级对齐。
if (shot) {
const b = shot.bounds;
bounds = { minX: b[0], maxX: b[1], minZ: b[2], maxZ: b[3] };
return;
}
// 无底图:按点位自身范围自适应(点位常远离世界原点,强含原点会把它们挤到角落)。
// 原点十字仍由 draw() 在其落入视野时绘出。单点/共线时补一点跨度避免除零。
const xs = anchors.map(a => a.pos[0]), zs = anchors.map(a => a.pos[2]);
let minX = Math.min(...xs), maxX = Math.max(...xs), minZ = Math.min(...zs), maxZ = Math.max(...zs);
if (maxX - minX < 1) { minX -= 1; maxX += 1; }
if (maxZ - minZ < 1) { minZ -= 1; maxZ += 1; }
bounds = { minX, maxX, minZ, maxZ };
}
function resizeAndDraw() {
if (!cv) return;
const w = Math.max(320, els.stagewrap.clientWidth), h = Math.max(240, els.stagewrap.clientHeight);
const dpr = window.devicePixelRatio || 1;
cv.width = Math.round(w * dpr); cv.height = Math.round(h * dpr);
cv.style.width = w + "px"; cv.style.height = h + "px";
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
draw();
}
// 世界(x,z) → 画布像素。等比缩放,+z 朝上。
function proj(x, z) {
const dpr = window.devicePixelRatio || 1;
const w = cv.width / dpr, h = cv.height / dpr, pad = 48;
const dx = (bounds.maxX - bounds.minX) || 1, dz = (bounds.maxZ - bounds.minZ) || 1;
const sc = Math.min((w - pad * 2) / dx, (h - pad * 2) / dz);
const cx = (bounds.minX + bounds.maxX) / 2, cz = (bounds.minZ + bounds.maxZ) / 2;
return { x: w / 2 + (x - cx) * sc, y: h / 2 - (z - cz) * sc, sc };
}
function draw() {
if (!ctx) return;
const dpr = window.devicePixelRatio || 1, w = cv.width / dpr, h = cv.height / dpr;
ctx.clearRect(0, 0, w, h);
ctx.fillStyle = "#0f1714"; ctx.fillRect(0, 0, w, h);
// 底图:画进 bounds 投影出的矩形(其纵横比 = bounds 纵横比 = 图纵横比,故像素级贴合)
const bgOn = shot && bgReady && showBg;
if (bgOn) {
const tl = proj(bounds.minX, bounds.maxZ), br = proj(bounds.maxX, bounds.minZ);
ctx.drawImage(bgImg, tl.x, tl.y, br.x - tl.x, br.y - tl.y);
}
if (!bgOn) drawGrid(w, h); // 有底图时网格让位给真实场景
// 世界原点十字(空间参照)
const o = proj(0, 0);
if (o.x >= 0 && o.x <= w && o.y >= 0 && o.y <= h) {
ctx.strokeStyle = "rgba(120,180,150,.5)"; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(o.x - 9, o.y); ctx.lineTo(o.x + 9, o.y); ctx.moveTo(o.x, o.y - 9); ctx.lineTo(o.x, o.y + 9); ctx.stroke();
ctx.fillStyle = "rgba(120,180,150,.6)"; ctx.font = "10px sans-serif"; ctx.textAlign = "left"; ctx.fillText("原点(0,0)", o.x + 6, o.y - 6);
}
// 指北:+Z 朝上
ctx.fillStyle = "rgba(150,200,175,.65)"; ctx.font = "11px sans-serif"; ctx.textAlign = "center";
ctx.fillText("+Z ↑", w - 34, 16); ctx.fillText("+X →", w - 30, 30);
anchors.forEach(a => drawAnchor(a));
}
function drawGrid(w, h) {
// 取整世界单位画网格(自适应步长,避免过密)
const range = Math.max(bounds.maxX - bounds.minX, bounds.maxZ - bounds.minZ) || 1;
let step = 1; while (range / step > 14) step *= (step === 1 ? 2 : (step === 2 ? 2.5 : 2));
ctx.strokeStyle = "rgba(120,150,135,.10)"; ctx.lineWidth = 1; ctx.font = "9px sans-serif"; ctx.textAlign = "center";
const x0 = Math.ceil(bounds.minX / step) * step, x1 = bounds.maxX;
for (let x = x0; x <= x1 + 1e-6; x += step) {
const p = proj(x, bounds.minZ); ctx.beginPath(); ctx.moveTo(p.x, 0); ctx.lineTo(p.x, h); ctx.stroke();
}
const z0 = Math.ceil(bounds.minZ / step) * step, z1 = bounds.maxZ;
for (let z = z0; z <= z1 + 1e-6; z += step) {
const p = proj(bounds.minX, z); ctx.beginPath(); ctx.moveTo(0, p.y); ctx.lineTo(w, p.y); ctx.stroke();
}
}
function drawAnchor(a) {
const p = proj(a.pos[0], a.pos[2]), col = kindOf(a.kind).col;
const isSel = selected === a.name, isHov = hovered && hovered.name === a.name;
const r = isSel ? 9 : (isHov ? 8 : 6);
// 朝向箭头Unity Y 欧拉角 θforward=(sinθ, cosθ) in (x,z);屏幕 +z 向上故 dy 取负
const th = (a.rot || 0) * Math.PI / 180, L = 22;
const ex = p.x + Math.sin(th) * L, ey = p.y - Math.cos(th) * L;
ctx.strokeStyle = col; ctx.lineWidth = isSel || isHov ? 2 : 1.4; ctx.globalAlpha = isSel || isHov ? 1 : .75;
ctx.beginPath(); ctx.moveTo(p.x, p.y); ctx.lineTo(ex, ey); ctx.stroke();
// 箭头头
const ah = 5, base = Math.atan2(ey - p.y, ex - p.x);
ctx.beginPath(); ctx.moveTo(ex, ey);
ctx.lineTo(ex - ah * Math.cos(base - .4), ey - ah * Math.sin(base - .4));
ctx.lineTo(ex - ah * Math.cos(base + .4), ey - ah * Math.sin(base + .4));
ctx.closePath(); ctx.fillStyle = col; ctx.fill();
ctx.globalAlpha = 1;
// 点
if (isSel || isHov) { ctx.shadowColor = col; ctx.shadowBlur = 10; }
ctx.fillStyle = col; ctx.beginPath(); ctx.arc(p.x, p.y, r, 0, Math.PI * 2); ctx.fill();
ctx.shadowBlur = 0;
ctx.strokeStyle = "#000"; ctx.lineWidth = 1; ctx.stroke();
// 名字(深色描边,叠在真实底图上也清晰)
ctx.textAlign = "center"; ctx.lineJoin = "round";
ctx.font = (isSel ? "bold " : "") + "11px sans-serif";
ctx.strokeStyle = "rgba(0,0,0,.85)"; ctx.lineWidth = 3; ctx.strokeText(a.name, p.x, p.y - r - 5);
ctx.fillStyle = isSel ? "#fff" : "#eaf2ec"; ctx.fillText(a.name, p.x, p.y - r - 5);
if (a.npc) {
ctx.font = "9px sans-serif";
ctx.strokeStyle = "rgba(0,0,0,.85)"; ctx.lineWidth = 3; ctx.strokeText(a.npc, p.x, p.y + r + 11);
ctx.fillStyle = "rgba(210,225,215,.95)"; ctx.fillText(a.npc, p.x, p.y + r + 11);
}
}
function pickNearest(mx, my, tol) {
const dpr = window.devicePixelRatio || 1; mx /= dpr; my /= dpr;
let best = null, bd = tol * tol;
anchors.forEach(a => {
const p = proj(a.pos[0], a.pos[2]), d = (p.x - mx) * (p.x - mx) + (p.y - my) * (p.y - my);
if (d < bd) { bd = d; best = a; }
});
return best;
}
function showTip(x, y, a) {
const t = els.tip;
t.innerHTML = '<b>' + esc(a.name) + '</b> · ' + esc(kindOf(a.kind).label) +
(a.npc ? '' + esc(a.npc) + '' : '') +
'<br>pos [' + a.pos.map(v => (+v).toFixed(2)).join(", ") + '] 朝向 ' + (a.rot || 0) + '°';
t.classList.remove("hidden");
const tw = t.offsetWidth, th = t.offsetHeight, W = els.stagewrap.clientWidth, H = els.stagewrap.clientHeight;
t.style.left = Math.max(4, Math.min(W - tw - 4, x + 14)) + "px";
t.style.top = Math.max(4, Math.min(H - th - 4, y + 14)) + "px";
}
function hideTip() { if (els.tip) els.tip.classList.add("hidden"); }
function buildLegend() {
const used = {}; anchors.forEach(a => { const k = (a.kind in KIND) ? a.kind : ""; used[k] = true; });
els.legend.innerHTML = Object.keys(used).map(k =>
'<span class="pv-leg"><i style="background:' + kindOf(k).col + '"></i>' + esc(kindOf(k).label) + '</span>'
).join("") + '<span class="pv-leg pv-leg-hint">箭头=朝向</span>';
}
function buildList() {
const order = { player: 0, role: 1, point: 2, "": 3 };
const sorted = anchors.slice().sort((a, b) => (order[a.kind] ?? 3) - (order[b.kind] ?? 3) || a.name.localeCompare(b.name));
els.list.innerHTML = "";
sorted.forEach(a => {
const d = document.createElement("div");
d.className = "pv-item" + (selected === a.name ? " sel" : "");
d.dataset.name = a.name;
d.innerHTML = '<i class="pv-dot" style="background:' + kindOf(a.kind).col + '"></i>' +
'<span class="pv-nm">' + esc(a.name) + '</span>' +
'<span class="pv-pos">[' + a.pos.map(v => (+v).toFixed(1)).join(", ") + ']' + (a.npc ? " · " + esc(a.npc) : "") + '</span>';
d.onclick = () => selectPoint(a.name);
els.list.appendChild(d);
});
}
function selectPoint(name) {
selected = (selected === name) ? null : name;
els.list.querySelectorAll(".pv-item").forEach(el => el.classList.toggle("sel", el.dataset.name === selected));
draw();
}
window.PointView = { show, clear };
})();

View File

@ -255,6 +255,55 @@ body.perform-mode .mode-switch { border-color:#2f7a60; }
#perform-main { flex:1; min-width:0; display:flex; flex-direction:column; padding:14px 16px; min-height:0; }
#perform-main .empty-center { position:static; inset:auto; min-height:240px; }
/* ---- 场景/点位页:靛蓝色调,区分于海选(棕)/演出(青绿) ---- */
body.points-mode { background:#0d0f17; }
body.points-mode header { background:#11142a; border-bottom-color:#2c3160; }
body.points-mode .mode-btn.active { background:#2e3470; border-color:#4a52a0; color:#c5cbf0; }
body.points-mode .mode-switch { border-color:#4a52a0; }
#points-wrap { display:flex; flex:1; min-height:0; background:#0d0f17; }
#wrap.hidden, #perform-wrap.hidden, #points-wrap.hidden { display:none; }
#points-list-pane { width:250px; background:#12152a; border-right:1px solid #262c52;
display:flex; flex-direction:column; flex:none; }
#points-list-pane .perform-listhead { background:#11142a; color:#aab0e0; border-bottom-color:#262c52; }
#points-set-list { overflow:auto; flex:1; }
#points-set-list .ev { border-bottom-color:#1c2040; }
#points-set-list .ev:hover { background:#1a1f3e; }
#points-set-list .ev.sel { background:#222853; border-left-color:#6b73c8; }
#points-main { flex:1; min-width:0; display:flex; flex-direction:column; padding:14px 16px; min-height:0; position:relative; }
#points-main .empty-center { position:static; inset:auto; min-height:240px; }
#points-view { flex:1; min-height:0; display:flex; flex-direction:column; }
#points-view:empty { display:none; }
.pv-head { flex:none; font-size:13px; color:#aab0e0; margin-bottom:8px; }
.pv-head b { color:#e6e9ff; }
.pv-bgtag { color:#9aa0d0; } .pv-bgtag input { vertical-align:middle; margin-right:3px; }
.pv-nobg { color:#7077a8; font-size:12px; }
.pv-body { flex:1; min-height:0; display:flex; gap:12px; }
.pv-stagewrap { position:relative; flex:1; min-width:0; min-height:0; background:#0f1714;
border:1px solid #262c52; border-radius:6px; overflow:hidden; }
.pv-stage { display:block; }
.pv-noanchor { position:absolute; inset:0; display:flex; flex-direction:column; align-items:center;
justify-content:center; text-align:center; color:#8088c0; font-size:13px; line-height:1.7; padding:20px; }
.pv-tip { position:absolute; z-index:5; background:rgba(16,18,34,.95); border:1px solid #4a52a0;
border-radius:5px; padding:6px 9px; font-size:11.5px; color:#dfe2ff; pointer-events:none;
white-space:nowrap; line-height:1.5; box-shadow:0 2px 10px rgba(0,0,0,.5); }
.pv-tip.hidden { display:none; }
.pv-tip b { color:#fff; }
.pv-side { width:230px; flex:none; display:flex; flex-direction:column; min-height:0; }
.pv-legend { flex:none; display:flex; flex-wrap:wrap; gap:8px 12px; padding:4px 2px 10px; font-size:11px; color:#9aa0d0; }
.pv-leg { display:inline-flex; align-items:center; gap:5px; }
.pv-leg i { width:11px; height:11px; border-radius:50%; display:inline-block; border:1px solid #000; }
.pv-leg-hint { color:#7077a8; }
.pv-listhead { flex:none; font-size:11px; color:#8088c0; letter-spacing:.5px; padding:6px 2px; border-top:1px solid #262c52; }
.pv-list { flex:1; overflow:auto; }
.pv-item { display:flex; align-items:center; gap:7px; padding:5px 7px; border-radius:4px; cursor:pointer; font-size:12px; }
.pv-item:hover { background:#1a1f3e; }
.pv-item.sel { background:#262c5c; }
.pv-dot { width:9px; height:9px; border-radius:50%; flex:none; border:1px solid #000; }
.pv-nm { color:#dfe2ff; flex:none; }
.pv-pos { color:#7e85b8; font-size:10.5px; margin-left:auto; text-align:right; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
/* ---- 演出预览:上=舞台面板(自适应放大) / 下=时间轴面板(可拖高度) ---- */
.tl-stagepanel { flex:1 1 auto; min-height:150px; display:flex; flex-direction:column; }
.tl-mapinfo { flex:none; font-size:12px; color:#9a8f7e; margin-bottom:6px; }