Your IP : 216.73.216.79


Current Path : /var/www/html/unity/development/ui-builder/
Upload File :
Current File : /var/www/html/unity/development/ui-builder/ui-player.html

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>UI Player — Game Interface Runtime</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/animate.css/4.1.1/animate.min.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/OwlCarousel2/2.3.4/assets/owl.carousel.min.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/OwlCarousel2/2.3.4/assets/owl.theme.default.min.css" rel="stylesheet">
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.1/jquery.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/OwlCarousel2/2.3.4/owl.carousel.min.js"></script>
<link href="https://fonts.googleapis.com/css2?family=Rajdhani:wght@400;500;600;700&family=Orbitron:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
:root {
  --accent: #00e5a0;
  --accent2: #a78bfa;
  --danger: #ff4d6a;
  --text-muted: #607080;
}
* { margin:0; padding:0; box-sizing:border-box; }
html, body {
  width:100%; height:100%;
  overflow:hidden;
  background:#000;
  font-family:'Rajdhani',sans-serif;
  color:#e0e8f0;
}

/* ═══ Page background layer ═══ */
#page-bg {
  position:fixed; inset:0; z-index:0;
  background:#000;
  background-size:cover; background-position:center;
}

/* ═══ Loading / Drop screen ═══ */
#loading-overlay {
  position:fixed; inset:0; z-index:10000;
  background:#05070e;
  display:flex; flex-direction:column;
  align-items:center; justify-content:center;
  transition:opacity 0.5s;
}
#loading-overlay.fade-out { opacity:0; pointer-events:none; }
.load-logo {
  font-family:'Orbitron',sans-serif; font-size:1.5rem; font-weight:700;
  letter-spacing:5px; color:var(--accent); text-transform:uppercase;
  margin-bottom:6px;
}
.load-sub {
  font-size:0.8rem; color:#a0b0c0; letter-spacing:2px; margin-bottom:26px;
}
.load-card {
  background:rgba(18,20,30,0.9);
  border:1px solid rgba(255,255,255,0.08);
  border-radius:14px; padding:26px 30px;
  width:400px; max-width:92vw;
  text-align:center;
  box-shadow:0 10px 40px rgba(0,0,0,0.5);
}
.load-card h3 {
  font-family:'Orbitron',sans-serif; font-size:0.7rem; letter-spacing:2px;
  color:var(--accent2); margin-bottom:8px; text-transform:uppercase;
}
.load-card h3 i { margin-right:6px; }
.load-card p { font-size:0.75rem; color:#8a90a4; margin-bottom:18px; line-height:1.5; }
.load-drop {
  position:relative;
  border:2px dashed rgba(167,139,250,0.35);
  border-radius:12px; padding:26px 16px;
  cursor:pointer; transition:all 0.2s;
  display:flex; flex-direction:column; align-items:center; gap:6px;
}
.load-drop:hover, .load-drop.dragover {
  border-color:var(--accent2);
  background:rgba(167,139,250,0.06);
}
.load-drop.loaded { border-color:var(--accent); background:rgba(0,229,160,0.05); }
.load-drop .drop-icon { font-size:1.9rem; color:var(--accent2); }
.load-drop.loaded .drop-icon { color:var(--accent); }
.load-drop span { font-size:0.8rem; font-weight:600; color:#c8cce0; }
.load-drop small { font-size:0.6rem; color:var(--text-muted); }
.load-drop .drop-filename { font-size:0.62rem; color:var(--accent); word-break:break-all; }
.load-drop .drop-check { display:none; position:absolute; top:8px; right:10px; color:var(--accent); font-size:0.9rem; }
.load-drop.loaded .drop-check { display:block; }
.load-drop input[type=file] { position:absolute; inset:0; opacity:0; cursor:pointer; }
#btn-start {
  display:none;
  margin:18px auto 0; padding:11px 42px;
  font-family:'Orbitron',sans-serif; font-size:0.75rem; font-weight:700;
  letter-spacing:3px; text-transform:uppercase;
  color:#000; background:linear-gradient(135deg,#00e5a0,#00b87a);
  border:none; border-radius:10px; cursor:pointer;
  box-shadow:0 4px 20px rgba(0,229,160,0.35);
  transition:transform 0.15s, box-shadow 0.15s;
}
#btn-start.visible { display:block; }
#btn-start:hover { transform:translateY(-2px); box-shadow:0 6px 26px rgba(0,229,160,0.5); }
#loading-status {
  display:none; margin-top:14px;
  font-size:0.68rem; color:#8a90a4;
}
#load-progress {
  display:none; width:280px; height:6px; margin-top:12px;
  background:rgba(255,255,255,0.08); border-radius:3px; overflow:hidden;
}
#load-progress-bar {
  height:100%; width:5%;
  background:linear-gradient(90deg, var(--accent), #00c8ff);
  border-radius:3px; transition:width 0.3s;
}

/* ═══ Game UI Overlay (same classes as game-level) ═══ */
#game-ui-overlay {
  position:fixed; top:0; left:0; width:100%; height:100%;
  pointer-events:none; z-index:100; overflow:hidden;
}
.gui-el {
  position:absolute; pointer-events:auto;
  transition: opacity 0.2s;
}
.gui-el.gui-hidden { display:none; }
.gui-progressbar-track { width:100%; overflow:hidden; position:relative; }
.gui-progressbar-fill { height:100%; transition:width 0.3s ease; }
.gui-progressbar-label {
  position:absolute; left:6px; top:50%; transform:translateY(-50%);
  font-family:'Rajdhani',sans-serif; font-size:0.55rem; font-weight:700;
  color:rgba(255,255,255,0.8); text-shadow:0 1px 2px rgba(0,0,0,0.5); z-index:1;
}
@media (max-width:768px) {
  .gui-el.gui-mobile-hidden { display:none !important; }
}

/* ═══ Toast ═══ */
#toast-container {
  position:fixed; bottom:20px; left:50%; transform:translateX(-50%);
  z-index:11000; display:flex; flex-direction:column; gap:6px; align-items:center;
  pointer-events:none;
}
.player-toast {
  background:rgba(10,12,20,0.92); color:#e0e8f0;
  border:1px solid rgba(0,229,160,0.35);
  padding:8px 20px; border-radius:8px; font-size:0.78rem;
  animation:toastIn 0.25s ease;
}
.player-toast.warn { border-color:rgba(245,158,11,0.5); }
.player-toast.error { border-color:rgba(255,77,106,0.5); }
@keyframes toastIn { from { opacity:0; transform:translateY(8px); } to { opacity:1; transform:none; } }

/* ═══ Iframe overlay / HTML popup ═══ */
#iframe-overlay {
  position:fixed; inset:0; z-index:10500; background:#000;
  display:flex; flex-direction:column;
}
#iframe-overlay iframe { flex:1; border:none; width:100%; }
#iframe-overlay .iframe-back {
  position:absolute; top:12px; left:12px; z-index:2;
  width:44px; height:44px; border-radius:50%;
  background:rgba(10,12,20,0.85); border:1px solid rgba(255,255,255,0.2);
  color:#fff; font-size:1.1rem; cursor:pointer;
  display:flex; align-items:center; justify-content:center;
  box-shadow:0 4px 16px rgba(0,0,0,0.5);
}
.html-popup-backdrop {
  position:fixed; inset:0; z-index:10400;
  background:rgba(0,0,0,0.6); backdrop-filter:blur(3px);
  display:flex; align-items:center; justify-content:center;
}
.html-popup {
  background:rgba(15,18,28,0.97); color:#e0e8f0;
  border:1px solid rgba(255,255,255,0.12); border-radius:14px;
  padding:24px 28px; max-width:min(620px, 92vw); max-height:80vh; overflow:auto;
  box-shadow:0 12px 48px rgba(0,0,0,0.6);
  position:relative; font-size:0.9rem; line-height:1.6;
}
.html-popup .popup-close {
  position:absolute; top:10px; right:10px;
  width:28px; height:28px; border-radius:6px;
  background:rgba(255,77,106,0.1); border:1px solid rgba(255,77,106,0.4);
  color:var(--danger); cursor:pointer; font-size:0.7rem;
  display:flex; align-items:center; justify-content:center;
}
</style>
</head>
<body>

<div id="page-bg"></div>

<!-- ═══ Loading / Drop Screen ═══ -->
<div id="loading-overlay">
  <div class="load-logo" id="load-title">UI Player</div>
  <div class="load-sub" id="load-subtitle">UI Builder Runtime</div>
  <div class="load-card" id="load-card">
    <h3><i class="bi bi-layout-wtf"></i> Load Your UI</h3>
    <p>Drop a project saved from the UI Builder to run it.</p>
    <div class="load-drop" id="drop-zone-ui">
      <i class="bi bi-filetype-json drop-icon"></i>
      <span>UI Project File</span>
      <small>.ui.json</small>
      <span class="drop-filename" id="ui-filename"></span>
      <i class="bi bi-check-circle-fill drop-check"></i>
      <input type="file" id="file-input-ui" accept=".json,.ui.json">
    </div>
    <button id="btn-start"><i class="bi bi-play-fill"></i> Start</button>
  </div>
  <div id="loading-status"></div>
  <div id="load-progress"><div id="load-progress-bar"></div></div>
</div>

<!-- ═══ Game UI Overlay ═══ -->
<div id="game-ui-overlay"></div>

<div id="toast-container"></div>

<script>
/* ═══════════════════════════════════════════
   UI PLAYER — dedicated runtime for
   UI Builder projects (.ui.json)

   Loads a project three ways:
   1. Drag & drop / browse on the start screen
   2. Auto-load via ui-player.json → { "uiFile": "saves/menu.ui.json" }
   3. URL query param → ui-player.html?ui=saves/menu.ui.json
   ═══════════════════════════════════════════ */

/* ═══════════════════════════════════════════
   SETTINGS (ui-player.json)
   ═══════════════════════════════════════════ */
const uiPlayerSettings = {
  // Auto-load: if uiFile is set, skip the drop-zone and load directly
  uiFile: '',                 // path/URL to .ui.json
  // Page background behind the UI (CSS color/gradient). The project's own
  // bgImage (set in the builder) is drawn on top of this if present.
  pageBackground: '#000000',
  // Toasts
  showToasts: true,
  // Loading screen customization
  loadingTitle: 'UI Player',
  loadingSubtitle: 'UI Builder Runtime',
  loadingMessage: 'Memuat kandungan. Sila tunggu sebentar.',
  loadingBgColor: '#05070e',
  loadingTextColor: '#a0b0c0',
  loadingAccentColor: '#00e5a0',
  loadingFontFamily: 'Rajdhani, sans-serif',
  loadingShowProgress: true,
  loadingLogo: '',            // optional image URL for logo
};

let _settingsLoaded = false;
async function loadUIPlayerSettings() {
  if (_settingsLoaded) return;
  _settingsLoaded = true;
  if (window.__EMBEDDED_SETTINGS__) {
    Object.assign(uiPlayerSettings, window.__EMBEDDED_SETTINGS__);
    return;
  }
  try {
    const r = await fetch('./ui-player.json');
    if (!r.ok) throw new Error('HTTP ' + r.status);
    Object.assign(uiPlayerSettings, await r.json());
  } catch(e) {
    // No settings file — use defaults
  }
}

/* ═══════════════════════════════════════════
   TOAST
   ═══════════════════════════════════════════ */
function showToast(msg, type) {
  if (uiPlayerSettings.showToasts === false) return;
  const t = document.createElement('div');
  t.className = 'player-toast' + (type ? ' ' + type : '');
  t.textContent = msg;
  document.getElementById('toast-container').appendChild(t);
  setTimeout(() => { t.style.opacity = '0'; setTimeout(() => t.remove(), 300); }, 2500);
}

/* ═══════════════════════════════════════════
   MUSIC & AUDIO
   ═══════════════════════════════════════════ */
let musicAudio = null;
let masterVolume = 0.5;
let musicMuted = false;

function playMusic(src, loop) {
  stopMusic();
  try {
    musicAudio = new Audio(src);
    musicAudio.loop = loop !== false;
    musicAudio.volume = musicMuted ? 0 : masterVolume;
    musicAudio.play().catch(e => console.warn('[Music]', e));
  } catch(e) { console.warn('[Music]', e); }
}
function stopMusic() {
  if (musicAudio) { musicAudio.pause(); musicAudio.currentTime = 0; musicAudio = null; }
}
function pauseMusic() { if (musicAudio) musicAudio.pause(); }
function resumeMusic() { if (musicAudio) musicAudio.play().catch(() => {}); }
function setMusicVolume(vol) {
  masterVolume = Math.max(0, Math.min(1, vol));
  if (musicAudio && !musicMuted) musicAudio.volume = masterVolume;
}

/* ═══════════════════════════════════════════
   IFRAME OVERLAY + HTML POPUP
   ═══════════════════════════════════════════ */
function showIframeOverlay(url, backIcon, closeSound) {
  const existing = document.getElementById('iframe-overlay');
  if (existing) existing.remove();
  const ov = document.createElement('div');
  ov.id = 'iframe-overlay';
  const back = document.createElement('div');
  back.className = 'iframe-back';
  back.innerHTML = backIcon ? '<img src="' + backIcon + '" style="width:60%;height:60%;object-fit:contain;">' : '<i class="bi bi-arrow-left"></i>';
  back.onclick = () => {
    if (closeSound) { try { new Audio(closeSound).play().catch(() => {}); } catch(e) {} }
    ov.remove();
  };
  const frame = document.createElement('iframe');
  frame.src = url;
  frame.allow = 'autoplay; fullscreen';
  ov.appendChild(back);
  ov.appendChild(frame);
  document.body.appendChild(ov);
}

function showHTMLPopup(html, opts) {
  opts = opts || {};
  const backdrop = document.createElement('div');
  backdrop.className = 'html-popup-backdrop';
  const box = document.createElement('div');
  box.className = 'html-popup';
  if (opts.bgColor) box.style.background = opts.bgColor;
  if (opts.textColor) box.style.color = opts.textColor;
  const close = document.createElement('div');
  close.className = 'popup-close';
  close.innerHTML = '<i class="bi bi-x-lg"></i>';
  close.onclick = () => backdrop.remove();
  const content = document.createElement('div');
  content.innerHTML = html;
  box.appendChild(close);
  box.appendChild(content);
  backdrop.appendChild(box);
  backdrop.addEventListener('click', e => { if (e.target === backdrop) backdrop.remove(); });
  document.body.appendChild(backdrop);
}

/* ═══════════════════════════════════════════
   GAME UI RUNTIME (same model as game-level)
   ═══════════════════════════════════════════ */
let gameUIData = null;
let gameUIElements = {};  // id → { dom, data }
let gameUIState = {};     // variable bindings: score, timer, health, etc.

// Virtual key state for d-pad / joystick panels
window.playerKeys = window.playerKeys || { w:false, a:false, s:false, d:false, shift:false, space:false };

function resolveBindingText(content, binding) {
  if (!content) return '';
  return content.replace(/\{(\w+)\}/g, (match, key) => {
    return gameUIState[key] !== undefined ? gameUIState[key] : match;
  });
}

function updateGameUI() {
  for (const [id, entry] of Object.entries(gameUIElements)) {
    const el = entry.data;
    if (el.binding && (el.type === 'text' || el.type === 'button')) {
      const inner = entry.dom.querySelector('.gui-inner');
      if (inner) inner.textContent = resolveBindingText(el.content, el.binding);
    }
    if (el.type === 'progressbar' && el.binding) {
      const fill = entry.dom.querySelector('.gui-progressbar-fill');
      if (fill) {
        const val = gameUIState[el.binding] !== undefined ? gameUIState[el.binding] : el.barValue;
        fill.style.width = Math.max(0, Math.min(100, val || 0)) + '%';
      }
    }
  }
}

function setUIValue(key, val) { gameUIState[key] = val; updateGameUI(); }
function getUIValue(key) { return gameUIState[key]; }
function setUIVisible(elId, visible) {
  const entry = gameUIElements[elId];
  if (!entry) return;
  if (visible) { entry.dom.classList.remove('gui-hidden'); entry.data.visible = true; }
  else { entry.dom.classList.add('gui-hidden'); entry.data.visible = false; }
}
function setUIContent(elId, text) {
  const entry = gameUIElements[elId];
  if (!entry) return;
  entry.data.content = text;
  const inner = entry.dom.querySelector('.gui-inner');
  if (inner) inner.textContent = resolveBindingText(text, entry.data.binding);
}
function setUIStyle(elId, prop, val) {
  const entry = gameUIElements[elId];
  if (!entry) return;
  const inner = entry.dom.querySelector('.gui-inner');
  if (inner) inner.style[prop] = val;
}

/* ── Multi-stage element animation (entrance → attention → exit) ── */
function uiAnimHasSequence(el) {
  return !!(el.animEnterClass || el.animAttnClass || el.animExitClass || el.animateClass);
}

function clearUIAnim(inner) {
  if (!inner) return;
  if (inner._animTimers) inner._animTimers.forEach(function(t) { window.clearTimeout(t); });
  inner._animTimers = [];
  Array.prototype.slice.call(inner.classList).forEach(function(c) {
    if (c.indexOf('animate__') === 0) inner.classList.remove(c);
  });
  inner.style.removeProperty('--animate-duration');
  inner.style.removeProperty('animation-delay');
  inner.style.removeProperty('animation-duration');
  inner.style.removeProperty('animation-iteration-count');
  inner.style.removeProperty('opacity');
  inner.style.removeProperty('pointer-events');
}

/** Plays one animate.css class. `done` is not called for infinite repeats. */
function playUIAnimStage(inner, cls, durationMs, repeat, done) {
  if (!inner) return;
  if (!cls) { if (done) done(); return; }
  Array.prototype.slice.call(inner.classList).forEach(function(c) {
    if (c.indexOf('animate__') === 0) inner.classList.remove(c);
  });
  void inner.offsetWidth; // reflow so the animation can restart
  var dur = parseInt(durationMs);
  if (!dur || dur < 0) dur = 800;
  var infinite = (String(repeat) === 'infinite');
  var rep = infinite ? 'infinite' : (parseInt(repeat) || 1);
  inner.style.setProperty('--animate-duration', (dur / 1000) + 's');
  inner.style.animationDuration = (dur / 1000) + 's';
  inner.style.animationIterationCount = rep;
  inner.classList.add('animate__animated', cls.indexOf('animate__') === 0 ? cls : 'animate__' + cls);
  if (infinite || !done) return;
  var t = window.setTimeout(done, dur * rep + 30);
  (inner._animTimers = inner._animTimers || []).push(t);
}

/**
 * Runs the element's animation sequence.
 * stage: 'all' (default) | 'entrance' | 'attention' | 'exit' | 'stop'
 */
