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 (
e.stopPropagation()} style={{ maxWidth: 920, width: '90vw' }}>
Редактор 3D-таблички
{/* Левая колонка — форма */}
{TEMPLATES.map(t => ( ))}
{fields.includes('icon') && (
)} {fields.includes('title') && (
setTitle(e.target.value)} maxLength={80} style={inputStyle}/>
)} {fields.includes('sub') && (
setSub(e.target.value)} maxLength={40} placeholder='Например: "1 > 2"' style={inputStyle}/>
)} {fields.includes('price') && (
setPrice(e.target.value)} maxLength={20} placeholder='Например: $10,000' style={inputStyle}/>
)} {fields.includes('gradient') && (
{GRADIENT_PRESETS.map(g => (
setGradFrom(e.target.value)} style={{ width: 50, height: 28 }}/> setGradTo(e.target.value)} style={{ width: 50, height: 28 }}/>
)}
{/* Правая колонка — превью */}
Превью:
{templateDef.hasButton && 'В Play кнопка кликабельна — подпишись через game.billboard.onClick(ref, "buy", fn).'}
); }; // ─── Утилиты UI ───────────────────────────────────────────────────────────── function Section({ title, children }) { return (
{title}
{children}
); } 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;