feat(10): живые 3D-надписи (attachFace) + витрина-лутбокс + вики
Some checks failed
CI / Lint (pull_request) Failing after 5s
CI / PR size check (pull_request) Successful in 12s
CI / Secret scan (pull_request) Failing after 14m51s
CI / Build (pull_request) Failing after 14m52s
CI / Deploy to S1 + S2 (pull_request) Has been cancelled

Движок: 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>
This commit is contained in:
МИН 2026-06-01 21:15:54 +03:00
parent 7c95072e4f
commit 76b2afd312
10 changed files with 1299 additions and 385 deletions

View File

@ -327,5 +327,10 @@ export const GAMES = [
title: 'Лего-полигон — studs материал',
desc: 'Лего-кружки (studs) на блоках и примитивах любого цвета: зелёный пол, оранжевая стена, разноцветные кубы + готовый лего-сет (дерево, дом, машина).',
mechanics: ['material: studs', 'studs-block (цвет на блок)', 'тайлинг по размеру', 'лего-сет моделей'],
previewShot: 'guide-lego-scene.png', openProjectId: 0, ready: true },
previewShot: 'guide-lego-scene.png', openProjectId: 2217, ready: true },
{ id: 'guide-dynamic-label', num: 57, group: 'g5', stars: 2, icon: 'clock',
title: 'Часовая башня — живые 3D-надписи',
desc: 'Живые 3D-надписи + витрина-лутбокс: таймер над башней, ряд подиумов с вращающимися предметами и наклонными табличками-ценниками, счётчик монет (клик +10), HP над зомби. Текст крепится плоско к грани наклонного примитива.',
mechanics: ['scene.bindLabel', 'scene.bindTimer', 'attachFace (текст на грани)', '5 пресетов (gameui/boss-hp/reward…)', 'richText <color>', 'game.format.money', 'obj.move/rotate', 'onClick объекта'],
previewShot: 'guide-dynamic-label-scene.png', openProjectId: 2261, ready: true },
];

View File