function runUIAnimSequence(inner, el, stage) {
  if (!inner || !el) return;
  stage = stage || 'all';
  clearUIAnim(inner);
  if (stage === 'stop') return;
  inner._animTimers = inner._animTimers || [];

  // Legacy single-animation projects behave like an entrance
  var enterCls = el.animEnterClass || (!el.animAttnClass && !el.animExitClass ? el.animateClass : '') || '';
  var enterDur = el.animEnterDuration !== undefined && el.animEnterDuration !== ''
    ? el.animEnterDuration
    : (parseFloat(el.animateDuration) ? parseFloat(el.animateDuration) * 1000 : 800);
  var enterDelay = el.animEnterDelay !== undefined && el.animEnterDelay !== ''
    ? el.animEnterDelay
    : (parseFloat(el.animateDelay) ? parseFloat(el.animateDelay) * 1000 : 0);
  var enterRep = el.animEnterRepeat || (el.animateRepeat && !el.animEnterClass ? el.animateRepeat : 1);

  function wait(ms, fn) {
    var t = window.setTimeout(fn, Math.max(0, parseInt(ms) || 0));
    inner._animTimers.push(t);
    return t;
  }

  function doExit() {
    if (!el.animExitClass) return;
    wait(el.animExitDelay, function() {
      playUIAnimStage(inner, el.animExitClass, el.animExitDuration, el.animExitRepeat || 1, function() {
        if (el.animExitHide !== false) {
          inner.style.opacity = '0';
          inner.style.pointerEvents = 'none';
        }
      });
    });
  }

  function doAttention(next) {
    if (!el.animAttnClass) { if (next) next(); return; }
    wait(el.animAttnDelay, function() {
      var infinite = String(el.animAttnRepeat) === 'infinite';
      playUIAnimStage(inner, el.animAttnClass, el.animAttnDuration, el.animAttnRepeat || 1, infinite ? null : next);
      if (infinite && next) next(); // exit is timed from when the loop starts
    });
  }

  // While an entrance is waiting out its delay, keep the element invisible —
  // otherwise it pops in first and only then animates.
  function armEntrance(then) {
    var d = Math.max(0, parseInt(enterDelay) || 0);
    if (enterCls && d > 0) inner.style.opacity = '0';
    wait(d, function() {
      inner.style.removeProperty('opacity');
      then();
    });
  }

  if (stage === 'entrance') { armEntrance(function() { playUIAnimStage(inner, enterCls, enterDur, enterRep, null); }); return; }
  if (stage === 'attention') { doAttention(null); return; }
  if (stage === 'exit') { doExit(); return; }

  // Full sequence
  if (enterCls) {
    armEntrance(function() {
      playUIAnimStage(inner, enterCls, enterDur, enterRep, function() { doAttention(doExit); });
    });
  } else {
    doAttention(doExit);
  }
}

