/** * 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} 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); } }