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/game-level.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>Game Level Player</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.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=Orbitron:wght@400;600;700;900&family=Rajdhani:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
  :root {
    --accent: #00e5a0;
    --accent-glow: rgba(0,229,160,0.3);
    --bg-dark: #0a0c12;
    --text-primary: #e0e8f0;
    --text-muted: rgba(255,255,255,0.35);
  }
  * { margin:0; padding:0; box-sizing:border-box; }
  body { overflow:hidden; background:#000; font-family:'Rajdhani',sans-serif; color:var(--text-primary); }
  canvas { display:block; }
  #canvas-container { width:100vw; height:100vh; position:relative; }

  /* ── Loading Screen ── */
  #loading-overlay {
    position:fixed; inset:0; z-index:9999;
    background: linear-gradient(135deg, #0a0c12 0%, #121620 50%, #0a0c12 100%);
    display:flex; flex-direction:column; align-items:center; justify-content:center;
    transition: opacity 0.6s ease, visibility 0.6s;
  }
  #loading-overlay.hidden { display:none; }

  .load-logo {
    font-family:'Orbitron',sans-serif; font-size:2.2rem; font-weight:900;
    letter-spacing:6px; text-transform:uppercase;
    background: linear-gradient(135deg, var(--accent), #00b8ff);
    -webkit-background-clip:text; -webkit-text-fill-color:transparent;
    margin-bottom:8px;
  }
  .load-sub {
    font-family:'Rajdhani',sans-serif; font-size:0.85rem; color:var(--text-muted);
    letter-spacing:3px; text-transform:uppercase; margin-bottom:40px;
  }
  .load-card {
    background: rgba(255,255,255,0.03);
    border: 1px solid rgba(255,255,255,0.08);
    border-radius:16px; padding:32px 40px;
    text-align:center; backdrop-filter:blur(20px);
    max-width:520px; width:92%;
  }
  .load-card h3 {
    font-family:'Orbitron',sans-serif; font-size:0.7rem; letter-spacing:2px;
    text-transform:uppercase; color:var(--accent); margin-bottom:16px;
  }
  .load-card p {
    font-size:0.75rem; color:var(--text-muted); margin-bottom:16px; line-height:1.6;
  }
  .load-zones { display:flex; gap:12px; margin-bottom:12px; }
  .load-drop {
    border:2px dashed rgba(255,255,255,0.12); border-radius:12px;
    padding:20px 16px; cursor:pointer; transition:all 0.3s;
    position:relative; overflow:hidden; flex:1; text-align:center;
  }
  .load-drop:hover { border-color:var(--accent); background:rgba(0,229,160,0.04); }
  .load-drop.dragover { border-color:var(--accent); background:rgba(0,229,160,0.08); }
  .load-drop.loaded { border-color:var(--accent); border-style:solid; background:rgba(0,229,160,0.06); }
  .load-drop.loaded .drop-icon { color:var(--accent); }
  .load-drop .drop-icon { font-size:1.6rem; color:rgba(255,255,255,0.25); display:block; margin-bottom:8px; transition:color 0.3s; }
  .load-drop span { font-size:0.7rem; color:var(--text-muted); display:block; }
  .load-drop small { font-size:0.55rem; color:rgba(255,255,255,0.2); display:block; margin-top:4px; }
  .load-drop .drop-check { display:none; position:absolute; top:8px; right:10px; font-size:0.8rem; color:var(--accent); }
  .load-drop.loaded .drop-check { display:block; }
  .load-drop .drop-filename {
    display:none; font-size:0.55rem; color:var(--accent); margin-top:6px;
    overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
    font-family:'Orbitron',sans-serif; letter-spacing:0.5px;
  }
  .load-drop.loaded .drop-filename { display:block; }
  .load-drop.ui-zone { border-color:rgba(167,139,250,0.2); }
  .load-drop.ui-zone:hover { border-color:#a78bfa; background:rgba(167,139,250,0.04); }
  .load-drop.ui-zone.dragover { border-color:#a78bfa; background:rgba(167,139,250,0.08); }
  .load-drop.ui-zone.loaded { border-color:#a78bfa; border-style:solid; background:rgba(167,139,250,0.06); }
  .load-drop.ui-zone.loaded .drop-icon, .load-drop.ui-zone.loaded .drop-check,
  .load-drop.ui-zone.loaded .drop-filename { color:#a78bfa; }
  /* Fully hide file inputs — no tooltip, no text, no interaction leak */
  .load-drop input[type="file"] {
    position:absolute; inset:0; opacity:0; cursor:pointer;
    width:100%; height:100%; font-size:0 !important;
    -webkit-appearance:none; appearance:none;
  }
  .load-start-wrap { text-align:center; margin-top:4px; }
  #btn-start-game {
    display:none; padding:12px 48px; border-radius:10px; border:none;
    background:linear-gradient(135deg, var(--accent) 0%, #00b8ff 100%);
    color:#000; font-family:'Orbitron',sans-serif; font-size:0.75rem;
    font-weight:700; letter-spacing:3px; text-transform:uppercase;
    cursor:pointer; transition:all 0.3s; box-shadow:0 0 20px rgba(0,229,160,0.3);
  }
  #btn-start-game:hover { transform:scale(1.04); box-shadow:0 0 30px rgba(0,229,160,0.5); }
  #btn-start-game.visible { display:inline-block; animation:fadeInUp 0.4s ease; }
  @keyframes fadeInUp { from { opacity:0; transform:translateY(8px); } to { opacity:1; transform:translateY(0); } }
  .load-or {
    font-size:0.5rem; color:rgba(255,255,255,0.15); margin:6px 0;
    font-family:'Orbitron',sans-serif; letter-spacing:2px; text-align:center;
  }
  @media (max-width:500px) {
    .load-zones { flex-direction:column; }
  }

  #loading-status {
    margin-top:20px; font-family:'Orbitron',sans-serif; font-size:0.6rem;
    letter-spacing:1px; color:var(--accent); display:none;
  }

  .load-progress {
    width:100%; height:3px; background:rgba(255,255,255,0.06);
    border-radius:2px; margin-top:12px; overflow:hidden; display:none;
  }
  .load-progress-bar {
    height:100%; background:linear-gradient(90deg, var(--accent), #00b8ff);
    border-radius:2px; width:0%; transition:width 0.3s;
  }

  /* ── Game HUD ── */
  #game-hud {
    position:fixed; top:0; left:0; right:0;
    display:flex; justify-content:flex-end; align-items:flex-start;
    padding:12px 16px; z-index:100; pointer-events:none;
  }
  #game-hud > * { pointer-events:auto; }

  .hud-panel {
    background:rgba(10,12,18,0.7); backdrop-filter:blur(12px);
    border:1px solid rgba(255,255,255,0.06); border-radius:10px;
    padding:8px 14px;
  }

  #hud-clock {
    text-align:right;
  }
  #clock-time {
    font-family:'Orbitron',sans-serif; font-size:1rem;
    font-weight:700; color:var(--text-primary); letter-spacing:2px;
  }
  #clock-date {
    font-size:0.55rem; color:var(--text-muted); margin-top:1px;
    font-family:'Orbitron',sans-serif; letter-spacing:1px;
  }

  /* ── Toast ── */
  #toast-container {
    position:fixed; top:60px; left:50%; transform:translateX(-50%);
    z-index:200; display:flex; flex-direction:column; gap:6px; align-items:center;
  }
  .game-toast {
    background:rgba(10,12,18,0.85); backdrop-filter:blur(12px);
    border:1px solid rgba(0,229,160,0.2); border-radius:8px;
    padding:6px 16px; font-size:0.7rem; color:var(--accent);
    display:flex; align-items:center; gap:6px;
    animation: toast-in 0.3s ease, toast-out 0.3s ease 2s forwards;
  }
  .game-toast.error { color:#ff6b6b; border-color:rgba(255,107,107,0.2); }
  @keyframes toast-in { from { opacity:0; transform:translateY(-10px); } to { opacity:1; transform:translateY(0); } }
  @keyframes toast-out { from { opacity:1; } to { opacity:0; transform:translateY(-10px); } }
  @keyframes dialog-in { from { opacity:0; transform:translateY(20px); } to { opacity:1; transform:translateY(0); } }

  /* ── HUD Buttons ── */
  .hud-btn {
    width:26px; height:26px; border:1px solid rgba(255,255,255,0.1);
    border-radius:6px; background:rgba(255,255,255,0.05); color:var(--text-muted);
    cursor:pointer; display:flex; align-items:center; justify-content:center;
    font-size:0.6rem; transition:all 0.2s; padding:0;
  }
  .hud-btn:hover { border-color:var(--accent); color:var(--accent); background:rgba(0,229,160,0.08); }
  .hud-btn.active { color:var(--accent); border-color:var(--accent); }

  /* ── Camera Nav Bar ── */
  #camera-nav {
    position:fixed; bottom:16px; left:50%; transform:translateX(-50%);
    z-index:100; display:flex; align-items:center; justify-content:center;
  }
  #camera-nav-inner {
    display:flex; align-items:center; gap:4px;
    background:rgba(10,12,18,0.75); backdrop-filter:blur(14px);
    border:1px solid rgba(255,255,255,0.07); border-radius:12px;
    padding:5px 8px;
  }
  .cam-nav-btn {
    display:flex; align-items:center; gap:5px;
    padding:6px 12px; border:1px solid rgba(255,255,255,0.08);
    border-radius:8px; background:transparent; color:var(--text-muted);
    cursor:pointer; font-family:'Rajdhani',sans-serif; font-size:0.68rem;
    font-weight:600; transition:all 0.2s; white-space:nowrap;
  }
  .cam-nav-btn i { font-size:0.7rem; }
  .cam-nav-btn:hover { border-color:rgba(0,204,255,0.4); color:#ccc; background:rgba(255,255,255,0.04); }
  .cam-nav-btn.active {
    border-color:rgba(0,204,255,0.6); color:#00ccff;
    background:rgba(0,204,255,0.1); box-shadow:0 0 10px rgba(0,204,255,0.15);
  }
  .cam-nav-arrow {
    width:28px; height:28px; border:1px solid rgba(255,255,255,0.08);
    border-radius:7px; background:transparent; color:var(--text-muted);
    cursor:pointer; display:flex; align-items:center; justify-content:center;
    font-size:0.7rem; transition:all 0.2s; padding:0; flex-shrink:0;
  }
  .cam-nav-arrow:hover { border-color:rgba(0,204,255,0.4); color:#00ccff; background:rgba(255,255,255,0.04); }
  /* ── Game UI Overlay ── */
  #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;
  }
  /* Responsive game UI */
  @media (max-width:768px) {
    .gui-el.gui-mobile-hidden { display:none !important; }
  }
</style>
</head>
<body>
<div id="canvas-container"></div>

<!-- Loading Screen -->
<div id="loading-overlay">
  <div class="load-logo">Game Level</div>
  <div class="load-sub">World Builder Runtime</div>
  <div class="load-card">
    <h3><i class="bi bi-controller"></i> Load Your World</h3>
    <p>Drop your saved files from the builders to start playing.</p>
    <div class="load-zones">
      <div class="load-drop" id="drop-zone-level">
        <i class="bi bi-globe-americas drop-icon"></i>
        <span>Level File</span>
        <small>.level.json</small>
        <span class="drop-filename" id="level-filename"></span>
        <i class="bi bi-check-circle-fill drop-check"></i>
        <input type="file" id="file-input-level" accept=".json,.level.json">
      </div>
      <div class="load-drop ui-zone" id="drop-zone-ui">
        <i class="bi bi-layout-wtf drop-icon"></i>
        <span>Game UI</span>
        <small>.ui.json (optional)</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>
    </div>
    <div class="load-or">— OR DRAG &amp; DROP —</div>
    <div class="load-start-wrap">
      <button id="btn-start-game">▶ Start Game</button>
      <button id="btn-export-standalone" style="display:none;margin:8px auto 0;padding:8px 20px;background:linear-gradient(135deg,#a78bfa,#7c3aed);border:none;color:#fff;border-radius:8px;cursor:pointer;font-family:Rajdhani,sans-serif;font-size:0.8rem;letter-spacing:1px;font-weight:600;transition:opacity 0.2s;">⬇ Export Standalone HTML</button>
    </div>
    <div id="loading-status"></div>
    <div class="load-progress" id="load-progress">
      <div class="load-progress-bar" id="load-progress-bar"></div>
    </div>
  </div>
</div>
<!-- /loading-overlay -->

<!-- Game HUD -->
<div id="game-hud" style="display:none;">
  <div class="hud-panel" id="hud-clock" style="display:none;">
    <div style="display:flex;align-items:center;gap:8px;">
      <div>
        <div id="clock-time">08:00</div>
        <div id="clock-date">Day 1 — Spring</div>
      </div>
      <div id="hud-time-controls" style="display:none;gap:4px;align-items:center;">
        <button class="hud-btn" id="btn-time-slower" title="Slower"><i class="bi bi-skip-backward-fill"></i></button>
        <button class="hud-btn" id="btn-time-pause" title="Pause/Play"><i class="bi bi-pause-fill"></i></button>
        <button class="hud-btn" id="btn-time-faster" title="Faster"><i class="bi bi-skip-forward-fill"></i></button>
      </div>
    </div>
  </div>
  <div class="hud-panel" id="hud-volume" style="position:absolute;top:8px;right:8px;display:flex;align-items:center;gap:6px;padding:4px 10px;font-size:0.7rem;">
    <button class="hud-btn" id="btn-music-toggle" title="Music On/Off" style="font-size:0.8rem;"><i class="bi bi-music-note-beamed"></i></button>
    <input type="range" id="vol-slider" min="0" max="1" step="0.05" value="0.5" style="width:70px;accent-color:#00e5a0;cursor:pointer;">
    <span id="vol-label" style="font-size:0.55rem;color:#a0b0c0;min-width:24px;">50%</span>
  </div>
</div>
<!-- no toast container -->

<!-- Camera Navigation Bar -->
<div id="camera-nav" style="display:none;">
  <div id="camera-nav-inner"></div>
</div>

<script type="importmap">
{
  "imports": {
    "three": "https://cdn.jsdelivr.net/npm/three@0.163.0/build/three.module.js",
    "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.163.0/examples/jsm/"
  }
}
</script>
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { FBXLoader } from 'three/addons/loaders/FBXLoader.js';
import { RGBELoader } from 'three/addons/loaders/RGBELoader.js';
import * as SkeletonUtils from 'three/addons/utils/SkeletonUtils.js';
import { TextGeometry } from 'three/addons/geometries/TextGeometry.js';
import { FontLoader } from 'three/addons/loaders/FontLoader.js';
const fontLoader = new FontLoader();
const fontCache = {};
const TEXT3D_FONT_BASE = 'https://cdn.jsdelivr.net/npm/three@0.163.0/examples/fonts/';
const TEXT3D_FONTS = {
  'helvetiker': { regular: 'helvetiker_regular.typeface.json', bold: 'helvetiker_bold.typeface.json' },
  'optimer': { regular: 'optimer_regular.typeface.json', bold: 'optimer_bold.typeface.json' },
  'gentilis': { regular: 'gentilis_regular.typeface.json', bold: 'gentilis_bold.typeface.json' },
  'droid_sans': { regular: 'droid/droid_sans_regular.typeface.json', bold: 'droid/droid_sans_bold.typeface.json' },
  'droid_serif': { regular: 'droid/droid_serif_regular.typeface.json', bold: 'droid/droid_serif_bold.typeface.json' },
};
function loadFont(fontName, weight) {
  const key = `${fontName}_${weight}`;
  if (fontCache[key]) return Promise.resolve(fontCache[key]);
  const entry = TEXT3D_FONTS[fontName];
  if (!entry) return loadFont('helvetiker', weight);
  const file = entry[weight] || entry.regular;
  return new Promise((res, rej) => {
    fontLoader.load(TEXT3D_FONT_BASE + file, font => { fontCache[key] = font; res(font); }, undefined, () => {
      if (fontName !== 'helvetiker') loadFont('helvetiker', weight).then(res).catch(rej);
      else rej(new Error('Font load failed'));
    });
  });
}
async function createTextObject(cfg) {
  const g = new THREE.Group();
  const props = cfg.properties || {};
  const text = props.text3dContent || 'Text';
  const fontName = props.text3dFont || 'helvetiker';
  const weight = props.text3dWeight || 'regular';
  const size = props.text3dSize || 0.5;
  const depth = props.text3dDepth || 0.1;
  const color = props.text3dColor || '#ffffff';
  const texUrl = props.text3dTextureUrl || '';
  const curveSegments = props.text3dCurveSegments || 6;
  const bevelEnabled = !!props.text3dBevelEnabled;
  const bevelSize = props.text3dBevelSize || 0.02;
  const bevelThickness = props.text3dBevelThickness || 0.02;
  try {
    const font = await loadFont(fontName, weight);
    const geo = new TextGeometry(text, { font, size, depth, curveSegments, bevelEnabled, bevelSize, bevelThickness, bevelSegments:3 });
    geo.computeBoundingBox(); geo.center();
    let mat;
    if (texUrl) {
      const resolvedUrl = resolveModelPath(texUrl);
      const tex = new THREE.TextureLoader().load(resolvedUrl);
      tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
      mat = new THREE.MeshStandardMaterial({ map:tex, color:new THREE.Color(color), roughness:0.4, metalness:0.2, side:THREE.DoubleSide });
    } else {
      mat = new THREE.MeshStandardMaterial({ color:new THREE.Color(color), roughness:0.4, metalness:0.2, side:THREE.DoubleSide });
    }
    const mesh = new THREE.Mesh(geo, mat);
    mesh.castShadow = true; mesh.receiveShadow = true;
    g.add(mesh);
  } catch(e) {
    console.warn('Text3D creation failed:', e);
    g.add(new THREE.Mesh(new THREE.BoxGeometry(0.5,0.2,0.1), new THREE.MeshStandardMaterial({ color })));
  }
  return g;
}

async function rebuildGLTextObject(entry) {
  while (entry.mesh.children.length) {
    const c = entry.mesh.children[0];
    c.traverse(n => { if (n.geometry) n.geometry.dispose(); if (n.material) { if (n.material.map) n.material.map.dispose(); n.material.dispose(); }});
    entry.mesh.remove(c);
  }
  const newGroup = await createTextObject({ properties: entry.properties });
  while (newGroup.children.length) entry.mesh.add(newGroup.children[0]);
}

function createPrimitiveObject(cfg) {
  const g = new THREE.Group();
  const props = cfg.properties || {};
  const type = props.primitiveType || 'cube';
  const color = props.primColor || '#cccccc';
  let texUrl = props.primTextureUrl || '';
  const metalness = props.primMetalness ?? 0.1;
  const roughness = props.primRoughness ?? 0.6;
  const transparent = !!props.primTransparent;
  const opacity = props.primOpacity ?? 1;
  const doubleSide = !!props.primDoubleSide;
  let geo;
  switch (type) {
    case 'cube': geo = new THREE.BoxGeometry(1,1,1); break;
    case 'sphere': geo = new THREE.SphereGeometry(0.5,24,16); break;
    case 'cylinder': geo = new THREE.CylinderGeometry(0.5,0.5,1,24); break;
    case 'cone': geo = new THREE.ConeGeometry(0.5,1,24); break;
    case 'torus': geo = new THREE.TorusGeometry(0.4,0.15,12,32); break;
    case 'plane': geo = new THREE.PlaneGeometry(props.primWidth||1, props.primHeight||1); break;
    default: geo = new THREE.BoxGeometry(1,1,1);
  }
  const matOpts = { color: new THREE.Color(color), metalness, roughness, transparent, opacity, side: doubleSide ? THREE.DoubleSide : THREE.FrontSide };
  if (texUrl) {
    // Resolve path only for non-data-URI textures
    if (!texUrl.startsWith('data:')) texUrl = resolveModelPath(texUrl);
    const tex = new THREE.TextureLoader().load(texUrl);
    tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
    matOpts.map = tex;
  }
  const mat = new THREE.MeshStandardMaterial(matOpts);
  const mesh = new THREE.Mesh(geo, mat);
  mesh.castShadow = true; mesh.receiveShadow = true;
  g.add(mesh);
  return g;
}

function rebuildGLPrimitiveObject(entry) {
  while (entry.mesh.children.length) {
    const c = entry.mesh.children[0];
    c.traverse(n => { if (n.geometry) n.geometry.dispose(); if (n.material) { if (n.material.map) n.material.map.dispose(); n.material.dispose(); }});
    entry.mesh.remove(c);
  }
  const newGroup = createPrimitiveObject({ properties: entry.properties });
  while (newGroup.children.length) entry.mesh.add(newGroup.children[0]);
}

/* ═══════════════════════════════════════════
   LEVEL META
   ═══════════════════════════════════════════ */
const levelMeta = {
  name:'Untitled Level', description:'', type:'overworld', gridSize:40,
  groundColor:'#2a5a2a', music:'', weather:'clear', allowDecoOverlap:true,
  ambientColor:'#606080', ambientIntensity:0.5,
  hemiSkyColor:'#87ceeb', hemiGroundColor:'#362907', hemiIntensity:0.4,
  dirColor:'#fff4e0', dirIntensity:1.8, shadowEnabled:true,
  toneMappingExposure:1.2, useDayNightCycle:true, sunEnabled:true,
  fixedTimeOfDay:8, cameraType:'perspective', cameraFov:50,
  skyboxType:'procedural', skyboxColor:'#87ceeb', skyboxImage:'', skyboxStars:true,
  showGround:true, showGrid:false,
};

const SCENE_CONFIG = { groundColor:"#2a5a2a", skyboxEquirect:"", gridSize:40, cellSize:1, startHour:8, timeSpeed:60 };

/* ═══════════════════════════════════════════
   ITEM CONFIG
   ═══════════════════════════════════════════ */
let ITEM_CONFIG = [];

async function loadItemConfig() {
  // Use embedded config from standalone export if available
  if (window.__EMBEDDED_CONFIG__) {
    ITEM_CONFIG = Array.isArray(window.__EMBEDDED_CONFIG__) ? window.__EMBEDDED_CONFIG__ : window.__EMBEDDED_CONFIG__.items || [];
  } else {
  try {
    let r = await fetch('./world-item-config.json');
    // If not found locally, try from modelBasePath
    if (!r.ok && gameLevelSettings.modelBasePath) {
      const altPath = gameLevelSettings.modelBasePath + 'world-item-config.json';
      r = await fetch(altPath);
    }
    if (!r.ok) throw new Error(`HTTP ${r.status}`);
    const raw = await r.json();
    if (Array.isArray(raw)) ITEM_CONFIG = raw;
    else if (raw.items && Array.isArray(raw.items)) ITEM_CONFIG = raw.items;
    else throw new Error('Invalid format');
  } catch(err) {
    console.warn('world-item-config.json not found, using defaults:', err);
    ITEM_CONFIG = [
      { id:"building1", name:"House", category:"building", icon:"bi-house-door", modelPath:"", modelType:"glb", position:[0,0,0], rotation:[0,0,0], scale:[1,1,1], footprint:[[0,0],[1,0],[0,1],[1,1]], color:"#00e5a0" },
    ];
  }
  } // end else (non-embedded)
  // Built-in cameras
  [
    { id:"cam_default", name:"Camera", category:"camera", icon:"bi-camera-video", modelType:"camera", modelPath:"", position:[0,1.5,0], rotation:[0,0,0], scale:[1,1,1], footprint:[[0,0]], color:"#00ccff", properties:{ label:"Camera 1", fov:60, height:1.5, isMainCamera:false } },
    { id:"cam_overhead", name:"Overhead Cam", category:"camera", icon:"bi-camera-video-fill", modelType:"camera", modelPath:"", position:[0,5,0], rotation:[0,0,0], scale:[1,1,1], footprint:[[0,0]], color:"#00aaff", properties:{ label:"Overhead", fov:50, height:5, isMainCamera:false } },
    { id:"cam_cinematic", name:"Cinematic Cam", category:"camera", icon:"bi-camera-reels", modelType:"camera", modelPath:"", position:[0,2,0], rotation:[0,0,0], scale:[1,1,1], footprint:[[0,0]], color:"#ff6699", properties:{ label:"Cinematic", fov:35, height:2, isMainCamera:false } },
  ].forEach(cam => { if (!ITEM_CONFIG.find(c => c.id === cam.id)) ITEM_CONFIG.push(cam); });

  // Built-in skyboxes
  [
    { id:"sky_procedural", name:"Day/Night Cycle", category:"skybox", icon:"bi-brightness-high", modelType:"none", modelPath:"", position:[0,0,0], rotation:[0,0,0], scale:[1,1,1], footprint:[[0,0]], color:"#87ceeb", properties:{ skyboxType:"procedural", skyboxStars:true } },
    { id:"sky_blue", name:"Clear Blue", category:"skybox", modelType:"none", modelPath:"", position:[0,0,0], rotation:[0,0,0], scale:[1,1,1], footprint:[[0,0]], color:"#4a90d9", properties:{ skyboxType:"color", skyboxColor:"#4a90d9" } },
  ].forEach(sky => { if (!ITEM_CONFIG.find(c => c.id === sky.id)) ITEM_CONFIG.push(sky); });

  // Built-in players
  [
    { id:"player_main", name:"Main Character", category:"player", icon:"bi-person-badge", modelType:"glb", modelPath:"", position:[0,0,0], rotation:[0,0,0], scale:[1,1,1], footprint:[[0,0]], color:"#00e5a0",
      properties:{ controlType:'keyboard', moveSpeed:3, runSpeed:6, rotateSpeed:3, clickMoveSpeed:3,
        keyBindings:{ forward:{key:'W',anim:'',desc:'Walk Forward'}, backward:{key:'S',anim:'',desc:'Walk Backward'}, left:{key:'A',anim:'',desc:'Rotate Left'}, right:{key:'D',anim:'',desc:'Rotate Right'}, run:{key:'Shift',anim:'',desc:'Run (hold)'}, idle:{key:'',anim:'',desc:'Idle'}, clickmove:{key:'',anim:'',desc:'Click-to-Move'} },
        cameraFollow:false, cameraDistance:8, cameraHeight:6, walkOnSurfaces:true } },
    { id:"player_click", name:"Click-Move Char", category:"player", modelType:"glb", modelPath:"", position:[0,0,0], rotation:[0,0,0], scale:[1,1,1], footprint:[[0,0]], color:"#00ccff",
      properties:{ controlType:'clickmove', moveSpeed:3, runSpeed:6, rotateSpeed:3, clickMoveSpeed:3,
        keyBindings:{ forward:{key:'W',anim:'',desc:'Walk Forward'}, backward:{key:'S',anim:'',desc:'Walk Backward'}, left:{key:'A',anim:'',desc:'Rotate Left'}, right:{key:'D',anim:'',desc:'Rotate Right'}, run:{key:'Shift',anim:'',desc:'Run (hold)'}, idle:{key:'',anim:'',desc:'Idle'}, clickmove:{key:'',anim:'',desc:'Click-to-Move'} },
        cameraFollow:false, cameraDistance:8, cameraHeight:6, walkOnSurfaces:true } },
    { id:"player_both", name:"Dual-Control Char", category:"player", modelType:"glb", modelPath:"", position:[0,0,0], rotation:[0,0,0], scale:[1,1,1], footprint:[[0,0]], color:"#ff9900",
      properties:{ controlType:'both', moveSpeed:3, runSpeed:6, rotateSpeed:3, clickMoveSpeed:3,
        keyBindings:{ forward:{key:'W',anim:'',desc:'Walk Forward'}, backward:{key:'S',anim:'',desc:'Walk Backward'}, left:{key:'A',anim:'',desc:'Rotate Left'}, right:{key:'D',anim:'',desc:'Rotate Right'}, run:{key:'Shift',anim:'',desc:'Run (hold)'}, idle:{key:'',anim:'',desc:'Idle'}, clickmove:{key:'',anim:'',desc:'Click-to-Move'} },
        cameraFollow:false, cameraDistance:8, cameraHeight:6, walkOnSurfaces:true } },
  ].forEach(p => { if (!ITEM_CONFIG.find(c => c.id === p.id)) ITEM_CONFIG.push(p); });

  // Built-in driveable vehicles
  [
    { id:"drive_car", name:"Driveable Car", category:"driveable", icon:"bi-car-front-fill", modelType:"glb", modelPath:"", position:[0,0,0], rotation:[0,0,0], scale:[1,1,1], footprint:[[0,0]], color:"#ff6b35",
      properties:{ acceleration:8, maxSpeed:15, brakeForce:12, reverseMaxSpeed:5, turnSpeed:2.5, friction:3, engineSound:'', brakeSound:'', hornSound:'', hornKey:'H',
        keyBindings:{ accelerate:{key:'W',anim:'',desc:'Accelerate'}, brake:{key:'S',anim:'',desc:'Brake/Reverse'}, left:{key:'A',anim:'',desc:'Steer Left'}, right:{key:'D',anim:'',desc:'Steer Right'}, horn:{key:'H',anim:'',desc:'Horn'}, action1:{key:'1',anim:'',desc:'Action 1'}, action2:{key:'2',anim:'',desc:'Action 2'} },
        cameraFollow:true, cameraDistance:12, cameraHeight:6, walkOnSurfaces:true, wheelMeshNames:'', steerAngle:30 } },
    { id:"drive_truck", name:"Driveable Truck", category:"driveable", icon:"bi-truck", modelType:"glb", modelPath:"", position:[0,0,0], rotation:[0,0,0], scale:[1,1,1], footprint:[[0,0]], color:"#e55039",
      properties:{ acceleration:5, maxSpeed:10, brakeForce:8, reverseMaxSpeed:3, turnSpeed:1.8, friction:2, engineSound:'', brakeSound:'', hornSound:'', hornKey:'H',
        keyBindings:{ accelerate:{key:'W',anim:'',desc:'Accelerate'}, brake:{key:'S',anim:'',desc:'Brake/Reverse'}, left:{key:'A',anim:'',desc:'Steer Left'}, right:{key:'D',anim:'',desc:'Steer Right'}, horn:{key:'H',anim:'',desc:'Horn'}, action1:{key:'1',anim:'',desc:'Action 1'}, action2:{key:'2',anim:'',desc:'Action 2'} },
        cameraFollow:true, cameraDistance:16, cameraHeight:8, walkOnSurfaces:true, wheelMeshNames:'', steerAngle:25 } },
  ].forEach(d => { if (!ITEM_CONFIG.find(c => c.id === d.id)) ITEM_CONFIG.push(d); });

  // Particle presets
  ['fx_explosion','fx_smoke','fx_fire','fx_sparkle','fx_rain','fx_dust','fx_custom'].forEach(id => {
    if (!ITEM_CONFIG.find(c => c.id === id)) {
      ITEM_CONFIG.push({ id, name:id.replace('fx_',''), category:'particle', modelType:'particle', modelPath:'', position:[0,0,0], rotation:[0,0,0], scale:[1,1,1], footprint:[[0,0]], color:'#f472b6', properties:{} });
    }
  });

  // Built-in 3D text presets
  ['text3d_default','text3d_bold','text3d_sign'].forEach(id => {
    if (!ITEM_CONFIG.find(c => c.id === id)) {
      ITEM_CONFIG.push({ id, name:'3D Text', category:'text3d', modelType:'text3d', modelPath:'', position:[0,0,0], rotation:[0,0,0], scale:[1,1,1], footprint:[[0,0]], color:'#38bdf8', properties:{ text3dContent:'Text', text3dFont:'helvetiker', text3dWeight:'regular', text3dSize:0.5, text3dDepth:0.1, text3dColor:'#ffffff' } });
    }
  });

  // Built-in primitives
  ['prim_cube','prim_sphere','prim_cylinder','prim_cone','prim_torus','prim_plane'].forEach(id => {
    if (!ITEM_CONFIG.find(c => c.id === id)) {
      ITEM_CONFIG.push({ id, name:id.replace('prim_',''), category:'primitive', modelType:'primitive', modelPath:'', position:[0,0,0], rotation:[0,0,0], scale:[1,1,1], footprint:[[0,0]], color:'#c084fc', properties:{ primitiveType:id.replace('prim_',''), primColor:'#cccccc' } });
    }
  });

}

/* ═══════════════════════════════════════════
   CONSTANTS & STATE
   ═══════════════════════════════════════════ */
let GRID_SIZE = SCENE_CONFIG.gridSize;
const CELL_SIZE = SCENE_CONFIG.cellSize;
let HALF_GRID = (GRID_SIZE * CELL_SIZE) / 2;

let placedItems = [];
let occupiedCells = new Set();
let modelCache = {};

// Landscape height tracking
let landscapeGrid = new Map();
const LANDSCAPE_TILE_HEIGHT = 0.2;
function _isLandCat(c) { return c === 'landscape' || c === 'scenes'; }
function getTerrainHeight(gx, gz) {
  const ld = landscapeGrid.get(ck(gx, gz));
  return ld ? ld.height : 0;
}
function getTerrainHeightForCells(cells) {
  let maxH = 0;
  for (const [gx, gz] of cells) maxH = Math.max(maxH, getTerrainHeight(gx, gz));
  return maxH;
}

// Road tracking
let roadCells = new Set();
let vehicleLanes = new Map();
let roadGraph = new Map();

// Time
let gameTime = SCENE_CONFIG.startHour * 3600;
let gameDay = 1;
let timeSpeed = SCENE_CONFIG.timeSpeed;
let timePaused = true;
let lastRealTime = performance.now();

/* ═══════════════════════════════════════════
   THREE.JS CORE
   ═══════════════════════════════════════════ */
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x87ceeb);

