feat: ������������� 3D-�������� (BillboardGui/SurfaceGui) (#12)
Some checks failed
CI / PR size check (push) Has been skipped
CI / Build (push) Failing after 14s
CI / Lint (push) Failing after 1m37s
CI / Secret scan (push) Successful in 2m48s
CI / Deploy to S1 + S2 (push) Has been skipped

This commit is contained in:
min 2026-05-29 09:04:48 +00:00
commit 6f8b654671
10 changed files with 1290 additions and 1 deletions

View File

@ -0,0 +1,466 @@
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;

View File

@ -255,6 +255,8 @@ const GLYPHS = {
'prim-checkpoint': () => (<><path d="M6 21V4" {...S}/><path d="M6 4h6v3h6v3h-6V7H6" {...S}/><path d="M6 12h12v3h-6v-3" {...S}/></>), 'prim-checkpoint': () => (<><path d="M6 21V4" {...S}/><path d="M6 4h6v3h6v3h-6V7H6" {...S}/><path d="M6 12h12v3h-6v-3" {...S}/></>),
'prim-light': () => (<><path d="M8 14a6 6 0 1 1 8 0c-1 1-1.3 1.7-1.5 3h-5c-0.2-1.3-0.5-2-1.5-3z" {...S}/><path d="M9.5 20h5M10 22h4" {...S}/></>), 'prim-light': () => (<><path d="M8 14a6 6 0 1 1 8 0c-1 1-1.3 1.7-1.5 3h-5c-0.2-1.3-0.5-2-1.5-3z" {...S}/><path d="M9.5 20h5M10 22h4" {...S}/></>),
'prim-emitter': () => (<><circle cx="12" cy="12" r="3" {...S}/><path d="M12 3v3M12 18v3M3 12h3M18 12h3M5.5 5.5l2 2M16.5 16.5l2 2M18.5 5.5l-2 2M5.5 18.5l2-2" {...S}/></>), 'prim-emitter': () => (<><circle cx="12" cy="12" r="3" {...S}/><path d="M12 3v3M12 18v3M3 12h3M18 12h3M5.5 5.5l2 2M16.5 16.5l2 2M18.5 5.5l-2 2M5.5 18.5l2-2" {...S}/></>),
// Табличка с GUI: прямоугольник с заголовком, иконкой-кружком и кнопкой
'prim-billboard': () => (<><rect x="3" y="5" width="18" height="14" rx="2" {...S}/><circle cx="7" cy="11" r="2" {...S}/><path d="M10.5 9h6M10.5 12h4" {...S}/><rect x="14" y="14.5" width="5" height="3" rx="1" {...S}/></>),
'prim-portal': () => (<><ellipse cx="12" cy="12" rx="5" ry="8.5" {...S}/><ellipse cx="12" cy="12" rx="2" ry="4" {...S}/></>), 'prim-portal': () => (<><ellipse cx="12" cy="12" rx="5" ry="8.5" {...S}/><ellipse cx="12" cy="12" rx="2" ry="4" {...S}/></>),
'prim-spike': () => (<><path d="M3 19l3-9 3 9M9 19l3-11 3 11M15 19l3-9 3 9" {...S}/><path d="M3 19h18" {...S}/></>), 'prim-spike': () => (<><path d="M3 19l3-9 3 9M9 19l3-11 3 11M15 19l3-9 3 9" {...S}/><path d="M3 19h18" {...S}/></>),
'prim-finish': () => (<><path d="M6 21V4" {...S}/><path d="M6 4h12v10H6" {...S}/><path d="M6 7h4v3.5h4V7h4M6 10.5h4M10 14v-3.5h4V14" {...S}/></>), 'prim-finish': () => (<><path d="M6 21V4" {...S}/><path d="M6 4h12v10H6" {...S}/><path d="M6 7h4v3.5h4V7h4M6 10.5h4M10 14v-3.5h4V14" {...S}/></>),

View File

@ -1666,6 +1666,24 @@ const InspectorPanel = ({
</div> </div>
)} )}
{/* 3D-табличка (billboard) — кнопка «Редактировать табличку» */}
{primitiveType?.kind === 'billboard' && (
<div className={cl.section}>
<div className={cl.sectionTitle}><Icon name="prim-billboard" size={12} /> Контент таблички</div>
<button
type="button"
className={cl.smallBtn}
onClick={() => onEditBillboard?.()}
style={{ width: '100%', padding: '8px 12px', fontWeight: 600 }}
>
Редактировать табличку
</button>
<div style={{ fontSize: 11, color: '#9ca3af', marginTop: 6, lineHeight: 1.4 }}>
{(selection.template || 'shop-item')} · {(selection.face || 'camera') === 'camera' ? 'смотрит на камеру' : 'фиксирована'}
</div>
</div>
)}
{/* Свойства — для всех примитивов */} {/* Свойства — для всех примитивов */}
<div className={cl.section}> <div className={cl.section}>
<div className={cl.sectionTitle}><Icon name="settings" size={12} /> Свойства</div> <div className={cl.sectionTitle}><Icon name="settings" size={12} /> Свойства</div>