/* ── Carousel runtime (Owl Carousel 2) ── */
function escAttr(s) { return String(s == null ? '' : s).replace(/"/g, '&quot;'); }

/** Overlay image: an absolutely placed box (margin + padding) holding a sized image */
function carFgHTML(el, item, boxStyle, isCover) {
  var pad = item.fgPadding !== undefined && item.fgPadding !== '' ? item.fgPadding : el.carFgPadding;
  var w = item.fgWidth !== undefined && item.fgWidth !== '' ? item.fgWidth : el.carFgWidth;
  var hgt = item.fgHeight !== undefined && item.fgHeight !== '' ? item.fgHeight : el.carFgHeight;
  var fit = item.fgFit || el.carFgFit || 'contain';
  if (fit === 'stretch') fit = 'fill';
  var imgStyle = 'width:' + cssLen(w, '100%') + ';height:' + cssLen(hgt, '100%') +
    ';object-fit:' + fit + ';';
  return '<div class="car-fg-box' + (isCover ? ' car-fg-cover' : '') + '" style="' + boxStyle +
    'padding:' + cssLen(pad, '0px') + ';">' +
    '<img class="car-fg" src="' + escAttr(item.foreground) + '" style="' + imgStyle + '" draggable="false"></div>';
}

function carouselItemInnerHTML(el, item) {
  if (el.carItemTemplate) {
    var out = el.carItemTemplate
      .replace(/\{image\}/g, item.image || '')
      .replace(/\{title\}/g, item.title || '')
      .replace(/\{text\}/g, item.text || '')
      .replace(/\{link\}/g, item.link || '');
    // Any custom field can be used as {fieldName}
    if (item.fields) {
      Object.keys(item.fields).forEach(function(k) {
        out = out.split('{' + k + '}').join(item.fields[k] == null ? '' : item.fields[k]);
      });
    }
    return out;
  }
  var h = '';
  var cover = el.carImgMode === 'cover';
  // What sits on top of the card image: a 3D model, another image, or nothing
  var layer = item.layer || (item.model ? 'model' : (item.foreground ? 'image' : 'none'));
  var hasModel = layer === 'model' && item.model;
  var hasFg = layer === 'image' && item.foreground;
  var fgInset = item.fgInset !== undefined && item.fgInset !== '' ? item.fgInset : el.carFgInset;
  var fgStyle = 'inset:' + cssLen(fgInset, '0px') + ';';

  if (cover) {
    // The image fills the whole card and everything else stacks on top of it
    if (el.carShowImage !== false && item.image) {
      h += '<img class="car-bgimg" src="' + escAttr(item.image) + '" alt="' + escAttr(item.title) + '" draggable="false">';
    }
    if (hasModel) h += '<div class="car-model car-model-cover" data-model="' + escAttr(item.model) + '"></div>';
    else if (hasFg) h += carFgHTML(el, item, fgStyle, true);
  } else if (hasModel || hasFg) {
    // Background image keeps its normal size; the model / overlay image is layered over it,
    // so a card with either is exactly the same size as one without.
    h += '<div class="car-media">';
    if (el.carShowImage !== false && item.image) {
      h += '<img class="car-img" src="' + escAttr(item.image) + '" alt="' + escAttr(item.title) + '" draggable="false">';
    }
    if (hasModel) h += '<div class="car-model" data-model="' + escAttr(item.model) + '"></div>';
    else h += carFgHTML(el, item, fgStyle, false);
    h += '</div>';
  } else if (el.carShowImage !== false && item.image) {
    h += '<img class="car-img" src="' + escAttr(item.image) + '" alt="' + escAttr(item.title) + '" draggable="false">';
  }
  if (el.carShowTitle !== false && item.title) h += '<div class="car-title">' + item.title + '</div>';
  if (el.carShowText !== false && item.text) h += '<div class="car-text">' + item.text + '</div>';
  // Extra fields marked "show in card"
  (el.carShowFields || []).forEach(function(key) {
    var v = item.fields ? item.fields[key] : undefined;
    if (v !== undefined && v !== '') h += '<div class="car-field car-field-' + escAttr(key).replace(/[^\w-]/g, '') + '">' + v + '</div>';
  });
  return h;
}

/** Active-item settings can differ per device; these are the keys that support it */
var CAR_DEVICE_PROPS = ['carActiveSlot','carCenterScale','carSideScale','carSideOpacity',
  'carCenterOffsetX','carCenterOffsetY','carCenterWidth','carCenterHeight',
  'carCenterPadding','carCenterMargin','carCenterRadius',
  // breathing room around the strip, per-device
  'carPadTop','carPadBottom',
  // arrow + dot placement is per-device too
  'carNavInset','carNavOffsetX','carNavOffsetY',
  'carNavPrevX','carNavPrevY','carNavNextX','carNavNextY',
  'carDotOffsetX','carDotOffsetY','carDotMarginTop','carDotBottom'];

/** el merged with its overrides for `device` ('tablet' | 'phone' | 'phone-land') */
function carMergedFor(el, device) {
  var ov = (device && el.carDeviceOverrides) ? el.carDeviceOverrides[device] : null;
  if (!ov) return el;
  var m = {}, k;
  for (k in el) m[k] = el[k];
  CAR_DEVICE_PROPS.forEach(function(prop) {
    if (ov[prop] !== undefined && ov[prop] !== null && ov[prop] !== '') m[prop] = ov[prop];
    else if (prop === 'carActiveSlot' && ov[prop] === '') m[prop] = '';
  });
  return m;
}

function carHasDeviceOverride(el, device) {
  var ov = (el.carDeviceOverrides || {})[device];
  if (!ov) return false;
  return CAR_DEVICE_PROPS.some(function(p) { return ov[p] !== undefined && ov[p] !== null; });
}

/** Explicit active-slot mode: the active card sits in a chosen visible slot
 *  instead of being geometrically centred (e.g. 1 card left, 2 cards right). */
function carActiveSlotOf(el, perView) {
  var v = el.carActiveSlot;
  if (v === '' || v === undefined || v === null || v === 'auto') return null;
  var n = parseInt(v);
  if (isNaN(n)) return null;
  return Math.max(0, Math.min((parseInt(perView) || 3) - 1, n));
}

function carUsesSlotMode(el) {
  return carActiveSlotOf(el, el.carItems) !== null;
}

/** Media queries matching the builder's device modes */
var CAR_DEVICE_QUERIES = {
  tablet: ['@media (max-width:1024px) and (min-width:481px)'],
  phone: ['@media (max-width:480px) and (orientation:portrait)'],
  'phone-land': ['@media (orientation:landscape) and (max-height:500px)', '@media (max-width:768px) and (orientation:landscape)']
};

/** The active-item + stage-shift rules for one configuration (base or a device override) */
function carActiveRules(el, cfg, itemCount) {
  var s = '.car-' + el.id;
  var cScale = cfg.carCenterScale !== undefined ? cfg.carCenterScale : 1;
  var sScale = cfg.carSideScale !== undefined ? cfg.carSideScale : 1;
  var sOpac = cfg.carSideOpacity !== undefined ? cfg.carSideOpacity : 1;
  var cx = parseFloat(cfg.carCenterOffsetX) || 0;
  var cy = parseFloat(cfg.carCenterOffsetY) || 0;
  var grow = Math.max(0, (cScale - 1) * 60);
  var padTop = Math.round(grow + Math.max(0, -cy) + (parseFloat(cfg.carPadTop) || 0));
  var padBottom = Math.round(grow + Math.max(0, cy) + (parseFloat(cfg.carPadBottom) || 0));
  var centerSizeCss = '';
  if (cfg.carCenterWidth) centerSizeCss += 'width:' + cssLen(cfg.carCenterWidth) + '; margin-left:auto; margin-right:auto;';
  if (cfg.carCenterHeight) centerSizeCss += 'height:' + cssLen(cfg.carCenterHeight) + ';';
  var centerSel = s + ' .owl-item.center .car-item, ' + s + ' .owl-item.car-active .car-item, ' + s + ' .car-fallback .car-item.center';
  var out = [
    s + ' .owl-stage-outer { padding:' + padTop + 'px 0 ' + padBottom + 'px; }',
    s + ' .car-item { transform:translateX(var(--car-shift, 0px)) scale(' + sScale + '); opacity:' + sOpac + '; }',
    centerSel + ' { transform:translate(' + cx + 'px, ' + cy + 'px) scale(' + cScale + '); opacity:1; ' + centerSizeCss +
      (cfg.carCenterPadding ? ' padding:' + cfg.carCenterPadding + ';' : '') +
      (cfg.carCenterMargin ? ' margin:' + cfg.carCenterMargin + ';' : '') +
      (cfg.carCenterRadius ? ' border-radius:' + cfg.carCenterRadius + ';' : '') +
      (cfg.carCenterBg ? ' background:' + cfg.carCenterBg + ';' : '') +
      (cfg.carCenterBorder ? ' border:' + cfg.carCenterBorder + ';' : '') +
      (cfg.carCenterShadow ? ' box-shadow:' + cfg.carCenterShadow + ';' : '') + ' }'
  ];
  // Off-balance layout: push the whole strip right by whole slots
  var c = Math.max(1, parseInt(itemCount) || 3);
  var slot = carActiveSlotOf(cfg, c);
  if (slot !== null) out.push(s + ' .owl-stage { margin-left:' + ((slot / c) * 100).toFixed(4) + '%; }');
  else out.push(s + ' .owl-stage { margin-left:0; }');
  return out.join('\n');
}

/** Arrow + dot placement for one configuration (base or a device override) */
function carPositionRules(el, cfg) {
  var s = '.car-' + el.id;
  var num = function(v) { return parseFloat(v) || 0; };
  var inset = num(cfg.carNavInset !== undefined ? cfg.carNavInset : cfg.carNavOffset);
  var prevT = 'translate(' + num(cfg.carNavPrevX) + 'px, ' + num(cfg.carNavPrevY) + 'px)';
  var nextT = 'translate(' + num(cfg.carNavNextX) + 'px, ' + num(cfg.carNavNextY) + 'px)';
  var dotT = 'translate(' + num(cfg.carDotOffsetX) + 'px, ' + num(cfg.carDotOffsetY) + 'px)';
  var topSpace = (cfg.carDotMarginTop === '' || cfg.carDotMarginTop === undefined) ? 6 : num(cfg.carDotMarginTop);
  var out = [
    s + ' .owl-nav { position:absolute; top:calc(50% + ' + num(cfg.carNavOffsetY) + 'px); left:' + num(cfg.carNavOffsetX) +
      'px; width:100%; margin:0 !important; transform:translateY(-50%); display:flex; justify-content:space-between; pointer-events:none; box-sizing:border-box; }',
    s + ' .owl-nav button.owl-prev { margin:0 ' + inset + 'px !important; transform:' + prevT + '; }',
    s + ' .owl-nav button.owl-next { margin:0 ' + inset + 'px !important; transform:' + nextT + '; }',
    // keep the nudge when hovering, or the button would jump back to its default spot
    s + ' .owl-nav button.owl-prev:hover { transform:' + prevT + ' scale(1.12); }',
    s + ' .owl-nav button.owl-next:hover { transform:' + nextT + ' scale(1.12); }'
  ];
  if (cfg.carDotPosition === 'overlay') {
    out.push(s + ' { position:relative; }');
    out.push(s + ' .owl-dots { position:absolute; left:0; right:0; bottom:' +
      ((cfg.carDotBottom === '' || cfg.carDotBottom === undefined) ? 8 : num(cfg.carDotBottom)) +
      'px; z-index:3; margin:0 !important; transform:' + dotT + '; }');
  } else {
    if (cfg.carDotPosition === 'above') {
      out.push(s + ' .owl-dots { order:-1; }');
      out.push(s + ' .car-wrap { display:flex; flex-direction:column; }');
    }
    // !important because owl.theme.default.css has a more specific rule
    // (`.owl-theme .owl-nav.disabled + .owl-dots { margin-top:10px }`) that would
    // otherwise pin the spacing and make "Top space" do nothing.
    out.push(s + ' .owl-dots { margin-top:' + topSpace + 'px !important; position:static; transform:' + dotT + '; }');
  }
  return out.join('\n');
}

/** "160" → "160px"; leaves "160px", "50%", "auto", "" alone */
function cssLen(v, fallback) {
  if (v === undefined || v === null || v === '') return fallback;
  var str = String(v).trim();
  return /^-?\d*\.?\d+$/.test(str) ? str + 'px' : str;
}

function buildCarouselCSS(el) {
  var s = '.car-' + el.id;
  // The builder previews one device at a time and can't rely on media queries,
  // so it asks for that device's values to be baked into the base rules.
  var previewDevice = (typeof window !== 'undefined') ? window.__uiCarouselDevice : null;
  var base = previewDevice ? carMergedFor(el, previewDevice) : el;
  var per = parseInt(el.carItems) || 3;
  var gap = parseInt(el.carMargin) || 0;
  // Item box sizing
  var sizeCss = '';
  if (el.carItemWidth) sizeCss += 'width:' + cssLen(el.carItemWidth) + '; margin-left:auto; margin-right:auto;';
  if (el.carItemHeight) sizeCss += 'height:' + cssLen(el.carItemHeight) + ';';

  // Active-item rules: base first, then a block per device that overrides them
  var basePer = previewDevice
    ? (previewDevice === 'phone' ? (parseInt(el.carItemsPhone) || 1)
      : previewDevice === 'tablet' ? (parseInt(el.carItemsTablet) || per)
      : (parseInt(el.carItemsPhoneLand) || parseInt(el.carItemsTablet) || per))
    : per;
  var activeCss = [carActiveRules(el, base, basePer), carPositionRules(el, base)];
  if (!previewDevice) {
    var counts = {
      tablet: parseInt(el.carItemsTablet) || per,
      phone: parseInt(el.carItemsPhone) || 1,
      'phone-land': parseInt(el.carItemsPhoneLand) || parseInt(el.carItemsTablet) || per
    };
    Object.keys(CAR_DEVICE_QUERIES).forEach(function(dev) {
      var cfg = carMergedFor(el, dev);
      if (cfg === el && counts[dev] === per) return;   // nothing device-specific
      // (cfg !== el means this device carries at least one override)
      var rules = carActiveRules(el, cfg, counts[dev]) + '\n' + carPositionRules(el, cfg);
      CAR_DEVICE_QUERIES[dev].forEach(function(q) { activeCss.push(q + ' {\n' + rules + '\n}'); });
    });
  }
  var slotCss = activeCss.join('\n');
  return [
    s + ' { width:100%; }',
    // Owl ships `.owl-carousel { display:none }` and only reveals it once it has
    // initialised — if anything goes wrong the whole carousel vanishes. Keep it shown.
    s + ' .owl-carousel { display:block; }',
    s + ' .owl-carousel .owl-item { float:left; }',
    s + ' .owl-stage { display:flex; align-items:center; }',
    // z-index only applies to positioned elements — without this the *next* card in
    // DOM order paints over the enlarged active one.
    s + ' .owl-item { display:flex; align-items:center; position:relative; z-index:1; }',
    s + ' .owl-item.center, ' + s + ' .owl-item.car-active { z-index:' + (parseInt(el.carActiveZ) || 10) + '; }',
    s + ' .car-item { position:relative; }',
    s + ' .car-fallback .car-item.center { z-index:' + (parseInt(el.carActiveZ) || 10) + '; }',
    s + ' .car-item { background:' + (el.carItemBg || 'transparent') + '; border:' + (el.carItemBorder || 'none') +
      '; border-radius:' + (el.carItemRadius || '0') + '; padding:' + (el.carItemPadding || '0') +
      '; margin:' + (el.carItemMargin || '0') +
      '; box-shadow:' + (el.carItemShadow || 'none') + '; text-align:' + (el.carItemAlign || 'center') +
      '; overflow:hidden; box-sizing:border-box; ' + sizeCss +
      ' transition:transform 0.35s ease, opacity 0.35s ease; }',
    s + ' .car-media { position:relative; width:' + cssLen(el.carModelWidth, '100%') + '; height:' +
      cssLen(el.carModelHeight, cssLen(el.carImgHeight, '140px')) + '; margin:0 auto; }',
    // The canvas covers the media box, so adding a model never resizes the card
    s + ' .car-media .car-model { position:absolute; inset:' + (parseFloat(el.carModelInset) || 0) + 'px; }',
    s + ' .car-media .car-fg-box { position:absolute; }',
    s + ' .car-media .car-img { position:absolute; inset:0; width:100%; height:100%; }',
    s + ' .car-model { border-radius:' + (el.carImgRadius || '0') + '; overflow:hidden; z-index:1; }',
    // Full-bleed image mode: the picture covers the card, content sits above it
    s + ' .car-bgimg { position:absolute; inset:0; width:100%; height:100%; object-fit:' +
      (el.carImgFit || 'cover') + '; object-position:' + (el.carImgPosition || 'center') +
      '; border-radius:inherit; z-index:0; display:block; }',
    s + ' .car-model-cover { position:absolute; inset:' + (parseFloat(el.carModelInset) || 0) + 'px; z-index:1; }',
    // Overlay image: sits above the card image, its inset acts as a margin from the edges
    // Overlay image: the box handles margin/padding/alignment, the image its own size + fit
    s + ' .car-fg-box { position:absolute; inset:' + cssLen(el.carFgInset, '0px') + '; padding:' + cssLen(el.carFgPadding, '0px') +
      '; box-sizing:border-box; display:flex; z-index:1; pointer-events:none;' +
      ' align-items:' + (el.carFgAlignV === 'top' ? 'flex-start' : el.carFgAlignV === 'bottom' ? 'flex-end' : 'center') +
      '; justify-content:' + (el.carFgAlignH === 'left' ? 'flex-start' : el.carFgAlignH === 'right' ? 'flex-end' : 'center') + '; }',
    s + ' .car-fg { max-width:100%; max-height:100%; object-position:' + (el.carFgPosition || 'center') +
      '; border-radius:' + (el.carFgRadius || '0') + '; display:block; ' +
      (el.carFgShadow ? 'filter:drop-shadow(' + el.carFgShadow + ');' : '') + ' }',
    s + ' .car-item > .car-title, ' + s + ' .car-item > .car-text, ' + s + ' .car-item > .car-field { position:relative; z-index:2; }',
    (el.carImgMode === 'cover' ? s + ' .car-item { padding:' + (el.carItemPadding || '0') + '; display:flex; flex-direction:column; justify-content:' +
      (el.carCoverAlign === 'top' ? 'flex-start' : el.carCoverAlign === 'center' ? 'center' : 'flex-end') + '; }' : ''),
    s + ' .car-img { width:100%; height:' + cssLen(el.carImgHeight, 'auto') + '; object-fit:' + (el.carImgFit || 'cover') +
      '; border-radius:' + (el.carImgRadius || '0') + '; display:block; }',
    s + ' .car-title { font-size:' + (el.carTitleSize !== undefined ? el.carTitleSize : 0.85) + (el.carTitleUnit || 'rem') + '; color:' + (el.carTitleColor || '#fff') +
      '; font-weight:' + (el.carTitleWeight || '700') + '; margin-top:6px; line-height:1.3; }',
    s + ' .car-text { font-size:' + (el.carTextSize !== undefined ? el.carTextSize : 0.65) + (el.carTextUnit || 'rem') + '; color:' + (el.carTextColor || '#a0b0c0') +
      '; margin-top:3px; line-height:1.4; }',
    s + ' .car-field { font-size:' + (el.carFieldSize !== undefined ? el.carFieldSize : 0.6) + (el.carFieldUnit || 'rem') + '; color:' + (el.carFieldColor || '#8a93a8') +
      '; margin-top:2px; line-height:1.4; }',
    s + ' .owl-nav button.owl-prev, ' + s + ' .owl-nav button.owl-next { pointer-events:auto; color:' + (el.carNavColor || '#fff') +
      ' !important; background:' + (el.carNavBg || 'rgba(0,0,0,0.4)') + ' !important; width:' + (parseFloat(el.carNavSize) || 28) +
      'px; height:' + (parseFloat(el.carNavSize) || 28) + 'px; border-radius:' + (el.carNavRadius || '50%') +
      '; border:' + (el.carNavBorder || 'none') + ' !important; display:flex; align-items:center; justify-content:center; font-size:' +
      (el.carNavIconSize !== undefined ? el.carNavIconSize : 0.8) + 'rem !important; transition:transform 0.15s, background 0.15s; }',
    // Owl adds `disabled` to the nav/dots wrapper whenever every slide already fits on
    // screen, and its core CSS hides `.disabled` outright — which silently removes the
    // arrows and dots as soon as "Visible" reaches the number of slides. With loop on
    // they still work, so keep them shown when the user asked for them.
    (el.carNav !== false ? s + ' .owl-nav.disabled { display:flex !important; }' : ''),
    (el.carDots !== false ? s + ' .owl-dots.disabled { display:flex !important; }' : ''),
    s + ' .owl-dots { display:flex; justify-content:center; align-items:center; }',
    s + ' .owl-dots .owl-dot span { background:' + (el.carDotColor || 'rgba(255,255,255,0.25)') + ' !important; width:' +
      (parseFloat(el.carDotWidth) || 7) + 'px; height:' + (parseFloat(el.carDotHeight) || 7) + 'px; border-radius:' +
      (el.carDotRadius || '50%') + ' !important; margin:0 ' + ((parseFloat(el.carDotGap) || 6) / 2) + 'px !important; transition:all 0.2s; }',
    s + ' .owl-dots .owl-dot.active span { background:' + (el.carDotActiveColor || '#00e5a0') + ' !important;' +
      (el.carDotActiveWidth ? ' width:' + parseFloat(el.carDotActiveWidth) + 'px !important;' : '') +
      (el.carDotActiveHeight ? ' height:' + parseFloat(el.carDotActiveHeight) + 'px !important;' : '') + ' }',
    s + ' .car-fallback { display:flex; gap:' + gap + 'px; align-items:center; justify-content:center; width:100%; overflow:hidden; }',
    s + ' .car-fallback .car-item { flex:0 0 calc((100% - ' + (gap * (per - 1)) + 'px) / ' + per + '); }',
    (el.carItemClick && el.carItemClick !== 'none') ? s + ' .car-item { cursor:pointer; }' : '',
    slotCss
  ].join('\n');
}

function applyCarouselCSS(el) {
  var st = document.getElementById('car-style-' + el.id);
  if (!st) { st = document.createElement('style'); st.id = 'car-style-' + el.id; document.head.appendChild(st); }
  st.textContent = buildCarouselCSS(el);
}

function owlAvailable() {
  return typeof window.jQuery !== 'undefined' && window.jQuery.fn && typeof window.jQuery.fn.owlCarousel === 'function';
}

/** Visible item count for the current viewport — mirrors the builder's device modes.
 *  window.__uiCarouselForceItems lets the builder pin a count for its device preview. */
function carItemsForViewport(el) {
  // The builder previews devices by resizing its canvas, not the window, so it installs
  // a resolver that answers from the selected device instead of window.innerWidth.
  if (typeof window !== 'undefined' && typeof window.__uiCarouselItemsResolver === 'function') {
    try {
      var r = parseInt(window.__uiCarouselItemsResolver(el));
      if (r > 0) return r;
    } catch(e) {}
  }
  var forced = (typeof window !== 'undefined') ? window.__uiCarouselForceItems : null;
  if (forced) return Math.max(1, parseInt(forced) || 1);
  var desktop = Math.max(1, parseInt(el.carItems) || 3);
  var tablet = Math.max(1, parseInt(el.carItemsTablet) || desktop);
  var phone = Math.max(1, parseInt(el.carItemsPhone) || 1);
  var land = el.carItemsPhoneLand ? Math.max(1, parseInt(el.carItemsPhoneLand)) : tablet;
  var w = (typeof window !== 'undefined' && window.innerWidth) ? window.innerWidth : 1280;
  var h = (typeof window !== 'undefined' && window.innerHeight) ? window.innerHeight : 800;
  var landscape = w > h;
  // Phones held sideways are wide but short (844×390, 932×430…), so height matters
  // more than width for spotting them.
  if (landscape && (w <= 768 || (h <= 500 && w <= 1200))) return land;
  if (w <= 480) return phone;
  if (w <= 1024) return tablet;
  return desktop;
}

function owlOptionsFor(el, live) {
  var per = carItemsForViewport(el);
  return {
    items: per,
    margin: parseInt(el.carMargin) || 0,
    loop: el.carLoop !== false,
    center: carUsesSlotMode(el) ? false : !!el.carCenter,
    autoWidth: !!el.carAutoWidth,
    stagePadding: parseInt(el.carStagePadding) || 0,
    nav: el.carNav !== false,
    dots: el.carDots !== false,
    dotsEach: 1,          // one dot per item (Owl otherwise draws one dot per "page",
                          // which gives a single dot when every item is already visible)
    navText: ['<i class="bi ' + (el.carNavPrevIcon || 'bi-chevron-left') + '"></i>', '<i class="bi ' + (el.carNavNextIcon || 'bi-chevron-right') + '"></i>'],
    autoplay: !!(live && el.carAutoplay),
    autoplayTimeout: parseInt(el.carAutoplayTimeout) || 4000,
    autoplayHoverPause: el.carAutoplayHoverPause !== false,
    smartSpeed: parseInt(el.carSpeed) || 450,
    mouseDrag: live ? el.carMouseDrag !== false : false,
    touchDrag: live ? el.carMouseDrag !== false : false,
    pullDrag: false,
    rewind: el.carLoop === false,
    startPosition: parseInt(el.carStart) || 0,
    // One bucket only: the count is resolved by carItemsForViewport(), which understands
    // portrait/landscape. Owl's own breakpoints can't express orientation.
    responsive: { 0: { items: per } }
  };
}

/* ── Linked elements: update other UI elements when the active item changes ── */
function carouselTargetDom(id) {
  if (!id) return null;
  try { if (typeof gameUIElements !== 'undefined' && gameUIElements && gameUIElements[id]) return gameUIElements[id].dom; } catch(e) {}
  try { if (typeof viewport !== 'undefined' && viewport) return viewport.querySelector('[data-id="' + id + '"]'); } catch(e) {}
  return document.getElementById('gui-' + id);
}

function carouselFieldValue(item, field, index) {
  if (!item) return '';
  if (field === 'index') return String(index + 1);
  if (field && field.indexOf('field:') === 0) {
    var key = field.slice(6);
    return (item.fields && item.fields[key] !== undefined) ? item.fields[key] : '';
  }
  return item[field || 'title'] !== undefined ? item[field || 'title'] : '';
}

/** Tags the current .owl-item with `car-active` (Owl only adds `center` in centre mode) */
function markCarouselActive(el, wrap, $t) {
  if (!wrap) return;
  var nodes = wrap.querySelectorAll('.owl-item');
  if (!nodes.length) return;
  var target = wrap.querySelector('.owl-item.center');
  if (!target) {
    var pos = -1;
    try {
      var inst = $t && $t.data ? $t.data('owl.carousel') : null;
      if (inst) pos = inst.current();
    } catch(e) {}
    if (pos >= 0 && nodes[pos]) target = nodes[pos];
  }
  Array.prototype.forEach.call(nodes, function(n) { n.classList.remove('car-active'); });
  if (target) target.classList.add('car-active');
  applyCarouselSpread(el, wrap, nodes, target);
}

/** Pushes the side cards away from the active one and poses their 3D models */
function applyCarouselSpread(el, wrap, nodes, activeNode) {
  nodes = nodes || wrap.querySelectorAll('.owl-item');
  if (!nodes.length) return;
  var activeIdx = -1;
  Array.prototype.forEach.call(nodes, function(n, i) { if (n === activeNode) activeIdx = i; });
  var gap = parseFloat(el.carSideGap) || 0;
  var stack = el.carSideGapStacks !== false;   // 2nd neighbour moves twice as far
  Array.prototype.forEach.call(nodes, function(n, i) {
    var card = n.querySelector('.car-item');
    if (!card) return;
    var shift = 0;
    if (activeIdx >= 0 && i !== activeIdx && gap) {
      var dist = i - activeIdx;
      var steps = stack ? Math.abs(dist) : 1;
      shift = (dist < 0 ? -1 : 1) * gap * steps;
    }
    card.style.setProperty('--car-shift', shift + 'px');

    // Per-card 3D pose: the active card can sit differently from the side ones
    var host = n.querySelector('.car-model .ui-model');
    if (host && host._modelApi && host._modelApi.setPose) {
      var isActive = (i === activeIdx);
      var item = null;
      var ci = card.dataset ? parseInt(card.dataset.index) : NaN;
      if (!isNaN(ci)) item = (el.carouselItems || [])[ci];
      var pose = {
        rotX: pickNum(isActive ? el.carModelActiveRotX : el.carModelSideRotX, item && item.modelRotX, el.carModelRotX, 0),
        rotY: pickNum(isActive ? el.carModelActiveRotY : el.carModelSideRotY, item && item.modelRotY, el.carModelRotY, 0),
        rotZ: pickNum(isActive ? el.carModelActiveRotZ : el.carModelSideRotZ, item && item.modelRotZ, el.carModelRotZ, 0),
        scale: pickNum(isActive ? el.carModelActiveScale : el.carModelSideScale, item && item.modelScale, el.carModelScale, 1),
        distance: pickNum(isActive ? el.carModelActiveDistance : el.carModelSideDistance, item && item.modelDistance, el.carModelDistance, 4)
      };
      host._modelApi.setPose(pose);
      var spins = carItemSpins(el, item, false);
      host._modelApi.setSpin(el.carModelSpinActiveOnly ? (isActive && spins) : spins);
    }
  });
}

/** Whether an item's model should spin — per item, falling back to the carousel */
function carItemSpins(el, item, atCreation) {
  var v = item ? item.modelAutoRotate : undefined;
  var on = (v === undefined || v === null || v === '') ? (el.carModelAutoRotate !== false) : (v === true || v === 'true');
  // "spin active only" is applied per card once we know which one is active
  if (atCreation && el.carModelSpinActiveOnly) return false;
  return on;
}

/** First value that is actually set */
function pickNum() {
  for (var i = 0; i < arguments.length; i++) {
    var v = arguments[i];
    if (v !== undefined && v !== null && v !== '' && !isNaN(parseFloat(v))) return parseFloat(v);
  }
  return 0;
}

function carouselActiveIndex(wrap) {
  if (!wrap) return 0;
  var node = wrap.querySelector('.owl-item.center .car-item')
    || wrap.querySelector('.owl-item.car-active .car-item')
    || wrap.querySelector('.owl-item.active .car-item')
    || wrap.querySelector('.car-item.center')
    || wrap.querySelector('.car-item');
  if (!node) return 0;
  var i = parseInt(node.dataset.index);
  return isNaN(i) ? 0 : i;
}

/** Base (designed) visibility of another element, so per-item show/hide can be reset */
function carouselTargetBaseVisible(id) {
  try { if (typeof gameUIElements !== 'undefined' && gameUIElements && gameUIElements[id]) return gameUIElements[id].data.visible !== false; } catch(e) {}
  try {
    if (typeof elements !== 'undefined' && elements && elements.find) {
      var m = elements.find(function(o) { return o.id === id; });
      if (m) return m.visible !== false;
    }
  } catch(e) {}
  return true;
}

function carouselSetTargetVisible(dom, show) {
  if (!dom) return;
  if (dom.classList.contains('gui-el')) {
    dom.classList.toggle('gui-hidden', !show);
    if (show) dom.style.removeProperty('display');
  } else {
    dom.style.display = show ? '' : 'none';
    dom.classList.toggle('el-hidden', !show);
  }
}

/** Applies one action (shared by carousel links and per-item triggers) */
function applyCarouselAction(dom, action, value) {
  if (!dom) return;
  var inner = dom.querySelector('.gui-inner') || dom.querySelector('.ui-el-inner') || dom.querySelector('.el-inner') || dom.firstElementChild || dom;
  switch (action || 'setText') {
    case 'setText': inner.textContent = value; break;
    case 'setHTML': inner.innerHTML = value; break;
    case 'setImage': {
      var img = inner.querySelector('img');
      if (img) img.src = value;
      else {
        inner.style.backgroundImage = value ? 'url(' + value + ')' : 'none';
        inner.style.backgroundSize = inner.style.backgroundSize || 'cover';
        inner.style.backgroundPosition = inner.style.backgroundPosition || 'center';
      }
      break;
    }
    case 'setBackground':
      inner.style.backgroundImage = value ? 'url(' + value + ')' : 'none';
      inner.style.backgroundSize = 'cover';
      inner.style.backgroundPosition = 'center';
      break;
    case 'setVideo': {
      var vid = inner.querySelector('video');
      if (vid && value) { vid.src = value; vid.load(); }
      break;
    }
    case 'setColor': inner.style.color = value; break;
    case 'show': carouselSetTargetVisible(dom, true); break;
    case 'hide': carouselSetTargetVisible(dom, false); break;
  }
}

/** Every element touched by any item's show/hide actions — reset before each slide */
function carouselManagedVisibility(el) {
  var ids = {};
  (el.carouselItems || []).forEach(function(it) {
    (it.actions || []).forEach(function(a) {
      if (a && a.targetId && (a.action === 'show' || a.action === 'hide')) ids[a.targetId] = true;
    });
  });
  return Object.keys(ids);
}

function applyCarouselLinks(el, index) {
  var items = el.carouselItems || [];
  if (!items.length) return;
  if (index < 0 || index >= items.length) index = 0;
  var item = items[index];
  // 1. Reset anything a slide controls, so show/hide is per-slide rather than sticky
  if (el.carResetVisibility !== false) {
    carouselManagedVisibility(el).forEach(function(id) {
      carouselSetTargetVisible(carouselTargetDom(id), carouselTargetBaseVisible(id));
    });
  }

  // 2. Carousel-wide links (same mapping for every slide)
  (el.carLinks || []).forEach(function(link) {
    if (!link || !link.targetId) return;
    applyCarouselAction(carouselTargetDom(link.targetId), link.action, carouselFieldValue(item, link.field, index));
  });

  // 3. This slide's own triggers
  (item.actions || []).forEach(function(a) {
    if (!a || !a.targetId) return;
    var val = a.value;
    if (a.useField) val = carouselFieldValue(item, a.useField, index);
    applyCarouselAction(carouselTargetDom(a.targetId), a.action, val);
  });
  if (el.carChangeFunc && typeof window[el.carChangeFunc] === 'function') {
    try { window[el.carChangeFunc](item, index, el); } catch(err) { console.warn('[Carousel] change function error:', err); }
  }
}

/** Current real item index — asks Owl first (clone-safe), then falls back to the DOM */
function carouselCurrentIndex(el, wrap, $t) {
  var n = (el.carouselItems || []).length;
  if (n) {
    try {
      var inst = $t && $t.data ? $t.data('owl.carousel') : null;
      if (inst && typeof inst.relative === 'function') {
        var rel = inst.relative(inst.current());
        if (!isNaN(rel)) return ((rel % n) + n) % n;
      }
    } catch(e) {}
  }
  return carouselActiveIndex(wrap);
}

function syncCarouselLinks(el, wrap, $t) {
  applyCarouselLinks(el, carouselCurrentIndex(el, wrap, $t));
}

function renderUICarousel(container, el, live) {
  var items = el.carouselItems || [];
  if (typeof disposeUIModel === 'function') {
    Array.prototype.forEach.call(container.querySelectorAll('.car-model'), function(m) { disposeUIModel(m); });
  }
  container.innerHTML = '';
  applyCarouselCSS(el);
  if (items.length === 0) return;

  var wrap = document.createElement('div');
  wrap.className = 'car-wrap car-' + el.id;
  wrap.style.cssText = 'width:100%;';

  var ready = owlAvailable();
  var track = document.createElement('div');
  track.className = ready ? 'owl-carousel owl-theme' : 'car-fallback';

  items.forEach(function(it, idx) {
    var box = document.createElement('div');
    box.className = 'car-item';
    box.dataset.index = idx;
    box.innerHTML = carouselItemInnerHTML(el, it);
    if (!ready) {
      var per = parseInt(el.carItems) || 3;
      var slotIdx = carUsesSlotMode(el) ? carActiveSlotOf(el, per) : (el.carCenter ? Math.floor(Math.min(items.length, per) / 2) : -1);
      if (slotIdx >= 0 && idx === slotIdx) box.classList.add('center');
    }
    var modelBox = box.querySelector('.car-model');
    if (modelBox && typeof renderUIModel === 'function') {
      renderUIModel(modelBox, {
        id: el.id + '_m' + idx,
        src: it.model,
        borderRadius: el.carImgRadius,
        modelBg: it.modelBg || el.carModelBg || 'transparent',
        modelStubBg: (el.carShowImage !== false && it.image) ? 'transparent' : null,
        modelAutoRotate: carItemSpins(el, it, true),
        modelRotateSpeed: el.carModelRotateSpeed,
        modelControls: el.carModelControls,
        modelDragRotate: el.carModelDragRotate,
        modelZoom: el.carModelZoom,
        modelPan: el.carModelPan,
        modelDragSpeed: el.carModelDragSpeed,
        modelZoomSpeed: el.carModelZoomSpeed,
        modelMinDistance: el.carModelMinDistance,
        modelMaxDistance: el.carModelMaxDistance,
        modelMinPolar: el.carModelMinPolar,
        modelMaxPolar: el.carModelMaxPolar,
        modelDamping: el.carModelDamping,
        modelStopSpinOnInteract: el.carModelStopSpinOnInteract,
        modelResumeSpin: el.carModelResumeSpin,
        modelDistance: it.modelDistance || el.carModelDistance,
        modelFov: el.carModelFov,
        modelLighting: el.carModelLighting,
        modelLightIntensity: el.carModelLightIntensity,
        modelScale: it.modelScale !== undefined ? it.modelScale : el.carModelScale,
        modelRotX: it.modelRotX !== undefined ? it.modelRotX : el.carModelRotX,
        modelRotY: it.modelRotY !== undefined ? it.modelRotY : el.carModelRotY,
        modelRotZ: it.modelRotZ !== undefined ? it.modelRotZ : el.carModelRotZ,
        modelY: it.modelY !== undefined ? it.modelY : el.carModelY,
        modelX: it.modelX !== undefined ? it.modelX : el.carModelX,
        modelZ: it.modelZ !== undefined ? it.modelZ : el.carModelZ,
        modelInset: it.modelInset,
        onModelReady: function() { markCarouselActive(el, wrap, $track); },
        modelCamHeight: el.carModelCamHeight,
        modelAutoFit: true
      }, live);
    }
    if (live && el.carItemClick && el.carItemClick !== 'none') {
      box.addEventListener('click', function() {
        if (el.carItemClick === 'link' && it.link) {
          var t = el.carLinkTarget || 'newTab';
          if (t === 'same') window.location.href = it.link;
          else if (t === 'iframe' && typeof showIframeOverlay === 'function') showIframeOverlay(it.link);
          else window.open(it.link, '_blank');
        } else if (el.carItemClick === 'function' && el.carItemFunc && typeof window[el.carItemFunc] === 'function') {
          try { window[el.carItemFunc](it, idx, el); } catch(err) { console.warn('[Carousel]', err); }
        }
      });
    }
    track.appendChild(box);
  });

  wrap.appendChild(track);
  container.appendChild(wrap);

  var $track = null;
  if (ready) {
    try {
      var $t = window.jQuery(track);
      $track = $t;
      $t.on('initialized.owl.carousel changed.owl.carousel translated.owl.carousel', function(e) {
        // Prefer Owl's own index (clone-safe), fall back to reading the DOM
        var idx = null;
        try {
          if (e && e.item && e.relatedTarget && typeof e.relatedTarget.relative === 'function') {
            idx = e.relatedTarget.relative(e.item.index);
          }
        } catch(err) {}
        markCarouselActive(el, wrap, $t);
        var n = items.length;
        if (idx !== null && !isNaN(idx) && n) applyCarouselLinks(el, ((idx % n) + n) % n);
        else syncCarouselLinks(el, wrap, $t);
        // Re-read once the slide has settled
        window.setTimeout(function() {
          markCarouselActive(el, wrap, $t);
          syncCarouselLinks(el, wrap, $t);
        }, (parseInt(el.carSpeed) || 450) + 40);
      });
      // Clicking a side item brings it to the centre (feels natural with center mode)
      if ((el.carCenter || carUsesSlotMode(el)) && live && el.carClickToCenter !== false) {
        $t.on('click', '.owl-item:not(.center):not(.car-active) .car-item', function(e) {
          if (el.carModelControls && e.target && e.target.closest && e.target.closest('.car-model')) return;
          var i = parseInt(this.dataset.index);
          if (!isNaN(i)) $t.trigger('to.owl.carousel', [i, parseInt(el.carSpeed) || 450]);
        });
      }
      $t.owlCarousel(owlOptionsFor(el, live));
    } catch(err) {
      console.warn('[Carousel] init failed:', err);
      track.classList.add('owl-loaded');     // show the cards rather than nothing
    }
  }
  window.setTimeout(function() {
    markCarouselActive(el, wrap, $track);
    syncCarouselLinks(el, wrap, $track);
  }, 30);

  // Keep Owl in step with size / orientation changes. Owl caches item widths at init,
  // so without this a resized (or rotated) carousel shows the wrong number of items.
  if (ready && $track) {
    var lastPer = owlOptionsFor(el, live).items;
    var reflowTimer = null;
    var reflow = function() {
      window.clearTimeout(reflowTimer);
      reflowTimer = window.setTimeout(function() {
        if (!document.body.contains(track)) return; // element was re-rendered
        var per = carItemsForViewport(el);
        if (per !== lastPer) {
          lastPer = per;
          try {
            $track.trigger('destroy.owl.carousel');
          } catch(e) { console.warn('[Carousel] destroy failed:', e); }
          try {
            $track.addClass('owl-carousel owl-theme');
            $track.owlCarousel(owlOptionsFor(el, live));
          } catch(e) {
            console.warn('[Carousel] re-init failed:', e);
            $track.addClass('owl-loaded');   // fall back to a plain visible row
          }
        } else {
          try { $track.trigger('refresh.owl.carousel'); } catch(e) {}
        }
        markCarouselActive(el, wrap, $track);
        syncCarouselLinks(el, wrap, $track);
      }, 120);
    };
    window.addEventListener('resize', reflow);
    window.addEventListener('orientationchange', reflow);
    if (typeof ResizeObserver !== 'undefined') {
      try { new ResizeObserver(reflow).observe(wrap); } catch(e) {}
    }
  }
}

function controlUICarousel(dom, cmd, index) {
  if (!owlAvailable() || !dom) return;
  var $t = window.jQuery(dom).find('.owl-carousel');
  if (!$t.length) return;
  switch (cmd || 'next') {
    case 'next': $t.trigger('next.owl.carousel'); break;
    case 'prev': $t.trigger('prev.owl.carousel'); break;
    case 'goTo': $t.trigger('to.owl.carousel', [parseInt(index) || 0, 300]); break;
    case 'play': $t.trigger('play.owl.autoplay'); break;
    case 'stop': $t.trigger('stop.owl.autoplay'); break;
    case 'refresh': $t.trigger('refresh.owl.carousel'); break;
  }
}

/* ── Video ── */
/* ── Design stage: reproduce the builder's canvas exactly ───────────────────
   The builder lays out on a fixed canvas (960×540 on desktop) and scales it to
   fit. Positions are percentages, but font sizes, image heights, paddings and
   radii are px/rem — so rendering the same UI into a raw browser window of a
   different size changes every one of those proportions. Runtimes therefore
   size the overlay to the design resolution and scale it, exactly like the
   builder does. */
var UI_DESIGN_SIZES = {
  desktop: { w: 960, h: 540 },
  tablet: { w: 512, h: 683 },
  phone: { w: 320, h: 568 },
  'phone-land': { w: 568, h: 320 }
};

/** Anchor → translate offsets (the element's own position is a % of the stage) */
function uiAnchorTranslate(anchor) {
  switch (anchor || 'center') {
    case 'top-left': return { tx: '0', ty: '0' };
    case 'top-center': return { tx: '-50%', ty: '0' };
    case 'top-right': return { tx: '-100%', ty: '0' };
    case 'center-left': return { tx: '0', ty: '-50%' };
    case 'center-right': return { tx: '-100%', ty: '-50%' };
    case 'bottom-left': return { tx: '0', ty: '-100%' };
    case 'bottom-center': return { tx: '-50%', ty: '-100%' };
    case 'bottom-right': return { tx: '-100%', ty: '-100%' };
    default: return { tx: '-50%', ty: '-50%' };
  }
}

/** The transform an element carries: anchor offset plus a hover/active scale hook */
function uiAnchorTransform(anchor) {
  var a = uiAnchorTranslate(anchor);
  return 'translate(' + a.tx + ', ' + a.ty + ') scale(var(--gui-scale, 1))';
}

function uiDeviceBucket() {
  var w = (typeof window !== 'undefined' && window.innerWidth) ? window.innerWidth : 1280;
  var h = (typeof window !== 'undefined' && window.innerHeight) ? window.innerHeight : 800;
  var landscape = w > h;
  if (landscape && (w <= 768 || (h <= 500 && w <= 1200))) return 'phone-land';
  if (w <= 480) return 'phone';
  if (w <= 1024) return 'tablet';
  return 'desktop';
}

function uiDesignSize(uiData) {
  var sizes = (uiData && uiData.designSizes) || UI_DESIGN_SIZES;
  return sizes[uiDeviceBucket()] || sizes.desktop || UI_DESIGN_SIZES.desktop;
}

function applyUIStage(uiData, overlayId) {
  var overlay = document.getElementById(overlayId || 'game-ui-overlay');
  if (!overlay) return;
  var mode = (uiData && uiData.scaleMode) || 'fit';

  if (mode === 'stretch') {   // legacy: fill the window, px sizes unscaled
    overlay.style.width = '100%';
    overlay.style.height = '100%';
    overlay.style.left = '0';
    overlay.style.top = '0';
    overlay.style.transform = '';
    overlay.style.transformOrigin = '';
    return;
  }

  var size = uiDesignSize(uiData);
  var w = window.innerWidth, h = window.innerHeight;
  var scale = mode === 'fill'
    ? Math.max(w / size.w, h / size.h)
    : Math.min(w / size.w, h / size.h);

  overlay.style.width = size.w + 'px';
  overlay.style.height = size.h + 'px';
  overlay.style.left = '50%';
  overlay.style.top = '50%';
  overlay.style.transformOrigin = 'center center';
  overlay.style.transform = 'translate(-50%, -50%) scale(' + scale + ')';
}

/** Re-apply on resize / rotate; safe to call repeatedly. */
function watchUIStage(getUIData, overlayId) {
  if (window.__uiStageWatch) window.removeEventListener('resize', window.__uiStageWatch);
  var timer = null;
  window.__uiStageWatch = function() {
    window.clearTimeout(timer);
    timer = window.setTimeout(function() { applyUIStage(getUIData(), overlayId); }, 60);
  };
  window.addEventListener('resize', window.__uiStageWatch);
  window.addEventListener('orientationchange', window.__uiStageWatch);
}

/* ── 3D model viewer (Three.js + GLB) ──────────────────────────────────────
   Loads Three.js on demand the first time a model element is rendered, so a
   project without one pays nothing. */
var UI_THREE_VERSION = '0.160.0';
var UI_THREE_CDN = 'https://cdn.jsdelivr.net/npm/three@' + UI_THREE_VERSION;
var UI_THREE_ESM = 'https://esm.sh/three@' + UI_THREE_VERSION;
var _uiThreePromise = null;
var _uiThreeError = '';

/**
 * Three's add-ons (GLTFLoader, OrbitControls) are published as ES modules that
 * `import ... from 'three'` — a bare specifier the browser can only resolve through
 * an import map. Loading them straight from a CDN therefore fails with
 * "Failed to resolve module specifier". So: make sure an import map exists first,
 * and keep a self-resolving CDN as a backup.
 */
function ensureThreeImportMap() {
  if (typeof document === 'undefined') return false;
  var existing = document.querySelector('script[type="importmap"]');
  if (existing) {
    // A host page (game-level) already maps "three" — use whatever it points at
    return true;
  }
  try {
    var map = document.createElement('script');
    map.type = 'importmap';
    map.textContent = JSON.stringify({
      imports: {
        'three': UI_THREE_CDN + '/build/three.module.js',
        'three/': UI_THREE_CDN + '/',
        'three/addons/': UI_THREE_CDN + '/examples/jsm/'
      }
    });
    document.head.appendChild(map);
    return true;
  } catch(e) {
    return false;
  }
}

function loadUIThree() {
  if (typeof window !== 'undefined' && window.__uiThreeStub) return Promise.resolve(window.__uiThreeStub);
  if (_uiThreePromise) return _uiThreePromise;

  var viaImportMap = function() {
    ensureThreeImportMap();
    return Promise.all([
      import(/* webpackIgnore: true */ 'three'),
      import(/* webpackIgnore: true */ 'three/addons/loaders/GLTFLoader.js'),
      import(/* webpackIgnore: true */ 'three/addons/controls/OrbitControls.js')
    ]);
  };
  // esm.sh rewrites the add-ons' internal "three" imports, so it needs no map
  var viaEsmSh = function() {
    return Promise.all([
      import(/* webpackIgnore: true */ UI_THREE_ESM),
      import(/* webpackIgnore: true */ UI_THREE_ESM + '/examples/jsm/loaders/GLTFLoader.js'),
      import(/* webpackIgnore: true */ UI_THREE_ESM + '/examples/jsm/controls/OrbitControls.js')
    ]);
  };

  _uiThreePromise = viaImportMap()
    .catch(function(err) {
      console.warn('[Model3D] import-map load failed (' + (err && err.message) + '), retrying via esm.sh');
      return viaEsmSh();
    })
    .then(function(m) {
      return { THREE: m[0], GLTFLoader: m[1].GLTFLoader, OrbitControls: m[2].OrbitControls };
    })
    .catch(function(err) {
      _uiThreeError = (err && err.message) || 'unknown error';
      console.warn('[Model3D] Three.js could not be loaded:', _uiThreeError,
        '\nIf you are offline, host three.module.js yourself and add an import map to the page.');
      return null;
    });
  return _uiThreePromise;
}

function disposeUIModel(container) {
  if (!container) return;
  var host = container.querySelector('.ui-model');
  if (host && host._modelCleanup) { try { host._modelCleanup(); } catch(e) {} }
}

function modelPlaceholder(el, message, spinner) {
  return '<div class="ui-model-stub" style="width:100%;height:100%;min-height:60px;display:flex;flex-direction:column;' +
    'gap:6px;align-items:center;justify-content:center;background:' +
    (el.modelStubBg ? el.modelStubBg : (el.modelBg && el.modelBg !== 'transparent' ? el.modelBg : 'rgba(0,0,0,0.3)')) +
    ';border-radius:' + (el.borderRadius || '8px') + ';color:#c9d2e0;text-align:center;padding:8px;box-sizing:border-box;">' +
    '<i class="bi ' + (spinner ? 'bi-arrow-repeat' : 'bi-box') + '" style="font-size:1.5rem;opacity:0.65;' +
    (spinner ? 'animation:uiModelSpin 1.1s linear infinite;' : '') + '"></i>' +
    '<span style="font-size:0.5rem;opacity:0.75;">' + message + '</span></div>';
}

function renderUIModel(container, el, live) {
  disposeUIModel(container);
  container.innerHTML = '';

  if (!document.getElementById('ui-model-spin-css')) {
    var st = document.createElement('style');
    st.id = 'ui-model-spin-css';
    st.textContent = '@keyframes uiModelSpin { to { transform:rotate(360deg); } }';
    document.head.appendChild(st);
  }

  if (!el.src) {
    container.innerHTML = modelPlaceholder(el, 'Set a .glb / .gltf path', false);
    return null;
  }

  var host = document.createElement('div');
  host.className = 'ui-model';
  host.style.cssText = 'position:relative;width:100%;height:100%;min-height:40px;overflow:hidden;border-radius:' +
    (el.borderRadius || '0') + ';' + (el.modelBg && el.modelBg !== 'transparent' ? 'background:' + el.modelBg + ';' : '');
  var loading = document.createElement('div');
  loading.className = 'ui-model-loading';
  loading.style.cssText = 'position:absolute;inset:0;';
  loading.innerHTML = modelPlaceholder(el, 'Loading 3D model…', true);
  host.appendChild(loading);
  container.appendChild(host);

  var cancelled = false;
  host._modelEl = el;
  host._modelCleanup = function() { cancelled = true; };

  loadUIThree().then(function(libs) {
    if (cancelled || !host.isConnected) return;
    if (!libs || !libs.THREE) {
      host.innerHTML = modelPlaceholder(el, '3D viewer unavailable' + (_uiThreeError ? ' — ' + _uiThreeError : ''), false);
      return;
    }
    var THREE = libs.THREE;
    var w = host.clientWidth || 300, h = host.clientHeight || 200;

    var renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true, preserveDrawingBuffer: false });
    renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
    renderer.setSize(w, h, false);
    renderer.domElement.style.cssText = 'width:100%;height:100%;display:block;';
    if (renderer.outputColorSpace !== undefined && THREE.SRGBColorSpace) renderer.outputColorSpace = THREE.SRGBColorSpace;
    if (el.modelShadows && renderer.shadowMap) { renderer.shadowMap.enabled = true; renderer.shadowMap.type = THREE.PCFSoftShadowMap; }

    var scene = new THREE.Scene();
    if (el.modelBg && el.modelBg !== 'transparent') {
      try { scene.background = new THREE.Color(el.modelBg); } catch(e) {}
    } else {
      renderer.setClearColor(0x000000, 0);
    }

    var camera = new THREE.PerspectiveCamera(parseFloat(el.modelFov) || 45, w / h, 0.01, 2000);

    // Lighting presets
    var preset = el.modelLighting || 'studio';
    var intensity = el.modelLightIntensity !== undefined ? parseFloat(el.modelLightIntensity) : 1;
    var amb = new THREE.AmbientLight(0xffffff, (preset === 'dramatic' ? 0.25 : preset === 'soft' ? 0.9 : 0.55) * intensity);
    scene.add(amb);
    var key = new THREE.DirectionalLight(0xffffff, (preset === 'dramatic' ? 2.2 : preset === 'soft' ? 0.8 : 1.4) * intensity);
    key.position.set(3, 5, 4);
    if (el.modelShadows) { key.castShadow = true; }
    scene.add(key);
    if (preset !== 'dramatic') {
      var fill = new THREE.DirectionalLight(0xffffff, 0.6 * intensity);
      fill.position.set(-4, 2, -3);
      scene.add(fill);
    }
    if (el.modelHemi !== false && THREE.HemisphereLight) {
      scene.add(new THREE.HemisphereLight(0xffffff, 0x223344, 0.4 * intensity));
    }

    var root = new THREE.Group();
    scene.add(root);

    var controls = null;
    if (el.modelControls && libs.OrbitControls) {
      controls = new libs.OrbitControls(camera, renderer.domElement);
      controls.enableDamping = el.modelDamping !== false;
      controls.dampingFactor = parseFloat(el.modelDampingFactor) || 0.08;
      controls.enableRotate = el.modelDragRotate !== false;
      controls.enableZoom = el.modelZoom !== false;
      controls.enablePan = !!el.modelPan;
      controls.rotateSpeed = parseFloat(el.modelDragSpeed) || 1;
      controls.zoomSpeed = parseFloat(el.modelZoomSpeed) || 1;
      controls.autoRotate = !!(live && el.modelAutoRotate);
      controls.autoRotateSpeed = parseFloat(el.modelRotateSpeed) || 2;
      if (el.modelMinDistance !== undefined && el.modelMinDistance !== '') controls.minDistance = parseFloat(el.modelMinDistance);
      if (el.modelMaxDistance !== undefined && el.modelMaxDistance !== '') controls.maxDistance = parseFloat(el.modelMaxDistance);
      if (el.modelMinPolar !== undefined && el.modelMinPolar !== '') controls.minPolarAngle = parseFloat(el.modelMinPolar) * Math.PI / 180;
      if (el.modelMaxPolar !== undefined && el.modelMaxPolar !== '') controls.maxPolarAngle = parseFloat(el.modelMaxPolar) * Math.PI / 180;

      // Touch gestures: one finger turns, two fingers zoom (and pan when allowed)
      if (THREE.TOUCH) {
        controls.touches = {
          ONE: el.modelDragRotate === false ? THREE.TOUCH.PAN : THREE.TOUCH.ROTATE,
          TWO: el.modelPan ? THREE.TOUCH.DOLLY_PAN : THREE.TOUCH.DOLLY_ROTATE
        };
      }
      renderer.domElement.style.touchAction = 'none';
      renderer.domElement.style.cursor = 'grab';

      // Don't let a carousel (or any parent) steal the gesture
      ['pointerdown', 'mousedown', 'touchstart', 'wheel', 'click'].forEach(function(evt) {
        renderer.domElement.addEventListener(evt, function(e) { e.stopPropagation(); }, { passive: evt === 'wheel' ? false : true });
      });

      // Spinning pauses while the user is dragging, and can come back afterwards
      controls.addEventListener('start', function() {
        renderer.domElement.style.cursor = 'grabbing';
        if (el.modelStopSpinOnInteract !== false) {
          controls.autoRotate = false;
          if (host._modelState) host._modelState.spinning = false;
        }
        if (host._resumeTimer) { window.clearTimeout(host._resumeTimer); host._resumeTimer = null; }
      });
      controls.addEventListener('end', function() {
        renderer.domElement.style.cursor = 'grab';
        var delay = parseFloat(el.modelResumeSpin);
        if (el.modelStopSpinOnInteract !== false && el.modelAutoRotate && delay > 0) {
          host._resumeTimer = window.setTimeout(function() {
            controls.autoRotate = true;
            if (host._modelState) host._modelState.spinning = true;
          }, delay);
        }
      });
    }

    function frameModel(obj) {
      var box = new THREE.Box3().setFromObject(obj);
      if (!isFinite(box.min.x)) return;
      var size = box.getSize(new THREE.Vector3());
      var center = box.getCenter(new THREE.Vector3());
      var maxDim = Math.max(size.x, size.y, size.z) || 1;
      if (el.modelAutoFit !== false) {
        obj.position.sub(center);                       // sit on the origin
        var target = 1.6;
        obj.scale.multiplyScalar(target / maxDim);
      }
      var dist = (parseFloat(el.modelDistance) || 4);
      camera.position.set(0, parseFloat(el.modelCamHeight) || 0.4, dist);
      camera.lookAt(0, parseFloat(el.modelTargetY) || 0, 0);
      if (controls) { controls.target.set(0, parseFloat(el.modelTargetY) || 0, 0); controls.update(); }
    }

    function applyTransform(obj) {
      obj.position.x += parseFloat(el.modelX) || 0;
      obj.position.y += parseFloat(el.modelY) || 0;
      obj.position.z += parseFloat(el.modelZ) || 0;
      var sc = parseFloat(el.modelScale);
      if (!sc && sc !== 0) sc = 1;
      obj.scale.multiplyScalar(sc);
      obj.rotation.set(
        (parseFloat(el.modelRotX) || 0) * Math.PI / 180,
        (parseFloat(el.modelRotY) || 0) * Math.PI / 180,
        (parseFloat(el.modelRotZ) || 0) * Math.PI / 180
      );
    }

    var mixer = null, clock = new THREE.Clock();
    var loader = new libs.GLTFLoader();
    loader.load(el.src, function(gltf) {
      if (cancelled || !host.isConnected) return;
      var obj = gltf.scene || gltf.scenes[0];
      if (el.modelShadows) {
        obj.traverse(function(n) { if (n.isMesh) { n.castShadow = true; n.receiveShadow = true; } });
      }
      frameModel(obj);
      host._modelBaseScale = obj.scale.x;   // scale after auto-fit, before user scale
      applyTransform(obj);
      root.add(obj);
      host._modelObject = obj;
      if (gltf.animations && gltf.animations.length && el.modelPlayClips !== false) {
        mixer = new THREE.AnimationMixer(obj);
        gltf.animations.forEach(function(clip) { mixer.clipAction(clip).play(); });
      }
      if (loading.parentNode) loading.remove();
      // Let the host (e.g. a carousel) re-apply its pose now the model exists
      if (typeof el.onModelReady === 'function') {
        try { el.onModelReady(host); } catch(e) { console.warn('[Model3D] onModelReady:', e); }
      }
    }, undefined, function(err) {
      var isFile = (location.protocol === 'file:');
      console.warn('[Model3D] failed to load', el.src, err && err.message,
        isFile ? '\nThe page was opened from disk (file://) — browsers refuse to fetch .glb files that way. ' +
                 'Tick "Embed 3D models" when exporting, or serve the folder over http.' : '');
      host.innerHTML = modelPlaceholder(el,
        isFile ? 'Cannot load models from file:// — embed them on export or serve over http'
               : 'Could not load ' + el.src, false);
    });

    host.appendChild(renderer.domElement);

    // Keep the drawing buffer in step with the element
    var ro = null;
    function resize() {
      var nw = host.clientWidth || w, nh = host.clientHeight || h;
      if (!nw || !nh) return;
      camera.aspect = nw / nh;
      camera.updateProjectionMatrix();
      renderer.setSize(nw, nh, false);
    }
    if (typeof ResizeObserver !== 'undefined') {
      try { ro = new ResizeObserver(resize); ro.observe(host); } catch(e) {}
    }
    window.addEventListener('resize', resize);

    var raf = null, spinning = !!(live && el.modelAutoRotate && !controls);
    host._modelState = { spinning: spinning };
    function tick() {
      if (cancelled || !host.isConnected) return;
      raf = requestAnimationFrame(tick);
      var dt = clock.getDelta();
      if (mixer) mixer.update(dt);
      if (host._modelState.spinning && host._modelObject) {
        host._modelObject.rotation.y += (parseFloat(el.modelRotateSpeed) || 2) * 0.25 * dt;
      }
      if (controls) controls.update();
      renderer.render(scene, camera);
    }
    tick();

    host._modelCleanup = function() {
      cancelled = true;
      if (raf) cancelAnimationFrame(raf);
      window.removeEventListener('resize', resize);
      if (ro) { try { ro.disconnect(); } catch(e) {} }
      if (controls) { try { controls.dispose(); } catch(e) {} }
      try {
        scene.traverse(function(n) {
          if (n.geometry) n.geometry.dispose();
          if (n.material) {
            var mats = Array.isArray(n.material) ? n.material : [n.material];
            mats.forEach(function(m) {
              Object.keys(m).forEach(function(k) { if (m[k] && m[k].isTexture) m[k].dispose(); });
              m.dispose();
            });
          }
        });
        renderer.dispose();
      } catch(e) {}
    };
    host._modelApi = {
      renderer: renderer, scene: scene, camera: camera, root: root, controls: controls,
      setSpin: function(on) {
        host._modelState.spinning = !!on && !controls;
        if (controls) controls.autoRotate = !!on;
      },
      setPose: function(pose) {
        if (!pose) return;
        var obj = host._modelObject;
        if (obj) {
          if (pose.rotX !== undefined || pose.rotY !== undefined || pose.rotZ !== undefined) {
            obj.rotation.set(
              (pose.rotX || 0) * Math.PI / 180,
              (pose.rotY || 0) * Math.PI / 180,
              (pose.rotZ || 0) * Math.PI / 180
            );
          }
          if (pose.scale !== undefined && host._modelBaseScale) {
            var sc = host._modelBaseScale * pose.scale;
            obj.scale.set(sc, sc, sc);
          }
        }
        if (pose.distance !== undefined && camera) {
          camera.position.z = pose.distance;
          if (controls) controls.update();
        }
      },
      controlsFor: function() { return controls; },
      reset: function() {
        if (host._modelObject) {
          host._modelObject.rotation.set(
            (parseFloat(el.modelRotX) || 0) * Math.PI / 180,
            (parseFloat(el.modelRotY) || 0) * Math.PI / 180,
            (parseFloat(el.modelRotZ) || 0) * Math.PI / 180
          );
        }
        if (controls) controls.reset();
      }
    };
  });

  return host;
}