const camera = new THREE.PerspectiveCamera(50, innerWidth / innerHeight, 0.1, 500);
camera.position.set(12, 16, 18);

const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(innerWidth, innerHeight);
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.2;
document.getElementById('canvas-container').appendChild(renderer.domElement);

const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.08;
controls.maxPolarAngle = Math.PI / 2.15;
controls.minDistance = 1;
controls.maxDistance = 60;
controls.target.set(0, 0, 0);
controls.mouseButtons = { LEFT: THREE.MOUSE.ROTATE, MIDDLE: THREE.MOUSE.DOLLY, RIGHT: THREE.MOUSE.ROTATE };

/* ── Lighting ── */
const ambientLight = new THREE.AmbientLight(0x606080, 0.5);
scene.add(ambientLight);
const hemiLight = new THREE.HemisphereLight(0x87ceeb, 0x362907, 0.4);
scene.add(hemiLight);
const dirLight = new THREE.DirectionalLight(0xfff4e0, 1.8);
dirLight.castShadow = true;
dirLight.shadow.mapSize.set(2048, 2048);
dirLight.shadow.camera.left = -30; dirLight.shadow.camera.right = 30;
dirLight.shadow.camera.top = 30; dirLight.shadow.camera.bottom = -30;
dirLight.shadow.camera.near = 1; dirLight.shadow.camera.far = 80;
dirLight.shadow.bias = -0.002;
scene.add(dirLight);
const fillLight = new THREE.DirectionalLight(0x6080c0, 0.3);
fillLight.position.set(-10, 10, -10);
scene.add(fillLight);

/* ── Stars ── */
const starGeo = new THREE.BufferGeometry();
const starPos = new Float32Array(2000 * 3);
for (let i = 0; i < 2000; i++) {
  const th = Math.random() * Math.PI * 2, ph = Math.acos(Math.random()), r = 150 + Math.random() * 50;
  starPos[i*3] = r*Math.sin(ph)*Math.cos(th); starPos[i*3+1] = r*Math.cos(ph); starPos[i*3+2] = r*Math.sin(ph)*Math.sin(th);
}
starGeo.setAttribute('position', new THREE.BufferAttribute(starPos, 3));
const starMat = new THREE.PointsMaterial({ color: 0xffffff, size: 0.5, transparent: true, opacity: 0 });
scene.add(new THREE.Points(starGeo, starMat));

/* ── Ground ── */
const groundMat = new THREE.MeshStandardMaterial({ color: new THREE.Color(SCENE_CONFIG.groundColor), roughness: 0.92, metalness: 0.05 });
const ground = new THREE.Mesh(new THREE.PlaneGeometry(GRID_SIZE, GRID_SIZE), groundMat);
ground.rotation.x = -Math.PI / 2; ground.receiveShadow = true; ground.name = 'ground';
scene.add(ground);
let gridHelper = new THREE.GridHelper(GRID_SIZE, GRID_SIZE, 0x3a5a3a, 0x2e4e2e);
gridHelper.position.y = 0.005;
gridHelper.visible = false; // Hidden by default in game mode
scene.add(gridHelper);

/* ── Raycaster ── */
const raycaster = new THREE.Raycaster();
const pointer = new THREE.Vector2();

/* ═══════════════════════════════════════════
   SKYBOX
   ═══════════════════════════════════════════ */
let useProcSky = true;

function applySkybox() {
  const type = levelMeta.skyboxType || 'procedural';
  if (type === 'procedural') {
    useProcSky = true;
    scene.environment = null;
    scene.background = new THREE.Color(0x87ceeb);
  } else if (type === 'color') {
    useProcSky = false;
    scene.environment = null;
    scene.background = new THREE.Color(levelMeta.skyboxColor || '#87ceeb');
  } else if (type === 'equirect' && levelMeta.skyboxImage) {
    useProcSky = false;
    const rawPath = levelMeta.skyboxImage.startsWith('./') ? levelMeta.skyboxImage : `./images/skybox/${levelMeta.skyboxImage}`;
    const path = resolveModelPath(rawPath);
    const ext = path.split('.').pop().toLowerCase();
    if (ext === 'hdr') {
      new RGBELoader().load(path, tex => {
        tex.mapping = THREE.EquirectangularReflectionMapping;
        scene.environment = tex; scene.background = tex;
        if (levelMeta.skyboxRotationY) { const r = new THREE.Euler(0, THREE.MathUtils.degToRad(levelMeta.skyboxRotationY), 0); scene.backgroundRotation = r; scene.environmentRotation = r; }
      });
    } else {
      new THREE.TextureLoader().load(path, tex => {
        tex.mapping = THREE.EquirectangularReflectionMapping;
        tex.colorSpace = THREE.SRGBColorSpace;
        scene.background = tex; scene.environment = tex;
        if (levelMeta.skyboxRotationY) { const r = new THREE.Euler(0, THREE.MathUtils.degToRad(levelMeta.skyboxRotationY), 0); scene.backgroundRotation = r; scene.environmentRotation = r; }
      });
    }
  }
}

/* ═══════════════════════════════════════════
   SCENE LIGHTING & DAY/NIGHT
   ═══════════════════════════════════════════ */
function applySceneLighting() {
  ambientLight.color.set(levelMeta.ambientColor);
  ambientLight.intensity = levelMeta.ambientIntensity;
  hemiLight.color.set(levelMeta.hemiSkyColor);
  hemiLight.groundColor.set(levelMeta.hemiGroundColor);
  hemiLight.intensity = levelMeta.hemiIntensity;
  dirLight.color.set(levelMeta.dirColor);
  dirLight.intensity = levelMeta.sunEnabled !== false ? levelMeta.dirIntensity : 0;
  dirLight.visible = levelMeta.sunEnabled !== false;
  renderer.toneMappingExposure = levelMeta.toneMappingExposure;
  renderer.shadowMap.enabled = levelMeta.shadowEnabled;
  dirLight.castShadow = levelMeta.shadowEnabled && levelMeta.sunEnabled !== false;
}

let weatherParticles = null;

function applyWeather() {
  const weather = levelMeta.weather || 'clear';
  // Clean up previous weather particles
  if (weatherParticles) { scene.remove(weatherParticles); weatherParticles.geometry.dispose(); weatherParticles.material.dispose(); weatherParticles = null; }
  scene.fog = null;

  const gs = GRID_SIZE * CELL_SIZE;
  if (weather === 'fog') {
    const near = (levelMeta.fogNear || 0.15) * gs;
    const far = (levelMeta.fogFar || 0.6) * gs;
    const fogColor = new THREE.Color(levelMeta.groundColor || '#2a5a2a').lerp(new THREE.Color(0x9999aa), 0.7);
    scene.fog = new THREE.Fog(fogColor, near, far);
  } else if (weather === 'rain' || weather === 'snow') {
    const intensity = levelMeta.weatherIntensity || 1;
    const count = Math.round((weather === 'rain' ? 3000 : 1500) * intensity);
    const area = gs * 0.6;
    const geo = new THREE.BufferGeometry();
    const positions = new Float32Array(count * 3);
    const velocities = new Float32Array(count);
    for (let i = 0; i < count; i++) {
      positions[i*3] = (Math.random() - 0.5) * area;
      positions[i*3+1] = Math.random() * 30;
      positions[i*3+2] = (Math.random() - 0.5) * area;
      velocities[i] = weather === 'rain' ? (8 + Math.random() * 4) * intensity : (1 + Math.random() * 1.5) * intensity;
    }
    geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
    geo.userData._velocities = velocities;
    geo.userData._area = area;
    const mat = new THREE.PointsMaterial({
      color: weather === 'rain' ? 0xaaccff : 0xffffff,
      size: weather === 'rain' ? 0.08 : 0.15,
      transparent: true,
      opacity: weather === 'rain' ? 0.4 : 0.7,
      depthWrite: false,
      sizeAttenuation: true,
    });
    weatherParticles = new THREE.Points(geo, mat);
    weatherParticles.frustumCulled = false;
    scene.add(weatherParticles);
  }
}

function updateWeatherParticles(dt) {
  if (!weatherParticles) return;
  const entry = activePlayerEntry || activeDriveableEntry;
  // Follow player position so particles always surround the camera
  if (entry) {
    weatherParticles.position.x = entry.mesh.position.x;
    weatherParticles.position.z = entry.mesh.position.z;
  }
  const geo = weatherParticles.geometry;
  const positions = geo.attributes.position.array;
  const velocities = geo.userData._velocities;
  const area = geo.userData._area;
  const count = velocities.length;
  const isSnow = levelMeta.weather === 'snow';
  for (let i = 0; i < count; i++) {
    positions[i*3+1] -= velocities[i] * dt;
    if (isSnow) {
      // Snow sway
      positions[i*3] += Math.sin(Date.now() * 0.001 + i) * 0.01;
      positions[i*3+2] += Math.cos(Date.now() * 0.0013 + i) * 0.01;
    }
    if (positions[i*3+1] < 0) {
      positions[i*3] = (Math.random() - 0.5) * area;
      positions[i*3+1] = 25 + Math.random() * 5;
      positions[i*3+2] = (Math.random() - 0.5) * area;
    }
  }
  geo.attributes.position.needsUpdate = true;
}

function getHour() { return (gameTime % 86400) / 3600; }
function lerp(a,b,t){ return a+(b-a)*t; }
function lerpH(a,b,t){ const ca=new THREE.Color(a); ca.lerp(new THREE.Color(b),t); return ca.getHex(); }

function updateDayNight() {
  if (!levelMeta.useDayNightCycle) return;
  const h = getHour();
  const sunAngle = ((h - 6) / 12) * Math.PI, R = 30;
  if (h >= 6 && h <= 18) dirLight.position.set(R*Math.cos(sunAngle), Math.max(2, R*Math.sin(sunAngle)), 10);
  else dirLight.position.set(0, 20, 5);

  let sky, amb, dI, dC, hI, sO, ex;
  if      (h<5)    { sky=0x080820; amb=0.06; dI=0.08; dC=0x223366; hI=0.03; sO=0.8; ex=0.3; }
  else if (h<6)    { const t=h-5;       sky=lerpH(0x080820,0xff7733,t); amb=lerp(0.06,0.3,t); dI=lerp(0.08,0.8,t); dC=lerpH(0x223366,0xff6633,t); hI=lerp(0.03,0.25,t); sO=lerp(0.8,0.1,t); ex=lerp(0.3,0.8,t); }
  else if (h<7.5)  { const t=(h-6)/1.5; sky=lerpH(0xff7733,0x87ceeb,t); amb=lerp(0.3,0.5,t); dI=lerp(0.8,1.8,t); dC=lerpH(0xff6633,0xfff4e0,t); hI=lerp(0.25,0.4,t); sO=lerp(0.1,0,t); ex=lerp(0.8,1.2,t); }
  else if (h<16)   { sky=0x87ceeb; amb=0.5; dI=1.8; dC=0xfff4e0; hI=0.4; sO=0; ex=1.2; }
  else if (h<17.5) { const t=(h-16)/1.5; sky=lerpH(0x87ceeb,0xff6622,t); amb=lerp(0.5,0.3,t); dI=lerp(1.8,1.0,t); dC=lerpH(0xfff4e0,0xff7733,t); hI=lerp(0.4,0.2,t); sO=0; ex=lerp(1.2,0.85,t); }
  else if (h<19)   { const t=(h-17.5)/1.5; sky=lerpH(0xff6622,0x1a1a3e,t); amb=lerp(0.3,0.08,t); dI=lerp(1.0,0.1,t); dC=lerpH(0xff7733,0x223366,t); hI=lerp(0.2,0.04,t); sO=lerp(0,0.7,t); ex=lerp(0.85,0.32,t); }
  else              { sky=0x080820; amb=0.06; dI=0.08; dC=0x223366; hI=0.03; sO=0.8; ex=0.3; }

  if (useProcSky) scene.background.setHex(sky);
  ambientLight.intensity = amb;
  if (levelMeta.sunEnabled !== false) {
    dirLight.intensity = dI; dirLight.color.setHex(dC); dirLight.visible = true;
  } else { dirLight.intensity = 0; dirLight.visible = false; }
  hemiLight.intensity = hI; starMat.opacity = (levelMeta.skyboxStars !== false) ? sO : 0; renderer.toneMappingExposure = ex;
}

function updateClockUI() {
  const sec = gameTime % 86400;
  document.getElementById('clock-time').textContent = `${String(Math.floor(sec/3600)).padStart(2,'0')}:${String(Math.floor((sec%3600)/60)).padStart(2,'0')}`;
  const seasons = ['Spring','Summer','Autumn','Winter'];
  document.getElementById('clock-date').textContent = `Day ${gameDay} — ${seasons[Math.floor(((gameDay-1)%120)/30)]}`;
}

/* ═══════════════════════════════════════════
   TOAST
   ═══════════════════════════════════════════ */
function showToast(msg, type='success') {
  return; // Toasts disabled in game runtime
}

/* ═══════════════════════════════════════════
   GAME LEVEL SETTINGS
   Loaded from game-level.json if present,
   or uses defaults. Controls HUD visibility.
   ═══════════════════════════════════════════ */
const gameLevelSettings = {
  // Auto-load: if levelFile is set, skip drop-zone and load directly
  levelFile: '',           // path/URL to .level.json (e.g. './saves/level1.json' or 'https://...')
  uiFile: '',              // optional path/URL to .ui.json
  // Model path prefix — prepend to all model paths from config
  // Use when game-level.html and world-builder.html are in different directories
  // e.g. '../world-builder/' or '/assets/'
  modelBasePath: '',
  // HUD visibility
  showClock: false,
  showTimeControls: false,
  showCameraNav: true,
  showVolumeSlider: true,
  showToasts: true,
  startFromCamera: true,
  // Loading screen customization
  loadingTitle: 'Game Level',
  loadingSubtitle: 'World 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 loadGameLevelSettings() {
  if (_settingsLoaded) return;
  _settingsLoaded = true;
  // In standalone mode, settings are already embedded
  if (window.__EMBEDDED_SETTINGS__) {
    Object.assign(gameLevelSettings, window.__EMBEDDED_SETTINGS__);
    return;
  }
  try {
    const r = await fetch('./game-level.json');
    if (!r.ok) throw new Error(`HTTP ${r.status}`);
    const data = await r.json();
    Object.assign(gameLevelSettings, data);
  } catch(e) {
    // No settings file — use defaults
  }
}

function applyGameLevelSettings() {
  const clockEl = document.getElementById('hud-clock');
  const timeCtrlEl = document.getElementById('hud-time-controls');
  const volEl = document.getElementById('hud-volume');
  clockEl.style.display = gameLevelSettings.showClock ? 'block' : 'none';
  timeCtrlEl.style.display = gameLevelSettings.showTimeControls ? 'flex' : 'none';
  if (volEl) volEl.style.display = gameLevelSettings.showVolumeSlider !== false ? 'flex' : 'none';
}

// Toast suppression
const _origShowToast = showToast;
showToast = function(msg, type) {
  if (gameLevelSettings.showToasts === false) return;
  _origShowToast(msg, type);
};

/* ═══════════════════════════════════════════
   COMPASS & MINIMAP
   ═══════════════════════════════════════════ */
let compassEl = null, minimapEl = null, minimapDotEl = null;

function initCompass() {
  if (compassEl) { compassEl.remove(); compassEl = null; }
  if (!levelMeta.compassEnabled) return;
  const pos = levelMeta.compassPosition || 'top-right';
  const scale = levelMeta.compassScale || 1;
  const size = Math.round(60 * scale);
  compassEl = document.createElement('div');
  compassEl.id = 'hud-compass';
  compassEl.style.cssText = `position:fixed;z-index:180;width:${size}px;height:${size}px;pointer-events:none;transition:transform 0.15s;`;
  if (pos.includes('top')) compassEl.style.top = '10px'; else compassEl.style.bottom = '10px';
  if (pos.includes('right')) compassEl.style.right = '10px'; else compassEl.style.left = '10px';
  if (levelMeta.compassImage) {
    const img = document.createElement('img');
    img.src = resolveModelPath(levelMeta.compassImage);
    img.style.cssText = 'width:100%;height:100%;object-fit:contain;';
    compassEl.appendChild(img);
  } else {
    // Default SVG compass — N above red arrow pointing up
    compassEl.innerHTML = `<svg viewBox="0 0 60 60" width="${size}" height="${size}"><circle cx="30" cy="30" r="28" fill="rgba(0,0,0,0.5)" stroke="rgba(255,255,255,0.2)" stroke-width="1.5"/><text x="30" y="11" text-anchor="middle" fill="#ff4444" font-size="9" font-weight="bold" font-family="Orbitron,sans-serif">N</text><polygon points="30,13 34,28 30,24 26,28" fill="#ff4444"/><polygon points="30,47 34,32 30,36 26,32" fill="#ffffff" opacity="0.5"/><text x="30" y="56" text-anchor="middle" fill="rgba(255,255,255,0.3)" font-size="7" font-family="Orbitron,sans-serif">S</text></svg>`;
  }
  document.body.appendChild(compassEl);
}

function updateCompass() {
  if (!compassEl) return;
  // Use camera direction for compass orientation
  const camDir = new THREE.Vector3();
  camera.getWorldDirection(camDir);
  const angleDeg = THREE.MathUtils.radToDeg(Math.atan2(camDir.x, camDir.z));
  const northEdge = levelMeta.compassNorthEdge || 'top';
  const northOffsets = { top: 0, right: 90, bottom: 180, left: -90 };
  const northOff = northOffsets[northEdge] || 0;
  compassEl.style.transform = `rotate(${angleDeg - 180 + northOff}deg)`;
}

function initMinimap() {
  if (minimapEl) minimapEl.remove();
  minimapEl = null; minimapDotEl = null;
  if (!levelMeta.minimapEnabled) return;
  const pos = levelMeta.minimapPosition || 'bottom-right';
  const size = levelMeta.minimapSize || 150;
  minimapEl = document.createElement('div');
  minimapEl.id = 'hud-minimap';
  minimapEl.style.cssText = `position:fixed;z-index:175;width:${size}px;height:${size}px;border-radius:8px;overflow:hidden;border:2px solid rgba(255,255,255,0.15);box-shadow:0 2px 12px rgba(0,0,0,0.5);background:#1a1c2e;`;
  if (pos.includes('top')) minimapEl.style.top = '10px'; else minimapEl.style.bottom = '10px';
  if (pos.includes('right')) minimapEl.style.right = '10px'; else minimapEl.style.left = '10px';
  if (levelMeta.minimapImage) {
    const img = document.createElement('img');
    img.src = resolveModelPath(levelMeta.minimapImage);
    img.style.cssText = 'width:100%;height:100%;object-fit:cover;position:absolute;inset:0;';
    img.onerror = () => { img.style.display = 'none'; captureMinimapFromScene(); };
    minimapEl.appendChild(img);
  } else {
    // Auto-capture from top-down view after models finish loading
    setTimeout(() => captureMinimapFromScene(), 2000);
  }
  // Player dot
  minimapDotEl = document.createElement('div');
  minimapDotEl.style.cssText = 'position:absolute;width:8px;height:8px;background:#00e5a0;border-radius:50%;border:1.5px solid #fff;transform:translate(-50%,-50%);z-index:1;box-shadow:0 0 6px #00e5a0;';
  minimapEl.appendChild(minimapDotEl);
  document.body.appendChild(minimapEl);
}

function captureMinimapFromScene() {
  if (!minimapEl) return;
  try {
    const gs = GRID_SIZE * CELL_SIZE;
    const half = gs / 2;
    const captureSize = 512;
    // Orthographic camera looking straight down
    const topCam = new THREE.OrthographicCamera(-half, half, half, -half, 0.1, 200);
    topCam.position.set(0, 80, 0);
    topCam.up.set(0, 0, -1); // -Z is "up" on rendered image = north at top
    topCam.lookAt(0, 0, 0);
    topCam.updateProjectionMatrix();
    // Render to offscreen target using existing renderer
    const rt = new THREE.WebGLRenderTarget(captureSize, captureSize);
    renderer.setRenderTarget(rt);
    renderer.render(scene, topCam);
    renderer.setRenderTarget(null);
    // Read pixels
    const buf = new Uint8Array(captureSize * captureSize * 4);
    renderer.readRenderTargetPixels(rt, 0, 0, captureSize, captureSize, buf);
    const canvas = document.createElement('canvas');
    canvas.width = captureSize; canvas.height = captureSize;
    const ctx = canvas.getContext('2d');
    const imgData = ctx.createImageData(captureSize, captureSize);
    // Flip Y (WebGL is bottom-up)
    for (let y = 0; y < captureSize; y++) {
      for (let x = 0; x < captureSize; x++) {
        const srcIdx = ((captureSize - 1 - y) * captureSize + x) * 4;
        const dstIdx = (y * captureSize + x) * 4;
        imgData.data[dstIdx] = buf[srcIdx]; imgData.data[dstIdx+1] = buf[srcIdx+1];
        imgData.data[dstIdx+2] = buf[srcIdx+2]; imgData.data[dstIdx+3] = buf[srcIdx+3];
      }
    }
    ctx.putImageData(imgData, 0, 0);
    rt.dispose();
    const img = document.createElement('img');
    img.src = canvas.toDataURL();
    img.style.cssText = 'width:100%;height:100%;object-fit:cover;position:absolute;inset:0;';
    minimapEl.insertBefore(img, minimapDotEl);
  } catch(e) { console.warn('Minimap capture failed:', e); }
}

function updateMinimap() {
  if (!minimapEl || !minimapDotEl) return;
  const entry = activePlayerEntry || activeDriveableEntry;
  if (!entry) return;
  const gs = GRID_SIZE * CELL_SIZE;
  const half = gs / 2;
  const px = (entry.mesh.position.x + half) / gs;
  // With camera up=(0,0,-1), -Z renders at top of image. Dot matches directly.
  const pz = (entry.mesh.position.z + half) / gs;
  const size = levelMeta.minimapSize || 150;
  minimapDotEl.style.left = Math.max(4, Math.min(size - 4, px * size)) + 'px';
  minimapDotEl.style.top = Math.max(4, Math.min(size - 4, pz * size)) + 'px';
}

/* ── Time Controls ── */
document.getElementById('btn-time-pause').addEventListener('click', function() {
  timePaused = !timePaused;
  this.innerHTML = timePaused ? '<i class="bi bi-play-fill"></i>' : '<i class="bi bi-pause-fill"></i>';
  this.classList.toggle('active', !timePaused);
});
document.getElementById('btn-time-slower').onclick = () => { timeSpeed = Math.max(1, timeSpeed/2);  };
document.getElementById('btn-time-faster').onclick = () => { timeSpeed = Math.min(7200, timeSpeed*2);  };

/* ═══════════════════════════════════════════
   CAMERA SYSTEM — view from placed cameras
   with lock support & navigation
   ═══════════════════════════════════════════ */
let placedCameras = []; // populated after scene load
let activeCameraIndex = -1;

function applyCameraView(camEntry) {
  if (!camEntry) return;
  const props = camEntry.properties;
  const mesh = camEntry.mesh;

  // Use exact stored position if available, otherwise fall back to mesh position
  let camX, camY, camZ;
  if (props.camPosX !== undefined) {
    camX = props.camPosX;
    camY = props.camPosY;
    camZ = props.camPosZ;
  } else {
    const worldPos = new THREE.Vector3();
    mesh.getWorldPosition(worldPos);
    camX = worldPos.x;
    camY = worldPos.y > 0.1 ? worldPos.y : worldPos.y + (props.height || 1.5);
    camZ = worldPos.z;
  }
  camera.position.set(camX, camY, camZ);

  if (props.targetX !== undefined) {
    const target = new THREE.Vector3(props.targetX, props.targetY, props.targetZ);
    camera.lookAt(target);
    controls.target.copy(target);
  } else {
    const forward = new THREE.Vector3(0, 0, 1);
    forward.applyQuaternion(mesh.quaternion);
    const lookTarget = new THREE.Vector3(camX, camY, camZ).add(forward.multiplyScalar(props.orbitDistance || 5));
    lookTarget.y = camY - (props.orbitDistance || 5) * Math.sin(THREE.MathUtils.degToRad(props.pitch || 0));
    camera.lookAt(lookTarget);
    controls.target.copy(lookTarget);
  }
  camera.fov = props.fov || 60;
  camera.updateProjectionMatrix();

  // Apply view locks
  controls.enableRotate = !props.lockRotation;
  controls.enablePan = !props.lockPan;
  controls.enableZoom = !props.lockZoom;
  controls.update();

  // Track active index
  activeCameraIndex = placedCameras.indexOf(camEntry);
  updateCameraNavHighlight();
}

function switchToCamera(index) {
  if (index < 0 || index >= placedCameras.length) return;
  applyCameraView(placedCameras[index]);
}

function buildCameraNav() {
  const navEl = document.getElementById('camera-nav');
  const innerEl = document.getElementById('camera-nav-inner');
  innerEl.innerHTML = '';

  if (!gameLevelSettings.showCameraNav || placedCameras.length === 0) {
    navEl.style.display = 'none';
    return;
  }

  navEl.style.display = 'flex';

  // Prev button
  const prevBtn = document.createElement('button');
  prevBtn.className = 'cam-nav-arrow';
  prevBtn.innerHTML = '<i class="bi bi-chevron-left"></i>';
  prevBtn.onclick = () => {
    const next = activeCameraIndex <= 0 ? placedCameras.length - 1 : activeCameraIndex - 1;
    switchToCamera(next);
  };
  innerEl.appendChild(prevBtn);

  // Camera buttons
  placedCameras.forEach((cam, i) => {
    const btn = document.createElement('button');
    btn.className = 'cam-nav-btn' + (i === activeCameraIndex ? ' active' : '');
    btn.dataset.index = i;
    const label = cam.properties.label || cam.instanceName || `Camera ${i + 1}`;
    btn.innerHTML = `<i class="bi bi-camera-video"></i><span>${label}</span>`;
    btn.onclick = () => switchToCamera(i);
    innerEl.appendChild(btn);
  });

  // Next button
  const nextBtn = document.createElement('button');
  nextBtn.className = 'cam-nav-arrow';
  nextBtn.innerHTML = '<i class="bi bi-chevron-right"></i>';
  nextBtn.onclick = () => {
    const next = activeCameraIndex >= placedCameras.length - 1 ? 0 : activeCameraIndex + 1;
    switchToCamera(next);
  };
  innerEl.appendChild(nextBtn);
}

function updateCameraNavHighlight() {
  document.querySelectorAll('.cam-nav-btn').forEach(btn => {
    btn.classList.toggle('active', parseInt(btn.dataset.index) === activeCameraIndex);
  });
}

/* ═══════════════════════════════════════════
   GRID UTILITIES
   ═══════════════════════════════════════════ */
const w2g = (wx,wz) => [Math.floor((wx+HALF_GRID)/CELL_SIZE), Math.floor((wz+HALF_GRID)/CELL_SIZE)];
const g2w = (gx,gz) => [gx*CELL_SIZE-HALF_GRID+0.5, gz*CELL_SIZE-HALF_GRID+0.5];
const ck  = (gx,gz) => `${gx},${gz}`;

/* ═══════════════════════════════════════════
   ZONE MARKER CREATION
   ═══════════════════════════════════════════ */
function createZoneMarker(cfg) {
  const g = new THREE.Group();
  const col = new THREE.Color(cfg.color || '#f59e0b');
  const pole = new THREE.Mesh(new THREE.CylinderGeometry(0.04,0.04,1.2,8), new THREE.MeshStandardMaterial({color:col,emissive:col,emissiveIntensity:0.5,transparent:true,opacity:0.9}));
  pole.position.y = 0.6; g.add(pole);
  const diamond = new THREE.Mesh(new THREE.OctahedronGeometry(0.18,0), new THREE.MeshStandardMaterial({color:col,emissive:col,emissiveIntensity:0.8,transparent:true,opacity:0.9}));
  diamond.position.y = 1.35; diamond.userData.isZoneDiamond = true; g.add(diamond);
  const ring = new THREE.Mesh(new THREE.RingGeometry(0.35,0.45,24), new THREE.MeshBasicMaterial({color:col,transparent:true,opacity:0.5,side:THREE.DoubleSide}));
  ring.rotation.x = -Math.PI/2; ring.position.y = 0.02; g.add(ring);
  const props = cfg.properties || {};
  if (props.radius > 0) {
    const areaRing = new THREE.Mesh(new THREE.RingGeometry(props.radius-0.05,props.radius+0.05,48), new THREE.MeshBasicMaterial({color:col,transparent:true,opacity:0.2,side:THREE.DoubleSide}));
    areaRing.rotation.x = -Math.PI/2; areaRing.position.y = 0.01; g.add(areaRing);
  }
  g.userData.isZoneMarker = true;
  // Hide zone markers in game mode
  g.visible = false;
  return g;
}

/* ═══════════════════════════════════════════
   LIGHT CREATION
   ═══════════════════════════════════════════ */
function createLightObject(cfg) {
  const g = new THREE.Group();
  const props = cfg.properties || {};
  const lightType = props.lightType || 'point';
  const color = new THREE.Color(props.color || '#ffffff');
  const intensity = props.intensity !== undefined ? props.intensity : 1;
  let light;
  if (lightType === 'point') {
    light = new THREE.PointLight(color, intensity, props.distance||10, props.decay||2);
    light.castShadow = props.castShadow !== false; light.shadow.mapSize.set(512,512);
  } else if (lightType === 'spot') {
    light = new THREE.SpotLight(color, intensity, props.distance||15, THREE.MathUtils.degToRad(props.angle||45), props.penumbra||0.3, props.decay||2);
    light.castShadow = props.castShadow !== false; light.shadow.mapSize.set(512,512);
    light.target.position.set(0,-1,0); g.add(light.target);
  } else if (lightType === 'rect') {
    light = new THREE.PointLight(color, intensity, props.distance||8, 2);
    light.castShadow = false;
  }
  if (light) { light.position.set(0,0,0); g.add(light); }
  // No visual helpers in game mode — just the light
  g.userData.isLightHelper = true;
  g.userData.lightRef = light;
  return g;
}

/** Sync a placed light's Three.js object with its entry.properties */
function updatePlacedLight(entry) {
  if (entry.config.category !== 'light') return;
  const light = entry.mesh.userData.lightRef;
  if (!light) return;
  const props = entry.properties;
  const color = new THREE.Color(props.color || '#ffffff');
  light.color.copy(color);
  light.intensity = props.intensity !== undefined ? props.intensity : 1;
  if (light.isPointLight || light.isSpotLight) {
    if (props.distance !== undefined) light.distance = props.distance;
    if (props.decay !== undefined) light.decay = props.decay;
    light.castShadow = props.castShadow !== false;
  }
  if (light.isSpotLight) {
    if (props.angle !== undefined) light.angle = THREE.MathUtils.degToRad(props.angle);
    if (props.penumbra !== undefined) light.penumbra = props.penumbra;
  }
}

/* ═══════════════════════════════════════════
   CAMERA OBJECT (invisible in game mode)
   ═══════════════════════════════════════════ */
function createCameraObject(cfg) {
  const g = new THREE.Group();
  g.visible = false; // Hide camera objects in game
  g.userData.isCameraHelper = true;
  return g;
}

/* ═══════════════════════════════════════════
   MODEL LOADING
   ═══════════════════════════════════════════ */
const gltfL = new GLTFLoader(), fbxL = new FBXLoader();
const textureLoader = new THREE.TextureLoader();

function resolveTexPath(texPath, basePath) {
  if (!texPath) return '';
  if (texPath.startsWith('http') || texPath.startsWith('data:')) return texPath;
  if (texPath.startsWith('./') || texPath.startsWith('../') || texPath.startsWith('/')) return texPath;
  return basePath + texPath;
}

function loadTex(path, srgb, isFBX) {
  const t = textureLoader.load(path);
  t.flipY = !!isFBX;
  if (srgb) t.colorSpace = THREE.SRGBColorSpace;
  t.wrapS = t.wrapT = THREE.RepeatWrapping;
  return t;
}

