player/src/engine/LoadingScreenOverlay.js
min a5e1558c2d
All checks were successful
CI / Lint (push) Successful in 54s
CI / Build (push) Successful in 1m30s
CI / Secret scan (push) Successful in 20s
CI / PR size check (push) Has been skipped
CI / Deploy to S1 + S2 (push) Successful in 2m56s
feat(player): ������������� �� ������� (Lua + JS-API + Roblox-������ + LoadingOverlay)
2026-06-09 22:01:51 +00:00

558 lines
28 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* LoadingScreenOverlay — внутриигровой экран загрузки (задача 12).
*
* Программный mid-game transition: чёрный фон (fadeIn/Out), картинка-превью
* (cover) по центру, прогресс-бар (жёлтый по серому) + процент, спиннер
* «ЗАГРУЗКА» справа-снизу (CSS keyframes), кнопка «ПРОПУСТИТЬ» по центру-снизу
* (появляется через 0.5с — анти-accidental), логотип игры слева-снизу.
*
* Вызывается из скрипта через game.loading.show(opts) / game.loading.transition(opts).
* Покрывает и кейс задачи 05 (начальный экран при входе).
*
* Реализация — лёгкий DOM-оверлей поверх canvas (как ShopInventoryUi), а не
* Babylon-GUI: фиксированный layout с прогресс-баром/спиннером/кнопкой на HTML
* делается быстрее и доступнее. Класс самодостаточен: хранит state, рисует DOM,
* имеет tick(dt) для fade-фаз и авто-duration (в отличие от ShopInventoryUi,
* которому tick не нужен).
*
* Один активный экран одновременно: повторный show() мгновенно закрывает
* предыдущий (как ModalManager) — нет утечки overlay'ев при нескольких
* transition подряд.
*
* Фича-парность: идентичный модуль в rublox-player/src/engine/.
*/
const EASE_OUT = (t) => 1 - Math.pow(1 - t, 3);
// CSS спиннера вставляем один раз в <head> (keyframes нельзя инлайнить в style).
let _spinCssInjected = false;
function injectSpinnerCss() {
if (_spinCssInjected) return;
_spinCssInjected = true;
try {
const style = document.createElement('style');
style.id = 'kbn-loading-spin-css';
style.textContent =
'@keyframes kbn-ls-spin{from{transform:rotate(0)}to{transform:rotate(360deg)}}' +
'.kbn-ls-spinner{animation:kbn-ls-spin 0.9s linear infinite}' +
// Ken Burns — медленный pan+zoom фона (задача 05).
'@keyframes kbn-ls-kenburns{' +
'0%{transform:scale(1.0) translate3d(0,0,0)}' +
'50%{transform:scale(1.10) translate3d(-3%,-2%,0)}' +
'100%{transform:scale(1.0) translate3d(-6%,0,0)}}' +
'.kbn-ls-kenburns{animation:kbn-ls-kenburns 24s ease-in-out infinite}' +
// particles — медленно всплывающие искры.
'@keyframes kbn-ls-rise{' +
'0%{transform:translateY(0) scale(1);opacity:0}' +
'10%{opacity:0.9}' +
'90%{opacity:0.7}' +
'100%{transform:translateY(-110vh) scale(1.4);opacity:0}}' +
'.kbn-ls-particle{animation:kbn-ls-rise linear infinite}' +
// лёгкий «дыхательный» glow карточки-превью.
'@keyframes kbn-ls-cardglow{' +
'0%,100%{box-shadow:0 18px 60px rgba(0,0,0,0.6),0 0 0 rgba(120,160,255,0)}' +
'50%{box-shadow:0 18px 60px rgba(0,0,0,0.6),0 0 40px rgba(120,160,255,0.35)}}' +
'.kbn-ls-cardglow{animation:kbn-ls-cardglow 4s ease-in-out infinite}' +
'@media (prefers-reduced-motion:reduce){.kbn-ls-spinner,.kbn-ls-kenburns,.kbn-ls-particle,.kbn-ls-cardglow{animation:none}}';
document.head.appendChild(style);
} catch { /* ignore */ }
}
export class LoadingScreenOverlay {
constructor(scene3d) {
this.s = scene3d;
this.root = null;
this._st = null; // state активного экрана или null
this._idSeq = 0;
// Мост наружу (GameRuntime подписывает) — id-based колбэки.
this._onSkipCb = null; // (id) => void
this._onCompleteCb = null; // (id) => void
this._onHideCb = null; // () => void — задача 05 (game.loading.onHide)
this._parallaxHandler = null;
// DOM-ссылки активного экрана:
this._els = null;
}
/** Колбэки-мост к GameRuntime (он рассылает globalEvent в sandbox'ы). */
setBridge(onSkip, onComplete, onHide) {
this._onSkipCb = onSkip;
this._onCompleteCb = onComplete;
if (onHide) this._onHideCb = onHide;
}
/** Конфиг проекта (логотип/акцент по умолчанию) из BabylonScene._loadingConfig. */
_cfg() {
return (this.s && this.s._loadingConfig) || {};
}
/**
* Показать экран загрузки. Возвращает числовой id (для матчинга команд).
* opts — см. 12_ingame_loading.md §2.2.
*/
show(opts) {
injectSpinnerCss();
opts = opts && typeof opts === 'object' ? opts : {};
// Один активный — мгновенно убрать предыдущий.
if (this._st) this._instantClose();
const cfg = this._cfg();
const accent = opts.progressColor || cfg.accentColor || '#ffc020';
const st = {
id: ++this._idSeq,
// Фон
bgColor: opts.bgColor || '#000',
bgOpacity: opts.bgOpacity != null ? Number(opts.bgOpacity) : 1,
fadeIn: opts.fadeIn != null ? Number(opts.fadeIn) : 0.3,
fadeOut: opts.fadeOut != null ? Number(opts.fadeOut) : 0.3,
// Прогресс
progressBar: opts.progressBar !== false,
progressColor: accent,
progressBgColor: opts.progressBgColor || '#444',
percentText: opts.percentText !== false,
progress: Math.max(0, Math.min(1, Number(opts.initialProgress) || 0)),
duration: Number.isFinite(opts.duration) && opts.duration > 0 ? Number(opts.duration) : null,
manualProgress: false,
// Спиннер
spinner: opts.spinner != null ? !!opts.spinner : (cfg.defaultSpinner !== false),
spinnerText: opts.spinnerText != null ? String(opts.spinnerText) : 'ЗАГРУЗКА',
// Кнопка Пропустить
skipButton: opts.skipButton != null ? !!opts.skipButton : !!cfg.defaultSkipButton,
skipButtonText: opts.skipButtonText != null ? String(opts.skipButtonText) : 'ПРОПУСТИТЬ',
skipButtonColor: opts.skipButtonColor || accent,
skipShown: false,
// Логотип
logo: opts.logo || cfg.logo || (this.s && this.s._projectThumbnail) || null,
logoCornerRadius: opts.logoCornerRadius != null ? Number(opts.logoCornerRadius) : 12,
// Текст под картинкой
text: opts.text != null ? String(opts.text) : '',
// --- Задача 05: Ken-Burns фон + карточка места ---
// style: 'ken-burns' | 'static' | 'parallax' | 'particles'
style: opts.style || cfg.style || 'ken-burns',
// фоновое размытое изображение (на весь экран); резолвится в _resolveCover.
background: opts.background != null ? opts.background : (cfg.background || null),
// карточка-витрина по центру (название места + автор), как в Roblox.
placeName: opts.placeName != null ? String(opts.placeName) : (cfg.placeName || ''),
studioName: opts.studioName != null ? String(opts.studioName) : (cfg.studioName || ''),
verified: opts.verified != null ? !!opts.verified : !!cfg.verified,
// Поведение
blockInput: opts.blockInput !== false,
pauseSimulation: opts.pauseSimulation !== false,
// Жизненный цикл
phase: 'in', // 'in' | 'visible' | 'out'
alpha: 0,
elapsed: 0, // время с момента полного появления (для duration/skip)
fadeT: 0,
completed: false, // onComplete уже вызывался
};
this._st = st;
this._build(st, opts.cover);
// Блок ввода + пауза симуляции.
if (st.blockInput) { try { this.s.player?.setInputBlocked?.(true); } catch { /* ignore */ } }
if (st.pauseSimulation && this.s.gameRuntime) { try { this.s.gameRuntime.paused = true; } catch { /* ignore */ } }
return st.id;
}
/** Резолв cover в URL/dataURL. */
_resolveCover(cover) {
if (!cover) return null;
if (typeof cover === 'string') {
// asset:xxx → пробуем через AssetManager, иначе как прямой URL.
try {
const r = this.s.assetManager?.resolveUrl?.(cover);
if (r) return r;
} catch { /* ignore */ }
return cover;
}
if (typeof cover === 'object') {
if (cover.sceneSnapshot) {
try {
const canvas = this.s.engine?.getRenderingCanvas?.();
if (canvas) return canvas.toDataURL('image/jpeg', 0.72);
} catch { /* ignore */ }
return null;
}
if (cover.url) return cover.url;
}
return null;
}
_build(st, cover) {
const parent = (this.s.canvas && this.s.canvas.parentElement) || document.body;
try { if (getComputedStyle(parent).position === 'static') parent.style.position = 'relative'; } catch { /* ignore */ }
const root = document.createElement('div');
root.className = 'kbn-loading';
root.style.cssText =
'position:absolute;inset:0;z-index:60;overflow:hidden;' +
'display:flex;align-items:center;justify-content:center;' +
'opacity:0;transition:none;font-family:system-ui,"Segoe UI",sans-serif;' +
`background:${st.bgColor};`;
// фон с настраиваемой непрозрачностью — отдельный слой, чтобы контент был непрозрачным
// (используем opacity всего root для fade, а bgOpacity — через rgba фон):
root.style.background = this._bgRgba(st.bgColor, st.bgOpacity);
// --- Фоновый слой (Ken Burns / parallax / static) ---
// Размытое изображение игры на весь экран. Отдельный div под контентом,
// чтобы blur/анимация не трогали карточку и текст.
const bgUrl = this._resolveCover(st.background);
const bgLayer = document.createElement('div');
let bgClass = '';
if (bgUrl) {
if (st.style === 'ken-burns') bgClass = 'kbn-ls-kenburns';
bgLayer.className = bgClass;
bgLayer.style.cssText =
'position:absolute;inset:-8%;z-index:0;background-size:cover;background-position:center;' +
'filter:blur(8px) brightness(0.55);will-change:transform;' +
`background-image:url("${bgUrl}");`;
// parallax — лёгкий сдвиг по мыши (2 слоя имитируем одним + transform).
if (st.style === 'parallax') {
bgLayer.style.transition = 'transform 0.25s ease-out';
this._parallaxHandler = (e) => {
const cx = (e.clientX / window.innerWidth - 0.5) * 28;
const cy = (e.clientY / window.innerHeight - 0.5) * 18;
bgLayer.style.transform = `translate3d(${-cx}px,${-cy}px,0) scale(1.06)`;
};
window.addEventListener('mousemove', this._parallaxHandler);
}
root.appendChild(bgLayer);
}
// --- particles слой (медленные искры) ---
if (st.style === 'particles') {
const pLayer = document.createElement('div');
pLayer.style.cssText = 'position:absolute;inset:0;z-index:1;overflow:hidden;pointer-events:none;';
for (let i = 0; i < 26; i++) {
const sp = document.createElement('span');
sp.className = 'kbn-ls-particle';
const size = 2 + Math.round(Math.random() * 4);
const dur = 7 + Math.random() * 10;
sp.style.cssText =
`position:absolute;bottom:-10px;left:${(Math.random() * 100).toFixed(1)}%;` +
`width:${size}px;height:${size}px;border-radius:50%;` +
`background:rgba(${180 + Math.round(Math.random() * 70)},${190 + Math.round(Math.random() * 60)},255,0.85);` +
`box-shadow:0 0 ${size * 2}px rgba(140,170,255,0.7);` +
`animation-duration:${dur.toFixed(1)}s;animation-delay:${(-Math.random() * dur).toFixed(1)}s;`;
pLayer.appendChild(sp);
}
root.appendChild(pLayer);
}
// Обёртка контента (над фоном).
const content = document.createElement('div');
content.style.cssText =
'position:relative;z-index:2;display:flex;flex-direction:column;align-items:center;justify-content:center;width:100%;height:100%;';
// --- Cover (картинка-карточка по центру) ---
const coverUrl = this._resolveCover(cover);
// Режим карточки места (задача 05): квадрат + название + автор под ней.
const hasPlaceCard = !!(st.placeName || st.studioName);
const coverImg = document.createElement('div');
if (hasPlaceCard) {
coverImg.className = 'kbn-ls-cardglow';
coverImg.style.cssText =
'width:min(42vw,400px);aspect-ratio:1/1;border-radius:18px;' +
'background-size:cover;background-position:center;background-color:#1a1f2b;' +
'border:2px solid rgba(255,255,255,0.12);';
} else {
coverImg.style.cssText =
'max-width:min(62vw,860px);width:62vw;aspect-ratio:16/9;border-radius:16px;' +
'box-shadow:0 18px 60px rgba(0,0,0,0.6);background-size:cover;background-position:center;' +
'background-color:#1a1f2b;margin-bottom:140px;';
}
if (coverUrl) coverImg.style.backgroundImage = `url("${coverUrl}")`;
else if (bgUrl && hasPlaceCard) coverImg.style.backgroundImage = `url("${bgUrl}")`;
// --- Название места (крупный белый, под карточкой) ---
const placeEl = document.createElement('div');
placeEl.style.cssText =
'margin-top:22px;color:#fff;font-size:38px;font-weight:800;letter-spacing:0.5px;' +
'text-align:center;text-shadow:0 3px 14px rgba(0,0,0,0.7);' +
(st.placeName ? '' : 'display:none;');
placeEl.textContent = st.placeName || '';
// --- Автор + verified-галочка ---
const studioRow = document.createElement('div');
studioRow.style.cssText =
'margin-top:8px;display:flex;align-items:center;gap:7px;' +
'color:#cdd6e6;font-size:16px;font-weight:600;text-shadow:0 1px 4px rgba(0,0,0,0.6);' +
(st.studioName ? '' : 'display:none;');
const studioTxt = document.createElement('span');
studioTxt.textContent = st.studioName || '';
studioRow.appendChild(studioTxt);
if (st.verified) studioRow.appendChild(this._buildVerifiedBadge());
// --- Текст под картинкой (для не-карточного режима / mid-game) ---
const textEl = document.createElement('div');
if (hasPlaceCard) {
textEl.style.cssText =
'margin-top:14px;color:#e8edf5;font-size:16px;font-weight:500;text-align:center;' +
'text-shadow:0 1px 3px rgba(0,0,0,0.6);' + (st.text ? '' : 'display:none;');
} else {
textEl.style.cssText =
'position:absolute;left:50%;transform:translateX(-50%);bottom:170px;' +
'color:#e8edf5;font-size:18px;font-weight:500;text-align:center;text-shadow:0 1px 3px rgba(0,0,0,0.6);';
}
textEl.textContent = st.text || '';
// --- Прогресс-бар ---
const barWrap = document.createElement('div');
barWrap.style.cssText =
'position:absolute;left:50%;transform:translateX(-50%);bottom:120px;' +
`width:min(74vw,1180px);height:14px;border-radius:8px;background:${st.progressBgColor};` +
'overflow:hidden;box-shadow:inset 0 1px 3px rgba(0,0,0,0.5);' +
(st.progressBar ? '' : 'display:none;');
const bar = document.createElement('div');
bar.style.cssText =
`height:100%;width:${(st.progress * 100).toFixed(1)}%;border-radius:8px;` +
`background:linear-gradient(90deg,${st.progressColor},${this._lighten(st.progressColor)});` +
'transition:width 0.12s linear;box-shadow:0 0 8px rgba(255,200,40,0.4);';
barWrap.appendChild(bar);
// --- Процент ---
const percent = document.createElement('div');
percent.style.cssText =
'position:absolute;left:50%;transform:translateX(-50%);bottom:74px;' +
`color:${st.progressColor};font-size:30px;font-weight:800;text-shadow:0 2px 4px rgba(0,0,0,0.5);` +
(st.percentText ? '' : 'display:none;');
percent.textContent = `${Math.round(st.progress * 100)}%`;
// --- Кнопка Пропустить ---
const skipBtn = document.createElement('button');
skipBtn.type = 'button';
skipBtn.textContent = st.skipButtonText;
skipBtn.style.cssText =
'position:absolute;left:50%;transform:translateX(-50%);bottom:18px;' +
'min-width:260px;padding:14px 36px;border:none;border-radius:12px;cursor:pointer;' +
`background:linear-gradient(180deg,${this._lighten(st.skipButtonColor)},${st.skipButtonColor});` +
'color:#3a2a00;font-size:18px;font-weight:800;letter-spacing:0.5px;' +
'box-shadow:0 6px 16px rgba(0,0,0,0.4),inset 0 1px 0 rgba(255,255,255,0.4);' +
'opacity:0;transition:opacity 0.25s,transform 0.1s;pointer-events:none;' +
(st.skipButton ? '' : 'display:none;');
skipBtn.onmouseenter = () => { skipBtn.style.transform = 'translateX(-50%) translateY(-2px)'; };
skipBtn.onmouseleave = () => { skipBtn.style.transform = 'translateX(-50%)'; };
skipBtn.onclick = () => {
if (skipBtn.style.pointerEvents === 'none') return;
this._fireSkip();
};
// --- Логотип (слева снизу) ---
const logo = document.createElement('div');
logo.style.cssText =
'position:absolute;left:28px;bottom:24px;max-width:200px;max-height:110px;' +
`border-radius:${st.logoCornerRadius}px;background-size:contain;background-repeat:no-repeat;` +
'background-position:left bottom;width:200px;height:90px;';
if (st.logo) logo.style.backgroundImage = `url("${st.logo}")`;
else logo.style.display = 'none';
// --- Спиннер + «ЗАГРУЗКА» (справа снизу) ---
const spinWrap = document.createElement('div');
spinWrap.style.cssText =
'position:absolute;right:32px;bottom:32px;display:flex;align-items:center;gap:14px;' +
'color:#fff;font-size:20px;font-weight:700;letter-spacing:1px;' +
(st.spinner ? '' : 'display:none;');
const spinTxt = document.createElement('span');
spinTxt.textContent = st.spinnerText;
const spinCircle = document.createElement('span');
spinCircle.className = 'kbn-ls-spinner';
spinCircle.style.cssText =
`display:inline-block;width:28px;height:28px;border:3px solid rgba(255,255,255,0.25);` +
`border-top-color:${st.progressColor};border-radius:50%;`;
spinWrap.appendChild(spinTxt);
spinWrap.appendChild(spinCircle);
// Центральная композиция (карточка + название + автор + текст) — в content.
content.appendChild(coverImg);
content.appendChild(placeEl);
content.appendChild(studioRow);
content.appendChild(textEl);
root.appendChild(content);
// Нижние/угловые элементы — абсолютно на root (поверх фона, рядом с content).
root.appendChild(barWrap);
root.appendChild(percent);
root.appendChild(skipBtn);
root.appendChild(logo);
root.appendChild(spinWrap);
parent.appendChild(root);
this.root = root;
this._els = { root, content, coverImg, placeEl, studioRow, textEl, barWrap, bar, percent, skipBtn, logo, spinWrap };
}
/** Verified-галочка (синий круг + белый чек) — inline SVG, без emoji-шрифта. */
_buildVerifiedBadge() {
const wrap = document.createElement('span');
wrap.style.cssText = 'display:inline-flex;align-items:center;';
wrap.innerHTML =
'<svg width="18" height="18" viewBox="0 0 24 24" aria-label="verified">' +
'<circle cx="12" cy="12" r="11" fill="#3897f0"/>' +
'<path d="M7 12.5l3 3 7-7" fill="none" stroke="#fff" stroke-width="2.4" ' +
'stroke-linecap="round" stroke-linejoin="round"/></svg>';
return wrap;
}
/** tick — fade-фазы, авто-duration, появление кнопки Пропустить. */
tick(dt) {
const st = this._st;
if (!st || !this._els) return;
dt = Number(dt) || 0;
if (st.phase === 'in') {
st.fadeT += dt;
const d = st.fadeIn > 0 ? st.fadeIn : 0.0001;
st.alpha = Math.min(1, EASE_OUT(st.fadeT / d));
this._els.root.style.opacity = String(st.alpha);
if (st.fadeT >= d) { st.phase = 'visible'; st.alpha = 1; st.fadeT = 0; }
} else if (st.phase === 'visible') {
st.elapsed += dt;
// Кнопка Пропустить — появляется через 0.5с.
if (!st.skipShown && st.skipButton && st.elapsed >= 0.5) {
st.skipShown = true;
this._els.skipBtn.style.opacity = '1';
this._els.skipBtn.style.pointerEvents = 'auto';
}
// Авто-duration (если не было ручного setProgress).
if (st.duration && !st.manualProgress) {
st.progress = Math.min(1, st.elapsed / st.duration);
this._applyProgress(st);
if (st.progress >= 1 && !st.completed) {
st.completed = true;
this._fireComplete();
this.close();
}
}
} else if (st.phase === 'out') {
st.fadeT += dt;
const d = st.fadeOut > 0 ? st.fadeOut : 0.0001;
st.alpha = Math.max(0, 1 - EASE_OUT(st.fadeT / d));
this._els.root.style.opacity = String(st.alpha);
if (st.fadeT >= d) this._teardown();
}
}
_applyProgress(st) {
if (!this._els) return;
this._els.bar.style.width = `${(st.progress * 100).toFixed(1)}%`;
this._els.percent.textContent = `${Math.round(st.progress * 100)}%`;
}
setProgress(value) {
const st = this._st;
if (!st) return;
st.manualProgress = true;
st.progress = Math.max(0, Math.min(1, Number(value) || 0));
this._applyProgress(st);
if (st.progress >= 1 && !st.completed) {
st.completed = true;
this._fireComplete();
this.close();
}
}
setText(text) {
const st = this._st;
if (!st || !this._els) return;
st.text = String(text == null ? '' : text);
this._els.textEl.textContent = st.text;
}
setCover(cover) {
if (!this._st || !this._els) return;
const url = this._resolveCover(cover);
if (url) this._els.coverImg.style.backgroundImage = `url("${url}")`;
}
/** Задача 05: сменить фоновое (Ken-Burns) изображение на лету. */
setBackground(bg) {
if (!this._st || !this._els) return;
const url = this._resolveCover(bg);
if (!url) return;
this._st.background = bg;
// фоновый слой — первый ребёнок root с background-image; найдём его.
const layer = this._els.root.querySelector('.kbn-ls-kenburns')
|| this._els.root.firstElementChild;
if (layer && layer !== this._els.content) layer.style.backgroundImage = `url("${url}")`;
}
/** Задача 05: виден ли экран сейчас. */
isVisible() {
return !!(this._st && this._st.phase !== 'out');
}
/** Закрыть программно (с fadeOut). */
close() {
const st = this._st;
if (!st) return;
if (st.phase !== 'out') { st.phase = 'out'; st.fadeT = 0; }
}
_fireSkip() {
const st = this._st;
if (!st) return;
if (this._onSkipCb) { try { this._onSkipCb(st.id); } catch { /* ignore */ } }
this.close();
}
_fireComplete() {
const st = this._st;
if (!st) return;
if (this._onCompleteCb) { try { this._onCompleteCb(st.id); } catch { /* ignore */ } }
}
/** Мгновенно убрать без fade (повторный show / выход из Play). */
_instantClose() {
this._teardown();
}
_teardown() {
// Снять блок ввода / паузу.
const st = this._st;
if (st) {
if (st.blockInput) { try { this.s.player?.setInputBlocked?.(false); } catch { /* ignore */ } }
if (st.pauseSimulation && this.s.gameRuntime) { try { this.s.gameRuntime.paused = false; } catch { /* ignore */ } }
}
// Снять parallax-listener (задача 05).
if (this._parallaxHandler) {
try { window.removeEventListener('mousemove', this._parallaxHandler); } catch { /* ignore */ }
this._parallaxHandler = null;
}
// onHide-мост (задача 05) — сообщаем скриптам что экран скрылся.
if (this._onHideCb) { try { this._onHideCb(); } catch { /* ignore */ } }
if (this.root) { try { this.root.remove(); } catch { /* ignore */ } }
this.root = null;
this._els = null;
this._st = null;
}
dispose() {
this._instantClose();
this._onSkipCb = null;
this._onCompleteCb = null;
}
// --- утилиты цвета ---
_lighten(hex) {
try {
const h = String(hex).replace('#', '');
if (h.length !== 6) return hex;
const r = Math.min(255, parseInt(h.slice(0, 2), 16) + 40);
const g = Math.min(255, parseInt(h.slice(2, 4), 16) + 40);
const b = Math.min(255, parseInt(h.slice(4, 6), 16) + 40);
return `rgb(${r},${g},${b})`;
} catch { return hex; }
}
_bgRgba(hex, opacity) {
try {
const h = String(hex).replace('#', '');
if (h.length !== 6) return hex;
const r = parseInt(h.slice(0, 2), 16);
const g = parseInt(h.slice(2, 4), 16);
const b = parseInt(h.slice(4, 6), 16);
const a = opacity != null ? Math.max(0, Math.min(1, opacity)) : 1;
return `rgba(${r},${g},${b},${a})`;
} catch { return hex; }
}
}