// 场景/点位查看器(只读):把一个点位集里所有命名点画到 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, ">"); }
let els = {}, cv, ctx, anchors = [], bounds = null, selected = null, hovered = null, ro = null;
let shot = null, bgImg = null, bgReady = false, showBg = true; // 正交俯视底图
const TEMPLATE =
'
' +
'' +
'
' +
'
' +
'
' +
'
点位清单(点击定位)
' +
'
' +
'
' +
'
';
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 = "点位集 " + esc(name) + "" +
(ps.mapId ? "(地图 " + esc(ps.mapId) + ")" : "") + " · " + n + " 个点" +
(shot ? ' · 正交俯视底图'
: ' · 无底图(在 Unity「剧情场景俯视抓拍」生成)');
if (!n) {
els.stagewrap.innerHTML = '该点位集没有坐标数据(可能是旧文件只有点名)。
请在 Unity 用「SGame/剧情点位集取点」补采坐标。
';
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 = '' + esc(a.name) + ' · ' + esc(kindOf(a.kind).label) +
(a.npc ? '(' + esc(a.npc) + ')' : '') +
'
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 =>
'' + esc(kindOf(k).label) + ''
).join("") + '箭头=朝向';
}
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 = '' +
'' + esc(a.name) + '' +
'[' + a.pos.map(v => (+v).toFixed(1)).join(", ") + ']' + (a.npc ? " · " + esc(a.npc) : "") + '';
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 };
})();