function applyTexMapsToMat(mat, maps, basePath, isFBX) {
  if (maps.diffuse) mat.map = loadTex(resolveTexPath(maps.diffuse,basePath),true,isFBX);
  if (maps.normal) mat.normalMap = loadTex(resolveTexPath(maps.normal,basePath),false,isFBX);
  if (maps.roughness) mat.roughnessMap = loadTex(resolveTexPath(maps.roughness,basePath),false,isFBX);
  if (maps.metalness) mat.metalnessMap = loadTex(resolveTexPath(maps.metalness,basePath),false,isFBX);
  if (maps.ao) mat.aoMap = loadTex(resolveTexPath(maps.ao,basePath),false,isFBX);
  if (maps.emissive) { mat.emissiveMap = loadTex(resolveTexPath(maps.emissive,basePath),true,isFBX); if (!mat.emissive || mat.emissive.getHex()===0) mat.emissive = new THREE.Color(1,1,1); }
  if (maps.opacity) { mat.alphaMap = loadTex(resolveTexPath(maps.opacity,basePath),false,isFBX); mat.transparent = true; }
  if (maps.specular && mat.specularMap !== undefined) mat.specularMap = loadTex(resolveTexPath(maps.specular,basePath),true,isFBX);
  mat.needsUpdate = true;
}

function applyConfigTextures(model, cfg) {
  const resolvedPath = resolveModelPath(cfg.modelPath || '');
  const basePath = cfg.textureBasePath || (resolvedPath ? resolvedPath.substring(0,resolvedPath.lastIndexOf('/')+1) : '');
  const isFBX = cfg.modelType === 'fbx';
  const hasGlobal = cfg.textures && Object.keys(cfg.textures).length > 0;
  const hasOverrides = cfg.materialOverrides && Object.keys(cfg.materialOverrides).length > 0;
  if (!hasGlobal && !hasOverrides) return;
  model.traverse(c => {
    if (!c.isMesh) return;
    const mats = Array.isArray(c.material) ? c.material : [c.material];
    mats.forEach(mat => {
      const matName = mat.name || '';
      if (hasOverrides && cfg.materialOverrides[matName]) applyTexMapsToMat(mat,cfg.materialOverrides[matName],basePath,isFBX);
      else if (hasGlobal) applyTexMapsToMat(mat,cfg.textures,basePath,isFBX);
    });
  });
}

let animationCache = {};
let animationMixers = new Map();

function loadExternalAnimations(cfg) {
  const anims = cfg.externalAnimations;
  if (!anims || anims.length === 0) return Promise.resolve([]);
  const resolvedPath = resolveModelPath(cfg.modelPath || '');
  const basePath = cfg.textureBasePath || (resolvedPath ? resolvedPath.substring(0,resolvedPath.lastIndexOf('/')+1) : '');
  const targetModel = modelCache[cfg.id];
  const tgtBoneNames = new Set();
  if (targetModel) targetModel.traverse(n => { if (n.isBone) tgtBoneNames.add(n.name); });
  return Promise.all(anims.map(animDef => {
    return new Promise(resolve => {
      const animPath = resolveTexPath(animDef.path, basePath);
      const isFBX = animPath.toLowerCase().endsWith('.fbx');
      const loader = isFBX ? new FBXLoader() : gltfL;
      if (isFBX) { const abp = animPath.substring(0,animPath.lastIndexOf('/')+1); if (abp) loader.setResourcePath(abp); }
      loader.load(animPath, result => {
        const clips = (result.animations || []).map((clip,i) => {
          clip.name = animDef.name || clip.name || `Anim_${i}`;
          if (tgtBoneNames.size > 0) {
            clip.tracks.forEach(track => {
              const trackBone = track.name.split('.')[0];
              const baseBone = trackBone.replace(/^mixamorig[:\.]/, '');
              if (tgtBoneNames.has(baseBone) && !tgtBoneNames.has(trackBone)) track.name = track.name.replace(trackBone, baseBone);
            });
            clip.tracks = clip.tracks.filter(track => tgtBoneNames.has(track.name.split('.')[0]));
          }
          return clip;
        });
        resolve(clips);
      }, undefined, () => resolve([]));
    });
  })).then(arrays => arrays.flat());
}

function makeFallback(cfg) {
  const g = new THREE.Group(), fp = cfg.footprint;
  let mnX=Infinity,mnZ=Infinity,mxX=-Infinity,mxZ=-Infinity;
  fp.forEach(([x,z])=>{ mnX=Math.min(mnX,x); mxX=Math.max(mxX,x+1); mnZ=Math.min(mnZ,z); mxZ=Math.max(mxZ,z+1); });
  const w=mxX-mnX, d=mxZ-mnZ, col=new THREE.Color(cfg.color||'#888');

  if (_isLandCat(cfg.category)) {
    fp.forEach(([cx,cz])=>{
      const tH=(cfg.properties&&cfg.properties.tileHeight)||0.2;
      const isWater=cfg.properties&&cfg.properties.isWater;
      const mat=new THREE.MeshStandardMaterial({color:col,roughness:isWater?0.2:0.92,metalness:isWater?0.1:0.05,transparent:isWater,opacity:isWater?0.7:1.0});
      const tile=new THREE.Mesh(new THREE.BoxGeometry(0.98,tH,0.98),mat);
      tile.position.set(cx-mnX+0.5-w/2,tH/2,cz-mnZ+0.5-d/2); tile.receiveShadow=true; g.add(tile);
      if (!isWater) {
        const detail=new THREE.Mesh(new THREE.PlaneGeometry(0.96,0.96),new THREE.MeshStandardMaterial({color:col.clone().offsetHSL(0.02,0.05,0.05),roughness:0.95}));
        detail.rotation.x=-Math.PI/2; detail.position.set(tile.position.x,tH+0.001,tile.position.z); g.add(detail);
      }
    });
  } else if (cfg.category==='road') {
    fp.forEach(([cx,cz])=>{
      const rd=new THREE.Mesh(new THREE.BoxGeometry(0.95,0.05,0.95),new THREE.MeshStandardMaterial({color:0x444444,roughness:0.9}));
      rd.position.set(cx-mnX+0.5-w/2,0.025,cz-mnZ+0.5-d/2); rd.receiveShadow=true; g.add(rd);
      const ln=new THREE.Mesh(new THREE.BoxGeometry(0.05,0.055,0.35),new THREE.MeshStandardMaterial({color:0xdddd00}));
      ln.position.set(rd.position.x,0.05,rd.position.z); g.add(ln);
    });
  } else if (cfg.category==='vehicle') {
    fp.forEach(([cx,cz])=>{
      const body=new THREE.Mesh(new THREE.BoxGeometry(0.3,0.18,0.55),new THREE.MeshStandardMaterial({color:col,roughness:0.4,metalness:0.3}));
      body.position.set(cx-mnX+0.5-w/2,0.12,cz-mnZ+0.5-d/2); body.castShadow=true; g.add(body);
      const cabin=new THREE.Mesh(new THREE.BoxGeometry(0.25,0.12,0.28),new THREE.MeshStandardMaterial({color:0x333333,roughness:0.3}));
      cabin.position.set(body.position.x,0.27,body.position.z-0.02); cabin.castShadow=true; g.add(cabin);
      const wheelGeo=new THREE.CylinderGeometry(0.06,0.06,0.05,8), wheelMat=new THREE.MeshStandardMaterial({color:0x222222});
      [[-0.17,0.06,-0.16],[0.17,0.06,-0.16],[-0.17,0.06,0.16],[0.17,0.06,0.16]].forEach(([wx2,wy,wz2])=>{
        const whl=new THREE.Mesh(wheelGeo,wheelMat); whl.rotation.z=Math.PI/2; whl.position.set(body.position.x+wx2,wy,body.position.z+wz2); g.add(whl);
      });
    });
  } else if (cfg.category==='particle') {
    // Invisible helper mesh for positioning — particle system renders on top
    const ph = new THREE.Mesh(new THREE.SphereGeometry(0.01,4,4),new THREE.MeshBasicMaterial({visible:false}));
    ph.position.set(0,0.15,0); g.add(ph);
  } else if (cfg.category==='driveable') {
    const isTruck = cfg.id.includes('truck');
    fp.forEach(([cx,cz])=>{
      const bx = cx-mnX+0.5-w/2, bz = cz-mnZ+0.5-d/2;
      if (isTruck) {
        const bed = new THREE.Mesh(new THREE.BoxGeometry(0.45,0.12,0.8),new THREE.MeshStandardMaterial({color:col,roughness:0.5,metalness:0.3}));
        bed.position.set(bx,0.18,bz); bed.castShadow=true; g.add(bed);
        const cab = new THREE.Mesh(new THREE.BoxGeometry(0.4,0.22,0.3),new THREE.MeshStandardMaterial({color:col.clone().offsetHSL(0,0,-0.1),roughness:0.4,metalness:0.2}));
        cab.position.set(bx,0.28,bz+0.25); cab.castShadow=true; g.add(cab);
        const windshield = new THREE.Mesh(new THREE.BoxGeometry(0.34,0.12,0.02),new THREE.MeshStandardMaterial({color:0x88bbee,roughness:0.1,metalness:0.6,transparent:true,opacity:0.5}));
        windshield.position.set(bx,0.32,bz+0.41); g.add(windshield);
        const cargo = new THREE.Mesh(new THREE.BoxGeometry(0.42,0.2,0.4),new THREE.MeshStandardMaterial({color:0x666666,roughness:0.9}));
        cargo.position.set(bx,0.24,bz-0.18); cargo.castShadow=true; g.add(cargo);
        const wGeo = new THREE.CylinderGeometry(0.08,0.08,0.06,10), wMat = new THREE.MeshStandardMaterial({color:0x1a1a1a,roughness:0.85});
        [[-0.24,0.08,0.28],[0.24,0.08,0.28],[-0.24,0.08,-0.05],[0.24,0.08,-0.05],[-0.24,0.08,-0.3],[0.24,0.08,-0.3]].forEach(([wx2,wy,wz2])=>{
          const wh = new THREE.Mesh(wGeo,wMat); wh.rotation.z=Math.PI/2; wh.name='wheel'; wh.position.set(bx+wx2,wy,bz+wz2); g.add(wh);
        });
        [[-0.16,0.22,0.41],[0.16,0.22,0.41]].forEach(([hx,hy,hz])=>{
          g.add(new THREE.Mesh(new THREE.SphereGeometry(0.03,6,6),new THREE.MeshBasicMaterial({color:0xffffaa})).translateX(bx+hx).translateY(hy).translateZ(bz+hz));
        });
      } else {
        const chassis = new THREE.Mesh(new THREE.BoxGeometry(0.38,0.1,0.7),new THREE.MeshStandardMaterial({color:col,roughness:0.35,metalness:0.4}));
        chassis.position.set(bx,0.12,bz); chassis.castShadow=true; g.add(chassis);
        const bodyTop = new THREE.Mesh(new THREE.BoxGeometry(0.34,0.06,0.55),new THREE.MeshStandardMaterial({color:col,roughness:0.35,metalness:0.4}));
        bodyTop.position.set(bx,0.2,bz-0.02); bodyTop.castShadow=true; g.add(bodyTop);
        const cabin2 = new THREE.Mesh(new THREE.BoxGeometry(0.3,0.12,0.28),new THREE.MeshStandardMaterial({color:0x222222,roughness:0.2,metalness:0.5,transparent:true,opacity:0.6}));
        cabin2.position.set(bx,0.29,bz+0.02); cabin2.castShadow=true; g.add(cabin2);
        const ws = new THREE.Mesh(new THREE.BoxGeometry(0.28,0.1,0.02),new THREE.MeshStandardMaterial({color:0x88bbee,roughness:0.1,metalness:0.6,transparent:true,opacity:0.5}));
        ws.position.set(bx,0.3,bz+0.17); ws.rotation.x=-0.15; g.add(ws);
        const wGeo = new THREE.CylinderGeometry(0.065,0.065,0.05,10), wMat = new THREE.MeshStandardMaterial({color:0x1a1a1a,roughness:0.85});
        [[-0.2,0.065,0.2],[0.2,0.065,0.2],[-0.2,0.065,-0.2],[0.2,0.065,-0.2]].forEach(([wx2,wy,wz2])=>{
          const wh = new THREE.Mesh(wGeo,wMat); wh.rotation.z=Math.PI/2; wh.name='wheel'; wh.position.set(bx+wx2,wy,bz+wz2); g.add(wh);
        });
        [[-0.14,0.14,0.36],[0.14,0.14,0.36]].forEach(([hx,hy,hz])=>{
          g.add(Object.assign(new THREE.Mesh(new THREE.SphereGeometry(0.025,6,6),new THREE.MeshBasicMaterial({color:0xffffcc})),{position:new THREE.Vector3(bx+hx,hy,bz+hz)}));
        });
      }
    });
  } else if (cfg.category==='npc' || cfg.category==='player') {
    const isPlayer = cfg.category === 'player';
    fp.forEach(([cx,cz])=>{
      const body2=new THREE.Mesh(new THREE.CylinderGeometry(0.15,0.18,0.55,8),new THREE.MeshStandardMaterial({color:col,roughness:0.6}));
      body2.position.set(cx-mnX+0.5-w/2,0.35,cz-mnZ+0.5-d/2); body2.castShadow=true; g.add(body2);
      const head=new THREE.Mesh(new THREE.SphereGeometry(0.14,8,8),new THREE.MeshStandardMaterial({color:0xffdbac,roughness:0.5}));
      head.position.set(body2.position.x,0.72,body2.position.z); head.castShadow=true; g.add(head);
      const hair=new THREE.Mesh(new THREE.SphereGeometry(0.15,8,8,0,Math.PI*2,0,Math.PI/2),new THREE.MeshStandardMaterial({color:0x3d2b1f}));
      hair.position.copy(head.position); hair.position.y+=0.02; g.add(hair);
      [[-0.05,0.72,0.12],[0.05,0.72,0.12]].forEach(([ex2,ey,ez])=>{
        const eye=new THREE.Mesh(new THREE.SphereGeometry(0.025,6,6),new THREE.MeshBasicMaterial({color:0x222222}));
        eye.position.set(body2.position.x+ex2,ey,body2.position.z+ez); g.add(eye);
      });
    });
  } else if (cfg.category==='decoration') {
    fp.forEach(([cx,cz])=>{
      const trunk=new THREE.Mesh(new THREE.CylinderGeometry(0.07,0.11,0.6,6),new THREE.MeshStandardMaterial({color:0x8B4513}));
      trunk.position.set(cx-mnX+0.5-w/2,0.3,cz-mnZ+0.5-d/2); trunk.castShadow=true; g.add(trunk);
      const top=new THREE.Mesh(new THREE.SphereGeometry(0.38,8,6),new THREE.MeshStandardMaterial({color:col,roughness:0.8}));
      top.position.copy(trunk.position); top.position.y=0.85; top.castShadow=true; g.add(top);
    });
  } else {
    fp.forEach(([cx,cz])=>{
      const h=1.0+Math.random()*2;
      const box=new THREE.Mesh(new THREE.BoxGeometry(0.88,h,0.88),new THREE.MeshStandardMaterial({color:col,roughness:0.5,metalness:0.15}));
      box.position.set(cx-mnX+0.5-w/2,h/2,cz-mnZ+0.5-d/2); box.castShadow=true; box.receiveShadow=true; g.add(box);
    });
  }
  return g;
}

function resolveModelPath(path) {
  if (!path) return path;
  const base = gameLevelSettings.modelBasePath || '';
  if (!base) return path;
  // Don't prefix absolute URLs or data URIs
  if (path.startsWith('http://') || path.startsWith('https://') || path.startsWith('data:') || path.startsWith('/')) return path;
  // Remove leading ./ from path before prefixing
  const clean = path.startsWith('./') ? path.slice(2) : path;
  return base + clean;
}

function loadModel(cfg) {
  return new Promise(async resolve => {
    if (cfg.modelType === 'particle') { resolve(makeFallback(cfg)); return; }
    if (cfg.modelType === 'marker') { resolve(createZoneMarker(cfg)); return; }
    if (cfg.modelType === 'light') { resolve(createLightObject(cfg)); return; }
    if (cfg.modelType === 'camera') { resolve(createCameraObject(cfg)); return; }
    if (cfg.modelType === 'text3d' || cfg.category === 'text3d') { createTextObject(cfg).then(resolve); return; }
    if (cfg.modelType === 'primitive' || cfg.category === 'primitive') { resolve(createPrimitiveObject(cfg)); return; }
    if (modelCache[cfg.id]) { resolve(cloneModel(cfg.id)); return; }
    const ok = async r => {
      const rawScene = cfg.modelType === 'fbx' ? r : r.scene;
      rawScene.traverse(c => {
        if (c.isMesh || c.isSkinnedMesh) {
          c.castShadow = true; c.receiveShadow = true; c.frustumCulled = false;
          const mats = Array.isArray(c.material) ? c.material : [c.material];
          mats.forEach(m => {
            m.side = THREE.DoubleSide;
            if (m.transparent || m.alphaMap || (m.map && m.map.format === THREE.RGBAFormat)) {
              m.alphaTest = Math.max(m.alphaTest || 0, 0.1);
              m.depthWrite = true;
            }
          });
        }
      });
      applyConfigTextures(rawScene, cfg);
      modelCache[cfg.id] = rawScene;
      const clips = r.animations || [];
      if (clips.length > 0) animationCache[cfg.id] = [...clips];
      try {
        const extClips = await loadExternalAnimations(cfg);
        if (extClips.length > 0) {
          if (!animationCache[cfg.id]) animationCache[cfg.id] = [];
          const existing = new Set(animationCache[cfg.id].map(c=>c.name));
          extClips.forEach(clip => { if (!existing.has(clip.name)) { animationCache[cfg.id].push(clip); existing.add(clip.name); } });
        }
      } catch(e) { console.warn('External animation load error:', e); }
      resolve(cloneModel(cfg.id));
    };
    const fail = () => { modelCache[cfg.id] = makeFallback(cfg); resolve(cloneModel(cfg.id)); };
    const modelUrl = resolveModelPath(cfg.modelPath);
    if (cfg.modelType === 'fbx') {
      const resPath = cfg.textureBasePath || (modelUrl ? modelUrl.substring(0,modelUrl.lastIndexOf('/')+1) : '');
      if (resPath) fbxL.setResourcePath(resPath);
      fbxL.load(modelUrl, ok, undefined, fail);
    } else {
      gltfL.load(modelUrl, ok, undefined, fail);
    }
  });
}

function cloneModel(cfgId) {
  const source = modelCache[cfgId];
  if (!source) return new THREE.Group();
  const hasAnimations = animationCache[cfgId] && animationCache[cfgId].length > 0;
  let hasSkeleton = false;
  source.traverse(n => { if (n.isSkinnedMesh) hasSkeleton = true; });
  let clone;
  if (hasSkeleton || hasAnimations) {
    clone = SkeletonUtils.clone(source);
  } else {
    clone = source.clone();
  }
  clone.traverse(child => {
    if (child.isMesh || child.isSkinnedMesh) {
      if (Array.isArray(child.material)) child.material = child.material.map(m => { const mc = m.clone(); mc.side = THREE.DoubleSide; if (mc.transparent || mc.alphaMap) { mc.alphaTest = Math.max(mc.alphaTest||0, 0.1); mc.depthWrite = true; } return mc; });
      else { child.material = child.material.clone(); child.material.side = THREE.DoubleSide; if (child.material.transparent || child.material.alphaMap) { child.material.alphaTest = Math.max(child.material.alphaTest||0, 0.1); child.material.depthWrite = true; } }
    }
  });
  clone.position.set(0,0,0); clone.rotation.set(0,0,0); clone.scale.set(1,1,1);
  return clone;
}

/* ═══════════════════════════════════════════
   ANIMATION SYSTEM
   ═══════════════════════════════════════════ */
function setupAnimationMixer(entry) {
  const clips = animationCache[entry.config.id];
  if (!clips || clips.length === 0) return;
  const mixer = new THREE.AnimationMixer(entry.mesh);
  const actions = {}, clipNames = [];
  clips.forEach(clip => { actions[clip.name] = mixer.clipAction(clip); clipNames.push(clip.name); });
  const mixerData = { mixer, actions, clipNames, activeAction:null, activeClipName:'' };
  animationMixers.set(entry, mixerData);
  if (!entry.properties) entry.properties = {};
  entry.properties._availableAnimations = clipNames;
  if (!entry.properties.defaultAnimation) entry.properties.defaultAnimation = clipNames[0] || '';
  const defaultName = entry.properties.defaultAnimation;
  if (defaultName && actions[defaultName]) { actions[defaultName].play(); mixerData.activeAction = actions[defaultName]; mixerData.activeClipName = defaultName; }
  else if (clipNames.length > 0) { actions[clipNames[0]].play(); mixerData.activeAction = actions[clipNames[0]]; mixerData.activeClipName = clipNames[0]; }
  return mixerData;
}

function playAnimation(entry, clipName) {
  const md = animationMixers.get(entry); if (!md) return;
  const action = md.actions[clipName]; if (!action) return;
  if (md.activeAction && md.activeAction !== action) md.activeAction.fadeOut(0.3);
  action.reset().fadeIn(0.3).play();
  md.activeAction = action; md.activeClipName = clipName;
  entry.properties.defaultAnimation = clipName;
}

/** Play animation with mode: 'once', 'loop', or 'repeat' (N times) */
function playAnimationWithMode(entry, clipName, mode, repeatCount) {
  const md = animationMixers.get(entry); if (!md) return;
  const action = md.actions[clipName]; if (!action) return;
  if (md.activeAction && md.activeAction !== action) md.activeAction.fadeOut(0.3);
  action.reset().fadeIn(0.3);
  if (mode === 'loop') {
    action.setLoop(THREE.LoopRepeat, Infinity);
  } else if (mode === 'repeat') {
    action.setLoop(THREE.LoopRepeat, repeatCount || 2);
  } else {
    action.setLoop(THREE.LoopOnce, 1);
    action.clampWhenFinished = true;
  }
  action.play();
  md.activeAction = action; md.activeClipName = clipName;
}

function disposeAnimationMixer(entry) {
  const md = animationMixers.get(entry);
  if (md) { md.mixer.stopAllAction(); md.mixer.uncacheRoot(entry.mesh); animationMixers.delete(entry); }
}

function restoreAnimation(entry) {
  const md = animationMixers.get(entry); if (!md) return;
  const savedAnim = entry.properties.defaultAnimation;
  if (savedAnim && savedAnim !== md.activeClipName && md.actions[savedAnim]) playAnimation(entry, savedAnim);
  if (entry.properties.animationSpeed !== undefined) md.mixer.timeScale = entry.properties.animationSpeed;
}

function updateAnimations(dt) {
  for (const [entry, md] of animationMixers) { if (md.mixer) md.mixer.update(dt); }
}

/* ═══════════════════════════════════════════
   NPC BEHAVIOR SYSTEM
   ═══════════════════════════════════════════ */
function initNPCBehavior(entry) {
  if (entry.config.category !== 'npc') return;
  const props = entry.properties;
  if (!props.behavior && props.schedule) {
    if (props.schedule === 'patrol') props.behavior = 'patrol';
    else if (props.schedule === 'wander') props.behavior = 'wander';
    else props.behavior = 'idle';
  }
  if (!props.behavior) props.behavior = 'idle';
  if (!props.idleAnim) props.idleAnim = '';
  if (!props.walkAnim) props.walkAnim = '';
  if (!props.moveSpeed) props.moveSpeed = 1.5;
  if (!props.waypoints) props.waypoints = [];
  if (!props.patrolMode) props.patrolMode = 'loop';
  if (!props.wanderRadius) props.wanderRadius = 3;
  if (props.walkOnSurfaces === undefined) props.walkOnSurfaces = true;

  entry.npcState = {
    active:true, waypointIndex:0, progress:0,
    currentPos:entry.mesh.position.clone(), targetPos:null,
    direction:1, paused:false, wanderTimer:0, wanderTarget:null,
    basePos:entry.mesh.position.clone(),
  };

  const md = animationMixers.get(entry);
  if (md) {
    const idleAnim = props.idleAnim || props.defaultAnimation || '';
    if (idleAnim && md.actions[idleAnim]) playAnimation(entry, idleAnim);
  }
  if (props.behavior === 'patrol' && props.waypoints.length > 0) startPatrol(entry);
}

function startPatrol(entry) {
  const ns = entry.npcState; if (!ns) return;
  const wps = entry.properties.waypoints; if (wps.length === 0) return;
  ns.waypointIndex = 0; ns.direction = 1; ns.progress = 0;
  ns.currentPos.copy(entry.mesh.position);
  ns.targetPos = new THREE.Vector3(wps[0][0], entry.mesh.position.y, wps[0][1]);
  const md = animationMixers.get(entry);
  const walkAnim = entry.properties.walkAnim;
  if (md && walkAnim && md.actions[walkAnim]) playAnimation(entry, walkAnim);
}

function updateNPCs(dt) {
  for (const entry of placedItems) {
    if (entry.config.category !== 'npc') continue;
    const ns = entry.npcState;
    if (!ns || !ns.active || ns.paused) continue;
    if (entry.properties.behavior === 'patrol') updateNPCPatrol(entry, dt);
    else if (entry.properties.behavior === 'wander') updateNPCWander(entry, dt);

    // Surface following for ALL behaviors (idle included) — patrol/wander also handle it
    // but idle NPCs need it here since they have no movement update
    if (entry.properties.behavior === 'idle' && entry.properties.walkOnSurfaces) {
      const mesh = entry.mesh;
      const surfY = getNPCSurfaceY(entry, mesh.position.x, mesh.position.z, mesh.position.y);
      const targetY = surfY + (entry.config.position[1] || 0);
      mesh.position.y += (targetY - mesh.position.y) * Math.min(1, 10 * dt);
    }
  }
}

function updateNPCPatrol(entry, dt) {
  const ns = entry.npcState, props = entry.properties;
  const wps = props.waypoints;
  if (!wps || wps.length === 0 || !ns.targetPos) return;
  const speed = props.moveSpeed || 1.5, mesh = entry.mesh;
  const dir = new THREE.Vector3().subVectors(ns.targetPos, mesh.position); dir.y = 0;
  const dist = dir.length();
  if (dist < 0.05) {
    mesh.position.x = ns.targetPos.x; mesh.position.z = ns.targetPos.z;
    let nextIdx = ns.waypointIndex + ns.direction;
    if (props.patrolMode === 'loop') { if (nextIdx >= wps.length) nextIdx = 0; if (nextIdx < 0) nextIdx = wps.length - 1; }
    else if (props.patrolMode === 'pingpong') { if (nextIdx >= wps.length || nextIdx < 0) { ns.direction *= -1; nextIdx = ns.waypointIndex + ns.direction; nextIdx = Math.max(0, Math.min(wps.length-1, nextIdx)); } }
    else { if (nextIdx >= wps.length || nextIdx < 0) { const md=animationMixers.get(entry); const idleAnim=props.idleAnim||props.defaultAnimation||''; if(md&&idleAnim&&md.actions[idleAnim])playAnimation(entry,idleAnim); ns.targetPos=null; return; } }
    ns.waypointIndex = nextIdx;
    const wp = wps[nextIdx];
    const terrainH = getTerrainHeight(Math.round(wp[0]+HALF_GRID), Math.round(wp[1]+HALF_GRID));
    ns.targetPos = new THREE.Vector3(wp[0], entry.config.position[1]+terrainH, wp[1]);
  } else {
    dir.normalize(); const step = Math.min(speed*dt, dist);
    mesh.position.x += dir.x * step; mesh.position.z += dir.z * step;
    const angle = Math.atan2(dir.x, dir.z);
    mesh.rotation.y = angle + THREE.MathUtils.degToRad(entry.config.rotation[1]);
  }
  if (props.walkOnSurfaces) {
    const surfY = getNPCSurfaceY(entry, mesh.position.x, mesh.position.z, mesh.position.y);
    const targetY = surfY + (entry.config.position[1]||0);
    mesh.position.y += (targetY - mesh.position.y) * Math.min(1, 10*dt);
  }
}

function updateNPCWander(entry, dt) {
  const ns = entry.npcState, props = entry.properties;
  const speed = props.moveSpeed||1, radius = props.wanderRadius||3, mesh = entry.mesh;
  ns.wanderTimer -= dt;
  if (!ns.wanderTarget || ns.wanderTimer <= 0) {
    const angle = Math.random()*Math.PI*2, r = Math.random()*radius;
    ns.wanderTarget = new THREE.Vector3(ns.basePos.x+Math.cos(angle)*r, mesh.position.y, ns.basePos.z+Math.sin(angle)*r);
    ns.wanderTimer = 2 + Math.random()*4;
    const md = animationMixers.get(entry); const walkAnim = props.walkAnim;
    if (md && walkAnim && md.actions[walkAnim]) playAnimation(entry, walkAnim);
  }
  const dir = new THREE.Vector3().subVectors(ns.wanderTarget, mesh.position); dir.y = 0;
  const dist = dir.length();
  if (dist < 0.1) {
    ns.wanderTarget = null; ns.wanderTimer = 1.5+Math.random()*3;
    const md = animationMixers.get(entry); const idleAnim = props.idleAnim||props.defaultAnimation||'';
    if (md && idleAnim && md.actions[idleAnim]) playAnimation(entry, idleAnim);
  } else {
    dir.normalize(); const step = Math.min(speed*dt, dist);
    mesh.position.x += dir.x*step; mesh.position.z += dir.z*step;
    mesh.rotation.y = Math.atan2(dir.x, dir.z) + THREE.MathUtils.degToRad(entry.config.rotation[1]);
  }
  if (props.walkOnSurfaces) {
    const surfY = getNPCSurfaceY(entry, mesh.position.x, mesh.position.z, mesh.position.y);
    const targetY = surfY + (entry.config.position[1]||0);
    mesh.position.y += (targetY - mesh.position.y) * Math.min(1, 10*dt);
  }
}

/* ═══════════════════════════════════════════
   SURFACE FOLLOWING (walkOnSurface filtering)
   ═══════════════════════════════════════════ */
const _downRay = new THREE.Raycaster();

function getNPCSurfaceY(npcEntry, wx, wz, fromY) {
  _downRay.set(new THREE.Vector3(wx, fromY+5, wz), new THREE.Vector3(0,-1,0));
  _downRay.far = fromY + 10;
  const meshes = [];
  placedItems.forEach(e => {
    if (e === npcEntry) return;
    if (!e.mesh.visible) return;
    if (!e.properties.walkOnSurface) return;
    e.mesh.traverse(c => { if (c.isMesh) meshes.push(c); });
  });
  meshes.push(ground);
  const hits = _downRay.intersectObjects(meshes, false);
  if (hits.length > 0) { for (const h of hits) { if (h.point.y <= fromY+0.5) return h.point.y; } return hits[0].point.y; }
  return fromY;
}

function getSurfaceYAt(wx, wz, fromY, fallbackY) {
  _downRay.set(new THREE.Vector3(wx, fromY+5, wz), new THREE.Vector3(0,-1,0));
  _downRay.far = fromY + 10;
  const meshes = [];
  placedItems.forEach(e => {
    if (e === activePlayerEntry) return;
    if (!e.mesh.visible) return;
    if (!e.properties.walkOnSurface) return;
    e.mesh.traverse(c => { if (c.isMesh) meshes.push(c); });
  });
  meshes.push(ground);
  const hits = _downRay.intersectObjects(meshes, false);
  if (hits.length > 0) { for (const h of hits) { if (h.point.y <= fromY+0.5) return h.point.y; } return hits[0].point.y; }
  return fallbackY;
}

/* ═══════════════════════════════════════════
   PLAYER CONTROLLER
   ═══════════════════════════════════════════ */
let activePlayerEntry = null;
let playerKeys = { w:false, a:false, s:false, d:false, shift:false };

window.playerKeys = playerKeys; // expose for UI d-pad/joystick controls
let clickTargetMarker = null;

function pointInBoundaryPoly(px, pz, poly) {
  if (!poly || poly.length < 3) return true;
  let inside = false;
  for (let i=0, j=poly.length-1; i<poly.length; j=i++) {
    const xi=poly[i][0], zi=poly[i][1], xj=poly[j][0], zj=poly[j][1];
    if (((zi>pz)!==(zj>pz)) && (px < (xj-xi)*(pz-zi)/(zj-zi)+xi)) inside = !inside;
  }
  return inside;
}

function clampToBoundaryPoly(px, pz, poly) {
  if (!poly || poly.length < 3) return {x:px,z:pz};
  if (pointInBoundaryPoly(px,pz,poly)) return {x:px,z:pz};
  let bestDist=Infinity, bestX=px, bestZ=pz;
  for (let i=0;i<poly.length;i++) {
    const j=(i+1)%poly.length;
    const ax=poly[i][0],az=poly[i][1],bx=poly[j][0],bz=poly[j][1];
    const dx=bx-ax,dz=bz-az,lenSq=dx*dx+dz*dz;
    if (lenSq<0.0001) continue;
    let t=((px-ax)*dx+(pz-az)*dz)/lenSq; t=Math.max(0,Math.min(1,t));
    const cx2=ax+t*dx,cz2=az+t*dz,dist2=(px-cx2)*(px-cx2)+(pz-cz2)*(pz-cz2);
    if (dist2<bestDist) { bestDist=dist2; bestX=cx2; bestZ=cz2; }
  }
  return {x:bestX,z:bestZ};
}

