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, "&") 4879 .replace(/</g, "<") 4880 .replace(/>/g, ">") 4881 .replace(/\"/g, """) 4882 .replace(/'/g, "'"); 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