Большой консолидирующий коммит после поднятия studio.rublox.pro (28 мая 2026). Содержит изменения которые делались в процессе подготовки прод-окружения: Фиксы импортов после выноса из minecraftia: - Массовая замена путей ../../components → ../components (40+ файлов в src/community/, src/admin-preview/) - Замена ../KubikonEditor/ → ../editor/, ../KubikonStudio/ → ../community/, ../AdminPreview/ → ../admin-preview/ - API.js скопирован из минки целиком (было 8 экспортов, стало 312) - Добавлены PLAYER_URL, MyButton_1, недостающие компоненты - Заменены require() на статические ES-imports в BabylonScene, PrimitiveManager, GameRuntime (Vite не поддерживает CJS require) Структура ассетов: - public/kubikon-templates/ → public/assets/kubikon-templates/ - public/kubikon-learn/ → public/assets/kubikon-learn/ - (код искал в /assets/, файлы лежали без /assets/) Навигация роутов внутри студии: - /kubikon-studio/docs → /docs (90+ навигационных вызовов sed-replaced) - /kubikon-editor/X → /edit/X, /kubikon/play/X → /play/X, /kubikon/gd/X → /gd/X UI: - Новый компонент StudioHeader (61px, как в минке) + копия favicon - WithHeader wrapper в App.jsx для всех страниц кроме fullscreen-редактора/плеера - SSO ticket-flow в AuthContext (auto-redeem #ticket= при загрузке) - Тёмная тема карточек игр в ВИКИ (фон #1c2231 вместо #fff, картинка впритык) Документация: - docs/ONBOARDING.md — путь нового контрибьютора от нуля до PR - docs/TUTORIAL_ADD_SCRIPT_API.md — как добавить game.* API - API_USAGE.md — список эндпоинтов backend - README в подпапках engine/, engine/terrain/, engine/voxel/, engine/robloxterrain/, engine/types/ .gitignore: - public/wiki/ исключён (73МБ PNG, будут на CDN отдельной задачей) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
170 lines
7.1 KiB
JavaScript
170 lines
7.1 KiB
JavaScript
/**
|
||
* GdFinish — финишные ворота + конфетти при пересечении (этап G5).
|
||
*
|
||
* Ставит выбранную фабрику ворот на FINISH_X (из cylinder-примитива или из
|
||
* самого правого ненулевого x уровня). Поворачивает поперёк трассы (Y=90°).
|
||
*
|
||
* При вызове .celebrate() запускает конфетти-партикль на 1.5с.
|
||
*
|
||
* Финиш-событие триггерится в скрипте уровня (по FINISH_X) — мы лишь рисуем
|
||
* ворота. Подключим celebrate из BabylonScene при gdFinish сообщении.
|
||
*/
|
||
import {
|
||
Vector3, TransformNode, ParticleSystem, Texture, DynamicTexture,
|
||
Color3, Color4, MeshBuilder, StandardMaterial,
|
||
} from '@babylonjs/core';
|
||
import { FINISH_CATALOG } from '../../admin-preview/gdFinishes/finishFactories';
|
||
|
||
const DEFAULT_FINISH_BY_EPOCH = {
|
||
1: 'f1_v4', // Рустик (выбор юзера)
|
||
2: 'f2_v5', // Бронзовая
|
||
3: 'f3_v3',
|
||
4: 'f4_v4',
|
||
5: 'f5_v2',
|
||
6: 'f6_v4',
|
||
7: 'f7_v1',
|
||
8: 'f8_v5',
|
||
9: 'f9_v4',
|
||
10: 'f10_v5',
|
||
};
|
||
|
||
export class GdFinish {
|
||
constructor() {
|
||
this.scene = null;
|
||
this._scene3d = null;
|
||
this._handle = null;
|
||
this._root = null;
|
||
this._particles = null;
|
||
this._particleEmitter = null;
|
||
this._celebrated = false;
|
||
}
|
||
|
||
attach(scene, scene3d, epoch = 1, finishId = null) {
|
||
if (!scene || !scene3d) return;
|
||
this.scene = scene;
|
||
this._scene3d = scene3d;
|
||
const id = finishId || DEFAULT_FINISH_BY_EPOCH[epoch] || 'f1_v1';
|
||
const factory = FINISH_CATALOG.find(f => f.id === id);
|
||
if (!factory) {
|
||
console.warn('[GdFinish] фабрика не найдена:', id);
|
||
return;
|
||
}
|
||
// Определяем FINISH_X — ищем cylinder-примитив (это стандартный gd_finish)
|
||
let finishX = null;
|
||
const pm = scene3d.primitiveManager;
|
||
if (pm) {
|
||
for (const data of pm.instances.values()) {
|
||
if (String(data.type) === 'cylinder' && typeof data.x === 'number' && data.x > 50) {
|
||
// самый правый cylinder = финиш
|
||
if (finishX == null || data.x > finishX) finishX = data.x;
|
||
}
|
||
}
|
||
}
|
||
if (finishX == null) {
|
||
console.warn('[GdFinish] не нашли cylinder-финиш, ставлю на x=1000');
|
||
finishX = 1000;
|
||
}
|
||
// Скрываем оригинальный cylinder если он рядом с финишем
|
||
if (pm) {
|
||
for (const data of pm.instances.values()) {
|
||
if (String(data.type) === 'cylinder' && Math.abs(data.x - finishX) < 2 && data.mesh) {
|
||
try { data.mesh.setEnabled(false); } catch (e) {}
|
||
this._hiddenFinishCylinder = data.mesh;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
const h = factory.make(scene, `gd_finish_inst`);
|
||
if (!h || !h.root) return;
|
||
this._handle = h;
|
||
this._root = h.root;
|
||
this._root.position = new Vector3(finishX, 1, 0);
|
||
this._root.rotation.y = Math.PI / 2; // поперёк трассы
|
||
this._finishX = finishX;
|
||
this._setupConfetti(finishX);
|
||
// Автоматически срабатывает когда куб игрока пересекает FINISH_X.
|
||
// Скрипт уровня сам показывает win-screen, мы только конфетти добавим.
|
||
this._onBeforeRender = () => {
|
||
if (this._celebrated) return;
|
||
const pp = this._scene3d?.player?._pos;
|
||
if (!pp) return;
|
||
if (pp.x >= this._finishX - 1.5) {
|
||
this.celebrate();
|
||
}
|
||
};
|
||
this.scene.onBeforeRenderObservable.add(this._onBeforeRender);
|
||
}
|
||
|
||
/** Создать ParticleSystem конфетти (старт по celebrate). */
|
||
_setupConfetti(finishX) {
|
||
// Маленький emitter-mesh — невидимая точка над финишем
|
||
const emitter = MeshBuilder.CreateBox('gd_confetti_emitter', { size: 0.1 }, this.scene);
|
||
emitter.position.set(finishX, 4, 0);
|
||
emitter.isVisible = false;
|
||
this._particleEmitter = emitter;
|
||
|
||
// Текстура частицы — квадратик canvas (просто белый круг)
|
||
const T = 32;
|
||
const dt = new DynamicTexture('gd_confetti_tex', { width: T, height: T }, this.scene, true);
|
||
const ctx = dt.getContext();
|
||
ctx.clearRect(0, 0, T, T);
|
||
ctx.fillStyle = '#ffffff';
|
||
ctx.beginPath();
|
||
ctx.arc(T / 2, T / 2, T / 2 - 2, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
dt.hasAlpha = true;
|
||
dt.update();
|
||
|
||
const ps = new ParticleSystem('gd_confetti', 400, this.scene);
|
||
ps.particleTexture = dt;
|
||
ps.emitter = emitter;
|
||
ps.minEmitBox = new Vector3(-1.5, 0, -1.5);
|
||
ps.maxEmitBox = new Vector3(1.5, 0, 1.5);
|
||
// Конфетти разных цветов
|
||
ps.color1 = new Color4(1, 0.3, 0.4, 1);
|
||
ps.color2 = new Color4(0.3, 0.7, 1, 1);
|
||
ps.colorDead = new Color4(1, 1, 1, 0);
|
||
ps.minSize = 0.15; ps.maxSize = 0.35;
|
||
ps.minLifeTime = 1.0; ps.maxLifeTime = 2.5;
|
||
ps.emitRate = 0; // пока выключен
|
||
ps.gravity = new Vector3(0, -8, 0);
|
||
ps.direction1 = new Vector3(-3, 5, -3);
|
||
ps.direction2 = new Vector3(3, 8, 3);
|
||
ps.minAngularSpeed = -3; ps.maxAngularSpeed = 3;
|
||
ps.minEmitPower = 1; ps.maxEmitPower = 3;
|
||
ps.updateSpeed = 0.02;
|
||
ps.start(); // активен но без эмиссии
|
||
this._particles = ps;
|
||
}
|
||
|
||
/** Включить конфетти на ~1.5 секунды. Вызывает уровень при пересечении финиша. */
|
||
celebrate() {
|
||
if (this._celebrated || !this._particles) return;
|
||
this._celebrated = true;
|
||
this._particles.emitRate = 250;
|
||
setTimeout(() => {
|
||
try { this._particles.emitRate = 0; } catch (e) {}
|
||
}, 1500);
|
||
}
|
||
|
||
dispose() {
|
||
try {
|
||
if (this._onBeforeRender && this.scene) {
|
||
this.scene.onBeforeRenderObservable.removeCallback(this._onBeforeRender);
|
||
this._onBeforeRender = null;
|
||
}
|
||
} catch (e) {}
|
||
if (this._handle && this._handle.dispose) try { this._handle.dispose(); } catch (e) {}
|
||
if (this._particles) try { this._particles.dispose(); } catch (e) {}
|
||
if (this._particleEmitter) try { this._particleEmitter.dispose(); } catch (e) {}
|
||
if (this._hiddenFinishCylinder) try { this._hiddenFinishCylinder.setEnabled(true); } catch (e) {}
|
||
this._handle = null;
|
||
this._root = null;
|
||
this._particles = null;
|
||
this._particleEmitter = null;
|
||
this._hiddenFinishCylinder = null;
|
||
this.scene = null;
|
||
this._scene3d = null;
|
||
}
|
||
}
|