Some checks failed
Плеер отстал на несколько задач — игры из студии не открывались с механиками. Перенёс из rublox-studio в движок плеера: Новые файлы движка: - engine/ModalManager.js (задача 04 — модальные сцены) - engine/BillboardUiManager.js (задача 01 — 3D-таблички) Точечный перенос в существующие файлы: - ScriptSandboxWorker.js: namespace game.modal/billboard/environment, скины в game.player, game.gui.tween, _guiHandlerKeys(localId), события modalOpened/modalClosed/skinChanged/billboardClick - GameRuntime.js: команды modal.*/billboard.*/player.setSkin.*/gui.tween + _broadcastSkinsSnapshot/_ensureSkinState + routeGlobalEvent с localId - PlayerController.js: non-humanoid скины (loadNonHumanoid+reloadSkin+ процедурная анимация+pivot-центрирование), setInputBlocked/focusOnTarget, камера задачи 02 (zoom/shift-lock), клавиша B (магазин) - BabylonScene.js: init modalManager/billboardUiManager, методы магазина скинов, чтение scene.skins, modalManager.tick, Esc-приоритет - ScriptSandbox.js: sendSkinsSnapshot - GuiManager.js: поля анимаций задачи 03 (синхронизирован со студией) - PrimitiveTypes.js / PrimitiveManager.js: тип billboard + рендер React-слой (editor-shared): - ModalOverlay.jsx, SkinShopOverlay.jsx (новые) + подключены в KubikonPlayer - GuiOverlay.jsx, GameHud.jsx синхронизированы со студией eslint.config: послабления стилевых правил (no-empty off и т.п.). Локальный build зелёный. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
799 lines
38 KiB
JavaScript
799 lines
38 KiB
JavaScript
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 (
|
||
<div
|
||
style={{
|
||
position: 'absolute',
|
||
inset: 0,
|
||
pointerEvents: 'none',
|
||
zIndex: 25,
|
||
overflow: 'hidden',
|
||
}}
|
||
>
|
||
{rootSorted.map((el) => (
|
||
<GuiElement
|
||
key={el.id}
|
||
el={el}
|
||
allElements={elements}
|
||
childrenMap={childrenMap}
|
||
isPlaying={isPlaying}
|
||
selectedId={selectedId}
|
||
onSelect={onSelect}
|
||
onUpdate={onUpdate}
|
||
onDelete={onDelete}
|
||
onPlayClick={onPlayClick}
|
||
containerRef={containerRef}
|
||
setSnapLines={setSnapLines}
|
||
resolveAsset={resolveAsset}
|
||
/>
|
||
))}
|
||
{/* Snap-направляющие (рендерятся только для корневых элементов) */}
|
||
{!isPlaying && snapLines.map((line, i) => (
|
||
<div key={i} style={{
|
||
position: 'absolute',
|
||
background: '#66b04a',
|
||
opacity: 0.85,
|
||
pointerEvents: 'none',
|
||
zIndex: 100,
|
||
boxShadow: '0 0 4px rgba(102, 176, 74, 0.7)',
|
||
...(line.dir === 'v'
|
||
? { left: `${line.pos}%`, top: 0, bottom: 0, width: 1 }
|
||
: { top: `${line.pos}%`, left: 0, right: 0, height: 1 }),
|
||
}} />
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<div
|
||
ref={selfRef}
|
||
onMouseDown={(e) => {
|
||
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 (
|
||
<img
|
||
src={src}
|
||
alt={el.name || ''}
|
||
draggable={false}
|
||
style={{
|
||
width: '100%', height: '100%', objectFit: 'contain',
|
||
pointerEvents: 'none', display: 'block',
|
||
}}
|
||
onError={(e) => { 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 (
|
||
<div style={{
|
||
width: '100%', height: '100%',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: el.textAlign === 'left' ? 'flex-start'
|
||
: el.textAlign === 'right' ? 'flex-end' : 'center',
|
||
color: el.textColor || '#f0e6d8',
|
||
fontSize: el.textSize || 16,
|
||
fontWeight: el.fontWeight || 500,
|
||
fontFamily: '"Roboto Condensed", system-ui, sans-serif',
|
||
padding: '4px 8px',
|
||
boxSizing: 'border-box',
|
||
textAlign: el.textAlign || 'center',
|
||
lineHeight: 1.2,
|
||
whiteSpace: 'pre-wrap',
|
||
wordBreak: 'break-word',
|
||
pointerEvents: 'none',
|
||
...(strokeStyle || {}),
|
||
}}>
|
||
{el.text}
|
||
</div>
|
||
);
|
||
})()}
|
||
{/* Задача 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 (
|
||
<div style={{
|
||
position: 'absolute',
|
||
...cornerStyle,
|
||
minWidth: big ? 32 : 22,
|
||
height: 22,
|
||
padding: '0 6px',
|
||
background: b.color || '#fbbf24',
|
||
color: '#3a1a00',
|
||
borderRadius: big ? 6 : 11,
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||
fontSize: big ? 11 : 14,
|
||
fontWeight: 800,
|
||
fontFamily: '"Roboto Condensed", system-ui, sans-serif',
|
||
boxShadow: '0 2px 4px rgba(0,0,0,0.4)',
|
||
border: '2px solid #fff',
|
||
pointerEvents: 'none',
|
||
zIndex: 5,
|
||
transform: 'rotate(8deg)',
|
||
}}>{text}</div>
|
||
);
|
||
})()}
|
||
|
||
{/* TextBox — настоящий <input> в Play (принимает ввод),
|
||
в редакторе — статичный вид с placeholder. */}
|
||
{isTextbox && isPlaying && (
|
||
<input
|
||
type="text"
|
||
defaultValue={el.text || ''}
|
||
placeholder={el.placeholder || ''}
|
||
onChange={(e) => 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 && (
|
||
<div style={{
|
||
width: '100%', height: '100%',
|
||
display: 'flex', alignItems: 'center',
|
||
color: (el.text ? el.textColor : '#8a7c6a'),
|
||
fontSize: el.textSize || 16,
|
||
fontFamily: '"Roboto Condensed", system-ui, sans-serif',
|
||
padding: '4px 10px',
|
||
boxSizing: 'border-box',
|
||
fontStyle: el.text ? 'normal' : 'italic',
|
||
pointerEvents: 'none',
|
||
}}>
|
||
{el.text || el.placeholder || 'Поле ввода'}
|
||
</div>
|
||
)}
|
||
|
||
{/* Метка с именем над выделенным элементом */}
|
||
{selected && !isPlaying && (
|
||
<div style={{
|
||
position: 'absolute',
|
||
left: 0, bottom: '100%',
|
||
marginBottom: 4,
|
||
fontSize: 10,
|
||
fontWeight: 600,
|
||
color: '#fff',
|
||
background: '#66b04a',
|
||
padding: '1px 6px',
|
||
borderRadius: 3,
|
||
pointerEvents: 'none',
|
||
whiteSpace: 'nowrap',
|
||
}}>
|
||
{el.name}
|
||
</div>
|
||
)}
|
||
|
||
{/* Resize-ручки на выделенном элементе */}
|
||
{selected && !isPlaying && (
|
||
<>
|
||
{RESIZE_HANDLES.map(h => (
|
||
<div
|
||
key={h.dir}
|
||
onMouseDown={startResize(h.dir)}
|
||
style={{
|
||
position: 'absolute',
|
||
width: 10, height: 10,
|
||
background: '#66b04a',
|
||
border: '1.5px solid #fff',
|
||
borderRadius: 2,
|
||
boxSizing: 'border-box',
|
||
...h.pos,
|
||
cursor: h.cursor,
|
||
zIndex: 10,
|
||
pointerEvents: 'auto',
|
||
}}
|
||
/>
|
||
))}
|
||
</>
|
||
)}
|
||
|
||
{/* Дочерние элементы — рендерятся ВНУТРИ этого контейнера.
|
||
Если у контейнера layout='vertical'/'horizontal' — детям
|
||
подменяем x/y на вычисленные раскладкой (UIListLayout). */}
|
||
{myChildren.length > 0 && layoutChildren(el, myChildren).map(child => (
|
||
<GuiElement
|
||
key={child.id}
|
||
el={child}
|
||
allElements={allElements}
|
||
childrenMap={childrenMap}
|
||
isPlaying={isPlaying}
|
||
selectedId={selectedId}
|
||
onSelect={onSelect}
|
||
onUpdate={onUpdate}
|
||
onDelete={onDelete}
|
||
onPlayClick={onPlayClick}
|
||
containerRef={containerRef}
|
||
setSnapLines={setSnapLines}
|
||
resolveAsset={resolveAsset}
|
||
/>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Авто-раскладка детей контейнера (Фаза 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;
|