第三个页签(与海选审核/演出配置平级),只读查看每个点位集里各点的真实
位置/朝向,配 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 覆盖范围)。
270 lines
12 KiB
JavaScript
270 lines
12 KiB
JavaScript
// 场景/点位查看器(只读):把一个点位集里所有命名点画到 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, "&").replace(/</g, "<").replace(/>/g, ">"); }
|
||
|
||
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 };
|
||
})();
|