| Current Path : /var/www/html/unity/development/ui-builder/ |
| Current File : /var/www/html/unity/development/ui-builder/ui-player.html |
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>UI Player — Game Interface Runtime</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/animate.css/4.1.1/animate.min.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/OwlCarousel2/2.3.4/assets/owl.carousel.min.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/OwlCarousel2/2.3.4/assets/owl.theme.default.min.css" rel="stylesheet">
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.1/jquery.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/OwlCarousel2/2.3.4/owl.carousel.min.js"></script>
<link href="https://fonts.googleapis.com/css2?family=Rajdhani:wght@400;500;600;700&family=Orbitron:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
:root {
--accent: #00e5a0;
--accent2: #a78bfa;
--danger: #ff4d6a;
--text-muted: #607080;
}
* { margin:0; padding:0; box-sizing:border-box; }
html, body {
width:100%; height:100%;
overflow:hidden;
background:#000;
font-family:'Rajdhani',sans-serif;
color:#e0e8f0;
}
/* ═══ Page background layer ═══ */
#page-bg {
position:fixed; inset:0; z-index:0;
background:#000;
background-size:cover; background-position:center;
}
/* ═══ Loading / Drop screen ═══ */
#loading-overlay {
position:fixed; inset:0; z-index:10000;
background:#05070e;
display:flex; flex-direction:column;
align-items:center; justify-content:center;
transition:opacity 0.5s;
}
#loading-overlay.fade-out { opacity:0; pointer-events:none; }
.load-logo {
font-family:'Orbitron',sans-serif; font-size:1.5rem; font-weight:700;
letter-spacing:5px; color:var(--accent); text-transform:uppercase;
margin-bottom:6px;
}
.load-sub {
font-size:0.8rem; color:#a0b0c0; letter-spacing:2px; margin-bottom:26px;
}
.load-card {
background:rgba(18,20,30,0.9);
border:1px solid rgba(255,255,255,0.08);
border-radius:14px; padding:26px 30px;
width:400px; max-width:92vw;
text-align:center;
box-shadow:0 10px 40px rgba(0,0,0,0.5);
}
.load-card h3 {
font-family:'Orbitron',sans-serif; font-size:0.7rem; letter-spacing:2px;
color:var(--accent2); margin-bottom:8px; text-transform:uppercase;
}
.load-card h3 i { margin-right:6px; }
.load-card p { font-size:0.75rem; color:#8a90a4; margin-bottom:18px; line-height:1.5; }
.load-drop {
position:relative;
border:2px dashed rgba(167,139,250,0.35);
border-radius:12px; padding:26px 16px;
cursor:pointer; transition:all 0.2s;
display:flex; flex-direction:column; align-items:center; gap:6px;
}
.load-drop:hover, .load-drop.dragover {
border-color:var(--accent2);
background:rgba(167,139,250,0.06);
}
.load-drop.loaded { border-color:var(--accent); background:rgba(0,229,160,0.05); }
.load-drop .drop-icon { font-size:1.9rem; color:var(--accent2); }
.load-drop.loaded .drop-icon { color:var(--accent); }
.load-drop span { font-size:0.8rem; font-weight:600; color:#c8cce0; }
.load-drop small { font-size:0.6rem; color:var(--text-muted); }
.load-drop .drop-filename { font-size:0.62rem; color:var(--accent); word-break:break-all; }
.load-drop .drop-check { display:none; position:absolute; top:8px; right:10px; color:var(--accent); font-size:0.9rem; }
.load-drop.loaded .drop-check { display:block; }
.load-drop input[type=file] { position:absolute; inset:0; opacity:0; cursor:pointer; }
#btn-start {
display:none;
margin:18px auto 0; padding:11px 42px;
font-family:'Orbitron',sans-serif; font-size:0.75rem; font-weight:700;
letter-spacing:3px; text-transform:uppercase;
color:#000; background:linear-gradient(135deg,#00e5a0,#00b87a);
border:none; border-radius:10px; cursor:pointer;
box-shadow:0 4px 20px rgba(0,229,160,0.35);
transition:transform 0.15s, box-shadow 0.15s;
}
#btn-start.visible { display:block; }
#btn-start:hover { transform:translateY(-2px); box-shadow:0 6px 26px rgba(0,229,160,0.5); }
#loading-status {
display:none; margin-top:14px;
font-size:0.68rem; color:#8a90a4;
}
#load-progress {
display:none; width:280px; height:6px; margin-top:12px;
background:rgba(255,255,255,0.08); border-radius:3px; overflow:hidden;
}
#load-progress-bar {
height:100%; width:5%;
background:linear-gradient(90deg, var(--accent), #00c8ff);
border-radius:3px; transition:width 0.3s;
}
/* ═══ Game UI Overlay (same classes as game-level) ═══ */
#game-ui-overlay {
position:fixed; top:0; left:0; width:100%; height:100%;
pointer-events:none; z-index:100; overflow:hidden;
}
.gui-el {
position:absolute; pointer-events:auto;
transition: opacity 0.2s;
}
.gui-el.gui-hidden { display:none; }
.gui-progressbar-track { width:100%; overflow:hidden; position:relative; }
.gui-progressbar-fill { height:100%; transition:width 0.3s ease; }
.gui-progressbar-label {
position:absolute; left:6px; top:50%; transform:translateY(-50%);
font-family:'Rajdhani',sans-serif; font-size:0.55rem; font-weight:700;
color:rgba(255,255,255,0.8); text-shadow:0 1px 2px rgba(0,0,0,0.5); z-index:1;
}
@media (max-width:768px) {
.gui-el.gui-mobile-hidden { display:none !important; }
}
/* ═══ Toast ═══ */
#toast-container {
position:fixed; bottom:20px; left:50%; transform:translateX(-50%);
z-index:11000; display:flex; flex-direction:column; gap:6px; align-items:center;
pointer-events:none;
}
.player-toast {
background:rgba(10,12,20,0.92); color:#e0e8f0;
border:1px solid rgba(0,229,160,0.35);
padding:8px 20px; border-radius:8px; font-size:0.78rem;
animation:toastIn 0.25s ease;
}
.player-toast.warn { border-color:rgba(245,158,11,0.5); }
.player-toast.error { border-color:rgba(255,77,106,0.5); }
@keyframes toastIn { from { opacity:0; transform:translateY(8px); } to { opacity:1; transform:none; } }
/* ═══ Iframe overlay / HTML popup ═══ */
#iframe-overlay {
position:fixed; inset:0; z-index:10500; background:#000;
display:flex; flex-direction:column;
}
#iframe-overlay iframe { flex:1; border:none; width:100%; }
#iframe-overlay .iframe-back {
position:absolute; top:12px; left:12px; z-index:2;
width:44px; height:44px; border-radius:50%;
background:rgba(10,12,20,0.85); border:1px solid rgba(255,255,255,0.2);
color:#fff; font-size:1.1rem; cursor:pointer;
display:flex; align-items:center; justify-content:center;
box-shadow:0 4px 16px rgba(0,0,0,0.5);
}
.html-popup-backdrop {
position:fixed; inset:0; z-index:10400;
background:rgba(0,0,0,0.6); backdrop-filter:blur(3px);
display:flex; align-items:center; justify-content:center;
}
.html-popup {
background:rgba(15,18,28,0.97); color:#e0e8f0;
border:1px solid rgba(255,255,255,0.12); border-radius:14px;
padding:24px 28px; max-width:min(620px, 92vw); max-height:80vh; overflow:auto;
box-shadow:0 12px 48px rgba(0,0,0,0.6);
position:relative; font-size:0.9rem; line-height:1.6;
}
.html-popup .popup-close {
position:absolute; top:10px; right:10px;
width:28px; height:28px; border-radius:6px;
background:rgba(255,77,106,0.1); border:1px solid rgba(255,77,106,0.4);
color:var(--danger); cursor:pointer; font-size:0.7rem;
display:flex; align-items:center; justify-content:center;
}
</style>
</head>
<body>
<div id="page-bg"></div>
<!-- ═══ Loading / Drop Screen ═══ -->
<div id="loading-overlay">
<div class="load-logo" id="load-title">UI Player</div>
<div class="load-sub" id="load-subtitle">UI Builder Runtime</div>
<div class="load-card" id="load-card">
<h3><i class="bi bi-layout-wtf"></i> Load Your UI</h3>
<p>Drop a project saved from the UI Builder to run it.</p>
<div class="load-drop" id="drop-zone-ui">
<i class="bi bi-filetype-json drop-icon"></i>
<span>UI Project File</span>
<small>.ui.json</small>
<span class="drop-filename" id="ui-filename"></span>
<i class="bi bi-check-circle-fill drop-check"></i>
<input type="file" id="file-input-ui" accept=".json,.ui.json">
</div>
<button id="btn-start"><i class="bi bi-play-fill"></i> Start</button>
</div>
<div id="loading-status"></div>
<div id="load-progress"><div id="load-progress-bar"></div></div>
</div>
<!-- ═══ Game UI Overlay ═══ -->
<div id="game-ui-overlay"></div>
<div id="toast-container"></div>
<script>
/* ═══════════════════════════════════════════
UI PLAYER — dedicated runtime for
UI Builder projects (.ui.json)
Loads a project three ways:
1. Drag & drop / browse on the start screen
2. Auto-load via ui-player.json → { "uiFile": "saves/menu.ui.json" }
3. URL query param → ui-player.html?ui=saves/menu.ui.json
═══════════════════════════════════════════ */
/* ═══════════════════════════════════════════
SETTINGS (ui-player.json)
═══════════════════════════════════════════ */
const uiPlayerSettings = {
// Auto-load: if uiFile is set, skip the drop-zone and load directly
uiFile: '', // path/URL to .ui.json
// Page background behind the UI (CSS color/gradient). The project's own
// bgImage (set in the builder) is drawn on top of this if present.
pageBackground: '#000000',
// Toasts
showToasts: true,
// Loading screen customization
loadingTitle: 'UI Player',
loadingSubtitle: 'UI Builder Runtime',
loadingMessage: 'Memuat kandungan. Sila tunggu sebentar.',
loadingBgColor: '#05070e',
loadingTextColor: '#a0b0c0',
loadingAccentColor: '#00e5a0',
loadingFontFamily: 'Rajdhani, sans-serif',
loadingShowProgress: true,
loadingLogo: '', // optional image URL for logo
};
let _settingsLoaded = false;
async function loadUIPlayerSettings() {
if (_settingsLoaded) return;
_settingsLoaded = true;
if (window.__EMBEDDED_SETTINGS__) {
Object.assign(uiPlayerSettings, window.__EMBEDDED_SETTINGS__);
return;
}
try {
const r = await fetch('./ui-player.json');
if (!r.ok) throw new Error('HTTP ' + r.status);
Object.assign(uiPlayerSettings, await r.json());
} catch(e) {
// No settings file — use defaults
}
}
/* ═══════════════════════════════════════════
TOAST
═══════════════════════════════════════════ */
function showToast(msg, type) {
if (uiPlayerSettings.showToasts === false) return;
const t = document.createElement('div');
t.className = 'player-toast' + (type ? ' ' + type : '');
t.textContent = msg;
document.getElementById('toast-container').appendChild(t);
setTimeout(() => { t.style.opacity = '0'; setTimeout(() => t.remove(), 300); }, 2500);
}
/* ═══════════════════════════════════════════
MUSIC & AUDIO
═══════════════════════════════════════════ */
let musicAudio = null;
let masterVolume = 0.5;
let musicMuted = false;
function playMusic(src, loop) {
stopMusic();
try {
musicAudio = new Audio(src);
musicAudio.loop = loop !== false;
musicAudio.volume = musicMuted ? 0 : masterVolume;
musicAudio.play().catch(e => console.warn('[Music]', e));
} catch(e) { console.warn('[Music]', e); }
}
function stopMusic() {
if (musicAudio) { musicAudio.pause(); musicAudio.currentTime = 0; musicAudio = null; }
}
function pauseMusic() { if (musicAudio) musicAudio.pause(); }
function resumeMusic() { if (musicAudio) musicAudio.play().catch(() => {}); }
function setMusicVolume(vol) {
masterVolume = Math.max(0, Math.min(1, vol));
if (musicAudio && !musicMuted) musicAudio.volume = masterVolume;
}
/* ═══════════════════════════════════════════
IFRAME OVERLAY + HTML POPUP
═══════════════════════════════════════════ */
function showIframeOverlay(url, backIcon, closeSound) {
const existing = document.getElementById('iframe-overlay');
if (existing) existing.remove();
const ov = document.createElement('div');
ov.id = 'iframe-overlay';
const back = document.createElement('div');
back.className = 'iframe-back';
back.innerHTML = backIcon ? '<img src="' + backIcon + '" style="width:60%;height:60%;object-fit:contain;">' : '<i class="bi bi-arrow-left"></i>';
back.onclick = () => {
if (closeSound) { try { new Audio(closeSound).play().catch(() => {}); } catch(e) {} }
ov.remove();
};
const frame = document.createElement('iframe');
frame.src = url;
frame.allow = 'autoplay; fullscreen';
ov.appendChild(back);
ov.appendChild(frame);
document.body.appendChild(ov);
}
function showHTMLPopup(html, opts) {
opts = opts || {};
const backdrop = document.createElement('div');
backdrop.className = 'html-popup-backdrop';
const box = document.createElement('div');
box.className = 'html-popup';
if (opts.bgColor) box.style.background = opts.bgColor;
if (opts.textColor) box.style.color = opts.textColor;
const close = document.createElement('div');
close.className = 'popup-close';
close.innerHTML = '<i class="bi bi-x-lg"></i>';
close.onclick = () => backdrop.remove();
const content = document.createElement('div');
content.innerHTML = html;
box.appendChild(close);
box.appendChild(content);
backdrop.appendChild(box);
backdrop.addEventListener('click', e => { if (e.target === backdrop) backdrop.remove(); });
document.body.appendChild(backdrop);
}
/* ═══════════════════════════════════════════
GAME UI RUNTIME (same model as game-level)
═══════════════════════════════════════════ */
let gameUIData = null;
let gameUIElements = {}; // id → { dom, data }
let gameUIState = {}; // variable bindings: score, timer, health, etc.
// Virtual key state for d-pad / joystick panels
window.playerKeys = window.playerKeys || { w:false, a:false, s:false, d:false, shift:false, space:false };
function resolveBindingText(content, binding) {
if (!content) return '';
return content.replace(/\{(\w+)\}/g, (match, key) => {
return gameUIState[key] !== undefined ? gameUIState[key] : match;
});
}
function updateGameUI() {
for (const [id, entry] of Object.entries(gameUIElements)) {
const el = entry.data;
if (el.binding && (el.type === 'text' || el.type === 'button')) {
const inner = entry.dom.querySelector('.gui-inner');
if (inner) inner.textContent = resolveBindingText(el.content, el.binding);
}
if (el.type === 'progressbar' && el.binding) {
const fill = entry.dom.querySelector('.gui-progressbar-fill');
if (fill) {
const val = gameUIState[el.binding] !== undefined ? gameUIState[el.binding] : el.barValue;
fill.style.width = Math.max(0, Math.min(100, val || 0)) + '%';
}
}
}
}
function setUIValue(key, val) { gameUIState[key] = val; updateGameUI(); }
function getUIValue(key) { return gameUIState[key]; }
function setUIVisible(elId, visible) {
const entry = gameUIElements[elId];
if (!entry) return;
if (visible) { entry.dom.classList.remove('gui-hidden'); entry.data.visible = true; }
else { entry.dom.classList.add('gui-hidden'); entry.data.visible = false; }
}
function setUIContent(elId, text) {
const entry = gameUIElements[elId];
if (!entry) return;
entry.data.content = text;
const inner = entry.dom.querySelector('.gui-inner');
if (inner) inner.textContent = resolveBindingText(text, entry.data.binding);
}
function setUIStyle(elId, prop, val) {
const entry = gameUIElements[elId];
if (!entry) return;
const inner = entry.dom.querySelector('.gui-inner');
if (inner) inner.style[prop] = val;
}
/* ── Multi-stage element animation (entrance → attention → exit) ── */
function uiAnimHasSequence(el) {
return !!(el.animEnterClass || el.animAttnClass || el.animExitClass || el.animateClass);
}
function clearUIAnim(inner) {
if (!inner) return;
if (inner._animTimers) inner._animTimers.forEach(function(t) { window.clearTimeout(t); });
inner._animTimers = [];
Array.prototype.slice.call(inner.classList).forEach(function(c) {
if (c.indexOf('animate__') === 0) inner.classList.remove(c);
});
inner.style.removeProperty('--animate-duration');
inner.style.removeProperty('animation-delay');
inner.style.removeProperty('animation-duration');
inner.style.removeProperty('animation-iteration-count');
inner.style.removeProperty('opacity');
inner.style.removeProperty('pointer-events');
}
/** Plays one animate.css class. `done` is not called for infinite repeats. */
function playUIAnimStage(inner, cls, durationMs, repeat, done) {
if (!inner) return;
if (!cls) { if (done) done(); return; }
Array.prototype.slice.call(inner.classList).forEach(function(c) {
if (c.indexOf('animate__') === 0) inner.classList.remove(c);
});
void inner.offsetWidth; // reflow so the animation can restart
var dur = parseInt(durationMs);
if (!dur || dur < 0) dur = 800;
var infinite = (String(repeat) === 'infinite');
var rep = infinite ? 'infinite' : (parseInt(repeat) || 1);
inner.style.setProperty('--animate-duration', (dur / 1000) + 's');
inner.style.animationDuration = (dur / 1000) + 's';
inner.style.animationIterationCount = rep;
inner.classList.add('animate__animated', cls.indexOf('animate__') === 0 ? cls : 'animate__' + cls);
if (infinite || !done) return;
var t = window.setTimeout(done, dur * rep + 30);
(inner._animTimers = inner._animTimers || []).push(t);
}
/**
* Runs the element's animation sequence.
* stage: 'all' (default) | 'entrance' | 'attention' | 'exit' | 'stop'
*/
function runUIAnimSequence(inner, el, stage) {
if (!inner || !el) return;
stage = stage || 'all';
clearUIAnim(inner);
if (stage === 'stop') return;
inner._animTimers = inner._animTimers || [];
// Legacy single-animation projects behave like an entrance
var enterCls = el.animEnterClass || (!el.animAttnClass && !el.animExitClass ? el.animateClass : '') || '';
var enterDur = el.animEnterDuration !== undefined && el.animEnterDuration !== ''
? el.animEnterDuration
: (parseFloat(el.animateDuration) ? parseFloat(el.animateDuration) * 1000 : 800);
var enterDelay = el.animEnterDelay !== undefined && el.animEnterDelay !== ''
? el.animEnterDelay
: (parseFloat(el.animateDelay) ? parseFloat(el.animateDelay) * 1000 : 0);
var enterRep = el.animEnterRepeat || (el.animateRepeat && !el.animEnterClass ? el.animateRepeat : 1);
function wait(ms, fn) {
var t = window.setTimeout(fn, Math.max(0, parseInt(ms) || 0));
inner._animTimers.push(t);
return t;
}
function doExit() {
if (!el.animExitClass) return;
wait(el.animExitDelay, function() {
playUIAnimStage(inner, el.animExitClass, el.animExitDuration, el.animExitRepeat || 1, function() {
if (el.animExitHide !== false) {
inner.style.opacity = '0';
inner.style.pointerEvents = 'none';
}
});
});
}
function doAttention(next) {
if (!el.animAttnClass) { if (next) next(); return; }
wait(el.animAttnDelay, function() {
var infinite = String(el.animAttnRepeat) === 'infinite';
playUIAnimStage(inner, el.animAttnClass, el.animAttnDuration, el.animAttnRepeat || 1, infinite ? null : next);
if (infinite && next) next(); // exit is timed from when the loop starts
});
}
// While an entrance is waiting out its delay, keep the element invisible —
// otherwise it pops in first and only then animates.
function armEntrance(then) {
var d = Math.max(0, parseInt(enterDelay) || 0);
if (enterCls && d > 0) inner.style.opacity = '0';
wait(d, function() {
inner.style.removeProperty('opacity');
then();
});
}
if (stage === 'entrance') { armEntrance(function() { playUIAnimStage(inner, enterCls, enterDur, enterRep, null); }); return; }
if (stage === 'attention') { doAttention(null); return; }
if (stage === 'exit') { doExit(); return; }
// Full sequence
if (enterCls) {
armEntrance(function() {
playUIAnimStage(inner, enterCls, enterDur, enterRep, function() { doAttention(doExit); });
});
} else {
doAttention(doExit);
}
}
/* ── Carousel runtime (Owl Carousel 2) ── */
function escAttr(s) { return String(s == null ? '' : s).replace(/"/g, '"'); }
/** Overlay image: an absolutely placed box (margin + padding) holding a sized image */
function carFgHTML(el, item, boxStyle, isCover) {
var pad = item.fgPadding !== undefined && item.fgPadding !== '' ? item.fgPadding : el.carFgPadding;
var w = item.fgWidth !== undefined && item.fgWidth !== '' ? item.fgWidth : el.carFgWidth;
var hgt = item.fgHeight !== undefined && item.fgHeight !== '' ? item.fgHeight : el.carFgHeight;
var fit = item.fgFit || el.carFgFit || 'contain';
if (fit === 'stretch') fit = 'fill';
var imgStyle = 'width:' + cssLen(w, '100%') + ';height:' + cssLen(hgt, '100%') +
';object-fit:' + fit + ';';
return '<div class="car-fg-box' + (isCover ? ' car-fg-cover' : '') + '" style="' + boxStyle +
'padding:' + cssLen(pad, '0px') + ';">' +
'<img class="car-fg" src="' + escAttr(item.foreground) + '" style="' + imgStyle + '" draggable="false"></div>';
}
function carouselItemInnerHTML(el, item) {
if (el.carItemTemplate) {
var out = el.carItemTemplate
.replace(/\{image\}/g, item.image || '')
.replace(/\{title\}/g, item.title || '')
.replace(/\{text\}/g, item.text || '')
.replace(/\{link\}/g, item.link || '');
// Any custom field can be used as {fieldName}
if (item.fields) {
Object.keys(item.fields).forEach(function(k) {
out = out.split('{' + k + '}').join(item.fields[k] == null ? '' : item.fields[k]);
});
}
return out;
}
var h = '';
var cover = el.carImgMode === 'cover';
// What sits on top of the card image: a 3D model, another image, or nothing
var layer = item.layer || (item.model ? 'model' : (item.foreground ? 'image' : 'none'));
var hasModel = layer === 'model' && item.model;
var hasFg = layer === 'image' && item.foreground;
var fgInset = item.fgInset !== undefined && item.fgInset !== '' ? item.fgInset : el.carFgInset;
var fgStyle = 'inset:' + cssLen(fgInset, '0px') + ';';
if (cover) {
// The image fills the whole card and everything else stacks on top of it
if (el.carShowImage !== false && item.image) {
h += '<img class="car-bgimg" src="' + escAttr(item.image) + '" alt="' + escAttr(item.title) + '" draggable="false">';
}
if (hasModel) h += '<div class="car-model car-model-cover" data-model="' + escAttr(item.model) + '"></div>';
else if (hasFg) h += carFgHTML(el, item, fgStyle, true);
} else if (hasModel || hasFg) {
// Background image keeps its normal size; the model / overlay image is layered over it,
// so a card with either is exactly the same size as one without.
h += '<div class="car-media">';
if (el.carShowImage !== false && item.image) {
h += '<img class="car-img" src="' + escAttr(item.image) + '" alt="' + escAttr(item.title) + '" draggable="false">';
}
if (hasModel) h += '<div class="car-model" data-model="' + escAttr(item.model) + '"></div>';
else h += carFgHTML(el, item, fgStyle, false);
h += '</div>';
} else if (el.carShowImage !== false && item.image) {
h += '<img class="car-img" src="' + escAttr(item.image) + '" alt="' + escAttr(item.title) + '" draggable="false">';
}
if (el.carShowTitle !== false && item.title) h += '<div class="car-title">' + item.title + '</div>';
if (el.carShowText !== false && item.text) h += '<div class="car-text">' + item.text + '</div>';
// Extra fields marked "show in card"
(el.carShowFields || []).forEach(function(key) {
var v = item.fields ? item.fields[key] : undefined;
if (v !== undefined && v !== '') h += '<div class="car-field car-field-' + escAttr(key).replace(/[^\w-]/g, '') + '">' + v + '</div>';
});
return h;
}
/** Active-item settings can differ per device; these are the keys that support it */
var CAR_DEVICE_PROPS = ['carActiveSlot','carCenterScale','carSideScale','carSideOpacity',
'carCenterOffsetX','carCenterOffsetY','carCenterWidth','carCenterHeight',
'carCenterPadding','carCenterMargin','carCenterRadius',
// breathing room around the strip, per-device
'carPadTop','carPadBottom',
// arrow + dot placement is per-device too
'carNavInset','carNavOffsetX','carNavOffsetY',
'carNavPrevX','carNavPrevY','carNavNextX','carNavNextY',
'carDotOffsetX','carDotOffsetY','carDotMarginTop','carDotBottom'];
/** el merged with its overrides for `device` ('tablet' | 'phone' | 'phone-land') */
function carMergedFor(el, device) {
var ov = (device && el.carDeviceOverrides) ? el.carDeviceOverrides[device] : null;
if (!ov) return el;
var m = {}, k;
for (k in el) m[k] = el[k];
CAR_DEVICE_PROPS.forEach(function(prop) {
if (ov[prop] !== undefined && ov[prop] !== null && ov[prop] !== '') m[prop] = ov[prop];
else if (prop === 'carActiveSlot' && ov[prop] === '') m[prop] = '';
});
return m;
}
function carHasDeviceOverride(el, device) {
var ov = (el.carDeviceOverrides || {})[device];
if (!ov) return false;
return CAR_DEVICE_PROPS.some(function(p) { return ov[p] !== undefined && ov[p] !== null; });
}
/** Explicit active-slot mode: the active card sits in a chosen visible slot
* instead of being geometrically centred (e.g. 1 card left, 2 cards right). */
function carActiveSlotOf(el, perView) {
var v = el.carActiveSlot;
if (v === '' || v === undefined || v === null || v === 'auto') return null;
var n = parseInt(v);
if (isNaN(n)) return null;
return Math.max(0, Math.min((parseInt(perView) || 3) - 1, n));
}
function carUsesSlotMode(el) {
return carActiveSlotOf(el, el.carItems) !== null;
}
/** Media queries matching the builder's device modes */
var CAR_DEVICE_QUERIES = {
tablet: ['@media (max-width:1024px) and (min-width:481px)'],
phone: ['@media (max-width:480px) and (orientation:portrait)'],
'phone-land': ['@media (orientation:landscape) and (max-height:500px)', '@media (max-width:768px) and (orientation:landscape)']
};
/** The active-item + stage-shift rules for one configuration (base or a device override) */
function carActiveRules(el, cfg, itemCount) {
var s = '.car-' + el.id;
var cScale = cfg.carCenterScale !== undefined ? cfg.carCenterScale : 1;
var sScale = cfg.carSideScale !== undefined ? cfg.carSideScale : 1;
var sOpac = cfg.carSideOpacity !== undefined ? cfg.carSideOpacity : 1;
var cx = parseFloat(cfg.carCenterOffsetX) || 0;
var cy = parseFloat(cfg.carCenterOffsetY) || 0;
var grow = Math.max(0, (cScale - 1) * 60);
var padTop = Math.round(grow + Math.max(0, -cy) + (parseFloat(cfg.carPadTop) || 0));
var padBottom = Math.round(grow + Math.max(0, cy) + (parseFloat(cfg.carPadBottom) || 0));
var centerSizeCss = '';
if (cfg.carCenterWidth) centerSizeCss += 'width:' + cssLen(cfg.carCenterWidth) + '; margin-left:auto; margin-right:auto;';
if (cfg.carCenterHeight) centerSizeCss += 'height:' + cssLen(cfg.carCenterHeight) + ';';
var centerSel = s + ' .owl-item.center .car-item, ' + s + ' .owl-item.car-active .car-item, ' + s + ' .car-fallback .car-item.center';
var out = [
s + ' .owl-stage-outer { padding:' + padTop + 'px 0 ' + padBottom + 'px; }',
s + ' .car-item { transform:translateX(var(--car-shift, 0px)) scale(' + sScale + '); opacity:' + sOpac + '; }',
centerSel + ' { transform:translate(' + cx + 'px, ' + cy + 'px) scale(' + cScale + '); opacity:1; ' + centerSizeCss +
(cfg.carCenterPadding ? ' padding:' + cfg.carCenterPadding + ';' : '') +
(cfg.carCenterMargin ? ' margin:' + cfg.carCenterMargin + ';' : '') +
(cfg.carCenterRadius ? ' border-radius:' + cfg.carCenterRadius + ';' : '') +
(cfg.carCenterBg ? ' background:' + cfg.carCenterBg + ';' : '') +
(cfg.carCenterBorder ? ' border:' + cfg.carCenterBorder + ';' : '') +
(cfg.carCenterShadow ? ' box-shadow:' + cfg.carCenterShadow + ';' : '') + ' }'
];
// Off-balance layout: push the whole strip right by whole slots
var c = Math.max(1, parseInt(itemCount) || 3);
var slot = carActiveSlotOf(cfg, c);
if (slot !== null) out.push(s + ' .owl-stage { margin-left:' + ((slot / c) * 100).toFixed(4) + '%; }');
else out.push(s + ' .owl-stage { margin-left:0; }');
return out.join('\n');
}
/** Arrow + dot placement for one configuration (base or a device override) */
function carPositionRules(el, cfg) {
var s = '.car-' + el.id;
var num = function(v) { return parseFloat(v) || 0; };
var inset = num(cfg.carNavInset !== undefined ? cfg.carNavInset : cfg.carNavOffset);
var prevT = 'translate(' + num(cfg.carNavPrevX) + 'px, ' + num(cfg.carNavPrevY) + 'px)';
var nextT = 'translate(' + num(cfg.carNavNextX) + 'px, ' + num(cfg.carNavNextY) + 'px)';
var dotT = 'translate(' + num(cfg.carDotOffsetX) + 'px, ' + num(cfg.carDotOffsetY) + 'px)';
var topSpace = (cfg.carDotMarginTop === '' || cfg.carDotMarginTop === undefined) ? 6 : num(cfg.carDotMarginTop);
var out = [
s + ' .owl-nav { position:absolute; top:calc(50% + ' + num(cfg.carNavOffsetY) + 'px); left:' + num(cfg.carNavOffsetX) +
'px; width:100%; margin:0 !important; transform:translateY(-50%); display:flex; justify-content:space-between; pointer-events:none; box-sizing:border-box; }',
s + ' .owl-nav button.owl-prev { margin:0 ' + inset + 'px !important; transform:' + prevT + '; }',
s + ' .owl-nav button.owl-next { margin:0 ' + inset + 'px !important; transform:' + nextT + '; }',
// keep the nudge when hovering, or the button would jump back to its default spot
s + ' .owl-nav button.owl-prev:hover { transform:' + prevT + ' scale(1.12); }',
s + ' .owl-nav button.owl-next:hover { transform:' + nextT + ' scale(1.12); }'
];
if (cfg.carDotPosition === 'overlay') {
out.push(s + ' { position:relative; }');
out.push(s + ' .owl-dots { position:absolute; left:0; right:0; bottom:' +
((cfg.carDotBottom === '' || cfg.carDotBottom === undefined) ? 8 : num(cfg.carDotBottom)) +
'px; z-index:3; margin:0 !important; transform:' + dotT + '; }');
} else {
if (cfg.carDotPosition === 'above') {
out.push(s + ' .owl-dots { order:-1; }');
out.push(s + ' .car-wrap { display:flex; flex-direction:column; }');
}
// !important because owl.theme.default.css has a more specific rule
// (`.owl-theme .owl-nav.disabled + .owl-dots { margin-top:10px }`) that would
// otherwise pin the spacing and make "Top space" do nothing.
out.push(s + ' .owl-dots { margin-top:' + topSpace + 'px !important; position:static; transform:' + dotT + '; }');
}
return out.join('\n');
}
/** "160" → "160px"; leaves "160px", "50%", "auto", "" alone */
function cssLen(v, fallback) {
if (v === undefined || v === null || v === '') return fallback;
var str = String(v).trim();
return /^-?\d*\.?\d+$/.test(str) ? str + 'px' : str;
}
function buildCarouselCSS(el) {
var s = '.car-' + el.id;
// The builder previews one device at a time and can't rely on media queries,
// so it asks for that device's values to be baked into the base rules.
var previewDevice = (typeof window !== 'undefined') ? window.__uiCarouselDevice : null;
var base = previewDevice ? carMergedFor(el, previewDevice) : el;
var per = parseInt(el.carItems) || 3;
var gap = parseInt(el.carMargin) || 0;
// Item box sizing
var sizeCss = '';
if (el.carItemWidth) sizeCss += 'width:' + cssLen(el.carItemWidth) + '; margin-left:auto; margin-right:auto;';
if (el.carItemHeight) sizeCss += 'height:' + cssLen(el.carItemHeight) + ';';
// Active-item rules: base first, then a block per device that overrides them
var basePer = previewDevice
? (previewDevice === 'phone' ? (parseInt(el.carItemsPhone) || 1)
: previewDevice === 'tablet' ? (parseInt(el.carItemsTablet) || per)
: (parseInt(el.carItemsPhoneLand) || parseInt(el.carItemsTablet) || per))
: per;
var activeCss = [carActiveRules(el, base, basePer), carPositionRules(el, base)];
if (!previewDevice) {
var counts = {
tablet: parseInt(el.carItemsTablet) || per,
phone: parseInt(el.carItemsPhone) || 1,
'phone-land': parseInt(el.carItemsPhoneLand) || parseInt(el.carItemsTablet) || per
};
Object.keys(CAR_DEVICE_QUERIES).forEach(function(dev) {
var cfg = carMergedFor(el, dev);
if (cfg === el && counts[dev] === per) return; // nothing device-specific
// (cfg !== el means this device carries at least one override)
var rules = carActiveRules(el, cfg, counts[dev]) + '\n' + carPositionRules(el, cfg);
CAR_DEVICE_QUERIES[dev].forEach(function(q) { activeCss.push(q + ' {\n' + rules + '\n}'); });
});
}
var slotCss = activeCss.join('\n');
return [
s + ' { width:100%; }',
// Owl ships `.owl-carousel { display:none }` and only reveals it once it has
// initialised — if anything goes wrong the whole carousel vanishes. Keep it shown.
s + ' .owl-carousel { display:block; }',
s + ' .owl-carousel .owl-item { float:left; }',
s + ' .owl-stage { display:flex; align-items:center; }',
// z-index only applies to positioned elements — without this the *next* card in
// DOM order paints over the enlarged active one.
s + ' .owl-item { display:flex; align-items:center; position:relative; z-index:1; }',
s + ' .owl-item.center, ' + s + ' .owl-item.car-active { z-index:' + (parseInt(el.carActiveZ) || 10) + '; }',
s + ' .car-item { position:relative; }',
s + ' .car-fallback .car-item.center { z-index:' + (parseInt(el.carActiveZ) || 10) + '; }',
s + ' .car-item { background:' + (el.carItemBg || 'transparent') + '; border:' + (el.carItemBorder || 'none') +
'; border-radius:' + (el.carItemRadius || '0') + '; padding:' + (el.carItemPadding || '0') +
'; margin:' + (el.carItemMargin || '0') +
'; box-shadow:' + (el.carItemShadow || 'none') + '; text-align:' + (el.carItemAlign || 'center') +
'; overflow:hidden; box-sizing:border-box; ' + sizeCss +
' transition:transform 0.35s ease, opacity 0.35s ease; }',
s + ' .car-media { position:relative; width:' + cssLen(el.carModelWidth, '100%') + '; height:' +
cssLen(el.carModelHeight, cssLen(el.carImgHeight, '140px')) + '; margin:0 auto; }',
// The canvas covers the media box, so adding a model never resizes the card
s + ' .car-media .car-model { position:absolute; inset:' + (parseFloat(el.carModelInset) || 0) + 'px; }',
s + ' .car-media .car-fg-box { position:absolute; }',
s + ' .car-media .car-img { position:absolute; inset:0; width:100%; height:100%; }',
s + ' .car-model { border-radius:' + (el.carImgRadius || '0') + '; overflow:hidden; z-index:1; }',
// Full-bleed image mode: the picture covers the card, content sits above it
s + ' .car-bgimg { position:absolute; inset:0; width:100%; height:100%; object-fit:' +
(el.carImgFit || 'cover') + '; object-position:' + (el.carImgPosition || 'center') +
'; border-radius:inherit; z-index:0; display:block; }',
s + ' .car-model-cover { position:absolute; inset:' + (parseFloat(el.carModelInset) || 0) + 'px; z-index:1; }',
// Overlay image: sits above the card image, its inset acts as a margin from the edges
// Overlay image: the box handles margin/padding/alignment, the image its own size + fit
s + ' .car-fg-box { position:absolute; inset:' + cssLen(el.carFgInset, '0px') + '; padding:' + cssLen(el.carFgPadding, '0px') +
'; box-sizing:border-box; display:flex; z-index:1; pointer-events:none;' +
' align-items:' + (el.carFgAlignV === 'top' ? 'flex-start' : el.carFgAlignV === 'bottom' ? 'flex-end' : 'center') +
'; justify-content:' + (el.carFgAlignH === 'left' ? 'flex-start' : el.carFgAlignH === 'right' ? 'flex-end' : 'center') + '; }',
s + ' .car-fg { max-width:100%; max-height:100%; object-position:' + (el.carFgPosition || 'center') +
'; border-radius:' + (el.carFgRadius || '0') + '; display:block; ' +
(el.carFgShadow ? 'filter:drop-shadow(' + el.carFgShadow + ');' : '') + ' }',
s + ' .car-item > .car-title, ' + s + ' .car-item > .car-text, ' + s + ' .car-item > .car-field { position:relative; z-index:2; }',
(el.carImgMode === 'cover' ? s + ' .car-item { padding:' + (el.carItemPadding || '0') + '; display:flex; flex-direction:column; justify-content:' +
(el.carCoverAlign === 'top' ? 'flex-start' : el.carCoverAlign === 'center' ? 'center' : 'flex-end') + '; }' : ''),
s + ' .car-img { width:100%; height:' + cssLen(el.carImgHeight, 'auto') + '; object-fit:' + (el.carImgFit || 'cover') +
'; border-radius:' + (el.carImgRadius || '0') + '; display:block; }',
s + ' .car-title { font-size:' + (el.carTitleSize !== undefined ? el.carTitleSize : 0.85) + (el.carTitleUnit || 'rem') + '; color:' + (el.carTitleColor || '#fff') +
'; font-weight:' + (el.carTitleWeight || '700') + '; margin-top:6px; line-height:1.3; }',
s + ' .car-text { font-size:' + (el.carTextSize !== undefined ? el.carTextSize : 0.65) + (el.carTextUnit || 'rem') + '; color:' + (el.carTextColor || '#a0b0c0') +
'; margin-top:3px; line-height:1.4; }',
s + ' .car-field { font-size:' + (el.carFieldSize !== undefined ? el.carFieldSize : 0.6) + (el.carFieldUnit || 'rem') + '; color:' + (el.carFieldColor || '#8a93a8') +
'; margin-top:2px; line-height:1.4; }',
s + ' .owl-nav button.owl-prev, ' + s + ' .owl-nav button.owl-next { pointer-events:auto; color:' + (el.carNavColor || '#fff') +
' !important; background:' + (el.carNavBg || 'rgba(0,0,0,0.4)') + ' !important; width:' + (parseFloat(el.carNavSize) || 28) +
'px; height:' + (parseFloat(el.carNavSize) || 28) + 'px; border-radius:' + (el.carNavRadius || '50%') +
'; border:' + (el.carNavBorder || 'none') + ' !important; display:flex; align-items:center; justify-content:center; font-size:' +
(el.carNavIconSize !== undefined ? el.carNavIconSize : 0.8) + 'rem !important; transition:transform 0.15s, background 0.15s; }',
// Owl adds `disabled` to the nav/dots wrapper whenever every slide already fits on
// screen, and its core CSS hides `.disabled` outright — which silently removes the
// arrows and dots as soon as "Visible" reaches the number of slides. With loop on
// they still work, so keep them shown when the user asked for them.
(el.carNav !== false ? s + ' .owl-nav.disabled { display:flex !important; }' : ''),
(el.carDots !== false ? s + ' .owl-dots.disabled { display:flex !important; }' : ''),
s + ' .owl-dots { display:flex; justify-content:center; align-items:center; }',
s + ' .owl-dots .owl-dot span { background:' + (el.carDotColor || 'rgba(255,255,255,0.25)') + ' !important; width:' +
(parseFloat(el.carDotWidth) || 7) + 'px; height:' + (parseFloat(el.carDotHeight) || 7) + 'px; border-radius:' +
(el.carDotRadius || '50%') + ' !important; margin:0 ' + ((parseFloat(el.carDotGap) || 6) / 2) + 'px !important; transition:all 0.2s; }',
s + ' .owl-dots .owl-dot.active span { background:' + (el.carDotActiveColor || '#00e5a0') + ' !important;' +
(el.carDotActiveWidth ? ' width:' + parseFloat(el.carDotActiveWidth) + 'px !important;' : '') +
(el.carDotActiveHeight ? ' height:' + parseFloat(el.carDotActiveHeight) + 'px !important;' : '') + ' }',
s + ' .car-fallback { display:flex; gap:' + gap + 'px; align-items:center; justify-content:center; width:100%; overflow:hidden; }',
s + ' .car-fallback .car-item { flex:0 0 calc((100% - ' + (gap * (per - 1)) + 'px) / ' + per + '); }',
(el.carItemClick && el.carItemClick !== 'none') ? s + ' .car-item { cursor:pointer; }' : '',
slotCss
].join('\n');
}
function applyCarouselCSS(el) {
var st = document.getElementById('car-style-' + el.id);
if (!st) { st = document.createElement('style'); st.id = 'car-style-' + el.id; document.head.appendChild(st); }
st.textContent = buildCarouselCSS(el);
}
function owlAvailable() {
return typeof window.jQuery !== 'undefined' && window.jQuery.fn && typeof window.jQuery.fn.owlCarousel === 'function';
}
/** Visible item count for the current viewport — mirrors the builder's device modes.
* window.__uiCarouselForceItems lets the builder pin a count for its device preview. */
function carItemsForViewport(el) {
// The builder previews devices by resizing its canvas, not the window, so it installs
// a resolver that answers from the selected device instead of window.innerWidth.
if (typeof window !== 'undefined' && typeof window.__uiCarouselItemsResolver === 'function') {
try {
var r = parseInt(window.__uiCarouselItemsResolver(el));
if (r > 0) return r;
} catch(e) {}
}
var forced = (typeof window !== 'undefined') ? window.__uiCarouselForceItems : null;
if (forced) return Math.max(1, parseInt(forced) || 1);
var desktop = Math.max(1, parseInt(el.carItems) || 3);
var tablet = Math.max(1, parseInt(el.carItemsTablet) || desktop);
var phone = Math.max(1, parseInt(el.carItemsPhone) || 1);
var land = el.carItemsPhoneLand ? Math.max(1, parseInt(el.carItemsPhoneLand)) : tablet;
var w = (typeof window !== 'undefined' && window.innerWidth) ? window.innerWidth : 1280;
var h = (typeof window !== 'undefined' && window.innerHeight) ? window.innerHeight : 800;
var landscape = w > h;
// Phones held sideways are wide but short (844×390, 932×430…), so height matters
// more than width for spotting them.
if (landscape && (w <= 768 || (h <= 500 && w <= 1200))) return land;
if (w <= 480) return phone;
if (w <= 1024) return tablet;
return desktop;
}
function owlOptionsFor(el, live) {
var per = carItemsForViewport(el);
return {
items: per,
margin: parseInt(el.carMargin) || 0,
loop: el.carLoop !== false,
center: carUsesSlotMode(el) ? false : !!el.carCenter,
autoWidth: !!el.carAutoWidth,
stagePadding: parseInt(el.carStagePadding) || 0,
nav: el.carNav !== false,
dots: el.carDots !== false,
dotsEach: 1, // one dot per item (Owl otherwise draws one dot per "page",
// which gives a single dot when every item is already visible)
navText: ['<i class="bi ' + (el.carNavPrevIcon || 'bi-chevron-left') + '"></i>', '<i class="bi ' + (el.carNavNextIcon || 'bi-chevron-right') + '"></i>'],
autoplay: !!(live && el.carAutoplay),
autoplayTimeout: parseInt(el.carAutoplayTimeout) || 4000,
autoplayHoverPause: el.carAutoplayHoverPause !== false,
smartSpeed: parseInt(el.carSpeed) || 450,
mouseDrag: live ? el.carMouseDrag !== false : false,
touchDrag: live ? el.carMouseDrag !== false : false,
pullDrag: false,
rewind: el.carLoop === false,
startPosition: parseInt(el.carStart) || 0,
// One bucket only: the count is resolved by carItemsForViewport(), which understands
// portrait/landscape. Owl's own breakpoints can't express orientation.
responsive: { 0: { items: per } }
};
}
/* ── Linked elements: update other UI elements when the active item changes ── */
function carouselTargetDom(id) {
if (!id) return null;
try { if (typeof gameUIElements !== 'undefined' && gameUIElements && gameUIElements[id]) return gameUIElements[id].dom; } catch(e) {}
try { if (typeof viewport !== 'undefined' && viewport) return viewport.querySelector('[data-id="' + id + '"]'); } catch(e) {}
return document.getElementById('gui-' + id);
}
function carouselFieldValue(item, field, index) {
if (!item) return '';
if (field === 'index') return String(index + 1);
if (field && field.indexOf('field:') === 0) {
var key = field.slice(6);
return (item.fields && item.fields[key] !== undefined) ? item.fields[key] : '';
}
return item[field || 'title'] !== undefined ? item[field || 'title'] : '';
}
/** Tags the current .owl-item with `car-active` (Owl only adds `center` in centre mode) */
function markCarouselActive(el, wrap, $t) {
if (!wrap) return;
var nodes = wrap.querySelectorAll('.owl-item');
if (!nodes.length) return;
var target = wrap.querySelector('.owl-item.center');
if (!target) {
var pos = -1;
try {
var inst = $t && $t.data ? $t.data('owl.carousel') : null;
if (inst) pos = inst.current();
} catch(e) {}
if (pos >= 0 && nodes[pos]) target = nodes[pos];
}
Array.prototype.forEach.call(nodes, function(n) { n.classList.remove('car-active'); });
if (target) target.classList.add('car-active');
applyCarouselSpread(el, wrap, nodes, target);
}
/** Pushes the side cards away from the active one and poses their 3D models */
function applyCarouselSpread(el, wrap, nodes, activeNode) {
nodes = nodes || wrap.querySelectorAll('.owl-item');
if (!nodes.length) return;
var activeIdx = -1;
Array.prototype.forEach.call(nodes, function(n, i) { if (n === activeNode) activeIdx = i; });
var gap = parseFloat(el.carSideGap) || 0;
var stack = el.carSideGapStacks !== false; // 2nd neighbour moves twice as far
Array.prototype.forEach.call(nodes, function(n, i) {
var card = n.querySelector('.car-item');
if (!card) return;
var shift = 0;
if (activeIdx >= 0 && i !== activeIdx && gap) {
var dist = i - activeIdx;
var steps = stack ? Math.abs(dist) : 1;
shift = (dist < 0 ? -1 : 1) * gap * steps;
}
card.style.setProperty('--car-shift', shift + 'px');
// Per-card 3D pose: the active card can sit differently from the side ones
var host = n.querySelector('.car-model .ui-model');
if (host && host._modelApi && host._modelApi.setPose) {
var isActive = (i === activeIdx);
var item = null;
var ci = card.dataset ? parseInt(card.dataset.index) : NaN;
if (!isNaN(ci)) item = (el.carouselItems || [])[ci];
var pose = {
rotX: pickNum(isActive ? el.carModelActiveRotX : el.carModelSideRotX, item && item.modelRotX, el.carModelRotX, 0),
rotY: pickNum(isActive ? el.carModelActiveRotY : el.carModelSideRotY, item && item.modelRotY, el.carModelRotY, 0),
rotZ: pickNum(isActive ? el.carModelActiveRotZ : el.carModelSideRotZ, item && item.modelRotZ, el.carModelRotZ, 0),
scale: pickNum(isActive ? el.carModelActiveScale : el.carModelSideScale, item && item.modelScale, el.carModelScale, 1),
distance: pickNum(isActive ? el.carModelActiveDistance : el.carModelSideDistance, item && item.modelDistance, el.carModelDistance, 4)
};
host._modelApi.setPose(pose);
var spins = carItemSpins(el, item, false);
host._modelApi.setSpin(el.carModelSpinActiveOnly ? (isActive && spins) : spins);
}
});
}
/** Whether an item's model should spin — per item, falling back to the carousel */
function carItemSpins(el, item, atCreation) {
var v = item ? item.modelAutoRotate : undefined;
var on = (v === undefined || v === null || v === '') ? (el.carModelAutoRotate !== false) : (v === true || v === 'true');
// "spin active only" is applied per card once we know which one is active
if (atCreation && el.carModelSpinActiveOnly) return false;
return on;
}
/** First value that is actually set */
function pickNum() {
for (var i = 0; i < arguments.length; i++) {
var v = arguments[i];
if (v !== undefined && v !== null && v !== '' && !isNaN(parseFloat(v))) return parseFloat(v);
}
return 0;
}
function carouselActiveIndex(wrap) {
if (!wrap) return 0;
var node = wrap.querySelector('.owl-item.center .car-item')
|| wrap.querySelector('.owl-item.car-active .car-item')
|| wrap.querySelector('.owl-item.active .car-item')
|| wrap.querySelector('.car-item.center')
|| wrap.querySelector('.car-item');
if (!node) return 0;
var i = parseInt(node.dataset.index);
return isNaN(i) ? 0 : i;
}
/** Base (designed) visibility of another element, so per-item show/hide can be reset */
function carouselTargetBaseVisible(id) {
try { if (typeof gameUIElements !== 'undefined' && gameUIElements && gameUIElements[id]) return gameUIElements[id].data.visible !== false; } catch(e) {}
try {
if (typeof elements !== 'undefined' && elements && elements.find) {
var m = elements.find(function(o) { return o.id === id; });
if (m) return m.visible !== false;
}
} catch(e) {}
return true;
}
function carouselSetTargetVisible(dom, show) {
if (!dom) return;
if (dom.classList.contains('gui-el')) {
dom.classList.toggle('gui-hidden', !show);
if (show) dom.style.removeProperty('display');
} else {
dom.style.display = show ? '' : 'none';
dom.classList.toggle('el-hidden', !show);
}
}
/** Applies one action (shared by carousel links and per-item triggers) */
function applyCarouselAction(dom, action, value) {
if (!dom) return;
var inner = dom.querySelector('.gui-inner') || dom.querySelector('.ui-el-inner') || dom.querySelector('.el-inner') || dom.firstElementChild || dom;
switch (action || 'setText') {
case 'setText': inner.textContent = value; break;
case 'setHTML': inner.innerHTML = value; break;
case 'setImage': {
var img = inner.querySelector('img');
if (img) img.src = value;
else {
inner.style.backgroundImage = value ? 'url(' + value + ')' : 'none';
inner.style.backgroundSize = inner.style.backgroundSize || 'cover';
inner.style.backgroundPosition = inner.style.backgroundPosition || 'center';
}
break;
}
case 'setBackground':
inner.style.backgroundImage = value ? 'url(' + value + ')' : 'none';
inner.style.backgroundSize = 'cover';
inner.style.backgroundPosition = 'center';
break;
case 'setVideo': {
var vid = inner.querySelector('video');
if (vid && value) { vid.src = value; vid.load(); }
break;
}
case 'setColor': inner.style.color = value; break;
case 'show': carouselSetTargetVisible(dom, true); break;
case 'hide': carouselSetTargetVisible(dom, false); break;
}
}
/** Every element touched by any item's show/hide actions — reset before each slide */
function carouselManagedVisibility(el) {
var ids = {};
(el.carouselItems || []).forEach(function(it) {
(it.actions || []).forEach(function(a) {
if (a && a.targetId && (a.action === 'show' || a.action === 'hide')) ids[a.targetId] = true;
});
});
return Object.keys(ids);
}
function applyCarouselLinks(el, index) {
var items = el.carouselItems || [];
if (!items.length) return;
if (index < 0 || index >= items.length) index = 0;
var item = items[index];
// 1. Reset anything a slide controls, so show/hide is per-slide rather than sticky
if (el.carResetVisibility !== false) {
carouselManagedVisibility(el).forEach(function(id) {
carouselSetTargetVisible(carouselTargetDom(id), carouselTargetBaseVisible(id));
});
}
// 2. Carousel-wide links (same mapping for every slide)
(el.carLinks || []).forEach(function(link) {
if (!link || !link.targetId) return;
applyCarouselAction(carouselTargetDom(link.targetId), link.action, carouselFieldValue(item, link.field, index));
});
// 3. This slide's own triggers
(item.actions || []).forEach(function(a) {
if (!a || !a.targetId) return;
var val = a.value;
if (a.useField) val = carouselFieldValue(item, a.useField, index);
applyCarouselAction(carouselTargetDom(a.targetId), a.action, val);
});
if (el.carChangeFunc && typeof window[el.carChangeFunc] === 'function') {
try { window[el.carChangeFunc](item, index, el); } catch(err) { console.warn('[Carousel] change function error:', err); }
}
}
/** Current real item index — asks Owl first (clone-safe), then falls back to the DOM */
function carouselCurrentIndex(el, wrap, $t) {
var n = (el.carouselItems || []).length;
if (n) {
try {
var inst = $t && $t.data ? $t.data('owl.carousel') : null;
if (inst && typeof inst.relative === 'function') {
var rel = inst.relative(inst.current());
if (!isNaN(rel)) return ((rel % n) + n) % n;
}
} catch(e) {}
}
return carouselActiveIndex(wrap);
}
function syncCarouselLinks(el, wrap, $t) {
applyCarouselLinks(el, carouselCurrentIndex(el, wrap, $t));
}
function renderUICarousel(container, el, live) {
var items = el.carouselItems || [];
if (typeof disposeUIModel === 'function') {
Array.prototype.forEach.call(container.querySelectorAll('.car-model'), function(m) { disposeUIModel(m); });
}
container.innerHTML = '';
applyCarouselCSS(el);
if (items.length === 0) return;
var wrap = document.createElement('div');
wrap.className = 'car-wrap car-' + el.id;
wrap.style.cssText = 'width:100%;';
var ready = owlAvailable();
var track = document.createElement('div');
track.className = ready ? 'owl-carousel owl-theme' : 'car-fallback';
items.forEach(function(it, idx) {
var box = document.createElement('div');
box.className = 'car-item';
box.dataset.index = idx;
box.innerHTML = carouselItemInnerHTML(el, it);
if (!ready) {
var per = parseInt(el.carItems) || 3;
var slotIdx = carUsesSlotMode(el) ? carActiveSlotOf(el, per) : (el.carCenter ? Math.floor(Math.min(items.length, per) / 2) : -1);
if (slotIdx >= 0 && idx === slotIdx) box.classList.add('center');
}
var modelBox = box.querySelector('.car-model');
if (modelBox && typeof renderUIModel === 'function') {
renderUIModel(modelBox, {
id: el.id + '_m' + idx,
src: it.model,
borderRadius: el.carImgRadius,
modelBg: it.modelBg || el.carModelBg || 'transparent',
modelStubBg: (el.carShowImage !== false && it.image) ? 'transparent' : null,
modelAutoRotate: carItemSpins(el, it, true),
modelRotateSpeed: el.carModelRotateSpeed,
modelControls: el.carModelControls,
modelDragRotate: el.carModelDragRotate,
modelZoom: el.carModelZoom,
modelPan: el.carModelPan,
modelDragSpeed: el.carModelDragSpeed,
modelZoomSpeed: el.carModelZoomSpeed,
modelMinDistance: el.carModelMinDistance,
modelMaxDistance: el.carModelMaxDistance,
modelMinPolar: el.carModelMinPolar,
modelMaxPolar: el.carModelMaxPolar,
modelDamping: el.carModelDamping,
modelStopSpinOnInteract: el.carModelStopSpinOnInteract,
modelResumeSpin: el.carModelResumeSpin,
modelDistance: it.modelDistance || el.carModelDistance,
modelFov: el.carModelFov,
modelLighting: el.carModelLighting,
modelLightIntensity: el.carModelLightIntensity,
modelScale: it.modelScale !== undefined ? it.modelScale : el.carModelScale,
modelRotX: it.modelRotX !== undefined ? it.modelRotX : el.carModelRotX,
modelRotY: it.modelRotY !== undefined ? it.modelRotY : el.carModelRotY,
modelRotZ: it.modelRotZ !== undefined ? it.modelRotZ : el.carModelRotZ,
modelY: it.modelY !== undefined ? it.modelY : el.carModelY,
modelX: it.modelX !== undefined ? it.modelX : el.carModelX,
modelZ: it.modelZ !== undefined ? it.modelZ : el.carModelZ,
modelInset: it.modelInset,
onModelReady: function() { markCarouselActive(el, wrap, $track); },
modelCamHeight: el.carModelCamHeight,
modelAutoFit: true
}, live);
}
if (live && el.carItemClick && el.carItemClick !== 'none') {
box.addEventListener('click', function() {
if (el.carItemClick === 'link' && it.link) {
var t = el.carLinkTarget || 'newTab';
if (t === 'same') window.location.href = it.link;
else if (t === 'iframe' && typeof showIframeOverlay === 'function') showIframeOverlay(it.link);
else window.open(it.link, '_blank');
} else if (el.carItemClick === 'function' && el.carItemFunc && typeof window[el.carItemFunc] === 'function') {
try { window[el.carItemFunc](it, idx, el); } catch(err) { console.warn('[Carousel]', err); }
}
});
}
track.appendChild(box);
});
wrap.appendChild(track);
container.appendChild(wrap);
var $track = null;
if (ready) {
try {
var $t = window.jQuery(track);
$track = $t;
$t.on('initialized.owl.carousel changed.owl.carousel translated.owl.carousel', function(e) {
// Prefer Owl's own index (clone-safe), fall back to reading the DOM
var idx = null;
try {
if (e && e.item && e.relatedTarget && typeof e.relatedTarget.relative === 'function') {
idx = e.relatedTarget.relative(e.item.index);
}
} catch(err) {}
markCarouselActive(el, wrap, $t);
var n = items.length;
if (idx !== null && !isNaN(idx) && n) applyCarouselLinks(el, ((idx % n) + n) % n);
else syncCarouselLinks(el, wrap, $t);
// Re-read once the slide has settled
window.setTimeout(function() {
markCarouselActive(el, wrap, $t);
syncCarouselLinks(el, wrap, $t);
}, (parseInt(el.carSpeed) || 450) + 40);
});
// Clicking a side item brings it to the centre (feels natural with center mode)
if ((el.carCenter || carUsesSlotMode(el)) && live && el.carClickToCenter !== false) {
$t.on('click', '.owl-item:not(.center):not(.car-active) .car-item', function(e) {
if (el.carModelControls && e.target && e.target.closest && e.target.closest('.car-model')) return;
var i = parseInt(this.dataset.index);
if (!isNaN(i)) $t.trigger('to.owl.carousel', [i, parseInt(el.carSpeed) || 450]);
});
}
$t.owlCarousel(owlOptionsFor(el, live));
} catch(err) {
console.warn('[Carousel] init failed:', err);
track.classList.add('owl-loaded'); // show the cards rather than nothing
}
}
window.setTimeout(function() {
markCarouselActive(el, wrap, $track);
syncCarouselLinks(el, wrap, $track);
}, 30);
// Keep Owl in step with size / orientation changes. Owl caches item widths at init,
// so without this a resized (or rotated) carousel shows the wrong number of items.
if (ready && $track) {
var lastPer = owlOptionsFor(el, live).items;
var reflowTimer = null;
var reflow = function() {
window.clearTimeout(reflowTimer);
reflowTimer = window.setTimeout(function() {
if (!document.body.contains(track)) return; // element was re-rendered
var per = carItemsForViewport(el);
if (per !== lastPer) {
lastPer = per;
try {
$track.trigger('destroy.owl.carousel');
} catch(e) { console.warn('[Carousel] destroy failed:', e); }
try {
$track.addClass('owl-carousel owl-theme');
$track.owlCarousel(owlOptionsFor(el, live));
} catch(e) {
console.warn('[Carousel] re-init failed:', e);
$track.addClass('owl-loaded'); // fall back to a plain visible row
}
} else {
try { $track.trigger('refresh.owl.carousel'); } catch(e) {}
}
markCarouselActive(el, wrap, $track);
syncCarouselLinks(el, wrap, $track);
}, 120);
};
window.addEventListener('resize', reflow);
window.addEventListener('orientationchange', reflow);
if (typeof ResizeObserver !== 'undefined') {
try { new ResizeObserver(reflow).observe(wrap); } catch(e) {}
}
}
}
function controlUICarousel(dom, cmd, index) {
if (!owlAvailable() || !dom) return;
var $t = window.jQuery(dom).find('.owl-carousel');
if (!$t.length) return;
switch (cmd || 'next') {
case 'next': $t.trigger('next.owl.carousel'); break;
case 'prev': $t.trigger('prev.owl.carousel'); break;
case 'goTo': $t.trigger('to.owl.carousel', [parseInt(index) || 0, 300]); break;
case 'play': $t.trigger('play.owl.autoplay'); break;
case 'stop': $t.trigger('stop.owl.autoplay'); break;
case 'refresh': $t.trigger('refresh.owl.carousel'); break;
}
}
/* ── Video ── */
/* ── Design stage: reproduce the builder's canvas exactly ───────────────────
The builder lays out on a fixed canvas (960×540 on desktop) and scales it to
fit. Positions are percentages, but font sizes, image heights, paddings and
radii are px/rem — so rendering the same UI into a raw browser window of a
different size changes every one of those proportions. Runtimes therefore
size the overlay to the design resolution and scale it, exactly like the
builder does. */
var UI_DESIGN_SIZES = {
desktop: { w: 960, h: 540 },
tablet: { w: 512, h: 683 },
phone: { w: 320, h: 568 },
'phone-land': { w: 568, h: 320 }
};
/** Anchor → translate offsets (the element's own position is a % of the stage) */
function uiAnchorTranslate(anchor) {
switch (anchor || 'center') {
case 'top-left': return { tx: '0', ty: '0' };
case 'top-center': return { tx: '-50%', ty: '0' };
case 'top-right': return { tx: '-100%', ty: '0' };
case 'center-left': return { tx: '0', ty: '-50%' };
case 'center-right': return { tx: '-100%', ty: '-50%' };
case 'bottom-left': return { tx: '0', ty: '-100%' };
case 'bottom-center': return { tx: '-50%', ty: '-100%' };
case 'bottom-right': return { tx: '-100%', ty: '-100%' };
default: return { tx: '-50%', ty: '-50%' };
}
}
/** The transform an element carries: anchor offset plus a hover/active scale hook */
function uiAnchorTransform(anchor) {
var a = uiAnchorTranslate(anchor);
return 'translate(' + a.tx + ', ' + a.ty + ') scale(var(--gui-scale, 1))';
}
function uiDeviceBucket() {
var w = (typeof window !== 'undefined' && window.innerWidth) ? window.innerWidth : 1280;
var h = (typeof window !== 'undefined' && window.innerHeight) ? window.innerHeight : 800;
var landscape = w > h;
if (landscape && (w <= 768 || (h <= 500 && w <= 1200))) return 'phone-land';
if (w <= 480) return 'phone';
if (w <= 1024) return 'tablet';
return 'desktop';
}
function uiDesignSize(uiData) {
var sizes = (uiData && uiData.designSizes) || UI_DESIGN_SIZES;
return sizes[uiDeviceBucket()] || sizes.desktop || UI_DESIGN_SIZES.desktop;
}
function applyUIStage(uiData, overlayId) {
var overlay = document.getElementById(overlayId || 'game-ui-overlay');
if (!overlay) return;
var mode = (uiData && uiData.scaleMode) || 'fit';
if (mode === 'stretch') { // legacy: fill the window, px sizes unscaled
overlay.style.width = '100%';
overlay.style.height = '100%';
overlay.style.left = '0';
overlay.style.top = '0';
overlay.style.transform = '';
overlay.style.transformOrigin = '';
return;
}
var size = uiDesignSize(uiData);
var w = window.innerWidth, h = window.innerHeight;
var scale = mode === 'fill'
? Math.max(w / size.w, h / size.h)
: Math.min(w / size.w, h / size.h);
overlay.style.width = size.w + 'px';
overlay.style.height = size.h + 'px';
overlay.style.left = '50%';
overlay.style.top = '50%';
overlay.style.transformOrigin = 'center center';
overlay.style.transform = 'translate(-50%, -50%) scale(' + scale + ')';
}
/** Re-apply on resize / rotate; safe to call repeatedly. */
function watchUIStage(getUIData, overlayId) {
if (window.__uiStageWatch) window.removeEventListener('resize', window.__uiStageWatch);
var timer = null;
window.__uiStageWatch = function() {
window.clearTimeout(timer);
timer = window.setTimeout(function() { applyUIStage(getUIData(), overlayId); }, 60);
};
window.addEventListener('resize', window.__uiStageWatch);
window.addEventListener('orientationchange', window.__uiStageWatch);
}
/* ── 3D model viewer (Three.js + GLB) ──────────────────────────────────────
Loads Three.js on demand the first time a model element is rendered, so a
project without one pays nothing. */
var UI_THREE_VERSION = '0.160.0';
var UI_THREE_CDN = 'https://cdn.jsdelivr.net/npm/three@' + UI_THREE_VERSION;
var UI_THREE_ESM = 'https://esm.sh/three@' + UI_THREE_VERSION;
var _uiThreePromise = null;
var _uiThreeError = '';
/**
* Three's add-ons (GLTFLoader, OrbitControls) are published as ES modules that
* `import ... from 'three'` — a bare specifier the browser can only resolve through
* an import map. Loading them straight from a CDN therefore fails with
* "Failed to resolve module specifier". So: make sure an import map exists first,
* and keep a self-resolving CDN as a backup.
*/
function ensureThreeImportMap() {
if (typeof document === 'undefined') return false;
var existing = document.querySelector('script[type="importmap"]');
if (existing) {
// A host page (game-level) already maps "three" — use whatever it points at
return true;
}
try {
var map = document.createElement('script');
map.type = 'importmap';
map.textContent = JSON.stringify({
imports: {
'three': UI_THREE_CDN + '/build/three.module.js',
'three/': UI_THREE_CDN + '/',
'three/addons/': UI_THREE_CDN + '/examples/jsm/'
}
});
document.head.appendChild(map);
return true;
} catch(e) {
return false;
}
}
function loadUIThree() {
if (typeof window !== 'undefined' && window.__uiThreeStub) return Promise.resolve(window.__uiThreeStub);
if (_uiThreePromise) return _uiThreePromise;
var viaImportMap = function() {
ensureThreeImportMap();
return Promise.all([
import(/* webpackIgnore: true */ 'three'),
import(/* webpackIgnore: true */ 'three/addons/loaders/GLTFLoader.js'),
import(/* webpackIgnore: true */ 'three/addons/controls/OrbitControls.js')
]);
};
// esm.sh rewrites the add-ons' internal "three" imports, so it needs no map
var viaEsmSh = function() {
return Promise.all([
import(/* webpackIgnore: true */ UI_THREE_ESM),
import(/* webpackIgnore: true */ UI_THREE_ESM + '/examples/jsm/loaders/GLTFLoader.js'),
import(/* webpackIgnore: true */ UI_THREE_ESM + '/examples/jsm/controls/OrbitControls.js')
]);
};
_uiThreePromise = viaImportMap()
.catch(function(err) {
console.warn('[Model3D] import-map load failed (' + (err && err.message) + '), retrying via esm.sh');
return viaEsmSh();
})
.then(function(m) {
return { THREE: m[0], GLTFLoader: m[1].GLTFLoader, OrbitControls: m[2].OrbitControls };
})
.catch(function(err) {
_uiThreeError = (err && err.message) || 'unknown error';
console.warn('[Model3D] Three.js could not be loaded:', _uiThreeError,
'\nIf you are offline, host three.module.js yourself and add an import map to the page.');
return null;
});
return _uiThreePromise;
}
function disposeUIModel(container) {
if (!container) return;
var host = container.querySelector('.ui-model');
if (host && host._modelCleanup) { try { host._modelCleanup(); } catch(e) {} }
}
function modelPlaceholder(el, message, spinner) {
return '<div class="ui-model-stub" style="width:100%;height:100%;min-height:60px;display:flex;flex-direction:column;' +
'gap:6px;align-items:center;justify-content:center;background:' +
(el.modelStubBg ? el.modelStubBg : (el.modelBg && el.modelBg !== 'transparent' ? el.modelBg : 'rgba(0,0,0,0.3)')) +
';border-radius:' + (el.borderRadius || '8px') + ';color:#c9d2e0;text-align:center;padding:8px;box-sizing:border-box;">' +
'<i class="bi ' + (spinner ? 'bi-arrow-repeat' : 'bi-box') + '" style="font-size:1.5rem;opacity:0.65;' +
(spinner ? 'animation:uiModelSpin 1.1s linear infinite;' : '') + '"></i>' +
'<span style="font-size:0.5rem;opacity:0.75;">' + message + '</span></div>';
}
function renderUIModel(container, el, live) {
disposeUIModel(container);
container.innerHTML = '';
if (!document.getElementById('ui-model-spin-css')) {
var st = document.createElement('style');
st.id = 'ui-model-spin-css';
st.textContent = '@keyframes uiModelSpin { to { transform:rotate(360deg); } }';
document.head.appendChild(st);
}
if (!el.src) {
container.innerHTML = modelPlaceholder(el, 'Set a .glb / .gltf path', false);
return null;
}
var host = document.createElement('div');
host.className = 'ui-model';
host.style.cssText = 'position:relative;width:100%;height:100%;min-height:40px;overflow:hidden;border-radius:' +
(el.borderRadius || '0') + ';' + (el.modelBg && el.modelBg !== 'transparent' ? 'background:' + el.modelBg + ';' : '');
var loading = document.createElement('div');
loading.className = 'ui-model-loading';
loading.style.cssText = 'position:absolute;inset:0;';
loading.innerHTML = modelPlaceholder(el, 'Loading 3D model…', true);
host.appendChild(loading);
container.appendChild(host);
var cancelled = false;
host._modelEl = el;
host._modelCleanup = function() { cancelled = true; };
loadUIThree().then(function(libs) {
if (cancelled || !host.isConnected) return;
if (!libs || !libs.THREE) {
host.innerHTML = modelPlaceholder(el, '3D viewer unavailable' + (_uiThreeError ? ' — ' + _uiThreeError : ''), false);
return;
}
var THREE = libs.THREE;
var w = host.clientWidth || 300, h = host.clientHeight || 200;
var renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true, preserveDrawingBuffer: false });
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
renderer.setSize(w, h, false);
renderer.domElement.style.cssText = 'width:100%;height:100%;display:block;';
if (renderer.outputColorSpace !== undefined && THREE.SRGBColorSpace) renderer.outputColorSpace = THREE.SRGBColorSpace;
if (el.modelShadows && renderer.shadowMap) { renderer.shadowMap.enabled = true; renderer.shadowMap.type = THREE.PCFSoftShadowMap; }
var scene = new THREE.Scene();
if (el.modelBg && el.modelBg !== 'transparent') {
try { scene.background = new THREE.Color(el.modelBg); } catch(e) {}
} else {
renderer.setClearColor(0x000000, 0);
}
var camera = new THREE.PerspectiveCamera(parseFloat(el.modelFov) || 45, w / h, 0.01, 2000);
// Lighting presets
var preset = el.modelLighting || 'studio';
var intensity = el.modelLightIntensity !== undefined ? parseFloat(el.modelLightIntensity) : 1;
var amb = new THREE.AmbientLight(0xffffff, (preset === 'dramatic' ? 0.25 : preset === 'soft' ? 0.9 : 0.55) * intensity);
scene.add(amb);
var key = new THREE.DirectionalLight(0xffffff, (preset === 'dramatic' ? 2.2 : preset === 'soft' ? 0.8 : 1.4) * intensity);
key.position.set(3, 5, 4);
if (el.modelShadows) { key.castShadow = true; }
scene.add(key);
if (preset !== 'dramatic') {
var fill = new THREE.DirectionalLight(0xffffff, 0.6 * intensity);
fill.position.set(-4, 2, -3);
scene.add(fill);
}
if (el.modelHemi !== false && THREE.HemisphereLight) {
scene.add(new THREE.HemisphereLight(0xffffff, 0x223344, 0.4 * intensity));
}
var root = new THREE.Group();
scene.add(root);
var controls = null;
if (el.modelControls && libs.OrbitControls) {
controls = new libs.OrbitControls(camera, renderer.domElement);
controls.enableDamping = el.modelDamping !== false;
controls.dampingFactor = parseFloat(el.modelDampingFactor) || 0.08;
controls.enableRotate = el.modelDragRotate !== false;
controls.enableZoom = el.modelZoom !== false;
controls.enablePan = !!el.modelPan;
controls.rotateSpeed = parseFloat(el.modelDragSpeed) || 1;
controls.zoomSpeed = parseFloat(el.modelZoomSpeed) || 1;
controls.autoRotate = !!(live && el.modelAutoRotate);
controls.autoRotateSpeed = parseFloat(el.modelRotateSpeed) || 2;
if (el.modelMinDistance !== undefined && el.modelMinDistance !== '') controls.minDistance = parseFloat(el.modelMinDistance);
if (el.modelMaxDistance !== undefined && el.modelMaxDistance !== '') controls.maxDistance = parseFloat(el.modelMaxDistance);
if (el.modelMinPolar !== undefined && el.modelMinPolar !== '') controls.minPolarAngle = parseFloat(el.modelMinPolar) * Math.PI / 180;
if (el.modelMaxPolar !== undefined && el.modelMaxPolar !== '') controls.maxPolarAngle = parseFloat(el.modelMaxPolar) * Math.PI / 180;
// Touch gestures: one finger turns, two fingers zoom (and pan when allowed)
if (THREE.TOUCH) {
controls.touches = {
ONE: el.modelDragRotate === false ? THREE.TOUCH.PAN : THREE.TOUCH.ROTATE,
TWO: el.modelPan ? THREE.TOUCH.DOLLY_PAN : THREE.TOUCH.DOLLY_ROTATE
};
}
renderer.domElement.style.touchAction = 'none';
renderer.domElement.style.cursor = 'grab';
// Don't let a carousel (or any parent) steal the gesture
['pointerdown', 'mousedown', 'touchstart', 'wheel', 'click'].forEach(function(evt) {
renderer.domElement.addEventListener(evt, function(e) { e.stopPropagation(); }, { passive: evt === 'wheel' ? false : true });
});
// Spinning pauses while the user is dragging, and can come back afterwards
controls.addEventListener('start', function() {
renderer.domElement.style.cursor = 'grabbing';
if (el.modelStopSpinOnInteract !== false) {
controls.autoRotate = false;
if (host._modelState) host._modelState.spinning = false;
}
if (host._resumeTimer) { window.clearTimeout(host._resumeTimer); host._resumeTimer = null; }
});
controls.addEventListener('end', function() {
renderer.domElement.style.cursor = 'grab';
var delay = parseFloat(el.modelResumeSpin);
if (el.modelStopSpinOnInteract !== false && el.modelAutoRotate && delay > 0) {
host._resumeTimer = window.setTimeout(function() {
controls.autoRotate = true;
if (host._modelState) host._modelState.spinning = true;
}, delay);
}
});
}
function frameModel(obj) {
var box = new THREE.Box3().setFromObject(obj);
if (!isFinite(box.min.x)) return;
var size = box.getSize(new THREE.Vector3());
var center = box.getCenter(new THREE.Vector3());
var maxDim = Math.max(size.x, size.y, size.z) || 1;
if (el.modelAutoFit !== false) {
obj.position.sub(center); // sit on the origin
var target = 1.6;
obj.scale.multiplyScalar(target / maxDim);
}
var dist = (parseFloat(el.modelDistance) || 4);
camera.position.set(0, parseFloat(el.modelCamHeight) || 0.4, dist);
camera.lookAt(0, parseFloat(el.modelTargetY) || 0, 0);
if (controls) { controls.target.set(0, parseFloat(el.modelTargetY) || 0, 0); controls.update(); }
}
function applyTransform(obj) {
obj.position.x += parseFloat(el.modelX) || 0;
obj.position.y += parseFloat(el.modelY) || 0;
obj.position.z += parseFloat(el.modelZ) || 0;
var sc = parseFloat(el.modelScale);
if (!sc && sc !== 0) sc = 1;
obj.scale.multiplyScalar(sc);
obj.rotation.set(
(parseFloat(el.modelRotX) || 0) * Math.PI / 180,
(parseFloat(el.modelRotY) || 0) * Math.PI / 180,
(parseFloat(el.modelRotZ) || 0) * Math.PI / 180
);
}
var mixer = null, clock = new THREE.Clock();
var loader = new libs.GLTFLoader();
loader.load(el.src, function(gltf) {
if (cancelled || !host.isConnected) return;
var obj = gltf.scene || gltf.scenes[0];
if (el.modelShadows) {
obj.traverse(function(n) { if (n.isMesh) { n.castShadow = true; n.receiveShadow = true; } });
}
frameModel(obj);
host._modelBaseScale = obj.scale.x; // scale after auto-fit, before user scale
applyTransform(obj);
root.add(obj);
host._modelObject = obj;
if (gltf.animations && gltf.animations.length && el.modelPlayClips !== false) {
mixer = new THREE.AnimationMixer(obj);
gltf.animations.forEach(function(clip) { mixer.clipAction(clip).play(); });
}
if (loading.parentNode) loading.remove();
// Let the host (e.g. a carousel) re-apply its pose now the model exists
if (typeof el.onModelReady === 'function') {
try { el.onModelReady(host); } catch(e) { console.warn('[Model3D] onModelReady:', e); }
}
}, undefined, function(err) {
var isFile = (location.protocol === 'file:');
console.warn('[Model3D] failed to load', el.src, err && err.message,
isFile ? '\nThe page was opened from disk (file://) — browsers refuse to fetch .glb files that way. ' +
'Tick "Embed 3D models" when exporting, or serve the folder over http.' : '');
host.innerHTML = modelPlaceholder(el,
isFile ? 'Cannot load models from file:// — embed them on export or serve over http'
: 'Could not load ' + el.src, false);
});
host.appendChild(renderer.domElement);
// Keep the drawing buffer in step with the element
var ro = null;
function resize() {
var nw = host.clientWidth || w, nh = host.clientHeight || h;
if (!nw || !nh) return;
camera.aspect = nw / nh;
camera.updateProjectionMatrix();
renderer.setSize(nw, nh, false);
}
if (typeof ResizeObserver !== 'undefined') {
try { ro = new ResizeObserver(resize); ro.observe(host); } catch(e) {}
}
window.addEventListener('resize', resize);
var raf = null, spinning = !!(live && el.modelAutoRotate && !controls);
host._modelState = { spinning: spinning };
function tick() {
if (cancelled || !host.isConnected) return;
raf = requestAnimationFrame(tick);
var dt = clock.getDelta();
if (mixer) mixer.update(dt);
if (host._modelState.spinning && host._modelObject) {
host._modelObject.rotation.y += (parseFloat(el.modelRotateSpeed) || 2) * 0.25 * dt;
}
if (controls) controls.update();
renderer.render(scene, camera);
}
tick();
host._modelCleanup = function() {
cancelled = true;
if (raf) cancelAnimationFrame(raf);
window.removeEventListener('resize', resize);
if (ro) { try { ro.disconnect(); } catch(e) {} }
if (controls) { try { controls.dispose(); } catch(e) {} }
try {
scene.traverse(function(n) {
if (n.geometry) n.geometry.dispose();
if (n.material) {
var mats = Array.isArray(n.material) ? n.material : [n.material];
mats.forEach(function(m) {
Object.keys(m).forEach(function(k) { if (m[k] && m[k].isTexture) m[k].dispose(); });
m.dispose();
});
}
});
renderer.dispose();
} catch(e) {}
};
host._modelApi = {
renderer: renderer, scene: scene, camera: camera, root: root, controls: controls,
setSpin: function(on) {
host._modelState.spinning = !!on && !controls;
if (controls) controls.autoRotate = !!on;
},
setPose: function(pose) {
if (!pose) return;
var obj = host._modelObject;
if (obj) {
if (pose.rotX !== undefined || pose.rotY !== undefined || pose.rotZ !== undefined) {
obj.rotation.set(
(pose.rotX || 0) * Math.PI / 180,
(pose.rotY || 0) * Math.PI / 180,
(pose.rotZ || 0) * Math.PI / 180
);
}
if (pose.scale !== undefined && host._modelBaseScale) {
var sc = host._modelBaseScale * pose.scale;
obj.scale.set(sc, sc, sc);
}
}
if (pose.distance !== undefined && camera) {
camera.position.z = pose.distance;
if (controls) controls.update();
}
},
controlsFor: function() { return controls; },
reset: function() {
if (host._modelObject) {
host._modelObject.rotation.set(
(parseFloat(el.modelRotX) || 0) * Math.PI / 180,
(parseFloat(el.modelRotY) || 0) * Math.PI / 180,
(parseFloat(el.modelRotZ) || 0) * Math.PI / 180
);
}
if (controls) controls.reset();
}
};
});
return host;
}
function controlUIModel(dom, cmd, value) {
var host = dom ? dom.querySelector('.ui-model') : null;
if (!host) return;
var api = host._modelApi;
switch (cmd || 'toggleSpin') {
case 'spin': if (api) api.setSpin(true); break;
case 'stop': if (api) api.setSpin(false); break;
case 'toggleSpin': if (api) api.setSpin(!(host._modelState && host._modelState.spinning)); break;
case 'reset': if (api) api.reset(); break;
case 'setModel':
if (value && host.parentNode) {
var el = host._modelEl || {};
var next = {};
for (var k in el) next[k] = el[k];
next.src = value;
renderUIModel(host.parentNode, next, true);
}
break;
}
}
/* ── Iframe / embed element ── */
function uiEmbedURL(src) {
var u = String(src || '').trim();
if (!u) return '';
// Friendly conversions so a pasted share link just works
var yt = u.match(/(?:youtube\.com\/(?:watch\?v=|live\/|shorts\/)|youtu\.be\/)([\w-]{6,})/i);
if (yt) return 'https://www.youtube.com/embed/' + yt[1];
var vim = u.match(/vimeo\.com\/(?:video\/)?(\d+)/i);
if (vim) return 'https://player.vimeo.com/video/' + vim[1];
var gmap = u.match(/google\.[a-z.]+\/maps\/place\/([^/?]+)/i);
if (gmap && u.indexOf('/embed') === -1) {
return 'https://www.google.com/maps?q=' + encodeURIComponent(decodeURIComponent(gmap[1])) + '&output=embed';
}
return u;
}
function buildUIFrame(el) {
var frame = document.createElement('iframe');
frame.className = 'ui-frame';
frame.src = uiEmbedURL(el.src);
frame.style.cssText = 'width:100%;height:100%;display:block;border:' + (el.frameBorder || 'none') +
';border-radius:' + (el.borderRadius || '0') + ';background:' + (el.frameBg || 'transparent') + ';';
if (el.frameTitle) frame.title = el.frameTitle;
frame.setAttribute('allow', el.frameAllow || 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture');
if (el.frameAllowFullscreen !== false) frame.setAttribute('allowfullscreen', '');
if (el.frameLazy) frame.setAttribute('loading', 'lazy');
if (el.frameSandbox) frame.setAttribute('sandbox', el.frameSandboxRules || 'allow-scripts allow-same-origin allow-popups allow-forms');
if (el.frameScroll === false) frame.setAttribute('scrolling', 'no');
return frame;
}
function renderUIFrame(container, el, live) {
container.innerHTML = '';
if (!el.src) {
container.innerHTML = '<div style="width:100%;height:100%;min-height:60px;display:flex;flex-direction:column;gap:4px;' +
'align-items:center;justify-content:center;background:rgba(0,0,0,0.25);border:1px dashed rgba(255,255,255,0.2);' +
'border-radius:' + (el.borderRadius || '8px') + ';"><i class="bi bi-window" style="font-size:1.5rem;opacity:0.5;"></i>' +
'<span style="font-size:0.5rem;opacity:0.6;">Set a page URL</span></div>';
return null;
}
// While designing, a placeholder avoids loading (and autoplaying) the real page
if (!live && el.framePreviewOnly !== false) {
var box = document.createElement('div');
box.className = 'ui-frame-stub';
box.style.cssText = 'width:100%;height:100%;display:flex;flex-direction:column;gap:6px;align-items:center;justify-content:center;' +
'background:' + (el.frameBg || 'rgba(0,0,0,0.35)') + ';border:' + (el.frameBorder || '1px solid rgba(255,255,255,0.12)') +
';border-radius:' + (el.borderRadius || '8px') + ';color:#cfd6e4;text-align:center;padding:8px;box-sizing:border-box;overflow:hidden;';
box.innerHTML = '<i class="bi bi-window-stack" style="font-size:1.4rem;opacity:0.7;"></i>' +
'<div style="font-size:0.5rem;font-weight:700;letter-spacing:1px;">EMBEDDED PAGE</div>' +
'<div style="font-size:0.45rem;opacity:0.7;word-break:break-all;max-width:90%;">' + escAttr(uiEmbedURL(el.src)) + '</div>' +
'<div style="font-size:0.4rem;opacity:0.5;">loads in Preview and in the player</div>';
container.appendChild(box);
return null;
}
var frame = buildUIFrame(el);
container.appendChild(frame);
return frame;
}
function controlUIFrame(dom, cmd, value) {
var frame = dom ? dom.querySelector('iframe') : null;
if (!frame) return;
switch (cmd || 'reload') {
case 'reload': frame.src = frame.src; break;
case 'setSrc': if (value) frame.src = uiEmbedURL(value); break;
case 'blank': frame.src = 'about:blank'; break;
}
}
/* ── Hero section (full-bleed backgrounds that cross-fade) ── */
function heroSlidesOf(el) {
var list = el.heroSlides || [];
return list.length ? list : [{ id: 'hs_empty', image: '', title: '', text: '' }];
}
function buildHeroCSS(el) {
var s = '.hero-' + el.id;
var dur = parseInt(el.heroTransDuration);
if (!dur && dur !== 0) dur = 900;
var trans = el.heroTransition || 'fade';
var pad = el.heroPadding || '6%';
var alignH = el.heroAlignH || 'center';
var alignV = el.heroAlignV || 'center';
var justify = alignV === 'top' ? 'flex-start' : alignV === 'bottom' ? 'flex-end' : 'center';
var items = alignH === 'left' ? 'flex-start' : alignH === 'right' ? 'flex-end' : 'center';
var textAlign = alignH === 'left' ? 'left' : alignH === 'right' ? 'right' : 'center';
// How an inactive slide sits before/after it becomes the active one
var restState, activeState;
if (trans === 'slide-left') { restState = 'transform:translateX(100%);'; activeState = 'transform:translateX(0);'; }
else if (trans === 'slide-right') { restState = 'transform:translateX(-100%);'; activeState = 'transform:translateX(0);'; }
else if (trans === 'slide-up') { restState = 'transform:translateY(100%);'; activeState = 'transform:translateY(0);'; }
else if (trans === 'zoom') { restState = 'opacity:0; transform:scale(1.15);'; activeState = 'opacity:1; transform:scale(1);'; }
else { restState = 'opacity:0;'; activeState = 'opacity:1;'; }
var out = [
s + ' { position:relative; width:100%; height:100%; overflow:hidden; }',
s + ' .hero-bgs { position:absolute; inset:0; overflow:hidden; }',
s + ' .hero-bg { position:absolute; inset:0; background-size:' + (el.heroBgFit || 'cover') +
'; background-position:' + (el.heroBgPosition || 'center') + '; background-repeat:no-repeat; ' +
restState + ' transition:opacity ' + dur + 'ms ease, transform ' + dur + 'ms ease; }',
s + ' .hero-bg.active { ' + activeState + ' z-index:1; }',
s + ' .hero-bg video { width:100%; height:100%; object-fit:' + (el.heroBgFit || 'cover') + '; display:block; }',
s + ' .hero-overlay { position:absolute; inset:0; z-index:2; pointer-events:none; background:' +
(el.heroOverlay || 'none') + '; opacity:' + (el.heroOverlayOpacity !== undefined ? el.heroOverlayOpacity : 1) + '; }',
s + ' .hero-content { position:absolute; inset:0; z-index:3; display:flex; flex-direction:column; box-sizing:border-box;' +
' justify-content:' + justify + '; align-items:' + items + '; text-align:' + textAlign +
'; padding:' + pad + '; gap:' + (el.heroGap || '10px') + '; pointer-events:none; }',
s + ' .hero-content > * { pointer-events:auto; max-width:' + (el.heroMaxWidth || '100%') + '; }',
s + ' .hero-title { font-family:' + (el.heroTitleFont || el.fontFamily || "'Orbitron',sans-serif") +
'; font-size:' + (el.heroTitleSize !== undefined ? el.heroTitleSize : 2.4) + (el.heroTitleUnit || 'rem') +
'; font-weight:' + (el.heroTitleWeight || '700') + '; color:' + (el.heroTitleColor || '#ffffff') +
'; line-height:' + (el.heroTitleLineHeight || '1.1') + '; letter-spacing:' + (el.heroTitleSpacing || 'normal') +
'; text-shadow:' + (el.heroTitleShadow || 'none') + '; margin:0; }',
s + ' .hero-sub { font-family:' + (el.heroSubFont || el.fontFamily || "'Rajdhani',sans-serif") +
'; font-size:' + (el.heroSubSize !== undefined ? el.heroSubSize : 1) + (el.heroSubUnit || 'rem') +
'; font-weight:' + (el.heroSubWeight || '400') + '; color:' + (el.heroSubColor || '#d0d8e4') +
'; line-height:' + (el.heroSubLineHeight || '1.5') + '; text-shadow:' + (el.heroSubShadow || 'none') + '; margin:0; }',
s + ' .hero-children { position:absolute; inset:0; z-index:4; pointer-events:none; }',
s + ' .hero-dots { position:absolute; z-index:5; left:0; right:0; bottom:' + (parseFloat(el.heroDotBottom) || 18) +
'px; display:flex; justify-content:center; align-items:center; gap:' + (parseFloat(el.heroDotGap) || 8) + 'px; }',
s + ' .hero-dot { width:' + (parseFloat(el.heroDotWidth) || 10) + 'px; height:' + (parseFloat(el.heroDotHeight) || 10) +
'px; border-radius:' + (el.heroDotRadius || '50%') + '; background:' + (el.heroDotColor || 'rgba(255,255,255,0.35)') +
'; cursor:pointer; transition:all 0.25s; border:none; padding:0; }',
s + ' .hero-dot.active { background:' + (el.heroDotActiveColor || '#ffffff') + ';' +
(el.heroDotActiveWidth ? ' width:' + parseFloat(el.heroDotActiveWidth) + 'px;' : '') + ' }',
s + ' .hero-arrow { position:absolute; z-index:5; top:50%; transform:translateY(-50%); width:' +
(parseFloat(el.heroArrowSize) || 36) + 'px; height:' + (parseFloat(el.heroArrowSize) || 36) +
'px; border-radius:' + (el.heroArrowRadius || '50%') + '; background:' + (el.heroArrowBg || 'rgba(0,0,0,0.35)') +
'; color:' + (el.heroArrowColor || '#fff') + '; border:none; cursor:pointer; display:flex; align-items:center; justify-content:center; font-size:' +
(el.heroArrowIconSize !== undefined ? el.heroArrowIconSize : 1) + 'rem; }',
s + ' .hero-arrow.prev { left:' + (parseFloat(el.heroArrowInset) || 12) + 'px; }',
s + ' .hero-arrow.next { right:' + (parseFloat(el.heroArrowInset) || 12) + 'px; }'
];
if (el.heroKenBurns) {
out.push('@keyframes heroKen-' + el.id + ' { from { transform:scale(1); } to { transform:scale(' +
(el.heroKenScale || 1.12) + '); } }');
out.push(s + ' .hero-bg.active { animation:heroKen-' + el.id + ' ' +
((parseInt(el.heroInterval) || 6000) + dur) + 'ms ease-out forwards; }');
}
return out.join('\n');
}
function applyHeroCSS(el) {
var st = document.getElementById('hero-style-' + el.id);
if (!st) { st = document.createElement('style'); st.id = 'hero-style-' + el.id; document.head.appendChild(st); }
st.textContent = buildHeroCSS(el);
}
function heroShowSlide(wrap, el, index, live) {
var slides = heroSlidesOf(el);
var n = slides.length;
if (!n) return;
index = ((index % n) + n) % n;
wrap._heroIndex = index;
var bgs = wrap.querySelectorAll('.hero-bg');
Array.prototype.forEach.call(bgs, function(b, i) { b.classList.toggle('active', i === index); });
var dots = wrap.querySelectorAll('.hero-dot');
Array.prototype.forEach.call(dots, function(d, i) { d.classList.toggle('active', i === index); });
// Per-slide headings fall back to the hero's own text
var slide = slides[index] || {};
var t = wrap.querySelector('.hero-title');
var sub = wrap.querySelector('.hero-sub');
if (t) {
var title = slide.title !== undefined && slide.title !== '' ? slide.title : (el.heroTitle || '');
t.innerHTML = title;
t.style.display = title ? '' : 'none';
}
if (sub) {
var text = slide.text !== undefined && slide.text !== '' ? slide.text : (el.heroSubtitle || '');
sub.innerHTML = text;
sub.style.display = text ? '' : 'none';
}
// Videos only play while their slide is on screen
Array.prototype.forEach.call(bgs, function(b, i) {
var v = b.querySelector('video');
if (!v) return;
if (i === index && live) { v.play().catch(function(){}); }
else { v.pause(); }
});
if (live && el.heroChangeFunc && typeof window[el.heroChangeFunc] === 'function') {
try { window[el.heroChangeFunc](slide, index, el); } catch(e) { console.warn('[Hero]', e); }
}
}
function heroStopAuto(wrap) {
if (wrap && wrap._heroTimer) { window.clearInterval(wrap._heroTimer); wrap._heroTimer = null; }
}
function heroStartAuto(wrap, el, live) {
heroStopAuto(wrap);
if (!live || !el.heroAutoplay) return;
var slides = heroSlidesOf(el);
if (slides.length < 2) return;
wrap._heroTimer = window.setInterval(function() {
if (!document.body.contains(wrap)) { heroStopAuto(wrap); return; }
heroShowSlide(wrap, el, (wrap._heroIndex || 0) + 1, live);
}, Math.max(600, parseInt(el.heroInterval) || 6000));
}
function renderUIHero(container, el, live) {
var prev = container.querySelector('.hero-wrap');
if (prev) heroStopAuto(prev);
container.innerHTML = '';
applyHeroCSS(el);
var wrap = document.createElement('div');
wrap.className = 'hero-wrap hero-' + el.id;
var slides = heroSlidesOf(el);
var bgs = document.createElement('div');
bgs.className = 'hero-bgs';
slides.forEach(function(sl, i) {
var b = document.createElement('div');
b.className = 'hero-bg';
if (sl.video) {
var v = document.createElement('video');
v.src = sl.video; v.muted = true; v.loop = true; v.playsInline = true; v.preload = 'auto';
b.appendChild(v);
} else if (sl.image) {
b.style.backgroundImage = 'url(' + sl.image + ')';
} else {
b.style.background = sl.color || (el.heroEmptyBg || 'linear-gradient(135deg,#1b2030,#0b0e17)');
}
bgs.appendChild(b);
});
wrap.appendChild(bgs);
var ov = document.createElement('div');
ov.className = 'hero-overlay';
wrap.appendChild(ov);
var content = document.createElement('div');
content.className = 'hero-content';
var title = document.createElement('div');
title.className = 'hero-title';
var sub = document.createElement('div');
sub.className = 'hero-sub';
content.appendChild(title);
content.appendChild(sub);
wrap.appendChild(content);
// Anything parented onto the hero lands here
var kids = document.createElement('div');
kids.className = 'hero-children gui-children ui-el-children';
wrap.appendChild(kids);
if (el.heroArrows && slides.length > 1) {
['prev', 'next'].forEach(function(dir) {
var b = document.createElement('button');
b.className = 'hero-arrow ' + dir;
b.innerHTML = '<i class="bi ' + (dir === 'prev'
? (el.heroPrevIcon || 'bi-chevron-left')
: (el.heroNextIcon || 'bi-chevron-right')) + '"></i>';
b.addEventListener('click', function(e) {
e.stopPropagation();
heroShowSlide(wrap, el, (wrap._heroIndex || 0) + (dir === 'next' ? 1 : -1), live);
heroStartAuto(wrap, el, live);
});
wrap.appendChild(b);
});
}
if (el.heroDots && slides.length > 1) {
var dotBox = document.createElement('div');
dotBox.className = 'hero-dots';
slides.forEach(function(sl, i) {
var dt = document.createElement('button');
dt.className = 'hero-dot';
dt.addEventListener('click', function(e) {
e.stopPropagation();
heroShowSlide(wrap, el, i, live);
heroStartAuto(wrap, el, live);
});
dotBox.appendChild(dt);
});
wrap.appendChild(dotBox);
}
wrap._heroEl = el;
wrap._heroLive = live;
container.appendChild(wrap);
heroShowSlide(wrap, el, parseInt(el.heroStart) || 0, live);
heroStartAuto(wrap, el, live);
return wrap;
}
function controlUIHero(dom, cmd, index) {
var wrap = dom ? dom.querySelector('.hero-wrap') : null;
if (!wrap) return;
var el = wrap._heroEl;
if (!el) return;
switch (cmd || 'next') {
case 'next': heroShowSlide(wrap, el, (wrap._heroIndex || 0) + 1, true); break;
case 'prev': heroShowSlide(wrap, el, (wrap._heroIndex || 0) - 1, true); break;
case 'goTo': heroShowSlide(wrap, el, parseInt(index) || 0, true); break;
case 'play': heroStartAuto(wrap, el, true); break;
case 'stop': heroStopAuto(wrap); break;
}
}
/* ── Video chroma key (colour → transparency) ── */
function uiHexToRgb(hex) {
var h = String(hex || '#00ff00').trim();
if (h.charAt(0) === '#') h = h.slice(1);
if (h.length === 3) h = h.charAt(0) + h.charAt(0) + h.charAt(1) + h.charAt(1) + h.charAt(2) + h.charAt(2);
var n = parseInt(h, 16);
if (isNaN(n)) return { r: 0, g: 255, b: 0 };
return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 };
}
function stopUIVideoChroma(container) {
if (!container) return;
var old = container.querySelector('canvas.ui-chroma');
if (old) { old._stop = true; old.remove(); }
}
/**
* Replaces the <video> with a canvas whose keyed colour is made transparent.
* Falls back to the plain video if the source taints the canvas (cross-origin).
*/
function setupUIVideoChroma(container, vid, el) {
stopUIVideoChroma(container);
if (!container || !vid) return;
vid.style.removeProperty('display');
if (!el.chromaKey) return;
var canvas = document.createElement('canvas');
canvas.className = 'ui-chroma';
canvas.style.cssText = vid.style.cssText;
canvas.style.background = 'transparent';
var ctx = null;
try { ctx = canvas.getContext('2d', { willReadFrequently: true }); } catch(e) {}
if (!ctx) return;
vid.style.display = 'none';
container.appendChild(canvas);
var key = uiHexToRgb(el.chromaColor || '#00ff00');
var sim = el.chromaSimilarity !== undefined ? parseFloat(el.chromaSimilarity) : 0.4;
var smooth = el.chromaSmoothness !== undefined ? parseFloat(el.chromaSmoothness) : 0.08;
var spill = el.chromaSpill !== undefined ? parseFloat(el.chromaSpill) : 0;
var lastTime = -1, lastReady = -1;
function draw() {
if (canvas._stop || !canvas.isConnected) return;
var w = vid.videoWidth, h = vid.videoHeight;
// Only re-key when there is a new frame (a paused video stays cheap)
if (w && h && (vid.currentTime !== lastTime || vid.readyState !== lastReady)) {
lastTime = vid.currentTime; lastReady = vid.readyState;
if (canvas.width !== w || canvas.height !== h) { canvas.width = w; canvas.height = h; }
try {
ctx.clearRect(0, 0, w, h);
ctx.drawImage(vid, 0, 0, w, h);
var img = ctx.getImageData(0, 0, w, h), d = img.data;
var range = Math.max(0.0001, smooth);
for (var i = 0; i < d.length; i += 4) {
var dr = d[i] - key.r, dg = d[i + 1] - key.g, db = d[i + 2] - key.b;
var dist = Math.sqrt(dr * dr + dg * dg + db * db) / 441.6729;
if (dist < sim) {
d[i + 3] = 0;
} else if (dist < sim + range) {
var a = (dist - sim) / range;
d[i + 3] = d[i + 3] * a;
if (spill > 0) { // pull the key colour out of the soft edge
d[i] = d[i] + (d[i] - key.r) * spill * (1 - a);
d[i + 1] = d[i + 1] + (d[i + 1] - key.g) * spill * (1 - a);
d[i + 2] = d[i + 2] + (d[i + 2] - key.b) * spill * (1 - a);
}
}
}
ctx.putImageData(img, 0, 0);
} catch(err) {
// Tainted canvas: the browser refuses to read frames from a cross-origin or
// file:// video. Try once with CORS, then fall back to the untouched video.
if (!vid._chromaRetried && /^https?:/i.test(vid.src)) {
vid._chromaRetried = true;
var wasTime = vid.currentTime, wasPlaying = !vid.paused;
vid.crossOrigin = 'anonymous';
vid.load();
vid.addEventListener('loadeddata', function once() {
vid.removeEventListener('loadeddata', once);
try { vid.currentTime = wasTime; } catch(e) {}
if (wasPlaying) vid.play().catch(function(){});
});
lastTime = -1; lastReady = -1;
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(draw);
return;
}
console.warn('[Video] chroma key unavailable for this source (' + (err && err.message) + '). ' +
'Frames cannot be read from a file:// or cross-origin video — embed the video in the export, ' +
'or serve it from the same origin with CORS enabled.');
canvas._stop = true;
canvas.remove();
vid.style.removeProperty('display');
return;
}
}
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(draw);
else window.setTimeout(draw, 40);
}
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(draw);
else window.setTimeout(draw, 40);
}
function buildUIVideo(el) {
const vid = document.createElement('video');
vid.className = 'gui-video';
vid.src = el.src;
vid.playsInline = true;
vid.preload = 'auto';
if (el.videoPoster) vid.poster = el.videoPoster;
vid.muted = el.muted !== false;
vid.volume = Math.max(0, Math.min(1, el.volume !== undefined ? el.volume : 1));
vid.loop = el.loopMode === 'loop';
if (el.controls) vid.controls = true;
const vfit = el.videoFit || 'contain';
let vcss = 'display:block;border-radius:' + (el.borderRadius || 0) + ';';
if (vfit === 'fit-width') vcss += 'width:100%;height:auto;';
else if (vfit === 'fit-height') vcss += 'height:100%;width:auto;';
else vcss += 'width:100%;height:100%;object-fit:' + (vfit === 'stretch' ? 'fill' : vfit) + ';';
vid.style.cssText = vcss;
if (el.loopMode === 'count') {
let plays = 1;
vid.addEventListener('ended', () => {
if (plays < (parseInt(el.loopCount) || 1)) { plays++; vid.currentTime = 0; vid.play().catch(() => {}); }
});
}
if ((el.playMode || 'auto') === 'auto') {
vid.autoplay = true;
const p = vid.play(); if (p && p.catch) p.catch(() => {});
} else if (el.playMode === 'click' && !el.controls) {
vid.style.cursor = 'pointer';
vid.addEventListener('click', () => { if (vid.paused) vid.play().catch(() => {}); else vid.pause(); });
}
return vid;
}
function controlUIVideo(dom, cmd) {
const vid = dom ? dom.querySelector('video') : null;
if (!vid) return;
switch (cmd || 'play') {
case 'play': vid.play().catch(() => {}); break;
case 'pause': vid.pause(); break;
case 'toggle': vid.paused ? vid.play().catch(() => {}) : vid.pause(); break;
case 'stop': vid.pause(); try { vid.currentTime = 0; } catch(e) {} break;
case 'restart': try { vid.currentTime = 0; } catch(e) {} vid.play().catch(() => {}); break;
}
}
/* ── Element factory ── */
function createGameUIElement(el) {
const dom = document.createElement('div');
dom.className = 'gui-el';
dom.id = 'gui-' + el.id;
const anchor = el.anchor || 'center';
dom.style.left = el.x + '%';
dom.style.top = el.y + '%';
dom.style.transform = uiAnchorTransform(anchor);
dom.style.width = el.w > 0 ? el.w + '%' : 'auto';
dom.style.height = el.h > 0 ? el.h + '%' : 'auto';
dom.style.zIndex = el.zIndex || 10;
dom.style.opacity = el.opacity !== undefined ? el.opacity : 1;
if (!el.visible) dom.classList.add('gui-hidden');
const inner = document.createElement('div');
inner.className = 'gui-inner';
inner.style.width = '100%'; inner.style.height = '100%';
const styleProps = ['fontFamily','fontSize','fontWeight','color','background','borderRadius',
'padding','border','textAlign','textShadow','boxShadow','letterSpacing','lineHeight',
'textTransform','backdropFilter','objectFit','aspectRatio','minWidth','minHeight',
'cursor','display','justifyContent','alignItems','borderTop','overflow'];
styleProps.forEach(prop => {
if (el[prop] && el[prop] !== '0' && el[prop] !== '' && el[prop] !== 'none' && el[prop] !== 'normal') {
if (prop === 'fontSize') inner.style[prop] = el[prop] + (el.fontUnit || 'rem');
else inner.style[prop] = el[prop];
}
});
inner.style.overflow = (el.type === 'panel' || el.type === 'carousel') ? 'visible' : 'hidden';
if (el.type === 'hero') inner.style.position = 'relative';
if (el.type === 'panel') inner.style.position = 'relative';
// Custom CSS
if (el.customCSS) {
try {
el.customCSS.split(';').filter(p => p.includes(':')).forEach(p => {
const [prop, ...rest] = p.split(':');
const cssProp = prop.trim();
const val = rest.join(':').trim();
if (cssProp && val) {
const camel = cssProp.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
inner.style[camel] = val;
}
});
} catch(e) {}
}
// Animation sequence (entrance → attention → exit); legacy animateClass is treated as an entrance
if (uiAnimHasSequence(el) && el.animTrigger !== 'manual') {
window.setTimeout(function() { runUIAnimSequence(inner, el, 'all'); }, 0);
}
// ── Type-specific content ──
if (el.type === 'text' || el.type === 'button') {
inner.textContent = resolveBindingText(el.content, el.binding);
if (el.type === 'button') inner.style.cursor = el.cursor || 'pointer';
} else if (el.type === 'image') {
if (el.src && el.objectFit === 'background') {
inner.style.backgroundImage = 'url(' + el.src + ')';
inner.style.backgroundSize = el.bgSize || 'cover';
inner.style.backgroundPosition = el.bgPosition || 'center';
inner.style.backgroundRepeat = el.bgRepeat || 'no-repeat';
} else if (el.src) {
const img = document.createElement('img');
img.src = el.src;
img.style.cssText = 'width:100%;height:100%;object-fit:' + (el.objectFit || 'contain') + ';border-radius:' + (el.borderRadius || 0) + ';display:block;';
img.draggable = false;
inner.appendChild(img);
}
} else if (el.type === 'icon') {
const i = document.createElement('i');
i.className = 'bi ' + (el.iconClass || 'bi-circle');
i.style.fontSize = (el.fontSize || 2) + (el.fontUnit || 'rem');
i.style.color = el.color || '#ffffff';
if (el.textShadow) i.style.textShadow = el.textShadow;
inner.appendChild(i);
} else if (el.type === 'video') {
if (el.src) {
const vid = buildUIVideo(el);
if (el.videoFit === 'fit-width' || el.videoFit === 'fit-height') {
inner.style.display = 'flex'; inner.style.alignItems = 'center'; inner.style.justifyContent = 'center';
}
if (el.videoBlend && el.videoBlend !== 'none') vid.style.mixBlendMode = el.videoBlend;
inner.appendChild(vid);
if (el.chromaKey) setupUIVideoChroma(inner, vid, el);
}
} else if (el.type === 'carousel') {
inner.style.display = 'flex'; inner.style.alignItems = 'center'; inner.style.overflow = 'visible';
renderUICarousel(inner, el, true);
} else if (el.type === 'hero') {
inner.style.overflow = 'hidden'; inner.style.position = 'relative';
renderUIHero(inner, el, true);
} else if (el.type === 'iframe') {
renderUIFrame(inner, el, true);
} else if (el.type === 'model3d') {
inner.style.overflow = 'hidden';
renderUIModel(inner, el, true);
} else if (el.type === 'progressbar') {
const pct = Math.max(0, Math.min(100, el.barValue || 0));
const track = document.createElement('div');
track.className = 'gui-progressbar-track';
track.style.height = el.barHeight || '12px';
track.style.background = el.barBgColor || 'rgba(255,255,255,0.08)';
track.style.borderRadius = el.borderRadius || '6px';
if (el.showLabel && el.labelText) {
const label = document.createElement('span');
label.className = 'gui-progressbar-label';
label.textContent = el.labelText;
track.appendChild(label);
}
const fill = document.createElement('div');
fill.className = 'gui-progressbar-fill';
fill.style.width = pct + '%';
fill.style.background = el.barColor || '#00e5a0';
fill.style.borderRadius = el.borderRadius || '6px';
fill.dataset.binding = el.binding || '';
track.appendChild(fill);
inner.appendChild(track);
} else if (el.type === 'panel') {
const childBox = document.createElement('div');
childBox.className = 'gui-children';
childBox.style.cssText = 'position:absolute;inset:0;pointer-events:none;';
inner.appendChild(childBox);
// ── D-Pad ──
if (el.controlMode === 'dpad') {
const cBg = el.controlBg || 'rgba(255,255,255,0.08)';
const cArrow = el.controlKnob || 'rgba(255,255,255,0.7)';
const cActive = el.controlKnob || 'rgba(0,229,160,0.7)';
const dpad = document.createElement('div');
dpad.className = 'gui-dpad';
dpad.style.cssText = 'position:absolute;inset:5%;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:2px;user-select:none;touch-action:none;z-index:5;';
const btnStyle = 'display:flex;align-items:center;justify-content:center;width:36%;aspect-ratio:1;border-radius:8px;background:' + cBg + ';border:1px solid rgba(255,255,255,0.15);color:' + cArrow + ';font-size:1.2rem;cursor:pointer;user-select:none;-webkit-user-select:none;touch-action:none;transition:background 0.1s;';
const midRow = document.createElement('div');
midRow.style.cssText = 'display:flex;gap:2px;width:100%;justify-content:center;';
function makeDir(key, icon, parent) {
const btn = document.createElement('div');
btn.style.cssText = btnStyle;
btn.innerHTML = '<i class="bi ' + icon + '"></i>';
const press = () => { window.playerKeys[key] = true; btn.style.background = cActive; };
const release = () => { window.playerKeys[key] = false; btn.style.background = cBg; };
btn.addEventListener('pointerdown', e => { e.preventDefault(); e.stopPropagation(); press(); });
btn.addEventListener('pointerup', e => { e.stopPropagation(); release(); });
btn.addEventListener('pointerleave', release);
btn.addEventListener('pointercancel', release);
parent.appendChild(btn);
}
makeDir('w', 'bi-caret-up-fill', dpad);
makeDir('a', 'bi-caret-left-fill', midRow);
makeDir('d', 'bi-caret-right-fill', midRow);
dpad.appendChild(midRow);
makeDir('s', 'bi-caret-down-fill', dpad);
inner.appendChild(dpad);
}
// ── Joystick ──
if (el.controlMode === 'joystick') {
const cBg = el.controlBg || 'rgba(255,255,255,0.04)';
const cKnob = el.controlKnob || 'rgba(0,229,160,0.3)';
const cKnobActive = el.controlKnob || 'rgba(0,229,160,0.5)';
inner.style.position = 'relative'; inner.style.touchAction = 'none'; inner.style.userSelect = 'none';
const base = document.createElement('div');
base.style.cssText = 'position:absolute;inset:10%;border-radius:50%;background:' + cBg + ';border:1px solid rgba(255,255,255,0.1);';
const knob = document.createElement('div');
knob.style.cssText = 'position:absolute;width:40%;height:40%;border-radius:50%;background:' + cKnob + ';border:2px solid ' + cKnobActive + ';left:50%;top:50%;transform:translate(-50%,-50%);cursor:grab;touch-action:none;transition:background 0.1s;';
base.appendChild(knob); inner.appendChild(base);
let dragging = false, baseRect = null;
const deadzone = 0.2;
knob.addEventListener('pointerdown', e => { e.preventDefault(); e.stopPropagation(); dragging = true; baseRect = base.getBoundingClientRect(); knob.setPointerCapture(e.pointerId); knob.style.background = cKnobActive; });
const onMove = e => {
if (!dragging || !baseRect) return;
const cx = baseRect.left + baseRect.width/2, cy = baseRect.top + baseRect.height/2, maxR = baseRect.width/2;
let dx = (e.clientX - cx)/maxR, dy = (e.clientY - cy)/maxR;
const dist = Math.sqrt(dx*dx + dy*dy);
if (dist > 1) { dx /= dist; dy /= dist; }
knob.style.left = (50 + dx*40) + '%'; knob.style.top = (50 + dy*40) + '%';
window.playerKeys.w = dy < -deadzone; window.playerKeys.s = dy > deadzone;
window.playerKeys.a = dx < -deadzone; window.playerKeys.d = dx > deadzone;
window.playerKeys.shift = dist > 0.85;
};
const onRelease = () => {
if (!dragging) return;
dragging = false;
knob.style.left = '50%'; knob.style.top = '50%'; knob.style.background = cKnob;
window.playerKeys.w = window.playerKeys.s = window.playerKeys.a = window.playerKeys.d = window.playerKeys.shift = false;
};
window.addEventListener('pointermove', onMove);
window.addEventListener('pointerup', onRelease);
window.addEventListener('pointercancel', onRelease);
}
}
dom.appendChild(inner);
dom.style.pointerEvents = 'auto';
// ── Event-based action chains ──
const evts = el.events || {};
if (!el.events && el.actions) evts.click = el.actions; // legacy actions[]
if (el.onClick && (!evts.click || evts.click.length === 0)) { // legacy onClick
evts.click = [{ type:'callFunction', funcName: el.onClick, delay: 0 }];
}
const hasClick = evts.click && evts.click.length > 0;
const hasDbl = evts.dblclick && evts.dblclick.length > 0;
const hasDown = evts.mousedown && evts.mousedown.length > 0;
const hasUp = evts.mouseup && evts.mouseup.length > 0;
const hasHoverEvt = evts.hover && evts.hover.length > 0;
if (hasClick || hasDbl || hasDown || hasUp || hasHoverEvt) inner.style.cursor = inner.style.cursor || 'pointer';
if (hasClick) dom.addEventListener('click', () => executeUIActions(evts.click, el, evts.clickMode));
if (hasDbl) dom.addEventListener('dblclick', () => executeUIActions(evts.dblclick, el, evts.dblclickMode));
if (hasDown) dom.addEventListener('mousedown', () => executeUIActions(evts.mousedown, el, evts.mousedownMode));
if (hasUp) dom.addEventListener('mouseup', () => executeUIActions(evts.mouseup, el, evts.mouseupMode));
if (hasHoverEvt) dom.addEventListener('mouseenter', () => executeUIActions(evts.hover, el, evts.hoverMode));
// ── Hover / Active interaction states ──
const origBg = el.background || '';
const origColor = el.color || '';
const hasHover = el.hoverBackground || el.hoverColor || el.hoverScale || el.hoverSrc || el.hoverBoxShadow;
const hasActive = el.activeBackground || el.activeColor || el.activeScale || el.activeSrc || el.activeBoxShadow;
const defaultBtnHover = (el.type === 'button' && !hasHover);
inner.style.transition = 'all 0.15s ease';
inner.addEventListener('mouseenter', () => {
if (hasHover) {
if (el.hoverBackground) inner.style.background = el.hoverBackground;
if (el.hoverColor) inner.style.color = el.hoverColor;
if (el.hoverScale) dom.style.setProperty('--gui-scale', el.hoverScale);
if (el.hoverBoxShadow) inner.style.boxShadow = el.hoverBoxShadow;
if (el.hoverSrc && el.type === 'image') { const img = inner.querySelector('img'); if (img) img.src = el.hoverSrc; }
} else if (defaultBtnHover) inner.style.filter = 'brightness(1.15)';
});
inner.addEventListener('mouseleave', () => {
if (hasHover) {
inner.style.background = origBg;
inner.style.color = origColor;
inner.style.boxShadow = el.boxShadow || '';
dom.style.removeProperty('--gui-scale');
if (el.hoverSrc && el.type === 'image') { const img = inner.querySelector('img'); if (img) img.src = el.src; }
} else if (defaultBtnHover) inner.style.filter = '';
});
if (hasActive) {
inner.addEventListener('mousedown', () => {
if (el.activeBackground) inner.style.background = el.activeBackground;
if (el.activeColor) inner.style.color = el.activeColor;
if (el.activeScale) dom.style.setProperty('--gui-scale', el.activeScale);
if (el.activeBoxShadow) inner.style.boxShadow = el.activeBoxShadow;
if (el.activeSrc && el.type === 'image') { const img = inner.querySelector('img'); if (img) img.src = el.activeSrc; }
});
inner.addEventListener('mouseup', () => {
inner.style.background = hasHover && el.hoverBackground ? el.hoverBackground : origBg;
inner.style.color = hasHover && el.hoverColor ? el.hoverColor : origColor;
inner.style.boxShadow = (hasHover && el.hoverBoxShadow) ? el.hoverBoxShadow : (el.boxShadow || '');
dom.style.removeProperty('--gui-scale');
});
}
return dom;
}
/* ── Action executor ── */
function executeUIActions(actions, sourceEl, mode) {
const isChain = mode === 'chain';
let cumDelay = 0;
actions.forEach((act, i) => {
if (isChain) cumDelay += (i > 0 ? (act.delay || 0) : 0);
const delay = isChain ? cumDelay : (act.delay || 0);
setTimeout(() => {
const targetId = act.targetId || sourceEl.id;
const entry = gameUIElements[targetId];
if (!entry && !['callFunction','addAnimation','removeAnimation','playSound','loadScene','playMusic','stopMusic','setVolume','loadURL','showHTML'].includes(act.type)) return;
switch (act.type) {
case 'show':
if (entry) { entry.dom.classList.remove('gui-hidden'); entry.data.visible = true; }
break;
case 'hide':
if (entry) { entry.dom.classList.add('gui-hidden'); entry.data.visible = false; }
break;
case 'toggle':
if (entry) { const h = entry.dom.classList.toggle('gui-hidden'); entry.data.visible = !h; }
break;
case 'setText':
if (entry) {
entry.data.content = act.value || '';
const inner = entry.dom.querySelector('.gui-inner');
if (inner) inner.textContent = resolveBindingText(act.value || '', entry.data.binding);
}
break;
case 'setProgress':
if (entry) { const f = entry.dom.querySelector('.gui-progressbar-fill'); if (f) f.style.width = Math.max(0, Math.min(100, act.value || 0)) + '%'; }
break;
case 'videoControl':
if (entry) controlUIVideo(entry.dom, act.videoCmd);
break;
case 'carouselControl':
if (entry) controlUICarousel(entry.dom, act.carCmd, act.carIndex);
break;
case 'heroControl':
if (entry) controlUIHero(entry.dom, act.heroCmd, act.heroIndex);
break;
case 'frameControl':
if (entry) controlUIFrame(entry.dom, act.frameCmd, act.frameValue);
break;
case 'modelControl':
if (entry) controlUIModel(entry.dom, act.modelCmd, act.modelValue);
break;
case 'playAnimation':
if (entry) {
const aInner = entry.dom.querySelector('.gui-inner');
if (aInner) runUIAnimSequence(aInner, entry.data, act.animStage || 'all');
}
break;
case 'playSound':
if (act.soundUrl) { try { const a = new Audio(act.soundUrl); a.volume = act.volume !== undefined ? act.volume : 1; a.play().catch(() => {}); } catch(e) {} }
break;
case 'callFunction':
if (act.funcName && typeof window[act.funcName] === 'function') {
let extraArgs = [];
if (act.funcArgs) { try { extraArgs = Function('return [' + act.funcArgs + ']')(); } catch(e) {} }
try { window[act.funcName](...extraArgs, sourceEl, entry ? entry.data : null); }
catch(err) { console.warn('[UIPlayer] callFunction error:', err); }
}
break;
case 'addAnimation':
{
const aEntry = entry || gameUIElements[sourceEl.id];
const aInner = aEntry ? aEntry.dom.querySelector('.gui-inner') : null;
if (aInner && act.animClass) {
[...aInner.classList].filter(c => c.startsWith('animate__') && c !== 'animate__animated').forEach(c => aInner.classList.remove(c));
aInner.classList.remove('animate__animated');
void aInner.offsetWidth;
aInner.classList.add('animate__animated', act.animClass);
if (act.animDuration) aInner.style.setProperty('--animate-duration', act.animDuration);
else aInner.style.removeProperty('--animate-duration');
if (act.animDelay) aInner.style.animationDelay = act.animDelay;
else aInner.style.animationDelay = '';
if (act.animRepeat) aInner.style.animationIterationCount = act.animRepeat;
else aInner.style.animationIterationCount = '';
}
}
break;
case 'removeAnimation':
{
const rEntry = entry || gameUIElements[sourceEl.id];
const rInner = rEntry ? rEntry.dom.querySelector('.gui-inner') : null;
if (rInner) {
if (act.animClass) {
rInner.classList.remove(act.animClass);
const has = [...rInner.classList].some(c => c.startsWith('animate__') && c !== 'animate__animated');
if (!has) rInner.classList.remove('animate__animated');
} else {
[...rInner.classList].filter(c => c.startsWith('animate__')).forEach(c => rInner.classList.remove(c));
}
rInner.style.removeProperty('--animate-duration');
rInner.style.animationDelay = '';
rInner.style.animationIterationCount = '';
}
}
break;
case 'loadScene':
{
const params = {};
if (act.sceneParams) { try { Object.assign(params, typeof act.sceneParams === 'string' ? JSON.parse(act.sceneParams) : act.sceneParams); } catch(e) {} }
GameAPI.loadScene(act.levelFile || '', act.uiFile || '', params);
}
break;
case 'playMusic':
if (act.musicSrc) playMusic(act.musicSrc, act.musicLoop !== false);
break;
case 'stopMusic':
stopMusic();
break;
case 'setVolume':
setMusicVolume(act.volume !== undefined ? act.volume : 0.5);
break;
case 'loadURL':
if (act.url) {
const tgt = act.urlTarget || 'newTab';
if (tgt === 'same') window.location.href = act.url;
else if (tgt === 'newTab') window.open(act.url, '_blank');
else if (tgt === 'newWindow') window.open(act.url, '_blank', 'width=800,height=600');
else if (tgt === 'iframe') showIframeOverlay(act.url, act.iframeBackIcon, act.iframeCloseSound);
}
break;
case 'showHTML':
if (act.htmlContent) {
// '3d' billboard mode has no 3D scene here — falls back to popup
showHTMLPopup(act.htmlContent, { bgColor: act.htmlBgColor, textColor: act.htmlTextColor });
}
break;
}
}, delay);
});
}
/* ── Build the whole UI from a .ui.json project ── */
function loadGameUI(uiData) {
// Clean previous UI
const existing = document.getElementById('game-ui-overlay');
if (existing) existing.innerHTML = '';
const existingStyle = document.getElementById('game-ui-styles');
if (existingStyle) existingStyle.remove();
const existingScript = document.getElementById('game-ui-custom-code');
if (existingScript) existingScript.remove();
const existingFonts = document.getElementById('game-ui-fonts');
if (existingFonts) existingFonts.remove();
gameUIElements = {};
gameUIData = uiData;
const overlay = document.getElementById('game-ui-overlay');
// Load any extra Google fonts used by the project
const fonts = new Set();
(uiData.elements || []).forEach(el => {
if (el.fontFamily) {
const m = el.fontFamily.match(/['"]([^'"]+)['"]/);
if (m && !['Rajdhani','Orbitron','Courier New','Arial','Trebuchet MS'].includes(m[1])) fonts.add(m[1]);
}
});
if (fonts.size > 0) {
const link = document.createElement('link');
link.id = 'game-ui-fonts';
link.rel = 'stylesheet';
link.href = 'https://fonts.googleapis.com/css2?' + [...fonts].map(f => 'family=' + f.replace(/ /g, '+')).join('&') + '&display=swap';
document.head.appendChild(link);
}
// Page + project background
document.getElementById('page-bg').style.background = uiPlayerSettings.pageBackground || '#000';
if (uiData.bgImage) {
document.getElementById('page-bg').style.backgroundImage = 'url(' + uiData.bgImage + ')';
document.getElementById('page-bg').style.backgroundSize = 'cover';
document.getElementById('page-bg').style.backgroundPosition = 'center';
} else {
document.getElementById('page-bg').style.backgroundImage = 'none';
}
// ── Responsive override CSS ──
const style = document.createElement('style');
style.id = 'game-ui-styles';
function genOverrideCss(ov, elId, fontUnit) {
let css = '';
if (!ov || typeof ov !== 'object') return css;
if (ov.visible === false) css += ' #gui-' + elId + ' { display:none !important; }\n';
if (ov.x !== undefined) css += ' #gui-' + elId + ' { left:' + ov.x + '% !important; }\n';
if (ov.y !== undefined) css += ' #gui-' + elId + ' { top:' + ov.y + '% !important; }\n';
if (ov.w !== undefined) css += ' #gui-' + elId + ' { width:' + (ov.w > 0 ? ov.w + '%' : 'auto') + ' !important; }\n';
if (ov.h !== undefined) css += ' #gui-' + elId + ' { height:' + (ov.h > 0 ? ov.h + '%' : 'auto') + ' !important; }\n';
if (ov.anchor) css += ' #gui-' + elId + ' { transform:' + uiAnchorTransform(ov.anchor) + ' !important; }\n';
if (ov.opacity !== undefined) css += ' #gui-' + elId + ' { opacity:' + ov.opacity + ' !important; }\n';
const innerProps = { fontSize:'font-size', fontWeight:'font-weight', padding:'padding', background:'background', borderRadius:'border-radius', border:'border', color:'color', textAlign:'text-align', boxShadow:'box-shadow', letterSpacing:'letter-spacing' };
for (const [jsProp, cssProp] of Object.entries(innerProps)) {
if (ov[jsProp] !== undefined) {
const val = jsProp === 'fontSize' ? ov[jsProp] + (ov.fontUnit || fontUnit || 'rem') : ov[jsProp];
css += ' #gui-' + elId + ' .gui-inner { ' + cssProp + ':' + val + ' !important; }\n';
}
}
return css;
}
const deviceQueries = {
tablet: '@media (max-width:1024px) and (min-width:481px)',
phone: '@media (max-width:480px) and (orientation:portrait)',
'phone-land': '@media (max-width:768px) and (orientation:landscape)',
};
const showOnQueries = {
desktop: '@media (min-width:1025px)',
tablet: '@media (max-width:1024px) and (min-width:481px)',
phone: '@media (max-width:480px) and (orientation:portrait)',
'phone-land': '@media (max-width:768px) and (orientation:landscape)',
};
let responsiveCss = '';
function collectCss(el) {
if (el.responsive && el.overrides) {
for (const [dev, query] of Object.entries(deviceQueries)) {
const css = genOverrideCss(el.overrides[dev], el.id, el.fontUnit);
if (css) responsiveCss += query + ' {\n' + css + '}\n';
}
}
if (el.mobileHidden) responsiveCss += '@media (max-width:768px) { #gui-' + el.id + ' { display:none !important; } }\n';
if (el.showOn && el.showOn.length > 0) {
for (const [dev, query] of Object.entries(showOnQueries)) {
if (!el.showOn.includes(dev)) responsiveCss += query + ' { #gui-' + el.id + ' { display:none !important; } }\n';
}
}
}
// ── Render parents first, then children ──
const parents = (uiData.elements || []).filter(e => !e.parentId && !e.disabled);
const children = (uiData.elements || []).filter(e => e.parentId && !e.disabled);
parents.forEach(el => {
const dom = createGameUIElement(el);
overlay.appendChild(dom);
gameUIElements[el.id] = { dom, data: el };
collectCss(el);
});
children.forEach(el => {
const parentEntry = gameUIElements[el.parentId];
const container = parentEntry ? (parentEntry.dom.querySelector('.gui-children') || parentEntry.dom.querySelector('.gui-inner .gui-children') || parentEntry.dom) : overlay;
const dom = createGameUIElement(el);
container.appendChild(dom);
dom.style.pointerEvents = 'auto';
gameUIElements[el.id] = { dom, data: el };
collectCss(el);
});
style.textContent = responsiveCss;
document.head.appendChild(style);
// ── Inject custom code after UI exists, then call onReady() ──
if (uiData.customCode) {
const script = document.createElement('script');
script.id = 'game-ui-custom-code';
script.textContent = uiData.customCode + '\nif (typeof onReady === "function") onReady();';
document.body.appendChild(script);
}
// Match the builder's design resolution so px/rem sizes keep their proportions
applyUIStage(uiData);
watchUIStage(function() { return gameUIData; });
console.log('[UIPlayer] Loaded "' + (uiData.name || 'UI') + '" — ' + (uiData.elements || []).length + ' elements');
}
/* ═══════════════════════════════════════════
GAME API (subset — no 3D scene here)
═══════════════════════════════════════════ */
window._gameGlobals = window._gameGlobals || {};
const GameAPI = {
globals: window._gameGlobals,
setGlobal(name, value) { window._gameGlobals[name] = value; },
getGlobal(name) { return window._gameGlobals[name]; },
// UI
setUIValue, getUIValue, setUIVisible, setUIContent, setUIStyle, loadGameUI,
// Video
getUIVideo(elId) { const e = gameUIElements[elId]; return e ? e.dom.querySelector('video') : null; },
playVideo(elId) { const v = this.getUIVideo(elId); if (v) v.play().catch(() => {}); },
pauseVideo(elId) { const v = this.getUIVideo(elId); if (v) v.pause(); },
toggleVideo(elId) { const v = this.getUIVideo(elId); if (v) { v.paused ? v.play().catch(() => {}) : v.pause(); } },
stopVideo(elId) { const v = this.getUIVideo(elId); if (v) { v.pause(); try { v.currentTime = 0; } catch(e) {} } },
restartVideo(elId) { const v = this.getUIVideo(elId); if (v) { try { v.currentTime = 0; } catch(e) {} v.play().catch(() => {}); } },
setVideoSrc(elId, src) { const v = this.getUIVideo(elId); if (v) { v.src = src; v.load(); } },
// ── Carousel control ──
carouselNext(elId) { const e = gameUIElements[elId]; if (e) controlUICarousel(e.dom, 'next'); },
carouselPrev(elId) { const e = gameUIElements[elId]; if (e) controlUICarousel(e.dom, 'prev'); },
carouselGoTo(elId, i) { const e = gameUIElements[elId]; if (e) controlUICarousel(e.dom, 'goTo', i); },
carouselPlay(elId) { const e = gameUIElements[elId]; if (e) controlUICarousel(e.dom, 'play'); },
carouselStop(elId) { const e = gameUIElements[elId]; if (e) controlUICarousel(e.dom, 'stop'); },
carouselRefresh(elId) { const e = gameUIElements[elId]; if (e) controlUICarousel(e.dom, 'refresh'); },
// ── Hero section ──
heroNext(elId) { const e = gameUIElements[elId]; if (e) controlUIHero(e.dom, 'next'); },
heroPrev(elId) { const e = gameUIElements[elId]; if (e) controlUIHero(e.dom, 'prev'); },
heroGoTo(elId, i) { const e = gameUIElements[elId]; if (e) controlUIHero(e.dom, 'goTo', i); },
heroPlay(elId) { const e = gameUIElements[elId]; if (e) controlUIHero(e.dom, 'play'); },
heroStop(elId) { const e = gameUIElements[elId]; if (e) controlUIHero(e.dom, 'stop'); },
// ── Embedded pages ──
reloadFrame(elId) { const e = gameUIElements[elId]; if (e) controlUIFrame(e.dom, 'reload'); },
setFrameSrc(elId, url) { const e = gameUIElements[elId]; if (e) controlUIFrame(e.dom, 'setSrc', url); },
getFrame(elId) { const e = gameUIElements[elId]; return e ? e.dom.querySelector('iframe') : null; },
// ── 3D models ──
spinModel(elId, on) { const e = gameUIElements[elId]; if (e) controlUIModel(e.dom, on === false ? 'stop' : 'spin'); },
resetModel(elId) { const e = gameUIElements[elId]; if (e) controlUIModel(e.dom, 'reset'); },
setModelSrc(elId, url) { const e = gameUIElements[elId]; if (e) controlUIModel(e.dom, 'setModel', url); },
getModelApi(elId) {
const e = gameUIElements[elId];
const host = e ? e.dom.querySelector('.ui-model') : null;
return host ? host._modelApi : null;
},
setHeroSlides(elId, slides) {
const e = gameUIElements[elId];
if (!e) return;
e.data.heroSlides = slides || [];
const inner = e.dom.querySelector('.gui-inner');
if (inner) renderUIHero(inner, e.data, true);
},
// ── Animation sequence ──
playAnimation(elId, stage) {
const e = gameUIElements[elId];
if (!e) return;
const inner = e.dom.querySelector('.gui-inner');
if (inner) runUIAnimSequence(inner, e.data, stage || 'all');
},
stopAnimation(elId) { this.playAnimation(elId, 'stop'); },
setCarouselItems(elId, items) {
const e = gameUIElements[elId];
if (!e) return;
e.data.carouselItems = items || [];
const inner = e.dom.querySelector('.gui-inner');
if (inner) renderUICarousel(inner, e.data, true);
},
// Audio / Music
playAudio(src, vol) {
try { const a = new Audio(src); if (vol !== undefined) a.volume = vol; a.play().catch(e => console.warn('Audio:', e)); return a; }
catch(e) { console.warn('Audio error:', e); return null; }
},
playMusic, stopMusic, pauseMusic, resumeMusic,
setMusicVolume,
getMusicVolume() { return masterVolume; },
setMusicMuted(m) { musicMuted = !!m; if (musicAudio) musicAudio.volume = musicMuted ? 0 : masterVolume; },
isMusicMuted() { return musicMuted; },
// Toast / popups
showToast,
showHTMLPopup,
showIframeOverlay,
// Key simulation (for virtual d-pad / joystick or external listeners)
pressKey(key) {
const k = key.toLowerCase();
if (k in window.playerKeys) window.playerKeys[k] = true;
window.dispatchEvent(new KeyboardEvent('keydown', { key: k, bubbles: true }));
},
releaseKey(key) {
const k = key.toLowerCase();
if (k in window.playerKeys) window.playerKeys[k] = false;
window.dispatchEvent(new KeyboardEvent('keyup', { key: k, bubbles: true }));
},
simulateKey(key, ms) { this.pressKey(key); setTimeout(() => this.releaseKey(key), ms || 200); },
releaseAllKeys() { Object.keys(window.playerKeys).forEach(k => window.playerKeys[k] = false); },
// Scene params (persist across UI transitions)
sceneParams: {},
getSceneParam(name) { return GameAPI.sceneParams[name]; },
setSceneParam(name, value) { GameAPI.sceneParams[name] = value; },
/**
* UI scene transitions.
* Same signature as game-level: loadScene(levelSource, uiSource, params).
* In UI Player there is no 3D scene, so:
* - uiSource (path/URL string or .ui.json object) → loads that UI
* - levelSource pointing to a .ui.json (or a game-ui object) is also accepted
* - a real 3D .level.json cannot run here → a toast explains to use game-level.html
*/
async loadScene(levelSource, uiSource, params) {
if (params && typeof params === 'object') Object.assign(GameAPI.sceneParams, params);
// Decide what to load as UI
let src = uiSource || null;
if (!src && levelSource) {
const looksLikeUI = (typeof levelSource === 'object' && levelSource && levelSource.type === 'game-ui')
|| (typeof levelSource === 'string' && /\.ui\.json(\?|#|$)/i.test(levelSource));
if (looksLikeUI) src = levelSource;
else {
showToast('3D levels need game-level.html — UI Player only runs .ui.json', 'warn');
return;
}
}
if (!src) return;
// Transition overlay
const s = uiPlayerSettings;
let ov = document.getElementById('scene-transition-overlay');
if (!ov) {
ov = document.createElement('div');
ov.id = 'scene-transition-overlay';
ov.style.cssText = 'position:fixed;inset:0;z-index:10000;background:' + (s.loadingBgColor || '#05070e') + 'f8;display:flex;flex-direction:column;align-items:center;justify-content:center;font-family:' + (s.loadingFontFamily || 'Rajdhani,sans-serif') + ';transition:opacity 0.4s;';
ov.innerHTML = '<div style="font-family:Orbitron,sans-serif;font-size:1.1rem;letter-spacing:3px;color:' + (s.loadingAccentColor || '#00e5a0') + ';margin-bottom:14px;text-transform:uppercase;">Loading…</div>'
+ '<div style="width:240px;height:5px;background:rgba(255,255,255,0.08);border-radius:3px;overflow:hidden;"><div id="scene-transition-bar" style="height:100%;width:15%;background:' + (s.loadingAccentColor || '#00e5a0') + ';transition:width 0.3s;"></div></div>';
document.body.appendChild(ov);
}
ov.style.opacity = '1';
const bar = document.getElementById('scene-transition-bar');
try {
let uiData;
if (typeof src === 'object') uiData = src;
else {
const resp = await fetch(src);
if (!resp.ok) throw new Error('Failed to load UI: ' + resp.statusText);
uiData = await resp.json();
}
if (bar) bar.style.width = '70%';
stopMusic();
loadGameUI(uiData);
if (bar) bar.style.width = '100%';
} catch(err) {
console.error('[UIPlayer] loadScene error:', err);
showToast('Load failed: ' + err.message, 'error');
}
setTimeout(() => { ov.style.opacity = '0'; setTimeout(() => ov.remove(), 450); }, 250);
},
};
window.GameAPI = GameAPI;
/* ═══════════════════════════════════════════
LOADING SCREEN / STARTUP
═══════════════════════════════════════════ */
let pendingUIData = null;
const dropUI = document.getElementById('drop-zone-ui');
const fileUI = document.getElementById('file-input-ui');
const btnStart = document.getElementById('btn-start');
const loadingOverlay = document.getElementById('loading-overlay');
function stageFile(file) {
if (!file) return;
const reader = new FileReader();
reader.onload = ev => {
try {
const data = JSON.parse(ev.target.result);
if (data.type !== 'game-ui' && !file.name.endsWith('.ui.json') && !Array.isArray(data.elements)) {
throw new Error('Not a UI Builder project file');
}
pendingUIData = data;
dropUI.classList.add('loaded');
document.getElementById('ui-filename').textContent = file.name;
btnStart.classList.add('visible');
} catch(err) {
const statusEl = document.getElementById('loading-status');
statusEl.style.display = 'block';
statusEl.textContent = 'Invalid file: ' + err.message;
statusEl.style.color = '#ff6b6b';
}
};
reader.readAsText(file);
}
function startUI(uiData) {
loadGameUI(uiData);
loadingOverlay.classList.add('fade-out');
setTimeout(() => { loadingOverlay.style.display = 'none'; }, 550);
}
btnStart.addEventListener('click', () => { if (pendingUIData) startUI(pendingUIData); });
fileUI.addEventListener('change', e => { stageFile(e.target.files[0]); e.target.value = ''; });
dropUI.addEventListener('dragover', e => { e.preventDefault(); dropUI.classList.add('dragover'); });
dropUI.addEventListener('dragleave', () => dropUI.classList.remove('dragover'));
dropUI.addEventListener('drop', e => {
e.preventDefault(); dropUI.classList.remove('dragover');
if (e.dataTransfer.files[0]) stageFile(e.dataTransfer.files[0]);
});
document.body.addEventListener('dragover', e => e.preventDefault());
document.body.addEventListener('drop', e => {
if (e.target.closest('#drop-zone-ui')) return;
e.preventDefault();
// Only accept file drops on the start screen
if (loadingOverlay.style.display !== 'none' && e.dataTransfer.files[0]) stageFile(e.dataTransfer.files[0]);
});
/* ── Auto-load from ui-player.json / ?ui= query param ── */
(async function autoLoadCheck() {
await loadUIPlayerSettings();
// Apply loading-screen customization
const s = uiPlayerSettings;
loadingOverlay.style.background = s.loadingBgColor || '#05070e';
document.getElementById('load-title').textContent = s.loadingTitle || 'UI Player';
document.getElementById('load-title').style.color = s.loadingAccentColor || '#00e5a0';
document.getElementById('load-subtitle').textContent = s.loadingSubtitle || 'UI Builder Runtime';
document.getElementById('load-subtitle').style.color = s.loadingTextColor || '#a0b0c0';
document.getElementById('page-bg').style.background = s.pageBackground || '#000';
// Embedded standalone mode support
if (window.__EMBEDDED_UI__) {
startUI(window.__EMBEDDED_UI__);
return;
}
// ?ui=path overrides settings uiFile
const urlParams = new URLSearchParams(location.search);
const uiFile = urlParams.get('ui') || s.uiFile;
if (!uiFile) return; // stay on drop-zone screen
// Auto-load mode: replace drop card with clean loading screen
const card = document.getElementById('load-card');
card.style.display = 'none';
const statusEl = document.getElementById('loading-status');
statusEl.style.display = 'block';
statusEl.style.color = s.loadingTextColor || '#8a90a4';
statusEl.textContent = s.loadingMessage || 'Loading…';
if (s.loadingLogo) {
const logo = document.createElement('img');
logo.src = s.loadingLogo;
logo.style.cssText = 'max-width:200px;max-height:120px;margin-bottom:16px;object-fit:contain;order:-1;';
loadingOverlay.insertBefore(logo, loadingOverlay.firstChild);
}
let bar = null;
if (s.loadingShowProgress !== false) {
document.getElementById('load-progress').style.display = 'block';
bar = document.getElementById('load-progress-bar');
}
try {
if (bar) bar.style.width = '25%';
const resp = await fetch(uiFile);
if (!resp.ok) throw new Error('Failed to load UI: ' + resp.statusText);
const uiData = await resp.json();
if (bar) bar.style.width = '80%';
startUI(uiData);
} catch(err) {
console.error('[UIPlayer] Auto-load error:', err);
statusEl.textContent = 'Error: ' + err.message;
statusEl.style.color = '#ff6b6b';
const retry = document.createElement('button');
retry.textContent = 'Retry';
retry.style.cssText = 'margin-top:16px;padding:8px 24px;background:rgba(255,255,255,0.06);border:1px solid rgba(255,255,255,0.15);border-radius:8px;color:#aaa;cursor:pointer;font-family:Rajdhani,sans-serif;font-size:0.8rem;';
retry.onclick = () => location.reload();
loadingOverlay.appendChild(retry);
// Bring the drop card back as a fallback
card.style.display = '';
}
})();
/* ═══════════════════════════════════════════
★ CUSTOM CODE HOOKS ★
Your project's Code-tab functions are injected automatically
when the UI loads, and onReady() runs after all elements exist.
── GAME UI API ──
GameAPI.setUIValue('score', 100) → update bound elements showing {score}
GameAPI.getUIValue('score') → read a value
GameAPI.setUIVisible('el_1', false) → hide an element
GameAPI.setUIContent('el_2', 'Hello') → change text
GameAPI.setUIStyle('el_3', 'color', '#f00')
GameAPI.loadGameUI(uiJsonData) → swap in another UI object
── VIDEO ──
GameAPI.playVideo('el_5') / pauseVideo / toggleVideo / stopVideo / restartVideo
GameAPI.setVideoSrc('el_5', './videos/clip2.mp4')
GameAPI.getUIVideo('el_5') → the raw <video> element
── SCENE / SCREEN TRANSITIONS ──
GameAPI.loadScene(null, 'saves/menu.ui.json', {from:'level1'})
GameAPI.getSceneParam('from')
(3D .level.json files need game-level.html)
── MUSIC / AUDIO ──
GameAPI.playMusic('./audio/theme.mp3', true)
GameAPI.stopMusic() / setMusicVolume(0.5) / playAudio('sfx.mp3')
── MISC ──
GameAPI.showToast('Saved!')
GameAPI.showHTMLPopup('<h3>Hi</h3>')
GameAPI.showIframeOverlay('https://example.com')
GameAPI.pressKey('w') / releaseKey / simulateKey — writes window.playerKeys
═══════════════════════════════════════════ */
</script>
</body>
</html>