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)
1018 lines
46 KiB
JavaScript
1018 lines
46 KiB
JavaScript
/**
|
||
* 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);
|
||
}
|
||
}
|