function createClickTargetMarker() {
  if (clickTargetMarker) return clickTargetMarker;
  const ring = new THREE.Mesh(
    new THREE.RingGeometry(0.15,0.25,16),
    new THREE.MeshBasicMaterial({color:0x00e5a0, side:THREE.DoubleSide, transparent:true, opacity:0.5})
  );
  ring.rotation.x = -Math.PI/2; ring.visible = false; scene.add(ring);
  clickTargetMarker = ring;
  return ring;
}

/* ═══════════════════════════════════════════
   ACTIVE INDICATOR — shows which character is controlled
   ═══════════════════════════════════════════ */
function _makeCircleIndicator(color, scale) {
  const g = new THREE.Group(); g.userData._indType = 'circle';
  const ring = new THREE.Mesh(new THREE.RingGeometry(0.35*scale, 0.45*scale, 24), new THREE.MeshBasicMaterial({ color, transparent:true, opacity:0.6, side:THREE.DoubleSide, depthWrite:false }));
  ring.rotation.x = -Math.PI/2; g.add(ring);
  const inner = new THREE.Mesh(new THREE.CircleGeometry(0.35*scale, 24), new THREE.MeshBasicMaterial({ color, transparent:true, opacity:0.12, side:THREE.DoubleSide, depthWrite:false }));
  inner.rotation.x = -Math.PI/2; g.add(inner);
  return g;
}
function _makePlumbobIndicator(color, scale) {
  const g = new THREE.Group(); g.userData._indType = 'plumbob';
  const mat = new THREE.MeshBasicMaterial({ color, transparent:true, opacity:0.8 });
  const top = new THREE.Mesh(new THREE.ConeGeometry(0.12*scale, 0.2*scale, 4), mat); top.position.y = 0.1*scale;
  const bot = new THREE.Mesh(new THREE.ConeGeometry(0.12*scale, 0.12*scale, 4), mat.clone()); bot.rotation.x = Math.PI; bot.position.y = -0.06*scale;
  g.add(top); g.add(bot);
  const glowCanvas = document.createElement('canvas'); glowCanvas.width = 32; glowCanvas.height = 32;
  const glowCtx = glowCanvas.getContext('2d');
  const grad = glowCtx.createRadialGradient(16,16,0,16,16,16);
  grad.addColorStop(0, 'rgba(' + Math.round(color.r*255) + ',' + Math.round(color.g*255) + ',' + Math.round(color.b*255) + ',0.5)');
  grad.addColorStop(1, 'rgba(' + Math.round(color.r*255) + ',' + Math.round(color.g*255) + ',' + Math.round(color.b*255) + ',0)');
  glowCtx.fillStyle = grad; glowCtx.fillRect(0,0,32,32);
  const glowTex = new THREE.CanvasTexture(glowCanvas);
  const glowMat = new THREE.SpriteMaterial({ map:glowTex, transparent:true, depthTest:false });
  const glow = new THREE.Sprite(glowMat); glow.scale.set(0.5*scale, 0.5*scale, 1); g.add(glow);
  return g;
}
function _makeImageIndicator(url, scale) {
  const g = new THREE.Group(); g.userData._indType = 'image';
  const tex = new THREE.TextureLoader().load(url);
  const sprite = new THREE.Sprite(new THREE.SpriteMaterial({ map:tex, transparent:true, depthWrite:false }));
  sprite.scale.set(0.5*scale, 0.5*scale, 1); g.add(sprite);
  return g;
}

function createActiveIndicator(entry) {
  removeActiveIndicator(entry);
  const props = entry.properties;
  const type = props.indicatorType || 'none';
  if (type === 'none') return;
  const color = new THREE.Color(props.indicatorColor || '#00e5a0');
  const scale = props.indicatorScale || 1;
  const offY = props.indicatorOffsetY !== undefined ? props.indicatorOffsetY : (type.includes('plumbob') ? 2.2 : 0.05);

  const group = new THREE.Group();
  group.userData._isIndicator = true;

  if (type === 'circle') {
    group.add(_makeCircleIndicator(color, scale));
  } else if (type === 'plumbob') {
    group.add(_makePlumbobIndicator(color, scale));
  } else if (type === 'image' && props.indicatorImage) {
    group.add(_makeImageIndicator(props.indicatorImage, scale));
  } else if (type === 'plumbob_circle') {
    // Circle at ground level
    const circle = _makeCircleIndicator(color, scale);
    circle.position.y = -offY + 0.05; // offset down to ground
    group.add(circle);
    // Plumbob at top
    group.add(_makePlumbobIndicator(color, scale));
  } else if (type === 'plumbob_image' && props.indicatorImage) {
    const img = _makeImageIndicator(props.indicatorImage, scale);
    img.position.y = -offY + 0.05;
    group.add(img);
    group.add(_makePlumbobIndicator(color, scale));
  }

  group.position.y = offY;
  entry.mesh.add(group);
  entry._indicatorGroup = group;
}

function removeActiveIndicator(entry) {
  if (entry._indicatorGroup) {
    entry.mesh.remove(entry._indicatorGroup);
    entry._indicatorGroup.traverse(c => { if (c.geometry) c.geometry.dispose(); if (c.material) { if (c.material.map) c.material.map.dispose(); c.material.dispose(); } });
    entry._indicatorGroup = null;
  }
}

function updateActiveIndicators(dt) {
  // Animate plumbob rotation + bob
  [activePlayerEntry, activeDriveableEntry].forEach(entry => {
    if (!entry || !entry._indicatorGroup) return;
    const g = entry._indicatorGroup;
    const type = entry.properties.indicatorType || 'none';
    if (type === 'plumbob') {
      g.rotation.y += dt * 2;
      g.children.forEach(c => { if (!c.isSprite) c.position.y += Math.sin(Date.now() * 0.003) * 0.001; });
    } else if (type === 'circle') {
      g.rotation.y += dt * 0.5;
    }
  });
}

function initPlayerController(entry) {
  if (entry.config.category !== 'player') return;
  const props = entry.properties;
  if (!props.controlType) props.controlType = 'keyboard';
  if (!props.moveSpeed) props.moveSpeed = 3;
  if (!props.runSpeed) props.runSpeed = 6;
  if (!props.rotateSpeed) props.rotateSpeed = 3;
  if (!props.clickMoveSpeed) props.clickMoveSpeed = 3;
  if (!props.keyBindings) props.keyBindings = {
    forward:{key:'W',anim:'',desc:'Walk Forward'}, backward:{key:'S',anim:'',desc:'Walk Backward'},
    left:{key:'A',anim:'',desc:'Rotate Left'}, right:{key:'D',anim:'',desc:'Rotate Right'},
    run:{key:'Shift',anim:'',desc:'Run (hold)'}, idle:{key:'',anim:'',desc:'Idle'},
    clickmove:{key:'',anim:'',desc:'Click-to-Move'},
    action1:{key:'1',anim:'',desc:'Action 1'}, action2:{key:'2',anim:'',desc:'Action 2'}, action3:{key:'3',anim:'',desc:'Action 3'}, action4:{key:'4',anim:'',desc:'Action 4'},
  };
  if (!props.cameraFollow) props.cameraFollow = false;
  if (!props.cameraDistance) props.cameraDistance = 8;
  if (!props.cameraHeight) props.cameraHeight = 6;
  if (props.walkOnSurfaces === undefined) props.walkOnSurfaces = true;

  entry.playerState = { active:true, moving:false, running:false, currentAnim:'', clickTarget:null };
  activePlayerEntry = entry;

  // Active indicator (plumbob/circle)
  createActiveIndicator(entry);

  const md = animationMixers.get(entry);
  const idleAnim = props.keyBindings.idle.anim || props.defaultAnimation || '';
  if (md && idleAnim && md.actions[idleAnim]) { playAnimation(entry, idleAnim); entry.playerState.currentAnim = idleAnim; }
}

function updatePlayer(dt) {
  if (!activePlayerEntry) return;
  const entry = activePlayerEntry;
  const ps = entry.playerState;
  if (!ps || !ps.active) return;

  const props = entry.properties, mesh = entry.mesh, cfg = entry.config;
  const cfgRotY = THREE.MathUtils.degToRad(cfg.rotation[1]);
  const md = animationMixers.get(entry);
  const kb = props.keyBindings;
  const useKeyboard = props.controlType === 'keyboard' || props.controlType === 'both';
  const useClick = props.controlType === 'clickmove' || props.controlType === 'both';

  let moved = false, targetAnim = kb.idle.anim || '';

  // Stuck detection for click-to-move: if position barely changed since last frame, player is blocked
  if (useClick && ps.clickTarget && ps._prevClickPos) {
    const pdx = mesh.position.x - ps._prevClickPos.x;
    const pdz = mesh.position.z - ps._prevClickPos.z;
    const movedDist = Math.sqrt(pdx*pdx + pdz*pdz);
    if (movedDist < 0.005) {
      ps._stuckFrames = (ps._stuckFrames || 0) + 1;
      if (ps._stuckFrames > 3) {
        // Player is stuck — cancel click target and go idle
        ps.clickTarget = null;
        ps._stuckFrames = 0;
        if (clickTargetMarker) clickTargetMarker.visible = false;
      }
    } else {
      ps._stuckFrames = 0;
    }
  }
  // Record position before this frame's movement for next frame's stuck check
  if (useClick && ps.clickTarget) {
    ps._prevClickPos = { x: mesh.position.x, z: mesh.position.z };
  } else {
    ps._prevClickPos = null;
    ps._stuckFrames = 0;
  }

  // Keyboard movement
  if (useKeyboard) {
    const isRunning = playerKeys.shift && kb.run.anim;
    const speed = isRunning ? (props.runSpeed||6) : (props.moveSpeed||3);
    const rotSpeed = props.rotateSpeed || 3;
    if (playerKeys.a) mesh.rotation.y += rotSpeed * dt;
    if (playerKeys.d) mesh.rotation.y -= rotSpeed * dt;
    if (playerKeys.w || playerKeys.s) {
      const direction = playerKeys.w ? 1 : -1;
      const angle = mesh.rotation.y - cfgRotY;
      mesh.position.x += Math.sin(angle)*speed*dt*direction;
      mesh.position.z += Math.cos(angle)*speed*dt*direction;
      moved = true;
      if (isRunning && kb.run.anim) targetAnim = kb.run.anim;
      else targetAnim = playerKeys.w ? (kb.forward.anim||targetAnim) : (kb.backward.anim||targetAnim);
    }
  }

  // Click-to-move
  if (useClick && ps.clickTarget) {
    const target = ps.clickTarget;
    const dir = new THREE.Vector3(target.x-mesh.position.x, 0, target.z-mesh.position.z);
    const dist = dir.length();
    if (dist < 0.15) {
      ps.clickTarget = null; if (clickTargetMarker) clickTargetMarker.visible = false;
    } else {
      dir.normalize(); const speed = props.clickMoveSpeed||3;
      const step = Math.min(speed*dt, dist);
      mesh.position.x += dir.x*step; mesh.position.z += dir.z*step;
      mesh.rotation.y = Math.atan2(dir.x, dir.z) + cfgRotY;
      if (clickTargetMarker && clickTargetMarker.visible) {
        clickTargetMarker.rotation.z += dt*2;
        clickTargetMarker.material.opacity = 0.3+0.2*Math.sin(Date.now()*0.005);
      }
      moved = true;
      targetAnim = kb.clickmove.anim || kb.forward.anim || targetAnim;
    }
  }

  // Animation switching
  if (md && targetAnim !== ps.currentAnim) {
    if (targetAnim && md.actions[targetAnim]) playAnimation(entry, targetAnim);
    else if (!moved && kb.idle.anim && md.actions[kb.idle.anim]) { playAnimation(entry, kb.idle.anim); targetAnim = kb.idle.anim; }
    ps.currentAnim = targetAnim;
  }

  // Boundary enforcement
  if (moved && props.boundaryPoly && props.boundaryPoly.length >= 3) {
    const clamped = clampToBoundaryPoly(mesh.position.x, mesh.position.z, props.boundaryPoly);
    mesh.position.x = clamped.x; mesh.position.z = clamped.z;
  }

  // Surface following
  if (props.walkOnSurfaces) {
    const surfY = getSurfaceYAt(mesh.position.x, mesh.position.z, mesh.position.y, mesh.position.y);
    const targetY = surfY + (cfg.position[1]||0);
    mesh.position.y += (targetY - mesh.position.y) * Math.min(1, 10*dt);
    if (clickTargetMarker && clickTargetMarker.visible) clickTargetMarker.position.y = mesh.position.y + 0.02;
  }

  // Camera follow
  if (props.cameraFollow) {
    const mode = props.cameraFollowMode || 'behind';
    const lockOrbit = props.cameraLockOrbit;

    if (mode === 'initial') {
      // Keep Initial View: preserve starting camera offset
      if (!entry._camInitOffset) {
        entry._camInitOffset = camera.position.clone().sub(mesh.position);
        entry._camInitTarget = controls.target.clone().sub(mesh.position);
      }
      if (lockOrbit) {
        // Locked: rigidly maintain initial view
        camera.position.copy(mesh.position.clone().add(entry._camInitOffset));
        controls.target.copy(mesh.position.clone().add(entry._camInitTarget));
        controls.enableRotate = false;
      } else {
        // Unlocked: translate camera with player movement, allow free orbit
        controls.enableRotate = true;
        controls.target.lerp(mesh.position.clone().add(entry._camInitTarget), 5*dt);
        if (entry._lastFollowPos) {
          const delta = new THREE.Vector3().subVectors(mesh.position, entry._lastFollowPos);
          camera.position.add(delta);
        }
      }
    } else {
      const camDist = props.cameraDistance||8, camH = props.cameraHeight||6;
      const offX = props.cameraOffsetX || 0;
      const pitchRad = THREE.MathUtils.degToRad(props.cameraPitch || 0);
      const hDist = camDist * Math.cos(pitchRad);
      const vOff = camDist * Math.sin(pitchRad);

      // Target always follows player
      const targetPos = mesh.position.clone();
      targetPos.y += (props.cameraTargetOffsetY || 0);
      controls.target.lerp(targetPos, 5*dt);

      if (lockOrbit) {
        let camX, camZ;
        if (mode === 'fixed') {
          camX = mesh.position.x + offX;
          camZ = mesh.position.z + hDist;
        } else {
          const angle = mesh.rotation.y - cfgRotY;
          const right = angle + Math.PI / 2;
          camX = mesh.position.x - Math.sin(angle)*hDist + Math.sin(right)*offX;
          camZ = mesh.position.z - Math.cos(angle)*hDist + Math.cos(right)*offX;
        }
        camera.position.lerp(new THREE.Vector3(camX, mesh.position.y + camH + vOff, camZ), 3*dt);
        controls.enableRotate = false;
      } else {
        controls.enableRotate = true;
        if (entry._lastFollowPos) {
          const delta = new THREE.Vector3().subVectors(mesh.position, entry._lastFollowPos);
          camera.position.add(delta);
        }
      }
    }
    entry._lastFollowPos = mesh.position.clone();
  }
}




/* ═══════════════════════════════════════════
   DRIVEABLE VEHICLE CONTROLLER
   ═══════════════════════════════════════════ */
let activeDriveableEntry = null;
let driveKeys = { w:false, a:false, s:false, d:false };
let driveCustomKeys = {}; // { 'keyLower': true/false }
window.driveKeys = driveKeys;

function initDriveableController(entry) {
  if (entry.config.category !== 'driveable') return;
  const props = entry.properties;
  if (!props.acceleration) props.acceleration = 8;
  if (!props.maxSpeed) props.maxSpeed = 15;
  if (!props.brakeForce) props.brakeForce = 12;
  if (!props.reverseMaxSpeed) props.reverseMaxSpeed = 5;
  if (!props.turnSpeed) props.turnSpeed = 2.5;
  if (!props.friction) props.friction = 3;
  if (!props.steerAngle) props.steerAngle = 30;
  if (props.walkOnSurfaces === undefined) props.walkOnSurfaces = true;
  if (!props.cameraFollow) props.cameraFollow = true;
  if (!props.cameraDistance) props.cameraDistance = 12;
  if (!props.cameraHeight) props.cameraHeight = 6;
  if (!props.keyBindings) props.keyBindings = {
    accelerate:{key:'W',anim:'',desc:'Accelerate'}, brake:{key:'S',anim:'',desc:'Brake/Reverse'},
    left:{key:'A',anim:'',desc:'Steer Left'}, right:{key:'D',anim:'',desc:'Steer Right'},
    horn:{key:'H',anim:'',desc:'Horn'}, action1:{key:'1',anim:'',desc:'Action 1'}, action2:{key:'2',anim:'',desc:'Action 2'},
  };

  entry.driveState = { active:true, speed:0, steerAngle:0, currentAnim:'', engineAudio:null, brakeAudio:null };
  activeDriveableEntry = entry;

  // Active indicator
  createActiveIndicator(entry);

  // Setup engine sound loop
  if (props.engineSound) {
    try {
      const audio = new Audio(props.engineSound);
      audio.loop = true; audio.volume = 0;
      entry.driveState.engineAudio = audio;
    } catch(e) {}
  }
}

function updateDriveable(dt) {
  if (!activeDriveableEntry) return;
  const entry = activeDriveableEntry;
  const ds = entry.driveState;
  if (!ds || !ds.active) return;
  const props = entry.properties, mesh = entry.mesh, cfg = entry.config;
  const cfgRotY = THREE.MathUtils.degToRad(cfg.rotation[1]);
  const md = animationMixers.get(entry);
  const kb = props.keyBindings || {};

  const accelForce = props.acceleration || 8;
  const maxSpd = props.maxSpeed || 15;
  const brakeF = props.brakeForce || 12;
  const revMax = props.reverseMaxSpeed || 5;
  const turnSpd = props.turnSpeed || 2.5;
  const friction = props.friction || 3;
  const maxSteer = THREE.MathUtils.degToRad(props.steerAngle || 30);

  // Acceleration / braking
  if (driveKeys.w) {
    ds.speed += accelForce * dt;
    if (ds.speed > maxSpd) ds.speed = maxSpd;
  } else if (driveKeys.s) {
    if (ds.speed > 0.1) {
      ds.speed -= brakeF * dt; // braking
      if (ds.speed < 0) ds.speed = 0;
    } else {
      ds.speed -= accelForce * 0.5 * dt; // reverse
      if (ds.speed < -revMax) ds.speed = -revMax;
    }
  } else {
    // Coast / friction
    if (Math.abs(ds.speed) < 0.05) ds.speed = 0;
    else ds.speed -= Math.sign(ds.speed) * friction * dt;
  }

  // Steering
  const steerInput = (driveKeys.a ? 1 : 0) - (driveKeys.d ? 1 : 0);
  const speedFactor = Math.min(1, Math.abs(ds.speed) / 3); // less turning at low speed
  ds.steerAngle = steerInput * maxSteer * speedFactor;

  // Apply movement
  if (Math.abs(ds.speed) > 0.01) {
    const turnAmount = ds.steerAngle * (ds.speed > 0 ? 1 : -1) * dt;
    mesh.rotation.y += turnAmount;
    const angle = mesh.rotation.y - cfgRotY;
    mesh.position.x += Math.sin(angle) * ds.speed * dt;
    mesh.position.z += Math.cos(angle) * ds.speed * dt;
  }

  // Wheel mesh rotation (visual only)
  if (props.wheelMeshNames) {
    const names = props.wheelMeshNames.split(',').map(s => s.trim()).filter(Boolean);
    mesh.traverse(child => {
      if (child.isMesh && names.includes(child.name)) {
        child.rotation.x += ds.speed * dt * 3; // wheel spin
      }
    });
  }

  // Animations based on state
  let targetAnim = '';
  if (Math.abs(ds.speed) > 0.5 && kb.accelerate && kb.accelerate.anim) targetAnim = kb.accelerate.anim;
  if (driveKeys.s && ds.speed <= 0.1 && kb.brake && kb.brake.anim) targetAnim = kb.brake.anim;
  if (md && targetAnim && targetAnim !== ds.currentAnim) {
    playAnimation(entry, targetAnim); ds.currentAnim = targetAnim;
  }

  // Custom key actions (action1, action2, etc.)
  for (const [bindName, bind] of Object.entries(kb)) {
    if (!bindName.startsWith('action') || !bind.key || !bind.anim) continue;
    if (driveCustomKeys[bind.key.toLowerCase()]) {
      if (md && ds.currentAnim !== bind.anim) { playAnimation(entry, bind.anim); ds.currentAnim = bind.anim; }
    }
  }

  // Engine sound
  if (ds.engineAudio) {
    const vol = Math.min(1, Math.abs(ds.speed) / maxSpd * 0.8 + (driveKeys.w ? 0.2 : 0));
    ds.engineAudio.volume = vol;
    const rate = 0.6 + Math.abs(ds.speed) / maxSpd * 1.0;
    ds.engineAudio.playbackRate = Math.min(2, rate);
    if (vol > 0 && ds.engineAudio.paused) ds.engineAudio.play().catch(()=>{});
    if (vol <= 0 && !ds.engineAudio.paused) ds.engineAudio.pause();
  }

  // Horn
  if (driveCustomKeys[(kb.horn?.key||'h').toLowerCase()] && props.hornSound && !ds._hornPlaying) {
    ds._hornPlaying = true;
    const horn = new Audio(props.hornSound); horn.play().catch(()=>{});
    horn.onended = () => { ds._hornPlaying = false; };
  }

  // Brake sound
  if (driveKeys.s && ds.speed > 2 && props.brakeSound && !ds._brakePlaying) {
    ds._brakePlaying = true;
    const brk = new Audio(props.brakeSound); brk.play().catch(()=>{});
    brk.onended = () => { ds._brakePlaying = false; };
  }

  // Surface following
  if (props.walkOnSurfaces) {
    const surfY = getSurfaceYAt(mesh.position.x, mesh.position.z, mesh.position.y, mesh.position.y);
    const targetY = surfY + (cfg.position[1]||0);
    mesh.position.y += (targetY - mesh.position.y) * Math.min(1, 10*dt);
  }

  // Boundary enforcement for driveable
  if (props.boundaryPoly && props.boundaryPoly.length >= 3) {
    const clamped = clampToBoundaryPoly(mesh.position.x, mesh.position.z, props.boundaryPoly);
    if (clamped.x !== mesh.position.x || clamped.z !== mesh.position.z) {
      mesh.position.x = clamped.x; mesh.position.z = clamped.z;
      ds.speed *= 0.3;
    }
  }

  // Camera follow
  if (props.cameraFollow) {
    const mode = props.cameraFollowMode || 'behind';
    const lockOrbit = props.cameraLockOrbit;

    if (mode === 'initial') {
      if (!entry._camInitOffset) {
        entry._camInitOffset = camera.position.clone().sub(mesh.position);
        entry._camInitTarget = controls.target.clone().sub(mesh.position);
      }
      if (lockOrbit) {
        camera.position.copy(mesh.position.clone().add(entry._camInitOffset));
        controls.target.copy(mesh.position.clone().add(entry._camInitTarget));
        controls.enableRotate = false;
      } else {
        controls.enableRotate = true;
        controls.target.lerp(mesh.position.clone().add(entry._camInitTarget), 5*dt);
        if (entry._lastFollowPos) {
          const delta = new THREE.Vector3().subVectors(mesh.position, entry._lastFollowPos);
          camera.position.add(delta);
        }
      }
    } else {
      const camDist = props.cameraDistance||12, camH = props.cameraHeight||6;
      const offX = props.cameraOffsetX || 0;
      const pitchRad = THREE.MathUtils.degToRad(props.cameraPitch || 0);
      const hDist = camDist * Math.cos(pitchRad);
      const vOff = camDist * Math.sin(pitchRad);

      const targetPos = mesh.position.clone();
      targetPos.y += (props.cameraTargetOffsetY || 0);
      controls.target.lerp(targetPos, 5*dt);

      if (lockOrbit) {
        let camX, camZ;
        if (mode === 'fixed') {
          camX = mesh.position.x + offX;
          camZ = mesh.position.z + hDist;
        } else {
          const angle = mesh.rotation.y - cfgRotY;
          const right = angle + Math.PI / 2;
          camX = mesh.position.x - Math.sin(angle)*hDist + Math.sin(right)*offX;
          camZ = mesh.position.z - Math.cos(angle)*hDist + Math.cos(right)*offX;
        }
        camera.position.lerp(new THREE.Vector3(camX, mesh.position.y + camH + vOff, camZ), 3*dt);
        controls.enableRotate = false;
      } else {
        controls.enableRotate = true;
        if (entry._lastFollowPos) {
          const delta = new THREE.Vector3().subVectors(mesh.position, entry._lastFollowPos);
          camera.position.add(delta);
        }
      }
    }
    entry._lastFollowPos = mesh.position.clone();
  }
}

// Keyboard listeners
window.addEventListener('keydown', e => {
  const k = e.key.toLowerCase();
  // Player keys
  if (activePlayerEntry) {
    if (k==='w') playerKeys.w=true; if (k==='a') playerKeys.a=true;
    if (k==='s') playerKeys.s=true; if (k==='d') playerKeys.d=true;
    if (k==='shift') playerKeys.shift=true;
    // Player custom action keys (action1-4)
    const pkb = activePlayerEntry.properties.keyBindings || {};
    for (const [name, bind] of Object.entries(pkb)) {
      if (name.startsWith('action') && bind.key && k === bind.key.toLowerCase()) {
        const md = animationMixers.get(activePlayerEntry);
        if (md && bind.anim) playAnimation(activePlayerEntry, bind.anim);
      }
    }
  }
  // Driveable keys
  if (activeDriveableEntry) {
    if (k==='w') driveKeys.w=true; if (k==='a') driveKeys.a=true;
    if (k==='s') driveKeys.s=true; if (k==='d') driveKeys.d=true;
    driveCustomKeys[k] = true;
  }
});
window.addEventListener('keyup', e => {
  const k = e.key.toLowerCase();
  if (k==='w') playerKeys.w=false; if (k==='a') playerKeys.a=false;
  if (k==='s') playerKeys.s=false; if (k==='d') playerKeys.d=false;
  if (k==='shift') playerKeys.shift=false;
  // Driveable
  if (k==='w') driveKeys.w=false; if (k==='a') driveKeys.a=false;
  if (k==='s') driveKeys.s=false; if (k==='d') driveKeys.d=false;
  delete driveCustomKeys[k];
  // Reset player to idle
  if (activePlayerEntry && activePlayerEntry.playerState) {
    if (!playerKeys.w && !playerKeys.s && !playerKeys.a && !playerKeys.d) {
      const ps = activePlayerEntry.playerState, kb = activePlayerEntry.properties.keyBindings;
      const md = animationMixers.get(activePlayerEntry);
      const idleAnim = kb.idle.anim || '';
      if (md && idleAnim && md.actions[idleAnim] && ps.currentAnim !== idleAnim) {
        playAnimation(activePlayerEntry, idleAnim); ps.currentAnim = idleAnim;
      }
    }
  }
});

// Click-to-move: all clicks go to player movement in game mode
function raycastGround(cx, cy) {
  pointer.x = (cx/innerWidth)*2-1; pointer.y = -(cy/innerHeight)*2+1;
  raycaster.setFromCamera(pointer, camera);
  const hits = raycaster.intersectObject(ground);
  return hits.length ? hits[0].point : null;
}

function raycastAtY(cx, cy, yHeight) {
  pointer.x = (cx/innerWidth)*2-1; pointer.y = -(cy/innerHeight)*2+1;
  raycaster.setFromCamera(pointer, camera);
  const plane = new THREE.Plane(new THREE.Vector3(0,1,0), -yHeight);
  const target = new THREE.Vector3();
  const hit = raycaster.ray.intersectPlane(plane, target);
  if (hit) return hit;
  const gHits = raycaster.intersectObject(ground);
  return gHits.length ? gHits[0].point : null;
}

// ── onClick model detection ──
function raycastModels(cx, cy) {
  pointer.x = (cx/innerWidth)*2-1; pointer.y = -(cy/innerHeight)*2+1;
  raycaster.setFromCamera(pointer, camera);
  const meshes = [];
  placedItems.forEach(e => { if (e.mesh.visible && e.properties.onClickActions && e.properties.onClickActions.length && e.properties.onClickActions[0].type !== 'nothing') e.mesh.traverse(c => { if (c.isMesh) { c.userData._ownerEntry = e; meshes.push(c); } }); });
  const hits = raycaster.intersectObjects(meshes, false);
  return hits.length ? hits[0].object.userData._ownerEntry : null;
}

renderer.domElement.addEventListener('pointerup', e => {
  if (e.button !== 0) return;
  // Check onClick models first
  const clickedEntry = raycastModels(e.clientX, e.clientY);
  if (clickedEntry) {
    const actions = clickedEntry.properties.onClickActions || [];
    const colKey = `click:${clickedEntry.objectId}`;
    const other = activePlayerEntry || activeDriveableEntry || clickedEntry;
    let cumDelay = 0;
    actions.forEach((act, ai) => {
      cumDelay += (ai === 0 ? 0 : (act.delay || 0));
      const d = cumDelay;
      if (d === 0) executeSingleAction(act, clickedEntry, other, colKey);
      else setTimeout(() => executeSingleAction(act, clickedEntry, other, colKey), d);
    });
    return;
  }
  // Click-to-move fallback
  if (!activePlayerEntry || !activePlayerEntry.playerState) return;
  const props = activePlayerEntry.properties;
  if (props.controlType !== 'clickmove' && props.controlType !== 'both') return;

  const playerY = activePlayerEntry.mesh.position.y;
  const pt = playerY > 0.2 ? raycastAtY(e.clientX, e.clientY, playerY) : raycastGround(e.clientX, e.clientY);
  if (!pt) return;

  let tx = pt.x, tz = pt.z;
  if (props.boundaryPoly && props.boundaryPoly.length >= 3) {
    const clamped = clampToBoundaryPoly(tx, tz, props.boundaryPoly); tx = clamped.x; tz = clamped.z;
  }
  activePlayerEntry.playerState.clickTarget = { x:tx, z:tz };
  const marker = createClickTargetMarker();
  marker.position.set(tx, playerY+0.02, tz); marker.visible = true;
  const kb = props.keyBindings, md2 = animationMixers.get(activePlayerEntry);
  const moveAnim = kb.clickmove.anim || kb.forward.anim || '';
  if (md2 && moveAnim && md2.actions[moveAnim]) { playAnimation(activePlayerEntry, moveAnim); activePlayerEntry.playerState.currentAnim = moveAnim; }
});

// ── Force/Velocity System ──
function applyForceToEntry(entry, forceVec) {
  if (!entry._velocity) entry._velocity = new THREE.Vector3();
  entry._velocity.add(forceVec);
}

function updateForces(dt) {
  for (const entry of placedItems) {
    if (!entry._velocity || entry._velocity.lengthSq() < 0.0001) continue;
    entry.mesh.position.add(entry._velocity.clone().multiplyScalar(dt));
    entry._velocity.multiplyScalar(Math.max(0, 1 - 3 * dt));
    if (entry._velocity.lengthSq() < 0.001) entry._velocity.set(0,0,0);
  }
}

/* ═══════════════════════════════════════════
   BODY COLLISION (RIGIDBODY)
   Prevents Player and NPCs from overlapping
   ═══════════════════════════════════════════ */
const BODY_RADIUS = 0.35; // collision radius for characters

function getEffectiveColliderRadius(entry) {
  const r = entry.properties.colliderRadius || 1;
  // Scale collider radius by model's world scale
  const ws = new THREE.Vector3();
  entry.mesh.getWorldScale(ws);
  const avgScale = (Math.abs(ws.x) + Math.abs(ws.z)) / 2;
  return r * avgScale;
}

