client.js (14340B)
1 (() => { 2 if (!window?.BzlPluginHost?.register) return; 3 4 const BRIDGE_VERSION = "bzl.godot.v1"; 5 const STORAGE_SOURCE_KEY = "source_path"; 6 const FALLBACK_PLUGIN_ID = "godot"; 7 8 function detectPluginIdFromScript() { 9 try { 10 const src = String(document.currentScript?.src || ""); 11 if (!src) return FALLBACK_PLUGIN_ID; 12 const url = new URL(src, window.location.origin); 13 const m = url.pathname.match(/^\/plugins\/([a-z0-9][a-z0-9_.-]{0,31})\//i); 14 if (m && m[1]) return String(m[1]).toLowerCase(); 15 } catch { 16 // ignore 17 } 18 return FALLBACK_PLUGIN_ID; 19 } 20 21 const PLUGIN_ID = detectPluginIdFromScript(); 22 23 function defaultBundledPath(pluginId) { 24 const id = String(pluginId || FALLBACK_PLUGIN_ID).toLowerCase(); 25 return `/plugins/${id}/godotapp/index.html`; 26 } 27 28 function esc(value) { 29 return String(value ?? "") 30 .replace(/&/g, "&") 31 .replace(/</g, "<") 32 .replace(/>/g, ">") 33 .replace(/"/g, """) 34 .replace(/'/g, "'"); 35 } 36 37 function ensureStyles() { 38 if (document.getElementById("bzlGodotPanelStyle")) return; 39 const style = document.createElement("style"); 40 style.id = "bzlGodotPanelStyle"; 41 style.textContent = ` 42 .godotWrap { display:flex; flex-direction:column; gap:10px; min-height:0; height:100%; } 43 .godotControls { display:flex; gap:8px; flex-wrap:wrap; align-items:center; } 44 .godotControls input[type="text"] { flex:1 1 340px; min-width:220px; } 45 .godotHint { font-size:12px; color: rgba(246,240,255,0.72); } 46 .godotStatus { font-size:12px; color: rgba(246,240,255,0.85); } 47 .godotStatus[data-kind="bad"] { color: var(--bad, #ff4d8a); } 48 .godotStatus[data-kind="good"] { color: var(--good, #3ddc97); } 49 .godotViewport { 50 position: relative; 51 min-height: 360px; 52 height: 100%; 53 flex: 1 1 auto; 54 border: 1px solid rgba(246,240,255,0.14); 55 border-radius: 14px; 56 background: linear-gradient(180deg, rgba(0,0,0,0.34), rgba(0,0,0,0.22)); 57 overflow: hidden; 58 } 59 .godotFrame { 60 width: 100%; 61 height: 100%; 62 border: 0; 63 display: block; 64 } 65 .godotEmpty { 66 position:absolute; 67 inset:0; 68 display:flex; 69 align-items:center; 70 justify-content:center; 71 padding:16px; 72 text-align:center; 73 color: rgba(246,240,255,0.7); 74 font-size:13px; 75 } 76 .godotMeta { display:flex; gap:10px; align-items:center; flex-wrap:wrap; } 77 .godotChip { 78 border:1px solid rgba(246,240,255,0.16); 79 border-radius:999px; 80 padding:3px 9px; 81 font-size:11px; 82 color: rgba(246,240,255,0.78); 83 } 84 `; 85 document.head.appendChild(style); 86 } 87 88 function normalizeLocalPath(rawPath) { 89 const raw = String(rawPath || "").trim(); 90 if (!raw) return { ok: false, error: "Enter a Godot export path." }; 91 if (/^https?:\/\//i.test(raw)) { 92 return { ok: false, error: "Use a same-origin path (for example: /uploads/godot/my-game/index.html)." }; 93 } 94 if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/i.test(raw)) { 95 return { ok: false, error: "Only same-origin relative paths are allowed in MVP." }; 96 } 97 const normalized = raw.startsWith("/") ? raw : `/${raw.replace(/^\/+/, "")}`; 98 let url; 99 try { 100 url = new URL(normalized, window.location.origin); 101 } catch { 102 return { ok: false, error: "Invalid path." }; 103 } 104 if (url.origin !== window.location.origin) { 105 return { ok: false, error: "Only same-origin paths are allowed." }; 106 } 107 return { ok: true, path: `${url.pathname}${url.search}${url.hash}` }; 108 } 109 110 window.BzlPluginHost.register(PLUGIN_ID, (ctx) => { 111 ensureStyles(); 112 const bundledPath = defaultBundledPath(ctx?.id || PLUGIN_ID); 113 114 let mountEl = null; 115 let panelEl = null; 116 let viewportEl = null; 117 let frameEl = null; 118 let sourceInputEl = null; 119 let statusEl = null; 120 let lastEventEl = null; 121 let lastVisibility = null; 122 let visibilityTimer = 0; 123 let visibilityObserver = null; 124 let resizeObserver = null; 125 let onVisibilityChange = null; 126 let onWindowResize = null; 127 let onMessage = null; 128 let loadId = 0; 129 let currentPath = ""; 130 let lastBridgeEvent = ""; 131 132 function setStatus(kind, message) { 133 if (!statusEl) return; 134 statusEl.dataset.kind = kind || ""; 135 statusEl.textContent = String(message || ""); 136 } 137 138 function setLastEvent(text) { 139 lastBridgeEvent = String(text || ""); 140 if (!lastEventEl) return; 141 lastEventEl.textContent = lastBridgeEvent || "none"; 142 } 143 144 function panelIsVisible() { 145 if (!(panelEl instanceof HTMLElement)) return false; 146 if (!panelEl.isConnected) return false; 147 if (panelEl.classList.contains("hidden")) return false; 148 if (document.visibilityState === "hidden") return false; 149 const style = window.getComputedStyle(panelEl); 150 if (style.display === "none" || style.visibility === "hidden") return false; 151 return true; 152 } 153 154 function postBridge(eventName, payload) { 155 if (!(frameEl instanceof HTMLIFrameElement)) return false; 156 if (!frameEl.contentWindow) return false; 157 const message = { 158 type: BRIDGE_VERSION, 159 event: String(eventName || ""), 160 payload: payload && typeof payload === "object" ? payload : {}, 161 sentAt: Date.now() 162 }; 163 frameEl.contentWindow.postMessage(message, "*"); 164 setLastEvent(`host->game ${message.event}`); 165 return true; 166 } 167 168 function postResize() { 169 if (!(viewportEl instanceof HTMLElement)) return; 170 const rect = viewportEl.getBoundingClientRect(); 171 postBridge("host:resize", { 172 width: Math.max(0, Math.round(rect.width)), 173 height: Math.max(0, Math.round(rect.height)) 174 }); 175 } 176 177 function syncLifecycle() { 178 const visible = panelIsVisible(); 179 if (visible === lastVisibility) return; 180 lastVisibility = visible; 181 if (visible) { 182 postBridge("host:resume", { reason: "visible" }); 183 setStatus("good", frameEl ? "Running." : "Ready."); 184 } else { 185 postBridge("host:pause", { reason: "hidden" }); 186 setStatus("", frameEl ? "Paused while panel is hidden." : "Ready."); 187 } 188 } 189 190 function unloadFrame(reason) { 191 loadId += 1; 192 if (frameEl) { 193 try { 194 frameEl.remove(); 195 } catch { 196 // ignore 197 } 198 } 199 frameEl = null; 200 if (viewportEl) { 201 viewportEl.innerHTML = `<div class="godotEmpty">No export loaded.</div>`; 202 } 203 if (reason) setStatus("", reason); 204 setLastEvent("none"); 205 } 206 207 function loadFrame(path) { 208 const targetPath = String(path || "").trim(); 209 if (!targetPath) { 210 setStatus("bad", "Provide a path first."); 211 return; 212 } 213 loadId += 1; 214 const id = loadId; 215 if (viewportEl) viewportEl.innerHTML = ""; 216 const frame = document.createElement("iframe"); 217 frame.className = "godotFrame"; 218 frame.setAttribute("sandbox", "allow-scripts allow-same-origin allow-pointer-lock allow-downloads"); 219 frame.setAttribute("allow", "fullscreen; gamepad"); 220 frame.setAttribute("referrerpolicy", "no-referrer"); 221 frame.src = targetPath; 222 frame.addEventListener("load", () => { 223 if (id !== loadId) return; 224 setStatus("good", `Loaded ${targetPath}`); 225 postBridge("host:ready", { 226 user: String(ctx.getUser?.() || ""), 227 role: String(ctx.getRole?.() || ""), 228 plugin: PLUGIN_ID, 229 bridge: BRIDGE_VERSION 230 }); 231 postResize(); 232 syncLifecycle(); 233 }); 234 frame.addEventListener("error", () => { 235 if (id !== loadId) return; 236 setStatus("bad", "Failed to load export."); 237 }); 238 frameEl = frame; 239 viewportEl?.appendChild(frame); 240 setStatus("", `Loading ${targetPath} ...`); 241 } 242 243 function buildUi(api) { 244 const savedPath = String(api?.storage?.get(STORAGE_SOURCE_KEY) || "").trim(); 245 currentPath = savedPath || bundledPath; 246 247 mountEl.innerHTML = ` 248 <div class="godotWrap"> 249 <div class="godotControls"> 250 <input type="text" data-godot-source="1" maxlength="300" placeholder="${bundledPath}" value="${esc(currentPath)}" /> 251 <button type="button" class="primary smallBtn" data-godot-loadbundled="1">Load Bundled App</button> 252 <button type="button" class="primary smallBtn" data-godot-load="1">Load</button> 253 <button type="button" class="ghost smallBtn" data-godot-reload="1">Reload</button> 254 <button type="button" class="ghost smallBtn" data-godot-unload="1">Unload</button> 255 </div> 256 <div class="godotHint">Template mode: put your Godot HTML5 export files in <code>godotapp/</code> inside this plugin zip. Default entry: <code>${bundledPath}</code></div> 257 <div class="godotMeta"> 258 <div class="godotStatus" data-godot-status="1">Ready.</div> 259 <div class="godotChip">bridge: ${BRIDGE_VERSION}</div> 260 <div class="godotChip">last event: <span data-godot-last="1">none</span></div> 261 </div> 262 <div class="godotViewport" data-godot-viewport="1"> 263 <div class="godotEmpty">No export loaded.</div> 264 </div> 265 </div> 266 `; 267 268 sourceInputEl = mountEl.querySelector("[data-godot-source='1']"); 269 statusEl = mountEl.querySelector("[data-godot-status='1']"); 270 lastEventEl = mountEl.querySelector("[data-godot-last='1']"); 271 viewportEl = mountEl.querySelector("[data-godot-viewport='1']"); 272 panelEl = mountEl.closest(".panel"); 273 274 const loadNow = () => { 275 const normalized = normalizeLocalPath(sourceInputEl?.value); 276 if (!normalized.ok) { 277 setStatus("bad", normalized.error); 278 return; 279 } 280 currentPath = normalized.path; 281 api?.storage?.set(STORAGE_SOURCE_KEY, currentPath); 282 loadFrame(currentPath); 283 }; 284 285 mountEl.querySelector("[data-godot-load='1']")?.addEventListener("click", loadNow); 286 mountEl.querySelector("[data-godot-loadbundled='1']")?.addEventListener("click", () => { 287 currentPath = bundledPath; 288 if (sourceInputEl) sourceInputEl.value = currentPath; 289 api?.storage?.set(STORAGE_SOURCE_KEY, currentPath); 290 loadFrame(currentPath); 291 }); 292 mountEl.querySelector("[data-godot-reload='1']")?.addEventListener("click", () => { 293 if (!currentPath) { 294 setStatus("bad", "Load a source first."); 295 return; 296 } 297 loadFrame(currentPath); 298 }); 299 mountEl.querySelector("[data-godot-unload='1']")?.addEventListener("click", () => { 300 unloadFrame("Unloaded."); 301 }); 302 sourceInputEl?.addEventListener("keydown", (e) => { 303 if (e.key !== "Enter") return; 304 e.preventDefault(); 305 loadNow(); 306 }); 307 } 308 309 function bindLifecycleObservers() { 310 if (!panelEl) return; 311 lastVisibility = null; 312 syncLifecycle(); 313 314 visibilityObserver = new MutationObserver(() => syncLifecycle()); 315 visibilityObserver.observe(panelEl, { attributes: true, attributeFilter: ["class", "style"] }); 316 317 onVisibilityChange = () => syncLifecycle(); 318 document.addEventListener("visibilitychange", onVisibilityChange); 319 320 onWindowResize = () => { 321 postResize(); 322 syncLifecycle(); 323 }; 324 window.addEventListener("resize", onWindowResize); 325 326 if (window.ResizeObserver && viewportEl) { 327 resizeObserver = new ResizeObserver(() => postResize()); 328 resizeObserver.observe(viewportEl); 329 } 330 331 visibilityTimer = window.setInterval(syncLifecycle, 900); 332 } 333 334 function bindMessageBridge() { 335 onMessage = (evt) => { 336 if (!(frameEl instanceof HTMLIFrameElement)) return; 337 if (evt.source !== frameEl.contentWindow) return; 338 const msg = evt.data; 339 if (!msg || typeof msg !== "object") return; 340 if (String(msg.type || "") !== BRIDGE_VERSION) return; 341 const ev = String(msg.event || ""); 342 const payload = msg.payload && typeof msg.payload === "object" ? msg.payload : {}; 343 setLastEvent(`game->host ${ev || "unknown"}`); 344 345 if (ev === "ready") { 346 setStatus("good", "Game reported ready."); 347 postResize(); 348 return; 349 } 350 if (ev === "error") { 351 const detail = String(payload.message || payload.code || "unknown error"); 352 setStatus("bad", `Game error: ${detail}`); 353 return; 354 } 355 if (ev === "state") { 356 const status = String(payload.status || "state"); 357 setStatus("", `State: ${status}`); 358 return; 359 } 360 }; 361 window.addEventListener("message", onMessage); 362 } 363 364 ctx.ui?.registerPanel?.({ 365 id: "godot", 366 title: "Godot", 367 icon: "G", 368 defaultRack: "main", 369 role: "primary", 370 presetHints: { 371 defaultSocial: { place: "docked.bottom" }, 372 mapsSession: { place: "docked.bottom" } 373 }, 374 render(mount, api) { 375 mountEl = mount; 376 buildUi(api); 377 bindLifecycleObservers(); 378 bindMessageBridge(); 379 380 loadFrame(currentPath || bundledPath); 381 382 return () => { 383 if (visibilityTimer) window.clearInterval(visibilityTimer); 384 visibilityTimer = 0; 385 try { 386 visibilityObserver?.disconnect(); 387 } catch { 388 // ignore 389 } 390 try { 391 resizeObserver?.disconnect(); 392 } catch { 393 // ignore 394 } 395 if (onVisibilityChange) document.removeEventListener("visibilitychange", onVisibilityChange); 396 if (onWindowResize) window.removeEventListener("resize", onWindowResize); 397 if (onMessage) window.removeEventListener("message", onMessage); 398 unloadFrame(""); 399 mountEl = null; 400 panelEl = null; 401 viewportEl = null; 402 sourceInputEl = null; 403 statusEl = null; 404 lastEventEl = null; 405 visibilityObserver = null; 406 resizeObserver = null; 407 onVisibilityChange = null; 408 onWindowResize = null; 409 onMessage = null; 410 }; 411 } 412 }); 413 }); 414 })();