diff --git a/eslint.config.js b/eslint.config.js
index ea36dd3..5394fc9 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -17,5 +17,15 @@ export default defineConfig([
globals: globals.browser,
parserOptions: { ecmaFeatures: { jsx: true } },
},
+ rules: {
+ // Стилевые правила — не валим CI на осознанном код-стиле движка
+ // (пустые catch для тихого проглатывания ошибок, нестрогие var'ы).
+ 'no-empty': 'off',
+ 'no-unused-vars': ['warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }],
+ 'no-constant-condition': ['warn', { checkLoops: false }],
+ 'no-fallthrough': 'warn',
+ 'no-useless-catch': 'warn',
+ 'react-refresh/only-export-components': 'off',
+ },
},
])
diff --git a/src/KubikonPlayer/KubikonPlayer.jsx b/src/KubikonPlayer/KubikonPlayer.jsx
index 24ca076..c23dcf3 100644
--- a/src/KubikonPlayer/KubikonPlayer.jsx
+++ b/src/KubikonPlayer/KubikonPlayer.jsx
@@ -9,6 +9,8 @@ import { MultiplayerSync } from '../engine/MultiplayerSync';
import { REALTIME_WS } from '../api/API';
import GameHud from '../editor-shared/GameHud';
import GuiOverlay from '../editor-shared/GuiOverlay';
+import ModalOverlay from '../editor-shared/ModalOverlay';
+import SkinShopOverlay from '../editor-shared/SkinShopOverlay';
import KubikonLeaderboard, { formatTimeMs } from '../components/KubikonLeaderboard/KubikonLeaderboard';
import KubikonPerfOverlay from '../components/KubikonPerfOverlay/KubikonPerfOverlay';
import Hotbar from '../editor-shared/Hotbar';
@@ -1382,6 +1384,10 @@ const KubikonPlayer = () => {
rt.routeGlobalEvent('guiClick', { id: gid });
}}
/>
+ {/* Задача 04: модал-overlay (затемнение + spotlight mask) */}
+
+ {/* Задача 07: встроенный магазин скинов (клавиша B / API) */}
+
{/* Мобильное управление — на любых тач-устройствах,
и в портрете и в ландшафте (ранее был блок portrait,
убрали по фидбэку — играть можно как угодно). */}
diff --git a/src/editor-shared/GameHud.jsx b/src/editor-shared/GameHud.jsx
index 140f6a0..cbefb2c 100644
--- a/src/editor-shared/GameHud.jsx
+++ b/src/editor-shared/GameHud.jsx
@@ -21,11 +21,16 @@ import Icon from './Icon';
*/
function _optsEqual(a, b) {
+ // Расширенный compare — учитываем все поля стилизации.
if (a === b) return true;
if (!a || !b) return false;
- return a.x === b.x && a.y === b.y && a.color === b.color && a.size === b.size;
+ const keys = ['x','y','color','size','textSize','bold','bg','border',
+ 'borderRadius','padding','w','h','textAlign','anchor'];
+ for (const k of keys) {
+ if (a[k] !== b[k]) return false;
+ }
+ return true;
}
-
const DEFAULT_LABEL_STYLE = {
fontSize: 18,
fontWeight: 700,
@@ -137,32 +142,59 @@ function GameHud({ visible, hudRef }) {
{otherIds.map((id, i) => {
const lbl = labels[id];
const o = lbl.opts || {};
- const hasPos = typeof o.x === 'number' || typeof o.y === 'number';
+ // Поддерживаем как старый формат opts (x/y в %, color, size),
+ // так и расширенный (bg, border, borderRadius, padding,
+ // w/h/textSize/bold/textAlign, x/y в пикселях или с '%').
+ const hasPercentXY = (typeof o.x === 'number' && o.x <= 100 && typeof o.y === 'number' && o.y <= 100)
+ && (o.bg === undefined && o.w === undefined && o.h === undefined);
+ const usePixelPos = (typeof o.x === 'number' && !hasPercentXY)
+ || typeof o.x === 'string';
const style = {
- ...DEFAULT_LABEL_STYLE,
- fontSize: o.size || DEFAULT_LABEL_STYLE.fontSize,
+ fontFamily: '"Roboto Condensed", system-ui, sans-serif',
+ fontWeight: o.bold ? 800 : DEFAULT_LABEL_STYLE.fontWeight,
+ fontSize: o.textSize || o.size || DEFAULT_LABEL_STYLE.fontSize,
color: o.color || DEFAULT_LABEL_STYLE.color,
- background: 'rgba(15,12,8,0.55)',
- padding: '4px 10px',
- borderRadius: 5,
- // длинные подписи переносятся и остаются по центру,
- // не вылезая за края экрана
- textAlign: 'center',
+ background: o.bg || 'rgba(15,12,8,0.55)',
+ padding: o.padding != null ? o.padding : '4px 10px',
+ borderRadius: o.borderRadius != null ? o.borderRadius : 5,
+ border: o.border || undefined,
+ textAlign: o.textAlign || 'center',
maxWidth: '70vw',
- whiteSpace: 'normal',
+ whiteSpace: 'pre-line',
wordBreak: 'break-word',
+ width: o.w != null ? o.w : undefined,
+ height: o.h != null ? o.h : undefined,
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: o.textAlign === 'left' ? 'flex-start' : 'center',
+ boxSizing: 'border-box',
};
- if (hasPos) {
+ if (hasPercentXY) {
return (
{lbl.text}
);
}
+ if (usePixelPos) {
+ // Якорь: 'center' — translate(-50%,-50%); по умолчанию top-left
+ const isCenter = o.anchor === 'center';
+ const leftVal = typeof o.x === 'string' ? o.x : `${o.x}px`;
+ const topVal = typeof o.y === 'string' ? o.y : `${o.y}px`;
+ return (
+ {lbl.text}
+ );
+ }
// Без позиции — стек в левом верхнем углу
return (
);
})()}
- {isText && (el.text != null) && (
-
- {el.text}
-
- )}
+ {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. */}
@@ -483,11 +552,30 @@ function GuiElement({ el, allElements, childrenMap, isPlaying, selectedId, onSel
*/
function layoutChildren(container, children) {
const layout = container && container.layout;
- if (layout !== 'vertical' && layout !== 'horizontal') return children;
+ 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').
+ // 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;
@@ -501,7 +589,7 @@ function layoutChildren(container, children) {
ny = pad - scrollY;
cursor += w + gap;
}
- // Якорь top-left — координаты считаются от левого-верхнего угла.
+ // Якорь top-left -- координаты считаются от левого-верхнего угла.
return { ...ch, x: nx, y: ny, anchor: 'top-left' };
});
}
@@ -609,22 +697,77 @@ 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}%`; transform = 'translate(0, 0)'; break;
- case 'top-right': left = `${100 - (el.x ?? 0)}%`; top = `${el.y ?? 0}%`; transform = 'translate(-100%, 0)'; break;
- case 'bottom-left': left = `${el.x ?? 0}%`; top = `${100 - (el.y ?? 0)}%`; transform = 'translate(0, -100%)'; break;
- case 'bottom-right': left = `${100 - (el.x ?? 0)}%`; top = `${100 - (el.y ?? 0)}%`; transform = 'translate(-100%, -100%)'; break;
+ 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}%`; transform = 'translate(-50%, -50%)'; break;
+ 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
@@ -632,14 +775,11 @@ function elementToStyle(el) {
: 'none',
borderRadius: (el.borderRadius || 0) + 'px',
boxSizing: 'border-box',
- // Тень: явный флаг shadow → мягкая drop-shadow; у кнопок —
- // лёгкая тень по умолчанию (как было). shadow=true усиливает.
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'),
- // Frame обрезает детей по своей границе (как ScreenGui в Roblox).
- // Для не-frame оставляем visible чтобы текст не клипался.
overflow: el.type === 'frame' ? 'hidden' : 'visible',
+ filter: brightness !== 1 ? `brightness(${brightness})` : undefined,
};
}
diff --git a/src/editor-shared/ModalOverlay.jsx b/src/editor-shared/ModalOverlay.jsx
new file mode 100644
index 0000000..8141cd2
--- /dev/null
+++ b/src/editor-shared/ModalOverlay.jsx
@@ -0,0 +1,101 @@
+/**
+ * ModalOverlay — рендерит затемнение модальной сцены.
+ * Задача 04. Подписан на ModalManager.setOnChange — получает state.
+ *
+ * Архитектура:
+ * - Слой ПОД GUI-overlay (z-index ниже GuiOverlay) но НАД Babylon-канвасом.
+ * - Если target='screen' — слой поверх ВСЕГО (включая GUI). z-index выше.
+ * - Spotlights через CSS mask-image: radial-gradient(...) — вырезает «дырки».
+ * - pointer-events: auto когда модал открыт (перехватывает клики кроме GUI).
+ */
+
+import React, { useEffect, useState } from 'react';
+
+export default function ModalOverlay({ scene }) {
+ const [state, setState] = useState(null);
+
+ // Поллинг — надёжнее чем setOnChange callback, который может перетереться
+ // или не вызваться если scene изменился на следующем кадре.
+ useEffect(() => {
+ if (!scene?.modalManager) return;
+ let cancelled = false;
+ const tick = () => {
+ if (cancelled) return;
+ const s = scene.modalManager.getState?.();
+ // Снимок shallow-clone — иначе React не увидит изменение
+ setState(s ? {
+ id: s.id,
+ fadePhase: s.fadePhase,
+ currentAlpha: s.currentAlpha,
+ opts: s.opts,
+ spotlightScreens: s.spotlightScreens,
+ } : null);
+ requestAnimationFrame(tick);
+ };
+ tick();
+ return () => { cancelled = true; };
+ }, [scene]);
+
+ if (!state || state.fadePhase === 'closed') return null;
+ if (state.currentAlpha <= 0.001) return null;
+ console.log('[ModalOverlay] RENDERING alpha=', state.currentAlpha.toFixed(2), 'phase=', state.fadePhase, 'target=', state.opts?.target);
+
+ const opts = state.opts;
+ const isScreen = opts.target === 'screen';
+ const color = opts.darkenColor || '#000000';
+ const alpha = Math.max(0, Math.min(1, state.currentAlpha));
+ // RGBA bg
+ const bg = _hexToRgba(color, alpha);
+
+ // mask-image для spotlights (только для target='scene' — на 'screen' нет смысла)
+ let maskStyle = {};
+ if (!isScreen && Array.isArray(state.spotlightScreens) && state.spotlightScreens.length) {
+ const softEdge = opts.spotlightSoftEdge ?? 40;
+ const gradients = state.spotlightScreens.map(s => {
+ const inner = Math.max(0, s.r - softEdge);
+ const outer = s.r;
+ // mask-image: внутри круга — transparent (вырезаем), снаружи — black (показываем затемнение)
+ return `radial-gradient(circle at ${s.x.toFixed(0)}px ${s.y.toFixed(0)}px, transparent ${inner}px, black ${outer}px)`;
+ });
+ maskStyle = {
+ WebkitMaskImage: gradients.join(', '),
+ maskImage: gradients.join(', '),
+ WebkitMaskComposite: 'source-in',
+ maskComposite: 'intersect',
+ };
+ }
+
+ // ВАЖНО pointer-events: none — иначе overlay перехватывает клики и кнопки модала не работают.
+ // Затемнение — это просто визуальный фильтр, blockInput реализован в PlayerController.
+ // zIndex:
+ // target='scene' → 24 (под GuiOverlay zIndex=25 чтобы GUI был ВИДЕН поверх затемнения)
+ // target='screen' → 60 (поверх GUI — закрывает ВСЁ)
+ // Для 'screen' GUI модала всё равно поверх (GuiOverlay zIndex=25, наш ScreenOverlay 60,
+ // GUI элементы модала рендерятся в GuiOverlay — поэтому надо ставить их в отдельный
+ // слой ВЫШЕ overlay). Простой фикс: для screen ставим overlay на 24 тоже.
+ const zIdx = 24;
+ return (
+
+ );
+}
+
+function _hexToRgba(hex, a) {
+ if (typeof hex !== 'string' || !hex.startsWith('#')) return `rgba(0,0,0,${a})`;
+ let h = hex.slice(1);
+ if (h.length === 3) h = h.split('').map(c => c + c).join('');
+ if (h.length !== 6) return `rgba(0,0,0,${a})`;
+ 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},${a})`;
+}
diff --git a/src/editor-shared/SkinShopOverlay.jsx b/src/editor-shared/SkinShopOverlay.jsx
new file mode 100644
index 0000000..df18ea8
--- /dev/null
+++ b/src/editor-shared/SkinShopOverlay.jsx
@@ -0,0 +1,294 @@
+/**
+ * SkinShopOverlay — встроенный магазин скинов игрока (задача 07).
+ *
+ * Готовый GUI-кит: полноэкранная витрина карточек скинов. Открывается
+ * клавишей B в Play или через game.player.openSkinShop(). Логика покупки
+ * (списание локальных рубликов проекта, unlock, setSkin) живёт в GameRuntime;
+ * этот компонент только рендерит состояние и шлёт намерение «купить/надеть».
+ *
+ * Подписка на состояние — rAF-поллинг scene.getSkinShopState() (как ModalOverlay):
+ * { open, rev, data: { all:[{slug,name,kind,category,price}], unlocked:[slug],
+ * current, coins, shopVisible } }
+ *
+ * Превью скина — цветная плашка по категории + крупная самописная SVG-иконка
+ * (правило проекта: без эмодзи в UI). Категории: human/animal/food/vehicle/robot.
+ */
+
+import React, { useEffect, useState, useMemo } from 'react';
+
+// Палитра градиентов по категории — чтобы витрина была живой и читаемой.
+const CAT_THEME = {
+ human: { from: '#3b6cff', to: '#1e2da5', label: 'Люди' },
+ animal: { from: '#46b34a', to: '#1f6b2a', label: 'Животные' },
+ food: { from: '#ff8a3d', to: '#c2410c', label: 'Еда' },
+ vehicle: { from: '#9b59ff', to: '#5b21b6', label: 'Транспорт' },
+ robot: { from: '#22c6d6', to: '#0e7490', label: 'Роботы' },
+ custom: { from: '#fbbf24', to: '#b45309', label: 'Свои' },
+};
+const CAT_ORDER = ['all', 'human', 'animal', 'food', 'vehicle', 'robot', 'custom'];
+
+// Самописные SVG-иконки категорий (viewBox 24×24, обводка currentColor).
+function CatGlyph({ cat, size = 46 }) {
+ const st = { fill: 'none', stroke: 'currentColor', strokeWidth: 1.6, strokeLinecap: 'round', strokeLinejoin: 'round' };
+ let body;
+ switch (cat) {
+ case 'human':
+ body = (<>>);
+ break;
+ case 'animal': // мордочка зверя с ушами
+ body = (<>>);
+ break;
+ case 'food': // пончик
+ body = (<>>);
+ break;
+ case 'vehicle': // машинка
+ body = (<>>);
+ break;
+ case 'robot': // голова робота
+ body = (<>>);
+ break;
+ default: // custom — звезда
+ body = ();
+ }
+ return ();
+}
+
+// Монета-рублик (для баланса/цены).
+function CoinIcon({ size = 16 }) {
+ return (
+
+ );
+}
+
+export default function SkinShopOverlay({ scene }) {
+ const [snap, setSnap] = useState(null);
+ const [cat, setCat] = useState('all');
+
+ // rAF-поллинг состояния магазина из сцены.
+ useEffect(() => {
+ if (!scene?.getSkinShopState) return;
+ let cancelled = false;
+ let lastRev = -1;
+ const tick = () => {
+ if (cancelled) return;
+ const s = scene.getSkinShopState?.();
+ if (s && s.rev !== lastRev) {
+ lastRev = s.rev;
+ setSnap({
+ open: s.open,
+ data: s.data,
+ buyResult: s.buyResult,
+ });
+ } else if (!s && lastRev !== -1) {
+ lastRev = -1;
+ setSnap(null);
+ }
+ requestAnimationFrame(tick);
+ };
+ tick();
+ return () => { cancelled = true; };
+ }, [scene]);
+
+ const data = snap?.data || null;
+
+ // Список скинов с категориями (фильтрованный).
+ const skins = useMemo(() => {
+ const all = (data?.all) || [];
+ if (cat === 'all') return all;
+ return all.filter(s => (s.category || 'human') === cat);
+ }, [data, cat]);
+
+ // Какие категории реально есть — для табов.
+ const cats = useMemo(() => {
+ const present = new Set((data?.all || []).map(s => s.category || 'human'));
+ return CAT_ORDER.filter(c => c === 'all' || present.has(c));
+ }, [data]);
+
+ if (!snap || !snap.open || !data) return null;
+
+ const unlocked = new Set(data.unlocked || []);
+ const current = data.current;
+ const coins = data.coins || 0;
+
+ const close = () => { try { scene._closeSkinShop?.(); } catch (e) {} };
+ const onCardClick = (s) => {
+ const owned = unlocked.has(s.slug);
+ const price = s.price || 0;
+ if (!owned && coins < price) return; // не хватает — карточка покажет это
+ try { scene.requestBuySkin?.(s.slug, price); } catch (e) {}
+ };
+
+ return (
+
+
e.stopPropagation()}
+ style={{
+ width: 'min(880px, 92vw)', maxHeight: '86vh',
+ background: 'linear-gradient(160deg, #161b2e 0%, #0c1020 100%)',
+ border: '2px solid #2b3a66', borderRadius: 20,
+ boxShadow: '0 24px 60px rgba(0,0,0,0.55)',
+ display: 'flex', flexDirection: 'column', overflow: 'hidden',
+ }}
+ >
+ {/* Шапка */}
+
+
+ Магазин скинов
+
+
+ {/* Баланс */}
+
+ {coins}
+
+ {/* Закрыть */}
+
+
+
+ {/* Табы категорий */}
+
+ {cats.map(c => {
+ const active = c === cat;
+ const label = c === 'all' ? 'Все' : (CAT_THEME[c]?.label || c);
+ return (
+
+ );
+ })}
+
+
+ {/* Сетка карточек */}
+
+ {skins.map(s => {
+ const theme = CAT_THEME[s.category] || CAT_THEME.human;
+ const owned = unlocked.has(s.slug);
+ const isActive = current === s.slug;
+ const price = s.price || 0;
+ const canAfford = owned || coins >= price;
+ return (
+
onCardClick(s)}
+ style={{
+ borderRadius: 16, overflow: 'hidden', cursor: canAfford ? 'pointer' : 'not-allowed',
+ border: isActive ? '3px solid #22ff88' : '2px solid rgba(255,255,255,0.10)',
+ background: 'rgba(255,255,255,0.04)',
+ opacity: canAfford ? 1 : 0.55,
+ transition: 'transform 0.1s, border-color 0.15s',
+ position: 'relative',
+ }}
+ onMouseEnter={(e) => { if (canAfford) e.currentTarget.style.transform = 'translateY(-3px)'; }}
+ onMouseLeave={(e) => { e.currentTarget.style.transform = 'none'; }}
+ >
+ {/* Превью-плашка с иконкой категории */}
+
+
+
+ {/* Бейдж активного/купленного */}
+ {isActive && (
+
Надет
+ )}
+ {!isActive && owned && (
+
Куплено
+ )}
+ {/* Низ карточки: имя + цена/статус */}
+
+
{s.name || s.slug}
+
+ {isActive ? (
+ Активен
+ ) : owned ? (
+ Нажми, чтобы надеть
+ ) : price === 0 ? (
+ Бесплатно
+ ) : (
+
+ {price}
+
+ )}
+
+
+
+ );
+ })}
+ {skins.length === 0 && (
+
+ В этой категории пока нет скинов
+
+ )}
+
+
+ {/* Подвал-подсказка */}
+
+ Нажми B или Esc, чтобы закрыть
+
+
+
+ );
+}
+
+function badgeStyle(bg, fg) {
+ return {
+ position: 'absolute', top: 8, right: 8,
+ background: bg, color: fg,
+ fontSize: 11, fontWeight: 900, padding: '3px 8px', borderRadius: 999,
+ boxShadow: '0 2px 6px rgba(0,0,0,0.4)',
+ };
+}
diff --git a/src/engine/BabylonScene.js b/src/engine/BabylonScene.js
index 6db84d2..80c98f3 100644
--- a/src/engine/BabylonScene.js
+++ b/src/engine/BabylonScene.js
@@ -53,9 +53,11 @@ import { placeVoxelTree, TREE_TYPES } from './VoxelTreeBuilder';
import { UserModelManager, parseUserModelId, USER_MODEL_PREFIX } from './UserModelManager';
import { ModelManager } from './ModelManager';
import { PrimitiveManager } from './PrimitiveManager';
+import { BillboardUiManager } from './BillboardUiManager';
import { getPrimitiveType } from './PrimitiveTypes';
import { FolderManager } from './FolderManager';
import { GuiManager } from './GuiManager';
+import { ModalManager } from './ModalManager';
import { InventoryManager } from './InventoryManager';
import { WeaponSystem } from './WeaponSystem';
import { ZombieManager } from './ZombieManager';
@@ -1266,8 +1268,16 @@ export class BabylonScene {
// Ссылка на обёртку BabylonScene — нужна для эмиттеров частиц
// (createEmitterParticles живёт на обёртке).
this.primitiveManager.scene3d = this;
+ // BillboardUiManager — отдельный модуль, рисует GUI на DynamicTexture
+ // для билбордов. Нужен PrimitiveManager-у чтобы при создании billboard
+ // (type='billboard') сразу применить текстуру с дефолтным пресетом.
+ this.billboardUiManager = new BillboardUiManager(this.scene);
+ this.primitiveManager.billboardUiManager = this.billboardUiManager;
this.folderManager = new FolderManager(this.blockManager, this.modelManager, this.primitiveManager);
this.guiManager = new GuiManager();
+ this.modalManager = new ModalManager();
+ this.modalManager.attachScene(this);
+ this.modalManager.attachGui(this.guiManager);
this.inventory = new InventoryManager();
this.physics = new PhysicsAABB(this.blockManager);
// Сразу синхронизируем границу пола с текущим размером мира,
@@ -1474,6 +1484,10 @@ export class BabylonScene {
}
}
}
+ // Задача 04: modalManager.tick — независимо от runtime'а
+ if (this._isPlaying && this.modalManager?.tick) {
+ try { this.modalManager.tick(dt); } catch (e) {}
+ }
// Tick пользовательских скриптов: в Play-режиме или в solo-debug
if (this.gameRuntime && (this._isPlaying || this.gameRuntime.isSolo?.())) {
this.gameRuntime.tick(dt);
@@ -5266,6 +5280,11 @@ export class BabylonScene {
// Создаём PlayerController и стартуем
this.player = new PlayerController(this.scene, this.canvas, this.physics, this);
this.player.setModelType(this._playerModelType);
+ // Задача 04: модал нуждается в player и audio для блока ввода/freeze камеры/duck
+ try {
+ this.modalManager?.attachPlayer?.(this.player);
+ this.modalManager?.attachAudio?.(this.audioManager);
+ } catch (e) {}
this.player._jumpPowerMul = this._jumpPowerMul ?? 1;
// Применяем дефолтную камеру если задана в сцене
if (this._defaultCameraMode && this._defaultCameraMode !== this.player._cameraMode) {
@@ -5274,6 +5293,18 @@ export class BabylonScene {
// На тач-устройствах отключаем pointer-lock и mouse-камеру
if (this._touchMode) this.player.setTouchMode(true);
this.player.setOnExitRequest(() => {
+ // Задача 07: магазин скинов открыт → Esc закрывает его (приоритет выше модала).
+ if (this._skinShop?.open) {
+ this._closeSkinShop();
+ return;
+ }
+ // Задача 04: если открыт модал — первый Esc закрывает его,
+ // второй Esc уже выходит из Play. Так юзер не теряет состояние игры
+ // случайно при попытке скрыть модал.
+ if (this.modalManager?.isOpen?.()) {
+ this.modalManager.close();
+ return;
+ }
this.exitPlayMode();
if (this._onPlayChange) this._onPlayChange(false);
});
@@ -5285,6 +5316,7 @@ export class BabylonScene {
// Async PlayerController.start даёт нам тик чтобы дать ему собрать сцену,
// поэтому скрипты стартуем в следующем кадре.
this.gameRuntime = new GameRuntime(this);
+ try { this.modalManager?.attachRuntime?.(this.gameRuntime); } catch (e) {}
// Аудио-менеджер (GD-музыка/SFX) — создаётся вместе с runtime.
// ВАЖНО: поле называется gameAudioManager чтобы не путать со штатным
// this.audioManager (AudioManager — ambient/music для всех проектов).
@@ -5778,6 +5810,7 @@ export class BabylonScene {
if (!sc) return false;
if (!this.gameRuntime) {
this.gameRuntime = new GameRuntime(this);
+ try { this.modalManager?.attachRuntime?.(this.gameRuntime); } catch (e) {}
if (!this.gameAudioManager) {
this.gameAudioManager = new GameAudioManager();
}
@@ -6024,6 +6057,71 @@ export class BabylonScene {
return this.guiManager ? this.guiManager.getAll() : [];
}
+ // ===== Задача 07: встроенный магазин скинов (React-оверлей) =====
+ // Состояние держим тут; React-компонент SkinShopOverlay поллит getSkinShopState().
+ _ensureSkinShopState() {
+ if (!this._skinShop) {
+ this._skinShop = {
+ open: false,
+ rev: 0, // ревизия — React видит изменение
+ data: { all: [], unlocked: [], current: null, coins: 0, manifestFull: [] },
+ buyResult: null, // последний результат покупки {slug, ok, reason}
+ };
+ }
+ return this._skinShop;
+ }
+ /** Снимок состояния магазина для React (поллинг через rAF). */
+ getSkinShopState() { return this._skinShop || null; }
+ /** Открыть/закрыть магазин (из скрипта или клавиши B). */
+ _openSkinShop() {
+ const s = this._ensureSkinShopState();
+ // Отключён в проекте? (скрипт всё равно может открыть через API —
+ // shopVisible:false запрещает только клавишу B, см. toggleSkinShop).
+ s.open = true; s.rev++;
+ }
+ _closeSkinShop() {
+ const s = this._ensureSkinShopState();
+ s.open = false; s.rev++;
+ }
+ toggleSkinShop() {
+ const s = this._ensureSkinShopState();
+ if (s.open) { this._closeSkinShop(); return; }
+ // Клавиша B открывает магазин только если он включён в проекте.
+ if (this._skinsConfig && this._skinsConfig.shopVisible === false) return;
+ this._openSkinShop();
+ }
+ /** Данные скинов от GameRuntime (манифест + unlocked + coins). */
+ _setSkinShopData(data) {
+ const s = this._ensureSkinShopState();
+ s.data = { ...s.data, ...data };
+ s.rev++;
+ }
+ _onSkinBuyResult(res) {
+ const s = this._ensureSkinShopState();
+ s.buyResult = { ...res, t: (this.scene?.getEngine?.()?.getFps?.() || 0) };
+ s.rev++;
+ }
+ /** Намерение купить/надеть скин — шлём в runtime (он списывает рублики). */
+ requestBuySkin(slug, price) {
+ const rt = this.gameRuntime;
+ if (!rt) return;
+ try { rt._handleCommand?.(null, 'player.buySkin', { slug, price: price || 0 }); } catch (e) {}
+ }
+ /** Данные из загруженных проектом кастомных .glb-скинов (data-URL по slug). */
+ getAssetDataUrl(slug) {
+ try {
+ // Кастомные скины хранят dataUrl прямо в _skinsConfig.customGlbs.
+ const list = this._skinsConfig?.customGlbs || [];
+ const rec = list.find(g => g && g.slug === slug);
+ if (rec && rec.dataUrl) return rec.dataUrl;
+ } catch (e) {}
+ return null;
+ }
+ _onPlayerSkinChanged(slug) {
+ const s = this._ensureSkinShopState();
+ if (s.data) { s.data.current = slug; s.rev++; }
+ }
+
// ===== Библиотека пользовательских картинок (этап 3.6) =====
/** Список картинок проекта [{id, name, dataUrl}]. */
@@ -6697,6 +6795,13 @@ export class BabylonScene {
inventory: this.inventory ? this.inventory.serialize() : null,
spawnPoint: { ...this._spawnPoint },
playerModelType: this._playerModelType,
+ skins: this._skinsConfig ? {
+ default: this._skinsConfig.default || null,
+ unlocked: this._skinsConfig.unlocked || [],
+ shopVisible: this._skinsConfig.shopVisible !== false,
+ coins: this._skinsConfig.coins || 0,
+ customGlbs: this._skinsConfig.customGlbs || [],
+ } : undefined,
worldSize: this._worldHalf * 2,
floorEnabled: this._floorEnabled !== false,
jumpPowerMul: this._jumpPowerMul ?? 1,
@@ -7135,6 +7240,24 @@ export class BabylonScene {
this._playerModelType = pmt;
}
}
+ // Задача 07: конфиг скинов проекта { default, unlocked, shopVisible, coins, customGlbs }.
+ if (state.scene.skins && typeof state.scene.skins === 'object') {
+ this._skinsConfig = {
+ default: state.scene.skins.default || null,
+ unlocked: Array.isArray(state.scene.skins.unlocked) ? state.scene.skins.unlocked.slice() : [],
+ shopVisible: state.scene.skins.shopVisible !== false,
+ coins: Number.isFinite(state.scene.skins.coins) ? state.scene.skins.coins : 0,
+ customGlbs: Array.isArray(state.scene.skins.customGlbs) ? state.scene.skins.customGlbs.slice() : [],
+ };
+ // Стартовый скин из skins.default имеет приоритет над playerModelType.
+ if (this._skinsConfig.default) {
+ const d = this._skinsConfig.default;
+ this._playerModelType = d.startsWith('character-') || d.startsWith('skin_') || d.startsWith('customskin:')
+ ? d : ('skin_' + d);
+ }
+ } else {
+ this._skinsConfig = null;
+ }
// Пользовательские скрипты
if (Array.isArray(state.scene.scripts)) {
this._scripts = state.scene.scripts
@@ -7171,6 +7294,8 @@ export class BabylonScene {
exitPlayMode() {
if (!this._isPlaying) return;
this._isPlaying = false;
+ // Задача 04: закрываем любой активный модал чтобы не «висел» при стопе
+ try { this.modalManager?._instantClose?.(); } catch (e) {}
// Сбрасываем таймер прохождения
this._timerRunning = false;
this._timerStartedAt = null;
diff --git a/src/engine/BillboardUiManager.js b/src/engine/BillboardUiManager.js
new file mode 100644
index 0000000..0eb5e17
--- /dev/null
+++ b/src/engine/BillboardUiManager.js
@@ -0,0 +1,698 @@
+/**
+ * BillboardUiManager — управление 3D-табличками с GUI (BillboardGui в Roblox).
+ *
+ * Каждая табличка — это plane-mesh с натянутой DynamicTexture, на которой
+ * с помощью обычного Canvas 2D API рисуется содержимое: градиентный фон,
+ * иконка, заголовок, подзаголовок, кнопка цены.
+ *
+ * Поддерживает 4 пресета (template):
+ * - 'shop-item' — иконка слева, заголовок, "1 > 2", кнопка цены справа
+ * - 'shop-purchase' — иконка + название + цена в рубликах
+ * - 'banner' — крупная плашка с одним текстом
+ * - 'sign' — простой указатель с текстом
+ *
+ * Режимы ориентации:
+ * - 'camera' — всегда смотрит на камеру (BillboardMode.BILLBOARDMODE_ALL)
+ * - 'fixed' — фиксированная ориентация (используется rotationY mesh-а)
+ *
+ * Клики:
+ * - на mesh-е ставим pickable=true
+ * - в _handlePick ловим точку пересечения, переводим в UV,
+ * ищем под этой точкой кнопку и эмитим событие
+ *
+ * State хранится в PrimitiveManager.instances[id].billboard:
+ * {
+ * template: 'shop-item',
+ * face: 'camera',
+ * content: { icon, title, sub, price, gradient: [from, to] }
+ * // или для custom-режима — elements: [...]
+ * onClickHandlers: { 'buy': fn } // только в Play-режиме
+ * }
+ */
+import {
+ DynamicTexture, StandardMaterial, Color3, Mesh, Texture,
+} from '@babylonjs/core';
+
+// Размер текстуры таблички (UV pixels). Чем больше — тем чётче, но больше VRAM.
+// 512×320 даёт нормальное качество на расстоянии 2-10 метров.
+const TEXTURE_W = 512;
+const TEXTURE_H = 320;
+
+// Координаты кнопки в shop-item пресете (для hit-теста кликов).
+// Совпадают с тем что рисуется в _renderShopItem (cx, cy, cw, ch).
+const SHOP_ITEM_BUTTON = { x: 332, y: 200, w: 160, h: 90 };
+
+// Описания доступных иконок (key → emoji-аналог для Canvas).
+// На фронте мы не имеем доступа к специализированным icon-fonts,
+// поэтому используем простые символы рисуемые крупно. Можно расширить
+// до полноценной библиотеки PNG-иконок позже.
+const ICONS = {
+ hammer: '🔨',
+ saw: '🪚',
+ drop: '💧',
+ seed: '🌱',
+ cube: '🧊',
+ coin: '💰',
+ home: '🏠',
+ rocket: '🚀',
+ sprinkler: '⛲',
+ sparkle: '✨',
+ star: '⭐',
+ bag: '🎒',
+ diamond: '💎',
+ fire: '🔥',
+ lightning: '⚡',
+ heart: '❤',
+ key: '🔑',
+ shield: '🛡',
+};
+
+export class BillboardUiManager {
+ constructor(scene) {
+ this.scene = scene;
+ // Колбэки от пользовательских скриптов: key=`${id}:${buttonId}` → fn
+ this._clickHandlers = new Map();
+ }
+
+ /**
+ * Применить к существующему mesh-у настройки билборда:
+ * — натянуть DynamicTexture, нарисовать контент, выставить billboardMode.
+ *
+ * data — объект из PrimitiveManager.instances[id], содержит уже mesh, sx, sy, sz.
+ * billboardOpts — { template, face, content, elements }
+ */
+ applyToMesh(data, billboardOpts = {}) {
+ const mesh = data.mesh;
+ if (!mesh) return;
+
+ const template = billboardOpts.template || 'shop-item';
+ const face = billboardOpts.face || 'camera';
+ const content = billboardOpts.content || this._defaultContent(template);
+
+ // Создаём DynamicTexture один раз и кешируем на mesh.
+ let dyn = mesh.metadata?._billboardTexture;
+ if (!dyn) {
+ dyn = new DynamicTexture(
+ `bb_tex_${data.id}`,
+ { width: TEXTURE_W, height: TEXTURE_H },
+ this.scene,
+ false /* generateMipMaps */
+ );
+ dyn.hasAlpha = true;
+ // Новый StandardMaterial с этой текстурой как diffuseTexture+emissiveTexture
+ // (emissive чтобы светилось и без освещения, как UI).
+ const mat = new StandardMaterial(`bb_mat_${data.id}`, this.scene);
+ mat.diffuseTexture = dyn;
+ mat.emissiveTexture = dyn;
+ mat.emissiveColor = new Color3(1, 1, 1);
+ mat.specularColor = new Color3(0, 0, 0);
+ mat.useAlphaFromDiffuseTexture = true;
+ mat.backFaceCulling = false;
+ mesh.material = mat;
+ if (!mesh.metadata) mesh.metadata = {};
+ mesh.metadata._billboardTexture = dyn;
+ mesh.metadata._billboardMaterial = mat;
+ }
+
+ // Ориентация. Babylon-quirk: CreatePlane FRONT в -Z (left-handed),
+ // юзер ставит билборд так чтобы лицо смотрело в +Z мира → +π.
+ mesh.billboardMode = Mesh.BILLBOARDMODE_NONE;
+ if (mesh.metadata._billboardLookObs) {
+ this.scene.onBeforeRenderObservable.remove(mesh.metadata._billboardLookObs);
+ mesh.metadata._billboardLookObs = null;
+ }
+ if (face === 'camera') {
+ // Ручной look-at — каждый кадр поворачиваем front к камере.
+ const obs = this.scene.onBeforeRenderObservable.add(() => {
+ if (mesh.isDisposed()) return;
+ const cam = this.scene.activeCamera;
+ if (!cam) return;
+ const dx = cam.position.x - mesh.position.x;
+ const dz = cam.position.z - mesh.position.z;
+ mesh.rotation.y = Math.atan2(dx, dz) + Math.PI;
+ });
+ mesh.metadata._billboardLookObs = obs;
+ } else {
+ // Фиксированная ориентация: front в +Z + пользовательский rotationY.
+ const userY = Number.isFinite(billboardOpts.rotationY) ? billboardOpts.rotationY : 0;
+ mesh.rotation.y = Math.PI + userY;
+ // Двусторонняя табличка: рамка стоит, но при взгляде сзади
+ // флипаем UV таблички чтобы текст не был зеркальным.
+ const mat = mesh.material;
+ if (mat) {
+ // Включаем рендер обеих сторон (back-face визуализируется).
+ mat.backFaceCulling = false;
+ }
+ const obs = this.scene.onBeforeRenderObservable.add(() => {
+ if (mesh.isDisposed()) return;
+ const cam = this.scene.activeCamera;
+ if (!cam) return;
+ // Локальная нормаль FRONT plane = +Z. Поворот mesh.rotation.y
+ // переводит её в world: normalWorld = (sin(ry), 0, cos(ry)).
+ const ry = mesh.rotation.y;
+ const nWx = Math.sin(ry);
+ const nWz = Math.cos(ry);
+ // Вектор от mesh к камере
+ const vx = cam.position.x - mesh.position.x;
+ const vz = cam.position.z - mesh.position.z;
+ // Скалярное произведение: >0 — камера смотрит на FRONT,
+ // <0 — на BACK (зеркальная UV). Для BACK инвертируем uScale.
+ const dot = nWx * vx + nWz * vz;
+ const dyn = mesh.metadata?._billboardTexture;
+ if (dyn) {
+ // dot > 0 — камера со стороны FRONT-нормали → flip
+ // dot < 0 — камера сзади → нормально
+ if (dot > 0) {
+ if (dyn.uScale !== -1) { dyn.uScale = -1; dyn.uOffset = 1; }
+ } else {
+ if (dyn.uScale !== 1) { dyn.uScale = 1; dyn.uOffset = 0; }
+ }
+ }
+ });
+ mesh.metadata._billboardLookObs = obs;
+ }
+ mesh.scaling.x = Math.abs(mesh.scaling.x || 1);
+ mesh.metadata._billboardMirrorX = false;
+
+ // Сохраняем state в data для сериализации и для hit-теста кликов.
+ data.billboard = {
+ template,
+ face,
+ content: { ...content },
+ elements: billboardOpts.elements || null,
+ };
+
+ dyn._kubikonMirrorX = mesh.metadata._billboardMirrorX === true;
+ dyn._kubikonOwnerMesh = mesh;
+ this._render(dyn, template, content, billboardOpts.elements);
+ }
+
+ /**
+ * Обновить контент билборда (без пересоздания текстуры).
+ * Две формы:
+ * 1) update(data, { sub: '2 > 3', price: '$20,000' }) — patch content
+ * 2) update(data, 'buy', { text: '$15,000' }) — patch конкретного элемента
+ * по id (для elements-режима ИЛИ для known-id пресета: 'buy', 'title',
+ * 'sub', 'price', 'icon', 'gradient' маппятся на поля content).
+ */
+ update(data, elementIdOrPatch, patchMaybe) {
+ if (!data.billboard) return;
+ // Форма 2: 3 аргумента (data, elementId, patch)
+ if (typeof elementIdOrPatch === 'string' && typeof patchMaybe === 'object' && patchMaybe !== null) {
+ const elId = elementIdOrPatch;
+ const patch = patchMaybe;
+ // Кастомные elements: ищем элемент по id и обновляем его поля.
+ if (Array.isArray(data.billboard.elements)) {
+ data.billboard.elements = data.billboard.elements.map(el =>
+ el && el.id === elId ? { ...el, ...patch } : el);
+ } else {
+ // Пресет: мапим известные elementId → ключ content.
+ // 'buy' → content.price; 'title'/'sub'/'icon'/'gradient' → одноимённый ключ.
+ const c = { ...(data.billboard.content || {}) };
+ if (elId === 'buy' && 'text' in patch) {
+ c.price = patch.text;
+ } else if (elId in c) {
+ // Если patch имеет text — кладём в content[elId], иначе мерджим поля.
+ if ('text' in patch) c[elId] = patch.text;
+ else Object.assign(c, patch);
+ } else {
+ Object.assign(c, patch);
+ }
+ data.billboard.content = c;
+ }
+ } else if (typeof elementIdOrPatch === 'object' && elementIdOrPatch !== null) {
+ // Форма 1: 2 аргумента (data, patchContent)
+ data.billboard.content = { ...data.billboard.content, ...elementIdOrPatch };
+ } else {
+ return;
+ }
+ const dyn = data.mesh?.metadata?._billboardTexture;
+ if (dyn) {
+ this._render(dyn, data.billboard.template, data.billboard.content, data.billboard.elements);
+ }
+ }
+
+ /** Подписаться на клик по кнопке билборда (только в Play-режиме). */
+ onClick(data, buttonId, fn) {
+ const key = `${data.id}:${buttonId}`;
+ this._clickHandlers.set(key, fn);
+ }
+
+ /** Снять все подписки (вызывается при остановке Play). */
+ clearHandlers() {
+ this._clickHandlers.clear();
+ }
+
+ /**
+ * Проверить клик по точке UV: вернуть buttonId или null.
+ * UV точка — нормализованная (0..1).
+ */
+ pickButtonAt(data, uvX, uvY) {
+ if (!data.billboard) return null;
+ // Если текстура в данный момент отзеркалена (face=fixed, смотрим
+ // на back-side), uScale=-1 — инвертим UV.x чтобы попасть в правильный
+ // canvas-пиксель.
+ const dyn = data.mesh?.metadata?._billboardTexture;
+ const flipped = dyn && dyn.uScale === -1;
+ const uX = flipped ? (1 - uvX) : uvX;
+ const px = uX * TEXTURE_W;
+ const py = (1 - uvY) * TEXTURE_H;
+ // Кастомные elements имеют приоритет (если заданы)
+ if (data.billboard.elements) {
+ return this._hitTestElements(data.billboard.elements, px, py);
+ }
+ const tmpl = data.billboard.template;
+ if (tmpl === 'shop-item' || tmpl === 'shop-purchase') {
+ // Кнопка адаптивной ширины — пересчитываем её rect по тексту
+ // именно ЭТОЙ таблички (тем же _computeBuyRect, что и при рисовании).
+ const label = (data.billboard.content && data.billboard.content.price) || '$0';
+ let b = SHOP_ITEM_BUTTON;
+ try {
+ const measCtx = (dyn && dyn.getContext && dyn.getContext()) || null;
+ if (measCtx) b = this._computeBuyRect(measCtx, label, SHOP_ITEM_BUTTON);
+ } catch (e) { /* fallback на базовый rect */ }
+ if (px >= b.x && px <= b.x + b.w && py >= b.y && py <= b.y + b.h) {
+ return 'buy';
+ }
+ }
+ // banner и sign — кнопок нет, только текст.
+ return null;
+ }
+
+ /** Вызвать обработчик клика, если он подписан. */
+ fireClick(data, buttonId) {
+ const key = `${data.id}:${buttonId}`;
+ const fn = this._clickHandlers.get(key);
+ if (fn) {
+ try { fn(); } catch (e) { console.error('[Billboard onClick]', e); }
+ }
+ // Также пишем кнопку в "нажатом" виде на 100мс для UX-фидбека.
+ this._flashButton(data, buttonId);
+ }
+
+ _flashButton(data, buttonId) {
+ if (!data.billboard) return;
+ const dyn = data.mesh?.metadata?._billboardTexture;
+ if (!dyn) return;
+ // Перерисовываем pressed=true. ВАЖНО: используем СВЕЖИЙ content в callback'е
+ // (на момент 120мс content уже может быть обновлён через update — берём
+ // актуальный, иначе откатим к старому).
+ // Также гарантируем 1 flash на табличку — если предыдущий ещё крутится,
+ // отменяем его таймер.
+ if (data._flashTimer) {
+ clearTimeout(data._flashTimer);
+ data._flashTimer = null;
+ }
+ this._render(dyn, data.billboard.template, data.billboard.content,
+ data.billboard.elements, /* pressed */ buttonId);
+ data._flashTimer = setTimeout(() => {
+ data._flashTimer = null;
+ // Берём АКТУАЛЬНЫЕ data.billboard.content/elements — могли обновиться
+ // через game.billboard.update() ВО ВРЕМЯ flash'а.
+ if (data.mesh?.metadata?._billboardTexture === dyn && data.billboard) {
+ this._render(dyn, data.billboard.template, data.billboard.content,
+ data.billboard.elements, null);
+ }
+ }, 120);
+ }
+
+ _defaultContent(template) {
+ switch (template) {
+ case 'shop-item':
+ return { icon: 'hammer', title: 'Апгрейд', sub: '1 > 2',
+ price: '$100', gradient: ['#ff5a5a', '#ff8a3d'] };
+ case 'shop-purchase':
+ return { icon: 'seed', title: 'Набор семян', sub: 'x3',
+ price: '199 R', gradient: ['#3b82f6', '#0ea5e9'] };
+ case 'banner':
+ return { title: 'Удвоенный урожай в 17:00',
+ gradient: ['#7c3aed', '#a855f7'] };
+ case 'sign':
+ return { title: 'Сюда', gradient: ['#1f2937', '#374151'] };
+ default:
+ return { title: 'Табличка', gradient: ['#1f2937', '#374151'] };
+ }
+ }
+
+ /** Главная функция рендера — рисует контент на canvas DynamicTexture. */
+ _render(dyn, template, content, elements, pressedButtonId) {
+ const ctx = dyn.getContext();
+ ctx.save();
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
+ ctx.clearRect(0, 0, TEXTURE_W, TEXTURE_H);
+ if (elements && Array.isArray(elements)) {
+ this._renderElements(ctx, elements, pressedButtonId);
+ } else {
+ switch (template) {
+ case 'shop-item':
+ this._renderShopItem(ctx, content, pressedButtonId);
+ break;
+ case 'shop-purchase':
+ this._renderShopPurchase(ctx, content, pressedButtonId);
+ break;
+ case 'banner':
+ this._renderBanner(ctx, content);
+ break;
+ case 'sign':
+ this._renderSign(ctx, content);
+ break;
+ default:
+ this._renderBanner(ctx, content);
+ }
+ }
+ ctx.restore();
+ dyn.update(true);
+ }
+
+ /** Скруглённый прямоугольник + заливка градиентом + обводка. */
+ _roundedGradientRect(ctx, x, y, w, h, opts) {
+ const r = opts.radius ?? 24;
+ ctx.beginPath();
+ ctx.moveTo(x + r, y);
+ ctx.arcTo(x + w, y, x + w, y + h, r);
+ ctx.arcTo(x + w, y + h, x, y + h, r);
+ ctx.arcTo(x, y + h, x, y, r);
+ ctx.arcTo(x, y, x + w, y, r);
+ ctx.closePath();
+ const grad = ctx.createLinearGradient(x, y, x, y + h);
+ const [from, to] = opts.gradient || ['#333', '#111'];
+ grad.addColorStop(0, from);
+ grad.addColorStop(1, to);
+ ctx.fillStyle = grad;
+ ctx.fill();
+ if (opts.stroke) {
+ ctx.lineWidth = opts.stroke.width ?? 3;
+ ctx.strokeStyle = opts.stroke.color || '#000';
+ ctx.stroke();
+ }
+ }
+
+ /** Рендер пресета shop-item: иконка слева | title + sub | кнопка цены. */
+ _renderShopItem(ctx, content, pressedButtonId) {
+ // Главная плашка
+ this._roundedGradientRect(ctx, 8, 8, TEXTURE_W - 16, TEXTURE_H - 16, {
+ gradient: content.gradient || ['#ff5a5a', '#ff8a3d'],
+ radius: 28,
+ stroke: { color: '#0008', width: 4 },
+ });
+
+ // Иконка-слот: круг чуть темнее в левой части
+ ctx.beginPath();
+ ctx.arc(110, 130, 70, 0, Math.PI * 2);
+ ctx.fillStyle = 'rgba(0,0,0,0.18)';
+ ctx.fill();
+
+ // Сама иконка (emoji крупно)
+ const iconChar = ICONS[content.icon] || ICONS.cube;
+ ctx.font = 'bold 96px "Segoe UI Emoji", Arial';
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+ ctx.fillStyle = '#fff';
+ ctx.fillText(iconChar, 110, 132);
+
+ // Заголовок
+ ctx.font = 'bold 36px Arial, sans-serif';
+ ctx.textAlign = 'left';
+ ctx.textBaseline = 'top';
+ ctx.fillStyle = '#fff';
+ // Лёгкая тень
+ ctx.shadowColor = 'rgba(0,0,0,0.45)';
+ ctx.shadowBlur = 4;
+ ctx.shadowOffsetY = 2;
+ ctx.fillText(this._truncate(content.title || '', 18), 200, 50);
+ ctx.shadowColor = 'transparent';
+ ctx.shadowBlur = 0;
+ ctx.shadowOffsetY = 0;
+
+ // Подзаголовок "1 > 2" — зелёный, поменьше
+ if (content.sub) {
+ ctx.font = 'bold 28px Arial, sans-serif';
+ ctx.fillStyle = '#a7f3d0';
+ ctx.fillText(content.sub, 200, 105);
+ }
+
+ // Кнопка цены — жёлтый прямоугольник внизу справа.
+ // Ширина адаптивная: длинный текст («ПРИМЕРИТЬ»/«НАДЕТО») расширяет
+ // кнопку ВЛЕВО (правый край прижат к краю таблички), шрифт ужимается
+ // если даже расширенная кнопка узка. Фактический rect → _buyRect для hit-теста.
+ const pressed = pressedButtonId === 'buy';
+ const label = content.price || '$0';
+ const rect = this._computeBuyRect(ctx, label, SHOP_ITEM_BUTTON);
+ this._roundedGradientRect(ctx, rect.x, rect.y, rect.w, rect.h, {
+ gradient: pressed
+ ? ['#d97706', '#92400e']
+ : ['#fbbf24', '#f59e0b'],
+ radius: 16,
+ stroke: { color: '#000', width: 3 },
+ });
+ ctx.font = `bold ${rect.fontSize}px Arial, sans-serif`;
+ ctx.fillStyle = pressed ? '#fef3c7' : '#1c1917';
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+ ctx.fillText(label, rect.x + rect.w / 2, rect.y + rect.h / 2);
+ }
+
+ /**
+ * Подобрать прямоугольник кнопки «buy» под текст: правый край прижат к
+ * правому краю таблички (как в базовом SHOP_ITEM_BUTTON), ширина растёт
+ * влево под длину текста, шрифт ужимается если упёрлись в макс-ширину.
+ * Возвращает { x, y, w, h, fontSize }.
+ */
+ _computeBuyRect(ctx, label, base) {
+ const PAD = 36; // отступы текста по бокам
+ const MAX_W = 300; // макс ширина кнопки (не залезать на title)
+ const rightEdge = base.x + base.w; // правый край держим на месте
+ let fontSize = 36;
+ ctx.font = `bold ${fontSize}px Arial, sans-serif`;
+ let textW = ctx.measureText(label).width;
+ let w = Math.max(base.w, textW + PAD * 2);
+ if (w > MAX_W) {
+ // Ужимаем шрифт чтобы текст влез в MAX_W.
+ w = MAX_W;
+ const inner = MAX_W - PAD * 2;
+ while (fontSize > 20 && textW > inner) {
+ fontSize -= 2;
+ ctx.font = `bold ${fontSize}px Arial, sans-serif`;
+ textW = ctx.measureText(label).width;
+ }
+ }
+ return { x: rightEdge - w, y: base.y, w, h: base.h, fontSize };
+ }
+
+ /** Рендер пресета shop-purchase: похож на shop-item, но иконка крупнее и центрирована. */
+ _renderShopPurchase(ctx, content, pressedButtonId) {
+ this._roundedGradientRect(ctx, 8, 8, TEXTURE_W - 16, TEXTURE_H - 16, {
+ gradient: content.gradient || ['#3b82f6', '#0ea5e9'],
+ radius: 28,
+ stroke: { color: '#0008', width: 4 },
+ });
+
+ const iconChar = ICONS[content.icon] || ICONS.bag;
+ ctx.font = 'bold 110px "Segoe UI Emoji", Arial';
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+ ctx.fillStyle = '#fff';
+ ctx.fillText(iconChar, 110, 140);
+
+ ctx.font = 'bold 34px Arial, sans-serif';
+ ctx.textAlign = 'left';
+ ctx.textBaseline = 'top';
+ ctx.fillStyle = '#fff';
+ ctx.fillText(this._truncate(content.title || '', 16), 200, 50);
+
+ if (content.sub) {
+ ctx.font = 'bold 26px Arial, sans-serif';
+ ctx.fillStyle = '#dbeafe';
+ ctx.fillText(content.sub, 200, 100);
+ }
+
+ // Кнопка-цена (адаптивная ширина под длинный текст, см. _computeBuyRect).
+ const pressed = pressedButtonId === 'buy';
+ const label = content.price || '0 R';
+ const rect = this._computeBuyRect(ctx, label, SHOP_ITEM_BUTTON);
+ this._roundedGradientRect(ctx, rect.x, rect.y, rect.w, rect.h, {
+ gradient: pressed
+ ? ['#9333ea', '#6b21a8']
+ : ['#a855f7', '#7c3aed'],
+ radius: 16,
+ stroke: { color: '#000', width: 3 },
+ });
+ ctx.font = `bold ${rect.fontSize}px Arial, sans-serif`;
+ ctx.fillStyle = '#fff';
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+ ctx.fillText(label, rect.x + rect.w / 2, rect.y + rect.h / 2);
+ }
+
+ /** Рендер пресета banner: одна крупная фраза по центру. */
+ _renderBanner(ctx, content) {
+ this._roundedGradientRect(ctx, 8, 8, TEXTURE_W - 16, TEXTURE_H - 16, {
+ gradient: content.gradient || ['#7c3aed', '#a855f7'],
+ radius: 28,
+ stroke: { color: '#0008', width: 4 },
+ });
+
+ ctx.font = 'bold 46px Arial, sans-serif';
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+ ctx.fillStyle = '#fff';
+ ctx.shadowColor = 'rgba(0,0,0,0.45)';
+ ctx.shadowBlur = 8;
+ ctx.shadowOffsetY = 3;
+
+ // Перенос строк, чтобы длинная фраза влезла
+ const lines = this._wrapText(ctx, content.title || '', TEXTURE_W - 80);
+ const lh = 56;
+ const startY = TEXTURE_H / 2 - (lines.length - 1) * lh / 2;
+ for (let i = 0; i < lines.length; i++) {
+ ctx.fillText(lines[i], TEXTURE_W / 2, startY + i * lh);
+ }
+ ctx.shadowColor = 'transparent';
+ ctx.shadowBlur = 0;
+ ctx.shadowOffsetY = 0;
+ }
+
+ /** Рендер пресета sign: компактный указатель. */
+ _renderSign(ctx, content) {
+ this._roundedGradientRect(ctx, 8, 8, TEXTURE_W - 16, TEXTURE_H - 16, {
+ gradient: content.gradient || ['#1f2937', '#374151'],
+ radius: 20,
+ stroke: { color: '#fff', width: 4 },
+ });
+
+ // Заголовок крупно сверху
+ ctx.font = 'bold 44px Arial, sans-serif';
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+ ctx.fillStyle = '#ffd166';
+ const title = content.title || '';
+ const subText = content.sub || '';
+ if (subText) {
+ // Заголовок сверху, sub-строки списком ниже
+ ctx.fillText(this._truncate(title, 18), TEXTURE_W / 2, 50);
+ // Sub — многострочный, выравнивание по левому краю
+ ctx.font = '20px Arial, sans-serif';
+ ctx.fillStyle = '#fff';
+ ctx.textAlign = 'left';
+ ctx.textBaseline = 'top';
+ const lines = String(subText).split('\n');
+ const startY = 95;
+ const lineH = 30;
+ const leftX = 38;
+ for (let i = 0; i < lines.length && i < 8; i++) {
+ ctx.fillText(this._truncate(lines[i], 36), leftX, startY + i * lineH);
+ }
+ } else {
+ ctx.font = 'bold 64px Arial, sans-serif';
+ ctx.fillText(this._truncate(title, 14), TEXTURE_W / 2, TEXTURE_H / 2);
+ }
+ }
+
+ /** Рендер кастомного списка элементов: фон + список text/image/button.
+ * Каждый элемент: { kind, x, y, w, h, ... }
+ * text: { text, size, color, bold, align }
+ * image: { src (icon-key), w, h }
+ * button: { id, text, background: {color|gradient, cornerRadius, stroke} }
+ */
+ _renderElements(ctx, elements, pressedButtonId) {
+ // Фоновая плашка — первый элемент типа 'background' (опционально)
+ const bg = elements.find(e => e.kind === 'background');
+ if (bg) {
+ this._roundedGradientRect(ctx, 8, 8, TEXTURE_W - 16, TEXTURE_H - 16, {
+ gradient: bg.gradient || ['#1f2937', '#374151'],
+ radius: bg.cornerRadius ?? 24,
+ stroke: bg.stroke || { color: '#0008', width: 4 },
+ });
+ }
+ // Остальные элементы — поверх фона
+ for (const el of elements) {
+ if (el.kind === 'background') continue;
+ if (el.kind === 'text') {
+ ctx.font = `${el.bold ? 'bold ' : ''}${el.size || 24}px Arial, sans-serif`;
+ ctx.fillStyle = el.color || '#fff';
+ ctx.textAlign = el.align || 'left';
+ ctx.textBaseline = 'top';
+ ctx.fillText(el.text || '', el.x || 0, el.y || 0);
+ } else if (el.kind === 'image') {
+ const iconChar = ICONS[el.src] || ICONS.cube;
+ const size = Math.min(el.w || 64, el.h || 64);
+ ctx.font = `${Math.round(size * 1.1)}px "Segoe UI Emoji", Arial`;
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+ ctx.fillStyle = el.color || '#fff';
+ ctx.fillText(iconChar, (el.x || 0) + (el.w || 64) / 2,
+ (el.y || 0) + (el.h || 64) / 2);
+ } else if (el.kind === 'button') {
+ const isPressed = pressedButtonId === el.id;
+ const bgSpec = el.background || {};
+ this._roundedGradientRect(ctx, el.x || 0, el.y || 0,
+ el.w || 100, el.h || 36, {
+ gradient: bgSpec.gradient ||
+ (bgSpec.color ? [bgSpec.color, bgSpec.color] : ['#fbbf24', '#f59e0b']),
+ radius: bgSpec.cornerRadius ?? 12,
+ stroke: bgSpec.stroke || { color: '#000', width: 2 },
+ });
+ ctx.font = `bold ${el.textSize || 28}px Arial, sans-serif`;
+ ctx.fillStyle = isPressed ? '#fef3c7' : (el.textColor || '#1c1917');
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+ ctx.fillText(el.text || '', (el.x || 0) + (el.w || 100) / 2,
+ (el.y || 0) + (el.h || 36) / 2);
+ }
+ }
+ }
+
+ /** Hit-тест для кастомных elements (используется в pickButtonAt). */
+ _hitTestElements(elements, px, py) {
+ for (const el of elements) {
+ if (el.kind !== 'button') continue;
+ const x = el.x || 0, y = el.y || 0;
+ const w = el.w || 100, h = el.h || 36;
+ if (px >= x && px <= x + w && py >= y && py <= y + h) {
+ return el.id || null;
+ }
+ }
+ return null;
+ }
+
+ _truncate(s, max) {
+ if (!s) return '';
+ return s.length > max ? s.slice(0, max - 1) + '…' : s;
+ }
+
+ _wrapText(ctx, text, maxWidth) {
+ const words = (text || '').split(' ');
+ const lines = [];
+ let cur = '';
+ for (const w of words) {
+ const test = cur ? cur + ' ' + w : w;
+ if (ctx.measureText(test).width <= maxWidth) {
+ cur = test;
+ } else {
+ if (cur) lines.push(cur);
+ cur = w;
+ }
+ }
+ if (cur) lines.push(cur);
+ return lines.slice(0, 3); // максимум 3 строки
+ }
+
+ /** Список доступных иконок (для UI редактора). */
+ static getAvailableIcons() {
+ return Object.keys(ICONS);
+ }
+
+ /** Список доступных пресетов (для UI редактора). */
+ static getAvailableTemplates() {
+ return [
+ { id: 'shop-item', name: 'Магазин: апгрейд', hasButton: true,
+ fields: ['icon', 'title', 'sub', 'price', 'gradient'] },
+ { id: 'shop-purchase', name: 'Магазин: покупка', hasButton: true,
+ fields: ['icon', 'title', 'sub', 'price', 'gradient'] },
+ { id: 'banner', name: 'Баннер', hasButton: false,
+ fields: ['title', 'gradient'] },
+ { id: 'sign', name: 'Указатель', hasButton: false,
+ fields: ['title', 'gradient'] },
+ ];
+ }
+}
diff --git a/src/engine/GameRuntime.js b/src/engine/GameRuntime.js
index 5407740..92604a4 100644
--- a/src/engine/GameRuntime.js
+++ b/src/engine/GameRuntime.js
@@ -157,6 +157,9 @@ export class GameRuntime {
this._broadcastSceneSnapshot();
this._broadcastGuiSnapshot();
this._broadcastTerrainHeightmap();
+ this._broadcastSkinsSnapshot(); // задача 07
+ // Задача 03: запустить animationPreset для GUI-элементов с пресетом ≠ 'none'.
+ this._startGuiAnimationPresets();
};
if (typeof requestAnimationFrame !== 'undefined') {
requestAnimationFrame(sendInitial);
@@ -191,6 +194,206 @@ export class GameRuntime {
}
}
+ /** Задача 03: запустить пресеты анимаций для GUI-элементов в Play. */
+ _startGuiAnimationPresets() {
+ const gm = this.scene3d?.guiManager;
+ if (!gm) return;
+ if (!this._guiTweens) this._guiTweens = [];
+ for (const el of (gm.elements || [])) {
+ const preset = el.animationPreset;
+ if (!preset || preset === 'none') continue;
+ const id = el.id;
+ // Каждый пресет = одна tween-запись с reverses+repeat=-1
+ switch (preset) {
+ case 'pulse':
+ this._guiTweens.push(this._mkGuiPreset(id, el,
+ { scaleX: 1.1, scaleY: 1.1 }, 0.7, 'ease', true, -1));
+ break;
+ case 'rotate':
+ this._guiTweens.push(this._mkGuiPreset(id, el,
+ { rotation: (el.rotation || 0) + 360 }, 3, 'linear', false, -1));
+ break;
+ case 'sway':
+ this._guiTweens.push(this._mkGuiPreset(id, el,
+ { rotation: (el.rotation || 0) + 8 }, 0.6, 'ease', true, -1));
+ this._guiTweens[this._guiTweens.length - 1].start.rotation = (el.rotation || 0) - 8;
+ break;
+ case 'glow':
+ this._guiTweens.push(this._mkGuiPreset(id, el,
+ { bgOpacity: 0.6 }, 0.8, 'ease', true, -1));
+ break;
+ case 'bounce':
+ this._guiTweens.push(this._mkGuiPreset(id, el,
+ { y: (el.y || 50) - 1 }, 0.5, 'ease', true, -1));
+ break;
+ }
+ }
+ }
+ _mkGuiPreset(id, el, targetProps, duration, easing, reverses, repeat) {
+ const start = {};
+ for (const k of Object.keys(targetProps)) {
+ if (k === 'scaleX' || k === 'scaleY') start[k] = el[k] || 1;
+ else if (k === 'rotation') start[k] = el.rotation || 0;
+ else if (k === 'bgOpacity') start[k] = el.bgOpacity == null ? 1 : el.bgOpacity;
+ else start[k] = el[k] || 0;
+ }
+ return {
+ tweenId: ++this._tweenSeq || (this._tweenSeq = 1),
+ scriptId: '__preset__',
+ realId: id,
+ start, target: targetProps,
+ elapsed: 0, delay: 0,
+ duration, easing,
+ repeat, reverses, iter: 0, dir: 1,
+ };
+ }
+
+ /**
+ * Задача 07: разослать снапшот скинов всем sandbox'ам — чтобы
+ * game.player.getAvailableSkins/getAllSkins работали синхронно.
+ * Манифест грузится через fetch (кешируется браузером), затем
+ * объединяется с разблокированными скинами из scene.skins.
+ */
+ async _broadcastSkinsSnapshot() {
+ try {
+ this._ensureSkinState();
+ let manifest = this._skinManifestCache;
+ if (!manifest) {
+ const resp = await fetch('/kubikon-assets/characters/skins_manifest.json');
+ const json = await resp.json();
+ manifest = (json.skins || []).map(s => ({
+ slug: s.slug || (s.id || '').replace(/^skin_/, ''),
+ name: s.name || s.slug,
+ kind: s.kind || 'r15',
+ category: s.category || 'human',
+ price: Number.isFinite(s.price) ? s.price : 0,
+ }));
+ // Встроенные «человеки» character-a..g тоже добавим как базовый выбор.
+ this._skinManifestCache = manifest;
+ }
+ const payload = {
+ all: manifest,
+ unlocked: Array.from(this._skinState.unlocked),
+ current: this._skinState.current,
+ coins: this._skinState.coins,
+ };
+ for (const sb of this.sandboxes) sb.sendSkinsSnapshot(payload);
+ // Также отдать снапшот в scene для React-магазина.
+ try { this.scene3d?._setSkinShopData?.({ ...payload, manifestFull: this._skinManifestCache }); } catch (e) {}
+ } catch (e) {
+ // манифест недоступен — не критично, скрипт получит пустой список
+ }
+ }
+
+ /**
+ * Задача 07: гарантированно инициализировать состояние скинов при первом
+ * обращении. Держит множество разблокированных скинов и текущий.
+ */
+ _ensureSkinState() {
+ if (this._skinState) return this._skinState;
+ const sk = this.scene3d?._skinsConfig || {};
+ const def = sk.default || this.scene3d?._playerModelType || 'character-a';
+ const defSlug = this._slugFromTypeId(def);
+ const unlocked = new Set(Array.isArray(sk.unlocked) ? sk.unlocked : []);
+ unlocked.add(defSlug);
+ this._skinState = {
+ unlocked,
+ current: defSlug,
+ shopVisible: sk.shopVisible !== false,
+ coins: Number.isFinite(sk.coins) ? sk.coins : 0,
+ };
+ return this._skinState;
+ }
+
+ /** slug → _modelTypeId движка. Встроенные → 'skin_', character-* как есть. */
+ _resolveSkinTypeId(slug) {
+ if (!slug) return 'character-a';
+ if (slug.startsWith('character-')) return slug;
+ if (slug.startsWith('skin_') || slug.startsWith('customskin:')) return slug;
+ return 'skin_' + slug;
+ }
+
+ /** _modelTypeId → slug (обратно). */
+ _slugFromTypeId(typeId) {
+ if (!typeId) return 'character-a';
+ if (typeId.startsWith('skin_')) return typeId.slice('skin_'.length);
+ return typeId;
+ }
+
+ /** Задача 03: обновить GUI-твины (gui.tween + animationPresets). */
+ _updateGuiTweens(dt) {
+ const gm = this.scene3d?.guiManager;
+ if (!gm) return;
+ for (let i = this._guiTweens.length - 1; i >= 0; i--) {
+ const tw = this._guiTweens[i];
+ if (tw.delay > 0) { tw.delay -= dt; if (tw.delay > 0) continue; }
+ tw.elapsed += dt;
+ let t = tw.elapsed / tw.duration;
+ let done = false;
+ if (t >= 1) { t = 1; done = true; }
+ const raw = tw.dir === -1 ? 1 - t : t;
+ const k = GameRuntime._ease(tw.easing, raw);
+ // Применяем
+ const el = gm.elements.find(e => e.id === tw.realId);
+ if (!el) { this._guiTweens.splice(i, 1); continue; }
+ const patch = {};
+ for (const key of Object.keys(tw.target)) {
+ const from = tw.start[key];
+ const to = tw.target[key];
+ if (typeof from === 'number' && typeof to === 'number') {
+ patch[key] = from + (to - from) * k;
+ } else if (typeof from === 'string' && typeof to === 'string'
+ && from.startsWith('#') && to.startsWith('#')) {
+ patch[key] = GameRuntime._lerpColor(from, to, k);
+ } else {
+ // Прочее — на конце ставим целевое
+ if (done) patch[key] = to;
+ }
+ }
+ // Throttle: обновляем не чаще чем раз в 32мс (~30 FPS).
+ tw._lastApply = tw._lastApply || 0;
+ tw._lastApply += dt;
+ if (tw._lastApply >= 0.032 || done) {
+ tw._lastApply = 0;
+ try { gm.update(tw.realId, patch); } catch (e) {}
+ }
+
+ if (done) {
+ if (tw.reverses && tw.dir === 1) {
+ tw.dir = -1;
+ tw.elapsed = 0;
+ continue;
+ }
+ tw.iter++;
+ if (tw.repeat === -1 || tw.iter < tw.repeat) {
+ // повтор
+ tw.elapsed = 0;
+ tw.dir = 1;
+ continue;
+ }
+ // готово
+ this._guiTweens.splice(i, 1);
+ // onDone callback в worker
+ const sb = this.sandboxes.find(s => s.scriptId === tw.scriptId);
+ if (sb) sb.sendGlobalEvent({ type: 'tweenDone', tweenId: tw.tweenId });
+ }
+ }
+ }
+
+ /** Слить отложенные команды для конкретного только что зарезолвленного ref. */
+ _drainPendingResolveQueue(resolvedLocalRef) {
+ if (!this._pendingResolveQueue || !this._pendingResolveQueue.length) return;
+ const stay = [];
+ for (const item of this._pendingResolveQueue) {
+ if (item.payload?.ref === resolvedLocalRef) {
+ this._handleCommand(item.scriptId, item.cmd, item.payload);
+ } else {
+ stay.push(item);
+ }
+ }
+ this._pendingResolveQueue = stay;
+ }
+
/**
* Получить позицию объекта по его target (для зеркалирования в worker).
*/
@@ -353,6 +556,8 @@ export class GameRuntime {
}
// Анимации game.tween
if (this._tweens.length > 0) this._updateTweens(dt);
+ // Задача 03: GUI tweens
+ if (this._guiTweens && this._guiTweens.length > 0) this._updateGuiTweens(dt);
// ProximityPrompt — подсказка [E] над ближайшим интерактивным объектом
if (this._interactables.length > 0) this._updateInteractables();
@@ -905,14 +1110,20 @@ export class GameRuntime {
*/
routeGlobalEvent(eventType, extra = {}) {
if (!eventType) return;
- // Спецслучай: guiClick приходит с realId, но worker подписан на localRef
- // (потому что gui.create() возвращает worker'у только localRef).
- // Резолвим обратно по реверс-карте.
+ // Спецслучай: guiClick приходит с realId. Worker мог подписаться двумя
+ // способами:
+ // 1) по локальному ref, который вернул gui.create() — '_gui_local_N'
+ // 2) по ЯВНОМУ id, который сам передал в gui.create({ id: 'fight' }),
+ // или по name элемента.
+ // Раньше мы ПЕРЕЗАПИСЫВАЛИ extra.id на localRef — это ломало вариант (2),
+ // потому что worker искал handler по localRef, а юзер подписался по
+ // явному id. Теперь шлём ОБА id (`id` = реальный + `localId` = ref),
+ // worker матчит по обоим (см. _guiHandlerKeys в ScriptSandboxWorker).
if ((eventType === 'guiClick' || eventType === 'guiSubmit'
|| eventType === 'guiTextChange')
&& extra && extra.id != null && this._guiRealToLocal) {
const local = this._guiRealToLocal.get(extra.id);
- if (local) extra = { ...extra, id: local };
+ if (local && local !== extra.id) extra = { ...extra, localId: local };
}
// ProximityPrompt: keydown клавиши взаимодействия → событие interact
if (eventType === 'keydown' && extra && extra.key
@@ -2558,6 +2769,317 @@ export class GameRuntime {
}
return;
}
+ // === Задача 07: скины игрока ===
+ if (cmd === 'player.setSkin') {
+ const player = this.scene3d?.player;
+ const slug = payload?.slug;
+ if (player && typeof slug === 'string' && slug) {
+ const typeId = this._resolveSkinTypeId(slug);
+ // Помечаем доступным (setSkin неявно разблокирует).
+ this._ensureSkinState();
+ this._skinState.unlocked.add(slug);
+ this._skinState.current = slug;
+ // Асинхронная перезагрузка модели; по завершении шлём skinChanged.
+ Promise.resolve(player.reloadSkin?.(typeId)).then(() => {
+ this.routeGlobalEvent?.('skinChanged', { slug });
+ try { this.scene3d?._onPlayerSkinChanged?.(slug); } catch (e) {}
+ }).catch((e) => {
+ this._log('error', 'setSkin failed: ' + (e?.message || e));
+ });
+ }
+ return;
+ }
+ if (cmd === 'player.unlockSkin') {
+ const slug = payload?.slug;
+ if (typeof slug === 'string' && slug) {
+ this._ensureSkinState();
+ this._skinState.unlocked.add(slug);
+ this.routeGlobalEvent?.('skinUnlocked', { slug });
+ }
+ return;
+ }
+ if (cmd === 'player.openSkinShop') {
+ this._ensureSkinState();
+ try { this.scene3d?._openSkinShop?.(); } catch (e) {}
+ return;
+ }
+ if (cmd === 'player.closeSkinShop') {
+ try { this.scene3d?._closeSkinShop?.(); } catch (e) {}
+ return;
+ }
+ if (cmd === 'player.setSkinCoins') {
+ this._ensureSkinState();
+ const n = Number(payload?.amount);
+ if (Number.isFinite(n)) {
+ this._skinState.coins = Math.max(0, Math.floor(n));
+ this._broadcastSkinsSnapshot();
+ }
+ return;
+ }
+ // Покупка скина из встроенного магазина (намерение от React-оверлея
+ // или из скрипта). Списывает локальные рублики, разблокирует, надевает.
+ if (cmd === 'player.buySkin') {
+ this._ensureSkinState();
+ const slug = payload?.slug;
+ const price = Number(payload?.price) || 0;
+ if (typeof slug !== 'string' || !slug) return;
+ const st = this._skinState;
+ const owned = st.unlocked.has(slug);
+ if (owned) {
+ // Уже куплен — просто надеть.
+ this._handleCommand(scriptId, 'player.setSkin', { slug });
+ return;
+ }
+ if (st.coins < price) {
+ // Не хватает — сообщаем оверлею.
+ try { this.scene3d?._onSkinBuyResult?.({ slug, ok: false, reason: 'no_coins' }); } catch (e) {}
+ return;
+ }
+ st.coins -= price;
+ st.unlocked.add(slug);
+ this._handleCommand(scriptId, 'player.setSkin', { slug });
+ this._broadcastSkinsSnapshot();
+ try { this.scene3d?._onSkinBuyResult?.({ slug, ok: true }); } catch (e) {}
+ return;
+ }
+ // === Задача 02: setCameraZoom / setCameraZoomLimits / setShiftLock ===
+ if (cmd === 'player.setCameraZoom') {
+ const player = this.scene3d?.player;
+ if (player && typeof player.setCameraZoom === 'function') {
+ try { player.setCameraZoom(payload?.distance); } catch (e) {}
+ }
+ return;
+ }
+ if (cmd === 'player.setCameraZoomLimits') {
+ const player = this.scene3d?.player;
+ if (player && typeof player.setCameraZoomLimits === 'function') {
+ try { player.setCameraZoomLimits(payload?.min, payload?.max); } catch (e) {}
+ }
+ return;
+ }
+ if (cmd === 'player.setShiftLock') {
+ const player = this.scene3d?.player;
+ if (player && typeof player.setShiftLock === 'function') {
+ try { player.setShiftLock(payload?.on); } catch (e) {}
+ }
+ return;
+ }
+ // === Задача 02: environment API ===
+ if (cmd === 'environment.setSkyColor') {
+ try {
+ const hex = String(payload?.color || '');
+ const scene = this.scene3d?.scene;
+ if (scene && hex) {
+ // Парсим #rrggbb → clearColor
+ const m = hex.match(/^#?([0-9a-f]{6})$/i);
+ if (m) {
+ const n = parseInt(m[1], 16);
+ const r = ((n >> 16) & 0xff) / 255;
+ const g = ((n >> 8) & 0xff) / 255;
+ const b = (n & 0xff) / 255;
+ if (scene.clearColor) {
+ scene.clearColor.r = r;
+ scene.clearColor.g = g;
+ scene.clearColor.b = b;
+ scene.clearColor.a = 1;
+ }
+ }
+ }
+ } catch (e) {
+ this._log('error', 'environment.setSkyColor failed: ' + (e?.message || e));
+ }
+ return;
+ }
+ if (cmd === 'environment.setFog') {
+ try {
+ const env = this.scene3d?.environment;
+ if (env && typeof env.setFog === 'function') {
+ env.setFog(payload?.enabled, payload?.color, payload?.density);
+ }
+ } catch (e) {}
+ return;
+ }
+ if (cmd === 'environment.setTimeOfDay') {
+ try {
+ const env = this.scene3d?.environment;
+ if (env && typeof env.setTimeOfDay === 'function') {
+ env.setTimeOfDay(payload?.hours);
+ }
+ } catch (e) {}
+ return;
+ }
+ // === Задача 03: GUI tween ===
+ if (cmd === 'gui.tween') {
+ try {
+ const guiId = payload?.id;
+ if (typeof guiId !== 'string' || !guiId) return;
+ const gm = this.scene3d?.guiManager;
+ if (!gm) return;
+ // Резолв localRef → realId если есть
+ let realId = guiId;
+ if (this._guiLocalToReal?.has(guiId)) realId = this._guiLocalToReal.get(guiId);
+ const el = gm.elements?.find(e => e.id === realId);
+ if (!el) return;
+ if (!this._guiTweens) this._guiTweens = [];
+ // Снимок начальных значений по тем ключам что есть в props
+ const props = payload.props || {};
+ const propKeys = Object.keys(props);
+ // Перебивка: убрать существующие tween'ы на ТОТ ЖЕ id,
+ // которые анимируют ХОТЯ БЫ ОДИН из этих же ключей.
+ // Это поведение Roblox TweenService: новый tween на тот же ключ перебивает старый.
+ for (let j = this._guiTweens.length - 1; j >= 0; j--) {
+ const old = this._guiTweens[j];
+ if (old.realId !== realId) continue;
+ const oldKeys = Object.keys(old.target);
+ const overlap = oldKeys.some(k => propKeys.includes(k));
+ if (overlap) this._guiTweens.splice(j, 1);
+ }
+ const start = {};
+ for (const k of propKeys) {
+ if (k in el) start[k] = el[k];
+ else if (k === 'scaleX' || k === 'scaleY' || k === 'rotation') start[k] = el[k] || (k === 'rotation' ? 0 : 1);
+ }
+ this._guiTweens.push({
+ tweenId: payload.tweenId,
+ scriptId,
+ realId,
+ start, target: { ...props },
+ elapsed: 0,
+ duration: Math.max(0.001, Number(payload.duration) || 0.5),
+ delay: Math.max(0, Number(payload.delay) || 0),
+ easing: payload.easing || 'ease',
+ repeat: Number.isFinite(payload.repeat) ? payload.repeat : 0,
+ reverses: !!payload.reverses,
+ iter: 0,
+ dir: 1, // 1 = вперёд, -1 = обратно (для reverses)
+ });
+ } catch (e) {
+ this._log('error', 'gui.tween failed: ' + (e?.message || e));
+ }
+ return;
+ }
+ if (cmd === 'gui.cancelTween') {
+ const tid = payload?.tweenId;
+ if (tid != null && this._guiTweens) {
+ const i = this._guiTweens.findIndex(t => t.tweenId === tid);
+ if (i >= 0) this._guiTweens.splice(i, 1);
+ }
+ return;
+ }
+ // === Задача 04: модал-сцены ===
+ if (cmd === 'modal.open') {
+ try {
+ const mm = this.scene3d?.modalManager;
+ if (!mm) return;
+ // Резолв spotlights: строки-id → ref-объекты {kind:'primitive', id} как обычно
+ const opts = { ...(payload?.opts || {}) };
+ if (Array.isArray(opts.spotlights)) {
+ opts.spotlights = opts.spotlights.map(r => this._normRef ? this._normRef(r) : r);
+ }
+ if (opts.cameraOverride && opts.cameraOverride.target) {
+ opts.cameraOverride = {
+ ...opts.cameraOverride,
+ target: this._normRef ? this._normRef(opts.cameraOverride.target) : opts.cameraOverride.target,
+ };
+ }
+ const modalId = mm.open(opts);
+ // Подписка чтобы автоматически слать tweenDone-стиль событий
+ // на конкретный скрипт (тот кто открыл) — для onClose.
+ if (!mm._runtimeBoundOnClose) {
+ mm._runtimeBoundOnClose = true;
+ mm.onClose((closedId) => {
+ // Шлём событие всем sandbox'ам — они роутят к зарегистрированным fn
+ this.routeGlobalEvent?.('modalClosed', { id: closedId });
+ });
+ }
+ // Ответ обратно в worker: фактический modalId (юзер мог вернуть из open)
+ const sb = this.sandboxes.find(s => s.scriptId === scriptId);
+ if (sb && payload?.replyId != null) {
+ sb.sendGlobalEvent({ type: 'modalOpened', replyId: payload.replyId, modalId });
+ }
+ } catch (e) {
+ this._log('error', 'modal.open failed: ' + (e?.message || e));
+ }
+ return;
+ }
+ if (cmd === 'modal.close') {
+ try {
+ const mm = this.scene3d?.modalManager;
+ mm?.close?.(payload?.modalId);
+ } catch (e) {}
+ return;
+ }
+ if (cmd === 'modal.update') {
+ try {
+ const mm = this.scene3d?.modalManager;
+ mm?.update?.(payload?.modalId, payload?.patch);
+ } catch (e) {}
+ return;
+ }
+ // === Задача 01: Billboard 3D-таблички (см. BillboardUiManager) ===
+ if (cmd === 'billboard.set' || cmd === 'billboard.update' || cmd === 'billboard.onClick') {
+ // Резолв ref → primitiveId.
+ // Worker может прислать ref сразу после game.scene.spawn — до
+ // того как main spawn'нул примитив и обновил _localToReal.
+ // Откладываем команду до резолва.
+ let ref = payload?.ref;
+ if (typeof ref === 'string' && ref.includes('_local_')
+ && !this._localToReal?.has(ref)) {
+ this._pendingResolveQueue = this._pendingResolveQueue || [];
+ this._pendingResolveQueue.push({ cmd, payload, scriptId });
+ return;
+ }
+ try {
+ if (this._localToReal?.has(ref)) ref = this._localToReal.get(ref);
+ let id = null;
+ if (typeof ref === 'string' && ref.startsWith('primitive:')) {
+ id = Number(ref.slice('primitive:'.length));
+ } else if (Number.isFinite(ref)) {
+ id = Number(ref);
+ }
+ if (!Number.isFinite(id) || id == null) return;
+ const data = this.scene3d?.primitiveManager?.instances?.get(id);
+ if (!data || data.type !== 'billboard') return;
+ const mgr = this.scene3d?.billboardUiManager;
+ if (!mgr) return;
+
+ if (cmd === 'billboard.set') {
+ mgr.applyToMesh(data, {
+ template: payload.template || data.billboard?.template || 'shop-item',
+ face: payload.face || data.billboard?.face || 'camera',
+ content: payload.content || data.billboard?.content,
+ elements: payload.elements || data.billboard?.elements,
+ });
+ this.scheduleSceneSnapshot?.();
+ } else if (cmd === 'billboard.update') {
+ // 2 формы: с elementId (точечно) или без (patch content)
+ if (typeof payload.elementId === 'string') {
+ mgr.update(data, payload.elementId, payload.patch || {});
+ } else {
+ mgr.update(data, payload.patch || {});
+ }
+ this.scheduleSceneSnapshot?.();
+ } else if (cmd === 'billboard.onClick') {
+ const buttonId = String(payload.buttonId || 'buy');
+ const realRef = 'primitive:' + id;
+ mgr.onClick(data, buttonId, () => {
+ const sb = this.sandboxes.find(s => s.scriptId === scriptId);
+ if (sb && typeof sb.sendGlobalEvent === 'function') {
+ // billboardClick роутится в worker'е через globalEvent-ветку
+ // (см. ScriptSandboxWorker.js cmd === 'globalEvent').
+ sb.sendGlobalEvent({
+ type: 'billboardClick',
+ ref: realRef,
+ button: buttonId,
+ });
+ }
+ });
+ }
+ } catch (e) {
+ this._log('error', cmd + ' failed: ' + (e?.message || e));
+ }
+ return;
+ }
// eslint-disable-next-line no-console
console.warn('[GameRuntime] unknown cmd', cmd);
}
@@ -2592,6 +3114,7 @@ export class GameRuntime {
}
this._localToReal.set(ref, 'model:' + instId);
this._notifySpawnResolved(ref, 'model:' + instId);
+ this._drainPendingResolveQueue?.(ref);
this.scheduleSceneSnapshot();
}).catch((err) => {
this._log('error', 'spawn model failed: ' + (err?.message || err));
@@ -2611,6 +3134,7 @@ export class GameRuntime {
}
this._localToReal.set(ref, 'usermodel:' + instId);
this._notifySpawnResolved(ref, 'usermodel:' + instId);
+ this._drainPendingResolveQueue?.(ref);
this.scheduleSceneSnapshot();
}).catch((err) => {
this._log('error', 'spawn user model failed: ' + (err?.message || err));
@@ -2636,6 +3160,7 @@ export class GameRuntime {
if (id != null) {
this._localToReal.set(ref, 'primitive:' + id);
this._notifySpawnResolved(ref, 'primitive:' + id);
+ this._drainPendingResolveQueue?.(ref);
const data = this.scene3d?.primitiveManager?.instances?.get(id);
if (data) {
// Помечаем как заспавненный скриптом — движок шлёт
diff --git a/src/engine/GuiManager.js b/src/engine/GuiManager.js
index 1efd91c..b68b8b9 100644
--- a/src/engine/GuiManager.js
+++ b/src/engine/GuiManager.js
@@ -99,6 +99,11 @@ export class GuiManager {
w: opts.w ?? _defaultSize(type).w,
h: opts.h ?? _defaultSize(type).h,
anchor: opts.anchor || 'center',
+ // Phase 6.3.1: AnchorPoint -- точка ВНУТРИ элемента, относительно
+ // которой считается позиция x/y. По умолчанию НЕ задан -- рендерер
+ // вычислит дефолт из anchor (center → {0.5,0.5}, top-left → {0,0}, ...).
+ // Юзер может override через opts.anchorPoint = {x:0..1, y:0..1}.
+ anchorPoint: opts.anchorPoint || null,
visible: opts.visible !== false,
bgColor: opts.bgColor ?? _defaultBgColor(type),
bgOpacity: opts.bgOpacity ?? _defaultBgOpacity(type),
@@ -118,17 +123,42 @@ export class GuiManager {
placeholder: opts.placeholder ?? (type === 'textbox' ? 'Введите текст…' : ''),
onClickScriptId: opts.onClickScriptId ?? null,
zIndex: opts.zIndex ?? this.elements.length + 1,
- // Авто-раскладка детей (Фаза 5.3, для frame/scroll):
- // 'none' — дети как есть (по своим x/y);
- // 'vertical' — дети в столбик; 'horizontal' — в строку.
+ // Авто-раскладка детей (Фаза 5.3 + 6.3.2):
+ // 'none' -- дети как есть (по своим x/y);
+ // 'vertical' -- дети в столбик; 'horizontal' -- в строку;
+ // 'grid' -- сетка (требует layoutCellW/H/Cols).
layout: opts.layout ?? 'none',
// Отступ между детьми и внутреннее поле контейнера (в % размера контейнера).
layoutGap: opts.layoutGap ?? 2,
layoutPad: opts.layoutPad ?? 3,
+ // Phase 6.3.2: параметры grid-layout.
+ // cellW/H -- размер каждой ячейки в %; cols -- количество колонок (0 = авто).
+ layoutCellW: opts.layoutCellW ?? 18,
+ layoutCellH: opts.layoutCellH ?? 18,
+ layoutCols: opts.layoutCols ?? 0,
// Текущая прокрутка scroll-контейнера (в %, только для type='scroll').
scrollY: opts.scrollY ?? 0,
// Тень под элементом (Фаза 5.4) — мягкая drop-shadow.
shadow: opts.shadow ?? false,
+ // === Задача 03: расширения для красивого UI + анимаций ===
+ // Линейный градиент фона. Формат: { stops: ['#a','#b'] | [{c,p}], angle: 0..360 }.
+ bgGradient: opts.bgGradient ?? null,
+ // Обводка текста (для крупных подписей "X2 ДЕНЕГ"). { color, width }.
+ textStroke: opts.textStroke ?? null,
+ // Поворот элемента в градусах (transform: rotate).
+ rotation: opts.rotation ?? 0,
+ // Scale-множитель (transform: scale). 1 = нормальный размер.
+ scaleX: opts.scaleX ?? 1,
+ scaleY: opts.scaleY ?? 1,
+ // Бейдж-маркер в углу: { corner, icon, color, text }.
+ badge: opts.badge ?? null,
+ // Hover-реакция (только для button/image-button): { scale, rotation, brightness, duration, easing }.
+ hover: opts.hover ?? null,
+ // Active-реакция (зажатие ЛКМ): { scale, duration }.
+ active: opts.active ?? null,
+ // Анимация-пресет: 'none'|'pulse'|'rotate'|'sway'|'glow'|'bounce'|'custom'.
+ // Раскрывается в реальный tween при applyAnimationPreset(id) в Play.
+ animationPreset: opts.animationPreset ?? 'none',
// Создан скриптом в Play (game.gui.create) — НЕ сериализуется
// в проект, удаляется при Stop.
_scriptCreated: opts._scriptCreated === true,
diff --git a/src/engine/ModalManager.js b/src/engine/ModalManager.js
new file mode 100644
index 0000000..2c8f29a
--- /dev/null
+++ b/src/engine/ModalManager.js
@@ -0,0 +1,398 @@
+/**
+ * ModalManager — модальные сцены (затемнение + GUI поверх + блок ввода).
+ *
+ * Задача 04 из «1 - Неделя 4/ЗАДАЧИ РУБЛОКС/04_modal_cutscene.md».
+ *
+ * Типовой кейс: boss-fight intro / открытие лутбокса / диалог с NPC / получил
+ * питомца. Скрипт зовёт `game.modal.open(opts)` → весь 3D-мир затемняется
+ * (но HUD остаётся), управление блокируется, поверх показывается контент.
+ *
+ * Координирует:
+ * - DOM overlay (рендерится в KubikonEditor/KubikonPlayer)
+ * - PlayerController.setInputBlocked / setCameraFrozen
+ * - HighlightLayer Babylon (spotlight-объекты светятся)
+ * - GameRuntime.paused (опционально, через pauseSimulation)
+ * - AudioManager.duck (опционально, через muteWorld)
+ * - GuiManager (временные элементы создаются/удаляются с модалом)
+ *
+ * Не зависит от React — просто состояние и колбэки.
+ *
+ * Архитектура:
+ * _state = {
+ * id, opts,
+ * fadePhase: 'in'|'visible'|'out'|'closed',
+ * fadeStart: ms, fadeFrom: 0..1, fadeTo: 0..1,
+ * currentAlpha: 0..1,
+ * tempGuiIds: [...], — id-шники созданных временных GUI-элементов
+ * spotlightScreens: [{x,y,r}], — позиции spotlight'ов в экранных координатах
+ * }
+ *
+ * Активен только ОДИН модал одновременно (Roblox-style). Повторный open
+ * автоматически закрывает предыдущий (через close+open).
+ */
+
+let _seq = 1;
+
+export class ModalManager {
+ constructor() {
+ /** @type {object|null} текущий модал, null если закрыт */
+ this._state = null;
+ /** @type {Function|null} вызывается когда меняется state — UI пере-рендерится */
+ this._onChange = null;
+ /** Babylon scene нужна для HighlightLayer и Vector3.Project */
+ this._scene = null;
+ /** PlayerController для блока ввода/freeze камеры */
+ this._player = null;
+ /** GuiManager для temp-элементов */
+ this._gui = null;
+ /** GameRuntime для pauseSimulation */
+ this._runtime = null;
+ /** AudioManager для muteWorld */
+ this._audio = null;
+ /** HighlightLayer Babylon — создаётся лениво при первом spotlight */
+ this._highlight = null;
+ /** Колбэки onClose — массив функций (modalId) => void */
+ this._closeCallbacks = [];
+ /** Прежний WASD-state и FOV — для восстановления */
+ this._savedCameraState = null;
+ }
+
+ setOnChange(cb) { this._onChange = cb; }
+ _notify() { if (this._onChange) try { this._onChange(this._state); } catch (e) {} }
+
+ attachScene(scene) { this._scene = scene; }
+ attachPlayer(player) { this._player = player; }
+ attachGui(gui) { this._gui = gui; }
+ attachRuntime(runtime) { this._runtime = runtime; }
+ attachAudio(audio) { this._audio = audio; }
+
+ /** Открыт ли сейчас модал. */
+ isOpen() {
+ return !!this._state && this._state.fadePhase !== 'closed';
+ }
+
+ /** Получить текущий state (для UI-overlay). */
+ getState() { return this._state; }
+
+ /**
+ * Открыть модал. opts — см. doc по задаче 04.
+ * Возвращает modalId (число).
+ */
+ open(opts) {
+ opts = opts || {};
+ console.log('[ModalManager] open called, opts:', opts);
+ // Если уже открыт — мгновенно закрываем (без fadeOut, чтобы не плодить
+ // одновременных модалов).
+ if (this.isOpen()) this._instantClose();
+
+ const id = ++_seq;
+ const norm = {
+ darken: Number.isFinite(opts.darken) ? Math.max(0, Math.min(1, opts.darken)) : 0.5,
+ darkenColor: typeof opts.darkenColor === 'string' ? opts.darkenColor : '#000000',
+ target: opts.target === 'screen' ? 'screen' : 'scene',
+ blockInput: opts.blockInput !== false, // по умолчанию true
+ freezeCamera: !!opts.freezeCamera,
+ cameraOverride: opts.cameraOverride || null,
+ fadeIn: Number.isFinite(opts.fadeIn) ? Math.max(0, opts.fadeIn) : 0.3,
+ fadeOut: Number.isFinite(opts.fadeOut) ? Math.max(0, opts.fadeOut) : 0.3,
+ spotlights: Array.isArray(opts.spotlights) ? opts.spotlights.slice() : [],
+ spotlightRadius: Number.isFinite(opts.spotlightRadius) ? opts.spotlightRadius : 120,
+ spotlightSoftEdge: Number.isFinite(opts.spotlightSoftEdge) ? opts.spotlightSoftEdge : 40,
+ pauseSimulation: !!opts.pauseSimulation,
+ muteWorld: !!opts.muteWorld,
+ content: opts.content || null,
+ };
+
+ this._state = {
+ id,
+ opts: norm,
+ fadePhase: 'in',
+ fadeStart: this._now(),
+ fadeFrom: 0,
+ fadeTo: norm.darken,
+ currentAlpha: 0,
+ tempGuiIds: [],
+ spotlightScreens: [],
+ };
+
+ // 1) Block input
+ if (norm.blockInput) {
+ try { this._player?.setInputBlocked?.(true); } catch (e) {}
+ }
+ // 2) Freeze camera (сохраняем текущее состояние для восстановления)
+ if (norm.freezeCamera) {
+ try {
+ this._savedCameraState = this._player?.captureCameraState?.() || null;
+ this._player?.setCameraFrozen?.(true);
+ } catch (e) {}
+ }
+ // 3) Camera override — переключение на focusOn
+ if (norm.cameraOverride && this._scene) {
+ this._applyCameraOverride(norm.cameraOverride);
+ }
+ // 4) Pause simulation
+ if (norm.pauseSimulation && this._runtime) {
+ try { this._runtime.paused = true; } catch (e) {}
+ }
+ // 5) Mute world audio
+ if (norm.muteWorld && this._audio) {
+ try { this._audio.duck?.(0.3); } catch (e) {}
+ }
+ // 6) Highlight spotlight-объектов в Babylon
+ if (norm.spotlights.length && norm.target === 'scene' && this._scene) {
+ this._applyHighlight(norm.spotlights);
+ }
+ // 7) content.elements — создать временные GUI-элементы
+ if (norm.content?.elements && this._gui) {
+ this._createTempGui(norm.content.elements);
+ }
+
+ this._notify();
+ return id;
+ }
+
+ /** Закрыть модал. Если modalId передан и не совпадает — игнор. */
+ close(modalId) {
+ if (!this._state) return;
+ if (modalId != null && this._state.id !== modalId) return;
+ if (this._state.fadePhase === 'out' || this._state.fadePhase === 'closed') return;
+ this._state.fadePhase = 'out';
+ this._state.fadeStart = this._now();
+ this._state.fadeFrom = this._state.currentAlpha;
+ this._state.fadeTo = 0;
+ this._notify();
+ }
+
+ /** Поменять параметры на лету. */
+ update(modalId, patch) {
+ if (!this._state) return;
+ if (modalId != null && this._state.id !== modalId) return;
+ if (!patch || typeof patch !== 'object') return;
+ Object.assign(this._state.opts, patch);
+ // Если поменяли darken — плавно tween-им currentAlpha к новому значению
+ if (Number.isFinite(patch.darken) && this._state.fadePhase !== 'out') {
+ this._state.fadeFrom = this._state.currentAlpha;
+ this._state.fadeTo = patch.darken;
+ this._state.fadeStart = this._now();
+ this._state.fadePhase = 'in';
+ }
+ this._notify();
+ }
+
+ /** Подписаться на закрытие. fn получает modalId. */
+ onClose(fn) {
+ if (typeof fn === 'function') this._closeCallbacks.push(fn);
+ }
+
+ /** Обновление за кадр — двигает fade-phase и spotlight-screens. dt в секундах. */
+ tick(dt) {
+ if (!this._state) return;
+ const st = this._state;
+ if (!this._tickLogged) {
+ this._tickLogged = true;
+ console.log('[ModalManager] first tick, phase:', st.fadePhase, 'alpha:', st.currentAlpha);
+ }
+
+ // 1) Fade-tween
+ if (st.fadePhase === 'in' || st.fadePhase === 'out') {
+ const dur = st.fadePhase === 'in' ? st.opts.fadeIn : st.opts.fadeOut;
+ const elapsed = (this._now() - st.fadeStart) / 1000;
+ const t = dur > 0 ? Math.min(1, elapsed / dur) : 1;
+ // ease-out cubic
+ const k = 1 - Math.pow(1 - t, 3);
+ st.currentAlpha = st.fadeFrom + (st.fadeTo - st.fadeFrom) * k;
+ if (t >= 1) {
+ if (st.fadePhase === 'in') {
+ st.fadePhase = 'visible';
+ } else {
+ // close завершился — финальная уборка
+ st.fadePhase = 'closed';
+ this._teardown();
+ }
+ }
+ }
+
+ // 2) Обновить экранные координаты spotlight'ов (объекты могут двигаться)
+ if (st.fadePhase !== 'closed' && st.opts.spotlights.length && st.opts.target === 'scene') {
+ st.spotlightScreens = this._computeSpotlightScreens(st.opts.spotlights);
+ }
+
+ this._notify();
+ }
+
+ // ===== private =====
+
+ _now() {
+ return (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now();
+ }
+
+ _instantClose() {
+ if (!this._state) return;
+ this._teardown();
+ this._state = null;
+ }
+
+ _teardown() {
+ const st = this._state;
+ if (!st) return;
+ // 1) Unblock input
+ if (st.opts.blockInput) {
+ try { this._player?.setInputBlocked?.(false); } catch (e) {}
+ }
+ // 2) Unfreeze camera
+ if (st.opts.freezeCamera) {
+ try { this._player?.setCameraFrozen?.(false); } catch (e) {}
+ }
+ // 3) Camera reset — только если был cameraOverride
+ if (st.opts.cameraOverride && this._savedCameraState) {
+ try { this._player?.restoreCameraState?.(this._savedCameraState); } catch (e) {}
+ this._savedCameraState = null;
+ }
+ // 4) Unpause
+ if (st.opts.pauseSimulation && this._runtime) {
+ try { this._runtime.paused = false; } catch (e) {}
+ }
+ // 5) Unmute
+ if (st.opts.muteWorld && this._audio) {
+ try { this._audio.unduck?.(); } catch (e) {}
+ }
+ // 6) Снять highlight
+ if (this._highlight) {
+ try { this._highlight.removeAllMeshes(); } catch (e) {}
+ }
+ // 7) Удалить temp GUI
+ if (st.tempGuiIds.length && this._gui) {
+ for (const id of st.tempGuiIds) {
+ try { this._gui.remove(id); } catch (e) {}
+ }
+ }
+ // 8) Колбэки onClose
+ for (const cb of this._closeCallbacks) {
+ try { cb(st.id); } catch (e) {}
+ }
+ }
+
+ _applyCameraOverride(co) {
+ // Используем существующий camera.focusOn механизм из BabylonScene/PlayerController
+ try {
+ const ref = co.target;
+ const distance = Number.isFinite(co.distance) ? co.distance : 8;
+ const height = Number.isFinite(co.height) ? co.height : 3;
+ const fov = Number.isFinite(co.fov) ? co.fov : null;
+ const duration = Number.isFinite(co.duration) ? co.duration : 0.5;
+ if (this._player?.focusOnTarget) {
+ this._player.focusOnTarget(ref, { distance, height, fov, duration });
+ } else if (this._scene?._gameRuntime?._handleCommand) {
+ // fallback через runtime — отправляем camera.focus
+ this._scene._gameRuntime._handleCommand(null, 'camera.focus', {
+ ref, distance, height, fov, duration,
+ });
+ }
+ } catch (e) {}
+ }
+
+ _applyHighlight(refs) {
+ if (!this._scene) return;
+ // Лениво создаём HighlightLayer
+ if (!this._highlight) {
+ try {
+ const BABYLON = window.BABYLON || (typeof globalThis !== 'undefined' ? globalThis.BABYLON : null);
+ if (BABYLON?.HighlightLayer && this._scene.scene) {
+ this._highlight = new BABYLON.HighlightLayer('modal-spotlight', this._scene.scene);
+ this._highlight.innerGlow = false;
+ this._highlight.outerGlow = true;
+ }
+ } catch (e) {}
+ }
+ if (!this._highlight) return;
+ try { this._highlight.removeAllMeshes(); } catch (e) {}
+ const BABYLON = window.BABYLON;
+ const glowColor = (BABYLON && BABYLON.Color3)
+ ? new BABYLON.Color3(1, 1, 0.6)
+ : null;
+ for (const ref of refs) {
+ const meshes = this._resolveMeshes(ref);
+ for (const m of meshes) {
+ try {
+ if (glowColor) this._highlight.addMesh(m, glowColor);
+ } catch (e) {}
+ }
+ }
+ }
+
+ /** Резолв ref → массив Babylon-мешей.
+ * ref может быть: строка-id, объект ref-обёртка ({kind, id}), либо сам Mesh. */
+ _resolveMeshes(ref) {
+ if (!ref || !this._scene) return [];
+ // Уже Mesh-инстанс
+ if (ref.getScene && typeof ref.getScene === 'function') return [ref];
+
+ const sc = this._scene;
+ const idStr = (typeof ref === 'string') ? ref : (ref.id || ref.refId || null);
+ if (!idStr) return [];
+
+ // Пробуем разные менеджеры
+ const tryGetters = [
+ () => sc.primitiveManager?.getMesh?.(idStr),
+ () => sc.modelManager?.getInstanceMeshes?.(idStr),
+ () => sc.scene?.getMeshByName?.(idStr),
+ () => sc.npcManager?.getMeshes?.(idStr),
+ () => sc.zombieManager?.getMeshes?.(idStr),
+ ];
+ for (const g of tryGetters) {
+ try {
+ const r = g();
+ if (!r) continue;
+ if (Array.isArray(r)) return r;
+ return [r];
+ } catch (e) {}
+ }
+ return [];
+ }
+
+ /** Проектируем 3D-позиции spotlight-refs в экранные координаты для CSS-mask. */
+ _computeSpotlightScreens(refs) {
+ if (!this._scene?.scene) return [];
+ const out = [];
+ const BABYLON = window.BABYLON;
+ if (!BABYLON) return [];
+ const engine = this._scene.scene.getEngine();
+ const camera = this._scene.scene.activeCamera;
+ if (!camera || !engine) return [];
+ const w = engine.getRenderWidth();
+ const h = engine.getRenderHeight();
+ const matrix = camera.getTransformationMatrix();
+ const viewport = camera.viewport.toGlobal(w, h);
+ for (const ref of refs) {
+ const meshes = this._resolveMeshes(ref);
+ if (!meshes.length) continue;
+ const m = meshes[0];
+ try {
+ const pos = m.getAbsolutePosition?.() || m.position;
+ if (!pos) continue;
+ // Center проектируем
+ const proj = BABYLON.Vector3.Project(pos, BABYLON.Matrix.Identity(), matrix, viewport);
+ // Если за камерой — скип (z вне 0..1)
+ if (proj.z < 0 || proj.z > 1) continue;
+ // Радиус — фиксированный из opts (можно потом масштабировать по distance/size)
+ out.push({ x: proj.x, y: proj.y, r: this._state.opts.spotlightRadius });
+ } catch (e) {}
+ }
+ return out;
+ }
+
+ _createTempGui(elements) {
+ if (!Array.isArray(elements) || !this._gui) return;
+ for (const el of elements) {
+ if (!el || typeof el !== 'object') continue;
+ const kind = el.kind || el.type || 'frame';
+ const opts = { ...el };
+ delete opts.kind;
+ delete opts.type;
+ try {
+ const id = this._gui.create(kind, opts);
+ if (id) this._state.tempGuiIds.push(id);
+ } catch (e) {}
+ }
+ }
+}
diff --git a/src/engine/PlayerController.js b/src/engine/PlayerController.js
index 142f4ad..c3ff5b5 100644
--- a/src/engine/PlayerController.js
+++ b/src/engine/PlayerController.js
@@ -144,6 +144,12 @@ export class PlayerController {
// Камера. Дефолт — первое лицо (как в большинстве игр).
this._cameraMode = 'third';
this._thirdDistance = this.THIRD_DISTANCE_DEFAULT;
+ // Порог авто-перехода third→first при зуме колесом (Roblox-style).
+ this.FIRST_PERSON_ZOOM_THRESHOLD = 0.7;
+ // Если true — нельзя выйти из first-person зумом (lockfirst-режим).
+ this._lockFirstPerson = false;
+ // Shift-Lock (Roblox-style): курсор зафиксирован, корпус лицом к камере.
+ this._shiftLock = false;
// Ввод
this._codes = new Set();
@@ -185,6 +191,21 @@ export class PlayerController {
this._skinManifest = null; // кеш skins_manifest.json
this._skinOverrides = {}; // overrides текущего скина
+ // === non-humanoid скины (задача 07) ===
+ // Скин без R15-скелета (животное, машина, абстрактная модель).
+ // Для них центрируем pivot, считаем собственный AABB и анимируем
+ // процедурно через _animateNonHumanoidMesh (см. _loadPlayerModel/_tick).
+ this._modelKind = 'r15'; // 'r15' | 'non-humanoid-mesh'
+ this._modelHipHeight = null; // локальная база модели (опущена на ноги)
+ this._nonHumanoidBox = null; // {hw,hh,hd} собственный AABB модели
+ this._lastFrameSpeed = 0; // горизонтальная скорость кадра (для анимаций)
+ this._isGrounded = true; // флаг «на земле» (для анимаций)
+
+ // === Блокировка ввода/камеры для модалов (задача 04) ===
+ this._inputBlocked = false; // глотает игровой ввод (кроме Esc/Tab/Enter)
+ this._cameraFrozen = false; // замораживает вращение/зум камеры
+ this._savedCameraState = null;// сохранённое состояние камеры (focusOnTarget)
+
// === Жизни игрока ===
this.maxHp = 100;
this.hp = 100;
@@ -296,6 +317,44 @@ export class PlayerController {
this._modelTypeId = typeId || 'character-a';
}
+ /**
+ * Сменить скин ИГРОКА в Play-режиме без перезапуска сцены (задача 07).
+ * Выгружает текущую модель/скелет/аниматор, сбрасывает AABB к дефолту,
+ * грузит новую модель (R15 или non-humanoid). Возвращает Promise.
+ *
+ * Используется из game.player.setSkin(slug).
+ */
+ async reloadSkin(typeId) {
+ if (!this._active) return false;
+ const newType = typeId || 'character-a';
+ if (newType === this._modelTypeId && this._modelRoot) return true; // уже этот скин
+ // 1) Выгрузить текущую модель и связанные аниматоры.
+ try {
+ if (this._modelRoot) { this._modelRoot.dispose(false, true); }
+ } catch (e) { /* ignore */ }
+ this._modelRoot = null;
+ this._modelMeshes = [];
+ this._rightArmMeshes = [];
+ this._r15Skeleton = null;
+ this._r15Animator = null;
+ this._isR15 = false;
+ this._modelKind = 'r15';
+ this._modelHipHeight = null;
+ this._nonHumanoidBox = null;
+ // 2) Сбросить AABB к дефолтному «человеку» (non-humanoid его переопределит).
+ this.HALF_W = 0.3;
+ this.HALF_H = 0.9;
+ this.HALF_D = 0.3;
+ this.HALF_H_NORMAL = 0.9;
+ this.EYE_HEIGHT = 0.7;
+ // 3) Поднять игрока на запас, чтобы новый AABB не оказался в полу.
+ this._pos.y += 0.5;
+ // 4) Загрузить новую модель.
+ this._modelTypeId = newType;
+ await this._loadPlayerModel();
+ return !!this._modelRoot;
+ }
+
/**
* Запустить режим игры.
* spawnPos — точка спавна. Если не указано — (0, 5, 0).
@@ -677,10 +736,34 @@ export class PlayerController {
// Прямой URL (для preview-режима или тестов).
return { file: typeId, isR15: true, overrides: {} };
}
+ // Кастомный .glb пользователя: 'customskin:'. dataUrl + метаданные
+ // (scale/hipHeight) лежат в scene._skinsConfig.customGlbs.
+ if (typeId.startsWith('customskin:')) {
+ const slug = typeId.slice('customskin:'.length);
+ const list = this._scene3d?._skinsConfig?.customGlbs || [];
+ const meta = list.find(g => g && g.slug === slug) || null;
+ const url = this._scene3d?.getAssetDataUrl?.(slug) || (meta && meta.dataUrl) || null;
+ if (url) {
+ return {
+ file: url, isR15: false, kind: 'non-humanoid-mesh', overrides: {},
+ scaleManifest: meta?.scale ?? 1.5,
+ hipHeight: meta?.hipHeight ?? 0.4,
+ rotationYOffset: meta?.rotationYOffset ?? 0,
+ isDataUrl: true,
+ };
+ }
+ return null;
+ }
if (typeId.startsWith('skin_')) {
const manifest = await this._loadSkinManifest();
const entry = manifest.find((s) => s.id === typeId);
if (entry) {
+ // kind определяет систему анимации:
+ // 'r15' → R15-скелет (как раньше)
+ // 'non-humanoid-mesh' → single-mesh, процедурное покачивание
+ // 'non-humanoid-rigged' → свой скелет + встроенные AnimationGroup
+ // Отсутствие kind = 'r15' (обратная совместимость со старым манифестом).
+ const kind = entry.kind || 'r15';
// absolute_file=true (источник /rublox/avatars) — file уже
// полный URL (legacy /kubikon-assets/... или дизайнерский
// /api-storys/...). Без флага — это легаси-формат
@@ -690,20 +773,25 @@ export class PlayerController {
: '/kubikon-assets/' + entry.file;
return {
file,
- isR15: true,
+ isR15: kind === 'r15',
+ kind,
overrides: entry.overrides || {},
+ scaleManifest: Number.isFinite(entry.scale) ? entry.scale : null,
+ hipHeight: Number.isFinite(entry.hipHeight) ? entry.hipHeight : null,
+ rotationYOffset: Number.isFinite(entry.rotationYOffset) ? entry.rotationYOffset : 0,
};
}
// нет в манифесте — пробуем прямой путь
return {
file: `/kubikon-assets/characters/${typeId}/body.glb`,
isR15: true,
+ kind: 'r15',
overrides: {},
};
}
const modelType = getModelType(typeId);
if (!modelType) return null;
- return { file: modelType.file, isR15: false, overrides: {} };
+ return { file: modelType.file, isR15: false, kind: 'r15', overrides: {} };
}
/** Подгрузить metadata designer-аватара по id через api-storys. */
@@ -830,9 +918,17 @@ export class PlayerController {
absFile = 'https://minecraftia-school.ru' + absFile;
}
}
- const lastSlash = absFile.lastIndexOf('/');
- const rootUrl = absFile.substring(0, lastSlash + 1);
- const filename = absFile.substring(lastSlash + 1);
+ let rootUrl, filename;
+ if (source.isDataUrl) {
+ // Кастомный скин — data:URL. SceneLoader принимает его как rootUrl=''
+ // и filename=data:... с подсказкой расширения через 5-й аргумент.
+ rootUrl = '';
+ filename = absFile;
+ } else {
+ const lastSlash = absFile.lastIndexOf('/');
+ rootUrl = absFile.substring(0, lastSlash + 1);
+ filename = absFile.substring(lastSlash + 1);
+ }
// eslint-disable-next-line no-console
console.log(`[PlayerController] SceneLoader.Load: ${rootUrl}${filename}`);
// Прогресс-индикатор для больших GLB (некоторые дизайнерские
@@ -858,6 +954,7 @@ export class PlayerController {
try {
container = await SceneLoader.LoadAssetContainerAsync(
rootUrl, filename, this.scene, onProgress,
+ source.isDataUrl ? '.glb' : undefined,
);
try { window.__playerLoadProgress = null; } catch (e) {}
} catch (e) {
@@ -880,10 +977,20 @@ export class PlayerController {
// с торчащими волосами/плащами (как у bacon-hair).
// - Kenney-модели: старый 0.72.
// - overrides.scale_mult — per-skin множитель из манифеста.
- let modelScale = source.isR15 ? 0.301 : this._modelScale;
- const scaleMult = (source.overrides && source.overrides.scale_mult) || 1.0;
- modelScale *= scaleMult;
+ // Non-humanoid скины (животное/машина/еда) масштабируются иначе:
+ // базовый размер из манифеста (scale), без фикс-0.301.
+ const isNonHumanoid = source.kind === 'non-humanoid-mesh'
+ || source.kind === 'non-humanoid-rigged';
+ let modelScale;
+ if (isNonHumanoid) {
+ modelScale = Number.isFinite(source.scaleManifest) ? source.scaleManifest : 1.0;
+ } else {
+ modelScale = source.isR15 ? 0.301 : this._modelScale;
+ const scaleMult = (source.overrides && source.overrides.scale_mult) || 1.0;
+ modelScale *= scaleMult;
+ }
root.scaling = new Vector3(modelScale, modelScale, modelScale);
+ if (source.rotationYOffset) root.rotation.y = source.rotationYOffset;
const inst = container.instantiateModelsToScene(
(name) => `player_${name}`,
/*cloneAnimations*/ true,
@@ -900,6 +1007,14 @@ export class PlayerController {
}
}
this._modelRoot = root;
+ this._modelKind = source.kind || 'r15';
+ // hipHeight: на сколько центр модели поднят от «низа ног».
+ this._modelHipHeight = Number.isFinite(source.hipHeight) ? source.hipHeight : null;
+
+ // Non-humanoid: нормализуем размер и опускаем модель на «ноги».
+ if (isNonHumanoid) {
+ this._setupNonHumanoidModel(root, modelScale, source);
+ }
// === R15-скин: детекция скелета ===
// R15-скины приходят с встроенным скелетом Mixamo. Babylon
@@ -1050,6 +1165,121 @@ export class PlayerController {
}
}
+ /**
+ * Настройка non-humanoid модели (животное/машина/еда): нормализация
+ * размера и опускание на «низ ног». В отличие от R15 (нормализованы
+ * пайплайном), эти модели произвольного размера, поэтому считаем bbox.
+ *
+ * Локальные координаты root: модель должна стоять так, чтобы её низ был
+ * на y=0 (там «ноги»). PlayerController позиционирует root в точке
+ * `_pos.y - HALF_H` (низ AABB) — модель «садится» на землю.
+ */
+ _setupNonHumanoidModel(root, scaleApplied, source) {
+ try {
+ // Считаем bounding box всех дочерних мешей в world-координатах ПОСЛЕ
+ // применения scaling root'а. Babylon refreshBoundingInfo нужен после
+ // инстансинга.
+ const meshes = root.getChildMeshes(false).filter(m => m.getBoundingInfo && m.getTotalVertices && m.getTotalVertices() > 0);
+ if (!meshes.length) return;
+ root.computeWorldMatrix(true);
+ let minY = Infinity, maxY = -Infinity, maxDim = 0;
+ let minX = Infinity, maxX = -Infinity, minZ = Infinity, maxZ = -Infinity;
+ for (const m of meshes) {
+ m.computeWorldMatrix(true);
+ // refreshBoundingInfo(true) — пересчитать bbox с учётом возможного
+ // скелета/морфов; без него minimumWorld у инстансов часто нулевой
+ // или из исходной позы → центр считался неверно (баг пришельца/робота).
+ try { m.refreshBoundingInfo(true); } catch (e) { try { m.refreshBoundingInfo(); } catch (e2) {} }
+ const bi = m.getBoundingInfo();
+ const bb = bi.boundingBox;
+ const lo = bb.minimumWorld, hi = bb.maximumWorld;
+ if (!lo || !hi) continue;
+ minY = Math.min(minY, lo.y); maxY = Math.max(maxY, hi.y);
+ minX = Math.min(minX, lo.x); maxX = Math.max(maxX, hi.x);
+ minZ = Math.min(minZ, lo.z); maxZ = Math.max(maxZ, hi.z);
+ }
+ if (!Number.isFinite(minX) || !Number.isFinite(minY)) return;
+ const h = maxY - minY;
+ const w = maxX - minX;
+ const d = maxZ - minZ;
+ maxDim = Math.max(h, w, d);
+ // === Центрирование модели через pivot-node ===
+ // Многие Kenney-модели имеют origin НЕ в геометрическом центре
+ // (в углу/ноге) → при повороте модель «облетает» вокруг смещённого
+ // origin (баг пришельца/робота). Ручной сдвиг детей с делением на
+ // scaleApplied неверен если у детей свой scale/rotation. Надёжно:
+ // вставляем промежуточный pivot между root и моделью и смещаем pivot
+ // на -localCenter (через инверсию world-матрицы root — точно при
+ // любом scale/rotation).
+ const worldCenter = new Vector3(
+ (minX + maxX) / 2, // центр X
+ minY, // низ Y (модель «садится» на ноги)
+ (minZ + maxZ) / 2 // центр Z
+ );
+ // world-центр → локальные координаты root
+ const invRoot = root.getWorldMatrix().clone().invert();
+ const localCenter = Vector3.TransformCoordinates(worldCenter, invRoot);
+ const pivot = new TransformNode('playerModelPivot', this.scene);
+ pivot.parent = root;
+ pivot.position.set(-localCenter.x, -localCenter.y, -localCenter.z);
+ // Перевешиваем все прямые дочерние ноды root (кроме самого pivot) на pivot.
+ for (const ch of root.getChildren().slice()) {
+ if (ch === pivot) continue;
+ ch.parent = pivot;
+ }
+ // Сохраняем размеры для настраиваемого AABB и камеры.
+ // hipHeight из манифеста — приоритетно; иначе берём низ модели.
+ this._nonHumanoidBox = { w, h, d };
+ this._modelBaseHeight = h;
+ // AABB подгоняем под модель (плоская/широкая для машин, узкая для еды).
+ // Ограничиваем разумными пределами чтобы не проваливаться/застревать.
+ this.HALF_W = Math.max(0.25, Math.min(0.9, w / 2));
+ this.HALF_D = Math.max(0.25, Math.min(1.1, d / 2));
+ const halfH = Math.max(0.3, Math.min(1.0, h / 2));
+ this.HALF_H = halfH;
+ this.HALF_H_NORMAL = halfH;
+ this.EYE_HEIGHT = halfH * 0.7;
+ // eslint-disable-next-line no-console
+ console.log('[PlayerController] non-humanoid setup:', this._modelTypeId,
+ 'kind=' + source.kind, 'bbox(w/h/d)=' + w.toFixed(2) + '/' + h.toFixed(2) + '/' + d.toFixed(2),
+ 'AABB(W/H/D)=' + this.HALF_W.toFixed(2) + '/' + this.HALF_H.toFixed(2) + '/' + this.HALF_D.toFixed(2));
+ } catch (e) {
+ // eslint-disable-next-line no-console
+ console.warn('[PlayerController] _setupNonHumanoidModel failed:', e);
+ }
+ }
+
+ /**
+ * Процедурная анимация single-mesh скина (нет скелета — нечего анимировать
+ * костями). Лёгкое покачивание вверх-вниз при движении + наклон вперёд при
+ * беге + наклон в воздухе. Вызывается каждый кадр из _tick.
+ * baseY — локальная база модели (опущена на ноги в _setupNonHumanoidModel).
+ */
+ _animateNonHumanoidMesh(dt) {
+ const root = this._modelRoot;
+ if (!root) return;
+ const t = (typeof performance !== 'undefined' && performance.now)
+ ? performance.now() / 1000 : Date.now() / 1000;
+ const speed = this._lastFrameSpeed || 0;
+ // Базовое вращение по yaw уже выставляет _tick (он крутит модель под
+ // направление движения/камеры). Мы добавляем ТОЛЬКО процедурный bob/tilt
+ // поверх — храним их в отдельных полях, чтобы _tick их не перетёр.
+ let bobY = 0, tiltX = 0;
+ if (!this._isGrounded) {
+ tiltX = 0.2; // в воздухе — нос вверх
+ } else if (speed > 0.1) {
+ const bobFreq = 8 * Math.min(2, speed / 4);
+ bobY = Math.sin(t * bobFreq) * 0.06;
+ tiltX = Math.min(speed * 0.04, 0.13);
+ } else {
+ bobY = Math.sin(t * 1.5) * 0.015; // лёгкое «дыхание» в покое
+ }
+ // Применяем поверх позиции, которую _tick уже выставил в root.position.y.
+ root.position.y += bobY;
+ // tilt по локальной оси X модели. rotation.y уже выставлен _tick'ом.
+ root.rotation.x = tiltX;
+ }
+
// ─── Аксессуары (Подфаза 3.3 RUBLOX_ACCESSORY_ATTACHMENT_PLAN.md) ──
/**
@@ -1795,6 +2025,169 @@ export class PlayerController {
this._applyCameraMode();
}
+ /** Включить/выключить Shift-Lock (Roblox-style: курсор зафиксирован, корпус
+ * всегда лицом к камере, камера через плечо).
+ */
+ setShiftLock(on) {
+ this._shiftLock = !!on;
+ if (this._shiftLock) {
+ // Запросить pointer-lock — курсор в центре
+ this._requestPointerLockSafe();
+ } else {
+ // Снять lock если он есть и нет других причин держать (first/sideview)
+ const needPermLock = (
+ this._cameraMode === 'first' ||
+ this._cameraMode === 'lockfirst' ||
+ this._cameraMode === 'sideview'
+ );
+ if (!needPermLock && document.pointerLockElement === this.canvas) {
+ try { document.exitPointerLock(); } catch (e) {}
+ }
+ }
+ this._applyCursorVisibility?.();
+ }
+ isShiftLock() { return !!this._shiftLock; }
+
+ /** Задача 04: блокирует игровой ввод (WASD/Space/Ctrl/Shift/etc).
+ * Не блокирует Esc/Tab/Enter (нужны для GUI).
+ * Также сбрасывает накопленные клавиши чтобы движение остановилось. */
+ setInputBlocked(blocked) {
+ this._inputBlocked = !!blocked;
+ if (this._inputBlocked) {
+ try { this._codes?.clear(); } catch (e) {}
+ this._shift = false;
+ // Снимаем pointer-lock — иначе мышь застрянет «в режиме игры»
+ try {
+ if (document.pointerLockElement === this.canvas) document.exitPointerLock();
+ } catch (e) {}
+ }
+ }
+ isInputBlocked() { return !!this._inputBlocked; }
+
+ /** Задача 04: замораживает камеру (не вращается мышью, не зумится колесом). */
+ setCameraFrozen(frozen) {
+ this._cameraFrozen = !!frozen;
+ }
+ isCameraFrozen() { return !!this._cameraFrozen; }
+
+ /** Задача 04: снимок состояния камеры — для восстановления после модала. */
+ captureCameraState() {
+ return {
+ yaw: this._yaw,
+ pitch: this._pitch,
+ cameraMode: this._cameraMode,
+ thirdDistance: this._thirdDistance,
+ fov: this.scene?.activeCamera?.fov,
+ playerPos: this._pos ? {
+ x: this._pos.x, y: this._pos.y, z: this._pos.z
+ } : null,
+ };
+ }
+
+ /** Задача 04: восстановить состояние камеры из снимка. */
+ restoreCameraState(s) {
+ if (!s) return;
+ if (Number.isFinite(s.yaw)) this._yaw = s.yaw;
+ if (Number.isFinite(s.pitch)) this._pitch = s.pitch;
+ if (s.cameraMode) {
+ this._cameraMode = s.cameraMode;
+ try { this._applyCameraMode?.(); } catch (e) {}
+ }
+ if (Number.isFinite(s.thirdDistance)) this._thirdDistance = s.thirdDistance;
+ if (Number.isFinite(s.fov) && this.scene?.activeCamera) {
+ this.scene.activeCamera.fov = s.fov;
+ }
+ }
+
+ /** Задача 04: камера-фокус на reference (cube/npc/cam-target).
+ * ref может быть: строка-id, ref-объект ({kind, id}), Mesh, или {x,y,z}.
+ * Использует уже существующий механизм camera.focus в GameRuntime, но
+ * здесь упрощённая обёртка: ставим yaw/pitch так, чтобы смотреть на цель,
+ * и зум на distance. */
+ focusOnTarget(ref, opts) {
+ opts = opts || {};
+ const distance = Number.isFinite(opts.distance) ? opts.distance : 8;
+ const height = Number.isFinite(opts.height) ? opts.height : 3;
+ const fov = Number.isFinite(opts.fov) ? opts.fov : null;
+ let target = null;
+ if (ref && typeof ref === 'object' && 'x' in ref && 'y' in ref && 'z' in ref) {
+ target = ref;
+ } else {
+ const m = this._resolveTargetMesh(ref);
+ if (m) {
+ const p = m.getAbsolutePosition?.() || m.position;
+ target = { x: p.x, y: p.y, z: p.z };
+ }
+ }
+ if (!target) return;
+ // Прицельный взгляд: позиция камеры за игроком на distance, направление — на target
+ // Но мы во freezeCamera режиме — лучше через прямой override yaw/pitch.
+ if (!this._pos) return;
+ const dx = target.x - this._pos.x;
+ const dz = target.z - this._pos.z;
+ const dy = target.y - this._pos.y;
+ const horiz = Math.hypot(dx, dz);
+ this._yaw = Math.atan2(dx, dz);
+ this._pitch = Math.max(-1.4, Math.min(1.4, -Math.atan2(dy, horiz)));
+ this._thirdDistance = distance;
+ if (this._cameraMode !== 'third') {
+ this._cameraMode = 'third';
+ try { this._applyCameraMode?.(); } catch (e) {}
+ }
+ if (fov && this.scene?.activeCamera) {
+ this.scene.activeCamera.fov = fov * Math.PI / 180;
+ }
+ }
+
+ _resolveTargetMesh(ref) {
+ if (!ref) return null;
+ if (ref.getScene && typeof ref.getScene === 'function') return ref;
+ const sc = this._scene3d || this.scene3d;
+ const idStr = (typeof ref === 'string') ? ref : (ref.id || ref.refId || null);
+ if (!idStr || !sc) return null;
+ const tries = [
+ () => sc.primitiveManager?.getMesh?.(idStr),
+ () => sc.modelManager?.getInstanceMeshes?.(idStr)?.[0],
+ () => sc.scene?.getMeshByName?.(idStr),
+ () => sc.npcManager?.getMeshes?.(idStr)?.[0],
+ ];
+ for (const fn of tries) {
+ try { const r = fn(); if (r) return r; } catch (e) {}
+ }
+ return null;
+ }
+
+ /** Прямо установить дистанцию камеры (для third). Кламп в min/max. */
+ setCameraZoom(distance) {
+ const d = Number(distance);
+ if (!Number.isFinite(d)) return;
+ this._thirdDistance = Math.max(this.THIRD_DISTANCE_MIN,
+ Math.min(this.THIRD_DISTANCE_MAX, d));
+ // Авто-переход third↔first если пересекли порог
+ if (this._thirdDistance <= this.FIRST_PERSON_ZOOM_THRESHOLD
+ && this._cameraMode === 'third') {
+ this._cameraMode = 'first';
+ this._applyCameraMode?.();
+ this._requestPointerLockSafe();
+ } else if (this._thirdDistance > this.FIRST_PERSON_ZOOM_THRESHOLD
+ && this._cameraMode === 'first' && !this._lockFirstPerson) {
+ this._cameraMode = 'third';
+ this._applyCameraMode?.();
+ if (!this._shiftLock && document.pointerLockElement === this.canvas) {
+ try { document.exitPointerLock(); } catch (e) {}
+ }
+ }
+ }
+ /** Установить границы зума колеса. */
+ setCameraZoomLimits(min, max) {
+ const mn = Number(min), mx = Number(max);
+ if (Number.isFinite(mn) && mn >= 0) this.THIRD_DISTANCE_MIN = mn;
+ if (Number.isFinite(mx) && mx > 0) this.THIRD_DISTANCE_MAX = mx;
+ // Перекламп текущей дистанции
+ this._thirdDistance = Math.max(this.THIRD_DISTANCE_MIN,
+ Math.min(this.THIRD_DISTANCE_MAX, this._thirdDistance));
+ }
+
_setupInput() {
const canvas = this.canvas;
@@ -1849,6 +2242,8 @@ export class PlayerController {
if (document.pointerLockElement !== canvas) return;
// Кубикон Dash: в sideview мышь не вращает камеру.
if (this._cameraMode === 'sideview') return;
+ // Задача 04: модал с freezeCamera — мышь не вращает.
+ if (this._cameraFrozen) return;
this._yaw += e.movementX * this.MOUSE_SENSITIVITY;
// _invertCamera (GameMenu Настройки → Инвертировать) — флипает Y.
const pitchSign = this._invertCamera ? -1 : 1;
@@ -1862,6 +2257,8 @@ export class PlayerController {
// Колесо в 3rd-person — меняет дистанцию
const onWheel = (e) => {
if (!this._active) return;
+ // Задача 04: модал с freezeCamera — колесо не зумит.
+ if (this._cameraFrozen) { e.preventDefault(); return; }
if (this._cameraMode !== 'third') return;
this._thirdDistance += Math.sign(e.deltaY) * 0.5;
if (this._thirdDistance < this.THIRD_DISTANCE_MIN) this._thirdDistance = this.THIRD_DISTANCE_MIN;
@@ -1892,6 +2289,23 @@ export class PlayerController {
const onKeyDown = (e) => {
if (!this._active) return;
if (isTypingTarget(e.target)) return;
+ // Задача 04: Esc в Play — приоритет закрытие модала через _onExitRequest
+ // (там стоит проверка modalManager.isOpen). Без явного перехвата Esc
+ // в third (без pointer-lock) сразу выходил из Play.
+ if (e.code === 'Escape') {
+ if (this._onExitRequest) {
+ this._onExitRequest();
+ return;
+ }
+ }
+ // Задача 04: модал блокирует игровой ввод (WASD/Space/Ctrl/Shift и т.п.),
+ // но НЕ блокирует Esc (нужен для закрытия модала через Esc-обработчик),
+ // и НЕ блокирует Tab/Enter (могут понадобиться в GUI-модалах).
+ if (this._inputBlocked && e.code !== 'Escape' && e.code !== 'Tab' && e.code !== 'Enter') {
+ // Глотаем preventDefault только для игровых клавиш
+ if (['Space','ArrowUp','ArrowDown','ArrowLeft','ArrowRight'].includes(e.code)) e.preventDefault();
+ return;
+ }
this._codes.add(e.code);
if (e.shiftKey) this._shift = true;
// C — переключение first/third. Отключаем в GD-режиме (автобег > 0)
@@ -1901,6 +2315,17 @@ export class PlayerController {
|| this._shipMode || this._ufoMode || this._waveMode || this._robotMode;
if (!inGdMode) this._toggleCameraMode();
}
+ // L — Shift-Lock (Roblox по умолчанию на Shift, у нас Shift = бег,
+ // поэтому переназначено на L). Курсор центрируется, корпус всегда
+ // лицом к камере, камера через плечо.
+ if (e.code === 'KeyL') {
+ this.setShiftLock(!this._shiftLock);
+ }
+ // B — встроенный магазин скинов (задача 07). Открывается только если
+ // включён в проекте (scene.skins.shopVisible). Toggle.
+ if (e.code === 'KeyB' && !this._inputBlocked) {
+ try { this._scene3d?.toggleSkinShop?.(); } catch (err) {}
+ }
// Tab — переключить «UI-режим курсора» (для кликов по GUI в Play)
if (e.code === 'Tab') {
e.preventDefault();
@@ -2496,6 +2921,17 @@ export class PlayerController {
this._tickDebris(dt);
// === Анимации ===
+ // Снимок скорости/опоры для процедурной анимации non-humanoid скинов.
+ this._lastFrameSpeed = (isMoving ? (isSprinting ? this.WALK_SPEED * this.SPRINT_MULT : this.WALK_SPEED) : 0) * (this._speedMul || 1);
+ this._isGrounded = !!result.onGround;
+
+ // Non-humanoid single-mesh скин: костей нет — анимируем процедурно
+ // (покачивание/наклон). Делаем ДО R15-ветки, т.к. _isR15=false для них.
+ if (this._modelKind === 'non-humanoid-mesh' && this._modelRoot) {
+ this._animateNonHumanoidMesh(dt);
+ return;
+ }
+
// R15-скин: процедурный аниматор (нет glTF AnimationGroups).
// Состояния: idle/walk/run/jump/fall. sprint → run.
if (this._isR15 && this._r15Animator) {
diff --git a/src/engine/PrimitiveManager.js b/src/engine/PrimitiveManager.js
index 442d51d..6300fd2 100644
--- a/src/engine/PrimitiveManager.js
+++ b/src/engine/PrimitiveManager.js
@@ -156,6 +156,21 @@ export class PrimitiveManager {
}
}
+ // === 3D-табличка (billboard): натягиваем DynamicTexture с GUI ===
+ if (typeDef.kind === 'billboard' && this.billboardUiManager) {
+ // Сохраняем настройки билборда в data.billboardOpts чтобы
+ // serialize мог записать их обратно в JSON проекта.
+ const billboardOpts = {
+ template: opts.template || 'shop-item',
+ face: opts.face || 'fixed',
+ content: opts.content || null,
+ elements: opts.elements || null,
+ rotationY: opts.rotationY,
+ };
+ this.billboardUiManager.applyToMesh(data, billboardOpts);
+ // billboardOpts хранится в data.billboard после applyToMesh.
+ }
+
this.instances.set(id, data);
// Авто-регистрация в shadow casters (Этап 4 теней).
// Когда скрипт спавнит новый объект через scene.spawn(...) — раньше он
@@ -210,6 +225,16 @@ export class PrimitiveManager {
// создаются отдельно в addInstance.
return MeshBuilder.CreateSphere(name,
{ diameterX: sx, diameterY: sy, diameterZ: sz, segments: 12 }, this.scene);
+ case 'billboard': {
+ // 3D-табличка — плоскость с пропорциями таблички (sx × sy),
+ // sz — толщина рамки (визуально-незаметная).
+ // ВАЖНО: FRONTSIDE, не DOUBLESIDE — иначе сквозь back-side
+ // видно зеркальную сторону UV (текст справа-налево).
+ // BillboardMode разворачивает FRONT к камере.
+ const m = MeshBuilder.CreatePlane(name,
+ { width: sx, height: sy, sideOrientation: Mesh.FRONTSIDE }, this.scene);
+ return m;
+ }
case 'plane':
return MeshBuilder.CreateBox(name,
{ width: sx, height: sy, depth: sz }, this.scene);
@@ -607,6 +632,11 @@ export class PrimitiveManager {
}
}
+ // Billboard: пересоздать GUI-текстуру при изменении template/content/face/elements
+ if (patch.billboardOpts && this.billboardUiManager && data.type === 'billboard') {
+ this.billboardUiManager.applyToMesh(data, patch.billboardOpts);
+ }
+
// === Лампа: синхронизируем привязанный PointLight ===
if (data.light) {
// позиция света — за маркером
@@ -739,6 +769,13 @@ export class PrimitiveManager {
...(d.light ? { brightness: d.brightness, range: d.range } : {}),
// Параметр эмиттера (только для type='emitter')
...(d.effect !== undefined ? { effect: d.effect } : {}),
+ // Параметры билборда (только для type='billboard')
+ ...(d.billboard ? {
+ template: d.billboard.template,
+ face: d.billboard.face,
+ content: d.billboard.content,
+ ...(d.billboard.elements ? { elements: d.billboard.elements } : {}),
+ } : {}),
}));
}
diff --git a/src/engine/PrimitiveTypes.js b/src/engine/PrimitiveTypes.js
index 42a9728..cdbafe8 100644
--- a/src/engine/PrimitiveTypes.js
+++ b/src/engine/PrimitiveTypes.js
@@ -57,6 +57,15 @@ export const PRIMITIVE_TYPES = [
{ id: 'emitter', name: 'Эмиттер частиц', icon: 'prim-emitter', kind: 'emitter',
defaultScale: { x: 0.4, y: 0.4, z: 0.4 }, defaultColor: '#ff8833' },
+ // === Табличка — 3D-карточка с GUI (как BillboardGui в Roblox) ===
+ // Плоскость с натянутой DynamicTexture, на которой рендерится контент
+ // (заголовок, иконка, кнопка). Поддерживает 4 пресета (см. BillboardUiManager):
+ // shop-item, shop-purchase, banner, sign. Может смотреть на камеру
+ // (face=camera) или быть фиксированной (face=fixed). Клик по кнопке
+ // эмитит событие через game.billboard.onClick.
+ { id: 'billboard', name: '3D-табличка', icon: 'prim-billboard', kind: 'billboard',
+ defaultScale: { x: 2, y: 1.2, z: 0.05 }, defaultColor: '#1a1a2e' },
+
// === GD-порталы (Geometry Dash 2.0) — переключают гейммод игрока ===
// Все цилиндры-«столбы» с neon-материалом. Цвета и gdMode задают режим.
{ id: 'gd_cube', name: 'Портал: Куб', icon: 'prim-portal', kind: 'gd_portal', gdMode: 'cube',
@@ -87,7 +96,7 @@ export const PRIMITIVE_TYPES = [
/** Категории для группировки в палитре. */
export const PRIMITIVE_CATEGORIES = [
{ id: 'geometry', name: 'Фигуры', ids: ['cube','sphere','cylinder','cone','plane','torus','wedge','cornerwedge'] },
- { id: 'gameplay', name: 'Геймплей', ids: ['trigger', 'checkpoint', 'light', 'emitter'] },
+ { id: 'gameplay', name: 'Геймплей', ids: ['trigger', 'checkpoint', 'light', 'emitter', 'billboard'] },
{ id: 'gd-portals', name: 'GD-порталы', ids: ['gd_cube','gd_ship','gd_ball','gd_ufo','gd_wave','gd_robot'] },
{ id: 'gd-objects', name: 'GD-объекты', ids: ['gd_spike','gd_finish','gd_coin'] },
];
diff --git a/src/engine/ScriptSandbox.js b/src/engine/ScriptSandbox.js
index 247b5bd..e73b2c1 100644
--- a/src/engine/ScriptSandbox.js
+++ b/src/engine/ScriptSandbox.js
@@ -89,6 +89,10 @@ export class ScriptSandbox {
try { this.worker.postMessage({ cmd: 'guiSnapshot', payload: this._pendingGuiSnapshot }); } catch (e) {}
this._pendingGuiSnapshot = null;
}
+ if (this._pendingSkinsSnapshot) {
+ try { this.worker.postMessage({ cmd: 'skinsSnapshot', payload: this._pendingSkinsSnapshot }); } catch (e) {}
+ this._pendingSkinsSnapshot = null;
+ }
if (this._pendingTerrainHM) {
try { this.worker.postMessage({ cmd: 'terrainHeightmap', payload: this._pendingTerrainHM }); } catch (e) {}
this._pendingTerrainHM = null;
@@ -165,6 +169,16 @@ export class ScriptSandbox {
try { this.worker.postMessage({ cmd: 'guiSnapshot', payload: snapshot }); } catch (e) {}
}
+ /** Задача 07: снапшот скинов — для game.player.getAvailableSkins/getAllSkins. */
+ sendSkinsSnapshot(snapshot) {
+ if (!this.worker) return;
+ if (!this._isReady) {
+ this._pendingSkinsSnapshot = snapshot;
+ return;
+ }
+ try { this.worker.postMessage({ cmd: 'skinsSnapshot', payload: snapshot }); } catch (e) {}
+ }
+
/** Snapshot атрибутов объектов — для синхронного game.scene.getData. */
sendDataSnapshot(snapshot) {
if (!this.worker) return;
diff --git a/src/engine/ScriptSandboxWorker.js b/src/engine/ScriptSandboxWorker.js
index 002a997..0ac66fc 100644
--- a/src/engine/ScriptSandboxWorker.js
+++ b/src/engine/ScriptSandboxWorker.js
@@ -93,17 +93,35 @@ let _selfUntouchHandlers = [];
let _selfInteractHandlers = [];
// Зеркало всех GUI-элементов (синхронизируется main через cmd='guiSnapshot')
let _guiIndex = [];
+// Задача 07: зеркало скинов (синхронизируется через 'skinsSnapshot').
+// _skinsIndex — все встроенные скины [{slug,name,kind,category,price}].
+// _unlockedSkins — slug'и доступные игроку. _currentSkin — активный.
+let _skinsIndex = [];
+let _unlockedSkins = [];
+let _currentSkin = null;
+let _skinChangeHandlers = [];
+let _skinCoins = 0; // локальная валюта магазина скинов (рублики проекта)
// Подписки game.gui.onClick(id, fn)
let _guiClickHandlers = {};
// Подписки game.gui.onSubmit(id, fn) — ввод в TextBox завершён (Enter)
let _guiSubmitHandlers = {};
+// Подписки game.billboard.onClick(ref, buttonId, fn) — клик по кнопке
+// 3D-таблички. Ключ имеет вид ref + ":" + buttonId где ref — строка
+// из game.scene.spawn() или game.scene.findOne() в формате
+// 'primitive:NN' либо локальный '_local_X'. Резолв в main (GameRuntime),
+// сюда приходит событие 'billboardClick' с готовым ref='primitive:NN'.
+let _billboardClickHandlers = {};
// Для GUI-события с реальным id вернуть набор ключей, под которыми
// могли быть зарегистрированы handlers: сам id + имя элемента (скрипт
// часто подписывается через game.gui.onClick('ИмяКнопки', fn)).
-function _guiHandlerKeys(id) {
+function _guiHandlerKeys(id, localId) {
const keys = [id];
+ // localId — ref который вернул gui.create() ('_gui_local_N'); скрипт мог
+ // подписаться по нему, если не задавал явный id.
+ if (localId != null && localId !== id) keys.push(localId);
+ // name элемента — скрипт часто подписывается game.gui.onClick('ИмяКнопки', fn).
const el = _guiIndex.find(g => g.id === id);
- if (el && el.name && el.name !== id) keys.push(el.name);
+ if (el && el.name && el.name !== id && !keys.includes(el.name)) keys.push(el.name);
return keys;
}
@@ -244,6 +262,19 @@ const _send = (cmd, payload) => {
try { postMessage({ cmd, payload }); } catch (e) {}
};
+// Нормализация ref: строка → она сама; Instance-прокси → поле .ref;
+// иначе null. Нужно чтобы billboard.set/update/onClick принимали и
+// строковый ref ('primitive:NN'), и объект, у которого есть .ref.
+function _normRef(ref) {
+ if (typeof ref === 'string') return ref || null;
+ if (ref && typeof ref === 'object') {
+ if (typeof ref.ref === 'string' && ref.ref) return ref.ref;
+ const s = String(ref);
+ return s && s !== '[object Object]' ? s : null;
+ }
+ return null;
+}
+
const _safeCall = (fn, arg, where) => {
try { fn(arg); }
catch (err) {
@@ -641,6 +672,69 @@ const game = {
setSkinVisible(visible) {
_send('player.setSkinVisible', { visible: !!visible });
},
+ /**
+ * === Задача 07: скины игрока (любая 3D-модель + магазин) ===
+ * Сменить активный скин в Play (без перезагрузки сцены).
+ * game.player.setSkin('squirrel-donut'); // встроенный
+ * game.player.setSkin('character-a'); // человек
+ * Возвращает «локальный Promise» (объект с .then) — реальная смена
+ * асинхронна (грузится .glb). Для большинства игр можно не ждать.
+ */
+ setSkin(slug) {
+ if (typeof slug !== 'string' || !slug) return;
+ _currentSkin = slug;
+ if (_unlockedSkins.indexOf(slug) === -1) _unlockedSkins.push(slug);
+ _send('player.setSkin', { slug });
+ },
+ /** Дать игроку скин (разблокировать — например после покупки). */
+ unlockSkin(slug) {
+ if (typeof slug !== 'string' || !slug) return;
+ if (_unlockedSkins.indexOf(slug) === -1) _unlockedSkins.push(slug);
+ _send('player.unlockSkin', { slug });
+ },
+ /** Список slug'ов скинов, доступных игроку (разблокированных). */
+ getAvailableSkins() {
+ return _unlockedSkins.slice();
+ },
+ /** Все встроенные скины с метаданными: [{slug,name,kind,category,price}]. */
+ getAllSkins() {
+ return _skinsIndex.map(s => ({ ...s }));
+ },
+ /** Текущий активный скин (slug). */
+ getCurrentSkin() {
+ return _currentSkin;
+ },
+ /** Подписка на смену скина: fn(slug). */
+ onSkinChange(fn) {
+ if (typeof fn === 'function') _skinChangeHandlers.push(fn);
+ },
+ /** Открыть встроенный GUI-магазин скинов (если включён в проекте). */
+ openSkinShop() {
+ _send('player.openSkinShop', {});
+ },
+ /** Закрыть магазин скинов. */
+ closeSkinShop() {
+ _send('player.closeSkinShop', {});
+ },
+ /** Игровая валюта магазина скинов (рублики проекта, ЛОКАЛЬНЫЕ —
+ * не путать с серверной экономикой game.economy). */
+ getSkinCoins() {
+ return _skinCoins;
+ },
+ /** Задать баланс валюты магазина (например стартовые 200). */
+ setSkinCoins(amount) {
+ const n = Number(amount);
+ if (!Number.isFinite(n)) return;
+ _skinCoins = Math.max(0, Math.floor(n));
+ _send('player.setSkinCoins', { amount: _skinCoins });
+ },
+ /** Добавить валюту магазина (награда за что-то). */
+ addSkinCoins(amount) {
+ const n = Number(amount);
+ if (!Number.isFinite(n)) return;
+ _skinCoins = Math.max(0, _skinCoins + Math.floor(n));
+ _send('player.setSkinCoins', { amount: _skinCoins });
+ },
/**
* Режим камеры: 'first' | 'third' | 'front' | 'sideview'.
* 'sideview' нужен для Кубикон Dash — камера фиксируется сбоку,
@@ -650,6 +744,22 @@ const game = {
if (typeof mode !== 'string') return;
_send('player.setCameraMode', { mode });
},
+ /** Задача 02: установить дистанцию камеры (для third-person). */
+ setCameraZoom(distance) {
+ const d = Number(distance);
+ if (!Number.isFinite(d)) return;
+ _send('player.setCameraZoom', { distance: d });
+ },
+ /** Задача 02: границы зума колесом мыши. По умолчанию 0.5..32. */
+ setCameraZoomLimits(min, max) {
+ const mn = Number(min), mx = Number(max);
+ if (!Number.isFinite(mn) || !Number.isFinite(mx)) return;
+ _send('player.setCameraZoomLimits', { min: mn, max: mx });
+ },
+ /** Задача 02: Shift-Lock — курсор в центре, корпус лицом к камере. */
+ setShiftLock(on) {
+ _send('player.setShiftLock', { on: !!on });
+ },
/**
* Присед: уменьшает высоту хитбокса игрока с 1.8 до 0.9 ед.
* Используется чтобы пройти под низким потолком.
@@ -1702,6 +1812,32 @@ const game = {
if (typeof id !== 'string' || typeof fn !== 'function') return;
(_guiSubmitHandlers[id] = _guiSubmitHandlers[id] || []).push(fn);
},
+ /** Задача 03: tween свойства GUI-элемента.
+ * props: { x, y, w, h, rotation, scaleX, scaleY, bgOpacity, textSize,
+ * bgColor, textColor, borderColor } (любое числовое или hex-цвет).
+ * opts: { duration, easing, delay, repeat, reverses, onDone } */
+ tween(id, props, opts) {
+ if (typeof id !== 'string' || !id) return null;
+ if (!props || typeof props !== 'object') return null;
+ opts = opts || {};
+ const tid = ++_tweenSeq;
+ if (typeof opts.onDone === 'function') _tweenCallbacks[tid] = opts.onDone;
+ _send('gui.tween', {
+ tweenId: tid, id, props,
+ duration: Number.isFinite(Number(opts.duration)) ? Number(opts.duration) : 0.5,
+ easing: typeof opts.easing === 'string' ? opts.easing : 'ease',
+ delay: Number.isFinite(Number(opts.delay)) ? Number(opts.delay) : 0,
+ repeat: Number.isFinite(Number(opts.repeat)) ? Number(opts.repeat) : 0,
+ reverses: !!opts.reverses,
+ });
+ return tid;
+ },
+ /** Отменить tween по id (возвращённому из game.gui.tween). */
+ cancelTween(tweenId) {
+ if (!Number.isFinite(tweenId)) return;
+ _send('gui.cancelTween', { tweenId });
+ delete _tweenCallbacks[tweenId];
+ },
},
/**
* Камера — тряска, FOV, привязка к объекту, катсцены (Фаза 5.7).
@@ -1775,6 +1911,274 @@ const game = {
_send('hud.setVisible', { visible: !!visible });
},
},
+ /**
+ * Задача 04: модальные сцены (затемнение + GUI поверх + блок ввода).
+ *
+ * Типовой кейс: boss-fight intro, открытие лутбокса, диалог с NPC, получил питомца.
+ *
+ * const m = game.modal.open({
+ * darken: 0.7, // 0..1 — насколько затемнить (по умолчанию 0.5)
+ * darkenColor: '#000', // цвет затемнения
+ * target: 'scene', // 'scene' (HUD виден) | 'screen' (всё затемнено)
+ * blockInput: true, // блокирует WASD/Space/Ctrl (Esc/Tab/Enter работают)
+ * freezeCamera: true, // камера замирает
+ * fadeIn: 0.4, // секунды до полного затемнения
+ * fadeOut: 0.3,
+ * spotlights: [boss, h1, h2], // 3D-рефы, которые остаются яркими (вырезка mask)
+ * spotlightRadius: 120, // пиксели — радиус «прожектора»
+ * pauseSimulation: false, // ставит весь GameRuntime на паузу (мобы замирают)
+ * muteWorld: false, // приглушает ambient/sfx
+ * cameraOverride: { // фокус камеры на цель
+ * target: boss, distance: 8, height: 3, fov: 60, duration: 0.5,
+ * },
+ * content: { elements: [ // временные GUI поверх модала, удалятся при close
+ * { kind: 'text', x: 50, y: 25, text: 'Франкенслим', textSize: 48,
+ * textStroke: { color: '#000', width: 3 }, textColor: '#fff' },
+ * { kind: 'button', id: 'fight', x: 50, y: 80, w: 20, h: 6, text: 'В бой!' },
+ * ]},
+ * });
+ * game.gui.onClick('fight', () => game.modal.close(m));
+ *
+ * Готовые пресеты:
+ * game.modal.bossIntro(name, hp, refs) — intro босса с HP-баром
+ * game.modal.lootbox(items, onPick) — открытие лутбокса
+ * game.modal.dialog(npcName, lines, onDone) — диалог с NPC построчно
+ * game.modal.confirmation(title, body, onYes, onNo) — Да/Нет
+ *
+ * Только ОДИН модал одновременно. Повторный open мгновенно закрывает предыдущий.
+ */
+ modal: {
+ _localSeq: 0,
+ _localToReal: new Map(), // localId → реальный modalId (приходит в modalOpened)
+ _onCloseFns: [],
+ open(opts) {
+ opts = opts || {};
+ const localId = ++this._localSeq;
+ const replyId = '_mopen_' + localId;
+ _send('modal.open', { opts, replyId });
+ // Возвращаем локальный id — реальный придёт асинхронно через modalOpened-event
+ return localId;
+ },
+ close(modalId) {
+ // Резолвим локальный id → реальный. Если modalId — локальное число, но
+ // реальный ещё не пришёл (гонка modalOpened-event), шлём null: модал
+ // одиночный, null закрывает активный. Передавать локальный id нельзя —
+ // ModalManager.close сверяет его со своим _state.id и молча игнорит.
+ let real = null;
+ if (typeof modalId === 'number') {
+ real = this._localToReal.has(modalId) ? this._localToReal.get(modalId) : null;
+ } else if (modalId != null) {
+ real = modalId; // уже реальный id (строка/число от runtime)
+ }
+ _send('modal.close', { modalId: real });
+ },
+ update(modalId, patch) {
+ let real = null;
+ if (typeof modalId === 'number') {
+ real = this._localToReal.has(modalId) ? this._localToReal.get(modalId) : null;
+ } else if (modalId != null) {
+ real = modalId;
+ }
+ _send('modal.update', { modalId: real, patch: patch || {} });
+ },
+ isOpen() { return !!this._isOpenLocal; },
+ onClose(fn) {
+ if (typeof fn === 'function') this._onCloseFns.push(fn);
+ },
+
+ // === Пресеты ===
+ /** Intro босса с именем + HP-баром + кнопкой «В бой!» через delay секунд. */
+ bossIntro(name, hp, refs, opts) {
+ opts = opts || {};
+ const startBtnDelay = Number.isFinite(opts.startBtnDelay) ? opts.startBtnDelay : 2;
+ const buttonText = opts.buttonText || 'В бой!';
+ const onStart = opts.onStart;
+ const elements = [
+ { kind: 'text', id: '_boss_name', x: 50, y: 22, w: 60, h: 8, anchor: 'center',
+ text: String(name || 'Босс'), textSize: 48, fontWeight: 900, textColor: '#ffffff',
+ textStroke: { color: '#000', width: 3 }, bgColor: 'transparent', bgOpacity: 0,
+ animationPreset: 'glow' },
+ { kind: 'text', id: '_boss_hp', x: 50, y: 30, w: 30, h: 6, anchor: 'center',
+ text: '❤ ' + hp + ' / ' + hp, textSize: 28, fontWeight: 800, textColor: '#22ff66',
+ textStroke: { color: '#000', width: 2 }, bgColor: 'transparent', bgOpacity: 0 },
+ ];
+ const m = this.open({
+ darken: 0.7, target: 'scene',
+ blockInput: true, freezeCamera: true,
+ spotlights: Array.isArray(refs) ? refs : (refs ? [refs] : []),
+ cameraOverride: refs ? { target: Array.isArray(refs) ? refs[0] : refs,
+ distance: 8, height: 3, fov: 60, duration: 0.5 } : null,
+ content: { elements },
+ });
+ const _modal = this;
+ const _afterTid = ++_timerSeq;
+ _timers.push({ id: _afterTid, fn: () => {
+ _send('gui.create', { type: 'button', opts: {
+ id: '_boss_start', x: 50, y: 78, w: 20, h: 7, anchor: 'center',
+ text: buttonText,
+ bgGradient: { stops: ['#ff5454','#a00000'], angle: 90 },
+ borderColor: '#000', borderWidth: 3, borderRadius: 14,
+ textColor: '#fff', textSize: 22, fontWeight: 900,
+ textStroke: { color: '#000', width: 2 },
+ hover: { scale: 1.08, brightness: 1.2, duration: 0.15 },
+ active: { scale: 0.94, duration: 0.08 },
+ animationPreset: 'pulse',
+ }, localRef: '_boss_start' });
+ let _started = false;
+ _guiClickHandlers['_boss_start'] = [() => {
+ if (_started) return;
+ _started = true;
+ delete _guiClickHandlers['_boss_start'];
+ _modal.close(m);
+ if (typeof onStart === 'function') { try { onStart(); } catch (e) {} }
+ }];
+ }, delay: startBtnDelay, elapsed: 0, repeat: false });
+ return m;
+ },
+ /** Открытие лутбокса. items: [{name, color, icon, rarity}]. onPick(item). */
+ lootbox(items, onPick) {
+ items = Array.isArray(items) ? items.slice(0, 5) : [];
+ const elements = [
+ { kind: 'frame', id: '_lb_bg', x: 50, y: 50, w: 70, h: 50, anchor: 'center',
+ bgGradient: { stops: ['#1a1a3a','#0a0a1a'], angle: 135 },
+ borderColor: '#ffd700', borderWidth: 3, borderRadius: 18 },
+ { kind: 'text', id: '_lb_title', x: 50, y: 28, w: 60, h: 8, anchor: 'center',
+ text: '✨ Лутбокс ✨', textSize: 32, fontWeight: 900, textColor: '#ffd700',
+ textStroke: { color: '#000', width: 2 }, bgColor: 'transparent', bgOpacity: 0,
+ animationPreset: 'glow' },
+ ];
+ for (let i = 0; i < items.length; i++) {
+ const it = items[i];
+ const x = 50 + (i - (items.length - 1) / 2) * 13;
+ elements.push({
+ kind: 'button', id: '_lb_item_' + i,
+ x: x, y: 50, w: 11, h: 16, anchor: 'center',
+ text: (it.icon || '*') + '\\n' + (it.name || 'Приз'),
+ bgColor: it.color || '#3a3a5a', borderRadius: 12,
+ borderColor: '#ffd700', borderWidth: 2,
+ textColor: '#fff', textSize: 14, fontWeight: 700,
+ hover: { scale: 1.1, brightness: 1.3, duration: 0.15 },
+ active: { scale: 0.94, duration: 0.08 },
+ animationPreset: 'pulse',
+ });
+ }
+ const m = this.open({
+ darken: 0.6, target: 'screen', blockInput: true,
+ content: { elements },
+ });
+ const _modal = this;
+ // _picked: после первого выбора остальные карточки не должны срабатывать,
+ // пока модал доигрывает fadeOut (иначе onPick зовётся несколько раз).
+ let _picked = false;
+ for (let i = 0; i < items.length; i++) {
+ const id = '_lb_item_' + i;
+ const it = items[i];
+ _guiClickHandlers[id] = [() => {
+ if (_picked) return;
+ _picked = true;
+ for (let j = 0; j < items.length; j++) delete _guiClickHandlers['_lb_item_' + j];
+ _modal.close(m);
+ if (typeof onPick === 'function') { try { onPick(it); } catch (e) {} }
+ }];
+ }
+ return m;
+ },
+ /** Диалог с NPC построчно. lines: ['Привет', 'Как дела?']. onDone() в конце. */
+ dialog(npcName, lines, onDone) {
+ lines = Array.isArray(lines) ? lines : [String(lines || '')];
+ let idx = 0;
+ const elements = [
+ { kind: 'frame', id: '_dlg_bg', x: 50, y: 80, w: 80, h: 25, anchor: 'center',
+ bgGradient: { stops: ['#1a1a3a','#0a0a1a'], angle: 90 },
+ borderColor: '#fff', borderWidth: 2, borderRadius: 12 },
+ { kind: 'text', id: '_dlg_name', x: 14, y: 71, w: 22, h: 6, anchor: 'center',
+ text: String(npcName || 'NPC'), textSize: 20, fontWeight: 900,
+ textColor: '#ffd700', textStroke: { color: '#000', width: 2 },
+ bgColor: 'transparent', bgOpacity: 0 },
+ { kind: 'text', id: '_dlg_line', x: 50, y: 80, w: 76, h: 12, anchor: 'center',
+ text: lines[0], textSize: 22, fontWeight: 600, textColor: '#fff',
+ textAlign: 'left', bgColor: 'transparent', bgOpacity: 0 },
+ { kind: 'button', id: '_dlg_next', x: 88, y: 88, w: 8, h: 6, anchor: 'center',
+ // На ПОСЛЕДНЕЙ строке кнопка сразу показывает галочку «завершить»,
+ // на остальных — стрелку «дальше».
+ text: lines.length > 1 ? '▶' : '✓', textSize: 22, fontWeight: 900,
+ bgColor: '#ffd700', textColor: '#000', borderRadius: 8,
+ borderColor: '#000', borderWidth: 2,
+ hover: { scale: 1.1, brightness: 1.2 }, active: { scale: 0.92 },
+ animationPreset: 'pulse' },
+ ];
+ const m = this.open({
+ darken: 0.5, target: 'scene', blockInput: true, freezeCamera: true,
+ content: { elements },
+ });
+ const _modal = this;
+ // _done защищает от повторного срабатывания: game.modal.close() доигрывает
+ // fadeOut асинхронно, кнопка остаётся кликабельной — без guard'а каждый
+ // лишний клик снова звал onDone (баг «Диалог завершён ×7»).
+ let _done = false;
+ _guiClickHandlers['_dlg_next'] = [() => {
+ if (_done) return;
+ idx++;
+ if (idx < lines.length) {
+ _send('gui.update', { id: '_dlg_line', patch: { text: lines[idx] } });
+ // Последняя строка достигнута — превращаем «дальше» в «завершить».
+ if (idx === lines.length - 1) {
+ _send('gui.update', { id: '_dlg_next', patch: { text: '✓' } });
+ }
+ } else {
+ _done = true;
+ delete _guiClickHandlers['_dlg_next']; // снимаем handler сразу
+ _modal.close(m);
+ if (typeof onDone === 'function') { try { onDone(); } catch (e) {} }
+ }
+ }];
+ return m;
+ },
+ /** Подтверждение Да/Нет. */
+ confirmation(title, body, onYes, onNo) {
+ const elements = [
+ { kind: 'frame', id: '_cf_bg', x: 50, y: 50, w: 50, h: 30, anchor: 'center',
+ bgGradient: { stops: ['#2a2a4a','#0a0a1a'], angle: 135 },
+ borderColor: '#fff', borderWidth: 2, borderRadius: 14 },
+ { kind: 'text', id: '_cf_title', x: 50, y: 41, w: 44, h: 6, anchor: 'center',
+ text: String(title || 'Подтверждение'), textSize: 28, fontWeight: 900,
+ textColor: '#fff', textStroke: { color: '#000', width: 2 },
+ bgColor: 'transparent', bgOpacity: 0 },
+ { kind: 'text', id: '_cf_body', x: 50, y: 50, w: 44, h: 8, anchor: 'center',
+ text: String(body || ''), textSize: 16, fontWeight: 500,
+ textColor: '#cfd0e8', bgColor: 'transparent', bgOpacity: 0 },
+ { kind: 'button', id: '_cf_yes', x: 38, y: 60, w: 14, h: 6, anchor: 'center',
+ text: 'Да', bgGradient: { stops: ['#22ff66','#0a803a'], angle: 90 },
+ borderColor: '#000', borderWidth: 2, borderRadius: 10,
+ textColor: '#fff', textSize: 18, fontWeight: 900,
+ hover: { scale: 1.08, brightness: 1.2 }, active: { scale: 0.94 } },
+ { kind: 'button', id: '_cf_no', x: 62, y: 60, w: 14, h: 6, anchor: 'center',
+ text: 'Нет', bgGradient: { stops: ['#ff5454','#a00000'], angle: 90 },
+ borderColor: '#000', borderWidth: 2, borderRadius: 10,
+ textColor: '#fff', textSize: 18, fontWeight: 900,
+ hover: { scale: 1.08, brightness: 1.2 }, active: { scale: 0.94 } },
+ ];
+ const m = this.open({
+ darken: 0.6, target: 'screen', blockInput: true,
+ content: { elements },
+ });
+ const _modal = this;
+ // _answered: одна кнопка снимает обе (Да/Нет) сразу, чтобы пока модал
+ // доигрывает fadeOut нельзя было нажать вторую и продублировать ответ.
+ let _answered = false;
+ const _finish = (cb) => {
+ if (_answered) return;
+ _answered = true;
+ delete _guiClickHandlers['_cf_yes'];
+ delete _guiClickHandlers['_cf_no'];
+ _modal.close(m);
+ if (typeof cb === 'function') { try { cb(); } catch (e) {} }
+ };
+ _guiClickHandlers['_cf_yes'] = [() => _finish(onYes)];
+ _guiClickHandlers['_cf_no'] = [() => _finish(onNo)];
+ return m;
+ },
+ },
/**
* Инвентарь игрока (Фаза 4.2) — 5 слотов hot-bar.
* game.inventory.add({ name: 'Зелье', kind: 'item' })
@@ -2141,6 +2545,114 @@ const game = {
_send('economy.spend', { reqId, amount: Number(amount) || 0, reason: String(reason || '') });
},
},
+ /**
+ * Billboard — 3D-таблички с GUI (как BillboardGui в Roblox).
+ * Создаются через game.scene.spawn('billboard', {x,y,z, template, content}),
+ * затем настраиваются через game.billboard.set/update.
+ *
+ * Пресеты (template):
+ * - 'shop-item' — иконка + заголовок + sub-строка + кнопка цены
+ * - 'shop-purchase' — иконка + название + цена (для покупки)
+ * - 'banner' — крупный текст
+ * - 'sign' — простой указатель
+ *
+ * Пример (4 таблички-апгрейды):
+ * const refs = ['vis','range','saws','sprink'].map((kind, i) => {
+ * return game.scene.spawn('billboard', {
+ * x: -6 + i*4, y: 3, z: 5,
+ * template: 'shop-item',
+ * content: { icon: 'hammer', title: 'Апгрейд', sub: '1 > 2',
+ * price: '$10,000', gradient: ['#ff5a5a','#ff8a3d'] },
+ * });
+ * });
+ * game.billboard.onClick(refs[0], 'buy', () => {
+ * game.ui.showText('Куплено!');
+ * game.billboard.update(refs[0], { sub: '2 > 3', price: '$20,000' });
+ * });
+ */
+ billboard: {
+ /**
+ * Полная замена контента таблички. Если пресет тот же — мгновенно
+ * перерисует. Если template другой — пересоздаст текстуру.
+ * ref — string-ref из game.scene.spawn() или game.scene.findOne()
+ * opts — { template?, face?, content?, elements? }
+ */
+ set(ref, opts) {
+ const refStr = _normRef(ref);
+ if (!refStr || typeof opts !== 'object' || opts == null) return;
+ _send('billboard.set', { ref: refStr, ...opts });
+ },
+ /**
+ * Частичное обновление таблички.
+ * Две формы:
+ * 1) update(ref, patch)
+ * patch — частичный content: { sub, price, title, icon, gradient }
+ * Применяется к content пресета (shop-item/banner/sign).
+ * 2) update(ref, elementId, patch)
+ * Обновляет конкретный элемент по id (только для template:'card'
+ * или elements-режима). Например: update(ref, 'buy', { text: '$15,000' }).
+ * Для пресетов: 'title'|'sub'|'price'|'icon'|'gradient' тоже
+ * работают как ключи content.
+ */
+ update(ref, secondArg, thirdArg) {
+ const refStr = _normRef(ref);
+ if (!refStr) return;
+ // 3-аргументная форма: update(ref, elementId, patch)
+ if (typeof secondArg === 'string' && typeof thirdArg === 'object' && thirdArg !== null) {
+ _send('billboard.update', {
+ ref: refStr,
+ elementId: secondArg,
+ patch: thirdArg,
+ });
+ return;
+ }
+ // 2-аргументная форма: update(ref, patch)
+ if (typeof secondArg === 'object' && secondArg !== null) {
+ _send('billboard.update', { ref: refStr, patch: secondArg });
+ }
+ },
+ /**
+ * Подписаться на клик по кнопке таблички (shop-item: buttonId='buy';
+ * в кастомных elements — id из элемента kind='button').
+ * ref — string-ref
+ * buttonId — id кнопки (по умолчанию 'buy')
+ * fn — () => void
+ */
+ onClick(ref, buttonId, fn) {
+ if (typeof fn !== 'function') {
+ fn = buttonId;
+ buttonId = 'buy';
+ }
+ // Принудительная нормализация ref в plain-string: Instance-Proxy
+ // не сериализуется через postMessage (DataCloneError).
+ const refStr = _normRef(ref);
+ if (!refStr || typeof fn !== 'function') return;
+ const bid = String(buttonId || 'buy');
+ const key = refStr + ':' + bid;
+ if (!_billboardClickHandlers[key]) _billboardClickHandlers[key] = [];
+ _billboardClickHandlers[key].push(fn);
+ _send('billboard.onClick', { ref: refStr, buttonId: bid });
+ },
+ },
+ /** Окружение: небо, туман, время суток. */
+ environment: {
+ /** Установить цвет неба (clearColor сцены). hex или 'r,g,b'. */
+ setSkyColor(color) {
+ if (typeof color !== 'string') return;
+ _send('environment.setSkyColor', { color });
+ },
+ /** Установить туман: {enabled, color, density}. */
+ setFog(opts) {
+ if (typeof opts !== 'object' || !opts) return;
+ _send('environment.setFog', opts);
+ },
+ /** Установить время суток (часы, 0..24). */
+ setTimeOfDay(hours) {
+ const h = Number(hours);
+ if (!Number.isFinite(h)) return;
+ _send('environment.setTimeOfDay', { hours: h });
+ },
+ },
/**
* Управление режимами ввода — курсор и камера.
* В режиме 'ui' мышь работает как обычный курсор (как в браузере),
@@ -2648,19 +3160,83 @@ self.onmessage = (e) => {
for (const fn of arr) _safeCall(fn, payload.data, 'onMessage:' + name);
} else if (t === 'guiClick') {
const id = String(payload.id || '');
- // Собираем handlers и по id, и по имени элемента — скрипт
- // мог подписаться через game.gui.onClick('ИмяКнопки', fn).
- for (const key of _guiHandlerKeys(id)) {
- const arr = _guiClickHandlers[key] || [];
+ const localId = payload.localId != null ? String(payload.localId) : null;
+ // Собираем handlers по id, по локальному ref и по имени элемента —
+ // скрипт мог подписаться любым из этих ключей.
+ // _matched защищает от двойного вызова если несколько ключей ведут
+ // к одному и тому же массиву handlers.
+ const _matched = new Set();
+ for (const key of _guiHandlerKeys(id, localId)) {
+ const arr = _guiClickHandlers[key];
+ if (!arr || _matched.has(arr)) continue;
+ _matched.add(arr);
for (const fn of arr) _safeCall(fn, { id }, 'gui.onClick:' + key);
}
} else if (t === 'guiSubmit') {
const id = String(payload.id || '');
+ const localId = payload.localId != null ? String(payload.localId) : null;
const val = payload.value != null ? String(payload.value) : '';
- for (const key of _guiHandlerKeys(id)) {
- const arr = _guiSubmitHandlers[key] || [];
+ const _matched = new Set();
+ for (const key of _guiHandlerKeys(id, localId)) {
+ const arr = _guiSubmitHandlers[key];
+ if (!arr || _matched.has(arr)) continue;
+ _matched.add(arr);
for (const fn of arr) _safeCall(fn, val, 'gui.onSubmit:' + key);
}
+ } else if (t === 'billboardClick') {
+ // payload: { ref, button } — клик по кнопке 3D-таблички.
+ // Ищем handlers и по реальному ref (primitive:NN), и по локальному
+ // ref если такой есть (на случай если скрипт подписался по
+ // локальному ref от scene.spawn).
+ const realRef = String(payload.ref || '');
+ const button = String(payload.button || 'buy');
+ const tryKeys = [realRef + ':' + button];
+ // Если есть локальный ref, ведущий к этому real — тоже попробуем
+ // (скрипт мог подписаться на ref сразу после game.scene.spawn,
+ // когда ref был ещё локальным _local_N).
+ for (const [local, real] of Object.entries(_spawnLocalToReal || {})) {
+ if (real === realRef) tryKeys.push(local + ':' + button);
+ }
+ for (const key of tryKeys) {
+ const arr = _billboardClickHandlers[key] || [];
+ for (const fn of arr) _safeCall(fn, { ref: realRef, button },
+ 'billboard.onClick:' + key);
+ }
+ } else if (t === 'modalOpened') {
+ // Задача 04: реальный modalId от runtime. worker сразу вернул скрипту
+ // локальный id (чтобы он мог его сохранить и звать close/update); здесь
+ // запоминаем маппинг local→real, иначе close(m) уходит с локальным id
+ // и ModalManager.close его не узнаёт (баг «закрывается только по Esc»).
+ try {
+ const mm = (typeof game !== 'undefined') && game.modal;
+ if (mm && payload && payload.replyId) {
+ const localId = Number(String(payload.replyId).replace(/^_mopen_/, ''));
+ if (Number.isFinite(localId) && payload.modalId != null) {
+ mm._localToReal.set(localId, payload.modalId);
+ mm._isOpenLocal = true;
+ }
+ }
+ } catch (e) {}
+ } else if (t === 'modalClosed') {
+ // Модал закрыт (fadeOut доиграл) — снимаем флаг и зовём onClose-подписчиков.
+ try {
+ const mm = (typeof game !== 'undefined') && game.modal;
+ if (mm) {
+ mm._isOpenLocal = false;
+ const cbs = mm._onCloseFns || [];
+ for (const fn of cbs) _safeCall(fn, payload && payload.id, 'modal.onClose');
+ }
+ } catch (e) {}
+ } else if (t === 'skinChanged') {
+ // Задача 07: скин игрока сменился — обновляем зеркало и зовём подписчиков.
+ const slug = payload && payload.slug;
+ if (slug) {
+ _currentSkin = slug;
+ for (const fn of _skinChangeHandlers) _safeCall(fn, slug, 'player.onSkinChange');
+ }
+ } else if (t === 'skinUnlocked') {
+ const slug = payload && payload.slug;
+ if (slug && _unlockedSkins.indexOf(slug) === -1) _unlockedSkins.push(slug);
}
} else if (cmd === 'sceneSnapshot') {
// payload: { blocks, models, primitives } — массивы { ref, x, y, z, name?, type? }
@@ -2674,6 +3250,14 @@ self.onmessage = (e) => {
} else if (cmd === 'guiSnapshot') {
// payload: массив всех GUI-элементов (для game.gui.find/get/all)
_guiIndex = Array.isArray(payload) ? payload : [];
+ } else if (cmd === 'skinsSnapshot') {
+ // Задача 07: payload { all:[{slug,name,kind,category,price}], unlocked:[slug], current }
+ if (payload && typeof payload === 'object') {
+ _skinsIndex = Array.isArray(payload.all) ? payload.all : [];
+ _unlockedSkins = Array.isArray(payload.unlocked) ? payload.unlocked.slice() : [];
+ _currentSkin = payload.current || _currentSkin;
+ if (Number.isFinite(payload.coins)) _skinCoins = payload.coins;
+ }
} else if (cmd === 'dataSnapshot') {
// payload: { ref: { key: value } } — атрибуты всех объектов
_dataIndex = payload && typeof payload === 'object' ? payload : {};