View File

@ -21,6 +21,7 @@ import TopRibbon from './TopRibbon';
import TerrainPanel from './TerrainPanel'; import TerrainPanel from './TerrainPanel';
import ModelEditorScreen from './ModelEditorScreen'; import ModelEditorScreen from './ModelEditorScreen';
import ModelSaveModal from './ModelSaveModal'; import ModelSaveModal from './ModelSaveModal';
import BillboardEditorModal from './BillboardEditorModal';
import TerrainGenPanel from './TerrainGenPanel'; import TerrainGenPanel from './TerrainGenPanel';
import ScriptConsole from './ScriptConsole'; import ScriptConsole from './ScriptConsole';
import SceneTabs from './SceneTabs'; import SceneTabs from './SceneTabs';
@ -379,6 +380,9 @@ const KubikonEditor = () => {
const [editingUserModelId, setEditingUserModelId] = useState(null); const [editingUserModelId, setEditingUserModelId] = useState(null);
// Состояние для модалки "Настройки модели" (имя/описание/публичность) // Состояние для модалки "Настройки модели" (имя/описание/публичность)
const [settingsModalModel, setSettingsModalModel] = useState(null); const [settingsModalModel, setSettingsModalModel] = useState(null);
// BillboardEditorModal открывается из инспектора при клике
// «Редактировать табличку». Содержит primitiveData выделенного билборда.
const [billboardEditorData, setBillboardEditorData] = useState(null);
// Bumper для обновления списков в Toolbox после edit/settings/delete. // Bumper для обновления списков в Toolbox после edit/settings/delete.
const [userModelsRefreshKey, setUserModelsRefreshKey] = useState(0); const [userModelsRefreshKey, setUserModelsRefreshKey] = useState(0);
// Bump-счётчик: инкрементируется при создании/очистке гладкого // Bump-счётчик: инкрементируется при создании/очистке гладкого
@ -2921,6 +2925,23 @@ const KubikonEditor = () => {
sceneRef.current?.resizeSelectedPrimitiveTo(sx, sy, sz)} sceneRef.current?.resizeSelectedPrimitiveTo(sx, sy, sz)}
onSetPrimitiveProps={(patch) => onSetPrimitiveProps={(patch) =>
sceneRef.current?.setSelectedPrimitivePropsTo(patch)} sceneRef.current?.setSelectedPrimitivePropsTo(patch)}
onEditBillboard={() => {
// Открываем модалку с данными выделенного billboard-примитива
const s = sceneRef.current;
const sel = s?.selection?._selection;
if (!sel || sel.type !== 'primitive') return;
const data = s?.primitiveManager?.instances?.get(sel.id);
if (!data || data.type !== 'billboard') return;
setBillboardEditorData({
id: data.id,
billboard: data.billboard ? {
template: data.billboard.template,
face: data.billboard.face,
content: { ...data.billboard.content },
elements: data.billboard.elements,
} : null,
});
}}
onSetAnchored={(val) => onSetAnchored={(val) =>
sceneRef.current?.setSelectedAnchored(val)} sceneRef.current?.setSelectedAnchored(val)}
onSetMass={(val) => onSetMass={(val) =>
@ -3445,6 +3466,21 @@ const KubikonEditor = () => {
projectId={id === 'new' ? null : Number(id)} projectId={id === 'new' ? null : Number(id)}
hidden hidden
/> />
{/* Редактор контента 3D-таблички (billboard primitive) */}
<BillboardEditorModal
open={billboardEditorData != null}
primitiveData={billboardEditorData}
onClose={() => setBillboardEditorData(null)}
onApply={(opts) => {
// Применяем к выделенному билборду через setSelectedPrimitivePropsTo
// (там логика прокидывания в PrimitiveManager + persist).
sceneRef.current?.setSelectedPrimitivePropsTo({
billboardOpts: opts,
});
markDirty();
setBillboardEditorData(null);
}}
/>
</div> </div>
); );
}; };

