Compare commits
3 Commits
3279d59f02
...
c32914c819
| Author | SHA1 | Date | |
|---|---|---|---|
| c32914c819 | |||
| 5518537d53 | |||
| 0bcbb89664 |
466
src/editor/BillboardEditorModal.jsx
Normal file
466
src/editor/BillboardEditorModal.jsx
Normal 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;
|
||||||
@ -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}/></>),
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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-сцены
|
||||||
@ -2352,6 +2394,11 @@ export class BabylonScene {
|
|||||||
const tag = (target.tagName || '').toLowerCase();
|
const tag = (target.tagName || '').toLowerCase();
|
||||||
if (tag === 'input' || tag === 'textarea' || tag === 'select') return true;
|
if (tag === 'input' || tag === 'textarea' || tag === 'select') return true;
|
||||||
if (target.isContentEditable) return true;
|
if (target.isContentEditable) return true;
|
||||||
|
// Monaco-редактор — у его внутренних элементов tagName бывает 'div',
|
||||||
|
// фокус живёт на скрытой textarea, но в зависимости от роутинга
|
||||||
|
// событий e.target может оказаться родительским div. Проверяем
|
||||||
|
// принадлежность дереву Monaco — там точно идёт набор текста.
|
||||||
|
if (typeof target.closest === 'function' && target.closest('.monaco-editor')) return true;
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
533
src/editor/engine/BillboardUiManager.js
Normal file
533
src/editor/engine/BillboardUiManager.js
Normal 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'] },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 } : {}),
|
||||||
|
|||||||
@ -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 } : {}),
|
||||||
|
} : {}),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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'] },
|
||||||
];
|
];
|
||||||
|
|||||||
@ -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 = {};
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user