Some checks failed
Задача 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>
699 lines
32 KiB
JavaScript
699 lines
32 KiB
JavaScript
/**
|
||
* 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'] },
|
||
];
|
||
}
|
||
}
|