View File

@ -35,6 +35,7 @@ import {
ParticleSystem, ParticleSystem,
Texture, Texture,
Ray, Ray,
PointerEventTypes,
Tools as BabylonTools, Tools as BabylonTools,
} from '@babylonjs/core'; } from '@babylonjs/core';
import { BlockManager } from './BlockManager'; import { BlockManager } from './BlockManager';
@ -53,6 +54,7 @@ import { placeVoxelTree, TREE_TYPES } from './VoxelTreeBuilder';
import { UserModelManager, parseUserModelId, USER_MODEL_PREFIX } from './UserModelManager'; import { UserModelManager, parseUserModelId, USER_MODEL_PREFIX } from './UserModelManager';
import { ModelManager } from './ModelManager'; import { ModelManager } from './ModelManager';
import { PrimitiveManager } from './PrimitiveManager'; import { PrimitiveManager } from './PrimitiveManager';
import { BillboardUiManager } from './BillboardUiManager';
import { getPrimitiveType } from './PrimitiveTypes'; import { getPrimitiveType } from './PrimitiveTypes';
import { FolderManager } from './FolderManager'; import { FolderManager } from './FolderManager';
import { GuiManager } from './GuiManager'; import { GuiManager } from './GuiManager';
@ -1235,6 +1237,11 @@ export class BabylonScene {
// Ссылка на обёртку BabylonScene — нужна для эмиттеров частиц // Ссылка на обёртку BabylonScene — нужна для эмиттеров частиц
// (createEmitterParticles живёт на обёртке). // (createEmitterParticles живёт на обёртке).
this.primitiveManager.scene3d = this; 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.folderManager = new FolderManager(this.blockManager, this.modelManager, this.primitiveManager);
this.guiManager = new GuiManager(); this.guiManager = new GuiManager();
this.inventory = new InventoryManager(); this.inventory = new InventoryManager();
@ -1267,6 +1274,41 @@ export class BabylonScene {
this.selection.setUserModelManager(this.userModelManager); this.selection.setUserModelManager(this.userModelManager);
this.selection.setScene3D(this); this.selection.setScene3D(this);
// === Обработка кликов по 3D-табличкам (billboard) в Play-режиме ===
// При клике луч из позиции курсора (либо из центра экрана, если игрок
// в pointer-lock) → ищем под ним меш типа billboard → переводим точку
// пересечения в UV → BillboardUiManager.pickButtonAt → fireClick.
// Работает только когда _isPlaying=true (в редакторе клик гизмо или ничего).
this.scene.onPointerObservable.add((info) => {
if (info.type !== PointerEventTypes.POINTERDOWN) return;
if (info.event && info.event.button !== 0) return; // только ЛКМ
if (!this._isPlaying) return;
// Для pointer-lock (FPS-камера) — стреляем из центра экрана.
// Иначе — используем pickInfo от Babylon (он уже от курсора).
let pi = info.pickInfo;
const inLock = (document.pointerLockElement != null);
if (inLock) {
const cx = this.engine.getRenderWidth() / 2;
const cy = this.engine.getRenderHeight() / 2;
pi = this.scene.pick(cx, cy, (m) => {
return m.metadata?.isPrimitive
&& this.primitiveManager.instances.get(m.metadata.primitiveId)?.type === 'billboard';
});
}
if (!pi || !pi.hit || !pi.pickedMesh) return;
const meta = pi.pickedMesh.metadata;
if (!meta || !meta.isPrimitive) return;
const data = this.primitiveManager.instances.get(meta.primitiveId);
if (!data || data.type !== 'billboard') return;
// UV точка пересечения с мешем (Babylon знает, если есть UV-координаты).
const uv = pi.getTextureCoordinates ? pi.getTextureCoordinates() : null;
if (!uv) return;
const buttonId = this.billboardUiManager.pickButtonAt(data, uv.x, uv.y);
if (buttonId) {
this.billboardUiManager.fireClick(data, buttonId);
}
});
// GizmoController — управляет 3 типами гизмо (move/rotate/scale). // GizmoController — управляет 3 типами гизмо (move/rotate/scale).
// UtilityLayer — отдельный слой, чтобы гизмо рисовалось поверх сцены. // UtilityLayer — отдельный слой, чтобы гизмо рисовалось поверх сцены.
// Babylon автоматически активирует pointer-observable utility-сцены // Babylon автоматически активирует pointer-observable utility-сцены

View File

@ -0,0 +1,533 @@
/**
* 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;
}
// Ориентация на камеру (BillboardMode_ALL = и X, и Y, и Z).
if (face === 'camera') {
mesh.billboardMode = Mesh.BILLBOARDMODE_ALL;
} else {
mesh.billboardMode = Mesh.BILLBOARDMODE_NONE;
}
// Сохраняем state в data для сериализации и для hit-теста кликов.
data.billboard = {
template,
face,
content: { ...content },
elements: billboardOpts.elements || null,
};
this._render(dyn, template, content, billboardOpts.elements);
}
/**
* Обновить контент билборда (без пересоздания текстуры).
* patch частичные изменения к content (например {sub: '2 > 3', price: '$20,000'}).
*/
update(data, patch) {
if (!data.billboard) return;
data.billboard.content = { ...data.billboard.content, ...patch };
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;
const px = uvX * TEXTURE_W;
const py = (1 - uvY) * TEXTURE_H; // UV.y перевёрнута относительно canvas
// Кастомные 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') {
const b = SHOP_ITEM_BUTTON;
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;
// Перерисовываем с pressed=true, через 100мс — обратно.
const dyn = data.mesh?.metadata?._billboardTexture;
if (!dyn) return;
this._render(dyn, data.billboard.template, data.billboard.content,
data.billboard.elements, /* pressed */ buttonId);
setTimeout(() => {
if (data.mesh?.metadata?._billboardTexture === dyn) {
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.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);
}
}
dyn.update(false /* invertY */);
}
/** Скруглённый прямоугольник + заливка градиентом + обводка. */
_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);
}
// Кнопка цены — жёлтый прямоугольник внизу справа
const b = SHOP_ITEM_BUTTON;
const pressed = pressedButtonId === 'buy';
this._roundedGradientRect(ctx, b.x, b.y, b.w, b.h, {
gradient: pressed
? ['#d97706', '#92400e']
: ['#fbbf24', '#f59e0b'],
radius: 16,
stroke: { color: '#000', width: 3 },
});
ctx.font = 'bold 36px Arial, sans-serif';
ctx.fillStyle = pressed ? '#fef3c7' : '#1c1917';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(content.price || '$0', b.x + b.w / 2, b.y + b.h / 2);
}
/** Рендер пресета 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);
}
// Кнопка-цена
const b = SHOP_ITEM_BUTTON;
const pressed = pressedButtonId === 'buy';
this._roundedGradientRect(ctx, b.x, b.y, b.w, b.h, {
gradient: pressed
? ['#9333ea', '#6b21a8']
: ['#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(content.price || '0 R', b.x + b.w / 2, b.y + b.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 64px Arial, sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = '#fff';
ctx.fillText(this._truncate(content.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'] },
];
}
}

View File

@ -2670,6 +2670,57 @@ export class GameRuntime {
} }
return; return;
} }
// === Billboard 3D-таблички (см. BillboardUiManager) ===
if (cmd === 'billboard.set' || cmd === 'billboard.update' || cmd === 'billboard.onClick') {
try {
// Резолв ref → primitiveId
let ref = payload?.ref;
if (this._localToReal?.has(ref)) ref = this._localToReal.get(ref);
// ref имеет формат 'primitive:NN' — выделяем числовой id
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') {
mgr.update(data, payload.patch || {});
this.scheduleSceneSnapshot?.();
} else if (cmd === 'billboard.onClick') {
const buttonId = String(payload.buttonId || 'buy');
// Регистрируем handler: при клике эмитим event в worker,
// worker найдёт зарегистрированный JS-callback по (ref,button).
const realRef = 'primitive:' + id;
mgr.onClick(data, buttonId, () => {
const sb = this.sandboxes.find(s => s.scriptId === scriptId);
if (sb && typeof sb.sendEvent === 'function') {
sb.sendEvent({
type: 'billboardClick',
ref: realRef,
button: buttonId,
});
}
});
}
} catch (e) {
this._log('error', cmd + ' failed: ' + (e?.message || e));
}
return;
}
if (cmd === 'gui.update') { if (cmd === 'gui.update') {
// payload: { id, patch } // payload: { id, patch }
try { try {
@ -2812,6 +2863,11 @@ export class GameRuntime {
effect: opts.effect, effect: opts.effect,
// textureAsset — картинка из ассетов проекта на грани. // textureAsset — картинка из ассетов проекта на грани.
...(opts.textureAsset != null ? { textureAsset: opts.textureAsset } : {}), ...(opts.textureAsset != null ? { textureAsset: opts.textureAsset } : {}),
// billboard-параметры (только для type='billboard')
...(opts.template != null ? { template: opts.template } : {}),
...(opts.face != null ? { face: opts.face } : {}),
...(opts.content != null ? { content: opts.content } : {}),
...(opts.elements != null ? { elements: opts.elements } : {}),
// anchored:false → объект падает (физика unanchored). // anchored:false → объект падает (физика unanchored).
// canCollide:false → проходимый (зона-триггер). // canCollide:false → проходимый (зона-триггер).
...(opts.anchored != null ? { anchored: opts.anchored } : {}), ...(opts.anchored != null ? { anchored: opts.anchored } : {}),

View File

@ -145,6 +145,20 @@ 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 || 'camera',
content: opts.content || null,
elements: opts.elements || null,
};
this.billboardUiManager.applyToMesh(data, billboardOpts);
// billboardOpts хранится в data.billboard после applyToMesh.
}
this.instances.set(id, data); this.instances.set(id, data);
// Авто-регистрация в shadow casters (Этап 4 теней). // Авто-регистрация в shadow casters (Этап 4 теней).
try { try {
@ -197,6 +211,13 @@ export class PrimitiveManager {
// создаются отдельно в addInstance. // создаются отдельно в addInstance.
return MeshBuilder.CreateSphere(name, return MeshBuilder.CreateSphere(name,
{ diameterX: sx, diameterY: sy, diameterZ: sz, segments: 12 }, this.scene); { diameterX: sx, diameterY: sy, diameterZ: sz, segments: 12 }, this.scene);
case 'billboard':
// 3D-табличка — плоскость с пропорциями таблички (sx × sy),
// sz используется как «толщина рамки» (визуально-незаметная).
// Использует CreatePlane для одностороннего рендера, но в
// BillboardUiManager backFaceCulling=false → видно с обеих сторон.
return MeshBuilder.CreatePlane(name,
{ width: sx, height: sy, sideOrientation: Mesh.DOUBLESIDE }, this.scene);
case 'plane': case 'plane':
return MeshBuilder.CreateBox(name, return MeshBuilder.CreateBox(name,
{ width: sx, height: sy, depth: sz }, this.scene); { width: sx, height: sy, depth: sz }, this.scene);
@ -590,6 +611,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 === // === Лампа: синхронизируем привязанный PointLight ===
if (data.light) { if (data.light) {
// позиция света — за маркером // позиция света — за маркером
@ -722,6 +748,13 @@ export class PrimitiveManager {
...(d.light ? { brightness: d.brightness, range: d.range } : {}), ...(d.light ? { brightness: d.brightness, range: d.range } : {}),
// Параметр эмиттера (только для type='emitter') // Параметр эмиттера (только для type='emitter')
...(d.effect !== undefined ? { effect: d.effect } : {}), ...(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 } : {}),
} : {}),
})); }));
} }

View File

@ -57,6 +57,15 @@ export const PRIMITIVE_TYPES = [
{ id: 'emitter', name: 'Эмиттер частиц', icon: 'prim-emitter', kind: 'emitter', { id: 'emitter', name: 'Эмиттер частиц', icon: 'prim-emitter', kind: 'emitter',
defaultScale: { x: 0.4, y: 0.4, z: 0.4 }, defaultColor: '#ff8833' }, 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) — переключают гейммод игрока === // === GD-порталы (Geometry Dash 2.0) — переключают гейммод игрока ===
// Все цилиндры-«столбы» с neon-материалом. Цвета и gdMode задают режим. // Все цилиндры-«столбы» с neon-материалом. Цвета и gdMode задают режим.
{ id: 'gd_cube', name: 'Портал: Куб', icon: 'prim-portal', kind: 'gd_portal', gdMode: 'cube', { id: 'gd_cube', name: 'Портал: Куб', icon: 'prim-portal', kind: 'gd_portal', gdMode: 'cube',
@ -87,7 +96,7 @@ export const PRIMITIVE_TYPES = [
/** Категории для группировки в палитре. */ /** Категории для группировки в палитре. */
export const PRIMITIVE_CATEGORIES = [ export const PRIMITIVE_CATEGORIES = [
{ id: 'geometry', name: 'Фигуры', ids: ['cube','sphere','cylinder','cone','plane','torus','wedge','cornerwedge'] }, { 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-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'] }, { id: 'gd-objects', name: 'GD-объекты', ids: ['gd_spike','gd_finish','gd_coin'] },
]; ];

View File

@ -103,6 +103,12 @@ let _guiIndex = [];
let _guiClickHandlers = {}; let _guiClickHandlers = {};
// Подписки game.gui.onSubmit(id, fn) — ввод в TextBox завершён (Enter) // Подписки game.gui.onSubmit(id, fn) — ввод в TextBox завершён (Enter)
let _guiSubmitHandlers = {}; 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 вернуть набор ключей, под которыми // Для GUI-события с реальным id вернуть набор ключей, под которыми
// могли быть зарегистрированы handlers: сам id + имя элемента (скрипт // могли быть зарегистрированы handlers: сам id + имя элемента (скрипт
// часто подписывается через game.gui.onClick('ИмяКнопки', fn)). // часто подписывается через game.gui.onClick('ИмяКнопки', fn)).
@ -2644,6 +2650,74 @@ const game = {
_send('economy.spend', { reqId, amount: Number(amount) || 0, reason: String(reason || '') }); _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) {
if (!ref || typeof opts !== 'object' || opts == null) return;
_send('billboard.set', { ref, ...opts });
},
/**
* Частичное обновление content. Самое частое после клика поменять
* sub-строку и цену.
* patch частичный content: { sub, price, title, icon, gradient }
*/
update(ref, patch) {
if (!ref || typeof patch !== 'object' || patch == null) return;
_send('billboard.update', { ref, patch });
},
/**
* Подписаться на клик по кнопке таблички (shop-item: buttonId='buy';
* в кастомных elements id из элемента kind='button').
* ref string-ref
* buttonId id кнопки (по умолчанию 'buy')
* fn () => void
*/
onClick(ref, buttonId, fn) {
if (typeof fn !== 'function') {
// Поддержка вызова с 2 аргументами — buttonId по умолчанию 'buy'.
fn = buttonId;
buttonId = 'buy';
}
if (!ref || typeof fn !== 'function') return;
const bid = String(buttonId || 'buy');
const key = ref + ':' + bid;
if (!_billboardClickHandlers[key]) _billboardClickHandlers[key] = [];
_billboardClickHandlers[key].push(fn);
// Уведомляем main о подписке (чтобы он зарегистрировал hit-listener
// в BillboardUiManager и слал нам billboardClick события).
_send('billboard.onClick', { ref, buttonId: bid });
},
},
/** /**
* Управление режимами ввода курсор и камера. * Управление режимами ввода курсор и камера.
* В режиме 'ui' мышь работает как обычный курсор (как в браузере), * В режиме 'ui' мышь работает как обычный курсор (как в браузере),
@ -3187,6 +3261,25 @@ self.onmessage = (e) => {
const arr = _guiSubmitHandlers[key] || []; const arr = _guiSubmitHandlers[key] || [];
for (const fn of arr) _safeCall(fn, val, 'gui.onSubmit:' + key); 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 (cmd === 'sceneSnapshot') { } else if (cmd === 'sceneSnapshot') {
// payload: { blocks, models, primitives } — массивы { ref, x, y, z, name?, type? } // payload: { blocks, models, primitives } — массивы { ref, x, y, z, name?, type? }
@ -3267,6 +3360,7 @@ self.onmessage = (e) => {
_messageHandlers = {}; _messageHandlers = {};
_guiClickHandlers = {}; _guiClickHandlers = {};
_guiSubmitHandlers = {}; _guiSubmitHandlers = {};
_billboardClickHandlers = {};
_npcDeathHandlers = {}; _npcDeathHandlers = {};
_globalNpcDeathHandlers = []; _globalNpcDeathHandlers = [];
_npcLocalToReal = {}; _npcLocalToReal = {};