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