game.fx.autoMobFloaters(true) — включает облачка урона над NPC при любой потере HP (NpcManager.damage). NpcManager.damageByMesh — оружие (бластер/меч) наносит урон скриптовым NPC (weapons.setOnHit → npcManager.damageByMesh). Связка: выстрел бластера → урон NPC → авто-floater «-N» над целью. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
673 lines
28 KiB
JavaScript
673 lines
28 KiB
JavaScript
/**
|
||
* 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);
|
||
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;
|
||
for (const npc of this.npcs.values()) {
|
||
if (npc.dead) continue;
|
||
const root = npc.data && npc.data.rootMesh;
|
||
if (!root) continue;
|
||
let m = mesh, hit = false;
|
||
for (let i = 0; i < 8 && m; i++) {
|
||
if (m === root) { hit = true; break; }
|
||
m = m.parent;
|
||
}
|
||
if (hit) { this.damage(npc.id, amount); return true; }
|
||
}
|
||
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 + 2.4, 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();
|
||
}
|
||
}
|