bzl

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

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, "&lt;")
     32       .replace(/>/g, "&gt;")
     33       .replace(/"/g, "&quot;")
     34       .replace(/'/g, "&#39;");
     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 })();