function controlUIModel(dom, cmd, value) {
  var host = dom ? dom.querySelector('.ui-model') : null;
  if (!host) return;
  var api = host._modelApi;
  switch (cmd || 'toggleSpin') {
    case 'spin': if (api) api.setSpin(true); break;
    case 'stop': if (api) api.setSpin(false); break;
    case 'toggleSpin': if (api) api.setSpin(!(host._modelState && host._modelState.spinning)); break;
    case 'reset': if (api) api.reset(); break;
    case 'setModel':
      if (value && host.parentNode) {
        var el = host._modelEl || {};
        var next = {};
        for (var k in el) next[k] = el[k];
        next.src = value;
        renderUIModel(host.parentNode, next, true);
      }
      break;
  }
}

/* ── Iframe / embed element ── */
function uiEmbedURL(src) {
  var u = String(src || '').trim();
  if (!u) return '';
  // Friendly conversions so a pasted share link just works
  var yt = u.match(/(?:youtube\.com\/(?:watch\?v=|live\/|shorts\/)|youtu\.be\/)([\w-]{6,})/i);
  if (yt) return 'https://www.youtube.com/embed/' + yt[1];
  var vim = u.match(/vimeo\.com\/(?:video\/)?(\d+)/i);
  if (vim) return 'https://player.vimeo.com/video/' + vim[1];
  var gmap = u.match(/google\.[a-z.]+\/maps\/place\/([^/?]+)/i);
  if (gmap && u.indexOf('/embed') === -1) {
    return 'https://www.google.com/maps?q=' + encodeURIComponent(decodeURIComponent(gmap[1])) + '&output=embed';
  }
  return u;
}

