player/src/engine/NpcManager.js
МИН 87444ee2c8 Initial public release: Rublox Player v1.0
Open-source web player for Rublox games, dual-licensed under
AGPL-3.0 + Commercial.

Highlights:
- Babylon.js 7 + React 18 + Vite 5 stack
- Self-contained engine (~46k lines): BlockManager, ModelManager,
  PlayerController, ScriptSandboxWorker, MultiplayerSync, 30+ GD
  gamemodes
- Configurable backend via VITE_API_BASE and friends — works against
  staging (dev-api.rublox.pro) out of the box
- Standalone mode (VITE_STANDALONE=true) loads a bundled sample game
  for first-run without any backend
- Full docs: README, ARCHITECTURE, CONTRIBUTING, SECURITY, CHANGELOG
- Lint + format scaffolding (ESLint + Prettier + EditorConfig)
- Legal: LICENSE (AGPL-3.0), LICENSE-COMMERCIAL.md, CLA.md, COPYRIGHT.md
- Issue templates: bug_report, feature_request, security_disclosure

Removed before public release:
- frontend_deploy.py (contained production SSH credentials)
- ~27 admin endpoints (kept in private repo)
- Hard-coded internal URLs and IPs
- All previous git history (clean repo init)
2026-05-27 23:04:04 +03:00

636 lines
26 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);
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;
}
// Регистрируем NPC как shadow caster (тень от него ложится на мир)
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;
}
/** Реплика над головой 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) {}
}
root.position.set(npc.x, npc.y, npc.z);
root.rotation.y = npc.yaw;
// data.x/y/z — чтобы scene.find/getPosition видели NPC.
data.x = npc.x; data.y = npc.y; data.z = npc.z;
// Анимация ходьбы — простое покачивание (без R15-скелета у Kenney).
if (moving) npc.walkPhase += dt * 6;
// R15-NPC (skin_*): процедурная анимация бега/покоя через R15Animator.
if (npc.r15Animator) {
try {
npc.r15Animator.setState(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();
}
}