/** * 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; } // Ориентация. Babylon-quirk: CreatePlane FRONT в -Z (left-handed), // юзер ставит билборд так чтобы лицо смотрело в +Z мира → +π. mesh.billboardMode = Mesh.BILLBOARDMODE_NONE; if (mesh.metadata._billboardLookObs) { this.scene.onBeforeRenderObservable.remove(mesh.metadata._billboardLookObs); mesh.metadata._billboardLookObs = null; } if (face === 'camera') { // Ручной look-at — каждый кадр поворачиваем front к камере. const obs = this.scene.onBeforeRenderObservable.add(() => { if (mesh.isDisposed()) return; const cam = this.scene.activeCamera; if (!cam) return; const dx = cam.position.x - mesh.position.x; const dz = cam.position.z - mesh.position.z; mesh.rotation.y = Math.atan2(dx, dz) + Math.PI; }); mesh.metadata._billboardLookObs = obs; } else { // Фиксированная ориентация: front в +Z + пользовательский rotationY. const userY = Number.isFinite(billboardOpts.rotationY) ? billboardOpts.rotationY : 0; mesh.rotation.y = Math.PI + userY; // Двусторонняя табличка: рамка стоит, но при взгляде сзади // флипаем UV таблички чтобы текст не был зеркальным. const mat = mesh.material; if (mat) { // Включаем рендер обеих сторон (back-face визуализируется). mat.backFaceCulling = false; } const obs = this.scene.onBeforeRenderObservable.add(() => { if (mesh.isDisposed()) return; const cam = this.scene.activeCamera; if (!cam) return; // Локальная нормаль FRONT plane = +Z. Поворот mesh.rotation.y // переводит её в world: normalWorld = (sin(ry), 0, cos(ry)). const ry = mesh.rotation.y; const nWx = Math.sin(ry); const nWz = Math.cos(ry); // Вектор от mesh к камере const vx = cam.position.x - mesh.position.x; const vz = cam.position.z - mesh.position.z; // Скалярное произведение: >0 — камера смотрит на FRONT, // <0 — на BACK (зеркальная UV). Для BACK инвертируем uScale. const dot = nWx * vx + nWz * vz; const dyn = mesh.metadata?._billboardTexture; if (dyn) { // dot > 0 — камера со стороны FRONT-нормали → flip // dot < 0 — камера сзади → нормально if (dot > 0) { if (dyn.uScale !== -1) { dyn.uScale = -1; dyn.uOffset = 1; } } else { if (dyn.uScale !== 1) { dyn.uScale = 1; dyn.uOffset = 0; } } } }); mesh.metadata._billboardLookObs = obs; } mesh.scaling.x = Math.abs(mesh.scaling.x || 1); mesh.metadata._billboardMirrorX = false; // Сохраняем state в data для сериализации и для hit-теста кликов. data.billboard = { template, face, content: { ...content }, elements: billboardOpts.elements || null, }; dyn._kubikonMirrorX = mesh.metadata._billboardMirrorX === true; dyn._kubikonOwnerMesh = mesh; this._render(dyn, template, content, billboardOpts.elements); } /** * Обновить контент билборда (без пересоздания текстуры). * Две формы: * 1) update(data, { sub: '2 > 3', price: '$20,000' }) — patch content * 2) update(data, 'buy', { text: '$15,000' }) — patch конкретного элемента * по id (для elements-режима ИЛИ для known-id пресета: 'buy', 'title', * 'sub', 'price', 'icon', 'gradient' маппятся на поля content). */ update(data, elementIdOrPatch, patchMaybe) { if (!data.billboard) return; // Форма 2: 3 аргумента (data, elementId, patch) if (typeof elementIdOrPatch === 'string' && typeof patchMaybe === 'object' && patchMaybe !== null) { const elId = elementIdOrPatch; const patch = patchMaybe; // Кастомные elements: ищем элемент по id и обновляем его поля. if (Array.isArray(data.billboard.elements)) { data.billboard.elements = data.billboard.elements.map(el => el && el.id === elId ? { ...el, ...patch } : el); } else { // Пресет: мапим известные elementId → ключ content. // 'buy' → content.price; 'title'/'sub'/'icon'/'gradient' → одноимённый ключ. const c = { ...(data.billboard.content || {}) }; if (elId === 'buy' && 'text' in patch) { c.price = patch.text; } else if (elId in c) { // Если patch имеет text — кладём в content[elId], иначе мерджим поля. if ('text' in patch) c[elId] = patch.text; else Object.assign(c, patch); } else { Object.assign(c, patch); } data.billboard.content = c; } } else if (typeof elementIdOrPatch === 'object' && elementIdOrPatch !== null) { // Форма 1: 2 аргумента (data, patchContent) data.billboard.content = { ...data.billboard.content, ...elementIdOrPatch }; } else { return; } 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; // Если текстура в данный момент отзеркалена (face=fixed, смотрим // на back-side), uScale=-1 — инвертим UV.x чтобы попасть в правильный // canvas-пиксель. const dyn = data.mesh?.metadata?._billboardTexture; const flipped = dyn && dyn.uScale === -1; const uX = flipped ? (1 - uvX) : uvX; const px = uX * TEXTURE_W; const py = (1 - uvY) * TEXTURE_H; // Кастомные 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') { // Кнопка адаптивной ширины — пересчитываем её rect по тексту // именно ЭТОЙ таблички (тем же _computeBuyRect, что и при рисовании). const label = (data.billboard.content && data.billboard.content.price) || '$0'; let b = SHOP_ITEM_BUTTON; try { const measCtx = (dyn && dyn.getContext && dyn.getContext()) || null; if (measCtx) b = this._computeBuyRect(measCtx, label, SHOP_ITEM_BUTTON); } catch (e) { /* fallback на базовый rect */ } 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; const dyn = data.mesh?.metadata?._billboardTexture; if (!dyn) return; // Перерисовываем pressed=true. ВАЖНО: используем СВЕЖИЙ content в callback'е // (на момент 120мс content уже может быть обновлён через update — берём // актуальный, иначе откатим к старому). // Также гарантируем 1 flash на табличку — если предыдущий ещё крутится, // отменяем его таймер. if (data._flashTimer) { clearTimeout(data._flashTimer); data._flashTimer = null; } this._render(dyn, data.billboard.template, data.billboard.content, data.billboard.elements, /* pressed */ buttonId); data._flashTimer = setTimeout(() => { data._flashTimer = null; // Берём АКТУАЛЬНЫЕ data.billboard.content/elements — могли обновиться // через game.billboard.update() ВО ВРЕМЯ flash'а. if (data.mesh?.metadata?._billboardTexture === dyn && data.billboard) { 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.save(); ctx.setTransform(1, 0, 0, 1, 0, 0); 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); } } ctx.restore(); dyn.update(true); } /** Скруглённый прямоугольник + заливка градиентом + обводка. */ _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); } // Кнопка цены — жёлтый прямоугольник внизу справа. // Ширина адаптивная: длинный текст («ПРИМЕРИТЬ»/«НАДЕТО») расширяет // кнопку ВЛЕВО (правый край прижат к краю таблички), шрифт ужимается // если даже расширенная кнопка узка. Фактический rect → _buyRect для hit-теста. const pressed = pressedButtonId === 'buy'; const label = content.price || '$0'; const rect = this._computeBuyRect(ctx, label, SHOP_ITEM_BUTTON); this._roundedGradientRect(ctx, rect.x, rect.y, rect.w, rect.h, { gradient: pressed ? ['#d97706', '#92400e'] : ['#fbbf24', '#f59e0b'], radius: 16, stroke: { color: '#000', width: 3 }, }); ctx.font = `bold ${rect.fontSize}px Arial, sans-serif`; ctx.fillStyle = pressed ? '#fef3c7' : '#1c1917'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(label, rect.x + rect.w / 2, rect.y + rect.h / 2); } /** * Подобрать прямоугольник кнопки «buy» под текст: правый край прижат к * правому краю таблички (как в базовом SHOP_ITEM_BUTTON), ширина растёт * влево под длину текста, шрифт ужимается если упёрлись в макс-ширину. * Возвращает { x, y, w, h, fontSize }. */ _computeBuyRect(ctx, label, base) { const PAD = 36; // отступы текста по бокам const MAX_W = 300; // макс ширина кнопки (не залезать на title) const rightEdge = base.x + base.w; // правый край держим на месте let fontSize = 36; ctx.font = `bold ${fontSize}px Arial, sans-serif`; let textW = ctx.measureText(label).width; let w = Math.max(base.w, textW + PAD * 2); if (w > MAX_W) { // Ужимаем шрифт чтобы текст влез в MAX_W. w = MAX_W; const inner = MAX_W - PAD * 2; while (fontSize > 20 && textW > inner) { fontSize -= 2; ctx.font = `bold ${fontSize}px Arial, sans-serif`; textW = ctx.measureText(label).width; } } return { x: rightEdge - w, y: base.y, w, h: base.h, fontSize }; } /** Рендер пресета 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); } // Кнопка-цена (адаптивная ширина под длинный текст, см. _computeBuyRect). const pressed = pressedButtonId === 'buy'; const label = content.price || '0 R'; const rect = this._computeBuyRect(ctx, label, SHOP_ITEM_BUTTON); this._roundedGradientRect(ctx, rect.x, rect.y, rect.w, rect.h, { gradient: pressed ? ['#9333ea', '#6b21a8'] : ['#a855f7', '#7c3aed'], radius: 16, stroke: { color: '#000', width: 3 }, }); ctx.font = `bold ${rect.fontSize}px Arial, sans-serif`; ctx.fillStyle = '#fff'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(label, rect.x + rect.w / 2, rect.y + rect.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 44px Arial, sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillStyle = '#ffd166'; const title = content.title || ''; const subText = content.sub || ''; if (subText) { // Заголовок сверху, sub-строки списком ниже ctx.fillText(this._truncate(title, 18), TEXTURE_W / 2, 50); // Sub — многострочный, выравнивание по левому краю ctx.font = '20px Arial, sans-serif'; ctx.fillStyle = '#fff'; ctx.textAlign = 'left'; ctx.textBaseline = 'top'; const lines = String(subText).split('\n'); const startY = 95; const lineH = 30; const leftX = 38; for (let i = 0; i < lines.length && i < 8; i++) { ctx.fillText(this._truncate(lines[i], 36), leftX, startY + i * lineH); } } else { ctx.font = 'bold 64px Arial, sans-serif'; ctx.fillText(this._truncate(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'] }, ]; } }