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)
193 lines
8.0 KiB
JavaScript
193 lines
8.0 KiB
JavaScript
/**
|
||
* GdDiamond — визуал алмазов и эффект подбора для GD-уровней.
|
||
*
|
||
* 1. Находит все primitive id ∈ [14001, 14002] и заменяет sphere
|
||
* на красивый **октаэдр-кристалл** с emissive свечением и точечным светом.
|
||
* 2. Idle-анимация: вращение + плавание вверх-вниз.
|
||
* 3. При исчезновении (setEnabled(false) или setVisible(false)) — взрыв ParticleSystem
|
||
* на 1 секунду на месте алмаза.
|
||
*
|
||
* Подключается в BabylonScene.enterPlayMode.
|
||
*/
|
||
import {
|
||
Vector3, Color3, Color4, StandardMaterial, MeshBuilder, PointLight,
|
||
ParticleSystem, Texture,
|
||
} from '@babylonjs/core';
|
||
|
||
const DIAMOND_IDS = [14001, 14002];
|
||
|
||
export class GdDiamond {
|
||
constructor() {
|
||
this.scene = null;
|
||
this._scene3d = null;
|
||
this._items = []; // [{ primId, root, mesh, light, baseY, phase }]
|
||
this._onBeforeRender = null;
|
||
this._t = 0;
|
||
}
|
||
|
||
attach(scene, scene3d) {
|
||
if (!scene || !scene3d) return;
|
||
this.scene = scene;
|
||
this._scene3d = scene3d;
|
||
// Отложенно — primitives могут не быть загружены
|
||
let attempts = 0;
|
||
const tryAttach = () => {
|
||
attempts++;
|
||
const pm = scene3d.primitiveManager;
|
||
if (!pm || !pm.instances) {
|
||
if (attempts < 10) setTimeout(tryAttach, 200);
|
||
return;
|
||
}
|
||
this._replaceDiamonds();
|
||
this._setupAnim();
|
||
};
|
||
tryAttach();
|
||
}
|
||
|
||
_replaceDiamonds() {
|
||
const pm = this._scene3d.primitiveManager;
|
||
let n = 0;
|
||
for (const id of DIAMOND_IDS) {
|
||
const data = pm.instances.get(id);
|
||
if (!data || !data.mesh) continue;
|
||
const orig = data.mesh;
|
||
const x = data.x, y = data.y, z = data.z || 0;
|
||
|
||
// Сделать красивый октаэдр-кристалл вместо sphere
|
||
const crystal = MeshBuilder.CreatePolyhedron(`gd_diamond_${id}`,
|
||
{ type: 1, size: 0.55 }, this.scene); // type=1 = octahedron
|
||
crystal.position.set(x, y, z);
|
||
|
||
// Материал — голубой стеклянный с свечением
|
||
const mat = new StandardMaterial(`gd_diamond_mat_${id}`, this.scene);
|
||
mat.diffuseColor = new Color3(0.5, 0.85, 1.0);
|
||
mat.emissiveColor = new Color3(0.3, 0.7, 1.0);
|
||
mat.specularColor = new Color3(1, 1, 1);
|
||
mat.specularPower = 128;
|
||
mat.alpha = 0.85;
|
||
crystal.material = mat;
|
||
|
||
// Точечный свет для атмосферы
|
||
const light = new PointLight(`gd_diamond_light_${id}`, new Vector3(x, y, z), this.scene);
|
||
light.diffuse = new Color3(0.4, 0.8, 1.0);
|
||
light.specular = new Color3(0.5, 0.9, 1.0);
|
||
light.intensity = 0.5;
|
||
light.range = 4;
|
||
|
||
// Прячем исходный sphere (визуально — кристалл его заменяет)
|
||
try { orig.setEnabled(false); } catch (e) {}
|
||
|
||
this._items.push({
|
||
primId: id,
|
||
origMesh: orig,
|
||
mesh: crystal,
|
||
light,
|
||
baseY: y,
|
||
phase: id * 0.7,
|
||
collected: false,
|
||
});
|
||
// Если data.visible уже false (скрипт уровня загрузил флаг из save —
|
||
// алмаз был забран в прошлый заход), кристалл скрыть без эффекта.
|
||
if (data.visible === false) {
|
||
try { crystal.setEnabled(false); } catch (e) {}
|
||
try { light && light.setEnabled(false); } catch (e) {}
|
||
this._items[this._items.length - 1].collected = true;
|
||
}
|
||
n++;
|
||
}
|
||
console.log(`[GdDiamond] установлено ${n} кристаллов`);
|
||
}
|
||
|
||
_setupAnim() {
|
||
const pm = this._scene3d.primitiveManager;
|
||
this._onBeforeRender = () => {
|
||
this._t += 0.016;
|
||
for (const it of this._items) {
|
||
if (it.collected || !it.mesh) continue;
|
||
// Вращение по Y
|
||
it.mesh.rotation.y = this._t * 1.5 + it.phase;
|
||
// Плавание вверх-вниз
|
||
const bob = Math.sin(this._t * 2 + it.phase) * 0.25;
|
||
it.mesh.position.y = it.baseY + bob;
|
||
if (it.light) it.light.position.y = it.baseY + bob;
|
||
// Подбор определяем по data.visible — скрипт уровня вызывает
|
||
// game.scene.setVisible('primitive:14001', false) когда забрал.
|
||
// Это надёжно: setEnabled false мы сами установили в _replaceDiamonds.
|
||
const data = pm && pm.instances.get(it.primId);
|
||
if (data && data.visible === false) {
|
||
this._triggerCollect(it);
|
||
}
|
||
}
|
||
};
|
||
this.scene.onBeforeRenderObservable.add(this._onBeforeRender);
|
||
}
|
||
|
||
_triggerCollect(it) {
|
||
if (it.collected) return;
|
||
it.collected = true;
|
||
const pos = it.mesh.position.clone();
|
||
// Эффект частиц
|
||
try {
|
||
const ps = new ParticleSystem(`diamond_burst_${it.primId}`, 60, this.scene);
|
||
// Используем процедурную текстуру (точка) если нет ассета
|
||
ps.particleTexture = null; // Babylon делает квадратик по умолчанию
|
||
ps.emitter = pos;
|
||
ps.minEmitBox = new Vector3(-0.1, -0.1, -0.1);
|
||
ps.maxEmitBox = new Vector3(0.1, 0.1, 0.1);
|
||
ps.color1 = new Color4(0.5, 0.9, 1.0, 1);
|
||
ps.color2 = new Color4(0.9, 1.0, 1.0, 1);
|
||
ps.colorDead = new Color4(0.3, 0.5, 0.9, 0);
|
||
ps.minSize = 0.08; ps.maxSize = 0.20;
|
||
ps.minLifeTime = 0.4; ps.maxLifeTime = 1.0;
|
||
ps.emitRate = 0; // burst-режим
|
||
ps.manualEmitCount = 60;
|
||
ps.gravity = new Vector3(0, -3, 0);
|
||
ps.direction1 = new Vector3(-3, 4, -3);
|
||
ps.direction2 = new Vector3(3, 8, 3);
|
||
ps.minEmitPower = 1; ps.maxEmitPower = 4;
|
||
ps.updateSpeed = 0.02;
|
||
ps.disposeOnStop = true;
|
||
ps.start();
|
||
// Стоп через 200мс (один burst)
|
||
setTimeout(() => { try { ps.stop(); } catch (e) {} }, 200);
|
||
} catch (e) { console.warn('[GdDiamond] particles failed', e); }
|
||
// Кристалл — scale-up и fade за 400мс
|
||
const mesh = it.mesh;
|
||
const light = it.light;
|
||
const startT = performance.now();
|
||
const DUR = 400;
|
||
const fadeStep = () => {
|
||
const elapsed = performance.now() - startT;
|
||
if (elapsed > DUR) {
|
||
try { mesh.dispose(); } catch (e) {}
|
||
try { light && light.dispose(); } catch (e) {}
|
||
return;
|
||
}
|
||
const t = elapsed / DUR;
|
||
const s = 1 + t * 1.5;
|
||
mesh.scaling.set(s, s, s);
|
||
if (mesh.material) mesh.material.alpha = 0.85 * (1 - t);
|
||
if (light) light.intensity = 0.5 * (1 - t);
|
||
requestAnimationFrame(fadeStep);
|
||
};
|
||
requestAnimationFrame(fadeStep);
|
||
}
|
||
|
||
dispose() {
|
||
if (!this.scene) return;
|
||
try {
|
||
if (this._onBeforeRender) {
|
||
this.scene.onBeforeRenderObservable.removeCallback(this._onBeforeRender);
|
||
this._onBeforeRender = null;
|
||
}
|
||
} catch (e) {}
|
||
for (const it of this._items) {
|
||
try { it.mesh && it.mesh.dispose(); } catch (e) {}
|
||
try { it.light && it.light.dispose(); } catch (e) {}
|
||
}
|
||
this._items = [];
|
||
this.scene = null;
|
||
this._scene3d = null;
|
||
}
|
||
}
|