bzl

self-hosted ephemeral community engine
Log | Files | Refs | README | LICENSE

client.js (155433B)


      1 (function () {
      2   if (!window.BzlPluginHost) return;
      3 
      4   window.BzlPluginHost.register("maps", (ctx) => {
      5     const ws = window.__bzlWs;
      6     if (!ws) return;
      7 
      8     const appRootRef = document.querySelector(".app");
      9     const inRackMode = (() => {
     10       try {
     11         // Rack mode reloads the page; this flag is available before the DOM gets the .rackMode class.
     12         if (localStorage.getItem("bzl_rackLayout_enabled") === "1") return true;
     13       } catch {
     14         // ignore
     15       }
     16       return Boolean(appRootRef?.classList.contains("rackMode"));
     17     })();
     18 
     19     // In rack mode, Maps should render into its own dockable panel (not inside Hives).
     20     if (inRackMode && ctx?.ui?.registerPanel) {
     21       try {
     22         ctx.ui.registerPanel({
     23           id: "maps",
     24           title: "Maps",
     25           icon: "🗺️",
     26           defaultRack: "main",
     27           role: "primary",
     28           render() {
     29             // no-op: this plugin mounts into the panel shell below
     30           }
     31         });
     32       } catch {
     33         // ignore
     34       }
     35     }
     36 
     37     let mainPanel = document.querySelector(".main .panelFill");
     38     if (inRackMode) {
     39       const shell = document.querySelector('.panel.pluginPanel[data-panel-id="maps"]');
     40       if (shell instanceof HTMLElement) mainPanel = shell;
     41     }
     42     const panelHeader = mainPanel ? mainPanel.querySelector(".panelHeader") : null;
     43     const panelTitle = panelHeader ? panelHeader.querySelector(".panelTitle") : null;
     44     const filters = panelHeader ? panelHeader.querySelector(".filters") : null;
     45     const hiveTabs = inRackMode ? null : document.getElementById("hiveTabs");
     46     const feed = inRackMode ? null : document.getElementById("feed");
     47     const pollinatePanel = inRackMode ? null : document.getElementById("pollinatePanel");
     48     const chatPanel = inRackMode ? null : document.querySelector(".chat");
     49     const chatResizeHandle = inRackMode ? null : document.getElementById("chatResizeHandle");
     50     const appRoot = inRackMode ? null : appRootRef;
     51 
     52     if (!mainPanel || !panelHeader || !panelTitle) return;
     53 
     54     const style = document.createElement("style");
     55     style.textContent = `
     56       .mapsTabBtn { margin-left: 10px; }
     57       .mapsPanel.hidden { display: none; }
     58       .mapsPanel { flex: 1; min-height: 0; overflow-y: auto; overflow-x: hidden; display:flex; flex-direction: column; }
     59       .app.mapsRoom .chat { display: none !important; }
     60       .app.mapsRoom .chatResizeHandle { display: none !important; }
     61       /* Keep core resize handles working in map mode by preserving grid areas. */
     62       @media (min-width: 761px) {
     63         .app.mapsRoom {
     64           grid-template-columns: minmax(240px, var(--sidebar-width)) 10px 1fr !important;
     65           grid-template-areas: "sidebar sidebarResize main" !important;
     66         }
     67         .app.mapsRoom.hasMod {
     68           grid-template-columns: minmax(240px, var(--sidebar-width)) 10px 1fr 10px minmax(280px, var(--mod-width)) !important;
     69           grid-template-areas: "sidebar sidebarResize main mainResize moderation" !important;
     70         }
     71         .app.mapsRoom.sidebarHidden {
     72           grid-template-columns: 1fr !important;
     73           grid-template-areas: "main" !important;
     74         }
     75         .app.mapsRoom.sidebarHidden.hasMod {
     76           grid-template-columns: 1fr 10px minmax(280px, var(--mod-width)) !important;
     77           grid-template-areas: "main mainResize moderation" !important;
     78         }
     79       }
     80       .mapsTop { padding: 12px 12px 0; display:flex; justify-content: space-between; align-items: center; gap: 10px; }
     81       .mapsTopTitle { font-weight: 900; }
     82       .mapCreateWrap { padding: 12px; }
     83       .mapCreateCard { border: 1px solid rgba(246,240,255,0.14); border-radius: 14px; background: rgba(255,255,255,0.02); padding: 10px; }
     84       .mapCreateGrid { display:grid; grid-template-columns: 1fr 1fr; gap: 10px; align-items: end; }
     85       .mapCreateGrid label span { display:block; font-size: 12px; color: rgba(246,240,255,0.72); margin-bottom: 4px; }
     86       .mapCreateGrid input[type="text"] { width: 100%; }
     87       .mapCreateRow { display:flex; gap: 10px; align-items: center; justify-content: flex-end; margin-top: 10px; }
     88       .mapRangeRow { display:flex; gap: 10px; align-items: center; }
     89       .mapRangeRow input[type="range"] { flex: 1; }
     90       .mapRangeVal { width: 44px; text-align: right; font-variant-numeric: tabular-nums; }
     91       .mapsGrid { display:grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 12px; padding: 12px; }
     92       .mapCard { border: 1px solid rgba(246,240,255,0.14); border-radius: 14px; background: rgba(255,255,255,0.02); padding: 10px; }
     93       .mapThumb { width: 100%; aspect-ratio: 16 / 9; border-radius: 12px; border: 1px solid rgba(246,240,255,0.10); object-fit: cover; background: rgba(255,255,255,0.02); }
     94       .mapTitle { font-weight: 800; margin-top: 8px; }
     95       .mapMeta { margin-top: 6px; color: rgba(246,240,255,0.72); font-size: 12px; display:flex; justify-content: space-between; gap: 10px; }
     96       .mapEnterRow { margin-top: 10px; display:flex; justify-content:flex-end; gap: 8px; }
     97       .mapView { display:flex; gap: 12px; padding: 12px; min-height: 0; flex: 1; }
     98       .mapCanvasWrap { flex: 1; border: 1px solid rgba(246,240,255,0.14); border-radius: 14px; background: rgba(0,0,0,0.18); position: relative; overflow:hidden; min-height: 360px; }
     99       .mapCanvas { width: 100%; height: 100%; display:block; }
    100       .mapHud { width: 240px; border: 1px solid rgba(246,240,255,0.14); border-radius: 14px; background: rgba(255,255,255,0.02); padding: 10px; min-height: 0; max-height: 100%; overflow: auto; }
    101       .mapHudTitle { font-weight: 800; display:flex; justify-content: space-between; align-items:center; gap: 8px; }
    102       .mapHudList { margin-top: 10px; display:flex; flex-direction: column; gap: 8px; max-height: 340px; overflow:auto; }
    103       .mapHint { margin-top: 10px; color: rgba(246,240,255,0.72); font-size: 12px; line-height: 1.05rem; }
    104       .mapChatOverlay { position:absolute; left: 12px; right: 12px; bottom: 12px; display:flex; gap: 8px; }
    105       .mapChatOverlay input { flex:1; }
    106       .mapWalkieBar { position:absolute; left: 12px; right: 12px; bottom: 12px; display:flex; justify-content:center; pointer-events:none; }
    107       .mapWalkieBarInner { pointer-events:auto; display:flex; gap: 10px; align-items:center; width: min(520px, 100%); }
    108       .mapWalkieBtn { flex: 1; height: 44px; border-radius: 14px; font-weight: 900; letter-spacing: 0.01em; }
    109       .mapWalkieHint { font-size: 12px; color: rgba(246,240,255,0.75); white-space: nowrap; }
    110       .mapsRoomWrap { display:flex; flex-direction: column; min-height: 0; flex: 1; }
    111       .mapDock { border: 1px solid rgba(246,240,255,0.14); border-radius: 14px; background: rgba(255,255,255,0.02); margin: 0 12px 12px; padding: 10px 12px; display:flex; flex-direction: column; min-height: 0; max-height: min(46vh, 520px); overflow:hidden; }
    112       .mapDock.collapsed { max-height: none; }
    113       .mapDock.collapsed .dockBody { display:none; }
    114       .dockHeaderRow { margin-bottom: 0; }
    115       .dockBody { flex: 1; min-height: 0; overflow:auto; padding-right: 4px; padding-top: 8px; }
    116       .dockRow { display:flex; gap: 10px; align-items: center; margin-bottom: 8px; flex-wrap: wrap; }
    117       .dockTitle { font-weight: 900; }
    118       .dockRow input[type="file"] { flex: 1 1 240px; max-width: 360px; }
    119       .dockRow input[type="text"] { flex: 1 1 220px; min-width: 180px; }
    120       .dockRow input[type="number"] { width: 92px; }
    121       .dockScale { display:flex; gap: 8px; align-items:center; min-width: 160px; flex: 1 1 160px; }
    122       .dockScale input[type="range"] { flex: 1; }
    123       .dockScaleVal { width: 52px; text-align:right; font-variant-numeric: tabular-nums; color: rgba(246,240,255,0.78); }
    124       .spriteTray { display:flex; gap: 8px; overflow:auto; padding: 8px; border: 1px solid rgba(246,240,255,0.10); border-radius: 12px; background: rgba(0,0,0,0.12); }
    125       .spriteThumb { width: 56px; height: 56px; border-radius: 12px; border: 1px solid rgba(246,240,255,0.14); background: rgba(255,255,255,0.02); overflow:hidden; padding: 0; display:flex; }
    126       .spriteThumb img { width:100%; height:100%; object-fit: cover; display:block; }
    127       .spriteThumb.selected { outline: 2px solid rgba(255,62,165,0.80); }
    128 
    129       .mapsPolyModal { position: fixed; inset: 0; display:flex; align-items:center; justify-content:center; background: rgba(0,0,0,0.55); z-index: 9999; }
    130       .mapsPolyModalInner { width: min(980px, calc(100vw - 48px)); max-height: min(78vh, 760px); overflow:hidden; border-radius: 18px; border: 1px solid rgba(246,240,255,0.14); background: linear-gradient(180deg, rgba(30,20,38,0.96), rgba(12,10,18,0.96)); box-shadow: 0 18px 60px rgba(0,0,0,0.55); padding: 14px; display:flex; flex-direction:column; }
    131       .mapsPolyHeader { display:flex; justify-content:space-between; gap: 14px; align-items:flex-start; }
    132       .mapsPolyTitle { font-weight: 900; font-size: 16px; }
    133       .mapsPolyGrid { margin-top: 12px; display:grid; grid-template-columns: 1fr 1fr; gap: 12px; min-height: 0; flex: 1; }
    134       .mapsPolyList { min-height: 0; overflow:auto; border: 1px solid rgba(246,240,255,0.10); border-radius: 14px; background: rgba(0,0,0,0.10); padding: 8px; }
    135       .mapsPolyInspector { min-height: 0; overflow:auto; border: 1px solid rgba(246,240,255,0.10); border-radius: 14px; background: rgba(0,0,0,0.10); padding: 10px; }
    136       .polyRowBtn { width: 100%; text-align:left; padding: 10px; border-radius: 12px; border: 1px solid rgba(246,240,255,0.10); background: rgba(255,255,255,0.02); margin-bottom: 8px; cursor:pointer; }
    137       .polyRowBtn:hover { border-color: rgba(246,240,255,0.18); background: rgba(255,255,255,0.03); }
    138       .polyRowBtn.selected { outline: 2px solid rgba(255,62,165,0.55); border-color: rgba(255,62,165,0.55); }
    139       .polyRowMain { font-weight: 800; }
    140       .polyRowMeta { margin-top: 2px; font-size: 12px; color: rgba(246,240,255,0.62); }
    141     `;
    142     document.head.appendChild(style);
    143 
    144     const mapsBtn = inRackMode
    145       ? null
    146       : (() => {
    147           const btn = document.createElement("button");
    148           btn.type = "button";
    149           btn.className = "ghost smallBtn mapsTabBtn";
    150           btn.textContent = "Maps";
    151           panelTitle.insertAdjacentElement("afterend", btn);
    152           return btn;
    153         })();
    154 
    155     const mapsPanel = document.createElement("div");
    156     mapsPanel.className = inRackMode ? "mapsPanel" : "mapsPanel hidden";
    157     const mount = inRackMode ? mainPanel.querySelector("[data-pluginmount]") : null;
    158     (mount || mainPanel).appendChild(mapsPanel);
    159 
    160     let mode = inRackMode ? "maps" : "hives"; // "hives" | "maps" | "map"
    161     let maps = [];
    162     let activeMap = null;
    163     let users = new Map(); // username -> {x,y,color,image}
    164     let bubbles = new Map(); // username -> {text, expiresAt}
    165     const avatarCache = new Map(); // username -> {src:string,img:HTMLImageElement|null,status:"loading"|"ok"|"error",failedAt:number}
    166     let self = "";
    167     let localPos = { x: 0.5, y: 0.5 };
    168     let keys = new Set();
    169     let raf = 0;
    170     let lastTick = 0;
    171     let lastSentAt = 0;
    172     let moveSeq = 1;
    173     let bgImg = null;
    174     let cameraPos = null; // {x,y} in normalized 0..1
    175     let createIdTouched = false;
    176     let mapAvatarSaveTimer = 0;
    177     let mapZoomSaveTimer = 0;
    178     let editMode = false;
    179     let editKind = "collision"; // "collision" | "mask" | "exit" | "hidden" | "occluder"
    180     let editTool = "draw"; // "draw" | "select" | "move" | "vertex"
    181     let selectedPolyKind = "";
    182     let selectedPolyIndex = -1;
    183     let selectedVertexIndex = -1;
    184     let polyClipboard = null; // { kind, poly }
    185     let polyDrag = null; // { kind, index, start:{x,y}, origPoints:[{x,y}] }
    186     let vertexDrag = null; // { kind, index, vIdx:number }
    187 
    188     // Exit metadata (used both for "new exit defaults" and for selected-exit edits)
    189     let exitAction = "toMaps"; // "toMaps" | "toMap"
    190     let exitTargetMapId = "";
    191     let exitTargetExitName = "";
    192     let exitDraftName = "";
    193     let draftPoly = []; // points [{x,y}] in normalized
    194     let lastTransform = null; // {srcX,srcY,zoom,worldW,worldH,viewW,viewH}
    195     let selfInvisible = false;
    196     let panning = false;
    197     let panStart = null; // {x,y,cx,cy}
    198     let audioCtx = null;
    199     let audioWarned = false;
    200     let walkieStream = null;
    201     let walkieRecorder = null;
    202     let walkieChunks = [];
    203     let walkieRecording = false;
    204     let walkieStartAt = 0;
    205     const walkiePlaybacks = new Map(); // id -> {audio, gain, pan, filter?, interval?, ackTimer?}
    206     const exitInside = new Map(); // idx -> boolean
    207     let lastExitAt = 0;
    208     let pendingSpawn = null; // { mapId, exitName }
    209     let selectedSpriteId = "";
    210     let selectedPropId = "";
    211     let spriteKind = "prop"; // "prop" | "token" (v1 uses props)
    212     let spriteScale = 1.0;
    213     let spriteScaleSaveTimer = 0;
    214     let placeRot = 0; // degrees (-180..180)
    215     const spriteImageCache = new Map(); // url -> {img,status:"loading"|"ok"|"error",failedAt:number}
    216     let propDrag = null; // {propId, offsetX, offsetY}
    217     let propDragMoved = false;
    218     let lastPropMoveAt = 0;
    219     let canManageTtrpg = false;
    220     let ttrpgTool = "select"; // "select" | "place" | "pan"
    221     let placeScale = 1.0;
    222     let speakingAsPropId = "";
    223     let ttrpgDockCollapsed = false;
    224 
    225     function setHidden(el, hidden) {
    226       if (!el) return;
    227       el.classList.toggle("hidden", Boolean(hidden));
    228     }
    229 
    230     function getSessionToken() {
    231       try {
    232         return localStorage.getItem("bzl_session_token") || "";
    233       } catch {
    234         return "";
    235       }
    236     }
    237 
    238     function dockCollapsedKey(mapId) {
    239       const id = String(mapId || "")
    240         .trim()
    241         .toLowerCase();
    242       const safe = id && /^[a-z0-9][a-z0-9_-]{0,40}$/.test(id) ? id : "default";
    243       return `bzl_maps_dockCollapsed_${safe}`;
    244     }
    245 
    246     function readDockCollapsed(mapId) {
    247       try {
    248         return localStorage.getItem(dockCollapsedKey(mapId)) === "1";
    249       } catch {
    250         return false;
    251       }
    252     }
    253 
    254     function writeDockCollapsed(mapId, collapsed) {
    255       ttrpgDockCollapsed = Boolean(collapsed);
    256       try {
    257         localStorage.setItem(dockCollapsedKey(mapId), ttrpgDockCollapsed ? "1" : "0");
    258       } catch {
    259         // ignore
    260       }
    261     }
    262 
    263     function slugifyId(title) {
    264       const t = String(title || "")
    265         .trim()
    266         .toLowerCase()
    267         .replace(/[^a-z0-9]+/g, "-")
    268         .replace(/^-+|-+$/g, "")
    269         .slice(0, 28);
    270       if (!t) return "";
    271       const first = t[0];
    272       if (!/[a-z0-9]/.test(first)) return `map-${t}`.slice(0, 31);
    273       return t;
    274     }
    275 
    276     async function uploadImageFile(file) {
    277       const token = getSessionToken();
    278       if (!token) throw new Error("Sign in required.");
    279       const maxBytes = 20 * 1024 * 1024;
    280       if (Number(file?.size || 0) > maxBytes) throw new Error("Map image too large (max 20 MB).");
    281       const name = String(file?.name || "").toLowerCase();
    282       const guessed =
    283         name.endsWith(".png")
    284           ? "image/png"
    285           : name.endsWith(".jpg") || name.endsWith(".jpeg")
    286             ? "image/jpeg"
    287             : name.endsWith(".gif")
    288               ? "image/gif"
    289               : name.endsWith(".webp")
    290                 ? "image/webp"
    291                 : "";
    292       const contentType = file.type || guessed || "";
    293       if (!contentType.startsWith("image/")) throw new Error("Unsupported image type.");
    294       const res = await fetch("/api/upload?kind=image&purpose=map", {
    295         method: "POST",
    296         headers: {
    297           Authorization: `Bearer ${token}`,
    298           "Content-Type": contentType
    299         },
    300         body: file
    301       });
    302       const json = await res.json().catch(() => ({}));
    303       if (!res.ok) throw new Error(String(json?.error || "Upload failed."));
    304       if (!json?.url) throw new Error("Upload failed.");
    305       return String(json.url);
    306     }
    307 
    308     async function uploadSpriteImageFile(file) {
    309       const token = getSessionToken();
    310       if (!token) throw new Error("Sign in required.");
    311       const maxBytes = 10 * 1024 * 1024;
    312       if (Number(file?.size || 0) > maxBytes) throw new Error("Sprite image too large (max 10 MB).");
    313       const name = String(file?.name || "").toLowerCase();
    314       const isPng = name.endsWith(".png") || file.type === "image/png";
    315       const isWebp = name.endsWith(".webp") || file.type === "image/webp";
    316       if (!isPng && !isWebp) throw new Error("Sprites must be PNG or WebP (transparency).");
    317       const contentType = isWebp ? "image/webp" : "image/png";
    318       const res = await fetch("/api/upload?kind=image&purpose=sprite", {
    319         method: "POST",
    320         headers: {
    321           Authorization: `Bearer ${token}`,
    322           "Content-Type": contentType
    323         },
    324         body: file
    325       });
    326       const json = await res.json().catch(() => ({}));
    327       if (!res.ok) throw new Error(String(json?.error || "Upload failed."));
    328       if (!json?.url) throw new Error("Upload failed.");
    329       return String(json.url);
    330     }
    331 
    332     async function uploadAudioBlob(blob, filenameHint = "walkie.webm") {
    333       const token = getSessionToken();
    334       if (!token) throw new Error("Sign in required.");
    335       const name = String(filenameHint || "").toLowerCase();
    336       const guessed =
    337         name.endsWith(".wav")
    338           ? "audio/wav"
    339           : name.endsWith(".ogg")
    340             ? "audio/ogg"
    341             : name.endsWith(".m4a")
    342               ? "audio/mp4"
    343               : name.endsWith(".aac")
    344                 ? "audio/aac"
    345                 : "audio/webm";
    346       const rawType = typeof blob?.type === "string" ? blob.type : "";
    347       const contentType = (rawType.split(";")[0] || "").trim().toLowerCase() || guessed;
    348       if (!contentType.startsWith("audio/")) throw new Error("Unsupported audio type.");
    349       const res = await fetch("/api/upload?kind=audio", {
    350         method: "POST",
    351         headers: {
    352           Authorization: `Bearer ${token}`,
    353           "Content-Type": contentType
    354         },
    355         body: blob
    356       });
    357       const json = await res.json().catch(() => ({}));
    358       if (!res.ok) throw new Error(String(json?.error || "Upload failed."));
    359       if (!json?.url) throw new Error("Upload failed.");
    360       return String(json.url);
    361     }
    362 
    363     function ensureAudioContext() {
    364       if (audioCtx) return audioCtx;
    365       const Ctx = window.AudioContext || window.webkitAudioContext;
    366       if (!Ctx) return null;
    367       audioCtx = new Ctx();
    368       return audioCtx;
    369     }
    370 
    371     async function ensureAudioReady() {
    372       const ctxA = ensureAudioContext();
    373       if (!ctxA) return false;
    374       try {
    375         if (ctxA.state !== "running") await ctxA.resume();
    376         return true;
    377       } catch {
    378         return false;
    379       }
    380     }
    381 
    382     function clamp(n, a, b) {
    383       const x = Number(n);
    384       if (!Number.isFinite(x)) return a;
    385       return Math.max(a, Math.min(b, x));
    386     }
    387 
    388     function computeWalkieSpatial(from, to, dims) {
    389       const dx = (Number(from.x || 0) - Number(to.x || 0)) * dims.w;
    390       const dy = (Number(from.y || 0) - Number(to.y || 0)) * dims.h;
    391       const dist = Math.hypot(dx, dy);
    392       const base = Math.min(dims.w, dims.h);
    393       const radius = clamp(base * 0.28, 280, 680);
    394       const v = Math.max(0, 1 - dist / radius);
    395       const vol = v * v;
    396       const pan = clamp(dx / radius, -1, 1);
    397       const cutoff = 600 + 7400 * vol;
    398       return { vol, pan, cutoff };
    399     }
    400 
    401     async function ensureWalkieStream() {
    402       if (walkieStream) return walkieStream;
    403       if (!navigator.mediaDevices?.getUserMedia) throw new Error("Mic not supported in this browser.");
    404       const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
    405       walkieStream = stream;
    406       return stream;
    407     }
    408 
    409     function pickRecorderMime() {
    410       if (!window.MediaRecorder) return "";
    411       const prefs = ["audio/webm;codecs=opus", "audio/ogg;codecs=opus", "audio/webm", "audio/ogg"];
    412       for (const t of prefs) {
    413         try {
    414           if (MediaRecorder.isTypeSupported(t)) return t;
    415         } catch {
    416           // ignore
    417         }
    418       }
    419       return "";
    420     }
    421 
    422     async function startWalkie() {
    423       if (walkieRecording) return;
    424       if (!activeMap?.walkiesEnabled) return;
    425       await ensureAudioReady();
    426       const stream = await ensureWalkieStream();
    427       const mimeType = pickRecorderMime();
    428       walkieChunks = [];
    429       walkieRecorder = mimeType ? new MediaRecorder(stream, { mimeType }) : new MediaRecorder(stream);
    430       walkieRecording = true;
    431       walkieStartAt = Date.now();
    432       walkieRecorder.ondataavailable = (e) => {
    433         if (e.data && e.data.size) walkieChunks.push(e.data);
    434       };
    435       walkieRecorder.onstop = async () => {
    436         walkieRecording = false;
    437         const elapsed = Date.now() - walkieStartAt;
    438         if (elapsed < 180) return;
    439         const blob = new Blob(walkieChunks, { type: walkieRecorder?.mimeType || walkieChunks?.[0]?.type || "audio/webm" });
    440         walkieChunks = [];
    441         const id = `${Date.now()}_${Math.random().toString(16).slice(2)}`;
    442         try {
    443           const url = await uploadAudioBlob(blob, "walkie.webm");
    444           // Play locally immediately using spatial audio too.
    445           playWalkie({ id, username: String(ctx.getUser() || "").trim().toLowerCase(), url, x: localPos.x, y: localPos.y });
    446           ctx.send("walkieSend", { id, url, x: localPos.x, y: localPos.y });
    447         } catch (e) {
    448           ctx.toast("Walkie", String(e?.message || e));
    449         }
    450       };
    451       walkieRecorder.start();
    452     }
    453 
    454     function stopWalkie() {
    455       if (!walkieRecording || !walkieRecorder) return;
    456       try {
    457         walkieRecorder.stop();
    458       } catch {
    459         // ignore
    460       }
    461     }
    462 
    463     function stopAllWalkies() {
    464       for (const entry of walkiePlaybacks.values()) {
    465         try {
    466           if (entry.interval) clearInterval(entry.interval);
    467           if (entry.ackTimer) clearTimeout(entry.ackTimer);
    468           entry.audio?.pause?.();
    469           entry.audio?.remove?.();
    470         } catch {
    471           // ignore
    472         }
    473       }
    474       walkiePlaybacks.clear();
    475     }
    476 
    477     async function playWalkie(msg) {
    478       const id = String(msg?.id || "").trim();
    479       const url = String(msg?.url || "").trim();
    480       const username = String(msg?.username || "").trim().toLowerCase();
    481       if (!id || !url || !username) return;
    482       if (walkiePlaybacks.has(id)) return;
    483       if (!activeMap?.walkiesEnabled) return;
    484 
    485       const ok = await ensureAudioReady();
    486       if (!ok) {
    487         if (!audioWarned) {
    488           audioWarned = true;
    489           ctx.toast("Audio", "Click or press a key once to enable audio playback.");
    490         }
    491         return;
    492       }
    493 
    494       const dims = getWorldDims();
    495       const from = { x: Number(msg.x || 0), y: Number(msg.y || 0) };
    496       const to = { x: Number(localPos.x || 0), y: Number(localPos.y || 0) };
    497       const spatial = computeWalkieSpatial(from, to, dims);
    498 
    499       const a = document.createElement("audio");
    500       a.src = url;
    501       a.preload = "auto";
    502       a.crossOrigin = "anonymous";
    503       a.style.display = "none";
    504       document.body.appendChild(a);
    505 
    506       const ac = ensureAudioContext();
    507       let source = null;
    508       let gain = null;
    509       let pan = null;
    510       let filter = null;
    511       try {
    512         source = ac.createMediaElementSource(a);
    513         gain = ac.createGain();
    514         gain.gain.value = spatial.vol;
    515         filter = ac.createBiquadFilter();
    516         filter.type = "lowpass";
    517         filter.frequency.value = spatial.cutoff;
    518         if (ac.createStereoPanner) {
    519           pan = ac.createStereoPanner();
    520           pan.pan.value = spatial.pan;
    521           source.connect(filter);
    522           filter.connect(pan);
    523           pan.connect(gain);
    524           gain.connect(ac.destination);
    525         } else {
    526           source.connect(filter);
    527           filter.connect(gain);
    528           gain.connect(ac.destination);
    529         }
    530       } catch (e) {
    531         try {
    532           a.remove();
    533         } catch {
    534           // ignore
    535         }
    536         return;
    537       }
    538 
    539       const ack = () => {
    540         if (!activeMap?.id) return;
    541         ctx.send("walkiePlayed", { id });
    542       };
    543 
    544       const cleanup = () => {
    545         const entry = walkiePlaybacks.get(id);
    546         if (!entry) return;
    547         try {
    548           if (entry.interval) clearInterval(entry.interval);
    549           if (entry.ackTimer) clearTimeout(entry.ackTimer);
    550         } catch {
    551           // ignore
    552         }
    553         try {
    554           a.pause();
    555         } catch {
    556           // ignore
    557         }
    558         try {
    559           a.remove();
    560         } catch {
    561           // ignore
    562         }
    563         walkiePlaybacks.delete(id);
    564       };
    565 
    566       a.onended = () => {
    567         ack();
    568         cleanup();
    569       };
    570       a.onerror = () => {
    571         ack();
    572         cleanup();
    573       };
    574 
    575       const interval = setInterval(() => {
    576         const u = users.get(username);
    577         const fx = u && typeof u.tx === "number" ? u.tx : from.x;
    578         const fy = u && typeof u.ty === "number" ? u.ty : from.y;
    579         const sp = computeWalkieSpatial({ x: fx, y: fy }, { x: localPos.x, y: localPos.y }, dims);
    580         if (gain) gain.gain.value = sp.vol;
    581         if (pan) pan.pan.value = sp.pan;
    582         if (filter) filter.frequency.value = sp.cutoff;
    583       }, 120);
    584 
    585       const ackTimer = setTimeout(() => {
    586         ack();
    587       }, 25_000);
    588 
    589       walkiePlaybacks.set(id, { audio: a, gain, pan, filter, interval, ackTimer });
    590       try {
    591         await a.play();
    592       } catch {
    593         // If autoplay blocked, we'll just cleanup (owner can click to enable and retry later).
    594         cleanup();
    595       }
    596     }
    597 
    598     function enterMaps() {
    599       mode = "maps";
    600       if (mapsBtn) {
    601         mapsBtn.classList.add("primary");
    602         mapsBtn.classList.remove("ghost");
    603       }
    604       setHidden(filters, true);
    605       setHidden(hiveTabs, true);
    606       setHidden(feed, true);
    607       setHidden(pollinatePanel, true);
    608       setHidden(mapsPanel, false);
    609       if (appRoot) appRoot.classList.remove("mapsRoom");
    610       if (chatPanel) chatPanel.classList.remove("hidden");
    611       if (chatResizeHandle) chatResizeHandle.classList.remove("hidden");
    612       renderMapsList();
    613       ctx.send("list", {});
    614     }
    615 
    616     function exitMapsToHives() {
    617       mode = "hives";
    618       if (mapsBtn) {
    619         mapsBtn.classList.add("ghost");
    620         mapsBtn.classList.remove("primary");
    621       }
    622       setHidden(filters, false);
    623       setHidden(hiveTabs, false);
    624       setHidden(feed, false);
    625       setHidden(pollinatePanel, false);
    626       setHidden(mapsPanel, true);
    627       if (appRoot) appRoot.classList.remove("mapsRoom");
    628       if (chatPanel) chatPanel.classList.remove("hidden");
    629       if (chatResizeHandle) chatResizeHandle.classList.remove("hidden");
    630       stopLoop();
    631       stopWalkie();
    632       stopAllWalkies();
    633       activeMap = null;
    634       speakingAsPropId = "";
    635       users.clear();
    636       bubbles.clear();
    637       keys.clear();
    638     }
    639 
    640     function renderMapsList() {
    641       if (!mapsPanel) return;
    642       if (mode !== "maps") return;
    643       const canCreate = ["owner", "moderator"].includes(String(ctx.getRole() || "").toLowerCase());
    644       const me = String(ctx.getUser() || "").trim().toLowerCase();
    645       const role = String(ctx.getRole() || "").toLowerCase();
    646       const createHtml = canCreate
    647         ? `
    648         <div class="mapCreateWrap">
    649           <div class="mapCreateCard">
    650             <div class="mapsTop">
    651               <div class="mapsTopTitle">Create map</div>
    652               <div class="small muted">Owner/mod only</div>
    653             </div>
    654             <div class="mapCreateGrid">
    655               <label>
    656                 <span>Title</span>
    657                 <input id="mapsCreateTitle" type="text" maxlength="60" placeholder="Example: Lounge" />
    658               </label>
    659               <label>
    660                 <span>Map id</span>
    661                 <input id="mapsCreateId" type="text" maxlength="31" placeholder="lounge" />
    662               </label>
    663               <label>
    664                 <span>Background image</span>
    665                 <input id="mapsCreateFile" type="file" accept="image/*" />
    666               </label>
    667               <label>
    668                 <span>Avatar size</span>
    669                 <div class="mapRangeRow">
    670                   <input id="mapsCreateAvatarSize" type="range" min="18" max="96" value="36" />
    671                   <div class="mapRangeVal" id="mapsCreateAvatarVal">36</div>
    672                 </div>
    673               </label>
    674             </div>
    675             <div class="mapCreateRow">
    676               <div class="small muted grow" id="mapsCreateStatus"></div>
    677               <button type="button" class="primary smallBtn" id="mapsCreateBtn">Create</button>
    678             </div>
    679           </div>
    680         </div>`
    681         : "";
    682       const grid = maps
    683         .map((m) => {
    684           const count = Number(m.userCount || 0) || 0;
    685           const thumb = m.thumbUrl || "";
    686           const owner = String(m.owner || "").trim().toLowerCase();
    687           const canManage = role === "owner" || role === "moderator" || (owner && me && owner === me);
    688           return `<div class="mapCard">
    689             <img class="mapThumb" src="${thumb}" alt="" />
    690             <div class="mapTitle">${escapeHtml(m.title || m.id)}</div>
    691             <div class="mapMeta"><span>${escapeHtml(m.id)}</span><span>${count} in room</span></div>
    692             <div class="mapEnterRow">
    693               <button type="button" class="primary smallBtn" data-mapenter="${escapeHtml(m.id)}">Enter</button>
    694               ${canManage && owner ? `<button type="button" class="ghost smallBtn" data-mapdelete="${escapeHtml(m.id)}">Delete</button>` : ""}
    695             </div>
    696           </div>`;
    697         })
    698         .join("");
    699       mapsPanel.innerHTML = `${createHtml}<div class="mapsGrid">${grid || `<div class="muted">No maps available.</div>`}</div>`;
    700 
    701       const titleEl = document.getElementById("mapsCreateTitle");
    702       const idEl = document.getElementById("mapsCreateId");
    703       const fileEl = document.getElementById("mapsCreateFile");
    704       const rangeEl = document.getElementById("mapsCreateAvatarSize");
    705       const rangeVal = document.getElementById("mapsCreateAvatarVal");
    706       const btnEl = document.getElementById("mapsCreateBtn");
    707       const statusEl = document.getElementById("mapsCreateStatus");
    708       if (rangeEl && rangeVal) {
    709         rangeEl.oninput = () => {
    710           rangeVal.textContent = String(rangeEl.value || "36");
    711         };
    712       }
    713       if (idEl) {
    714         idEl.oninput = () => {
    715           createIdTouched = true;
    716         };
    717       }
    718       if (titleEl && idEl) {
    719         titleEl.oninput = () => {
    720           if (createIdTouched) return;
    721           idEl.value = slugifyId(titleEl.value);
    722         };
    723       }
    724       if (btnEl && titleEl && idEl && fileEl && statusEl && rangeEl) {
    725         btnEl.onclick = async () => {
    726           const title = String(titleEl.value || "").trim();
    727           const id = String(idEl.value || "").trim().toLowerCase();
    728           const file = fileEl.files && fileEl.files[0] ? fileEl.files[0] : null;
    729           const avatarSize = Number(rangeEl.value || 36);
    730           if (!title) {
    731             statusEl.textContent = "Title required.";
    732             return;
    733           }
    734           if (!id || !/^[a-z0-9][a-z0-9_.-]{0,31}$/.test(id)) {
    735             statusEl.textContent = "Valid map id required (letters/numbers, '-', '_', '.').";
    736             return;
    737           }
    738           if (!file) {
    739             statusEl.textContent = "Choose an image file.";
    740             return;
    741           }
    742           btnEl.disabled = true;
    743           statusEl.textContent = "Uploading...";
    744           try {
    745             const url = await uploadImageFile(file);
    746             statusEl.textContent = "Creating...";
    747             ctx.send("createMap", { id, title, backgroundUrl: url, thumbUrl: url, avatarSize });
    748             statusEl.textContent = "Created.";
    749             titleEl.value = "";
    750             idEl.value = "";
    751             fileEl.value = "";
    752             createIdTouched = false;
    753           } catch (e) {
    754             statusEl.textContent = String(e?.message || e);
    755           } finally {
    756             btnEl.disabled = false;
    757           }
    758         };
    759       }
    760     }
    761 
    762     function renderMapView() {
    763       if (!mapsPanel) return;
    764       if (mode !== "map" || !activeMap) return;
    765       if (appRoot) appRoot.classList.add("mapsRoom");
    766       if (chatPanel) chatPanel.classList.add("hidden");
    767       if (chatResizeHandle) chatResizeHandle.classList.add("hidden");
    768       const title = escapeHtml(activeMap.title || activeMap.id);
    769       const list = Array.from(users.keys())
    770         .sort((a, b) => a.localeCompare(b))
    771         .map((u) => `<div class="small">@${escapeHtml(u)}</div>`)
    772         .join("");
    773 
    774       const role = String(ctx.getRole() || "").toLowerCase();
    775       const me = String(ctx.getUser() || "").trim().toLowerCase();
    776       const canManage = role === "owner" || role === "moderator" || (activeMap.owner && me && activeMap.owner === me);
    777       const canEditMap = canManage && Boolean(activeMap.owner);
    778       const showSettings = canManage;
    779       canManageTtrpg = Boolean(canManage);
    780       const avatarSize = Number(activeMap.avatarSize || 36);
    781       const cameraZoom = Math.max(0.8, Math.min(5.0, Number(activeMap.cameraZoom || 2.35) || 2.35));
    782       const polysCount =
    783         (Array.isArray(activeMap.collisions) ? activeMap.collisions.length : 0) +
    784         (Array.isArray(activeMap.masks) ? activeMap.masks.length : 0) +
    785         (Array.isArray(activeMap.exits) ? activeMap.exits.length : 0);
    786       const walkiesEnabled = Boolean(activeMap.walkiesEnabled);
    787       const ttrpgEnabled = Boolean(activeMap.ttrpgEnabled);
    788       const shortcutHintHtml = `
    789         <div class="mapHint">
    790           Shortcuts:<br/>
    791           Move: <b>WASD</b> / arrows, Chat: <b>T</b><br/>
    792           ${walkiesEnabled ? `Walkie: hold <b>~</b><br/>` : ""}
    793           ${ttrpgEnabled && canManageTtrpg ? `Tools: <b>V</b> select, <b>P</b> place, hold <b>Space</b> pan<br/>Transform: <b>Q/E</b> rotate, <b>Z/X</b> scale<br/>` : ""}
    794           Leave: click <b>Back</b>
    795         </div>
    796       `;
    797       const settingsHtml = showSettings
    798         ? `
    799           <div class="panelDivider"></div>
    800           <div class="small muted">Map settings</div>
    801           <label class="checkRow" style="margin-top:10px;">
    802             <span>GM invisible</span>
    803             <input id="mapsInvisibleToggle" type="checkbox" ${selfInvisible ? "checked" : ""} />
    804           </label>
    805           ${canEditMap ? `
    806           <label>
    807             <span class="small muted">Avatar size</span>
    808             <div class="mapRangeRow">
    809               <input id="mapsAvatarSizeRange" type="range" min="18" max="96" value="${escapeHtml(avatarSize)}" />
    810               <div class="mapRangeVal" id="mapsAvatarSizeVal">${escapeHtml(avatarSize)}</div>
    811             </div>
    812           </label>
    813           <label style="margin-top:10px;">
    814             <span class="small muted">Camera zoom</span>
    815             <div class="mapRangeRow">
    816               <input id="mapsCameraZoomRange" type="range" min="1" max="4" step="0.05" value="${escapeHtml(cameraZoom.toFixed(2))}" />
    817               <div class="mapRangeVal" id="mapsCameraZoomVal">${escapeHtml(cameraZoom.toFixed(2))}</div>
    818             </div>
    819           </label>
    820           <label class="checkRow" style="margin-top:10px;">
    821             <span>Enable walkies</span>
    822             <input id="mapsWalkiesToggle" type="checkbox" ${walkiesEnabled ? "checked" : ""} />
    823           </label>
    824           <label class="checkRow" style="margin-top:10px;">
    825             <span>TTRPG mode</span>
    826             <input id="mapsTtrpgToggle" type="checkbox" ${ttrpgEnabled ? "checked" : ""} />
    827           </label>
    828           ${ttrpgEnabled && canManageTtrpg ? `
    829           <div class="small muted" style="margin-top:10px; line-height:1.15rem;">
    830             Inspector shortcuts:<br/>
    831             <b>V</b> select, <b>P</b> place, hold <b>Space</b> pan<br/>
    832             <b>Q/E</b> rotate selected, <b>Z/X</b> scale selected
    833           </div>
    834           ` : ""}
    835           <div class="row" style="margin-top:10px; gap:10px;">
    836             <button type="button" class="${editMode ? "primary" : "ghost"} smallBtn" id="mapsEditToggle">Polygon editor</button>
    837             <div class="small muted grow">${polysCount} polys</div>
    838           </div>
    839           <div id="mapsEditPanel" class="hidden">
    840             <div class="small muted" style="margin-top:10px;">Click to add points. Right-click (or Shift+drag) to pan. ESC clears draft.</div>
    841             <div class="row" style="margin-top:10px; gap:10px; align-items:center;">
    842               <button type="button" class="${editKind === "collision" ? "primary" : "ghost"} smallBtn" id="mapsKindCollision">Collision</button>
    843               <button type="button" class="${editKind === "mask" ? "primary" : "ghost"} smallBtn" id="mapsKindMask">Y-sort mask</button>
    844               <button type="button" class="${editKind === "exit" ? "primary" : "ghost"} smallBtn" id="mapsKindExit">Exit</button>
    845             </div>
    846             <div class="row ${editKind === "exit" ? "" : "hidden"}" id="mapsExitRow" style="margin-top:10px; gap:10px; align-items:center;">
    847               <select id="mapsExitAction" class="grow">
    848                 <option value="toMaps">Exit to Maps</option>
    849                 <option value="toMap">Exit to Map…</option>
    850               </select>
    851               <select id="mapsExitTarget" class="grow hidden"></select>
    852             </div>
    853             <div class="row" style="margin-top:10px; gap:10px; align-items:center;">
    854               <button type="button" class="ghost smallBtn" id="mapsPolyUndo">Undo</button>
    855               <button type="button" class="ghost smallBtn" id="mapsPolyClose">Close poly</button>
    856             </div>
    857             <div class="row" style="margin-top:10px; gap:10px; align-items:center;">
    858               <button type="button" class="ghost smallBtn" id="mapsPolyClearAll">Clear all</button>
    859               <button type="button" class="primary smallBtn" id="mapsPolySave">Save</button>
    860             </div>
    861             <div class="small muted" id="mapsPolyStatus" style="margin-top:8px;"></div>
    862           </div>
    863           ` : ""}
    864         `
    865         : "";
    866       const polyModalHtml = canEditMap && editMode ? renderPolyModal() : "";
    867       mapsPanel.innerHTML = `
    868         <div class="mapsRoomWrap">
    869         <div class="mapView">
    870           <div class="mapCanvasWrap">
    871             <canvas class="mapCanvas" id="mapsCanvas"></canvas>
    872             <div class="mapChatOverlay hidden" id="mapsChatOverlay">
    873               <input id="mapsChatInput" placeholder="Say something..." />
    874               <button type="button" class="primary" id="mapsChatSend">Send</button>
    875             </div>
    876             <div class="mapWalkieBar ${walkiesEnabled ? "" : "hidden"}" id="mapsWalkieBar">
    877               <div class="mapWalkieBarInner">
    878                 <button type="button" class="primary mapWalkieBtn" id="mapsWalkieBtn">Hold to talk</button>
    879                 <div class="mapWalkieHint">or hold <b>~</b></div>
    880               </div>
    881             </div>
    882           </div>
    883           <div class="mapHud">
    884             <div class="mapHudTitle">
    885               <div>${title}</div>
    886               <button type="button" class="ghost smallBtn" data-mapback="1">Back</button>
    887             </div>
    888             <div class="small muted">${users.size} in room</div>
    889             <div class="mapHudList">${list || `<div class="muted small">No one here yet.</div>`}</div>
    890             <div class="mapHint">
    891               Exits: <b>${escapeHtml(Array.isArray(activeMap.exits) ? activeMap.exits.length : 0)}</b>
    892             </div>
    893             ${shortcutHintHtml}
    894             ${settingsHtml}
    895           </div>
    896         </div>
    897         <div class="mapDock ${ttrpgEnabled ? "" : "hidden"}" id="mapsTtrpgDock"></div>
    898         ${polyModalHtml}
    899         </div>
    900       `;
    901       loadBackground(activeMap.backgroundUrl || "");
    902       startLoop();
    903 
    904       const invToggle = document.getElementById("mapsInvisibleToggle");
    905       if (invToggle && showSettings) {
    906         invToggle.onchange = () => {
    907           const invisible = Boolean(invToggle.checked);
    908           selfInvisible = invisible;
    909           ctx.send("setInvisible", { mapId: activeMap.id, invisible });
    910           renderMapView();
    911         };
    912       }
    913 
    914       const range = document.getElementById("mapsAvatarSizeRange");
    915       const val = document.getElementById("mapsAvatarSizeVal");
    916       if (range && val && canEditMap) {
    917         const commit = () => {
    918           const next = Math.max(18, Math.min(96, Number(range.value || 36)));
    919           val.textContent = String(next);
    920           activeMap.avatarSize = next;
    921           if (mapAvatarSaveTimer) clearTimeout(mapAvatarSaveTimer);
    922           mapAvatarSaveTimer = setTimeout(() => {
    923             ctx.send("updateMap", { id: activeMap.id, avatarSize: next });
    924           }, 220);
    925         };
    926         range.oninput = commit;
    927         range.onchange = commit;
    928       }
    929 
    930       const zoomRange = document.getElementById("mapsCameraZoomRange");
    931       const zoomVal = document.getElementById("mapsCameraZoomVal");
    932       if (zoomRange && zoomVal && canEditMap) {
    933         const commit = () => {
    934           const next = Math.max(1, Math.min(4, Number(zoomRange.value || 2.35) || 2.35));
    935           zoomVal.textContent = next.toFixed(2);
    936           activeMap.cameraZoom = next;
    937           if (mapZoomSaveTimer) clearTimeout(mapZoomSaveTimer);
    938           mapZoomSaveTimer = setTimeout(() => {
    939             ctx.send("updateMap", { id: activeMap.id, cameraZoom: next });
    940           }, 220);
    941         };
    942         zoomRange.oninput = commit;
    943         zoomRange.onchange = commit;
    944       }
    945 
    946       const walkiesToggle = document.getElementById("mapsWalkiesToggle");
    947       if (walkiesToggle && canEditMap) {
    948         walkiesToggle.onchange = () => {
    949           const enabled = Boolean(walkiesToggle.checked);
    950           activeMap.walkiesEnabled = enabled;
    951           ctx.send("updateMap", { id: activeMap.id, walkiesEnabled: enabled });
    952           const bar = document.getElementById("mapsWalkieBar");
    953           if (bar) bar.classList.toggle("hidden", !enabled);
    954         };
    955       }
    956 
    957       const ttrpgToggle = document.getElementById("mapsTtrpgToggle");
    958       if (ttrpgToggle && canEditMap) {
    959         ttrpgToggle.onchange = () => {
    960           const enabled = Boolean(ttrpgToggle.checked);
    961           activeMap.ttrpgEnabled = enabled;
    962           ctx.send("ttrpgSetEnabled", { mapId: activeMap.id, enabled });
    963           renderMapView();
    964         };
    965       }
    966 
    967       const editBtn = document.getElementById("mapsEditToggle");
    968       if (editBtn && canEditMap) {
    969         editBtn.onclick = () => {
    970           editMode = !editMode;
    971           draftPoly = [];
    972           polyDrag = null;
    973           vertexDrag = null;
    974           panning = false;
    975           panStart = null;
    976           if (editMode) {
    977             const list = polysForKind(activeMap, editKind);
    978             editTool = list.length ? "select" : "draw";
    979           } else {
    980             selectedPolyKind = "";
    981             selectedPolyIndex = -1;
    982             selectedVertexIndex = -1;
    983           }
    984           renderMapView();
    985         };
    986       }
    987 
    988       if (editMode) {
    989         wirePolyModalHandlers();
    990       }
    991 
    992       const canvas = document.getElementById("mapsCanvas");
    993       if (canvas) {
    994         if (editMode) {
    995           canvas.style.cursor =
    996             editTool === "draw" ? "crosshair" : editTool === "select" ? "pointer" : editTool === "move" ? "move" : "cell";
    997         }
    998         else if (activeMap?.ttrpgEnabled && canManageTtrpg && ttrpgTool === "pan") canvas.style.cursor = "grab";
    999         else if (activeMap?.ttrpgEnabled && canManageTtrpg && ttrpgTool === "place") canvas.style.cursor = "copy";
   1000         else canvas.style.cursor = "default";
   1001         canvas.oncontextmenu = (e) => {
   1002           if (editMode) e.preventDefault();
   1003         };
   1004         canvas.onpointerdown = (e) => {
   1005           if (!lastTransform) return;
   1006           // Edit mode interactions
   1007           if (editMode) {
   1008             const isPan = e.button === 2 || e.shiftKey;
   1009             if (isPan) {
   1010               panning = true;
   1011               canvas.setPointerCapture(e.pointerId);
   1012               panStart = { x: e.clientX, y: e.clientY, cx: cameraPos?.x ?? 0.5, cy: cameraPos?.y ?? 0.5 };
   1013               return;
   1014             }
   1015             if (e.button !== 0) return;
   1016             const pt = screenToWorldNormalized(e.clientX, e.clientY, canvas, lastTransform);
   1017             if (!pt) return;
   1018 
   1019             if (editTool === "draw") {
   1020               draftPoly.push(pt);
   1021               const se = document.getElementById("mapsPolyStatus");
   1022               if (se) se.textContent = `${draftPoly.length} pts (draft)`;
   1023               return;
   1024             }
   1025 
   1026             if (editTool === "select") {
   1027               const hit = hitTestPoly(pt, activeMap, editKind);
   1028               if (hit) {
   1029                 selectedPolyKind = editKind;
   1030                 selectedPolyIndex = hit.index;
   1031                 selectedVertexIndex = -1;
   1032               } else {
   1033                 selectedPolyKind = "";
   1034                 selectedPolyIndex = -1;
   1035                 selectedVertexIndex = -1;
   1036               }
   1037               renderMapView();
   1038               return;
   1039             }
   1040 
   1041             if (editTool === "move") {
   1042               if (selectedPolyKind !== editKind || selectedPolyIndex < 0) return;
   1043               const list = polysForKind(activeMap, editKind);
   1044               const poly = list[selectedPolyIndex];
   1045               if (!poly || !pointInPoly(pt, poly)) return;
   1046               polyDrag = {
   1047                 kind: editKind,
   1048                 index: selectedPolyIndex,
   1049                 start: { x: pt.x, y: pt.y },
   1050                 origPoints: (Array.isArray(poly.points) ? poly.points : []).map((p) => ({ x: Number(p.x || 0), y: Number(p.y || 0) })),
   1051               };
   1052               canvas.setPointerCapture(e.pointerId);
   1053               return;
   1054             }
   1055 
   1056             if (editTool === "vertex") {
   1057               if (selectedPolyKind !== editKind || selectedPolyIndex < 0) return;
   1058               const list = polysForKind(activeMap, editKind);
   1059               const poly = list[selectedPolyIndex];
   1060               if (!poly) return;
   1061               const vIdx = hitTestVertex(e.clientX, e.clientY, canvas, lastTransform, poly);
   1062               if (vIdx < 0) return;
   1063               selectedVertexIndex = vIdx;
   1064               vertexDrag = { kind: editKind, index: selectedPolyIndex, vIdx };
   1065               canvas.setPointerCapture(e.pointerId);
   1066               return;
   1067             }
   1068           }
   1069 
   1070           if (!activeMap?.ttrpgEnabled || !canManageTtrpg) return;
   1071           if (e.button !== 0) return;
   1072           if (ttrpgTool === "pan") {
   1073             panning = true;
   1074             canvas.setPointerCapture(e.pointerId);
   1075             panStart = { x: e.clientX, y: e.clientY, cx: cameraPos?.x ?? 0.5, cy: cameraPos?.y ?? 0.5 };
   1076             canvas.style.cursor = "grabbing";
   1077             return;
   1078           }
   1079           const hit = hitTestPropAtPointer(e.clientX, e.clientY, canvas, lastTransform);
   1080           if (hit) {
   1081             selectedPropId = hit.propId;
   1082             const pt = screenToWorldNormalized(e.clientX, e.clientY, canvas, lastTransform);
   1083             if (!pt) return;
   1084             if (ttrpgTool === "select") {
   1085               propDrag = { propId: hit.propId, offsetX: hit.x - pt.x, offsetY: hit.y - pt.y };
   1086               propDragMoved = false;
   1087               canvas.setPointerCapture(e.pointerId);
   1088             }
   1089             renderTtrpgDock();
   1090           } else if (ttrpgTool === "select") {
   1091             selectedPropId = "";
   1092             renderTtrpgDock();
   1093           }
   1094         };
   1095         canvas.onpointermove = (e) => {
   1096           if (!lastTransform) return;
   1097           if (editMode) {
   1098             if (panning && panStart && lastTransform) {
   1099               const dx = e.clientX - panStart.x;
   1100               const dy = e.clientY - panStart.y;
   1101               const worldDx = -(dx / lastTransform.zoom);
   1102               const worldDy = -(dy / lastTransform.zoom);
   1103               const nx = (panStart.cx * lastTransform.worldW + worldDx) / lastTransform.worldW;
   1104               const ny = (panStart.cy * lastTransform.worldH + worldDy) / lastTransform.worldH;
   1105               cameraPos = { x: Math.max(0, Math.min(1, nx)), y: Math.max(0, Math.min(1, ny)) };
   1106               return;
   1107             }
   1108             if (polyDrag) {
   1109               const pt = screenToWorldNormalized(e.clientX, e.clientY, canvas, lastTransform);
   1110               if (!pt) return;
   1111               const dx = pt.x - polyDrag.start.x;
   1112               const dy = pt.y - polyDrag.start.y;
   1113               const list = polysForKind(activeMap, polyDrag.kind);
   1114               const poly = list[polyDrag.index];
   1115               if (!poly) return;
   1116               poly.points = polyDrag.origPoints.map((p) => ({ x: Math.max(0, Math.min(1, p.x + dx)), y: Math.max(0, Math.min(1, p.y + dy)) }));
   1117               return;
   1118             }
   1119             if (vertexDrag) {
   1120               const pt = screenToWorldNormalized(e.clientX, e.clientY, canvas, lastTransform);
   1121               if (!pt) return;
   1122               const list = polysForKind(activeMap, vertexDrag.kind);
   1123               const poly = list[vertexDrag.index];
   1124               const pts = Array.isArray(poly?.points) ? poly.points : null;
   1125               if (!poly || !pts || vertexDrag.vIdx < 0 || vertexDrag.vIdx >= pts.length) return;
   1126               pts[vertexDrag.vIdx] = { x: pt.x, y: pt.y };
   1127               poly.points = pts;
   1128               return;
   1129             }
   1130             return;
   1131           }
   1132           if (panning && panStart && lastTransform) {
   1133             const dx = e.clientX - panStart.x;
   1134             const dy = e.clientY - panStart.y;
   1135             const worldDx = -(dx / lastTransform.zoom);
   1136             const worldDy = -(dy / lastTransform.zoom);
   1137             const nx = (panStart.cx * lastTransform.worldW + worldDx) / lastTransform.worldW;
   1138             const ny = (panStart.cy * lastTransform.worldH + worldDy) / lastTransform.worldH;
   1139             cameraPos = { x: Math.max(0, Math.min(1, nx)), y: Math.max(0, Math.min(1, ny)) };
   1140             return;
   1141           }
   1142           if (!propDrag || !activeMap?.ttrpgEnabled || !canManageTtrpg) return;
   1143           const pt = screenToWorldNormalized(e.clientX, e.clientY, canvas, lastTransform);
   1144           if (!pt) return;
   1145           const x = Math.max(0, Math.min(1, pt.x + propDrag.offsetX));
   1146           const y = Math.max(0, Math.min(1, pt.y + propDrag.offsetY));
   1147           const props = Array.isArray(activeMap.props) ? activeMap.props : [];
   1148           const idx = props.findIndex((p) => String(p?.id || "") === propDrag.propId);
   1149           if (idx < 0) return;
   1150           const prev = props[idx];
   1151           if (!prev) return;
   1152           const moved = Math.hypot((prev.x || 0) - x, (prev.y || 0) - y) > 0.0006;
   1153           if (moved) propDragMoved = true;
   1154           props[idx] = { ...prev, x, y };
   1155           activeMap.props = props;
   1156           const now = Date.now();
   1157           if (now - lastPropMoveAt > 70) {
   1158             lastPropMoveAt = now;
   1159             ctx.send("ttrpgPropMove", { mapId: activeMap.id, propId: propDrag.propId, x, y, z: prev.z || 0, rot: prev.rot || 0, scale: prev.scale || 1 });
   1160           }
   1161         };
   1162         canvas.onpointerup = (e) => {
   1163           if (!lastTransform) return;
   1164           if (editMode) {
   1165             panning = false;
   1166             panStart = null;
   1167             polyDrag = null;
   1168             vertexDrag = null;
   1169             try {
   1170               canvas.releasePointerCapture(e.pointerId);
   1171             } catch {
   1172               // ignore
   1173             }
   1174             return;
   1175           }
   1176           if (panning) {
   1177             panning = false;
   1178             panStart = null;
   1179             if (activeMap?.ttrpgEnabled && canManageTtrpg && ttrpgTool === "pan") canvas.style.cursor = "grab";
   1180             return;
   1181           }
   1182 
   1183           if (propDrag) {
   1184             // finalize drag
   1185             const props = Array.isArray(activeMap.props) ? activeMap.props : [];
   1186             const idx = props.findIndex((p) => String(p?.id || "") === propDrag.propId);
   1187             if (idx >= 0) {
   1188               const p = props[idx];
   1189               if (p) ctx.send("ttrpgPropMove", { mapId: activeMap.id, propId: propDrag.propId, x: p.x, y: p.y, z: p.z || 0, rot: p.rot || 0, scale: p.scale || 1 });
   1190             }
   1191             propDrag = null;
   1192             renderTtrpgDock();
   1193             return;
   1194           }
   1195 
   1196           // Place prop (GM only) by clicking canvas with a selected sprite.
   1197           if (!activeMap?.ttrpgEnabled || !canManageTtrpg) return;
   1198           if (ttrpgTool !== "place") return;
   1199           if (!selectedSpriteId) return;
   1200           if (e.button !== 0) return;
   1201           const pt = screenToWorldNormalized(e.clientX, e.clientY, canvas, lastTransform);
   1202           if (!pt) return;
   1203           const propId = `prop_${Date.now()}_${Math.random().toString(16).slice(2)}`;
   1204           const sprite = (Array.isArray(activeMap.sprites) ? activeMap.sprites : []).find((s) => String(s?.id || "") === String(selectedSpriteId || ""));
   1205           const isToken = sprite?.kind === "token";
   1206           const optimistic = {
   1207             id: propId,
   1208             spriteId: selectedSpriteId,
   1209             x: pt.x,
   1210             y: pt.y,
   1211             z: 0,
   1212             rot: placeRot,
   1213             scale: placeScale,
   1214             nickname: "",
   1215             hpCurrent: isToken ? 10 : 0,
   1216             hpMax: isToken ? 10 : 0,
   1217             controlledBy: ""
   1218           };
   1219           if (!Array.isArray(activeMap.props)) activeMap.props = [];
   1220           activeMap.props = [...activeMap.props.filter((p) => String(p?.id || "") !== propId), optimistic];
   1221           selectedPropId = propId;
   1222           renderTtrpgDock();
   1223           ctx.send("ttrpgPropAdd", { mapId: activeMap.id, id: propId, spriteId: selectedSpriteId, x: pt.x, y: pt.y, z: 0, rot: placeRot, scale: placeScale });
   1224         };
   1225       }
   1226 
   1227       const walkieBtn = document.getElementById("mapsWalkieBtn");
   1228       if (walkieBtn) {
   1229         const down = async (e) => {
   1230           if (e) e.preventDefault();
   1231           if (editMode) return;
   1232           if (!activeMap?.walkiesEnabled) return;
   1233           try {
   1234             await startWalkie();
   1235             walkieBtn.textContent = "Recording…";
   1236           } catch (err) {
   1237             ctx.toast("Walkie", String(err?.message || err));
   1238           }
   1239         };
   1240         const up = (e) => {
   1241           if (e) e.preventDefault();
   1242           stopWalkie();
   1243           walkieBtn.textContent = "Hold to talk";
   1244         };
   1245         walkieBtn.onpointerdown = down;
   1246         walkieBtn.onpointerup = up;
   1247         walkieBtn.onpointercancel = up;
   1248         walkieBtn.onpointerleave = (e) => {
   1249           // If the pointer is captured during recording, we'll still stop on up; otherwise stop on leave.
   1250           if (walkieRecording) up(e);
   1251         };
   1252       }
   1253 
   1254       renderTtrpgDock();
   1255     }
   1256 
   1257     function renderTtrpgDock() {
   1258       const dock = document.getElementById("mapsTtrpgDock");
   1259       if (!dock) return;
   1260       if (!activeMap?.ttrpgEnabled) {
   1261         dock.innerHTML = "";
   1262         dock.classList.remove("collapsed");
   1263         return;
   1264       }
   1265       const collapsed = Boolean(ttrpgDockCollapsed);
   1266       dock.classList.toggle("collapsed", collapsed);
   1267       const sprites = Array.isArray(activeMap.sprites) ? activeMap.sprites : [];
   1268       const props = Array.isArray(activeMap.props) ? activeMap.props : [];
   1269       const kind = spriteKind === "token" ? "token" : "prop";
   1270       const spritesOfKind = sprites.filter((s) => (s?.kind || "prop") === kind);
   1271       if (canManageTtrpg) {
   1272         const hasSelected = spritesOfKind.some((s) => String(s?.id || "") === String(selectedSpriteId || ""));
   1273         if ((!selectedSpriteId || !hasSelected) && spritesOfKind.length) {
   1274           selectedSpriteId = String(spritesOfKind[0]?.id || "");
   1275         }
   1276       }
   1277       const selectedSprite = sprites.find((s) => String(s?.id || "") === selectedSpriteId) || null;
   1278       const placingLabel = selectedSprite ? (selectedSprite.name ? selectedSprite.name : selectedSprite.id) : "";
   1279       const thumbs = spritesOfKind
   1280         .map((s) => {
   1281           const sel = s.id === selectedSpriteId ? " selected" : "";
   1282           const label = s.name ? escapeHtml(s.name) : escapeHtml(s.id);
   1283           return `<button type="button" class="spriteThumb${sel}" data-spriteid="${escapeHtml(s.id)}" title="${label}">
   1284             <img src="${escapeHtml(s.url)}" alt="" />
   1285           </button>`;
   1286         })
   1287         .join("");
   1288       const spriteById = new Map(sprites.map((s) => [String(s?.id || ""), s]));
   1289       const placedOfKind = props.filter((p) => (spriteById.get(String(p?.spriteId || ""))?.kind || "prop") === kind);
   1290       const placedThumbs = placedOfKind
   1291         .slice()
   1292         .sort((a, b) => Number(a?.y || 0) - Number(b?.y || 0))
   1293         .slice(0, 160)
   1294         .map((p) => {
   1295           const spr = spriteById.get(String(p?.spriteId || ""));
   1296           if (!spr) return "";
   1297           const sel = String(p?.id || "") === String(selectedPropId || "") ? " selected" : "";
   1298           const label = spr.name ? spr.name : spr.id;
   1299           return `<button type="button" class="spriteThumb${sel}" data-propid="${escapeHtml(String(p.id || ""))}" title="${escapeHtml(label)}">
   1300             <img src="${escapeHtml(String(spr.url || ""))}" alt="" />
   1301           </button>`;
   1302         })
   1303         .join("");
   1304       const me = String(ctx.getUser() || "").trim().toLowerCase();
   1305       const selectedProp = props.find((p) => String(p?.id || "") === String(selectedPropId || "")) || null;
   1306       const selectedPropSprite = selectedProp ? spriteById.get(String(selectedProp.spriteId || "")) || null : null;
   1307       const selectedIsToken = Boolean(selectedProp && selectedPropSprite?.kind === "token");
   1308       const selectedScale = selectedProp ? Math.max(0.1, Math.min(4.0, Number(selectedProp.scale || 1))) : 1;
   1309       if (speakingAsPropId && !props.some((p) => String(p?.id || "") === String(speakingAsPropId))) {
   1310         speakingAsPropId = "";
   1311       }
   1312       const speakingProp = speakingAsPropId ? props.find((p) => String(p?.id || "") === String(speakingAsPropId)) : null;
   1313       const speakingSprite = speakingProp ? spriteById.get(String(speakingProp.spriteId || "")) : null;
   1314       const speakingName = speakingProp ? String(speakingProp.nickname || speakingSprite?.name || speakingSprite?.id || "token") : "";
   1315 
   1316       dock.innerHTML = `
   1317         <div class="dockRow">
   1318           <div class="dockTitle">TTRPG mode</div>
   1319           <div class="small muted grow">${canManageTtrpg ? "GM tools enabled" : "Waiting for GM…"}</div>
   1320         </div>
   1321         <div class="dockRow">
   1322           <button type="button" class="${ttrpgTool === "select" ? "primary" : "ghost"} smallBtn" id="mapsToolSelect">Select</button>
   1323           <button type="button" class="${ttrpgTool === "place" ? "primary" : "ghost"} smallBtn" id="mapsToolPlace">Place</button>
   1324           <button type="button" class="${ttrpgTool === "pan" ? "primary" : "ghost"} smallBtn" id="mapsToolPan">Pan</button>
   1325           <div class="small muted grow">V select · P place · Space pan</div>
   1326         </div>
   1327         <div class="dockRow">
   1328           <button type="button" class="${kind === "prop" ? "primary" : "ghost"} smallBtn" id="mapsKindProp">Props</button>
   1329           <button type="button" class="${kind === "token" ? "primary" : "ghost"} smallBtn" id="mapsKindToken">Tokens</button>
   1330           <div class="small muted grow">${spritesOfKind.length} sprites • ${placedOfKind.length} placed</div>
   1331         </div>
   1332         <div class="dockRow">
   1333           <div class="small muted grow">${
   1334             canManageTtrpg
   1335               ? placingLabel
   1336                 ? `Placing: <b>${escapeHtml(placingLabel)}</b> · Rot <b>Q/E</b> ${escapeHtml(placeRot.toFixed(0))}° · Scale <b>Z/X</b> ${escapeHtml(placeScale.toFixed(2))}x`
   1337                 : `Select a sprite then place it on the map.`
   1338               : `${kind === "token" ? "Tokens" : "Props"} are controlled by the GM.`
   1339           }</div>
   1340         </div>
   1341         <div class="dockRow">
   1342           ${canManageTtrpg ? `
   1343           <input id="mapsSpriteFile" type="file" accept="image/png,image/webp" />
   1344           <input id="mapsSpriteName" type="text" maxlength="40" placeholder="Sprite name" />
   1345           <div class="dockScale">
   1346             <input id="mapsSpriteScale" type="range" min="0.25" max="4" step="0.05" value="${escapeHtml(String(spriteScale))}" />
   1347             <div class="dockScaleVal" id="mapsSpriteScaleVal">${escapeHtml(spriteScale.toFixed(2))}</div>
   1348           </div>
   1349           <div class="dockScale">
   1350             <input id="mapsPlaceScale" type="range" min="0.10" max="4" step="0.05" value="${escapeHtml(String(placeScale))}" />
   1351             <div class="dockScaleVal" id="mapsPlaceScaleVal">${escapeHtml(placeScale.toFixed(2))}</div>
   1352           </div>
   1353           <button type="button" class="ghost smallBtn" id="mapsSpriteAddBtn">Add</button>
   1354           <button type="button" class="ghost smallBtn" id="mapsSpriteRemoveBtn" ${selectedSpriteId ? "" : "disabled"}>Remove</button>
   1355           ` : `<div class="muted small">Props/tokens are controlled by the GM.</div>`}
   1356         </div>
   1357         <div class="spriteTray" id="mapsSpriteTray">
   1358           ${thumbs || `<div class="muted small">No sprites yet.</div>`}
   1359         </div>
   1360         <div class="dockRow" style="margin-top:6px;">
   1361           <div class="small muted grow">Placed ${kind === "token" ? "tokens" : "props"}</div>
   1362           <button type="button" class="ghost smallBtn" id="mapsPropDeleteBtn" ${selectedPropId ? "" : "disabled"}>Delete</button>
   1363         </div>
   1364         <div class="spriteTray" id="mapsPropTray">
   1365           ${placedThumbs || `<div class="muted small">None placed yet.</div>`}
   1366         </div>
   1367         <div class="dockRow" style="margin-top:8px;">
   1368           ${selectedProp ? `<div class="small muted grow">Selected: <b>${escapeHtml(String(selectedProp.nickname || selectedPropSprite?.name || selectedPropSprite?.id || selectedProp.id || "item"))}</b> · ${escapeHtml(selectedScale.toFixed(2))}x</div>` : `<div class="small muted grow">Select an item to edit it.</div>`}
   1369           <button type="button" class="ghost smallBtn" id="mapsScaleDownBtn" ${selectedProp ? "" : "disabled"}>-</button>
   1370           <button type="button" class="ghost smallBtn" id="mapsScaleUpBtn" ${selectedProp ? "" : "disabled"}>+</button>
   1371         </div>
   1372         ${
   1373           selectedIsToken
   1374             ? `<div class="dockRow" style="gap:8px;">
   1375                  <input id="mapsPropNick" type="text" maxlength="40" placeholder="Token nickname" value="${escapeHtml(String(selectedProp.nickname || ""))}" />
   1376                  <input id="mapsPropHpCur" type="number" min="0" max="9999" value="${escapeHtml(String(selectedProp.hpCurrent || 0))}" />
   1377                  <input id="mapsPropHpMax" type="number" min="0" max="9999" value="${escapeHtml(String(selectedProp.hpMax || 0))}" />
   1378                  <button type="button" class="ghost smallBtn" id="mapsPropSaveMeta">Save</button>
   1379                </div>
   1380                <div class="dockRow" style="gap:8px;">
   1381                  <div class="small muted grow">Controller: <b>${escapeHtml(String(selectedProp.controlledBy || "none"))}</b></div>
   1382                   <button type="button" class="ghost smallBtn" id="mapsTokenPossessBtn" ${selectedProp.controlledBy && selectedProp.controlledBy !== me ? "disabled" : ""}>${selectedProp.controlledBy === me ? "Release" : "Possess"}</button>
   1383                   <button type="button" class="${speakingAsPropId === selectedPropId ? "primary" : "ghost"} smallBtn" id="mapsTokenSpeakBtn">${speakingAsPropId === selectedPropId ? "Speaking" : "Speak as"}</button>
   1384                 </div>`
   1385              : ""
   1386         }
   1387         ${speakingProp ? `<div class="dockRow"><div class="small muted">Chat voice: <b>${escapeHtml(speakingName)}</b></div></div>` : ""}
   1388       `;
   1389 
   1390       const dockChildren = Array.from(dock.children);
   1391       const headerRow = dockChildren[0];
   1392       if (headerRow) {
   1393         headerRow.classList.add("dockHeaderRow");
   1394         const grow = headerRow.querySelector(".grow");
   1395         if (grow) {
   1396           const base = grow.getAttribute("data-base") || grow.textContent || "";
   1397           if (!grow.hasAttribute("data-base")) grow.setAttribute("data-base", base);
   1398           grow.textContent = base + (collapsed ? " (hidden)" : "");
   1399         }
   1400         let toggleBtn = headerRow.querySelector("#mapsDockToggle");
   1401         if (!toggleBtn) {
   1402           toggleBtn = document.createElement("button");
   1403           toggleBtn.type = "button";
   1404           toggleBtn.className = "ghost smallBtn";
   1405           toggleBtn.id = "mapsDockToggle";
   1406           headerRow.appendChild(toggleBtn);
   1407         }
   1408 
   1409         let releaseBtn = headerRow.querySelector("#mapsReleasePossession");
   1410         if (canManageTtrpg) {
   1411           const possessed = getPossessedTokenForMe();
   1412           if (!releaseBtn) {
   1413             releaseBtn = document.createElement("button");
   1414             releaseBtn.type = "button";
   1415             releaseBtn.className = "ghost smallBtn";
   1416             releaseBtn.id = "mapsReleasePossession";
   1417           }
   1418           if (toggleBtn) headerRow.insertBefore(releaseBtn, toggleBtn);
   1419           else headerRow.appendChild(releaseBtn);
   1420           releaseBtn.textContent = "Release";
   1421           releaseBtn.disabled = !possessed;
   1422           releaseBtn.title = possessed ? "Release token control" : "Not controlling a token";
   1423           releaseBtn.onclick = () => {
   1424             if (!activeMap?.id) return;
   1425             const pid = possessed ? String(possessed.id || "") : String(selectedPropId || "");
   1426             const meU = String(ctx.getUser() || "").trim().toLowerCase();
   1427             speakingAsPropId = "";
   1428             if (meU) {
   1429               const list = Array.isArray(activeMap.props) ? activeMap.props : [];
   1430               let changed = false;
   1431               const nextList = list.map((p) => {
   1432                 if (!p) return p;
   1433                 if (String(p.controlledBy || "") !== meU) return p;
   1434                 const spr = spriteById.get(String(p?.spriteId || ""));
   1435                 if (spr?.kind !== "token") return p;
   1436                 changed = true;
   1437                 return { ...p, controlledBy: "" };
   1438               });
   1439               if (changed) activeMap.props = nextList;
   1440             }
   1441             ctx.send("ttrpgTokenPossess", { mapId: activeMap.id, propId: pid, action: "release" });
   1442             renderTtrpgDock();
   1443           };
   1444         } else if (releaseBtn) {
   1445           releaseBtn.remove();
   1446         }
   1447 
   1448         toggleBtn.textContent = collapsed ? "Show" : "Hide";
   1449         toggleBtn.onclick = () => {
   1450           if (!activeMap?.id) return;
   1451           writeDockCollapsed(activeMap.id, !ttrpgDockCollapsed);
   1452           renderTtrpgDock();
   1453         };
   1454 
   1455         const body = document.createElement("div");
   1456         body.className = "dockBody";
   1457         for (let i = 1; i < dockChildren.length; i++) {
   1458           body.appendChild(dockChildren[i]);
   1459         }
   1460         dock.appendChild(body);
   1461       }
   1462 
   1463       const tray = document.getElementById("mapsSpriteTray");
   1464       if (tray) {
   1465         tray.onclick = (e) => {
   1466           const btn = e.target.closest("[data-spriteid]");
   1467           if (!btn) return;
   1468           selectedSpriteId = String(btn.getAttribute("data-spriteid") || "");
   1469           selectedPropId = "";
   1470           ttrpgTool = "place";
   1471           renderTtrpgDock();
   1472         };
   1473       }
   1474 
   1475       const toolSelect = document.getElementById("mapsToolSelect");
   1476       const toolPlace = document.getElementById("mapsToolPlace");
   1477       const toolPan = document.getElementById("mapsToolPan");
   1478       if (toolSelect) toolSelect.onclick = () => { ttrpgTool = "select"; renderMapView(); };
   1479       if (toolPlace) toolPlace.onclick = () => { ttrpgTool = "place"; renderMapView(); };
   1480       if (toolPan) toolPan.onclick = () => { ttrpgTool = "pan"; renderMapView(); };
   1481 
   1482       const kindPropBtn = document.getElementById("mapsKindProp");
   1483       const kindTokenBtn = document.getElementById("mapsKindToken");
   1484       if (kindPropBtn) kindPropBtn.onclick = () => { spriteKind = "prop"; selectedSpriteId = ""; selectedPropId = ""; renderTtrpgDock(); };
   1485       if (kindTokenBtn) kindTokenBtn.onclick = () => { spriteKind = "token"; selectedSpriteId = ""; selectedPropId = ""; renderTtrpgDock(); };
   1486 
   1487       const propTray = document.getElementById("mapsPropTray");
   1488       if (propTray) {
   1489         propTray.onclick = (e) => {
   1490           const btn = e.target.closest("[data-propid]");
   1491           if (!btn) return;
   1492           selectedPropId = String(btn.getAttribute("data-propid") || "");
   1493           ttrpgTool = "select";
   1494           renderTtrpgDock();
   1495         };
   1496       }
   1497 
   1498       if (!canManageTtrpg) return;
   1499       const scaleEl = document.getElementById("mapsSpriteScale");
   1500       const scaleVal = document.getElementById("mapsSpriteScaleVal");
   1501       if (scaleEl && scaleVal) {
   1502         const update = () => {
   1503           const v = Math.max(0.1, Math.min(4.0, Number(scaleEl.value || 1)));
   1504           spriteScale = v;
   1505           scaleVal.textContent = v.toFixed(2);
   1506         };
   1507         scaleEl.oninput = update;
   1508         update();
   1509       }
   1510       const placeScaleEl = document.getElementById("mapsPlaceScale");
   1511       const placeScaleVal = document.getElementById("mapsPlaceScaleVal");
   1512       if (placeScaleEl && placeScaleVal) {
   1513         const update = () => {
   1514           const v = Math.max(0.1, Math.min(4.0, Number(placeScaleEl.value || 1)));
   1515           placeScale = v;
   1516           placeScaleVal.textContent = v.toFixed(2);
   1517         };
   1518         placeScaleEl.oninput = update;
   1519         update();
   1520       }
   1521       const addBtn = document.getElementById("mapsSpriteAddBtn");
   1522       const fileEl = document.getElementById("mapsSpriteFile");
   1523       const nameEl = document.getElementById("mapsSpriteName");
   1524       if (addBtn && fileEl) {
   1525         addBtn.onclick = async () => {
   1526           const file = fileEl.files && fileEl.files[0] ? fileEl.files[0] : null;
   1527           if (!file) return;
   1528           addBtn.disabled = true;
   1529           try {
   1530             const url = await uploadSpriteImageFile(file);
   1531             const name = nameEl ? String(nameEl.value || "").trim() : "";
   1532             const k = spriteKind === "token" ? "token" : "prop";
   1533             const id = `spr_${Date.now()}_${Math.random().toString(16).slice(2)}`;
   1534             const sprite = { id, kind: k, name, url, scale: spriteScale };
   1535             if (!Array.isArray(activeMap.sprites)) activeMap.sprites = [];
   1536             activeMap.sprites = [...activeMap.sprites.filter((s) => String(s?.id || "") !== id), sprite];
   1537             selectedSpriteId = id;
   1538             selectedPropId = "";
   1539             ttrpgTool = "place";
   1540             renderTtrpgDock();
   1541             ctx.send("ttrpgSpriteAdd", { mapId: activeMap.id, id, kind: k, name, url, scale: spriteScale });
   1542             fileEl.value = "";
   1543             if (nameEl) nameEl.value = "";
   1544           } catch (e) {
   1545             ctx.toast("Sprites", String(e?.message || e));
   1546           } finally {
   1547             addBtn.disabled = false;
   1548           }
   1549         };
   1550       }
   1551       const removeBtn = document.getElementById("mapsSpriteRemoveBtn");
   1552       if (removeBtn) {
   1553         removeBtn.onclick = () => {
   1554           if (!selectedSpriteId) return;
   1555           const ok = window.confirm("Remove this sprite? Props using it will also be removed.");
   1556           if (!ok) return;
   1557           const spriteId = String(selectedSpriteId || "");
   1558           activeMap.sprites = (Array.isArray(activeMap.sprites) ? activeMap.sprites : []).filter((s) => String(s?.id || "") !== spriteId);
   1559           activeMap.props = (Array.isArray(activeMap.props) ? activeMap.props : []).filter((p) => String(p?.spriteId || "") !== spriteId);
   1560           if (speakingAsPropId && !activeMap.props.some((p) => String(p?.id || "") === String(speakingAsPropId))) speakingAsPropId = "";
   1561           selectedSpriteId = "";
   1562           selectedPropId = "";
   1563           renderTtrpgDock();
   1564           ctx.send("ttrpgSpriteRemove", { mapId: activeMap.id, spriteId });
   1565         };
   1566       }
   1567 
   1568       const delPropBtn = document.getElementById("mapsPropDeleteBtn");
   1569       if (delPropBtn) {
   1570         delPropBtn.onclick = () => {
   1571           if (!selectedPropId) return;
   1572           const ok = window.confirm("Delete this placed item?");
   1573           if (!ok) return;
   1574           const propId = String(selectedPropId || "");
   1575           activeMap.props = (Array.isArray(activeMap.props) ? activeMap.props : []).filter((p) => String(p?.id || "") !== propId);
   1576           if (speakingAsPropId === propId) speakingAsPropId = "";
   1577           selectedPropId = "";
   1578           renderTtrpgDock();
   1579           ctx.send("ttrpgPropRemove", { mapId: activeMap.id, propId });
   1580         };
   1581       }
   1582 
   1583       const scaleDownBtn = document.getElementById("mapsScaleDownBtn");
   1584       const scaleUpBtn = document.getElementById("mapsScaleUpBtn");
   1585       if (scaleDownBtn && selectedProp) {
   1586         scaleDownBtn.onclick = () => {
   1587           const next = Math.max(0.1, Math.min(4.0, Number(selectedProp.scale || 1) - 0.1));
   1588           const list = Array.isArray(activeMap.props) ? activeMap.props : [];
   1589           const idx = list.findIndex((p) => String(p?.id || "") === String(selectedProp.id || ""));
   1590           if (idx < 0) return;
   1591           list[idx] = { ...list[idx], scale: next };
   1592           activeMap.props = list;
   1593           renderTtrpgDock();
   1594           ctx.send("ttrpgPropMove", { mapId: activeMap.id, propId: selectedProp.id, x: list[idx].x, y: list[idx].y, z: list[idx].z || 0, rot: list[idx].rot || 0, scale: next });
   1595         };
   1596       }
   1597       if (scaleUpBtn && selectedProp) {
   1598         scaleUpBtn.onclick = () => {
   1599           const next = Math.max(0.1, Math.min(4.0, Number(selectedProp.scale || 1) + 0.1));
   1600           const list = Array.isArray(activeMap.props) ? activeMap.props : [];
   1601           const idx = list.findIndex((p) => String(p?.id || "") === String(selectedProp.id || ""));
   1602           if (idx < 0) return;
   1603           list[idx] = { ...list[idx], scale: next };
   1604           activeMap.props = list;
   1605           renderTtrpgDock();
   1606           ctx.send("ttrpgPropMove", { mapId: activeMap.id, propId: selectedProp.id, x: list[idx].x, y: list[idx].y, z: list[idx].z || 0, rot: list[idx].rot || 0, scale: next });
   1607         };
   1608       }
   1609 
   1610       const saveMetaBtn = document.getElementById("mapsPropSaveMeta");
   1611       if (saveMetaBtn && selectedProp && selectedIsToken) {
   1612         saveMetaBtn.onclick = () => {
   1613           const nickEl = document.getElementById("mapsPropNick");
   1614           const hpCurEl = document.getElementById("mapsPropHpCur");
   1615           const hpMaxEl = document.getElementById("mapsPropHpMax");
   1616           const nickname = String(nickEl?.value || "").trim().slice(0, 40);
   1617           const hpMax = Math.max(0, Math.min(9999, Number(hpMaxEl?.value || 0) || 0));
   1618           let hpCurrent = Math.max(0, Math.min(hpMax > 0 ? hpMax : 9999, Number(hpCurEl?.value || 0) || 0));
   1619           if (hpCurrent > hpMax && hpMax > 0) hpCurrent = hpMax;
   1620           const list = Array.isArray(activeMap.props) ? activeMap.props : [];
   1621           const idx = list.findIndex((p) => String(p?.id || "") === String(selectedProp.id || ""));
   1622           if (idx < 0) return;
   1623           list[idx] = { ...list[idx], nickname, hpCurrent, hpMax };
   1624           activeMap.props = list;
   1625           renderTtrpgDock();
   1626           ctx.send("ttrpgPropPatch", { mapId: activeMap.id, propId: selectedProp.id, nickname, hpCurrent, hpMax });
   1627         };
   1628       }
   1629 
   1630       const possessBtn = document.getElementById("mapsTokenPossessBtn");
   1631       if (possessBtn && selectedProp && selectedIsToken) {
   1632         possessBtn.onclick = () => {
   1633           if (!activeMap?.id) return;
   1634           const isMine = String(selectedProp.controlledBy || "") === me;
   1635           const action = isMine ? "release" : "possess";
   1636 
   1637           // Optimistic UI: keep possession exclusive and make release always "release all my tokens".
   1638           const list = Array.isArray(activeMap.props) ? activeMap.props : [];
   1639           const targetId = String(selectedProp.id || "");
   1640           let changed = false;
   1641           const nextList = list.map((p) => {
   1642             if (!p) return p;
   1643             const pid = String(p?.id || "");
   1644             const spr = spriteById.get(String(p?.spriteId || ""));
   1645             const isToken = spr?.kind === "token";
   1646             if (!isToken) return p;
   1647             if (action === "release") {
   1648               if (String(p.controlledBy || "") !== me) return p;
   1649               changed = true;
   1650               return { ...p, controlledBy: "" };
   1651             }
   1652             // possess: release other tokens I control, and claim selected
   1653             if (pid === targetId) {
   1654               if (String(p.controlledBy || "") !== me) changed = true;
   1655               return { ...p, controlledBy: me };
   1656             }
   1657             if (String(p.controlledBy || "") === me) {
   1658               changed = true;
   1659               return { ...p, controlledBy: "" };
   1660             }
   1661             return p;
   1662           });
   1663           if (action === "release") speakingAsPropId = "";
   1664           if (action === "possess") speakingAsPropId = targetId;
   1665           if (changed) activeMap.props = nextList;
   1666 
   1667           ctx.send("ttrpgTokenPossess", { mapId: activeMap.id, propId: selectedProp.id, action });
   1668           renderTtrpgDock();
   1669         };
   1670       }
   1671 
   1672       const speakBtn = document.getElementById("mapsTokenSpeakBtn");
   1673       if (speakBtn && selectedProp && selectedIsToken) {
   1674         speakBtn.onclick = () => {
   1675           speakingAsPropId = speakingAsPropId === selectedProp.id ? "" : selectedProp.id;
   1676           renderTtrpgDock();
   1677         };
   1678       }
   1679     }
   1680 
   1681     function screenToWorldNormalized(clientX, clientY, canvas, tr) {
   1682       const rect = canvas.getBoundingClientRect();
   1683       const sx = clientX - rect.left;
   1684       const sy = clientY - rect.top;
   1685       if (sx < 0 || sy < 0 || sx > rect.width || sy > rect.height) return null;
   1686       const worldX = tr.srcX + (sx / tr.zoom);
   1687       const worldY = tr.srcY + (sy / tr.zoom);
   1688       return { x: Math.max(0, Math.min(1, worldX / tr.worldW)), y: Math.max(0, Math.min(1, worldY / tr.worldH)) };
   1689     }
   1690 
   1691     function getSpriteImage(url) {
   1692       const src = String(url || "").trim();
   1693       if (!src) return null;
   1694       const now = Date.now();
   1695       const cached = spriteImageCache.get(src);
   1696       if (cached) {
   1697         if (cached.status === "ok" && cached.img) return cached.img;
   1698         if (cached.status === "loading") return null;
   1699         if (cached.status === "error" && now - Number(cached.failedAt || 0) < 5000) return null;
   1700       }
   1701       const img = new Image();
   1702       if (!src.startsWith("data:")) img.crossOrigin = "anonymous";
   1703       spriteImageCache.set(src, { img: null, status: "loading", failedAt: 0 });
   1704       img.onload = () => spriteImageCache.set(src, { img, status: "ok", failedAt: 0 });
   1705       img.onerror = () => spriteImageCache.set(src, { img: null, status: "error", failedAt: Date.now() });
   1706       img.src = src;
   1707       return null;
   1708     }
   1709 
   1710     function commitDraftPoly() {
   1711       if (!draftPoly || draftPoly.length < 3) return false;
   1712       const poly = { points: draftPoly.map((p) => ({ x: p.x, y: p.y })) };
   1713       const list = polysForKind(activeMap, editKind, true);
   1714       if (editKind === "exit") {
   1715         const name = String(exitDraftName || "").trim().slice(0, 40);
   1716         if (!name) return false;
   1717         const action = exitAction === "toMap" ? "toMap" : "toMaps";
   1718         const toMapId = action === "toMap" ? String(exitTargetMapId || "").trim().toLowerCase() : "";
   1719         if (action === "toMap" && !toMapId) return false;
   1720         const targetExit = action === "toMap" ? String(exitTargetExitName || "").trim().slice(0, 40) : "";
   1721         list.push({ ...poly, name, action, toMapId, targetExit });
   1722       } else {
   1723         list.push(poly);
   1724       }
   1725       draftPoly = [];
   1726       selectedPolyKind = editKind;
   1727       selectedPolyIndex = Math.max(0, list.length - 1);
   1728       selectedVertexIndex = -1;
   1729       return true;
   1730     }
   1731 
   1732     function polysForKind(map, kind, ensure = false) {
   1733       if (!map) return [];
   1734       const k = String(kind || "");
   1735       if (k === "collision") {
   1736         if (ensure && !Array.isArray(map.collisions)) map.collisions = [];
   1737         return Array.isArray(map.collisions) ? map.collisions : [];
   1738       }
   1739       if (k === "mask") {
   1740         if (ensure && !Array.isArray(map.masks)) map.masks = [];
   1741         return Array.isArray(map.masks) ? map.masks : [];
   1742       }
   1743       if (k === "exit") {
   1744         if (ensure && !Array.isArray(map.exits)) map.exits = [];
   1745         return Array.isArray(map.exits) ? map.exits : [];
   1746       }
   1747       if (k === "hidden") {
   1748         if (ensure && !Array.isArray(map.hiddenMasks)) map.hiddenMasks = [];
   1749         return Array.isArray(map.hiddenMasks) ? map.hiddenMasks : [];
   1750       }
   1751       if (k === "occluder") {
   1752         if (ensure && !Array.isArray(map.occluders)) map.occluders = [];
   1753         return Array.isArray(map.occluders) ? map.occluders : [];
   1754       }
   1755       return [];
   1756     }
   1757 
   1758     function kindLabel(kind) {
   1759       if (kind === "collision") return "Collisions";
   1760       if (kind === "mask") return "Y-sort masks";
   1761       if (kind === "exit") return "Exits";
   1762       if (kind === "hidden") return "Hidden masks";
   1763       if (kind === "occluder") return "Occluders";
   1764       return String(kind || "");
   1765     }
   1766 
   1767     function hitTestPoly(pt, map, kind) {
   1768       const list = polysForKind(map, kind);
   1769       for (let i = list.length - 1; i >= 0; i--) {
   1770         const p = list[i];
   1771         if (p && pointInPoly(pt, p)) return { index: i };
   1772       }
   1773       return null;
   1774     }
   1775 
   1776     function hitTestVertex(clientX, clientY, canvas, tr, poly) {
   1777       const pts = Array.isArray(poly?.points) ? poly.points : [];
   1778       if (!pts.length) return -1;
   1779       const rect = canvas.getBoundingClientRect();
   1780       const sx = clientX - rect.left;
   1781       const sy = clientY - rect.top;
   1782       const threshold = 12;
   1783       let best = { idx: -1, d2: Infinity };
   1784       for (let i = 0; i < pts.length; i++) {
   1785         const p = pts[i];
   1786         const x = (Number(p.x) * tr.worldW - tr.srcX) * tr.zoom;
   1787         const y = (Number(p.y) * tr.worldH - tr.srcY) * tr.zoom;
   1788         const dx = x - sx;
   1789         const dy = y - sy;
   1790         const d2 = dx * dx + dy * dy;
   1791         if (d2 < best.d2) best = { idx: i, d2 };
   1792       }
   1793       if (best.idx < 0) return -1;
   1794       return best.d2 <= threshold * threshold ? best.idx : -1;
   1795     }
   1796 
   1797     function polyCentroid(points) {
   1798       const pts = Array.isArray(points) ? points : [];
   1799       if (!pts.length) return { x: 0.5, y: 0.5 };
   1800       let sx = 0;
   1801       let sy = 0;
   1802       for (const p of pts) {
   1803         sx += Number(p?.x || 0);
   1804         sy += Number(p?.y || 0);
   1805       }
   1806       return { x: Math.max(0, Math.min(1, sx / pts.length)), y: Math.max(0, Math.min(1, sy / pts.length)) };
   1807     }
   1808 
   1809     function renderPolyModal() {
   1810       const list = polysForKind(activeMap, editKind);
   1811       const selOk = selectedPolyKind === editKind && selectedPolyIndex >= 0 && selectedPolyIndex < list.length;
   1812       const selected = selOk ? list[selectedPolyIndex] : null;
   1813 
   1814       const otherMaps = (Array.isArray(maps) ? maps : [])
   1815         .map((m) => String(m?.id || "").trim().toLowerCase())
   1816         .filter(Boolean)
   1817         .filter((id) => id !== String(activeMap?.id || "").trim().toLowerCase())
   1818         .sort((a, b) => a.localeCompare(b));
   1819 
   1820       const mapOptions = otherMaps
   1821         .map((id) => `<option value="${escapeHtml(id)}" ${id === exitModel.toMapId ? "selected" : ""}>${escapeHtml(id)}</option>`)
   1822         .join("");
   1823 
   1824       const exitModel =
   1825         editKind === "exit" && selected
   1826           ? {
   1827               name: String(selected.name || "").trim(),
   1828               action: String(selected.action || "toMaps") === "toMap" ? "toMap" : "toMaps",
   1829               toMapId: String(selected.toMapId || "").trim().toLowerCase(),
   1830               targetExit: String(selected.targetExit || "").trim(),
   1831             }
   1832           : {
   1833               name: String(exitDraftName || "").trim(),
   1834               action: exitAction === "toMap" ? "toMap" : "toMaps",
   1835               toMapId: String(exitTargetMapId || "").trim().toLowerCase(),
   1836               targetExit: String(exitTargetExitName || "").trim(),
   1837             };
   1838 
   1839       const polyRows = list
   1840         .map((p, idx) => {
   1841           const on = selOk && idx === selectedPolyIndex ? " selected" : "";
   1842           let label = `${kindLabel(editKind).replace(/s$/, "")} ${idx + 1}`;
   1843           if (editKind === "exit") {
   1844             const name = String(p?.name || "").trim();
   1845             label = name ? name : `Exit ${idx + 1}`;
   1846           }
   1847           const pts = Array.isArray(p?.points) ? p.points.length : 0;
   1848           const meta =
   1849             editKind === "exit"
   1850               ? `${String(p?.action || "toMaps") === "toMap" ? `to ${escapeHtml(String(p?.toMapId || ""))}` : "to maps"}`
   1851               : `${pts} pts`;
   1852           return `<button type="button" class="polyRowBtn${on}" data-polysel="${idx}">
   1853             <div class="polyRowMain">${escapeHtml(label)}</div>
   1854             <div class="polyRowMeta">${meta}</div>
   1855           </button>`;
   1856         })
   1857         .join("");
   1858 
   1859       const kindBtn = (kind, label, disabled = false) => {
   1860         const on = editKind === kind;
   1861         const dis = disabled ? "disabled" : "";
   1862         return `<button type="button" class="${on ? "primary" : "ghost"} smallBtn" ${dis} data-polykind="${escapeHtml(kind)}">${escapeHtml(label)}</button>`;
   1863       };
   1864       const toolBtn = (tool, label) => {
   1865         const on = editTool === tool;
   1866         return `<button type="button" class="${on ? "primary" : "ghost"} smallBtn" data-polytool="${escapeHtml(tool)}">${escapeHtml(label)}</button>`;
   1867       };
   1868 
   1869       const inspectorBody = (() => {
   1870         const pts = Array.isArray(selected?.points) ? selected.points.length : 0;
   1871         if (editKind !== "exit") {
   1872           return selOk
   1873             ? `
   1874               <div class="small muted">Selected</div>
   1875               <div style="margin-top:6px;"><b>${escapeHtml(kindLabel(editKind))}</b></div>
   1876               <div class="small muted" style="margin-top:6px;">${pts} points</div>
   1877             `
   1878             : `<div class="small muted">Select a polygon to edit, or draw a new one.</div>`;
   1879         }
   1880 
   1881         const header = selOk ? "Selected exit" : "New exit defaults";
   1882         return `
   1883           <div class="small muted">${header}</div>
   1884           <label style="margin-top:6px;">
   1885             <div class="small muted">Name</div>
   1886             <input id="mapsExitName" type="text" maxlength="40" placeholder="Example: North Gate" value="${escapeHtml(exitModel.name)}" />
   1887           </label>
   1888           <label style="margin-top:10px;">
   1889             <div class="small muted">Behavior</div>
   1890             <select id="mapsExitBehavior">
   1891               <option value="toMaps" ${exitModel.action === "toMaps" ? "selected" : ""}>Exit to Maps</option>
   1892               <option value="toMap" ${exitModel.action === "toMap" ? "selected" : ""}>Exit to Map</option>
   1893             </select>
   1894           </label>
   1895           <div class="${exitModel.action === "toMap" ? "" : "hidden"}" id="mapsExitToMapWrap" style="margin-top:10px;">
   1896             <label>
   1897               <div class="small muted">Target map</div>
   1898               <select id="mapsExitToMap">${mapOptions}</select>
   1899             </label>
   1900             <label style="margin-top:10px;">
   1901               <div class="small muted">Target exit name (optional)</div>
   1902               <input id="mapsExitTargetExit" type="text" maxlength="40" placeholder="Example: South Gate" value="${escapeHtml(exitModel.targetExit)}" />
   1903             </label>
   1904           </div>
   1905           <div class="small muted" style="margin-top:10px;">${selOk ? `${pts} points` : "Tip: pick Draw, click 3+ points, then Close polygon."}</div>
   1906         `;
   1907       })();
   1908 
   1909       return `
   1910         <div class="mapsPolyModal" id="mapsPolyModal">
   1911           <div class="mapsPolyModalInner">
   1912             <div class="mapsPolyHeader">
   1913               <div>
   1914                 <div class="mapsPolyTitle">Polygon editor</div>
   1915                 <div class="small muted">Right-click or Shift+drag to pan. Esc clears draft. Delete removes selected.</div>
   1916               </div>
   1917               <button type="button" class="ghost smallBtn" id="mapsPolyModalClose">Close</button>
   1918             </div>
   1919             <div class="row" style="gap:10px; flex-wrap:wrap; margin-top:10px;">
   1920               ${kindBtn("collision", "Collisions")}
   1921               ${kindBtn("mask", "Y-sort")}
   1922               ${kindBtn("exit", "Exits")}
   1923               ${kindBtn("hidden", "Hidden (soon)", true)}
   1924               ${kindBtn("occluder", "Occluders (soon)", true)}
   1925               <div class="small muted" style="margin-left:auto;">${escapeHtml(String(list.length))} in ${escapeHtml(kindLabel(editKind))}</div>
   1926             </div>
   1927             <div class="row" style="gap:10px; flex-wrap:wrap; margin-top:10px;">
   1928               ${toolBtn("draw", "Draw")}
   1929               ${toolBtn("select", "Select")}
   1930               ${toolBtn("move", "Move")}
   1931               ${toolBtn("vertex", "Vertices")}
   1932               <div class="row" style="gap:10px; margin-left:auto; flex-wrap:wrap;">
   1933                 <button type="button" class="ghost smallBtn" id="mapsPolyPrev">Prev</button>
   1934                 <button type="button" class="ghost smallBtn" id="mapsPolyNext">Next</button>
   1935                 <button type="button" class="ghost smallBtn" id="mapsPolyCopy">Copy</button>
   1936                 <button type="button" class="ghost smallBtn" id="mapsPolyPaste" ${polyClipboard ? "" : "disabled"}>Paste</button>
   1937                 <button type="button" class="ghost smallBtn" id="mapsPolyDuplicate" ${selOk ? "" : "disabled"}>Duplicate</button>
   1938                 <button type="button" class="danger smallBtn" id="mapsPolyDelete" ${selOk ? "" : "disabled"}>Delete</button>
   1939                 <button type="button" class="primary smallBtn" id="mapsPolySaveAll">Save</button>
   1940               </div>
   1941             </div>
   1942             <div class="row" style="gap:10px; flex-wrap:wrap; margin-top:10px; align-items:center;">
   1943               <div class="small muted">Draft: <b>${escapeHtml(String(draftPoly.length))}</b> pts</div>
   1944               <button type="button" class="ghost smallBtn" id="mapsPolyUndoPt" ${draftPoly.length ? "" : "disabled"}>Undo point</button>
   1945               <button type="button" class="ghost smallBtn" id="mapsPolyCloseDraft" ${draftPoly.length >= 3 ? "" : "disabled"}>Close polygon</button>
   1946               <button type="button" class="ghost smallBtn" id="mapsPolyClearDraft" ${draftPoly.length ? "" : "disabled"}>Clear draft</button>
   1947               <button type="button" class="ghost smallBtn" id="mapsPolyClearKind" ${list.length ? "" : "disabled"}>Clear kind</button>
   1948               <div class="small muted" id="mapsPolyStatus" style="margin-left:auto;"></div>
   1949             </div>
   1950             <div class="mapsPolyGrid">
   1951               <div class="mapsPolyList" id="mapsPolyList">${polyRows || `<div class="small muted" style="padding:10px;">No polygons yet.</div>`}</div>
   1952               <div class="mapsPolyInspector" id="mapsPolyInspector">${inspectorBody}</div>
   1953             </div>
   1954           </div>
   1955         </div>
   1956       `;
   1957     }
   1958 
   1959     function wirePolyModalHandlers() {
   1960       const modal = document.getElementById("mapsPolyModal");
   1961       if (!modal) return;
   1962 
   1963       const modalClose = document.getElementById("mapsPolyModalClose");
   1964       if (modalClose) {
   1965         modalClose.onclick = () => {
   1966           editMode = false;
   1967           draftPoly = [];
   1968           polyDrag = null;
   1969           vertexDrag = null;
   1970           selectedPolyKind = "";
   1971           selectedPolyIndex = -1;
   1972           selectedVertexIndex = -1;
   1973           renderMapView();
   1974         };
   1975       }
   1976 
   1977       const statusEl = document.getElementById("mapsPolyStatus");
   1978       const setStatus = (txt) => {
   1979         if (statusEl) statusEl.textContent = txt;
   1980       };
   1981 
   1982       modal.onclick = (e) => {
   1983         const k = e.target.closest?.("[data-polykind]");
   1984         if (k) {
   1985           if (k.hasAttribute("disabled")) return;
   1986           const kind = String(k.getAttribute("data-polykind") || "");
   1987           if (!kind) return;
   1988           editKind = kind;
   1989           const list = polysForKind(activeMap, editKind);
   1990           if (!(selectedPolyKind === editKind && selectedPolyIndex >= 0 && selectedPolyIndex < list.length)) {
   1991             selectedPolyKind = "";
   1992             selectedPolyIndex = -1;
   1993             selectedVertexIndex = -1;
   1994           }
   1995           renderMapView();
   1996           return;
   1997         }
   1998         const t = e.target.closest?.("[data-polytool]");
   1999         if (t) {
   2000           const tool = String(t.getAttribute("data-polytool") || "");
   2001           if (!tool) return;
   2002           editTool = tool;
   2003           renderMapView();
   2004           return;
   2005         }
   2006       };
   2007 
   2008       const listEl = document.getElementById("mapsPolyList");
   2009       if (listEl) {
   2010         listEl.onclick = (e) => {
   2011           const btn = e.target.closest?.("[data-polysel]");
   2012           if (!btn) return;
   2013           const idx = Number(btn.getAttribute("data-polysel") || -1);
   2014           const list = polysForKind(activeMap, editKind);
   2015           if (idx < 0 || idx >= list.length) return;
   2016           selectedPolyKind = editKind;
   2017           selectedPolyIndex = idx;
   2018           selectedVertexIndex = -1;
   2019           editTool = "select";
   2020           renderMapView();
   2021         };
   2022       }
   2023 
   2024       const undoPt = document.getElementById("mapsPolyUndoPt");
   2025       if (undoPt) {
   2026         undoPt.onclick = () => {
   2027           if (!draftPoly.length) return;
   2028           draftPoly.pop();
   2029           setStatus(`${draftPoly.length} pts (draft)`);
   2030           renderMapView();
   2031         };
   2032       }
   2033       const clearDraft = document.getElementById("mapsPolyClearDraft");
   2034       if (clearDraft) {
   2035         clearDraft.onclick = () => {
   2036           draftPoly = [];
   2037           setStatus("Draft cleared.");
   2038           renderMapView();
   2039         };
   2040       }
   2041       const closeDraft = document.getElementById("mapsPolyCloseDraft");
   2042       if (closeDraft) {
   2043         closeDraft.onclick = () => {
   2044           const ok = commitDraftPoly();
   2045           setStatus(ok ? "Polygon added." : editKind === "exit" ? "Exit needs a name + target map (if to map)." : "Need at least 3 points.");
   2046           if (ok) editTool = "select";
   2047           renderMapView();
   2048         };
   2049       }
   2050 
   2051       const clearKind = document.getElementById("mapsPolyClearKind");
   2052       if (clearKind) {
   2053         clearKind.onclick = () => {
   2054           const list = polysForKind(activeMap, editKind);
   2055           if (!list.length) return;
   2056           const ok = window.confirm(`Clear all polygons in ${kindLabel(editKind)}? (Not saved yet)`);
   2057           if (!ok) return;
   2058           const target = polysForKind(activeMap, editKind, true);
   2059           target.length = 0;
   2060           selectedPolyKind = "";
   2061           selectedPolyIndex = -1;
   2062           selectedVertexIndex = -1;
   2063           draftPoly = [];
   2064           setStatus("Cleared kind (not saved).");
   2065           renderMapView();
   2066         };
   2067       }
   2068 
   2069       const saveAll = document.getElementById("mapsPolySaveAll");
   2070       if (saveAll) {
   2071         saveAll.onclick = () => {
   2072           if (!activeMap?.id) return;
   2073           const collisions = Array.isArray(activeMap.collisions) ? activeMap.collisions : [];
   2074           const masks = Array.isArray(activeMap.masks) ? activeMap.masks : [];
   2075           const exits = Array.isArray(activeMap.exits) ? activeMap.exits : [];
   2076           const hiddenMasks = Array.isArray(activeMap.hiddenMasks) ? activeMap.hiddenMasks : [];
   2077           const occluders = Array.isArray(activeMap.occluders) ? activeMap.occluders : [];
   2078           ctx.send("updateMap", { id: activeMap.id, collisions, masks, exits, hiddenMasks, occluders });
   2079           setStatus("Saved.");
   2080         };
   2081       }
   2082 
   2083       const cycle = (delta) => {
   2084         const list = polysForKind(activeMap, editKind);
   2085         if (!list.length) return;
   2086         const current = selectedPolyKind === editKind ? selectedPolyIndex : -1;
   2087         const next = current < 0 ? 0 : (current + delta + list.length) % list.length;
   2088         selectedPolyKind = editKind;
   2089         selectedPolyIndex = next;
   2090         selectedVertexIndex = -1;
   2091         editTool = "select";
   2092         renderMapView();
   2093       };
   2094       const prevBtn = document.getElementById("mapsPolyPrev");
   2095       const nextBtn = document.getElementById("mapsPolyNext");
   2096       if (prevBtn) prevBtn.onclick = () => cycle(-1);
   2097       if (nextBtn) nextBtn.onclick = () => cycle(+1);
   2098 
   2099       const copyBtn = document.getElementById("mapsPolyCopy");
   2100       if (copyBtn) {
   2101         copyBtn.onclick = () => {
   2102           const list = polysForKind(activeMap, editKind);
   2103           const ok = selectedPolyKind === editKind && selectedPolyIndex >= 0 && selectedPolyIndex < list.length;
   2104           if (!ok) return;
   2105           const src = list[selectedPolyIndex];
   2106           polyClipboard = { kind: editKind, poly: JSON.parse(JSON.stringify(src)) };
   2107           setStatus("Copied.");
   2108           renderMapView();
   2109         };
   2110       }
   2111       const pasteBtn = document.getElementById("mapsPolyPaste");
   2112       if (pasteBtn) {
   2113         pasteBtn.onclick = () => {
   2114           if (!polyClipboard || !polyClipboard.poly) return;
   2115           const targetKind = editKind;
   2116           const list = polysForKind(activeMap, targetKind, true);
   2117           const copy = JSON.parse(JSON.stringify(polyClipboard.poly));
   2118           const pts = Array.isArray(copy.points) ? copy.points : [];
   2119           for (const p of pts) {
   2120             p.x = Math.max(0, Math.min(1, Number(p.x || 0) + 0.02));
   2121             p.y = Math.max(0, Math.min(1, Number(p.y || 0) + 0.02));
   2122           }
   2123           copy.points = pts;
   2124           if (targetKind === "exit") {
   2125             copy.name = String(copy.name || "Exit").trim().slice(0, 40);
   2126           }
   2127           list.push(copy);
   2128           selectedPolyKind = targetKind;
   2129           selectedPolyIndex = list.length - 1;
   2130           selectedVertexIndex = -1;
   2131           setStatus("Pasted.");
   2132           renderMapView();
   2133         };
   2134       }
   2135 
   2136       const dupBtn = document.getElementById("mapsPolyDuplicate");
   2137       if (dupBtn) {
   2138         dupBtn.onclick = () => {
   2139           const list = polysForKind(activeMap, editKind);
   2140           const ok = selectedPolyKind === editKind && selectedPolyIndex >= 0 && selectedPolyIndex < list.length;
   2141           if (!ok) return;
   2142           polyClipboard = { kind: editKind, poly: JSON.parse(JSON.stringify(list[selectedPolyIndex])) };
   2143           const paste = document.getElementById("mapsPolyPaste");
   2144           if (paste) paste.click();
   2145         };
   2146       }
   2147 
   2148       const delBtn = document.getElementById("mapsPolyDelete");
   2149       if (delBtn) {
   2150         delBtn.onclick = () => {
   2151           const list = polysForKind(activeMap, editKind);
   2152           const ok = selectedPolyKind === editKind && selectedPolyIndex >= 0 && selectedPolyIndex < list.length;
   2153           if (!ok) return;
   2154           const label = editKind === "exit" ? String(list[selectedPolyIndex]?.name || `Exit ${selectedPolyIndex + 1}`) : `${kindLabel(editKind)} #${selectedPolyIndex + 1}`;
   2155           const yes = window.confirm(`Delete "${label}"? (Not saved yet)`);
   2156           if (!yes) return;
   2157           list.splice(selectedPolyIndex, 1);
   2158           selectedPolyKind = "";
   2159           selectedPolyIndex = -1;
   2160           selectedVertexIndex = -1;
   2161           setStatus("Deleted (not saved).");
   2162           renderMapView();
   2163         };
   2164       }
   2165 
   2166       // Exit meta fields (selected exit OR draft defaults)
   2167       const exitNameEl = document.getElementById("mapsExitName");
   2168       const exitBehaviorEl = document.getElementById("mapsExitBehavior");
   2169       const exitToMapWrap = document.getElementById("mapsExitToMapWrap");
   2170       const exitToMapEl = document.getElementById("mapsExitToMap");
   2171       const exitTargetExitEl = document.getElementById("mapsExitTargetExit");
   2172 
   2173       const applyExitModel = (patch) => {
   2174         if (editKind !== "exit") return;
   2175         const list = polysForKind(activeMap, "exit", true);
   2176         const isSel = selectedPolyKind === "exit" && selectedPolyIndex >= 0 && selectedPolyIndex < list.length;
   2177         if (isSel) {
   2178           list[selectedPolyIndex] = { ...list[selectedPolyIndex], ...patch };
   2179         } else {
   2180           if (Object.prototype.hasOwnProperty.call(patch, "name")) exitDraftName = String(patch.name || "");
   2181           if (Object.prototype.hasOwnProperty.call(patch, "action")) exitAction = patch.action === "toMap" ? "toMap" : "toMaps";
   2182           if (Object.prototype.hasOwnProperty.call(patch, "toMapId")) exitTargetMapId = String(patch.toMapId || "");
   2183           if (Object.prototype.hasOwnProperty.call(patch, "targetExit")) exitTargetExitName = String(patch.targetExit || "");
   2184         }
   2185       };
   2186 
   2187       const syncExitVis = () => {
   2188         const behavior = exitBehaviorEl ? String(exitBehaviorEl.value || "toMaps") : "toMaps";
   2189         if (exitToMapWrap) exitToMapWrap.classList.toggle("hidden", behavior !== "toMap");
   2190       };
   2191 
   2192       if (exitBehaviorEl) {
   2193         exitBehaviorEl.onchange = () => {
   2194           const behavior = String(exitBehaviorEl.value || "toMaps") === "toMap" ? "toMap" : "toMaps";
   2195           applyExitModel({ action: behavior });
   2196           syncExitVis();
   2197           renderMapView();
   2198         };
   2199       }
   2200       if (exitNameEl) {
   2201         exitNameEl.oninput = () => {
   2202           applyExitModel({ name: String(exitNameEl.value || "").slice(0, 40) });
   2203         };
   2204       }
   2205       if (exitToMapEl) {
   2206         if (!exitToMapEl.value && otherMaps.length) exitToMapEl.value = otherMaps[0];
   2207         exitToMapEl.onchange = () => {
   2208           applyExitModel({ toMapId: String(exitToMapEl.value || "").trim().toLowerCase() });
   2209         };
   2210       }
   2211       if (exitTargetExitEl) {
   2212         exitTargetExitEl.oninput = () => {
   2213           applyExitModel({ targetExit: String(exitTargetExitEl.value || "").slice(0, 40) });
   2214         };
   2215       }
   2216       syncExitVis();
   2217     }
   2218 
   2219     function pointInPoly(pt, poly) {
   2220       const x = pt.x;
   2221       const y = pt.y;
   2222       const pts = Array.isArray(poly?.points) ? poly.points : [];
   2223       if (pts.length < 3) return false;
   2224       let inside = false;
   2225       for (let i = 0, j = pts.length - 1; i < pts.length; j = i++) {
   2226         const xi = Number(pts[i].x);
   2227         const yi = Number(pts[i].y);
   2228         const xj = Number(pts[j].x);
   2229         const yj = Number(pts[j].y);
   2230         const intersect = yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi + 1e-12) + xi;
   2231         if (intersect) inside = !inside;
   2232       }
   2233       return inside;
   2234     }
   2235 
   2236     function loadBackground(url) {
   2237       bgImg = null;
   2238       if (!url) return;
   2239       const img = new Image();
   2240       img.crossOrigin = "anonymous";
   2241       img.onload = () => {
   2242         bgImg = img;
   2243       };
   2244       img.src = url;
   2245     }
   2246 
   2247     function stopLoop() {
   2248       if (raf) cancelAnimationFrame(raf);
   2249       raf = 0;
   2250       lastTick = 0;
   2251     }
   2252 
   2253     function startLoop() {
   2254       stopLoop();
   2255       lastTick = performance.now();
   2256       raf = requestAnimationFrame(tick);
   2257     }
   2258 
   2259     function tick(ts) {
   2260       raf = requestAnimationFrame(tick);
   2261       const dt = Math.max(0, Math.min(0.05, (ts - lastTick) / 1000));
   2262       lastTick = ts;
   2263       if (mode !== "map" || !activeMap) return;
   2264 
   2265       // Smooth remote users to reduce jitter.
   2266       for (const [name, u] of users.entries()) {
   2267         if (!u) continue;
   2268         if (name === (self || String(ctx.getUser() || "").trim().toLowerCase())) continue;
   2269         if (typeof u.tx !== "number" || typeof u.ty !== "number") continue;
   2270         if (typeof u.x !== "number" || typeof u.y !== "number") {
   2271           u.x = u.tx;
   2272           u.y = u.ty;
   2273           continue;
   2274         }
   2275         const k = 1 - Math.exp(-dt * 14);
   2276         u.x = u.x + (u.tx - u.x) * k;
   2277         u.y = u.y + (u.ty - u.y) * k;
   2278       }
   2279 
   2280       // Movement speed in world pixels/sec, converted to normalized units based on map size.
   2281       const dims = getWorldDims();
   2282       const speedPxPerSec = 220;
   2283       const possessedToken = getPossessedTokenForMe();
   2284       const controlPos = possessedToken
   2285         ? { x: Math.max(0, Math.min(1, Number(possessedToken.x || 0.5))), y: Math.max(0, Math.min(1, Number(possessedToken.y || 0.5))) }
   2286         : { x: localPos.x, y: localPos.y };
   2287       let dx = 0;
   2288       let dy = 0;
   2289       if (!editMode) {
   2290         if (keys.has("ArrowUp") || keys.has("KeyW")) dy -= 1;
   2291         if (keys.has("ArrowDown") || keys.has("KeyS")) dy += 1;
   2292         if (keys.has("ArrowLeft") || keys.has("KeyA")) dx -= 1;
   2293         if (keys.has("ArrowRight") || keys.has("KeyD")) dx += 1;
   2294       }
   2295       if (selfInvisible && !possessedToken) {
   2296         dx = 0;
   2297         dy = 0;
   2298       }
   2299       const mag = Math.hypot(dx, dy) || 1;
   2300       dx /= mag;
   2301       dy /= mag;
   2302 
   2303       const moved = Boolean(dx || dy);
   2304       if (moved) {
   2305         const speedNx = speedPxPerSec / Math.max(1, dims.w);
   2306         const speedNy = speedPxPerSec / Math.max(1, dims.h);
   2307         const nextX = Math.max(0, Math.min(1, controlPos.x + dx * speedNx * dt));
   2308         const nextY = Math.max(0, Math.min(1, controlPos.y + dy * speedNy * dt));
   2309         const collisions = Array.isArray(activeMap.collisions) ? activeMap.collisions : [];
   2310         const tryPtX = { x: nextX, y: controlPos.y };
   2311         const tryPtY = { x: controlPos.x, y: nextY };
   2312         const blockedX = collisions.some((p) => pointInPoly(tryPtX, p));
   2313         const blockedY = collisions.some((p) => pointInPoly(tryPtY, p));
   2314         const finalX = !blockedX ? nextX : controlPos.x;
   2315         const finalY = !blockedY ? nextY : controlPos.y;
   2316         if (possessedToken && activeMap?.ttrpgEnabled && canManageTtrpg) {
   2317           const props = Array.isArray(activeMap.props) ? activeMap.props : [];
   2318           const idx = props.findIndex((p) => String(p?.id || "") === String(possessedToken.id || ""));
   2319           if (idx >= 0) {
   2320             const current = props[idx];
   2321             props[idx] = { ...current, x: finalX, y: finalY };
   2322             activeMap.props = props;
   2323             const now = Date.now();
   2324             if (now - lastPropMoveAt > 60) {
   2325               lastPropMoveAt = now;
   2326               ctx.send("ttrpgPropMove", {
   2327                 mapId: activeMap.id,
   2328                 propId: current.id,
   2329                 x: finalX,
   2330                 y: finalY,
   2331                 z: current.z || 0,
   2332                 rot: current.rot || 0,
   2333                 scale: current.scale || 1
   2334               });
   2335             }
   2336           }
   2337         } else {
   2338           localPos.x = finalX;
   2339           localPos.y = finalY;
   2340           const me = (self || String(ctx.getUser() || "")).trim().toLowerCase();
   2341           if (me) {
   2342             const prev = users.get(me) || { x: localPos.x, y: localPos.y, tx: localPos.x, ty: localPos.y, color: "", image: "" };
   2343             users.set(me, { ...prev, x: localPos.x, y: localPos.y, tx: localPos.x, ty: localPos.y });
   2344           }
   2345           const now = Date.now();
   2346           if (now - lastSentAt > 60) {
   2347             lastSentAt = now;
   2348             ctx.send("move", { x: localPos.x, y: localPos.y, seq: moveSeq++ });
   2349           }
   2350         }
   2351       }
   2352 
   2353       if (!editMode) {
   2354         const exitPos = possessedToken
   2355           ? { x: Math.max(0, Math.min(1, Number(possessedToken.x || 0.5))), y: Math.max(0, Math.min(1, Number(possessedToken.y || 0.5))) }
   2356           : localPos;
   2357         checkExits(exitPos);
   2358       }
   2359       draw();
   2360       cleanupBubbles();
   2361     }
   2362 
   2363     function checkExits(position = localPos) {
   2364       if (!activeMap) return;
   2365       const exits = Array.isArray(activeMap.exits) ? activeMap.exits : [];
   2366       if (!exits.length) return;
   2367       const now = Date.now();
   2368       if (now - lastExitAt < 900) return;
   2369       let triggered = null;
   2370       for (let i = 0; i < exits.length; i++) {
   2371         const ex = exits[i];
   2372         const inside = pointInPoly({ x: Number(position?.x || 0), y: Number(position?.y || 0) }, ex);
   2373         const was = Boolean(exitInside.get(i));
   2374         exitInside.set(i, inside);
   2375         if (inside && !was) {
   2376           triggered = ex;
   2377           break;
   2378         }
   2379       }
   2380       if (!triggered) return;
   2381       lastExitAt = now;
   2382       const action = String(triggered.action || "toMaps");
   2383       if (action === "toMap") {
   2384         const to = String(triggered.toMapId || "").trim().toLowerCase();
   2385         const targetExit = String(triggered.targetExit || "").trim();
   2386         if (to && to !== String(activeMap.id || "").trim().toLowerCase()) transitionToMap(to, targetExit);
   2387         return;
   2388       }
   2389       leaveMap();
   2390     }
   2391 
   2392     function transitionToMap(mapId, targetExitName = "") {
   2393       // Leave current room on the server side, then join target.
   2394       try {
   2395         ctx.send("leave", {});
   2396       } catch {
   2397         // ignore
   2398       }
   2399       stopWalkie();
   2400       stopAllWalkies();
   2401       exitInside.clear();
   2402       const to = String(mapId || "").trim().toLowerCase();
   2403       pendingSpawn = targetExitName ? { mapId: to, exitName: String(targetExitName || "").trim().toLowerCase() } : null;
   2404       enterMap(to);
   2405     }
   2406 
   2407     function getWorldDims() {
   2408       const w =
   2409         activeMap?.world?.w && Number.isFinite(Number(activeMap.world.w))
   2410           ? Number(activeMap.world.w)
   2411           : bgImg && (bgImg.naturalWidth || bgImg.width)
   2412             ? Number(bgImg.naturalWidth || bgImg.width)
   2413             : 1400;
   2414       const h =
   2415         activeMap?.world?.h && Number.isFinite(Number(activeMap.world.h))
   2416           ? Number(activeMap.world.h)
   2417           : bgImg && (bgImg.naturalHeight || bgImg.height)
   2418             ? Number(bgImg.naturalHeight || bgImg.height)
   2419             : 900;
   2420       return { w: Math.max(200, Math.min(10000, w)), h: Math.max(200, Math.min(10000, h)) };
   2421     }
   2422 
   2423     function propScreenBox(prop, spriteById, tr) {
   2424       const sprite = spriteById.get(String(prop?.spriteId || "")) || null;
   2425       if (!sprite) return null;
   2426       const img = getSpriteImage(sprite.url || "");
   2427       if (!img) return null;
   2428       const spriteScale = Math.max(0.1, Math.min(4.0, Number(sprite.scale || 1)));
   2429       const instanceScale = Math.max(0.1, Math.min(4.0, Number(prop?.scale || 1)));
   2430       const scale = spriteScale * instanceScale;
   2431       const maxWorld = 220;
   2432       const minWorld = 12;
   2433       const iw = Math.max(1, Number(img.naturalWidth || img.width || 1));
   2434       const ih = Math.max(1, Number(img.naturalHeight || img.height || 1));
   2435       const wWorld = Math.max(minWorld, Math.min(maxWorld, iw * scale));
   2436       const hWorld = Math.max(minWorld, Math.min(maxWorld, ih * scale));
   2437 
   2438       const xw = Number(prop?.x || 0) * tr.worldW;
   2439       const yw = Number(prop?.y || 0) * tr.worldH;
   2440       const cx = (xw - tr.srcX) * tr.zoom;
   2441       const cy = (yw - tr.srcY) * tr.zoom;
   2442       const w = wWorld * tr.zoom;
   2443       const h = hWorld * tr.zoom;
   2444       return { x: cx - w / 2, y: cy - h / 2, w, h, cx, cy, img, sprite };
   2445     }
   2446 
   2447     function hitTestPropAtPointer(clientX, clientY, canvas, tr) {
   2448       if (!activeMap?.ttrpgEnabled) return null;
   2449       const props = Array.isArray(activeMap.props) ? activeMap.props : [];
   2450       if (!props.length) return null;
   2451       const sprites = Array.isArray(activeMap.sprites) ? activeMap.sprites : [];
   2452       const spriteById = new Map(sprites.map((s) => [String(s.id || ""), s]));
   2453       const rect = canvas.getBoundingClientRect();
   2454       const sx = clientX - rect.left;
   2455       const sy = clientY - rect.top;
   2456       if (sx < 0 || sy < 0 || sx > rect.width || sy > rect.height) return null;
   2457 
   2458       // Check top-most first: sort by y then z.
   2459       const sorted = props
   2460         .slice()
   2461         .sort((a, b) => {
   2462           const ay = Number(a?.y || 0);
   2463           const by = Number(b?.y || 0);
   2464           if (ay !== by) return ay - by;
   2465           return Number(a?.z || 0) - Number(b?.z || 0);
   2466         });
   2467       for (let i = sorted.length - 1; i >= 0; i--) {
   2468         const p = sorted[i];
   2469         const box = propScreenBox(p, spriteById, tr);
   2470         if (!box) continue;
   2471         if (sx >= box.x && sx <= box.x + box.w && sy >= box.y && sy <= box.y + box.h) {
   2472           return { propId: String(p.id || ""), x: Number(p.x || 0), y: Number(p.y || 0) };
   2473         }
   2474       }
   2475       return null;
   2476     }
   2477 
   2478     function getPossessedTokenForMe() {
   2479       if (!activeMap?.ttrpgEnabled) return null;
   2480       const me = String(ctx.getUser() || "").trim().toLowerCase();
   2481       if (!me) return null;
   2482       const props = Array.isArray(activeMap.props) ? activeMap.props : [];
   2483       if (!props.length) return null;
   2484       const sprites = Array.isArray(activeMap.sprites) ? activeMap.sprites : [];
   2485       const spriteById = new Map(sprites.map((s) => [String(s?.id || ""), s]));
   2486       const isTokenControlledByMe = (prop) => {
   2487         if (!prop) return false;
   2488         if (String(prop.controlledBy || "").trim().toLowerCase() !== me) return false;
   2489         const spr = spriteById.get(String(prop.spriteId || ""));
   2490         return spr?.kind === "token";
   2491       };
   2492       if (speakingAsPropId) {
   2493         const preferred = props.find((p) => String(p?.id || "") === String(speakingAsPropId || ""));
   2494         if (isTokenControlledByMe(preferred)) return preferred;
   2495       }
   2496       const fallback = props.find((p) => isTokenControlledByMe(p));
   2497       return fallback || null;
   2498     }
   2499 
   2500     function cleanupBubbles() {
   2501       const t = Date.now();
   2502       let changed = false;
   2503       for (const [u, b] of bubbles.entries()) {
   2504         if (!b || Number(b.expiresAt || 0) <= t) {
   2505           bubbles.delete(u);
   2506           changed = true;
   2507         }
   2508       }
   2509       if (changed && mode === "map") {
   2510         // force redraw by leaving tick running
   2511       }
   2512     }
   2513 
   2514     function draw() {
   2515       const canvas = document.getElementById("mapsCanvas");
   2516       if (!canvas) return;
   2517       const wrap = canvas.parentElement;
   2518       if (!wrap) return;
   2519       const rect = wrap.getBoundingClientRect();
   2520       const w = Math.max(1, Math.floor(rect.width));
   2521       const h = Math.max(1, Math.floor(rect.height));
   2522       if (canvas.width !== w || canvas.height !== h) {
   2523         canvas.width = w;
   2524         canvas.height = h;
   2525       }
   2526       const g = canvas.getContext("2d");
   2527       if (!g) return;
   2528       g.clearRect(0, 0, w, h);
   2529 
   2530       // Camera + zoom.
   2531       const zoom = Math.max(0.8, Math.min(5.0, Number(activeMap?.cameraZoom || 2.35) || 2.35));
   2532       const me = (self || String(ctx.getUser() || "")).trim().toLowerCase();
   2533       const possessedToken = getPossessedTokenForMe();
   2534       const followTarget = editMode
   2535         ? null
   2536         : possessedToken
   2537           ? { x: Number(possessedToken.x || 0.5), y: Number(possessedToken.y || 0.5) }
   2538           : me && !selfInvisible
   2539             ? { x: Number(localPos.x || 0.5), y: Number(localPos.y || 0.5) }
   2540             : null;
   2541       if (!cameraPos) {
   2542         const seed = followTarget || { x: Number(localPos.x || 0.5), y: Number(localPos.y || 0.5) };
   2543         cameraPos = { x: seed.x, y: seed.y };
   2544       }
   2545       if (followTarget) {
   2546         const dist = Math.hypot(followTarget.x - cameraPos.x, followTarget.y - cameraPos.y);
   2547         const lerp = dist > 0.25 ? 1 : 0.28;
   2548         cameraPos.x = cameraPos.x + (followTarget.x - cameraPos.x) * lerp;
   2549         cameraPos.y = cameraPos.y + (followTarget.y - cameraPos.y) * lerp;
   2550       }
   2551       const cam = cameraPos;
   2552 
   2553       const worldW = activeMap?.world?.w ? Number(activeMap.world.w) : bgImg ? bgImg.naturalWidth : 1400;
   2554       const worldH = activeMap?.world?.h ? Number(activeMap.world.h) : bgImg ? bgImg.naturalHeight : 900;
   2555       const viewW = w / zoom;
   2556       const viewH = h / zoom;
   2557       const cx = Math.max(viewW / 2, Math.min(worldW - viewW / 2, cam.x * worldW));
   2558       const cy = Math.max(viewH / 2, Math.min(worldH - viewH / 2, cam.y * worldH));
   2559       const srcX = Math.max(0, Math.min(worldW - viewW, cx - viewW / 2));
   2560       const srcY = Math.max(0, Math.min(worldH - viewH, cy - viewH / 2));
   2561       lastTransform = { srcX, srcY, zoom, worldW, worldH, viewW, viewH };
   2562 
   2563       // Background (cropped to camera view)
   2564       if (bgImg) {
   2565         g.globalAlpha = 0.92;
   2566         g.drawImage(bgImg, srcX, srcY, viewW, viewH, 0, 0, w, h);
   2567         g.globalAlpha = 1;
   2568       } else {
   2569         g.fillStyle = "rgba(0,0,0,0.25)";
   2570         g.fillRect(0, 0, w, h);
   2571       }
   2572 
   2573       // Props (TTRPG mode) — draw before players.
   2574       const tr = { srcX, srcY, zoom, worldW, worldH };
   2575       if (activeMap?.ttrpgEnabled) {
   2576         const sprites = Array.isArray(activeMap.sprites) ? activeMap.sprites : [];
   2577         const spriteById = new Map(sprites.map((s) => [String(s?.id || ""), s]));
   2578         const props = Array.isArray(activeMap.props) ? activeMap.props : [];
   2579         const sortedProps = props
   2580           .slice()
   2581           .sort((a, b) => {
   2582             const ay = Number(a?.y || 0);
   2583             const by = Number(b?.y || 0);
   2584             if (ay !== by) return ay - by;
   2585             return Number(a?.z || 0) - Number(b?.z || 0);
   2586           });
   2587         for (const p of sortedProps) {
   2588           const box = propScreenBox(p, spriteById, tr);
   2589           if (!box) continue;
   2590           // Skip if far outside viewport for perf.
   2591           if (box.x > w + 80 || box.y > h + 80 || box.x + box.w < -80 || box.y + box.h < -80) continue;
   2592           const rotDeg = Number(p?.rot || 0);
   2593           const rot = Number.isFinite(rotDeg) ? (rotDeg * Math.PI) / 180 : 0;
   2594           g.save();
   2595           g.globalAlpha = 0.98;
   2596           g.imageSmoothingEnabled = true;
   2597           g.shadowColor = "rgba(0,0,0,0.35)";
   2598           g.shadowBlur = 10;
   2599           g.shadowOffsetY = 6;
   2600           g.translate(box.cx, box.cy);
   2601           if (rot) g.rotate(rot);
   2602           g.drawImage(box.img, -box.w / 2, -box.h / 2, box.w, box.h);
   2603           g.restore();
   2604         }
   2605       }
   2606 
   2607       // Players (draw in world coords -> screen coords)
   2608       for (const [username, u] of users.entries()) {
   2609         if (!u) continue;
   2610         const rx = typeof u.x === "number" ? u.x : Number(u.tx || 0);
   2611         const ry = typeof u.y === "number" ? u.y : Number(u.ty || 0);
   2612         const xw = Number(rx || 0) * worldW;
   2613         const yw = Number(ry || 0) * worldH;
   2614         const px = Math.floor((xw - srcX) * zoom);
   2615         const py = Math.floor((yw - srcY) * zoom);
   2616 
   2617         const size = Math.max(18, Math.min(96, Math.floor(Number(activeMap?.avatarSize || 36))));
   2618         const radius = Math.floor(size / 2);
   2619         const color = typeof u.color === "string" && u.color ? u.color : "#ff3ea5";
   2620 
   2621         // Avatar circle
   2622         const img = getAvatarImage(username, u.image || "");
   2623         g.save();
   2624         g.beginPath();
   2625         g.arc(px, py, radius, 0, Math.PI * 2);
   2626         g.closePath();
   2627         g.clip();
   2628         if (img) {
   2629           g.drawImage(img, px - radius, py - radius, size, size);
   2630         } else {
   2631           g.fillStyle = color;
   2632           g.beginPath();
   2633           g.arc(px, py, radius, 0, Math.PI * 2);
   2634           g.fill();
   2635         }
   2636         g.restore();
   2637         g.strokeStyle = "rgba(255,255,255,0.28)";
   2638         g.lineWidth = 2;
   2639         g.beginPath();
   2640         g.arc(px, py, radius, 0, Math.PI * 2);
   2641         g.stroke();
   2642 
   2643         // Username in user's color, with contrast highlight (bigger + darker for readability)
   2644         const nameText = `@${username}`;
   2645         const nameColor = normalizeReadableColor(color);
   2646         g.font = "700 15px system-ui, -apple-system, Segoe UI, sans-serif";
   2647         g.textAlign = "center";
   2648         const nm = g.measureText(nameText);
   2649         const nameW = Math.ceil(nm.width) + 14;
   2650         const nameH = 22;
   2651         const nameX = px - nameW / 2;
   2652         const nameY = py - (radius + 30);
   2653         const bg = chooseHighlightBg(nameColor);
   2654         g.fillStyle = bg;
   2655         g.strokeStyle = "rgba(255,255,255,0.10)";
   2656         roundRect(g, nameX, nameY, nameW, nameH, 10);
   2657         g.fill();
   2658         g.stroke();
   2659         g.fillStyle = nameColor;
   2660         g.shadowColor = "rgba(0,0,0,0.55)";
   2661         g.shadowBlur = 6;
   2662         g.shadowOffsetY = 2;
   2663         g.fillText(nameText, px, nameY + 16);
   2664         g.shadowBlur = 0;
   2665 
   2666         const b = bubbles.get(`user:${username}`);
   2667         if (b && b.text) {
   2668           const text = String(b.text);
   2669           const pad = 7;
   2670           g.font = "14px system-ui, -apple-system, Segoe UI, sans-serif";
   2671           const tw = Math.min(w - 20, Math.ceil(g.measureText(text).width) + pad * 2);
   2672           const th = 26;
   2673           const bx = Math.max(10, Math.min(w - 10 - tw, px - tw / 2));
   2674           const by = Math.max(10, py - (radius + 64));
   2675           g.fillStyle = "rgba(10,9,14,0.88)";
   2676           g.strokeStyle = "rgba(246,240,255,0.14)";
   2677           roundRect(g, bx, by, tw, th, 12);
   2678           g.fill();
   2679           g.stroke();
   2680           g.fillStyle = "rgba(246,240,255,0.92)";
   2681           g.shadowColor = "rgba(0,0,0,0.55)";
   2682           g.shadowBlur = 6;
   2683           g.shadowOffsetY = 2;
   2684           g.fillText(text, bx + tw / 2, by + 18);
   2685           g.shadowBlur = 0;
   2686         }
   2687       }
   2688 
   2689       // Token chat bubbles
   2690       if (activeMap?.ttrpgEnabled) {
   2691         const sprites = Array.isArray(activeMap.sprites) ? activeMap.sprites : [];
   2692         const spriteById = new Map(sprites.map((s) => [String(s?.id || ""), s]));
   2693         const props = Array.isArray(activeMap.props) ? activeMap.props : [];
   2694         for (const [key, b] of bubbles.entries()) {
   2695           if (!b || b.actorType !== "token") continue;
   2696           const propId = String(b.actorPropId || "");
   2697           if (!propId || key !== `token:${propId}`) continue;
   2698           const prop = props.find((p) => String(p?.id || "") === propId);
   2699           if (!prop) continue;
   2700           const box = propScreenBox(prop, spriteById, { srcX, srcY, zoom, worldW, worldH });
   2701           if (!box) continue;
   2702           const text = String(b.text || "").trim();
   2703           if (!text) continue;
   2704           const pad = 7;
   2705           g.font = "14px system-ui, -apple-system, Segoe UI, sans-serif";
   2706           const tw = Math.min(w - 20, Math.ceil(g.measureText(text).width) + pad * 2);
   2707           const th = 26;
   2708           const bx = Math.max(10, Math.min(w - 10 - tw, box.cx - tw / 2));
   2709           const by = Math.max(10, box.y - 34);
   2710           g.fillStyle = "rgba(10,9,14,0.88)";
   2711           g.strokeStyle = "rgba(246,240,255,0.14)";
   2712           roundRect(g, bx, by, tw, th, 12);
   2713           g.fill();
   2714           g.stroke();
   2715           g.fillStyle = "rgba(246,240,255,0.92)";
   2716           g.shadowColor = "rgba(0,0,0,0.55)";
   2717           g.shadowBlur = 6;
   2718           g.shadowOffsetY = 2;
   2719           g.fillText(text, bx + tw / 2, by + 18);
   2720           g.shadowBlur = 0;
   2721         }
   2722       }
   2723 
   2724       // Y-sort masks: redraw background clipped to polygon on top of entities when they're "behind".
   2725       const masks = Array.isArray(activeMap.masks) ? activeMap.masks : [];
   2726       if (bgImg && masks.length) {
   2727         for (const poly of masks) {
   2728           const pts = Array.isArray(poly?.points) ? poly.points : [];
   2729           if (pts.length < 3) continue;
   2730           const sortY = Math.max(...pts.map((p) => Number(p?.y || 0)));
   2731           let needs = false;
   2732           for (const [, u] of users.entries()) {
   2733             if (!u) continue;
   2734             const ux = typeof u.x === "number" ? u.x : Number(u.tx || 0);
   2735             const uy = typeof u.y === "number" ? u.y : Number(u.ty || 0);
   2736             if (uy >= sortY) continue; // in front
   2737             if (!pointInPoly({ x: ux, y: uy }, poly)) continue;
   2738             needs = true;
   2739             break;
   2740           }
   2741           if (!needs && activeMap?.ttrpgEnabled) {
   2742             const props = Array.isArray(activeMap.props) ? activeMap.props : [];
   2743             for (const p of props) {
   2744               if (!p) continue;
   2745               const px = Number(p.x || 0);
   2746               const py = Number(p.y || 0);
   2747               if (py >= sortY) continue;
   2748               if (!pointInPoly({ x: px, y: py }, poly)) continue;
   2749               needs = true;
   2750               break;
   2751             }
   2752           }
   2753           if (!needs) continue;
   2754           g.save();
   2755           g.beginPath();
   2756           const first = pts[0];
   2757           g.moveTo(((Number(first.x) * worldW - srcX) * zoom) | 0, ((Number(first.y) * worldH - srcY) * zoom) | 0);
   2758           for (let i = 1; i < pts.length; i++) {
   2759             const p = pts[i];
   2760             g.lineTo(((Number(p.x) * worldW - srcX) * zoom) | 0, ((Number(p.y) * worldH - srcY) * zoom) | 0);
   2761           }
   2762           g.closePath();
   2763           g.clip();
   2764           g.globalAlpha = 0.92;
   2765           g.drawImage(bgImg, srcX, srcY, viewW, viewH, 0, 0, w, h);
   2766           g.restore();
   2767         }
   2768       }
   2769 
   2770       // Edit overlays
   2771       if (editMode) {
   2772         drawPolysOverlay(g, activeMap, worldW, worldH, srcX, srcY, zoom);
   2773       }
   2774     }
   2775 
   2776     function drawPolysOverlay(g, map, worldW, worldH, srcX, srcY, zoom) {
   2777       const selected =
   2778         selectedPolyKind && selectedPolyKind === editKind
   2779           ? (() => {
   2780               const list = polysForKind(map, editKind);
   2781               return selectedPolyIndex >= 0 && selectedPolyIndex < list.length ? list[selectedPolyIndex] : null;
   2782             })()
   2783           : null;
   2784 
   2785       const drawPoly = (poly, stroke, fill, showPoints, emphasized) => {
   2786         const pts = Array.isArray(poly?.points) ? poly.points : [];
   2787         if (pts.length < 2) return;
   2788         g.save();
   2789         g.beginPath();
   2790         g.moveTo((Number(pts[0].x) * worldW - srcX) * zoom, (Number(pts[0].y) * worldH - srcY) * zoom);
   2791         for (let i = 1; i < pts.length; i++) {
   2792           g.lineTo((Number(pts[i].x) * worldW - srcX) * zoom, (Number(pts[i].y) * worldH - srcY) * zoom);
   2793         }
   2794         g.closePath();
   2795         g.fillStyle = fill;
   2796         g.strokeStyle = stroke;
   2797         g.lineWidth = emphasized ? 3.5 : 2;
   2798         if (emphasized) {
   2799           g.shadowColor = "rgba(0,0,0,0.45)";
   2800           g.shadowBlur = 12;
   2801         }
   2802         g.fill();
   2803         g.stroke();
   2804         if (showPoints) {
   2805           g.fillStyle = stroke;
   2806           for (let i = 0; i < pts.length; i++) {
   2807             const p = pts[i];
   2808             const x = (Number(p.x) * worldW - srcX) * zoom;
   2809             const y = (Number(p.y) * worldH - srcY) * zoom;
   2810             g.beginPath();
   2811             const r = emphasized && selectedVertexIndex === i ? 7.0 : emphasized ? 5.2 : 3.2;
   2812             g.arc(x, y, r, 0, Math.PI * 2);
   2813             g.fill();
   2814             if (emphasized) {
   2815               g.strokeStyle = "rgba(0,0,0,0.35)";
   2816               g.lineWidth = 1;
   2817               g.stroke();
   2818             }
   2819           }
   2820         }
   2821         g.restore();
   2822       };
   2823 
   2824       const collisions = Array.isArray(map.collisions) ? map.collisions : [];
   2825       const masks = Array.isArray(map.masks) ? map.masks : [];
   2826       for (const p of collisions) drawPoly(p, "rgba(255,70,70,0.82)", "rgba(255,70,70,0.10)", false, selected === p);
   2827       for (const p of masks) drawPoly(p, "rgba(80,195,255,0.82)", "rgba(80,195,255,0.08)", false, selected === p);
   2828       const exits = Array.isArray(map.exits) ? map.exits : [];
   2829       for (const p of exits) drawPoly(p, "rgba(255,215,90,0.90)", "rgba(255,215,90,0.10)", false, selected === p);
   2830       const hidden = Array.isArray(map.hiddenMasks) ? map.hiddenMasks : [];
   2831       const occ = Array.isArray(map.occluders) ? map.occluders : [];
   2832       for (const p of hidden) drawPoly(p, "rgba(180,120,255,0.80)", "rgba(180,120,255,0.08)", false, selected === p);
   2833       for (const p of occ) drawPoly(p, "rgba(120,255,180,0.80)", "rgba(120,255,180,0.08)", false, selected === p);
   2834 
   2835       if (selected) {
   2836         const stroke =
   2837           editKind === "collision"
   2838             ? "rgba(255,70,70,0.98)"
   2839             : editKind === "mask"
   2840               ? "rgba(80,195,255,0.98)"
   2841               : editKind === "exit"
   2842                 ? "rgba(255,215,90,0.98)"
   2843                 : editKind === "hidden"
   2844                   ? "rgba(180,120,255,0.98)"
   2845                   : "rgba(120,255,180,0.98)";
   2846         const fill =
   2847           editKind === "collision"
   2848             ? "rgba(255,70,70,0.16)"
   2849             : editKind === "mask"
   2850               ? "rgba(80,195,255,0.14)"
   2851               : editKind === "exit"
   2852                 ? "rgba(255,215,90,0.14)"
   2853                 : editKind === "hidden"
   2854                   ? "rgba(180,120,255,0.12)"
   2855                   : "rgba(120,255,180,0.12)";
   2856         drawPoly(selected, stroke, fill, true, true);
   2857       }
   2858 
   2859       if (draftPoly && draftPoly.length) {
   2860         const poly = { points: draftPoly };
   2861         const stroke =
   2862           editKind === "collision"
   2863             ? "rgba(255,70,70,0.95)"
   2864             : editKind === "mask"
   2865               ? "rgba(80,195,255,0.95)"
   2866               : editKind === "exit"
   2867                 ? "rgba(255,215,90,0.98)"
   2868                 : editKind === "hidden"
   2869                   ? "rgba(180,120,255,0.98)"
   2870                   : "rgba(120,255,180,0.98)";
   2871         const fill =
   2872           editKind === "collision"
   2873             ? "rgba(255,70,70,0.10)"
   2874             : editKind === "mask"
   2875               ? "rgba(80,195,255,0.10)"
   2876               : editKind === "exit"
   2877                 ? "rgba(255,215,90,0.10)"
   2878                 : editKind === "hidden"
   2879                   ? "rgba(180,120,255,0.10)"
   2880                   : "rgba(120,255,180,0.10)";
   2881         drawPoly(poly, stroke, fill, true, false);
   2882       }
   2883     }
   2884 
   2885     function parseHexColor(hex) {
   2886       const s = String(hex || "").trim();
   2887       const m = s.match(/^#([0-9a-f]{6})$/i);
   2888       if (!m) return null;
   2889       const n = parseInt(m[1], 16);
   2890       return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 };
   2891     }
   2892 
   2893     function relLuma(rgb) {
   2894       // sRGB relative luminance
   2895       const srgb = [rgb.r, rgb.g, rgb.b].map((v) => v / 255).map((c) => (c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4)));
   2896       return 0.2126 * srgb[0] + 0.7152 * srgb[1] + 0.0722 * srgb[2];
   2897     }
   2898 
   2899     function mix(a, b, t) {
   2900       return Math.round(a + (b - a) * t);
   2901     }
   2902 
   2903     function normalizeReadableColor(hex) {
   2904       const rgb = parseHexColor(hex);
   2905       if (!rgb) return "#ff3ea5";
   2906       const l = relLuma(rgb);
   2907       if (l > 0.25) return hex;
   2908       // brighten toward white a bit
   2909       const t = (0.25 - l) * 1.15;
   2910       const r = mix(rgb.r, 255, Math.min(0.65, Math.max(0.15, t)));
   2911       const g = mix(rgb.g, 255, Math.min(0.65, Math.max(0.15, t)));
   2912       const b = mix(rgb.b, 255, Math.min(0.65, Math.max(0.15, t)));
   2913       return `#${[r, g, b].map((v) => v.toString(16).padStart(2, "0")).join("")}`;
   2914     }
   2915 
   2916     function chooseHighlightBg(textHex) {
   2917       // Always use a darker background for legibility on busy maps.
   2918       // (We still tint the text itself via normalizeReadableColor().)
   2919       const rgb = parseHexColor(textHex);
   2920       if (!rgb) return "rgba(10,9,14,0.80)";
   2921       return "rgba(10,9,14,0.80)";
   2922     }
   2923 
   2924     function getAvatarImage(username, url) {
   2925       const u = String(username || "").toLowerCase();
   2926       if (!u) return null;
   2927       const src = String(url || "").trim();
   2928       if (!src) return null;
   2929       const now = Date.now();
   2930       const cached = avatarCache.get(u);
   2931       if (cached && cached.src === src) {
   2932         if (cached.status === "ok" && cached.img) return cached.img;
   2933         if (cached.status === "loading") return null;
   2934         if (cached.status === "error" && now - Number(cached.failedAt || 0) < 5000) return null;
   2935       }
   2936       const img = new Image();
   2937       if (!src.startsWith("data:")) img.crossOrigin = "anonymous";
   2938       avatarCache.set(u, { src, img: null, status: "loading", failedAt: 0 });
   2939       img.onload = () => avatarCache.set(u, { src, img, status: "ok", failedAt: 0 });
   2940       img.onerror = () => avatarCache.set(u, { src, img: null, status: "error", failedAt: Date.now() });
   2941       img.src = src;
   2942       return null;
   2943     }
   2944 
   2945     function roundRect(g, x, y, w, h, r) {
   2946       const rr = Math.max(0, Math.min(r, Math.min(w, h) / 2));
   2947       g.beginPath();
   2948       g.moveTo(x + rr, y);
   2949       g.arcTo(x + w, y, x + w, y + h, rr);
   2950       g.arcTo(x + w, y + h, x, y + h, rr);
   2951       g.arcTo(x, y + h, x, y, rr);
   2952       g.arcTo(x, y, x + w, y, rr);
   2953       g.closePath();
   2954     }
   2955 
   2956     function escapeHtml(s) {
   2957       return String(s || "")
   2958         .replace(/&/g, "&amp;")
   2959         .replace(/</g, "&lt;")
   2960         .replace(/>/g, "&gt;")
   2961         .replace(/\"/g, "&quot;")
   2962         .replace(/'/g, "&#39;");
   2963     }
   2964 
   2965     function enterMap(mapId) {
   2966       mode = "map";
   2967       users.clear();
   2968       bubbles.clear();
   2969       editMode = false;
   2970       draftPoly = [];
   2971       polyDrag = null;
   2972       vertexDrag = null;
   2973       selectedPolyKind = "";
   2974       selectedPolyIndex = -1;
   2975       selectedVertexIndex = -1;
   2976       selfInvisible = false;
   2977       speakingAsPropId = "";
   2978       ttrpgDockCollapsed = readDockCollapsed(mapId);
   2979       ttrpgTool = "select";
   2980       cameraPos = null;
   2981       // Seed a known-good local position (will be replaced once we get roomState).
   2982       localPos = { x: 0.5, y: 0.5 };
   2983       exitInside.clear();
   2984       activeMap =
   2985         maps.find((m) => m.id === mapId) || {
   2986           id: mapId,
   2987           title: mapId,
   2988           owner: "",
   2989           backgroundUrl: "",
   2990           thumbUrl: "",
   2991           userCount: 0,
   2992           avatarSize: 36,
   2993           cameraZoom: 2.35,
   2994           collisions: [],
   2995           masks: [],
   2996           exits: [],
   2997           hiddenMasks: [],
   2998           occluders: [],
   2999           ttrpgEnabled: false,
   3000           sprites: [],
   3001           props: [],
   3002           walkiesEnabled: false
   3003         };
   3004       selectedPropId = "";
   3005       renderMapView();
   3006       ctx.send("join", { mapId });
   3007     }
   3008 
   3009     function leaveMap() {
   3010       ctx.send("leave", {});
   3011       mode = "maps";
   3012       activeMap = null;
   3013       speakingAsPropId = "";
   3014       if (appRoot) appRoot.classList.remove("mapsRoom");
   3015       if (chatPanel) chatPanel.classList.remove("hidden");
   3016       if (chatResizeHandle) chatResizeHandle.classList.remove("hidden");
   3017       stopWalkie();
   3018       stopAllWalkies();
   3019       users.clear();
   3020       bubbles.clear();
   3021       keys.clear();
   3022       stopLoop();
   3023       renderMapsList();
   3024     }
   3025 
   3026     if (mapsBtn) {
   3027       mapsBtn.addEventListener("click", () => {
   3028         if (mode === "hives") enterMaps();
   3029         else exitMapsToHives();
   3030       });
   3031     }
   3032 
   3033     mapsPanel.addEventListener("click", (e) => {
   3034       const enter = e.target.closest("[data-mapenter]");
   3035       if (enter) {
   3036         const id = String(enter.getAttribute("data-mapenter") || "");
   3037         if (id) enterMap(id);
   3038         return;
   3039       }
   3040       const del = e.target.closest("[data-mapdelete]");
   3041       if (del) {
   3042         const id = String(del.getAttribute("data-mapdelete") || "");
   3043         if (!id) return;
   3044         const ok = window.confirm(`Delete map "${id}"? This cannot be undone.`);
   3045         if (!ok) return;
   3046         ctx.send("deleteMap", { id });
   3047         return;
   3048       }
   3049       const back = e.target.closest("[data-mapback]");
   3050       if (back) {
   3051         leaveMap();
   3052         return;
   3053       }
   3054     });
   3055 
   3056     function setChatOverlayOpen(open) {
   3057       const overlay = document.getElementById("mapsChatOverlay");
   3058       const input = document.getElementById("mapsChatInput");
   3059       const send = document.getElementById("mapsChatSend");
   3060       const walkieBar = document.getElementById("mapsWalkieBar");
   3061       if (!overlay || !input || !send) return;
   3062       overlay.classList.toggle("hidden", !open);
   3063       if (walkieBar) walkieBar.classList.toggle("hidden", Boolean(open) || !Boolean(activeMap?.walkiesEnabled));
   3064       if (open) {
   3065         input.value = "";
   3066         input.focus();
   3067       } else {
   3068         input.blur();
   3069       }
   3070       send.onclick = () => {
   3071         const text = String(input.value || "").trim();
   3072         if (!text) return;
   3073         const me = String(ctx.getUser() || "").trim().toLowerCase();
   3074         const actorPropId = speakingAsPropId ? String(speakingAsPropId) : "";
   3075         if (actorPropId) bubbles.set(`token:${actorPropId}`, { text: text.slice(0, 120), actorType: "token", actorPropId, expiresAt: Date.now() + 4000 });
   3076         else if (me) bubbles.set(`user:${me}`, { text: text.slice(0, 120), actorType: "user", username: me, expiresAt: Date.now() + 4000 });
   3077         ctx.send("say", { text, actorPropId });
   3078         setChatOverlayOpen(false);
   3079       };
   3080       input.onkeydown = (ev) => {
   3081         if (ev.key === "Escape") {
   3082           ev.preventDefault();
   3083           setChatOverlayOpen(false);
   3084         }
   3085         if (ev.key === "Enter") {
   3086           ev.preventDefault();
   3087           const text = String(input.value || "").trim();
   3088           if (!text) return;
   3089           const me = String(ctx.getUser() || "").trim().toLowerCase();
   3090           const actorPropId = speakingAsPropId ? String(speakingAsPropId) : "";
   3091           if (actorPropId) bubbles.set(`token:${actorPropId}`, { text: text.slice(0, 120), actorType: "token", actorPropId, expiresAt: Date.now() + 4000 });
   3092           else if (me) bubbles.set(`user:${me}`, { text: text.slice(0, 120), actorType: "user", username: me, expiresAt: Date.now() + 4000 });
   3093           ctx.send("say", { text, actorPropId });
   3094           setChatOverlayOpen(false);
   3095         }
   3096       };
   3097     }
   3098 
   3099     window.addEventListener("keydown", (e) => {
   3100       if (mode !== "map") return;
   3101       // This is a user gesture; try to unlock audio playback early.
   3102       ensureAudioReady();
   3103       const overlay = document.getElementById("mapsChatOverlay");
   3104       const overlayOpen = overlay && !overlay.classList.contains("hidden");
   3105       if (editMode) {
   3106         if (e.key === "Escape") {
   3107           draftPoly = [];
   3108           polyDrag = null;
   3109           vertexDrag = null;
   3110           const se = document.getElementById("mapsPolyStatus");
   3111           if (se) se.textContent = "Draft cleared.";
   3112           renderMapView();
   3113           return;
   3114         }
   3115         if (e.key === "Delete" || e.key === "Backspace") {
   3116           const list = polysForKind(activeMap, editKind);
   3117           const ok = selectedPolyKind === editKind && selectedPolyIndex >= 0 && selectedPolyIndex < list.length;
   3118           if (!ok) return;
   3119           e.preventDefault();
   3120           list.splice(selectedPolyIndex, 1);
   3121           selectedPolyKind = "";
   3122           selectedPolyIndex = -1;
   3123           selectedVertexIndex = -1;
   3124           renderMapView();
   3125           return;
   3126         }
   3127         if ((e.ctrlKey || e.metaKey) && (e.key === "c" || e.key === "C")) {
   3128           const list = polysForKind(activeMap, editKind);
   3129           const ok = selectedPolyKind === editKind && selectedPolyIndex >= 0 && selectedPolyIndex < list.length;
   3130           if (!ok) return;
   3131           e.preventDefault();
   3132           polyClipboard = { kind: editKind, poly: JSON.parse(JSON.stringify(list[selectedPolyIndex])) };
   3133           renderMapView();
   3134           return;
   3135         }
   3136         if ((e.ctrlKey || e.metaKey) && (e.key === "v" || e.key === "V")) {
   3137           if (!polyClipboard || !polyClipboard.poly) return;
   3138           e.preventDefault();
   3139           const list = polysForKind(activeMap, editKind, true);
   3140           const copy = JSON.parse(JSON.stringify(polyClipboard.poly));
   3141           const pts = Array.isArray(copy.points) ? copy.points : [];
   3142           for (const p of pts) {
   3143             p.x = Math.max(0, Math.min(1, Number(p.x || 0) + 0.02));
   3144             p.y = Math.max(0, Math.min(1, Number(p.y || 0) + 0.02));
   3145           }
   3146           copy.points = pts;
   3147           list.push(copy);
   3148           selectedPolyKind = editKind;
   3149           selectedPolyIndex = list.length - 1;
   3150           selectedVertexIndex = -1;
   3151           renderMapView();
   3152           return;
   3153         }
   3154         // Don't move / chat while editing.
   3155         return;
   3156       }
   3157       if (activeMap?.walkiesEnabled && !overlayOpen && !editMode && e.code === "Backquote") {
   3158         e.preventDefault();
   3159         startWalkie().catch((err) => ctx.toast("Walkie", String(err?.message || err)));
   3160         const btn = document.getElementById("mapsWalkieBtn");
   3161         if (btn) btn.textContent = "Recording…";
   3162         return;
   3163       }
   3164       if (e.code === "KeyT" && !overlayOpen) {
   3165         e.preventDefault();
   3166         setChatOverlayOpen(true);
   3167         return;
   3168       }
   3169       if (!overlayOpen && !editMode && activeMap?.ttrpgEnabled && canManageTtrpg) {
   3170         if (e.code === "KeyV") {
   3171           e.preventDefault();
   3172           ttrpgTool = "select";
   3173           renderMapView();
   3174           return;
   3175         }
   3176         if (e.code === "KeyP") {
   3177           e.preventDefault();
   3178           ttrpgTool = "place";
   3179           renderMapView();
   3180           return;
   3181         }
   3182         if (e.code === "Space") {
   3183           e.preventDefault();
   3184           ttrpgTool = "pan";
   3185           renderMapView();
   3186           return;
   3187         }
   3188       }
   3189       if (!overlayOpen && !editMode && activeMap?.ttrpgEnabled && canManageTtrpg && (e.code === "KeyQ" || e.code === "KeyE")) {
   3190         e.preventDefault();
   3191         const step = e.shiftKey ? 45 : 15;
   3192         const dir = e.code === "KeyQ" ? -1 : 1;
   3193         const wrapRot = (deg) => {
   3194           let d = Number(deg || 0);
   3195           if (!Number.isFinite(d)) d = 0;
   3196           while (d > 180) d -= 360;
   3197           while (d < -180) d += 360;
   3198           return d;
   3199         };
   3200         const props = Array.isArray(activeMap?.props) ? activeMap.props : [];
   3201         const pidx = selectedPropId ? props.findIndex((p) => String(p?.id || "") === selectedPropId) : -1;
   3202         if (pidx >= 0) {
   3203           const p = props[pidx];
   3204           const nextRot = wrapRot(Number(p?.rot || 0) + step * dir);
   3205           props[pidx] = { ...p, rot: nextRot };
   3206           activeMap.props = props;
   3207           ctx.send("ttrpgPropMove", { mapId: activeMap.id, propId: selectedPropId, x: p.x, y: p.y, z: p.z || 0, rot: nextRot, scale: p.scale || 1 });
   3208           return;
   3209         }
   3210         if (selectedSpriteId) {
   3211           placeRot = wrapRot(placeRot + step * dir);
   3212           renderTtrpgDock();
   3213           return;
   3214         }
   3215       }
   3216       if (!overlayOpen && !editMode && activeMap?.ttrpgEnabled && canManageTtrpg && (e.code === "KeyZ" || e.code === "KeyX")) {
   3217         e.preventDefault();
   3218         const delta = e.shiftKey ? 0.2 : 0.1;
   3219         const dir = e.code === "KeyZ" ? -1 : 1;
   3220         const props = Array.isArray(activeMap?.props) ? activeMap.props : [];
   3221         const pidx = selectedPropId ? props.findIndex((p) => String(p?.id || "") === selectedPropId) : -1;
   3222         if (pidx >= 0) {
   3223           const p = props[pidx];
   3224           const nextScale = Math.max(0.1, Math.min(4.0, Number(p?.scale || 1) + delta * dir));
   3225           props[pidx] = { ...p, scale: nextScale };
   3226           activeMap.props = props;
   3227           ctx.send("ttrpgPropMove", { mapId: activeMap.id, propId: selectedPropId, x: p.x, y: p.y, z: p.z || 0, rot: p.rot || 0, scale: nextScale });
   3228           renderTtrpgDock();
   3229           return;
   3230         }
   3231         if (selectedSpriteId) {
   3232           placeScale = Math.max(0.1, Math.min(4.0, Number(placeScale || 1) + delta * dir));
   3233           renderTtrpgDock();
   3234           return;
   3235         }
   3236       }
   3237       if (overlayOpen) return;
   3238       if (["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "KeyW", "KeyA", "KeyS", "KeyD"].includes(e.code)) {
   3239         keys.add(e.code);
   3240       }
   3241     });
   3242     window.addEventListener("keyup", (e) => {
   3243       if (mode !== "map") return;
   3244       keys.delete(e.code);
   3245       if (activeMap?.ttrpgEnabled && canManageTtrpg && e.code === "Space" && ttrpgTool === "pan") {
   3246         ttrpgTool = "select";
   3247         renderMapView();
   3248       }
   3249       if (activeMap?.walkiesEnabled && e.code === "Backquote") {
   3250         stopWalkie();
   3251         const btn = document.getElementById("mapsWalkieBtn");
   3252         if (btn) btn.textContent = "Hold to talk";
   3253       }
   3254     });
   3255 
   3256     ws.addEventListener("message", (evt) => {
   3257       let msg;
   3258       try {
   3259         msg = JSON.parse(evt.data);
   3260       } catch {
   3261         return;
   3262       }
   3263       if (!msg || typeof msg !== "object") return;
   3264       const type = String(msg.type || "");
   3265 
   3266       if (type === "plugin:maps:mapsList") {
   3267         maps = Array.isArray(msg.maps) ? msg.maps : [];
   3268         if (mode === "maps") renderMapsList();
   3269         return;
   3270       }
   3271 
   3272       if (type === "plugin:maps:joinOk") {
   3273         self = String(ctx.getUser() || "").trim().toLowerCase();
   3274         selfInvisible = Boolean(msg.selfInvisible);
   3275         if (msg.map && typeof msg.map === "object") {
   3276           activeMap = {
   3277             id: String(msg.map.id || "").trim().toLowerCase(),
   3278             title: String(msg.map.title || "").trim(),
   3279             owner: String(msg.map.owner || "").trim().toLowerCase(),
   3280             backgroundUrl: String(msg.map.backgroundUrl || "").trim(),
   3281             world: msg.map.world || null,
   3282             avatarSize: Number(msg.map.avatarSize || 36) || 36,
   3283             cameraZoom: Number(msg.map.cameraZoom || 2.35) || 2.35,
   3284             collisions: Array.isArray(msg.map.collisions) ? msg.map.collisions : [],
   3285             masks: Array.isArray(msg.map.masks) ? msg.map.masks : [],
   3286             exits: Array.isArray(msg.map.exits) ? msg.map.exits : [],
   3287             hiddenMasks: Array.isArray(msg.map.hiddenMasks) ? msg.map.hiddenMasks : [],
   3288             occluders: Array.isArray(msg.map.occluders) ? msg.map.occluders : [],
   3289             ttrpgEnabled: Boolean(msg.map.ttrpgEnabled),
   3290             sprites: Array.isArray(msg.map.sprites) ? msg.map.sprites : [],
   3291             props: Array.isArray(msg.map.props) ? msg.map.props : [],
   3292             walkiesEnabled: Boolean(msg.map.walkiesEnabled)
   3293           };
   3294           ttrpgDockCollapsed = readDockCollapsed(activeMap.id);
   3295           if (pendingSpawn && pendingSpawn.mapId === activeMap.id && pendingSpawn.exitName) {
   3296             const exits = Array.isArray(activeMap.exits) ? activeMap.exits : [];
   3297             const want = String(pendingSpawn.exitName || "").trim().toLowerCase();
   3298             const target = exits.find((ex) => String(ex?.name || "").trim().toLowerCase() === want);
   3299             if (target && Array.isArray(target.points) && target.points.length) {
   3300               const c = polyCentroid(target.points);
   3301               localPos = { x: c.x, y: c.y };
   3302               lastExitAt = Date.now();
   3303               try {
   3304                 ctx.send("move", { x: c.x, y: c.y, seq: moveSeq++ });
   3305               } catch {
   3306                 // ignore
   3307               }
   3308             }
   3309             pendingSpawn = null;
   3310           }
   3311           renderMapView();
   3312         }
   3313         return;
   3314       }
   3315 
   3316       if (type === "plugin:maps:mapPatched") {
   3317         if (mode !== "map") return;
   3318         const mapId = String(msg.mapId || "").trim().toLowerCase();
   3319         if (!activeMap || mapId !== String(activeMap.id || "")) return;
   3320         const patch = msg.patch && typeof msg.patch === "object" ? msg.patch : null;
   3321         if (!patch) return;
   3322         if (Object.prototype.hasOwnProperty.call(patch, "avatarSize")) activeMap.avatarSize = Number(patch.avatarSize || 36) || 36;
   3323         if (Object.prototype.hasOwnProperty.call(patch, "cameraZoom")) activeMap.cameraZoom = Number(patch.cameraZoom || 2.35) || 2.35;
   3324         if (Object.prototype.hasOwnProperty.call(patch, "walkiesEnabled")) activeMap.walkiesEnabled = Boolean(patch.walkiesEnabled);
   3325         if (Object.prototype.hasOwnProperty.call(patch, "collisions")) activeMap.collisions = Array.isArray(patch.collisions) ? patch.collisions : [];
   3326         if (Object.prototype.hasOwnProperty.call(patch, "masks")) activeMap.masks = Array.isArray(patch.masks) ? patch.masks : [];
   3327         if (Object.prototype.hasOwnProperty.call(patch, "exits")) activeMap.exits = Array.isArray(patch.exits) ? patch.exits : [];
   3328         if (Object.prototype.hasOwnProperty.call(patch, "hiddenMasks")) activeMap.hiddenMasks = Array.isArray(patch.hiddenMasks) ? patch.hiddenMasks : [];
   3329         if (Object.prototype.hasOwnProperty.call(patch, "occluders")) activeMap.occluders = Array.isArray(patch.occluders) ? patch.occluders : [];
   3330         renderMapView();
   3331         return;
   3332       }
   3333 
   3334       if (type === "plugin:maps:selfInvisible") {
   3335         if (mode !== "map") return;
   3336         const mapId = String(msg.mapId || "").trim().toLowerCase();
   3337         if (!activeMap || mapId !== String(activeMap.id || "")) return;
   3338         selfInvisible = Boolean(msg.invisible);
   3339         renderMapView();
   3340         return;
   3341       }
   3342 
   3343       if (type === "plugin:maps:roomState") {
   3344         if (mode !== "map") return;
   3345         const list = Array.isArray(msg.users) ? msg.users : [];
   3346         const next = new Map();
   3347         for (const raw of list) {
   3348           const name = String(raw?.username || "").toLowerCase();
   3349           if (!name) continue;
   3350           const tx = Number(raw?.x || 0);
   3351           const ty = Number(raw?.y || 0);
   3352           const prev = users.get(name) || { x: tx, y: ty, tx, ty, color: "", image: "" };
   3353           prev.tx = tx;
   3354           prev.ty = ty;
   3355           // Initialize render position on first sight.
   3356           if (typeof prev.x !== "number" || typeof prev.y !== "number") {
   3357             prev.x = tx;
   3358             prev.y = ty;
   3359           }
   3360           prev.color = raw?.color || prev.color || "";
   3361           prev.image = raw?.image || prev.image || "";
   3362           next.set(name, prev);
   3363         }
   3364         users = next;
   3365         const me = (self || String(ctx.getUser() || "")).trim().toLowerCase();
   3366         const mine = me ? users.get(me) : null;
   3367         if (mine) {
   3368           // Keep local prediction, but if we're brand new, seed from server once.
   3369           if (!Number.isFinite(localPos?.x) || !Number.isFinite(localPos?.y)) localPos = { x: Number(mine.tx || 0.5), y: Number(mine.ty || 0.5) };
   3370         }
   3371         renderMapView();
   3372         return;
   3373       }
   3374 
   3375       if (type === "plugin:maps:userMoved") {
   3376         if (mode !== "map") return;
   3377         const username = String(msg.username || "").toLowerCase();
   3378         if (!username) return;
   3379         const me = (self || String(ctx.getUser() || "")).trim().toLowerCase();
   3380         // Ignore self movement echoes to avoid jitter/snapback.
   3381         if (me && username === me) return;
   3382         const tx = Number(msg.x || 0);
   3383         const ty = Number(msg.y || 0);
   3384         const prev = users.get(username) || { x: tx, y: ty, tx, ty, color: "", image: "" };
   3385         prev.tx = tx;
   3386         prev.ty = ty;
   3387         if (typeof prev.x !== "number" || typeof prev.y !== "number") {
   3388           prev.x = tx;
   3389           prev.y = ty;
   3390         }
   3391         users.set(username, prev);
   3392         return;
   3393       }
   3394 
   3395       if (type === "plugin:maps:bubble") {
   3396         if (mode !== "map") return;
   3397         const username = String(msg.username || "").toLowerCase();
   3398         const actorType = String(msg.actorType || "user");
   3399         const actorPropId = String(msg.actorPropId || "");
   3400         const text = String(msg.text || "").trim();
   3401         if (!username || !text) return;
   3402         const bubbleKey = actorType === "token" && actorPropId ? `token:${actorPropId}` : `user:${username}`;
   3403         bubbles.set(bubbleKey, {
   3404           text: text.slice(0, 120),
   3405           actorType: actorType === "token" ? "token" : "user",
   3406           actorPropId,
   3407           username,
   3408           displayName: String(msg.displayName || ""),
   3409           color: String(msg.color || ""),
   3410           expiresAt: Date.now() + 4000
   3411         });
   3412         return;
   3413       }
   3414 
   3415       if (type === "plugin:maps:walkie") {
   3416         if (mode !== "map") return;
   3417         playWalkie(msg);
   3418         return;
   3419       }
   3420 
   3421       if (type === "plugin:maps:ttrpgEnabled") {
   3422         if (mode !== "map") return;
   3423         const mapId = String(msg.mapId || "").trim().toLowerCase();
   3424         if (!activeMap || mapId !== String(activeMap.id || "")) return;
   3425         activeMap.ttrpgEnabled = Boolean(msg.enabled);
   3426         if (!activeMap.ttrpgEnabled) {
   3427           selectedSpriteId = "";
   3428         }
   3429         renderMapView();
   3430         return;
   3431       }
   3432 
   3433       if (type === "plugin:maps:spriteAdded") {
   3434         if (mode !== "map") return;
   3435         const mapId = String(msg.mapId || "").trim().toLowerCase();
   3436         if (!activeMap || mapId !== String(activeMap.id || "")) return;
   3437         const sprite = msg.sprite && typeof msg.sprite === "object" ? msg.sprite : null;
   3438         if (!sprite || !sprite.id) return;
   3439         if (!Array.isArray(activeMap.sprites)) activeMap.sprites = [];
   3440         activeMap.sprites = [...activeMap.sprites.filter((s) => String(s?.id || "") !== String(sprite.id)), sprite];
   3441         if (canManageTtrpg && !selectedSpriteId) {
   3442           const k = spriteKind === "token" ? "token" : "prop";
   3443           if ((sprite.kind || "prop") === k) selectedSpriteId = String(sprite.id);
   3444         }
   3445         renderTtrpgDock();
   3446         return;
   3447       }
   3448 
   3449       if (type === "plugin:maps:spriteRemoved") {
   3450         if (mode !== "map") return;
   3451         const mapId = String(msg.mapId || "").trim().toLowerCase();
   3452         if (!activeMap || mapId !== String(activeMap.id || "")) return;
   3453         const spriteId = String(msg.spriteId || "");
   3454         if (!spriteId) return;
   3455         activeMap.sprites = (Array.isArray(activeMap.sprites) ? activeMap.sprites : []).filter((s) => String(s?.id || "") !== spriteId);
   3456         activeMap.props = (Array.isArray(activeMap.props) ? activeMap.props : []).filter((p) => String(p?.spriteId || "") !== spriteId);
   3457         if (selectedSpriteId === spriteId) selectedSpriteId = "";
   3458         selectedPropId = "";
   3459         renderTtrpgDock();
   3460         return;
   3461       }
   3462 
   3463       if (type === "plugin:maps:propsReset") {
   3464         if (mode !== "map") return;
   3465         const mapId = String(msg.mapId || "").trim().toLowerCase();
   3466         if (!activeMap || mapId !== String(activeMap.id || "")) return;
   3467         activeMap.props = Array.isArray(msg.props) ? msg.props : [];
   3468         if (speakingAsPropId && !activeMap.props.some((p) => String(p?.id || "") === String(speakingAsPropId))) speakingAsPropId = "";
   3469         selectedPropId = "";
   3470         renderTtrpgDock();
   3471         return;
   3472       }
   3473 
   3474       if (type === "plugin:maps:propAdded") {
   3475         if (mode !== "map") return;
   3476         const mapId = String(msg.mapId || "").trim().toLowerCase();
   3477         if (!activeMap || mapId !== String(activeMap.id || "")) return;
   3478         const prop = msg.prop && typeof msg.prop === "object" ? msg.prop : null;
   3479         if (!prop || !prop.id) return;
   3480         if (!Array.isArray(activeMap.props)) activeMap.props = [];
   3481         activeMap.props = [...activeMap.props.filter((p) => String(p?.id || "") !== String(prop.id)), prop];
   3482         renderTtrpgDock();
   3483         return;
   3484       }
   3485 
   3486       if (type === "plugin:maps:propMoved") {
   3487         if (mode !== "map") return;
   3488         const mapId = String(msg.mapId || "").trim().toLowerCase();
   3489         if (!activeMap || mapId !== String(activeMap.id || "")) return;
   3490         const propId = String(msg.propId || "");
   3491         if (!propId) return;
   3492         const props = Array.isArray(activeMap.props) ? activeMap.props : [];
   3493         const idx = props.findIndex((p) => String(p?.id || "") === propId);
   3494         if (idx < 0) return;
   3495         props[idx] = {
   3496           ...props[idx],
   3497           x: Number(msg.x || 0),
   3498           y: Number(msg.y || 0),
   3499           z: Number(msg.z || props[idx]?.z || 0),
   3500           rot: Object.prototype.hasOwnProperty.call(msg || {}, "rot") ? Number(msg.rot || 0) : Number(props[idx]?.rot || 0),
   3501           scale: Object.prototype.hasOwnProperty.call(msg || {}, "scale") ? Number(msg.scale || 1) : Number(props[idx]?.scale || 1)
   3502         };
   3503         activeMap.props = props;
   3504         if (selectedPropId === propId) renderTtrpgDock();
   3505         return;
   3506       }
   3507 
   3508       if (type === "plugin:maps:propRemoved") {
   3509         if (mode !== "map") return;
   3510         const mapId = String(msg.mapId || "").trim().toLowerCase();
   3511         if (!activeMap || mapId !== String(activeMap.id || "")) return;
   3512         const propId = String(msg.propId || "");
   3513         if (!propId) return;
   3514         activeMap.props = (Array.isArray(activeMap.props) ? activeMap.props : []).filter((p) => String(p?.id || "") !== propId);
   3515         if (selectedPropId === propId) selectedPropId = "";
   3516         if (speakingAsPropId === propId) speakingAsPropId = "";
   3517         renderTtrpgDock();
   3518         return;
   3519       }
   3520 
   3521       if (type === "plugin:maps:propPatched") {
   3522         if (mode !== "map") return;
   3523         const mapId = String(msg.mapId || "").trim().toLowerCase();
   3524         if (!activeMap || mapId !== String(activeMap.id || "")) return;
   3525         const prop = msg.prop && typeof msg.prop === "object" ? msg.prop : null;
   3526         if (!prop || !prop.id) return;
   3527         const props = Array.isArray(activeMap.props) ? activeMap.props : [];
   3528         const idx = props.findIndex((p) => String(p?.id || "") === String(prop.id || ""));
   3529         if (idx >= 0) props[idx] = { ...props[idx], ...prop };
   3530         else props.push(prop);
   3531         activeMap.props = props;
   3532         if (speakingAsPropId && String(prop.id || "") === String(speakingAsPropId || "")) {
   3533           const controller = String(prop.controlledBy || "").trim().toLowerCase();
   3534           const me = String(ctx.getUser() || "").trim().toLowerCase();
   3535           if (controller && controller !== me) speakingAsPropId = "";
   3536         }
   3537         renderTtrpgDock();
   3538         return;
   3539       }
   3540 
   3541       if (type === "plugin:maps:error") {
   3542         const message = String(msg.message || "Maps error.");
   3543         ctx.toast("Maps", message);
   3544         return;
   3545       }
   3546     });
   3547 
   3548     if (inRackMode) {
   3549       // In rack mode, Maps is its own panel: start in the list view immediately.
   3550       enterMaps();
   3551     } else {
   3552       // Initial list request (in case the Maps view is opened immediately).
   3553       // The Maps panel triggers another list() on open.
   3554       ctx.send("list", {});
   3555     }
   3556   });
   3557 })();
   3558