clone

an endless marble-race generator in the browser; the url is the seed, so every track can be shared

main
marbles / src / client / inspect.ts
inspect.ts 7.0K
1/**2 * Inspect mode: tile-level debugging. Toggle with the tilde/backquote key;3 * click placements to select them (raycast, drag-suppressed so orbiting4 * still works); copy a portable JSON report of5 * "these tiles on this seed on this version".6 */7import * as THREE from "three";8import type { SceneCtx } from "./scene.ts";9import type { Placement, Track } from "../shared/types.ts";10import { genVersion } from "../gen/version.ts";1112export interface InspectSource {13 seed: string;14 track: Track | null;15 /** Scene group whose children carry userData.placementIndex. */16 group: THREE.Group | null;17}1819export interface Inspector {20 /** Drop all selections/highlights (called on regenerate). */21 reset(): void;22 readonly active: boolean;23 /** Selected placement indices, for CUA state dumps. */24 selection(): number[];25}2627export function installInspect(28 ctx: SceneCtx,29 source: () => InspectSource,30): Inspector {31 let active = false;32 const selected = new Map<number, THREE.Box3Helper>();3334 // --- DOM ------------------------------------------------------------------35 const panel = document.createElement("div");36 panel.id = "inspect";37 panel.style.cssText =38 "position:fixed;top:12px;right:12px;z-index:20;display:none;" +39 "flex-direction:column;gap:8px;align-items:flex-end;" +40 "font:12px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace;color:#dce6f5;";41 const buttons = document.createElement("div");42 buttons.style.cssText = "display:flex;gap:8px;";43 const mkBtn = (label: string, onClick: () => void): HTMLButtonElement => {44 const b = document.createElement("button");45 b.textContent = label;46 b.style.cssText =47 "background:rgba(14,20,32,.72);color:#dce6f5;cursor:pointer;" +48 "border:1px solid rgba(220,230,245,.25);border-radius:6px;" +49 "padding:6px 10px;font:inherit;backdrop-filter:blur(6px);";50 b.addEventListener("click", onClick);51 buttons.appendChild(b);52 return b;53 };54 const list = document.createElement("div");55 list.style.cssText =56 "background:rgba(14,20,32,.72);border:1px solid rgba(220,230,245,.25);" +57 "border-radius:6px;padding:8px 10px;white-space:pre;text-align:left;" +58 "max-height:50vh;overflow:auto;backdrop-filter:blur(6px);";59 const copyBtn = mkBtn("copy debug info", () => copyReport());60 mkBtn("clear selection", () => clearSelection());61 mkBtn("exit mode", () => setActive(false));62 panel.appendChild(buttons);63 panel.appendChild(list);64 document.body.appendChild(panel);6566 function describe(index: number, p: Placement): string {67 const cell = `${Math.round(p.pos[0])},${Math.round(p.pos[2])}`;68 return `#${index} ${p.pieceId} @ (${cell}) y${p.pos[1]} r${p.rotation}` +69 `${p.mirrored ? " mirrored" : ""} [${p.role}]`;70 }7172 function renderList(): void {73 const { seed } = source();74 const lines = [...selected.keys()].sort((a, b) => a - b).map((i) => {75 const p = source().track?.placements[i];76 return p ? describe(i, p) : `#${i} ?`;77 });78 list.textContent = `inspect • ${seed} • ${genVersion()}\n` +79 (lines.length ? lines.join("\n") : "click tiles to select");80 }8182 function report(): string {83 const { seed, track } = source();84 return JSON.stringify(85 {86 seed,87 version: genVersion(),88 url: location.href,89 tiles: [...selected.keys()].sort((a, b) => a - b).map((i) => {90 const p = track?.placements[i];91 return p92 ? {93 i,94 piece: p.pieceId,95 cell: [Math.round(p.pos[0]), Math.round(p.pos[2])],96 pos: p.pos.map((v) => Number(v.toFixed(2))),97 rot: p.rotation,98 ...(p.mirrored ? { mirrored: true } : {}),99 role: p.role,100 }101 : { i };102 }),103 },104 null,105 1,106 );107 }108109 function copyReport(): void {110 const text = report();111 console.log("[inspect]", text);112 const showInline = () => {113 // Clipboard unavailable — render the JSON in the panel (user-select:114 // all) so it can be copied by hand.115 list.textContent = text;116 (list.style as CSSStyleDeclaration).userSelect = "all";117 copyBtn.textContent = "select & copy below";118 setTimeout(() => (copyBtn.textContent = "copy debug info"), 2000);119 };120 if (!navigator.clipboard) {121 showInline();122 return;123 }124 navigator.clipboard.writeText(text).then(() => {125 copyBtn.textContent = "copied!";126 setTimeout(() => (copyBtn.textContent = "copy debug info"), 1200);127 }).catch(showInline);128 }129130 // --- selection ------------------------------------------------------------131 function drop(index: number, helper: THREE.Box3Helper): void {132 ctx.scene.remove(helper);133 helper.geometry.dispose();134 (helper.material as THREE.Material).dispose();135 selected.delete(index);136 }137138 function clearSelection(): void {139 for (const [i, helper] of [...selected]) drop(i, helper);140 renderList();141 }142143 function toggle(index: number, obj: THREE.Object3D): void {144 const cur = selected.get(index);145 if (cur) {146 drop(index, cur);147 } else {148 const box = new THREE.Box3().setFromObject(obj);149 const helper = new THREE.Box3Helper(box, new THREE.Color(0xff4757));150 helper.name = `inspect:${index}`;151 ctx.scene.add(helper);152 selected.set(index, helper);153 }154 renderList();155 }156157 // --- picking (drag-suppressed click) ---------------------------------------158 const ray = new THREE.Raycaster();159 const ndc = new THREE.Vector2();160 let downAt: [number, number] | null = null;161162 ctx.renderer.domElement.addEventListener("pointerdown", (e: PointerEvent) => {163 if (active) downAt = [e.clientX, e.clientY];164 });165 ctx.renderer.domElement.addEventListener("pointerup", (e: PointerEvent) => {166 if (!active || !downAt) return;167 const moved = Math.hypot(e.clientX - downAt[0], e.clientY - downAt[1]);168 downAt = null;169 if (moved > 5) return; // orbit drag, not a pick170171 const rect = ctx.renderer.domElement.getBoundingClientRect();172 ndc.set(173 ((e.clientX - rect.left) / rect.width) * 2 - 1,174 -((e.clientY - rect.top) / rect.height) * 2 + 1,175 );176 ray.setFromCamera(ndc, ctx.camera);177 const { group } = source();178 if (!group) return;179 for (const hit of ray.intersectObject(group, true)) {180 let node: THREE.Object3D | null = hit.object;181 if (node.name === "ground") continue;182 while (node && node.userData.placementIndex === undefined) {183 node = node.parent;184 }185 if (node) {186 toggle(node.userData.placementIndex as number, node);187 return;188 }189 }190 });191192 // --- mode toggle ------------------------------------------------------------193 function setActive(on: boolean): void {194 active = on;195 panel.style.display = on ? "flex" : "none";196 if (on) renderList();197 }198199 addEventListener("keydown", (e) => {200 if (e.code === "Backquote") setActive(!active);201 });202203 return {204 reset() {205 clearSelection();206 renderList();207 },208 get active() {209 return active;210 },211 selection() {212 return [...selected.keys()].sort((a, b) => a - b);213 },214 };215}