function resolveBodyCollisions() {
  // Collect movable bodies: player, NPCs, and driveables
  const bodies = [];
  placedItems.forEach(e => {
    if ((e.config.category === 'player' || e.config.category === 'npc' || e.config.category === 'driveable') && e.mesh.visible) {
      bodies.push(e);
    }
  });
  // Collect collidable objects (non-player, non-npc with collider)
  const collidables = [];
  placedItems.forEach(e => {
    if (e.properties.colliderEnabled && e.mesh.visible &&
        e.config.category !== 'player' && e.config.category !== 'npc' && e.config.category !== 'driveable') {
      collidables.push(e);
    }
  });
  // Player/NPC vs collidable objects — handle body mode
  for (const body of bodies) {
    for (const obj of collidables) {
      const mode = obj.properties.colliderBodyMode || 'solid';
      if (mode === 'passthrough') continue;
      const dx = body.mesh.position.x - obj.mesh.position.x;
      const dz = body.mesh.position.z - obj.mesh.position.z;
      const dist = Math.sqrt(dx*dx + dz*dz);
      const minDist = getEffectiveColliderRadius(obj);
      if (dist < minDist && dist > 0.001) {
        const nx = dx/dist, nz = dz/dist;
        if (mode === 'pushable') {
          obj.mesh.position.x -= nx * (minDist - dist);
          obj.mesh.position.z -= nz * (minDist - dist);
        } else {
          body.mesh.position.x = obj.mesh.position.x + nx * minDist;
          body.mesh.position.z = obj.mesh.position.z + nz * minDist;
        }
      }
    }
  }
  if (bodies.length < 2) return;

  // Pairwise push-apart
  for (let i = 0; i < bodies.length; i++) {
    for (let j = i + 1; j < bodies.length; j++) {
      const a = bodies[i], b = bodies[j];
      const ax = a.mesh.position.x, az = a.mesh.position.z;
      const bx = b.mesh.position.x, bz = b.mesh.position.z;
      const dx = ax - bx, dz = az - bz;
      const dist = Math.sqrt(dx * dx + dz * dz);
      const aRad = (a.properties.colliderEnabled && a.properties.colliderRadius) ? getEffectiveColliderRadius(a) : BODY_RADIUS;
      const bRad = (b.properties.colliderEnabled && b.properties.colliderRadius) ? getEffectiveColliderRadius(b) : BODY_RADIUS;
      const minDist = aRad + bRad;

      if (dist < minDist && dist > 0.001) {
        const overlap = (minDist - dist) / 2;
        const nx = dx / dist, nz = dz / dist;

        // Check body modes — respect collider settings on NPCs
        const aMode = a.properties.colliderBodyMode || (a.config.category === 'npc' && a.properties.colliderEnabled ? 'solid' : 'pushable');
        const bMode = b.properties.colliderBodyMode || (b.config.category === 'npc' && b.properties.colliderEnabled ? 'solid' : 'pushable');

        // Passthrough — skip
        if (aMode === 'passthrough' || bMode === 'passthrough') continue;

        const aIsPriority = a.config.category === 'player' || a.config.category === 'driveable';
        const bIsPriority = b.config.category === 'player' || b.config.category === 'driveable';

        if (aIsPriority && !bIsPriority) {
          if (bMode === 'solid') {
            // NPC is solid — block the player, push player back
            a.mesh.position.x += nx * overlap * 2;
            a.mesh.position.z += nz * overlap * 2;
            // Cancel click-to-move target so player stops immediately
            if (a.playerState && a.playerState.clickTarget) { a.playerState.clickTarget = null; if (clickTargetMarker) clickTargetMarker.visible = false; }
            if (a.driveState) a.driveState.speed *= 0.3;
          } else {
            // NPC is pushable — push NPC away
            b.mesh.position.x -= nx * overlap * 2;
            b.mesh.position.z -= nz * overlap * 2;
          }
        } else if (bIsPriority && !aIsPriority) {
          if (aMode === 'solid') {
            // NPC is solid — block the player/driveable, push it back
            b.mesh.position.x -= nx * overlap * 2;
            b.mesh.position.z -= nz * overlap * 2;
            if (b.playerState && b.playerState.clickTarget) { b.playerState.clickTarget = null; if (clickTargetMarker) clickTargetMarker.visible = false; }
            if (b.driveState) b.driveState.speed *= 0.3;
          } else {
            // NPC is pushable — push NPC away
            a.mesh.position.x += nx * overlap * 2;
            a.mesh.position.z += nz * overlap * 2;
          }
        } else {
          // Both same priority (NPC vs NPC) — push equally
          a.mesh.position.x += nx * overlap;
          a.mesh.position.z += nz * overlap;
          b.mesh.position.x -= nx * overlap;
          b.mesh.position.z -= nz * overlap;
        }
      }
    }
  }
}

/* ═══════════════════════════════════════════
   ROAD & VEHICLE SYSTEM
   ═══════════════════════════════════════════ */
function rebuildRoadGraph() {
  roadGraph.clear();
  const dirs = [[1,0],[-1,0],[0,1],[0,-1]];
  for (const key of roadCells) {
    const [gx,gz] = key.split(',').map(Number);
    const neighbors = new Set();
    for (const [dx,dz] of dirs) { const nk = ck(gx+dx, gz+dz); if (roadCells.has(nk)) neighbors.add(nk); }
    roadGraph.set(key, neighbors);
  }
}

const DIR_VEC = { N:[0,-1], S:[0,1], E:[1,0], W:[-1,0] };
const DIR_OPP = { N:'S', S:'N', E:'W', W:'E' };
const EDGE_POS = { N:[0,-0.5], S:[0,0.5], E:[0.5,0], W:[-0.5,0] };
const LANE_PERP = { N:[-1,0], S:[1,0], E:[0,-1], W:[0,1] };
const LANE_WIDTH = 0.20;
const DIR_ANGLE = { S:0, N:Math.PI, E:-Math.PI/2, W:Math.PI/2 };
const ROAD_BASE_EXITS = { road1:['N','S'], road2:['S','E'], road3:['N','S','E','W'] };
const VEHICLE_SPEED = 1.2;

function rotateDir(dir, deg) { const dirs=['N','E','S','W']; return dirs[((dirs.indexOf(dir)-(((deg%360)+360)%360)/90)%4+4)%4]; }
function getRoadExits(roadId, rotDeg) { const base = ROAD_BASE_EXITS[roadId.replace(/\d+$/,'')+(roadId.match(/\d+$/)?.[0]||'')]; return base ? base.map(d=>rotateDir(d,rotDeg)) : []; }
function getRoadAt(gx,gz) { if (!roadCells.has(ck(gx,gz))) return null; return placedItems.find(e=>e.config.category==='road'&&e.gridCells.some(([cx,cz])=>cx===gx&&cz===gz)); }
function getExitsAt(gx,gz) { const road=getRoadAt(gx,gz); return road ? getRoadExits(road.config.id, road.rotationDeg) : []; }
function getLaneOffForHeading(heading) { const p=LANE_PERP[heading]; return [p[0]*LANE_WIDTH, p[1]*LANE_WIDTH]; }
function quadBezier(p0,p1,p2,t) { const mt=1-t; return [mt*mt*p0[0]+2*mt*t*p1[0]+t*t*p2[0], mt*mt*p0[1]+2*mt*t*p1[1]+t*t*p2[1]]; }
function rotToHeading(rotDeg) { const r=((rotDeg%360)+360)%360; if(r===0)return 'S'; if(r===90)return 'W'; if(r===180)return 'N'; return 'E'; }

function pickVehicleLane(gx,gz) {
  const lanes = vehicleLanes.get(ck(gx,gz)) || {a:null,b:null};
  if (!lanes.a) return 'a'; if (!lanes.b) return 'b'; return null;
}
function getVehicleLaneOffset(lane, rotDeg) { return getLaneOffForHeading(rotToHeading(rotDeg)); }

function computePath(entry) {
  const vs = entry.vehicleState;
  const [gx,gz] = vs.currentCell, [wx,wz] = g2w(gx,gz);
  const dv = DIR_VEC[vs.nextDir], ngx=gx+dv[0], ngz=gz+dv[1], [nwx,nwz]=g2w(ngx,ngz);
  const startLane=getLaneOffForHeading(vs.heading), endLane=getLaneOffForHeading(vs.nextDir);
  const startPt=[wx+startLane[0],wz+startLane[1]], endPt=[nwx+endLane[0],nwz+endLane[1]];
  const isTurn = vs.heading!==vs.nextDir && DIR_OPP[vs.heading]!==vs.nextDir;
  let controlPt;
  if (isTurn) { const ee=EDGE_POS[vs.nextDir]; controlPt=[wx+ee[0]+startLane[0]*0.5+endLane[0]*0.5, wz+ee[1]+startLane[1]*0.5+endLane[1]*0.5]; }
  else controlPt = [(startPt[0]+endPt[0])/2, (startPt[1]+endPt[1])/2];
  vs.pathStart=startPt; vs.pathControl=controlPt; vs.pathEnd=endPt;
  vs.startAngle=DIR_ANGLE[vs.heading]; vs.endAngle=DIR_ANGLE[vs.nextDir];
}

function computeNextMove(entry) {
  const vs = entry.vehicleState, [gx,gz]=vs.currentCell;
  const exits = getExitsAt(gx,gz); if (exits.length===0) { vs.moving=false; return; }
  let available = exits.filter(d=>d!==vs.cameFrom);
  const validExits = available.filter(d => { const dv=DIR_VEC[d]; return getExitsAt(gx+dv[0],gz+dv[1]).includes(DIR_OPP[d]); });
  if (validExits.length>0) vs.nextDir = validExits[Math.floor(Math.random()*validExits.length)];
  else if (vs.cameFrom && exits.includes(vs.cameFrom)) { const dv=DIR_VEC[vs.cameFrom]; if (getExitsAt(gx+dv[0],gz+dv[1]).includes(DIR_OPP[vs.cameFrom])) vs.nextDir=vs.cameFrom; else { vs.moving=false; return; } }
  else { vs.moving=false; return; }
  vs.progress = 0; computePath(entry);
}

function initVehicleMovement(entry) {
  const [gx,gz] = entry.gridCells[0];
  let heading = rotToHeading(entry.rotationDeg);
  const exits = getExitsAt(gx,gz); if (exits.length===0) return;
  if (!exits.includes(heading)) heading = exits[0];
  const cfg = entry.config;
  entry.mesh.rotation.y = DIR_ANGLE[heading] + THREE.MathUtils.degToRad(cfg.rotation[1]);
  const [wx,wz] = g2w(gx,gz), laneOff = getLaneOffForHeading(heading);
  entry.mesh.position.set(wx+cfg.position[0]+laneOff[0], cfg.position[1], wz+cfg.position[2]+laneOff[1]);
  entry.vehicleState = { moving:true, heading, cameFrom:DIR_OPP[heading], currentCell:[gx,gz], nextDir:null, progress:0, speed:VEHICLE_SPEED*(0.7+Math.random()*0.6), pathStart:null, pathControl:null, pathEnd:null, startAngle:DIR_ANGLE[heading], endAngle:DIR_ANGLE[heading] };
  computeNextMove(entry);
}

function updateVehicles(dt) {
  for (const entry of placedItems) {
    if (entry.config.category !== 'vehicle') continue;
    const vs = entry.vehicleState; if (!vs || !vs.moving || !vs.pathStart) continue;
    vs.progress += vs.speed*dt; const t = Math.min(vs.progress,1);
    const [px2,pz2] = quadBezier(vs.pathStart, vs.pathControl, vs.pathEnd, t);
    const cfg = entry.config;
    entry.mesh.position.x = px2+cfg.position[0]; entry.mesh.position.z = pz2+cfg.position[2];
    const dt2=0.02, t0=Math.max(0,t-dt2), t1=Math.min(1,t+dt2);
    const [px0,pz0]=quadBezier(vs.pathStart,vs.pathControl,vs.pathEnd,t0);
    const [px1,pz1]=quadBezier(vs.pathStart,vs.pathControl,vs.pathEnd,t1);
    const dx2=px1-px0, dz2=pz1-pz0;
    if (dx2!==0||dz2!==0) entry.mesh.rotation.y = Math.atan2(dx2,dz2) + THREE.MathUtils.degToRad(cfg.rotation[1]);
    if (vs.progress >= 1) {
      const dv=DIR_VEC[vs.nextDir]; const [ogx,ogz]=vs.currentCell;
      const ngx2=ogx+dv[0], ngz2=ogz+dv[1];
      vs.heading=vs.nextDir; entry.gridCells=[[ngx2,ngz2]];
      const oldKey=ck(ogx,ogz), newKey=ck(ngx2,ngz2);
      if (entry.lane) { const oldLanes=vehicleLanes.get(oldKey); if (oldLanes&&oldLanes[entry.lane]===entry) oldLanes[entry.lane]=null; }
      const newLanes=vehicleLanes.get(newKey)||{a:null,b:null};
      if (!vehicleLanes.has(newKey)) vehicleLanes.set(newKey,newLanes);
      if (!newLanes.a||newLanes.a===entry) { entry.lane='a'; newLanes.a=entry; } else if (!newLanes.b||newLanes.b===entry) { entry.lane='b'; newLanes.b=entry; }
      vs.currentCell=[ngx2,ngz2]; vs.cameFrom=DIR_OPP[vs.nextDir]; vs.progress=0; computeNextMove(entry);
    }
  }
}

/* ═══════════════════════════════════════════
   ZONE ANIMATION
   ═══════════════════════════════════════════ */
function updateZoneAnimations(time) {
  placedItems.forEach(entry => {
    if (entry.config.modelType !== 'marker') return;
    entry.mesh.traverse(c => { if (c.userData && c.userData.isZoneDiamond) { c.rotation.y=time*1.5; c.position.y=1.35+Math.sin(time*2)*0.08; } });
  });
}

/* ═══════════════════════════════════════════
   TRANSFORM SYSTEM
   ═══════════════════════════════════════════ */
function recordBaseTransform(entry) {
  entry.basePosition = entry.mesh.position.clone();
  entry.baseRotation = entry.mesh.rotation.clone();
  entry.baseScale = entry.mesh.scale.clone();
  if (!entry.transformAdj) entry.transformAdj = { posOffset:[0,0,0], rotOffset:[0,0,0], scaleOffset:[1,1,1] };
}

function applyTransformAdj(entry) {
  if (!entry.transformAdj || !entry.basePosition) return;
  const adj = entry.transformAdj, m = entry.mesh;
  m.position.set(entry.basePosition.x+adj.posOffset[0], entry.basePosition.y+adj.posOffset[1], entry.basePosition.z+adj.posOffset[2]);
  m.rotation.set(entry.baseRotation.x+THREE.MathUtils.degToRad(adj.rotOffset[0]), entry.baseRotation.y+THREE.MathUtils.degToRad(adj.rotOffset[1]), entry.baseRotation.z+THREE.MathUtils.degToRad(adj.rotOffset[2]));
  m.scale.set(entry.baseScale.x*adj.scaleOffset[0], entry.baseScale.y*adj.scaleOffset[1], entry.baseScale.z*adj.scaleOffset[2]);
}

/* ═══════════════════════════════════════════
   PLACE ITEM (simplified for game runtime)
   ═══════════════════════════════════════════ */
async function placeItem(cfg, cells, rotDeg, forceLane, worldPos) {
  const model = await loadModel(cfg);
  const isVehicle = cfg.category === 'vehicle';
  const isRoad = cfg.category === 'road';
  const isLandscape = _isLandCat(cfg.category);
  const isOverlap = cfg.allowOverlap && levelMeta.allowDecoOverlap;
  const isZone = cfg.category === 'zone';
  const isLight = cfg.category === 'light';
  const isCamera = cfg.category === 'camera';
  const isPlayer = cfg.category === 'player';

  let sx=0, sz=0;
  cells.forEach(([gx,gz]) => { const [wx,wz]=g2w(gx,gz); sx+=wx; sz+=wz; });
  const cx = sx/cells.length, cz = sz/cells.length;

  model.scale.set(...cfg.scale);
  model.rotation.set(THREE.MathUtils.degToRad(cfg.rotation[0]), THREE.MathUtils.degToRad(cfg.rotation[1]+rotDeg), THREE.MathUtils.degToRad(cfg.rotation[2]));

  const terrainH = isLandscape ? 0 : getTerrainHeightForCells(cells);
  let lane = null;

  if (isVehicle) {
    const [gx,gz] = cells[0];
    lane = forceLane || pickVehicleLane(gx,gz); if (!lane) return;
    const [offX,offZ] = getVehicleLaneOffset(lane, rotDeg);
    if (worldPos) model.position.set(worldPos[0]+offX, worldPos[1], worldPos[2]+offZ);
    else model.position.set(cx+cfg.position[0]+offX, cfg.position[1]+terrainH, cz+cfg.position[2]+offZ);
    const key = ck(gx,gz);
    if (!vehicleLanes.has(key)) vehicleLanes.set(key, {a:null,b:null});
    vehicleLanes.get(key)[lane] = true;
  } else if (worldPos && !isLandscape) {
    model.position.set(worldPos[0], worldPos[1], worldPos[2]);
  } else if (isLandscape) {
    const [gx,gz] = cells[0];
    const tileH = (cfg.properties && cfg.properties.tileHeight) || LANDSCAPE_TILE_HEIGHT;
    const existingH = getTerrainHeight(gx, gz);
    if (worldPos) model.position.set(worldPos[0], worldPos[1], worldPos[2]);
    else model.position.set(cx+cfg.position[0], existingH+cfg.position[1], cz+cfg.position[2]);
  } else {
    model.position.set(cx+cfg.position[0], cfg.position[1]+terrainH, cz+cfg.position[2]);
  }

  scene.add(model);
  const isParticle = cfg.category === 'particle';
  if (!isVehicle && !isZone && !isLandscape && !isOverlap && !isLight && !isCamera && !isPlayer && !isParticle) cells.forEach(([gx,gz]) => occupiedCells.add(ck(gx,gz)));
  if (isRoad) { cells.forEach(([gx,gz]) => roadCells.add(ck(gx,gz))); rebuildRoadGraph(); }

  const props = cfg.properties ? JSON.parse(JSON.stringify(cfg.properties)) : {};
  if (props.walkOnSurface === undefined) props.walkOnSurface = isLandscape ? true : false;

  const entry = { mesh:model, config:cfg, gridCells:cells.map(c=>[...c]), rotationDeg:rotDeg, lane, properties:props };
  placedItems.push(entry);
  recordBaseTransform(entry);

  if (animationCache[cfg.id]) setupAnimationMixer(entry);
  if (cfg.category === 'npc') initNPCBehavior(entry);
  if (cfg.category === 'player') initPlayerController(entry);
  if (cfg.category === 'driveable') initDriveableController(entry);
  // particle system created after properties are restored in loadScene

  if (isLandscape) {
    const [gx,gz] = cells[0]; const key = ck(gx,gz);
    const tileH = (cfg.properties && cfg.properties.tileHeight) || LANDSCAPE_TILE_HEIGHT;
    const ld = landscapeGrid.get(key) || { height:0, tiles:[] };
    ld.tiles.push(entry); ld.height += tileH;
    landscapeGrid.set(key, ld);
  }
  if (isVehicle) { vehicleLanes.get(ck(cells[0][0],cells[0][1]))[lane] = entry; initVehicleMovement(entry); }

  return entry;
}

/* ═══════════════════════════════════════════
   LOAD SCENE
   ═══════════════════════════════════════════ */
async function loadScene(data) {
  // Clear existing scene objects
  billboard3DDialogs.forEach(d => d.el.remove());
  billboard3DDialogs.length = 0;
  const dialogOvClear = document.getElementById('dialog-overlay');
  if (dialogOvClear) dialogOvClear.innerHTML = '';
  const iframeOv = document.getElementById('iframe-game-overlay');
  if (iframeOv) iframeOv.remove();
  const htmlPopup = document.getElementById('html-popup-overlay');
  if (htmlPopup) htmlPopup.remove();
  if (compassEl) { compassEl.remove(); compassEl = null; }
  if (minimapEl) { minimapEl.remove(); minimapEl = null; minimapDotEl = null; }
  if (weatherParticles) { scene.remove(weatherParticles); weatherParticles.geometry.dispose(); weatherParticles.material.dispose(); weatherParticles = null; }
  scene.fog = null;
  activeCollisions.clear();
  collisionTimers.forEach(timers => timers.forEach(t => clearTimeout(t)));
  collisionTimers.clear();
  collisionAudioTracks.forEach(a => { try { a.pause(); } catch(e){} });
  collisionAudioTracks.clear();

  [...placedItems].forEach(e => { disposeAnimationMixer(e); scene.remove(e.mesh); });
  placedItems = []; occupiedCells.clear(); roadCells.clear(); vehicleLanes.clear(); roadGraph.clear(); landscapeGrid.clear(); animationMixers.clear(); activePlayerEntry = null;

  // Level metadata
  if (data.level) {
    // Reset scene-specific settings to defaults before applying new data
    // (prevents carryover from previous scene when keys are missing in new scene)
    levelMeta.compassEnabled = false; levelMeta.minimapEnabled = false; levelMeta.minimapImage = '';
    levelMeta.weather = 'clear'; levelMeta.fogNear = 0.15; levelMeta.fogFar = 0.6; levelMeta.weatherIntensity = 1;
    levelMeta.lockRotation = false; levelMeta.lockPan = false; levelMeta.lockZoom = false;
    Object.assign(levelMeta, data.level);
    groundMat.color.set(levelMeta.groundColor || '#2a5a2a');
    if (levelMeta.allowDecoOverlap === undefined) levelMeta.allowDecoOverlap = true;
    // Lighting defaults
    if (levelMeta.ambientColor === undefined) levelMeta.ambientColor = '#606080';
    if (levelMeta.ambientIntensity === undefined) levelMeta.ambientIntensity = 0.5;
    if (levelMeta.hemiSkyColor === undefined) levelMeta.hemiSkyColor = '#87ceeb';
    if (levelMeta.hemiGroundColor === undefined) levelMeta.hemiGroundColor = '#362907';
    if (levelMeta.hemiIntensity === undefined) levelMeta.hemiIntensity = 0.4;
    if (levelMeta.dirColor === undefined) levelMeta.dirColor = '#fff4e0';
    if (levelMeta.dirIntensity === undefined) levelMeta.dirIntensity = 1.8;
    if (levelMeta.toneMappingExposure === undefined) levelMeta.toneMappingExposure = 1.2;
    if (levelMeta.shadowEnabled === undefined) levelMeta.shadowEnabled = true;
    if (levelMeta.useDayNightCycle === undefined) levelMeta.useDayNightCycle = true;
    if (levelMeta.sunEnabled === undefined) levelMeta.sunEnabled = true;
    if (levelMeta.fixedTimeOfDay === undefined) levelMeta.fixedTimeOfDay = 8;
    if (levelMeta.cameraFov === undefined) levelMeta.cameraFov = 50;
    if (levelMeta.skyboxType === undefined) levelMeta.skyboxType = 'procedural';
    if (levelMeta.skyboxColor === undefined) levelMeta.skyboxColor = '#87ceeb';
    if (levelMeta.skyboxImage === undefined) levelMeta.skyboxImage = '';
    if (levelMeta.skyboxStars === undefined) levelMeta.skyboxStars = true;
    applySceneLighting();
    camera.fov = levelMeta.cameraFov || 50; camera.updateProjectionMatrix();
    applySkybox();
    applyWeather();
    // Ground/grid visibility
    if (levelMeta.showGround === undefined) levelMeta.showGround = true;
    if (levelMeta.showGrid === undefined) levelMeta.showGrid = false;
    ground.visible = levelMeta.showGround;
    gridHelper.visible = levelMeta.showGrid;
  }

  // Restore camera
  if (data.mainCamera) {
    const mc = data.mainCamera;
    if (mc.position) camera.position.set(...mc.position);
    if (mc.target) controls.target.set(...mc.target);
    if (mc.fov) { camera.fov = mc.fov; camera.updateProjectionMatrix(); }
    controls.update();
  }

  if (data.gameTime !== undefined) gameTime = data.gameTime;
  if (data.gameDay !== undefined) gameDay = data.gameDay;
  if (data.timeSpeed !== undefined) timeSpeed = data.timeSpeed;
  if (data.sceneConfig?.groundColor) groundMat.color.set(data.sceneConfig.groundColor);

  // Update grid size if needed
  if (levelMeta.gridSize && levelMeta.gridSize !== GRID_SIZE) {
    GRID_SIZE = levelMeta.gridSize;
    HALF_GRID = (GRID_SIZE * CELL_SIZE) / 2;
    ground.geometry.dispose();
    ground.geometry = new THREE.PlaneGeometry(GRID_SIZE, GRID_SIZE);
    scene.remove(gridHelper);
    gridHelper = new THREE.GridHelper(GRID_SIZE, GRID_SIZE, 0x3a5a3a, 0x2e4e2e);
    gridHelper.position.y = 0.005;
    gridHelper.visible = levelMeta.showGrid !== false;
    scene.add(gridHelper);
  }

  // Place items in order: landscape → non-vehicles → vehicles
  const landscape = data.items.filter(i => { const c = ITEM_CONFIG.find(x => x.id === i.configId); return c && _isLandCat(c.category); });
  const nonVehicles = data.items.filter(i => { const c = ITEM_CONFIG.find(x => x.id === i.configId); return c && c.category !== 'vehicle' && !_isLandCat(c.category); });
  const vehicles = data.items.filter(i => { const c = ITEM_CONFIG.find(x => x.id === i.configId); return c && c.category === 'vehicle'; });

  const statusEl = document.getElementById('loading-status');
  const progressBar = document.getElementById('load-progress-bar');
  const transBar = document.getElementById('scene-trans-bar');
  const transStatus = document.getElementById('scene-trans-status');
  const totalItems = data.items.length;
  let count = 0;

  const updateProgress = () => {
    count++;
    const pct = Math.round((count/totalItems)*100);
    if (statusEl) statusEl.textContent = `Loading items… ${pct}%`;
    if (progressBar) progressBar.style.width = `${pct}%`;
    // Also update transition overlay if active
    if (transBar) transBar.style.width = `${50 + pct * 0.5}%`;
    if (transStatus) transStatus.textContent = `Loading items… ${count}/${totalItems}`;
  };

  for (const item of landscape) {
    const cfg = ITEM_CONFIG.find(c => c.id === item.configId); if (!cfg) { updateProgress(); continue; }
    const entry = await placeItem(cfg, item.gridCells, item.rotationDeg || 0, null, item.worldPos);
    if (entry) {
      if (item.properties) { Object.assign(entry.properties, item.properties); restoreAnimation(entry); }
      if (entry.config.category === 'particle') { particleSystems.delete(entry); createParticleSystem(entry); }
      if (entry.config.category === 'text3d') { rebuildGLTextObject(entry); }
      if (entry.config.category === 'primitive') { rebuildGLPrimitiveObject(entry); }
      if (item.transformAdj) { entry.transformAdj = JSON.parse(JSON.stringify(item.transformAdj)); if (item.worldPos) entry.transformAdj.posOffset = [0,0,0]; applyTransformAdj(entry); }
      if (item.instanceName) entry.instanceName = item.instanceName;
      if (item.objectId) entry.objectId = item.objectId;
      if (item.visible === false) entry.mesh.visible = false;
    }
    updateProgress();
  }

  for (const item of nonVehicles) {
    const cfg = ITEM_CONFIG.find(c => c.id === item.configId); if (!cfg) { updateProgress(); continue; }
    const entry = await placeItem(cfg, item.gridCells, item.rotationDeg || 0, null, item.worldPos);
    if (entry) {
      if (item.properties) { Object.assign(entry.properties, item.properties); restoreAnimation(entry); }
      if (entry.config.category === 'particle') { particleSystems.delete(entry); createParticleSystem(entry); }
      if (entry.config.category === 'text3d') { rebuildGLTextObject(entry); }
      if (entry.config.category === 'primitive') { rebuildGLPrimitiveObject(entry); }
      if (item.transformAdj) { entry.transformAdj = JSON.parse(JSON.stringify(item.transformAdj)); if (item.worldPos) entry.transformAdj.posOffset = [0,0,0]; applyTransformAdj(entry); }
      // Restore camera exact world position & rotation from saved properties
      if (cfg.category === 'camera') {
        const p = entry.properties;
        if (p.camPosX !== undefined) {
          entry.mesh.position.set(p.camPosX, p.camPosY, p.camPosZ);
          entry.mesh.rotation.set(0, THREE.MathUtils.degToRad(p.camRotY || 0), 0);
        }
      }
      if (cfg.category === 'npc') initNPCBehavior(entry);
      if (cfg.category === 'player') initPlayerController(entry);
      // Sync light object with saved properties
      if (cfg.category === 'light') updatePlacedLight(entry);
      if (item.instanceName) entry.instanceName = item.instanceName;
      if (item.objectId) entry.objectId = item.objectId;
      if (item.visible === false) entry.mesh.visible = false;
    }
    updateProgress();
  }

  for (const item of vehicles) {
    const cfg = ITEM_CONFIG.find(c => c.id === item.configId); if (!cfg) { updateProgress(); continue; }
    const entry = await placeItem(cfg, item.gridCells, item.rotationDeg || 0, item.lane || null, item.worldPos);
    if (entry) {
      if (item.properties) { Object.assign(entry.properties, item.properties); restoreAnimation(entry); }
      if (entry.config.category === 'particle') { particleSystems.delete(entry); createParticleSystem(entry); }
      if (entry.config.category === 'text3d') { rebuildGLTextObject(entry); }
      if (entry.config.category === 'primitive') { rebuildGLPrimitiveObject(entry); }
      if (item.transformAdj) { entry.transformAdj = JSON.parse(JSON.stringify(item.transformAdj)); if (item.worldPos) entry.transformAdj.posOffset = [0,0,0]; applyTransformAdj(entry); }
      if (item.instanceName) entry.instanceName = item.instanceName;
      if (item.objectId) entry.objectId = item.objectId;
      if (item.visible === false) entry.mesh.visible = false;
    }
    updateProgress();
  }

  // Collect placed cameras for navigation
  placedCameras = placedItems.filter(e => e.config.category === 'camera');

  // Start from first placed camera if available and enabled
  if (gameLevelSettings.startFromCamera && placedCameras.length > 0) {
    applyCameraView(placedCameras[0]);
  } else {
    // No camera models — apply level-level lock settings
    controls.enableRotate = !(levelMeta.lockRotation);
    controls.enablePan = !(levelMeta.lockPan);
    controls.enableZoom = !(levelMeta.lockZoom);
  }

  // Apply HUD settings & build camera nav
  applyGameLevelSettings();
  buildCameraNav();

  // Start background music if configured
  initMusicFromLevel();

  // Init compass & minimap
  initCompass();
  initMinimap();

  // Start the game
  const overlay = document.getElementById('loading-overlay');
  if (overlay) {
    // Fade out gracefully then remove
    overlay.style.transition = 'opacity 0.5s';
    overlay.style.opacity = '0';
    setTimeout(() => overlay.remove(), 600);
  }
  document.getElementById('game-hud').style.display = 'flex';

  // Load embedded Game UI if present in level data
  if (data.gameUI && data.gameUI.type === 'game-ui') {
    loadGameUI(data.gameUI);
  }

  // Inject world custom code from level
  if (data.customCode) {
    try {
      const script = document.createElement('script');
      script.textContent = data.customCode;
      document.body.appendChild(script);
    } catch(e) { console.error('Custom code error:', e); }
  }

  // Execute attached scripts on placed items
  if (data.items) {
    for (const itemData of data.items) {
      if (!itemData.properties || !itemData.properties.attachedScripts) continue;
      const scripts = itemData.properties.attachedScripts;
      const objectId = itemData.objectId || '';
      const entry = placedItems.find(e => e.config?.id === itemData.configId && (!objectId || e.objectId === objectId));
      scripts.forEach(scr => {
        if (!scr.funcName) return;
        const runScript = () => {
          try {
            if (typeof window[scr.funcName] === 'function') {
              const args = scr.args || {};
              const argNames = Object.keys(args);
              const argValues = argNames.map(k => {
                const v = args[k];
                if (v === 'true') return true; if (v === 'false') return false;
                if (!isNaN(v) && v !== '') return Number(v);
                return v;
              });
              // Append entry context as last arg
              argValues.push(entry || null);
              window[scr.funcName](...argValues);
            }
          } catch(e) { console.error(`Script ${scr.funcName} error:`, e); }
        };
        const mode = scr.runMode || (!scr.intervalMs || scr.intervalMs <= 0 ? 'once' : 'loop');
        if (mode === 'once') {
          setTimeout(runScript, 100);
        } else if (mode === 'count') {
          let remaining = scr.runCount || 5;
          const intv = Math.max(50, scr.intervalMs || 1000);
          setTimeout(() => { const tmr = setInterval(() => { runScript(); remaining--; if (remaining <= 0) clearInterval(tmr); }, intv); }, 100);
        } else {
          const intv = Math.max(50, scr.intervalMs || 1000);
          setTimeout(() => setInterval(runScript, intv), 100);
        }
      });
    }
  }

  // Call onReady if defined
  if (typeof window.onReady === 'function') { try { window.onReady(); } catch(e) { console.error('onReady error:', e); } }

  
}

/* ═══════════════════════════════════════════
   RESIZE
   ═══════════════════════════════════════════ */
addEventListener('resize', () => {
  camera.aspect = innerWidth / innerHeight;
  camera.updateProjectionMatrix();
  renderer.setSize(innerWidth, innerHeight);
});

/* ═══════════════════════════════════════════
   MAIN LOOP
   ═══════════════════════════════════════════ */

