player/src/engine/ZombieManager.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

1018 lines
46 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.

/**
* ZombieManager — управление зомби-врагами в Play-режиме.
*
* Каждый зомби — экземпляр Kenney character-модели (берётся из existing
* scene модели, помеченной metadata.isZombie=true, или спавнится из manager).
*
* AI:
* - WANDER: бродит к случайной точке в радиусе 8м, потом стоит 1-3с.
* - CHASE: если игрок ближе DETECTION_RADIUS — идёт к нему.
* - ATTACK: если игрок ближе ATTACK_RANGE — наносит DAMAGE раз в ATTACK_COOLDOWN.
*
* Анимации: idle / walk используются из GLB, плюс мы поднимаем руки зомби
* через override-rotation на мешах рук (как для оружия) — «зомби-поза».
*/
import { Vector3, MeshBuilder, StandardMaterial, Color3, TransformNode } from '@babylonjs/core';
const DEFAULTS = {
hp: 50,
speed: 2.2, // м/с в режиме CHASE
wanderSpeed: 1.3, // м/с при WANDER (живее ходят)
detectionRadius: 16,
attackRange: 1.8,
attackDamage: 10,
attackCooldown: 1.0,
wanderRadius: 14, // длиннее отрезки — реже паузы
};
let _zombieIdSeq = 1;
// Worker-tick interval — раз в 50мс шлём playerPos и получаем target позиции.
// Между тиками главный поток lerp-ит позиции/yaw — движение остаётся плавным.
const WORKER_TICK_MS = 50;
export class ZombieManager {
constructor(scene3d) {
this.scene3d = scene3d;
this.scene = scene3d.scene;
/** @type {Map<number, object>} id → zombie state */
this.zombies = new Map();
this._renderHook = null;
this._lastTickTime = performance.now() / 1000;
this._onZombieDeath = null;
// Карта instanceId → zombie (быстрый поиск при попадании пули)
this._byInstanceId = new Map();
// === Web Worker для AI ===
// Worker ОПЦИОНАЛЕН: если creation falls — используем main-thread
// fallback (старый код). Главное чтобы при этом игра не сломалась.
this._worker = null;
this._workerReady = false;
this._workerLastTick = 0;
// Целевые позиции от worker'а: id → {x, y, z, yaw, state, isMoving, walkPhase}
this._aiTargets = new Map();
}
setOnDeath(cb) { this._onZombieDeath = cb; }
start() {
if (this._renderHook) return;
this._renderHook = () => this._tick();
this.scene.registerBeforeRender(this._renderHook);
this._lastTickTime = performance.now() / 1000;
// Создаём worker. Если не получилось — работаем в main-thread.
this._initWorker();
}
_initWorker() {
try {
// Webpack 5 / CRA 5+ поддерживают new URL() для worker'ов.
// import.meta заменяется webpack'ом на правильный URL чанка.
// eslint-disable-next-line no-undef
this._worker = new Worker(new URL('./ZombieAIWorker.js', import.meta.url));
this._worker.onmessage = (e) => this._onWorkerMessage(e.data);
this._worker.onerror = (err) => {
// eslint-disable-next-line no-console
console.warn('[ZombieManager] worker error:', err);
try { this._worker.terminate(); } catch (e) {}
this._worker = null;
this._workerReady = false;
};
// Init: surface map + worldHalf
this._sendSurfaceMap();
this._workerReady = true;
// eslint-disable-next-line no-console
console.log('[ZombieManager] AI worker started');
} catch (e) {
// eslint-disable-next-line no-console
console.warn('[ZombieManager] worker creation failed, fallback to main thread:', e);
this._worker = null;
this._workerReady = false;
}
}
/**
* Построить карту высоты поверхности и отправить в worker.
*
* Источники:
* 1. БЛОКИ — топ-блок в каждой целой клетке (gx,gz). Шаг 1.
* 2. ГЛАДКИЙ ЛАНДШАФТ (RobloxTerrain) — raycast по реальному мешу
* с шагом surfaceStep=2 (130K лучей на всю карту слишком дорого).
* Worker интерполирует между узлами сетки шага 2.
*
* Блоки ИМЕЮТ ПРИОРИТЕТ над ландшафтом (платформы замка/лагерей).
*/
_sendSurfaceMap() {
if (!this._worker) return;
const surfaceMap = {};
const worldHalf = this.scene3d?._worldHalf ?? 40;
// 1. Гладкий ландшафт — сначала, чтобы блоки могли перезаписать.
const SURFACE_STEP = 2;
const phys = this.scene3d?.physics;
if (phys && typeof phys._sampleRobloxSurface === 'function'
&& this.scene3d?._robloxTerrain?.grid) {
const t0 = performance.now();
let hits = 0;
for (let gx = -worldHalf; gx <= worldHalf; gx += SURFACE_STEP) {
for (let gz = -worldHalf; gz <= worldHalf; gz += SURFACE_STEP) {
const y = phys._sampleRobloxSurface(gx, gz);
if (y !== null && y !== undefined) {
surfaceMap[gx * 65537 + gz] = y;
hits++;
}
}
}
console.log(`[ZombieManager] surfaceMap: гладкий ландшафт ${hits} узлов (шаг ${SURFACE_STEP}м, ${(performance.now() - t0).toFixed(0)}мс)`);
}
// 2. Блоки — поверх ландшафта (топ-блок в каждой целой клетке).
const bm = this.scene3d?.blockManager;
if (bm && bm.blocks) {
for (const [key, mesh] of bm.blocks) {
if (mesh?.metadata?.isWater) continue;
const parts = key.split(',');
const gx = parseInt(parts[0], 10);
const gy = parseInt(parts[1], 10);
const gz = parseInt(parts[2], 10);
const k = gx * 65537 + gz;
const cur = surfaceMap[k];
const top = gy + 1;
if (cur === undefined || top > cur) surfaceMap[k] = top;
}
}
this._worker.postMessage({
type: 'init', surfaceMap, worldHalf, surfaceStep: SURFACE_STEP,
});
}
_onWorkerMessage(msg) {
if (msg.type !== 'state') return;
// Запоминаем target-позиции для каждого зомби — render-loop их lerp-ит.
for (const z of msg.zombies) {
this._aiTargets.set(z.id, z);
}
// События — атаки игрока, можем озвучить, и т.д.
if (msg.events && msg.events.length) {
for (const ev of msg.events) {
if (ev.type === 'attack') {
// Доп. защита от «урон от невидимого зомби»: пропускаем
// атаку если меш зомби сейчас скрыт (setEnabled=false из-за
// distance-cull или провала под террейн). Также проверяем
// 3D-расстояние до игрока — если зомби на y=0 а игрок на
// y=10 (классический баг GLB-террейна, см. _spawnAt /
// surfaceMap), фактически он не дотягивается.
let canHit = true;
if (ev.id != null) {
const z = this.zombies.get(ev.id);
if (z) {
if (z.data?.rootMesh && z.data.rootMesh.isEnabled?.() === false) {
canHit = false;
} else if (z.data && this.scene3d?.player?._pos) {
const p = this.scene3d.player._pos;
const dx = p.x - z.data.x;
const dy = p.y - z.data.y;
const dz = p.z - z.data.z;
const dist3d = Math.sqrt(dx * dx + dy * dy + dz * dz);
// Порог 4 (был 3): игрок может стоять НА голове
// зомби (dy~2) — dist3d тогда до ~3.9, но атака
// легитимна (зомби топчут). Защита от провала
// под террейн (dy огромный) всё равно работает.
if (dist3d > 4) canHit = false;
}
}
}
if (canHit) {
try { this.scene3d.player?.takeDamage?.(ev.damage, 'zombie'); } catch (e) {}
}
}
}
}
}
stop() {
if (this._renderHook) {
this.scene.unregisterBeforeRender(this._renderHook);
this._renderHook = null;
}
// Возвращаем ЖИВЫХ ручных зомби на исходные позиции.
for (const z of this.zombies.values()) {
if (z.spawnerId == null && z.data) {
z.data.x = z.originX;
z.data.y = z.originY;
z.data.z = z.originZ;
z.data.rotationY = z.originYaw;
if (z.data.rootMesh) {
z.data.rootMesh.position.set(z.originX, z.originY, z.originZ);
z.data.rootMesh.rotation.y = z.originYaw;
z.data.rootMesh.setEnabled(true);
}
this._resetZombiePose(z.data);
}
try {
z.healthBar?.anchor?.dispose();
z.healthBar?.bg?.dispose();
z.healthBar?.fill?.dispose();
} catch (e) {}
}
// Также нужно вернуть УБИТЫХ ручных зомби (они скрыты, но в ModelManager).
// Их `data` есть в modelManager.instances, но они не в this.zombies.
// Прохожусь по всем моделям и для убитых (скрытых) с gameplay.isZombie
// возвращаем их видимость и позицию.
const mm = this.scene3d?.modelManager;
if (mm) {
for (const data of mm.instances.values()) {
if (data._spawnedAtRuntime) continue;
if (!data.gameplay?.isZombie) continue;
if (data.rootMesh && !data.rootMesh.isEnabled()) {
// Возвращаем — позицию мы не сохраняли отдельно, но их
// origin = текущая data.x/y/z (т.к. мёртвые не двигались)
data.rootMesh.setEnabled(true);
data.rootMesh.position.set(data.x, data.y, data.z);
this._resetZombiePose(data);
}
}
}
this.zombies.clear();
this._byInstanceId.clear();
this._aiTargets.clear();
// Останавливаем worker — он больше не нужен в редакторе.
if (this._worker) {
try { this._worker.terminate(); } catch (e) {}
this._worker = null;
this._workerReady = false;
}
// Чистим debris если остались
if (this._debris) {
for (const d of this._debris) {
try { d.mesh.dispose(); d.mat.dispose(); } catch (e) {}
}
this._debris = [];
}
}
/** Сбросить override-rotation на меше зомби (вернуть к стандартной анимации). */
_resetZombiePose(data) {
const root = data?.rootMesh;
if (!root || !root.getChildMeshes) return;
for (const m of root.getChildMeshes(false)) {
const n = (m.name || '').toLowerCase();
if (n.includes('arm') || n.includes('leg') || n.includes('torso')) {
if (m.rotationQuaternion) m.rotationQuaternion = null;
m.rotation.x = 0;
m.rotation.y = 0;
m.rotation.z = 0;
}
}
}
/**
* Зарегистрировать существующую модель сцены как зомби.
* Используется для зомби, заранее расставленных в редакторе с
* metadata.isZombie=true.
*/
registerExisting(modelInstanceId, options = {}) {
const mm = this.scene3d.modelManager;
const data = mm?.instances?.get(modelInstanceId);
if (!data) return null;
const id = _zombieIdSeq++;
const opts = { ...DEFAULTS, ...options };
const z = {
id,
instanceId: modelInstanceId,
data,
hp: opts.hp,
maxHp: opts.hp,
opts,
state: 'wander',
stateTime: 0,
idlePauseUntil: null,
wanderTarget: this._pickWanderTarget(data.x, data.z, opts.wanderRadius),
lastAttackTime: 0,
spawnerId: options.spawnerId ?? null,
yaw: data.rootMesh?.rotation?.y ?? 0,
walkPhase: Math.random() * Math.PI * 2,
isMoving: false,
vy: 0,
onGround: false,
healthBar: this._createHealthBar(),
// Исходная позиция — для возврата при exitPlayMode (только для
// вручную размещённых зомби; спавнерные удаляются целиком).
originX: data.x,
originY: data.y,
originZ: data.z,
originYaw: data.rotationY,
};
this.zombies.set(id, z);
this._byInstanceId.set(modelInstanceId, z);
// Регистрируем зомби в worker'е (если он есть)
if (this._worker) {
this._worker.postMessage({
type: 'addZombie',
id, x: data.x, y: data.y, z: data.z,
opts: { ...opts },
});
}
return id;
}
/**
* Снимок всех живых зомби — для отправки в скрипт-воркеры.
* Возвращает массив {id, mobType, x, y, z, hp}.
*/
getMobsSnapshot() {
const out = [];
for (const z of this.zombies.values()) {
const d = z.data;
if (!d) continue;
out.push({
id: z.id,
mobType: 'zombie',
x: d.x, y: d.y, z: d.z,
hp: z.hp,
});
}
return out;
}
/**
* Убить зомби по id. Используется скриптами для «зомби достиг цели — удалить».
* Срабатывает как обычная смерть (с эффектами + onMobKilled).
*/
killById(id) {
const z = this.zombies.get(Number(id));
if (!z) return false;
this._killZombie(z);
return true;
}
/** Нанести урон зомби (вызывается из логики попадания пули). */
damageByMesh(mesh, amount) {
// Ищем зомби, к которому принадлежит этот меш
let target = null;
for (const z of this.zombies.values()) {
const root = z.data?.rootMesh;
if (!root) continue;
if (mesh === root) { target = z; break; }
// Проверяем потомков
if (root.getChildMeshes) {
const children = root.getChildMeshes(false);
if (children.includes(mesh)) { target = z; break; }
}
}
if (!target) return false;
target.hp = Math.max(0, target.hp - amount);
if (target.hp <= 0) {
this._killZombie(target);
}
return true;
}
_killZombie(z) {
// Эффект распада
this._spawnDeathDebris(z.data);
// Сообщаем GameRuntime для скриптов через onMobKilled
const pos = z.data ? { x: z.data.x, y: z.data.y, z: z.data.z } : { x: 0, y: 0, z: 0 };
try {
this.scene3d?.gameRuntime?.notifyMobKilled?.('zombie', pos);
} catch (e) {}
if (z.spawnerId != null) {
// Спавнерный зомби — удаляем целиком (он временный)
this.scene3d.modelManager?.removeInstance(z.instanceId);
} else {
// Ручной зомби — НЕ удаляем модель, только прячем.
// При Stop он вернётся на исходное место.
if (z.data?.rootMesh) z.data.rootMesh.setEnabled(false);
}
this._byInstanceId.delete(z.instanceId);
this.zombies.delete(z.id);
this._aiTargets.delete(z.id);
// Уведомляем worker — больше не тикать этого зомби.
if (this._worker) {
try { this._worker.postMessage({ type: 'removeZombie', id: z.id }); } catch (e) {}
}
if (z.healthBar) {
try {
z.healthBar.anchor?.dispose();
z.healthBar.bg?.dispose();
z.healthBar.fill?.dispose();
} catch (e) {}
}
if (this._onZombieDeath) {
try { this._onZombieDeath(z); } catch (e) {}
}
}
/**
* Эффект «распада на куски» при смерти — Roblox-style.
* Спавним 6-8 цветных кубов с физикой (физики у нас простой нет, поэтому
* через ручную анимацию в _tick).
*/
_spawnDeathDebris(data) {
if (!data) return;
const colors = [
new Color3(0.7, 0.55, 0.4), // тело/кожа
new Color3(0.9, 0.7, 0.5),
new Color3(0.4, 0.3, 0.2), // одежда
new Color3(0.5, 0.4, 0.3),
];
const debris = [];
const cx = data.x, cy = data.y + 1.0, cz = data.z;
const count = 8;
for (let i = 0; i < count; i++) {
const size = 0.18 + Math.random() * 0.12;
const cube = MeshBuilder.CreateBox(`debris_${i}`, { size }, this.scene);
const mat = new StandardMaterial(`debrisMat_${i}`, this.scene);
mat.diffuseColor = colors[i % colors.length];
mat.specularColor = new Color3(0, 0, 0);
cube.material = mat;
cube.position.set(cx + (Math.random() - 0.5) * 0.5, cy + Math.random() * 0.5, cz + (Math.random() - 0.5) * 0.5);
cube.rotation.set(Math.random() * Math.PI, Math.random() * Math.PI, Math.random() * Math.PI);
cube.isPickable = false;
cube.alwaysSelectAsActiveMesh = true;
debris.push({
mesh: cube,
mat,
vx: (Math.random() - 0.5) * 4,
vy: 3 + Math.random() * 3,
vz: (Math.random() - 0.5) * 4,
rx: (Math.random() - 0.5) * 8,
ry: (Math.random() - 0.5) * 8,
rz: (Math.random() - 0.5) * 8,
age: 0,
life: 1.5,
});
}
if (!this._debris) this._debris = [];
this._debris.push(...debris);
}
/** Обновить физику и фейд кусков debris. Вызывается каждый тик. */
_tickDebris(dt) {
if (!this._debris || this._debris.length === 0) return;
const G = -9.8;
const next = [];
for (const d of this._debris) {
d.age += dt;
if (d.age >= d.life) {
try { d.mesh.dispose(); d.mat.dispose(); } catch (e) {}
continue;
}
d.vy += G * dt;
d.mesh.position.x += d.vx * dt;
d.mesh.position.y += d.vy * dt;
d.mesh.position.z += d.vz * dt;
// Простой пол — y=0
if (d.mesh.position.y < 0.05) {
d.mesh.position.y = 0.05;
d.vy *= -0.4;
d.vx *= 0.6;
d.vz *= 0.6;
}
d.mesh.rotation.x += d.rx * dt;
d.mesh.rotation.y += d.ry * dt;
d.mesh.rotation.z += d.rz * dt;
// Фейд в последние 0.5с
const fadeStart = d.life - 0.5;
if (d.age > fadeStart) {
const k = 1 - (d.age - fadeStart) / 0.5;
d.mesh.visibility = Math.max(0, k);
}
next.push(d);
}
this._debris = next;
}
_pickWanderTarget(cx, cz, radius) {
// Если зомби близко к краю карты — даём bias к центру, чтобы цель не была
// зажата clamp'ом в саму границу (тогда зомби «упирается в стену»).
const half = (this.scene3d?._worldHalf ?? 40) - 1.5;
const distToEdge = Math.min(half - Math.abs(cx), half - Math.abs(cz));
let bias = 0;
if (distToEdge < radius) {
// Угол смещаем к центру: вектор (-cx, -cz) нормализован
bias = Math.atan2(-cx, -cz);
}
const a = (bias + (Math.random() - 0.5) * Math.PI); // случайно ±90° от bias
const r = (radius * 0.5) + Math.random() * (radius * 0.5); // не слишком близко
let tx = cx + Math.cos(a) * r;
let tz = cz + Math.sin(a) * r;
if (tx > half) tx = half;
if (tx < -half) tx = -half;
if (tz > half) tz = half;
if (tz < -half) tz = -half;
return new Vector3(tx, 0, tz);
}
/**
* Хелсбар над зомби: TransformNode-якорь (билборд) + два plane'а внутри.
* Заливка слева направо: pivot fill сдвинут к левому краю чтобы scaling.x
* масштабировал от левого края, а не от центра.
*/
_createHealthBar() {
const BAR_W = 1.4, BAR_H = 0.16;
const anchor = new TransformNode('zhpAnchor', this.scene);
anchor.billboardMode = 7;
anchor.setEnabled(false);
const bg = MeshBuilder.CreatePlane('zhpBg', { width: BAR_W, height: BAR_H }, this.scene);
const bgMat = new StandardMaterial('zhpBgMat', 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; // чуть позади fill
const fill = MeshBuilder.CreatePlane('zhpFill', { width: BAR_W * 0.92, height: BAR_H * 0.7 }, this.scene);
const fillMat = new StandardMaterial('zhpFillMat', this.scene);
fillMat.emissiveColor = new Color3(0.95, 0.2, 0.2);
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,
};
}
_tick() {
const now = performance.now() / 1000;
const dt = Math.min(0.05, now - this._lastTickTime);
this._lastTickTime = now;
// Куски разлетающихся зомби
this._tickDebris(dt);
if (this.zombies.size === 0) return;
const player = this.scene3d.player;
const playerPos = player?._pos;
if (!playerPos) return;
const nowMs = now * 1000;
if (this._workerReady && this._worker) {
// === WORKER-РЕЖИМ: AI считается в worker'е, главный поток lerp-ит. ===
// Раз в WORKER_TICK_MS отправляем в worker playerPos.
if (nowMs - this._workerLastTick >= WORKER_TICK_MS) {
const workerDt = (nowMs - this._workerLastTick) / 1000;
this._workerLastTick = nowMs;
this._worker.postMessage({
type: 'tick',
playerX: playerPos.x,
playerY: playerPos.y,
playerZ: playerPos.z,
now,
dt: Math.min(0.5, workerDt || WORKER_TICK_MS / 1000),
});
}
// Каждый кадр: lerp mesh.position и yaw к target от worker'а,
// обновляем pose и health-bar — это работа главного потока (рендер).
for (const z of this.zombies.values()) {
this._renderZombie(z, dt, playerPos);
}
return;
}
// === FALLBACK (без worker'а): старая логика с adaptive tick rate. ===
const NEAR_DIST_SQ = 20 * 20;
const MID_DIST_SQ = 60 * 60;
for (const z of this.zombies.values()) {
const data = z.data;
if (!data) continue;
const dx = playerPos.x - (data.x || 0);
const dz = playerPos.z - (data.z || 0);
const distSq = dx * dx + dz * dz;
let interval;
if (distSq < NEAR_DIST_SQ) interval = 0;
else if (distSq < MID_DIST_SQ) interval = 100;
else interval = 500;
if (z._lastAiTickMs == null) z._lastAiTickMs = 0;
if (interval > 0 && nowMs - z._lastAiTickMs < interval) continue;
const zDt = z._lastAiTickMs > 0
? Math.min(0.5, (nowMs - z._lastAiTickMs) / 1000)
: dt;
z._lastAiTickMs = nowMs;
this._tickZombie(z, zDt, playerPos, now);
}
}
/**
* Render-only обработка одного зомби в worker-режиме. AI/FSM/физика
* считаются в worker'е, здесь только: lerp позиции к target, поворот,
* apply pose, обновление health-bar, видимость.
*/
_renderZombie(z, dt, playerPos) {
const target = this._aiTargets.get(z.id);
const data = z.data;
if (!data || !data.rootMesh) return;
// Lerp позиции к target от worker'а. Если target ещё не пришёл —
// используем data.x/y/z как есть.
if (target) {
const prevX = data.x, prevZ = data.z;
const LERP = Math.min(1, dt * 16);
data.x += (target.x - data.x) * LERP;
data.y += (target.y - data.y) * LERP;
data.z += (target.z - data.z) * LERP;
z.state = target.state;
// isMoving детектим по реальному движению mesh'а в этом кадре,
// а не по флагу из worker'а — так анимация ходьбы работает
// даже если worker задерживается.
const moveDxz = Math.hypot(data.x - prevX, data.z - prevZ);
z.isMoving = moveDxz > 0.001;
// Yaw: если двигаемся — поворачиваемся по направлению реального
// движения mesh'а в мире (forward = -Z локально, поэтому +π).
if (z.isMoving) {
const dirX = data.x - prevX;
const dirZ = data.z - prevZ;
const targetYaw = Math.atan2(dirX, dirZ) + Math.PI;
z.yaw = this._lerpAngle(z.yaw, targetYaw, dt * 8);
} else if (z.state === 'attack') {
// В режиме атаки лицом к игроку
const dxp = playerPos.x - data.x;
const dzp = playerPos.z - data.z;
const targetYaw = Math.atan2(dxp, dzp) + Math.PI;
z.yaw = this._lerpAngle(z.yaw, targetYaw, dt * 5);
}
// Walk phase каждый кадр, не из worker (он отстаёт на 50мс).
if (z.walkPhase == null) z.walkPhase = 0;
if (z.isMoving) {
const stepFreq = (z.state === 'chase') ? 8 : 5;
z.walkPhase += dt * stepFreq;
}
}
// Distance-cull: считаем дистанцию до игрока в main thread.
// Видимость не зависит от worker — он мог не успеть прислать target.
const dx = playerPos.x - data.x;
const dz = playerPos.z - data.z;
const distToPlayer = Math.sqrt(dx * dx + dz * dz);
const wantVisible = distToPlayer < 80;
if (z._lastVisible !== wantVisible) {
z._lastVisible = wantVisible;
try { data.rootMesh.setEnabled(wantVisible); } catch (e) {}
}
if (!wantVisible) return;
const root = data.rootMesh;
// Защита: если меш зомби кем-то заморожен (старая freezeStaticModels,
// LOD freeze) — раз-замораживаем. Без этого position.set() ничего
// визуально не меняет: меш стоит а хитбокс двигается → стреляешь в
// пустоту, ловишь попадание, полоска жизней в правильном месте.
if (root._isWorldMatrixFrozen) {
try { root.unfreezeWorldMatrix(); } catch (e) {}
}
root.position.set(data.x, data.y, data.z);
root.rotation.y = z.yaw;
// Pose update — только близкие зомби (детали не видно издалека).
if (distToPlayer < 30) {
this._applyZombiePose(data, z);
}
// Health-bar
const hb = z.healthBar;
if (hb) {
const showBar = z.hp < z.maxHp;
hb.anchor.setEnabled(showBar);
if (showBar) {
hb.anchor.position.set(data.x, data.y + 2.2, data.z);
const pct = Math.max(0, Math.min(1, z.hp / z.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
);
}
}
}
_tickZombie(z, dt, playerPos, now) {
const data = z.data;
const root = data?.rootMesh;
if (!root) return;
const dx = playerPos.x - data.x;
const dz = playerPos.z - data.z;
const distToPlayer = Math.sqrt(dx * dx + dz * dz);
// LOD: скрываем зомби дальше 80м — игрок их всё равно не видит,
// а Babylon продолжает рисовать их меши и материалы.
const HIDE_DIST = 80;
const wantVisible = distToPlayer < HIDE_DIST;
if (z._lastVisible !== wantVisible) {
z._lastVisible = wantVisible;
try { root.setEnabled(wantVisible); } catch (e) {}
}
if (!wantVisible) return; // дальше тикать не нужно
// FSM
if (distToPlayer < z.opts.attackRange) {
z.state = 'attack';
} else if (distToPlayer < z.opts.detectionRadius) {
z.state = 'chase';
} else if (z.state !== 'wander') {
z.state = 'wander';
z.wanderTarget = this._pickWanderTarget(data.x, data.z, z.opts.wanderRadius);
z.stateTime = 0;
z.idlePauseUntil = null;
}
let moveDir = null;
let speed = 0;
if (z.state === 'attack') {
// Стоит и бьёт
if (now - z.lastAttackTime > z.opts.attackCooldown) {
z.lastAttackTime = now;
this.scene3d.player?.takeDamage?.(z.opts.attackDamage, 'zombie');
}
// Поворачиваемся к игроку (+π потому что модель смотрит в -Z)
const targetYaw = Math.atan2(dx, dz) + Math.PI;
z.yaw = this._lerpAngle(z.yaw, targetYaw, dt * 5);
} else if (z.state === 'chase') {
const len = Math.max(0.001, distToPlayer);
moveDir = { x: dx / len, z: dz / len };
speed = z.opts.speed;
} else {
// wander: «походили — постояли — снова пошли».
// Состояния хранятся в z.idlePauseUntil:
// null → активно идём к z.wanderTarget
// number → стоим до момента когда z.stateTime достигнет idlePauseUntil
//
// КРИТИЧНО: stateTime сбрасываем при смене фазы (idle→walk и walk→idle),
// иначе он растёт бесконечно и условие walk_timeout ниже срабатывает сразу
// после старта движения — зомби «застывает».
z.stateTime += dt;
if (z.idlePauseUntil != null) {
// === Фаза idle — стоим ===
if (z.stateTime >= z.idlePauseUntil) {
// Пауза закончилась → переходим к ходьбе, выбираем новую цель
z.idlePauseUntil = null;
z.stateTime = 0;
z.wanderTarget = this._pickWanderTarget(
data.x, data.z, z.opts.wanderRadius);
}
// moveDir остаётся null — стоим на месте
} else {
// === Фаза walk — идём к wanderTarget ===
const tx = z.wanderTarget.x - data.x;
const tz = z.wanderTarget.z - data.z;
const tdist = Math.sqrt(tx * tx + tz * tz);
const reached = tdist < 0.7;
const tooLong = z.stateTime > 15; // защита от зацикливания
if (reached || tooLong) {
// Дошли (или таймаут) → пауза 1.5..3.0 секунды
z.idlePauseUntil = 1.5 + Math.random() * 1.5;
z.stateTime = 0;
} else {
moveDir = { x: tx / tdist, z: tz / tdist };
speed = z.opts.wanderSpeed;
}
}
}
// Гравитация и вертикальное падение каждый кадр
if (z.vy == null) z.vy = 0;
z.vy += -18 * dt;
if (z.vy < -25) z.vy = -25;
let newY = data.y + z.vy * dt;
// Определяем высоту поверхности под зомби
const surfaceY = this._surfaceHeightAt(data.x, data.z);
if (newY <= surfaceY) {
newY = surfaceY;
z.vy = 0;
z.onGround = true;
} else {
z.onGround = false;
}
data.y = newY;
// Дополнительная страховка: если зомби как-то оказался ЗА картой,
// тянем его обратно к границе. Старые «зависшие за краем» мобы быстро
// вернутся в игровую зону.
const halfClamp = (this.scene3d?._worldHalf ?? 40) - 0.5;
if (data.x > halfClamp) data.x = halfClamp;
if (data.x < -halfClamp) data.x = -halfClamp;
if (data.z > halfClamp) data.z = halfClamp;
if (data.z < -halfClamp) data.z = -halfClamp;
let stepped = false; // получилось ли реально сдвинуться по X или Z
if (moveDir) {
const stepX = moveDir.x * speed * dt;
const stepZ = moveDir.z * speed * dt;
const half = (this.scene3d?._worldHalf ?? 40) - 1.0;
const tryX = data.x + stepX;
const tryZ = data.z + stepZ;
const outX = tryX > half || tryX < -half;
const outZ = tryZ > half || tryZ < -half;
const targetSurface = this._surfaceHeightAt(tryX, tryZ);
const heightDiff = targetSurface - data.y;
if (heightDiff <= 1.05 && !outX && !outZ) {
// Полный шаг
data.x = tryX;
data.z = tryZ;
stepped = true;
if (heightDiff > 0.05 && z.onGround) z.vy = 6;
} else {
// Boundary slide: пробуем по одной оси
const onlyXok = !outX
&& (this._surfaceHeightAt(data.x + stepX, data.z) - data.y) <= 1.05;
const onlyZok = !outZ
&& (this._surfaceHeightAt(data.x, data.z + stepZ) - data.y) <= 1.05;
if (onlyXok) {
data.x += stepX;
stepped = true;
} else if (onlyZok) {
data.z += stepZ;
stepped = true;
}
}
// Не получилось сдвинуться (стена/край) — переходим в idle на 0.8..2с,
// выбираем новую цель относительно текущей позиции (с bias к центру).
if (!stepped && z.state === 'wander') {
z.idlePauseUntil = 0.8 + Math.random() * 1.2;
z.stateTime = 0;
z.wanderTarget = this._pickWanderTarget(
data.x, data.z, z.opts.wanderRadius);
moveDir = null; // на этот кадр анимация — idle
}
}
root.position.set(data.x, data.y, data.z);
if (moveDir) {
// Поворот к направлению движения. Kenney character смотрит в -Z
// локально — поэтому +π чтобы лицо было по направлению хода.
const targetYaw = Math.atan2(moveDir.x, moveDir.z) + Math.PI;
z.yaw = this._lerpAngle(z.yaw, targetYaw, dt * 5);
}
root.rotation.y = z.yaw;
// === Поза «зомби» с анимацией ходьбы и атаки ===
z.isMoving = !!moveDir;
if (z.walkPhase == null) z.walkPhase = 0;
if (z.isMoving) {
const stepFreq = (z.state === 'chase') ? 8 : 5; // быстрее когда гонится
z.walkPhase += dt * stepFreq;
}
// Pose update — самая дорогая операция per-зомби (трогает rotations
// 4-6 child-мешей). Пропускаем для дальних зомби (>30м) — игрок не
// видит покачивания их рук/ног.
if (distToPlayer < 30) {
this._applyZombiePose(data, z);
}
// === Хелсбар ===
const hb = z.healthBar;
if (hb) {
const showBar = z.hp < z.maxHp;
hb.anchor.setEnabled(showBar);
if (showBar) {
hb.anchor.position.set(data.x, data.y + 2.2, data.z);
const pct = Math.max(0, Math.min(1, z.hp / z.maxHp));
hb.fill.scaling.x = pct;
// Сдвигаем fill ВЛЕВО по локальной X: при scaling.x=1 центр fill
// совпадает с центром bg (offset=0). При scaling=0.5 fill ужался
// с обеих сторон, нужно сдвинуть на (1-pct) * width/2 влево
// чтобы левый край остался на месте.
hb.fill.position.x = -(1 - pct) * hb.barWidth / 2;
// Цвет от красного к зелёному
hb.fillMat.emissiveColor.set(
1 - pct * 0.6,
0.2 + pct * 0.7,
0.1
);
}
}
}
/**
* Анимация зомби: руки подняты вперёд + покачиваются, ноги шагают,
* туловище покачивается. Время фазы = z.walkPhase, тикается в _tickZombie.
*/
_applyZombiePose(data, z) {
const root = data?.rootMesh;
if (!root || !root.getChildMeshes) return;
const phase = z?.walkPhase ?? 0;
const isWalking = z?.isMoving;
const isAttacking = z?.state === 'attack';
const swing = isWalking ? Math.sin(phase) : 0; // -1..1 для ходьбы
const attackSwing = isAttacking
? Math.sin((performance.now() / 1000) * 6) * 0.4 // быстрое покачивание для атаки
: 0;
const meshes = root.getChildMeshes(false);
for (const m of meshes) {
const n = (m.name || '').toLowerCase();
if (m.rotationQuaternion) m.rotationQuaternion = null;
if (n.includes('arm-left')) {
// Левая (с нашего ракурса) рука вытянута вперёд + раскачивание
m.rotation.x = -Math.PI / 2 + swing * 0.15 + attackSwing;
m.rotation.y = 0;
m.rotation.z = 0.05 * swing;
} else if (n.includes('arm-right')) {
m.rotation.x = -Math.PI / 2 - swing * 0.15 + attackSwing;
m.rotation.y = 0;
m.rotation.z = -0.05 * swing;
} else if (n.includes('leg-left')) {
// Левая нога вперёд при положительной фазе
m.rotation.x = swing * 0.6;
m.rotation.y = 0;
m.rotation.z = 0;
} else if (n.includes('leg-right')) {
m.rotation.x = -swing * 0.6;
m.rotation.y = 0;
m.rotation.z = 0;
} else if (n.includes('torso')) {
// Лёгкое покачивание
m.rotation.x = swing * 0.05;
m.rotation.y = 0;
m.rotation.z = swing * 0.04;
}
}
}
/**
* Высота поверхности под точкой (x, z) — Y верха самого высокого блока.
* Сканирует столбец сверху вниз. Если блоков нет — НЕ возвращает 0
* (это роняло зомби на базовую плиту сквозь гладкий ландшафт), а
* берёт высоту RobloxTerrain через physics._sampleRobloxSurface.
*
* ОПТИМИЗАЦИЯ: кэш per (gx,gz). На зомби-острове `_tickZombie` зовёт
* этот метод 3-4 раза за кадр на каждого зомби. Без кэша — много
* lookup'ов по Map. Сбрасываем кэш при изменениях BlockManager.
*/
_surfaceHeightAt(x, z) {
const bm = this.scene3d?.blockManager;
if (!bm) return this._robloxSurfaceFallback(x, z);
const gx = Math.round(x);
const gz = Math.round(z);
// Кэш живёт на самом BlockManager — все зомби делят его.
if (!bm._surfaceCache) bm._surfaceCache = new Map();
if (typeof bm._surfaceCacheVersion !== 'number') bm._surfaceCacheVersion = 0;
const key = gx * 65537 + gz;
const cached = bm._surfaceCache.get(key);
if (cached && cached.v === bm._surfaceCacheVersion) {
return cached.h;
}
// Cache miss — сканируем столбец блоков сверху вниз.
// Потолок поднят до 60: замок/донжон/платформы могут быть высоко.
let height = null;
for (let y = 60; y >= -5; y--) {
const blockKey = `${gx},${y},${gz}`;
const mesh = bm.blocks.get(blockKey);
if (mesh && !mesh.metadata?.isWater) {
height = y + 1;
break;
}
}
// Блоков в столбце нет — зомби стоит на гладком ландшафте.
// Берём высоту реального меша RobloxTerrain (raycast в physics).
if (height === null) {
height = this._robloxSurfaceFallback(x, z);
}
bm._surfaceCache.set(key, { v: bm._surfaceCacheVersion, h: height });
return height;
}
/**
* Высота гладкого ландшафта (RobloxTerrain) под точкой — через
* physics._sampleRobloxSurface (raycast по реальному мешу).
* Fallback на 0 только если RobloxTerrain вообще нет.
*/
_robloxSurfaceFallback(x, z) {
const phys = this.scene3d?.physics;
if (phys && typeof phys._sampleRobloxSurface === 'function') {
const y = phys._sampleRobloxSurface(x, z);
if (y !== null && y !== undefined) return y;
}
return 0;
}
_lerpAngle(a, b, t) {
// Кратчайший угловой путь
let diff = b - a;
while (diff > Math.PI) diff -= Math.PI * 2;
while (diff < -Math.PI) diff += Math.PI * 2;
return a + diff * Math.min(1, t);
}
}