function buildUIFrame(el) {
  var frame = document.createElement('iframe');
  frame.className = 'ui-frame';
  frame.src = uiEmbedURL(el.src);
  frame.style.cssText = 'width:100%;height:100%;display:block;border:' + (el.frameBorder || 'none') +
    ';border-radius:' + (el.borderRadius || '0') + ';background:' + (el.frameBg || 'transparent') + ';';
  if (el.frameTitle) frame.title = el.frameTitle;
  frame.setAttribute('allow', el.frameAllow || 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture');
  if (el.frameAllowFullscreen !== false) frame.setAttribute('allowfullscreen', '');
  if (el.frameLazy) frame.setAttribute('loading', 'lazy');
  if (el.frameSandbox) frame.setAttribute('sandbox', el.frameSandboxRules || 'allow-scripts allow-same-origin allow-popups allow-forms');
  if (el.frameScroll === false) frame.setAttribute('scrolling', 'no');
  return frame;
}

function renderUIFrame(container, el, live) {
  container.innerHTML = '';
  if (!el.src) {
    container.innerHTML = '<div style="width:100%;height:100%;min-height:60px;display:flex;flex-direction:column;gap:4px;' +
      'align-items:center;justify-content:center;background:rgba(0,0,0,0.25);border:1px dashed rgba(255,255,255,0.2);' +
      'border-radius:' + (el.borderRadius || '8px') + ';"><i class="bi bi-window" style="font-size:1.5rem;opacity:0.5;"></i>' +
      '<span style="font-size:0.5rem;opacity:0.6;">Set a page URL</span></div>';
    return null;
  }
  // While designing, a placeholder avoids loading (and autoplaying) the real page
  if (!live && el.framePreviewOnly !== false) {
    var box = document.createElement('div');
    box.className = 'ui-frame-stub';
    box.style.cssText = 'width:100%;height:100%;display:flex;flex-direction:column;gap:6px;align-items:center;justify-content:center;' +
      'background:' + (el.frameBg || 'rgba(0,0,0,0.35)') + ';border:' + (el.frameBorder || '1px solid rgba(255,255,255,0.12)') +
      ';border-radius:' + (el.borderRadius || '8px') + ';color:#cfd6e4;text-align:center;padding:8px;box-sizing:border-box;overflow:hidden;';
    box.innerHTML = '<i class="bi bi-window-stack" style="font-size:1.4rem;opacity:0.7;"></i>' +
      '<div style="font-size:0.5rem;font-weight:700;letter-spacing:1px;">EMBEDDED PAGE</div>' +
      '<div style="font-size:0.45rem;opacity:0.7;word-break:break-all;max-width:90%;">' + escAttr(uiEmbedURL(el.src)) + '</div>' +
      '<div style="font-size:0.4rem;opacity:0.5;">loads in Preview and in the player</div>';
    container.appendChild(box);
    return null;
  }
  var frame = buildUIFrame(el);
  container.appendChild(frame);
  return frame;
}

function controlUIFrame(dom, cmd, value) {
  var frame = dom ? dom.querySelector('iframe') : null;
  if (!frame) return;
  switch (cmd || 'reload') {
    case 'reload': frame.src = frame.src; break;
    case 'setSrc': if (value) frame.src = uiEmbedURL(value); break;
    case 'blank': frame.src = 'about:blank'; break;
  }
}

/* ── Hero section (full-bleed backgrounds that cross-fade) ── */
function heroSlidesOf(el) {
  var list = el.heroSlides || [];
  return list.length ? list : [{ id: 'hs_empty', image: '', title: '', text: '' }];
}

function buildHeroCSS(el) {
  var s = '.hero-' + el.id;
  var dur = parseInt(el.heroTransDuration);
  if (!dur && dur !== 0) dur = 900;
  var trans = el.heroTransition || 'fade';
  var pad = el.heroPadding || '6%';
  var alignH = el.heroAlignH || 'center';
  var alignV = el.heroAlignV || 'center';
  var justify = alignV === 'top' ? 'flex-start' : alignV === 'bottom' ? 'flex-end' : 'center';
  var items = alignH === 'left' ? 'flex-start' : alignH === 'right' ? 'flex-end' : 'center';
  var textAlign = alignH === 'left' ? 'left' : alignH === 'right' ? 'right' : 'center';

  // How an inactive slide sits before/after it becomes the active one
  var restState, activeState;
  if (trans === 'slide-left') { restState = 'transform:translateX(100%);'; activeState = 'transform:translateX(0);'; }
  else if (trans === 'slide-right') { restState = 'transform:translateX(-100%);'; activeState = 'transform:translateX(0);'; }
  else if (trans === 'slide-up') { restState = 'transform:translateY(100%);'; activeState = 'transform:translateY(0);'; }
  else if (trans === 'zoom') { restState = 'opacity:0; transform:scale(1.15);'; activeState = 'opacity:1; transform:scale(1);'; }
  else { restState = 'opacity:0;'; activeState = 'opacity:1;'; }

  var out = [
    s + ' { position:relative; width:100%; height:100%; overflow:hidden; }',
    s + ' .hero-bgs { position:absolute; inset:0; overflow:hidden; }',
    s + ' .hero-bg { position:absolute; inset:0; background-size:' + (el.heroBgFit || 'cover') +
      '; background-position:' + (el.heroBgPosition || 'center') + '; background-repeat:no-repeat; ' +
      restState + ' transition:opacity ' + dur + 'ms ease, transform ' + dur + 'ms ease; }',
    s + ' .hero-bg.active { ' + activeState + ' z-index:1; }',
    s + ' .hero-bg video { width:100%; height:100%; object-fit:' + (el.heroBgFit || 'cover') + '; display:block; }',
    s + ' .hero-overlay { position:absolute; inset:0; z-index:2; pointer-events:none; background:' +
      (el.heroOverlay || 'none') + '; opacity:' + (el.heroOverlayOpacity !== undefined ? el.heroOverlayOpacity : 1) + '; }',
    s + ' .hero-content { position:absolute; inset:0; z-index:3; display:flex; flex-direction:column; box-sizing:border-box;' +
      ' justify-content:' + justify + '; align-items:' + items + '; text-align:' + textAlign +
      '; padding:' + pad + '; gap:' + (el.heroGap || '10px') + '; pointer-events:none; }',
    s + ' .hero-content > * { pointer-events:auto; max-width:' + (el.heroMaxWidth || '100%') + '; }',
    s + ' .hero-title { font-family:' + (el.heroTitleFont || el.fontFamily || "'Orbitron',sans-serif") +
      '; font-size:' + (el.heroTitleSize !== undefined ? el.heroTitleSize : 2.4) + (el.heroTitleUnit || 'rem') +
      '; font-weight:' + (el.heroTitleWeight || '700') + '; color:' + (el.heroTitleColor || '#ffffff') +
      '; line-height:' + (el.heroTitleLineHeight || '1.1') + '; letter-spacing:' + (el.heroTitleSpacing || 'normal') +
      '; text-shadow:' + (el.heroTitleShadow || 'none') + '; margin:0; }',
    s + ' .hero-sub { font-family:' + (el.heroSubFont || el.fontFamily || "'Rajdhani',sans-serif") +
      '; font-size:' + (el.heroSubSize !== undefined ? el.heroSubSize : 1) + (el.heroSubUnit || 'rem') +
      '; font-weight:' + (el.heroSubWeight || '400') + '; color:' + (el.heroSubColor || '#d0d8e4') +
      '; line-height:' + (el.heroSubLineHeight || '1.5') + '; text-shadow:' + (el.heroSubShadow || 'none') + '; margin:0; }',
    s + ' .hero-children { position:absolute; inset:0; z-index:4; pointer-events:none; }',
    s + ' .hero-dots { position:absolute; z-index:5; left:0; right:0; bottom:' + (parseFloat(el.heroDotBottom) || 18) +
      'px; display:flex; justify-content:center; align-items:center; gap:' + (parseFloat(el.heroDotGap) || 8) + 'px; }',
    s + ' .hero-dot { width:' + (parseFloat(el.heroDotWidth) || 10) + 'px; height:' + (parseFloat(el.heroDotHeight) || 10) +
      'px; border-radius:' + (el.heroDotRadius || '50%') + '; background:' + (el.heroDotColor || 'rgba(255,255,255,0.35)') +
      '; cursor:pointer; transition:all 0.25s; border:none; padding:0; }',
    s + ' .hero-dot.active { background:' + (el.heroDotActiveColor || '#ffffff') + ';' +
      (el.heroDotActiveWidth ? ' width:' + parseFloat(el.heroDotActiveWidth) + 'px;' : '') + ' }',
    s + ' .hero-arrow { position:absolute; z-index:5; top:50%; transform:translateY(-50%); width:' +
      (parseFloat(el.heroArrowSize) || 36) + 'px; height:' + (parseFloat(el.heroArrowSize) || 36) +
      'px; border-radius:' + (el.heroArrowRadius || '50%') + '; background:' + (el.heroArrowBg || 'rgba(0,0,0,0.35)') +
      '; color:' + (el.heroArrowColor || '#fff') + '; border:none; cursor:pointer; display:flex; align-items:center; justify-content:center; font-size:' +
      (el.heroArrowIconSize !== undefined ? el.heroArrowIconSize : 1) + 'rem; }',
    s + ' .hero-arrow.prev { left:' + (parseFloat(el.heroArrowInset) || 12) + 'px; }',
    s + ' .hero-arrow.next { right:' + (parseFloat(el.heroArrowInset) || 12) + 'px; }'
  ];
  if (el.heroKenBurns) {
    out.push('@keyframes heroKen-' + el.id + ' { from { transform:scale(1); } to { transform:scale(' +
      (el.heroKenScale || 1.12) + '); } }');
    out.push(s + ' .hero-bg.active { animation:heroKen-' + el.id + ' ' +
      ((parseInt(el.heroInterval) || 6000) + dur) + 'ms ease-out forwards; }');
  }
  return out.join('\n');
}

function applyHeroCSS(el) {
  var st = document.getElementById('hero-style-' + el.id);
  if (!st) { st = document.createElement('style'); st.id = 'hero-style-' + el.id; document.head.appendChild(st); }
  st.textContent = buildHeroCSS(el);
}

function heroShowSlide(wrap, el, index, live) {
  var slides = heroSlidesOf(el);
  var n = slides.length;
  if (!n) return;
  index = ((index % n) + n) % n;
  wrap._heroIndex = index;
  var bgs = wrap.querySelectorAll('.hero-bg');
  Array.prototype.forEach.call(bgs, function(b, i) { b.classList.toggle('active', i === index); });
  var dots = wrap.querySelectorAll('.hero-dot');
  Array.prototype.forEach.call(dots, function(d, i) { d.classList.toggle('active', i === index); });

  // Per-slide headings fall back to the hero's own text
  var slide = slides[index] || {};
  var t = wrap.querySelector('.hero-title');
  var sub = wrap.querySelector('.hero-sub');
  if (t) {
    var title = slide.title !== undefined && slide.title !== '' ? slide.title : (el.heroTitle || '');
    t.innerHTML = title;
    t.style.display = title ? '' : 'none';
  }
  if (sub) {
    var text = slide.text !== undefined && slide.text !== '' ? slide.text : (el.heroSubtitle || '');
    sub.innerHTML = text;
    sub.style.display = text ? '' : 'none';
  }
  // Videos only play while their slide is on screen
  Array.prototype.forEach.call(bgs, function(b, i) {
    var v = b.querySelector('video');
    if (!v) return;
    if (i === index && live) { v.play().catch(function(){}); }
    else { v.pause(); }
  });
  if (live && el.heroChangeFunc && typeof window[el.heroChangeFunc] === 'function') {
    try { window[el.heroChangeFunc](slide, index, el); } catch(e) { console.warn('[Hero]', e); }
  }
}

function heroStopAuto(wrap) {
  if (wrap && wrap._heroTimer) { window.clearInterval(wrap._heroTimer); wrap._heroTimer = null; }
}

function heroStartAuto(wrap, el, live) {
  heroStopAuto(wrap);
  if (!live || !el.heroAutoplay) return;
  var slides = heroSlidesOf(el);
  if (slides.length < 2) return;
  wrap._heroTimer = window.setInterval(function() {
    if (!document.body.contains(wrap)) { heroStopAuto(wrap); return; }
    heroShowSlide(wrap, el, (wrap._heroIndex || 0) + 1, live);
  }, Math.max(600, parseInt(el.heroInterval) || 6000));
}

function renderUIHero(container, el, live) {
  var prev = container.querySelector('.hero-wrap');
  if (prev) heroStopAuto(prev);
  container.innerHTML = '';
  applyHeroCSS(el);

  var wrap = document.createElement('div');
  wrap.className = 'hero-wrap hero-' + el.id;

  var slides = heroSlidesOf(el);
  var bgs = document.createElement('div');
  bgs.className = 'hero-bgs';
  slides.forEach(function(sl, i) {
    var b = document.createElement('div');
    b.className = 'hero-bg';
    if (sl.video) {
      var v = document.createElement('video');
      v.src = sl.video; v.muted = true; v.loop = true; v.playsInline = true; v.preload = 'auto';
      b.appendChild(v);
    } else if (sl.image) {
      b.style.backgroundImage = 'url(' + sl.image + ')';
    } else {
      b.style.background = sl.color || (el.heroEmptyBg || 'linear-gradient(135deg,#1b2030,#0b0e17)');
    }
    bgs.appendChild(b);
  });
  wrap.appendChild(bgs);

  var ov = document.createElement('div');
  ov.className = 'hero-overlay';
  wrap.appendChild(ov);

  var content = document.createElement('div');
  content.className = 'hero-content';
  var title = document.createElement('div');
  title.className = 'hero-title';
  var sub = document.createElement('div');
  sub.className = 'hero-sub';
  content.appendChild(title);
  content.appendChild(sub);
  wrap.appendChild(content);

  // Anything parented onto the hero lands here
  var kids = document.createElement('div');
  kids.className = 'hero-children gui-children ui-el-children';
  wrap.appendChild(kids);

  if (el.heroArrows && slides.length > 1) {
    ['prev', 'next'].forEach(function(dir) {
      var b = document.createElement('button');
      b.className = 'hero-arrow ' + dir;
      b.innerHTML = '<i class="bi ' + (dir === 'prev'
        ? (el.heroPrevIcon || 'bi-chevron-left')
        : (el.heroNextIcon || 'bi-chevron-right')) + '"></i>';
      b.addEventListener('click', function(e) {
        e.stopPropagation();
        heroShowSlide(wrap, el, (wrap._heroIndex || 0) + (dir === 'next' ? 1 : -1), live);
        heroStartAuto(wrap, el, live);
      });
      wrap.appendChild(b);
    });
  }

  if (el.heroDots && slides.length > 1) {
    var dotBox = document.createElement('div');
    dotBox.className = 'hero-dots';
    slides.forEach(function(sl, i) {
      var dt = document.createElement('button');
      dt.className = 'hero-dot';
      dt.addEventListener('click', function(e) {
        e.stopPropagation();
        heroShowSlide(wrap, el, i, live);
        heroStartAuto(wrap, el, live);
      });
      dotBox.appendChild(dt);
    });
    wrap.appendChild(dotBox);
  }

  wrap._heroEl = el;
  wrap._heroLive = live;
  container.appendChild(wrap);
  heroShowSlide(wrap, el, parseInt(el.heroStart) || 0, live);
  heroStartAuto(wrap, el, live);
  return wrap;
}

function controlUIHero(dom, cmd, index) {
  var wrap = dom ? dom.querySelector('.hero-wrap') : null;
  if (!wrap) return;
  var el = wrap._heroEl;
  if (!el) return;
  switch (cmd || 'next') {
    case 'next': heroShowSlide(wrap, el, (wrap._heroIndex || 0) + 1, true); break;
    case 'prev': heroShowSlide(wrap, el, (wrap._heroIndex || 0) - 1, true); break;
    case 'goTo': heroShowSlide(wrap, el, parseInt(index) || 0, true); break;
    case 'play': heroStartAuto(wrap, el, true); break;
    case 'stop': heroStopAuto(wrap); break;
  }
}

/* ── Video chroma key (colour → transparency) ── */
function uiHexToRgb(hex) {
  var h = String(hex || '#00ff00').trim();
  if (h.charAt(0) === '#') h = h.slice(1);
  if (h.length === 3) h = h.charAt(0) + h.charAt(0) + h.charAt(1) + h.charAt(1) + h.charAt(2) + h.charAt(2);
  var n = parseInt(h, 16);
  if (isNaN(n)) return { r: 0, g: 255, b: 0 };
  return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 };
}

