studio/src/editor/engine/GdLevelManager.js
МИН 31adbf151b Initial public release: Студия Рублокса v1.0
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)
2026-05-27 23:41:10 +03:00

268 lines
11 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.

/**
* GdLevelManager — авто-обработка всех GD-объектов в Play-режиме.
*
* Находит примитивы по их kind ('gd_portal', 'gd_spike', 'gd_finish', 'gd_coin')
* и при пересечении с игроком автоматически выполняет нужное действие:
* - gd_portal → переключение гейммода (Cube/Ship/Ball/UFO/Wave/Robot)
* - gd_spike → смерть и respawn (telepor на spawnPoint)
* - gd_finish → событие 'gdFinish' (скрипт показывает win-screen)
* - gd_coin → +1 монета (скрытие меша, событие 'gdCoinCollected')
*
* Цель: дать пользователю возможность ставить GD-объекты в редакторе из палитры,
* и всё работает автоматически без скрипта-обвязки.
*
* События пробрасываются в скрипт через `gameRuntime.routeGlobalEvent`,
* чтобы скрипт мог показывать UI и проигрывать SFX.
*/
import { getPrimitiveType } from './PrimitiveTypes';
const PORTAL_RADIUS = 1.0;
const SPIKE_RADIUS = 0.7;
const FINISH_RADIUS = 1.5;
const COIN_RADIUS = 1.4;
const PORTAL_COOLDOWN = 1.0;
const DEATH_COOLDOWN = 0.5; // после смерти не убиваем 0.5с (даём respawn-окно)
export class GdLevelManager {
constructor(scene3d) {
this.scene3d = scene3d;
this._observer = null;
this._portals = []; // [{ x, y, z, gdMode, primId }]
this._spikes = []; // [{ x, y, z, primId }]
this._finishes = []; // [{ x, y, z, primId }]
this._coins = []; // [{ x, y, z, primId, collected }]
this._portalCooldown = 0;
this._deathCooldown = 0;
this._winSent = false;
this._onPortalEnter = null;
this._onDeath = null;
this._onFinish = null;
this._onCoinCollected = null;
this._lastPmCount = -1; // для авто-refresh когда primitiveManager наполнится
}
setOnPortalEnter(cb) { this._onPortalEnter = cb; }
setOnDeath(cb) { this._onDeath = cb; }
setOnFinish(cb) { this._onFinish = cb; }
setOnCoinCollected(cb) { this._onCoinCollected = cb; }
start() {
this._refreshObjectList();
const scene = this.scene3d?.scene;
if (!scene) return;
this._winSent = false;
this._deathCooldown = 0;
this._portalCooldown = 0;
this._observer = scene.onBeforeRenderObservable.add(() => this._tick());
}
stop() {
const scene = this.scene3d?.scene;
if (scene && this._observer) {
scene.onBeforeRenderObservable.remove(this._observer);
}
this._observer = null;
const p = this.scene3d?.player;
if (p) {
p._shipMode = false;
p._ufoMode = false;
p._waveMode = false;
p._robotMode = false;
p._gravityDir = 1;
p._robotBoostLeft = 0;
}
}
/** Собрать все GD-объекты из текущего набора примитивов. */
_refreshObjectList() {
this._portals = [];
this._spikes = [];
this._finishes = [];
this._coins = [];
const pm = this.scene3d?.primitiveManager;
if (!pm) return;
for (const data of pm.instances.values()) {
const typeDef = getPrimitiveType(data.type);
if (!typeDef) continue;
const base = { x: data.x, y: data.y, z: data.z, primId: data.id };
if (typeDef.kind === 'gd_portal') {
this._portals.push({ ...base, gdMode: typeDef.gdMode });
} else if (typeDef.kind === 'gd_spike') {
this._spikes.push(base);
} else if (typeDef.kind === 'gd_finish') {
this._finishes.push(base);
} else if (typeDef.kind === 'gd_coin') {
this._coins.push({ ...base, collected: false });
}
}
}
/** Очистка состояния (вызывается при respawn). Монеты сохраняются — собранные собраны. */
resetForRespawn() {
this._deathCooldown = DEATH_COOLDOWN;
this._portalCooldown = 0;
// Режим возвращаем к Cube
const p = this.scene3d?.player;
if (p) {
p._shipMode = false;
p._ufoMode = false;
p._waveMode = false;
p._robotMode = false;
p._gravityDir = 1;
p._robotBoostLeft = 0;
p._ballHintActive = false;
}
}
/** Сбросить ВСЁ (включая монеты) — для рестарта уровня. */
resetForNewRun() {
this.resetForRespawn();
this._winSent = false;
for (const coin of this._coins) {
coin.collected = false;
try {
const data = this.scene3d?.primitiveManager?.instances?.get(coin.primId);
if (data?.mesh) data.mesh.setEnabled(true);
} catch (e) {}
}
}
_currentMode() {
const p = this.scene3d?.player;
if (!p) return 'cube';
if (p._shipMode) return 'ship';
if (p._ufoMode) return 'ufo';
if (p._waveMode) return 'wave';
if (p._robotMode) return 'robot';
if ((p._gravityDir || 1) < 0 || p._ballHintActive) return 'ball';
return 'cube';
}
_setMode(gdMode) {
const p = this.scene3d?.player;
if (!p) return;
console.log('[GdLevelManager] _setMode →', gdMode);
p._shipMode = false;
p._ufoMode = false;
p._waveMode = false;
p._robotMode = false;
p._ballHintActive = (gdMode === 'ball');
if (gdMode === 'ship') p._shipMode = true;
if (gdMode === 'ufo') p._ufoMode = true;
if (gdMode === 'wave') p._waveMode = true;
if (gdMode === 'robot') p._robotMode = true;
if (gdMode === 'cube') {
p._gravityDir = 1;
p._robotBoostLeft = 0;
}
}
_tick() {
const p = this.scene3d?.player;
if (!p) return;
const dt = (this.scene3d.scene.getEngine().getDeltaTime() / 1000) || 0.016;
this._portalCooldown = Math.max(0, this._portalCooldown - dt);
this._deathCooldown = Math.max(0, this._deathCooldown - dt);
// Автообновление: primitives загружаются асинхронно после start().
// Считаем кол-во в primitiveManager и при изменении — refresh всех списков.
const pmCount = this.scene3d?.primitiveManager?.instances?.size || 0;
if (pmCount !== this._lastPmCount) {
this._lastPmCount = pmCount;
this._refreshObjectList();
console.log(`[GdLevelManager] refresh: pm=${pmCount}, portals=${this._portals.length}, spikes=${this._spikes.length}, finishes=${this._finishes.length}, coins=${this._coins.length}`);
}
// PlayerController хранит позицию в _pos (не в .position — там нет геттера).
const ppos = p._pos || p.position;
const px = ppos?.x ?? 0;
const py = ppos?.y ?? 0;
// === Порталы (приоритет: они перекрывают всё) ===
// Триггер — пересечение по X на любой высоте 0..12 (как у финиша).
// Чтобы в ship-режиме игрок не пролетел над cube-порталом сверху.
if (this._portalCooldown <= 0) {
for (const portal of this._portals) {
const dx = px - portal.x;
if (Math.abs(dx) < PORTAL_RADIUS * 1.5 && py >= 0 && py <= 12) {
const cur = this._currentMode();
if (cur === portal.gdMode) continue;
const prevMode = cur;
this._setMode(portal.gdMode);
this._portalCooldown = PORTAL_COOLDOWN;
if (this._onPortalEnter) {
try { this._onPortalEnter(portal.gdMode, prevMode); } catch (e) {}
}
break;
}
}
}
// === Финиш (приоритет 2: победа важнее смерти) ===
// Финиш = вертикальный столб (cylinder neon sy=8). Триггер — пересечение по X
// на любой допустимой высоте (от пола y=0 до y=8). Это покрывает все режимы
// (cube/ship/ball/ufo/wave/robot — игрок может быть на разной высоте).
if (!this._winSent) {
for (const fin of this._finishes) {
const dx = px - fin.x;
// По X — узко (1.5м), по Y — широко (от 0 до 10)
if (Math.abs(dx) < FINISH_RADIUS && py >= -1 && py <= 10) {
this._winSent = true;
if (this._onFinish) {
try { this._onFinish({ x: fin.x, y: fin.y }); } catch (e) {}
}
return;
}
}
}
// === Шипы (смерть) ===
if (this._deathCooldown <= 0) {
for (const spike of this._spikes) {
const dx = px - spike.x;
const dy = py - spike.y;
if (dx*dx + dy*dy < SPIKE_RADIUS * SPIKE_RADIUS) {
this.resetForRespawn();
if (this._onDeath) {
try { this._onDeath({ x: spike.x, y: spike.y, cause: 'spike' }); } catch (e) {}
}
return;
}
}
// Также «упал в бездну» — y < -2
if (py < -2) {
this.resetForRespawn();
if (this._onDeath) {
try { this._onDeath({ x: px, y: py, cause: 'fall' }); } catch (e) {}
}
return;
}
}
// === Монеты (сбор) ===
for (const coin of this._coins) {
if (coin.collected) continue;
const dx = px - coin.x;
const dy = py - coin.y;
if (dx*dx + dy*dy < COIN_RADIUS * COIN_RADIUS) {
coin.collected = true;
try {
const data = this.scene3d?.primitiveManager?.instances?.get(coin.primId);
if (data?.mesh) data.mesh.setEnabled(false);
} catch (e) {}
if (this._onCoinCollected) {
try { this._onCoinCollected({ primId: coin.primId, index: this._coins.indexOf(coin) }); } catch (e) {}
}
}
}
}
/** Сколько собрано монет / всего. Для HUD. */
getCoinsStats() {
const total = this._coins.length;
const collected = this._coins.filter(c => c.collected).length;
return { collected, total };
}
}