Files
story-edit-web/web/static/pointview.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

270 lines
12 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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.

// 场景/点位查看器(只读):把一个点位集里所有命名点画到 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 };
})();