Some checks failed
Движок: LabelManager attachFace (текст плоско на грань примитива, FRONTSIDE без зеркала), tilt, 5 пресетов, richText; GameRuntime scene.move/scene.rotate для моделей и примитивов; ScriptSandboxWorker obj.move/obj.rotate в Instance- proxy; InspectorPanel настройки label. Вики: карточка #57 guide-dynamic-label (Часовая башня) + полная статья-урок с разбором attachFace/obj.move/format.money. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
397 lines
21 KiB
JavaScript
397 lines
21 KiB
JavaScript
/**
|
||
* LabelManager — billboard-плашки (текст-надписи) над 3D-объектами.
|
||
*
|
||
* game.scene.setLabel(ref, text, opts) — имена/HP/таймеры/счётчики над
|
||
* персонажами, врагами, предметами. По умолчанию плашка повёрнута лицом к
|
||
* камере (billboardMode=7), всегда поверх геометрии (renderingGroupId=1).
|
||
*
|
||
* Задача 10 — расширенные стили: фон/обводка/скругление (пресеты gameui/
|
||
* warning/reward/boss-hp/plain), обводка текста, richText (<color>/<b>/<size>),
|
||
* 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: парсим теги <color=#hex>...</color>, <b>...</b>, <size=N>...</size>.
|
||
* Рисуем сегментами по горизонтали, центрируя всю строку. Вложенность не
|
||
* поддерживается (на 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=...>
|
||
color = closing ? baseColor : m[3];
|
||
} else if (m[4]) { // <b>
|
||
bold = !closing;
|
||
} else if (m[6]) { // <size=N>
|
||
sizeMul = closing ? 1 : Math.max(0.4, Math.min(2, Number(m[7]) / 100));
|
||
}
|
||
// <i> игнорим визуально (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);
|
||
}
|
||
}
|