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)
15
.editorconfig
Normal 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
@ -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
@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
47
.gitea/ISSUE_TEMPLATE/bug_report.md
Normal 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, попытки решения и т.д. -->
|
||||||
37
.gitea/ISSUE_TEMPLATE/feature_request.md
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
---
|
||||||
|
name: 💡 Предложить фичу
|
||||||
|
about: Идея новой функциональности или улучшения
|
||||||
|
title: '[FEATURE] '
|
||||||
|
labels: enhancement
|
||||||
|
assignees: ''
|
||||||
|
---
|
||||||
|
|
||||||
|
## Проблема, которую решает фича
|
||||||
|
|
||||||
|
<!-- Какая боль / неудобство существует сейчас? -->
|
||||||
|
|
||||||
|
## Предлагаемое решение
|
||||||
|
|
||||||
|
<!-- Как должно работать -->
|
||||||
|
|
||||||
|
## Альтернативы, которые рассматривал
|
||||||
|
|
||||||
|
<!-- Если думал о других вариантах — опиши -->
|
||||||
|
|
||||||
|
## Mockup / схема (опционально)
|
||||||
|
|
||||||
|
<!-- Если есть набросок UI или диаграмма архитектуры — приложи -->
|
||||||
|
|
||||||
|
## Влияние на другие части системы
|
||||||
|
|
||||||
|
- [ ] Изменяет API
|
||||||
|
- [ ] Требует миграции БД
|
||||||
|
- [ ] Меняет UX-flow существующих фич
|
||||||
|
- [ ] Требует изменений в плеере / студии / минке
|
||||||
|
|
||||||
|
## Готов реализовать сам?
|
||||||
|
|
||||||
|
- [ ] Да, открою PR
|
||||||
|
- [ ] Нет, прошу команду
|
||||||
|
|
||||||
|
## Дополнительный контекст
|
||||||
45
.gitea/ISSUE_TEMPLATE/security_disclosure.md
Normal 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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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) (отдельный репозиторий).
|
||||||
|
|
||||||
|
[](./LICENSE)
|
||||||
|
[](./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
@ -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
@ -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
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
public/kubikon-learn/assets.jpg
Normal file
|
After Width: | Height: | Size: 139 KiB |
BIN
public/kubikon-learn/beta.jpg
Normal file
|
After Width: | Height: | Size: 102 KiB |
BIN
public/kubikon-learn/players.jpg
Normal file
|
After Width: | Height: | Size: 143 KiB |
BIN
public/kubikon-learn/publish.jpg
Normal file
|
After Width: | Height: | Size: 134 KiB |
BIN
public/kubikon-learn/scripts.jpg
Normal file
|
After Width: | Height: | Size: 83 KiB |
BIN
public/kubikon-learn/start.jpg
Normal file
|
After Width: | Height: | Size: 129 KiB |
0
public/kubikon-templates/.gitkeep
Normal file
BIN
public/kubikon-templates/city.jpg
Normal file
|
After Width: | Height: | Size: 675 KiB |
BIN
public/kubikon-templates/hills.jpg
Normal file
|
After Width: | Height: | Size: 758 KiB |
BIN
public/kubikon-templates/island.jpg
Normal file
|
After Width: | Height: | Size: 868 KiB |
BIN
public/kubikon-templates/plain.jpg
Normal file
|
After Width: | Height: | Size: 996 KiB |
BIN
public/kubikon-templates/platformer.jpg
Normal file
|
After Width: | Height: | Size: 237 KiB |
BIN
public/kubikon-templates/racing.jpg
Normal file
|
After Width: | Height: | Size: 449 KiB |
BIN
public/kubikon-templates/shooter.jpg
Normal file
|
After Width: | Height: | Size: 297 KiB |
BIN
public/kubikon-templates/village.jpg
Normal file
|
After Width: | Height: | Size: 880 KiB |
94
src/App.jsx
Normal 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
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
216
src/admin-preview/GdArchesPreview.jsx
Normal 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 };
|
||||||
278
src/admin-preview/GdBossesPreview.jsx
Normal 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;
|
||||||
151
src/admin-preview/GdBossesPreview.module.css
Normal 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;
|
||||||
|
}
|
||||||
217
src/admin-preview/GdDecoPreview.jsx
Normal 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 };
|
||||||
179
src/admin-preview/GdFinishesPreview.jsx
Normal 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 };
|
||||||
193
src/admin-preview/GdMusicPreview.jsx
Normal 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/<trackId>.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/<trackId>.mp3</code></li>
|
||||||
|
<li>Перезагрузить страницу → плеер автоматически подцепит файл</li>
|
||||||
|
</ol>
|
||||||
|
<p>Синтез нужен ровно для теста <strong>темпа</strong> (BPM правильный) пока нет реальных треков.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default GdMusicPreview;
|
||||||
225
src/admin-preview/GdMusicPreview.module.css
Normal 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;
|
||||||
|
}
|
||||||
209
src/admin-preview/GdPortalsPreview.jsx
Normal 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 };
|
||||||
141
src/admin-preview/GdSfxPreview.jsx
Normal 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;
|
||||||
195
src/admin-preview/GdSfxPreview.module.css
Normal 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;
|
||||||
|
}
|
||||||
140
src/admin-preview/GdShipSkinsPreview.jsx
Normal 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 };
|
||||||
362
src/admin-preview/GdSkinsPreview.jsx
Normal 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;
|
||||||
213
src/admin-preview/GdSkinsPreview.module.css
Normal 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;
|
||||||
|
}
|
||||||
258
src/admin-preview/GdSpikesPreview.jsx
Normal 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,
|
||||||
|
};
|
||||||
535
src/admin-preview/gdArches/archFactories.js
Normal 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);
|
||||||
|
}
|
||||||
219
src/admin-preview/gdDeco/decoFactories.js
Normal 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);
|
||||||
|
}
|
||||||
518
src/admin-preview/gdFinishes/finishFactories.js
Normal 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);
|
||||||
|
}
|
||||||
135
src/admin-preview/gdMusic/musicCatalog.js
Normal 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 };
|
||||||
263
src/admin-preview/gdMusic/musicSynth.js
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
330
src/admin-preview/gdPortals/portalFactories.js
Normal 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);
|
||||||
|
}
|
||||||
289
src/admin-preview/gdSfx/sfxFactories.js
Normal 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,
|
||||||
|
},
|
||||||
|
];
|
||||||
370
src/admin-preview/gdShipSkins/shipSkinFactories.js
Normal 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);
|
||||||
|
}
|
||||||
405
src/admin-preview/gdSkins/cubeSkinFactories.js
Normal 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);
|
||||||
|
}
|
||||||
372
src/admin-preview/gdSpikes/spikeFactories.js
Normal 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
@ -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
@ -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,
|
||||||
|
});
|
||||||
59
src/api/KubikonCadService.js
Normal 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
@ -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
@ -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
@ -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>;
|
||||||
|
}
|
||||||
84
src/auth/SanctionsContext.jsx
Normal 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);
|
||||||
|
}
|
||||||
138
src/community/KubikonDesktopOnlyStub.jsx
Normal 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;
|
||||||
1133
src/community/KubikonDocs.jsx
Normal file
3766
src/community/KubikonFeed.jsx
Normal file
1292
src/community/KubikonGamePage.jsx
Normal file
468
src/community/KubikonHeroKit.jsx
Normal 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;
|
||||||
563
src/community/KubikonLearn.jsx
Normal 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;
|
||||||
985
src/community/KubikonRules.jsx
Normal 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;
|
||||||
790
src/community/KubikonStudio.jsx
Normal 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;
|
||||||
1022
src/community/KubikonStudio.module.css
Normal file
362
src/community/KubikonUserGames.jsx
Normal 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;
|
||||||
1524
src/community/RealtimeTest.jsx
Normal file
2392
src/community/docsData.jsx
Normal file
295
src/community/docsGames.js
Normal 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 },
|
||||||
|
];
|
||||||
6165
src/community/docsGamesBuilders.js
Normal file
445
src/community/docsIcons.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
7326
src/community/docsLessons.jsx
Normal file
387
src/community/learnArticles.jsx
Normal 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;
|
||||||
1
src/community/template-data/city.json
Normal 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}}
|
||||||
1
src/community/template-data/hills.json
Normal file
1
src/community/template-data/island.json
Normal file
1
src/community/template-data/plain.json
Normal file
1
src/community/template-data/platformer.json
Normal 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}}
|
||||||
1
src/community/template-data/racing.json
Normal file
1
src/community/template-data/shooter.json
Normal file
1
src/community/template-data/village.json
Normal file
121
src/community/templateScreenshots.js
Normal 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
@ -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),
|
||||||
|
},
|
||||||
|
];
|
||||||
121
src/components/EmailConfirmNotice/EmailConfirmNotice.jsx
Normal 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;
|
||||||
645
src/components/KubikonBugReport/KubikonBugReportButton.jsx
Normal 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;
|
||||||
249
src/components/KubikonLeaderboard/KubikonLeaderboard.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
403
src/components/KubikonPerfOverlay/KubikonPerfOverlay.jsx
Normal 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>
|
||||||
|
);
|
||||||
24
src/components/PleeseReg/PleeseReg.jsx
Normal 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;
|
||||||