function stopUIVideoChroma(container) {
  if (!container) return;
  var old = container.querySelector('canvas.ui-chroma');
  if (old) { old._stop = true; old.remove(); }
}

/**
 * Replaces the <video> with a canvas whose keyed colour is made transparent.
 * Falls back to the plain video if the source taints the canvas (cross-origin).
 */
function setupUIVideoChroma(container, vid, el) {
  stopUIVideoChroma(container);
  if (!container || !vid) return;
  vid.style.removeProperty('display');
  if (!el.chromaKey) return;

  var canvas = document.createElement('canvas');
  canvas.className = 'ui-chroma';
  canvas.style.cssText = vid.style.cssText;
  canvas.style.background = 'transparent';
  var ctx = null;
  try { ctx = canvas.getContext('2d', { willReadFrequently: true }); } catch(e) {}
  if (!ctx) return;

  vid.style.display = 'none';
  container.appendChild(canvas);

  var key = uiHexToRgb(el.chromaColor || '#00ff00');
  var sim = el.chromaSimilarity !== undefined ? parseFloat(el.chromaSimilarity) : 0.4;
  var smooth = el.chromaSmoothness !== undefined ? parseFloat(el.chromaSmoothness) : 0.08;
  var spill = el.chromaSpill !== undefined ? parseFloat(el.chromaSpill) : 0;
  var lastTime = -1, lastReady = -1;

  function draw() {
    if (canvas._stop || !canvas.isConnected) return;
    var w = vid.videoWidth, h = vid.videoHeight;
    // Only re-key when there is a new frame (a paused video stays cheap)
    if (w && h && (vid.currentTime !== lastTime || vid.readyState !== lastReady)) {
      lastTime = vid.currentTime; lastReady = vid.readyState;
      if (canvas.width !== w || canvas.height !== h) { canvas.width = w; canvas.height = h; }
      try {
        ctx.clearRect(0, 0, w, h);
        ctx.drawImage(vid, 0, 0, w, h);
        var img = ctx.getImageData(0, 0, w, h), d = img.data;
        var range = Math.max(0.0001, smooth);
        for (var i = 0; i < d.length; i += 4) {
          var dr = d[i] - key.r, dg = d[i + 1] - key.g, db = d[i + 2] - key.b;
          var dist = Math.sqrt(dr * dr + dg * dg + db * db) / 441.6729;
          if (dist < sim) {
            d[i + 3] = 0;
          } else if (dist < sim + range) {
            var a = (dist - sim) / range;
            d[i + 3] = d[i + 3] * a;
            if (spill > 0) {   // pull the key colour out of the soft edge
              d[i] = d[i] + (d[i] - key.r) * spill * (1 - a);
              d[i + 1] = d[i + 1] + (d[i + 1] - key.g) * spill * (1 - a);
              d[i + 2] = d[i + 2] + (d[i + 2] - key.b) * spill * (1 - a);
            }
          }
        }
        ctx.putImageData(img, 0, 0);
      } catch(err) {
        // Tainted canvas: the browser refuses to read frames from a cross-origin or
        // file:// video. Try once with CORS, then fall back to the untouched video.
        if (!vid._chromaRetried && /^https?:/i.test(vid.src)) {
          vid._chromaRetried = true;
          var wasTime = vid.currentTime, wasPlaying = !vid.paused;
          vid.crossOrigin = 'anonymous';
          vid.load();
          vid.addEventListener('loadeddata', function once() {
            vid.removeEventListener('loadeddata', once);
            try { vid.currentTime = wasTime; } catch(e) {}
            if (wasPlaying) vid.play().catch(function(){});
          });
          lastTime = -1; lastReady = -1;
          if (typeof requestAnimationFrame === 'function') requestAnimationFrame(draw);
          return;
        }
        console.warn('[Video] chroma key unavailable for this source (' + (err && err.message) + '). ' +
          'Frames cannot be read from a file:// or cross-origin video — embed the video in the export, ' +
          'or serve it from the same origin with CORS enabled.');
        canvas._stop = true;
        canvas.remove();
        vid.style.removeProperty('display');
        return;
      }
    }
    if (typeof requestAnimationFrame === 'function') requestAnimationFrame(draw);
    else window.setTimeout(draw, 40);
  }
  if (typeof requestAnimationFrame === 'function') requestAnimationFrame(draw);
  else window.setTimeout(draw, 40);
}

function buildUIVideo(el) {
  const vid = document.createElement('video');
  vid.className = 'gui-video';
  vid.src = el.src;
  vid.playsInline = true;
  vid.preload = 'auto';
  if (el.videoPoster) vid.poster = el.videoPoster;
  vid.muted = el.muted !== false;
  vid.volume = Math.max(0, Math.min(1, el.volume !== undefined ? el.volume : 1));
  vid.loop = el.loopMode === 'loop';
  if (el.controls) vid.controls = true;
  const vfit = el.videoFit || 'contain';
  let vcss = 'display:block;border-radius:' + (el.borderRadius || 0) + ';';
  if (vfit === 'fit-width') vcss += 'width:100%;height:auto;';
  else if (vfit === 'fit-height') vcss += 'height:100%;width:auto;';
  else vcss += 'width:100%;height:100%;object-fit:' + (vfit === 'stretch' ? 'fill' : vfit) + ';';
  vid.style.cssText = vcss;
  if (el.loopMode === 'count') {
    let plays = 1;
    vid.addEventListener('ended', () => {
      if (plays < (parseInt(el.loopCount) || 1)) { plays++; vid.currentTime = 0; vid.play().catch(() => {}); }
    });
  }
  if ((el.playMode || 'auto') === 'auto') {
    vid.autoplay = true;
    const p = vid.play(); if (p && p.catch) p.catch(() => {});
  } else if (el.playMode === 'click' && !el.controls) {
    vid.style.cursor = 'pointer';
    vid.addEventListener('click', () => { if (vid.paused) vid.play().catch(() => {}); else vid.pause(); });
  }
  return vid;
}

function controlUIVideo(dom, cmd) {
  const vid = dom ? dom.querySelector('video') : null;
  if (!vid) return;
  switch (cmd || 'play') {
    case 'play': vid.play().catch(() => {}); break;
    case 'pause': vid.pause(); break;
    case 'toggle': vid.paused ? vid.play().catch(() => {}) : vid.pause(); break;
    case 'stop': vid.pause(); try { vid.currentTime = 0; } catch(e) {} break;
    case 'restart': try { vid.currentTime = 0; } catch(e) {} vid.play().catch(() => {}); break;
  }
}