/* ═══════════════════════════════════════════
   PARTICLE SYSTEM RUNTIME
   ═══════════════════════════════════════════ */
const particleSystems = new Map(); // entry → { points, particles, emitTimer, active }

function createParticleSystem(entry) {
  const props = entry.properties;
  // Normalize field names: support both WB designer names and legacy names
  const count = props.particleCount || props.count || 50;
  const sizeStart = props.sizeStart ?? props.size ?? 0.15;
  const emitterShape = props.emitterShape || props.shape || 'sphere';
  const emitterRadius = props.emitterRadius ?? props.radius ?? 0.5;
  const blending = (props.blending === 'normal') ? THREE.NormalBlending : THREE.AdditiveBlending;

  let texture = null;
  if (props.spriteUrl) {
    try { texture = new THREE.TextureLoader().load(props.spriteUrl); } catch(e) {}
  }

  const geometry = new THREE.BufferGeometry();
  const positions = new Float32Array(count * 3);
  const colors = new Float32Array(count * 3);
  const sizes = new Float32Array(count);
  const ages = new Float32Array(count);
  const lifetimes = new Float32Array(count);
  const velocities = new Float32Array(count * 3);

  const cStart = new THREE.Color(props.colorStart || '#ff8800');
  const cEnd = new THREE.Color(props.colorEnd || '#ff2200');

  for (let i = 0; i < count; i++) {
    _resetGLParticle(i, positions, velocities, ages, lifetimes, sizes, colors, props, cStart);
    ages[i] = Math.random() * (props.lifetime || 2); // stagger initial ages
  }

  geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
  geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3));
  geometry.setAttribute('size', new THREE.BufferAttribute(sizes, 1));

  const material = new THREE.PointsMaterial({
    size: sizeStart,
    map: texture,
    blending,
    transparent: true,
    opacity: props.opacity ?? 1,
    vertexColors: true,
    depthWrite: false,
    sizeAttenuation: true,
  });

  const points = new THREE.Points(geometry, material);
  points.frustumCulled = false;
  // Add invisible helper mesh for scene bounds
  const helper = new THREE.Mesh(new THREE.SphereGeometry(0.01,4,4), new THREE.MeshBasicMaterial({visible:false}));
  points.add(helper);
  entry.mesh.add(points);

  const sys = { points, geometry, material, ages, lifetimes, velocities, positions, colors, sizes, cStart, cEnd, elapsed:0, active: props.autoPlay !== false, emitTimer:0, burstDone:false };
  particleSystems.set(entry, sys);

  if (!sys.active) points.visible = false;

  // Burst mode: all particles already alive from staggered init, just mark done
  if (props.burstMode && sys.active) sys.burstDone = true;
}

function _resetGLParticle(i, positions, velocities, ages, lifetimes, sizes, colors, props, cStart) {
  const lt = (props.lifetime || 2) + (Math.random() - 0.5) * 2 * (props.lifetimeVar || 0);
  lifetimes[i] = Math.max(0.1, lt);
  ages[i] = 0;

  const emitterShape = props.emitterShape || props.shape || 'sphere';
  const emitterRadius = props.emitterRadius ?? props.radius ?? 0.5;
  let px = 0, py = 0, pz = 0;

  switch (emitterShape) {
    case 'sphere': {
      const theta = Math.random() * Math.PI * 2, phi = Math.acos(2 * Math.random() - 1);
      const rr = emitterRadius * Math.cbrt(Math.random());
      px = rr * Math.sin(phi) * Math.cos(theta);
      py = rr * Math.sin(phi) * Math.sin(theta);
      pz = rr * Math.cos(phi);
      break;
    }
    case 'box': {
      const es = props.emitterSize || [1,1,1];
      px = (Math.random() - 0.5) * (es[0] || 1);
      py = (Math.random() - 0.5) * (es[1] || 1);
      pz = (Math.random() - 0.5) * (es[2] || 1);
      break;
    }
    case 'cone': {
      const ang = THREE.MathUtils.degToRad(props.coneAngle || 30);
      const theta2 = Math.random() * Math.PI * 2;
      const phi2 = Math.random() * ang;
      const rr2 = emitterRadius * Math.random();
      px = rr2 * Math.sin(phi2) * Math.cos(theta2);
      pz = rr2 * Math.sin(phi2) * Math.sin(theta2);
      py = 0;
      break;
    }
    default: break;
  }
  positions[i*3] = px; positions[i*3+1] = py; positions[i*3+2] = pz;

  // Velocity using direction + spread (matches WB designer)
  const dir = props.direction || [0,1,0];
  const spd = (props.speed || 2) + (Math.random() - 0.5) * 2 * (props.speedVar || props.speedVariance || 0);
  const spread = props.spread || 0.5;
  velocities[i*3]   = dir[0] * spd + (Math.random() - 0.5) * spread;
  velocities[i*3+1] = dir[1] * spd + (Math.random() - 0.5) * spread;
  velocities[i*3+2] = dir[2] * spd + (Math.random() - 0.5) * spread;

  sizes[i] = props.sizeStart ?? props.size ?? 0.15;
  colors[i*3] = cStart.r; colors[i*3+1] = cStart.g; colors[i*3+2] = cStart.b;
}

function updateParticleSystems(dt) {
  particleSystems.forEach((sys, entry) => {
    if (!sys.active) return;
    const props = entry.properties;
    const count = sys.ages.length;
    const gravity = props.gravity || 0;
    const sizeS = props.sizeStart ?? props.size ?? 0.15;
    const sizeE = props.sizeEnd ?? 0;
    const { geometry, ages, lifetimes, velocities, positions, colors, sizes, cStart, cEnd } = sys;

    sys.elapsed += dt;
    const duration = props.duration || props.lifetime || 2;

    for (let i = 0; i < count; i++) {
      ages[i] += dt;
      const t = ages[i] / lifetimes[i];
      if (t >= 1) {
        if (props.loop || props.burstMode || sys.elapsed < duration) {
          _resetGLParticle(i, positions, velocities, ages, lifetimes, sizes, colors, props, cStart);
        } else {
          positions[i*3+1] = -9999;
        }
        continue;
      }
      // Physics
      velocities[i*3+1] += gravity * dt;
      positions[i*3]   += velocities[i*3]   * dt;
      positions[i*3+1] += velocities[i*3+1] * dt;
      positions[i*3+2] += velocities[i*3+2] * dt;
      // Size
      sizes[i] = sizeS + (sizeE - sizeS) * t;
      // Color
      colors[i*3]   = cStart.r + (cEnd.r - cStart.r) * t;
      colors[i*3+1] = cStart.g + (cEnd.g - cStart.g) * t;
      colors[i*3+2] = cStart.b + (cEnd.b - cStart.b) * t;
    }
    geometry.attributes.position.needsUpdate = true;
    geometry.attributes.color.needsUpdate = true;
    geometry.attributes.size.needsUpdate = true;
    sys.material.opacity = props.opacity ?? 1;

    // Non-loop: deactivate when duration exceeded and all particles dead
    if (!props.loop && !props.burstMode && sys.elapsed > duration * 2) {
      sys.active = false;
    }
  });
}

function animate() {
  requestAnimationFrame(animate);
  const now = performance.now(), rawDt = (now - lastRealTime) / 1000;
  lastRealTime = now;
  // Pause game logic when tab is not visible, and clamp dt to prevent huge jumps
  const dt = document.hidden ? 0 : Math.min(rawDt, 0.1);
  if (!timePaused && dt > 0) { gameTime += dt * timeSpeed; if (gameTime >= 86400) { gameTime -= 86400; gameDay++; } }
  updateDayNight();
  updateClockUI();
  updateVehicles(dt);
  updateNPCs(dt);
  updatePlayer(dt);
  updateDriveable(dt);
  updateParticleSystems(dt);
  updateWeatherParticles(dt);
  updateForces(dt);
  resolveBodyCollisions();
  updateAnimations(dt);
  updateActiveIndicators(now / 1000);
  updateZoneAnimations(now / 1000);
  updateColliders();
  update3DDialogs();
  updateActiveIndicators(dt);
  updateCompass();
  updateMinimap();
  updateGameUI();
  controls.update();
  renderer.render(scene, camera);
}

/* ═══════════════════════════════════════════
   COLLISION SYSTEM — Action Chains
   ═══════════════════════════════════════════ */
const activeCollisions = new Set();
const collisionTimers = new Map(); // colKey → timer IDs for cleanup on exit
const collisionAudioTracks = new Map(); // colKey → [Audio] for cleanup on exit

function updateColliders() {
  const collidersAll = placedItems.filter(e => e.properties.colliderEnabled && e.mesh.visible);
  // Always include active player as collision target
  if (activePlayerEntry && activePlayerEntry.mesh.visible && !collidersAll.includes(activePlayerEntry)) {
    collidersAll.push(activePlayerEntry);
  }

  for (const a of collidersAll) {
    const aPos = a.mesh.position;
    const aR = getEffectiveColliderRadius(a);

    for (const b of collidersAll) {
      if (a === b) continue;
      if (!matchesColliderTarget(a, b)) continue;

      const bPos = b.mesh.position;
      const bR = getEffectiveColliderRadius(b);
      // Use 2D horizontal distance for collision detection (consistent with body collision)
      const dx = aPos.x - bPos.x, dz = aPos.z - bPos.z;
      const dist = Math.sqrt(dx*dx + dz*dz);
      const colKey = `${a.objectId}:${b.objectId}`;

      // Small tolerance so collider triggers even when body collision pushes to exact boundary
      if (dist < aR + bR + 0.05) {
        if (!activeCollisions.has(colKey)) {
          activeCollisions.add(colKey);
          onCollisionEnter(a, b, colKey);
        }
      } else {
        if (activeCollisions.has(colKey)) {
          activeCollisions.delete(colKey);
          onCollisionExit(a, b, colKey);
        }
      }
    }
  }
}

function matchesColliderTarget(a, b) {
  const t = a.properties.colliderTarget || 'any';
  if (t === 'any') return true;
  if (t === 'id') return b.objectId === a.properties.colliderTargetId;
  if (t === 'category') return b.config.category === a.properties.colliderTargetCategory;
  return false;
}

function onCollisionEnter(owner, other, colKey) {
  // Cancel click-to-move when player/driveable collides with anything
  if (owner === activePlayerEntry && owner.playerState && owner.playerState.clickTarget) {
    owner.playerState.clickTarget = null;
    if (clickTargetMarker) clickTargetMarker.visible = false;
  }
  if (other === activePlayerEntry && other.playerState && other.playerState.clickTarget) {
    other.playerState.clickTarget = null;
    if (clickTargetMarker) clickTargetMarker.visible = false;
  }
  // Pause NPC patrol on collision
  if (owner.config.category === 'npc' && owner.npcState) {
    owner.npcState._colPaused = true; owner.npcState.paused = true;
    if (owner.properties.idleAnim) playAnimation(owner, owner.properties.idleAnim);
  }
  if (other.config.category === 'npc' && other.npcState) {
    other.npcState._colPaused = true; other.npcState.paused = true;
    if (other.properties.idleAnim) playAnimation(other, other.properties.idleAnim);
  }
  // NPC face target on collision
  if (owner.properties.colliderFaceTarget) {
    const target = other.mesh.position;
    const dx = target.x - owner.mesh.position.x, dz = target.z - owner.mesh.position.z;
    if (dx*dx + dz*dz > 0.01) {
      const cfgRotY = THREE.MathUtils.degToRad(owner.config.rotation ? owner.config.rotation[1] : 0);
      owner.mesh.rotation.y = Math.atan2(dx, dz) + cfgRotY;
    }
  }
  if (other.properties.colliderFaceTarget) {
    const target = owner.mesh.position;
    const dx = target.x - other.mesh.position.x, dz = target.z - other.mesh.position.z;
    if (dx*dx + dz*dz > 0.01) {
      const cfgRotY = THREE.MathUtils.degToRad(other.config.rotation ? other.config.rotation[1] : 0);
      other.mesh.rotation.y = Math.atan2(dx, dz) + cfgRotY;
    }
  }
  executeActionChain(owner, other, colKey);
}

function onCollisionExit(owner, other, colKey) {
  // Clear pending timers
  const timers = collisionTimers.get(colKey);
  if (timers) { timers.forEach(t => clearTimeout(t)); collisionTimers.delete(colKey); }

  // Stop all audio tracks from this collision
  const audioTracks = collisionAudioTracks.get(colKey);
  if (audioTracks) { audioTracks.forEach(a => { try { a.pause(); a.currentTime = 0; } catch(e){} }); collisionAudioTracks.delete(colKey); }
  // Dismiss any 3D dialogs from this owner
  const dIdx = billboard3DDialogs.findIndex(d => d.entry === owner);
  if (dIdx !== -1) { billboard3DDialogs[dIdx].el.remove(); billboard3DDialogs.splice(dIdx, 1); }
  // Dismiss screen dialog if it was from this collision
  const screenOverlay = document.getElementById('dialog-overlay');
  if (screenOverlay && screenOverlay.dataset.ownerKey === colKey) screenOverlay.innerHTML = '';

  // Revert animation to idle/default if collision triggered an animation
  const actions = owner.properties.colliderActions || [];
  const hadAnimate = actions.some(a => a.type === 'animate');
  if (hadAnimate) {
    const defaultAnim = owner.properties.defaultAnimation || owner.properties.idleAnim;
    if (defaultAnim) playAnimation(owner, defaultAnim);
  }

  // Resume NPC patrol
  if (owner.config.category === 'npc' && owner.npcState && owner.npcState._colPaused) {
    owner.npcState._colPaused = false; owner.npcState.paused = false;
    if (owner.properties.behavior === 'patrol') { startPatrol(owner); }
    else if (owner.properties.behavior === 'wander') { /* wander resumes naturally */ }
    else if (owner.properties.walkAnim) playAnimation(owner, owner.properties.walkAnim);
  }
  if (other.config.category === 'npc' && other.npcState && other.npcState._colPaused) {
    other.npcState._colPaused = false; other.npcState.paused = false;
    if (other.properties.behavior === 'patrol') { startPatrol(other); }
    else if (other.properties.behavior === 'wander') { /* wander resumes naturally */ }
    else if (other.properties.walkAnim) playAnimation(other, other.properties.walkAnim);
  }
}

function executeActionChain(owner, other, colKey) {
  let actions = null;

  // Check conditional action sets first (target-specific chains)
  const sets = owner.properties.colliderActionSets;
  if (sets && sets.length > 0) {
    // Find best matching set: specific id > category > any
    let matchById = null, matchByCat = null, matchAny = null;
    for (const s of sets) {
      if (s.targetType === 'id' && other.objectId === s.targetValue) { matchById = s; break; }
      else if (s.targetType === 'category' && other.config.category === s.targetValue) matchByCat = matchByCat || s;
      else if (s.targetType === 'any') matchAny = matchAny || s;
    }
    const match = matchById || matchByCat || matchAny;
    if (match && match.actions && match.actions.length) actions = match.actions;
  }

  // Fallback to legacy colliderActions
  if (!actions) {
    actions = owner.properties.colliderActions;
    if (!actions && owner.properties.colliderAction) {
      const act = { type: owner.properties.colliderAction, delay: 0 };
      if (owner.properties.colliderAnimName) act.animName = owner.properties.colliderAnimName;
      if (owner.properties.colliderDialogText) { act.dialogText = owner.properties.colliderDialogText; act.dialogMode = 'screen'; }
      if (owner.properties.colliderFuncName) act.funcName = owner.properties.colliderFuncName;
      actions = [act];
    }
  }
  if (!actions || !actions.length) return;

  let cumDelay = 0;
  const timers = [];
  actions.forEach((act, i) => {
    cumDelay += (i === 0 ? 0 : (act.delay || 0));
    const d = cumDelay;
    if (d === 0) {
      executeSingleAction(act, owner, other, colKey);
    } else {
      const t = setTimeout(() => executeSingleAction(act, owner, other, colKey), d);
      timers.push(t);
    }
  });
  if (timers.length) collisionTimers.set(colKey, timers);
}

function executeSingleAction(act, owner, other, colKey) {
  switch (act.type) {
    case 'nothing':
      break;
    case 'hide':
      owner.mesh.visible = false;
      break;
    case 'destroy':
      owner.mesh.visible = false;
      scene.remove(owner.mesh);
      owner.mesh.traverse(c => { if (c.isMesh) { c.geometry.dispose(); const m = c.material; Array.isArray(m) ? m.forEach(x=>x.dispose()) : m.dispose(); } });
      const idx = placedItems.indexOf(owner);
      if (idx !== -1) placedItems.splice(idx, 1);
      break;
    case 'animate':
      if (act.animName) {
        const mode = act.animPlayMode || 'once';
        playAnimationWithMode(owner, act.animName, mode, act.animRepeatCount || 2);
      }
      break;
    case 'audio':
      if (act.audioSrc) {
        try {
          const a = new Audio(act.audioSrc); a.play().catch(e => console.warn('Audio play failed:', e));
          // Track for cleanup on collision exit
          if (colKey) {
            if (!collisionAudioTracks.has(colKey)) collisionAudioTracks.set(colKey, []);
            collisionAudioTracks.get(colKey).push(a);
          }
        } catch(e) { console.warn('Audio error:', e); }
      }
      break;
    case 'dialog':
      if (act.dialogMode === '3d') {
        show3DDialog(owner, act.dialogText || '...', {
          speaker: act.dialogSpeaker,
          offsetX: act.dialogOffsetX || 0, offsetY: act.dialogOffsetY || 2.0, offsetZ: act.dialogOffsetZ || 0,
          bgColor: act.dialogBgColor, textColor: act.dialogTextColor, duration: act.dialogDuration || 0,
          borderColor: act.dialogBorderColor, borderWidth: act.dialogBorderWidth
        });
      } else {
        showDialog(act.dialogText || '...', {
          speaker: act.dialogSpeaker, bgColor: act.dialogBgColor, textColor: act.dialogTextColor,
          ownerKey: colKey, portrait: act.dialogPortrait || '', side: act.dialogSide || 'left',
          duration: act.dialogDuration || 0
        });
      }
      break;
    case 'playParticle': {
      const targetId = act.targetId || owner.objectId;
      if (window.GameAPI && window.GameAPI.playParticle) window.GameAPI.playParticle(targetId);
      break;
    }
    case 'spawnAnim': {
      if (act.spawnModelUrl) {
        const spawnPos = other ? other.mesh.position.clone() : owner.mesh.position.clone();
        gltfL.load(act.spawnModelUrl, gltf => {
          const m = gltf.scene; m.position.copy(spawnPos);
          if (act.spawnScale) m.scale.setScalar(act.spawnScale);
          scene.add(m);
          let mixer = null;
          if (gltf.animations && gltf.animations.length) {
            mixer = new THREE.AnimationMixer(m);
            const clipName = act.spawnAnimName || '';
            const clip = clipName ? gltf.animations.find(a => a.name === clipName) : gltf.animations[0];
            if (clip) { const a = mixer.clipAction(clip); a.setLoop(THREE.LoopOnce); a.clampWhenFinished = true; a.play(); }
          }
          const dur = (act.spawnDuration || 2) * 1000;
          const startTime = performance.now();
          function updateSpawn() {
            const elapsed = performance.now() - startTime;
            if (mixer) mixer.update(0.016);
            if (elapsed < dur) requestAnimationFrame(updateSpawn);
            else { scene.remove(m); m.traverse(c => { if(c.isMesh){c.geometry.dispose();const mt=c.material;Array.isArray(mt)?mt.forEach(x=>x.dispose()):mt.dispose();}}); }
          }
          updateSpawn();
        });
      }
      break;
    }
    case 'replaceModel': {
      if (act.replaceModelUrl) {
        const L = gltfL;
        L.load(act.replaceModelUrl, gltf => {
          const oldPos = owner.mesh.position.clone();
          const oldRot = owner.mesh.rotation.clone();
          const oldScale = owner.mesh.scale.clone();
          // Remove old children, add new
          while (owner.mesh.children.length) owner.mesh.remove(owner.mesh.children[0]);
          gltf.scene.children.slice().forEach(c => owner.mesh.add(c));
          owner.mesh.position.copy(oldPos); owner.mesh.rotation.copy(oldRot); owner.mesh.scale.copy(oldScale);
          // Setup new animations directly
          if (gltf.animations && gltf.animations.length) {
            const mixer = new THREE.AnimationMixer(owner.mesh);
            const actions = {}, clipNames = [];
            gltf.animations.forEach(clip => { actions[clip.name] = mixer.clipAction(clip); clipNames.push(clip.name); });
            const mixerData = { mixer, actions, clipNames, activeAction:null, activeClipName:'' };
            animationMixers.set(owner, mixerData);
            owner.properties._availableAnimations = clipNames;
            const animName = act.replaceAnimName || clipNames[0] || '';
            if (animName && actions[animName]) { actions[animName].play(); mixerData.activeAction = actions[animName]; mixerData.activeClipName = animName; }
          }
        });
      }
      break;
    }
    case 'function': {
      const fnName = act.funcName;
      if (fnName && typeof window[fnName] === 'function') {
        const fa = act.funcArgs || {};
        const extraArgs = Object.values(fa).map(v => {
          if (v === 'true') return true; if (v === 'false') return false;
          if (!isNaN(v) && v !== '') return Number(v);
          return v;
        });
        window[fnName](...extraArgs, owner, other);
      } else if (fnName) {
        
      }
      break;
    }
    case 'applyForce': {
      const target = act.forceTarget === 'other' ? other : owner;
      const strength = act.forceStrength || 5;
      let dir;
      if (act.forceMode === 'manual') {
        dir = new THREE.Vector3(act.forceX || 0, act.forceY || 0, act.forceZ || 0).normalize();
      } else {
        // Auto: direction from owner to other (collision push direction)
        dir = new THREE.Vector3();
        dir.subVectors(target.mesh.position, (target === owner ? other : owner).mesh.position);
        dir.y = act.forceY || 0; // allow manual Y override
        if (dir.lengthSq() < 0.001) dir.set(0, 0, 1);
        dir.normalize();
      }
      applyForceToEntry(target, dir.multiplyScalar(strength));
      break;
    }
    case 'showResponse': {
      const resps = act.responses || [];
      showResponse(act.dialogText || '', resps, owner, other, colKey, {
        speaker: act.dialogSpeaker, bgColor: act.dialogBgColor, textColor: act.dialogTextColor,
        ownerKey: colKey, portrait: act.dialogPortrait, side: act.dialogSide
      });
      break;
    }
    case 'loadScene': {
      const params = {};
      if (act.sceneParams) {
        try { Object.assign(params, typeof act.sceneParams === 'string' ? JSON.parse(act.sceneParams) : act.sceneParams); } catch(e) {}
      }
      if (act.levelFile) {
        GameAPI.loadScene(act.levelFile, act.uiFile || null, params);
      }
      break;
    }
    case 'loadURL': {
      if (!act.url) break;
      const target = act.urlTarget || 'newTab';
      if (target === 'same') { window.location.href = act.url; }
      else if (target === 'newTab') { window.open(act.url, '_blank'); }
      else if (target === 'newWindow') { window.open(act.url, '_blank', 'width=800,height=600,menubar=no,toolbar=no'); }
      else if (target === 'iframe') { showIframeOverlay(act.url, act.iframeBackIcon, act.iframeCloseSound); }
      break;
    }
    case 'showHTML': {
      if (!act.htmlContent) break;
      const mode = act.htmlMode || 'popup';
      const bg = act.htmlBgColor || '#0a0c12';
      const fg = act.htmlTextColor || '#e0e0e0';
      if (mode === 'popup') {
        showHTMLPopup(act.htmlContent, { bgColor: bg, textColor: fg });
      } else if (mode === '3d') {
        show3DHTMLBillboard(owner, act.htmlContent, { offsetY: act.html3dOffsetY || 2.5, bgColor: bg, textColor: fg });
      }
      break;
    }
  }
}

/* ═══════════════════════════════════════════
   DIALOG SYSTEM — Screen + 3D Billboard
   ═══════════════════════════════════════════ */
function showDialog(text, options) {
  const opts = options || {};
  const bgColor = opts.bgColor || '#0a0c12';
  const textColor = opts.textColor || '#e0e0e0';
  let overlay = document.getElementById('dialog-overlay');
  if (!overlay) {
    overlay = document.createElement('div');
    overlay.id = 'dialog-overlay';
    overlay.style.cssText = 'position:fixed;bottom:0;left:0;right:0;z-index:200;display:flex;justify-content:center;padding:20px;pointer-events:none;';
    document.body.appendChild(overlay);
  }
  overlay.innerHTML = '';
  if (opts.ownerKey) overlay.dataset.ownerKey = opts.ownerKey;
  const box = document.createElement('div');
  const hasPortrait = opts.portrait;
  const side = opts.side || 'left';
  box.style.cssText = `max-width:650px;width:100%;background:${bgColor}ee;backdrop-filter:blur(16px);border:1px solid rgba(255,255,255,0.1);border-radius:14px;padding:16px 20px;pointer-events:auto;color:${textColor};font-family:Rajdhani,sans-serif;font-size:0.9rem;line-height:1.5;animation:dialog-in 0.3s ease;display:flex;align-items:flex-start;gap:14px;${side === 'right' ? 'flex-direction:row-reverse;' : ''}`;
  if (hasPortrait) {
    const img = document.createElement('img');
    img.src = opts.portrait;
    img.style.cssText = 'width:56px;height:56px;border-radius:50%;object-fit:cover;border:2px solid rgba(255,255,255,0.15);flex-shrink:0;background:#1a1c2e;';
    img.onerror = () => { img.style.display = 'none'; };
    box.appendChild(img);
  }
  const content = document.createElement('div'); content.style.flex = '1';
  if (opts.speaker) {
    const sp = document.createElement('div');
    sp.style.cssText = 'font-size:0.65rem;color:#f59e0b;letter-spacing:1px;text-transform:uppercase;margin-bottom:4px;font-weight:700;';
    sp.textContent = opts.speaker;
    content.appendChild(sp);
  }
  const txt = document.createElement('div'); txt.textContent = text; content.appendChild(txt);
  const dismissFn = () => { overlay.innerHTML = ''; delete overlay.dataset.ownerKey; if (opts.onClose) opts.onClose(); };
  // Click anywhere on dialog to dismiss (no OK button)
  box.style.cursor = 'pointer';
  box.onclick = (e) => { if (!e.target.closest('.response-btn')) dismissFn(); };
  // Small hint
  const hint = document.createElement('div');
  hint.style.cssText = 'margin-top:8px;font-size:0.55rem;color:rgba(255,255,255,0.25);text-align:center;';
  hint.textContent = '▼ tap to continue';
  content.appendChild(hint);
  box.appendChild(content);
  overlay.appendChild(box);
  // Auto-dismiss timer
  if (opts.duration && opts.duration > 0) {
    setTimeout(() => { if (overlay.contains(box)) dismissFn(); }, opts.duration);
  }
}

// ── Response Dialog — multiple choice ──
function showResponse(text, responses, owner, other, colKey, opts) {
  const options = opts || {};
  const bgColor = options.bgColor || '#0a0c12';
  const textColor = options.textColor || '#e0e0e0';
  let overlay = document.getElementById('dialog-overlay');
  if (!overlay) {
    overlay = document.createElement('div');
    overlay.id = 'dialog-overlay';
    overlay.style.cssText = 'position:fixed;bottom:0;left:0;right:0;z-index:200;display:flex;justify-content:center;padding:20px;pointer-events:none;';
    document.body.appendChild(overlay);
  }
  overlay.innerHTML = '';
  if (options.ownerKey) overlay.dataset.ownerKey = options.ownerKey;
  const box = document.createElement('div');
  box.style.cssText = `max-width:650px;width:100%;background:${bgColor}ee;backdrop-filter:blur(16px);border:1px solid rgba(255,255,255,0.1);border-radius:14px;padding:16px 20px;pointer-events:auto;color:${textColor};font-family:Rajdhani,sans-serif;font-size:0.9rem;line-height:1.5;animation:dialog-in 0.3s ease;`;
  if (options.speaker) {
    const sp = document.createElement('div');
    sp.style.cssText = 'font-size:0.65rem;color:#f59e0b;letter-spacing:1px;text-transform:uppercase;margin-bottom:4px;font-weight:700;';
    sp.textContent = options.speaker;
    box.appendChild(sp);
  }
  if (text) { const t = document.createElement('div'); t.textContent = text; t.style.marginBottom = '12px'; box.appendChild(t); }
  // Response buttons
  const btnWrap = document.createElement('div');
  btnWrap.style.cssText = 'display:flex;flex-direction:column;gap:6px;';
  (responses || []).forEach((resp, i) => {
    const btn = document.createElement('button');
    btn.className = 'response-btn';
    btn.style.cssText = 'padding:8px 14px;background:rgba(255,255,255,0.06);border:1px solid rgba(255,255,255,0.15);border-radius:8px;color:#e0e0e0;cursor:pointer;font-family:Rajdhani,sans-serif;font-size:0.8rem;text-align:left;transition:background 0.15s;';
    btn.onmouseenter = () => { btn.style.background = 'rgba(0,229,160,0.15)'; btn.style.borderColor = '#00e5a0'; };
    btn.onmouseleave = () => { btn.style.background = 'rgba(255,255,255,0.06)'; btn.style.borderColor = 'rgba(255,255,255,0.15)'; };
    btn.textContent = resp.text || `Option ${i+1}`;
    btn.onclick = (e) => {
      e.stopPropagation();
      overlay.innerHTML = '';
      delete overlay.dataset.ownerKey;
      // Execute this choice's action chain
      if (resp.actions && resp.actions.length) {
        let cumDelay = 0;
        resp.actions.forEach((act, ai) => {
          cumDelay += (ai === 0 ? 0 : (act.delay || 0));
          const d = cumDelay;
          if (d === 0) executeSingleAction(act, owner, other, colKey);
          else setTimeout(() => executeSingleAction(act, owner, other, colKey), d);
        });
      }
    };
    btnWrap.appendChild(btn);
  });
  box.appendChild(btnWrap);
  overlay.appendChild(box);
}

// ── 3D Billboard Dialogs ──
const billboard3DDialogs = []; // { el, entry, offsetY, id }
let billboard3DContainer = null;

function show3DDialog(entry, text, options) {
  const opts = options || {};
  const bgColor = opts.bgColor || '#0a0c12';
  const textColor = opts.textColor || '#e0e0e0';
  const borderColor = opts.borderColor || 'rgba(255,255,255,0.12)';
  const borderWidth = opts.borderWidth || 1;
  if (!billboard3DContainer) {
    billboard3DContainer = document.createElement('div');
    billboard3DContainer.id = 'billboard-3d-container';
    billboard3DContainer.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;z-index:150;pointer-events:none;overflow:hidden;';
    document.body.appendChild(billboard3DContainer);
  }

  const existing = billboard3DDialogs.findIndex(d => d.entry === entry);
  if (existing !== -1) { billboard3DDialogs[existing].el.remove(); billboard3DDialogs.splice(existing, 1); }

  const el = document.createElement('div');
  el.style.cssText = `position:absolute;pointer-events:auto;cursor:pointer;max-width:280px;background:${bgColor}ee;backdrop-filter:blur(14px);border:${borderWidth}px solid ${borderColor};border-radius:12px;padding:10px 14px;color:${textColor};font-family:Rajdhani,sans-serif;font-size:0.8rem;line-height:1.4;transform:translate(-50%,-100%);white-space:pre-wrap;visibility:hidden;opacity:0;`;

  const arrow = document.createElement('div');
  arrow.style.cssText = `position:absolute;bottom:-6px;left:50%;transform:translateX(-50%);width:0;height:0;border-left:6px solid transparent;border-right:6px solid transparent;border-top:6px solid ${bgColor}ee;`;
  el.appendChild(arrow);

  if (opts.speaker) {
    const sp = document.createElement('div');
    sp.style.cssText = 'font-size:0.6rem;color:#f59e0b;letter-spacing:1px;text-transform:uppercase;margin-bottom:3px;font-weight:700;';
    sp.textContent = opts.speaker;
    el.appendChild(sp);
  }
  const txt = document.createElement('div'); txt.textContent = text; el.appendChild(txt);

  const dialogId = Math.random().toString(36).substr(2, 6);
  el.onclick = () => {
    el.remove();
    const i = billboard3DDialogs.findIndex(d => d.id === dialogId);
    if (i !== -1) billboard3DDialogs.splice(i, 1);
    if (opts.onClose) opts.onClose();
  };
  el.onmouseenter = () => { el.style.opacity = '0.7'; };
  el.onmouseleave = () => { el.style.opacity = '1'; };

  billboard3DContainer.appendChild(el);
  const dEntry = {
    el, entry, id: dialogId,
    offsetX: opts.offsetX || 0, offsetY: opts.offsetY || 2.0, offsetZ: opts.offsetZ || 0
  };
  billboard3DDialogs.push(dEntry);
  // Wait one frame for layout calc (translate(-50%,-100%) needs dimensions), then reveal
  requestAnimationFrame(() => {
    const wp2 = new THREE.Vector3();
    entry.mesh.getWorldPosition(wp2);
    wp2.x += dEntry.offsetX; wp2.y += dEntry.offsetY; wp2.z += dEntry.offsetZ;
    const sp2 = wp2.project(camera);
    el.style.left = ((sp2.x * 0.5 + 0.5) * window.innerWidth) + 'px';
    el.style.top = ((-sp2.y * 0.5 + 0.5) * window.innerHeight) + 'px';
    el.style.visibility = 'visible';
    el.style.opacity = '1';
    el.style.transition = 'opacity 0.3s';
  });
  // Auto-dismiss timer
  if (opts.duration && opts.duration > 0) {
    setTimeout(() => { const i = billboard3DDialogs.findIndex(d => d.id === dialogId); if (i !== -1) { billboard3DDialogs[i].el.remove(); billboard3DDialogs.splice(i, 1); } }, opts.duration);
  }
}

