player/src/editor-shared/GuiOverlay.jsx
МИН a46829c5f7
Some checks failed
CI / Lint (pull_request) Failing after 42s
CI / Build (pull_request) Successful in 1m30s
CI / Secret scan (pull_request) Successful in 2m28s
CI / PR size check (pull_request) Successful in 6s
CI / Deploy to S1 + S2 (pull_request) Has been skipped
feat: синхронизация движка плеера со студией (задачи 01-07)
Плеер отстал на несколько задач — игры из студии не открывались с механиками.
Перенёс из 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>
2026-05-30 03:15:43 +03:00

799 lines
38 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.

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;