studio/src/editor/BillboardEditorModal.jsx
МИН c32914c819
Some checks failed
CI / PR size check (pull_request) Successful in 22s
CI / Lint (pull_request) Failing after 2m17s
CI / Secret scan (pull_request) Successful in 3m21s
CI / Build (pull_request) Successful in 3m31s
CI / Deploy to S1 + S2 (pull_request) Has been skipped
feat: интерактивные 3D-таблички (BillboardGui/SurfaceGui)
Задача 01 из 1 - Неделя 4/ЗАДАЧИ РУБЛОКС. P0-приоритет: без таблиц
с кнопками невозможны симуляторы, тайкуны, фермы.

Новое:
- engine/BillboardUiManager.js — 4 пресета (shop-item, shop-purchase,
  banner, sign), 18 иконок, DynamicTexture-рендер, UV-hit-test
- PrimitiveTypes: новый тип 'billboard' в категории 'gameplay'
- PrimitiveManager: case billboard в _createMeshForType (Plane), init
  через applyToMesh, billboardOpts в updateInstance
- BabylonScene: PointerEventTypes-handler для кликов с _isPlaying-чеком
  и pointer-lock поддержкой
- GameRuntime: команды billboard.set/update/onClick, callback через
  sandbox.sendEvent('billboardClick')
- ScriptSandboxWorker: пространство game.billboard.{set,update,onClick}
- BillboardEditorModal.jsx — модалка с живым canvas-превью, 8 готовых
  градиентов + кастомные пикеры, дропдаун из 18 иконок
- InspectorPanel: кнопка 'Редактировать табличку…' для billboard-примитива
- KubikonEditor: проброс модалки через onEditBillboard prop
- Icon.jsx: SVG prim-billboard

Два режима ориентации: 'camera' (BillboardGui-аналог, всегда смотрит
на игрока) и 'fixed' (SurfaceGui-аналог, прикреплён к поверхности).

Клик-детекция через ray-pick → UV → пиксели текстуры → поиск кнопки
по AABB; работает с пиксельной точностью даже при повороте камеры.

Документация: RUBLOX_STUDIO_FUNCTIONS.md раздел 1.25.

Тестовая игра 'Магазин апгрейдов' (4 таблички + банер + HUD) — МИН
соберёт в студии drag-n-drop'ом.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 11:58:06 +03:00

467 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

