bzl

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

client.js (272164B)


      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 devLog = (level, message, data) => {
      9       try {
     10         ctx.devLog?.(level, message, data);
     11       } catch {
     12         // ignore
     13       }
     14     };
     15 
     16     const appRootRef = document.querySelector(".app");
     17     const inRackMode = (() => {
     18       try {
     19         // Rack mode reloads the page; this flag is available before the DOM gets the .rackMode class.
     20         if (localStorage.getItem("bzl_rackLayout_enabled") === "1") return true;
     21       } catch {
     22         // ignore
     23       }
     24       return Boolean(appRootRef?.classList.contains("rackMode"));
     25     })();
     26 
     27     // In rack mode, Maps should render into its own dockable panel (not inside the Hives panel).
     28     if (inRackMode && ctx?.ui?.registerPanel) {
     29       try {
     30         ctx.ui.registerPanel({
     31           id: "maps",
     32           title: "Maps",
     33           icon: "🗺️",
     34           defaultRack: "main",
     35           role: "primary",
     36           render() {
     37             // no-op: this plugin uses DOM mounting below, into the panel shell's mount node
     38           },
     39         });
     40       } catch {
     41         // ignore
     42       }
     43     }
     44 
     45     let mainPanel = document.querySelector(".main .panelFill");
     46     if (inRackMode) {
     47       const shell = document.querySelector('.panel.pluginPanel[data-panel-id="maps"]');
     48       if (shell instanceof HTMLElement) mainPanel = shell;
     49     }
     50 
     51     const panelHeader = mainPanel ? mainPanel.querySelector(".panelHeader") : null;
     52     const panelTitle = panelHeader ? panelHeader.querySelector(".panelTitle") : null;
     53     const filters = panelHeader ? panelHeader.querySelector(".filters") : null;
     54     const hiveTabs = inRackMode ? null : document.getElementById("hiveTabs");
     55     const feed = inRackMode ? null : document.getElementById("feed");
     56     const pollinatePanel = inRackMode ? null : document.getElementById("pollinatePanel");
     57     const chatPanel = inRackMode ? null : document.querySelector(".chat");
     58     const chatResizeHandle = inRackMode ? null : document.getElementById("chatResizeHandle");
     59     const appRoot = inRackMode ? null : appRootRef;
     60 
     61     if (!mainPanel || !panelHeader || !panelTitle) return;
     62 
     63     const style = document.createElement("style");
     64     style.textContent = `
     65       .mapsTabBtn { margin-left: 10px; }
     66       .mapsPanel.hidden { display: none; }
     67       .mapsPanel { flex: 1; min-height: 0; overflow-y: auto; overflow-x: hidden; display:flex; flex-direction: column; }
     68       /* Keep core resize handles working in map mode by preserving grid areas. */
     69       @media (min-width: 761px) {
     70         .app.mapsRoom {
     71           grid-template-columns: minmax(240px, var(--sidebar-width)) 10px 1fr !important;
     72           grid-template-areas: "sidebar sidebarResize main" !important;
     73         }
     74         .app.mapsRoom.hasMod {
     75           grid-template-columns: minmax(240px, var(--sidebar-width)) 10px 1fr 10px minmax(280px, var(--mod-width)) !important;
     76           grid-template-areas: "sidebar sidebarResize main mainResize moderation" !important;
     77         }
     78         .app.mapsRoom.sidebarHidden {
     79           grid-template-columns: 1fr !important;
     80           grid-template-areas: "main" !important;
     81         }
     82         .app.mapsRoom.sidebarHidden.hasMod {
     83           grid-template-columns: 1fr 10px minmax(280px, var(--mod-width)) !important;
     84           grid-template-areas: "main mainResize moderation" !important;
     85         }
     86       }
     87       .mapsTop { padding: 12px 12px 0; display:flex; justify-content: space-between; align-items: center; gap: 10px; }
     88       .mapsTopTitle { font-weight: 900; }
     89       .mapCreateWrap { padding: 12px; }
     90       .mapCreateCard { border: 1px solid rgba(246,240,255,0.14); border-radius: 14px; background: rgba(255,255,255,0.02); padding: 10px; }
     91       .mapCreateGrid { display:grid; grid-template-columns: 1fr 1fr; gap: 10px; align-items: end; }
     92       .mapCreateGrid label span { display:block; font-size: 12px; color: rgba(246,240,255,0.72); margin-bottom: 4px; }
     93       .mapCreateGrid input[type="text"] { width: 100%; }
     94       .mapCreateRow { display:flex; gap: 10px; align-items: center; justify-content: flex-end; margin-top: 10px; }
     95       .mapRangeRow { display:flex; gap: 10px; align-items: center; }
     96       .mapRangeRow input[type="range"] { flex: 1; }
     97       .mapRangeVal { width: 44px; text-align: right; font-variant-numeric: tabular-nums; }
     98       .mapsGrid { display:grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 12px; padding: 12px; }
     99       .mapCard { border: 1px solid rgba(246,240,255,0.14); border-radius: 14px; background: rgba(255,255,255,0.02); padding: 10px; }
    100       .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); }
    101       .mapTitle { font-weight: 800; margin-top: 8px; }
    102       .mapMeta { margin-top: 6px; color: rgba(246,240,255,0.72); font-size: 12px; display:flex; justify-content: space-between; gap: 10px; }
    103       .mapEnterRow { margin-top: 10px; display:flex; justify-content:flex-end; gap: 8px; }
    104       .mapView { display:flex; gap: 12px; padding: 12px; min-height: 0; flex: 1; align-items: stretch; }
    105       .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; }
    106       .mapCanvas { width: 100%; height: 100%; display:block; }
    107       .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; height: 100%; max-height: 100%; overflow-y: auto; overflow-x: hidden; }
    108       .mapHudTitle { font-weight: 800; display:flex; justify-content: space-between; align-items:center; gap: 8px; }
    109       .mapHudList { margin-top: 10px; display:flex; flex-direction: column; gap: 8px; max-height: 340px; overflow:auto; }
    110       .mapHint { margin-top: 10px; color: rgba(246,240,255,0.72); font-size: 12px; line-height: 1.05rem; }
    111       .mapChatOverlay {
    112         position:absolute;
    113         left: 12px;
    114         bottom: 12px;
    115         width: min(560px, calc(100% - 24px));
    116         display:flex;
    117         flex-direction: column;
    118         gap: 8px;
    119         padding: 10px;
    120         border: 1px solid rgba(246,240,255,0.16);
    121         border-radius: 12px;
    122         background: rgba(10,10,18,var(--maps-chat-overlay-alpha,0.92));
    123         backdrop-filter: blur(6px);
    124         z-index: 9;
    125       }
    126       .mapChatOverlay.raiseForWalkie { bottom: 68px; }
    127       .mapChatOverlay.raiseForHotbar { bottom: 126px; }
    128       .mapChatOverlay input { flex:1; }
    129       .mapChatToolbar { display:flex; align-items:center; gap: 8px; flex-wrap: wrap; }
    130       .mapChatDragHandle { cursor: move; user-select: none; touch-action: none; }
    131       .mapChatScopeRow { display:flex; gap: 6px; }
    132       .mapChatOpacity { display:flex; align-items:center; gap: 6px; margin-left: auto; min-width: 132px; }
    133       .mapChatOpacity input[type="range"] { flex: 1; }
    134       .mapChatFeed { min-height: 120px; max-height: min(38vh, 320px); overflow: auto; border: 1px solid rgba(246,240,255,0.12); border-radius: 10px; padding: 8px; background: rgba(0,0,0,0.16); display:flex; flex-direction:column; gap: 6px; }
    135       .mapChatFeedItem { border: 1px solid rgba(246,240,255,0.10); border-radius: 9px; padding: 6px 8px; background: rgba(255,255,255,0.03); }
    136       .mapChatFeedMeta { display:flex; justify-content: space-between; gap: 8px; font-size: 11px; color: rgba(246,240,255,0.70); margin-bottom: 2px; }
    137       .mapChatFeedText { font-size: 13px; line-height: 1.25rem; color: rgba(246,240,255,0.92); white-space: pre-wrap; word-break: break-word; }
    138       .mapWalkieBar { position:absolute; left: 12px; right: 12px; bottom: 12px; display:flex; justify-content:center; pointer-events:none; }
    139       .mapWalkieBarInner { pointer-events:auto; display:flex; gap: 10px; align-items:center; width: min(520px, 100%); }
    140       .mapWalkieBtn { flex: 1; height: 44px; border-radius: 14px; font-weight: 900; letter-spacing: 0.01em; }
    141       .mapWalkieHint { font-size: 12px; color: rgba(246,240,255,0.75); white-space: nowrap; }
    142       .mapsRoomWrap { display:flex; flex-direction: column; min-height: 0; flex: 1; }
    143       .mapCornerTools { position:absolute; top: 12px; right: 12px; display:flex; gap: 8px; z-index: 5; }
    144       .mapGmTopLeft { position:absolute; top: 12px; left: 12px; z-index: 5; display:flex; flex-direction:column; gap: 8px; }
    145       .mapModePill { display:inline-flex; align-items:center; gap: 8px; padding: 6px 10px; border-radius: 999px; border:1px solid rgba(246,240,255,0.18); background: rgba(10,10,18,0.72); font-size: 12px; }
    146       .mapGmHotbar { position:absolute; left: 12px; right: 12px; bottom: 12px; z-index: 5; display:flex; justify-content:center; pointer-events:none; }
    147       .mapGmHotbar.raiseForWalkie { bottom: 68px; }
    148       .mapGmHotbarInner { pointer-events:auto; display:flex; gap: 8px; padding: 8px; border:1px solid rgba(246,240,255,0.14); border-radius: 14px; background: rgba(10,10,18,0.72); }
    149       .mapGmHotbarInner .smallBtn { min-width: 84px; }
    150       .mapInspectorDrawer { position:absolute; top: 56px; right: 12px; z-index: 5; width: min(320px, calc(100% - 24px)); border:1px solid rgba(246,240,255,0.14); border-radius: 14px; background: rgba(10,10,18,0.92); padding: 10px; }
    151       .mapInspectorDrawer.hidden { display:none; }
    152       .mapInspectorTitle { font-weight: 900; margin-bottom: 6px; }
    153       .mapsPanel.focusMode .mapView { gap: 0; padding: 0; }
    154       .mapsPanel.focusMode .mapCanvasWrap { border-radius: 0; border-left: 0; border-right: 0; }
    155       .mapsPanel.focusMode .mapHud { display: none; }
    156       .mapsPanel.focusMode .mapDock { display: none; }
    157       .mapsPanel.cinematicMode .mapView { gap: 0; padding: 0; }
    158       .mapsPanel.cinematicMode .mapCanvasWrap { border-radius: 0; border-left: 0; border-right: 0; }
    159       .mapsPanel.cinematicMode .mapHud,
    160       .mapsPanel.cinematicMode .mapDock,
    161       .mapsPanel.cinematicMode .mapGmTopLeft,
    162       .mapsPanel.cinematicMode .mapCornerTools,
    163       .mapsPanel.cinematicMode .mapInspectorDrawer,
    164       .mapsPanel.cinematicMode .mapGmHotbar,
    165       .mapsPanel.cinematicMode .mapWalkieBar,
    166       .mapsPanel.cinematicMode .mapChatOverlay { display: none !important; }
    167       .mapsAvatarEditorModal { position: fixed; inset: 0; z-index: 1500; background: rgba(6,5,12,0.68); display:flex; align-items:center; justify-content:center; padding: 16px; }
    168       .mapsAvatarEditorCard { width: min(920px, 96vw); max-height: 90vh; overflow:auto; border: 1px solid rgba(246,240,255,0.18); border-radius: 16px; background: linear-gradient(180deg, rgba(20,16,32,0.98), rgba(10,9,16,0.98)); padding: 14px; }
    169       .mapsAvatarEditorGrid { display:grid; grid-template-columns: 1fr 1fr; gap: 12px; }
    170       .mapsAvatarStates { border:1px solid rgba(246,240,255,0.14); border-radius: 12px; padding: 10px; background: rgba(255,255,255,0.02); }
    171       .mapsAvatarFrames { border:1px solid rgba(246,240,255,0.14); border-radius: 12px; padding: 10px; background: rgba(255,255,255,0.02); min-height: 180px; }
    172       .mapsAvatarFrameRow { display:flex; align-items:center; gap: 8px; border:1px solid rgba(246,240,255,0.10); border-radius: 10px; padding: 6px; margin-bottom: 6px; }
    173       .mapsAvatarFrameThumb { width: 40px; height: 40px; border-radius: 8px; object-fit: cover; border:1px solid rgba(246,240,255,0.15); }
    174       .mapsAvatarFrameName { flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size: 12px; color: rgba(246,240,255,0.82); }
    175       .mapsAvatarSheetGrid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; margin-top: 8px; }
    176       .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; }
    177       .mapDock.collapsed { max-height: none; }
    178       .mapDock.collapsed .dockBody { display:none; }
    179       .dockHeaderRow { margin-bottom: 0; }
    180       .dockBody { flex: 1; min-height: 0; overflow:auto; padding-right: 4px; padding-top: 8px; }
    181       .dockRow { display:flex; gap: 10px; align-items: center; margin-bottom: 8px; flex-wrap: wrap; }
    182       .dockTitle { font-weight: 900; }
    183       .dockRow input[type="file"] { flex: 1 1 240px; max-width: 360px; }
    184       .dockRow input[type="text"] { flex: 1 1 220px; min-width: 180px; }
    185       .dockRow input[type="number"] { width: 92px; }
    186       .dockScale { display:flex; gap: 8px; align-items:center; min-width: 160px; flex: 1 1 160px; }
    187       .dockScale input[type="range"] { flex: 1; }
    188       .dockScaleVal { width: 52px; text-align:right; font-variant-numeric: tabular-nums; color: rgba(246,240,255,0.78); }
    189       .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); }
    190       .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; }
    191       .spriteThumb img { width:100%; height:100%; object-fit: cover; display:block; }
    192       .spriteThumb.selected { outline: 2px solid rgba(255,62,165,0.80); }
    193 
    194       /* Polygon editor: render inline in the same panel as the toggle (no full-screen modal). */
    195       .mapsPolyModal { display:block; margin-top: 12px; }
    196       .mapsPolyModalInner { width: 100%; 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.35); padding: 14px; display:flex; flex-direction:column; }
    197       .mapsPolyHeader { display:flex; justify-content:space-between; gap: 14px; align-items:flex-start; }
    198       .mapsPolyTitle { font-weight: 900; font-size: 16px; }
    199       .mapsPolyGrid { margin-top: 12px; display:grid; grid-template-columns: 1fr; gap: 12px; min-height: 0; flex: 1; }
    200       .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; }
    201       .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; }
    202       .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; }
    203       .polyRowBtn:hover { border-color: rgba(246,240,255,0.18); background: rgba(255,255,255,0.03); }
    204       .polyRowBtn.selected { outline: 2px solid rgba(255,62,165,0.55); border-color: rgba(255,62,165,0.55); }
    205       .polyRowMain { font-weight: 800; }
    206       .polyRowMeta { margin-top: 2px; font-size: 12px; color: rgba(246,240,255,0.62); }
    207     `;
    208     document.head.appendChild(style);
    209 
    210     const mapsBtn = inRackMode
    211       ? null
    212       : (() => {
    213           const btn = document.createElement("button");
    214           btn.type = "button";
    215           btn.className = "ghost smallBtn mapsTabBtn";
    216           btn.textContent = "Maps";
    217           panelTitle.insertAdjacentElement("afterend", btn);
    218           return btn;
    219         })();
    220 
    221     const mapsPanel = document.createElement("div");
    222     mapsPanel.className = inRackMode ? "mapsPanel" : "mapsPanel hidden";
    223     const mount = inRackMode ? mainPanel.querySelector("[data-pluginmount]") : null;
    224     (mount || mainPanel).appendChild(mapsPanel);
    225 
    226     let mode = inRackMode ? "maps" : "hives"; // "hives" | "maps" | "map"
    227     let maps = [];
    228     let activeMap = null;
    229     let users = new Map(); // username -> {x,y,color,image}
    230     let bubbles = new Map(); // username -> {text, expiresAt}
    231     const avatarCache = new Map(); // username -> {src:string,img:HTMLImageElement|null,status:"loading"|"ok"|"error",failedAt:number}
    232     let self = "";
    233     let localPos = { x: 0.5, y: 0.5 };
    234     let keys = new Set();
    235     let raf = 0;
    236     let lastTick = 0;
    237     let lastSentAt = 0;
    238     let moveSeq = 1;
    239     let bgImg = null;
    240     let cameraPos = null; // {x,y} in normalized 0..1
    241     let createIdTouched = false;
    242     let mapAvatarSaveTimer = 0;
    243     let mapZoomSaveTimer = 0;
    244     let lastEditModeLogged = false;
    245     let lastPolyUiLogAt = 0;
    246     let editMode = false;
    247     let editKind = "collision"; // "collision" | "mask" | "exit" | "hidden" | "fall" | "occluder"
    248     let editTool = "draw"; // "draw" | "select" | "move" | "vertex"
    249     let selectedPolyKind = "";
    250     let selectedPolyIndex = -1;
    251     let selectedVertexIndex = -1;
    252     let polyClipboard = null; // { kind, poly }
    253     let polyDrag = null; // { kind, index, start:{x,y}, origPoints:[{x,y}] }
    254     let vertexDrag = null; // { kind, index, vIdx:number }
    255 
    256     // Exit metadata (used both for "new exit defaults" and for selected-exit edits)
    257     let exitAction = "toMaps"; // "toMaps" | "toMap"
    258     let exitTargetMapId = "";
    259     let exitTargetExitName = "";
    260     let exitDraftName = "";
    261     // Fog metadata ("hiddenMasks")
    262     let fogDraftMode = "auto"; // "auto" | "manual"
    263     let fogDraftName = "";
    264     // Fall-through metadata
    265     let fallDraftDirection = "down"; // "down" | "up" | "left" | "right"
    266     let fallDraftOffset = 0.02; // normalized units (0..1)
    267     let fallDraftName = "";
    268     // Fog reveal toggle (per-map, local-only)
    269     let revealFog = false;
    270     let draftPoly = []; // points [{x,y}] in normalized
    271     let lastTransform = null; // {srcX,srcY,zoom,worldW,worldH,viewW,viewH}
    272     let selfInvisible = false;
    273     let panning = false;
    274     let panStart = null; // {x,y,cx,cy}
    275     let audioCtx = null;
    276     let audioWarned = false;
    277     let walkieStream = null;
    278     let walkieRecorder = null;
    279     let walkieChunks = [];
    280     let walkieRecording = false;
    281     let walkieStartAt = 0;
    282     let walkieState = { phase: "idle", id: "", error: "", attempt: 0 };
    283     const walkiePhases = new Set(["idle", "recording", "encoding", "uploading", "sent", "playing", "played", "timeout", "failed"]);
    284     let walkieLastStateEmit = "";
    285     const walkiePlaybacks = new Map(); // id -> {audio, gain, pan, filter?, interval?, ackTimer?}
    286     const exitInside = new Map(); // idx -> boolean
    287     let lastExitAt = 0;
    288     let pendingSpawn = null; // { mapId, exitName }
    289     let selectedSpriteId = "";
    290     let selectedPropId = "";
    291     let spriteKind = "prop"; // "prop" | "token" (v1 uses props)
    292     let spriteScale = 1.0;
    293     let spriteScaleSaveTimer = 0;
    294     let placeRot = 0; // degrees (-180..180)
    295     const spriteImageCache = new Map(); // url -> {img,status:"loading"|"ok"|"error",failedAt:number}
    296     let propDrag = null; // {propId, offsetX, offsetY}
    297     let propDragMoved = false;
    298     let lastPropMoveAt = 0;
    299     let canManageTtrpg = false;
    300     let ttrpgTool = "select"; // "select" | "place" | "pan"
    301     let placeScale = 1.0;
    302     let speakingAsPropId = "";
    303     let ttrpgDockCollapsed = false;
    304     let mapsCapabilities = null;
    305     const typingUntil = new Map(); // username -> expiresAt(ms)
    306     const emoteUntil = new Map(); // username -> {state:string, until:number, loop:boolean}
    307     const avatarAnimRuntime = new Map(); // username -> {lastX,lastY,lastState,lastFrameAt,lastFacing:number,lastMoveAt:number}
    308     const frameAvatarCache = new Map(); // url -> {img,status,failedAt}
    309     let typingLastSentAt = 0;
    310     let typingOpen = false;
    311     let mapChatScope = "local";
    312     let mapChatOverlayOpacity = 0.92;
    313     let mapChatOverlayPos = null;
    314     let mapChatOverlayDrag = null;
    315     let mapChatFeed = []; // Array<{id:string,scope:"local"|"global",fromUser:string,text:string,createdAt:number}>
    316     let isFocusMode = false;
    317     let cinematicMode = false;
    318     let gmMode = "play"; // "play" | "select" | "place" | "polygon"
    319     let inspectorOpen = false;
    320     let lastPaletteToastAt = 0;
    321     let gmOverlayVisible = false;
    322     let avatarEditorOpen = false;
    323     let avatarEditorDraft = null;
    324     let avatarPresets = [];
    325     let avatarPresetsCanManage = false;
    326     let avatarPresetSelectedId = "";
    327     let capabilitiesRetryTimer = 0;
    328     let capabilitiesRetries = 0;
    329 
    330     function clearCapabilitiesRetry() {
    331       if (capabilitiesRetryTimer) {
    332         try {
    333           clearTimeout(capabilitiesRetryTimer);
    334         } catch {
    335           // ignore
    336         }
    337       }
    338       capabilitiesRetryTimer = 0;
    339       capabilitiesRetries = 0;
    340     }
    341 
    342     function requestCapabilitiesWithRetry(reason = "manual") {
    343       if (!activeMap?.id) return;
    344       const maxRetries = 3;
    345       const attemptDelay = 500;
    346       const attempt = () => {
    347         if (!activeMap?.id) return;
    348         ctx.send("getCapabilities", { mapId: activeMap.id, reason });
    349         if (mapsCapabilities?.features && typeof mapsCapabilities.features === "object") {
    350           clearCapabilitiesRetry();
    351           return;
    352         }
    353         if (capabilitiesRetries >= maxRetries) {
    354           clearCapabilitiesRetry();
    355           return;
    356         }
    357         capabilitiesRetries += 1;
    358         capabilitiesRetryTimer = setTimeout(attempt, attemptDelay * capabilitiesRetries);
    359       };
    360       clearCapabilitiesRetry();
    361       attempt();
    362     }
    363 
    364     function isMapStaffRole(role) {
    365       const r = String(role || "").toLowerCase();
    366       return r === "owner" || r === "admin" || r === "moderator";
    367     }
    368 
    369     function focusModePrefKey() {
    370       return "bzl_maps_focusMode";
    371     }
    372 
    373     function mapChatOverlayPrefKey() {
    374       return "bzl_maps_chatOverlay";
    375     }
    376 
    377     function readFocusModePref() {
    378       try {
    379         return localStorage.getItem(focusModePrefKey()) === "1";
    380       } catch {
    381         return false;
    382       }
    383     }
    384 
    385     function writeFocusModePref(on) {
    386       try {
    387         localStorage.setItem(focusModePrefKey(), on ? "1" : "0");
    388       } catch {
    389         // ignore
    390       }
    391     }
    392 
    393     function readMapChatOverlayPrefs() {
    394       try {
    395         const raw = localStorage.getItem(mapChatOverlayPrefKey());
    396         if (!raw) return;
    397         const parsed = JSON.parse(raw);
    398         if (parsed && typeof parsed === "object") {
    399           mapChatScope = String(parsed.scope || "local") === "global" ? "global" : "local";
    400           mapChatOverlayOpacity = clamp(Number(parsed.opacity || 0.92), 0.25, 1);
    401           const x = Number(parsed?.pos?.x);
    402           const y = Number(parsed?.pos?.y);
    403           mapChatOverlayPos = Number.isFinite(x) && Number.isFinite(y) ? { x, y } : null;
    404         }
    405       } catch {
    406         // ignore
    407       }
    408     }
    409 
    410     function writeMapChatOverlayPrefs() {
    411       try {
    412         localStorage.setItem(
    413           mapChatOverlayPrefKey(),
    414           JSON.stringify({
    415             scope: mapChatScope,
    416             opacity: mapChatOverlayOpacity,
    417             pos: mapChatOverlayPos && Number.isFinite(mapChatOverlayPos.x) && Number.isFinite(mapChatOverlayPos.y) ? { x: mapChatOverlayPos.x, y: mapChatOverlayPos.y } : null
    418           })
    419         );
    420       } catch {
    421         // ignore
    422       }
    423     }
    424 
    425     function pushMapChatFeedEntry(scopeRaw, messageRaw) {
    426       const scope = String(scopeRaw || "").trim().toLowerCase() === "global" ? "global" : "local";
    427       const message = messageRaw && typeof messageRaw === "object" ? messageRaw : null;
    428       if (!message) return;
    429       const id = String(message.id || `${Date.now()}_${Math.random().toString(16).slice(2)}`).slice(0, 120);
    430       const fromUser = String(message.fromUser || "").trim().toLowerCase().slice(0, 40);
    431       const text = String(message.text || "").replace(/\s+/g, " ").trim().slice(0, 420);
    432       const createdAt = Number(message.createdAt || Date.now()) || Date.now();
    433       if (!text) return;
    434       mapChatFeed.push({ id, scope, fromUser, text, createdAt });
    435       if (mapChatFeed.length > 260) mapChatFeed = mapChatFeed.slice(-220);
    436     }
    437 
    438     function replaceMapChatGlobalHistory(messages) {
    439       const list = Array.isArray(messages) ? messages : [];
    440       const keepLocal = mapChatFeed.filter((entry) => entry.scope !== "global");
    441       mapChatFeed = keepLocal;
    442       for (const item of list) pushMapChatFeedEntry("global", item);
    443     }
    444 
    445     function renderMapChatFeedDom() {
    446       const feedEl = document.getElementById("mapsChatFeed");
    447       if (!feedEl) return;
    448       const rows = mapChatFeed
    449         .filter((entry) => entry.scope === mapChatScope)
    450         .slice(-120);
    451       if (!rows.length) {
    452         feedEl.innerHTML = `<div class="small muted">No ${mapChatScope} messages yet.</div>`;
    453         return;
    454       }
    455       const html = rows
    456         .map((entry) => {
    457           const t = new Date(Number(entry.createdAt || 0) || Date.now());
    458           const hh = String(t.getHours()).padStart(2, "0");
    459           const mm = String(t.getMinutes()).padStart(2, "0");
    460           const user = entry.fromUser ? `@${entry.fromUser}` : "unknown";
    461           return `<div class="mapChatFeedItem"><div class="mapChatFeedMeta"><span>${escapeHtml(user)}</span><span>${escapeHtml(`${hh}:${mm}`)}</span></div><div class="mapChatFeedText">${escapeHtml(entry.text)}</div></div>`;
    462         })
    463         .join("");
    464       feedEl.innerHTML = html;
    465       feedEl.scrollTop = feedEl.scrollHeight;
    466     }
    467 
    468     function applyFocusModeClass() {
    469       if (!mapsPanel) return;
    470       mapsPanel.classList.toggle("focusMode", Boolean(mode === "map" && isFocusMode));
    471       mapsPanel.classList.toggle("cinematicMode", Boolean(mode === "map" && cinematicMode));
    472     }
    473 
    474     function setFocusMode(on, persist = true) {
    475       if (Boolean(on) && !featureEnabled("focusMode")) return;
    476       const wasFocusMode = Boolean(isFocusMode);
    477       isFocusMode = Boolean(on) && featureEnabled("focusMode");
    478       if (persist) writeFocusModePref(isFocusMode);
    479       if (isFocusMode) {
    480         gmMode = "play";
    481         editMode = false;
    482       }
    483       applyFocusModeClass();
    484       if (mode === "map" && isFocusMode && !wasFocusMode) {
    485         // Entering focus can change layout density and tool mode, so rerender once.
    486         renderMapView();
    487       } else if (mode === "map") {
    488         const focusBtn = mapsPanel?.querySelector?.("[data-mapfocus]");
    489         if (focusBtn) {
    490           focusBtn.textContent = isFocusMode ? "Exit focus" : "Focus";
    491           focusBtn.classList.toggle("primary", isFocusMode);
    492           focusBtn.classList.toggle("ghost", !isFocusMode);
    493         }
    494       }
    495     }
    496 
    497     function setCinematicMode(on) {
    498       cinematicMode = Boolean(on);
    499       if (cinematicMode) typingOpen = false;
    500       applyFocusModeClass();
    501       if (mode === "map") renderMapView();
    502     }
    503 
    504     isFocusMode = readFocusModePref();
    505     readMapChatOverlayPrefs();
    506 
    507     function setGmMode(next) {
    508       const target = String(next || "").toLowerCase();
    509       const canUseTools = Boolean(activeMap?.ttrpgEnabled && canManageTtrpg);
    510       if (target === "play") {
    511         gmMode = "play";
    512         editMode = false;
    513         ttrpgTool = "select";
    514         if (!shouldDeferMapRerenderForChat()) renderMapView();
    515         return;
    516       }
    517       if (!canUseTools) return;
    518       if (target === "select") {
    519         gmMode = "select";
    520         editMode = false;
    521         ttrpgTool = "select";
    522         if (!shouldDeferMapRerenderForChat()) renderMapView();
    523         return;
    524       }
    525       if (target === "place") {
    526         gmMode = "place";
    527         editMode = false;
    528         ttrpgTool = "place";
    529         if (!shouldDeferMapRerenderForChat()) renderMapView();
    530         return;
    531       }
    532       if (target === "polygon") {
    533         gmMode = "polygon";
    534         editMode = true;
    535         renderMapView();
    536       }
    537     }
    538 
    539     function setGmOverlayVisible(on) {
    540       gmOverlayVisible = Boolean(on);
    541       if (!gmOverlayVisible) inspectorOpen = false;
    542       if (mode === "map") renderMapView();
    543     }
    544 
    545     function featureEnabled(name, fallback = false) {
    546       const features = mapsCapabilities?.features;
    547       if (!features || typeof features !== "object") return fallback;
    548       if (!Object.prototype.hasOwnProperty.call(features, name)) return fallback;
    549       return Boolean(features[name]);
    550     }
    551 
    552     function avatarModesSupported() {
    553       const features = mapsCapabilities?.features;
    554       const list = Array.isArray(features?.avatarModes) ? features.avatarModes : ["profile_token"];
    555       return new Set(list.map((x) => String(x || "").trim()).filter(Boolean));
    556     }
    557 
    558     function normalizeAvatarState(raw) {
    559       const supported = avatarModesSupported();
    560       let mode = String(raw?.mode || "profile_token");
    561       if (!supported.has(mode)) mode = "profile_token";
    562       const displayName = String(raw?.displayName || "").replace(/\s+/g, " ").trim().slice(0, 32);
    563       const showUsername = raw && Object.prototype.hasOwnProperty.call(raw, "showUsername") ? Boolean(raw.showUsername) : true;
    564       const frameAnimation = mode === "frame_animation" ? normalizeFrameAnimation(raw?.frameAnimation) : null;
    565       return { mode: frameAnimation ? "frame_animation" : "profile_token", displayName, showUsername, frameAnimation };
    566     }
    567 
    568     function displayNameForUser(username, u) {
    569       const avatar = normalizeAvatarState(u?.avatar || null);
    570       if (!avatar.showUsername) return "";
    571       return avatar.displayName || `@${String(username || "")}`;
    572     }
    573 
    574     function defaultFrameAnimationDraft() {
    575       return {
    576         defaultFps: 8,
    577         renderScale: 1,
    578         sheetImport: { cols: 4, rows: 4, limit: 24 },
    579         selectedState: "idle_down",
    580         states: {
    581           idle_down: { frames: [], fps: 8, loop: true, flipXWithDirection: true },
    582           idle_up: { frames: [], fps: 8, loop: true, flipXWithDirection: true },
    583           walk_down: { frames: [], fps: 8, loop: true, flipXWithDirection: true },
    584           walk_up: { frames: [], fps: 8, loop: true, flipXWithDirection: true },
    585           walk_horizontal: { frames: [], fps: 8, loop: true, flipXWithDirection: true }
    586         },
    587         movementMap: {
    588           idleDown: "idle_down",
    589           idleUp: "idle_up",
    590           walkDown: "walk_down",
    591           walkUp: "walk_up",
    592           walkHorizontal: "walk_horizontal"
    593         },
    594         emotes: []
    595       };
    596     }
    597 
    598     function cloneAvatarForEditor(avatarRaw) {
    599       const avatar = normalizeAvatarState(avatarRaw || null);
    600       const frameBase = avatar.frameAnimation
    601         ? {
    602             defaultFps: clamp(avatar.frameAnimation.defaultFps || 8, 1, 24),
    603             renderScale: clamp(avatar.frameAnimation.renderScale || 1, 0.25, 4.0),
    604             sheetImport: {
    605               cols: Math.floor(clamp(avatar.frameAnimation?.sheetImport?.cols || 4, 1, 32)),
    606               rows: Math.floor(clamp(avatar.frameAnimation?.sheetImport?.rows || 4, 1, 32)),
    607               limit: Math.floor(clamp(avatar.frameAnimation?.sheetImport?.limit || 24, 1, 96))
    608             },
    609             selectedState: "idle_down",
    610             states: JSON.parse(JSON.stringify(avatar.frameAnimation.states || {})),
    611             movementMap: { ...(avatar.frameAnimation.movementMap || {}) },
    612             emotes: Array.isArray(avatar.frameAnimation.emotes) ? JSON.parse(JSON.stringify(avatar.frameAnimation.emotes)) : []
    613           }
    614         : defaultFrameAnimationDraft();
    615       if (!frameBase.selectedState || !frameBase.states?.[frameBase.selectedState]) {
    616         const first = Object.keys(frameBase.states || {})[0] || "idle_down";
    617         frameBase.selectedState = first;
    618       }
    619       return {
    620         mode: avatar.mode === "frame_animation" ? "frame_animation" : "profile_token",
    621         displayName: avatar.displayName || "",
    622         showUsername: avatar.showUsername !== false,
    623         frameAnimation: frameBase
    624       };
    625     }
    626 
    627     function ensureAvatarEditorDraft(currentAvatar) {
    628       if (!avatarEditorDraft) avatarEditorDraft = cloneAvatarForEditor(currentAvatar);
    629       return avatarEditorDraft;
    630     }
    631 
    632     function normalizeAvatarPresetList(list) {
    633       const src = Array.isArray(list) ? list : [];
    634       const out = [];
    635       for (const raw of src) {
    636         const id = String(raw?.id || "").trim().toLowerCase();
    637         const name = String(raw?.name || "").trim();
    638         if (!id || !name) continue;
    639         out.push({
    640           id,
    641           name: name.slice(0, 40),
    642           description: String(raw?.description || "").trim().slice(0, 140),
    643           tags: Array.isArray(raw?.tags) ? raw.tags.map((x) => String(x || "").trim()).filter(Boolean).slice(0, 12) : [],
    644           mode: String(raw?.mode || "profile_token"),
    645           published: Boolean(raw?.published),
    646           avatar: raw?.avatar && typeof raw.avatar === "object" ? raw.avatar : null
    647         });
    648       }
    649       return out;
    650     }
    651 
    652     function selectedAvatarPresetById(id) {
    653       const key = String(id || "").trim().toLowerCase();
    654       if (!key) return null;
    655       return avatarPresets.find((preset) => preset.id === key) || null;
    656     }
    657 
    658     function collectAvatarPayloadFromDraft() {
    659       const draft = avatarEditorDraft || cloneAvatarForEditor(null);
    660       const payload = {
    661         mode: draft.mode === "frame_animation" ? "frame_animation" : "profile_token",
    662         displayName: String(draft.displayName || "").replace(/\s+/g, " ").trim().slice(0, 32),
    663         showUsername: Boolean(draft.showUsername)
    664       };
    665       if (payload.mode === "frame_animation") {
    666         const fa = draft.frameAnimation || defaultFrameAnimationDraft();
    667         const states = {};
    668         for (const [stateName, stateDef] of Object.entries(fa.states || {})) {
    669           const cleanState = normalizeFrameStateName(stateName);
    670           if (!cleanState) continue;
    671           const frames = (Array.isArray(stateDef?.frames) ? stateDef.frames : [])
    672             .map((f) => {
    673               const url = String(f?.url || "").trim();
    674               const hasCrop =
    675                 Number.isFinite(Number(f?.sx)) &&
    676                 Number.isFinite(Number(f?.sy)) &&
    677                 Number.isFinite(Number(f?.sw)) &&
    678                 Number.isFinite(Number(f?.sh));
    679               return hasCrop
    680                 ? {
    681                     url,
    682                     sx: clamp(Number(f.sx), 0, 8192),
    683                     sy: clamp(Number(f.sy), 0, 8192),
    684                     sw: clamp(Number(f.sw), 1, 8192),
    685                     sh: clamp(Number(f.sh), 1, 8192)
    686                   }
    687                 : { url };
    688             })
    689             .filter((f) => Boolean(f.url));
    690           if (!frames.length) continue;
    691           states[cleanState] = {
    692             frames,
    693             fps: clamp(stateDef?.fps || fa.defaultFps || 8, 1, 24),
    694             loop: Object.prototype.hasOwnProperty.call(stateDef || {}, "loop") ? Boolean(stateDef.loop) : true,
    695             flipXWithDirection: Object.prototype.hasOwnProperty.call(stateDef || {}, "flipXWithDirection") ? Boolean(stateDef.flipXWithDirection) : true
    696           };
    697         }
    698         const hasStates = Object.keys(states).length > 0;
    699         if (!hasStates) {
    700           payload.mode = "profile_token";
    701         } else {
    702           const movementMap = {};
    703           const moveRaw = fa.movementMap && typeof fa.movementMap === "object" ? fa.movementMap : {};
    704           for (const key of ["idle", "idleUp", "idleDown", "walkVertical", "walkHorizontal", "walkUp", "walkDown", "walkLeft", "walkRight"]) {
    705             const v = normalizeFrameStateName(moveRaw[key]);
    706             if (v && states[v]) movementMap[key] = v;
    707           }
    708           payload.frameAnimation = {
    709             defaultFps: clamp(fa.defaultFps || 8, 1, 24),
    710             renderScale: clamp(fa.renderScale || 1, 0.25, 4.0),
    711             states,
    712             movementMap,
    713             emotes: Array.isArray(fa.emotes) ? fa.emotes.map((e) => ({ ...e })) : []
    714           };
    715         }
    716       }
    717       return payload;
    718     }
    719 
    720     function normalizeFrameStateName(name) {
    721       const raw = String(name || "").trim();
    722       if (!raw) return "";
    723       if (!/^[a-z][a-z0-9_]{0,31}$/i.test(raw)) return "";
    724       return raw;
    725     }
    726 
    727     function normalizeFrameAnimation(raw) {
    728       const input = raw && typeof raw === "object" ? raw : {};
    729       const defaultFps = clamp(input.defaultFps, 1, 24);
    730       const renderScale = clamp(input.renderScale, 0.25, 4.0);
    731       const statesRaw = input.states && typeof input.states === "object" ? input.states : {};
    732       const states = {};
    733       let totalFrames = 0;
    734       const MAX_STATES = 24;
    735       const MAX_FRAMES_PER_STATE = 48;
    736       const MAX_TOTAL_FRAMES = 220;
    737       for (const [stateRaw, defRaw] of Object.entries(statesRaw).slice(0, MAX_STATES)) {
    738         const state = normalizeFrameStateName(stateRaw);
    739         if (!state) continue;
    740         const def = defRaw && typeof defRaw === "object" ? defRaw : {};
    741         const framesRaw = Array.isArray(def.frames) ? def.frames : [];
    742         const frames = [];
    743         for (const frameRaw of framesRaw.slice(0, MAX_FRAMES_PER_STATE)) {
    744           const url = String(frameRaw?.url || "").trim();
    745           if (!url || url.length > 240) continue;
    746           if (!url) continue;
    747           const sx = clamp(Number(frameRaw?.sx), 0, 8192);
    748           const sy = clamp(Number(frameRaw?.sy), 0, 8192);
    749           const sw = clamp(Number(frameRaw?.sw), 1, 8192);
    750           const sh = clamp(Number(frameRaw?.sh), 1, 8192);
    751           const hasCrop =
    752             Number.isFinite(Number(frameRaw?.sx)) &&
    753             Number.isFinite(Number(frameRaw?.sy)) &&
    754             Number.isFinite(Number(frameRaw?.sw)) &&
    755             Number.isFinite(Number(frameRaw?.sh));
    756           frames.push(hasCrop ? { url, sx, sy, sw, sh } : { url });
    757           totalFrames += 1;
    758           if (totalFrames >= MAX_TOTAL_FRAMES) break;
    759         }
    760         if (!frames.length) continue;
    761         states[state] = {
    762           frames,
    763           fps: clamp(def.fps || defaultFps, 1, 24),
    764           loop: Object.prototype.hasOwnProperty.call(def, "loop") ? Boolean(def.loop) : true,
    765           flipXWithDirection: Object.prototype.hasOwnProperty.call(def, "flipXWithDirection") ? Boolean(def.flipXWithDirection) : true
    766         };
    767         if (totalFrames >= MAX_TOTAL_FRAMES) break;
    768       }
    769       if (!Object.keys(states).length) return null;
    770       const movementRaw = input.movementMap && typeof input.movementMap === "object" ? input.movementMap : {};
    771       const movementMap = {};
    772       const movementKeys = ["idle", "idleUp", "idleDown", "walkVertical", "walkHorizontal", "walkUp", "walkDown", "walkLeft", "walkRight"];
    773       for (const key of movementKeys) {
    774         const state = normalizeFrameStateName(movementRaw[key]);
    775         if (state && states[state]) movementMap[key] = state;
    776       }
    777       const emotesRaw = Array.isArray(input.emotes) ? input.emotes : [];
    778       const emotes = [];
    779       for (const emoteRaw of emotesRaw.slice(0, 16)) {
    780         const name = normalizeFrameStateName(emoteRaw?.name);
    781         const state = normalizeFrameStateName(emoteRaw?.state);
    782         if (!name || !state || !states[state]) continue;
    783         emotes.push({
    784           name,
    785           state,
    786           hotkey: String(emoteRaw?.hotkey || "").trim(),
    787           loop: Boolean(emoteRaw?.loop),
    788           interruptible: Object.prototype.hasOwnProperty.call(emoteRaw || {}, "interruptible") ? Boolean(emoteRaw.interruptible) : true
    789         });
    790       }
    791       return { defaultFps, renderScale, states, movementMap, emotes };
    792     }
    793 
    794     function getFrameImage(url) {
    795       const src = String(url || "").trim();
    796       if (!src) return null;
    797       const now = Date.now();
    798       const cached = frameAvatarCache.get(src);
    799       if (cached) {
    800         if (cached.status === "ok" && cached.img) return cached.img;
    801         if (cached.status === "loading") return null;
    802         if (cached.status === "error" && now - Number(cached.failedAt || 0) < 5000) return null;
    803       }
    804       const img = new Image();
    805       if (!src.startsWith("data:")) img.crossOrigin = "anonymous";
    806       frameAvatarCache.set(src, { img: null, status: "loading", failedAt: 0 });
    807       img.onload = () => frameAvatarCache.set(src, { img, status: "ok", failedAt: 0 });
    808       img.onerror = () => frameAvatarCache.set(src, { img: null, status: "error", failedAt: Date.now() });
    809       img.src = src;
    810       return null;
    811     }
    812 
    813     function resolveAvatarFrame(username, u, nowMs) {
    814       const avatar = normalizeAvatarState(u?.avatar || null);
    815       if (avatar.mode !== "frame_animation" || !avatar.frameAnimation) return null;
    816       const anim = avatar.frameAnimation;
    817       const states = anim.states || {};
    818       const x = Number(u?.x ?? u?.tx ?? 0);
    819       const y = Number(u?.y ?? u?.ty ?? 0);
    820       const rt = avatarAnimRuntime.get(username) || { lastX: x, lastY: y, lastState: "", lastFrameAt: nowMs, lastFacing: 1, lastDir: "down", lastMoveAt: 0 };
    821       const dx = x - Number(rt.lastX || x);
    822       const dy = y - Number(rt.lastY || y);
    823       const movedDistance = Math.hypot(dx, dy);
    824       if (movedDistance > 0.00008) {
    825         rt.lastMoveAt = nowMs;
    826         if (Math.abs(dx) >= Math.abs(dy)) {
    827           rt.lastFacing = dx < 0 ? -1 : 1;
    828           rt.lastDir = dx < 0 ? "left" : "right";
    829         } else {
    830           rt.lastDir = dy < 0 ? "up" : "down";
    831         }
    832       }
    833       const isMoving = nowMs - Number(rt.lastMoveAt || 0) < 180;
    834       const override = emoteUntil.get(username);
    835       if (override && Number(override.until || 0) <= nowMs) emoteUntil.delete(username);
    836       const liveOverride = emoteUntil.get(username);
    837       let stateName = "";
    838       if (liveOverride?.state && states[liveOverride.state]) {
    839         stateName = liveOverride.state;
    840       } else if (isMoving) {
    841         if (Math.abs(dx) >= Math.abs(dy)) {
    842           stateName = anim.movementMap?.walkHorizontal || anim.movementMap?.walkRight || anim.movementMap?.walkLeft || "walk_horizontal";
    843         } else if (dy < 0) {
    844           stateName = anim.movementMap?.walkUp || anim.movementMap?.walkVertical || "walk_vertical";
    845         } else {
    846           stateName = anim.movementMap?.walkDown || anim.movementMap?.walkVertical || "walk_vertical";
    847         }
    848       } else {
    849         stateName =
    850           rt.lastDir === "up"
    851             ? anim.movementMap?.idleUp || anim.movementMap?.idle || "idle_up"
    852             : anim.movementMap?.idleDown || anim.movementMap?.idle || "idle_down";
    853       }
    854       if (!states[stateName]) {
    855         stateName = states.idle_down ? "idle_down" : states.idle ? "idle" : Object.keys(states)[0] || "";
    856       }
    857       const state = stateName ? states[stateName] : null;
    858       if (!state) {
    859         avatarAnimRuntime.set(username, { ...rt, lastX: x, lastY: y });
    860         return null;
    861       }
    862       const frames = Array.isArray(state.frames) ? state.frames : [];
    863       if (!frames.length) {
    864         avatarAnimRuntime.set(username, { ...rt, lastX: x, lastY: y });
    865         return null;
    866       }
    867       if (rt.lastState !== stateName) {
    868         rt.lastState = stateName;
    869         rt.lastFrameAt = nowMs;
    870       }
    871       const fps = clamp(state.fps || anim.defaultFps || 8, 1, 24);
    872       const elapsed = Math.max(0, (nowMs - Number(rt.lastFrameAt || nowMs)) / 1000);
    873       const rawIndex = Math.floor(elapsed * fps);
    874       const loop = Boolean(state.loop);
    875       const index = loop ? rawIndex % frames.length : Math.min(frames.length - 1, rawIndex);
    876       const frame = frames[index] || {};
    877       const frameUrl = String(frame?.url || "").trim();
    878       const flipX = Boolean(state.flipXWithDirection) && Number(rt.lastFacing || 1) < 0;
    879       rt.lastX = x;
    880       rt.lastY = y;
    881       avatarAnimRuntime.set(username, rt);
    882       return {
    883         frameUrl,
    884         flipX,
    885         renderScale: clamp(anim.renderScale || 1, 0.25, 4.0),
    886         crop:
    887           Number.isFinite(Number(frame?.sx)) &&
    888           Number.isFinite(Number(frame?.sy)) &&
    889           Number.isFinite(Number(frame?.sw)) &&
    890           Number.isFinite(Number(frame?.sh))
    891             ? {
    892                 sx: clamp(Number(frame.sx), 0, 8192),
    893                 sy: clamp(Number(frame.sy), 0, 8192),
    894                 sw: clamp(Number(frame.sw), 1, 8192),
    895                 sh: clamp(Number(frame.sh), 1, 8192)
    896               }
    897             : null
    898       };
    899     }
    900 
    901     function setHidden(el, hidden) {
    902       if (!el) return;
    903       el.classList.toggle("hidden", Boolean(hidden));
    904     }
    905 
    906     function isTextEditingElement(el) {
    907       const node = el instanceof HTMLElement ? el : null;
    908       if (!node) return false;
    909       if (node.isContentEditable) return true;
    910       const tag = String(node.tagName || "").toLowerCase();
    911       if (tag === "textarea") return true;
    912       if (tag !== "input") return false;
    913       const type = String(node.getAttribute("type") || "text").toLowerCase();
    914       return !["button", "checkbox", "radio", "range", "file", "color", "submit"].includes(type);
    915     }
    916 
    917     function getSessionToken() {
    918       try {
    919         return localStorage.getItem("bzl_session_token") || "";
    920       } catch {
    921         return "";
    922       }
    923     }
    924 
    925     function dockCollapsedKey(mapId) {
    926       const id = String(mapId || "")
    927         .trim()
    928         .toLowerCase();
    929       const safe = id && /^[a-z0-9][a-z0-9_-]{0,40}$/.test(id) ? id : "default";
    930       return `bzl_maps_dockCollapsed_${safe}`;
    931     }
    932 
    933     function fogRevealKey(mapId) {
    934       const id = String(mapId || "")
    935         .trim()
    936         .toLowerCase();
    937       const safe = id && /^[a-z0-9][a-z0-9_-]{0,40}$/.test(id) ? id : "default";
    938       return `bzl_maps_revealFog_${safe}`;
    939     }
    940 
    941     function getFogReveal(mapId) {
    942       try {
    943         return localStorage.getItem(fogRevealKey(mapId)) === "1";
    944       } catch {
    945         return false;
    946       }
    947     }
    948 
    949     function setFogReveal(mapId, on) {
    950       revealFog = Boolean(on);
    951       try {
    952         localStorage.setItem(fogRevealKey(mapId), revealFog ? "1" : "0");
    953       } catch {
    954         // ignore
    955       }
    956     }
    957 
    958     function readDockCollapsed(mapId) {
    959       try {
    960         return localStorage.getItem(dockCollapsedKey(mapId)) === "1";
    961       } catch {
    962         return false;
    963       }
    964     }
    965 
    966     function writeDockCollapsed(mapId, collapsed) {
    967       ttrpgDockCollapsed = Boolean(collapsed);
    968       try {
    969         localStorage.setItem(dockCollapsedKey(mapId), ttrpgDockCollapsed ? "1" : "0");
    970       } catch {
    971         // ignore
    972       }
    973     }
    974 
    975     function slugifyId(title) {
    976       const t = String(title || "")
    977         .trim()
    978         .toLowerCase()
    979         .replace(/[^a-z0-9]+/g, "-")
    980         .replace(/^-+|-+$/g, "")
    981         .slice(0, 28);
    982       if (!t) return "";
    983       const first = t[0];
    984       if (!/[a-z0-9]/.test(first)) return `map-${t}`.slice(0, 31);
    985       return t;
    986     }
    987 
    988     async function uploadImageFile(file) {
    989       const token = getSessionToken();
    990       if (!token) throw new Error("Sign in required.");
    991       const maxBytes = 20 * 1024 * 1024;
    992       if (Number(file?.size || 0) > maxBytes) throw new Error("Map image too large (max 20 MB).");
    993       const name = String(file?.name || "").toLowerCase();
    994       const guessed =
    995         name.endsWith(".png")
    996           ? "image/png"
    997           : name.endsWith(".jpg") || name.endsWith(".jpeg")
    998             ? "image/jpeg"
    999             : name.endsWith(".gif")
   1000               ? "image/gif"
   1001               : name.endsWith(".webp")
   1002                 ? "image/webp"
   1003                 : "";
   1004       const contentType = file.type || guessed || "";
   1005       if (!contentType.startsWith("image/")) throw new Error("Unsupported image type.");
   1006       const res = await fetch("/api/upload?kind=image&purpose=map", {
   1007         method: "POST",
   1008         headers: {
   1009           Authorization: `Bearer ${token}`,
   1010           "Content-Type": contentType
   1011         },
   1012         body: file
   1013       });
   1014       const json = await res.json().catch(() => ({}));
   1015       if (!res.ok) throw new Error(String(json?.error || "Upload failed."));
   1016       if (!json?.url) throw new Error("Upload failed.");
   1017       return String(json.url);
   1018     }
   1019 
   1020     async function uploadSpriteImageFile(file) {
   1021       const token = getSessionToken();
   1022       if (!token) throw new Error("Sign in required.");
   1023       const maxBytes = 10 * 1024 * 1024;
   1024       if (Number(file?.size || 0) > maxBytes) throw new Error("Sprite image too large (max 10 MB).");
   1025       const name = String(file?.name || "").toLowerCase();
   1026       const isPng = name.endsWith(".png") || file.type === "image/png";
   1027       const isWebp = name.endsWith(".webp") || file.type === "image/webp";
   1028       if (!isPng && !isWebp) throw new Error("Sprites must be PNG or WebP (transparency).");
   1029       const contentType = isWebp ? "image/webp" : "image/png";
   1030       const res = await fetch("/api/upload?kind=image&purpose=sprite", {
   1031         method: "POST",
   1032         headers: {
   1033           Authorization: `Bearer ${token}`,
   1034           "Content-Type": contentType
   1035         },
   1036         body: file
   1037       });
   1038       const json = await res.json().catch(() => ({}));
   1039       if (!res.ok) throw new Error(String(json?.error || "Upload failed."));
   1040       if (!json?.url) throw new Error("Upload failed.");
   1041       return String(json.url);
   1042     }
   1043 
   1044     function countDraftFrames(frameAnimation) {
   1045       const states = frameAnimation?.states && typeof frameAnimation.states === "object" ? frameAnimation.states : {};
   1046       let total = 0;
   1047       for (const state of Object.values(states)) {
   1048         total += Array.isArray(state?.frames) ? state.frames.length : 0;
   1049       }
   1050       return total;
   1051     }
   1052 
   1053     async function readImageNaturalSizeFromFile(file) {
   1054       const objectUrl = URL.createObjectURL(file);
   1055       try {
   1056         const img = await new Promise((resolve, reject) => {
   1057           const el = new Image();
   1058           el.onload = () => resolve(el);
   1059           el.onerror = () => reject(new Error("Could not read image."));
   1060           el.src = objectUrl;
   1061         });
   1062         const width = Number(img?.naturalWidth || 0);
   1063         const height = Number(img?.naturalHeight || 0);
   1064         if (!Number.isFinite(width) || !Number.isFinite(height) || width < 1 || height < 1) {
   1065           throw new Error("Invalid image size.");
   1066         }
   1067         return { width, height };
   1068       } finally {
   1069         URL.revokeObjectURL(objectUrl);
   1070       }
   1071     }
   1072 
   1073     async function uploadAudioBlob(blob, filenameHint = "walkie.webm") {
   1074       const token = getSessionToken();
   1075       if (!token) throw new Error("Sign in required.");
   1076       const name = String(filenameHint || "").toLowerCase();
   1077       const guessed =
   1078         name.endsWith(".wav")
   1079           ? "audio/wav"
   1080           : name.endsWith(".ogg")
   1081             ? "audio/ogg"
   1082             : name.endsWith(".m4a")
   1083               ? "audio/mp4"
   1084               : name.endsWith(".aac")
   1085                 ? "audio/aac"
   1086                 : "audio/webm";
   1087       const rawType = typeof blob?.type === "string" ? blob.type : "";
   1088       const contentType = (rawType.split(";")[0] || "").trim().toLowerCase() || guessed;
   1089       if (!contentType.startsWith("audio/")) throw new Error("Unsupported audio type.");
   1090       const res = await fetch("/api/upload?kind=audio", {
   1091         method: "POST",
   1092         headers: {
   1093           Authorization: `Bearer ${token}`,
   1094           "Content-Type": contentType
   1095         },
   1096         body: blob
   1097       });
   1098       const json = await res.json().catch(() => ({}));
   1099       if (!res.ok) throw new Error(String(json?.error || "Upload failed."));
   1100       if (!json?.url) throw new Error("Upload failed.");
   1101       return String(json.url);
   1102     }
   1103 
   1104     function ensureAudioContext() {
   1105       if (audioCtx) return audioCtx;
   1106       const Ctx = window.AudioContext || window.webkitAudioContext;
   1107       if (!Ctx) return null;
   1108       audioCtx = new Ctx();
   1109       return audioCtx;
   1110     }
   1111 
   1112     async function ensureAudioReady() {
   1113       const ctxA = ensureAudioContext();
   1114       if (!ctxA) return false;
   1115       try {
   1116         if (ctxA.state !== "running") await ctxA.resume();
   1117         return true;
   1118       } catch {
   1119         return false;
   1120       }
   1121     }
   1122 
   1123     function clamp(n, a, b) {
   1124       const x = Number(n);
   1125       if (!Number.isFinite(x)) return a;
   1126       return Math.max(a, Math.min(b, x));
   1127     }
   1128 
   1129     function isWalkieTransitionAllowed(fromPhase, toPhase) {
   1130       if (fromPhase === toPhase) return true;
   1131       const allowed = {
   1132         idle: new Set(["recording", "playing", "failed"]),
   1133         recording: new Set(["encoding", "idle", "failed"]),
   1134         encoding: new Set(["uploading", "failed", "idle"]),
   1135         uploading: new Set(["sent", "failed", "idle"]),
   1136         sent: new Set(["idle", "playing", "failed"]),
   1137         playing: new Set(["played", "timeout", "idle", "failed"]),
   1138         played: new Set(["idle"]),
   1139         timeout: new Set(["idle"]),
   1140         failed: new Set(["idle", "recording"])
   1141       };
   1142       return Boolean(allowed[fromPhase]?.has(toPhase));
   1143     }
   1144 
   1145     function emitWalkieState() {
   1146       if (!activeMap?.id) return;
   1147       const payload = {
   1148         mapId: activeMap.id,
   1149         id: walkieState.id || "",
   1150         phase: walkieState.phase || "idle",
   1151         attempt: Number(walkieState.attempt || 0) || 0,
   1152         error: walkieState.error || ""
   1153       };
   1154       const key = `${payload.mapId}|${payload.id}|${payload.phase}|${payload.attempt}|${payload.error}`;
   1155       if (key === walkieLastStateEmit) return;
   1156       walkieLastStateEmit = key;
   1157       ctx.send("walkieState", payload);
   1158     }
   1159 
   1160     function setWalkieState(phase, patch = {}, options = {}) {
   1161       const nextPhase = String(phase || "idle");
   1162       const force = Boolean(options?.force);
   1163       if (!walkiePhases.has(nextPhase)) return;
   1164       if (!force && !isWalkieTransitionAllowed(String(walkieState.phase || "idle"), nextPhase)) return;
   1165       walkieState = {
   1166         phase: nextPhase,
   1167         id: typeof patch.id === "string" ? patch.id : walkieState.id,
   1168         error: typeof patch.error === "string" ? patch.error : "",
   1169         attempt: Number(patch.attempt || 0) || 0
   1170       };
   1171       emitWalkieState();
   1172       const btn = document.getElementById("mapsWalkieBtn");
   1173       const hint = document.getElementById("mapsWalkieHint");
   1174       const status = document.getElementById("mapsWalkieStatus");
   1175       if (btn) {
   1176         const ph = walkieState.phase;
   1177         btn.textContent =
   1178           ph === "recording"
   1179             ? "Recording…"
   1180             : ph === "encoding"
   1181               ? "Encoding…"
   1182               : ph === "uploading"
   1183                 ? "Uploading…"
   1184                 : ph === "playing"
   1185                   ? "Playing…"
   1186                   : ph === "failed"
   1187                     ? "Retry walkie"
   1188                     : "Hold to talk";
   1189       }
   1190       if (status) {
   1191         const ph = walkieState.phase;
   1192         let txt = "";
   1193         if (ph === "uploading") txt = walkieState.attempt > 1 ? `Uploading (retry ${walkieState.attempt - 1})…` : "Uploading…";
   1194         else if (ph === "sent") txt = "Sent";
   1195         else if (ph === "playing") txt = "Playing nearby";
   1196         else if (ph === "played") txt = "Played";
   1197         else if (ph === "timeout") txt = "Playback timeout";
   1198         else if (ph === "failed") txt = walkieState.error || "Walkie failed";
   1199         else if (ph === "recording") txt = "Recording";
   1200         status.textContent = txt;
   1201       }
   1202       if (hint) {
   1203         hint.textContent = walkieState.phase === "failed" ? "Press and hold to retry" : "or hold ~";
   1204       }
   1205     }
   1206 
   1207     function computeWalkieSpatial(from, to, dims) {
   1208       const dx = (Number(from.x || 0) - Number(to.x || 0)) * dims.w;
   1209       const dy = (Number(from.y || 0) - Number(to.y || 0)) * dims.h;
   1210       const dist = Math.hypot(dx, dy);
   1211       const base = Math.min(dims.w, dims.h);
   1212       const radius = clamp(base * 0.28, 280, 680);
   1213       const v = Math.max(0, 1 - dist / radius);
   1214       const vol = v * v;
   1215       const pan = clamp(dx / radius, -1, 1);
   1216       const cutoff = 600 + 7400 * vol;
   1217       return { vol, pan, cutoff };
   1218     }
   1219 
   1220     async function ensureWalkieStream() {
   1221       if (walkieStream) return walkieStream;
   1222       if (!navigator.mediaDevices?.getUserMedia) throw new Error("Mic not supported in this browser.");
   1223       const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
   1224       walkieStream = stream;
   1225       return stream;
   1226     }
   1227 
   1228     function pickRecorderMime() {
   1229       if (!window.MediaRecorder) return "";
   1230       const prefs = ["audio/webm;codecs=opus", "audio/ogg;codecs=opus", "audio/webm", "audio/ogg"];
   1231       for (const t of prefs) {
   1232         try {
   1233           if (MediaRecorder.isTypeSupported(t)) return t;
   1234         } catch {
   1235           // ignore
   1236         }
   1237       }
   1238       return "";
   1239     }
   1240 
   1241     async function startWalkie() {
   1242       if (walkieRecording) return;
   1243       if (!activeMap?.walkiesEnabled) return;
   1244       await ensureAudioReady();
   1245       const stream = await ensureWalkieStream();
   1246       const mimeType = pickRecorderMime();
   1247       walkieChunks = [];
   1248       walkieRecorder = mimeType ? new MediaRecorder(stream, { mimeType }) : new MediaRecorder(stream);
   1249       walkieRecording = true;
   1250       walkieStartAt = Date.now();
   1251       setWalkieState("recording");
   1252       walkieRecorder.ondataavailable = (e) => {
   1253         if (e.data && e.data.size) walkieChunks.push(e.data);
   1254       };
   1255       walkieRecorder.onstop = async () => {
   1256         walkieRecording = false;
   1257         const elapsed = Date.now() - walkieStartAt;
   1258         if (elapsed < 180) {
   1259           setWalkieState("idle", { id: "", error: "", attempt: 0 }, { force: true });
   1260           return;
   1261         }
   1262         if (!activeMap?.id) {
   1263           setWalkieState("failed", { id: "", error: "Not in a map.", attempt: 0 }, { force: true });
   1264           return;
   1265         }
   1266         setWalkieState("encoding", {}, { force: true });
   1267         const blob = new Blob(walkieChunks, { type: walkieRecorder?.mimeType || walkieChunks?.[0]?.type || "audio/webm" });
   1268         walkieChunks = [];
   1269         const id = `${Date.now()}_${Math.random().toString(16).slice(2)}`;
   1270         let uploadError = null;
   1271         const maxAttempts = 2;
   1272         for (let attempt = 1; attempt <= maxAttempts; attempt++) {
   1273           try {
   1274             setWalkieState("uploading", { id, attempt }, { force: true });
   1275             const url = await uploadAudioBlob(blob, "walkie.webm");
   1276             // Play locally immediately using spatial audio too.
   1277             playWalkie({ id, username: String(ctx.getUser() || "").trim().toLowerCase(), url, x: localPos.x, y: localPos.y });
   1278             ctx.send("walkieSend", { id, url, x: localPos.x, y: localPos.y });
   1279             setWalkieState("sent", { id, attempt }, { force: true });
   1280             setTimeout(() => {
   1281               if (walkieState.id === id && (walkieState.phase === "sent" || walkieState.phase === "idle")) {
   1282                 setWalkieState("idle", { id: "", error: "", attempt: 0 }, { force: true });
   1283               }
   1284             }, 1200);
   1285             uploadError = null;
   1286             break;
   1287           } catch (e) {
   1288             uploadError = e;
   1289             if (attempt < maxAttempts) {
   1290               const backoff = 450 * (2 ** (attempt - 1)) + Math.floor(Math.random() * 180);
   1291               await new Promise((resolve) => setTimeout(resolve, backoff));
   1292             }
   1293           }
   1294         }
   1295         if (uploadError) {
   1296           const msg = String(uploadError?.message || uploadError);
   1297           setWalkieState("failed", { id, error: msg, attempt: maxAttempts }, { force: true });
   1298           ctx.toast("Walkie", msg);
   1299         }
   1300       };
   1301       walkieRecorder.start();
   1302     }
   1303 
   1304     function stopWalkie() {
   1305       if (!walkieRecording || !walkieRecorder) return;
   1306       try {
   1307         walkieRecorder.stop();
   1308       } catch {
   1309         // ignore
   1310       }
   1311     }
   1312 
   1313     function stopAllWalkies() {
   1314       for (const entry of walkiePlaybacks.values()) {
   1315         try {
   1316           entry.ack?.("stop-all");
   1317           entry.cleanup?.("stop-all");
   1318         } catch {
   1319           // ignore
   1320         }
   1321       }
   1322       walkiePlaybacks.clear();
   1323     }
   1324 
   1325     async function playWalkie(msg) {
   1326       const id = String(msg?.id || "").trim();
   1327       const url = String(msg?.url || "").trim();
   1328       const username = String(msg?.username || "").trim().toLowerCase();
   1329       if (!id || !url || !username) return;
   1330       if (walkiePlaybacks.has(id)) return;
   1331       if (!activeMap?.walkiesEnabled) return;
   1332       if (walkieState.phase !== "recording" && walkieState.phase !== "uploading" && walkieState.phase !== "encoding") {
   1333         setWalkieState("playing", { id });
   1334       }
   1335 
   1336       const ok = await ensureAudioReady();
   1337       if (!ok) {
   1338         if (!audioWarned) {
   1339           audioWarned = true;
   1340           ctx.toast("Audio", "Click or press a key once to enable audio playback.");
   1341         }
   1342         return;
   1343       }
   1344 
   1345       const dims = getWorldDims();
   1346       const from = { x: Number(msg.x || 0), y: Number(msg.y || 0) };
   1347       const to = { x: Number(localPos.x || 0), y: Number(localPos.y || 0) };
   1348       const spatial = computeWalkieSpatial(from, to, dims);
   1349 
   1350       const a = document.createElement("audio");
   1351       a.src = url;
   1352       a.preload = "auto";
   1353       a.crossOrigin = "anonymous";
   1354       a.style.display = "none";
   1355       document.body.appendChild(a);
   1356 
   1357       const ac = ensureAudioContext();
   1358       let source = null;
   1359       let gain = null;
   1360       let pan = null;
   1361       let filter = null;
   1362       try {
   1363         source = ac.createMediaElementSource(a);
   1364         gain = ac.createGain();
   1365         gain.gain.value = spatial.vol;
   1366         filter = ac.createBiquadFilter();
   1367         filter.type = "lowpass";
   1368         filter.frequency.value = spatial.cutoff;
   1369         if (ac.createStereoPanner) {
   1370           pan = ac.createStereoPanner();
   1371           pan.pan.value = spatial.pan;
   1372           source.connect(filter);
   1373           filter.connect(pan);
   1374           pan.connect(gain);
   1375           gain.connect(ac.destination);
   1376         } else {
   1377           source.connect(filter);
   1378           filter.connect(gain);
   1379           gain.connect(ac.destination);
   1380         }
   1381       } catch (e) {
   1382         try {
   1383           a.remove();
   1384         } catch {
   1385           // ignore
   1386         }
   1387         return;
   1388       }
   1389 
   1390       let acked = false;
   1391       const ackOnce = (reason) => {
   1392         if (acked) return;
   1393         acked = true;
   1394         if (!activeMap?.id) return;
   1395         ctx.send("walkiePlayed", { id, reason: String(reason || "played") });
   1396       };
   1397 
   1398       const cleanup = () => {
   1399         const entry = walkiePlaybacks.get(id);
   1400         if (!entry) return;
   1401         try {
   1402           if (entry.interval) clearInterval(entry.interval);
   1403           if (entry.ackTimer) clearTimeout(entry.ackTimer);
   1404         } catch {
   1405           // ignore
   1406         }
   1407         try {
   1408           a.pause();
   1409         } catch {
   1410           // ignore
   1411         }
   1412         try {
   1413           a.remove();
   1414         } catch {
   1415           // ignore
   1416         }
   1417         walkiePlaybacks.delete(id);
   1418         if (walkieState.phase === "playing" && walkieState.id === id) {
   1419           setWalkieState("idle", { id: "", error: "", attempt: 0 }, { force: true });
   1420         }
   1421       };
   1422 
   1423       a.onended = () => {
   1424         ackOnce("ended");
   1425         setWalkieState("played", { id }, { force: true });
   1426         setTimeout(() => {
   1427           if (walkieState.id === id && walkieState.phase === "played") {
   1428             setWalkieState("idle", { id: "", error: "", attempt: 0 }, { force: true });
   1429           }
   1430         }, 700);
   1431         cleanup();
   1432       };
   1433       a.onerror = () => {
   1434         ackOnce("error");
   1435         cleanup();
   1436       };
   1437 
   1438       const interval = setInterval(() => {
   1439         const u = users.get(username);
   1440         const fx = u && typeof u.tx === "number" ? u.tx : from.x;
   1441         const fy = u && typeof u.ty === "number" ? u.ty : from.y;
   1442         const sp = computeWalkieSpatial({ x: fx, y: fy }, { x: localPos.x, y: localPos.y }, dims);
   1443         if (gain) gain.gain.value = sp.vol;
   1444         if (pan) pan.pan.value = sp.pan;
   1445         if (filter) filter.frequency.value = sp.cutoff;
   1446       }, 120);
   1447 
   1448       const ackTimer = setTimeout(() => {
   1449         ackOnce("timeout");
   1450         if (walkieState.id === id && walkieState.phase === "playing") {
   1451           setWalkieState("timeout", { id, error: "Playback timed out." }, { force: true });
   1452           setTimeout(() => {
   1453             if (walkieState.id === id && walkieState.phase === "timeout") {
   1454               setWalkieState("idle", { id: "", error: "", attempt: 0 }, { force: true });
   1455             }
   1456           }, 900);
   1457         }
   1458         cleanup();
   1459       }, 25_000);
   1460 
   1461       walkiePlaybacks.set(id, { audio: a, gain, pan, filter, interval, ackTimer, ack: ackOnce, cleanup });
   1462       try {
   1463         await a.play();
   1464       } catch {
   1465         // If autoplay blocked, we'll just cleanup (owner can click to enable and retry later).
   1466         ackOnce("autoplay-blocked");
   1467         cleanup();
   1468       }
   1469     }
   1470 
   1471     function enterMaps() {
   1472       mode = "maps";
   1473       applyFocusModeClass();
   1474       if (mapsBtn) {
   1475         mapsBtn.classList.add("primary");
   1476         mapsBtn.classList.remove("ghost");
   1477       }
   1478       setHidden(filters, true);
   1479       setHidden(hiveTabs, true);
   1480       setHidden(feed, true);
   1481       setHidden(pollinatePanel, true);
   1482       setHidden(mapsPanel, false);
   1483       if (appRoot) appRoot.classList.remove("mapsRoom");
   1484       if (chatPanel) chatPanel.classList.remove("hidden");
   1485       if (chatResizeHandle) chatResizeHandle.classList.remove("hidden");
   1486       renderMapsList();
   1487       ctx.send("list", {});
   1488     }
   1489 
   1490     function exitMapsToHives() {
   1491       mode = "hives";
   1492       applyFocusModeClass();
   1493       if (mapsBtn) {
   1494         mapsBtn.classList.add("ghost");
   1495         mapsBtn.classList.remove("primary");
   1496       }
   1497       setHidden(filters, false);
   1498       setHidden(hiveTabs, false);
   1499       setHidden(feed, false);
   1500       setHidden(pollinatePanel, false);
   1501       setHidden(mapsPanel, true);
   1502       if (appRoot) appRoot.classList.remove("mapsRoom");
   1503       if (chatPanel) chatPanel.classList.remove("hidden");
   1504       if (chatResizeHandle) chatResizeHandle.classList.remove("hidden");
   1505       stopLoop();
   1506       stopWalkie();
   1507       stopAllWalkies();
   1508       activeMap = null;
   1509       speakingAsPropId = "";
   1510       users.clear();
   1511       bubbles.clear();
   1512       keys.clear();
   1513     }
   1514 
   1515     function renderMapsList() {
   1516       if (!mapsPanel) return;
   1517       if (mode !== "maps") return;
   1518       const canCreate = isMapStaffRole(ctx.getRole());
   1519       const me = String(ctx.getUser() || "").trim().toLowerCase();
   1520       const role = String(ctx.getRole() || "").toLowerCase();
   1521       const createHtml = canCreate
   1522         ? `
   1523         <div class="mapCreateWrap">
   1524           <div class="mapCreateCard">
   1525             <div class="mapsTop">
   1526               <div class="mapsTopTitle">Create map</div>
   1527               <div class="small muted">Owner/admin/mod only</div>
   1528             </div>
   1529             <div class="mapCreateGrid">
   1530               <label>
   1531                 <span>Title</span>
   1532                 <input id="mapsCreateTitle" type="text" maxlength="60" placeholder="Example: Lounge" />
   1533               </label>
   1534               <label>
   1535                 <span>Map id</span>
   1536                 <input id="mapsCreateId" type="text" maxlength="31" placeholder="lounge" />
   1537               </label>
   1538               <label>
   1539                 <span>Background image</span>
   1540                 <input id="mapsCreateFile" type="file" accept="image/*" />
   1541               </label>
   1542               <label>
   1543                 <span>Avatar size</span>
   1544                 <div class="mapRangeRow">
   1545                   <input id="mapsCreateAvatarSize" type="range" min="18" max="96" value="36" />
   1546                   <div class="mapRangeVal" id="mapsCreateAvatarVal">36</div>
   1547                 </div>
   1548               </label>
   1549             </div>
   1550             <div class="mapCreateRow">
   1551               <div class="small muted grow" id="mapsCreateStatus"></div>
   1552               <button type="button" class="primary smallBtn" id="mapsCreateBtn">Create</button>
   1553             </div>
   1554           </div>
   1555         </div>`
   1556         : "";
   1557       const grid = maps
   1558         .map((m) => {
   1559           const count = Number(m.userCount || 0) || 0;
   1560           const thumb = m.thumbUrl || "";
   1561           const owner = String(m.owner || "").trim().toLowerCase();
   1562           const canManage = isMapStaffRole(role) || (owner && me && owner === me);
   1563           const liveBadge = Boolean(m.live) ? `<span class="tag" style="background: rgba(0,255,150,0.16); border-color: rgba(0,255,150,0.45); color:#8fffd0;">LIVE</span>` : "";
   1564           return `<div class="mapCard">
   1565             <img class="mapThumb" src="${thumb}" alt="" />
   1566             <div class="mapTitle">${escapeHtml(m.title || m.id)}</div>
   1567             <div class="mapMeta"><span>${escapeHtml(m.id)}</span><span>${count} in room ${liveBadge}</span></div>
   1568             <div class="mapEnterRow">
   1569               <button type="button" class="primary smallBtn" data-mapenter="${escapeHtml(m.id)}">Enter</button>
   1570               ${canManage && owner ? `<button type="button" class="ghost smallBtn" data-mapdelete="${escapeHtml(m.id)}">Delete</button>` : ""}
   1571             </div>
   1572           </div>`;
   1573         })
   1574         .join("");
   1575       mapsPanel.innerHTML = `${createHtml}<div class="mapsGrid">${grid || `<div class="muted">No maps available.</div>`}</div>`;
   1576 
   1577       const titleEl = document.getElementById("mapsCreateTitle");
   1578       const idEl = document.getElementById("mapsCreateId");
   1579       const fileEl = document.getElementById("mapsCreateFile");
   1580       const rangeEl = document.getElementById("mapsCreateAvatarSize");
   1581       const rangeVal = document.getElementById("mapsCreateAvatarVal");
   1582       const btnEl = document.getElementById("mapsCreateBtn");
   1583       const statusEl = document.getElementById("mapsCreateStatus");
   1584       if (rangeEl && rangeVal) {
   1585         rangeEl.oninput = () => {
   1586           rangeVal.textContent = String(rangeEl.value || "36");
   1587         };
   1588       }
   1589       if (idEl) {
   1590         idEl.oninput = () => {
   1591           createIdTouched = true;
   1592         };
   1593       }
   1594       if (titleEl && idEl) {
   1595         titleEl.oninput = () => {
   1596           if (createIdTouched) return;
   1597           idEl.value = slugifyId(titleEl.value);
   1598         };
   1599       }
   1600       if (btnEl && titleEl && idEl && fileEl && statusEl && rangeEl) {
   1601         btnEl.onclick = async () => {
   1602           const title = String(titleEl.value || "").trim();
   1603           const id = String(idEl.value || "").trim().toLowerCase();
   1604           const file = fileEl.files && fileEl.files[0] ? fileEl.files[0] : null;
   1605           const avatarSize = Number(rangeEl.value || 36);
   1606           if (!title) {
   1607             statusEl.textContent = "Title required.";
   1608             return;
   1609           }
   1610           if (!id || !/^[a-z0-9][a-z0-9_.-]{0,31}$/.test(id)) {
   1611             statusEl.textContent = "Valid map id required (letters/numbers, '-', '_', '.').";
   1612             return;
   1613           }
   1614           if (!file) {
   1615             statusEl.textContent = "Choose an image file.";
   1616             return;
   1617           }
   1618           btnEl.disabled = true;
   1619           statusEl.textContent = "Uploading...";
   1620           try {
   1621             const url = await uploadImageFile(file);
   1622             statusEl.textContent = "Creating...";
   1623             ctx.send("createMap", { id, title, backgroundUrl: url, thumbUrl: url, avatarSize });
   1624             statusEl.textContent = "Created.";
   1625             titleEl.value = "";
   1626             idEl.value = "";
   1627             fileEl.value = "";
   1628             createIdTouched = false;
   1629           } catch (e) {
   1630             statusEl.textContent = String(e?.message || e);
   1631           } finally {
   1632             btnEl.disabled = false;
   1633           }
   1634         };
   1635       }
   1636     }
   1637 
   1638     function renderAvatarEditorModal(draft, canManagePresets = false) {
   1639       const mode = draft?.mode === "frame_animation" ? "frame_animation" : "profile_token";
   1640       const displayName = String(draft?.displayName || "");
   1641       const showUsername = draft?.showUsername !== false;
   1642       const fa = draft?.frameAnimation || defaultFrameAnimationDraft();
   1643       const sheetImport = fa?.sheetImport && typeof fa.sheetImport === "object" ? fa.sheetImport : { cols: 4, rows: 4, limit: 24 };
   1644       const sheetCols = Math.floor(clamp(sheetImport.cols || 4, 1, 32));
   1645       const sheetRows = Math.floor(clamp(sheetImport.rows || 4, 1, 32));
   1646       const sheetLimit = Math.floor(clamp(sheetImport.limit || 24, 1, 96));
   1647       const states = fa.states && typeof fa.states === "object" ? fa.states : {};
   1648       const stateNames = Object.keys(states);
   1649       const selectedState = states[fa.selectedState] ? fa.selectedState : stateNames[0] || "idle_down";
   1650       const selected = states[selectedState] || { frames: [], fps: fa.defaultFps || 8, loop: true, flipXWithDirection: true };
   1651       const presetOptions = avatarPresets
   1652         .map((preset) => `<option value="${escapeHtml(preset.id)}" ${avatarPresetSelectedId === preset.id ? "selected" : ""}>${escapeHtml(preset.name)}${preset.published ? "" : " (draft)"}</option>`)
   1653         .join("");
   1654       const frameRows = (Array.isArray(selected.frames) ? selected.frames : [])
   1655         .map((frame, idx) => {
   1656           const url = String(frame?.url || "");
   1657           const short = url.length > 54 ? `${url.slice(0, 54)}...` : url;
   1658           return `<div class="mapsAvatarFrameRow">
   1659             <img class="mapsAvatarFrameThumb" src="${escapeHtml(url)}" alt="" />
   1660             <div class="mapsAvatarFrameName">${escapeHtml(short)}</div>
   1661             <button type="button" class="ghost smallBtn" data-avatar-frame-up="${idx}">↑</button>
   1662             <button type="button" class="ghost smallBtn" data-avatar-frame-down="${idx}">↓</button>
   1663             <button type="button" class="ghost smallBtn" data-avatar-frame-remove="${idx}">✕</button>
   1664           </div>`;
   1665         })
   1666         .join("");
   1667       return `
   1668         <div class="mapsAvatarEditorModal" id="mapsAvatarEditorModal">
   1669           <div class="mapsAvatarEditorCard">
   1670             <div class="row" style="justify-content:space-between; gap:10px;">
   1671               <div>
   1672                 <div class="dockTitle">Avatar editor</div>
   1673                 <div class="small muted">Quick mode: upload frame images by state.</div>
   1674               </div>
   1675               <button type="button" class="ghost smallBtn" id="mapsAvatarEditorCloseBtn">Close</button>
   1676             </div>
   1677             <div class="mapsAvatarEditorGrid" style="margin-top:10px;">
   1678               <div class="mapsAvatarStates">
   1679                 <div class="small muted">Profile</div>
   1680                 <label style="margin-top:8px;">
   1681                   <span class="small muted">Display name</span>
   1682                   <input id="mapsAvatarEditorDisplayName" type="text" maxlength="32" value="${escapeHtml(displayName)}" />
   1683                 </label>
   1684                 <label class="checkRow" style="margin-top:8px;">
   1685                   <span>Show username</span>
   1686                   <input id="mapsAvatarEditorShowUsername" type="checkbox" ${showUsername ? "checked" : ""} />
   1687                 </label>
   1688                 <label style="margin-top:10px;">
   1689                   <span class="small muted">Avatar mode</span>
   1690                   <select id="mapsAvatarEditorMode">
   1691                     <option value="profile_token" ${mode === "profile_token" ? "selected" : ""}>Profile token</option>
   1692                     <option value="frame_animation" ${mode === "frame_animation" ? "selected" : ""}>Frame animation (Quick)</option>
   1693                   </select>
   1694                 </label>
   1695                 <div class="panelDivider" style="margin-top:10px;"></div>
   1696                 <div class="small muted">Server presets</div>
   1697                 <label style="margin-top:8px;">
   1698                   <span class="small muted">Preset</span>
   1699                   <select id="mapsAvatarPresetSelect">
   1700                     <option value="">Select preset...</option>
   1701                     ${presetOptions}
   1702                   </select>
   1703                 </label>
   1704                 <div class="row" style="margin-top:8px; gap:8px; flex-wrap:wrap;">
   1705                   <button type="button" class="ghost smallBtn" id="mapsAvatarPresetApplyBtn">Apply preset</button>
   1706                   <button type="button" class="ghost smallBtn" id="mapsAvatarPresetRefreshBtn">Refresh</button>
   1707                 </div>
   1708                 ${canManagePresets ? `
   1709                 <label style="margin-top:8px;">
   1710                   <span class="small muted">Preset name (staff)</span>
   1711                   <input id="mapsAvatarPresetName" type="text" maxlength="40" placeholder="Example: Forest Ranger" />
   1712                 </label>
   1713                 <label class="checkRow" style="margin-top:8px;">
   1714                   <span>Published</span>
   1715                   <input id="mapsAvatarPresetPublished" type="checkbox" checked />
   1716                 </label>
   1717                 <div class="row" style="margin-top:8px; gap:8px; flex-wrap:wrap;">
   1718                   <button type="button" class="ghost smallBtn" id="mapsAvatarPresetSaveBtn">Save preset</button>
   1719                   <button type="button" class="ghost smallBtn" id="mapsAvatarPresetDeleteBtn">Delete preset</button>
   1720                 </div>
   1721                 ` : ""}
   1722                 <div class="${mode === "frame_animation" ? "" : "hidden"}" id="mapsAvatarEditorFrameSettings">
   1723                   <label style="margin-top:10px;">
   1724                     <span class="small muted">Default FPS</span>
   1725                     <input id="mapsAvatarEditorDefaultFps" type="number" min="1" max="24" value="${escapeHtml(String(clamp(fa.defaultFps || 8, 1, 24)))}" />
   1726                   </label>
   1727                   <label style="margin-top:10px;">
   1728                     <span class="small muted">State</span>
   1729                     <div class="row" style="gap:8px;">
   1730                       <select id="mapsAvatarEditorStateSelect">${stateNames.map((stateName) => `<option value="${escapeHtml(stateName)}" ${stateName === selectedState ? "selected" : ""}>${escapeHtml(stateName)}</option>`).join("")}</select>
   1731                       <button type="button" class="ghost smallBtn" id="mapsAvatarEditorAddStateBtn">+ State</button>
   1732                     </div>
   1733                   </label>
   1734                   <label class="checkRow" style="margin-top:8px;">
   1735                     <span>Loop selected state</span>
   1736                     <input id="mapsAvatarEditorStateLoop" type="checkbox" ${selected.loop !== false ? "checked" : ""} />
   1737                   </label>
   1738                   <label class="checkRow" style="margin-top:8px;">
   1739                     <span>Flip with direction</span>
   1740                     <input id="mapsAvatarEditorStateFlip" type="checkbox" ${selected.flipXWithDirection !== false ? "checked" : ""} />
   1741                   </label>
   1742                   <label style="margin-top:10px;">
   1743                     <span class="small muted">State FPS</span>
   1744                     <input id="mapsAvatarEditorStateFps" type="number" min="1" max="24" value="${escapeHtml(String(clamp(selected.fps || fa.defaultFps || 8, 1, 24)))}" />
   1745                   </label>
   1746                   <label style="margin-top:10px;">
   1747                     <span class="small muted">Render scale</span>
   1748                     <input id="mapsAvatarEditorRenderScale" type="range" min="0.25" max="4" step="0.05" value="${escapeHtml(String(clamp(fa.renderScale || 1, 0.25, 4).toFixed(2)))}" />
   1749                     <div class="small muted" id="mapsAvatarEditorRenderScaleVal">${escapeHtml(String(clamp(fa.renderScale || 1, 0.25, 4).toFixed(2)))}x</div>
   1750                   </label>
   1751                 </div>
   1752               </div>
   1753               <div class="mapsAvatarFrames">
   1754                 <div class="row" style="justify-content:space-between; gap:8px;">
   1755                   <div class="small muted">Frames (${escapeHtml(selectedState)})</div>
   1756                   <div class="row" style="gap:8px;">
   1757                     <label class="ghost smallBtn" style="cursor:pointer;">
   1758                       Add frame
   1759                       <input id="mapsAvatarEditorFrameInput" type="file" accept="image/png,image/webp,image/gif,image/jpeg" style="display:none;" />
   1760                     </label>
   1761                     <label class="ghost smallBtn" style="cursor:pointer;">
   1762                       Import sheet
   1763                       <input id="mapsAvatarEditorSheetInput" type="file" accept="image/png,image/webp" style="display:none;" />
   1764                     </label>
   1765                   </div>
   1766                 </div>
   1767                 <div class="mapsAvatarSheetGrid">
   1768                   <label>
   1769                     <span class="small muted">Cols</span>
   1770                     <input id="mapsAvatarEditorSheetCols" type="number" min="1" max="32" value="${escapeHtml(String(sheetCols))}" />
   1771                   </label>
   1772                   <label>
   1773                     <span class="small muted">Rows</span>
   1774                     <input id="mapsAvatarEditorSheetRows" type="number" min="1" max="32" value="${escapeHtml(String(sheetRows))}" />
   1775                   </label>
   1776                   <label>
   1777                     <span class="small muted">Import max</span>
   1778                     <input id="mapsAvatarEditorSheetLimit" type="number" min="1" max="96" value="${escapeHtml(String(sheetLimit))}" />
   1779                   </label>
   1780                 </div>
   1781                 <div class="small muted" style="margin-top:6px;">One upload, many frames: split by rows/cols into cropped frames for this state.</div>
   1782                 <div style="margin-top:8px; max-height: 46vh; overflow:auto;">
   1783                   ${frameRows || `<div class="small muted">No frames yet.</div>`}
   1784                 </div>
   1785               </div>
   1786             </div>
   1787             <div class="row" style="justify-content:flex-end; gap:8px; margin-top:10px;">
   1788               <div class="small muted grow" id="mapsAvatarEditorStatus"></div>
   1789               <button type="button" class="ghost smallBtn" id="mapsAvatarEditorCancelBtn">Cancel</button>
   1790               <button type="button" class="primary smallBtn" id="mapsAvatarEditorSaveBtn">Save avatar</button>
   1791             </div>
   1792           </div>
   1793         </div>
   1794       `;
   1795     }
   1796 
   1797     function renderMapView() {
   1798       if (!mapsPanel) return;
   1799       if (mode !== "map" || !activeMap) return;
   1800       if (appRoot) appRoot.classList.add("mapsRoom");
   1801       const title = escapeHtml(activeMap.title || activeMap.id);
   1802       const now = Date.now();
   1803       const list = Array.from(users.keys())
   1804         .sort((a, b) => a.localeCompare(b))
   1805         .map((u) => {
   1806           const typing = Number(typingUntil.get(u) || 0) > now;
   1807           const label = displayNameForUser(u, users.get(u));
   1808           const shown = label ? escapeHtml(label) : `<span class="muted">(hidden)</span>`;
   1809           return `<div class="small">${shown}${typing ? ` <span class="muted">(typing...)</span>` : ""}</div>`;
   1810         })
   1811         .join("");
   1812 
   1813       const role = String(ctx.getRole() || "").toLowerCase();
   1814       const me = String(ctx.getUser() || "").trim().toLowerCase();
   1815       const canManage = isMapStaffRole(role) || (activeMap.owner && me && activeMap.owner === me);
   1816       // Moderators/owners can edit maps even if legacy maps have no owner set.
   1817       const canEditMap = canManage;
   1818       const showSettings = canManage;
   1819       canManageTtrpg = Boolean(canManage);
   1820       const avatarSize = Number(activeMap.avatarSize || 36);
   1821       const cameraZoom = Math.max(0.8, Math.min(5.0, Number(activeMap.cameraZoom || 2.35) || 2.35));
   1822       const polysCount =
   1823         (Array.isArray(activeMap.collisions) ? activeMap.collisions.length : 0) +
   1824         (Array.isArray(activeMap.masks) ? activeMap.masks.length : 0) +
   1825         (Array.isArray(activeMap.exits) ? activeMap.exits.length : 0) +
   1826         (Array.isArray(activeMap.hiddenMasks) ? activeMap.hiddenMasks.length : 0) +
   1827         (Array.isArray(activeMap.fallThroughs) ? activeMap.fallThroughs.length : 0);
   1828       const walkiesEnabled = Boolean(activeMap.walkiesEnabled);
   1829       const ttrpgEnabled = Boolean(activeMap.ttrpgEnabled);
   1830       const focusSupported = featureEnabled("focusMode");
   1831       const gmOverlaySupported = featureEnabled("gmOverlay");
   1832       const walkieV2Supported = featureEnabled("walkieV2");
   1833       const canUseGmTools = Boolean(ttrpgEnabled && canManageTtrpg);
   1834       const meUser = users.get(me) || null;
   1835       const meAvatar = normalizeAvatarState(meUser?.avatar || null);
   1836       const avatarDraft = ensureAvatarEditorDraft(meAvatar);
   1837       const fogCount = Array.isArray(activeMap.hiddenMasks) ? activeMap.hiddenMasks.length : 0;
   1838       const shortcutHintHtml = `
   1839         <div class="mapHint">
   1840           Shortcuts:<br/>
   1841           Move: <b>WASD</b> / arrows, Chat: <b>T</b>${focusSupported ? `, Focus: <b>F</b>` : ""}, Cinematic: <b>C</b>${canManageTtrpg ? `, GM UI: <b>G</b>` : ""}<br/>
   1842           ${walkiesEnabled ? `Walkie: hold <b>~</b><br/>` : ""}
   1843           Emotes: <b>Alt+1..5</b> (if configured)<br/>
   1844           ${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/>` : ""}
   1845           Leave: click <b>Back</b>
   1846         </div>
   1847       `;
   1848       const avatarEditorHtml = avatarEditorOpen ? renderAvatarEditorModal(avatarDraft, isMapStaffRole(role) || avatarPresetsCanManage) : "";
   1849       let polyModalHtml = "";
   1850       if (canEditMap && editMode) {
   1851         try {
   1852           polyModalHtml = renderPolyModal();
   1853         } catch (e) {
   1854           devLog("error", "maps:renderPolyModal failed", { error: e?.message || String(e) });
   1855           const msg = escapeHtml(e?.message || String(e));
   1856           polyModalHtml = `
   1857             <div class="mapsPolyModal" id="mapsPolyModal">
   1858               <div class="mapsPolyModalInner">
   1859                 <div class="mapsPolyHeader">
   1860                   <div>
   1861                     <div class="mapsPolyTitle">Polygon editor (fallback)</div>
   1862                     <div class="small muted">UI render failed. You can still Close polygon and Save.</div>
   1863                   </div>
   1864                 </div>
   1865                 <div class="small muted" style="margin-top:10px;">${msg}</div>
   1866                 <div class="row" style="gap:10px; flex-wrap:wrap; margin-top:12px;">
   1867                   <button type="button" class="ghost smallBtn" id="mapsPolyCloseDraft" ${draftPoly.length >= 3 ? "" : "disabled"}>Close polygon</button>
   1868                   <button type="button" class="ghost smallBtn" id="mapsPolyClearDraft" ${draftPoly.length ? "" : "disabled"}>Clear draft</button>
   1869                   <button type="button" class="primary smallBtn" id="mapsPolySaveAll">Save</button>
   1870                   <div class="small muted" id="mapsPolyStatus" style="margin-left:auto;"></div>
   1871                 </div>
   1872               </div>
   1873             </div>
   1874           `;
   1875         }
   1876       }
   1877       const settingsHtml = showSettings
   1878         ? `
   1879           <div class="panelDivider"></div>
   1880           <div class="small muted">Map settings</div>
   1881           <label class="checkRow" style="margin-top:10px;">
   1882             <span>GM invisible</span>
   1883             <input id="mapsInvisibleToggle" type="checkbox" ${selfInvisible ? "checked" : ""} />
   1884           </label>
   1885           ${canEditMap ? `
   1886           <label>
   1887             <span class="small muted">Avatar size</span>
   1888             <div class="mapRangeRow">
   1889               <input id="mapsAvatarSizeRange" type="range" min="18" max="96" value="${escapeHtml(avatarSize)}" />
   1890               <div class="mapRangeVal" id="mapsAvatarSizeVal">${escapeHtml(avatarSize)}</div>
   1891             </div>
   1892           </label>
   1893           <label style="margin-top:10px;">
   1894             <span class="small muted">Camera zoom</span>
   1895             <div class="mapRangeRow">
   1896               <input id="mapsCameraZoomRange" type="range" min="1" max="4" step="0.05" value="${escapeHtml(cameraZoom.toFixed(2))}" />
   1897               <div class="mapRangeVal" id="mapsCameraZoomVal">${escapeHtml(cameraZoom.toFixed(2))}</div>
   1898             </div>
   1899           </label>
   1900           <label class="checkRow" style="margin-top:10px;">
   1901             <span>Enable walkies</span>
   1902             <input id="mapsWalkiesToggle" type="checkbox" ${walkiesEnabled ? "checked" : ""} />
   1903           </label>
   1904           <label class="checkRow" style="margin-top:10px;">
   1905             <span>TTRPG mode</span>
   1906             <input id="mapsTtrpgToggle" type="checkbox" ${ttrpgEnabled ? "checked" : ""} />
   1907           </label>
   1908           ${ttrpgEnabled && canManageTtrpg ? `
   1909           <div class="small muted" style="margin-top:10px; line-height:1.15rem;">
   1910             Inspector shortcuts:<br/>
   1911             <b>V</b> select, <b>P</b> place, hold <b>Space</b> pan<br/>
   1912             <b>Q/E</b> rotate selected, <b>Z/X</b> scale selected
   1913           </div>
   1914           ` : ""}
   1915           <div class="row" style="margin-top:10px; gap:10px;">
   1916             <button type="button" class="${editMode ? "primary" : "ghost"} smallBtn" id="mapsEditToggle">Polygon editor tools${editMode ? " (ON)" : ""}</button>
   1917             <div class="small muted grow">${polysCount} polys</div>
   1918           </div>
   1919           ${polyModalHtml}
   1920           ` : ""}
   1921         `
   1922         : "";
   1923       const modeLabel = gmMode === "polygon" ? "Polygon" : gmMode === "place" ? "Place" : gmMode === "select" ? "Select" : "Play";
   1924       const inspectorHtml = gmOverlaySupported && gmOverlayVisible && inspectorOpen
   1925         ? `<div class="mapInspectorDrawer" id="mapsInspectorDrawer">
   1926             <div class="mapInspectorTitle">Inspector</div>
   1927             <div class="small muted">Mode: <b>${escapeHtml(modeLabel)}</b></div>
   1928             <div class="small muted">Users: <b>${Number(activeMap.userCount || users.size)}</b>${activeMap.live ? ` • <span style="color:#8fffd0">LIVE</span>` : ""}</div>
   1929             <div class="small muted">Map: <b>${escapeHtml(activeMap.id || "")}</b></div>
   1930             <div class="small muted">Tools: ${canUseGmTools ? "enabled" : "waiting for GM/TTRPG mode"}</div>
   1931             <div class="small muted" style="margin-top:8px;">Command palette: <span class="tag">/</span> or <span class="tag">Ctrl/Cmd+K</span></div>
   1932           </div>`
   1933         : `<div class="mapInspectorDrawer hidden" id="mapsInspectorDrawer"></div>`;
   1934       const hotbarHtml = gmOverlaySupported && gmOverlayVisible
   1935         ? `<div class="mapGmHotbar ${walkiesEnabled ? "raiseForWalkie" : ""}">
   1936           <div class="mapGmHotbarInner">
   1937             <button type="button" class="${gmMode === "play" ? "primary" : "ghost"} smallBtn" data-gm-mode="play" title="1">Play</button>
   1938             <button type="button" class="${gmMode === "select" ? "primary" : "ghost"} smallBtn" data-gm-mode="select" ${canUseGmTools ? "" : "disabled"} title="2">Select</button>
   1939             <button type="button" class="${gmMode === "place" ? "primary" : "ghost"} smallBtn" data-gm-mode="place" ${canUseGmTools ? "" : "disabled"} title="3">Place</button>
   1940             <button type="button" class="${gmMode === "polygon" ? "primary" : "ghost"} smallBtn" data-gm-mode="polygon" ${canUseGmTools ? "" : "disabled"} title="4">Polygon</button>
   1941             <button type="button" class="${inspectorOpen ? "primary" : "ghost"} smallBtn" data-gm-inspector="1">Inspector</button>
   1942           </div>
   1943         </div>`
   1944         : "";
   1945       mapsPanel.innerHTML = `
   1946         <div class="mapsRoomWrap">
   1947         <div class="mapView">
   1948           <div class="mapCanvasWrap">
   1949             <canvas class="mapCanvas" id="mapsCanvas"></canvas>
   1950             <div class="mapGmTopLeft">
   1951               <div class="mapModePill"><b>${escapeHtml(activeMap.title || activeMap.id || "Map")}</b><span class="tag">${escapeHtml(modeLabel)}</span></div>
   1952             </div>
   1953             <div class="mapCornerTools">
   1954               <button type="button" class="ghost smallBtn" data-mapback="1">Back</button>
   1955               <button type="button" class="${cinematicMode ? "primary" : "ghost"} smallBtn" data-mapcinematic="1">${cinematicMode ? "Exit cinematic" : "Cinematic"}</button>
   1956               ${gmOverlaySupported && canUseGmTools ? `<button type="button" class="${gmOverlayVisible ? "primary" : "ghost"} smallBtn" data-gm-overlay="1">${gmOverlayVisible ? "Hide GM" : "GM UI"}</button>` : ""}
   1957               ${focusSupported ? `<button type="button" class="${isFocusMode ? "primary" : "ghost"} smallBtn" data-mapfocus="1">${isFocusMode ? "Exit focus" : "Focus"}</button>` : ""}
   1958             </div>
   1959             ${inspectorHtml}
   1960             ${hotbarHtml}
   1961             <div class="mapChatOverlay hidden ${gmOverlaySupported && gmOverlayVisible ? "raiseForHotbar" : walkiesEnabled ? "raiseForWalkie" : ""}" id="mapsChatOverlay">
   1962               <div class="mapChatToolbar">
   1963                 <button type="button" class="ghost smallBtn mapChatDragHandle" id="mapsChatDragHandle" title="Drag chat overlay">Drag</button>
   1964                 <div class="mapChatScopeRow">
   1965                   <button type="button" class="${mapChatScope === "local" ? "primary" : "ghost"} smallBtn" id="mapsChatScopeLocal">Local</button>
   1966                   <button type="button" class="${mapChatScope === "global" ? "primary" : "ghost"} smallBtn" id="mapsChatScopeGlobal">Global</button>
   1967                 </div>
   1968                 <div class="mapChatOpacity">
   1969                   <span class="small muted">Opacity</span>
   1970                   <input id="mapsChatOpacity" type="range" min="0.25" max="1" step="0.05" value="${escapeHtml(String(clamp(mapChatOverlayOpacity, 0.25, 1).toFixed(2)))}" />
   1971                 </div>
   1972                 <button type="button" class="ghost smallBtn" id="mapsChatReset" title="Reset chat overlay position and opacity">Reset</button>
   1973                 <button type="button" class="ghost smallBtn" id="mapsChatClose" title="Close">✕</button>
   1974               </div>
   1975               <div class="row" style="gap:8px;">
   1976                 <input id="mapsChatInput" placeholder="Say something..." autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" />
   1977                 <button type="button" class="primary" id="mapsChatSend">Send</button>
   1978               </div>
   1979             </div>
   1980             <div class="mapWalkieBar ${walkiesEnabled ? "" : "hidden"}" id="mapsWalkieBar">
   1981               <div class="mapWalkieBarInner">
   1982                 <button type="button" class="primary mapWalkieBtn" id="mapsWalkieBtn">Hold to talk</button>
   1983                 <div class="mapWalkieHint" id="mapsWalkieHint">or hold ~</div>
   1984                 <div class="mapWalkieHint ${walkieV2Supported ? "" : "hidden"}" id="mapsWalkieStatus"></div>
   1985               </div>
   1986             </div>
   1987           </div>
   1988           <div class="mapHud">
   1989             <div class="mapHudTitle">
   1990               <div>${title}</div>
   1991               <button type="button" class="ghost smallBtn" data-mapback="1">Back</button>
   1992             </div>
   1993             <div class="small muted">${Number(activeMap.userCount || users.size)} in room${activeMap.live ? ` • <span style="color:#8fffd0">LIVE</span>` : ""}</div>
   1994             <div class="mapHudList">${list || `<div class="muted small">No one here yet.</div>`}</div>
   1995             <div class="mapHint">
   1996               Exits: <b>${escapeHtml(Array.isArray(activeMap.exits) ? activeMap.exits.length : 0)}</b>
   1997             </div>
   1998             ${
   1999               fogCount
   2000                 ? `
   2001             <label class="checkRow" style="margin-top:10px;">
   2002               <span>Reveal fog</span>
   2003               <input id="mapsFogRevealToggle" type="checkbox" ${revealFog ? "checked" : ""} />
   2004             </label>
   2005             <div class="small muted" style="margin-top:6px; line-height:1.15rem;">
   2006               Fog zones: <b>${escapeHtml(String(fogCount))}</b><br/>
   2007               Auto fog reveals when you stand inside it.
   2008             </div>
   2009             `
   2010                 : ""
   2011             }
   2012             ${shortcutHintHtml}
   2013             <div class="panelDivider"></div>
   2014             <div class="small muted">Your avatar label</div>
   2015             <label style="margin-top:8px;">
   2016               <span class="small muted">Display name (optional)</span>
   2017               <input id="mapsDisplayNameInput" type="text" maxlength="32" placeholder="@${escapeHtml(me || "you")}" value="${escapeHtml(meAvatar.displayName || "")}" />
   2018             </label>
   2019             <label class="checkRow" style="margin-top:8px;">
   2020               <span>Show username label</span>
   2021               <input id="mapsShowUsernameToggle" type="checkbox" ${meAvatar.showUsername ? "checked" : ""} />
   2022             </label>
   2023             <div class="row" style="margin-top:8px; gap:8px;">
   2024               <button type="button" class="ghost smallBtn" id="mapsSaveAvatarBtn">Save</button>
   2025               <button type="button" class="ghost smallBtn" id="mapsOpenAvatarEditorBtn">Edit avatar...</button>
   2026               <div class="small muted" id="mapsAvatarStatus"></div>
   2027             </div>
   2028             ${settingsHtml}
   2029           </div>
   2030         </div>
   2031         <div class="mapDock ${ttrpgEnabled ? "" : "hidden"}" id="mapsTtrpgDock"></div>
   2032         </div>
   2033         ${avatarEditorHtml}
   2034       `;
   2035       setWalkieState(walkieState.phase || "idle", walkieState);
   2036 
   2037       if (editMode !== lastEditModeLogged) {
   2038         lastEditModeLogged = editMode;
   2039         devLog("info", "maps:editMode", { editMode, canEditMap, mapId: activeMap?.id || "" });
   2040       }
   2041       if (editMode && canEditMap) {
   2042         const now = Date.now();
   2043         if (now - lastPolyUiLogAt > 1500) {
   2044           lastPolyUiLogAt = now;
   2045           const modal = document.getElementById("mapsPolyModal");
   2046           const inHud = Boolean(modal && modal.closest(".mapHud"));
   2047           const h = modal ? Number(modal.getBoundingClientRect().height || 0) : 0;
   2048           devLog("debug", "maps:polyUi", { exists: Boolean(modal), inHud, height: h, draftPts: draftPoly.length, kind: editKind, tool: editTool });
   2049         }
   2050       }
   2051       loadBackground(activeMap.backgroundUrl || "");
   2052       startLoop();
   2053 
   2054       const fogRevealToggle = document.getElementById("mapsFogRevealToggle");
   2055       if (fogRevealToggle && fogCount) {
   2056         fogRevealToggle.onchange = () => {
   2057           setFogReveal(activeMap.id, Boolean(fogRevealToggle.checked));
   2058         };
   2059       }
   2060 
   2061       const avatarDisplayNameInput = document.getElementById("mapsDisplayNameInput");
   2062       const avatarShowUsernameToggle = document.getElementById("mapsShowUsernameToggle");
   2063       const avatarSaveBtn = document.getElementById("mapsSaveAvatarBtn");
   2064       const openAvatarEditorBtn = document.getElementById("mapsOpenAvatarEditorBtn");
   2065       const avatarStatus = document.getElementById("mapsAvatarStatus");
   2066       if (avatarSaveBtn && avatarDisplayNameInput && avatarShowUsernameToggle) {
   2067         avatarSaveBtn.onclick = () => {
   2068           const displayName = String(avatarDisplayNameInput.value || "").replace(/\s+/g, " ").trim().slice(0, 32);
   2069           const showUsername = Boolean(avatarShowUsernameToggle.checked);
   2070           if (avatarStatus) avatarStatus.textContent = "Saving...";
   2071           ctx.send("setAvatar", { mode: "profile_token", displayName, showUsername });
   2072           const mine = me ? users.get(me) : null;
   2073           if (mine) {
   2074             mine.avatar = { mode: "profile_token", displayName, showUsername };
   2075             users.set(me, mine);
   2076           }
   2077           avatarEditorDraft = cloneAvatarForEditor({ mode: "profile_token", displayName, showUsername });
   2078           if (avatarStatus) avatarStatus.textContent = "Saved.";
   2079           renderMapView();
   2080         };
   2081       }
   2082       if (openAvatarEditorBtn) {
   2083         openAvatarEditorBtn.onclick = () => {
   2084           avatarEditorOpen = true;
   2085           avatarEditorDraft = cloneAvatarForEditor(users.get(me)?.avatar || meAvatar);
   2086           ctx.send("listAvatarPresets", {});
   2087           renderMapView();
   2088         };
   2089       }
   2090 
   2091       const avatarEditorCloseBtn = document.getElementById("mapsAvatarEditorCloseBtn");
   2092       const avatarEditorCancelBtn = document.getElementById("mapsAvatarEditorCancelBtn");
   2093       const avatarEditorSaveBtn = document.getElementById("mapsAvatarEditorSaveBtn");
   2094       const avatarEditorDisplayName = document.getElementById("mapsAvatarEditorDisplayName");
   2095       const avatarEditorShowUsername = document.getElementById("mapsAvatarEditorShowUsername");
   2096       const avatarEditorMode = document.getElementById("mapsAvatarEditorMode");
   2097       const avatarEditorFrameInput = document.getElementById("mapsAvatarEditorFrameInput");
   2098       const avatarEditorSheetInput = document.getElementById("mapsAvatarEditorSheetInput");
   2099       const avatarEditorSheetCols = document.getElementById("mapsAvatarEditorSheetCols");
   2100       const avatarEditorSheetRows = document.getElementById("mapsAvatarEditorSheetRows");
   2101       const avatarEditorSheetLimit = document.getElementById("mapsAvatarEditorSheetLimit");
   2102       const avatarEditorDefaultFps = document.getElementById("mapsAvatarEditorDefaultFps");
   2103       const avatarEditorStateSelect = document.getElementById("mapsAvatarEditorStateSelect");
   2104       const avatarEditorAddStateBtn = document.getElementById("mapsAvatarEditorAddStateBtn");
   2105       const avatarEditorStateLoop = document.getElementById("mapsAvatarEditorStateLoop");
   2106       const avatarEditorStateFlip = document.getElementById("mapsAvatarEditorStateFlip");
   2107       const avatarEditorStateFps = document.getElementById("mapsAvatarEditorStateFps");
   2108       const avatarEditorRenderScale = document.getElementById("mapsAvatarEditorRenderScale");
   2109       const avatarEditorRenderScaleVal = document.getElementById("mapsAvatarEditorRenderScaleVal");
   2110       const avatarPresetSelect = document.getElementById("mapsAvatarPresetSelect");
   2111       const avatarPresetApplyBtn = document.getElementById("mapsAvatarPresetApplyBtn");
   2112       const avatarPresetRefreshBtn = document.getElementById("mapsAvatarPresetRefreshBtn");
   2113       const avatarPresetName = document.getElementById("mapsAvatarPresetName");
   2114       const avatarPresetPublished = document.getElementById("mapsAvatarPresetPublished");
   2115       const avatarPresetSaveBtn = document.getElementById("mapsAvatarPresetSaveBtn");
   2116       const avatarPresetDeleteBtn = document.getElementById("mapsAvatarPresetDeleteBtn");
   2117       const avatarEditorStatus = document.getElementById("mapsAvatarEditorStatus");
   2118 
   2119       const closeAvatarEditor = (resetDraft) => {
   2120         avatarEditorOpen = false;
   2121         if (resetDraft) avatarEditorDraft = cloneAvatarForEditor(users.get(me)?.avatar || meAvatar);
   2122         renderMapView();
   2123       };
   2124       if (avatarEditorCloseBtn) avatarEditorCloseBtn.onclick = () => closeAvatarEditor(false);
   2125       if (avatarEditorCancelBtn) avatarEditorCancelBtn.onclick = () => closeAvatarEditor(true);
   2126 
   2127       if (avatarEditorDisplayName) {
   2128         avatarEditorDisplayName.oninput = () => {
   2129           const draft = ensureAvatarEditorDraft(meAvatar);
   2130           draft.displayName = String(avatarEditorDisplayName.value || "").replace(/\s+/g, " ").trim().slice(0, 32);
   2131         };
   2132       }
   2133       if (avatarPresetSelect) {
   2134         avatarPresetSelect.onchange = () => {
   2135           avatarPresetSelectedId = String(avatarPresetSelect.value || "").trim().toLowerCase();
   2136           const selected = selectedAvatarPresetById(avatarPresetSelectedId);
   2137           if (avatarPresetName) avatarPresetName.value = selected?.name || "";
   2138           if (avatarPresetPublished) avatarPresetPublished.checked = selected ? Boolean(selected.published) : true;
   2139         };
   2140       }
   2141       if (avatarPresetRefreshBtn) {
   2142         avatarPresetRefreshBtn.onclick = () => {
   2143           if (avatarEditorStatus) avatarEditorStatus.textContent = "Refreshing presets...";
   2144           ctx.send("listAvatarPresets", {});
   2145         };
   2146       }
   2147       if (avatarPresetApplyBtn) {
   2148         avatarPresetApplyBtn.onclick = () => {
   2149           const id = String(avatarPresetSelect?.value || "").trim();
   2150           avatarPresetSelectedId = id.toLowerCase();
   2151           if (!id) {
   2152             if (avatarEditorStatus) avatarEditorStatus.textContent = "Select a preset first.";
   2153             return;
   2154           }
   2155           if (avatarEditorStatus) avatarEditorStatus.textContent = "Applying preset...";
   2156           ctx.send("applyAvatarPreset", { id });
   2157         };
   2158       }
   2159       if (avatarPresetSaveBtn) {
   2160         avatarPresetSaveBtn.onclick = () => {
   2161           const name = String(avatarPresetName?.value || "").replace(/\s+/g, " ").trim().slice(0, 40);
   2162           if (!name) {
   2163             if (avatarEditorStatus) avatarEditorStatus.textContent = "Preset name required.";
   2164             return;
   2165           }
   2166           const id = String(avatarPresetSelect?.value || "").trim();
   2167           avatarPresetSelectedId = id.toLowerCase();
   2168           const payload = collectAvatarPayloadFromDraft();
   2169           if (avatarEditorStatus) avatarEditorStatus.textContent = "Saving preset...";
   2170           ctx.send("upsertAvatarPreset", {
   2171             id,
   2172             name,
   2173             published: Boolean(avatarPresetPublished?.checked),
   2174             avatar: payload
   2175           });
   2176         };
   2177       }
   2178       if (avatarPresetDeleteBtn) {
   2179         avatarPresetDeleteBtn.onclick = () => {
   2180           const id = String(avatarPresetSelect?.value || "").trim();
   2181           avatarPresetSelectedId = id.toLowerCase();
   2182           if (!id) {
   2183             if (avatarEditorStatus) avatarEditorStatus.textContent = "Select a preset first.";
   2184             return;
   2185           }
   2186           if (!confirm("Delete this avatar preset?")) return;
   2187           if (avatarEditorStatus) avatarEditorStatus.textContent = "Deleting preset...";
   2188           ctx.send("deleteAvatarPreset", { id });
   2189         };
   2190       }
   2191       if (avatarEditorShowUsername) {
   2192         avatarEditorShowUsername.onchange = () => {
   2193           const draft = ensureAvatarEditorDraft(meAvatar);
   2194           draft.showUsername = Boolean(avatarEditorShowUsername.checked);
   2195         };
   2196       }
   2197       if (avatarEditorMode) {
   2198         avatarEditorMode.onchange = () => {
   2199           const draft = ensureAvatarEditorDraft(meAvatar);
   2200           draft.mode = String(avatarEditorMode.value || "") === "frame_animation" ? "frame_animation" : "profile_token";
   2201           renderMapView();
   2202         };
   2203       }
   2204       if (avatarEditorDefaultFps) {
   2205         avatarEditorDefaultFps.onchange = () => {
   2206           const draft = ensureAvatarEditorDraft(meAvatar);
   2207           draft.frameAnimation = draft.frameAnimation || defaultFrameAnimationDraft();
   2208           draft.frameAnimation.defaultFps = clamp(Number(avatarEditorDefaultFps.value || 8), 1, 24);
   2209         };
   2210       }
   2211       if (avatarEditorStateSelect) {
   2212         avatarEditorStateSelect.onchange = () => {
   2213           const draft = ensureAvatarEditorDraft(meAvatar);
   2214           draft.frameAnimation = draft.frameAnimation || defaultFrameAnimationDraft();
   2215           draft.frameAnimation.selectedState = String(avatarEditorStateSelect.value || "idle_down");
   2216           renderMapView();
   2217         };
   2218       }
   2219       if (avatarEditorAddStateBtn) {
   2220         avatarEditorAddStateBtn.onclick = () => {
   2221           const draft = ensureAvatarEditorDraft(meAvatar);
   2222           draft.frameAnimation = draft.frameAnimation || defaultFrameAnimationDraft();
   2223           const states = draft.frameAnimation.states || {};
   2224           if (Object.keys(states).length >= 24) {
   2225             if (avatarEditorStatus) avatarEditorStatus.textContent = "Max 24 states.";
   2226             return;
   2227           }
   2228           let i = 1;
   2229           let key = `state_${i}`;
   2230           while (states[key]) {
   2231             i += 1;
   2232             key = `state_${i}`;
   2233           }
   2234           states[key] = { frames: [], fps: draft.frameAnimation.defaultFps || 8, loop: true, flipXWithDirection: true };
   2235           draft.frameAnimation.states = states;
   2236           draft.frameAnimation.selectedState = key;
   2237           renderMapView();
   2238         };
   2239       }
   2240       if (avatarEditorStateLoop) {
   2241         avatarEditorStateLoop.onchange = () => {
   2242           const draft = ensureAvatarEditorDraft(meAvatar);
   2243           const stateName = draft.frameAnimation?.selectedState;
   2244           const state = stateName ? draft.frameAnimation?.states?.[stateName] : null;
   2245           if (!state) return;
   2246           state.loop = Boolean(avatarEditorStateLoop.checked);
   2247         };
   2248       }
   2249       if (avatarEditorStateFlip) {
   2250         avatarEditorStateFlip.onchange = () => {
   2251           const draft = ensureAvatarEditorDraft(meAvatar);
   2252           const stateName = draft.frameAnimation?.selectedState;
   2253           const state = stateName ? draft.frameAnimation?.states?.[stateName] : null;
   2254           if (!state) return;
   2255           state.flipXWithDirection = Boolean(avatarEditorStateFlip.checked);
   2256         };
   2257       }
   2258       if (avatarEditorStateFps) {
   2259         avatarEditorStateFps.onchange = () => {
   2260           const draft = ensureAvatarEditorDraft(meAvatar);
   2261           const stateName = draft.frameAnimation?.selectedState;
   2262           const state = stateName ? draft.frameAnimation?.states?.[stateName] : null;
   2263           if (!state) return;
   2264           state.fps = clamp(Number(avatarEditorStateFps.value || draft.frameAnimation.defaultFps || 8), 1, 24);
   2265         };
   2266       }
   2267       if (avatarEditorRenderScale) {
   2268         const apply = () => {
   2269           const draft = ensureAvatarEditorDraft(meAvatar);
   2270           draft.frameAnimation = draft.frameAnimation || defaultFrameAnimationDraft();
   2271           const next = clamp(Number(avatarEditorRenderScale.value || 1), 0.25, 4);
   2272           draft.frameAnimation.renderScale = next;
   2273           if (avatarEditorRenderScaleVal) avatarEditorRenderScaleVal.textContent = `${next.toFixed(2)}x`;
   2274         };
   2275         avatarEditorRenderScale.oninput = apply;
   2276         avatarEditorRenderScale.onchange = apply;
   2277       }
   2278       const applySheetImportSettings = () => {
   2279         const draft = ensureAvatarEditorDraft(meAvatar);
   2280         draft.frameAnimation = draft.frameAnimation || defaultFrameAnimationDraft();
   2281         draft.frameAnimation.sheetImport = {
   2282           cols: Math.floor(clamp(Number(avatarEditorSheetCols?.value || 4), 1, 32)),
   2283           rows: Math.floor(clamp(Number(avatarEditorSheetRows?.value || 4), 1, 32)),
   2284           limit: Math.floor(clamp(Number(avatarEditorSheetLimit?.value || 24), 1, 96))
   2285         };
   2286         if (avatarEditorSheetCols) avatarEditorSheetCols.value = String(draft.frameAnimation.sheetImport.cols);
   2287         if (avatarEditorSheetRows) avatarEditorSheetRows.value = String(draft.frameAnimation.sheetImport.rows);
   2288         if (avatarEditorSheetLimit) avatarEditorSheetLimit.value = String(draft.frameAnimation.sheetImport.limit);
   2289       };
   2290       if (avatarEditorSheetCols) {
   2291         avatarEditorSheetCols.oninput = applySheetImportSettings;
   2292         avatarEditorSheetCols.onchange = applySheetImportSettings;
   2293       }
   2294       if (avatarEditorSheetRows) {
   2295         avatarEditorSheetRows.oninput = applySheetImportSettings;
   2296         avatarEditorSheetRows.onchange = applySheetImportSettings;
   2297       }
   2298       if (avatarEditorSheetLimit) {
   2299         avatarEditorSheetLimit.oninput = applySheetImportSettings;
   2300         avatarEditorSheetLimit.onchange = applySheetImportSettings;
   2301       }
   2302       if (avatarEditorFrameInput) {
   2303         avatarEditorFrameInput.onchange = async () => {
   2304           const file = avatarEditorFrameInput.files && avatarEditorFrameInput.files[0];
   2305           if (!file) return;
   2306           const draft = ensureAvatarEditorDraft(meAvatar);
   2307           draft.frameAnimation = draft.frameAnimation || defaultFrameAnimationDraft();
   2308           const stateName = draft.frameAnimation.selectedState || "idle_down";
   2309           const state = draft.frameAnimation.states[stateName] || { frames: [], fps: draft.frameAnimation.defaultFps || 8, loop: true, flipXWithDirection: true };
   2310           state.frames = Array.isArray(state.frames) ? state.frames : [];
   2311           if (state.frames.length >= 48) {
   2312             if (avatarEditorStatus) avatarEditorStatus.textContent = "Max 48 frames per state.";
   2313             avatarEditorFrameInput.value = "";
   2314             return;
   2315           }
   2316           if (avatarEditorStatus) avatarEditorStatus.textContent = "Uploading frame...";
   2317           try {
   2318             const url = await uploadSpriteImageFile(file);
   2319             state.frames.push({ url });
   2320             draft.frameAnimation.states[stateName] = state;
   2321             if (avatarEditorStatus) avatarEditorStatus.textContent = "Frame added.";
   2322             renderMapView();
   2323           } catch (err) {
   2324             if (avatarEditorStatus) avatarEditorStatus.textContent = String(err?.message || err);
   2325           } finally {
   2326             avatarEditorFrameInput.value = "";
   2327           }
   2328         };
   2329       }
   2330       if (avatarEditorSheetInput) {
   2331         avatarEditorSheetInput.onchange = async () => {
   2332           const file = avatarEditorSheetInput.files && avatarEditorSheetInput.files[0];
   2333           if (!file) return;
   2334           const draft = ensureAvatarEditorDraft(meAvatar);
   2335           draft.frameAnimation = draft.frameAnimation || defaultFrameAnimationDraft();
   2336           const stateName = draft.frameAnimation.selectedState || "idle_down";
   2337           const state = draft.frameAnimation.states[stateName] || { frames: [], fps: draft.frameAnimation.defaultFps || 8, loop: true, flipXWithDirection: true };
   2338           state.frames = Array.isArray(state.frames) ? state.frames : [];
   2339           applySheetImportSettings();
   2340           const cols = Math.floor(clamp(Number(draft.frameAnimation.sheetImport?.cols || 4), 1, 32));
   2341           const rows = Math.floor(clamp(Number(draft.frameAnimation.sheetImport?.rows || 4), 1, 32));
   2342           const requestedLimit = Math.floor(clamp(Number(draft.frameAnimation.sheetImport?.limit || (cols * rows)), 1, 96));
   2343           const perStateRoom = Math.max(0, 48 - state.frames.length);
   2344           const totalRoom = Math.max(0, 220 - countDraftFrames(draft.frameAnimation));
   2345           const importLimit = Math.min(requestedLimit, perStateRoom, totalRoom);
   2346           if (importLimit < 1) {
   2347             if (avatarEditorStatus) avatarEditorStatus.textContent = "Frame limit reached.";
   2348             avatarEditorSheetInput.value = "";
   2349             return;
   2350           }
   2351           if (avatarEditorStatus) avatarEditorStatus.textContent = "Uploading sheet...";
   2352           try {
   2353             const [{ width, height }, url] = await Promise.all([readImageNaturalSizeFromFile(file), uploadSpriteImageFile(file)]);
   2354             const frameW = Math.floor(width / cols);
   2355             const frameH = Math.floor(height / rows);
   2356             if (frameW < 1 || frameH < 1) throw new Error("Sheet rows/cols are too large for this image.");
   2357             let added = 0;
   2358             for (let row = 0; row < rows && added < importLimit; row += 1) {
   2359               for (let col = 0; col < cols && added < importLimit; col += 1) {
   2360                 state.frames.push({ url, sx: col * frameW, sy: row * frameH, sw: frameW, sh: frameH });
   2361                 added += 1;
   2362               }
   2363             }
   2364             draft.frameAnimation.states[stateName] = state;
   2365             if (avatarEditorStatus) avatarEditorStatus.textContent = `Imported ${added} frame${added === 1 ? "" : "s"} from sheet.`;
   2366             renderMapView();
   2367           } catch (err) {
   2368             if (avatarEditorStatus) avatarEditorStatus.textContent = String(err?.message || err);
   2369           } finally {
   2370             avatarEditorSheetInput.value = "";
   2371           }
   2372         };
   2373       }
   2374       const frameUpBtns = mapsPanel.querySelectorAll("[data-avatar-frame-up]");
   2375       frameUpBtns.forEach((btn) => {
   2376         btn.addEventListener("click", () => {
   2377           const idx = Number(btn.getAttribute("data-avatar-frame-up") || -1);
   2378           const draft = ensureAvatarEditorDraft(meAvatar);
   2379           const stateName = draft.frameAnimation?.selectedState;
   2380           const state = stateName ? draft.frameAnimation?.states?.[stateName] : null;
   2381           if (!state || !Array.isArray(state.frames) || idx <= 0 || idx >= state.frames.length) return;
   2382           const tmp = state.frames[idx - 1];
   2383           state.frames[idx - 1] = state.frames[idx];
   2384           state.frames[idx] = tmp;
   2385           renderMapView();
   2386         });
   2387       });
   2388       const frameDownBtns = mapsPanel.querySelectorAll("[data-avatar-frame-down]");
   2389       frameDownBtns.forEach((btn) => {
   2390         btn.addEventListener("click", () => {
   2391           const idx = Number(btn.getAttribute("data-avatar-frame-down") || -1);
   2392           const draft = ensureAvatarEditorDraft(meAvatar);
   2393           const stateName = draft.frameAnimation?.selectedState;
   2394           const state = stateName ? draft.frameAnimation?.states?.[stateName] : null;
   2395           if (!state || !Array.isArray(state.frames) || idx < 0 || idx >= state.frames.length - 1) return;
   2396           const tmp = state.frames[idx + 1];
   2397           state.frames[idx + 1] = state.frames[idx];
   2398           state.frames[idx] = tmp;
   2399           renderMapView();
   2400         });
   2401       });
   2402       const frameRemoveBtns = mapsPanel.querySelectorAll("[data-avatar-frame-remove]");
   2403       frameRemoveBtns.forEach((btn) => {
   2404         btn.addEventListener("click", () => {
   2405           const idx = Number(btn.getAttribute("data-avatar-frame-remove") || -1);
   2406           const draft = ensureAvatarEditorDraft(meAvatar);
   2407           const stateName = draft.frameAnimation?.selectedState;
   2408           const state = stateName ? draft.frameAnimation?.states?.[stateName] : null;
   2409           if (!state || !Array.isArray(state.frames) || idx < 0 || idx >= state.frames.length) return;
   2410           state.frames.splice(idx, 1);
   2411           renderMapView();
   2412         });
   2413       });
   2414       if (avatarEditorSaveBtn) {
   2415         avatarEditorSaveBtn.onclick = () => {
   2416           const payload = collectAvatarPayloadFromDraft();
   2417           if (avatarEditorStatus) avatarEditorStatus.textContent = "Saving...";
   2418           ctx.send("setAvatar", payload);
   2419           const mine = me ? users.get(me) : null;
   2420           if (mine) {
   2421             mine.avatar = payload;
   2422             users.set(me, mine);
   2423           }
   2424           if (avatarStatus) avatarStatus.textContent = "Saved.";
   2425           if (avatarEditorStatus) avatarEditorStatus.textContent = "Saved.";
   2426           avatarEditorOpen = false;
   2427           avatarEditorDraft = cloneAvatarForEditor(payload);
   2428           renderMapView();
   2429         };
   2430       }
   2431 
   2432       const invToggle = document.getElementById("mapsInvisibleToggle");
   2433       if (invToggle && showSettings) {
   2434         invToggle.onchange = () => {
   2435           const invisible = Boolean(invToggle.checked);
   2436           selfInvisible = invisible;
   2437           ctx.send("setInvisible", { mapId: activeMap.id, invisible });
   2438           renderMapView();
   2439         };
   2440       }
   2441 
   2442       const range = document.getElementById("mapsAvatarSizeRange");
   2443       const val = document.getElementById("mapsAvatarSizeVal");
   2444       if (range && val && canEditMap) {
   2445         const commit = () => {
   2446           const next = Math.max(18, Math.min(96, Number(range.value || 36)));
   2447           val.textContent = String(next);
   2448           activeMap.avatarSize = next;
   2449           if (mapAvatarSaveTimer) clearTimeout(mapAvatarSaveTimer);
   2450           mapAvatarSaveTimer = setTimeout(() => {
   2451             ctx.send("updateMap", { id: activeMap.id, avatarSize: next });
   2452           }, 220);
   2453         };
   2454         range.oninput = commit;
   2455         range.onchange = commit;
   2456       }
   2457 
   2458       const zoomRange = document.getElementById("mapsCameraZoomRange");
   2459       const zoomVal = document.getElementById("mapsCameraZoomVal");
   2460       if (zoomRange && zoomVal && canEditMap) {
   2461         const commit = () => {
   2462           const next = Math.max(1, Math.min(4, Number(zoomRange.value || 2.35) || 2.35));
   2463           zoomVal.textContent = next.toFixed(2);
   2464           activeMap.cameraZoom = next;
   2465           if (mapZoomSaveTimer) clearTimeout(mapZoomSaveTimer);
   2466           mapZoomSaveTimer = setTimeout(() => {
   2467             ctx.send("updateMap", { id: activeMap.id, cameraZoom: next });
   2468           }, 220);
   2469         };
   2470         zoomRange.oninput = commit;
   2471         zoomRange.onchange = commit;
   2472       }
   2473 
   2474       const walkiesToggle = document.getElementById("mapsWalkiesToggle");
   2475       if (walkiesToggle && canEditMap) {
   2476         walkiesToggle.onchange = () => {
   2477           const enabled = Boolean(walkiesToggle.checked);
   2478           activeMap.walkiesEnabled = enabled;
   2479           ctx.send("updateMap", { id: activeMap.id, walkiesEnabled: enabled });
   2480           const bar = document.getElementById("mapsWalkieBar");
   2481           if (bar) bar.classList.toggle("hidden", !enabled);
   2482         };
   2483       }
   2484 
   2485       const ttrpgToggle = document.getElementById("mapsTtrpgToggle");
   2486       if (ttrpgToggle && canEditMap) {
   2487         ttrpgToggle.onchange = () => {
   2488           const enabled = Boolean(ttrpgToggle.checked);
   2489           activeMap.ttrpgEnabled = enabled;
   2490           ctx.send("ttrpgSetEnabled", { mapId: activeMap.id, enabled });
   2491           renderMapView();
   2492         };
   2493       }
   2494 
   2495       const editBtn = document.getElementById("mapsEditToggle");
   2496       if (editBtn && canEditMap) {
   2497         editBtn.onclick = () => {
   2498           devLog("info", "maps:editToggle click", { before: editMode, mapId: activeMap?.id || "" });
   2499           editMode = !editMode;
   2500           draftPoly = [];
   2501           polyDrag = null;
   2502           vertexDrag = null;
   2503           panning = false;
   2504           panStart = null;
   2505           if (editMode) {
   2506             gmMode = "polygon";
   2507             const list = polysForKind(activeMap, editKind);
   2508             editTool = list.length ? "select" : "draw";
   2509             devLog("info", "maps:editToggle on", { mapId: activeMap?.id || "", kind: editKind, tool: editTool });
   2510           } else {
   2511             gmMode = "play";
   2512             selectedPolyKind = "";
   2513             selectedPolyIndex = -1;
   2514             selectedVertexIndex = -1;
   2515             devLog("info", "maps:editToggle off", { mapId: activeMap?.id || "" });
   2516           }
   2517           renderMapView();
   2518         };
   2519       }
   2520 
   2521       if (editMode) {
   2522         wirePolyModalHandlers();
   2523         const modal = document.getElementById("mapsPolyModal");
   2524         try {
   2525           modal?.scrollIntoView?.({ block: "nearest" });
   2526         } catch {
   2527           // ignore
   2528         }
   2529       }
   2530 
   2531       const canvas = document.getElementById("mapsCanvas");
   2532       if (canvas) {
   2533         if (editMode) {
   2534           canvas.style.cursor =
   2535             editTool === "draw" ? "crosshair" : editTool === "select" ? "pointer" : editTool === "move" ? "move" : "cell";
   2536         }
   2537         else if (activeMap?.ttrpgEnabled && canManageTtrpg && ttrpgTool === "pan") canvas.style.cursor = "grab";
   2538         else if (activeMap?.ttrpgEnabled && canManageTtrpg && ttrpgTool === "place") canvas.style.cursor = "copy";
   2539         else canvas.style.cursor = "default";
   2540         canvas.oncontextmenu = (e) => {
   2541           if (editMode) e.preventDefault();
   2542         };
   2543         canvas.onpointerdown = (e) => {
   2544           if (!lastTransform) return;
   2545           // Edit mode interactions
   2546           if (editMode) {
   2547             const isPan = e.button === 2 || e.shiftKey;
   2548             if (isPan) {
   2549               panning = true;
   2550               canvas.setPointerCapture(e.pointerId);
   2551               panStart = { x: e.clientX, y: e.clientY, cx: cameraPos?.x ?? 0.5, cy: cameraPos?.y ?? 0.5 };
   2552               return;
   2553             }
   2554             if (e.button !== 0) return;
   2555             const pt = screenToWorldNormalized(e.clientX, e.clientY, canvas, lastTransform);
   2556             if (!pt) return;
   2557 
   2558             if (editTool === "draw") {
   2559               draftPoly.push(pt);
   2560               const se = document.getElementById("mapsPolyStatus");
   2561               if (se) se.textContent = `${draftPoly.length} pts (draft)`;
   2562               syncPolyDraftUi();
   2563               return;
   2564             }
   2565 
   2566             if (editTool === "select") {
   2567               const hit = hitTestPoly(pt, activeMap, editKind);
   2568               if (hit) {
   2569                 selectedPolyKind = editKind;
   2570                 selectedPolyIndex = hit.index;
   2571                 selectedVertexIndex = -1;
   2572               } else {
   2573                 selectedPolyKind = "";
   2574                 selectedPolyIndex = -1;
   2575                 selectedVertexIndex = -1;
   2576               }
   2577               renderMapView();
   2578               return;
   2579             }
   2580 
   2581             if (editTool === "move") {
   2582               if (selectedPolyKind !== editKind || selectedPolyIndex < 0) return;
   2583               const list = polysForKind(activeMap, editKind);
   2584               const poly = list[selectedPolyIndex];
   2585               if (!poly || !pointInPoly(pt, poly)) return;
   2586               polyDrag = {
   2587                 kind: editKind,
   2588                 index: selectedPolyIndex,
   2589                 start: { x: pt.x, y: pt.y },
   2590                 origPoints: (Array.isArray(poly.points) ? poly.points : []).map((p) => ({ x: Number(p.x || 0), y: Number(p.y || 0) })),
   2591               };
   2592               canvas.setPointerCapture(e.pointerId);
   2593               return;
   2594             }
   2595 
   2596             if (editTool === "vertex") {
   2597               if (selectedPolyKind !== editKind || selectedPolyIndex < 0) return;
   2598               const list = polysForKind(activeMap, editKind);
   2599               const poly = list[selectedPolyIndex];
   2600               if (!poly) return;
   2601               const vIdx = hitTestVertex(e.clientX, e.clientY, canvas, lastTransform, poly);
   2602               if (vIdx < 0) return;
   2603               selectedVertexIndex = vIdx;
   2604               vertexDrag = { kind: editKind, index: selectedPolyIndex, vIdx };
   2605               canvas.setPointerCapture(e.pointerId);
   2606               return;
   2607             }
   2608           }
   2609 
   2610           if (!activeMap?.ttrpgEnabled || !canManageTtrpg) return;
   2611           if (e.button !== 0) return;
   2612           if (ttrpgTool === "pan") {
   2613             panning = true;
   2614             canvas.setPointerCapture(e.pointerId);
   2615             panStart = { x: e.clientX, y: e.clientY, cx: cameraPos?.x ?? 0.5, cy: cameraPos?.y ?? 0.5 };
   2616             canvas.style.cursor = "grabbing";
   2617             return;
   2618           }
   2619           const hit = hitTestPropAtPointer(e.clientX, e.clientY, canvas, lastTransform);
   2620           if (hit) {
   2621             selectedPropId = hit.propId;
   2622             const pt = screenToWorldNormalized(e.clientX, e.clientY, canvas, lastTransform);
   2623             if (!pt) return;
   2624             if (ttrpgTool === "select") {
   2625               propDrag = { propId: hit.propId, offsetX: hit.x - pt.x, offsetY: hit.y - pt.y };
   2626               propDragMoved = false;
   2627               canvas.setPointerCapture(e.pointerId);
   2628             }
   2629             renderTtrpgDock();
   2630           } else if (ttrpgTool === "select") {
   2631             selectedPropId = "";
   2632             renderTtrpgDock();
   2633           }
   2634         };
   2635         canvas.onpointermove = (e) => {
   2636           if (!lastTransform) return;
   2637           if (editMode) {
   2638             if (panning && panStart && lastTransform) {
   2639               const dx = e.clientX - panStart.x;
   2640               const dy = e.clientY - panStart.y;
   2641               const worldDx = -(dx / lastTransform.zoom);
   2642               const worldDy = -(dy / lastTransform.zoom);
   2643               const nx = (panStart.cx * lastTransform.worldW + worldDx) / lastTransform.worldW;
   2644               const ny = (panStart.cy * lastTransform.worldH + worldDy) / lastTransform.worldH;
   2645               cameraPos = { x: Math.max(0, Math.min(1, nx)), y: Math.max(0, Math.min(1, ny)) };
   2646               return;
   2647             }
   2648             if (polyDrag) {
   2649               const pt = screenToWorldNormalized(e.clientX, e.clientY, canvas, lastTransform);
   2650               if (!pt) return;
   2651               const dx = pt.x - polyDrag.start.x;
   2652               const dy = pt.y - polyDrag.start.y;
   2653               const list = polysForKind(activeMap, polyDrag.kind);
   2654               const poly = list[polyDrag.index];
   2655               if (!poly) return;
   2656               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)) }));
   2657               return;
   2658             }
   2659             if (vertexDrag) {
   2660               const pt = screenToWorldNormalized(e.clientX, e.clientY, canvas, lastTransform);
   2661               if (!pt) return;
   2662               const list = polysForKind(activeMap, vertexDrag.kind);
   2663               const poly = list[vertexDrag.index];
   2664               const pts = Array.isArray(poly?.points) ? poly.points : null;
   2665               if (!poly || !pts || vertexDrag.vIdx < 0 || vertexDrag.vIdx >= pts.length) return;
   2666               pts[vertexDrag.vIdx] = { x: pt.x, y: pt.y };
   2667               poly.points = pts;
   2668               return;
   2669             }
   2670             return;
   2671           }
   2672           if (panning && panStart && lastTransform) {
   2673             const dx = e.clientX - panStart.x;
   2674             const dy = e.clientY - panStart.y;
   2675             const worldDx = -(dx / lastTransform.zoom);
   2676             const worldDy = -(dy / lastTransform.zoom);
   2677             const nx = (panStart.cx * lastTransform.worldW + worldDx) / lastTransform.worldW;
   2678             const ny = (panStart.cy * lastTransform.worldH + worldDy) / lastTransform.worldH;
   2679             cameraPos = { x: Math.max(0, Math.min(1, nx)), y: Math.max(0, Math.min(1, ny)) };
   2680             return;
   2681           }
   2682           if (!propDrag || !activeMap?.ttrpgEnabled || !canManageTtrpg) return;
   2683           const pt = screenToWorldNormalized(e.clientX, e.clientY, canvas, lastTransform);
   2684           if (!pt) return;
   2685           const x = Math.max(0, Math.min(1, pt.x + propDrag.offsetX));
   2686           const y = Math.max(0, Math.min(1, pt.y + propDrag.offsetY));
   2687           const props = Array.isArray(activeMap.props) ? activeMap.props : [];
   2688           const idx = props.findIndex((p) => String(p?.id || "") === propDrag.propId);
   2689           if (idx < 0) return;
   2690           const prev = props[idx];
   2691           if (!prev) return;
   2692           const moved = Math.hypot((prev.x || 0) - x, (prev.y || 0) - y) > 0.0006;
   2693           if (moved) propDragMoved = true;
   2694           props[idx] = { ...prev, x, y };
   2695           activeMap.props = props;
   2696           const now = Date.now();
   2697           if (now - lastPropMoveAt > 70) {
   2698             lastPropMoveAt = now;
   2699             ctx.send("ttrpgPropMove", { mapId: activeMap.id, propId: propDrag.propId, x, y, z: prev.z || 0, rot: prev.rot || 0, scale: prev.scale || 1 });
   2700           }
   2701         };
   2702         canvas.onpointerup = (e) => {
   2703           if (!lastTransform) return;
   2704           if (editMode) {
   2705             panning = false;
   2706             panStart = null;
   2707             polyDrag = null;
   2708             vertexDrag = null;
   2709             try {
   2710               canvas.releasePointerCapture(e.pointerId);
   2711             } catch {
   2712               // ignore
   2713             }
   2714             return;
   2715           }
   2716           if (panning) {
   2717             panning = false;
   2718             panStart = null;
   2719             if (activeMap?.ttrpgEnabled && canManageTtrpg && ttrpgTool === "pan") canvas.style.cursor = "grab";
   2720             return;
   2721           }
   2722 
   2723           if (propDrag) {
   2724             // finalize drag
   2725             const props = Array.isArray(activeMap.props) ? activeMap.props : [];
   2726             const idx = props.findIndex((p) => String(p?.id || "") === propDrag.propId);
   2727             if (idx >= 0) {
   2728               const p = props[idx];
   2729               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 });
   2730             }
   2731             propDrag = null;
   2732             renderTtrpgDock();
   2733             return;
   2734           }
   2735 
   2736           // Place prop (GM only) by clicking canvas with a selected sprite.
   2737           if (!activeMap?.ttrpgEnabled || !canManageTtrpg) return;
   2738           if (ttrpgTool !== "place") return;
   2739           if (!selectedSpriteId) return;
   2740           if (e.button !== 0) return;
   2741           const pt = screenToWorldNormalized(e.clientX, e.clientY, canvas, lastTransform);
   2742           if (!pt) return;
   2743           const propId = `prop_${Date.now()}_${Math.random().toString(16).slice(2)}`;
   2744           const sprite = (Array.isArray(activeMap.sprites) ? activeMap.sprites : []).find((s) => String(s?.id || "") === String(selectedSpriteId || ""));
   2745           const isToken = sprite?.kind === "token";
   2746           const optimistic = {
   2747             id: propId,
   2748             spriteId: selectedSpriteId,
   2749             x: pt.x,
   2750             y: pt.y,
   2751             z: 0,
   2752             rot: placeRot,
   2753             scale: placeScale,
   2754             nickname: "",
   2755             hpCurrent: isToken ? 10 : 0,
   2756             hpMax: isToken ? 10 : 0,
   2757             controlledBy: ""
   2758           };
   2759           if (!Array.isArray(activeMap.props)) activeMap.props = [];
   2760           activeMap.props = [...activeMap.props.filter((p) => String(p?.id || "") !== propId), optimistic];
   2761           selectedPropId = propId;
   2762           renderTtrpgDock();
   2763           ctx.send("ttrpgPropAdd", { mapId: activeMap.id, id: propId, spriteId: selectedSpriteId, x: pt.x, y: pt.y, z: 0, rot: placeRot, scale: placeScale });
   2764         };
   2765       }
   2766 
   2767       const walkieBtn = document.getElementById("mapsWalkieBtn");
   2768       if (walkieBtn) {
   2769         const down = async (e) => {
   2770           if (e) e.preventDefault();
   2771           if (editMode) return;
   2772           if (!activeMap?.walkiesEnabled) return;
   2773           try {
   2774             await startWalkie();
   2775           } catch (err) {
   2776             ctx.toast("Walkie", String(err?.message || err));
   2777           }
   2778         };
   2779         const up = (e) => {
   2780           if (e) e.preventDefault();
   2781           stopWalkie();
   2782         };
   2783         walkieBtn.onpointerdown = down;
   2784         walkieBtn.onpointerup = up;
   2785         walkieBtn.onpointercancel = up;
   2786         walkieBtn.onpointerleave = (e) => {
   2787           // If the pointer is captured during recording, we'll still stop on up; otherwise stop on leave.
   2788           if (walkieRecording) up(e);
   2789         };
   2790       }
   2791 
   2792       renderTtrpgDock();
   2793     }
   2794 
   2795     function renderTtrpgDock() {
   2796       const dock = document.getElementById("mapsTtrpgDock");
   2797       if (!dock) return;
   2798       if (!activeMap?.ttrpgEnabled) {
   2799         dock.innerHTML = "";
   2800         dock.classList.remove("collapsed");
   2801         return;
   2802       }
   2803       const collapsed = Boolean(ttrpgDockCollapsed);
   2804       dock.classList.toggle("collapsed", collapsed);
   2805       const sprites = Array.isArray(activeMap.sprites) ? activeMap.sprites : [];
   2806       const props = Array.isArray(activeMap.props) ? activeMap.props : [];
   2807       const kind = spriteKind === "token" ? "token" : "prop";
   2808       const spritesOfKind = sprites.filter((s) => (s?.kind || "prop") === kind);
   2809       if (canManageTtrpg) {
   2810         const hasSelected = spritesOfKind.some((s) => String(s?.id || "") === String(selectedSpriteId || ""));
   2811         if ((!selectedSpriteId || !hasSelected) && spritesOfKind.length) {
   2812           selectedSpriteId = String(spritesOfKind[0]?.id || "");
   2813         }
   2814       }
   2815       const selectedSprite = sprites.find((s) => String(s?.id || "") === selectedSpriteId) || null;
   2816       const placingLabel = selectedSprite ? (selectedSprite.name ? selectedSprite.name : selectedSprite.id) : "";
   2817       const thumbs = spritesOfKind
   2818         .map((s) => {
   2819           const sel = s.id === selectedSpriteId ? " selected" : "";
   2820           const label = s.name ? escapeHtml(s.name) : escapeHtml(s.id);
   2821           return `<button type="button" class="spriteThumb${sel}" data-spriteid="${escapeHtml(s.id)}" title="${label}">
   2822             <img src="${escapeHtml(s.url)}" alt="" />
   2823           </button>`;
   2824         })
   2825         .join("");
   2826       const spriteById = new Map(sprites.map((s) => [String(s?.id || ""), s]));
   2827       const placedOfKind = props.filter((p) => (spriteById.get(String(p?.spriteId || ""))?.kind || "prop") === kind);
   2828       const placedThumbs = placedOfKind
   2829         .slice()
   2830         .sort((a, b) => Number(a?.y || 0) - Number(b?.y || 0))
   2831         .slice(0, 160)
   2832         .map((p) => {
   2833           const spr = spriteById.get(String(p?.spriteId || ""));
   2834           if (!spr) return "";
   2835           const sel = String(p?.id || "") === String(selectedPropId || "") ? " selected" : "";
   2836           const label = spr.name ? spr.name : spr.id;
   2837           return `<button type="button" class="spriteThumb${sel}" data-propid="${escapeHtml(String(p.id || ""))}" title="${escapeHtml(label)}">
   2838             <img src="${escapeHtml(String(spr.url || ""))}" alt="" />
   2839           </button>`;
   2840         })
   2841         .join("");
   2842       const me = String(ctx.getUser() || "").trim().toLowerCase();
   2843       const selectedProp = props.find((p) => String(p?.id || "") === String(selectedPropId || "")) || null;
   2844       const selectedPropSprite = selectedProp ? spriteById.get(String(selectedProp.spriteId || "")) || null : null;
   2845       const selectedIsToken = Boolean(selectedProp && selectedPropSprite?.kind === "token");
   2846       const selectedScale = selectedProp ? Math.max(0.1, Math.min(4.0, Number(selectedProp.scale || 1))) : 1;
   2847       if (speakingAsPropId && !props.some((p) => String(p?.id || "") === String(speakingAsPropId))) {
   2848         speakingAsPropId = "";
   2849       }
   2850       const speakingProp = speakingAsPropId ? props.find((p) => String(p?.id || "") === String(speakingAsPropId)) : null;
   2851       const speakingSprite = speakingProp ? spriteById.get(String(speakingProp.spriteId || "")) : null;
   2852       const speakingName = speakingProp ? String(speakingProp.nickname || speakingSprite?.name || speakingSprite?.id || "token") : "";
   2853 
   2854       dock.innerHTML = `
   2855         <div class="dockRow">
   2856           <div class="dockTitle">TTRPG mode</div>
   2857           <div class="small muted grow">${canManageTtrpg ? "GM tools enabled" : "Waiting for GM…"}</div>
   2858         </div>
   2859         <div class="dockRow">
   2860           <button type="button" class="${ttrpgTool === "select" ? "primary" : "ghost"} smallBtn" id="mapsToolSelect">Select</button>
   2861           <button type="button" class="${ttrpgTool === "place" ? "primary" : "ghost"} smallBtn" id="mapsToolPlace">Place</button>
   2862           <button type="button" class="${ttrpgTool === "pan" ? "primary" : "ghost"} smallBtn" id="mapsToolPan">Pan</button>
   2863           <div class="small muted grow">V select · P place · Space pan</div>
   2864         </div>
   2865         <div class="dockRow">
   2866           <button type="button" class="${kind === "prop" ? "primary" : "ghost"} smallBtn" id="mapsKindProp">Props</button>
   2867           <button type="button" class="${kind === "token" ? "primary" : "ghost"} smallBtn" id="mapsKindToken">Tokens</button>
   2868           <div class="small muted grow">${spritesOfKind.length} sprites • ${placedOfKind.length} placed</div>
   2869         </div>
   2870         <div class="dockRow">
   2871           <div class="small muted grow">${
   2872             canManageTtrpg
   2873               ? placingLabel
   2874                 ? `Placing: <b>${escapeHtml(placingLabel)}</b> · Rot <b>Q/E</b> ${escapeHtml(placeRot.toFixed(0))}° · Scale <b>Z/X</b> ${escapeHtml(placeScale.toFixed(2))}x`
   2875                 : `Select a sprite then place it on the map.`
   2876               : `${kind === "token" ? "Tokens" : "Props"} are controlled by the GM.`
   2877           }</div>
   2878         </div>
   2879         <div class="dockRow">
   2880           ${canManageTtrpg ? `
   2881           <input id="mapsSpriteFile" type="file" accept="image/png,image/webp" />
   2882           <input id="mapsSpriteName" type="text" maxlength="40" placeholder="Sprite name" />
   2883           <div class="dockScale">
   2884             <input id="mapsSpriteScale" type="range" min="0.25" max="4" step="0.05" value="${escapeHtml(String(spriteScale))}" />
   2885             <div class="dockScaleVal" id="mapsSpriteScaleVal">${escapeHtml(spriteScale.toFixed(2))}</div>
   2886           </div>
   2887           <div class="dockScale">
   2888             <input id="mapsPlaceScale" type="range" min="0.10" max="4" step="0.05" value="${escapeHtml(String(placeScale))}" />
   2889             <div class="dockScaleVal" id="mapsPlaceScaleVal">${escapeHtml(placeScale.toFixed(2))}</div>
   2890           </div>
   2891           <button type="button" class="ghost smallBtn" id="mapsSpriteAddBtn">Add</button>
   2892           <button type="button" class="ghost smallBtn" id="mapsSpriteRemoveBtn" ${selectedSpriteId ? "" : "disabled"}>Remove</button>
   2893           ` : `<div class="muted small">Props/tokens are controlled by the GM.</div>`}
   2894         </div>
   2895         <div class="spriteTray" id="mapsSpriteTray">
   2896           ${thumbs || `<div class="muted small">No sprites yet.</div>`}
   2897         </div>
   2898         <div class="dockRow" style="margin-top:6px;">
   2899           <div class="small muted grow">Placed ${kind === "token" ? "tokens" : "props"}</div>
   2900           <button type="button" class="ghost smallBtn" id="mapsPropDeleteBtn" ${selectedPropId ? "" : "disabled"}>Delete</button>
   2901         </div>
   2902         <div class="spriteTray" id="mapsPropTray">
   2903           ${placedThumbs || `<div class="muted small">None placed yet.</div>`}
   2904         </div>
   2905         <div class="dockRow" style="margin-top:8px;">
   2906           ${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>`}
   2907           <button type="button" class="ghost smallBtn" id="mapsScaleDownBtn" ${selectedProp ? "" : "disabled"}>-</button>
   2908           <button type="button" class="ghost smallBtn" id="mapsScaleUpBtn" ${selectedProp ? "" : "disabled"}>+</button>
   2909         </div>
   2910         ${
   2911           selectedIsToken
   2912             ? `<div class="dockRow" style="gap:8px;">
   2913                  <input id="mapsPropNick" type="text" maxlength="40" placeholder="Token nickname" value="${escapeHtml(String(selectedProp.nickname || ""))}" />
   2914                  <input id="mapsPropHpCur" type="number" min="0" max="9999" value="${escapeHtml(String(selectedProp.hpCurrent || 0))}" />
   2915                  <input id="mapsPropHpMax" type="number" min="0" max="9999" value="${escapeHtml(String(selectedProp.hpMax || 0))}" />
   2916                  <button type="button" class="ghost smallBtn" id="mapsPropSaveMeta">Save</button>
   2917                </div>
   2918                <div class="dockRow" style="gap:8px;">
   2919                  <div class="small muted grow">Controller: <b>${escapeHtml(String(selectedProp.controlledBy || "none"))}</b></div>
   2920                   <button type="button" class="ghost smallBtn" id="mapsTokenPossessBtn" ${selectedProp.controlledBy && selectedProp.controlledBy !== me ? "disabled" : ""}>${selectedProp.controlledBy === me ? "Release" : "Possess"}</button>
   2921                   <button type="button" class="${speakingAsPropId === selectedPropId ? "primary" : "ghost"} smallBtn" id="mapsTokenSpeakBtn">${speakingAsPropId === selectedPropId ? "Speaking" : "Speak as"}</button>
   2922                 </div>`
   2923              : ""
   2924         }
   2925         ${speakingProp ? `<div class="dockRow"><div class="small muted">Chat voice: <b>${escapeHtml(speakingName)}</b></div></div>` : ""}
   2926       `;
   2927 
   2928       const dockChildren = Array.from(dock.children);
   2929       const headerRow = dockChildren[0];
   2930       if (headerRow) {
   2931         headerRow.classList.add("dockHeaderRow");
   2932         const grow = headerRow.querySelector(".grow");
   2933         if (grow) {
   2934           const base = grow.getAttribute("data-base") || grow.textContent || "";
   2935           if (!grow.hasAttribute("data-base")) grow.setAttribute("data-base", base);
   2936           grow.textContent = base + (collapsed ? " (hidden)" : "");
   2937         }
   2938         let toggleBtn = headerRow.querySelector("#mapsDockToggle");
   2939         if (!toggleBtn) {
   2940           toggleBtn = document.createElement("button");
   2941           toggleBtn.type = "button";
   2942           toggleBtn.className = "ghost smallBtn";
   2943           toggleBtn.id = "mapsDockToggle";
   2944           headerRow.appendChild(toggleBtn);
   2945         }
   2946 
   2947         let releaseBtn = headerRow.querySelector("#mapsReleasePossession");
   2948         if (canManageTtrpg) {
   2949           const possessed = getPossessedTokenForMe();
   2950           if (!releaseBtn) {
   2951             releaseBtn = document.createElement("button");
   2952             releaseBtn.type = "button";
   2953             releaseBtn.className = "ghost smallBtn";
   2954             releaseBtn.id = "mapsReleasePossession";
   2955           }
   2956           if (toggleBtn) headerRow.insertBefore(releaseBtn, toggleBtn);
   2957           else headerRow.appendChild(releaseBtn);
   2958           releaseBtn.textContent = "Release";
   2959           releaseBtn.disabled = !possessed;
   2960           releaseBtn.title = possessed ? "Release token control" : "Not controlling a token";
   2961           releaseBtn.onclick = () => {
   2962             if (!activeMap?.id) return;
   2963             const pid = possessed ? String(possessed.id || "") : String(selectedPropId || "");
   2964             const meU = String(ctx.getUser() || "").trim().toLowerCase();
   2965             speakingAsPropId = "";
   2966             if (meU) {
   2967               const list = Array.isArray(activeMap.props) ? activeMap.props : [];
   2968               let changed = false;
   2969               const nextList = list.map((p) => {
   2970                 if (!p) return p;
   2971                 if (String(p.controlledBy || "") !== meU) return p;
   2972                 const spr = spriteById.get(String(p?.spriteId || ""));
   2973                 if (spr?.kind !== "token") return p;
   2974                 changed = true;
   2975                 return { ...p, controlledBy: "" };
   2976               });
   2977               if (changed) activeMap.props = nextList;
   2978             }
   2979             ctx.send("ttrpgTokenPossess", { mapId: activeMap.id, propId: pid, action: "release" });
   2980             renderTtrpgDock();
   2981           };
   2982         } else if (releaseBtn) {
   2983           releaseBtn.remove();
   2984         }
   2985 
   2986         toggleBtn.textContent = collapsed ? "Show" : "Hide";
   2987         toggleBtn.onclick = () => {
   2988           if (!activeMap?.id) return;
   2989           writeDockCollapsed(activeMap.id, !ttrpgDockCollapsed);
   2990           renderTtrpgDock();
   2991         };
   2992 
   2993         const body = document.createElement("div");
   2994         body.className = "dockBody";
   2995         for (let i = 1; i < dockChildren.length; i++) {
   2996           body.appendChild(dockChildren[i]);
   2997         }
   2998         dock.appendChild(body);
   2999       }
   3000 
   3001       const tray = document.getElementById("mapsSpriteTray");
   3002       if (tray) {
   3003         tray.onclick = (e) => {
   3004           const btn = e.target.closest("[data-spriteid]");
   3005           if (!btn) return;
   3006           selectedSpriteId = String(btn.getAttribute("data-spriteid") || "");
   3007           selectedPropId = "";
   3008           ttrpgTool = "place";
   3009           renderTtrpgDock();
   3010         };
   3011       }
   3012 
   3013       const toolSelect = document.getElementById("mapsToolSelect");
   3014       const toolPlace = document.getElementById("mapsToolPlace");
   3015       const toolPan = document.getElementById("mapsToolPan");
   3016       if (toolSelect) toolSelect.onclick = () => { ttrpgTool = "select"; renderMapView(); };
   3017       if (toolPlace) toolPlace.onclick = () => { ttrpgTool = "place"; renderMapView(); };
   3018       if (toolPan) toolPan.onclick = () => { ttrpgTool = "pan"; renderMapView(); };
   3019 
   3020       const kindPropBtn = document.getElementById("mapsKindProp");
   3021       const kindTokenBtn = document.getElementById("mapsKindToken");
   3022       if (kindPropBtn) kindPropBtn.onclick = () => { spriteKind = "prop"; selectedSpriteId = ""; selectedPropId = ""; renderTtrpgDock(); };
   3023       if (kindTokenBtn) kindTokenBtn.onclick = () => { spriteKind = "token"; selectedSpriteId = ""; selectedPropId = ""; renderTtrpgDock(); };
   3024 
   3025       const propTray = document.getElementById("mapsPropTray");
   3026       if (propTray) {
   3027         propTray.onclick = (e) => {
   3028           const btn = e.target.closest("[data-propid]");
   3029           if (!btn) return;
   3030           selectedPropId = String(btn.getAttribute("data-propid") || "");
   3031           ttrpgTool = "select";
   3032           renderTtrpgDock();
   3033         };
   3034       }
   3035 
   3036       if (!canManageTtrpg) return;
   3037       const scaleEl = document.getElementById("mapsSpriteScale");
   3038       const scaleVal = document.getElementById("mapsSpriteScaleVal");
   3039       if (scaleEl && scaleVal) {
   3040         const update = () => {
   3041           const v = Math.max(0.1, Math.min(4.0, Number(scaleEl.value || 1)));
   3042           spriteScale = v;
   3043           scaleVal.textContent = v.toFixed(2);
   3044         };
   3045         scaleEl.oninput = update;
   3046         update();
   3047       }
   3048       const placeScaleEl = document.getElementById("mapsPlaceScale");
   3049       const placeScaleVal = document.getElementById("mapsPlaceScaleVal");
   3050       if (placeScaleEl && placeScaleVal) {
   3051         const update = () => {
   3052           const v = Math.max(0.1, Math.min(4.0, Number(placeScaleEl.value || 1)));
   3053           placeScale = v;
   3054           placeScaleVal.textContent = v.toFixed(2);
   3055         };
   3056         placeScaleEl.oninput = update;
   3057         update();
   3058       }
   3059       const addBtn = document.getElementById("mapsSpriteAddBtn");
   3060       const fileEl = document.getElementById("mapsSpriteFile");
   3061       const nameEl = document.getElementById("mapsSpriteName");
   3062       if (addBtn && fileEl) {
   3063         addBtn.onclick = async () => {
   3064           const file = fileEl.files && fileEl.files[0] ? fileEl.files[0] : null;
   3065           if (!file) return;
   3066           addBtn.disabled = true;
   3067           try {
   3068             const url = await uploadSpriteImageFile(file);
   3069             const name = nameEl ? String(nameEl.value || "").trim() : "";
   3070             const k = spriteKind === "token" ? "token" : "prop";
   3071             const id = `spr_${Date.now()}_${Math.random().toString(16).slice(2)}`;
   3072             const sprite = { id, kind: k, name, url, scale: spriteScale };
   3073             if (!Array.isArray(activeMap.sprites)) activeMap.sprites = [];
   3074             activeMap.sprites = [...activeMap.sprites.filter((s) => String(s?.id || "") !== id), sprite];
   3075             selectedSpriteId = id;
   3076             selectedPropId = "";
   3077             ttrpgTool = "place";
   3078             renderTtrpgDock();
   3079             ctx.send("ttrpgSpriteAdd", { mapId: activeMap.id, id, kind: k, name, url, scale: spriteScale });
   3080             fileEl.value = "";
   3081             if (nameEl) nameEl.value = "";
   3082           } catch (e) {
   3083             ctx.toast("Sprites", String(e?.message || e));
   3084           } finally {
   3085             addBtn.disabled = false;
   3086           }
   3087         };
   3088       }
   3089       const removeBtn = document.getElementById("mapsSpriteRemoveBtn");
   3090       if (removeBtn) {
   3091         removeBtn.onclick = () => {
   3092           if (!selectedSpriteId) return;
   3093           const ok = window.confirm("Remove this sprite? Props using it will also be removed.");
   3094           if (!ok) return;
   3095           const spriteId = String(selectedSpriteId || "");
   3096           activeMap.sprites = (Array.isArray(activeMap.sprites) ? activeMap.sprites : []).filter((s) => String(s?.id || "") !== spriteId);
   3097           activeMap.props = (Array.isArray(activeMap.props) ? activeMap.props : []).filter((p) => String(p?.spriteId || "") !== spriteId);
   3098           if (speakingAsPropId && !activeMap.props.some((p) => String(p?.id || "") === String(speakingAsPropId))) speakingAsPropId = "";
   3099           selectedSpriteId = "";
   3100           selectedPropId = "";
   3101           renderTtrpgDock();
   3102           ctx.send("ttrpgSpriteRemove", { mapId: activeMap.id, spriteId });
   3103         };
   3104       }
   3105 
   3106       const delPropBtn = document.getElementById("mapsPropDeleteBtn");
   3107       if (delPropBtn) {
   3108         delPropBtn.onclick = () => {
   3109           if (!selectedPropId) return;
   3110           const ok = window.confirm("Delete this placed item?");
   3111           if (!ok) return;
   3112           const propId = String(selectedPropId || "");
   3113           activeMap.props = (Array.isArray(activeMap.props) ? activeMap.props : []).filter((p) => String(p?.id || "") !== propId);
   3114           if (speakingAsPropId === propId) speakingAsPropId = "";
   3115           selectedPropId = "";
   3116           renderTtrpgDock();
   3117           ctx.send("ttrpgPropRemove", { mapId: activeMap.id, propId });
   3118         };
   3119       }
   3120 
   3121       const scaleDownBtn = document.getElementById("mapsScaleDownBtn");
   3122       const scaleUpBtn = document.getElementById("mapsScaleUpBtn");
   3123       if (scaleDownBtn && selectedProp) {
   3124         scaleDownBtn.onclick = () => {
   3125           const next = Math.max(0.1, Math.min(4.0, Number(selectedProp.scale || 1) - 0.1));
   3126           const list = Array.isArray(activeMap.props) ? activeMap.props : [];
   3127           const idx = list.findIndex((p) => String(p?.id || "") === String(selectedProp.id || ""));
   3128           if (idx < 0) return;
   3129           list[idx] = { ...list[idx], scale: next };
   3130           activeMap.props = list;
   3131           renderTtrpgDock();
   3132           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 });
   3133         };
   3134       }
   3135       if (scaleUpBtn && selectedProp) {
   3136         scaleUpBtn.onclick = () => {
   3137           const next = Math.max(0.1, Math.min(4.0, Number(selectedProp.scale || 1) + 0.1));
   3138           const list = Array.isArray(activeMap.props) ? activeMap.props : [];
   3139           const idx = list.findIndex((p) => String(p?.id || "") === String(selectedProp.id || ""));
   3140           if (idx < 0) return;
   3141           list[idx] = { ...list[idx], scale: next };
   3142           activeMap.props = list;
   3143           renderTtrpgDock();
   3144           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 });
   3145         };
   3146       }
   3147 
   3148       const saveMetaBtn = document.getElementById("mapsPropSaveMeta");
   3149       if (saveMetaBtn && selectedProp && selectedIsToken) {
   3150         saveMetaBtn.onclick = () => {
   3151           const nickEl = document.getElementById("mapsPropNick");
   3152           const hpCurEl = document.getElementById("mapsPropHpCur");
   3153           const hpMaxEl = document.getElementById("mapsPropHpMax");
   3154           const nickname = String(nickEl?.value || "").trim().slice(0, 40);
   3155           const hpMax = Math.max(0, Math.min(9999, Number(hpMaxEl?.value || 0) || 0));
   3156           let hpCurrent = Math.max(0, Math.min(hpMax > 0 ? hpMax : 9999, Number(hpCurEl?.value || 0) || 0));
   3157           if (hpCurrent > hpMax && hpMax > 0) hpCurrent = hpMax;
   3158           const list = Array.isArray(activeMap.props) ? activeMap.props : [];
   3159           const idx = list.findIndex((p) => String(p?.id || "") === String(selectedProp.id || ""));
   3160           if (idx < 0) return;
   3161           list[idx] = { ...list[idx], nickname, hpCurrent, hpMax };
   3162           activeMap.props = list;
   3163           renderTtrpgDock();
   3164           ctx.send("ttrpgPropPatch", { mapId: activeMap.id, propId: selectedProp.id, nickname, hpCurrent, hpMax });
   3165         };
   3166       }
   3167 
   3168       const possessBtn = document.getElementById("mapsTokenPossessBtn");
   3169       if (possessBtn && selectedProp && selectedIsToken) {
   3170         possessBtn.onclick = () => {
   3171           if (!activeMap?.id) return;
   3172           const isMine = String(selectedProp.controlledBy || "") === me;
   3173           const action = isMine ? "release" : "possess";
   3174 
   3175           // Optimistic UI: keep possession exclusive and make release always "release all my tokens".
   3176           const list = Array.isArray(activeMap.props) ? activeMap.props : [];
   3177           const targetId = String(selectedProp.id || "");
   3178           let changed = false;
   3179           const nextList = list.map((p) => {
   3180             if (!p) return p;
   3181             const pid = String(p?.id || "");
   3182             const spr = spriteById.get(String(p?.spriteId || ""));
   3183             const isToken = spr?.kind === "token";
   3184             if (!isToken) return p;
   3185             if (action === "release") {
   3186               if (String(p.controlledBy || "") !== me) return p;
   3187               changed = true;
   3188               return { ...p, controlledBy: "" };
   3189             }
   3190             // possess: release other tokens I control, and claim selected
   3191             if (pid === targetId) {
   3192               if (String(p.controlledBy || "") !== me) changed = true;
   3193               return { ...p, controlledBy: me };
   3194             }
   3195             if (String(p.controlledBy || "") === me) {
   3196               changed = true;
   3197               return { ...p, controlledBy: "" };
   3198             }
   3199             return p;
   3200           });
   3201           if (action === "release") speakingAsPropId = "";
   3202           if (action === "possess") speakingAsPropId = targetId;
   3203           if (changed) activeMap.props = nextList;
   3204 
   3205           ctx.send("ttrpgTokenPossess", { mapId: activeMap.id, propId: selectedProp.id, action });
   3206           renderTtrpgDock();
   3207         };
   3208       }
   3209 
   3210       const speakBtn = document.getElementById("mapsTokenSpeakBtn");
   3211       if (speakBtn && selectedProp && selectedIsToken) {
   3212         speakBtn.onclick = () => {
   3213           speakingAsPropId = speakingAsPropId === selectedProp.id ? "" : selectedProp.id;
   3214           renderTtrpgDock();
   3215         };
   3216       }
   3217     }
   3218 
   3219     function screenToWorldNormalized(clientX, clientY, canvas, tr) {
   3220       const rect = canvas.getBoundingClientRect();
   3221       const sx = clientX - rect.left;
   3222       const sy = clientY - rect.top;
   3223       if (sx < 0 || sy < 0 || sx > rect.width || sy > rect.height) return null;
   3224       const worldX = tr.srcX + (sx / tr.zoom);
   3225       const worldY = tr.srcY + (sy / tr.zoom);
   3226       return { x: Math.max(0, Math.min(1, worldX / tr.worldW)), y: Math.max(0, Math.min(1, worldY / tr.worldH)) };
   3227     }
   3228 
   3229     function getSpriteImage(url) {
   3230       const src = String(url || "").trim();
   3231       if (!src) return null;
   3232       const now = Date.now();
   3233       const cached = spriteImageCache.get(src);
   3234       if (cached) {
   3235         if (cached.status === "ok" && cached.img) return cached.img;
   3236         if (cached.status === "loading") return null;
   3237         if (cached.status === "error" && now - Number(cached.failedAt || 0) < 5000) return null;
   3238       }
   3239       const img = new Image();
   3240       if (!src.startsWith("data:")) img.crossOrigin = "anonymous";
   3241       spriteImageCache.set(src, { img: null, status: "loading", failedAt: 0 });
   3242       img.onload = () => spriteImageCache.set(src, { img, status: "ok", failedAt: 0 });
   3243       img.onerror = () => spriteImageCache.set(src, { img: null, status: "error", failedAt: Date.now() });
   3244       img.src = src;
   3245       return null;
   3246     }
   3247 
   3248     function commitDraftPoly() {
   3249       if (!draftPoly || draftPoly.length < 3) return false;
   3250       const poly = { points: draftPoly.map((p) => ({ x: p.x, y: p.y })) };
   3251       const list = polysForKind(activeMap, editKind, true);
   3252       if (editKind === "exit") {
   3253         let name = String(exitDraftName || "").trim().slice(0, 40);
   3254         if (!name) name = `Exit ${list.length + 1}`.slice(0, 40);
   3255         const action = exitAction === "toMap" ? "toMap" : "toMaps";
   3256         const selfId = String(activeMap?.id || "").trim().toLowerCase();
   3257         const otherMaps = (Array.isArray(maps) ? maps : [])
   3258           .map((m) => String(m?.id || "").trim().toLowerCase())
   3259           .filter(Boolean)
   3260           .filter((id) => id !== selfId)
   3261           .sort((a, b) => a.localeCompare(b));
   3262         let toMapId = action === "toMap" ? String(exitTargetMapId || "").trim().toLowerCase() : "";
   3263         if (action === "toMap" && (!toMapId || toMapId === selfId)) {
   3264           toMapId = otherMaps[0] || "";
   3265         }
   3266         if (action === "toMap" && !toMapId) return false;
   3267         const targetExit = action === "toMap" ? String(exitTargetExitName || "").trim().slice(0, 40) : "";
   3268         list.push({ ...poly, name, action, toMapId, targetExit });
   3269       } else if (editKind === "hidden") {
   3270         const mode = fogDraftMode === "manual" ? "manual" : "auto";
   3271         const name = String(fogDraftName || "").trim().slice(0, 40);
   3272         list.push({ ...poly, mode, name });
   3273       } else if (editKind === "fall") {
   3274         const dir = String(fallDraftDirection || "").trim().toLowerCase();
   3275         const direction = dir === "up" || dir === "left" || dir === "right" ? dir : "down";
   3276         const offset = Math.max(0.002, Math.min(0.08, Number(fallDraftOffset || 0.02) || 0.02));
   3277         const name = String(fallDraftName || "").trim().slice(0, 40);
   3278         list.push({ ...poly, direction, offset, name });
   3279       } else {
   3280         list.push(poly);
   3281       }
   3282       draftPoly = [];
   3283       selectedPolyKind = editKind;
   3284       selectedPolyIndex = Math.max(0, list.length - 1);
   3285       selectedVertexIndex = -1;
   3286       return true;
   3287     }
   3288 
   3289     function syncPolyDraftUi() {
   3290       const ptsEl = document.getElementById("mapsPolyDraftPts");
   3291       if (ptsEl) ptsEl.textContent = String(draftPoly.length);
   3292       const closeDraft = document.getElementById("mapsPolyCloseDraft");
   3293       if (closeDraft) closeDraft.toggleAttribute("disabled", draftPoly.length < 3);
   3294       const undoPt = document.getElementById("mapsPolyUndoPt");
   3295       if (undoPt) undoPt.toggleAttribute("disabled", !draftPoly.length);
   3296       const clearDraft = document.getElementById("mapsPolyClearDraft");
   3297       if (clearDraft) clearDraft.toggleAttribute("disabled", !draftPoly.length);
   3298     }
   3299 
   3300     function polysForKind(map, kind, ensure = false) {
   3301       if (!map) return [];
   3302       const k = String(kind || "");
   3303       if (k === "collision") {
   3304         if (ensure && !Array.isArray(map.collisions)) map.collisions = [];
   3305         return Array.isArray(map.collisions) ? map.collisions : [];
   3306       }
   3307       if (k === "mask") {
   3308         if (ensure && !Array.isArray(map.masks)) map.masks = [];
   3309         return Array.isArray(map.masks) ? map.masks : [];
   3310       }
   3311       if (k === "exit") {
   3312         if (ensure && !Array.isArray(map.exits)) map.exits = [];
   3313         return Array.isArray(map.exits) ? map.exits : [];
   3314       }
   3315       if (k === "hidden") {
   3316         if (ensure && !Array.isArray(map.hiddenMasks)) map.hiddenMasks = [];
   3317         return Array.isArray(map.hiddenMasks) ? map.hiddenMasks : [];
   3318       }
   3319       if (k === "fall") {
   3320         if (ensure && !Array.isArray(map.fallThroughs)) map.fallThroughs = [];
   3321         return Array.isArray(map.fallThroughs) ? map.fallThroughs : [];
   3322       }
   3323       if (k === "occluder") {
   3324         if (ensure && !Array.isArray(map.occluders)) map.occluders = [];
   3325         return Array.isArray(map.occluders) ? map.occluders : [];
   3326       }
   3327       return [];
   3328     }
   3329 
   3330     function kindLabel(kind) {
   3331       if (kind === "collision") return "Collisions";
   3332       if (kind === "mask") return "Y-sort masks";
   3333       if (kind === "exit") return "Exits";
   3334       if (kind === "hidden") return "Fog zones";
   3335       if (kind === "fall") return "Fall-through zones";
   3336       if (kind === "occluder") return "Occluders";
   3337       return String(kind || "");
   3338     }
   3339 
   3340     function hitTestPoly(pt, map, kind) {
   3341       const list = polysForKind(map, kind);
   3342       for (let i = list.length - 1; i >= 0; i--) {
   3343         const p = list[i];
   3344         if (p && pointInPoly(pt, p)) return { index: i };
   3345       }
   3346       return null;
   3347     }
   3348 
   3349     function hitTestVertex(clientX, clientY, canvas, tr, poly) {
   3350       const pts = Array.isArray(poly?.points) ? poly.points : [];
   3351       if (!pts.length) return -1;
   3352       const rect = canvas.getBoundingClientRect();
   3353       const sx = clientX - rect.left;
   3354       const sy = clientY - rect.top;
   3355       const threshold = 12;
   3356       let best = { idx: -1, d2: Infinity };
   3357       for (let i = 0; i < pts.length; i++) {
   3358         const p = pts[i];
   3359         const x = (Number(p.x) * tr.worldW - tr.srcX) * tr.zoom;
   3360         const y = (Number(p.y) * tr.worldH - tr.srcY) * tr.zoom;
   3361         const dx = x - sx;
   3362         const dy = y - sy;
   3363         const d2 = dx * dx + dy * dy;
   3364         if (d2 < best.d2) best = { idx: i, d2 };
   3365       }
   3366       if (best.idx < 0) return -1;
   3367       return best.d2 <= threshold * threshold ? best.idx : -1;
   3368     }
   3369 
   3370     function polyCentroid(points) {
   3371       const pts = Array.isArray(points) ? points : [];
   3372       if (!pts.length) return { x: 0.5, y: 0.5 };
   3373       let sx = 0;
   3374       let sy = 0;
   3375       for (const p of pts) {
   3376         sx += Number(p?.x || 0);
   3377         sy += Number(p?.y || 0);
   3378       }
   3379       return { x: Math.max(0, Math.min(1, sx / pts.length)), y: Math.max(0, Math.min(1, sy / pts.length)) };
   3380     }
   3381 
   3382     function renderPolyModal() {
   3383       const list = polysForKind(activeMap, editKind);
   3384       const selOk = selectedPolyKind === editKind && selectedPolyIndex >= 0 && selectedPolyIndex < list.length;
   3385       const selected = selOk ? list[selectedPolyIndex] : null;
   3386 
   3387       const exitModel =
   3388         editKind === "exit" && selected
   3389           ? {
   3390               name: String(selected.name || "").trim(),
   3391               action: String(selected.action || "toMaps") === "toMap" ? "toMap" : "toMaps",
   3392               toMapId: String(selected.toMapId || "").trim().toLowerCase(),
   3393               targetExit: String(selected.targetExit || "").trim(),
   3394             }
   3395           : {
   3396               name: String(exitDraftName || "").trim(),
   3397               action: exitAction === "toMap" ? "toMap" : "toMaps",
   3398               toMapId: String(exitTargetMapId || "").trim().toLowerCase(),
   3399               targetExit: String(exitTargetExitName || "").trim(),
   3400             };
   3401 
   3402       const otherMaps = (Array.isArray(maps) ? maps : [])
   3403         .map((m) => String(m?.id || "").trim().toLowerCase())
   3404         .filter(Boolean)
   3405         .filter((id) => id !== String(activeMap?.id || "").trim().toLowerCase())
   3406         .sort((a, b) => a.localeCompare(b));
   3407 
   3408       const mapOptions = otherMaps
   3409         .map((id) => `<option value="${escapeHtml(id)}" ${id === exitModel.toMapId ? "selected" : ""}>${escapeHtml(id)}</option>`)
   3410         .join("");
   3411 
   3412       const polyRows = list
   3413         .map((p, idx) => {
   3414           const on = selOk && idx === selectedPolyIndex ? " selected" : "";
   3415           let label = `${kindLabel(editKind).replace(/s$/, "")} ${idx + 1}`;
   3416           if (editKind === "exit") {
   3417             const name = String(p?.name || "").trim();
   3418             label = name ? name : `Exit ${idx + 1}`;
   3419           }
   3420           const pts = Array.isArray(p?.points) ? p.points.length : 0;
   3421           const meta =
   3422             editKind === "exit"
   3423               ? `${String(p?.action || "toMaps") === "toMap" ? `to ${escapeHtml(String(p?.toMapId || ""))}` : "to maps"}`
   3424               : `${pts} pts`;
   3425           return `<button type="button" class="polyRowBtn${on}" data-polysel="${idx}">
   3426             <div class="polyRowMain">${escapeHtml(label)}</div>
   3427             <div class="polyRowMeta">${meta}</div>
   3428           </button>`;
   3429         })
   3430         .join("");
   3431 
   3432       const kindBtn = (kind, label, disabled = false) => {
   3433         const on = editKind === kind;
   3434         const dis = disabled ? "disabled" : "";
   3435         return `<button type="button" class="${on ? "primary" : "ghost"} smallBtn" ${dis} data-polykind="${escapeHtml(kind)}">${escapeHtml(label)}</button>`;
   3436       };
   3437       const toolBtn = (tool, label) => {
   3438         const on = editTool === tool;
   3439         return `<button type="button" class="${on ? "primary" : "ghost"} smallBtn" data-polytool="${escapeHtml(tool)}">${escapeHtml(label)}</button>`;
   3440       };
   3441 
   3442       const inspectorBody = (() => {
   3443         const pts = Array.isArray(selected?.points) ? selected.points.length : 0;
   3444         if (editKind !== "exit") {
   3445           const header = selOk ? "Selected" : "New polygon defaults";
   3446           const fogModel =
   3447             editKind === "hidden" && selected
   3448               ? { mode: String(selected.mode || "auto") === "manual" ? "manual" : "auto", name: String(selected.name || "").trim().slice(0, 40) }
   3449               : { mode: fogDraftMode === "manual" ? "manual" : "auto", name: String(fogDraftName || "").trim().slice(0, 40) };
   3450           const fallModel =
   3451             editKind === "fall" && selected
   3452               ? {
   3453                   direction: ["up", "down", "left", "right"].includes(String(selected.direction || "")) ? String(selected.direction || "") : "down",
   3454                   offset: Math.max(0.002, Math.min(0.08, Number(selected.offset || 0.02) || 0.02)),
   3455                   name: String(selected.name || "").trim().slice(0, 40),
   3456                 }
   3457               : {
   3458                   direction: ["up", "down", "left", "right"].includes(String(fallDraftDirection || "")) ? String(fallDraftDirection || "") : "down",
   3459                   offset: Math.max(0.002, Math.min(0.08, Number(fallDraftOffset || 0.02) || 0.02)),
   3460                   name: String(fallDraftName || "").trim().slice(0, 40),
   3461                 };
   3462 
   3463           const metaControls =
   3464             editKind === "hidden"
   3465               ? `
   3466                 <label style="margin-top:10px;">
   3467                   <div class="small muted">Reveal mode</div>
   3468                   <select id="mapsFogMode">
   3469                     <option value="auto" ${fogModel.mode === "auto" ? "selected" : ""}>Auto (reveal when inside)</option>
   3470                     <option value="manual" ${fogModel.mode === "manual" ? "selected" : ""}>Manual (toggle “Reveal fog”)</option>
   3471                   </select>
   3472                 </label>
   3473                 <label style="margin-top:10px;">
   3474                   <div class="small muted">Label (optional)</div>
   3475                   <input id="mapsFogName" type="text" maxlength="40" placeholder="Example: Secret room" value="${escapeHtml(fogModel.name)}" />
   3476                 </label>
   3477               `
   3478               : editKind === "fall"
   3479                 ? `
   3480                 <label style="margin-top:10px;">
   3481                   <div class="small muted">Direction</div>
   3482                   <select id="mapsFallDirection">
   3483                     <option value="down" ${fallModel.direction === "down" ? "selected" : ""}>Down</option>
   3484                     <option value="up" ${fallModel.direction === "up" ? "selected" : ""}>Up</option>
   3485                     <option value="left" ${fallModel.direction === "left" ? "selected" : ""}>Left</option>
   3486                     <option value="right" ${fallModel.direction === "right" ? "selected" : ""}>Right</option>
   3487                   </select>
   3488                 </label>
   3489                 <label style="margin-top:10px;">
   3490                   <div class="small muted">Nudge distance</div>
   3491                   <input id="mapsFallOffset" type="number" min="0.002" max="0.08" step="0.002" value="${escapeHtml(fallModel.offset.toFixed(3))}" />
   3492                 </label>
   3493                 <label style="margin-top:10px;">
   3494                   <div class="small muted">Label (optional)</div>
   3495                   <input id="mapsFallName" type="text" maxlength="40" placeholder="Example: Cliff edge" value="${escapeHtml(fallModel.name)}" />
   3496                 </label>
   3497               `
   3498                 : "";
   3499 
   3500           return `
   3501             <div class="small muted">${header}</div>
   3502             <div style="margin-top:6px;"><b>${escapeHtml(kindLabel(editKind))}</b></div>
   3503             ${selOk ? `<div class="small muted" style="margin-top:6px;">${pts} points</div>` : `<div class="small muted" style="margin-top:6px;">Draw a polygon, then Close polygon.</div>`}
   3504             ${metaControls}
   3505           `;
   3506         }
   3507 
   3508         const header = selOk ? "Selected exit" : "New exit defaults";
   3509         return `
   3510           <div class="small muted">${header}</div>
   3511           <label style="margin-top:6px;">
   3512             <div class="small muted">Name</div>
   3513             <input id="mapsExitName" type="text" maxlength="40" placeholder="Example: North Gate" value="${escapeHtml(exitModel.name)}" />
   3514           </label>
   3515           <label style="margin-top:10px;">
   3516             <div class="small muted">Behavior</div>
   3517             <select id="mapsExitBehavior">
   3518               <option value="toMaps" ${exitModel.action === "toMaps" ? "selected" : ""}>Exit to Maps</option>
   3519               <option value="toMap" ${exitModel.action === "toMap" ? "selected" : ""}>Exit to Map</option>
   3520             </select>
   3521           </label>
   3522           <div class="${exitModel.action === "toMap" ? "" : "hidden"}" id="mapsExitToMapWrap" style="margin-top:10px;">
   3523             <label>
   3524               <div class="small muted">Target map</div>
   3525               <select id="mapsExitToMap">${mapOptions}</select>
   3526             </label>
   3527             <label style="margin-top:10px;">
   3528               <div class="small muted">Target exit name (optional)</div>
   3529               <input id="mapsExitTargetExit" type="text" maxlength="40" placeholder="Example: South Gate" value="${escapeHtml(exitModel.targetExit)}" />
   3530             </label>
   3531           </div>
   3532           <div class="small muted" style="margin-top:10px;">${selOk ? `${pts} points` : "Tip: pick Draw, click 3+ points, then Close polygon."}</div>
   3533         `;
   3534       })();
   3535 
   3536       return `
   3537         <div class="mapsPolyModal" id="mapsPolyModal">
   3538           <div class="mapsPolyModalInner">
   3539             <div class="mapsPolyHeader">
   3540               <div>
   3541                 <div class="mapsPolyTitle">Polygon editor</div>
   3542                 <div class="small muted">Right-click or Shift+drag to pan. Esc clears draft. Delete removes selected.</div>
   3543               </div>
   3544               <button type="button" class="ghost smallBtn" id="mapsPolyModalClose">Close</button>
   3545             </div>
   3546             <div class="row" style="gap:10px; flex-wrap:wrap; margin-top:10px;">
   3547               ${kindBtn("collision", "Collisions")}
   3548               ${kindBtn("mask", "Y-sort")}
   3549               ${kindBtn("exit", "Exits")}
   3550               ${kindBtn("hidden", "Fog")}
   3551               ${kindBtn("fall", "Fall-through")}
   3552               ${kindBtn("occluder", "Occluders (soon)", true)}
   3553               <div class="small muted" style="margin-left:auto;">${escapeHtml(String(list.length))} in ${escapeHtml(kindLabel(editKind))}</div>
   3554             </div>
   3555             <div class="row" style="gap:10px; flex-wrap:wrap; margin-top:10px;">
   3556               ${toolBtn("draw", "Draw")}
   3557               ${toolBtn("select", "Select")}
   3558               ${toolBtn("move", "Move")}
   3559               ${toolBtn("vertex", "Vertices")}
   3560               <div class="row" style="gap:10px; margin-left:auto; flex-wrap:wrap;">
   3561                 <button type="button" class="ghost smallBtn" id="mapsPolyPrev">Prev</button>
   3562                 <button type="button" class="ghost smallBtn" id="mapsPolyNext">Next</button>
   3563                 <button type="button" class="ghost smallBtn" id="mapsPolyCopy">Copy</button>
   3564                 <button type="button" class="ghost smallBtn" id="mapsPolyPaste" ${polyClipboard ? "" : "disabled"}>Paste</button>
   3565                 <button type="button" class="ghost smallBtn" id="mapsPolyDuplicate" ${selOk ? "" : "disabled"}>Duplicate</button>
   3566                 <button type="button" class="danger smallBtn" id="mapsPolyDelete" ${selOk ? "" : "disabled"}>Delete</button>
   3567                 <button type="button" class="primary smallBtn" id="mapsPolySaveAll">Save</button>
   3568               </div>
   3569             </div>
   3570             <div class="row" style="gap:10px; flex-wrap:wrap; margin-top:10px; align-items:center;">
   3571               <div class="small muted">Draft: <b id="mapsPolyDraftPts">${escapeHtml(String(draftPoly.length))}</b> pts</div>
   3572               <button type="button" class="ghost smallBtn" id="mapsPolyUndoPt" ${draftPoly.length ? "" : "disabled"}>Undo point</button>
   3573               <button type="button" class="ghost smallBtn" id="mapsPolyCloseDraft" ${draftPoly.length >= 3 ? "" : "disabled"}>Close polygon</button>
   3574               <button type="button" class="ghost smallBtn" id="mapsPolyClearDraft" ${draftPoly.length ? "" : "disabled"}>Clear draft</button>
   3575               <button type="button" class="ghost smallBtn" id="mapsPolyClearKind" ${list.length ? "" : "disabled"}>Clear kind</button>
   3576               <div class="small muted" id="mapsPolyStatus" style="margin-left:auto;"></div>
   3577             </div>
   3578             <div class="mapsPolyGrid">
   3579               <div class="mapsPolyList" id="mapsPolyList">${polyRows || `<div class="small muted" style="padding:10px;">No polygons yet.</div>`}</div>
   3580               <div class="mapsPolyInspector" id="mapsPolyInspector">${inspectorBody}</div>
   3581             </div>
   3582           </div>
   3583         </div>
   3584       `;
   3585     }
   3586 
   3587     function syncPolyModal(canEditMap) {
   3588       const existing = document.getElementById("mapsPolyModal");
   3589       if (!(mode === "map" && canEditMap && editMode && activeMap)) {
   3590         if (existing) existing.remove();
   3591         return;
   3592       }
   3593       const html = String(renderPolyModal() || "").trim();
   3594       if (!html) return;
   3595       const tmp = document.createElement("div");
   3596       tmp.innerHTML = html;
   3597       const next = tmp.firstElementChild;
   3598       if (!next) return;
   3599       if (existing) existing.replaceWith(next);
   3600       else document.body.appendChild(next);
   3601     }
   3602 
   3603     function wirePolyModalHandlers() {
   3604       const modal = document.getElementById("mapsPolyModal");
   3605       if (!modal) return;
   3606 
   3607       const modalClose = document.getElementById("mapsPolyModalClose");
   3608       if (modalClose) {
   3609         modalClose.onclick = () => {
   3610           editMode = false;
   3611           draftPoly = [];
   3612           polyDrag = null;
   3613           vertexDrag = null;
   3614           selectedPolyKind = "";
   3615           selectedPolyIndex = -1;
   3616           selectedVertexIndex = -1;
   3617           renderMapView();
   3618         };
   3619       }
   3620 
   3621       const statusEl = document.getElementById("mapsPolyStatus");
   3622       const setStatus = (txt) => {
   3623         if (statusEl) statusEl.textContent = txt;
   3624       };
   3625 
   3626       modal.onclick = (e) => {
   3627         const k = e.target.closest?.("[data-polykind]");
   3628         if (k) {
   3629           if (k.hasAttribute("disabled")) return;
   3630           const kind = String(k.getAttribute("data-polykind") || "");
   3631           if (!kind) return;
   3632           devLog("info", "maps:polyKind", { from: editKind, to: kind });
   3633           editKind = kind;
   3634           const list = polysForKind(activeMap, editKind);
   3635           if (!(selectedPolyKind === editKind && selectedPolyIndex >= 0 && selectedPolyIndex < list.length)) {
   3636             selectedPolyKind = "";
   3637             selectedPolyIndex = -1;
   3638             selectedVertexIndex = -1;
   3639           }
   3640           renderMapView();
   3641           return;
   3642         }
   3643         const t = e.target.closest?.("[data-polytool]");
   3644         if (t) {
   3645           const tool = String(t.getAttribute("data-polytool") || "");
   3646           if (!tool) return;
   3647           devLog("info", "maps:polyTool", { from: editTool, to: tool });
   3648           editTool = tool;
   3649           renderMapView();
   3650           return;
   3651         }
   3652       };
   3653 
   3654       const listEl = document.getElementById("mapsPolyList");
   3655       if (listEl) {
   3656         listEl.onclick = (e) => {
   3657           const btn = e.target.closest?.("[data-polysel]");
   3658           if (!btn) return;
   3659           const idx = Number(btn.getAttribute("data-polysel") || -1);
   3660           const list = polysForKind(activeMap, editKind);
   3661           if (idx < 0 || idx >= list.length) return;
   3662           selectedPolyKind = editKind;
   3663           selectedPolyIndex = idx;
   3664           selectedVertexIndex = -1;
   3665           editTool = "select";
   3666           renderMapView();
   3667         };
   3668       }
   3669 
   3670       const undoPt = document.getElementById("mapsPolyUndoPt");
   3671       if (undoPt) {
   3672         undoPt.onclick = () => {
   3673           if (!draftPoly.length) return;
   3674           draftPoly.pop();
   3675           setStatus(`${draftPoly.length} pts (draft)`);
   3676           syncPolyDraftUi();
   3677           renderMapView();
   3678         };
   3679       }
   3680       const clearDraft = document.getElementById("mapsPolyClearDraft");
   3681       if (clearDraft) {
   3682         clearDraft.onclick = () => {
   3683           draftPoly = [];
   3684           setStatus("Draft cleared.");
   3685           syncPolyDraftUi();
   3686           renderMapView();
   3687         };
   3688       }
   3689       const closeDraft = document.getElementById("mapsPolyCloseDraft");
   3690       if (closeDraft) {
   3691         closeDraft.onclick = () => {
   3692           const before = { kind: editKind, draftPts: draftPoly.length, action: exitAction, toMapId: exitTargetMapId };
   3693           const ok = commitDraftPoly();
   3694           devLog("info", "maps:closeDraft", { ok, ...before, exits: Array.isArray(activeMap?.exits) ? activeMap.exits.length : 0 });
   3695           setStatus(ok ? "Polygon added." : editKind === "exit" ? "Exit needs a name + target map (if to map)." : "Need at least 3 points.");
   3696           if (ok) editTool = "select";
   3697           syncPolyDraftUi();
   3698           renderMapView();
   3699         };
   3700       }
   3701 
   3702       const clearKind = document.getElementById("mapsPolyClearKind");
   3703       if (clearKind) {
   3704         clearKind.onclick = () => {
   3705           const list = polysForKind(activeMap, editKind);
   3706           if (!list.length) return;
   3707           const ok = window.confirm(`Clear all polygons in ${kindLabel(editKind)}? (Not saved yet)`);
   3708           if (!ok) return;
   3709           const target = polysForKind(activeMap, editKind, true);
   3710           target.length = 0;
   3711           selectedPolyKind = "";
   3712           selectedPolyIndex = -1;
   3713           selectedVertexIndex = -1;
   3714           draftPoly = [];
   3715           setStatus("Cleared kind (not saved).");
   3716           renderMapView();
   3717         };
   3718       }
   3719 
   3720       const saveAll = document.getElementById("mapsPolySaveAll");
   3721       if (saveAll) {
   3722         saveAll.onclick = () => {
   3723           if (!activeMap?.id) return;
   3724           const collisions = Array.isArray(activeMap.collisions) ? activeMap.collisions : [];
   3725           const masks = Array.isArray(activeMap.masks) ? activeMap.masks : [];
   3726           const exits = Array.isArray(activeMap.exits) ? activeMap.exits : [];
   3727           const hiddenMasks = Array.isArray(activeMap.hiddenMasks) ? activeMap.hiddenMasks : [];
   3728           const fallThroughs = Array.isArray(activeMap.fallThroughs) ? activeMap.fallThroughs : [];
   3729           const occluders = Array.isArray(activeMap.occluders) ? activeMap.occluders : [];
   3730           devLog("info", "maps:saveAll", {
   3731             mapId: activeMap.id,
   3732             collisions: collisions.length,
   3733             masks: masks.length,
   3734             exits: exits.length,
   3735             hiddenMasks: hiddenMasks.length,
   3736             fallThroughs: fallThroughs.length,
   3737             occluders: occluders.length,
   3738           });
   3739           ctx.send("updateMap", { id: activeMap.id, collisions, masks, exits, hiddenMasks, fallThroughs, occluders });
   3740           setStatus("Saved.");
   3741         };
   3742       }
   3743 
   3744       const cycle = (delta) => {
   3745         const list = polysForKind(activeMap, editKind);
   3746         if (!list.length) return;
   3747         const current = selectedPolyKind === editKind ? selectedPolyIndex : -1;
   3748         const next = current < 0 ? 0 : (current + delta + list.length) % list.length;
   3749         selectedPolyKind = editKind;
   3750         selectedPolyIndex = next;
   3751         selectedVertexIndex = -1;
   3752         editTool = "select";
   3753         renderMapView();
   3754       };
   3755       const prevBtn = document.getElementById("mapsPolyPrev");
   3756       const nextBtn = document.getElementById("mapsPolyNext");
   3757       if (prevBtn) prevBtn.onclick = () => cycle(-1);
   3758       if (nextBtn) nextBtn.onclick = () => cycle(+1);
   3759 
   3760       const copyBtn = document.getElementById("mapsPolyCopy");
   3761       if (copyBtn) {
   3762         copyBtn.onclick = () => {
   3763           const list = polysForKind(activeMap, editKind);
   3764           const ok = selectedPolyKind === editKind && selectedPolyIndex >= 0 && selectedPolyIndex < list.length;
   3765           if (!ok) return;
   3766           const src = list[selectedPolyIndex];
   3767           polyClipboard = { kind: editKind, poly: JSON.parse(JSON.stringify(src)) };
   3768           setStatus("Copied.");
   3769           renderMapView();
   3770         };
   3771       }
   3772       const pasteBtn = document.getElementById("mapsPolyPaste");
   3773       if (pasteBtn) {
   3774         pasteBtn.onclick = () => {
   3775           if (!polyClipboard || !polyClipboard.poly) return;
   3776           const targetKind = editKind;
   3777           const list = polysForKind(activeMap, targetKind, true);
   3778           const copy = JSON.parse(JSON.stringify(polyClipboard.poly));
   3779           const pts = Array.isArray(copy.points) ? copy.points : [];
   3780           for (const p of pts) {
   3781             p.x = Math.max(0, Math.min(1, Number(p.x || 0) + 0.02));
   3782             p.y = Math.max(0, Math.min(1, Number(p.y || 0) + 0.02));
   3783           }
   3784           copy.points = pts;
   3785           if (targetKind === "exit") {
   3786             copy.name = String(copy.name || "Exit").trim().slice(0, 40);
   3787           }
   3788           list.push(copy);
   3789           selectedPolyKind = targetKind;
   3790           selectedPolyIndex = list.length - 1;
   3791           selectedVertexIndex = -1;
   3792           setStatus("Pasted.");
   3793           renderMapView();
   3794         };
   3795       }
   3796 
   3797       const dupBtn = document.getElementById("mapsPolyDuplicate");
   3798       if (dupBtn) {
   3799         dupBtn.onclick = () => {
   3800           const list = polysForKind(activeMap, editKind);
   3801           const ok = selectedPolyKind === editKind && selectedPolyIndex >= 0 && selectedPolyIndex < list.length;
   3802           if (!ok) return;
   3803           polyClipboard = { kind: editKind, poly: JSON.parse(JSON.stringify(list[selectedPolyIndex])) };
   3804           const paste = document.getElementById("mapsPolyPaste");
   3805           if (paste) paste.click();
   3806         };
   3807       }
   3808 
   3809       const delBtn = document.getElementById("mapsPolyDelete");
   3810       if (delBtn) {
   3811         delBtn.onclick = () => {
   3812           const list = polysForKind(activeMap, editKind);
   3813           const ok = selectedPolyKind === editKind && selectedPolyIndex >= 0 && selectedPolyIndex < list.length;
   3814           if (!ok) return;
   3815           const label = editKind === "exit" ? String(list[selectedPolyIndex]?.name || `Exit ${selectedPolyIndex + 1}`) : `${kindLabel(editKind)} #${selectedPolyIndex + 1}`;
   3816           const yes = window.confirm(`Delete "${label}"? (Not saved yet)`);
   3817           if (!yes) return;
   3818           list.splice(selectedPolyIndex, 1);
   3819           selectedPolyKind = "";
   3820           selectedPolyIndex = -1;
   3821           selectedVertexIndex = -1;
   3822           setStatus("Deleted (not saved).");
   3823           renderMapView();
   3824         };
   3825       }
   3826 
   3827       // Exit meta fields (selected exit OR draft defaults)
   3828       const exitNameEl = document.getElementById("mapsExitName");
   3829       const exitBehaviorEl = document.getElementById("mapsExitBehavior");
   3830       const exitToMapWrap = document.getElementById("mapsExitToMapWrap");
   3831       const exitToMapEl = document.getElementById("mapsExitToMap");
   3832       const exitTargetExitEl = document.getElementById("mapsExitTargetExit");
   3833       // Fog meta fields (selected fog OR draft defaults)
   3834       const fogModeEl = document.getElementById("mapsFogMode");
   3835       const fogNameEl = document.getElementById("mapsFogName");
   3836       // Fall-through meta fields (selected fall OR draft defaults)
   3837       const fallDirEl = document.getElementById("mapsFallDirection");
   3838       const fallOffsetEl = document.getElementById("mapsFallOffset");
   3839       const fallNameEl = document.getElementById("mapsFallName");
   3840 
   3841       const applyExitModel = (patch) => {
   3842         if (editKind !== "exit") return;
   3843         const list = polysForKind(activeMap, "exit", true);
   3844         const isSel = selectedPolyKind === "exit" && selectedPolyIndex >= 0 && selectedPolyIndex < list.length;
   3845         if (isSel) {
   3846           list[selectedPolyIndex] = { ...list[selectedPolyIndex], ...patch };
   3847         } else {
   3848           if (Object.prototype.hasOwnProperty.call(patch, "name")) exitDraftName = String(patch.name || "");
   3849           if (Object.prototype.hasOwnProperty.call(patch, "action")) exitAction = patch.action === "toMap" ? "toMap" : "toMaps";
   3850           if (Object.prototype.hasOwnProperty.call(patch, "toMapId")) exitTargetMapId = String(patch.toMapId || "");
   3851           if (Object.prototype.hasOwnProperty.call(patch, "targetExit")) exitTargetExitName = String(patch.targetExit || "");
   3852         }
   3853       };
   3854 
   3855       const applyFogModel = (patch) => {
   3856         if (editKind !== "hidden") return;
   3857         const list = polysForKind(activeMap, "hidden", true);
   3858         const isSel = selectedPolyKind === "hidden" && selectedPolyIndex >= 0 && selectedPolyIndex < list.length;
   3859         if (isSel) {
   3860           const next = { ...list[selectedPolyIndex], ...patch };
   3861           next.mode = String(next.mode || "auto") === "manual" ? "manual" : "auto";
   3862           next.name = typeof next.name === "string" ? next.name.trim().slice(0, 40) : "";
   3863           list[selectedPolyIndex] = next;
   3864         } else {
   3865           if (Object.prototype.hasOwnProperty.call(patch, "mode")) fogDraftMode = String(patch.mode || "") === "manual" ? "manual" : "auto";
   3866           if (Object.prototype.hasOwnProperty.call(patch, "name")) fogDraftName = typeof patch.name === "string" ? patch.name.trim().slice(0, 40) : "";
   3867         }
   3868       };
   3869 
   3870       const applyFallModel = (patch) => {
   3871         if (editKind !== "fall") return;
   3872         const list = polysForKind(activeMap, "fall", true);
   3873         const isSel = selectedPolyKind === "fall" && selectedPolyIndex >= 0 && selectedPolyIndex < list.length;
   3874         const normalizeDir = (d) => {
   3875           const dir = String(d || "").trim().toLowerCase();
   3876           return dir === "up" || dir === "left" || dir === "right" ? dir : "down";
   3877         };
   3878         const normalizeOffset = (n) => Math.max(0.002, Math.min(0.08, Number(n || 0.02) || 0.02));
   3879         if (isSel) {
   3880           const next = { ...list[selectedPolyIndex], ...patch };
   3881           next.direction = normalizeDir(next.direction);
   3882           next.offset = normalizeOffset(next.offset);
   3883           next.name = typeof next.name === "string" ? next.name.trim().slice(0, 40) : "";
   3884           list[selectedPolyIndex] = next;
   3885         } else {
   3886           if (Object.prototype.hasOwnProperty.call(patch, "direction")) fallDraftDirection = normalizeDir(patch.direction);
   3887           if (Object.prototype.hasOwnProperty.call(patch, "offset")) fallDraftOffset = normalizeOffset(patch.offset);
   3888           if (Object.prototype.hasOwnProperty.call(patch, "name")) fallDraftName = typeof patch.name === "string" ? patch.name.trim().slice(0, 40) : "";
   3889         }
   3890       };
   3891 
   3892       const syncExitVis = () => {
   3893         const behavior = exitBehaviorEl ? String(exitBehaviorEl.value || "toMaps") : "toMaps";
   3894         if (exitToMapWrap) exitToMapWrap.classList.toggle("hidden", behavior !== "toMap");
   3895       };
   3896 
   3897       if (exitBehaviorEl) {
   3898         exitBehaviorEl.onchange = () => {
   3899           const behavior = String(exitBehaviorEl.value || "toMaps") === "toMap" ? "toMap" : "toMaps";
   3900           applyExitModel({ action: behavior });
   3901           if (behavior === "toMap") {
   3902             const want = String(exitToMapEl?.value || "").trim().toLowerCase() || (otherMaps && otherMaps.length ? otherMaps[0] : "");
   3903             if (want) {
   3904               if (exitToMapEl && exitToMapEl.value !== want) exitToMapEl.value = want;
   3905               applyExitModel({ toMapId: want });
   3906             }
   3907           }
   3908           syncExitVis();
   3909           renderMapView();
   3910         };
   3911       }
   3912       if (exitNameEl) {
   3913         exitNameEl.oninput = () => {
   3914           applyExitModel({ name: String(exitNameEl.value || "").slice(0, 40) });
   3915         };
   3916       }
   3917       if (exitToMapEl) {
   3918         if (!exitToMapEl.value && otherMaps.length) {
   3919           exitToMapEl.value = otherMaps[0];
   3920           // If we're editing draft defaults, keep the draft model in sync with the UI.
   3921           applyExitModel({ toMapId: String(exitToMapEl.value || "").trim().toLowerCase() });
   3922         }
   3923         exitToMapEl.onchange = () => {
   3924           applyExitModel({ toMapId: String(exitToMapEl.value || "").trim().toLowerCase() });
   3925         };
   3926       }
   3927       if (exitTargetExitEl) {
   3928         exitTargetExitEl.oninput = () => {
   3929           applyExitModel({ targetExit: String(exitTargetExitEl.value || "").slice(0, 40) });
   3930         };
   3931       }
   3932       syncExitVis();
   3933 
   3934       if (fogModeEl) {
   3935         fogModeEl.onchange = () => {
   3936           applyFogModel({ mode: String(fogModeEl.value || "auto") === "manual" ? "manual" : "auto" });
   3937         };
   3938       }
   3939       if (fogNameEl) {
   3940         fogNameEl.oninput = () => {
   3941           applyFogModel({ name: String(fogNameEl.value || "").slice(0, 40) });
   3942         };
   3943       }
   3944 
   3945       if (fallDirEl) {
   3946         fallDirEl.onchange = () => {
   3947           applyFallModel({ direction: String(fallDirEl.value || "down") });
   3948         };
   3949       }
   3950       if (fallOffsetEl) {
   3951         const onOffset = () => applyFallModel({ offset: Number(fallOffsetEl.value || 0.02) || 0.02 });
   3952         fallOffsetEl.oninput = onOffset;
   3953         fallOffsetEl.onchange = onOffset;
   3954       }
   3955       if (fallNameEl) {
   3956         fallNameEl.oninput = () => {
   3957           applyFallModel({ name: String(fallNameEl.value || "").slice(0, 40) });
   3958         };
   3959       }
   3960     }
   3961 
   3962     function pointInPoly(pt, poly) {
   3963       const x = pt.x;
   3964       const y = pt.y;
   3965       const pts = Array.isArray(poly?.points) ? poly.points : [];
   3966       if (pts.length < 3) return false;
   3967       let inside = false;
   3968       for (let i = 0, j = pts.length - 1; i < pts.length; j = i++) {
   3969         const xi = Number(pts[i].x);
   3970         const yi = Number(pts[i].y);
   3971         const xj = Number(pts[j].x);
   3972         const yj = Number(pts[j].y);
   3973         const intersect = yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi + 1e-12) + xi;
   3974         if (intersect) inside = !inside;
   3975       }
   3976       return inside;
   3977     }
   3978 
   3979     function loadBackground(url) {
   3980       bgImg = null;
   3981       if (!url) return;
   3982       const img = new Image();
   3983       img.crossOrigin = "anonymous";
   3984       img.onload = () => {
   3985         bgImg = img;
   3986       };
   3987       img.src = url;
   3988     }
   3989 
   3990     function stopLoop() {
   3991       if (raf) cancelAnimationFrame(raf);
   3992       raf = 0;
   3993       lastTick = 0;
   3994     }
   3995 
   3996     function startLoop() {
   3997       stopLoop();
   3998       lastTick = performance.now();
   3999       raf = requestAnimationFrame(tick);
   4000     }
   4001 
   4002     function tick(ts) {
   4003       raf = requestAnimationFrame(tick);
   4004       const dt = Math.max(0, Math.min(0.05, (ts - lastTick) / 1000));
   4005       lastTick = ts;
   4006       if (mode !== "map" || !activeMap) return;
   4007 
   4008       // Smooth remote users to reduce jitter.
   4009       for (const [name, u] of users.entries()) {
   4010         if (!u) continue;
   4011         if (name === (self || String(ctx.getUser() || "").trim().toLowerCase())) continue;
   4012         if (typeof u.tx !== "number" || typeof u.ty !== "number") continue;
   4013         if (typeof u.x !== "number" || typeof u.y !== "number") {
   4014           u.x = u.tx;
   4015           u.y = u.ty;
   4016           continue;
   4017         }
   4018         const k = 1 - Math.exp(-dt * 14);
   4019         u.x = u.x + (u.tx - u.x) * k;
   4020         u.y = u.y + (u.ty - u.y) * k;
   4021       }
   4022 
   4023       // Movement speed in world pixels/sec, converted to normalized units based on map size.
   4024       const dims = getWorldDims();
   4025       const speedPxPerSec = 220;
   4026       const possessedToken = getPossessedTokenForMe();
   4027       const controlPos = possessedToken
   4028         ? { x: Math.max(0, Math.min(1, Number(possessedToken.x || 0.5))), y: Math.max(0, Math.min(1, Number(possessedToken.y || 0.5))) }
   4029         : { x: localPos.x, y: localPos.y };
   4030       let dx = 0;
   4031       let dy = 0;
   4032       if (!editMode) {
   4033         if (keys.has("ArrowUp") || keys.has("KeyW")) dy -= 1;
   4034         if (keys.has("ArrowDown") || keys.has("KeyS")) dy += 1;
   4035         if (keys.has("ArrowLeft") || keys.has("KeyA")) dx -= 1;
   4036         if (keys.has("ArrowRight") || keys.has("KeyD")) dx += 1;
   4037       }
   4038       if (selfInvisible && !possessedToken) {
   4039         dx = 0;
   4040         dy = 0;
   4041       }
   4042       const mag = Math.hypot(dx, dy) || 1;
   4043       dx /= mag;
   4044       dy /= mag;
   4045 
   4046       const moved = Boolean(dx || dy);
   4047       if (moved) {
   4048         const speedNx = speedPxPerSec / Math.max(1, dims.w);
   4049         const speedNy = speedPxPerSec / Math.max(1, dims.h);
   4050         let nextX = Math.max(0, Math.min(1, controlPos.x + dx * speedNx * dt));
   4051         let nextY = Math.max(0, Math.min(1, controlPos.y + dy * speedNy * dt));
   4052 
   4053         // Fall-through zones: if you enter one, teleport to the far side based on direction.
   4054         const fallThroughs = Array.isArray(activeMap.fallThroughs) ? activeMap.fallThroughs : [];
   4055         if (fallThroughs.length) {
   4056           const prevPt = { x: controlPos.x, y: controlPos.y };
   4057           const entered = (poly) => !pointInPoly(prevPt, poly) && pointInPoly({ x: nextX, y: nextY }, poly);
   4058           for (const poly of fallThroughs) {
   4059             if (!poly || !Array.isArray(poly.points) || poly.points.length < 3) continue;
   4060             if (!entered(poly)) continue;
   4061             const pts = poly.points;
   4062             let minX = 1,
   4063               maxX = 0,
   4064               minY = 1,
   4065               maxY = 0;
   4066             for (const p of pts) {
   4067               const x = Number(p?.x);
   4068               const y = Number(p?.y);
   4069               if (!Number.isFinite(x) || !Number.isFinite(y)) continue;
   4070               minX = Math.min(minX, x);
   4071               maxX = Math.max(maxX, x);
   4072               minY = Math.min(minY, y);
   4073               maxY = Math.max(maxY, y);
   4074             }
   4075             const dirRaw = String(poly.direction || "").trim().toLowerCase();
   4076             const direction = dirRaw === "up" || dirRaw === "left" || dirRaw === "right" ? dirRaw : "down";
   4077             const off = Math.max(0.002, Math.min(0.08, Number(poly.offset || 0.02) || 0.02));
   4078             if (direction === "up" || direction === "down") {
   4079               const clampedX = Math.max(minX + 1e-4, Math.min(maxX - 1e-4, nextX));
   4080               nextX = Math.max(0, Math.min(1, clampedX));
   4081               nextY = direction === "down" ? Math.max(0, Math.min(1, maxY + off)) : Math.max(0, Math.min(1, minY - off));
   4082               for (let i = 0; i < 8 && pointInPoly({ x: nextX, y: nextY }, poly); i++) {
   4083                 nextY = direction === "down" ? Math.max(0, Math.min(1, nextY + off)) : Math.max(0, Math.min(1, nextY - off));
   4084               }
   4085             } else {
   4086               const clampedY = Math.max(minY + 1e-4, Math.min(maxY - 1e-4, nextY));
   4087               nextY = Math.max(0, Math.min(1, clampedY));
   4088               nextX = direction === "right" ? Math.max(0, Math.min(1, maxX + off)) : Math.max(0, Math.min(1, minX - off));
   4089               for (let i = 0; i < 8 && pointInPoly({ x: nextX, y: nextY }, poly); i++) {
   4090                 nextX = direction === "right" ? Math.max(0, Math.min(1, nextX + off)) : Math.max(0, Math.min(1, nextX - off));
   4091               }
   4092             }
   4093             break;
   4094           }
   4095         }
   4096         const collisions = Array.isArray(activeMap.collisions) ? activeMap.collisions : [];
   4097         const tryPtX = { x: nextX, y: controlPos.y };
   4098         const tryPtY = { x: controlPos.x, y: nextY };
   4099         const blockedX = collisions.some((p) => pointInPoly(tryPtX, p));
   4100         const blockedY = collisions.some((p) => pointInPoly(tryPtY, p));
   4101         const finalX = !blockedX ? nextX : controlPos.x;
   4102         const finalY = !blockedY ? nextY : controlPos.y;
   4103         if (possessedToken && activeMap?.ttrpgEnabled && canManageTtrpg) {
   4104           const props = Array.isArray(activeMap.props) ? activeMap.props : [];
   4105           const idx = props.findIndex((p) => String(p?.id || "") === String(possessedToken.id || ""));
   4106           if (idx >= 0) {
   4107             const current = props[idx];
   4108             props[idx] = { ...current, x: finalX, y: finalY };
   4109             activeMap.props = props;
   4110             const now = Date.now();
   4111             if (now - lastPropMoveAt > 60) {
   4112               lastPropMoveAt = now;
   4113               ctx.send("ttrpgPropMove", {
   4114                 mapId: activeMap.id,
   4115                 propId: current.id,
   4116                 x: finalX,
   4117                 y: finalY,
   4118                 z: current.z || 0,
   4119                 rot: current.rot || 0,
   4120                 scale: current.scale || 1
   4121               });
   4122             }
   4123           }
   4124         } else {
   4125           localPos.x = finalX;
   4126           localPos.y = finalY;
   4127           const me = (self || String(ctx.getUser() || "")).trim().toLowerCase();
   4128           if (me) {
   4129             const prev = users.get(me) || { x: localPos.x, y: localPos.y, tx: localPos.x, ty: localPos.y, color: "", image: "" };
   4130             users.set(me, { ...prev, x: localPos.x, y: localPos.y, tx: localPos.x, ty: localPos.y });
   4131           }
   4132           const now = Date.now();
   4133           if (now - lastSentAt > 60) {
   4134             lastSentAt = now;
   4135             ctx.send("move", { x: localPos.x, y: localPos.y, seq: moveSeq++ });
   4136           }
   4137         }
   4138       }
   4139 
   4140       if (!editMode) {
   4141         const exitPos = possessedToken
   4142           ? { x: Math.max(0, Math.min(1, Number(possessedToken.x || 0.5))), y: Math.max(0, Math.min(1, Number(possessedToken.y || 0.5))) }
   4143           : localPos;
   4144         checkExits(exitPos);
   4145       }
   4146       draw();
   4147       cleanupBubbles();
   4148     }
   4149 
   4150     function checkExits(position = localPos) {
   4151       if (!activeMap) return;
   4152       const exits = Array.isArray(activeMap.exits) ? activeMap.exits : [];
   4153       if (!exits.length) return;
   4154       const now = Date.now();
   4155       if (now - lastExitAt < 900) return;
   4156       let triggered = null;
   4157       for (let i = 0; i < exits.length; i++) {
   4158         const ex = exits[i];
   4159         const inside = pointInPoly({ x: Number(position?.x || 0), y: Number(position?.y || 0) }, ex);
   4160         const was = Boolean(exitInside.get(i));
   4161         exitInside.set(i, inside);
   4162         if (inside && !was) {
   4163           triggered = ex;
   4164           break;
   4165         }
   4166       }
   4167       if (!triggered) return;
   4168       lastExitAt = now;
   4169       const action = String(triggered.action || "toMaps");
   4170       devLog("info", "maps:exitTriggered", {
   4171         mapId: activeMap?.id || "",
   4172         action,
   4173         toMapId: String(triggered.toMapId || ""),
   4174         targetExit: String(triggered.targetExit || ""),
   4175       });
   4176       if (action === "toMap") {
   4177         const to = String(triggered.toMapId || "").trim().toLowerCase();
   4178         const targetExit = String(triggered.targetExit || "").trim();
   4179         const selfId = String(activeMap?.id || "").trim().toLowerCase();
   4180         if (!to) {
   4181           devLog("warn", "maps:exitMissingTarget", { mapId: activeMap?.id || "", action: "toMap" });
   4182           leaveMap();
   4183           return;
   4184         }
   4185         if (to === selfId) {
   4186           devLog("warn", "maps:exitSelfTarget", { mapId: activeMap?.id || "", toMapId: to, action: "toMap" });
   4187           leaveMap();
   4188           return;
   4189         }
   4190         transitionToMap(to, targetExit);
   4191         return;
   4192       }
   4193       leaveMap();
   4194     }
   4195 
   4196     function transitionToMap(mapId, targetExitName = "") {
   4197       // Leave current room on the server side, then join target.
   4198       try {
   4199         ctx.send("leave", {});
   4200       } catch {
   4201         // ignore
   4202       }
   4203       stopWalkie();
   4204       stopAllWalkies();
   4205       exitInside.clear();
   4206       const to = String(mapId || "").trim().toLowerCase();
   4207       pendingSpawn = targetExitName ? { mapId: to, exitName: String(targetExitName || "").trim().toLowerCase() } : null;
   4208       enterMap(to);
   4209     }
   4210 
   4211     function getWorldDims() {
   4212       const w =
   4213         activeMap?.world?.w && Number.isFinite(Number(activeMap.world.w))
   4214           ? Number(activeMap.world.w)
   4215           : bgImg && (bgImg.naturalWidth || bgImg.width)
   4216             ? Number(bgImg.naturalWidth || bgImg.width)
   4217             : 1400;
   4218       const h =
   4219         activeMap?.world?.h && Number.isFinite(Number(activeMap.world.h))
   4220           ? Number(activeMap.world.h)
   4221           : bgImg && (bgImg.naturalHeight || bgImg.height)
   4222             ? Number(bgImg.naturalHeight || bgImg.height)
   4223             : 900;
   4224       return { w: Math.max(200, Math.min(10000, w)), h: Math.max(200, Math.min(10000, h)) };
   4225     }
   4226 
   4227     function propScreenBox(prop, spriteById, tr) {
   4228       const sprite = spriteById.get(String(prop?.spriteId || "")) || null;
   4229       if (!sprite) return null;
   4230       const img = getSpriteImage(sprite.url || "");
   4231       if (!img) return null;
   4232       const spriteScale = Math.max(0.1, Math.min(4.0, Number(sprite.scale || 1)));
   4233       const instanceScale = Math.max(0.1, Math.min(4.0, Number(prop?.scale || 1)));
   4234       const scale = spriteScale * instanceScale;
   4235       const maxWorld = 220;
   4236       const minWorld = 12;
   4237       const iw = Math.max(1, Number(img.naturalWidth || img.width || 1));
   4238       const ih = Math.max(1, Number(img.naturalHeight || img.height || 1));
   4239       const wWorld = Math.max(minWorld, Math.min(maxWorld, iw * scale));
   4240       const hWorld = Math.max(minWorld, Math.min(maxWorld, ih * scale));
   4241 
   4242       const xw = Number(prop?.x || 0) * tr.worldW;
   4243       const yw = Number(prop?.y || 0) * tr.worldH;
   4244       const cx = (xw - tr.srcX) * tr.zoom;
   4245       const cy = (yw - tr.srcY) * tr.zoom;
   4246       const w = wWorld * tr.zoom;
   4247       const h = hWorld * tr.zoom;
   4248       return { x: cx - w / 2, y: cy - h / 2, w, h, cx, cy, img, sprite };
   4249     }
   4250 
   4251     function hitTestPropAtPointer(clientX, clientY, canvas, tr) {
   4252       if (!activeMap?.ttrpgEnabled) return null;
   4253       const props = Array.isArray(activeMap.props) ? activeMap.props : [];
   4254       if (!props.length) return null;
   4255       const sprites = Array.isArray(activeMap.sprites) ? activeMap.sprites : [];
   4256       const spriteById = new Map(sprites.map((s) => [String(s.id || ""), s]));
   4257       const rect = canvas.getBoundingClientRect();
   4258       const sx = clientX - rect.left;
   4259       const sy = clientY - rect.top;
   4260       if (sx < 0 || sy < 0 || sx > rect.width || sy > rect.height) return null;
   4261 
   4262       // Check top-most first: sort by y then z.
   4263       const sorted = props
   4264         .slice()
   4265         .sort((a, b) => {
   4266           const ay = Number(a?.y || 0);
   4267           const by = Number(b?.y || 0);
   4268           if (ay !== by) return ay - by;
   4269           return Number(a?.z || 0) - Number(b?.z || 0);
   4270         });
   4271       for (let i = sorted.length - 1; i >= 0; i--) {
   4272         const p = sorted[i];
   4273         const box = propScreenBox(p, spriteById, tr);
   4274         if (!box) continue;
   4275         if (sx >= box.x && sx <= box.x + box.w && sy >= box.y && sy <= box.y + box.h) {
   4276           return { propId: String(p.id || ""), x: Number(p.x || 0), y: Number(p.y || 0) };
   4277         }
   4278       }
   4279       return null;
   4280     }
   4281 
   4282     function getPossessedTokenForMe() {
   4283       if (!activeMap?.ttrpgEnabled) return null;
   4284       const me = String(ctx.getUser() || "").trim().toLowerCase();
   4285       if (!me) return null;
   4286       const props = Array.isArray(activeMap.props) ? activeMap.props : [];
   4287       if (!props.length) return null;
   4288       const sprites = Array.isArray(activeMap.sprites) ? activeMap.sprites : [];
   4289       const spriteById = new Map(sprites.map((s) => [String(s?.id || ""), s]));
   4290       const isTokenControlledByMe = (prop) => {
   4291         if (!prop) return false;
   4292         if (String(prop.controlledBy || "").trim().toLowerCase() !== me) return false;
   4293         const spr = spriteById.get(String(prop.spriteId || ""));
   4294         return spr?.kind === "token";
   4295       };
   4296       if (speakingAsPropId) {
   4297         const preferred = props.find((p) => String(p?.id || "") === String(speakingAsPropId || ""));
   4298         if (isTokenControlledByMe(preferred)) return preferred;
   4299       }
   4300       const fallback = props.find((p) => isTokenControlledByMe(p));
   4301       return fallback || null;
   4302     }
   4303 
   4304     function cleanupBubbles() {
   4305       const t = Date.now();
   4306       let changed = false;
   4307       for (const [u, b] of bubbles.entries()) {
   4308         if (!b || Number(b.expiresAt || 0) <= t) {
   4309           bubbles.delete(u);
   4310           changed = true;
   4311         }
   4312       }
   4313       if (changed && mode === "map") {
   4314         // force redraw by leaving tick running
   4315       }
   4316     }
   4317 
   4318     function draw() {
   4319       const canvas = document.getElementById("mapsCanvas");
   4320       if (!canvas) return;
   4321       const wrap = canvas.parentElement;
   4322       if (!wrap) return;
   4323       const rect = wrap.getBoundingClientRect();
   4324       const w = Math.max(1, Math.floor(rect.width));
   4325       const h = Math.max(1, Math.floor(rect.height));
   4326       if (canvas.width !== w || canvas.height !== h) {
   4327         canvas.width = w;
   4328         canvas.height = h;
   4329       }
   4330       const g = canvas.getContext("2d");
   4331       if (!g) return;
   4332       g.clearRect(0, 0, w, h);
   4333 
   4334       // Camera + zoom.
   4335       const zoom = Math.max(0.8, Math.min(5.0, Number(activeMap?.cameraZoom || 2.35) || 2.35));
   4336       const me = (self || String(ctx.getUser() || "")).trim().toLowerCase();
   4337       const possessedToken = getPossessedTokenForMe();
   4338       const followTarget = editMode
   4339         ? null
   4340         : possessedToken
   4341           ? { x: Number(possessedToken.x || 0.5), y: Number(possessedToken.y || 0.5) }
   4342           : me && !selfInvisible
   4343             ? { x: Number(localPos.x || 0.5), y: Number(localPos.y || 0.5) }
   4344             : null;
   4345       if (!cameraPos) {
   4346         const seed = followTarget || { x: Number(localPos.x || 0.5), y: Number(localPos.y || 0.5) };
   4347         cameraPos = { x: seed.x, y: seed.y };
   4348       }
   4349       if (followTarget) {
   4350         const dist = Math.hypot(followTarget.x - cameraPos.x, followTarget.y - cameraPos.y);
   4351         const lerp = dist > 0.25 ? 1 : 0.28;
   4352         cameraPos.x = cameraPos.x + (followTarget.x - cameraPos.x) * lerp;
   4353         cameraPos.y = cameraPos.y + (followTarget.y - cameraPos.y) * lerp;
   4354       }
   4355       const cam = cameraPos;
   4356 
   4357       const worldW = activeMap?.world?.w ? Number(activeMap.world.w) : bgImg ? bgImg.naturalWidth : 1400;
   4358       const worldH = activeMap?.world?.h ? Number(activeMap.world.h) : bgImg ? bgImg.naturalHeight : 900;
   4359       const viewW = w / zoom;
   4360       const viewH = h / zoom;
   4361       const cx = Math.max(viewW / 2, Math.min(worldW - viewW / 2, cam.x * worldW));
   4362       const cy = Math.max(viewH / 2, Math.min(worldH - viewH / 2, cam.y * worldH));
   4363       const srcX = Math.max(0, Math.min(worldW - viewW, cx - viewW / 2));
   4364       const srcY = Math.max(0, Math.min(worldH - viewH, cy - viewH / 2));
   4365       lastTransform = { srcX, srcY, zoom, worldW, worldH, viewW, viewH };
   4366 
   4367       // Background (cropped to camera view)
   4368       if (bgImg) {
   4369         g.globalAlpha = 0.92;
   4370         g.drawImage(bgImg, srcX, srcY, viewW, viewH, 0, 0, w, h);
   4371         g.globalAlpha = 1;
   4372       } else {
   4373         g.fillStyle = "rgba(0,0,0,0.25)";
   4374         g.fillRect(0, 0, w, h);
   4375       }
   4376 
   4377       // Props (TTRPG mode) — draw before players.
   4378       const tr = { srcX, srcY, zoom, worldW, worldH };
   4379       if (activeMap?.ttrpgEnabled) {
   4380         const sprites = Array.isArray(activeMap.sprites) ? activeMap.sprites : [];
   4381         const spriteById = new Map(sprites.map((s) => [String(s?.id || ""), s]));
   4382         const props = Array.isArray(activeMap.props) ? activeMap.props : [];
   4383         const sortedProps = props
   4384           .slice()
   4385           .sort((a, b) => {
   4386             const ay = Number(a?.y || 0);
   4387             const by = Number(b?.y || 0);
   4388             if (ay !== by) return ay - by;
   4389             return Number(a?.z || 0) - Number(b?.z || 0);
   4390           });
   4391         for (const p of sortedProps) {
   4392           const box = propScreenBox(p, spriteById, tr);
   4393           if (!box) continue;
   4394           // Skip if far outside viewport for perf.
   4395           if (box.x > w + 80 || box.y > h + 80 || box.x + box.w < -80 || box.y + box.h < -80) continue;
   4396           const rotDeg = Number(p?.rot || 0);
   4397           const rot = Number.isFinite(rotDeg) ? (rotDeg * Math.PI) / 180 : 0;
   4398           g.save();
   4399           g.globalAlpha = 0.98;
   4400           g.imageSmoothingEnabled = true;
   4401           g.shadowColor = "rgba(0,0,0,0.35)";
   4402           g.shadowBlur = 10;
   4403           g.shadowOffsetY = 6;
   4404           g.translate(box.cx, box.cy);
   4405           if (rot) g.rotate(rot);
   4406           g.drawImage(box.img, -box.w / 2, -box.h / 2, box.w, box.h);
   4407           g.restore();
   4408         }
   4409       }
   4410 
   4411       // Players (draw in world coords -> screen coords)
   4412       const drawNowMs = Date.now();
   4413       for (const [username, u] of users.entries()) {
   4414         if (!u) continue;
   4415         const rx = typeof u.x === "number" ? u.x : Number(u.tx || 0);
   4416         const ry = typeof u.y === "number" ? u.y : Number(u.ty || 0);
   4417         const xw = Number(rx || 0) * worldW;
   4418         const yw = Number(ry || 0) * worldH;
   4419         const px = Math.floor((xw - srcX) * zoom);
   4420         const py = Math.floor((yw - srcY) * zoom);
   4421 
   4422         const size = Math.max(18, Math.min(96, Math.floor(Number(activeMap?.avatarSize || 36))));
   4423         const radius = Math.floor(size / 2);
   4424         const color = typeof u.color === "string" && u.color ? u.color : "#ff3ea5";
   4425         const frameRender = resolveAvatarFrame(username, u, drawNowMs);
   4426         const frameImg = frameRender?.frameUrl ? getFrameImage(frameRender.frameUrl) : null;
   4427         let avatarHalfHeight = radius;
   4428 
   4429         // Avatar render
   4430         const img = getAvatarImage(username, u.image || "");
   4431         if (frameRender && frameImg) {
   4432           const crop = frameRender?.crop || null;
   4433           const srcW = crop ? Number(crop.sw || 0) : Number(frameImg.naturalWidth || size);
   4434           const srcH = crop ? Number(crop.sh || 0) : Number(frameImg.naturalHeight || size);
   4435           const scale = clamp(Number(frameRender?.renderScale || 1), 0.25, 4.0);
   4436           const drawW = Math.max(1, Math.min(1024, Math.round(srcW * scale)));
   4437           const drawH = Math.max(1, Math.min(1024, Math.round(srcH * scale)));
   4438           avatarHalfHeight = Math.floor(drawH / 2);
   4439           g.save();
   4440           if (frameRender?.flipX) {
   4441             g.translate(px, py);
   4442             g.scale(-1, 1);
   4443             if (crop) g.drawImage(frameImg, crop.sx, crop.sy, crop.sw, crop.sh, -Math.floor(drawW / 2), -Math.floor(drawH / 2), drawW, drawH);
   4444             else g.drawImage(frameImg, -Math.floor(drawW / 2), -Math.floor(drawH / 2), drawW, drawH);
   4445           } else {
   4446             if (crop) g.drawImage(frameImg, crop.sx, crop.sy, crop.sw, crop.sh, px - Math.floor(drawW / 2), py - Math.floor(drawH / 2), drawW, drawH);
   4447             else g.drawImage(frameImg, px - Math.floor(drawW / 2), py - Math.floor(drawH / 2), drawW, drawH);
   4448           }
   4449           g.restore();
   4450         } else if (frameRender && !frameImg) {
   4451           const placeholder = Math.max(10, Math.floor(size * 0.8));
   4452           avatarHalfHeight = Math.floor(placeholder / 2);
   4453           g.fillStyle = "rgba(246,240,255,0.18)";
   4454           roundRect(g, px - placeholder / 2, py - placeholder / 2, placeholder, placeholder, 4);
   4455           g.fill();
   4456         } else if (img) {
   4457           g.save();
   4458           g.beginPath();
   4459           g.arc(px, py, radius, 0, Math.PI * 2);
   4460           g.closePath();
   4461           g.clip();
   4462           g.drawImage(img, px - radius, py - radius, size, size);
   4463           g.restore();
   4464           g.strokeStyle = "rgba(255,255,255,0.28)";
   4465           g.lineWidth = 2;
   4466           g.beginPath();
   4467           g.arc(px, py, radius, 0, Math.PI * 2);
   4468           g.stroke();
   4469         } else {
   4470           g.fillStyle = color;
   4471           g.beginPath();
   4472           g.arc(px, py, radius, 0, Math.PI * 2);
   4473           g.fill();
   4474           g.strokeStyle = "rgba(255,255,255,0.28)";
   4475           g.lineWidth = 2;
   4476           g.beginPath();
   4477           g.arc(px, py, radius, 0, Math.PI * 2);
   4478           g.stroke();
   4479         }
   4480 
   4481         const isTypingNow = Number(typingUntil.get(username) || 0) > drawNowMs;
   4482         if (isTypingNow) {
   4483           const dots = ".".repeat((Math.floor(drawNowMs / 360) % 3) + 1);
   4484           const dotY = py - (avatarHalfHeight + 46);
   4485           g.font = "700 13px system-ui, -apple-system, Segoe UI, sans-serif";
   4486           g.textAlign = "center";
   4487           g.fillStyle = "rgba(246,240,255,0.96)";
   4488           g.shadowColor = "rgba(0,0,0,0.6)";
   4489           g.shadowBlur = 6;
   4490           g.shadowOffsetY = 2;
   4491           g.fillText(dots, px, Math.max(14, dotY));
   4492           g.shadowBlur = 0;
   4493           g.shadowOffsetY = 0;
   4494         }
   4495 
   4496         // Username in user's color, with contrast highlight (bigger + darker for readability)
   4497         const nameText = displayNameForUser(username, u);
   4498         if (!nameText) {
   4499           const bNoLabel = bubbles.get(`user:${username}`);
   4500           if (bNoLabel && bNoLabel.text) {
   4501             const text = String(bNoLabel.text);
   4502             const pad = 7;
   4503             g.font = "14px system-ui, -apple-system, Segoe UI, sans-serif";
   4504             const tw = Math.min(w - 20, Math.ceil(g.measureText(text).width) + pad * 2);
   4505             const th = 26;
   4506             const bx = Math.max(10, Math.min(w - 10 - tw, px - tw / 2));
   4507             const by = Math.max(10, py - (avatarHalfHeight + 64));
   4508             g.fillStyle = "rgba(10,9,14,0.88)";
   4509             g.strokeStyle = "rgba(246,240,255,0.14)";
   4510             roundRect(g, bx, by, tw, th, 12);
   4511             g.fill();
   4512             g.stroke();
   4513             g.fillStyle = "rgba(246,240,255,0.92)";
   4514             g.shadowColor = "rgba(0,0,0,0.55)";
   4515             g.shadowBlur = 6;
   4516             g.shadowOffsetY = 2;
   4517             g.fillText(text, bx + tw / 2, by + 18);
   4518             g.shadowBlur = 0;
   4519           }
   4520           continue;
   4521         }
   4522         const nameColor = normalizeReadableColor(color);
   4523         g.font = "700 15px system-ui, -apple-system, Segoe UI, sans-serif";
   4524         g.textAlign = "center";
   4525         const nm = g.measureText(nameText);
   4526         const nameW = Math.ceil(nm.width) + 14;
   4527         const nameH = 22;
   4528         const nameX = px - nameW / 2;
   4529         const nameY = py - (avatarHalfHeight + 30);
   4530         const bg = chooseHighlightBg(nameColor);
   4531         g.fillStyle = bg;
   4532         g.strokeStyle = "rgba(255,255,255,0.10)";
   4533         roundRect(g, nameX, nameY, nameW, nameH, 10);
   4534         g.fill();
   4535         g.stroke();
   4536         g.fillStyle = nameColor;
   4537         g.shadowColor = "rgba(0,0,0,0.55)";
   4538         g.shadowBlur = 6;
   4539         g.shadowOffsetY = 2;
   4540         g.fillText(nameText, px, nameY + 16);
   4541         g.shadowBlur = 0;
   4542 
   4543         const b = bubbles.get(`user:${username}`);
   4544         if (b && b.text) {
   4545           const text = String(b.text);
   4546           const pad = 7;
   4547           g.font = "14px system-ui, -apple-system, Segoe UI, sans-serif";
   4548           const tw = Math.min(w - 20, Math.ceil(g.measureText(text).width) + pad * 2);
   4549           const th = 26;
   4550           const bx = Math.max(10, Math.min(w - 10 - tw, px - tw / 2));
   4551           const by = Math.max(10, py - (avatarHalfHeight + 64));
   4552           g.fillStyle = "rgba(10,9,14,0.88)";
   4553           g.strokeStyle = "rgba(246,240,255,0.14)";
   4554           roundRect(g, bx, by, tw, th, 12);
   4555           g.fill();
   4556           g.stroke();
   4557           g.fillStyle = "rgba(246,240,255,0.92)";
   4558           g.shadowColor = "rgba(0,0,0,0.55)";
   4559           g.shadowBlur = 6;
   4560           g.shadowOffsetY = 2;
   4561           g.fillText(text, bx + tw / 2, by + 18);
   4562           g.shadowBlur = 0;
   4563         }
   4564       }
   4565 
   4566       // Token chat bubbles
   4567       if (activeMap?.ttrpgEnabled) {
   4568         const sprites = Array.isArray(activeMap.sprites) ? activeMap.sprites : [];
   4569         const spriteById = new Map(sprites.map((s) => [String(s?.id || ""), s]));
   4570         const props = Array.isArray(activeMap.props) ? activeMap.props : [];
   4571         for (const [key, b] of bubbles.entries()) {
   4572           if (!b || b.actorType !== "token") continue;
   4573           const propId = String(b.actorPropId || "");
   4574           if (!propId || key !== `token:${propId}`) continue;
   4575           const prop = props.find((p) => String(p?.id || "") === propId);
   4576           if (!prop) continue;
   4577           const box = propScreenBox(prop, spriteById, { srcX, srcY, zoom, worldW, worldH });
   4578           if (!box) continue;
   4579           const text = String(b.text || "").trim();
   4580           if (!text) continue;
   4581           const pad = 7;
   4582           g.font = "14px system-ui, -apple-system, Segoe UI, sans-serif";
   4583           const tw = Math.min(w - 20, Math.ceil(g.measureText(text).width) + pad * 2);
   4584           const th = 26;
   4585           const bx = Math.max(10, Math.min(w - 10 - tw, box.cx - tw / 2));
   4586           const by = Math.max(10, box.y - 34);
   4587           g.fillStyle = "rgba(10,9,14,0.88)";
   4588           g.strokeStyle = "rgba(246,240,255,0.14)";
   4589           roundRect(g, bx, by, tw, th, 12);
   4590           g.fill();
   4591           g.stroke();
   4592           g.fillStyle = "rgba(246,240,255,0.92)";
   4593           g.shadowColor = "rgba(0,0,0,0.55)";
   4594           g.shadowBlur = 6;
   4595           g.shadowOffsetY = 2;
   4596           g.fillText(text, bx + tw / 2, by + 18);
   4597           g.shadowBlur = 0;
   4598         }
   4599       }
   4600 
   4601       // Y-sort masks: redraw background clipped to polygon on top of entities when they're "behind".
   4602       const masks = Array.isArray(activeMap.masks) ? activeMap.masks : [];
   4603       if (bgImg && masks.length) {
   4604         for (const poly of masks) {
   4605           const pts = Array.isArray(poly?.points) ? poly.points : [];
   4606           if (pts.length < 3) continue;
   4607           const sortY = Math.max(...pts.map((p) => Number(p?.y || 0)));
   4608           let needs = false;
   4609           for (const [, u] of users.entries()) {
   4610             if (!u) continue;
   4611             const ux = typeof u.x === "number" ? u.x : Number(u.tx || 0);
   4612             const uy = typeof u.y === "number" ? u.y : Number(u.ty || 0);
   4613             if (uy >= sortY) continue; // in front
   4614             if (!pointInPoly({ x: ux, y: uy }, poly)) continue;
   4615             needs = true;
   4616             break;
   4617           }
   4618           if (!needs && activeMap?.ttrpgEnabled) {
   4619             const props = Array.isArray(activeMap.props) ? activeMap.props : [];
   4620             for (const p of props) {
   4621               if (!p) continue;
   4622               const px = Number(p.x || 0);
   4623               const py = Number(p.y || 0);
   4624               if (py >= sortY) continue;
   4625               if (!pointInPoly({ x: px, y: py }, poly)) continue;
   4626               needs = true;
   4627               break;
   4628             }
   4629           }
   4630           if (!needs) continue;
   4631           g.save();
   4632           g.beginPath();
   4633           const first = pts[0];
   4634           g.moveTo(((Number(first.x) * worldW - srcX) * zoom) | 0, ((Number(first.y) * worldH - srcY) * zoom) | 0);
   4635           for (let i = 1; i < pts.length; i++) {
   4636             const p = pts[i];
   4637             g.lineTo(((Number(p.x) * worldW - srcX) * zoom) | 0, ((Number(p.y) * worldH - srcY) * zoom) | 0);
   4638           }
   4639           g.closePath();
   4640           g.clip();
   4641           g.globalAlpha = 0.92;
   4642           g.drawImage(bgImg, srcX, srcY, viewW, viewH, 0, 0, w, h);
   4643           g.restore();
   4644         }
   4645       }
   4646 
   4647       // Fog zones: draw dark overlays over polygons, unless revealed.
   4648       if (!editMode && !revealFog) {
   4649         const fogs = Array.isArray(activeMap.hiddenMasks) ? activeMap.hiddenMasks : [];
   4650         if (fogs.length) {
   4651           const possessed = getPossessedTokenForMe();
   4652           const myPos = possessed
   4653             ? { x: Math.max(0, Math.min(1, Number(possessed.x || 0.5))), y: Math.max(0, Math.min(1, Number(possessed.y || 0.5))) }
   4654             : { x: localPos.x, y: localPos.y };
   4655           g.save();
   4656           g.globalAlpha = 1;
   4657           for (const poly of fogs) {
   4658             const pts = Array.isArray(poly?.points) ? poly.points : [];
   4659             if (pts.length < 3) continue;
   4660             const mode = String(poly?.mode || "auto") === "manual" ? "manual" : "auto";
   4661             if (mode === "auto" && pointInPoly(myPos, poly)) continue;
   4662             g.beginPath();
   4663             const first = pts[0];
   4664             g.moveTo((Number(first.x) * worldW - srcX) * zoom, (Number(first.y) * worldH - srcY) * zoom);
   4665             for (let i = 1; i < pts.length; i++) {
   4666               const p = pts[i];
   4667               g.lineTo((Number(p.x) * worldW - srcX) * zoom, (Number(p.y) * worldH - srcY) * zoom);
   4668             }
   4669             g.closePath();
   4670             g.fillStyle = "rgba(5,4,10,0.78)";
   4671             g.strokeStyle = "rgba(180,120,255,0.22)";
   4672             g.lineWidth = 1.2;
   4673             g.fill();
   4674             g.stroke();
   4675           }
   4676           g.restore();
   4677         }
   4678       }
   4679 
   4680       // Edit overlays
   4681       if (editMode) {
   4682         drawPolysOverlay(g, activeMap, worldW, worldH, srcX, srcY, zoom);
   4683       }
   4684     }
   4685 
   4686     function drawPolysOverlay(g, map, worldW, worldH, srcX, srcY, zoom) {
   4687       const selected =
   4688         selectedPolyKind && selectedPolyKind === editKind
   4689           ? (() => {
   4690               const list = polysForKind(map, editKind);
   4691               return selectedPolyIndex >= 0 && selectedPolyIndex < list.length ? list[selectedPolyIndex] : null;
   4692             })()
   4693           : null;
   4694 
   4695       const drawPoly = (poly, stroke, fill, showPoints, emphasized) => {
   4696         const pts = Array.isArray(poly?.points) ? poly.points : [];
   4697         if (pts.length < 2) return;
   4698         g.save();
   4699         g.beginPath();
   4700         g.moveTo((Number(pts[0].x) * worldW - srcX) * zoom, (Number(pts[0].y) * worldH - srcY) * zoom);
   4701         for (let i = 1; i < pts.length; i++) {
   4702           g.lineTo((Number(pts[i].x) * worldW - srcX) * zoom, (Number(pts[i].y) * worldH - srcY) * zoom);
   4703         }
   4704         g.closePath();
   4705         g.fillStyle = fill;
   4706         g.strokeStyle = stroke;
   4707         g.lineWidth = emphasized ? 3.5 : 2;
   4708         if (emphasized) {
   4709           g.shadowColor = "rgba(0,0,0,0.45)";
   4710           g.shadowBlur = 12;
   4711         }
   4712         g.fill();
   4713         g.stroke();
   4714         if (showPoints) {
   4715           g.fillStyle = stroke;
   4716           for (let i = 0; i < pts.length; i++) {
   4717             const p = pts[i];
   4718             const x = (Number(p.x) * worldW - srcX) * zoom;
   4719             const y = (Number(p.y) * worldH - srcY) * zoom;
   4720             g.beginPath();
   4721             const r = emphasized && selectedVertexIndex === i ? 7.0 : emphasized ? 5.2 : 3.2;
   4722             g.arc(x, y, r, 0, Math.PI * 2);
   4723             g.fill();
   4724             if (emphasized) {
   4725               g.strokeStyle = "rgba(0,0,0,0.35)";
   4726               g.lineWidth = 1;
   4727               g.stroke();
   4728             }
   4729           }
   4730         }
   4731         g.restore();
   4732       };
   4733 
   4734       const collisions = Array.isArray(map.collisions) ? map.collisions : [];
   4735       const masks = Array.isArray(map.masks) ? map.masks : [];
   4736       for (const p of collisions) drawPoly(p, "rgba(255,70,70,0.82)", "rgba(255,70,70,0.10)", false, selected === p);
   4737       for (const p of masks) drawPoly(p, "rgba(80,195,255,0.82)", "rgba(80,195,255,0.08)", false, selected === p);
   4738       const exits = Array.isArray(map.exits) ? map.exits : [];
   4739       for (const p of exits) drawPoly(p, "rgba(255,215,90,0.90)", "rgba(255,215,90,0.10)", false, selected === p);
   4740       const hidden = Array.isArray(map.hiddenMasks) ? map.hiddenMasks : [];
   4741       const occ = Array.isArray(map.occluders) ? map.occluders : [];
   4742       const fall = Array.isArray(map.fallThroughs) ? map.fallThroughs : [];
   4743       for (const p of hidden) drawPoly(p, "rgba(180,120,255,0.80)", "rgba(180,120,255,0.08)", false, selected === p);
   4744       for (const p of fall) drawPoly(p, "rgba(255,140,80,0.80)", "rgba(255,140,80,0.08)", false, selected === p);
   4745       for (const p of occ) drawPoly(p, "rgba(120,255,180,0.80)", "rgba(120,255,180,0.08)", false, selected === p);
   4746 
   4747       if (selected) {
   4748         const stroke =
   4749           editKind === "collision"
   4750             ? "rgba(255,70,70,0.98)"
   4751             : editKind === "mask"
   4752               ? "rgba(80,195,255,0.98)"
   4753               : editKind === "exit"
   4754                 ? "rgba(255,215,90,0.98)"
   4755                 : editKind === "hidden"
   4756                   ? "rgba(180,120,255,0.98)"
   4757                   : editKind === "fall"
   4758                     ? "rgba(255,140,80,0.98)"
   4759                     : "rgba(120,255,180,0.98)";
   4760         const fill =
   4761           editKind === "collision"
   4762             ? "rgba(255,70,70,0.16)"
   4763             : editKind === "mask"
   4764               ? "rgba(80,195,255,0.14)"
   4765               : editKind === "exit"
   4766                 ? "rgba(255,215,90,0.14)"
   4767                 : editKind === "hidden"
   4768                   ? "rgba(180,120,255,0.12)"
   4769                   : editKind === "fall"
   4770                     ? "rgba(255,140,80,0.12)"
   4771                     : "rgba(120,255,180,0.12)";
   4772         drawPoly(selected, stroke, fill, true, true);
   4773       }
   4774 
   4775       if (draftPoly && draftPoly.length) {
   4776         const poly = { points: draftPoly };
   4777         const stroke =
   4778           editKind === "collision"
   4779             ? "rgba(255,70,70,0.95)"
   4780             : editKind === "mask"
   4781               ? "rgba(80,195,255,0.95)"
   4782               : editKind === "exit"
   4783                 ? "rgba(255,215,90,0.98)"
   4784                 : editKind === "hidden"
   4785                   ? "rgba(180,120,255,0.98)"
   4786                   : editKind === "fall"
   4787                     ? "rgba(255,140,80,0.98)"
   4788                     : "rgba(120,255,180,0.98)";
   4789         const fill =
   4790           editKind === "collision"
   4791             ? "rgba(255,70,70,0.10)"
   4792             : editKind === "mask"
   4793               ? "rgba(80,195,255,0.10)"
   4794               : editKind === "exit"
   4795                 ? "rgba(255,215,90,0.10)"
   4796                 : editKind === "hidden"
   4797                   ? "rgba(180,120,255,0.10)"
   4798                   : editKind === "fall"
   4799                     ? "rgba(255,140,80,0.10)"
   4800                     : "rgba(120,255,180,0.10)";
   4801         drawPoly(poly, stroke, fill, true, false);
   4802       }
   4803     }
   4804 
   4805     function parseHexColor(hex) {
   4806       const s = String(hex || "").trim();
   4807       const m = s.match(/^#([0-9a-f]{6})$/i);
   4808       if (!m) return null;
   4809       const n = parseInt(m[1], 16);
   4810       return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 };
   4811     }
   4812 
   4813     function relLuma(rgb) {
   4814       // sRGB relative luminance
   4815       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)));
   4816       return 0.2126 * srgb[0] + 0.7152 * srgb[1] + 0.0722 * srgb[2];
   4817     }
   4818 
   4819     function mix(a, b, t) {
   4820       return Math.round(a + (b - a) * t);
   4821     }
   4822 
   4823     function normalizeReadableColor(hex) {
   4824       const rgb = parseHexColor(hex);
   4825       if (!rgb) return "#ff3ea5";
   4826       const l = relLuma(rgb);
   4827       if (l > 0.25) return hex;
   4828       // brighten toward white a bit
   4829       const t = (0.25 - l) * 1.15;
   4830       const r = mix(rgb.r, 255, Math.min(0.65, Math.max(0.15, t)));
   4831       const g = mix(rgb.g, 255, Math.min(0.65, Math.max(0.15, t)));
   4832       const b = mix(rgb.b, 255, Math.min(0.65, Math.max(0.15, t)));
   4833       return `#${[r, g, b].map((v) => v.toString(16).padStart(2, "0")).join("")}`;
   4834     }
   4835 
   4836     function chooseHighlightBg(textHex) {
   4837       // Always use a darker background for legibility on busy maps.
   4838       // (We still tint the text itself via normalizeReadableColor().)
   4839       const rgb = parseHexColor(textHex);
   4840       if (!rgb) return "rgba(10,9,14,0.80)";
   4841       return "rgba(10,9,14,0.80)";
   4842     }
   4843 
   4844     function getAvatarImage(username, url) {
   4845       const u = String(username || "").toLowerCase();
   4846       if (!u) return null;
   4847       const src = String(url || "").trim();
   4848       if (!src) return null;
   4849       const now = Date.now();
   4850       const cached = avatarCache.get(u);
   4851       if (cached && cached.src === src) {
   4852         if (cached.status === "ok" && cached.img) return cached.img;
   4853         if (cached.status === "loading") return null;
   4854         if (cached.status === "error" && now - Number(cached.failedAt || 0) < 5000) return null;
   4855       }
   4856       const img = new Image();
   4857       if (!src.startsWith("data:")) img.crossOrigin = "anonymous";
   4858       avatarCache.set(u, { src, img: null, status: "loading", failedAt: 0 });
   4859       img.onload = () => avatarCache.set(u, { src, img, status: "ok", failedAt: 0 });
   4860       img.onerror = () => avatarCache.set(u, { src, img: null, status: "error", failedAt: Date.now() });
   4861       img.src = src;
   4862       return null;
   4863     }
   4864 
   4865     function roundRect(g, x, y, w, h, r) {
   4866       const rr = Math.max(0, Math.min(r, Math.min(w, h) / 2));
   4867       g.beginPath();
   4868       g.moveTo(x + rr, y);
   4869       g.arcTo(x + w, y, x + w, y + h, rr);
   4870       g.arcTo(x + w, y + h, x, y + h, rr);
   4871       g.arcTo(x, y + h, x, y, rr);
   4872       g.arcTo(x, y, x + w, y, rr);
   4873       g.closePath();
   4874     }
   4875 
   4876     function escapeHtml(s) {
   4877       return String(s || "")
   4878         .replace(/&/g, "&amp;")
   4879         .replace(/</g, "&lt;")
   4880         .replace(/>/g, "&gt;")
   4881         .replace(/\"/g, "&quot;")
   4882         .replace(/'/g, "&#39;");
   4883     }
   4884 
   4885     function enterMap(mapId) {
   4886       mode = "map";
   4887       applyFocusModeClass();
   4888       gmMode = "play";
   4889       inspectorOpen = false;
   4890       gmOverlayVisible = false;
   4891       users.clear();
   4892       emoteUntil.clear();
   4893       avatarAnimRuntime.clear();
   4894       typingUntil.clear();
   4895       typingOpen = false;
   4896       bubbles.clear();
   4897       editMode = false;
   4898       draftPoly = [];
   4899       polyDrag = null;
   4900       vertexDrag = null;
   4901       selectedPolyKind = "";
   4902       selectedPolyIndex = -1;
   4903       selectedVertexIndex = -1;
   4904       selfInvisible = false;
   4905       speakingAsPropId = "";
   4906       ttrpgDockCollapsed = readDockCollapsed(mapId);
   4907       ttrpgTool = "select";
   4908       cameraPos = null;
   4909       revealFog = getFogReveal(mapId);
   4910       // Seed a known-good local position (will be replaced once we get roomState).
   4911       localPos = { x: 0.5, y: 0.5 };
   4912       mapChatFeed = [];
   4913       exitInside.clear();
   4914       activeMap =
   4915         maps.find((m) => m.id === mapId) || {
   4916           id: mapId,
   4917           title: mapId,
   4918           owner: "",
   4919           backgroundUrl: "",
   4920           thumbUrl: "",
   4921           userCount: 0,
   4922           avatarSize: 36,
   4923           cameraZoom: 2.35,
   4924           collisions: [],
   4925           masks: [],
   4926           exits: [],
   4927           hiddenMasks: [],
   4928           fallThroughs: [],
   4929           occluders: [],
   4930           ttrpgEnabled: false,
   4931           sprites: [],
   4932           props: [],
   4933           walkiesEnabled: false
   4934         };
   4935       selectedPropId = "";
   4936       mapsCapabilities = null;
   4937       clearCapabilitiesRetry();
   4938       renderMapView();
   4939       ctx.send("join", { mapId });
   4940       requestCapabilitiesWithRetry("join-request");
   4941     }
   4942 
   4943     function leaveMap() {
   4944       ctx.send("leave", {});
   4945       mode = "maps";
   4946       applyFocusModeClass();
   4947       gmMode = "play";
   4948       inspectorOpen = false;
   4949       gmOverlayVisible = false;
   4950       activeMap = null;
   4951       speakingAsPropId = "";
   4952       if (appRoot) appRoot.classList.remove("mapsRoom");
   4953       if (chatPanel) chatPanel.classList.remove("hidden");
   4954       if (chatResizeHandle) chatResizeHandle.classList.remove("hidden");
   4955       stopWalkie();
   4956       stopAllWalkies();
   4957       users.clear();
   4958       emoteUntil.clear();
   4959       avatarAnimRuntime.clear();
   4960       typingUntil.clear();
   4961       typingOpen = false;
   4962       clearCapabilitiesRetry();
   4963       mapChatFeed = [];
   4964       bubbles.clear();
   4965       keys.clear();
   4966       stopLoop();
   4967       renderMapsList();
   4968     }
   4969 
   4970     if (mapsBtn) {
   4971       mapsBtn.addEventListener("click", () => {
   4972         if (mode === "hives") enterMaps();
   4973         else exitMapsToHives();
   4974       });
   4975     }
   4976 
   4977     mapsPanel.addEventListener("click", (e) => {
   4978       const enter = e.target.closest("[data-mapenter]");
   4979       if (enter) {
   4980         const id = String(enter.getAttribute("data-mapenter") || "");
   4981         if (id) enterMap(id);
   4982         return;
   4983       }
   4984       const del = e.target.closest("[data-mapdelete]");
   4985       if (del) {
   4986         const id = String(del.getAttribute("data-mapdelete") || "");
   4987         if (!id) return;
   4988         const ok = window.confirm(`Delete map "${id}"? This cannot be undone.`);
   4989         if (!ok) return;
   4990         ctx.send("deleteMap", { id });
   4991         return;
   4992       }
   4993       const focus = e.target.closest("[data-mapfocus]");
   4994       if (focus) {
   4995         if (!featureEnabled("focusMode")) return;
   4996         setFocusMode(!isFocusMode);
   4997         return;
   4998       }
   4999       const gmOverlayBtn = e.target.closest("[data-gm-overlay]");
   5000       if (gmOverlayBtn) {
   5001         if (!featureEnabled("gmOverlay")) return;
   5002         setGmOverlayVisible(!gmOverlayVisible);
   5003         return;
   5004       }
   5005       const gmModeBtn = e.target.closest("[data-gm-mode]");
   5006       if (gmModeBtn) {
   5007         const nextMode = String(gmModeBtn.getAttribute("data-gm-mode") || "").trim().toLowerCase();
   5008         if (nextMode) setGmMode(nextMode);
   5009         return;
   5010       }
   5011       const gmInspectorBtn = e.target.closest("[data-gm-inspector]");
   5012       if (gmInspectorBtn) {
   5013         inspectorOpen = !inspectorOpen;
   5014         if (!shouldDeferMapRerenderForChat()) renderMapView();
   5015         return;
   5016       }
   5017       const back = e.target.closest("[data-mapback]");
   5018       if (back) {
   5019         leaveMap();
   5020         return;
   5021       }
   5022       const cinematicBtn = e.target.closest("[data-mapcinematic]");
   5023       if (cinematicBtn) {
   5024         setCinematicMode(!cinematicMode);
   5025         return;
   5026       }
   5027     });
   5028 
   5029     function setChatOverlayOpen(open) {
   5030       const overlay = document.getElementById("mapsChatOverlay");
   5031       const input = document.getElementById("mapsChatInput");
   5032       const send = document.getElementById("mapsChatSend");
   5033       const closeBtn = document.getElementById("mapsChatClose");
   5034       const scopeLocalBtn = document.getElementById("mapsChatScopeLocal");
   5035       const scopeGlobalBtn = document.getElementById("mapsChatScopeGlobal");
   5036       const opacityRange = document.getElementById("mapsChatOpacity");
   5037       const resetBtn = document.getElementById("mapsChatReset");
   5038       const dragHandle = document.getElementById("mapsChatDragHandle");
   5039       const canvasWrap = document.querySelector(".mapCanvasWrap");
   5040       const walkieBar = document.getElementById("mapsWalkieBar");
   5041       if (!overlay || !input || !send) return;
   5042       const clampOverlayToCanvas = () => {
   5043         if (!overlay || !canvasWrap) return;
   5044         const parentRect = canvasWrap.getBoundingClientRect();
   5045         if (parentRect.width <= 0 || parentRect.height <= 0) return;
   5046         const width = Math.max(180, Math.min(parentRect.width - 16, overlay.offsetWidth || 320));
   5047         const height = Math.max(56, Math.min(parentRect.height - 16, overlay.offsetHeight || 120));
   5048         if (overlay.style.right !== "auto") overlay.style.right = "auto";
   5049         if (!overlay.style.left || overlay.style.left === "auto") overlay.style.left = "12px";
   5050         if (!overlay.style.top || overlay.style.top === "auto") {
   5051           const baseBottom = overlay.classList.contains("raiseForHotbar") ? 126 : overlay.classList.contains("raiseForWalkie") ? 68 : 12;
   5052           overlay.style.top = `${Math.max(8, parentRect.height - height - baseBottom)}px`;
   5053         }
   5054         const currentLeft = Number.parseFloat(overlay.style.left || "12");
   5055         const currentTop = Number.parseFloat(overlay.style.top || "12");
   5056         const maxLeft = Math.max(8, parentRect.width - width - 8);
   5057         const maxTop = Math.max(8, parentRect.height - height - 8);
   5058         const clampedLeft = Math.max(8, Math.min(maxLeft, Number.isFinite(currentLeft) ? currentLeft : 12));
   5059         const clampedTop = Math.max(8, Math.min(maxTop, Number.isFinite(currentTop) ? currentTop : 12));
   5060         overlay.style.left = `${Math.round(clampedLeft)}px`;
   5061         overlay.style.top = `${Math.round(clampedTop)}px`;
   5062         overlay.style.bottom = "auto";
   5063         mapChatOverlayPos = { x: Math.round(clampedLeft), y: Math.round(clampedTop) };
   5064       };
   5065       overlay.classList.toggle("hidden", !open);
   5066       typingOpen = Boolean(open);
   5067       if (walkieBar) walkieBar.classList.toggle("hidden", Boolean(open) || !Boolean(activeMap?.walkiesEnabled));
   5068       if (open) {
   5069         input.value = "";
   5070         input.focus();
   5071         typingLastSentAt = 0;
   5072         overlay.style.setProperty("--maps-chat-overlay-alpha", String(clamp(mapChatOverlayOpacity, 0.25, 1)));
   5073         if (mapChatOverlayPos && Number.isFinite(mapChatOverlayPos.x) && Number.isFinite(mapChatOverlayPos.y)) {
   5074           overlay.style.left = `${Math.round(mapChatOverlayPos.x)}px`;
   5075           overlay.style.top = `${Math.round(mapChatOverlayPos.y)}px`;
   5076           overlay.style.right = "auto";
   5077           overlay.style.bottom = "auto";
   5078         } else {
   5079           overlay.style.left = "12px";
   5080           overlay.style.right = "auto";
   5081           overlay.style.top = "";
   5082           overlay.style.bottom = "";
   5083         }
   5084         requestAnimationFrame(clampOverlayToCanvas);
   5085         renderMapChatFeedDom();
   5086         keys.clear();
   5087       } else {
   5088         if (activeMap?.id) ctx.send("typing", { mapId: activeMap.id, isTyping: false });
   5089         input.blur();
   5090         mapChatOverlayDrag = null;
   5091         writeMapChatOverlayPrefs();
   5092       }
   5093       const renderScopeButtons = () => {
   5094         if (scopeLocalBtn) scopeLocalBtn.classList.toggle("primary", mapChatScope === "local");
   5095         if (scopeLocalBtn) scopeLocalBtn.classList.toggle("ghost", mapChatScope !== "local");
   5096         if (scopeGlobalBtn) scopeGlobalBtn.classList.toggle("primary", mapChatScope === "global");
   5097         if (scopeGlobalBtn) scopeGlobalBtn.classList.toggle("ghost", mapChatScope !== "global");
   5098       };
   5099       renderScopeButtons();
   5100       if (scopeLocalBtn) {
   5101         scopeLocalBtn.onclick = () => {
   5102           mapChatScope = "local";
   5103           writeMapChatOverlayPrefs();
   5104           renderScopeButtons();
   5105           renderMapChatFeedDom();
   5106         };
   5107       }
   5108       if (scopeGlobalBtn) {
   5109         scopeGlobalBtn.onclick = () => {
   5110           mapChatScope = "global";
   5111           writeMapChatOverlayPrefs();
   5112           renderScopeButtons();
   5113           renderMapChatFeedDom();
   5114         };
   5115       }
   5116       if (opacityRange) {
   5117         const applyOpacity = () => {
   5118           mapChatOverlayOpacity = clamp(Number(opacityRange.value || mapChatOverlayOpacity), 0.25, 1);
   5119           overlay.style.setProperty("--maps-chat-overlay-alpha", String(mapChatOverlayOpacity));
   5120           writeMapChatOverlayPrefs();
   5121         };
   5122         opacityRange.oninput = applyOpacity;
   5123         opacityRange.onchange = applyOpacity;
   5124       }
   5125       if (resetBtn) {
   5126         resetBtn.onclick = () => {
   5127           mapChatOverlayOpacity = 0.92;
   5128           mapChatOverlayPos = null;
   5129           if (opacityRange) opacityRange.value = mapChatOverlayOpacity.toFixed(2);
   5130           overlay.style.setProperty("--maps-chat-overlay-alpha", String(mapChatOverlayOpacity));
   5131           overlay.style.left = "12px";
   5132           overlay.style.right = "auto";
   5133           overlay.style.top = "";
   5134           overlay.style.bottom = "";
   5135           requestAnimationFrame(clampOverlayToCanvas);
   5136           writeMapChatOverlayPrefs();
   5137         };
   5138       }
   5139       if (closeBtn) closeBtn.onclick = () => setChatOverlayOpen(false);
   5140       if (dragHandle) {
   5141         const onPointerMove = (ev) => {
   5142           if (!mapChatOverlayDrag) return;
   5143           const parentRect = mapChatOverlayDrag.parentRect;
   5144           const overlayRect = overlay.getBoundingClientRect();
   5145           const maxLeft = Math.max(8, parentRect.width - overlayRect.width - 8);
   5146           const maxTop = Math.max(8, parentRect.height - overlayRect.height - 8);
   5147           const nx = Math.max(8, Math.min(maxLeft, mapChatOverlayDrag.startLeft + (ev.clientX - mapChatOverlayDrag.startX)));
   5148           const ny = Math.max(8, Math.min(maxTop, mapChatOverlayDrag.startTop + (ev.clientY - mapChatOverlayDrag.startY)));
   5149           overlay.style.left = `${Math.round(nx)}px`;
   5150           overlay.style.right = "auto";
   5151           overlay.style.top = `${Math.round(ny)}px`;
   5152           overlay.style.bottom = "auto";
   5153           mapChatOverlayPos = { x: Math.round(nx), y: Math.round(ny) };
   5154         };
   5155         const onPointerUp = () => {
   5156           if (!mapChatOverlayDrag) return;
   5157           mapChatOverlayDrag = null;
   5158           clampOverlayToCanvas();
   5159           writeMapChatOverlayPrefs();
   5160           window.removeEventListener("pointermove", onPointerMove);
   5161           window.removeEventListener("pointerup", onPointerUp);
   5162         };
   5163         dragHandle.onpointerdown = (ev) => {
   5164           if (!canvasWrap) return;
   5165           const rect = overlay.getBoundingClientRect();
   5166           const parentRect = canvasWrap.getBoundingClientRect();
   5167           mapChatOverlayDrag = {
   5168             startX: ev.clientX,
   5169             startY: ev.clientY,
   5170             startLeft: rect.left - parentRect.left,
   5171             startTop: rect.top - parentRect.top,
   5172             parentRect
   5173           };
   5174           window.addEventListener("pointermove", onPointerMove);
   5175           window.addEventListener("pointerup", onPointerUp);
   5176         };
   5177       }
   5178       const submitChat = () => {
   5179         const text = String(input.value || "").trim();
   5180         if (!text) return;
   5181         if (activeMap?.id) ctx.send("typing", { mapId: activeMap.id, isTyping: false });
   5182         const me = String(ctx.getUser() || "").trim().toLowerCase();
   5183         const actorPropId = speakingAsPropId ? String(speakingAsPropId) : "";
   5184         if (actorPropId) bubbles.set(`token:${actorPropId}`, { text: text.slice(0, 120), actorType: "token", actorPropId, expiresAt: Date.now() + 4000 });
   5185         else if (me) bubbles.set(`user:${me}`, { text: text.slice(0, 120), actorType: "user", username: me, expiresAt: Date.now() + 4000 });
   5186         ctx.send("chatSend", { mapId: activeMap?.id || "", text, scope: mapChatScope });
   5187         ctx.send("say", { text, actorPropId, scope: mapChatScope });
   5188         input.value = "";
   5189         input.focus();
   5190       };
   5191       send.onclick = () => {
   5192         submitChat();
   5193       };
   5194       input.onkeydown = (ev) => {
   5195         ev.stopPropagation();
   5196         if (ev.key === "Escape") {
   5197           ev.preventDefault();
   5198           setChatOverlayOpen(false);
   5199         }
   5200         if (ev.key === "Enter") {
   5201           ev.preventDefault();
   5202           submitChat();
   5203         }
   5204       };
   5205       input.oninput = () => {
   5206         keys.clear();
   5207         if (!activeMap?.id || !typingOpen) return;
   5208         const text = String(input.value || "");
   5209         const now = Date.now();
   5210         const wantsTyping = text.trim().length > 0;
   5211         if (!wantsTyping) {
   5212           ctx.send("typing", { mapId: activeMap.id, isTyping: false });
   5213           typingLastSentAt = now;
   5214           return;
   5215         }
   5216         if (now - typingLastSentAt < 600) return;
   5217         typingLastSentAt = now;
   5218         ctx.send("typing", { mapId: activeMap.id, isTyping: true });
   5219       };
   5220     }
   5221 
   5222     function shouldDeferMapRerenderForChat() {
   5223       if (!typingOpen) return false;
   5224       const overlay = document.getElementById("mapsChatOverlay");
   5225       return Boolean(overlay && !overlay.classList.contains("hidden"));
   5226     }
   5227 
   5228     function isTypingLockActive() {
   5229       if (!typingOpen) return false;
   5230       const overlay = document.getElementById("mapsChatOverlay");
   5231       if (!overlay || overlay.classList.contains("hidden")) return false;
   5232       const activeEl = document.activeElement;
   5233       if (!activeEl) return false;
   5234       return Boolean(activeEl.id === "mapsChatInput" || overlay.contains(activeEl));
   5235     }
   5236 
   5237     window.addEventListener("keydown", (e) => {
   5238       if (mode !== "map") return;
   5239       // This is a user gesture; try to unlock audio playback early.
   5240       ensureAudioReady();
   5241       const focusSupported = featureEnabled("focusMode");
   5242       const gmOverlaySupported = featureEnabled("gmOverlay");
   5243       const overlay = document.getElementById("mapsChatOverlay");
   5244       const overlayOpen = overlay && !overlay.classList.contains("hidden");
   5245       const editingText = isTextEditingElement(document.activeElement);
   5246       if (overlayOpen && e.key === "Escape") {
   5247         e.preventDefault();
   5248         setChatOverlayOpen(false);
   5249         return;
   5250       }
   5251       if (isTypingLockActive()) {
   5252         if (["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "KeyW", "KeyA", "KeyS", "KeyD", "Backquote"].includes(e.code)) {
   5253           e.preventDefault();
   5254         }
   5255         return;
   5256       }
   5257       if (editingText) return;
   5258       if (!overlayOpen && !editMode && e.altKey && !e.ctrlKey && !e.metaKey && /^Digit[1-5]$/.test(e.code)) {
   5259         e.preventDefault();
   5260         const idx = Number(String(e.code).replace("Digit", "")) - 1;
   5261         if (idx >= 0) ctx.send("avatarEmote", { mapId: activeMap?.id || "", index: idx });
   5262         return;
   5263       }
   5264       if (!editingText && !overlayOpen && !e.altKey && !e.ctrlKey && !e.metaKey) {
   5265         if (!gmOverlaySupported || !gmOverlayVisible) {
   5266           // With overlay hidden, reserve number keys for normal typing/navigation.
   5267         } else if (e.code === "Digit1") {
   5268           e.preventDefault();
   5269           setGmMode("play");
   5270           return;
   5271         } else if (e.code === "Digit2") {
   5272           e.preventDefault();
   5273           setGmMode("select");
   5274           return;
   5275         } else if (e.code === "Digit3") {
   5276           e.preventDefault();
   5277           setGmMode("place");
   5278           return;
   5279         } else if (e.code === "Digit4") {
   5280           e.preventDefault();
   5281           setGmMode("polygon");
   5282           return;
   5283         }
   5284       }
   5285       if (!overlayOpen && gmOverlaySupported && gmOverlayVisible && (e.key === "/" || ((e.ctrlKey || e.metaKey) && e.code === "KeyK"))) {
   5286         e.preventDefault();
   5287         const now = Date.now();
   5288         if (now - lastPaletteToastAt > 1200) {
   5289           lastPaletteToastAt = now;
   5290           ctx.toast("Maps", "Command palette coming soon.");
   5291         }
   5292         return;
   5293       }
   5294       if (focusSupported && e.code === "KeyF") {
   5295         e.preventDefault();
   5296         setFocusMode(!isFocusMode);
   5297         return;
   5298       }
   5299       if (!overlayOpen && !editMode && !e.altKey && !e.ctrlKey && !e.metaKey && e.code === "KeyC") {
   5300         e.preventDefault();
   5301         setCinematicMode(!cinematicMode);
   5302         return;
   5303       }
   5304       if (!overlayOpen && !editMode && !e.altKey && !e.ctrlKey && !e.metaKey && e.code === "KeyG" && gmOverlaySupported && canManageTtrpg) {
   5305         e.preventDefault();
   5306         setGmOverlayVisible(!gmOverlayVisible);
   5307         return;
   5308       }
   5309       if (editMode) {
   5310         if (e.key === "Escape") {
   5311           draftPoly = [];
   5312           polyDrag = null;
   5313           vertexDrag = null;
   5314           const se = document.getElementById("mapsPolyStatus");
   5315           if (se) se.textContent = "Draft cleared.";
   5316           renderMapView();
   5317           return;
   5318         }
   5319         if (e.key === "Enter") {
   5320           if (draftPoly.length < 3) return;
   5321           e.preventDefault();
   5322           const ok = commitDraftPoly();
   5323           const se = document.getElementById("mapsPolyStatus");
   5324           if (se) se.textContent = ok ? "Polygon added." : "Need at least 3 points.";
   5325           if (ok) editTool = "select";
   5326           renderMapView();
   5327           return;
   5328         }
   5329         if ((e.ctrlKey || e.metaKey) && (e.key === "s" || e.key === "S")) {
   5330           e.preventDefault();
   5331           if (!activeMap?.id) return;
   5332           const collisions = Array.isArray(activeMap.collisions) ? activeMap.collisions : [];
   5333           const masks = Array.isArray(activeMap.masks) ? activeMap.masks : [];
   5334           const exits = Array.isArray(activeMap.exits) ? activeMap.exits : [];
   5335           const hiddenMasks = Array.isArray(activeMap.hiddenMasks) ? activeMap.hiddenMasks : [];
   5336           const fallThroughs = Array.isArray(activeMap.fallThroughs) ? activeMap.fallThroughs : [];
   5337           const occluders = Array.isArray(activeMap.occluders) ? activeMap.occluders : [];
   5338           ctx.send("updateMap", { id: activeMap.id, collisions, masks, exits, hiddenMasks, fallThroughs, occluders });
   5339           const se = document.getElementById("mapsPolyStatus");
   5340           if (se) se.textContent = "Saved.";
   5341           return;
   5342         }
   5343         if (e.key === "Delete" || e.key === "Backspace") {
   5344           const list = polysForKind(activeMap, editKind);
   5345           const ok = selectedPolyKind === editKind && selectedPolyIndex >= 0 && selectedPolyIndex < list.length;
   5346           if (!ok) return;
   5347           e.preventDefault();
   5348           list.splice(selectedPolyIndex, 1);
   5349           selectedPolyKind = "";
   5350           selectedPolyIndex = -1;
   5351           selectedVertexIndex = -1;
   5352           renderMapView();
   5353           return;
   5354         }
   5355         if ((e.ctrlKey || e.metaKey) && (e.key === "c" || e.key === "C")) {
   5356           const list = polysForKind(activeMap, editKind);
   5357           const ok = selectedPolyKind === editKind && selectedPolyIndex >= 0 && selectedPolyIndex < list.length;
   5358           if (!ok) return;
   5359           e.preventDefault();
   5360           polyClipboard = { kind: editKind, poly: JSON.parse(JSON.stringify(list[selectedPolyIndex])) };
   5361           renderMapView();
   5362           return;
   5363         }
   5364         if ((e.ctrlKey || e.metaKey) && (e.key === "v" || e.key === "V")) {
   5365           if (!polyClipboard || !polyClipboard.poly) return;
   5366           e.preventDefault();
   5367           const list = polysForKind(activeMap, editKind, true);
   5368           const copy = JSON.parse(JSON.stringify(polyClipboard.poly));
   5369           const pts = Array.isArray(copy.points) ? copy.points : [];
   5370           for (const p of pts) {
   5371             p.x = Math.max(0, Math.min(1, Number(p.x || 0) + 0.02));
   5372             p.y = Math.max(0, Math.min(1, Number(p.y || 0) + 0.02));
   5373           }
   5374           copy.points = pts;
   5375           list.push(copy);
   5376           selectedPolyKind = editKind;
   5377           selectedPolyIndex = list.length - 1;
   5378           selectedVertexIndex = -1;
   5379           renderMapView();
   5380           return;
   5381         }
   5382         // Don't move / chat while editing.
   5383         return;
   5384       }
   5385       if (activeMap?.walkiesEnabled && !overlayOpen && !editMode && e.code === "Backquote") {
   5386         e.preventDefault();
   5387         startWalkie().catch((err) => ctx.toast("Walkie", String(err?.message || err)));
   5388         return;
   5389       }
   5390       if (e.code === "KeyT" && !overlayOpen && !cinematicMode) {
   5391         e.preventDefault();
   5392         setChatOverlayOpen(true);
   5393         return;
   5394       }
   5395       if (!overlayOpen && !editMode && gmOverlaySupported && gmOverlayVisible && activeMap?.ttrpgEnabled && canManageTtrpg) {
   5396         if (e.code === "KeyV") {
   5397           e.preventDefault();
   5398           ttrpgTool = "select";
   5399           gmMode = "select";
   5400           renderMapView();
   5401           return;
   5402         }
   5403         if (e.code === "KeyP") {
   5404           e.preventDefault();
   5405           ttrpgTool = "place";
   5406           gmMode = "place";
   5407           renderMapView();
   5408           return;
   5409         }
   5410         if (e.code === "Space") {
   5411           e.preventDefault();
   5412           ttrpgTool = "pan";
   5413           gmMode = "play";
   5414           renderMapView();
   5415           return;
   5416         }
   5417       }
   5418       if (!overlayOpen && !editMode && gmOverlaySupported && gmOverlayVisible && activeMap?.ttrpgEnabled && canManageTtrpg && (e.code === "KeyQ" || e.code === "KeyE")) {
   5419         e.preventDefault();
   5420         const step = e.shiftKey ? 45 : 15;
   5421         const dir = e.code === "KeyQ" ? -1 : 1;
   5422         const wrapRot = (deg) => {
   5423           let d = Number(deg || 0);
   5424           if (!Number.isFinite(d)) d = 0;
   5425           while (d > 180) d -= 360;
   5426           while (d < -180) d += 360;
   5427           return d;
   5428         };
   5429         const props = Array.isArray(activeMap?.props) ? activeMap.props : [];
   5430         const pidx = selectedPropId ? props.findIndex((p) => String(p?.id || "") === selectedPropId) : -1;
   5431         if (pidx >= 0) {
   5432           const p = props[pidx];
   5433           const nextRot = wrapRot(Number(p?.rot || 0) + step * dir);
   5434           props[pidx] = { ...p, rot: nextRot };
   5435           activeMap.props = props;
   5436           ctx.send("ttrpgPropMove", { mapId: activeMap.id, propId: selectedPropId, x: p.x, y: p.y, z: p.z || 0, rot: nextRot, scale: p.scale || 1 });
   5437           return;
   5438         }
   5439         if (selectedSpriteId) {
   5440           placeRot = wrapRot(placeRot + step * dir);
   5441           renderTtrpgDock();
   5442           return;
   5443         }
   5444       }
   5445       if (!overlayOpen && !editMode && gmOverlaySupported && gmOverlayVisible && activeMap?.ttrpgEnabled && canManageTtrpg && (e.code === "KeyZ" || e.code === "KeyX")) {
   5446         e.preventDefault();
   5447         const delta = e.shiftKey ? 0.2 : 0.1;
   5448         const dir = e.code === "KeyZ" ? -1 : 1;
   5449         const props = Array.isArray(activeMap?.props) ? activeMap.props : [];
   5450         const pidx = selectedPropId ? props.findIndex((p) => String(p?.id || "") === selectedPropId) : -1;
   5451         if (pidx >= 0) {
   5452           const p = props[pidx];
   5453           const nextScale = Math.max(0.1, Math.min(4.0, Number(p?.scale || 1) + delta * dir));
   5454           props[pidx] = { ...p, scale: nextScale };
   5455           activeMap.props = props;
   5456           ctx.send("ttrpgPropMove", { mapId: activeMap.id, propId: selectedPropId, x: p.x, y: p.y, z: p.z || 0, rot: p.rot || 0, scale: nextScale });
   5457           renderTtrpgDock();
   5458           return;
   5459         }
   5460         if (selectedSpriteId) {
   5461           placeScale = Math.max(0.1, Math.min(4.0, Number(placeScale || 1) + delta * dir));
   5462           renderTtrpgDock();
   5463           return;
   5464         }
   5465       }
   5466       if (overlayOpen) return;
   5467       if (["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "KeyW", "KeyA", "KeyS", "KeyD"].includes(e.code)) {
   5468         keys.add(e.code);
   5469       }
   5470     });
   5471     window.addEventListener("keyup", (e) => {
   5472       if (mode !== "map") return;
   5473       keys.delete(e.code);
   5474       if (isTextEditingElement(document.activeElement)) return;
   5475       if (activeMap?.ttrpgEnabled && canManageTtrpg && e.code === "Space" && ttrpgTool === "pan") {
   5476         ttrpgTool = "select";
   5477         gmMode = "select";
   5478         renderMapView();
   5479       }
   5480       if (activeMap?.walkiesEnabled && e.code === "Backquote") {
   5481         stopWalkie();
   5482       }
   5483     });
   5484 
   5485     ws.addEventListener("message", (evt) => {
   5486       let msg;
   5487       try {
   5488         msg = JSON.parse(evt.data);
   5489       } catch {
   5490         return;
   5491       }
   5492       if (!msg || typeof msg !== "object") return;
   5493       const type = String(msg.type || "");
   5494 
   5495       if (type === "plugin:maps:mapsList") {
   5496         maps = Array.isArray(msg.maps) ? msg.maps : [];
   5497         if (mode === "map" && activeMap?.id) {
   5498           const current = maps.find((m) => String(m.id || "").trim().toLowerCase() === String(activeMap.id || ""));
   5499           if (current) {
   5500             activeMap.userCount = Number(current.userCount || 0) || 0;
   5501             activeMap.live = Boolean(current.live);
   5502             activeMap.lastActiveAt = Number(current.lastActiveAt || 0) || 0;
   5503           }
   5504         }
   5505         if (mode === "maps") renderMapsList();
   5506         return;
   5507       }
   5508 
   5509       if (type === "plugin:maps:capabilities") {
   5510         mapsCapabilities = msg && typeof msg === "object" ? msg : null;
   5511         clearCapabilitiesRetry();
   5512         if (!featureEnabled("gmOverlay")) {
   5513           gmOverlayVisible = false;
   5514           inspectorOpen = false;
   5515           gmMode = "play";
   5516           editMode = false;
   5517         }
   5518         if (!featureEnabled("focusMode")) {
   5519           isFocusMode = false;
   5520           applyFocusModeClass();
   5521         }
   5522         devLog("info", "maps:capabilities", mapsCapabilities);
   5523         if (mode === "map" && activeMap) renderMapView();
   5524         return;
   5525       }
   5526 
   5527       if (type === "plugin:maps:avatarPresets") {
   5528         avatarPresets = normalizeAvatarPresetList(msg?.presets);
   5529         avatarPresetsCanManage = Boolean(msg?.canManage);
   5530         if (!avatarPresets.some((preset) => preset.id === avatarPresetSelectedId)) {
   5531           avatarPresetSelectedId = avatarPresets[0]?.id || "";
   5532         }
   5533         if (mode === "map" && avatarEditorOpen) renderMapView();
   5534         return;
   5535       }
   5536 
   5537       if (type === "plugin:maps:avatarPresetsUpdated") {
   5538         ctx.send("listAvatarPresets", {});
   5539         return;
   5540       }
   5541 
   5542       if (type === "plugin:maps:avatarSet") {
   5543         const me = String(ctx.getUser() || "").trim().toLowerCase();
   5544         if (!me) return;
   5545         const mine = users.get(me);
   5546         const normalized = normalizeAvatarState(msg?.avatar || null);
   5547         if (mine) {
   5548           mine.avatar = normalized;
   5549           users.set(me, mine);
   5550         }
   5551         avatarEditorDraft = cloneAvatarForEditor(normalized);
   5552         if (mode === "map") renderMapView();
   5553         return;
   5554       }
   5555 
   5556       if (type === "plugin:maps:joinOk") {
   5557         self = String(ctx.getUser() || "").trim().toLowerCase();
   5558         selfInvisible = Boolean(msg.selfInvisible);
   5559         if (msg.map && typeof msg.map === "object") {
   5560           activeMap = {
   5561             id: String(msg.map.id || "").trim().toLowerCase(),
   5562             title: String(msg.map.title || "").trim(),
   5563             owner: String(msg.map.owner || "").trim().toLowerCase(),
   5564             backgroundUrl: String(msg.map.backgroundUrl || "").trim(),
   5565             world: msg.map.world || null,
   5566             avatarSize: Number(msg.map.avatarSize || 36) || 36,
   5567             cameraZoom: Number(msg.map.cameraZoom || 2.35) || 2.35,
   5568             collisions: Array.isArray(msg.map.collisions) ? msg.map.collisions : [],
   5569             masks: Array.isArray(msg.map.masks) ? msg.map.masks : [],
   5570             exits: Array.isArray(msg.map.exits) ? msg.map.exits : [],
   5571             hiddenMasks: Array.isArray(msg.map.hiddenMasks) ? msg.map.hiddenMasks : [],
   5572             occluders: Array.isArray(msg.map.occluders) ? msg.map.occluders : [],
   5573             fallThroughs: Array.isArray(msg.map.fallThroughs) ? msg.map.fallThroughs : [],
   5574             ttrpgEnabled: Boolean(msg.map.ttrpgEnabled),
   5575             sprites: Array.isArray(msg.map.sprites) ? msg.map.sprites : [],
   5576             props: Array.isArray(msg.map.props) ? msg.map.props : [],
   5577             walkiesEnabled: Boolean(msg.map.walkiesEnabled),
   5578             userCount: Number(msg?.presence?.userCount || 0) || 0,
   5579             live: Boolean(msg?.presence?.live),
   5580             lastActiveAt: Number(msg?.presence?.lastActiveAt || 0) || 0
   5581           };
   5582           ttrpgDockCollapsed = readDockCollapsed(activeMap.id);
   5583           revealFog = getFogReveal(activeMap.id);
   5584           if (pendingSpawn && pendingSpawn.mapId === activeMap.id && pendingSpawn.exitName) {
   5585             const exits = Array.isArray(activeMap.exits) ? activeMap.exits : [];
   5586             const want = String(pendingSpawn.exitName || "").trim().toLowerCase();
   5587             const target = exits.find((ex) => String(ex?.name || "").trim().toLowerCase() === want);
   5588             if (target && Array.isArray(target.points) && target.points.length) {
   5589               const c = polyCentroid(target.points);
   5590               localPos = { x: c.x, y: c.y };
   5591               lastExitAt = Date.now();
   5592               try {
   5593                 ctx.send("move", { x: c.x, y: c.y, seq: moveSeq++ });
   5594               } catch {
   5595                 // ignore
   5596               }
   5597             }
   5598             pendingSpawn = null;
   5599           }
   5600           renderMapView();
   5601         }
   5602         if (activeMap?.id) ctx.send("chatHistoryReq", { mapId: activeMap.id });
   5603         requestCapabilitiesWithRetry("join-ok");
   5604         return;
   5605       }
   5606 
   5607       if (type === "plugin:maps:mapPatched") {
   5608         if (mode !== "map") return;
   5609         const mapId = String(msg.mapId || "").trim().toLowerCase();
   5610         if (!activeMap || mapId !== String(activeMap.id || "")) return;
   5611         const patch = msg.patch && typeof msg.patch === "object" ? msg.patch : null;
   5612         if (!patch) return;
   5613         if (Object.prototype.hasOwnProperty.call(patch, "avatarSize")) activeMap.avatarSize = Number(patch.avatarSize || 36) || 36;
   5614         if (Object.prototype.hasOwnProperty.call(patch, "cameraZoom")) activeMap.cameraZoom = Number(patch.cameraZoom || 2.35) || 2.35;
   5615         if (Object.prototype.hasOwnProperty.call(patch, "walkiesEnabled")) activeMap.walkiesEnabled = Boolean(patch.walkiesEnabled);
   5616         if (Object.prototype.hasOwnProperty.call(patch, "collisions")) activeMap.collisions = Array.isArray(patch.collisions) ? patch.collisions : [];
   5617         if (Object.prototype.hasOwnProperty.call(patch, "masks")) activeMap.masks = Array.isArray(patch.masks) ? patch.masks : [];
   5618         if (Object.prototype.hasOwnProperty.call(patch, "exits")) activeMap.exits = Array.isArray(patch.exits) ? patch.exits : [];
   5619         if (Object.prototype.hasOwnProperty.call(patch, "hiddenMasks")) activeMap.hiddenMasks = Array.isArray(patch.hiddenMasks) ? patch.hiddenMasks : [];
   5620         if (Object.prototype.hasOwnProperty.call(patch, "occluders")) activeMap.occluders = Array.isArray(patch.occluders) ? patch.occluders : [];
   5621         if (Object.prototype.hasOwnProperty.call(patch, "fallThroughs")) activeMap.fallThroughs = Array.isArray(patch.fallThroughs) ? patch.fallThroughs : [];
   5622         if (!shouldDeferMapRerenderForChat()) renderMapView();
   5623         return;
   5624       }
   5625 
   5626       if (type === "plugin:maps:selfInvisible") {
   5627         if (mode !== "map") return;
   5628         const mapId = String(msg.mapId || "").trim().toLowerCase();
   5629         if (!activeMap || mapId !== String(activeMap.id || "")) return;
   5630         selfInvisible = Boolean(msg.invisible);
   5631         if (!shouldDeferMapRerenderForChat()) renderMapView();
   5632         return;
   5633       }
   5634 
   5635       if (type === "plugin:maps:roomState") {
   5636         if (mode !== "map") return;
   5637         const list = Array.isArray(msg.users) ? msg.users : [];
   5638         const next = new Map();
   5639         for (const raw of list) {
   5640           const name = String(raw?.username || "").toLowerCase();
   5641           if (!name) continue;
   5642           const tx = Number(raw?.x || 0);
   5643           const ty = Number(raw?.y || 0);
   5644           const prev = users.get(name) || { x: tx, y: ty, tx, ty, color: "", image: "", avatar: null };
   5645           prev.tx = tx;
   5646           prev.ty = ty;
   5647           // Initialize render position on first sight.
   5648           if (typeof prev.x !== "number" || typeof prev.y !== "number") {
   5649             prev.x = tx;
   5650             prev.y = ty;
   5651           }
   5652           prev.color = raw?.color || prev.color || "";
   5653           prev.image = raw?.image || prev.image || "";
   5654           prev.avatar = normalizeAvatarState(raw?.avatar || prev.avatar || null);
   5655           next.set(name, prev);
   5656         }
   5657         users = next;
   5658         typingUntil.clear();
   5659         for (const rawName of Array.isArray(msg.typingUsers) ? msg.typingUsers : []) {
   5660           const name = String(rawName || "").trim().toLowerCase();
   5661           if (!name) continue;
   5662           typingUntil.set(name, Date.now() + 4500);
   5663         }
   5664         if (activeMap && msg.presence && typeof msg.presence === "object") {
   5665           activeMap.userCount = Number(msg.presence.userCount || users.size) || users.size;
   5666           activeMap.live = Boolean(msg.presence.live);
   5667           activeMap.lastActiveAt = Number(msg.presence.lastActiveAt || 0) || 0;
   5668         }
   5669         const me = (self || String(ctx.getUser() || "")).trim().toLowerCase();
   5670         const mine = me ? users.get(me) : null;
   5671         if (mine) {
   5672           // Keep local prediction, but if we're brand new, seed from server once.
   5673           if (!Number.isFinite(localPos?.x) || !Number.isFinite(localPos?.y)) localPos = { x: Number(mine.tx || 0.5), y: Number(mine.ty || 0.5) };
   5674         }
   5675         if (!shouldDeferMapRerenderForChat()) renderMapView();
   5676         return;
   5677       }
   5678 
   5679       if (type === "plugin:maps:chatHistory") {
   5680         if (mode !== "map") return;
   5681         const mapId = String(msg.mapId || "").trim().toLowerCase();
   5682         if (!activeMap || mapId !== String(activeMap.id || "")) return;
   5683         const scope = String(msg.scope || "local").trim().toLowerCase();
   5684         if (scope === "global") {
   5685           replaceMapChatGlobalHistory(Array.isArray(msg.messages) ? msg.messages : []);
   5686           renderMapChatFeedDom();
   5687         }
   5688         return;
   5689       }
   5690 
   5691       if (type === "plugin:maps:chatMessage") {
   5692         if (mode !== "map") return;
   5693         const mapId = String(msg.mapId || "").trim().toLowerCase();
   5694         if (!activeMap || mapId !== String(activeMap.id || "")) return;
   5695         pushMapChatFeedEntry(msg.scope, msg.message);
   5696         renderMapChatFeedDom();
   5697         return;
   5698       }
   5699 
   5700       if (type === "plugin:maps:typing") {
   5701         if (mode !== "map") return;
   5702         const mapId = String(msg.mapId || "").trim().toLowerCase();
   5703         if (!activeMap || mapId !== String(activeMap.id || "")) return;
   5704         const username = String(msg.username || "").trim().toLowerCase();
   5705         if (!username) return;
   5706         if (Boolean(msg.isTyping)) {
   5707           const expiresAt = Number(msg.expiresAt || 0) || Date.now() + 4500;
   5708           typingUntil.set(username, expiresAt);
   5709         } else {
   5710           typingUntil.delete(username);
   5711         }
   5712         if (!shouldDeferMapRerenderForChat()) renderMapView();
   5713         return;
   5714       }
   5715 
   5716       if (type === "plugin:maps:avatarEmote") {
   5717         if (mode !== "map") return;
   5718         const mapId = String(msg.mapId || "").trim().toLowerCase();
   5719         if (!activeMap || mapId !== String(activeMap.id || "")) return;
   5720         const username = String(msg.username || "").trim().toLowerCase();
   5721         const state = String(msg.state || "").trim();
   5722         const until = Number(msg.until || 0) || 0;
   5723         if (!username || !state || !until) return;
   5724         emoteUntil.set(username, { state, until, loop: Boolean(msg.loop) });
   5725         if (!shouldDeferMapRerenderForChat()) renderMapView();
   5726         return;
   5727       }
   5728 
   5729       if (type === "plugin:maps:avatarChanged") {
   5730         if (mode !== "map") return;
   5731         const mapId = String(msg.mapId || "").trim().toLowerCase();
   5732         if (!activeMap || mapId !== String(activeMap.id || "")) return;
   5733         const username = String(msg.username || "").trim().toLowerCase();
   5734         if (!username) return;
   5735         const prev = users.get(username) || { x: 0.5, y: 0.5, tx: 0.5, ty: 0.5, color: "", image: "", avatar: null };
   5736         prev.avatar = normalizeAvatarState(msg?.avatar || prev.avatar || null);
   5737         users.set(username, prev);
   5738         if (!shouldDeferMapRerenderForChat()) renderMapView();
   5739         return;
   5740       }
   5741 
   5742       if (type === "plugin:maps:userMoved") {
   5743         if (mode !== "map") return;
   5744         const username = String(msg.username || "").toLowerCase();
   5745         if (!username) return;
   5746         const me = (self || String(ctx.getUser() || "")).trim().toLowerCase();
   5747         // Ignore self movement echoes to avoid jitter/snapback.
   5748         if (me && username === me) return;
   5749         const tx = Number(msg.x || 0);
   5750         const ty = Number(msg.y || 0);
   5751         const prev = users.get(username) || { x: tx, y: ty, tx, ty, color: "", image: "", avatar: null };
   5752         prev.tx = tx;
   5753         prev.ty = ty;
   5754         if (typeof prev.x !== "number" || typeof prev.y !== "number") {
   5755           prev.x = tx;
   5756           prev.y = ty;
   5757         }
   5758         users.set(username, prev);
   5759         return;
   5760       }
   5761 
   5762       if (type === "plugin:maps:bubble") {
   5763         if (mode !== "map") return;
   5764         const username = String(msg.username || "").toLowerCase();
   5765         const actorType = String(msg.actorType || "user");
   5766         const actorPropId = String(msg.actorPropId || "");
   5767         const text = String(msg.text || "").trim();
   5768         if (!username || !text) return;
   5769         const bubbleKey = actorType === "token" && actorPropId ? `token:${actorPropId}` : `user:${username}`;
   5770         bubbles.set(bubbleKey, {
   5771           text: text.slice(0, 120),
   5772           actorType: actorType === "token" ? "token" : "user",
   5773           actorPropId,
   5774           username,
   5775           displayName: String(msg.displayName || ""),
   5776           color: String(msg.color || ""),
   5777           expiresAt: Date.now() + 4000
   5778         });
   5779         return;
   5780       }
   5781 
   5782       if (type === "plugin:maps:walkie") {
   5783         if (mode !== "map") return;
   5784         playWalkie(msg);
   5785         return;
   5786       }
   5787 
   5788       if (type === "plugin:maps:ttrpgEnabled") {
   5789         if (mode !== "map") return;
   5790         const mapId = String(msg.mapId || "").trim().toLowerCase();
   5791         if (!activeMap || mapId !== String(activeMap.id || "")) return;
   5792         activeMap.ttrpgEnabled = Boolean(msg.enabled);
   5793         if (!activeMap.ttrpgEnabled) {
   5794           selectedSpriteId = "";
   5795         }
   5796         renderMapView();
   5797         return;
   5798       }
   5799 
   5800       if (type === "plugin:maps:spriteAdded") {
   5801         if (mode !== "map") return;
   5802         const mapId = String(msg.mapId || "").trim().toLowerCase();
   5803         if (!activeMap || mapId !== String(activeMap.id || "")) return;
   5804         const sprite = msg.sprite && typeof msg.sprite === "object" ? msg.sprite : null;
   5805         if (!sprite || !sprite.id) return;
   5806         if (!Array.isArray(activeMap.sprites)) activeMap.sprites = [];
   5807         activeMap.sprites = [...activeMap.sprites.filter((s) => String(s?.id || "") !== String(sprite.id)), sprite];
   5808         if (canManageTtrpg && !selectedSpriteId) {
   5809           const k = spriteKind === "token" ? "token" : "prop";
   5810           if ((sprite.kind || "prop") === k) selectedSpriteId = String(sprite.id);
   5811         }
   5812         renderTtrpgDock();
   5813         return;
   5814       }
   5815 
   5816       if (type === "plugin:maps:spriteRemoved") {
   5817         if (mode !== "map") return;
   5818         const mapId = String(msg.mapId || "").trim().toLowerCase();
   5819         if (!activeMap || mapId !== String(activeMap.id || "")) return;
   5820         const spriteId = String(msg.spriteId || "");
   5821         if (!spriteId) return;
   5822         activeMap.sprites = (Array.isArray(activeMap.sprites) ? activeMap.sprites : []).filter((s) => String(s?.id || "") !== spriteId);
   5823         activeMap.props = (Array.isArray(activeMap.props) ? activeMap.props : []).filter((p) => String(p?.spriteId || "") !== spriteId);
   5824         if (selectedSpriteId === spriteId) selectedSpriteId = "";
   5825         selectedPropId = "";
   5826         renderTtrpgDock();
   5827         return;
   5828       }
   5829 
   5830       if (type === "plugin:maps:propsReset") {
   5831         if (mode !== "map") return;
   5832         const mapId = String(msg.mapId || "").trim().toLowerCase();
   5833         if (!activeMap || mapId !== String(activeMap.id || "")) return;
   5834         activeMap.props = Array.isArray(msg.props) ? msg.props : [];
   5835         if (speakingAsPropId && !activeMap.props.some((p) => String(p?.id || "") === String(speakingAsPropId))) speakingAsPropId = "";
   5836         selectedPropId = "";
   5837         renderTtrpgDock();
   5838         return;
   5839       }
   5840 
   5841       if (type === "plugin:maps:propAdded") {
   5842         if (mode !== "map") return;
   5843         const mapId = String(msg.mapId || "").trim().toLowerCase();
   5844         if (!activeMap || mapId !== String(activeMap.id || "")) return;
   5845         const prop = msg.prop && typeof msg.prop === "object" ? msg.prop : null;
   5846         if (!prop || !prop.id) return;
   5847         if (!Array.isArray(activeMap.props)) activeMap.props = [];
   5848         activeMap.props = [...activeMap.props.filter((p) => String(p?.id || "") !== String(prop.id)), prop];
   5849         renderTtrpgDock();
   5850         return;
   5851       }
   5852 
   5853       if (type === "plugin:maps:propMoved") {
   5854         if (mode !== "map") return;
   5855         const mapId = String(msg.mapId || "").trim().toLowerCase();
   5856         if (!activeMap || mapId !== String(activeMap.id || "")) return;
   5857         const propId = String(msg.propId || "");
   5858         if (!propId) return;
   5859         const props = Array.isArray(activeMap.props) ? activeMap.props : [];
   5860         const idx = props.findIndex((p) => String(p?.id || "") === propId);
   5861         if (idx < 0) return;
   5862         props[idx] = {
   5863           ...props[idx],
   5864           x: Number(msg.x || 0),
   5865           y: Number(msg.y || 0),
   5866           z: Number(msg.z || props[idx]?.z || 0),
   5867           rot: Object.prototype.hasOwnProperty.call(msg || {}, "rot") ? Number(msg.rot || 0) : Number(props[idx]?.rot || 0),
   5868           scale: Object.prototype.hasOwnProperty.call(msg || {}, "scale") ? Number(msg.scale || 1) : Number(props[idx]?.scale || 1)
   5869         };
   5870         activeMap.props = props;
   5871         if (selectedPropId === propId) renderTtrpgDock();
   5872         return;
   5873       }
   5874 
   5875       if (type === "plugin:maps:propRemoved") {
   5876         if (mode !== "map") return;
   5877         const mapId = String(msg.mapId || "").trim().toLowerCase();
   5878         if (!activeMap || mapId !== String(activeMap.id || "")) return;
   5879         const propId = String(msg.propId || "");
   5880         if (!propId) return;
   5881         activeMap.props = (Array.isArray(activeMap.props) ? activeMap.props : []).filter((p) => String(p?.id || "") !== propId);
   5882         if (selectedPropId === propId) selectedPropId = "";
   5883         if (speakingAsPropId === propId) speakingAsPropId = "";
   5884         renderTtrpgDock();
   5885         return;
   5886       }
   5887 
   5888       if (type === "plugin:maps:propPatched") {
   5889         if (mode !== "map") return;
   5890         const mapId = String(msg.mapId || "").trim().toLowerCase();
   5891         if (!activeMap || mapId !== String(activeMap.id || "")) return;
   5892         const prop = msg.prop && typeof msg.prop === "object" ? msg.prop : null;
   5893         if (!prop || !prop.id) return;
   5894         const props = Array.isArray(activeMap.props) ? activeMap.props : [];
   5895         const idx = props.findIndex((p) => String(p?.id || "") === String(prop.id || ""));
   5896         if (idx >= 0) props[idx] = { ...props[idx], ...prop };
   5897         else props.push(prop);
   5898         activeMap.props = props;
   5899         if (speakingAsPropId && String(prop.id || "") === String(speakingAsPropId || "")) {
   5900           const controller = String(prop.controlledBy || "").trim().toLowerCase();
   5901           const me = String(ctx.getUser() || "").trim().toLowerCase();
   5902           if (controller && controller !== me) speakingAsPropId = "";
   5903         }
   5904         renderTtrpgDock();
   5905         return;
   5906       }
   5907 
   5908       if (type === "plugin:maps:error") {
   5909         const message = String(msg.message || "Maps error.");
   5910         ctx.toast("Maps", message);
   5911         return;
   5912       }
   5913     });
   5914 
   5915     if (inRackMode) {
   5916       // In rack mode, Maps is its own panel: start in the list view immediately.
   5917       enterMaps();
   5918     } else {
   5919       // Initial list request (in case the Maps view is opened immediately).
   5920       // The Maps panel triggers another list() on open.
   5921       ctx.send("list", {});
   5922     }
   5923   });
   5924 })();
   5925 
   5926