Open-source веб-студия для создания игр Рублокса, двойная лицензия AGPL-3.0 + Коммерческая. Главное: - Vite 5 + React 18 + Babylon 7.54.3 + Monaco Editor + Colyseus 0.16 - Самодостаточный движок ~28к строк (66 файлов): BlockManager, TerrainVoxelBuilder, ModelManager, DecoManager, PlayerController, ScriptSandboxWorker, MultiplayerSync, 30+ GD-гейммодов - Главный редактор KubikonEditor (~37к строк) + панели, ScriptEditor (Monaco) - Витрина игр (KubikonFeed, KubikonStudio, KubikonDocs, KubikonLearn) - Geometry Dash sub-app (GdMenu, GdShop, GdRules, GdCoverArt) - 10 admin-preview каталогов для дизайнеров (скины, музыка, порталы и т.д.) - Конфигурируемый бэкенд через VITE_API_BASE — работает со staging (dev-api.rublox.pro) без настройки - Standalone-режим (VITE_STANDALONE=true) — открыть пустой редактор без бэка - Полная документация (на русском): README, ARCHITECTURE, CONTRIBUTING, SECURITY, CHANGELOG - ESLint + Prettier + EditorConfig - Legal: LICENSE (AGPL-3.0), LICENSE-COMMERCIAL.md, CLA.md, COPYRIGHT.md - Issue templates: bug_report, feature_request, security_disclosure Перед публикацией: - Все импорты из minecraftia заменены на локальные - Все хардкоды URL (minecraftia-school.ru) и внутренних IP убраны → env - Admin-эндпоинты Kubikon3DService вырезаны (остаются в приватном репо) - AdminKubikonModeration не публикуется (модерация — в team.rublox.pro) - 93 МБ ассетов public/kubikon-assets вынесены в .gitignore (раздаются через release artifact)
162 lines
7.2 KiB
JavaScript
162 lines
7.2 KiB
JavaScript
/**
|
||
* 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<number, object>} 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;
|
||
}
|
||
}
|