function update3DDialogs() {
  if (!billboard3DDialogs.length) return;
  for (const d of billboard3DDialogs) {
    const worldPos = new THREE.Vector3();
    d.entry.mesh.getWorldPosition(worldPos);
    worldPos.x += (d.offsetX || 0);
    worldPos.y += (d.offsetY || 2.0);
    worldPos.z += (d.offsetZ || 0);
    const screenPos = worldPos.project(camera);
    const x = (screenPos.x * 0.5 + 0.5) * window.innerWidth;
    const y = (-screenPos.y * 0.5 + 0.5) * window.innerHeight;
    if (screenPos.z > 1) { d.el.style.visibility = 'hidden'; continue; }
    d.el.style.visibility = 'visible';
    d.el.style.opacity = '1';
    d.el.style.left = x + 'px';
    d.el.style.top = y + 'px';
  }
}

/* ═══════════════════════════════════════════
   IFRAME OVERLAY — Full-screen in-game browser
   ═══════════════════════════════════════════ */
function showIframeOverlay(url, backIcon, closeSound) {
  let ov = document.getElementById('iframe-game-overlay');
  if (ov) ov.remove();
  ov = document.createElement('div');
  ov.id = 'iframe-game-overlay';
  ov.style.cssText = 'position:fixed;inset:0;z-index:20000;background:#fff;';
  const btn = document.createElement('button');
  btn.style.cssText = 'position:absolute;top:10px;left:10px;z-index:20001;width:40px;height:40px;border-radius:50%;border:none;background:rgba(0,0,0,0.5);backdrop-filter:blur(8px);cursor:pointer;display:flex;align-items:center;justify-content:center;transition:transform 0.15s,background 0.15s;';
  btn.onmouseenter = () => { btn.style.transform = 'scale(1.1)'; btn.style.background = 'rgba(0,0,0,0.7)'; };
  btn.onmouseleave = () => { btn.style.transform = 'scale(1)'; btn.style.background = 'rgba(0,0,0,0.5)'; };
  if (backIcon) {
    const img = document.createElement('img'); img.src = backIcon; img.style.cssText = 'width:24px;height:24px;object-fit:contain;'; img.onerror = () => { img.replaceWith(Object.assign(document.createElement('i'),{className:'bi bi-x-lg',style:'color:#fff;font-size:1.2rem;'})); };
    btn.appendChild(img);
  } else {
    btn.innerHTML = '<i class="bi bi-x-lg" style="color:#fff;font-size:1.1rem;"></i>';
  }
  btn.onclick = () => {
    if (closeSound) { try { new Audio(closeSound).play().catch(()=>{}); } catch(e){} }
    ov.remove();
  };
  ov.appendChild(btn);
  const iframe = document.createElement('iframe');
  iframe.src = url; iframe.style.cssText = 'position:absolute;inset:0;border:none;width:100%;height:100%;';
  iframe.setAttribute('allow','autoplay;fullscreen;encrypted-media');
  ov.appendChild(iframe);
  document.body.appendChild(ov);
}

/* ═══════════════════════════════════════════
   HTML POPUP — centered overlay with close button
   ═══════════════════════════════════════════ */
function showHTMLPopup(html, opts) {
  const bg = (opts && opts.bgColor) || '#0a0c12';
  const fg = (opts && opts.textColor) || '#e0e0e0';
  let ov = document.getElementById('html-popup-overlay');
  if (ov) ov.remove();
  ov = document.createElement('div');
  ov.id = 'html-popup-overlay';
  ov.style.cssText = 'position:fixed;inset:0;z-index:15000;background:rgba(0,0,0,0.7);backdrop-filter:blur(6px);display:flex;align-items:center;justify-content:center;animation:dialog-in 0.3s ease;';
  const box = document.createElement('div');
  box.style.cssText = `max-width:700px;max-height:80vh;width:90%;background:${bg}ee;border:1px solid rgba(255,255,255,0.1);border-radius:14px;padding:20px;color:${fg};font-family:Rajdhani,sans-serif;overflow:auto;position:relative;`;
  const closeBtn = document.createElement('button');
  closeBtn.style.cssText = 'position:absolute;top:8px;right:8px;width:28px;height:28px;border-radius:50%;border:1px solid rgba(255,255,255,0.15);background:rgba(255,255,255,0.06);color:#aaa;cursor:pointer;font-size:1rem;display:flex;align-items:center;justify-content:center;z-index:1;';
  closeBtn.innerHTML = '✕';
  closeBtn.onclick = () => { ov.remove(); };
  box.appendChild(closeBtn);
  const content = document.createElement('div'); content.style.marginTop = '10px';
  content.innerHTML = html;
  box.appendChild(content);
  ov.appendChild(box);
  ov.addEventListener('click', e => { if (e.target === ov) ov.remove(); });
  document.body.appendChild(ov);
}

/* ═══════════════════════════════════════════
   3D HTML BILLBOARD — floating HTML panel facing camera
   ═══════════════════════════════════════════ */
function show3DHTMLBillboard(entry, html, opts) {
  const bg = (opts && opts.bgColor) || '#0a0c12';
  const fg = (opts && opts.textColor) || '#e0e0e0';
  const offsetY = (opts && opts.offsetY) || 2.5;
  if (!billboard3DContainer) {
    billboard3DContainer = document.createElement('div');
    billboard3DContainer.id = 'billboard-3d-container';
    billboard3DContainer.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;z-index:150;pointer-events:none;overflow:hidden;';
    document.body.appendChild(billboard3DContainer);
  }
  // Remove existing for this entry
  const existing = billboard3DDialogs.findIndex(d => d.entry === entry && d._isHTML);
  if (existing !== -1) { billboard3DDialogs[existing].el.remove(); billboard3DDialogs.splice(existing, 1); }
  const el = document.createElement('div');
  el.style.cssText = `position:absolute;pointer-events:auto;max-width:320px;background:${bg}ee;backdrop-filter:blur(14px);border:1px solid rgba(255,255,255,0.12);border-radius:12px;padding:10px 14px;color:${fg};font-family:Rajdhani,sans-serif;font-size:0.8rem;line-height:1.4;transform:translate(-50%,-100%);animation:dialog-in 0.3s ease;position:absolute;`;
  const closeBtn = document.createElement('button');
  closeBtn.style.cssText = 'position:absolute;top:4px;right:4px;width:20px;height:20px;border-radius:50%;border:none;background:rgba(255,255,255,0.1);color:#aaa;cursor:pointer;font-size:0.7rem;display:flex;align-items:center;justify-content:center;';
  closeBtn.innerHTML = '✕';
  const dialogId = 'html3d_' + Date.now();
  closeBtn.onclick = () => { el.remove(); const i = billboard3DDialogs.findIndex(d => d.id === dialogId); if (i !== -1) billboard3DDialogs.splice(i, 1); };
  el.appendChild(closeBtn);
  const content = document.createElement('div'); content.style.marginTop = '6px';
  content.innerHTML = html;
  el.appendChild(content);
  billboard3DContainer.appendChild(el);
  billboard3DDialogs.push({ el, entry, offsetX: 0, offsetY, offsetZ: 0, id: dialogId, _isHTML: true });
}

/* ═══════════════════════════════════════════
   GAME OBJECT API — access objects by ID
   ═══════════════════════════════════════════ */
/* ═══════════════════════════════════════════
   GAME UI OVERLAY SYSTEM
   ═══════════════════════════════════════════ */
let gameUIData = null;
let gameUIElements = {};  // id → DOM element
let gameUIState = {};     // variable bindings: score, timer, health, etc.

function loadGameUI(uiData) {
  // Remove existing UI overlay
  const existing = document.getElementById('game-ui-overlay');
  if (existing) existing.remove();
  const existingStyle = document.getElementById('game-ui-styles');
  if (existingStyle) existingStyle.remove();
  const existingScript = document.getElementById('game-ui-custom-code');
  if (existingScript) existingScript.remove();
  gameUIElements = {};
  gameUIData = uiData;

  const overlay = document.createElement('div');
  overlay.id = 'game-ui-overlay';
  document.body.appendChild(overlay);

  // Generate responsive CSS from per-device overrides
  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.fontSize !== undefined) css += `  #gui-${elId} .gui-inner { font-size:${ov.fontSize}${ov.fontUnit || fontUnit || 'rem'} !important; }\n`;
    if (ov.fontWeight !== undefined) css += `  #gui-${elId} .gui-inner { font-weight:${ov.fontWeight} !important; }\n`;
    if (ov.padding !== undefined) css += `  #gui-${elId} .gui-inner { padding:${ov.padding} !important; }\n`;
    if (ov.background !== undefined) css += `  #gui-${elId} .gui-inner { background:${ov.background} !important; }\n`;
    if (ov.borderRadius !== undefined) css += `  #gui-${elId} .gui-inner { border-radius:${ov.borderRadius} !important; }\n`;
    if (ov.border !== undefined) css += `  #gui-${elId} .gui-inner { border:${ov.border} !important; }\n`;
    if (ov.opacity !== undefined) css += `  #gui-${elId} { opacity:${ov.opacity} !important; }\n`;
    if (ov.color !== undefined) css += `  #gui-${elId} .gui-inner { color:${ov.color} !important; }\n`;
    if (ov.textAlign !== undefined) css += `  #gui-${elId} .gui-inner { text-align:${ov.textAlign} !important; }\n`;
    if (ov.boxShadow !== undefined) css += `  #gui-${elId} .gui-inner { box-shadow:${ov.boxShadow} !important; }\n`;
    if (ov.letterSpacing !== undefined) css += `  #gui-${elId} .gui-inner { letter-spacing:${ov.letterSpacing} !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)',
  };
  let responsiveCss = '';

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

  parents.forEach(el => {
    if (el.disabled) return; // disabled elements are never rendered
    const dom = createGameUIElement(el, uiData);
    overlay.appendChild(dom);
    gameUIElements[el.id] = { dom, data: el };
    // Collect overrides
    if (el.responsive && el.overrides) {
      for (const [dev, query] of Object.entries(deviceQueries)) {
        const ov = el.overrides[dev];
        const css = genOverrideCss(ov, el.id, el.fontUnit);
        if (css) responsiveCss += `${query} {\n${css}}\n`;
      }
    }
    // Legacy migration support
    if (el.mobileHidden) responsiveCss += `@media (max-width:768px) { #gui-${el.id} { display:none !important; } }\n`;
  });
  children.forEach(el => {
    if (el.disabled) return; // disabled elements are never rendered
    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, uiData);
    container.appendChild(dom);
    dom.style.pointerEvents = 'auto';
    gameUIElements[el.id] = { dom, data: el };
    if (el.responsive && el.overrides) {
      for (const [dev, query] of Object.entries(deviceQueries)) {
        const ov = el.overrides[dev];
        const css = genOverrideCss(ov, el.id, el.fontUnit);
        if (css) responsiveCss += `${query} {\n${css}}\n`;
      }
    }
  });

  // Generate showOn visibility CSS
  function genShowOnCss(el) {
    if (!el.showOn || el.showOn.length === 0) return ''; // empty = show everywhere
    let css = '';
    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)',
    };
    for (const [dev, query] of Object.entries(showOnQueries)) {
      if (!el.showOn.includes(dev)) {
        css += `${query} { #gui-${el.id} { display:none !important; } }\n`;
      }
    }
    return css;
  }

  style.textContent = responsiveCss;
  // Append showOn CSS for all elements
  (uiData.elements || []).forEach(el => {
    style.textContent += genShowOnCss(el);
  });
  document.head.appendChild(style);

  // Inject custom code AFTER all UI elements are created
  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('[GameUI] Loaded ' + (uiData.elements || []).length + ' UI elements');
}

/* ── 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 element helpers (UI Builder 'video' type) ──
/* ── 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;
  }
}

function createGameUIElement(el, uiData) {
  const dom = document.createElement('div');
  dom.className = 'gui-el';
  dom.id = 'gui-' + el.id;

  // Anchor-based transform
  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');

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

  // Apply styles
  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';

  // Apply 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') {
    // Panel children container inside inner for proper positioning
    const childBox = document.createElement('div');
    childBox.className = 'gui-children';
    childBox.style.cssText = 'position:absolute;inset:0;pointer-events:none;';
    inner.appendChild(childBox);

    // ── D-Pad controller ──
    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 ? 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 defaultBg = cBg;
        const press = () => {
          if (window.playerKeys) window.playerKeys[key] = true;
          btn.style.background = cActive;
        };
        const release = () => {
          if (window.playerKeys) {
            window.playerKeys[key] = false;
            if (!playerKeys.w && !playerKeys.s && !playerKeys.a && !playerKeys.d) {
              window.dispatchEvent(new KeyboardEvent('keyup', { key }));
            }
          }
          btn.style.background = defaultBg;
        };
        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 controller ──
    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; knob.style.cursor = 'grabbing';
      });
      const onMove = e => {
        if (!dragging || !baseRect) return;
        const cx = baseRect.left + baseRect.width / 2;
        const cy = baseRect.top + baseRect.height / 2;
        const maxR = baseRect.width / 2;
        let dx = (e.clientX - cx) / maxR;
        let 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) + '%';
        if (window.playerKeys) {
          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; knob.style.cursor = 'grab';
        if (window.playerKeys) {
          playerKeys.w = playerKeys.s = playerKeys.a = playerKeys.d = playerKeys.shift = false;
          window.dispatchEvent(new KeyboardEvent('keyup', { key: 'w' }));
        }
      };
      knob.addEventListener('pointermove', onMove);
      knob.addEventListener('pointerup', onRelease);
      knob.addEventListener('pointercancel', onRelease);
      knob.addEventListener('lostpointercapture', onRelease);
    }
  }

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

  // ── Attach event-based action chains ──
  const evts = el.events || {};
  // Legacy: migrate old actions[] or onClick
  if (!el.events && el.actions) { evts.click = el.actions; }
  if (el.onClick && (!evts.click || evts.click.length === 0)) {
    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;
  // Default: buttons get brightness hover if no custom hover set
  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;
}

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 isHidden = entry.dom.classList.toggle('gui-hidden');
            entry.data.visible = !isHidden;
          }
          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 fill = entry.dom.querySelector('.gui-progressbar-fill');
            if (fill) fill.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 audio = new Audio(act.soundUrl);
              audio.volume = act.volume !== undefined ? act.volume : 1;
              audio.play().catch(e => console.warn('Audio play failed:', e));
            } catch(e) { console.warn('Sound error:', 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) { console.warn('funcArgs parse error:', e); }
            }
            window[act.funcName](...extraArgs, sourceEl, entry ? entry.data : null);
          }
          break;
        case 'addAnimation':
          if (entry && act.animClass) {
            const aInner = entry.dom.querySelector('.gui-inner');
            if (aInner) {
              [...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':
          if (entry) {
            const rInner = entry.dom.querySelector('.gui-inner');
            if (rInner) {
              if (act.animClass) {
                rInner.classList.remove(act.animClass);
                const hasOtherAnim = [...rInner.classList].some(c => c.startsWith('animate__') && c !== 'animate__animated');
                if (!hasOtherAnim) 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':
          if (act.levelFile) {
            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 || null, params);
          }
          break;
        case 'playMusic':
          if (act.musicSrc) GameAPI.playMusic(act.musicSrc, act.musicLoop !== false);
          break;
        case 'stopMusic':
          GameAPI.stopMusic();
          break;
        case 'setVolume':
          GameAPI.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) {
            if (act.htmlMode === '3d') show3DHTMLBillboard(null, act.htmlContent, { bgColor: act.htmlBgColor, textColor: act.htmlTextColor, offsetY: act.html3dOffsetY || 2.5 });
            else showHTMLPopup(act.htmlContent, { bgColor: act.htmlBgColor, textColor: act.htmlTextColor });
          }
          break;
      }
    }, delay);
  });
}

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

function updateGameUI() {
  if (!gameUIData) return;
  // Update bound text elements and progress bars
  for (const [id, entry] of Object.entries(gameUIElements)) {
    const el = entry.data;
    if (!el.binding) continue;
    const val = gameUIState[el.binding];
    if (val === undefined) continue;
    const inner = entry.dom.querySelector('.gui-inner');
    if (!inner) continue;

    if (el.type === 'text' || el.type === 'button') {
      inner.textContent = resolveBindingText(el.content, el.binding);
    } else if (el.type === 'progressbar') {
      const fill = inner.querySelector('.gui-progressbar-fill');
      if (fill) fill.style.width = Math.max(0, Math.min(100, val)) + '%';
    }
  }
}

// GameUI API for scripts
function setUIValue(varName, value) {
  gameUIState[varName] = value;
}
function getUIValue(varName) {
  return gameUIState[varName];
}
function setUIVisible(elementId, visible) {
  const entry = gameUIElements[elementId];
  if (entry) {
    entry.dom.classList.toggle('gui-hidden', !visible);
    entry.data.visible = visible;
  }
}
function setUIContent(elementId, content) {
  const entry = gameUIElements[elementId];
  if (!entry) return;
  entry.data.content = content;
  const inner = entry.dom.querySelector('.gui-inner');
  if (inner && (entry.data.type === 'text' || entry.data.type === 'button')) {
    inner.textContent = resolveBindingText(content, entry.data.binding);
  }
}
function setUIStyle(elementId, prop, value) {
  const entry = gameUIElements[elementId];
  if (!entry) return;
  const inner = entry.dom.querySelector('.gui-inner');
  if (inner) inner.style[prop] = value;
}

const GameAPI = {
  _globals: {},
  setGlobal(name, value) { this._globals[name] = value; },
  getGlobal(name) { return this._globals[name]; },

  /** Get entry by objectId */
  getObject(id) {
    if (!id) return null;
    if (typeof id === 'object' && id.objectId) return id;
    return placedItems.find(e => e.objectId === id || e.objectId === String(id)) || null;
  },

  /** Get all entries matching a filter: category, configId, or predicate function */
  getObjects(filter) {
    if (typeof filter === 'string') {
      // Check if it's a category name
      const byCat = placedItems.filter(e => e.config.category === filter);
      if (byCat.length) return byCat;
      // Check if it's a configId
      return placedItems.filter(e => e.config.id === filter);
    }
    if (typeof filter === 'function') return placedItems.filter(filter);
    return [...placedItems];
  },

  /** Set position of object by ID */
  setPosition(id, x, y, z) {
    const e = this.getObject(id); if (!e) return null;
    e.mesh.position.set(x, y, z);
    return e;
  },

  /** Set rotation of object by ID (degrees) */
  setRotation(id, rx, ry, rz) {
    const e = this.getObject(id); if (!e) return null;
    e.mesh.rotation.set(
      THREE.MathUtils.degToRad(rx),
      THREE.MathUtils.degToRad(ry),
      THREE.MathUtils.degToRad(rz)
    );
    return e;
  },

  /** Set scale of object by ID */
  setScale(id, sx, sy, sz) {
    const e = this.getObject(id); if (!e) return null;
    e.mesh.scale.set(sx, sy || sx, sz || sx);
    return e;
  },

  /** Play animation on object by ID */
  setAnimation(id, clipName, mode, repeatCount) {
    const e = this.getObject(id); if (!e) return null;
    if (mode) { playAnimationWithMode(e, clipName, mode, repeatCount); }
    else { playAnimation(e, clipName); }
    return e;
  },

  /** Get available animation names for object */
  getAnimations(id) {
    const e = this.getObject(id); if (!e) return [];
    const md = animationMixers.get(e);
    return md ? [...md.clipNames] : [];
  },

  /** Set visibility of object by ID */
  setVisible(id, visible) {
    const e = this.getObject(id); if (!e) return null;
    e.mesh.visible = visible;
    return e;
  },

  /** Destroy (remove) object by ID */
  destroy(id) {
    const e = this.getObject(id); if (!e) return false;
    e.mesh.visible = false;
    scene.remove(e.mesh);
    e.mesh.traverse(c => { if (c.isMesh) { c.geometry.dispose(); const m = c.material; Array.isArray(m) ? m.forEach(x=>x.dispose()) : m.dispose(); } });
    const idx = placedItems.indexOf(e);
    if (idx !== -1) placedItems.splice(idx, 1);
    return true;
  },

  /** Get world position of object by ID */
  getPosition(id) {
    const e = this.getObject(id); if (!e) return null;
    const p = e.mesh.position;
    return { x: p.x, y: p.y, z: p.z };
  },

  /** Get distance between two objects by ID */
  getDistance(id1, id2) {
    const a = this.getObject(id1), b = this.getObject(id2);
    if (!a || !b) return Infinity;
    return a.mesh.position.distanceTo(b.mesh.position);
  },

  /** Get/set a property on an object */
  getProperty(id, key) {
    const e = this.getObject(id); if (!e) return undefined;
    return e.properties[key];
  },
  setProperty(id, key, value) {
    const e = this.getObject(id); if (!e) return null;
    e.properties[key] = value;
    return e;
  },

  /** Switch camera view to a placed camera by objectId */
  switchCamera(id) {
    const cam = placedCameras.find(c => c.objectId === id);
    if (cam) applyCameraView(cam);
    return !!cam;
  },

  /** Play audio file */
  playAudio(src) {
    try { const a = new Audio(src); a.play().catch(e => console.warn('Audio:', e)); return a; } catch(e) { console.warn('Audio error:', e); return null; }
  },

  /** Show screen dialog (bottom center) */
  showDialog,
  /** Show 3D billboard dialog above an object */
  show3DDialog,
  /** Show a toast */
  showToast,
  /** ── Game UI methods ── */
  /** Set a game UI variable (triggers bound element updates) */
  setUIValue,
  /** Get a game UI variable */
  getUIValue,
  /** Show/hide a UI element by its ID */
  setUIVisible,
  /** Update text content of a UI element */
  setUIContent,
  /** Set a CSS style property on a UI element */
  setUIStyle,
  /** Load game UI from a .ui.json data object */
  loadGameUI,

  // ── Video UI elements ──
  /** Get the raw <video> element of a UI video by element ID */
  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);
  },

  // ── Key Simulation ──
  /** Press a key (simulates keydown). key = 'w','a','s','d','shift','space' or any key string */
  pressKey(key) {
    const k = key.toLowerCase();
    if (window.playerKeys && k in window.playerKeys) {
      window.playerKeys[k] = true;
    }
    window.dispatchEvent(new KeyboardEvent('keydown', { key: k, bubbles: true }));
  },
  /** Release a key (simulates keyup) */
  releaseKey(key) {
    const k = key.toLowerCase();
    if (window.playerKeys && k in window.playerKeys) {
      window.playerKeys[k] = false;
    }
    window.dispatchEvent(new KeyboardEvent('keyup', { key: k, bubbles: true }));
  },
  /** Press and auto-release after ms milliseconds */
  simulateKey(key, ms = 200) {
    this.pressKey(key);
    setTimeout(() => this.releaseKey(key), ms);
  },
  /** Release all currently pressed keys */
  releaseAllKeys() {
    if (window.playerKeys) {
      Object.keys(window.playerKeys).forEach(k => {
        window.playerKeys[k] = false;
      });
    }
    window.dispatchEvent(new KeyboardEvent('keyup', { key: 'w', bubbles: true }));
  },
};
window._gameGlobals = window._gameGlobals || {};
GameAPI.globals = window._gameGlobals;
GameAPI.setGlobal = function(n,v){ window._gameGlobals[n]=v; };
GameAPI.getGlobal = function(n){ return window._gameGlobals[n]; };
window.GameAPI = GameAPI;

// ── Scene Parameters (passed between scene transitions) ──
GameAPI.sceneParams = {};
GameAPI.getSceneParam = function(name) { return GameAPI.sceneParams[name]; };
GameAPI.setSceneParam = function(name, value) { GameAPI.sceneParams[name] = value; };

// ── Scene Transition System ──
GameAPI.loadScene = async function(levelSource, uiSource, params) {
  // Merge params into sceneParams (preserves previous params unless overwritten)
  if (params && typeof params === 'object') {
    Object.assign(GameAPI.sceneParams, params);
  }

  // Show transition loading overlay
  let overlay = document.getElementById('scene-transition-overlay');
  if (!overlay) {
    const s = gameLevelSettings;
    const bg = s.loadingBgColor || '#05070e';
    const accent = s.loadingAccentColor || '#00e5a0';
    const txtCol = s.loadingTextColor || '#a0b0c0';
    const font = s.loadingFontFamily || 'Rajdhani,sans-serif';
    overlay = document.createElement('div');
    overlay.id = 'scene-transition-overlay';
    overlay.style.cssText = `position:fixed;inset:0;z-index:10000;background:${bg}f8;display:flex;flex-direction:column;align-items:center;justify-content:center;font-family:${font};transition:opacity 0.4s;`;
    overlay.innerHTML = `
      <div style="font-family:Orbitron,sans-serif;font-size:1.2rem;letter-spacing:3px;color:${accent};margin-bottom:12px;">LOADING</div>
      <div id="scene-trans-msg" style="font-size:0.9rem;color:${txtCol};margin-bottom:20px;">${s.loadingMessage || 'Memuat kandungan. Sila tunggu sebentar.'}</div>
      <div style="width:280px;height:6px;background:rgba(255,255,255,0.08);border-radius:3px;overflow:hidden;">
        <div id="scene-trans-bar" style="height:100%;width:0%;background:linear-gradient(90deg,${accent},#00c8ff);border-radius:3px;transition:width 0.3s;"></div>
      </div>
      <div id="scene-trans-status" style="font-size:0.65rem;color:#607080;margin-top:10px;"></div>
    `;
    document.body.appendChild(overlay);
  }
  overlay.style.display = 'flex';
  overlay.style.opacity = '1';
  const bar = document.getElementById('scene-trans-bar');
  const status = document.getElementById('scene-trans-status');
  bar.style.width = '5%';
  status.textContent = '';

  try {
    // Load level data
    let levelData;
    if (typeof levelSource === 'string') {
      status.textContent = 'Fetching level data…';
      bar.style.width = '10%';
      const resp = await fetch(levelSource);
      if (!resp.ok) throw new Error('Failed to load level: ' + resp.statusText);
      levelData = await resp.json();
    } else if (levelSource && typeof levelSource === 'object') {
      levelData = levelSource;
    } else {
      throw new Error('Invalid level source');
    }
    bar.style.width = '20%';

    // Load UI data (optional)
    let uiData = null;
    if (uiSource) {
      if (typeof uiSource === 'string') {
        status.textContent = 'Fetching UI data…';
        bar.style.width = '25%';
        try {
          const uiResp = await fetch(uiSource);
          if (uiResp.ok) uiData = await uiResp.json();
        } catch(e) { console.warn('UI load failed:', e); }
      } else if (typeof uiSource === 'object') {
        uiData = uiSource;
      }
    }
    bar.style.width = '30%';

    // Embed UI data if present
    if (uiData) levelData.gameUI = uiData;

    // Stop current music
    if (bgMusic) { bgMusic.pause(); bgMusic.src = ''; bgMusic = null; }

    // Clear active collisions and timers
    activeCollisions.clear();
    collisionTimers.forEach(timers => timers.forEach(t => clearTimeout(t)));
    collisionTimers.clear();
    billboard3DDialogs.forEach(d => d.el.remove());
    billboard3DDialogs.length = 0;

    // Clear screen dialog
    const dialogOv = document.getElementById('dialog-overlay');
    if (dialogOv) dialogOv.innerHTML = '';

    // Clear particle systems
    particleSystems.forEach((sys, entry) => {
      if (sys.points) scene.remove(sys.points);
    });
    particleSystems.clear();

    bar.style.width = '40%';
    status.textContent = 'Building scene…';

    // Reload item config in case new scene uses different models
    await loadItemConfig();
    bar.style.width = '50%';

    // Load the scene
    await loadScene(levelData);
    bar.style.width = '100%';
    status.textContent = 'Done!';

    // Fade out overlay
    await new Promise(r => setTimeout(r, 300));
    overlay.style.opacity = '0';
    await new Promise(r => setTimeout(r, 400));
    overlay.style.display = 'none';

  } catch(err) {
    console.error('Scene transition error:', err);
    status.textContent = 'Error: ' + err.message;
    status.style.color = '#ff6b6b';
    bar.style.width = '100%';
    bar.style.background = '#ff6b6b';
    // Allow clicking to dismiss on error
    overlay.onclick = () => { overlay.style.display = 'none'; overlay.onclick = null; };
  }
};

window.GameAPI.moveTo = function(id, tx, ty, tz, speed) {
  var e = GameAPI.getObject(id); if (!e) return;
  speed = speed || 1;
  var dx = tx - e.mesh.position.x, dy = ty - e.mesh.position.y, dz = tz - e.mesh.position.z;
  var dist = Math.sqrt(dx*dx+dy*dy+dz*dz); if (dist < 0.01) return;
  var dur = dist / speed * 1000;
  var sx = e.mesh.position.x, sy = e.mesh.position.y, sz = e.mesh.position.z;
  var start = performance.now();
  (function step() {
    var t = Math.min(1, (performance.now() - start) / dur);
    e.mesh.position.set(sx+dx*t, sy+dy*t, sz+dz*t);
    if (t < 1) requestAnimationFrame(step);
  })();
};
window.GameAPI.lookAt = function(id, targetId) {
  var e = GameAPI.getObject(id), t = GameAPI.getObject(targetId);
  if (e && t) e.mesh.lookAt(t.mesh.position);
};

// GameAPI extensions for particles
window.GameAPI.playParticle = function(objectId) {
  const entry = placedItems.find(e => e.objectId === objectId);
  if (!entry) return;
  const sys = particleSystems.get(entry);
  if (sys) {
    sys.active = true;
    sys.elapsed = 0;
    sys.points.visible = true;
    sys.burstDone = false;
    // Re-init all particles
    const count = sys.ages.length;
    for (let i = 0; i < count; i++) {
      _resetGLParticle(i, sys.positions, sys.velocities, sys.ages, sys.lifetimes, sys.sizes, sys.colors, entry.properties, sys.cStart);
      sys.ages[i] = Math.random() * (entry.properties.lifetime || 2);
    }
    if (entry.properties.burstMode) sys.burstDone = true;
  } else {
    entry.properties.autoPlay = true;
    createParticleSystem(entry);
  }
  entry.mesh.visible = true;
};
window.GameAPI.stopParticle = function(objectId) {
  const entry = placedItems.find(e => e.objectId === objectId);
  if (!entry) return;
  const sys = particleSystems.get(entry);
  if (sys) { sys.active = false; sys.points.visible = false; }
};
window.GameAPI.setParticleVisible = function(objectId, visible) {
  const entry = placedItems.find(e => e.objectId === objectId);
  if (entry) entry.mesh.visible = visible;
};

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

function initMusicFromLevel() {
  const src = levelMeta.music;
  if (!src) return;
  playBGMusic(resolveModelPath(src));
}

function playBGMusic(src) {
  if (bgMusic) { bgMusic.pause(); bgMusic.src = ''; }
  bgMusic = new Audio(src);
  bgMusic.loop = true;
  bgMusic.volume = musicMuted ? 0 : masterVolume;
  bgMusic.play().catch(e => {
    // Autoplay blocked — wait for first user interaction
    const resume = () => {
      bgMusic.play().catch(() => {});
      document.removeEventListener('click', resume);
      document.removeEventListener('keydown', resume);
    };
    document.addEventListener('click', resume, { once: false });
    document.addEventListener('keydown', resume, { once: false });
  });
}

function updateMusicVolume() {
  if (bgMusic) bgMusic.volume = musicMuted ? 0 : masterVolume;
  const btn = document.getElementById('btn-music-toggle');
  if (btn) btn.innerHTML = musicMuted ? '<i class="bi bi-music-note-beamed" style="opacity:0.3;text-decoration:line-through;"></i>' : '<i class="bi bi-music-note-beamed"></i>';
  const lbl = document.getElementById('vol-label');
  if (lbl) lbl.textContent = musicMuted ? 'OFF' : Math.round(masterVolume * 100) + '%';
}

