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