studio/src/editor/engine/BillboardUiManager.js
МИН 42be04def9
Some checks failed
CI / Lint (pull_request) Failing after 1m10s
CI / Build (pull_request) Successful in 2m2s
CI / Secret scan (pull_request) Successful in 2m36s
CI / PR size check (pull_request) Successful in 6s
CI / Deploy to S1 + S2 (pull_request) Has been skipped
feat(week4): модальные сцены, кастомные скины игрока и вики-гайды по 4 играм
Задача 04 — модальные сцены (затемнение + GUI/3D-анимация + блок ввода):
- engine/ModalManager.js (state-машина, fade, spotlight-проекция, HighlightLayer)
- editor/ModalOverlay.jsx (CSS-overlay + mask-image для spotlight)
- PlayerController.setInputBlocked/setCameraFrozen/captureCameraState/focusOnTarget
- game.modal.open/close/update/isOpen/onClose + пресеты bossIntro/lootbox/dialog/confirmation
- Фиксы ядра: клик GUI-кнопок (realId↔localId), modalOpened через globalEvent,
  guard от двойного срабатывания колбэков, кнопка ✓ на последней строке диалога

Задача 07 — кастомные скины игрока + магазин:
- non-humanoid скины (любая 3D-модель): загрузчик, процедурная анимация,
  настраиваемый AABB, центрирование через pivot-node
- PlayerController.reloadSkin (смена скина в Play)
- game.player.setSkin/unlockSkin/getAvailableSkins/onSkinChange/openSkinShop + локальная валюта
- editor/SkinShopOverlay.jsx (встроенный магазин, клавиша B) + SkinManagerModal.jsx (вкладка «Скины»)
- сериализация scene.skins, кнопка «Скины» в тулбаре

Вики — категория «Разбор готовых игр»:
- docsGames.js: группа g5 + 4 карточки (Двор/Витрина GUI/Сундук/Парк)
- docsLessons.jsx: 4 подробные статьи-урока для детей
- «Открыть мою копию» создаёт копию проекта (оригинал не трогается)

Адаптивная ширина кнопки billboard под длинный текст.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 00:50:56 +03:00

699 lines
32 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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'] },
];
}
}