// Volume slider
document.getElementById('vol-slider').addEventListener('input', function() {
  masterVolume = parseFloat(this.value);
  if (musicMuted && masterVolume > 0) musicMuted = false;
  updateMusicVolume();
});
document.getElementById('btn-music-toggle').addEventListener('click', function() {
  musicMuted = !musicMuted;
  updateMusicVolume();
});

// GameAPI: Music & Volume
GameAPI.playMusic = function(src, loop) {
  if (loop === undefined) loop = true;
  if (bgMusic) { bgMusic.pause(); bgMusic.src = ''; }
  bgMusic = new Audio(src);
  bgMusic.loop = loop;
  bgMusic.volume = musicMuted ? 0 : masterVolume;
  bgMusic.play().catch(() => {});
};
GameAPI.stopMusic = function() {
  if (bgMusic) { bgMusic.pause(); bgMusic.currentTime = 0; }
};
GameAPI.pauseMusic = function() {
  if (bgMusic) bgMusic.pause();
};
GameAPI.resumeMusic = function() {
  if (bgMusic) bgMusic.play().catch(() => {});
};
GameAPI.setMusicVolume = function(vol) {
  masterVolume = Math.max(0, Math.min(1, vol));
  const sl = document.getElementById('vol-slider');
  if (sl) sl.value = masterVolume;
  updateMusicVolume();
};
GameAPI.getMusicVolume = function() { return masterVolume; };
GameAPI.setMusicMuted = function(muted) {
  musicMuted = !!muted;
  updateMusicVolume();
};
GameAPI.isMusicMuted = function() { return musicMuted; };
GameAPI.playAudio = function(src, vol) {
  try {
    const a = new Audio(src);
    a.volume = (vol !== undefined ? vol : 1) * masterVolume;
    a.play().catch(() => {});
    return a;
  } catch(e) { return null; }
};

// Expose overlay functions for scripts
GameAPI.showIframeOverlay = showIframeOverlay;
GameAPI.showHTMLPopup = showHTMLPopup;
GameAPI.show3DHTMLBillboard = show3DHTMLBillboard;
window.showIframeOverlay = showIframeOverlay;
window.showHTMLPopup = showHTMLPopup;
window.show3DHTMLBillboard = show3DHTMLBillboard;


/* ═══════════════════════════════════════════
   FILE INPUT & DRAG-DROP (Dual Zone + Start)
   ═══════════════════════════════════════════ */
let pendingLevelData = null;
let pendingUIData = null;

const dropLevel = document.getElementById('drop-zone-level');
const dropUI = document.getElementById('drop-zone-ui');
const fileLevel = document.getElementById('file-input-level');
const fileUI = document.getElementById('file-input-ui');
const btnStart = document.getElementById('btn-start-game');
const btnExport = document.getElementById('btn-export-standalone');

// ── Export Standalone HTML ──
function showExportPopup() {
  if (!pendingLevelData) { alert('No level data loaded'); return; }
  const levelName = pendingLevelData.level?.name || 'Game';
  const s = gameLevelSettings;

  // Remove existing popup
  let existing = document.getElementById('export-popup-overlay');
  if (existing) existing.remove();

  const overlay = document.createElement('div');
  overlay.id = 'export-popup-overlay';
  overlay.style.cssText = 'position:fixed;inset:0;z-index:10000;background:rgba(0,0,0,0.7);backdrop-filter:blur(6px);display:flex;align-items:center;justify-content:center;padding:20px;';

  const popup = document.createElement('div');
  popup.style.cssText = 'background:#0d0f17;border:1px solid rgba(255,255,255,0.1);border-radius:16px;width:100%;max-width:520px;max-height:90vh;overflow-y:auto;padding:0;font-family:Rajdhani,sans-serif;color:#c0c8d0;';

  // Header
  const hdr = document.createElement('div');
  hdr.style.cssText = 'padding:16px 20px;border-bottom:1px solid rgba(255,255,255,0.08);display:flex;align-items:center;justify-content:space-between;';
  hdr.innerHTML = '<div style="font-family:Orbitron,sans-serif;font-size:0.8rem;letter-spacing:2px;color:#a78bfa;">⬇ EXPORT STANDALONE</div>';
  const closeBtn = document.createElement('button');
  closeBtn.style.cssText = 'background:none;border:none;color:#666;cursor:pointer;font-size:1.1rem;padding:0;';
  closeBtn.innerHTML = '<i class="bi bi-x-lg"></i>';
  closeBtn.onclick = () => overlay.remove();
  hdr.appendChild(closeBtn);
  popup.appendChild(hdr);

  const body = document.createElement('div');
  body.style.cssText = 'padding:16px 20px;display:flex;flex-direction:column;gap:12px;';

  // State
  const exp = {
    filename: levelName.replace(/[^a-zA-Z0-9]/g, '_') + '_standalone',
    title: levelName,
    description: '',
    keywords: '',
    author: '',
    favicon: '',
    loadBg: s.loadingBgColor || '#05070e',
    loadBgImage: '',
    loadAccent: s.loadingAccentColor || '#00e5a0',
    loadTextColor: s.loadingTextColor || '#a0b0c0',
    loadTitle: s.loadingTitle || levelName,
    loadMessage: s.loadingMessage || 'Loading...',
    loadPosition: 'center',
    loadStyle: 'minimal',
    assetBasePath: gameLevelSettings.modelBasePath || './',
  };

  function makeRow(label) {
    const r = document.createElement('div');
    r.style.cssText = 'display:flex;flex-direction:column;gap:3px;';
    const l = document.createElement('label');
    l.style.cssText = 'font-size:0.6rem;color:#8090a0;text-transform:uppercase;letter-spacing:0.5px;';
    l.textContent = label;
    r.appendChild(l);
    return r;
  }
  function makeInput(parent, value, placeholder, onChange) {
    const inp = document.createElement('input');
    inp.type = 'text'; inp.value = value; inp.placeholder = placeholder;
    inp.style.cssText = 'padding:6px 10px;background:rgba(255,255,255,0.04);border:1px solid rgba(255,255,255,0.1);border-radius:6px;color:#e0e0e0;font-family:Rajdhani,sans-serif;font-size:0.75rem;outline:none;';
    inp.oninput = () => onChange(inp.value);
    parent.appendChild(inp);
    return inp;
  }
  function makeColorRow(parent, label, value, onChange) {
    const r = document.createElement('div');
    r.style.cssText = 'display:flex;align-items:center;gap:8px;';
    const l = document.createElement('span'); l.style.cssText = 'font-size:0.6rem;color:#8090a0;min-width:60px;'; l.textContent = label;
    const c = document.createElement('input'); c.type = 'color'; c.value = value;
    c.style.cssText = 'width:32px;height:24px;border:1px solid rgba(255,255,255,0.1);border-radius:4px;cursor:pointer;padding:0;background:none;';
    c.oninput = () => onChange(c.value);
    r.appendChild(l); r.appendChild(c); parent.appendChild(r);
  }

  // ── Section: File ──
  const secFile = document.createElement('div');
  secFile.innerHTML = '<div style="font-size:0.55rem;color:#a78bfa;text-transform:uppercase;letter-spacing:1px;margin-bottom:4px;font-weight:700;">📁 File</div>';
  let r = makeRow('Filename'); makeInput(r, exp.filename, 'my_game', v => { exp.filename = v; }); secFile.appendChild(r);
  // Asset base path
  r = makeRow('Assets Base Path');
  const abpHint = document.createElement('div');
  abpHint.style.cssText = 'font-size:0.5rem;color:#607080;line-height:1.3;margin-bottom:2px;';
  abpHint.textContent = 'Relative path from standalone HTML to the folder containing models/, images/, audio/ etc.';
  r.appendChild(abpHint);
  makeInput(r, exp.assetBasePath, './  or  ../world-builder/', v => { exp.assetBasePath = v; });
  secFile.appendChild(r);
  body.appendChild(secFile);

  // ── Section: Meta ──
  const secMeta = document.createElement('div');
  secMeta.innerHTML = '<div style="font-size:0.55rem;color:#38bdf8;text-transform:uppercase;letter-spacing:1px;margin-bottom:4px;font-weight:700;">🏷 HTML Meta</div>';
  r = makeRow('Page Title'); makeInput(r, exp.title, 'My Game', v => { exp.title = v; }); secMeta.appendChild(r);
  r = makeRow('Description'); makeInput(r, exp.description, 'An awesome 3D game', v => { exp.description = v; }); secMeta.appendChild(r);
  r = makeRow('Keywords'); makeInput(r, exp.keywords, 'game, 3d, adventure', v => { exp.keywords = v; }); secMeta.appendChild(r);
  r = makeRow('Author'); makeInput(r, exp.author, 'Your name', v => { exp.author = v; }); secMeta.appendChild(r);
  r = makeRow('Favicon URL'); makeInput(r, exp.favicon, './favicon.ico', v => { exp.favicon = v; }); secMeta.appendChild(r);
  body.appendChild(secMeta);

  // ── Section: Loading Screen ──
  const secLoad = document.createElement('div');
  secLoad.innerHTML = '<div style="font-size:0.55rem;color:#00e5a0;text-transform:uppercase;letter-spacing:1px;margin-bottom:4px;font-weight:700;">⏳ Loading Screen</div>';

  r = makeRow('Loading Title'); makeInput(r, exp.loadTitle, 'LOADING', v => { exp.loadTitle = v; }); secLoad.appendChild(r);
  r = makeRow('Loading Message'); makeInput(r, exp.loadMessage, 'Please wait...', v => { exp.loadMessage = v; }); secLoad.appendChild(r);

  // Colors row
  const colorsRow = document.createElement('div');
  colorsRow.style.cssText = 'display:flex;gap:12px;flex-wrap:wrap;';
  makeColorRow(colorsRow, 'Background', exp.loadBg, v => { exp.loadBg = v; });
  makeColorRow(colorsRow, 'Accent', exp.loadAccent, v => { exp.loadAccent = v; });
  makeColorRow(colorsRow, 'Text', exp.loadTextColor, v => { exp.loadTextColor = v; });
  secLoad.appendChild(colorsRow);

  // Background image
  r = makeRow('Background Image (optional)');
  const bgImgRow = document.createElement('div'); bgImgRow.style.cssText = 'display:flex;gap:4px;align-items:center;';
  const bgImgInp = document.createElement('input'); bgImgInp.type = 'text'; bgImgInp.value = exp.loadBgImage; bgImgInp.placeholder = './images/loading-bg.jpg or upload';
  bgImgInp.style.cssText = 'flex:1;padding:5px 8px;background:rgba(255,255,255,0.04);border:1px solid rgba(255,255,255,0.1);border-radius:5px;color:#e0e0e0;font-size:0.65rem;outline:none;';
  bgImgInp.onchange = () => { exp.loadBgImage = bgImgInp.value; };
  const bgUpBtn = document.createElement('button');
  bgUpBtn.style.cssText = 'padding:4px 8px;background:rgba(255,255,255,0.06);border:1px solid rgba(255,255,255,0.15);border-radius:5px;color:#aaa;cursor:pointer;font-size:0.55rem;white-space:nowrap;';
  bgUpBtn.textContent = '📷 Upload';
  bgUpBtn.onclick = () => {
    const inp = document.createElement('input'); inp.type = 'file'; inp.accept = 'image/*';
    inp.onchange = ev => { const f = ev.target.files[0]; if (!f) return; const rd = new FileReader(); rd.onload = re => { exp.loadBgImage = re.target.result; bgImgInp.value = '(uploaded image)'; }; rd.readAsDataURL(f); };
    inp.click();
  };
  bgImgRow.appendChild(bgImgInp); bgImgRow.appendChild(bgUpBtn);
  r.appendChild(bgImgRow); secLoad.appendChild(r);

  // Position
  r = makeRow('Loading Position');
  const posSel = document.createElement('select');
  posSel.style.cssText = 'padding:5px 8px;background:rgba(255,255,255,0.04);border:1px solid rgba(255,255,255,0.1);border-radius:5px;color:#e0e0e0;font-size:0.7rem;outline:none;';
  [['center','Center'],['bottom-center','Bottom Center'],['bottom-right','Bottom Right'],['top-center','Top Center']].forEach(([v,t]) => {
    const o = document.createElement('option'); o.value = v; o.textContent = t; o.style.background = '#1a1c2e'; if (v === exp.loadPosition) o.selected = true; posSel.appendChild(o);
  });
  posSel.onchange = () => { exp.loadPosition = posSel.value; };
  r.appendChild(posSel); secLoad.appendChild(r);

  // Style
  r = makeRow('Loading Style');
  const styleSel = document.createElement('select');
  styleSel.style.cssText = 'padding:5px 8px;background:rgba(255,255,255,0.04);border:1px solid rgba(255,255,255,0.1);border-radius:5px;color:#e0e0e0;font-size:0.7rem;outline:none;';
  [['minimal','Minimal (bar only)'],['spinner','Spinner + Text'],['dots','Animated Dots'],['fullscreen','Fullscreen with Logo']].forEach(([v,t]) => {
    const o = document.createElement('option'); o.value = v; o.textContent = t; o.style.background = '#1a1c2e'; if (v === exp.loadStyle) o.selected = true; styleSel.appendChild(o);
  });
  styleSel.onchange = () => { exp.loadStyle = styleSel.value; };
  r.appendChild(styleSel); secLoad.appendChild(r);

  body.appendChild(secLoad);
  popup.appendChild(body);

  // Footer
  const footer = document.createElement('div');
  footer.style.cssText = 'padding:12px 20px;border-top:1px solid rgba(255,255,255,0.08);display:flex;gap:8px;justify-content:flex-end;';
  const cancelBtn = document.createElement('button');
  cancelBtn.style.cssText = 'padding:8px 20px;background:rgba(255,255,255,0.04);border:1px solid rgba(255,255,255,0.1);border-radius:8px;color:#999;cursor:pointer;font-family:Rajdhani,sans-serif;font-size:0.75rem;';
  cancelBtn.textContent = 'Cancel';
  cancelBtn.onclick = () => overlay.remove();
  const exportBtn = document.createElement('button');
  exportBtn.style.cssText = 'padding:8px 24px;background:linear-gradient(135deg,#a78bfa,#7c3aed);border:none;border-radius:8px;color:#fff;cursor:pointer;font-family:Rajdhani,sans-serif;font-size:0.8rem;font-weight:600;letter-spacing:1px;';
  exportBtn.textContent = '⬇ Export HTML';
  exportBtn.onclick = async () => {
    exportBtn.textContent = '⏳ Generating...'; exportBtn.disabled = true;
    try {
      await doExport(exp);
      overlay.remove();
    } catch(e) {
      alert('Export failed: ' + e.message);
      exportBtn.textContent = '⬇ Export HTML'; exportBtn.disabled = false;
    }
  };
  footer.appendChild(cancelBtn); footer.appendChild(exportBtn);
  popup.appendChild(footer);
  overlay.appendChild(popup);
  document.body.appendChild(overlay);
}

function buildLoadingOverlayHTML(exp) {
  const pos = exp.loadPosition || 'center';
  const style = exp.loadStyle || 'minimal';
  const bg = exp.loadBgImage ? `background-image:url(${exp.loadBgImage});background-size:cover;background-position:center;background-color:${exp.loadBg};` : `background:${exp.loadBg};`;

  let justify = 'center', align = 'center', padding = '20px';
  if (pos === 'bottom-center') { justify = 'flex-end'; padding = '0 0 60px 0'; }
  else if (pos === 'bottom-right') { justify = 'flex-end'; align = 'flex-end'; padding = '0 40px 60px 0'; }
  else if (pos === 'top-center') { justify = 'flex-start'; padding = '80px 0 0 0'; }

  let inner = '';
  if (style === 'minimal') {
    inner = `<div style="font-family:Orbitron,sans-serif;font-size:1.2rem;letter-spacing:3px;color:${exp.loadAccent};margin-bottom:8px;">${exp.loadTitle}</div>
      <div style="font-size:0.75rem;color:${exp.loadTextColor};margin-bottom:16px;">${exp.loadMessage}</div>
      <div style="width:220px;height:4px;background:rgba(255,255,255,0.08);border-radius:2px;overflow:hidden;">
        <div id="embed-progress" style="height:100%;width:0%;background:linear-gradient(90deg,${exp.loadAccent},#00c8ff);border-radius:2px;transition:width 0.3s;"></div>
      </div>`;
  } else if (style === 'spinner') {
    inner = `<style>@keyframes exp-spin{to{transform:rotate(360deg)}}</style>
      <div style="width:40px;height:40px;border:3px solid rgba(255,255,255,0.1);border-top-color:${exp.loadAccent};border-radius:50%;animation:exp-spin 0.8s linear infinite;margin-bottom:14px;"></div>
      <div style="font-family:Orbitron,sans-serif;font-size:1rem;letter-spacing:2px;color:${exp.loadAccent};margin-bottom:6px;">${exp.loadTitle}</div>
      <div style="font-size:0.7rem;color:${exp.loadTextColor};">${exp.loadMessage}</div>
      <div id="embed-progress" style="display:none;"></div>`;
  } else if (style === 'dots') {
    inner = `<style>@keyframes exp-dot{0%,80%,100%{transform:scale(0)}40%{transform:scale(1)}}</style>
      <div style="font-family:Orbitron,sans-serif;font-size:1.1rem;letter-spacing:2px;color:${exp.loadAccent};margin-bottom:12px;">${exp.loadTitle}</div>
      <div style="display:flex;gap:8px;margin-bottom:10px;">
        <div style="width:10px;height:10px;border-radius:50%;background:${exp.loadAccent};animation:exp-dot 1.4s infinite ease-in-out both;animation-delay:-0.32s;"></div>
        <div style="width:10px;height:10px;border-radius:50%;background:${exp.loadAccent};animation:exp-dot 1.4s infinite ease-in-out both;animation-delay:-0.16s;"></div>
        <div style="width:10px;height:10px;border-radius:50%;background:${exp.loadAccent};animation:exp-dot 1.4s infinite ease-in-out both;"></div>
      </div>
      <div style="font-size:0.7rem;color:${exp.loadTextColor};">${exp.loadMessage}</div>
      <div id="embed-progress" style="display:none;"></div>`;
  } else { // fullscreen
    inner = `<div style="font-family:Orbitron,sans-serif;font-size:1.6rem;letter-spacing:5px;color:${exp.loadAccent};margin-bottom:10px;">${exp.loadTitle}</div>
      <div style="font-size:0.85rem;color:${exp.loadTextColor};margin-bottom:24px;">${exp.loadMessage}</div>
      <div style="width:280px;height:5px;background:rgba(255,255,255,0.08);border-radius:3px;overflow:hidden;">
        <div id="embed-progress" style="height:100%;width:0%;background:linear-gradient(90deg,${exp.loadAccent},#00c8ff);border-radius:3px;transition:width 0.3s;"></div>
      </div>`;
  }

  return `<div id="loading-overlay" style="position:fixed;inset:0;z-index:9999;${bg}display:flex;align-items:${align};justify-content:${justify};flex-direction:column;font-family:Rajdhani,sans-serif;padding:${padding};">
    <div style="text-align:center;">${inner}</div>
  </div>`;
}

async function doExport(exp) {
  await loadItemConfig();

  const levelJSON = JSON.stringify(pendingLevelData);
  const uiJSON = pendingUIData ? JSON.stringify(pendingUIData) : 'null';
  // Override modelBasePath with user's asset base path
  const exportSettings = Object.assign({}, gameLevelSettings, { modelBasePath: exp.assetBasePath || './' });
  const settingsJSON = JSON.stringify(exportSettings);
  const configJSON = JSON.stringify(ITEM_CONFIG);

  const resp = await fetch(location.href);
  let html = await resp.text();

  // Build meta tags
  let metaTags = '';
  if (exp.title) metaTags += `<title>${exp.title}</title>\n`;
  if (exp.description) metaTags += `<meta name="description" content="${exp.description.replace(/"/g, '&quot;')}">\n`;
  if (exp.keywords) metaTags += `<meta name="keywords" content="${exp.keywords.replace(/"/g, '&quot;')}">\n`;
  if (exp.author) metaTags += `<meta name="author" content="${exp.author.replace(/"/g, '&quot;')}">\n`;
  if (exp.favicon) metaTags += `<link rel="icon" href="${exp.favicon}">\n`;

  // Replace existing <title>
  html = html.replace(/<title>.*?<\/title>/i, '');

  // Inject meta + embedded data
  const embedScript = `
${metaTags}<script>
  window.__EMBEDDED_LEVEL__ = ${levelJSON};
  window.__EMBEDDED_UI__ = ${uiJSON};
  window.__EMBEDDED_SETTINGS__ = ${settingsJSON};
  window.__EMBEDDED_CONFIG__ = ${configJSON};
  window.__STANDALONE__ = true;
<` + `/script>`;
  html = html.replace('</head>', embedScript + '\n</head>');

  // Replace loading overlay
  const standaloneOverlay = buildLoadingOverlayHTML(exp);
  html = html.replace(/<div id="loading-overlay">[\s\S]*?<!-- \/loading-overlay -->/m, standaloneOverlay + '\n<!-- /loading-overlay -->');

  // Download
  const blob = new Blob([html], { type: 'text/html' });
  const url = URL.createObjectURL(blob);
  const a = document.createElement('a');
  a.href = url; a.download = (exp.filename || 'game_standalone') + '.html';
  a.click();
  URL.revokeObjectURL(url);
}

// Legacy wrapper
async function exportStandaloneHTML() {
  showExportPopup();
}

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')) {
        pendingUIData = data;
        if (dropUI) dropUI.classList.add('loaded');
        const uiFnEl = document.getElementById('ui-filename');
        if (uiFnEl) uiFnEl.textContent = file.name;
      } else {
        pendingLevelData = data;
        if (dropLevel) dropLevel.classList.add('loaded');
        const lvlFnEl = document.getElementById('level-filename');
        if (lvlFnEl) lvlFnEl.textContent = file.name;
      }
      updateStartButton();
    } 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 updateStartButton() {
  if (pendingLevelData) {
    if (btnStart) btnStart.classList.add('visible'); if (btnExport) btnExport.style.display = 'block';
  }
}

async function startGame() {
  if (btnStart) btnStart.style.display = 'none';
  const statusEl = document.getElementById('loading-status');
  const progressEl = document.getElementById('load-progress');
  statusEl.style.display = 'block';
  progressEl.style.display = 'block';

  try {
    statusEl.textContent = 'Loading settings…';
    await loadGameLevelSettings();
    statusEl.textContent = 'Loading item config…';
    await loadItemConfig();
    statusEl.textContent = 'Building scene…';

    // Embed UI data into level so loadScene can pick it up
    if (pendingUIData) pendingLevelData.gameUI = pendingUIData;

    await loadScene(pendingLevelData);
  } catch(err) {
    console.error(err);
    statusEl.textContent = `Error: ${err.message}`;
    statusEl.style.color = '#ff6b6b';
    if (btnStart) btnStart.classList.add('visible'); if (btnExport) btnExport.style.display = 'block';
    if (btnStart) btnStart.style.display = '';
  }
}

if (btnStart) btnStart.addEventListener('click', startGame);
if (btnExport) btnExport.addEventListener('click', exportStandaloneHTML);

// ── Level zone events ──
if (fileLevel) fileLevel.addEventListener('change', e => { stageFile(e.target.files[0]); e.target.value = ''; });
if (dropLevel) {
  dropLevel.addEventListener('dragover', e => { e.preventDefault(); dropLevel.classList.add('dragover'); });
  dropLevel.addEventListener('dragleave', () => dropLevel.classList.remove('dragover'));
  dropLevel.addEventListener('drop', e => {
    e.preventDefault(); dropLevel.classList.remove('dragover');
    if (e.dataTransfer.files[0]) stageFile(e.dataTransfer.files[0]);
  });
}

// ── UI zone events ──
if (fileUI) fileUI.addEventListener('change', e => { stageFile(e.target.files[0]); e.target.value = ''; });
if (dropUI) {
  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]);
  });
}

// ── Global body drop (auto-detect which zone) ──
document.body.addEventListener('dragover', e => e.preventDefault());
document.body.addEventListener('drop', e => {
  if (e.target.closest('#drop-zone-level') || e.target.closest('#drop-zone-ui')) return;
  e.preventDefault();
  Array.from(e.dataTransfer.files).forEach(f => { if (f) stageFile(f); });
});

/* ═══════════════════════════════════════════
   INIT
   ═══════════════════════════════════════════ */
animate();

// ── Auto-Load from Config ──
(async function autoLoadCheck() {
  try {
    await loadGameLevelSettings();
  } catch(e) {}

  // ── Embedded standalone mode ──
  if (window.__EMBEDDED_LEVEL__) {
    await loadItemConfig();
    let bar = document.getElementById('embed-progress');
    if (bar) bar.style.width = '30%';
    const levelData = window.__EMBEDDED_LEVEL__;
    if (window.__EMBEDDED_UI__) levelData.gameUI = window.__EMBEDDED_UI__;
    if (bar) bar.style.width = '50%';
    await loadScene(levelData);
    return;
  }

  if (gameLevelSettings.levelFile) {
    // Auto-load mode: replace drop-zone with clean loading screen
    const overlay = document.getElementById('loading-overlay');
    if (overlay) {
      const s = gameLevelSettings;
      overlay.style.background = s.loadingBgColor || '#05070e';
      overlay.innerHTML = '';
      // Logo
      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;';
        overlay.appendChild(logo);
      }
      // Title
      const title = document.createElement('div');
      title.style.cssText = `font-family:Orbitron,sans-serif;font-size:1.4rem;letter-spacing:4px;color:${s.loadingAccentColor || '#00e5a0'};margin-bottom:6px;text-transform:uppercase;`;
      title.textContent = s.loadingTitle || 'Game Level';
      overlay.appendChild(title);
      // Subtitle
      if (s.loadingSubtitle) {
        const sub = document.createElement('div');
        sub.style.cssText = `font-family:${s.loadingFontFamily || 'Rajdhani,sans-serif'};font-size:0.8rem;color:${s.loadingTextColor || '#a0b0c0'};margin-bottom:20px;letter-spacing:2px;`;
        sub.textContent = s.loadingSubtitle;
        overlay.appendChild(sub);
      }
      // Message
      const msg = document.createElement('div');
      msg.id = 'auto-load-msg';
      msg.style.cssText = `font-family:${s.loadingFontFamily || 'Rajdhani,sans-serif'};font-size:0.9rem;color:${s.loadingTextColor || '#a0b0c0'};margin-bottom:20px;`;
      msg.textContent = s.loadingMessage || 'Memuat kandungan. Sila tunggu sebentar.';
      overlay.appendChild(msg);
      // Progress bar
      if (s.loadingShowProgress !== false) {
        const barWrap = document.createElement('div');
        barWrap.style.cssText = 'width:280px;height:6px;background:rgba(255,255,255,0.08);border-radius:3px;overflow:hidden;';
        const bar = document.createElement('div');
        bar.id = 'load-progress-bar';
        bar.style.cssText = `height:100%;width:5%;background:linear-gradient(90deg,${s.loadingAccentColor || '#00e5a0'},#00c8ff);border-radius:3px;transition:width 0.3s;`;
        barWrap.appendChild(bar);
        overlay.appendChild(barWrap);
      }
      // Status text
      const status = document.createElement('div');
      status.id = 'loading-status';
      status.style.cssText = `font-size:0.65rem;color:${s.loadingTextColor || '#607080'};margin-top:12px;`;
      overlay.appendChild(status);
      // Also create the progress elements referenced by loadScene
      if (!document.getElementById('load-progress-bar')) {
        // Already created above or not needed
      }

      // Start auto-load
      try {
        status.textContent = 'Loading item config…';
        await loadItemConfig();
        status.textContent = 'Fetching level data…';
        const bar = document.getElementById('load-progress-bar');
        if (bar) bar.style.width = '15%';

        const levelResp = await fetch(s.levelFile);
        if (!levelResp.ok) throw new Error('Failed to load level: ' + levelResp.statusText);
        const levelData = await levelResp.json();
        if (bar) bar.style.width = '25%';

        // Load UI if specified
        if (s.uiFile) {
          status.textContent = 'Fetching UI data…';
          try {
            const uiResp = await fetch(s.uiFile);
            if (uiResp.ok) {
              const uiData = await uiResp.json();
              levelData.gameUI = uiData;
            }
          } catch(e) { console.warn('UI load failed:', e); }
        }
        if (bar) bar.style.width = '35%';

        status.textContent = 'Building scene…';
        await loadScene(levelData);

      } catch(err) {
        console.error('Auto-load error:', err);
        status.textContent = 'Error: ' + err.message;
        status.style.color = '#ff6b6b';
        // Show retry button
        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(); };
        overlay.appendChild(retry);
      }
    }
  }
})();

/* ═══════════════════════════════════════════
   ★ GAME MECHANICS — Add your custom code below! ★

   ── GAME API ──
   Use GameAPI to interact with any object by its Object ID:

   GameAPI.getObject('npc_1')                   → entry or null
   GameAPI.getObjects('npc')                     → all NPCs
   GameAPI.getObjects(e => e.properties.health)  → custom filter
   GameAPI.setPosition('box_1', 5, 0, 10)       → move object
   GameAPI.setRotation('door_1', 0, 90, 0)      → rotate (degrees)
   GameAPI.setScale('coin_1', 2, 2, 2)          → resize
   GameAPI.setAnimation('npc_1', 'Wave')         → play animation
   GameAPI.getAnimations('npc_1')                → ['Idle','Walk','Wave']
   GameAPI.setVisible('wall_1', false)           → hide
   GameAPI.destroy('enemy_1')                    → remove from scene
   GameAPI.getPosition('player_1')               → {x, y, z}
   GameAPI.getDistance('player_1', 'npc_1')       → distance
   GameAPI.getProperty('chest_1', 'loot')        → get property
   GameAPI.setProperty('chest_1', 'opened', true) → set property
   GameAPI.switchCamera('camera_2')              → switch to camera
   GameAPI.playAudio('sounds/coin.mp3')           → play audio, returns Audio element
   GameAPI.showDialog('Hello!', {speaker:'NPC', buttonText:'Continue'})
   GameAPI.show3DDialog(entry, 'Hello!', {speaker:'NPC', offsetY:2.5})
   GameAPI.showToast('Item collected!')

   ── SCENE TRANSITIONS ──
   GameAPI.loadScene(levelPath, uiPath, params) → load new scene with loading screen
     levelPath: URL string or JSON object of level data
     uiPath: optional URL string or JSON object of UI data
     params: optional {key:value} object passed to next scene
   GameAPI.getSceneParam('name')                → get param from previous scene
   GameAPI.setSceneParam('name', value)         → set param for next transition
   GameAPI.sceneParams                          → all params object
   Globals (GameAPI.getGlobal/setGlobal) persist across scene transitions.

   ── COLLIDER ACTION CHAINS ──
   Actions execute in sequence with optional delays (ms).
   Available types: nothing, hide, destroy, animate, audio, dialog, function, loadScene
   Example chain in builder: animate → 500ms → audio → 1000ms → destroy

   ── COLLIDER FUNCTIONS ──
   When a collider's action is "Call Function", define the function globally:

   window.onCoinCollect = function(owner, other) {
     // owner = the item with the collider (e.g. coin)
     // other = the item it collided with (e.g. player)
     GameAPI.destroy(owner.objectId);
     score++;
     GameAPI.showToast('Coin collected! Score: ' + score);
   };

   ── DIRECT ACCESS ──
   - placedItems[]          — all placed items with .mesh, .config, .properties, .objectId
   - activePlayerEntry      — the active player entry
   - scene, camera, controls — Three.js objects
   - gameTime, gameDay      — game clock
   - playAnimation(entry, clipName) — low-level animation control

   ── GAME UI API ──
   Load a .ui.json file alongside .level.json (drag both onto the loading screen).
   Or load programmatically: GameAPI.loadGameUI(uiJsonData)

   GameAPI.setUIValue('score', 100)     → update bound UI elements showing {score}
   GameAPI.getUIValue('score')           → get current value
   GameAPI.setUIVisible('el_1', false)   → hide a UI element
   GameAPI.setUIContent('el_2', 'Hello') → change text content
   GameAPI.setUIStyle('el_3', 'color', '#ff0000') → change CSS style

   KEY SIMULATION (for virtual d-pad/joystick buttons):
   GameAPI.pressKey('w')                → simulate key press (WASD, shift, space, etc)
   GameAPI.releaseKey('w')              → simulate key release
   GameAPI.simulateKey('w', 200)        → press and auto-release after 200ms
   GameAPI.releaseAllKeys()             → release all currently pressed keys

   Supported bindings: score, timer, health, level, coins, lives, mana, xp, stamina, custom...
   Use {variableName} in UI text content to auto-bind: "Score: {score}" → "Score: 100"

   ═══════════════════════════════════════════ */

</script>
</body>
</html>