Some checks failed
Задача 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>
467 lines
20 KiB
JavaScript
467 lines
20 KiB
JavaScript
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;
|