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