| Current Path : /var/www/html/unity/development/ui-builder/ |
| 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 & 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, '"'); }
/** 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, '"')}">\n`;
if (exp.keywords) metaTags += `<meta name="keywords" content="${exp.keywords.replace(/"/g, '"')}">\n`;
if (exp.author) metaTags += `<meta name="author" content="${exp.author.replace(/"/g, '"')}">\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>