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)
This commit is contained in:
МИН 2026-05-27 23:41:10 +03:00
commit 31adbf151b
255 changed files with 116364 additions and 0 deletions

15
.editorconfig Normal file
View File

@ -0,0 +1,15 @@
root = true
[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.md]
trim_trailing_whitespace = false
[Makefile]
indent_style = tab

25
.env.example Normal file
View File

@ -0,0 +1,25 @@
# ============================================================================
# Переменные окружения rublox-studio
# ============================================================================
# Скопируй этот файл в .env (`cp .env.example .env`) и поправь.
# Дефолты указывают на публичный staging — студия заработает сразу.
# Базовый URL HTTP-API.
# Оставь пустым чтобы использовать vite-proxy в dev (http://localhost:5174 → staging).
VITE_API_BASE=
# (только dev) куда vite-proxy шлёт /api-* запросы.
VITE_API_PROXY_TARGET=https://dev-api.rublox.pro
# Colyseus realtime (мультиплеер).
VITE_REALTIME_HTTP=https://dev-api.rublox.pro/api-game
VITE_REALTIME_WS=wss://dev-api.rublox.pro/api-game
# Главный сайт Рублокса и плеер.
VITE_RUBLOX_HOME=https://rublox.pro/app
VITE_PLAYER_URL=https://player.rublox.pro
# Standalone-режим — открывает пустой редактор без бэкенда (для первого запуска).
# Save-кнопка отключена. Все API-вызовы возвращают моки.
# Значения: "true" | "false"
VITE_STANDALONE=false

45
.eslintrc.json Normal file
View File

@ -0,0 +1,45 @@
{
"root": true,
"env": {
"browser": true,
"es2022": true,
"node": true,
"worker": true
},
"parserOptions": {
"ecmaVersion": "latest",
"sourceType": "module",
"ecmaFeatures": {
"jsx": true
}
},
"settings": {
"react": {
"version": "detect"
}
},
"extends": [
"eslint:recommended",
"plugin:react/recommended",
"plugin:react-hooks/recommended"
],
"rules": {
"no-unused-vars": ["warn", { "argsIgnorePattern": "^_", "varsIgnorePattern": "^_" }],
"no-console": "off",
"react/prop-types": "off",
"react/react-in-jsx-scope": "off",
"react/display-name": "off",
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "warn",
"no-eval": "error",
"no-new-func": "error",
"no-implied-eval": "error"
},
"ignorePatterns": [
"build/",
"dist/",
"node_modules/",
"public/kubikon-assets/",
"*.config.js"
]
}

View File

@ -0,0 +1,47 @@
---
name: 🐛 Сообщить о баге
about: Что-то работает не так как должно
title: '[BUG] '
labels: bug
assignees: ''
---
## Описание
<!-- Опиши проблему в 1-2 предложениях -->
## Шаги для воспроизведения
1.
2.
3.
## Ожидаемое поведение
<!-- Что должно было произойти -->
## Фактическое поведение
<!-- Что произошло на самом деле -->
## Скриншоты / видео
<!-- Если визуальный баг — приложи -->
## Окружение
- ОС:
- Браузер + версия:
- Node.js + npm версия:
- Git-ветка / коммит:
- Окружение: [ ] localhost [ ] staging [ ] prod
## Логи / стек-трейс
```
вставь сюда
```
## Дополнительный контекст
<!-- Любая полезная информация: связанные issue, попытки решения и т.д. -->

View File

@ -0,0 +1,37 @@
---
name: 💡 Предложить фичу
about: Идея новой функциональности или улучшения
title: '[FEATURE] '
labels: enhancement
assignees: ''
---
## Проблема, которую решает фича
<!-- Какая боль / неудобство существует сейчас? -->
## Предлагаемое решение
<!-- Как должно работать -->
## Альтернативы, которые рассматривал
<!-- Если думал о других вариантах — опиши -->
## Mockup / схема (опционально)
<!-- Если есть набросок UI или диаграмма архитектуры — приложи -->
## Влияние на другие части системы
- [ ] Изменяет API
- [ ] Требует миграции БД
- [ ] Меняет UX-flow существующих фич
- [ ] Требует изменений в плеере / студии / минке
## Готов реализовать сам?
- [ ] Да, открою PR
- [ ] Нет, прошу команду
## Дополнительный контекст

View File

@ -0,0 +1,45 @@
---
name: 🔒 Уязвимость безопасности (ПРИВАТНО)
about: НЕ публикуй уязвимости здесь — пиши на security@rublox.pro
title: '[SECURITY] Не публикуй детали публично'
labels: security
assignees: ''
---
## ⚠️ СТОП
**Если ты нашёл уязвимость безопасности — НЕ публикуй детали в публичном issue.**
Уязвимости в open-source проекте могут быть использованы злоумышленниками против работающего prod-сервиса до того, как мы успеем выпустить патч.
## Куда писать
📧 Email: **security@rublox.pro** (читает только Лид)
В письме укажи:
1. Тип уязвимости (XSS, SQL-injection, RCE, IDOR, auth-bypass, etc.)
2. Шаги воспроизведения
3. Уровень воздействия (что злоумышленник может сделать)
4. Твой контакт для уточнений
5. Хочешь ли ты публичную благодарность после фикса (Hall of Fame)
## Что ты получишь в ответ
- Подтверждение получения — в течение 24 часов
- Оценка серьёзности и план исправления — в течение 3 рабочих дней
- Уведомление о выпуске патча
- Публичное упоминание в CHANGELOG (если хочешь)
- Для критических уязвимостей — символическое вознаграждение (по договорённости)
## Что НЕ считать уязвимостью
- Отсутствие rate-limit на публичных эндпоинтах документации
- Mising HSTS / CSP headers (мы знаем, работаем над этим)
- Self-XSS (требует чтобы юзер сам выполнил JS у себя в DevTools)
- Уязвимости в third-party библиотеках (репортить туда напрямую)
---
## Если ты ошибся и это НЕ безопасность
Закрой этот issue и открой новый по шаблону **🐛 Сообщить о баге** или **💡 Предложить фичу**.

41
.gitignore vendored Normal file
View File

@ -0,0 +1,41 @@
node_modules/
dist/
dist-ssr/
build/
# Environment (НИКОГДА не коммитить .env, только .env.example)
.env
.env.local
.env.*.local
*.local
# Editor
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# Локальные заметки и приватные deploy-скрипты
disaster-recovery/
*.local.md
NOTES.md
# Большие бинарные ассеты (93 МБ GLB/PNG/MP3) — раздаются отдельно через release.
public/kubikon-assets/
# OS
Thumbs.db

12
.prettierignore Normal file
View File

@ -0,0 +1,12 @@
build/
dist/
node_modules/
public/kubikon-assets/
package-lock.json
*.glb
*.png
*.jpg
*.jpeg
*.mp3
*.wav
*.ico

10
.prettierrc Normal file
View File

@ -0,0 +1,10 @@
{
"semi": true,
"singleQuote": true,
"trailingComma": "all",
"printWidth": 100,
"tabWidth": 2,
"bracketSpacing": true,
"arrowParens": "always",
"endOfLine": "lf"
}

165
ARCHITECTURE.md Normal file
View File

@ -0,0 +1,165 @@
# Архитектура студии Рублокса
Как редактор превращает действия мыши в воксельный мир, GLB-модели, скрипты на JS — и сохраняет в JSON для запуска в плеере. Чтение ~10 минут.
## Общий поток (открытие проекта)
```
URL = /edit/<gameId>
AuthProvider читает JWT из localStorage
KubikonEditor.jsx (~3500 строк) главный контейнер, useParams().id = projectId
├── GET /api-storys/kubikon3d/projects/{id} → project_data (JSON)
BabylonScene.create() создание Babylon engine, scene, lights, skybox
GameRuntime.loadProject() парсит project_data, спавнит:
│ - BlockManager.placeBlock() × N (воксели)
│ - ModelManager.spawnModel() × N (GLB Kenney)
│ - DecoManager.placeDeco() × N (трава, камни)
│ - PrimitiveManager.add() × N (box/sphere/cylinder)
│ - PlayerController.spawn() (камера-«редактор»)
ОСНОВНОЙ UI на сцене:
│ - TopRibbon (тулбар)
│ - HierarchyPanel (дерево объектов слева)
│ - InspectorPanel (свойства справа)
│ - TerrainPanel (кисти ландшафта)
│ - ScriptEditor (Monaco)
│ - ToolboxModal (выбор моделей)
│ - GuiOverlay/Hotbar (HUD режима игры)
ЦИКЛ РЕДАКТИРОВАНИЯ юзер:
- кликает мышью → SelectionManager
- drag-and-drop → GizmoManager
- переключает кисти → TerrainManager
- пишет скрипт → ScriptEditor + Babylon-эмулятор
- жмёт «Сохранить» → POST /kubikon3d/projects/:id
- жмёт «Тест» → embedded preview-player
```
## Главные модули редактора
### `editor/KubikonEditor.jsx` (3452 строки)
Корневой компонент. Хранит ссылки на scene, engine, runtime, managers. Обрабатывает горячие клавиши, undo/redo через `HistoryManager`. Обвязывает все панели вокруг `<canvas>`.
### `editor/engine/` (66 файлов, ~28к строк)
Самодостаточный Babylon-движок. Точно такой же как в плеере, но с дополнительными редакторскими функциями:
| Менеджер | Файл | Что делает |
|---|---|---|
| `BabylonScene` | `BabylonScene.js` | Wrapper над Engine+Scene, освещение |
| `GameRuntime` | `GameRuntime.js` | Оркестратор: loadProject/start/pause/dispose |
| `PlayerController` | `PlayerController.js` | R15-персонаж, камеры FPV/TPV, контролы |
| `BlockManager` | `BlockManager.js` | Воксельные блоки uint16-сетка |
| `TerrainVoxelBuilder` | `terrain/*` | Greedy meshing для чанков |
| `ModelManager` | `ModelManager.js` | Загрузка GLB через AssetContainer + кеш |
| `DecoManager` | `DecoManager.js` | ThinInstances для тысяч пропсов |
| `PrimitiveManager` | `PrimitiveManager.js` | Box/sphere/cylinder примитивы |
| `SelectionManager` | `SelectionManager.js` | Клик → выделение → highlight |
| `GizmoManager` | `GizmoManager.js` | Стрелки перемещения/вращения/масштаба |
| `HistoryManager` | `HistoryManager.js` | Undo/redo стек |
| `ScriptSandbox` | `scripts/ScriptSandbox.js` | Запуск user JS в Web Worker |
| `MultiplayerSync` | `multiplayer/MultiplayerSync.js` | Colyseus 0.16 клиент |
| `Audio/Weapon/Zombie/Npc/Dynamics` | `*Manager.js` | Игровые менеджеры |
### `editor/engine/scripts/ScriptSandbox*.js`
Безопасный JS-runtime для пользовательских скриптов. Запускается в Web Worker, без доступа к window/document/fetch. Через postMessage-мост даёт API:
```js
game.onTick((dt) => { ... });
game.onKey('space', () => { player.jump(); });
scene.findOne('Cube1').rotateY(0.1);
ui.set({ score: 42 });
```
### `editor/engine/types/`
TypeScript-определения для Monaco-автокомплита. Когда юзер пишет скрипт — IDE показывает все доступные методы.
## UI-панели
```
+----------------------------------------------------------+
| TopRibbon: Файл | Правка | Тест | Опубликовать | Помощь |
+--------+-----------------------------------+-------------+
| Hierar | | Inspector |
| chyPan | | Panel |
| el | <canvas WebGL> | (свойства |
| | | выделен- |
| Дерево | | ного) |
| объек- | | |
| тов | | |
| | | |
| +-----------------------------------+ |
| | Hotbar / TerrainPanel (внизу) |
+--------+-------------------------------------------------+
```
## Скриптовый редактор (Monaco)
`editor/ScriptEditor.jsx` запускает Monaco (~5МБ vendor chunk, lazy-loaded). Подгружает .d.ts из `engine/types/` для автокомплита. При сохранении скрипт идёт в `ScriptSandbox`, который запускает Worker и шлёт результат обратно.
## Поток данных при сохранении
```
Save кнопка → собрать снимок:
- blocks[] (uint16 array → RLE-compress → base64)
- models[] {id, type, x, y, z, rx, ry, rz}
- primitives[] {id, type, color, size}
- deco[] {type, instances[]}
- script.code (текст из Monaco)
- settings {sky, fog, multiplayer, isGd}
- spawnPoint {x, y, z}
→ JSON.stringify
→ PUT /api-storys/kubikon3d/projects/<id> { project_data: "<json>" }
```
## Чего НЕТ в студии
- **Само воспроизведение игры в проде** — это [Плеер](https://git.rublox.pro/rublox/player) (отдельный домен `player.rublox.pro`). В студии есть `preview-player/` для теста «прямо здесь».
- **Лента / поиск опубликованных игр** — на главном сайте `rublox.pro/app`.
- **Админка модерации** — в `team.rublox.pro/moderator/*` (приватный фронт команды).
## С чего начать новую фичу
| Что хочешь добавить | Начни здесь |
|---|---|
| Новый тип блока | `editor/engine/CONST/blockTypes.js` |
| Новая декор-фабрика | `admin-preview/gd<тип>/` (фабрика) + регистрация в DecoManager |
| Новое API скриптов | `editor/engine/scripts/ScriptSandboxAPI.js` + .d.ts в `engine/types/` |
| Новая GD-механика | `editor/engine/Gd<тип>.js` + регистрация в `GdGameModeRegistry.js` |
| Новый UI-блок инспектора | `editor/InspectorPanel.jsx` |
| Новая кисть ландшафта | `editor/TerrainPanel.jsx` + `TerrainManager.brush*` |
## Производительность
Главные узкие места (что смотреть первым делом):
1. **`game.ui.set()` каждый кадр** — React setState 60Hz убивает FPS. Дросселируй 250мс + diff.
2. **`scene.findOne()` на старте скрипта** — sceneSnapshot приходит через rAF. Звать в `onTick` или setTimeout(0).
3. **`blockMaterialDirtyMechanism=true`** в Babylon — НЕ включать (ломает рендер новых мешей).
4. **GLB через `SceneLoader` вместо `AssetContainer`** — течёт материалами.
5. **Monaco-instance создаётся повторно при ремаунте** — закешируй ссылку.
6. **Воксельный rebuild на каждый блок** — батчить через requestIdleCallback.
## Связь со студией / плеером / минкой
- **Бэкенд один на всех**`api-storys` микросервис (см. `disaster-recovery/` в приватном репо мейнтейнера).
- **Авторизация общая** — JWT с rublox.pro и player.rublox.pro работают здесь.
- **API-контракт описан в `api/Kubikon3DService.js`** — все эндпоинты `/kubikon3d/*`.
## Лицензионные заметки для контрибьюторов
Контрибьютя, ты соглашаешься лицензировать свои изменения под AGPL-3.0 И предоставляешь мейнтейнеру неисключительную безотзывную лицензию на сублицензирование (см. [CLA.md](./CLA.md)). Это нужно чтобы проект мог продавать коммерческие лицензии корпорациям.

37
CHANGELOG.md Normal file
View File

@ -0,0 +1,37 @@
# Changelog
Все значимые изменения документируются здесь.
Формат основан на [Keep a Changelog](https://keepachangelog.com/ru/1.1.0/).
## [Unreleased]
### Добавлено
- Open-source релиз: двойная лицензия AGPL-3.0 + Коммерческая.
- Выделен из `minecraftia-school.ru/Фронтенд/React/src/pages/KubikonEditor`
и связанных папок (KubikonPlayer, KubikonStudio, GdShop, AdminPreview/gd*).
- Перевод с Create React App на Vite 5 (быстрая dev-сборка, HMR).
- Standalone-режим (`VITE_STANDALONE=true`) — открывает пустой редактор без бэкенда.
- Конфигурируемый бэкенд через `VITE_API_BASE`, `VITE_RUBLOX_HOME`, `VITE_PLAYER_URL`.
- Свой роутер с 25+ роутами (раньше встраивался в Menu.jsx минки).
- Лёгкие версии AuthContext / SanctionsContext без зависимости от UserService минки.
- Шаблоны Issue и PR в `.gitea/`.
- `CONTRIBUTING.md`, `SECURITY.md`, `ARCHITECTURE.md`, `CLA.md`.
- ESLint + Prettier + EditorConfig.
### Удалено
- Все импорты из minecraftia (`API/`, `context/`, `components/UI/`) → заменены на локальные.
- Захардкоженные URL `minecraftia-school.ru` (все теперь через env).
- Внутренние IP серверов (`85.175.x.x`, `192.168.x.x`) из комментариев.
- Admin-эндпоинты из `Kubikon3DService.js` — вынесены в приватный репо.
- `AdminKubikonModeration.jsx` (модерация контента) — остаётся в `team.rublox.pro`.
### Безопасность
- Чистый `git init` (без истории минки, которая содержала credentials).
- Pre-commit hook блокирует пуш файлов с паттернами секретов.
## [0.1.0] — начальный приватный билд (до публикации)
- CRA + React 18 + Babylon 7.54.3 базовая сборка (в составе minecraftia).
- Редактор воксельного ландшафта, GLB-моделей, скриптов на JS.
- 30+ Geometry Dash гейммодов.

134
CLA.md Normal file
View File

@ -0,0 +1,134 @@
# Contributor License Agreement (CLA)
**Лицензионное соглашение с контрибьютором**
Версия: 1.0
Дата вступления в силу: 2026-05-27
---
## Преамбула
Спасибо за желание внести вклад в проекты Рублокса (rublox-player, rublox-studio и связанные репозитории).
Чтобы правообладатель проекта мог использовать ваш вклад без юридических ограничений (включая возможность распространять проект под двойной лицензией AGPL-3.0 + Commercial), необходимо подписать настоящее CLA.
Это стандартная практика крупных open-source проектов (Apache Foundation, Google, Microsoft, Notion и др.).
---
## Стороны
**Правообладатель:**
Индивидуальный предприниматель Иванкова Виктория Сергеевна
ОГРНИП: 322237500039230
ИНН: 233507286445
Адрес: Краснодарский край, г. Кореновск, ул. Мира 93
Email: maksimivankov26@yandex.ru
**Контрибьютор:**
Вы — физическое или юридическое лицо, подписывающее это соглашение через систему team.rublox.pro путём нажатия кнопки «Я ознакомился и согласен с CLA» либо комментарием `/sign-cla` в Gitea.
---
## 1. Определения
- **«Вклад»** (Contribution) — любой исходный код, документация, графика, конфигурация или иной материал, который вы отправляете в репозиторий правообладателя через Pull Request, патч, issue, комментарий или иным способом.
- **«Проект»** — программное обеспечение, репозитории и сопутствующие материалы, размещённые в организации `rublox` на git.rublox.pro и связанных платформах.
---
## 2. Передаваемые права
Подписывая это CLA, вы предоставляете правообладателю:
### 2.1. Лицензия на использование (Copyright License)
Неисключительную, бессрочную, безотзывную, всемирную, безвозмездную, сублицензируемую и передаваемую лицензию на:
- Воспроизведение, изменение, отображение, распространение вашего Вклада;
- Включение Вклада в производные работы;
- **Распространение Вклада под любой лицензией по выбору правообладателя, включая, но не ограничиваясь:**
- GNU Affero General Public License v3.0;
- Любую коммерческую (проприетарную) лицензию;
- Любую другую open-source лицензию (MIT, Apache 2.0 и т.д.).
### 2.2. Лицензия на патенты (Patent License)
Если ваш Вклад содержит реализацию запатентованной вами технологии — вы предоставляете правообладателю и пользователям проекта неисключительную, бессрочную, безотзывную, безвозмездную лицензию на использование, изготовление и продажу этих патентов в составе проекта.
---
## 3. Ваши гарантии
Подписывая CLA, вы заявляете и гарантируете, что:
1. **Вам исполнилось 18 лет** (подписывать CLA от лица несовершеннолетнего могут только родители/законные представители, и таким контрибьюциям мы предпочитаем отказывать).
2. Вы имеете полное право предоставлять перечисленные в разделе 2 лицензии.
3. Ваш Вклад является вашей оригинальной работой ИЛИ корректно атрибутирован к источнику и совместим с лицензией проекта.
4. Ваш Вклад не нарушает права третьих лиц (включая авторские права, патенты, торговые знаки, коммерческие тайны).
5. Если вы работаете по найму или контракту и ваш работодатель имеет права на ваши разработки — вы получили разрешение работодателя на этот вклад. При необходимости работодатель подписывает [Corporate CLA](#corporate-cla) отдельно.
6. Вы понимаете, что после слияния (merge) ваш Вклад становится частью проекта и не может быть отозван в одностороннем порядке.
---
## 4. Отказ от гарантий
Вы предоставляете Вклад «как есть», без каких-либо явных или подразумеваемых гарантий, включая, но не ограничиваясь, гарантии товарного качества, пригодности для конкретной цели и ненарушения прав.
---
## 5. Применимое право
Это CLA регулируется законодательством Российской Федерации. Все споры подлежат разрешению в судах по месту нахождения правообладателя (Краснодарский край).
---
## 6. Версионирование CLA
Правообладатель оставляет за собой право обновлять текст CLA. При публикации новой версии действующие контрибьюторы будут уведомлены и должны подписать новую версию для дальнейшего внесения вкладов. Уже принятые Вклады остаются под действовавшей на момент их merge версии CLA.
Текущая версия и история изменений: https://git.rublox.pro/rublox/legal/blob/main/CLA.md
---
## 7. Corporate CLA
Если вы работаете в компании и она имеет права на ваши разработки, необходимо отдельное соглашение между компанией и правообладателем. Свяжитесь по адресу `maksimivankov26@yandex.ru` с темой "Corporate CLA Request".
---
## Подписание
Подписать CLA можно одним из способов:
### Способ 1: Через team.rublox.pro
1. Войдите в https://team.rublox.pro
2. Откройте `/developer/cla`
3. Внимательно прочитайте текст
4. Нажмите «Я ознакомился и согласен подписать CLA версии 1.0»
5. Подпись с указанием вашего ID, IP, времени и user-agent сохраняется в базе
### Способ 2: Через комментарий в PR
1. Откройте свой Pull Request в Gitea
2. Напишите комментарий: `/sign-cla`
3. Бот проверит вашу учётку в team.rublox.pro и зарегистрирует подпись
### Способ 3: По email (для Corporate CLA)
Напишите на `maksimivankov26@yandex.ru` с темой "CLA Request — [Ваше имя/Компания]" и подписанным PDF-сканом.
---
## Контрольный список перед подписанием
Перед нажатием «Я согласен» убедитесь, что:
- [ ] Вы прочитали и поняли текст CLA полностью
- [ ] Вам исполнилось 18 лет
- [ ] У вас есть права на код, который вы планируете контрибьютить
- [ ] Если у вас есть работодатель/контракт — вы проверили, что можете передавать права на свой код
- [ ] Вы понимаете, что AGPL-3.0 — copyleft-лицензия, но правообладатель может распространять ваш вклад также и под коммерческой лицензией
---
*Этот документ адаптирован на основе Apache Software Foundation Individual Contributor License Agreement v2.0 и приведён в соответствие с законодательством Российской Федерации.*

150
CONTRIBUTING.md Normal file
View File

@ -0,0 +1,150 @@
# Контрибьютинг в студию Рублокса
Спасибо за интерес! Всё что нужно знать.
## Перед первым PR
1. **Подпиши [CLA](./CLA.md)** — открой `https://team.rublox.pro/developer/cla` (нужна роль `developer` — попроси у мейнтейнера) ИЛИ комментарий `/sign-cla` на PR.
2. **Заведи Gitea-аккаунт** через OAuth-привязку к team.rublox.pro.
3. **Добавь SSH-ключ** в профиль Gitea (иначе пуш по HTTPS + пароль).
## Workflow
```bash
# 1. Клонируем (или форкаем — если есть доступ, можно сразу пушить в feature-ветку)
git clone ssh://git@git.rublox.pro:2222/rublox/studio.git
cd player
npm install
cp .env.example .env
# 2. Создаём feature-ветку
git checkout -b feature/короткое-описание
# 3. Делаем изменения
npm run dev # проверяем локально
npm run lint # ESLint
npm run format # Prettier
# 4. Коммитим (Conventional Commits)
git commit -m "feat: добавить анимацию прыжка для GdBall"
# Другие префиксы: fix:, chore:, docs:, refactor:, test:, perf:, ci:
# 5. Пушим и открываем PR
git push origin feature/короткое-описание
# Открыть PR через https://git.rublox.pro/rublox/studio
```
## Стиль кода
Используем **Prettier** (авто-формат) + **ESLint** (линт). Конфиги закоммичены в `.prettierrc` и `.eslintrc.json`.
Главные правила:
- 2-space отступ
- Одинарные кавычки (`'foo'`, не `"foo"`)
- Точки с запятой обязательны
- Trailing comma в многострочных литералах
- Ширина строки 100 символов
- Никаких `eval`, `new Function`, `innerHTML = ...`
- React: hooks-правила обязательны (`react-hooks/rules-of-hooks`)
Запускай перед коммитом:
```bash
npm run format # авто-фикс большинства
npm run lint # предупреждения для остального
```
Pre-commit-хук (Husky, если установлен) заблокирует если Prettier-чек провален.
## Именование веток
| Префикс | Использовать для |
|---|---|
| `feature/...` | Новые фичи |
| `fix/...` | Багфиксы |
| `chore/...` | Рефакторинг, зависимости, тулинг |
| `docs/...` | Только документация |
| `perf/...` | Улучшения производительности |
## Commit-сообщения (Conventional Commits)
```
<тип>: <короткое описание>
[опционально тело]
[опционально футер: BREAKING CHANGE / Closes #N]
```
Типы: `feat`, `fix`, `chore`, `docs`, `refactor`, `test`, `perf`, `ci`, `style`.
**Хорошо:**
- `feat: добавить WaveMode в GdGameModeRegistry`
- `fix: ScriptSandbox падает при таймауте Worker`
- `perf: дросселировать ui.set() до 250мс с diff-проверкой`
**Плохо:**
- `update code`
- `wip`
- `пофиксил всякое`
## Шаблон PR
При открытии PR заполни:
```markdown
## Что
(1-2 предложения: что делает этот PR?)
## Зачем
(мотивация: репорт бага, фича-реквест, связанный issue #N)
## Как протестить
(воспроизводимые шаги для ревьюера)
## Скриншоты / видео (если UI-изменение)
## Чек-лист
- [ ] `npm run lint` проходит
- [ ] `npm run format:check` проходит
- [ ] `npm run build` собирается
- [ ] Протестил локально через `npm run dev`
- [ ] Обновил соответствующие доки (README / ARCHITECTURE / CHANGELOG)
- [ ] CLA подписан
```
## Процесс ревью
- Мейнтейнер репо (МИН) ревьюит каждый PR.
- Ожидай фидбек в течение **48 часов** (часто в тот же день).
- Маленькие PR (<300 строк) ревьюятся быстро. PR'ы >1000 строк попросят раздробить.
- После approval **мерджит мейнтейнер** (право merge только у него).
- После merge автоматический webhook запускает деплой в прод.
## Что не смерджим
- PR с новыми внешними зависимостями без обсуждения
- PR трогающие чувствительные пути (`engine/scripts/ScriptSandbox*.js`, `engine/multiplayer/*`) без security-ревью
- PR ломающие сборку или линт
- PR от контрибьюторов без подписанного CLA
- Массивные рефакторинги без заранее обсуждённого tracking-issue
## Что любим
- Багфиксы с воспроизводимыми тест-кейсами
- Улучшения производительности с before/after метриками
- Новые `engine/gd/Gd*.js` гейммоды
- Новые API для скриптов (в `ScriptSandboxAPI.js`)
- Улучшения документации (особенно с примерами)
## Безопасность
Нашёл уязвимость? **Не открывай публичный issue.** Пиши на `security@rublox.pro` напрямую. См. [SECURITY.md](./SECURITY.md).
## Вопросы?
- Открой issue с лейблом **Question**
- Или приходи в канал `#разработка` на https://team.rublox.pro/chat

62
COPYRIGHT.md Normal file
View File

@ -0,0 +1,62 @@
# Copyright
Copyright © 2026 Иванкова Виктория Сергеевна (ИП)
**Контактные данные правообладателя:**
- ИП Иванкова Виктория Сергеевна
- ОГРНИП: 322237500039230
- ИНН: 233507286445
- Адрес: Краснодарский край, г. Кореновск, ул. Мира 93
- Email: maksimivankov26@yandex.ru
---
## Лицензирование
Этот проект распространяется под **двойной лицензией**:
### 1. AGPL-3.0-or-later (для open-source использования)
См. файл [LICENSE](./LICENSE) — полный текст GNU Affero General Public License v3.0.
Кратко: вы можете свободно использовать, изменять и распространять этот код, при условии что:
- Производные работы и форки распространяются под той же AGPL-3.0 лицензией;
- Исходный код всех модификаций доступен пользователям, в том числе при использовании по сети (SaaS);
- Сохраняется указание авторства и копирайта.
### 2. Commercial License (для проприетарного использования)
Если вы хотите использовать этот код в проприетарном (закрытом) продукте, в коммерческом SaaS без раскрытия исходников модификаций, или иным образом несовместимым с AGPL — приобретите коммерческую лицензию.
См. [LICENSE-COMMERCIAL.md](./LICENSE-COMMERCIAL.md) для подробностей и контактов.
---
## Contributor License Agreement
Все контрибьюторы перед первым merge обязаны подписать [CLA](./CLA.md), передавая лицензионные права на свой вклад правообладателю. Это позволяет правообладателю выпускать продукт под обеими лицензиями (AGPL + Commercial).
---
## Используемые открытые библиотеки
Полный список зависимостей с лицензиями доступен в `package.json` каждого репозитория и через команду `npm ls --all --json`.
Основные библиотеки:
- **Babylon.js** — Apache 2.0
- **React** — MIT
- **Vite** — MIT
- **Colyseus** — MIT
- **Monaco Editor** — MIT
Этот проект **не модифицирует** код этих библиотек — только использует их через стандартные API. Условия их лицензий выполняются автоматически при использовании через npm.
---
## Активы (ассеты)
3D-модели и текстуры в папке `public/kubikon-assets/` либо:
- Созданы автором проекта (© Иванкова В.С., лицензируются по той же двойной лицензии),
- Либо взяты из открытых наборов с указанной авторской лицензией (Kenney Nature Kit — CC0 1.0).
Полный список ассетов с источниками — в `public/kubikon-assets/CREDITS.md` (создаётся при подготовке репо к публикации).

661
LICENSE Normal file
View File

@ -0,0 +1,661 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.

54
LICENSE-COMMERCIAL.md Normal file
View File

@ -0,0 +1,54 @@
# Commercial License
Этот проект распространяется под **двойной лицензией**:
1. AGPL-3.0-or-later (см. [LICENSE](./LICENSE)) — для open-source использования
2. **Commercial License** (этот документ) — для проприетарного использования
## Когда нужна Commercial License
AGPL-3.0 — copyleft-лицензия, которая требует:
- Открывать исходный код всех модификаций и форков;
- При использовании по сети (SaaS, web-сервис) — предоставлять пользователям полный исходник;
- Производные работы распространять под той же AGPL-3.0.
**Commercial License нужна, если вы хотите:**
- Использовать код в проприетарном продукте без открытия модификаций;
- Запустить SaaS-сервис на базе этого кода без публикации исходников;
- Распространять модифицированную версию под другой лицензией (например, MIT для своих клиентов);
- Получить дополнительные гарантии, индемнификацию или техническую поддержку;
- Интегрировать код в продукт с несовместимой open-source лицензией.
## Как получить
Напишите письмо на адрес правообладателя:
**Email:** maksimivankov26@yandex.ru
**Юрлицо:** ИП Иванкова Виктория Сергеевна (ОГРНИП 322237500039230)
**Тема письма:** "Commercial License Request — Rublox [player/studio]"
В письме укажите:
1. Название вашей компании / ИП / физлица;
2. Краткое описание продукта, в котором планируется использование;
3. Объём использования (количество пользователей, географическая зона);
4. Сроки внедрения.
В ответ вы получите коммерческое предложение с условиями и стоимостью лицензии. Стоимость зависит от объёма использования и типа продукта.
## Сроки ответа
- Первоначальный ответ — в течение 3 рабочих дней;
- Полное коммерческое предложение — в течение 10 рабочих дней с момента получения уточняющих данных.
## Что НЕ требует Commercial License
Вам **не нужна** Commercial License, если вы:
- Используете код для личного некоммерческого изучения;
- Используете в open-source проекте, совместимом с AGPL-3.0, и публикуете все модификации;
- Запускаете для внутреннего использования в своей компании **без предоставления доступа извне** (AGPL допускает это — обязательство по открытию кода возникает только при предоставлении сервиса внешним пользователям);
- Делаете контрибьюции в основной репозиторий — для этого нужен только [CLA](./CLA.md), бесплатно.
---
*Этот документ является публичной офертой на заключение лицензионного договора. Полные юридические условия фиксируются отдельным договором при покупке.*

169
README.md Normal file
View File

@ -0,0 +1,169 @@
# Студия Рублокса
Open-source веб-студия для создания игр Рублокса — платформы 3D-игр в стиле Roblox.
Построена на **Babylon.js 7** + **React 18** + **Vite 5** + **Monaco Editor**. Делай вокселные карты, ставь модели, пиши скрипты на JS в Web Worker-песочнице. Опубликованные игры открываются в [Плеере Рублокса](https://git.rublox.pro/rublox/player) (отдельный репозиторий).
[![Лицензия: AGPL-3.0](https://img.shields.io/badge/%D0%9B%D0%B8%D1%86%D0%B5%D0%BD%D0%B7%D0%B8%D1%8F-AGPL--3.0-blue.svg)](./LICENSE)
[![Коммерческая лицензия](https://img.shields.io/badge/%D0%9A%D0%BE%D0%BC%D0%BC%D0%B5%D1%80%D1%87%D0%B5%D1%81%D0%BA%D0%B0%D1%8F-%D0%B4%D0%BE%D1%81%D1%82%D1%83%D0%BF%D0%BD%D0%B0-green.svg)](./LICENSE-COMMERCIAL.md)
---
## Быстрый старт (5 минут)
**Требования:**
- Node.js 18+ и npm 10+
- Браузер с WebGL2 (Chrome 90+ / Firefox 90+ / Safari 15+)
- 8 ГБ свободной памяти (студия тяжелее плеера — Monaco + большой ландшафт)
**Установка:**
```bash
git clone ssh://git@git.rublox.pro:2222/rublox/studio.git
cd studio
npm install
cp .env.example .env # дефолты указывают на публичный staging — работает сразу
npm run dev
```
Открой `http://localhost:5174/edit/<gameId>` — например `http://localhost:5174/edit/265`.
**Без JWT** ты увидишь экран авторизации. Чтобы обойти:
```js
// В DevTools браузера:
localStorage.setItem('Authorization', '<твой-jwt-с-rublox.pro>');
location.reload();
```
---
## Ассеты (GLB-модели / текстуры / звуки)
Папка `public/kubikon-assets/` (~93 МБ) **не хранится в git** чтобы репозиторий был лёгким. Скачать отдельно:
```bash
curl -L -o assets.zip https://git.rublox.pro/rublox/studio/releases/download/latest/kubikon-assets.zip
unzip assets.zip -d public/
```
Без ассетов студия запустится, но ландшафт будет пустой (нет текстур блоков).
---
## Standalone-режим (без бэкенда)
Хочешь попробовать без авторизации?
```bash
echo "VITE_STANDALONE=true" >> .env
npm run dev
```
Открой `http://localhost:5174/edit/sample` — пустой редактор, авторизация-мок, save отключён.
---
## Структура проекта
```
src/
├── editor/ # Главный редактор (KubikonEditor) ~37к строк
│ ├── engine/ # Babylon-движок — 66 файлов, ~28к строк
│ │ # BabylonScene, GameRuntime, MultiplayerSync,
│ │ # PlayerController, BlockManager, TerrainVoxelBuilder,
│ │ # ModelManager, DecoManager, ScriptSandbox,
│ │ # 30+ GD-гейммодов
│ └── *Panel.jsx, *Modal.jsx — UI редактора (Hierarchy/Inspector/Terrain/ScriptEditor)
├── preview-player/ # Встроенный мини-плеер для теста («Play»-кнопка в редакторе)
├── community/ # Витрина/лента: KubikonFeed, KubikonStudio, KubikonDocs,
│ # KubikonLearn, KubikonGamePage, KubikonHeroKit, RealtimeTest
├── gd-shop/ # Geometry Dash: GdMenu, GdShop, GdRules, GdCoverArt, GdPlayWrapper
├── admin-preview/ # Превью-каталоги для дизайнеров: GdSkins, GdBosses,
│ # GdSpikes, GdArches, GdFinishes, GdPortals, GdMusic,
│ # GdSfx, GdShipSkins, GdDeco
├── components/ # Общий UI: KubikonBugReport, KubikonLeaderboard,
│ # KubikonPerfOverlay, EmailConfirmNotice, PleeseReg,
│ # RublocsLogo
├── api/ # API.js (конфиг) + Kubikon3DService.js + KubikonCadService
│ # + SanctionsService + playTicket
├── auth/ # AuthContext, SanctionsContext (лёгкие версии,
│ # без UserService от минки)
├── hooks/ # useDeviceType
├── utils/ # kubikonTheme, kubikonTime
├── App.jsx # Router + Suspense (15+ роутов)
├── main.jsx
├── index.css
└── LoadingScreen.jsx
```
Подробнее об архитектуре — в [ARCHITECTURE.md](./ARCHITECTURE.md).
---
## Переменные окружения
Скопируй `.env.example` в `.env` и поправь:
| Переменная | Дефолт | Что означает |
|---|---|---|
| `VITE_API_BASE` | _(пустая)_ | Базовый URL HTTP-API. Пустой = vite-proxy (для dev). |
| `VITE_API_PROXY_TARGET` | `https://dev-api.rublox.pro` | (dev) куда vite-proxy шлёт `/api-*`. |
| `VITE_REALTIME_HTTP` | `https://dev-api.rublox.pro/api-game` | Colyseus HTTP. |
| `VITE_REALTIME_WS` | `wss://dev-api.rublox.pro/api-game` | Colyseus WS. |
| `VITE_RUBLOX_HOME` | `https://rublox.pro/app` | Редирект на главный сайт если нет авторизации. |
| `VITE_PLAYER_URL` | `https://player.rublox.pro` | URL плеера (для кнопки «Тест в плеере»). |
| `VITE_STANDALONE` | `false` | Пропустить API, открыть пустой редактор. |
---
## Команды
```bash
npm run dev # Dev-сервер (vite, порт 5174)
npm run build # Прод-билд → build/
npm run preview # Превью прод-билда локально
npm run lint # ESLint
npm run format # Prettier (записать)
npm run format:check # Prettier (проверить)
```
---
## Контрибьютинг
Мы рады PR'ам! Перед первым:
1. Прочитай [CONTRIBUTING.md](./CONTRIBUTING.md) — стиль, ветки, шаблон PR.
2. Подпиши [CLA](./CLA.md) (через `https://team.rublox.pro/developer/cla` или комментарий `/sign-cla`).
3. Открывай issues через шаблоны в `.gitea/ISSUE_TEMPLATE/`.
**Быстрый цикл:**
```bash
git checkout -b feature/моя-фича
# ...пишешь код...
npm run lint && npm run format
git commit -m "feat: описание"
git push origin feature/моя-фича
```
---
## Лицензия
Двойная лицензия:
- **[AGPL-3.0-or-later](./LICENSE)** — для open-source использования. Форки и производные работы (включая SaaS) обязаны публиковать свой исходник под той же лицензией.
- **[Коммерческая лицензия](./LICENSE-COMMERCIAL.md)** — для проприетарных продуктов. Контакт: `maksimivankov26@yandex.ru`.
Все контрибьюторы обязаны подписать [CLA](./CLA.md) перед первым merge.
© 2026 Иванкова Виктория Сергеевна (ИП). Все права защищены.
---
## Ссылки
- Главный сайт: https://rublox.pro
- Студия (демо): https://studio.rublox.pro
- Плеер (отдельный репо): https://git.rublox.pro/rublox/player
- Issues и PR: https://git.rublox.pro/rublox/studio
- Безопасность: [SECURITY.md](./SECURITY.md)

64
SECURITY.md Normal file
View File

@ -0,0 +1,64 @@
# Политика безопасности
## Поддерживаемые версии
Только ветка `main` получает security-обновления. Тегированные релизы (`v0.x`) — best-effort.
## Сообщение об уязвимости
**НЕ открывай публичный issue для уязвимостей безопасности.**
Уязвимости в этой студии могут напрямую повлиять на наш продакшен `studio.rublox.pro` и созданные через неё игры. Публичное раскрытие до выпуска патча может привести к реальному вреду реальным пользователям (включая несовершеннолетних, играющих в опубликованные игры).
Пиши на: **`security@rublox.pro`** (читает только мейнтейнер проекта)
Включи в письмо:
1. Описание проблемы
2. Шаги воспроизведения (или proof-of-concept)
3. Затронутые версии / файлы
4. Твою оценку воздействия (XSS / RCE / IDOR / побег из песочницы / и т.д.)
5. Твой контакт для уточнений
6. Хочешь ли публичное упоминание после фикса (добавим в Hall of Fame в CHANGELOG)
## Что ожидать
| Шаг | Сроки |
|---|---|
| Подтверждение получения | в течение 24 часов |
| Оценка серьёзности | в течение 3 рабочих дней |
| Фикс в приватной ветке | в течение 7 дней для критичных |
| Публичный релиз патча | когда готов, с благодарностью |
## Особенно ценные находки
Следующие классы багов **высоко ценятся** (потому что могут скомпрометировать пользователей):
- **Побег из script-песочницы** (`engine/scripts/ScriptSandbox*.js`) — скрипт юзера получает доступ к `window`, `document`, `fetch`, `localStorage`, либо угоняет сессии других игроков.
- **XSS в рендере контента игр** — названия игр, чат-сообщения, тексты комментов рендерятся как HTML.
- **Инъекция мультиплеер-сообщений** — кастомные Colyseus-сообщения роняют других клиентов или выполняют код атакующего.
- **Утечка auth-токена** — JWT или ticket появляется в URL, query-string, console-логах, error-страницах или analytics-событиях.
- **Инъекция в asset-URL** — префиксы `url:...` (модели от дизайнеров) загружают произвольные файлы вне нашего asset-бакета.
## Что НЕ является уязвимостью
(Не теряй своё и наше время — эти отчёты отклоняются.)
- Отсутствующий `X-Frame-Options` или `Content-Security-Policy` (знаем, работаем над этим на стороне сервера).
- Self-XSS (юзер сам вставляет JS в свой DevTools).
- Устаревшие `npm audit` warning'и без рабочего эксплойта.
- Раскрытие информации о файлах в репо (это же open-source!).
- Отчёты от автоматических сканеров без ручной проверки.
## Награда
Мы маленький проект и не можем платить bounty, но за любую настоящую уязвимость:
- Публичное упоминание в `CHANGELOG.md` (если хочешь)
- Включение в раздел «Hall of Fame» этого файла
- Личное спасибо от мейнтейнера
- За high-impact-репорты: разовый денежный подарок по усмотрению мейнтейнера (редко, но возможно)
## Hall of Fame
ока пусто — стань первым!_

15
index.html Normal file
View File

@ -0,0 +1,15 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" />
<meta name="description" content="Студия Рублокса — создавай 3D-игры в браузере" />
<meta name="theme-color" content="#3357ff" />
<title>Студия Рублокса</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

68
package.json Normal file
View File

@ -0,0 +1,68 @@
{
"name": "rublox-studio",
"version": "1.0.0",
"type": "module",
"description": "Open-source веб-студия для создания игр Рублокса — Babylon.js 7 + React 18 + Vite 5. Скриптинг на JS в Web Worker-песочнице.",
"keywords": [
"rublox",
"game-editor",
"babylonjs",
"3d-editor",
"voxel-editor",
"react",
"vite",
"geometry-dash",
"monaco"
],
"homepage": "https://rublox.pro",
"bugs": {
"url": "https://git.rublox.pro/rublox/studio/issues",
"email": "security@rublox.pro"
},
"repository": {
"type": "git",
"url": "git+ssh://git@git.rublox.pro:2222/rublox/studio.git"
},
"license": "AGPL-3.0-or-later",
"author": {
"name": "Иванкова Виктория Сергеевна (ИП)",
"email": "maksimivankov26@yandex.ru",
"url": "https://rublox.pro"
},
"scripts": {
"start": "vite",
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"lint": "eslint . --ext .js,.jsx --max-warnings 200",
"format": "prettier --write \"src/**/*.{js,jsx,json,md,css}\"",
"format:check": "prettier --check \"src/**/*.{js,jsx,json,md,css}\""
},
"dependencies": {
"@babylonjs/core": "7.54.3",
"@babylonjs/loaders": "7.54.3",
"@monaco-editor/react": "^4.7.0",
"axios": "1.8.4",
"colyseus.js": "0.16.22",
"dompurify": "^3.0.0",
"html2canvas": "^1.4.0",
"jwt-decode": "4.0.0",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-router-dom": "7.4.0",
"socket.io-client": "^4.8.3"
},
"devDependencies": {
"@types/react": "18.3.12",
"@types/react-dom": "18.3.1",
"@vitejs/plugin-react": "4.3.3",
"eslint": "^8.57.0",
"eslint-plugin-react": "^7.34.0",
"eslint-plugin-react-hooks": "^4.6.0",
"prettier": "^3.2.5",
"vite": "5.4.10"
},
"engines": {
"node": ">=18"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 675 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 758 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 868 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 996 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 237 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 449 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 297 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 880 KiB

94
src/App.jsx Normal file
View File

@ -0,0 +1,94 @@
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { lazy, Suspense } from 'react';
import { AuthProvider } from './auth/AuthContext.jsx';
import { SanctionsProvider } from './auth/SanctionsContext.jsx';
import LoadingScreen from './LoadingScreen.jsx';
// Code-splitting: каждая большая страница в своём чанке.
const KubikonEditor = lazy(() => import('./editor/KubikonEditor.jsx'));
const KubikonPlayer = lazy(() => import('./preview-player/KubikonPlayer.jsx'));
const KubikonFeed = lazy(() => import('./community/KubikonFeed.jsx'));
const KubikonStudio = lazy(() => import('./community/KubikonStudio.jsx'));
const KubikonDocs = lazy(() => import('./community/KubikonDocs.jsx'));
const KubikonRules = lazy(() => import('./community/KubikonRules.jsx'));
const KubikonHeroKit = lazy(() => import('./community/KubikonHeroKit.jsx'));
const KubikonLearn = lazy(() => import('./community/KubikonLearn.jsx'));
const KubikonGamePage = lazy(() => import('./community/KubikonGamePage.jsx'));
const KubikonUserGames = lazy(() => import('./community/KubikonUserGames.jsx'));
const RealtimeTest = lazy(() => import('./community/RealtimeTest.jsx'));
// Geometry Dash sub-app
const GdShop = lazy(() => import('./gd-shop/GdShop.jsx'));
const GdMenu = lazy(() => import('./gd-shop/GdMenu.jsx'));
const GdRules = lazy(() => import('./gd-shop/GdRules.jsx'));
const GdCoverArt = lazy(() => import('./gd-shop/GdCoverArt.jsx'));
const GdPlayWrapper = lazy(() => import('./gd-shop/GdPlayWrapper.jsx'));
// Превью-роуты для команды дизайнеров (закрытые в проде по роли,
// в opensource доступны всем это просто галереи ассетов).
const GdBossesPreview = lazy(() => import('./admin-preview/GdBossesPreview.jsx'));
const GdSkinsPreview = lazy(() => import('./admin-preview/GdSkinsPreview.jsx'));
const GdSpikesPreview = lazy(() => import('./admin-preview/GdSpikesPreview.jsx'));
const GdArchesPreview = lazy(() => import('./admin-preview/GdArchesPreview.jsx'));
const GdFinishesPreview = lazy(() => import('./admin-preview/GdFinishesPreview.jsx'));
const GdPortalsPreview = lazy(() => import('./admin-preview/GdPortalsPreview.jsx'));
const GdMusicPreview = lazy(() => import('./admin-preview/GdMusicPreview.jsx'));
const GdSfxPreview = lazy(() => import('./admin-preview/GdSfxPreview.jsx'));
const GdShipSkinsPreview = lazy(() => import('./admin-preview/GdShipSkinsPreview.jsx'));
const GdDecoPreview = lazy(() => import('./admin-preview/GdDecoPreview.jsx'));
function FallbackLoader() {
return <LoadingScreen text="Загружаю модуль студии..." />;
}
export default function App() {
return (
<BrowserRouter>
<AuthProvider>
<SanctionsProvider>
<Suspense fallback={<FallbackLoader />}>
<Routes>
{/* Редактор и плеер (fullscreen) */}
<Route path="/edit/:id" element={<KubikonEditor />} />
<Route path="/play/:id" element={<KubikonPlayer />} />
<Route path="/game/:id" element={<KubikonGamePage />} />
{/* Лента, профиль, доки */}
<Route path="/" element={<KubikonStudio />} />
<Route path="/feed" element={<KubikonFeed />} />
<Route path="/docs" element={<KubikonDocs />} />
<Route path="/rules" element={<KubikonRules />} />
<Route path="/hero-kit" element={<KubikonHeroKit />} />
<Route path="/learn" element={<KubikonLearn />} />
<Route path="/learn/:articleId" element={<KubikonLearn />} />
<Route path="/user/:userId" element={<KubikonUserGames />} />
<Route path="/realtime-test" element={<RealtimeTest />} />
{/* Geometry Dash */}
<Route path="/gd" element={<GdMenu />} />
<Route path="/gd/shop" element={<GdShop />} />
<Route path="/gd/rules" element={<GdRules />} />
<Route path="/gd/cover-art" element={<GdCoverArt />} />
<Route path="/gd/play/:id" element={<GdPlayWrapper />} />
{/* Каталоги ассетов (для дизайнеров) */}
<Route path="/preview/gd/bosses" element={<GdBossesPreview />} />
<Route path="/preview/gd/skins" element={<GdSkinsPreview />} />
<Route path="/preview/gd/spikes" element={<GdSpikesPreview />} />
<Route path="/preview/gd/arches" element={<GdArchesPreview />} />
<Route path="/preview/gd/finishes" element={<GdFinishesPreview />} />
<Route path="/preview/gd/portals" element={<GdPortalsPreview />} />
<Route path="/preview/gd/music" element={<GdMusicPreview />} />
<Route path="/preview/gd/sfx" element={<GdSfxPreview />} />
<Route path="/preview/gd/ship-skins" element={<GdShipSkinsPreview />} />
<Route path="/preview/gd/deco" element={<GdDecoPreview />} />
{/* 404 → главная */}
<Route path="*" element={<KubikonStudio />} />
</Routes>
</Suspense>
</SanctionsProvider>
</AuthProvider>
</BrowserRouter>
);
}

36
src/LoadingScreen.jsx Normal file
View File

@ -0,0 +1,36 @@
export default function LoadingScreen({ text = 'Загрузка...', subText = '' }) {
return (
<div
style={{
position: 'fixed',
inset: 0,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
background: '#0e1117',
color: '#d8def0',
fontFamily: '-apple-system, system-ui, sans-serif',
gap: 16,
}}
>
<div
style={{
width: 64,
height: 64,
border: '4px solid #2c3344',
borderTopColor: '#3357ff',
borderRadius: '50%',
animation: 'rs-spin 1s linear infinite',
}}
/>
<div style={{ fontSize: 18, fontWeight: 600 }}>{text}</div>
{subText && (
<div style={{ fontSize: 13, color: '#8a93a4', maxWidth: 480, textAlign: 'center' }}>
{subText}
</div>
)}
<style>{`@keyframes rs-spin { to { transform: rotate(360deg); } }`}</style>
</div>
);
}

View File

@ -0,0 +1,216 @@
/**
* GdArchesPreview превью стартовых арок (10 эпох × 5).
*
* Маршрут: /admin-preview/gd-arches
*
* Юзер выбирает по одной арке на эпоху POST в kubikon3d_savegame
* (project_id=295, namespace='gd_arch_choices').
*
* Carousel-canvas с lazy IntersectionObserver иначе WebGL-контексты упрутся в лимит.
*/
import React, { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../../auth/AuthContext.jsx';
import { jwtDecode } from 'jwt-decode';
import axios from 'axios';
import { STORYS_addres } from '../../api/API';
import { Engine } from '@babylonjs/core/Engines/engine';
import { Scene } from '@babylonjs/core/scene';
import { ArcRotateCamera } from '@babylonjs/core/Cameras/arcRotateCamera';
import { HemisphericLight } from '@babylonjs/core/Lights/hemisphericLight';
import { DirectionalLight } from '@babylonjs/core/Lights/directionalLight';
import { Vector3, Color3, Color4 } from '@babylonjs/core/Maths/math';
import { MeshBuilder } from '@babylonjs/core/Meshes/meshBuilder';
import { StandardMaterial } from '@babylonjs/core/Materials/standardMaterial';
import { ARCH_CATALOG, EPOCH_INFO, getArchesByEpoch } from './gdArches/archFactories';
const CHOICES_PID = 295;
const CHOICES_NS = 'gd_arch_choices';
function getUserId() {
try {
const t = localStorage.getItem('Authorization');
if (!t) return 0;
return Number(jwtDecode(t).id) || 0;
} catch (e) { return 0; }
}
const api = axios.create({ baseURL: STORYS_addres, timeout: 15000 });
api.interceptors.request.use((cfg) => {
try {
const token = localStorage.getItem('Authorization');
if (token) cfg.headers.Authorization = token;
} catch (e) {}
return cfg;
});
function ArchCard({ arch, isChosen, onChoose }) {
const wrapRef = useRef(null);
const canvasRef = useRef(null);
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
if (!wrapRef.current) return;
const io = new IntersectionObserver((entries) => {
for (const e of entries) setIsVisible(e.isIntersecting);
}, { rootMargin: '200px', threshold: 0.01 });
io.observe(wrapRef.current);
return () => io.disconnect();
}, []);
useEffect(() => {
if (!isVisible || !canvasRef.current) return;
let engine = null, scene = null;
try {
engine = new Engine(canvasRef.current, true, { stencil: false, antialias: true });
scene = new Scene(engine);
scene.clearColor = new Color4(0.05, 0.07, 0.12, 1);
const camera = new ArcRotateCamera('cam', -Math.PI / 2.5, Math.PI / 2.6, 8, new Vector3(0, 2, 0), scene);
camera.attachControl(canvasRef.current, false);
camera.minZ = 0.1;
camera.maxZ = 50;
new HemisphericLight('hemi', new Vector3(0, 1, 0), scene).intensity = 0.7;
const sun = new DirectionalLight('sun', new Vector3(-0.5, -1, -0.3), scene);
sun.intensity = 0.8;
const floor = MeshBuilder.CreateGround('floor', { width: 7, height: 4 }, scene);
const fmat = new StandardMaterial('fmat', scene);
fmat.diffuseColor = new Color3(0.15, 0.20, 0.18);
floor.material = fmat;
const handle = arch.make(scene, `prev_${arch.id}`);
if (handle && handle.root) handle.root.position.y = 0;
scene.onBeforeRenderObservable.add(() => {
if (handle && handle.root) handle.root.rotation.y += 0.005;
});
engine.runRenderLoop(() => scene.render());
return () => {
try { engine && engine.stopRenderLoop(); } catch (e) {}
try { handle && handle.dispose && handle.dispose(); } catch (e) {}
try { scene && scene.dispose(); } catch (e) {}
try { engine && engine.dispose(); } catch (e) {}
};
} catch (e) { console.warn('[ArchCard] init failed', e); return () => {}; }
}, [isVisible, arch]);
return (
<div
ref={wrapRef}
onClick={onChoose}
style={{
...cardStyle,
border: isChosen ? '3px solid #22ff66' : '2px solid #2a3146',
boxShadow: isChosen ? '0 0 16px rgba(34,255,102,0.4)' : 'none',
cursor: 'pointer',
}}
>
{isVisible
? <canvas ref={canvasRef} style={{ width: '100%', height: 240, display: 'block', background: '#0a1020' }} />
: <div style={{ width: '100%', height: 240, background: '#0a1020', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#444' }}></div>}
<div style={{ padding: '10px 12px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<div style={{ fontSize: 14, fontWeight: 700, color: '#fff', marginBottom: 2 }}>{arch.title}</div>
<div style={{ fontSize: 11, color: '#888' }}><code>{arch.id}</code></div>
</div>
{isChosen && <span style={{ color: '#22ff66', fontSize: 20 }}></span>}
</div>
</div>
);
}
export default function GdArchesPreview() {
const { userRole, isLoading } = useAuth();
const navigate = useNavigate();
const [choices, setChoices] = useState({});
const [status, setStatus] = useState('idle');
useEffect(() => {
const uid = getUserId();
if (!uid) return;
api.get(`/kubikon3d/savegame/${CHOICES_PID}/${uid}/${CHOICES_NS}`)
.then((r) => setChoices(r.data?.data || {}))
.catch(() => {});
}, []);
const choose = (epoch, archId) => {
setChoices(prev => ({ ...prev, [epoch]: archId }));
setStatus('idle');
};
const save = async () => {
const uid = getUserId();
if (!uid) { setStatus('error'); return; }
setStatus('loading');
try {
await api.post(`/kubikon3d/savegame/${CHOICES_PID}/${uid}/${CHOICES_NS}`, { data: choices });
setStatus('saved');
} catch (e) { console.warn('[GdArchesPreview] save failed', e); setStatus('error'); }
};
if (isLoading) return <div style={{ padding: 24, color: '#fff' }}>Загрузка...</div>;
if (userRole !== 'admin') {
return (
<div style={{ padding: 24, color: '#fff', background: '#070a14', minHeight: '100vh' }}>
<h2>Доступ только для администратора</h2>
<button onClick={() => navigate('/')} style={btnStyle}>На главную</button>
</div>
);
}
const chosenCount = Object.keys(choices).length;
return (
<div style={{ minHeight: '100vh', background: '#070a14', color: '#fff', padding: '20px 24px' }}>
<div style={{
position: 'sticky', top: 0, background: '#070a14', zIndex: 10,
padding: '12px 0', marginBottom: 16, borderBottom: '1px solid #2a3146',
display: 'flex', alignItems: 'center', gap: 16, flexWrap: 'wrap',
}}>
<button onClick={() => navigate('/')} style={btnStyle}> Назад</button>
<h1 style={{ margin: 0, fontSize: 24, color: '#22ff66' }}>GD Стартовые арки по эпохам ({ARCH_CATALOG.length})</h1>
<div style={{ flex: 1, fontSize: 14, color: '#888' }}>
Выбрано: <span style={{ color: '#fff', fontWeight: 700 }}>{chosenCount}/10</span> эпох
</div>
<button onClick={save} disabled={status === 'loading' || chosenCount === 0} style={{
...btnPrimary, opacity: status === 'loading' || chosenCount === 0 ? 0.5 : 1,
}}>
{status === 'loading' ? '⏳ Сохраняю...' : status === 'saved' ? '✓ Сохранено' : '💾 Сохранить выбор'}
</button>
{status === 'error' && <span style={{ color: '#ff5566' }}>Ошибка</span>}
</div>
{EPOCH_INFO.map(epoch => {
const arches = getArchesByEpoch(epoch.n);
const chosenId = choices[epoch.n];
return (
<div key={epoch.n} style={{ marginBottom: 32 }}>
<h2 style={{ fontSize: 22, color: epoch.color, marginBottom: 12, borderBottom: `2px solid ${epoch.color}33`, paddingBottom: 8 }}>
{epoch.emoji} Эпоха {epoch.n} {epoch.name}
<span style={{ fontSize: 14, color: '#666', marginLeft: 12 }}>L{(epoch.n - 1) * 10 + 1} L{epoch.n * 10}</span>
{chosenId && (
<span style={{ fontSize: 14, color: '#22ff66', marginLeft: 12 }}>
выбран: <code>{chosenId}</code>
</span>
)}
</h2>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))',
gap: 16,
}}>
{arches.map(arch => (
<ArchCard key={arch.id}
arch={arch}
isChosen={chosenId === arch.id}
onChoose={() => choose(epoch.n, arch.id)} />
))}
</div>
</div>
);
})}
</div>
);
}
const cardStyle = { background: '#0e1525', borderRadius: 10, overflow: 'hidden', transition: 'all 0.15s' };
const btnStyle = { padding: '10px 18px', background: '#2a3146', color: '#cdd4e0', border: 'none', borderRadius: 8, cursor: 'pointer', fontWeight: 600, fontSize: 14 };
const btnPrimary = { padding: '10px 24px', background: 'linear-gradient(135deg, #22ff66, #44aaff)', color: '#000', border: 'none', borderRadius: 8, cursor: 'pointer', fontWeight: 700, fontSize: 15 };

View File

@ -0,0 +1,278 @@
/**
* GdBossesPreview превью-страница для просмотра всех 10 GD-боссов разом.
*
* Маршрут: /admin-preview/gd-bosses
*
* Для каждого босса делает мини-Babylon-сцену с вращающейся моделью.
* МИН открывает страницу, смотрит все 10 в виде сетки, по каждому даёт фидбэк.
* Доступ только для админа.
*
* Источник данных: kubikon3d_user_models (id 164-173, см. RUBLOX_GD_BOSSES_REGISTRY.md).
*/
import React, { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../../auth/AuthContext.jsx';
import { getUserModel } from '../../api/Kubikon3DService';
import { decodeVoxelModel } from '../KubikonEditor/engine/voxelModelCodec';
import styles from './GdBossesPreview.module.css';
import { Engine } from '@babylonjs/core/Engines/engine';
import { Scene } from '@babylonjs/core/scene';
import { ArcRotateCamera } from '@babylonjs/core/Cameras/arcRotateCamera';
import { HemisphericLight } from '@babylonjs/core/Lights/hemisphericLight';
import { DirectionalLight } from '@babylonjs/core/Lights/directionalLight';
import { Vector3, Color3, Color4 } from '@babylonjs/core/Maths/math';
import { MeshBuilder } from '@babylonjs/core/Meshes/meshBuilder';
import { StandardMaterial } from '@babylonjs/core/Materials/standardMaterial';
import { VertexData } from '@babylonjs/core/Meshes/mesh.vertexData';
import { Mesh } from '@babylonjs/core/Meshes/mesh';
// ID из реестра RUBLOX_GD_BOSSES_REGISTRY.md
const BOSSES = [
{ level: 10, title: 'Стражник леса', modelId: 164, description: 'Деревянный голем с горящими жёлтыми глазами. Корона из веток.' },
{ level: 20, title: 'Король горы', modelId: 165, description: 'Йети с ледяной короной. Снежно-белый мех.' },
{ level: 30, title: 'Хозяин города', modelId: 166, description: 'Робот-небоскрёб с окнами-глазами и красным маяком.' },
{ level: 40, title: 'Призрак ночи', modelId: 167, description: 'Полупрозрачный фиолетовый фантом с magenta-глазами.' },
{ level: 50, title: 'Король пустыни', modelId: 168, description: 'Сфинкс с золотой маской фараона.' },
{ level: 60, title: 'Левиафан', modelId: 169, description: 'Морской змей с большими белыми зубами.' },
{ level: 70, title: 'Хранитель глубин', modelId: 170, description: 'Каменный голем-минотавр с лавовыми жилами и кристальными рогами.' },
{ level: 80, title: 'Дракон вулкана', modelId: 171, description: 'Огнедышащий дракон с шипастой спиной и крыльями.' },
{ level: 90, title: 'Космический владыка', modelId: 172, description: 'НЛО-корабль с куполом, неоновыми окнами и тремя двигателями.' },
{ level: 100, title: 'АРХИТЕКТОР', modelId: 173, description: 'Финальный босс. Неоновая спираль + пирамида со всевидящим глазом.' },
];
const VOXEL_SIZE = 0.0625; // совпадает с UserModelManager 1/16 м
/**
* Построить меш одной воксельной модели в сцене.
* Один меш = один воксель (для простоты). При size=64 это до 262144 кубов,
* но реально вокселей ~10000 Babylon справляется.
*/
function buildVoxelMesh(scene, decoded, name) {
if (!decoded || !decoded.voxels) return null;
// Группируем воксели по цвету для оптимизации (один меш на цвет)
const byColor = new Map();
for (const v of decoded.voxels) {
const c = v.c || '#ffffff';
if (!byColor.has(c)) byColor.set(c, []);
byColor.get(c).push(v);
}
const parent = new Mesh(name + '_root', scene);
const halfSize = decoded.size / 2;
let colorIdx = 0;
for (const [hex, voxels] of byColor.entries()) {
// Собираем геометрию вручную (vertex buffer) без instances для простоты
const positions = [];
const normals = [];
const indices = [];
let vIdx = 0;
for (const v of voxels) {
// Центр воксели в мире
const wx = (v.x - halfSize) * VOXEL_SIZE;
const wy = (v.y) * VOXEL_SIZE;
const wz = (v.z - halfSize) * VOXEL_SIZE;
const s = VOXEL_SIZE / 2;
// 8 углов куба
const corners = [
[wx - s, wy - s, wz - s], [wx + s, wy - s, wz - s],
[wx + s, wy + s, wz - s], [wx - s, wy + s, wz - s],
[wx - s, wy - s, wz + s], [wx + s, wy - s, wz + s],
[wx + s, wy + s, wz + s], [wx - s, wy + s, wz + s],
];
// 6 граней (12 треугольников)
const faces = [
[0, 1, 2, 3, 0, 0, -1], // back
[5, 4, 7, 6, 0, 0, 1], // front
[4, 0, 3, 7, -1, 0, 0], // left
[1, 5, 6, 2, 1, 0, 0], // right
[3, 2, 6, 7, 0, 1, 0], // top
[4, 5, 1, 0, 0, -1, 0], // bottom
];
for (const [a, b, c, d, nx, ny, nz] of faces) {
for (const i of [a, b, c, d]) {
positions.push(corners[i][0], corners[i][1], corners[i][2]);
normals.push(nx, ny, nz);
}
indices.push(vIdx, vIdx + 1, vIdx + 2, vIdx, vIdx + 2, vIdx + 3);
vIdx += 4;
}
}
const mesh = new Mesh(`${name}_${colorIdx}`, scene);
const vd = new VertexData();
vd.positions = positions;
vd.normals = normals;
vd.indices = indices;
vd.applyToMesh(mesh);
const mat = new StandardMaterial(`mat_${name}_${colorIdx}`, scene);
const col = Color3.FromHexString(hex);
mat.diffuseColor = col;
mat.specularColor = new Color3(0.1, 0.1, 0.1);
// Светящиеся (emissive) цвета определим эвристически: ярко-насыщенные
const max = Math.max(col.r, col.g, col.b);
const min = Math.min(col.r, col.g, col.b);
const sat = max > 0 ? (max - min) / max : 0;
if (sat > 0.7 && max > 0.7) {
mat.emissiveColor = col.scale(0.5);
}
mesh.material = mat;
mesh.parent = parent;
colorIdx++;
}
return parent;
}
function BossPreviewCard({ boss }) {
const canvasRef = useRef(null);
const [status, setStatus] = useState('loading');
const [stats, setStats] = useState(null);
useEffect(() => {
let cancelled = false;
let engine = null;
async function init() {
try {
const resp = await getUserModel(boss.modelId);
if (cancelled) return;
const modelData = resp.data?.model_data;
const decoded = decodeVoxelModel(modelData);
if (!decoded) throw new Error('decode failed');
if (!canvasRef.current) return;
engine = new Engine(canvasRef.current, true, { preserveDrawingBuffer: true });
const scene = new Scene(engine);
scene.clearColor = new Color4(0.12, 0.14, 0.20, 1);
// Камера арк, медленно вращается вокруг
const camera = new ArcRotateCamera('cam',
-Math.PI / 2 - 0.3, Math.PI / 2.4,
decoded.size * VOXEL_SIZE * 2.2,
new Vector3(0, decoded.size * VOXEL_SIZE / 2, 0),
scene);
camera.attachControl(canvasRef.current, true);
camera.wheelPrecision = 50;
camera.lowerRadiusLimit = decoded.size * VOXEL_SIZE * 1.2;
camera.upperRadiusLimit = decoded.size * VOXEL_SIZE * 4;
// Освещение
const hemi = new HemisphericLight('hemi', new Vector3(0, 1, 0), scene);
hemi.intensity = 0.7;
hemi.groundColor = new Color3(0.3, 0.3, 0.4);
const dir = new DirectionalLight('dir', new Vector3(-0.5, -1, -0.3), scene);
dir.intensity = 0.6;
// Платформа под боссом
const ground = MeshBuilder.CreateDisc('ground',
{ radius: decoded.size * VOXEL_SIZE * 0.7, tessellation: 32 }, scene);
ground.rotation.x = Math.PI / 2;
const groundMat = new StandardMaterial('groundMat', scene);
groundMat.diffuseColor = new Color3(0.2, 0.22, 0.28);
ground.material = groundMat;
// Меш модели
const mesh = buildVoxelMesh(scene, decoded, `boss_${boss.modelId}`);
// Автовращение
scene.onBeforeRenderObservable.add(() => {
camera.alpha += 0.003;
});
engine.runRenderLoop(() => scene.render());
const onResize = () => engine.resize();
window.addEventListener('resize', onResize);
setStats({
voxels: decoded.voxels.length,
size: decoded.size,
colors: new Set(decoded.voxels.map(v => v.c)).size,
});
setStatus('ok');
// cleanup
return () => {
window.removeEventListener('resize', onResize);
};
} catch (e) {
console.error(`[BossPreview L${boss.level}]`, e);
setStatus('error: ' + e.message);
}
}
init();
return () => {
cancelled = true;
if (engine) {
try { engine.dispose(); } catch (_) {}
}
};
}, [boss.modelId]);
return (
<div className={styles.card}>
<div className={styles.header}>
<span className={styles.levelBadge}>L{boss.level}</span>
<h3 className={styles.title}>{boss.title}</h3>
</div>
<div className={styles.canvasWrap}>
<canvas ref={canvasRef} className={styles.canvas} />
{status !== 'ok' && (
<div className={styles.overlay}>{status}</div>
)}
</div>
<div className={styles.description}>{boss.description}</div>
{stats && (
<div className={styles.stats}>
📦 {stats.size}³ · 🎨 {stats.colors} цв. · 🧱 {stats.voxels} вокс.
· <a href={`/kubikon-editor/models/${boss.modelId}`} target="_blank" rel="noopener noreferrer">открыть в редакторе</a>
</div>
)}
</div>
);
}
function GdBossesPreview() {
const { userRole, isLoading } = useAuth();
const navigate = useNavigate();
if (isLoading) return <div className={styles.loading}>Загрузка...</div>;
if (userRole !== 'admin') {
return (
<div className={styles.denied}>
<h2>Доступ только для администратора</h2>
<button onClick={() => navigate('/')} className={styles.backBtn}>На главную</button>
</div>
);
}
return (
<div className={styles.root}>
<div className={styles.topbar}>
<h1 className={styles.h1}>GD-Боссы превью 10 моделей</h1>
<div className={styles.subline}>
Источник: <code>kubikon3d_user_models</code> id 164-173 · реестр в <code>RUBLOX_GD_BOSSES_REGISTRY.md</code>
</div>
</div>
<div className={styles.grid}>
{BOSSES.map(b => <BossPreviewCard key={b.modelId} boss={b} />)}
</div>
<div className={styles.footer}>
<p>Жми/тащи мышью на любой превью можно покрутить модель руками.</p>
<p>Если нужны правки скажи МИНу какие, я подкручу Python-генератор и перезалью.</p>
</div>
</div>
);
}
export default GdBossesPreview;

View File

@ -0,0 +1,151 @@
.root {
min-height: 100vh;
background: #1f2433;
color: #e3e8f0;
padding: 24px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Arial, sans-serif;
}
.loading, .denied {
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: #1f2433;
color: #e3e8f0;
gap: 16px;
}
.backBtn {
padding: 8px 18px;
background: #228be6;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
}
.topbar {
margin-bottom: 24px;
padding-bottom: 16px;
border-bottom: 1px solid #2f3548;
}
.h1 {
margin: 0;
font-size: 22px;
font-weight: 700;
}
.subline {
margin-top: 6px;
color: #8a93a8;
font-size: 13px;
}
.subline code {
background: #2a3142;
padding: 1px 6px;
border-radius: 3px;
font-size: 12px;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(360px, 1fr));
gap: 18px;
}
.card {
background: #2a3142;
border: 1px solid #3a4156;
border-radius: 10px;
overflow: hidden;
display: flex;
flex-direction: column;
}
.header {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 14px;
background: #232938;
border-bottom: 1px solid #2f3548;
}
.levelBadge {
background: linear-gradient(135deg, #3357ff, #b266ff);
color: white;
font-weight: 700;
padding: 4px 10px;
border-radius: 5px;
font-size: 13px;
min-width: 42px;
text-align: center;
}
.title {
margin: 0;
font-size: 16px;
font-weight: 600;
}
.canvasWrap {
position: relative;
aspect-ratio: 1;
background: #1c2030;
}
.canvas {
width: 100%;
height: 100%;
display: block;
touch-action: none;
}
.overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(28, 32, 48, 0.85);
color: #ff8a8a;
font-size: 13px;
text-align: center;
padding: 16px;
}
.description {
padding: 12px 14px;
font-size: 13px;
line-height: 1.5;
color: #cdd4e0;
}
.stats {
padding: 8px 14px 12px;
font-size: 12px;
color: #8a93a8;
border-top: 1px dashed #3a4156;
}
.stats a {
color: #66b3ff;
margin-left: 6px;
}
.footer {
margin-top: 28px;
padding-top: 16px;
border-top: 1px solid #2f3548;
color: #8a93a8;
font-size: 13px;
}
.footer p {
margin: 4px 0;
}

View File

@ -0,0 +1,217 @@
/**
* GdDecoPreview превью декораций ландшафта (10 эпох × 10 = 100).
* Маршрут: /admin-preview/gd-deco
* Multi-select: до 5 моделей на эпоху. Выбор kubikon3d_savegame
* (project_id=295, namespace='gd_deco_choices'). data = { 1: ['d1_v1','d1_v3',...] }.
*/
import React, { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../../auth/AuthContext.jsx';
import { jwtDecode } from 'jwt-decode';
import axios from 'axios';
import { STORYS_addres } from '../../api/API';
import { Engine } from '@babylonjs/core/Engines/engine';
import { Scene } from '@babylonjs/core/scene';
import { ArcRotateCamera } from '@babylonjs/core/Cameras/arcRotateCamera';
import { HemisphericLight } from '@babylonjs/core/Lights/hemisphericLight';
import { DirectionalLight } from '@babylonjs/core/Lights/directionalLight';
import { Vector3, Color3, Color4 } from '@babylonjs/core/Maths/math';
import { MeshBuilder } from '@babylonjs/core/Meshes/meshBuilder';
import { StandardMaterial } from '@babylonjs/core/Materials/standardMaterial';
import { DECO_CATALOG, EPOCH_INFO, getDecoByEpoch } from './gdDeco/decoFactories';
const CHOICES_PID = 295;
const CHOICES_NS = 'gd_deco_choices';
const MAX_PER_EPOCH = 5;
function getUserId() {
try { const t = localStorage.getItem('Authorization'); if (!t) return 0; return Number(jwtDecode(t).id) || 0; } catch (e) { return 0; }
}
const api = axios.create({ baseURL: STORYS_addres, timeout: 15000 });
api.interceptors.request.use((cfg) => {
try { const token = localStorage.getItem('Authorization'); if (token) cfg.headers.Authorization = token; } catch (e) {}
return cfg;
});
function DecoCard({ deco, isChosen, onToggle, disabled }) {
const wrapRef = useRef(null);
const canvasRef = useRef(null);
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
if (!wrapRef.current) return;
const io = new IntersectionObserver((entries) => {
for (const e of entries) setIsVisible(e.isIntersecting);
}, { rootMargin: '200px', threshold: 0.01 });
io.observe(wrapRef.current);
return () => io.disconnect();
}, []);
useEffect(() => {
if (!isVisible || !canvasRef.current) return;
let engine = null, scene = null;
try {
engine = new Engine(canvasRef.current, true, { stencil: false, antialias: true });
scene = new Scene(engine);
scene.clearColor = new Color4(0.05, 0.07, 0.12, 1);
const camera = new ArcRotateCamera('cam', -Math.PI / 2.5, Math.PI / 2.7, 5, new Vector3(0, 1.2, 0), scene);
camera.attachControl(canvasRef.current, false);
camera.minZ = 0.1; camera.maxZ = 50;
new HemisphericLight('hemi', new Vector3(0, 1, 0), scene).intensity = 0.7;
const sun = new DirectionalLight('sun', new Vector3(-0.5, -1, -0.3), scene);
sun.intensity = 0.8;
const floor = MeshBuilder.CreateGround('floor', { width: 4, height: 4 }, scene);
const fmat = new StandardMaterial('fmat', scene);
fmat.diffuseColor = new Color3(0.18, 0.22, 0.20);
floor.material = fmat;
const handle = deco.make(scene, `prev_${deco.id}`);
if (handle && handle.root) handle.root.position.y = 0;
scene.onBeforeRenderObservable.add(() => {
if (handle && handle.root) handle.root.rotation.y += 0.008;
});
engine.runRenderLoop(() => scene.render());
return () => {
try { engine && engine.stopRenderLoop(); } catch (e) {}
try { handle && handle.dispose && handle.dispose(); } catch (e) {}
try { scene && scene.dispose(); } catch (e) {}
try { engine && engine.dispose(); } catch (e) {}
};
} catch (e) { console.warn('[DecoCard]', e); return () => {}; }
}, [isVisible, deco]);
return (
<div
ref={wrapRef}
onClick={() => { if (!disabled || isChosen) onToggle(); }}
style={{
...cardStyle,
border: isChosen ? '3px solid #22ff66' : '2px solid #2a3146',
boxShadow: isChosen ? '0 0 16px rgba(34,255,102,0.4)' : 'none',
cursor: disabled && !isChosen ? 'not-allowed' : 'pointer',
opacity: disabled && !isChosen ? 0.45 : 1,
}}
>
{isVisible
? <canvas ref={canvasRef} style={{ width: '100%', height: 200, display: 'block', background: '#0a1020' }} />
: <div style={{ width: '100%', height: 200, background: '#0a1020', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#444' }}></div>}
<div style={{ padding: '8px 12px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<div style={{ fontSize: 13, fontWeight: 700, color: '#fff' }}>{deco.title}</div>
<div style={{ fontSize: 11, color: '#888' }}><code>{deco.id}</code></div>
</div>
{isChosen && <span style={{ color: '#22ff66', fontSize: 20 }}></span>}
</div>
</div>
);
}
export default function GdDecoPreview() {
const { userRole, isLoading } = useAuth();
const navigate = useNavigate();
// choices: { '1': ['d1_v1','d1_v4',...], '2': [...], ... }
const [choices, setChoices] = useState({});
const [status, setStatus] = useState('idle');
useEffect(() => {
const uid = getUserId();
if (!uid) return;
api.get(`/kubikon3d/savegame/${CHOICES_PID}/${uid}/${CHOICES_NS}`)
.then((r) => setChoices(r.data?.data || {}))
.catch(() => {});
}, []);
const toggle = (epoch, decoId) => {
setChoices(prev => {
const arr = prev[epoch] || [];
const has = arr.includes(decoId);
let next;
if (has) {
next = arr.filter(x => x !== decoId);
} else {
if (arr.length >= MAX_PER_EPOCH) return prev;
next = [...arr, decoId];
}
return { ...prev, [epoch]: next };
});
setStatus('idle');
};
const save = async () => {
const uid = getUserId();
if (!uid) { setStatus('error'); return; }
setStatus('loading');
try {
await api.post(`/kubikon3d/savegame/${CHOICES_PID}/${uid}/${CHOICES_NS}`, { data: choices });
setStatus('saved');
} catch (e) { console.warn('[GdDecoPreview] save failed', e); setStatus('error'); }
};
if (isLoading) return <div style={{ padding: 24, color: '#fff' }}>Загрузка...</div>;
if (userRole !== 'admin') {
return (
<div style={{ padding: 24, color: '#fff', background: '#070a14', minHeight: '100vh' }}>
<h2>Доступ только для администратора</h2>
<button onClick={() => navigate('/')} style={btnStyle}>На главную</button>
</div>
);
}
const totalChosen = Object.values(choices).reduce((s, arr) => s + (arr?.length || 0), 0);
return (
<div style={{ minHeight: '100vh', background: '#070a14', color: '#fff', padding: '20px 24px' }}>
<div style={{
position: 'sticky', top: 0, background: '#070a14', zIndex: 10,
padding: '12px 0', marginBottom: 16, borderBottom: '1px solid #2a3146',
display: 'flex', alignItems: 'center', gap: 16, flexWrap: 'wrap',
}}>
<button onClick={() => navigate('/')} style={btnStyle}> Назад</button>
<h1 style={{ margin: 0, fontSize: 22, color: '#22ff66' }}>GD Декорации ландшафта ({DECO_CATALOG.length})</h1>
<div style={{ flex: 1, fontSize: 14, color: '#888' }}>
Выбрано: <span style={{ color: '#fff', fontWeight: 700 }}>{totalChosen}</span> (до 5 на эпоху)
</div>
<button onClick={save} disabled={status === 'loading' || totalChosen === 0} style={{
...btnPrimary, opacity: status === 'loading' || totalChosen === 0 ? 0.5 : 1,
}}>
{status === 'loading' ? '⏳ Сохраняю...' : status === 'saved' ? '✓ Сохранено' : '💾 Сохранить выбор'}
</button>
{status === 'error' && <span style={{ color: '#ff5566' }}>Ошибка</span>}
</div>
{EPOCH_INFO.map(epoch => {
const items = getDecoByEpoch(epoch.n);
const arr = choices[epoch.n] || [];
const fullyChosen = arr.length >= MAX_PER_EPOCH;
return (
<div key={epoch.n} style={{ marginBottom: 32 }}>
<h2 style={{ fontSize: 22, color: epoch.color, marginBottom: 12, borderBottom: `2px solid ${epoch.color}33`, paddingBottom: 8 }}>
{epoch.emoji} Эпоха {epoch.n} {epoch.name}
<span style={{ fontSize: 14, color: '#666', marginLeft: 12 }}>L{(epoch.n - 1) * 10 + 1} L{epoch.n * 10}</span>
<span style={{ fontSize: 14, color: fullyChosen ? '#22ff66' : '#888', marginLeft: 12 }}>
выбрано {arr.length}/{MAX_PER_EPOCH}
</span>
</h2>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))',
gap: 16,
}}>
{items.map(deco => (
<DecoCard
key={deco.id}
deco={deco}
isChosen={arr.includes(deco.id)}
onToggle={() => toggle(epoch.n, deco.id)}
disabled={fullyChosen}
/>
))}
</div>
</div>
);
})}
</div>
);
}
const cardStyle = { background: '#0e1525', borderRadius: 10, overflow: 'hidden', transition: 'all 0.15s' };
const btnStyle = { padding: '10px 18px', background: '#2a3146', color: '#cdd4e0', border: 'none', borderRadius: 8, cursor: 'pointer', fontWeight: 600, fontSize: 14 };
const btnPrimary = { padding: '10px 24px', background: 'linear-gradient(135deg, #22ff66, #44aaff)', color: '#000', border: 'none', borderRadius: 8, cursor: 'pointer', fontWeight: 700, fontSize: 15 };

View File

@ -0,0 +1,179 @@
/**
* GdFinishesPreview превью финишных ворот (10 эпох × 5 = 50).
* Маршрут: /admin-preview/gd-finishes
* Выбор юзера kubikon3d_savegame (project_id=295, namespace='gd_finish_choices').
*/
import React, { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../../auth/AuthContext.jsx';
import { jwtDecode } from 'jwt-decode';
import axios from 'axios';
import { STORYS_addres } from '../../api/API';
import { Engine } from '@babylonjs/core/Engines/engine';
import { Scene } from '@babylonjs/core/scene';
import { ArcRotateCamera } from '@babylonjs/core/Cameras/arcRotateCamera';
import { HemisphericLight } from '@babylonjs/core/Lights/hemisphericLight';
import { DirectionalLight } from '@babylonjs/core/Lights/directionalLight';
import { Vector3, Color3, Color4 } from '@babylonjs/core/Maths/math';
import { MeshBuilder } from '@babylonjs/core/Meshes/meshBuilder';
import { StandardMaterial } from '@babylonjs/core/Materials/standardMaterial';
import { FINISH_CATALOG, EPOCH_INFO, getFinishesByEpoch } from './gdFinishes/finishFactories';
const CHOICES_PID = 295;
const CHOICES_NS = 'gd_finish_choices';
function getUserId() {
try { const t = localStorage.getItem('Authorization'); if (!t) return 0; return Number(jwtDecode(t).id) || 0; } catch (e) { return 0; }
}
const api = axios.create({ baseURL: STORYS_addres, timeout: 15000 });
api.interceptors.request.use((cfg) => {
try { const token = localStorage.getItem('Authorization'); if (token) cfg.headers.Authorization = token; } catch (e) {}
return cfg;
});
function FinCard({ fin, isChosen, onChoose }) {
const wrapRef = useRef(null);
const canvasRef = useRef(null);
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
if (!wrapRef.current) return;
const io = new IntersectionObserver((entries) => {
for (const e of entries) setIsVisible(e.isIntersecting);
}, { rootMargin: '200px', threshold: 0.01 });
io.observe(wrapRef.current);
return () => io.disconnect();
}, []);
useEffect(() => {
if (!isVisible || !canvasRef.current) return;
let engine = null, scene = null;
try {
engine = new Engine(canvasRef.current, true, { stencil: false, antialias: true });
scene = new Scene(engine);
scene.clearColor = new Color4(0.05, 0.07, 0.12, 1);
const camera = new ArcRotateCamera('cam', -Math.PI / 2.5, Math.PI / 2.6, 8.5, new Vector3(0, 2.2, 0), scene);
camera.attachControl(canvasRef.current, false);
camera.minZ = 0.1; camera.maxZ = 50;
new HemisphericLight('hemi', new Vector3(0, 1, 0), scene).intensity = 0.7;
const sun = new DirectionalLight('sun', new Vector3(-0.5, -1, -0.3), scene);
sun.intensity = 0.8;
const floor = MeshBuilder.CreateGround('floor', { width: 7, height: 4 }, scene);
const fmat = new StandardMaterial('fmat', scene);
fmat.diffuseColor = new Color3(0.15, 0.20, 0.18);
floor.material = fmat;
const handle = fin.make(scene, `prev_${fin.id}`);
if (handle && handle.root) handle.root.position.y = 0;
scene.onBeforeRenderObservable.add(() => {
if (handle && handle.root) handle.root.rotation.y += 0.005;
});
engine.runRenderLoop(() => scene.render());
return () => {
try { engine && engine.stopRenderLoop(); } catch (e) {}
try { handle && handle.dispose && handle.dispose(); } catch (e) {}
try { scene && scene.dispose(); } catch (e) {}
try { engine && engine.dispose(); } catch (e) {}
};
} catch (e) { console.warn('[FinCard] init failed', e); return () => {}; }
}, [isVisible, fin]);
return (
<div ref={wrapRef} onClick={onChoose}
style={{
...cardStyle,
border: isChosen ? '3px solid #22ff66' : '2px solid #2a3146',
boxShadow: isChosen ? '0 0 16px rgba(34,255,102,0.4)' : 'none',
cursor: 'pointer',
}}>
{isVisible
? <canvas ref={canvasRef} style={{ width: '100%', height: 260, display: 'block', background: '#0a1020' }} />
: <div style={{ width: '100%', height: 260, background: '#0a1020', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#444' }}></div>}
<div style={{ padding: '10px 12px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<div style={{ fontSize: 14, fontWeight: 700, color: '#fff', marginBottom: 2 }}>{fin.title}</div>
<div style={{ fontSize: 11, color: '#888' }}><code>{fin.id}</code></div>
</div>
{isChosen && <span style={{ color: '#22ff66', fontSize: 20 }}></span>}
</div>
</div>
);
}
export default function GdFinishesPreview() {
const { userRole, isLoading } = useAuth();
const navigate = useNavigate();
const [choices, setChoices] = useState({});
const [status, setStatus] = useState('idle');
useEffect(() => {
const uid = getUserId();
if (!uid) return;
api.get(`/kubikon3d/savegame/${CHOICES_PID}/${uid}/${CHOICES_NS}`)
.then((r) => setChoices(r.data?.data || {}))
.catch(() => {});
}, []);
const choose = (epoch, finId) => { setChoices(prev => ({ ...prev, [epoch]: finId })); setStatus('idle'); };
const save = async () => {
const uid = getUserId();
if (!uid) { setStatus('error'); return; }
setStatus('loading');
try {
await api.post(`/kubikon3d/savegame/${CHOICES_PID}/${uid}/${CHOICES_NS}`, { data: choices });
setStatus('saved');
} catch (e) { console.warn('[GdFinishesPreview] save failed', e); setStatus('error'); }
};
if (isLoading) return <div style={{ padding: 24, color: '#fff' }}>Загрузка...</div>;
if (userRole !== 'admin') {
return (
<div style={{ padding: 24, color: '#fff', background: '#070a14', minHeight: '100vh' }}>
<h2>Доступ только для администратора</h2>
<button onClick={() => navigate('/')} style={btnStyle}>На главную</button>
</div>
);
}
const chosenCount = Object.keys(choices).length;
return (
<div style={{ minHeight: '100vh', background: '#070a14', color: '#fff', padding: '20px 24px' }}>
<div style={{
position: 'sticky', top: 0, background: '#070a14', zIndex: 10,
padding: '12px 0', marginBottom: 16, borderBottom: '1px solid #2a3146',
display: 'flex', alignItems: 'center', gap: 16, flexWrap: 'wrap',
}}>
<button onClick={() => navigate('/')} style={btnStyle}> Назад</button>
<h1 style={{ margin: 0, fontSize: 24, color: '#22ff66' }}>GD Финишные ворота ({FINISH_CATALOG.length})</h1>
<div style={{ flex: 1, fontSize: 14, color: '#888' }}>
Выбрано: <span style={{ color: '#fff', fontWeight: 700 }}>{chosenCount}/10</span> эпох
</div>
<button onClick={save} disabled={status === 'loading' || chosenCount === 0} style={{
...btnPrimary, opacity: status === 'loading' || chosenCount === 0 ? 0.5 : 1,
}}>
{status === 'loading' ? '⏳ Сохраняю...' : status === 'saved' ? '✓ Сохранено' : '💾 Сохранить выбор'}
</button>
{status === 'error' && <span style={{ color: '#ff5566' }}>Ошибка</span>}
</div>
{EPOCH_INFO.map(epoch => {
const fins = getFinishesByEpoch(epoch.n);
const chosenId = choices[epoch.n];
return (
<div key={epoch.n} style={{ marginBottom: 32 }}>
<h2 style={{ fontSize: 22, color: epoch.color, marginBottom: 12, borderBottom: `2px solid ${epoch.color}33`, paddingBottom: 8 }}>
{epoch.emoji} Эпоха {epoch.n} {epoch.name}
<span style={{ fontSize: 14, color: '#666', marginLeft: 12 }}>L{(epoch.n - 1) * 10 + 1} L{epoch.n * 10}</span>
{chosenId && (<span style={{ fontSize: 14, color: '#22ff66', marginLeft: 12 }}> выбран: <code>{chosenId}</code></span>)}
</h2>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))',
gap: 16,
}}>
{fins.map(fin => (
<FinCard key={fin.id} fin={fin} isChosen={chosenId === fin.id} onChoose={() => choose(epoch.n, fin.id)} />
))}
</div>
</div>
);
})}
</div>
);
}
const cardStyle = { background: '#0e1525', borderRadius: 10, overflow: 'hidden', transition: 'all 0.15s' };
const btnStyle = { padding: '10px 18px', background: '#2a3146', color: '#cdd4e0', border: 'none', borderRadius: 8, cursor: 'pointer', fontWeight: 600, fontSize: 14 };
const btnPrimary = { padding: '10px 24px', background: 'linear-gradient(135deg, #22ff66, #44aaff)', color: '#000', border: 'none', borderRadius: 8, cursor: 'pointer', fontWeight: 700, fontSize: 15 };

View File

@ -0,0 +1,193 @@
/**
* GdMusicPreview превью 20 треков GD-музыки.
*
* Маршрут: /admin-preview/gd-music
*
* Для каждого трека:
* - проверяет есть ли реальный mp3 в /music/gd/
* - если есть играет файл через <audio>
* - если нет играет процедурный fallback через Web Audio (SynthPlayer)
*
* Показывает BPM, длительность, эпоху, kind (main/boss).
* Одновременно играет только один трек.
*/
import React, { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../../auth/AuthContext.jsx';
import styles from './GdMusicPreview.module.css';
import { TRACKS, EPOCH_INFO } from './gdMusic/musicCatalog';
import { SynthPlayer, trackFileExists } from './gdMusic/musicSynth';
function formatDuration(sec) {
const m = Math.floor(sec / 60);
const s = sec % 60;
return `${m}:${s.toString().padStart(2, '0')}`;
}
function TrackCard({ track, isPlaying, onPlay, onStop, fileStatus }) {
const epoch = EPOCH_INFO[track.epoch - 1];
const isBoss = track.kind === 'boss';
return (
<div className={`${styles.card} ${isBoss ? styles.cardBoss : ''}`}>
<div className={styles.cardHeader} style={{ borderTopColor: epoch.color }}>
<span className={styles.epochBadge}>
{epoch.emoji} E{track.epoch}
</span>
<span className={isBoss ? styles.kindBoss : styles.kindMain}>
{isBoss ? '⚔ БОСС' : '♪ main'}
</span>
</div>
<div className={styles.cardBody}>
<div className={styles.title}>{track.title}</div>
<div className={styles.stats}>
<span><strong>{track.bpm}</strong> BPM</span>
<span>{formatDuration(track.durationSec)}</span>
<span className={fileStatus === 'file' ? styles.statusFile : styles.statusSynth}>
{fileStatus === 'file' ? '📁 файл' : (fileStatus === 'synth' ? '🎹 синтез' : '⋯')}
</span>
</div>
</div>
<div className={styles.cardActions}>
{isPlaying ? (
<button className={styles.btnStop} onClick={onStop}> Стоп</button>
) : (
<button className={styles.btnPlay} onClick={onPlay}> Играть</button>
)}
<code className={styles.trackId}>{track.id}</code>
</div>
</div>
);
}
function GdMusicPreview() {
const { userRole, isLoading } = useAuth();
const navigate = useNavigate();
const [playingId, setPlayingId] = useState(null);
const [fileStatuses, setFileStatuses] = useState({}); // trackId 'file' | 'synth' | undefined
const audioRef = useRef(null);
const synthRef = useRef(null);
// При монтировании проверяем все mp3-файлы (HEAD-запросы)
useEffect(() => {
let cancelled = false;
(async () => {
const statuses = {};
// параллельно
await Promise.all(TRACKS.map(async (t) => {
const exists = await trackFileExists(t.file);
statuses[t.id] = exists ? 'file' : 'synth';
}));
if (!cancelled) setFileStatuses(statuses);
})();
return () => { cancelled = true; };
}, []);
// Остановить всё
const stopAll = () => {
if (audioRef.current) {
audioRef.current.pause();
audioRef.current.currentTime = 0;
}
if (synthRef.current) {
synthRef.current.stop();
synthRef.current = null;
}
setPlayingId(null);
};
const play = async (track) => {
stopAll();
const status = fileStatuses[track.id];
setPlayingId(track.id);
if (status === 'file') {
const a = new Audio(track.file);
a.volume = 0.7;
a.loop = false;
audioRef.current = a;
a.onended = () => setPlayingId(curr => curr === track.id ? null : curr);
try { await a.play(); }
catch (e) { console.warn('[gd-music] audio.play() failed', e); }
} else {
const p = new SynthPlayer(track.fallbackSynth, track.bpm);
synthRef.current = p;
await p.start();
}
};
// Cleanup при unmount
useEffect(() => {
return () => stopAll();
}, []);
if (isLoading) return <div className={styles.loading}>Загрузка...</div>;
if (userRole !== 'admin') {
return (
<div className={styles.denied}>
<h2>Доступ только для администратора</h2>
<button onClick={() => navigate('/')} className={styles.backBtn}>На главную</button>
</div>
);
}
// Группируем по эпохам
const tracksByEpoch = {};
for (const t of TRACKS) {
if (!tracksByEpoch[t.epoch]) tracksByEpoch[t.epoch] = [];
tracksByEpoch[t.epoch].push(t);
}
const filesFound = Object.values(fileStatuses).filter(s => s === 'file').length;
const synthCount = Object.values(fileStatuses).filter(s => s === 'synth').length;
return (
<div className={styles.root}>
<div className={styles.topbar}>
<h1 className={styles.h1}>GD-Музыка превью {TRACKS.length} треков</h1>
<div className={styles.subline}>
{filesFound > 0 && <span>📁 файлов: {filesFound}</span>}
{synthCount > 0 && <span style={{ marginLeft: 16 }}>🎹 синтез: {synthCount} (Web Audio fallback)</span>}
<div style={{ marginTop: 6 }}>
Промпты для Suno: <code>RUBLOX_GD_MUSIC_PROMPTS.md</code> ·
Файлы класть в: <code>/public/music/gd/&lt;trackId&gt;.mp3</code>
</div>
</div>
</div>
{EPOCH_INFO.map(epoch => (
<div key={epoch.n} className={styles.epochSection}>
<h2 className={styles.epochTitle} style={{ color: epoch.color }}>
{epoch.emoji} Эпоха {epoch.n} {epoch.name}
</h2>
<div className={styles.grid}>
{(tracksByEpoch[epoch.n] || []).map(track => (
<TrackCard
key={track.id}
track={track}
isPlaying={playingId === track.id}
onPlay={() => play(track)}
onStop={stopAll}
fileStatus={fileStatuses[track.id]}
/>
))}
</div>
</div>
))}
<div className={styles.footer}>
<p><strong>Как заменить fallback на реальную музыку:</strong></p>
<ol>
<li>Купить Suno Pro ($10/мес)</li>
<li>Скопировать промпт из <code>RUBLOX_GD_MUSIC_PROMPTS.md</code></li>
<li>Сгенерировать в Suno, скачать mp3</li>
<li>Положить в <code>/public/music/gd/&lt;trackId&gt;.mp3</code></li>
<li>Перезагрузить страницу плеер автоматически подцепит файл</li>
</ol>
<p>Синтез нужен ровно для теста <strong>темпа</strong> (BPM правильный) пока нет реальных треков.</p>
</div>
</div>
);
}
export default GdMusicPreview;

View File

@ -0,0 +1,225 @@
.root {
min-height: 100vh;
background: #1f2433;
color: #e3e8f0;
padding: 24px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Arial, sans-serif;
}
.loading, .denied {
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: #1f2433;
color: #e3e8f0;
gap: 16px;
}
.backBtn {
padding: 8px 18px;
background: #228be6;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
}
.topbar {
margin-bottom: 18px;
padding-bottom: 14px;
border-bottom: 1px solid #2f3548;
}
.h1 {
margin: 0;
font-size: 22px;
font-weight: 700;
}
.subline {
margin-top: 8px;
color: #8a93a8;
font-size: 13px;
}
.subline code {
background: #2a3142;
padding: 1px 6px;
border-radius: 3px;
font-size: 12px;
}
.epochSection {
margin-top: 24px;
}
.epochTitle {
margin: 0 0 12px;
font-size: 16px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 1px;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 12px;
}
.card {
background: #2a3142;
border: 1px solid #3a4156;
border-radius: 8px;
overflow: hidden;
display: flex;
flex-direction: column;
}
.cardBoss {
border-color: #ffaa00;
box-shadow: 0 0 0 1px rgba(255, 170, 0, 0.2);
}
.cardHeader {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
background: #232938;
border-top: 4px solid #3a7a4a;
}
.epochBadge {
font-size: 12px;
font-weight: 700;
color: #cdd4e0;
}
.kindMain, .kindBoss {
font-size: 11px;
font-weight: 600;
padding: 2px 8px;
border-radius: 4px;
}
.kindMain {
background: #1c2030;
color: #8a93a8;
}
.kindBoss {
background: linear-gradient(135deg, #ff9533, #ffaa00);
color: #1a1500;
}
.cardBody {
padding: 10px 12px;
}
.title {
font-size: 14px;
font-weight: 600;
line-height: 1.35;
margin-bottom: 8px;
}
.stats {
display: flex;
gap: 12px;
font-size: 12px;
color: #8a93a8;
align-items: center;
}
.stats strong {
color: #66b3ff;
font-size: 14px;
}
.statusFile {
color: #5fd886;
font-weight: 600;
margin-left: auto;
}
.statusSynth {
color: #ffaa66;
font-weight: 600;
margin-left: auto;
}
.cardActions {
padding: 8px 12px 10px;
border-top: 1px dashed #3a4156;
display: flex;
gap: 10px;
align-items: center;
}
.btnPlay, .btnStop {
padding: 6px 14px;
border: none;
border-radius: 5px;
cursor: pointer;
font-size: 13px;
font-weight: 600;
transition: all 0.1s;
}
.btnPlay {
background: #2db360;
color: white;
}
.btnPlay:hover {
background: #34c970;
}
.btnStop {
background: #cc4444;
color: white;
}
.btnStop:hover {
background: #dd5555;
}
.trackId {
margin-left: auto;
font-size: 11px;
color: #8a93a8;
background: #1c2030;
padding: 2px 6px;
border-radius: 3px;
}
.footer {
margin-top: 32px;
padding: 18px 20px;
background: #232938;
border-radius: 8px;
border: 1px solid #2f3548;
color: #cdd4e0;
font-size: 13px;
line-height: 1.6;
}
.footer p {
margin: 6px 0;
}
.footer ol {
margin: 8px 0;
padding-left: 22px;
}
.footer code {
background: #1c2030;
padding: 1px 6px;
border-radius: 3px;
font-size: 12px;
color: #66b3ff;
}

View File

@ -0,0 +1,209 @@
/**
* GdPortalsPreview превью порталов смены гейммода (6 групп × 5 = 30).
* Маршрут: /admin-preview/gd-portals
* Single-select на группу: { cube: 'cube_v1', ship: 'ship_v3', ... }.
* Сохраняется в kubikon3d_savegame (project_id=295, namespace='gd_portal_choices').
*/
import React, { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../../auth/AuthContext.jsx';
import { jwtDecode } from 'jwt-decode';
import axios from 'axios';
import { STORYS_addres } from '../../api/API';
import { Engine } from '@babylonjs/core/Engines/engine';
import { Scene } from '@babylonjs/core/scene';
import { ArcRotateCamera } from '@babylonjs/core/Cameras/arcRotateCamera';
import { HemisphericLight } from '@babylonjs/core/Lights/hemisphericLight';
import { DirectionalLight } from '@babylonjs/core/Lights/directionalLight';
import { Vector3, Color3, Color4 } from '@babylonjs/core/Maths/math';
import { MeshBuilder } from '@babylonjs/core/Meshes/meshBuilder';
import { StandardMaterial } from '@babylonjs/core/Materials/standardMaterial';
import { PORTAL_CATALOG, PORTAL_GROUPS, getPortalsByGroup } from './gdPortals/portalFactories';
const CHOICES_PID = 295;
const CHOICES_NS = 'gd_portal_choices';
function getUserId() {
try { const t = localStorage.getItem('Authorization'); if (!t) return 0; return Number(jwtDecode(t).id) || 0; } catch (e) { return 0; }
}
const api = axios.create({ baseURL: STORYS_addres, timeout: 15000 });
api.interceptors.request.use((cfg) => {
try { const token = localStorage.getItem('Authorization'); if (token) cfg.headers.Authorization = token; } catch (e) {}
return cfg;
});
function PortalCard({ portal, isChosen, onPick, accent }) {
const wrapRef = useRef(null);
const canvasRef = useRef(null);
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
if (!wrapRef.current) return;
const io = new IntersectionObserver((entries) => {
for (const e of entries) setIsVisible(e.isIntersecting);
}, { rootMargin: '200px', threshold: 0.01 });
io.observe(wrapRef.current);
return () => io.disconnect();
}, []);
useEffect(() => {
if (!isVisible || !canvasRef.current) return;
let engine = null, scene = null;
try {
engine = new Engine(canvasRef.current, true, { stencil: false, antialias: true });
scene = new Scene(engine);
scene.clearColor = new Color4(0.05, 0.07, 0.12, 1);
const camera = new ArcRotateCamera('cam', -Math.PI / 2, Math.PI / 2.4, 4.2, new Vector3(0, 1.1, 0), scene);
camera.attachControl(canvasRef.current, false);
camera.minZ = 0.1; camera.maxZ = 50;
new HemisphericLight('hemi', new Vector3(0, 1, 0), scene).intensity = 0.55;
const sun = new DirectionalLight('sun', new Vector3(-0.5, -1, -0.3), scene);
sun.intensity = 0.7;
const floor = MeshBuilder.CreateGround('floor', { width: 4, height: 4 }, scene);
const fmat = new StandardMaterial('fmat', scene);
fmat.diffuseColor = new Color3(0.10, 0.13, 0.18);
floor.material = fmat;
const handle = portal.make(scene, `prev_${portal.id}`);
if (handle && handle.root) handle.root.position.y = 0;
scene.onBeforeRenderObservable.add(() => {
if (handle && handle.root) handle.root.rotation.y += 0.012;
});
engine.runRenderLoop(() => scene.render());
return () => {
try { engine && engine.stopRenderLoop(); } catch (e) {}
try { handle && handle.dispose && handle.dispose(); } catch (e) {}
try { scene && scene.dispose(); } catch (e) {}
try { engine && engine.dispose(); } catch (e) {}
};
} catch (e) { console.warn('[PortalCard]', e); return () => {}; }
}, [isVisible, portal]);
return (
<div
ref={wrapRef}
onClick={onPick}
style={{
...cardStyle,
border: isChosen ? `3px solid ${accent}` : '2px solid #2a3146',
boxShadow: isChosen ? `0 0 18px ${accent}66` : 'none',
cursor: 'pointer',
}}
>
{isVisible
? <canvas ref={canvasRef} style={{ width: '100%', height: 220, display: 'block', background: '#0a1020' }} />
: <div style={{ width: '100%', height: 220, background: '#0a1020', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#444' }}></div>}
<div style={{ padding: '8px 12px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<div style={{ fontSize: 13, fontWeight: 700, color: '#fff' }}>{portal.title}</div>
<div style={{ fontSize: 11, color: '#888' }}><code>{portal.id}</code></div>
</div>
{isChosen && <span style={{ color: accent, fontSize: 22 }}></span>}
</div>
</div>
);
}
export default function GdPortalsPreview() {
const { userRole, isLoading } = useAuth();
const navigate = useNavigate();
// choices: { cube: 'cube_v1', ship: 'ship_v3', ball: ..., ufo: ..., wave: ..., robot: ... }
const [choices, setChoices] = useState({});
const [status, setStatus] = useState('idle');
useEffect(() => {
const uid = getUserId();
if (!uid) return;
api.get(`/kubikon3d/savegame/${CHOICES_PID}/${uid}/${CHOICES_NS}`)
.then((r) => setChoices(r.data?.data || {}))
.catch(() => {});
}, []);
const pick = (group, portalId) => {
setChoices(prev => ({ ...prev, [group]: portalId }));
setStatus('idle');
};
const save = async () => {
const uid = getUserId();
if (!uid) { setStatus('error'); return; }
setStatus('loading');
try {
await api.post(`/kubikon3d/savegame/${CHOICES_PID}/${uid}/${CHOICES_NS}`, { data: choices });
setStatus('saved');
} catch (e) { console.warn('[GdPortalsPreview] save failed', e); setStatus('error'); }
};
if (isLoading) return <div style={{ padding: 24, color: '#fff' }}>Загрузка...</div>;
if (userRole !== 'admin') {
return (
<div style={{ padding: 24, color: '#fff', background: '#070a14', minHeight: '100vh' }}>
<h2>Доступ только для администратора</h2>
<button onClick={() => navigate('/')} style={btnStyle}>На главную</button>
</div>
);
}
const totalChosen = Object.keys(choices).filter(k => choices[k]).length;
return (
<div style={{ minHeight: '100vh', background: '#070a14', color: '#fff', padding: '20px 24px' }}>
<div style={{
position: 'sticky', top: 0, background: '#070a14', zIndex: 10,
padding: '12px 0', marginBottom: 16, borderBottom: '1px solid #2a3146',
display: 'flex', alignItems: 'center', gap: 16, flexWrap: 'wrap',
}}>
<button onClick={() => navigate('/')} style={btnStyle}> Назад</button>
<h1 style={{ margin: 0, fontSize: 22, color: '#ff44aa' }}>GD Порталы гейммодов ({PORTAL_CATALOG.length})</h1>
<div style={{ flex: 1, fontSize: 14, color: '#888' }}>
Выбрано: <span style={{ color: '#fff', fontWeight: 700 }}>{totalChosen}</span> / {PORTAL_GROUPS.length}
</div>
<button onClick={save} disabled={status === 'loading' || totalChosen === 0} style={{
...btnPrimary, opacity: status === 'loading' || totalChosen === 0 ? 0.5 : 1,
}}>
{status === 'loading' ? '⏳ Сохраняю...' : status === 'saved' ? '✓ Сохранено' : '💾 Сохранить выбор'}
</button>
{status === 'error' && <span style={{ color: '#ff5566' }}>Ошибка</span>}
</div>
<div style={{ marginBottom: 20, color: '#aab', fontSize: 14, lineHeight: 1.5 }}>
По одному варианту на каждый гейммод. Cube обычный куб (возврат в стандартный режим),
Ship корабль (Space тяга вверх), Ball шар (Space смена гравитации),
UFO НЛО (Space короткий импульс), Wave волна (зигзаг), Robot робот (длинный прыжок).
</div>
{PORTAL_GROUPS.map(group => {
const items = getPortalsByGroup(group.id);
const chosen = choices[group.id];
return (
<div key={group.id} style={{ marginBottom: 32 }}>
<h2 style={{ fontSize: 22, color: group.color, marginBottom: 12, borderBottom: `2px solid ${group.color}33`, paddingBottom: 8 }}>
{group.emoji} {group.name}
<span style={{ fontSize: 14, color: chosen ? group.color : '#666', marginLeft: 12 }}>
{chosen ? `выбрано: ${chosen}` : 'не выбрано'}
</span>
</h2>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))',
gap: 16,
}}>
{items.map(portal => (
<PortalCard
key={portal.id}
portal={portal}
isChosen={chosen === portal.id}
onPick={() => pick(group.id, portal.id)}
accent={group.color}
/>
))}
</div>
</div>
);
})}
</div>
);
}
const cardStyle = { background: '#0e1525', borderRadius: 10, overflow: 'hidden', transition: 'all 0.15s' };
const btnStyle = { padding: '10px 18px', background: '#2a3146', color: '#cdd4e0', border: 'none', borderRadius: 8, cursor: 'pointer', fontWeight: 600, fontSize: 14 };
const btnPrimary = { padding: '10px 24px', background: 'linear-gradient(135deg, #ff44aa, #33aaff)', color: '#000', border: 'none', borderRadius: 8, cursor: 'pointer', fontWeight: 700, fontSize: 15 };

View File

@ -0,0 +1,141 @@
/**
* GdSfxPreview превью 9 звуковых эффектов GD-игры.
*
* Маршрут: /admin-preview/gd-sfx
*
* Все эффекты процедурные (Web Audio API), не файлы.
* Один AudioContext на всю страницу, создаётся при первом клике.
*/
import React, { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../../auth/AuthContext.jsx';
import styles from './GdSfxPreview.module.css';
import { SFX_CATALOG } from './gdSfx/sfxFactories';
function GdSfxPreview() {
const { userRole, isLoading } = useAuth();
const navigate = useNavigate();
const ctxRef = useRef(null);
const masterRef = useRef(null);
const [ready, setReady] = useState(false);
const [lastPlayed, setLastPlayed] = useState(null);
// Создаём AudioContext по первому клику (autoplay policy)
const ensureCtx = async () => {
if (!ctxRef.current) {
const Ctor = window.AudioContext || window.webkitAudioContext;
if (!Ctor) {
alert('Web Audio API не поддерживается в этом браузере');
return null;
}
ctxRef.current = new Ctor();
masterRef.current = ctxRef.current.createGain();
masterRef.current.gain.value = 0.8;
masterRef.current.connect(ctxRef.current.destination);
setReady(true);
}
if (ctxRef.current.state === 'suspended') {
try { await ctxRef.current.resume(); } catch (e) {}
}
return ctxRef.current;
};
const playSfx = async (sfx) => {
const ctx = await ensureCtx();
if (!ctx) return;
try {
sfx.play(ctx, masterRef.current);
setLastPlayed(sfx.id);
// Сбросить highlight через duration
setTimeout(() => setLastPlayed(curr => curr === sfx.id ? null : curr), sfx.durationMs + 200);
} catch (e) {
console.error('[gd-sfx]', sfx.id, e);
}
};
const playAll = async () => {
const ctx = await ensureCtx();
if (!ctx) return;
let delay = 0;
for (const sfx of SFX_CATALOG) {
setTimeout(() => playSfx(sfx), delay);
delay += sfx.durationMs + 200;
}
};
// Cleanup
useEffect(() => () => {
if (ctxRef.current) {
try { ctxRef.current.close(); } catch (_) {}
}
}, []);
if (isLoading) return <div className={styles.loading}>Загрузка...</div>;
if (userRole !== 'admin') {
return (
<div className={styles.denied}>
<h2>Доступ только для администратора</h2>
<button onClick={() => navigate('/')} className={styles.backBtn}>На главную</button>
</div>
);
}
return (
<div className={styles.root}>
<div className={styles.topbar}>
<h1 className={styles.h1}>GD-Звуки превью {SFX_CATALOG.length} эффектов</h1>
<div className={styles.subline}>
Все процедурные (Web Audio API), нет файлов.
Реализация: <code>gdSfx/sfxFactories.js</code>
</div>
<div className={styles.controls}>
<button className={styles.playAllBtn} onClick={playAll}>
Проиграть всё подряд
</button>
{ready && <span className={styles.statusReady}>🔊 готов</span>}
</div>
</div>
<div className={styles.grid}>
{SFX_CATALOG.map(sfx => (
<div
key={sfx.id}
className={`${styles.card} ${lastPlayed === sfx.id ? styles.cardActive : ''}`}
>
<div className={styles.cardHeader}>
<span className={styles.emoji}>{sfx.emoji}</span>
<div>
<h3 className={styles.title}>{sfx.title}</h3>
<code className={styles.id}>{sfx.id}</code>
</div>
</div>
<p className={styles.description}>{sfx.description}</p>
<div className={styles.cardActions}>
<button className={styles.playBtn} onClick={() => playSfx(sfx)}>
Играть
</button>
<span className={styles.duration}>{sfx.durationMs} мс</span>
</div>
</div>
))}
</div>
<div className={styles.footer}>
<p><strong>Где использоваться:</strong></p>
<ul>
<li><code>jump</code> при тапе/Space (в L1+)</li>
<li><code>death</code> при касании шипа или падении в холу</li>
<li><code>orb_tap</code> при активации Jump Orb (L7+)</li>
<li><code>bounce</code> при отскоке от трамплина (L3+)</li>
<li><code>whoosh</code> при ускорении от speed pad (L4+)</li>
<li><code>flip</code> при перевороте гравитации (L12+)</li>
<li><code>coin</code> при сборе монеты (любой уровень)</li>
<li><code>level_complete</code> при касании финиш-портала</li>
<li><code>new_record</code> поверх level_complete если время лучше прошлого</li>
</ul>
</div>
</div>
);
}
export default GdSfxPreview;

View File

@ -0,0 +1,195 @@
.root {
min-height: 100vh;
background: #1f2433;
color: #e3e8f0;
padding: 24px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Arial, sans-serif;
}
.loading, .denied {
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: #1f2433;
color: #e3e8f0;
gap: 16px;
}
.backBtn {
padding: 8px 18px;
background: #228be6;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
}
.topbar {
margin-bottom: 20px;
padding-bottom: 14px;
border-bottom: 1px solid #2f3548;
}
.h1 {
margin: 0;
font-size: 22px;
font-weight: 700;
}
.subline {
margin-top: 6px;
color: #8a93a8;
font-size: 13px;
}
.subline code {
background: #2a3142;
padding: 1px 6px;
border-radius: 3px;
font-size: 12px;
}
.controls {
margin-top: 12px;
display: flex;
gap: 14px;
align-items: center;
}
.playAllBtn {
padding: 8px 18px;
background: linear-gradient(135deg, #3357ff, #b266ff);
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
font-weight: 600;
}
.playAllBtn:hover {
filter: brightness(1.1);
}
.statusReady {
color: #5fd886;
font-size: 12px;
font-weight: 600;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 14px;
}
.card {
background: #2a3142;
border: 1px solid #3a4156;
border-radius: 8px;
padding: 14px;
display: flex;
flex-direction: column;
transition: all 0.15s;
}
.cardActive {
border-color: #ffe44a;
box-shadow: 0 0 16px rgba(255, 228, 74, 0.45);
transform: scale(1.02);
}
.cardHeader {
display: flex;
align-items: center;
gap: 14px;
margin-bottom: 10px;
}
.emoji {
font-size: 36px;
line-height: 1;
}
.title {
margin: 0;
font-size: 15px;
font-weight: 600;
}
.id {
display: inline-block;
margin-top: 3px;
font-size: 11px;
color: #8a93a8;
background: #1c2030;
padding: 1px 6px;
border-radius: 3px;
}
.description {
flex: 1;
margin: 0 0 12px;
font-size: 12.5px;
line-height: 1.4;
color: #cdd4e0;
}
.cardActions {
display: flex;
align-items: center;
gap: 10px;
padding-top: 8px;
border-top: 1px dashed #3a4156;
}
.playBtn {
padding: 6px 14px;
background: #2db360;
color: white;
border: none;
border-radius: 5px;
cursor: pointer;
font-size: 13px;
font-weight: 600;
}
.playBtn:hover {
background: #34c970;
}
.duration {
margin-left: auto;
font-size: 11px;
color: #8a93a8;
}
.footer {
margin-top: 28px;
padding: 16px 20px;
background: #232938;
border-radius: 8px;
border: 1px solid #2f3548;
color: #cdd4e0;
font-size: 13px;
line-height: 1.6;
}
.footer p {
margin: 4px 0;
}
.footer ul {
margin: 8px 0;
padding-left: 22px;
}
.footer code {
background: #1c2030;
padding: 1px 6px;
border-radius: 3px;
font-size: 12px;
color: #66b3ff;
}

View File

@ -0,0 +1,140 @@
/**
* GdShipSkinsPreview превью 2D-скинов корабля для ship-режима GD (15 моделей).
* Маршрут: /admin-preview/gd-ship-skins
* Single-select. Сохраняется в kubikon3d_savegame (project_id=295, namespace='gd_ship_skin').
* data = { equipped: 'ss_v3' }
*
* Карточки плоские 256x256 canvas с draw() из shipSkinFactories.
* Это и есть тот самый визуал, что наложится на грань куба в игре.
*/
import React, { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../../auth/AuthContext.jsx';
import { jwtDecode } from 'jwt-decode';
import axios from 'axios';
import { STORYS_addres } from '../../api/API';
import { SHIP_SKIN_CATALOG, renderShipSkin } from './gdShipSkins/shipSkinFactories';
const CHOICES_PID = 295;
const CHOICES_NS = 'gd_ship_skin';
function getUserId() {
try { const t = localStorage.getItem('Authorization'); if (!t) return 0; return Number(jwtDecode(t).id) || 0; } catch (e) { return 0; }
}
const api = axios.create({ baseURL: STORYS_addres, timeout: 15000 });
api.interceptors.request.use((cfg) => {
try { const token = localStorage.getItem('Authorization'); if (token) cfg.headers.Authorization = token; } catch (e) {}
return cfg;
});
function SkinCard({ skin, isChosen, onPick }) {
const canvasRef = useRef(null);
useEffect(() => {
if (canvasRef.current) renderShipSkin(canvasRef.current, skin.id);
}, [skin]);
return (
<div
onClick={onPick}
style={{
...cardStyle,
border: isChosen ? '3px solid #44ccff' : '2px solid #2a3146',
boxShadow: isChosen ? '0 0 18px rgba(68,204,255,0.5)' : 'none',
cursor: 'pointer',
}}
>
<canvas
ref={canvasRef}
width={256}
height={256}
style={{ width: '100%', height: 'auto', display: 'block', imageRendering: 'pixelated' }}
/>
<div style={{ padding: '8px 12px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<div style={{ fontSize: 13, fontWeight: 700, color: '#fff' }}>{skin.title}</div>
<div style={{ fontSize: 11, color: '#888' }}><code>{skin.id}</code></div>
</div>
{isChosen && <span style={{ color: '#44ccff', fontSize: 22 }}></span>}
</div>
</div>
);
}
export default function GdShipSkinsPreview() {
const { userRole, isLoading } = useAuth();
const navigate = useNavigate();
const [equipped, setEquipped] = useState(null);
const [status, setStatus] = useState('idle');
useEffect(() => {
const uid = getUserId();
if (!uid) return;
api.get(`/kubikon3d/savegame/${CHOICES_PID}/${uid}/${CHOICES_NS}`)
.then((r) => setEquipped(r.data?.data?.equipped || null))
.catch(() => {});
}, []);
const save = async () => {
const uid = getUserId();
if (!uid || !equipped) { setStatus('error'); return; }
setStatus('loading');
try {
await api.post(`/kubikon3d/savegame/${CHOICES_PID}/${uid}/${CHOICES_NS}`, { data: { equipped } });
setStatus('saved');
} catch (e) { console.warn('[GdShipSkinsPreview] save failed', e); setStatus('error'); }
};
if (isLoading) return <div style={{ padding: 24, color: '#fff' }}>Загрузка...</div>;
if (userRole !== 'admin') {
return (
<div style={{ padding: 24, color: '#fff', background: '#070a14', minHeight: '100vh' }}>
<h2>Доступ только для администратора</h2>
<button onClick={() => navigate('/')} style={btnStyle}>На главную</button>
</div>
);
}
return (
<div style={{ minHeight: '100vh', background: '#070a14', color: '#fff', padding: '20px 24px' }}>
<div style={{
position: 'sticky', top: 0, background: '#070a14', zIndex: 10,
padding: '12px 0', marginBottom: 16, borderBottom: '1px solid #2a3146',
display: 'flex', alignItems: 'center', gap: 16, flexWrap: 'wrap',
}}>
<button onClick={() => navigate('/')} style={btnStyle}> Назад</button>
<h1 style={{ margin: 0, fontSize: 22, color: '#44ccff' }}>GD Скины-обложки корабля ({SHIP_SKIN_CATALOG.length})</h1>
<div style={{ flex: 1, fontSize: 14, color: '#888' }}>
{equipped ? <>Выбрано: <span style={{ color: '#44ccff', fontWeight: 700 }}>{equipped}</span></> : 'Не выбрано'}
</div>
<button onClick={save} disabled={status === 'loading' || !equipped} style={{
...btnPrimary, opacity: status === 'loading' || !equipped ? 0.5 : 1,
}}>
{status === 'loading' ? '⏳ Сохраняю...' : status === 'saved' ? '✓ Сохранено' : '💾 Сохранить выбор'}
</button>
{status === 'error' && <span style={{ color: '#ff5566' }}>Ошибка</span>}
</div>
<div style={{ marginBottom: 20, color: '#aab', fontSize: 14, lineHeight: 1.5 }}>
Выбранная текстура накладывается на грани куба в ship-режиме (после прохода через ship-портал).
Сам куб остаётся кубом, но визуально выглядит как корабль/НЛО/шар.
</div>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))',
gap: 16,
}}>
{SHIP_SKIN_CATALOG.map(skin => (
<SkinCard
key={skin.id}
skin={skin}
isChosen={equipped === skin.id}
onPick={() => { setEquipped(skin.id); setStatus('idle'); }}
/>
))}
</div>
</div>
);
}
const cardStyle = { background: '#0e1525', borderRadius: 10, overflow: 'hidden', transition: 'all 0.15s' };
const btnStyle = { padding: '10px 18px', background: '#2a3146', color: '#cdd4e0', border: 'none', borderRadius: 8, cursor: 'pointer', fontWeight: 600, fontSize: 14 };
const btnPrimary = { padding: '10px 24px', background: 'linear-gradient(135deg, #44ccff, #2266ff)', color: '#000', border: 'none', borderRadius: 8, cursor: 'pointer', fontWeight: 700, fontSize: 15 };

View File

@ -0,0 +1,362 @@
/**
* GdSkinsPreview превью всех GD-скинов куба разом.
*
* Маршрут: /admin-preview/gd-skins
*
* Каждый скин отдельная мини-Babylon-сцена с вращающимся кубом.
* Текстура куба DynamicTexture из canvas-фабрики (CUBE_SKINS[*].draw).
* Глобальный переключатель цветов (primary/secondary) применяется ко всем.
*
* Анимированные скины (cube_flame, cube_cat) перерисовывают canvas каждый кадр.
*/
import React, { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../../auth/AuthContext.jsx';
import styles from './GdSkinsPreview.module.css';
import {
CUBE_SKINS, CUBE_TRAILS, SKIN_COLORS,
DEFAULT_PRIMARY, DEFAULT_SECONDARY,
renderSkin,
} from './gdSkins/cubeSkinFactories';
import { Engine } from '@babylonjs/core/Engines/engine';
import { Scene } from '@babylonjs/core/scene';
import { ArcRotateCamera } from '@babylonjs/core/Cameras/arcRotateCamera';
import { HemisphericLight } from '@babylonjs/core/Lights/hemisphericLight';
import { DirectionalLight } from '@babylonjs/core/Lights/directionalLight';
import { Vector3, Color3, Color4 } from '@babylonjs/core/Maths/math';
import { MeshBuilder } from '@babylonjs/core/Meshes/meshBuilder';
import { StandardMaterial } from '@babylonjs/core/Materials/standardMaterial';
import { DynamicTexture } from '@babylonjs/core/Materials/Textures/dynamicTexture';
const CUBE_SIZE = 1.2;
/**
* Карточка одного скина куба.
* Принимает skin + текущие primary/secondary цвета (могут меняться сверху).
*/
function CubeSkinCard({ skin, primary, secondary }) {
const canvasRef = useRef(null);
const textureCanvasRef = useRef(null); // отдельный 2D-canvas для DynamicTexture
useEffect(() => {
if (!canvasRef.current) return;
let engine = null;
// 2D-canvas для текстуры (off-screen)
const tex2d = document.createElement('canvas');
tex2d.width = 256;
tex2d.height = 256;
textureCanvasRef.current = tex2d;
engine = new Engine(canvasRef.current, true, { preserveDrawingBuffer: true });
const scene = new Scene(engine);
scene.clearColor = new Color4(0.12, 0.14, 0.20, 1);
// Камера
const cam = new ArcRotateCamera('cam',
-Math.PI / 4, Math.PI / 2.8, 3.0,
new Vector3(0, 0, 0), scene);
cam.attachControl(canvasRef.current, true);
cam.wheelPrecision = 50;
cam.lowerRadiusLimit = 1.8;
cam.upperRadiusLimit = 6;
// Свет
const hemi = new HemisphericLight('h', new Vector3(0, 1, 0), scene);
hemi.intensity = 0.6;
hemi.groundColor = new Color3(0.3, 0.3, 0.4);
const dir = new DirectionalLight('d', new Vector3(-0.5, -1, -0.3), scene);
dir.intensity = 0.7;
// Куб
const cube = MeshBuilder.CreateBox('cube', { size: CUBE_SIZE }, scene);
const mat = new StandardMaterial('mat', scene);
const dt = new DynamicTexture('dt_' + skin.id, { width: 256, height: 256 }, scene, false);
mat.diffuseTexture = dt;
mat.specularColor = new Color3(0.1, 0.1, 0.1);
cube.material = mat;
// Платформа под кубом
const ground = MeshBuilder.CreateDisc('ground', { radius: 1.4, tessellation: 32 }, scene);
ground.rotation.x = Math.PI / 2;
ground.position.y = -CUBE_SIZE / 2 - 0.01;
const gmat = new StandardMaterial('gmat', scene);
gmat.diffuseColor = new Color3(0.2, 0.22, 0.28);
ground.material = gmat;
let time = 0;
let stopped = false;
const refreshTexture = (t) => {
const ctx = tex2d.getContext('2d');
renderSkin(tex2d, skin.id, primary, secondary, t);
// копируем в DynamicTexture
const dtCtx = dt.getContext();
dtCtx.drawImage(tex2d, 0, 0);
dt.update(false);
};
refreshTexture(0);
scene.onBeforeRenderObservable.add(() => {
cube.rotation.y += 0.005;
cube.rotation.x = Math.sin(time * 0.5) * 0.15;
time += engine.getDeltaTime() / 1000;
if (skin.animated) refreshTexture(time);
});
engine.runRenderLoop(() => {
if (!stopped) scene.render();
});
const onResize = () => engine.resize();
window.addEventListener('resize', onResize);
return () => {
stopped = true;
window.removeEventListener('resize', onResize);
try { engine.dispose(); } catch (_) {}
};
}, [skin.id, primary, secondary]);
return (
<div className={styles.card}>
<div className={styles.header}>
<h3 className={styles.title}>{skin.title}</h3>
<span className={styles.category}>{skin.category}</span>
</div>
<div className={styles.canvasWrap}>
<canvas ref={canvasRef} className={styles.canvas} />
</div>
<div className={styles.meta}>
<span className={styles.price}>
{skin.priceStars === 0 ? 'бесплатно' : `${skin.priceStars}`}
</span>
{skin.animated && <span className={styles.animated}>🎬 анимация</span>}
<code className={styles.skinId}>{skin.id}</code>
</div>
</div>
);
}
/**
* Карточка trail-эффекта.
* Показывает движущийся куб с шлейфом из частиц (для визуального теста).
*/
function TrailCard({ trail }) {
const canvasRef = useRef(null);
useEffect(() => {
if (!canvasRef.current) return;
let engine = null;
let stopped = false;
(async () => {
const { ParticleSystem } = await import('@babylonjs/core/Particles/particleSystem');
const { Texture } = await import('@babylonjs/core/Materials/Textures/texture');
engine = new Engine(canvasRef.current, true, { preserveDrawingBuffer: true });
const scene = new Scene(engine);
scene.clearColor = new Color4(0.12, 0.14, 0.20, 1);
const cam = new ArcRotateCamera('cam',
-Math.PI / 2 - 0.1, Math.PI / 2.5, 4,
new Vector3(0, 0, 0), scene);
cam.attachControl(canvasRef.current, true);
const hemi = new HemisphericLight('h', new Vector3(0, 1, 0), scene);
hemi.intensity = 0.7;
// Куб который бегает по горизонтали оставляет след
const cube = MeshBuilder.CreateBox('cube', { size: 0.6 }, scene);
const mat = new StandardMaterial('m', scene);
mat.diffuseColor = new Color3(0.4, 1.0, 0.4);
cube.material = mat;
// Дисочек-пол
const g = MeshBuilder.CreateGround('g', { width: 6, height: 1.5 }, scene);
g.position.y = -0.4;
const gmat = new StandardMaterial('gm', scene);
gmat.diffuseColor = new Color3(0.2, 0.22, 0.28);
g.material = gmat;
// Текстура частицы (просто белый круг)
const partCanvas = document.createElement('canvas');
partCanvas.width = 64; partCanvas.height = 64;
const pctx = partCanvas.getContext('2d');
const grad = pctx.createRadialGradient(32, 32, 0, 32, 32, 32);
grad.addColorStop(0, 'rgba(255,255,255,1)');
grad.addColorStop(1, 'rgba(255,255,255,0)');
pctx.fillStyle = grad;
pctx.fillRect(0, 0, 64, 64);
const partTex = new DynamicTexture('partTex', { width: 64, height: 64 }, scene, false);
partTex.getContext().drawImage(partCanvas, 0, 0);
partTex.update(false);
const ps = new ParticleSystem('ps', 200, scene);
ps.particleTexture = partTex;
ps.emitter = cube;
ps.minEmitBox = new Vector3(0, 0, 0);
ps.maxEmitBox = new Vector3(0, 0, 0);
ps.emitRate = trail.params.emissionRate;
ps.minLifeTime = trail.params.lifetime * 0.7;
ps.maxLifeTime = trail.params.lifetime;
ps.minSize = trail.params.sizeStart * 0.6;
ps.maxSize = trail.params.sizeStart;
ps.direction1 = new Vector3(-0.3, -0.3, 0);
ps.direction2 = new Vector3(-0.7, 0.3, 0);
ps.minEmitPower = 0.4;
ps.maxEmitPower = 0.8;
ps.gravity = new Vector3(0, -0.5, 0);
// Цвета частиц
const parseHex = (h) => {
const r = parseInt(h.slice(1, 3), 16) / 255;
const g = parseInt(h.slice(3, 5), 16) / 255;
const b = parseInt(h.slice(5, 7), 16) / 255;
return new Color4(r, g, b, 1);
};
if (trail.params.colorStart === 'rainbow') {
// радуга цикл по цветам
ps.color1 = new Color4(1, 0.3, 0.3, 1);
ps.color2 = new Color4(0.3, 0.6, 1, 1);
ps.colorDead = new Color4(1, 1, 1, 0);
} else {
ps.color1 = parseHex(trail.params.colorStart);
ps.color2 = parseHex(trail.params.colorStart);
const cd = parseHex(trail.params.colorEnd);
cd.a = 0;
ps.colorDead = cd;
}
ps.start();
// Куб двигается по дуге туда-сюда
let t = 0;
scene.onBeforeRenderObservable.add(() => {
t += engine.getDeltaTime() / 1000;
cube.position.x = Math.sin(t * 1.5) * 1.5;
cube.rotation.y = t;
cube.rotation.z = t * 0.7;
// Для радуги циклически меняем цвет
if (trail.params.colorStart === 'rainbow') {
const r = 0.5 + 0.5 * Math.sin(t * 2);
const g = 0.5 + 0.5 * Math.sin(t * 2 + 2);
const b = 0.5 + 0.5 * Math.sin(t * 2 + 4);
ps.color1 = new Color4(r, g, b, 1);
ps.color2 = new Color4(g, b, r, 1);
}
});
engine.runRenderLoop(() => { if (!stopped) scene.render(); });
const onResize = () => engine.resize();
window.addEventListener('resize', onResize);
})();
return () => {
stopped = true;
if (engine) {
try { engine.dispose(); } catch (_) {}
}
};
}, [trail.id]);
return (
<div className={styles.card}>
<div className={styles.header}>
<h3 className={styles.title}>{trail.title}</h3>
<span className={styles.category}>Trail</span>
</div>
<div className={styles.canvasWrap}>
<canvas ref={canvasRef} className={styles.canvas} />
</div>
<div className={styles.meta}>
<span className={styles.price}>
{trail.priceStars === 0 ? 'бесплатно' : `${trail.priceStars}`}
</span>
<code className={styles.skinId}>{trail.id}</code>
</div>
</div>
);
}
function GdSkinsPreview() {
const { userRole, isLoading } = useAuth();
const navigate = useNavigate();
const [primary, setPrimary] = useState(DEFAULT_PRIMARY);
const [secondary, setSecondary] = useState(DEFAULT_SECONDARY);
if (isLoading) return <div className={styles.loading}>Загрузка...</div>;
if (userRole !== 'admin') {
return (
<div className={styles.denied}>
<h2>Доступ только для администратора</h2>
<button onClick={() => navigate('/')} className={styles.backBtn}>На главную</button>
</div>
);
}
return (
<div className={styles.root}>
<div className={styles.topbar}>
<h1 className={styles.h1}>GD-Скины куба превью {CUBE_SKINS.length} дизайнов + {CUBE_TRAILS.length} trail</h1>
<div className={styles.subline}>
Все скины генерируются программно через canvas (cubeSkinFactories.js) никаких PNG-файлов
</div>
</div>
{/* Палитра цветов */}
<div className={styles.colorBar}>
<div className={styles.colorGroup}>
<span className={styles.colorLabel}>Основной:</span>
{SKIN_COLORS.map(c => (
<button
key={'p_' + c.id}
className={primary === c.hex ? styles.colorBtnActive : styles.colorBtn}
style={{ background: c.hex }}
onClick={() => setPrimary(c.hex)}
title={c.title}
/>
))}
</div>
<div className={styles.colorGroup}>
<span className={styles.colorLabel}>Контур:</span>
{SKIN_COLORS.map(c => (
<button
key={'s_' + c.id}
className={secondary === c.hex ? styles.colorBtnActive : styles.colorBtn}
style={{ background: c.hex }}
onClick={() => setSecondary(c.hex)}
title={c.title}
/>
))}
</div>
<button
className={styles.resetBtn}
onClick={() => { setPrimary(DEFAULT_PRIMARY); setSecondary(DEFAULT_SECONDARY); }}
>Сброс</button>
</div>
<h2 className={styles.h2}>Скины куба ({CUBE_SKINS.length})</h2>
<div className={styles.grid}>
{CUBE_SKINS.map(s => (
<CubeSkinCard key={s.id} skin={s} primary={primary} secondary={secondary} />
))}
</div>
<h2 className={styles.h2}>Trail-эффекты ({CUBE_TRAILS.length})</h2>
<div className={styles.grid}>
{CUBE_TRAILS.map(t => (
<TrailCard key={t.id} trail={t} />
))}
</div>
<div className={styles.footer}>
<p>Тащи мышью на любой карточке куб крутится руками. Меняй цвета сверху все скины обновятся синхронно.</p>
<p>Если нужны правки скажи МИНу, я подкручу canvas-функции в <code>cubeSkinFactories.js</code>.</p>
</div>
</div>
);
}
export default GdSkinsPreview;

View File

@ -0,0 +1,213 @@
.root {
min-height: 100vh;
background: #1f2433;
color: #e3e8f0;
padding: 24px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Arial, sans-serif;
}
.loading, .denied {
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: #1f2433;
color: #e3e8f0;
gap: 16px;
}
.backBtn {
padding: 8px 18px;
background: #228be6;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
}
.topbar {
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid #2f3548;
}
.h1 {
margin: 0;
font-size: 22px;
font-weight: 700;
}
.subline {
margin-top: 6px;
color: #8a93a8;
font-size: 13px;
}
.h2 {
margin: 24px 0 12px;
font-size: 18px;
font-weight: 600;
color: #cdd4e0;
}
/* Палитра цветов */
.colorBar {
background: #2a3142;
border: 1px solid #3a4156;
border-radius: 10px;
padding: 12px 16px;
margin-bottom: 20px;
display: flex;
flex-wrap: wrap;
gap: 18px;
align-items: center;
}
.colorGroup {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.colorLabel {
font-size: 13px;
color: #cdd4e0;
margin-right: 4px;
}
.colorBtn, .colorBtnActive {
width: 28px;
height: 28px;
border-radius: 5px;
border: 2px solid transparent;
cursor: pointer;
transition: transform 0.1s;
}
.colorBtn:hover {
transform: scale(1.1);
}
.colorBtnActive {
border: 2px solid #ffe44a;
box-shadow: 0 0 8px rgba(255, 228, 74, 0.6);
transform: scale(1.15);
}
.resetBtn {
margin-left: auto;
padding: 6px 12px;
background: #3a4156;
color: #e3e8f0;
border: none;
border-radius: 5px;
cursor: pointer;
font-size: 12px;
}
.resetBtn:hover {
background: #4a5169;
}
/* Сетка карточек */
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
}
.card {
background: #2a3142;
border: 1px solid #3a4156;
border-radius: 10px;
overflow: hidden;
display: flex;
flex-direction: column;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 10px 14px;
background: #232938;
border-bottom: 1px solid #2f3548;
}
.title {
margin: 0;
font-size: 15px;
font-weight: 600;
}
.category {
font-size: 11px;
color: #8a93a8;
background: #1c2030;
padding: 2px 7px;
border-radius: 4px;
}
.canvasWrap {
aspect-ratio: 1;
background: #1c2030;
}
.canvas {
width: 100%;
height: 100%;
display: block;
touch-action: none;
}
.meta {
padding: 8px 14px 10px;
font-size: 12px;
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
border-top: 1px dashed #3a4156;
}
.price {
color: #ffe44a;
font-weight: 600;
}
.animated {
color: #66b3ff;
font-size: 11px;
}
.skinId {
margin-left: auto;
color: #8a93a8;
font-size: 11px;
background: #1c2030;
padding: 2px 6px;
border-radius: 3px;
}
.footer {
margin-top: 28px;
padding-top: 16px;
border-top: 1px solid #2f3548;
color: #8a93a8;
font-size: 13px;
}
.footer p {
margin: 4px 0;
}
.footer code {
background: #2a3142;
padding: 1px 5px;
border-radius: 3px;
font-size: 12px;
}

View File

@ -0,0 +1,258 @@
/**
* GdSpikesPreview превью 100 вариантов шипов (10 эпох × 10).
*
* Маршрут: /admin-preview/gd-spikes
*
* Юзер выбирает по одному шипу на каждую эпоху (радио-кнопка), нажимает
* «Сохранить» выборы пишутся в kubikon3d_savegame
* (project_id=295, namespace='gd_spike_choices') как { '1': 'forest_wood', ... }.
*
* После сохранения Claude может прочитать БД и подставить выбранный шип
* в GdSpikes.js для соответствующих 10 уровней.
*/
import React, { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../../auth/AuthContext.jsx';
import { jwtDecode } from 'jwt-decode';
import axios from 'axios';
import { STORYS_addres } from '../../api/API';
import { Engine } from '@babylonjs/core/Engines/engine';
import { Scene } from '@babylonjs/core/scene';
import { ArcRotateCamera } from '@babylonjs/core/Cameras/arcRotateCamera';
import { HemisphericLight } from '@babylonjs/core/Lights/hemisphericLight';
import { DirectionalLight } from '@babylonjs/core/Lights/directionalLight';
import { Vector3, Color3, Color4 } from '@babylonjs/core/Maths/math';
import { MeshBuilder } from '@babylonjs/core/Meshes/meshBuilder';
import { StandardMaterial } from '@babylonjs/core/Materials/standardMaterial';
import { SPIKE_CATALOG, EPOCH_INFO, getSpikesByEpoch } from './gdSpikes/spikeFactories';
const CHOICES_PID = 295; // sandbox-проект для хранения админ-настроек
const CHOICES_NS = 'gd_spike_choices';
function getUserId() {
try {
const t = localStorage.getItem('Authorization');
if (!t) return 0;
return Number(jwtDecode(t).id) || 0;
} catch (e) { return 0; }
}
const api = axios.create({ baseURL: STORYS_addres, timeout: 15000 });
api.interceptors.request.use((cfg) => {
try {
const token = localStorage.getItem('Authorization');
if (token) cfg.headers.Authorization = token;
} catch (e) {}
return cfg;
});
function SpikeCard({ spike, isChosen, onChoose }) {
const wrapRef = useRef(null);
const canvasRef = useRef(null);
const [isVisible, setIsVisible] = useState(false);
// 1) IntersectionObserver отслеживаем когда карточка в viewport
useEffect(() => {
if (!wrapRef.current) return;
const io = new IntersectionObserver((entries) => {
for (const e of entries) {
setIsVisible(e.isIntersecting);
}
}, { rootMargin: '200px', threshold: 0.01 });
io.observe(wrapRef.current);
return () => io.disconnect();
}, []);
// 2) Babylon-сцена только пока карточка видима
useEffect(() => {
if (!isVisible || !canvasRef.current) return;
let engine = null, scene = null;
try {
engine = new Engine(canvasRef.current, true, {
stencil: false, preserveDrawingBuffer: false, antialias: true,
});
scene = new Scene(engine);
scene.clearColor = new Color4(0.05, 0.07, 0.12, 1);
const camera = new ArcRotateCamera('cam', -Math.PI / 2.5, Math.PI / 2.7, 3.6, new Vector3(0, 0.7, 0), scene);
camera.attachControl(canvasRef.current, false);
camera.minZ = 0.1;
camera.maxZ = 50;
new HemisphericLight('hemi', new Vector3(0, 1, 0), scene).intensity = 0.6;
const sun = new DirectionalLight('sun', new Vector3(-0.5, -1, -0.3), scene);
sun.intensity = 0.8;
const floor = MeshBuilder.CreateGround('floor', { width: 3, height: 3 }, scene);
const fmat = new StandardMaterial('fmat', scene);
fmat.diffuseColor = new Color3(0.18, 0.22, 0.25);
floor.material = fmat;
const handle = spike.make(scene, `prev_${spike.id}`);
if (handle && handle.root) handle.root.position.y = 0;
scene.onBeforeRenderObservable.add(() => {
if (handle && handle.root) handle.root.rotation.y += 0.012;
});
engine.runRenderLoop(() => scene.render());
return () => {
try { engine && engine.stopRenderLoop(); } catch (e) {}
try { handle && handle.dispose && handle.dispose(); } catch (e) {}
try { scene && scene.dispose(); } catch (e) {}
try { engine && engine.dispose(); } catch (e) {}
};
} catch (e) {
console.warn('[SpikeCard] init failed', e);
return () => {};
}
}, [isVisible, spike]);
return (
<div
ref={wrapRef}
onClick={onChoose}
style={{
...cardStyle,
border: isChosen ? '3px solid #22ff66' : '2px solid #2a3146',
boxShadow: isChosen ? '0 0 16px rgba(34,255,102,0.4)' : 'none',
cursor: 'pointer',
}}
>
{isVisible
? <canvas ref={canvasRef} style={{ width: '100%', height: 180, display: 'block', background: '#0a1020' }} />
: <div style={{ width: '100%', height: 180, background: '#0a1020', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#444' }}></div>}
<div style={{ padding: '10px 12px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<div style={{ fontSize: 14, fontWeight: 700, color: '#fff', marginBottom: 2 }}>{spike.title}</div>
<div style={{ fontSize: 11, color: '#888' }}><code>{spike.id}</code></div>
</div>
{isChosen && <span style={{ color: '#22ff66', fontSize: 20 }}></span>}
</div>
</div>
);
}
export default function GdSpikesPreview() {
const { userRole, isLoading } = useAuth();
const navigate = useNavigate();
// choices: { 1: 'e1_v1', 2: 'e2_v3', ... }
const [choices, setChoices] = useState({});
const [status, setStatus] = useState('idle'); // 'idle' | 'loading' | 'saved' | 'error'
// Загрузка сохранённых выборов
useEffect(() => {
const uid = getUserId();
if (!uid) return;
api.get(`/kubikon3d/savegame/${CHOICES_PID}/${uid}/${CHOICES_NS}`)
.then((r) => {
const data = r.data?.data || {};
setChoices(data);
})
.catch(() => {});
}, []);
const choose = (epoch, spikeId) => {
setChoices(prev => ({ ...prev, [epoch]: spikeId }));
setStatus('idle');
};
const save = async () => {
const uid = getUserId();
if (!uid) { setStatus('error'); return; }
setStatus('loading');
try {
await api.post(`/kubikon3d/savegame/${CHOICES_PID}/${uid}/${CHOICES_NS}`, {
data: choices,
});
setStatus('saved');
} catch (e) {
console.warn('[GdSpikesPreview] save failed', e);
setStatus('error');
}
};
if (isLoading) return <div style={{ padding: 24, color: '#fff' }}>Загрузка...</div>;
if (userRole !== 'admin') {
return (
<div style={{ padding: 24, color: '#fff', background: '#070a14', minHeight: '100vh' }}>
<h2>Доступ только для администратора</h2>
<button onClick={() => navigate('/')} style={btnStyle}>На главную</button>
</div>
);
}
const chosenCount = Object.keys(choices).length;
return (
<div style={{ minHeight: '100vh', background: '#070a14', color: '#fff', padding: '20px 24px' }}>
<div style={{
position: 'sticky', top: 0, background: '#070a14', zIndex: 10,
padding: '12px 0', marginBottom: 16, borderBottom: '1px solid #2a3146',
display: 'flex', alignItems: 'center', gap: 16, flexWrap: 'wrap',
}}>
<button onClick={() => navigate('/')} style={btnStyle}> Назад</button>
<h1 style={{ margin: 0, fontSize: 24, color: '#22ff66' }}>GD Шипы по эпохам ({SPIKE_CATALOG.length})</h1>
<div style={{ flex: 1, fontSize: 14, color: '#888' }}>
Выбрано: <span style={{ color: '#fff', fontWeight: 700 }}>{chosenCount}/10</span> эпох
</div>
<button onClick={save} disabled={status === 'loading' || chosenCount === 0} style={{
...btnPrimary,
opacity: status === 'loading' || chosenCount === 0 ? 0.5 : 1,
}}>
{status === 'loading' ? '⏳ Сохраняю...' : status === 'saved' ? '✓ Сохранено' : '💾 Сохранить выбор'}
</button>
{status === 'error' && <span style={{ color: '#ff5566' }}>Ошибка сохранения</span>}
</div>
{EPOCH_INFO.map(epoch => {
const spikes = getSpikesByEpoch(epoch.n);
const chosenId = choices[epoch.n];
return (
<div key={epoch.n} style={{ marginBottom: 32 }}>
<h2 style={{ fontSize: 22, color: epoch.color, marginBottom: 12, borderBottom: `2px solid ${epoch.color}33`, paddingBottom: 8 }}>
{epoch.emoji} Эпоха {epoch.n} {epoch.name}
<span style={{ fontSize: 14, color: '#666', marginLeft: 12 }}>L{(epoch.n - 1) * 10 + 1} L{epoch.n * 10}</span>
{chosenId && (
<span style={{ fontSize: 14, color: '#22ff66', marginLeft: 12 }}>
выбран: <code>{chosenId}</code>
</span>
)}
</h2>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))',
gap: 16,
}}>
{spikes.map(spike => (
<SpikeCard
key={spike.id}
spike={spike}
isChosen={chosenId === spike.id}
onChoose={() => choose(epoch.n, spike.id)}
/>
))}
</div>
</div>
);
})}
</div>
);
}
const cardStyle = {
background: '#0e1525',
borderRadius: 10,
overflow: 'hidden',
transition: 'all 0.15s',
};
const btnStyle = {
padding: '10px 18px', background: '#2a3146', color: '#cdd4e0',
border: 'none', borderRadius: 8, cursor: 'pointer', fontWeight: 600, fontSize: 14,
};
const btnPrimary = {
padding: '10px 24px', background: 'linear-gradient(135deg, #22ff66, #44aaff)',
color: '#000', border: 'none', borderRadius: 8, cursor: 'pointer',
fontWeight: 700, fontSize: 15,
};

View File

@ -0,0 +1,535 @@
/**
* archFactories реестр стартовых арок GD по эпохам.
*
* 10 эпох × 5 вариантов = 50 арок. Каждая фабрика строит арку:
* - две вертикальные «колонны» (cylinder/box) высотой ~4м.
* - горизонтальная «балка» сверху.
* - надпись «СТАРТ» (DynamicTexture на plane) посередине балки.
* - подсветка/glow в стиле эпохи.
*
* Возвращает (scene, id) => { root: TransformNode, dispose() }.
* Габариты: ширина ~3.5м, высота ~4м, центр в (0, 0, 0).
*/
import {
MeshBuilder, StandardMaterial, Color3, Vector3, TransformNode,
DynamicTexture,
} from '@babylonjs/core';
// =========================================================================
// УТИЛИТЫ
// =========================================================================
function makeMat(scene, name, opts = {}) {
const m = new StandardMaterial(name, scene);
m.diffuseColor = new Color3(...(opts.diffuse || [1, 1, 1]));
m.emissiveColor = new Color3(...(opts.emissive || [0, 0, 0]));
m.specularColor = new Color3(...(opts.specular || [0.2, 0.2, 0.2]));
if (opts.specPower != null) m.specularPower = opts.specPower;
if (opts.alpha != null) m.alpha = opts.alpha;
if (opts.disableLighting) m.disableLighting = true;
return m;
}
function makeColumn(scene, name, opts = {}) {
const {
height = 3.5, diameter = 0.5,
shape = 'cylinder', // 'cylinder' | 'box'
diffuse = [0.4, 0.3, 0.2], emissive = [0.05, 0.04, 0.03],
specular, specPower,
} = opts;
let mesh;
if (shape === 'box') {
mesh = MeshBuilder.CreateBox(name, { width: diameter, height, depth: diameter }, scene);
} else {
mesh = MeshBuilder.CreateCylinder(name, {
diameter, height, tessellation: 12,
}, scene);
}
mesh.position.y = height / 2;
mesh.material = makeMat(scene, `${name}_mat`, { diffuse, emissive, specular, specPower });
return mesh;
}
function makeBeam(scene, name, opts = {}) {
const {
width = 3.5, height = 0.5, depth = 0.5,
diffuse = [0.4, 0.3, 0.2], emissive = [0.05, 0.04, 0.03],
specular, specPower,
} = opts;
const beam = MeshBuilder.CreateBox(name, { width, height, depth }, scene);
beam.material = makeMat(scene, `${name}_mat`, { diffuse, emissive, specular, specPower });
return beam;
}
/** Табличка с надписью «СТАРТ» (DynamicTexture на plane). */
function makeSignText(scene, name, text, opts = {}) {
const {
width = 2.4, height = 0.7,
bgColor = '#0a1428', textColor = '#22ff66',
fontSize = 96, fontFamily = 'sans-serif',
emissive = [0.2, 1.0, 0.4],
} = opts;
const TW = 512, TH = 128;
const dt = new DynamicTexture(`${name}_tex`, { width: TW, height: TH }, scene, true);
const ctx = dt.getContext();
ctx.fillStyle = bgColor;
ctx.fillRect(0, 0, TW, TH);
ctx.fillStyle = textColor;
ctx.font = `bold ${fontSize}px ${fontFamily}`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(text, TW / 2, TH / 2);
dt.hasAlpha = false;
dt.update();
const plane = MeshBuilder.CreatePlane(name, { width, height }, scene);
const mat = new StandardMaterial(`${name}_mat`, scene);
mat.diffuseTexture = dt;
mat.emissiveTexture = dt;
mat.emissiveColor = new Color3(...emissive);
mat.disableLighting = true;
mat.backFaceCulling = false;
plane.material = mat;
return plane;
}
/** Сферическая «лампочка»-glow (для гирлянд/неона). */
function makeBulb(scene, name, x, y, z, color, scale = 1) {
const bulb = MeshBuilder.CreateSphere(name, { diameter: 0.18, segments: 8 }, scene);
bulb.position.set(x, y, z);
bulb.scaling.set(scale, scale, scale);
const mat = makeMat(scene, `${name}_mat`, { diffuse: color, emissive: color, disableLighting: true });
bulb.material = mat;
return bulb;
}
// =========================================================================
// ФАБРИКИ АРОК ПО ЭПОХАМ (по 5 на эпоху)
// =========================================================================
// ----------------------------------------- Эпоха I — Лес -----
function archForestWood(scene, id) {
const root = new TransformNode(`arch_${id}`, scene);
const COL_H = 3.6, GAP = 3.0;
const colL = makeColumn(scene, `${id}_colL`, { height: COL_H, diameter: 0.55,
diffuse: [0.40, 0.25, 0.13], emissive: [0.10, 0.07, 0.04] });
const colR = makeColumn(scene, `${id}_colR`, { height: COL_H, diameter: 0.55,
diffuse: [0.40, 0.25, 0.13], emissive: [0.10, 0.07, 0.04] });
colL.position.x = -GAP / 2;
colR.position.x = GAP / 2;
colL.parent = root; colR.parent = root;
const beam = makeBeam(scene, `${id}_beam`, {
width: GAP + 0.7, height: 0.45, depth: 0.45,
diffuse: [0.45, 0.28, 0.15], emissive: [0.10, 0.07, 0.04],
});
beam.position.y = COL_H + 0.225;
beam.parent = root;
// листва — зелёная сферка над балкой
for (let i = 0; i < 5; i++) {
const leaf = MeshBuilder.CreateSphere(`${id}_leaf_${i}`, { diameter: 0.7 }, scene);
leaf.position.set(-1.4 + i * 0.7, COL_H + 0.7, 0);
const mat = makeMat(scene, `${id}_leaf_mat_${i}`, {
diffuse: [0.2, 0.55, 0.25], emissive: [0.06, 0.18, 0.08],
});
leaf.material = mat;
leaf.parent = root;
}
const sign = makeSignText(scene, `${id}_sign`, 'СТАРТ', {
width: 2.4, height: 0.7, textColor: '#22ff66',
});
sign.position.set(0, COL_H - 0.3, 0.26);
sign.parent = root;
return { root, dispose: () => { for (const ch of root.getChildMeshes()) ch.dispose(); root.dispose(); } };
}
function archForestStone(scene, id) {
const root = new TransformNode(`arch_${id}`, scene);
const COL_H = 3.4, GAP = 3.0;
const stoneDif = [0.55, 0.55, 0.50];
const colL = makeColumn(scene, `${id}_colL`, { height: COL_H, diameter: 0.7, shape: 'box',
diffuse: stoneDif, emissive: [0.10, 0.10, 0.09] });
const colR = makeColumn(scene, `${id}_colR`, { height: COL_H, diameter: 0.7, shape: 'box',
diffuse: stoneDif, emissive: [0.10, 0.10, 0.09] });
colL.position.x = -GAP / 2;
colR.position.x = GAP / 2;
colL.parent = root; colR.parent = root;
const beam = makeBeam(scene, `${id}_beam`, {
width: GAP + 0.9, height: 0.55, depth: 0.55,
diffuse: stoneDif, emissive: [0.10, 0.10, 0.09],
});
beam.position.y = COL_H + 0.275;
beam.parent = root;
// мох сверху
for (let i = 0; i < 7; i++) {
const moss = MeshBuilder.CreateSphere(`${id}_moss_${i}`, { diameter: 0.35 }, scene);
moss.position.set(-1.7 + i * 0.55, COL_H + 0.55, 0);
moss.material = makeMat(scene, `${id}_moss_mat_${i}`,
{ diffuse: [0.25, 0.5, 0.20], emissive: [0.07, 0.15, 0.05] });
moss.parent = root;
}
const sign = makeSignText(scene, `${id}_sign`, 'СТАРТ', { textColor: '#88dd55' });
sign.position.set(0, COL_H - 0.3, 0.31);
sign.parent = root;
return { root, dispose: () => { for (const ch of root.getChildMeshes()) ch.dispose(); root.dispose(); } };
}
function archForestVine(scene, id) {
const root = new TransformNode(`arch_${id}`, scene);
const COL_H = 3.6, GAP = 3.0;
// тонкие колонны-ветки
const colL = makeColumn(scene, `${id}_colL`, { height: COL_H, diameter: 0.35,
diffuse: [0.32, 0.22, 0.12], emissive: [0.08, 0.05, 0.03] });
const colR = makeColumn(scene, `${id}_colR`, { height: COL_H, diameter: 0.35,
diffuse: [0.32, 0.22, 0.12], emissive: [0.08, 0.05, 0.03] });
colL.position.x = -GAP / 2;
colR.position.x = GAP / 2;
colL.parent = root; colR.parent = root;
// тонкая балка
const beam = makeBeam(scene, `${id}_beam`, {
width: GAP + 0.4, height: 0.25, depth: 0.25,
diffuse: [0.32, 0.22, 0.12], emissive: [0.08, 0.05, 0.03],
});
beam.position.y = COL_H + 0.125;
beam.parent = root;
// лианы — много зелёных мелких сфер
for (let i = 0; i < 12; i++) {
const x = -1.8 + i * 0.32;
const dropY = 0.5 + Math.abs(Math.sin(i * 1.3)) * 0.8;
for (let j = 0; j < 4; j++) {
const leaf = MeshBuilder.CreateSphere(`${id}_v_${i}_${j}`, { diameter: 0.25 }, scene);
leaf.position.set(x, COL_H + 0.2 - j * 0.25, 0);
leaf.material = makeMat(scene, `${id}_vm_${i}_${j}`,
{ diffuse: [0.15 + (j % 2) * 0.1, 0.55 - j * 0.05, 0.15], emissive: [0.05, 0.18, 0.05] });
leaf.parent = root;
}
}
const sign = makeSignText(scene, `${id}_sign`, 'СТАРТ', { textColor: '#aaff66' });
sign.position.set(0, COL_H - 0.5, 0.16);
sign.parent = root;
return { root, dispose: () => { for (const ch of root.getChildMeshes()) ch.dispose(); root.dispose(); } };
}
function archForestLanterns(scene, id) {
const root = new TransformNode(`arch_${id}`, scene);
const COL_H = 3.6, GAP = 3.0;
const colL = makeColumn(scene, `${id}_colL`, { height: COL_H, diameter: 0.50,
diffuse: [0.30, 0.18, 0.08], emissive: [0.06, 0.04, 0.02] });
const colR = makeColumn(scene, `${id}_colR`, { height: COL_H, diameter: 0.50,
diffuse: [0.30, 0.18, 0.08], emissive: [0.06, 0.04, 0.02] });
colL.position.x = -GAP / 2;
colR.position.x = GAP / 2;
colL.parent = root; colR.parent = root;
const beam = makeBeam(scene, `${id}_beam`, {
width: GAP + 0.6, height: 0.35, depth: 0.35,
diffuse: [0.30, 0.18, 0.08], emissive: [0.06, 0.04, 0.02],
});
beam.position.y = COL_H + 0.175;
beam.parent = root;
// тёплые лампочки-гирлянда
const colors = [[1.0, 0.85, 0.30], [1.0, 0.55, 0.20], [1.0, 0.95, 0.65]];
for (let i = 0; i < 9; i++) {
const bulb = makeBulb(scene, `${id}_b_${i}`,
-1.6 + i * 0.4,
COL_H + 0.18 - Math.abs(Math.sin(i * 1.7)) * 0.3,
0,
colors[i % colors.length],
0.9
);
bulb.parent = root;
}
const sign = makeSignText(scene, `${id}_sign`, 'СТАРТ', {
textColor: '#ffd76b', bgColor: '#1f1408', emissive: [0.7, 0.55, 0.15],
});
sign.position.set(0, COL_H - 0.4, 0.21);
sign.parent = root;
return { root, dispose: () => { for (const ch of root.getChildMeshes()) ch.dispose(); root.dispose(); } };
}
function archForestRustic(scene, id) {
const root = new TransformNode(`arch_${id}`, scene);
const COL_H = 3.5, GAP = 2.8;
// кривые колонны (две box со слегка разным масштабом)
const colL = makeColumn(scene, `${id}_colL`, { height: COL_H, diameter: 0.6, shape: 'box',
diffuse: [0.42, 0.28, 0.16], emissive: [0.10, 0.07, 0.04] });
const colR = makeColumn(scene, `${id}_colR`, { height: COL_H, diameter: 0.6, shape: 'box',
diffuse: [0.42, 0.28, 0.16], emissive: [0.10, 0.07, 0.04] });
colL.position.x = -GAP / 2;
colL.rotation.z = -0.05;
colR.position.x = GAP / 2;
colR.rotation.z = 0.05;
colL.parent = root; colR.parent = root;
// балка — две, на разной высоте (как ферма)
const beam1 = makeBeam(scene, `${id}_beam1`, {
width: GAP + 0.5, height: 0.35, depth: 0.35,
diffuse: [0.45, 0.30, 0.18], emissive: [0.10, 0.07, 0.04],
});
beam1.position.y = COL_H + 0.175;
beam1.parent = root;
const beam2 = makeBeam(scene, `${id}_beam2`, {
width: GAP, height: 0.25, depth: 0.25,
diffuse: [0.45, 0.30, 0.18], emissive: [0.10, 0.07, 0.04],
});
beam2.position.y = COL_H - 0.5;
beam2.parent = root;
// X-перекрестье из досок
const dr1 = makeBeam(scene, `${id}_dr1`, {
width: 2.6, height: 0.18, depth: 0.18,
diffuse: [0.40, 0.25, 0.13], emissive: [0.08, 0.05, 0.03],
});
dr1.position.y = COL_H * 0.5;
dr1.rotation.z = Math.PI / 5;
dr1.parent = root;
const dr2 = dr1.clone(`${id}_dr2`);
dr2.rotation.z = -Math.PI / 5;
dr2.parent = root;
const sign = makeSignText(scene, `${id}_sign`, 'СТАРТ', { textColor: '#ffe44a' });
sign.position.set(0, COL_H + 0.5, 0.26);
sign.parent = root;
return { root, dispose: () => { for (const ch of root.getChildMeshes()) ch.dispose(); root.dispose(); } };
}
// ----------------------------------------- Эпоха II — Горы -----
function archMountainStone(scene, id) {
const root = new TransformNode(`arch_${id}`, scene);
const COL_H = 3.4, GAP = 3.0;
const colDif = [0.50, 0.50, 0.55];
const colL = makeColumn(scene, `${id}_colL`, { height: COL_H, diameter: 0.75, shape: 'box', diffuse: colDif, emissive: [0.10, 0.10, 0.11] });
const colR = makeColumn(scene, `${id}_colR`, { height: COL_H, diameter: 0.75, shape: 'box', diffuse: colDif, emissive: [0.10, 0.10, 0.11] });
colL.position.x = -GAP / 2; colR.position.x = GAP / 2;
colL.parent = root; colR.parent = root;
const beam = makeBeam(scene, `${id}_beam`, { width: GAP + 1.0, height: 0.55, depth: 0.55, diffuse: colDif, emissive: [0.10, 0.10, 0.11] });
beam.position.y = COL_H + 0.275; beam.parent = root;
const sign = makeSignText(scene, `${id}_sign`, 'СТАРТ', { textColor: '#aaccff' });
sign.position.set(0, COL_H - 0.3, 0.31);
sign.parent = root;
return { root, dispose: () => { for (const ch of root.getChildMeshes()) ch.dispose(); root.dispose(); } };
}
function archMountainCrystal(scene, id) {
const root = new TransformNode(`arch_${id}`, scene);
const COL_H = 3.6, GAP = 3.0;
const xtaldif = [0.5, 0.7, 0.95], xtalem = [0.20, 0.35, 0.55];
const colL = makeColumn(scene, `${id}_colL`, { height: COL_H, diameter: 0.55, diffuse: xtaldif, emissive: xtalem, specPower: 96 });
const colR = makeColumn(scene, `${id}_colR`, { height: COL_H, diameter: 0.55, diffuse: xtaldif, emissive: xtalem, specPower: 96 });
colL.position.x = -GAP / 2; colR.position.x = GAP / 2;
colL.parent = root; colR.parent = root;
const beam = makeBeam(scene, `${id}_beam`, { width: GAP + 0.6, height: 0.35, depth: 0.35, diffuse: xtaldif, emissive: xtalem });
beam.position.y = COL_H + 0.175; beam.parent = root;
const sign = makeSignText(scene, `${id}_sign`, 'СТАРТ', { textColor: '#aaeeff' });
sign.position.set(0, COL_H - 0.4, 0.21);
sign.parent = root;
return { root, dispose: () => { for (const ch of root.getChildMeshes()) ch.dispose(); root.dispose(); } };
}
function archMountainIce(scene, id) {
const root = new TransformNode(`arch_${id}`, scene);
const COL_H = 3.5, GAP = 3.0;
const icedif = [0.80, 0.92, 1.0], iceem = [0.25, 0.35, 0.50];
const colL = makeColumn(scene, `${id}_colL`, { height: COL_H, diameter: 0.55, diffuse: icedif, emissive: iceem, specPower: 128 });
const colR = makeColumn(scene, `${id}_colR`, { height: COL_H, diameter: 0.55, diffuse: icedif, emissive: iceem, specPower: 128 });
colL.position.x = -GAP / 2; colR.position.x = GAP / 2;
colL.parent = root; colR.parent = root;
const beam = makeBeam(scene, `${id}_beam`, { width: GAP + 0.6, height: 0.35, depth: 0.35, diffuse: icedif, emissive: iceem });
beam.position.y = COL_H + 0.175; beam.parent = root;
const sign = makeSignText(scene, `${id}_sign`, 'СТАРТ', { textColor: '#ddffff' });
sign.position.set(0, COL_H - 0.4, 0.21);
sign.parent = root;
return { root, dispose: () => { for (const ch of root.getChildMeshes()) ch.dispose(); root.dispose(); } };
}
function archMountainPeak(scene, id) {
const root = new TransformNode(`arch_${id}`, scene);
const COL_H = 3.8, GAP = 3.0;
const colDif = [0.40, 0.45, 0.50];
const colL = makeColumn(scene, `${id}_colL`, { height: COL_H, diameter: 0.6, shape: 'box', diffuse: colDif, emissive: [0.05, 0.06, 0.07] });
const colR = makeColumn(scene, `${id}_colR`, { height: COL_H, diameter: 0.6, shape: 'box', diffuse: colDif, emissive: [0.05, 0.06, 0.07] });
colL.position.x = -GAP / 2; colR.position.x = GAP / 2;
colL.parent = root; colR.parent = root;
// вместо балки — пирамидальная крыша из 2-х наклонённых box
const sideA = makeBeam(scene, `${id}_a`, { width: GAP * 0.7, height: 0.4, depth: 0.4, diffuse: colDif, emissive: [0.05, 0.06, 0.07] });
sideA.position.set(-GAP * 0.27, COL_H + GAP * 0.27, 0);
sideA.rotation.z = -Math.PI / 4;
sideA.parent = root;
const sideB = sideA.clone(`${id}_b`);
sideB.position.set(GAP * 0.27, COL_H + GAP * 0.27, 0);
sideB.rotation.z = Math.PI / 4;
sideB.parent = root;
const sign = makeSignText(scene, `${id}_sign`, 'СТАРТ', { textColor: '#88aacc' });
sign.position.set(0, COL_H - 0.6, 0.31);
sign.parent = root;
return { root, dispose: () => { for (const ch of root.getChildMeshes()) ch.dispose(); root.dispose(); } };
}
function archMountainBronze(scene, id) {
const root = new TransformNode(`arch_${id}`, scene);
const COL_H = 3.6, GAP = 3.0;
const brz = [0.55, 0.38, 0.18];
const colL = makeColumn(scene, `${id}_colL`, { height: COL_H, diameter: 0.5, diffuse: brz, emissive: [0.15, 0.08, 0.03], specPower: 96 });
const colR = makeColumn(scene, `${id}_colR`, { height: COL_H, diameter: 0.5, diffuse: brz, emissive: [0.15, 0.08, 0.03], specPower: 96 });
colL.position.x = -GAP / 2; colR.position.x = GAP / 2;
colL.parent = root; colR.parent = root;
const beam = makeBeam(scene, `${id}_beam`, { width: GAP + 0.8, height: 0.45, depth: 0.45, diffuse: brz, emissive: [0.15, 0.08, 0.03] });
beam.position.y = COL_H + 0.225; beam.parent = root;
const sign = makeSignText(scene, `${id}_sign`, 'СТАРТ', { textColor: '#ffd76b' });
sign.position.set(0, COL_H - 0.3, 0.26);
sign.parent = root;
return { root, dispose: () => { for (const ch of root.getChildMeshes()) ch.dispose(); root.dispose(); } };
}
// ----------------------------------------- Эпохи III-X — упрощённые шаблоны -----
function archGeneric(scene, id, palette) {
const root = new TransformNode(`arch_${id}`, scene);
const COL_H = palette.colH || 3.5, GAP = palette.gap || 3.0;
const colL = makeColumn(scene, `${id}_colL`, {
height: COL_H, diameter: palette.colDia || 0.55,
shape: palette.shape || 'cylinder',
diffuse: palette.colDif, emissive: palette.colEm,
specPower: palette.specPower,
});
const colR = makeColumn(scene, `${id}_colR`, {
height: COL_H, diameter: palette.colDia || 0.55,
shape: palette.shape || 'cylinder',
diffuse: palette.colDif, emissive: palette.colEm,
specPower: palette.specPower,
});
colL.position.x = -GAP / 2; colR.position.x = GAP / 2;
colL.parent = root; colR.parent = root;
const beam = makeBeam(scene, `${id}_beam`, {
width: GAP + 0.6, height: palette.beamH || 0.35, depth: 0.35,
diffuse: palette.beamDif || palette.colDif, emissive: palette.beamEm || palette.colEm,
});
beam.position.y = COL_H + (palette.beamH || 0.35) / 2;
beam.parent = root;
// бонус — например лампочки или сферки
if (palette.bulbs) {
for (let i = 0; i < palette.bulbs.count; i++) {
const b = makeBulb(scene, `${id}_bulb_${i}`,
-GAP / 2 + (i + 0.5) * (GAP / palette.bulbs.count),
COL_H + 0.5,
0,
palette.bulbs.color
);
b.parent = root;
}
}
const sign = makeSignText(scene, `${id}_sign`, palette.signText || 'СТАРТ', {
textColor: palette.signColor || '#ffffff',
bgColor: palette.signBg || '#0a1428',
emissive: palette.signEm || [0.5, 0.5, 0.5],
});
sign.position.set(0, COL_H - 0.4, 0.21);
sign.parent = root;
return { root, dispose: () => { for (const ch of root.getChildMeshes()) ch.dispose(); root.dispose(); } };
}
// =========================================================================
// КАТАЛОГ
// =========================================================================
const GENERIC_EPOCHS = {
3: [
{ name: 'Город хром', colDif:[0.65,0.65,0.7], colEm:[0.15,0.15,0.18], signColor:'#22ccff', specPower:128 },
{ name: 'Город неон', colDif:[0.10,0.10,0.20], colEm:[0.10,0.30,0.60], signColor:'#22ddff', signEm:[0.1,0.6,1.0] },
{ name: 'Город бетон', colDif:[0.55,0.55,0.55], colEm:[0.10,0.10,0.10], signColor:'#cccccc' },
{ name: 'Граффити', colDif:[0.20,0.20,0.25], colEm:[0.05,0.05,0.07], signColor:'#ff44aa', signBg:'#2a0a1a', signEm:[0.8,0.2,0.5] },
{ name: 'Город жёлтый', colDif:[0.85,0.65,0.10], colEm:[0.30,0.20,0.05], signColor:'#000', signBg:'#ffe44a', signEm:[1,0.85,0.1] },
],
4: [
{ name: 'Ночной фиолет', colDif:[0.35,0.10,0.50], colEm:[0.35,0.10,0.60], signColor:'#ff88ff', signEm:[0.7,0.3,0.9] },
{ name: 'Ночной циан', colDif:[0.10,0.40,0.50], colEm:[0.10,0.50,0.65], signColor:'#88eeff', signEm:[0.3,0.8,1.0] },
{ name: 'Ночной розовый', colDif:[0.80,0.20,0.45], colEm:[0.55,0.15,0.30], signColor:'#ff88aa', signEm:[1.0,0.4,0.7] },
{ name: 'Ночной жёлтый', colDif:[0.85,0.65,0.10], colEm:[0.55,0.40,0.05], signColor:'#ffee88', signEm:[1,0.85,0.2] },
{ name: 'Голограмма', colDif:[0.30,0.50,1.0], colEm:[0.40,0.70,1.0], signColor:'#ffffff', signEm:[0.6,0.8,1.0] },
],
5: [
{ name: 'Пустыня песчаник', colDif:[0.75,0.62,0.38], colEm:[0.18,0.13,0.07], signColor:'#5a3a18' },
{ name: 'Пустыня терракота', colDif:[0.65,0.30,0.15], colEm:[0.20,0.07,0.03], signColor:'#ffd76b' },
{ name: 'Пустыня кость', colDif:[0.85,0.80,0.65], colEm:[0.25,0.20,0.12], signColor:'#3a2a1a' },
{ name: 'Пустыня кактус', colDif:[0.30,0.50,0.25], colEm:[0.07,0.15,0.07], signColor:'#ffffff' },
{ name: 'Пустыня закат', colDif:[0.85,0.40,0.20], colEm:[0.45,0.15,0.05], signColor:'#ffe44a', signEm:[1,0.5,0.1] },
],
6: [
{ name: 'Океан коралл', colDif:[0.90,0.40,0.50], colEm:[0.30,0.10,0.15], signColor:'#ffffff' },
{ name: 'Океан синий', colDif:[0.10,0.40,0.65], colEm:[0.05,0.20,0.35], signColor:'#aaeeff' },
{ name: 'Океан жемчуг', colDif:[0.85,0.85,0.95], colEm:[0.20,0.20,0.30], signColor:'#3a5a7a', specPower:128 },
{ name: 'Океан водоросль', colDif:[0.20,0.50,0.30], colEm:[0.07,0.20,0.10], signColor:'#aaffaa' },
{ name: 'Океан бирюза', colDif:[0.10,0.65,0.65], colEm:[0.10,0.40,0.40], signColor:'#ffffff' },
],
7: [
{ name: 'Пещеры камень', colDif:[0.35,0.30,0.25], colEm:[0.07,0.05,0.04], signColor:'#aa8855' },
{ name: 'Пещеры кварц', colDif:[0.55,0.45,0.75], colEm:[0.20,0.15,0.40], signColor:'#ddccff' },
{ name: 'Пещеры гриб', colDif:[0.55,0.20,0.20], colEm:[0.25,0.08,0.08], signColor:'#ffffff' },
{ name: 'Пещеры мох', colDif:[0.20,0.50,0.30], colEm:[0.20,0.55,0.30], signColor:'#aaff88', signEm:[0.3,0.8,0.4] },
{ name: 'Пещеры алмаз', colDif:[0.75,0.85,0.95], colEm:[0.35,0.40,0.45], signColor:'#ffffff', specPower:128 },
],
8: [
{ name: 'Вулкан лава', colDif:[0.30,0.10,0.05], colEm:[0.50,0.15,0.02], signColor:'#ffaa00', signEm:[1,0.5,0.05] },
{ name: 'Вулкан обсидиан', colDif:[0.10,0.05,0.10], colEm:[0.05,0.02,0.05], signColor:'#aa44aa', specPower:128 },
{ name: 'Вулкан магма', colDif:[0.55,0.25,0.05], colEm:[0.85,0.40,0.08], signColor:'#ffff55', signEm:[1,1,0.3] },
{ name: 'Вулкан пепел', colDif:[0.25,0.22,0.22], colEm:[0.05,0.04,0.04], signColor:'#ff5555' },
{ name: 'Вулкан огонь', colDif:[0.45,0.15,0.05], colEm:[0.85,0.30,0.05], signColor:'#ffee00', signEm:[1,0.9,0.1] },
],
9: [
{ name: 'Космос звезда', colDif:[0.45,0.40,0.10], colEm:[0.65,0.55,0.15], signColor:'#ffffaa' },
{ name: 'Космос плазма', colDif:[0.30,0.55,0.95], colEm:[0.40,0.80,1.00], signColor:'#aaffff' },
{ name: 'Космос лазер', colDif:[0.80,0.10,0.50], colEm:[0.95,0.20,0.80], signColor:'#ffaaff', signEm:[1,0.4,1] },
{ name: 'Космос туманность', colDif:[0.55,0.25,0.85], colEm:[0.50,0.20,0.85], signColor:'#ffccff' },
{ name: 'Космос луна', colDif:[0.65,0.65,0.65], colEm:[0.35,0.35,0.35], signColor:'#bbbbbb' },
],
10: [
{ name: 'Кибер зелёный', colDif:[0.10,0.80,0.30], colEm:[0.20,1.00,0.40], signColor:'#aaffaa', signEm:[0.2,1.0,0.4] },
{ name: 'Кибер фиолет', colDif:[0.55,0.15,0.85], colEm:[0.70,0.25,1.00], signColor:'#ffaaff' },
{ name: 'Кибер красный', colDif:[0.85,0.10,0.30], colEm:[0.90,0.20,0.40], signColor:'#ffaaaa' },
{ name: 'Кибер голограмма', colDif:[0.30,0.50,1.0], colEm:[0.50,0.85,1.0], signColor:'#ffffff' },
{ name: 'Кибер жёлтый', colDif:[0.85,0.85,0.10], colEm:[0.85,0.85,0.15], signColor:'#000', signBg:'#ffff44', signEm:[1,1,0.3] },
],
};
export const ARCH_CATALOG = [];
const epoch1 = [
{ id: 'a1_v1', title: 'Деревянная', make: archForestWood },
{ id: 'a1_v2', title: 'Каменная', make: archForestStone },
{ id: 'a1_v3', title: 'С лианами', make: archForestVine },
{ id: 'a1_v4', title: 'С фонариками', make: archForestLanterns },
{ id: 'a1_v5', title: 'Рустик', make: archForestRustic },
];
for (const a of epoch1) ARCH_CATALOG.push({ ...a, epoch: 1 });
const epoch2 = [
{ id: 'a2_v1', title: 'Каменная', make: archMountainStone },
{ id: 'a2_v2', title: 'Кристалл', make: archMountainCrystal },
{ id: 'a2_v3', title: 'Ледяная', make: archMountainIce },
{ id: 'a2_v4', title: 'Пиковая', make: archMountainPeak },
{ id: 'a2_v5', title: 'Бронзовая', make: archMountainBronze },
];
for (const a of epoch2) ARCH_CATALOG.push({ ...a, epoch: 2 });
for (let epoch = 3; epoch <= 10; epoch++) {
const palettes = GENERIC_EPOCHS[epoch] || [];
for (let i = 0; i < palettes.length; i++) {
const palette = palettes[i];
ARCH_CATALOG.push({
id: `a${epoch}_v${i + 1}`,
epoch,
title: palette.name,
make: (scene, id) => archGeneric(scene, id, palette),
});
}
}
export const EPOCH_INFO = [
{ n: 1, name: 'Лес', emoji: '🌲', color: '#3a7a4a' },
{ n: 2, name: 'Горы', emoji: '🏔', color: '#aaccff' },
{ n: 3, name: 'Город днём', emoji: '🏙', color: '#7a8aaa' },
{ n: 4, name: 'Город ночью', emoji: '🌃', color: '#1a1a3a' },
{ n: 5, name: 'Пустыня', emoji: '🏜', color: '#c8a575' },
{ n: 6, name: 'Океан', emoji: '🌊', color: '#3a8aaa' },
{ n: 7, name: 'Пещеры', emoji: '🕳', color: '#3a3a3a' },
{ n: 8, name: 'Вулкан', emoji: '🌋', color: '#8a2a2a' },
{ n: 9, name: 'Космос', emoji: '🚀', color: '#3a1a5a' },
{ n: 10, name: 'Кибер', emoji: '🤖', color: '#ff00ff' },
];
export function getArchesByEpoch(epoch) {
return ARCH_CATALOG.filter(a => a.epoch === epoch);
}

View File

@ -0,0 +1,219 @@
/**
* decoFactories реестр декораций ландшафта GD (v2 на готовых GLB Kenney).
*
* Используем nature-kit (Kenney CC0) те же модели что в гладком ландшафте
* (см. SmoothDecoManager). Все .glb лежат в /public/kubikon-assets/models/nature-kit/.
*
* Структура каталога:
* 10 эпох × 10 моделей = 100. Для эпох без природы (Кибер) пустой массив,
* юзер просто не выбирает ничего и декораций на тех уровнях не будет.
*
* Каждая фабрика возвращает (scene, id) => { root: TransformNode, dispose() }.
* Под капотом: SceneLoader.LoadAssetContainerAsync(...) кэшируется по file-name.
*/
import {
SceneLoader, TransformNode, Vector3,
} from '@babylonjs/core';
import '@babylonjs/loaders/glTF';
const ASSET_ROOT = '/kubikon-assets/models/nature-kit/';
// Кэш загруженных AssetContainer'ов (по file → container).
// Когда юзер открывает превью одного и того же id повторно — не грузим заново.
const _containerCache = new Map(); // file → Promise<container>
/** Универсальная загрузка GLB с кэшем. Возвращает массив instantiated meshes. */
async function loadGlb(file, scene) {
let promise = _containerCache.get(file);
if (!promise) {
promise = SceneLoader.LoadAssetContainerAsync(ASSET_ROOT, file, scene);
_containerCache.set(file, promise);
}
const container = await promise;
// instantiateModelsToScene создаёт независимые копии — можно ставить в любую сцену
return container.instantiateModelsToScene();
}
/** Фабрика — обёртка над loadGlb. Возвращает root + dispose, scale настраиваемый. */
function makeGlb(file, opts = {}) {
const { scale = 1.0, rotY = 0 } = opts;
return (scene, id) => {
const root = new TransformNode(`deco_${id}`, scene);
// Параллельно начинаем загружать GLB и сразу возвращаем root. Когда модель
// загрузится — паррентим её к root. Это позволяет рендерить root сразу.
loadGlb(file, scene).then((result) => {
try {
if (root.isDisposed && root.isDisposed()) {
// root уже удалён — disposить инстанцию
for (const m of result.rootNodes) m.dispose();
return;
}
for (const rn of result.rootNodes) {
rn.parent = root;
}
root.scaling = new Vector3(scale, scale, scale);
root.rotation.y = rotY;
} catch (e) { console.warn('[deco] mount failed', file, e); }
// Сохраним handle для dispose
root._glbResult = result;
}).catch((e) => {
console.warn('[deco] load failed', file, e);
});
return {
root,
dispose: () => {
try {
if (root._glbResult) {
for (const m of root._glbResult.rootNodes) {
try { m.dispose(false, true); } catch (e) {}
}
}
root.dispose();
} catch (e) {}
},
};
};
}
// =========================================================================
// КАТАЛОГ
// =========================================================================
export const DECO_CATALOG = [];
function add(epoch, num, title, file, scale = 1.0) {
DECO_CATALOG.push({
id: `d${epoch}_v${num}`,
epoch,
title,
file,
scale, // <-- доступно для GdForest
make: makeGlb(file, { scale }),
});
}
// ---------- Эпоха I — Лес (10) ----------
// Scale тут — итоговый множитель ВНУТРИ GdForest (умножается на 0.85-1.20).
// Kenney модели после bake — обычно 0.2-1.5м, надо ×10-25 чтобы получить
// деревья 3-6м (вдвое выше игрока для заметного бэкграунда).
add(1, 1, 'Дуб', 'tree_oak.glb', 12);
add(1, 2, 'Большое дерево', 'tree_default.glb', 10);
add(1, 3, 'Толстое дерево', 'tree_fat.glb', 10);
add(1, 4, 'Высокое дерево', 'tree_tall.glb', 14);
add(1, 5, 'Малое дерево', 'tree_small.glb', 8);
add(1, 6, 'Детальное дерево', 'tree_detailed.glb', 11);
add(1, 7, 'Куст', 'grass_leafsLarge.glb', 6);
add(1, 8, 'Гриб красный', 'mushroom_redGroup.glb', 5);
add(1, 9, 'Гриб тан', 'mushroom_tanGroup.glb', 5);
add(1, 10, 'Большой камень', 'stone_largeA.glb', 8);
// ---------- Эпоха II — Горы (10) ----------
add(2, 1, 'Сосна высокая', 'tree_pineTallA.glb', 16);
add(2, 2, 'Сосна круглая A', 'tree_pineRoundA.glb', 11);
add(2, 3, 'Сосна круглая B', 'tree_pineRoundB.glb', 11);
add(2, 4, 'Сосна малая', 'tree_pineSmallA.glb', 7);
add(2, 5, 'Сосна по умолч.', 'tree_pineDefaultA.glb', 13);
add(2, 6, 'Камень большой A', 'stone_largeA.glb', 9);
add(2, 7, 'Камень большой B', 'stone_largeB.glb', 9);
add(2, 8, 'Камень высокий A', 'stone_tallA.glb', 10);
add(2, 9, 'Камень высокий B', 'stone_tallB.glb', 10);
add(2, 10, 'Камень малый', 'stone_smallTopA.glb', 5);
// ---------- Эпоха III — Город (10) ----------
add(3, 1, 'Дерево парка', 'tree_default.glb', 10);
add(3, 2, 'Дерево парка 2', 'tree_oak.glb', 10);
add(3, 3, 'Куст-кустарник', 'grass_leafsLarge.glb', 6);
add(3, 4, 'Тумба-камень A', 'stone_smallA.glb', 5);
add(3, 5, 'Тумба-камень B', 'stone_smallB.glb', 5);
add(3, 6, 'Тумба-камень C', 'stone_smallC.glb', 5);
add(3, 7, 'Цветок жёлтый', 'flower_yellowB.glb', 10);
add(3, 8, 'Цветок красный', 'flower_redB.glb', 10);
add(3, 9, 'Цветок фиолет', 'flower_purpleB.glb', 10);
add(3, 10, 'Куст-трава', 'grass_large.glb', 5);
// ---------- Эпоха IV — Город ночью (10) ----------
add(4, 1, 'Тёмное дерево', 'tree_default_dark.glb', 10);
add(4, 2, 'Тёмный дуб', 'tree_oak_dark.glb', 11);
add(4, 3, 'Тёмное толстое', 'tree_fat_darkh.glb', 10);
add(4, 4, 'Тёмное детальное', 'tree_detailed_dark.glb', 11);
add(4, 5, 'Тёмная сосна', 'tree_cone_dark.glb', 11);
add(4, 6, 'Камень малый A', 'stone_smallA.glb', 5);
add(4, 7, 'Камень малый D', 'stone_smallD.glb', 5);
add(4, 8, 'Камень малый E', 'stone_smallE.glb', 5);
add(4, 9, 'Камень flat A', 'stone_smallFlatA.glb', 5);
add(4, 10, 'Камень flat B', 'stone_smallFlatB.glb', 5);
// ---------- Эпоха V — Пустыня (10) ----------
add(5, 1, 'Кактус высокий', 'cactus_tall.glb', 9);
add(5, 2, 'Кактус малый', 'cactus_short.glb', 7);
add(5, 3, 'Пальма', 'tree_palm.glb', 12);
add(5, 4, 'Пальма короткая', 'tree_palmShort.glb', 9);
add(5, 5, 'Пальма гнутая', 'tree_palmBend.glb', 12);
add(5, 6, 'Камень-валун A', 'stone_largeC.glb', 9);
add(5, 7, 'Камень-валун D', 'stone_largeD.glb', 9);
add(5, 8, 'Камень-валун E', 'stone_largeE.glb', 9);
add(5, 9, 'Цветок жёлтый', 'flower_yellowC.glb', 10);
add(5, 10, 'Камень малый F', 'stone_smallF.glb', 5);
// ---------- Эпоха VI — Океан (10) ----------
add(6, 1, 'Камень морской A', 'stone_largeA.glb', 7);
add(6, 2, 'Камень морской B', 'stone_largeB.glb', 7);
add(6, 3, 'Камень морской C', 'stone_largeC.glb', 7);
add(6, 4, 'Камень флэт A', 'stone_smallFlatA.glb', 5);
add(6, 5, 'Гриб (коралл) A', 'mushroom_red.glb', 5);
add(6, 6, 'Гриб (коралл) B', 'mushroom_redTall.glb', 5);
add(6, 7, 'Гриб (коралл) C', 'mushroom_redGroup.glb', 5);
add(6, 8, 'Гриб тан', 'mushroom_tan.glb', 5);
add(6, 9, 'Цветок (анемона)', 'flower_redC.glb', 10);
add(6, 10, 'Трава (водорсль)', 'grass_leafs.glb', 8);
// ---------- Эпоха VII — Пещеры (10) ----------
add(7, 1, 'Камень высокий A', 'stone_tallA.glb', 10);
add(7, 2, 'Камень высокий B', 'stone_tallB.glb', 10);
add(7, 3, 'Камень высокий C', 'stone_tallC.glb', 10);
add(7, 4, 'Камень высокий D', 'stone_tallD.glb', 10);
add(7, 5, 'Камень высокий E', 'stone_tallE.glb', 10);
add(7, 6, 'Гриб красный', 'mushroom_red.glb', 5);
add(7, 7, 'Гриб красный t.', 'mushroom_redTall.glb', 5);
add(7, 8, 'Гриб тан', 'mushroom_tan.glb', 5);
add(7, 9, 'Гриб тан t.', 'mushroom_tanTall.glb', 5);
add(7, 10, 'Гриб тан группа', 'mushroom_tanGroup.glb', 5);
// ---------- Эпоха VIII — Вулкан (10) ----------
add(8, 1, 'Тёмная сосна', 'tree_cone_dark.glb', 11);
add(8, 2, 'Сухое дерево', 'tree_default_fall.glb', 10);
add(8, 3, 'Сухой дуб', 'tree_oak_fall.glb', 11);
add(8, 4, 'Сухой толстый', 'tree_fat_fall.glb', 10);
add(8, 5, 'Сухой детальный', 'tree_detailed_fall.glb', 11);
add(8, 6, 'Камень тёмный A', 'stone_smallA.glb', 5);
add(8, 7, 'Камень тёмный D', 'stone_smallD.glb', 5);
add(8, 8, 'Камень тёмный E', 'stone_smallE.glb', 5);
add(8, 9, 'Валун', 'stone_largeF.glb', 8);
add(8, 10, 'Камень flat C', 'stone_smallFlatC.glb', 5);
// ---------- Эпоха IX — Космос (5, без природы) ----------
add(9, 1, 'Астероид большой A','stone_largeA.glb', 6);
add(9, 2, 'Астероид большой B','stone_largeB.glb', 6);
add(9, 3, 'Астероид высокий', 'stone_tallG.glb', 7);
add(9, 4, 'Метеорит', 'stone_smallTopB.glb', 5);
add(9, 5, 'Лунный камень', 'stone_smallH.glb', 5);
// ---------- Эпоха X — Кибер (без природы) ----------
// Юзер сказал — деревья тут не нужны. Каталог пустой.
export const EPOCH_INFO = [
{ n: 1, name: 'Лес', emoji: '🌲', color: '#3a7a4a' },
{ n: 2, name: 'Горы', emoji: '🏔', color: '#aaccff' },
{ n: 3, name: 'Город днём', emoji: '🏙', color: '#7a8aaa' },
{ n: 4, name: 'Город ночью', emoji: '🌃', color: '#1a1a3a' },
{ n: 5, name: 'Пустыня', emoji: '🏜', color: '#c8a575' },
{ n: 6, name: 'Океан', emoji: '🌊', color: '#3a8aaa' },
{ n: 7, name: 'Пещеры', emoji: '🕳', color: '#3a3a3a' },
{ n: 8, name: 'Вулкан', emoji: '🌋', color: '#8a2a2a' },
{ n: 9, name: 'Космос', emoji: '🚀', color: '#3a1a5a' },
{ n: 10, name: 'Кибер', emoji: '🤖', color: '#ff00ff' },
];
export function getDecoByEpoch(epoch) {
return DECO_CATALOG.filter(d => d.epoch === epoch);
}

View File

@ -0,0 +1,518 @@
/**
* finishFactories реестр финишных ворот GD по эпохам.
*
* 10 эпох × 5 вариантов = 50 ворот. Каждая фабрика строит:
* - две высокие колонны (4м).
* - шахматный флаг между колоннами (DynamicTexture).
* - надпись «ФИНИШ».
* - подсветка стилистики эпохи (золото / неон / лава / лёд / ...).
*
* Возвращает (scene, id) => { root: TransformNode, dispose() }.
* Габариты: ширина ~3.5м, высота ~4.5м, центр в (0, 0, 0).
*/
import {
MeshBuilder, StandardMaterial, Color3, Vector3, TransformNode,
DynamicTexture,
} from '@babylonjs/core';
function makeMat(scene, name, opts = {}) {
const m = new StandardMaterial(name, scene);
m.diffuseColor = new Color3(...(opts.diffuse || [1, 1, 1]));
m.emissiveColor = new Color3(...(opts.emissive || [0, 0, 0]));
m.specularColor = new Color3(...(opts.specular || [0.2, 0.2, 0.2]));
if (opts.specPower != null) m.specularPower = opts.specPower;
if (opts.alpha != null) m.alpha = opts.alpha;
if (opts.disableLighting) m.disableLighting = true;
return m;
}
function makeColumn(scene, name, opts = {}) {
const {
height = 4, diameter = 0.55,
shape = 'cylinder',
diffuse = [0.4, 0.3, 0.2], emissive = [0.05, 0.04, 0.03],
specular, specPower,
} = opts;
let mesh;
if (shape === 'box') {
mesh = MeshBuilder.CreateBox(name, { width: diameter, height, depth: diameter }, scene);
} else {
mesh = MeshBuilder.CreateCylinder(name, { diameter, height, tessellation: 12 }, scene);
}
mesh.position.y = height / 2;
mesh.material = makeMat(scene, `${name}_mat`, { diffuse, emissive, specular, specPower });
return mesh;
}
function makeBeam(scene, name, opts = {}) {
const {
width = 3.5, height = 0.5, depth = 0.5,
diffuse = [0.4, 0.3, 0.2], emissive = [0.05, 0.04, 0.03],
specular, specPower,
} = opts;
const beam = MeshBuilder.CreateBox(name, { width, height, depth }, scene);
beam.material = makeMat(scene, `${name}_mat`, { diffuse, emissive, specular, specPower });
return beam;
}
/** Шахматный флаг — DynamicTexture с квадратиками. */
function makeChecker(scene, name, opts = {}) {
const {
width = 2.6, height = 1.6,
cols = 8, rows = 5,
c1 = '#ffffff', c2 = '#0a0a14',
} = opts;
const TW = 256, TH = 160;
const dt = new DynamicTexture(`${name}_tex`, { width: TW, height: TH }, scene, true);
const ctx = dt.getContext();
const cw = TW / cols, ch = TH / rows;
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
ctx.fillStyle = ((r + c) % 2 === 0) ? c1 : c2;
ctx.fillRect(c * cw, r * ch, cw, ch);
}
}
dt.hasAlpha = false;
dt.update();
const plane = MeshBuilder.CreatePlane(name, { width, height }, scene);
const mat = new StandardMaterial(`${name}_mat`, scene);
mat.diffuseTexture = dt;
mat.emissiveTexture = dt;
mat.emissiveColor = new Color3(0.5, 0.5, 0.5);
mat.backFaceCulling = false;
plane.material = mat;
return plane;
}
function makeSignText(scene, name, text, opts = {}) {
const {
width = 2.6, height = 0.7,
bgColor = '#0a1428', textColor = '#ffe44a',
fontSize = 96, fontFamily = 'sans-serif',
emissive = [0.8, 0.7, 0.2],
} = opts;
const TW = 512, TH = 128;
const dt = new DynamicTexture(`${name}_tex`, { width: TW, height: TH }, scene, true);
const ctx = dt.getContext();
ctx.fillStyle = bgColor;
ctx.fillRect(0, 0, TW, TH);
ctx.fillStyle = textColor;
ctx.font = `bold ${fontSize}px ${fontFamily}`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(text, TW / 2, TH / 2);
dt.update();
const plane = MeshBuilder.CreatePlane(name, { width, height }, scene);
const mat = new StandardMaterial(`${name}_mat`, scene);
mat.diffuseTexture = dt;
mat.emissiveTexture = dt;
mat.emissiveColor = new Color3(...emissive);
mat.disableLighting = true;
mat.backFaceCulling = false;
plane.material = mat;
return plane;
}
function makeBulb(scene, name, x, y, z, color, scale = 1) {
const bulb = MeshBuilder.CreateSphere(name, { diameter: 0.18, segments: 8 }, scene);
bulb.position.set(x, y, z);
bulb.scaling.set(scale, scale, scale);
bulb.material = makeMat(scene, `${name}_mat`, { diffuse: color, emissive: color, disableLighting: true });
return bulb;
}
// =========================================================================
// ДЕТАЛЬНЫЕ ФАБРИКИ — ЭПОХА I (Лес) и II (Горы)
// =========================================================================
// ----- Эпоха I — Лес -----
function finishForestWoodFlag(scene, id) {
const root = new TransformNode(`fin_${id}`, scene);
const COL_H = 4.2, GAP = 3.0;
const colL = makeColumn(scene, `${id}_colL`, { height: COL_H, diameter: 0.55,
diffuse: [0.40, 0.25, 0.13], emissive: [0.10, 0.07, 0.04] });
const colR = makeColumn(scene, `${id}_colR`, { height: COL_H, diameter: 0.55,
diffuse: [0.40, 0.25, 0.13], emissive: [0.10, 0.07, 0.04] });
colL.position.x = -GAP / 2; colR.position.x = GAP / 2;
colL.parent = root; colR.parent = root;
const beam = makeBeam(scene, `${id}_beam`, {
width: GAP + 0.7, height: 0.45, depth: 0.45,
diffuse: [0.45, 0.28, 0.15], emissive: [0.10, 0.07, 0.04],
});
beam.position.y = COL_H + 0.225;
beam.parent = root;
const flag = makeChecker(scene, `${id}_flag`, { width: GAP - 0.2, height: 1.8 });
flag.position.y = COL_H - 1.4;
flag.parent = root;
const sign = makeSignText(scene, `${id}_sign`, 'ФИНИШ', { textColor: '#ffe44a' });
sign.position.set(0, COL_H + 0.55, 0.26);
sign.parent = root;
return { root, dispose: () => { for (const ch of root.getChildMeshes()) ch.dispose(); root.dispose(); } };
}
function finishForestGold(scene, id) {
const root = new TransformNode(`fin_${id}`, scene);
const COL_H = 4.0, GAP = 3.0;
const gold = [0.85, 0.65, 0.10];
const colL = makeColumn(scene, `${id}_colL`, { height: COL_H, diameter: 0.5,
diffuse: gold, emissive: [0.30, 0.20, 0.05], specPower: 96 });
const colR = makeColumn(scene, `${id}_colR`, { height: COL_H, diameter: 0.5,
diffuse: gold, emissive: [0.30, 0.20, 0.05], specPower: 96 });
colL.position.x = -GAP / 2; colR.position.x = GAP / 2;
colL.parent = root; colR.parent = root;
const beam = makeBeam(scene, `${id}_beam`, {
width: GAP + 0.7, height: 0.4, depth: 0.4,
diffuse: gold, emissive: [0.30, 0.20, 0.05],
});
beam.position.y = COL_H + 0.2;
beam.parent = root;
const flag = makeChecker(scene, `${id}_flag`, { width: GAP - 0.2, height: 1.6,
c1: '#ffe44a', c2: '#3a2a1a' });
flag.position.y = COL_H - 1.2;
flag.parent = root;
const sign = makeSignText(scene, `${id}_sign`, 'ФИНИШ', { textColor: '#ffffff', bgColor: '#2a1a0a' });
sign.position.set(0, COL_H + 0.55, 0.21);
sign.parent = root;
return { root, dispose: () => { for (const ch of root.getChildMeshes()) ch.dispose(); root.dispose(); } };
}
function finishForestLeafy(scene, id) {
const root = new TransformNode(`fin_${id}`, scene);
const COL_H = 4.2, GAP = 3.0;
const colL = makeColumn(scene, `${id}_colL`, { height: COL_H, diameter: 0.55,
diffuse: [0.35, 0.22, 0.12], emissive: [0.08, 0.05, 0.03] });
const colR = makeColumn(scene, `${id}_colR`, { height: COL_H, diameter: 0.55,
diffuse: [0.35, 0.22, 0.12], emissive: [0.08, 0.05, 0.03] });
colL.position.x = -GAP / 2; colR.position.x = GAP / 2;
colL.parent = root; colR.parent = root;
const beam = makeBeam(scene, `${id}_beam`, { width: GAP + 0.5, height: 0.35, depth: 0.35,
diffuse: [0.35, 0.22, 0.12], emissive: [0.08, 0.05, 0.03] });
beam.position.y = COL_H + 0.175;
beam.parent = root;
// Листва (зелёные шарики) поверх балки
for (let i = 0; i < 8; i++) {
const leaf = MeshBuilder.CreateSphere(`${id}_leaf_${i}`, { diameter: 0.6 }, scene);
leaf.position.set(-1.6 + i * 0.45, COL_H + 0.55, (i % 2) * 0.15 - 0.075);
leaf.material = makeMat(scene, `${id}_lm_${i}`,
{ diffuse: [0.2, 0.55 + (i % 2) * 0.1, 0.25], emissive: [0.06, 0.18, 0.08] });
leaf.parent = root;
}
const flag = makeChecker(scene, `${id}_flag`, { width: GAP - 0.2, height: 1.6 });
flag.position.y = COL_H - 1.2;
flag.parent = root;
const sign = makeSignText(scene, `${id}_sign`, 'ФИНИШ', { textColor: '#aaff66' });
sign.position.set(0, COL_H - 0.3, 0.26);
sign.parent = root;
return { root, dispose: () => { for (const ch of root.getChildMeshes()) ch.dispose(); root.dispose(); } };
}
function finishForestRustic(scene, id) {
const root = new TransformNode(`fin_${id}`, scene);
const COL_H = 4.0, GAP = 2.8;
const colL = makeColumn(scene, `${id}_colL`, { height: COL_H, diameter: 0.6, shape: 'box',
diffuse: [0.42, 0.28, 0.16], emissive: [0.10, 0.07, 0.04] });
const colR = makeColumn(scene, `${id}_colR`, { height: COL_H, diameter: 0.6, shape: 'box',
diffuse: [0.42, 0.28, 0.16], emissive: [0.10, 0.07, 0.04] });
colL.position.x = -GAP / 2; colL.rotation.z = -0.05;
colR.position.x = GAP / 2; colR.rotation.z = 0.05;
colL.parent = root; colR.parent = root;
const beam1 = makeBeam(scene, `${id}_beam1`, { width: GAP + 0.5, height: 0.35, depth: 0.35,
diffuse: [0.45, 0.30, 0.18], emissive: [0.10, 0.07, 0.04] });
beam1.position.y = COL_H + 0.175;
beam1.parent = root;
const beam2 = makeBeam(scene, `${id}_beam2`, { width: GAP, height: 0.25, depth: 0.25,
diffuse: [0.45, 0.30, 0.18], emissive: [0.10, 0.07, 0.04] });
beam2.position.y = COL_H - 0.5;
beam2.parent = root;
const flag = makeChecker(scene, `${id}_flag`, { width: GAP - 0.4, height: 1.3 });
flag.position.y = COL_H * 0.55;
flag.parent = root;
const sign = makeSignText(scene, `${id}_sign`, 'ФИНИШ', { textColor: '#ffe44a' });
sign.position.set(0, COL_H + 0.55, 0.21);
sign.parent = root;
return { root, dispose: () => { for (const ch of root.getChildMeshes()) ch.dispose(); root.dispose(); } };
}
function finishForestLanterns(scene, id) {
const root = new TransformNode(`fin_${id}`, scene);
const COL_H = 4.2, GAP = 3.0;
const colL = makeColumn(scene, `${id}_colL`, { height: COL_H, diameter: 0.5,
diffuse: [0.30, 0.18, 0.08], emissive: [0.06, 0.04, 0.02] });
const colR = makeColumn(scene, `${id}_colR`, { height: COL_H, diameter: 0.5,
diffuse: [0.30, 0.18, 0.08], emissive: [0.06, 0.04, 0.02] });
colL.position.x = -GAP / 2; colR.position.x = GAP / 2;
colL.parent = root; colR.parent = root;
const beam = makeBeam(scene, `${id}_beam`, { width: GAP + 0.5, height: 0.3, depth: 0.3,
diffuse: [0.30, 0.18, 0.08], emissive: [0.06, 0.04, 0.02] });
beam.position.y = COL_H + 0.15;
beam.parent = root;
// тёплые лампочки
const colors = [[1.0, 0.85, 0.30], [1.0, 0.55, 0.20], [1.0, 0.95, 0.65]];
for (let i = 0; i < 9; i++) {
const bulb = makeBulb(scene, `${id}_b_${i}`,
-1.6 + i * 0.4,
COL_H + 0.15 - Math.abs(Math.sin(i * 1.7)) * 0.3,
0, colors[i % colors.length], 0.9);
bulb.parent = root;
}
const flag = makeChecker(scene, `${id}_flag`, { width: GAP - 0.2, height: 1.6 });
flag.position.y = COL_H - 1.2;
flag.parent = root;
const sign = makeSignText(scene, `${id}_sign`, 'ФИНИШ', {
textColor: '#ffd76b', bgColor: '#1f1408', emissive: [0.7, 0.55, 0.15],
});
sign.position.set(0, COL_H - 0.4, 0.26);
sign.parent = root;
return { root, dispose: () => { for (const ch of root.getChildMeshes()) ch.dispose(); root.dispose(); } };
}
// ----- Эпоха II — Горы -----
function finishMountainStone(scene, id) {
const root = new TransformNode(`fin_${id}`, scene);
const COL_H = 4.0, GAP = 3.0;
const dif = [0.50, 0.50, 0.55];
const colL = makeColumn(scene, `${id}_colL`, { height: COL_H, diameter: 0.7, shape: 'box', diffuse: dif, emissive: [0.10, 0.10, 0.11] });
const colR = makeColumn(scene, `${id}_colR`, { height: COL_H, diameter: 0.7, shape: 'box', diffuse: dif, emissive: [0.10, 0.10, 0.11] });
colL.position.x = -GAP / 2; colR.position.x = GAP / 2;
colL.parent = root; colR.parent = root;
const beam = makeBeam(scene, `${id}_beam`, { width: GAP + 1, height: 0.55, depth: 0.55, diffuse: dif, emissive: [0.10, 0.10, 0.11] });
beam.position.y = COL_H + 0.275;
beam.parent = root;
const flag = makeChecker(scene, `${id}_flag`, { width: GAP - 0.2, height: 1.6, c1: '#cccccc', c2: '#1a1a20' });
flag.position.y = COL_H - 1.2;
flag.parent = root;
const sign = makeSignText(scene, `${id}_sign`, 'ФИНИШ', { textColor: '#aaccff' });
sign.position.set(0, COL_H + 0.55, 0.31);
sign.parent = root;
return { root, dispose: () => { for (const ch of root.getChildMeshes()) ch.dispose(); root.dispose(); } };
}
function finishMountainCrystal(scene, id) {
const root = new TransformNode(`fin_${id}`, scene);
const COL_H = 4.2, GAP = 3.0;
const dif = [0.5, 0.7, 0.95], em = [0.20, 0.35, 0.55];
const colL = makeColumn(scene, `${id}_colL`, { height: COL_H, diameter: 0.55, diffuse: dif, emissive: em, specPower: 96 });
const colR = makeColumn(scene, `${id}_colR`, { height: COL_H, diameter: 0.55, diffuse: dif, emissive: em, specPower: 96 });
colL.position.x = -GAP / 2; colR.position.x = GAP / 2;
colL.parent = root; colR.parent = root;
const beam = makeBeam(scene, `${id}_beam`, { width: GAP + 0.6, height: 0.35, depth: 0.35, diffuse: dif, emissive: em });
beam.position.y = COL_H + 0.175; beam.parent = root;
const flag = makeChecker(scene, `${id}_flag`, { width: GAP - 0.2, height: 1.6, c1: '#ddeeff', c2: '#1a2a4a' });
flag.position.y = COL_H - 1.2; flag.parent = root;
const sign = makeSignText(scene, `${id}_sign`, 'ФИНИШ', { textColor: '#aaeeff' });
sign.position.set(0, COL_H - 0.3, 0.21);
sign.parent = root;
return { root, dispose: () => { for (const ch of root.getChildMeshes()) ch.dispose(); root.dispose(); } };
}
function finishMountainIce(scene, id) {
const root = new TransformNode(`fin_${id}`, scene);
const COL_H = 4.0, GAP = 3.0;
const dif = [0.80, 0.92, 1.0], em = [0.25, 0.35, 0.50];
const colL = makeColumn(scene, `${id}_colL`, { height: COL_H, diameter: 0.55, diffuse: dif, emissive: em, specPower: 128 });
const colR = makeColumn(scene, `${id}_colR`, { height: COL_H, diameter: 0.55, diffuse: dif, emissive: em, specPower: 128 });
colL.position.x = -GAP / 2; colR.position.x = GAP / 2;
colL.parent = root; colR.parent = root;
const beam = makeBeam(scene, `${id}_beam`, { width: GAP + 0.6, height: 0.35, depth: 0.35, diffuse: dif, emissive: em });
beam.position.y = COL_H + 0.175; beam.parent = root;
const flag = makeChecker(scene, `${id}_flag`, { width: GAP - 0.2, height: 1.6, c1: '#ffffff', c2: '#4488aa' });
flag.position.y = COL_H - 1.2; flag.parent = root;
const sign = makeSignText(scene, `${id}_sign`, 'ФИНИШ', { textColor: '#ddffff' });
sign.position.set(0, COL_H - 0.3, 0.21);
sign.parent = root;
return { root, dispose: () => { for (const ch of root.getChildMeshes()) ch.dispose(); root.dispose(); } };
}
function finishMountainPeak(scene, id) {
const root = new TransformNode(`fin_${id}`, scene);
const COL_H = 4.4, GAP = 3.0;
const dif = [0.40, 0.45, 0.50];
const colL = makeColumn(scene, `${id}_colL`, { height: COL_H, diameter: 0.6, shape: 'box', diffuse: dif, emissive: [0.05, 0.06, 0.07] });
const colR = makeColumn(scene, `${id}_colR`, { height: COL_H, diameter: 0.6, shape: 'box', diffuse: dif, emissive: [0.05, 0.06, 0.07] });
colL.position.x = -GAP / 2; colR.position.x = GAP / 2;
colL.parent = root; colR.parent = root;
const sideA = makeBeam(scene, `${id}_a`, { width: GAP * 0.7, height: 0.4, depth: 0.4, diffuse: dif, emissive: [0.05, 0.06, 0.07] });
sideA.position.set(-GAP * 0.27, COL_H + GAP * 0.27, 0);
sideA.rotation.z = -Math.PI / 4;
sideA.parent = root;
const sideB = sideA.clone(`${id}_b`);
sideB.position.set(GAP * 0.27, COL_H + GAP * 0.27, 0);
sideB.rotation.z = Math.PI / 4;
sideB.parent = root;
const flag = makeChecker(scene, `${id}_flag`, { width: GAP - 0.2, height: 1.6, c1: '#aabbcc', c2: '#1a2030' });
flag.position.y = COL_H - 1.2; flag.parent = root;
const sign = makeSignText(scene, `${id}_sign`, 'ФИНИШ', { textColor: '#88aacc' });
sign.position.set(0, COL_H - 0.6, 0.31);
sign.parent = root;
return { root, dispose: () => { for (const ch of root.getChildMeshes()) ch.dispose(); root.dispose(); } };
}
function finishMountainBronze(scene, id) {
const root = new TransformNode(`fin_${id}`, scene);
const COL_H = 4.2, GAP = 3.0;
const brz = [0.55, 0.38, 0.18];
const colL = makeColumn(scene, `${id}_colL`, { height: COL_H, diameter: 0.5, diffuse: brz, emissive: [0.15, 0.08, 0.03], specPower: 96 });
const colR = makeColumn(scene, `${id}_colR`, { height: COL_H, diameter: 0.5, diffuse: brz, emissive: [0.15, 0.08, 0.03], specPower: 96 });
colL.position.x = -GAP / 2; colR.position.x = GAP / 2;
colL.parent = root; colR.parent = root;
const beam = makeBeam(scene, `${id}_beam`, { width: GAP + 0.8, height: 0.45, depth: 0.45, diffuse: brz, emissive: [0.15, 0.08, 0.03] });
beam.position.y = COL_H + 0.225; beam.parent = root;
const flag = makeChecker(scene, `${id}_flag`, { width: GAP - 0.2, height: 1.6, c1: '#ffd76b', c2: '#3a2a1a' });
flag.position.y = COL_H - 1.2; flag.parent = root;
const sign = makeSignText(scene, `${id}_sign`, 'ФИНИШ', { textColor: '#ffd76b' });
sign.position.set(0, COL_H - 0.3, 0.26);
sign.parent = root;
return { root, dispose: () => { for (const ch of root.getChildMeshes()) ch.dispose(); root.dispose(); } };
}
// =========================================================================
// УПРОЩЁННЫЕ ЭПОХИ (3-10) через palette-обёртку
// =========================================================================
function finishGeneric(scene, id, palette) {
const root = new TransformNode(`fin_${id}`, scene);
const COL_H = 4.2, GAP = 3.0;
const colL = makeColumn(scene, `${id}_colL`, {
height: COL_H, diameter: palette.colDia || 0.55,
shape: palette.shape || 'cylinder',
diffuse: palette.colDif, emissive: palette.colEm,
specPower: palette.specPower,
});
const colR = makeColumn(scene, `${id}_colR`, {
height: COL_H, diameter: palette.colDia || 0.55,
shape: palette.shape || 'cylinder',
diffuse: palette.colDif, emissive: palette.colEm,
specPower: palette.specPower,
});
colL.position.x = -GAP / 2; colR.position.x = GAP / 2;
colL.parent = root; colR.parent = root;
const beam = makeBeam(scene, `${id}_beam`, {
width: GAP + 0.6, height: 0.35, depth: 0.35,
diffuse: palette.beamDif || palette.colDif, emissive: palette.beamEm || palette.colEm,
});
beam.position.y = COL_H + 0.175;
beam.parent = root;
const flag = makeChecker(scene, `${id}_flag`, {
width: GAP - 0.2, height: 1.6,
c1: palette.flagC1 || '#ffffff', c2: palette.flagC2 || '#0a0a14',
});
flag.position.y = COL_H - 1.2;
flag.parent = root;
const sign = makeSignText(scene, `${id}_sign`, 'ФИНИШ', {
textColor: palette.signColor || '#ffe44a',
bgColor: palette.signBg || '#0a1428',
emissive: palette.signEm || [0.8, 0.7, 0.2],
});
sign.position.set(0, COL_H - 0.4, 0.21);
sign.parent = root;
return { root, dispose: () => { for (const ch of root.getChildMeshes()) ch.dispose(); root.dispose(); } };
}
const GENERIC_EPOCHS = {
3: [
{ name: 'Город хром', colDif:[0.65,0.65,0.7], colEm:[0.15,0.15,0.18], signColor:'#22ccff', specPower:128, flagC1:'#ffffff', flagC2:'#222222' },
{ name: 'Город неон', colDif:[0.10,0.10,0.20], colEm:[0.10,0.30,0.60], signColor:'#22ddff', signEm:[0.1,0.6,1.0], flagC1:'#22ddff', flagC2:'#0a0a30' },
{ name: 'Город бетон', colDif:[0.55,0.55,0.55], colEm:[0.10,0.10,0.10], signColor:'#cccccc' },
{ name: 'Граффити', colDif:[0.20,0.20,0.25], colEm:[0.05,0.05,0.07], signColor:'#ff44aa', signBg:'#2a0a1a', signEm:[0.8,0.2,0.5], flagC1:'#ff44aa', flagC2:'#222' },
{ name: 'Город жёлтый', colDif:[0.85,0.65,0.10], colEm:[0.30,0.20,0.05], signColor:'#000', signBg:'#ffe44a', signEm:[1,0.85,0.1], flagC1:'#ffe44a', flagC2:'#222' },
],
4: [
{ name: 'Ночной фиолет', colDif:[0.35,0.10,0.50], colEm:[0.35,0.10,0.60], signColor:'#ff88ff', signEm:[0.7,0.3,0.9], flagC1:'#ff88ff', flagC2:'#22002a' },
{ name: 'Ночной циан', colDif:[0.10,0.40,0.50], colEm:[0.10,0.50,0.65], signColor:'#88eeff', signEm:[0.3,0.8,1.0], flagC1:'#88eeff', flagC2:'#001a2a' },
{ name: 'Ночной розовый', colDif:[0.80,0.20,0.45], colEm:[0.55,0.15,0.30], signColor:'#ff88aa', signEm:[1.0,0.4,0.7], flagC1:'#ff88aa', flagC2:'#2a0a1a' },
{ name: 'Ночной жёлтый', colDif:[0.85,0.65,0.10], colEm:[0.55,0.40,0.05], signColor:'#ffee88', signEm:[1,0.85,0.2], flagC1:'#ffee88', flagC2:'#2a2010' },
{ name: 'Голограмма', colDif:[0.30,0.50,1.0], colEm:[0.40,0.70,1.0], signColor:'#ffffff', signEm:[0.6,0.8,1.0], flagC1:'#88ccff', flagC2:'#0a1a3a' },
],
5: [
{ name: 'Пустыня песчаник', colDif:[0.75,0.62,0.38], colEm:[0.18,0.13,0.07], signColor:'#5a3a18', flagC1:'#d4b878', flagC2:'#3a2a1a' },
{ name: 'Пустыня терракота', colDif:[0.65,0.30,0.15], colEm:[0.20,0.07,0.03], signColor:'#ffd76b', flagC1:'#ffaa66', flagC2:'#3a1a0a' },
{ name: 'Пустыня кость', colDif:[0.85,0.80,0.65], colEm:[0.25,0.20,0.12], signColor:'#3a2a1a', flagC1:'#fff5e0', flagC2:'#3a2a1a' },
{ name: 'Пустыня кактус', colDif:[0.30,0.50,0.25], colEm:[0.07,0.15,0.07], signColor:'#ffffff', flagC1:'#9ad889', flagC2:'#1a3a1a' },
{ name: 'Пустыня закат', colDif:[0.85,0.40,0.20], colEm:[0.45,0.15,0.05], signColor:'#ffe44a', signEm:[1,0.5,0.1], flagC1:'#ffaa44', flagC2:'#3a1a0a' },
],
6: [
{ name: 'Океан коралл', colDif:[0.90,0.40,0.50], colEm:[0.30,0.10,0.15], signColor:'#ffffff', flagC1:'#ffaabb', flagC2:'#2a0a1a' },
{ name: 'Океан синий', colDif:[0.10,0.40,0.65], colEm:[0.05,0.20,0.35], signColor:'#aaeeff', flagC1:'#aaeeff', flagC2:'#0a1a3a' },
{ name: 'Океан жемчуг', colDif:[0.85,0.85,0.95], colEm:[0.20,0.20,0.30], signColor:'#3a5a7a', specPower:128, flagC1:'#ffffff', flagC2:'#3a5a7a' },
{ name: 'Океан водоросль', colDif:[0.20,0.50,0.30], colEm:[0.07,0.20,0.10], signColor:'#aaffaa', flagC1:'#7acc7a', flagC2:'#0a2a1a' },
{ name: 'Океан бирюза', colDif:[0.10,0.65,0.65], colEm:[0.10,0.40,0.40], signColor:'#ffffff', flagC1:'#aaffff', flagC2:'#0a2a2a' },
],
7: [
{ name: 'Пещеры камень', colDif:[0.35,0.30,0.25], colEm:[0.07,0.05,0.04], signColor:'#aa8855', flagC1:'#aa9977', flagC2:'#1a1410' },
{ name: 'Пещеры кварц', colDif:[0.55,0.45,0.75], colEm:[0.20,0.15,0.40], signColor:'#ddccff', flagC1:'#ddccff', flagC2:'#2a1a3a' },
{ name: 'Пещеры гриб', colDif:[0.55,0.20,0.20], colEm:[0.25,0.08,0.08], signColor:'#ffffff', flagC1:'#ffaaaa', flagC2:'#2a0a0a' },
{ name: 'Пещеры мох', colDif:[0.20,0.50,0.30], colEm:[0.20,0.55,0.30], signColor:'#aaff88', signEm:[0.3,0.8,0.4], flagC1:'#aaff88', flagC2:'#0a2a1a' },
{ name: 'Пещеры алмаз', colDif:[0.75,0.85,0.95], colEm:[0.35,0.40,0.45], signColor:'#ffffff', specPower:128, flagC1:'#ffffff', flagC2:'#4477aa' },
],
8: [
{ name: 'Вулкан лава', colDif:[0.30,0.10,0.05], colEm:[0.50,0.15,0.02], signColor:'#ffaa00', signEm:[1,0.5,0.05], flagC1:'#ff6600', flagC2:'#3a0a0a' },
{ name: 'Вулкан обсидиан', colDif:[0.10,0.05,0.10], colEm:[0.05,0.02,0.05], signColor:'#aa44aa', specPower:128, flagC1:'#aa44aa', flagC2:'#0a050a' },
{ name: 'Вулкан магма', colDif:[0.55,0.25,0.05], colEm:[0.85,0.40,0.08], signColor:'#ffff55', signEm:[1,1,0.3], flagC1:'#ffaa33', flagC2:'#3a1a05' },
{ name: 'Вулкан пепел', colDif:[0.25,0.22,0.22], colEm:[0.05,0.04,0.04], signColor:'#ff5555', flagC1:'#ff5555', flagC2:'#222' },
{ name: 'Вулкан огонь', colDif:[0.45,0.15,0.05], colEm:[0.85,0.30,0.05], signColor:'#ffee00', signEm:[1,0.9,0.1], flagC1:'#ffcc00', flagC2:'#3a1a05' },
],
9: [
{ name: 'Космос звезда', colDif:[0.45,0.40,0.10], colEm:[0.65,0.55,0.15], signColor:'#ffffaa', flagC1:'#ffffaa', flagC2:'#1a1a05' },
{ name: 'Космос плазма', colDif:[0.30,0.55,0.95], colEm:[0.40,0.80,1.00], signColor:'#aaffff', flagC1:'#aaffff', flagC2:'#0a1a3a' },
{ name: 'Космос лазер', colDif:[0.80,0.10,0.50], colEm:[0.95,0.20,0.80], signColor:'#ffaaff', signEm:[1,0.4,1], flagC1:'#ffaaff', flagC2:'#2a0a2a' },
{ name: 'Космос туманность', colDif:[0.55,0.25,0.85], colEm:[0.50,0.20,0.85], signColor:'#ffccff', flagC1:'#ffccff', flagC2:'#1a0a3a' },
{ name: 'Космос луна', colDif:[0.65,0.65,0.65], colEm:[0.35,0.35,0.35], signColor:'#bbbbbb', flagC1:'#ffffff', flagC2:'#1a1a2a' },
],
10: [
{ name: 'Кибер зелёный', colDif:[0.10,0.80,0.30], colEm:[0.20,1.00,0.40], signColor:'#aaffaa', signEm:[0.2,1.0,0.4], flagC1:'#aaffaa', flagC2:'#0a1a0a' },
{ name: 'Кибер фиолет', colDif:[0.55,0.15,0.85], colEm:[0.70,0.25,1.00], signColor:'#ffaaff', flagC1:'#ffaaff', flagC2:'#1a0a2a' },
{ name: 'Кибер красный', colDif:[0.85,0.10,0.30], colEm:[0.90,0.20,0.40], signColor:'#ffaaaa', flagC1:'#ffaaaa', flagC2:'#2a0a0a' },
{ name: 'Кибер голограмма', colDif:[0.30,0.50,1.0], colEm:[0.50,0.85,1.0], signColor:'#ffffff', flagC1:'#aaeeff', flagC2:'#0a2a4a' },
{ name: 'Кибер жёлтый', colDif:[0.85,0.85,0.10], colEm:[0.85,0.85,0.15], signColor:'#000', signBg:'#ffff44', signEm:[1,1,0.3], flagC1:'#ffee44', flagC2:'#222' },
],
};
export const FINISH_CATALOG = [];
const epoch1 = [
{ id: 'f1_v1', title: 'Деревянная с флагом', make: finishForestWoodFlag },
{ id: 'f1_v2', title: 'Золотая', make: finishForestGold },
{ id: 'f1_v3', title: 'С листвой', make: finishForestLeafy },
{ id: 'f1_v4', title: 'Рустик', make: finishForestRustic },
{ id: 'f1_v5', title: 'С фонариками', make: finishForestLanterns },
];
for (const a of epoch1) FINISH_CATALOG.push({ ...a, epoch: 1 });
const epoch2 = [
{ id: 'f2_v1', title: 'Каменная', make: finishMountainStone },
{ id: 'f2_v2', title: 'Кристалл', make: finishMountainCrystal },
{ id: 'f2_v3', title: 'Ледяная', make: finishMountainIce },
{ id: 'f2_v4', title: 'Пиковая', make: finishMountainPeak },
{ id: 'f2_v5', title: 'Бронзовая', make: finishMountainBronze },
];
for (const a of epoch2) FINISH_CATALOG.push({ ...a, epoch: 2 });
for (let epoch = 3; epoch <= 10; epoch++) {
const palettes = GENERIC_EPOCHS[epoch] || [];
for (let i = 0; i < palettes.length; i++) {
const palette = palettes[i];
FINISH_CATALOG.push({
id: `f${epoch}_v${i + 1}`,
epoch,
title: palette.name,
make: (scene, id) => finishGeneric(scene, id, palette),
});
}
}
export const EPOCH_INFO = [
{ n: 1, name: 'Лес', emoji: '🌲', color: '#3a7a4a' },
{ n: 2, name: 'Горы', emoji: '🏔', color: '#aaccff' },
{ n: 3, name: 'Город днём', emoji: '🏙', color: '#7a8aaa' },
{ n: 4, name: 'Город ночью', emoji: '🌃', color: '#1a1a3a' },
{ n: 5, name: 'Пустыня', emoji: '🏜', color: '#c8a575' },
{ n: 6, name: 'Океан', emoji: '🌊', color: '#3a8aaa' },
{ n: 7, name: 'Пещеры', emoji: '🕳', color: '#3a3a3a' },
{ n: 8, name: 'Вулкан', emoji: '🌋', color: '#8a2a2a' },
{ n: 9, name: 'Космос', emoji: '🚀', color: '#3a1a5a' },
{ n: 10, name: 'Кибер', emoji: '🤖', color: '#ff00ff' },
];
export function getFinishesByEpoch(epoch) {
return FINISH_CATALOG.filter(a => a.epoch === epoch);
}

View File

@ -0,0 +1,135 @@
/**
* musicCatalog реестр 20 треков GD-музыки.
*
* Каждый трек:
* - file: путь к mp3 (если есть)
* - fallbackSynth: параметры для процедурной генерации через Web Audio
* (используется когда mp3 ещё не залит)
* - bpm: темп определяет тайминги препятствий в редакторе уровней
* - epoch: 1-10
* - kind: 'main' (для L*1-L*9) или 'boss' (для L*0)
* - durationSec: целевая длительность
*
* При появлении реального mp3 в /public/music/gd/ fallback автоматически
* отключается, играет файл.
*/
// Параметры синтезированных мелодий для каждой эпохи.
// Используется musicSynth.js для генерации звука через Web Audio API.
//
// Шкалы (lydian/minor/etc) задают набор нот для арпеджио, делая каждую
// эпоху узнаваемой.
const SCALES = {
major: [0, 2, 4, 5, 7, 9, 11], // мажор
minor: [0, 2, 3, 5, 7, 8, 10], // натуральный минор
pentatonic: [0, 3, 5, 7, 10], // пентатоника (универсально приятная)
dorian: [0, 2, 3, 5, 7, 9, 10], // тёмный мажор
phrygian: [0, 1, 3, 5, 7, 8, 10], // восточная
harmonic: [0, 2, 3, 5, 7, 8, 11], // гармонический минор (драматичный)
blues: [0, 3, 5, 6, 7, 10],
whole: [0, 2, 4, 6, 8, 10], // целотоновая (космическая)
};
// 10 эпох × 2 трека = 20
export const TRACKS = [
// I. Лес
{ id: 'epoch_01_main', epoch: 1, kind: 'main', bpm: 130, durationSec: 120,
title: 'Лес — основной', file: '/music/gd/epoch_01_main.mp3',
fallbackSynth: { rootHz: 220, scale: 'pentatonic', leadWave: 'triangle', bassWave: 'sine', mood: 'calm' } },
{ id: 'epoch_01_boss', epoch: 1, kind: 'boss', bpm: 130, durationSec: 240,
title: 'Лес — босс «Стражник леса»', file: '/music/gd/epoch_01_boss.mp3',
fallbackSynth: { rootHz: 220, scale: 'minor', leadWave: 'sawtooth', bassWave: 'square', mood: 'epic' } },
// II. Горы
{ id: 'epoch_02_main', epoch: 2, kind: 'main', bpm: 140, durationSec: 120,
title: 'Горы — основной', file: '/music/gd/epoch_02_main.mp3',
fallbackSynth: { rootHz: 261.63, scale: 'minor', leadWave: 'sawtooth', bassWave: 'square', mood: 'cold' } },
{ id: 'epoch_02_boss', epoch: 2, kind: 'boss', bpm: 140, durationSec: 240,
title: 'Горы — босс «Король горы»', file: '/music/gd/epoch_02_boss.mp3',
fallbackSynth: { rootHz: 220, scale: 'harmonic', leadWave: 'sawtooth', bassWave: 'square', mood: 'epic' } },
// III. Город днём
{ id: 'epoch_03_main', epoch: 3, kind: 'main', bpm: 145, durationSec: 120,
title: 'Город — основной', file: '/music/gd/epoch_03_main.mp3',
fallbackSynth: { rootHz: 293.66, scale: 'major', leadWave: 'square', bassWave: 'sawtooth', mood: 'energetic' } },
{ id: 'epoch_03_boss', epoch: 3, kind: 'boss', bpm: 145, durationSec: 270,
title: 'Город — босс «Хозяин города»', file: '/music/gd/epoch_03_boss.mp3',
fallbackSynth: { rootHz: 220, scale: 'dorian', leadWave: 'square', bassWave: 'sawtooth', mood: 'epic' } },
// IV. Город ночью
{ id: 'epoch_04_main', epoch: 4, kind: 'main', bpm: 150, durationSec: 120,
title: 'Ночной город — основной', file: '/music/gd/epoch_04_main.mp3',
fallbackSynth: { rootHz: 196, scale: 'minor', leadWave: 'sawtooth', bassWave: 'square', mood: 'dark' } },
{ id: 'epoch_04_boss', epoch: 4, kind: 'boss', bpm: 150, durationSec: 300,
title: 'Ночной город — босс «Призрак ночи»', file: '/music/gd/epoch_04_boss.mp3',
fallbackSynth: { rootHz: 174.61, scale: 'phrygian', leadWave: 'sawtooth', bassWave: 'square', mood: 'epic' } },
// V. Пустыня
{ id: 'epoch_05_main', epoch: 5, kind: 'main', bpm: 140, durationSec: 120,
title: 'Пустыня — основной', file: '/music/gd/epoch_05_main.mp3',
fallbackSynth: { rootHz: 233.08, scale: 'phrygian', leadWave: 'triangle', bassWave: 'sine', mood: 'mystical' } },
{ id: 'epoch_05_boss', epoch: 5, kind: 'boss', bpm: 140, durationSec: 300,
title: 'Пустыня — босс «Король пустыни»', file: '/music/gd/epoch_05_boss.mp3',
fallbackSynth: { rootHz: 196, scale: 'phrygian', leadWave: 'sawtooth', bassWave: 'square', mood: 'epic' } },
// VI. Океан
{ id: 'epoch_06_main', epoch: 6, kind: 'main', bpm: 145, durationSec: 120,
title: 'Океан — основной', file: '/music/gd/epoch_06_main.mp3',
fallbackSynth: { rootHz: 196, scale: 'pentatonic', leadWave: 'sine', bassWave: 'sine', mood: 'chill' } },
{ id: 'epoch_06_boss', epoch: 6, kind: 'boss', bpm: 145, durationSec: 300,
title: 'Океан — босс «Левиафан»', file: '/music/gd/epoch_06_boss.mp3',
fallbackSynth: { rootHz: 174.61, scale: 'dorian', leadWave: 'triangle', bassWave: 'sine', mood: 'epic' } },
// VII. Пещеры
{ id: 'epoch_07_main', epoch: 7, kind: 'main', bpm: 135, durationSec: 120,
title: 'Пещеры — основной', file: '/music/gd/epoch_07_main.mp3',
fallbackSynth: { rootHz: 165, scale: 'minor', leadWave: 'triangle', bassWave: 'square', mood: 'dark' } },
{ id: 'epoch_07_boss', epoch: 7, kind: 'boss', bpm: 135, durationSec: 300,
title: 'Пещеры — босс «Хранитель глубин»', file: '/music/gd/epoch_07_boss.mp3',
fallbackSynth: { rootHz: 138, scale: 'minor', leadWave: 'square', bassWave: 'square', mood: 'epic' } },
// VIII. Вулкан
{ id: 'epoch_08_main', epoch: 8, kind: 'main', bpm: 160, durationSec: 120,
title: 'Вулкан — основной', file: '/music/gd/epoch_08_main.mp3',
fallbackSynth: { rootHz: 220, scale: 'harmonic', leadWave: 'sawtooth', bassWave: 'sawtooth', mood: 'aggressive' } },
{ id: 'epoch_08_boss', epoch: 8, kind: 'boss', bpm: 160, durationSec: 300,
title: 'Вулкан — босс «Дракон вулкана»', file: '/music/gd/epoch_08_boss.mp3',
fallbackSynth: { rootHz: 196, scale: 'harmonic', leadWave: 'sawtooth', bassWave: 'square', mood: 'epic' } },
// IX. Космос
{ id: 'epoch_09_main', epoch: 9, kind: 'main', bpm: 150, durationSec: 120,
title: 'Космос — основной', file: '/music/gd/epoch_09_main.mp3',
fallbackSynth: { rootHz: 261.63, scale: 'whole', leadWave: 'sine', bassWave: 'triangle', mood: 'space' } },
{ id: 'epoch_09_boss', epoch: 9, kind: 'boss', bpm: 150, durationSec: 300,
title: 'Космос — босс «Космический владыка»', file: '/music/gd/epoch_09_boss.mp3',
fallbackSynth: { rootHz: 220, scale: 'whole', leadWave: 'sawtooth', bassWave: 'square', mood: 'epic' } },
// X. Кибер
{ id: 'epoch_10_main', epoch: 10, kind: 'main', bpm: 155, durationSec: 120,
title: 'Кибер — основной', file: '/music/gd/epoch_10_main.mp3',
fallbackSynth: { rootHz: 261.63, scale: 'dorian', leadWave: 'sawtooth', bassWave: 'square', mood: 'intense' } },
{ id: 'epoch_10_boss', epoch: 10, kind: 'boss', bpm: 155, durationSec: 360,
title: 'ФИНАЛ — босс «АРХИТЕКТОР»', file: '/music/gd/epoch_10_boss.mp3',
fallbackSynth: { rootHz: 220, scale: 'harmonic', leadWave: 'sawtooth', bassWave: 'sawtooth', mood: 'final' } },
// === Спец-трек игры «СабвейСёрф» (id=900) ===
// Реальный mp3-файл в /public/music/gd/. Если файла нет — fallback-синтез.
{ id: 'subway_surf_main', epoch: 0, kind: 'main', bpm: 145, durationSec: 150,
title: 'СабвейСёрф — главная тема', file: '/music/gd/subway_surf_main.mp3',
fallbackSynth: { rootHz: 293.66, scale: 'major', leadWave: 'square', bassWave: 'sawtooth', mood: 'energetic' } },
];
export const EPOCH_INFO = [
{ n: 1, name: 'Лес', emoji: '🌲', color: '#3a7a4a' },
{ n: 2, name: 'Горы', emoji: '🏔', color: '#aaccff' },
{ n: 3, name: 'Город днём', emoji: '🏙', color: '#7a8aaa' },
{ n: 4, name: 'Город ночью', emoji: '🌃', color: '#1a1a3a' },
{ n: 5, name: 'Пустыня', emoji: '🏜', color: '#c8a575' },
{ n: 6, name: 'Океан', emoji: '🌊', color: '#3a8aaa' },
{ n: 7, name: 'Пещеры', emoji: '🕳', color: '#3a3a3a' },
{ n: 8, name: 'Вулкан', emoji: '🌋', color: '#8a2a2a' },
{ n: 9, name: 'Космос', emoji: '🚀', color: '#3a1a5a' },
{ n: 10, name: 'Кибер-будущее', emoji: '🤖', color: '#ff00ff' },
];
export { SCALES };

View File

@ -0,0 +1,263 @@
/**
* musicSynth процедурный синтезатор GD-музыки на Web Audio API.
*
* Используется как fallback пока нет реальных mp3-файлов.
* Принимает параметры из musicCatalog.fallbackSynth и играет:
* - басовую партию (4/4 кик + бас по тонике)
* - арпеджио лида по выбранной шкале
* - хайхэт (шум) для ритма
*
* Это НЕ замена настоящей музыке это инструмент для проверки темпа.
* Когда МИН закачает реальные треки, синтез автоматически отключается.
*/
import { SCALES } from './musicCatalog';
const MOOD_PARAMS = {
calm: { leadGain: 0.10, bassGain: 0.18, density: 0.5, drumGain: 0.20 },
cold: { leadGain: 0.12, bassGain: 0.20, density: 0.6, drumGain: 0.22 },
dark: { leadGain: 0.10, bassGain: 0.22, density: 0.6, drumGain: 0.24 },
energetic: { leadGain: 0.13, bassGain: 0.22, density: 0.8, drumGain: 0.26 },
mystical: { leadGain: 0.11, bassGain: 0.18, density: 0.7, drumGain: 0.22 },
chill: { leadGain: 0.10, bassGain: 0.18, density: 0.5, drumGain: 0.20 },
aggressive: { leadGain: 0.14, bassGain: 0.25, density: 0.9, drumGain: 0.30 },
space: { leadGain: 0.10, bassGain: 0.18, density: 0.6, drumGain: 0.22 },
intense: { leadGain: 0.14, bassGain: 0.24, density: 0.9, drumGain: 0.28 },
epic: { leadGain: 0.13, bassGain: 0.24, density: 0.85, drumGain: 0.28 },
final: { leadGain: 0.15, bassGain: 0.26, density: 0.95, drumGain: 0.30 },
};
/**
* Преобразовать степень шкалы в частоту относительно root.
* step 0..N в пределах октавы, octaveOffset сдвиг октавы (целое).
*/
function noteHz(rootHz, scale, step, octaveOffset = 0) {
const semis = scale[step % scale.length] + 12 * Math.floor(step / scale.length);
return rootHz * Math.pow(2, (semis + octaveOffset * 12) / 12);
}
/**
* Класс для управления одной играющей синтез-партией.
* Создаёт AudioContext, расписывает ноты, может остановиться.
*/
export class SynthPlayer {
constructor(synthParams, bpm) {
this.synthParams = synthParams;
this.bpm = bpm;
this.ctx = null;
this.masterGain = null;
this.scheduledStop = [];
this.schedulerInterval = null;
this.startTime = 0;
this.nextNoteTime = 0;
this.beat = 0;
this.isPlaying = false;
}
async start() {
if (this.isPlaying) return;
const Ctor = window.AudioContext || window.webkitAudioContext;
if (!Ctor) {
console.warn('[SynthPlayer] Web Audio API не поддерживается в этом браузере');
return;
}
this.ctx = new Ctor();
// Браузер мог создать context в состоянии 'suspended' (autoplay policy).
// resume() работает только если вызван внутри user-gesture цепочки — здесь
// это так, потому что start() вызывается из onClick. На всякий случай ждём.
if (this.ctx.state === 'suspended') {
try { await this.ctx.resume(); }
catch (e) { console.warn('[SynthPlayer] resume() failed:', e); }
}
console.log('[SynthPlayer] AudioContext state:', this.ctx.state,
'sampleRate:', this.ctx.sampleRate, 'bpm:', this.bpm,
'synth:', this.synthParams);
this.masterGain = this.ctx.createGain();
this.masterGain.gain.value = 0.7;
this.masterGain.connect(this.ctx.destination);
this.startTime = this.ctx.currentTime;
this.nextNoteTime = this.startTime + 0.05;
this.beat = 0;
this.isPlaying = true;
// Планировщик нот — каждые 25мс заглядывает на 0.1с вперёд и ставит ноты
this.schedulerInterval = setInterval(() => this._scheduler(), 25);
}
stop() {
this.isPlaying = false;
if (this.schedulerInterval) {
clearInterval(this.schedulerInterval);
this.schedulerInterval = null;
}
if (this.ctx) {
try {
this.masterGain.gain.linearRampToValueAtTime(0, this.ctx.currentTime + 0.05);
setTimeout(() => {
try { this.ctx.close(); } catch (_) {}
}, 100);
} catch (_) {}
this.ctx = null;
}
}
_scheduler() {
if (!this.isPlaying || !this.ctx) return;
const lookahead = 0.1; // секунд
while (this.nextNoteTime < this.ctx.currentTime + lookahead) {
this._scheduleBeat(this.beat, this.nextNoteTime);
const beatDur = 60 / this.bpm;
// играем по 8-м долям (полубиты)
this.nextNoteTime += beatDur / 2;
this.beat++;
}
}
_scheduleBeat(beat, time) {
const sp = this.synthParams;
const mood = MOOD_PARAMS[sp.mood] || MOOD_PARAMS.calm;
const scale = SCALES[sp.scale] || SCALES.minor;
const root = sp.rootHz;
// Каждый 8-й полу-бит = 4 такта по 4 ноты, итого 16 на цикл
const pos = beat % 32;
// === КИК (бас-барабан) — на сильных долях ===
if (pos % 4 === 0) {
this._kick(time, mood.drumGain);
}
// === СНЭР — на off-beat ===
if (pos % 8 === 4) {
this._snare(time, mood.drumGain * 0.8);
}
// === ХАЙХЭТ — каждая 8-я ===
if (pos % 2 === 0) {
this._hat(time, mood.drumGain * 0.35);
}
// === БАС-СИНТ — тоника на сильных, иногда квинта ===
if (pos % 4 === 0) {
const bassNote = pos % 16 === 8 ? noteHz(root, scale, 4, -1) : noteHz(root, scale, 0, -1);
this._tone(time, bassNote, sp.bassWave, mood.bassGain, 0.45);
}
// === ЛИД — арпеджио по шкале, не на каждом тике ===
if (pos % 2 === 0 && Math.random() < mood.density) {
const arpSteps = [0, 2, 4, 7, 4, 2];
const step = arpSteps[Math.floor(beat / 2) % arpSteps.length];
const oct = pos % 8 === 0 ? 1 : 0;
this._tone(time, noteHz(root, scale, step, oct), sp.leadWave, mood.leadGain, 0.25, true);
}
// На эпик-моментах — арпеджио в высокой октаве
if (sp.mood === 'epic' || sp.mood === 'final') {
if (pos % 16 === 0) {
this._tone(time, noteHz(root, scale, 0, 2), sp.leadWave, mood.leadGain * 1.5, 0.8, true);
}
}
}
_tone(time, freq, waveType, gain, duration, withFilter = false) {
const osc = this.ctx.createOscillator();
osc.type = waveType || 'sine';
osc.frequency.value = freq;
const g = this.ctx.createGain();
g.gain.setValueAtTime(0, time);
g.gain.linearRampToValueAtTime(gain, time + 0.005);
g.gain.exponentialRampToValueAtTime(0.001, time + duration);
if (withFilter) {
const filter = this.ctx.createBiquadFilter();
filter.type = 'lowpass';
filter.frequency.value = freq * 4;
filter.Q.value = 2;
osc.connect(filter);
filter.connect(g);
} else {
osc.connect(g);
}
g.connect(this.masterGain);
osc.start(time);
osc.stop(time + duration + 0.05);
}
_kick(time, gain) {
const osc = this.ctx.createOscillator();
osc.frequency.setValueAtTime(140, time);
osc.frequency.exponentialRampToValueAtTime(40, time + 0.15);
const g = this.ctx.createGain();
g.gain.setValueAtTime(gain * 1.8, time);
g.gain.exponentialRampToValueAtTime(0.001, time + 0.2);
osc.connect(g);
g.connect(this.masterGain);
osc.start(time);
osc.stop(time + 0.25);
}
_snare(time, gain) {
// короткий шум
const noise = this.ctx.createBufferSource();
const buf = this.ctx.createBuffer(1, 4410, this.ctx.sampleRate);
const d = buf.getChannelData(0);
for (let i = 0; i < d.length; i++) d[i] = (Math.random() * 2 - 1) * (1 - i / d.length);
noise.buffer = buf;
const filter = this.ctx.createBiquadFilter();
filter.type = 'highpass';
filter.frequency.value = 1800;
const g = this.ctx.createGain();
g.gain.setValueAtTime(gain * 1.5, time);
g.gain.exponentialRampToValueAtTime(0.001, time + 0.12);
noise.connect(filter);
filter.connect(g);
g.connect(this.masterGain);
noise.start(time);
noise.stop(time + 0.15);
}
_hat(time, gain) {
const noise = this.ctx.createBufferSource();
const buf = this.ctx.createBuffer(1, 2200, this.ctx.sampleRate);
const d = buf.getChannelData(0);
for (let i = 0; i < d.length; i++) d[i] = (Math.random() * 2 - 1) * (1 - i / d.length);
noise.buffer = buf;
const filter = this.ctx.createBiquadFilter();
filter.type = 'highpass';
filter.frequency.value = 6000;
const g = this.ctx.createGain();
g.gain.setValueAtTime(gain, time);
g.gain.exponentialRampToValueAtTime(0.001, time + 0.05);
noise.connect(filter);
filter.connect(g);
g.connect(this.masterGain);
noise.start(time);
noise.stop(time + 0.07);
}
}
/**
* Проверить, есть ли файл трека на сервере (HEAD-запрос).
*
* Важно: dev-server React на 404 возвращает index.html с 200 OK
* (history fallback). Поэтому проверяем не только r.ok, но и
* Content-Type должен начинаться с "audio/".
*/
export async function trackFileExists(url) {
try {
const r = await fetch(url, { method: 'HEAD', cache: 'no-cache' });
if (!r.ok) return false;
const ct = (r.headers.get('content-type') || '').toLowerCase();
return ct.startsWith('audio/');
} catch (_) {
return false;
}
}

View File

@ -0,0 +1,330 @@
/**
* portalFactories реестр 3D-арок порталов GD (смена гейммода).
*
* 6 гейммодов × 5 вариантов = 30 арок. Каждая арка:
* - 2 вертикальные колонны (по бокам проёма ~3м)
* - 1 горизонтальная балка сверху
* - табличка с символом/названием гейммода (CUBE / SHIP / BALL / UFO / WAVE / ROBOT)
* - неоновая подсветка в цвете режима
*
* Возвращает (scene, id) => { root: TransformNode, dispose() }.
* Габариты: ширина проёма ~3м, высота ~4м, центр в (0, 0, 0).
* Низ колонн на y=0; ставить корнем на y=1 (на верх пола, как GdStartArch).
*/
import {
MeshBuilder, StandardMaterial, Color3, Vector3, TransformNode,
DynamicTexture,
} from '@babylonjs/core';
// =========================================================================
// УТИЛИТЫ
// =========================================================================
function makeMat(scene, name, opts = {}) {
const m = new StandardMaterial(name, scene);
m.diffuseColor = new Color3(...(opts.diffuse || [1, 1, 1]));
m.emissiveColor = new Color3(...(opts.emissive || [0, 0, 0]));
m.specularColor = new Color3(...(opts.specular || [0.2, 0.2, 0.2]));
if (opts.specPower != null) m.specularPower = opts.specPower;
if (opts.alpha != null) m.alpha = opts.alpha;
if (opts.disableLighting) m.disableLighting = true;
return m;
}
function makeColumn(scene, name, opts = {}) {
const {
height = 3.6, diameter = 0.5, shape = 'cylinder',
diffuse, emissive, specular, specPower,
} = opts;
let mesh;
if (shape === 'box') {
mesh = MeshBuilder.CreateBox(name, { width: diameter, height, depth: diameter }, scene);
} else if (shape === 'oct') {
mesh = MeshBuilder.CreateCylinder(name, { diameter, height, tessellation: 8 }, scene);
} else {
mesh = MeshBuilder.CreateCylinder(name, { diameter, height, tessellation: 16 }, scene);
}
mesh.position.y = height / 2;
mesh.material = makeMat(scene, `${name}_mat`, { diffuse, emissive, specular, specPower });
return mesh;
}
function makeBeam(scene, name, opts = {}) {
const {
width = 3.5, height = 0.5, depth = 0.5,
diffuse, emissive, specular, specPower,
} = opts;
const beam = MeshBuilder.CreateBox(name, { width, height, depth }, scene);
beam.material = makeMat(scene, `${name}_mat`, { diffuse, emissive, specular, specPower });
return beam;
}
/** Табличка с текстом-символом гейммода. */
function makeSignText(scene, name, text, opts = {}) {
const {
width = 2.4, height = 0.7,
bgColor = '#0a1428', textColor = '#ffffff',
fontSize = 88, fontFamily = 'sans-serif',
emissive = [0.6, 0.6, 0.6],
} = opts;
const TW = 512, TH = 128;
const dt = new DynamicTexture(`${name}_tex`, { width: TW, height: TH }, scene, true);
const ctx = dt.getContext();
ctx.fillStyle = bgColor;
ctx.fillRect(0, 0, TW, TH);
ctx.fillStyle = textColor;
ctx.font = `bold ${fontSize}px ${fontFamily}`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(text, TW / 2, TH / 2);
dt.hasAlpha = false;
dt.update();
const plane = MeshBuilder.CreatePlane(name, { width, height }, scene);
const mat = new StandardMaterial(`${name}_mat`, scene);
mat.diffuseTexture = dt;
mat.emissiveTexture = dt;
mat.emissiveColor = new Color3(...emissive);
mat.disableLighting = true;
mat.backFaceCulling = false;
plane.material = mat;
return plane;
}
/** Неоновая лента вокруг проёма (P-образно). */
function makeNeonOutline(scene, idBase, gap, colH, color) {
const meshes = [];
const tube = (name, w, h, x, y) => {
const m = MeshBuilder.CreateBox(name, { width: w, height: h, depth: 0.12 }, scene);
m.position.set(x, y, 0.22);
m.material = makeMat(scene, `${name}_mat`, {
diffuse: color, emissive: color, disableLighting: true,
});
meshes.push(m);
return m;
};
// Левая лента, правая лента, верхняя
tube(`${idBase}_neonL`, 0.12, colH, -gap/2, colH/2);
tube(`${idBase}_neonR`, 0.12, colH, gap/2, colH/2);
tube(`${idBase}_neonT`, gap + 0.12, 0.12, 0, colH);
return meshes;
}
/** Сферическая «лампочка»-glow. */
function makeBulb(scene, name, x, y, z, color, scale = 1) {
const bulb = MeshBuilder.CreateSphere(name, { diameter: 0.20, segments: 8 }, scene);
bulb.position.set(x, y, z);
bulb.scaling.set(scale, scale, scale);
bulb.material = makeMat(scene, `${name}_mat`, {
diffuse: color, emissive: color, disableLighting: true,
});
return bulb;
}
/** Финиш-стиль: 2 колонны + балка + табличка. Это база для всех вариантов. */
function buildArch(scene, id, opts) {
const root = new TransformNode(`portal_arch_${id}`, scene);
const {
colShape = 'cylinder',
colDiameter = 0.55,
colH = 3.6,
gap = 3.0,
beamH = 0.45,
frameDif, frameEm,
signText = 'PORTAL',
signColor = '#ffffff',
signBg = '#0a1428',
accent, // цвет неона/glow
extras, // function(scene, id, root, gap, colH) — доп. декор
addNeon = false,
addBulbs = false,
} = opts;
const colL = makeColumn(scene, `${id}_colL`, {
height: colH, diameter: colDiameter, shape: colShape,
diffuse: frameDif, emissive: frameEm,
specular: [0.5, 0.5, 0.5], specPower: 64,
});
const colR = makeColumn(scene, `${id}_colR`, {
height: colH, diameter: colDiameter, shape: colShape,
diffuse: frameDif, emissive: frameEm,
specular: [0.5, 0.5, 0.5], specPower: 64,
});
colL.position.x = -gap/2;
colR.position.x = gap/2;
colL.parent = root; colR.parent = root;
const beam = makeBeam(scene, `${id}_beam`, {
width: gap + colDiameter + 0.2, height: beamH, depth: 0.5,
diffuse: frameDif, emissive: frameEm,
specular: [0.5, 0.5, 0.5], specPower: 64,
});
beam.position.y = colH + beamH/2;
beam.parent = root;
const sign = makeSignText(scene, `${id}_sign`, signText, {
width: 2.3, height: 0.7, textColor: signColor, bgColor: signBg,
emissive: hexToRgb01(accent || signColor),
});
sign.position.set(0, colH - 0.2, 0.28);
sign.parent = root;
if (addNeon && accent) {
const ns = makeNeonOutline(scene, id, gap, colH, hexToRgb01(accent));
for (const m of ns) m.parent = root;
}
if (addBulbs && accent) {
// 6 лампочек по балке
const c = hexToRgb01(accent);
for (let i = 0; i < 6; i++) {
const bx = -gap/2 + (i * gap / 5);
const b = makeBulb(scene, `${id}_bulb_${i}`, bx, colH + beamH + 0.18, 0.0, c, 1.0);
b.parent = root;
}
}
if (extras) {
try { extras(scene, id, root, gap, colH); } catch (e) {}
}
return {
root,
dispose: () => {
for (const ch of root.getChildMeshes()) ch.dispose();
root.dispose();
},
};
}
function hexToRgb01(hex) {
if (Array.isArray(hex)) return hex;
const h = hex.replace('#', '');
return [
parseInt(h.slice(0, 2), 16) / 255,
parseInt(h.slice(2, 4), 16) / 255,
parseInt(h.slice(4, 6), 16) / 255,
];
}
// =========================================================================
// ФАБРИКИ — 6 гейммодов × 5 вариантов
// =========================================================================
// Цвета режимов (rgb 0..1 для эмиссии и hex для текстов)
const COLORS = {
cube: { hex: '#22ff66', rgb: [0.13, 1.00, 0.40] }, // зелёный
ship: { hex: '#ff44aa', rgb: [1.00, 0.27, 0.67] }, // розовый
ball: { hex: '#ff8833', rgb: [1.00, 0.53, 0.20] }, // оранжевый
ufo: { hex: '#ffe44a', rgb: [1.00, 0.89, 0.29] }, // жёлтый
wave: { hex: '#33aaff', rgb: [0.20, 0.67, 1.00] }, // голубой
robot: { hex: '#bbbbbb', rgb: [0.73, 0.73, 0.73] }, // серый
};
const LABELS = {
cube: 'CUBE', ship: 'SHIP', ball: 'BALL',
ufo: 'UFO', wave: 'WAVE', robot: 'ROBOT',
};
// Helper для group-фабрики: возвращает фабрику с правильными цветами
function archForGroup(group, variant, opts) {
return (scene, id) => buildArch(scene, id, {
...opts,
signText: LABELS[group],
signColor: COLORS[group].hex,
accent: COLORS[group].hex,
});
}
// 5 вариантов формы (повторяются для всех групп):
const VARIANTS = {
// v1 — Классический: толстые цилиндрические колонны, чистый рамка
v1: (group) => archForGroup(group, 1, {
colShape: 'cylinder', colDiameter: 0.55, colH: 3.6, gap: 3.0, beamH: 0.45,
frameDif: [0.30, 0.30, 0.32], frameEm: [0.05, 0.05, 0.06],
signBg: '#0a1428',
}),
// v2 — Круглый: тонкие колонны + декоративные кольца + неоновая обводка
v2: (group) => archForGroup(group, 2, {
colShape: 'cylinder', colDiameter: 0.40, colH: 3.6, gap: 3.0, beamH: 0.35,
frameDif: [0.18, 0.18, 0.22], frameEm: [0.08, 0.08, 0.10],
addNeon: true,
signBg: '#000000',
extras: (scene, id, root, gap, colH) => {
// Кольца на колоннах
const c = COLORS[group === 'cube' ? 'cube' : group].rgb;
for (let side of [-1, 1]) {
for (let i = 0; i < 3; i++) {
const r = MeshBuilder.CreateTorus(`${id}_ring_${side}_${i}`,
{ diameter: 0.65, thickness: 0.06, tessellation: 16 }, scene);
r.position.set(side * gap/2, 0.8 + i * 1.0, 0);
r.rotation.x = Math.PI / 2;
r.material = makeMat(scene, `${id}_ring_mat_${side}_${i}`, {
diffuse: c, emissive: c, disableLighting: true,
});
r.parent = root;
}
}
},
}),
// v3 — Высокий: квадратные колонны (box), массивная балка
v3: (group) => archForGroup(group, 3, {
colShape: 'box', colDiameter: 0.70, colH: 4.2, gap: 3.2, beamH: 0.6,
frameDif: [0.45, 0.45, 0.48], frameEm: [0.12, 0.12, 0.14],
addBulbs: true,
signBg: '#1a1a2e',
}),
// v4 — Неон тонкий: очень тонкие колонны (только неон-обводка читается)
v4: (group) => archForGroup(group, 4, {
colShape: 'cylinder', colDiameter: 0.25, colH: 3.5, gap: 3.0, beamH: 0.20,
frameDif: [0.10, 0.10, 0.14], frameEm: [0.05, 0.05, 0.08],
addNeon: true,
signBg: '#050510',
}),
// v5 — Восьмигранный: oct-колонны + лампочки
v5: (group) => archForGroup(group, 5, {
colShape: 'oct', colDiameter: 0.65, colH: 3.8, gap: 3.1, beamH: 0.50,
frameDif: [0.35, 0.30, 0.40], frameEm: [0.10, 0.08, 0.12],
addBulbs: true,
addNeon: false,
signBg: '#0e0a14',
}),
};
// =========================================================================
// КАТАЛОГ
// =========================================================================
export const PORTAL_CATALOG = [];
function add(group, num, title, make) {
PORTAL_CATALOG.push({
id: `${group}_v${num}`,
group,
title,
make,
});
}
const VARIANT_TITLES = {
1: 'Классическая',
2: 'Кольца + неон',
3: 'Массивная',
4: 'Неон тонкий',
5: 'Восьмигранная',
};
for (const group of ['cube', 'ship', 'ball', 'ufo', 'wave', 'robot']) {
for (const num of [1, 2, 3, 4, 5]) {
const factory = VARIANTS[`v${num}`](group);
add(group, num, VARIANT_TITLES[num], factory);
}
}
export const PORTAL_GROUPS = [
{ id: 'cube', name: 'Cube — Куб', emoji: '🟦', color: '#22ff66' },
{ id: 'ship', name: 'Ship — Корабль', emoji: '🚀', color: '#ff44aa' },
{ id: 'ball', name: 'Ball — Шар', emoji: '🟢', color: '#ff8833' },
{ id: 'ufo', name: 'UFO — НЛО', emoji: '🛸', color: '#ffe44a' },
{ id: 'wave', name: 'Wave — Волна', emoji: '〰', color: '#33aaff' },
{ id: 'robot', name: 'Robot — Робот', emoji: '🤖', color: '#bbbbbb' },
];
export function getPortalsByGroup(group) {
return PORTAL_CATALOG.filter(p => p.group === group);
}

View File

@ -0,0 +1,289 @@
/**
* sfxFactories процедурная генерация звуковых эффектов для GD-игры.
*
* Каждый SFX = функция playXxx(ctx, masterGain) которая ставит осцилляторы/шум
* и сразу проигрывает звук. Без файлов.
*
* Все 9 эффектов согласно RUBLOX_GD_GDD.md §8:
* jump, death, orb_tap, bounce, whoosh, flip, coin, level_complete, new_record
*/
// ============================================================
// УТИЛИТЫ
// ============================================================
/**
* Создать один тон-осциллятор с ADSR-огибающей.
*/
function tone(ctx, dest, opts) {
const {
freq, freqEnd = freq, wave = 'sine',
attack = 0.005, decay = 0.0, sustain = 1.0, release = 0.1,
duration = 0.15, gain = 0.3,
filter = null, // { type, freq, q }
} = opts;
const t0 = ctx.currentTime;
const osc = ctx.createOscillator();
osc.type = wave;
osc.frequency.setValueAtTime(freq, t0);
if (freqEnd !== freq) {
osc.frequency.exponentialRampToValueAtTime(Math.max(freqEnd, 1), t0 + duration);
}
const g = ctx.createGain();
g.gain.setValueAtTime(0, t0);
g.gain.linearRampToValueAtTime(gain, t0 + attack);
if (decay > 0) {
g.gain.linearRampToValueAtTime(gain * sustain, t0 + attack + decay);
}
g.gain.linearRampToValueAtTime(0, t0 + duration);
let lastNode = osc;
if (filter) {
const f = ctx.createBiquadFilter();
f.type = filter.type || 'lowpass';
f.frequency.value = filter.freq || 1000;
f.Q.value = filter.q || 1;
osc.connect(f);
lastNode = f;
}
lastNode.connect(g);
g.connect(dest);
osc.start(t0);
osc.stop(t0 + duration + 0.05);
}
/**
* Короткий "взрыв" шума с фильтром (для удара / снэра).
*/
function noiseBurst(ctx, dest, opts) {
const {
duration = 0.15, gain = 0.3,
filterType = 'highpass', filterFreq = 1000, filterQ = 1,
} = opts;
const t0 = ctx.currentTime;
const len = Math.max(1, Math.floor(ctx.sampleRate * duration));
const buf = ctx.createBuffer(1, len, ctx.sampleRate);
const d = buf.getChannelData(0);
for (let i = 0; i < len; i++) {
d[i] = (Math.random() * 2 - 1) * (1 - i / len);
}
const src = ctx.createBufferSource();
src.buffer = buf;
const f = ctx.createBiquadFilter();
f.type = filterType;
f.frequency.value = filterFreq;
f.Q.value = filterQ;
const g = ctx.createGain();
g.gain.setValueAtTime(gain, t0);
g.gain.exponentialRampToValueAtTime(0.001, t0 + duration);
src.connect(f);
f.connect(g);
g.connect(dest);
src.start(t0);
src.stop(t0 + duration + 0.05);
}
// ============================================================
// 9 ЭФФЕКТОВ
// ============================================================
/** Прыжок — короткий восходящий "whoop". */
export function playJump(ctx, master) {
tone(ctx, master, {
freq: 280, freqEnd: 520, wave: 'triangle',
duration: 0.13, gain: 0.25, attack: 0.005, release: 0.1,
});
}
/** Смерть — низкий "smash" с шумом. */
export function playDeath(ctx, master) {
// Низкочастотный осциллятор который падает
tone(ctx, master, {
freq: 200, freqEnd: 60, wave: 'sawtooth',
duration: 0.4, gain: 0.28,
});
// Шумовой взрыв сверху
noiseBurst(ctx, master, {
duration: 0.25, gain: 0.22,
filterType: 'bandpass', filterFreq: 400, filterQ: 1,
});
}
/** Тап по orb — звон. */
export function playOrbTap(ctx, master) {
tone(ctx, master, {
freq: 880, freqEnd: 1760, wave: 'sine',
duration: 0.18, gain: 0.22,
});
// обертон
tone(ctx, master, {
freq: 1320, wave: 'triangle',
duration: 0.12, gain: 0.12,
});
}
/** Трамплин — пружина "boing". */
export function playBounce(ctx, master) {
tone(ctx, master, {
freq: 200, freqEnd: 600, wave: 'square',
duration: 0.2, gain: 0.22,
filter: { type: 'lowpass', freq: 1500, q: 4 },
});
// лёгкий шум для "wobble"
noiseBurst(ctx, master, {
duration: 0.08, gain: 0.06,
filterType: 'bandpass', filterFreq: 800, filterQ: 2,
});
}
/** Speed pad — шипящий swoosh. */
export function playWhoosh(ctx, master) {
noiseBurst(ctx, master, {
duration: 0.35, gain: 0.28,
filterType: 'bandpass', filterFreq: 2500, filterQ: 3,
});
// тональная подложка
tone(ctx, master, {
freq: 400, freqEnd: 1200, wave: 'sawtooth',
duration: 0.3, gain: 0.1,
filter: { type: 'lowpass', freq: 3000, q: 1 },
});
}
/** Gravity flip — короткое "wo-op" + glitch. */
export function playFlip(ctx, master) {
tone(ctx, master, {
freq: 800, freqEnd: 200, wave: 'square',
duration: 0.15, gain: 0.2,
});
// обратный slide через 80мс
setTimeout(() => {
tone(ctx, master, {
freq: 200, freqEnd: 600, wave: 'triangle',
duration: 0.15, gain: 0.18,
});
}, 80);
}
/** Монета — двойной звон вверх. */
export function playCoin(ctx, master) {
tone(ctx, master, {
freq: 988, wave: 'square',
duration: 0.08, gain: 0.18,
});
setTimeout(() => {
tone(ctx, master, {
freq: 1318, wave: 'square',
duration: 0.15, gain: 0.2,
});
}, 70);
}
/** Уровень пройден — короткие фанфары: тоника-квинта-октава. */
export function playLevelComplete(ctx, master) {
const root = 523.25; // C5
const notes = [
{ f: root, delay: 0, dur: 0.18 }, // C
{ f: root * 1.5, delay: 150, dur: 0.18 }, // G
{ f: root * 2, delay: 300, dur: 0.45 }, // C октава
];
for (const n of notes) {
setTimeout(() => {
tone(ctx, master, {
freq: n.f, wave: 'square',
duration: n.dur, gain: 0.22,
attack: 0.01, release: 0.1,
});
tone(ctx, master, {
freq: n.f * 2, wave: 'triangle',
duration: n.dur * 0.6, gain: 0.1,
});
}, n.delay);
}
}
/** Новый рекорд — восходящая короткая мелодия 5 нот. */
export function playNewRecord(ctx, master) {
const root = 523.25;
// C - E - G - C - E (мажорное трезвучие + октава)
const ratios = [1, 1.26, 1.5, 2, 2.52];
ratios.forEach((r, i) => {
setTimeout(() => {
tone(ctx, master, {
freq: root * r, wave: 'square',
duration: 0.14, gain: 0.22,
});
tone(ctx, master, {
freq: root * r * 2, wave: 'triangle',
duration: 0.1, gain: 0.1,
});
}, i * 80);
});
// Финальная высокая нота
setTimeout(() => {
tone(ctx, master, {
freq: root * 4, wave: 'sine',
duration: 0.4, gain: 0.18,
});
}, ratios.length * 80);
}
// ============================================================
// РЕЕСТР
// ============================================================
export const SFX_CATALOG = [
{
id: 'jump', title: 'Прыжок', emoji: '⬆',
description: 'Короткий восходящий «whoop» при тапе чтобы прыгнуть',
play: playJump, durationMs: 130,
},
{
id: 'death', title: 'Смерть', emoji: '💥',
description: 'Низкий «smash» с шумом при касании шипа или падении в холу',
play: playDeath, durationMs: 400,
},
{
id: 'orb_tap', title: 'Jump Orb', emoji: '🟡',
description: 'Звон при активации жёлтого orba в воздухе',
play: playOrbTap, durationMs: 180,
},
{
id: 'bounce', title: 'Трамплин', emoji: '🟠',
description: 'Пружина «boing» при отскоке от оранжевого трамплина',
play: playBounce, durationMs: 200,
},
{
id: 'whoosh', title: 'Speed pad', emoji: '🔵',
description: 'Шипящий swoosh при ускорении от голубого пада',
play: playWhoosh, durationMs: 350,
},
{
id: 'flip', title: 'Gravity Flip', emoji: '🟣',
description: 'Wo-op + обратный slide при перевороте гравитации',
play: playFlip, durationMs: 250,
},
{
id: 'coin', title: 'Монета', emoji: '🪙',
description: 'Двойной звон вверх при сборе монеты',
play: playCoin, durationMs: 230,
},
{
id: 'level_complete', title: 'Уровень пройден', emoji: '🏁',
description: 'Короткие фанфары: тоника-квинта-октава при достижении финиша',
play: playLevelComplete, durationMs: 750,
},
{
id: 'new_record', title: 'Новый рекорд', emoji: '⭐',
description: 'Восходящая мелодия 5 нот + высокая финальная нота',
play: playNewRecord, durationMs: 800,
},
];

View File

@ -0,0 +1,370 @@
/**
* shipSkinFactories фабрики 2D-текстур "ship-обложек" для куба в ship-режиме.
*
* Концепция (как в Geometry Dash): куб остаётся кубом по геометрии, но в ship-режиме
* на его грани накладывается текстура с силуэтом корабля сбоку. Когда камера смотрит
* на куб сбоку (sideview) выглядит как настоящий кораблик.
*
* Каждая фабрика = функция (ctx, primary, secondary, time?) => void, рисует на
* canvas 256×256. Возвращаемый canvas dataURL game.scene.setTexture(REF_BODY).
*
* primary/secondary hex-цвета (зависят от выбранного skin-color куба).
*/
const SIZE = 256;
// ---- утилиты ----
function clear(ctx, color) {
ctx.fillStyle = color;
ctx.fillRect(0, 0, SIZE, SIZE);
}
function shape(ctx, color, points, stroke = null, strokeWidth = 4) {
ctx.fillStyle = color;
ctx.beginPath();
ctx.moveTo(points[0][0], points[0][1]);
for (let i = 1; i < points.length; i++) ctx.lineTo(points[i][0], points[i][1]);
ctx.closePath();
ctx.fill();
if (stroke) {
ctx.strokeStyle = stroke;
ctx.lineWidth = strokeWidth;
ctx.stroke();
}
}
function filledCircle(ctx, color, cx, cy, r) {
ctx.fillStyle = color;
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.fill();
}
function filledRect(ctx, color, x, y, w, h) {
ctx.fillStyle = color;
ctx.fillRect(x, y, w, h);
}
function gradientBG(ctx, color1, color2) {
const g = ctx.createLinearGradient(0, 0, 0, SIZE);
g.addColorStop(0, color1);
g.addColorStop(1, color2);
ctx.fillStyle = g;
ctx.fillRect(0, 0, SIZE, SIZE);
}
// =========================================================================
// SHIP SKINS (15 моделей)
// Нос корабля — направлен ВПРАВО (куб бежит по +X).
// =========================================================================
/** v1 — Классический: серебристый корпус с синим окном. */
function shipClassic(ctx, primary, secondary) {
gradientBG(ctx, '#1a2a3a', '#0a1525');
// Корпус — длинный овал
shape(ctx, primary, [
[40, 140], [80, 100], [180, 90], [225, 128],
[180, 165], [80, 175],
], secondary, 4);
// Окошко
filledCircle(ctx, '#88ccff', 145, 115, 18);
filledCircle(ctx, '#ffffff', 150, 110, 6);
// Хвост-крыло
shape(ctx, secondary, [[40, 140], [25, 100], [60, 130]]);
shape(ctx, secondary, [[40, 165], [25, 200], [60, 175]]);
// Огонь сзади
shape(ctx, '#ff8833', [[10, 150], [40, 138], [40, 167], [10, 155]]);
shape(ctx, '#ffe44a', [[15, 150], [35, 143], [35, 162], [15, 155]]);
}
/** v2 — Истребитель: красный острый с дельтой. */
function shipFighter(ctx, primary, secondary) {
gradientBG(ctx, '#2a0a0a', '#100505');
shape(ctx, '#dd2222', [
[30, 145], [80, 90], [220, 128], [80, 165],
], '#660000', 4);
// Окошко тёмное
filledCircle(ctx, '#222', 145, 110, 14);
// Крылья
shape(ctx, '#aa1111', [[60, 110], [50, 50], [110, 105]]);
shape(ctx, '#aa1111', [[60, 145], [50, 205], [110, 150]]);
// Сопло
shape(ctx, '#ff5511', [[10, 130], [30, 122], [30, 152], [10, 145]]);
}
/** v3 — Шаттл бело-синий. */
function shipShuttle(ctx, primary, secondary) {
gradientBG(ctx, '#1a2540', '#0a1530');
shape(ctx, '#f0f0f0', [
[35, 130], [60, 90], [180, 90], [225, 128],
[180, 165], [60, 165],
], '#3366aa', 3);
// Окошки в ряд
filledRect(ctx, '#4488dd', 80, 105, 90, 15);
filledRect(ctx, '#bbddff', 85, 108, 80, 9);
// Крыло сверху и снизу
shape(ctx, '#2266aa', [[80, 90], [105, 50], [155, 50], [180, 90]]);
shape(ctx, '#2266aa', [[80, 165], [105, 205], [155, 205], [180, 165]]);
// Два сопла
shape(ctx, '#4488ff', [[10, 110], [35, 105], [35, 125], [10, 120]]);
shape(ctx, '#4488ff', [[10, 145], [35, 140], [35, 160], [10, 155]]);
}
/** v4 — НЛО (диск). */
function shipUfo(ctx, primary, secondary) {
gradientBG(ctx, '#0a1525', '#000005');
// Диск
shape(ctx, '#aaaaaa', [
[25, 140], [80, 115], [180, 115], [230, 140],
[180, 165], [80, 165],
], '#444', 4);
// Купол
filledCircle(ctx, '#88ccff', 128, 100, 35);
filledCircle(ctx, '#aaeeff', 122, 95, 22);
// Лампочки снизу
filledCircle(ctx, '#ffe44a', 70, 165, 6);
filledCircle(ctx, '#ffe44a', 128, 175, 6);
filledCircle(ctx, '#ffe44a', 188, 165, 6);
}
/** v5 — Биплан. */
function shipBiplane(ctx, primary, secondary) {
gradientBG(ctx, '#1a2030', '#0a1020');
// Корпус коричневый
shape(ctx, '#cc9933', [
[40, 135], [70, 110], [200, 115], [225, 128], [200, 142], [70, 145],
], '#664400', 3);
// Винт
filledRect(ctx, '#222', 220, 80, 6, 95);
// Два крыла
filledRect(ctx, '#ddaa44', 60, 65, 130, 12);
filledRect(ctx, '#aa7722', 60, 180, 130, 12);
// Стойки между крыльями
filledRect(ctx, '#cc9933', 75, 77, 4, 103);
filledRect(ctx, '#cc9933', 175, 77, 4, 103);
// Кокпит
filledCircle(ctx, '#333', 140, 115, 12);
}
/** v6 — Сфера. */
function shipSphere(ctx, primary, secondary) {
gradientBG(ctx, '#0a2010', '#001005');
filledCircle(ctx, '#44aa66', 128, 128, 80);
// Полоса
filledRect(ctx, '#ddee44', 48, 124, 160, 8);
// Антенна
filledRect(ctx, '#888', 125, 30, 4, 30);
filledCircle(ctx, '#ff4444', 127, 30, 8);
// Окно
filledCircle(ctx, '#88ccff', 165, 120, 14);
}
/** v7 — Стелс (чёрный). */
function shipStealth(ctx, primary, secondary) {
gradientBG(ctx, '#0a0a14', '#000000');
// Треугольник плоский
shape(ctx, '#1a1a22', [
[30, 130], [60, 80], [225, 128], [60, 175],
], '#444', 2);
// Красная подсветка
shape(ctx, '#ff2222', [[40, 155], [220, 130], [40, 162]]);
// Окошко
filledRect(ctx, '#330000', 145, 120, 25, 12);
}
/** v8 — Ракета (длинная). */
function shipRocket(ctx, primary, secondary) {
gradientBG(ctx, '#1a1a25', '#0a0a15');
// Тело белое
shape(ctx, '#eeeeee', [
[40, 120], [180, 120], [220, 128], [180, 145], [40, 145],
], '#888', 3);
// Нос красный
shape(ctx, '#dd2222', [[180, 120], [225, 128], [180, 145]]);
// 4 фины хвоста
shape(ctx, '#dd2222', [[40, 120], [20, 95], [70, 120]]);
shape(ctx, '#dd2222', [[40, 145], [20, 175], [70, 145]]);
// Окно
filledCircle(ctx, '#88ccff', 110, 132, 8);
// Огонь
shape(ctx, '#ffe44a', [[10, 125], [40, 128], [40, 140], [10, 140]]);
shape(ctx, '#ff6611', [[18, 128], [40, 130], [40, 138], [18, 138]]);
}
/** v9 — Дрон. */
function shipDrone(ctx, primary, secondary) {
gradientBG(ctx, '#0a0a15', '#000005');
// Тело по центру
filledRect(ctx, '#222230', 100, 110, 60, 40);
filledRect(ctx, '#ff0000', 145, 122, 10, 6); // LED
// 4 пропеллера по углам
for (let cx of [70, 190]) {
for (let cy of [85, 175]) {
// ножка от центра
const dx = cx > 128 ? -1 : 1;
const dy = cy > 128 ? -1 : 1;
ctx.strokeStyle = '#444';
ctx.lineWidth = 4;
ctx.beginPath();
ctx.moveTo(cx, cy);
ctx.lineTo(cx + dx * 25, cy + dy * 25);
ctx.stroke();
// диск пропеллера
ctx.globalAlpha = 0.5;
filledCircle(ctx, '#88aabb', cx, cy, 18);
ctx.globalAlpha = 1;
filledCircle(ctx, '#222', cx, cy, 5);
}
}
}
/** v10 — Воздушный шар (роуз+корзина). */
function shipBalloon(ctx, primary, secondary) {
gradientBG(ctx, '#2a1525', '#0a0510');
// Шар
filledCircle(ctx, '#dd3366', 128, 95, 60);
// Полосы
ctx.strokeStyle = '#aa1144';
ctx.lineWidth = 3;
ctx.beginPath(); ctx.arc(128, 95, 60, 0, Math.PI * 2); ctx.stroke();
ctx.beginPath(); ctx.moveTo(68, 95); ctx.lineTo(188, 95); ctx.stroke();
ctx.beginPath(); ctx.moveTo(128, 35); ctx.lineTo(128, 155); ctx.stroke();
// Корзина
filledRect(ctx, '#996644', 108, 180, 40, 25);
filledRect(ctx, '#553311', 108, 175, 40, 5);
// Верёвки
ctx.strokeStyle = '#553311';
ctx.lineWidth = 2;
for (let dx of [-15, -5, 5, 15]) {
ctx.beginPath(); ctx.moveTo(128 + dx, 150); ctx.lineTo(128 + dx, 180); ctx.stroke();
}
}
/** v11 — Золотой. */
function shipGolden(ctx, primary, secondary) {
gradientBG(ctx, '#2a2010', '#0a0805');
// Корпус золотой
shape(ctx, '#ffd700', [
[35, 135], [75, 95], [185, 95], [225, 128],
[185, 160], [75, 160],
], '#aa7700', 4);
// Окошко тёмное
filledCircle(ctx, '#221100', 145, 110, 16);
filledCircle(ctx, '#664400', 148, 107, 6);
// Крылья двойные
shape(ctx, '#cc9900', [[75, 95], [95, 50], [135, 50], [115, 95]]);
shape(ctx, '#cc9900', [[75, 160], [95, 205], [135, 205], [115, 160]]);
// Декоративная полоса
filledRect(ctx, '#ffeb55', 80, 125, 100, 6);
// Огонь
shape(ctx, '#ff8811', [[10, 120], [35, 125], [35, 145], [10, 140]]);
shape(ctx, '#ffe44a', [[18, 125], [35, 128], [35, 142], [18, 138]]);
}
/** v12 — Хром. */
function shipChrome(ctx, primary, secondary) {
// Зеркальный фон
const g = ctx.createLinearGradient(0, 0, SIZE, SIZE);
g.addColorStop(0, '#445566');
g.addColorStop(0.5, '#bbccdd');
g.addColorStop(1, '#445566');
ctx.fillStyle = g; ctx.fillRect(0, 0, SIZE, SIZE);
// Корпус
shape(ctx, '#eeeeff', [
[35, 135], [75, 95], [185, 95], [225, 128],
[185, 160], [75, 160],
], '#778899', 3);
// Блик
filledRect(ctx, '#ffffff', 90, 100, 80, 6);
// Кокпит
filledCircle(ctx, '#3399ff', 145, 115, 16);
filledCircle(ctx, '#aaddff', 142, 110, 7);
// Крыло
shape(ctx, '#ccccdd', [[75, 160], [50, 195], [120, 165]]);
// Сопло
shape(ctx, '#44aaff', [[10, 122], [35, 125], [35, 145], [10, 142]]);
}
/** v13 — Пришелец. */
function shipAlien(ctx, primary, secondary) {
gradientBG(ctx, '#0a2a10', '#001005');
// Тело — горизонтальный овал
shape(ctx, '#33aa44', [
[40, 128], [80, 90], [180, 90], [220, 128],
[180, 165], [80, 165],
], '#226633', 3);
// 3 глаза
filledCircle(ctx, '#ffee22', 165, 110, 10);
filledCircle(ctx, '#ffee22', 180, 128, 10);
filledCircle(ctx, '#ffee22', 165, 145, 10);
filledCircle(ctx, '#000', 167, 110, 4);
filledCircle(ctx, '#000', 182, 128, 4);
filledCircle(ctx, '#000', 167, 145, 4);
// Щупальце-хвост
for (let i = 0; i < 4; i++) {
filledCircle(ctx, '#338844', 50 - i * 12, 128 + i * 6, 12 - i * 2);
}
}
/** v14 — Парусник. */
function shipSailship(ctx, primary, secondary) {
gradientBG(ctx, '#1a2540', '#0a1530');
// Корпус
shape(ctx, '#996644', [
[40, 150], [70, 130], [200, 130], [225, 150],
[200, 175], [70, 175],
], '#553311', 3);
// Мачта
filledRect(ctx, '#553311', 130, 50, 4, 80);
// Парус
shape(ctx, '#f0f0e0', [[134, 60], [200, 90], [134, 120]], '#888', 2);
// Флаг
shape(ctx, '#dd2222', [[130, 50], [115, 45], [130, 60]]);
}
/** v15 — Капсула. */
function shipCapsule(ctx, primary, secondary) {
gradientBG(ctx, '#0a1525', '#000005');
// Овальное тело белое
shape(ctx, '#f0f0f0', [
[50, 130], [70, 95], [190, 95], [220, 128],
[190, 160], [70, 160],
], '#999', 3);
// Синяя полоса
filledRect(ctx, '#3399ff', 60, 124, 160, 8);
// Окошко
filledCircle(ctx, '#1144aa', 160, 128, 18);
filledCircle(ctx, '#88ccff', 155, 122, 8);
// Сопло
shape(ctx, '#ff8833', [[15, 122], [50, 125], [50, 142], [15, 140]]);
}
// =========================================================================
// КАТАЛОГ
// =========================================================================
export const SHIP_SKIN_CATALOG = [
{ id: 'ss_v1', title: 'Классический', draw: shipClassic },
{ id: 'ss_v2', title: 'Истребитель', draw: shipFighter },
{ id: 'ss_v3', title: 'Шаттл', draw: shipShuttle },
{ id: 'ss_v4', title: 'НЛО', draw: shipUfo },
{ id: 'ss_v5', title: 'Биплан', draw: shipBiplane },
{ id: 'ss_v6', title: 'Сфера', draw: shipSphere },
{ id: 'ss_v7', title: 'Стелс', draw: shipStealth },
{ id: 'ss_v8', title: 'Ракета', draw: shipRocket },
{ id: 'ss_v9', title: 'Дрон', draw: shipDrone },
{ id: 'ss_v10', title: 'Воздушный шар', draw: shipBalloon },
{ id: 'ss_v11', title: 'Золотой', draw: shipGolden },
{ id: 'ss_v12', title: 'Хром', draw: shipChrome },
{ id: 'ss_v13', title: 'Пришелец', draw: shipAlien },
{ id: 'ss_v14', title: 'Парусник', draw: shipSailship },
{ id: 'ss_v15', title: 'Капсула', draw: shipCapsule },
];
/** Перерисовать canvas со скином корабля. */
export function renderShipSkin(canvas, skinId, primary = '#66ff66', secondary = '#44aa44', time = 0) {
const skin = SHIP_SKIN_CATALOG.find(s => s.id === skinId);
if (!skin) return;
const ctx = canvas.getContext('2d');
ctx.imageSmoothingEnabled = false;
skin.draw(ctx, primary, secondary, time);
}

View File

@ -0,0 +1,405 @@
/**
* cubeSkinFactories фабрики текстур для GD-скинов куба.
*
* Каждый скин = функция (canvas, primary, secondary) которая рисует на canvas
* 256×256 пикселей. Дальше canvas заворачивается в Babylon DynamicTexture
* и накладывается на куб как diffuseTexture (одна и та же текстура на все
* 6 граней на куб смотрят с одного-двух ракурсов как в GD).
*
* primary/secondary hex-цвета вида "#66ff66".
* isAnimated если true, фабрика умеет работать с time-параметром (для пульсации/glow).
*
* Базовые 10 скинов согласно RUBLOX_GD_SKINS.md §3.
*/
const SIZE = 256;
const PAD = 12; // отступ от края (рамка)
// --- утилиты ---
function clear(ctx, color) {
ctx.fillStyle = color;
ctx.fillRect(0, 0, SIZE, SIZE);
}
function border(ctx, color, thickness = 8) {
ctx.strokeStyle = color;
ctx.lineWidth = thickness;
const o = thickness / 2;
ctx.strokeRect(o, o, SIZE - thickness, SIZE - thickness);
}
function filledRect(ctx, color, x, y, w, h) {
ctx.fillStyle = color;
ctx.fillRect(x, y, w, h);
}
function filledCircle(ctx, color, cx, cy, r) {
ctx.fillStyle = color;
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.fill();
}
function strokeCircle(ctx, color, cx, cy, r, thickness = 6) {
ctx.strokeStyle = color;
ctx.lineWidth = thickness;
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.stroke();
}
// =====================================================================
// СКИН 1 — DEFAULT: простой куб с базовой рамкой и квадратом внутри.
// Имитирует "icon_01" из оригинального GD (плоское лицо)
// =====================================================================
function drawDefault(ctx, primary, secondary) {
clear(ctx, primary);
border(ctx, secondary, 12);
// Глаза-квадраты
filledRect(ctx, secondary, 70, 90, 36, 36);
filledRect(ctx, secondary, 150, 90, 36, 36);
// Рот — полоса
filledRect(ctx, secondary, 80, 165, 96, 14);
}
// =====================================================================
// СКИН 2 — SMILE: широкая улыбка
// =====================================================================
function drawSmile(ctx, primary, secondary) {
clear(ctx, primary);
border(ctx, secondary, 12);
// Глаза-круги
filledCircle(ctx, secondary, 88, 100, 18);
filledCircle(ctx, secondary, 168, 100, 18);
// Улыбка — дуга
ctx.strokeStyle = secondary;
ctx.lineWidth = 10;
ctx.lineCap = 'round';
ctx.beginPath();
ctx.arc(SIZE/2, 150, 50, 0.15 * Math.PI, 0.85 * Math.PI);
ctx.stroke();
}
// =====================================================================
// СКИН 3 — SAD: грустный (рот дугой вниз, брови опущены)
// =====================================================================
function drawSad(ctx, primary, secondary) {
clear(ctx, primary);
border(ctx, secondary, 12);
// Глаза прищуренные (овалы)
ctx.fillStyle = secondary;
ctx.beginPath();
ctx.ellipse(88, 110, 18, 10, 0, 0, Math.PI * 2);
ctx.ellipse(168, 110, 18, 10, 0, 0, Math.PI * 2);
ctx.fill();
// Брови — диагонали внутрь-вниз
ctx.strokeStyle = secondary;
ctx.lineWidth = 6;
ctx.lineCap = 'round';
ctx.beginPath();
ctx.moveTo(68, 80); ctx.lineTo(108, 95);
ctx.moveTo(188, 80); ctx.lineTo(148, 95);
ctx.stroke();
// Рот — обратная дуга
ctx.beginPath();
ctx.arc(SIZE/2, 210, 50, 1.15 * Math.PI, 1.85 * Math.PI);
ctx.stroke();
}
// =====================================================================
// СКИН 4 — COOL: солнцезащитные очки
// =====================================================================
function drawCool(ctx, primary, secondary) {
clear(ctx, primary);
border(ctx, secondary, 12);
// Очки — два прямоугольника соединённые перемычкой
filledRect(ctx, '#000000', 50, 90, 60, 42);
filledRect(ctx, '#000000', 146, 90, 60, 42);
filledRect(ctx, '#000000', 110, 102, 36, 12);
// Блик в очках (secondary)
filledRect(ctx, secondary, 60, 98, 16, 8);
filledRect(ctx, secondary, 156, 98, 16, 8);
// Полуулыбка
ctx.strokeStyle = secondary;
ctx.lineWidth = 8;
ctx.lineCap = 'round';
ctx.beginPath();
ctx.arc(SIZE/2, 170, 36, 0.1 * Math.PI, 0.9 * Math.PI);
ctx.stroke();
}
// =====================================================================
// СКИН 5 — SKULL: череп
// =====================================================================
function drawSkull(ctx, primary, secondary) {
clear(ctx, primary);
border(ctx, secondary, 12);
// Глазницы (большие чёрные круги)
filledCircle(ctx, '#000', 90, 120, 32);
filledCircle(ctx, '#000', 166, 120, 32);
// Точки-зрачки (secondary)
filledCircle(ctx, secondary, 90, 120, 8);
filledCircle(ctx, secondary, 166, 120, 8);
// Нос — треугольник
ctx.fillStyle = '#000';
ctx.beginPath();
ctx.moveTo(SIZE/2, 165);
ctx.lineTo(SIZE/2 - 10, 185);
ctx.lineTo(SIZE/2 + 10, 185);
ctx.closePath();
ctx.fill();
// Зубы — ряд квадратов
for (let i = 0; i < 5; i++) {
filledRect(ctx, '#000', 80 + i * 20, 200, 14, 18);
}
}
// =====================================================================
// СКИН 6 — STAR: пятиконечная звезда
// =====================================================================
function drawStar(ctx, primary, secondary) {
clear(ctx, primary);
border(ctx, secondary, 12);
// Звезда
const cx = SIZE / 2;
const cy = SIZE / 2;
const rOuter = 70;
const rInner = 30;
ctx.fillStyle = secondary;
ctx.beginPath();
for (let i = 0; i < 10; i++) {
const r = i % 2 === 0 ? rOuter : rInner;
const a = (i / 10) * Math.PI * 2 - Math.PI / 2;
const x = cx + r * Math.cos(a);
const y = cy + r * Math.sin(a);
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
}
ctx.closePath();
ctx.fill();
}
// =====================================================================
// СКИН 7 — FLAME: языки пламени (анимируется через time)
// =====================================================================
function drawFlame(ctx, primary, secondary, time = 0) {
clear(ctx, primary);
// Языки пламени снизу-вверх
ctx.fillStyle = secondary;
ctx.beginPath();
ctx.moveTo(0, SIZE);
for (let x = 0; x <= SIZE; x += 16) {
// волна Y зависит от time для пульсации
const wave = Math.sin((x / SIZE) * Math.PI * 4 + time * 3) * 22;
const baseY = 110 + Math.cos((x / SIZE) * Math.PI * 2 + time * 2) * 18;
ctx.lineTo(x, baseY + wave);
}
ctx.lineTo(SIZE, SIZE);
ctx.closePath();
ctx.fill();
// Внутренние огоньки — белые точки
const dots = 7;
for (let i = 0; i < dots; i++) {
const fx = 30 + (SIZE - 60) * (i / (dots - 1));
const fy = 180 + Math.sin(time * 4 + i) * 15;
filledCircle(ctx, '#ffffff', fx, fy, 6);
}
border(ctx, secondary, 8);
}
// =====================================================================
// СКИН 8 — ICE: ледяные кристаллы
// =====================================================================
function drawIce(ctx, primary, secondary) {
clear(ctx, primary);
border(ctx, secondary, 12);
// Снежинка в центре — 6 лучей
const cx = SIZE / 2;
const cy = SIZE / 2;
ctx.strokeStyle = secondary;
ctx.lineWidth = 8;
ctx.lineCap = 'round';
for (let i = 0; i < 6; i++) {
const a = (i / 6) * Math.PI * 2;
const x = cx + 70 * Math.cos(a);
const y = cy + 70 * Math.sin(a);
ctx.beginPath();
ctx.moveTo(cx, cy);
ctx.lineTo(x, y);
ctx.stroke();
// маленькие веточки на каждом луче
const bx1 = cx + 45 * Math.cos(a);
const by1 = cy + 45 * Math.sin(a);
ctx.beginPath();
ctx.moveTo(bx1, by1);
ctx.lineTo(bx1 + 12 * Math.cos(a + 0.5), by1 + 12 * Math.sin(a + 0.5));
ctx.moveTo(bx1, by1);
ctx.lineTo(bx1 + 12 * Math.cos(a - 0.5), by1 + 12 * Math.sin(a - 0.5));
ctx.stroke();
}
// центральный кристалл
filledCircle(ctx, secondary, cx, cy, 12);
}
// =====================================================================
// СКИН 9 — PIZZA: ломтик пиццы (треугольник с начинкой)
// =====================================================================
function drawPizza(ctx, primary, secondary) {
clear(ctx, primary);
border(ctx, secondary, 12);
// Корка (треугольник) — золотисто-коричневый
ctx.fillStyle = '#d4a572';
ctx.beginPath();
ctx.moveTo(SIZE/2, 50);
ctx.lineTo(40, 210);
ctx.lineTo(216, 210);
ctx.closePath();
ctx.fill();
// Сыр сверху
ctx.fillStyle = '#ffe066';
ctx.beginPath();
ctx.moveTo(SIZE/2, 70);
ctx.lineTo(58, 200);
ctx.lineTo(198, 200);
ctx.closePath();
ctx.fill();
// Пепперони
filledCircle(ctx, '#cc3333', SIZE/2, 120, 15);
filledCircle(ctx, '#cc3333', SIZE/2 - 30, 160, 13);
filledCircle(ctx, '#cc3333', SIZE/2 + 30, 160, 13);
filledCircle(ctx, '#cc3333', SIZE/2 - 50, 195, 11);
filledCircle(ctx, '#cc3333', SIZE/2 + 50, 195, 11);
filledCircle(ctx, '#cc3333', SIZE/2, 195, 12);
}
// =====================================================================
// СКИН 10 — CAT: морда кота с ушами и усами
// =====================================================================
function drawCat(ctx, primary, secondary, time = 0) {
clear(ctx, primary);
border(ctx, secondary, 12);
// Уши — два треугольника сверху
ctx.fillStyle = secondary;
ctx.beginPath();
ctx.moveTo(40, 50); ctx.lineTo(78, 30); ctx.lineTo(95, 75); ctx.closePath();
ctx.moveTo(216, 50); ctx.lineTo(178, 30); ctx.lineTo(161, 75); ctx.closePath();
ctx.fill();
// Внутренняя розовая часть ушей
ctx.fillStyle = '#ff99cc';
ctx.beginPath();
ctx.moveTo(60, 52); ctx.lineTo(78, 42); ctx.lineTo(82, 65); ctx.closePath();
ctx.moveTo(196, 52); ctx.lineTo(178, 42); ctx.lineTo(174, 65); ctx.closePath();
ctx.fill();
// Глаза (моргание — time-based)
const blink = Math.sin(time * 0.8) > 0.96 ? 0.15 : 1.0;
ctx.fillStyle = secondary;
ctx.beginPath();
ctx.ellipse(88, 130, 18, 22 * blink, 0, 0, Math.PI * 2);
ctx.ellipse(168, 130, 18, 22 * blink, 0, 0, Math.PI * 2);
ctx.fill();
// Зрачки-щёлки (если глаза открыты)
if (blink > 0.5) {
ctx.fillStyle = '#000';
ctx.beginPath();
ctx.ellipse(88, 130, 5, 18, 0, 0, Math.PI * 2);
ctx.ellipse(168, 130, 5, 18, 0, 0, Math.PI * 2);
ctx.fill();
}
// Нос — треугольник
ctx.fillStyle = '#ff99cc';
ctx.beginPath();
ctx.moveTo(SIZE/2 - 10, 168);
ctx.lineTo(SIZE/2 + 10, 168);
ctx.lineTo(SIZE/2, 182);
ctx.closePath();
ctx.fill();
// Рот — w-образный
ctx.strokeStyle = secondary;
ctx.lineWidth = 4;
ctx.lineCap = 'round';
ctx.beginPath();
ctx.moveTo(SIZE/2, 182);
ctx.lineTo(SIZE/2, 195);
ctx.moveTo(SIZE/2, 195);
ctx.quadraticCurveTo(SIZE/2 - 15, 210, SIZE/2 - 22, 200);
ctx.moveTo(SIZE/2, 195);
ctx.quadraticCurveTo(SIZE/2 + 15, 210, SIZE/2 + 22, 200);
ctx.stroke();
// Усы
ctx.lineWidth = 2.5;
ctx.beginPath();
ctx.moveTo(50, 175); ctx.lineTo(105, 180);
ctx.moveTo(50, 188); ctx.lineTo(105, 188);
ctx.moveTo(206, 175); ctx.lineTo(151, 180);
ctx.moveTo(206, 188); ctx.lineTo(151, 188);
ctx.stroke();
}
// =====================================================================
// РЕЕСТР СКИНОВ
// =====================================================================
export const CUBE_SKINS = [
{ id: 'cube_default', title: 'Базовый', category: 'Базовый', priceStars: 0, draw: drawDefault, animated: false },
{ id: 'cube_smile', title: 'Улыбка', category: 'Эмоция', priceStars: 5, draw: drawSmile, animated: false },
{ id: 'cube_sad', title: 'Грустный', category: 'Эмоция', priceStars: 5, draw: drawSad, animated: false },
{ id: 'cube_cool', title: 'В очках', category: 'Эмоция', priceStars: 10, draw: drawCool, animated: false },
{ id: 'cube_skull', title: 'Череп', category: 'Базовый', priceStars: 10, draw: drawSkull, animated: false },
{ id: 'cube_star', title: 'Звезда', category: 'Базовый', priceStars: 10, draw: drawStar, animated: false },
{ id: 'cube_flame', title: 'Пламя', category: 'Тематический', priceStars: 20, draw: drawFlame, animated: true },
{ id: 'cube_ice', title: 'Лёд', category: 'Тематический', priceStars: 20, draw: drawIce, animated: false },
{ id: 'cube_pizza', title: 'Пицца', category: 'Тематический', priceStars: 15, draw: drawPizza, animated: false },
{ id: 'cube_cat', title: 'Кошка', category: 'Тематический', priceStars: 15, draw: drawCat, animated: true },
];
// =====================================================================
// TRAIL-эффекты (параметры для Babylon ParticleSystem, без canvas)
// =====================================================================
export const CUBE_TRAILS = [
{
id: 'trail_white', title: 'Белый', priceStars: 0,
params: { colorStart: '#ffffff', colorEnd: '#aaaaaa', emissionRate: 30, lifetime: 0.6, sizeStart: 0.18 },
},
{
id: 'trail_fire', title: 'Огонь', priceStars: 25,
params: { colorStart: '#ff6600', colorEnd: '#ffe44a', emissionRate: 50, lifetime: 0.9, sizeStart: 0.22 },
},
{
id: 'trail_rainbow', title: 'Радуга', priceStars: 50,
params: { colorStart: 'rainbow', colorEnd: '#ffffff', emissionRate: 60, lifetime: 1.0, sizeStart: 0.20 },
},
];
// Базовая палитра цветов (18 как в GD, см. SKINS.md §4)
export const SKIN_COLORS = [
{ id: 'green', hex: '#66ff66', priceStars: 0, title: 'Зелёный' },
{ id: 'red', hex: '#ff4444', priceStars: 0, title: 'Красный' },
{ id: 'blue', hex: '#4488ff', priceStars: 0, title: 'Синий' },
{ id: 'yellow', hex: '#ffe44a', priceStars: 10, title: 'Жёлтый' },
{ id: 'purple', hex: '#b266ff', priceStars: 10, title: 'Фиолетовый' },
{ id: 'orange', hex: '#ff9533', priceStars: 10, title: 'Оранжевый' },
{ id: 'pink', hex: '#ff66cc', priceStars: 20, title: 'Розовый' },
{ id: 'cyan', hex: '#44ddcc', priceStars: 20, title: 'Бирюзовый' },
{ id: 'lime', hex: '#aaff44', priceStars: 20, title: 'Лайм' },
{ id: 'black', hex: '#222222', priceStars: 30, title: 'Чёрный' },
{ id: 'white', hex: '#f0f0f0', priceStars: 30, title: 'Белый' },
{ id: 'silver', hex: '#cccccc', priceStars: 50, title: 'Серебро' },
{ id: 'gold', hex: '#ffd700', priceStars: 100, title: 'Золото' },
];
export const DEFAULT_PRIMARY = '#66ff66';
export const DEFAULT_SECONDARY = '#44aa44';
/**
* Перерисовать canvas со скином.
* Принимает HTMLCanvasElement, id скина, цвета и опциональное время.
*/
export function renderSkin(canvas, skinId, primary = DEFAULT_PRIMARY, secondary = DEFAULT_SECONDARY, time = 0) {
const skin = CUBE_SKINS.find(s => s.id === skinId);
if (!skin) return;
const ctx = canvas.getContext('2d');
ctx.imageSmoothingEnabled = false;
skin.draw(ctx, primary, secondary, time);
}

View File

@ -0,0 +1,372 @@
/**
* spikeFactories реестр 100 шипов GD по эпохам (10 эпох × 10 вариантов).
*
* Каждая фабрика возвращает:
* (scene, id) => { root: TransformNode, dispose() }
*
* Стилистика 10 эпох (палитры + форма):
* 1 Лес дерево/мох/камень/природа
* 2 Горы кристаллы/металл/лёд
* 3 Город неон/хром/опасность
* 4 Ночной фиолет/циан/розовый неон
* 5 Пустыня кактус/кость/песок
* 6 Океан коралл/ракушка/водоросль
* 7 Пещеры сталактит/кварц/гриб
* 8 Вулкан лава/обсидиан/магма
* 9 Космос лазер/звезда/плазма
* 10 Кибер голограмма/чип/неон
*
* Для каждой эпохи 10 вариантов: пары (стиль×форма) + цветовые ремиксы.
*/
import {
Mesh, MeshBuilder, VertexData, StandardMaterial, Color3, Vector3, TransformNode,
} from '@babylonjs/core';
// =========================================================================
// БАЗОВЫЕ БИЛДЕРЫ
// =========================================================================
/** N-гранная пирамида с фасками (3 кольца: bottom, mid, top). */
function buildSpikeCone(scene, name, opts = {}) {
const {
segments = 8, height = 1.4, baseR = 0.5, tipR = 0.04,
colorBase = [0.45, 0.05, 0.10],
colorMid = [1.0, 0.25, 0.30],
colorTip = [1.0, 0.55, 0.55],
twist = 0,
} = opts;
const positions = [], indices = [], normals = [], colors = [];
for (let i = 0; i < segments; i++) {
const a = (i / segments) * Math.PI * 2;
positions.push(Math.cos(a) * baseR, 0, Math.sin(a) * baseR);
normals.push(Math.cos(a), 0.2, Math.sin(a));
colors.push(...colorBase, 1);
}
for (let i = 0; i < segments; i++) {
const a = (i / segments) * Math.PI * 2 + twist;
positions.push(Math.cos(a) * tipR, height, Math.sin(a) * tipR);
normals.push(Math.cos(a), 1, Math.sin(a));
colors.push(...colorTip, 1);
}
const MID_T = 0.55;
for (let i = 0; i < segments; i++) {
const a = (i / segments) * Math.PI * 2 + Math.PI / segments + twist * MID_T;
const r = baseR * (1 - MID_T) + tipR * MID_T;
positions.push(Math.cos(a) * r, height * MID_T, Math.sin(a) * r);
normals.push(Math.cos(a), 0.6, Math.sin(a));
colors.push(...colorMid, 1);
}
const rB = 0, rT = segments, rM = segments * 2;
for (let i = 0; i < segments; i++) {
const i2 = (i + 1) % segments;
indices.push(rB + i, rB + i2, rM + i);
indices.push(rM + i, rB + i2, rT + i2);
indices.push(rM + i, rT + i2, rT + i);
}
const cB = positions.length / 3;
positions.push(0, 0, 0);
normals.push(0, -1, 0);
colors.push(...colorBase, 1);
for (let i = 0; i < segments; i++) {
const i2 = (i + 1) % segments;
indices.push(cB, rB + i2, rB + i);
}
const mesh = new Mesh(name, scene);
const vd = new VertexData();
vd.positions = positions; vd.indices = indices; vd.normals = normals; vd.colors = colors;
vd.applyToMesh(mesh, false);
mesh.useVertexColors = true;
return mesh;
}
function makeMat(scene, name, opts = {}) {
const m = new StandardMaterial(name, scene);
m.diffuseColor = new Color3(...(opts.diffuse || [1, 1, 1]));
m.emissiveColor = new Color3(...(opts.emissive || [0, 0, 0]));
m.specularColor = new Color3(...(opts.specular || [0.2, 0.2, 0.2]));
if (opts.specPower != null) m.specularPower = opts.specPower;
if (opts.alpha != null) m.alpha = opts.alpha;
if (opts.disableLighting) m.disableLighting = true;
return m;
}
function buildBase(scene, name, opts = {}) {
const {
diaTop = 1.2, diaBottom = 1.3, height = 0.15, tessellation = 12,
diffuse = [0.30, 0.28, 0.30], emissive = [0.05, 0.05, 0.05],
} = opts;
const base = MeshBuilder.CreateCylinder(name, {
diameterTop: diaTop, diameterBottom: diaBottom, height, tessellation,
}, scene);
base.position.y = height / 2;
base.material = makeMat(scene, `${name}_mat`, { diffuse, emissive });
return base;
}
// =========================================================================
// СТРОИТЕЛИ ВАРИАНТОВ (универсальные)
// =========================================================================
/**
* Одиночный шип-конус + основание.
* params: { baseDiffuse, baseEmissive, conColors: {base,mid,tip}, segments,
* height, baseR, tipR, twist, matEmissive, matSpec, specPower, alpha }
*/
function buildSingleSpike(scene, id, p) {
const root = new TransformNode(`spike_${id}`, scene);
const base = buildBase(scene, `${id}_base`, {
diaTop: p.baseDiaTop || 1.1, diaBottom: p.baseDiaBottom || 1.25,
height: p.baseHeight || 0.15,
diffuse: p.baseDiffuse, emissive: p.baseEmissive,
});
base.parent = root;
const spike = buildSpikeCone(scene, `${id}_spike`, {
segments: p.segments || 6, height: p.height || 1.4,
baseR: p.baseR || 0.42, tipR: p.tipR || 0.03,
colorBase: p.conColors.base, colorMid: p.conColors.mid, colorTip: p.conColors.tip,
twist: p.twist || 0,
});
spike.parent = root;
spike.material = makeMat(scene, `${id}_mat`, {
emissive: p.matEmissive, specular: p.matSpec || [0.3, 0.3, 0.3],
specPower: p.specPower || 32, alpha: p.alpha,
});
return { root, dispose: () => { base.dispose(); spike.dispose(); root.dispose(); } };
}
/**
* Кластер шипов 3 маленьких конуса вокруг центра.
* params: { count, height, baseR, tipR, conColors, matEmissive, matSpec, twist,
* baseDiffuse, baseEmissive }
*/
function buildCluster(scene, id, p) {
const root = new TransformNode(`spike_${id}`, scene);
const base = buildBase(scene, `${id}_base`, {
diaTop: p.baseDiaTop || 1.0, diaBottom: p.baseDiaBottom || 1.15,
height: p.baseHeight || 0.12,
diffuse: p.baseDiffuse, emissive: p.baseEmissive,
});
base.parent = root;
const N = p.count || 3;
for (let i = 0; i < N; i++) {
const a = (i / N) * Math.PI * 2;
const off = p.off || 0.18;
const sp = buildSpikeCone(scene, `${id}_c_${i}`, {
segments: p.segments || 5,
height: (p.height || 1.0) + (i % 2) * (p.heightVar || 0.25),
baseR: p.baseR || 0.18, tipR: p.tipR || 0.02,
colorBase: p.conColors.base, colorMid: p.conColors.mid, colorTip: p.conColors.tip,
twist: p.twist || 0,
});
sp.position = new Vector3(Math.cos(a) * off, 0, Math.sin(a) * off);
sp.rotation.z = Math.sin(a) * (p.lean || 0.15);
sp.rotation.x = -Math.cos(a) * (p.lean || 0.15);
sp.parent = root;
sp.material = makeMat(scene, `${id}_m_${i}`, {
emissive: p.matEmissive, specular: p.matSpec || [0.2, 0.2, 0.2],
specPower: p.specPower || 32, alpha: p.alpha,
});
}
return { root, dispose: () => { for (const ch of root.getChildMeshes()) ch.dispose(); root.dispose(); } };
}
// =========================================================================
// ПАЛИТРЫ ПО ЭПОХАМ — 10 на эпоху
// =========================================================================
/* Каждая палитра задаёт: base+mid+tip конуса, emissive материала, цвет диска. */
const PALETTES = {
// Эпоха 1 — Лес: дерево / мох / камень / шипастые растения
1: [
{ name: 'Дерево', con: [[0.35,0.22,0.12],[0.55,0.38,0.20],[0.75,0.55,0.30]], em:[0.12,0.07,0.04], bd:[0.45,0.30,0.18], be:[0.10,0.07,0.05], seg:6, h:1.3 },
{ name: 'Кора', con: [[0.30,0.18,0.10],[0.45,0.30,0.15],[0.65,0.45,0.22]], em:[0.10,0.06,0.03], bd:[0.40,0.25,0.15], be:[0.08,0.05,0.03], seg:8, h:1.4 },
{ name: 'Камень серый', con: [[0.45,0.45,0.42],[0.65,0.62,0.58],[0.85,0.83,0.78]], em:[0.10,0.10,0.09], bd:[0.40,0.40,0.38], be:[0.07,0.07,0.07], seg:6, h:1.4 },
{ name: 'Мшистый камень', con: [[0.30,0.45,0.25],[0.50,0.65,0.40],[0.85,0.90,0.60]], em:[0.10,0.20,0.08], bd:[0.30,0.40,0.25], be:[0.05,0.10,0.05], seg:6, h:1.35 },
{ name: 'Терновник', cluster:true, count:3, con:[[0.18,0.32,0.15],[0.30,0.55,0.25],[0.85,0.95,0.50]], em:[0.07,0.18,0.07], bd:[0.25,0.40,0.20], be:[0.05,0.15,0.05], seg:5, h:1.0, twist:0.3 },
{ name: 'Ветка с шипами', cluster:true, count:4, con:[[0.30,0.18,0.10],[0.50,0.30,0.15],[0.75,0.55,0.25]], em:[0.10,0.06,0.03], bd:[0.40,0.25,0.15], be:[0.08,0.05,0.03], seg:5, h:0.9, twist:0.2 },
{ name: 'Бамбук', con:[[0.20,0.45,0.20],[0.40,0.75,0.35],[0.70,0.95,0.50]], em:[0.12,0.30,0.12], bd:[0.30,0.50,0.25], be:[0.07,0.15,0.05], seg:8, h:1.6, baseR:0.30 },
{ name: 'Корень', con:[[0.25,0.15,0.08],[0.40,0.25,0.12],[0.60,0.40,0.20]], em:[0.08,0.05,0.03], bd:[0.30,0.20,0.10], be:[0.06,0.04,0.02], seg:6, h:1.3, twist:0.5 },
{ name: 'Сосна', con:[[0.10,0.30,0.15],[0.20,0.50,0.25],[0.45,0.75,0.40]], em:[0.05,0.15,0.07], bd:[0.20,0.30,0.18], be:[0.04,0.07,0.04], seg:6, h:1.5 },
{ name: 'Старый камень', con:[[0.40,0.38,0.32],[0.55,0.52,0.45],[0.75,0.72,0.65]], em:[0.08,0.08,0.07], bd:[0.35,0.33,0.28], be:[0.06,0.06,0.05], seg:5, h:1.2 },
],
// Эпоха 2 — Горы: кристалл / лёд / сталь
2: [
{ name: 'Кристалл синий', con:[[0.30,0.45,0.70],[0.55,0.75,0.95],[0.85,0.95,1.00]], em:[0.20,0.30,0.45], bd:[0.35,0.40,0.50], be:[0.05,0.08,0.12], seg:6, h:1.5, twist:0.6, alpha:0.92 },
{ name: 'Кристалл фиолет', con:[[0.40,0.20,0.55],[0.65,0.45,0.85],[0.90,0.80,1.00]], em:[0.30,0.15,0.45], bd:[0.30,0.20,0.40], be:[0.05,0.03,0.10], seg:6, h:1.5, twist:0.6, alpha:0.90 },
{ name: 'Сталь', con:[[0.25,0.25,0.28],[0.50,0.50,0.55],[0.85,0.85,0.90]], em:[0.05,0.05,0.07], bd:[0.20,0.20,0.22], be:[0.04,0.04,0.04], seg:8, h:1.3, specPower:128 },
{ name: 'Лёд', con:[[0.75,0.85,0.95],[0.85,0.93,1.00],[1.00,1.00,1.00]], em:[0.30,0.40,0.50], bd:[0.70,0.85,0.95], be:[0.15,0.20,0.25], seg:8, h:1.6, alpha:0.85, specPower:128 },
{ name: 'Алмаз', con:[[0.50,0.85,0.95],[0.75,0.95,1.00],[1.00,1.00,1.00]], em:[0.40,0.55,0.65], bd:[0.30,0.50,0.60], be:[0.10,0.15,0.20], seg:6, h:1.5, twist:0.4, alpha:0.88, specPower:128 },
{ name: 'Камень-руда', con:[[0.40,0.30,0.20],[0.65,0.55,0.40],[0.85,0.75,0.55]], em:[0.10,0.08,0.05], bd:[0.30,0.25,0.18], be:[0.06,0.05,0.03], seg:6, h:1.3 },
{ name: 'Сосулька', con:[[0.85,0.92,1.00],[0.92,0.96,1.00],[1.00,1.00,1.00]], em:[0.40,0.50,0.60], bd:[0.65,0.80,0.92], be:[0.12,0.18,0.22], seg:8, h:1.7, baseR:0.30, alpha:0.80 },
{ name: 'Гранит', con:[[0.35,0.35,0.38],[0.55,0.55,0.58],[0.75,0.75,0.78]], em:[0.07,0.07,0.07], bd:[0.30,0.30,0.32], be:[0.05,0.05,0.05], seg:6, h:1.3 },
{ name: 'Изумруд', con:[[0.10,0.55,0.35],[0.35,0.85,0.55],[0.75,1.00,0.80]], em:[0.15,0.40,0.25], bd:[0.10,0.30,0.20], be:[0.03,0.10,0.05], seg:6, h:1.5, twist:0.5, alpha:0.92 },
{ name: 'Бронза', con:[[0.50,0.30,0.10],[0.75,0.50,0.20],[0.95,0.75,0.40]], em:[0.20,0.10,0.04], bd:[0.45,0.25,0.10], be:[0.10,0.05,0.02], seg:8, h:1.3, specPower:96 },
],
// Эпоха 3 — Город днём
3: [
{ name: 'Неон-синий', con:[[0.20,0.40,1.00],[0.40,0.70,1.00],[1.00,1.00,1.00]], em:[0.20,0.45,0.90], bd:[0.20,0.20,0.22], be:[0.05,0.05,0.05], seg:4, h:1.4 },
{ name: 'Знак-опасность', con:[[0.95,0.75,0.10],[0.10,0.10,0.10],[0.95,0.75,0.10]], em:[0.30,0.25,0.05], bd:[0.15,0.15,0.15], be:[0.03,0.03,0.03], seg:6, h:1.3 },
{ name: 'Хром', con:[[0.60,0.60,0.65],[0.85,0.85,0.90],[1.00,1.00,1.00]], em:[0.20,0.20,0.25], bd:[0.55,0.55,0.60], be:[0.10,0.10,0.12], seg:12, h:1.3, specPower:128 },
{ name: 'Светофор-красный', con:[[0.80,0.10,0.10],[1.00,0.30,0.20],[1.00,0.75,0.55]], em:[0.50,0.15,0.10], bd:[0.20,0.20,0.22], be:[0.04,0.04,0.04], seg:6, h:1.4 },
{ name: 'Бетон', con:[[0.55,0.55,0.55],[0.70,0.70,0.70],[0.85,0.85,0.85]], em:[0.10,0.10,0.10], bd:[0.50,0.50,0.50], be:[0.07,0.07,0.07], seg:6, h:1.3 },
{ name: 'Кирпич', con:[[0.55,0.25,0.15],[0.75,0.40,0.25],[0.90,0.55,0.40]], em:[0.15,0.07,0.04], bd:[0.45,0.20,0.10], be:[0.07,0.04,0.02], seg:4, h:1.3 },
{ name: 'Зелёный неон', con:[[0.10,0.80,0.30],[0.30,1.00,0.50],[0.85,1.00,0.90]], em:[0.20,0.80,0.40], bd:[0.10,0.20,0.10], be:[0.05,0.10,0.05], seg:4, h:1.4 },
{ name: 'Граффити-фиолет', con:[[0.55,0.20,0.85],[0.80,0.40,1.00],[1.00,0.80,1.00]], em:[0.35,0.15,0.65], bd:[0.25,0.20,0.30], be:[0.05,0.03,0.10], seg:5, h:1.3 },
{ name: 'Асфальт-шип', con:[[0.20,0.20,0.22],[0.35,0.35,0.38],[0.55,0.55,0.58]], em:[0.05,0.05,0.05], bd:[0.15,0.15,0.18], be:[0.03,0.03,0.03], seg:6, h:1.3 },
{ name: 'Жёлтое предупр.', con:[[0.85,0.65,0.10],[1.00,0.85,0.20],[1.00,1.00,0.50]], em:[0.40,0.30,0.05], bd:[0.20,0.20,0.20], be:[0.05,0.05,0.05], seg:6, h:1.3 },
],
// Эпоха 4 — Город ночью
4: [
{ name: 'Пурпурный неон', con:[[0.40,0.10,0.55],[0.70,0.25,0.85],[1.00,0.55,1.00]], em:[0.40,0.15,0.60], bd:[0.10,0.10,0.15], be:[0.03,0.03,0.07], seg:6, h:1.4 },
{ name: 'Циан кристалл', con:[[0.10,0.50,0.65],[0.25,0.85,1.00],[0.85,1.00,1.00]], em:[0.15,0.45,0.65], bd:[0.05,0.10,0.15], be:[0.02,0.05,0.07], seg:6, h:1.4 },
{ name: 'Розовый неон', con:[[0.85,0.15,0.45],[1.00,0.35,0.65],[1.00,0.75,0.90]], em:[0.60,0.15,0.35], bd:[0.10,0.05,0.10], be:[0.05,0.02,0.05], seg:6, h:1.4 },
{ name: 'Жёлтый софит', con:[[0.85,0.65,0.10],[1.00,0.85,0.20],[1.00,1.00,0.60]], em:[0.55,0.40,0.05], bd:[0.10,0.10,0.15], be:[0.03,0.03,0.05], seg:6, h:1.4 },
{ name: 'Голограмма', con:[[0.30,0.50,1.00],[0.50,0.85,1.00],[1.00,1.00,1.00]], em:[0.40,0.70,1.00], bd:[0.05,0.05,0.15], be:[0.02,0.02,0.05], seg:8, h:1.5, alpha:0.65 },
{ name: 'Тёмный лазер', con:[[0.55,0.05,0.05],[0.85,0.20,0.20],[1.00,0.60,0.60]], em:[0.55,0.15,0.15], bd:[0.10,0.05,0.05], be:[0.05,0.02,0.02], seg:6, h:1.5 },
{ name: 'Изумрудный неон', con:[[0.10,0.55,0.30],[0.25,0.85,0.45],[0.70,1.00,0.80]], em:[0.15,0.55,0.30], bd:[0.05,0.10,0.05], be:[0.02,0.05,0.03], seg:6, h:1.4 },
{ name: 'Лиловый', con:[[0.50,0.20,0.65],[0.75,0.45,0.85],[0.95,0.80,1.00]], em:[0.30,0.15,0.45], bd:[0.20,0.10,0.25], be:[0.05,0.03,0.07], seg:6, h:1.4 },
{ name: 'Оранжевый софит', con:[[0.85,0.40,0.10],[1.00,0.60,0.20],[1.00,0.85,0.50]], em:[0.50,0.25,0.05], bd:[0.10,0.05,0.05], be:[0.03,0.02,0.02], seg:6, h:1.4 },
{ name: 'Чёрный с подсветкой', con:[[0.05,0.05,0.10],[0.15,0.15,0.25],[0.40,0.40,0.65]], em:[0.10,0.10,0.30], bd:[0.05,0.05,0.10], be:[0.05,0.05,0.15], seg:8, h:1.4 },
],
// Эпоха 5 — Пустыня
5: [
{ name: 'Кактус', con:[[0.20,0.40,0.20],[0.35,0.60,0.30],[0.95,0.95,0.80]], em:[0.10,0.20,0.10], bd:[0.55,0.45,0.25], be:[0.10,0.08,0.04], seg:6, h:1.2 },
{ name: 'Кость', con:[[0.85,0.80,0.65],[0.95,0.92,0.80],[1.00,1.00,0.95]], em:[0.20,0.18,0.12], bd:[0.50,0.40,0.30], be:[0.07,0.05,0.03], seg:5, h:1.3, baseR:0.32 },
{ name: 'Песчаник', con:[[0.65,0.50,0.25],[0.80,0.65,0.40],[0.95,0.85,0.60]], em:[0.20,0.15,0.08], bd:[0.75,0.62,0.38], be:[0.18,0.13,0.07], seg:4, h:1.0, baseR:0.45 },
{ name: 'Скорпионий хвост', cluster:true, count:1, con:[[0.55,0.40,0.20],[0.80,0.60,0.30],[1.00,0.85,0.50]], em:[0.20,0.15,0.05], bd:[0.40,0.30,0.15], be:[0.08,0.06,0.03], seg:6, h:1.4, twist:0.6 },
{ name: 'Ребро дракона', con:[[0.75,0.65,0.50],[0.85,0.78,0.62],[0.95,0.90,0.78]], em:[0.20,0.18,0.12], bd:[0.50,0.40,0.30], be:[0.08,0.06,0.04], seg:5, h:1.5, twist:0.3 },
{ name: 'Кактус-цветок', con:[[0.30,0.50,0.25],[0.85,0.30,0.50],[1.00,0.70,0.85]], em:[0.30,0.15,0.25], bd:[0.30,0.45,0.20], be:[0.07,0.10,0.05], seg:6, h:1.1 },
{ name: 'Терракота', con:[[0.65,0.30,0.15],[0.85,0.50,0.25],[1.00,0.75,0.45]], em:[0.30,0.15,0.07], bd:[0.50,0.25,0.10], be:[0.10,0.05,0.02], seg:6, h:1.3 },
{ name: 'Мумийный шип', con:[[0.65,0.55,0.40],[0.85,0.75,0.55],[1.00,0.95,0.75]], em:[0.25,0.20,0.12], bd:[0.45,0.35,0.20], be:[0.08,0.06,0.03], seg:6, h:1.3 },
{ name: 'Кварц-кактус', con:[[0.85,0.75,0.40],[0.95,0.85,0.55],[1.00,1.00,0.85]], em:[0.35,0.30,0.15], bd:[0.50,0.40,0.20], be:[0.12,0.10,0.05], seg:6, h:1.4, alpha:0.92 },
{ name: 'Огненный песок', con:[[0.90,0.55,0.10],[1.00,0.75,0.30],[1.00,0.95,0.65]], em:[0.55,0.25,0.05], bd:[0.55,0.30,0.10], be:[0.15,0.07,0.02], seg:5, h:1.2 },
],
// Эпоха 6 — Океан
6: [
{ name: 'Коралл розовый', cluster:true, count:4, con:[[0.90,0.40,0.50],[1.00,0.55,0.65],[1.00,0.80,0.85]], em:[0.35,0.10,0.18], bd:[0.85,0.35,0.45], be:[0.20,0.05,0.10], seg:5, h:0.9, twist:0.4 },
{ name: 'Ракушка', con:[[0.30,0.55,0.70],[0.60,0.85,0.95],[0.95,1.00,1.00]], em:[0.20,0.35,0.45], bd:[0.50,0.65,0.70], be:[0.10,0.15,0.18], seg:8, h:1.3, twist:0.8 },
{ name: 'Водоросль', con:[[0.15,0.45,0.25],[0.35,0.70,0.40],[0.65,0.90,0.60]], em:[0.08,0.25,0.12], bd:[0.20,0.35,0.30], be:[0.04,0.08,0.06], seg:5, h:1.5, twist:0.5 },
{ name: 'Коралл синий', cluster:true, count:3, con:[[0.20,0.55,0.85],[0.40,0.75,1.00],[0.80,0.95,1.00]], em:[0.15,0.40,0.55], bd:[0.30,0.55,0.70], be:[0.07,0.15,0.20], seg:5, h:1.0, twist:0.3 },
{ name: 'Жемчуг', con:[[0.85,0.85,0.95],[0.95,0.95,1.00],[1.00,1.00,1.00]], em:[0.30,0.30,0.40], bd:[0.70,0.75,0.85], be:[0.10,0.12,0.15], seg:10, h:1.3, alpha:0.90, specPower:128 },
{ name: 'Морская звезда', cluster:true, count:5, con:[[0.95,0.55,0.20],[1.00,0.75,0.35],[1.00,0.95,0.65]], em:[0.45,0.25,0.08], bd:[0.75,0.45,0.15], be:[0.15,0.10,0.04], seg:5, h:0.9, off:0.15, lean:0.5 },
{ name: 'Морской ёж', cluster:true, count:6, con:[[0.20,0.10,0.40],[0.40,0.20,0.60],[0.70,0.45,0.85]], em:[0.15,0.07,0.30], bd:[0.30,0.15,0.40], be:[0.05,0.03,0.10], seg:4, h:0.9, off:0.20 },
{ name: 'Каменный коралл', con:[[0.55,0.55,0.50],[0.75,0.75,0.70],[0.95,0.95,0.90]], em:[0.15,0.15,0.13], bd:[0.45,0.45,0.40], be:[0.08,0.08,0.07], seg:6, h:1.3 },
{ name: 'Бирюзовый кристалл', con:[[0.10,0.65,0.65],[0.30,0.85,0.85],[0.75,1.00,1.00]], em:[0.15,0.45,0.45], bd:[0.10,0.40,0.40], be:[0.03,0.10,0.10], seg:6, h:1.5, alpha:0.90 },
{ name: 'Морская трава', cluster:true, count:5, con:[[0.20,0.55,0.40],[0.40,0.75,0.55],[0.70,0.95,0.75]], em:[0.10,0.30,0.18], bd:[0.20,0.40,0.30], be:[0.05,0.10,0.07], seg:4, h:1.3, off:0.10, lean:0.2 },
],
// Эпоха 7 — Пещеры
7: [
{ name: 'Сталактит', con:[[0.35,0.30,0.25],[0.55,0.45,0.35],[0.75,0.65,0.55]], em:[0.10,0.08,0.06], bd:[0.30,0.25,0.20], be:[0.05,0.04,0.03], seg:6, h:1.4 },
{ name: 'Кварц фиолет', con:[[0.55,0.45,0.75],[0.80,0.70,0.95],[1.00,0.95,1.00]], em:[0.30,0.20,0.50], bd:[0.45,0.35,0.55], be:[0.10,0.05,0.15], seg:6, h:1.5, twist:0.7, alpha:0.90 },
{ name: 'Гриб-шип', con:[[0.55,0.20,0.20],[0.80,0.30,0.30],[0.95,0.95,0.85]], em:[0.30,0.10,0.10], bd:[0.55,0.40,0.30], be:[0.10,0.07,0.05], seg:6, h:1.2 },
{ name: 'Изумрудный кварц', con:[[0.15,0.55,0.30],[0.40,0.85,0.55],[0.80,1.00,0.85]], em:[0.15,0.45,0.25], bd:[0.10,0.40,0.20], be:[0.03,0.10,0.05], seg:6, h:1.5, twist:0.6, alpha:0.90 },
{ name: 'Тёмный сталагмит', con:[[0.20,0.18,0.15],[0.35,0.32,0.28],[0.55,0.50,0.45]], em:[0.05,0.04,0.03], bd:[0.20,0.18,0.15], be:[0.04,0.04,0.03], seg:6, h:1.5 },
{ name: 'Кристалл воды', con:[[0.20,0.60,0.85],[0.45,0.80,1.00],[0.85,1.00,1.00]], em:[0.20,0.45,0.65], bd:[0.15,0.30,0.40], be:[0.05,0.10,0.15], seg:6, h:1.5, twist:0.5, alpha:0.88 },
{ name: 'Огненный кварц', con:[[0.85,0.30,0.10],[1.00,0.55,0.20],[1.00,0.90,0.55]], em:[0.55,0.20,0.05], bd:[0.40,0.20,0.10], be:[0.10,0.05,0.02], seg:6, h:1.5, twist:0.4, alpha:0.90 },
{ name: 'Светящийся мох', cluster:true, count:4, con:[[0.20,0.60,0.40],[0.40,0.85,0.55],[0.80,1.00,0.75]], em:[0.20,0.55,0.30], bd:[0.20,0.30,0.20], be:[0.07,0.15,0.10], seg:4, h:1.0 },
{ name: 'Чёрный гранит', con:[[0.10,0.10,0.10],[0.20,0.20,0.20],[0.35,0.35,0.35]], em:[0.03,0.03,0.03], bd:[0.08,0.08,0.08], be:[0.02,0.02,0.02], seg:6, h:1.4, specPower:64 },
{ name: 'Алмазная пыль', con:[[0.75,0.80,0.85],[0.90,0.92,0.95],[1.00,1.00,1.00]], em:[0.35,0.40,0.45], bd:[0.45,0.50,0.55], be:[0.10,0.12,0.15], seg:8, h:1.4, alpha:0.85, specPower:128 },
],
// Эпоха 8 — Вулкан
8: [
{ name: 'Лавовый', con:[[0.30,0.05,0.02],[0.95,0.45,0.10],[1.00,0.90,0.30]], em:[0.80,0.30,0.05], bd:[0.20,0.10,0.05], be:[0.30,0.10,0.02], seg:6, h:1.4 },
{ name: 'Обсидиан', con:[[0.05,0.02,0.05],[0.15,0.05,0.15],[0.50,0.20,0.50]], em:[0.10,0.03,0.10], bd:[0.10,0.05,0.10], be:[0.05,0.02,0.05], seg:6, h:1.5, specPower:128 },
{ name: 'Магма', con:[[0.55,0.20,0.05],[1.00,0.55,0.10],[1.00,0.95,0.40]], em:[0.90,0.40,0.08], bd:[0.30,0.15,0.05], be:[0.40,0.10,0.02], seg:8, h:1.3 },
{ name: 'Пепел', con:[[0.20,0.18,0.18],[0.35,0.32,0.32],[0.55,0.52,0.50]], em:[0.05,0.04,0.04], bd:[0.15,0.13,0.13], be:[0.03,0.03,0.03], seg:6, h:1.3 },
{ name: 'Раскалённый базальт', con:[[0.10,0.05,0.05],[0.50,0.10,0.05],[0.95,0.50,0.10]], em:[0.40,0.10,0.02], bd:[0.10,0.05,0.05], be:[0.10,0.03,0.02], seg:6, h:1.4 },
{ name: 'Алый кристалл', con:[[0.55,0.05,0.10],[0.85,0.15,0.20],[1.00,0.50,0.50]], em:[0.55,0.10,0.15], bd:[0.30,0.05,0.10], be:[0.10,0.02,0.03], seg:6, h:1.5, twist:0.5, alpha:0.92 },
{ name: 'Огонь', cluster:true, count:5, con:[[0.95,0.45,0.10],[1.00,0.65,0.20],[1.00,0.95,0.50]], em:[0.90,0.45,0.10], bd:[0.30,0.10,0.05], be:[0.30,0.10,0.02], seg:4, h:1.0, off:0.10, lean:0.3 },
{ name: 'Чёрный шип', con:[[0.02,0.02,0.02],[0.10,0.10,0.10],[0.25,0.25,0.25]], em:[0.02,0.02,0.02], bd:[0.05,0.05,0.05], be:[0.02,0.02,0.02], seg:6, h:1.4, specPower:96 },
{ name: 'Серный кристалл', con:[[0.85,0.75,0.10],[1.00,0.90,0.25],[1.00,1.00,0.60]], em:[0.55,0.45,0.05], bd:[0.45,0.40,0.10], be:[0.15,0.12,0.03], seg:6, h:1.4, twist:0.4 },
{ name: 'Уголь', con:[[0.15,0.10,0.10],[0.30,0.20,0.20],[0.50,0.35,0.30]], em:[0.10,0.05,0.05], bd:[0.10,0.07,0.07], be:[0.05,0.03,0.03], seg:6, h:1.3 },
],
// Эпоха 9 — Космос
9: [
{ name: 'Лазер красный', con:[[0.80,0.10,0.50],[1.00,0.30,0.80],[1.00,0.95,1.00]], em:[1.00,0.20,0.80], bd:[0.05,0.05,0.10], be:[0.02,0.02,0.05], seg:16, h:1.6, baseR:0.20, tipR:0.01 },
{ name: 'Звезда', con:[[0.50,0.40,0.10],[1.00,0.85,0.20],[1.00,1.00,0.85]], em:[0.80,0.65,0.15], bd:[0.10,0.05,0.20], be:[0.05,0.02,0.10], seg:5, h:1.4, twist:0.4 },
{ name: 'Плазма', con:[[0.20,0.55,0.95],[0.50,0.85,1.00],[0.95,1.00,1.00]], em:[0.40,0.80,1.00], bd:[0.05,0.10,0.20], be:[0.05,0.10,0.20], seg:8, h:1.4, alpha:0.92 },
{ name: 'Чёрная дыра', con:[[0.05,0.02,0.10],[0.20,0.10,0.30],[0.55,0.25,0.65]], em:[0.30,0.05,0.45], bd:[0.05,0.02,0.10], be:[0.05,0.02,0.10], seg:8, h:1.5, specPower:128 },
{ name: 'Лазер зелёный', con:[[0.10,0.80,0.30],[0.30,1.00,0.50],[0.85,1.00,0.90]], em:[0.30,1.00,0.50], bd:[0.05,0.10,0.05], be:[0.02,0.05,0.02], seg:16, h:1.6, baseR:0.20 },
{ name: 'Лазер фиолет', con:[[0.50,0.10,0.85],[0.70,0.25,1.00],[0.95,0.75,1.00]], em:[0.70,0.20,1.00], bd:[0.05,0.05,0.10], be:[0.02,0.02,0.05], seg:16, h:1.6, baseR:0.20 },
{ name: 'Кристалл космоса', con:[[0.30,0.30,0.95],[0.55,0.55,1.00],[0.95,0.95,1.00]], em:[0.45,0.45,0.95], bd:[0.10,0.10,0.30], be:[0.05,0.05,0.15], seg:6, h:1.5, twist:0.6, alpha:0.88 },
{ name: 'Туманность', con:[[0.55,0.25,0.85],[0.85,0.45,1.00],[1.00,0.85,1.00]], em:[0.50,0.20,0.85], bd:[0.20,0.10,0.30], be:[0.07,0.05,0.15], seg:8, h:1.4, alpha:0.85 },
{ name: 'Луна', con:[[0.55,0.55,0.55],[0.75,0.75,0.75],[0.95,0.95,0.95]], em:[0.30,0.30,0.30], bd:[0.40,0.40,0.40], be:[0.10,0.10,0.10], seg:8, h:1.3 },
{ name: 'Метеорит', con:[[0.35,0.25,0.15],[0.55,0.40,0.25],[0.85,0.70,0.45]], em:[0.20,0.15,0.07], bd:[0.20,0.15,0.10], be:[0.07,0.05,0.03], seg:6, h:1.3 },
],
// Эпоха 10 — Кибер
10: [
{ name: 'Кибер-зелёный', con:[[0.10,0.80,0.30],[0.20,1.00,0.40],[0.80,1.00,0.90]], em:[0.20,1.00,0.40], bd:[0.05,0.05,0.05], be:[0.02,0.05,0.02], seg:4, h:1.5 },
{ name: 'Чип', con:[[0.20,0.45,0.25],[0.40,0.85,0.45],[0.85,1.00,0.85]], em:[0.30,0.65,0.30], bd:[0.15,0.20,0.15], be:[0.05,0.10,0.05], seg:6, h:1.3 },
{ name: 'Голограмма', con:[[0.30,0.50,1.00],[0.50,0.85,1.00],[1.00,1.00,1.00]], em:[0.40,0.70,1.00], bd:[0.05,0.05,0.15], be:[0.05,0.05,0.15], seg:8, h:1.5, alpha:0.65 },
{ name: 'Кибер-красный', con:[[0.85,0.10,0.30],[1.00,0.30,0.50],[1.00,0.85,0.95]], em:[0.90,0.20,0.40], bd:[0.10,0.05,0.05], be:[0.05,0.02,0.02], seg:4, h:1.5 },
{ name: 'Электрический', con:[[0.30,0.55,1.00],[0.55,0.80,1.00],[0.95,1.00,1.00]], em:[0.55,0.80,1.00], bd:[0.10,0.10,0.20], be:[0.05,0.05,0.15], seg:6, h:1.5 },
{ name: 'Жёлтый кибер', con:[[0.85,0.85,0.10],[1.00,1.00,0.25],[1.00,1.00,0.85]], em:[0.85,0.85,0.15], bd:[0.10,0.10,0.05], be:[0.05,0.05,0.02], seg:4, h:1.5 },
{ name: 'Фиолетовый кибер', con:[[0.55,0.15,0.85],[0.80,0.35,1.00],[1.00,0.85,1.00]], em:[0.70,0.25,1.00], bd:[0.10,0.05,0.15], be:[0.05,0.02,0.07], seg:4, h:1.5 },
{ name: 'Оранжевый чип', con:[[0.85,0.45,0.10],[1.00,0.65,0.20],[1.00,0.90,0.55]], em:[0.85,0.40,0.10], bd:[0.15,0.10,0.05], be:[0.07,0.04,0.02], seg:6, h:1.4 },
{ name: 'Кибер-голограмма', con:[[0.85,0.40,1.00],[0.95,0.65,1.00],[1.00,0.95,1.00]], em:[0.75,0.40,1.00], bd:[0.10,0.05,0.20], be:[0.05,0.02,0.10], seg:8, h:1.5, alpha:0.65 },
{ name: 'Тёмный чип', con:[[0.10,0.20,0.15],[0.20,0.40,0.30],[0.40,0.70,0.55]], em:[0.15,0.35,0.25], bd:[0.05,0.10,0.07], be:[0.02,0.05,0.03], seg:6, h:1.3 },
],
};
// =========================================================================
// СБОРКА КАТАЛОГА (10 эпох × 10 вариантов = 100)
// =========================================================================
function makeFromPalette(scene, id, palette) {
const p = {
conColors: { base: palette.con[0], mid: palette.con[1], tip: palette.con[2] },
matEmissive: palette.em,
baseDiffuse: palette.bd,
baseEmissive: palette.be,
segments: palette.seg || 6,
height: palette.h || 1.4,
baseR: palette.baseR || 0.42,
tipR: palette.tipR || 0.03,
twist: palette.twist || 0,
alpha: palette.alpha,
specPower: palette.specPower,
matSpec: palette.matSpec,
};
if (palette.cluster) {
return buildCluster(scene, id, {
...p,
count: palette.count || 3,
off: palette.off || 0.18,
lean: palette.lean || 0.15,
heightVar: 0.25,
});
}
return buildSingleSpike(scene, id, p);
}
export const SPIKE_CATALOG = [];
for (let epoch = 1; epoch <= 10; epoch++) {
const palettes = PALETTES[epoch] || [];
for (let i = 0; i < palettes.length; i++) {
const palette = palettes[i];
SPIKE_CATALOG.push({
id: `e${epoch}_v${i + 1}`,
epoch,
title: palette.name,
make: (scene, id) => makeFromPalette(scene, id, palette),
});
}
}
export const EPOCH_INFO = [
{ n: 1, name: 'Лес', emoji: '🌲', color: '#3a7a4a' },
{ n: 2, name: 'Горы', emoji: '🏔', color: '#aaccff' },
{ n: 3, name: 'Город днём', emoji: '🏙', color: '#7a8aaa' },
{ n: 4, name: 'Город ночью', emoji: '🌃', color: '#1a1a3a' },
{ n: 5, name: 'Пустыня', emoji: '🏜', color: '#c8a575' },
{ n: 6, name: 'Океан', emoji: '🌊', color: '#3a8aaa' },
{ n: 7, name: 'Пещеры', emoji: '🕳', color: '#3a3a3a' },
{ n: 8, name: 'Вулкан', emoji: '🌋', color: '#8a2a2a' },
{ n: 9, name: 'Космос', emoji: '🚀', color: '#3a1a5a' },
{ n: 10, name: 'Кибер', emoji: '🤖', color: '#ff00ff' },
];
export function getSpikesByEpoch(epoch) {
return SPIKE_CATALOG.filter(s => s.epoch === epoch);
}

39
src/api/API.js Normal file
View File

@ -0,0 +1,39 @@
// API-эндпоинты студии Рублокса.
//
// Все URL бэкенда настраиваются через Vite environment variables:
// VITE_API_BASE — базовый URL HTTP-API (пустой = vite-proxy)
// VITE_REALTIME_HTTP — Colyseus matchmaking HTTP
// VITE_REALTIME_WS — Colyseus WebSocket
// VITE_RUBLOX_HOME — главный сайт Рублокса (редирект если нет JWT)
//
// См. .env.example с готовыми дефолтами на публичный staging
// (https://dev-api.rublox.pro).
const ENV = (typeof import.meta !== 'undefined' && import.meta.env) || {};
const IS_DEV = typeof window !== 'undefined'
&& (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1');
const BASE = ENV.VITE_API_BASE
?? (IS_DEV ? '' : (typeof window !== 'undefined' ? window.location.origin : ''));
export const USER_addres = BASE + '/api-user';
export const STORYS_addres = BASE + '/api-storys';
// Realtime (Colyseus)
const IS_HTTPS = typeof window !== 'undefined' && window.location.protocol === 'https:';
export const REALTIME_HTTP = ENV.VITE_REALTIME_HTTP
?? (IS_HTTPS ? `${window.location.origin}/api-game` : 'http://localhost:8685');
export const REALTIME_WS = ENV.VITE_REALTIME_WS
?? (IS_HTTPS ? `wss://${window.location.host}/api-game` : 'ws://localhost:8685');
// Главный сайт Рублокса
export const RUBLOX_HOME = ENV.VITE_RUBLOX_HOME ?? 'https://rublox.pro/app';
// Веб-плеер (для запуска игр из студии "Тест").
export const PLAYER_URL = ENV.VITE_PLAYER_URL ?? 'https://player.rublox.pro';
// Папки ассетов
export const FOLDER_PROFILE = USER_addres + '/assets/profile/';
export const FOLDER_STORYS = STORYS_addres + '/assets/skin/';

459
src/api/Kubikon3DService.js Normal file
View File

@ -0,0 +1,459 @@
/**
* API-сервис для Рублокс 3D (отдельный от 2D-Кубоши).
* Бэкенд: storys-микросервис, префикс /kubikon3d/...
*/
import axios from 'axios';
import { STORYS_addres } from './API';
const api = axios.create({
baseURL: STORYS_addres,
timeout: 30000,
// Поднимаем лимит размера body — без этого axios отказывается отправлять
// payload > ~10МБ. После Этапа 3 voxel-движка RLE-сжатие даёт <2МБ
// для 250м карты, но запас не помешает.
maxContentLength: 100 * 1024 * 1024, // 100 МБ
maxBodyLength: 100 * 1024 * 1024,
});
// Подкладываем JWT в каждый запрос — storys использует его, чтобы дёрнуть
// user-микросервис и узнать имя пользователя (resolve_my_username).
// Если токена нет (гость) — заголовок не ставим, бэк отдаст пустое имя.
api.interceptors.request.use((config) => {
try {
const token = localStorage.getItem('Authorization');
if (token) {
config.headers = config.headers || {};
config.headers.Authorization = token;
}
} catch (e) { /* ignore */ }
return config;
});
// ============ ПРОЕКТЫ ============
// Save-операции с увеличенным таймаутом (120с) — для больших карт.
// Стандартный 30с таймаут вызывал ошибку «не удалось сохранить» на 250м
// карте, потому что upload 5+МБ JSON по медленной сети занимает больше.
const SAVE_TIMEOUT = 120000;
export const createProject = (userId, data) =>
api.post('/kubikon3d/projects', { user_id: userId, ...data }, { timeout: SAVE_TIMEOUT });
/**
* Загрузить проект по id.
*
* Бэкенд проверяет права доступа по правилам:
* - published открыто всем (можно вызвать без userId)
* - draft / review / blocked только автору и админу
*
* Поэтому если открываем чужой/свой черновик в редакторе обязательно
* передаём userId, иначе бэк отдаст 403.
*/
export const getProject = (id, userId = null) => {
const params = {};
if (userId != null) params.user_id = userId;
return api.get(`/kubikon3d/projects/${id}`, { params });
};
/**
* Загрузить проект с retry на случай зависшего/медленного запроса.
*
* ПРОБЛЕМА: иногда запрос getProject подвисает (dev-proxy, перегруженный
* пул соединений, сетевой лаг) и страница "Загрузка проекта… 0%"
* замирает на 30с (таймаут axios) или до 60с (safety-timer редактора).
* Приходилось перезагружать вручную по 5 раз.
*
* РЕШЕНИЕ: короткий таймаут на ПОПЫТКУ (12с) + до 3 попыток. Зависшая
* попытка отменяется и повторяется сама, без ручной перезагрузки.
* Сетевые/таймаут-ошибки retry; 4xx (403/404) сразу пробрасываем
* (повтор не поможет).
*
* @param {number} id id проекта
* @param {number|null} userId
* @param {number} attempts сколько попыток (по умолчанию 3)
* @param {number} perTryTimeout таймаут одной попытки в мс (по умолчанию 12000)
*/
export const getProjectWithRetry = async (
id, userId = null, attempts = 3, perTryTimeout = 12000,
) => {
const params = {};
if (userId != null) params.user_id = userId;
let lastErr = null;
for (let i = 0; i < attempts; i++) {
try {
return await api.get(`/kubikon3d/projects/${id}`, {
params,
timeout: perTryTimeout,
});
} catch (err) {
lastErr = err;
const status = err.response?.status;
// 4xx (кроме 408/429) — повтор не имеет смысла, пробрасываем сразу.
if (status && status >= 400 && status < 500
&& status !== 408 && status !== 429) {
throw err;
}
// Сеть/таймаут/5xx — пробуем ещё раз.
console.warn(
`[Kubikon3DApi] getProject attempt ${i + 1}/${attempts} failed`
+ ` (${err.code || status || 'network'}), retrying...`,
);
}
}
throw lastErr;
};
export const updateProject = (id, data) =>
api.put(`/kubikon3d/projects/${id}`, data, { timeout: SAVE_TIMEOUT });
export const deleteProject = (id, userId) =>
api.delete(`/kubikon3d/projects/${id}`, { data: { user_id: userId } });
export const getMyProjects = (userId) =>
api.get('/kubikon3d/my-projects', { params: { user_id: userId } });
/**
* Лента игр Рублокса (умная лента RUBLOX_SMART_FEED_PLAN.md).
*
* Второй аргумент вкладка ленты:
* recommended ранжирование по hot_score (умная лента);
* new самые свежие;
* popular по числу запусков;
* top_week топ за неделю.
* Бэкенд принимает этот параметр и как `tab`, и как `sort` (старое имя)
* шлём под обоими именами, чтобы не зависеть от версии бэкенда.
*/
export const getFeed = (page = 1, tab = 'recommended', maxAge = null, minRating = null, opts = {}) =>
api.get('/kubikon3d/feed', {
params: {
page, tab, sort: tab,
...(maxAge != null ? { max_age: maxAge } : {}),
...(minRating != null ? { min_rating: minRating } : {}),
...(opts.rank ? { rank: opts.rank } : {}),
...(opts.multiplayer != null
? { multiplayer: opts.multiplayer ? 'true' : 'false' } : {}),
...(opts.genre ? { genre: opts.genre } : {}),
...(opts.per_page ? { per_page: opts.per_page } : {}),
},
});
export const searchProjects = (q, maxAge = null) =>
api.get('/kubikon3d/search', {
params: { q, ...(maxAge != null ? { max_age: maxAge } : {}) },
});
// ============ ПУБЛИКАЦИЯ И МОДЕРАЦИЯ (Этап 3) ============
/**
* Опубликовать проект (умная лента RUBLOX_SMART_FEED_PLAN.md).
* Премодерации нет: чистая игра сразу в ленте, подозрительная review.
* payload: { user_id, age_rating: 6|12|16|18, title?, description?, thumbnail? }
* Ответ: { project, review: bool, too_empty: bool }
*/
export const publishProject = (id, payload) =>
api.post(`/kubikon3d/projects/${id}/publish`, payload);
export const unpublishProject = (id, userId) =>
api.post(`/kubikon3d/projects/${id}/unpublish`, { user_id: userId });
/** Очередь проверки админа — игры со status='review' (подозрительные скрипты). */
export const getModerationQueue = () =>
api.get('/kubikon3d/admin/moderation-queue');
/**
* Решение админа по игре из очереди проверки.
* payload: { admin_user_id, action: 'approve'|'block', comment?, age_rating? }
*/
export const moderateProject = (id, payload) =>
api.post(`/kubikon3d/admin/projects/${id}/moderate`, payload);
/** Заблокировать игру (умная лента, Фаза 7). payload: { admin_user_id, comment? } */
export const blockProject = (id, payload) =>
api.post(`/kubikon3d/admin/projects/${id}/block`, payload);
/** Разблокировать игру → published. payload: { admin_user_id } */
export const unblockProject = (id, payload) =>
api.post(`/kubikon3d/admin/projects/${id}/unblock`, payload);
/** Вернуть demoted-игру в ленту вручную. payload: { admin_user_id } */
export const restoreFeed = (id, payload) =>
api.post(`/kubikon3d/admin/projects/${id}/restore-feed`, payload);
export const getModerationHistory = (id) =>
api.get(`/kubikon3d/projects/${id}/moderation-history`);
// ============ ПЛЕЕР / СОЦИАЛКА (Этап 3.3) ============
/** Загрузить проект для плеера. Передаём user_id для проверки доступа. */
export const getProjectForPlay = (id, userId = null, isAdmin = false) =>
api.get(`/kubikon3d/projects/${id}`, {
params: {
...(userId ? { user_id: userId } : {}),
...(isAdmin ? { is_admin: 'true' } : {}),
},
});
export const incrementPlay = (id) =>
api.post(`/kubikon3d/projects/${id}/play`);
/** kind: 'like' | 'dislike'. Toggle: повторный голос того же типа снимает,
* голос другого типа переключает. */
export const toggleLike = (id, userId, kind = 'like') =>
api.post(`/kubikon3d/projects/${id}/like`, { user_id: userId, kind });
export const getLikeStatus = (id, userId) =>
api.get(`/kubikon3d/projects/${id}/like-status`, { params: { user_id: userId } });
/** payload: { reporter_user_id, target_type, target_id, category, text } */
export const createReport = (payload) =>
api.post('/kubikon3d/reports', payload);
/** Публичные игры автора. */
export const getUserGames = (userId, maxAge = null) =>
api.get(`/kubikon3d/users/${userId}/games`, {
params: maxAge != null ? { max_age: maxAge } : {},
});
// ============ ЛИДЕРБОРД (рекорды прохождения игр) ============
/** Топ-N рекордов прохождения проекта. По умолчанию 5. */
export const getLeaderboard = (projectId, limit = 5) =>
api.get(`/kubikon3d/projects/${projectId}/leaderboard`, {
params: { limit },
});
/** Отправить рекорд прохождения. Если время лучше предыдущего — обновляется. */
export const submitLeaderboard = (projectId, userId, timeMs) =>
api.post(`/kubikon3d/projects/${projectId}/leaderboard`, {
user_id: userId,
time_ms: timeMs,
});
// ============ ИЗБРАННОЕ ============
export const toggleFavorite = (projectId, userId) =>
api.post(`/kubikon3d/projects/${projectId}/favorite`, { user_id: userId });
export const getFavoriteStatus = (projectId, userId) =>
api.get(`/kubikon3d/projects/${projectId}/favorite-status`,
{ params: { user_id: userId } });
export const getMyFavorites = (userId) =>
api.get(`/kubikon3d/users/${userId}/favorites`);
// ============ ИСТОРИЯ ============
export const getPlayHistory = (userId, limit = 8) =>
api.get(`/kubikon3d/users/${userId}/history`, { params: { limit } });
// ============ ТРЕНДЫ / ТОП-АВТОРЫ / АКТИВНОСТЬ ============
export const getTrending = (limit = 8) =>
api.get('/kubikon3d/trending', { params: { limit } });
export const getTopAuthors = (limit = 10) =>
api.get('/kubikon3d/top-authors', { params: { limit } });
export const getActivity = (limit = 10) =>
api.get('/kubikon3d/activity', { params: { limit } });
export const getCollections = () =>
api.get('/kubikon3d/collections');
export const getEvents = () =>
api.get('/kubikon3d/events');
// ============ PERF LOGS ============
export const submitPerfLog = (sample) =>
api.post('/kubikon3d/perf-log', sample);
// ============ БАГ-РЕПОРТЫ РУБЛОКСА (с прикреплением скриншота) ============
/**
* Создать баг-репорт. Использует multipart/form-data, потому что может нести файл.
* fields: { bug_type, title, message, url_path, user_agent, user_id?, project_id?, screenshot? File }
*/
export const createBugReport = (fields) => {
const fd = new FormData();
Object.entries(fields).forEach(([k, v]) => {
if (v == null || v === '') return;
fd.append(k, v);
});
return api.post('/kubikon3d/bug-reports', fd, {
headers: { 'Content-Type': 'multipart/form-data' },
});
};
export const getAdminBugReports = (status = 'open', limit = 100) =>
api.get('/kubikon3d/admin/bug-reports', { params: { status, limit } });
export const updateAdminBugReport = (id, payload) =>
api.post(`/kubikon3d/admin/bug-reports/${id}`, payload);
// ============ HEARTBEAT / ОНЛАЙН ============
export const playHeartbeat = (sessionId, projectId, userId = null) =>
api.post('/kubikon3d/play/heartbeat', {
session_id: sessionId,
project_id: projectId,
user_id: userId,
});
export const getOnline = () =>
api.get('/kubikon3d/admin/online');
// ============ DASHBOARD / СТАТИСТИКА ============
export const getDashboard = () =>
api.get('/kubikon3d/admin/dashboard');
export const getAdminAllGames = (status = 'all', sort = 'new', limit = 200) =>
api.get('/kubikon3d/admin/all-games', { params: { status, sort, limit } });
export const getAdminAuthors = (limit = 100) =>
api.get('/kubikon3d/admin/authors', { params: { limit } });
// ============ ЖАЛОБЫ (АДМИНКА) ============
export const getAdminReports = (status = 'open', limit = 200) =>
api.get('/kubikon3d/admin/reports', { params: { status, limit } });
export const resolveAdminReport = (id, status, adminUserId) =>
api.post(`/kubikon3d/admin/reports/${id}`, { status, admin_user_id: adminUserId });
// ============ БАН ПУБЛИКАЦИЙ (этап 3.10) ============
/** Публичный — узнать активный бан публикаций пользователя. */
export const getPublishBanStatus = (userId) =>
api.get(`/kubikon3d/users/${userId}/publish-ban-status`);
export const getPublishBanHistory = (userId) =>
api.get(`/kubikon3d/admin/users/${userId}/publish-ban-history`);
// ============ КОММЕНТАРИИ ПОД ИГРАМИ (этап 3.9) ============
export const getProjectComments = (projectId) =>
api.get(`/kubikon3d/projects/${projectId}/comments`);
/** payload: { user_id, username, text } */
export const createProjectComment = (projectId, payload) =>
api.post(`/kubikon3d/projects/${projectId}/comments`, payload);
export const deleteProjectComment = (commentId, userId) =>
api.delete(`/kubikon3d/comments/${commentId}`, { data: { user_id: userId } });
export const editProjectComment = (commentId, userId, text) =>
api.put(`/kubikon3d/comments/${commentId}`, { user_id: userId, text });
// Админ
export const getAdminComments = (filter = 'flagged', projectId = null, limit = 200) =>
api.get('/kubikon3d/admin/comments', {
params: { filter, ...(projectId ? { project: projectId } : {}), limit },
});
// ============ ВНУТРИИГРОВОЙ ЧАТ (этап 3.5/3.6) ============
/** since (опц.) — id последнего сообщения; если задан — вернётся только дельта. */
export const getChat = (projectId, since = null, limit = 50) =>
api.get(`/kubikon3d/projects/${projectId}/chat`, {
params: { ...(since ? { since } : {}), limit },
});
/** payload: { user_id, username, text } */
export const postChatMessage = (projectId, payload) =>
api.post(`/kubikon3d/projects/${projectId}/chat`, payload);
/** Узнать активный мьют чата для пользователя. */
export const getChatMuteStatus = (userId) =>
api.get(`/kubikon3d/users/${userId}/chat-mute-status`);
// Админ
export const getAdminChatMessages = (filter = 'flagged', projectId = null, limit = 200) =>
api.get('/kubikon3d/admin/chat/messages', {
params: { filter, ...(projectId ? { project: projectId } : {}), limit },
});
export const getAdminChatBans = (filter = 'active', limit = 200) =>
api.get('/kubikon3d/admin/chat/bans', { params: { filter, limit } });
// ============================================================================
// Пользовательские модели (Этап 1+ редактора моделей)
// ============================================================================
// Эндпоинты для воксельных и гладких моделей, созданных пользователями.
// Отображаются в Toolbox в разделах "Мои модели" и "Сообщество".
// См. KUBIKON_MODEL_EDITOR_PLAN.md.
/** Создать модель.
* payload: { title, kind: 'voxel'|'smooth', model_data: stringJSON,
* description?, thumbnail_b64? }
* Возвращает serialize_full (с model_data).
*/
export const createUserModel = (userId, payload) =>
api.post('/kubikon3d/models', { user_id: userId, ...payload },
{ timeout: SAVE_TIMEOUT });
/** Загрузить модель по id. userId — для проверки доступа к приватной. */
export const getUserModel = (id, userId = null) => {
const params = {};
if (userId != null) params.user_id = userId;
return api.get(`/kubikon3d/models/${id}`, { params });
};
/** Обновить модель. payload: { title?, description?, model_data?, thumbnail_b64? } */
export const updateUserModel = (id, userId, payload) =>
api.put(`/kubikon3d/models/${id}`, { user_id: userId, ...payload },
{ timeout: SAVE_TIMEOUT });
export const deleteUserModel = (id, userId) =>
api.delete(`/kubikon3d/models/${id}`, {
data: { user_id: userId },
params: { user_id: userId },
});
/** Мои модели (для раздела "Мои" в Toolbox). */
export const getMyUserModels = (userId, opts = {}) =>
api.get('/kubikon3d/models/mine', {
params: {
user_id: userId,
...(opts.kind ? { kind: opts.kind } : {}),
...(opts.limit ? { limit: opts.limit } : {}),
...(opts.offset ? { offset: opts.offset } : {}),
},
});
/** Публичные модели (для раздела "Сообщество" в Toolbox).
* opts: { q, kind, limit, offset, userId }
* userId чтобы у моделей пришёл флаг liked_by_me (подсветка лайка). */
export const getPublicUserModels = (opts = {}) =>
api.get('/kubikon3d/models/public', {
params: {
...(opts.q ? { q: opts.q } : {}),
...(opts.kind ? { kind: opts.kind } : {}),
...(opts.limit ? { limit: opts.limit } : {}),
...(opts.offset ? { offset: opts.offset } : {}),
...(opts.userId != null ? { user_id: opts.userId } : {}),
},
});
export const publishUserModel = (id, userId) =>
api.post(`/kubikon3d/models/${id}/publish`, { user_id: userId });
export const unpublishUserModel = (id, userId) =>
api.post(`/kubikon3d/models/${id}/unpublish`, { user_id: userId });
/** Инкремент uses_count — вызывать когда модель ставят в проект. */
export const incrementModelUses = (id) =>
api.post(`/kubikon3d/models/${id}/use`);
/** Поставить/снять лайк пользовательской модели (toggle).
* Возвращает { liked, likes_count }. */
export const likeUserModel = (id, userId) =>
api.post(`/kubikon3d/models/${id}/like`, { user_id: userId });
// ============ СКИНЫ ИГРОКА (R15) ============
/** Список купленных скинов игрока. Возвращает { skins: [...], count }. */
export const getOwnedSkins = (userId) =>
api.get('/kubikon3d/rublox/owned-skins', { params: { user_id: userId } });
/** Выбранный (надетый) скин игрока. Возвращает { user_id, skin_folder }.
* Если записи нет бэк отдаёт дефолт skin_bacon-hair. */
export const getEquippedSkin = (userId) =>
api.get('/kubikon3d/rublox/equipped-skin', { params: { user_id: userId } });
/** Установить выбранный скин игрока. Бэк проверяет владение скином.
* Возвращает { ok, skin_folder } или ошибку. */
export const setEquippedSkin = (userId, skinFolder) =>
api.post('/kubikon3d/rublox/equipped-skin', {
user_id: userId, skin_folder: skinFolder,
});

View File

@ -0,0 +1,59 @@
/**
* API-сервис для облачных проектов Кубикон CAD (Месяц 9 плана).
* Бэкенд: storys-микросервис, префикс /kubikon-cad/...
*
* Это отдельная сущность от Kubikon3DService (Рублокс):
* Рублокс игры (kubikon3d_projects)
* CAD параметрические модели (kubikon_cad_projects)
*/
import axios from 'axios';
import { STORYS_addres } from './API';
const api = axios.create({
baseURL: STORYS_addres,
timeout: 30000,
});
// JWT — для resolve_my_username на бэке (если когда-нибудь понадобится).
api.interceptors.request.use((config) => {
try {
const token = localStorage.getItem('Authorization');
if (token) {
config.headers = config.headers || {};
config.headers.Authorization = token;
}
} catch (_) { /* ignore */ }
return config;
});
// === CRUD ===
/** Создать новый проект.
* body: { user_id, title?, description?, document_data?, thumbnail? }
* ответ: serialize_full (включая id и document_data) */
export const createProject = (body) =>
api.post('/kubikon-cad/projects', body);
/** Получить проект целиком (с document_data) для открытия в редакторе.
* Только владельцу (user_id обязателен). */
export const getProject = (projectId, userId) =>
api.get(`/kubikon-cad/projects/${projectId}`, {
params: { user_id: userId },
});
/** Обновить проект. body: { user_id, title?, description?, document_data?, thumbnail? }.
* Только владелец может. */
export const updateProject = (projectId, body) =>
api.put(`/kubikon-cad/projects/${projectId}`, body);
/** Удалить проект. Только владелец. */
export const deleteProject = (projectId, userId) =>
api.delete(`/kubikon-cad/projects/${projectId}`, {
params: { user_id: userId },
});
/** Список проектов пользователя (без document_data).
* ответ: { items: [...] }, отсортированный по updated_at desc. */
export const listUserProjects = (userId) =>
api.get(`/kubikon-cad/users/${userId}/projects`);

109
src/api/SanctionsService.js Normal file
View File

@ -0,0 +1,109 @@
/**
* SanctionsService клиент для системы санкций и жалоб на баны.
*
* Storys-эндпоинты (через /api-storys):
* GET /api/v1/storys/me/sanctions активные санкции юзера
*
* Team-эндпоинты (через /api-team):
* POST /api-team/ban-appeals подать жалобу
* GET /api-team/ban-appeals/my мои жалобы
* GET /api-team/ban-appeals/<id> детали + чат
* POST /api-team/ban-appeals/<id>/messages написать в чат
*/
import axios from 'axios';
const STORYS_BASE = '/api-storys';
const TEAM_BASE = '/api-team';
function authHeaders() {
const t = localStorage.getItem('Authorization');
return t ? { Authorization: t } : {};
}
class SanctionsService {
/** Активные санкции текущего юзера. Возвращает {sanctions: [], has_active}. */
static async getMySanctions() {
try {
const r = await axios.get(
`${STORYS_BASE}/api/v1/storys/me/sanctions`,
{ headers: authHeaders(), timeout: 8000 },
);
return r.data || { sanctions: [], has_active: false };
} catch (e) {
return { sanctions: [], has_active: false, error: e?.message };
}
}
/** Подать жалобу на бан. */
static async submitAppeal({ banType, banId, banService, text, banSnapshot }) {
try {
const r = await axios.post(
`${TEAM_BASE}/ban-appeals`,
{
ban_type: banType,
ban_id: banId,
ban_service: banService || 'storys',
text,
ban_snapshot: banSnapshot || null,
},
{ headers: { ...authHeaders(), 'Content-Type': 'application/json' } },
);
return { ok: true, ...r.data };
} catch (e) {
const d = e.response?.data || {};
return {
ok: false,
error: d.error,
appealId: d.appeal_id,
status: d.status,
message: d.error === 'already_submitted'
? 'Вы уже подавали жалобу на этот бан.'
: d.error === 'invalid_text'
? 'Введите текст жалобы (1-4000 символов).'
: 'Не удалось подать жалобу.',
};
}
}
/** Мои жалобы. */
static async getMyAppeals() {
try {
const r = await axios.get(
`${TEAM_BASE}/ban-appeals/my`,
{ headers: authHeaders() },
);
return r.data?.items || [];
} catch (e) {
return [];
}
}
/** Детали жалобы + сообщения. */
static async getAppeal(appealId) {
try {
const r = await axios.get(
`${TEAM_BASE}/ban-appeals/${appealId}`,
{ headers: authHeaders() },
);
return { ok: true, ...r.data };
} catch (e) {
return { ok: false, error: e.response?.data?.error };
}
}
/** Написать сообщение в жалобу. */
static async sendMessage(appealId, text) {
try {
const r = await axios.post(
`${TEAM_BASE}/ban-appeals/${appealId}/messages`,
{ text },
{ headers: { ...authHeaders(), 'Content-Type': 'application/json' } },
);
return { ok: true, ...r.data };
} catch (e) {
return { ok: false, error: e.response?.data?.error };
}
}
}
export default SanctionsService;

35
src/api/playTicket.js Normal file
View File

@ -0,0 +1,35 @@
/**
* Helper для редиректа из студии в плеер с одноразовым ticket'ом.
*
* Flow:
* студия POST /api-user/api/v1/auth/play-ticket { ticket }
* браузер window.location = PLAYER_URL/<gameId>#ticket=<ticket>
* плеер обменивает ticket на JWT (в PlayerAuth)
*
* Если бэкенд не отдал ticket фоллбек на простой редирект без ticket'а
* (плеер сам попросит JWT/перенаправит на главный сайт).
*/
import { USER_addres, PLAYER_URL } from './API';
export const PLAYER_BASE = PLAYER_URL;
export async function requestPlayTicket() {
const jwt = localStorage.getItem('Authorization') || localStorage.getItem('player_jwt');
if (!jwt) return null;
try {
const r = await fetch(`${USER_addres}/api/v1/auth/play-ticket`, {
method: 'POST',
headers: { Authorization: jwt },
});
if (!r.ok) return null;
const d = await r.json();
return d?.ticket || d?.data?.ticket || null;
} catch {
return null;
}
}
export function buildPlayerUrl(gameId, ticket) {
const base = `${PLAYER_URL}/${gameId}`;
return ticket ? `${base}#ticket=${encodeURIComponent(ticket)}` : base;
}

107
src/auth/AuthContext.jsx Normal file
View File

@ -0,0 +1,107 @@
import { createContext, useContext, useState, useEffect } from 'react';
import { jwtDecode } from 'jwt-decode';
/**
* Лёгкий AuthContext для студии Рублокса (opensource-версия).
*
* Главное отличие от минки:
* - НЕТ UserService.validateAndRefreshToken (нет refresh-токенов в opensource).
* - Если JWT в localStorage просрочен просто считаем юзера гостем
* и редиректим на VITE_RUBLOX_HOME при попытке зайти в защищённый раздел.
* - Profile-данные подгружаются один раз через GET /api-user/api/v1/users/profile.
*
* Все эндпоинты бэкенда конфигурируются через .env (VITE_API_BASE).
*/
const AuthContext = createContext(null);
export const useAuth = () => {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error('useAuth должен использоваться внутри <AuthProvider>');
return ctx;
};
function decodeJwtSafe(raw) {
try {
const p = jwtDecode(raw);
if (p.exp && p.exp * 1000 < Date.now()) return null;
return p;
} catch {
return null;
}
}
export function AuthProvider({ children }) {
const [state, setState] = useState({
isAuthenticated: false,
isLoading: true,
user: null,
role: 'user',
jwt: null,
});
useEffect(() => {
let cancelled = false;
const env = (typeof import.meta !== 'undefined' && import.meta.env) || {};
// STANDALONE мокаем гостевого юзера, без запросов.
if (String(env.VITE_STANDALONE).toLowerCase() === 'true') {
setState({
isAuthenticated: true,
isLoading: false,
user: { id: 0, firstName: 'Гость', _standalone: true },
role: 'admin',
jwt: 'standalone',
});
return;
}
const raw = localStorage.getItem('Authorization') || localStorage.getItem('player_jwt');
if (!raw) {
setState({ isAuthenticated: false, isLoading: false, user: null, role: 'user', jwt: null });
return;
}
const payload = decodeJwtSafe(raw);
if (!payload) {
// Просрочен чистим и выходим.
localStorage.removeItem('Authorization');
setState({ isAuthenticated: false, isLoading: false, user: null, role: 'user', jwt: null });
return;
}
// Тянем профиль с бэка.
const apiBase = env.VITE_API_BASE
|| (typeof window !== 'undefined' && (window.location.hostname === 'localhost'
|| window.location.hostname === '127.0.0.1') ? '' : window.location.origin);
fetch(`${apiBase}/api-user/api/v1/users/profile`, {
headers: { Authorization: raw },
})
.then((r) => (r.ok ? r.json() : null))
.then((data) => {
if (cancelled) return;
const profile = data?.data || data;
if (!profile) {
setState({ isAuthenticated: false, isLoading: false, user: null, role: 'user', jwt: null });
return;
}
setState({
isAuthenticated: true,
isLoading: false,
user: profile,
role: profile.role || 'user',
jwt: raw,
});
})
.catch(() => {
if (cancelled) return;
setState({ isAuthenticated: false, isLoading: false, user: null, role: 'user', jwt: null });
});
return () => {
cancelled = true;
};
}, []);
return <AuthContext.Provider value={state}>{children}</AuthContext.Provider>;
}

View File

@ -0,0 +1,84 @@
/**
* SanctionsContext глобальный контекст активных санкций юзера.
*
* Дёргает /api-storys/api/v1/storys/me/sanctions ОДИН раз при монтировании
* и при изменении localStorage Authorization (логин/логаут). Возвращает:
*
* sanctions массив { type, banned_until, reason, ... }
* isMuted true если есть активный user_timed_ban или chat_ban
* или comment_ban (то что блокирует ввод текста)
* isCantPublish true если есть user_timed_ban или publish_ban
* muteInfo { type, banned_until, reason } первой найденной мут-санкции
* reload() перезагрузить (например после успешной подачи жалобы)
*
* Подключи провайдер один раз в корне приложения (Menu.jsx). Дальше
* через useSanctions() в любом компоненте, без отдельных запросов.
*/
import React, { createContext, useContext, useEffect, useState, useCallback } from 'react';
import SanctionsService from '../api/SanctionsService';
const SanctionsContext = createContext({
sanctions: [], isMuted: false, isCantPublish: false,
muteInfo: null, loading: true, reload: () => {},
});
export function SanctionsProvider({ children }) {
const [sanctions, setSanctions] = useState([]);
const [loading, setLoading] = useState(true);
const reload = useCallback(async () => {
// Без JWT не дёргаем (вернёт пустоту, но всё равно лишний запрос).
if (!localStorage.getItem('Authorization')) {
setSanctions([]);
setLoading(false);
return;
}
try {
const r = await SanctionsService.getMySanctions();
setSanctions(Array.isArray(r?.sanctions) ? r.sanctions : []);
} catch (_) {
setSanctions([]);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
reload();
}, [reload]);
// Перезагрузка при изменении токена (login/logout)
useEffect(() => {
const onStorage = (e) => {
if (e.key === 'Authorization') reload();
};
window.addEventListener('storage', onStorage);
return () => window.removeEventListener('storage', onStorage);
}, [reload]);
// Мут на текст: user_timed_ban или chat_ban или comment_ban
const muteInfo = sanctions.find((s) =>
['user_timed_ban', 'chat_ban', 'comment_ban'].includes(s.type)
) || null;
const isMuted = !!muteInfo;
// Запрет публикации: user_timed_ban или publish_ban
const cantPublishInfo = sanctions.find((s) =>
['user_timed_ban', 'publish_ban'].includes(s.type)
) || null;
const isCantPublish = !!cantPublishInfo;
return (
<SanctionsContext.Provider value={{
sanctions, isMuted, isCantPublish,
muteInfo, cantPublishInfo,
loading, reload,
}}>
{children}
</SanctionsContext.Provider>
);
}
export function useSanctions() {
return useContext(SanctionsContext);
}

View File

@ -0,0 +1,138 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import RublocsLogo from '../../components/RublocsLogo/RublocsLogo';
import Icon from '../KubikonEditor/Icon';
/**
* Заглушка «только для компьютера» для редактора Рублокса.
*
* Используется в KubikonStudio / KubikonEditor / KubikonDocs, когда
* пользователь зашёл с телефона или планшета: 3D-редактор требует мышь
* и клавиатуру и не адаптирован под тач.
*
* Плеер опубликованных игр (`/kubikon/play/:id`) поддерживает тач
* на него ведёт кнопка «Играть в опубликованные игры».
*/
const KubikonDesktopOnlyStub = ({ feature = 'Редактор Рублокса' }) => {
const navigate = useNavigate();
return (
<div style={{
minHeight: '100vh',
background: 'linear-gradient(160deg, #0a0e1a 0%, #1a1f3a 100%)',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
padding: '20px',
color: '#f1f5fb',
fontFamily: '"Roboto Condensed", system-ui, sans-serif',
}}>
{/* Фиксированный top-bar — кнопка возврата в школу */}
<div style={{
width: '100%',
maxWidth: 460,
marginBottom: 16,
display: 'flex',
justifyContent: 'flex-start',
}}>
<button
onClick={() => navigate('/')}
style={{
background: 'rgba(255, 255, 255, 0.08)',
color: '#f1f5fb',
border: '1px solid rgba(255, 255, 255, 0.18)',
borderRadius: 999,
padding: '8px 14px',
fontSize: 13,
fontWeight: 600,
cursor: 'pointer',
whiteSpace: 'nowrap',
}}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
<Icon name="arrow-left" size={14} /> Майнкрафтия
</span>
</button>
</div>
<div style={{
flex: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '100%',
}}>
<div style={{
maxWidth: 460,
width: '100%',
background: 'rgba(20, 24, 45, 0.94)',
border: '1px solid rgba(255, 255, 255, 0.10)',
borderRadius: 20,
padding: 32,
textAlign: 'center',
boxShadow: '0 20px 60px rgba(0, 0, 0, 0.45)',
}}>
<div style={{ display: 'flex', justifyContent: 'center', marginBottom: 18 }}>
<RublocsLogo size={64} />
</div>
<div style={{ display: 'flex', justifyContent: 'center', marginBottom: 14, color: '#5470ff' }}>
<Icon name="monitor" size={60} strokeWidth={1.5} />
</div>
<h1 style={{
fontSize: 24,
fontWeight: 800,
margin: '0 0 12px',
color: '#f1f5fb',
}}>
Только для компьютера
</h1>
<p style={{
fontSize: 16,
lineHeight: 1.5,
color: 'rgba(241, 245, 251, 0.72)',
margin: '0 0 22px',
}}>
{feature} требует мышь и клавиатуру. Открой Рублокс с компьютера или ноутбука, чтобы создавать свои 3D-игры.
</p>
<div style={{
display: 'flex',
flexDirection: 'column',
gap: 10,
marginTop: 18,
}}>
<button
onClick={() => navigate('/kubikon')}
style={{
background: 'linear-gradient(135deg, #3357ff 0%, #1e2da5 100%)',
color: '#fff',
border: 'none',
borderRadius: 12,
padding: '14px 18px',
fontSize: 16,
fontWeight: 700,
cursor: 'pointer',
boxShadow: '0 8px 24px rgba(51, 87, 255, 0.35)',
}}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
<Icon name="gamepad" size={16} /> Играть в опубликованные игры
</span>
</button>
<button
onClick={() => navigate('/')}
style={{
background: 'transparent',
color: 'rgba(241, 245, 251, 0.72)',
border: '1px solid rgba(255, 255, 255, 0.18)',
borderRadius: 12,
padding: '12px 18px',
fontSize: 15,
fontWeight: 600,
cursor: 'pointer',
}}>
На главную школы
</button>
</div>
</div>
</div>
</div>
);
};
export default KubikonDesktopOnlyStub;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,468 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import cl from './KubikonStudio.module.css';
import RublocsLogo from '../../components/RublocsLogo/RublocsLogo';
import useDeviceType from '../../hooks/useDeviceType';
import KubikonDesktopOnlyStub from './KubikonDesktopOnlyStub';
import Icon from '../KubikonEditor/Icon';
/**
* KubikonHeroKit кит из 10 вариантов hero-блока для главной Studio.
* URL: /kubikon-studio/hero-kit
*
* В оригинальном Roblox Studio на главной крупное изображение игры.
* Здесь 10 вариантов такого hero-блока: каждый реальный чистый
* скриншот игры Рублокса (снят dev-tools/wiki-shots/shoot-hero.js,
* 1920×800, без HUD) + своя композиция текста/кнопки/оверлея.
*
* Пользователь смотрит кит, выбирает номер выбранный вариант
* переносится на главную Studio (секция HERO в KubikonStudio.jsx).
*
* Картинки: public/wiki/hero/hero-<id>.png
*/
// 10 вариантов hero-блока
// img файл кадра из public/wiki/hero/
// title крупный заголовок
// desc подзаголовок
// btn текст кнопки
// align 'left' | 'center' расположение текстового блока
// overlay тип затемнения под текстом для читаемости:
// 'left-dark' (градиент слева), 'bottom-dark' (снизу),
// 'full-soft' (лёгкое сплошное), 'panel' (карточка-плашка)
// theme 'light' (белый текст) все кадры тёмные/яркие
const VARIANTS = [
{
id: 1, img: 'hero-1.png', align: 'left', overlay: 'left-dark',
title: 'Создай целый мир',
desc: 'Холмы, леса, реки — построй свой 3D-мир и пригласи друзей. Всё начинается с пустого холста.',
btn: '+ Новая игра',
},
{
id: 2, img: 'hero-79.png', align: 'left', overlay: 'left-dark',
title: 'Твоя игра — твои правила',
desc: 'Паркур, корабли, порталы, радуги. Собери игру мечты и опубликуй её в ленте Рублокса.',
btn: 'Начать создавать',
},
{
id: 3, img: 'hero-82.png', align: 'center', overlay: 'full-soft',
title: 'Построй. Запусти. Поделись.',
desc: 'От первого кубика до целой игры с порталами и механиками — в одном редакторе.',
btn: '+ Новая игра',
},
{
id: 4, img: 'hero-87.png', align: 'left', overlay: 'bottom-dark',
title: 'Приключения ждут',
desc: 'Лава, обрывы, ловушки — придумай уровень, который не пройти с первого раза.',
btn: 'Создать игру',
},
{
id: 5, img: 'hero-226.png', align: 'left', overlay: 'left-dark',
title: 'Создавай большие игры',
desc: 'Замки, подземелья, целые королевства. Редактор Рублокса справится с любой задумкой.',
btn: '+ Новая игра',
},
{
id: 6, img: 'hero-86.png', align: 'panel', overlay: 'panel',
title: 'Сделай свою игру',
desc: 'Выбери шаблон или начни с нуля. Опубликуй — и в неё будут играть тысячи.',
btn: '+ Новая игра',
},
{
id: 7, img: 'hero-62.png', align: 'center', overlay: 'full-soft',
title: 'Парящие острова и не только',
desc: 'Размести блоки в воздухе, собери паркур, добавь скрипты — всё возможно.',
btn: 'Начать',
},
{
id: 8, img: 'hero-80.png', align: 'left', overlay: 'left-dark',
title: 'Выше облаков',
desc: 'Построй небесный мир и проложи маршрут среди облаков. Твоя фантазия — без границ.',
btn: '+ Новая игра',
},
{
id: 9, img: 'hero-84.png', align: 'left', overlay: 'bottom-dark',
title: 'Тайны подземелий',
desc: 'Тёмные коридоры, секретные комнаты, ловушки. Создай игру, в которой страшно и интересно.',
btn: 'Создать игру',
},
{
id: 10, img: 'hero-45.png', align: 'panel', overlay: 'panel',
title: 'Арена для твоих игр',
desc: 'Гонки, битвы, испытания на время. Собери арену и зови друзей соревноваться.',
btn: '+ Новая игра',
},
];
// Один hero-вариант рендерит готовый блок ровно так, как он
// будет выглядеть на главной Studio.
const HeroPreview = ({ v }) => {
const overlayCls = `hkOverlay hkOverlay--${v.overlay}`;
return (
<div className="hkHero">
<img className="hkHeroImg" src={`/wiki/hero/${v.img}`} alt={v.title} />
<div className={overlayCls} />
<div className={`hkHeroInner hkHeroInner--${v.align}`}>
<div className="hkHeroBox">
<div className="hkBadge">NEW</div>
<h2 className="hkTitle">{v.title}</h2>
<p className="hkDesc">{v.desc}</p>
<span className="hkBtn">{v.btn}</span>
</div>
</div>
</div>
);
};
const KubikonHeroKit = () => {
const navigate = useNavigate();
const { isDesktop } = useDeviceType();
const [chosen, setChosen] = useState(null);
if (!isDesktop) {
return <KubikonDesktopOnlyStub feature="Кит hero-блоков Рублокс" />;
}
return (
<div className={cl.studio}>
<style>{INLINE_STYLES}</style>
{/* === Левая боковая панель === */}
<aside className={cl.sidebar}>
<div className={cl.sidebarHeader}>
<div className={cl.sidebarLogo}>
<RublocsLogo size={44} bg="#16181d" />
</div>
<div className={cl.sidebarTitle}>
<div className={cl.brandName}>Рублокс</div>
<div className={cl.brandSub}>Студия</div>
</div>
</div>
<nav className={cl.sidebarNav}>
<button
className={cl.navNewGame}
onClick={() => navigate('/kubikon-editor/new')}
>
<span className={cl.navNewGameIco}><Icon name="plus" size={15} /></span>
<span>Новая игра</span>
</button>
<button className={cl.navItem} onClick={() => navigate('/kubikon-studio?tab=recent')}>
<span className={cl.navIcon}><Icon name="clock" size={15} /></span>
<span>История</span>
</button>
<button className={cl.navItem} onClick={() => navigate('/kubikon-studio?tab=home')}>
<span className={cl.navIcon}><Icon name="home" size={15} /></span>
<span>Главная</span>
</button>
<button className={cl.navItem} onClick={() => navigate('/kubikon-studio?tab=my')}>
<span className={cl.navIcon}><Icon name="folder" size={15} /></span>
<span>Мои игры</span>
</button>
<button className={cl.navItem} onClick={() => navigate('/kubikon-studio?tab=templates')}>
<span className={cl.navIcon}><Icon name="grid" size={15} /></span>
<span>Шаблоны</span>
</button>
<button className={cl.navItem} onClick={() => navigate('/kubikon-studio?tab=archive')}>
<span className={cl.navIcon}><Icon name="archive" size={15} /></span>
<span>Архив</span>
</button>
<button className={cl.navItem} onClick={() => navigate('/kubikon-studio/docs')}>
<span className={cl.navIcon}><Icon name="book-open" size={15} /></span>
<span>ВИКИ</span>
</button>
</nav>
<div className={cl.sidebarFooter}>
<button
className={cl.docsBtn}
style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6 }}
onClick={() => navigate('/kubikon-studio/rules')}
>
<Icon name="shield" size={15} /> Правила создания игр
</button>
</div>
</aside>
{/* === Основной контент === */}
<main className={cl.main}>
<header className={cl.topBar}>
<div>
<h1 className={cl.pageTitle}>
<Icon name="image" size={13} /> Кит hero-блоков
</h1>
<p className={cl.pageSub}>
10 вариантов главного баннера Studio выбери тот, что нравится
</p>
</div>
<div className={cl.topBarActions}>
<button className={cl.searchBox} onClick={() => navigate('/kubikon-studio')}>
В Studio
</button>
</div>
</header>
<div className="hkIntro">
Каждый блок ниже реальный кадр игры Рублокса. Это варианты
того, как будет выглядеть главный баннер на странице Studio
(вместо нынешнего с кубиками). Посмотри и скажи мне номер
вставлю выбранный на главную.
</div>
{/* 10 вариантов */}
{VARIANTS.map((v) => (
<section
key={v.id}
className={'hkCard' + (chosen === v.id ? ' hkCard--chosen' : '')}
>
<div className="hkCardHead">
<span className="hkNum">Вариант {v.id}</span>
<button
className={'hkPick' + (chosen === v.id ? ' hkPick--on' : '')}
onClick={() => setChosen(chosen === v.id ? null : v.id)}
>
{chosen === v.id
? <><Icon name="check" size={13} /> Этот нравится</>
: 'Мне нравится этот'}
</button>
</div>
<HeroPreview v={v} />
</section>
))}
{/* Подсказка о выборе */}
<div className="hkFooter">
{chosen ? (
<>
<div className="hkFooterIco"><Icon name="check" size={22} /></div>
<div>
<b>Отмечен вариант {chosen}.</b> Напиши мне в чат
«ставь вариант {chosen}» и я вставлю его на главную
страницу Studio.
</div>
</>
) : (
<>
<div className="hkFooterIco hkFooterIco--idle">
<Icon name="image" size={22} />
</div>
<div>
Отметь понравившийся вариант кнопкой «Мне нравится
этот» или просто напиши мне его номер в чат.
</div>
</>
)}
</div>
</main>
</div>
);
};
//
// Инлайн-стили
//
const INLINE_STYLES = `
.hkIntro {
background: #eef2ff;
border: 1px solid #c7d2fe;
border-radius: 14px;
padding: 14px 18px;
margin-bottom: 22px;
font-size: 14px;
line-height: 1.6;
color: #3730a3;
}
/* === Карточка варианта === */
.hkCard {
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 20px;
padding: 14px;
margin-bottom: 20px;
box-shadow: 0 4px 16px rgba(15, 23, 42, 0.05);
transition: border-color 180ms ease, box-shadow 180ms ease;
}
.hkCard--chosen {
border-color: #16a34a;
box-shadow: 0 8px 28px rgba(22, 163, 74, 0.18);
}
.hkCardHead {
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 6px 12px;
}
.hkNum {
font-size: 14px;
font-weight: 900;
color: #0f172a;
letter-spacing: -0.2px;
}
.hkPick {
display: inline-flex;
align-items: center;
gap: 6px;
background: #f1f5f9;
border: 1px solid #e2e8f0;
color: #475569;
font-size: 12.5px;
font-weight: 800;
padding: 7px 14px;
border-radius: 50px;
cursor: pointer;
font-family: inherit;
transition: all 160ms ease;
}
.hkPick:hover { background: #e2e8f0; }
.hkPick--on, .hkPick--on:hover {
background: #16a34a;
border-color: #16a34a;
color: #fff;
}
/* === Hero-блок (то, что встанет на главную) === */
.hkHero {
position: relative;
width: 100%;
aspect-ratio: 1920 / 640;
border-radius: 16px;
overflow: hidden;
background: #1e293b;
}
.hkHeroImg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
/* оверлеи для читаемости текста */
.hkOverlay { position: absolute; inset: 0; pointer-events: none; }
.hkOverlay--left-dark {
background: linear-gradient(100deg,
rgba(8, 11, 22, 0.86) 0%,
rgba(8, 11, 22, 0.62) 38%,
rgba(8, 11, 22, 0.06) 66%,
transparent 100%);
}
.hkOverlay--bottom-dark {
background: linear-gradient(to top,
rgba(8, 11, 22, 0.90) 0%,
rgba(8, 11, 22, 0.45) 36%,
transparent 70%);
}
.hkOverlay--full-soft {
background: radial-gradient(ellipse at center,
rgba(8, 11, 22, 0.30) 0%,
rgba(8, 11, 22, 0.66) 100%);
}
.hkOverlay--panel {
background: linear-gradient(90deg,
rgba(8, 11, 22, 0.20) 0%, transparent 55%);
}
/* контейнер текста */
.hkHeroInner {
position: absolute;
inset: 0;
display: flex;
align-items: center;
padding: 0 56px;
z-index: 2;
}
.hkHeroInner--left { justify-content: flex-start; }
.hkHeroInner--center { justify-content: center; text-align: center; }
.hkHeroInner--panel { justify-content: flex-start; }
.hkHeroBox { max-width: 560px; }
.hkHeroInner--center .hkHeroBox { max-width: 680px; }
/* вариант 'panel' — текст в полупрозрачной карточке */
.hkHeroInner--panel .hkHeroBox {
max-width: 480px;
background: rgba(12, 16, 28, 0.74);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 18px;
padding: 28px 30px;
}
.hkBadge {
display: inline-flex;
align-items: center;
background: rgba(255, 255, 255, 0.18);
border: 1px solid rgba(255, 255, 255, 0.34);
color: #fff;
padding: 4px 13px;
border-radius: 999px;
font-size: 10.5px;
font-weight: 800;
letter-spacing: 1.5px;
text-transform: uppercase;
margin-bottom: 14px;
}
.hkHeroInner--center .hkBadge { margin-left: auto; margin-right: auto; }
.hkTitle {
margin: 0 0 12px;
font-size: 38px;
line-height: 1.08;
font-weight: 900;
color: #fff;
letter-spacing: -1px;
text-shadow: 0 2px 18px rgba(0, 0, 0, 0.55);
}
.hkDesc {
margin: 0 0 22px;
font-size: 15.5px;
line-height: 1.55;
color: rgba(255, 255, 255, 0.92);
text-shadow: 0 1px 10px rgba(0, 0, 0, 0.5);
}
.hkBtn {
display: inline-block;
padding: 13px 26px;
background: #fff;
color: #3357ff;
border-radius: 12px;
font-size: 14.5px;
font-weight: 900;
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.3);
letter-spacing: 0.2px;
}
/* === Подвал === */
.hkFooter {
display: flex;
align-items: center;
gap: 14px;
background: #f0fdf4;
border: 1px solid #bbf7d0;
border-radius: 16px;
padding: 18px 22px;
margin-top: 8px;
font-size: 14px;
color: #14532d;
line-height: 1.55;
}
.hkFooterIco {
flex-shrink: 0;
width: 44px; height: 44px;
border-radius: 12px;
background: #16a34a;
color: #fff;
display: flex; align-items: center; justify-content: center;
}
.hkFooterIco--idle { background: #94a3b8; }
/* Адаптив — на узких экранах текст крупного hero уменьшаем */
@media (max-width: 1100px) {
.hkHeroInner { padding: 0 32px; }
.hkTitle { font-size: 30px; }
.hkDesc { font-size: 14px; }
}
`;
export default KubikonHeroKit;

View File

@ -0,0 +1,563 @@
import React, { useState, useEffect, useRef } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import cl from './KubikonStudio.module.css';
import RublocsLogo from '../../components/RublocsLogo/RublocsLogo';
import useDeviceType from '../../hooks/useDeviceType';
import KubikonDesktopOnlyStub from './KubikonDesktopOnlyStub';
import Icon from '../KubikonEditor/Icon';
import DocIcon from './docsIcons';
import { ARTICLES, getArticle } from './learnArticles';
/**
* KubikonLearn раздел «Изучай Студию» Рублокс Студии.
* URL: /kubikon-studio/learn список статей
* /kubikon-studio/learn/<id> конкретная статья
*
* Статьи (контент) в learnArticles.jsx. Превью карточек
* public/assets/kubikon-learn/<cover> (скриншоты редактора).
* Стиль/layout повторяют KubikonRules.jsx своя боковая панель Studio.
*/
const KubikonLearn = () => {
const navigate = useNavigate();
const { articleId } = useParams();
const { isDesktop } = useDeviceType();
const mainRef = useRef(null);
const article = articleId ? getArticle(articleId) : null;
// при открытии статьи / возврате к списку скролл вверх
useEffect(() => {
if (mainRef.current) mainRef.current.scrollTo({ top: 0 });
}, [articleId]);
if (!isDesktop) {
return <KubikonDesktopOnlyStub feature="Изучай Студию Рублокс" />;
}
return (
<div className={cl.studio}>
<style>{INLINE_STYLES}</style>
{/* === Левая боковая панель Studio === */}
<aside className={cl.sidebar}>
<div className={cl.sidebarHeader}>
<div className={cl.sidebarLogo}>
<RublocsLogo size={44} bg="#16181d" />
</div>
<div className={cl.sidebarTitle}>
<div className={cl.brandName}>Рублокс</div>
<div className={cl.brandSub}>Студия</div>
</div>
</div>
<nav className={cl.sidebarNav}>
<button
className={cl.navNewGame}
onClick={() => navigate('/kubikon-editor/new')}
>
<span className={cl.navNewGameIco}><Icon name="plus" size={15} /></span>
<span>Новая игра</span>
</button>
<button className={cl.navItem} onClick={() => navigate('/kubikon-studio?tab=recent')}>
<span className={cl.navIcon}><Icon name="clock" size={15} /></span>
<span>История</span>
</button>
<button className={cl.navItem} onClick={() => navigate('/kubikon-studio?tab=home')}>
<span className={cl.navIcon}><Icon name="home" size={15} /></span>
<span>Главная</span>
</button>
<button className={cl.navItem} onClick={() => navigate('/kubikon-studio?tab=my')}>
<span className={cl.navIcon}><Icon name="folder" size={15} /></span>
<span>Мои игры</span>
</button>
<button className={cl.navItem} onClick={() => navigate('/kubikon-studio?tab=templates')}>
<span className={cl.navIcon}><Icon name="grid" size={15} /></span>
<span>Шаблоны</span>
</button>
<button className={cl.navItem} onClick={() => navigate('/kubikon-studio?tab=archive')}>
<span className={cl.navIcon}><Icon name="archive" size={15} /></span>
<span>Архив</span>
</button>
<button className={cl.navItem} onClick={() => navigate('/kubikon-studio/docs')}>
<span className={cl.navIcon}><Icon name="book-open" size={15} /></span>
<span>ВИКИ</span>
</button>
</nav>
<div className={cl.sidebarFooter}>
<button
className={cl.docsBtn}
style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6 }}
onClick={() => navigate('/kubikon-studio/rules')}
>
<Icon name="shield" size={15} /> Правила создания игр
</button>
</div>
</aside>
{/* === Основной контент === */}
<main className={cl.main} ref={mainRef}>
<header className={cl.topBar}>
<div>
<h1 className={cl.pageTitle}>
<Icon name="book-open" size={13} />{' '}
{article ? article.title : 'Изучай Студию'}
</h1>
<p className={cl.pageSub}>
{article
? 'Статья — Изучай Студию'
: 'Уроки и материалы, чтобы быстро освоиться в Рублокс Студии'}
</p>
</div>
<div className={cl.topBarActions}>
{article ? (
<button className={cl.searchBox} onClick={() => navigate('/kubikon-studio/learn')}>
Ко всем статьям
</button>
) : (
<button className={cl.searchBox} onClick={() => navigate('/kubikon-studio')}>
В Studio
</button>
)}
</div>
</header>
{/* ── СТРАНИЦА СТАТЬИ ── */}
{article && (
<article className="lrnArticle">
<div
className="lrnArticleHero"
style={{ background: `linear-gradient(135deg, ${article.color} 0%, #15151c 130%)` }}
>
<div className="lrnArticleHero__ico">
<DocIcon name={article.icon} size={56} />
</div>
<div>
<div className="lrnArticleHero__badge">Изучай Студию</div>
<h2 className="lrnArticleHero__title">{article.title}</h2>
<p className="lrnArticleHero__sum">{article.summary}</p>
</div>
</div>
<div className="lrnArticleBody">{article.body}</div>
{/* навигация: следующая статья */}
<ArticleFooter currentId={article.id} navigate={navigate} />
</article>
)}
{/* ── СПИСОК СТАТЕЙ ── */}
{!article && (
<>
<section className="lrnHero">
<div className="lrnHero__content">
<div className="lrnHero__badge">
<DocIcon name="rocket" size={13} /> Изучай Студию
</div>
<h2 className="lrnHero__title">Освойся в Рублокс Студии</h2>
<p className="lrnHero__desc">
Короткие статьи о главном: как загружать свои
модели, чем отличаются плееры, как писать
скрипты и публиковать игры. Начни с любой.
</p>
</div>
<div className="lrnHero__emoji"><DocIcon name="book" size={84} /></div>
</section>
<div className="lrnGrid">
{ARTICLES.map((a) => (
<button
key={a.id}
className="lrnCard"
onClick={() => navigate('/kubikon-studio/learn/' + a.id)}
>
<div className="lrnCard__cover">
{/* запасной фон ПОД картинкой (если скрин
не загрузился он останется виден) */}
<div
className="lrnCard__fallback"
style={{ background: `linear-gradient(135deg, ${a.color} 0%, #15151c 130%)` }}
>
<DocIcon name={a.icon} size={42} />
</div>
<img
className="lrnCard__img"
src={`/assets/kubikon-learn/${a.cover}`}
alt={a.title}
loading="lazy"
onError={(e) => { e.currentTarget.style.display = 'none'; }}
/>
<span className="lrnCard__chip" style={{ background: a.color }}>
<DocIcon name={a.icon} size={15} />
</span>
</div>
<div className="lrnCard__body">
<div className="lrnCard__title">{a.title}</div>
<div className="lrnCard__sum">{a.summary}</div>
<div className="lrnCard__more">Читать </div>
</div>
</button>
))}
</div>
</>
)}
</main>
</div>
);
};
// Подвал статьи кнопка к следующей статье по кругу.
const ArticleFooter = ({ currentId, navigate }) => {
const idx = ARTICLES.findIndex((a) => a.id === currentId);
const next = ARTICLES[(idx + 1) % ARTICLES.length];
return (
<div className="lrnNext">
<div className="lrnNext__label">Следующая статья</div>
<button
className="lrnNext__btn"
onClick={() => navigate('/kubikon-studio/learn/' + next.id)}
>
<span className="lrnNext__ico"><DocIcon name={next.icon} size={20} /></span>
<span>{next.title}</span>
<span className="lrnNext__arrow"></span>
</button>
</div>
);
};
//
// Инлайн-стили
//
const INLINE_STYLES = `
/* ── Hero списка ── */
.lrnHero {
background: linear-gradient(135deg, #3357ff 0%, #1e2da5 55%, #6d28d9 100%);
background-size: 200% 200%;
animation: lrnGrad 16s ease-in-out infinite;
border-radius: 28px;
padding: 36px 40px;
margin-bottom: 28px;
color: #fff;
display: flex;
align-items: center;
gap: 24px;
box-shadow: 0 24px 56px rgba(51, 87, 255, 0.28);
}
@keyframes lrnGrad {
0%, 100% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
}
.lrnHero__content { flex: 1; min-width: 0; }
.lrnHero__badge {
display: inline-flex;
align-items: center;
gap: 6px;
background: rgba(255, 255, 255, 0.18);
border: 1px solid rgba(255, 255, 255, 0.30);
color: #fff;
padding: 5px 14px;
border-radius: 999px;
font-size: 11px;
font-weight: 800;
letter-spacing: 1.4px;
text-transform: uppercase;
margin-bottom: 12px;
}
.lrnHero__title {
margin: 0 0 10px;
font-size: 32px;
font-weight: 900;
color: #fff;
letter-spacing: -1px;
line-height: 1.1;
text-shadow: 0 2px 12px rgba(0, 0, 0, 0.18);
}
.lrnHero__desc {
margin: 0;
font-size: 15px;
color: rgba(255, 255, 255, 0.94);
line-height: 1.55;
max-width: 660px;
}
.lrnHero__emoji {
flex-shrink: 0;
color: #fff;
opacity: 0.9;
filter: drop-shadow(0 12px 24px rgba(0, 0, 0, 0.2));
}
/* ── Сетка карточек статей ── */
.lrnGrid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 18px;
}
/* Карточки — светлые, в стиле вики (белый фон, тёмный текст). */
.lrnCard {
background: #ffffff;
border: 1px solid #e5e7eb;
border-radius: 16px;
overflow: hidden;
cursor: pointer;
text-align: left;
font-family: inherit;
transition: transform 180ms ease, border-color 180ms ease, box-shadow 180ms ease;
display: flex;
flex-direction: column;
}
.lrnCard:hover {
transform: translateY(-4px);
border-color: #3357ff;
box-shadow: 0 16px 36px rgba(15, 23, 42, 0.16);
}
.lrnCard__cover {
position: relative;
aspect-ratio: 16 / 9;
overflow: hidden;
}
.lrnCard__fallback {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: rgba(255, 255, 255, 0.9);
}
.lrnCard__img {
position: relative;
width: 100%;
height: 100%;
object-fit: cover;
display: block;
transition: transform 300ms ease;
}
.lrnCard:hover .lrnCard__img { transform: scale(1.05); }
.lrnCard__chip {
position: absolute;
top: 9px; left: 9px;
width: 30px; height: 30px;
border-radius: 8px;
display: flex; align-items: center; justify-content: center;
color: #fff;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.4);
}
.lrnCard__body {
padding: 14px 16px 16px;
display: flex;
flex-direction: column;
flex: 1;
}
.lrnCard__title {
font-size: 16px;
font-weight: 800;
color: #0f172a;
margin-bottom: 6px;
letter-spacing: -0.2px;
}
.lrnCard__sum {
font-size: 13px;
color: #64748b;
line-height: 1.5;
flex: 1;
margin-bottom: 12px;
}
.lrnCard__more {
font-size: 13px;
font-weight: 800;
color: #3357ff;
}
/* ── Страница статьи ── */
.lrnArticle { display: flex; flex-direction: column; gap: 16px; }
.lrnArticleHero {
display: flex;
align-items: center;
gap: 22px;
border-radius: 22px;
padding: 28px 32px;
color: #fff;
box-shadow: 0 18px 44px rgba(0, 0, 0, 0.4);
}
.lrnArticleHero__ico {
flex-shrink: 0;
width: 88px; height: 88px;
border-radius: 20px;
background: rgba(255, 255, 255, 0.14);
display: flex; align-items: center; justify-content: center;
}
.lrnArticleHero__badge {
display: inline-block;
background: rgba(255, 255, 255, 0.20);
border: 1px solid rgba(255, 255, 255, 0.30);
color: #fff;
padding: 4px 12px;
border-radius: 999px;
font-size: 10.5px;
font-weight: 800;
letter-spacing: 1.3px;
text-transform: uppercase;
margin-bottom: 10px;
}
.lrnArticleHero__title {
margin: 0 0 8px;
font-size: 30px;
font-weight: 900;
letter-spacing: -0.8px;
line-height: 1.1;
}
.lrnArticleHero__sum {
margin: 0;
font-size: 14.5px;
line-height: 1.5;
color: rgba(255, 255, 255, 0.92);
}
/* Тело статьи — светлое, в стиле вики (белый фон, тёмный текст). */
.lrnArticleBody {
background: #ffffff;
border: 1px solid #e5e7eb;
border-radius: 18px;
padding: 28px 32px;
color: #334155;
font-size: 14.5px;
line-height: 1.7;
box-shadow: 0 4px 16px rgba(15, 23, 42, 0.05);
}
.lrnArticleBody p { margin: 0 0 14px; }
.lrnArticleBody p:last-child { margin-bottom: 0; }
.lrnArticleBody ul {
margin: 0 0 14px;
padding-left: 22px;
}
.lrnArticleBody li { margin-bottom: 8px; line-height: 1.6; }
.lrnArticleBody li:last-child { margin-bottom: 0; }
.lrnArticleBody b { color: #0f172a; font-weight: 800; }
.lrnArticleBody code {
background: #e0e8ff;
color: #3357ff;
padding: 2px 7px;
border-radius: 6px;
font-family: Consolas, Menlo, "Courier New", monospace;
font-size: 13px;
font-weight: 700;
}
/* подзаголовок в статье */
.lrnH {
margin: 24px 0 12px;
font-size: 19px;
font-weight: 900;
color: #0f172a;
letter-spacing: -0.3px;
padding-bottom: 8px;
border-bottom: 1px solid #eef2f7;
}
.lrnArticleBody > :first-child { margin-top: 0; }
/* шаг инструкции */
.lrnStep {
display: flex;
gap: 12px;
align-items: flex-start;
margin: 0 0 12px;
}
.lrnStep__n {
flex-shrink: 0;
width: 26px; height: 26px;
border-radius: 50%;
background: #3357ff;
color: #fff;
font-size: 13px;
font-weight: 900;
display: flex; align-items: center; justify-content: center;
}
.lrnStep__body { flex: 1; padding-top: 2px; }
/* плашки совет / внимание / отлично */
.lrnBox {
display: flex;
gap: 11px;
align-items: flex-start;
border-radius: 11px;
padding: 12px 14px;
margin: 14px 0;
font-size: 13.5px;
line-height: 1.6;
border: 1px solid;
border-left-width: 4px;
}
.lrnBox__ico { flex-shrink: 0; line-height: 0; margin-top: 1px; }
/* плашки — светлые, в стиле вики */
.lrnBox--tip {
background: #fffbeb;
border-color: #fde68a;
border-left-color: #f59e0b;
color: #78350f;
}
.lrnBox--tip .lrnBox__ico { color: #b45309; }
.lrnBox--warn {
background: #fef2f2;
border-color: #fecaca;
border-left-color: #ef4444;
color: #7f1d1d;
}
.lrnBox--warn .lrnBox__ico { color: #dc2626; }
.lrnBox--ok {
background: #f0fdf4;
border-color: #bbf7d0;
border-left-color: #16a34a;
color: #14532d;
}
.lrnBox--ok .lrnBox__ico { color: #16a34a; }
.lrnBox code { background: rgba(0, 0, 0, 0.06); }
/* подвал статьи — следующая */
.lrnNext {
background: #ffffff;
border: 1px solid #e5e7eb;
border-radius: 16px;
padding: 18px 22px;
box-shadow: 0 4px 16px rgba(15, 23, 42, 0.05);
}
.lrnNext__label {
font-size: 12px;
font-weight: 800;
color: #94a3b8;
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 10px;
}
.lrnNext__btn {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
background: #f1f5f9;
border: 1px solid #e5e7eb;
border-radius: 12px;
padding: 14px 16px;
cursor: pointer;
color: #0f172a;
font-family: inherit;
font-size: 15px;
font-weight: 800;
transition: all 160ms ease;
}
.lrnNext__btn:hover { border-color: #3357ff; background: #e0e8ff; }
.lrnNext__ico {
flex-shrink: 0;
width: 36px; height: 36px;
border-radius: 10px;
background: #3357ff;
color: #fff;
display: flex; align-items: center; justify-content: center;
}
.lrnNext__arrow { margin-left: auto; color: #3357ff; font-size: 18px; }
@media (max-width: 980px) {
.lrnHero, .lrnArticleHero { flex-direction: column; text-align: center; }
.lrnArticleBody { padding: 20px; }
}
`;
export default KubikonLearn;

View File

@ -0,0 +1,985 @@
import React, { useState, useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import cl from './KubikonStudio.module.css';
import RublocsLogo from '../../components/RublocsLogo/RublocsLogo';
import useDeviceType from '../../hooks/useDeviceType';
import KubikonDesktopOnlyStub from './KubikonDesktopOnlyStub';
import Icon from '../KubikonEditor/Icon';
import DocIcon from './docsIcons';
/**
* KubikonRules «Правила создания игр» платформы Рублокс.
* URL: /kubikon-studio/rules
*
* Отдельная страница: подробно расписано, какие игры можно создавать
* и публиковать, чтобы их не заблокировали и они были популярными.
* Опирается на реальную систему умной ленты Рублокса:
* - автопроверка скриптов при публикации (опасные очередь review);
* - статусы игр draft / published / review / blocked;
* - алгоритм ленты hot_score (Wilson-рейтинг + вовлечённость +
* популярность + свежесть);
* - мягкое автоскрытие (demoted) игр с низким рейтингом.
*
* Стиль и вёрстка повторяют вику (KubikonDocs.jsx): своя боковая
* панель, hero, разделы с оглавлением. Все стили в INLINE_STYLES,
* чтобы страница была самодостаточной. Иконки самописные SVG.
*/
// Маленькие хелперы разметки
// Плашка «можно» зелёная
const Ok = ({ children }) => (
<div className="ruleBox ruleBox--ok">
<span className="ruleBox__ico"><DocIcon name="flag" size={18} /></span>
<div>{children}</div>
</div>
);
// Плашка «нельзя» красная
const No = ({ children }) => (
<div className="ruleBox ruleBox--no">
<span className="ruleBox__ico"><DocIcon name="warning" size={18} /></span>
<div>{children}</div>
</div>
);
// Жёлтая плашка-подсказка
const Tip = ({ children }) => (
<div className="ruleBox ruleBox--tip">
<span className="ruleBox__ico"><DocIcon name="lightbulb" size={18} /></span>
<div>{children}</div>
</div>
);
// Карточка статуса игры
const Status = ({ color, name, children }) => (
<div className="statusRow">
<span className="statusDot" style={{ background: color }} />
<div>
<b className="statusName">{name}</b>
<span className="statusText"> {children}</span>
</div>
</div>
);
// Контент: разделы правил
// Каждый раздел: { id, icon, title, body }. body готовый JSX.
const RULES = [
{
id: 'how-publish',
icon: 'rocket',
title: 'Как игра попадает в ленту',
body: (
<>
<p>
В Рублоксе <b>нет предварительной модерации каждой игры
человеком</b>. Ты сам нажимаешь «Опубликовать» и игра
почти сразу появляется в общей ленте. Это значит свободу,
но и ответственность: правила нужно соблюдать самому.
</p>
<p>Что происходит, когда ты жмёшь «Опубликовать»:</p>
<ol>
<li>
Игра автоматически проверяется система смотрит
твои скрипты на опасный код.
</li>
<li>
Если всё чисто игра <b>сразу в ленте</b>, её видят
все игроки.
</li>
<li>
Если в скриптах нашлось что-то подозрительное игра
уходит на ручную проверку к админу (это бывает
редко). После проверки её либо публикуют, либо
отправляют на доработку.
</li>
</ol>
<Tip>
Пока игра в статусе «черновик», её видишь только ты.
Тестируй сколько угодно публикуй, только когда игра
реально готова и в неё интересно играть.
</Tip>
</>
),
},
{
id: 'statuses',
icon: 'flag',
title: 'Статусы игры — что они значат',
body: (
<>
<p>У каждой твоей игры есть статус. Вот что они означают:</p>
<div className="statusList">
<Status color="#94a3b8" name="Черновик">
игра ещё не опубликована, её видишь только ты.
Можно свободно тестировать и переделывать.
</Status>
<Status color="#16a34a" name="Опубликована">
игра в общей ленте, в неё играют все. Главное
состояние успешной игры.
</Status>
<Status color="#f59e0b" name="На проверке">
в игре нашли подозрительные скрипты, её смотрит
админ. Пока проверяет игра не в ленте.
</Status>
<Status color="#dc2626" name="Заблокирована">
игра нарушила правила. Её убрали из ленты. Причину
админ пишет в комментарии исправь и можно
опубликовать заново.
</Status>
</div>
<p>
Отдельно есть «мягкое скрытие»: даже опубликованная игра
может <b>выпасть из ленты</b>, если в неё совсем не
играют или ставят сплошные дизлайки. Она не блокируется
её по-прежнему можно найти поиском и открыть по ссылке,
но в рекомендациях её не показывают. Подробнее в
разделе «Как стать популярным».
</p>
</>
),
},
{
id: 'allowed',
icon: 'sparkles',
title: 'Какие игры можно публиковать',
body: (
<>
<p>
Рублокс площадка для <b>детей и подростков</b>. Можно
публиковать любые игры, в которые было бы не стыдно
поиграть на уроке. Примеры хороших жанров:
</p>
<Ok>
<b>Платформеры и паркур</b> прыгай по платформам,
доберись до финиша, собирай монетки.
</Ok>
<Ok>
<b>Гонки и аркады</b> проедь трассу на время, обгони
соперников, уворачивайся от препятствий.
</Ok>
<Ok>
<b>Головоломки и квесты</b> лабиринты, кнопки и двери,
логические загадки.
</Ok>
<Ok>
<b>Тиры и «выживалки»</b> стрельба по мишеням,
мультяшные зомби, защита базы. Бой допустим, если он
мультяшный и без крови.
</Ok>
<Ok>
<b>Симуляторы и «тайкуны»</b> построй город, ферму,
магазин; собирай ресурсы, прокачивай постройки.
</Ok>
<Ok>
<b>Ролевые игры и приключения</b> исследуй мир,
говори с NPC, выполняй задания.
</Ok>
<Ok>
<b>Мини-игры и обучалки</b> викторины, игры на
реакцию, игры, которые чему-то учат.
</Ok>
<Tip>
Не знаешь, с чего начать открой вику, раздел «50
игр-уроков». Там готовые игры разных жанров с пошаговыми
инструкциями: бери за основу и делай свою.
</Tip>
</>
),
},
{
id: 'forbidden-content',
icon: 'warning',
title: 'Что публиковать запрещено — контент',
body: (
<>
<p>
Игру <b>заблокируют</b>, если в ней есть что-то из
списка ниже. Это правила про содержание игры текст,
картинки, название, тему.
</p>
<No>
<b>Жестокость и кровь.</b> Реалистичное насилие, кровь,
расчленёнка, сцены смерти людей. Мультяшный «бой» без
крови можно, реализм нельзя.
</No>
<No>
<b>Взрослый контент.</b> Любая эротика, намёки на секс,
раздетые персонажи. Площадка детская это под строгим
запретом.
</No>
<No>
<b>Мат и оскорбления.</b> Нецензурная брань в названии,
описании, в текстах внутри игры, на табличках и в
репликах NPC.
</No>
<No>
<b>Вражда и травля.</b> Оскорбление людей по
национальности, религии, полу; призывы кого-то
травить; буллинг конкретных игроков.
</No>
<No>
<b>Опасное и противозаконное.</b> Пропаганда наркотиков,
алкоголя, курения, оружия; инструкции, как навредить
себе или другим.
</No>
<No>
<b>Страшный «шок-контент».</b> Скример-игры, цель
которых напугать; крик и страшные лица «в лоб».
Лёгкая «страшилка» в мультяшном стиле можно.
</No>
<No>
<b>Реклама и обман.</b> Реклама посторонних сайтов,
«накрутка», просьбы прислать деньги или данные аккаунта,
фейковые «розыгрыши».
</No>
<No>
<b>Чужое.</b> Нельзя выдавать чужую игру за свою.
Скопировал чужую игру целиком и опубликовал как свою
это нарушение.
</No>
<Tip>
Простое правило: если ты не показал бы эту игру учителю
или родителям её не стоит публиковать.
</Tip>
</>
),
},
{
id: 'forbidden-scripts',
icon: 'shield',
title: 'Что запрещено в скриптах',
body: (
<>
<p>
Скрипты в игре выполняются в <b>песочнице</b> у них нет
доступа к «настоящему» интернету и к чужим данным. При
публикации система автоматически проверяет код. Если она
находит опасные команды игра уходит на ручную проверку.
</p>
<p>В скриптах нельзя использовать:</p>
<No>
<b>Выполнение «кода из строки»</b> <code>eval</code>,
{' '}<code>Function(...)</code>. Так прячут вредоносный
код, поэтому это запрещено.
</No>
<No>
<b>Сетевые запросы</b> <code>fetch</code>,
{' '}<code>XMLHttpRequest</code>, WebSocket к чужим
адресам. Игра не должна сама лазить в интернет.
</No>
<No>
<b>Доступ к странице браузера</b> <code>window</code>,
{' '}<code>document</code>, <code>localStorage</code>,
{' '}<code>cookie</code>. Скрипт игры работает только с
игровым миром через <code>game.*</code>, не со страницей.
</No>
<No>
<b>Попытки «зависить» игру нарочно</b> бесконечные
циклы без выхода, создание миллионов объектов, чтобы
у других всё затормозило.
</No>
<Tip>
Всё, что нужно для игры, уже есть в безопасном API
{' '}<code>game.*</code>: движение игрока, спавн
объектов, очки, звуки, интерфейс. Полный список в вике,
раздел про скрипты. Если пишешь игру по урокам из вики
запрещённый код туда просто не попадёт.
</Tip>
<p>
Важно: автопроверка не наказывает за случайность. Если
игра честная, а слово просто совпало админ увидит это
при ручной проверке и опубликует игру.
</p>
</>
),
},
{
id: 'reports',
icon: 'bug',
title: 'Жалобы игроков и блокировки',
body: (
<>
<p>
Под каждой игрой есть кнопка <b>«Пожаловаться»</b>. Если
игроки массово жалуются на твою игру её посмотрит
админ. Найдёт нарушение заблокирует.
</p>
<p>За что чаще всего прилетает жалоба и блокировка:</p>
<ul>
<li>в игре мат, грубость или обидные надписи;</li>
<li>
игра пустышка: зашёл, а играть не во что (нет цели,
нет геймплея);
</li>
<li>
название и обложка обещают одно, а в игре совсем
другое (обман игрока);
</li>
<li>игра намеренно тормозит или «вылетает»;</li>
<li>это копия чужой игры.</li>
</ul>
<p>Если твою игру заблокировали:</p>
<ol>
<li>прочитай комментарий админа там написана причина;</li>
<li>исправь то, на что указали;</li>
<li>опубликуй игру заново.</li>
</ol>
<No>
Не пытайся обойти блокировку: переименовать игру и залить
то же самое снова, спамить одинаковыми играми, делать
несколько аккаунтов. За это блокируют уже не игру, а
аккаунт.
</No>
</>
),
},
{
id: 'popular',
icon: 'trophy',
title: 'Как сделать игру популярной',
body: (
<>
<p>
В ленте Рублокса игры показываются не по порядку, а по
«рейтингу интереса». Чем интереснее игра, тем выше она в
рекомендациях. На рейтинг влияют четыре вещи:
</p>
<div className="factorList">
<div className="factorRow">
<span className="factorIco"><DocIcon name="star" size={18} /></span>
<div>
<b>Лайки и дизлайки.</b> Игроки голосуют после
игры. Важно не само число лайков, а их доля:
маленькая честная игра с одними лайками
обгоняет большую с дизлайками.
</div>
</div>
<div className="factorRow">
<span className="factorIco"><DocIcon name="run" size={18} /></span>
<div>
<b>Сколько в неё играют.</b> Чем больше игрок
проводит времени в игре и не закрывает её через
10 секунд тем выше рейтинг.
</div>
</div>
<div className="factorRow">
<span className="factorIco"><DocIcon name="globeIcon" size={18} /></span>
<div>
<b>Количество запусков.</b> Сколько раз игру
вообще открыли. Популярные игры поднимаются выше.
</div>
</div>
<div className="factorRow">
<span className="factorIco"><DocIcon name="sparkles" size={18} /></span>
<div>
<b>Свежесть.</b> Новым играм лента даёт «фору»
их какое-то время показывают чаще, чтобы их
успели заметить.
</div>
</div>
</div>
<p>
Что реально делать, чтобы игра нравилась людям:
</p>
<Tip>
<b>Дай игроку понятную цель.</b> С первых секунд должно
быть ясно: куда идти и что делать. «Добеги до флага»,
«собери 10 монет», «победи всех зомби».
</Tip>
<Tip>
<b>Сделай красивую обложку и честное название.</b> По
карточке игрок решает, заходить ли. Обложка должна
показывать саму игру, а не что-то постороннее.
</Tip>
<Tip>
<b>Добавь звук.</b> Игра без звука кажется «мёртвой».
Звук на прыжок, на монетку, на победу и проигрыш и
игра сразу живее.
</Tip>
<Tip>
<b>Сделай так, чтобы было не слишком сложно и не слишком
скучно.</b> Начало лёгкое, дальше постепенно сложнее.
Если игрок умирает на первой секунде, он закроет игру.
</Tip>
<Tip>
<b>Проверь игру перед публикацией.</b> Пройди её сам от
начала до конца. Нет ли мест, где застреваешь, не падаешь
ли сквозь пол, доходит ли дело до победы.
</Tip>
<Tip>
<b>Обновляй игру.</b> Чини баги, добавляй уровни. Лента
любит игры, которые живут и развиваются.
</Tip>
<No>
Не пытайся «накрутить» рейтинг: лайкать свою игру с
кучи аккаунтов, просить друзей спамить запуски. Система
это замечает, и игра наоборот падает в ленте.
</No>
</>
),
},
{
id: 'checklist',
icon: 'target',
title: 'Чек-лист перед публикацией',
body: (
<>
<p>
Пройдись по списку перед тем, как нажать «Опубликовать».
Если на все пункты ответ «да» игру можно публиковать.
</p>
<div className="checkList">
{[
'В игре есть понятная цель, и игрок сразу её понимает.',
'Игру можно пройти от начала до конца — я проверил сам.',
'Нигде нет мата, грубостей и обидных надписей.',
'Нет жестокости, крови и взрослого контента.',
'Название и обложка честно показывают, что это за игра.',
'Игра не тормозит и не вылетает.',
'В игре есть звук на главные события.',
'Это моя игра, а не копия чужой.',
'Я не использовал запрещённый код в скриптах.',
].map((t, i) => (
<div key={i} className="checkItem">
<span className="checkBox">
<Icon name="check" size={12} />
</span>
<span>{t}</span>
</div>
))}
</div>
<Tip>
Сомневаешься, можно ли публиковать игру лучше спроси
учителя или оставь её черновиком. Заблокированная игра
портит твой профиль, а исправить и переопубликовать
можно всегда.
</Tip>
</>
),
},
];
const KubikonRules = () => {
const navigate = useNavigate();
const { isDesktop } = useDeviceType();
const mainRef = useRef(null);
// активный раздел для подсветки в оглавлении
const [activeSec, setActiveSec] = useState(RULES[0].id);
// подсветка активного раздела при скролле
useEffect(() => {
const main = mainRef.current;
if (!main) return;
const onScroll = () => {
const top = main.scrollTop + 140;
let cur = RULES[0].id;
for (const r of RULES) {
const el = main.querySelector(`#rule-${r.id}`);
if (el && el.offsetTop <= top) cur = r.id;
}
setActiveSec(cur);
};
main.addEventListener('scroll', onScroll);
return () => main.removeEventListener('scroll', onScroll);
}, []);
if (!isDesktop) {
return <KubikonDesktopOnlyStub feature="Правила создания игр Рублокс" />;
}
const scrollToSec = (id) => {
const main = mainRef.current;
const el = main && main.querySelector(`#rule-${id}`);
if (el) main.scrollTo({ top: el.offsetTop - 16, behavior: 'smooth' });
};
return (
<div className={cl.studio}>
<style>{INLINE_STYLES}</style>
{/* === Левая боковая панель === */}
<aside className={cl.sidebar}>
<div className={cl.sidebarHeader}>
<div className={cl.sidebarLogo}>
<RublocsLogo size={44} bg="#16181d" />
</div>
<div className={cl.sidebarTitle}>
<div className={cl.brandName}>Рублокс</div>
<div className={cl.brandSub}>Студия</div>
</div>
</div>
{/* Меню — единое со страницей Studio (7 пунктов). */}
<nav className={cl.sidebarNav}>
<button
className={cl.navNewGame}
onClick={() => navigate('/kubikon-editor/new')}
>
<span className={cl.navNewGameIco}><Icon name="plus" size={15} /></span>
<span>Новая игра</span>
</button>
<button className={cl.navItem} onClick={() => navigate('/kubikon-studio?tab=recent')}>
<span className={cl.navIcon}><Icon name="clock" size={15} /></span>
<span>История</span>
</button>
<button className={cl.navItem} onClick={() => navigate('/kubikon-studio?tab=home')}>
<span className={cl.navIcon}><Icon name="home" size={15} /></span>
<span>Главная</span>
</button>
<button className={cl.navItem} onClick={() => navigate('/kubikon-studio?tab=my')}>
<span className={cl.navIcon}><Icon name="folder" size={15} /></span>
<span>Мои игры</span>
</button>
<button className={cl.navItem} onClick={() => navigate('/kubikon-studio?tab=templates')}>
<span className={cl.navIcon}><Icon name="grid" size={15} /></span>
<span>Шаблоны</span>
</button>
<button className={cl.navItem} onClick={() => navigate('/kubikon-studio?tab=archive')}>
<span className={cl.navIcon}><Icon name="archive" size={15} /></span>
<span>Архив</span>
</button>
<button className={cl.navItem} onClick={() => navigate('/kubikon-studio/docs')}>
<span className={cl.navIcon}><Icon name="book-open" size={15} /></span>
<span>ВИКИ</span>
</button>
</nav>
<div className={cl.sidebarFooter}>
<button
className={cl.docsBtn}
style={{
background: '#3357ff', color: '#fff', borderColor: 'transparent',
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
}}
onClick={() => navigate('/kubikon-studio/rules')}
>
<Icon name="shield" size={15} /> Правила создания игр
</button>
</div>
</aside>
{/* === Основной контент === */}
<main className={cl.main} ref={mainRef}>
<header className={cl.topBar}>
<div>
<h1 className={cl.pageTitle}>
<Icon name="shield" size={13} /> Правила создания игр
</h1>
<p className={cl.pageSub}>
Какие игры можно публиковать, чтобы их не
заблокировали и в них играли
</p>
</div>
<div className={cl.topBarActions}>
<button className={cl.searchBox} onClick={() => navigate('/kubikon-studio')}>
В Studio
</button>
</div>
</header>
{/* Hero */}
<section className="rulesHero">
<div className="rulesHeroContent">
<div className="rulesHeroBadge">
<DocIcon name="shield" size={13} /> Правила платформы
</div>
<h2 className="rulesHeroTitle">
Создавай игры, которые полюбят
</h2>
<p className="rulesHeroDesc">
В Рублоксе ты публикуешь игры сам, без долгой
модерации. Эти правила помогут: твою игру не
заблокируют, а игроки будут в неё играть.
Прочитай их один раз и всё будет получаться.
</p>
</div>
<div className="rulesHeroEmoji"><DocIcon name="shield" size={88} /></div>
</section>
{/* Body: оглавление + разделы */}
<section className="rulesBody">
<aside className="rulesToc">
<div className="rulesTocTitle">
<DocIcon name="scroll" size={16} /> Разделы
</div>
{RULES.map((r) => (
<button
key={r.id}
className={`rulesTocItem ${activeSec === r.id ? 'rulesTocItemActive' : ''}`}
onClick={() => scrollToSec(r.id)}
>
{r.title}
</button>
))}
</aside>
<div className="rulesContent">
{RULES.map((r) => (
<article key={r.id} id={`rule-${r.id}`} className="ruleChapter">
<h3 className="ruleTitle">
<span className="ruleTitle__ico">
<DocIcon name={r.icon} size={20} />
</span>
{r.title}
</h3>
<div className="ruleText">{r.body}</div>
</article>
))}
{/* Финальный CTA */}
<div className="rulesCta">
<div className="rulesCtaIcon"><DocIcon name="rocket" size={48} /></div>
<div className="rulesCtaTitle">Готов создавать?</div>
<div className="rulesCtaText">
Открой вику с 50 играми-уроками там готовые
игры с инструкциями. Бери за основу и делай
свою по этим правилам.
</div>
<button
className="rulesCtaBtn"
onClick={() => navigate('/kubikon-studio/docs')}
>
Открыть вику
</button>
</div>
</div>
</section>
</main>
</div>
);
};
//
// Инлайн-стили страница самодостаточна, не зависит от вики.
//
const INLINE_STYLES = `
.rulesHero {
background: linear-gradient(135deg, #3357ff 0%, #1e2da5 60%, #6d28d9 100%);
background-size: 200% 200%;
animation: rulesGradient 16s ease-in-out infinite;
border-radius: 28px;
padding: 36px 40px;
margin-bottom: 28px;
color: #fff;
display: flex;
align-items: center;
gap: 24px;
box-shadow: 0 24px 56px rgba(51, 87, 255, 0.28);
}
@keyframes rulesGradient {
0%, 100% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
}
.rulesHeroContent { flex: 1; min-width: 0; }
.rulesHeroBadge {
display: inline-flex;
align-items: center;
gap: 6px;
background: rgba(255, 255, 255, 0.20);
border: 1px solid rgba(255, 255, 255, 0.30);
color: #fff;
padding: 5px 14px;
border-radius: 999px;
font-size: 11px;
font-weight: 800;
letter-spacing: 1.4px;
text-transform: uppercase;
margin-bottom: 12px;
}
.rulesHeroTitle {
margin: 0 0 10px;
font-size: 32px;
font-weight: 900;
color: #fff;
letter-spacing: -1px;
line-height: 1.1;
text-shadow: 0 2px 12px rgba(0, 0, 0, 0.18);
}
.rulesHeroDesc {
margin: 0;
font-size: 15px;
color: rgba(255, 255, 255, 0.94);
line-height: 1.55;
max-width: 680px;
}
.rulesHeroEmoji {
flex-shrink: 0;
color: #fff;
opacity: 0.92;
animation: rulesFloat 6s ease-in-out infinite;
filter: drop-shadow(0 12px 24px rgba(0, 0, 0, 0.20));
}
@keyframes rulesFloat {
0%, 100% { transform: translateY(0) rotate(-2deg); }
50% { transform: translateY(-10px) rotate(3deg); }
}
/* === Body: оглавление + контент === */
.rulesBody {
display: grid;
grid-template-columns: 250px minmax(0, 1fr);
gap: 28px;
align-items: flex-start;
}
.rulesToc {
position: sticky;
top: 8px;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 16px;
padding: 12px 8px;
box-shadow: 0 4px 16px rgba(15, 23, 42, 0.04);
}
.rulesTocTitle {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px 10px;
font-size: 13px;
font-weight: 900;
color: #0f172a;
border-bottom: 1px solid #eef2f7;
margin-bottom: 6px;
}
.rulesTocTitle svg { color: #3357ff; flex-shrink: 0; }
.rulesTocItem {
display: block;
width: 100%;
padding: 8px 12px;
background: transparent;
border: none;
border-radius: 9px;
color: #475569;
font-size: 12.5px;
font-weight: 700;
cursor: pointer;
text-align: left;
transition: all 160ms ease;
font-family: inherit;
line-height: 1.4;
}
.rulesTocItem:hover { background: #f1f5f9; color: #0f172a; }
.rulesTocItemActive, .rulesTocItemActive:hover {
background: linear-gradient(135deg, #3357ff 0%, #1e2da5 100%);
color: #fff;
}
.rulesContent {
min-width: 0;
display: flex;
flex-direction: column;
gap: 18px;
}
/* === Раздел правил === */
.ruleChapter {
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 18px;
padding: 24px 28px;
box-shadow: 0 4px 16px rgba(15, 23, 42, 0.04);
scroll-margin-top: 16px;
}
.ruleTitle {
display: flex;
align-items: center;
gap: 12px;
margin: 0 0 16px;
font-size: 20px;
font-weight: 900;
color: #0f172a;
letter-spacing: -0.4px;
padding-bottom: 12px;
border-bottom: 1px solid #eef2f7;
}
.ruleTitle__ico {
flex-shrink: 0;
width: 38px; height: 38px;
border-radius: 11px;
background: linear-gradient(135deg, #3357ff 0%, #1e2da5 100%);
color: #fff;
display: flex; align-items: center; justify-content: center;
}
.ruleText { color: #334155; font-size: 14.5px; line-height: 1.65; }
.ruleText p { margin: 0 0 12px; }
.ruleText p:last-child { margin-bottom: 0; }
.ruleText ul, .ruleText ol { margin: 0 0 12px; padding-left: 22px; }
.ruleText li { margin-bottom: 8px; line-height: 1.55; }
.ruleText li:last-child { margin-bottom: 0; }
.ruleText b { color: #0f172a; font-weight: 800; }
.ruleText code {
background: #e0e8ff;
color: #3357ff;
padding: 2px 7px;
border-radius: 6px;
font-family: Consolas, Menlo, "Courier New", monospace;
font-size: 13px;
font-weight: 700;
}
/* === Плашки можно / нельзя / совет === */
.ruleBox {
display: flex;
gap: 11px;
align-items: flex-start;
border-radius: 11px;
padding: 12px 14px;
margin: 10px 0;
font-size: 13.5px;
line-height: 1.55;
border: 1px solid;
border-left-width: 4px;
}
.ruleBox__ico { flex-shrink: 0; line-height: 0; margin-top: 1px; }
.ruleBox--ok {
background: #f0fdf4;
border-color: #bbf7d0;
border-left-color: #16a34a;
color: #14532d;
}
.ruleBox--ok .ruleBox__ico { color: #16a34a; }
.ruleBox--no {
background: #fef2f2;
border-color: #fecaca;
border-left-color: #dc2626;
color: #7f1d1d;
}
.ruleBox--no .ruleBox__ico { color: #dc2626; }
.ruleBox--no code { background: #fee2e2; color: #b91c1c; }
.ruleBox--tip {
background: #fffbeb;
border-color: #fde68a;
border-left-color: #f59e0b;
color: #78350f;
}
.ruleBox--tip .ruleBox__ico { color: #b45309; }
.ruleBox--tip code { background: #fef3c7; color: #92400e; }
/* === Список статусов === */
.statusList {
display: flex;
flex-direction: column;
gap: 4px;
margin: 12px 0;
}
.statusRow {
display: flex;
gap: 11px;
align-items: flex-start;
background: #f8fafc;
border: 1px solid #eef2f7;
border-radius: 10px;
padding: 11px 14px;
}
.statusDot {
flex-shrink: 0;
width: 12px; height: 12px;
border-radius: 50%;
margin-top: 4px;
}
.statusName { color: #0f172a; font-weight: 800; }
.statusText { color: #475569; }
/* === Факторы рейтинга === */
.factorList {
display: flex;
flex-direction: column;
gap: 8px;
margin: 12px 0;
}
.factorRow {
display: flex;
gap: 12px;
align-items: flex-start;
background: #f5f7ff;
border: 1px solid #e0e8ff;
border-radius: 11px;
padding: 12px 14px;
}
.factorIco {
flex-shrink: 0;
width: 32px; height: 32px;
border-radius: 9px;
background: #fff;
border: 1px solid #d6e0ff;
color: #3357ff;
display: flex; align-items: center; justify-content: center;
}
/* === Чек-лист === */
.checkList {
display: flex;
flex-direction: column;
gap: 6px;
margin: 12px 0;
}
.checkItem {
display: flex;
gap: 11px;
align-items: center;
background: #f0fdf4;
border: 1px solid #bbf7d0;
border-radius: 10px;
padding: 10px 14px;
font-size: 13.5px;
color: #14532d;
line-height: 1.45;
}
.checkBox {
flex-shrink: 0;
width: 20px; height: 20px;
border-radius: 6px;
background: #16a34a;
color: #fff;
display: flex; align-items: center; justify-content: center;
}
/* === Финальный CTA === */
.rulesCta {
background: linear-gradient(135deg, #3357ff 0%, #1e2da5 100%);
border-radius: 24px;
padding: 32px;
text-align: center;
color: #fff;
box-shadow: 0 16px 40px rgba(51, 87, 255, 0.32);
margin-top: 8px;
}
.rulesCtaIcon {
display: flex;
justify-content: center;
color: #fff;
margin-bottom: 10px;
}
.rulesCtaTitle { font-size: 22px; font-weight: 900; margin-bottom: 8px; }
.rulesCtaText {
font-size: 14px;
color: rgba(255, 255, 255, 0.90);
margin: 0 auto 20px;
max-width: 520px;
line-height: 1.55;
}
.rulesCtaBtn {
padding: 12px 26px;
background: #fff;
color: #3357ff;
border: none;
border-radius: 10px;
font-size: 14px;
font-weight: 900;
cursor: pointer;
font-family: inherit;
}
.rulesCtaBtn:hover { transform: translateY(-2px); }
/* Адаптив */
@media (max-width: 980px) {
.rulesBody { grid-template-columns: 1fr; }
.rulesToc { position: static; }
.rulesHero { flex-direction: column; padding: 28px 24px; text-align: center; }
.ruleChapter { padding: 20px; }
}
`;
export default KubikonRules;

View File

@ -0,0 +1,790 @@
import React, { useState, useEffect } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { jwtDecode } from 'jwt-decode';
import { useAuth } from '../../auth/AuthContext.jsx';
import * as Kubikon3DApi from '../../api/Kubikon3DService';
import { USER_addres } from '../api/API';
import cl from './KubikonStudio.module.css';
import { TEMPLATES } from './templates';
import { ARTICLES as LEARN_ARTICLES } from './learnArticles';
import { generateAllTemplateScreenshots } from './templateScreenshots';
import PublishStatusBadge from '../KubikonEditor/PublishStatusBadge';
import RublocsLogo from '../../components/RublocsLogo/RublocsLogo';
import useDeviceType from '../../hooks/useDeviceType';
import KubikonDesktopOnlyStub from './KubikonDesktopOnlyStub';
import PleeseReg from '../../components/PleeseReg/PleeseReg';
import Icon from '../KubikonEditor/Icon';
function getCurrentUserId() {
try {
const token = localStorage.getItem('Authorization');
if (!token) return null;
return jwtDecode(token).id;
} catch {
return null;
}
}
/** Жанры — для отображения в карточках. Должно совпадать с GENRES в GameSettingsModal. */
const GENRE_NAME = {
platformer: 'Платформер',
racing: 'Гонки',
shooter: 'Шутер',
sandbox: 'Песочница',
adventure: 'Приключение',
puzzle: 'Головоломка',
tycoon: 'Тайкун',
rpg: 'РПГ',
other: 'Другое',
};
/**
* Хелпер рендера карточки проекта (используется в нескольких секциях).
*/
function renderProjectCard(p, navigate, genreMap, onDeleteClick, cl, requireAuth = (a) => a()) {
const open = () => requireAuth(() => navigate(`/kubikon-editor/${p.id}`));
return (
<div
key={p.id}
role="button"
tabIndex={0}
className={cl.templateCard}
onClick={open}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
open();
}
}}
title={p.title}
>
<div className={cl.templatePreview}>
{p.thumbnail ? (
<img src={p.thumbnail} alt={p.title} />
) : (
<div className={cl.templateEmoji}><Icon name="gamepad" size={14} /></div>
)}
{p.status && p.status !== 'draft' && (
<div style={{ position: 'absolute', top: 8, right: 8, zIndex: 5 }}>
<PublishStatusBadge status={p.status} comment={p.moderator_comment} />
</div>
)}
</div>
<div className={cl.templateBody}>
<div className={cl.templateTitle}>{p.title}</div>
<div className={cl.templateDesc}>
<span style={{ opacity: 0.92 }}>{genreMap[p.genre] || ''}</span>
{p.genre && <span style={{ margin: '0 6px', opacity: 0.4 }}>·</span>}
{p.updated_at?.slice(0, 10) || '—'}
<button
onClick={(e) => onDeleteClick(p.id, p.title, e)}
style={{
float: 'right', background: 'transparent', border: 'none',
color: 'var(--text-muted)', cursor: 'pointer', fontSize: 14, padding: 0
}}
title="Удалить"
><Icon name="delete" size={13} /> </button>
</div>
</div>
</div>
);
}
/**
* KubikonStudio стартовый экран Studio для платформы Рублокс
* (наш аналог Roblox Studio с Minecraft-эстетикой).
*
* На этом экране (как в Roblox Studio Home):
* - крупная карточка «Создать новую игру»
* - сетка шаблонов (Платформер, Гонки, Шутер, Песочница, Пустой мир)
* - блок «Мои игры» (пока пустой)
* - блок «Последние»
* - блок «Обучение»
*
* Доступ: открыт всем авторизованным пользователям (раньше только admin).
*/
const KubikonStudio = () => {
const navigate = useNavigate();
const { isAuthenticated, isLoading } = useAuth();
const { isDesktop } = useDeviceType();
// Активная вкладка. Начальное значение можно задать через ?tab= в
// URL так на Studio переходят из вики (у неё нет своих вкладок).
const [searchParams] = useSearchParams();
const VALID_TABS = ['home', 'recent', 'my', 'templates', 'archive'];
const initialTab = VALID_TABS.includes(searchParams.get('tab'))
? searchParams.get('tab') : 'home';
const [activeTab, setActiveTab] = useState(initialTab);
const [myProjects, setMyProjects] = useState([]);
const [myProjectsLoading, setMyProjectsLoading] = useState(true);
const [creatingTemplate, setCreatingTemplate] = useState(null);
// { id, title } | null управляет модалкой подтверждения удаления
const [deleteTarget, setDeleteTarget] = useState(null);
const [deleting, setDeleting] = useState(false);
// Прогресс генерации скринов шаблонов
const [genProgress, setGenProgress] = useState(null); // null | {current, total, id}
// Имя пользователя для приветствия (как «Welcome, ...» в Roblox Studio)
const [greetName, setGreetName] = useState('');
// Поиск по своим играм. searchOpen раскрыт ли инпут в шапке.
const [searchQuery, setSearchQuery] = useState('');
const [searchOpen, setSearchOpen] = useState(false);
// Гость МОЖЕТ просматривать студию видит шаблоны и обучение.
// При попытке создать игру / открыть редактор / документацию модалка.
const [showRegModal, setShowRegModal] = useState(false);
const requireAuth = (action) => {
if (isAuthenticated) {
action();
} else {
setShowRegModal(true);
}
};
// Грузим список «Мои игры»
useEffect(() => {
if (!isAuthenticated) return;
const userId = getCurrentUserId();
if (!userId) { setMyProjectsLoading(false); return; }
Kubikon3DApi.getMyProjects(userId)
.then(res => setMyProjects(res.data?.projects || []))
.catch(err => console.error('[KubikonStudio] my-projects error:', err))
.finally(() => setMyProjectsLoading(false));
}, [isAuthenticated]);
// Имя пользователя для приветствия на главной. Берём из профиля
// (в JWT-токене имени нет, только id). firstName основное поле,
// nikname запасное.
useEffect(() => {
if (!isAuthenticated) { setGreetName(''); return; }
const jwt = localStorage.getItem('Authorization') || localStorage.getItem('player_jwt');
fetch(`${USER_addres}/api/v1/users/profile`, { headers: { Authorization: jwt || '' } })
.then(r => r.ok ? r.json() : null)
.then(d => {
const p = d?.data || d || {};
setGreetName(p.firstName || p.nikname || '');
})
.catch(err => console.error('[KubikonStudio] profile name error:', err));
}, [isAuthenticated]);
const handleDeleteProject = (projectId, projectTitle, e) => {
e.stopPropagation();
setDeleteTarget({ id: projectId, title: projectTitle || 'Без названия' });
};
const confirmDelete = async () => {
if (!deleteTarget || deleting) return;
const userId = getCurrentUserId();
if (!userId) return;
setDeleting(true);
try {
await Kubikon3DApi.deleteProject(deleteTarget.id, userId);
setMyProjects(prev => prev.filter(p => p.id !== deleteTarget.id));
setDeleteTarget(null);
} catch (err) {
console.error('[KubikonStudio] delete error:', err);
alert('Не удалось удалить проект');
} finally {
setDeleting(false);
}
};
if (isLoading) {
return null;
}
// «Изучай Студию» карточки-статьи (контент в learnArticles.jsx,
// страница чтения KubikonLearn.jsx). Берём первые 6 статей.
const GUIDES = LEARN_ARTICLES.slice(0, 6);
const handleTemplateClick = async (tpl) => {
if (!isAuthenticated) {
setShowRegModal(true);
return;
}
const userId = getCurrentUserId();
if (!userId || creatingTemplate) return;
setCreatingTemplate(tpl.id);
try {
const state = tpl.build();
const payload = {
user_id: userId,
title: tpl.title,
description: tpl.desc,
genre: tpl.genre || 'sandbox',
thumbnail: '',
is_public: false,
project_data: JSON.stringify(state),
};
const res = await Kubikon3DApi.createProject(userId, payload);
const newId = res.data?.id;
if (newId) {
navigate(`/kubikon-editor/${newId}`);
} else {
throw new Error('No id from server');
}
} catch (err) {
console.error('[KubikonStudio] template create error:', err);
// Лимит игр на аккаунт показываем понятное сообщение с сервера
const msg = err?.response?.data?.message;
alert(msg || 'Не удалось создать игру из шаблона');
setCreatingTemplate(null);
}
};
const handleGenerateScreenshots = async () => {
if (genProgress) return;
setGenProgress({ current: 0, total: TEMPLATES.length, id: '' });
try {
await generateAllTemplateScreenshots((p) => setGenProgress(p));
alert(
`Готово! ${TEMPLATES.length} превью скачаны.\n\n` +
`Скопируй файлы в:\npublic/assets/kubikon-templates/`
);
} catch (err) {
console.error('[KubikonStudio] generate screenshots error:', err);
alert('Не удалось сгенерировать превью');
} finally {
setGenProgress(null);
}
};
// Сортировка для секции «Последние» по updated_at, top-6
const recentProjects = [...myProjects]
.sort((a, b) => (b.updated_at || '').localeCompare(a.updated_at || ''))
.slice(0, 6);
// Для вкладки «Мои игры»: опубликованные сверху, остальные снизу,
// внутри каждой группы по updated_at desc.
// Умная лента: опубликованная игра единый статус 'published'.
const isApproved = (p) => p.status === 'published';
const sortedMyProjects = [...myProjects].sort((a, b) => {
const pa = isApproved(a) ? 1 : 0;
const pb = isApproved(b) ? 1 : 0;
if (pa !== pb) return pb - pa; // одобренные первыми
return (b.updated_at || '').localeCompare(a.updated_at || '');
});
const publishedProjects = sortedMyProjects.filter(isApproved);
const draftProjects = sortedMyProjects.filter(p => !isApproved(p));
// 3D-редактор требует мышь и клавиатуру на телефонах/планшетах
// показываем заглушку. Проверка ПОСЛЕ всех хуков, чтобы не нарушить
// правило rules-of-hooks (одинаковый порядок вызовов).
if (!isDesktop) {
return <KubikonDesktopOnlyStub feature="Рублокс" />;
}
return (
<div className={cl.studio}>
{/* === Модалка для гостя — войди или зарегистрируйся === */}
{showRegModal && (
<div
onClick={() => setShowRegModal(false)}
style={{
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
background: 'rgba(15, 8, 35, 0.78)',
backdropFilter: 'blur(8px)',
WebkitBackdropFilter: 'blur(8px)',
zIndex: 9999,
display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: 20,
}}
>
<div
onClick={(e) => e.stopPropagation()}
style={{
background: '#fff',
borderRadius: 20,
padding: 30,
position: 'relative',
width: '100%', maxWidth: 460,
boxShadow: '0 30px 80px rgba(0,0,0,0.5)',
}}
>
<button
onClick={() => setShowRegModal(false)}
style={{
position: 'absolute', top: 12, right: 12,
width: 36, height: 36, borderRadius: 18,
border: 'none',
background: '#F3E8FF', color: '#1E1B4B',
fontSize: 20, fontWeight: 800,
cursor: 'pointer',
}}
aria-label="Закрыть"
><Icon name="close" size={14} /></button>
<PleeseReg textDefault='Чтобы создавать игры в Рублоксе' style={{width:'auto',height:'auto'}} />
</div>
</div>
)}
{/* Левая боковая панель — навигация */}
<aside className={cl.sidebar}>
<div className={cl.sidebarHeader}>
<div className={cl.sidebarLogo}>
<RublocsLogo size={44} bg="#16181d" />
</div>
<div className={cl.sidebarTitle}>
<div className={cl.brandName}>Рублокс</div>
<div className={cl.brandSub}>Студия</div>
</div>
</div>
{/* Навигация в стиле Roblox Studio:
Новая игра · История · Главная · Мои игры · Шаблоны · Архив · ВИКИ */}
<nav className={cl.sidebarNav}>
{/* Новая игра — отдельная акцентная кнопка-действие */}
<button
className={cl.navNewGame}
onClick={() => requireAuth(() => navigate('/kubikon-editor/new'))}
>
<span className={cl.navNewGameIco}><Icon name="plus" size={15} /></span>
<span>Новая игра</span>
</button>
<button
className={`${cl.navItem} ${activeTab === 'recent' ? cl.navActive : ''}`}
onClick={() => setActiveTab('recent')}
>
<span className={cl.navIcon}><Icon name="clock" size={15} /></span>
<span>История</span>
</button>
<button
className={`${cl.navItem} ${activeTab === 'home' ? cl.navActive : ''}`}
onClick={() => setActiveTab('home')}
>
<span className={cl.navIcon}><Icon name="home" size={15} /></span>
<span>Главная</span>
</button>
<button
className={`${cl.navItem} ${activeTab === 'my' ? cl.navActive : ''}`}
onClick={() => setActiveTab('my')}
>
<span className={cl.navIcon}><Icon name="folder" size={15} /></span>
<span>Мои игры</span>
</button>
<button
className={`${cl.navItem} ${activeTab === 'templates' ? cl.navActive : ''}`}
onClick={() => setActiveTab('templates')}
>
<span className={cl.navIcon}><Icon name="grid" size={15} /></span>
<span>Шаблоны</span>
</button>
<button
className={`${cl.navItem} ${activeTab === 'archive' ? cl.navActive : ''}`}
onClick={() => setActiveTab('archive')}
>
<span className={cl.navIcon}><Icon name="archive" size={15} /></span>
<span>Архив</span>
</button>
<button
className={cl.navItem}
onClick={() => navigate('/kubikon-studio/docs')}
title="Вики — учебник по редактору и 50 игр-уроков"
>
<span className={cl.navIcon}><Icon name="book-open" size={15} /></span>
<span>ВИКИ</span>
</button>
</nav>
<div className={cl.sidebarFooter}>
<button
className={cl.docsBtn}
onClick={() => navigate('/kubikon-studio/rules')}
style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6 }}
>
<Icon name="shield" size={15} /> Правила создания игр
</button>
</div>
</aside>
{/* Основной контент */}
<main className={cl.main}>
<header className={cl.topBar}>
<div>
<h1 className={cl.pageTitle}>
{activeTab === 'home'
? (greetName ? `Привет, ${greetName}!` : 'Добро пожаловать в Студию')
: activeTab === 'recent' ? 'История'
: activeTab === 'my' ? 'Мои игры'
: activeTab === 'templates' ? 'Шаблоны'
: activeTab === 'archive' ? 'Архив'
: 'Студия'}
</h1>
<p className={cl.pageSub}>
{activeTab === 'home' ? 'Создавай 3D-игры и делись ими с друзьями'
: activeTab === 'recent' ? 'Игры, которые ты открывал последними'
: activeTab === 'my' ? 'Все твои игры — черновики и опубликованные'
: activeTab === 'templates' ? 'Готовые заготовки — начни игру в один клик'
: activeTab === 'archive' ? 'Сюда попадают игры, убранные в архив'
: ''}
</p>
</div>
<div className={cl.topBarActions}>
{/* Поиск по своим играм. Кнопка раскрывается в
инпут; непустой запрос показывает результаты. */}
{searchOpen ? (
<div
className={cl.searchBox}
style={{ display: 'inline-flex', alignItems: 'center', gap: 6, padding: '0 6px 0 14px' }}
>
<Icon name="search" size={14} />
<input
autoFocus
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Поиск по моим играм…"
style={{
background: 'transparent', border: 'none', outline: 'none',
color: 'inherit', fontFamily: 'inherit', fontSize: 13,
width: 180,
}}
/>
<button
onClick={() => { setSearchQuery(''); setSearchOpen(false); }}
style={{
background: 'transparent', border: 'none', cursor: 'pointer',
color: 'inherit', fontSize: 16, padding: '6px 8px', lineHeight: 1,
}}
title="Закрыть поиск"
>×</button>
</div>
) : (
<button
className={cl.searchBox}
onClick={() => setSearchOpen(true)}
style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}
>
<Icon name="search" size={14} /> Поиск
</button>
)}
<button
className={cl.profileBtn}
onClick={() => navigate('/me')}
style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}
>
<Icon name="user" size={14} /> Профиль
</button>
</div>
</header>
{/* РЕЗУЛЬТАТЫ ПОИСКА когда в шапке введён запрос.
Ищем по своим играм (название/описание). Пока
поиск активен обычные секции скрыты. */}
{searchQuery.trim() && (
<section className={cl.section}>
<div className={cl.sectionHead}>
<h3 className={cl.sectionTitle}>
Результаты поиска: «{searchQuery.trim()}»
</h3>
</div>
{(() => {
const q = searchQuery.trim().toLowerCase();
const found = myProjects.filter(p =>
(p.title || '').toLowerCase().includes(q)
|| (p.description || '').toLowerCase().includes(q));
if (myProjectsLoading) {
return <div className={cl.emptyStateSmall}><Icon name="hourglass" size={13} /> Загрузка...</div>;
}
if (found.length === 0) {
return (
<div className={cl.emptyState}>
<div className={cl.emptyEmoji}><Icon name="search" size={14} /></div>
<div className={cl.emptyTitle}>Ничего не найдено</div>
<div className={cl.emptyDesc}>
По запросу «{searchQuery.trim()}» среди твоих игр ничего нет
</div>
</div>
);
}
return (
<div className={cl.templatesGrid}>
{found.map(p =>
renderProjectCard(p, navigate, GENRE_NAME, handleDeleteProject, cl, requireAuth))}
</div>
);
})()}
</section>
)}
{/* HERO крупный баннер с кадром игры (как Roblox Studio).
Кадр hero-226.png, текст слева, кнопка «Новая игра». */}
{!searchQuery.trim() && activeTab === 'home' && (
<section className={cl.hero}>
<img
className={cl.heroImg}
src="/wiki/hero/hero-226.png"
alt="Игра в Рублоксе"
/>
<div className={cl.heroOverlay} />
<div className={cl.heroContent}>
<div className={cl.heroBadge}>НАЧНИ ЗДЕСЬ</div>
<h2 className={cl.heroTitle}>Создавай большие игры</h2>
<p className={cl.heroDesc}>
Замки, подземелья, целые королевства. Редактор
Рублокса справится с любой задумкой начни прямо
сейчас.
</p>
<button
className={cl.heroBtn}
onClick={() => requireAuth(() => navigate('/kubikon-editor/new'))}
>
<Icon name="plus" size={15} /> Новая игра
</button>
</div>
</section>
)}
{/* ═══ ШАБЛОНЫ — «Открой шаблон» (главная + вкладка Шаблоны) ═══ */}
{!searchQuery.trim() && (activeTab === 'home' || activeTab === 'templates') && (
<section className={cl.section}>
<div className={cl.sectionHead}>
<h3 className={cl.sectionTitle}>Открой шаблон</h3>
<span className={cl.sectionSub}>
Готовые заготовки популярных жанров или начни с нуля
</span>
</div>
<div className={cl.tplGrid}>
{TEMPLATES.map(t => (
<button
key={t.id}
className={cl.tplCard}
disabled={!!creatingTemplate}
onClick={() => handleTemplateClick(t)}
title={`Создать игру из шаблона «${t.title}»`}
style={{ '--tpl-color': t.color }}
>
<div className={cl.tplImageWrap}>
<img
src={`/assets/kubikon-templates/${t.id}.jpg`}
alt={t.title}
className={cl.tplImage}
onError={(e) => { e.currentTarget.style.display = 'none'; }}
/>
{/* Фолбэк-фон при отсутствии картинки */}
<div
className={cl.tplFallback}
style={{ background: `linear-gradient(135deg, ${t.color} 0%, #2a2218 100%)` }}
>
<span className={cl.tplFallbackEmoji}>
<Icon name={t.icon} size={40} />
</span>
</div>
<div className={cl.tplOverlay} />
<div className={cl.tplContent}>
<div className={cl.tplBadge}>
<Icon name={t.icon} size={20} />
</div>
<div className={cl.tplTitle}>{t.title}</div>
<div className={cl.tplDesc}>{t.desc}</div>
</div>
<div className={cl.tplCta}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
<Icon name="play" size={14} /> Создать игру
</span>
</div>
{creatingTemplate === t.id && (
<div className={cl.tplLoading}>
<div className={cl.tplLoadingSpinner} />
<span>Создаём...</span>
</div>
)}
</div>
</button>
))}
</div>
</section>
)}
{/* ИЗУЧАЙ СТУДИЮ карточки-статьи (Discover Studio).
Контент в learnArticles.jsx, читалка KubikonLearn. */}
{!searchQuery.trim() && activeTab === 'home' && (
<section className={cl.section}>
<div className={cl.sectionHead}>
<h3 className={cl.sectionTitle}>Изучай Студию</h3>
<button className={cl.sectionMore} onClick={() => navigate('/kubikon-studio/learn')}>
Все статьи
</button>
</div>
<div className={cl.guideGrid}>
{GUIDES.map(g => (
<button
key={g.id}
type="button"
className={cl.guideCard}
onClick={() => navigate('/kubikon-studio/learn/' + g.id)}
>
<div className={cl.guideImage}>
{/* запасной фон ПОД картинкой */}
<div
className={cl.guideFallback}
style={{ background: `linear-gradient(135deg, ${g.color} 0%, #15151c 120%)` }}
>
<Icon name="book-open" size={42} />
</div>
<img
className={cl.guideImg}
src={`/assets/kubikon-learn/${g.cover}`}
alt={g.title}
loading="lazy"
onError={(e) => { e.currentTarget.style.display = 'none'; }}
/>
</div>
<div className={cl.guideBody}>
<div className={cl.guideTitle}>{g.title}</div>
<div className={cl.guideDesc}>{g.summary}</div>
</div>
</button>
))}
</div>
</section>
)}
{/* ═══ ИСТОРИЯ — последние открытые игры ═══ */}
{!searchQuery.trim() && activeTab === 'recent' && (
<section className={cl.section}>
{!isAuthenticated ? (
<div className={cl.emptyState}>
<div className={cl.emptyEmoji}><Icon name="clock" size={14} /></div>
<div className={cl.emptyTitle}>Войди в аккаунт</div>
<div className={cl.emptyDesc}>История открытых игр доступна после входа</div>
<button className={cl.emptyBtn} onClick={() => setShowRegModal(true)}>
Войти
</button>
</div>
) : myProjectsLoading ? (
<div className={cl.emptyStateSmall}><Icon name="hourglass" size={13} /> Загрузка...</div>
) : recentProjects.length === 0 ? (
<div className={cl.emptyState}>
<div className={cl.emptyEmoji}><Icon name="clock" size={14} /></div>
<div className={cl.emptyTitle}>История пуста</div>
<div className={cl.emptyDesc}>Открой любую игру она появится здесь</div>
<button className={cl.emptyBtn} onClick={() => navigate('/kubikon-editor/new')}>
+ Создать игру
</button>
</div>
) : (
<div className={cl.templatesGrid}>
{recentProjects.map(p =>
renderProjectCard(p, navigate, GENRE_NAME, handleDeleteProject, cl, requireAuth))}
</div>
)}
</section>
)}
{/* ═══ МОИ ИГРЫ — опубликованные + черновики ═══ */}
{!searchQuery.trim() && activeTab === 'my' && (
<>
{!isAuthenticated ? (
<section className={cl.section}>
<div className={cl.emptyState}>
<div className={cl.emptyEmoji}><Icon name="folder" size={14} /></div>
<div className={cl.emptyTitle}>Войди в аккаунт</div>
<div className={cl.emptyDesc}>Твои игры хранятся в аккаунте Рублокса</div>
<button className={cl.emptyBtn} onClick={() => setShowRegModal(true)}>
Войти
</button>
</div>
</section>
) : myProjectsLoading ? (
<section className={cl.section}>
<div className={cl.emptyStateSmall}><Icon name="hourglass" size={13} /> Загрузка...</div>
</section>
) : myProjects.length === 0 ? (
<section className={cl.section}>
<div className={cl.emptyState}>
<div className={cl.emptyEmoji}><Icon name="package" size={14} /></div>
<div className={cl.emptyTitle}>Пока пусто</div>
<div className={cl.emptyDesc}>Создай свою первую игру она появится здесь</div>
<button className={cl.emptyBtn} onClick={() => navigate('/kubikon-editor/new')}>
+ Создать игру
</button>
</div>
</section>
) : (
<>
{publishedProjects.length > 0 && (
<section className={cl.section}>
<div className={cl.sectionHead}>
<h3 className={cl.sectionTitle}>
<Icon name="globe" size={13} /> Опубликованные ({publishedProjects.length})
</h3>
</div>
<div className={cl.templatesGrid}>
{publishedProjects.map(p =>
renderProjectCard(p, navigate, GENRE_NAME, handleDeleteProject, cl, requireAuth))}
</div>
</section>
)}
{draftProjects.length > 0 && (
<section className={cl.section}>
<div className={cl.sectionHead}>
<h3 className={cl.sectionTitle}>
<Icon name="rename" size={13} /> Черновики ({draftProjects.length})
</h3>
</div>
<div className={cl.templatesGrid}>
{draftProjects.map(p =>
renderProjectCard(p, navigate, GENRE_NAME, handleDeleteProject, cl, requireAuth))}
</div>
</section>
)}
</>
)}
</>
)}
{/* ═══ АРХИВ — пока пусто (заглушка) ═══ */}
{!searchQuery.trim() && activeTab === 'archive' && (
<section className={cl.section}>
<div className={cl.emptyState}>
<div className={cl.emptyEmoji}><Icon name="archive" size={14} /></div>
<div className={cl.emptyTitle}>Архив пуст</div>
<div className={cl.emptyDesc}>
Сюда можно убирать игры, которые сейчас не нужны,
но удалять их жалко. Скоро.
</div>
</div>
</section>
)}
{/* Подвал-блок */}
<section className={cl.footerBlock}>
<div className={cl.footerInfo}>
Рублокс Studio · Альфа-версия
</div>
</section>
</main>
{/* Модалка подтверждения удаления */}
{deleteTarget && (
<div className={cl.modalBackdrop} onClick={() => !deleting && setDeleteTarget(null)}>
<div className={cl.modalCard} onClick={(e) => e.stopPropagation()}>
<div className={cl.modalIcon}><Icon name="delete" size={14} /></div>
<div className={cl.modalTitle}>Удалить игру?</div>
<div className={cl.modalText}>
Игра <b>«{deleteTarget.title}»</b> будет удалена безвозвратно.
Восстановить её будет нельзя.
</div>
<div className={cl.modalActions}>
<button
className={cl.modalCancelBtn}
onClick={() => setDeleteTarget(null)}
disabled={deleting}
>
Отмена
</button>
<button
className={cl.modalDeleteBtn}
onClick={confirmDelete}
disabled={deleting}
>
{deleting ? <><Icon name="hourglass" size={13} /> Удаляем...</> : <><Icon name="delete" size={13} /> Удалить</>}
</button>
</div>
</div>
</div>
)}
</div>
);
};
export default KubikonStudio;

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,362 @@
import React, { useEffect, useState } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom';
import * as Kubikon3DApi from '../../api/Kubikon3DService';
import { KT, KUBIKON_KEYFRAMES, skeletonStyle } from '../../utils/kubikonTheme';
import RublocsLogo from '../../components/RublocsLogo/RublocsLogo';
import Icon from '../KubikonEditor/Icon';
/**
* KubikonUserGames публичный профиль автора со списком его опубликованных игр.
* URL: /kubikon/user/:userId
*
* Видны опубликованные игры автора (status='published').
* Дизайн в едином wow-стиле Рублокса.
*/
const KubikonUserGames = () => {
const { userId } = useParams();
const navigate = useNavigate();
const [items, setItems] = useState([]);
const [authorName, setAuthorName] = useState('');
const [loading, setLoading] = useState(true);
useEffect(() => {
let active = true;
(async () => {
try {
const res = await Kubikon3DApi.getUserGames(userId);
if (active) {
setItems(res.data?.projects || []);
setAuthorName(res.data?.author_username || '');
setLoading(false);
}
} catch (e) {
if (active) setLoading(false);
}
})();
return () => { active = false; };
}, [userId]);
const totalPlays = items.reduce((s, p) => s + (p.play_count || 0), 0);
const totalLikes = items.reduce((s, p) => s + (p.likes_count || 0), 0);
const initial = (authorName || '?').slice(0, 1).toUpperCase();
return (
<div style={{
minHeight: '100vh',
background: KT.bg,
color: KT.text,
fontFamily: KT.font,
}}>
<style>{KUBIKON_KEYFRAMES}</style>
{/* Sticky glass header */}
<div style={{
background: KT.glassDark,
backdropFilter: 'blur(20px)',
WebkitBackdropFilter: 'blur(20px)',
borderBottom: `1px solid ${KT.borderSoft}`,
padding: '12px 24px',
position: 'sticky', top: 0, zIndex: 50,
}}>
<div style={{
maxWidth: 1240, margin: '0 auto',
display: 'flex', alignItems: 'center', gap: 16, flexWrap: 'wrap',
}}>
<Link to="/kubikon" style={{
color: KT.text, textDecoration: 'none',
fontSize: 14, fontWeight: 700,
display: 'inline-flex', alignItems: 'center', gap: 6,
padding: '8px 14px',
background: KT.bgPage,
border: `1px solid ${KT.border}`,
borderRadius: 999,
boxShadow: KT.shadowSm,
}}><Icon name="arrow-left" size={13} /> В ленту</Link>
<Link to="/kubikon" style={{
marginLeft: 'auto',
display: 'inline-flex', alignItems: 'center', gap: 10,
textDecoration: 'none',
}}>
<RublocsLogo size={32} />
<span style={{
fontSize: 22, fontWeight: 900, color: KT.text,
letterSpacing: -0.5,
}}>Рублокс</span>
</Link>
</div>
</div>
{/* Hero — большая аватарка + имя + статистика */}
<div style={{
position: 'relative',
background: KT.gradientHero,
backgroundSize: '200% 200%',
animation: 'kubikonGradientShift 16s ease-in-out infinite',
padding: '56px 24px 80px',
overflow: 'hidden',
}}>
{/* Floating shapes */}
<FloatingShapes />
<div style={{
position: 'relative', zIndex: 2,
maxWidth: 1100, margin: '0 auto',
display: 'flex', alignItems: 'center',
gap: 28, flexWrap: 'wrap',
}}>
{/* Avatar */}
<div style={{
width: 120, height: 120, borderRadius: '50%',
background: '#fff',
color: KT.accent,
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 48, fontWeight: 900,
flexShrink: 0,
boxShadow: '0 16px 40px rgba(0, 0, 0, 0.28), 0 0 0 6px rgba(255,255,255,0.18)',
animation: 'kubikonFloat 5s ease-in-out infinite',
}}>
{initial}
</div>
<div style={{ flex: 1, minWidth: 240 }}>
<div style={{
display: 'inline-flex', alignItems: 'center', gap: 6,
background: 'rgba(255,255,255,0.18)',
backdropFilter: 'blur(8px)',
border: '1px solid rgba(255,255,255,0.30)',
padding: '4px 12px',
borderRadius: 999,
fontSize: 11, fontWeight: 800,
color: '#fff', letterSpacing: 1.2,
textTransform: 'uppercase',
marginBottom: 12,
}}>
<Icon emoji="👤" size={13} /> Профиль автора
</div>
<h1 style={{
margin: 0,
fontSize: 'clamp(28px, 5vw, 44px)',
fontWeight: 900,
color: '#fff',
letterSpacing: -1,
textShadow: '0 2px 16px rgba(0,0,0,0.18)',
wordBreak: 'break-word',
}}>
{authorName || `Игрок #${userId}`}
</h1>
{/* Статистика */}
<div style={{
display: 'flex', gap: 12, marginTop: 16, flexWrap: 'wrap',
}}>
<StatPill icon="🎮" value={items.length} label={
items.length === 1 ? 'игра' : (items.length >= 2 && items.length <= 4) ? 'игры' : 'игр'
} />
<StatPill icon="▶️" value={totalPlays.toLocaleString('ru')} label="плеев" />
<StatPill icon="❤️" value={totalLikes.toLocaleString('ru')} label="лайков" />
</div>
</div>
</div>
</div>
{/* Список игр — overlap с hero */}
<div style={{
maxWidth: 1240, margin: '-50px auto 0',
padding: '0 24px 64px',
position: 'relative', zIndex: 5,
animation: 'kubikonHeroSlide 480ms cubic-bezier(0.2, 0.8, 0.4, 1) both',
animationDelay: '120ms',
}}>
{loading ? (
<SkeletonGrid />
) : items.length === 0 ? (
<div style={{
padding: 56, textAlign: 'center',
background: KT.bgPage,
border: `1px dashed ${KT.borderStrong}`,
borderRadius: KT.radiusXl,
color: KT.textSecondary,
animation: 'kubikonFadeInScale 420ms cubic-bezier(0.34, 1.56, 0.64, 1)',
}}>
<div style={{
fontSize: 64, marginBottom: 12,
animation: 'kubikonFloat 4s ease-in-out infinite',
display: 'inline-block',
}}><Icon name="sprout" size={14} /></div>
<div style={{
fontSize: 20, fontWeight: 800, color: KT.text, marginBottom: 6,
}}>
Пока нет опубликованных игр
</div>
<div style={{ fontSize: 14 }}>
У этого автора скоро здесь что-то появится
</div>
</div>
) : (
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(240px, 1fr))',
gap: 18,
}}>
{items.map((p, i) => (
<GameCard
key={p.id}
project={p}
onClick={() => navigate(`/kubikon/game/${p.id}`)}
animateDelay={Math.min(i * 35, 600)}
/>
))}
</div>
)}
</div>
</div>
);
};
const StatPill = ({ icon, value, label }) => (
<div style={{
display: 'inline-flex', alignItems: 'center', gap: 8,
background: 'rgba(255,255,255,0.18)',
backdropFilter: 'blur(12px)',
border: '1px solid rgba(255,255,255,0.25)',
padding: '8px 16px',
borderRadius: 999,
color: '#fff',
boxShadow: '0 4px 12px rgba(0,0,0,0.10)',
}}>
<Icon emoji={icon} size={16} />
<span style={{ fontSize: 16, fontWeight: 800 }}>{value}</span>
<span style={{ fontSize: 12, opacity: 0.86, fontWeight: 600 }}>{label}</span>
</div>
);
const FloatingShapes = () => (
<>
<div style={{
position: 'absolute', top: '20%', left: '8%',
width: 64, height: 64,
background: 'rgba(255, 255, 255, 0.12)',
borderRadius: 14,
transform: 'rotate(15deg)',
animation: 'kubikonFloat 8s ease-in-out infinite',
}} />
<div style={{
position: 'absolute', top: '60%', right: '12%',
width: 80, height: 80,
background: 'rgba(255, 255, 255, 0.10)',
borderRadius: 18,
transform: 'rotate(-10deg)',
animation: 'kubikonFloat 10s ease-in-out 1s infinite',
}} />
<div style={{
position: 'absolute', top: '30%', right: '32%',
width: 40, height: 40,
background: 'rgba(255, 255, 255, 0.14)',
borderRadius: 10,
transform: 'rotate(28deg)',
animation: 'kubikonFloat 7s ease-in-out 2s infinite',
}} />
</>
);
const GameCard = ({ project, onClick, animateDelay = 0 }) => {
const [hovered, setHovered] = useState(false);
const p = project;
return (
<div
role="button"
tabIndex={0}
onClick={onClick}
onKeyDown={(e) => { if (e.key === 'Enter') onClick(); }}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
style={{
background: KT.bgPage,
border: `1px solid ${hovered ? KT.accent : KT.border}`,
borderRadius: KT.radiusLg,
boxShadow: hovered ? KT.shadowLg : KT.shadow,
transform: hovered ? 'translateY(-6px)' : 'translateY(0)',
transition: 'all 280ms cubic-bezier(0.34, 1.56, 0.64, 1)',
cursor: 'pointer',
overflow: 'hidden',
animation: `kubikonFadeIn 460ms cubic-bezier(0.34, 1.56, 0.64, 1) ${animateDelay}ms both`,
}}
>
<div style={{
aspectRatio: '4/3',
background: KT.gradientBrand,
position: 'relative', overflow: 'hidden',
}}>
{p.thumbnail ? (
<img src={p.thumbnail} alt={p.title}
style={{
width: '100%', height: '100%', objectFit: 'cover',
transform: hovered ? 'scale(1.08)' : 'scale(1)',
transition: 'transform 600ms cubic-bezier(0.2, 0.8, 0.4, 1)',
}} />
) : (
<div style={{
position: 'absolute', inset: 0,
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 64, color: '#fff',
}}><Icon name="gamepad" size={14} /></div>
)}
{/* Умная лента: бейджа «ранг» больше нет все опубликованные
игры равны, их позицию определяет алгоритм в ленте. */}
<span style={{
position: 'absolute', top: 10, right: 10,
background: 'rgba(15, 23, 42, 0.85)',
color: '#fff',
fontSize: 11, padding: '3px 9px',
borderRadius: 999, fontWeight: 700,
backdropFilter: 'blur(8px)',
}}>{p.age_rating || 12}+</span>
</div>
<div style={{ padding: '12px 14px' }}>
<div style={{
fontSize: 15, fontWeight: 800,
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
color: KT.text, marginBottom: 4,
letterSpacing: -0.2,
}}>{p.title}</div>
<div style={{
fontSize: 12, color: KT.textMuted,
fontWeight: 600,
display: 'flex', gap: 12,
}}>
<span><Icon name="gamepad" size={13} /> {(p.play_count || 0).toLocaleString('ru')}</span>
<span><Icon name="heart" size={13} /> {(p.likes_count || 0).toLocaleString('ru')}</span>
</div>
</div>
</div>
);
};
const SkeletonGrid = () => (
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(240px, 1fr))',
gap: 18,
}}>
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} style={{
background: KT.bgPage,
border: `1px solid ${KT.border}`,
borderRadius: KT.radiusLg,
overflow: 'hidden',
boxShadow: KT.shadow,
animation: `kubikonFadeIn 400ms ease ${i * 60}ms both`,
}}>
<div style={skeletonStyle({ aspectRatio: '4/3', borderRadius: 0 })} />
<div style={{ padding: '12px 14px' }}>
<div style={skeletonStyle({ height: 16, width: '70%', marginBottom: 8 })} />
<div style={skeletonStyle({ height: 12, width: '40%' })} />
</div>
</div>
))}
</div>
);
export default KubikonUserGames;

File diff suppressed because it is too large Load Diff

2392
src/community/docsData.jsx Normal file

File diff suppressed because it is too large Load Diff

295
src/community/docsGames.js Normal file
View File

@ -0,0 +1,295 @@
/**
* docsGames.js каталог 50 мини-игр-уроков вики Рублокса.
*
* Раздел K вики. Каждая игра карточка: { id, num, title, stars, icon,
* desc, mechanics, ready }.
* - num порядковый номер 1..50
* - stars сложность 1..3
* - icon имя SVG-иконки из docsIcons.jsx (НЕ эмодзи)
* - desc что получится в игре, 1-2 предложения
* - mechanics список механик, которым учит игра
* - previewShot (необязательно) имя файла из public/wiki/ для
* превью на карточке. По умолчанию берётся
* lessonN-result.png; задаётся, когда другой кадр
* урока смотрится на карточке лучше.
* - ready есть ли подробный урок (пока у всех false наполним позже)
*
* Группы:
* 1-12 самые простые
* 13-28 простые с механиками
* 29-40 средние
* 41-50 сложные
*/
export const GAME_GROUPS = [
{ id: 'g1', title: 'Группа 1 — Самые простые', stars: 1,
hint: 'Постановка объектов, базовые скрипты, события, простые твины.' },
{ id: 'g2', title: 'Группа 2 — Простые с механиками', stars: 2,
hint: 'NPC, инвентарь, теги, billboard, камера, констрейнты.' },
{ id: 'g3', title: 'Группа 3 — Средние', stars: 2,
hint: 'Комбинации механик, GUI, состояние игры, NPC-логика.' },
{ id: 'g4', title: 'Группа 4 — Сложные', stars: 3,
hint: 'Полные игры, мультиплеер, продвинутые системы.' },
];
export const GAMES = [
// ── Группа 1 — Самые простые ──────────────────────────────────
{ id: 'collect-coins', num: 1, group: 'g1', stars: 1, icon: 'coin',
title: 'Собери монетки',
desc: 'Ходишь по уровню и собираешь жёлтые сферы, счёт растёт.',
mechanics: ['события касания', 'счётчик очков', 'удаление объектов'],
ready: false },
{ id: 'platform-jump', num: 2, group: 'g1', stars: 1, icon: 'jump',
title: 'Прыгай по платформам',
desc: 'Паркур из примитивов — допрыгай до финиша, не упав вниз.',
mechanics: ['примитивы-платформы', 'точка спавна', 'финиш'],
ready: false },
{ id: 'dont-fall', num: 3, group: 'g1', stars: 1, icon: 'hole',
title: 'Не упади',
desc: 'Платформы исчезают под ногами — нужно всё время убегать.',
mechanics: ['таймеры', 'исчезновение объектов', 'onTouch'],
ready: false },
{ id: 'button-door', num: 4, group: 'g1', stars: 1, icon: 'door',
title: 'Кнопка-открывашка',
desc: 'Нажми кнопку клавишей E — и откроется дверь в следующую комнату.',
mechanics: ['ProximityPrompt (E)', 'твины', 'перемещение объектов'],
ready: false },
{ id: 'maze', num: 5, group: 'g1', stars: 1, icon: 'maze',
title: 'Лабиринт',
desc: 'Найди путь из стен-кубов от старта к выходу из лабиринта.',
mechanics: ['постройка из блоков', 'спавн', 'триггер-финиш'],
// на карточке лучше виден лабиринт сверху — берём второй скрин урока
previewShot: 'lesson5-scene.png',
ready: false },
{ id: 'color-tiles', num: 6, group: 'g1', stars: 1, icon: 'palette',
title: 'Цветные плитки',
desc: 'Наступаешь на плитки на полу — и они меняют свой цвет.',
mechanics: ['onTouch', 'setColor', 'примитивы-плитки'],
ready: false },
{ id: 'catch-falling', num: 7, group: 'g1', stars: 1, icon: 'box',
title: 'Поймай падающее',
desc: 'С неба падают кубы — лови их, пока не упали на землю.',
mechanics: ['спавн с таймером', 'физика падения', 'счёт'],
ready: false },
{ id: 'run-to-finish', num: 8, group: 'g1', stars: 1, icon: 'flag',
title: 'Беги к финишу',
desc: 'Гонка на время: добеги до финишной черты как можно быстрее.',
mechanics: ['таймер', 'триггер-финиш', 'game.ui'],
ready: false },
{ id: 'traffic-light', num: 9, group: 'g1', stars: 1, icon: 'light',
title: 'Светофор',
desc: 'Стой на красный, беги на зелёный — успей дойти до конца.',
mechanics: ['лампы', 'таймеры', 'проверка движения'],
ready: false },
{ id: 'spring-jump', num: 10, group: 'g1', stars: 1, icon: 'spring',
title: 'Прыжок-пружина',
desc: 'Батуты подбрасывают тебя всё выше — допрыгни до верхней площадки.',
mechanics: ['пружины (spring)', 'boostJump', 'onTouch'],
ready: false },
{ id: 'echo-room', num: 11, group: 'g1', stars: 1, icon: 'sound',
title: 'Эхо-комната',
desc: 'Наступаешь на плитку — играет звук. Собери мелодию из шагов.',
mechanics: ['game.sound', 'onTouch', '3D-звук'],
ready: false },
{ id: 'code-door', num: 12, group: 'g1', stars: 1, icon: 'keypad',
title: 'Дверь по коду',
desc: 'Введи правильное число в поле ввода — и дверь откроется.',
mechanics: ['GUI-поле ввода', 'onSubmit', 'твины'],
ready: false },
// ── Группа 2 — Простые с механиками ───────────────────────────
{ id: 'trader', num: 13, group: 'g2', stars: 2, icon: 'trader',
title: 'Торговец',
desc: 'Поговори с NPC-торговцем по клавише E и получи от него предмет.',
mechanics: ['NPC', 'диалоги', 'инвентарь'],
ready: false },
{ id: 'collect-by-tag', num: 14, group: 'g2', stars: 2, icon: 'star',
title: 'Собери по тегам',
desc: 'Собери все объекты, помеченные тегом «звезда», на уровне.',
mechanics: ['теги', 'getTagged', 'счётчик'],
ready: false },
{ id: 'shooting-range', num: 15, group: 'g2', stars: 2, icon: 'crosshair',
title: 'Тир',
desc: 'Стреляй по мишеням лучом-raycast и считай выбитые очки.',
mechanics: ['raycast', 'onClick', 'счёт'],
ready: false },
{ id: 'lava-floor', num: 16, group: 'g2', stars: 2, icon: 'lava',
title: 'Лава-пол',
desc: 'Пол наносит урон — прыгай только по безопасным островкам.',
mechanics: ['урон по касанию', 'HP', 'платформы'],
ready: false },
{ id: 'key-chest', num: 17, group: 'g2', stars: 2, icon: 'key',
title: 'Ключ и сундук',
desc: 'Найди ключ на уровне, подбери его и открой запертый сундук.',
mechanics: ['инвентарь', 'has()', 'ProximityPrompt'],
ready: false },
{ id: 'swing', num: 18, group: 'g2', stars: 2, icon: 'swing',
title: 'Качели',
desc: 'Запрыгни на качели-констрейнт и катайся туда-сюда.',
mechanics: ['констрейнт-петля (hinge)', 'физика'],
ready: false },
{ id: 'elevator', num: 19, group: 'g2', stars: 2, icon: 'elevator',
title: 'Лифт',
desc: 'Платформа-лифт возит тебя между этажами здания.',
mechanics: ['твины', 'движущаяся платформа', 'триггеры'],
ready: false },
{ id: 'enemy-names', num: 20, group: 'g2', stars: 2, icon: 'tag',
title: 'Имена над врагами',
desc: 'У каждого врага над головой висит метка с именем и его HP.',
mechanics: ['billboard-метки', 'NPC', 'HP'],
ready: false },
{ id: 'chaser', num: 21, group: 'g2', stars: 2, icon: 'chase',
title: 'Преследователь',
desc: 'NPC гонится за тобой по всему уровню — убегай и прячься.',
mechanics: ['NPC follow', 'distance', 'onTick'],
ready: false },
{ id: 'danger-zone', num: 22, group: 'g2', stars: 2, icon: 'warning',
title: 'Зона опасности',
desc: 'Войдёшь в триггер-зону — начинаешь терять здоровье.',
mechanics: ['триггер-зона', 'onTouch/onUntouch', 'урон'],
ready: false },
{ id: 'switches', num: 23, group: 'g2', stars: 2, icon: 'lever',
title: 'Переключатели',
desc: 'Активируй три рычага в правильном порядке, чтобы пройти дальше.',
mechanics: ['ProximityPrompt', 'состояние', 'проверка порядка'],
ready: false },
{ id: 'falling-bridge', num: 24, group: 'g2', stars: 2, icon: 'bridge',
title: 'Падающий мост',
desc: 'Мост собран из исчезающих платформ — беги, пока он не рухнул.',
mechanics: ['таймеры', 'исчезновение', 'onTouch'],
ready: false },
{ id: 'flyby-camera', num: 25, group: 'g2', stars: 2, icon: 'camera',
title: 'Камера-облёт',
desc: 'При старте игры камера красиво облетает весь уровень.',
mechanics: ['game.camera.cutscene', 'onCutsceneDone'],
ready: false },
{ id: 'coin-magnet', num: 26, group: 'g2', stars: 2, icon: 'magnet',
title: 'Магнит монет',
desc: 'Монеты сами летят к игроку, когда он подходит близко.',
mechanics: ['твин к позиции', 'distance', 'onTick'],
ready: false },
{ id: 'double-jump', num: 27, group: 'g2', stars: 2, icon: 'doubleArrow',
title: 'Двойной прыжок',
desc: 'Паркур, где не пройти без двойного прыжка в воздухе.',
mechanics: ['setDoubleJump', 'платформы', 'финиш'],
ready: false },
{ id: 'ghost-walls', num: 28, group: 'g2', stars: 2, icon: 'ghost',
title: 'Призрачные стены',
desc: 'Некоторые стены становятся проходимыми — найди секретный путь.',
mechanics: ['passThrough', 'триггеры', 'секреты'],
ready: false },
// ── Группа 3 — Средние ────────────────────────────────────────
{ id: 'shop', num: 29, group: 'g3', stars: 2, icon: 'cart',
title: 'Магазин',
desc: 'GUI-список товаров — покупай предметы за собранные монеты.',
mechanics: ['GUI-список', 'экономика', 'инвентарь'],
ready: false },
{ id: 'quest-tasks', num: 30, group: 'g3', stars: 2, icon: 'scroll',
title: 'Квест с заданиями',
desc: 'NPC выдаёт цепочку заданий — выполни их все по очереди.',
mechanics: ['NPC-диалоги', 'состояние квеста', 'события'],
ready: false },
{ id: 'base-defense', num: 31, group: 'g3', stars: 2, icon: 'shield',
title: 'Защита базы',
desc: 'Враги идут к твоей базе — останавливай их, пока не дошли.',
mechanics: ['NPC-волны', 'raycast', 'HP базы'],
ready: false },
{ id: 'lap-race', num: 32, group: 'g3', stars: 2, icon: 'car',
title: 'Гонка с кругами',
desc: 'Проедь несколько кругов через чекпоинты на время.',
mechanics: ['чекпоинты', 'таймер', 'счётчик кругов'],
ready: false },
{ id: 'boss-platformer', num: 33, group: 'g3', stars: 2, icon: 'boss',
title: 'Платформер с боссом',
desc: 'Пройди паркур до NPC-босса и победи его в конце уровня.',
mechanics: ['паркур', 'NPC-босс', 'бой'],
ready: false },
{ id: 'harvest', num: 34, group: 'g3', stars: 2, icon: 'plant',
title: 'Сбор урожая',
desc: 'Растения растут на грядках — собирай их вовремя, пока спелые.',
mechanics: ['твины роста', 'таймеры', 'счёт'],
ready: false },
{ id: 'hide-from-npc', num: 35, group: 'g3', stars: 2, icon: 'hide',
title: 'Прятки от NPC',
desc: 'NPC ищет тебя по уровню — прячься за объектами, чтобы не нашёл.',
mechanics: ['NPC-логика', 'raycast видимости', 'distance'],
ready: false },
{ id: 'box-puzzle', num: 36, group: 'g3', stars: 2, icon: 'puzzle',
title: 'Головоломка с ящиками',
desc: 'Двигай ящики на кнопки-плиты, чтобы открыть запертую дверь.',
mechanics: ['физика толкания', 'кнопки-плиты', 'логика'],
ready: false },
{ id: 'obstacle-course', num: 37, group: 'g3', stars: 2, icon: 'obstacle',
title: 'Полоса препятствий',
desc: 'Шипы, ямы и движущиеся платформы — пройди всё без смерти.',
mechanics: ['шипы', 'движущиеся платформы', 'чекпоинты'],
ready: false },
{ id: 'music-game', num: 38, group: 'g3', stars: 2, icon: 'music',
title: 'Музыкальная игра',
desc: 'Запомни и повтори последовательность звуков, которую сыграла игра.',
mechanics: ['game.sound', 'массивы', 'проверка ответа'],
ready: false },
{ id: 'tower-build', num: 39, group: 'g3', stars: 2, icon: 'tower',
title: 'Башня — стройка',
desc: 'Ставь блоки по подсказкам скрипта и построй высокую башню.',
mechanics: ['спавн блоков', 'GUI-подсказки', 'счётчик'],
ready: false },
{ id: 'wave-survival', num: 40, group: 'g3', stars: 2, icon: 'zombie',
title: 'Выживание от волн',
desc: 'Волны врагов нападают одна за другой — продержись N секунд.',
mechanics: ['NPC-волны', 'таймеры', 'HP'],
ready: false },
// ── Группа 4 — Сложные ────────────────────────────────────────
{ id: 'adventure-platformer', num: 41, group: 'g4', stars: 3, icon: 'map',
title: 'Платформер-приключение',
desc: 'Большой уровень с чекпоинтами, врагами и финишем-сокровищем.',
mechanics: ['большой уровень', 'чекпоинты', 'враги', 'финиш'],
ready: false },
{ id: 'rpg-village', num: 42, group: 'g4', stars: 3, icon: 'village',
title: 'RPG-деревня',
desc: 'Деревня с NPC, диалогами, квестами, инвентарём и торговлей.',
mechanics: ['NPC', 'квесты', 'инвентарь', 'экономика'],
ready: false },
{ id: 'obstacle-race', num: 43, group: 'g4', stars: 3, icon: 'car',
title: 'Гонка с препятствиями',
desc: 'Трасса с бустами скорости и ловушками — приди первым к финишу.',
mechanics: ['бусты', 'ловушки', 'таймер', 'чекпоинты'],
ready: false },
{ id: 'tower-defense', num: 44, group: 'g4', stars: 3, icon: 'castle',
title: 'Tower Defense',
desc: 'Ставь башни вдоль дороги и отбивай волны наступающих врагов.',
mechanics: ['GUI-меню', 'NPC-волны', 'башни', 'экономика'],
ready: false },
{ id: 'arena-shooter', num: 45, group: 'g4', stars: 3, icon: 'gun',
title: 'Стрелялка-арена',
desc: 'Оружие, враги, очки и GUI-счёт — выживай на боевой арене.',
mechanics: ['оружие (Tool)', 'raycast', 'NPC', 'GUI-счёт'],
ready: false },
{ id: 'clicker', num: 46, group: 'g4', stars: 3, icon: 'click',
title: 'Кликер',
desc: 'GUI-игра: кликай по кнопке, копи очки и покупай улучшения.',
mechanics: ['GUI', 'game.save', 'экономика улучшений'],
ready: false },
{ id: 'escape-quest', num: 47, group: 'g4', stars: 3, icon: 'door',
title: 'Квест-побег',
desc: 'Комната-головоломка: реши все загадки и найди выход.',
mechanics: ['головоломки', 'инвентарь', 'состояние', 'триггеры'],
ready: false },
{ id: 'mp-tag', num: 48, group: 'g4', stars: 3, icon: 'chase',
title: 'Мультиплеер: Салки',
desc: 'Несколько игроков, один водящий — догони и осаль остальных.',
mechanics: ['мультиплеер', 'команды', 'game.room'],
ready: false },
{ id: 'mp-race', num: 49, group: 'g4', stars: 3, icon: 'trophy',
title: 'Мультиплеер: Гонка',
desc: 'Соревнование игроков на трассе с общим счётом комнаты.',
mechanics: ['мультиплеер', 'game.room', 'чекпоинты'],
ready: false },
{ id: 'make-your-own', num: 50, group: 'g4', stars: 3, icon: 'sparkles',
title: 'Своя игра',
desc: 'Гайд: как придумать и собрать собственную игру с нуля.',
mechanics: ['проектирование игры', 'все механики вместе'],
ready: false },
];

File diff suppressed because it is too large Load Diff

445
src/community/docsIcons.jsx Normal file
View File

@ -0,0 +1,445 @@
import React from 'react';
/**
* docsIcons.jsx самописные SVG-иконки для вики Рублокса.
*
* Единый стиль: viewBox 24×24, обводка currentColor, stroke-width 1.8,
* скруглённые концы. Цвет наследуется от текста родителя.
* Эмодзи в интерфейсе не используем только эти иконки.
*
* Использование: <DocIcon name="rocket" size={32} />
*/
const S = {
fill: 'none',
stroke: 'currentColor',
strokeWidth: 1.8,
strokeLinecap: 'round',
strokeLinejoin: 'round',
};
const F = { fill: 'currentColor', stroke: 'none' };
const ICONS = {
// разделы вики
rocket: () => (
<>
<path d="M12 3c3.4 1.7 5.4 5 5.4 9l-2.2 2.3H8.8L6.6 12C6.6 8 8.6 4.7 12 3Z" {...S} />
<circle cx="12" cy="9" r="1.7" {...S} />
<path d="M9 17c-1 .6-1.7 1.9-1.8 4 2.1-.1 3.4-.8 4-1.8M15 17c1 .6 1.7 1.9 1.8 4-2.1-.1-3.4-.8-4-1.8" {...S} />
</>
),
cube: () => (
<>
<path d="M12 3 21 8v8l-9 5-9-5V8l9-5Z" {...S} />
<path d="M3 8l9 5 9-5M12 13v8" {...S} />
</>
),
window: () => (
<>
<rect x="3" y="4" width="18" height="16" rx="2.5" {...S} />
<path d="M3 9h18M7 6.5h.01M10 6.5h.01" {...S} />
</>
),
code: () => (
<>
<path d="M9 8l-4 4 4 4M15 8l4 4-4 4" {...S} />
<path d="M13 5l-2 14" {...S} />
</>
),
run: () => (
<>
<circle cx="14" cy="5" r="2" {...S} />
<path d="M12.5 9 9 11l1 4M12.5 9l3 2 .5 4M9 11l-3 1M10 15l-2 5M15.5 15l1.5 5" {...S} />
</>
),
target: () => (
<>
<circle cx="12" cy="12" r="8" {...S} />
<circle cx="12" cy="12" r="4.2" {...S} />
<circle cx="12" cy="12" r="1.1" {...F} />
</>
),
gear: () => (
<>
<circle cx="12" cy="12" r="3" {...S} />
<path d="M12 2.5v3M12 18.5v3M21.5 12h-3M5.5 12h-3M19 5l-2.1 2.1M7.1 16.9 5 19M19 19l-2.1-2.1M7.1 7.1 5 5" {...S} />
</>
),
book: () => (
<>
<path d="M5 4h11a2 2 0 0 1 2 2v14H7a2 2 0 0 1-2-2V4Z" {...S} />
<path d="M5 18a2 2 0 0 1 2-2h11" {...S} />
<path d="M9 8h6M9 11h5" {...S} />
</>
),
glossary: () => (
<>
<path d="M4 5h16v13a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V5Z" {...S} />
<path d="M8 9h8M8 12h8M8 15h5" {...S} />
</>
),
bug: () => (
<>
<rect x="8" y="8" width="8" height="10" rx="4" {...S} />
<path d="M8 12H4M8 15H4.5M16 12h4M16 15h3.5M9 8l-1.5-2.5M15 8l1.5-2.5M12 18v3" {...S} />
</>
),
// общие
wiki: () => (
<>
<path d="M4 5.5A2.5 2.5 0 0 1 6.5 3H20v15H6.5A2.5 2.5 0 0 0 4 20.5V5.5Z" {...S} />
<path d="M4 20.5A2.5 2.5 0 0 1 6.5 18H20v3H6.5A2.5 2.5 0 0 1 4 20.5Z" {...S} />
</>
),
gamepad: () => (
<>
<path d="M7 8h10a4 4 0 0 1 4 4v.6c0 2-1.2 3.9-3.4 3.9-1.5 0-2.1-1-3-2.2-.4-.5-.9-.8-1.6-.8h-2c-.7 0-1.2.3-1.6.8-.9 1.2-1.5 2.2-3 2.2C3.7 16.5 2.5 14.6 2.5 12.6V12a4 4 0 0 1 4-4Z" {...S} />
<path d="M7.5 11v2.4M6.3 12.2h2.4" {...S} />
<circle cx="15.7" cy="11.4" r="1" {...F} />
<circle cx="17.6" cy="13.2" r="1" {...F} />
</>
),
lightbulb: () => (
<>
<path d="M9 17a5.5 5.5 0 1 1 6 0c-.6.4-1 1-1 1.7V19h-4v-.3c0-.7-.4-1.3-1-1.7Z" {...S} />
<path d="M9.5 21h5M10 19h4" {...S} />
</>
),
pin: () => (
<>
<path d="M9 3h6l-1 6 3 3v2H7v-2l3-3-1-6Z" {...S} />
<path d="M12 14v7" {...S} />
</>
),
globeIcon: () => (
<>
<circle cx="12" cy="12" r="8.5" {...S} />
<path d="M3.5 12h17M12 3.5c2.5 2.4 2.5 14.6 0 17M12 3.5c-2.5 2.4-2.5 14.6 0 17" {...S} />
</>
),
// иконки для 50 игр
coin: () => (
<>
<circle cx="12" cy="12" r="8" {...S} />
<circle cx="12" cy="12" r="4.5" {...S} />
<path d="M12 9.5v5" {...S} />
</>
),
jump: () => (
<>
<circle cx="12" cy="5" r="2" {...S} />
<path d="M12 7.5v5M12 12.5l-3 4M12 12.5l3 4M9 9.5 6 8M15 9.5 18 8" {...S} />
<path d="M5 21h14" {...S} />
</>
),
hole: () => (
<>
<ellipse cx="12" cy="15" rx="8" ry="4" {...S} />
<path d="M6 14c1-3 3.5-5 6-5s5 2 6 5" {...S} />
</>
),
door: () => (
<>
<rect x="6" y="3" width="12" height="18" rx="1" {...S} />
<circle cx="14.5" cy="12" r="1" {...F} />
</>
),
maze: () => (
<>
<rect x="3.5" y="3.5" width="17" height="17" rx="1.5" {...S} />
<path d="M3.5 9h6v6h6M14.5 3.5v6M9.5 20.5v-5.5" {...S} />
</>
),
palette: () => (
<>
<path d="M12 3a9 9 0 0 0 0 18c1.4 0 1.8-1 1.4-1.9-.5-1 .2-2.1 1.3-2.1H17a4 4 0 0 0 4-4c0-5-4-10-9-10Z" {...S} />
<circle cx="8" cy="11" r="1" {...F} />
<circle cx="12" cy="8" r="1" {...F} />
<circle cx="16" cy="11" r="1" {...F} />
</>
),
box: () => (
<>
<path d="M12 3 20 7v10l-8 4-8-4V7l8-4Z" {...S} />
<path d="M4 7l8 4 8-4M12 11v10" {...S} />
</>
),
flag: () => (
<>
<path d="M6 3v18" {...S} />
<path d="M6 4h11l-2 3.5L17 11H6V4Z" {...S} />
</>
),
light: () => (
<>
<rect x="9" y="3" width="6" height="16" rx="3" {...S} />
<circle cx="12" cy="7" r="1.3" {...F} />
<circle cx="12" cy="15" r="1.3" {...F} />
<path d="M9 21h6" {...S} />
</>
),
spring: () => (
<>
<path d="M7 21h10M8 17h8" {...S} />
<path d="M9 17c-1-1.5-1-3 0-4.5s1-3 0-4.5M15 17c1-1.5 1-3 0-4.5s-1-3 0-4.5" {...S} />
<path d="M9 3.5h6" {...S} />
</>
),
sound: () => (
<>
<path d="M4 9.5h3.5L13 5v14L7.5 14.5H4v-5Z" {...S} />
<path d="M16 9.5a3.5 3.5 0 0 1 0 5M18.5 7a7 7 0 0 1 0 10" {...S} />
</>
),
keypad: () => (
<>
<rect x="4" y="3" width="16" height="18" rx="2" {...S} />
<path d="M8 8h.01M12 8h.01M16 8h.01M8 12h.01M12 12h.01M16 12h.01M8 16h8" {...S} />
</>
),
trader: () => (
<>
<circle cx="12" cy="7" r="3" {...S} />
<path d="M6 20a6 6 0 0 1 12 0" {...S} />
<path d="M9 13l-2 2M15 13l2 2" {...S} />
</>
),
star: () => (
<path d="M12 3.5l2.5 5.4 5.9.6-4.4 4 1.2 5.8-5.2-3-5.2 3 1.2-5.8-4.4-4 5.9-.6L12 3.5Z" {...S} />
),
crosshair: () => (
<>
<circle cx="12" cy="12" r="8" {...S} />
<path d="M12 2.5v4M12 17.5v4M2.5 12h4M17.5 12h4" {...S} />
</>
),
lava: () => (
<>
<path d="M3 16h18M3 16c1.5-2 3-2 4.5 0s3 2 4.5 0 3-2 4.5 0 3 2 4.5 0" {...S} />
<path d="M12 3c.4 2.5-1.7 3.3-1.7 5.8a1.7 1.7 0 0 0 3.4 0c0-.7-.3-1.2-.3-1.2 1.5.8 2.6 2.3 2.6 4.4" {...S} />
</>
),
key: () => (
<>
<circle cx="8" cy="8" r="4" {...S} />
<path d="M11 11l8 8M16 16l2-2M18 18l2-2" {...S} />
</>
),
swing: () => (
<>
<path d="M5 4h14M12 4v8" {...S} />
<rect x="8" y="12" width="8" height="3" rx="1" {...S} />
<path d="M9 12l-1 8M15 12l1 8" {...S} />
</>
),
elevator: () => (
<>
<rect x="6" y="3" width="12" height="18" rx="1.5" {...S} />
<path d="M12 8l-2.5 3h5L12 8ZM12 16l-2.5-3h5L12 16Z" {...F} />
</>
),
tag: () => (
<>
<path d="M3 12V5a2 2 0 0 1 2-2h7l9 9-9 9-9-9Z" {...S} />
<circle cx="8.5" cy="8.5" r="1.5" {...S} />
</>
),
chase: () => (
<>
<circle cx="7" cy="6" r="2.2" {...S} />
<path d="M7 8.5v5M7 13.5l-2 5M7 13.5l2 4M5 10.5 3 9M9 10.5l2-1" {...S} />
<path d="M16 11l4 1-4 1" {...S} />
<circle cx="19" cy="6" r="2" {...S} />
</>
),
warning: () => (
<>
<path d="M12 4 21 19H3L12 4Z" {...S} />
<path d="M12 10v4" {...S} />
<circle cx="12" cy="16.6" r="0.5" {...F} />
</>
),
lever: () => (
<>
<rect x="4" y="14" width="16" height="6" rx="1.5" {...S} />
<circle cx="9" cy="17" r="1.2" {...F} />
<circle cx="15" cy="17" r="1.2" {...F} />
<path d="M9 14V8M15 14v-4" {...S} />
</>
),
bridge: () => (
<>
<path d="M3 9c0 5 4 9 9 9s9-4 9-9" {...S} />
<path d="M3 9h18M7 9.6v4M12 9.8v6M17 9.6v4" {...S} />
</>
),
camera: () => (
<>
<rect x="3" y="7" width="14" height="11" rx="2" {...S} />
<path d="M17 11l4-2.5v9L17 14" {...S} />
<circle cx="9" cy="12.5" r="2.5" {...S} />
</>
),
magnet: () => (
<>
<path d="M6 3v8a6 6 0 0 0 12 0V3" {...S} />
<path d="M6 8h4M14 8h4" {...S} />
</>
),
doubleArrow: () => (
<>
<path d="M12 3l-5 6h10l-5-6ZM12 12l-5 6h10l-5-6Z" {...S} />
</>
),
ghost: () => (
<>
<path d="M5 20v-9a7 7 0 0 1 14 0v9l-2.3-1.6L14.3 20 12 18.4 9.7 20l-2.4-1.6L5 20Z" {...S} />
<circle cx="9.5" cy="10.5" r="1" {...F} />
<circle cx="14.5" cy="10.5" r="1" {...F} />
</>
),
cart: () => (
<>
<path d="M3 4h2.5l2.2 10.5a1 1 0 0 0 1 .8h7.6a1 1 0 0 0 1-.8L19 7H6" {...S} />
<circle cx="9.5" cy="19" r="1.3" {...S} />
<circle cx="16.5" cy="19" r="1.3" {...S} />
</>
),
scroll: () => (
<>
<path d="M6 4h11a2 2 0 0 1 2 2v11a3 3 0 0 1-3 3H7a3 3 0 0 1-3-3V6" {...S} />
<path d="M4 6a2 2 0 0 1 4 0v11M9 9h7M9 12h7M9 15h4" {...S} />
</>
),
shield: () => (
<>
<path d="M12 3 5 6v6c0 4.5 3 7.5 7 9 4-1.5 7-4.5 7-9V6l-7-3Z" {...S} />
<path d="M9 12l2 2 4-4" {...S} />
</>
),
car: () => (
<>
<path d="M3 14l1.8-5a2 2 0 0 1 1.9-1.4h8.6A2 2 0 0 1 19 9l1.8 5M3 14h18M3 14v3.5h2.5V14M21 14v3.5h-2.5V14" {...S} />
<circle cx="7.5" cy="14" r="1.6" {...S} />
<circle cx="16.5" cy="14" r="1.6" {...S} />
</>
),
boss: () => (
<>
<path d="M5 18V9l3 2 4-6 4 6 3-2v9H5Z" {...S} />
<path d="M5 20.5h14" {...S} />
<circle cx="9.5" cy="13.5" r="0.9" {...F} />
<circle cx="14.5" cy="13.5" r="0.9" {...F} />
</>
),
plant: () => (
<>
<path d="M12 21v-9" {...S} />
<path d="M12 12c0-3-2-5-5-5 0 3 2 5 5 5ZM12 12c0-3 2-5 5-5 0 3-2 5-5 5Z" {...S} />
<path d="M8 21h8" {...S} />
</>
),
hide: () => (
<>
<path d="M3 12s3.5-6 9-6c1.4 0 2.7.4 3.8 1M21 12s-3.5 6-9 6c-1.4 0-2.7-.4-3.8-1" {...S} />
<path d="M9.5 9.5a3.5 3.5 0 0 0 5 5M4 4l16 16" {...S} />
</>
),
puzzle: () => (
<path d="M10 4h4v2.2a1.5 1.5 0 1 0 3 0V4h3v3.5h-2.2a1.5 1.5 0 1 0 0 3H20v3.5h-3v-2.2a1.5 1.5 0 1 0-3 0V17h-4v-2.5H7.8a1.5 1.5 0 1 0 0-3H10V8H7.8a1.5 1.5 0 1 1 0-3H10V4Z" {...S} />
),
obstacle: () => (
<>
<rect x="3.5" y="10" width="17" height="5" rx="1" {...S} />
<path d="M6 10 9 15M11 10 14 15M16 10 19 15M5 10V8h14v2M5 15v2h14v-2" {...S} />
</>
),
music: () => (
<>
<path d="M9 18V6l10-2v12" {...S} />
<circle cx="6" cy="18" r="3" {...S} />
<circle cx="16" cy="16" r="3" {...S} />
</>
),
tower: () => (
<>
<path d="M7 21V9h10v12M7 9 9 5h6l2 4" {...S} />
<path d="M10 21v-5h4v5M9 9h6M9 13h6" {...S} />
</>
),
zombie: () => (
<>
<rect x="6" y="3" width="12" height="11" rx="1.5" {...S} />
<path d="M9 14v7M15 14v7M6 8H4l1 4M18 8h2l-1 4" {...S} />
<path d="M9.5 8h.01M14.5 8h.01M10 11h4" {...S} />
</>
),
map: () => (
<>
<path d="M9 4 4 6v14l5-2 6 2 5-2V4l-5 2-6-2Z" {...S} />
<path d="M9 4v14M15 6v14" {...S} />
</>
),
village: () => (
<>
<path d="M3 20V11l4-3 4 3v9M13 20v-7l4-3 4 3v7" {...S} />
<path d="M3 20h18M6 20v-4h2v4M16 20v-3h2v3" {...S} />
</>
),
castle: () => (
<>
<path d="M4 21V8l2 2V6l2 2V5l2 2 2-2v3l2-2v4l2-2v13" {...S} />
<path d="M4 21h12M9 21v-4h3v4" {...S} />
</>
),
gun: () => (
<>
<path d="M3 8h13v4h-3l-1 3H8l-1-3H3V8Z" {...S} />
<path d="M16 8h5v3h-3M7 12v3" {...S} />
</>
),
click: () => (
<>
<path d="M9 4v8M9 8H4M9 8l5 1.5L9 11l3 4-2 1-3-4-2 3V8Z" {...S} />
</>
),
sparkles: () => (
<>
<path d="M12 4l1.6 4.4L18 10l-4.4 1.6L12 16l-1.6-4.4L6 10l4.4-1.6L12 4Z" {...S} />
<path d="M18.5 14.5l.7 1.8 1.8.7-1.8.7-.7 1.8-.7-1.8-1.8-.7 1.8-.7.7-1.8Z" {...S} />
</>
),
trophy: () => (
<>
<path d="M7 4h10v4a5 5 0 0 1-10 0V4Z" {...S} />
<path d="M7 6H4.5v1.5A3 3 0 0 0 7.5 10M17 6h2.5v1.5A3 3 0 0 1 16.5 10" {...S} />
<path d="M12 13v3M9 20h6M10 20c0-1.5.5-2.5 2-2.5s2 1 2 2.5" {...S} />
</>
),
};
export default function DocIcon({ name, size = 24, className = '' }) {
const render = ICONS[name];
if (!render) {
// фолбэк точка, чтобы было видно отсутствие иконки
return (
<svg width={size} height={size} viewBox="0 0 24 24" className={className} aria-hidden="true">
<circle cx="12" cy="12" r="2.5" {...F} />
</svg>
);
}
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
className={className}
role="img"
aria-hidden="true"
>
{render()}
</svg>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,387 @@
import React from 'react';
import DocIcon from './docsIcons';
/**
* learnArticles.jsx статьи блока «Изучай Студию» Рублокс Студии.
*
* Каждая статья { id, icon, color, title, summary, cover, body }:
* - id ключ для роута /kubikon-studio/learn/<id>
* - icon имя SVG-иконки (docsIcons), для запасного фона карточки
* - color акцентный цвет карточки
* - cover файл превью из public/assets/kubikon-learn/<cover>
* - summary краткое описание для карточки
* - body готовый JSX статьи
*
* Рендерит KubikonLearn.jsx.
*/
// Хелперы разметки (своя плашка-стилистика, классы в KubikonLearn)
const Tip = ({ children }) => (
<div className="lrnBox lrnBox--tip">
<span className="lrnBox__ico"><DocIcon name="lightbulb" size={18} /></span>
<div>{children}</div>
</div>
);
const Warn = ({ children }) => (
<div className="lrnBox lrnBox--warn">
<span className="lrnBox__ico"><DocIcon name="warning" size={18} /></span>
<div>{children}</div>
</div>
);
const Ok = ({ children }) => (
<div className="lrnBox lrnBox--ok">
<span className="lrnBox__ico"><DocIcon name="flag" size={18} /></span>
<div>{children}</div>
</div>
);
// Шаг инструкции с номером
const Step = ({ n, children }) => (
<div className="lrnStep">
<span className="lrnStep__n">{n}</span>
<div className="lrnStep__body">{children}</div>
</div>
);
// Подзаголовок внутри статьи
const H = ({ children }) => <h3 className="lrnH">{children}</h3>;
// Статьи
export const ARTICLES = [
// 1. С чего начать
{
id: 'start',
icon: 'rocket',
color: '#3357ff',
cover: 'start.jpg',
title: 'С чего начать',
summary: 'Что такое Рублокс Студия и как сделать свою первую игру за пару минут.',
body: (
<>
<p>
<b>Рублокс Студия</b> это редактор, в котором ты создаёшь
свои 3D-игры: строишь мир, ставишь объекты, пишешь логику и
публикуешь игру, чтобы в неё играли другие.
</p>
<H>Первая игра за 1 минуту</H>
<Step n="1">
На главной Студии нажми <b>«Новая игра»</b> или выбери
готовый шаблон в блоке «Открой шаблон».
</Step>
<Step n="2">
Откроется редактор. Шаблон уже содержит ландшафт можно
сразу ходить по нему и достраивать.
</Step>
<Step n="3">
Нажми <b>«Запустить»</b> попробуй игру так, как её увидят
игроки.
</Step>
<Step n="4">
Когда игра готова нажми <b>«Опубликовать»</b>, и она
появится в общей ленте Рублокса.
</Step>
<H>Что дальше</H>
<p>
Изучи остальные статьи этого раздела: как добавлять свои
модели и текстуры, как писать скрипты, чем отличаются плееры
и как правильно тестировать игру. А подробный учебник по
всем механикам в разделе <b>ВИКИ</b>.
</p>
<Tip>
Лучший способ научиться открыть готовый шаблон или
игру-урок из вики и разобраться, как она устроена.
</Tip>
</>
),
},
// 2. Свои текстуры и 3D-модели (ОБЯЗАТЕЛЬНАЯ)
{
id: 'assets',
icon: 'cube',
color: '#8b5cf6',
cover: 'assets.jpg',
title: 'Свои текстуры и 3D-модели',
summary: 'Как загрузить свои картинки и 3D-модели .glb в игру. Про лимиты на загрузку.',
body: (
<>
<p>
В Рублокс Студию можно добавлять <b>свои картинки</b> (для
текстур и интерфейса) и <b>свои 3D-модели</b> в формате{' '}
<b>.glb</b> (или .gltf). Это позволяет наполнять игру
уникальным контентом, а не только встроенными объектами.
</p>
<H>Как загрузить 3D-модель (.glb)</H>
<Step n="1">
Открой свою игру в редакторе.
</Step>
<Step n="2">
В панели объектов найди раздел загрузки моделей и нажми
кнопку <b>«Загрузить .glb»</b>.
</Step>
<Step n="3">
Выбери файл <b>.glb</b> или <b>.gltf</b> на компьютере.
Модель появится в палитре её можно ставить в сцену как
обычный объект.
</Step>
<p>
Где взять .glb-модели: бесплатные библиотеки (например
Sketchfab, Poly Pizza, Quaternius), или сделай свою в
Blender и экспортируй в .glb.
</p>
<H>Как загрузить картинку (текстуру)</H>
<p>
В редакторе есть библиотека картинок туда можно загрузить
свои изображения и использовать их как текстуры объектов
или картинки в интерфейсе игры. Картинка автоматически
ужимается до 512 пикселей, чтобы не раздувать игру.
</p>
<H>Лимиты на загрузку важно</H>
<p>
Рублокс Студия веб-платформа: вся твоя игра вместе с
моделями, текстурами и звуками <b>хранится на сервере</b>.
Место на сервере не бесконечно, поэтому действуют лимиты:
</p>
<ul>
<li><b>3D-модели .glb:</b> до 6 штук на одну игру, каждая не больше 4 МБ.</li>
<li><b>Картинки:</b> до 24 штук на одну игру.</li>
<li><b>Звуки:</b> ограничены по числу и размеру на игру.</li>
<li><b>Игры:</b> до 40 игр на один аккаунт.</li>
</ul>
<Warn>
Лимиты не придирка, а забота о платформе. Рублокс делает
один человек, и сервер пока небольшой. Если каждый зальёт
сотни тяжёлых моделей место кончится у всех. Поэтому:
удаляй ненужные игры и не загружай огромные модели без
необходимости.
</Warn>
<Tip>
Чтобы модель весила меньше: уменьши количество полигонов в
Blender, сожми текстуры модели, убирай лишние материалы.
Лёгкая модель и грузится быстрее у игроков.
</Tip>
<Ok>
Достиг лимита игр на аккаунт? Удали те, что не нужны
освободится место для новых. Старые наработки лучше держать
компактными.
</Ok>
</>
),
},
// 3. Плееры (ОБЯЗАТЕЛЬНАЯ)
{
id: 'players',
icon: 'window',
color: '#06b6d4',
cover: 'players.jpg',
title: 'Плееры: веб, Windows, мобильный',
summary: 'Где играют в твою игру и почему её обязательно нужно тестировать в плеере.',
body: (
<>
<p>
Игру ты создаёшь <b>здесь</b>, в Рублокс Студии на сайте
Майнкрафтии в браузере. Но играют в твою игру на разных
устройствах, и у каждого свой «плеер» (программа, которая
запускает игру).
</p>
<H>Три плеера Рублокса</H>
<ul>
<li>
<b>Веб-плеер</b> прямо в браузере на сайте. На нём же
ты тестируешь игру кнопкой «Запустить» в редакторе.
</li>
<li>
<b>Windows-приложение</b> отдельная программа для ПК.
Работает на движке <b>Godot</b>.
</li>
<li>
<b>Мобильное приложение</b> для телефонов. Работает на
отдельном движке, написанном специально для Рублокса.
</li>
</ul>
<H>Почему это важно</H>
<p>
Редактор и три плеера используют <b>разные движки</b>. Мы
стараемся, чтобы игра везде выглядела и работала одинаково,
но <b>небольшие различия возможны</b>: где-то иначе ляжет
свет, чуть по-другому сработает физика или эффект.
</p>
<Warn>
<b>Всегда проверяй свою игру в плеере, а не только в
редакторе.</b> То, что хорошо смотрится в редакторе, на
Windows или телефоне может выглядеть немного иначе. Прошёл
игру сам в плеере значит, в неё точно можно играть.
</Warn>
<Tip>
Если заметил, что в плеере что-то работает не так, как в
редакторе, это ценная находка. Отправь баг-репорт (см.
статью «Бета-версия и баг-репорты»), и расхождение починят.
</Tip>
</>
),
},
// 4. Бета-версия и баг-репорты (ОБЯЗАТЕЛЬНАЯ)
{
id: 'beta',
icon: 'bug',
color: '#ec4899',
cover: 'beta.jpg',
title: 'Бета-версия и баг-репорты',
summary: 'Почему сейчас бета, как сообщить об ошибке и помочь сделать Рублокс лучше.',
body: (
<>
<p>
Рублокс большая платформа: редактор, три плеера,
мультиплеер, лента игр, чат, система скинов. И всё это
делает <b>один человек МИН</b>.
</p>
<H>Сейчас Рублокс это бета-версия</H>
<p>
Работы очень много, и причесать каждую мелочь к идеалу
одному человеку пока не получилось. Поэтому текущая версия
Рублокса <b>бета</b>: всё основное работает, но кое-где
могут встречаться баги и шероховатости.
</p>
<p>
Когда платформа станет стабильной и отполированной будет
полноценный <b>релиз</b>. А пока помоги её до него
довести.
</p>
<H>Нашёл баг отправь репорт</H>
<p>
Если что-то сломалось, работает странно или вылетает
<b> отправь баг-репорт</b>. МИН видит все репорты и
исправляет ошибки. Каждый репорт реально помогает.
</p>
<Step n="1">
Заметил проблему в редакторе или плеере найди кнопку
<b> «Сообщить о баге»</b>.
</Step>
<Step n="2">
Опиши, что случилось: что ты делал, что ожидал и что
получилось вместо этого. Чем подробнее тем быстрее
починят.
</Step>
<Step n="3">
Если можно приложи скриншот. Картинка часто объясняет
баг лучше слов.
</Step>
<Ok>
Хороший баг-репорт: «Открыл игру в Windows-плеере, нажал
прыжок на платформе 3 персонаж провалился сквозь
платформу. В редакторе всё нормально». Понятно, что, где и
при каких условиях.
</Ok>
<Warn>
Не нужно: репорты вроде «всё плохо», «не работает» без
подробностей. По ним невозможно понять, что чинить.
</Warn>
<Tip>
Спасибо, что пользуешься Рублоксом на стадии беты. Каждый
твой репорт приближает релиз и платформа станет лучше для
всех.
</Tip>
</>
),
},
// 5. Скрипты и логика
{
id: 'scripts',
icon: 'code',
color: '#16a34a',
cover: 'scripts.jpg',
title: 'Скрипты: оживи свою игру',
summary: 'Как с помощью скриптов сделать так, чтобы в игре что-то происходило.',
body: (
<>
<p>
Построить красивый мир половина дела. Чтобы игра была
<b> игрой</b>, в ней должно что-то происходить: считаться
очки, появляться враги, открываться двери. За это отвечают
<b> скрипты</b>.
</p>
<H>Что такое скрипт</H>
<p>
Скрипт это небольшая программа на JavaScript. Её можно
повесить на весь мир или на конкретный объект. Через объект
{' '}<code>game</code> скрипт управляет игрой: игроком,
объектами, интерфейсом, звуками.
</p>
<H>Что умеют скрипты</H>
<ul>
<li>реагировать на касание объекта, клик, нажатие клавиши;</li>
<li>двигать и создавать объекты, менять их цвет;</li>
<li>считать очки, таймер, здоровье игрока;</li>
<li>показывать текст и интерфейс, проигрывать звуки;</li>
<li>телепортировать игрока, спавнить врагов и эффекты.</li>
</ul>
<Tip>
Не нужно писать всё с нуля. Открой готовый шаблон
(Платформер, Шутер, Гонки) или игру-урок из вики
посмотри, как там написаны скрипты, и переделай под себя.
</Tip>
<p>
Полный справочник по скриптам что доступно через{' '}
<code>game.*</code>, какие есть события и команды в
разделе <b>ВИКИ</b>.
</p>
</>
),
},
// 6. Публикация
{
id: 'publish',
icon: 'globeIcon',
color: '#f59e0b',
cover: 'publish.jpg',
title: 'Как опубликовать игру',
summary: 'Что происходит при публикации и как попасть в ленту Рублокса.',
body: (
<>
<p>
Когда игра готова её можно <b>опубликовать</b>, и она
появится в общей ленте Рублокса, где её увидят и запустят
другие игроки.
</p>
<H>Как опубликовать</H>
<Step n="1">
Проверь игру: пройди её сам в плеере от начала до конца.
</Step>
<Step n="2">
В редакторе нажми <b>«Опубликовать»</b>.
</Step>
<Step n="3">
Скрипты автоматически проверятся на безопасность. Если всё
чисто игра сразу попадёт в ленту.
</Step>
<H>Как игру заметят</H>
<p>
Лента показывает игры по «рейтингу интереса»: чем больше
игроки лайкают игру и проводят в ней времени тем выше она
в рекомендациях. Новым играм лента какое-то время даёт
«фору», чтобы их успели заметить.
</p>
<Tip>
Понятная цель, честная обложка, звук и проверенный геймплей
вот что делает игру популярной. Подробно в разделе
«Правила создания игр».
</Tip>
<Ok>
Опубликовал не бросай: чини баги, добавляй уровни. Лента
любит игры, которые развиваются.
</Ok>
</>
),
},
];
export const getArticle = (id) => ARTICLES.find((a) => a.id === id) || null;

View File

@ -0,0 +1 @@
{"version":1,"scene":{"blocks":[],"models":[{"type":"city-suburban-building-type-a","x":-26,"y":0.2,"z":-26,"rotationY":3.141592653589793},{"type":"city-suburban-building-type-b","x":-26,"y":0.2,"z":-12,"rotationY":3.141592653589793},{"type":"city-suburban-building-type-c","x":-12,"y":0.2,"z":-26,"rotationY":3.141592653589793},{"type":"city-suburban-building-type-d","x":-12,"y":0.2,"z":-12,"rotationY":3.141592653589793},{"type":"city-suburban-building-type-e","x":-26,"y":0.2,"z":10,"rotationY":0},{"type":"city-suburban-building-type-f","x":-26,"y":0.2,"z":24,"rotationY":0},{"type":"city-suburban-building-type-g","x":-12,"y":0.2,"z":10,"rotationY":0},{"type":"city-suburban-building-type-i","x":-12,"y":0.2,"z":24,"rotationY":0},{"type":"city-suburban-building-type-a","x":10,"y":0.2,"z":-26,"rotationY":3.141592653589793},{"type":"city-suburban-building-type-b","x":10,"y":0.2,"z":-12,"rotationY":3.141592653589793},{"type":"city-suburban-building-type-c","x":24,"y":0.2,"z":-26,"rotationY":3.141592653589793},{"type":"city-suburban-building-type-d","x":24,"y":0.2,"z":-12,"rotationY":3.141592653589793},{"type":"city-suburban-building-type-e","x":10,"y":0.2,"z":10,"rotationY":0},{"type":"city-suburban-building-type-f","x":10,"y":0.2,"z":24,"rotationY":0},{"type":"city-suburban-building-type-g","x":24,"y":0.2,"z":10,"rotationY":0},{"type":"city-suburban-building-type-i","x":24,"y":0.2,"z":24,"rotationY":0},{"type":"tree","x":-22,"y":0.2,"z":5,"rotationY":0},{"type":"tree","x":-22,"y":0.2,"z":-5,"rotationY":0},{"type":"tree","x":-11,"y":0.2,"z":5,"rotationY":0},{"type":"tree","x":-11,"y":0.2,"z":-5,"rotationY":0},{"type":"tree","x":0,"y":0.2,"z":5,"rotationY":0},{"type":"tree","x":0,"y":0.2,"z":-5,"rotationY":0},{"type":"tree","x":11,"y":0.2,"z":5,"rotationY":0},{"type":"tree","x":11,"y":0.2,"z":-5,"rotationY":0},{"type":"tree","x":22,"y":0.2,"z":5,"rotationY":0},{"type":"tree","x":22,"y":0.2,"z":-5,"rotationY":0}],"primitives":[],"userModels":[],"terrain":[],"robloxTerrain":{"format":"robloxterrain-v1","origin":{"x":-21,"y":0,"z":-21},"size":{"x":42,"y":24,"z":42},"palette":["","grass","rock","sand","snow","dirt","water","asphalt","concrete","wood","glacier","salt","mud","leaves","leaves_orange","trunk","trunk_white","rock_moss","flower_red","flower_blue","flower_yellow","mushroom_red","tall_grass"],"mat":"fgABcgMAfgABcgMAfgABcgMAfgABcgMAfgABcgMAfgABcgMAfgABcgMAfgABcgMAfgABcgMAfgABcgMAfgABcgMAfgABcgMAfgABcgMAfgABcgMAfgABcgMAfgABcgMAfgABcgMAfgABcgMAfgABcgMAfgABcgMAfgABcgMAfgABcgMAfgABcgMAfgABcgMAfgABcgMAfgABcgMAfgABcgMAfgABcgMAfgABcgMAfgABcgMAfgABcgMAfgABcgMAfgABcgMAfgABcgMAfgABcgMAfgABcgMAfgABcgMAfgABcgMAfgABcgMAfgABcgMAfgABcgMAfgABcgMA","density":"KgCDKgBDKgADcgMAKgCDKgBDKgADcgMAKgCDKgBDKgADcgMAKgCDKgBDKgADcgMAKgCDKgBDKgADcgMAKgCDKgBDKgADcgMAKgCDKgBDKgADcgMAKgCDKgBDKgADcgMAKgCDKgBDKgADcgMAKgCDKgBDKgADcgMAKgCDKgBDKgADcgMAKgCDKgBDKgADcgMAKgCDKgBDKgADcgMAKgCDKgBDKgADcgMAKgCDKgBDKgADcgMAKgCDKgBDKgADcgMAKgCDKgBDKgADcgMAKgCDKgBDKgADcgMAKgCDKgBDKgADcgMAKgCDKgBDKgADcgMAKgCDKgBDKgADcgMAKgCDKgBDKgADcgMAKgCDKgBDKgADcgMAKgCDKgBDKgADcgMAKgCDKgBDKgADcgMAKgCDKgBDKgADcgMAKgCDKgBDKgADcgMAKgCDKgBDKgADcgMAKgCDKgBDKgADcgMAKgCDKgBDKgADcgMAKgCDKgBDKgADcgMAKgCDKgBDKgADcgMAKgCDKgBDKgADcgMAKgCDKgBDKgADcgMAKgCDKgBDKgADcgMAKgCDKgBDKgADcgMAKgCDKgBDKgADcgMAKgCDKgBDKgADcgMAKgCDKgBDKgADcgMAKgCDKgBDKgADcgMAKgCDKgBDKgADcgMAKgCDKgBDKgADcgMA","decoParams":{"flowersDensity":0,"grassDensity":0,"treesDensity":0,"seed":909,"bbox":{"minX":-84,"maxX":84,"minZ":-84,"maxZ":84},"genParams":{"seed":909,"heightmap":{"scale":0.012,"octaves":3,"persistence":0.45,"lacunarity":2,"amplitude":0,"baseHeight":1,"exponent":1},"biomes":[{"id":"plain","topMaterial":"grass","softMaterial":"dirt","hardMaterial":"rock","features":{"trees":0,"treeTypes":[],"grass":0},"threshold":[0,1]}],"smoothDeco":{"flowersDensity":0,"grassDensity":0,"treesDensity":0}}}},"decorations":[],"folders":[],"gui":[],"inventory":{"slots":[null,null,null,null,null],"activeIndex":0},"spawnPoint":{"x":0,"y":3,"z":0},"playerModelType":"skin_bacon-hair","worldSize":100,"floorEnabled":false,"jumpPowerMul":1,"cameraMode":"third","crosshair":"none","shadowQuality":"soft","environment":{"preset":"day","timeOfDay":12,"dayDurationMin":5,"nightDurationMin":3,"fogEnabled":false,"fogColor":[0.7,0.8,0.9],"fogDensity":0.01},"audio":{"ambientId":"none","ambientVolume":0.3,"musicId":"none","musicVolume":0.25},"assets":[],"sounds":[],"glbModels":[],"scripts":[]},"editorCamera":{"position":{"x":50.4,"y":34,"z":50.4},"rotation":{"x":0.3983901633637537,"y":-2.356194490192345,"z":0}},"settings":{"isGd":false}}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1 @@
{"version":1,"scene":{"blocks":[],"models":[],"primitives":[{"id":1,"type":"box","name":"Платформа","x":6,"y":1.2,"z":0,"sx":2.8,"sy":0.6,"sz":2.8,"color":"#ff5b5b","material":"glossy","canCollide":true,"visible":true,"anchored":true,"mass":1,"rotationX":0,"rotationY":0,"rotationZ":0},{"id":2,"type":"box","name":"Платформа","x":11,"y":3.4000000000000004,"z":2,"sx":2.6,"sy":0.6,"sz":2.6,"color":"#ffa23a","material":"glossy","canCollide":true,"visible":true,"anchored":true,"mass":1,"rotationX":0,"rotationY":0,"rotationZ":0},{"id":3,"type":"box","name":"Платформа","x":16,"y":5.6000000000000005,"z":-2,"sx":2.4,"sy":0.6,"sz":2.4,"color":"#ffe14d","material":"glossy","canCollide":true,"visible":true,"anchored":true,"mass":1,"rotationX":0,"rotationY":0,"rotationZ":0},{"id":4,"type":"box","name":"Платформа","x":21,"y":7.8,"z":1,"sx":2.6,"sy":0.6,"sz":2.6,"color":"#46d160","material":"glossy","canCollide":true,"visible":true,"anchored":true,"mass":1,"rotationX":0,"rotationY":0,"rotationZ":0},{"id":5,"type":"box","name":"Платформа","x":26,"y":10,"z":-1,"sx":2.4,"sy":0.6,"sz":2.4,"color":"#3aa0ff","material":"glossy","canCollide":true,"visible":true,"anchored":true,"mass":1,"rotationX":0,"rotationY":0,"rotationZ":0},{"id":6,"type":"box","name":"Платформа","x":31,"y":12.2,"z":1,"sx":3.2,"sy":0.6,"sz":3.2,"color":"#a05bff","material":"glossy","canCollide":true,"visible":true,"anchored":true,"mass":1,"rotationX":0,"rotationY":0,"rotationZ":0},{"id":7,"type":"trigger","name":"Финиш","x":31,"y":14.2,"z":1,"sx":3,"sy":3,"sz":3,"color":"#2ad24a","material":"glossy","canCollide":false,"visible":true,"anchored":true,"mass":1,"rotationX":0,"rotationY":0,"rotationZ":0}],"userModels":[],"terrain":[],"robloxTerrain":{"format":"robloxterrain-v1","origin":{"x":-15,"y":0,"z":-15},"size":{"x":30,"y":24,"z":30},"palette":["","grass","rock","sand","snow","dirt","water","asphalt","concrete","wood","glacier","salt","mud","leaves","leaves_orange","trunk","trunk_white","rock_moss","flower_red","flower_blue","flower_yellow","mushroom_red","tall_grass"],"mat":"WgADdgIAWgADdgIAWgADdgIAWgADdgIAWgADdgIAWgADdgIAWgADdgIAWgADdgIAWgADdgIAWgADdgIAWgADdgIAWgADdgIAWgADdgIAWgADdgIAWgADdgIAWgADdgIAWgADdgIAWgADdgIAWgADdgIAWgADdgIAWgADdgIAWgADdgIAWgADdgIAWgADdgIAWgADdgIAWgADdgIAWgADdgIAWgADdgIAWgADdgIAWgADdgIA","density":"HgCDHgBDHgADdgIAHgCDHgBDHgADdgIAHgCDHgBDHgADdgIAHgCDHgBDHgADdgIAHgCDHgBDHgADdgIAHgCDHgBDHgADdgIAHgCDHgBDHgADdgIAHgCDHgBDHgADdgIAHgCDHgBDHgADdgIAHgCDHgBDHgADdgIAHgCDHgBDHgADdgIAHgCDHgBDHgADdgIAHgCDHgBDHgADdgIAHgCDHgBDHgADdgIAHgCDHgBDHgADdgIAHgCDHgBDHgADdgIAHgCDHgBDHgADdgIAHgCDHgBDHgADdgIAHgCDHgBDHgADdgIAHgCDHgBDHgADdgIAHgCDHgBDHgADdgIAHgCDHgBDHgADdgIAHgCDHgBDHgADdgIAHgCDHgBDHgADdgIAHgCDHgBDHgADdgIAHgCDHgBDHgADdgIAHgCDHgBDHgADdgIAHgCDHgBDHgADdgIAHgCDHgBDHgADdgIAHgCDHgBDHgADdgIA","decoParams":{"flowersDensity":0,"grassDensity":0,"treesDensity":0,"seed":13,"bbox":{"minX":-60,"maxX":60,"minZ":-60,"maxZ":60},"genParams":{"seed":13,"heightmap":{"scale":0.012,"octaves":3,"persistence":0.45,"lacunarity":2,"amplitude":0,"baseHeight":1,"exponent":1},"biomes":[{"id":"desert","topMaterial":"sand","softMaterial":"sand","hardMaterial":"sand","features":{"trees":0,"treeTypes":[],"grass":0},"threshold":[0,1]}],"smoothDeco":{"flowersDensity":0,"grassDensity":0,"treesDensity":0}}}},"decorations":[],"folders":[],"gui":[],"inventory":{"slots":[null,null,null,null,null],"activeIndex":0},"spawnPoint":{"x":0,"y":2.2,"z":0},"playerModelType":"skin_bacon-hair","worldSize":100,"floorEnabled":false,"jumpPowerMul":1,"cameraMode":"third","crosshair":"none","shadowQuality":"soft","environment":{"preset":"day","timeOfDay":12,"dayDurationMin":5,"nightDurationMin":3,"fogEnabled":false,"fogColor":[0.7,0.8,0.9],"fogDensity":0.01},"audio":{"ambientId":"none","ambientVolume":0.3,"musicId":"none","musicVolume":0.25},"assets":[],"sounds":[],"glbModels":[],"scripts":[{"id":"pf_main","name":"Игра","target":null,"code":"// Двойной прыжок — чтобы платформы реально допрыгивались.\ngame.player.setDoubleJump(true);\ngame.ui.showText('Прыгай по платформам! В воздухе можно прыгнуть ещё раз.', 4);\nconst SX = 0, SY = 2.20, SZ = 0;\ngame.onTick(() => {\n const p = game.player.position;\n if (p.y < -5.8) { game.player.teleport(SX, SY, SZ); game.sound.play('lose'); }\n});"},{"id":"pf_finish","name":"Финиш","target":{"kind":"primitive","id":7},"code":"let won = false;\ngame.self.onTouch(() => {\n if (won) return; won = true;\n game.ui.showText('Победа!', 4);\n game.sound.play('win');\n game.scene.spawnParticles('confetti', game.self.position, { duration: 3, count: 3 });\n});"}]},"editorCamera":{"position":{"x":36,"y":34,"z":36},"rotation":{"x":0.5324817864083563,"y":-2.356194490192345,"z":0}},"settings":{"isGd":false}}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,121 @@
/**
* templateScreenshots.js генерирует PNG-скриншоты шаблонов offscreen.
*
* Запускается dev-кнопкой «Перегенерировать превью» в KubikonStudio.
* Для каждого шаблона:
* 1) создаёт оффскрин Babylon engine + scene
* 2) грузит state шаблона через scene.loadFromState
* 3) ждёт следующего кадра (модели подгружаются async)
* 4) делает canvas.toDataURL('image/jpeg')
* 5) триггерит скачивание файла
*
* Полученные PNG-файлы пользователь кладёт в
* public/assets/kubikon-templates/<id>.jpg
* И они подхватываются как обычные статические картинки.
*/
import { BabylonScene } from '../KubikonEditor/engine/BabylonScene';
import { TEMPLATES } from './templates';
const SCREENSHOT_W = 640;
const SCREENSHOT_H = 360;
/**
* Сгенерировать скриншот одного шаблона. Возвращает Promise<dataUrl>.
*/
async function captureOne(template) {
// Временный canvas, добавляем в DOM (Babylon engine требует видимый canvas
// для GL-контекста — но мы скрываем визуально через position:absolute + opacity:0).
const canvas = document.createElement('canvas');
canvas.width = SCREENSHOT_W;
canvas.height = SCREENSHOT_H;
canvas.style.position = 'fixed';
canvas.style.left = '-9999px';
canvas.style.top = '0';
canvas.style.width = `${SCREENSHOT_W}px`;
canvas.style.height = `${SCREENSHOT_H}px`;
canvas.style.opacity = '0';
canvas.style.pointerEvents = 'none';
canvas.tabIndex = -1;
document.body.appendChild(canvas);
let scene = null;
try {
scene = new BabylonScene(canvas);
scene.init();
// Стандартная камера для красивого ракурса (3/4 сверху)
if (scene.camera) {
scene.camera.position.set(60, 60, 60);
// Нацеливаем камеру в (0, 5, 0) через yaw/pitch.
// UniversalCamera использует rotation.y = yaw, rotation.x = pitch.
const tx = 0, ty = 5, tz = 0;
const dx = tx - scene.camera.position.x;
const dy = ty - scene.camera.position.y;
const dz = tz - scene.camera.position.z;
scene.camera.rotation.y = Math.atan2(dx, dz);
const horiz = Math.sqrt(dx * dx + dz * dz);
scene.camera.rotation.x = -Math.atan2(dy, horiz);
scene.camera.rotation.z = 0;
}
const state = template.build();
await scene.loadFromState(state);
// Прогреваем 3 кадра и ждём — модели подгружаются async, материалы
// компилятся в первом render-loop.
for (let i = 0; i < 8; i++) {
scene.scene.render();
await new Promise(res => requestAnimationFrame(res));
}
// Дополнительная пауза 500мс для GLB-моделей
await new Promise(res => setTimeout(res, 500));
for (let i = 0; i < 4; i++) {
scene.scene.render();
await new Promise(res => requestAnimationFrame(res));
}
// Снимок
const dataUrl = canvas.toDataURL('image/jpeg', 0.85);
return dataUrl;
} finally {
// Чистим
try { scene?.dispose?.(); } catch (e) { /* ignore */ }
try { document.body.removeChild(canvas); } catch (e) { /* ignore */ }
}
}
/**
* Триггер скачивания PNG-файла в браузере.
*/
function downloadDataUrl(dataUrl, filename) {
const a = document.createElement('a');
a.href = dataUrl;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}
/**
* Сгенерировать скриншоты ВСЕХ шаблонов и скачать их по одному.
* Возвращает Promise который резолвится массивом dataUrl'ов.
*
* @param {(progress:{current:number,total:number,id:string}) => void} onProgress
*/
export async function generateAllTemplateScreenshots(onProgress) {
const results = [];
for (let i = 0; i < TEMPLATES.length; i++) {
const tpl = TEMPLATES[i];
if (onProgress) onProgress({ current: i + 1, total: TEMPLATES.length, id: tpl.id });
// eslint-disable-next-line no-await-in-loop
const dataUrl = await captureOne(tpl);
downloadDataUrl(dataUrl, `${tpl.id}.jpg`);
results.push({ id: tpl.id, dataUrl });
// Небольшая пауза между генерациями чтобы браузер не тормозил
// eslint-disable-next-line no-await-in-loop
await new Promise(res => setTimeout(res, 200));
}
return results;
}

113
src/community/templates.js Normal file
View File

@ -0,0 +1,113 @@
/**
* templates.js шаблоны игр для KubikonStudio.
*
* Каждый шаблон это готовый снимок project_data, СГЕНЕРИРОВАННЫЙ
* самим движком: настоящий гладкий ландшафт (RobloxTerrain / Surface
* Nets + биомы + деревья/трава/цветы), а для геймплейных плюс
* примитивы и скрипты.
*
* JSON-снимки лежат в ./template-data/<id>.json. Их собрал инструмент
* dev-tools/wiki-shots/gen-templates.js: открывает редактор, запускает
* window.__robloxTest(size, params) с пресет-параметрами под жанр,
* сериализует результат через BabylonScene.serialize().
*
* Чтобы пересобрать шаблоны (новые параметры/декор):
* 1) правишь CONFIGS в gen-templates.js
* 2) node gen-templates.js пересоберёт JSON
* 3) копируешь template-data/*.json сюда
* 4) node shoot-template.js пересоберёт превью-картинки
*
* build() шаблона возвращает project_data его сразу можно
* отдать в createProject({ project_data: JSON.stringify(build()) }).
*/
import plain from './template-data/plain.json';
import hills from './template-data/hills.json';
import island from './template-data/island.json';
import city from './template-data/city.json';
import village from './template-data/village.json';
import platformer from './template-data/platformer.json';
import shooter from './template-data/shooter.json';
import racing from './template-data/racing.json';
// build() отдаёт глубокую копию — чтобы создание игры из шаблона
// не мутировало общий импортированный объект.
const snap = (data) => () => JSON.parse(JSON.stringify(data));
/** Список всех шаблонов с метаданными — рендерится в KubikonStudio. */
export const TEMPLATES = [
// === Геймплейные (с готовой логикой) ===
{
id: 'platformer',
title: 'Платформер',
desc: 'Прыгай по платформам до финиша',
icon: 'tpl-platformer',
color: '#5a8c3e',
genre: 'platformer',
build: snap(platformer),
},
{
id: 'shooter',
title: 'Шутер',
desc: 'Уничтожь все мишени кликом',
icon: 'tpl-shooter',
color: '#a83232',
genre: 'shooter',
build: snap(shooter),
},
{
id: 'racing',
title: 'Гонки',
desc: 'Пройди трек с чек-поинтами на время',
icon: 'tpl-racing',
color: '#d97a3a',
genre: 'racing',
build: snap(racing),
},
// === Ландшафтные (готовый гладкий мир) ===
{
id: 'plain',
title: 'Равнина',
desc: 'Холмистый луг с цветами и деревьями',
icon: 'tpl-plain',
color: '#5a8c3e',
genre: 'sandbox',
build: snap(plain),
},
{
id: 'hills',
title: 'Холмы',
desc: 'Высокие горы со снежными вершинами',
icon: 'tpl-hills',
color: '#7a8b7a',
genre: 'adventure',
build: snap(hills),
},
{
id: 'island',
title: 'Острова',
desc: 'Архипелаг с пляжами и пальмами',
icon: 'tpl-island',
color: '#3a8bd9',
genre: 'adventure',
build: snap(island),
},
{
id: 'city',
title: 'Город',
desc: 'Городские кварталы с домами',
icon: 'tpl-city',
color: '#5a6478',
genre: 'sandbox',
build: snap(city),
},
{
id: 'village',
title: 'Деревня',
desc: 'Деревенька с домиками на поляне',
icon: 'tpl-village',
color: '#a8743f',
genre: 'rpg',
build: snap(village),
},
];

View File

@ -0,0 +1,121 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
/**
* EmailConfirmNotice модалка-предупреждение «подтвердите email».
*
* Показывается, когда бэкенд вернул ошибку `email_not_confirmed` при
* попытке заблокированного действия (комментарий, публикация игры, чат).
*
* Управляется через проп `open`. Кнопка «Подтвердить email» ведёт на
* /settingssecur, где есть блок подтверждения.
*
* Props:
* open bool, показывать ли модалку
* onClose закрыть модалку
* action короткое описание заблокированного действия (для текста),
* напр. 'писать комментарии', 'публиковать игры'
*/
// Самописная inline-SVG иконка письма (правило проекта без эмодзи).
const MailIcon = ({ size = 40 }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none"
stroke="currentColor" strokeWidth="1.8" strokeLinecap="round"
strokeLinejoin="round">
<rect x="2" y="4" width="20" height="16" rx="3" />
<path d="M3 6l9 7 9-7" />
</svg>
);
const EmailConfirmNotice = ({ open, onClose, action = 'пользоваться этой функцией' }) => {
const navigate = useNavigate();
if (!open) return null;
const goConfirm = () => {
onClose?.();
navigate('/settingssecur');
};
return (
<div
onClick={onClose}
style={{
position: 'fixed', inset: 0, zIndex: 10050,
background: 'rgba(15, 23, 42, 0.55)',
backdropFilter: 'blur(4px)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: 20,
fontFamily: '"Roboto Condensed", system-ui, sans-serif',
}}
>
<div
onClick={(e) => e.stopPropagation()}
style={{
background: '#fff',
borderRadius: 18,
maxWidth: 420, width: '100%',
overflow: 'hidden',
boxShadow: '0 24px 60px rgba(15, 23, 42, 0.3)',
}}
>
{/* Шапка */}
<div style={{
background: 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)',
color: '#fff',
padding: '26px 24px 22px',
textAlign: 'center',
}}>
<div style={{ marginBottom: 8 }}><MailIcon size={44} /></div>
<div style={{ fontSize: 20, fontWeight: 800, letterSpacing: -0.3 }}>
Подтвердите email
</div>
</div>
{/* Тело */}
<div style={{ padding: 24 }}>
<p style={{
margin: 0, fontSize: 15, lineHeight: 1.55, color: '#334155',
textAlign: 'center',
}}>
Чтобы {action}, нужно подтвердить адрес электронной
почты. Это быстро мы пришлём код на твой email.
</p>
<p style={{
margin: '12px 0 0', fontSize: 13, lineHeight: 1.5,
color: '#94a3b8', textAlign: 'center',
}}>
Курсы и игры остаются доступны без подтверждения
ограничены только комментарии, чат и публикация игр.
</p>
<div style={{ display: 'flex', gap: 10, marginTop: 22 }}>
<button
onClick={onClose}
style={{
flex: 1, height: 44, borderRadius: 10,
border: '1px solid #cbd5e1', background: '#fff',
color: '#334155', fontSize: 14, fontWeight: 600,
cursor: 'pointer', fontFamily: 'inherit',
}}
>
Позже
</button>
<button
onClick={goConfirm}
style={{
flex: 2, height: 44, borderRadius: 10, border: 'none',
background: 'linear-gradient(135deg, #3357ff 0%, #1e2da5 100%)',
color: '#fff', fontSize: 14, fontWeight: 700,
cursor: 'pointer', fontFamily: 'inherit',
}}
>
Подтвердить email
</button>
</div>
</div>
</div>
</div>
);
};
export default EmailConfirmNotice;

View File

@ -0,0 +1,645 @@
import React, { useState, useRef } from 'react';
import { jwtDecode } from 'jwt-decode';
import * as Kubikon3DApi from '../../api/Kubikon3DService';
/**
* KubikonBugReportButton плавающая кнопка справа снизу + модалка
* для отправки баг-репорта со скриншотом. Синий wow-стиль Рублокса.
*
* Используется в плеере (`bugType='player'`, projectId передаётся) и в редакторе
* (`bugType='editor'`).
*
* Принимает props:
* - bugType: 'player' | 'editor' | 'site' | 'other'
* - projectId: number | null (если репорт привязан к конкретной игре)
* - bottomOffset: number (отступ снизу, чтобы не наезжать на чужой UI)
* - rightOffset: number
* - hidden: bool (если true кнопка не рисуется,
* триггерится извне через [data-kubikon-bug-btn])
*/
const KubikonBugReportButton = ({
bugType = 'site',
projectId = null,
bottomOffset = 16,
rightOffset = 16,
hidden = false,
}) => {
const [open, setOpen] = useState(false);
const [hover, setHover] = useState(false);
return (
<>
<style>{BUG_KEYFRAMES}</style>
<button
data-kubikon-bug-btn
onClick={() => setOpen(true)}
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
title="Сообщить об ошибке"
style={{
position: 'fixed',
bottom: bottomOffset,
right: rightOffset,
zIndex: 1500,
width: 52, height: 52,
borderRadius: '50%',
background: BUG.gradientBrand,
border: '1px solid rgba(255, 255, 255, 0.18)',
boxShadow: hover
? '0 12px 28px rgba(51, 87, 255, 0.55), 0 0 0 6px rgba(51, 87, 255, 0.18)'
: '0 8px 20px rgba(51, 87, 255, 0.40)',
color: '#fff',
fontSize: 22,
cursor: 'pointer',
display: hidden ? 'none' : 'flex',
alignItems: 'center',
justifyContent: 'center',
fontFamily: BUG.font,
transform: hover ? 'translateY(-2px) scale(1.05)' : 'translateY(0) scale(1)',
transition: 'all 220ms cubic-bezier(0.34, 1.56, 0.64, 1)',
animation: hover ? 'none' : 'bugPulseRing 2.4s ease-in-out infinite',
}}
>
🐛
</button>
{open && (
<KubikonBugReportModal
bugType={bugType}
projectId={projectId}
onClose={() => setOpen(false)}
/>
)}
</>
);
};
const BUG_TYPES = [
{ id: 'player', label: '🎮 Проблема в плеере', desc: 'запуск/управление/UI' },
{ id: 'editor', label: '🔨 Проблема в редакторе', desc: 'блоки/сохранение/скрипты' },
{ id: 'site', label: '🌐 Проблема на сайте', desc: 'страницы/ссылки' },
{ id: 'other', label: '❓ Другое', desc: 'что-то ещё' },
];
const KubikonBugReportModal = ({ bugType: initialType, projectId, onClose }) => {
const [bugType, setBugType] = useState(initialType || 'site');
const [title, setTitle] = useState('');
const [message, setMessage] = useState('');
const [file, setFile] = useState(null);
const [filePreview, setFilePreview] = useState(null);
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState(null);
const [done, setDone] = useState(false);
const [dragOver, setDragOver] = useState(false);
const fileInputRef = useRef(null);
const userId = (() => {
try {
const t = localStorage.getItem('Authorization');
if (!t) return null;
return jwtDecode(t).id || null;
} catch (e) { return null; }
})();
const onFileChange = (e) => {
const f = e.target.files?.[0] || null;
applyFile(f);
};
const applyFile = (f) => {
setFile(f);
if (f) {
const reader = new FileReader();
reader.onload = () => setFilePreview(reader.result);
reader.readAsDataURL(f);
} else {
setFilePreview(null);
}
};
const onDrop = (e) => {
e.preventDefault();
setDragOver(false);
const f = e.dataTransfer?.files?.[0];
if (f && f.type.startsWith('image/')) applyFile(f);
};
const submit = async () => {
if (!message.trim() && !title.trim()) {
setSubmitError('Опиши, что не так — хотя бы пару слов.');
return;
}
setSubmitting(true);
setSubmitError(null);
try {
await Kubikon3DApi.createBugReport({
bug_type: bugType,
title: title.trim(),
message: message.trim(),
url_path: window.location.pathname,
user_agent: navigator.userAgent,
user_id: userId,
project_id: projectId,
screenshot: file,
});
setDone(true);
} catch (e) {
setSubmitError(e?.response?.data?.error || e?.message || 'Ошибка сети');
setSubmitting(false);
}
};
return (
<div onClick={onClose} style={modalBackdrop}>
<style>{BUG_KEYFRAMES}</style>
<div onClick={(e) => e.stopPropagation()} style={modalCard}>
{done ? (
<SuccessView onClose={onClose} />
) : (
<>
{/* Градиентный header */}
<div style={{
background: BUG.gradientBrand,
padding: '20px 24px',
display: 'flex', alignItems: 'center',
justifyContent: 'space-between', gap: 12,
position: 'relative',
overflow: 'hidden',
}}>
{/* Декоративные плавающие кружки */}
<div style={{
position: 'absolute', top: -20, right: 60,
width: 80, height: 80, borderRadius: '50%',
background: 'rgba(255,255,255,0.10)',
pointerEvents: 'none',
}} />
<div style={{
position: 'absolute', bottom: -30, left: 100,
width: 60, height: 60, borderRadius: '50%',
background: 'rgba(255,255,255,0.08)',
pointerEvents: 'none',
}} />
<div style={{
display: 'inline-flex', alignItems: 'center', gap: 14,
position: 'relative', zIndex: 1,
}}>
<div style={{
width: 44, height: 44, borderRadius: 12,
background: 'rgba(255,255,255,0.18)',
border: '1px solid rgba(255,255,255,0.25)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 22,
animation: 'bugFloat 3.6s ease-in-out infinite',
}}>🐛</div>
<div>
<h2 style={{
margin: 0, fontSize: 18, fontWeight: 800, color: '#fff',
letterSpacing: -0.3,
}}>Сообщить об ошибке</h2>
<div style={{
fontSize: 12, color: 'rgba(255,255,255,0.78)',
marginTop: 2, fontWeight: 600,
}}>
Расскажи, что пошло не так мы исправим
</div>
</div>
</div>
<button
onClick={onClose}
style={iconCloseBtn}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(255,255,255,0.22)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'rgba(255,255,255,0.10)';
}}
>×</button>
</div>
<div style={{ padding: '22px 24px' }}>
{/* Тип бага — карточки с иконками */}
<div style={field}>
<div style={fieldLabel}>📍 Где сломалось</div>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(2, 1fr)',
gap: 8,
}}>
{BUG_TYPES.map(t => (
<BugTypeCard
key={t.id}
option={t}
selected={bugType === t.id}
onClick={() => setBugType(t.id)}
/>
))}
</div>
</div>
{/* Заголовок */}
<div style={field}>
<div style={fieldLabel}> Заголовок (коротко)</div>
<FocusableInput
value={title}
onChange={(v) => setTitle(v)}
placeholder="Например: «Не работает прыжок»"
maxLength={200}
/>
</div>
{/* Подробности */}
<div style={field}>
<div style={fieldLabel}>📝 Подробности</div>
<FocusableTextarea
value={message}
onChange={(v) => setMessage(v)}
placeholder="Опиши: что делал, что ожидал, что получилось"
rows={5}
/>
</div>
{/* Скриншот — drag-and-drop зона */}
<div style={field}>
<div style={fieldLabel}>📎 Скриншот (необязательно, 5 МБ)</div>
<input
ref={fileInputRef}
type="file"
accept="image/png,image/jpeg,image/gif,image/webp"
onChange={onFileChange}
style={{ display: 'none' }}
/>
{!file ? (
<button
onClick={() => fileInputRef.current?.click()}
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
onDragLeave={() => setDragOver(false)}
onDrop={onDrop}
style={{
width: '100%',
padding: '24px 16px',
background: dragOver
? BUG.accentBg
: '#fafbfd',
border: dragOver
? `2px dashed ${BUG.accent}`
: `2px dashed ${BUG.borderHover}`,
borderRadius: 12,
color: dragOver ? BUG.accent : BUG.textMuted,
cursor: 'pointer',
fontFamily: BUG.font,
fontSize: 14, fontWeight: 700,
transition: 'all 200ms ease',
display: 'flex', alignItems: 'center',
justifyContent: 'center', gap: 10,
}}
>
<span style={{
fontSize: 22,
transform: dragOver ? 'scale(1.2)' : 'scale(1)',
transition: 'transform 200ms ease',
}}>📎</span>
{dragOver ? 'Отпусти, и оно загрузится' : 'Выбрать или перетащить картинку'}
</button>
) : (
<div style={{
background: BUG.bgInput,
border: `1px solid ${BUG.border}`,
borderRadius: 12,
padding: 10,
display: 'flex',
gap: 12,
alignItems: 'center',
animation: 'bugFadeInScale 220ms cubic-bezier(0.34, 1.56, 0.64, 1)',
}}>
{filePreview && (
<img src={filePreview} alt="preview" style={{
width: 88, height: 64, objectFit: 'cover',
borderRadius: 8,
border: `1px solid ${BUG.border}`,
boxShadow: '0 4px 12px rgba(0,0,0,0.4)',
}} />
)}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
fontSize: 13, fontWeight: 700, color: BUG.text,
overflow: 'hidden', textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}>{file.name}</div>
<div style={{
fontSize: 11, color: BUG.textMuted, marginTop: 3,
display: 'inline-flex', alignItems: 'center', gap: 6,
}}>
<span style={{
width: 6, height: 6, borderRadius: '50%',
background: BUG.success,
}} />
{(file.size / 1024).toFixed(0)} КБ · готово
</div>
</div>
<button
onClick={() => { setFile(null); setFilePreview(null); }}
style={removeFileBtn}
title="Убрать"
></button>
</div>
)}
</div>
{submitError && (
<div style={{
background: BUG.dangerBg,
border: `1px solid ${BUG.danger}55`,
borderRadius: 10,
padding: '10px 14px',
marginBottom: 14,
color: BUG.danger, fontSize: 13, fontWeight: 600,
display: 'inline-flex', alignItems: 'center', gap: 8,
width: '100%', boxSizing: 'border-box',
animation: 'bugFadeIn 200ms ease',
}}>
<span style={{ fontSize: 16 }}></span>
{submitError}
</div>
)}
<div style={{
display: 'flex', gap: 10, justifyContent: 'flex-end',
marginTop: 6,
}}>
<button onClick={onClose} style={btnSecondary} disabled={submitting}>
Отмена
</button>
<button
onClick={submit}
disabled={submitting}
style={{
...btnPrimary,
opacity: submitting ? 0.6 : 1,
cursor: submitting ? 'not-allowed' : 'pointer',
}}
>
{submitting ? 'Отправка…' : '🚀 Отправить'}
</button>
</div>
</div>
</>
)}
</div>
</div>
);
};
const BugTypeCard = ({ option, selected, onClick }) => {
const [hovered, setHovered] = useState(false);
return (
<button
type="button"
onClick={onClick}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
style={{
padding: '12px 14px',
background: selected
? BUG.gradientBrand
: (hovered ? BUG.bgHover : BUG.bgInput),
border: selected
? '1px solid transparent'
: `1px solid ${hovered ? BUG.borderHover : BUG.border}`,
borderRadius: 12,
color: selected ? '#fff' : BUG.text,
cursor: 'pointer',
fontFamily: BUG.font,
textAlign: 'left',
transition: 'all 200ms ease',
display: 'flex', flexDirection: 'column', gap: 2,
boxShadow: selected
? '0 8px 20px rgba(51,87,255,0.40)'
: 'none',
transform: selected ? 'translateY(-2px)' : 'translateY(0)',
}}
>
<div style={{
fontSize: 13, fontWeight: 800, letterSpacing: -0.1,
}}>
{option.label}
</div>
<div style={{
fontSize: 11, fontWeight: 600,
color: selected ? 'rgba(255,255,255,0.78)' : BUG.textMuted,
}}>
{option.desc}
</div>
</button>
);
};
const FocusableInput = ({ value, onChange, placeholder, maxLength }) => {
const [focus, setFocus] = useState(false);
return (
<input
value={value}
onChange={(e) => onChange(e.target.value)}
onFocus={() => setFocus(true)}
onBlur={() => setFocus(false)}
placeholder={placeholder}
maxLength={maxLength}
style={{
...inputStyle,
border: `1.5px solid ${focus ? BUG.accent : BUG.border}`,
boxShadow: focus ? `0 0 0 3px ${BUG.accentBg}` : 'none',
}}
/>
);
};
const FocusableTextarea = ({ value, onChange, placeholder, rows }) => {
const [focus, setFocus] = useState(false);
return (
<textarea
value={value}
onChange={(e) => onChange(e.target.value)}
onFocus={() => setFocus(true)}
onBlur={() => setFocus(false)}
placeholder={placeholder}
rows={rows}
style={{
...inputStyle,
resize: 'vertical',
lineHeight: 1.5,
border: `1.5px solid ${focus ? BUG.accent : BUG.border}`,
boxShadow: focus ? `0 0 0 3px ${BUG.accentBg}` : 'none',
}}
/>
);
};
const SuccessView = ({ onClose }) => (
<div style={{
textAlign: 'center', padding: '48px 32px',
background:
'radial-gradient(ellipse at top, rgba(34,217,122,0.18) 0%, transparent 60%)',
}}>
<div style={{
width: 88, height: 88, margin: '0 auto 18px',
borderRadius: '50%',
background: 'linear-gradient(135deg, #22d97a 0%, #0f9d56 100%)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 44,
boxShadow:
'0 16px 40px rgba(34,217,122,0.45), '
+ '0 0 0 8px rgba(34,217,122,0.16)',
animation: 'bugFadeInScale 380ms cubic-bezier(0.34, 1.56, 0.64, 1)',
}}></div>
<div style={{
fontSize: 22, fontWeight: 800, color: BUG.text, marginBottom: 8,
letterSpacing: -0.3,
}}>
Спасибо!
</div>
<div style={{
fontSize: 13, color: BUG.textMuted, marginBottom: 24,
maxWidth: 340, margin: '0 auto 24px', lineHeight: 1.55,
}}>
Баг-репорт отправлен. Мы посмотрим его в админке Рублокса
и постараемся починить как можно скорее.
</div>
<button onClick={onClose} style={btnPrimary}>
Закрыть
</button>
</div>
);
// ============================================================
// === Стили / палитра ===
// ============================================================
const BUG = {
bgPanel: '#ffffff',
bgInput: '#fafbfd',
bgHover: '#f1f5f9',
border: '#e5e7eb',
borderHover:'#cbd5e1',
text: '#0f172a',
textMuted: '#475569',
textDim: '#94a3b8',
accent: '#3357ff',
accentBg: '#e0e8ff',
success: '#10b981',
danger: '#ef4444',
dangerBg: '#fef2f2',
gradientBrand: 'linear-gradient(135deg, #3357ff 0%, #1e2da5 100%)',
font: '"Roboto Condensed", system-ui, -apple-system, sans-serif',
};
const BUG_KEYFRAMES = `
@keyframes bugFadeIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } }
@keyframes bugFadeInScale { from { opacity: 0; transform: scale(0.94); } to { opacity: 1; transform: scale(1); } }
@keyframes bugFloat {
0%, 100% { transform: translateY(0) rotate(0deg); }
50% { transform: translateY(-4px) rotate(6deg); }
}
@keyframes bugPulseRing {
0%, 100% { box-shadow: 0 8px 20px rgba(51, 87, 255, 0.40), 0 0 0 0 rgba(51, 87, 255, 0.45); }
50% { box-shadow: 0 8px 20px rgba(51, 87, 255, 0.40), 0 0 0 10px rgba(51, 87, 255, 0); }
}
`;
const modalBackdrop = {
position: 'fixed', inset: 0, zIndex: 2200,
background: 'rgba(15, 23, 42, 0.55)',
backdropFilter: 'blur(8px)',
WebkitBackdropFilter: 'blur(8px)',
display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20,
fontFamily: BUG.font,
animation: 'bugFadeIn 200ms ease',
};
const modalCard = {
background: BUG.bgPanel,
border: `1px solid ${BUG.border}`,
borderRadius: 22,
width: '100%', maxWidth: 540,
color: BUG.text,
overflow: 'hidden',
boxShadow: '0 24px 60px rgba(15, 23, 42, 0.32), 0 0 0 1px rgba(51, 87, 255, 0.06)',
maxHeight: '92vh', overflowY: 'auto',
animation: 'bugFadeInScale 240ms cubic-bezier(0.34, 1.56, 0.64, 1)',
};
const iconCloseBtn = {
width: 34, height: 34,
border: '1px solid rgba(255,255,255,0.25)',
background: 'rgba(255,255,255,0.10)',
color: '#fff', borderRadius: 10,
fontSize: 22, fontWeight: 700, lineHeight: 1,
cursor: 'pointer', fontFamily: BUG.font,
display: 'flex', alignItems: 'center', justifyContent: 'center',
transition: 'all 150ms ease',
position: 'relative', zIndex: 1,
};
const removeFileBtn = {
width: 28, height: 28,
background: 'rgba(255,111,122,0.16)',
border: '1px solid rgba(255,111,122,0.35)',
color: '#ff6f7a',
borderRadius: 8,
fontSize: 13, fontWeight: 700,
cursor: 'pointer',
fontFamily: BUG.font,
display: 'flex', alignItems: 'center', justifyContent: 'center',
transition: 'all 150ms ease',
flexShrink: 0,
};
const btnPrimary = {
padding: '10px 20px',
background: BUG.gradientBrand,
border: '1px solid transparent',
color: '#fff',
borderRadius: 10,
fontSize: 14, fontWeight: 800,
cursor: 'pointer',
fontFamily: BUG.font,
boxShadow: '0 6px 16px rgba(51,87,255,0.35)',
transition: 'all 150ms ease',
minWidth: 130,
letterSpacing: 0.2,
};
const btnSecondary = {
padding: '10px 18px',
background: BUG.bgInput,
border: `1px solid ${BUG.border}`,
color: BUG.text,
borderRadius: 10,
fontSize: 14, fontWeight: 700,
cursor: 'pointer',
fontFamily: BUG.font,
transition: 'all 150ms ease',
};
const field = { marginBottom: 16 };
const fieldLabel = {
fontSize: 11,
color: BUG.textMuted,
marginBottom: 8,
textTransform: 'uppercase',
letterSpacing: 0.8,
fontWeight: 800,
};
const inputStyle = {
width: '100%', boxSizing: 'border-box',
background: BUG.bgInput,
color: BUG.text,
border: `1.5px solid ${BUG.border}`,
borderRadius: 10,
padding: '11px 14px',
fontSize: 14,
fontFamily: BUG.font,
outline: 'none',
transition: 'all 150ms ease',
};
export default KubikonBugReportButton;

View File

@ -0,0 +1,249 @@
import React, { useEffect, useState, useCallback, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import * as Kubikon3DApi from '../../api/Kubikon3DService';
/**
* Форматирует время в мс "1:23.45" (мин:сек.сотые) или "23.45" если меньше минуты.
*/
export function formatTimeMs(ms) {
if (ms == null || !Number.isFinite(ms)) return '—';
const totalSec = ms / 1000;
const mm = Math.floor(totalSec / 60);
const sec = totalSec - mm * 60;
if (mm > 0) {
return mm + ':' + sec.toFixed(2).padStart(5, '0');
}
return sec.toFixed(2);
}
const RANK_BADGES = ['🥇', '🥈', '🥉', '4⃣', '5⃣'];
/**
* KubikonLeaderboard таблица топ-N рекордов прохождения игры.
*
* Props:
* projectId id проекта Кубикон 3D
* limit топ N (default 5)
* currentTimeMs текущее время игрока (показывается отдельной строкой)
* currentUserId id текущего игрока (выделить его строку)
* onClose если задан, показывается крестик в углу для скрытия
* compact если true, компактный режим (для оверлея в игре)
* refreshKey менять чтобы перезагрузить (например при submit)
* style, className для оборачивания
*/
export default function KubikonLeaderboard({
projectId, limit = 5, currentTimeMs = null, currentUserId = null,
onClose = null, compact = false, refreshKey = 0,
clickable = true, onLoaded = null,
style = {}, className = '',
}) {
const navigate = useNavigate();
const [records, setRecords] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
// onLoaded храним в ref чтобы не плодить fetchData при каждом рендере
// родителя (если он не оборачивает onLoaded в useCallback). Без этого
// компонент попадал в бесконечный цикл fetch.
const onLoadedRef = useRef(onLoaded);
useEffect(() => { onLoadedRef.current = onLoaded; }, [onLoaded]);
const fetchData = useCallback(async () => {
if (!projectId) return;
setLoading(true);
setError(null);
try {
const r = await Kubikon3DApi.getLeaderboard(projectId, limit);
const list = r.data?.records || [];
setRecords(list);
const cb = onLoadedRef.current;
if (cb) try { cb(list); } catch (e) {}
} catch (e) {
setError(e?.message || 'Ошибка загрузки');
} finally {
setLoading(false);
}
}, [projectId, limit]);
useEffect(() => { fetchData(); }, [fetchData, refreshKey]);
const goProfile = (uid) => {
if (!clickable) return;
if (!uid) return;
navigate(`/profile/${uid}`);
};
// Палитра берём «золотистую» гамму с лёгким бирюзовым акцентом (соответствует Кубикону)
const C = {
bg: compact ? 'rgba(10, 14, 26, 0.92)' : 'rgba(20, 24, 45, 0.96)',
bgRow: 'rgba(15, 19, 38, 0.55)',
bgRowAlt: 'rgba(15, 19, 38, 0.30)',
bgRowMe: 'rgba(255, 215, 0, 0.16)',
border: 'rgba(255, 215, 0, 0.45)',
borderSoft:'rgba(255, 255, 255, 0.10)',
gold: '#ffd700',
goldDim: '#caa01c',
text: '#f1f5fb',
muted: 'rgba(241, 245, 251, 0.55)',
accent: '#3357ff',
success: '#22d97a',
};
return (
<div
className={className}
style={{
background: C.bg,
border: `2px solid ${C.border}`,
borderRadius: compact ? 14 : 18,
padding: compact ? 12 : 16,
color: C.text,
fontFamily: '"Roboto Condensed", system-ui, sans-serif',
backdropFilter: 'blur(8px)',
boxShadow: '0 8px 32px rgba(0,0,0,0.45), 0 0 0 1px rgba(255,215,0,0.10) inset',
minWidth: compact ? 240 : 320,
maxWidth: compact ? 320 : 480,
...style,
}}
>
{/* Заголовок */}
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
marginBottom: 10, gap: 8,
}}>
<div style={{
display: 'flex', alignItems: 'center', gap: 8,
fontSize: compact ? 16 : 18, fontWeight: 800,
letterSpacing: 0.5,
color: C.gold,
textShadow: '0 0 12px rgba(255,215,0,0.35)',
}}>
<span style={{ fontSize: compact ? 22 : 26 }}>🏆</span>
Топ-{limit}
</div>
{onClose && (
<button
onClick={onClose}
title="Скрыть таблицу (Tab)"
style={{
background: 'transparent', border: 'none',
color: C.muted, cursor: 'pointer', fontSize: 18,
padding: '2px 8px', borderRadius: 6,
}}
onMouseEnter={e => { e.currentTarget.style.color = C.text; }}
onMouseLeave={e => { e.currentTarget.style.color = C.muted; }}
></button>
)}
</div>
{/* Тело */}
{loading ? (
<div style={{ color: C.muted, fontSize: 14, textAlign: 'center', padding: '12px 0' }}>
Загрузка
</div>
) : error ? (
<div style={{ color: '#ff6f7a', fontSize: 13, textAlign: 'center', padding: '12px 0' }}>
{error}
</div>
) : records.length === 0 ? (
<div style={{
color: C.muted, fontSize: 13, textAlign: 'center',
padding: '14px 6px',
background: C.bgRowAlt, borderRadius: 10,
border: `1px dashed ${C.borderSoft}`,
}}>
Пока нет рекордов.<br/>
<span style={{ color: C.gold }}>Стань первым!</span>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{records.map((r, idx) => {
const isMe = currentUserId && r.user_id === currentUserId;
return (
<div
key={r.id}
onClick={() => goProfile(r.user_id)}
style={{
display: 'grid',
gridTemplateColumns: '32px 1fr auto',
alignItems: 'center',
gap: 8,
padding: '8px 10px',
borderRadius: 10,
background: isMe ? C.bgRowMe :
idx % 2 === 0 ? C.bgRow : C.bgRowAlt,
border: isMe ? `1px solid ${C.gold}` : `1px solid ${C.borderSoft}`,
cursor: clickable ? 'pointer' : 'default',
transition: 'transform 0.12s ease, background 0.12s ease',
}}
onMouseEnter={e => {
if (!clickable) return;
e.currentTarget.style.transform = 'translateX(2px)';
e.currentTarget.style.background = isMe ? 'rgba(255,215,0,0.24)' : 'rgba(51,87,255,0.18)';
}}
onMouseLeave={e => {
if (!clickable) return;
e.currentTarget.style.transform = 'translateX(0)';
e.currentTarget.style.background = isMe ? C.bgRowMe :
idx % 2 === 0 ? C.bgRow : C.bgRowAlt;
}}
>
<div style={{
fontSize: idx < 3 ? 22 : 16,
fontWeight: 700,
color: idx < 3 ? C.gold : C.muted,
textAlign: 'center',
}}>
{RANK_BADGES[idx] || (idx + 1)}
</div>
<div style={{
fontWeight: 600, fontSize: 14,
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
color: isMe ? C.gold : C.text,
}}>
{r.username || `id ${r.user_id}`}
{isMe && <span style={{ marginLeft: 6, fontSize: 11, opacity: 0.7 }}>(ты)</span>}
</div>
<div style={{
fontFamily: 'ui-monospace, "SF Mono", Menlo, monospace',
fontSize: 14, fontWeight: 700,
color: idx === 0 ? C.gold : C.text,
minWidth: 60, textAlign: 'right',
}}>
{formatTimeMs(r.time_ms)}
</div>
</div>
);
})}
</div>
)}
{/* Текущее время игрока (если идёт прохождение) */}
{currentTimeMs != null && currentTimeMs > 0 && (
<div style={{
marginTop: 10,
padding: '8px 10px',
borderRadius: 10,
background: 'linear-gradient(135deg, rgba(34, 217, 122, 0.18), rgba(51, 87, 255, 0.14))',
border: `1px solid ${C.success}`,
display: 'grid',
gridTemplateColumns: 'auto 1fr auto',
alignItems: 'center',
gap: 8,
}}>
<span style={{ fontSize: 18 }}></span>
<span style={{ fontSize: 13, color: C.success, fontWeight: 700 }}>
Твоё время
</span>
<span style={{
fontFamily: 'ui-monospace, "SF Mono", Menlo, monospace',
fontSize: 16, fontWeight: 800,
color: C.success,
}}>
{formatTimeMs(currentTimeMs)}
</span>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,403 @@
import React, { useEffect, useState, useRef } from 'react';
import * as Kubikon3DApi from '../../api/Kubikon3DService';
// Тег версии оптимизации менять при каждом этапе чтобы фильтровать логи.
const BUILD_TAG = 'diag-2';
const round2 = (v) => Math.round(v * 100) / 100;
/**
* KubikonPerfOverlay лёгкий performance-monitor для Play-режима Кубикона.
*
* Показывает в левом нижнем углу:
* FPS, frame-time, draw-calls, active meshes, total meshes, RAM, GPU.
*
* Включается/выключается клавишей F или кликом по компактному бейджу.
* По умолчанию ВЫКЛЮЧЕН чтобы не мешать игроку.
*
* Props:
* getScene функция возвращающая BabylonScene (sceneRef.current)
* visible внешний контроль видимости (если null/undefined internal toggle через F)
*/
export default function KubikonPerfOverlay({ getScene, projectId, userId }) {
const [open, setOpen] = useState(false);
const [stats, setStats] = useState({
fps: 0,
frameMs: 0,
drawCalls: 0,
activeMeshes: 0,
totalMeshes: 0,
memMB: null,
memLimitMB: null,
gpu: '',
mobile: false,
});
const rafRef = useRef(null);
const lastSampleRef = useRef(performance.now());
const framesRef = useRef(0);
const lastFpsRef = useRef(0);
const lastFrameTimeRef = useRef(0);
const gpuInfoRef = useRef(null);
// ====================================================================
// ФОНОВЫЙ СЭМПЛЕР: всегда работает в Play-режиме (даже если overlay
// закрыт). Раз в 5 секунд агрегирует и отправляет на бэк.
// ====================================================================
useEffect(() => {
if (!projectId) return;
let cancelled = false;
let frames = 0;
let frameMsSum = 0;
let drawCallsSum = 0;
let activeMeshesSum = 0;
let totalMeshes = 0;
let fpsMin = Infinity;
let fpsMax = 0;
let lastFrameTs = performance.now();
let windowStart = performance.now();
let raf = null;
// Кэш окружения не пересчитываем каждый кадр
let envCache = null;
const getEnv = () => {
if (envCache) return envCache;
try {
const scene = getScene?.();
let gpu = '';
let mobile = false;
if (scene?.engine?._gl) {
const dbg = scene.engine._gl.getExtension('WEBGL_debug_renderer_info');
if (dbg) {
gpu = String(scene.engine._gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL) || '').slice(0, 200);
}
}
if (scene && '_isMobileMode' in scene) mobile = !!scene._isMobileMode;
envCache = {
gpu,
mobile,
ua: (navigator?.userAgent || '').slice(0, 400),
screen_w: window.innerWidth,
screen_h: window.innerHeight,
dpr: window.devicePixelRatio || 1,
};
} catch (e) {
envCache = { gpu: '', mobile: false, ua: '', screen_w: 0, screen_h: 0, dpr: 1 };
}
return envCache;
};
const flush = () => {
if (frames < 5) {
// Слишком мало кадров скип
return;
}
const elapsed = performance.now() - windowStart;
const fpsAvg = (frames * 1000) / elapsed;
const frameMsAvg = frameMsSum / frames;
const drawCallsAvg = drawCallsSum / frames;
const activeMeshesAvg = activeMeshesSum / frames;
const env = getEnv();
// PERF-METRICS: забираем и сбрасываем накопленные за окно метрики
let perfM = null;
try {
const scene = getScene?.();
if (scene?.flushPerfMetrics) perfM = scene.flushPerfMetrics();
} catch (e) { /* ignore */ }
const sample = {
user_id: userId || null,
project_id: projectId,
fps_avg: round2(fpsAvg),
fps_min: round2(fpsMin === Infinity ? 0 : fpsMin),
fps_max: round2(fpsMax),
frame_ms_avg: round2(frameMsAvg),
draw_calls_avg: round2(drawCallsAvg),
active_meshes_avg: round2(activeMeshesAvg),
total_meshes: totalMeshes || 0,
mem_mb: performance.memory
? Math.round(performance.memory.usedJSHeapSize / 1048576)
: null,
mem_limit_mb: performance.memory
? Math.round(performance.memory.jsHeapSizeLimit / 1048576)
: null,
...env,
sample_count: frames,
build_tag: BUILD_TAG,
render_ms_avg: perfM ? round2(perfM.render_ms_avg) : null,
physics_ms_avg: perfM ? round2(perfM.physics_ms_avg) : null,
script_ms_avg: perfM ? round2(perfM.script_ms_avg) : null,
script_count: perfM ? perfM.script_count : null,
idle_ms_avg: perfM ? round2(perfM.idle_ms_avg) : null,
};
// Шлём fire-and-forget
Kubikon3DApi.submitPerfLog(sample).catch(() => { /* ignore */ });
// Сброс окна
frames = 0;
frameMsSum = 0;
drawCallsSum = 0;
activeMeshesSum = 0;
fpsMin = Infinity;
fpsMax = 0;
windowStart = performance.now();
};
const tick = () => {
if (cancelled) return;
const now = performance.now();
const dt = now - lastFrameTs;
lastFrameTs = now;
// FPS на отдельном кадре (для min/max)
if (dt > 0) {
const instFps = 1000 / dt;
if (instFps < fpsMin) fpsMin = instFps;
if (instFps > fpsMax) fpsMax = instFps;
}
frames++;
// Снапшот метрик
try {
const scene = getScene?.();
if (scene) {
const eng = scene.engine;
const sc = scene.scene;
if (eng) {
frameMsSum += eng.getDeltaTime?.() || dt;
drawCallsSum += (eng.drawCallsCounter?.current
|| eng._drawCalls?.current || 0);
}
if (sc) {
activeMeshesSum += (sc.getActiveMeshes?.()?.length || 0);
totalMeshes = sc.meshes?.length || 0;
}
}
} catch (e) { /* ignore */ }
// Раз в 5 сек flush
if (now - windowStart >= 5000) {
flush();
}
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => {
cancelled = true;
if (raf) cancelAnimationFrame(raf);
// Финальный flush при unmount (если что-то накопилось)
try { flush(); } catch (e) { /* ignore */ }
};
}, [projectId, userId, getScene]);
// Тоггл по F
useEffect(() => {
const onKey = (e) => {
if (e.code !== 'KeyF') return;
// Не перехватываем если фокус в input
const tag = (e.target && e.target.tagName) || '';
if (tag === 'INPUT' || tag === 'TEXTAREA') return;
// Не должно конфликтовать с Ctrl+F (поиск)
if (e.ctrlKey || e.metaKey || e.altKey) return;
setOpen(v => !v);
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, []);
// Измерение FPS и сбор статистики
useEffect(() => {
if (!open) return;
let cancelled = false;
const tick = () => {
if (cancelled) return;
framesRef.current++;
const now = performance.now();
const elapsed = now - lastSampleRef.current;
// Раз в ~500мс обновляем стейт (чтобы не ререндерить 60 раз/сек)
if (elapsed >= 500) {
const fps = (framesRef.current * 1000) / elapsed;
lastFpsRef.current = fps;
framesRef.current = 0;
lastSampleRef.current = now;
// Babylon Engine + Scene статистика
const scene = getScene?.();
let drawCalls = 0;
let activeMeshes = 0;
let totalMeshes = 0;
let frameMs = 0;
let mobile = false;
if (scene) {
try {
const eng = scene.engine;
const sc = scene.scene;
if (eng) {
drawCalls = eng.drawCallsCounter?.current
|| eng._drawCalls?.current
|| 0;
// getDeltaTime в мс
frameMs = eng.getDeltaTime?.() || 0;
lastFrameTimeRef.current = frameMs;
// GPU info один раз
if (!gpuInfoRef.current) {
try {
const gl = eng._gl;
if (gl) {
const dbgRendererInfo = gl.getExtension('WEBGL_debug_renderer_info');
if (dbgRendererInfo) {
gpuInfoRef.current = String(
gl.getParameter(dbgRendererInfo.UNMASKED_RENDERER_WEBGL)
|| ''
).slice(0, 60);
}
}
} catch (e) { /* ignore */ }
if (!gpuInfoRef.current) gpuInfoRef.current = '?';
}
}
if (sc) {
activeMeshes = sc.getActiveMeshes?.()?.length || 0;
totalMeshes = sc.meshes?.length || 0;
}
mobile = !!scene._isMobileMode;
} catch (e) { /* ignore */ }
}
// Memory (Chrome only)
let memMB = null;
let memLimitMB = null;
if (performance.memory) {
memMB = Math.round(performance.memory.usedJSHeapSize / 1048576);
memLimitMB = Math.round(performance.memory.jsHeapSizeLimit / 1048576);
}
setStats({
fps: Math.round(fps),
frameMs: lastFrameTimeRef.current,
drawCalls,
activeMeshes,
totalMeshes,
memMB,
memLimitMB,
gpu: gpuInfoRef.current || '?',
mobile,
});
}
rafRef.current = requestAnimationFrame(tick);
};
rafRef.current = requestAnimationFrame(tick);
return () => {
cancelled = true;
if (rafRef.current) cancelAnimationFrame(rafRef.current);
};
}, [open, getScene]);
// Цвет FPS индикация качества
const fpsColor = stats.fps >= 55 ? '#22d97a'
: stats.fps >= 30 ? '#ffd700'
: '#ff5e3a';
// Маленький бейдж когда закрыто РИГТ-БОТТОМ, не мешает HUD сверху
if (!open) {
return (
<button
onClick={() => setOpen(true)}
title="Открыть FPS-монитор (F)"
style={{
position: 'absolute',
bottom: 12, right: 12,
zIndex: 40,
padding: '6px 10px',
background: 'rgba(20, 24, 45, 0.85)',
border: '1px solid rgba(255, 255, 255, 0.18)',
borderRadius: 8,
color: 'rgba(255, 255, 255, 0.7)',
fontFamily: 'ui-monospace, "SF Mono", Menlo, monospace',
fontSize: 11, fontWeight: 700,
cursor: 'pointer',
backdropFilter: 'blur(6px)',
pointerEvents: 'auto',
opacity: 0.55,
transition: 'opacity 200ms ease',
}}
onMouseEnter={(e) => { e.currentTarget.style.opacity = '1'; }}
onMouseLeave={(e) => { e.currentTarget.style.opacity = '0.55'; }}
>📊 FPS</button>
);
}
return (
<div style={{
position: 'absolute',
bottom: 12, right: 12,
zIndex: 40,
background: 'rgba(10, 14, 26, 0.92)',
border: '1px solid rgba(255, 255, 255, 0.10)',
borderRadius: 10,
color: '#f1f5fb',
fontFamily: 'ui-monospace, "SF Mono", Menlo, monospace',
fontSize: 11, lineHeight: 1.5,
padding: '8px 12px',
backdropFilter: 'blur(10px)',
boxShadow: '0 8px 22px rgba(0,0,0,0.4)',
minWidth: 200,
pointerEvents: 'auto',
animation: 'kubikonFadeIn 200ms ease',
}}>
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
marginBottom: 6,
}}>
<span style={{ fontWeight: 800, color: '#3357ff', fontSize: 11 }}>
📊 PERF
</span>
<button
onClick={() => setOpen(false)}
title="Скрыть (F)"
style={{
background: 'transparent', border: 'none',
color: 'rgba(255,255,255,0.5)', cursor: 'pointer',
fontSize: 14, padding: '0 4px', lineHeight: 1,
}}
></button>
</div>
<Row label="FPS" value={
<span style={{ color: fpsColor, fontWeight: 800 }}>
{stats.fps}
</span>
} />
<Row label="frame" value={`${stats.frameMs.toFixed(1)} ms`} />
<Row label="draw calls" value={stats.drawCalls} />
<Row label="meshes" value={`${stats.activeMeshes} / ${stats.totalMeshes}`} />
{stats.memMB != null && (
<Row label="JS heap" value={
`${stats.memMB} / ${stats.memLimitMB} MB`
} />
)}
<Row label="mode" value={
<span style={{
color: stats.mobile ? '#ec4899' : '#22d97a',
fontWeight: 700,
}}>
{stats.mobile ? '📱 MOBILE' : '🖥️ DESKTOP'}
</span>
} />
<div style={{
marginTop: 6, paddingTop: 6,
borderTop: '1px solid rgba(255,255,255,0.08)',
fontSize: 10, color: 'rgba(255,255,255,0.55)',
wordBreak: 'break-word',
}}>
GPU: {stats.gpu}
</div>
</div>
);
}
const Row = ({ label, value }) => (
<div style={{
display: 'flex', justifyContent: 'space-between',
gap: 12,
}}>
<span style={{ color: 'rgba(255,255,255,0.55)' }}>{label}</span>
<span>{value}</span>
</div>
);

View File

@ -0,0 +1,24 @@
import React from 'react';
import cl from './PleeseReg.module.css'
import { Link } from "react-router-dom";
import a1 from './img/1.png';
import MyButton_1 from '../MyButton_1/MyButton_1';
const PleeseReg = ({textDefault,...props}) => {
return (
<div className={cl.Wrap} {...props}>
<div className={cl.wrapLast}>
<img src={a1} alt="" />
<div>
<Link to="/login" style={{ textDecoration: 'none' }}><MyButton_1>Войди</MyButton_1></Link>
<p>или</p>
<Link to="/registration" style={{ textDecoration: 'none' }}><MyButton_1>Зарегистрируйся</MyButton_1></Link>
<p className={cl.ppropsRegPleese}>{textDefault}</p>
</div>
</div>
</div>
)
};
export default PleeseReg;

Some files were not shown because too many files have changed in this diff Show More