/* ── Element factory ── */
function createGameUIElement(el) {
  const dom = document.createElement('div');
  dom.className = 'gui-el';
  dom.id = 'gui-' + el.id;

  const anchor = el.anchor || 'center';

  dom.style.left = el.x + '%';
  dom.style.top = el.y + '%';
  dom.style.transform = uiAnchorTransform(anchor);
  dom.style.width = el.w > 0 ? el.w + '%' : 'auto';
  dom.style.height = el.h > 0 ? el.h + '%' : 'auto';
  dom.style.zIndex = el.zIndex || 10;
  dom.style.opacity = el.opacity !== undefined ? el.opacity : 1;
  if (!el.visible) dom.classList.add('gui-hidden');

  const inner = document.createElement('div');
  inner.className = 'gui-inner';
  inner.style.width = '100%'; inner.style.height = '100%';

  const styleProps = ['fontFamily','fontSize','fontWeight','color','background','borderRadius',
    'padding','border','textAlign','textShadow','boxShadow','letterSpacing','lineHeight',
    'textTransform','backdropFilter','objectFit','aspectRatio','minWidth','minHeight',
    'cursor','display','justifyContent','alignItems','borderTop','overflow'];
  styleProps.forEach(prop => {
    if (el[prop] && el[prop] !== '0' && el[prop] !== '' && el[prop] !== 'none' && el[prop] !== 'normal') {
      if (prop === 'fontSize') inner.style[prop] = el[prop] + (el.fontUnit || 'rem');
      else inner.style[prop] = el[prop];
    }
  });
  inner.style.overflow = (el.type === 'panel' || el.type === 'carousel') ? 'visible' : 'hidden';
  if (el.type === 'hero') inner.style.position = 'relative';
  if (el.type === 'panel') inner.style.position = 'relative';

  // Custom CSS
  if (el.customCSS) {
    try {
      el.customCSS.split(';').filter(p => p.includes(':')).forEach(p => {
        const [prop, ...rest] = p.split(':');
        const cssProp = prop.trim();
        const val = rest.join(':').trim();
        if (cssProp && val) {
          const camel = cssProp.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
          inner.style[camel] = val;
        }
      });
    } catch(e) {}
  }

  // Animation sequence (entrance → attention → exit); legacy animateClass is treated as an entrance
  if (uiAnimHasSequence(el) && el.animTrigger !== 'manual') {
    window.setTimeout(function() { runUIAnimSequence(inner, el, 'all'); }, 0);
  }

  // ── Type-specific content ──
  if (el.type === 'text' || el.type === 'button') {
    inner.textContent = resolveBindingText(el.content, el.binding);
    if (el.type === 'button') inner.style.cursor = el.cursor || 'pointer';
  } else if (el.type === 'image') {
    if (el.src && el.objectFit === 'background') {
      inner.style.backgroundImage = 'url(' + el.src + ')';
      inner.style.backgroundSize = el.bgSize || 'cover';
      inner.style.backgroundPosition = el.bgPosition || 'center';
      inner.style.backgroundRepeat = el.bgRepeat || 'no-repeat';
    } else if (el.src) {
      const img = document.createElement('img');
      img.src = el.src;
      img.style.cssText = 'width:100%;height:100%;object-fit:' + (el.objectFit || 'contain') + ';border-radius:' + (el.borderRadius || 0) + ';display:block;';
      img.draggable = false;
      inner.appendChild(img);
    }
  } else if (el.type === 'icon') {
    const i = document.createElement('i');
    i.className = 'bi ' + (el.iconClass || 'bi-circle');
    i.style.fontSize = (el.fontSize || 2) + (el.fontUnit || 'rem');
    i.style.color = el.color || '#ffffff';
    if (el.textShadow) i.style.textShadow = el.textShadow;
    inner.appendChild(i);
  } else if (el.type === 'video') {
    if (el.src) {
      const vid = buildUIVideo(el);
      if (el.videoFit === 'fit-width' || el.videoFit === 'fit-height') {
        inner.style.display = 'flex'; inner.style.alignItems = 'center'; inner.style.justifyContent = 'center';
      }
      if (el.videoBlend && el.videoBlend !== 'none') vid.style.mixBlendMode = el.videoBlend;
      inner.appendChild(vid);
      if (el.chromaKey) setupUIVideoChroma(inner, vid, el);
    }
  } else if (el.type === 'carousel') {
    inner.style.display = 'flex'; inner.style.alignItems = 'center'; inner.style.overflow = 'visible';
    renderUICarousel(inner, el, true);
  } else if (el.type === 'hero') {
    inner.style.overflow = 'hidden'; inner.style.position = 'relative';
    renderUIHero(inner, el, true);
  } else if (el.type === 'iframe') {
    renderUIFrame(inner, el, true);
  } else if (el.type === 'model3d') {
    inner.style.overflow = 'hidden';
    renderUIModel(inner, el, true);
  } else if (el.type === 'progressbar') {
    const pct = Math.max(0, Math.min(100, el.barValue || 0));
    const track = document.createElement('div');
    track.className = 'gui-progressbar-track';
    track.style.height = el.barHeight || '12px';
    track.style.background = el.barBgColor || 'rgba(255,255,255,0.08)';
    track.style.borderRadius = el.borderRadius || '6px';
    if (el.showLabel && el.labelText) {
      const label = document.createElement('span');
      label.className = 'gui-progressbar-label';
      label.textContent = el.labelText;
      track.appendChild(label);
    }
    const fill = document.createElement('div');
    fill.className = 'gui-progressbar-fill';
    fill.style.width = pct + '%';
    fill.style.background = el.barColor || '#00e5a0';
    fill.style.borderRadius = el.borderRadius || '6px';
    fill.dataset.binding = el.binding || '';
    track.appendChild(fill);
    inner.appendChild(track);
  } else if (el.type === 'panel') {
    const childBox = document.createElement('div');
    childBox.className = 'gui-children';
    childBox.style.cssText = 'position:absolute;inset:0;pointer-events:none;';
    inner.appendChild(childBox);

    // ── D-Pad ──
    if (el.controlMode === 'dpad') {
      const cBg = el.controlBg || 'rgba(255,255,255,0.08)';
      const cArrow = el.controlKnob || 'rgba(255,255,255,0.7)';
      const cActive = el.controlKnob || 'rgba(0,229,160,0.7)';
      const dpad = document.createElement('div');
      dpad.className = 'gui-dpad';
      dpad.style.cssText = 'position:absolute;inset:5%;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:2px;user-select:none;touch-action:none;z-index:5;';
      const btnStyle = 'display:flex;align-items:center;justify-content:center;width:36%;aspect-ratio:1;border-radius:8px;background:' + cBg + ';border:1px solid rgba(255,255,255,0.15);color:' + cArrow + ';font-size:1.2rem;cursor:pointer;user-select:none;-webkit-user-select:none;touch-action:none;transition:background 0.1s;';
      const midRow = document.createElement('div');
      midRow.style.cssText = 'display:flex;gap:2px;width:100%;justify-content:center;';
      function makeDir(key, icon, parent) {
        const btn = document.createElement('div');
        btn.style.cssText = btnStyle;
        btn.innerHTML = '<i class="bi ' + icon + '"></i>';
        const press = () => { window.playerKeys[key] = true; btn.style.background = cActive; };
        const release = () => { window.playerKeys[key] = false; btn.style.background = cBg; };
        btn.addEventListener('pointerdown', e => { e.preventDefault(); e.stopPropagation(); press(); });
        btn.addEventListener('pointerup', e => { e.stopPropagation(); release(); });
        btn.addEventListener('pointerleave', release);
        btn.addEventListener('pointercancel', release);
        parent.appendChild(btn);
      }
      makeDir('w', 'bi-caret-up-fill', dpad);
      makeDir('a', 'bi-caret-left-fill', midRow);
      makeDir('d', 'bi-caret-right-fill', midRow);
      dpad.appendChild(midRow);
      makeDir('s', 'bi-caret-down-fill', dpad);
      inner.appendChild(dpad);
    }

    // ── Joystick ──
    if (el.controlMode === 'joystick') {
      const cBg = el.controlBg || 'rgba(255,255,255,0.04)';
      const cKnob = el.controlKnob || 'rgba(0,229,160,0.3)';
      const cKnobActive = el.controlKnob || 'rgba(0,229,160,0.5)';
      inner.style.position = 'relative'; inner.style.touchAction = 'none'; inner.style.userSelect = 'none';
      const base = document.createElement('div');
      base.style.cssText = 'position:absolute;inset:10%;border-radius:50%;background:' + cBg + ';border:1px solid rgba(255,255,255,0.1);';
      const knob = document.createElement('div');
      knob.style.cssText = 'position:absolute;width:40%;height:40%;border-radius:50%;background:' + cKnob + ';border:2px solid ' + cKnobActive + ';left:50%;top:50%;transform:translate(-50%,-50%);cursor:grab;touch-action:none;transition:background 0.1s;';
      base.appendChild(knob); inner.appendChild(base);
      let dragging = false, baseRect = null;
      const deadzone = 0.2;
      knob.addEventListener('pointerdown', e => { e.preventDefault(); e.stopPropagation(); dragging = true; baseRect = base.getBoundingClientRect(); knob.setPointerCapture(e.pointerId); knob.style.background = cKnobActive; });
      const onMove = e => {
        if (!dragging || !baseRect) return;
        const cx = baseRect.left + baseRect.width/2, cy = baseRect.top + baseRect.height/2, maxR = baseRect.width/2;
        let dx = (e.clientX - cx)/maxR, dy = (e.clientY - cy)/maxR;
        const dist = Math.sqrt(dx*dx + dy*dy);
        if (dist > 1) { dx /= dist; dy /= dist; }
        knob.style.left = (50 + dx*40) + '%'; knob.style.top = (50 + dy*40) + '%';
        window.playerKeys.w = dy < -deadzone; window.playerKeys.s = dy > deadzone;
        window.playerKeys.a = dx < -deadzone; window.playerKeys.d = dx > deadzone;
        window.playerKeys.shift = dist > 0.85;
      };
      const onRelease = () => {
        if (!dragging) return;
        dragging = false;
        knob.style.left = '50%'; knob.style.top = '50%'; knob.style.background = cKnob;
        window.playerKeys.w = window.playerKeys.s = window.playerKeys.a = window.playerKeys.d = window.playerKeys.shift = false;
      };
      window.addEventListener('pointermove', onMove);
      window.addEventListener('pointerup', onRelease);
      window.addEventListener('pointercancel', onRelease);
    }
  }

  dom.appendChild(inner);
  dom.style.pointerEvents = 'auto';

  // ── Event-based action chains ──
  const evts = el.events || {};
  if (!el.events && el.actions) evts.click = el.actions;         // legacy actions[]
  if (el.onClick && (!evts.click || evts.click.length === 0)) {  // legacy onClick
    evts.click = [{ type:'callFunction', funcName: el.onClick, delay: 0 }];
  }
  const hasClick = evts.click && evts.click.length > 0;
  const hasDbl = evts.dblclick && evts.dblclick.length > 0;
  const hasDown = evts.mousedown && evts.mousedown.length > 0;
  const hasUp = evts.mouseup && evts.mouseup.length > 0;
  const hasHoverEvt = evts.hover && evts.hover.length > 0;
  if (hasClick || hasDbl || hasDown || hasUp || hasHoverEvt) inner.style.cursor = inner.style.cursor || 'pointer';
  if (hasClick) dom.addEventListener('click', () => executeUIActions(evts.click, el, evts.clickMode));
  if (hasDbl) dom.addEventListener('dblclick', () => executeUIActions(evts.dblclick, el, evts.dblclickMode));
  if (hasDown) dom.addEventListener('mousedown', () => executeUIActions(evts.mousedown, el, evts.mousedownMode));
  if (hasUp) dom.addEventListener('mouseup', () => executeUIActions(evts.mouseup, el, evts.mouseupMode));
  if (hasHoverEvt) dom.addEventListener('mouseenter', () => executeUIActions(evts.hover, el, evts.hoverMode));

  // ── Hover / Active interaction states ──
  const origBg = el.background || '';
  const origColor = el.color || '';
  const hasHover = el.hoverBackground || el.hoverColor || el.hoverScale || el.hoverSrc || el.hoverBoxShadow;
  const hasActive = el.activeBackground || el.activeColor || el.activeScale || el.activeSrc || el.activeBoxShadow;
  const defaultBtnHover = (el.type === 'button' && !hasHover);
  inner.style.transition = 'all 0.15s ease';
  inner.addEventListener('mouseenter', () => {
    if (hasHover) {
      if (el.hoverBackground) inner.style.background = el.hoverBackground;
      if (el.hoverColor) inner.style.color = el.hoverColor;
      if (el.hoverScale) dom.style.setProperty('--gui-scale', el.hoverScale);
      if (el.hoverBoxShadow) inner.style.boxShadow = el.hoverBoxShadow;
      if (el.hoverSrc && el.type === 'image') { const img = inner.querySelector('img'); if (img) img.src = el.hoverSrc; }
    } else if (defaultBtnHover) inner.style.filter = 'brightness(1.15)';
  });
  inner.addEventListener('mouseleave', () => {
    if (hasHover) {
      inner.style.background = origBg;
      inner.style.color = origColor;
      inner.style.boxShadow = el.boxShadow || '';
      dom.style.removeProperty('--gui-scale');
      if (el.hoverSrc && el.type === 'image') { const img = inner.querySelector('img'); if (img) img.src = el.src; }
    } else if (defaultBtnHover) inner.style.filter = '';
  });
  if (hasActive) {
    inner.addEventListener('mousedown', () => {
      if (el.activeBackground) inner.style.background = el.activeBackground;
      if (el.activeColor) inner.style.color = el.activeColor;
      if (el.activeScale) dom.style.setProperty('--gui-scale', el.activeScale);
      if (el.activeBoxShadow) inner.style.boxShadow = el.activeBoxShadow;
      if (el.activeSrc && el.type === 'image') { const img = inner.querySelector('img'); if (img) img.src = el.activeSrc; }
    });
    inner.addEventListener('mouseup', () => {
      inner.style.background = hasHover && el.hoverBackground ? el.hoverBackground : origBg;
      inner.style.color = hasHover && el.hoverColor ? el.hoverColor : origColor;
      inner.style.boxShadow = (hasHover && el.hoverBoxShadow) ? el.hoverBoxShadow : (el.boxShadow || '');
      dom.style.removeProperty('--gui-scale');
    });
  }

  return dom;
}

/* ── Action executor ── */
function executeUIActions(actions, sourceEl, mode) {
  const isChain = mode === 'chain';
  let cumDelay = 0;
  actions.forEach((act, i) => {
    if (isChain) cumDelay += (i > 0 ? (act.delay || 0) : 0);
    const delay = isChain ? cumDelay : (act.delay || 0);
    setTimeout(() => {
      const targetId = act.targetId || sourceEl.id;
      const entry = gameUIElements[targetId];
      if (!entry && !['callFunction','addAnimation','removeAnimation','playSound','loadScene','playMusic','stopMusic','setVolume','loadURL','showHTML'].includes(act.type)) return;

      switch (act.type) {
        case 'show':
          if (entry) { entry.dom.classList.remove('gui-hidden'); entry.data.visible = true; }
          break;
        case 'hide':
          if (entry) { entry.dom.classList.add('gui-hidden'); entry.data.visible = false; }
          break;
        case 'toggle':
          if (entry) { const h = entry.dom.classList.toggle('gui-hidden'); entry.data.visible = !h; }
          break;
        case 'setText':
          if (entry) {
            entry.data.content = act.value || '';
            const inner = entry.dom.querySelector('.gui-inner');
            if (inner) inner.textContent = resolveBindingText(act.value || '', entry.data.binding);
          }
          break;
        case 'setProgress':
          if (entry) { const f = entry.dom.querySelector('.gui-progressbar-fill'); if (f) f.style.width = Math.max(0, Math.min(100, act.value || 0)) + '%'; }
          break;
        case 'videoControl':
          if (entry) controlUIVideo(entry.dom, act.videoCmd);
          break;
        case 'carouselControl':
          if (entry) controlUICarousel(entry.dom, act.carCmd, act.carIndex);
          break;
        case 'heroControl':
          if (entry) controlUIHero(entry.dom, act.heroCmd, act.heroIndex);
          break;
        case 'frameControl':
          if (entry) controlUIFrame(entry.dom, act.frameCmd, act.frameValue);
          break;
        case 'modelControl':
          if (entry) controlUIModel(entry.dom, act.modelCmd, act.modelValue);
          break;
        case 'playAnimation':
          if (entry) {
            const aInner = entry.dom.querySelector('.gui-inner');
            if (aInner) runUIAnimSequence(aInner, entry.data, act.animStage || 'all');
          }
          break;
        case 'playSound':
          if (act.soundUrl) { try { const a = new Audio(act.soundUrl); a.volume = act.volume !== undefined ? act.volume : 1; a.play().catch(() => {}); } catch(e) {} }
          break;
        case 'callFunction':
          if (act.funcName && typeof window[act.funcName] === 'function') {
            let extraArgs = [];
            if (act.funcArgs) { try { extraArgs = Function('return [' + act.funcArgs + ']')(); } catch(e) {} }
            try { window[act.funcName](...extraArgs, sourceEl, entry ? entry.data : null); }
            catch(err) { console.warn('[UIPlayer] callFunction error:', err); }
          }
          break;
        case 'addAnimation':
          {
            const aEntry = entry || gameUIElements[sourceEl.id];
            const aInner = aEntry ? aEntry.dom.querySelector('.gui-inner') : null;
            if (aInner && act.animClass) {
              [...aInner.classList].filter(c => c.startsWith('animate__') && c !== 'animate__animated').forEach(c => aInner.classList.remove(c));
              aInner.classList.remove('animate__animated');
              void aInner.offsetWidth;
              aInner.classList.add('animate__animated', act.animClass);
              if (act.animDuration) aInner.style.setProperty('--animate-duration', act.animDuration);
              else aInner.style.removeProperty('--animate-duration');
              if (act.animDelay) aInner.style.animationDelay = act.animDelay;
              else aInner.style.animationDelay = '';
              if (act.animRepeat) aInner.style.animationIterationCount = act.animRepeat;
              else aInner.style.animationIterationCount = '';
            }
          }
          break;
        case 'removeAnimation':
          {
            const rEntry = entry || gameUIElements[sourceEl.id];
            const rInner = rEntry ? rEntry.dom.querySelector('.gui-inner') : null;
            if (rInner) {
              if (act.animClass) {
                rInner.classList.remove(act.animClass);
                const has = [...rInner.classList].some(c => c.startsWith('animate__') && c !== 'animate__animated');
                if (!has) rInner.classList.remove('animate__animated');
              } else {
                [...rInner.classList].filter(c => c.startsWith('animate__')).forEach(c => rInner.classList.remove(c));
              }
              rInner.style.removeProperty('--animate-duration');
              rInner.style.animationDelay = '';
              rInner.style.animationIterationCount = '';
            }
          }
          break;
        case 'loadScene':
          {
            const params = {};
            if (act.sceneParams) { try { Object.assign(params, typeof act.sceneParams === 'string' ? JSON.parse(act.sceneParams) : act.sceneParams); } catch(e) {} }
            GameAPI.loadScene(act.levelFile || '', act.uiFile || '', params);
          }
          break;
        case 'playMusic':
          if (act.musicSrc) playMusic(act.musicSrc, act.musicLoop !== false);
          break;
        case 'stopMusic':
          stopMusic();
          break;
        case 'setVolume':
          setMusicVolume(act.volume !== undefined ? act.volume : 0.5);
          break;
        case 'loadURL':
          if (act.url) {
            const tgt = act.urlTarget || 'newTab';
            if (tgt === 'same') window.location.href = act.url;
            else if (tgt === 'newTab') window.open(act.url, '_blank');
            else if (tgt === 'newWindow') window.open(act.url, '_blank', 'width=800,height=600');
            else if (tgt === 'iframe') showIframeOverlay(act.url, act.iframeBackIcon, act.iframeCloseSound);
          }
          break;
        case 'showHTML':
          if (act.htmlContent) {
            // '3d' billboard mode has no 3D scene here — falls back to popup
            showHTMLPopup(act.htmlContent, { bgColor: act.htmlBgColor, textColor: act.htmlTextColor });
          }
          break;
      }
    }, delay);
  });
}

/* ── Build the whole UI from a .ui.json project ── */
function loadGameUI(uiData) {
  // Clean previous UI
  const existing = document.getElementById('game-ui-overlay');
  if (existing) existing.innerHTML = '';
  const existingStyle = document.getElementById('game-ui-styles');
  if (existingStyle) existingStyle.remove();
  const existingScript = document.getElementById('game-ui-custom-code');
  if (existingScript) existingScript.remove();
  const existingFonts = document.getElementById('game-ui-fonts');
  if (existingFonts) existingFonts.remove();
  gameUIElements = {};
  gameUIData = uiData;

  const overlay = document.getElementById('game-ui-overlay');

  // Load any extra Google fonts used by the project
  const fonts = new Set();
  (uiData.elements || []).forEach(el => {
    if (el.fontFamily) {
      const m = el.fontFamily.match(/['"]([^'"]+)['"]/);
      if (m && !['Rajdhani','Orbitron','Courier New','Arial','Trebuchet MS'].includes(m[1])) fonts.add(m[1]);
    }
  });
  if (fonts.size > 0) {
    const link = document.createElement('link');
    link.id = 'game-ui-fonts';
    link.rel = 'stylesheet';
    link.href = 'https://fonts.googleapis.com/css2?' + [...fonts].map(f => 'family=' + f.replace(/ /g, '+')).join('&') + '&display=swap';
    document.head.appendChild(link);
  }

  // Page + project background
  document.getElementById('page-bg').style.background = uiPlayerSettings.pageBackground || '#000';
  if (uiData.bgImage) {
    document.getElementById('page-bg').style.backgroundImage = 'url(' + uiData.bgImage + ')';
    document.getElementById('page-bg').style.backgroundSize = 'cover';
    document.getElementById('page-bg').style.backgroundPosition = 'center';
  } else {
    document.getElementById('page-bg').style.backgroundImage = 'none';
  }

  // ── Responsive override CSS ──
  const style = document.createElement('style');
  style.id = 'game-ui-styles';

  function genOverrideCss(ov, elId, fontUnit) {
    let css = '';
    if (!ov || typeof ov !== 'object') return css;
    if (ov.visible === false) css += '  #gui-' + elId + ' { display:none !important; }\n';
    if (ov.x !== undefined) css += '  #gui-' + elId + ' { left:' + ov.x + '% !important; }\n';
    if (ov.y !== undefined) css += '  #gui-' + elId + ' { top:' + ov.y + '% !important; }\n';
    if (ov.w !== undefined) css += '  #gui-' + elId + ' { width:' + (ov.w > 0 ? ov.w + '%' : 'auto') + ' !important; }\n';
    if (ov.h !== undefined) css += '  #gui-' + elId + ' { height:' + (ov.h > 0 ? ov.h + '%' : 'auto') + ' !important; }\n';
    if (ov.anchor) css += '  #gui-' + elId + ' { transform:' + uiAnchorTransform(ov.anchor) + ' !important; }\n';
    if (ov.opacity !== undefined) css += '  #gui-' + elId + ' { opacity:' + ov.opacity + ' !important; }\n';
    const innerProps = { fontSize:'font-size', fontWeight:'font-weight', padding:'padding', background:'background', borderRadius:'border-radius', border:'border', color:'color', textAlign:'text-align', boxShadow:'box-shadow', letterSpacing:'letter-spacing' };
    for (const [jsProp, cssProp] of Object.entries(innerProps)) {
      if (ov[jsProp] !== undefined) {
        const val = jsProp === 'fontSize' ? ov[jsProp] + (ov.fontUnit || fontUnit || 'rem') : ov[jsProp];
        css += '  #gui-' + elId + ' .gui-inner { ' + cssProp + ':' + val + ' !important; }\n';
      }
    }
    return css;
  }

  const deviceQueries = {
    tablet: '@media (max-width:1024px) and (min-width:481px)',
    phone: '@media (max-width:480px) and (orientation:portrait)',
    'phone-land': '@media (max-width:768px) and (orientation:landscape)',
  };
  const showOnQueries = {
    desktop: '@media (min-width:1025px)',
    tablet: '@media (max-width:1024px) and (min-width:481px)',
    phone: '@media (max-width:480px) and (orientation:portrait)',
    'phone-land': '@media (max-width:768px) and (orientation:landscape)',
  };
  let responsiveCss = '';

  function collectCss(el) {
    if (el.responsive && el.overrides) {
      for (const [dev, query] of Object.entries(deviceQueries)) {
        const css = genOverrideCss(el.overrides[dev], el.id, el.fontUnit);
        if (css) responsiveCss += query + ' {\n' + css + '}\n';
      }
    }
    if (el.mobileHidden) responsiveCss += '@media (max-width:768px) { #gui-' + el.id + ' { display:none !important; } }\n';
    if (el.showOn && el.showOn.length > 0) {
      for (const [dev, query] of Object.entries(showOnQueries)) {
        if (!el.showOn.includes(dev)) responsiveCss += query + ' { #gui-' + el.id + ' { display:none !important; } }\n';
      }
    }
  }

  // ── Render parents first, then children ──
  const parents = (uiData.elements || []).filter(e => !e.parentId && !e.disabled);
  const children = (uiData.elements || []).filter(e => e.parentId && !e.disabled);

  parents.forEach(el => {
    const dom = createGameUIElement(el);
    overlay.appendChild(dom);
    gameUIElements[el.id] = { dom, data: el };
    collectCss(el);
  });
  children.forEach(el => {
    const parentEntry = gameUIElements[el.parentId];
    const container = parentEntry ? (parentEntry.dom.querySelector('.gui-children') || parentEntry.dom.querySelector('.gui-inner .gui-children') || parentEntry.dom) : overlay;
    const dom = createGameUIElement(el);
    container.appendChild(dom);
    dom.style.pointerEvents = 'auto';
    gameUIElements[el.id] = { dom, data: el };
    collectCss(el);
  });

  style.textContent = responsiveCss;
  document.head.appendChild(style);

  // ── Inject custom code after UI exists, then call onReady() ──
  if (uiData.customCode) {
    const script = document.createElement('script');
    script.id = 'game-ui-custom-code';
    script.textContent = uiData.customCode + '\nif (typeof onReady === "function") onReady();';
    document.body.appendChild(script);
  }

  // Match the builder's design resolution so px/rem sizes keep their proportions
  applyUIStage(uiData);
  watchUIStage(function() { return gameUIData; });

  console.log('[UIPlayer] Loaded "' + (uiData.name || 'UI') + '" — ' + (uiData.elements || []).length + ' elements');
}