import React, { useState, useEffect, useRef, useMemo } from 'react';
import cl from './GameSettingsModal.module.css';
import Icon from './Icon';
import { BillboardUiManager } from './engine/BillboardUiManager';
/**
* BillboardEditorModal — редактор контента 3D-таблички (billboard primitive).
*
* Открывается из InspectorPanel при клике «Редактировать табличку…».
* Слева — форма (пресет + контент-поля), справа — живое превью на canvas
* (тот же рендер что в BillboardUiManager._render).
*
* Пресеты:
* shop-item — иконка | заголовок + sub | кнопка цены
* shop-purchase — иконка | название | sub | кнопка
* banner — крупный текст по центру
* sign — простой указатель
*
* Props:
* open — boolean
* primitiveData — { id, billboard: {template, face, content, elements} }
* onClose
* onApply(opts) — { template, face, content }
*/
const TEMPLATES = BillboardUiManager.getAvailableTemplates();
const ICONS = BillboardUiManager.getAvailableIcons();
// 8 готовых цветовых пресетов градиента — чтобы дети не подбирали HEX'ы.
const GRADIENT_PRESETS = [
{ name: 'Красный', from: '#ff5a5a', to: '#ff8a3d' },
{ name: 'Синий', from: '#3b82f6', to: '#0ea5e9' },
{ name: 'Зелёный', from: '#10b981', to: '#34d399' },
{ name: 'Фиолетовый', from: '#7c3aed', to: '#a855f7' },
{ name: 'Жёлтый', from: '#f59e0b', to: '#fbbf24' },
{ name: 'Розовый', from: '#ec4899', to: '#f472b6' },
{ name: 'Бирюзовый', from: '#06b6d4', to: '#22d3ee' },
{ name: 'Тёмный', from: '#1f2937', to: '#374151' },
];
const BillboardEditorModal = ({ open, primitiveData, onClose, onApply }) => {
const initial = primitiveData?.billboard || {};
const [template, setTemplate] = useState(initial.template || 'shop-item');
const [face, setFace] = useState(initial.face || 'camera');
const initContent = initial.content || {};
const [icon, setIcon] = useState(initContent.icon || 'hammer');
const [title, setTitle] = useState(initContent.title || 'Апгрейд');
const [sub, setSub] = useState(initContent.sub || '1 > 2');
const [price, setPrice] = useState(initContent.price || '$10,000');
const [gradFrom, setGradFrom] = useState(
(initContent.gradient && initContent.gradient[0]) || '#ff5a5a');
const [gradTo, setGradTo] = useState(
(initContent.gradient && initContent.gradient[1]) || '#ff8a3d');
// Перезагружаем поля при открытии новой таблички
useEffect(() => {
if (!open) return;
const init = primitiveData?.billboard || {};
const c = init.content || {};
setTemplate(init.template || 'shop-item');
setFace(init.face || 'camera');
setIcon(c.icon || 'hammer');
setTitle(c.title || (init.template === 'banner' ? 'Заголовок баннера' : 'Апгрейд'));
setSub(c.sub || '1 > 2');
setPrice(c.price || '$10,000');
if (c.gradient) {
setGradFrom(c.gradient[0] || '#ff5a5a');
setGradTo(c.gradient[1] || '#ff8a3d');
}
}, [open, primitiveData]);
const templateDef = useMemo(
() => TEMPLATES.find(t => t.id === template) || TEMPLATES[0],
[template]);
// Поля доступные для текущего шаблона
const fields = templateDef.fields;
// === Живое превью на canvas ===
const canvasRef = useRef(null);
const previewMgrRef = useRef(null);
useEffect(() => {
if (!open || !canvasRef.current) return;
// Создаём отдельный canvas-based рендер. Мы НЕ используем сам BillboardUiManager
// (он требует Babylon scene). Дублируем minimal-логику рисования здесь,
// вызывая те же приватные методы рендера через статический хелпер.
// Проще всего — сделать temp-инстанс с фиктивной scene и вызвать _render.
// Но _render требует dyn.getContext()/.update() — Babylon API. Поэтому
// рисуем напрямую на 2D canvas с упрощённой копией логики ниже.
renderPreview(canvasRef.current, template, {
icon, title, sub, price,
gradient: [gradFrom, gradTo],
});
}, [open, template, icon, title, sub, price, gradFrom, gradTo]);
if (!open) return null;
const handleApply = () => {
const content = {};
if (fields.includes('icon')) content.icon = icon;
if (fields.includes('title')) content.title = title;
if (fields.includes('sub')) content.sub = sub;
if (fields.includes('price')) content.price = price;
if (fields.includes('gradient')) content.gradient = [gradFrom, gradTo];
onApply({ template, face, content });
};
return (
<div className={cl.overlay} onClick={onClose}>
<div className={cl.modal} onClick={e => e.stopPropagation()}
style={{ maxWidth: 920, width: '90vw' }}>
<div className={cl.header}>
<div className={cl.title}>
<Icon name="prim-billboard" size={20}/>
<span style={{ marginLeft: 8 }}>Редактор 3D-таблички</span>
</div>
<button className={cl.closeBtn} onClick={onClose}
aria-label="Закрыть">
<Icon name="close" size={16}/>
</button>
</div>
<div className={cl.body} style={{ display: 'flex', gap: 16,
padding: 16, minHeight: 480 }}>
{/* Левая колонка — форма */}
<div style={{ flex: '1 1 360px', minWidth: 320 }}>
<Section title="Пресет">
<div style={{ display: 'grid',
gridTemplateColumns: '1fr 1fr', gap: 6 }}>
{TEMPLATES.map(t => (
<button key={t.id}
className={t.id === template
? cl.pillActive : cl.pill}
onClick={() => setTemplate(t.id)}
style={pillStyle(t.id === template)}>
{t.name}
</button>
))}
</div>
</Section>
<Section title="Ориентация">
<div style={{ display: 'flex', gap: 6 }}>
<button onClick={() => setFace('camera')}
style={pillStyle(face === 'camera')}>
Смотрит на игрока
</button>
<button onClick={() => setFace('fixed')}
style={pillStyle(face === 'fixed')}>
Фиксированная
</button>
</div>
</Section>
{fields.includes('icon') && (
<Section title="Иконка">
<select value={icon}
onChange={e => setIcon(e.target.value)}
style={inputStyle}>
{ICONS.map(i => (
<option key={i} value={i}>{i}</option>
))}
</select>
</Section>
)}
{fields.includes('title') && (
<Section title="Заголовок">
<input type="text" value={title}
onChange={e => setTitle(e.target.value)}
maxLength={80}
style={inputStyle}/>
</Section>
)}
{fields.includes('sub') && (
<Section title="Подзаголовок (прогресс)">
<input type="text" value={sub}
onChange={e => setSub(e.target.value)}
maxLength={40}
placeholder='Например: "1 > 2"'
style={inputStyle}/>
</Section>
)}
{fields.includes('price') && (
<Section title="Цена (на кнопке)">
<input type="text" value={price}
onChange={e => setPrice(e.target.value)}
maxLength={20}
placeholder='Например: $10,000'
style={inputStyle}/>
</Section>
)}
{fields.includes('gradient') && (
<Section title="Градиент фона">
<div style={{ display: 'grid',
gridTemplateColumns: 'repeat(4, 1fr)',
gap: 6, marginBottom: 8 }}>
{GRADIENT_PRESETS.map(g => (
<button key={g.name}
title={g.name}
onClick={() => {
setGradFrom(g.from);
setGradTo(g.to);
}}
style={{
height: 28,
background: `linear-gradient(180deg, ${g.from}, ${g.to})`,
border: gradFrom === g.from && gradTo === g.to
? '2px solid #fff' : '1px solid #4b5563',
borderRadius: 4,
cursor: 'pointer',
}}/>
))}
</div>
<div style={{ display: 'flex', gap: 6,
alignItems: 'center' }}>
<label style={{ fontSize: 12, color: '#9ca3af' }}>От:</label>
<input type="color" value={gradFrom}
onChange={e => setGradFrom(e.target.value)}
style={{ width: 50, height: 28 }}/>
<label style={{ fontSize: 12, color: '#9ca3af', marginLeft: 10 }}>До:</label>
<input type="color" value={gradTo}
onChange={e => setGradTo(e.target.value)}
style={{ width: 50, height: 28 }}/>
</div>
</Section>
)}
</div>
{/* Правая колонка — превью */}
<div style={{ flex: '0 0 380px', display: 'flex',
flexDirection: 'column' }}>
<div style={{ fontSize: 12, color: '#9ca3af',
marginBottom: 8 }}>Превью:</div>
<div style={{ background: '#0d0e14', borderRadius: 8,
padding: 16, flex: 1,
display: 'flex', alignItems: 'center',
justifyContent: 'center' }}>
<canvas ref={canvasRef} width={512} height={320}
style={{ width: '100%', maxWidth: 360,
aspectRatio: '512/320',
borderRadius: 4,
boxShadow: '0 4px 20px rgba(0,0,0,0.4)' }}/>
</div>
<div style={{ fontSize: 11, color: '#6b7280',
marginTop: 8, lineHeight: 1.5 }}>
{templateDef.hasButton && 'В Play кнопка кликабельна — подпишись через game.billboard.onClick(ref, "buy", fn).'}
</div>
</div>
</div>
<div className={cl.footer}>
<button className={cl.btnGhost} onClick={onClose}>
Отмена
</button>
<button className={cl.btnPrimary} onClick={handleApply}>
Применить
</button>
</div>
</div>
</div>
);
};
// ─── Утилиты UI ─────────────────────────────────────────────────────────────
function Section({ title, children }) {
return (
<div style={{ marginBottom: 14 }}>
<div style={{ fontSize: 12, color: '#9ca3af',
textTransform: 'uppercase', letterSpacing: 0.5,
marginBottom: 6 }}>
{title}
</div>
{children}
</div>
);
}
const inputStyle = {
width: '100%',
padding: '6px 10px',
background: '#1f2937',
border: '1px solid #374151',
borderRadius: 4,
color: '#e5e7eb',
fontSize: 13,
boxSizing: 'border-box',
};
function pillStyle(active) {
return {
padding: '6px 12px',
background: active ? '#3357ff' : '#1f2937',
border: '1px solid ' + (active ? '#3357ff' : '#374151'),
borderRadius: 4,
color: active ? '#fff' : '#9ca3af',
fontSize: 12,
cursor: 'pointer',
flex: 1,
whiteSpace: 'nowrap',
};
}
// ─── Рендер превью (зеркалит BillboardUiManager._render*) ──────────────────
const PREVIEW_ICONS = {
hammer: '🔨', saw: '🪚', drop: '💧', seed: '🌱', cube: '🧊',
coin: '💰', home: '🏠', rocket: '🚀', sprinkler: '⛲', sparkle: '✨',
star: '⭐', bag: '🎒', diamond: '💎', fire: '🔥', lightning: '⚡',
heart: '❤', key: '🔑', shield: '🛡',
};
const W = 512, H = 320;
const BTN = { x: 332, y: 200, w: 160, h: 90 };
function renderPreview(canvas, template, content) {
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, W, H);
switch (template) {
case 'shop-item': drawShopItem(ctx, content); break;
case 'shop-purchase': drawShopPurchase(ctx, content); break;
case 'banner': drawBanner(ctx, content); break;
case 'sign': drawSign(ctx, content); break;
default: drawBanner(ctx, content);
}
}
function roundedGrad(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();
}
}
function drawShopItem(ctx, c) {
roundedGrad(ctx, 8, 8, W - 16, H - 16, {
gradient: c.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();
ctx.font = 'bold 96px "Segoe UI Emoji", Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = '#fff';
ctx.fillText(PREVIEW_ICONS[c.icon] || '🧊', 110, 132);
ctx.font = 'bold 36px Arial, sans-serif';
ctx.textAlign = 'left';
ctx.textBaseline = 'top';
ctx.fillText((c.title || '').slice(0, 18), 200, 50);
if (c.sub) {
ctx.font = 'bold 28px Arial, sans-serif';
ctx.fillStyle = '#a7f3d0';
ctx.fillText(c.sub, 200, 105);
}
roundedGrad(ctx, BTN.x, BTN.y, BTN.w, BTN.h, {
gradient: ['#fbbf24', '#f59e0b'], radius: 16,
stroke: { color: '#000', width: 3 },
});
ctx.font = 'bold 36px Arial, sans-serif';
ctx.fillStyle = '#1c1917';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(c.price || '$0', BTN.x + BTN.w / 2, BTN.y + BTN.h / 2);
}
function drawShopPurchase(ctx, c) {
roundedGrad(ctx, 8, 8, W - 16, H - 16, {
gradient: c.gradient || ['#3b82f6', '#0ea5e9'],
radius: 28, stroke: { color: '#0008', width: 4 },
});
ctx.font = 'bold 110px "Segoe UI Emoji", Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = '#fff';
ctx.fillText(PREVIEW_ICONS[c.icon] || '🎒', 110, 140);
ctx.font = 'bold 34px Arial, sans-serif';
ctx.textAlign = 'left';
ctx.textBaseline = 'top';
ctx.fillText((c.title || '').slice(0, 16), 200, 50);
if (c.sub) {
ctx.font = 'bold 26px Arial, sans-serif';
ctx.fillStyle = '#dbeafe';
ctx.fillText(c.sub, 200, 100);
}
roundedGrad(ctx, BTN.x, BTN.y, BTN.w, BTN.h, {
gradient: ['#a855f7', '#7c3aed'], radius: 16,
stroke: { color: '#000', width: 3 },
});
ctx.font = 'bold 34px Arial, sans-serif';
ctx.fillStyle = '#fff';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(c.price || '0 R', BTN.x + BTN.w / 2, BTN.y + BTN.h / 2);
}
function drawBanner(ctx, c) {
roundedGrad(ctx, 8, 8, W - 16, H - 16, {
gradient: c.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 = wrapText(ctx, c.title || '', W - 80);
const lh = 56;
const sy = H / 2 - (lines.length - 1) * lh / 2;
for (let i = 0; i < lines.length; i++) {
ctx.fillText(lines[i], W / 2, sy + i * lh);
}
ctx.shadowColor = 'transparent';
ctx.shadowBlur = 0;
ctx.shadowOffsetY = 0;
}
function drawSign(ctx, c) {
roundedGrad(ctx, 8, 8, W - 16, H - 16, {
gradient: c.gradient || ['#1f2937', '#374151'],
radius: 20, stroke: { color: '#fff', width: 4 },
});
ctx.font = 'bold 64px Arial, sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = '#fff';
ctx.fillText((c.title || '').slice(0, 14), W / 2, H / 2);
}
function wrapText(ctx, text, maxW) {
const words = (text || '').split(' ');
const lines = [];
let cur = '';
for (const w of words) {
const t = cur ? cur + ' ' + w : w;
if (ctx.measureText(t).width <= maxW) cur = t;
else { if (cur) lines.push(cur); cur = w; }
}
if (cur) lines.push(cur);
return lines.slice(0, 3);
}
export default BillboardEditorModal;