@ -418,6 +418,12 @@ const ICONS = {
<path d="M12 13v3M9 20h6M10 20c0-1.5.5-2.5 2-2.5s2 1 2 2.5" {...S} />
</>
),
clock: () => (
<>
<circle cx="12" cy="12" r="9" {...S} />
<path d="M12 7v5l3.5 2" {...S} />
</>
),
};
export default function DocIcon({ name, size = 24, className = '' }) {

View File

@ -7982,6 +7982,131 @@ game.scene.setColor('block:0,0,0', '#ff0000');`}</Code>
),
},
'guide-dynamic-label': {
body: (
<>
<h3 className="lessonH">Что получится</h3>
<p>
Часовая башня с <b>живыми надписями прямо в 3D</b> и <b>витриной-
лутбоксом</b>: над башней <b>таймер обратного отсчёта</b> в
жёлто-синей рамке (как в Roblox); ряд из <b>трёх подиумов</b>, на
каждом парит и вращается предмет (меч, кубок, ключ), а перед ним
<b> наклонная табличка-ценник</b> с названием; <b>счётчик монет</b>
«1 230 рубликов» с золотой монетой (клик +10); над зомби <b>полоса
HP</b>. Все надписи обновляются сами, без мигания.
</p>
<Shot src="guide-dynamic-label-play.png" wide
caption="Витрина-лутбокс: на подиумах вращаются предметы, перед каждым наклонная табличка с названием; слева счётчик монет, над башней таймер." />
<h3 className="lessonH">Чему научишься</h3>
<ul>
<li><b>scene.bindLabel(объект, fn, opts)</b> привязать надпись к
функции: что вернёт функция, то и на плашке. Обновляется раз в
<code>interval</code> сек, текстура перерисовывается только
когда текст реально изменился (диф-чек);</li>
<li><b>scene.bindTimer(объект, времяКонца, opts)</b> готовый
таймер обратного отсчёта с <code>onEnd</code>-колбэком;</li>
<li><b>attachFace</b> прикрепить надпись ПЛОСКО к грани примитива
(<code>'front'/'top'/...</code>): наклоняешь сам примитив
текст лежит в его плоскости и наклонён вместе с ним;</li>
<li><b>5 пресетов плашек</b> <code>gameui</code>, <code>boss-hp</code>,
<code>reward</code> (золото), <code>warning</code>, <code>plain</code>;</li>
<li><b>richText</b> разноцветные части: <code>{'<color=#hex>…</color>'}</code>;</li>
<li><b>game.format</b> <code>time</code> (00:15:59),
<code>money</code> («1 334 рублика» разделитель + склонение);</li>
<li><b>obj.move / obj.rotate</b> двигать и вращать предмет (парение
и крутёж на подиуме);</li>
<li><b>надпись из инспектора</b> раздел «Подпись над объектом»:
таймер/счётчик/HP без кода.</li>
</ul>
<h3 className="lessonH">Шаг 1. Надпись из инспектора (без кода)</h3>
<Shot src="guide-dynamic-label-scene.png" wide
caption="Сцена в редакторе: часовая башня из studs, витрина с подиумами, антураж (сосны, камни, цветы)." />
<Step n="1">
Выдели объект в инспекторе справа раздел
<b> «Подпись над объектом»</b> включи галочку.
</Step>
<Step n="2">
В списке <b>«Связать с»</b> выбери <b>Таймер</b>, длительность
(960 = 16 минут), формат <code>hh:mm:ss</code>, стиль <b>gameui</b>.
Жми Play цифры пошли вниз.
</Step>
<h3 className="lessonH">Шаг 2. Таблички-ценники: наклони примитив</h3>
<p>
Правильная логика <b>наклоняешь сам примитив-планшет</b>, а текст
крепишь к его передней грани через <code>attachFace: 'front'</code>.
Текст ляжет ТОЧНО в плоскость планшета и наклонится вместе с ним
как ценник на витрине. Размер планшета сделай чуть больше текста,
чтобы надпись не вылезала за края.
</p>
<ScriptKind kind="object" />
<Code>{`// Планшет наклонён в редакторе (поворот по X ≈ -29°, верх назад).
// Табличка лежит ПЛОСКО на его передней грани (БЕЗ отдельного наклона):
const plate = game.scene.findOne('Планшет1');
game.scene.bindLabel(plate, () => 'Золотой кубок',
{ preset: 'gameui', size: 0.8, attachFace: 'front' });
// Предмет на подиуме парит и вращается:
const obj = game.scene.findOne(редмет1');
const x = obj.position.x, z = obj.position.z;
let t = 0;
game.onTick((dt) => {
t += dt;
obj.move(x, 1.9 + Math.sin(t * 2) * 0.18, z); // парение
obj.rotate(t * 1.2); // вращение вокруг Y
});
// Клик по предмету "взять":
obj.onClick(() => game.ui.set('grab', 'Ты взял: Золотой кубок!',
{ x: 50, y: 14, anchor: 'top', color: '#ffd23a', size: 22 }));`}</Code>
<h3 className="lessonH">Шаг 3. Таймер, счётчик, HP</h3>
<Code>{`// Таймер над башней (на передней грани верхнего яруса):
const endTs = Date.now() + 16 * 60 * 1000;
game.scene.bindTimer(game.scene.findOne('ВерхБашни'), endTs, {
prefix: 'Сбросится через ', format: 'hh:mm:ss', preset: 'gameui', attachFace: 'front',
});
// Счётчик монет (формат с разделителем и склонением), клик +10:
const plate = game.scene.findOne('ПланшетМонет');
let coins = 1230;
game.scene.bindLabel(plate, () => game.format.money(coins),
{ preset: 'reward', attachFace: 'front' });
game.scene.findOne('МонетаСчёт').onClick(() => { coins += 10; });
// HP над зомби billboard (всегда к камере), меняется по клику:
const zombie = game.scene.findOne('Зомби');
let hp = 100;
game.scene.bindLabel(zombie, () => 'Зомби HP: ' + hp + '/100', { preset: 'boss-hp' });
zombie.onClick(() => { hp = Math.max(0, hp - 10); });`}</Code>
<Note>
<b>findOne сразу на старте может вернуть null</b> сцена приходит
чуть позже. Оберни поиск в <code>game.after(0.3, () =&gt; {'{ … }'})</code>.
Без <code>attachFace</code> плашка висит билбордом над верхом
объекта (как HP-полоса) это удобно для NPC.
</Note>
<h3 className="lessonH">Почему это не тормозит</h3>
<p>
<b>Диф-чек:</b> <code>bindLabel</code> перерисовывает текстуру
только когда строка изменилась таймер обновляется раз в секунду,
а не каждый кадр. Привязка <b>сама отменяется</b> при
<code> scene.delete</code> утечек нет.
</p>
<Try>
Сделай ещё один подиум со своим предметом из палитры моделей.
Наклони планшет под тем же углом, повесь название через
<code> attachFace: 'front'</code>, а предмет заставь парить и
крутиться через <code>obj.move</code> + <code>obj.rotate</code>.
</Try>
</>
),
},
};
/** Есть ли готовый текст урока для игры с таким id. */

View File

@ -318,6 +318,8 @@ const InspectorPanel = ({
const [localColor, setLocalColor] = useState('#888888');
const [localMaterial, setLocalMaterial] = useState('matte');
const [localStudDensity, setLocalStudDensity] = useState(1); // плотность studs
// Подпись над объектом (задача 10).
const [localLabel, setLocalLabel] = useState(null); // { enabled, binding, params, preset, height }
const [localCanCollide, setLocalCanCollide] = useState(true);
const [localVisible, setLocalVisible] = useState(true);
const [localAnchored, setLocalAnchored] = useState(true);
@ -364,6 +366,7 @@ const InspectorPanel = ({
setLocalColor(selection.color || '#888888');
setLocalMaterial(selection.material || 'matte');
setLocalStudDensity(selection.studDensity || 1);
setLocalLabel(selection.label || null);
setLocalCanCollide(selection.canCollide !== false);
setLocalVisible(selection.visible !== false);
setLocalAnchored(selection.anchored !== false);
@ -1789,6 +1792,100 @@ const InspectorPanel = ({
</div>
)}
{/* Подпись над объектом (задача 10) */}
{(() => {
const L = localLabel || { enabled: false, binding: 'static', params: {}, preset: 'gameui', height: 2.5 };
const applyLabel = (patch) => {
const next = { ...L, ...patch, params: { ...(L.params || {}), ...(patch.params || {}) } };
setLocalLabel(next);
onSetPrimitiveProps?.({ label: next });
};
const inp = { background: 'var(--bg-input,#2a1f15)', color: 'var(--text-primary,#f0e6d8)', border: '1px solid var(--border,#5a4a3a)', borderRadius: 4, padding: '3px 6px', fontSize: 12, width: '100%' };
return (
<div className={cl.section}>
<div className={cl.sectionTitle}><Icon name="type" size={12} /> Подпись над объектом</div>
<label style={{ display: 'flex', gap: 8, alignItems: 'center', cursor: 'pointer', padding: '2px 0' }}>
<input type="checkbox" checked={!!L.enabled}
onChange={(e) => applyLabel({ enabled: e.target.checked })} />
<span style={{ fontSize: 13 }}>Показывать подпись</span>
</label>
{L.enabled && (<>
<div style={{ display: 'flex', gap: 6, alignItems: 'center', marginTop: 6 }}>
<span style={{ fontSize: 12, flex: 1 }}>Связать с</span>
<select value={L.binding} onChange={(e) => applyLabel({ binding: e.target.value })} style={{ ...inp, flex: 1.5 }}>
<option value="static">Статический текст</option>
<option value="timer">Таймер</option>
<option value="save">Счётчик из save</option>
<option value="hp">HP</option>
<option value="formula">Своя формула</option>
</select>
</div>
{(L.binding === 'static' || L.binding === 'formula') && (
<input type="text" placeholder="Текст" value={L.params?.text || ''}
onChange={(e) => applyLabel({ params: { text: e.target.value } })}
style={{ ...inp, marginTop: 6 }} />
)}
{L.binding === 'timer' && (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 6, marginTop: 6 }}>
<label style={{ fontSize: 11, display: 'flex', flexDirection: 'column', gap: 2 }}>Секунд
<input type="number" value={L.params?.duration ?? 960}
onChange={(e) => applyLabel({ params: { duration: Number(e.target.value) } })} style={inp} /></label>
<label style={{ fontSize: 11, display: 'flex', flexDirection: 'column', gap: 2 }}>Формат
<select value={L.params?.format || 'mm:ss'} onChange={(e) => applyLabel({ params: { format: e.target.value } })} style={inp}>
<option value="mm:ss">мм:сс</option>
<option value="hh:mm:ss">чч:мм:сс</option>
<option value="auto">авто</option>
</select></label>
<input type="text" placeholder="Префикс" value={L.params?.prefix || ''}
onChange={(e) => applyLabel({ params: { prefix: e.target.value } })} style={{ ...inp, gridColumn: '1 / 3' }} />
</div>
)}
{L.binding === 'save' && (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 6, marginTop: 6 }}>
<input type="text" placeholder="Ключ (coins)" value={L.params?.key || ''}
onChange={(e) => applyLabel({ params: { key: e.target.value } })} style={inp} />
<input type="text" placeholder="Суффикс" value={L.params?.suffix || ''}
onChange={(e) => applyLabel({ params: { suffix: e.target.value } })} style={inp} />
</div>
)}
<div style={{ display: 'flex', gap: 6, alignItems: 'center', marginTop: 6 }}>
<span style={{ fontSize: 12, flex: 1 }}>Стиль</span>
<select value={L.preset || 'gameui'} onChange={(e) => applyLabel({ preset: e.target.value })} style={{ ...inp, flex: 1.5 }}>
<option value="gameui">Игровой (синий/жёлтый)</option>
<option value="warning">Предупреждение</option>
<option value="reward">Награда (золото)</option>
<option value="boss-hp">Босс HP</option>
<option value="plain">Простой</option>
</select>
</div>
<div style={{ display: 'flex', gap: 6, alignItems: 'center', marginTop: 6 }}>
<span style={{ fontSize: 12, flex: 1 }}>Крепление</span>
<select value={L.attachFace || 'over'}
onChange={(e) => {
const v = e.target.value;
// 'over' billboard над верхом (к камере); иначе на грань.
applyLabel({ attachFace: v === 'over' ? null : v });
}}
style={{ ...inp, flex: 1.5 }}>
<option value="over">Над объектом (к камере)</option>
<option value="front">На грань: перёд</option>
<option value="back">На грань: зад</option>
<option value="left">На грань: лево</option>
<option value="right">На грань: право</option>
<option value="top">На грань: верх</option>
<option value="bottom">На грань: низ</option>
</select>
</div>
<label style={{ fontSize: 11, display: 'flex', flexDirection: 'column', gap: 2, marginTop: 6 }}>
{L.attachFace ? 'Отступ от грани' : 'Высота над объектом'}
<input type="number" step={L.attachFace ? 0.05 : 0.5}
value={L.height ?? (L.attachFace ? 0.05 : 2.5)}
onChange={(e) => applyLabel({ height: Number(e.target.value) })} style={inp} /></label>
</>)}
</div>
);
})()}
{/* Текстура — своя картинка на гранях примитива */}
<div className={cl.section}>
<div className={cl.sectionTitle}>

View File

@ -1510,6 +1510,13 @@ export class BabylonScene {
if (this._touchDetectFrame >= 3) {
this._touchDetectFrame = 0;
this._detectTouchEvents();
// Задача 10: maxDistance-скрытие плашек (раз в 3 кадра).
if (this._labelManager) {
if (this.player?._modelRoot && !this._labelManager._playerMesh) {
this._labelManager.setPlayerMesh(this.player._modelRoot);
}
try { this._labelManager.update(); } catch (e) { /* ignore */ }
}
}
}
}
@ -2193,7 +2200,10 @@ export class BabylonScene {
if (this._isPlaying) {
// В Play-режиме ЛКМ — клик игрока в forward-направлении.
// Pointer Lock — курсор всё равно в центре экрана.
if (e.button === 0) this._handlePlayClick();
if (e.button === 0) {
const r = canvas.getBoundingClientRect();
this._handlePlayClick(e.clientX - r.left, e.clientY - r.top);
}
return;
}
// Обновляем pointer координаты для raycast и Gizmo
@ -2986,7 +2996,7 @@ export class BabylonScene {
* - в self-обработчики скриптов (routeEvent с target)
* - в глобальные обработчики (game.onClick) с event.target
*/
_handlePlayClick() {
_handlePlayClick(clickX, clickY) {
if (!this._isPlaying) return;
// Мультиплеер-выстрел: если у сцены есть mpSync, шлём 'shoot' серверу.
@ -3014,6 +3024,29 @@ export class BabylonScene {
if (target) {
this.gameRuntime.routeEvent(target, 'click', { point });
}
// 1b) findOne(x).onClick(fn) — адресный клик по объекту (задача 8/10).
// В pointer-lock курсор в центре → пикаем центром (target).
// В свободном курсоре (third) → пикаем по реальным координатам клика.
const wc = this.gameRuntime._watchedClickRefs;
if (wc && wc.size > 0) {
const locked = (document.pointerLockElement === this.canvas);
let clTarget = target, clPoint = point;
if (!locked && Number.isFinite(clickX) && Number.isFinite(clickY)) {
const cp = this.scene.pick(clickX, clickY, (m) => m && m.metadata
&& (m.metadata.isModel || m.metadata.isPrimitive || m.metadata.isBlock));
if (cp && cp.hit && cp.pickedMesh) {
clTarget = this._meshToTarget(cp.pickedMesh);
clPoint = cp.pickedPoint ? { x: cp.pickedPoint.x, y: cp.pickedPoint.y, z: cp.pickedPoint.z } : point;
}
}
if (clTarget) {
let ref = null;
if (clTarget.kind === 'primitive') ref = 'primitive:' + clTarget.id;
else if (clTarget.kind === 'model') ref = 'model:' + clTarget.id;
else if (clTarget.kind === 'block' && clTarget.ref) ref = 'block:' + clTarget.ref.x + ',' + clTarget.ref.y + ',' + clTarget.ref.z;
if (ref && wc.has(ref)) this.gameRuntime.routeInstEvent(ref, 'instClick', { point: clPoint });
}
}
// 2) Глобальный onClick — всегда (даже если попали в пустоту)
this.gameRuntime.routeGlobalEvent('click', { point, target });
// 3) toolUse — если в активном слоте инвентаря есть инструмент/оружие.

View File

@ -14,7 +14,7 @@
* Этап 2.1: минимальный API player.teleport, onTick, log.
*/
import { Color3 } from '@babylonjs/core';
import { Color3, Vector3 } from '@babylonjs/core';
import { ScriptSandbox } from './ScriptSandbox';
import { STORYS_addres } from '../../api/API';
import { PhysicsWorld } from './PhysicsWorld';
@ -77,6 +77,8 @@ export class GameRuntime {
if (!Array.isArray(scripts) || scripts.length === 0) {
// eslint-disable-next-line no-console
console.warn('[GameRuntime] start: no scripts to run');
// Задача 10: подписи из инспектора (label) работают и БЕЗ скриптов.
this._setupLabelBindings();
return;
}
// Карта модулей для game.require — { имя_скрипта: код }.
@ -180,6 +182,102 @@ export class GameRuntime {
} else {
setTimeout(sendInitial, 16);
}
// Задача 10: авто-биндинг подписей (label) из project_data — без скрипта.
this._setupLabelBindings();
}
/**
* Задача 10: для всех примитивов с data.label.enabled создаём плашку и
* автообновление (timer/save/hp/static/formula) это «таймер двумя кликами»
* из инспектора, без написания скрипта. Обновление раз в секунду из main.
*/
_setupLabelBindings() {
this._stopLabelBindings();
const pm = this.scene3d?.primitiveManager;
if (!pm) return;
const binds = [];
for (const data of pm.instances.values()) {
const L = data.label;
if (!L || !L.enabled) continue;
binds.push({ id: data.id, label: L, lastText: null });
}
if (binds.length === 0) return;
// Лениво создаём LabelManager.
if (!this.scene3d._labelManager) {
this.scene3d._labelManager = new LabelManager(this.scene3d.scene);
}
this._labelBinds = binds;
const tick = () => {
if (!this._isRunning) return;
const lm = this.scene3d?._labelManager;
const pmm = this.scene3d?.primitiveManager;
if (!lm || !pmm) return;
for (const b of this._labelBinds) {
const data = pmm.instances.get(b.id);
if (!data || !data.mesh) continue;
const text = this._computeLabelText(b.label, data);
if (text === b.lastText) continue;
b.lastText = text;
const L = b.label;
const opts = {
preset: L.preset || 'gameui',
height: L.height ?? 2.5,
richText: !!L.richText,
// Крепление на грань (attachFace) — плашка = часть постройки,
// движется/вращается с объектом; иначе billboard над верхом.
attachFace: L.face || L.attachFace || null,
attachPoint: L.attachPoint || null,
faceMode: L.faceMode || ((L.face || L.attachFace) ? 'fixed' : null),
...(L.style || {}),
};
lm.setLabel('primitive:' + b.id, data.mesh, text, opts);
}
};
tick();
this._labelBindTimer = setInterval(tick, 1000);
}
/** Вычислить текст подписи по типу биндинга. */
_computeLabelText(L, data) {
const p = L.params || {};
const fmt = (sec, f) => {
sec = Math.max(0, Math.floor(sec));
const h = Math.floor(sec / 3600), m = Math.floor((sec % 3600) / 60), s = sec % 60;
const p2 = (n) => String(n).padStart(2, '0');
if (f === 'hh:mm:ss') return `${p2(h)}:${p2(m)}:${p2(s)}`;
if (f === 'mm:ss') return `${p2(Math.floor(sec / 60))}:${p2(s)}`;
if (h > 0) return `${h}ч ${m}м`;
if (m > 0) return `${m}м ${s}с`;
return `${s}с`;
};
switch (L.binding) {
case 'timer': {
// params.duration сек; отсчёт от старта Play. params._end запоминаем.
if (!L._endTs) L._endTs = Date.now() + (Number(p.duration) || 60) * 1000;
const sec = (L._endTs - Date.now()) / 1000;
return (p.prefix || '') + fmt(sec, p.format || 'mm:ss') + (p.suffix || '');
}
case 'save': {
// Локальный кеш save (обновляется при save.set в Worker → через snapshot).
const v = (this._saveCache && this._saveCache[p.key]) ?? p.default ?? 0;
return (p.prefix || '') + v + (p.suffix || '');
}
case 'hp': {
const hp = Math.round(data.hp ?? data.maxHp ?? 100);
const max = Math.round(data.maxHp ?? 100);
return (p.prefix || 'HP: ') + hp + '/' + max;
}
case 'formula':
return String(p.text || '');
case 'static':
default:
return String(p.text || L.text || '');
}
}
_stopLabelBindings() {
if (this._labelBindTimer) { clearInterval(this._labelBindTimer); this._labelBindTimer = null; }
this._labelBinds = null;
}
/** Задача 03: запустить пресеты анимаций для GUI-элементов в Play. */
@ -341,6 +439,7 @@ export class GameRuntime {
this._cleanupSpawnedGui();
// Убираем billboard-метки над объектами (game.scene.setLabel).
try {
this._stopLabelBindings(); // задача 10: остановить авто-биндинги подписей
if (this.scene3d?._labelManager) this.scene3d._labelManager.clearAll();
} catch (e) { /* ignore */ }
// Phase 6.5: освобождаем физ-мир и его wasm-память.
@ -2494,14 +2593,29 @@ export class GameRuntime {
this._applySelfMove(payload);
return;
}
// scene.move {ref, x, y, z} — переместить объект (примитив/модель/userModel)
// из obj.move()/game.scene.move(). Без этого obj.move крашился «unknown cmd».
if (cmd === 'scene.move') {
try {
const target = this._refStrToTarget(payload?.ref);
if (target) this._applySelfMove({ target, x: payload.x, y: payload.y, z: payload.z });
} catch (e) {
console.warn('[GameRuntime] scene.move failed', e);
}
return;
}
if (cmd === 'scene.rotate') {
try {
const ry = Number(payload?.rotationY);
if (!Number.isFinite(ry)) return;
// kind СНАЧАЛА — иначе _resolvePrimitiveId('model:1')→1 совпадёт с
// чужим примитивом id=1 (баг: модель витрины не крутилась).
const isModel = payload?.kind === 'model'
|| (typeof payload?.ref === 'string' && payload.ref.indexOf('model:') === 0);
// 1) Примитив — только если НЕ модель.
const pm = this.scene3d?.primitiveManager;
if (!pm) return;
const rid = this._resolvePrimitiveId(payload?.id);
const data = rid != null ? pm.instances.get(rid) : null;
const rid = (!isModel && pm) ? this._resolvePrimitiveId(payload?.id ?? payload?.ref) : null;
const data = (pm && rid != null) ? pm.instances.get(rid) : null;
if (data) {
data.rotationY = ry;
if (data.mesh?.rotation) {
@ -2511,9 +2625,28 @@ export class GameRuntime {
data._worldMatrixFrozen = false;
}
}
return;
}
// 2) Модель — вращаем rootMesh (rotation = новый Vector3 после unfreeze).
if (isModel && this.scene3d?.modelManager) {
const mm = this.scene3d.modelManager;
let mid = payload?.id;
if (mid == null && typeof payload?.ref === 'string') mid = payload.ref.slice(payload.ref.indexOf(':') + 1);
let md = mm.instances.get(mid);
if (!md && typeof mid === 'string') { const n = Number(mid); if (Number.isFinite(n)) md = mm.instances.get(n); }
if (md) {
md.rotationY = ry;
const root = md.rootMesh || md.rootNode;
if (root) {
if (md._worldMatrixFrozen) {
try { root.unfreezeWorldMatrix?.(); } catch (e) {}
if (Array.isArray(md.meshes)) for (const m of md.meshes) { try { m?.unfreezeWorldMatrix?.(); } catch (e) {} }
md._worldMatrixFrozen = false;
}
root.rotation = new Vector3(0, ry, 0);
}
}
}
// snapshot не шлём — объект всё ещё findOne'ится по тому же ref'у,
// только rotationY обновился, для скрипта это прозрачно.
} catch (e) {
// eslint-disable-next-line no-console
console.warn('[GameRuntime] scene.rotate failed', e);
@ -3555,6 +3688,10 @@ export class GameRuntime {
} else if (kind === 'primitive') {
this.scene3d?.primitiveManager?.removeInstance(Number(rest));
}
// Задача 10: снять плашку удалённого объекта (иначе висит сиротой).
if (this.scene3d?._labelManager) {
try { this.scene3d._labelManager.clearLabel(ref); } catch (e) { /* ignore */ }
}
// Удалили — снимаем mapping
for (const [k, v] of (this._localToReal || new Map()).entries()) {
if (v === ref) this._localToReal.delete(k);
@ -3684,6 +3821,23 @@ export class GameRuntime {
return { blocks, models, primitives };
}
// Разобрать ref-строку ('primitive:N' / 'model:N' / 'block:x,y,z') в target
// {kind, id|ref} для переиспользования _applySelfMove (scene.move).
_refStrToTarget(ref) {
if (typeof ref !== 'string') return null;
const colon = ref.indexOf(':');
if (colon < 0) return null;
const kind = ref.slice(0, colon);
const rest = ref.slice(colon + 1);
if (kind === 'block') {
const p = rest.split(',').map(Number);
if (p.length === 3 && p.every(Number.isFinite)) return { kind: 'block', ref: { x: p[0], y: p[1], z: p[2] } };
return null;
}
if (kind === 'primitive' || kind === 'model' || kind === 'userModel') return { kind, id: rest, ref: rest };
return null;
}
_applySelfMove(payload) {
if (!payload || !payload.target) return;
const t = payload.target;

View File

@ -1,80 +1,385 @@
/**
* LabelManager billboard-метки (текст-плашки) над 3D-объектами.
* LabelManager billboard-плашки (текст-надписи) над 3D-объектами.
*
* Используется для game.scene.setLabel(ref, text) имена/HP над
* персонажами, врагами, предметами. Метка всегда повёрнута лицом к камере
* (billboardMode=7), всегда поверх геометрии (renderingGroupId=1).
* game.scene.setLabel(ref, text, opts) имена/HP/таймеры/счётчики над
* персонажами, врагами, предметами. По умолчанию плашка повёрнута лицом к
* камере (billboardMode=7), всегда поверх геометрии (renderingGroupId=1).
*
* Метка привязывается к мешу объекта (parent) и висит над ним.
* Задача 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 }
// ref-строка объекта → { plane, tex, mat, lastKey, opts }
this.labels = new Map();
this._playerMesh = null; // для maxDistance — задаётся из BabylonScene
}
/** Дать ссылку на меш игрока (для maxDistance-скрытия). */
setPlayerMesh(mesh) { this._playerMesh = mesh; }
/**
* Установить/обновить метку над объектом.
* ref ref-строка объекта (от scene.spawn / scene.find).
* anchorMesh Babylon-меш объекта (метка крепится к нему).
* text текст метки.
* opts { color: '#fff', height: 2.5 (м над объектом), size: 1 }
* Установить/обновить плашку над объектом.
* 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;
const color = opts.color || '#ffffff';
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}_${Date.now()}`,
const tex = new DynamicTexture(`lblTex_${ref}_${this._uid()}`,
{ width: W, height: H }, this.scene, true);
tex.updateSamplingMode?.(3); // TRILINEAR
tex.anisotropicFilteringLevel = 8;
const ctx = tex.getContext();
ctx.clearRect(0, 0, W, H);
ctx.font = 'bold 120px "Roboto Condensed", "Segoe UI", sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.lineWidth = 16;
ctx.lineJoin = 'round';
ctx.strokeStyle = '#000';
ctx.strokeText(String(text), W / 2, H / 2);
ctx.fillStyle = color;
ctx.fillText(String(text), W / 2, H / 2);
tex.update(true);
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: 2.2 * sizeMul, height: 0.55 * sizeMul }, this.scene);
{ 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.billboardMode = 7; // всегда лицом к камере
plane.renderingGroupId = 1; // поверх геометрии
plane.renderingGroupId = 1;
plane.isPickable = false;
// Крепим к объекту: метка висит над ним и двигается вместе с ним.
plane.parent = anchorMesh;
plane.position.set(0, heightAbove, 0);
this.labels.set(ref, { plane, tex, mat });
// Полуразмеры объекта по каждой оси (для крепления над верхом ИЛИ на
// грань). Берём 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;
@ -84,7 +389,7 @@ export class LabelManager {
this.labels.delete(ref);
}
/** Удалить все метки (при выходе из Play). */
/** Удалить все плашки (при выходе из Play). */
clearAll() {
for (const ref of [...this.labels.keys()]) this.clearLabel(ref);
}

View File

@ -40,12 +40,30 @@ function _getStudsTextures(scene) {
let c = _studsTexCache.get(scene);
if (!c) {
const diffuse = new Texture(STUDS_DIFFUSE_URL, scene);
const normal = new Texture(STUDS_NORMAL_URL, scene);
c = { diffuse, normal };
// base — uScale=1 (для cube/trigger: тайлинг через faceUV геометрии).
// tiled — кэш клонов по ключу 'u_v' для не-кубических форм, чтобы НЕ
// плодить по текстуре на каждый меш (был источник FPS-просадки: десятки
// клонов diffuse+normal = десятки GPU-ресурсов + дорогой bump на каждом).
c = { diffuse, tiled: new Map() };
_studsTexCache.set(scene, c);
}
return c;
}
// Общая diffuse-текстура с заданным тайлингом (u,v). Клон создаётся ОДИН раз
// на пару (u,v) и переиспользуется всеми мешами с тем же тайлингом.
function _getStudsTiledTexture(scene, u, v) {
const c = _getStudsTextures(scene);
// округляем до 0.05 — близкие тайлинги шарят одну текстуру
const ru = Math.round(u * 20) / 20, rv = Math.round(v * 20) / 20;
const key = ru + '_' + rv;
let t = c.tiled.get(key);
if (!t) {
t = c.diffuse.clone();
t.uScale = ru; t.vScale = rv;
c.tiled.set(key, t);
}
return t;
}
/**
* Посчитать тайлинг (uScale/vScale) для studs по размеру меша. Чтобы кружки не
* растягивались: число кружков на грань = размер_грани / STUD_UNIT, делённое на
@ -180,6 +198,8 @@ export class PrimitiveManager {
rotationX, rotationY, rotationZ,
color, material, canCollide, visible, anchored, mass,
textureAsset, studDensity,
// Подпись над объектом (задача 10) — восстанавливается из project_data.
label: opts.label || null,
// locked — объект защищён от выделения/перемещения в редакторе
// (Фаза 5.11). На геймплей не влияет.
locked: opts.locked === true,
@ -519,22 +539,21 @@ export class PrimitiveManager {
// Лего-материал: почти белая diffuse-текстура с лёгкими кружками
// умножается на цвет меша → цвет остаётся СОЧНЫМ (как в Roblox).
// emissive = доля цвета → цвет «светится», не тускнеет в тени.
const tex = _getStudsTextures(this.scene);
const dt = tex.diffuse.clone();
const nt = tex.normal.clone();
// Объём студов запечён в diffuse v4 (baked-тени) — bumpTexture НЕ
// используем: normal-mapping удваивает стоимость шейдера на каждом
// меше и почти не виден на маленьких студах. Текстуры ШАРЯТСЯ
// (общая для cube, кэш-клон по тайлингу для форм) — без этого
// десятки клонов роняли FPS.
const dims = mesh._studsDims || { type: 'cube', sx: 1, sy: 1, sz: 1 };
// Куб/триггер тайлятся через faceUV геометрии (uScale=1) — кружки
// одного размера на всех гранях. Остальные формы — через uScale.
let dt;
if (dims.type === 'cube' || dims.type === 'trigger') {
dt.uScale = nt.uScale = 1;
dt.vScale = nt.vScale = 1;
// uScale=1 — тайлинг через faceUV геометрии. Общая текстура.
dt = _getStudsTextures(this.scene).diffuse;
} else {
const tile = _studsTiling(dims.type, dims.sx, dims.sy, dims.sz, dims.density);
dt.uScale = nt.uScale = tile.u;
dt.vScale = nt.vScale = tile.v;
dt = _getStudsTiledTexture(this.scene, tile.u, tile.v);
}
mat.diffuseTexture = dt;
mat.bumpTexture = nt;
const sc = Color3.FromHexString(color || '#cccccc');
mat.diffuseColor = sc;
// Сочность: подмешиваем цвет в emissive (45%) — Roblox-look,
@ -829,6 +848,14 @@ export class PrimitiveManager {
}
}
// Задача 10: подпись над объектом (label) — редактируется в инспекторе,
// сериализуется, при Play создаётся биндинг без скрипта.
// data.label = { enabled, binding:'static'|'timer'|'save'|'hp'|'formula',
// params:{...}, preset, height }.
if (patch.label !== undefined) {
data.label = patch.label || null;
}
this._notifyChange();
}
@ -917,6 +944,8 @@ export class PrimitiveManager {
...(d.textureAsset ? { textureAsset: d.textureAsset } : {}),
// Плотность studs (если не 1) — мелкие/крупные кружки.
...(d.studDensity && d.studDensity !== 1 ? { studDensity: d.studDensity } : {}),
// Подпись над объектом (задача 10) — если включена.
...(d.label && d.label.enabled ? { label: d.label } : {}),
// Параметры лампы (только для type='light', иначе undefined)
...(d.light ? { brightness: d.brightness, range: d.range } : {}),
// Параметр эмиттера (только для type='emitter')

View File

@ -272,6 +272,10 @@ const _tweenCallbacks = {};
// Тикаются в обработчике cmd='tick' по накоплению dt.
let _timers = [];
let _timerSeq = 0;
// Биндинги лейблов (задача 10): ref → { timerId, lastText }. bindLabel/bindTimer
// создают повторяющийся таймер, который зовёт fn() и шлёт setLabel при изменении
// текста. Автоотменяются при scene.delete(ref) (через instTouch/destroying).
const _labelBindings = new Map();
// Зеркало всех объектов сцены (заполняется main через cmd='sceneSnapshot' каждый раз
// когда что-то меняется). Это позволяет game.scene.find/all/get работать синхронно.
// { blocks: [{ref, type, x, y, z, name?}], models: [...], primitives: [...] }
@ -473,6 +477,13 @@ function _getOrCreateInstance(ref, kindHint) {
if (prop === 'removeTag') return (t) => _send('scene.untag', { ref, tag: t });
if (prop === 'tween') return (props, opts) => game.tween(ref, props, opts);
if (prop === 'move') return (x, y, z) => _send('scene.move', { ref, x, y, z });
// obj.rotate(ry) — поворот объекта вокруг Y (рад). Для примитивов И
// моделей (kind берётся из ref). Нужно вращающимся предметам витрины.
if (prop === 'rotate' || prop === 'rotateY') return (ry) => {
const colon = ref.indexOf(':');
if (colon < 0) return;
_send('scene.rotate', { ref, kind: ref.slice(0, colon), id: ref.slice(colon + 1), rotationY: Number(ry) || 0 });
};
if (prop === 'setLabel') return (text, o) => _send('scene.setLabel', { ref, text, opts: o || {} });
if (prop === 'clearLabel') return () => _send('scene.clearLabel', { ref });
@ -594,6 +605,13 @@ function _emitInstDestroying(ref) {
_instEvents.delete(ref);
_instCache.delete(ref);
_instLastValues.delete(ref);
// Задача 10: снять биндинг лейбла удалённого объекта (иначе таймер утечёт).
const b = _labelBindings.get(ref);
if (b) {
const i = _timers.findIndex(t => t.id === b.timerId);
if (i >= 0) _timers.splice(i, 1);
_labelBindings.delete(ref);
}
}
/**
@ -1679,8 +1697,74 @@ const game = {
clearLabel(ref) {
ref = _normRef(ref);
if (!ref) return;
this.unbindLabel(ref);
_send('scene.clearLabel', { ref });
},
/**
* Привязать плашку к функции текст обновляется автоматически раз в
* interval секунд (по умолчанию 0.5). setLabel шлётся только при
* ИЗМЕНЕНИИ текста (диф-чек). Биндинг автоотменяется при clearLabel/
* scene.delete(ref). Возвращает ref (для unbindLabel).
* game.scene.bindLabel(chest, () => game.save_cache.coins + ' монет',
* { preset: 'gameui', interval: 0.5 });
*/
bindLabel(ref, fn, opts) {
ref = _normRef(ref);
if (!ref || typeof fn !== 'function') return null;
opts = opts || {};
this.unbindLabel(ref); // снять прошлый биндинг этого объекта
const interval = Number.isFinite(opts.interval) && opts.interval > 0 ? opts.interval : 0.5;
const sceneApi = this;
const tick = () => {
let txt;
try { txt = String(fn() == null ? '' : fn()); }
catch (e) { return; }
const b = _labelBindings.get(ref);
if (b && b.lastText === txt) return; // диф-чек: текст не изменился
if (b) b.lastText = txt;
_send('scene.setLabel', { ref, text: txt, opts });
};
const id = ++_timerSeq;
_timers.push({ id, fn: tick, delay: interval, elapsed: interval, repeat: true });
_labelBindings.set(ref, { timerId: id, lastText: null });
tick(); // сразу первый рендер (не ждать interval)
return ref;
},
/** Снять биндинг плашки (остановить автообновление). */
unbindLabel(ref) {
ref = _normRef(ref);
if (!ref) return;
const b = _labelBindings.get(ref);
if (!b) return;
const i = _timers.findIndex(t => t.id === b.timerId);
if (i >= 0) _timers.splice(i, 1);
_labelBindings.delete(ref);
},
/**
* Плашка-таймер обратного отсчёта до endTs (мс epoch). Формат hh:mm:ss/
* mm:ss/auto. onEnd зовётся при достижении 0.
* game.scene.bindTimer(tower, Date.now()+16*60*1000,
* { format:'mm:ss', prefix:'Сбросится через ', preset:'gameui',
* onEnd: () => game.log('сброс!') });
*/
bindTimer(ref, endTs, opts) {
ref = _normRef(ref);
if (!ref) return null;
opts = opts || {};
const end = Number(endTs);
const fmt = opts.format || 'auto';
const prefix = opts.prefix || '';
const suffix = opts.suffix || '';
let ended = false;
return this.bindLabel(ref, () => {
const sec = Math.max(0, Math.floor((end - Date.now()) / 1000));
if (sec <= 0 && !ended) {
ended = true;
if (typeof opts.onEnd === 'function') { try { opts.onEnd(); } catch (e) {} }
}
return prefix + game.format.time(sec, fmt) + suffix;
}, { interval: opts.interval || 1, ...opts });
},
/**
* Прозрачность примитива: 1 = непрозрачно, 0 = полностью невидимо.
* Для плавного исчезновения используй game.tween(ref, {opacity: 0}, ...).
@ -3429,6 +3513,79 @@ const game = {
return a + Math.random() * (b - a);
},
/**
* Хелперы форматирования (задача 10) для счётчиков/таймеров в плашках.
* game.format.time(959, 'mm:ss') "15:59"
* game.format.time(57555, 'hh:mm:ss') "15:59:15"
* game.format.time(959, 'auto') "15м 59с"
* game.format.number(1234567, 'short') "1.2M"
* game.format.number(1234567, 'comma') "1 234 567"
* game.format.number(0.42, 'percent') "42%"
* game.format.money(199) "199 рубликов"
* game.format.duration(3600) "1 час"
*/
format: {
time(seconds, fmt) {
let sec = Math.max(0, Math.floor(Number(seconds) || 0));
const h = Math.floor(sec / 3600);
const m = Math.floor((sec % 3600) / 60);
const s = sec % 60;
const p2 = (n) => String(n).padStart(2, '0');
if (fmt === 'hh:mm:ss') return p2(h) + ':' + p2(m) + ':' + p2(s);
if (fmt === 'mm:ss') {
const tm = Math.floor(sec / 60);
return p2(tm) + ':' + p2(s);
}
// auto
if (h > 0) return h + 'ч ' + m + 'м';
if (m > 0) return m + 'м ' + s + 'с';
return s + 'с';
},
number(n, fmt) {
n = Number(n) || 0;
if (fmt === 'percent') return Math.round(n * 100) + '%';
if (fmt === 'short') {
const abs = Math.abs(n);
if (abs >= 1e9) return (n / 1e9).toFixed(1).replace('.0', '') + 'B';
if (abs >= 1e6) return (n / 1e6).toFixed(1).replace('.0', '') + 'M';
if (abs >= 1e3) return (n / 1e3).toFixed(1).replace('.0', '') + 'K';
return String(Math.round(n));
}
// comma — пробелы-разделители тысяч (русский стиль), без regex.
const str = String(Math.abs(Math.round(n)));
let out = '';
for (let i = 0; i < str.length; i++) {
if (i > 0 && (str.length - i) % 3 === 0) out += ' ';
out += str[i];
}
return (n < 0 ? '-' : '') + out;
},
money(amount, unit) {
const num = this.number(amount, 'comma');
const u = (unit === 'rubles' || unit === undefined)
? this._plural(Math.round(Number(amount) || 0), 'рублик', 'рублика', 'рубликов')
: unit;
return num + ' ' + u;
},
duration(seconds) {
let sec = Math.max(0, Math.floor(Number(seconds) || 0));
const h = Math.floor(sec / 3600);
const m = Math.floor((sec % 3600) / 60);
if (h > 0) return h + ' ' + this._plural(h, 'час', 'часа', 'часов');
if (m > 0) return m + ' ' + this._plural(m, 'минута', 'минуты', 'минут');
return sec + ' ' + this._plural(sec, 'секунда', 'секунды', 'секунд');
},
// Русское склонение числительных (1 рублик / 2 рублика / 5 рубликов).
_plural(n, one, few, many) {
n = Math.abs(n) % 100;
const n1 = n % 10;
if (n > 10 && n < 20) return many;
if (n1 > 1 && n1 < 5) return few;
if (n1 === 1) return one;
return many;
},
},
/**
* Расстояние между двумя точками или объектами.
* Аргументы могут быть {x,y,z} или ref-строкой (для ref берём позицию из sceneIndex).
@ -3971,6 +4128,7 @@ self.onmessage = (e) => {
} else if (cmd === 'stop') {
_tickHandlers = [];
_timers = [];
_labelBindings.clear();
_selfClickHandlers = [];
_selfTouchHandlers = [];
_selfUntouchHandlers = [];

View File

@ -128,6 +128,8 @@ export class SelectionManager {
range: data.range,
effect: data.effect,
textureAsset: data.textureAsset || null,
studDensity: data.studDensity || 1,
label: data.label || null, // подпись над объектом (задача 10)
locked: !!data.locked,
mesh: data.mesh,
rootMesh: data.mesh,