/** * 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} 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 (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; npc.hp = Math.max(0, npc.hp - (Number(amount) || 0)); if (npc.hp <= 0) this._killNpc(npc); } /** Удалить 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(); } }