/* ═══════════════════════════════════════════
   GAME API (subset — no 3D scene here)
   ═══════════════════════════════════════════ */
window._gameGlobals = window._gameGlobals || {};
const GameAPI = {
  globals: window._gameGlobals,
  setGlobal(name, value) { window._gameGlobals[name] = value; },
  getGlobal(name) { return window._gameGlobals[name]; },

  // UI
  setUIValue, getUIValue, setUIVisible, setUIContent, setUIStyle, loadGameUI,

  // Video
  getUIVideo(elId) { const e = gameUIElements[elId]; return e ? e.dom.querySelector('video') : null; },
  playVideo(elId) { const v = this.getUIVideo(elId); if (v) v.play().catch(() => {}); },
  pauseVideo(elId) { const v = this.getUIVideo(elId); if (v) v.pause(); },
  toggleVideo(elId) { const v = this.getUIVideo(elId); if (v) { v.paused ? v.play().catch(() => {}) : v.pause(); } },
  stopVideo(elId) { const v = this.getUIVideo(elId); if (v) { v.pause(); try { v.currentTime = 0; } catch(e) {} } },
  restartVideo(elId) { const v = this.getUIVideo(elId); if (v) { try { v.currentTime = 0; } catch(e) {} v.play().catch(() => {}); } },
  setVideoSrc(elId, src) { const v = this.getUIVideo(elId); if (v) { v.src = src; v.load(); } },

  // ── Carousel control ──
  carouselNext(elId) { const e = gameUIElements[elId]; if (e) controlUICarousel(e.dom, 'next'); },
  carouselPrev(elId) { const e = gameUIElements[elId]; if (e) controlUICarousel(e.dom, 'prev'); },
  carouselGoTo(elId, i) { const e = gameUIElements[elId]; if (e) controlUICarousel(e.dom, 'goTo', i); },
  carouselPlay(elId) { const e = gameUIElements[elId]; if (e) controlUICarousel(e.dom, 'play'); },
  carouselStop(elId) { const e = gameUIElements[elId]; if (e) controlUICarousel(e.dom, 'stop'); },
  carouselRefresh(elId) { const e = gameUIElements[elId]; if (e) controlUICarousel(e.dom, 'refresh'); },
  // ── Hero section ──
  heroNext(elId) { const e = gameUIElements[elId]; if (e) controlUIHero(e.dom, 'next'); },
  heroPrev(elId) { const e = gameUIElements[elId]; if (e) controlUIHero(e.dom, 'prev'); },
  heroGoTo(elId, i) { const e = gameUIElements[elId]; if (e) controlUIHero(e.dom, 'goTo', i); },
  heroPlay(elId) { const e = gameUIElements[elId]; if (e) controlUIHero(e.dom, 'play'); },
  heroStop(elId) { const e = gameUIElements[elId]; if (e) controlUIHero(e.dom, 'stop'); },
  // ── Embedded pages ──
  reloadFrame(elId) { const e = gameUIElements[elId]; if (e) controlUIFrame(e.dom, 'reload'); },
  setFrameSrc(elId, url) { const e = gameUIElements[elId]; if (e) controlUIFrame(e.dom, 'setSrc', url); },
  getFrame(elId) { const e = gameUIElements[elId]; return e ? e.dom.querySelector('iframe') : null; },
  // ── 3D models ──
  spinModel(elId, on) { const e = gameUIElements[elId]; if (e) controlUIModel(e.dom, on === false ? 'stop' : 'spin'); },
  resetModel(elId) { const e = gameUIElements[elId]; if (e) controlUIModel(e.dom, 'reset'); },
  setModelSrc(elId, url) { const e = gameUIElements[elId]; if (e) controlUIModel(e.dom, 'setModel', url); },
  getModelApi(elId) {
    const e = gameUIElements[elId];
    const host = e ? e.dom.querySelector('.ui-model') : null;
    return host ? host._modelApi : null;
  },
  setHeroSlides(elId, slides) {
    const e = gameUIElements[elId];
    if (!e) return;
    e.data.heroSlides = slides || [];
    const inner = e.dom.querySelector('.gui-inner');
    if (inner) renderUIHero(inner, e.data, true);
  },
  // ── Animation sequence ──
  playAnimation(elId, stage) {
    const e = gameUIElements[elId];
    if (!e) return;
    const inner = e.dom.querySelector('.gui-inner');
    if (inner) runUIAnimSequence(inner, e.data, stage || 'all');
  },
  stopAnimation(elId) { this.playAnimation(elId, 'stop'); },
  setCarouselItems(elId, items) {
    const e = gameUIElements[elId];
    if (!e) return;
    e.data.carouselItems = items || [];
    const inner = e.dom.querySelector('.gui-inner');
    if (inner) renderUICarousel(inner, e.data, true);
  },

  // Audio / Music
  playAudio(src, vol) {
    try { const a = new Audio(src); if (vol !== undefined) a.volume = vol; a.play().catch(e => console.warn('Audio:', e)); return a; }
    catch(e) { console.warn('Audio error:', e); return null; }
  },
  playMusic, stopMusic, pauseMusic, resumeMusic,
  setMusicVolume,
  getMusicVolume() { return masterVolume; },
  setMusicMuted(m) { musicMuted = !!m; if (musicAudio) musicAudio.volume = musicMuted ? 0 : masterVolume; },
  isMusicMuted() { return musicMuted; },

  // Toast / popups
  showToast,
  showHTMLPopup,
  showIframeOverlay,

  // Key simulation (for virtual d-pad / joystick or external listeners)
  pressKey(key) {
    const k = key.toLowerCase();
    if (k in window.playerKeys) window.playerKeys[k] = true;
    window.dispatchEvent(new KeyboardEvent('keydown', { key: k, bubbles: true }));
  },
  releaseKey(key) {
    const k = key.toLowerCase();
    if (k in window.playerKeys) window.playerKeys[k] = false;
    window.dispatchEvent(new KeyboardEvent('keyup', { key: k, bubbles: true }));
  },
  simulateKey(key, ms) { this.pressKey(key); setTimeout(() => this.releaseKey(key), ms || 200); },
  releaseAllKeys() { Object.keys(window.playerKeys).forEach(k => window.playerKeys[k] = false); },

  // Scene params (persist across UI transitions)
  sceneParams: {},
  getSceneParam(name) { return GameAPI.sceneParams[name]; },
  setSceneParam(name, value) { GameAPI.sceneParams[name] = value; },

  /**
   * UI scene transitions.
   * Same signature as game-level: loadScene(levelSource, uiSource, params).
   * In UI Player there is no 3D scene, so:
   *  - uiSource (path/URL string or .ui.json object) → loads that UI
   *  - levelSource pointing to a .ui.json (or a game-ui object) is also accepted
   *  - a real 3D .level.json cannot run here → a toast explains to use game-level.html
   */
  async loadScene(levelSource, uiSource, params) {
    if (params && typeof params === 'object') Object.assign(GameAPI.sceneParams, params);

    // Decide what to load as UI
    let src = uiSource || null;
    if (!src && levelSource) {
      const looksLikeUI = (typeof levelSource === 'object' && levelSource && levelSource.type === 'game-ui')
        || (typeof levelSource === 'string' && /\.ui\.json(\?|#|$)/i.test(levelSource));
      if (looksLikeUI) src = levelSource;
      else {
        showToast('3D levels need game-level.html — UI Player only runs .ui.json', 'warn');
        return;
      }
    }
    if (!src) return;

    // Transition overlay
    const s = uiPlayerSettings;
    let ov = document.getElementById('scene-transition-overlay');
    if (!ov) {
      ov = document.createElement('div');
      ov.id = 'scene-transition-overlay';
      ov.style.cssText = 'position:fixed;inset:0;z-index:10000;background:' + (s.loadingBgColor || '#05070e') + 'f8;display:flex;flex-direction:column;align-items:center;justify-content:center;font-family:' + (s.loadingFontFamily || 'Rajdhani,sans-serif') + ';transition:opacity 0.4s;';
      ov.innerHTML = '<div style="font-family:Orbitron,sans-serif;font-size:1.1rem;letter-spacing:3px;color:' + (s.loadingAccentColor || '#00e5a0') + ';margin-bottom:14px;text-transform:uppercase;">Loading…</div>'
        + '<div style="width:240px;height:5px;background:rgba(255,255,255,0.08);border-radius:3px;overflow:hidden;"><div id="scene-transition-bar" style="height:100%;width:15%;background:' + (s.loadingAccentColor || '#00e5a0') + ';transition:width 0.3s;"></div></div>';
      document.body.appendChild(ov);
    }
    ov.style.opacity = '1';
    const bar = document.getElementById('scene-transition-bar');

    try {
      let uiData;
      if (typeof src === 'object') uiData = src;
      else {
        const resp = await fetch(src);
        if (!resp.ok) throw new Error('Failed to load UI: ' + resp.statusText);
        uiData = await resp.json();
      }
      if (bar) bar.style.width = '70%';
      stopMusic();
      loadGameUI(uiData);
      if (bar) bar.style.width = '100%';
    } catch(err) {
      console.error('[UIPlayer] loadScene error:', err);
      showToast('Load failed: ' + err.message, 'error');
    }
    setTimeout(() => { ov.style.opacity = '0'; setTimeout(() => ov.remove(), 450); }, 250);
  },
};
window.GameAPI = GameAPI;

/* ═══════════════════════════════════════════
   LOADING SCREEN / STARTUP
   ═══════════════════════════════════════════ */
let pendingUIData = null;
const dropUI = document.getElementById('drop-zone-ui');
const fileUI = document.getElementById('file-input-ui');
const btnStart = document.getElementById('btn-start');
const loadingOverlay = document.getElementById('loading-overlay');

function stageFile(file) {
  if (!file) return;
  const reader = new FileReader();
  reader.onload = ev => {
    try {
      const data = JSON.parse(ev.target.result);
      if (data.type !== 'game-ui' && !file.name.endsWith('.ui.json') && !Array.isArray(data.elements)) {
        throw new Error('Not a UI Builder project file');
      }
      pendingUIData = data;
      dropUI.classList.add('loaded');
      document.getElementById('ui-filename').textContent = file.name;
      btnStart.classList.add('visible');
    } catch(err) {
      const statusEl = document.getElementById('loading-status');
      statusEl.style.display = 'block';
      statusEl.textContent = 'Invalid file: ' + err.message;
      statusEl.style.color = '#ff6b6b';
    }
  };
  reader.readAsText(file);
}

function startUI(uiData) {
  loadGameUI(uiData);
  loadingOverlay.classList.add('fade-out');
  setTimeout(() => { loadingOverlay.style.display = 'none'; }, 550);
}

btnStart.addEventListener('click', () => { if (pendingUIData) startUI(pendingUIData); });

fileUI.addEventListener('change', e => { stageFile(e.target.files[0]); e.target.value = ''; });
dropUI.addEventListener('dragover', e => { e.preventDefault(); dropUI.classList.add('dragover'); });
dropUI.addEventListener('dragleave', () => dropUI.classList.remove('dragover'));
dropUI.addEventListener('drop', e => {
  e.preventDefault(); dropUI.classList.remove('dragover');
  if (e.dataTransfer.files[0]) stageFile(e.dataTransfer.files[0]);
});
document.body.addEventListener('dragover', e => e.preventDefault());
document.body.addEventListener('drop', e => {
  if (e.target.closest('#drop-zone-ui')) return;
  e.preventDefault();
  // Only accept file drops on the start screen
  if (loadingOverlay.style.display !== 'none' && e.dataTransfer.files[0]) stageFile(e.dataTransfer.files[0]);
});

/* ── Auto-load from ui-player.json / ?ui= query param ── */
(async function autoLoadCheck() {
  await loadUIPlayerSettings();

  // Apply loading-screen customization
  const s = uiPlayerSettings;
  loadingOverlay.style.background = s.loadingBgColor || '#05070e';
  document.getElementById('load-title').textContent = s.loadingTitle || 'UI Player';
  document.getElementById('load-title').style.color = s.loadingAccentColor || '#00e5a0';
  document.getElementById('load-subtitle').textContent = s.loadingSubtitle || 'UI Builder Runtime';
  document.getElementById('load-subtitle').style.color = s.loadingTextColor || '#a0b0c0';
  document.getElementById('page-bg').style.background = s.pageBackground || '#000';

  // Embedded standalone mode support
  if (window.__EMBEDDED_UI__) {
    startUI(window.__EMBEDDED_UI__);
    return;
  }

  // ?ui=path overrides settings uiFile
  const urlParams = new URLSearchParams(location.search);
  const uiFile = urlParams.get('ui') || s.uiFile;
  if (!uiFile) return; // stay on drop-zone screen

  // Auto-load mode: replace drop card with clean loading screen
  const card = document.getElementById('load-card');
  card.style.display = 'none';
  const statusEl = document.getElementById('loading-status');
  statusEl.style.display = 'block';
  statusEl.style.color = s.loadingTextColor || '#8a90a4';
  statusEl.textContent = s.loadingMessage || 'Loading…';

  if (s.loadingLogo) {
    const logo = document.createElement('img');
    logo.src = s.loadingLogo;
    logo.style.cssText = 'max-width:200px;max-height:120px;margin-bottom:16px;object-fit:contain;order:-1;';
    loadingOverlay.insertBefore(logo, loadingOverlay.firstChild);
  }

  let bar = null;
  if (s.loadingShowProgress !== false) {
    document.getElementById('load-progress').style.display = 'block';
    bar = document.getElementById('load-progress-bar');
  }

  try {
    if (bar) bar.style.width = '25%';
    const resp = await fetch(uiFile);
    if (!resp.ok) throw new Error('Failed to load UI: ' + resp.statusText);
    const uiData = await resp.json();
    if (bar) bar.style.width = '80%';
    startUI(uiData);
  } catch(err) {
    console.error('[UIPlayer] Auto-load error:', err);
    statusEl.textContent = 'Error: ' + err.message;
    statusEl.style.color = '#ff6b6b';
    const retry = document.createElement('button');
    retry.textContent = 'Retry';
    retry.style.cssText = 'margin-top:16px;padding:8px 24px;background:rgba(255,255,255,0.06);border:1px solid rgba(255,255,255,0.15);border-radius:8px;color:#aaa;cursor:pointer;font-family:Rajdhani,sans-serif;font-size:0.8rem;';
    retry.onclick = () => location.reload();
    loadingOverlay.appendChild(retry);
    // Bring the drop card back as a fallback
    card.style.display = '';
  }
})();

/* ═══════════════════════════════════════════
   ★ CUSTOM CODE HOOKS ★

   Your project's Code-tab functions are injected automatically
   when the UI loads, and onReady() runs after all elements exist.

   ── GAME UI API ──
   GameAPI.setUIValue('score', 100)      → update bound elements showing {score}
   GameAPI.getUIValue('score')            → read a value
   GameAPI.setUIVisible('el_1', false)    → hide an element
   GameAPI.setUIContent('el_2', 'Hello')  → change text
   GameAPI.setUIStyle('el_3', 'color', '#f00')
   GameAPI.loadGameUI(uiJsonData)         → swap in another UI object

   ── VIDEO ──
   GameAPI.playVideo('el_5') / pauseVideo / toggleVideo / stopVideo / restartVideo
   GameAPI.setVideoSrc('el_5', './videos/clip2.mp4')
   GameAPI.getUIVideo('el_5')             → the raw <video> element

   ── SCENE / SCREEN TRANSITIONS ──
   GameAPI.loadScene(null, 'saves/menu.ui.json', {from:'level1'})
   GameAPI.getSceneParam('from')
   (3D .level.json files need game-level.html)

   ── MUSIC / AUDIO ──
   GameAPI.playMusic('./audio/theme.mp3', true)
   GameAPI.stopMusic() / setMusicVolume(0.5) / playAudio('sfx.mp3')

   ── MISC ──
   GameAPI.showToast('Saved!')
   GameAPI.showHTMLPopup('<h3>Hi</h3>')
   GameAPI.showIframeOverlay('https://example.com')
   GameAPI.pressKey('w') / releaseKey / simulateKey — writes window.playerKeys
   ═══════════════════════════════════════════ */
</script>
</body>
</html>