import React from 'react'; /** * GuiOverlay — рендерит GUI-элементы (Frame/Text/Button/Image) поверх viewport. * * Этап 2.8.1: drag-and-drop, выделение. * Этап 2.8.2: resize-ручки (8 точек: углы и середины сторон), snap-линии * к центру/краям/середине viewport и к границам других элементов. * * В Play-режиме: * - элементы рендерятся «как есть», * - кнопки кликабельны (на этапе 2.8.3 будут шлёт событие в скрипты). */ const SNAP_THRESHOLD = 1.5; // % — расстояние, в пределах которого срабатывает snap function GuiOverlay({ elements = [], isPlaying = false, selectedId = null, onSelect, onUpdate, onDelete, onPlayClick, containerRef, // resolveAsset(id) → dataURL картинки из AssetManager (для image-GUI). resolveAsset, }) { // snapLines — массив { dir: 'v'|'h', pos: % } для отрисовки направляющих во время drag/resize const [snapLines, setSnapLines] = React.useState([]); if (!Array.isArray(elements) || elements.length === 0) { return null; } // Корневые элементы (parentId == null) — рендерятся в overlay-контейнер. // Дочерние рендерятся внутри своего родителя через рекурсивный GuiElement. const rootSorted = elements .filter(e => !e.parentId) .sort((a, b) => (a.zIndex || 0) - (b.zIndex || 0)); // Map id → children для быстрого поиска const childrenMap = new Map(); for (const el of elements) { if (!el.parentId) continue; if (!childrenMap.has(el.parentId)) childrenMap.set(el.parentId, []); childrenMap.get(el.parentId).push(el); } for (const arr of childrenMap.values()) { arr.sort((a, b) => (a.zIndex || 0) - (b.zIndex || 0)); } return (
{rootSorted.map((el) => ( ))} {/* Snap-направляющие (рендерятся только для корневых элементов) */} {!isPlaying && snapLines.map((line, i) => (
))}
); } function GuiElement({ el, allElements, childrenMap, isPlaying, selectedId, onSelect, onUpdate, onDelete, onPlayClick, containerRef, setSnapLines, resolveAsset }) { const [dragging, setDragging] = React.useState(false); const [resizing, setResizing] = React.useState(null); // 'n'|'s'|'e'|'w'|'ne'|'nw'|'se'|'sw' или null const [hover, setHover] = React.useState(false); const [pressed, setPressed] = React.useState(false); const selfRef = React.useRef(null); const selected = !isPlaying && selectedId === el.id; const myChildren = childrenMap?.get(el.id) || []; if (el.visible === false && !isPlaying && !selected) return null; if (el.visible === false && isPlaying) return null; const style = elementToStyle(el); /** Начало drag (перемещение всего элемента). */ const startDrag = (e) => { if (isPlaying || e.button !== 0) return; e.stopPropagation(); e.preventDefault(); onSelect?.(el.id); // Координаты считаем относительно родительского DOM: // - для root-элементов это viewport (containerRef) // - для дочерних — DOM-узел Frame'а-родителя. // Используем parentNode т.к. selfRef.current.parentNode и есть наш контейнер // (обёртка-родитель в DOM). const container = el.parentId ? selfRef.current?.parentNode : containerRef?.current; if (!container) return; const rect = container.getBoundingClientRect(); const startMouseX = e.clientX; const startMouseY = e.clientY; const startX = el.x ?? 50; const startY = el.y ?? 50; setDragging(true); // Знаки для якорей: позиция X/Y отсчитывается ОТ соответствующего края. // Для bottom-* — Y растёт ВВЕРХ от низа, поэтому движение мыши вниз // (положительный dyPct) должно уменьшать Y. То же для right-* по X. const anchor = el.anchor || 'center'; const xSign = anchor.endsWith('-right') ? -1 : 1; const ySign = anchor.startsWith('bottom-') ? -1 : 1; const onMove = (ev) => { const dxPct = ((ev.clientX - startMouseX) / rect.width) * 100; const dyPct = ((ev.clientY - startMouseY) / rect.height) * 100; const step = ev.shiftKey ? 5 : 1; let nx = Math.max(0, Math.min(100, startX + dxPct * xSign)); let ny = Math.max(0, Math.min(100, startY + dyPct * ySign)); // Snap работает только для корневых элементов (внутри viewport). // Для дочерних — только округление, без snap-линий. if (!el.parentId) { const snaps = computeSnap(el, allElements, nx, ny, el.w, el.h); if (snaps.x != null) nx = snaps.x; if (snaps.y != null) ny = snaps.y; setSnapLines(snaps.lines || []); } // Округление к целым (Shift = 5%) nx = Math.round(nx / step) * step; ny = Math.round(ny / step) * step; onUpdate?.(el.id, { x: nx, y: ny }); }; const onUp = () => { setDragging(false); setSnapLines([]); window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); }; window.addEventListener('mousemove', onMove); window.addEventListener('mouseup', onUp); }; /** Начало resize за одну из 8 ручек. */ const startResize = (handle) => (e) => { if (isPlaying || e.button !== 0) return; e.stopPropagation(); e.preventDefault(); const container = el.parentId ? selfRef.current?.parentNode : containerRef?.current; if (!container) return; const rect = container.getBoundingClientRect(); const startMouseX = e.clientX; const startMouseY = e.clientY; const startX = el.x ?? 50; const startY = el.y ?? 50; const startW = el.w ?? 20; const startH = el.h ?? 10; const anchor = el.anchor || 'center'; setResizing(handle); const onMove = (ev) => { const dxPct = ((ev.clientX - startMouseX) / rect.width) * 100; const dyPct = ((ev.clientY - startMouseY) / rect.height) * 100; const step = ev.shiftKey ? 5 : 1; let nx = startX, ny = startY, nw = startW, nh = startH; // Считаем что Y «направлен» от ССЫЛОЧНОГО края к противоположному: // top-* и center — Y растёт вниз (как мышь), bottom-* — вверх. // X аналогично: left-* и center — вправо (как мышь), right-* — влево. const xSign = anchor.endsWith('-right') ? -1 : 1; const ySign = anchor.startsWith('bottom-') ? -1 : 1; // dx/dy в координатах элемента (с учётом направления) const adx = dxPct * xSign; const ady = dyPct * ySign; // Какой край тянем в координатах ЭЛЕМЕНТА (не мыши) // 'e' — мышь вправо, для left-якорей это правый край элемента, // для right-якорей — левый край (мы дёргаем дальний от ссылки край). const isPosEdgeX = anchor.endsWith('-right') ? handle.includes('w') : handle.includes('e'); const isNegEdgeX = anchor.endsWith('-right') ? handle.includes('e') : handle.includes('w'); const isPosEdgeY = anchor.startsWith('bottom-') ? handle.includes('n') : handle.includes('s'); const isNegEdgeY = anchor.startsWith('bottom-') ? handle.includes('s') : handle.includes('n'); // Изменение размера и позиции в координатах элемента if (anchor === 'center') { // Центрированный якорь: тянем любой край → центр движется на половину дельты if (handle.includes('e')) { nw = startW + dxPct; nx = startX + dxPct / 2; } if (handle.includes('w')) { nw = startW - dxPct; nx = startX + dxPct / 2; } if (handle.includes('s')) { nh = startH + dyPct; ny = startY + dyPct / 2; } if (handle.includes('n')) { nh = startH - dyPct; ny = startY + dyPct / 2; } } else { // Угловые якоря: ближняя к якорю сторона = позиция (фиксирована), // дальняя сторона = позиция + размер. if (isPosEdgeX) { // Тянем «дальний» край — меняется только размер nw = Math.max(2, startW + adx); nx = startX; } else if (isNegEdgeX) { // Тянем «ближний» край — двигается и позиция, и размер nx = startX + adx; nw = Math.max(2, startW - adx); } if (isPosEdgeY) { nh = Math.max(2, startH + ady); ny = startY; } else if (isNegEdgeY) { ny = startY + ady; nh = Math.max(2, startH - ady); } } // Защита от отрицательных размеров (для center) nw = Math.max(2, nw); nh = Math.max(2, nh); // Округление до целых процентов nx = Math.max(0, Math.min(100, Math.round(nx / step) * step)); ny = Math.max(0, Math.min(100, Math.round(ny / step) * step)); nw = Math.max(2, Math.min(100, Math.round(nw / step) * step)); nh = Math.max(2, Math.min(100, Math.round(nh / step) * step)); onUpdate?.(el.id, { x: nx, y: ny, w: nw, h: nh }); }; const onUp = () => { setResizing(null); window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); }; window.addEventListener('mousemove', onMove); window.addEventListener('mouseup', onUp); }; const isButton = el.type === 'button'; const isImage = el.type === 'image'; const isTextbox = el.type === 'textbox'; const isScroll = el.type === 'scroll'; const isText = el.type === 'text' || el.type === 'button'; // textbox в Play кликабелен (для фокуса и ввода), как и кнопка const pointerEvents = isPlaying ? ((isButton || isTextbox) ? 'auto' : 'none') : 'auto'; // В Play на кнопке — hover/pressed эффект (Задача 03). // Если у элемента задан el.hover/el.active — используем их параметры, // иначе дефолтные значения. const playInteractive = isPlaying && isButton; const hoverCfg = el.hover || { scale: 1.08, brightness: 1.15, rotation: 0 }; const activeCfg = el.active || { scale: 0.94 }; const hoverBrightness = (typeof hoverCfg.brightness === 'number') ? hoverCfg.brightness : 1.15; const hoverScale = (typeof hoverCfg.scale === 'number') ? hoverCfg.scale : 1.08; const hoverRotation = (typeof hoverCfg.rotation === 'number') ? hoverCfg.rotation : 0; const activeScale = (typeof activeCfg.scale === 'number') ? activeCfg.scale : 0.94; const playFilter = pressed ? 'brightness(0.85)' : (hover ? `brightness(${hoverBrightness})` : 'none'); const dynScale = pressed ? activeScale : (hover ? hoverScale : 1); const dynRot = hover ? hoverRotation : 0; let extraTr = ''; if (dynScale !== 1) extraTr += ` scale(${dynScale})`; if (dynRot) extraTr += ` rotate(${dynRot}deg)`; const playTransform = (style.transform || '') + extraTr; return (
{ if (playInteractive) { setPressed(true); } startDrag(e); }} onMouseUp={() => { if (playInteractive) setPressed(false); }} onMouseEnter={() => { if (playInteractive) setHover(true); }} onMouseLeave={() => { if (playInteractive) { setHover(false); setPressed(false); } }} onClick={(e) => { if (!isPlaying) return; e.stopPropagation(); if (el.type === 'button') onPlayClick?.(el.id); }} onContextMenu={(e) => { if (isPlaying) return; e.preventDefault(); e.stopPropagation(); onSelect?.(el.id); }} onWheel={(e) => { // Прокрутка scroll-контейнера колесом мыши. if (!isScroll) return; e.stopPropagation(); const children = childrenMap?.get(el.id) || []; // Полная высота контента (в % размера экрана) при вертикальной раскладке. const gap = Number.isFinite(el.layoutGap) ? el.layoutGap : 2; const pad = Number.isFinite(el.layoutPad) ? el.layoutPad : 3; let contentH = pad * 2; for (const ch of children) contentH += (ch.h ?? 10) + gap; // Сколько контента не влезает = на сколько можно прокрутить. const maxScroll = Math.max(0, contentH - (el.h ?? 40)); const cur = Number.isFinite(el.scrollY) ? el.scrollY : 0; // deltaY>0 (колесо вниз) — увеличиваем сдвиг. const next = Math.max(0, Math.min(maxScroll, cur + (e.deltaY > 0 ? 4 : -4))); if (next !== cur) onUpdate?.(el.id, { scrollY: next }); }} style={{ ...style, transform: playInteractive ? playTransform : style.transform, filter: playInteractive ? playFilter : 'none', pointerEvents: isScroll && isPlaying ? 'auto' : pointerEvents, // scroll-контейнер обрезает детей, выходящих за границу. overflow: isScroll ? 'hidden' : (style.overflow || 'visible'), cursor: isPlaying ? (isButton ? 'pointer' : 'default') : (dragging ? 'grabbing' : 'grab'), outline: selected ? '2px solid #66b04a' : (isPlaying ? 'none' : '1px dashed rgba(150,200,120,0.35)'), outlineOffset: selected ? 2 : 0, transition: dragging || resizing ? 'none' : 'outline-color 0.12s, filter 0.1s, transform 0.05s', userSelect: 'none', }} title={isPlaying ? undefined : `${el.name} — перетащи мышью, потяни за ручки чтобы изменить размер`} > {isImage && (() => { // imageAsset (картинка из AssetManager) приоритетнее imageUrl. const assetUrl = el.imageAsset && resolveAsset ? resolveAsset(el.imageAsset) : null; const src = assetUrl || el.imageUrl; if (!src) return null; return ( {el.name { e.currentTarget.style.opacity = '0.3'; }} /> ); })()} {isText && (el.text != null) && (() => { // Задача 03: text-stroke (обводка текста). Через -webkit-text-stroke // (хорошая поддержка, чётко на крупном шрифте) + paint-order // (stroke под fill чтобы текст не «сжимался»). const ts = el.textStroke; const strokeStyle = (ts && ts.color && Number.isFinite(ts.width)) ? { WebkitTextStroke: `${ts.width}px ${ts.color}`, paintOrder: 'stroke fill', } : null; return (
{el.text}
); })()} {/* Задача 03: Бейдж в углу — отдельный absolute-элемент. */} {el.badge && (() => { const b = el.badge; const corner = b.corner || 'top-right'; const cornerStyle = { 'top-right': { top: -6, right: -6 }, 'top-left': { top: -6, left: -6 }, 'bottom-right': { bottom: -6, right: -6 }, 'bottom-left': { bottom: -6, left: -6 }, }[corner] || { top: -6, right: -6 }; const icons = { exclamation: '!', star: '★', plus: '+', new: 'NEW', sale: '%', }; const text = b.text != null ? b.text : (icons[b.icon] || '!'); const big = b.icon === 'new'; return (
{text}
); })()} {/* TextBox — настоящий в Play (принимает ввод), в редакторе — статичный вид с placeholder. */} {isTextbox && isPlaying && ( onPlayClick?.(el.id, 'textchange', e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') { e.currentTarget.blur(); onPlayClick?.(el.id, 'submit', e.currentTarget.value); } }} onClick={(e) => e.stopPropagation()} style={{ width: '100%', height: '100%', boxSizing: 'border-box', background: 'transparent', border: 'none', outline: 'none', color: el.textColor || '#f0e6d8', fontSize: el.textSize || 16, fontWeight: el.fontWeight || 500, fontFamily: '"Roboto Condensed", system-ui, sans-serif', padding: '4px 10px', textAlign: el.textAlign || 'left', }} /> )} {isTextbox && !isPlaying && (
{el.text || el.placeholder || 'Поле ввода'}
)} {/* Метка с именем над выделенным элементом */} {selected && !isPlaying && (
{el.name}
)} {/* Resize-ручки на выделенном элементе */} {selected && !isPlaying && ( <> {RESIZE_HANDLES.map(h => (
))} )} {/* Дочерние элементы — рендерятся ВНУТРИ этого контейнера. Если у контейнера layout='vertical'/'horizontal' — детям подменяем x/y на вычисленные раскладкой (UIListLayout). */} {myChildren.length > 0 && layoutChildren(el, myChildren).map(child => ( ))}
); } /** * Авто-раскладка детей контейнера (Фаза 5.3, UIListLayout-аналог). * Если у контейнера layout='vertical'/'horizontal' — возвращает копии * детей с вычисленными x/y (в % размера контейнера) и якорем top-left. * При layout='none' возвращает детей как есть. */ function layoutChildren(container, children) { const layout = container && container.layout; if (layout !== 'vertical' && layout !== 'horizontal' && layout !== 'grid') return children; const gap = Number.isFinite(container.layoutGap) ? container.layoutGap : 2; const pad = Number.isFinite(container.layoutPad) ? container.layoutPad : 3; // scrollY -- сдвиг прокрутки (для type='scroll'). const scrollY = Number.isFinite(container.scrollY) ? container.scrollY : 0; // Phase 6.3.2: Grid layout -- авто-сетка с заданной шириной ячейки. // layoutCellW/H -- размер ячейки в %, layoutCols -- сколько колонок (если 0 -- авто). if (layout === 'grid') { const cellW = Number.isFinite(container.layoutCellW) ? container.layoutCellW : 18; const cellH = Number.isFinite(container.layoutCellH) ? container.layoutCellH : 18; const availW = 100 - pad * 2; // Авто-вычисление кол-ва колонок если не задано. let cols = Number(container.layoutCols) || 0; if (cols < 1) cols = Math.max(1, Math.floor((availW + gap) / (cellW + gap))); return children.map((ch, i) => { const row = Math.floor(i / cols); const col = i % cols; const nx = pad + col * (cellW + gap); const ny = pad + row * (cellH + gap) - scrollY; return { ...ch, x: nx, y: ny, w: cellW, h: cellH, anchor: 'top-left' }; }); } let cursor = pad; return children.map((ch) => { const w = ch.w ?? 20, h = ch.h ?? 10; let nx, ny; if (layout === 'vertical') { nx = pad; ny = cursor - scrollY; cursor += h + gap; } else { nx = cursor; ny = pad - scrollY; cursor += w + gap; } // Якорь top-left -- координаты считаются от левого-верхнего угла. return { ...ch, x: nx, y: ny, anchor: 'top-left' }; }); } const RESIZE_HANDLES = [ { dir: 'nw', pos: { left: -5, top: -5 }, cursor: 'nwse-resize' }, { dir: 'n', pos: { left: '50%', top: -5, transform: 'translateX(-50%)' }, cursor: 'ns-resize' }, { dir: 'ne', pos: { right: -5, top: -5 }, cursor: 'nesw-resize' }, { dir: 'e', pos: { right: -5, top: '50%', transform: 'translateY(-50%)' }, cursor: 'ew-resize' }, { dir: 'se', pos: { right: -5, bottom: -5 }, cursor: 'nwse-resize' }, { dir: 's', pos: { left: '50%', bottom: -5, transform: 'translateX(-50%)' }, cursor: 'ns-resize' }, { dir: 'sw', pos: { left: -5, bottom: -5 }, cursor: 'nesw-resize' }, { dir: 'w', pos: { left: -5, top: '50%', transform: 'translateY(-50%)' }, cursor: 'ew-resize' }, ]; /** * Считает snap-привязки во время drag. * Возвращает { x, y, lines }, где x/y — скорректированные координаты (или null если без snap), * а lines — массив { dir: 'v'|'h', pos: % } для отрисовки направляющих. * * Цели для привязки: * - центр viewport (50%, 50%) * - края viewport (0%, 100%) * - центр и края других элементов * * Учитываем только anchor='center' для расчёта границ — для упрощения первой версии. * (Корректно работает для большинства случаев.) */ function computeSnap(self, allElements, x, y, w, h) { const lines = []; let snapX = null, snapY = null; // Кандидаты: центр + границы (left/right/top/bottom) текущего элемента const halfW = (w || 20) / 2; const halfH = (h || 10) / 2; // Точки которые мы хотим «прицелить» к чему-то на сцене const myXTargets = [ { value: x, kind: 'cx' }, { value: x - halfW, kind: 'left' }, { value: x + halfW, kind: 'right' }, ]; const myYTargets = [ { value: y, kind: 'cy' }, { value: y - halfH, kind: 'top' }, { value: y + halfH, kind: 'bottom' }, ]; // Цели-якоря на viewport const xAnchors = [0, 25, 50, 75, 100]; const yAnchors = [0, 25, 50, 75, 100]; // Цели от других элементов for (const other of allElements) { if (other.id === self.id) continue; if (other.visible === false) continue; if ((other.anchor || 'center') !== 'center') continue; // упрощённая модель const ohw = (other.w || 20) / 2; const ohh = (other.h || 10) / 2; const ox = other.x ?? 50, oy = other.y ?? 50; xAnchors.push(ox, ox - ohw, ox + ohw); yAnchors.push(oy, oy - ohh, oy + ohh); } // Ищем ближайший snap по X for (const my of myXTargets) { for (const a of xAnchors) { if (Math.abs(my.value - a) < SNAP_THRESHOLD) { snapX = x + (a - my.value); lines.push({ dir: 'v', pos: a }); break; } } if (snapX != null) break; } // Ищем ближайший snap по Y for (const my of myYTargets) { for (const a of yAnchors) { if (Math.abs(my.value - a) < SNAP_THRESHOLD) { snapY = y + (a - my.value); lines.push({ dir: 'h', pos: a }); break; } } if (snapY != null) break; } return { x: snapX, y: snapY, lines }; } function _anchorFactorX(anchor) { if (anchor === 'center') return 0.5; if (anchor.endsWith('-left')) return 0; if (anchor.endsWith('-right')) return 1; return 0.5; } function _anchorFactorY(anchor) { if (anchor === 'center') return 0.5; if (anchor.startsWith('top-')) return 0; if (anchor.startsWith('bottom-')) return 1; return 0.5; } /** Преобразовать описание элемента в CSS-стиль (позиция/размер/фон). */ function elementToStyle(el) { const w = `${el.w ?? 20}%`; const h = `${el.h ?? 10}%`; const anchor = el.anchor || 'center'; // Phase 6.3.1: AnchorPoint -- точка ВНУТРИ элемента (0..1 по обеим осям), // относительно которой считается позиция. Если el.anchorPoint не задан, // вычисляем по anchor: center → {0.5, 0.5}, top-left → {0, 0}, и т.д. // (это сохраняет старое поведение). Юзер может override через anchorPoint. const apDefault = { x: anchor === 'right' || anchor.endsWith('-right') ? 1 : (anchor === 'left' || anchor.endsWith('-left') ? 0 : 0.5), y: anchor === 'bottom' || anchor.startsWith('bottom-') ? 1 : (anchor === 'top' || anchor.startsWith('top-') ? 0 : 0.5), }; const ap = el.anchorPoint && typeof el.anchorPoint === 'object' ? { x: typeof el.anchorPoint.x === 'number' ? el.anchorPoint.x : apDefault.x, y: typeof el.anchorPoint.y === 'number' ? el.anchorPoint.y : apDefault.y, } : apDefault; let left, top, transform; // Левый/верх вычисляется по anchor (ссылочная точка на экране). // translate(-anchorPoint*100% по каждой оси) -- сдвиг сам элемент так, // чтобы anchorPoint оказался в (left, top). const tx = -ap.x * 100; const ty = -ap.y * 100; switch (anchor) { case 'top-left': left = `${el.x ?? 0}%`; top = `${el.y ?? 0}%`; break; case 'top-right': left = `${100 - (el.x ?? 0)}%`; top = `${el.y ?? 0}%`; break; case 'bottom-left': left = `${el.x ?? 0}%`; top = `${100 - (el.y ?? 0)}%`; break; case 'bottom-right': left = `${100 - (el.x ?? 0)}%`; top = `${100 - (el.y ?? 0)}%`; break; // Phase 6.3.1: одиночные стороны -- ссылочная точка на середине стороны. case 'top': left = `${el.x ?? 50}%`; top = `${el.y ?? 0}%`; break; case 'bottom': left = `${el.x ?? 50}%`; top = `${100 - (el.y ?? 0)}%`; break; case 'left': left = `${el.x ?? 0}%`; top = `${el.y ?? 50}%`; break; case 'right': left = `${100 - (el.x ?? 0)}%`; top = `${el.y ?? 50}%`; break; case 'center': default: left = `${el.x ?? 50}%`; top = `${el.y ?? 50}%`; break; } // Задача 03: rotation + scale через transform. Добавляются ПОСЛЕ translate. // hoverScale/activeScale хранятся в el._dynScale (выставляется hover-handler'ом // в GuiElement через mutate-ref). При штатном рендере читаем el.scaleX/scaleY. const sx = (typeof el._dynScaleX === 'number' ? el._dynScaleX : 1) * (typeof el.scaleX === 'number' ? el.scaleX : 1); const sy = (typeof el._dynScaleY === 'number' ? el._dynScaleY : 1) * (typeof el.scaleY === 'number' ? el.scaleY : 1); const rot = (typeof el._dynRotation === 'number' ? el._dynRotation : 0) + (typeof el.rotation === 'number' ? el.rotation : 0); const brightness = (typeof el._dynBrightness === 'number' ? el._dynBrightness : 1); transform = `translate(${tx}%, ${ty}%)`; if (sx !== 1 || sy !== 1) transform += ` scale(${sx}, ${sy})`; if (rot) transform += ` rotate(${rot}deg)`; let bg = el.bgColor || '#1f1810'; const opacity = el.bgOpacity == null ? 1 : Math.max(0, Math.min(1, el.bgOpacity)); if (bg === 'transparent' || opacity === 0) bg = 'transparent'; else bg = hexToRgba(bg, opacity); // Задача 03: bgGradient — { stops: ['#a','#b'] | [{c,p}], angle: 0..360 }. // Если задан — перебиваем background. if (el.bgGradient && Array.isArray(el.bgGradient.stops) && el.bgGradient.stops.length >= 2) { const angle = Number.isFinite(el.bgGradient.angle) ? el.bgGradient.angle : 90; const parts = el.bgGradient.stops.map((s, i, arr) => { if (typeof s === 'string') { const p = (i / (arr.length - 1)) * 100; return `${s} ${p.toFixed(1)}%`; } const c = s.c || '#000'; const p = typeof s.p === 'number' ? s.p * 100 : (i / (arr.length - 1)) * 100; return `${c} ${p.toFixed(1)}%`; }); bg = `linear-gradient(${angle}deg, ${parts.join(', ')})`; } return { position: 'absolute', left, top, transform, transformOrigin: 'center center', width: w, height: h, background: bg, border: el.borderWidth > 0 ? `${el.borderWidth}px solid ${el.borderColor || '#5a4a3a'}` : 'none', borderRadius: (el.borderRadius || 0) + 'px', boxSizing: 'border-box', boxShadow: el.shadow ? '0 6px 16px rgba(0,0,0,0.45)' : (el.type === 'button' ? '0 2px 6px rgba(0,0,0,0.3)' : 'none'), overflow: el.type === 'frame' ? 'hidden' : 'visible', filter: brightness !== 1 ? `brightness(${brightness})` : undefined, }; } function hexToRgba(hex, alpha = 1) { if (!hex || typeof hex !== 'string') return 'rgba(0,0,0,' + alpha + ')'; if (hex.startsWith('rgba') || hex.startsWith('rgb')) return hex; let h = hex.replace('#', ''); if (h.length === 3) h = h.split('').map(c => c + c).join(''); 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); return `rgba(${r},${g},${b},${alpha})`; } export default GuiOverlay;