/** * LabelManager — billboard-плашки (текст-надписи) над 3D-объектами. * * game.scene.setLabel(ref, text, opts) — имена/HP/таймеры/счётчики над * персонажами, врагами, предметами. По умолчанию плашка повёрнута лицом к * камере (billboardMode=7), всегда поверх геометрии (renderingGroupId=1). * * Задача 10 — расширенные стили: фон/обводка/скругление (пресеты gameui/ * warning/reward/boss-hp/plain), обводка текста, richText (//), * faceMode billboard|fixed, attachPoint, maxDistance. * * Плашка привязывается к мешу объекта (parent) и висит над ним. */ import { DynamicTexture } from '@babylonjs/core/Materials/Textures/dynamicTexture'; import { StandardMaterial } from '@babylonjs/core/Materials/standardMaterial'; import { MeshBuilder } from '@babylonjs/core/Meshes/meshBuilder'; import { Color3 } from '@babylonjs/core/Maths/math.color'; import { Mesh } from '@babylonjs/core/Meshes/mesh'; // === Пресеты стилей плашки (фон/обводка/текст) === // gameui — жёлтая обводка + тёмно-синий фон + белый текст (как в Roblox-UI). export const LABEL_PRESETS = { plain: { background: null, borderColor: null, borderWidth: 0, cornerRadius: 0, color: '#ffffff', textStroke: { color: '#000', width: 8 }, }, gameui: { background: '#1a3a8a', borderColor: '#f5c020', borderWidth: 10, cornerRadius: 28, color: '#ffffff', textStroke: { color: '#0a1430', width: 6 }, }, warning: { background: '#b01e1e', borderColor: '#ffce3a', borderWidth: 10, cornerRadius: 28, color: '#ffffff', textStroke: { color: '#000', width: 6 }, }, reward: { background: '#caa018', borderColor: '#fff0a0', borderWidth: 10, cornerRadius: 28, color: '#fff8e0', textStroke: { color: '#6b4e00', width: 6 }, gradient: ['#f7d34a', '#c98a18'], // золотой градиент фона }, 'boss-hp': { background: '#3a0a0a', borderColor: '#ff4040', borderWidth: 8, cornerRadius: 20, color: '#ffd0d0', textStroke: { color: '#000', width: 6 }, gradient: ['#8a1414', '#3a0a0a'], }, }; export class LabelManager { constructor(scene) { this.scene = scene; // ref-строка объекта → { plane, tex, mat, lastKey, opts } this.labels = new Map(); this._playerMesh = null; // для maxDistance — задаётся из BabylonScene } /** Дать ссылку на меш игрока (для maxDistance-скрытия). */ setPlayerMesh(mesh) { this._playerMesh = mesh; } /** * Установить/обновить плашку над объектом. * ref — ref-строка объекта. * anchorMesh — Babylon-меш объекта (плашка крепится к нему). * text — текст (может содержать richText-теги если opts.richText). * opts — см. LABEL_PRESETS + { color, height, size, background, * borderColor, borderWidth, cornerRadius, padding, textStroke, * fontWeight, faceMode, rotationY, attachPoint, preset, * richText, maxDistance } */ setLabel(ref, anchorMesh, text, opts = {}) { if (!anchorMesh) return; text = String(text == null ? '' : text); // Пресет → база, поверх — явные opts. const preset = opts.preset && LABEL_PRESETS[opts.preset] ? LABEL_PRESETS[opts.preset] : null; const st = { ...(preset || {}), ...opts }; const color = st.color || '#ffffff'; const heightAbove = Number.isFinite(opts.height) ? opts.height : 2.5; const sizeMul = Number.isFinite(opts.size) ? opts.size : 1; const richText = !!opts.richText; // Диф-чек: если ничего не поменялось — не пересоздаём (важно для bindLabel). const styleKey = JSON.stringify({ text, color, preset: opts.preset, bg: st.background, bc: st.borderColor, bw: st.borderWidth, cr: st.cornerRadius, rich: richText, fw: st.fontWeight, h: heightAbove, sz: sizeMul, fm: st.faceMode, ry: st.rotationY, af: st.attachFace, tl: st.tilt, ap: typeof st.attachPoint === 'string' ? st.attachPoint : null }); const existing = this.labels.get(ref); if (existing && existing.lastKey === styleKey && existing.plane.parent === anchorMesh) { return; // ничего не изменилось } // Меняется только текст (тот же стиль/размер) → перерисуем canvas без // пересоздания меша (дешевле). Иначе — полное пересоздание. const sameStruct = existing && existing.styleStruct === this._structKey(st, richText, heightAbove, sizeMul); if (sameStruct) { this._drawCanvas(existing.tex, text, color, st, richText); existing.tex.update(true); existing.lastKey = styleKey; existing.lastText = text; return; } this.clearLabel(ref); // Размер текстуры: чем больше текста — тем шире, чтобы не растягивать. const fontPx = 120; const W = 1024, H = 256; const tex = new DynamicTexture(`lblTex_${ref}_${this._uid()}`, { width: W, height: H }, this.scene, true); tex.updateSamplingMode?.(3); // TRILINEAR tex.anisotropicFilteringLevel = 8; tex.hasAlpha = true; this._drawCanvas(tex, text, color, st, richText); tex.update(true); // Соотношение плашки — 4:1 (1024×256). Крупная и читаемая (как в Roblox). // ОДНОСТОРОННЯЯ плоскость (FRONTSIDE): лицо = нормаль +Z. Мы всегда // разворачиваем плашку лицом к наблюдателю снаружи грани, поэтому текст // читается прямо. DOUBLESIDE НЕ используем — задняя грань зеркалит UV // (баг: «Ключ от тайника» наоборот). backFaceCulling выключен только // чтобы плашка не пропадала при взгляде сзади (без отражённого текста). const plane = MeshBuilder.CreatePlane(`lbl_${ref}`, { width: 3.4 * sizeMul, height: 0.85 * sizeMul, sideOrientation: Mesh.FRONTSIDE }, this.scene); const mat = new StandardMaterial(`lblMat_${ref}`, this.scene); mat.diffuseTexture = tex; mat.diffuseTexture.hasAlpha = true; mat.emissiveColor = new Color3(1, 1, 1); mat.diffuseColor = new Color3(0, 0, 0); mat.disableLighting = true; // Геометрия двусторонняя (DOUBLESIDE) с прямыми backUVs — culling можно // включить, дублей нет; текст читается с обеих сторон без зеркала. mat.backFaceCulling = false; mat.disableDepthWrite = true; mat.useAlphaFromDiffuseTexture = true; plane.material = mat; plane.renderingGroupId = 1; plane.isPickable = false; plane.parent = anchorMesh; // Полуразмеры объекта по каждой оси (для крепления над верхом ИЛИ на // грань). Берём bounding box в ЛОКАЛЬНЫХ координатах меша (min/max), чтобы // позиция плашки-ребёнка была верной при любом масштабе/вращении родителя. let halfX = 0.5, halfY = 0.5, halfZ = 0.5; try { const bb = anchorMesh.getBoundingInfo?.().boundingBox; if (bb && bb.minimum && bb.maximum) { halfX = (bb.maximum.x - bb.minimum.x) / 2; halfY = (bb.maximum.y - bb.minimum.y) / 2; halfZ = (bb.maximum.z - bb.minimum.z) / 2; } else if (anchorMesh.scaling) { halfX = Math.abs(anchorMesh.scaling.x) / 2; halfY = Math.abs(anchorMesh.scaling.y) / 2; halfZ = Math.abs(anchorMesh.scaling.z) / 2; } } catch (e) { /* ignore */ } const halfH = halfY; const halfPlane = 0.45 * sizeMul; // полувысота самой плашки (3.4×0.85) // attachFace — ПРИКРЕПИТЬ плашку НА ГРАНЬ объекта (как табличка на // стене/полу): плашка лежит В ПЛОСКОСТИ грани, фиксированной ориентации, // и перемещается/вращается ВМЕСТЕ с объектом (она его ребёнок). Это // Roblox-style «надпись = часть постройки» (в отличие от billboard над // верхом). Значения: 'top'|'bottom'|'front'|'back'|'left'|'right' // (или сырые '+y'/'-y'/'+z'/'-z'/'+x'/'-x'). const FACE = { top: '+y', bottom: '-y', front: '+z', back: '-z', right: '+x', left: '-x' }; let face = st.attachFace; if (face && FACE[face]) face = FACE[face]; if (face) { // На грань — всегда фиксированная ориентация (не billboard), иначе // «связки с примитивом» не будет (плашка крутилась бы к камере). plane.billboardMode = 0; const gap = Number.isFinite(opts.height) ? opts.height : 0.05; // ВАЖНО: Babylon CreatePlane (FRONTSIDE) лицевой стороной (где UV/текст // не зеркалятся) смотрит в −Z. Поэтому чтобы ЛИЦО таблички смотрело // НАРУЖУ выбранной грани, поворачиваем плоскость так, чтобы её −Z // совпал с внешней нормалью грани. tiltSign — знак наклона tilt с // учётом того, что для грани +z плоскость развёрнута на π. let tiltSign = 1; if (face === '+z') { plane.position.set(0, 0, halfZ + gap); plane.rotation.set(0, Math.PI, 0); tiltSign = -1; } else if (face === '-z') { plane.position.set(0, 0, -(halfZ + gap)); plane.rotation.set(0, 0, 0); } else if (face === '+x') { plane.position.set( halfX + gap, 0, 0); plane.rotation.set(0, -Math.PI / 2, 0); } else if (face === '-x') { plane.position.set(-(halfX + gap), 0, 0); plane.rotation.set(0, Math.PI / 2, 0); } else if (face === '+y') { plane.position.set(0, halfY + gap, 0); plane.rotation.set( Math.PI / 2, 0, 0); } else if (face === '-y') { plane.position.set(0, -(halfY + gap), 0); plane.rotation.set(-Math.PI / 2, 0, 0); } if (Number.isFinite(st.rotationY)) plane.rotation.y += st.rotationY; // tilt — наклон таблички вокруг локальной X (ценник под ~45°, как на // витрине Roblox). Знак нормализуем (tiltSign), чтобы «верх назад» был // одинаковым для всех граней. Отрицательный tilt = верх отклоняется // назад (от наблюдателя), как пюпитр. if (Number.isFinite(st.tilt)) plane.rotation.x += st.tilt * tiltSign; } else { // faceMode: 'fixed' — фиксированная ориентация (вращается с объектом), // но позиционируется как обычная плашка (над верхом/центром/низом). if (st.faceMode === 'fixed') { plane.billboardMode = 0; if (Number.isFinite(st.rotationY)) plane.rotation.y = st.rotationY; } else { plane.billboardMode = 7; // всегда лицом к камере } // attachPoint: 'top'(default) — над верхом + небольшой зазор (height); // 'center' — по центру; 'bottom' — у низа; {x,y,z} — явно. const gap = Number.isFinite(opts.height) ? opts.height : 0.6; let py = halfH + gap + halfPlane; // верх объекта + зазор + полувысота плашки if (st.attachPoint === 'center') py = 0; else if (st.attachPoint === 'bottom') py = -(halfH + gap); else if (st.attachPoint && typeof st.attachPoint === 'object') { plane.position.set(st.attachPoint.x || 0, st.attachPoint.y || 0, st.attachPoint.z || 0); py = null; } if (py !== null) plane.position.set(0, py, 0); } this.labels.set(ref, { plane, tex, mat, lastKey: styleKey, lastText: text, styleStruct: this._structKey(st, richText, heightAbove, sizeMul), maxDistance: Number.isFinite(opts.maxDistance) ? opts.maxDistance : null, }); } /** Ключ «структуры» (всё кроме текста) — для решения перерисовать ли canvas. */ _structKey(st, richText, h, sz) { return JSON.stringify({ p: st.preset, bg: st.background, bc: st.borderColor, bw: st.borderWidth, cr: st.cornerRadius, rich: richText, fw: st.fontWeight, grad: st.gradient, ts: st.textStroke, h, sz, fm: st.faceMode, af: st.attachFace, tl: st.tilt, ap: typeof st.attachPoint === 'string' ? st.attachPoint : null }); } _uid() { this._seq = (this._seq || 0) + 1; return this._seq; } /** * Нарисовать плашку на canvas DynamicTexture. * Фон (roundRect + gradient/fill) → обводка border → текст (с обводкой). */ _drawCanvas(tex, text, color, st, richText) { const W = 1024, H = 256; const ctx = tex.getContext(); ctx.clearRect(0, 0, W, H); const hasBg = !!st.background || (Array.isArray(st.gradient) && st.gradient.length === 2); const pad = Number.isFinite(st.padding) ? st.padding : 28; const cr = Number.isFinite(st.cornerRadius) ? st.cornerRadius : 0; const bw = Number.isFinite(st.borderWidth) ? st.borderWidth : 0; const weight = st.fontWeight || 700; const innerPad = (hasBg ? (bw + pad) : 24); // отступ от краёв (под рамку) const maxTextW = W - innerPad * 2; // Auto-fit: подбираем размер шрифта, чтобы текст влез по ширине (не обрезался). let fontPx = 120; if (!richText) { ctx.font = `${weight} ${fontPx}px "Roboto Condensed", "Segoe UI", sans-serif`; const tw = ctx.measureText(text).width; if (tw > maxTextW) fontPx = Math.max(40, Math.floor(fontPx * maxTextW / tw)); } ctx.font = `${weight} ${fontPx}px "Roboto Condensed", "Segoe UI", sans-serif`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; // === Фон-плашка === if (hasBg) { const m = bw / 2 + 4; // отступ рамки от края текстуры const x = m, y = m, w = W - m * 2, h = H - m * 2; this._roundRectPath(ctx, x, y, w, h, cr); if (Array.isArray(st.gradient) && st.gradient.length === 2) { const g = ctx.createLinearGradient(0, y, 0, y + h); g.addColorStop(0, st.gradient[0]); g.addColorStop(1, st.gradient[1]); ctx.fillStyle = g; } else { ctx.fillStyle = st.background; } ctx.globalAlpha = Number.isFinite(st.backgroundOpacity) ? st.backgroundOpacity : 0.92; ctx.fill(); ctx.globalAlpha = 1; if (bw > 0 && st.borderColor) { ctx.lineWidth = bw; ctx.strokeStyle = st.borderColor; ctx.stroke(); } } // === Текст === const ts = st.textStroke || { color: '#000', width: hasBg ? 6 : 10 }; if (richText) { this._drawRichText(ctx, text, color, ts, W, H); } else { if (ts && ts.width > 0) { ctx.lineWidth = ts.width; ctx.lineJoin = 'round'; ctx.strokeStyle = ts.color || '#000'; ctx.strokeText(text, W / 2, H / 2 + 4); } ctx.fillStyle = color; ctx.fillText(text, W / 2, H / 2 + 4); } } /** Путь скруглённого прямоугольника (roundRect не везде есть). */ _roundRectPath(ctx, x, y, w, h, r) { r = Math.min(r, w / 2, h / 2); 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(); } /** * RichText: парсим теги ..., ..., .... * Рисуем сегментами по горизонтали, центрируя всю строку. Вложенность не * поддерживается (на MVP) — берём последний открытый тег каждого типа. */ _drawRichText(ctx, text, baseColor, ts, W, H) { const segs = this._parseRich(text, baseColor); const fontPx = 120; // Замер ширины каждого сегмента в его размере. let total = 0; for (const s of segs) { ctx.font = `${s.bold ? 800 : 700} ${Math.round(fontPx * s.sizeMul)}px "Roboto Condensed","Segoe UI",sans-serif`; s.w = ctx.measureText(s.text).width; total += s.w; } let x = (W - total) / 2; for (const s of segs) { ctx.font = `${s.bold ? 800 : 700} ${Math.round(fontPx * s.sizeMul)}px "Roboto Condensed","Segoe UI",sans-serif`; ctx.textAlign = 'left'; if (ts && ts.width > 0) { ctx.lineWidth = ts.width; ctx.lineJoin = 'round'; ctx.strokeStyle = ts.color || '#000'; ctx.strokeText(s.text, x, H / 2 + 4); } ctx.fillStyle = s.color; ctx.fillText(s.text, x, H / 2 + 4); x += s.w; } ctx.textAlign = 'center'; } /** Простой парсер richText → [{text, color, bold, sizeMul}]. */ _parseRich(text, baseColor) { const segs = []; let color = baseColor, bold = false, sizeMul = 1; // Разбиваем по тегам (открывающим/закрывающим). const re = /<(\/?)(?:(color)=([#0-9a-fA-F]+)|(b)|(i)|(size)=(\d+))>|([^<]+)/g; let m; while ((m = re.exec(text)) !== null) { const closing = m[1] === '/'; if (m[8] != null) { // текстовый кусок if (m[8]) segs.push({ text: m[8], color, bold, sizeMul }); } else if (m[2]) { // color = closing ? baseColor : m[3]; } else if (m[4]) { // bold = !closing; } else if (m[6]) { // sizeMul = closing ? 1 : Math.max(0.4, Math.min(2, Number(m[7]) / 100)); } // игнорим визуально (italic в canvas через font-style — опускаем на MVP) } if (segs.length === 0) segs.push({ text: '', color: baseColor, bold: false, sizeMul: 1 }); return segs; } /** Каждый кадр: maxDistance-скрытие плашек дальше порога от игрока. */ update() { if (!this._playerMesh) return; const pp = this._playerMesh.position; for (const rec of this.labels.values()) { if (rec.maxDistance == null) continue; const ap = rec.plane.getAbsolutePosition(); const dx = ap.x - pp.x, dy = ap.y - pp.y, dz = ap.z - pp.z; const far = (dx * dx + dy * dy + dz * dz) > rec.maxDistance * rec.maxDistance; rec.plane.setEnabled(!far); } } /** Убрать плашку с объекта. */ clearLabel(ref) { const rec = this.labels.get(ref); if (!rec) return; try { rec.plane.dispose(); } catch (e) { /* ignore */ } try { rec.tex.dispose(); } catch (e) { /* ignore */ } try { rec.mat.dispose(); } catch (e) { /* ignore */ } this.labels.delete(ref); } /** Удалить все плашки (при выходе из Play). */ clearAll() { for (const ref of [...this.labels.keys()]) this.clearLabel(ref); } }