studio/src/editor/engine/NpcManager.js
min f7441b0bd6
Some checks failed
CI / Lint (push) Failing after 1m8s
CI / Build (push) Successful in 1m58s
CI / Secret scan (push) Successful in 23s
CI / PR size check (push) Has been skipped
CI / Deploy to S1 + S2 (push) Successful in 3m28s
feat: 50 ��� �� Lua + ������ Roblox ��� ���� + ������ ����
2026-06-09 21:59:19 +00:00

694 lines
29 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

/**
* NpcManager — управляемые скриптом персонажи (NPC) в Play-режиме.
*
* Отличие от ZombieManager: зомби — враги с фиксированным AI (wander/
* chase/attack), а NPC полностью под управлением скрипта:
* moveTo / follow / stop / say / setLabel / damage / onDeath.
*
* NPC = модель из ModelManager + state. AI простой (без worker'а — NPC
* на сцене десятки, не сотни): в main-thread tick двигаем к цели в
* плоскости XZ. Высоту Y держим на стартовом уровне (NPC ставятся на
* ровную площадку); если есть гладкий ландшафт — подгоняем к поверхности.
*
* Реплика над головой (say) — через billboard DynamicTexture-плашку,
* исчезает через несколько секунд. Имя — постоянная метка.
*/
import {
Vector3, MeshBuilder, StandardMaterial, Color3, TransformNode,
DynamicTexture, SceneLoader,
} from '@babylonjs/core';
import '@babylonjs/loaders/glTF';
import { R15Skeleton } from './R15Skeleton';
import { R15Animator } from './R15Animator';
const NPC_DEFAULTS = {
hp: 100,
speed: 2.6, // м/с при moveTo / follow
arriveDist: 0.6, // на каком расстоянии считаем «дошёл»
followGap: 2.5, // на каком расстоянии останавливаться при follow
};
let _npcIdSeq = 1;
export class NpcManager {
constructor(scene3d) {
this.scene3d = scene3d;
this.scene = scene3d.scene;
/** @type {Map<number, object>} npcId → state */
this.npcs = new Map();
this._renderHook = null;
this._lastTick = performance.now() / 1000;
// Колбэк смерти NPC — GameRuntime подписывается, шлёт в скрипты.
this._onNpcDeath = null;
}
setOnDeath(cb) { this._onNpcDeath = cb; }
start() {
if (this._renderHook) return;
this._renderHook = () => this._tick();
this.scene.registerBeforeRender(this._renderHook);
this._lastTick = performance.now() / 1000;
}
stop() {
if (this._renderHook) {
try { this.scene.unregisterBeforeRender(this._renderHook); } catch (e) {}
this._renderHook = null;
}
// Удаляем модели NPC и их UI.
for (const npc of this.npcs.values()) {
this._disposeNpcVisuals(npc);
this._disposeNpcModel(npc);
}
this.npcs.clear();
}
_disposeNpcVisuals(npc) {
const hb = npc.healthBar;
if (hb) {
try {
hb.anchor?.dispose();
hb.bg?.dispose();
hb.fill?.dispose();
hb.bgMat?.dispose();
hb.fillMat?.dispose();
} catch (e) { /* ignore */ }
}
const sb = npc.speechBubble;
if (sb) {
try {
sb.plane?.dispose();
sb.mat?.dispose();
sb.tex?.dispose();
} catch (e) { /* ignore */ }
}
}
/** Удалить 3D-модель NPC: и Kenney-инстанс, и R15-скин. */
_disposeNpcModel(npc) {
try {
if (npc.instanceId != null) {
// обычный NPC — инстанс ModelManager
this.scene3d.modelManager?.removeInstance(npc.instanceId);
} else if (npc.data && npc.data._isR15Npc) {
// R15-NPC — диспозим root-меш и AssetContainer
npc.data.rootMesh?.dispose(false, true);
try { npc.data._r15Container?.dispose(); } catch (e) {}
}
} catch (e) { /* ignore */ }
}
/**
* Создать NPC: спавнит модель и регистрирует state.
* Возвращает Promise<npcId> (async — модель грузится через GLB).
* opts: { x, y, z, rotationY, hp, name, speed }
*/
async spawnNpc(modelType, opts = {}) {
const mm = this.scene3d.modelManager;
if (!mm) return null;
const x = Number(opts.x) || 0;
const y = Number(opts.y) || 0;
const z = Number(opts.z) || 0;
const rotationY = Number(opts.rotationY) || 0;
// R15-скин ('skin_*') — отдельная ветка: грузим body.glb,
// строим R15Skeleton + R15Animator (процедурные анимации
// run/idle). Так NPC может быть полноценным R15-персонажем
// (полицейский в раннере и т.п.), а не статичной Kenney-моделью.
let instId, data, r15Animator = null;
if (typeof modelType === 'string' && modelType.startsWith('skin_')) {
const r15 = await this._spawnR15Skin(modelType, x, y, z, rotationY);
if (!r15) return null;
data = r15.data;
instId = null; // не из ModelManager
r15Animator = r15.animator;
} else {
try {
instId = await mm.addInstance(modelType, x, y, z, rotationY);
} catch (e) {
return null;
}
if (instId == null) return null;
data = mm.instances.get(instId);
}
if (data && opts.name) data.name = opts.name;
const id = _npcIdSeq++;
const hp = Number.isFinite(opts.hp) ? opts.hp : NPC_DEFAULTS.hp;
const npc = {
id,
instanceId: instId,
data,
hp,
maxHp: hp,
name: opts.name || ('NPC ' + id),
speed: Number.isFinite(opts.speed) ? opts.speed : NPC_DEFAULTS.speed,
// Режим: 'idle' | 'move' | 'follow'
mode: 'idle',
targetX: x, targetZ: z, // куда идём (для move)
followRef: null, // за кем следуем (для follow)
x, y, z, yaw: rotationY,
originY: y,
walkPhase: Math.random() * Math.PI * 2,
isMoving: false,
healthBar: this._createHealthBar(),
speechBubble: null,
speechUntil: 0,
dead: false,
// R15-аниматор (только для skin_*-NPC) — иначе null.
r15Animator,
};
this.npcs.set(id, npc);
// Пометить меши NPC для попаданий оружия (бластер/меч): pickable + npcId
// в metadata. Без pickable raycast оружия проходит сквозь NPC и урон/
// floater'ы не срабатывают (задача 40).
try {
const root = npc.data && npc.data.rootMesh;
if (root) {
root.isPickable = true;
root.metadata = Object.assign({}, root.metadata, { npcId: id });
for (const m of root.getChildMeshes(false)) {
m.isPickable = true;
m.metadata = Object.assign({}, m.metadata, { npcId: id });
}
}
} catch (e) { /* ignore */ }
return id;
}
/**
* Загрузить R15-скин как NPC-модель. Возвращает { data, animator }
* или null. data — объект-обёртка с rootMesh (как у ModelManager),
* чтобы остальной код NpcManager работал единообразно.
*/
async _spawnR15Skin(skinId, x, y, z, rotationY) {
// путь к body.glb скина
const file = `/kubikon-assets/characters/${skinId}/body.glb`;
const lastSlash = file.lastIndexOf('/');
const rootUrl = file.substring(0, lastSlash + 1);
const filename = file.substring(lastSlash + 1);
let container;
try {
container = await SceneLoader.LoadAssetContainerAsync(
rootUrl, filename, this.scene);
} catch (e) {
// eslint-disable-next-line no-console
console.error('[NpcManager] R15-скин не загружен:', skinId, e);
return null;
}
const root = new TransformNode('npcR15_' + skinId, this.scene);
// тот же масштаб что у игрока-R15 (модели нормализованы к 5.98)
const SC = 0.301;
root.scaling = new Vector3(SC, SC, SC);
root.position.set(x, y, z);
root.rotation.y = rotationY;
// ВАЖНО: R15-скин ('body.glb') ориентирован лицом в -Z. NpcManager
// двигает NPC в +Z с npc.yaw=atan2(dx,dz)=0 — для Kenney-моделей
// (лицом в +Z) это «вперёд». Чтобы R15-NPC тоже бежал лицом
// вперёд, ставим промежуточный узел, развёрнутый на π: тогда
// root.rotation.y работает в той же системе, что у Kenney.
const faceNode = new TransformNode('npcR15face_' + skinId, this.scene);
faceNode.parent = root;
faceNode.rotation.y = Math.PI;
const inst = container.instantiateModelsToScene(
(n) => `npc_${skinId}_${n}`, true, { doNotInstantiate: false });
for (const r of inst.rootNodes) r.parent = faceNode;
// глушим авто-играющие animationGroups (анимация — процедурная)
if (inst.animationGroups) {
for (const g of inst.animationGroups) { try { g.stop(); } catch (e) {} }
}
// строим R15-скелет/аниматор
let animator = null;
let sk = (inst.skeletons && inst.skeletons[0])
|| (container.skeletons && container.skeletons[0]) || null;
if (!sk) {
const m = root.getChildMeshes(false).find((mm2) => mm2.skeleton);
if (m) sk = m.skeleton;
}
if (sk) {
const r15 = new R15Skeleton(sk);
if (r15.isValidR15()) animator = new R15Animator(r15, {});
}
// меши NPC не должны ловить raycast игрока
const meshes = root.getChildMeshes(false);
for (const m of meshes) {
m.isPickable = false;
// Тени: NPC принимает тени и отбрасывает свою.
m.receiveShadows = true;
}
try {
if (this.scene3d && typeof this.scene3d.addShadowCaster === 'function') {
for (const m of meshes) this.scene3d.addShadowCaster(m);
}
} catch (e) { /* ignore */ }
// data-обёртка — совместима с тем, что ждёт NpcManager
return {
data: {
rootMesh: root, clonedMeshes: meshes,
x, y, z, rotationY,
_isR15Npc: true, _r15Container: container,
},
animator,
};
}
/** Изменить скорость NPC (м/с) на лету. */
setSpeed(id, speed) {
const npc = this.npcs.get(Number(id));
if (!npc || npc.dead) return;
const s = Number(speed);
if (Number.isFinite(s) && s > 0) npc.speed = s;
}
/** Приказать NPC идти в точку (XZ). */
moveTo(id, x, z) {
const npc = this.npcs.get(Number(id));
if (!npc || npc.dead) return;
npc.mode = 'move';
npc.targetX = Number(x) || 0;
npc.targetZ = Number(z) || 0;
npc.followRef = null;
}
/** Приказать NPC следовать за объектом/игроком (ref-строка или 'player'). */
follow(id, ref) {
const npc = this.npcs.get(Number(id));
if (!npc || npc.dead) return;
npc.mode = 'follow';
npc.followRef = typeof ref === 'string' ? ref : null;
}
/** Остановить NPC (перейти в idle). */
stopNpc(id) {
const npc = this.npcs.get(Number(id));
if (!npc) return;
npc.mode = 'idle';
npc.isMoving = false;
}
/** Включить/выключить анимацию атаки (R15-NPC машет руками). */
setAttacking(id, on) {
const npc = this.npcs.get(Number(id));
if (npc) npc.attacking = !!on;
}
/** Реплика над головой NPC на duration секунд. */
say(id, text, duration = 3) {
const npc = this.npcs.get(Number(id));
if (!npc || npc.dead) return;
this._setSpeech(npc, String(text == null ? '' : text));
npc.speechUntil = performance.now() / 1000 + (Number(duration) || 3);
}
/** Нанести урон NPC. При hp<=0 — смерть. */
damage(id, amount) {
const npc = this.npcs.get(Number(id));
if (!npc || npc.dead) return;
const amt = Number(amount) || 0;
npc.hp = Math.max(0, npc.hp - amt);
// Авто-floater над мобом (задача 40 доп): game.fx.autoMobFloaters(true).
if (this._autoFloater && amt > 0 && this.scene3d?.floaters) {
try {
this.scene3d.floaters.spawn(
{ x: npc.x, y: (npc.y || 0) + 2.2, z: npc.z }, amt, this._autoFloater.opts || {});
} catch (e) { /* ignore */ }
}
if (npc.hp <= 0) this._killNpc(npc);
}
/** Нанести урон NPC по мешу-попаданию (бластер/оружие). Ищет NPC, чьи меши
* содержат hit-меш (или предка). Вызывает damage() → авто-floater. */
damageByMesh(mesh, amount) {
if (!mesh) return false;
// 1) Быстрый путь: npcId в metadata меша (или предка).
let m = mesh;
for (let i = 0; i < 8 && m; i++) {
const nid = m.metadata && m.metadata.npcId;
if (nid != null && this.npcs.has(nid)) { this.damage(nid, amount); return true; }
m = m.parent;
}
// 2) Fallback: сравнение с rootMesh по иерархии.
for (const npc of this.npcs.values()) {
if (npc.dead) continue;
const root = npc.data && npc.data.rootMesh;
if (!root) continue;
let mm = mesh;
for (let i = 0; i < 8 && mm; i++) {
if (mm === root) { this.damage(npc.id, amount); return true; }
mm = mm.parent;
}
}
return false;
}
/** Удалить NPC по id (без эффекта смерти — просто убрать). */
removeNpc(id) {
const npc = this.npcs.get(Number(id));
if (!npc) return;
this._disposeNpcVisuals(npc);
this._disposeNpcModel(npc);
this.npcs.delete(npc.id);
}
_killNpc(npc) {
if (npc.dead) return;
npc.dead = true;
const pos = { x: npc.x, y: npc.y, z: npc.z };
// Уведомляем GameRuntime → скрипты (npc.onDeath).
if (this._onNpcDeath) {
try { this._onNpcDeath(npc.id, pos); } catch (e) { /* ignore */ }
}
// Прячем модель и UI, потом удаляем.
this._disposeNpcVisuals(npc);
this._disposeNpcModel(npc);
this.npcs.delete(npc.id);
}
/** Снимок всех NPC — для скрипт-воркеров (game.scene.npcs). */
getSnapshot() {
const out = [];
for (const npc of this.npcs.values()) {
out.push({
id: npc.id,
name: npc.name,
x: npc.x, y: npc.y, z: npc.z,
hp: npc.hp, maxHp: npc.maxHp,
mode: npc.mode,
});
}
return out;
}
// ===== внутреннее =====
_tick() {
const now = performance.now() / 1000;
const dt = Math.min(0.05, now - this._lastTick);
this._lastTick = now;
if (this.npcs.size === 0) return;
for (const npc of this.npcs.values()) {
if (npc.dead) continue;
this._tickNpc(npc, dt, now);
}
}
_tickNpc(npc, dt, now) {
const data = npc.data;
const root = data?.rootMesh;
if (!root) return;
// Определяем целевую точку движения.
let tx = null, tz = null;
if (npc.mode === 'move') {
tx = npc.targetX; tz = npc.targetZ;
} else if (npc.mode === 'follow' && npc.followRef) {
const fp = this._resolveRefPos(npc.followRef);
if (fp) { tx = fp.x; tz = fp.z; }
}
let moving = false;
if (tx != null) {
const dx = tx - npc.x;
const dz = tz - npc.z;
const dist = Math.hypot(dx, dz);
// Порог остановки: для follow — followGap, для move — arriveDist.
const stopDist = npc.mode === 'follow'
? NPC_DEFAULTS.followGap : NPC_DEFAULTS.arriveDist;
if (dist > stopDist) {
const step = Math.min(dist - stopDist, npc.speed * dt);
npc.x += (dx / dist) * step;
npc.z += (dz / dist) * step;
moving = true;
// Поворот лицом по направлению движения. Kenney-модели
// персонажей смотрят в +Z, поэтому без +π (иначе NPC
// идёт спиной вперёд).
const targetYaw = Math.atan2(dx, dz);
npc.yaw = this._lerpAngle(npc.yaw, targetYaw, dt * 8);
} else if (npc.mode === 'move') {
// Дошёл до точки move — переходим в idle.
npc.mode = 'idle';
}
}
npc.isMoving = moving;
// Высота: если есть гладкий ландшафт — подгоняем Y к поверхности,
// иначе держим стартовый уровень.
const surfY = this._sampleSurface(npc.x, npc.z);
npc.y = surfY != null ? surfY : npc.originY;
// Применяем к мешу.
if (root._isWorldMatrixFrozen) {
try { root.unfreezeWorldMatrix(); } catch (e) {}
}
// Анимация ходьбы — процедурное покачивание (у Kenney-моделей нет
// скелета). Подпрыгивание по Y + лёгкое раскачивание корпуса.
if (moving) npc.walkPhase += dt * 10;
let bobY = 0, lean = 0;
if (moving && !npc.r15Animator) {
bobY = Math.abs(Math.sin(npc.walkPhase)) * 0.12; // шаги вверх-вниз
lean = Math.sin(npc.walkPhase) * 0.08; // покачивание
}
root.position.set(npc.x, npc.y + bobY, npc.z);
root.rotation.y = npc.yaw;
root.rotation.z = lean;
// data.x/y/z — чтобы scene.find/getPosition видели NPC.
data.x = npc.x; data.y = npc.y; data.z = npc.z;
// R15-NPC (skin_*): процедурная анимация бега/покоя/атаки через R15Animator.
if (npc.r15Animator) {
try {
npc.r15Animator.setState(npc.attacking ? 'attack' : (moving ? 'run' : 'idle'));
npc.r15Animator.update(dt);
} catch (e) { /* ignore */ }
}
// Health-bar над головой при уроне.
const hb = npc.healthBar;
if (hb) {
const show = npc.hp < npc.maxHp;
hb.anchor.setEnabled(show);
if (show) {
hb.anchor.position.set(npc.x, npc.y + 1.9, npc.z);
const pct = Math.max(0, Math.min(1, npc.hp / npc.maxHp));
hb.fill.scaling.x = pct;
hb.fill.position.x = -(1 - pct) * hb.barWidth / 2;
hb.fillMat.emissiveColor.set(1 - pct * 0.6, 0.2 + pct * 0.7, 0.1);
}
}
// Реплика над головой — только авто-скрытие. Позиция держится
// через parent (plane закреплён на rootMesh при создании).
const sb = npc.speechBubble;
if (sb) {
sb.plane.setEnabled(now <= npc.speechUntil);
}
}
/** Позиция объекта по ref ('player' | 'model:N' | 'primitive:N' | 'block:x,y,z'). */
_resolveRefPos(ref) {
if (ref === 'player') {
const p = this.scene3d.player?._pos;
return p ? { x: p.x, y: p.y, z: p.z } : 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 [bx, by, bz] = rest.split(',').map(Number);
if ([bx, by, bz].every(Number.isFinite)) return { x: bx, y: by, z: bz };
return null;
}
const mgr = kind === 'primitive'
? this.scene3d.primitiveManager
: (kind === 'model' ? this.scene3d.modelManager : null);
if (!mgr || !mgr.instances) return null;
let d = mgr.instances.get(rest);
if (!d) {
const n = Number(rest);
if (Number.isFinite(n)) d = mgr.instances.get(n);
}
return d ? { x: d.x, y: d.y, z: d.z } : null;
}
/** Высота поверхности гладкого ландшафта в точке или null. */
_sampleSurface(x, z) {
const phys = this.scene3d.physics;
if (phys && typeof phys._sampleRobloxSurface === 'function') {
try {
const y = phys._sampleRobloxSurface(x, z);
if (y != null && Number.isFinite(y)) return y;
} catch (e) { /* ignore */ }
}
return null;
}
_lerpAngle(from, to, t) {
let diff = to - from;
while (diff > Math.PI) diff -= Math.PI * 2;
while (diff < -Math.PI) diff += Math.PI * 2;
return from + diff * Math.min(1, t);
}
_createHealthBar() {
const BAR_W = 1.4, BAR_H = 0.16;
const anchor = new TransformNode('npcHpAnchor', this.scene);
anchor.billboardMode = 7;
anchor.setEnabled(false);
const bg = MeshBuilder.CreatePlane('npcHpBg', { width: BAR_W, height: BAR_H }, this.scene);
const bgMat = new StandardMaterial('npcHpBgMat', this.scene);
bgMat.emissiveColor = new Color3(0.05, 0.05, 0.05);
bgMat.disableLighting = true;
bgMat.backFaceCulling = false;
bg.material = bgMat;
bg.isPickable = false;
bg.renderingGroupId = 1;
bg.parent = anchor;
bg.position.z = 0.001;
const fill = MeshBuilder.CreatePlane('npcHpFill', { width: BAR_W * 0.92, height: BAR_H * 0.7 }, this.scene);
const fillMat = new StandardMaterial('npcHpFillMat', this.scene);
fillMat.emissiveColor = new Color3(0.3, 0.85, 0.3);
fillMat.disableLighting = true;
fillMat.backFaceCulling = false;
fill.material = fillMat;
fill.isPickable = false;
fill.renderingGroupId = 1;
fill.parent = anchor;
return { anchor, bg, fill, bgMat, fillMat, barWidth: BAR_W * 0.92 };
}
/** Создать/обновить плашку-реплику над головой NPC. */
_setSpeech(npc, text) {
// Пересоздаём текстуру под новый текст (текст меняется редко).
if (npc.speechBubble) {
try {
npc.speechBubble.plane.dispose();
npc.speechBubble.mat.dispose();
npc.speechBubble.tex.dispose();
} catch (e) { /* ignore */ }
npc.speechBubble = null;
}
if (!text) return;
// Текст переносим по словам на несколько строк, плашка
// растягивается по высоте под количество строк.
const W = 1024;
const FONT_PX = 96;
const LINE_H = 130; // высота строки в пикселях canvas
const PAD_Y = 40; // вертикальные поля внутри пузыря
const MARGIN = 16; // отступ пузыря от края текстуры
// Шрифт нужен ДО разбивки — measureText зависит от font.
// Считаем на временном canvas.
const measureCanvas = document.createElement('canvas');
const mctx = measureCanvas.getContext('2d');
mctx.font = 'bold ' + FONT_PX + 'px sans-serif';
const maxTextW = W - 2 * MARGIN - 2 * PAD_Y;
const lines = this._wrapText(mctx, String(text), maxTextW);
// Высота canvas = поля + строки. Кратно 4 для текстуры.
let H = MARGIN * 2 + PAD_Y * 2 + lines.length * LINE_H;
H = Math.ceil(H / 4) * 4;
// 4-й аргумент true — как у LabelManager (там текст не перевёрнут).
const tex = new DynamicTexture('npcSpeechTex', { width: W, height: H }, this.scene, true);
const ctx = tex.getContext();
ctx.clearRect(0, 0, W, H);
// Пузырь — скруглённый прямоугольник на всю текстуру минус поля.
ctx.fillStyle = 'rgba(255,255,255,0.95)';
this._roundRect(ctx, MARGIN, MARGIN, W - 2 * MARGIN, H - 2 * MARGIN, 36);
ctx.fill();
// Строки текста по центру.
ctx.fillStyle = '#1a1a1a';
ctx.font = 'bold ' + FONT_PX + 'px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
const firstY = MARGIN + PAD_Y + LINE_H / 2;
for (let i = 0; i < lines.length; i++) {
ctx.fillText(lines[i], W / 2, firstY + i * LINE_H);
}
tex.update(true); // invertY=true — как у LabelManager
// Размер плашки в мире — ширина фикс, высота по соотношению сторон.
const planeW = 4.4;
const planeH = planeW * (H / W);
const plane = MeshBuilder.CreatePlane('npcSpeech',
{ width: planeW, height: planeH }, this.scene);
plane.billboardMode = 7;
plane.isPickable = false;
plane.renderingGroupId = 1;
const mat = new StandardMaterial('npcSpeechMat', this.scene);
mat.diffuseTexture = tex;
mat.diffuseTexture.hasAlpha = true;
mat.emissiveColor = new Color3(1, 1, 1);
mat.disableLighting = true;
mat.backFaceCulling = false;
mat.useAlphaFromDiffuseTexture = true;
mat.disableDepthWrite = true;
plane.material = mat;
// Точно как LabelManager (там текст не перевёрнут): плоскость
// крепится parent'ом к мешу NPC и висит над ним. Билборд
// разворачивает её к камере без зеркальности — потому что
// плоскость наследует worldMatrix меша, а не висит сама по себе.
const root = npc.data && npc.data.rootMesh;
if (root) {
plane.parent = root;
plane.position.set(0, 3.0, 0);
}
plane.setEnabled(false);
npc.speechBubble = { plane, mat, tex };
}
/**
* Разбить текст на строки по словам так, чтобы каждая влезала в maxW.
* Слишком длинное одиночное слово режется посимвольно.
* Возвращает массив строк (минимум одна).
*/
_wrapText(ctx, text, maxW) {
const words = text.split(/\s+/).filter(Boolean);
const lines = [];
let cur = '';
const pushChunked = (word) => {
// Слово длиннее строки — режем по символам.
let part = '';
for (const ch of word) {
if (ctx.measureText(part + ch).width > maxW && part) {
lines.push(part);
part = ch;
} else {
part += ch;
}
}
return part;
};
for (const w of words) {
const test = cur ? cur + ' ' + w : w;
if (ctx.measureText(test).width <= maxW) {
cur = test;
} else {
if (cur) lines.push(cur);
if (ctx.measureText(w).width > maxW) {
cur = pushChunked(w);
} else {
cur = w;
}
}
}
if (cur) lines.push(cur);
return lines.length > 0 ? lines : [''];
}
_roundRect(ctx, x, y, w, h, r) {
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();
}
}