/** * ZombieSpawnerManager — спавн зомби с лимитом и периодом. * * Визуал: вместо character-d показываем светящийся диск (цилиндр) с * пульсацией. Сама character-d модель скрывается. */ import { MeshBuilder, StandardMaterial, Color3 } from '@babylonjs/core'; const DEFAULTS = { // Снижено с 10 → 3: меньше живых зомби на спавнер = меньше нагрузки и // меньше шанс нарваться на невидимых из-за бага высоты на GLB-террейне. maxAlive: 3, spawnInterval: 10, radius: 6, zombieModelType: 'character-c', }; let _spawnerIdSeq = 1; export class ZombieSpawnerManager { constructor(scene3d, zombieManager) { this.scene3d = scene3d; this.zombieManager = zombieManager; /** @type {Map} id → spawner state */ this.spawners = new Map(); this._lastTick = performance.now() / 1000; this._renderHook = null; // Подписываемся на смерть зомби — уменьшаем счётчик у спавнера this.zombieManager.setOnDeath((zombie) => this.onZombieDied(zombie)); } start() { if (this._renderHook) return; this._renderHook = () => this._tick(); this.scene3d.scene.registerBeforeRender(this._renderHook); this._lastTick = performance.now() / 1000; } stop() { if (this._renderHook) { this.scene3d.scene.unregisterBeforeRender(this._renderHook); this._renderHook = null; } // Удаляем всех зомби, которые были СПАВНУТЫ нашими спавнерами // (но не трогаем зомби размещённых вручную — они переживают Play→Stop). for (const sp of this.spawners.values()) { for (const instId of (sp.spawnedZombieIds || [])) { this.scene3d.modelManager?.removeInstance(instId); } } this.spawners.clear(); } /** Колбэк когда зомби умер — обновляем счётчик спавнера. */ onZombieDied(zombie) { const sid = zombie.spawnerId; if (sid == null) return; const sp = this.spawners.get(sid); if (!sp) return; sp.aliveZombies = Math.max(0, sp.aliveZombies - 1); // Также убираем из списка spawnedZombieIds (чтобы не пытаться удалить уже мёртвого) const idx = sp.spawnedZombieIds.indexOf(zombie.instanceId); if (idx >= 0) sp.spawnedZombieIds.splice(idx, 1); } /** * Зарегистрировать модель сцены как спавнер. * modelInstanceId — id из ModelManager.instances. */ register(modelInstanceId, options = {}) { const data = this.scene3d.modelManager?.instances?.get(modelInstanceId); if (!data) return null; const id = _spawnerIdSeq++; const opts = { ...DEFAULTS, ...options }; this.spawners.set(id, { id, instanceId: modelInstanceId, data, opts, spawnedZombieIds: [], // ids зомби которых мы создали — для cleanup при stop aliveZombies: 0, lastSpawnTime: 0, }); return id; } _tick() { const now = performance.now() / 1000; this._lastTick = now; // Глобальный лимит зомби на тач-устройствах (low-perf): если суммарно // живых зомби > MOBILE_TOTAL_LIMIT — не спавним больше. Это критично // для мобилки на «зомби-острове»: иначе FPS просаживается до 5-10. const lowPerf = !!this.scene3d?._lowPerfApplied; const MOBILE_TOTAL_LIMIT = 6; if (lowPerf) { const total = this.zombieManager?.zombies?.size || 0; if (total >= MOBILE_TOTAL_LIMIT) return; } for (const sp of this.spawners.values()) { // На мобиле каждый спавнер ограничиваем 3 зомби (вне зависимости от opts). const cap = lowPerf ? Math.min(3, sp.opts.maxAlive) : sp.opts.maxAlive; if (sp.aliveZombies >= cap) continue; if (now - sp.lastSpawnTime < sp.opts.spawnInterval) continue; sp.lastSpawnTime = now; this._spawnAt(sp); } } async _spawnAt(sp) { const { x, z } = sp.data; const a = Math.random() * Math.PI * 2; const r = Math.random() * sp.opts.radius; let sx = x + Math.cos(a) * r; let sz = z + Math.sin(a) * r; // Не спавнить зомби за пределами карты (worldHalf по X и Z, отступ 1.5м). const half = (this.scene3d?._worldHalf ?? 40) - 1.5; if (sx > half) sx = half; if (sx < -half) sx = -half; if (sz > half) sz = half; if (sz < -half) sz = -half; // Y берём из ПОВЕРХНОСТИ под (sx, sz), а не из y спавнера. Иначе зомби // спавнится на y=0 (под рельефом), его видимый меш под полом и // невидимый хитбокс наносит урон сквозь землю. const sy = this._surfaceHeightAt(sx, sz); const mm = this.scene3d.modelManager; if (!mm) return; const modelType = sp.opts.zombieModelType || 'zombie'; const instId = await mm.addInstance(modelType, sx, sy, sz, Math.random() * Math.PI * 2); if (instId == null) return; // Помечаем data модели чтобы при сериализации НЕ сохранять этих зомби const data = mm.instances.get(instId); if (data) data._spawnedAtRuntime = true; const zombieParams = { spawnerId: sp.id, hp: sp.opts.zombieHp ?? 50, speed: sp.opts.zombieSpeed ?? 2.2, attackDamage: sp.opts.zombieDamage ?? 10, }; this.zombieManager.registerExisting(instId, zombieParams); sp.aliveZombies += 1; sp.spawnedZombieIds.push(instId); // Новый меш в сцене — frozen active-meshes устарел try { this.scene3d?.setActiveMeshesDirty?.(); } catch (e) {} } /** Вернуть y-поверхности (top самого высокого блока) под (x, z). * Без блоков — fallback 0. Та же логика что в ZombieManager и worker'е. */ _surfaceHeightAt(x, z) { const bm = this.scene3d?.blockManager; if (!bm || !bm.blocks) return 0; const gx = Math.round(x); const gz = Math.round(z); for (let y = 20; y >= -3; y--) { const key = `${gx},${y},${gz}`; const mesh = bm.blocks.get(key); if (mesh && !mesh.metadata?.isWater) return y + 1; } return 0; } }