Compare commits
2 Commits
main
...
fix/npm-lo
| Author | SHA1 | Date | |
|---|---|---|---|
| 3c4ce8af77 | |||
| 4163f065dc |
@ -1,11 +0,0 @@
|
|||||||
# Активная сессия: импорт Roblox .rbxl
|
|
||||||
|
|
||||||
Это **отдельный worktree** для разработки `feat/rbxl-import` — импорта Roblox-карт в Rublox.
|
|
||||||
|
|
||||||
**Не работайте здесь параллельно из других сессий!**
|
|
||||||
|
|
||||||
Ветка: `feat/rbxl-import`
|
|
||||||
Сервис на сервере: VM 130 на S1
|
|
||||||
Сопутствующий worktree: `Desktop/studio-rbxl-import`
|
|
||||||
|
|
||||||
Started: 2026-06-07
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
VITE_API_BASE=https://minecraftia-school.ru
|
|
||||||
VITE_REALTIME_HTTP=https://minecraftia-school.ru/api-game
|
|
||||||
VITE_REALTIME_WS=wss://minecraftia-school.ru/api-game
|
|
||||||
VITE_RUBLOX_HOME=https://rublox.pro/app
|
|
||||||
VITE_STANDALONE=false
|
|
||||||
@ -33,12 +33,7 @@
|
|||||||
"react-hooks/exhaustive-deps": "warn",
|
"react-hooks/exhaustive-deps": "warn",
|
||||||
"no-eval": "error",
|
"no-eval": "error",
|
||||||
"no-new-func": "error",
|
"no-new-func": "error",
|
||||||
"no-implied-eval": "error",
|
"no-implied-eval": "error"
|
||||||
"no-empty": "off",
|
|
||||||
"react/no-unescaped-entities": "off",
|
|
||||||
"no-useless-catch": "warn",
|
|
||||||
"no-constant-condition": ["warn", { "checkLoops": false }],
|
|
||||||
"no-fallthrough": "warn"
|
|
||||||
},
|
},
|
||||||
"ignorePatterns": [
|
"ignorePatterns": [
|
||||||
"build/",
|
"build/",
|
||||||
|
|||||||
@ -41,11 +41,9 @@ jobs:
|
|||||||
- run: npm ci
|
- run: npm ci
|
||||||
- run: npm run build
|
- run: npm run build
|
||||||
- name: Save build size
|
- name: Save build size
|
||||||
# set -o pipefail (default Gitea Actions) валит step при SIGPIPE
|
|
||||||
# от head. Делаем команды непадающими через || true.
|
|
||||||
run: |
|
run: |
|
||||||
du -sh build/ || true
|
du -sh build/
|
||||||
ls -la build/assets/ 2>/dev/null | head -10 || true
|
ls -la build/assets/ | head -10
|
||||||
|
|
||||||
secret-scan:
|
secret-scan:
|
||||||
name: Secret scan
|
name: Secret scan
|
||||||
@ -85,74 +83,3 @@ jobs:
|
|||||||
if [ "$TOTAL" -gt 1000 ]; then
|
if [ "$TOTAL" -gt 1000 ]; then
|
||||||
echo "::warning::PR изменяет $TOTAL строк (> 1000). Подумай о дроблении на несколько меньших."
|
echo "::warning::PR изменяет $TOTAL строк (> 1000). Подумай о дроблении на несколько меньших."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ────────────────────────────────────────────────────────────────────
|
|
||||||
# DEPLOY — собирает прод-бандл и заливает на ОБА сервера (S1+S2)
|
|
||||||
# параллельно через rsync over SSH.
|
|
||||||
#
|
|
||||||
# Запускается ТОЛЬКО на push в main (т.е. после успешного мержа PR).
|
|
||||||
# PR-проверки выше (lint/build/secret-scan/size-check) гарантируют
|
|
||||||
# что в main попадает только корректный код.
|
|
||||||
#
|
|
||||||
# Секреты:
|
|
||||||
# DEPLOY_SSH_KEY — приватный ed25519 ключ (CI-only, отдельный от
|
|
||||||
# админских), pubkey уже на ~min/.ssh/authorized_keys
|
|
||||||
# на S1 VM 124 и S2 VM 124
|
|
||||||
# KNOWN_HOSTS — host-keys S1 и S2 (защита от MITM)
|
|
||||||
#
|
|
||||||
# Цели (на VM 124 обоих серверов):
|
|
||||||
# /var/www/rublox-player/build/
|
|
||||||
# ────────────────────────────────────────────────────────────────────
|
|
||||||
deploy:
|
|
||||||
name: Deploy to S1 + S2
|
|
||||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
|
||||||
# Lint НЕ в needs — он опциональный (исторический долг empty-блоков
|
|
||||||
# ещё не вычищен, см. branch protection без 'CI / Lint' в required).
|
|
||||||
# Deploy всё равно зависит от Build и Secret-scan — это критично.
|
|
||||||
needs: [build, secret-scan]
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v3
|
|
||||||
- uses: actions/setup-node@v3
|
|
||||||
with:
|
|
||||||
node-version: '18'
|
|
||||||
- name: Install deps
|
|
||||||
run: npm ci
|
|
||||||
- name: Production build
|
|
||||||
run: npm run build
|
|
||||||
- name: Prepare SSH
|
|
||||||
env:
|
|
||||||
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
|
|
||||||
KNOWN_HOSTS: ${{ secrets.KNOWN_HOSTS }}
|
|
||||||
run: |
|
|
||||||
mkdir -p ~/.ssh && chmod 700 ~/.ssh
|
|
||||||
echo "$DEPLOY_SSH_KEY" > ~/.ssh/id_deploy
|
|
||||||
chmod 600 ~/.ssh/id_deploy
|
|
||||||
echo "$KNOWN_HOSTS" > ~/.ssh/known_hosts
|
|
||||||
chmod 600 ~/.ssh/known_hosts
|
|
||||||
- name: Install rsync
|
|
||||||
run: apt-get update -qq && apt-get install -y rsync openssh-client
|
|
||||||
# S1 — НЕ блокирующий: при недоступности S1 (downtime) деплой не должен
|
|
||||||
# валиться, главное доставить на S2. ConnectTimeout 20с чтобы не висеть.
|
|
||||||
- name: Deploy to S1 (85.175.7.40:1998)
|
|
||||||
continue-on-error: true
|
|
||||||
run: |
|
|
||||||
rsync -az --delete-after --human-readable --exclude=wiki --exclude=kubikon-assets \
|
|
||||||
-e "ssh -i ~/.ssh/id_deploy -o UserKnownHostsFile=~/.ssh/known_hosts -o ConnectTimeout=20 -p 1998" \
|
|
||||||
build/ min@85.175.7.40:/var/www/rublox-player/build/
|
|
||||||
- name: Deploy to S2 (192.168.0.124:22, runner в той же сети)
|
|
||||||
run: |
|
|
||||||
rsync -az --delete-after --human-readable --exclude=wiki --exclude=kubikon-assets \
|
|
||||||
-e "ssh -i ~/.ssh/id_deploy -o UserKnownHostsFile=~/.ssh/known_hosts -p 22" \
|
|
||||||
build/ min@192.168.0.124:/var/www/rublox-player/build/
|
|
||||||
- name: Verify S1 (не блокирующий)
|
|
||||||
continue-on-error: true
|
|
||||||
run: |
|
|
||||||
ssh -i ~/.ssh/id_deploy -o UserKnownHostsFile=~/.ssh/known_hosts -o ConnectTimeout=20 -p 1998 \
|
|
||||||
min@85.175.7.40 \
|
|
||||||
"ls /var/www/rublox-player/build/index.html && du -sh /var/www/rublox-player/build/ 2>/dev/null || true"
|
|
||||||
- name: Verify S2 (обязательный)
|
|
||||||
run: |
|
|
||||||
ssh -i ~/.ssh/id_deploy -o UserKnownHostsFile=~/.ssh/known_hosts -p 22 \
|
|
||||||
min@192.168.0.124 \
|
|
||||||
"ls /var/www/rublox-player/build/index.html && (du -sh /var/www/rublox-player/build/ 2>/dev/null || true)"
|
|
||||||
|
|||||||
40
.gitignore
vendored
40
.gitignore
vendored
@ -41,43 +41,3 @@ public/kubikon-assets/
|
|||||||
|
|
||||||
# OS
|
# OS
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# SECURITY — добавлено после взлома 2026-06-04
|
|
||||||
# НИКОГДА не коммитить эти файлы — они могут содержать секреты!
|
|
||||||
# ============================================================
|
|
||||||
CLAUDE.md
|
|
||||||
INFO_PROCESS.md
|
|
||||||
PASSWORD_*.md
|
|
||||||
SECRETS*
|
|
||||||
*_SECRETS*
|
|
||||||
*.kdbx
|
|
||||||
*.kdbx.bak
|
|
||||||
.env
|
|
||||||
.env.*
|
|
||||||
!.env.example
|
|
||||||
!.env.sample
|
|
||||||
# .env.production содержит ТОЛЬКО публичные URL (api-base, realtime, rublox.pro)
|
|
||||||
# — без секретов. Нужен в git, чтобы CI собирал прод-бандл с правильным
|
|
||||||
# VITE_API_BASE (иначе API уходит на origin вместо minecraftia-school.ru,
|
|
||||||
# redeem-ticket падает → плеер выбивает на /app). Инцидент 2026-06-07.
|
|
||||||
!.env.production
|
|
||||||
secrets/
|
|
||||||
*.pem
|
|
||||||
*.key
|
|
||||||
id_rsa
|
|
||||||
id_ed25519
|
|
||||||
known_hosts
|
|
||||||
authorized_keys
|
|
||||||
|
|
||||||
# Текстовые заметки разработчика (могут содержать всё что угодно)
|
|
||||||
NOTES*.md
|
|
||||||
TODO*.md
|
|
||||||
PRIVATE*.md
|
|
||||||
INTERNAL_*.md
|
|
||||||
|
|
||||||
# Бэкапы кода с предыдущих версий
|
|
||||||
*.bak
|
|
||||||
*.bak_*
|
|
||||||
BackUp/
|
|
||||||
backup/
|
|
||||||
|
|||||||
104
API_USAGE.md
104
API_USAGE.md
@ -1,104 +0,0 @@
|
|||||||
# API, которые использует плеер
|
|
||||||
|
|
||||||
Плеер — это **клиент**. Все данные он берёт с серверных API. Этот документ — полный список эндпоинтов которые он дёргает, зачем и от чего ломается если API изменится.
|
|
||||||
|
|
||||||
## Базовый URL
|
|
||||||
|
|
||||||
В prod: `https://api.rublox.pro` (alias на `minecraftia-school.ru/api-storys`)
|
|
||||||
В dev: `https://dev-api.rublox.pro` (staging, см. [reference-staging-env])
|
|
||||||
Локально: `http://localhost:8674` (storys-микросервис)
|
|
||||||
|
|
||||||
## Эндпоинты — список
|
|
||||||
|
|
||||||
### Игры и лента
|
|
||||||
|
|
||||||
| Method | Path | Когда зовётся | Сломается если... |
|
|
||||||
|---|---|---|---|
|
|
||||||
| GET | `/kubikon3d/feed?tab=hot\|new\|popular\|topweek` | при заходе на главную | поле `games[]` отсутствует |
|
|
||||||
| GET | `/kubikon3d/trending` | главная, секция трендов | возвращает не-массив |
|
|
||||||
| GET | `/kubikon3d/search?q=<query>` | поиск игр | поле `results[]` отсутствует |
|
|
||||||
| GET | `/kubikon3d/collections` | главная, секции «Хиты», «Новинки» | возвращает не-массив |
|
|
||||||
| GET | `/kubikon3d/projects/<id>` | при открытии конкретной игры | поле `project_data` отсутствует или поломан JSON-формат |
|
|
||||||
| GET | `/kubikon3d/my-projects` | страница «Мои игры» | в ответе нет `projects[]` |
|
|
||||||
| GET | `/kubikon3d/top-authors` | страница «Топ авторов» | возвращает не-массив |
|
|
||||||
| GET | `/kubikon3d/events` | главная, баннер мероприятий | возвращает не-массив |
|
|
||||||
|
|
||||||
### Игровой процесс (telemetry)
|
|
||||||
|
|
||||||
| Method | Path | Когда | Что шлёт |
|
|
||||||
|---|---|---|---|
|
|
||||||
| POST | `/kubikon3d/play/heartbeat` | каждые 30 сек пока игрок в игре | `{ game_id, play_time_ms }` |
|
|
||||||
| POST | `/kubikon3d/activity` | при входе/выходе из игры | `{ game_id, action: 'enter'\|'leave' }` |
|
|
||||||
| POST | `/kubikon3d/perf-log` | при детектировании просадки FPS <20 на 5+ сек | `{ game_id, fps, draw_calls, mem }` |
|
|
||||||
| POST | `/kubikon3d/bug-reports` | юзер жмёт «Сообщить о баге» | `{ game_id, description, screenshot_b64 }` |
|
|
||||||
| POST | `/kubikon3d/reports` | юзер репортит игру | `{ game_id, reason, comment }` |
|
|
||||||
|
|
||||||
### Скины и аватары (Рублокс-персонажи)
|
|
||||||
|
|
||||||
| Method | Path | Когда |
|
|
||||||
|---|---|---|
|
|
||||||
| GET | `/kubikon3d/rublox/equipped-skin/<user_id>` | при спавне аватара любого игрока |
|
|
||||||
| GET | `/kubikon3d/rublox/owned-skins` | страница «Мои скины» |
|
|
||||||
| POST | `/kubikon3d/rublox/equipped-skin` | юзер сменил скин |
|
|
||||||
| GET | `/api-storys/rublox/avatars/<user_id>` | список аватаров юзера |
|
|
||||||
| GET | `/api-storys/rublox/outfit/<user_id>` | детали одежды |
|
|
||||||
| GET | `/kubikon-assets/characters/skins_manifest.json` | при загрузке плеера, список всех доступных скинов |
|
|
||||||
|
|
||||||
### Эмоушены (R15 анимации)
|
|
||||||
|
|
||||||
| Method | Path | Когда |
|
|
||||||
|---|---|---|
|
|
||||||
| GET | `/api-storys/rublox/emotes/list` | при заходе в игру (для меню эмоушенов) |
|
|
||||||
| POST | `/api-storys/rublox/emotes/play/<emote_id>` | юзер выбрал эмоушен |
|
|
||||||
|
|
||||||
### Модели и ассеты
|
|
||||||
|
|
||||||
| Method | Path | Когда |
|
|
||||||
|---|---|---|
|
|
||||||
| GET | `/kubikon3d/models/public` | при загрузке игры — список public GLB-моделей |
|
|
||||||
| GET | `/kubikon3d/models/mine` | в редакторе (плеер запускает превью своих моделей) |
|
|
||||||
| GET | `/kubikon3d/models/<id>` | при первом упоминании модели в проекте |
|
|
||||||
|
|
||||||
### Админка (только видна юзерам с ролью admin)
|
|
||||||
|
|
||||||
Все `/kubikon3d/admin/*` доступны только если в JWT юзер имеет роль `admin`. Иначе бэк возвращает 403.
|
|
||||||
|
|
||||||
| Method | Path | Что показывает |
|
|
||||||
|---|---|---|
|
|
||||||
| GET | `/kubikon3d/admin/dashboard` | сводка: онлайн, активные игры |
|
|
||||||
| GET | `/kubikon3d/admin/online` | список онлайн-юзеров |
|
|
||||||
| GET | `/kubikon3d/admin/all-games` | все игры с фильтрами |
|
|
||||||
| GET | `/kubikon3d/admin/moderation-queue` | очередь премодерации (для review-игр) |
|
|
||||||
| GET | `/kubikon3d/admin/reports` | репорты на игры |
|
|
||||||
| GET | `/kubikon3d/admin/bug-reports` | баг-репорты |
|
|
||||||
| GET | `/kubikon3d/admin/comments` | модерация комментариев |
|
|
||||||
| GET | `/kubikon3d/admin/chat/messages` | чат-сообщения |
|
|
||||||
| GET | `/kubikon3d/admin/chat/bans` | список банов в чате |
|
|
||||||
| GET | `/kubikon3d/admin/authors` | топ авторов с детальной статистикой |
|
|
||||||
|
|
||||||
### Multiplayer (Colyseus)
|
|
||||||
|
|
||||||
WebSocket-соединение, не HTTP. Адрес: `wss://multiplayer.rublox.pro/<room_id>`. Сейчас работает через `kubikon-realtime` микросервис (VM 110, port 8685, Node.js+Colyseus+Redis).
|
|
||||||
|
|
||||||
## Аутентификация
|
|
||||||
|
|
||||||
Все приватные эндпоинты ожидают **JWT в заголовке** `Authorization: Bearer <token>`. JWT выдаёт `/api-user/auth/login`. Срок жизни access-токена — 1 час, refresh-токена — 30 дней.
|
|
||||||
|
|
||||||
В плеере токен хранится в `localStorage.jwt`, рефрешится автоматически при 401 через `localStorage.refresh_token` → `/api-user/auth/refresh`.
|
|
||||||
|
|
||||||
## Изменения API
|
|
||||||
|
|
||||||
**До publish:** если меняешь сигнатуру эндпоинта (убираешь поле, переименовываешь, меняешь тип) — это **breaking change**. Объявление за 48 часов в канале `#разработка` на https://team.rublox.pro.
|
|
||||||
|
|
||||||
Записывай в `API_CHANGELOG.md` админ-репо (приватный, `minecraftia-school.ru/...`) — это позволяет отследить какие изменения когда были.
|
|
||||||
|
|
||||||
## Что делать если эндпоинт пропал
|
|
||||||
|
|
||||||
1. Открой issue в репо плеера: «API endpoint /xxx not found».
|
|
||||||
2. Прикрепи console-лог с ошибкой.
|
|
||||||
3. Кто-то из core-команды посмотрит в `API_CHANGELOG.md` админ-репо и ответит — это было умышленное изменение или баг.
|
|
||||||
|
|
||||||
## Контакты
|
|
||||||
|
|
||||||
- Issue tracker: https://git.rublox.pro/rublox/player/issues
|
|
||||||
- Чат: `#разработка` на https://team.rublox.pro
|
|
||||||
@ -122,7 +122,7 @@ Pre-commit-хук (Husky, если установлен) заблокирует
|
|||||||
- Ожидай фидбек в течение **48 часов** (часто в тот же день).
|
- Ожидай фидбек в течение **48 часов** (часто в тот же день).
|
||||||
- Маленькие PR (<300 строк) ревьюятся быстро. PR'ы >1000 строк попросят раздробить.
|
- Маленькие PR (<300 строк) ревьюятся быстро. PR'ы >1000 строк попросят раздробить.
|
||||||
- После approval **мерджит мейнтейнер** (право merge только у него).
|
- После approval **мерджит мейнтейнер** (право merge только у него).
|
||||||
- После merge **CI автоматически билдит и заливает на оба прод-сервера** (S1 и S2 параллельно через rsync). Время от мержа до прода ~3-5 мин.
|
- После merge автоматический webhook запускает деплой в прод.
|
||||||
|
|
||||||
## Что не смерджим
|
## Что не смерджим
|
||||||
|
|
||||||
@ -140,57 +140,6 @@ Pre-commit-хук (Husky, если установлен) заблокирует
|
|||||||
- Новые API для скриптов (в `ScriptSandboxAPI.js`)
|
- Новые API для скриптов (в `ScriptSandboxAPI.js`)
|
||||||
- Улучшения документации (особенно с примерами)
|
- Улучшения документации (особенно с примерами)
|
||||||
|
|
||||||
## Для мейнтейнеров (включая Lead)
|
|
||||||
|
|
||||||
**Даже Lead работает через PR.** Это не бюрократия — это страховка:
|
|
||||||
|
|
||||||
- **CI запускается только на PR и push в main**, прямой коммит в main минует CI → можно положить прод.
|
|
||||||
- **История изменений линейна** — каждое изменение имеет описание, ревьюера (даже самого себя) и chain of trust.
|
|
||||||
- **Контрибьюторы видят дисциплину Lead'а** и сами начинают её соблюдать.
|
|
||||||
|
|
||||||
### Workflow Lead/Maintainer (когда сам себе ревьюер)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. Feature-ветка от свежей main
|
|
||||||
git checkout main && git pull
|
|
||||||
git checkout -b feature/название
|
|
||||||
|
|
||||||
# 2. Делаем изменения, коммитим, пушим
|
|
||||||
git push origin feature/название
|
|
||||||
|
|
||||||
# 3. Открываем PR через Gitea UI или git.rublox.pro CLI
|
|
||||||
# 4. Ждём пока CI зелёный (~2 мин на lint+build)
|
|
||||||
# 5. Самоапрув + Merge (можно Squash для feature, Rebase для chore)
|
|
||||||
# 6. CI deploy job сам зальёт на S1+S2 (~3 мин)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Когда можно прямой push в main
|
|
||||||
|
|
||||||
**Никогда.** Даже emergency hotfix:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git checkout -b hotfix/что-сломалось
|
|
||||||
# фикс + git commit + git push
|
|
||||||
# Открыть PR → дождаться CI зелёного (~2 мин) → merge
|
|
||||||
# CI задеплоит за 3 мин
|
|
||||||
```
|
|
||||||
|
|
||||||
Итого hotfix-флоу = ~5 мин от обнаружения проблемы до развёрнутого фикса.
|
|
||||||
Это быстрее чем хаотичный прямой push с риском сломать прод повторно.
|
|
||||||
|
|
||||||
### Если CI deploy сломался (как fallback)
|
|
||||||
|
|
||||||
Можно вручную через DevPanel:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# На локалке
|
|
||||||
cd c:/Users/min/Desktop/server/rublox-player
|
|
||||||
npm run build
|
|
||||||
# Затем заливка через DevPanel → "rublox-player" → "Deploy"
|
|
||||||
```
|
|
||||||
|
|
||||||
Это **временный** обходной путь. После починки CI обязательно вернуться к авто-деплою.
|
|
||||||
|
|
||||||
## Безопасность
|
## Безопасность
|
||||||
|
|
||||||
Нашёл уязвимость? **Не открывай публичный issue.** Пиши на `security@rublox.pro` напрямую. См. [SECURITY.md](./SECURITY.md).
|
Нашёл уязвимость? **Не открывай публичный issue.** Пиши на `security@rublox.pro` напрямую. См. [SECURITY.md](./SECURITY.md).
|
||||||
|
|||||||
21
eslint.config.js
Normal file
21
eslint.config.js
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import js from '@eslint/js'
|
||||||
|
import globals from 'globals'
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
|
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{js,jsx}'],
|
||||||
|
extends: [
|
||||||
|
js.configs.recommended,
|
||||||
|
reactHooks.configs.flat.recommended,
|
||||||
|
reactRefresh.configs.vite,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
globals: globals.browser,
|
||||||
|
parserOptions: { ecmaFeatures: { jsx: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
21
package-lock.json
generated
21
package-lock.json
generated
@ -18,8 +18,7 @@
|
|||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-dom": "18.3.1",
|
"react-dom": "18.3.1",
|
||||||
"react-router-dom": "7.4.0",
|
"react-router-dom": "7.4.0",
|
||||||
"socket.io-client": "^4.8.3",
|
"socket.io-client": "^4.8.3"
|
||||||
"wasmoon": "^1.16.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "18.3.12",
|
"@types/react": "18.3.12",
|
||||||
@ -1428,12 +1427,6 @@
|
|||||||
"integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==",
|
"integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/emscripten": {
|
|
||||||
"version": "1.39.10",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/emscripten/-/emscripten-1.39.10.tgz",
|
|
||||||
"integrity": "sha512-TB/6hBkYQJxsZHSqyeuO1Jt0AB/bW6G7rHt9g7lML7SOF6lbgcHvw/Lr+69iqN0qxgXLhWKScAon73JNnptuDw==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@types/estree": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||||
@ -5213,18 +5206,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/wasmoon": {
|
|
||||||
"version": "1.16.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/wasmoon/-/wasmoon-1.16.0.tgz",
|
|
||||||
"integrity": "sha512-FlRLb15WwAOz1A9OQDbf6oOKKSiefi5VK0ZRF2wgH9xk3o5SnU11tNPaOnQuAh1Ucr66cwwvVXaeVRaFdRBt5g==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@types/emscripten": "1.39.10"
|
|
||||||
},
|
|
||||||
"bin": {
|
|
||||||
"wasmoon": "bin/wasmoon"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/which": {
|
"node_modules/which": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
|
|||||||
@ -34,7 +34,7 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"lint": "eslint . --ext .js,.jsx --max-warnings 200",
|
"lint": "eslint . --max-warnings 200",
|
||||||
"format": "prettier --write \"src/**/*.{js,jsx,json,md,css}\"",
|
"format": "prettier --write \"src/**/*.{js,jsx,json,md,css}\"",
|
||||||
"format:check": "prettier --check \"src/**/*.{js,jsx,json,md,css}\"",
|
"format:check": "prettier --check \"src/**/*.{js,jsx,json,md,css}\"",
|
||||||
"fetch-assets": "node scripts/fetch-assets.js",
|
"fetch-assets": "node scripts/fetch-assets.js",
|
||||||
@ -49,8 +49,7 @@
|
|||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-dom": "18.3.1",
|
"react-dom": "18.3.1",
|
||||||
"react-router-dom": "7.4.0",
|
"react-router-dom": "7.4.0",
|
||||||
"socket.io-client": "^4.8.3",
|
"socket.io-client": "^4.8.3"
|
||||||
"wasmoon": "^1.16.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "18.3.12",
|
"@types/react": "18.3.12",
|
||||||
|
|||||||
@ -38,8 +38,7 @@ function PlayerRoute() {
|
|||||||
return (
|
return (
|
||||||
<LoadingScreen
|
<LoadingScreen
|
||||||
text="Нужен JWT"
|
text="Нужен JWT"
|
||||||
subText={`Это dev-fallback (только localhost). На проде сразу редирект на rublox.pro. (gameId=${id})`}
|
subText={`Положи токен в localStorage["player_jwt"] и перезагрузи страницу. Это dev-fallback, на проде такого экрана нет — там сразу редирект на rublox.pro. (gameId=${id})`}
|
||||||
devJwt
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,198 +0,0 @@
|
|||||||
/**
|
|
||||||
* GameLoadingScreen — красивый экран загрузки игры в плеере (задача 05).
|
|
||||||
*
|
|
||||||
* Показывается пока грузится игра (после клика «Играть» на странице игры →
|
|
||||||
* открытие плеера). Композиция как в Roblox:
|
|
||||||
* - размытый фон-обложка игры с медленным Ken Burns (pan + zoom);
|
|
||||||
* - карточка-витрина по центру (обложка игры);
|
|
||||||
* - крупное название места;
|
|
||||||
* - автор + verified-галочка;
|
|
||||||
* - прогресс-бар + спиннер «ЗАГРУЗКА».
|
|
||||||
*
|
|
||||||
* Данные берёт из меты игры (title/thumbnail/автор) и, если автор настроил в
|
|
||||||
* студии вкладку «Стартовый экран» — из project_data.scene.loadingScreen
|
|
||||||
* (placeName / studioName / style / verified / background / cover).
|
|
||||||
*/
|
|
||||||
import React, { useEffect, useRef, useState } from 'react';
|
|
||||||
|
|
||||||
// Один раз вставляем CSS-keyframes (нельзя инлайнить в style).
|
|
||||||
let _cssInjected = false;
|
|
||||||
function injectCss() {
|
|
||||||
if (_cssInjected || typeof document === 'undefined') return;
|
|
||||||
_cssInjected = true;
|
|
||||||
const s = document.createElement('style');
|
|
||||||
s.id = 'kbn-game-loading-css';
|
|
||||||
s.textContent =
|
|
||||||
'@keyframes kbnGlsKen{0%{transform:scale(1.05) translate3d(0,0,0)}50%{transform:scale(1.15) translate3d(-3%,-2%,0)}100%{transform:scale(1.05) translate3d(-6%,0,0)}}' +
|
|
||||||
'.kbnGlsKen{animation:kbnGlsKen 22s ease-in-out infinite}' +
|
|
||||||
'@keyframes kbnGlsSpin{to{transform:rotate(360deg)}}' +
|
|
||||||
'.kbnGlsSpin{animation:kbnGlsSpin 0.85s linear infinite}' +
|
|
||||||
'@keyframes kbnGlsRise{0%{transform:translateY(0) scale(1);opacity:0}12%{opacity:.9}88%{opacity:.6}100%{transform:translateY(-110vh) scale(1.4);opacity:0}}' +
|
|
||||||
'.kbnGlsP{animation:kbnGlsRise linear infinite}' +
|
|
||||||
'@keyframes kbnGlsGlow{0%,100%{box-shadow:0 18px 60px rgba(0,0,0,.6),0 0 0 rgba(120,160,255,0)}50%{box-shadow:0 18px 60px rgba(0,0,0,.6),0 0 44px rgba(120,160,255,.4)}}' +
|
|
||||||
'.kbnGlsCard{animation:kbnGlsGlow 4s ease-in-out infinite}' +
|
|
||||||
'@keyframes kbnGlsBar{0%{transform:translateX(-100%)}100%{transform:translateX(250%)}}' +
|
|
||||||
'.kbnGlsBarRun{animation:kbnGlsBar 1.2s ease-in-out infinite}' +
|
|
||||||
'@media (prefers-reduced-motion:reduce){.kbnGlsKen,.kbnGlsP,.kbnGlsCard,.kbnGlsBarRun{animation:none}}';
|
|
||||||
document.head.appendChild(s);
|
|
||||||
}
|
|
||||||
|
|
||||||
function VerifiedBadge() {
|
|
||||||
return (
|
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" style={{ flex: '0 0 auto' }} aria-label="verified">
|
|
||||||
<circle cx="12" cy="12" r="11" fill="#3897f0" />
|
|
||||||
<path d="M7 12.5l3 3 7-7" fill="none" stroke="#fff" strokeWidth="2.4"
|
|
||||||
strokeLinecap="round" strokeLinejoin="round" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* props:
|
|
||||||
* meta — ответ getProjectForPlay (title, thumbnail, author_username/username, ...)
|
|
||||||
* loadingScreen — project_data.scene.loadingScreen (опц., настройки автора)
|
|
||||||
* progress — 0..1 (если null — «бегущая» полоса без процента)
|
|
||||||
*/
|
|
||||||
export default function GameLoadingScreen({ meta, loadingScreen, progress }) {
|
|
||||||
injectCss();
|
|
||||||
const ls = loadingScreen || {};
|
|
||||||
const [fade, setFade] = useState(0);
|
|
||||||
const rootRef = useRef(null);
|
|
||||||
|
|
||||||
useEffect(() => { const t = setTimeout(() => setFade(1), 20); return () => clearTimeout(t); }, []);
|
|
||||||
|
|
||||||
// Источники данных: настройки автора → мета игры → дефолт.
|
|
||||||
const bg = ls.background || meta?.thumbnail || null;
|
|
||||||
const cover = ls.cover || meta?.thumbnail || null;
|
|
||||||
const placeName = ls.placeName || meta?.title || 'Загрузка игры';
|
|
||||||
const studioName = ls.studioName
|
|
||||||
|| meta?.author_username || meta?.username || meta?.author || '';
|
|
||||||
const verified = ls.verified != null ? !!ls.verified
|
|
||||||
: !!(meta?.author_verified || meta?.is_verified);
|
|
||||||
const style = ls.style || 'ken-burns';
|
|
||||||
const accent = ls.accentColor || '#5fd0ff';
|
|
||||||
const hasProgress = typeof progress === 'number' && progress >= 0;
|
|
||||||
const pct = hasProgress ? Math.round(Math.max(0, Math.min(1, progress)) * 100) : null;
|
|
||||||
|
|
||||||
// parallax по мыши
|
|
||||||
const bgRef = useRef(null);
|
|
||||||
useEffect(() => {
|
|
||||||
if (style !== 'parallax' || !bgRef.current) return;
|
|
||||||
const h = (e) => {
|
|
||||||
const cx = (e.clientX / window.innerWidth - 0.5) * 26;
|
|
||||||
const cy = (e.clientY / window.innerHeight - 0.5) * 16;
|
|
||||||
if (bgRef.current) bgRef.current.style.transform = `translate3d(${-cx}px,${-cy}px,0) scale(1.1)`;
|
|
||||||
};
|
|
||||||
window.addEventListener('mousemove', h);
|
|
||||||
return () => window.removeEventListener('mousemove', h);
|
|
||||||
}, [style]);
|
|
||||||
|
|
||||||
const particles = style === 'particles'
|
|
||||||
? Array.from({ length: 24 }, (_, i) => {
|
|
||||||
const size = 2 + (i % 4);
|
|
||||||
const dur = 7 + (i % 7);
|
|
||||||
return (
|
|
||||||
<span key={i} className="kbnGlsP" style={{
|
|
||||||
position: 'absolute', bottom: -10, left: `${(i * 37) % 100}%`,
|
|
||||||
width: size, height: size, borderRadius: '50%',
|
|
||||||
background: `rgba(${180 + (i * 7) % 70},${190 + (i * 5) % 60},255,0.85)`,
|
|
||||||
boxShadow: `0 0 ${size * 2}px rgba(140,170,255,0.7)`,
|
|
||||||
animationDuration: `${dur}s`, animationDelay: `${-(i % 7)}s`,
|
|
||||||
}} />
|
|
||||||
);
|
|
||||||
}) : null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div ref={rootRef} style={{
|
|
||||||
position: 'absolute', inset: 0, zIndex: 60, overflow: 'hidden',
|
|
||||||
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
|
|
||||||
background: 'radial-gradient(ellipse at center, #0e1430 0%, #070a14 70%)',
|
|
||||||
opacity: fade, transition: 'opacity 0.4s ease',
|
|
||||||
fontFamily: 'system-ui,"Segoe UI",sans-serif',
|
|
||||||
}}>
|
|
||||||
{/* Фоновый слой (Ken Burns / parallax / static) */}
|
|
||||||
{bg && (
|
|
||||||
<div ref={bgRef}
|
|
||||||
className={style === 'ken-burns' ? 'kbnGlsKen' : undefined}
|
|
||||||
style={{
|
|
||||||
position: 'absolute', inset: '-8%', zIndex: 0,
|
|
||||||
backgroundImage: `url("${bg}")`, backgroundSize: 'cover', backgroundPosition: 'center',
|
|
||||||
filter: 'blur(9px) brightness(0.5)', willChange: 'transform',
|
|
||||||
transition: style === 'parallax' ? 'transform 0.25s ease-out' : 'none',
|
|
||||||
}} />
|
|
||||||
)}
|
|
||||||
{/* particles */}
|
|
||||||
{particles && <div style={{ position: 'absolute', inset: 0, zIndex: 1, pointerEvents: 'none' }}>{particles}</div>}
|
|
||||||
|
|
||||||
{/* Контент */}
|
|
||||||
<div style={{
|
|
||||||
position: 'relative', zIndex: 2, display: 'flex',
|
|
||||||
flexDirection: 'column', alignItems: 'center',
|
|
||||||
}}>
|
|
||||||
{/* Карточка-витрина */}
|
|
||||||
<div className="kbnGlsCard" style={{
|
|
||||||
width: 'min(40vw,300px)', aspectRatio: '1/1', borderRadius: 18,
|
|
||||||
backgroundImage: cover ? `url("${cover}")` : 'none',
|
|
||||||
backgroundColor: '#1a1f2b', backgroundSize: 'cover', backgroundPosition: 'center',
|
|
||||||
border: '2px solid rgba(255,255,255,0.14)',
|
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
||||||
}}>
|
|
||||||
{!cover && (
|
|
||||||
<span style={{ color: '#5a6178', fontSize: 14, fontWeight: 700 }}>РУБЛОКС • 3D</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Название места */}
|
|
||||||
<div style={{
|
|
||||||
marginTop: 22, color: '#fff', fontSize: 34, fontWeight: 800,
|
|
||||||
letterSpacing: 0.4, textAlign: 'center', maxWidth: '80vw',
|
|
||||||
textShadow: '0 3px 14px rgba(0,0,0,0.7)',
|
|
||||||
}}>{placeName}</div>
|
|
||||||
|
|
||||||
{/* Автор + verified */}
|
|
||||||
{studioName && (
|
|
||||||
<div style={{
|
|
||||||
marginTop: 8, display: 'flex', alignItems: 'center', gap: 7,
|
|
||||||
color: '#cdd6e6', fontSize: 16, fontWeight: 600,
|
|
||||||
textShadow: '0 1px 4px rgba(0,0,0,0.6)',
|
|
||||||
}}>
|
|
||||||
<span>{studioName}</span>
|
|
||||||
{verified && <VerifiedBadge />}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Прогресс-бар */}
|
|
||||||
<div style={{
|
|
||||||
marginTop: 26, width: 'min(64vw,420px)', height: 10, borderRadius: 6,
|
|
||||||
background: 'rgba(255,255,255,0.12)', overflow: 'hidden',
|
|
||||||
boxShadow: 'inset 0 1px 3px rgba(0,0,0,0.5)', position: 'relative',
|
|
||||||
}}>
|
|
||||||
{hasProgress ? (
|
|
||||||
<div style={{
|
|
||||||
height: '100%', width: `${pct}%`, borderRadius: 6,
|
|
||||||
background: `linear-gradient(90deg, ${accent}, #ffffff)`,
|
|
||||||
transition: 'width 0.2s linear', boxShadow: `0 0 10px ${accent}`,
|
|
||||||
}} />
|
|
||||||
) : (
|
|
||||||
<div className="kbnGlsBarRun" style={{
|
|
||||||
height: '100%', width: '40%', borderRadius: 6,
|
|
||||||
background: `linear-gradient(90deg, transparent, ${accent}, transparent)`,
|
|
||||||
}} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Спиннер + статус */}
|
|
||||||
<div style={{
|
|
||||||
marginTop: 16, display: 'flex', alignItems: 'center', gap: 12,
|
|
||||||
color: '#fff', fontSize: 15, fontWeight: 700, letterSpacing: 0.5,
|
|
||||||
}}>
|
|
||||||
<span className="kbnGlsSpin" style={{
|
|
||||||
display: 'inline-block', width: 20, height: 20,
|
|
||||||
border: '3px solid rgba(255,255,255,0.25)', borderTopColor: accent, borderRadius: '50%',
|
|
||||||
}} />
|
|
||||||
{pct != null ? `${pct}%` : 'ЗАГРУЗКА'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1217,17 +1217,6 @@ function TabReport({ gameId, gameTitle }) {
|
|||||||
setStatus({ text: 'Отправка...', error: false });
|
setStatus({ text: 'Отправка...', error: false });
|
||||||
try {
|
try {
|
||||||
const token = getToken();
|
const token = getToken();
|
||||||
// Бэкенд /kubikon3d/reports требует reporter_user_id и target_id и
|
|
||||||
// принимает поле text. Раньше TabReport слал {title, message,
|
|
||||||
// game_id, game_title} БЕЗ reporter_user_id → бэк отвечал
|
|
||||||
// 400 'reporter_user_id required' → жалоба падала. Приводим к
|
|
||||||
// формату бэкенда (как нижняя кнопка «Жалоба»).
|
|
||||||
const me = getMyProfile();
|
|
||||||
if (!me || !me.id) {
|
|
||||||
setStatus({ text: 'Войдите, чтобы отправить жалобу.', error: true });
|
|
||||||
setSending(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const res = await fetch(`${STORYS_addres}/kubikon3d/reports`, {
|
const res = await fetch(`${STORYS_addres}/kubikon3d/reports`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
@ -1235,12 +1224,11 @@ function TabReport({ gameId, gameTitle }) {
|
|||||||
...(token ? { Authorization: token } : {}),
|
...(token ? { Authorization: token } : {}),
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
reporter_user_id: me.id,
|
|
||||||
target_type: 'project',
|
|
||||||
target_id: gameId || null,
|
|
||||||
category,
|
category,
|
||||||
text: title.trim() + '\n\n' + message.trim()
|
title: title.trim(),
|
||||||
+ (gameTitle ? `\n\n(игра: ${gameTitle})` : ''),
|
message: message.trim(),
|
||||||
|
game_id: gameId || null,
|
||||||
|
game_title: gameTitle || null,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
@ -1248,9 +1236,7 @@ function TabReport({ gameId, gameTitle }) {
|
|||||||
setTitle('');
|
setTitle('');
|
||||||
setMessage('');
|
setMessage('');
|
||||||
} else {
|
} else {
|
||||||
let detail = '';
|
setStatus({ text: 'Не удалось отправить. Попробуйте позже.', error: true });
|
||||||
try { const j = await res.json(); if (j && j.error) detail = ': ' + j.error; } catch (e) {}
|
|
||||||
setStatus({ text: 'Не удалось отправить' + detail + '.', error: true });
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setStatus({ text: 'Сеть недоступна.', error: true });
|
setStatus({ text: 'Сеть недоступна.', error: true });
|
||||||
|
|||||||
@ -363,10 +363,6 @@ const KubikonChatPanel = ({ projectId, onClose, onRequestAuth, compact = false,
|
|||||||
is_manual: data.is_manual,
|
is_manual: data.is_manual,
|
||||||
});
|
});
|
||||||
setError(data.message || formatMuteMessage(data));
|
setError(data.message || formatMuteMessage(data));
|
||||||
} else if (code === 'email_not_confirmed') {
|
|
||||||
// То же поведение что и в WS-пути: русская модалка «подтвердите
|
|
||||||
// email», а не сырой английский код ошибки.
|
|
||||||
setEmailNotice(true);
|
|
||||||
} else if (code === 'too_frequent') {
|
} else if (code === 'too_frequent') {
|
||||||
setError(data.message || 'Слишком быстро.');
|
setError(data.message || 'Слишком быстро.');
|
||||||
} else if (code === 'login_required') {
|
} else if (code === 'login_required') {
|
||||||
|
|||||||
@ -9,8 +9,6 @@ import { MultiplayerSync } from '../engine/MultiplayerSync';
|
|||||||
import { REALTIME_WS } from '../api/API';
|
import { REALTIME_WS } from '../api/API';
|
||||||
import GameHud from '../editor-shared/GameHud';
|
import GameHud from '../editor-shared/GameHud';
|
||||||
import GuiOverlay from '../editor-shared/GuiOverlay';
|
import GuiOverlay from '../editor-shared/GuiOverlay';
|
||||||
import ModalOverlay from '../editor-shared/ModalOverlay';
|
|
||||||
import SkinShopOverlay from '../editor-shared/SkinShopOverlay';
|
|
||||||
import KubikonLeaderboard, { formatTimeMs } from '../components/KubikonLeaderboard/KubikonLeaderboard';
|
import KubikonLeaderboard, { formatTimeMs } from '../components/KubikonLeaderboard/KubikonLeaderboard';
|
||||||
import KubikonPerfOverlay from '../components/KubikonPerfOverlay/KubikonPerfOverlay';
|
import KubikonPerfOverlay from '../components/KubikonPerfOverlay/KubikonPerfOverlay';
|
||||||
import Hotbar from '../editor-shared/Hotbar';
|
import Hotbar from '../editor-shared/Hotbar';
|
||||||
@ -22,7 +20,6 @@ import { useAuth } from '../auth/PlayerAuth';
|
|||||||
import RublocsLogo from '../components/RublocsLogo/RublocsLogo';
|
import RublocsLogo from '../components/RublocsLogo/RublocsLogo';
|
||||||
import useDeviceType from '../hooks/useDeviceType';
|
import useDeviceType from '../hooks/useDeviceType';
|
||||||
import KubikonMobileControls from './KubikonMobileControls';
|
import KubikonMobileControls from './KubikonMobileControls';
|
||||||
import GameLoadingScreen from './GameLoadingScreen';
|
|
||||||
|
|
||||||
// Плеер живёт на player.rublox.pro — он не знает SPA-роутов Майнкрафтии
|
// Плеер живёт на player.rublox.pro — он не знает SPA-роутов Майнкрафтии
|
||||||
// (/kubikon, /login, /auth). Поэтому вместо navigate(...) делаем
|
// (/kubikon, /login, /auth). Поэтому вместо navigate(...) делаем
|
||||||
@ -39,12 +36,12 @@ function exitPlayer(gameId) {
|
|||||||
// (флаг читает onBeforeUnload listener ниже).
|
// (флаг читает onBeforeUnload listener ниже).
|
||||||
try { window.__rubloxExplicitExit = true; } catch {}
|
try { window.__rubloxExplicitExit = true; } catch {}
|
||||||
const env = (typeof import.meta !== 'undefined' && import.meta.env) || {};
|
const env = (typeof import.meta !== 'undefined' && import.meta.env) || {};
|
||||||
const RUBLOX_HOME = (env.VITE_RUBLOX_HOME || 'https://rublox.pro/app').replace(/\/+$/, '');
|
const RUBLOX_HOME = env.VITE_RUBLOX_HOME || 'https://rublox.pro/app';
|
||||||
if (gameId) {
|
if (gameId) {
|
||||||
// У страницы игры теперь свой URL: /app/game/<id> (им можно делиться).
|
// Передаём gameId через ?game=<id> — главный сайт прочитает и снова
|
||||||
// Возвращаемся прямо на него. base RUBLOX_HOME оканчивается на /app.
|
// откроет карточку игры (юзер возвращается на ту же страницу).
|
||||||
const base = RUBLOX_HOME.endsWith('/app') ? RUBLOX_HOME : `${RUBLOX_HOME}/app`;
|
const sep = RUBLOX_HOME.includes('?') ? '&' : '?';
|
||||||
window.location.assign(`${base}/game/${gameId}`);
|
window.location.assign(`${RUBLOX_HOME}${sep}game=${gameId}`);
|
||||||
} else {
|
} else {
|
||||||
window.location.assign(RUBLOX_HOME);
|
window.location.assign(RUBLOX_HOME);
|
||||||
}
|
}
|
||||||
@ -217,9 +214,6 @@ const KubikonPlayer = () => {
|
|||||||
const [forbidden, setForbidden] = useState(false);
|
const [forbidden, setForbidden] = useState(false);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
// Задача 05: конфиг красивого экрана загрузки (из project_data.scene.loadingScreen).
|
|
||||||
const [loadingScreenCfg, setLoadingScreenCfg] = useState(null);
|
|
||||||
const [loadProgress, setLoadProgress] = useState(0);
|
|
||||||
// Раньше была стартовая заглушка «тапни чтобы начать» — убрали по
|
// Раньше была стартовая заглушка «тапни чтобы начать» — убрали по
|
||||||
// фидбэку, она бесила. Теперь fullscreen опционально через кнопку
|
// фидбэку, она бесила. Теперь fullscreen опционально через кнопку
|
||||||
// в углу. Этот state остался для совместимости с handleMobileStart.
|
// в углу. Этот state остался для совместимости с handleMobileStart.
|
||||||
@ -509,31 +503,20 @@ const KubikonPlayer = () => {
|
|||||||
});
|
});
|
||||||
scene.setOnPlayChange?.((playing) => {
|
scene.setOnPlayChange?.((playing) => {
|
||||||
setIsPlaying(playing);
|
setIsPlaying(playing);
|
||||||
// ВНИМАНИЕ: при обычном ESC сюда мы больше НЕ попадаем — ESC теперь
|
// ESC обрабатывается через pointerlockchange-перехват в плеере
|
||||||
// открывает меню через setOnEscMenu (ниже), не выходя из Play.
|
// (см. отдельный useEffect ниже). Сюда мы попадаем только если
|
||||||
// exitPlayMode(false) случается только по-настоящему (напр. движок
|
// exitPlayMode вызвался по другой причине — тогда просто открываем
|
||||||
// сам остановил Play). В этом случае просто открываем меню, чтобы
|
// меню, чтобы пользователь мог выйти/вернуться, и пересоздаём Play
|
||||||
// юзер мог выйти/перезапустить. НЕ пересоздаём Play автоматически —
|
// в UI-cursor режиме.
|
||||||
// повторный enterPlayMode респавнил игрока и перезапускал скрипты
|
|
||||||
// («перезапуск плейса» при ESC). Перезапуск делается явной кнопкой.
|
|
||||||
if (!playing) {
|
if (!playing) {
|
||||||
|
setTimeout(() => {
|
||||||
const s = sceneRef.current;
|
const s = sceneRef.current;
|
||||||
s?.player?.setUiCursorMode?.(true);
|
if (!s) return;
|
||||||
|
s.enterPlayMode?.();
|
||||||
|
s.player?.setUiCursorMode?.(true);
|
||||||
|
}, 30);
|
||||||
setChatOpen(false);
|
setChatOpen(false);
|
||||||
setTopMenuOpen(true);
|
setTopMenuOpen(true);
|
||||||
try { if (s) s._playerMenuOpen = true; } catch (e) { /* ignore */ }
|
|
||||||
}
|
|
||||||
});
|
|
||||||
// ESC в Play → TOGGLE меню-оверлея поверх ЖИВОЙ игры (Roblox-style).
|
|
||||||
// Движок сам решает open/close (единый источник истины _playerMenuOpen)
|
|
||||||
// и передаёт сюда. Это убирает гонку двух ESC-обработчиков, из-за которой
|
|
||||||
// меню открывалось поверх меню, а orbit-камера по ПКМ зависала.
|
|
||||||
scene.setOnEscMenu?.((open) => {
|
|
||||||
if (open) {
|
|
||||||
setChatOpen(false);
|
|
||||||
setTopMenuOpen(true);
|
|
||||||
} else {
|
|
||||||
setTopMenuOpen(false);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -555,18 +538,11 @@ const KubikonPlayer = () => {
|
|||||||
setMeta(data);
|
setMeta(data);
|
||||||
setLikesCount(data.likes_count || 0);
|
setLikesCount(data.likes_count || 0);
|
||||||
setDislikesCount(data.dislikes_count || 0);
|
setDislikesCount(data.dislikes_count || 0);
|
||||||
setLoadProgress(0.3);
|
|
||||||
|
|
||||||
if (data.project_data) {
|
if (data.project_data) {
|
||||||
const parsed = JSON.parse(data.project_data);
|
const parsed = JSON.parse(data.project_data);
|
||||||
initialStateRef.current = parsed;
|
initialStateRef.current = parsed;
|
||||||
// Задача 05: красивый экран загрузки — конфиг автора (если задан в студии).
|
|
||||||
try {
|
|
||||||
const lsc = parsed?.scene?.loadingScreen;
|
|
||||||
if (lsc && typeof lsc === 'object' && lsc.enabled !== false) setLoadingScreenCfg(lsc);
|
|
||||||
} catch (e) { /* ignore */ }
|
|
||||||
await scene.loadFromState(parsed);
|
await scene.loadFromState(parsed);
|
||||||
setLoadProgress(0.7);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ждём пока Babylon реально загрузит и скомпилит все
|
// Ждём пока Babylon реально загрузит и скомпилит все
|
||||||
@ -603,12 +579,9 @@ const KubikonPlayer = () => {
|
|||||||
skinFolderRef.current = mySkin;
|
skinFolderRef.current = mySkin;
|
||||||
try { scene.setPlayerModelType?.(mySkin); } catch (e) {}
|
try { scene.setPlayerModelType?.(mySkin); } catch (e) {}
|
||||||
|
|
||||||
setLoadProgress(1);
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
// Засчитываем плей. Передаём user_id (если залогинен) —
|
// Засчитываем плей
|
||||||
// это активирует self-cooldown (автор не накручивает себе)
|
Kubikon3DApi.incrementPlay(projectId).catch(() => {});
|
||||||
// и user-cooldown на бэке (см. /play в Kubikon3D.py).
|
|
||||||
Kubikon3DApi.incrementPlay(projectId, userId).catch(() => {});
|
|
||||||
// Запускаем игру сразу
|
// Запускаем игру сразу
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
scene.enterPlayMode?.();
|
scene.enterPlayMode?.();
|
||||||
@ -736,39 +709,35 @@ const KubikonPlayer = () => {
|
|||||||
const s = sceneRef.current;
|
const s = sceneRef.current;
|
||||||
if (!s || !s._isPlaying) return;
|
if (!s || !s._isPlaying) return;
|
||||||
const locked = !!document.pointerLockElement;
|
const locked = !!document.pointerLockElement;
|
||||||
if (locked || !s.player || s.player._uiCursorMode) return;
|
// Lock потерян, мы НЕ в UI-cursor mode → пользователь нажал ESC
|
||||||
// Lock потерян. НЕ всякая потеря = ESC! В third-person отпускание
|
if (!locked && s.player && !s.player._uiCursorMode) {
|
||||||
// ПКМ (orbit-камера) тоже снимает lock — это НЕ выход в меню.
|
// Синхронно ставим флаг — listener PlayerController сработает
|
||||||
// Меню открываем ТОЛЬКО если lock был «постоянным» (perma-режим:
|
// следующим и увидит true, не вызовет _onExitRequest.
|
||||||
// first/lockfirst/sideview/shiftLock) — там потеря lock = реальный ESC.
|
s.player._uiCursorMode = true;
|
||||||
const p = s.player;
|
// Открываем меню в следующий тик (state-update React)
|
||||||
const permaLock = (
|
|
||||||
p._cameraMode === 'first' ||
|
|
||||||
p._cameraMode === 'lockfirst' ||
|
|
||||||
p._cameraMode === 'sideview' ||
|
|
||||||
p._shiftLock
|
|
||||||
);
|
|
||||||
// _rmbHeld был выставлен при входе в lock; если ПКМ отпущена в third —
|
|
||||||
// это orbit-завершение, не меню.
|
|
||||||
if (!permaLock) return;
|
|
||||||
// Реальный ESC в perma-режиме → открываем меню.
|
|
||||||
p._uiCursorMode = true;
|
|
||||||
setChatOpen(false);
|
setChatOpen(false);
|
||||||
setTopMenuOpen(true);
|
setTopMenuOpen(true);
|
||||||
// Синхронизируем единый флаг меню в движке, чтобы следующий ESC
|
}
|
||||||
// сработал как toggle-закрытие (а не открыл второе меню).
|
|
||||||
try { s._playerMenuOpen = true; } catch (e) { /* ignore */ }
|
|
||||||
};
|
};
|
||||||
// capture-фаза, чтобы успеть раньше PlayerController
|
// capture-фаза, чтобы успеть раньше PlayerController
|
||||||
document.addEventListener('pointerlockchange', onLockChange, true);
|
document.addEventListener('pointerlockchange', onLockChange, true);
|
||||||
return () => document.removeEventListener('pointerlockchange', onLockChange, true);
|
return () => document.removeEventListener('pointerlockchange', onLockChange, true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Повторный ESC (toggle закрытие) теперь обрабатывает движок через
|
// Повторный ESC (когда меню уже открыто) — закрыть меню и вернуть
|
||||||
// setOnExitRequest → _onEscMenu(false). Отдельный React-обработчик ESC
|
// мышь в игру.
|
||||||
// УБРАН — он слушал тот же ESC, что и движок, и создавал гонку:
|
useEffect(() => {
|
||||||
// меню открывалось поверх себя, а _uiCursorMode застревал в true
|
if (!topMenuOpen) return;
|
||||||
// (orbit-камера по ПКМ переставала работать после закрытия меню).
|
const onEsc = (e) => {
|
||||||
|
if (e.key !== 'Escape') return;
|
||||||
|
const s = sceneRef.current;
|
||||||
|
if (!s || !s._isPlaying) return;
|
||||||
|
setTopMenuOpen(false);
|
||||||
|
s.player?.setUiCursorMode?.(false);
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onEsc, true);
|
||||||
|
return () => window.removeEventListener('keydown', onEsc, true);
|
||||||
|
}, [topMenuOpen]);
|
||||||
|
|
||||||
// Горячая клавиша T — открыть/закрыть чат. Игнорируем когда:
|
// Горячая клавиша T — открыть/закрыть чат. Игнорируем когда:
|
||||||
// • уже введён текст в <input>/<textarea>/contenteditable (юзер печатает)
|
// • уже введён текст в <input>/<textarea>/contenteditable (юзер печатает)
|
||||||
@ -982,11 +951,6 @@ const KubikonPlayer = () => {
|
|||||||
// Очищаем ref'ы — иначе следующий connectMultiplayer выйдет
|
// Очищаем ref'ы — иначе следующий connectMultiplayer выйдет
|
||||||
// на if (mpSyncRef.current || roomRef.current) return.
|
// на if (mpSyncRef.current || roomRef.current) return.
|
||||||
try { sync.stop?.(); } catch (e) {}
|
try { sync.stop?.(); } catch (e) {}
|
||||||
// ВАЖНО: dispose() сносит ВСЕ старые меши remote-игроков со
|
|
||||||
// сцены. Без этого при auto-reconnect (Colyseus rejoin) новый
|
|
||||||
// MultiplayerSync видит пустую Map и при +remote создаёт
|
|
||||||
// дубль-меш на каждый кадр (см. фикс 2026-06-05).
|
|
||||||
try { sync.dispose?.(); } catch (e) {}
|
|
||||||
mpSyncRef.current = null;
|
mpSyncRef.current = null;
|
||||||
roomRef.current = null;
|
roomRef.current = null;
|
||||||
// Code 1000 / 1001 — нормальное закрытие. Code >= 4000 — наш
|
// Code 1000 / 1001 — нормальное закрытие. Code >= 4000 — наш
|
||||||
@ -1150,13 +1114,46 @@ const KubikonPlayer = () => {
|
|||||||
outline: 'none',
|
outline: 'none',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{/* Задача 05: красивый экран загрузки игры (Ken Burns + название места + автор). */}
|
{/* Loading-оверлей */}
|
||||||
{loading && (
|
{loading && (
|
||||||
<GameLoadingScreen
|
<div style={{
|
||||||
meta={meta}
|
position: 'absolute', inset: 0,
|
||||||
loadingScreen={loadingScreenCfg}
|
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
|
||||||
progress={loadProgress}
|
background:
|
||||||
/>
|
'radial-gradient(ellipse at center, rgba(51,87,255,0.22) 0%, rgba(7,10,20,0.96) 60%)',
|
||||||
|
gap: 18, color: HUD.text,
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
position: 'relative',
|
||||||
|
animation: 'hudFloat 3s ease-in-out infinite',
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', inset: -10,
|
||||||
|
borderRadius: 20,
|
||||||
|
animation: 'hudPulseRing 1.6s ease-out infinite',
|
||||||
|
}} />
|
||||||
|
<RublocsLogo size={72} />
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 10,
|
||||||
|
fontSize: 15, fontWeight: 700, letterSpacing: 0.3,
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
width: 14, height: 14,
|
||||||
|
border: `2.5px solid ${HUD.accentBg}`,
|
||||||
|
borderTopColor: HUD.accent,
|
||||||
|
borderRadius: '50%',
|
||||||
|
animation: 'hudSpin 0.8s linear infinite',
|
||||||
|
}} />
|
||||||
|
Загрузка игры…
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
fontSize: 11, color: HUD.textDim,
|
||||||
|
textTransform: 'uppercase', letterSpacing: 1.4, fontWeight: 700,
|
||||||
|
}}>
|
||||||
|
Рублокс • 3D
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Игровой UI (HUD, GUI, hotbar, прицел в первом лице) */}
|
{/* Игровой UI (HUD, GUI, hotbar, прицел в первом лице) */}
|
||||||
@ -1385,10 +1382,6 @@ const KubikonPlayer = () => {
|
|||||||
rt.routeGlobalEvent('guiClick', { id: gid });
|
rt.routeGlobalEvent('guiClick', { id: gid });
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{/* Задача 04: модал-overlay (затемнение + spotlight mask) */}
|
|
||||||
<ModalOverlay scene={sceneRef.current} />
|
|
||||||
{/* Задача 07: встроенный магазин скинов (клавиша B / API) */}
|
|
||||||
<SkinShopOverlay scene={sceneRef.current} />
|
|
||||||
{/* Мобильное управление — на любых тач-устройствах,
|
{/* Мобильное управление — на любых тач-устройствах,
|
||||||
и в портрете и в ландшафте (ранее был блок portrait,
|
и в портрете и в ландшафте (ранее был блок portrait,
|
||||||
убрали по фидбэку — играть можно как угодно). */}
|
убрали по фидбэку — играть можно как угодно). */}
|
||||||
@ -1606,10 +1599,9 @@ const KubikonPlayer = () => {
|
|||||||
visible={topMenuOpen}
|
visible={topMenuOpen}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setTopMenuOpen(false);
|
setTopMenuOpen(false);
|
||||||
// Синхронизируем движок (_playerMenuOpen) И возвращаем мышь
|
// Возвращаем мышь в pointer-lock игры (как делал
|
||||||
// в игру одним вызовом. Без этого следующий ESC решит, что
|
// старый ESC-handler выше).
|
||||||
// меню «ещё открыто», и не откроет его.
|
try { sceneRef.current?.player?.setUiCursorMode?.(false); } catch {}
|
||||||
try { sceneRef.current?.setPlayerMenuOpen?.(false); } catch {}
|
|
||||||
}}
|
}}
|
||||||
onExit={() => exitPlayer(id)}
|
onExit={() => exitPlayer(id)}
|
||||||
onRespawn={() => respawnPlayer()}
|
onRespawn={() => respawnPlayer()}
|
||||||
|
|||||||
@ -9,62 +9,11 @@
|
|||||||
//
|
//
|
||||||
// CSS-анимации, без JS-фрейма каждый кадр.
|
// CSS-анимации, без JS-фрейма каждый кадр.
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
const TITLE = 'Рублокс';
|
const TITLE = 'Рублокс';
|
||||||
|
|
||||||
/**
|
export default function LoadingScreen({ text = 'Подключение', subText = null }) {
|
||||||
* Dev-only панель вставки JWT. Показывается на экране «Нужен JWT» (только
|
|
||||||
* localhost). Кнопка → инпут → сохраняет в localStorage['player_jwt'] и
|
|
||||||
* перезагружает страницу. На проде этот экран не наступает (там redirect).
|
|
||||||
*/
|
|
||||||
function DevJwtPanel() {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const [val, setVal] = useState('');
|
|
||||||
|
|
||||||
const apply = () => {
|
|
||||||
const t = (val || '').trim();
|
|
||||||
if (!t) return;
|
|
||||||
try {
|
|
||||||
localStorage.setItem('player_jwt', t);
|
|
||||||
// совместимость с другими местами чтения токена
|
|
||||||
localStorage.setItem('Authorization', t);
|
|
||||||
} catch (e) { /* ignore */ }
|
|
||||||
window.location.reload();
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!open) {
|
|
||||||
return (
|
|
||||||
<button className="rb-devbtn" onClick={() => setOpen(true)} title="Вставить JWT (dev)">
|
|
||||||
🔑 Вставить JWT
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div className="rb-devpanel">
|
|
||||||
<textarea
|
|
||||||
className="rb-devinput"
|
|
||||||
placeholder="Вставь сюда player_jwt…"
|
|
||||||
value={val}
|
|
||||||
onChange={(e) => setVal(e.target.value)}
|
|
||||||
autoFocus
|
|
||||||
spellCheck={false}
|
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) apply(); }}
|
|
||||||
/>
|
|
||||||
<div className="rb-devrow">
|
|
||||||
<button className="rb-devapply" onClick={apply} disabled={!val.trim()}>
|
|
||||||
Войти
|
|
||||||
</button>
|
|
||||||
<button className="rb-devcancel" onClick={() => setOpen(false)}>
|
|
||||||
Отмена
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="rb-devhint">Ctrl+Enter — войти. Токен сохранится в localStorage.</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function LoadingScreen({ text = 'Подключение', subText = null, devJwt = false }) {
|
|
||||||
return (
|
return (
|
||||||
<div className="rb-splash">
|
<div className="rb-splash">
|
||||||
<style>{splashCss}</style>
|
<style>{splashCss}</style>
|
||||||
@ -98,9 +47,6 @@ export default function LoadingScreen({ text = 'Подключение', subText
|
|||||||
{text}<span className="rb-dots" aria-hidden="true">…</span>
|
{text}<span className="rb-dots" aria-hidden="true">…</span>
|
||||||
</div>
|
</div>
|
||||||
{subText && <div className="rb-substatus">{subText}</div>}
|
{subText && <div className="rb-substatus">{subText}</div>}
|
||||||
|
|
||||||
{/* Dev-only: вставка JWT прямо с экрана (вместо ручного localStorage). */}
|
|
||||||
{devJwt && <DevJwtPanel />}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -181,51 +127,6 @@ const splashCss = `
|
|||||||
transform: translate(-50%, -50%);
|
transform: translate(-50%, -50%);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
/* === Dev JWT panel === */
|
|
||||||
.rb-devbtn {
|
|
||||||
margin-top: 22px;
|
|
||||||
background: rgba(255,255,255,0.14);
|
|
||||||
border: 1px solid rgba(255,255,255,0.3);
|
|
||||||
color: #fff;
|
|
||||||
font-size: 13px; font-weight: 600;
|
|
||||||
padding: 8px 16px; border-radius: 10px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.15s;
|
|
||||||
}
|
|
||||||
.rb-devbtn:hover { background: rgba(255,255,255,0.24); }
|
|
||||||
.rb-devpanel {
|
|
||||||
margin-top: 20px;
|
|
||||||
width: 380px; max-width: calc(100vw - 40px);
|
|
||||||
display: flex; flex-direction: column; gap: 8px;
|
|
||||||
}
|
|
||||||
.rb-devinput {
|
|
||||||
width: 100%; height: 70px; resize: vertical;
|
|
||||||
background: rgba(0,0,0,0.28);
|
|
||||||
border: 1px solid rgba(255,255,255,0.3);
|
|
||||||
border-radius: 10px;
|
|
||||||
color: #fff; font-size: 12px;
|
|
||||||
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
|
|
||||||
padding: 10px; box-sizing: border-box;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
.rb-devinput:focus { border-color: rgba(255,255,255,0.6); }
|
|
||||||
.rb-devrow { display: flex; gap: 8px; }
|
|
||||||
.rb-devapply {
|
|
||||||
flex: 1;
|
|
||||||
background: #ffffff; color: #2841C8;
|
|
||||||
border: none; border-radius: 10px;
|
|
||||||
font-size: 14px; font-weight: 700;
|
|
||||||
padding: 9px 0; cursor: pointer;
|
|
||||||
}
|
|
||||||
.rb-devapply:disabled { opacity: 0.4; cursor: default; }
|
|
||||||
.rb-devcancel {
|
|
||||||
background: transparent; color: rgba(255,255,255,0.8);
|
|
||||||
border: 1px solid rgba(255,255,255,0.3); border-radius: 10px;
|
|
||||||
font-size: 13px; padding: 9px 16px; cursor: pointer;
|
|
||||||
}
|
|
||||||
.rb-devhint {
|
|
||||||
font-size: 11px; opacity: 0.6; text-align: center;
|
|
||||||
}
|
|
||||||
@keyframes rbBubble {
|
@keyframes rbBubble {
|
||||||
0%, 100% { opacity: 0.35; }
|
0%, 100% { opacity: 0.35; }
|
||||||
50% { opacity: 0.70; }
|
50% { opacity: 0.70; }
|
||||||
|
|||||||
@ -132,7 +132,7 @@ export default function PreviewAvatarRoute() {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button onClick={() => { try { window.close(); } catch (e) {} navigate('/'); }}
|
<button onClick={() => { try { window.close(); } catch (e) {}; navigate('/'); }}
|
||||||
style={closeBtnStyle}>Закрыть</button>
|
style={closeBtnStyle}>Закрыть</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -214,7 +214,7 @@ export default function PreviewEmoteRoute() {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button onClick={() => { try { window.close(); } catch (e) {} navigate('/'); }}
|
<button onClick={() => { try { window.close(); } catch (e) {}; navigate('/'); }}
|
||||||
style={closeBtnStyle}>Закрыть</button>
|
style={closeBtnStyle}>Закрыть</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -208,7 +208,7 @@ export default function PreviewModelRoute() {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button onClick={() => { try { window.close(); } catch (e) {} navigate('/'); }}
|
<button onClick={() => { try { window.close(); } catch (e) {}; navigate('/'); }}
|
||||||
style={closeBtnStyle}>Закрыть</button>
|
style={closeBtnStyle}>Закрыть</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -30,13 +30,10 @@ export const STORYS_addres = BASE + '/api-storys';
|
|||||||
// env-настроенные прямые URL.
|
// env-настроенные прямые URL.
|
||||||
const IS_HTTPS = typeof window !== 'undefined' && window.location.protocol === 'https:';
|
const IS_HTTPS = typeof window !== 'undefined' && window.location.protocol === 'https:';
|
||||||
|
|
||||||
// 2026-06-05: realtime теперь прямо на game.rublox.pro (S1 NPM → S1 VM 110),
|
|
||||||
// не через minecraftia-school.ru/api-game (лишний hop S2 NPM → S1 NAT
|
|
||||||
// давал разрывы WebSocket каждую секунду).
|
|
||||||
export const REALTIME_HTTP = ENV.VITE_REALTIME_HTTP
|
export const REALTIME_HTTP = ENV.VITE_REALTIME_HTTP
|
||||||
?? (IS_HTTPS ? 'https://game.rublox.pro' : 'http://localhost:8685');
|
?? (IS_HTTPS ? `${window.location.origin}/api-game` : 'http://localhost:8685');
|
||||||
export const REALTIME_WS = ENV.VITE_REALTIME_WS
|
export const REALTIME_WS = ENV.VITE_REALTIME_WS
|
||||||
?? (IS_HTTPS ? 'wss://game.rublox.pro' : 'ws://localhost:8685');
|
?? (IS_HTTPS ? `wss://${window.location.host}/api-game` : 'ws://localhost:8685');
|
||||||
|
|
||||||
// Главный сайт Рублокса — куда редиректить юзеров без ticket/JWT.
|
// Главный сайт Рублокса — куда редиректить юзеров без ticket/JWT.
|
||||||
export const RUBLOX_HOME = ENV.VITE_RUBLOX_HOME ?? 'https://rublox.pro/app';
|
export const RUBLOX_HOME = ENV.VITE_RUBLOX_HOME ?? 'https://rublox.pro/app';
|
||||||
|
|||||||
@ -198,9 +198,8 @@ export const getProjectForPlay = (id, userId = null, isAdmin = false) =>
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const incrementPlay = (id, userId) =>
|
export const incrementPlay = (id) =>
|
||||||
api.post(`/kubikon3d/projects/${id}/play`,
|
api.post(`/kubikon3d/projects/${id}/play`);
|
||||||
userId ? { user_id: userId } : {});
|
|
||||||
|
|
||||||
/** kind: 'like' | 'dislike'. Toggle: повторный голос того же типа снимает,
|
/** kind: 'like' | 'dislike'. Toggle: повторный голос того же типа снимает,
|
||||||
* голос другого типа — переключает. */
|
* голос другого типа — переключает. */
|
||||||
|
|||||||
@ -91,7 +91,7 @@ export function readTicketFromHash() {
|
|||||||
export function readTeamJwtFromHash() {
|
export function readTeamJwtFromHash() {
|
||||||
if (typeof window === 'undefined') return null;
|
if (typeof window === 'undefined') return null;
|
||||||
// JWT-формат: header.payload.signature — три blob'а из base64url, точки.
|
// JWT-формат: header.payload.signature — три blob'а из base64url, точки.
|
||||||
const m = /(?:^|[#&])team_jwt=([A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+)/
|
const m = /(?:^|[#&])team_jwt=([A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+)/
|
||||||
.exec(window.location.hash || '');
|
.exec(window.location.hash || '');
|
||||||
return m ? m[1] : null;
|
return m ? m[1] : null;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -21,16 +21,11 @@ import Icon from './Icon';
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
function _optsEqual(a, b) {
|
function _optsEqual(a, b) {
|
||||||
// Расширенный compare — учитываем все поля стилизации.
|
|
||||||
if (a === b) return true;
|
if (a === b) return true;
|
||||||
if (!a || !b) return false;
|
if (!a || !b) return false;
|
||||||
const keys = ['x','y','color','size','textSize','bold','bg','border',
|
return a.x === b.x && a.y === b.y && a.color === b.color && a.size === b.size;
|
||||||
'borderRadius','padding','w','h','textAlign','anchor'];
|
|
||||||
for (const k of keys) {
|
|
||||||
if (a[k] !== b[k]) return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_LABEL_STYLE = {
|
const DEFAULT_LABEL_STYLE = {
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
@ -142,59 +137,32 @@ function GameHud({ visible, hudRef }) {
|
|||||||
{otherIds.map((id, i) => {
|
{otherIds.map((id, i) => {
|
||||||
const lbl = labels[id];
|
const lbl = labels[id];
|
||||||
const o = lbl.opts || {};
|
const o = lbl.opts || {};
|
||||||
// Поддерживаем как старый формат opts (x/y в %, color, size),
|
const hasPos = typeof o.x === 'number' || typeof o.y === 'number';
|
||||||
// так и расширенный (bg, border, borderRadius, padding,
|
|
||||||
// w/h/textSize/bold/textAlign, x/y в пикселях или с '%').
|
|
||||||
const hasPercentXY = (typeof o.x === 'number' && o.x <= 100 && typeof o.y === 'number' && o.y <= 100)
|
|
||||||
&& (o.bg === undefined && o.w === undefined && o.h === undefined);
|
|
||||||
const usePixelPos = (typeof o.x === 'number' && !hasPercentXY)
|
|
||||||
|| typeof o.x === 'string';
|
|
||||||
const style = {
|
const style = {
|
||||||
fontFamily: '"Roboto Condensed", system-ui, sans-serif',
|
...DEFAULT_LABEL_STYLE,
|
||||||
fontWeight: o.bold ? 800 : DEFAULT_LABEL_STYLE.fontWeight,
|
fontSize: o.size || DEFAULT_LABEL_STYLE.fontSize,
|
||||||
fontSize: o.textSize || o.size || DEFAULT_LABEL_STYLE.fontSize,
|
|
||||||
color: o.color || DEFAULT_LABEL_STYLE.color,
|
color: o.color || DEFAULT_LABEL_STYLE.color,
|
||||||
background: o.bg || 'rgba(15,12,8,0.55)',
|
background: 'rgba(15,12,8,0.55)',
|
||||||
padding: o.padding != null ? o.padding : '4px 10px',
|
padding: '4px 10px',
|
||||||
borderRadius: o.borderRadius != null ? o.borderRadius : 5,
|
borderRadius: 5,
|
||||||
border: o.border || undefined,
|
// длинные подписи переносятся и остаются по центру,
|
||||||
textAlign: o.textAlign || 'center',
|
// не вылезая за края экрана
|
||||||
|
textAlign: 'center',
|
||||||
maxWidth: '70vw',
|
maxWidth: '70vw',
|
||||||
whiteSpace: 'pre-line',
|
whiteSpace: 'normal',
|
||||||
wordBreak: 'break-word',
|
wordBreak: 'break-word',
|
||||||
width: o.w != null ? o.w : undefined,
|
|
||||||
height: o.h != null ? o.h : undefined,
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: o.textAlign === 'left' ? 'flex-start' : 'center',
|
|
||||||
boxSizing: 'border-box',
|
|
||||||
};
|
};
|
||||||
if (hasPercentXY) {
|
if (hasPos) {
|
||||||
return (
|
return (
|
||||||
<div key={id} style={{
|
<div key={id} style={{
|
||||||
...style,
|
...style,
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
left: `${o.x}%`,
|
left: typeof o.x === 'number' ? `${o.x}%` : undefined,
|
||||||
top: `${o.y}%`,
|
top: typeof o.y === 'number' ? `${o.y}%` : undefined,
|
||||||
transform: 'translate(-50%, -50%)',
|
transform: 'translate(-50%, -50%)',
|
||||||
}}>{lbl.text}</div>
|
}}>{lbl.text}</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (usePixelPos) {
|
|
||||||
// Якорь: 'center' — translate(-50%,-50%); по умолчанию top-left
|
|
||||||
const isCenter = o.anchor === 'center';
|
|
||||||
const leftVal = typeof o.x === 'string' ? o.x : `${o.x}px`;
|
|
||||||
const topVal = typeof o.y === 'string' ? o.y : `${o.y}px`;
|
|
||||||
return (
|
|
||||||
<div key={id} style={{
|
|
||||||
...style,
|
|
||||||
position: 'absolute',
|
|
||||||
left: leftVal,
|
|
||||||
top: topVal,
|
|
||||||
transform: isCenter ? 'translate(-50%, -50%)' : undefined,
|
|
||||||
}}>{lbl.text}</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// Без позиции — стек в левом верхнем углу
|
// Без позиции — стек в левом верхнем углу
|
||||||
return (
|
return (
|
||||||
<div key={id} style={{
|
<div key={id} style={{
|
||||||
|
|||||||
@ -264,25 +264,10 @@ function GuiElement({ el, allElements, childrenMap, isPlaying, selectedId, onSel
|
|||||||
// textbox в Play кликабелен (для фокуса и ввода), как и кнопка
|
// textbox в Play кликабелен (для фокуса и ввода), как и кнопка
|
||||||
const pointerEvents = isPlaying ? ((isButton || isTextbox) ? 'auto' : 'none') : 'auto';
|
const pointerEvents = isPlaying ? ((isButton || isTextbox) ? 'auto' : 'none') : 'auto';
|
||||||
|
|
||||||
// В Play на кнопке — hover/pressed эффект (Задача 03).
|
// В Play на кнопке — лёгкий hover/pressed эффект
|
||||||
// Если у элемента задан el.hover/el.active — используем их параметры,
|
|
||||||
// иначе дефолтные значения.
|
|
||||||
const playInteractive = isPlaying && isButton;
|
const playInteractive = isPlaying && isButton;
|
||||||
const hoverCfg = el.hover || { scale: 1.08, brightness: 1.15, rotation: 0 };
|
const playFilter = pressed ? 'brightness(0.85)' : (hover ? 'brightness(1.15)' : 'none');
|
||||||
const activeCfg = el.active || { scale: 0.94 };
|
const playTransform = pressed ? `${style.transform || ''} scale(0.97)` : style.transform;
|
||||||
const hoverBrightness = (typeof hoverCfg.brightness === 'number') ? hoverCfg.brightness : 1.15;
|
|
||||||
const hoverScale = (typeof hoverCfg.scale === 'number') ? hoverCfg.scale : 1.08;
|
|
||||||
const hoverRotation = (typeof hoverCfg.rotation === 'number') ? hoverCfg.rotation : 0;
|
|
||||||
const activeScale = (typeof activeCfg.scale === 'number') ? activeCfg.scale : 0.94;
|
|
||||||
const playFilter = pressed
|
|
||||||
? 'brightness(0.85)'
|
|
||||||
: (hover ? `brightness(${hoverBrightness})` : 'none');
|
|
||||||
const dynScale = pressed ? activeScale : (hover ? hoverScale : 1);
|
|
||||||
const dynRot = hover ? hoverRotation : 0;
|
|
||||||
let extraTr = '';
|
|
||||||
if (dynScale !== 1) extraTr += ` scale(${dynScale})`;
|
|
||||||
if (dynRot) extraTr += ` rotate(${dynRot}deg)`;
|
|
||||||
const playTransform = (style.transform || '') + extraTr;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@ -355,18 +340,7 @@ function GuiElement({ el, allElements, childrenMap, isPlaying, selectedId, onSel
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
{isText && (el.text != null) && (() => {
|
{isText && (el.text != null) && (
|
||||||
// Задача 03: text-stroke (обводка текста). Через -webkit-text-stroke
|
|
||||||
// (хорошая поддержка, чётко на крупном шрифте) + paint-order
|
|
||||||
// (stroke под fill чтобы текст не «сжимался»).
|
|
||||||
const ts = el.textStroke;
|
|
||||||
const strokeStyle = (ts && ts.color && Number.isFinite(ts.width))
|
|
||||||
? {
|
|
||||||
WebkitTextStroke: `${ts.width}px ${ts.color}`,
|
|
||||||
paintOrder: 'stroke fill',
|
|
||||||
}
|
|
||||||
: null;
|
|
||||||
return (
|
|
||||||
<div style={{
|
<div style={{
|
||||||
width: '100%', height: '100%',
|
width: '100%', height: '100%',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@ -384,53 +358,10 @@ function GuiElement({ el, allElements, childrenMap, isPlaying, selectedId, onSel
|
|||||||
whiteSpace: 'pre-wrap',
|
whiteSpace: 'pre-wrap',
|
||||||
wordBreak: 'break-word',
|
wordBreak: 'break-word',
|
||||||
pointerEvents: 'none',
|
pointerEvents: 'none',
|
||||||
...(strokeStyle || {}),
|
|
||||||
}}>
|
}}>
|
||||||
{el.text}
|
{el.text}
|
||||||
</div>
|
</div>
|
||||||
);
|
)}
|
||||||
})()}
|
|
||||||
{/* Задача 03: Бейдж в углу — отдельный absolute-элемент. */}
|
|
||||||
{el.badge && (() => {
|
|
||||||
const b = el.badge;
|
|
||||||
const corner = b.corner || 'top-right';
|
|
||||||
const cornerStyle = {
|
|
||||||
'top-right': { top: -6, right: -6 },
|
|
||||||
'top-left': { top: -6, left: -6 },
|
|
||||||
'bottom-right': { bottom: -6, right: -6 },
|
|
||||||
'bottom-left': { bottom: -6, left: -6 },
|
|
||||||
}[corner] || { top: -6, right: -6 };
|
|
||||||
const icons = {
|
|
||||||
exclamation: '!',
|
|
||||||
star: '★',
|
|
||||||
plus: '+',
|
|
||||||
new: 'NEW',
|
|
||||||
sale: '%',
|
|
||||||
};
|
|
||||||
const text = b.text != null ? b.text : (icons[b.icon] || '!');
|
|
||||||
const big = b.icon === 'new';
|
|
||||||
return (
|
|
||||||
<div style={{
|
|
||||||
position: 'absolute',
|
|
||||||
...cornerStyle,
|
|
||||||
minWidth: big ? 32 : 22,
|
|
||||||
height: 22,
|
|
||||||
padding: '0 6px',
|
|
||||||
background: b.color || '#fbbf24',
|
|
||||||
color: '#3a1a00',
|
|
||||||
borderRadius: big ? 6 : 11,
|
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
||||||
fontSize: big ? 11 : 14,
|
|
||||||
fontWeight: 800,
|
|
||||||
fontFamily: '"Roboto Condensed", system-ui, sans-serif',
|
|
||||||
boxShadow: '0 2px 4px rgba(0,0,0,0.4)',
|
|
||||||
border: '2px solid #fff',
|
|
||||||
pointerEvents: 'none',
|
|
||||||
zIndex: 5,
|
|
||||||
transform: 'rotate(8deg)',
|
|
||||||
}}>{text}</div>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
|
|
||||||
{/* TextBox — настоящий <input> в Play (принимает ввод),
|
{/* TextBox — настоящий <input> в Play (принимает ввод),
|
||||||
в редакторе — статичный вид с placeholder. */}
|
в редакторе — статичный вид с placeholder. */}
|
||||||
@ -552,30 +483,11 @@ function GuiElement({ el, allElements, childrenMap, isPlaying, selectedId, onSel
|
|||||||
*/
|
*/
|
||||||
function layoutChildren(container, children) {
|
function layoutChildren(container, children) {
|
||||||
const layout = container && container.layout;
|
const layout = container && container.layout;
|
||||||
if (layout !== 'vertical' && layout !== 'horizontal' && layout !== 'grid') return children;
|
if (layout !== 'vertical' && layout !== 'horizontal') return children;
|
||||||
const gap = Number.isFinite(container.layoutGap) ? container.layoutGap : 2;
|
const gap = Number.isFinite(container.layoutGap) ? container.layoutGap : 2;
|
||||||
const pad = Number.isFinite(container.layoutPad) ? container.layoutPad : 3;
|
const pad = Number.isFinite(container.layoutPad) ? container.layoutPad : 3;
|
||||||
// scrollY -- сдвиг прокрутки (для type='scroll').
|
// scrollY — сдвиг прокрутки (для type='scroll').
|
||||||
const scrollY = Number.isFinite(container.scrollY) ? container.scrollY : 0;
|
const scrollY = Number.isFinite(container.scrollY) ? container.scrollY : 0;
|
||||||
|
|
||||||
// Phase 6.3.2: Grid layout -- авто-сетка с заданной шириной ячейки.
|
|
||||||
// layoutCellW/H -- размер ячейки в %, layoutCols -- сколько колонок (если 0 -- авто).
|
|
||||||
if (layout === 'grid') {
|
|
||||||
const cellW = Number.isFinite(container.layoutCellW) ? container.layoutCellW : 18;
|
|
||||||
const cellH = Number.isFinite(container.layoutCellH) ? container.layoutCellH : 18;
|
|
||||||
const availW = 100 - pad * 2;
|
|
||||||
// Авто-вычисление кол-ва колонок если не задано.
|
|
||||||
let cols = Number(container.layoutCols) || 0;
|
|
||||||
if (cols < 1) cols = Math.max(1, Math.floor((availW + gap) / (cellW + gap)));
|
|
||||||
return children.map((ch, i) => {
|
|
||||||
const row = Math.floor(i / cols);
|
|
||||||
const col = i % cols;
|
|
||||||
const nx = pad + col * (cellW + gap);
|
|
||||||
const ny = pad + row * (cellH + gap) - scrollY;
|
|
||||||
return { ...ch, x: nx, y: ny, w: cellW, h: cellH, anchor: 'top-left' };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let cursor = pad;
|
let cursor = pad;
|
||||||
return children.map((ch) => {
|
return children.map((ch) => {
|
||||||
const w = ch.w ?? 20, h = ch.h ?? 10;
|
const w = ch.w ?? 20, h = ch.h ?? 10;
|
||||||
@ -589,7 +501,7 @@ function layoutChildren(container, children) {
|
|||||||
ny = pad - scrollY;
|
ny = pad - scrollY;
|
||||||
cursor += w + gap;
|
cursor += w + gap;
|
||||||
}
|
}
|
||||||
// Якорь top-left -- координаты считаются от левого-верхнего угла.
|
// Якорь top-left — координаты считаются от левого-верхнего угла.
|
||||||
return { ...ch, x: nx, y: ny, anchor: 'top-left' };
|
return { ...ch, x: nx, y: ny, anchor: 'top-left' };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -697,77 +609,22 @@ function elementToStyle(el) {
|
|||||||
const w = `${el.w ?? 20}%`;
|
const w = `${el.w ?? 20}%`;
|
||||||
const h = `${el.h ?? 10}%`;
|
const h = `${el.h ?? 10}%`;
|
||||||
const anchor = el.anchor || 'center';
|
const anchor = el.anchor || 'center';
|
||||||
// Phase 6.3.1: AnchorPoint -- точка ВНУТРИ элемента (0..1 по обеим осям),
|
|
||||||
// относительно которой считается позиция. Если el.anchorPoint не задан,
|
|
||||||
// вычисляем по anchor: center → {0.5, 0.5}, top-left → {0, 0}, и т.д.
|
|
||||||
// (это сохраняет старое поведение). Юзер может override через anchorPoint.
|
|
||||||
const apDefault = {
|
|
||||||
x: anchor === 'right' || anchor.endsWith('-right') ? 1
|
|
||||||
: (anchor === 'left' || anchor.endsWith('-left') ? 0 : 0.5),
|
|
||||||
y: anchor === 'bottom' || anchor.startsWith('bottom-') ? 1
|
|
||||||
: (anchor === 'top' || anchor.startsWith('top-') ? 0 : 0.5),
|
|
||||||
};
|
|
||||||
const ap = el.anchorPoint && typeof el.anchorPoint === 'object'
|
|
||||||
? {
|
|
||||||
x: typeof el.anchorPoint.x === 'number' ? el.anchorPoint.x : apDefault.x,
|
|
||||||
y: typeof el.anchorPoint.y === 'number' ? el.anchorPoint.y : apDefault.y,
|
|
||||||
}
|
|
||||||
: apDefault;
|
|
||||||
let left, top, transform;
|
let left, top, transform;
|
||||||
// Левый/верх вычисляется по anchor (ссылочная точка на экране).
|
|
||||||
// translate(-anchorPoint*100% по каждой оси) -- сдвиг сам элемент так,
|
|
||||||
// чтобы anchorPoint оказался в (left, top).
|
|
||||||
const tx = -ap.x * 100;
|
|
||||||
const ty = -ap.y * 100;
|
|
||||||
switch (anchor) {
|
switch (anchor) {
|
||||||
case 'top-left': left = `${el.x ?? 0}%`; top = `${el.y ?? 0}%`; break;
|
case 'top-left': left = `${el.x ?? 0}%`; top = `${el.y ?? 0}%`; transform = 'translate(0, 0)'; break;
|
||||||
case 'top-right': left = `${100 - (el.x ?? 0)}%`; top = `${el.y ?? 0}%`; break;
|
case 'top-right': left = `${100 - (el.x ?? 0)}%`; top = `${el.y ?? 0}%`; transform = 'translate(-100%, 0)'; break;
|
||||||
case 'bottom-left': left = `${el.x ?? 0}%`; top = `${100 - (el.y ?? 0)}%`; break;
|
case 'bottom-left': left = `${el.x ?? 0}%`; top = `${100 - (el.y ?? 0)}%`; transform = 'translate(0, -100%)'; break;
|
||||||
case 'bottom-right': left = `${100 - (el.x ?? 0)}%`; top = `${100 - (el.y ?? 0)}%`; break;
|
case 'bottom-right': left = `${100 - (el.x ?? 0)}%`; top = `${100 - (el.y ?? 0)}%`; transform = 'translate(-100%, -100%)'; break;
|
||||||
// Phase 6.3.1: одиночные стороны -- ссылочная точка на середине стороны.
|
|
||||||
case 'top': left = `${el.x ?? 50}%`; top = `${el.y ?? 0}%`; break;
|
|
||||||
case 'bottom': left = `${el.x ?? 50}%`; top = `${100 - (el.y ?? 0)}%`; break;
|
|
||||||
case 'left': left = `${el.x ?? 0}%`; top = `${el.y ?? 50}%`; break;
|
|
||||||
case 'right': left = `${100 - (el.x ?? 0)}%`; top = `${el.y ?? 50}%`; break;
|
|
||||||
case 'center':
|
case 'center':
|
||||||
default: left = `${el.x ?? 50}%`; top = `${el.y ?? 50}%`; break;
|
default: left = `${el.x ?? 50}%`; top = `${el.y ?? 50}%`; transform = 'translate(-50%, -50%)'; break;
|
||||||
}
|
}
|
||||||
// Задача 03: rotation + scale через transform. Добавляются ПОСЛЕ translate.
|
|
||||||
// hoverScale/activeScale хранятся в el._dynScale (выставляется hover-handler'ом
|
|
||||||
// в GuiElement через mutate-ref). При штатном рендере читаем el.scaleX/scaleY.
|
|
||||||
const sx = (typeof el._dynScaleX === 'number' ? el._dynScaleX : 1)
|
|
||||||
* (typeof el.scaleX === 'number' ? el.scaleX : 1);
|
|
||||||
const sy = (typeof el._dynScaleY === 'number' ? el._dynScaleY : 1)
|
|
||||||
* (typeof el.scaleY === 'number' ? el.scaleY : 1);
|
|
||||||
const rot = (typeof el._dynRotation === 'number' ? el._dynRotation : 0)
|
|
||||||
+ (typeof el.rotation === 'number' ? el.rotation : 0);
|
|
||||||
const brightness = (typeof el._dynBrightness === 'number' ? el._dynBrightness : 1);
|
|
||||||
transform = `translate(${tx}%, ${ty}%)`;
|
|
||||||
if (sx !== 1 || sy !== 1) transform += ` scale(${sx}, ${sy})`;
|
|
||||||
if (rot) transform += ` rotate(${rot}deg)`;
|
|
||||||
let bg = el.bgColor || '#1f1810';
|
let bg = el.bgColor || '#1f1810';
|
||||||
const opacity = el.bgOpacity == null ? 1 : Math.max(0, Math.min(1, el.bgOpacity));
|
const opacity = el.bgOpacity == null ? 1 : Math.max(0, Math.min(1, el.bgOpacity));
|
||||||
if (bg === 'transparent' || opacity === 0) bg = 'transparent';
|
if (bg === 'transparent' || opacity === 0) bg = 'transparent';
|
||||||
else bg = hexToRgba(bg, opacity);
|
else bg = hexToRgba(bg, opacity);
|
||||||
// Задача 03: bgGradient — { stops: ['#a','#b'] | [{c,p}], angle: 0..360 }.
|
|
||||||
// Если задан — перебиваем background.
|
|
||||||
if (el.bgGradient && Array.isArray(el.bgGradient.stops) && el.bgGradient.stops.length >= 2) {
|
|
||||||
const angle = Number.isFinite(el.bgGradient.angle) ? el.bgGradient.angle : 90;
|
|
||||||
const parts = el.bgGradient.stops.map((s, i, arr) => {
|
|
||||||
if (typeof s === 'string') {
|
|
||||||
const p = (i / (arr.length - 1)) * 100;
|
|
||||||
return `${s} ${p.toFixed(1)}%`;
|
|
||||||
}
|
|
||||||
const c = s.c || '#000';
|
|
||||||
const p = typeof s.p === 'number' ? s.p * 100 : (i / (arr.length - 1)) * 100;
|
|
||||||
return `${c} ${p.toFixed(1)}%`;
|
|
||||||
});
|
|
||||||
bg = `linear-gradient(${angle}deg, ${parts.join(', ')})`;
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
left, top, transform,
|
left, top, transform,
|
||||||
transformOrigin: 'center center',
|
|
||||||
width: w, height: h,
|
width: w, height: h,
|
||||||
background: bg,
|
background: bg,
|
||||||
border: el.borderWidth > 0
|
border: el.borderWidth > 0
|
||||||
@ -775,11 +632,14 @@ function elementToStyle(el) {
|
|||||||
: 'none',
|
: 'none',
|
||||||
borderRadius: (el.borderRadius || 0) + 'px',
|
borderRadius: (el.borderRadius || 0) + 'px',
|
||||||
boxSizing: 'border-box',
|
boxSizing: 'border-box',
|
||||||
|
// Тень: явный флаг shadow → мягкая drop-shadow; у кнопок —
|
||||||
|
// лёгкая тень по умолчанию (как было). shadow=true усиливает.
|
||||||
boxShadow: el.shadow
|
boxShadow: el.shadow
|
||||||
? '0 6px 16px rgba(0,0,0,0.45)'
|
? '0 6px 16px rgba(0,0,0,0.45)'
|
||||||
: (el.type === 'button' ? '0 2px 6px rgba(0,0,0,0.3)' : 'none'),
|
: (el.type === 'button' ? '0 2px 6px rgba(0,0,0,0.3)' : 'none'),
|
||||||
|
// Frame обрезает детей по своей границе (как ScreenGui в Roblox).
|
||||||
|
// Для не-frame оставляем visible чтобы текст не клипался.
|
||||||
overflow: el.type === 'frame' ? 'hidden' : 'visible',
|
overflow: el.type === 'frame' ? 'hidden' : 'visible',
|
||||||
filter: brightness !== 1 ? `brightness(${brightness})` : undefined,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -16,13 +16,6 @@ import Icon from './Icon';
|
|||||||
function Hotbar({ visible, slots = [], activeIndex = 0, onSelect, mobileMode = false }) {
|
function Hotbar({ visible, slots = [], activeIndex = 0, onSelect, mobileMode = false }) {
|
||||||
if (!visible) return null;
|
if (!visible) return null;
|
||||||
|
|
||||||
// ГЛОБАЛЬНОЕ ПРАВИЛО (для всех игр тулбокса): если в инвентаре нет ни
|
|
||||||
// одного предмета — панель инвентаря НЕ показываем вовсе. Пустой hotbar
|
|
||||||
// из 5 серых ячеек загромождает экран в играх, где инвентарь не нужен.
|
|
||||||
// Панель появится автоматически, как только в слот попадёт предмет.
|
|
||||||
const hasAnyItem = Array.isArray(slots) && slots.some((s) => s != null);
|
|
||||||
if (!hasAnyItem) return null;
|
|
||||||
|
|
||||||
const SLOT_COUNT = 5;
|
const SLOT_COUNT = 5;
|
||||||
const cells = [];
|
const cells = [];
|
||||||
for (let i = 0; i < SLOT_COUNT; i++) {
|
for (let i = 0; i < SLOT_COUNT; i++) {
|
||||||
|
|||||||
@ -313,7 +313,7 @@ const EMOJI_TO_NAME = {
|
|||||||
'◣': 'prim-wedge', '◢': 'prim-cornerwedge', '〰': 'waves',
|
'◣': 'prim-wedge', '◢': 'prim-cornerwedge', '〰': 'waves',
|
||||||
// UI / художественные
|
// UI / художественные
|
||||||
'🎨': 'palette', '📺': 'monitor', '🖼': 'image', '🖼️': 'image',
|
'🎨': 'palette', '📺': 'monitor', '🖼': 'image', '🖼️': 'image',
|
||||||
'🔤': 'type',
|
'🔤': 'type', '🟧': 'square',
|
||||||
// звук
|
// звук
|
||||||
'🎵': 'music', '🎼': 'music2', '🔊': 'sound',
|
'🎵': 'music', '🎼': 'music2', '🔊': 'sound',
|
||||||
// навигация
|
// навигация
|
||||||
|
|||||||
@ -1,101 +0,0 @@
|
|||||||
/**
|
|
||||||
* ModalOverlay — рендерит затемнение модальной сцены.
|
|
||||||
* Задача 04. Подписан на ModalManager.setOnChange — получает state.
|
|
||||||
*
|
|
||||||
* Архитектура:
|
|
||||||
* - Слой ПОД GUI-overlay (z-index ниже GuiOverlay) но НАД Babylon-канвасом.
|
|
||||||
* - Если target='screen' — слой поверх ВСЕГО (включая GUI). z-index выше.
|
|
||||||
* - Spotlights через CSS mask-image: radial-gradient(...) — вырезает «дырки».
|
|
||||||
* - pointer-events: auto когда модал открыт (перехватывает клики кроме GUI).
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { useEffect, useState } from 'react';
|
|
||||||
|
|
||||||
export default function ModalOverlay({ scene }) {
|
|
||||||
const [state, setState] = useState(null);
|
|
||||||
|
|
||||||
// Поллинг — надёжнее чем setOnChange callback, который может перетереться
|
|
||||||
// или не вызваться если scene изменился на следующем кадре.
|
|
||||||
useEffect(() => {
|
|
||||||
if (!scene?.modalManager) return;
|
|
||||||
let cancelled = false;
|
|
||||||
const tick = () => {
|
|
||||||
if (cancelled) return;
|
|
||||||
const s = scene.modalManager.getState?.();
|
|
||||||
// Снимок shallow-clone — иначе React не увидит изменение
|
|
||||||
setState(s ? {
|
|
||||||
id: s.id,
|
|
||||||
fadePhase: s.fadePhase,
|
|
||||||
currentAlpha: s.currentAlpha,
|
|
||||||
opts: s.opts,
|
|
||||||
spotlightScreens: s.spotlightScreens,
|
|
||||||
} : null);
|
|
||||||
requestAnimationFrame(tick);
|
|
||||||
};
|
|
||||||
tick();
|
|
||||||
return () => { cancelled = true; };
|
|
||||||
}, [scene]);
|
|
||||||
|
|
||||||
if (!state || state.fadePhase === 'closed') return null;
|
|
||||||
if (state.currentAlpha <= 0.001) return null;
|
|
||||||
console.log('[ModalOverlay] RENDERING alpha=', state.currentAlpha.toFixed(2), 'phase=', state.fadePhase, 'target=', state.opts?.target);
|
|
||||||
|
|
||||||
const opts = state.opts;
|
|
||||||
const isScreen = opts.target === 'screen';
|
|
||||||
const color = opts.darkenColor || '#000000';
|
|
||||||
const alpha = Math.max(0, Math.min(1, state.currentAlpha));
|
|
||||||
// RGBA bg
|
|
||||||
const bg = _hexToRgba(color, alpha);
|
|
||||||
|
|
||||||
// mask-image для spotlights (только для target='scene' — на 'screen' нет смысла)
|
|
||||||
let maskStyle = {};
|
|
||||||
if (!isScreen && Array.isArray(state.spotlightScreens) && state.spotlightScreens.length) {
|
|
||||||
const softEdge = opts.spotlightSoftEdge ?? 40;
|
|
||||||
const gradients = state.spotlightScreens.map(s => {
|
|
||||||
const inner = Math.max(0, s.r - softEdge);
|
|
||||||
const outer = s.r;
|
|
||||||
// mask-image: внутри круга — transparent (вырезаем), снаружи — black (показываем затемнение)
|
|
||||||
return `radial-gradient(circle at ${s.x.toFixed(0)}px ${s.y.toFixed(0)}px, transparent ${inner}px, black ${outer}px)`;
|
|
||||||
});
|
|
||||||
maskStyle = {
|
|
||||||
WebkitMaskImage: gradients.join(', '),
|
|
||||||
maskImage: gradients.join(', '),
|
|
||||||
WebkitMaskComposite: 'source-in',
|
|
||||||
maskComposite: 'intersect',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ВАЖНО pointer-events: none — иначе overlay перехватывает клики и кнопки модала не работают.
|
|
||||||
// Затемнение — это просто визуальный фильтр, blockInput реализован в PlayerController.
|
|
||||||
// zIndex:
|
|
||||||
// target='scene' → 24 (под GuiOverlay zIndex=25 чтобы GUI был ВИДЕН поверх затемнения)
|
|
||||||
// target='screen' → 60 (поверх GUI — закрывает ВСЁ)
|
|
||||||
// Для 'screen' GUI модала всё равно поверх (GuiOverlay zIndex=25, наш ScreenOverlay 60,
|
|
||||||
// GUI элементы модала рендерятся в GuiOverlay — поэтому надо ставить их в отдельный
|
|
||||||
// слой ВЫШЕ overlay). Простой фикс: для screen ставим overlay на 24 тоже.
|
|
||||||
const zIdx = 24;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: 'absolute', inset: 0,
|
|
||||||
background: bg,
|
|
||||||
zIndex: zIdx,
|
|
||||||
pointerEvents: 'none', // НЕ перехватываем клики — иначе кнопки не работают
|
|
||||||
transition: 'background-color 0.05s linear',
|
|
||||||
...maskStyle,
|
|
||||||
}}
|
|
||||||
data-modal-overlay={state.id}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function _hexToRgba(hex, a) {
|
|
||||||
if (typeof hex !== 'string' || !hex.startsWith('#')) return `rgba(0,0,0,${a})`;
|
|
||||||
let h = hex.slice(1);
|
|
||||||
if (h.length === 3) h = h.split('').map(c => c + c).join('');
|
|
||||||
if (h.length !== 6) return `rgba(0,0,0,${a})`;
|
|
||||||
const r = parseInt(h.slice(0, 2), 16);
|
|
||||||
const g = parseInt(h.slice(2, 4), 16);
|
|
||||||
const b = parseInt(h.slice(4, 6), 16);
|
|
||||||
return `rgba(${r},${g},${b},${a})`;
|
|
||||||
}
|
|
||||||
@ -1,294 +0,0 @@
|
|||||||
/**
|
|
||||||
* SkinShopOverlay — встроенный магазин скинов игрока (задача 07).
|
|
||||||
*
|
|
||||||
* Готовый GUI-кит: полноэкранная витрина карточек скинов. Открывается
|
|
||||||
* клавишей B в Play или через game.player.openSkinShop(). Логика покупки
|
|
||||||
* (списание локальных рубликов проекта, unlock, setSkin) живёт в GameRuntime;
|
|
||||||
* этот компонент только рендерит состояние и шлёт намерение «купить/надеть».
|
|
||||||
*
|
|
||||||
* Подписка на состояние — rAF-поллинг scene.getSkinShopState() (как ModalOverlay):
|
|
||||||
* { open, rev, data: { all:[{slug,name,kind,category,price}], unlocked:[slug],
|
|
||||||
* current, coins, shopVisible } }
|
|
||||||
*
|
|
||||||
* Превью скина — цветная плашка по категории + крупная самописная SVG-иконка
|
|
||||||
* (правило проекта: без эмодзи в UI). Категории: human/animal/food/vehicle/robot.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { useEffect, useState, useMemo } from 'react';
|
|
||||||
|
|
||||||
// Палитра градиентов по категории — чтобы витрина была живой и читаемой.
|
|
||||||
const CAT_THEME = {
|
|
||||||
human: { from: '#3b6cff', to: '#1e2da5', label: 'Люди' },
|
|
||||||
animal: { from: '#46b34a', to: '#1f6b2a', label: 'Животные' },
|
|
||||||
food: { from: '#ff8a3d', to: '#c2410c', label: 'Еда' },
|
|
||||||
vehicle: { from: '#9b59ff', to: '#5b21b6', label: 'Транспорт' },
|
|
||||||
robot: { from: '#22c6d6', to: '#0e7490', label: 'Роботы' },
|
|
||||||
custom: { from: '#fbbf24', to: '#b45309', label: 'Свои' },
|
|
||||||
};
|
|
||||||
const CAT_ORDER = ['all', 'human', 'animal', 'food', 'vehicle', 'robot', 'custom'];
|
|
||||||
|
|
||||||
// Самописные SVG-иконки категорий (viewBox 24×24, обводка currentColor).
|
|
||||||
function CatGlyph({ cat, size = 46 }) {
|
|
||||||
const st = { fill: 'none', stroke: 'currentColor', strokeWidth: 1.6, strokeLinecap: 'round', strokeLinejoin: 'round' };
|
|
||||||
let body;
|
|
||||||
switch (cat) {
|
|
||||||
case 'human':
|
|
||||||
body = (<><circle cx="12" cy="7" r="3.2" {...st} /><path d="M5 21c0-4 3.2-7 7-7s7 3 7 7" {...st} /></>);
|
|
||||||
break;
|
|
||||||
case 'animal': // мордочка зверя с ушами
|
|
||||||
body = (<><path d="M5 6l2.5 3M19 6l-2.5 3" {...st} /><circle cx="12" cy="13" r="7" {...st} /><circle cx="9.5" cy="12" r="0.9" fill="currentColor" stroke="none" /><circle cx="14.5" cy="12" r="0.9" fill="currentColor" stroke="none" /><path d="M10.5 16c1 0.8 2 0.8 3 0" {...st} /></>);
|
|
||||||
break;
|
|
||||||
case 'food': // пончик
|
|
||||||
body = (<><circle cx="12" cy="12" r="8" {...st} /><circle cx="12" cy="12" r="2.6" {...st} /><path d="M7 8.5l0.5 1M16.5 9l-0.7 0.9M9 16l0.6-1M15.5 15.5l-0.7-0.9" {...st} /></>);
|
|
||||||
break;
|
|
||||||
case 'vehicle': // машинка
|
|
||||||
body = (<><path d="M3 14l1.5-4.5A2 2 0 0 1 6.4 8h11.2a2 2 0 0 1 1.9 1.5L21 14v3h-2" {...st} /><path d="M3 14v3h2" {...st} /><circle cx="7.5" cy="17" r="1.8" {...st} /><circle cx="16.5" cy="17" r="1.8" {...st} /></>);
|
|
||||||
break;
|
|
||||||
case 'robot': // голова робота
|
|
||||||
body = (<><rect x="6" y="8" width="12" height="10" rx="2" {...st} /><path d="M12 8V5M9 5h6" {...st} /><circle cx="9.5" cy="13" r="1" fill="currentColor" stroke="none" /><circle cx="14.5" cy="13" r="1" fill="currentColor" stroke="none" /></>);
|
|
||||||
break;
|
|
||||||
default: // custom — звезда
|
|
||||||
body = (<path d="M12 4l2.2 4.8L19 9.4l-3.6 3.3 1 5-4.4-2.5L7.6 17.7l1-5L5 9.4l4.8-0.6z" {...st} />);
|
|
||||||
}
|
|
||||||
return (<svg width={size} height={size} viewBox="0 0 24 24" style={{ display: 'block' }}>{body}</svg>);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Монета-рублик (для баланса/цены).
|
|
||||||
function CoinIcon({ size = 16 }) {
|
|
||||||
return (
|
|
||||||
<svg width={size} height={size} viewBox="0 0 24 24" style={{ display: 'inline-block', verticalAlign: 'middle' }}>
|
|
||||||
<circle cx="12" cy="12" r="9" fill="#ffd24a" stroke="#a86b00" strokeWidth="1.6" />
|
|
||||||
<text x="12" y="16" textAnchor="middle" fontSize="11" fontWeight="900" fill="#7a4d00">₽</text>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function SkinShopOverlay({ scene }) {
|
|
||||||
const [snap, setSnap] = useState(null);
|
|
||||||
const [cat, setCat] = useState('all');
|
|
||||||
|
|
||||||
// rAF-поллинг состояния магазина из сцены.
|
|
||||||
useEffect(() => {
|
|
||||||
if (!scene?.getSkinShopState) return;
|
|
||||||
let cancelled = false;
|
|
||||||
let lastRev = -1;
|
|
||||||
const tick = () => {
|
|
||||||
if (cancelled) return;
|
|
||||||
const s = scene.getSkinShopState?.();
|
|
||||||
if (s && s.rev !== lastRev) {
|
|
||||||
lastRev = s.rev;
|
|
||||||
setSnap({
|
|
||||||
open: s.open,
|
|
||||||
data: s.data,
|
|
||||||
buyResult: s.buyResult,
|
|
||||||
});
|
|
||||||
} else if (!s && lastRev !== -1) {
|
|
||||||
lastRev = -1;
|
|
||||||
setSnap(null);
|
|
||||||
}
|
|
||||||
requestAnimationFrame(tick);
|
|
||||||
};
|
|
||||||
tick();
|
|
||||||
return () => { cancelled = true; };
|
|
||||||
}, [scene]);
|
|
||||||
|
|
||||||
const data = snap?.data || null;
|
|
||||||
|
|
||||||
// Список скинов с категориями (фильтрованный).
|
|
||||||
const skins = useMemo(() => {
|
|
||||||
const all = (data?.all) || [];
|
|
||||||
if (cat === 'all') return all;
|
|
||||||
return all.filter(s => (s.category || 'human') === cat);
|
|
||||||
}, [data, cat]);
|
|
||||||
|
|
||||||
// Какие категории реально есть — для табов.
|
|
||||||
const cats = useMemo(() => {
|
|
||||||
const present = new Set((data?.all || []).map(s => s.category || 'human'));
|
|
||||||
return CAT_ORDER.filter(c => c === 'all' || present.has(c));
|
|
||||||
}, [data]);
|
|
||||||
|
|
||||||
if (!snap || !snap.open || !data) return null;
|
|
||||||
|
|
||||||
const unlocked = new Set(data.unlocked || []);
|
|
||||||
const current = data.current;
|
|
||||||
const coins = data.coins || 0;
|
|
||||||
|
|
||||||
const close = () => { try { scene._closeSkinShop?.(); } catch (e) {} };
|
|
||||||
const onCardClick = (s) => {
|
|
||||||
const owned = unlocked.has(s.slug);
|
|
||||||
const price = s.price || 0;
|
|
||||||
if (!owned && coins < price) return; // не хватает — карточка покажет это
|
|
||||||
try { scene.requestBuySkin?.(s.slug, price); } catch (e) {}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: 'absolute', inset: 0, zIndex: 55,
|
|
||||||
background: 'rgba(6, 9, 20, 0.72)',
|
|
||||||
backdropFilter: 'blur(4px)',
|
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
||||||
fontFamily: '"Roboto Condensed", system-ui, sans-serif',
|
|
||||||
}}
|
|
||||||
onClick={close}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
style={{
|
|
||||||
width: 'min(880px, 92vw)', maxHeight: '86vh',
|
|
||||||
background: 'linear-gradient(160deg, #161b2e 0%, #0c1020 100%)',
|
|
||||||
border: '2px solid #2b3a66', borderRadius: 20,
|
|
||||||
boxShadow: '0 24px 60px rgba(0,0,0,0.55)',
|
|
||||||
display: 'flex', flexDirection: 'column', overflow: 'hidden',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Шапка */}
|
|
||||||
<div style={{
|
|
||||||
display: 'flex', alignItems: 'center', gap: 12,
|
|
||||||
padding: '16px 20px',
|
|
||||||
borderBottom: '1px solid rgba(255,255,255,0.08)',
|
|
||||||
background: 'linear-gradient(90deg, rgba(59,108,255,0.18), transparent)',
|
|
||||||
}}>
|
|
||||||
<div style={{ fontSize: 22, fontWeight: 900, color: '#fff', letterSpacing: 0.3 }}>
|
|
||||||
Магазин скинов
|
|
||||||
</div>
|
|
||||||
<div style={{ flex: 1 }} />
|
|
||||||
{/* Баланс */}
|
|
||||||
<div style={{
|
|
||||||
display: 'flex', alignItems: 'center', gap: 6,
|
|
||||||
background: 'rgba(255, 210, 74, 0.14)',
|
|
||||||
border: '1px solid rgba(255, 210, 74, 0.4)',
|
|
||||||
borderRadius: 999, padding: '6px 14px',
|
|
||||||
color: '#ffd24a', fontWeight: 900, fontSize: 16,
|
|
||||||
}}>
|
|
||||||
<CoinIcon size={18} /> {coins}
|
|
||||||
</div>
|
|
||||||
{/* Закрыть */}
|
|
||||||
<button
|
|
||||||
onClick={close}
|
|
||||||
style={{
|
|
||||||
width: 34, height: 34, borderRadius: 10, cursor: 'pointer',
|
|
||||||
background: 'rgba(255,255,255,0.08)', border: '1px solid rgba(255,255,255,0.16)',
|
|
||||||
color: '#fff', fontSize: 18, fontWeight: 700, lineHeight: 1,
|
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
||||||
}}
|
|
||||||
title="Закрыть (B / Esc)"
|
|
||||||
>×</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Табы категорий */}
|
|
||||||
<div style={{ display: 'flex', gap: 8, padding: '12px 20px 4px', flexWrap: 'wrap' }}>
|
|
||||||
{cats.map(c => {
|
|
||||||
const active = c === cat;
|
|
||||||
const label = c === 'all' ? 'Все' : (CAT_THEME[c]?.label || c);
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={c}
|
|
||||||
onClick={() => setCat(c)}
|
|
||||||
style={{
|
|
||||||
padding: '6px 14px', borderRadius: 999, cursor: 'pointer',
|
|
||||||
fontSize: 13, fontWeight: 800,
|
|
||||||
background: active ? 'linear-gradient(135deg, #3b6cff, #1e2da5)' : 'rgba(255,255,255,0.06)',
|
|
||||||
border: active ? '1px solid #6b8cff' : '1px solid rgba(255,255,255,0.12)',
|
|
||||||
color: active ? '#fff' : '#aab4d4',
|
|
||||||
}}
|
|
||||||
>{label}</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Сетка карточек */}
|
|
||||||
<div style={{
|
|
||||||
display: 'grid',
|
|
||||||
gridTemplateColumns: 'repeat(auto-fill, minmax(150px, 1fr))',
|
|
||||||
gap: 14, padding: 20, overflowY: 'auto',
|
|
||||||
}}>
|
|
||||||
{skins.map(s => {
|
|
||||||
const theme = CAT_THEME[s.category] || CAT_THEME.human;
|
|
||||||
const owned = unlocked.has(s.slug);
|
|
||||||
const isActive = current === s.slug;
|
|
||||||
const price = s.price || 0;
|
|
||||||
const canAfford = owned || coins >= price;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={s.slug}
|
|
||||||
onClick={() => onCardClick(s)}
|
|
||||||
style={{
|
|
||||||
borderRadius: 16, overflow: 'hidden', cursor: canAfford ? 'pointer' : 'not-allowed',
|
|
||||||
border: isActive ? '3px solid #22ff88' : '2px solid rgba(255,255,255,0.10)',
|
|
||||||
background: 'rgba(255,255,255,0.04)',
|
|
||||||
opacity: canAfford ? 1 : 0.55,
|
|
||||||
transition: 'transform 0.1s, border-color 0.15s',
|
|
||||||
position: 'relative',
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => { if (canAfford) e.currentTarget.style.transform = 'translateY(-3px)'; }}
|
|
||||||
onMouseLeave={(e) => { e.currentTarget.style.transform = 'none'; }}
|
|
||||||
>
|
|
||||||
{/* Превью-плашка с иконкой категории */}
|
|
||||||
<div style={{
|
|
||||||
height: 96, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
||||||
background: `linear-gradient(150deg, ${theme.from}, ${theme.to})`,
|
|
||||||
color: 'rgba(255,255,255,0.92)',
|
|
||||||
}}>
|
|
||||||
<CatGlyph cat={s.category || 'human'} size={50} />
|
|
||||||
</div>
|
|
||||||
{/* Бейдж активного/купленного */}
|
|
||||||
{isActive && (
|
|
||||||
<div style={badgeStyle('#22ff88', '#04361b')}>Надет</div>
|
|
||||||
)}
|
|
||||||
{!isActive && owned && (
|
|
||||||
<div style={badgeStyle('#ffd24a', '#5a3a00')}>Куплено</div>
|
|
||||||
)}
|
|
||||||
{/* Низ карточки: имя + цена/статус */}
|
|
||||||
<div style={{ padding: '10px 12px' }}>
|
|
||||||
<div style={{
|
|
||||||
color: '#fff', fontWeight: 800, fontSize: 14,
|
|
||||||
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
|
|
||||||
}}>{s.name || s.slug}</div>
|
|
||||||
<div style={{ marginTop: 6, minHeight: 22 }}>
|
|
||||||
{isActive ? (
|
|
||||||
<span style={{ color: '#22ff88', fontWeight: 800, fontSize: 13 }}>Активен</span>
|
|
||||||
) : owned ? (
|
|
||||||
<span style={{ color: '#9fb0d8', fontWeight: 700, fontSize: 13 }}>Нажми, чтобы надеть</span>
|
|
||||||
) : price === 0 ? (
|
|
||||||
<span style={{ color: '#7fe0a0', fontWeight: 800, fontSize: 13 }}>Бесплатно</span>
|
|
||||||
) : (
|
|
||||||
<span style={{
|
|
||||||
display: 'inline-flex', alignItems: 'center', gap: 4,
|
|
||||||
color: canAfford ? '#ffd24a' : '#ff7a7a', fontWeight: 900, fontSize: 14,
|
|
||||||
}}>
|
|
||||||
<CoinIcon size={15} /> {price}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{skins.length === 0 && (
|
|
||||||
<div style={{ color: '#8a93b4', gridColumn: '1 / -1', textAlign: 'center', padding: 30 }}>
|
|
||||||
В этой категории пока нет скинов
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Подвал-подсказка */}
|
|
||||||
<div style={{
|
|
||||||
padding: '10px 20px', borderTop: '1px solid rgba(255,255,255,0.08)',
|
|
||||||
color: '#6b76a0', fontSize: 12, textAlign: 'center',
|
|
||||||
}}>
|
|
||||||
Нажми <b style={{ color: '#aab4d4' }}>B</b> или <b style={{ color: '#aab4d4' }}>Esc</b>, чтобы закрыть
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function badgeStyle(bg, fg) {
|
|
||||||
return {
|
|
||||||
position: 'absolute', top: 8, right: 8,
|
|
||||||
background: bg, color: fg,
|
|
||||||
fontSize: 11, fontWeight: 900, padding: '3px 8px', borderRadius: 999,
|
|
||||||
boxShadow: '0 2px 6px rgba(0,0,0,0.4)',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -1,249 +0,0 @@
|
|||||||
/**
|
|
||||||
* AchievementsManager — достижения (badges) как в Roblox (задача 20).
|
|
||||||
*
|
|
||||||
* - define([...]) регистрирует достижения проекта.
|
|
||||||
* - unlock(id) разблокирует → toast справа-сверху (4 редкости, очередь, звук).
|
|
||||||
* - bindToStat(id, statName, {gte/lte/eq}) — авто-unlock по leaderstat.
|
|
||||||
* - кнопка-кубок слева-снизу → страница «Мои достижения» (grid + прогресс).
|
|
||||||
* - сохранение разблокированных в localStorage по projectId (закрыл-открыл → остались).
|
|
||||||
*
|
|
||||||
* API (через game.achievements.*): define/unlock/has/list/progress/bindToStat/
|
|
||||||
* setButtonVisible/openPage.
|
|
||||||
*
|
|
||||||
* Фича-парность: тот же модуль в rublox-player/src/engine/.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const RARITY = {
|
|
||||||
common: { label: 'Обычное', border: '#9aa3b2', bg: 'linear-gradient(135deg,rgba(120,130,150,0.9),rgba(80,88,104,0.9))', glow: 'rgba(154,163,178,0.5)' },
|
|
||||||
rare: { label: 'Редкое', border: '#4d8bff', bg: 'linear-gradient(135deg,rgba(60,110,220,0.92),rgba(30,60,150,0.92))', glow: 'rgba(77,139,255,0.6)' },
|
|
||||||
epic: { label: 'Эпическое', border: '#a05aff', bg: 'linear-gradient(135deg,rgba(150,80,230,0.92),rgba(90,40,160,0.92))', glow: 'rgba(160,90,255,0.65)' },
|
|
||||||
legendary: { label: 'Легендарное', border: '#ffd23a', bg: 'linear-gradient(135deg,rgba(255,200,60,0.95),rgba(220,140,20,0.95))', glow: 'rgba(255,210,58,0.75)' },
|
|
||||||
};
|
|
||||||
|
|
||||||
export class AchievementsManager {
|
|
||||||
constructor(scene3d) {
|
|
||||||
this.s = scene3d;
|
|
||||||
this._defs = []; // [{id,name,description,icon,rarity,points,hidden}]
|
|
||||||
this._unlocked = new Set(); // id разблокированных
|
|
||||||
this._binds = []; // [{id, stat, op, value}]
|
|
||||||
this._toastQueue = [];
|
|
||||||
this._toastActive = false;
|
|
||||||
this._btnVisible = true;
|
|
||||||
this.btn = null; this.toastRoot = null; this.page = null;
|
|
||||||
this._projectKey = 'rublox_ach_' + (this.s?._projectId ?? 'proj');
|
|
||||||
}
|
|
||||||
|
|
||||||
define(list) {
|
|
||||||
const arr = Array.isArray(list) ? list : [list];
|
|
||||||
for (const a of arr) {
|
|
||||||
if (!a || typeof a.id !== 'string') continue;
|
|
||||||
if (this._defs.some(d => d.id === a.id)) continue;
|
|
||||||
this._defs.push({
|
|
||||||
id: a.id, name: a.name || a.id, description: a.description || '',
|
|
||||||
icon: a.icon || '🏆', rarity: RARITY[a.rarity] ? a.rarity : 'common',
|
|
||||||
points: Number(a.points) || 5, hidden: !!a.hidden,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
this._loadSaved();
|
|
||||||
this._mountButton();
|
|
||||||
}
|
|
||||||
|
|
||||||
_loadSaved() {
|
|
||||||
// Резервная локальная копия (мгновенно, до ответа БД).
|
|
||||||
try {
|
|
||||||
const raw = localStorage.getItem(this._projectKey);
|
|
||||||
if (raw) for (const id of JSON.parse(raw)) this._unlocked.add(id);
|
|
||||||
} catch (e) { /* ignore */ }
|
|
||||||
}
|
|
||||||
/** Загрузить разблокированные достижения из БД (по игроку). Вызывать при Play. */
|
|
||||||
loadFromDB() {
|
|
||||||
const rt = this.s?.gameRuntime;
|
|
||||||
if (!rt || !rt.loadProgress) return;
|
|
||||||
rt.loadProgress('_achievements', (data) => {
|
|
||||||
if (Array.isArray(data)) {
|
|
||||||
for (const id of data) this._unlocked.add(id);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
_persist() {
|
|
||||||
// 1) локально (быстрый кэш), 2) в БД (между сессиями, любое устройство).
|
|
||||||
try { localStorage.setItem(this._projectKey, JSON.stringify([...this._unlocked])); } catch (e) {}
|
|
||||||
try { this.s?.gameRuntime?.saveProgress?.('_achievements', [...this._unlocked]); } catch (e) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
unlock(id, _playerId) {
|
|
||||||
const def = this._defs.find(d => d.id === id);
|
|
||||||
if (!def || this._unlocked.has(id)) return false;
|
|
||||||
this._unlocked.add(id);
|
|
||||||
this._persist();
|
|
||||||
this._queueToast(def);
|
|
||||||
this._playSound(def.rarity);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
has(id) { return this._unlocked.has(id); }
|
|
||||||
|
|
||||||
list() {
|
|
||||||
return this._defs.map(d => ({ id: d.id, name: d.name, unlocked: this._unlocked.has(d.id) }));
|
|
||||||
}
|
|
||||||
|
|
||||||
progress() {
|
|
||||||
const total = this._defs.length;
|
|
||||||
const unlocked = this._defs.filter(d => this._unlocked.has(d.id)).length;
|
|
||||||
const pts = this._defs.filter(d => this._unlocked.has(d.id)).reduce((s, d) => s + d.points, 0);
|
|
||||||
const maxPts = this._defs.reduce((s, d) => s + d.points, 0);
|
|
||||||
return { total, unlocked, points: pts, maxPoints: maxPts };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Авто-unlock при достижении leaderstat значения. */
|
|
||||||
bindToStat(id, statName, cond) {
|
|
||||||
const op = cond && (cond.gte != null ? 'gte' : cond.lte != null ? 'lte' : cond.eq != null ? 'eq' : null);
|
|
||||||
if (!op) return;
|
|
||||||
this._binds.push({ id, stat: statName, op, value: cond[op] });
|
|
||||||
// Подпишемся на leaderstats при первом bind.
|
|
||||||
if (!this._boundLs && this.s?.leaderstats) {
|
|
||||||
this._boundLs = true;
|
|
||||||
this.s.leaderstats.onChange((pid, name, nv) => this._checkBinds(name, nv));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_checkBinds(statName, value) {
|
|
||||||
for (const b of this._binds) {
|
|
||||||
if (b.stat !== statName || this._unlocked.has(b.id)) continue;
|
|
||||||
const ok = b.op === 'gte' ? value >= b.value : b.op === 'lte' ? value <= b.value : value === b.value;
|
|
||||||
if (ok) this.unlock(b.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setButtonVisible(v) { this._btnVisible = !!v; if (this.btn) this.btn.style.display = v ? 'flex' : 'none'; }
|
|
||||||
|
|
||||||
get active() { return this._defs.length > 0; }
|
|
||||||
|
|
||||||
// ── Кнопка-кубок ───────────────────────────────────────────────────────
|
|
||||||
_mountButton() {
|
|
||||||
if (this.btn || !this.active) return;
|
|
||||||
if (!this.s?._isPlaying) return; // кнопка-кубок только в Play
|
|
||||||
const parent = (this.s && this.s.canvas && this.s.canvas.parentElement) || document.body;
|
|
||||||
const b = document.createElement('button');
|
|
||||||
b.title = 'Мои достижения';
|
|
||||||
b.textContent = '🏆';
|
|
||||||
b.style.cssText = [
|
|
||||||
'position:absolute', 'left:14px', 'bottom:64px', 'z-index:50',
|
|
||||||
'width:46px', 'height:46px', 'border-radius:12px', 'font-size:24px',
|
|
||||||
'background:rgba(18,22,33,0.6)', 'backdrop-filter:blur(8px)',
|
|
||||||
'border:1px solid rgba(255,255,255,0.15)', 'cursor:pointer',
|
|
||||||
'display:flex', 'align-items:center', 'justify-content:center',
|
|
||||||
'box-shadow:0 4px 16px rgba(0,0,0,0.35)', 'pointer-events:auto',
|
|
||||||
].join(';');
|
|
||||||
if (!this._btnVisible) b.style.display = 'none';
|
|
||||||
b.onclick = () => this.openPage();
|
|
||||||
parent.appendChild(b);
|
|
||||||
this.btn = b;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Toast ────────────────────────────────────────────────────────────
|
|
||||||
_queueToast(def) { this._toastQueue.push(def); if (!this._toastActive) this._nextToast(); }
|
|
||||||
_nextToast() {
|
|
||||||
if (!this._toastQueue.length) { this._toastActive = false; return; }
|
|
||||||
this._toastActive = true;
|
|
||||||
const def = this._toastQueue.shift();
|
|
||||||
const r = RARITY[def.rarity];
|
|
||||||
const parent = (this.s && this.s.canvas && this.s.canvas.parentElement) || document.body;
|
|
||||||
const t = document.createElement('div');
|
|
||||||
t.style.cssText = [
|
|
||||||
'position:absolute', 'top:200px', 'right:14px', 'z-index:60',
|
|
||||||
'width:340px', 'display:flex', 'align-items:center', 'gap:12px',
|
|
||||||
'padding:12px 14px', 'border-radius:14px', 'background:' + r.bg,
|
|
||||||
'border:2px solid ' + r.border, 'box-shadow:0 0 24px ' + r.glow + ',0 8px 24px rgba(0,0,0,0.4)',
|
|
||||||
'font-family:Inter,system-ui,sans-serif', 'color:#fff',
|
|
||||||
'transform:translateX(380px)', 'transition:transform .32s cubic-bezier(.2,.8,.3,1)',
|
|
||||||
'pointer-events:auto', 'cursor:pointer',
|
|
||||||
].join(';');
|
|
||||||
t.innerHTML =
|
|
||||||
'<div style="font-size:42px;flex:0 0 auto;filter:drop-shadow(0 2px 4px rgba(0,0,0,0.4))">' + def.icon + '</div>' +
|
|
||||||
'<div style="flex:1;min-width:0">' +
|
|
||||||
'<div style="font-size:11px;opacity:0.85;font-weight:700;text-transform:uppercase;letter-spacing:0.5px">Достижение разблокировано · ' + r.label + '</div>' +
|
|
||||||
'<div style="font-size:17px;font-weight:800;margin:1px 0">' + this._esc(def.name) + '</div>' +
|
|
||||||
'<div style="font-size:12px;opacity:0.9">' + this._esc(def.description) + ' · +' + def.points + ' очк.</div>' +
|
|
||||||
'</div>';
|
|
||||||
t.onclick = () => this.openPage();
|
|
||||||
parent.appendChild(t);
|
|
||||||
// slide-in
|
|
||||||
requestAnimationFrame(() => { t.style.transform = 'translateX(0)'; });
|
|
||||||
// через 3с slide-out + следующий
|
|
||||||
setTimeout(() => {
|
|
||||||
t.style.transform = 'translateX(380px)';
|
|
||||||
setTimeout(() => { try { t.remove(); } catch (e) {} this._nextToast(); }, 350);
|
|
||||||
}, 3000);
|
|
||||||
}
|
|
||||||
|
|
||||||
_playSound(rarity) {
|
|
||||||
// Используем встроенные звуки движка через gameRuntime/audio.
|
|
||||||
try {
|
|
||||||
const map = { common: 'coin', rare: 'win', epic: 'win', legendary: 'win' };
|
|
||||||
const pitch = { common: 1, rare: 1.1, epic: 0.9, legendary: 0.8 }[rarity] || 1;
|
|
||||||
this.s?.gameRuntime?._playSound?.({ name: map[rarity] || 'coin', pitch });
|
|
||||||
} catch (e) { /* ignore */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Страница «Мои достижения» ───────────────────────────────────────────
|
|
||||||
openPage() {
|
|
||||||
if (this.page) { this._closePage(); return; }
|
|
||||||
const parent = (this.s && this.s.canvas && this.s.canvas.parentElement) || document.body;
|
|
||||||
const overlay = document.createElement('div');
|
|
||||||
overlay.style.cssText = [
|
|
||||||
'position:absolute', 'inset:0', 'z-index:80',
|
|
||||||
'background:rgba(8,10,16,0.78)', 'backdrop-filter:blur(6px)',
|
|
||||||
'display:flex', 'align-items:center', 'justify-content:center',
|
|
||||||
'font-family:Inter,system-ui,sans-serif', 'pointer-events:auto',
|
|
||||||
].join(';');
|
|
||||||
overlay.onclick = (e) => { if (e.target === overlay) this._closePage(); };
|
|
||||||
const pr = this.progress();
|
|
||||||
const pct = pr.total ? Math.round(pr.unlocked / pr.total * 100) : 0;
|
|
||||||
|
|
||||||
const panel = document.createElement('div');
|
|
||||||
panel.style.cssText = 'width:min(720px,92%);max-height:84%;overflow-y:auto;background:#141826;border:1px solid rgba(255,255,255,0.12);border-radius:18px;padding:22px;color:#e8ecf2;box-shadow:0 20px 60px rgba(0,0,0,0.5)';
|
|
||||||
|
|
||||||
let html = '<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px">' +
|
|
||||||
'<div style="font-size:22px;font-weight:800">🏆 Мои достижения</div>' +
|
|
||||||
'<button id="_achClose" style="width:34px;height:34px;border-radius:9px;background:rgba(255,255,255,0.1);border:1px solid rgba(255,255,255,0.15);color:#fff;font-size:18px;cursor:pointer">✕</button></div>';
|
|
||||||
html += '<div style="font-size:14px;color:#9aa3b2;margin-bottom:6px">' + pr.unlocked + ' из ' + pr.total + ' (' + pr.points + ' / ' + pr.maxPoints + ' очков)</div>';
|
|
||||||
html += '<div style="height:8px;background:rgba(255,255,255,0.1);border-radius:6px;margin-bottom:18px;overflow:hidden"><div style="height:100%;width:' + pct + '%;background:linear-gradient(90deg,#ffd23a,#ff9a3a);border-radius:6px"></div></div>';
|
|
||||||
html += '<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:12px">';
|
|
||||||
for (const d of this._defs) {
|
|
||||||
const un = this._unlocked.has(d.id);
|
|
||||||
const r = RARITY[d.rarity];
|
|
||||||
const hiddenLocked = d.hidden && !un;
|
|
||||||
const icon = hiddenLocked ? '❔' : d.icon;
|
|
||||||
const name = hiddenLocked ? 'Скрытое достижение' : d.name;
|
|
||||||
const desc = hiddenLocked ? 'Найди, чтобы открыть' : d.description;
|
|
||||||
html += '<div style="background:rgba(255,255,255,0.04);border:2px solid ' + (un ? r.border : 'rgba(255,255,255,0.08)') + ';border-radius:14px;padding:14px 10px;text-align:center;' + (un ? '' : 'opacity:0.55;') + '">' +
|
|
||||||
'<div style="font-size:44px;margin-bottom:6px;' + (un ? '' : 'filter:grayscale(1);') + '">' + icon + (un ? '' : ' 🔒') + '</div>' +
|
|
||||||
'<div style="font-size:14px;font-weight:800">' + this._esc(name) + '</div>' +
|
|
||||||
'<div style="font-size:11px;color:#9aa3b2;margin-top:3px;line-height:1.3">' + this._esc(desc) + '</div>' +
|
|
||||||
'<div style="font-size:10px;font-weight:700;margin-top:6px;color:' + r.border + '">' + r.label + ' · ' + d.points + ' очк.</div>' +
|
|
||||||
'</div>';
|
|
||||||
}
|
|
||||||
html += '</div>';
|
|
||||||
panel.innerHTML = html;
|
|
||||||
overlay.appendChild(panel);
|
|
||||||
parent.appendChild(overlay);
|
|
||||||
panel.querySelector('#_achClose').onclick = () => this._closePage();
|
|
||||||
this.page = overlay;
|
|
||||||
}
|
|
||||||
_closePage() { if (this.page) { try { this.page.remove(); } catch (e) {} this.page = null; } }
|
|
||||||
|
|
||||||
_esc(s) { return String(s).replace(/[&<>]/g, c => ({ '&': '&', '<': '<', '>': '>' }[c])); }
|
|
||||||
|
|
||||||
serialize() { return this._defs.map(d => ({ ...d })); }
|
|
||||||
load(arr) { if (Array.isArray(arr) && arr.length) this.define(arr); }
|
|
||||||
|
|
||||||
dispose() {
|
|
||||||
for (const el of [this.btn, this.toastRoot, this.page]) { if (el) try { el.remove(); } catch (e) {} }
|
|
||||||
this.btn = null; this.page = null; this._toastQueue = []; this._toastActive = false;
|
|
||||||
}
|
|
||||||
resetRuntime() {
|
|
||||||
// Определения и unlocked сохраняются (достижения «навсегда»). Чистим UI.
|
|
||||||
this._closePage();
|
|
||||||
this._toastQueue = []; this._toastActive = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -53,30 +53,18 @@ import { placeVoxelTree, TREE_TYPES } from './VoxelTreeBuilder';
|
|||||||
import { UserModelManager, parseUserModelId, USER_MODEL_PREFIX } from './UserModelManager';
|
import { UserModelManager, parseUserModelId, USER_MODEL_PREFIX } from './UserModelManager';
|
||||||
import { ModelManager } from './ModelManager';
|
import { ModelManager } from './ModelManager';
|
||||||
import { PrimitiveManager } from './PrimitiveManager';
|
import { PrimitiveManager } from './PrimitiveManager';
|
||||||
import { BillboardUiManager } from './BillboardUiManager';
|
|
||||||
import { getPrimitiveType } from './PrimitiveTypes';
|
import { getPrimitiveType } from './PrimitiveTypes';
|
||||||
import { FolderManager } from './FolderManager';
|
import { FolderManager } from './FolderManager';
|
||||||
import { GuiManager } from './GuiManager';
|
import { GuiManager } from './GuiManager';
|
||||||
import { ModalManager } from './ModalManager';
|
|
||||||
import { InventoryManager } from './InventoryManager';
|
import { InventoryManager } from './InventoryManager';
|
||||||
import { WeaponSystem } from './WeaponSystem';
|
import { WeaponSystem } from './WeaponSystem';
|
||||||
import { ZombieManager } from './ZombieManager';
|
import { ZombieManager } from './ZombieManager';
|
||||||
import { NpcManager } from './NpcManager';
|
import { NpcManager } from './NpcManager';
|
||||||
import { ConstraintManager } from './ConstraintManager';
|
import { ConstraintManager } from './ConstraintManager';
|
||||||
import { BeamManager } from './BeamManager';
|
import { BeamManager } from './BeamManager';
|
||||||
import { PlacementManager } from './PlacementManager';
|
|
||||||
import { ShopInventoryUi } from './ShopInventoryUi';
|
|
||||||
import { LoadingScreenOverlay } from './LoadingScreenOverlay';
|
|
||||||
import { VehicleManager } from './VehicleManager';
|
|
||||||
import { VehicleHud } from './VehicleHud';
|
|
||||||
import { ZombieSpawnerManager } from './ZombieSpawnerManager';
|
import { ZombieSpawnerManager } from './ZombieSpawnerManager';
|
||||||
import { DynamicsManager } from './DynamicsManager';
|
import { DynamicsManager } from './DynamicsManager';
|
||||||
import { Environment } from './Environment';
|
import { Environment } from './Environment';
|
||||||
import { SkyboxManager } from './SkyboxManager';
|
|
||||||
import { LeaderstatsManager } from './LeaderstatsManager';
|
|
||||||
import { AchievementsManager } from './AchievementsManager';
|
|
||||||
import { FloaterManager } from './FloaterManager'; // задача 40 — damage floaters
|
|
||||||
import { InventoryUI } from './InventoryUI'; // задача 44 — drag-drop инвентарь
|
|
||||||
import { AudioManager, AMBIENT_PRESETS, MUSIC_PRESETS } from './AudioManager';
|
import { AudioManager, AMBIENT_PRESETS, MUSIC_PRESETS } from './AudioManager';
|
||||||
import { GameAudioManager } from './GameAudioManager';
|
import { GameAudioManager } from './GameAudioManager';
|
||||||
import { AssetManager } from './AssetManager';
|
import { AssetManager } from './AssetManager';
|
||||||
@ -96,7 +84,6 @@ import { GdForest } from './GdForest';
|
|||||||
import { GdPlayerCube } from './GdPlayerCube';
|
import { GdPlayerCube } from './GdPlayerCube';
|
||||||
import { GdPlayerTrail } from './GdPlayerTrail';
|
import { GdPlayerTrail } from './GdPlayerTrail';
|
||||||
import { GdPostFx } from './GdPostFx';
|
import { GdPostFx } from './GdPostFx';
|
||||||
import { GraphicsManager } from './GraphicsManager';
|
|
||||||
import { PhysicsAABB } from './PhysicsAABB';
|
import { PhysicsAABB } from './PhysicsAABB';
|
||||||
import { PlayerController } from './PlayerController';
|
import { PlayerController } from './PlayerController';
|
||||||
import { SelectionManager } from './SelectionManager';
|
import { SelectionManager } from './SelectionManager';
|
||||||
@ -155,20 +142,6 @@ export class BabylonScene {
|
|||||||
this.npcManager = null; // управляемые скриптом NPC (Фаза 4.1)
|
this.npcManager = null; // управляемые скриптом NPC (Фаза 4.1)
|
||||||
this.constraintManager = null; // связи объектов (Фаза 5, Constraints)
|
this.constraintManager = null; // связи объектов (Фаза 5, Constraints)
|
||||||
this.beamManager = null; // лучи и следы (Фаза 5.2)
|
this.beamManager = null; // лучи и следы (Фаза 5.2)
|
||||||
// Placement mode (задача 11) — фича-парность со студией.
|
|
||||||
this.placementManager = null;
|
|
||||||
this.shopInventoryUi = null;
|
|
||||||
this.vehicleManager = null; // задача 14
|
|
||||||
this.vehicleHud = null;
|
|
||||||
this._VehicleHudClass = VehicleHud;
|
|
||||||
this._PlacementManagerClass = PlacementManager;
|
|
||||||
this._ShopInventoryUiClass = ShopInventoryUi;
|
|
||||||
// Экран загрузки (задача 12).
|
|
||||||
this.loadingScreen = null;
|
|
||||||
this._LoadingScreenOverlayClass = LoadingScreenOverlay;
|
|
||||||
this._loadingConfig = null;
|
|
||||||
this._mainMenuConfig = null; // задача 13
|
|
||||||
this._projectThumbnail = null;
|
|
||||||
this.spawnerManager = null; // спавнеры зомби
|
this.spawnerManager = null; // спавнеры зомби
|
||||||
this.environment = null;
|
this.environment = null;
|
||||||
this.audioManager = null;
|
this.audioManager = null;
|
||||||
@ -1293,16 +1266,8 @@ export class BabylonScene {
|
|||||||
// Ссылка на обёртку BabylonScene — нужна для эмиттеров частиц
|
// Ссылка на обёртку BabylonScene — нужна для эмиттеров частиц
|
||||||
// (createEmitterParticles живёт на обёртке).
|
// (createEmitterParticles живёт на обёртке).
|
||||||
this.primitiveManager.scene3d = this;
|
this.primitiveManager.scene3d = this;
|
||||||
// BillboardUiManager — отдельный модуль, рисует GUI на DynamicTexture
|
|
||||||
// для билбордов. Нужен PrimitiveManager-у чтобы при создании billboard
|
|
||||||
// (type='billboard') сразу применить текстуру с дефолтным пресетом.
|
|
||||||
this.billboardUiManager = new BillboardUiManager(this.scene);
|
|
||||||
this.primitiveManager.billboardUiManager = this.billboardUiManager;
|
|
||||||
this.folderManager = new FolderManager(this.blockManager, this.modelManager, this.primitiveManager);
|
this.folderManager = new FolderManager(this.blockManager, this.modelManager, this.primitiveManager);
|
||||||
this.guiManager = new GuiManager();
|
this.guiManager = new GuiManager();
|
||||||
this.modalManager = new ModalManager();
|
|
||||||
this.modalManager.attachScene(this);
|
|
||||||
this.modalManager.attachGui(this.guiManager);
|
|
||||||
this.inventory = new InventoryManager();
|
this.inventory = new InventoryManager();
|
||||||
this.physics = new PhysicsAABB(this.blockManager);
|
this.physics = new PhysicsAABB(this.blockManager);
|
||||||
// Сразу синхронизируем границу пола с текущим размером мира,
|
// Сразу синхронизируем границу пола с текущим размером мира,
|
||||||
@ -1315,7 +1280,6 @@ export class BabylonScene {
|
|||||||
// Voxel-террейн тоже участвует в физике. У террейна свой размер
|
// Voxel-террейн тоже участвует в физике. У террейна свой размер
|
||||||
// ячейки (TERRAIN_VOXEL_SIZE = 0.5), поэтому передаём отдельно.
|
// ячейки (TERRAIN_VOXEL_SIZE = 0.5), поэтому передаём отдельно.
|
||||||
this.physics.setTerrainManager(this.terrainManager, TERRAIN_VOXEL_SIZE);
|
this.physics.setTerrainManager(this.terrainManager, TERRAIN_VOXEL_SIZE);
|
||||||
this.vehicleManager = new VehicleManager(this); // задача 14
|
|
||||||
// Этап 4 voxel-движка: подключаем новый chunk-based VoxelWorld к физике.
|
// Этап 4 voxel-движка: подключаем новый chunk-based VoxelWorld к физике.
|
||||||
// Физика проверяет коллизии в обоих источниках (legacy terrainManager +
|
// Физика проверяет коллизии в обоих источниках (legacy terrainManager +
|
||||||
// voxelWorld), что позволяет постепенно мигрировать без поломки.
|
// voxelWorld), что позволяет постепенно мигрировать без поломки.
|
||||||
@ -1324,11 +1288,6 @@ export class BabylonScene {
|
|||||||
}
|
}
|
||||||
this.dynamics = new DynamicsManager(this);
|
this.dynamics = new DynamicsManager(this);
|
||||||
this.environment = new Environment(this.scene, this._hemiLight, this._sunLight);
|
this.environment = new Environment(this.scene, this._hemiLight, this._sunLight);
|
||||||
this.skybox = new SkyboxManager(this.scene, this._hemiLight, this._sunLight); // задача 16 — кастомное небо (единый источник света)
|
|
||||||
this.floaters = new FloaterManager(this); // задача 40 — damage floaters
|
|
||||||
this.invUI = new InventoryUI(this); // задача 44 — drag-drop инвентарь
|
|
||||||
this.leaderstats = new LeaderstatsManager(this); // задача 20 — лидерборды
|
|
||||||
this.achievements = new AchievementsManager(this); // задача 20 — достижения
|
|
||||||
this.audioManager = new AudioManager();
|
this.audioManager = new AudioManager();
|
||||||
this.assetManager = new AssetManager();
|
this.assetManager = new AssetManager();
|
||||||
// PrimitiveManager должен уметь брать dataURL картинки по id ассета,
|
// PrimitiveManager должен уметь брать dataURL картинки по id ассета,
|
||||||
@ -1432,18 +1391,6 @@ export class BabylonScene {
|
|||||||
if (this._isPlaying && this.environment) {
|
if (this._isPlaying && this.environment) {
|
||||||
this.environment.tick(dt);
|
this.environment.tick(dt);
|
||||||
}
|
}
|
||||||
// Небо: дрейф облаков + fadeTo
|
|
||||||
if (this.skybox) {
|
|
||||||
this.skybox.tick(dt);
|
|
||||||
}
|
|
||||||
// Лидерборды (задача 20) — рендер HUD-таблицы при изменениях.
|
|
||||||
if (this._isPlaying && this.leaderstats) {
|
|
||||||
this.leaderstats.tick();
|
|
||||||
}
|
|
||||||
// Damage floaters (задача 40) — анимация всплывающих цифр.
|
|
||||||
if (this.floaters) {
|
|
||||||
this.floaters.tick(dt);
|
|
||||||
}
|
|
||||||
// Анимация жидкостей — работает всегда (и в редакторе)
|
// Анимация жидкостей — работает всегда (и в редакторе)
|
||||||
if (this.blockManager) {
|
if (this.blockManager) {
|
||||||
this.blockManager.tick(dt);
|
this.blockManager.tick(dt);
|
||||||
@ -1527,14 +1474,6 @@ export class BabylonScene {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Задача 04: modalManager.tick — независимо от runtime'а
|
|
||||||
if (this._isPlaying && this.modalManager?.tick) {
|
|
||||||
try { this.modalManager.tick(dt); } catch (e) {}
|
|
||||||
}
|
|
||||||
// Задача 12: loadingScreen.tick — fade/auto-duration независимо от paused.
|
|
||||||
if (this._isPlaying && this.loadingScreen?.tick) {
|
|
||||||
try { this.loadingScreen.tick(dt); } catch (e) {}
|
|
||||||
}
|
|
||||||
// Tick пользовательских скриптов: в Play-режиме или в solo-debug
|
// Tick пользовательских скриптов: в Play-режиме или в solo-debug
|
||||||
if (this.gameRuntime && (this._isPlaying || this.gameRuntime.isSolo?.())) {
|
if (this.gameRuntime && (this._isPlaying || this.gameRuntime.isSolo?.())) {
|
||||||
this.gameRuntime.tick(dt);
|
this.gameRuntime.tick(dt);
|
||||||
@ -1650,42 +1589,6 @@ export class BabylonScene {
|
|||||||
this._ssaoEnabled = false;
|
this._ssaoEnabled = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Система графики/эффектов («шейдеры»). Лениво создаём GraphicsManager.
|
|
||||||
* Идентична студийной (фича-парность). Применяется при загрузке игры,
|
|
||||||
* если автор настроил graphics в проекте (и не 'off').
|
|
||||||
*/
|
|
||||||
_ensureGraphics() {
|
|
||||||
if (this._graphics) {
|
|
||||||
const cam = this.scene?.activeCamera || this.camera;
|
|
||||||
if (cam) this._graphics.setCamera(cam);
|
|
||||||
return this._graphics;
|
|
||||||
}
|
|
||||||
const cam = this.scene?.activeCamera || this.camera;
|
|
||||||
if (!this.scene || !cam) return null;
|
|
||||||
this._graphics = new GraphicsManager(this.scene, cam, this, {
|
|
||||||
mobile: !!this._isMobileMode,
|
|
||||||
});
|
|
||||||
return this._graphics;
|
|
||||||
}
|
|
||||||
|
|
||||||
setGraphics(settings) {
|
|
||||||
const g = this._ensureGraphics();
|
|
||||||
if (!g) return null;
|
|
||||||
const cfg = g.apply(settings || {});
|
|
||||||
this._graphicsConfig = cfg;
|
|
||||||
return cfg;
|
|
||||||
}
|
|
||||||
|
|
||||||
getGraphicsState() {
|
|
||||||
return this._graphics ? this._graphics.serialize() : (this._graphicsConfig || null);
|
|
||||||
}
|
|
||||||
|
|
||||||
disableGraphics() {
|
|
||||||
if (this._graphics) this._graphics.disableAll();
|
|
||||||
this._graphicsConfig = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Включить/выключить SSAO пост-эффект (контактные тени).
|
* Включить/выключить SSAO пост-эффект (контактные тени).
|
||||||
* Используем SSAORenderingPipeline v1 — v2 ломал thin-instance рендер
|
* Используем SSAORenderingPipeline v1 — v2 ломал thin-instance рендер
|
||||||
@ -1777,8 +1680,8 @@ export class BabylonScene {
|
|||||||
// peter-panning — тень "уезжала" далеко в сторону от блока (баг
|
// peter-panning — тень "уезжала" далеко в сторону от блока (баг
|
||||||
// 2026-05-27). 0.005 — баланс между acne и peter-panning для
|
// 2026-05-27). 0.005 — баланс между acne и peter-panning для
|
||||||
// воксельных кубов 1м.
|
// воксельных кубов 1м.
|
||||||
const PCF_BIAS = 0.0008;
|
const PCF_BIAS = 0.0005;
|
||||||
const PCF_NORMAL_BIAS = 0.02; // убирает «полосы»-acne на полу от соседних теней
|
const PCF_NORMAL_BIAS = 0.005;
|
||||||
|
|
||||||
if (!this._shadowGenerator) {
|
if (!this._shadowGenerator) {
|
||||||
if (wantCsm) {
|
if (wantCsm) {
|
||||||
@ -1788,9 +1691,9 @@ export class BabylonScene {
|
|||||||
const csm = new CascadedShadowGenerator(size, this._sunLight);
|
const csm = new CascadedShadowGenerator(size, this._sunLight);
|
||||||
csm.numCascades = numCascades;
|
csm.numCascades = numCascades;
|
||||||
csm.stabilizeCascades = true;
|
csm.stabilizeCascades = true;
|
||||||
csm.lambda = 0.6;
|
csm.lambda = 0.8;
|
||||||
csm.cascadeBlendPercentage = 0.1;
|
csm.cascadeBlendPercentage = 0.07;
|
||||||
csm.shadowMaxZ = (q === 'high') ? 90 : 60;
|
csm.shadowMaxZ = (q === 'high') ? 200 : 120;
|
||||||
csm.bias = PCF_BIAS;
|
csm.bias = PCF_BIAS;
|
||||||
csm.normalBias = PCF_NORMAL_BIAS;
|
csm.normalBias = PCF_NORMAL_BIAS;
|
||||||
csm.usePercentageCloserFiltering = true;
|
csm.usePercentageCloserFiltering = true;
|
||||||
@ -1798,8 +1701,7 @@ export class BabylonScene {
|
|||||||
? ShadowGenerator.QUALITY_HIGH
|
? ShadowGenerator.QUALITY_HIGH
|
||||||
: ShadowGenerator.QUALITY_MEDIUM;
|
: ShadowGenerator.QUALITY_MEDIUM;
|
||||||
csm.darkness = 0.4;
|
csm.darkness = 0.4;
|
||||||
csm.autoCalcDepthBounds = false;
|
csm.autoCalcDepthBounds = true;
|
||||||
csm.frustumEdgeFalloff = 12; // убирает «полосу-хвост» тени игрока
|
|
||||||
this._shadowGenerator = csm;
|
this._shadowGenerator = csm;
|
||||||
} else {
|
} else {
|
||||||
// Обычный ShadowGenerator. Soft теперь 2048 (было 1024).
|
// Обычный ShadowGenerator. Soft теперь 2048 (было 1024).
|
||||||
@ -1959,20 +1861,6 @@ export class BabylonScene {
|
|||||||
if (typeof mesh.getBoundingInfo !== 'function') return;
|
if (typeof mesh.getBoundingInfo !== 'function') return;
|
||||||
if (typeof mesh.getTotalVertices !== 'function') return;
|
if (typeof mesh.getTotalVertices !== 'function') return;
|
||||||
if (mesh.getTotalVertices() <= 0) return;
|
if (mesh.getTotalVertices() <= 0) return;
|
||||||
// ОПТИМИЗАЦИЯ ТЕНЕЙ (задача 14): мелкие/тонкие меши и огромный плоский
|
|
||||||
// пол НЕ кастят тень — каждый caster дорого стоит в shadow-map
|
|
||||||
// (на сцене из сотен примитивов давало 5-15 FPS вместо 45-60).
|
|
||||||
try {
|
|
||||||
const bb = mesh.getBoundingInfo().boundingBox;
|
|
||||||
const ext = bb.extendSizeWorld || bb.extendSize;
|
|
||||||
if (ext) {
|
|
||||||
const w = ext.x * 2, h = ext.y * 2, d = ext.z * 2;
|
|
||||||
const maxDim = Math.max(w, h, d);
|
|
||||||
const minDim = Math.min(w, h, d);
|
|
||||||
if (maxDim < 1.6 || minDim < 0.35) return;
|
|
||||||
if (maxDim > 30 && Math.min(w, d) > 30 && h < 3) return;
|
|
||||||
}
|
|
||||||
} catch (e) { /* ignore */ }
|
|
||||||
try { this._shadowGenerator.addShadowCaster(mesh, false); } catch (e) { /* ignore */ }
|
try { this._shadowGenerator.addShadowCaster(mesh, false); } catch (e) { /* ignore */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2281,16 +2169,8 @@ export class BabylonScene {
|
|||||||
const onMouseDown = (e) => {
|
const onMouseDown = (e) => {
|
||||||
if (this._isPlaying) {
|
if (this._isPlaying) {
|
||||||
// В Play-режиме ЛКМ — клик игрока в forward-направлении.
|
// В Play-режиме ЛКМ — клик игрока в forward-направлении.
|
||||||
// При pointer-lock курсор в центре; в third (свободный курсор)
|
// Pointer Lock — курсор всё равно в центре экрана.
|
||||||
// передаём реальные координаты клика для pick по табличкам.
|
if (e.button === 0) this._handlePlayClick();
|
||||||
if (this.placementManager && this.placementManager.isActive()) {
|
|
||||||
if (e.button === 0) { e.preventDefault(); this.placementManager.confirm(); return; }
|
|
||||||
if (e.button === 2) { e.preventDefault(); this.placementManager.cancel(); return; }
|
|
||||||
}
|
|
||||||
if (e.button === 0) {
|
|
||||||
const r = canvas.getBoundingClientRect();
|
|
||||||
this._handlePlayClick(e.clientX - r.left, e.clientY - r.top);
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Обновляем pointer координаты для raycast и Gizmo
|
// Обновляем pointer координаты для raycast и Gizmo
|
||||||
@ -2485,10 +2365,6 @@ export class BabylonScene {
|
|||||||
|
|
||||||
const onWheel = (e) => {
|
const onWheel = (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (this._isPlaying && this.placementManager && this.placementManager.isActive()) {
|
|
||||||
this.placementManager.rotate();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const forward = this._getCameraForward();
|
const forward = this._getCameraForward();
|
||||||
const delta = -Math.sign(e.deltaY) * this.ZOOM_SPEED;
|
const delta = -Math.sign(e.deltaY) * this.ZOOM_SPEED;
|
||||||
this.camera.position.addInPlace(forward.scale(delta));
|
this.camera.position.addInPlace(forward.scale(delta));
|
||||||
@ -2525,22 +2401,6 @@ export class BabylonScene {
|
|||||||
const key = this._normalizeKey(e);
|
const key = this._normalizeKey(e);
|
||||||
this.gameRuntime.routeGlobalEvent('keydown', { key, code: e.code });
|
this.gameRuntime.routeGlobalEvent('keydown', { key, code: e.code });
|
||||||
}
|
}
|
||||||
// Задача 44: I — открыть/закрыть инвентарь, Esc — закрыть, 1-9 — хотбар.
|
|
||||||
if (this._isPlaying && e.code === 'KeyI' && this.invUI &&
|
|
||||||
(this.invUI.defs.size > 0 || this.invUI.grid.some(Boolean) || this.invUI.hotbar.some(Boolean))) {
|
|
||||||
e.preventDefault(); this.invUI.toggle(); return;
|
|
||||||
}
|
|
||||||
if (this._isPlaying && e.code === 'Escape' && this.invUI?.isOpen()) {
|
|
||||||
e.preventDefault(); this.invUI.close(); return;
|
|
||||||
}
|
|
||||||
if (this._isPlaying && this.invUI && /^Digit[1-9]$/.test(e.code) &&
|
|
||||||
(this.invUI.hotbar.some(Boolean) || this.invUI.defs.size > 0)) {
|
|
||||||
this.invUI.setActiveHotbar(parseInt(e.code.slice(5), 10) - 1);
|
|
||||||
}
|
|
||||||
if (this._isPlaying && this.placementManager && this.placementManager.isActive()) {
|
|
||||||
if (e.code === 'KeyR') { e.preventDefault(); this.placementManager.rotate(); return; }
|
|
||||||
if (e.code === 'Escape') { e.preventDefault(); this.placementManager.cancel(); return; }
|
|
||||||
}
|
|
||||||
if (e.code === 'KeyF') {
|
if (e.code === 'KeyF') {
|
||||||
this._focusOnTarget(new Vector3(0, 0, 0));
|
this._focusOnTarget(new Vector3(0, 0, 0));
|
||||||
}
|
}
|
||||||
@ -2900,7 +2760,6 @@ export class BabylonScene {
|
|||||||
if (md.isBlock) {
|
if (md.isBlock) {
|
||||||
return { kind: 'block', ref: { x: md.gridX, y: md.gridY, z: md.gridZ } };
|
return { kind: 'block', ref: { x: md.gridX, y: md.gridY, z: md.gridZ } };
|
||||||
}
|
}
|
||||||
if (md.npcId != null) return { kind: 'npc', id: md.npcId };
|
|
||||||
if (md.isModel) return { kind: 'model', id: md.instanceId };
|
if (md.isModel) return { kind: 'model', id: md.instanceId };
|
||||||
if (md.isPrimitive) return { kind: 'primitive', id: md.primitiveId };
|
if (md.isPrimitive) return { kind: 'primitive', id: md.primitiveId };
|
||||||
return null;
|
return null;
|
||||||
@ -2997,59 +2856,12 @@ export class BabylonScene {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3) Касания объектов, на которые подписан ГЛОБАЛЬНЫЙ скрипт через
|
|
||||||
// findOne(x).onTouch(...) (rt._watchedTouchRefs). Объекты без скрипта
|
|
||||||
// и не триггеры — например цели туториала. Событие адресное (по ref).
|
|
||||||
const watched = rt._watchedTouchRefs;
|
|
||||||
if (watched && watched.size > 0) {
|
|
||||||
for (const ref of watched) {
|
|
||||||
const target = this._refToTarget(ref);
|
|
||||||
if (!target) continue;
|
|
||||||
const aabb = this._targetAABB(target);
|
|
||||||
if (!aabb) continue;
|
|
||||||
const key = 'w:' + ref;
|
|
||||||
seen.add(key);
|
|
||||||
const overlap =
|
|
||||||
px + phw > aabb.minX - EPS && px - phw < aabb.maxX + EPS &&
|
|
||||||
py + phh > aabb.minY - EPS && py - phh < aabb.maxY + EPS &&
|
|
||||||
pz + phd > aabb.minZ - EPS && pz - phd < aabb.maxZ + EPS;
|
|
||||||
const wasTouching = this._touchState.get(key);
|
|
||||||
if (overlap && !wasTouching) {
|
|
||||||
this._touchState.set(key, true);
|
|
||||||
rt.routeInstEvent(ref, 'instTouch', {});
|
|
||||||
} else if (!overlap && wasTouching) {
|
|
||||||
this._touchState.set(key, false);
|
|
||||||
rt.routeInstEvent(ref, 'instUntouch', {});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Чистим устаревшие записи (удалённые скрипты/триггеры)
|
// Чистим устаревшие записи (удалённые скрипты/триггеры)
|
||||||
for (const id of this._touchState.keys()) {
|
for (const id of this._touchState.keys()) {
|
||||||
if (!seen.has(id)) this._touchState.delete(id);
|
if (!seen.has(id)) this._touchState.delete(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Ref-строка 'primitive:NN' | 'model:NN' → {kind,id} для _targetAABB. */
|
|
||||||
_refToTarget(ref) {
|
|
||||||
if (typeof ref !== 'string') return null;
|
|
||||||
const colon = ref.indexOf(':');
|
|
||||||
if (colon < 0) return null;
|
|
||||||
const kind = ref.slice(0, colon);
|
|
||||||
const rest = ref.slice(colon + 1);
|
|
||||||
if (kind === 'primitive') {
|
|
||||||
const id = this.gameRuntime?._resolvePrimitiveId
|
|
||||||
? this.gameRuntime._resolvePrimitiveId(rest)
|
|
||||||
: (Number.isFinite(Number(rest)) ? Number(rest) : rest);
|
|
||||||
return { kind: 'primitive', id };
|
|
||||||
}
|
|
||||||
if (kind === 'model') {
|
|
||||||
const n = Number(rest);
|
|
||||||
return { kind: 'model', id: Number.isFinite(n) ? n : rest };
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Получить мировой AABB target-объекта (для touch-детекции). */
|
/** Получить мировой AABB target-объекта (для touch-детекции). */
|
||||||
_targetAABB(target) {
|
_targetAABB(target) {
|
||||||
if (!target) return null;
|
if (!target) return null;
|
||||||
@ -3087,7 +2899,7 @@ export class BabylonScene {
|
|||||||
* - в self-обработчики скриптов (routeEvent с target)
|
* - в self-обработчики скриптов (routeEvent с target)
|
||||||
* - в глобальные обработчики (game.onClick) с event.target
|
* - в глобальные обработчики (game.onClick) с event.target
|
||||||
*/
|
*/
|
||||||
_handlePlayClick(clickX, clickY) {
|
_handlePlayClick() {
|
||||||
if (!this._isPlaying) return;
|
if (!this._isPlaying) return;
|
||||||
|
|
||||||
// Мультиплеер-выстрел: если у сцены есть mpSync, шлём 'shoot' серверу.
|
// Мультиплеер-выстрел: если у сцены есть mpSync, шлём 'shoot' серверу.
|
||||||
@ -3108,63 +2920,7 @@ export class BabylonScene {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!this.gameRuntime) return;
|
if (!this.gameRuntime) return;
|
||||||
|
const pick = this._pickFromCenter();
|
||||||
// === Задача 01: клик по КНОПКЕ 3D-таблички (billboard) ===
|
|
||||||
// При pointer-lock (first/shift-lock) курсор в центре экрана → пикаем
|
|
||||||
// из центра. В third курсор СВОБОДНЫЙ → пикаем по реальным координатам
|
|
||||||
// клика (clickX/clickY переданы из onMouseDown). Без этого клик по
|
|
||||||
// табличке мышью в third промахивался — кнопки не нажимались.
|
|
||||||
if (this.billboardUiManager && this.primitiveManager) {
|
|
||||||
const locked = (document.pointerLockElement === this.canvas);
|
|
||||||
const w = this.engine?.getRenderWidth?.() || this.canvas.width;
|
|
||||||
const h = this.engine?.getRenderHeight?.() || this.canvas.height;
|
|
||||||
const px = locked ? w / 2 : (Number.isFinite(clickX) ? clickX : w / 2);
|
|
||||||
const py = locked ? h / 2 : (Number.isFinite(clickY) ? clickY : h / 2);
|
|
||||||
const bpick = this.scene.pick(px, py, (m) =>
|
|
||||||
m && m.metadata && m.metadata.primitiveId != null
|
|
||||||
&& this.primitiveManager.instances.get(m.metadata.primitiveId)?.type === 'billboard');
|
|
||||||
if (bpick && bpick.hit && bpick.pickedMesh) {
|
|
||||||
const bdata = this.primitiveManager.instances.get(bpick.pickedMesh.metadata.primitiveId);
|
|
||||||
const uv = bpick.getTextureCoordinates ? bpick.getTextureCoordinates() : null;
|
|
||||||
if (bdata && uv) {
|
|
||||||
const buttonId = this.billboardUiManager.pickButtonAt(bdata, uv.x, uv.y);
|
|
||||||
console.log('[billboard] клик id=' + bpick.pickedMesh.metadata.primitiveId
|
|
||||||
+ ' uv=(' + uv.x.toFixed(2) + ',' + uv.y.toFixed(2) + ') buttonId=' + buttonId
|
|
||||||
+ ' locked=' + locked);
|
|
||||||
if (buttonId) {
|
|
||||||
this.billboardUiManager.fireClick(bdata, buttonId);
|
|
||||||
return; // клик по табличке обработан
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.log('[billboard] попал в табличку id='
|
|
||||||
+ bpick.pickedMesh.metadata.primitiveId + ' но нет UV');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// В pointer-lock (1-е лицо) курсор скрыт в центре — пикаем центром.
|
|
||||||
// В 3-м лице (свободный курсор) — пикаем по реальным координатам клика.
|
|
||||||
const locked = (document.pointerLockElement === this.canvas);
|
|
||||||
let pick;
|
|
||||||
if (!locked && Number.isFinite(clickX) && Number.isFinite(clickY)) {
|
|
||||||
const pi = this.scene.pick(clickX, clickY, (mesh) => {
|
|
||||||
if (!mesh.isPickable) return false;
|
|
||||||
if (mesh.name && mesh.name.startsWith('gridLine')) return false;
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
if (pi?.hit) {
|
|
||||||
let m = pi.pickedMesh;
|
|
||||||
if (m?.metadata?._isBlockProto && this.blockManager) {
|
|
||||||
const proxy = this.blockManager.findProxyByPickInfo?.(pi);
|
|
||||||
if (proxy) m = proxy;
|
|
||||||
}
|
|
||||||
pick = { mesh: m, point: pi.pickedPoint, pickInfo: pi };
|
|
||||||
} else {
|
|
||||||
pick = null;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
pick = this._pickFromCenter();
|
|
||||||
}
|
|
||||||
const target = pick?.mesh ? this._meshToTarget(pick.mesh) : null;
|
const target = pick?.mesh ? this._meshToTarget(pick.mesh) : null;
|
||||||
const point = pick?.point ? { x: pick.point.x, y: pick.point.y, z: pick.point.z } : null;
|
const point = pick?.point ? { x: pick.point.x, y: pick.point.y, z: pick.point.z } : null;
|
||||||
// 1) Self-onClick — только если target есть
|
// 1) Self-onClick — только если target есть
|
||||||
@ -5364,11 +5120,6 @@ export class BabylonScene {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Изменить позицию выделенного (используется Inspector). */
|
/** Изменить позицию выделенного (используется Inspector). */
|
||||||
// ── Небо (задача 16) — обёртки для game-API ──────────────────────────
|
|
||||||
setSkybox(opts) { this.skybox?.setSkybox(opts); }
|
|
||||||
setClouds(opts) { this.skybox?.setClouds(opts); }
|
|
||||||
setSkyFog(opts) { this.skybox?.setFog(opts); }
|
|
||||||
|
|
||||||
moveSelectedTo(x, y, z) {
|
moveSelectedTo(x, y, z) {
|
||||||
if (!this.selection) return;
|
if (!this.selection) return;
|
||||||
const sel = this.selection.getSelection();
|
const sel = this.selection.getSelection();
|
||||||
@ -5473,56 +5224,6 @@ export class BabylonScene {
|
|||||||
return this._isPlaying;
|
return this._isPlaying;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Задача 12+05: конфиг экрана загрузки из настроек проекта. */
|
|
||||||
setLoadingConfig(cfg, thumbnail) {
|
|
||||||
if (cfg && typeof cfg === 'object') {
|
|
||||||
this._loadingConfig = {
|
|
||||||
logo: cfg.logo || null,
|
|
||||||
accentColor: cfg.accentColor || '#ffc020',
|
|
||||||
defaultSpinner: cfg.defaultSpinner !== false,
|
|
||||||
defaultSkipButton: !!cfg.defaultSkipButton,
|
|
||||||
// Задача 05:
|
|
||||||
enabled: cfg.enabled !== false,
|
|
||||||
background: cfg.background || cfg.backgroundUrl || null,
|
|
||||||
cover: cfg.cover || cfg.coverUrl || null,
|
|
||||||
style: cfg.style || 'ken-burns',
|
|
||||||
placeName: cfg.placeName || '',
|
|
||||||
studioName: cfg.studioName || '',
|
|
||||||
verified: !!cfg.verified,
|
|
||||||
duration: Number.isFinite(cfg.duration) && cfg.duration > 0 ? Number(cfg.duration) : 2.5,
|
|
||||||
progressBar: cfg.progressBar !== false,
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
this._loadingConfig = null;
|
|
||||||
}
|
|
||||||
if (thumbnail !== undefined) this._projectThumbnail = thumbnail || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Задача 05: стартовый экран загрузки при входе в Play (Ken-Burns + название места). */
|
|
||||||
showStartupLoadingScreen() {
|
|
||||||
const cfg = this._loadingConfig;
|
|
||||||
if (!cfg || cfg.enabled === false) return;
|
|
||||||
if (!this.gameRuntime) return;
|
|
||||||
try {
|
|
||||||
const ls = this.gameRuntime._ensureLoadingScreen?.();
|
|
||||||
if (!ls) return;
|
|
||||||
ls.show({
|
|
||||||
style: cfg.style,
|
|
||||||
background: cfg.background || cfg.cover || this._projectThumbnail,
|
|
||||||
cover: cfg.cover || this._projectThumbnail,
|
|
||||||
placeName: cfg.placeName || this._projectName || '',
|
|
||||||
studioName: cfg.studioName || '',
|
|
||||||
verified: cfg.verified,
|
|
||||||
duration: cfg.duration,
|
|
||||||
progressBar: cfg.progressBar,
|
|
||||||
spinner: true,
|
|
||||||
bgColor: '#070a14',
|
|
||||||
pauseSimulation: false,
|
|
||||||
blockInput: true,
|
|
||||||
});
|
|
||||||
} catch (e) { /* ignore */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Переключить в режим игры. Создаём PlayerController, прячем ghost-блок,
|
* Переключить в режим игры. Создаём PlayerController, прячем ghost-блок,
|
||||||
* запоминаем позицию редактор-камеры чтобы вернуть при exit.
|
* запоминаем позицию редактор-камеры чтобы вернуть при exit.
|
||||||
@ -5530,9 +5231,6 @@ export class BabylonScene {
|
|||||||
enterPlayMode() {
|
enterPlayMode() {
|
||||||
if (this._isPlaying) return;
|
if (this._isPlaying) return;
|
||||||
this._isPlaying = true;
|
this._isPlaying = true;
|
||||||
// Сброс состояния касаний — каждый прогон начинается «не касаясь».
|
|
||||||
if (this._touchState) this._touchState.clear();
|
|
||||||
this._playerMenuOpen = false; // меню-оверлей закрыт на старте Play
|
|
||||||
// По умолчанию стандартный HUD видим в Play.
|
// По умолчанию стандартный HUD видим в Play.
|
||||||
// Скрипт может скрыть через game.hud.setVisible(false).
|
// Скрипт может скрыть через game.hud.setVisible(false).
|
||||||
this._setStdHudVisible(true);
|
this._setStdHudVisible(true);
|
||||||
@ -5568,11 +5266,6 @@ export class BabylonScene {
|
|||||||
// Создаём PlayerController и стартуем
|
// Создаём PlayerController и стартуем
|
||||||
this.player = new PlayerController(this.scene, this.canvas, this.physics, this);
|
this.player = new PlayerController(this.scene, this.canvas, this.physics, this);
|
||||||
this.player.setModelType(this._playerModelType);
|
this.player.setModelType(this._playerModelType);
|
||||||
// Задача 04: модал нуждается в player и audio для блока ввода/freeze камеры/duck
|
|
||||||
try {
|
|
||||||
this.modalManager?.attachPlayer?.(this.player);
|
|
||||||
this.modalManager?.attachAudio?.(this.audioManager);
|
|
||||||
} catch (e) {}
|
|
||||||
this.player._jumpPowerMul = this._jumpPowerMul ?? 1;
|
this.player._jumpPowerMul = this._jumpPowerMul ?? 1;
|
||||||
// Применяем дефолтную камеру если задана в сцене
|
// Применяем дефолтную камеру если задана в сцене
|
||||||
if (this._defaultCameraMode && this._defaultCameraMode !== this.player._cameraMode) {
|
if (this._defaultCameraMode && this._defaultCameraMode !== this.player._cameraMode) {
|
||||||
@ -5581,63 +5274,17 @@ export class BabylonScene {
|
|||||||
// На тач-устройствах отключаем pointer-lock и mouse-камеру
|
// На тач-устройствах отключаем pointer-lock и mouse-камеру
|
||||||
if (this._touchMode) this.player.setTouchMode(true);
|
if (this._touchMode) this.player.setTouchMode(true);
|
||||||
this.player.setOnExitRequest(() => {
|
this.player.setOnExitRequest(() => {
|
||||||
// Задача 07: магазин скинов открыт → Esc закрывает его (приоритет выше модала).
|
|
||||||
if (this._skinShop?.open) {
|
|
||||||
this._closeSkinShop();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Задача 04: если открыт модал — первый Esc закрывает его,
|
|
||||||
// второй Esc уже выходит из Play. Так юзер не теряет состояние игры
|
|
||||||
// случайно при попытке скрыть модал.
|
|
||||||
if (this.modalManager?.isOpen?.()) {
|
|
||||||
this.modalManager.close();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// ESC в плеере = TOGGLE меню-оверлея поверх ЖИВОЙ игры (как в Roblox).
|
|
||||||
// Единый источник истины — _playerMenuOpen в движке. Раньше состояние
|
|
||||||
// меню держал React, а ESC слушали ДВА обработчика (движок + React) →
|
|
||||||
// гонка: меню открывалось поверх меню, а _uiCursorMode застревал в true
|
|
||||||
// → orbit-камера по ПКМ переставала работать после закрытия меню.
|
|
||||||
// Теперь движок сам решает open/close и шлёт это в _onEscMenu(open).
|
|
||||||
if (typeof this._onEscMenu === 'function') {
|
|
||||||
if (this._playerMenuOpen) {
|
|
||||||
// Меню открыто → ESC закрывает: вернуть мышь в игру.
|
|
||||||
this._playerMenuOpen = false;
|
|
||||||
this.player?.setUiCursorMode?.(false);
|
|
||||||
this._onEscMenu(false);
|
|
||||||
} else {
|
|
||||||
// Меню закрыто → ESC открывает: освободить курсор.
|
|
||||||
this._playerMenuOpen = true;
|
|
||||||
this.player?.setUiCursorMode?.(true);
|
|
||||||
this._onEscMenu(true);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Фолбэк (если меню не подписано, напр. в студии) — старое поведение.
|
|
||||||
this.exitPlayMode();
|
this.exitPlayMode();
|
||||||
if (this._onPlayChange) this._onPlayChange(false);
|
if (this._onPlayChange) this._onPlayChange(false);
|
||||||
});
|
});
|
||||||
if (this._onPlayerHpChange) this.player.setOnHpChange(this._onPlayerHpChange);
|
if (this._onPlayerHpChange) this.player.setOnHpChange(this._onPlayerHpChange);
|
||||||
if (this._onPlayerDeath) this.player.setOnDeath(this._onPlayerDeath);
|
if (this._onPlayerDeath) this.player.setOnDeath(this._onPlayerDeath);
|
||||||
// Точка спавна удалена → игрок появляется в (0, безопасная высота, 0).
|
this.player.start(this._spawnPoint);
|
||||||
let startPoint = this._spawnPoint;
|
|
||||||
if (this._spawnEnabled === false) {
|
|
||||||
let sy = 3;
|
|
||||||
try {
|
|
||||||
const surf = this.physics?._sampleRobloxSurface?.(0, 0);
|
|
||||||
if (surf !== null && surf !== undefined) sy = surf + 2;
|
|
||||||
} catch (e) { /* ignore */ }
|
|
||||||
startPoint = { x: 0, y: sy, z: 0 };
|
|
||||||
}
|
|
||||||
this.player.start(startPoint);
|
|
||||||
|
|
||||||
// Запускаем пользовательские скрипты (этап 2.1).
|
// Запускаем пользовательские скрипты (этап 2.1).
|
||||||
// Async PlayerController.start даёт нам тик чтобы дать ему собрать сцену,
|
// Async PlayerController.start даёт нам тик чтобы дать ему собрать сцену,
|
||||||
// поэтому скрипты стартуем в следующем кадре.
|
// поэтому скрипты стартуем в следующем кадре.
|
||||||
this.gameRuntime = new GameRuntime(this);
|
this.gameRuntime = new GameRuntime(this);
|
||||||
try { this.modalManager?.attachRuntime?.(this.gameRuntime); } catch (e) {}
|
|
||||||
// Задача 05: стартовый экран загрузки (Ken-Burns + название места).
|
|
||||||
try { this.showStartupLoadingScreen(); } catch (e) {}
|
|
||||||
// Аудио-менеджер (GD-музыка/SFX) — создаётся вместе с runtime.
|
// Аудио-менеджер (GD-музыка/SFX) — создаётся вместе с runtime.
|
||||||
// ВАЖНО: поле называется gameAudioManager чтобы не путать со штатным
|
// ВАЖНО: поле называется gameAudioManager чтобы не путать со штатным
|
||||||
// this.audioManager (AudioManager — ambient/music для всех проектов).
|
// this.audioManager (AudioManager — ambient/music для всех проектов).
|
||||||
@ -5781,24 +5428,9 @@ export class BabylonScene {
|
|||||||
if (this._onScriptCrosshair) this.gameRuntime.setOnCrosshairChange(this._onScriptCrosshair);
|
if (this._onScriptCrosshair) this.gameRuntime.setOnCrosshairChange(this._onScriptCrosshair);
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
console.log('[BabylonScene] enterPlayMode — scripts to run:', this._scripts?.length || 0, this._scripts);
|
console.log('[BabylonScene] enterPlayMode — scripts to run:', this._scripts?.length || 0, this._scripts);
|
||||||
// Задача 20: смонтировать HUD лидербордов/достижений если определения уже
|
|
||||||
// загружены из проекта (define из project_data при load).
|
|
||||||
try { if (this.leaderstats?.active) this.leaderstats._mount(); } catch (e) {}
|
|
||||||
try { if (this.achievements?.active) this.achievements._mountButton(); } catch (e) {}
|
|
||||||
try {
|
|
||||||
if (this.invUI && (this.invUI.defs.size > 0 || this.invUI.hotbar.some(Boolean) || this.invUI.grid.some(Boolean))) {
|
|
||||||
this.invUI.mountHotbar();
|
|
||||||
}
|
|
||||||
} catch (e) {}
|
|
||||||
// Старт через requestAnimationFrame — даём Babylon собрать сцену
|
// Старт через requestAnimationFrame — даём Babylon собрать сцену
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
if (this._isPlaying) this.gameRuntime?.start(this._scripts || []);
|
if (this._isPlaying) this.gameRuntime?.start(this._scripts || []);
|
||||||
// Задача 20: подгрузить сохранённый прогресс игрока из БД ПОСЛЕ define().
|
|
||||||
setTimeout(() => {
|
|
||||||
if (!this._isPlaying) return;
|
|
||||||
try { this.achievements?.loadFromDB?.(); } catch (e) {}
|
|
||||||
try { this.leaderstats?.loadFromDB?.(); } catch (e) {}
|
|
||||||
}, 250);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// === Оружие ===
|
// === Оружие ===
|
||||||
@ -5809,10 +5441,6 @@ export class BabylonScene {
|
|||||||
if (hit?.mesh && this.zombieManager) {
|
if (hit?.mesh && this.zombieManager) {
|
||||||
this.zombieManager.damageByMesh(hit.mesh, hit.damage || 25);
|
this.zombieManager.damageByMesh(hit.mesh, hit.damage || 25);
|
||||||
}
|
}
|
||||||
// Урон скриптовым NPC (киты-враги) → авто-floater над мобом (задача 40).
|
|
||||||
if (hit?.mesh && this.npcManager) {
|
|
||||||
try { this.npcManager.damageByMesh(hit.mesh, hit.damage || 25); } catch (e) {}
|
|
||||||
}
|
|
||||||
if (this._onWeaponHit) {
|
if (this._onWeaponHit) {
|
||||||
try { this._onWeaponHit(hit); } catch (e) {}
|
try { this._onWeaponHit(hit); } catch (e) {}
|
||||||
}
|
}
|
||||||
@ -5836,8 +5464,6 @@ export class BabylonScene {
|
|||||||
// === Лучи и следы (Фаза 5.2 — Beam/Trail) ===
|
// === Лучи и следы (Фаза 5.2 — Beam/Trail) ===
|
||||||
if (!this.beamManager) this.beamManager = new BeamManager(this);
|
if (!this.beamManager) this.beamManager = new BeamManager(this);
|
||||||
this.beamManager.start();
|
this.beamManager.start();
|
||||||
// Задача 08: активируем pointer-примитивы из палитры в реальные стрелки.
|
|
||||||
this._activatePointers();
|
|
||||||
|
|
||||||
// === 3D-звук (Фаза 5.5 — позиционный звук) ===
|
// === 3D-звук (Фаза 5.5 — позиционный звук) ===
|
||||||
if (!this.soundManager) this.soundManager = new SoundManager(this);
|
if (!this.soundManager) this.soundManager = new SoundManager(this);
|
||||||
@ -6152,7 +5778,6 @@ export class BabylonScene {
|
|||||||
if (!sc) return false;
|
if (!sc) return false;
|
||||||
if (!this.gameRuntime) {
|
if (!this.gameRuntime) {
|
||||||
this.gameRuntime = new GameRuntime(this);
|
this.gameRuntime = new GameRuntime(this);
|
||||||
try { this.modalManager?.attachRuntime?.(this.gameRuntime); } catch (e) {}
|
|
||||||
if (!this.gameAudioManager) {
|
if (!this.gameAudioManager) {
|
||||||
this.gameAudioManager = new GameAudioManager();
|
this.gameAudioManager = new GameAudioManager();
|
||||||
}
|
}
|
||||||
@ -6228,32 +5853,6 @@ export class BabylonScene {
|
|||||||
this._onPlayChange = cb;
|
this._onPlayChange = cb;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Колбэк «ESC в Play» для плеера: открыть меню-оверлей поверх живой игры
|
|
||||||
* БЕЗ выхода из Play. Если подписан — ESC не делает exitPlayMode (см.
|
|
||||||
* setOnExitRequest в enterPlayMode). В студии не подписывается → там ESC
|
|
||||||
* по-прежнему выходит из Play.
|
|
||||||
*/
|
|
||||||
setOnEscMenu(cb) {
|
|
||||||
this._onEscMenu = cb;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Синхронизация состояния меню-оверлея из UI (React). Когда меню закрывают
|
|
||||||
* НЕ через ESC (кнопка «Продолжить»/крестик/клик по фону), UI обязан сообщить
|
|
||||||
* движку — иначе _playerMenuOpen рассинхронизируется и следующий ESC решит,
|
|
||||||
* что меню «открыто», и не откроет его. open=false также возвращает мышь в игру.
|
|
||||||
*/
|
|
||||||
setPlayerMenuOpen(open) {
|
|
||||||
const v = !!open;
|
|
||||||
if (this._playerMenuOpen === v) return;
|
|
||||||
this._playerMenuOpen = v;
|
|
||||||
if (!v) {
|
|
||||||
// меню закрыли из UI → вернуть управление камерой/мышью
|
|
||||||
try { this.player?.setUiCursorMode?.(false); } catch (e) { /* ignore */ }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Колбэк изменения сцены (любая модификация блоков/моделей).
|
* Колбэк изменения сцены (любая модификация блоков/моделей).
|
||||||
* Используется KubikonEditor для dirty-tracking → auto-save.
|
* Используется KubikonEditor для dirty-tracking → auto-save.
|
||||||
@ -6301,18 +5900,6 @@ export class BabylonScene {
|
|||||||
try { this._onStdHudVisibilityChange?.(this._stdHudVisible); } catch (e) {}
|
try { this._onStdHudVisibilityChange?.(this._stdHudVisible); } catch (e) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Скрыть/показать только хотбар (5 слотов инвентаря снизу). */
|
|
||||||
_setHotbarVisible(visible) {
|
|
||||||
this._hotbarVisible = !!visible;
|
|
||||||
try { this._onHotbarVisibilityChange?.(this._hotbarVisible); } catch (e) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Скрыть/показать только HP-индикатор (полоска жизней). */
|
|
||||||
_setHpVisible(visible) {
|
|
||||||
this._hpVisible = !!visible;
|
|
||||||
try { this._onHpVisibilityChange?.(this._hpVisible); } catch (e) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Колбэк смены cursor-режима (ui/game) скриптом через game.input.setCursorMode.
|
/** Колбэк смены cursor-режима (ui/game) скриптом через game.input.setCursorMode.
|
||||||
* Редактор подписан чтобы синхронизировать React-state uiCursorMode (для бейджа). */
|
* Редактор подписан чтобы синхронизировать React-state uiCursorMode (для бейджа). */
|
||||||
setOnCursorModeChange(cb) {
|
setOnCursorModeChange(cb) {
|
||||||
@ -6437,71 +6024,6 @@ export class BabylonScene {
|
|||||||
return this.guiManager ? this.guiManager.getAll() : [];
|
return this.guiManager ? this.guiManager.getAll() : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== Задача 07: встроенный магазин скинов (React-оверлей) =====
|
|
||||||
// Состояние держим тут; React-компонент SkinShopOverlay поллит getSkinShopState().
|
|
||||||
_ensureSkinShopState() {
|
|
||||||
if (!this._skinShop) {
|
|
||||||
this._skinShop = {
|
|
||||||
open: false,
|
|
||||||
rev: 0, // ревизия — React видит изменение
|
|
||||||
data: { all: [], unlocked: [], current: null, coins: 0, manifestFull: [] },
|
|
||||||
buyResult: null, // последний результат покупки {slug, ok, reason}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return this._skinShop;
|
|
||||||
}
|
|
||||||
/** Снимок состояния магазина для React (поллинг через rAF). */
|
|
||||||
getSkinShopState() { return this._skinShop || null; }
|
|
||||||
/** Открыть/закрыть магазин (из скрипта или клавиши B). */
|
|
||||||
_openSkinShop() {
|
|
||||||
const s = this._ensureSkinShopState();
|
|
||||||
// Отключён в проекте? (скрипт всё равно может открыть через API —
|
|
||||||
// shopVisible:false запрещает только клавишу B, см. toggleSkinShop).
|
|
||||||
s.open = true; s.rev++;
|
|
||||||
}
|
|
||||||
_closeSkinShop() {
|
|
||||||
const s = this._ensureSkinShopState();
|
|
||||||
s.open = false; s.rev++;
|
|
||||||
}
|
|
||||||
toggleSkinShop() {
|
|
||||||
const s = this._ensureSkinShopState();
|
|
||||||
if (s.open) { this._closeSkinShop(); return; }
|
|
||||||
// Клавиша B открывает магазин только если он включён в проекте.
|
|
||||||
if (this._skinsConfig && this._skinsConfig.shopVisible === false) return;
|
|
||||||
this._openSkinShop();
|
|
||||||
}
|
|
||||||
/** Данные скинов от GameRuntime (манифест + unlocked + coins). */
|
|
||||||
_setSkinShopData(data) {
|
|
||||||
const s = this._ensureSkinShopState();
|
|
||||||
s.data = { ...s.data, ...data };
|
|
||||||
s.rev++;
|
|
||||||
}
|
|
||||||
_onSkinBuyResult(res) {
|
|
||||||
const s = this._ensureSkinShopState();
|
|
||||||
s.buyResult = { ...res, t: (this.scene?.getEngine?.()?.getFps?.() || 0) };
|
|
||||||
s.rev++;
|
|
||||||
}
|
|
||||||
/** Намерение купить/надеть скин — шлём в runtime (он списывает рублики). */
|
|
||||||
requestBuySkin(slug, price) {
|
|
||||||
const rt = this.gameRuntime;
|
|
||||||
if (!rt) return;
|
|
||||||
try { rt._handleCommand?.(null, 'player.buySkin', { slug, price: price || 0 }); } catch (e) {}
|
|
||||||
}
|
|
||||||
/** Данные из загруженных проектом кастомных .glb-скинов (data-URL по slug). */
|
|
||||||
getAssetDataUrl(slug) {
|
|
||||||
try {
|
|
||||||
// Кастомные скины хранят dataUrl прямо в _skinsConfig.customGlbs.
|
|
||||||
const list = this._skinsConfig?.customGlbs || [];
|
|
||||||
const rec = list.find(g => g && g.slug === slug);
|
|
||||||
if (rec && rec.dataUrl) return rec.dataUrl;
|
|
||||||
} catch (e) {}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
_onPlayerSkinChanged(slug) {
|
|
||||||
const s = this._ensureSkinShopState();
|
|
||||||
if (s.data) { s.data.current = slug; s.rev++; }
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== Библиотека пользовательских картинок (этап 3.6) =====
|
// ===== Библиотека пользовательских картинок (этап 3.6) =====
|
||||||
|
|
||||||
/** Список картинок проекта [{id, name, dataUrl}]. */
|
/** Список картинок проекта [{id, name, dataUrl}]. */
|
||||||
@ -7173,16 +6695,8 @@ export class BabylonScene {
|
|||||||
folders: this.folderManager ? this.folderManager.serialize() : [],
|
folders: this.folderManager ? this.folderManager.serialize() : [],
|
||||||
gui: this.guiManager ? this.guiManager.serialize() : [],
|
gui: this.guiManager ? this.guiManager.serialize() : [],
|
||||||
inventory: this.inventory ? this.inventory.serialize() : null,
|
inventory: this.inventory ? this.inventory.serialize() : null,
|
||||||
inventory2: this.invUI ? this.invUI.serialize() : null, // задача 44
|
|
||||||
spawnPoint: { ...this._spawnPoint },
|
spawnPoint: { ...this._spawnPoint },
|
||||||
playerModelType: this._playerModelType,
|
playerModelType: this._playerModelType,
|
||||||
skins: this._skinsConfig ? {
|
|
||||||
default: this._skinsConfig.default || null,
|
|
||||||
unlocked: this._skinsConfig.unlocked || [],
|
|
||||||
shopVisible: this._skinsConfig.shopVisible !== false,
|
|
||||||
coins: this._skinsConfig.coins || 0,
|
|
||||||
customGlbs: this._skinsConfig.customGlbs || [],
|
|
||||||
} : undefined,
|
|
||||||
worldSize: this._worldHalf * 2,
|
worldSize: this._worldHalf * 2,
|
||||||
floorEnabled: this._floorEnabled !== false,
|
floorEnabled: this._floorEnabled !== false,
|
||||||
jumpPowerMul: this._jumpPowerMul ?? 1,
|
jumpPowerMul: this._jumpPowerMul ?? 1,
|
||||||
@ -7190,9 +6704,6 @@ export class BabylonScene {
|
|||||||
crosshair: this._crosshair || 'dot',
|
crosshair: this._crosshair || 'dot',
|
||||||
shadowQuality: this._shadowQuality || 'soft',
|
shadowQuality: this._shadowQuality || 'soft',
|
||||||
environment: this.environment ? this.environment.serialize() : null,
|
environment: this.environment ? this.environment.serialize() : null,
|
||||||
skybox: this.skybox ? this.skybox.serialize() : null,
|
|
||||||
leaderstats: this.leaderstats ? this.leaderstats.serialize() : null,
|
|
||||||
achievements: this.achievements ? this.achievements.serialize() : null,
|
|
||||||
audio: this.audioManager ? this.audioManager.serialize() : null,
|
audio: this.audioManager ? this.audioManager.serialize() : null,
|
||||||
// Библиотека пользовательских картинок (текстуры/GUI-image).
|
// Библиотека пользовательских картинок (текстуры/GUI-image).
|
||||||
assets: this.assetManager ? this.assetManager.serialize() : [],
|
assets: this.assetManager ? this.assetManager.serialize() : [],
|
||||||
@ -7515,44 +7026,6 @@ export class BabylonScene {
|
|||||||
this._syncUserModelColliders();
|
this._syncUserModelColliders();
|
||||||
}
|
}
|
||||||
|
|
||||||
// === Тип модели персонажа — РЕШАЕМ ДО предзагрузки/плеера ===
|
|
||||||
// ВАЖНО: должно стоять ВЫШЕ _loadPrototype и до enterPlayMode, иначе
|
|
||||||
// PlayerController прочитает старый _playerModelType (баг: пончик 2046
|
|
||||||
// не ставился — skins.default применялся ниже, после предзагрузки).
|
|
||||||
// Миграция: старые проекты сохраняли Kenney-модель ('character-a..g');
|
|
||||||
// форсим R15 bacon-hair. Явно выбранные 'skin_*' не трогаем.
|
|
||||||
if (state.scene.playerModelType) {
|
|
||||||
const pmt = state.scene.playerModelType;
|
|
||||||
this._playerModelType = pmt.startsWith('character-') ? 'skin_bacon-hair' : pmt;
|
|
||||||
}
|
|
||||||
// Задача 07: конфиг скинов { default, unlocked, shopVisible, coins, customGlbs }.
|
|
||||||
if (state.scene.skins && typeof state.scene.skins === 'object') {
|
|
||||||
this._skinsConfig = {
|
|
||||||
default: state.scene.skins.default || null,
|
|
||||||
unlocked: Array.isArray(state.scene.skins.unlocked) ? state.scene.skins.unlocked.slice() : [],
|
|
||||||
shopVisible: state.scene.skins.shopVisible !== false,
|
|
||||||
coins: Number.isFinite(state.scene.skins.coins) ? state.scene.skins.coins : 0,
|
|
||||||
customGlbs: Array.isArray(state.scene.skins.customGlbs) ? state.scene.skins.customGlbs.slice() : [],
|
|
||||||
};
|
|
||||||
// Стартовый скин из skins.default имеет приоритет над playerModelType.
|
|
||||||
if (this._skinsConfig.default) {
|
|
||||||
const d = this._skinsConfig.default;
|
|
||||||
this._playerModelType = (d.startsWith('character-') || d.startsWith('skin_') || d.startsWith('customskin:'))
|
|
||||||
? d : ('skin_' + d);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
this._skinsConfig = null;
|
|
||||||
}
|
|
||||||
// Задача 12+05: конфиг экрана загрузки (через setLoadingConfig — единый маппинг).
|
|
||||||
if (state.scene.loadingScreen && typeof state.scene.loadingScreen === 'object') {
|
|
||||||
this.setLoadingConfig(state.scene.loadingScreen);
|
|
||||||
} else {
|
|
||||||
this._loadingConfig = null;
|
|
||||||
}
|
|
||||||
// Задача 13: конфиг главного меню (passthrough).
|
|
||||||
this._mainMenuConfig = (state.scene.mainMenu && typeof state.scene.mainMenu === 'object')
|
|
||||||
? state.scene.mainMenu : null;
|
|
||||||
|
|
||||||
// ОПТИМИЗАЦИЯ: предзагружаем модель ИГРОКА (character) тоже —
|
// ОПТИМИЗАЦИЯ: предзагружаем модель ИГРОКА (character) тоже —
|
||||||
// PlayerController.start() её ждёт, но если предзагрузить сейчас,
|
// PlayerController.start() её ждёт, но если предзагрузить сейчас,
|
||||||
// на enterPlayMode она будет в кэше Babylon и стартует мгновенно.
|
// на enterPlayMode она будет в кэше Babylon и стартует мгновенно.
|
||||||
@ -7584,9 +7057,6 @@ export class BabylonScene {
|
|||||||
if (this.inventory) {
|
if (this.inventory) {
|
||||||
this.inventory.loadFromArray(state.scene.inventory || null);
|
this.inventory.loadFromArray(state.scene.inventory || null);
|
||||||
}
|
}
|
||||||
if (this.invUI && state.scene.inventory2) { // задача 44
|
|
||||||
this.invUI.load(state.scene.inventory2);
|
|
||||||
}
|
|
||||||
// Простановка folderId на блоках (loadFromArray BlockManager не знает про это поле)
|
// Простановка folderId на блоках (loadFromArray BlockManager не знает про это поле)
|
||||||
if (this.blockManager && Array.isArray(state.scene.blocks)) {
|
if (this.blockManager && Array.isArray(state.scene.blocks)) {
|
||||||
for (const b of state.scene.blocks) {
|
for (const b of state.scene.blocks) {
|
||||||
@ -7630,10 +7100,8 @@ export class BabylonScene {
|
|||||||
// Точка спавна
|
// Точка спавна
|
||||||
if (state.scene.spawnPoint) {
|
if (state.scene.spawnPoint) {
|
||||||
this._spawnPoint = { ...state.scene.spawnPoint };
|
this._spawnPoint = { ...state.scene.spawnPoint };
|
||||||
this._updateSpawnMarker?.();
|
this._updateSpawnMarker();
|
||||||
}
|
}
|
||||||
// Удалена ли точка спавна (плеер: спавн в 0,0 при отсутствии).
|
|
||||||
this._spawnEnabled = state.scene.spawnEnabled !== false;
|
|
||||||
// === Авто-fix спавна для smooth terrain ===
|
// === Авто-fix спавна для smooth terrain ===
|
||||||
// Если есть RobloxTerrain и spawnPoint оказался НИЖЕ поверхности —
|
// Если есть RobloxTerrain и spawnPoint оказался НИЖЕ поверхности —
|
||||||
// поднимаем его, чтобы игрок не провалился в момент нажатия "Запустить".
|
// поднимаем его, чтобы игрок не провалился в момент нажатия "Запустить".
|
||||||
@ -7655,7 +7123,18 @@ export class BabylonScene {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) { console.warn('[BabylonScene] spawn auto-lift failed:', e); }
|
} catch (e) { console.warn('[BabylonScene] spawn auto-lift failed:', e); }
|
||||||
// (Тип модели персонажа и skins решены выше — до предзагрузки модели.)
|
// Тип модели персонажа.
|
||||||
|
// Миграция: старые проекты сохраняли Kenney-модель ('character-a..g').
|
||||||
|
// Теперь стандарт — R15-скин bacon-hair. Если в проекте старая
|
||||||
|
// Kenney-модель — форсим bacon-hair. Явно выбранные 'skin_*' не трогаем.
|
||||||
|
if (state.scene.playerModelType) {
|
||||||
|
const pmt = state.scene.playerModelType;
|
||||||
|
if (pmt.startsWith('character-')) {
|
||||||
|
this._playerModelType = 'skin_bacon-hair';
|
||||||
|
} else {
|
||||||
|
this._playerModelType = pmt;
|
||||||
|
}
|
||||||
|
}
|
||||||
// Пользовательские скрипты
|
// Пользовательские скрипты
|
||||||
if (Array.isArray(state.scene.scripts)) {
|
if (Array.isArray(state.scene.scripts)) {
|
||||||
this._scripts = state.scene.scripts
|
this._scripts = state.scene.scripts
|
||||||
@ -7671,22 +7150,6 @@ export class BabylonScene {
|
|||||||
if (state.scene.environment && this.environment) {
|
if (state.scene.environment && this.environment) {
|
||||||
this.environment.load(state.scene.environment);
|
this.environment.load(state.scene.environment);
|
||||||
}
|
}
|
||||||
// Графика/эффекты (шейдеры) — применяем если автор настроил и не 'off'.
|
|
||||||
if (state.scene.graphics && state.scene.graphics.preset
|
|
||||||
&& state.scene.graphics.preset !== 'off') {
|
|
||||||
try { this.setGraphics(state.scene.graphics); } catch (e) { /* ignore */ }
|
|
||||||
}
|
|
||||||
// Кастомное небо (задача 16)
|
|
||||||
if (state.scene.skybox && this.skybox) {
|
|
||||||
this.skybox.load(state.scene.skybox);
|
|
||||||
}
|
|
||||||
// Лидерборды и достижения (задача 20) — определения из проекта.
|
|
||||||
if (state.scene.leaderstats && this.leaderstats) {
|
|
||||||
this.leaderstats.load(state.scene.leaderstats);
|
|
||||||
}
|
|
||||||
if (state.scene.achievements && this.achievements) {
|
|
||||||
this.achievements.load(state.scene.achievements);
|
|
||||||
}
|
|
||||||
// Аудио (фоновая музыка/амбиент)
|
// Аудио (фоновая музыка/амбиент)
|
||||||
if (state.scene.audio && this.audioManager) {
|
if (state.scene.audio && this.audioManager) {
|
||||||
this.audioManager.load(state.scene.audio);
|
this.audioManager.load(state.scene.audio);
|
||||||
@ -7704,60 +7167,10 @@ export class BabylonScene {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Задача 08: активировать pointer-примитивы из палитры в реальные стрелки.
|
|
||||||
* Маркер-сфера прячется, через BeamManager создаётся анимированная стрелка
|
|
||||||
* (лента + парящий quest-marker) от источника к цели. from/to — из инспектора.
|
|
||||||
*/
|
|
||||||
_activatePointers() {
|
|
||||||
const pm = this.primitiveManager;
|
|
||||||
const bm = this.beamManager;
|
|
||||||
if (!pm || !bm) return;
|
|
||||||
for (const inst of pm.instances.values()) {
|
|
||||||
if (inst.type !== 'pointer') continue;
|
|
||||||
try { if (inst.mesh) inst.mesh.setEnabled(false); } catch (e) {}
|
|
||||||
const at = { x: inst.x, y: inst.y, z: inst.z };
|
|
||||||
const from = this._pointerRefOrPoint(inst.pointerFrom, at);
|
|
||||||
const to = this._pointerRefOrPoint(inst.pointerTo, { x: at.x, y: at.y, z: at.z + 4 });
|
|
||||||
try {
|
|
||||||
bm.addPointer({
|
|
||||||
from, to,
|
|
||||||
preset: inst.pointerPreset || 'guide',
|
|
||||||
color: inst.color, textureSpeed: inst.textureSpeed,
|
|
||||||
curved: inst.curved, curveHeight: inst.curveHeight,
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('[BabylonScene] addPointer failed:', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** from/to стрелки: 'player' | id примитива/модели → ref | точка-fallback. */
|
|
||||||
_pointerRefOrPoint(val, fallbackPoint) {
|
|
||||||
if (val === 'player') return 'player';
|
|
||||||
if (val != null && val !== '') {
|
|
||||||
const n = Number(val);
|
|
||||||
if (Number.isFinite(n)) {
|
|
||||||
if (this.primitiveManager?.instances?.has(n)) return 'primitive:' + n;
|
|
||||||
if (this.modelManager?.instances?.has(n)) return 'model:' + n;
|
|
||||||
}
|
|
||||||
if (typeof val === 'string'
|
|
||||||
&& (val.startsWith('primitive:') || val.startsWith('model:'))) return val;
|
|
||||||
}
|
|
||||||
return fallbackPoint;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Выйти из режима игры — восстановить редактор-камеру. */
|
/** Выйти из режима игры — восстановить редактор-камеру. */
|
||||||
exitPlayMode() {
|
exitPlayMode() {
|
||||||
if (!this._isPlaying) return;
|
if (!this._isPlaying) return;
|
||||||
this._isPlaying = false;
|
this._isPlaying = false;
|
||||||
// Задача 04: закрываем любой активный модал чтобы не «висел» при стопе
|
|
||||||
try { this.modalManager?._instantClose?.(); } catch (e) {}
|
|
||||||
// Задача 20: чистим рантайм лидербордов/достижений (определения остаются).
|
|
||||||
try { this.leaderstats?.resetRuntime?.(); } catch (e) {}
|
|
||||||
try { this.achievements?.resetRuntime?.(); } catch (e) {}
|
|
||||||
try { this.floaters?.resetRuntime?.(); } catch (e) {} // задача 40
|
|
||||||
try { this.invUI?.resetRuntime?.(); } catch (e) {} // задача 44
|
|
||||||
// Сбрасываем таймер прохождения
|
// Сбрасываем таймер прохождения
|
||||||
this._timerRunning = false;
|
this._timerRunning = false;
|
||||||
this._timerStartedAt = null;
|
this._timerStartedAt = null;
|
||||||
@ -7785,13 +7198,6 @@ export class BabylonScene {
|
|||||||
this.gameRuntime = null;
|
this.gameRuntime = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Placement mode (задача 11): сброс активной сессии + виджета магазина.
|
|
||||||
if (this.vehicleManager) { try { this.vehicleManager.dispose(); } catch (e) {} }
|
|
||||||
if (this.vehicleHud) { try { this.vehicleHud.dispose(); } catch (e) {} this.vehicleHud = null; }
|
|
||||||
if (this.placementManager) { try { this.placementManager.dispose(); } catch (e) {} this.placementManager = null; }
|
|
||||||
if (this.shopInventoryUi) { try { this.shopInventoryUi.dispose(); } catch (e) {} this.shopInventoryUi = null; }
|
|
||||||
if (this.loadingScreen) { try { this.loadingScreen.dispose(); } catch (e) {} this.loadingScreen = null; }
|
|
||||||
|
|
||||||
// Останавливаем GD-level manager (сбрасывает _shipMode/_ufoMode/... на player перед его удалением)
|
// Останавливаем GD-level manager (сбрасывает _shipMode/_ufoMode/... на player перед его удалением)
|
||||||
if (this.gdLevelManager) {
|
if (this.gdLevelManager) {
|
||||||
this.gdLevelManager.stop();
|
this.gdLevelManager.stop();
|
||||||
|
|||||||
@ -1,50 +1,30 @@
|
|||||||
/**
|
/**
|
||||||
* BeamManager — лучи (Beam), следы (Trail) и стрелки-указатели (Pointer)
|
* BeamManager — лучи (Beam) и следы (Trail) как объекты сцены (Фаза 5.2).
|
||||||
* как объекты сцены.
|
|
||||||
*
|
*
|
||||||
* Beam — светящаяся линия/лента между двумя точками. Точки могут быть
|
* Beam — светящаяся линия между двумя точками. Точки могут быть
|
||||||
* фиксированными координатами, ref объектов ИЛИ 'player' — тогда
|
* фиксированными координатами или ref объектов — тогда луч
|
||||||
* конец следует за объектом/игроком каждый кадр.
|
* следует за объектами каждый кадр (лазеры, мосты света,
|
||||||
* Trail — шлейф за движущимся объектом (Babylon TrailMesh).
|
* соединения, цепи).
|
||||||
* Pointer — высокоуровневая «стрелка иди сюда»: текстурированная лента
|
* Trail — шлейф, тянущийся за движущимся объектом (Babylon TrailMesh).
|
||||||
* (бегущие шевроны/стрелки), с пресетами, curved-дугой, градиентом.
|
|
||||||
*
|
*
|
||||||
* Задача 08: расширены опции addBeam (texture/textureSpeed/curved/colorSequence/
|
* Живут только в Play-режиме. Управляются скриптом через game.fx.* —
|
||||||
* faceMode/strokeColor/...) + game.fx.pointer. Живут только в Play-режиме
|
* каждый вызов возвращает прокси-объект.
|
||||||
* (и в превью редактора со скоростью анимации 0).
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import {
|
||||||
MeshBuilder, StandardMaterial, Color3, Color4, Vector3,
|
MeshBuilder, StandardMaterial, Color3, Vector3,
|
||||||
DynamicTexture, Texture, VertexBuffer, TransformNode,
|
|
||||||
} from '@babylonjs/core';
|
} from '@babylonjs/core';
|
||||||
import { TrailMesh } from '@babylonjs/core/Meshes/trailMesh';
|
import { TrailMesh } from '@babylonjs/core/Meshes/trailMesh';
|
||||||
|
|
||||||
let _fxIdSeq = 1;
|
let _fxIdSeq = 1;
|
||||||
|
|
||||||
// Кэш сгенерированных текстур по ключу (форма+обводка) — одна на сцену.
|
|
||||||
// Текстуры белые (рисуются белым), реальный цвет даёт mat.emissiveColor/diffuse.
|
|
||||||
const _texCache = new Map();
|
|
||||||
|
|
||||||
// Встроенные пресеты для game.fx.pointer — разворачиваются в опции beam.
|
|
||||||
const POINTER_PRESETS = {
|
|
||||||
guide: { texture: 'chevron', color: '#ff3a3a', strokeColor: '#000000', strokeWidth: 4, textureSpeed: 3, width: 0.9, textureScale: 1 },
|
|
||||||
quest: { texture: 'chevron', color: '#ffd23a', strokeColor: '#5a3a00', strokeWidth: 3, textureSpeed: 3.5, width: 0.9, textureScale: 1 },
|
|
||||||
danger: { texture: 'lightning', color: '#ff2a2a', strokeColor: '#3a0000', strokeWidth: 2, textureSpeed: 5, width: 1.0, textureScale: 1.2 },
|
|
||||||
gift: { texture: 'sparkle', color: '#ffffff', strokeColor: null, strokeWidth: 0, textureSpeed: 2, width: 1.0, textureScale: 1.4,
|
|
||||||
colorSequence: [{ p: 0, c: '#ff5a5a' }, { p: 0.25, c: '#ffd23a' }, { p: 0.5, c: '#5aff7a' }, { p: 0.75, c: '#3a9aff' }, { p: 1, c: '#c45aff' }] },
|
|
||||||
};
|
|
||||||
|
|
||||||
export class BeamManager {
|
export class BeamManager {
|
||||||
constructor(scene3d) {
|
constructor(scene3d) {
|
||||||
this.scene3d = scene3d;
|
this.scene3d = scene3d;
|
||||||
this.scene = scene3d.scene;
|
this.scene = scene3d.scene;
|
||||||
/** @type {Map<number, object>} id → fx state (beam | trail | pointer) */
|
/** @type {Map<number, object>} id → fx state (beam | trail) */
|
||||||
this.items = new Map();
|
this.items = new Map();
|
||||||
this._renderHook = null;
|
this._renderHook = null;
|
||||||
this._lastTime = 0;
|
|
||||||
// В превью редактора (не Play) анимацию текстур замораживаем.
|
|
||||||
this.animationEnabled = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
start() {
|
start() {
|
||||||
@ -66,304 +46,59 @@ export class BeamManager {
|
|||||||
try {
|
try {
|
||||||
if (it.mesh) it.mesh.dispose();
|
if (it.mesh) it.mesh.dispose();
|
||||||
if (it.mat) it.mat.dispose();
|
if (it.mat) it.mat.dispose();
|
||||||
// Парящий quest-marker: root (TransformNode) с дочерним конусом —
|
|
||||||
// dispose с потомками убирает и конус, и его outline.
|
|
||||||
if (it._markerMesh) it._markerMesh.dispose();
|
|
||||||
if (it._marker) it._marker.dispose();
|
|
||||||
if (it._markerMat) it._markerMat.dispose();
|
|
||||||
} catch (e) { /* ignore */ }
|
} catch (e) { /* ignore */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===================================================================
|
|
||||||
// ТЕКСТУРЫ ЛУЧА — генерируются на лету через Canvas2D (без PNG-файлов).
|
|
||||||
// Все белые с альфа-каналом; цвет даёт материал. strokeColor рисуется
|
|
||||||
// под основной формой.
|
|
||||||
// ===================================================================
|
|
||||||
|
|
||||||
/** Получить (с кэшем) DynamicTexture для формы. */
|
|
||||||
_getBeamTexture(shape, strokeColor, strokeWidth) {
|
|
||||||
const key = shape + '|' + (strokeColor || '') + '|' + (strokeWidth || 0);
|
|
||||||
if (_texCache.has(key)) return _texCache.get(key);
|
|
||||||
const S = 128;
|
|
||||||
const dyn = new DynamicTexture('beamTex_' + key, { width: S, height: S }, this.scene, true);
|
|
||||||
dyn.hasAlpha = true;
|
|
||||||
dyn.wrapU = Texture.WRAP_ADDRESSMODE;
|
|
||||||
dyn.wrapV = Texture.CLAMP_ADDRESSMODE;
|
|
||||||
const ctx = dyn.getContext();
|
|
||||||
ctx.clearRect(0, 0, S, S);
|
|
||||||
this._drawShape(ctx, shape, S, strokeColor, strokeWidth);
|
|
||||||
dyn.update();
|
|
||||||
_texCache.set(key, dyn);
|
|
||||||
return dyn;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Рисует форму на canvas. Ориентация: текстура натягивается вдоль ленты
|
* Создать луч между двумя точками.
|
||||||
* так, что U идёт ПО длине (from→to). Шеврон рисуем «>» указывающим в
|
* opts: { from, to — {x,y,z} или ref-строка объекта;
|
||||||
* сторону +U (к цели).
|
* color: '#hex', width: толщина (м) }.
|
||||||
*/
|
|
||||||
_drawShape(ctx, shape, S, strokeColor, strokeWidth) {
|
|
||||||
const sw = Number(strokeWidth) || 0;
|
|
||||||
ctx.lineJoin = 'round';
|
|
||||||
ctx.lineCap = 'round';
|
|
||||||
const drawPath = (pathFn, fill) => {
|
|
||||||
// Обводка (под формой) — рисуем толще тем же путём.
|
|
||||||
if (strokeColor && sw > 0) {
|
|
||||||
ctx.beginPath(); pathFn();
|
|
||||||
ctx.strokeStyle = strokeColor;
|
|
||||||
ctx.lineWidth = sw * 2 + 14;
|
|
||||||
ctx.stroke();
|
|
||||||
}
|
|
||||||
ctx.beginPath(); pathFn();
|
|
||||||
if (fill) {
|
|
||||||
ctx.fillStyle = '#ffffff';
|
|
||||||
ctx.fill();
|
|
||||||
} else {
|
|
||||||
ctx.strokeStyle = '#ffffff';
|
|
||||||
ctx.lineWidth = 16;
|
|
||||||
ctx.stroke();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const m = S * 0.18; // отступ
|
|
||||||
const cx = S / 2, cy = S / 2;
|
|
||||||
switch (shape) {
|
|
||||||
case 'chevron': {
|
|
||||||
// «>» указывающий вправо (к цели вдоль +U)
|
|
||||||
drawPath(() => {
|
|
||||||
ctx.moveTo(m, m);
|
|
||||||
ctx.lineTo(S - m, cy);
|
|
||||||
ctx.lineTo(m, S - m);
|
|
||||||
}, false);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'arrow': {
|
|
||||||
// наконечник-треугольник вправо
|
|
||||||
drawPath(() => {
|
|
||||||
ctx.moveTo(m, m + 6);
|
|
||||||
ctx.lineTo(S - m, cy);
|
|
||||||
ctx.lineTo(m, S - m - 6);
|
|
||||||
ctx.closePath();
|
|
||||||
}, true);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'dot': {
|
|
||||||
drawPath(() => { ctx.arc(cx, cy, S * 0.22, 0, Math.PI * 2); }, true);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'line': {
|
|
||||||
ctx.fillStyle = '#ffffff';
|
|
||||||
if (strokeColor && sw > 0) {
|
|
||||||
ctx.fillStyle = strokeColor;
|
|
||||||
ctx.fillRect(0, cy - (S * 0.28) / 2 - sw, S, S * 0.28 + sw * 2);
|
|
||||||
ctx.fillStyle = '#ffffff';
|
|
||||||
}
|
|
||||||
ctx.fillRect(0, cy - (S * 0.28) / 2, S, S * 0.28);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'dash': {
|
|
||||||
ctx.fillStyle = '#ffffff';
|
|
||||||
ctx.fillRect(S * 0.12, cy - (S * 0.22) / 2, S * 0.76, S * 0.22);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'wave': {
|
|
||||||
drawPath(() => {
|
|
||||||
ctx.moveTo(0, cy);
|
|
||||||
for (let x = 0; x <= S; x += 4) {
|
|
||||||
ctx.lineTo(x, cy + Math.sin((x / S) * Math.PI * 2) * (S * 0.28));
|
|
||||||
}
|
|
||||||
}, false);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'lightning': {
|
|
||||||
drawPath(() => {
|
|
||||||
ctx.moveTo(m, m);
|
|
||||||
ctx.lineTo(cx + 6, cy - 6);
|
|
||||||
ctx.lineTo(cx - 6, cy + 6);
|
|
||||||
ctx.lineTo(S - m, S - m);
|
|
||||||
}, false);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'sparkle': {
|
|
||||||
// 4-лучевая звёздочка
|
|
||||||
drawPath(() => {
|
|
||||||
const r = S * 0.30, ri = S * 0.10;
|
|
||||||
for (let i = 0; i < 8; i++) {
|
|
||||||
const a = (i / 8) * Math.PI * 2 - Math.PI / 2;
|
|
||||||
const rad = (i % 2 === 0) ? r : ri;
|
|
||||||
const x = cx + Math.cos(a) * rad, y = cy + Math.sin(a) * rad;
|
|
||||||
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
|
|
||||||
}
|
|
||||||
ctx.closePath();
|
|
||||||
}, true);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
default: { // fallback — сплошная линия
|
|
||||||
ctx.fillStyle = '#ffffff';
|
|
||||||
ctx.fillRect(0, cy - (S * 0.28) / 2, S, S * 0.28);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===================================================================
|
|
||||||
// BEAM (расширенный — задача 08)
|
|
||||||
// ===================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Создать луч/ленту между двумя точками.
|
|
||||||
* opts: {
|
|
||||||
* from, to — {x,y,z} | ref-строка | 'player';
|
|
||||||
* color, width;
|
|
||||||
* texture: 'chevron'|'arrow'|'dot'|'line'|'wave'|'lightning'|'dash'|'sparkle'|'custom'|null;
|
|
||||||
* customTextureUrl, textureMode, textureSpeed, textureScale,
|
|
||||||
* strokeColor, strokeWidth,
|
|
||||||
* colorSequence:[{p,c}], transparencySequence:[{p,t}], widthSequence:[{p,w}],
|
|
||||||
* faceMode: 'billboard'|'flat-x'|'flat-y'|'flat-z',
|
|
||||||
* segments, curved, curveHeight, attachOffset:{fromY,toY},
|
|
||||||
* ignoreDepth,
|
|
||||||
* }
|
|
||||||
* Возвращает id.
|
* Возвращает id.
|
||||||
*/
|
*/
|
||||||
addBeam(opts = {}) {
|
addBeam(opts = {}) {
|
||||||
const id = _fxIdSeq++;
|
const id = _fxIdSeq++;
|
||||||
const hasTexture = !!opts.texture && opts.texture !== 'none';
|
const width = Number.isFinite(opts.width) ? opts.width : 0.15;
|
||||||
const it = {
|
const mat = new StandardMaterial('beamMat_' + id, this.scene);
|
||||||
id, type: 'beam',
|
const col = Color3.FromHexString(opts.color || '#66ccff');
|
||||||
from: opts.from, to: opts.to,
|
|
||||||
width: Number.isFinite(opts.width) ? opts.width : (hasTexture ? 0.8 : 0.15),
|
|
||||||
color: opts.color || '#66ccff',
|
|
||||||
texture: hasTexture ? opts.texture : null,
|
|
||||||
customTextureUrl: opts.customTextureUrl || null,
|
|
||||||
textureMode: opts.textureMode || 'wrap',
|
|
||||||
textureSpeed: Number.isFinite(opts.textureSpeed) ? opts.textureSpeed : 0,
|
|
||||||
textureScale: Number.isFinite(opts.textureScale) ? opts.textureScale : 1,
|
|
||||||
strokeColor: opts.strokeColor || null,
|
|
||||||
strokeWidth: Number.isFinite(opts.strokeWidth) ? opts.strokeWidth : 3,
|
|
||||||
colorSequence: Array.isArray(opts.colorSequence) ? opts.colorSequence : null,
|
|
||||||
transparencySequence: Array.isArray(opts.transparencySequence) ? opts.transparencySequence : null,
|
|
||||||
widthSequence: Array.isArray(opts.widthSequence) ? opts.widthSequence : null,
|
|
||||||
faceMode: opts.faceMode || 'billboard',
|
|
||||||
segments: Math.max(2, Number.isFinite(opts.segments) ? opts.segments : 24),
|
|
||||||
curved: !!opts.curved,
|
|
||||||
curveHeight: Number.isFinite(opts.curveHeight) ? opts.curveHeight : 2,
|
|
||||||
attachOffset: opts.attachOffset || { fromY: 0, toY: 0 },
|
|
||||||
ignoreDepth: opts.ignoreDepth !== false, // по умолчанию рисуем поверх
|
|
||||||
uOffset: 0,
|
|
||||||
mesh: null, mat: null,
|
|
||||||
};
|
|
||||||
this._buildBeamMaterial(it);
|
|
||||||
this.items.set(id, it);
|
|
||||||
this._updateBeam(it);
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
|
|
||||||
_buildBeamMaterial(it) {
|
|
||||||
if (it.mat) { try { it.mat.dispose(); } catch (e) {} }
|
|
||||||
const mat = new StandardMaterial('beamMat_' + it.id, this.scene);
|
|
||||||
const col = Color3.FromHexString(it.color);
|
|
||||||
mat.diffuseColor = col;
|
mat.diffuseColor = col;
|
||||||
mat.emissiveColor = col;
|
mat.emissiveColor = col;
|
||||||
mat.disableLighting = true;
|
mat.disableLighting = true;
|
||||||
mat.backFaceCulling = false;
|
// Цилиндр-заготовка единичной высоты — масштабируем под длину луча.
|
||||||
if (it.texture) {
|
const mesh = MeshBuilder.CreateCylinder('beam_' + id,
|
||||||
let tex;
|
{ height: 1, diameter: width, tessellation: 8 }, this.scene);
|
||||||
if (it.texture === 'custom' && it.customTextureUrl) {
|
mesh.material = mat;
|
||||||
tex = new Texture(it.customTextureUrl, this.scene);
|
mesh.isPickable = false;
|
||||||
tex.hasAlpha = true;
|
mesh.renderingGroupId = 1;
|
||||||
tex.wrapU = Texture.WRAP_ADDRESSMODE;
|
const it = {
|
||||||
tex.wrapV = Texture.CLAMP_ADDRESSMODE;
|
id, type: 'beam', mesh, mat,
|
||||||
} else {
|
from: opts.from, to: opts.to,
|
||||||
tex = this._getBeamTexture(it.texture, it.strokeColor, it.strokeWidth);
|
};
|
||||||
}
|
this.items.set(id, it);
|
||||||
mat.diffuseTexture = tex;
|
this._updateBeam(it); // сразу позиционируем
|
||||||
mat.emissiveTexture = tex;
|
return id;
|
||||||
mat.opacityTexture = tex; // альфа формы
|
|
||||||
mat.useAlphaFromDiffuseTexture = true;
|
|
||||||
}
|
|
||||||
mat.alpha = it.texture ? 1 : 0.9;
|
|
||||||
if (it.ignoreDepth) {
|
|
||||||
mat.disableDepthWrite = true;
|
|
||||||
// рисуем поверх геометрии
|
|
||||||
}
|
|
||||||
it.mat = mat;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Сменить цвет луча. */
|
/** Сменить цвет луча. */
|
||||||
setBeamColor(id, color) {
|
setBeamColor(id, color) {
|
||||||
const it = this.items.get(Number(id));
|
const it = this.items.get(Number(id));
|
||||||
if (!it || !it.mat || !color) return;
|
if (!it || it.type !== 'beam' || !it.mat) return;
|
||||||
it.color = color;
|
const col = Color3.FromHexString(color || '#66ccff');
|
||||||
const col = Color3.FromHexString(color);
|
|
||||||
it.mat.diffuseColor = col;
|
it.mat.diffuseColor = col;
|
||||||
it.mat.emissiveColor = col;
|
it.mat.emissiveColor = col;
|
||||||
this._recolorMarker(it, col);
|
|
||||||
// если есть градиент — он перекрывает; иначе vertexColors обновим в _updateBeam
|
|
||||||
it.colorSequence = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Перекрасить quest-marker в цвет луча (при смене пресета). */
|
/** Сменить концы луча (координаты или ref). */
|
||||||
_recolorMarker(it, col) {
|
|
||||||
if (it && it._markerMat && col) {
|
|
||||||
it._markerMat.diffuseColor = col;
|
|
||||||
it._markerMat.emissiveColor = col;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Сменить концы луча (координаты | ref | 'player'). */
|
|
||||||
setBeamEndpoints(id, from, to) {
|
setBeamEndpoints(id, from, to) {
|
||||||
const it = this.items.get(Number(id));
|
const it = this.items.get(Number(id));
|
||||||
if (!it) return;
|
if (!it || it.type !== 'beam') return;
|
||||||
if (from !== undefined) it.from = from;
|
if (from !== undefined) it.from = from;
|
||||||
if (to !== undefined) it.to = to;
|
if (to !== undefined) it.to = to;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Обновить произвольные опции луча на лету (для pointer.update). */
|
|
||||||
updateBeam(id, opts = {}) {
|
|
||||||
const it = this.items.get(Number(id));
|
|
||||||
if (!it) return;
|
|
||||||
let rebuild = false;
|
|
||||||
for (const k of ['texture', 'strokeColor', 'strokeWidth', 'customTextureUrl', 'ignoreDepth']) {
|
|
||||||
if (opts[k] !== undefined && opts[k] !== it[k]) { it[k] = opts[k]; rebuild = true; }
|
|
||||||
}
|
|
||||||
for (const k of ['color', 'width', 'textureMode', 'textureSpeed', 'textureScale',
|
|
||||||
'faceMode', 'segments', 'curved', 'curveHeight', 'attachOffset',
|
|
||||||
'colorSequence', 'transparencySequence', 'widthSequence']) {
|
|
||||||
if (opts[k] !== undefined) it[k] = opts[k];
|
|
||||||
}
|
|
||||||
if (opts.from !== undefined) it.from = opts.from;
|
|
||||||
if (opts.to !== undefined) it.to = opts.to;
|
|
||||||
if (rebuild) {
|
|
||||||
this._buildBeamMaterial(it);
|
|
||||||
} else if (opts.color !== undefined && !it.colorSequence) {
|
|
||||||
const col = Color3.FromHexString(it.color);
|
|
||||||
it.mat.diffuseColor = col; it.mat.emissiveColor = col;
|
|
||||||
}
|
|
||||||
// Перекрасить quest-marker под новый пресет. Для градиента (gift) —
|
|
||||||
// средний цвет последовательности.
|
|
||||||
if (it._markerMat) {
|
|
||||||
let mc = it.color;
|
|
||||||
if (it.colorSequence && it.colorSequence.length) {
|
|
||||||
mc = this._sampleSeqColor(it.colorSequence, 0.5) || it.color;
|
|
||||||
}
|
|
||||||
if (mc) this._recolorMarker(it, Color3.FromHexString(mc));
|
|
||||||
}
|
|
||||||
// геометрия (curved/segments/width) пересоберётся в _updateBeam — сбросим mesh
|
|
||||||
if (opts.curved !== undefined || opts.segments !== undefined
|
|
||||||
|| opts.width !== undefined || opts.widthSequence !== undefined
|
|
||||||
|| opts.colorSequence !== undefined) {
|
|
||||||
if (it.mesh) { try { it.mesh.dispose(); } catch (e) {} it.mesh = null; }
|
|
||||||
}
|
|
||||||
this._updateBeam(it);
|
|
||||||
}
|
|
||||||
|
|
||||||
setVisible(id, vis) {
|
|
||||||
const it = this.items.get(Number(id));
|
|
||||||
if (it && it.mesh) it.mesh.setEnabled(!!vis);
|
|
||||||
if (it && it._marker) it._marker.setEnabled(!!vis); // и маркер
|
|
||||||
if (it) it._hidden = !vis;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Создать шлейф за объектом. (без изменений — Фаза 5.2)
|
* Создать шлейф за объектом.
|
||||||
|
* ref — ref-строка объекта. opts: { color, width, lifetime (сек) }.
|
||||||
|
* Возвращает id.
|
||||||
*/
|
*/
|
||||||
addTrail(ref, opts = {}) {
|
addTrail(ref, opts = {}) {
|
||||||
const data = this._resolve(ref);
|
const data = this._resolve(ref);
|
||||||
@ -372,8 +107,11 @@ export class BeamManager {
|
|||||||
const id = _fxIdSeq++;
|
const id = _fxIdSeq++;
|
||||||
const width = Number.isFinite(opts.width) ? opts.width : 0.4;
|
const width = Number.isFinite(opts.width) ? opts.width : 0.4;
|
||||||
const lifetime = Number.isFinite(opts.lifetime) ? opts.lifetime : 1.5;
|
const lifetime = Number.isFinite(opts.lifetime) ? opts.lifetime : 1.5;
|
||||||
|
// TrailMesh(name, generator, scene, diameter, length, autoStart).
|
||||||
|
// length — сколько сегментов хранить; считаем из lifetime (≈60 fps).
|
||||||
const segments = Math.max(10, Math.round(lifetime * 60));
|
const segments = Math.max(10, Math.round(lifetime * 60));
|
||||||
const trail = new TrailMesh('trail_' + id, mesh, this.scene, width, segments, true);
|
const trail = new TrailMesh('trail_' + id, mesh, this.scene,
|
||||||
|
width, segments, true);
|
||||||
const mat = new StandardMaterial('trailMat_' + id, this.scene);
|
const mat = new StandardMaterial('trailMat_' + id, this.scene);
|
||||||
const col = Color3.FromHexString(opts.color || '#ffcc44');
|
const col = Color3.FromHexString(opts.color || '#ffcc44');
|
||||||
mat.diffuseColor = col;
|
mat.diffuseColor = col;
|
||||||
@ -388,7 +126,7 @@ export class BeamManager {
|
|||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Убрать луч/след/указатель по id. */
|
/** Убрать луч/след по id. */
|
||||||
remove(id) {
|
remove(id) {
|
||||||
const it = this.items.get(Number(id));
|
const it = this.items.get(Number(id));
|
||||||
if (!it) return;
|
if (!it) return;
|
||||||
@ -400,380 +138,41 @@ export class BeamManager {
|
|||||||
|
|
||||||
_tick() {
|
_tick() {
|
||||||
if (this.items.size === 0) return;
|
if (this.items.size === 0) return;
|
||||||
const now = performance.now() / 1000;
|
|
||||||
let dt = this._lastTime ? (now - this._lastTime) : 0.016;
|
|
||||||
this._lastTime = now;
|
|
||||||
if (dt > 0.1) dt = 0.016; // защита от больших скачков (вкладка спала)
|
|
||||||
for (const it of this.items.values()) {
|
for (const it of this.items.values()) {
|
||||||
if (it.type === 'trail') continue; // Babylon сам
|
// Trail обновляется самим Babylon (autoStart). Beam — мы.
|
||||||
if (it._hidden) continue;
|
if (it.type === 'beam') this._updateBeam(it);
|
||||||
this._updateBeam(it);
|
|
||||||
// Анимация бегущей текстуры.
|
|
||||||
if (this.animationEnabled && it.texture && it.textureSpeed
|
|
||||||
&& it.mat && it.mat.diffuseTexture && it.mat.diffuseTexture.uOffset !== undefined) {
|
|
||||||
it.uOffset -= it.textureSpeed * dt * 0.4;
|
|
||||||
it.mat.diffuseTexture.uOffset = it.uOffset;
|
|
||||||
}
|
|
||||||
// Парящий 3D-маркер над целью: позиция + подпрыгивание + вращение.
|
|
||||||
if (it._marker) this._tickMarker(it, now);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Анимировать quest-marker над целью (bob + spin), держать над to. */
|
|
||||||
_tickMarker(it, now) {
|
|
||||||
const root = it._marker;
|
|
||||||
if (!root) return;
|
|
||||||
// Цель: точка `to` (без attachOffset — нам нужна верхушка объекта).
|
|
||||||
const tgt = this._point(it.to, 0);
|
|
||||||
if (!tgt) { root.setEnabled(false); return; }
|
|
||||||
root.setEnabled(true);
|
|
||||||
const ph = it._markerPhase || 0;
|
|
||||||
// bob: плавный синус ±0.22м; высота над целью ~2.2м.
|
|
||||||
const bob = Math.sin(now * 3 + ph) * 0.22;
|
|
||||||
root.position.set(tgt.x, tgt.y + 2.2 + bob, tgt.z);
|
|
||||||
// Вращение всего маркера вокруг Y (дочерний конус остаётся остриём вниз).
|
|
||||||
root.rotation.y = now * 1.6 + ph;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Построить/обновить геометрию луча. Для текстурированных/curved —
|
|
||||||
* ribbon из сегментов с UV вдоль длины. Для простого — цилиндр (легаси).
|
|
||||||
*/
|
|
||||||
_updateBeam(it) {
|
_updateBeam(it) {
|
||||||
const a = this._point(it.from, it.attachOffset && it.attachOffset.fromY);
|
const a = this._point(it.from);
|
||||||
const b = this._point(it.to, it.attachOffset && it.attachOffset.toY);
|
const b = this._point(it.to);
|
||||||
if (!a || !b) {
|
if (!a || !b || !it.mesh) return;
|
||||||
if (it.mesh) it.mesh.setEnabled(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const dx = b.x - a.x, dy = b.y - a.y, dz = b.z - a.z;
|
const dx = b.x - a.x, dy = b.y - a.y, dz = b.z - a.z;
|
||||||
const len = Math.hypot(dx, dy, dz);
|
const len = Math.hypot(dx, dy, dz);
|
||||||
if (len < 0.01) { if (it.mesh) it.mesh.setEnabled(false); return; }
|
if (len < 0.001) { it.mesh.setEnabled(false); return; }
|
||||||
|
|
||||||
// Текстурированный/curved/градиент — лента (ribbon).
|
|
||||||
if (it.texture || it.curved || it.colorSequence || it.widthSequence) {
|
|
||||||
this._updateRibbon(it, a, b, len);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Простой луч — легаси цилиндр.
|
|
||||||
if (!it.mesh || it._meshKind !== 'cyl') {
|
|
||||||
if (it.mesh) { try { it.mesh.dispose(); } catch (e) {} }
|
|
||||||
it.mesh = MeshBuilder.CreateCylinder('beam_' + it.id,
|
|
||||||
{ height: 1, diameter: it.width, tessellation: 8 }, this.scene);
|
|
||||||
it.mesh.material = it.mat;
|
|
||||||
it.mesh.isPickable = false;
|
|
||||||
it.mesh.renderingGroupId = 1;
|
|
||||||
it._meshKind = 'cyl';
|
|
||||||
}
|
|
||||||
it.mesh.setEnabled(true);
|
it.mesh.setEnabled(true);
|
||||||
|
// Цилиндр единичной высоты вдоль локальной оси Y. Растягиваем по длине.
|
||||||
it.mesh.scaling.y = len;
|
it.mesh.scaling.y = len;
|
||||||
|
// Центр луча.
|
||||||
it.mesh.position.set((a.x + b.x) / 2, (a.y + b.y) / 2, (a.z + b.z) / 2);
|
it.mesh.position.set((a.x + b.x) / 2, (a.y + b.y) / 2, (a.z + b.z) / 2);
|
||||||
|
// Ориентируем ось Y цилиндра вдоль вектора a→b.
|
||||||
const dir = new Vector3(dx, dy, dz).normalize();
|
const dir = new Vector3(dx, dy, dz).normalize();
|
||||||
|
// yaw + pitch так, чтобы +Y смотрел вдоль dir.
|
||||||
const yaw = Math.atan2(dir.x, dir.z);
|
const yaw = Math.atan2(dir.x, dir.z);
|
||||||
const pitch = Math.acos(Math.max(-1, Math.min(1, dir.y)));
|
const pitch = Math.acos(Math.max(-1, Math.min(1, dir.y)));
|
||||||
it.mesh.rotation.set(pitch, yaw, 0);
|
it.mesh.rotation.set(pitch, yaw, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Точка из {x,y,z} или ref-строки объекта. */
|
||||||
* Построить ленту (ribbon): две линии вершин вдоль направления from→to,
|
_point(p) {
|
||||||
* смещённые перпендикулярно (billboard — к камере). UV: u вдоль длины
|
|
||||||
* (количество тайлов = длина/ширина × textureScale), v поперёк.
|
|
||||||
*/
|
|
||||||
_updateRibbon(it, a, b, len) {
|
|
||||||
const seg = it.curved ? it.segments : 1;
|
|
||||||
const A = new Vector3(a.x, a.y, a.z);
|
|
||||||
const B = new Vector3(b.x, b.y, b.z);
|
|
||||||
// центральная линия (curved = квадратичная Безье через приподнятый midpoint)
|
|
||||||
const center = [];
|
|
||||||
if (it.curved) {
|
|
||||||
const mid = A.add(B).scale(0.5);
|
|
||||||
mid.y += it.curveHeight;
|
|
||||||
for (let i = 0; i <= seg; i++) {
|
|
||||||
const t = i / seg;
|
|
||||||
const omt = 1 - t;
|
|
||||||
// B(t) = (1-t)^2 A + 2(1-t)t M + t^2 B
|
|
||||||
const x = omt * omt * A.x + 2 * omt * t * mid.x + t * t * B.x;
|
|
||||||
const y = omt * omt * A.y + 2 * omt * t * mid.y + t * t * B.y;
|
|
||||||
const z = omt * omt * A.z + 2 * omt * t * mid.z + t * t * B.z;
|
|
||||||
center.push(new Vector3(x, y, z));
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
center.push(A, B);
|
|
||||||
}
|
|
||||||
|
|
||||||
// направление «вбок» для ширины: billboard → перпендикуляр к лучу и к камере.
|
|
||||||
const cam = this.scene.activeCamera;
|
|
||||||
const camPos = cam ? cam.position : new Vector3(0, 50, 0);
|
|
||||||
const widthAt = (t) => {
|
|
||||||
if (it.widthSequence && it.widthSequence.length) {
|
|
||||||
return this._sampleSeq(it.widthSequence, t, 'w', it.width);
|
|
||||||
}
|
|
||||||
return it.width;
|
|
||||||
};
|
|
||||||
|
|
||||||
const left = [], right = [];
|
|
||||||
for (let i = 0; i < center.length; i++) {
|
|
||||||
const p = center[i];
|
|
||||||
const t = center.length > 1 ? i / (center.length - 1) : 0;
|
|
||||||
// касательная к линии
|
|
||||||
const prev = center[Math.max(0, i - 1)];
|
|
||||||
const next = center[Math.min(center.length - 1, i + 1)];
|
|
||||||
const tangent = next.subtract(prev);
|
|
||||||
if (tangent.lengthSquared() < 1e-6) tangent.copyFrom(B.subtract(A));
|
|
||||||
tangent.normalize();
|
|
||||||
let side;
|
|
||||||
if (it.faceMode === 'flat-y') {
|
|
||||||
side = new Vector3(0, 1, 0).cross(tangent); // лежит горизонтально
|
|
||||||
} else if (it.faceMode === 'flat-x') {
|
|
||||||
side = new Vector3(1, 0, 0);
|
|
||||||
} else if (it.faceMode === 'flat-z') {
|
|
||||||
side = new Vector3(0, 0, 1);
|
|
||||||
} else { // billboard — перпендикуляр к лучу, в плоскости к камере
|
|
||||||
const toCam = camPos.subtract(p);
|
|
||||||
side = tangent.cross(toCam);
|
|
||||||
}
|
|
||||||
if (side.lengthSquared() < 1e-6) side = new Vector3(1, 0, 0);
|
|
||||||
side.normalize().scaleInPlace(widthAt(t) / 2);
|
|
||||||
left.push(p.add(side));
|
|
||||||
right.push(p.subtract(side));
|
|
||||||
}
|
|
||||||
|
|
||||||
const pathArray = [left, right];
|
|
||||||
// UV вдоль длины: тайлов = длина / ширина × scale
|
|
||||||
const tiles = Math.max(1, Math.round((len / Math.max(0.1, it.width)) * 0.6 * it.textureScale));
|
|
||||||
|
|
||||||
// (пере)создание ribbon. updatable, чтобы менять каждый кадр дёшево.
|
|
||||||
const needNew = !it.mesh || it._meshKind !== 'ribbon' || it._segCount !== center.length;
|
|
||||||
if (needNew) {
|
|
||||||
if (it.mesh) { try { it.mesh.dispose(); } catch (e) {} }
|
|
||||||
// sideOrientation НЕ DOUBLESIDE: при DOUBLESIDE Babylon удваивает
|
|
||||||
// вершины (фронт+бэк), а наши UV/vertexColors считаются на 2n вершин
|
|
||||||
// → setVerticesData выходит за vertex buffer → GL_INVALID_OPERATION
|
|
||||||
// «Vertex buffer is not big enough» (стрелка не рисовалась, WebGL
|
|
||||||
// отрубал контекст). Двусторонность даёт материал (backFaceCulling
|
|
||||||
// = false), DOUBLESIDE-геометрия не нужна.
|
|
||||||
it.mesh = MeshBuilder.CreateRibbon('beam_' + it.id, {
|
|
||||||
pathArray, updatable: true,
|
|
||||||
}, this.scene);
|
|
||||||
it.mesh.material = it.mat;
|
|
||||||
it.mesh.isPickable = false;
|
|
||||||
it.mesh.renderingGroupId = it.ignoreDepth ? 3 : 1;
|
|
||||||
it._meshKind = 'ribbon';
|
|
||||||
it._segCount = center.length;
|
|
||||||
this._applyRibbonUV(it, center.length, tiles);
|
|
||||||
this._applyRibbonColors(it, center.length);
|
|
||||||
} else {
|
|
||||||
it.mesh = MeshBuilder.CreateRibbon('beam_' + it.id, {
|
|
||||||
pathArray, instance: it.mesh,
|
|
||||||
});
|
|
||||||
// UV/colors при изменении длины тайлов
|
|
||||||
if (it._tiles !== tiles) { this._applyRibbonUV(it, center.length, tiles); }
|
|
||||||
}
|
|
||||||
it._tiles = tiles;
|
|
||||||
it.mesh.setEnabled(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** UV: u = тайлы вдоль длины (для wrap-повтора), v = 0..1 поперёк. */
|
|
||||||
_applyRibbonUV(it, n, tiles) {
|
|
||||||
if (!it.mesh) return;
|
|
||||||
const uv = [];
|
|
||||||
// pathArray=[left,right] → вершины идут: left[0..n-1], right[0..n-1]
|
|
||||||
for (let i = 0; i < n; i++) {
|
|
||||||
const u = (i / (n - 1 || 1)) * tiles;
|
|
||||||
uv.push(u, 0);
|
|
||||||
}
|
|
||||||
for (let i = 0; i < n; i++) {
|
|
||||||
const u = (i / (n - 1 || 1)) * tiles;
|
|
||||||
uv.push(u, 1);
|
|
||||||
}
|
|
||||||
try { it.mesh.setVerticesData(VertexBuffer.UVKind, uv, true); } catch (e) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Градиент цвета/прозрачности вдоль длины через vertexColors. */
|
|
||||||
_applyRibbonColors(it, n) {
|
|
||||||
if (!it.mesh) return;
|
|
||||||
if (!it.colorSequence && !it.transparencySequence) {
|
|
||||||
// равномерный цвет — vertexColors не нужны (материал даёт цвет)
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const colors = [];
|
|
||||||
const base = Color3.FromHexString(it.color);
|
|
||||||
const sample = (t) => {
|
|
||||||
let c = base;
|
|
||||||
if (it.colorSequence) {
|
|
||||||
const hex = this._sampleSeqColor(it.colorSequence, t);
|
|
||||||
if (hex) c = Color3.FromHexString(hex);
|
|
||||||
}
|
|
||||||
const alpha = it.transparencySequence
|
|
||||||
? 1 - this._sampleSeq(it.transparencySequence, t, 't', 0)
|
|
||||||
: 1;
|
|
||||||
return new Color4(c.r, c.g, c.b, alpha);
|
|
||||||
};
|
|
||||||
for (let i = 0; i < n; i++) {
|
|
||||||
const t = i / (n - 1 || 1);
|
|
||||||
const c = sample(t);
|
|
||||||
colors.push(c.r, c.g, c.b, c.a);
|
|
||||||
}
|
|
||||||
for (let i = 0; i < n; i++) {
|
|
||||||
const t = i / (n - 1 || 1);
|
|
||||||
const c = sample(t);
|
|
||||||
colors.push(c.r, c.g, c.b, c.a);
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
it.mesh.setVerticesData(VertexBuffer.ColorKind, colors, true);
|
|
||||||
it.mat.useVertexColor = true;
|
|
||||||
if (it.transparencySequence) it.mat.alpha = 1; // альфа из vertexColor
|
|
||||||
} catch (e) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
_sampleSeq(seq, t, key, def) {
|
|
||||||
if (!seq || !seq.length) return def;
|
|
||||||
let prev = seq[0], next = seq[seq.length - 1];
|
|
||||||
for (let i = 0; i < seq.length; i++) {
|
|
||||||
if (seq[i].p <= t) prev = seq[i];
|
|
||||||
if (seq[i].p >= t) { next = seq[i]; break; }
|
|
||||||
}
|
|
||||||
if (prev === next || next.p === prev.p) return prev[key];
|
|
||||||
const f = (t - prev.p) / (next.p - prev.p);
|
|
||||||
return prev[key] + (next[key] - prev[key]) * f;
|
|
||||||
}
|
|
||||||
|
|
||||||
_sampleSeqColor(seq, t) {
|
|
||||||
if (!seq || !seq.length) return null;
|
|
||||||
let prev = seq[0], next = seq[seq.length - 1];
|
|
||||||
for (let i = 0; i < seq.length; i++) {
|
|
||||||
if (seq[i].p <= t) prev = seq[i];
|
|
||||||
if (seq[i].p >= t) { next = seq[i]; break; }
|
|
||||||
}
|
|
||||||
const ca = Color3.FromHexString(prev.c), cb = Color3.FromHexString(next.c);
|
|
||||||
if (prev === next || next.p === prev.p) return prev.c;
|
|
||||||
const f = (t - prev.p) / (next.p - prev.p);
|
|
||||||
const r = Math.round((ca.r + (cb.r - ca.r) * f) * 255);
|
|
||||||
const g = Math.round((ca.g + (cb.g - ca.g) * f) * 255);
|
|
||||||
const b = Math.round((ca.b + (cb.b - ca.b) * f) * 255);
|
|
||||||
return '#' + [r, g, b].map((x) => x.toString(16).padStart(2, '0')).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===================================================================
|
|
||||||
// POINTER (game.fx.pointer) — высокоуровневая стрелка-указатель
|
|
||||||
// ===================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Создать стрелку-указатель. opts: { from, to, preset, ...override }.
|
|
||||||
* from/to: {x,y,z} | ref | 'player'. preset: guide|quest|danger|gift|custom.
|
|
||||||
* Возвращает id (это beam с pointer-флагом).
|
|
||||||
*/
|
|
||||||
addPointer(opts = {}) {
|
|
||||||
const preset = POINTER_PRESETS[opts.preset] || (opts.preset === 'custom' ? {} : POINTER_PRESETS.guide);
|
|
||||||
const merged = {
|
|
||||||
from: opts.from,
|
|
||||||
to: opts.to,
|
|
||||||
...preset,
|
|
||||||
// явные override из opts перебивают пресет:
|
|
||||||
...(opts.color !== undefined ? { color: opts.color } : {}),
|
|
||||||
...(opts.texture !== undefined ? { texture: opts.texture } : {}),
|
|
||||||
...(opts.textureSpeed !== undefined ? { textureSpeed: opts.textureSpeed } : {}),
|
|
||||||
...(opts.strokeColor !== undefined ? { strokeColor: opts.strokeColor } : {}),
|
|
||||||
...(opts.colorSequence !== undefined ? { colorSequence: opts.colorSequence } : {}),
|
|
||||||
...(opts.curved !== undefined ? { curved: opts.curved } : {}),
|
|
||||||
...(opts.curveHeight !== undefined ? { curveHeight: opts.curveHeight } : {}),
|
|
||||||
...(opts.faceMode !== undefined ? { faceMode: opts.faceMode } : {}),
|
|
||||||
...(opts.customTextureUrl !== undefined ? { texture: 'custom', customTextureUrl: opts.customTextureUrl } : {}),
|
|
||||||
// Указатель — горизонтальная лента (flat-y), лежит над землёй и
|
|
||||||
// всегда видна сверху. billboard на curved-ленте вырождался (side-
|
|
||||||
// вектор tangent×toCam схлопывался на сегментах смотрящих на камеру)
|
|
||||||
// → стрелка пропадала. flat-y держит ширину стабильно.
|
|
||||||
faceMode: opts.faceMode || 'flat-y',
|
|
||||||
curved: opts.curved !== undefined ? opts.curved : true,
|
|
||||||
curveHeight: opts.curveHeight !== undefined ? opts.curveHeight : 0.8,
|
|
||||||
// Лента над землёй, цель — у её центра. Хорошо видно, не в земле.
|
|
||||||
width: opts.width !== undefined ? opts.width : 1.4,
|
|
||||||
attachOffset: opts.attachOffset || { fromY: 1.6, toY: 1.2 },
|
|
||||||
};
|
|
||||||
const id = this.addBeam(merged);
|
|
||||||
const it = this.items.get(id);
|
|
||||||
if (it) {
|
|
||||||
it._isPointer = true;
|
|
||||||
// Парящая 3D-стрелка над целью (как quest-marker в Roblox):
|
|
||||||
// объёмный конус вершиной ВНИЗ, подпрыгивает + вращается + светится.
|
|
||||||
this._makePointerMarker(it);
|
|
||||||
}
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 3D-маркер над целью (Roblox quest-pin): гладкий конус остриём ВНИЗ,
|
|
||||||
* светится, с чёрной обводкой. Анимация (bob + spin) — на РОДИТЕЛЕ
|
|
||||||
* (TransformNode), сам конус ориентирован вниз статично, поэтому spin
|
|
||||||
* не ломает «вниз» направление и обводка не съезжает.
|
|
||||||
*/
|
|
||||||
_makePointerMarker(it) {
|
|
||||||
try {
|
|
||||||
const col = Color3.FromHexString(it.color || '#ff3a3a');
|
|
||||||
// Родитель: только позиция/bob/spin. Дочерний конус — геометрия.
|
|
||||||
const root = new TransformNode('ptrMarkerRoot_' + it.id, this.scene);
|
|
||||||
|
|
||||||
// Гладкий конус (tessellation 18 — не пирамида). Остриё вниз:
|
|
||||||
// CreateCylinder остриём ВВЕРХ (diameterTop=0), поворот X=PI → вниз.
|
|
||||||
const m = MeshBuilder.CreateCylinder('ptrMarker_' + it.id, {
|
|
||||||
height: 1.0, diameterTop: 0, diameterBottom: 0.55, tessellation: 18,
|
|
||||||
}, this.scene);
|
|
||||||
m.parent = root;
|
|
||||||
m.rotation.x = Math.PI; // остриём вниз (на цель)
|
|
||||||
m.isPickable = false;
|
|
||||||
m.renderingGroupId = 3; // поверх геометрии (как beam)
|
|
||||||
const mat = new StandardMaterial('ptrMarkerMat_' + it.id, this.scene);
|
|
||||||
mat.diffuseColor = col;
|
|
||||||
mat.emissiveColor = col; // светится
|
|
||||||
mat.disableLighting = true;
|
|
||||||
mat.disableDepthWrite = true; // рисуем поверх
|
|
||||||
m.material = mat;
|
|
||||||
// Мультяшная обводка — встроенный Babylon outline (не второй меш,
|
|
||||||
// потому не съезжает). Толщину даём заметную.
|
|
||||||
m.renderOutline = true;
|
|
||||||
m.outlineColor = new Color3(0, 0, 0);
|
|
||||||
m.outlineWidth = 0.06;
|
|
||||||
|
|
||||||
it._marker = root;
|
|
||||||
it._markerMesh = m;
|
|
||||||
it._markerMat = mat;
|
|
||||||
it._markerPhase = (it.id % 7) * 0.5; // разный фазовый сдвиг bob
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('[BeamManager] marker create failed:', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Сменить цель указателя. */
|
|
||||||
setPointerTarget(id, to) {
|
|
||||||
const it = this.items.get(Number(id));
|
|
||||||
if (it) it.to = to;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Применить пресет к существующему указателю (для pointer.update({preset})). */
|
|
||||||
applyPointerPreset(id, preset) {
|
|
||||||
const p = POINTER_PRESETS[preset];
|
|
||||||
if (!p) return;
|
|
||||||
this.updateBeam(id, p);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===================================================================
|
|
||||||
|
|
||||||
/** Точка из {x,y,z} | ref | 'player'. yOff — доп. смещение по Y. */
|
|
||||||
_point(p, yOff) {
|
|
||||||
const off = Number.isFinite(yOff) ? yOff : 0;
|
|
||||||
if (!p) return null;
|
if (!p) return null;
|
||||||
if (p === 'player') {
|
|
||||||
const pl = this.scene3d && this.scene3d.player;
|
|
||||||
if (pl && pl._pos) return { x: pl._pos.x, y: pl._pos.y + off, z: pl._pos.z };
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (typeof p === 'object' && Number.isFinite(p.x)) {
|
if (typeof p === 'object' && Number.isFinite(p.x)) {
|
||||||
return { x: p.x, y: p.y + off, z: p.z };
|
return { x: p.x, y: p.y, z: p.z };
|
||||||
}
|
}
|
||||||
if (typeof p === 'string') {
|
if (typeof p === 'string') {
|
||||||
const d = this._resolve(p);
|
const d = this._resolve(p);
|
||||||
if (d) return { x: d.x, y: d.y + off, z: d.z };
|
if (d) return { x: d.x, y: d.y, z: d.z };
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,698 +0,0 @@
|
|||||||
/**
|
|
||||||
* BillboardUiManager — управление 3D-табличками с GUI (BillboardGui в Roblox).
|
|
||||||
*
|
|
||||||
* Каждая табличка — это plane-mesh с натянутой DynamicTexture, на которой
|
|
||||||
* с помощью обычного Canvas 2D API рисуется содержимое: градиентный фон,
|
|
||||||
* иконка, заголовок, подзаголовок, кнопка цены.
|
|
||||||
*
|
|
||||||
* Поддерживает 4 пресета (template):
|
|
||||||
* - 'shop-item' — иконка слева, заголовок, "1 > 2", кнопка цены справа
|
|
||||||
* - 'shop-purchase' — иконка + название + цена в рубликах
|
|
||||||
* - 'banner' — крупная плашка с одним текстом
|
|
||||||
* - 'sign' — простой указатель с текстом
|
|
||||||
*
|
|
||||||
* Режимы ориентации:
|
|
||||||
* - 'camera' — всегда смотрит на камеру (BillboardMode.BILLBOARDMODE_ALL)
|
|
||||||
* - 'fixed' — фиксированная ориентация (используется rotationY mesh-а)
|
|
||||||
*
|
|
||||||
* Клики:
|
|
||||||
* - на mesh-е ставим pickable=true
|
|
||||||
* - в _handlePick ловим точку пересечения, переводим в UV,
|
|
||||||
* ищем под этой точкой кнопку и эмитим событие
|
|
||||||
*
|
|
||||||
* State хранится в PrimitiveManager.instances[id].billboard:
|
|
||||||
* {
|
|
||||||
* template: 'shop-item',
|
|
||||||
* face: 'camera',
|
|
||||||
* content: { icon, title, sub, price, gradient: [from, to] }
|
|
||||||
* // или для custom-режима — elements: [...]
|
|
||||||
* onClickHandlers: { 'buy': fn } // только в Play-режиме
|
|
||||||
* }
|
|
||||||
*/
|
|
||||||
import {
|
|
||||||
DynamicTexture, StandardMaterial, Color3, Mesh, Texture,
|
|
||||||
} from '@babylonjs/core';
|
|
||||||
|
|
||||||
// Размер текстуры таблички (UV pixels). Чем больше — тем чётче, но больше VRAM.
|
|
||||||
// 512×320 даёт нормальное качество на расстоянии 2-10 метров.
|
|
||||||
const TEXTURE_W = 512;
|
|
||||||
const TEXTURE_H = 320;
|
|
||||||
|
|
||||||
// Координаты кнопки в shop-item пресете (для hit-теста кликов).
|
|
||||||
// Совпадают с тем что рисуется в _renderShopItem (cx, cy, cw, ch).
|
|
||||||
const SHOP_ITEM_BUTTON = { x: 332, y: 200, w: 160, h: 90 };
|
|
||||||
|
|
||||||
// Описания доступных иконок (key → emoji-аналог для Canvas).
|
|
||||||
// На фронте мы не имеем доступа к специализированным icon-fonts,
|
|
||||||
// поэтому используем простые символы рисуемые крупно. Можно расширить
|
|
||||||
// до полноценной библиотеки PNG-иконок позже.
|
|
||||||
const ICONS = {
|
|
||||||
hammer: '🔨',
|
|
||||||
saw: '🪚',
|
|
||||||
drop: '💧',
|
|
||||||
seed: '🌱',
|
|
||||||
cube: '🧊',
|
|
||||||
coin: '💰',
|
|
||||||
home: '🏠',
|
|
||||||
rocket: '🚀',
|
|
||||||
sprinkler: '⛲',
|
|
||||||
sparkle: '✨',
|
|
||||||
star: '⭐',
|
|
||||||
bag: '🎒',
|
|
||||||
diamond: '💎',
|
|
||||||
fire: '🔥',
|
|
||||||
lightning: '⚡',
|
|
||||||
heart: '❤',
|
|
||||||
key: '🔑',
|
|
||||||
shield: '🛡',
|
|
||||||
};
|
|
||||||
|
|
||||||
export class BillboardUiManager {
|
|
||||||
constructor(scene) {
|
|
||||||
this.scene = scene;
|
|
||||||
// Колбэки от пользовательских скриптов: key=`${id}:${buttonId}` → fn
|
|
||||||
this._clickHandlers = new Map();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Применить к существующему mesh-у настройки билборда:
|
|
||||||
* — натянуть DynamicTexture, нарисовать контент, выставить billboardMode.
|
|
||||||
*
|
|
||||||
* data — объект из PrimitiveManager.instances[id], содержит уже mesh, sx, sy, sz.
|
|
||||||
* billboardOpts — { template, face, content, elements }
|
|
||||||
*/
|
|
||||||
applyToMesh(data, billboardOpts = {}) {
|
|
||||||
const mesh = data.mesh;
|
|
||||||
if (!mesh) return;
|
|
||||||
|
|
||||||
const template = billboardOpts.template || 'shop-item';
|
|
||||||
const face = billboardOpts.face || 'camera';
|
|
||||||
const content = billboardOpts.content || this._defaultContent(template);
|
|
||||||
|
|
||||||
// Создаём DynamicTexture один раз и кешируем на mesh.
|
|
||||||
let dyn = mesh.metadata?._billboardTexture;
|
|
||||||
if (!dyn) {
|
|
||||||
dyn = new DynamicTexture(
|
|
||||||
`bb_tex_${data.id}`,
|
|
||||||
{ width: TEXTURE_W, height: TEXTURE_H },
|
|
||||||
this.scene,
|
|
||||||
false /* generateMipMaps */
|
|
||||||
);
|
|
||||||
dyn.hasAlpha = true;
|
|
||||||
// Новый StandardMaterial с этой текстурой как diffuseTexture+emissiveTexture
|
|
||||||
// (emissive чтобы светилось и без освещения, как UI).
|
|
||||||
const mat = new StandardMaterial(`bb_mat_${data.id}`, this.scene);
|
|
||||||
mat.diffuseTexture = dyn;
|
|
||||||
mat.emissiveTexture = dyn;
|
|
||||||
mat.emissiveColor = new Color3(1, 1, 1);
|
|
||||||
mat.specularColor = new Color3(0, 0, 0);
|
|
||||||
mat.useAlphaFromDiffuseTexture = true;
|
|
||||||
mat.backFaceCulling = false;
|
|
||||||
mesh.material = mat;
|
|
||||||
if (!mesh.metadata) mesh.metadata = {};
|
|
||||||
mesh.metadata._billboardTexture = dyn;
|
|
||||||
mesh.metadata._billboardMaterial = mat;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ориентация. Babylon-quirk: CreatePlane FRONT в -Z (left-handed),
|
|
||||||
// юзер ставит билборд так чтобы лицо смотрело в +Z мира → +π.
|
|
||||||
mesh.billboardMode = Mesh.BILLBOARDMODE_NONE;
|
|
||||||
if (mesh.metadata._billboardLookObs) {
|
|
||||||
this.scene.onBeforeRenderObservable.remove(mesh.metadata._billboardLookObs);
|
|
||||||
mesh.metadata._billboardLookObs = null;
|
|
||||||
}
|
|
||||||
if (face === 'camera') {
|
|
||||||
// Ручной look-at — каждый кадр поворачиваем front к камере.
|
|
||||||
const obs = this.scene.onBeforeRenderObservable.add(() => {
|
|
||||||
if (mesh.isDisposed()) return;
|
|
||||||
const cam = this.scene.activeCamera;
|
|
||||||
if (!cam) return;
|
|
||||||
const dx = cam.position.x - mesh.position.x;
|
|
||||||
const dz = cam.position.z - mesh.position.z;
|
|
||||||
mesh.rotation.y = Math.atan2(dx, dz) + Math.PI;
|
|
||||||
});
|
|
||||||
mesh.metadata._billboardLookObs = obs;
|
|
||||||
} else {
|
|
||||||
// Фиксированная ориентация: front в +Z + пользовательский rotationY.
|
|
||||||
const userY = Number.isFinite(billboardOpts.rotationY) ? billboardOpts.rotationY : 0;
|
|
||||||
mesh.rotation.y = Math.PI + userY;
|
|
||||||
// Двусторонняя табличка: рамка стоит, но при взгляде сзади
|
|
||||||
// флипаем UV таблички чтобы текст не был зеркальным.
|
|
||||||
const mat = mesh.material;
|
|
||||||
if (mat) {
|
|
||||||
// Включаем рендер обеих сторон (back-face визуализируется).
|
|
||||||
mat.backFaceCulling = false;
|
|
||||||
}
|
|
||||||
const obs = this.scene.onBeforeRenderObservable.add(() => {
|
|
||||||
if (mesh.isDisposed()) return;
|
|
||||||
const cam = this.scene.activeCamera;
|
|
||||||
if (!cam) return;
|
|
||||||
// Локальная нормаль FRONT plane = +Z. Поворот mesh.rotation.y
|
|
||||||
// переводит её в world: normalWorld = (sin(ry), 0, cos(ry)).
|
|
||||||
const ry = mesh.rotation.y;
|
|
||||||
const nWx = Math.sin(ry);
|
|
||||||
const nWz = Math.cos(ry);
|
|
||||||
// Вектор от mesh к камере
|
|
||||||
const vx = cam.position.x - mesh.position.x;
|
|
||||||
const vz = cam.position.z - mesh.position.z;
|
|
||||||
// Скалярное произведение: >0 — камера смотрит на FRONT,
|
|
||||||
// <0 — на BACK (зеркальная UV). Для BACK инвертируем uScale.
|
|
||||||
const dot = nWx * vx + nWz * vz;
|
|
||||||
const dyn = mesh.metadata?._billboardTexture;
|
|
||||||
if (dyn) {
|
|
||||||
// dot > 0 — камера со стороны FRONT-нормали → flip
|
|
||||||
// dot < 0 — камера сзади → нормально
|
|
||||||
if (dot > 0) {
|
|
||||||
if (dyn.uScale !== -1) { dyn.uScale = -1; dyn.uOffset = 1; }
|
|
||||||
} else {
|
|
||||||
if (dyn.uScale !== 1) { dyn.uScale = 1; dyn.uOffset = 0; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
mesh.metadata._billboardLookObs = obs;
|
|
||||||
}
|
|
||||||
mesh.scaling.x = Math.abs(mesh.scaling.x || 1);
|
|
||||||
mesh.metadata._billboardMirrorX = false;
|
|
||||||
|
|
||||||
// Сохраняем state в data для сериализации и для hit-теста кликов.
|
|
||||||
data.billboard = {
|
|
||||||
template,
|
|
||||||
face,
|
|
||||||
content: { ...content },
|
|
||||||
elements: billboardOpts.elements || null,
|
|
||||||
};
|
|
||||||
|
|
||||||
dyn._kubikonMirrorX = mesh.metadata._billboardMirrorX === true;
|
|
||||||
dyn._kubikonOwnerMesh = mesh;
|
|
||||||
this._render(dyn, template, content, billboardOpts.elements);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Обновить контент билборда (без пересоздания текстуры).
|
|
||||||
* Две формы:
|
|
||||||
* 1) update(data, { sub: '2 > 3', price: '$20,000' }) — patch content
|
|
||||||
* 2) update(data, 'buy', { text: '$15,000' }) — patch конкретного элемента
|
|
||||||
* по id (для elements-режима ИЛИ для known-id пресета: 'buy', 'title',
|
|
||||||
* 'sub', 'price', 'icon', 'gradient' маппятся на поля content).
|
|
||||||
*/
|
|
||||||
update(data, elementIdOrPatch, patchMaybe) {
|
|
||||||
if (!data.billboard) return;
|
|
||||||
// Форма 2: 3 аргумента (data, elementId, patch)
|
|
||||||
if (typeof elementIdOrPatch === 'string' && typeof patchMaybe === 'object' && patchMaybe !== null) {
|
|
||||||
const elId = elementIdOrPatch;
|
|
||||||
const patch = patchMaybe;
|
|
||||||
// Кастомные elements: ищем элемент по id и обновляем его поля.
|
|
||||||
if (Array.isArray(data.billboard.elements)) {
|
|
||||||
data.billboard.elements = data.billboard.elements.map(el =>
|
|
||||||
el && el.id === elId ? { ...el, ...patch } : el);
|
|
||||||
} else {
|
|
||||||
// Пресет: мапим известные elementId → ключ content.
|
|
||||||
// 'buy' → content.price; 'title'/'sub'/'icon'/'gradient' → одноимённый ключ.
|
|
||||||
const c = { ...(data.billboard.content || {}) };
|
|
||||||
if (elId === 'buy' && 'text' in patch) {
|
|
||||||
c.price = patch.text;
|
|
||||||
} else if (elId in c) {
|
|
||||||
// Если patch имеет text — кладём в content[elId], иначе мерджим поля.
|
|
||||||
if ('text' in patch) c[elId] = patch.text;
|
|
||||||
else Object.assign(c, patch);
|
|
||||||
} else {
|
|
||||||
Object.assign(c, patch);
|
|
||||||
}
|
|
||||||
data.billboard.content = c;
|
|
||||||
}
|
|
||||||
} else if (typeof elementIdOrPatch === 'object' && elementIdOrPatch !== null) {
|
|
||||||
// Форма 1: 2 аргумента (data, patchContent)
|
|
||||||
data.billboard.content = { ...data.billboard.content, ...elementIdOrPatch };
|
|
||||||
} else {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const dyn = data.mesh?.metadata?._billboardTexture;
|
|
||||||
if (dyn) {
|
|
||||||
this._render(dyn, data.billboard.template, data.billboard.content, data.billboard.elements);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Подписаться на клик по кнопке билборда (только в Play-режиме). */
|
|
||||||
onClick(data, buttonId, fn) {
|
|
||||||
const key = `${data.id}:${buttonId}`;
|
|
||||||
this._clickHandlers.set(key, fn);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Снять все подписки (вызывается при остановке Play). */
|
|
||||||
clearHandlers() {
|
|
||||||
this._clickHandlers.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Проверить клик по точке UV: вернуть buttonId или null.
|
|
||||||
* UV точка — нормализованная (0..1).
|
|
||||||
*/
|
|
||||||
pickButtonAt(data, uvX, uvY) {
|
|
||||||
if (!data.billboard) return null;
|
|
||||||
// Если текстура в данный момент отзеркалена (face=fixed, смотрим
|
|
||||||
// на back-side), uScale=-1 — инвертим UV.x чтобы попасть в правильный
|
|
||||||
// canvas-пиксель.
|
|
||||||
const dyn = data.mesh?.metadata?._billboardTexture;
|
|
||||||
const flipped = dyn && dyn.uScale === -1;
|
|
||||||
const uX = flipped ? (1 - uvX) : uvX;
|
|
||||||
const px = uX * TEXTURE_W;
|
|
||||||
const py = (1 - uvY) * TEXTURE_H;
|
|
||||||
// Кастомные elements имеют приоритет (если заданы)
|
|
||||||
if (data.billboard.elements) {
|
|
||||||
return this._hitTestElements(data.billboard.elements, px, py);
|
|
||||||
}
|
|
||||||
const tmpl = data.billboard.template;
|
|
||||||
if (tmpl === 'shop-item' || tmpl === 'shop-purchase') {
|
|
||||||
// Кнопка адаптивной ширины — пересчитываем её rect по тексту
|
|
||||||
// именно ЭТОЙ таблички (тем же _computeBuyRect, что и при рисовании).
|
|
||||||
const label = (data.billboard.content && data.billboard.content.price) || '$0';
|
|
||||||
let b = SHOP_ITEM_BUTTON;
|
|
||||||
try {
|
|
||||||
const measCtx = (dyn && dyn.getContext && dyn.getContext()) || null;
|
|
||||||
if (measCtx) b = this._computeBuyRect(measCtx, label, SHOP_ITEM_BUTTON);
|
|
||||||
} catch (e) { /* fallback на базовый rect */ }
|
|
||||||
if (px >= b.x && px <= b.x + b.w && py >= b.y && py <= b.y + b.h) {
|
|
||||||
return 'buy';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// banner и sign — кнопок нет, только текст.
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Вызвать обработчик клика, если он подписан. */
|
|
||||||
fireClick(data, buttonId) {
|
|
||||||
const key = `${data.id}:${buttonId}`;
|
|
||||||
const fn = this._clickHandlers.get(key);
|
|
||||||
if (fn) {
|
|
||||||
try { fn(); } catch (e) { console.error('[Billboard onClick]', e); }
|
|
||||||
}
|
|
||||||
// Также пишем кнопку в "нажатом" виде на 100мс для UX-фидбека.
|
|
||||||
this._flashButton(data, buttonId);
|
|
||||||
}
|
|
||||||
|
|
||||||
_flashButton(data, buttonId) {
|
|
||||||
if (!data.billboard) return;
|
|
||||||
const dyn = data.mesh?.metadata?._billboardTexture;
|
|
||||||
if (!dyn) return;
|
|
||||||
// Перерисовываем pressed=true. ВАЖНО: используем СВЕЖИЙ content в callback'е
|
|
||||||
// (на момент 120мс content уже может быть обновлён через update — берём
|
|
||||||
// актуальный, иначе откатим к старому).
|
|
||||||
// Также гарантируем 1 flash на табличку — если предыдущий ещё крутится,
|
|
||||||
// отменяем его таймер.
|
|
||||||
if (data._flashTimer) {
|
|
||||||
clearTimeout(data._flashTimer);
|
|
||||||
data._flashTimer = null;
|
|
||||||
}
|
|
||||||
this._render(dyn, data.billboard.template, data.billboard.content,
|
|
||||||
data.billboard.elements, /* pressed */ buttonId);
|
|
||||||
data._flashTimer = setTimeout(() => {
|
|
||||||
data._flashTimer = null;
|
|
||||||
// Берём АКТУАЛЬНЫЕ data.billboard.content/elements — могли обновиться
|
|
||||||
// через game.billboard.update() ВО ВРЕМЯ flash'а.
|
|
||||||
if (data.mesh?.metadata?._billboardTexture === dyn && data.billboard) {
|
|
||||||
this._render(dyn, data.billboard.template, data.billboard.content,
|
|
||||||
data.billboard.elements, null);
|
|
||||||
}
|
|
||||||
}, 120);
|
|
||||||
}
|
|
||||||
|
|
||||||
_defaultContent(template) {
|
|
||||||
switch (template) {
|
|
||||||
case 'shop-item':
|
|
||||||
return { icon: 'hammer', title: 'Апгрейд', sub: '1 > 2',
|
|
||||||
price: '$100', gradient: ['#ff5a5a', '#ff8a3d'] };
|
|
||||||
case 'shop-purchase':
|
|
||||||
return { icon: 'seed', title: 'Набор семян', sub: 'x3',
|
|
||||||
price: '199 R', gradient: ['#3b82f6', '#0ea5e9'] };
|
|
||||||
case 'banner':
|
|
||||||
return { title: 'Удвоенный урожай в 17:00',
|
|
||||||
gradient: ['#7c3aed', '#a855f7'] };
|
|
||||||
case 'sign':
|
|
||||||
return { title: 'Сюда', gradient: ['#1f2937', '#374151'] };
|
|
||||||
default:
|
|
||||||
return { title: 'Табличка', gradient: ['#1f2937', '#374151'] };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Главная функция рендера — рисует контент на canvas DynamicTexture. */
|
|
||||||
_render(dyn, template, content, elements, pressedButtonId) {
|
|
||||||
const ctx = dyn.getContext();
|
|
||||||
ctx.save();
|
|
||||||
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
|
||||||
ctx.clearRect(0, 0, TEXTURE_W, TEXTURE_H);
|
|
||||||
if (elements && Array.isArray(elements)) {
|
|
||||||
this._renderElements(ctx, elements, pressedButtonId);
|
|
||||||
} else {
|
|
||||||
switch (template) {
|
|
||||||
case 'shop-item':
|
|
||||||
this._renderShopItem(ctx, content, pressedButtonId);
|
|
||||||
break;
|
|
||||||
case 'shop-purchase':
|
|
||||||
this._renderShopPurchase(ctx, content, pressedButtonId);
|
|
||||||
break;
|
|
||||||
case 'banner':
|
|
||||||
this._renderBanner(ctx, content);
|
|
||||||
break;
|
|
||||||
case 'sign':
|
|
||||||
this._renderSign(ctx, content);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
this._renderBanner(ctx, content);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ctx.restore();
|
|
||||||
dyn.update(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Скруглённый прямоугольник + заливка градиентом + обводка. */
|
|
||||||
_roundedGradientRect(ctx, x, y, w, h, opts) {
|
|
||||||
const r = opts.radius ?? 24;
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(x + r, y);
|
|
||||||
ctx.arcTo(x + w, y, x + w, y + h, r);
|
|
||||||
ctx.arcTo(x + w, y + h, x, y + h, r);
|
|
||||||
ctx.arcTo(x, y + h, x, y, r);
|
|
||||||
ctx.arcTo(x, y, x + w, y, r);
|
|
||||||
ctx.closePath();
|
|
||||||
const grad = ctx.createLinearGradient(x, y, x, y + h);
|
|
||||||
const [from, to] = opts.gradient || ['#333', '#111'];
|
|
||||||
grad.addColorStop(0, from);
|
|
||||||
grad.addColorStop(1, to);
|
|
||||||
ctx.fillStyle = grad;
|
|
||||||
ctx.fill();
|
|
||||||
if (opts.stroke) {
|
|
||||||
ctx.lineWidth = opts.stroke.width ?? 3;
|
|
||||||
ctx.strokeStyle = opts.stroke.color || '#000';
|
|
||||||
ctx.stroke();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Рендер пресета shop-item: иконка слева | title + sub | кнопка цены. */
|
|
||||||
_renderShopItem(ctx, content, pressedButtonId) {
|
|
||||||
// Главная плашка
|
|
||||||
this._roundedGradientRect(ctx, 8, 8, TEXTURE_W - 16, TEXTURE_H - 16, {
|
|
||||||
gradient: content.gradient || ['#ff5a5a', '#ff8a3d'],
|
|
||||||
radius: 28,
|
|
||||||
stroke: { color: '#0008', width: 4 },
|
|
||||||
});
|
|
||||||
|
|
||||||
// Иконка-слот: круг чуть темнее в левой части
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.arc(110, 130, 70, 0, Math.PI * 2);
|
|
||||||
ctx.fillStyle = 'rgba(0,0,0,0.18)';
|
|
||||||
ctx.fill();
|
|
||||||
|
|
||||||
// Сама иконка (emoji крупно)
|
|
||||||
const iconChar = ICONS[content.icon] || ICONS.cube;
|
|
||||||
ctx.font = 'bold 96px "Segoe UI Emoji", Arial';
|
|
||||||
ctx.textAlign = 'center';
|
|
||||||
ctx.textBaseline = 'middle';
|
|
||||||
ctx.fillStyle = '#fff';
|
|
||||||
ctx.fillText(iconChar, 110, 132);
|
|
||||||
|
|
||||||
// Заголовок
|
|
||||||
ctx.font = 'bold 36px Arial, sans-serif';
|
|
||||||
ctx.textAlign = 'left';
|
|
||||||
ctx.textBaseline = 'top';
|
|
||||||
ctx.fillStyle = '#fff';
|
|
||||||
// Лёгкая тень
|
|
||||||
ctx.shadowColor = 'rgba(0,0,0,0.45)';
|
|
||||||
ctx.shadowBlur = 4;
|
|
||||||
ctx.shadowOffsetY = 2;
|
|
||||||
ctx.fillText(this._truncate(content.title || '', 18), 200, 50);
|
|
||||||
ctx.shadowColor = 'transparent';
|
|
||||||
ctx.shadowBlur = 0;
|
|
||||||
ctx.shadowOffsetY = 0;
|
|
||||||
|
|
||||||
// Подзаголовок "1 > 2" — зелёный, поменьше
|
|
||||||
if (content.sub) {
|
|
||||||
ctx.font = 'bold 28px Arial, sans-serif';
|
|
||||||
ctx.fillStyle = '#a7f3d0';
|
|
||||||
ctx.fillText(content.sub, 200, 105);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Кнопка цены — жёлтый прямоугольник внизу справа.
|
|
||||||
// Ширина адаптивная: длинный текст («ПРИМЕРИТЬ»/«НАДЕТО») расширяет
|
|
||||||
// кнопку ВЛЕВО (правый край прижат к краю таблички), шрифт ужимается
|
|
||||||
// если даже расширенная кнопка узка. Фактический rect → _buyRect для hit-теста.
|
|
||||||
const pressed = pressedButtonId === 'buy';
|
|
||||||
const label = content.price || '$0';
|
|
||||||
const rect = this._computeBuyRect(ctx, label, SHOP_ITEM_BUTTON);
|
|
||||||
this._roundedGradientRect(ctx, rect.x, rect.y, rect.w, rect.h, {
|
|
||||||
gradient: pressed
|
|
||||||
? ['#d97706', '#92400e']
|
|
||||||
: ['#fbbf24', '#f59e0b'],
|
|
||||||
radius: 16,
|
|
||||||
stroke: { color: '#000', width: 3 },
|
|
||||||
});
|
|
||||||
ctx.font = `bold ${rect.fontSize}px Arial, sans-serif`;
|
|
||||||
ctx.fillStyle = pressed ? '#fef3c7' : '#1c1917';
|
|
||||||
ctx.textAlign = 'center';
|
|
||||||
ctx.textBaseline = 'middle';
|
|
||||||
ctx.fillText(label, rect.x + rect.w / 2, rect.y + rect.h / 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Подобрать прямоугольник кнопки «buy» под текст: правый край прижат к
|
|
||||||
* правому краю таблички (как в базовом SHOP_ITEM_BUTTON), ширина растёт
|
|
||||||
* влево под длину текста, шрифт ужимается если упёрлись в макс-ширину.
|
|
||||||
* Возвращает { x, y, w, h, fontSize }.
|
|
||||||
*/
|
|
||||||
_computeBuyRect(ctx, label, base) {
|
|
||||||
const PAD = 36; // отступы текста по бокам
|
|
||||||
const MAX_W = 300; // макс ширина кнопки (не залезать на title)
|
|
||||||
const rightEdge = base.x + base.w; // правый край держим на месте
|
|
||||||
let fontSize = 36;
|
|
||||||
ctx.font = `bold ${fontSize}px Arial, sans-serif`;
|
|
||||||
let textW = ctx.measureText(label).width;
|
|
||||||
let w = Math.max(base.w, textW + PAD * 2);
|
|
||||||
if (w > MAX_W) {
|
|
||||||
// Ужимаем шрифт чтобы текст влез в MAX_W.
|
|
||||||
w = MAX_W;
|
|
||||||
const inner = MAX_W - PAD * 2;
|
|
||||||
while (fontSize > 20 && textW > inner) {
|
|
||||||
fontSize -= 2;
|
|
||||||
ctx.font = `bold ${fontSize}px Arial, sans-serif`;
|
|
||||||
textW = ctx.measureText(label).width;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { x: rightEdge - w, y: base.y, w, h: base.h, fontSize };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Рендер пресета shop-purchase: похож на shop-item, но иконка крупнее и центрирована. */
|
|
||||||
_renderShopPurchase(ctx, content, pressedButtonId) {
|
|
||||||
this._roundedGradientRect(ctx, 8, 8, TEXTURE_W - 16, TEXTURE_H - 16, {
|
|
||||||
gradient: content.gradient || ['#3b82f6', '#0ea5e9'],
|
|
||||||
radius: 28,
|
|
||||||
stroke: { color: '#0008', width: 4 },
|
|
||||||
});
|
|
||||||
|
|
||||||
const iconChar = ICONS[content.icon] || ICONS.bag;
|
|
||||||
ctx.font = 'bold 110px "Segoe UI Emoji", Arial';
|
|
||||||
ctx.textAlign = 'center';
|
|
||||||
ctx.textBaseline = 'middle';
|
|
||||||
ctx.fillStyle = '#fff';
|
|
||||||
ctx.fillText(iconChar, 110, 140);
|
|
||||||
|
|
||||||
ctx.font = 'bold 34px Arial, sans-serif';
|
|
||||||
ctx.textAlign = 'left';
|
|
||||||
ctx.textBaseline = 'top';
|
|
||||||
ctx.fillStyle = '#fff';
|
|
||||||
ctx.fillText(this._truncate(content.title || '', 16), 200, 50);
|
|
||||||
|
|
||||||
if (content.sub) {
|
|
||||||
ctx.font = 'bold 26px Arial, sans-serif';
|
|
||||||
ctx.fillStyle = '#dbeafe';
|
|
||||||
ctx.fillText(content.sub, 200, 100);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Кнопка-цена (адаптивная ширина под длинный текст, см. _computeBuyRect).
|
|
||||||
const pressed = pressedButtonId === 'buy';
|
|
||||||
const label = content.price || '0 R';
|
|
||||||
const rect = this._computeBuyRect(ctx, label, SHOP_ITEM_BUTTON);
|
|
||||||
this._roundedGradientRect(ctx, rect.x, rect.y, rect.w, rect.h, {
|
|
||||||
gradient: pressed
|
|
||||||
? ['#9333ea', '#6b21a8']
|
|
||||||
: ['#a855f7', '#7c3aed'],
|
|
||||||
radius: 16,
|
|
||||||
stroke: { color: '#000', width: 3 },
|
|
||||||
});
|
|
||||||
ctx.font = `bold ${rect.fontSize}px Arial, sans-serif`;
|
|
||||||
ctx.fillStyle = '#fff';
|
|
||||||
ctx.textAlign = 'center';
|
|
||||||
ctx.textBaseline = 'middle';
|
|
||||||
ctx.fillText(label, rect.x + rect.w / 2, rect.y + rect.h / 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Рендер пресета banner: одна крупная фраза по центру. */
|
|
||||||
_renderBanner(ctx, content) {
|
|
||||||
this._roundedGradientRect(ctx, 8, 8, TEXTURE_W - 16, TEXTURE_H - 16, {
|
|
||||||
gradient: content.gradient || ['#7c3aed', '#a855f7'],
|
|
||||||
radius: 28,
|
|
||||||
stroke: { color: '#0008', width: 4 },
|
|
||||||
});
|
|
||||||
|
|
||||||
ctx.font = 'bold 46px Arial, sans-serif';
|
|
||||||
ctx.textAlign = 'center';
|
|
||||||
ctx.textBaseline = 'middle';
|
|
||||||
ctx.fillStyle = '#fff';
|
|
||||||
ctx.shadowColor = 'rgba(0,0,0,0.45)';
|
|
||||||
ctx.shadowBlur = 8;
|
|
||||||
ctx.shadowOffsetY = 3;
|
|
||||||
|
|
||||||
// Перенос строк, чтобы длинная фраза влезла
|
|
||||||
const lines = this._wrapText(ctx, content.title || '', TEXTURE_W - 80);
|
|
||||||
const lh = 56;
|
|
||||||
const startY = TEXTURE_H / 2 - (lines.length - 1) * lh / 2;
|
|
||||||
for (let i = 0; i < lines.length; i++) {
|
|
||||||
ctx.fillText(lines[i], TEXTURE_W / 2, startY + i * lh);
|
|
||||||
}
|
|
||||||
ctx.shadowColor = 'transparent';
|
|
||||||
ctx.shadowBlur = 0;
|
|
||||||
ctx.shadowOffsetY = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Рендер пресета sign: компактный указатель. */
|
|
||||||
_renderSign(ctx, content) {
|
|
||||||
this._roundedGradientRect(ctx, 8, 8, TEXTURE_W - 16, TEXTURE_H - 16, {
|
|
||||||
gradient: content.gradient || ['#1f2937', '#374151'],
|
|
||||||
radius: 20,
|
|
||||||
stroke: { color: '#fff', width: 4 },
|
|
||||||
});
|
|
||||||
|
|
||||||
// Заголовок крупно сверху
|
|
||||||
ctx.font = 'bold 44px Arial, sans-serif';
|
|
||||||
ctx.textAlign = 'center';
|
|
||||||
ctx.textBaseline = 'middle';
|
|
||||||
ctx.fillStyle = '#ffd166';
|
|
||||||
const title = content.title || '';
|
|
||||||
const subText = content.sub || '';
|
|
||||||
if (subText) {
|
|
||||||
// Заголовок сверху, sub-строки списком ниже
|
|
||||||
ctx.fillText(this._truncate(title, 18), TEXTURE_W / 2, 50);
|
|
||||||
// Sub — многострочный, выравнивание по левому краю
|
|
||||||
ctx.font = '20px Arial, sans-serif';
|
|
||||||
ctx.fillStyle = '#fff';
|
|
||||||
ctx.textAlign = 'left';
|
|
||||||
ctx.textBaseline = 'top';
|
|
||||||
const lines = String(subText).split('\n');
|
|
||||||
const startY = 95;
|
|
||||||
const lineH = 30;
|
|
||||||
const leftX = 38;
|
|
||||||
for (let i = 0; i < lines.length && i < 8; i++) {
|
|
||||||
ctx.fillText(this._truncate(lines[i], 36), leftX, startY + i * lineH);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
ctx.font = 'bold 64px Arial, sans-serif';
|
|
||||||
ctx.fillText(this._truncate(title, 14), TEXTURE_W / 2, TEXTURE_H / 2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Рендер кастомного списка элементов: фон + список text/image/button.
|
|
||||||
* Каждый элемент: { kind, x, y, w, h, ... }
|
|
||||||
* text: { text, size, color, bold, align }
|
|
||||||
* image: { src (icon-key), w, h }
|
|
||||||
* button: { id, text, background: {color|gradient, cornerRadius, stroke} }
|
|
||||||
*/
|
|
||||||
_renderElements(ctx, elements, pressedButtonId) {
|
|
||||||
// Фоновая плашка — первый элемент типа 'background' (опционально)
|
|
||||||
const bg = elements.find(e => e.kind === 'background');
|
|
||||||
if (bg) {
|
|
||||||
this._roundedGradientRect(ctx, 8, 8, TEXTURE_W - 16, TEXTURE_H - 16, {
|
|
||||||
gradient: bg.gradient || ['#1f2937', '#374151'],
|
|
||||||
radius: bg.cornerRadius ?? 24,
|
|
||||||
stroke: bg.stroke || { color: '#0008', width: 4 },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// Остальные элементы — поверх фона
|
|
||||||
for (const el of elements) {
|
|
||||||
if (el.kind === 'background') continue;
|
|
||||||
if (el.kind === 'text') {
|
|
||||||
ctx.font = `${el.bold ? 'bold ' : ''}${el.size || 24}px Arial, sans-serif`;
|
|
||||||
ctx.fillStyle = el.color || '#fff';
|
|
||||||
ctx.textAlign = el.align || 'left';
|
|
||||||
ctx.textBaseline = 'top';
|
|
||||||
ctx.fillText(el.text || '', el.x || 0, el.y || 0);
|
|
||||||
} else if (el.kind === 'image') {
|
|
||||||
const iconChar = ICONS[el.src] || ICONS.cube;
|
|
||||||
const size = Math.min(el.w || 64, el.h || 64);
|
|
||||||
ctx.font = `${Math.round(size * 1.1)}px "Segoe UI Emoji", Arial`;
|
|
||||||
ctx.textAlign = 'center';
|
|
||||||
ctx.textBaseline = 'middle';
|
|
||||||
ctx.fillStyle = el.color || '#fff';
|
|
||||||
ctx.fillText(iconChar, (el.x || 0) + (el.w || 64) / 2,
|
|
||||||
(el.y || 0) + (el.h || 64) / 2);
|
|
||||||
} else if (el.kind === 'button') {
|
|
||||||
const isPressed = pressedButtonId === el.id;
|
|
||||||
const bgSpec = el.background || {};
|
|
||||||
this._roundedGradientRect(ctx, el.x || 0, el.y || 0,
|
|
||||||
el.w || 100, el.h || 36, {
|
|
||||||
gradient: bgSpec.gradient ||
|
|
||||||
(bgSpec.color ? [bgSpec.color, bgSpec.color] : ['#fbbf24', '#f59e0b']),
|
|
||||||
radius: bgSpec.cornerRadius ?? 12,
|
|
||||||
stroke: bgSpec.stroke || { color: '#000', width: 2 },
|
|
||||||
});
|
|
||||||
ctx.font = `bold ${el.textSize || 28}px Arial, sans-serif`;
|
|
||||||
ctx.fillStyle = isPressed ? '#fef3c7' : (el.textColor || '#1c1917');
|
|
||||||
ctx.textAlign = 'center';
|
|
||||||
ctx.textBaseline = 'middle';
|
|
||||||
ctx.fillText(el.text || '', (el.x || 0) + (el.w || 100) / 2,
|
|
||||||
(el.y || 0) + (el.h || 36) / 2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Hit-тест для кастомных elements (используется в pickButtonAt). */
|
|
||||||
_hitTestElements(elements, px, py) {
|
|
||||||
for (const el of elements) {
|
|
||||||
if (el.kind !== 'button') continue;
|
|
||||||
const x = el.x || 0, y = el.y || 0;
|
|
||||||
const w = el.w || 100, h = el.h || 36;
|
|
||||||
if (px >= x && px <= x + w && py >= y && py <= y + h) {
|
|
||||||
return el.id || null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
_truncate(s, max) {
|
|
||||||
if (!s) return '';
|
|
||||||
return s.length > max ? s.slice(0, max - 1) + '…' : s;
|
|
||||||
}
|
|
||||||
|
|
||||||
_wrapText(ctx, text, maxWidth) {
|
|
||||||
const words = (text || '').split(' ');
|
|
||||||
const lines = [];
|
|
||||||
let cur = '';
|
|
||||||
for (const w of words) {
|
|
||||||
const test = cur ? cur + ' ' + w : w;
|
|
||||||
if (ctx.measureText(test).width <= maxWidth) {
|
|
||||||
cur = test;
|
|
||||||
} else {
|
|
||||||
if (cur) lines.push(cur);
|
|
||||||
cur = w;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (cur) lines.push(cur);
|
|
||||||
return lines.slice(0, 3); // максимум 3 строки
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Список доступных иконок (для UI редактора). */
|
|
||||||
static getAvailableIcons() {
|
|
||||||
return Object.keys(ICONS);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Список доступных пресетов (для UI редактора). */
|
|
||||||
static getAvailableTemplates() {
|
|
||||||
return [
|
|
||||||
{ id: 'shop-item', name: 'Магазин: апгрейд', hasButton: true,
|
|
||||||
fields: ['icon', 'title', 'sub', 'price', 'gradient'] },
|
|
||||||
{ id: 'shop-purchase', name: 'Магазин: покупка', hasButton: true,
|
|
||||||
fields: ['icon', 'title', 'sub', 'price', 'gradient'] },
|
|
||||||
{ id: 'banner', name: 'Баннер', hasButton: false,
|
|
||||||
fields: ['title', 'gradient'] },
|
|
||||||
{ id: 'sign', name: 'Указатель', hasButton: false,
|
|
||||||
fields: ['title', 'gradient'] },
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -94,10 +94,6 @@ export class BlockManager {
|
|||||||
this._lavaSurfaceBaseY = null;
|
this._lavaSurfaceBaseY = null;
|
||||||
this._lavaDirty = false;
|
this._lavaDirty = false;
|
||||||
this._animTime = 0;
|
this._animTime = 0;
|
||||||
// Окрашиваемые блоки (studs-block, задача 09): per-instance color через
|
|
||||||
// ThinInstance color buffer. blockTypeId → Float32Array(maxBlocks*4 RGBA).
|
|
||||||
this._colorsByProto = new Map();
|
|
||||||
this._STUDS_MAX = 20000; // макс блоков одного окрашиваемого типа
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Вызывать каждый кадр для анимации воды/лавы. */
|
/** Вызывать каждый кадр для анимации воды/лавы. */
|
||||||
@ -363,23 +359,6 @@ export class BlockManager {
|
|||||||
const mat = new StandardMaterial(name, this.scene);
|
const mat = new StandardMaterial(name, this.scene);
|
||||||
mat.specularColor = new Color3(0, 0, 0);
|
mat.specularColor = new Color3(0, 0, 0);
|
||||||
|
|
||||||
// Окрашиваемый блок (studs-block): цвет берётся per-instance из vertex
|
|
||||||
// color буфера ThinInstance и умножается на серую текстуру. Включаем
|
|
||||||
// useVertexColors, normal map (выпуклость кружков), мягкий спекуляр.
|
|
||||||
if (blockType.colorable) {
|
|
||||||
const tex = new Texture(texturePath, this.scene);
|
|
||||||
mat.diffuseTexture = tex;
|
|
||||||
mat.diffuseColor = new Color3(1, 1, 1); // нейтраль — цвет идёт из vertex color
|
|
||||||
if (blockType.normal) {
|
|
||||||
try { mat.bumpTexture = new Texture(blockType.normal, this.scene); } catch (e) {}
|
|
||||||
}
|
|
||||||
// Сочность (Roblox-look): почти-белая текстура × яркий vertex color,
|
|
||||||
// specular убран (он белит/тускнит цвет).
|
|
||||||
mat.specularColor = new Color3(0, 0, 0);
|
|
||||||
mat.useVertexColors = true;
|
|
||||||
return mat;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (texturePath) {
|
if (texturePath) {
|
||||||
const tex = new Texture(texturePath, this.scene);
|
const tex = new Texture(texturePath, this.scene);
|
||||||
tex.updateSamplingMode(Texture.NEAREST_SAMPLINGMODE);
|
tex.updateSamplingMode(Texture.NEAREST_SAMPLINGMODE);
|
||||||
@ -460,7 +439,7 @@ export class BlockManager {
|
|||||||
* один meshes-prototype на тип блока, тысячи позиций в одном draw call.
|
* один meshes-prototype на тип блока, тысячи позиций в одном draw call.
|
||||||
* Жидкости (water/lava) идут по старому пути — у них свой single-surface.
|
* Жидкости (water/lava) идут по старому пути — у них свой single-surface.
|
||||||
*/
|
*/
|
||||||
addBlock(x, y, z, blockTypeId, color) {
|
addBlock(x, y, z, blockTypeId) {
|
||||||
const ix = Math.round(x);
|
const ix = Math.round(x);
|
||||||
const iy = Math.round(y);
|
const iy = Math.round(y);
|
||||||
const iz = Math.round(z);
|
const iz = Math.round(z);
|
||||||
@ -470,9 +449,6 @@ export class BlockManager {
|
|||||||
const typeDef = getBlockType(blockTypeId);
|
const typeDef = getBlockType(blockTypeId);
|
||||||
const isWater = !!typeDef?.isWater;
|
const isWater = !!typeDef?.isWater;
|
||||||
const isLava = !!typeDef?.isLava;
|
const isLava = !!typeDef?.isLava;
|
||||||
// Окрашиваемый блок: цвет инстанса (из аргумента или дефолт типа).
|
|
||||||
const colorable = !!typeDef?.colorable;
|
|
||||||
const instColor = colorable ? (color || typeDef.defaultColor || '#cccccc') : null;
|
|
||||||
|
|
||||||
// Для жидкостей оставляем старую логику: невидимый куб + единый surface
|
// Для жидкостей оставляем старую логику: невидимый куб + единый surface
|
||||||
if (isWater || isLava) {
|
if (isWater || isLava) {
|
||||||
@ -520,9 +496,6 @@ export class BlockManager {
|
|||||||
keysArr[idx] = key;
|
keysArr[idx] = key;
|
||||||
this._cellToInst.set(key, { typeId: blockTypeId, idx });
|
this._cellToInst.set(key, { typeId: blockTypeId, idx });
|
||||||
|
|
||||||
// Окрашиваемый блок — пишем цвет инстанса в color buffer.
|
|
||||||
if (colorable) this._setBlockColorAt(blockTypeId, idx, instColor);
|
|
||||||
|
|
||||||
// Логический «meshProxy» — объект, имитирующий API mesh для совместимости.
|
// Логический «meshProxy» — объект, имитирующий API mesh для совместимости.
|
||||||
// НЕ создаёт реального меша. Используется selection / removeBlockByMesh.
|
// НЕ создаёт реального меша. Используется selection / removeBlockByMesh.
|
||||||
const meshProxy = {
|
const meshProxy = {
|
||||||
@ -538,7 +511,6 @@ export class BlockManager {
|
|||||||
mass: 1,
|
mass: 1,
|
||||||
folderId: null,
|
folderId: null,
|
||||||
_thinIdx: idx,
|
_thinIdx: idx,
|
||||||
color: instColor, // per-instance цвет окрашиваемого блока
|
|
||||||
},
|
},
|
||||||
// Минимальные методы, которые ожидает остальной код
|
// Минимальные методы, которые ожидает остальной код
|
||||||
position: new Vector3(ix, iy + 0.5, iz),
|
position: new Vector3(ix, iy + 0.5, iz),
|
||||||
@ -566,18 +538,6 @@ export class BlockManager {
|
|||||||
proto.material = material;
|
proto.material = material;
|
||||||
if (isMulti) this._setupSubmeshes(proto);
|
if (isMulti) this._setupSubmeshes(proto);
|
||||||
|
|
||||||
// Окрашиваемый блок — включаем per-instance color buffer (vertex colors).
|
|
||||||
const _bt = getBlockType(blockTypeId);
|
|
||||||
if (_bt && _bt.colorable) {
|
|
||||||
proto.useVertexColors = true;
|
|
||||||
proto.hasVertexAlpha = false;
|
|
||||||
const buf = new Float32Array(this._STUDS_MAX * 4);
|
|
||||||
// Дефолт-цвет (белый) для всех слотов — иначе невыставленные = чёрные.
|
|
||||||
buf.fill(1);
|
|
||||||
proto.thinInstanceSetBuffer('color', buf, 4, false);
|
|
||||||
this._colorsByProto.set(blockTypeId, buf);
|
|
||||||
}
|
|
||||||
|
|
||||||
proto.checkCollisions = false; // коллизии через thin-instances не работают штатно;
|
proto.checkCollisions = false; // коллизии через thin-instances не работают штатно;
|
||||||
// PlayerController в Play-режиме читает blocks Map напрямую (см. PhysicsAABB).
|
// PlayerController в Play-режиме читает blocks Map напрямую (см. PhysicsAABB).
|
||||||
proto.isPickable = true;
|
proto.isPickable = true;
|
||||||
@ -611,44 +571,6 @@ export class BlockManager {
|
|||||||
/** Подписка для уведомлений о создании prototype-меша (для shadow setup). */
|
/** Подписка для уведомлений о создании prototype-меша (для shadow setup). */
|
||||||
setOnProtoCreated(cb) { this._onProtoCreated = cb; }
|
setOnProtoCreated(cb) { this._onProtoCreated = cb; }
|
||||||
|
|
||||||
/**
|
|
||||||
* Записать цвет инстанса окрашиваемого блока в color buffer (RGBA float).
|
|
||||||
* idx — индекс thin-instance. hex — '#rrggbb'. В batch-режиме обновление
|
|
||||||
* GPU откладывается (флаг dirty), иначе сразу thinInstanceBufferUpdated.
|
|
||||||
*/
|
|
||||||
_setBlockColorAt(blockTypeId, idx, hex) {
|
|
||||||
const buf = this._colorsByProto.get(blockTypeId);
|
|
||||||
if (!buf) return;
|
|
||||||
const c = Color3.FromHexString(hex || '#cccccc');
|
|
||||||
const o = idx * 4;
|
|
||||||
buf[o] = c.r; buf[o + 1] = c.g; buf[o + 2] = c.b; buf[o + 3] = 1;
|
|
||||||
const proto = this._protoMeshes.get(blockTypeId);
|
|
||||||
if (!proto) return;
|
|
||||||
if (this._batchMode) {
|
|
||||||
if (!this._colorDirtyProtos) this._colorDirtyProtos = new Set();
|
|
||||||
this._colorDirtyProtos.add(blockTypeId);
|
|
||||||
} else {
|
|
||||||
try { proto.thinInstanceBufferUpdated('color'); } catch (e) { /* ignore */ }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Сменить цвет окрашиваемого блока в (x,y,z) на лету (для scene.setColor /
|
|
||||||
* color-пикера). Возвращает true если блок окрашиваемый и цвет применён.
|
|
||||||
*/
|
|
||||||
setBlockColor(x, y, z, hex) {
|
|
||||||
const key = this._key(Math.round(x), Math.round(y), Math.round(z));
|
|
||||||
const inst = this._cellToInst.get(key);
|
|
||||||
if (!inst) return false;
|
|
||||||
const bt = getBlockType(inst.typeId);
|
|
||||||
if (!bt || !bt.colorable) return false;
|
|
||||||
this._setBlockColorAt(inst.typeId, inst.idx, hex);
|
|
||||||
const mp = this.blocks.get(key);
|
|
||||||
if (mp && mp.metadata) mp.metadata.color = hex;
|
|
||||||
this._notifyChange();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Установить флаг anchored у блока. */
|
/** Установить флаг anchored у блока. */
|
||||||
setBlockAnchored(x, y, z, anchored) {
|
setBlockAnchored(x, y, z, anchored) {
|
||||||
const mesh = this.blocks.get(this._key(x, y, z));
|
const mesh = this.blocks.get(this._key(x, y, z));
|
||||||
@ -845,8 +767,6 @@ export class BlockManager {
|
|||||||
canCollide: m.canCollide !== false,
|
canCollide: m.canCollide !== false,
|
||||||
visible: m.visible !== false,
|
visible: m.visible !== false,
|
||||||
mass: m.mass ?? 1,
|
mass: m.mass ?? 1,
|
||||||
// per-instance цвет окрашиваемого блока (studs-block); иначе пропускаем
|
|
||||||
...(m.color ? { color: m.color } : {}),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
@ -858,7 +778,7 @@ export class BlockManager {
|
|||||||
this._batchMode = true;
|
this._batchMode = true;
|
||||||
try {
|
try {
|
||||||
for (const b of arr) {
|
for (const b of arr) {
|
||||||
const mesh = this.addBlock(b.x, b.y, b.z, b.type, b.color);
|
const mesh = this.addBlock(b.x, b.y, b.z, b.type);
|
||||||
if (!mesh) continue;
|
if (!mesh) continue;
|
||||||
if (b.anchored === false) {
|
if (b.anchored === false) {
|
||||||
mesh.metadata.anchored = false;
|
mesh.metadata.anchored = false;
|
||||||
@ -882,14 +802,6 @@ export class BlockManager {
|
|||||||
proto.thinInstanceRefreshBoundingInfo(true);
|
proto.thinInstanceRefreshBoundingInfo(true);
|
||||||
} catch (e) { /* ignore */ }
|
} catch (e) { /* ignore */ }
|
||||||
}
|
}
|
||||||
// Финальный refresh color-буферов окрашиваемых блоков (batch).
|
|
||||||
if (this._colorDirtyProtos) {
|
|
||||||
for (const typeId of this._colorDirtyProtos) {
|
|
||||||
const proto = this._protoMeshes.get(typeId);
|
|
||||||
try { proto?.thinInstanceBufferUpdated('color'); } catch (e) { /* ignore */ }
|
|
||||||
}
|
|
||||||
this._colorDirtyProtos.clear();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
clear() {
|
clear() {
|
||||||
|
|||||||
@ -105,11 +105,6 @@ export const BLOCK_TYPES = [
|
|||||||
// top = stone, side = oven (4 стороны с дверцей — лучше чем раньше),
|
// top = stone, side = oven (4 стороны с дверцей — лучше чем раньше),
|
||||||
// bottom = stone. В будущем для одной двери понадобится 6-face формат.
|
// bottom = stone. В будущем для одной двери понадобится 6-face формат.
|
||||||
multiCube('oven', 'Печь', 'Особые', `${TEX}/stone.png`, `${TEX}/oven.png`, `${TEX}/stone.png`),
|
multiCube('oven', 'Печь', 'Особые', `${TEX}/stone.png`, `${TEX}/oven.png`, `${TEX}/stone.png`),
|
||||||
|
|
||||||
// === ОКРАШИВАЕМЫЕ (задача 09) — паритет со студией ===
|
|
||||||
cube('studs-block', 'Лего-кирпич', 'Окрашиваемые',
|
|
||||||
'/kubikon-assets/materials/studs_v4_diffuse.png',
|
|
||||||
{ colorable: true, normal: '/kubikon-assets/materials/studs_v4_normal.png', defaultColor: '#3a7aff' }),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Все доступные категории в порядке появления. */
|
/** Все доступные категории в порядке появления. */
|
||||||
@ -125,7 +120,6 @@ export const CATEGORY_COLORS = {
|
|||||||
'Кирпич': '#9d4a3a',
|
'Кирпич': '#9d4a3a',
|
||||||
'Особые': '#9966ff',
|
'Особые': '#9966ff',
|
||||||
'Природа': '#5a8c3e',
|
'Природа': '#5a8c3e',
|
||||||
'Окрашиваемые': '#3a7aff',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Найти описание блока по id. */
|
/** Найти описание блока по id. */
|
||||||
|
|||||||
@ -43,7 +43,7 @@ function normName(raw) {
|
|||||||
return String(raw || '')
|
return String(raw || '')
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.replace(/mixamorig/g, '')
|
.replace(/mixamorig/g, '')
|
||||||
.replace(/[:_\s.-]/g, '');
|
.replace(/[:_\s.\-]/g, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveLogicalR15(boneName) {
|
function resolveLogicalR15(boneName) {
|
||||||
|
|||||||
@ -91,14 +91,10 @@ export class Environment {
|
|||||||
this.fogEnabled = false;
|
this.fogEnabled = false;
|
||||||
this.fogColor = [0.7, 0.8, 0.9];
|
this.fogColor = [0.7, 0.8, 0.9];
|
||||||
this.fogDensity = 0.01;
|
this.fogDensity = 0.01;
|
||||||
// Видимые тела на небе (солнце и луна).
|
// Видимые тела на небе (солнце и луна) — создаём по запросу
|
||||||
// ВАЖНО (задача 16): единое небо рисует SkyboxManager. Environment больше
|
|
||||||
// НЕ рисует свою жёлтую сферу/луну — иначе на небе два солнца. Здесь
|
|
||||||
// остаётся только управление светом (направление/яркость/ambient).
|
|
||||||
this._drawSkyBodies = false;
|
|
||||||
this._sunMesh = null;
|
this._sunMesh = null;
|
||||||
this._moonMesh = null;
|
this._moonMesh = null;
|
||||||
if (this._drawSkyBodies) this._createSkyBodies();
|
this._createSkyBodies();
|
||||||
this._applyTime();
|
this._applyTime();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,236 +0,0 @@
|
|||||||
/**
|
|
||||||
* FloaterManager — всплывающие цифры урона (Damage Floaters), задача 40.
|
|
||||||
*
|
|
||||||
* game.fx.damageFloater(position, value, opts) → над точкой всплывает число,
|
|
||||||
* поднимается вверх, покачивается, плавно гаснет. Цвета: damage/crit/heal/
|
|
||||||
* mana/miss. Object pool из переиспользуемых billboard-планов (без create/
|
|
||||||
* destroy на каждый удар). Стек одинаковых по stackKey («×N»). Комикс-стиль
|
|
||||||
* (BAM!/KAPOW!/POW!).
|
|
||||||
*
|
|
||||||
* Билборд = плоскость с DynamicTexture (как LabelManager), billboardMode=7,
|
|
||||||
* renderingGroupId=1 (всегда поверх геометрии), disableDepthWrite.
|
|
||||||
*
|
|
||||||
* Фича-парность: тот же модуль в rublox-player/src/engine/.
|
|
||||||
*/
|
|
||||||
import { DynamicTexture } from '@babylonjs/core/Materials/Textures/dynamicTexture';
|
|
||||||
import { StandardMaterial } from '@babylonjs/core/Materials/standardMaterial';
|
|
||||||
import { MeshBuilder } from '@babylonjs/core/Meshes/meshBuilder';
|
|
||||||
import { Color3 } from '@babylonjs/core/Maths/math.color';
|
|
||||||
import { Mesh } from '@babylonjs/core/Meshes/mesh';
|
|
||||||
|
|
||||||
const POOL_SIZE = 30;
|
|
||||||
const TEX_W = 512, TEX_H = 256;
|
|
||||||
|
|
||||||
// Пресеты типов урона: цвет текста + множители.
|
|
||||||
const PRESETS = {
|
|
||||||
damage: { color: '#ff5a4a', stroke: '#3a0000' },
|
|
||||||
crit: { color: '#ffd23a', stroke: '#5a3a00' },
|
|
||||||
heal: { color: '#46e06a', stroke: '#063a14' },
|
|
||||||
mana: { color: '#4aa8ff', stroke: '#001a3a' },
|
|
||||||
miss: { color: '#b8b8b8', stroke: '#222222' },
|
|
||||||
};
|
|
||||||
|
|
||||||
function easeOutQuad(t) { return 1 - (1 - t) * (1 - t); }
|
|
||||||
|
|
||||||
export class FloaterManager {
|
|
||||||
constructor(scene3d) {
|
|
||||||
this.s = scene3d;
|
|
||||||
this.scene = scene3d.scene;
|
|
||||||
this.pool = [];
|
|
||||||
this._initialized = false;
|
|
||||||
this._stacks = new Map(); // stackKey → slot (для накопления ×N)
|
|
||||||
}
|
|
||||||
|
|
||||||
_init() {
|
|
||||||
if (this._initialized) return;
|
|
||||||
this._initialized = true;
|
|
||||||
for (let i = 0; i < POOL_SIZE; i++) {
|
|
||||||
const tex = new DynamicTexture(`floaterTex_${i}`, { width: TEX_W, height: TEX_H }, this.scene, true);
|
|
||||||
tex.hasAlpha = true;
|
|
||||||
const plane = MeshBuilder.CreatePlane(`floater_${i}`, { width: 2.4, height: 1.2, sideOrientation: Mesh.DOUBLESIDE }, this.scene);
|
|
||||||
const mat = new StandardMaterial(`floaterMat_${i}`, this.scene);
|
|
||||||
mat.diffuseTexture = tex;
|
|
||||||
mat.diffuseTexture.hasAlpha = true;
|
|
||||||
mat.emissiveColor = new Color3(1, 1, 1);
|
|
||||||
mat.diffuseColor = new Color3(0, 0, 0);
|
|
||||||
mat.disableLighting = true;
|
|
||||||
mat.backFaceCulling = false;
|
|
||||||
mat.disableDepthWrite = true;
|
|
||||||
mat.useAlphaFromDiffuseTexture = true;
|
|
||||||
plane.material = mat;
|
|
||||||
plane.billboardMode = 7;
|
|
||||||
plane.renderingGroupId = 1;
|
|
||||||
plane.isPickable = false;
|
|
||||||
plane.setEnabled(false);
|
|
||||||
this.pool.push({ plane, tex, mat, active: false, age: 0, lifetime: 0.8 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_acquire() {
|
|
||||||
for (const slot of this.pool) if (!slot.active) return slot;
|
|
||||||
return null; // все заняты — пропускаем новый floater (норма)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Главный API. position: {x,y,z}; value: число|строка; opts — см. задачу 40.
|
|
||||||
*/
|
|
||||||
spawn(position, value, opts = {}) {
|
|
||||||
this._init();
|
|
||||||
if (!position) return;
|
|
||||||
opts = opts || {};
|
|
||||||
|
|
||||||
// Стек: одинаковый stackKey за время жизни накапливает счётчик.
|
|
||||||
if (opts.stackKey && this._stacks.has(opts.stackKey)) {
|
|
||||||
const slot = this._stacks.get(opts.stackKey);
|
|
||||||
if (slot.active) {
|
|
||||||
slot.stackCount = (slot.stackCount || 1) + 1;
|
|
||||||
slot.age = Math.min(slot.age, slot.lifetime * 0.3); // продлеваем
|
|
||||||
this._draw(slot, slot.baseText, slot.preset, slot.fontSize, slot.comic, slot.stackCount);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const slot = this._acquire();
|
|
||||||
if (!slot) return;
|
|
||||||
|
|
||||||
// Тип floater'а.
|
|
||||||
let kind = 'damage';
|
|
||||||
if (opts.isCrit) kind = 'crit';
|
|
||||||
else if (opts.isHeal) kind = 'heal';
|
|
||||||
else if (opts.isMana) kind = 'mana';
|
|
||||||
else if (opts.isMiss) kind = 'miss';
|
|
||||||
const preset = PRESETS[kind];
|
|
||||||
const color = opts.color || preset.color;
|
|
||||||
const stroke = opts.strokeColor || preset.stroke;
|
|
||||||
|
|
||||||
let fontSize = Number.isFinite(opts.fontSize) ? opts.fontSize : 60;
|
|
||||||
let floatHeight = Number.isFinite(opts.floatHeight) ? opts.floatHeight : 2;
|
|
||||||
let lifetime = Number.isFinite(opts.lifetime) ? opts.lifetime : 0.9;
|
|
||||||
const randomOffset = Number.isFinite(opts.randomOffset) ? opts.randomOffset : (opts.isCrit ? 0.5 : 0.25);
|
|
||||||
|
|
||||||
// Текст: число с минусом (урон) или как есть (строка / heal с плюсом).
|
|
||||||
let baseText;
|
|
||||||
if (typeof value === 'string') baseText = value;
|
|
||||||
else if (opts.isHeal) baseText = '+' + value;
|
|
||||||
else if (opts.isMiss) baseText = String(value);
|
|
||||||
else baseText = '-' + Math.abs(value);
|
|
||||||
|
|
||||||
if (opts.isCrit) { fontSize = Math.round(fontSize * 1.4); floatHeight *= 1.2; }
|
|
||||||
|
|
||||||
slot.active = true;
|
|
||||||
slot.age = 0;
|
|
||||||
slot.lifetime = lifetime;
|
|
||||||
slot.floatHeight = floatHeight;
|
|
||||||
slot.isCrit = !!opts.isCrit;
|
|
||||||
slot.color = color; slot.stroke = stroke;
|
|
||||||
slot.preset = { color, stroke };
|
|
||||||
slot.fontSize = fontSize;
|
|
||||||
slot.comic = !!opts.comicStyle;
|
|
||||||
slot.baseText = baseText;
|
|
||||||
slot.stackCount = 1;
|
|
||||||
slot.stackKey = opts.stackKey || null;
|
|
||||||
|
|
||||||
const rx = (Math.random() - 0.5) * 2 * randomOffset;
|
|
||||||
const rz = (Math.random() - 0.5) * 2 * randomOffset;
|
|
||||||
slot.startX = position.x + rx;
|
|
||||||
slot.startY = position.y + (Number.isFinite(opts.yOffset) ? opts.yOffset : 1.5);
|
|
||||||
slot.startZ = position.z + rz;
|
|
||||||
slot.plane.position.set(slot.startX, slot.startY, slot.startZ);
|
|
||||||
slot.plane.scaling.set(1, 1, 1);
|
|
||||||
slot.plane.setEnabled(true);
|
|
||||||
|
|
||||||
this._draw(slot, baseText, slot.preset, fontSize, slot.comic, 1);
|
|
||||||
|
|
||||||
if (opts.stackKey) this._stacks.set(opts.stackKey, slot);
|
|
||||||
}
|
|
||||||
|
|
||||||
_draw(slot, baseText, preset, fontSize, comic, stackCount) {
|
|
||||||
const ctx = slot.tex.getContext();
|
|
||||||
ctx.clearRect(0, 0, TEX_W, TEX_H);
|
|
||||||
|
|
||||||
let text = baseText;
|
|
||||||
if (comic) {
|
|
||||||
const num = parseInt(String(baseText).replace(/[^0-9]/g, ''), 10) || 0;
|
|
||||||
if (slot.isCrit) text = 'POW!';
|
|
||||||
else if (num > 100) text = 'KAPOW!';
|
|
||||||
else if (num > 50) text = 'BAM!';
|
|
||||||
}
|
|
||||||
if (stackCount > 1) text = baseText + ' ×' + stackCount;
|
|
||||||
|
|
||||||
const fs = comic ? Math.round(fontSize * 1.1) : fontSize;
|
|
||||||
ctx.font = `900 ${fs}px ${comic ? 'Bangers, Impact, sans-serif' : 'Inter, Arial, sans-serif'}`;
|
|
||||||
ctx.textAlign = 'center';
|
|
||||||
ctx.textBaseline = 'middle';
|
|
||||||
ctx.lineJoin = 'round';
|
|
||||||
|
|
||||||
// Комикс-фон: жёлтая звезда-вспышка.
|
|
||||||
if (comic) {
|
|
||||||
ctx.save();
|
|
||||||
ctx.translate(TEX_W / 2, TEX_H / 2);
|
|
||||||
ctx.fillStyle = 'rgba(255,210,60,0.9)';
|
|
||||||
ctx.beginPath();
|
|
||||||
const spikes = 10, outer = 130, inner = 70;
|
|
||||||
for (let i = 0; i < spikes * 2; i++) {
|
|
||||||
const r = i % 2 === 0 ? outer : inner;
|
|
||||||
const a = (i / (spikes * 2)) * Math.PI * 2 - Math.PI / 2;
|
|
||||||
const px = Math.cos(a) * r, py = Math.sin(a) * r * 0.55;
|
|
||||||
i === 0 ? ctx.moveTo(px, py) : ctx.lineTo(px, py);
|
|
||||||
}
|
|
||||||
ctx.closePath(); ctx.fill();
|
|
||||||
ctx.restore();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Обводка + текст.
|
|
||||||
ctx.strokeStyle = comic ? '#000' : preset.stroke;
|
|
||||||
ctx.lineWidth = Math.max(6, fs * 0.16);
|
|
||||||
ctx.strokeText(text, TEX_W / 2, TEX_H / 2);
|
|
||||||
ctx.fillStyle = comic ? '#d22' : preset.color;
|
|
||||||
ctx.fillText(text, TEX_W / 2, TEX_H / 2);
|
|
||||||
|
|
||||||
slot.tex.update(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Вызывать каждый кадр (анимация подъёма + fade + покачивание + crit-pop). */
|
|
||||||
tick(dt) {
|
|
||||||
if (!this._initialized) return;
|
|
||||||
for (const slot of this.pool) {
|
|
||||||
if (!slot.active) continue;
|
|
||||||
slot.age += dt;
|
|
||||||
const t = slot.age / slot.lifetime;
|
|
||||||
if (t >= 1) {
|
|
||||||
slot.active = false;
|
|
||||||
slot.plane.setEnabled(false);
|
|
||||||
if (slot.stackKey && this._stacks.get(slot.stackKey) === slot) this._stacks.delete(slot.stackKey);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const ease = easeOutQuad(t);
|
|
||||||
slot.plane.position.y = slot.startY + slot.floatHeight * ease;
|
|
||||||
slot.plane.position.x = slot.startX + Math.sin(slot.age * 5) * 0.12;
|
|
||||||
|
|
||||||
// fade-in 0.12 / hold / fade-out 0.25
|
|
||||||
let alpha = 1;
|
|
||||||
if (t < 0.12) alpha = t / 0.12;
|
|
||||||
else if (t > 0.75) alpha = 1 - (t - 0.75) / 0.25;
|
|
||||||
slot.mat.alpha = Math.max(0, Math.min(1, alpha));
|
|
||||||
|
|
||||||
// crit pop: scale 1 → 1.3 → 1 в первые 0.4 жизни
|
|
||||||
if (slot.isCrit) {
|
|
||||||
let s = 1;
|
|
||||||
if (t < 0.2) s = 1 + (t / 0.2) * 0.3;
|
|
||||||
else if (t < 0.4) s = 1.3 - ((t - 0.2) / 0.2) * 0.3;
|
|
||||||
slot.plane.scaling.set(s, s, s);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dispose() {
|
|
||||||
for (const slot of this.pool) {
|
|
||||||
try { slot.plane.dispose(); slot.tex.dispose(); slot.mat.dispose(); } catch (e) {}
|
|
||||||
}
|
|
||||||
this.pool = []; this._stacks.clear(); this._initialized = false;
|
|
||||||
}
|
|
||||||
resetRuntime() {
|
|
||||||
for (const slot of this.pool) { slot.active = false; slot.plane?.setEnabled(false); }
|
|
||||||
this._stacks.clear();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@ -1,328 +0,0 @@
|
|||||||
/**
|
|
||||||
* GraphicsManager — система визуальных эффектов («шейдеры») для игр Рублокса.
|
|
||||||
*
|
|
||||||
* Управляет:
|
|
||||||
* - постобработкой экрана через Babylon DefaultRenderingPipeline:
|
|
||||||
* bloom (свечение), FXAA (сглаживание), виньетка, цветокоррекция
|
|
||||||
* (контраст/насыщенность/экспозиция), тонмаппинг, глубина резкости (DoF);
|
|
||||||
* - качеством теней (через scene3d.setShadowQuality);
|
|
||||||
* - контактными тенями SSAO (через scene3d.setSsaoEnabled).
|
|
||||||
*
|
|
||||||
* Управляется И из настроек игры (вкладка «Графика»), И из скриптов
|
|
||||||
* (game.graphics.*). По умолчанию ВСЁ ВЫКЛЮЧЕНО (preset 'off') — старые игры
|
|
||||||
* не меняются, FPS не страдает. Автор включает осознанно.
|
|
||||||
*
|
|
||||||
* Mobile-safe: на слабых устройствах тяжёлые эффекты (DoF, SSAO, ultra-тени,
|
|
||||||
* HDR-bloom) автоматически урезаются, даже если в пресете включены.
|
|
||||||
*
|
|
||||||
* Один и тот же класс используется в студии и плеере (фича-парность).
|
|
||||||
*
|
|
||||||
* Использование:
|
|
||||||
* const gfx = new GraphicsManager(scene, camera, scene3d, { mobile });
|
|
||||||
* gfx.apply({ preset: 'cinematic' });
|
|
||||||
* gfx.apply({ bloom: { enabled: true, intensity: 0.7 } });
|
|
||||||
* gfx.dispose();
|
|
||||||
*/
|
|
||||||
import {
|
|
||||||
DefaultRenderingPipeline, Color4, ImageProcessingConfiguration,
|
|
||||||
} from '@babylonjs/core';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Именованные пресеты. Каждый — полный набор настроек. 'off' = чистая картинка
|
|
||||||
* (pipeline не создаётся вовсе). Значения подобраны так, чтобы быть заметными,
|
|
||||||
* но не «кислотными».
|
|
||||||
*/
|
|
||||||
export const GRAPHICS_PRESETS = {
|
|
||||||
off: {
|
|
||||||
bloom: { enabled: false },
|
|
||||||
fxaa: false,
|
|
||||||
vignette: { enabled: false },
|
|
||||||
grading: { enabled: false },
|
|
||||||
dof: { enabled: false },
|
|
||||||
ssao: false,
|
|
||||||
shadows: null, // null = не трогаем текущее качество теней
|
|
||||||
},
|
|
||||||
// Лёгкий: только мягкое свечение + сглаживание. Дёшево, годится почти везде.
|
|
||||||
low: {
|
|
||||||
bloom: { enabled: true, intensity: 0.3, threshold: 0.9 },
|
|
||||||
fxaa: true,
|
|
||||||
vignette: { enabled: false },
|
|
||||||
grading: { enabled: false },
|
|
||||||
dof: { enabled: false },
|
|
||||||
ssao: false,
|
|
||||||
shadows: 'hard',
|
|
||||||
},
|
|
||||||
// Средний: свечение + лёгкая виньетка + чуть насыщенности.
|
|
||||||
medium: {
|
|
||||||
bloom: { enabled: true, intensity: 0.45, threshold: 0.85 },
|
|
||||||
fxaa: true,
|
|
||||||
vignette: { enabled: true, weight: 0.5 },
|
|
||||||
grading: { enabled: true, contrast: 1.05, saturation: 1.1, exposure: 1.0 },
|
|
||||||
dof: { enabled: false },
|
|
||||||
ssao: false,
|
|
||||||
shadows: 'soft',
|
|
||||||
},
|
|
||||||
// Высокий: всё кроме DoF, SSAO включён.
|
|
||||||
high: {
|
|
||||||
bloom: { enabled: true, intensity: 0.6, threshold: 0.82 },
|
|
||||||
fxaa: true,
|
|
||||||
vignette: { enabled: true, weight: 0.6 },
|
|
||||||
grading: { enabled: true, contrast: 1.1, saturation: 1.2, exposure: 1.05 },
|
|
||||||
dof: { enabled: false },
|
|
||||||
ssao: true,
|
|
||||||
shadows: 'soft',
|
|
||||||
},
|
|
||||||
// Ультра: + глубина резкости + мягкие каскадные тени.
|
|
||||||
ultra: {
|
|
||||||
bloom: { enabled: true, intensity: 0.7, threshold: 0.8 },
|
|
||||||
fxaa: true,
|
|
||||||
vignette: { enabled: true, weight: 0.65 },
|
|
||||||
grading: { enabled: true, contrast: 1.12, saturation: 1.25, exposure: 1.05 },
|
|
||||||
dof: { enabled: true, focusDistance: 18, focalLength: 50, aperture: 1.2 },
|
|
||||||
ssao: true,
|
|
||||||
shadows: 'high',
|
|
||||||
},
|
|
||||||
// === Стилевые пресеты (художественные) ===
|
|
||||||
cinematic: {
|
|
||||||
bloom: { enabled: true, intensity: 0.55, threshold: 0.8 },
|
|
||||||
fxaa: true,
|
|
||||||
vignette: { enabled: true, weight: 0.85 },
|
|
||||||
grading: { enabled: true, contrast: 1.18, saturation: 1.05, exposure: 1.0 },
|
|
||||||
dof: { enabled: true, focusDistance: 22, focalLength: 60, aperture: 1.0 },
|
|
||||||
ssao: true,
|
|
||||||
shadows: 'soft',
|
|
||||||
},
|
|
||||||
vivid: {
|
|
||||||
bloom: { enabled: true, intensity: 0.65, threshold: 0.78 },
|
|
||||||
fxaa: true,
|
|
||||||
vignette: { enabled: false },
|
|
||||||
grading: { enabled: true, contrast: 1.1, saturation: 1.5, exposure: 1.1 },
|
|
||||||
dof: { enabled: false },
|
|
||||||
ssao: false,
|
|
||||||
shadows: 'soft',
|
|
||||||
},
|
|
||||||
night: {
|
|
||||||
bloom: { enabled: true, intensity: 0.8, threshold: 0.7 },
|
|
||||||
fxaa: true,
|
|
||||||
vignette: { enabled: true, weight: 1.0 },
|
|
||||||
grading: { enabled: true, contrast: 1.2, saturation: 0.85, exposure: 0.8 },
|
|
||||||
dof: { enabled: false },
|
|
||||||
ssao: true,
|
|
||||||
shadows: 'soft',
|
|
||||||
},
|
|
||||||
retro: {
|
|
||||||
bloom: { enabled: false },
|
|
||||||
fxaa: false, // намеренно «пиксельно»
|
|
||||||
vignette: { enabled: true, weight: 1.2 },
|
|
||||||
grading: { enabled: true, contrast: 1.3, saturation: 0.7, exposure: 0.95 },
|
|
||||||
dof: { enabled: false },
|
|
||||||
ssao: false,
|
|
||||||
shadows: 'hard',
|
|
||||||
},
|
|
||||||
soft: {
|
|
||||||
bloom: { enabled: true, intensity: 0.4, threshold: 0.88 },
|
|
||||||
fxaa: true,
|
|
||||||
vignette: { enabled: true, weight: 0.4 },
|
|
||||||
grading: { enabled: true, contrast: 0.95, saturation: 1.05, exposure: 1.05 },
|
|
||||||
dof: { enabled: false },
|
|
||||||
ssao: false,
|
|
||||||
shadows: 'soft',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// Глубокое слияние пресета и пользовательских оверрайдов.
|
|
||||||
function _mergeConfig(base, over) {
|
|
||||||
const out = JSON.parse(JSON.stringify(base || {}));
|
|
||||||
if (!over) return out;
|
|
||||||
for (const k of Object.keys(over)) {
|
|
||||||
const v = over[k];
|
|
||||||
if (v && typeof v === 'object' && !Array.isArray(v)) {
|
|
||||||
out[k] = { ...(out[k] || {}), ...v };
|
|
||||||
} else {
|
|
||||||
out[k] = v;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class GraphicsManager {
|
|
||||||
/**
|
|
||||||
* @param scene Babylon Scene
|
|
||||||
* @param camera активная камера (для pipeline)
|
|
||||||
* @param scene3d ссылка на BabylonScene (для setShadowQuality / setSsaoEnabled / света)
|
|
||||||
* @param opts { mobile:boolean }
|
|
||||||
*/
|
|
||||||
constructor(scene, camera, scene3d, opts = {}) {
|
|
||||||
this.scene = scene;
|
|
||||||
this.camera = camera;
|
|
||||||
this.scene3d = scene3d;
|
|
||||||
this.mobile = !!opts.mobile;
|
|
||||||
this._pipeline = null;
|
|
||||||
// Текущая активная конфигурация (после merge + mobile-clamp).
|
|
||||||
this.config = _mergeConfig(GRAPHICS_PRESETS.off, null);
|
|
||||||
this.config.preset = 'off';
|
|
||||||
this.enabled = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Сменить камеру (например после смены режима камеры) — пересобрать pipeline. */
|
|
||||||
setCamera(camera) {
|
|
||||||
if (camera === this.camera) return;
|
|
||||||
this.camera = camera;
|
|
||||||
if (this.enabled) this._rebuildPipeline();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Применить настройки графики. Принимает либо {preset}, либо отдельные
|
|
||||||
* секции (bloom/vignette/grading/dof/ssao/fxaa/shadows), либо и то и другое
|
|
||||||
* (оверрайды поверх пресета). Сохраняет состояние в this.config.
|
|
||||||
*/
|
|
||||||
apply(settings = {}) {
|
|
||||||
let cfg;
|
|
||||||
if (settings.preset && GRAPHICS_PRESETS[settings.preset]) {
|
|
||||||
cfg = _mergeConfig(GRAPHICS_PRESETS[settings.preset], settings);
|
|
||||||
cfg.preset = settings.preset;
|
|
||||||
} else {
|
|
||||||
// частичный апдейт поверх текущего
|
|
||||||
cfg = _mergeConfig(this.config, settings);
|
|
||||||
cfg.preset = settings.preset || this.config.preset || 'custom';
|
|
||||||
}
|
|
||||||
this.config = this._clampForMobile(cfg);
|
|
||||||
this._applyConfig();
|
|
||||||
return this.config;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Полностью выключить эффекты (как preset 'off'). */
|
|
||||||
disableAll() {
|
|
||||||
return this.apply({ preset: 'off' });
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Текущая конфигурация (для serialize). */
|
|
||||||
serialize() {
|
|
||||||
// Храним «как просили» (preset + явные оверрайды). Для простоты — весь cfg.
|
|
||||||
return JSON.parse(JSON.stringify(this.config));
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- внутреннее ---
|
|
||||||
|
|
||||||
/** На слабых устройствах гасим самое дорогое, что бы ни просили. */
|
|
||||||
_clampForMobile(cfg) {
|
|
||||||
if (!this.mobile) return cfg;
|
|
||||||
const c = JSON.parse(JSON.stringify(cfg));
|
|
||||||
if (c.dof) c.dof.enabled = false; // DoF дорогой
|
|
||||||
c.ssao = false; // SSAO дорогой
|
|
||||||
if (c.shadows === 'high' || c.shadows === 'medium') c.shadows = 'hard';
|
|
||||||
// bloom оставляем, но без HDR (решается в _rebuildPipeline)
|
|
||||||
c._mobileClamped = true;
|
|
||||||
return c;
|
|
||||||
}
|
|
||||||
|
|
||||||
_applyConfig() {
|
|
||||||
const c = this.config;
|
|
||||||
const anyPipelineFx = (c.bloom && c.bloom.enabled) || c.fxaa
|
|
||||||
|| (c.vignette && c.vignette.enabled) || (c.grading && c.grading.enabled)
|
|
||||||
|| (c.dof && c.dof.enabled);
|
|
||||||
|
|
||||||
// Тени и SSAO — через scene3d (они вне pipeline).
|
|
||||||
try {
|
|
||||||
if (c.shadows && this.scene3d?.setShadowQuality) {
|
|
||||||
this.scene3d.setShadowQuality(c.shadows);
|
|
||||||
}
|
|
||||||
} catch (e) { /* ignore */ }
|
|
||||||
try {
|
|
||||||
if (this.scene3d?.setSsaoEnabled) this.scene3d.setSsaoEnabled(!!c.ssao);
|
|
||||||
} catch (e) { /* ignore */ }
|
|
||||||
|
|
||||||
if (!anyPipelineFx) {
|
|
||||||
this.enabled = false;
|
|
||||||
this._disposePipeline();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.enabled = true;
|
|
||||||
this._rebuildPipeline();
|
|
||||||
}
|
|
||||||
|
|
||||||
_rebuildPipeline() {
|
|
||||||
this._disposePipeline();
|
|
||||||
if (!this.scene || !this.camera) return;
|
|
||||||
const c = this.config;
|
|
||||||
const useHdr = (c.bloom && c.bloom.enabled) && !this.mobile;
|
|
||||||
|
|
||||||
const p = new DefaultRenderingPipeline('rbx_graphics', useHdr, this.scene, [this.camera]);
|
|
||||||
|
|
||||||
// Bloom
|
|
||||||
p.bloomEnabled = !!(c.bloom && c.bloom.enabled);
|
|
||||||
if (p.bloomEnabled) {
|
|
||||||
p.bloomThreshold = c.bloom.threshold ?? 0.85;
|
|
||||||
p.bloomWeight = c.bloom.intensity ?? 0.5;
|
|
||||||
p.bloomKernel = this.mobile ? 32 : 64;
|
|
||||||
p.bloomScale = 0.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
// FXAA
|
|
||||||
p.fxaaEnabled = !!c.fxaa;
|
|
||||||
p.samples = this.mobile ? 1 : 4;
|
|
||||||
|
|
||||||
// Image processing: виньетка + цветокоррекция + (опц.) тонмаппинг
|
|
||||||
const ip = p.imageProcessing;
|
|
||||||
if (ip) {
|
|
||||||
p.imageProcessingEnabled = true;
|
|
||||||
ip.toneMappingEnabled = false; // как в GdPostFx — иначе картинка темнеет
|
|
||||||
// экспозиция/контраст из grading
|
|
||||||
if (c.grading && c.grading.enabled) {
|
|
||||||
ip.exposure = c.grading.exposure ?? 1.0;
|
|
||||||
ip.contrast = c.grading.contrast ?? 1.0;
|
|
||||||
ip.colorCurvesEnabled = true;
|
|
||||||
try {
|
|
||||||
const curves = ip.colorCurves;
|
|
||||||
if (curves) {
|
|
||||||
// saturation: 1.0 = норма → curves в диапазоне примерно -100..100
|
|
||||||
const sat = c.grading.saturation ?? 1.0;
|
|
||||||
curves.globalSaturation = Math.round((sat - 1.0) * 60);
|
|
||||||
}
|
|
||||||
} catch (e) { /* ignore */ }
|
|
||||||
} else {
|
|
||||||
ip.exposure = 1.0; ip.contrast = 1.0;
|
|
||||||
}
|
|
||||||
// виньетка
|
|
||||||
if (c.vignette && c.vignette.enabled) {
|
|
||||||
ip.vignetteEnabled = true;
|
|
||||||
ip.vignetteWeight = c.vignette.weight ?? 0.6;
|
|
||||||
ip.vignetteColor = new Color4(0, 0, 0, 0);
|
|
||||||
ip.vignetteStretch = 0.3;
|
|
||||||
ip.vignetteCameraFov = 0.5;
|
|
||||||
ip.vignetteBlendMode = ImageProcessingConfiguration.VIGNETTEMODE_MULTIPLY;
|
|
||||||
} else {
|
|
||||||
ip.vignetteEnabled = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Depth of Field (глубина резкости) — только desktop
|
|
||||||
if (c.dof && c.dof.enabled && !this.mobile) {
|
|
||||||
p.depthOfFieldEnabled = true;
|
|
||||||
try {
|
|
||||||
p.depthOfFieldBlurLevel = 1; // 0..2
|
|
||||||
p.depthOfField.focusDistance = (c.dof.focusDistance ?? 18) * 1000; // мм
|
|
||||||
p.depthOfField.focalLength = c.dof.focalLength ?? 50;
|
|
||||||
p.depthOfField.fStop = c.dof.aperture ?? 1.2;
|
|
||||||
} catch (e) { /* ignore */ }
|
|
||||||
} else {
|
|
||||||
p.depthOfFieldEnabled = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
this._pipeline = p;
|
|
||||||
}
|
|
||||||
|
|
||||||
_disposePipeline() {
|
|
||||||
if (this._pipeline) {
|
|
||||||
try { this._pipeline.dispose(); } catch (e) { /* ignore */ }
|
|
||||||
this._pipeline = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dispose() {
|
|
||||||
this._disposePipeline();
|
|
||||||
this.scene = null;
|
|
||||||
this.camera = null;
|
|
||||||
this.scene3d = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -99,11 +99,6 @@ export class GuiManager {
|
|||||||
w: opts.w ?? _defaultSize(type).w,
|
w: opts.w ?? _defaultSize(type).w,
|
||||||
h: opts.h ?? _defaultSize(type).h,
|
h: opts.h ?? _defaultSize(type).h,
|
||||||
anchor: opts.anchor || 'center',
|
anchor: opts.anchor || 'center',
|
||||||
// Phase 6.3.1: AnchorPoint -- точка ВНУТРИ элемента, относительно
|
|
||||||
// которой считается позиция x/y. По умолчанию НЕ задан -- рендерер
|
|
||||||
// вычислит дефолт из anchor (center → {0.5,0.5}, top-left → {0,0}, ...).
|
|
||||||
// Юзер может override через opts.anchorPoint = {x:0..1, y:0..1}.
|
|
||||||
anchorPoint: opts.anchorPoint || null,
|
|
||||||
visible: opts.visible !== false,
|
visible: opts.visible !== false,
|
||||||
bgColor: opts.bgColor ?? _defaultBgColor(type),
|
bgColor: opts.bgColor ?? _defaultBgColor(type),
|
||||||
bgOpacity: opts.bgOpacity ?? _defaultBgOpacity(type),
|
bgOpacity: opts.bgOpacity ?? _defaultBgOpacity(type),
|
||||||
@ -123,42 +118,17 @@ export class GuiManager {
|
|||||||
placeholder: opts.placeholder ?? (type === 'textbox' ? 'Введите текст…' : ''),
|
placeholder: opts.placeholder ?? (type === 'textbox' ? 'Введите текст…' : ''),
|
||||||
onClickScriptId: opts.onClickScriptId ?? null,
|
onClickScriptId: opts.onClickScriptId ?? null,
|
||||||
zIndex: opts.zIndex ?? this.elements.length + 1,
|
zIndex: opts.zIndex ?? this.elements.length + 1,
|
||||||
// Авто-раскладка детей (Фаза 5.3 + 6.3.2):
|
// Авто-раскладка детей (Фаза 5.3, для frame/scroll):
|
||||||
// 'none' -- дети как есть (по своим x/y);
|
// 'none' — дети как есть (по своим x/y);
|
||||||
// 'vertical' -- дети в столбик; 'horizontal' -- в строку;
|
// 'vertical' — дети в столбик; 'horizontal' — в строку.
|
||||||
// 'grid' -- сетка (требует layoutCellW/H/Cols).
|
|
||||||
layout: opts.layout ?? 'none',
|
layout: opts.layout ?? 'none',
|
||||||
// Отступ между детьми и внутреннее поле контейнера (в % размера контейнера).
|
// Отступ между детьми и внутреннее поле контейнера (в % размера контейнера).
|
||||||
layoutGap: opts.layoutGap ?? 2,
|
layoutGap: opts.layoutGap ?? 2,
|
||||||
layoutPad: opts.layoutPad ?? 3,
|
layoutPad: opts.layoutPad ?? 3,
|
||||||
// Phase 6.3.2: параметры grid-layout.
|
|
||||||
// cellW/H -- размер каждой ячейки в %; cols -- количество колонок (0 = авто).
|
|
||||||
layoutCellW: opts.layoutCellW ?? 18,
|
|
||||||
layoutCellH: opts.layoutCellH ?? 18,
|
|
||||||
layoutCols: opts.layoutCols ?? 0,
|
|
||||||
// Текущая прокрутка scroll-контейнера (в %, только для type='scroll').
|
// Текущая прокрутка scroll-контейнера (в %, только для type='scroll').
|
||||||
scrollY: opts.scrollY ?? 0,
|
scrollY: opts.scrollY ?? 0,
|
||||||
// Тень под элементом (Фаза 5.4) — мягкая drop-shadow.
|
// Тень под элементом (Фаза 5.4) — мягкая drop-shadow.
|
||||||
shadow: opts.shadow ?? false,
|
shadow: opts.shadow ?? false,
|
||||||
// === Задача 03: расширения для красивого UI + анимаций ===
|
|
||||||
// Линейный градиент фона. Формат: { stops: ['#a','#b'] | [{c,p}], angle: 0..360 }.
|
|
||||||
bgGradient: opts.bgGradient ?? null,
|
|
||||||
// Обводка текста (для крупных подписей "X2 ДЕНЕГ"). { color, width }.
|
|
||||||
textStroke: opts.textStroke ?? null,
|
|
||||||
// Поворот элемента в градусах (transform: rotate).
|
|
||||||
rotation: opts.rotation ?? 0,
|
|
||||||
// Scale-множитель (transform: scale). 1 = нормальный размер.
|
|
||||||
scaleX: opts.scaleX ?? 1,
|
|
||||||
scaleY: opts.scaleY ?? 1,
|
|
||||||
// Бейдж-маркер в углу: { corner, icon, color, text }.
|
|
||||||
badge: opts.badge ?? null,
|
|
||||||
// Hover-реакция (только для button/image-button): { scale, rotation, brightness, duration, easing }.
|
|
||||||
hover: opts.hover ?? null,
|
|
||||||
// Active-реакция (зажатие ЛКМ): { scale, duration }.
|
|
||||||
active: opts.active ?? null,
|
|
||||||
// Анимация-пресет: 'none'|'pulse'|'rotate'|'sway'|'glow'|'bounce'|'custom'.
|
|
||||||
// Раскрывается в реальный tween при applyAnimationPreset(id) в Play.
|
|
||||||
animationPreset: opts.animationPreset ?? 'none',
|
|
||||||
// Создан скриптом в Play (game.gui.create) — НЕ сериализуется
|
// Создан скриптом в Play (game.gui.create) — НЕ сериализуется
|
||||||
// в проект, удаляется при Stop.
|
// в проект, удаляется при Stop.
|
||||||
_scriptCreated: opts._scriptCreated === true,
|
_scriptCreated: opts._scriptCreated === true,
|
||||||
|
|||||||
@ -1,370 +0,0 @@
|
|||||||
/**
|
|
||||||
* InventoryUI — drag-drop инвентарь (задача 44): сетка 8×5 + hotbar 9 + стаки +
|
|
||||||
* редкости + ПКМ-меню + tooltip. Самодостаточный DOM-оверлей (как
|
|
||||||
* LoadingScreenOverlay) — крепится к canvas.parentElement, работает в студии и
|
|
||||||
* плеере одинаково.
|
|
||||||
*
|
|
||||||
* Хранит: item-defs (game.items.define), слоты основного инвентаря (GRID),
|
|
||||||
* слоты hotbar (HOTBAR), активный hotbar-слот. Постоянный hotbar внизу HUD;
|
|
||||||
* окно инвентаря по клавише I (toggle).
|
|
||||||
*
|
|
||||||
* API (через game.inventory.* / game.items.*):
|
|
||||||
* game.items.define({id,name,icon,rarity,maxStack,description,value,onUse,tags})
|
|
||||||
* game.inventory.add(itemId, count) / remove / has / count
|
|
||||||
* game.inventory.open() / close() / toggle() / isOpen()
|
|
||||||
* game.inventory.move(from, to) / split(slot, n) / sort(by) / use(slot)
|
|
||||||
* game.inventory.setActiveHotbar(i) / getActiveItem()
|
|
||||||
*
|
|
||||||
* Фича-парность: тот же модуль в rublox-player/src/engine/.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const GRID = 40; // 8×5 основной инвентарь
|
|
||||||
const COLS = 8;
|
|
||||||
const HOTBAR = 9;
|
|
||||||
|
|
||||||
const RARITY = {
|
|
||||||
common: { color: '#bbbbbb', label: 'Обычное' },
|
|
||||||
uncommon: { color: '#5cb85c', label: 'Необычное' },
|
|
||||||
rare: { color: '#5bc0de', label: 'Редкое' },
|
|
||||||
epic: { color: '#9b59b6', label: 'Эпическое' },
|
|
||||||
legendary: { color: '#f0ad4e', label: 'Легендарное' },
|
|
||||||
};
|
|
||||||
|
|
||||||
export class InventoryUI {
|
|
||||||
constructor(scene3d) {
|
|
||||||
this.s = scene3d;
|
|
||||||
this.defs = new Map(); // itemId → def
|
|
||||||
this.grid = new Array(GRID).fill(null); // {itemId,count}|null
|
|
||||||
this.hotbar = new Array(HOTBAR).fill(null);
|
|
||||||
this.active = 0;
|
|
||||||
this._open = false;
|
|
||||||
this.root = null; this.hotbarRoot = null; this.tooltip = null; this.ctxMenu = null;
|
|
||||||
this._drag = null; // {from:'grid'|'hotbar', idx}
|
|
||||||
this._onChange = [];
|
|
||||||
this._events = { added: [], removed: [], used: [], slot: [] };
|
|
||||||
this._opts = { allowDrop: true, allowSplit: true, showRarity: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Определения предметов ───────────────────────────────────────────────
|
|
||||||
defineItem(def) {
|
|
||||||
if (!def || typeof def.id !== 'string') return;
|
|
||||||
this.defs.set(def.id, {
|
|
||||||
id: def.id, name: def.name || def.id,
|
|
||||||
icon: def.icon || null, emoji: def.emoji || null,
|
|
||||||
rarity: RARITY[def.rarity] ? def.rarity : 'common',
|
|
||||||
maxStack: Number(def.maxStack) > 0 ? Number(def.maxStack) : 1,
|
|
||||||
description: def.description || '', value: Number(def.value) || 0,
|
|
||||||
tags: Array.isArray(def.tags) ? def.tags : [],
|
|
||||||
onUseEffect: def.onUseEffect || null, // 'heal:50' | 'speed:1.5:5' | null
|
|
||||||
});
|
|
||||||
}
|
|
||||||
_def(id) { return this.defs.get(id) || { id, name: id, rarity: 'common', maxStack: 99, emoji: '📦', icon: null, description: '', value: 0, tags: [] }; }
|
|
||||||
|
|
||||||
// ── Операции ────────────────────────────────────────────────────────────
|
|
||||||
add(itemId, count = 1) {
|
|
||||||
const def = this._def(itemId);
|
|
||||||
let left = count;
|
|
||||||
// 1) долить в существующие стаки (сначала hotbar — он на виду, потом grid)
|
|
||||||
const fill = (arr) => {
|
|
||||||
for (let i = 0; i < arr.length && left > 0; i++) {
|
|
||||||
const s = arr[i];
|
|
||||||
if (s && s.itemId === itemId && s.count < def.maxStack) {
|
|
||||||
const room = def.maxStack - s.count;
|
|
||||||
const take = Math.min(room, left);
|
|
||||||
s.count += take; left -= take;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
fill(this.hotbar); fill(this.grid);
|
|
||||||
// 2) в пустые слоты (сначала hotbar — собранное видно сразу, потом grid)
|
|
||||||
const place = (arr) => {
|
|
||||||
for (let i = 0; i < arr.length && left > 0; i++) {
|
|
||||||
if (!arr[i]) { const take = Math.min(def.maxStack, left); arr[i] = { itemId, count: take }; left -= take; }
|
|
||||||
}
|
|
||||||
};
|
|
||||||
place(this.hotbar); place(this.grid);
|
|
||||||
const added = count - left;
|
|
||||||
if (added > 0) { this._emit('added', { itemId, count: added }); this._changed(); }
|
|
||||||
return { added, overflow: left };
|
|
||||||
}
|
|
||||||
|
|
||||||
remove(itemId, count = 1) {
|
|
||||||
let left = count;
|
|
||||||
const drain = (arr) => {
|
|
||||||
for (let i = arr.length - 1; i >= 0 && left > 0; i--) {
|
|
||||||
const s = arr[i];
|
|
||||||
if (s && s.itemId === itemId) {
|
|
||||||
const take = Math.min(s.count, left);
|
|
||||||
s.count -= take; left -= take;
|
|
||||||
if (s.count <= 0) arr[i] = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
drain(this.hotbar); drain(this.grid);
|
|
||||||
const removed = count - left;
|
|
||||||
if (removed > 0) { this._emit('removed', { itemId, count: removed }); this._changed(); }
|
|
||||||
return removed;
|
|
||||||
}
|
|
||||||
|
|
||||||
count(itemId) {
|
|
||||||
let n = 0;
|
|
||||||
for (const s of this.grid) if (s && s.itemId === itemId) n += s.count;
|
|
||||||
for (const s of this.hotbar) if (s && s.itemId === itemId) n += s.count;
|
|
||||||
return n;
|
|
||||||
}
|
|
||||||
has(itemId, n = 1) { return this.count(itemId) >= n; }
|
|
||||||
|
|
||||||
/** slot-ref: число 0..39 = grid; строка 'h0'..'h8' = hotbar. */
|
|
||||||
_arrIdx(ref) {
|
|
||||||
if (typeof ref === 'string' && ref[0] === 'h') return { arr: this.hotbar, idx: parseInt(ref.slice(1), 10) };
|
|
||||||
return { arr: this.grid, idx: Number(ref) };
|
|
||||||
}
|
|
||||||
move(from, to) {
|
|
||||||
const a = this._arrIdx(from), b = this._arrIdx(to);
|
|
||||||
if (!a.arr || !b.arr || a.idx == null || b.idx == null) return;
|
|
||||||
if (a.arr === b.arr && a.idx === b.idx) return;
|
|
||||||
const src = a.arr[a.idx], dst = b.arr[b.idx];
|
|
||||||
// merge одинаковых стаков
|
|
||||||
if (src && dst && src.itemId === dst.itemId) {
|
|
||||||
const def = this._def(src.itemId);
|
|
||||||
const room = def.maxStack - dst.count;
|
|
||||||
if (room > 0) {
|
|
||||||
const take = Math.min(room, src.count);
|
|
||||||
dst.count += take; src.count -= take;
|
|
||||||
if (src.count <= 0) a.arr[a.idx] = null;
|
|
||||||
this._changed(); return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// swap
|
|
||||||
a.arr[a.idx] = dst; b.arr[b.idx] = src;
|
|
||||||
this._changed();
|
|
||||||
}
|
|
||||||
split(ref, n) {
|
|
||||||
if (!this._opts.allowSplit) return;
|
|
||||||
const { arr, idx } = this._arrIdx(ref);
|
|
||||||
const s = arr[idx]; if (!s || s.count <= 1) return;
|
|
||||||
const take = Math.max(1, Math.min(s.count - 1, n || Math.floor(s.count / 2)));
|
|
||||||
const empty = this.grid.indexOf(null);
|
|
||||||
if (empty < 0) return;
|
|
||||||
s.count -= take; this.grid[empty] = { itemId: s.itemId, count: take };
|
|
||||||
this._changed();
|
|
||||||
}
|
|
||||||
sort(by = 'rarity') {
|
|
||||||
const order = { legendary: 0, epic: 1, rare: 2, uncommon: 3, common: 4 };
|
|
||||||
const all = this.grid.filter(Boolean);
|
|
||||||
all.sort((x, y) => {
|
|
||||||
const dx = this._def(x.itemId), dy = this._def(y.itemId);
|
|
||||||
if (by === 'rarity') return (order[dx.rarity] - order[dy.rarity]) || dx.name.localeCompare(dy.name);
|
|
||||||
if (by === 'name') return dx.name.localeCompare(dy.name);
|
|
||||||
return dx.id.localeCompare(dy.id);
|
|
||||||
});
|
|
||||||
this.grid = all.concat(new Array(GRID - all.length).fill(null));
|
|
||||||
this._changed();
|
|
||||||
}
|
|
||||||
use(ref) {
|
|
||||||
const { arr, idx } = this._arrIdx(ref);
|
|
||||||
const s = arr[idx]; if (!s) return;
|
|
||||||
const def = this._def(s.itemId);
|
|
||||||
let consume = false;
|
|
||||||
if (def.onUseEffect) {
|
|
||||||
const [eff, a, b] = String(def.onUseEffect).split(':');
|
|
||||||
try {
|
|
||||||
if (eff === 'heal') { this.s?.player?.heal?.(Number(a) || 25); consume = true; }
|
|
||||||
else if (eff === 'speed') { this.s?.player?.setSpeed?.(Number(a) || 1.5); consume = true; }
|
|
||||||
} catch (e) { /* ignore */ }
|
|
||||||
}
|
|
||||||
this._emit('used', { itemId: s.itemId });
|
|
||||||
if (consume) { s.count -= 1; if (s.count <= 0) arr[idx] = null; this._changed(); }
|
|
||||||
}
|
|
||||||
setActiveHotbar(i) { this.active = Math.max(0, Math.min(HOTBAR - 1, i | 0)); this._renderHotbar(); }
|
|
||||||
getActiveItem() { const s = this.hotbar[this.active]; return s ? { ...s, def: this._def(s.itemId) } : null; }
|
|
||||||
|
|
||||||
onChange(fn) { if (typeof fn === 'function') this._onChange.push(fn); }
|
|
||||||
on(evt, fn) { if (this._events[evt] && typeof fn === 'function') this._events[evt].push(fn); }
|
|
||||||
_emit(evt, data) { for (const fn of (this._events[evt] || [])) { try { fn(data); } catch (e) {} } }
|
|
||||||
_changed() {
|
|
||||||
for (const fn of this._onChange) { try { fn(); } catch (e) {} }
|
|
||||||
this._emit('slot', {});
|
|
||||||
if (this._open) this._renderGrid();
|
|
||||||
this._renderHotbar();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── DOM: hotbar (постоянный) ───────────────────────────────────────────
|
|
||||||
_parent() { return (this.s && this.s.canvas && this.s.canvas.parentElement) || document.body; }
|
|
||||||
mountHotbar() {
|
|
||||||
if (this.hotbarRoot) return;
|
|
||||||
const r = document.createElement('div');
|
|
||||||
r.style.cssText = 'position:absolute;left:50%;bottom:64px;transform:translateX(-50%);z-index:48;display:flex;gap:6px;pointer-events:auto;font-family:Inter,system-ui,sans-serif';
|
|
||||||
this._parent().appendChild(r); this.hotbarRoot = r;
|
|
||||||
this._renderHotbar();
|
|
||||||
}
|
|
||||||
_slotInner(s) {
|
|
||||||
if (!s) return '';
|
|
||||||
const def = this._def(s.itemId);
|
|
||||||
const icon = def.icon ? `<img src="${def.icon}" style="width:80%;height:80%;object-fit:contain;pointer-events:none">`
|
|
||||||
: `<span style="font-size:26px;pointer-events:none">${def.emoji || '📦'}</span>`;
|
|
||||||
const cnt = s.count > 1 ? `<span style="position:absolute;right:3px;bottom:1px;font-size:13px;font-weight:900;color:#fff;text-shadow:0 1px 2px #000">${s.count}</span>` : '';
|
|
||||||
return icon + cnt;
|
|
||||||
}
|
|
||||||
_slotStyle(s, activeBorder) {
|
|
||||||
const rc = (s && this._opts.showRarity) ? RARITY[this._def(s.itemId).rarity].color : 'rgba(255,255,255,0.15)';
|
|
||||||
const border = activeBorder ? '#ffd23a' : rc;
|
|
||||||
return `position:relative;width:52px;height:52px;border-radius:10px;border:2px solid ${border};background:rgba(20,26,40,0.7);display:flex;align-items:center;justify-content:center;cursor:pointer;box-shadow:0 2px 8px rgba(0,0,0,0.3)` + (activeBorder ? ';box-shadow:0 0 12px #ffd23a' : '');
|
|
||||||
}
|
|
||||||
_renderHotbar() {
|
|
||||||
if (!this.hotbarRoot) return;
|
|
||||||
this.hotbarRoot.innerHTML = '';
|
|
||||||
for (let i = 0; i < HOTBAR; i++) {
|
|
||||||
const s = this.hotbar[i];
|
|
||||||
const cell = document.createElement('div');
|
|
||||||
cell.style.cssText = this._slotStyle(s, i === this.active);
|
|
||||||
cell.innerHTML = `<span style="position:absolute;left:3px;top:1px;font-size:11px;color:#9aa3b2;font-weight:700">${i + 1}</span>` + this._slotInner(s);
|
|
||||||
cell.onmouseenter = (e) => this._showTooltip(s, e);
|
|
||||||
cell.onmouseleave = () => this._hideTooltip();
|
|
||||||
cell.onclick = () => { this.setActiveHotbar(i); };
|
|
||||||
cell.oncontextmenu = (e) => { e.preventDefault(); this._openCtx('h' + i, e); };
|
|
||||||
this._wireDrag(cell, 'h' + i);
|
|
||||||
this.hotbarRoot.appendChild(cell);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── DOM: окно инвентаря ─────────────────────────────────────────────────
|
|
||||||
open() { if (this._open) return; this._open = true; this._mountWindow(); }
|
|
||||||
close() { this._open = false; if (this.root) { try { this.root.remove(); } catch (e) {} this.root = null; } this._hideTooltip(); this._closeCtx(); }
|
|
||||||
toggle() { this._open ? this.close() : this.open(); }
|
|
||||||
isOpen() { return this._open; }
|
|
||||||
|
|
||||||
_mountWindow() {
|
|
||||||
const overlay = document.createElement('div');
|
|
||||||
overlay.style.cssText = 'position:absolute;inset:0;z-index:70;background:rgba(8,10,16,0.6);backdrop-filter:blur(4px);display:flex;align-items:center;justify-content:center;font-family:Inter,system-ui,sans-serif;pointer-events:auto';
|
|
||||||
overlay.onclick = (e) => { if (e.target === overlay) this.close(); };
|
|
||||||
const panel = document.createElement('div');
|
|
||||||
panel.style.cssText = 'width:min(640px,94%);background:#141826;border:1px solid rgba(255,255,255,0.12);border-radius:18px;padding:20px;color:#e8ecf2;box-shadow:0 20px 60px rgba(0,0,0,0.5)';
|
|
||||||
panel.onclick = (e) => e.stopPropagation();
|
|
||||||
panel.innerHTML =
|
|
||||||
'<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:14px">' +
|
|
||||||
'<div style="font-size:22px;font-weight:800">🎒 Инвентарь</div>' +
|
|
||||||
'<div style="display:flex;gap:8px">' +
|
|
||||||
'<button id="_inv_sort" style="height:34px;padding:0 14px;border-radius:9px;background:#2a3550;border:1px solid rgba(255,255,255,0.15);color:#fff;cursor:pointer;font-weight:700">Сорт.</button>' +
|
|
||||||
'<button id="_inv_close" style="width:34px;height:34px;border-radius:9px;background:rgba(255,255,255,0.1);border:1px solid rgba(255,255,255,0.15);color:#fff;font-size:18px;cursor:pointer">✕</button>' +
|
|
||||||
'</div></div>' +
|
|
||||||
'<div id="_inv_grid" style="display:grid;grid-template-columns:repeat(' + COLS + ',1fr);gap:6px"></div>' +
|
|
||||||
'<div style="margin:16px 0 6px;font-size:13px;color:#9aa3b2;font-weight:700">Панель быстрого доступа (1-9)</div>' +
|
|
||||||
'<div id="_inv_hb" style="display:grid;grid-template-columns:repeat(' + HOTBAR + ',1fr);gap:6px"></div>';
|
|
||||||
overlay.appendChild(panel); this._parent().appendChild(overlay); this.root = overlay;
|
|
||||||
panel.querySelector('#_inv_close').onclick = () => this.close();
|
|
||||||
panel.querySelector('#_inv_sort').onclick = () => this.sort('rarity');
|
|
||||||
this._gridEl = panel.querySelector('#_inv_grid');
|
|
||||||
this._hbEl = panel.querySelector('#_inv_hb');
|
|
||||||
this._renderGrid();
|
|
||||||
}
|
|
||||||
_renderGrid() {
|
|
||||||
if (!this._gridEl) return;
|
|
||||||
const build = (el, arr, prefix) => {
|
|
||||||
el.innerHTML = '';
|
|
||||||
for (let i = 0; i < arr.length; i++) {
|
|
||||||
const ref = prefix === 'h' ? 'h' + i : i;
|
|
||||||
const s = arr[i];
|
|
||||||
const cell = document.createElement('div');
|
|
||||||
cell.style.cssText = this._slotStyle(s, prefix === 'h' && i === this.active).replace('52px', '56px');
|
|
||||||
cell.innerHTML = (prefix === 'h' ? `<span style="position:absolute;left:3px;top:1px;font-size:11px;color:#9aa3b2;font-weight:700">${i + 1}</span>` : '') + this._slotInner(s);
|
|
||||||
cell.onmouseenter = (e) => this._showTooltip(s, e);
|
|
||||||
cell.onmouseleave = () => this._hideTooltip();
|
|
||||||
cell.oncontextmenu = (e) => { e.preventDefault(); this._openCtx(ref, e); };
|
|
||||||
if (prefix === 'h') cell.onclick = () => this.setActiveHotbar(i);
|
|
||||||
this._wireDrag(cell, ref);
|
|
||||||
el.appendChild(cell);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
build(this._gridEl, this.grid, 'g');
|
|
||||||
if (this._hbEl) build(this._hbEl, this.hotbar, 'h');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Drag-drop (HTML5 native) ────────────────────────────────────────────
|
|
||||||
_wireDrag(cell, ref) {
|
|
||||||
cell.draggable = true;
|
|
||||||
cell.addEventListener('dragstart', (e) => {
|
|
||||||
this._drag = ref; cell.style.opacity = '0.4';
|
|
||||||
try { e.dataTransfer.setData('text/plain', String(ref)); e.dataTransfer.effectAllowed = 'move'; } catch (er) {}
|
|
||||||
});
|
|
||||||
cell.addEventListener('dragend', () => { cell.style.opacity = '1'; this._drag = null; });
|
|
||||||
cell.addEventListener('dragover', (e) => { e.preventDefault(); });
|
|
||||||
cell.addEventListener('drop', (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const from = this._drag;
|
|
||||||
if (from != null && String(from) !== String(ref)) this.move(from, ref);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Tooltip ──────────────────────────────────────────────────────────────
|
|
||||||
_showTooltip(s, e) {
|
|
||||||
if (!s) return;
|
|
||||||
this._hideTooltip();
|
|
||||||
const def = this._def(s.itemId), rc = RARITY[def.rarity];
|
|
||||||
const t = document.createElement('div');
|
|
||||||
t.style.cssText = 'position:absolute;z-index:90;max-width:240px;padding:10px 12px;background:rgba(12,16,26,0.96);border:1px solid ' + rc.color + ';border-radius:10px;color:#e8ecf2;font-family:Inter,system-ui,sans-serif;font-size:13px;pointer-events:none;box-shadow:0 6px 20px rgba(0,0,0,0.5)';
|
|
||||||
t.innerHTML =
|
|
||||||
'<div style="font-weight:800;color:' + rc.color + '">' + this._esc(def.name) + '</div>' +
|
|
||||||
'<div style="font-size:11px;color:#9aa3b2;margin:2px 0">' + rc.label + (def.tags.length ? ' · ' + def.tags.join(', ') : '') + '</div>' +
|
|
||||||
(def.description ? '<div style="margin-top:4px">' + this._esc(def.description) + '</div>' : '') +
|
|
||||||
(def.value ? '<div style="margin-top:4px;color:#ffd23a">💰 ' + def.value + '</div>' : '');
|
|
||||||
document.body.appendChild(t);
|
|
||||||
const x = (e && e.clientX) || 0, y = (e && e.clientY) || 0;
|
|
||||||
t.style.left = Math.min(x + 14, window.innerWidth - 250) + 'px';
|
|
||||||
t.style.top = (y + 14) + 'px';
|
|
||||||
this.tooltip = t;
|
|
||||||
}
|
|
||||||
_hideTooltip() { if (this.tooltip) { try { this.tooltip.remove(); } catch (e) {} this.tooltip = null; } }
|
|
||||||
|
|
||||||
// ── ПКМ-меню (Use/Split/Drop) ─────────────────────────────────────────────
|
|
||||||
_openCtx(ref, e) {
|
|
||||||
this._closeCtx();
|
|
||||||
const { arr, idx } = this._arrIdx(ref);
|
|
||||||
const s = arr[idx]; if (!s) return;
|
|
||||||
const m = document.createElement('div');
|
|
||||||
m.style.cssText = 'position:absolute;z-index:95;background:#1a2030;border:1px solid rgba(255,255,255,0.15);border-radius:10px;padding:5px;min-width:140px;font-family:Inter,system-ui,sans-serif;box-shadow:0 8px 24px rgba(0,0,0,0.5)';
|
|
||||||
const item = (label, fn) => {
|
|
||||||
const b = document.createElement('div');
|
|
||||||
b.textContent = label;
|
|
||||||
b.style.cssText = 'padding:8px 12px;border-radius:7px;cursor:pointer;color:#e8ecf2;font-size:14px';
|
|
||||||
b.onmouseenter = () => b.style.background = 'rgba(255,255,255,0.08)';
|
|
||||||
b.onmouseleave = () => b.style.background = 'transparent';
|
|
||||||
b.onclick = () => { fn(); this._closeCtx(); };
|
|
||||||
m.appendChild(b);
|
|
||||||
};
|
|
||||||
item('Использовать', () => this.use(ref));
|
|
||||||
if (this._opts.allowSplit && s.count > 1) item('Разделить', () => this.split(ref, Math.floor(s.count / 2)));
|
|
||||||
if (this._opts.allowDrop && !this._def(s.itemId).tags.includes('quest')) item('Выбросить', () => { arr[idx] = null; this._changed(); });
|
|
||||||
item('Отмена', () => {});
|
|
||||||
document.body.appendChild(m);
|
|
||||||
m.style.left = Math.min((e.clientX || 0), window.innerWidth - 150) + 'px';
|
|
||||||
m.style.top = (e.clientY || 0) + 'px';
|
|
||||||
this.ctxMenu = m;
|
|
||||||
setTimeout(() => { this._ctxCloser = () => this._closeCtx(); window.addEventListener('click', this._ctxCloser, { once: true }); }, 0);
|
|
||||||
}
|
|
||||||
_closeCtx() { if (this.ctxMenu) { try { this.ctxMenu.remove(); } catch (e) {} this.ctxMenu = null; } }
|
|
||||||
|
|
||||||
_esc(str) { return String(str).replace(/[&<>]/g, c => ({ '&': '&', '<': '<', '>': '>' }[c])); }
|
|
||||||
|
|
||||||
// ── Сериализация ──────────────────────────────────────────────────────────
|
|
||||||
serialize() {
|
|
||||||
return { defs: [...this.defs.values()], grid: this.grid, hotbar: this.hotbar, active: this.active, opts: this._opts };
|
|
||||||
}
|
|
||||||
load(data) {
|
|
||||||
if (!data) return;
|
|
||||||
if (Array.isArray(data.defs)) for (const d of data.defs) this.defineItem(d);
|
|
||||||
if (Array.isArray(data.grid)) this.grid = data.grid.slice(0, GRID).concat(new Array(Math.max(0, GRID - data.grid.length)).fill(null));
|
|
||||||
if (Array.isArray(data.hotbar)) this.hotbar = data.hotbar.slice(0, HOTBAR).concat(new Array(Math.max(0, HOTBAR - data.hotbar.length)).fill(null));
|
|
||||||
if (typeof data.active === 'number') this.active = data.active;
|
|
||||||
if (data.opts) this._opts = { ...this._opts, ...data.opts };
|
|
||||||
}
|
|
||||||
|
|
||||||
dispose() {
|
|
||||||
this.close();
|
|
||||||
if (this.hotbarRoot) { try { this.hotbarRoot.remove(); } catch (e) {} this.hotbarRoot = null; }
|
|
||||||
}
|
|
||||||
resetRuntime() {
|
|
||||||
this.close();
|
|
||||||
if (this.hotbarRoot) { try { this.hotbarRoot.remove(); } catch (e) {} this.hotbarRoot = null; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,385 +1,80 @@
|
|||||||
/**
|
/**
|
||||||
* LabelManager — billboard-плашки (текст-надписи) над 3D-объектами.
|
* LabelManager — billboard-метки (текст-плашки) над 3D-объектами.
|
||||||
*
|
*
|
||||||
* game.scene.setLabel(ref, text, opts) — имена/HP/таймеры/счётчики над
|
* Используется для game.scene.setLabel(ref, text) — имена/HP над
|
||||||
* персонажами, врагами, предметами. По умолчанию плашка повёрнута лицом к
|
* персонажами, врагами, предметами. Метка всегда повёрнута лицом к камере
|
||||||
* камере (billboardMode=7), всегда поверх геометрии (renderingGroupId=1).
|
* (billboardMode=7), всегда поверх геометрии (renderingGroupId=1).
|
||||||
*
|
*
|
||||||
* Задача 10 — расширенные стили: фон/обводка/скругление (пресеты gameui/
|
* Метка привязывается к мешу объекта (parent) и висит над ним.
|
||||||
* warning/reward/boss-hp/plain), обводка текста, richText (<color>/<b>/<size>),
|
|
||||||
* faceMode billboard|fixed, attachPoint, maxDistance.
|
|
||||||
*
|
|
||||||
* Плашка привязывается к мешу объекта (parent) и висит над ним.
|
|
||||||
*/
|
*/
|
||||||
import { DynamicTexture } from '@babylonjs/core/Materials/Textures/dynamicTexture';
|
import { DynamicTexture } from '@babylonjs/core/Materials/Textures/dynamicTexture';
|
||||||
import { StandardMaterial } from '@babylonjs/core/Materials/standardMaterial';
|
import { StandardMaterial } from '@babylonjs/core/Materials/standardMaterial';
|
||||||
import { MeshBuilder } from '@babylonjs/core/Meshes/meshBuilder';
|
import { MeshBuilder } from '@babylonjs/core/Meshes/meshBuilder';
|
||||||
import { Color3 } from '@babylonjs/core/Maths/math.color';
|
import { Color3 } from '@babylonjs/core/Maths/math.color';
|
||||||
import { Mesh } from '@babylonjs/core/Meshes/mesh';
|
|
||||||
|
|
||||||
// === Пресеты стилей плашки (фон/обводка/текст) ===
|
|
||||||
// gameui — жёлтая обводка + тёмно-синий фон + белый текст (как в Roblox-UI).
|
|
||||||
export const LABEL_PRESETS = {
|
|
||||||
plain: {
|
|
||||||
background: null, borderColor: null, borderWidth: 0, cornerRadius: 0,
|
|
||||||
color: '#ffffff', textStroke: { color: '#000', width: 8 },
|
|
||||||
},
|
|
||||||
gameui: {
|
|
||||||
background: '#1a3a8a', borderColor: '#f5c020', borderWidth: 10, cornerRadius: 28,
|
|
||||||
color: '#ffffff', textStroke: { color: '#0a1430', width: 6 },
|
|
||||||
},
|
|
||||||
warning: {
|
|
||||||
background: '#b01e1e', borderColor: '#ffce3a', borderWidth: 10, cornerRadius: 28,
|
|
||||||
color: '#ffffff', textStroke: { color: '#000', width: 6 },
|
|
||||||
},
|
|
||||||
reward: {
|
|
||||||
background: '#caa018', borderColor: '#fff0a0', borderWidth: 10, cornerRadius: 28,
|
|
||||||
color: '#fff8e0', textStroke: { color: '#6b4e00', width: 6 },
|
|
||||||
gradient: ['#f7d34a', '#c98a18'], // золотой градиент фона
|
|
||||||
},
|
|
||||||
'boss-hp': {
|
|
||||||
background: '#3a0a0a', borderColor: '#ff4040', borderWidth: 8, cornerRadius: 20,
|
|
||||||
color: '#ffd0d0', textStroke: { color: '#000', width: 6 },
|
|
||||||
gradient: ['#8a1414', '#3a0a0a'],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export class LabelManager {
|
export class LabelManager {
|
||||||
constructor(scene) {
|
constructor(scene) {
|
||||||
this.scene = scene;
|
this.scene = scene;
|
||||||
// ref-строка объекта → { plane, tex, mat, lastKey, opts }
|
// ref-строка объекта → { plane, tex, mat }
|
||||||
this.labels = new Map();
|
this.labels = new Map();
|
||||||
this._playerMesh = null; // для maxDistance — задаётся из BabylonScene
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Дать ссылку на меш игрока (для maxDistance-скрытия). */
|
|
||||||
setPlayerMesh(mesh) { this._playerMesh = mesh; }
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Установить/обновить плашку над объектом.
|
* Установить/обновить метку над объектом.
|
||||||
* ref — ref-строка объекта.
|
* ref — ref-строка объекта (от scene.spawn / scene.find).
|
||||||
* anchorMesh — Babylon-меш объекта (плашка крепится к нему).
|
* anchorMesh — Babylon-меш объекта (метка крепится к нему).
|
||||||
* text — текст (может содержать richText-теги если opts.richText).
|
* text — текст метки.
|
||||||
* opts — см. LABEL_PRESETS + { color, height, size, background,
|
* opts — { color: '#fff', height: 2.5 (м над объектом), size: 1 }
|
||||||
* borderColor, borderWidth, cornerRadius, padding, textStroke,
|
|
||||||
* fontWeight, faceMode, rotationY, attachPoint, preset,
|
|
||||||
* richText, maxDistance }
|
|
||||||
*/
|
*/
|
||||||
setLabel(ref, anchorMesh, text, opts = {}) {
|
setLabel(ref, anchorMesh, text, opts = {}) {
|
||||||
if (!anchorMesh) return;
|
if (!anchorMesh) return;
|
||||||
text = String(text == null ? '' : text);
|
const color = opts.color || '#ffffff';
|
||||||
|
|
||||||
// Пресет → база, поверх — явные opts.
|
|
||||||
const preset = opts.preset && LABEL_PRESETS[opts.preset] ? LABEL_PRESETS[opts.preset] : null;
|
|
||||||
const st = { ...(preset || {}), ...opts };
|
|
||||||
const color = st.color || '#ffffff';
|
|
||||||
const heightAbove = Number.isFinite(opts.height) ? opts.height : 2.5;
|
const heightAbove = Number.isFinite(opts.height) ? opts.height : 2.5;
|
||||||
const sizeMul = Number.isFinite(opts.size) ? opts.size : 1;
|
const sizeMul = Number.isFinite(opts.size) ? opts.size : 1;
|
||||||
const richText = !!opts.richText;
|
|
||||||
|
|
||||||
// Диф-чек: если ничего не поменялось — не пересоздаём (важно для bindLabel).
|
|
||||||
const styleKey = JSON.stringify({ text, color, preset: opts.preset, bg: st.background,
|
|
||||||
bc: st.borderColor, bw: st.borderWidth, cr: st.cornerRadius, rich: richText,
|
|
||||||
fw: st.fontWeight, h: heightAbove, sz: sizeMul, fm: st.faceMode, ry: st.rotationY,
|
|
||||||
af: st.attachFace, tl: st.tilt, ap: typeof st.attachPoint === 'string' ? st.attachPoint : null });
|
|
||||||
const existing = this.labels.get(ref);
|
|
||||||
if (existing && existing.lastKey === styleKey && existing.plane.parent === anchorMesh) {
|
|
||||||
return; // ничего не изменилось
|
|
||||||
}
|
|
||||||
|
|
||||||
// Меняется только текст (тот же стиль/размер) → перерисуем canvas без
|
|
||||||
// пересоздания меша (дешевле). Иначе — полное пересоздание.
|
|
||||||
const sameStruct = existing && existing.styleStruct === this._structKey(st, richText, heightAbove, sizeMul);
|
|
||||||
if (sameStruct) {
|
|
||||||
this._drawCanvas(existing.tex, text, color, st, richText);
|
|
||||||
existing.tex.update(true);
|
|
||||||
existing.lastKey = styleKey;
|
|
||||||
existing.lastText = text;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// Если метка уже есть — пересоздаём (текст/цвет могли измениться).
|
||||||
this.clearLabel(ref);
|
this.clearLabel(ref);
|
||||||
|
|
||||||
// Размер текстуры: чем больше текста — тем шире, чтобы не растягивать.
|
|
||||||
const fontPx = 120;
|
|
||||||
const W = 1024, H = 256;
|
const W = 1024, H = 256;
|
||||||
const tex = new DynamicTexture(`lblTex_${ref}_${this._uid()}`,
|
const tex = new DynamicTexture(`lblTex_${ref}_${Date.now()}`,
|
||||||
{ width: W, height: H }, this.scene, true);
|
{ width: W, height: H }, this.scene, true);
|
||||||
tex.updateSamplingMode?.(3); // TRILINEAR
|
tex.updateSamplingMode?.(3); // TRILINEAR
|
||||||
tex.anisotropicFilteringLevel = 8;
|
tex.anisotropicFilteringLevel = 8;
|
||||||
tex.hasAlpha = true;
|
const ctx = tex.getContext();
|
||||||
this._drawCanvas(tex, text, color, st, richText);
|
ctx.clearRect(0, 0, W, H);
|
||||||
|
ctx.font = 'bold 120px "Roboto Condensed", "Segoe UI", sans-serif';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
ctx.lineWidth = 16;
|
||||||
|
ctx.lineJoin = 'round';
|
||||||
|
ctx.strokeStyle = '#000';
|
||||||
|
ctx.strokeText(String(text), W / 2, H / 2);
|
||||||
|
ctx.fillStyle = color;
|
||||||
|
ctx.fillText(String(text), W / 2, H / 2);
|
||||||
tex.update(true);
|
tex.update(true);
|
||||||
|
tex.hasAlpha = true;
|
||||||
|
|
||||||
// Соотношение плашки — 4:1 (1024×256). Крупная и читаемая (как в Roblox).
|
|
||||||
// ОДНОСТОРОННЯЯ плоскость (FRONTSIDE): лицо = нормаль +Z. Мы всегда
|
|
||||||
// разворачиваем плашку лицом к наблюдателю снаружи грани, поэтому текст
|
|
||||||
// читается прямо. DOUBLESIDE НЕ используем — задняя грань зеркалит UV
|
|
||||||
// (баг: «Ключ от тайника» наоборот). backFaceCulling выключен только
|
|
||||||
// чтобы плашка не пропадала при взгляде сзади (без отражённого текста).
|
|
||||||
const plane = MeshBuilder.CreatePlane(`lbl_${ref}`,
|
const plane = MeshBuilder.CreatePlane(`lbl_${ref}`,
|
||||||
{ width: 3.4 * sizeMul, height: 0.85 * sizeMul,
|
{ width: 2.2 * sizeMul, height: 0.55 * sizeMul }, this.scene);
|
||||||
sideOrientation: Mesh.FRONTSIDE }, this.scene);
|
|
||||||
const mat = new StandardMaterial(`lblMat_${ref}`, this.scene);
|
const mat = new StandardMaterial(`lblMat_${ref}`, this.scene);
|
||||||
mat.diffuseTexture = tex;
|
mat.diffuseTexture = tex;
|
||||||
mat.diffuseTexture.hasAlpha = true;
|
mat.diffuseTexture.hasAlpha = true;
|
||||||
mat.emissiveColor = new Color3(1, 1, 1);
|
mat.emissiveColor = new Color3(1, 1, 1);
|
||||||
mat.diffuseColor = new Color3(0, 0, 0);
|
|
||||||
mat.disableLighting = true;
|
mat.disableLighting = true;
|
||||||
// Геометрия двусторонняя (DOUBLESIDE) с прямыми backUVs — culling можно
|
|
||||||
// включить, дублей нет; текст читается с обеих сторон без зеркала.
|
|
||||||
mat.backFaceCulling = false;
|
mat.backFaceCulling = false;
|
||||||
mat.disableDepthWrite = true;
|
mat.disableDepthWrite = true;
|
||||||
mat.useAlphaFromDiffuseTexture = true;
|
|
||||||
plane.material = mat;
|
plane.material = mat;
|
||||||
plane.renderingGroupId = 1;
|
|
||||||
plane.isPickable = false;
|
|
||||||
plane.parent = anchorMesh;
|
|
||||||
|
|
||||||
// Полуразмеры объекта по каждой оси (для крепления над верхом ИЛИ на
|
|
||||||
// грань). Берём bounding box в ЛОКАЛЬНЫХ координатах меша (min/max), чтобы
|
|
||||||
// позиция плашки-ребёнка была верной при любом масштабе/вращении родителя.
|
|
||||||
let halfX = 0.5, halfY = 0.5, halfZ = 0.5;
|
|
||||||
try {
|
|
||||||
const bb = anchorMesh.getBoundingInfo?.().boundingBox;
|
|
||||||
if (bb && bb.minimum && bb.maximum) {
|
|
||||||
halfX = (bb.maximum.x - bb.minimum.x) / 2;
|
|
||||||
halfY = (bb.maximum.y - bb.minimum.y) / 2;
|
|
||||||
halfZ = (bb.maximum.z - bb.minimum.z) / 2;
|
|
||||||
} else if (anchorMesh.scaling) {
|
|
||||||
halfX = Math.abs(anchorMesh.scaling.x) / 2;
|
|
||||||
halfY = Math.abs(anchorMesh.scaling.y) / 2;
|
|
||||||
halfZ = Math.abs(anchorMesh.scaling.z) / 2;
|
|
||||||
}
|
|
||||||
} catch (e) { /* ignore */ }
|
|
||||||
const halfH = halfY;
|
|
||||||
const halfPlane = 0.45 * sizeMul; // полувысота самой плашки (3.4×0.85)
|
|
||||||
|
|
||||||
// attachFace — ПРИКРЕПИТЬ плашку НА ГРАНЬ объекта (как табличка на
|
|
||||||
// стене/полу): плашка лежит В ПЛОСКОСТИ грани, фиксированной ориентации,
|
|
||||||
// и перемещается/вращается ВМЕСТЕ с объектом (она его ребёнок). Это
|
|
||||||
// Roblox-style «надпись = часть постройки» (в отличие от billboard над
|
|
||||||
// верхом). Значения: 'top'|'bottom'|'front'|'back'|'left'|'right'
|
|
||||||
// (или сырые '+y'/'-y'/'+z'/'-z'/'+x'/'-x').
|
|
||||||
const FACE = { top: '+y', bottom: '-y', front: '+z', back: '-z',
|
|
||||||
right: '+x', left: '-x' };
|
|
||||||
let face = st.attachFace;
|
|
||||||
if (face && FACE[face]) face = FACE[face];
|
|
||||||
|
|
||||||
if (face) {
|
|
||||||
// На грань — всегда фиксированная ориентация (не billboard), иначе
|
|
||||||
// «связки с примитивом» не будет (плашка крутилась бы к камере).
|
|
||||||
plane.billboardMode = 0;
|
|
||||||
const gap = Number.isFinite(opts.height) ? opts.height : 0.05;
|
|
||||||
// ВАЖНО: Babylon CreatePlane (FRONTSIDE) лицевой стороной (где UV/текст
|
|
||||||
// не зеркалятся) смотрит в −Z. Поэтому чтобы ЛИЦО таблички смотрело
|
|
||||||
// НАРУЖУ выбранной грани, поворачиваем плоскость так, чтобы её −Z
|
|
||||||
// совпал с внешней нормалью грани. tiltSign — знак наклона tilt с
|
|
||||||
// учётом того, что для грани +z плоскость развёрнута на π.
|
|
||||||
let tiltSign = 1;
|
|
||||||
if (face === '+z') { plane.position.set(0, 0, halfZ + gap); plane.rotation.set(0, Math.PI, 0); tiltSign = -1; }
|
|
||||||
else if (face === '-z') { plane.position.set(0, 0, -(halfZ + gap)); plane.rotation.set(0, 0, 0); }
|
|
||||||
else if (face === '+x') { plane.position.set( halfX + gap, 0, 0); plane.rotation.set(0, -Math.PI / 2, 0); }
|
|
||||||
else if (face === '-x') { plane.position.set(-(halfX + gap), 0, 0); plane.rotation.set(0, Math.PI / 2, 0); }
|
|
||||||
else if (face === '+y') { plane.position.set(0, halfY + gap, 0); plane.rotation.set( Math.PI / 2, 0, 0); }
|
|
||||||
else if (face === '-y') { plane.position.set(0, -(halfY + gap), 0); plane.rotation.set(-Math.PI / 2, 0, 0); }
|
|
||||||
if (Number.isFinite(st.rotationY)) plane.rotation.y += st.rotationY;
|
|
||||||
// tilt — наклон таблички вокруг локальной X (ценник под ~45°, как на
|
|
||||||
// витрине Roblox). Знак нормализуем (tiltSign), чтобы «верх назад» был
|
|
||||||
// одинаковым для всех граней. Отрицательный tilt = верх отклоняется
|
|
||||||
// назад (от наблюдателя), как пюпитр.
|
|
||||||
if (Number.isFinite(st.tilt)) plane.rotation.x += st.tilt * tiltSign;
|
|
||||||
} else {
|
|
||||||
// faceMode: 'fixed' — фиксированная ориентация (вращается с объектом),
|
|
||||||
// но позиционируется как обычная плашка (над верхом/центром/низом).
|
|
||||||
if (st.faceMode === 'fixed') {
|
|
||||||
plane.billboardMode = 0;
|
|
||||||
if (Number.isFinite(st.rotationY)) plane.rotation.y = st.rotationY;
|
|
||||||
} else {
|
|
||||||
plane.billboardMode = 7; // всегда лицом к камере
|
plane.billboardMode = 7; // всегда лицом к камере
|
||||||
}
|
plane.renderingGroupId = 1; // поверх геометрии
|
||||||
// attachPoint: 'top'(default) — над верхом + небольшой зазор (height);
|
plane.isPickable = false;
|
||||||
// 'center' — по центру; 'bottom' — у низа; {x,y,z} — явно.
|
// Крепим к объекту: метка висит над ним и двигается вместе с ним.
|
||||||
const gap = Number.isFinite(opts.height) ? opts.height : 0.6;
|
plane.parent = anchorMesh;
|
||||||
let py = halfH + gap + halfPlane; // верх объекта + зазор + полувысота плашки
|
plane.position.set(0, heightAbove, 0);
|
||||||
if (st.attachPoint === 'center') py = 0;
|
|
||||||
else if (st.attachPoint === 'bottom') py = -(halfH + gap);
|
this.labels.set(ref, { plane, tex, mat });
|
||||||
else if (st.attachPoint && typeof st.attachPoint === 'object') {
|
|
||||||
plane.position.set(st.attachPoint.x || 0, st.attachPoint.y || 0, st.attachPoint.z || 0);
|
|
||||||
py = null;
|
|
||||||
}
|
|
||||||
if (py !== null) plane.position.set(0, py, 0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.labels.set(ref, {
|
/** Убрать метку с объекта. */
|
||||||
plane, tex, mat,
|
|
||||||
lastKey: styleKey,
|
|
||||||
lastText: text,
|
|
||||||
styleStruct: this._structKey(st, richText, heightAbove, sizeMul),
|
|
||||||
maxDistance: Number.isFinite(opts.maxDistance) ? opts.maxDistance : null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Ключ «структуры» (всё кроме текста) — для решения перерисовать ли canvas. */
|
|
||||||
_structKey(st, richText, h, sz) {
|
|
||||||
return JSON.stringify({ p: st.preset, bg: st.background, bc: st.borderColor,
|
|
||||||
bw: st.borderWidth, cr: st.cornerRadius, rich: richText, fw: st.fontWeight,
|
|
||||||
grad: st.gradient, ts: st.textStroke, h, sz, fm: st.faceMode,
|
|
||||||
af: st.attachFace, tl: st.tilt, ap: typeof st.attachPoint === 'string' ? st.attachPoint : null });
|
|
||||||
}
|
|
||||||
|
|
||||||
_uid() { this._seq = (this._seq || 0) + 1; return this._seq; }
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Нарисовать плашку на canvas DynamicTexture.
|
|
||||||
* Фон (roundRect + gradient/fill) → обводка border → текст (с обводкой).
|
|
||||||
*/
|
|
||||||
_drawCanvas(tex, text, color, st, richText) {
|
|
||||||
const W = 1024, H = 256;
|
|
||||||
const ctx = tex.getContext();
|
|
||||||
ctx.clearRect(0, 0, W, H);
|
|
||||||
|
|
||||||
const hasBg = !!st.background || (Array.isArray(st.gradient) && st.gradient.length === 2);
|
|
||||||
const pad = Number.isFinite(st.padding) ? st.padding : 28;
|
|
||||||
const cr = Number.isFinite(st.cornerRadius) ? st.cornerRadius : 0;
|
|
||||||
const bw = Number.isFinite(st.borderWidth) ? st.borderWidth : 0;
|
|
||||||
|
|
||||||
const weight = st.fontWeight || 700;
|
|
||||||
const innerPad = (hasBg ? (bw + pad) : 24); // отступ от краёв (под рамку)
|
|
||||||
const maxTextW = W - innerPad * 2;
|
|
||||||
// Auto-fit: подбираем размер шрифта, чтобы текст влез по ширине (не обрезался).
|
|
||||||
let fontPx = 120;
|
|
||||||
if (!richText) {
|
|
||||||
ctx.font = `${weight} ${fontPx}px "Roboto Condensed", "Segoe UI", sans-serif`;
|
|
||||||
const tw = ctx.measureText(text).width;
|
|
||||||
if (tw > maxTextW) fontPx = Math.max(40, Math.floor(fontPx * maxTextW / tw));
|
|
||||||
}
|
|
||||||
ctx.font = `${weight} ${fontPx}px "Roboto Condensed", "Segoe UI", sans-serif`;
|
|
||||||
ctx.textAlign = 'center';
|
|
||||||
ctx.textBaseline = 'middle';
|
|
||||||
|
|
||||||
// === Фон-плашка ===
|
|
||||||
if (hasBg) {
|
|
||||||
const m = bw / 2 + 4; // отступ рамки от края текстуры
|
|
||||||
const x = m, y = m, w = W - m * 2, h = H - m * 2;
|
|
||||||
this._roundRectPath(ctx, x, y, w, h, cr);
|
|
||||||
if (Array.isArray(st.gradient) && st.gradient.length === 2) {
|
|
||||||
const g = ctx.createLinearGradient(0, y, 0, y + h);
|
|
||||||
g.addColorStop(0, st.gradient[0]);
|
|
||||||
g.addColorStop(1, st.gradient[1]);
|
|
||||||
ctx.fillStyle = g;
|
|
||||||
} else {
|
|
||||||
ctx.fillStyle = st.background;
|
|
||||||
}
|
|
||||||
ctx.globalAlpha = Number.isFinite(st.backgroundOpacity) ? st.backgroundOpacity : 0.92;
|
|
||||||
ctx.fill();
|
|
||||||
ctx.globalAlpha = 1;
|
|
||||||
if (bw > 0 && st.borderColor) {
|
|
||||||
ctx.lineWidth = bw;
|
|
||||||
ctx.strokeStyle = st.borderColor;
|
|
||||||
ctx.stroke();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Текст ===
|
|
||||||
const ts = st.textStroke || { color: '#000', width: hasBg ? 6 : 10 };
|
|
||||||
if (richText) {
|
|
||||||
this._drawRichText(ctx, text, color, ts, W, H);
|
|
||||||
} else {
|
|
||||||
if (ts && ts.width > 0) {
|
|
||||||
ctx.lineWidth = ts.width;
|
|
||||||
ctx.lineJoin = 'round';
|
|
||||||
ctx.strokeStyle = ts.color || '#000';
|
|
||||||
ctx.strokeText(text, W / 2, H / 2 + 4);
|
|
||||||
}
|
|
||||||
ctx.fillStyle = color;
|
|
||||||
ctx.fillText(text, W / 2, H / 2 + 4);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Путь скруглённого прямоугольника (roundRect не везде есть). */
|
|
||||||
_roundRectPath(ctx, x, y, w, h, r) {
|
|
||||||
r = Math.min(r, w / 2, h / 2);
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(x + r, y);
|
|
||||||
ctx.arcTo(x + w, y, x + w, y + h, r);
|
|
||||||
ctx.arcTo(x + w, y + h, x, y + h, r);
|
|
||||||
ctx.arcTo(x, y + h, x, y, r);
|
|
||||||
ctx.arcTo(x, y, x + w, y, r);
|
|
||||||
ctx.closePath();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* RichText: парсим теги <color=#hex>...</color>, <b>...</b>, <size=N>...</size>.
|
|
||||||
* Рисуем сегментами по горизонтали, центрируя всю строку. Вложенность не
|
|
||||||
* поддерживается (на MVP) — берём последний открытый тег каждого типа.
|
|
||||||
*/
|
|
||||||
_drawRichText(ctx, text, baseColor, ts, W, H) {
|
|
||||||
const segs = this._parseRich(text, baseColor);
|
|
||||||
const fontPx = 120;
|
|
||||||
// Замер ширины каждого сегмента в его размере.
|
|
||||||
let total = 0;
|
|
||||||
for (const s of segs) {
|
|
||||||
ctx.font = `${s.bold ? 800 : 700} ${Math.round(fontPx * s.sizeMul)}px "Roboto Condensed","Segoe UI",sans-serif`;
|
|
||||||
s.w = ctx.measureText(s.text).width;
|
|
||||||
total += s.w;
|
|
||||||
}
|
|
||||||
let x = (W - total) / 2;
|
|
||||||
for (const s of segs) {
|
|
||||||
ctx.font = `${s.bold ? 800 : 700} ${Math.round(fontPx * s.sizeMul)}px "Roboto Condensed","Segoe UI",sans-serif`;
|
|
||||||
ctx.textAlign = 'left';
|
|
||||||
if (ts && ts.width > 0) {
|
|
||||||
ctx.lineWidth = ts.width;
|
|
||||||
ctx.lineJoin = 'round';
|
|
||||||
ctx.strokeStyle = ts.color || '#000';
|
|
||||||
ctx.strokeText(s.text, x, H / 2 + 4);
|
|
||||||
}
|
|
||||||
ctx.fillStyle = s.color;
|
|
||||||
ctx.fillText(s.text, x, H / 2 + 4);
|
|
||||||
x += s.w;
|
|
||||||
}
|
|
||||||
ctx.textAlign = 'center';
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Простой парсер richText → [{text, color, bold, sizeMul}]. */
|
|
||||||
_parseRich(text, baseColor) {
|
|
||||||
const segs = [];
|
|
||||||
let color = baseColor, bold = false, sizeMul = 1;
|
|
||||||
// Разбиваем по тегам (открывающим/закрывающим).
|
|
||||||
const re = /<(\/?)(?:(color)=([#0-9a-fA-F]+)|(b)|(i)|(size)=(\d+))>|([^<]+)/g;
|
|
||||||
let m;
|
|
||||||
while ((m = re.exec(text)) !== null) {
|
|
||||||
const closing = m[1] === '/';
|
|
||||||
if (m[8] != null) {
|
|
||||||
// текстовый кусок
|
|
||||||
if (m[8]) segs.push({ text: m[8], color, bold, sizeMul });
|
|
||||||
} else if (m[2]) { // <color=...>
|
|
||||||
color = closing ? baseColor : m[3];
|
|
||||||
} else if (m[4]) { // <b>
|
|
||||||
bold = !closing;
|
|
||||||
} else if (m[6]) { // <size=N>
|
|
||||||
sizeMul = closing ? 1 : Math.max(0.4, Math.min(2, Number(m[7]) / 100));
|
|
||||||
}
|
|
||||||
// <i> игнорим визуально (italic в canvas через font-style — опускаем на MVP)
|
|
||||||
}
|
|
||||||
if (segs.length === 0) segs.push({ text: '', color: baseColor, bold: false, sizeMul: 1 });
|
|
||||||
return segs;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Каждый кадр: maxDistance-скрытие плашек дальше порога от игрока. */
|
|
||||||
update() {
|
|
||||||
if (!this._playerMesh) return;
|
|
||||||
const pp = this._playerMesh.position;
|
|
||||||
for (const rec of this.labels.values()) {
|
|
||||||
if (rec.maxDistance == null) continue;
|
|
||||||
const ap = rec.plane.getAbsolutePosition();
|
|
||||||
const dx = ap.x - pp.x, dy = ap.y - pp.y, dz = ap.z - pp.z;
|
|
||||||
const far = (dx * dx + dy * dy + dz * dz) > rec.maxDistance * rec.maxDistance;
|
|
||||||
rec.plane.setEnabled(!far);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Убрать плашку с объекта. */
|
|
||||||
clearLabel(ref) {
|
clearLabel(ref) {
|
||||||
const rec = this.labels.get(ref);
|
const rec = this.labels.get(ref);
|
||||||
if (!rec) return;
|
if (!rec) return;
|
||||||
@ -389,7 +84,7 @@ export class LabelManager {
|
|||||||
this.labels.delete(ref);
|
this.labels.delete(ref);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Удалить все плашки (при выходе из Play). */
|
/** Удалить все метки (при выходе из Play). */
|
||||||
clearAll() {
|
clearAll() {
|
||||||
for (const ref of [...this.labels.keys()]) this.clearLabel(ref);
|
for (const ref of [...this.labels.keys()]) this.clearLabel(ref);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,255 +0,0 @@
|
|||||||
/**
|
|
||||||
* LeaderstatsManager — лидерборды (leaderstats) как в Roblox (задача 20).
|
|
||||||
*
|
|
||||||
* Хранит статы игроков и рендерит HUD-таблицу в правом-верхнем углу.
|
|
||||||
* В одиночной игре — один игрок ('me'). Поля сортируются по primary-стату.
|
|
||||||
*
|
|
||||||
* API (через game.leaderstats.*):
|
|
||||||
* define(name, opts) — зарегистрировать стат (initial/format/icon/color/primary)
|
|
||||||
* set(playerId, name, value) / add — изменить стат игрока
|
|
||||||
* get(playerId, name) — прочитать
|
|
||||||
* me.set/add(name, value) — для текущего игрока
|
|
||||||
* onChange(fn) — подписка (для bindToStat достижений)
|
|
||||||
*
|
|
||||||
* format: 'number' (42) | 'time' (mm:ss) | 'short' (1.2K).
|
|
||||||
* DOM-оверлей крепится к canvas.parentElement (как LoadingScreenOverlay).
|
|
||||||
*
|
|
||||||
* Фича-парность: тот же модуль в rublox-player/src/engine/.
|
|
||||||
*/
|
|
||||||
|
|
||||||
function fmt(value, format) {
|
|
||||||
const v = Number(value) || 0;
|
|
||||||
if (format === 'time') {
|
|
||||||
const m = Math.floor(v / 60), s = Math.floor(v % 60);
|
|
||||||
return (m < 10 ? '0' : '') + m + ':' + (s < 10 ? '0' : '') + s;
|
|
||||||
}
|
|
||||||
if (format === 'short') {
|
|
||||||
if (v >= 1e9) return (v / 1e9).toFixed(1).replace(/\.0$/, '') + 'B';
|
|
||||||
if (v >= 1e6) return (v / 1e6).toFixed(1).replace(/\.0$/, '') + 'M';
|
|
||||||
if (v >= 1e3) return (v / 1e3).toFixed(1).replace(/\.0$/, '') + 'K';
|
|
||||||
return String(Math.round(v));
|
|
||||||
}
|
|
||||||
return String(Math.round(v));
|
|
||||||
}
|
|
||||||
|
|
||||||
export class LeaderstatsManager {
|
|
||||||
constructor(scene3d) {
|
|
||||||
this.s = scene3d;
|
|
||||||
this._defs = []; // [{name, initial, format, icon, color, primary}]
|
|
||||||
this._stats = new Map(); // playerId → Map(name → value)
|
|
||||||
this._players = new Map(); // playerId → displayName
|
|
||||||
this._onChange = [];
|
|
||||||
this.root = null;
|
|
||||||
this._dirty = false;
|
|
||||||
this._meId = 'me';
|
|
||||||
}
|
|
||||||
|
|
||||||
/** id текущего игрока (одиночка = 'me'). */
|
|
||||||
_resolveMe() {
|
|
||||||
try {
|
|
||||||
const p = this.s?.gameRuntime?._players?.me;
|
|
||||||
if (p && p.id != null) return String(p.id);
|
|
||||||
} catch (e) { /* ignore */ }
|
|
||||||
return 'me';
|
|
||||||
}
|
|
||||||
|
|
||||||
define(name, opts = {}) {
|
|
||||||
if (typeof name !== 'string' || !name) return;
|
|
||||||
if (this._defs.some(d => d.name === name)) return; // уже есть
|
|
||||||
this._defs.push({
|
|
||||||
name,
|
|
||||||
initial: Number(opts.initial) || 0,
|
|
||||||
format: opts.format || 'number',
|
|
||||||
icon: opts.icon || '',
|
|
||||||
color: opts.color || '#e8ecf2',
|
|
||||||
primary: !!opts.primary,
|
|
||||||
});
|
|
||||||
// Если ни один не primary — первый становится primary.
|
|
||||||
if (!this._defs.some(d => d.primary)) this._defs[0].primary = true;
|
|
||||||
// Инициализируем стат у уже известных игроков.
|
|
||||||
for (const [pid] of this._players) this._ensure(pid, name);
|
|
||||||
this._ensureMe();
|
|
||||||
if (this.s?._isPlaying) this._mount(); // HUD только в Play
|
|
||||||
this._dirty = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
_ensureMe() {
|
|
||||||
const me = this._resolveMe();
|
|
||||||
this._meId = me;
|
|
||||||
if (!this._players.has(me)) {
|
|
||||||
let nm = 'Ты';
|
|
||||||
try { nm = this.s?.gameRuntime?._players?.me?.name || 'Ты'; } catch (e) {}
|
|
||||||
this._players.set(me, nm);
|
|
||||||
}
|
|
||||||
for (const d of this._defs) this._ensure(me, d.name);
|
|
||||||
}
|
|
||||||
|
|
||||||
_ensure(pid, name) {
|
|
||||||
if (!this._stats.has(pid)) this._stats.set(pid, new Map());
|
|
||||||
const m = this._stats.get(pid);
|
|
||||||
if (!m.has(name)) {
|
|
||||||
const def = this._defs.find(d => d.name === name);
|
|
||||||
m.set(name, def ? def.initial : 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
set(playerId, name, value) {
|
|
||||||
const pid = playerId == null ? this._resolveMe() : String(playerId);
|
|
||||||
if (!this._players.has(pid)) this._players.set(pid, pid === this._resolveMe() ? 'Ты' : ('Игрок ' + pid));
|
|
||||||
this._ensure(pid, name);
|
|
||||||
const m = this._stats.get(pid);
|
|
||||||
const old = m.get(name);
|
|
||||||
const nv = Number(value) || 0;
|
|
||||||
if (old === nv) return;
|
|
||||||
m.set(name, nv);
|
|
||||||
this._dirty = true;
|
|
||||||
this._flash = this._flash || {};
|
|
||||||
this._flash[pid + '|' + name] = performance.now ? performance.now() : Date.now();
|
|
||||||
for (const fn of this._onChange) {
|
|
||||||
try { fn(pid, name, nv, old); } catch (e) { /* ignore */ }
|
|
||||||
}
|
|
||||||
// Сохраняем статы текущего игрока в БД (дебаунс 1с) — между сессиями.
|
|
||||||
if (pid === this._resolveMe()) this._scheduleSave();
|
|
||||||
}
|
|
||||||
|
|
||||||
_scheduleSave() {
|
|
||||||
if (this._saveTimer) clearTimeout(this._saveTimer);
|
|
||||||
this._saveTimer = setTimeout(() => {
|
|
||||||
this._saveTimer = null;
|
|
||||||
try {
|
|
||||||
const me = this._resolveMe();
|
|
||||||
const m = this._stats.get(me);
|
|
||||||
if (!m) return;
|
|
||||||
const obj = {}; for (const [k, v] of m) obj[k] = v;
|
|
||||||
this.s?.gameRuntime?.saveProgress?.('_leaderstats', obj);
|
|
||||||
} catch (e) { /* ignore */ }
|
|
||||||
}, 1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Загрузить статы текущего игрока из БД (вызывать при Play, после define). */
|
|
||||||
loadFromDB() {
|
|
||||||
const rt = this.s?.gameRuntime;
|
|
||||||
if (!rt || !rt.loadProgress) return;
|
|
||||||
rt.loadProgress('_leaderstats', (data) => {
|
|
||||||
if (data && typeof data === 'object') {
|
|
||||||
const me = this._resolveMe();
|
|
||||||
for (const name of Object.keys(data)) {
|
|
||||||
// Применяем только к зарегистрированным статам, без повторного сейва.
|
|
||||||
if (this._defs.some(d => d.name === name)) {
|
|
||||||
this._ensure(me, name);
|
|
||||||
this._stats.get(me).set(name, Number(data[name]) || 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this._dirty = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
add(playerId, name, delta) {
|
|
||||||
const pid = playerId == null ? this._resolveMe() : String(playerId);
|
|
||||||
this._ensure(pid, name);
|
|
||||||
const cur = this._stats.get(pid).get(name) || 0;
|
|
||||||
this.set(pid, name, cur + (Number(delta) || 0));
|
|
||||||
}
|
|
||||||
|
|
||||||
get(playerId, name) {
|
|
||||||
const pid = playerId == null ? this._resolveMe() : String(playerId);
|
|
||||||
const m = this._stats.get(pid);
|
|
||||||
return m ? (m.get(name) || 0) : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
onChange(fn) { if (typeof fn === 'function') this._onChange.push(fn); }
|
|
||||||
|
|
||||||
/** Активны ли leaderstats (хотя бы один define). */
|
|
||||||
get active() { return this._defs.length > 0; }
|
|
||||||
|
|
||||||
// ── HUD ──────────────────────────────────────────────────────────────
|
|
||||||
_mount() {
|
|
||||||
if (this.root) return;
|
|
||||||
const parent = (this.s && this.s.canvas && this.s.canvas.parentElement) || document.body;
|
|
||||||
const root = document.createElement('div');
|
|
||||||
root.style.cssText = [
|
|
||||||
'position:absolute', 'top:14px', 'right:14px', 'z-index:50',
|
|
||||||
'min-width:230px', 'max-width:300px',
|
|
||||||
'background:rgba(18,22,33,0.55)', 'backdrop-filter:blur(8px)',
|
|
||||||
'-webkit-backdrop-filter:blur(8px)',
|
|
||||||
'border:1px solid rgba(255,255,255,0.12)', 'border-radius:12px',
|
|
||||||
'padding:10px 12px', 'font-family:Inter,system-ui,sans-serif',
|
|
||||||
'color:#e8ecf2', 'pointer-events:none', 'user-select:none',
|
|
||||||
'box-shadow:0 6px 24px rgba(0,0,0,0.35)',
|
|
||||||
].join(';');
|
|
||||||
parent.appendChild(root);
|
|
||||||
this.root = root;
|
|
||||||
this._sortBy = null; // имя стата для сортировки (null = primary)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Вызывать каждый кадр (рендер при изменениях + затухание flash). */
|
|
||||||
tick() {
|
|
||||||
if (!this.active) return;
|
|
||||||
if (!this.root) { this._mount(); this._dirty = true; }
|
|
||||||
if (this._dirty) { this._render(); this._dirty = false; }
|
|
||||||
// flash затухает ~600мс — перерисуем пока активен.
|
|
||||||
if (this._flash && Object.keys(this._flash).length) {
|
|
||||||
const now = performance.now ? performance.now() : Date.now();
|
|
||||||
let any = false;
|
|
||||||
for (const k of Object.keys(this._flash)) {
|
|
||||||
if (now - this._flash[k] < 600) any = true; else delete this._flash[k];
|
|
||||||
}
|
|
||||||
if (any) this._render();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_render() {
|
|
||||||
const defs = this._defs;
|
|
||||||
if (!defs.length) { this.root.innerHTML = ''; return; }
|
|
||||||
const sortStat = this._sortBy || (defs.find(d => d.primary) || defs[0]).name;
|
|
||||||
const me = this._resolveMe();
|
|
||||||
// Строки игроков, сортировка по убыванию sortStat, топ-10.
|
|
||||||
const rows = [...this._players.keys()]
|
|
||||||
.map(pid => ({ pid, name: this._players.get(pid) }))
|
|
||||||
.sort((a, b) => (this.get(b.pid, sortStat) - this.get(a.pid, sortStat)))
|
|
||||||
.slice(0, 10);
|
|
||||||
const now = performance.now ? performance.now() : Date.now();
|
|
||||||
|
|
||||||
let html = '<div style="display:flex;align-items:center;gap:6px;font-weight:800;font-size:13px;margin-bottom:8px;color:#ffd23a">🏆 Таблица лидеров</div>';
|
|
||||||
// Шапка столбцов.
|
|
||||||
html += '<div style="display:grid;grid-template-columns:1fr ' + defs.map(() => 'auto').join(' ') + ';gap:8px;font-size:11px;color:#9aa3b2;font-weight:700;padding-bottom:4px;border-bottom:1px solid rgba(255,255,255,0.1)">';
|
|
||||||
html += '<span>Игрок</span>';
|
|
||||||
for (const d of defs) html += '<span style="text-align:right;color:' + d.color + '">' + (d.icon ? d.icon + ' ' : '') + d.name + '</span>';
|
|
||||||
html += '</div>';
|
|
||||||
// Строки.
|
|
||||||
for (const r of rows) {
|
|
||||||
const mine = r.pid === me;
|
|
||||||
html += '<div style="display:grid;grid-template-columns:1fr ' + defs.map(() => 'auto').join(' ') + ';gap:8px;font-size:13px;padding:4px 2px;border-radius:6px;' + (mine ? 'background:rgba(51,87,255,0.22);' : '') + '">';
|
|
||||||
html += '<span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:' + (mine ? '800' : '600') + '">' + this._esc(r.name) + '</span>';
|
|
||||||
for (const d of defs) {
|
|
||||||
const flashed = this._flash && (now - (this._flash[r.pid + '|' + d.name] || 0) < 600);
|
|
||||||
const col = flashed ? '#ffe066' : d.color;
|
|
||||||
html += '<span style="text-align:right;font-weight:700;color:' + col + ';transition:color .2s">' + fmt(this.get(r.pid, d.name), d.format) + '</span>';
|
|
||||||
}
|
|
||||||
html += '</div>';
|
|
||||||
}
|
|
||||||
this.root.innerHTML = html;
|
|
||||||
}
|
|
||||||
|
|
||||||
_esc(s) { return String(s).replace(/[&<>]/g, c => ({ '&': '&', '<': '<', '>': '>' }[c])); }
|
|
||||||
|
|
||||||
/** Сериализация определений в project_data. */
|
|
||||||
serialize() {
|
|
||||||
return this._defs.map(d => ({ ...d }));
|
|
||||||
}
|
|
||||||
load(arr) {
|
|
||||||
if (!Array.isArray(arr)) return;
|
|
||||||
for (const d of arr) this.define(d.name, d);
|
|
||||||
}
|
|
||||||
|
|
||||||
dispose() {
|
|
||||||
if (this.root) { try { this.root.remove(); } catch (e) {} this.root = null; }
|
|
||||||
this._stats.clear(); this._players.clear(); this._onChange = [];
|
|
||||||
}
|
|
||||||
/** Сброс рантайм-значений при exitPlayMode (определения остаются). */
|
|
||||||
resetRuntime() {
|
|
||||||
this._stats.clear(); this._players.clear(); this._flash = {};
|
|
||||||
if (this.root) this.root.innerHTML = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,557 +0,0 @@
|
|||||||
/**
|
|
||||||
* LoadingScreenOverlay — внутриигровой экран загрузки (задача 12).
|
|
||||||
*
|
|
||||||
* Программный mid-game transition: чёрный фон (fadeIn/Out), картинка-превью
|
|
||||||
* (cover) по центру, прогресс-бар (жёлтый по серому) + процент, спиннер
|
|
||||||
* «ЗАГРУЗКА» справа-снизу (CSS keyframes), кнопка «ПРОПУСТИТЬ» по центру-снизу
|
|
||||||
* (появляется через 0.5с — анти-accidental), логотип игры слева-снизу.
|
|
||||||
*
|
|
||||||
* Вызывается из скрипта через game.loading.show(opts) / game.loading.transition(opts).
|
|
||||||
* Покрывает и кейс задачи 05 (начальный экран при входе).
|
|
||||||
*
|
|
||||||
* Реализация — лёгкий DOM-оверлей поверх canvas (как ShopInventoryUi), а не
|
|
||||||
* Babylon-GUI: фиксированный layout с прогресс-баром/спиннером/кнопкой на HTML
|
|
||||||
* делается быстрее и доступнее. Класс самодостаточен: хранит state, рисует DOM,
|
|
||||||
* имеет tick(dt) для fade-фаз и авто-duration (в отличие от ShopInventoryUi,
|
|
||||||
* которому tick не нужен).
|
|
||||||
*
|
|
||||||
* Один активный экран одновременно: повторный show() мгновенно закрывает
|
|
||||||
* предыдущий (как ModalManager) — нет утечки overlay'ев при нескольких
|
|
||||||
* transition подряд.
|
|
||||||
*
|
|
||||||
* Фича-парность: идентичный модуль в rublox-player/src/engine/.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const EASE_OUT = (t) => 1 - Math.pow(1 - t, 3);
|
|
||||||
|
|
||||||
// CSS спиннера вставляем один раз в <head> (keyframes нельзя инлайнить в style).
|
|
||||||
let _spinCssInjected = false;
|
|
||||||
function injectSpinnerCss() {
|
|
||||||
if (_spinCssInjected) return;
|
|
||||||
_spinCssInjected = true;
|
|
||||||
try {
|
|
||||||
const style = document.createElement('style');
|
|
||||||
style.id = 'kbn-loading-spin-css';
|
|
||||||
style.textContent =
|
|
||||||
'@keyframes kbn-ls-spin{from{transform:rotate(0)}to{transform:rotate(360deg)}}' +
|
|
||||||
'.kbn-ls-spinner{animation:kbn-ls-spin 0.9s linear infinite}' +
|
|
||||||
// Ken Burns — медленный pan+zoom фона (задача 05).
|
|
||||||
'@keyframes kbn-ls-kenburns{' +
|
|
||||||
'0%{transform:scale(1.0) translate3d(0,0,0)}' +
|
|
||||||
'50%{transform:scale(1.10) translate3d(-3%,-2%,0)}' +
|
|
||||||
'100%{transform:scale(1.0) translate3d(-6%,0,0)}}' +
|
|
||||||
'.kbn-ls-kenburns{animation:kbn-ls-kenburns 24s ease-in-out infinite}' +
|
|
||||||
// particles — медленно всплывающие искры.
|
|
||||||
'@keyframes kbn-ls-rise{' +
|
|
||||||
'0%{transform:translateY(0) scale(1);opacity:0}' +
|
|
||||||
'10%{opacity:0.9}' +
|
|
||||||
'90%{opacity:0.7}' +
|
|
||||||
'100%{transform:translateY(-110vh) scale(1.4);opacity:0}}' +
|
|
||||||
'.kbn-ls-particle{animation:kbn-ls-rise linear infinite}' +
|
|
||||||
// лёгкий «дыхательный» glow карточки-превью.
|
|
||||||
'@keyframes kbn-ls-cardglow{' +
|
|
||||||
'0%,100%{box-shadow:0 18px 60px rgba(0,0,0,0.6),0 0 0 rgba(120,160,255,0)}' +
|
|
||||||
'50%{box-shadow:0 18px 60px rgba(0,0,0,0.6),0 0 40px rgba(120,160,255,0.35)}}' +
|
|
||||||
'.kbn-ls-cardglow{animation:kbn-ls-cardglow 4s ease-in-out infinite}' +
|
|
||||||
'@media (prefers-reduced-motion:reduce){.kbn-ls-spinner,.kbn-ls-kenburns,.kbn-ls-particle,.kbn-ls-cardglow{animation:none}}';
|
|
||||||
document.head.appendChild(style);
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
export class LoadingScreenOverlay {
|
|
||||||
constructor(scene3d) {
|
|
||||||
this.s = scene3d;
|
|
||||||
this.root = null;
|
|
||||||
this._st = null; // state активного экрана или null
|
|
||||||
this._idSeq = 0;
|
|
||||||
// Мост наружу (GameRuntime подписывает) — id-based колбэки.
|
|
||||||
this._onSkipCb = null; // (id) => void
|
|
||||||
this._onCompleteCb = null; // (id) => void
|
|
||||||
this._onHideCb = null; // () => void — задача 05 (game.loading.onHide)
|
|
||||||
this._parallaxHandler = null;
|
|
||||||
// DOM-ссылки активного экрана:
|
|
||||||
this._els = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Колбэки-мост к GameRuntime (он рассылает globalEvent в sandbox'ы). */
|
|
||||||
setBridge(onSkip, onComplete, onHide) {
|
|
||||||
this._onSkipCb = onSkip;
|
|
||||||
this._onCompleteCb = onComplete;
|
|
||||||
if (onHide) this._onHideCb = onHide;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Конфиг проекта (логотип/акцент по умолчанию) из BabylonScene._loadingConfig. */
|
|
||||||
_cfg() {
|
|
||||||
return (this.s && this.s._loadingConfig) || {};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Показать экран загрузки. Возвращает числовой id (для матчинга команд).
|
|
||||||
* opts — см. 12_ingame_loading.md §2.2.
|
|
||||||
*/
|
|
||||||
show(opts) {
|
|
||||||
injectSpinnerCss();
|
|
||||||
opts = opts && typeof opts === 'object' ? opts : {};
|
|
||||||
// Один активный — мгновенно убрать предыдущий.
|
|
||||||
if (this._st) this._instantClose();
|
|
||||||
|
|
||||||
const cfg = this._cfg();
|
|
||||||
const accent = opts.progressColor || cfg.accentColor || '#ffc020';
|
|
||||||
const st = {
|
|
||||||
id: ++this._idSeq,
|
|
||||||
// Фон
|
|
||||||
bgColor: opts.bgColor || '#000',
|
|
||||||
bgOpacity: opts.bgOpacity != null ? Number(opts.bgOpacity) : 1,
|
|
||||||
fadeIn: opts.fadeIn != null ? Number(opts.fadeIn) : 0.3,
|
|
||||||
fadeOut: opts.fadeOut != null ? Number(opts.fadeOut) : 0.3,
|
|
||||||
// Прогресс
|
|
||||||
progressBar: opts.progressBar !== false,
|
|
||||||
progressColor: accent,
|
|
||||||
progressBgColor: opts.progressBgColor || '#444',
|
|
||||||
percentText: opts.percentText !== false,
|
|
||||||
progress: Math.max(0, Math.min(1, Number(opts.initialProgress) || 0)),
|
|
||||||
duration: Number.isFinite(opts.duration) && opts.duration > 0 ? Number(opts.duration) : null,
|
|
||||||
manualProgress: false,
|
|
||||||
// Спиннер
|
|
||||||
spinner: opts.spinner != null ? !!opts.spinner : (cfg.defaultSpinner !== false),
|
|
||||||
spinnerText: opts.spinnerText != null ? String(opts.spinnerText) : 'ЗАГРУЗКА',
|
|
||||||
// Кнопка Пропустить
|
|
||||||
skipButton: opts.skipButton != null ? !!opts.skipButton : !!cfg.defaultSkipButton,
|
|
||||||
skipButtonText: opts.skipButtonText != null ? String(opts.skipButtonText) : 'ПРОПУСТИТЬ',
|
|
||||||
skipButtonColor: opts.skipButtonColor || accent,
|
|
||||||
skipShown: false,
|
|
||||||
// Логотип
|
|
||||||
logo: opts.logo || cfg.logo || (this.s && this.s._projectThumbnail) || null,
|
|
||||||
logoCornerRadius: opts.logoCornerRadius != null ? Number(opts.logoCornerRadius) : 12,
|
|
||||||
// Текст под картинкой
|
|
||||||
text: opts.text != null ? String(opts.text) : '',
|
|
||||||
// --- Задача 05: Ken-Burns фон + карточка места ---
|
|
||||||
// style: 'ken-burns' | 'static' | 'parallax' | 'particles'
|
|
||||||
style: opts.style || cfg.style || 'ken-burns',
|
|
||||||
// фоновое размытое изображение (на весь экран); резолвится в _resolveCover.
|
|
||||||
background: opts.background != null ? opts.background : (cfg.background || null),
|
|
||||||
// карточка-витрина по центру (название места + автор), как в Roblox.
|
|
||||||
placeName: opts.placeName != null ? String(opts.placeName) : (cfg.placeName || ''),
|
|
||||||
studioName: opts.studioName != null ? String(opts.studioName) : (cfg.studioName || ''),
|
|
||||||
verified: opts.verified != null ? !!opts.verified : !!cfg.verified,
|
|
||||||
// Поведение
|
|
||||||
blockInput: opts.blockInput !== false,
|
|
||||||
pauseSimulation: opts.pauseSimulation !== false,
|
|
||||||
// Жизненный цикл
|
|
||||||
phase: 'in', // 'in' | 'visible' | 'out'
|
|
||||||
alpha: 0,
|
|
||||||
elapsed: 0, // время с момента полного появления (для duration/skip)
|
|
||||||
fadeT: 0,
|
|
||||||
completed: false, // onComplete уже вызывался
|
|
||||||
};
|
|
||||||
this._st = st;
|
|
||||||
this._build(st, opts.cover);
|
|
||||||
|
|
||||||
// Блок ввода + пауза симуляции.
|
|
||||||
if (st.blockInput) { try { this.s.player?.setInputBlocked?.(true); } catch { /* ignore */ } }
|
|
||||||
if (st.pauseSimulation && this.s.gameRuntime) { try { this.s.gameRuntime.paused = true; } catch { /* ignore */ } }
|
|
||||||
|
|
||||||
return st.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Резолв cover в URL/dataURL. */
|
|
||||||
_resolveCover(cover) {
|
|
||||||
if (!cover) return null;
|
|
||||||
if (typeof cover === 'string') {
|
|
||||||
// asset:xxx → пробуем через AssetManager, иначе как прямой URL.
|
|
||||||
try {
|
|
||||||
const r = this.s.assetManager?.resolveUrl?.(cover);
|
|
||||||
if (r) return r;
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
return cover;
|
|
||||||
}
|
|
||||||
if (typeof cover === 'object') {
|
|
||||||
if (cover.sceneSnapshot) {
|
|
||||||
try {
|
|
||||||
const canvas = this.s.engine?.getRenderingCanvas?.();
|
|
||||||
if (canvas) return canvas.toDataURL('image/jpeg', 0.72);
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (cover.url) return cover.url;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
_build(st, cover) {
|
|
||||||
const parent = (this.s.canvas && this.s.canvas.parentElement) || document.body;
|
|
||||||
try { if (getComputedStyle(parent).position === 'static') parent.style.position = 'relative'; } catch { /* ignore */ }
|
|
||||||
|
|
||||||
const root = document.createElement('div');
|
|
||||||
root.className = 'kbn-loading';
|
|
||||||
root.style.cssText =
|
|
||||||
'position:absolute;inset:0;z-index:60;overflow:hidden;' +
|
|
||||||
'display:flex;align-items:center;justify-content:center;' +
|
|
||||||
'opacity:0;transition:none;font-family:system-ui,"Segoe UI",sans-serif;' +
|
|
||||||
`background:${st.bgColor};`;
|
|
||||||
// фон с настраиваемой непрозрачностью — отдельный слой, чтобы контент был непрозрачным
|
|
||||||
// (используем opacity всего root для fade, а bgOpacity — через rgba фон):
|
|
||||||
root.style.background = this._bgRgba(st.bgColor, st.bgOpacity);
|
|
||||||
|
|
||||||
// --- Фоновый слой (Ken Burns / parallax / static) ---
|
|
||||||
// Размытое изображение игры на весь экран. Отдельный div под контентом,
|
|
||||||
// чтобы blur/анимация не трогали карточку и текст.
|
|
||||||
const bgUrl = this._resolveCover(st.background);
|
|
||||||
const bgLayer = document.createElement('div');
|
|
||||||
let bgClass = '';
|
|
||||||
if (bgUrl) {
|
|
||||||
if (st.style === 'ken-burns') bgClass = 'kbn-ls-kenburns';
|
|
||||||
bgLayer.className = bgClass;
|
|
||||||
bgLayer.style.cssText =
|
|
||||||
'position:absolute;inset:-8%;z-index:0;background-size:cover;background-position:center;' +
|
|
||||||
'filter:blur(8px) brightness(0.55);will-change:transform;' +
|
|
||||||
`background-image:url("${bgUrl}");`;
|
|
||||||
// parallax — лёгкий сдвиг по мыши (2 слоя имитируем одним + transform).
|
|
||||||
if (st.style === 'parallax') {
|
|
||||||
bgLayer.style.transition = 'transform 0.25s ease-out';
|
|
||||||
this._parallaxHandler = (e) => {
|
|
||||||
const cx = (e.clientX / window.innerWidth - 0.5) * 28;
|
|
||||||
const cy = (e.clientY / window.innerHeight - 0.5) * 18;
|
|
||||||
bgLayer.style.transform = `translate3d(${-cx}px,${-cy}px,0) scale(1.06)`;
|
|
||||||
};
|
|
||||||
window.addEventListener('mousemove', this._parallaxHandler);
|
|
||||||
}
|
|
||||||
root.appendChild(bgLayer);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- particles слой (медленные искры) ---
|
|
||||||
if (st.style === 'particles') {
|
|
||||||
const pLayer = document.createElement('div');
|
|
||||||
pLayer.style.cssText = 'position:absolute;inset:0;z-index:1;overflow:hidden;pointer-events:none;';
|
|
||||||
for (let i = 0; i < 26; i++) {
|
|
||||||
const sp = document.createElement('span');
|
|
||||||
sp.className = 'kbn-ls-particle';
|
|
||||||
const size = 2 + Math.round(Math.random() * 4);
|
|
||||||
const dur = 7 + Math.random() * 10;
|
|
||||||
sp.style.cssText =
|
|
||||||
`position:absolute;bottom:-10px;left:${(Math.random() * 100).toFixed(1)}%;` +
|
|
||||||
`width:${size}px;height:${size}px;border-radius:50%;` +
|
|
||||||
`background:rgba(${180 + Math.round(Math.random() * 70)},${190 + Math.round(Math.random() * 60)},255,0.85);` +
|
|
||||||
`box-shadow:0 0 ${size * 2}px rgba(140,170,255,0.7);` +
|
|
||||||
`animation-duration:${dur.toFixed(1)}s;animation-delay:${(-Math.random() * dur).toFixed(1)}s;`;
|
|
||||||
pLayer.appendChild(sp);
|
|
||||||
}
|
|
||||||
root.appendChild(pLayer);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Обёртка контента (над фоном).
|
|
||||||
const content = document.createElement('div');
|
|
||||||
content.style.cssText =
|
|
||||||
'position:relative;z-index:2;display:flex;flex-direction:column;align-items:center;justify-content:center;width:100%;height:100%;';
|
|
||||||
|
|
||||||
// --- Cover (картинка-карточка по центру) ---
|
|
||||||
const coverUrl = this._resolveCover(cover);
|
|
||||||
// Режим карточки места (задача 05): квадрат + название + автор под ней.
|
|
||||||
const hasPlaceCard = !!(st.placeName || st.studioName);
|
|
||||||
const coverImg = document.createElement('div');
|
|
||||||
if (hasPlaceCard) {
|
|
||||||
coverImg.className = 'kbn-ls-cardglow';
|
|
||||||
coverImg.style.cssText =
|
|
||||||
'width:min(42vw,400px);aspect-ratio:1/1;border-radius:18px;' +
|
|
||||||
'background-size:cover;background-position:center;background-color:#1a1f2b;' +
|
|
||||||
'border:2px solid rgba(255,255,255,0.12);';
|
|
||||||
} else {
|
|
||||||
coverImg.style.cssText =
|
|
||||||
'max-width:min(62vw,860px);width:62vw;aspect-ratio:16/9;border-radius:16px;' +
|
|
||||||
'box-shadow:0 18px 60px rgba(0,0,0,0.6);background-size:cover;background-position:center;' +
|
|
||||||
'background-color:#1a1f2b;margin-bottom:140px;';
|
|
||||||
}
|
|
||||||
if (coverUrl) coverImg.style.backgroundImage = `url("${coverUrl}")`;
|
|
||||||
else if (bgUrl && hasPlaceCard) coverImg.style.backgroundImage = `url("${bgUrl}")`;
|
|
||||||
|
|
||||||
// --- Название места (крупный белый, под карточкой) ---
|
|
||||||
const placeEl = document.createElement('div');
|
|
||||||
placeEl.style.cssText =
|
|
||||||
'margin-top:22px;color:#fff;font-size:38px;font-weight:800;letter-spacing:0.5px;' +
|
|
||||||
'text-align:center;text-shadow:0 3px 14px rgba(0,0,0,0.7);' +
|
|
||||||
(st.placeName ? '' : 'display:none;');
|
|
||||||
placeEl.textContent = st.placeName || '';
|
|
||||||
|
|
||||||
// --- Автор + verified-галочка ---
|
|
||||||
const studioRow = document.createElement('div');
|
|
||||||
studioRow.style.cssText =
|
|
||||||
'margin-top:8px;display:flex;align-items:center;gap:7px;' +
|
|
||||||
'color:#cdd6e6;font-size:16px;font-weight:600;text-shadow:0 1px 4px rgba(0,0,0,0.6);' +
|
|
||||||
(st.studioName ? '' : 'display:none;');
|
|
||||||
const studioTxt = document.createElement('span');
|
|
||||||
studioTxt.textContent = st.studioName || '';
|
|
||||||
studioRow.appendChild(studioTxt);
|
|
||||||
if (st.verified) studioRow.appendChild(this._buildVerifiedBadge());
|
|
||||||
|
|
||||||
// --- Текст под картинкой (для не-карточного режима / mid-game) ---
|
|
||||||
const textEl = document.createElement('div');
|
|
||||||
if (hasPlaceCard) {
|
|
||||||
textEl.style.cssText =
|
|
||||||
'margin-top:14px;color:#e8edf5;font-size:16px;font-weight:500;text-align:center;' +
|
|
||||||
'text-shadow:0 1px 3px rgba(0,0,0,0.6);' + (st.text ? '' : 'display:none;');
|
|
||||||
} else {
|
|
||||||
textEl.style.cssText =
|
|
||||||
'position:absolute;left:50%;transform:translateX(-50%);bottom:170px;' +
|
|
||||||
'color:#e8edf5;font-size:18px;font-weight:500;text-align:center;text-shadow:0 1px 3px rgba(0,0,0,0.6);';
|
|
||||||
}
|
|
||||||
textEl.textContent = st.text || '';
|
|
||||||
|
|
||||||
// --- Прогресс-бар ---
|
|
||||||
const barWrap = document.createElement('div');
|
|
||||||
barWrap.style.cssText =
|
|
||||||
'position:absolute;left:50%;transform:translateX(-50%);bottom:120px;' +
|
|
||||||
`width:min(74vw,1180px);height:14px;border-radius:8px;background:${st.progressBgColor};` +
|
|
||||||
'overflow:hidden;box-shadow:inset 0 1px 3px rgba(0,0,0,0.5);' +
|
|
||||||
(st.progressBar ? '' : 'display:none;');
|
|
||||||
const bar = document.createElement('div');
|
|
||||||
bar.style.cssText =
|
|
||||||
`height:100%;width:${(st.progress * 100).toFixed(1)}%;border-radius:8px;` +
|
|
||||||
`background:linear-gradient(90deg,${st.progressColor},${this._lighten(st.progressColor)});` +
|
|
||||||
'transition:width 0.12s linear;box-shadow:0 0 8px rgba(255,200,40,0.4);';
|
|
||||||
barWrap.appendChild(bar);
|
|
||||||
|
|
||||||
// --- Процент ---
|
|
||||||
const percent = document.createElement('div');
|
|
||||||
percent.style.cssText =
|
|
||||||
'position:absolute;left:50%;transform:translateX(-50%);bottom:74px;' +
|
|
||||||
`color:${st.progressColor};font-size:30px;font-weight:800;text-shadow:0 2px 4px rgba(0,0,0,0.5);` +
|
|
||||||
(st.percentText ? '' : 'display:none;');
|
|
||||||
percent.textContent = `${Math.round(st.progress * 100)}%`;
|
|
||||||
|
|
||||||
// --- Кнопка Пропустить ---
|
|
||||||
const skipBtn = document.createElement('button');
|
|
||||||
skipBtn.type = 'button';
|
|
||||||
skipBtn.textContent = st.skipButtonText;
|
|
||||||
skipBtn.style.cssText =
|
|
||||||
'position:absolute;left:50%;transform:translateX(-50%);bottom:18px;' +
|
|
||||||
'min-width:260px;padding:14px 36px;border:none;border-radius:12px;cursor:pointer;' +
|
|
||||||
`background:linear-gradient(180deg,${this._lighten(st.skipButtonColor)},${st.skipButtonColor});` +
|
|
||||||
'color:#3a2a00;font-size:18px;font-weight:800;letter-spacing:0.5px;' +
|
|
||||||
'box-shadow:0 6px 16px rgba(0,0,0,0.4),inset 0 1px 0 rgba(255,255,255,0.4);' +
|
|
||||||
'opacity:0;transition:opacity 0.25s,transform 0.1s;pointer-events:none;' +
|
|
||||||
(st.skipButton ? '' : 'display:none;');
|
|
||||||
skipBtn.onmouseenter = () => { skipBtn.style.transform = 'translateX(-50%) translateY(-2px)'; };
|
|
||||||
skipBtn.onmouseleave = () => { skipBtn.style.transform = 'translateX(-50%)'; };
|
|
||||||
skipBtn.onclick = () => {
|
|
||||||
if (skipBtn.style.pointerEvents === 'none') return;
|
|
||||||
this._fireSkip();
|
|
||||||
};
|
|
||||||
|
|
||||||
// --- Логотип (слева снизу) ---
|
|
||||||
const logo = document.createElement('div');
|
|
||||||
logo.style.cssText =
|
|
||||||
'position:absolute;left:28px;bottom:24px;max-width:200px;max-height:110px;' +
|
|
||||||
`border-radius:${st.logoCornerRadius}px;background-size:contain;background-repeat:no-repeat;` +
|
|
||||||
'background-position:left bottom;width:200px;height:90px;';
|
|
||||||
if (st.logo) logo.style.backgroundImage = `url("${st.logo}")`;
|
|
||||||
else logo.style.display = 'none';
|
|
||||||
|
|
||||||
// --- Спиннер + «ЗАГРУЗКА» (справа снизу) ---
|
|
||||||
const spinWrap = document.createElement('div');
|
|
||||||
spinWrap.style.cssText =
|
|
||||||
'position:absolute;right:32px;bottom:32px;display:flex;align-items:center;gap:14px;' +
|
|
||||||
'color:#fff;font-size:20px;font-weight:700;letter-spacing:1px;' +
|
|
||||||
(st.spinner ? '' : 'display:none;');
|
|
||||||
const spinTxt = document.createElement('span');
|
|
||||||
spinTxt.textContent = st.spinnerText;
|
|
||||||
const spinCircle = document.createElement('span');
|
|
||||||
spinCircle.className = 'kbn-ls-spinner';
|
|
||||||
spinCircle.style.cssText =
|
|
||||||
`display:inline-block;width:28px;height:28px;border:3px solid rgba(255,255,255,0.25);` +
|
|
||||||
`border-top-color:${st.progressColor};border-radius:50%;`;
|
|
||||||
spinWrap.appendChild(spinTxt);
|
|
||||||
spinWrap.appendChild(spinCircle);
|
|
||||||
|
|
||||||
// Центральная композиция (карточка + название + автор + текст) — в content.
|
|
||||||
content.appendChild(coverImg);
|
|
||||||
content.appendChild(placeEl);
|
|
||||||
content.appendChild(studioRow);
|
|
||||||
content.appendChild(textEl);
|
|
||||||
root.appendChild(content);
|
|
||||||
// Нижние/угловые элементы — абсолютно на root (поверх фона, рядом с content).
|
|
||||||
root.appendChild(barWrap);
|
|
||||||
root.appendChild(percent);
|
|
||||||
root.appendChild(skipBtn);
|
|
||||||
root.appendChild(logo);
|
|
||||||
root.appendChild(spinWrap);
|
|
||||||
parent.appendChild(root);
|
|
||||||
|
|
||||||
this.root = root;
|
|
||||||
this._els = { root, content, coverImg, placeEl, studioRow, textEl, barWrap, bar, percent, skipBtn, logo, spinWrap };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Verified-галочка (синий круг + белый чек) — inline SVG, без emoji-шрифта. */
|
|
||||||
_buildVerifiedBadge() {
|
|
||||||
const wrap = document.createElement('span');
|
|
||||||
wrap.style.cssText = 'display:inline-flex;align-items:center;';
|
|
||||||
wrap.innerHTML =
|
|
||||||
'<svg width="18" height="18" viewBox="0 0 24 24" aria-label="verified">' +
|
|
||||||
'<circle cx="12" cy="12" r="11" fill="#3897f0"/>' +
|
|
||||||
'<path d="M7 12.5l3 3 7-7" fill="none" stroke="#fff" stroke-width="2.4" ' +
|
|
||||||
'stroke-linecap="round" stroke-linejoin="round"/></svg>';
|
|
||||||
return wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** tick — fade-фазы, авто-duration, появление кнопки Пропустить. */
|
|
||||||
tick(dt) {
|
|
||||||
const st = this._st;
|
|
||||||
if (!st || !this._els) return;
|
|
||||||
dt = Number(dt) || 0;
|
|
||||||
|
|
||||||
if (st.phase === 'in') {
|
|
||||||
st.fadeT += dt;
|
|
||||||
const d = st.fadeIn > 0 ? st.fadeIn : 0.0001;
|
|
||||||
st.alpha = Math.min(1, EASE_OUT(st.fadeT / d));
|
|
||||||
this._els.root.style.opacity = String(st.alpha);
|
|
||||||
if (st.fadeT >= d) { st.phase = 'visible'; st.alpha = 1; st.fadeT = 0; }
|
|
||||||
} else if (st.phase === 'visible') {
|
|
||||||
st.elapsed += dt;
|
|
||||||
// Кнопка Пропустить — появляется через 0.5с.
|
|
||||||
if (!st.skipShown && st.skipButton && st.elapsed >= 0.5) {
|
|
||||||
st.skipShown = true;
|
|
||||||
this._els.skipBtn.style.opacity = '1';
|
|
||||||
this._els.skipBtn.style.pointerEvents = 'auto';
|
|
||||||
}
|
|
||||||
// Авто-duration (если не было ручного setProgress).
|
|
||||||
if (st.duration && !st.manualProgress) {
|
|
||||||
st.progress = Math.min(1, st.elapsed / st.duration);
|
|
||||||
this._applyProgress(st);
|
|
||||||
if (st.progress >= 1 && !st.completed) {
|
|
||||||
st.completed = true;
|
|
||||||
this._fireComplete();
|
|
||||||
this.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (st.phase === 'out') {
|
|
||||||
st.fadeT += dt;
|
|
||||||
const d = st.fadeOut > 0 ? st.fadeOut : 0.0001;
|
|
||||||
st.alpha = Math.max(0, 1 - EASE_OUT(st.fadeT / d));
|
|
||||||
this._els.root.style.opacity = String(st.alpha);
|
|
||||||
if (st.fadeT >= d) this._teardown();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_applyProgress(st) {
|
|
||||||
if (!this._els) return;
|
|
||||||
this._els.bar.style.width = `${(st.progress * 100).toFixed(1)}%`;
|
|
||||||
this._els.percent.textContent = `${Math.round(st.progress * 100)}%`;
|
|
||||||
}
|
|
||||||
|
|
||||||
setProgress(value) {
|
|
||||||
const st = this._st;
|
|
||||||
if (!st) return;
|
|
||||||
st.manualProgress = true;
|
|
||||||
st.progress = Math.max(0, Math.min(1, Number(value) || 0));
|
|
||||||
this._applyProgress(st);
|
|
||||||
if (st.progress >= 1 && !st.completed) {
|
|
||||||
st.completed = true;
|
|
||||||
this._fireComplete();
|
|
||||||
this.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setText(text) {
|
|
||||||
const st = this._st;
|
|
||||||
if (!st || !this._els) return;
|
|
||||||
st.text = String(text == null ? '' : text);
|
|
||||||
this._els.textEl.textContent = st.text;
|
|
||||||
}
|
|
||||||
|
|
||||||
setCover(cover) {
|
|
||||||
if (!this._st || !this._els) return;
|
|
||||||
const url = this._resolveCover(cover);
|
|
||||||
if (url) this._els.coverImg.style.backgroundImage = `url("${url}")`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Задача 05: сменить фоновое (Ken-Burns) изображение на лету. */
|
|
||||||
setBackground(bg) {
|
|
||||||
if (!this._st || !this._els) return;
|
|
||||||
const url = this._resolveCover(bg);
|
|
||||||
if (!url) return;
|
|
||||||
this._st.background = bg;
|
|
||||||
// фоновый слой — первый ребёнок root с background-image; найдём его.
|
|
||||||
const layer = this._els.root.querySelector('.kbn-ls-kenburns')
|
|
||||||
|| this._els.root.firstElementChild;
|
|
||||||
if (layer && layer !== this._els.content) layer.style.backgroundImage = `url("${url}")`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Задача 05: виден ли экран сейчас. */
|
|
||||||
isVisible() {
|
|
||||||
return !!(this._st && this._st.phase !== 'out');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Закрыть программно (с fadeOut). */
|
|
||||||
close() {
|
|
||||||
const st = this._st;
|
|
||||||
if (!st) return;
|
|
||||||
if (st.phase !== 'out') { st.phase = 'out'; st.fadeT = 0; }
|
|
||||||
}
|
|
||||||
|
|
||||||
_fireSkip() {
|
|
||||||
const st = this._st;
|
|
||||||
if (!st) return;
|
|
||||||
if (this._onSkipCb) { try { this._onSkipCb(st.id); } catch { /* ignore */ } }
|
|
||||||
this.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
_fireComplete() {
|
|
||||||
const st = this._st;
|
|
||||||
if (!st) return;
|
|
||||||
if (this._onCompleteCb) { try { this._onCompleteCb(st.id); } catch { /* ignore */ } }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Мгновенно убрать без fade (повторный show / выход из Play). */
|
|
||||||
_instantClose() {
|
|
||||||
this._teardown();
|
|
||||||
}
|
|
||||||
|
|
||||||
_teardown() {
|
|
||||||
// Снять блок ввода / паузу.
|
|
||||||
const st = this._st;
|
|
||||||
if (st) {
|
|
||||||
if (st.blockInput) { try { this.s.player?.setInputBlocked?.(false); } catch { /* ignore */ } }
|
|
||||||
if (st.pauseSimulation && this.s.gameRuntime) { try { this.s.gameRuntime.paused = false; } catch { /* ignore */ } }
|
|
||||||
}
|
|
||||||
// Снять parallax-listener (задача 05).
|
|
||||||
if (this._parallaxHandler) {
|
|
||||||
try { window.removeEventListener('mousemove', this._parallaxHandler); } catch { /* ignore */ }
|
|
||||||
this._parallaxHandler = null;
|
|
||||||
}
|
|
||||||
// onHide-мост (задача 05) — сообщаем скриптам что экран скрылся.
|
|
||||||
if (this._onHideCb) { try { this._onHideCb(); } catch { /* ignore */ } }
|
|
||||||
if (this.root) { try { this.root.remove(); } catch { /* ignore */ } }
|
|
||||||
this.root = null;
|
|
||||||
this._els = null;
|
|
||||||
this._st = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
dispose() {
|
|
||||||
this._instantClose();
|
|
||||||
this._onSkipCb = null;
|
|
||||||
this._onCompleteCb = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- утилиты цвета ---
|
|
||||||
_lighten(hex) {
|
|
||||||
try {
|
|
||||||
const h = String(hex).replace('#', '');
|
|
||||||
if (h.length !== 6) return hex;
|
|
||||||
const r = Math.min(255, parseInt(h.slice(0, 2), 16) + 40);
|
|
||||||
const g = Math.min(255, parseInt(h.slice(2, 4), 16) + 40);
|
|
||||||
const b = Math.min(255, parseInt(h.slice(4, 6), 16) + 40);
|
|
||||||
return `rgb(${r},${g},${b})`;
|
|
||||||
} catch { return hex; }
|
|
||||||
}
|
|
||||||
|
|
||||||
_bgRgba(hex, opacity) {
|
|
||||||
try {
|
|
||||||
const h = String(hex).replace('#', '');
|
|
||||||
if (h.length !== 6) return hex;
|
|
||||||
const r = parseInt(h.slice(0, 2), 16);
|
|
||||||
const g = parseInt(h.slice(2, 4), 16);
|
|
||||||
const b = parseInt(h.slice(4, 6), 16);
|
|
||||||
const a = opacity != null ? Math.max(0, Math.min(1, opacity)) : 1;
|
|
||||||
return `rgba(${r},${g},${b},${a})`;
|
|
||||||
} catch { return hex; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,398 +0,0 @@
|
|||||||
/**
|
|
||||||
* ModalManager — модальные сцены (затемнение + GUI поверх + блок ввода).
|
|
||||||
*
|
|
||||||
* Задача 04 из «1 - Неделя 4/ЗАДАЧИ РУБЛОКС/04_modal_cutscene.md».
|
|
||||||
*
|
|
||||||
* Типовой кейс: boss-fight intro / открытие лутбокса / диалог с NPC / получил
|
|
||||||
* питомца. Скрипт зовёт `game.modal.open(opts)` → весь 3D-мир затемняется
|
|
||||||
* (но HUD остаётся), управление блокируется, поверх показывается контент.
|
|
||||||
*
|
|
||||||
* Координирует:
|
|
||||||
* - DOM overlay (рендерится в KubikonEditor/KubikonPlayer)
|
|
||||||
* - PlayerController.setInputBlocked / setCameraFrozen
|
|
||||||
* - HighlightLayer Babylon (spotlight-объекты светятся)
|
|
||||||
* - GameRuntime.paused (опционально, через pauseSimulation)
|
|
||||||
* - AudioManager.duck (опционально, через muteWorld)
|
|
||||||
* - GuiManager (временные элементы создаются/удаляются с модалом)
|
|
||||||
*
|
|
||||||
* Не зависит от React — просто состояние и колбэки.
|
|
||||||
*
|
|
||||||
* Архитектура:
|
|
||||||
* _state = {
|
|
||||||
* id, opts,
|
|
||||||
* fadePhase: 'in'|'visible'|'out'|'closed',
|
|
||||||
* fadeStart: ms, fadeFrom: 0..1, fadeTo: 0..1,
|
|
||||||
* currentAlpha: 0..1,
|
|
||||||
* tempGuiIds: [...], — id-шники созданных временных GUI-элементов
|
|
||||||
* spotlightScreens: [{x,y,r}], — позиции spotlight'ов в экранных координатах
|
|
||||||
* }
|
|
||||||
*
|
|
||||||
* Активен только ОДИН модал одновременно (Roblox-style). Повторный open
|
|
||||||
* автоматически закрывает предыдущий (через close+open).
|
|
||||||
*/
|
|
||||||
|
|
||||||
let _seq = 1;
|
|
||||||
|
|
||||||
export class ModalManager {
|
|
||||||
constructor() {
|
|
||||||
/** @type {object|null} текущий модал, null если закрыт */
|
|
||||||
this._state = null;
|
|
||||||
/** @type {Function|null} вызывается когда меняется state — UI пере-рендерится */
|
|
||||||
this._onChange = null;
|
|
||||||
/** Babylon scene нужна для HighlightLayer и Vector3.Project */
|
|
||||||
this._scene = null;
|
|
||||||
/** PlayerController для блока ввода/freeze камеры */
|
|
||||||
this._player = null;
|
|
||||||
/** GuiManager для temp-элементов */
|
|
||||||
this._gui = null;
|
|
||||||
/** GameRuntime для pauseSimulation */
|
|
||||||
this._runtime = null;
|
|
||||||
/** AudioManager для muteWorld */
|
|
||||||
this._audio = null;
|
|
||||||
/** HighlightLayer Babylon — создаётся лениво при первом spotlight */
|
|
||||||
this._highlight = null;
|
|
||||||
/** Колбэки onClose — массив функций (modalId) => void */
|
|
||||||
this._closeCallbacks = [];
|
|
||||||
/** Прежний WASD-state и FOV — для восстановления */
|
|
||||||
this._savedCameraState = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
setOnChange(cb) { this._onChange = cb; }
|
|
||||||
_notify() { if (this._onChange) try { this._onChange(this._state); } catch (e) {} }
|
|
||||||
|
|
||||||
attachScene(scene) { this._scene = scene; }
|
|
||||||
attachPlayer(player) { this._player = player; }
|
|
||||||
attachGui(gui) { this._gui = gui; }
|
|
||||||
attachRuntime(runtime) { this._runtime = runtime; }
|
|
||||||
attachAudio(audio) { this._audio = audio; }
|
|
||||||
|
|
||||||
/** Открыт ли сейчас модал. */
|
|
||||||
isOpen() {
|
|
||||||
return !!this._state && this._state.fadePhase !== 'closed';
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Получить текущий state (для UI-overlay). */
|
|
||||||
getState() { return this._state; }
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Открыть модал. opts — см. doc по задаче 04.
|
|
||||||
* Возвращает modalId (число).
|
|
||||||
*/
|
|
||||||
open(opts) {
|
|
||||||
opts = opts || {};
|
|
||||||
console.log('[ModalManager] open called, opts:', opts);
|
|
||||||
// Если уже открыт — мгновенно закрываем (без fadeOut, чтобы не плодить
|
|
||||||
// одновременных модалов).
|
|
||||||
if (this.isOpen()) this._instantClose();
|
|
||||||
|
|
||||||
const id = ++_seq;
|
|
||||||
const norm = {
|
|
||||||
darken: Number.isFinite(opts.darken) ? Math.max(0, Math.min(1, opts.darken)) : 0.5,
|
|
||||||
darkenColor: typeof opts.darkenColor === 'string' ? opts.darkenColor : '#000000',
|
|
||||||
target: opts.target === 'screen' ? 'screen' : 'scene',
|
|
||||||
blockInput: opts.blockInput !== false, // по умолчанию true
|
|
||||||
freezeCamera: !!opts.freezeCamera,
|
|
||||||
cameraOverride: opts.cameraOverride || null,
|
|
||||||
fadeIn: Number.isFinite(opts.fadeIn) ? Math.max(0, opts.fadeIn) : 0.3,
|
|
||||||
fadeOut: Number.isFinite(opts.fadeOut) ? Math.max(0, opts.fadeOut) : 0.3,
|
|
||||||
spotlights: Array.isArray(opts.spotlights) ? opts.spotlights.slice() : [],
|
|
||||||
spotlightRadius: Number.isFinite(opts.spotlightRadius) ? opts.spotlightRadius : 120,
|
|
||||||
spotlightSoftEdge: Number.isFinite(opts.spotlightSoftEdge) ? opts.spotlightSoftEdge : 40,
|
|
||||||
pauseSimulation: !!opts.pauseSimulation,
|
|
||||||
muteWorld: !!opts.muteWorld,
|
|
||||||
content: opts.content || null,
|
|
||||||
};
|
|
||||||
|
|
||||||
this._state = {
|
|
||||||
id,
|
|
||||||
opts: norm,
|
|
||||||
fadePhase: 'in',
|
|
||||||
fadeStart: this._now(),
|
|
||||||
fadeFrom: 0,
|
|
||||||
fadeTo: norm.darken,
|
|
||||||
currentAlpha: 0,
|
|
||||||
tempGuiIds: [],
|
|
||||||
spotlightScreens: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
// 1) Block input
|
|
||||||
if (norm.blockInput) {
|
|
||||||
try { this._player?.setInputBlocked?.(true); } catch (e) {}
|
|
||||||
}
|
|
||||||
// 2) Freeze camera (сохраняем текущее состояние для восстановления)
|
|
||||||
if (norm.freezeCamera) {
|
|
||||||
try {
|
|
||||||
this._savedCameraState = this._player?.captureCameraState?.() || null;
|
|
||||||
this._player?.setCameraFrozen?.(true);
|
|
||||||
} catch (e) {}
|
|
||||||
}
|
|
||||||
// 3) Camera override — переключение на focusOn
|
|
||||||
if (norm.cameraOverride && this._scene) {
|
|
||||||
this._applyCameraOverride(norm.cameraOverride);
|
|
||||||
}
|
|
||||||
// 4) Pause simulation
|
|
||||||
if (norm.pauseSimulation && this._runtime) {
|
|
||||||
try { this._runtime.paused = true; } catch (e) {}
|
|
||||||
}
|
|
||||||
// 5) Mute world audio
|
|
||||||
if (norm.muteWorld && this._audio) {
|
|
||||||
try { this._audio.duck?.(0.3); } catch (e) {}
|
|
||||||
}
|
|
||||||
// 6) Highlight spotlight-объектов в Babylon
|
|
||||||
if (norm.spotlights.length && norm.target === 'scene' && this._scene) {
|
|
||||||
this._applyHighlight(norm.spotlights);
|
|
||||||
}
|
|
||||||
// 7) content.elements — создать временные GUI-элементы
|
|
||||||
if (norm.content?.elements && this._gui) {
|
|
||||||
this._createTempGui(norm.content.elements);
|
|
||||||
}
|
|
||||||
|
|
||||||
this._notify();
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Закрыть модал. Если modalId передан и не совпадает — игнор. */
|
|
||||||
close(modalId) {
|
|
||||||
if (!this._state) return;
|
|
||||||
if (modalId != null && this._state.id !== modalId) return;
|
|
||||||
if (this._state.fadePhase === 'out' || this._state.fadePhase === 'closed') return;
|
|
||||||
this._state.fadePhase = 'out';
|
|
||||||
this._state.fadeStart = this._now();
|
|
||||||
this._state.fadeFrom = this._state.currentAlpha;
|
|
||||||
this._state.fadeTo = 0;
|
|
||||||
this._notify();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Поменять параметры на лету. */
|
|
||||||
update(modalId, patch) {
|
|
||||||
if (!this._state) return;
|
|
||||||
if (modalId != null && this._state.id !== modalId) return;
|
|
||||||
if (!patch || typeof patch !== 'object') return;
|
|
||||||
Object.assign(this._state.opts, patch);
|
|
||||||
// Если поменяли darken — плавно tween-им currentAlpha к новому значению
|
|
||||||
if (Number.isFinite(patch.darken) && this._state.fadePhase !== 'out') {
|
|
||||||
this._state.fadeFrom = this._state.currentAlpha;
|
|
||||||
this._state.fadeTo = patch.darken;
|
|
||||||
this._state.fadeStart = this._now();
|
|
||||||
this._state.fadePhase = 'in';
|
|
||||||
}
|
|
||||||
this._notify();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Подписаться на закрытие. fn получает modalId. */
|
|
||||||
onClose(fn) {
|
|
||||||
if (typeof fn === 'function') this._closeCallbacks.push(fn);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Обновление за кадр — двигает fade-phase и spotlight-screens. dt в секундах. */
|
|
||||||
tick(dt) {
|
|
||||||
if (!this._state) return;
|
|
||||||
const st = this._state;
|
|
||||||
if (!this._tickLogged) {
|
|
||||||
this._tickLogged = true;
|
|
||||||
console.log('[ModalManager] first tick, phase:', st.fadePhase, 'alpha:', st.currentAlpha);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1) Fade-tween
|
|
||||||
if (st.fadePhase === 'in' || st.fadePhase === 'out') {
|
|
||||||
const dur = st.fadePhase === 'in' ? st.opts.fadeIn : st.opts.fadeOut;
|
|
||||||
const elapsed = (this._now() - st.fadeStart) / 1000;
|
|
||||||
const t = dur > 0 ? Math.min(1, elapsed / dur) : 1;
|
|
||||||
// ease-out cubic
|
|
||||||
const k = 1 - Math.pow(1 - t, 3);
|
|
||||||
st.currentAlpha = st.fadeFrom + (st.fadeTo - st.fadeFrom) * k;
|
|
||||||
if (t >= 1) {
|
|
||||||
if (st.fadePhase === 'in') {
|
|
||||||
st.fadePhase = 'visible';
|
|
||||||
} else {
|
|
||||||
// close завершился — финальная уборка
|
|
||||||
st.fadePhase = 'closed';
|
|
||||||
this._teardown();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2) Обновить экранные координаты spotlight'ов (объекты могут двигаться)
|
|
||||||
if (st.fadePhase !== 'closed' && st.opts.spotlights.length && st.opts.target === 'scene') {
|
|
||||||
st.spotlightScreens = this._computeSpotlightScreens(st.opts.spotlights);
|
|
||||||
}
|
|
||||||
|
|
||||||
this._notify();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== private =====
|
|
||||||
|
|
||||||
_now() {
|
|
||||||
return (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now();
|
|
||||||
}
|
|
||||||
|
|
||||||
_instantClose() {
|
|
||||||
if (!this._state) return;
|
|
||||||
this._teardown();
|
|
||||||
this._state = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
_teardown() {
|
|
||||||
const st = this._state;
|
|
||||||
if (!st) return;
|
|
||||||
// 1) Unblock input
|
|
||||||
if (st.opts.blockInput) {
|
|
||||||
try { this._player?.setInputBlocked?.(false); } catch (e) {}
|
|
||||||
}
|
|
||||||
// 2) Unfreeze camera
|
|
||||||
if (st.opts.freezeCamera) {
|
|
||||||
try { this._player?.setCameraFrozen?.(false); } catch (e) {}
|
|
||||||
}
|
|
||||||
// 3) Camera reset — только если был cameraOverride
|
|
||||||
if (st.opts.cameraOverride && this._savedCameraState) {
|
|
||||||
try { this._player?.restoreCameraState?.(this._savedCameraState); } catch (e) {}
|
|
||||||
this._savedCameraState = null;
|
|
||||||
}
|
|
||||||
// 4) Unpause
|
|
||||||
if (st.opts.pauseSimulation && this._runtime) {
|
|
||||||
try { this._runtime.paused = false; } catch (e) {}
|
|
||||||
}
|
|
||||||
// 5) Unmute
|
|
||||||
if (st.opts.muteWorld && this._audio) {
|
|
||||||
try { this._audio.unduck?.(); } catch (e) {}
|
|
||||||
}
|
|
||||||
// 6) Снять highlight
|
|
||||||
if (this._highlight) {
|
|
||||||
try { this._highlight.removeAllMeshes(); } catch (e) {}
|
|
||||||
}
|
|
||||||
// 7) Удалить temp GUI
|
|
||||||
if (st.tempGuiIds.length && this._gui) {
|
|
||||||
for (const id of st.tempGuiIds) {
|
|
||||||
try { this._gui.remove(id); } catch (e) {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 8) Колбэки onClose
|
|
||||||
for (const cb of this._closeCallbacks) {
|
|
||||||
try { cb(st.id); } catch (e) {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_applyCameraOverride(co) {
|
|
||||||
// Используем существующий camera.focusOn механизм из BabylonScene/PlayerController
|
|
||||||
try {
|
|
||||||
const ref = co.target;
|
|
||||||
const distance = Number.isFinite(co.distance) ? co.distance : 8;
|
|
||||||
const height = Number.isFinite(co.height) ? co.height : 3;
|
|
||||||
const fov = Number.isFinite(co.fov) ? co.fov : null;
|
|
||||||
const duration = Number.isFinite(co.duration) ? co.duration : 0.5;
|
|
||||||
if (this._player?.focusOnTarget) {
|
|
||||||
this._player.focusOnTarget(ref, { distance, height, fov, duration });
|
|
||||||
} else if (this._scene?._gameRuntime?._handleCommand) {
|
|
||||||
// fallback через runtime — отправляем camera.focus
|
|
||||||
this._scene._gameRuntime._handleCommand(null, 'camera.focus', {
|
|
||||||
ref, distance, height, fov, duration,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (e) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
_applyHighlight(refs) {
|
|
||||||
if (!this._scene) return;
|
|
||||||
// Лениво создаём HighlightLayer
|
|
||||||
if (!this._highlight) {
|
|
||||||
try {
|
|
||||||
const BABYLON = window.BABYLON || (typeof globalThis !== 'undefined' ? globalThis.BABYLON : null);
|
|
||||||
if (BABYLON?.HighlightLayer && this._scene.scene) {
|
|
||||||
this._highlight = new BABYLON.HighlightLayer('modal-spotlight', this._scene.scene);
|
|
||||||
this._highlight.innerGlow = false;
|
|
||||||
this._highlight.outerGlow = true;
|
|
||||||
}
|
|
||||||
} catch (e) {}
|
|
||||||
}
|
|
||||||
if (!this._highlight) return;
|
|
||||||
try { this._highlight.removeAllMeshes(); } catch (e) {}
|
|
||||||
const BABYLON = window.BABYLON;
|
|
||||||
const glowColor = (BABYLON && BABYLON.Color3)
|
|
||||||
? new BABYLON.Color3(1, 1, 0.6)
|
|
||||||
: null;
|
|
||||||
for (const ref of refs) {
|
|
||||||
const meshes = this._resolveMeshes(ref);
|
|
||||||
for (const m of meshes) {
|
|
||||||
try {
|
|
||||||
if (glowColor) this._highlight.addMesh(m, glowColor);
|
|
||||||
} catch (e) {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Резолв ref → массив Babylon-мешей.
|
|
||||||
* ref может быть: строка-id, объект ref-обёртка ({kind, id}), либо сам Mesh. */
|
|
||||||
_resolveMeshes(ref) {
|
|
||||||
if (!ref || !this._scene) return [];
|
|
||||||
// Уже Mesh-инстанс
|
|
||||||
if (ref.getScene && typeof ref.getScene === 'function') return [ref];
|
|
||||||
|
|
||||||
const sc = this._scene;
|
|
||||||
const idStr = (typeof ref === 'string') ? ref : (ref.id || ref.refId || null);
|
|
||||||
if (!idStr) return [];
|
|
||||||
|
|
||||||
// Пробуем разные менеджеры
|
|
||||||
const tryGetters = [
|
|
||||||
() => sc.primitiveManager?.getMesh?.(idStr),
|
|
||||||
() => sc.modelManager?.getInstanceMeshes?.(idStr),
|
|
||||||
() => sc.scene?.getMeshByName?.(idStr),
|
|
||||||
() => sc.npcManager?.getMeshes?.(idStr),
|
|
||||||
() => sc.zombieManager?.getMeshes?.(idStr),
|
|
||||||
];
|
|
||||||
for (const g of tryGetters) {
|
|
||||||
try {
|
|
||||||
const r = g();
|
|
||||||
if (!r) continue;
|
|
||||||
if (Array.isArray(r)) return r;
|
|
||||||
return [r];
|
|
||||||
} catch (e) {}
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Проектируем 3D-позиции spotlight-refs в экранные координаты для CSS-mask. */
|
|
||||||
_computeSpotlightScreens(refs) {
|
|
||||||
if (!this._scene?.scene) return [];
|
|
||||||
const out = [];
|
|
||||||
const BABYLON = window.BABYLON;
|
|
||||||
if (!BABYLON) return [];
|
|
||||||
const engine = this._scene.scene.getEngine();
|
|
||||||
const camera = this._scene.scene.activeCamera;
|
|
||||||
if (!camera || !engine) return [];
|
|
||||||
const w = engine.getRenderWidth();
|
|
||||||
const h = engine.getRenderHeight();
|
|
||||||
const matrix = camera.getTransformationMatrix();
|
|
||||||
const viewport = camera.viewport.toGlobal(w, h);
|
|
||||||
for (const ref of refs) {
|
|
||||||
const meshes = this._resolveMeshes(ref);
|
|
||||||
if (!meshes.length) continue;
|
|
||||||
const m = meshes[0];
|
|
||||||
try {
|
|
||||||
const pos = m.getAbsolutePosition?.() || m.position;
|
|
||||||
if (!pos) continue;
|
|
||||||
// Center проектируем
|
|
||||||
const proj = BABYLON.Vector3.Project(pos, BABYLON.Matrix.Identity(), matrix, viewport);
|
|
||||||
// Если за камерой — скип (z вне 0..1)
|
|
||||||
if (proj.z < 0 || proj.z > 1) continue;
|
|
||||||
// Радиус — фиксированный из opts (можно потом масштабировать по distance/size)
|
|
||||||
out.push({ x: proj.x, y: proj.y, r: this._state.opts.spotlightRadius });
|
|
||||||
} catch (e) {}
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
_createTempGui(elements) {
|
|
||||||
if (!Array.isArray(elements) || !this._gui) return;
|
|
||||||
for (const el of elements) {
|
|
||||||
if (!el || typeof el !== 'object') continue;
|
|
||||||
const kind = el.kind || el.type || 'frame';
|
|
||||||
const opts = { ...el };
|
|
||||||
delete opts.kind;
|
|
||||||
delete opts.type;
|
|
||||||
try {
|
|
||||||
const id = this._gui.create(kind, opts);
|
|
||||||
if (id) this._state.tempGuiIds.push(id);
|
|
||||||
} catch (e) {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -314,10 +314,9 @@ export class ModelManager {
|
|||||||
r.getChildMeshes(false).forEach(m => {
|
r.getChildMeshes(false).forEach(m => {
|
||||||
m.isPickable = true;
|
m.isPickable = true;
|
||||||
m.metadata = { isModel: true, instanceId: this._nextInstanceId };
|
m.metadata = { isModel: true, instanceId: this._nextInstanceId };
|
||||||
// Тени: на InstancedMesh receiveShadows не действует (warning+нагрузка).
|
// Тени: GLB-модель и принимает тени, и отбрасывает их
|
||||||
if (m.getClassName && m.getClassName() !== 'InstancedMesh') {
|
// (через addShadowCaster в refreshAllShadows).
|
||||||
m.receiveShadows = true;
|
m.receiveShadows = true;
|
||||||
}
|
|
||||||
clonedMeshes.push(m);
|
clonedMeshes.push(m);
|
||||||
});
|
});
|
||||||
// И сам root тоже на всякий
|
// И сам root тоже на всякий
|
||||||
@ -542,7 +541,6 @@ export class ModelManager {
|
|||||||
opacity: typeof data.opacity === 'number' ? data.opacity : 1,
|
opacity: typeof data.opacity === 'number' ? data.opacity : 1,
|
||||||
tint: data.tint || null,
|
tint: data.tint || null,
|
||||||
name: data.name || null,
|
name: data.name || null,
|
||||||
...(data.folderId != null ? { folderId: data.folderId } : {}), // папка
|
|
||||||
// Параметры геймплея (HP, скорость врага, лимит спавнера и т.п.)
|
// Параметры геймплея (HP, скорость врага, лимит спавнера и т.п.)
|
||||||
gameplayParams: data.gameplayParams || null,
|
gameplayParams: data.gameplayParams || null,
|
||||||
});
|
});
|
||||||
@ -776,7 +774,6 @@ export class ModelManager {
|
|||||||
if (m.tint) data.tint = m.tint;
|
if (m.tint) data.tint = m.tint;
|
||||||
if (m.name) data.name = m.name;
|
if (m.name) data.name = m.name;
|
||||||
if (m.gameplayParams) data.gameplayParams = m.gameplayParams;
|
if (m.gameplayParams) data.gameplayParams = m.gameplayParams;
|
||||||
if (m.folderId != null) data.folderId = m.folderId;
|
|
||||||
if (data.opacity != null || data.tint) this._applyMaterialOverrides(data);
|
if (data.opacity != null || data.tint) this._applyMaterialOverrides(data);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -879,85 +879,6 @@ export const MODEL_TYPES = [
|
|||||||
m('pg-fence-planks', 'Забор', 'Площадка', 'nature-kit', 'fence_planks',
|
m('pg-fence-planks', 'Забор', 'Площадка', 'nature-kit', 'fence_planks',
|
||||||
{ targetHeight: 1.5 }),
|
{ targetHeight: 1.5 }),
|
||||||
|
|
||||||
// === ЛЕГО-СЕТ (задача 09) — паритет со студией ===
|
|
||||||
mc('lego-brick-1x1', 'Лего 1×1', 'Лего-сет', [
|
|
||||||
{ kind: 'primitive', type: 'cube', sx: 1, sy: 1, sz: 1, color: '#e02a2a', material: 'studs', dy: 0.5 },
|
|
||||||
]),
|
|
||||||
mc('lego-brick-1x2', 'Лего 1×2', 'Лего-сет', [
|
|
||||||
{ kind: 'primitive', type: 'cube', sx: 2, sy: 1, sz: 1, color: '#2a6fe0', material: 'studs', dy: 0.5 },
|
|
||||||
]),
|
|
||||||
mc('lego-brick-1x4', 'Лего 1×4', 'Лего-сет', [
|
|
||||||
{ kind: 'primitive', type: 'cube', sx: 4, sy: 1, sz: 1, color: '#f0c020', material: 'studs', dy: 0.5 },
|
|
||||||
]),
|
|
||||||
mc('lego-brick-2x2', 'Лего 2×2', 'Лего-сет', [
|
|
||||||
{ kind: 'primitive', type: 'cube', sx: 2, sy: 1, sz: 2, color: '#35ba5c', material: 'studs', dy: 0.5 },
|
|
||||||
]),
|
|
||||||
mc('lego-brick-2x4', 'Лего 2×4', 'Лего-сет', [
|
|
||||||
{ kind: 'primitive', type: 'cube', sx: 4, sy: 1, sz: 2, color: '#e07a30', material: 'studs', dy: 0.5 },
|
|
||||||
]),
|
|
||||||
mc('lego-brick-2x8', 'Лего 2×8', 'Лего-сет', [
|
|
||||||
{ kind: 'primitive', type: 'cube', sx: 8, sy: 1, sz: 2, color: '#9b5cf0', material: 'studs', dy: 0.5 },
|
|
||||||
]),
|
|
||||||
mc('lego-plate-1x1', 'Плита 1×1', 'Лего-сет', [
|
|
||||||
{ kind: 'primitive', type: 'cube', sx: 1, sy: 0.35, sz: 1, color: '#cfd2d6', material: 'studs', dy: 0.175 },
|
|
||||||
]),
|
|
||||||
mc('lego-plate-1x2', 'Плита 1×2', 'Лего-сет', [
|
|
||||||
{ kind: 'primitive', type: 'cube', sx: 2, sy: 0.35, sz: 1, color: '#cfd2d6', material: 'studs', dy: 0.175 },
|
|
||||||
]),
|
|
||||||
mc('lego-plate-2x2', 'Плита 2×2', 'Лего-сет', [
|
|
||||||
{ kind: 'primitive', type: 'cube', sx: 2, sy: 0.35, sz: 2, color: '#cfd2d6', material: 'studs', dy: 0.175 },
|
|
||||||
]),
|
|
||||||
mc('lego-plate-4x4', 'Плита 4×4', 'Лего-сет', [
|
|
||||||
{ kind: 'primitive', type: 'cube', sx: 4, sy: 0.35, sz: 4, color: '#9aa0a6', material: 'studs', dy: 0.175 },
|
|
||||||
]),
|
|
||||||
mc('lego-slope-30', 'Скат 30°', 'Лего-сет', [
|
|
||||||
{ kind: 'primitive', type: 'wedge', sx: 2, sy: 1, sz: 2, color: '#e02a2a', material: 'studs', dy: 0.5 },
|
|
||||||
]),
|
|
||||||
mc('lego-slope-45', 'Скат 45°', 'Лего-сет', [
|
|
||||||
{ kind: 'primitive', type: 'wedge', sx: 2, sy: 2, sz: 2, color: '#2a6fe0', material: 'studs', dy: 1 },
|
|
||||||
]),
|
|
||||||
mc('lego-slope-60', 'Скат 60°', 'Лего-сет', [
|
|
||||||
{ kind: 'primitive', type: 'wedge', sx: 2, sy: 3, sz: 2, color: '#f0c020', material: 'studs', dy: 1.5 },
|
|
||||||
]),
|
|
||||||
mc('lego-tree', 'Лего-дерево', 'Лего-сет', [
|
|
||||||
{ kind: 'primitive', type: 'cube', sx: 1, sy: 3, sz: 1, color: '#8a5a2b', material: 'studs', dy: 1.5 },
|
|
||||||
{ kind: 'primitive', type: 'cube', sx: 3, sy: 2, sz: 3, color: '#35ba5c', material: 'studs', dy: 4 },
|
|
||||||
{ kind: 'primitive', type: 'cube', sx: 2, sy: 1.5, sz: 2, color: '#2e9e4c', material: 'studs', dy: 5.5 },
|
|
||||||
], { targetHeight: 6 }),
|
|
||||||
mc('lego-bush', 'Лего-куст', 'Лего-сет', [
|
|
||||||
{ kind: 'primitive', type: 'cube', sx: 2, sy: 1, sz: 2, color: '#2e9e4c', material: 'studs', dy: 0.5 },
|
|
||||||
{ kind: 'primitive', type: 'cube', sx: 1, sy: 1, sz: 1, color: '#35ba5c', material: 'studs', dy: 1.3, dx: 0.4 },
|
|
||||||
{ kind: 'primitive', type: 'cube', sx: 1, sy: 1, sz: 1, color: '#35ba5c', material: 'studs', dy: 1.3, dx: -0.5, dz: 0.3 },
|
|
||||||
], { targetHeight: 1.8 }),
|
|
||||||
mc('lego-house-small', 'Лего-дом', 'Лего-сет', [
|
|
||||||
{ kind: 'primitive', type: 'cube', sx: 6, sy: 4, sz: 6, color: '#e02a2a', material: 'studs', dy: 2 },
|
|
||||||
{ kind: 'primitive', type: 'wedge', sx: 7, sy: 2.5, sz: 7, color: '#2a6fe0', material: 'studs', dy: 5.25 },
|
|
||||||
{ kind: 'primitive', type: 'cube', sx: 1.4, sy: 2.4, sz: 0.3, color: '#f0c020', material: 'studs', dy: 1.2, dz: -3.05 },
|
|
||||||
{ kind: 'primitive', type: 'cube', sx: 1.2, sy: 1.2, sz: 0.3, color: '#9ad0ff', material: 'studs', dy: 2.6, dz: -3.05, dx: 1.8 },
|
|
||||||
], { targetHeight: 6.5 }),
|
|
||||||
mc('lego-car-racer', 'Лего-машина', 'Лего-сет', [
|
|
||||||
{ kind: 'primitive', type: 'cube', sx: 6, sy: 1, sz: 3, color: '#e02a2a', material: 'studs', dy: 0.9 },
|
|
||||||
{ kind: 'primitive', type: 'cube', sx: 2.5, sy: 1.2, sz: 2.6, color: '#2a6fe0', material: 'studs', dy: 1.9, dx: 0.6 },
|
|
||||||
{ kind: 'primitive', type: 'cylinder', sx: 1, sy: 0.6, sz: 1, color: '#222428', material: 'studs', dy: 0.5, dx: 1.8, dz: 1.6, rz: Math.PI / 2 },
|
|
||||||
{ kind: 'primitive', type: 'cylinder', sx: 1, sy: 0.6, sz: 1, color: '#222428', material: 'studs', dy: 0.5, dx: 1.8, dz: -1.6, rz: Math.PI / 2 },
|
|
||||||
{ kind: 'primitive', type: 'cylinder', sx: 1, sy: 0.6, sz: 1, color: '#222428', material: 'studs', dy: 0.5, dx: -1.8, dz: 1.6, rz: Math.PI / 2 },
|
|
||||||
{ kind: 'primitive', type: 'cylinder', sx: 1, sy: 0.6, sz: 1, color: '#222428', material: 'studs', dy: 0.5, dx: -1.8, dz: -1.6, rz: Math.PI / 2 },
|
|
||||||
], { targetHeight: 2.5 }),
|
|
||||||
mc('lego-stairs', 'Лего-ступеньки', 'Лего-сет', [
|
|
||||||
{ kind: 'primitive', type: 'cube', sx: 3, sy: 1, sz: 1.2, color: '#cfd2d6', material: 'studs', dy: 0.5, dz: 1.8 },
|
|
||||||
{ kind: 'primitive', type: 'cube', sx: 3, sy: 2, sz: 1.2, color: '#cfd2d6', material: 'studs', dy: 1.0, dz: 0.6 },
|
|
||||||
{ kind: 'primitive', type: 'cube', sx: 3, sy: 3, sz: 1.2, color: '#cfd2d6', material: 'studs', dy: 1.5, dz: -0.6 },
|
|
||||||
{ kind: 'primitive', type: 'cube', sx: 3, sy: 4, sz: 1.2, color: '#cfd2d6', material: 'studs', dy: 2.0, dz: -1.8 },
|
|
||||||
], { targetHeight: 4 }),
|
|
||||||
mc('lego-minifig', 'Лего-человечек', 'Лего-сет', [
|
|
||||||
{ kind: 'primitive', type: 'cube', sx: 1.4, sy: 0.6, sz: 0.9, color: '#f0c020', material: 'studs', dy: 0.3 },
|
|
||||||
{ kind: 'primitive', type: 'cube', sx: 1.2, sy: 1.6, sz: 0.8, color: '#2a6fe0', material: 'studs', dy: 1.4 },
|
|
||||||
{ kind: 'primitive', type: 'cube', sx: 0.4, sy: 1.4, sz: 0.4, color: '#f0c020', material: 'studs', dy: 1.4, dx: 0.85 },
|
|
||||||
{ kind: 'primitive', type: 'cube', sx: 0.4, sy: 1.4, sz: 0.4, color: '#f0c020', material: 'studs', dy: 1.4, dx: -0.85 },
|
|
||||||
{ kind: 'primitive', type: 'cube', sx: 1.1, sy: 1.0, sz: 0.85, color: '#f5c84a', material: 'studs', dy: 2.7 },
|
|
||||||
{ kind: 'primitive', type: 'cylinder', sx: 0.9, sy: 0.5, sz: 0.9, color: '#e02a2a', material: 'studs', dy: 3.4 },
|
|
||||||
], { targetHeight: 3.8 }),
|
|
||||||
|
|
||||||
// TOTAL: 644
|
// TOTAL: 644
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|||||||
@ -137,16 +137,9 @@ export class MultiplayerSync {
|
|||||||
// 1. Подписки на state
|
// 1. Подписки на state
|
||||||
const $ = getStateCallbacks(this.room);
|
const $ = getStateCallbacks(this.room);
|
||||||
|
|
||||||
// Защита от повторного срабатывания onAdd (Colyseus 0.16 + immediate:true
|
|
||||||
// может триггерить .onAdd на каждый schema patch). Локальный set хранит
|
|
||||||
// sessionId которые уже обработаны в ТЕКУЩЕМ sync объекте.
|
|
||||||
const _addedSessionIds = new Set();
|
|
||||||
const handleAdd = (player, sessionId) => {
|
const handleAdd = (player, sessionId) => {
|
||||||
// Не рисуем СЕБЯ как remote-меш — у нас уже есть PlayerController
|
// Не рисуем СЕБЯ как remote-меш — у нас уже есть PlayerController
|
||||||
if (sessionId === this.room.sessionId) return;
|
if (sessionId === this.room.sessionId) return;
|
||||||
// Защита от дублирующих onAdd событий для уже добавленного игрока
|
|
||||||
if (_addedSessionIds.has(sessionId)) return;
|
|
||||||
_addedSessionIds.add(sessionId);
|
|
||||||
this._addRemotePlayer(sessionId, player);
|
this._addRemotePlayer(sessionId, player);
|
||||||
// Подписываемся на изменения этого Player'а
|
// Подписываемся на изменения этого Player'а
|
||||||
$(player).onChange(() => this._updateRemoteTarget(sessionId, player));
|
$(player).onChange(() => this._updateRemoteTarget(sessionId, player));
|
||||||
@ -156,11 +149,7 @@ export class MultiplayerSync {
|
|||||||
this._attachRemoteWeapon(sessionId, val || '');
|
this._attachRemoteWeapon(sessionId, val || '');
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
// Используем тот же set в handleRemove чтобы при настоящем уходе игрока
|
|
||||||
// потом можно было его снова добавить.
|
|
||||||
this._addedSessionIds = _addedSessionIds;
|
|
||||||
const handleRemove = (player, sessionId) => {
|
const handleRemove = (player, sessionId) => {
|
||||||
if (this._addedSessionIds) this._addedSessionIds.delete(sessionId);
|
|
||||||
this._removeRemotePlayer(sessionId);
|
this._removeRemotePlayer(sessionId);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -300,20 +289,8 @@ export class MultiplayerSync {
|
|||||||
|
|
||||||
// Интерполяция remote-игроков (позиция + yaw ставится на root,
|
// Интерполяция remote-игроков (позиция + yaw ставится на root,
|
||||||
// модель — child root'а — следует за ним).
|
// модель — child root'а — следует за ним).
|
||||||
// 2026-06-05: читаем target напрямую из room.state.players —
|
|
||||||
// в Colyseus 0.16 onChange может не срабатывать для всех полей
|
|
||||||
// (особенно yaw/animState), а target.x/y/z/yaw обновляется
|
|
||||||
// через _updateRemoteTarget только из onChange. Подстраховка.
|
|
||||||
for (const rp of this.remotePlayers.values()) {
|
for (const rp of this.remotePlayers.values()) {
|
||||||
if (!rp.root || !rp.target) continue;
|
if (!rp.root || !rp.target) continue;
|
||||||
const livePlayer = this.room?.state?.players?.get?.(rp.sessionId);
|
|
||||||
if (livePlayer) {
|
|
||||||
rp.target.x = livePlayer.x;
|
|
||||||
rp.target.y = livePlayer.y;
|
|
||||||
rp.target.z = livePlayer.z;
|
|
||||||
rp.target.yaw = livePlayer.yaw || 0;
|
|
||||||
if (livePlayer.animState) rp.animState = livePlayer.animState;
|
|
||||||
}
|
|
||||||
const cur = rp.current;
|
const cur = rp.current;
|
||||||
cur.x += (rp.target.x - cur.x) * LERP_FACTOR;
|
cur.x += (rp.target.x - cur.x) * LERP_FACTOR;
|
||||||
cur.y += (rp.target.y - cur.y) * LERP_FACTOR;
|
cur.y += (rp.target.y - cur.y) * LERP_FACTOR;
|
||||||
@ -355,25 +332,13 @@ export class MultiplayerSync {
|
|||||||
// Развилка: R15-скины анимируются процедурно через R15Animator
|
// Развилка: R15-скины анимируются процедурно через R15Animator
|
||||||
// (как локальный игрок), Kenney-модели — через glTF AnimationGroups.
|
// (как локальный игрок), Kenney-модели — через glTF AnimationGroups.
|
||||||
if (rp.isR15 && rp.r15Animator && rp.modelLoaded) {
|
if (rp.isR15 && rp.r15Animator && rp.modelLoaded) {
|
||||||
// Серверный animState: 'idle' | 'run' | 'jump' | 'fall' | 'attack'.
|
// Серверный animState: 'idle' | 'run' | 'attack'. R15Animator
|
||||||
// R15Animator понимает idle/walk/run/jump/fall.
|
// понимает idle/walk/run/jump/fall. Сервер не различает
|
||||||
// 2026-06-05: раньше run/jump/fall маппились в idle (баг
|
// walk/run и не шлёт прыжки → маппим run→run, attack→idle
|
||||||
// в маппинге), из-за чего у remote-игроков не было
|
// (атака показывается отдельным swing-ом руки ниже).
|
||||||
// анимации ни ходьбы, ни прыжка. Теперь пробрасываем
|
const r15State = rp.isDead
|
||||||
// напрямую. attack показывается отдельным swing руки.
|
? 'idle'
|
||||||
let r15State;
|
: (rp.animState === 'run' ? 'run' : 'idle');
|
||||||
if (rp.isDead) {
|
|
||||||
r15State = 'idle';
|
|
||||||
} else if (rp.animState === 'jump') {
|
|
||||||
r15State = 'jump';
|
|
||||||
} else if (rp.animState === 'fall') {
|
|
||||||
r15State = 'fall';
|
|
||||||
} else if (rp.animState === 'run') {
|
|
||||||
r15State = 'run';
|
|
||||||
} else {
|
|
||||||
// 'attack' или 'idle' или неизвестное — стоим
|
|
||||||
r15State = 'idle';
|
|
||||||
}
|
|
||||||
rp.r15Animator.setState(r15State);
|
rp.r15Animator.setState(r15State);
|
||||||
rp.r15Animator.update(dt);
|
rp.r15Animator.update(dt);
|
||||||
} else if (!rp.isR15) {
|
} else if (!rp.isR15) {
|
||||||
@ -667,23 +632,6 @@ export class MultiplayerSync {
|
|||||||
// === Внутреннее: меши remote-игроков ===
|
// === Внутреннее: меши remote-игроков ===
|
||||||
// =================================================================
|
// =================================================================
|
||||||
_addRemotePlayer(sessionId, player) {
|
_addRemotePlayer(sessionId, player) {
|
||||||
// Защита от дублей при Colyseus reconnect: state получается заново
|
|
||||||
// и onAdd срабатывает для тех же sessionId. Без этой проверки в
|
|
||||||
// сцене появляются клоны игроков (см. issue после 2026-06-05).
|
|
||||||
if (this.remotePlayers && this.remotePlayers.has(sessionId)) {
|
|
||||||
const existing = this.remotePlayers.get(sessionId);
|
|
||||||
// Обновим target позицию и пометим что игрок жив
|
|
||||||
const sx2 = player.x || 0, sy2 = player.y || 0, sz2 = player.z || 0, yaw2 = player.yaw || 0;
|
|
||||||
existing.target = { x: sx2, y: sy2, z: sz2, yaw: yaw2 };
|
|
||||||
existing.username = player.username || sessionId;
|
|
||||||
existing.modelType = player.modelType || existing.modelType;
|
|
||||||
existing.hp = player.hp ?? existing.hp;
|
|
||||||
existing.maxHp = player.maxHp ?? existing.maxHp;
|
|
||||||
existing.isDead = !!player.isDead;
|
|
||||||
existing.animState = player.animState || existing.animState;
|
|
||||||
console.log(`[MultiplayerSync] re-add (reconnect): ${sessionId} (${player.username}) — обновили без пересоздания меша`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const sx = player.x || 0;
|
const sx = player.x || 0;
|
||||||
const sy = player.y || 0;
|
const sy = player.y || 0;
|
||||||
const sz = player.z || 0;
|
const sz = player.z || 0;
|
||||||
|
|||||||
@ -161,19 +161,6 @@ export class NpcManager {
|
|||||||
r15Animator,
|
r15Animator,
|
||||||
};
|
};
|
||||||
this.npcs.set(id, npc);
|
this.npcs.set(id, npc);
|
||||||
// Пометить меши NPC для попаданий оружия (бластер/меч): pickable + npcId
|
|
||||||
// в metadata. Без pickable raycast оружия проходит сквозь NPC (задача 40).
|
|
||||||
try {
|
|
||||||
const root = npc.data && npc.data.rootMesh;
|
|
||||||
if (root) {
|
|
||||||
root.isPickable = true;
|
|
||||||
root.metadata = Object.assign({}, root.metadata, { npcId: id });
|
|
||||||
for (const m of root.getChildMeshes(false)) {
|
|
||||||
m.isPickable = true;
|
|
||||||
m.metadata = Object.assign({}, m.metadata, { npcId: id });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) { /* ignore */ }
|
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -288,12 +275,6 @@ export class NpcManager {
|
|||||||
npc.isMoving = false;
|
npc.isMoving = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Включить/выключить анимацию атаки. */
|
|
||||||
setAttacking(id, on) {
|
|
||||||
const npc = this.npcs.get(Number(id));
|
|
||||||
if (npc) npc.attacking = !!on;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Реплика над головой NPC на duration секунд. */
|
/** Реплика над головой NPC на duration секунд. */
|
||||||
say(id, text, duration = 3) {
|
say(id, text, duration = 3) {
|
||||||
const npc = this.npcs.get(Number(id));
|
const npc = this.npcs.get(Number(id));
|
||||||
@ -306,41 +287,10 @@ export class NpcManager {
|
|||||||
damage(id, amount) {
|
damage(id, amount) {
|
||||||
const npc = this.npcs.get(Number(id));
|
const npc = this.npcs.get(Number(id));
|
||||||
if (!npc || npc.dead) return;
|
if (!npc || npc.dead) return;
|
||||||
const amt = Number(amount) || 0;
|
npc.hp = Math.max(0, npc.hp - (Number(amount) || 0));
|
||||||
npc.hp = Math.max(0, npc.hp - amt);
|
|
||||||
// Авто-floater над мобом (задача 40 доп): game.fx.autoMobFloaters(true).
|
|
||||||
if (this._autoFloater && amt > 0 && this.scene3d?.floaters) {
|
|
||||||
try {
|
|
||||||
this.scene3d.floaters.spawn(
|
|
||||||
{ x: npc.x, y: (npc.y || 0) + 2.2, z: npc.z }, amt, this._autoFloater.opts || {});
|
|
||||||
} catch (e) { /* ignore */ }
|
|
||||||
}
|
|
||||||
if (npc.hp <= 0) this._killNpc(npc);
|
if (npc.hp <= 0) this._killNpc(npc);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Нанести урон NPC по мешу-попаданию (бластер/оружие). Ищет NPC, чьи меши
|
|
||||||
* содержат hit-меш (или предка). Вызывает damage() → авто-floater. */
|
|
||||||
damageByMesh(mesh, amount) {
|
|
||||||
if (!mesh) return false;
|
|
||||||
let m = mesh;
|
|
||||||
for (let i = 0; i < 8 && m; i++) {
|
|
||||||
const nid = m.metadata && m.metadata.npcId;
|
|
||||||
if (nid != null && this.npcs.has(nid)) { this.damage(nid, amount); return true; }
|
|
||||||
m = m.parent;
|
|
||||||
}
|
|
||||||
for (const npc of this.npcs.values()) {
|
|
||||||
if (npc.dead) continue;
|
|
||||||
const root = npc.data && npc.data.rootMesh;
|
|
||||||
if (!root) continue;
|
|
||||||
let mm = mesh;
|
|
||||||
for (let i = 0; i < 8 && mm; i++) {
|
|
||||||
if (mm === root) { this.damage(npc.id, amount); return true; }
|
|
||||||
mm = mm.parent;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Удалить NPC по id (без эффекта смерти — просто убрать). */
|
/** Удалить NPC по id (без эффекта смерти — просто убрать). */
|
||||||
removeNpc(id) {
|
removeNpc(id) {
|
||||||
const npc = this.npcs.get(Number(id));
|
const npc = this.npcs.get(Number(id));
|
||||||
@ -441,22 +391,17 @@ export class NpcManager {
|
|||||||
if (root._isWorldMatrixFrozen) {
|
if (root._isWorldMatrixFrozen) {
|
||||||
try { root.unfreezeWorldMatrix(); } catch (e) {}
|
try { root.unfreezeWorldMatrix(); } catch (e) {}
|
||||||
}
|
}
|
||||||
// Процедурная анимация ходьбы (у Kenney-моделей нет скелета).
|
root.position.set(npc.x, npc.y, npc.z);
|
||||||
if (moving) npc.walkPhase += dt * 10;
|
|
||||||
let bobY = 0, lean = 0;
|
|
||||||
if (moving && !npc.r15Animator) {
|
|
||||||
bobY = Math.abs(Math.sin(npc.walkPhase)) * 0.12;
|
|
||||||
lean = Math.sin(npc.walkPhase) * 0.08;
|
|
||||||
}
|
|
||||||
root.position.set(npc.x, npc.y + bobY, npc.z);
|
|
||||||
root.rotation.y = npc.yaw;
|
root.rotation.y = npc.yaw;
|
||||||
root.rotation.z = lean;
|
|
||||||
// data.x/y/z — чтобы scene.find/getPosition видели NPC.
|
// data.x/y/z — чтобы scene.find/getPosition видели NPC.
|
||||||
data.x = npc.x; data.y = npc.y; data.z = npc.z;
|
data.x = npc.x; data.y = npc.y; data.z = npc.z;
|
||||||
|
|
||||||
|
// Анимация ходьбы — простое покачивание (без R15-скелета у Kenney).
|
||||||
|
if (moving) npc.walkPhase += dt * 6;
|
||||||
// R15-NPC (skin_*): процедурная анимация бега/покоя через R15Animator.
|
// R15-NPC (skin_*): процедурная анимация бега/покоя через R15Animator.
|
||||||
if (npc.r15Animator) {
|
if (npc.r15Animator) {
|
||||||
try {
|
try {
|
||||||
npc.r15Animator.setState(npc.attacking ? 'attack' : (moving ? 'run' : 'idle'));
|
npc.r15Animator.setState(moving ? 'run' : 'idle');
|
||||||
npc.r15Animator.update(dt);
|
npc.r15Animator.update(dt);
|
||||||
} catch (e) { /* ignore */ }
|
} catch (e) { /* ignore */ }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,586 +0,0 @@
|
|||||||
/**
|
|
||||||
* PlacementManager — drag-and-drop размещение объектов в 3D-мире (задача 11).
|
|
||||||
*
|
|
||||||
* Фундамент жанра tycoon/simulator/farm: «кликнул предмет в инвентаре →
|
|
||||||
* полупрозрачный preview летает за курсором → ЛКМ ставит, ПКМ/Esc отменяет».
|
|
||||||
* Аналог placement-системы из Roblox tycoon-игр (Pet Sim, Lumber Tycoon).
|
|
||||||
*
|
|
||||||
* Подключается из BabylonScene: `this.placementManager = new PlacementManager(this)`.
|
|
||||||
* Доступ к движку через scene3d: camera, scene, player, pick, spawn, fx.
|
|
||||||
*
|
|
||||||
* Скриптовый API игры (через GameRuntime → game.placement.*):
|
|
||||||
* start(itemKey, opts) — войти в режим расстановки
|
|
||||||
* cancel() — выйти (как ПКМ/Esc)
|
|
||||||
* confirm() — поставить на текущей позиции (как ЛКМ)
|
|
||||||
* rotate(deg) — повернуть preview (как R / колесо)
|
|
||||||
* onPlace / onCancel / onMove — колбэки (роутятся в worker как события)
|
|
||||||
*
|
|
||||||
* Фича-парность: идентичный модуль есть в rublox-player/src/engine/.
|
|
||||||
*/
|
|
||||||
import { MeshBuilder, StandardMaterial, Color3, Vector3 } from '@babylonjs/core';
|
|
||||||
|
|
||||||
const VALID_TINT = new Color3(0.30, 0.95, 0.40); // зелёный — можно ставить
|
|
||||||
const INVALID_TINT = new Color3(0.95, 0.25, 0.25); // красный — нельзя
|
|
||||||
|
|
||||||
export class PlacementManager {
|
|
||||||
constructor(scene3d) {
|
|
||||||
this.s = scene3d; // BabylonScene
|
|
||||||
this.scene = scene3d.scene;
|
|
||||||
this._active = null; // активная сессия placement или null
|
|
||||||
this._tickObs = null; // observer renderLoop
|
|
||||||
this._placementSeq = 0;
|
|
||||||
// Колбэки (вызываются движком, GameRuntime роутит их в worker как события)
|
|
||||||
this._onPlace = null;
|
|
||||||
this._onCancel = null;
|
|
||||||
this._onMove = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
setCallbacks({ onPlace, onCancel, onMove } = {}) {
|
|
||||||
if (onPlace !== undefined) this._onPlace = onPlace;
|
|
||||||
if (onCancel !== undefined) this._onCancel = onCancel;
|
|
||||||
if (onMove !== undefined) this._onMove = onMove;
|
|
||||||
}
|
|
||||||
|
|
||||||
isActive() { return !!this._active; }
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Войти в placement-режим.
|
|
||||||
* @param {string} itemKey — ключ предмета (передаётся обратно в onPlace)
|
|
||||||
* @param {object} opts — см. 11_placement_mode.md §2.1
|
|
||||||
* @returns {string} placementId
|
|
||||||
*/
|
|
||||||
start(itemKey, opts = {}) {
|
|
||||||
// Уже активна сессия — отменим прежнюю (без onCancel-шума автора).
|
|
||||||
if (this._active) this._teardown(false);
|
|
||||||
|
|
||||||
const o = {
|
|
||||||
previewType: opts.previewType || 'primitive:cube',
|
|
||||||
previewColor: opts.previewColor || '#a0522d',
|
|
||||||
previewScale: Number(opts.previewScale) || 1,
|
|
||||||
// modelScale — реальный scale воксельной модели для превью (чтобы
|
|
||||||
// полупрозрачная копия была того же размера, что и ставимый объект).
|
|
||||||
modelScale: Number(opts.modelScale) || Number(opts.previewScale) || 1,
|
|
||||||
ghostOpacity: opts.ghostOpacity != null ? Number(opts.ghostOpacity) : 0.5,
|
|
||||||
surfaceMode: opts.surfaceMode || 'ground', // 'ground'|'any'|'tag'
|
|
||||||
allowSurfaces: Array.isArray(opts.allowSurfaces) ? opts.allowSurfaces : null,
|
|
||||||
forbidOverlap: opts.forbidOverlap !== false,
|
|
||||||
grid: opts.grid != null ? Number(opts.grid) : 1,
|
|
||||||
rotationStep: opts.rotationStep != null ? Number(opts.rotationStep) : 90,
|
|
||||||
targetZone: opts.targetZone || null, // ref-строка примитива-зоны
|
|
||||||
showZoneOutline: opts.showZoneOutline !== false,
|
|
||||||
showArrowFrom: opts.showArrowFrom || null, // 'player' | ref
|
|
||||||
cost: Number(opts.cost) || 0,
|
|
||||||
currency: opts.currency || 'rubles',
|
|
||||||
hint: opts.hint || '',
|
|
||||||
hintError: opts.hintError || 'Разместите в отмеченном месте!',
|
|
||||||
placedType: opts.placedType || null,
|
|
||||||
chainPlace: !!opts.chainPlace,
|
|
||||||
maxDistance: opts.maxDistance != null ? Number(opts.maxDistance) : 0,
|
|
||||||
maxItems: opts.maxItems != null ? Number(opts.maxItems) : 0,
|
|
||||||
forceCameraMode: opts.forceCameraMode !== false,
|
|
||||||
freezePlayer: !!opts.freezePlayer,
|
|
||||||
previewPulse: opts.previewPulse !== false,
|
|
||||||
};
|
|
||||||
|
|
||||||
const id = 'placement_' + (++this._placementSeq);
|
|
||||||
const preview = this._createPreview(o);
|
|
||||||
|
|
||||||
this._active = {
|
|
||||||
id, itemKey, opts: o, preview,
|
|
||||||
rotationY: 0,
|
|
||||||
valid: false,
|
|
||||||
pos: new Vector3(0, 0, 0),
|
|
||||||
zoneOutline: null,
|
|
||||||
arrowFxRef: null,
|
|
||||||
placedCount: 0,
|
|
||||||
pulseT: 0,
|
|
||||||
prevCameraMode: null,
|
|
||||||
prevFrozen: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Зона размещения — красный контур по AABB.
|
|
||||||
if (o.targetZone && o.showZoneOutline) this._createZoneOutline();
|
|
||||||
// Стрелка от игрока/объекта к зоне (переиспользует fx.pointer задачи 08).
|
|
||||||
if (o.showArrowFrom && o.targetZone) this._createArrow();
|
|
||||||
// Камера: placement требует видимый курсор — в first переводим в third.
|
|
||||||
if (o.forceCameraMode) this._forceThirdCamera();
|
|
||||||
// Заморозка игрока (опция).
|
|
||||||
if (o.freezePlayer) this._setPlayerFrozen(true);
|
|
||||||
|
|
||||||
// HUD: подсказки снизу-справа + верхний hint. Сообщаем движку.
|
|
||||||
this._emitHud(true);
|
|
||||||
|
|
||||||
this._startTick();
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
|
|
||||||
cancel() {
|
|
||||||
if (!this._active) return;
|
|
||||||
const cb = this._onCancel;
|
|
||||||
this._teardown(true);
|
|
||||||
if (typeof cb === 'function') cb();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Поставить на текущей позиции (как ЛКМ). */
|
|
||||||
confirm() {
|
|
||||||
const a = this._active;
|
|
||||||
if (!a) return false;
|
|
||||||
if (!a.valid) {
|
|
||||||
// Невалидно — звук «не получилось» + мигание preview в красный.
|
|
||||||
this._playFail();
|
|
||||||
this._flashInvalid();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
// Позиция для spawn = точка курсора МИНУС offset центра модели (с учётом
|
|
||||||
// поворота), чтобы реальный объект встал ОСНОВАНИЕМ под курсором —
|
|
||||||
// ровно туда, где показывалось превью. Для куба-превью offset = 0.
|
|
||||||
let ox = a._modelOffsetX || 0, oz = a._modelOffsetZ || 0;
|
|
||||||
if (ox || oz) {
|
|
||||||
const c = Math.cos(a.rotationY), s = Math.sin(a.rotationY);
|
|
||||||
const rx = ox * c - oz * s;
|
|
||||||
const rz = ox * s + oz * c;
|
|
||||||
ox = rx; oz = rz;
|
|
||||||
}
|
|
||||||
const result = {
|
|
||||||
itemKey: a.itemKey,
|
|
||||||
position: { x: a.pos.x - ox, y: a.pos.y, z: a.pos.z - oz },
|
|
||||||
rotationY: a.rotationY,
|
|
||||||
};
|
|
||||||
// Списание стоимости (если задана и есть валюта-хелпер в движке).
|
|
||||||
if (a.opts.cost > 0) this._spendCurrency(a.opts.currency, a.opts.cost);
|
|
||||||
a.placedCount++;
|
|
||||||
this._playPlace();
|
|
||||||
|
|
||||||
if (typeof this._onPlace === 'function') this._onPlace(result);
|
|
||||||
|
|
||||||
if (a.opts.chainPlace) {
|
|
||||||
// Остаёмся в режиме для следующего — preview не удаляем, сбрасываем поворот? нет, сохраняем.
|
|
||||||
// Просто продолжаем тик; valid пересчитается в следующем кадре.
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
this._teardown(false);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Повернуть preview на N градусов вокруг Y. */
|
|
||||||
rotate(deg) {
|
|
||||||
const a = this._active;
|
|
||||||
if (!a) return;
|
|
||||||
const step = (deg != null ? Number(deg) : a.opts.rotationStep) || 90;
|
|
||||||
a.rotationY = (a.rotationY + step * Math.PI / 180) % (Math.PI * 2);
|
|
||||||
if (a.preview) a.preview.rotation.y = a.rotationY;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Внутреннее ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
_createPreview(o) {
|
|
||||||
const base = Color3.FromHexString(o.previewColor || '#a0522d');
|
|
||||||
|
|
||||||
// Для воксельной модели (user:<id>) строим ПРЕВЬЮ ИЗ РЕАЛЬНОЙ ГЕОМЕТРИИ
|
|
||||||
// модели — полупрозрачную копию. Так тень точно повторяет форму предмета
|
|
||||||
// И совпадает по позиционированию с реальным spawn (модель растёт от угла
|
|
||||||
// root, а не центрируется — куб-превью раньше центрировался → предмет
|
|
||||||
// вставал в угол превью). Здесь превью = тот же addInstance, поэтому
|
|
||||||
// угол-в-угол. Делается асинхронно (см. _buildUserModelPreview).
|
|
||||||
const pt = o.previewType || '';
|
|
||||||
if (pt.indexOf('user:') === 0 && this.s.userModelManager) {
|
|
||||||
// Временный куб-заглушка пока модель грузится (1-2 кадра), заменим.
|
|
||||||
const stub = MeshBuilder.CreateBox('placementGhostStub', { size: 0.01 }, this.scene);
|
|
||||||
stub.isPickable = false;
|
|
||||||
stub._baseColor = base;
|
|
||||||
this._buildUserModelPreview(pt, o, base);
|
|
||||||
return stub;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Примитивы / прочее — полупрозрачный куб размером previewScale (юниты).
|
|
||||||
const edge = Number(o.previewScale) || 1;
|
|
||||||
const ghost = MeshBuilder.CreateBox('placementGhost', { size: edge }, this.scene);
|
|
||||||
const mat = new StandardMaterial('placementGhostMat', this.scene);
|
|
||||||
mat.diffuseColor = base;
|
|
||||||
mat.emissiveColor = base.scale(0.25);
|
|
||||||
mat.specularColor = new Color3(0, 0, 0);
|
|
||||||
mat.alpha = o.ghostOpacity;
|
|
||||||
mat.disableLighting = true;
|
|
||||||
ghost.material = mat;
|
|
||||||
ghost.isPickable = false;
|
|
||||||
ghost._baseColor = base;
|
|
||||||
return ghost;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Построить полупрозрачное превью из реальной воксельной модели (async). */
|
|
||||||
async _buildUserModelPreview(previewType, o, base) {
|
|
||||||
try {
|
|
||||||
const um = this.s.userModelManager;
|
|
||||||
// Спавним реальный инстанс модели в (0,0,0) — будем двигать как превью.
|
|
||||||
const instId = await um.addInstance(previewType, 0, 0, 0, 0, {
|
|
||||||
scale: o.modelScale || o.previewScale || 1,
|
|
||||||
canCollide: false, visible: true, anchored: true,
|
|
||||||
currentUserId: this.s._currentUserId || null,
|
|
||||||
});
|
|
||||||
if (instId == null) return;
|
|
||||||
// Сессия уже могла завершиться/смениться, пока грузилось.
|
|
||||||
const a = this._active;
|
|
||||||
if (!a) { try { um.removeInstance(instId); } catch (e) {} return; }
|
|
||||||
const inst = um.instances.get(instId);
|
|
||||||
if (!inst || !inst.rootNode) return;
|
|
||||||
// Применяем ghost-вид ко всем мешам модели: полупрозрачно, не pickable.
|
|
||||||
const ghostMat = new StandardMaterial('placementGhostMatUM', this.scene);
|
|
||||||
ghostMat.diffuseColor = base;
|
|
||||||
ghostMat.emissiveColor = base.scale(0.25);
|
|
||||||
ghostMat.specularColor = new Color3(0, 0, 0);
|
|
||||||
ghostMat.alpha = o.ghostOpacity;
|
|
||||||
ghostMat.disableLighting = true;
|
|
||||||
ghostMat.backFaceCulling = false;
|
|
||||||
for (const m of (inst.meshes || [])) {
|
|
||||||
m.isPickable = false;
|
|
||||||
m.material = ghostMat;
|
|
||||||
}
|
|
||||||
// Центр модели по X/Z (воксели растут углом от root → центр смещён).
|
|
||||||
// Вычисляем из bbox мешей в локальных координатах root (root в 0,0,0).
|
|
||||||
// Этот offset вычитаем из позиции, чтобы ОСНОВАНИЕ модели (её центр
|
|
||||||
// по X/Z) было ровно под курсором, а не угол. Применяется и к превью,
|
|
||||||
// и к реальному spawn (onPlace) — иначе предмет «уезжает» по диагонали.
|
|
||||||
let minX = Infinity, maxX = -Infinity, minZ = Infinity, maxZ = -Infinity;
|
|
||||||
for (const m of (inst.meshes || [])) {
|
|
||||||
m.computeWorldMatrix(true);
|
|
||||||
const bb = m.getBoundingInfo().boundingBox;
|
|
||||||
minX = Math.min(minX, bb.minimumWorld.x); maxX = Math.max(maxX, bb.maximumWorld.x);
|
|
||||||
minZ = Math.min(minZ, bb.minimumWorld.z); maxZ = Math.max(maxZ, bb.maximumWorld.z);
|
|
||||||
}
|
|
||||||
const offX = Number.isFinite(minX) ? (minX + maxX) / 2 : 0;
|
|
||||||
const offZ = Number.isFinite(minZ) ? (minZ + maxZ) / 2 : 0;
|
|
||||||
a._modelOffsetX = offX;
|
|
||||||
a._modelOffsetZ = offZ;
|
|
||||||
|
|
||||||
// Удаляем временный stub, новый root становится превью.
|
|
||||||
const old = a.preview;
|
|
||||||
a.preview = inst.rootNode;
|
|
||||||
a.preview._baseColor = base;
|
|
||||||
a.preview._userModelInstId = instId; // для teardown
|
|
||||||
a.preview._ghostMat = ghostMat;
|
|
||||||
if (old) { try { old.dispose(); } catch (e) {} }
|
|
||||||
} catch (e) {
|
|
||||||
// тихо — превью некритично, останется stub
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_startTick() {
|
|
||||||
this._tickObs = this.scene.onBeforeRenderObservable.add(() => this._tick());
|
|
||||||
}
|
|
||||||
|
|
||||||
_tick() {
|
|
||||||
const a = this._active;
|
|
||||||
if (!a) return;
|
|
||||||
const scn = this.scene;
|
|
||||||
|
|
||||||
// Raycast от камеры через текущую позицию курсора.
|
|
||||||
const pick = scn.pick(scn.pointerX, scn.pointerY, (m) =>
|
|
||||||
m && m.isPickable && m !== a.preview && this._isSurface(m, a.opts));
|
|
||||||
if (pick && pick.hit && pick.pickedPoint) {
|
|
||||||
let p = pick.pickedPoint.clone();
|
|
||||||
// surfaceMode 'ground' — нормаль должна смотреть вверх.
|
|
||||||
// Поверхность валидна, если смотрит вверх (горизонтальная грань).
|
|
||||||
// Это и пол, и ВЕРХ другого объекта → можно строить стопкой.
|
|
||||||
let surfOk = true;
|
|
||||||
if (a.opts.surfaceMode === 'ground') {
|
|
||||||
const n = pick.getNormal(true);
|
|
||||||
surfOk = n && n.y > 0.6; // только грань, обращённая вверх
|
|
||||||
}
|
|
||||||
// Снэппинг к сетке (X/Z). Y берём с поверхности — чтобы объект
|
|
||||||
// лёг ровно сверху на пол ИЛИ на другой объект (стопка).
|
|
||||||
if (a.opts.grid > 0) {
|
|
||||||
p.x = Math.round(p.x / a.opts.grid) * a.opts.grid;
|
|
||||||
p.z = Math.round(p.z / a.opts.grid) * a.opts.grid;
|
|
||||||
}
|
|
||||||
a.pos.copyFrom(p);
|
|
||||||
if (a.preview) {
|
|
||||||
if (a.preview._userModelInstId != null) {
|
|
||||||
// userModel-превью: root = угол модели. Вычитаем offset центра
|
|
||||||
// по X/Z → ОСНОВАНИЕ модели (ствол/центр) точно под курсором.
|
|
||||||
// Высота p.y без сдвига (низ модели на поверхность).
|
|
||||||
a.preview.position.set(
|
|
||||||
p.x - (a._modelOffsetX || 0),
|
|
||||||
p.y,
|
|
||||||
p.z - (a._modelOffsetZ || 0),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
// Куб-превью центрирован → поднимаем на полвысоты.
|
|
||||||
a.preview.position.set(p.x, p.y + 0.5 * a.opts.previewScale, p.z);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Валидность. forbidOverlap теперь означает «не врезаться вбок в
|
|
||||||
// объект на ТОЙ ЖЕ высоте» — вертикальная стопка разрешена.
|
|
||||||
a.valid = surfOk
|
|
||||||
&& this._inZone(p, a.opts)
|
|
||||||
&& this._distanceOk(p, a.opts)
|
|
||||||
&& this._limitOk(a.opts)
|
|
||||||
&& this._affordable(a)
|
|
||||||
&& (!a.opts.forbidOverlap || !this._overlapsSide(p, a));
|
|
||||||
} else {
|
|
||||||
a.valid = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Цвет preview: зелёный/красный.
|
|
||||||
this._applyTint(a, a.valid);
|
|
||||||
|
|
||||||
// Пульсация прозрачности (привлекает внимание). Материал — у куба-превью
|
|
||||||
// напрямую, у userModel-превью в _ghostMat (root — TransformNode без mat).
|
|
||||||
const pmat = a.preview && (a.preview.material || a.preview._ghostMat);
|
|
||||||
if (a.opts.previewPulse && pmat) {
|
|
||||||
a.pulseT += this.scene.getEngine().getDeltaTime() / 1000;
|
|
||||||
const k = 0.5 + 0.5 * Math.sin(a.pulseT * Math.PI); // 0..1
|
|
||||||
pmat.alpha = a.opts.ghostOpacity * (0.7 + 0.3 * k);
|
|
||||||
}
|
|
||||||
|
|
||||||
// HUD-индикатор ошибки (красный текст когда невалидно).
|
|
||||||
this._emitHudError(!a.valid);
|
|
||||||
|
|
||||||
// Стрелка к зоне — обновим конечную точку (если игрок движется).
|
|
||||||
if (a.arrowFxRef) this._updateArrow();
|
|
||||||
|
|
||||||
// onMove колбэк автору (каждый кадр).
|
|
||||||
if (typeof this._onMove === 'function') {
|
|
||||||
this._onMove({ position: { x: a.pos.x, y: a.pos.y, z: a.pos.z }, valid: a.valid });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_applyTint(a, valid) {
|
|
||||||
// Материал куба-превью напрямую, userModel-превью — в _ghostMat.
|
|
||||||
const pmat = a.preview && (a.preview.material || a.preview._ghostMat);
|
|
||||||
if (!pmat) return;
|
|
||||||
if (a._flashUntil && performance && performance.now && performance.now() < a._flashUntil) {
|
|
||||||
return; // во время flash держим красный
|
|
||||||
}
|
|
||||||
const tint = valid ? VALID_TINT : INVALID_TINT;
|
|
||||||
// Смешиваем базовый цвет с tint-ом (multiply-эффект).
|
|
||||||
const b = a.preview._baseColor || new Color3(0.6, 0.4, 0.25);
|
|
||||||
pmat.diffuseColor = new Color3(
|
|
||||||
b.r * tint.r + tint.r * 0.4,
|
|
||||||
b.g * tint.g + tint.g * 0.4,
|
|
||||||
b.b * tint.b + tint.b * 0.4,
|
|
||||||
);
|
|
||||||
pmat.emissiveColor = tint.scale(0.35);
|
|
||||||
}
|
|
||||||
|
|
||||||
_flashInvalid() {
|
|
||||||
const a = this._active;
|
|
||||||
if (!a || !a.preview || !a.preview.material) return;
|
|
||||||
try { a._flashUntil = (performance.now ? performance.now() : Date.now()) + 300; } catch { a._flashUntil = Date.now() + 300; }
|
|
||||||
a.preview.material.diffuseColor = INVALID_TINT;
|
|
||||||
a.preview.material.emissiveColor = INVALID_TINT.scale(0.6);
|
|
||||||
}
|
|
||||||
|
|
||||||
_isSurface(mesh, o) {
|
|
||||||
if (!o.allowSurfaces) return true; // любая поверхность
|
|
||||||
// Совпадение по имени или тегу.
|
|
||||||
const name = mesh.name || '';
|
|
||||||
if (o.allowSurfaces.some(s => name.includes(s))) return true;
|
|
||||||
const tags = mesh.metadata && mesh.metadata.tags;
|
|
||||||
if (Array.isArray(tags) && tags.some(t => o.allowSurfaces.includes(t))) return true;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
_inZone(p, o) {
|
|
||||||
if (!o.targetZone) return true;
|
|
||||||
const z = this._resolveZoneMesh(o.targetZone);
|
|
||||||
if (!z) return true;
|
|
||||||
const bb = z.getBoundingInfo().boundingBox;
|
|
||||||
const min = bb.minimumWorld, max = bb.maximumWorld;
|
|
||||||
return p.x >= min.x && p.x <= max.x && p.z >= min.z && p.z <= max.z;
|
|
||||||
}
|
|
||||||
|
|
||||||
_distanceOk(p, o) {
|
|
||||||
if (!o.maxDistance || o.maxDistance <= 0) return true;
|
|
||||||
const pl = this.s.player && this.s.player._pos;
|
|
||||||
if (!pl) return true;
|
|
||||||
const dx = p.x - pl.x, dz = p.z - pl.z;
|
|
||||||
return (dx * dx + dz * dz) <= o.maxDistance * o.maxDistance;
|
|
||||||
}
|
|
||||||
|
|
||||||
_limitOk(o) {
|
|
||||||
if (!o.maxItems || o.maxItems <= 0) return true;
|
|
||||||
return (this._active.placedCount || 0) < o.maxItems;
|
|
||||||
}
|
|
||||||
|
|
||||||
_overlapsSide(p, a) {
|
|
||||||
// Боковое пересечение: объект мешает ТОЛЬКО если он на той же высоте
|
|
||||||
// (его тело пересекает уровень, куда ляжет новый объект). Объект строго
|
|
||||||
// НИЖЕ (фундамент под стопку) или строго ВЫШЕ — не мешает. Это позволяет
|
|
||||||
// строить башню из кубов, но не даёт двум кубам слипнуться вбок.
|
|
||||||
const r = Math.max(0.45, (a.opts.grid || 1) * 0.5);
|
|
||||||
const newY = p.y; // высота поверхности (низ нового объекта)
|
|
||||||
const newTop = newY + (a.opts.previewScale || 1);
|
|
||||||
for (const m of this.scene.meshes) {
|
|
||||||
if (!m.isPickable || m === a.preview) continue;
|
|
||||||
if (!m.getBoundingInfo) continue;
|
|
||||||
const bb = m.getBoundingInfo().boundingBox;
|
|
||||||
const sizeX = bb.maximumWorld.x - bb.minimumWorld.x;
|
|
||||||
if (sizeX > 8) continue; // пол/большая поверхность — не препятствие
|
|
||||||
const c = bb.centerWorld;
|
|
||||||
const dx = c.x - p.x, dz = c.z - p.z;
|
|
||||||
if (Math.abs(dx) >= r || Math.abs(dz) >= r) continue; // не под курсором
|
|
||||||
const mTop = bb.maximumWorld.y, mBot = bb.minimumWorld.y;
|
|
||||||
// Пересечение по вертикали: тела перекрываются по Y → бок в бок.
|
|
||||||
const overlapY = newY < (mTop - 0.05) && newTop > (mBot + 0.05);
|
|
||||||
if (overlapY) return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Хватает ли валюты на текущий предмет (если задан баланс). */
|
|
||||||
_affordable(a) {
|
|
||||||
const cur = a.opts.currency;
|
|
||||||
const cost = a.opts.cost || 0;
|
|
||||||
if (!cost) return true;
|
|
||||||
const bal = (this._balances && this._balances[cur] != null) ? this._balances[cur] : Infinity;
|
|
||||||
return cost <= bal;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Установить баланс валюты (для проверки «нельзя уйти в минус»). */
|
|
||||||
setBalance(currency, amount) {
|
|
||||||
if (!this._balances) this._balances = {};
|
|
||||||
if (currency) this._balances[currency] = Number(amount) || 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
_resolveZoneMesh(ref) {
|
|
||||||
// ref может быть строкой ('primitive:N' / имя) или уже мешем.
|
|
||||||
if (ref && ref.getBoundingInfo) return ref;
|
|
||||||
if (typeof ref === 'string') {
|
|
||||||
// через scene3d — найти примитив/модель по ref
|
|
||||||
try {
|
|
||||||
const mesh = this.s._refStrToMesh ? this.s._refStrToMesh(ref) : null;
|
|
||||||
if (mesh) return mesh;
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
// fallback — по имени
|
|
||||||
return this.scene.getMeshByName(ref) || null;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
_createZoneOutline() {
|
|
||||||
const a = this._active;
|
|
||||||
const z = this._resolveZoneMesh(a.opts.targetZone);
|
|
||||||
if (!z) return;
|
|
||||||
const bb = z.getBoundingInfo().boundingBox;
|
|
||||||
const min = bb.minimumWorld, max = bb.maximumWorld;
|
|
||||||
const y = min.y + 0.06;
|
|
||||||
const pts = [
|
|
||||||
new Vector3(min.x, y, min.z), new Vector3(max.x, y, min.z),
|
|
||||||
new Vector3(max.x, y, max.z), new Vector3(min.x, y, max.z),
|
|
||||||
new Vector3(min.x, y, min.z),
|
|
||||||
];
|
|
||||||
const line = MeshBuilder.CreateLines('placementZoneOutline', { points: pts }, this.scene);
|
|
||||||
line.color = new Color3(1, 0.19, 0.19);
|
|
||||||
line.isPickable = false;
|
|
||||||
// glow-имитация: чуть приподнятая полупрозрачная плоскость
|
|
||||||
a.zoneOutline = line;
|
|
||||||
}
|
|
||||||
|
|
||||||
_createArrow() {
|
|
||||||
const a = this._active;
|
|
||||||
// Стрелка-указатель через BeamManager (задача 08: addPointer/setPointerTarget).
|
|
||||||
try {
|
|
||||||
const bm = this.s.beamManager;
|
|
||||||
if (!bm || !bm.addPointer) return;
|
|
||||||
const z = this._resolveZoneMesh(a.opts.targetZone);
|
|
||||||
if (!z) return;
|
|
||||||
const c = z.getBoundingInfo().boundingBox.centerWorld;
|
|
||||||
const from = (a.opts.showArrowFrom === 'player' && this.s.player && this.s.player._pos)
|
|
||||||
? this.s.player._pos
|
|
||||||
: this._resolveZoneMesh(a.opts.showArrowFrom);
|
|
||||||
const fromV = from && from.x != null ? new Vector3(from.x, (from.y || 0) + 1, from.z) : null;
|
|
||||||
if (!fromV) return;
|
|
||||||
a.arrowFxRef = bm.addPointer({
|
|
||||||
from: { x: fromV.x, y: fromV.y, z: fromV.z },
|
|
||||||
to: { x: c.x, y: c.y + 0.6, z: c.z },
|
|
||||||
preset: 'guide',
|
|
||||||
});
|
|
||||||
} catch { /* стрелка не критична */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
_updateArrow() {
|
|
||||||
// Стрелка статична от точки старта к зоне (как в Roblox tycoon —
|
|
||||||
// указатель «куда ставить»). BeamManager не имеет setPointerOrigin,
|
|
||||||
// а пересоздавать каждый кадр дорого. Конец уже привязан к зоне.
|
|
||||||
}
|
|
||||||
|
|
||||||
_forceThirdCamera() {
|
|
||||||
const a = this._active;
|
|
||||||
try {
|
|
||||||
if (this.s.player && this.s.player.getCameraMode && this.s.player.setCameraMode) {
|
|
||||||
a.prevCameraMode = this.s.player.getCameraMode();
|
|
||||||
if (a.prevCameraMode === 'first') this.s.player.setCameraMode('third');
|
|
||||||
}
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
_setPlayerFrozen(frozen) {
|
|
||||||
try {
|
|
||||||
if (this.s.player && this.s.player.setFrozen) {
|
|
||||||
if (this._active) this._active.prevFrozen = true;
|
|
||||||
this.s.player.setFrozen(frozen);
|
|
||||||
}
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
_spendCurrency(currency, amount) {
|
|
||||||
// Движок не держит «кошелёк» — это делает игра через onPlace + save.
|
|
||||||
// Но если есть хелпер валюты, вызовем. Иначе — no-op (автор сам спишет).
|
|
||||||
try {
|
|
||||||
if (this.s.spendCurrency) this.s.spendCurrency(currency, amount);
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
_playPlace() { try { this.s.gameAudioManager && this.s.gameAudioManager.playUi && this.s.gameAudioManager.playUi('place'); } catch { /* ignore */ } }
|
|
||||||
_playFail() { try { this.s.gameAudioManager && this.s.gameAudioManager.playUi && this.s.gameAudioManager.playUi('lose'); } catch { /* ignore */ } }
|
|
||||||
|
|
||||||
_emitHud(show) {
|
|
||||||
// Сообщаем движку показать/скрыть placement-HUD (подсказки).
|
|
||||||
try {
|
|
||||||
if (this.s.onPlacementHud) this.s.onPlacementHud({ show, hint: this._active ? this._active.opts.hint : '' });
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
_emitHudError(isError) {
|
|
||||||
try {
|
|
||||||
if (this.s.onPlacementHudError) this.s.onPlacementHudError(isError);
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
_teardown(emitHudOff) {
|
|
||||||
const a = this._active;
|
|
||||||
if (!a) return;
|
|
||||||
if (this._tickObs) { this.scene.onBeforeRenderObservable.remove(this._tickObs); this._tickObs = null; }
|
|
||||||
if (a.preview) {
|
|
||||||
try {
|
|
||||||
if (a.preview._userModelInstId != null && this.s.userModelManager) {
|
|
||||||
// userModel-превью — это реальный инстанс; удаляем через менеджер
|
|
||||||
// (снимет из Map + dispose мешей). + чистим ghost-материал.
|
|
||||||
try { a.preview._ghostMat && a.preview._ghostMat.dispose(); } catch (e) {}
|
|
||||||
this.s.userModelManager.removeInstance(a.preview._userModelInstId);
|
|
||||||
} else {
|
|
||||||
a.preview.material && a.preview.material.dispose();
|
|
||||||
a.preview.dispose();
|
|
||||||
}
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
}
|
|
||||||
if (a.zoneOutline) { try { a.zoneOutline.dispose(); } catch { /* ignore */ } }
|
|
||||||
if (a.arrowFxRef != null && this.s.beamManager && this.s.beamManager.remove) {
|
|
||||||
try { this.s.beamManager.remove(a.arrowFxRef); } catch { /* ignore */ }
|
|
||||||
}
|
|
||||||
if (a.prevCameraMode === 'first' && this.s.player && this.s.player.setCameraMode) {
|
|
||||||
try { this.s.player.setCameraMode('first'); } catch { /* ignore */ }
|
|
||||||
}
|
|
||||||
if (a.prevFrozen && this.s.player && this.s.player.setFrozen) {
|
|
||||||
try { this.s.player.setFrozen(false); } catch { /* ignore */ }
|
|
||||||
}
|
|
||||||
this._active = null;
|
|
||||||
if (emitHudOff !== false) this._emitHud(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Полный сброс при Stop игры. */
|
|
||||||
dispose() {
|
|
||||||
this._teardown(true);
|
|
||||||
this._onPlace = this._onCancel = this._onMove = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -144,15 +144,6 @@ export class PlayerController {
|
|||||||
// Камера. Дефолт — первое лицо (как в большинстве игр).
|
// Камера. Дефолт — первое лицо (как в большинстве игр).
|
||||||
this._cameraMode = 'third';
|
this._cameraMode = 'third';
|
||||||
this._thirdDistance = this.THIRD_DISTANCE_DEFAULT;
|
this._thirdDistance = this.THIRD_DISTANCE_DEFAULT;
|
||||||
// Порог авто-перехода third→first при зуме колесом (Roblox-style).
|
|
||||||
this.FIRST_PERSON_ZOOM_THRESHOLD = 0.7;
|
|
||||||
// Если true — нельзя выйти из first-person зумом (lockfirst-режим).
|
|
||||||
this._lockFirstPerson = false;
|
|
||||||
// Shift-Lock (Roblox-style): курсор зафиксирован, корпус лицом к камере.
|
|
||||||
this._shiftLock = false;
|
|
||||||
// Задача 02: ПКМ зажата сейчас (orbit в third) + видимость курсора.
|
|
||||||
this._rmbHeld = false;
|
|
||||||
this._mouseIconVisible = true;
|
|
||||||
|
|
||||||
// Ввод
|
// Ввод
|
||||||
this._codes = new Set();
|
this._codes = new Set();
|
||||||
@ -194,21 +185,6 @@ export class PlayerController {
|
|||||||
this._skinManifest = null; // кеш skins_manifest.json
|
this._skinManifest = null; // кеш skins_manifest.json
|
||||||
this._skinOverrides = {}; // overrides текущего скина
|
this._skinOverrides = {}; // overrides текущего скина
|
||||||
|
|
||||||
// === non-humanoid скины (задача 07) ===
|
|
||||||
// Скин без R15-скелета (животное, машина, абстрактная модель).
|
|
||||||
// Для них центрируем pivot, считаем собственный AABB и анимируем
|
|
||||||
// процедурно через _animateNonHumanoidMesh (см. _loadPlayerModel/_tick).
|
|
||||||
this._modelKind = 'r15'; // 'r15' | 'non-humanoid-mesh'
|
|
||||||
this._modelHipHeight = null; // локальная база модели (опущена на ноги)
|
|
||||||
this._nonHumanoidBox = null; // {hw,hh,hd} собственный AABB модели
|
|
||||||
this._lastFrameSpeed = 0; // горизонтальная скорость кадра (для анимаций)
|
|
||||||
this._isGrounded = true; // флаг «на земле» (для анимаций)
|
|
||||||
|
|
||||||
// === Блокировка ввода/камеры для модалов (задача 04) ===
|
|
||||||
this._inputBlocked = false; // глотает игровой ввод (кроме Esc/Tab/Enter)
|
|
||||||
this._cameraFrozen = false; // замораживает вращение/зум камеры
|
|
||||||
this._savedCameraState = null;// сохранённое состояние камеры (focusOnTarget)
|
|
||||||
|
|
||||||
// === Жизни игрока ===
|
// === Жизни игрока ===
|
||||||
this.maxHp = 100;
|
this.maxHp = 100;
|
||||||
this.hp = 100;
|
this.hp = 100;
|
||||||
@ -320,44 +296,6 @@ export class PlayerController {
|
|||||||
this._modelTypeId = typeId || 'character-a';
|
this._modelTypeId = typeId || 'character-a';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Сменить скин ИГРОКА в Play-режиме без перезапуска сцены (задача 07).
|
|
||||||
* Выгружает текущую модель/скелет/аниматор, сбрасывает AABB к дефолту,
|
|
||||||
* грузит новую модель (R15 или non-humanoid). Возвращает Promise.
|
|
||||||
*
|
|
||||||
* Используется из game.player.setSkin(slug).
|
|
||||||
*/
|
|
||||||
async reloadSkin(typeId) {
|
|
||||||
if (!this._active) return false;
|
|
||||||
const newType = typeId || 'character-a';
|
|
||||||
if (newType === this._modelTypeId && this._modelRoot) return true; // уже этот скин
|
|
||||||
// 1) Выгрузить текущую модель и связанные аниматоры.
|
|
||||||
try {
|
|
||||||
if (this._modelRoot) { this._modelRoot.dispose(false, true); }
|
|
||||||
} catch (e) { /* ignore */ }
|
|
||||||
this._modelRoot = null;
|
|
||||||
this._modelMeshes = [];
|
|
||||||
this._rightArmMeshes = [];
|
|
||||||
this._r15Skeleton = null;
|
|
||||||
this._r15Animator = null;
|
|
||||||
this._isR15 = false;
|
|
||||||
this._modelKind = 'r15';
|
|
||||||
this._modelHipHeight = null;
|
|
||||||
this._nonHumanoidBox = null;
|
|
||||||
// 2) Сбросить AABB к дефолтному «человеку» (non-humanoid его переопределит).
|
|
||||||
this.HALF_W = 0.3;
|
|
||||||
this.HALF_H = 0.9;
|
|
||||||
this.HALF_D = 0.3;
|
|
||||||
this.HALF_H_NORMAL = 0.9;
|
|
||||||
this.EYE_HEIGHT = 0.7;
|
|
||||||
// 3) Поднять игрока на запас, чтобы новый AABB не оказался в полу.
|
|
||||||
this._pos.y += 0.5;
|
|
||||||
// 4) Загрузить новую модель.
|
|
||||||
this._modelTypeId = newType;
|
|
||||||
await this._loadPlayerModel();
|
|
||||||
return !!this._modelRoot;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Запустить режим игры.
|
* Запустить режим игры.
|
||||||
* spawnPos — точка спавна. Если не указано — (0, 5, 0).
|
* spawnPos — точка спавна. Если не указано — (0, 5, 0).
|
||||||
@ -398,29 +336,11 @@ export class PlayerController {
|
|||||||
this._beforeRender = () => this._tick();
|
this._beforeRender = () => this._tick();
|
||||||
this.scene.registerBeforeRender(this._beforeRender);
|
this.scene.registerBeforeRender(this._beforeRender);
|
||||||
|
|
||||||
// === Задача 02: pointer-lock берём ТОЛЬКО в режимах с постоянным lock
|
// Сразу запросить Pointer Lock. Promise-форма (новые Chrome) может
|
||||||
// (first/lockfirst/sideview/shift-lock). В third курсор виден свободно —
|
// отклониться с SecurityError если предыдущий lock ещё не отпущен —
|
||||||
// кликает GUI и 3D-таблички, камера крутится только при зажатой ПКМ.
|
// в этом случае ждём отпускания и пробуем снова.
|
||||||
if (this._isPermaLockMode()) {
|
|
||||||
this._requestPointerLockSafe();
|
this._requestPointerLockSafe();
|
||||||
}
|
}
|
||||||
this._applyCursorVisibility();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Задача 02: режим с ПОСТОЯННЫМ pointer-lock (мышь всегда крутит камеру). */
|
|
||||||
_isPermaLockMode() {
|
|
||||||
return this._cameraMode === 'first' || this._cameraMode === 'lockfirst'
|
|
||||||
|| this._cameraMode === 'sideview' || this._shiftLock;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Задача 02: показать/скрыть курсор. В third виден (если нет lock), в
|
|
||||||
* first/lock — скрыт. Учитывает game.input.setMouseIconVisible. */
|
|
||||||
_applyCursorVisibility() {
|
|
||||||
if (!this.canvas) return;
|
|
||||||
const locked = (document.pointerLockElement === this.canvas);
|
|
||||||
const show = (this._mouseIconVisible !== false) && !locked;
|
|
||||||
try { this.canvas.style.cursor = show ? '' : 'none'; } catch (e) { /* ignore */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Безопасный запрос Pointer Lock с обработкой SecurityError при быстрых
|
* Безопасный запрос Pointer Lock с обработкой SecurityError при быстрых
|
||||||
@ -580,10 +500,6 @@ export class PlayerController {
|
|||||||
setUiCursorMode(enabled) {
|
setUiCursorMode(enabled) {
|
||||||
this._uiCursorMode = !!enabled;
|
this._uiCursorMode = !!enabled;
|
||||||
if (enabled) {
|
if (enabled) {
|
||||||
// Открываем UI (меню/курсор) → сбрасываем удержание ПКМ. Иначе если
|
|
||||||
// меню открыли при зажатой ПКМ, _rmbHeld застревает в true и orbit-
|
|
||||||
// камера после закрытия меню «думает», что ПКМ всё ещё активна.
|
|
||||||
this._rmbHeld = false;
|
|
||||||
// Освобождаем мышь
|
// Освобождаем мышь
|
||||||
if (document.pointerLockElement === this.canvas) {
|
if (document.pointerLockElement === this.canvas) {
|
||||||
try { document.exitPointerLock(); } catch (e) { /* ignore */ }
|
try { document.exitPointerLock(); } catch (e) { /* ignore */ }
|
||||||
@ -657,51 +573,45 @@ export class PlayerController {
|
|||||||
*/
|
*/
|
||||||
async _loadSkinManifest() {
|
async _loadSkinManifest() {
|
||||||
if (this._skinManifest) return this._skinManifest;
|
if (this._skinManifest) return this._skinManifest;
|
||||||
// ВАЖНО: объединяем ОБА источника, а не «или-или».
|
// 2026-05-27: сначала пробуем БД (rublox_avatars), там и легаси и
|
||||||
// Баг (2026-05-30): раньше при непустом /rublox/avatars возвращался
|
// дизайнерские аватары после approve. Только при сетевой ошибке —
|
||||||
// ТОЛЬКО он, а статичный skins_manifest.json (где встроенные
|
// fallback на статичный manifest.json.
|
||||||
// non-humanoid скины — еда/машины/животные: skin_burger, squirrel-donut
|
|
||||||
// и т.д.) НЕ подгружался. setSkin('burger') не находил entry → fallback
|
|
||||||
// на несуществующий characters/skin_burger/body.glb → 404 → краш GLTF
|
|
||||||
// (Unexpected magic) → старая модель уже выгружена, новая не создаётся →
|
|
||||||
// скин исчезал. Теперь грузим статичный манифест ВСЕГДА, плюс аватары.
|
|
||||||
let combined = [];
|
|
||||||
// 1) Статичный JSON (встроенные скины, включая non-humanoid).
|
|
||||||
try {
|
|
||||||
const resp = await fetch('/kubikon-assets/characters/skins_manifest.json');
|
|
||||||
if (resp.ok) {
|
|
||||||
const json = await resp.json();
|
|
||||||
if (Array.isArray(json.skins)) combined = combined.concat(json.skins);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.warn('[PlayerController] skins_manifest load failed:', e);
|
|
||||||
}
|
|
||||||
// 2) БД rublox_avatars (легаси + дизайнерские аватары после approve).
|
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(_storysApiUrl('/rublox/avatars'));
|
const resp = await fetch(_storysApiUrl('/rublox/avatars'));
|
||||||
if (resp.ok) {
|
if (resp.ok) {
|
||||||
const json = await resp.json();
|
const json = await resp.json();
|
||||||
const items = json.items || [];
|
const items = json.items || [];
|
||||||
// Нормализуем: file уже полный путь (absolute_file=true), т.к.
|
// Нормализуем под формат старого manifest:
|
||||||
// _resolveModelSource иначе добавляет '/kubikon-assets/' префикс.
|
// {id, file (без /kubikon-assets/ префикса), overrides}
|
||||||
const avatars = items.map((a) => ({
|
// — потому что _resolveModelSource дальше добавляет
|
||||||
|
// '/kubikon-assets/' + entry.file.
|
||||||
|
// Дизайнерский file_path может быть /api-storys/... — оставляем
|
||||||
|
// как есть и добавляем спец-флаг entry.absolute_file=true,
|
||||||
|
// _resolveModelSource учтёт.
|
||||||
|
this._skinManifest = items.map((a) => ({
|
||||||
id: a.code,
|
id: a.code,
|
||||||
name: a.name,
|
name: a.name,
|
||||||
file: a.file_path,
|
file: a.file_path,
|
||||||
overrides: a.overrides || {},
|
overrides: a.overrides || {},
|
||||||
absolute_file: true,
|
absolute_file: true, // file уже полный путь, не resolve через /kubikon-assets/
|
||||||
}));
|
}));
|
||||||
// Аватары имеют приоритет при совпадении id — кладём в начало.
|
if (this._skinManifest.length > 0) return this._skinManifest;
|
||||||
const avatarIds = new Set(avatars.map((a) => a.id));
|
|
||||||
combined = avatars.concat(combined.filter((s) => !avatarIds.has(s.id)));
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
console.warn('[PlayerController] /rublox/avatars failed:', e);
|
console.warn('[PlayerController] /rublox/avatars failed, fallback to manifest.json:', e);
|
||||||
}
|
}
|
||||||
this._skinManifest = combined;
|
// Fallback на статичный JSON
|
||||||
return combined;
|
try {
|
||||||
|
const resp = await fetch('/kubikon-assets/characters/skins_manifest.json');
|
||||||
|
const json = await resp.json();
|
||||||
|
this._skinManifest = json.skins || [];
|
||||||
|
} catch (e) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.warn('[PlayerController] skins_manifest load failed:', e);
|
||||||
|
this._skinManifest = [];
|
||||||
|
}
|
||||||
|
return this._skinManifest;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -767,34 +677,10 @@ export class PlayerController {
|
|||||||
// Прямой URL (для preview-режима или тестов).
|
// Прямой URL (для preview-режима или тестов).
|
||||||
return { file: typeId, isR15: true, overrides: {} };
|
return { file: typeId, isR15: true, overrides: {} };
|
||||||
}
|
}
|
||||||
// Кастомный .glb пользователя: 'customskin:<slug>'. dataUrl + метаданные
|
|
||||||
// (scale/hipHeight) лежат в scene._skinsConfig.customGlbs.
|
|
||||||
if (typeId.startsWith('customskin:')) {
|
|
||||||
const slug = typeId.slice('customskin:'.length);
|
|
||||||
const list = this._scene3d?._skinsConfig?.customGlbs || [];
|
|
||||||
const meta = list.find(g => g && g.slug === slug) || null;
|
|
||||||
const url = this._scene3d?.getAssetDataUrl?.(slug) || (meta && meta.dataUrl) || null;
|
|
||||||
if (url) {
|
|
||||||
return {
|
|
||||||
file: url, isR15: false, kind: 'non-humanoid-mesh', overrides: {},
|
|
||||||
scaleManifest: meta?.scale ?? 1.5,
|
|
||||||
hipHeight: meta?.hipHeight ?? 0.4,
|
|
||||||
rotationYOffset: meta?.rotationYOffset ?? 0,
|
|
||||||
isDataUrl: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (typeId.startsWith('skin_')) {
|
if (typeId.startsWith('skin_')) {
|
||||||
const manifest = await this._loadSkinManifest();
|
const manifest = await this._loadSkinManifest();
|
||||||
const entry = manifest.find((s) => s.id === typeId);
|
const entry = manifest.find((s) => s.id === typeId);
|
||||||
if (entry) {
|
if (entry) {
|
||||||
// kind определяет систему анимации:
|
|
||||||
// 'r15' → R15-скелет (как раньше)
|
|
||||||
// 'non-humanoid-mesh' → single-mesh, процедурное покачивание
|
|
||||||
// 'non-humanoid-rigged' → свой скелет + встроенные AnimationGroup
|
|
||||||
// Отсутствие kind = 'r15' (обратная совместимость со старым манифестом).
|
|
||||||
const kind = entry.kind || 'r15';
|
|
||||||
// absolute_file=true (источник /rublox/avatars) — file уже
|
// absolute_file=true (источник /rublox/avatars) — file уже
|
||||||
// полный URL (legacy /kubikon-assets/... или дизайнерский
|
// полный URL (legacy /kubikon-assets/... или дизайнерский
|
||||||
// /api-storys/...). Без флага — это легаси-формат
|
// /api-storys/...). Без флага — это легаси-формат
|
||||||
@ -804,25 +690,20 @@ export class PlayerController {
|
|||||||
: '/kubikon-assets/' + entry.file;
|
: '/kubikon-assets/' + entry.file;
|
||||||
return {
|
return {
|
||||||
file,
|
file,
|
||||||
isR15: kind === 'r15',
|
isR15: true,
|
||||||
kind,
|
|
||||||
overrides: entry.overrides || {},
|
overrides: entry.overrides || {},
|
||||||
scaleManifest: Number.isFinite(entry.scale) ? entry.scale : null,
|
|
||||||
hipHeight: Number.isFinite(entry.hipHeight) ? entry.hipHeight : null,
|
|
||||||
rotationYOffset: Number.isFinite(entry.rotationYOffset) ? entry.rotationYOffset : 0,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
// нет в манифесте — пробуем прямой путь
|
// нет в манифесте — пробуем прямой путь
|
||||||
return {
|
return {
|
||||||
file: `/kubikon-assets/characters/${typeId}/body.glb`,
|
file: `/kubikon-assets/characters/${typeId}/body.glb`,
|
||||||
isR15: true,
|
isR15: true,
|
||||||
kind: 'r15',
|
|
||||||
overrides: {},
|
overrides: {},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const modelType = getModelType(typeId);
|
const modelType = getModelType(typeId);
|
||||||
if (!modelType) return null;
|
if (!modelType) return null;
|
||||||
return { file: modelType.file, isR15: false, kind: 'r15', overrides: {} };
|
return { file: modelType.file, isR15: false, overrides: {} };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Подгрузить metadata designer-аватара по id через api-storys. */
|
/** Подгрузить metadata designer-аватара по id через api-storys. */
|
||||||
@ -949,17 +830,9 @@ export class PlayerController {
|
|||||||
absFile = 'https://minecraftia-school.ru' + absFile;
|
absFile = 'https://minecraftia-school.ru' + absFile;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let rootUrl, filename;
|
|
||||||
if (source.isDataUrl) {
|
|
||||||
// Кастомный скин — data:URL. SceneLoader принимает его как rootUrl=''
|
|
||||||
// и filename=data:... с подсказкой расширения через 5-й аргумент.
|
|
||||||
rootUrl = '';
|
|
||||||
filename = absFile;
|
|
||||||
} else {
|
|
||||||
const lastSlash = absFile.lastIndexOf('/');
|
const lastSlash = absFile.lastIndexOf('/');
|
||||||
rootUrl = absFile.substring(0, lastSlash + 1);
|
const rootUrl = absFile.substring(0, lastSlash + 1);
|
||||||
filename = absFile.substring(lastSlash + 1);
|
const filename = absFile.substring(lastSlash + 1);
|
||||||
}
|
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
console.log(`[PlayerController] SceneLoader.Load: ${rootUrl}${filename}`);
|
console.log(`[PlayerController] SceneLoader.Load: ${rootUrl}${filename}`);
|
||||||
// Прогресс-индикатор для больших GLB (некоторые дизайнерские
|
// Прогресс-индикатор для больших GLB (некоторые дизайнерские
|
||||||
@ -985,7 +858,6 @@ export class PlayerController {
|
|||||||
try {
|
try {
|
||||||
container = await SceneLoader.LoadAssetContainerAsync(
|
container = await SceneLoader.LoadAssetContainerAsync(
|
||||||
rootUrl, filename, this.scene, onProgress,
|
rootUrl, filename, this.scene, onProgress,
|
||||||
source.isDataUrl ? '.glb' : undefined,
|
|
||||||
);
|
);
|
||||||
try { window.__playerLoadProgress = null; } catch (e) {}
|
try { window.__playerLoadProgress = null; } catch (e) {}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@ -1008,20 +880,10 @@ export class PlayerController {
|
|||||||
// с торчащими волосами/плащами (как у bacon-hair).
|
// с торчащими волосами/плащами (как у bacon-hair).
|
||||||
// - Kenney-модели: старый 0.72.
|
// - Kenney-модели: старый 0.72.
|
||||||
// - overrides.scale_mult — per-skin множитель из манифеста.
|
// - overrides.scale_mult — per-skin множитель из манифеста.
|
||||||
// Non-humanoid скины (животное/машина/еда) масштабируются иначе:
|
let modelScale = source.isR15 ? 0.301 : this._modelScale;
|
||||||
// базовый размер из манифеста (scale), без фикс-0.301.
|
|
||||||
const isNonHumanoid = source.kind === 'non-humanoid-mesh'
|
|
||||||
|| source.kind === 'non-humanoid-rigged';
|
|
||||||
let modelScale;
|
|
||||||
if (isNonHumanoid) {
|
|
||||||
modelScale = Number.isFinite(source.scaleManifest) ? source.scaleManifest : 1.0;
|
|
||||||
} else {
|
|
||||||
modelScale = source.isR15 ? 0.301 : this._modelScale;
|
|
||||||
const scaleMult = (source.overrides && source.overrides.scale_mult) || 1.0;
|
const scaleMult = (source.overrides && source.overrides.scale_mult) || 1.0;
|
||||||
modelScale *= scaleMult;
|
modelScale *= scaleMult;
|
||||||
}
|
|
||||||
root.scaling = new Vector3(modelScale, modelScale, modelScale);
|
root.scaling = new Vector3(modelScale, modelScale, modelScale);
|
||||||
if (source.rotationYOffset) root.rotation.y = source.rotationYOffset;
|
|
||||||
const inst = container.instantiateModelsToScene(
|
const inst = container.instantiateModelsToScene(
|
||||||
(name) => `player_${name}`,
|
(name) => `player_${name}`,
|
||||||
/*cloneAnimations*/ true,
|
/*cloneAnimations*/ true,
|
||||||
@ -1038,14 +900,6 @@ export class PlayerController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
this._modelRoot = root;
|
this._modelRoot = root;
|
||||||
this._modelKind = source.kind || 'r15';
|
|
||||||
// hipHeight: на сколько центр модели поднят от «низа ног».
|
|
||||||
this._modelHipHeight = Number.isFinite(source.hipHeight) ? source.hipHeight : null;
|
|
||||||
|
|
||||||
// Non-humanoid: нормализуем размер и опускаем модель на «ноги».
|
|
||||||
if (isNonHumanoid) {
|
|
||||||
this._setupNonHumanoidModel(root, modelScale, source);
|
|
||||||
}
|
|
||||||
|
|
||||||
// === R15-скин: детекция скелета ===
|
// === R15-скин: детекция скелета ===
|
||||||
// R15-скины приходят с встроенным скелетом Mixamo. Babylon
|
// R15-скины приходят с встроенным скелетом Mixamo. Babylon
|
||||||
@ -1196,121 +1050,6 @@ export class PlayerController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Настройка non-humanoid модели (животное/машина/еда): нормализация
|
|
||||||
* размера и опускание на «низ ног». В отличие от R15 (нормализованы
|
|
||||||
* пайплайном), эти модели произвольного размера, поэтому считаем bbox.
|
|
||||||
*
|
|
||||||
* Локальные координаты root: модель должна стоять так, чтобы её низ был
|
|
||||||
* на y=0 (там «ноги»). PlayerController позиционирует root в точке
|
|
||||||
* `_pos.y - HALF_H` (низ AABB) — модель «садится» на землю.
|
|
||||||
*/
|
|
||||||
_setupNonHumanoidModel(root, scaleApplied, source) {
|
|
||||||
try {
|
|
||||||
// Считаем bounding box всех дочерних мешей в world-координатах ПОСЛЕ
|
|
||||||
// применения scaling root'а. Babylon refreshBoundingInfo нужен после
|
|
||||||
// инстансинга.
|
|
||||||
const meshes = root.getChildMeshes(false).filter(m => m.getBoundingInfo && m.getTotalVertices && m.getTotalVertices() > 0);
|
|
||||||
if (!meshes.length) return;
|
|
||||||
root.computeWorldMatrix(true);
|
|
||||||
let minY = Infinity, maxY = -Infinity, maxDim = 0;
|
|
||||||
let minX = Infinity, maxX = -Infinity, minZ = Infinity, maxZ = -Infinity;
|
|
||||||
for (const m of meshes) {
|
|
||||||
m.computeWorldMatrix(true);
|
|
||||||
// refreshBoundingInfo(true) — пересчитать bbox с учётом возможного
|
|
||||||
// скелета/морфов; без него minimumWorld у инстансов часто нулевой
|
|
||||||
// или из исходной позы → центр считался неверно (баг пришельца/робота).
|
|
||||||
try { m.refreshBoundingInfo(true); } catch (e) { try { m.refreshBoundingInfo(); } catch (e2) {} }
|
|
||||||
const bi = m.getBoundingInfo();
|
|
||||||
const bb = bi.boundingBox;
|
|
||||||
const lo = bb.minimumWorld, hi = bb.maximumWorld;
|
|
||||||
if (!lo || !hi) continue;
|
|
||||||
minY = Math.min(minY, lo.y); maxY = Math.max(maxY, hi.y);
|
|
||||||
minX = Math.min(minX, lo.x); maxX = Math.max(maxX, hi.x);
|
|
||||||
minZ = Math.min(minZ, lo.z); maxZ = Math.max(maxZ, hi.z);
|
|
||||||
}
|
|
||||||
if (!Number.isFinite(minX) || !Number.isFinite(minY)) return;
|
|
||||||
const h = maxY - minY;
|
|
||||||
const w = maxX - minX;
|
|
||||||
const d = maxZ - minZ;
|
|
||||||
maxDim = Math.max(h, w, d);
|
|
||||||
// === Центрирование модели через pivot-node ===
|
|
||||||
// Многие Kenney-модели имеют origin НЕ в геометрическом центре
|
|
||||||
// (в углу/ноге) → при повороте модель «облетает» вокруг смещённого
|
|
||||||
// origin (баг пришельца/робота). Ручной сдвиг детей с делением на
|
|
||||||
// scaleApplied неверен если у детей свой scale/rotation. Надёжно:
|
|
||||||
// вставляем промежуточный pivot между root и моделью и смещаем pivot
|
|
||||||
// на -localCenter (через инверсию world-матрицы root — точно при
|
|
||||||
// любом scale/rotation).
|
|
||||||
const worldCenter = new Vector3(
|
|
||||||
(minX + maxX) / 2, // центр X
|
|
||||||
minY, // низ Y (модель «садится» на ноги)
|
|
||||||
(minZ + maxZ) / 2 // центр Z
|
|
||||||
);
|
|
||||||
// world-центр → локальные координаты root
|
|
||||||
const invRoot = root.getWorldMatrix().clone().invert();
|
|
||||||
const localCenter = Vector3.TransformCoordinates(worldCenter, invRoot);
|
|
||||||
const pivot = new TransformNode('playerModelPivot', this.scene);
|
|
||||||
pivot.parent = root;
|
|
||||||
pivot.position.set(-localCenter.x, -localCenter.y, -localCenter.z);
|
|
||||||
// Перевешиваем все прямые дочерние ноды root (кроме самого pivot) на pivot.
|
|
||||||
for (const ch of root.getChildren().slice()) {
|
|
||||||
if (ch === pivot) continue;
|
|
||||||
ch.parent = pivot;
|
|
||||||
}
|
|
||||||
// Сохраняем размеры для настраиваемого AABB и камеры.
|
|
||||||
// hipHeight из манифеста — приоритетно; иначе берём низ модели.
|
|
||||||
this._nonHumanoidBox = { w, h, d };
|
|
||||||
this._modelBaseHeight = h;
|
|
||||||
// AABB подгоняем под модель (плоская/широкая для машин, узкая для еды).
|
|
||||||
// Ограничиваем разумными пределами чтобы не проваливаться/застревать.
|
|
||||||
this.HALF_W = Math.max(0.25, Math.min(0.9, w / 2));
|
|
||||||
this.HALF_D = Math.max(0.25, Math.min(1.1, d / 2));
|
|
||||||
const halfH = Math.max(0.3, Math.min(1.0, h / 2));
|
|
||||||
this.HALF_H = halfH;
|
|
||||||
this.HALF_H_NORMAL = halfH;
|
|
||||||
this.EYE_HEIGHT = halfH * 0.7;
|
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.log('[PlayerController] non-humanoid setup:', this._modelTypeId,
|
|
||||||
'kind=' + source.kind, 'bbox(w/h/d)=' + w.toFixed(2) + '/' + h.toFixed(2) + '/' + d.toFixed(2),
|
|
||||||
'AABB(W/H/D)=' + this.HALF_W.toFixed(2) + '/' + this.HALF_H.toFixed(2) + '/' + this.HALF_D.toFixed(2));
|
|
||||||
} catch (e) {
|
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.warn('[PlayerController] _setupNonHumanoidModel failed:', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Процедурная анимация single-mesh скина (нет скелета — нечего анимировать
|
|
||||||
* костями). Лёгкое покачивание вверх-вниз при движении + наклон вперёд при
|
|
||||||
* беге + наклон в воздухе. Вызывается каждый кадр из _tick.
|
|
||||||
* baseY — локальная база модели (опущена на ноги в _setupNonHumanoidModel).
|
|
||||||
*/
|
|
||||||
_animateNonHumanoidMesh(dt) {
|
|
||||||
const root = this._modelRoot;
|
|
||||||
if (!root) return;
|
|
||||||
const t = (typeof performance !== 'undefined' && performance.now)
|
|
||||||
? performance.now() / 1000 : Date.now() / 1000;
|
|
||||||
const speed = this._lastFrameSpeed || 0;
|
|
||||||
// Базовое вращение по yaw уже выставляет _tick (он крутит модель под
|
|
||||||
// направление движения/камеры). Мы добавляем ТОЛЬКО процедурный bob/tilt
|
|
||||||
// поверх — храним их в отдельных полях, чтобы _tick их не перетёр.
|
|
||||||
let bobY = 0, tiltX = 0;
|
|
||||||
if (!this._isGrounded) {
|
|
||||||
tiltX = 0.2; // в воздухе — нос вверх
|
|
||||||
} else if (speed > 0.1) {
|
|
||||||
const bobFreq = 8 * Math.min(2, speed / 4);
|
|
||||||
bobY = Math.sin(t * bobFreq) * 0.06;
|
|
||||||
tiltX = Math.min(speed * 0.04, 0.13);
|
|
||||||
} else {
|
|
||||||
bobY = Math.sin(t * 1.5) * 0.015; // лёгкое «дыхание» в покое
|
|
||||||
}
|
|
||||||
// Применяем поверх позиции, которую _tick уже выставил в root.position.y.
|
|
||||||
root.position.y += bobY;
|
|
||||||
// tilt по локальной оси X модели. rotation.y уже выставлен _tick'ом.
|
|
||||||
root.rotation.x = tiltX;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Аксессуары (Подфаза 3.3 RUBLOX_ACCESSORY_ATTACHMENT_PLAN.md) ──
|
// ─── Аксессуары (Подфаза 3.3 RUBLOX_ACCESSORY_ATTACHMENT_PLAN.md) ──
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -1823,143 +1562,6 @@ export class PlayerController {
|
|||||||
this._cameraOverride = null;
|
this._cameraOverride = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== Задача 14: вождение машины =====
|
|
||||||
enterVehicle(veh) {
|
|
||||||
if (!veh) return;
|
|
||||||
this._inVehicle = veh;
|
|
||||||
this._vehicleCamMode = 'follow';
|
|
||||||
veh.driver = 'player';
|
|
||||||
if (this._codes) this._codes.clear();
|
|
||||||
this._skinVisibleScripted = false;
|
|
||||||
this._startEngineSound();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Звук мотора: низкочастотный РОКОТ (бас-пила + отфильтрованный шум +
|
|
||||||
// LFO-пульсация тактов), а не воющий тон. Парность со студией.
|
|
||||||
_startEngineSound() {
|
|
||||||
try {
|
|
||||||
if (!this._audioCtx) {
|
|
||||||
const Ctx = window.AudioContext || window.webkitAudioContext;
|
|
||||||
if (!Ctx) return;
|
|
||||||
this._audioCtx = new Ctx();
|
|
||||||
}
|
|
||||||
const ctx = this._audioCtx;
|
|
||||||
if (ctx.state === 'suspended') ctx.resume();
|
|
||||||
if (this._engineNodes) return;
|
|
||||||
const osc = ctx.createOscillator(); osc.type = 'sawtooth'; osc.frequency.value = 45;
|
|
||||||
const bufLen = ctx.sampleRate * 1.0;
|
|
||||||
const buf = ctx.createBuffer(1, bufLen, ctx.sampleRate);
|
|
||||||
const data = buf.getChannelData(0);
|
|
||||||
for (let i = 0; i < bufLen; i++) data[i] = (Math.random() * 2 - 1) * 0.6;
|
|
||||||
const noise = ctx.createBufferSource(); noise.buffer = buf; noise.loop = true;
|
|
||||||
const noiseLp = ctx.createBiquadFilter(); noiseLp.type = 'lowpass'; noiseLp.frequency.value = 180; noiseLp.Q.value = 0.7;
|
|
||||||
const noiseGain = ctx.createGain(); noiseGain.gain.value = 0.35;
|
|
||||||
const lp = ctx.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.value = 350; lp.Q.value = 0.5;
|
|
||||||
const lfo = ctx.createOscillator(); lfo.type = 'sine'; lfo.frequency.value = 12;
|
|
||||||
const lfoGain = ctx.createGain(); lfoGain.gain.value = 0.18;
|
|
||||||
const gain = ctx.createGain(); gain.gain.value = 0.05;
|
|
||||||
osc.connect(lp);
|
|
||||||
noise.connect(noiseLp); noiseLp.connect(noiseGain); noiseGain.connect(lp);
|
|
||||||
lp.connect(gain); gain.connect(ctx.destination);
|
|
||||||
lfo.connect(lfoGain); lfoGain.connect(gain.gain);
|
|
||||||
osc.start(); noise.start(); lfo.start();
|
|
||||||
this._engineNodes = { osc, noise, noiseLp, noiseGain, lp, lfo, lfoGain, gain };
|
|
||||||
} catch (e) {}
|
|
||||||
}
|
|
||||||
_updateEngineSound(speedMs, maxSpeed) {
|
|
||||||
const n = this._engineNodes; if (!n) return;
|
|
||||||
try {
|
|
||||||
const frac = Math.min(1, Math.abs(speedMs) / (maxSpeed || 14));
|
|
||||||
const ctx = this._audioCtx; const t = ctx.currentTime;
|
|
||||||
n.osc.frequency.setTargetAtTime(45 + frac * 50, t, 0.12);
|
|
||||||
n.lfo.frequency.setTargetAtTime(12 + frac * 33, t, 0.12);
|
|
||||||
n.lp.frequency.setTargetAtTime(300 + frac * 500, t, 0.12);
|
|
||||||
n.noiseLp.frequency.setTargetAtTime(150 + frac * 350, t, 0.12);
|
|
||||||
n.noiseGain.gain.setTargetAtTime(0.25 + frac * 0.35, t, 0.12);
|
|
||||||
n.gain.gain.setTargetAtTime(0.05 + frac * 0.08, t, 0.12);
|
|
||||||
} catch (e) {}
|
|
||||||
}
|
|
||||||
_stopEngineSound() {
|
|
||||||
const n = this._engineNodes; if (!n) return;
|
|
||||||
try {
|
|
||||||
const t = this._audioCtx.currentTime;
|
|
||||||
n.gain.gain.setTargetAtTime(0, t, 0.05);
|
|
||||||
n.osc.stop(t + 0.2); n.noise.stop(t + 0.2); n.lfo.stop(t + 0.2);
|
|
||||||
} catch (e) {}
|
|
||||||
this._engineNodes = null;
|
|
||||||
}
|
|
||||||
exitVehicle() {
|
|
||||||
const veh = this._inVehicle;
|
|
||||||
this._inVehicle = null;
|
|
||||||
if (veh) {
|
|
||||||
veh.driver = null;
|
|
||||||
try {
|
|
||||||
const side = new Vector3(Math.cos(veh.yaw), 0, -Math.sin(veh.yaw));
|
|
||||||
this._pos.set(veh.pos.x + side.x * (veh.half.w + 1.0), veh.pos.y + 1.0, veh.pos.z + side.z * (veh.half.w + 1.0));
|
|
||||||
this._vy = 0;
|
|
||||||
} catch (e) {}
|
|
||||||
}
|
|
||||||
this._stopEngineSound();
|
|
||||||
this._skinVisibleScripted = true;
|
|
||||||
if (this._modelMeshes) {
|
|
||||||
for (const m of this._modelMeshes) {
|
|
||||||
if (m && m.setEnabled) { try { m.setEnabled(true); } catch (e) {} }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
cycleVehicleCamera() {
|
|
||||||
const modes = ['follow', 'hood', 'cinematic'];
|
|
||||||
const i = modes.indexOf(this._vehicleCamMode || 'follow');
|
|
||||||
this._vehicleCamMode = modes[(i + 1) % modes.length];
|
|
||||||
}
|
|
||||||
_tickVehicle(dt) {
|
|
||||||
const veh = this._inVehicle;
|
|
||||||
if (!veh || !this._scene3d?.vehicleManager) return;
|
|
||||||
if (this._modelMeshes) {
|
|
||||||
for (const m of this._modelMeshes) {
|
|
||||||
if (m && m.isEnabled && m.isEnabled() && m.setEnabled) { try { m.setEnabled(false); } catch (e) {} }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const c = this._codes;
|
|
||||||
const throttle = (c.has('KeyW') || c.has('ArrowUp') ? 1 : 0) - (c.has('KeyS') || c.has('ArrowDown') ? 1 : 0);
|
|
||||||
const steer = (c.has('KeyD') || c.has('ArrowRight') ? 1 : 0) - (c.has('KeyA') || c.has('ArrowLeft') ? 1 : 0);
|
|
||||||
const handbrake = c.has('Space');
|
|
||||||
this._scene3d.vehicleManager.setInput(veh, throttle, steer, handbrake);
|
|
||||||
const _vres = this._scene3d.vehicleManager.tickVehicle(veh, dt);
|
|
||||||
this._updateEngineSound(veh.speed, veh.params?.maxSpeed);
|
|
||||||
if (_vres && _vres.fellOut) {
|
|
||||||
this.exitVehicle();
|
|
||||||
if (this._onVehicleExit) { try { this._onVehicleExit(veh); } catch (e) {} }
|
|
||||||
const sp = this._scene3d?._spawnPoint || { x: 0, y: 5, z: 0 };
|
|
||||||
try { this._pos.set(sp.x, sp.y + 1.0, sp.z); this._vy = 0; } catch (e) {}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try { this._pos.set(veh.pos.x, veh.pos.y + 1.0, veh.pos.z); } catch (e) {}
|
|
||||||
if (!this.camera) return;
|
|
||||||
const dir = new Vector3(Math.sin(veh.yaw), 0, Math.cos(veh.yaw));
|
|
||||||
const cp = veh.pos;
|
|
||||||
const mode = this._vehicleCamMode || 'follow';
|
|
||||||
let camPos, camTarget;
|
|
||||||
if (mode === 'hood') {
|
|
||||||
camPos = new Vector3(cp.x + dir.x * (veh.half.d + 0.3), cp.y + veh.half.h + 0.6, cp.z + dir.z * (veh.half.d + 0.3));
|
|
||||||
camTarget = new Vector3(cp.x + dir.x * 8, cp.y + 0.5, cp.z + dir.z * 8);
|
|
||||||
} else if (mode === 'cinematic') {
|
|
||||||
const side = new Vector3(Math.cos(veh.yaw), 0, -Math.sin(veh.yaw));
|
|
||||||
camPos = new Vector3(cp.x + side.x * 7 + dir.x * 2, cp.y + 2.5, cp.z + side.z * 7 + dir.z * 2);
|
|
||||||
camTarget = new Vector3(cp.x, cp.y + 0.8, cp.z);
|
|
||||||
} else {
|
|
||||||
camPos = new Vector3(cp.x - dir.x * 8, cp.y + 3.2, cp.z - dir.z * 8);
|
|
||||||
camTarget = new Vector3(cp.x + dir.x * 2, cp.y + 1.0, cp.z + dir.z * 2);
|
|
||||||
}
|
|
||||||
const k = Math.min(1, dt * 6);
|
|
||||||
this.camera.position.set(
|
|
||||||
this.camera.position.x + (camPos.x - this.camera.position.x) * k,
|
|
||||||
this.camera.position.y + (camPos.y - this.camera.position.y) * k,
|
|
||||||
this.camera.position.z + (camPos.z - this.camera.position.z) * k,
|
|
||||||
);
|
|
||||||
try { this.camera.setTarget(camTarget); } catch (e) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Применить активный режим камеры скрипта (вызывается в _tick). */
|
/** Применить активный режим камеры скрипта (вызывается в _tick). */
|
||||||
_applyCameraOverride(dt) {
|
_applyCameraOverride(dt) {
|
||||||
const o = this._cameraOverride;
|
const o = this._cameraOverride;
|
||||||
@ -2193,189 +1795,13 @@ export class PlayerController {
|
|||||||
this._applyCameraMode();
|
this._applyCameraMode();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Включить/выключить Shift-Lock (Roblox-style: курсор зафиксирован, корпус
|
|
||||||
* всегда лицом к камере, камера через плечо).
|
|
||||||
*/
|
|
||||||
setShiftLock(on) {
|
|
||||||
this._shiftLock = !!on;
|
|
||||||
if (this._shiftLock) {
|
|
||||||
// Запросить pointer-lock — курсор в центре
|
|
||||||
this._requestPointerLockSafe();
|
|
||||||
} else {
|
|
||||||
// Снять lock если он есть и нет других причин держать (first/sideview)
|
|
||||||
const needPermLock = (
|
|
||||||
this._cameraMode === 'first' ||
|
|
||||||
this._cameraMode === 'lockfirst' ||
|
|
||||||
this._cameraMode === 'sideview'
|
|
||||||
);
|
|
||||||
if (!needPermLock && document.pointerLockElement === this.canvas) {
|
|
||||||
try { document.exitPointerLock(); } catch (e) {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this._applyCursorVisibility?.();
|
|
||||||
}
|
|
||||||
isShiftLock() { return !!this._shiftLock; }
|
|
||||||
|
|
||||||
/** Задача 04: блокирует игровой ввод (WASD/Space/Ctrl/Shift/etc).
|
|
||||||
* Не блокирует Esc/Tab/Enter (нужны для GUI).
|
|
||||||
* Также сбрасывает накопленные клавиши чтобы движение остановилось. */
|
|
||||||
setInputBlocked(blocked) {
|
|
||||||
this._inputBlocked = !!blocked;
|
|
||||||
if (this._inputBlocked) {
|
|
||||||
try { this._codes?.clear(); } catch (e) {}
|
|
||||||
this._shift = false;
|
|
||||||
// Снимаем pointer-lock — иначе мышь застрянет «в режиме игры»
|
|
||||||
try {
|
|
||||||
if (document.pointerLockElement === this.canvas) document.exitPointerLock();
|
|
||||||
} catch (e) {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
isInputBlocked() { return !!this._inputBlocked; }
|
|
||||||
|
|
||||||
/** Задача 04: замораживает камеру (не вращается мышью, не зумится колесом). */
|
|
||||||
setCameraFrozen(frozen) {
|
|
||||||
this._cameraFrozen = !!frozen;
|
|
||||||
}
|
|
||||||
isCameraFrozen() { return !!this._cameraFrozen; }
|
|
||||||
|
|
||||||
/** Задача 04: снимок состояния камеры — для восстановления после модала. */
|
|
||||||
captureCameraState() {
|
|
||||||
return {
|
|
||||||
yaw: this._yaw,
|
|
||||||
pitch: this._pitch,
|
|
||||||
cameraMode: this._cameraMode,
|
|
||||||
thirdDistance: this._thirdDistance,
|
|
||||||
fov: this.scene?.activeCamera?.fov,
|
|
||||||
playerPos: this._pos ? {
|
|
||||||
x: this._pos.x, y: this._pos.y, z: this._pos.z
|
|
||||||
} : null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Задача 04: восстановить состояние камеры из снимка. */
|
|
||||||
restoreCameraState(s) {
|
|
||||||
if (!s) return;
|
|
||||||
if (Number.isFinite(s.yaw)) this._yaw = s.yaw;
|
|
||||||
if (Number.isFinite(s.pitch)) this._pitch = s.pitch;
|
|
||||||
if (s.cameraMode) {
|
|
||||||
this._cameraMode = s.cameraMode;
|
|
||||||
try { this._applyCameraMode?.(); } catch (e) {}
|
|
||||||
}
|
|
||||||
if (Number.isFinite(s.thirdDistance)) this._thirdDistance = s.thirdDistance;
|
|
||||||
if (Number.isFinite(s.fov) && this.scene?.activeCamera) {
|
|
||||||
this.scene.activeCamera.fov = s.fov;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Задача 04: камера-фокус на reference (cube/npc/cam-target).
|
|
||||||
* ref может быть: строка-id, ref-объект ({kind, id}), Mesh, или {x,y,z}.
|
|
||||||
* Использует уже существующий механизм camera.focus в GameRuntime, но
|
|
||||||
* здесь упрощённая обёртка: ставим yaw/pitch так, чтобы смотреть на цель,
|
|
||||||
* и зум на distance. */
|
|
||||||
focusOnTarget(ref, opts) {
|
|
||||||
opts = opts || {};
|
|
||||||
const distance = Number.isFinite(opts.distance) ? opts.distance : 8;
|
|
||||||
const height = Number.isFinite(opts.height) ? opts.height : 3;
|
|
||||||
const fov = Number.isFinite(opts.fov) ? opts.fov : null;
|
|
||||||
let target = null;
|
|
||||||
if (ref && typeof ref === 'object' && 'x' in ref && 'y' in ref && 'z' in ref) {
|
|
||||||
target = ref;
|
|
||||||
} else {
|
|
||||||
const m = this._resolveTargetMesh(ref);
|
|
||||||
if (m) {
|
|
||||||
const p = m.getAbsolutePosition?.() || m.position;
|
|
||||||
target = { x: p.x, y: p.y, z: p.z };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!target) return;
|
|
||||||
// Прицельный взгляд: позиция камеры за игроком на distance, направление — на target
|
|
||||||
// Но мы во freezeCamera режиме — лучше через прямой override yaw/pitch.
|
|
||||||
if (!this._pos) return;
|
|
||||||
const dx = target.x - this._pos.x;
|
|
||||||
const dz = target.z - this._pos.z;
|
|
||||||
const dy = target.y - this._pos.y;
|
|
||||||
const horiz = Math.hypot(dx, dz);
|
|
||||||
this._yaw = Math.atan2(dx, dz);
|
|
||||||
this._pitch = Math.max(-1.4, Math.min(1.4, -Math.atan2(dy, horiz)));
|
|
||||||
this._thirdDistance = distance;
|
|
||||||
if (this._cameraMode !== 'third') {
|
|
||||||
this._cameraMode = 'third';
|
|
||||||
try { this._applyCameraMode?.(); } catch (e) {}
|
|
||||||
}
|
|
||||||
if (fov && this.scene?.activeCamera) {
|
|
||||||
this.scene.activeCamera.fov = fov * Math.PI / 180;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_resolveTargetMesh(ref) {
|
|
||||||
if (!ref) return null;
|
|
||||||
if (ref.getScene && typeof ref.getScene === 'function') return ref;
|
|
||||||
const sc = this._scene3d || this.scene3d;
|
|
||||||
const idStr = (typeof ref === 'string') ? ref : (ref.id || ref.refId || null);
|
|
||||||
if (!idStr || !sc) return null;
|
|
||||||
const tries = [
|
|
||||||
() => sc.primitiveManager?.getMesh?.(idStr),
|
|
||||||
() => sc.modelManager?.getInstanceMeshes?.(idStr)?.[0],
|
|
||||||
() => sc.scene?.getMeshByName?.(idStr),
|
|
||||||
() => sc.npcManager?.getMeshes?.(idStr)?.[0],
|
|
||||||
];
|
|
||||||
for (const fn of tries) {
|
|
||||||
try { const r = fn(); if (r) return r; } catch (e) {}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Прямо установить дистанцию камеры (для third). Кламп в min/max. */
|
|
||||||
setCameraZoom(distance) {
|
|
||||||
const d = Number(distance);
|
|
||||||
if (!Number.isFinite(d)) return;
|
|
||||||
this._thirdDistance = Math.max(this.THIRD_DISTANCE_MIN,
|
|
||||||
Math.min(this.THIRD_DISTANCE_MAX, d));
|
|
||||||
// Авто-переход third↔first если пересекли порог
|
|
||||||
if (this._thirdDistance <= this.FIRST_PERSON_ZOOM_THRESHOLD
|
|
||||||
&& this._cameraMode === 'third') {
|
|
||||||
this._cameraMode = 'first';
|
|
||||||
this._applyCameraMode?.();
|
|
||||||
this._requestPointerLockSafe();
|
|
||||||
} else if (this._thirdDistance > this.FIRST_PERSON_ZOOM_THRESHOLD
|
|
||||||
&& this._cameraMode === 'first' && !this._lockFirstPerson) {
|
|
||||||
this._cameraMode = 'third';
|
|
||||||
this._applyCameraMode?.();
|
|
||||||
if (!this._shiftLock && document.pointerLockElement === this.canvas) {
|
|
||||||
try { document.exitPointerLock(); } catch (e) {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/** Установить границы зума колеса. */
|
|
||||||
setCameraZoomLimits(min, max) {
|
|
||||||
const mn = Number(min), mx = Number(max);
|
|
||||||
if (Number.isFinite(mn) && mn >= 0) this.THIRD_DISTANCE_MIN = mn;
|
|
||||||
if (Number.isFinite(mx) && mx > 0) this.THIRD_DISTANCE_MAX = mx;
|
|
||||||
// Перекламп текущей дистанции
|
|
||||||
this._thirdDistance = Math.max(this.THIRD_DISTANCE_MIN,
|
|
||||||
Math.min(this.THIRD_DISTANCE_MAX, this._thirdDistance));
|
|
||||||
}
|
|
||||||
|
|
||||||
_setupInput() {
|
_setupInput() {
|
||||||
const canvas = this.canvas;
|
const canvas = this.canvas;
|
||||||
|
|
||||||
// Задача 02 (как в студии): хелпер — режим с ПОСТОЯННЫМ pointer-lock.
|
|
||||||
const needPermLock = () => (
|
|
||||||
this._cameraMode === 'first' ||
|
|
||||||
this._cameraMode === 'lockfirst' ||
|
|
||||||
this._cameraMode === 'sideview' ||
|
|
||||||
this._shiftLock
|
|
||||||
);
|
|
||||||
|
|
||||||
const onCanvasClick = () => {
|
const onCanvasClick = () => {
|
||||||
// В UI-режиме клик не перехватывает мышь.
|
// В UI-режиме клик по канвасу НЕ перехватывает мышь
|
||||||
if (this._uiCursorMode) return;
|
if (this._uiCursorMode) return;
|
||||||
if (!this._active) return;
|
if (this._active && document.pointerLockElement !== canvas) {
|
||||||
// Roblox-style: в third-person ЛКМ-клик НЕ лочит курсор (он остаётся
|
|
||||||
// свободным для GUI/3D-onClick). Lock запрашиваем ТОЛЬКО для режимов
|
|
||||||
// где курсор постоянно скрыт, и только если lock был снят.
|
|
||||||
if (!needPermLock()) return;
|
|
||||||
if (document.pointerLockElement !== canvas) {
|
|
||||||
try {
|
try {
|
||||||
const p = canvas.requestPointerLock?.();
|
const p = canvas.requestPointerLock?.();
|
||||||
if (p && typeof p.catch === 'function') p.catch(() => {});
|
if (p && typeof p.catch === 'function') p.catch(() => {});
|
||||||
@ -2384,34 +1810,6 @@ export class PlayerController {
|
|||||||
};
|
};
|
||||||
canvas.addEventListener('click', onCanvasClick);
|
canvas.addEventListener('click', onCanvasClick);
|
||||||
|
|
||||||
// === ПКМ: в third-person удержание ПКМ запускает orbit-камеру ===
|
|
||||||
// Зажал ПКМ → курсор скрыт, мышь крутит камеру. Отпустил → курсор вернулся.
|
|
||||||
const onCanvasMouseDownGlobal = (e) => {
|
|
||||||
if (!this._active || this._uiCursorMode) return;
|
|
||||||
if (e.button !== 2) return; // только ПКМ
|
|
||||||
if (needPermLock()) return; // в perma-режимах ПКМ ничего не делает
|
|
||||||
this._rmbHeld = true;
|
|
||||||
if (document.pointerLockElement !== canvas) {
|
|
||||||
try {
|
|
||||||
const p = canvas.requestPointerLock?.();
|
|
||||||
if (p && typeof p.catch === 'function') p.catch(() => {});
|
|
||||||
} catch (err) { /* ignore */ }
|
|
||||||
}
|
|
||||||
e.preventDefault();
|
|
||||||
};
|
|
||||||
const onWindowMouseUpGlobal = (e) => {
|
|
||||||
if (e.button !== 2) return;
|
|
||||||
if (!this._rmbHeld) return;
|
|
||||||
this._rmbHeld = false;
|
|
||||||
if (needPermLock()) return;
|
|
||||||
if (document.pointerLockElement === canvas) {
|
|
||||||
try { document.exitPointerLock(); } catch (err) { /* ignore */ }
|
|
||||||
}
|
|
||||||
};
|
|
||||||
canvas.addEventListener('mousedown', onCanvasMouseDownGlobal);
|
|
||||||
window.addEventListener('mouseup', onWindowMouseUpGlobal);
|
|
||||||
canvas.addEventListener('contextmenu', (e) => { if (this._active) e.preventDefault(); });
|
|
||||||
|
|
||||||
// === UI-режим: mousedown / mouseup → callback (для drag-игр) ===
|
// === UI-режим: mousedown / mouseup → callback (для drag-игр) ===
|
||||||
const onCanvasMouseDown = (e) => {
|
const onCanvasMouseDown = (e) => {
|
||||||
if (!this._uiCursorMode) return;
|
if (!this._uiCursorMode) return;
|
||||||
@ -2451,8 +1849,6 @@ export class PlayerController {
|
|||||||
if (document.pointerLockElement !== canvas) return;
|
if (document.pointerLockElement !== canvas) return;
|
||||||
// Кубикон Dash: в sideview мышь не вращает камеру.
|
// Кубикон Dash: в sideview мышь не вращает камеру.
|
||||||
if (this._cameraMode === 'sideview') return;
|
if (this._cameraMode === 'sideview') return;
|
||||||
// Задача 04: модал с freezeCamera — мышь не вращает.
|
|
||||||
if (this._cameraFrozen) return;
|
|
||||||
this._yaw += e.movementX * this.MOUSE_SENSITIVITY;
|
this._yaw += e.movementX * this.MOUSE_SENSITIVITY;
|
||||||
// _invertCamera (GameMenu Настройки → Инвертировать) — флипает Y.
|
// _invertCamera (GameMenu Настройки → Инвертировать) — флипает Y.
|
||||||
const pitchSign = this._invertCamera ? -1 : 1;
|
const pitchSign = this._invertCamera ? -1 : 1;
|
||||||
@ -2463,38 +1859,13 @@ export class PlayerController {
|
|||||||
};
|
};
|
||||||
document.addEventListener('mousemove', onMouseMove);
|
document.addEventListener('mousemove', onMouseMove);
|
||||||
|
|
||||||
// Задача 02: колесо = зум third-камеры с авто-переходом third↔first.
|
// Колесо в 3rd-person — меняет дистанцию
|
||||||
const onWheel = (e) => {
|
const onWheel = (e) => {
|
||||||
if (!this._active) return;
|
if (!this._active) return;
|
||||||
if (this._cameraFrozen) { e.preventDefault(); return; } // модал
|
if (this._cameraMode !== 'third') return;
|
||||||
if (this._cameraMode === 'sideview') { e.preventDefault(); return; } // GD
|
this._thirdDistance += Math.sign(e.deltaY) * 0.5;
|
||||||
// В first зум наружу возвращает в third (если не lockfirst).
|
|
||||||
if (this._cameraMode === 'first') {
|
|
||||||
if (e.deltaY > 0 && !this._lockFirstPerson) {
|
|
||||||
this._cameraMode = 'third';
|
|
||||||
this._thirdDistance = (this.FIRST_PERSON_ZOOM_THRESHOLD || 0.7) + 0.5;
|
|
||||||
if (!this._isPermaLockMode() && document.pointerLockElement === canvas) {
|
|
||||||
try { document.exitPointerLock(); } catch (err) { /* ignore */ }
|
|
||||||
}
|
|
||||||
this._applyCursorVisibility();
|
|
||||||
this._applyCameraMode?.();
|
|
||||||
}
|
|
||||||
e.preventDefault();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (this._cameraMode !== 'third') { e.preventDefault(); return; }
|
|
||||||
// Экспоненциальный шаг (плавнее вблизи).
|
|
||||||
this._thirdDistance += Math.sign(e.deltaY) * Math.max(0.3, this._thirdDistance * 0.15);
|
|
||||||
if (this._thirdDistance < this.THIRD_DISTANCE_MIN) this._thirdDistance = this.THIRD_DISTANCE_MIN;
|
if (this._thirdDistance < this.THIRD_DISTANCE_MIN) this._thirdDistance = this.THIRD_DISTANCE_MIN;
|
||||||
if (this._thirdDistance > this.THIRD_DISTANCE_MAX) this._thirdDistance = this.THIRD_DISTANCE_MAX;
|
if (this._thirdDistance > this.THIRD_DISTANCE_MAX) this._thirdDistance = this.THIRD_DISTANCE_MAX;
|
||||||
// Зум внутрь до порога → авто-переход в first (Roblox-style).
|
|
||||||
const THRESH = this.FIRST_PERSON_ZOOM_THRESHOLD || 0.7;
|
|
||||||
if (this._thirdDistance <= THRESH) {
|
|
||||||
this._cameraMode = 'first';
|
|
||||||
this._requestPointerLockSafe();
|
|
||||||
this._applyCursorVisibility();
|
|
||||||
this._applyCameraMode?.();
|
|
||||||
}
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
};
|
};
|
||||||
canvas.addEventListener('wheel', onWheel, { passive: false });
|
canvas.addEventListener('wheel', onWheel, { passive: false });
|
||||||
@ -2502,26 +1873,12 @@ export class PlayerController {
|
|||||||
let wasLocked = false;
|
let wasLocked = false;
|
||||||
const onPointerLockChange = () => {
|
const onPointerLockChange = () => {
|
||||||
const locked = document.pointerLockElement === canvas;
|
const locked = document.pointerLockElement === canvas;
|
||||||
this._applyCursorVisibility?.(); // задача 02: вернуть/скрыть курсор
|
|
||||||
if (locked) {
|
if (locked) {
|
||||||
wasLocked = true;
|
wasLocked = true;
|
||||||
this._rmbHeld = true; // если попали в lock — ПКМ удерживается
|
|
||||||
} else if (wasLocked && this._active) {
|
} else if (wasLocked && this._active) {
|
||||||
// pointer-lock снят. Причин три:
|
// Если мы САМИ переключились в UI-cursor mode — не выходим из Play
|
||||||
// 1) пользователь в UI-режиме (game.input.setCursorMode('ui'))
|
if (this._uiCursorMode) return;
|
||||||
// 2) ПКМ отпущена в third-person (orbit-камера завершена)
|
|
||||||
// 3) Esc → выход из Play (если был в first/lockfirst/sideview/shift)
|
|
||||||
wasLocked = false;
|
|
||||||
this._rmbHeld = false;
|
|
||||||
if (this._uiCursorMode) { this._applyCursorVisibility?.(); return; }
|
|
||||||
if (needPermLock()) {
|
|
||||||
// Был режим с постоянным lock'ом и его сняли (Esc) → выход.
|
|
||||||
if (this._onExitRequest) this._onExitRequest();
|
if (this._onExitRequest) this._onExitRequest();
|
||||||
} else {
|
|
||||||
// Third-person: просто отпустили ПКМ. Остаёмся в Play,
|
|
||||||
// курсор вернулся — это НЕ повод открывать меню.
|
|
||||||
this._applyCursorVisibility?.();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
document.addEventListener('pointerlockchange', onPointerLockChange);
|
document.addEventListener('pointerlockchange', onPointerLockChange);
|
||||||
@ -2535,33 +1892,7 @@ export class PlayerController {
|
|||||||
const onKeyDown = (e) => {
|
const onKeyDown = (e) => {
|
||||||
if (!this._active) return;
|
if (!this._active) return;
|
||||||
if (isTypingTarget(e.target)) return;
|
if (isTypingTarget(e.target)) return;
|
||||||
// Задача 04: Esc в Play — приоритет закрытие модала через _onExitRequest
|
|
||||||
// (там стоит проверка modalManager.isOpen). Без явного перехвата Esc
|
|
||||||
// в third (без pointer-lock) сразу выходил из Play.
|
|
||||||
if (e.code === 'Escape') {
|
|
||||||
if (this._onExitRequest) {
|
|
||||||
this._onExitRequest();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Задача 04: модал блокирует игровой ввод (WASD/Space/Ctrl/Shift и т.п.),
|
|
||||||
// но НЕ блокирует Esc (нужен для закрытия модала через Esc-обработчик),
|
|
||||||
// и НЕ блокирует Tab/Enter (могут понадобиться в GUI-модалах).
|
|
||||||
if (this._inputBlocked && e.code !== 'Escape' && e.code !== 'Tab' && e.code !== 'Enter') {
|
|
||||||
// Глотаем preventDefault только для игровых клавиш
|
|
||||||
if (['Space','ArrowUp','ArrowDown','ArrowLeft','ArrowRight'].includes(e.code)) e.preventDefault();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this._codes.add(e.code);
|
this._codes.add(e.code);
|
||||||
// Задача 14: в машине — V камера, E выход.
|
|
||||||
if (this._inVehicle) {
|
|
||||||
if (e.code === 'KeyV') { this.cycleVehicleCamera(); }
|
|
||||||
else if (e.code === 'KeyE') {
|
|
||||||
const veh = this._inVehicle;
|
|
||||||
this.exitVehicle();
|
|
||||||
if (this._onVehicleExit) { try { this._onVehicleExit(veh); } catch (err) {} }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (e.shiftKey) this._shift = true;
|
if (e.shiftKey) this._shift = true;
|
||||||
// C — переключение first/third. Отключаем в GD-режиме (автобег > 0)
|
// C — переключение first/third. Отключаем в GD-режиме (автобег > 0)
|
||||||
// и при любом активном GD-гейммоде, потому что ломает физику auto-run + sideview.
|
// и при любом активном GD-гейммоде, потому что ломает физику auto-run + sideview.
|
||||||
@ -2570,17 +1901,6 @@ export class PlayerController {
|
|||||||
|| this._shipMode || this._ufoMode || this._waveMode || this._robotMode;
|
|| this._shipMode || this._ufoMode || this._waveMode || this._robotMode;
|
||||||
if (!inGdMode) this._toggleCameraMode();
|
if (!inGdMode) this._toggleCameraMode();
|
||||||
}
|
}
|
||||||
// L — Shift-Lock (Roblox по умолчанию на Shift, у нас Shift = бег,
|
|
||||||
// поэтому переназначено на L). Курсор центрируется, корпус всегда
|
|
||||||
// лицом к камере, камера через плечо.
|
|
||||||
if (e.code === 'KeyL') {
|
|
||||||
this.setShiftLock(!this._shiftLock);
|
|
||||||
}
|
|
||||||
// B — встроенный магазин скинов (задача 07). Открывается только если
|
|
||||||
// включён в проекте (scene.skins.shopVisible). Toggle.
|
|
||||||
if (e.code === 'KeyB' && !this._inputBlocked) {
|
|
||||||
try { this._scene3d?.toggleSkinShop?.(); } catch (err) {}
|
|
||||||
}
|
|
||||||
// Tab — переключить «UI-режим курсора» (для кликов по GUI в Play)
|
// Tab — переключить «UI-режим курсора» (для кликов по GUI в Play)
|
||||||
if (e.code === 'Tab') {
|
if (e.code === 'Tab') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@ -2633,12 +1953,6 @@ export class PlayerController {
|
|||||||
if (dt <= 0) return;
|
if (dt <= 0) return;
|
||||||
if (dt > 0.1) dt = 0.1;
|
if (dt > 0.1) dt = 0.1;
|
||||||
|
|
||||||
// === Задача 14: режим вождения — инпут идёт в машину, не в ходьбу ===
|
|
||||||
if (this._inVehicle) {
|
|
||||||
try { this._tickVehicle(dt); } catch (e) { /* ignore */ }
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Присед: по Ctrl на десктопе, или через мобильную кнопку
|
// === Присед: по Ctrl на десктопе, или через мобильную кнопку
|
||||||
// (которая шлёт keydown 'ControlLeft'). C — НЕ используется
|
// (которая шлёт keydown 'ControlLeft'). C — НЕ используется
|
||||||
// (это смена вида в Babylon).
|
// (это смена вида в Babylon).
|
||||||
@ -3182,17 +2496,6 @@ export class PlayerController {
|
|||||||
this._tickDebris(dt);
|
this._tickDebris(dt);
|
||||||
|
|
||||||
// === Анимации ===
|
// === Анимации ===
|
||||||
// Снимок скорости/опоры для процедурной анимации non-humanoid скинов.
|
|
||||||
this._lastFrameSpeed = (isMoving ? (isSprinting ? this.WALK_SPEED * this.SPRINT_MULT : this.WALK_SPEED) : 0) * (this._speedMul || 1);
|
|
||||||
this._isGrounded = !!result.onGround;
|
|
||||||
|
|
||||||
// Non-humanoid single-mesh скин: костей нет — анимируем процедурно
|
|
||||||
// (покачивание/наклон). Делаем ДО R15-ветки, т.к. _isR15=false для них.
|
|
||||||
if (this._modelKind === 'non-humanoid-mesh' && this._modelRoot) {
|
|
||||||
this._animateNonHumanoidMesh(dt);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// R15-скин: процедурный аниматор (нет glTF AnimationGroups).
|
// R15-скин: процедурный аниматор (нет glTF AnimationGroups).
|
||||||
// Состояния: idle/walk/run/jump/fall. sprint → run.
|
// Состояния: idle/walk/run/jump/fall. sprint → run.
|
||||||
if (this._isR15 && this._r15Animator) {
|
if (this._isR15 && this._r15Animator) {
|
||||||
|
|||||||
@ -19,7 +19,7 @@
|
|||||||
* При касании игроком обновляет spawnPoint сцены.
|
* При касании игроком обновляет spawnPoint сцены.
|
||||||
*/
|
*/
|
||||||
import {
|
import {
|
||||||
MeshBuilder, StandardMaterial, Color3, Vector3, Vector4, PointLight,
|
MeshBuilder, StandardMaterial, Color3, Vector3, PointLight,
|
||||||
Mesh, VertexData,
|
Mesh, VertexData,
|
||||||
} from '@babylonjs/core';
|
} from '@babylonjs/core';
|
||||||
// CRA→Vite адаптация: оригинальный код в setTexture()/_applyMaterial()/
|
// CRA→Vite адаптация: оригинальный код в setTexture()/_applyMaterial()/
|
||||||
@ -33,57 +33,6 @@ import { DynamicTexture } from '@babylonjs/core/Materials/Textures/dynamicTextur
|
|||||||
import { Texture } from '@babylonjs/core/Materials/Textures/texture';
|
import { Texture } from '@babylonjs/core/Materials/Textures/texture';
|
||||||
import { getPrimitiveType } from './PrimitiveTypes';
|
import { getPrimitiveType } from './PrimitiveTypes';
|
||||||
|
|
||||||
// === Материал «studs» (лего-кружки, задача 09) — паритет со студией ===
|
|
||||||
const STUDS_DIFFUSE_URL = '/kubikon-assets/materials/studs_v4_diffuse.png';
|
|
||||||
const STUDS_NORMAL_URL = '/kubikon-assets/materials/studs_v4_normal.png';
|
|
||||||
const STUD_UNIT = 1;
|
|
||||||
const STUDS_GRID = 4;
|
|
||||||
const _studsTexCache = new WeakMap();
|
|
||||||
function _getStudsTextures(scene) {
|
|
||||||
let c = _studsTexCache.get(scene);
|
|
||||||
if (!c) {
|
|
||||||
c = { diffuse: new Texture(STUDS_DIFFUSE_URL, scene), normal: new Texture(STUDS_NORMAL_URL, scene) };
|
|
||||||
_studsTexCache.set(scene, c);
|
|
||||||
}
|
|
||||||
return c;
|
|
||||||
}
|
|
||||||
function _studsTiling(type, sx, sy, sz, density) {
|
|
||||||
// density — множитель плотности кружков (1=стандарт, 2=вдвое мельче/чаще).
|
|
||||||
const d = density && density > 0 ? density : 1;
|
|
||||||
const f = (STUD_UNIT * STUDS_GRID) / d;
|
|
||||||
let u = Math.max(sx, sz) / f;
|
|
||||||
let v = sy / f;
|
|
||||||
if (type === 'cylinder') { u = (Math.PI * sx) / f; v = sy / f; }
|
|
||||||
else if (type === 'sphere') { u = (Math.PI * sx) / f; v = (Math.PI * sy) / f; }
|
|
||||||
else if (type === 'plane') { u = sx / f; v = sz / f; }
|
|
||||||
return { u: Math.max(0.25, u), v: Math.max(0.25, v) };
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* faceUV для куба со studs — КАЖДАЯ грань тайлится по СВОИМ реальным размерам,
|
|
||||||
* чтобы кружки были одного размера на всех гранях (не растягивались на длинных).
|
|
||||||
* Грани CreateBox: 0=front(z-) 1=back(z+) 2=right(x+) 3=left(x-) 4=top(y+) 5=bottom(y-).
|
|
||||||
* front/back → ширина=sx, высота=sy
|
|
||||||
* left/right → ширина=sz, высота=sy
|
|
||||||
* top/bottom → ширина=sx, высота=sz
|
|
||||||
* UV-диапазон грани = (0,0)..(кол-во_studs_по_ширине, кол-во_по_высоте).
|
|
||||||
*/
|
|
||||||
function _studsCubeFaceUV(sx, sy, sz, density) {
|
|
||||||
const d = density && density > 0 ? density : 1;
|
|
||||||
const f = (STUD_UNIT * STUDS_GRID) / d;
|
|
||||||
const nx = Math.max(0.25, sx / f); // studs вдоль X
|
|
||||||
const ny = Math.max(0.25, sy / f); // studs вдоль Y
|
|
||||||
const nz = Math.max(0.25, sz / f); // studs вдоль Z
|
|
||||||
// Vector4(u0, v0, u1, v1)
|
|
||||||
return [
|
|
||||||
new Vector4(0, 0, nx, ny), // front (z-): X×Y
|
|
||||||
new Vector4(0, 0, nx, ny), // back (z+): X×Y
|
|
||||||
new Vector4(0, 0, nz, ny), // right (x+): Z×Y
|
|
||||||
new Vector4(0, 0, nz, ny), // left (x-): Z×Y
|
|
||||||
new Vector4(0, 0, nx, nz), // top (y+): X×Z
|
|
||||||
new Vector4(0, 0, nx, nz), // bottom (y-): X×Z
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
export class PrimitiveManager {
|
export class PrimitiveManager {
|
||||||
constructor(scene) {
|
constructor(scene) {
|
||||||
this.scene = scene;
|
this.scene = scene;
|
||||||
@ -124,8 +73,6 @@ export class PrimitiveManager {
|
|||||||
const isGlowingGd = isGdKind;
|
const isGlowingGd = isGdKind;
|
||||||
const isGdSpike = typeDef.kind === 'gd_spike';
|
const isGdSpike = typeDef.kind === 'gd_spike';
|
||||||
const material = opts.material ?? (isGlowingGd ? 'neon' : 'matte');
|
const material = opts.material ?? (isGlowingGd ? 'neon' : 'matte');
|
||||||
// studDensity — плотность кружков studs (1=стандарт, >1 мельче/чаще).
|
|
||||||
const studDensity = Number.isFinite(opts.studDensity) ? opts.studDensity : 1;
|
|
||||||
// canCollide: для всех GD-сущностей и шипов — false (игрок проходит сквозь, логика по дистанции)
|
// canCollide: для всех GD-сущностей и шипов — false (игрок проходит сквозь, логика по дистанции)
|
||||||
const canCollide = opts.canCollide !== false && !isGdKind && !isGdSpike;
|
const canCollide = opts.canCollide !== false && !isGdKind && !isGdSpike;
|
||||||
const visible = opts.visible !== false;
|
const visible = opts.visible !== false;
|
||||||
@ -143,7 +90,7 @@ export class PrimitiveManager {
|
|||||||
const rotationY = opts.rotationY ?? 0;
|
const rotationY = opts.rotationY ?? 0;
|
||||||
const rotationZ = opts.rotationZ ?? 0;
|
const rotationZ = opts.rotationZ ?? 0;
|
||||||
|
|
||||||
const mesh = this._createMeshForType(typeDef, id, sx, sy, sz, material, studDensity);
|
const mesh = this._createMeshForType(typeDef, id, sx, sy, sz);
|
||||||
mesh.position = new Vector3(x, y, z);
|
mesh.position = new Vector3(x, y, z);
|
||||||
mesh.rotation = new Vector3(rotationX, rotationY, rotationZ);
|
mesh.rotation = new Vector3(rotationX, rotationY, rotationZ);
|
||||||
mesh.isPickable = true;
|
mesh.isPickable = true;
|
||||||
@ -156,7 +103,6 @@ export class PrimitiveManager {
|
|||||||
primitiveId: id,
|
primitiveId: id,
|
||||||
primitiveType: type,
|
primitiveType: type,
|
||||||
primitiveKind: typeDef.kind,
|
primitiveKind: typeDef.kind,
|
||||||
canCollide, // нужен camera-clamp: камера не цепляется за зоны canCollide:false
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// textureAsset — id картинки из AssetManager (пользовательская
|
// textureAsset — id картинки из AssetManager (пользовательская
|
||||||
@ -168,15 +114,13 @@ export class PrimitiveManager {
|
|||||||
id, mesh, type, x, y, z, sx, sy, sz,
|
id, mesh, type, x, y, z, sx, sy, sz,
|
||||||
rotationX, rotationY, rotationZ,
|
rotationX, rotationY, rotationZ,
|
||||||
color, material, canCollide, visible, anchored, mass,
|
color, material, canCollide, visible, anchored, mass,
|
||||||
textureAsset, studDensity,
|
textureAsset,
|
||||||
// locked — объект защищён от выделения/перемещения в редакторе
|
// locked — объект защищён от выделения/перемещения в редакторе
|
||||||
// (Фаза 5.11). На геймплей не влияет.
|
// (Фаза 5.11). На геймплей не влияет.
|
||||||
locked: opts.locked === true,
|
locked: opts.locked === true,
|
||||||
name: opts.name || null,
|
name: opts.name || null,
|
||||||
folderId: opts.folderId ?? null,
|
folderId: opts.folderId ?? null,
|
||||||
};
|
};
|
||||||
// Размеры для тайлинга studs-материала (читается в _applyMaterial).
|
|
||||||
mesh._studsDims = { type, sx, sy, sz, density: studDensity };
|
|
||||||
this._applyMaterial(mesh, typeDef, color, material);
|
this._applyMaterial(mesh, typeDef, color, material);
|
||||||
this._applyVisible(mesh, visible, typeDef);
|
this._applyVisible(mesh, visible, typeDef);
|
||||||
// Пользовательская текстура — поверх базового материала.
|
// Пользовательская текстура — поверх базового материала.
|
||||||
@ -212,21 +156,6 @@ export class PrimitiveManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// === 3D-табличка (billboard): натягиваем DynamicTexture с GUI ===
|
|
||||||
if (typeDef.kind === 'billboard' && this.billboardUiManager) {
|
|
||||||
// Сохраняем настройки билборда в data.billboardOpts чтобы
|
|
||||||
// serialize мог записать их обратно в JSON проекта.
|
|
||||||
const billboardOpts = {
|
|
||||||
template: opts.template || 'shop-item',
|
|
||||||
face: opts.face || 'fixed',
|
|
||||||
content: opts.content || null,
|
|
||||||
elements: opts.elements || null,
|
|
||||||
rotationY: opts.rotationY,
|
|
||||||
};
|
|
||||||
this.billboardUiManager.applyToMesh(data, billboardOpts);
|
|
||||||
// billboardOpts хранится в data.billboard после applyToMesh.
|
|
||||||
}
|
|
||||||
|
|
||||||
this.instances.set(id, data);
|
this.instances.set(id, data);
|
||||||
// Авто-регистрация в shadow casters (Этап 4 теней).
|
// Авто-регистрация в shadow casters (Этап 4 теней).
|
||||||
// Когда скрипт спавнит новый объект через scene.spawn(...) — раньше он
|
// Когда скрипт спавнит новый объект через scene.spawn(...) — раньше он
|
||||||
@ -241,17 +170,13 @@ export class PrimitiveManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Создать базовый mesh нужной формы (без материала). */
|
/** Создать базовый mesh нужной формы (без материала). */
|
||||||
_createMeshForType(typeDef, id, sx, sy, sz, material, studDensity) {
|
_createMeshForType(typeDef, id, sx, sy, sz) {
|
||||||
const name = `prim_${typeDef.id}_${id}`;
|
const name = `prim_${typeDef.id}_${id}`;
|
||||||
switch (typeDef.id) {
|
switch (typeDef.id) {
|
||||||
case 'cube':
|
case 'cube':
|
||||||
case 'trigger': {
|
case 'trigger':
|
||||||
const boxOpts = { width: sx, height: sy, depth: sz };
|
return MeshBuilder.CreateBox(name,
|
||||||
// studs — per-face UV, чтобы кружки были одного размера на всех
|
{ width: sx, height: sy, depth: sz }, this.scene);
|
||||||
// гранях (не растягивались на длинной стороне).
|
|
||||||
if (material === 'studs') boxOpts.faceUV = _studsCubeFaceUV(sx, sy, sz, studDensity);
|
|
||||||
return MeshBuilder.CreateBox(name, boxOpts, this.scene);
|
|
||||||
}
|
|
||||||
case 'sphere':
|
case 'sphere':
|
||||||
return MeshBuilder.CreateSphere(name,
|
return MeshBuilder.CreateSphere(name,
|
||||||
{ diameterX: sx, diameterY: sy, diameterZ: sz, segments: 24 }, this.scene);
|
{ diameterX: sx, diameterY: sy, diameterZ: sz, segments: 24 }, this.scene);
|
||||||
@ -285,16 +210,6 @@ export class PrimitiveManager {
|
|||||||
// создаются отдельно в addInstance.
|
// создаются отдельно в addInstance.
|
||||||
return MeshBuilder.CreateSphere(name,
|
return MeshBuilder.CreateSphere(name,
|
||||||
{ diameterX: sx, diameterY: sy, diameterZ: sz, segments: 12 }, this.scene);
|
{ diameterX: sx, diameterY: sy, diameterZ: sz, segments: 12 }, this.scene);
|
||||||
case 'billboard': {
|
|
||||||
// 3D-табличка — плоскость с пропорциями таблички (sx × sy),
|
|
||||||
// sz — толщина рамки (визуально-незаметная).
|
|
||||||
// ВАЖНО: FRONTSIDE, не DOUBLESIDE — иначе сквозь back-side
|
|
||||||
// видно зеркальную сторону UV (текст справа-налево).
|
|
||||||
// BillboardMode разворачивает FRONT к камере.
|
|
||||||
const m = MeshBuilder.CreatePlane(name,
|
|
||||||
{ width: sx, height: sy, sideOrientation: Mesh.FRONTSIDE }, this.scene);
|
|
||||||
return m;
|
|
||||||
}
|
|
||||||
case 'plane':
|
case 'plane':
|
||||||
return MeshBuilder.CreateBox(name,
|
return MeshBuilder.CreateBox(name,
|
||||||
{ width: sx, height: sy, depth: sz }, this.scene);
|
{ width: sx, height: sy, depth: sz }, this.scene);
|
||||||
@ -485,65 +400,12 @@ export class PrimitiveManager {
|
|||||||
break;
|
break;
|
||||||
case 'glass':
|
case 'glass':
|
||||||
mat.alpha = 0.4;
|
mat.alpha = 0.4;
|
||||||
mat.specularColor = new Color3(0.8, 0.85, 0.9);
|
mat.specularColor = new Color3(0.5, 0.5, 0.5);
|
||||||
mat.specularPower = 96;
|
|
||||||
mat.backFaceCulling = false;
|
|
||||||
break;
|
break;
|
||||||
case 'neon':
|
case 'neon':
|
||||||
mat.emissiveColor = Color3.FromHexString(color || '#888888');
|
mat.emissiveColor = Color3.FromHexString(color || '#888888');
|
||||||
mat.specularColor = new Color3(0, 0, 0);
|
mat.specularColor = new Color3(0, 0, 0);
|
||||||
break;
|
break;
|
||||||
case 'chrome': {
|
|
||||||
const cc = Color3.FromHexString(color || '#cfd6e0');
|
|
||||||
mat.diffuseColor = new Color3(cc.r * 0.6, cc.g * 0.6, cc.b * 0.6);
|
|
||||||
mat.specularColor = new Color3(1, 1, 1);
|
|
||||||
mat.specularPower = 128;
|
|
||||||
mat.emissiveColor = new Color3(cc.r * 0.12, cc.g * 0.12, cc.b * 0.14);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'water': {
|
|
||||||
const wc = Color3.FromHexString(color || '#3aa0ff');
|
|
||||||
mat.diffuseColor = wc;
|
|
||||||
mat.alpha = 0.55;
|
|
||||||
mat.specularColor = new Color3(0.9, 0.95, 1.0);
|
|
||||||
mat.specularPower = 64;
|
|
||||||
mat.emissiveColor = new Color3(wc.r * 0.1, wc.g * 0.14, wc.b * 0.2);
|
|
||||||
mesh._isWater = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'iridescent': {
|
|
||||||
const ic = Color3.FromHexString(color || '#a06bff');
|
|
||||||
mat.diffuseColor = ic;
|
|
||||||
mat.emissiveColor = new Color3(ic.r * 0.5, ic.g * 0.35, ic.b * 0.6);
|
|
||||||
mat.specularColor = new Color3(1, 1, 1);
|
|
||||||
mat.specularPower = 96;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'studs': {
|
|
||||||
// Лего-материал (паритет со студией): почти-белая diffuse × цвет
|
|
||||||
// меша + normal map. emissive = доля цвета → сочность (Roblox-look).
|
|
||||||
const tex = _getStudsTextures(this.scene);
|
|
||||||
const dt = tex.diffuse.clone();
|
|
||||||
const nt = tex.normal.clone();
|
|
||||||
const dims = mesh._studsDims || { type: 'cube', sx: 1, sy: 1, sz: 1 };
|
|
||||||
// Куб/триггер тайлятся через faceUV геометрии (uScale=1) — кружки
|
|
||||||
// одного размера на всех гранях. Остальные формы — через uScale.
|
|
||||||
if (dims.type === 'cube' || dims.type === 'trigger') {
|
|
||||||
dt.uScale = nt.uScale = 1;
|
|
||||||
dt.vScale = nt.vScale = 1;
|
|
||||||
} else {
|
|
||||||
const tile = _studsTiling(dims.type, dims.sx, dims.sy, dims.sz, dims.density);
|
|
||||||
dt.uScale = nt.uScale = tile.u;
|
|
||||||
dt.vScale = nt.vScale = tile.v;
|
|
||||||
}
|
|
||||||
mat.diffuseTexture = dt;
|
|
||||||
mat.bumpTexture = nt;
|
|
||||||
const sc = Color3.FromHexString(color || '#cccccc');
|
|
||||||
mat.diffuseColor = sc;
|
|
||||||
mat.emissiveColor = new Color3(sc.r * 0.45, sc.g * 0.45, sc.b * 0.45);
|
|
||||||
mat.specularColor = new Color3(0, 0, 0);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'matte':
|
case 'matte':
|
||||||
default:
|
default:
|
||||||
mat.specularColor = new Color3(0, 0, 0);
|
mat.specularColor = new Color3(0, 0, 0);
|
||||||
@ -689,12 +551,6 @@ export class PrimitiveManager {
|
|||||||
if (patch.sx !== undefined) { data.sx = patch.sx; scaleChanged = true; }
|
if (patch.sx !== undefined) { data.sx = patch.sx; scaleChanged = true; }
|
||||||
if (patch.sy !== undefined) { data.sy = patch.sy; scaleChanged = true; }
|
if (patch.sy !== undefined) { data.sy = patch.sy; scaleChanged = true; }
|
||||||
if (patch.sz !== undefined) { data.sz = patch.sz; scaleChanged = true; }
|
if (patch.sz !== undefined) { data.sz = patch.sz; scaleChanged = true; }
|
||||||
// Плотность studs (мелкие/крупные кружки) — требует пересоздания меша
|
|
||||||
// (faceUV для куба зашит в геометрию).
|
|
||||||
if (patch.studDensity !== undefined) {
|
|
||||||
data.studDensity = Number.isFinite(patch.studDensity) ? patch.studDensity : 1;
|
|
||||||
scaleChanged = true;
|
|
||||||
}
|
|
||||||
if (scaleChanged) {
|
if (scaleChanged) {
|
||||||
// Поскольку MeshBuilder уже создал mesh с базовыми размерами,
|
// Поскольку MeshBuilder уже создал mesh с базовыми размерами,
|
||||||
// изменения через scaling кажутся правильными. Простой способ —
|
// изменения через scaling кажутся правильными. Простой способ —
|
||||||
@ -720,7 +576,6 @@ export class PrimitiveManager {
|
|||||||
if (data.mesh.material) {
|
if (data.mesh.material) {
|
||||||
try { data.mesh.material.dispose(); } catch (e) { /* ignore */ }
|
try { data.mesh.material.dispose(); } catch (e) { /* ignore */ }
|
||||||
}
|
}
|
||||||
data.mesh._studsDims = { type: data.type, sx: data.sx, sy: data.sy, sz: data.sz };
|
|
||||||
this._applyMaterial(data.mesh, typeDef, data.color, data.material);
|
this._applyMaterial(data.mesh, typeDef, data.color, data.material);
|
||||||
}
|
}
|
||||||
// Текстуру переприменяем если: сменили саму текстуру, или
|
// Текстуру переприменяем если: сменили саму текстуру, или
|
||||||
@ -734,14 +589,10 @@ export class PrimitiveManager {
|
|||||||
if (data.mesh.material) {
|
if (data.mesh.material) {
|
||||||
try { data.mesh.material.dispose(); } catch (e) { /* ignore */ }
|
try { data.mesh.material.dispose(); } catch (e) { /* ignore */ }
|
||||||
}
|
}
|
||||||
data.mesh._studsDims = { type: data.type, sx: data.sx, sy: data.sy, sz: data.sz };
|
|
||||||
this._applyMaterial(data.mesh, typeDef, data.color, data.material);
|
this._applyMaterial(data.mesh, typeDef, data.color, data.material);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (patch.canCollide !== undefined) {
|
if (patch.canCollide !== undefined) data.canCollide = patch.canCollide;
|
||||||
data.canCollide = patch.canCollide;
|
|
||||||
if (data.mesh?.metadata) data.mesh.metadata.canCollide = patch.canCollide;
|
|
||||||
}
|
|
||||||
if (patch.locked !== undefined) data.locked = !!patch.locked;
|
if (patch.locked !== undefined) data.locked = !!patch.locked;
|
||||||
if (patch.visible !== undefined) {
|
if (patch.visible !== undefined) {
|
||||||
data.visible = patch.visible;
|
data.visible = patch.visible;
|
||||||
@ -756,11 +607,6 @@ export class PrimitiveManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Billboard: пересоздать GUI-текстуру при изменении template/content/face/elements
|
|
||||||
if (patch.billboardOpts && this.billboardUiManager && data.type === 'billboard') {
|
|
||||||
this.billboardUiManager.applyToMesh(data, patch.billboardOpts);
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Лампа: синхронизируем привязанный PointLight ===
|
// === Лампа: синхронизируем привязанный PointLight ===
|
||||||
if (data.light) {
|
if (data.light) {
|
||||||
// позиция света — за маркером
|
// позиция света — за маркером
|
||||||
@ -828,17 +674,10 @@ export class PrimitiveManager {
|
|||||||
const oldMat = oldMesh.material;
|
const oldMat = oldMesh.material;
|
||||||
|
|
||||||
const typeDef = getPrimitiveType(data.type);
|
const typeDef = getPrimitiveType(data.type);
|
||||||
const newMesh = this._createMeshForType(typeDef, data.id, data.sx, data.sy, data.sz, data.material, data.studDensity);
|
const newMesh = this._createMeshForType(typeDef, data.id, data.sx, data.sy, data.sz);
|
||||||
newMesh.position = oldPos;
|
newMesh.position = oldPos;
|
||||||
if (oldRot) newMesh.rotation = oldRot;
|
if (oldRot) newMesh.rotation = oldRot;
|
||||||
// studs — материал пересоздаём заново (свежий faceUV/тайлинг). Иначе перенос.
|
|
||||||
if (data.material === 'studs') {
|
|
||||||
newMesh._studsDims = { type: data.type, sx: data.sx, sy: data.sy, sz: data.sz, density: data.studDensity };
|
|
||||||
this._applyMaterial(newMesh, typeDef, data.color, data.material);
|
|
||||||
try { oldMat?.dispose(); } catch (e) { /* ignore */ }
|
|
||||||
} else {
|
|
||||||
newMesh.material = oldMat;
|
newMesh.material = oldMat;
|
||||||
}
|
|
||||||
newMesh.isPickable = true;
|
newMesh.isPickable = true;
|
||||||
newMesh.metadata = { ...oldMesh.metadata };
|
newMesh.metadata = { ...oldMesh.metadata };
|
||||||
newMesh.setEnabled(data.visible);
|
newMesh.setEnabled(data.visible);
|
||||||
@ -848,7 +687,6 @@ export class PrimitiveManager {
|
|||||||
catch (e) { /* ignore */ }
|
catch (e) { /* ignore */ }
|
||||||
|
|
||||||
data.mesh = newMesh;
|
data.mesh = newMesh;
|
||||||
// _studsDims и материал studs уже выставлены выше.
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Удалить инстанс. */
|
/** Удалить инстанс. */
|
||||||
@ -893,24 +731,14 @@ export class PrimitiveManager {
|
|||||||
anchored: d.anchored,
|
anchored: d.anchored,
|
||||||
mass: d.mass,
|
mass: d.mass,
|
||||||
name: d.name || null,
|
name: d.name || null,
|
||||||
...(d.folderId != null ? { folderId: d.folderId } : {}), // папка (парность со студией)
|
|
||||||
// locked — защита от выделения/перемещения (Фаза 5.11).
|
// locked — защита от выделения/перемещения (Фаза 5.11).
|
||||||
...(d.locked ? { locked: true } : {}),
|
...(d.locked ? { locked: true } : {}),
|
||||||
// id пользовательской текстуры (картинка из AssetManager).
|
// id пользовательской текстуры (картинка из AssetManager).
|
||||||
...(d.textureAsset ? { textureAsset: d.textureAsset } : {}),
|
...(d.textureAsset ? { textureAsset: d.textureAsset } : {}),
|
||||||
// Плотность studs (если не 1) — мелкие/крупные кружки.
|
|
||||||
...(d.studDensity && d.studDensity !== 1 ? { studDensity: d.studDensity } : {}),
|
|
||||||
// Параметры лампы (только для type='light', иначе undefined)
|
// Параметры лампы (только для type='light', иначе undefined)
|
||||||
...(d.light ? { brightness: d.brightness, range: d.range } : {}),
|
...(d.light ? { brightness: d.brightness, range: d.range } : {}),
|
||||||
// Параметр эмиттера (только для type='emitter')
|
// Параметр эмиттера (только для type='emitter')
|
||||||
...(d.effect !== undefined ? { effect: d.effect } : {}),
|
...(d.effect !== undefined ? { effect: d.effect } : {}),
|
||||||
// Параметры билборда (только для type='billboard')
|
|
||||||
...(d.billboard ? {
|
|
||||||
template: d.billboard.template,
|
|
||||||
face: d.billboard.face,
|
|
||||||
content: d.billboard.content,
|
|
||||||
...(d.billboard.elements ? { elements: d.billboard.elements } : {}),
|
|
||||||
} : {}),
|
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -57,15 +57,6 @@ export const PRIMITIVE_TYPES = [
|
|||||||
{ id: 'emitter', name: 'Эмиттер частиц', icon: 'prim-emitter', kind: 'emitter',
|
{ id: 'emitter', name: 'Эмиттер частиц', icon: 'prim-emitter', kind: 'emitter',
|
||||||
defaultScale: { x: 0.4, y: 0.4, z: 0.4 }, defaultColor: '#ff8833' },
|
defaultScale: { x: 0.4, y: 0.4, z: 0.4 }, defaultColor: '#ff8833' },
|
||||||
|
|
||||||
// === Табличка — 3D-карточка с GUI (как BillboardGui в Roblox) ===
|
|
||||||
// Плоскость с натянутой DynamicTexture, на которой рендерится контент
|
|
||||||
// (заголовок, иконка, кнопка). Поддерживает 4 пресета (см. BillboardUiManager):
|
|
||||||
// shop-item, shop-purchase, banner, sign. Может смотреть на камеру
|
|
||||||
// (face=camera) или быть фиксированной (face=fixed). Клик по кнопке
|
|
||||||
// эмитит событие через game.billboard.onClick.
|
|
||||||
{ id: 'billboard', name: '3D-табличка', icon: 'prim-billboard', kind: 'billboard',
|
|
||||||
defaultScale: { x: 2, y: 1.2, z: 0.05 }, defaultColor: '#1a1a2e' },
|
|
||||||
|
|
||||||
// === GD-порталы (Geometry Dash 2.0) — переключают гейммод игрока ===
|
// === GD-порталы (Geometry Dash 2.0) — переключают гейммод игрока ===
|
||||||
// Все цилиндры-«столбы» с neon-материалом. Цвета и gdMode задают режим.
|
// Все цилиндры-«столбы» с neon-материалом. Цвета и gdMode задают режим.
|
||||||
{ id: 'gd_cube', name: 'Портал: Куб', icon: 'prim-portal', kind: 'gd_portal', gdMode: 'cube',
|
{ id: 'gd_cube', name: 'Портал: Куб', icon: 'prim-portal', kind: 'gd_portal', gdMode: 'cube',
|
||||||
@ -96,7 +87,7 @@ export const PRIMITIVE_TYPES = [
|
|||||||
/** Категории для группировки в палитре. */
|
/** Категории для группировки в палитре. */
|
||||||
export const PRIMITIVE_CATEGORIES = [
|
export const PRIMITIVE_CATEGORIES = [
|
||||||
{ id: 'geometry', name: 'Фигуры', ids: ['cube','sphere','cylinder','cone','plane','torus','wedge','cornerwedge'] },
|
{ id: 'geometry', name: 'Фигуры', ids: ['cube','sphere','cylinder','cone','plane','torus','wedge','cornerwedge'] },
|
||||||
{ id: 'gameplay', name: 'Геймплей', ids: ['trigger', 'checkpoint', 'light', 'emitter', 'billboard'] },
|
{ id: 'gameplay', name: 'Геймплей', ids: ['trigger', 'checkpoint', 'light', 'emitter'] },
|
||||||
{ id: 'gd-portals', name: 'GD-порталы', ids: ['gd_cube','gd_ship','gd_ball','gd_ufo','gd_wave','gd_robot'] },
|
{ id: 'gd-portals', name: 'GD-порталы', ids: ['gd_cube','gd_ship','gd_ball','gd_ufo','gd_wave','gd_robot'] },
|
||||||
{ id: 'gd-objects', name: 'GD-объекты', ids: ['gd_spike','gd_finish','gd_coin'] },
|
{ id: 'gd-objects', name: 'GD-объекты', ids: ['gd_spike','gd_finish','gd_coin'] },
|
||||||
];
|
];
|
||||||
|
|||||||
@ -131,18 +131,6 @@ const ANIMS_STD = {
|
|||||||
{ bone: B.SPINE, axis: AXIS_RIGHT, angleDeg: 10,
|
{ bone: B.SPINE, axis: AXIS_RIGHT, angleDeg: 10,
|
||||||
times: [0.0, 0.4, 0.8], values: [0.0, 1.0, 0.0] },
|
times: [0.0, 0.4, 0.8], values: [0.0, 1.0, 0.0] },
|
||||||
]),
|
]),
|
||||||
attack: makeAnim(0.5, true, [
|
|
||||||
{ bone: B.RIGHT_ARM, axis: AXIS_RIGHT, angleDeg: -95,
|
|
||||||
times: [0.0, 0.15, 0.3, 0.5], values: [0.3, 1.0, 0.2, 0.3] },
|
|
||||||
{ bone: B.RIGHT_FOREARM, axis: AXIS_RIGHT, angleDeg: -50,
|
|
||||||
times: [0.0, 0.15, 0.3, 0.5], values: [1.0, 0.2, 1.0, 1.0] },
|
|
||||||
{ bone: B.LEFT_ARM, axis: AXIS_RIGHT, angleDeg: -45,
|
|
||||||
times: [0.0, 0.5], values: [1.0, 1.0] },
|
|
||||||
{ bone: B.LEFT_FOREARM, axis: AXIS_RIGHT, angleDeg: -70,
|
|
||||||
times: [0.0, 0.5], values: [1.0, 1.0] },
|
|
||||||
{ bone: B.SPINE, axis: AXIS_RIGHT, angleDeg: -12,
|
|
||||||
times: [0.0, 0.15, 0.3, 0.5], values: [0.0, 1.0, 0.3, 0.0] },
|
|
||||||
]),
|
|
||||||
|
|
||||||
// === ЭМОЦИИ (game.player.playAnimation) ===
|
// === ЭМОЦИИ (game.player.playAnimation) ===
|
||||||
// Разовые анимации поверх авто-состояния. loop=false — играют один раз,
|
// Разовые анимации поверх авто-состояния. loop=false — играют один раз,
|
||||||
|
|||||||
@ -1,109 +0,0 @@
|
|||||||
# Движок плеера Рублокса
|
|
||||||
|
|
||||||
Это движок, который **запускает** игры созданные в [студии](https://studio.rublox.pro). В отличие от студии, плеер **только проигрывает** — не редактирует.
|
|
||||||
|
|
||||||
## Чем плеер отличается от студии
|
|
||||||
|
|
||||||
Обе используют **общий код движка** (BlockManager, PhysicsWorld, ScriptSandbox и т.д.) — это исторически наследие, движок был выделен в студию когда плеер уже существовал. Сейчас репозитории отдельные, но движок один и тот же на 90%.
|
|
||||||
|
|
||||||
**Что есть в плеере и нет в студии:**
|
|
||||||
- `PlayerAuth` через ticket-flow (см. `src/auth/`)
|
|
||||||
- Polling статуса игры (опубликована / в премодерации / заблокирована)
|
|
||||||
- Heartbeat для метрик `/kubikon3d/play/heartbeat`
|
|
||||||
- Чат внутри игры
|
|
||||||
- Лидерборд по очкам
|
|
||||||
- Кнопка «Сообщить о баге» (`/kubikon3d/bug-reports`)
|
|
||||||
- Кнопка «Пожаловаться» (`/kubikon3d/reports`)
|
|
||||||
|
|
||||||
**Что есть в студии и нет в плеере:**
|
|
||||||
- Редактирование сцены (gizmo, кисти, инспектор)
|
|
||||||
- Сохранение `PUT /projects/<id>`
|
|
||||||
- Загрузка моделей юзера
|
|
||||||
- Скрипт-редактор Monaco
|
|
||||||
- CAD-редактор моделей
|
|
||||||
- Геймдиз-инструменты (Geometry Dash sub-app)
|
|
||||||
|
|
||||||
## Файлы
|
|
||||||
|
|
||||||
| Файл | Что |
|
|
||||||
|---|---|
|
|
||||||
| `BabylonScene.js` | Главный класс — Engine + Scene + Camera. Тот же что в студии но без gizmo и SelectionManager. |
|
|
||||||
| `BlockManager.js` | Блоки 1×1×1 на InstancedMesh. **Read-only режим** — не позволяет ставить новые блоки в runtime (только скрипты через `game.scene.spawn`). |
|
|
||||||
| `PrimitiveManager.js` | Сферы/кубы/цилиндры. Так же read-only. |
|
|
||||||
| `ModelManager.js` | GLB-модели (мечи, машины, NPC-скины). |
|
|
||||||
| `PhysicsWorld.js` | AABB-физика. **Всегда включена** в плеере (в студии — только в Play-режиме). |
|
|
||||||
| `PlayerController.js` | Управление игроком, WASD + Space + мышь. |
|
|
||||||
| `ScriptSandbox.js` + `ScriptSandboxWorker.js` | Песочница скриптов юзера. |
|
|
||||||
| `GameRuntime.js` | Оркестратор игрового режима (см. студию). |
|
|
||||||
| `MultiplayerSync.js` | Colyseus state-sync для онлайн-игр. |
|
|
||||||
| `AccessoryManager.js` | Шляпы, очки, аксессуары на R15-скелете. |
|
|
||||||
| `EmoteGlbParser.js` | Парсер GLB-анимаций (танцы, эмоушены). |
|
|
||||||
|
|
||||||
## Поток загрузки игры
|
|
||||||
|
|
||||||
```
|
|
||||||
1. Юзер открывает https://player.rublox.pro/<game_id>
|
|
||||||
↓
|
|
||||||
2. PlayerAuth проверяет JWT (или redeem ticket из #ticket=)
|
|
||||||
↓
|
|
||||||
3. fetch GET /kubikon3d/projects/<game_id>
|
|
||||||
→ возвращает project_data JSON ~100КБ-5МБ
|
|
||||||
↓
|
|
||||||
4. BabylonScene создаёт сцену
|
|
||||||
BlockManager.loadFromProject(data.blocks)
|
|
||||||
PrimitiveManager.loadFromProject(data.primitives)
|
|
||||||
ModelManager.loadFromProject(data.models)
|
|
||||||
...
|
|
||||||
↓
|
|
||||||
5. ScriptSandbox.startAll() — запускает все скрипты юзера
|
|
||||||
↓
|
|
||||||
6. GameRuntime.start() — включает физику, ввод
|
|
||||||
↓
|
|
||||||
7. Игрок играет
|
|
||||||
↓
|
|
||||||
8. Каждые 30с: POST /kubikon3d/play/heartbeat { game_id, play_time_ms }
|
|
||||||
```
|
|
||||||
|
|
||||||
## Что НЕ трогать (опасные оптимизации)
|
|
||||||
|
|
||||||
Те же грабли что в студии:
|
|
||||||
|
|
||||||
- `scene.blockMaterialDirtyMechanism = true` — ломает новые меши (трейсеры, debris)
|
|
||||||
- `scene.createOrUpdateSelectionOctree()` в hot path — O(N²) лагает
|
|
||||||
- `game.ui.set()` в `onTick` без throttle — React setState 60Hz убивает FPS
|
|
||||||
- `findOne` на старте скрипта — sceneSnapshot приходит через rAF, ref будет null
|
|
||||||
|
|
||||||
Подробнее: [docs/TUTORIAL_DEBUG_BABYLON.md](../../docs/TUTORIAL_DEBUG_BABYLON.md)
|
|
||||||
|
|
||||||
## AdminPreview/
|
|
||||||
|
|
||||||
Папка `src/AdminPreview/` — каталоги ассетов (gdSkins, gdPortals, gdSfx, gdMusic и т.д.). Используются движком при загрузке игр GD-формата (`GdLevelManager`). Контрибьюторам обычно трогать не нужно.
|
|
||||||
|
|
||||||
## Производительность — ориентиры
|
|
||||||
|
|
||||||
Те же что в студии:
|
|
||||||
|
|
||||||
| Объект | Норм | Лагать начинает |
|
|
||||||
|---|---|---|
|
|
||||||
| Блоки | 50К | 200К+ |
|
|
||||||
| Примитивы | 500 | 2000+ |
|
|
||||||
| GLB-модели | 200 | 500+ (зависит от вершин) |
|
|
||||||
| NPC | 50 | 100+ |
|
|
||||||
| Активные скрипты | 30 | 100+ |
|
|
||||||
| Частицы | 5К | 20К+ |
|
|
||||||
|
|
||||||
**FPS-цель плеера выше чем у студии** — игроки чувствительнее к лагам чем создатели:
|
|
||||||
- 60 FPS на средних ноутбуках 2020+
|
|
||||||
- 30 FPS на школьных машинках 2015+
|
|
||||||
- 60 FPS на мобиле (отдельная задача — мобильная оптимизация)
|
|
||||||
|
|
||||||
## Связанные доки
|
|
||||||
|
|
||||||
- [../../docs/TUTORIAL_FIRST_PR.md](../../docs/TUTORIAL_FIRST_PR.md) — первый PR
|
|
||||||
- [../../docs/TUTORIAL_DEBUG_BABYLON.md](../../docs/TUTORIAL_DEBUG_BABYLON.md) — отладка
|
|
||||||
- [../../API_USAGE.md](../../API_USAGE.md) — какие эндпоинты плеер дёргает
|
|
||||||
- В студии: [studio/src/editor/engine/README.md](https://git.rublox.pro/rublox/studio/src/branch/main/src/editor/engine/README.md) — полное описание движка
|
|
||||||
|
|
||||||
## Вопросы
|
|
||||||
|
|
||||||
Канал `#разработка` на https://team.rublox.pro
|
|
||||||
@ -1,177 +0,0 @@
|
|||||||
/**
|
|
||||||
* RbxlHudOverlay — DOM-оверлей с HUD-элементами для импортированных
|
|
||||||
* Roblox-карт: KillFeed (правый верх), Message/Hint (центр), WinGui.
|
|
||||||
*
|
|
||||||
* Один контейнер `.rbxl-hud-overlay` на canvas.parentElement, в нём дочерние
|
|
||||||
* блоки по типу. Стили inline, ничего не зависит от CSS приложения.
|
|
||||||
*
|
|
||||||
* API:
|
|
||||||
* const hud = new RbxlHudOverlay(canvasParent);
|
|
||||||
* hud.addKillFeed(killer, victim, weapon)
|
|
||||||
* hud.showMessage(text, opts)
|
|
||||||
* hud.hideMessage()
|
|
||||||
* hud.showWin(text)
|
|
||||||
* hud.dispose()
|
|
||||||
*/
|
|
||||||
|
|
||||||
export class RbxlHudOverlay {
|
|
||||||
constructor(parent) {
|
|
||||||
this._parent = parent || document.body;
|
|
||||||
this._root = null;
|
|
||||||
this._killFeed = null;
|
|
||||||
this._message = null;
|
|
||||||
this._winBox = null;
|
|
||||||
this._killEntries = []; // [{el, expireAt}]
|
|
||||||
this._mount();
|
|
||||||
}
|
|
||||||
|
|
||||||
_mount() {
|
|
||||||
if (this._root) return;
|
|
||||||
const root = document.createElement('div');
|
|
||||||
root.className = 'rbxl-hud-overlay';
|
|
||||||
Object.assign(root.style, {
|
|
||||||
position: 'absolute',
|
|
||||||
inset: '0',
|
|
||||||
pointerEvents: 'none',
|
|
||||||
zIndex: '999',
|
|
||||||
fontFamily: 'system-ui, -apple-system, "Segoe UI", sans-serif',
|
|
||||||
});
|
|
||||||
this._parent.appendChild(root);
|
|
||||||
this._root = root;
|
|
||||||
|
|
||||||
// KillFeed — правый верхний угол
|
|
||||||
const kf = document.createElement('div');
|
|
||||||
Object.assign(kf.style, {
|
|
||||||
position: 'absolute',
|
|
||||||
top: '60px',
|
|
||||||
right: '12px',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
gap: '6px',
|
|
||||||
maxWidth: '320px',
|
|
||||||
pointerEvents: 'none',
|
|
||||||
});
|
|
||||||
root.appendChild(kf);
|
|
||||||
this._killFeed = kf;
|
|
||||||
|
|
||||||
// Message — центр сверху (Roblox Message по центру экрана,
|
|
||||||
// но в верхней трети чтобы не мешать игре)
|
|
||||||
const msg = document.createElement('div');
|
|
||||||
Object.assign(msg.style, {
|
|
||||||
position: 'absolute',
|
|
||||||
top: '15%',
|
|
||||||
left: '50%',
|
|
||||||
transform: 'translateX(-50%)',
|
|
||||||
padding: '10px 24px',
|
|
||||||
background: 'rgba(0,0,0,0.6)',
|
|
||||||
color: '#fff',
|
|
||||||
fontSize: '22px',
|
|
||||||
fontWeight: '600',
|
|
||||||
borderRadius: '6px',
|
|
||||||
textShadow: '0 2px 4px rgba(0,0,0,0.8)',
|
|
||||||
display: 'none',
|
|
||||||
pointerEvents: 'none',
|
|
||||||
});
|
|
||||||
root.appendChild(msg);
|
|
||||||
this._message = msg;
|
|
||||||
|
|
||||||
// WinGui — большая надпись по центру
|
|
||||||
const win = document.createElement('div');
|
|
||||||
Object.assign(win.style, {
|
|
||||||
position: 'absolute',
|
|
||||||
top: '50%',
|
|
||||||
left: '50%',
|
|
||||||
transform: 'translate(-50%, -50%)',
|
|
||||||
padding: '24px 48px',
|
|
||||||
background: 'rgba(0,0,0,0.75)',
|
|
||||||
color: '#ffd86b',
|
|
||||||
fontSize: '48px',
|
|
||||||
fontWeight: '800',
|
|
||||||
borderRadius: '12px',
|
|
||||||
textShadow: '0 4px 8px rgba(0,0,0,0.8)',
|
|
||||||
display: 'none',
|
|
||||||
pointerEvents: 'none',
|
|
||||||
});
|
|
||||||
root.appendChild(win);
|
|
||||||
this._winBox = win;
|
|
||||||
|
|
||||||
// Тик для авто-исчезновения KillFeed entries (через 5с)
|
|
||||||
this._tickInterval = setInterval(() => this._cleanupKills(), 500);
|
|
||||||
}
|
|
||||||
|
|
||||||
addKillFeed(killer, victim, weapon) {
|
|
||||||
if (!this._killFeed) return;
|
|
||||||
const entry = document.createElement('div');
|
|
||||||
Object.assign(entry.style, {
|
|
||||||
background: 'rgba(0,0,0,0.55)',
|
|
||||||
color: '#fff',
|
|
||||||
padding: '6px 10px',
|
|
||||||
borderRadius: '4px',
|
|
||||||
fontSize: '13px',
|
|
||||||
display: 'flex',
|
|
||||||
gap: '6px',
|
|
||||||
alignItems: 'center',
|
|
||||||
animation: 'rbxlHudFadeIn 0.3s',
|
|
||||||
});
|
|
||||||
const killerEl = document.createElement('span');
|
|
||||||
killerEl.textContent = String(killer || '?');
|
|
||||||
killerEl.style.color = '#5bd1e8';
|
|
||||||
const arrow = document.createElement('span');
|
|
||||||
arrow.textContent = weapon ? `→ [${weapon}] →` : '→';
|
|
||||||
arrow.style.color = '#ff9a52';
|
|
||||||
const victimEl = document.createElement('span');
|
|
||||||
victimEl.textContent = String(victim || '?');
|
|
||||||
victimEl.style.color = '#f87a7a';
|
|
||||||
entry.appendChild(killerEl);
|
|
||||||
entry.appendChild(arrow);
|
|
||||||
entry.appendChild(victimEl);
|
|
||||||
this._killFeed.appendChild(entry);
|
|
||||||
this._killEntries.push({ el: entry, expireAt: performance.now() + 5000 });
|
|
||||||
// Keep only last 8
|
|
||||||
while (this._killEntries.length > 8) {
|
|
||||||
const old = this._killEntries.shift();
|
|
||||||
try { old.el.remove(); } catch (_) {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_cleanupKills() {
|
|
||||||
const now = performance.now();
|
|
||||||
const keep = [];
|
|
||||||
for (const e of this._killEntries) {
|
|
||||||
if (e.expireAt < now) { try { e.el.remove(); } catch (_) {} }
|
|
||||||
else keep.push(e);
|
|
||||||
}
|
|
||||||
this._killEntries = keep;
|
|
||||||
}
|
|
||||||
|
|
||||||
showMessage(text, opts = {}) {
|
|
||||||
if (!this._message) return;
|
|
||||||
this._message.textContent = String(text || '');
|
|
||||||
this._message.style.display = text ? 'block' : 'none';
|
|
||||||
if (opts.duration) {
|
|
||||||
clearTimeout(this._msgTimer);
|
|
||||||
this._msgTimer = setTimeout(() => this.hideMessage(), opts.duration);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
hideMessage() {
|
|
||||||
if (this._message) this._message.style.display = 'none';
|
|
||||||
}
|
|
||||||
|
|
||||||
showWin(text) {
|
|
||||||
if (!this._winBox) return;
|
|
||||||
this._winBox.textContent = String(text || '');
|
|
||||||
this._winBox.style.display = 'block';
|
|
||||||
// Auto-hide через 6с
|
|
||||||
clearTimeout(this._winTimer);
|
|
||||||
this._winTimer = setTimeout(() => { if (this._winBox) this._winBox.style.display = 'none'; }, 6000);
|
|
||||||
}
|
|
||||||
|
|
||||||
dispose() {
|
|
||||||
try { this._root?.remove(); } catch (_) {}
|
|
||||||
clearInterval(this._tickInterval);
|
|
||||||
clearTimeout(this._msgTimer);
|
|
||||||
clearTimeout(this._winTimer);
|
|
||||||
this._root = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -60,7 +60,6 @@ export class ScriptSandbox {
|
|||||||
target: this.target,
|
target: this.target,
|
||||||
selfPosition: this._initialSelfPosition || null,
|
selfPosition: this._initialSelfPosition || null,
|
||||||
modules: this._modules || {},
|
modules: this._modules || {},
|
||||||
initialScene: this._initialScene || null,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -70,11 +69,6 @@ export class ScriptSandbox {
|
|||||||
this._initialSelfPosition = p;
|
this._initialSelfPosition = p;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Первичный snapshot сцены (до start) — чтобы findOne работал на старте. */
|
|
||||||
setInitialScene(snap) {
|
|
||||||
this._initialScene = snap;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Установить код модулей для game.require — { 'имя': 'код' }. Вызывать до start(). */
|
/** Установить код модулей для game.require — { 'имя': 'код' }. Вызывать до start(). */
|
||||||
setModules(modules) {
|
setModules(modules) {
|
||||||
this._modules = modules || {};
|
this._modules = modules || {};
|
||||||
@ -95,10 +89,6 @@ export class ScriptSandbox {
|
|||||||
try { this.worker.postMessage({ cmd: 'guiSnapshot', payload: this._pendingGuiSnapshot }); } catch (e) {}
|
try { this.worker.postMessage({ cmd: 'guiSnapshot', payload: this._pendingGuiSnapshot }); } catch (e) {}
|
||||||
this._pendingGuiSnapshot = null;
|
this._pendingGuiSnapshot = null;
|
||||||
}
|
}
|
||||||
if (this._pendingSkinsSnapshot) {
|
|
||||||
try { this.worker.postMessage({ cmd: 'skinsSnapshot', payload: this._pendingSkinsSnapshot }); } catch (e) {}
|
|
||||||
this._pendingSkinsSnapshot = null;
|
|
||||||
}
|
|
||||||
if (this._pendingTerrainHM) {
|
if (this._pendingTerrainHM) {
|
||||||
try { this.worker.postMessage({ cmd: 'terrainHeightmap', payload: this._pendingTerrainHM }); } catch (e) {}
|
try { this.worker.postMessage({ cmd: 'terrainHeightmap', payload: this._pendingTerrainHM }); } catch (e) {}
|
||||||
this._pendingTerrainHM = null;
|
this._pendingTerrainHM = null;
|
||||||
@ -175,16 +165,6 @@ export class ScriptSandbox {
|
|||||||
try { this.worker.postMessage({ cmd: 'guiSnapshot', payload: snapshot }); } catch (e) {}
|
try { this.worker.postMessage({ cmd: 'guiSnapshot', payload: snapshot }); } catch (e) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Задача 07: снапшот скинов — для game.player.getAvailableSkins/getAllSkins. */
|
|
||||||
sendSkinsSnapshot(snapshot) {
|
|
||||||
if (!this.worker) return;
|
|
||||||
if (!this._isReady) {
|
|
||||||
this._pendingSkinsSnapshot = snapshot;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try { this.worker.postMessage({ cmd: 'skinsSnapshot', payload: snapshot }); } catch (e) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Snapshot атрибутов объектов — для синхронного game.scene.getData. */
|
/** Snapshot атрибутов объектов — для синхронного game.scene.getData. */
|
||||||
sendDataSnapshot(snapshot) {
|
sendDataSnapshot(snapshot) {
|
||||||
if (!this.worker) return;
|
if (!this.worker) return;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -1,132 +0,0 @@
|
|||||||
/**
|
|
||||||
* ShopInventoryUi — готовый GUI-кит «слот-инвентарь магазина» (задача 11).
|
|
||||||
*
|
|
||||||
* Нижняя (или боковая) панель кнопок-слотов с иконкой/названием/ценой и hover.
|
|
||||||
* Клик по слоту → колбэк onSlotClick(item) — обычно автор вызывает внутри
|
|
||||||
* game.placement.start(...). Слот серый и некликабельный, если валюты мало
|
|
||||||
* (показывается, когда заданы showCurrency + текущий баланс через setBalance).
|
|
||||||
*
|
|
||||||
* Реализация — лёгкий DOM-оверлей поверх canvas (а не Babylon-GUI): кнопки с
|
|
||||||
* иконками/hover/disabled делаются на HTML быстрее и доступнее. Крепится к
|
|
||||||
* родителю canvas, абсолютным позиционированием.
|
|
||||||
*
|
|
||||||
* Фича-парность: идентичный модуль в rublox-player/src/engine/.
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Встроенные inline-SVG иконки слотов (без эмодзи — самописные, см. правила UI).
|
|
||||||
const SLOT_ICONS = {
|
|
||||||
crate: '<svg viewBox="0 0 24 24" width="34" height="34" fill="none" stroke="#7a4a1e" stroke-width="1.6"><rect x="3" y="6" width="18" height="13" rx="1" fill="#c2884a"/><path d="M3 10h18M9 6v13M15 6v13" stroke="#7a4a1e"/></svg>',
|
|
||||||
plant: '<svg viewBox="0 0 24 24" width="34" height="34" fill="none"><path d="M12 21V11" stroke="#4a7a2e" stroke-width="2"/><path d="M12 12c-3-1-5-4-5-7 3 0 6 2 5 7zM12 11c3-1 5-3 5-6-3 0-6 1-5 6z" fill="#5aa83a"/></svg>',
|
|
||||||
oven: '<svg viewBox="0 0 24 24" width="34" height="34" fill="none" stroke="#444" stroke-width="1.4"><rect x="4" y="3" width="16" height="18" rx="1.5" fill="#9aa0a6"/><rect x="7" y="9" width="10" height="9" rx="1" fill="#3a3f44"/><circle cx="9" cy="6" r="1" fill="#444"/><circle cx="13" cy="6" r="1" fill="#444"/></svg>',
|
|
||||||
coin: '<svg viewBox="0 0 24 24" width="34" height="34"><circle cx="12" cy="12" r="9" fill="#f5c542" stroke="#b8860b" stroke-width="1.4"/><text x="12" y="16" font-size="10" text-anchor="middle" fill="#7a5a00" font-weight="700">$</text></svg>',
|
|
||||||
box: '<svg viewBox="0 0 24 24" width="34" height="34" fill="none" stroke="#666" stroke-width="1.6"><path d="M12 2l9 5v10l-9 5-9-5V7z" fill="#b0b6bc"/><path d="M12 2v20M3 7l9 5 9-5" /></svg>',
|
|
||||||
};
|
|
||||||
|
|
||||||
function iconSvg(name) {
|
|
||||||
return SLOT_ICONS[name] || SLOT_ICONS.box;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ShopInventoryUi {
|
|
||||||
constructor(scene3d) {
|
|
||||||
this.s = scene3d;
|
|
||||||
this.root = null;
|
|
||||||
this.items = [];
|
|
||||||
this.balance = {}; // currency → amount
|
|
||||||
this.currency = '';
|
|
||||||
this.showCost = true;
|
|
||||||
this._onSlotClick = null;
|
|
||||||
this._slotEls = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
create(opts, onSlotClick) {
|
|
||||||
this.remove();
|
|
||||||
this.items = Array.isArray(opts.items) ? opts.items : [];
|
|
||||||
this.currency = opts.showCurrency || '';
|
|
||||||
this.showCost = opts.showCost !== false;
|
|
||||||
this._onSlotClick = typeof onSlotClick === 'function' ? onSlotClick : null;
|
|
||||||
|
|
||||||
const parent = (this.s.canvas && this.s.canvas.parentElement) || document.body;
|
|
||||||
// Контейнер должен быть position:relative чтобы absolute-панель легла поверх.
|
|
||||||
try { if (getComputedStyle(parent).position === 'static') parent.style.position = 'relative'; } catch { /* ignore */ }
|
|
||||||
|
|
||||||
const pos = opts.position || 'bottom';
|
|
||||||
const slotSize = Number(opts.slotSize) || 80;
|
|
||||||
const spacing = Number(opts.spacing) || 4;
|
|
||||||
|
|
||||||
const root = document.createElement('div');
|
|
||||||
root.className = 'kbn-shop-inv';
|
|
||||||
const sideStyle = {
|
|
||||||
bottom: `left:50%;bottom:18px;transform:translateX(-50%);flex-direction:row;`,
|
|
||||||
top: `left:50%;top:18px;transform:translateX(-50%);flex-direction:row;`,
|
|
||||||
left: `left:18px;top:50%;transform:translateY(-50%);flex-direction:column;`,
|
|
||||||
right: `right:18px;top:50%;transform:translateY(-50%);flex-direction:column;`,
|
|
||||||
}[pos] || '';
|
|
||||||
root.style.cssText =
|
|
||||||
`position:absolute;display:flex;gap:${spacing}px;z-index:40;` +
|
|
||||||
`padding:8px;border-radius:14px;` +
|
|
||||||
`background:rgba(20,26,38,0.82);backdrop-filter:blur(4px);` +
|
|
||||||
`box-shadow:0 6px 24px rgba(0,0,0,0.4);` + sideStyle;
|
|
||||||
|
|
||||||
this.items.forEach((it, idx) => {
|
|
||||||
const slot = document.createElement('button');
|
|
||||||
slot.type = 'button';
|
|
||||||
slot.dataset.key = it.key;
|
|
||||||
slot.style.cssText =
|
|
||||||
`width:${slotSize}px;height:${slotSize}px;border:none;border-radius:11px;` +
|
|
||||||
`display:flex;flex-direction:column;align-items:center;justify-content:center;gap:2px;` +
|
|
||||||
`cursor:pointer;color:#fff;font:600 12px/1.1 system-ui,sans-serif;` +
|
|
||||||
`background:linear-gradient(180deg,#3a4a66,#26324a);` +
|
|
||||||
`transition:transform .1s,box-shadow .1s,filter .1s;position:relative;`;
|
|
||||||
slot.innerHTML =
|
|
||||||
`<span style="pointer-events:none">${iconSvg(it.icon)}</span>` +
|
|
||||||
`<span style="pointer-events:none;max-width:${slotSize - 8}px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${it.name || ''}</span>` +
|
|
||||||
(this.showCost && it.cost
|
|
||||||
? `<span class="kbn-cost" style="pointer-events:none;color:#ffd23a;font-size:11px">${it.cost}${this.currency ? ' ' + this._curShort() : ''}</span>`
|
|
||||||
: '');
|
|
||||||
slot.onmouseenter = () => { if (!slot.disabled) { slot.style.transform = 'translateY(-3px)'; slot.style.boxShadow = '0 6px 14px rgba(0,0,0,0.45)'; } };
|
|
||||||
slot.onmouseleave = () => { slot.style.transform = ''; slot.style.boxShadow = ''; };
|
|
||||||
slot.onclick = () => {
|
|
||||||
if (slot.disabled) return;
|
|
||||||
if (this._onSlotClick) this._onSlotClick(it);
|
|
||||||
};
|
|
||||||
this._slotEls[idx] = slot;
|
|
||||||
root.appendChild(slot);
|
|
||||||
});
|
|
||||||
|
|
||||||
parent.appendChild(root);
|
|
||||||
this.root = root;
|
|
||||||
this._refreshAffordability();
|
|
||||||
}
|
|
||||||
|
|
||||||
_curShort() {
|
|
||||||
const map = { rubles: 'руб', coins: 'мон', diamonds: 'алм' };
|
|
||||||
return map[this.currency] || this.currency;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Обновить баланс валюты — слоты дороже баланса станут серыми. */
|
|
||||||
setBalance(currency, amount) {
|
|
||||||
if (currency) this.balance[currency] = Number(amount) || 0;
|
|
||||||
this._refreshAffordability();
|
|
||||||
}
|
|
||||||
|
|
||||||
_refreshAffordability() {
|
|
||||||
if (!this.currency) return; // без валюты все слоты активны
|
|
||||||
const bal = this.balance[this.currency] != null ? this.balance[this.currency] : Infinity;
|
|
||||||
this.items.forEach((it, idx) => {
|
|
||||||
const slot = this._slotEls[idx];
|
|
||||||
if (!slot) return;
|
|
||||||
const afford = (Number(it.cost) || 0) <= bal;
|
|
||||||
slot.disabled = !afford;
|
|
||||||
slot.style.filter = afford ? '' : 'grayscale(1) brightness(0.6)';
|
|
||||||
slot.style.cursor = afford ? 'pointer' : 'not-allowed';
|
|
||||||
slot.style.opacity = afford ? '1' : '0.7';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
remove() {
|
|
||||||
if (this.root) { try { this.root.remove(); } catch { /* ignore */ } this.root = null; }
|
|
||||||
this._slotEls = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
dispose() { this.remove(); this._onSlotClick = null; }
|
|
||||||
}
|
|
||||||
@ -1,570 +0,0 @@
|
|||||||
/**
|
|
||||||
* SkyboxManager — кастомное небо для сцены (задача 16).
|
|
||||||
*
|
|
||||||
* Реализует процедурный gradient-skybox без внешних текстур (работает offline):
|
|
||||||
* - купол-сфера (BACKSIDE) с ShaderMaterial: плавный градиент верх→низ,
|
|
||||||
* солнечный диск, лёгкая дымка у горизонта;
|
|
||||||
* - low-poly горы на горизонте (как в Roblox-эталоне);
|
|
||||||
* - billboard-облака (плоскости, медленный дрейф);
|
|
||||||
* - атмосферный туман (scene.fog).
|
|
||||||
*
|
|
||||||
* Пресеты: clear-summer-day / cloudy / sunset / starry-night / space /
|
|
||||||
* lowpoly-roblox (+ gradient вручную). Плавный fadeTo между ними.
|
|
||||||
*
|
|
||||||
* API (через game.scene.*):
|
|
||||||
* setSkybox({ preset } | { mode:'gradient', topColor, bottomColor, ... })
|
|
||||||
* setClouds({ enabled, cover, density, speed, color })
|
|
||||||
* setFog({ color, density, near, far } | enabled:false)
|
|
||||||
* skybox.fadeTo(opts, durationSec)
|
|
||||||
* skybox.setSunDirection({x,y,z})
|
|
||||||
*
|
|
||||||
* Фича-парность: при портировании в плеер — тот же модуль в rublox-player/src/engine/.
|
|
||||||
*/
|
|
||||||
import {
|
|
||||||
Color3, Color4, Vector3,
|
|
||||||
MeshBuilder, ShaderMaterial, Effect, StandardMaterial, Texture,
|
|
||||||
DynamicTexture, VertexData, Mesh,
|
|
||||||
} from '@babylonjs/core';
|
|
||||||
|
|
||||||
// ── Шейдер градиентного неба ──────────────────────────────────────────────
|
|
||||||
// Купол изнутри: цвет интерполируется по высоте (y от -1 низ до +1 верх),
|
|
||||||
// плюс солнечный диск и осветление у горизонта (дымка).
|
|
||||||
const SKY_VERT = `
|
|
||||||
precision highp float;
|
|
||||||
attribute vec3 position;
|
|
||||||
uniform mat4 worldViewProjection;
|
|
||||||
varying vec3 vDir;
|
|
||||||
void main(void){
|
|
||||||
vDir = normalize(position);
|
|
||||||
gl_Position = worldViewProjection * vec4(position, 1.0);
|
|
||||||
}`;
|
|
||||||
|
|
||||||
const SKY_FRAG = `
|
|
||||||
precision highp float;
|
|
||||||
varying vec3 vDir;
|
|
||||||
uniform vec3 topColor;
|
|
||||||
uniform vec3 bottomColor;
|
|
||||||
uniform vec3 horizonColor;
|
|
||||||
uniform vec3 sunDir;
|
|
||||||
uniform vec3 sunColor;
|
|
||||||
uniform float sunSize; // 0..1 угловой радиус
|
|
||||||
uniform float horizonHaze; // 0..1 сила дымки у горизонта
|
|
||||||
void main(void){
|
|
||||||
float h = clamp(vDir.y * 0.5 + 0.5, 0.0, 1.0); // 0 низ .. 1 верх
|
|
||||||
// Градиент: низ→горизонт→верх (двойная интерполяция через горизонт у h~0.5)
|
|
||||||
vec3 col;
|
|
||||||
if (h < 0.5) {
|
|
||||||
col = mix(bottomColor, horizonColor, h * 2.0);
|
|
||||||
} else {
|
|
||||||
col = mix(horizonColor, topColor, (h - 0.5) * 2.0);
|
|
||||||
}
|
|
||||||
// Дымка у горизонта — осветление узкой полосы около h=0.5
|
|
||||||
float haze = smoothstep(0.5, 0.0, abs(h - 0.5)) * horizonHaze;
|
|
||||||
col = mix(col, horizonColor + vec3(0.08), haze * 0.5);
|
|
||||||
// Солнечный диск + гало
|
|
||||||
float d = distance(normalize(vDir), normalize(sunDir));
|
|
||||||
float disk = smoothstep(sunSize, sunSize * 0.4, d);
|
|
||||||
float glow = smoothstep(sunSize * 6.0, 0.0, d) * 0.35;
|
|
||||||
col += sunColor * disk;
|
|
||||||
col += sunColor * glow;
|
|
||||||
gl_FragColor = vec4(col, 1.0);
|
|
||||||
}`;
|
|
||||||
|
|
||||||
let _shaderRegistered = false;
|
|
||||||
function registerSkyShader() {
|
|
||||||
if (_shaderRegistered) return;
|
|
||||||
Effect.ShadersStore['kubikonSkyVertexShader'] = SKY_VERT;
|
|
||||||
Effect.ShadersStore['kubikonSkyFragmentShader'] = SKY_FRAG;
|
|
||||||
_shaderRegistered = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const hexToRgb = (hex) => {
|
|
||||||
if (Array.isArray(hex)) return hex;
|
|
||||||
let h = String(hex || '#ffffff').replace('#', '').trim();
|
|
||||||
if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];
|
|
||||||
if (h.length < 6) h = (h + 'ffffff').slice(0, 6);
|
|
||||||
const r = parseInt(h.substring(0, 2), 16);
|
|
||||||
const g = parseInt(h.substring(2, 4), 16);
|
|
||||||
const b = parseInt(h.substring(4, 6), 16);
|
|
||||||
return [
|
|
||||||
(Number.isFinite(r) ? r : 255) / 255,
|
|
||||||
(Number.isFinite(g) ? g : 255) / 255,
|
|
||||||
(Number.isFinite(b) ? b : 255) / 255,
|
|
||||||
];
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Пресеты неба ──────────────────────────────────────────────────────────
|
|
||||||
// top/bottom/horizon — цвета купола; sun — направление/цвет/размер солнца;
|
|
||||||
// mountains — рисовать ли горы; clouds — параметры облаков; fog — туман;
|
|
||||||
// stars — звёздное небо (для ночи/космоса).
|
|
||||||
const PRESETS = {
|
|
||||||
'clear-summer-day': {
|
|
||||||
top: '#3d7fe0', horizon: '#bcd9f2', bottom: '#dcebf7',
|
|
||||||
sunDir: [0.3, 0.85, 0.4], sunColor: '#fff6d8', sunSize: 0.035, haze: 0.6,
|
|
||||||
mountains: false,
|
|
||||||
clouds: { enabled: true, cover: 0.25, color: '#ffffff', speed: 0.015 },
|
|
||||||
fog: { color: '#cfe2f2', density: 0.0035 },
|
|
||||||
light: { sunIntensity: 1.0, sunColor: '#fff6e0', hemiIntensity: 0.7, ambient: '#b9d4ee' },
|
|
||||||
},
|
|
||||||
'lowpoly-roblox': {
|
|
||||||
top: '#4a93e6', horizon: '#cfe6f5', bottom: '#e6f1fa',
|
|
||||||
sunDir: [0.4, 0.8, 0.45], sunColor: '#fffbe6', sunSize: 0.03, haze: 0.85,
|
|
||||||
mountains: true,
|
|
||||||
clouds: { enabled: true, cover: 0.45, color: '#ffffff', speed: 0.012 },
|
|
||||||
fog: { color: '#e2eef7', density: 0.005 },
|
|
||||||
light: { sunIntensity: 0.95, sunColor: '#fff7e0', hemiIntensity: 0.75, ambient: '#cfe6f5' },
|
|
||||||
},
|
|
||||||
'cloudy': {
|
|
||||||
top: '#8fa6bd', horizon: '#c2ccd6', bottom: '#d8dde2',
|
|
||||||
sunDir: [0.2, 0.7, 0.3], sunColor: '#e8e8e8', sunSize: 0.0, haze: 0.4,
|
|
||||||
mountains: false,
|
|
||||||
clouds: { enabled: true, cover: 0.8, color: '#e8ebef', speed: 0.02 },
|
|
||||||
fog: { color: '#cfd6dd', density: 0.008 },
|
|
||||||
light: { sunIntensity: 0.5, sunColor: '#dfe4ea', hemiIntensity: 0.8, ambient: '#c2ccd6' },
|
|
||||||
},
|
|
||||||
'sunset': {
|
|
||||||
top: '#2a3a6b', horizon: '#f5915a', bottom: '#f7c98a',
|
|
||||||
sunDir: [0.7, 0.12, -0.2], sunColor: '#ffd28a', sunSize: 0.05, haze: 1.0,
|
|
||||||
mountains: true,
|
|
||||||
clouds: { enabled: true, cover: 0.35, color: '#ffd9b3', speed: 0.01 },
|
|
||||||
fog: { color: '#f0b483', density: 0.006 },
|
|
||||||
light: { sunIntensity: 0.7, sunColor: '#ff9a52', hemiIntensity: 0.5, ambient: '#9a6a78' },
|
|
||||||
},
|
|
||||||
'starry-night': {
|
|
||||||
top: '#070b1f', horizon: '#1b2547', bottom: '#243056',
|
|
||||||
sunDir: [-0.4, 0.75, 0.3], sunColor: '#cdd6ff', sunSize: 0.02, haze: 0.3,
|
|
||||||
mountains: true, stars: true,
|
|
||||||
clouds: { enabled: false },
|
|
||||||
fog: { color: '#141c38', density: 0.004 },
|
|
||||||
light: { sunIntensity: 0.18, sunColor: '#9aa8d8', hemiIntensity: 0.25, ambient: '#1b2547' },
|
|
||||||
},
|
|
||||||
'space': {
|
|
||||||
top: '#02030a', horizon: '#06070f', bottom: '#0a0c18',
|
|
||||||
sunDir: [0.5, 0.6, 0.4], sunColor: '#ffffff', sunSize: 0.015, haze: 0.0,
|
|
||||||
mountains: false, stars: true,
|
|
||||||
clouds: { enabled: false },
|
|
||||||
fog: { enabled: false },
|
|
||||||
light: { sunIntensity: 0.6, sunColor: '#ffffff', hemiIntensity: 0.2, ambient: '#10121c' },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export class SkyboxManager {
|
|
||||||
constructor(scene, hemiLight, sunLight) {
|
|
||||||
this.scene = scene;
|
|
||||||
this.hemiLight = hemiLight || null; // ambient
|
|
||||||
this.sunLight = sunLight || null; // directional (тени)
|
|
||||||
this._dome = null;
|
|
||||||
this._mat = null;
|
|
||||||
this._mountains = null;
|
|
||||||
this._clouds = []; // [{mesh, baseX, speed}]
|
|
||||||
this._cloudRoot = null;
|
|
||||||
this._stars = null;
|
|
||||||
this._fade = null; // активный fadeTo {from,to,t,dur}
|
|
||||||
this._state = this._defaultState();
|
|
||||||
registerSkyShader();
|
|
||||||
this._buildDome();
|
|
||||||
}
|
|
||||||
|
|
||||||
_defaultState() {
|
|
||||||
return {
|
|
||||||
mode: 'gradient',
|
|
||||||
top: '#4a93e6', horizon: '#cfe6f5', bottom: '#e6f1fa',
|
|
||||||
sunDir: [0.4, 0.8, 0.45], sunColor: '#fffbe6', sunSize: 0.03, haze: 0.8,
|
|
||||||
mountains: false, stars: false,
|
|
||||||
clouds: { enabled: false, cover: 0.4, color: '#ffffff', speed: 0.012 },
|
|
||||||
fog: { enabled: false, color: '#dde8f2', density: 0.005 },
|
|
||||||
light: { sunIntensity: 0.95, sunColor: '#fff7e0', hemiIntensity: 0.75, ambient: '#cfe6f5' },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Купол ──────────────────────────────────────────────────────────────
|
|
||||||
_buildDome() {
|
|
||||||
const dome = MeshBuilder.CreateSphere('kubikonSkyDome', {
|
|
||||||
diameter: 1000, segments: 24, sideOrientation: Mesh.BACKSIDE,
|
|
||||||
}, this.scene);
|
|
||||||
dome.isPickable = false;
|
|
||||||
dome.infiniteDistance = true; // не двигается с камерой
|
|
||||||
dome.renderingGroupId = 0;
|
|
||||||
dome.applyFog = false;
|
|
||||||
|
|
||||||
const mat = new ShaderMaterial('kubikonSkyMat', this.scene, {
|
|
||||||
vertex: 'kubikonSky', fragment: 'kubikonSky',
|
|
||||||
}, {
|
|
||||||
attributes: ['position'],
|
|
||||||
uniforms: ['worldViewProjection', 'topColor', 'bottomColor',
|
|
||||||
'horizonColor', 'sunDir', 'sunColor', 'sunSize', 'horizonHaze'],
|
|
||||||
});
|
|
||||||
mat.backFaceCulling = false;
|
|
||||||
mat.disableDepthWrite = true; // небо всегда позади
|
|
||||||
dome.material = mat;
|
|
||||||
this._dome = dome;
|
|
||||||
this._mat = mat;
|
|
||||||
this._applyShaderUniforms();
|
|
||||||
}
|
|
||||||
|
|
||||||
_applyShaderUniforms() {
|
|
||||||
const s = this._state;
|
|
||||||
const m = this._mat;
|
|
||||||
if (!m) return;
|
|
||||||
m.setColor3('topColor', Color3.FromArray(hexToRgb(s.top)));
|
|
||||||
m.setColor3('bottomColor', Color3.FromArray(hexToRgb(s.bottom)));
|
|
||||||
m.setColor3('horizonColor', Color3.FromArray(hexToRgb(s.horizon)));
|
|
||||||
const sd = Array.isArray(s.sunDir) ? s.sunDir : [0.4, 0.8, 0.45];
|
|
||||||
m.setVector3('sunDir', new Vector3(sd[0], sd[1], sd[2]).normalize());
|
|
||||||
m.setColor3('sunColor', Color3.FromArray(hexToRgb(s.sunColor)));
|
|
||||||
m.setFloat('sunSize', s.sunSize || 0.03);
|
|
||||||
m.setFloat('horizonHaze', s.haze != null ? s.haze : 0.7);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Горы (low-poly на горизонте) ────────────────────────────────────────
|
|
||||||
_buildMountains(colorHex) {
|
|
||||||
this._disposeMountains();
|
|
||||||
const positions = [], indices = [];
|
|
||||||
const ringR = 420, baseY = -10, segs = 64;
|
|
||||||
// Кольцо из треугольных пиков переменной высоты — стилизованный силуэт.
|
|
||||||
let vi = 0;
|
|
||||||
for (let i = 0; i < segs; i++) {
|
|
||||||
const a0 = (i / segs) * Math.PI * 2;
|
|
||||||
const a1 = ((i + 1) / segs) * Math.PI * 2;
|
|
||||||
const am = (a0 + a1) / 2;
|
|
||||||
// Псевдослучайная высота пика (детерминированно от индекса).
|
|
||||||
const hh = 40 + Math.abs(Math.sin(i * 1.7) * Math.cos(i * 0.6)) * 130;
|
|
||||||
const x0 = Math.cos(a0) * ringR, z0 = Math.sin(a0) * ringR;
|
|
||||||
const x1 = Math.cos(a1) * ringR, z1 = Math.sin(a1) * ringR;
|
|
||||||
const xm = Math.cos(am) * ringR, zm = Math.sin(am) * ringR;
|
|
||||||
positions.push(x0, baseY, z0, x1, baseY, z1, xm, baseY + hh, zm);
|
|
||||||
indices.push(vi, vi + 1, vi + 2);
|
|
||||||
vi += 3;
|
|
||||||
}
|
|
||||||
const vd = new VertexData();
|
|
||||||
vd.positions = positions; vd.indices = indices;
|
|
||||||
const normals = [];
|
|
||||||
VertexData.ComputeNormals(positions, indices, normals);
|
|
||||||
vd.normals = normals;
|
|
||||||
const mesh = new Mesh('kubikonSkyMountains', this.scene);
|
|
||||||
vd.applyToMesh(mesh);
|
|
||||||
mesh.isPickable = false;
|
|
||||||
mesh.applyFog = true; // горы выцветают в туман (атмосфера)
|
|
||||||
mesh.renderingGroupId = 0;
|
|
||||||
const mat = new StandardMaterial('kubikonSkyMountainsMat', this.scene);
|
|
||||||
const c = hexToRgb(colorHex || '#8fa98a');
|
|
||||||
mat.diffuseColor = new Color3(c[0], c[1], c[2]);
|
|
||||||
mat.specularColor = new Color3(0, 0, 0);
|
|
||||||
mat.emissiveColor = new Color3(c[0] * 0.25, c[1] * 0.25, c[2] * 0.25);
|
|
||||||
mesh.material = mat;
|
|
||||||
this._mountains = mesh;
|
|
||||||
}
|
|
||||||
|
|
||||||
_disposeMountains() {
|
|
||||||
if (this._mountains) { this._mountains.material?.dispose(); this._mountains.dispose(); this._mountains = null; }
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Облака (billboard-плоскости) ────────────────────────────────────────
|
|
||||||
_buildClouds(opts) {
|
|
||||||
this._disposeClouds();
|
|
||||||
const o = opts || {};
|
|
||||||
if (!o.enabled) return;
|
|
||||||
const cover = o.cover != null ? o.cover : 0.4;
|
|
||||||
const count = Math.round(4 + cover * 16); // 4..20 облаков
|
|
||||||
const tex = this._makeCloudTexture(o.color || '#ffffff');
|
|
||||||
for (let i = 0; i < count; i++) {
|
|
||||||
const w = 60 + Math.random() * 90;
|
|
||||||
const plane = MeshBuilder.CreatePlane(`kubikonCloud_${i}`, { width: w, height: w * 0.55 }, this.scene);
|
|
||||||
plane.billboardMode = Mesh.BILLBOARDMODE_ALL;
|
|
||||||
plane.isPickable = false;
|
|
||||||
plane.applyFog = false;
|
|
||||||
plane.renderingGroupId = 0;
|
|
||||||
const mat = new StandardMaterial(`kubikonCloudMat_${i}`, this.scene);
|
|
||||||
mat.diffuseTexture = tex;
|
|
||||||
mat.opacityTexture = tex;
|
|
||||||
mat.emissiveColor = new Color3(1, 1, 1);
|
|
||||||
mat.disableLighting = true;
|
|
||||||
mat.backFaceCulling = false;
|
|
||||||
plane.material = mat;
|
|
||||||
const ang = Math.random() * Math.PI * 2;
|
|
||||||
const rad = 150 + Math.random() * 200;
|
|
||||||
const x = Math.cos(ang) * rad;
|
|
||||||
const z = Math.sin(ang) * rad;
|
|
||||||
const y = 90 + Math.random() * 70;
|
|
||||||
plane.position.set(x, y, z);
|
|
||||||
this._clouds.push({ mesh: plane, speed: (o.speed || 0.012) * (0.6 + Math.random() * 0.8) });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Процедурная мягкая текстура-облако (radial-gradient на DynamicTexture). */
|
|
||||||
_makeCloudTexture(colorHex) {
|
|
||||||
const size = 256;
|
|
||||||
const dt = new DynamicTexture('kubikonCloudTex', { width: size, height: size }, this.scene, false);
|
|
||||||
const ctx = dt.getContext();
|
|
||||||
ctx.clearRect(0, 0, size, size);
|
|
||||||
const c = hexToRgb(colorHex);
|
|
||||||
const rgb = `${Math.round(c[0] * 255)},${Math.round(c[1] * 255)},${Math.round(c[2] * 255)}`;
|
|
||||||
// Несколько перекрывающихся мягких кругов → пухлое облако.
|
|
||||||
const blobs = [[128, 140, 70], [90, 150, 50], [170, 150, 55], [128, 110, 55], [110, 130, 45], [150, 130, 45]];
|
|
||||||
for (const [bx, by, br] of blobs) {
|
|
||||||
const g = ctx.createRadialGradient(bx, by, 0, bx, by, br);
|
|
||||||
g.addColorStop(0, `rgba(${rgb},0.9)`);
|
|
||||||
g.addColorStop(0.6, `rgba(${rgb},0.5)`);
|
|
||||||
g.addColorStop(1, `rgba(${rgb},0)`);
|
|
||||||
ctx.fillStyle = g;
|
|
||||||
ctx.beginPath(); ctx.arc(bx, by, br, 0, Math.PI * 2); ctx.fill();
|
|
||||||
}
|
|
||||||
dt.hasAlpha = true;
|
|
||||||
dt.update();
|
|
||||||
return dt;
|
|
||||||
}
|
|
||||||
|
|
||||||
_disposeClouds() {
|
|
||||||
for (const c of this._clouds) { c.mesh.material?.diffuseTexture?.dispose?.(); c.mesh.material?.dispose(); c.mesh.dispose(); }
|
|
||||||
this._clouds = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Звёзды (точки на куполе) ─────────────────────────────────────────────
|
|
||||||
_buildStars(enabled) {
|
|
||||||
this._disposeStars();
|
|
||||||
if (!enabled) return;
|
|
||||||
const size = 1024;
|
|
||||||
const dt = new DynamicTexture('kubikonStarsTex', { width: size, height: size }, this.scene, false);
|
|
||||||
const ctx = dt.getContext();
|
|
||||||
ctx.fillStyle = 'rgba(0,0,0,0)'; ctx.clearRect(0, 0, size, size);
|
|
||||||
for (let i = 0; i < 600; i++) {
|
|
||||||
const x = Math.random() * size, y = Math.random() * size;
|
|
||||||
const r = Math.random() * 1.4 + 0.3;
|
|
||||||
const a = 0.4 + Math.random() * 0.6;
|
|
||||||
ctx.fillStyle = `rgba(255,255,255,${a})`;
|
|
||||||
ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fill();
|
|
||||||
}
|
|
||||||
dt.hasAlpha = true; dt.update();
|
|
||||||
const dome = MeshBuilder.CreateSphere('kubikonStarsDome', {
|
|
||||||
diameter: 980, segments: 16, sideOrientation: Mesh.BACKSIDE,
|
|
||||||
}, this.scene);
|
|
||||||
dome.isPickable = false; dome.infiniteDistance = true;
|
|
||||||
dome.applyFog = false; dome.renderingGroupId = 0;
|
|
||||||
const mat = new StandardMaterial('kubikonStarsMat', this.scene);
|
|
||||||
mat.diffuseTexture = dt; mat.opacityTexture = dt;
|
|
||||||
mat.emissiveColor = new Color3(1, 1, 1);
|
|
||||||
mat.disableLighting = true; mat.backFaceCulling = false;
|
|
||||||
mat.disableDepthWrite = true;
|
|
||||||
dome.material = mat;
|
|
||||||
this._stars = dome;
|
|
||||||
}
|
|
||||||
|
|
||||||
_disposeStars() {
|
|
||||||
if (this._stars) { this._stars.material?.diffuseTexture?.dispose?.(); this._stars.material?.dispose(); this._stars.dispose(); this._stars = null; }
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Туман ────────────────────────────────────────────────────────────────
|
|
||||||
_applyFog(fog) {
|
|
||||||
if (!this.scene) return;
|
|
||||||
if (fog && fog.enabled !== false && (fog.density != null || fog.color)) {
|
|
||||||
this.scene.fogMode = 2; // EXP
|
|
||||||
const c = hexToRgb(fog.color || '#dde8f2');
|
|
||||||
this.scene.fogColor = new Color3(c[0], c[1], c[2]);
|
|
||||||
this.scene.fogDensity = fog.density != null ? fog.density : 0.005;
|
|
||||||
} else if (fog && fog.enabled === false) {
|
|
||||||
this.scene.fogMode = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Освещение (единый источник: небо управляет светом сцены) ─────────────
|
|
||||||
/** Выставить направление/яркость солнца и ambient под текущее небо. */
|
|
||||||
_applyLighting(light, sunDir) {
|
|
||||||
if (this.sunLight && sunDir) {
|
|
||||||
// DirectionalLight.direction указывает КУДА падает свет → от солнца вниз.
|
|
||||||
const d = new Vector3(-sunDir[0], -sunDir[1], -sunDir[2]);
|
|
||||||
if (d.lengthSquared() > 0) { d.normalize(); this.sunLight.direction = d; }
|
|
||||||
}
|
|
||||||
if (!light) return;
|
|
||||||
if (this.sunLight) {
|
|
||||||
if (typeof light.sunIntensity === 'number') this.sunLight.intensity = light.sunIntensity;
|
|
||||||
if (light.sunColor) this.sunLight.diffuse = Color3.FromArray(hexToRgb(light.sunColor));
|
|
||||||
}
|
|
||||||
if (this.hemiLight) {
|
|
||||||
if (typeof light.hemiIntensity === 'number') this.hemiLight.intensity = light.hemiIntensity;
|
|
||||||
if (light.ambient) this.hemiLight.groundColor = Color3.FromArray(hexToRgb(light.ambient));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Public API ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/** Применить пресет или ручные опции gradient. */
|
|
||||||
setSkybox(opts) {
|
|
||||||
if (!opts) return;
|
|
||||||
const preset = opts.preset && PRESETS[opts.preset] ? { ...PRESETS[opts.preset] } : null;
|
|
||||||
const s = this._state;
|
|
||||||
if (preset) {
|
|
||||||
s.top = preset.top; s.horizon = preset.horizon; s.bottom = preset.bottom;
|
|
||||||
s.sunDir = preset.sunDir; s.sunColor = preset.sunColor; s.sunSize = preset.sunSize;
|
|
||||||
s.haze = preset.haze; s.mountains = !!preset.mountains; s.stars = !!preset.stars;
|
|
||||||
s.clouds = { ...(preset.clouds || { enabled: false }) };
|
|
||||||
s.fog = { enabled: preset.fog?.enabled !== false, ...(preset.fog || {}) };
|
|
||||||
s.light = preset.light || null;
|
|
||||||
this._applyLighting(preset.light, preset.sunDir);
|
|
||||||
} else {
|
|
||||||
// Ручной gradient: { topColor, bottomColor, horizonColor, sunDirection, sunColor, sunSize }
|
|
||||||
if (opts.topColor) s.top = opts.topColor;
|
|
||||||
if (opts.bottomColor) s.bottom = opts.bottomColor;
|
|
||||||
if (opts.horizonColor) s.horizon = opts.horizonColor;
|
|
||||||
if (opts.sunDirection) s.sunDir = [opts.sunDirection.x, opts.sunDirection.y, opts.sunDirection.z];
|
|
||||||
if (opts.sunColor) s.sunColor = opts.sunColor;
|
|
||||||
if (typeof opts.sunSize === 'number') s.sunSize = opts.sunSize;
|
|
||||||
if (typeof opts.haze === 'number') s.haze = opts.haze;
|
|
||||||
if (typeof opts.mountains === 'boolean') s.mountains = opts.mountains;
|
|
||||||
if (typeof opts.stars === 'boolean') s.stars = opts.stars;
|
|
||||||
}
|
|
||||||
this._rebuildAll();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Облака поверх любого режима. */
|
|
||||||
setClouds(opts) {
|
|
||||||
if (!opts) return;
|
|
||||||
this._state.clouds = { ...this._state.clouds, ...opts };
|
|
||||||
if (this._state.clouds.enabled == null) this._state.clouds.enabled = true;
|
|
||||||
this._buildClouds(this._state.clouds);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Атмосферный туман. */
|
|
||||||
setFog(opts) {
|
|
||||||
if (!opts) { return; }
|
|
||||||
this._state.fog = { ...this._state.fog, ...opts };
|
|
||||||
if (opts.enabled == null) this._state.fog.enabled = true;
|
|
||||||
this._applyFog(this._state.fog);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Установить направление солнца (для программной анимации). */
|
|
||||||
setSunDirection(dir) {
|
|
||||||
if (!dir) return;
|
|
||||||
this._state.sunDir = [dir.x, dir.y, dir.z];
|
|
||||||
this._applyShaderUniforms();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Плавный переход к пресету/опциям за durationSec секунд (цвета купола + туман). */
|
|
||||||
fadeTo(opts, durationSec = 2) {
|
|
||||||
const target = opts?.preset && PRESETS[opts.preset] ? { ...PRESETS[opts.preset] } : null;
|
|
||||||
if (!target) { this.setSkybox(opts); return; }
|
|
||||||
// Запоминаем стартовые цвета и целевые — анимируем в tick().
|
|
||||||
this._fade = {
|
|
||||||
t: 0, dur: Math.max(0.1, durationSec),
|
|
||||||
from: {
|
|
||||||
top: hexToRgb(this._state.top), horizon: hexToRgb(this._state.horizon),
|
|
||||||
bottom: hexToRgb(this._state.bottom), sunColor: hexToRgb(this._state.sunColor),
|
|
||||||
sunDir: this._state.sunDir.slice(), sunSize: this._state.sunSize, haze: this._state.haze,
|
|
||||||
},
|
|
||||||
to: {
|
|
||||||
top: hexToRgb(target.top), horizon: hexToRgb(target.horizon),
|
|
||||||
bottom: hexToRgb(target.bottom), sunColor: hexToRgb(target.sunColor),
|
|
||||||
sunDir: target.sunDir.slice(), sunSize: target.sunSize, haze: target.haze,
|
|
||||||
},
|
|
||||||
target,
|
|
||||||
};
|
|
||||||
// Немедленно перестраиваем «дискретные» части (горы/звёзды/облака/туман
|
|
||||||
// целевого пресета появляются сразу, цвета купола — плавно).
|
|
||||||
const s = this._state;
|
|
||||||
s.mountains = !!target.mountains; s.stars = !!target.stars;
|
|
||||||
s.clouds = { ...(target.clouds || { enabled: false }) };
|
|
||||||
s.fog = { enabled: target.fog?.enabled !== false, ...(target.fog || {}) };
|
|
||||||
s.light = target.light || null;
|
|
||||||
this._rebuildExtras();
|
|
||||||
// Запоминаем стартовые/целевые значения света для плавной анимации.
|
|
||||||
if (target.light) {
|
|
||||||
this._fade.lightFrom = {
|
|
||||||
sunInt: this.sunLight?.intensity ?? 1,
|
|
||||||
hemiInt: this.hemiLight?.intensity ?? 0.7,
|
|
||||||
};
|
|
||||||
this._fade.lightTo = {
|
|
||||||
sunInt: target.light.sunIntensity ?? 1,
|
|
||||||
hemiInt: target.light.hemiIntensity ?? 0.7,
|
|
||||||
sunColor: target.light.sunColor, ambient: target.light.ambient,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Пересобрать всё (купол-uniforms + горы + звёзды + облака + туман + свет). */
|
|
||||||
_rebuildAll() {
|
|
||||||
this._applyShaderUniforms();
|
|
||||||
this._rebuildExtras();
|
|
||||||
this._applyLighting(this._state.light, this._state.sunDir);
|
|
||||||
}
|
|
||||||
|
|
||||||
_rebuildExtras() {
|
|
||||||
const s = this._state;
|
|
||||||
if (s.mountains) {
|
|
||||||
// Цвет гор подбираем под пресет (днём зеленовато-серый, ночью тёмный).
|
|
||||||
const mc = s.stars ? '#2a3550' : '#8fa98a';
|
|
||||||
this._buildMountains(mc);
|
|
||||||
} else this._disposeMountains();
|
|
||||||
this._buildStars(!!s.stars);
|
|
||||||
this._buildClouds(s.clouds);
|
|
||||||
this._applyFog(s.fog);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Вызывать каждый кадр: дрейф облаков + анимация fadeTo. */
|
|
||||||
tick(dt) {
|
|
||||||
// Дрейф облаков по кругу.
|
|
||||||
for (const c of this._clouds) {
|
|
||||||
c.mesh.position.x += c.speed * dt * 60;
|
|
||||||
if (c.mesh.position.x > 380) c.mesh.position.x = -380;
|
|
||||||
}
|
|
||||||
// Анимация перехода неба.
|
|
||||||
if (this._fade) {
|
|
||||||
this._fade.t += dt;
|
|
||||||
const k = Math.min(1, this._fade.t / this._fade.dur);
|
|
||||||
const f = this._fade.from, t = this._fade.to, m = this._mat;
|
|
||||||
const mix = (a, b) => [a[0] + (b[0] - a[0]) * k, a[1] + (b[1] - a[1]) * k, a[2] + (b[2] - a[2]) * k];
|
|
||||||
if (m) {
|
|
||||||
m.setColor3('topColor', Color3.FromArray(mix(f.top, t.top)));
|
|
||||||
m.setColor3('bottomColor', Color3.FromArray(mix(f.bottom, t.bottom)));
|
|
||||||
m.setColor3('horizonColor', Color3.FromArray(mix(f.horizon, t.horizon)));
|
|
||||||
m.setColor3('sunColor', Color3.FromArray(mix(f.sunColor, t.sunColor)));
|
|
||||||
const sd = mix(f.sunDir, t.sunDir);
|
|
||||||
m.setVector3('sunDir', new Vector3(sd[0], sd[1], sd[2]).normalize());
|
|
||||||
m.setFloat('sunSize', f.sunSize + (t.sunSize - f.sunSize) * k);
|
|
||||||
m.setFloat('horizonHaze', f.haze + (t.haze - f.haze) * k);
|
|
||||||
// Плавно ведём направление солнца (свет) к целевому (используем sd выше).
|
|
||||||
if (this.sunLight) {
|
|
||||||
const d = new Vector3(-sd[0], -sd[1], -sd[2]);
|
|
||||||
if (d.lengthSquared() > 0) { d.normalize(); this.sunLight.direction = d; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Плавно ведём яркость/ambient света.
|
|
||||||
if (this._fade.lightFrom && this._fade.lightTo) {
|
|
||||||
const lf = this._fade.lightFrom, lt = this._fade.lightTo;
|
|
||||||
if (this.sunLight) {
|
|
||||||
this.sunLight.intensity = lf.sunInt + (lt.sunInt - lf.sunInt) * k;
|
|
||||||
if (lt.sunColor) this.sunLight.diffuse = Color3.FromArray(hexToRgb(lt.sunColor));
|
|
||||||
}
|
|
||||||
if (this.hemiLight) {
|
|
||||||
this.hemiLight.intensity = lf.hemiInt + (lt.hemiInt - lf.hemiInt) * k;
|
|
||||||
if (lt.ambient) this.hemiLight.groundColor = Color3.FromArray(hexToRgb(lt.ambient));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (k >= 1) {
|
|
||||||
// Зафиксировать целевое состояние в _state (как hex).
|
|
||||||
const tp = this._fade.target;
|
|
||||||
Object.assign(this._state, {
|
|
||||||
top: tp.top, horizon: tp.horizon, bottom: tp.bottom,
|
|
||||||
sunColor: tp.sunColor, sunDir: tp.sunDir.slice(),
|
|
||||||
sunSize: tp.sunSize, haze: tp.haze,
|
|
||||||
});
|
|
||||||
this._fade = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
serialize() {
|
|
||||||
return { ...this._state, _active: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
load(data) {
|
|
||||||
if (!data) return;
|
|
||||||
this._state = { ...this._defaultState(), ...data };
|
|
||||||
this._rebuildAll();
|
|
||||||
}
|
|
||||||
|
|
||||||
dispose() {
|
|
||||||
this._disposeMountains();
|
|
||||||
this._disposeClouds();
|
|
||||||
this._disposeStars();
|
|
||||||
if (this._dome) { this._mat?.dispose(); this._dome.dispose(); this._dome = null; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -514,10 +514,6 @@ export class TerrainManager {
|
|||||||
const mat = new StandardMaterial(name, this.scene);
|
const mat = new StandardMaterial(name, this.scene);
|
||||||
// Specular почти выключен — на pixel-art-текстуре любой блик уничтожает стиль.
|
// Specular почти выключен — на pixel-art-текстуре любой блик уничтожает стиль.
|
||||||
mat.specularColor = new Color3(0, 0, 0);
|
mat.specularColor = new Color3(0, 0, 0);
|
||||||
// 2026-06-02: воксели «просвечивали» (видна задняя грань сквозь переднюю).
|
|
||||||
// backFaceCulling=false рисует обе стороны, ближняя перекрывает дальнюю
|
|
||||||
// по depth. Прозрачным (water/glacier) culling оставляем. См. studio.
|
|
||||||
mat.backFaceCulling = !(def.alpha != null && def.alpha < 1) ? false : true;
|
|
||||||
// Ambient ставим в белый, чтобы hemisphere-light освещал материал
|
// Ambient ставим в белый, чтобы hemisphere-light освещал материал
|
||||||
// с любой стороны (иначе нижние/тыловые грани выходят серыми, что
|
// с любой стороны (иначе нижние/тыловые грани выходят серыми, что
|
||||||
// особенно заметно на светло-бежевом песке — он становится серым).
|
// особенно заметно на светло-бежевом песке — он становится серым).
|
||||||
@ -547,12 +543,6 @@ export class TerrainManager {
|
|||||||
mat.diffuseTexture.hasAlpha = true;
|
mat.diffuseTexture.hasAlpha = true;
|
||||||
mat.useAlphaFromDiffuseTexture = true;
|
mat.useAlphaFromDiffuseTexture = true;
|
||||||
mat.alpha = def.alpha;
|
mat.alpha = def.alpha;
|
||||||
} else {
|
|
||||||
// RGBA-текстуры (alpha=255) Babylon мог рендерить с alpha-blend →
|
|
||||||
// воксели просвечивали. Явно OPAQUE для непрозрачных. См. studio.
|
|
||||||
mat.diffuseTexture.hasAlpha = false;
|
|
||||||
mat.useAlphaFromDiffuseTexture = false;
|
|
||||||
mat.transparencyMode = 0;
|
|
||||||
}
|
}
|
||||||
if (Array.isArray(def.emissive)) {
|
if (Array.isArray(def.emissive)) {
|
||||||
mat.emissiveColor = new Color3(def.emissive[0], def.emissive[1], def.emissive[2]);
|
mat.emissiveColor = new Color3(def.emissive[0], def.emissive[1], def.emissive[2]);
|
||||||
|
|||||||
@ -599,7 +599,6 @@ export class UserModelManager {
|
|||||||
// instanceId — чтобы target-скрипты могли стабильно ссылаться
|
// instanceId — чтобы target-скрипты могли стабильно ссылаться
|
||||||
// на конкретный инстанс после перезагрузки.
|
// на конкретный инстанс после перезагрузки.
|
||||||
instanceId: inst.instanceId,
|
instanceId: inst.instanceId,
|
||||||
...(inst.folderId != null ? { folderId: inst.folderId } : {}),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return arr;
|
return arr;
|
||||||
@ -664,13 +663,7 @@ export class UserModelManager {
|
|||||||
forceInstanceId: item.instanceId,
|
forceInstanceId: item.instanceId,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
if (id != null) {
|
if (id != null) loaded++;
|
||||||
loaded++;
|
|
||||||
if (item.folderId != null) {
|
|
||||||
const inst = this.instances.get(id);
|
|
||||||
if (inst) inst.folderId = item.folderId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('[UserModelManager] failed to load instance', item, e);
|
console.warn('[UserModelManager] failed to load instance', item, e);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,95 +0,0 @@
|
|||||||
/**
|
|
||||||
* VehicleHud — HUD водителя (задача 14): круглый спидометр со стрелкой,
|
|
||||||
* передача (D/R/N), подсказки клавиш. DOM-оверлей поверх canvas (как
|
|
||||||
* ShopInventoryUi). Показывается пока игрок за рулём.
|
|
||||||
*
|
|
||||||
* Фича-парность: идентичный модуль в rublox-player/src/engine/.
|
|
||||||
*/
|
|
||||||
export class VehicleHud {
|
|
||||||
constructor(scene3d) {
|
|
||||||
this.s = scene3d;
|
|
||||||
this.root = null;
|
|
||||||
this.needle = null;
|
|
||||||
this.speedText = null;
|
|
||||||
this.gearText = null;
|
|
||||||
this._maxKmh = 80;
|
|
||||||
}
|
|
||||||
|
|
||||||
show(maxKmh) {
|
|
||||||
this.remove();
|
|
||||||
this._maxKmh = Math.max(20, Math.round((maxKmh || 14) * 3.6 / 10) * 10 + 10);
|
|
||||||
const parent = (this.s.canvas && this.s.canvas.parentElement) || document.body;
|
|
||||||
try { if (getComputedStyle(parent).position === 'static') parent.style.position = 'relative'; } catch { /* ignore */ }
|
|
||||||
|
|
||||||
const root = document.createElement('div');
|
|
||||||
root.className = 'kbn-veh-hud';
|
|
||||||
root.style.cssText =
|
|
||||||
'position:absolute;left:24px;bottom:22px;z-index:45;width:160px;height:160px;' +
|
|
||||||
'pointer-events:none;font-family:system-ui,"Segoe UI",sans-serif;user-select:none;';
|
|
||||||
|
|
||||||
// SVG-циферблат.
|
|
||||||
const R = 70, CX = 80, CY = 80;
|
|
||||||
const startA = 135, endA = 405; // дуга 270°
|
|
||||||
const ticks = [];
|
|
||||||
const N = 8;
|
|
||||||
for (let i = 0; i <= N; i++) {
|
|
||||||
const a = (startA + (endA - startA) * i / N) * Math.PI / 180;
|
|
||||||
const x1 = CX + Math.cos(a) * (R - 4), y1 = CY + Math.sin(a) * (R - 4);
|
|
||||||
const x2 = CX + Math.cos(a) * (R - 14), y2 = CY + Math.sin(a) * (R - 14);
|
|
||||||
ticks.push(`<line x1="${x1.toFixed(1)}" y1="${y1.toFixed(1)}" x2="${x2.toFixed(1)}" y2="${y2.toFixed(1)}" stroke="#c8d0dc" stroke-width="2"/>`);
|
|
||||||
const lx = CX + Math.cos(a) * (R - 26), ly = CY + Math.sin(a) * (R - 26) + 4;
|
|
||||||
const val = Math.round(this._maxKmh * i / N);
|
|
||||||
ticks.push(`<text x="${lx.toFixed(1)}" y="${ly.toFixed(1)}" fill="#9aa6b8" font-size="9" text-anchor="middle">${val}</text>`);
|
|
||||||
}
|
|
||||||
root.innerHTML =
|
|
||||||
`<svg viewBox="0 0 160 160" width="160" height="160">` +
|
|
||||||
`<circle cx="${CX}" cy="${CY}" r="${R}" fill="rgba(16,20,32,0.82)" stroke="#3a4760" stroke-width="3"/>` +
|
|
||||||
ticks.join('') +
|
|
||||||
`<line id="kbn-veh-needle" x1="${CX}" y1="${CY}" x2="${CX}" y2="${CY - R + 18}" stroke="#ff5a3c" stroke-width="3.5" stroke-linecap="round" transform="rotate(-135 ${CX} ${CY})"/>` +
|
|
||||||
`<circle cx="${CX}" cy="${CY}" r="6" fill="#ff5a3c"/>` +
|
|
||||||
`<text id="kbn-veh-speed" x="${CX}" y="${CY + 30}" fill="#ffe44a" font-size="22" font-weight="800" text-anchor="middle">0</text>` +
|
|
||||||
`<text x="${CX}" y="${CY + 44}" fill="#9aa6b8" font-size="9" text-anchor="middle">км/ч</text>` +
|
|
||||||
`<text id="kbn-veh-gear" x="${CX}" y="${CY - 16}" fill="#7fe0a0" font-size="18" font-weight="900" text-anchor="middle">N</text>` +
|
|
||||||
`</svg>`;
|
|
||||||
parent.appendChild(root);
|
|
||||||
this.root = root;
|
|
||||||
this.needle = root.querySelector('#kbn-veh-needle');
|
|
||||||
this.speedText = root.querySelector('#kbn-veh-speed');
|
|
||||||
this.gearText = root.querySelector('#kbn-veh-gear');
|
|
||||||
this._CX = CX; this._CY = CY;
|
|
||||||
|
|
||||||
// Подсказки клавиш справа снизу.
|
|
||||||
const keys = document.createElement('div');
|
|
||||||
keys.className = 'kbn-veh-keys';
|
|
||||||
keys.style.cssText =
|
|
||||||
'position:absolute;right:24px;bottom:28px;z-index:45;pointer-events:none;' +
|
|
||||||
'color:#cfd6e0;font:600 14px/1.6 system-ui,sans-serif;text-align:right;' +
|
|
||||||
'text-shadow:0 1px 3px rgba(0,0,0,0.7);';
|
|
||||||
keys.innerHTML = '<div><b>WASD</b> — руль</div><div><b>V</b> — камера</div><div><b>E</b> — выйти</div>';
|
|
||||||
parent.appendChild(keys);
|
|
||||||
this._keys = keys;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Обновить стрелку/число/передачу. speed — м/с (signed). */
|
|
||||||
update(speedMs) {
|
|
||||||
if (!this.needle) return;
|
|
||||||
const kmh = Math.abs(speedMs) * 3.6;
|
|
||||||
const frac = Math.max(0, Math.min(1, kmh / this._maxKmh));
|
|
||||||
const ang = -135 + 270 * frac; // -135°..+135°
|
|
||||||
this.needle.setAttribute('transform', `rotate(${ang.toFixed(1)} ${this._CX} ${this._CY})`);
|
|
||||||
if (this.speedText) this.speedText.textContent = String(Math.round(kmh));
|
|
||||||
if (this.gearText) {
|
|
||||||
const g = speedMs < -0.3 ? 'R' : (Math.abs(speedMs) < 0.3 ? 'N' : 'D');
|
|
||||||
this.gearText.textContent = g;
|
|
||||||
this.gearText.setAttribute('fill', g === 'R' ? '#ff7a5a' : g === 'N' ? '#9aa6b8' : '#7fe0a0');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
remove() {
|
|
||||||
if (this.root) { try { this.root.remove(); } catch { /* ignore */ } this.root = null; }
|
|
||||||
if (this._keys) { try { this._keys.remove(); } catch { /* ignore */ } this._keys = null; }
|
|
||||||
this.needle = this.speedText = this.gearText = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
dispose() { this.remove(); }
|
|
||||||
}
|
|
||||||
@ -1,249 +0,0 @@
|
|||||||
import { Vector3, TransformNode } from '@babylonjs/core';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* VehicleManager — система транспорта (задача 14, фаза V1 аркадная + V2 параметры).
|
|
||||||
*
|
|
||||||
* Каждая машина = chassisNode (TransformNode) + GLB-кузов (modelManager-инстанс) +
|
|
||||||
* 4 колеса-визуала (передние доворачивают при руле). Физика АРКАДНАЯ:
|
|
||||||
* speed (скаляр вдоль yaw) += throttle*power*dt; трение; поворот по steer
|
|
||||||
* (масштаб от скорости — нет вращения на месте); коллизия с миром через
|
|
||||||
* physics.moveAABB (тот же солвер что у игрока). Колёса друг с другом и с
|
|
||||||
* другими машинами НЕ сталкиваются (V1) — только chassis с миром.
|
|
||||||
*
|
|
||||||
* Фича-парность: идентичный модуль в rublox-player/src/engine/.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const DEFAULT_PARAMS = {
|
|
||||||
mass: 1200,
|
|
||||||
enginePower: 14, // ускорение (м/с²) — аркадно, не реальные л.с.
|
|
||||||
maxSpeed: 14, // м/с (~50 км/ч) — для маленьких миров
|
|
||||||
turnSpeed: 1.8, // рад/с при полной скорости
|
|
||||||
brake: 26, // замедление при тормозе/реверсе
|
|
||||||
drive: 'rwd',
|
|
||||||
};
|
|
||||||
|
|
||||||
export class VehicleManager {
|
|
||||||
constructor(scene3d) {
|
|
||||||
this.s = scene3d;
|
|
||||||
this.scene = scene3d.scene;
|
|
||||||
this.vehicles = new Map(); // id → veh
|
|
||||||
this._seq = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
get _physics() { return this.s.physics; }
|
|
||||||
get _models() { return this.s.modelManager; }
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Создать машину. opts: { model:'car-taxi', color, name, params, x,y,z, rotationY }.
|
|
||||||
* Возвращает Promise<id>.
|
|
||||||
*/
|
|
||||||
async spawn(opts) {
|
|
||||||
opts = opts || {};
|
|
||||||
const x0 = Number(opts.x) || 0, z0 = Number(opts.z) || 0;
|
|
||||||
// Идемпотентность: если машина с такой позицией уже есть — не плодим
|
|
||||||
// (защита от двойного выполнения скрипта спавна → дубли машин).
|
|
||||||
for (const v of this.vehicles.values()) {
|
|
||||||
if (Math.abs(v.spawnX - x0) < 0.5 && Math.abs(v.spawnZ - z0) < 0.5) return v.id;
|
|
||||||
}
|
|
||||||
const id = ++this._seq;
|
|
||||||
const x = Number(opts.x) || 0, y = Number(opts.y) || 0.4, z = Number(opts.z) || 0;
|
|
||||||
const yaw = Number(opts.rotationY) || 0;
|
|
||||||
const params = { ...DEFAULT_PARAMS, ...(opts.params || {}) };
|
|
||||||
const modelType = opts.model || 'car-sedan';
|
|
||||||
|
|
||||||
// chassis-узел — родитель кузова и колёс.
|
|
||||||
const chassisNode = new TransformNode(`vehicle_${id}`, this.scene);
|
|
||||||
chassisNode.position = new Vector3(x, y, z);
|
|
||||||
chassisNode.rotation = new Vector3(0, yaw, 0);
|
|
||||||
|
|
||||||
const veh = {
|
|
||||||
id, name: opts.name || 'Машина', params,
|
|
||||||
spawnX: x, spawnZ: z, // для дедупа повторного спавна
|
|
||||||
chassisNode, bodyInstanceId: null, wheels: [],
|
|
||||||
pos: new Vector3(x, y, z), yaw, vy: 0,
|
|
||||||
speed: 0, steerAngle: 0,
|
|
||||||
half: { w: 1.0, h: 0.6, d: 2.0 }, // уточним по bbox кузова
|
|
||||||
throttle: 0, steer: 0, handbrake: false,
|
|
||||||
driver: null,
|
|
||||||
handlers: { onEnter: [], onExit: [], onCollide: [], onSpeedChange: [] },
|
|
||||||
ref: opts.ref || null,
|
|
||||||
};
|
|
||||||
this.vehicles.set(id, veh);
|
|
||||||
|
|
||||||
// Кузов (GLB Kenney car-kit).
|
|
||||||
try {
|
|
||||||
const bodyId = await this._models.addInstance(modelType, x, y, z, yaw);
|
|
||||||
veh.bodyInstanceId = bodyId;
|
|
||||||
const inst = this._models.instances.get(bodyId);
|
|
||||||
if (inst && inst.rootMesh) {
|
|
||||||
// Габариты AABB + вертикальный offset кузова СЧИТАЕМ ДО парентинга
|
|
||||||
// (в мировых координатах, кузов ещё в (x,y,z)).
|
|
||||||
try {
|
|
||||||
const bb = inst.rootMesh.getHierarchyBoundingVectors(true);
|
|
||||||
veh.half = {
|
|
||||||
w: Math.max(0.6, (bb.max.x - bb.min.x) / 2),
|
|
||||||
h: Math.max(0.4, (bb.max.y - bb.min.y) / 2),
|
|
||||||
d: Math.max(1.0, (bb.max.z - bb.min.z) / 2),
|
|
||||||
};
|
|
||||||
// Насколько низ кузова ниже точки спавна y — чтобы посадить
|
|
||||||
// кузов так, чтобы его НИЗ совпал с низом AABB (машина на земле,
|
|
||||||
// не парит). bodyYOffset применяется к локальной Y кузова.
|
|
||||||
veh.bodyYOffset = -(bb.min.y - y) - veh.half.h;
|
|
||||||
} catch (e) { veh.bodyYOffset = -veh.half.h; }
|
|
||||||
inst.rootMesh.setParent(chassisNode);
|
|
||||||
inst.rootMesh.position = new Vector3(0, veh.bodyYOffset || 0, 0);
|
|
||||||
inst.rootMesh.rotation = Vector3.Zero();
|
|
||||||
// Цвет кузова (tint поверх GLB-текстуры).
|
|
||||||
if (opts.color) { try { this._models.setInstanceProps?.(bodyId, { tint: opts.color }); } catch (e) {} }
|
|
||||||
}
|
|
||||||
} catch (e) { console.warn('[VehicleManager] body load failed', e); }
|
|
||||||
|
|
||||||
// Колёса НЕ спавним отдельно — GLB-модели Kenney car-kit уже содержат
|
|
||||||
// колёса в кузове. Отдельные колёса дублировали/отрывались (баг V1).
|
|
||||||
// Визуальный доворот передних колёс — фаза V3 (там кузов+колёса раздельно).
|
|
||||||
|
|
||||||
// «Оседание»: уроним машину на землю СРАЗУ (до посадки игрока), иначе она
|
|
||||||
// висит/утоплена на стартовой y, пока никто не за рулём (нет tick).
|
|
||||||
this._settle(veh);
|
|
||||||
// Повторное оседание на следующих кадрах: физический грид статики может
|
|
||||||
// ещё не проиндексироваться к моменту спавна (await addInstance), тогда
|
|
||||||
// первый _settle не находит пол и машина зависает в воздухе (баг седана).
|
|
||||||
for (const d of [120, 350, 800]) {
|
|
||||||
setTimeout(() => { try { if (!veh.driver) this._settle(veh); } catch (e) {} }, d);
|
|
||||||
}
|
|
||||||
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Опустить машину на поверхность гравитацией. Стартуем ВЫШЕ текущей точки и
|
|
||||||
* роняем большим запасом (много шагов), чтобы гарантированно найти пол даже
|
|
||||||
* если стартовая y оказалась чуть ниже/выше или физика поздно готова.
|
|
||||||
*/
|
|
||||||
_settle(veh) {
|
|
||||||
try {
|
|
||||||
veh.pos.y += 0.5;
|
|
||||||
let landed = false;
|
|
||||||
for (let i = 0; i < 80; i++) {
|
|
||||||
const r = this._physics.moveAABB(veh.pos, veh.half.w, veh.half.h, veh.half.d, 0, -0.25, 0);
|
|
||||||
veh.pos.set(r.x, r.y, r.z);
|
|
||||||
if (r.hitY) { landed = true; break; }
|
|
||||||
}
|
|
||||||
if (landed) {
|
|
||||||
for (let i = 0; i < 4; i++) {
|
|
||||||
const r = this._physics.moveAABB(veh.pos, veh.half.w, veh.half.h, veh.half.d, 0, -0.04, 0);
|
|
||||||
veh.pos.set(r.x, r.y, r.z);
|
|
||||||
if (r.hitY) break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
veh.vy = 0;
|
|
||||||
veh.chassisNode.position.copyFrom(veh.pos);
|
|
||||||
veh.chassisNode.rotation.y = veh.yaw;
|
|
||||||
} catch (e) { /* ignore */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
getById(id) { return this.vehicles.get(id) || null; }
|
|
||||||
|
|
||||||
/** Установить ввод водителя (из PlayerController). */
|
|
||||||
setInput(veh, throttle, steer, handbrake) {
|
|
||||||
if (!veh) return;
|
|
||||||
veh.throttle = Math.max(-1, Math.min(1, throttle || 0));
|
|
||||||
veh.steer = Math.max(-1, Math.min(1, steer || 0));
|
|
||||||
veh.handbrake = !!handbrake;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Физический шаг машины (вызывается каждый кадр пока есть водитель). */
|
|
||||||
tickVehicle(veh, dt) {
|
|
||||||
if (!veh) return;
|
|
||||||
dt = Math.min(dt, 1 / 30);
|
|
||||||
const p = veh.params;
|
|
||||||
const prevSpeed = veh.speed;
|
|
||||||
|
|
||||||
// Ускорение / торможение / реверс.
|
|
||||||
if (veh.throttle > 0) {
|
|
||||||
veh.speed += veh.throttle * p.enginePower * dt;
|
|
||||||
} else if (veh.throttle < 0) {
|
|
||||||
// S: сначала тормоз, потом задний ход (ограничен).
|
|
||||||
if (veh.speed > 0.2) veh.speed -= p.brake * dt;
|
|
||||||
else veh.speed += veh.throttle * p.enginePower * 0.5 * dt;
|
|
||||||
}
|
|
||||||
// Накат-трение.
|
|
||||||
veh.speed *= (1 - 1.2 * dt);
|
|
||||||
if (veh.handbrake) veh.speed *= (1 - 6 * dt);
|
|
||||||
// Клампы.
|
|
||||||
const maxFwd = p.maxSpeed, maxRev = p.maxSpeed * 0.4;
|
|
||||||
if (veh.speed > maxFwd) veh.speed = maxFwd;
|
|
||||||
if (veh.speed < -maxRev) veh.speed = -maxRev;
|
|
||||||
if (Math.abs(veh.speed) < 0.05) veh.speed = 0;
|
|
||||||
|
|
||||||
// Поворот (зависит от скорости — нельзя крутиться на месте).
|
|
||||||
const speedFrac = veh.speed / maxFwd;
|
|
||||||
veh.yaw += veh.steer * p.turnSpeed * speedFrac * dt;
|
|
||||||
// Угол доворота передних колёс (визуал) — плавный lerp.
|
|
||||||
const targetSteer = veh.steer * 0.5;
|
|
||||||
veh.steerAngle += (targetSteer - veh.steerAngle) * Math.min(1, dt * 8);
|
|
||||||
|
|
||||||
// Направление и перемещение.
|
|
||||||
const dir = new Vector3(Math.sin(veh.yaw), 0, Math.cos(veh.yaw));
|
|
||||||
const moveX = dir.x * veh.speed * dt;
|
|
||||||
const moveZ = dir.z * veh.speed * dt;
|
|
||||||
// Гравитация (машина сидит на полу/дороге).
|
|
||||||
veh.vy += -22 * dt;
|
|
||||||
|
|
||||||
// Коллизия с миром через тот же солвер что у игрока.
|
|
||||||
let res;
|
|
||||||
try {
|
|
||||||
res = this._physics.moveAABB(veh.pos, veh.half.w, veh.half.h, veh.half.d, moveX, veh.vy * dt, moveZ);
|
|
||||||
} catch (e) {
|
|
||||||
res = { x: veh.pos.x + moveX, y: veh.pos.y, z: veh.pos.z + moveZ, hitX: false, hitY: false, hitZ: false };
|
|
||||||
}
|
|
||||||
veh.pos.set(res.x, res.y, res.z);
|
|
||||||
if (res.hitY) veh.vy = 0;
|
|
||||||
// Удар об стену — гасим ход.
|
|
||||||
if (res.hitX || res.hitZ) {
|
|
||||||
const force = Math.abs(veh.speed);
|
|
||||||
veh.speed *= 0.3;
|
|
||||||
for (const fn of veh.handlers.onCollide) { try { fn(force); } catch (e) {} }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Применить к узлам.
|
|
||||||
veh.chassisNode.position.copyFrom(veh.pos);
|
|
||||||
veh.chassisNode.rotation.y = veh.yaw;
|
|
||||||
// Колёса: передние доворачивают, все катятся.
|
|
||||||
const roll = (veh.speed * dt) / 0.4;
|
|
||||||
for (const w of veh.wheels) {
|
|
||||||
if (w.isFront) w.node.rotation.y = veh.steerAngle;
|
|
||||||
w.node.rotation.x = (w.node.rotation.x + roll) % (Math.PI * 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Math.abs(veh.speed - prevSpeed) > 0.01) {
|
|
||||||
for (const fn of veh.handlers.onSpeedChange) { try { fn(Math.abs(veh.speed)); } catch (e) {} }
|
|
||||||
}
|
|
||||||
// Падение в бездну — сигнал PlayerController высадить + респавн.
|
|
||||||
if (veh.pos.y < -25) return { fellOut: true };
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Текущая скорость машины в м/с (для спидометра). */
|
|
||||||
speedOf(veh) { return veh ? Math.abs(veh.speed) : 0; }
|
|
||||||
|
|
||||||
applyImpulse(veh, v) {
|
|
||||||
if (!veh || !v) return;
|
|
||||||
// Простой импульс: вертикальная составляющая в vy, горизонтальная в speed по направлению.
|
|
||||||
if (Number.isFinite(v.y)) veh.vy += Number(v.y);
|
|
||||||
const dir = new Vector3(Math.sin(veh.yaw), 0, Math.cos(veh.yaw));
|
|
||||||
const horiz = (Number(v.x) || 0) * dir.x + (Number(v.z) || 0) * dir.z;
|
|
||||||
veh.speed += horiz;
|
|
||||||
}
|
|
||||||
|
|
||||||
dispose() {
|
|
||||||
for (const veh of this.vehicles.values()) {
|
|
||||||
try {
|
|
||||||
if (veh.bodyInstanceId != null) this._models.removeInstance?.(veh.bodyInstanceId);
|
|
||||||
for (const w of veh.wheels) this._models.removeInstance?.(w.instanceId);
|
|
||||||
veh.chassisNode?.dispose?.();
|
|
||||||
} catch (e) { /* ignore */ }
|
|
||||||
}
|
|
||||||
this.vehicles.clear();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -90,17 +90,6 @@ export class WeaponSystem {
|
|||||||
if (e.button !== 0) return;
|
if (e.button !== 0) return;
|
||||||
// Если UI-режим курсора — не стреляем (мышь работает по GUI)
|
// Если UI-режим курсора — не стреляем (мышь работает по GUI)
|
||||||
if (this.scene3d?.player?.isUiCursorMode?.()) return;
|
if (this.scene3d?.player?.isUiCursorMode?.()) return;
|
||||||
// Свободный курсор (нет pointer-lock, обычно 3-е лицо) → стрелять туда,
|
|
||||||
// куда кликнули, а не в центр камеры.
|
|
||||||
if (document.pointerLockElement !== canvas) {
|
|
||||||
const rect = canvas.getBoundingClientRect();
|
|
||||||
const cx = (e.clientX != null ? e.clientX : 0) - rect.left;
|
|
||||||
const cy = (e.clientY != null ? e.clientY : 0) - rect.top;
|
|
||||||
if (cx >= 0 && cy >= 0 && cx <= rect.width && cy <= rect.height) {
|
|
||||||
this.setAimScreenPoint(cx * (canvas.width / rect.width),
|
|
||||||
cy * (canvas.height / rect.height));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this._mouseDown = true;
|
this._mouseDown = true;
|
||||||
this._tryFire();
|
this._tryFire();
|
||||||
};
|
};
|
||||||
@ -108,26 +97,14 @@ export class WeaponSystem {
|
|||||||
if (e.button !== 0) return;
|
if (e.button !== 0) return;
|
||||||
this._mouseDown = false;
|
this._mouseDown = false;
|
||||||
};
|
};
|
||||||
const onMove = (e) => {
|
|
||||||
if (!this._mouseDown) return;
|
|
||||||
if (document.pointerLockElement === canvas) return;
|
|
||||||
const rect = canvas.getBoundingClientRect();
|
|
||||||
const cx = (e.clientX != null ? e.clientX : 0) - rect.left;
|
|
||||||
const cy = (e.clientY != null ? e.clientY : 0) - rect.top;
|
|
||||||
if (cx >= 0 && cy >= 0 && cx <= rect.width && cy <= rect.height) {
|
|
||||||
this._holdAim = { x: cx * (canvas.width / rect.width), y: cy * (canvas.height / rect.height) };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const onKey = (e) => {
|
const onKey = (e) => {
|
||||||
if (e.code === 'KeyR') this.reload();
|
if (e.code === 'KeyR') this.reload();
|
||||||
};
|
};
|
||||||
canvas.addEventListener('mousedown', onDown);
|
canvas.addEventListener('mousedown', onDown);
|
||||||
window.addEventListener('mouseup', onUp);
|
window.addEventListener('mouseup', onUp);
|
||||||
window.addEventListener('mousemove', onMove);
|
|
||||||
window.addEventListener('keydown', onKey);
|
window.addEventListener('keydown', onKey);
|
||||||
this._listeners.push({ target: canvas, type: 'mousedown', fn: onDown });
|
this._listeners.push({ target: canvas, type: 'mousedown', fn: onDown });
|
||||||
this._listeners.push({ target: window, type: 'mouseup', fn: onUp });
|
this._listeners.push({ target: window, type: 'mouseup', fn: onUp });
|
||||||
this._listeners.push({ target: window, type: 'mousemove', fn: onMove });
|
|
||||||
this._listeners.push({ target: window, type: 'keydown', fn: onKey });
|
this._listeners.push({ target: window, type: 'keydown', fn: onKey });
|
||||||
|
|
||||||
// Регистрируем перед-кадровый хук для авто-стрельбы (если auto=true)
|
// Регистрируем перед-кадровый хук для авто-стрельбы (если auto=true)
|
||||||
@ -606,10 +583,7 @@ export class WeaponSystem {
|
|||||||
// (для tap-to-shoot на мобиле). Точка применяется один раз.
|
// (для tap-to-shoot на мобиле). Точка применяется один раз.
|
||||||
let hit = null;
|
let hit = null;
|
||||||
let ray;
|
let ray;
|
||||||
let aim = this._aimScreenPoint;
|
const aim = this._aimScreenPoint;
|
||||||
if (!aim && this._holdAim && document.pointerLockElement !== this.scene3d?.canvas) {
|
|
||||||
aim = this._holdAim;
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
if (aim) {
|
if (aim) {
|
||||||
ray = this.scene.createPickingRay(aim.x, aim.y, null, camera);
|
ray = this.scene.createPickingRay(aim.x, aim.y, null, camera);
|
||||||
|
|||||||
@ -1,337 +0,0 @@
|
|||||||
/**
|
|
||||||
* LuaSharedSandbox (v3, main-thread) — wasmoon-VM работает в MAIN потоке,
|
|
||||||
* без Web Worker. Это позволяет:
|
|
||||||
* - Видеть точные Lua-ошибки в DevTools (через console.error)
|
|
||||||
* - Использовать debugger / breakpoints прямо в RobloxShim.js
|
|
||||||
* - Не возиться с молчаливыми Worker-падениями
|
|
||||||
*
|
|
||||||
* Цена: тяжёлые Lua-скрипты могут блокировать UI. Для KillBrick-style
|
|
||||||
* скриптов это нестрашно — они быстрые.
|
|
||||||
*
|
|
||||||
* API совместим с ScriptSandbox: setOnCommand / sendEvent / sendGlobalEvent /
|
|
||||||
* sendSceneSnapshot / sendGuiSnapshot / sendDataSnapshot / sendSkinsSnapshot /
|
|
||||||
* sendTerrainHeightmap / stop / tick / target.
|
|
||||||
*
|
|
||||||
* Что добавлено сверх ScriptSandbox:
|
|
||||||
* - addScript(id, code, target) — добавить скрипт в общий VM. Можно
|
|
||||||
* до или после start().
|
|
||||||
* - start() — асинхронен (createEngine), но возвращает сразу. После init
|
|
||||||
* стартует main loop (Heartbeat + scheduler).
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { LuaFactory } from 'wasmoon';
|
|
||||||
import { registerRobloxShim } from './RobloxShim.js';
|
|
||||||
|
|
||||||
export class LuaSharedSandbox {
|
|
||||||
constructor() {
|
|
||||||
this.vm = null;
|
|
||||||
this.api = null;
|
|
||||||
this._onCommand = null;
|
|
||||||
this._isReady = false;
|
|
||||||
this._isStopped = false;
|
|
||||||
this._isKickedOff = false;
|
|
||||||
this._pendingScripts = []; // [{id, code, target, name}]
|
|
||||||
this._scriptsById = new Map();
|
|
||||||
this._scenes = null;
|
|
||||||
this._guiTree = null;
|
|
||||||
this._loopHandle = null;
|
|
||||||
this._lastTickAt = 0;
|
|
||||||
// Маркер для GameRuntime.routeEvent — этот sandbox принимает все
|
|
||||||
// события и сам маршрутизирует через shim.fireTargetEvent.
|
|
||||||
this._luaShared = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
setOnCommand(cb) { this._onCommand = cb; }
|
|
||||||
|
|
||||||
get target() { return null; }
|
|
||||||
tick(_dt, _state) { /* no-op: main-loop запускается изнутри (setInterval) */ }
|
|
||||||
|
|
||||||
addScript(id, code, target, name, extra) {
|
|
||||||
const entry = {
|
|
||||||
id: String(id || `lua_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`),
|
|
||||||
code: String(code || ''),
|
|
||||||
target: target == null ? null : target,
|
|
||||||
name: name || null,
|
|
||||||
toolName: extra?.toolName || null,
|
|
||||||
};
|
|
||||||
this._scriptsById.set(entry.id, entry);
|
|
||||||
if (!this._isKickedOff) {
|
|
||||||
this._pendingScripts.push(entry);
|
|
||||||
} else {
|
|
||||||
this._startSingleScript(entry);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
removeScript(id) {
|
|
||||||
this._scriptsById.delete(String(id));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Стартует VM, регистрирует shim, запускает main-loop. */
|
|
||||||
start() {
|
|
||||||
if (this.vm || this._isStopped) return;
|
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.log('[LuaSharedSandbox v3] starting Lua VM in main thread...');
|
|
||||||
this._initAsync().catch((err) => {
|
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.error('[LuaSharedSandbox] FATAL init error:', err);
|
|
||||||
this._emit('log', { level: 'error', text: `Lua-runtime init: ${err?.message || err}` });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async _initAsync() {
|
|
||||||
const factory = new LuaFactory();
|
|
||||||
this.vm = await factory.createEngine({ openStandardLibs: true });
|
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.log('[LuaSharedSandbox] VM created. Registering Roblox shim...');
|
|
||||||
|
|
||||||
// Адаптер для shim'а: он ожидает send(cmd, payload), getSceneSnapshot, getGuiTree, scheduleWait.
|
|
||||||
const send = (cmd, payload) => this._emit(cmd, payload);
|
|
||||||
|
|
||||||
this.api = registerRobloxShim(this.vm, {
|
|
||||||
send,
|
|
||||||
getSceneSnapshot: () => this._scenes,
|
|
||||||
getGuiTree: () => this._guiTree,
|
|
||||||
scheduleWait: () => null,
|
|
||||||
});
|
|
||||||
|
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.log('[LuaSharedSandbox] Shim registered. api keys:', Object.keys(this.api || {}));
|
|
||||||
|
|
||||||
// Применим snapshot если он есть
|
|
||||||
if (this._scenes && this.api?.onSceneSnapshot) {
|
|
||||||
try { this.api.onSceneSnapshot(this._scenes); } catch (e) {
|
|
||||||
console.error('[LuaSharedSandbox] onSceneSnapshot:', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this._isReady = true;
|
|
||||||
this._kickoff();
|
|
||||||
}
|
|
||||||
|
|
||||||
_kickoff() {
|
|
||||||
if (this._isKickedOff || this._isStopped) return;
|
|
||||||
this._isKickedOff = true;
|
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.log(`[LuaSharedSandbox] kickoff: starting ${this._pendingScripts.length} scripts`);
|
|
||||||
const pending = this._pendingScripts;
|
|
||||||
this._pendingScripts = [];
|
|
||||||
// Запускаем main-loop сразу — он начнёт tick'ать как только будут coroutines.
|
|
||||||
this._lastTickAt = performance.now();
|
|
||||||
this._startMainLoop();
|
|
||||||
// Init батчами по 5 с задержкой 20мс между ними, чтобы UI отзывался.
|
|
||||||
const BATCH_SIZE = 5;
|
|
||||||
let idx = 0;
|
|
||||||
const initBatch = () => {
|
|
||||||
if (this._isStopped) return;
|
|
||||||
const end = Math.min(idx + BATCH_SIZE, pending.length);
|
|
||||||
for (let i = idx; i < end; i++) {
|
|
||||||
try { this._startSingleScript(pending[i]); }
|
|
||||||
catch (e) {
|
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.error('[LuaSharedSandbox] init batch err:', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
idx = end;
|
|
||||||
if (idx < pending.length) {
|
|
||||||
setTimeout(initBatch, 20);
|
|
||||||
} else {
|
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.log(`[LuaSharedSandbox] all ${pending.length} scripts kicked off`);
|
|
||||||
// После того как все скрипты подключили хендлеры — фейрим
|
|
||||||
// events для уже существующих сущностей. Roblox-конвенция:
|
|
||||||
// если игрок уже на сервере когда скрипт подключается,
|
|
||||||
// Players.PlayerAdded не сработает повторно. Юзеру нужно
|
|
||||||
// делать ручной обход GetPlayers() — но это редко кто помнит.
|
|
||||||
// Мы дублируем событие через короткую задержку.
|
|
||||||
setTimeout(() => {
|
|
||||||
try {
|
|
||||||
if (this.api?.fireExistingPlayers) {
|
|
||||||
this.api.fireExistingPlayers();
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('[LuaSharedSandbox] fireExistingPlayers failed:', e);
|
|
||||||
}
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
setTimeout(initBatch, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
_startSingleScript(entry) {
|
|
||||||
if (!this.vm || !entry || typeof entry.code !== 'string') return;
|
|
||||||
let primId = null;
|
|
||||||
if (typeof entry.target === 'number') primId = entry.target;
|
|
||||||
else if (entry.target && typeof entry.target === 'object') {
|
|
||||||
if (entry.target.kind === 'primitive') primId = entry.target.id ?? entry.target.ref;
|
|
||||||
}
|
|
||||||
const safeId = entry.id.replace(/[^a-zA-Z0-9_]/g, '_');
|
|
||||||
const scriptName = entry.name || `Script_${safeId}`;
|
|
||||||
// Скрипт оборачиваем в coroutine — это позволяет task.wait через yield.
|
|
||||||
// Резюмим coroutine из main-loop когда наступило время.
|
|
||||||
// Регистрируем coroutine в __rbxl_coroutines с id для возобновления.
|
|
||||||
// Скрипт оборачиваем в coroutine. task.wait()→coroutine.yield(sec) возвращает
|
|
||||||
// delay из resume → планируем следующий resume через scheduleResume.
|
|
||||||
// Fallback Parent: если скрипт связан с Tool (по имени Tool из metadata) —
|
|
||||||
// подсовываем виртуальный Tool как script.Parent. Иначе primitive по id,
|
|
||||||
// иначе workspace.
|
|
||||||
let parentExpr;
|
|
||||||
if (entry.toolName) {
|
|
||||||
// Tool создаётся в shim как Instance.new('Tool'). По имени достаём.
|
|
||||||
// Если не нашли — fallback на новый Tool того же имени.
|
|
||||||
const safeName = JSON.stringify(entry.toolName);
|
|
||||||
parentExpr = `(function()
|
|
||||||
local existing = __rbxl_get_tool_by_name(${safeName})
|
|
||||||
if existing then return existing end
|
|
||||||
local t = Instance.new("Tool")
|
|
||||||
t.Name = ${safeName}
|
|
||||||
return t
|
|
||||||
end)()`;
|
|
||||||
} else if (primId != null) {
|
|
||||||
parentExpr = `(__rbxl_get_part_by_id(${Number(primId)}) or workspace)`;
|
|
||||||
} else {
|
|
||||||
parentExpr = 'workspace';
|
|
||||||
}
|
|
||||||
const wrapped = `
|
|
||||||
do
|
|
||||||
-- Если parentExpr вернул primitive — у него уже есть :FindFirstChild и пр.
|
|
||||||
-- Если ничего не вернёт — workspace (всегда валидный).
|
|
||||||
-- script.Parent.Parent (Tool.Parent = StarterPack / Backpack / workspace).
|
|
||||||
local _scriptParent = ${parentExpr}
|
|
||||||
if _scriptParent == nil then _scriptParent = workspace end
|
|
||||||
if _scriptParent.Parent == nil then _scriptParent.Parent = workspace end
|
|
||||||
local script = setmetatable({
|
|
||||||
Name = ${JSON.stringify(scriptName)},
|
|
||||||
Parent = _scriptParent,
|
|
||||||
ClassName = "Script",
|
|
||||||
Disabled = false,
|
|
||||||
Source = nil,
|
|
||||||
}, {
|
|
||||||
-- Любой доступ к несуществующему полю → workspace
|
|
||||||
-- (на случай script.Foo:Bar() в старом коде)
|
|
||||||
__index = function(t, k)
|
|
||||||
if k == "FindFirstChild" or k == "WaitForChild" or k == "GetChildren" then
|
|
||||||
return function() return nil end
|
|
||||||
end
|
|
||||||
return workspace[k]
|
|
||||||
end,
|
|
||||||
})
|
|
||||||
local co = coroutine.create(function()
|
|
||||||
-- WATCHDOG: каждые 100000 инструкций — yield 1 кадр.
|
|
||||||
-- НЕ оборачиваем в pcall — внутри C-call boundary yield
|
|
||||||
-- упадёт ошибкой, что прервёт скрипт. Это лучше чем виснуть.
|
|
||||||
debug.sethook(function()
|
|
||||||
coroutine.yield(0.016)
|
|
||||||
end, "", 20000)
|
|
||||||
-- pcall защищает от runtime-ошибок которые иначе крашат
|
|
||||||
-- coroutine и могут повредить WASM-стейт. Возвраты
|
|
||||||
-- handler'а намеренно поглощаются.
|
|
||||||
local ok_, err_ = pcall(function()
|
|
||||||
${entry.code}
|
|
||||||
end)
|
|
||||||
if not ok_ then
|
|
||||||
__rbxl_send_error(${JSON.stringify(entry.id)}, tostring(err_))
|
|
||||||
end
|
|
||||||
end)
|
|
||||||
__rbxl_register_coroutine(${JSON.stringify(entry.id)}, co)
|
|
||||||
local ok, ret = coroutine.resume(co)
|
|
||||||
if not ok then
|
|
||||||
__rbxl_send_error(${JSON.stringify(entry.id)}, tostring(ret))
|
|
||||||
__rbxl_unregister_coroutine(${JSON.stringify(entry.id)})
|
|
||||||
elseif type(ret) == 'number' then
|
|
||||||
-- скрипт yield'нул с delay (через task.wait) — планируем resume
|
|
||||||
__rbxl_schedule_resume(${JSON.stringify(entry.id)}, ret)
|
|
||||||
elseif coroutine.status(co) == 'dead' then
|
|
||||||
__rbxl_unregister_coroutine(${JSON.stringify(entry.id)})
|
|
||||||
end
|
|
||||||
end
|
|
||||||
`;
|
|
||||||
try {
|
|
||||||
this.vm.doStringSync(wrapped);
|
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.log(`[LuaSharedSandbox] script ${entry.id} initialized OK`);
|
|
||||||
} catch (err) {
|
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.error(`[LuaSharedSandbox] script ${entry.id} init FAILED:`, err);
|
|
||||||
this._emit('log', { level: 'error', text: `[Lua ${entry.id}] ${err?.message || err}` });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_startMainLoop() {
|
|
||||||
const tick = () => {
|
|
||||||
if (this._isStopped) return;
|
|
||||||
try {
|
|
||||||
const now = performance.now();
|
|
||||||
const dt = Math.min(0.1, (now - this._lastTickAt) / 1000);
|
|
||||||
this._lastTickAt = now;
|
|
||||||
if (this.api?.tickScheduler) this.api.tickScheduler(dt);
|
|
||||||
if (this.api?.fireHeartbeat) this.api.fireHeartbeat(dt);
|
|
||||||
} catch (e) {
|
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.error('[LuaSharedSandbox tick]', e);
|
|
||||||
}
|
|
||||||
this._loopHandle = setTimeout(tick, 16);
|
|
||||||
};
|
|
||||||
this._loopHandle = setTimeout(tick, 16);
|
|
||||||
}
|
|
||||||
|
|
||||||
_emit(cmd, payload) {
|
|
||||||
if (typeof this._onCommand === 'function') {
|
|
||||||
try { this._onCommand({ cmd, payload }); } catch (_) {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ----- API совместимый с ScriptSandbox -----
|
|
||||||
sendEvent(payload) {
|
|
||||||
if (!this.api?.fireTargetEvent || !this._isReady) return;
|
|
||||||
try { this.api.fireTargetEvent(payload); } catch (e) {
|
|
||||||
console.error('[LuaSharedSandbox] sendEvent:', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
sendGlobalEvent(payload) {
|
|
||||||
if (!this.api?.fireGlobalEvent || !this._isReady) return;
|
|
||||||
try { this.api.fireGlobalEvent(payload); } catch (e) {
|
|
||||||
console.error('[LuaSharedSandbox] sendGlobalEvent:', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
sendSceneSnapshot(snapshot) {
|
|
||||||
this._scenes = snapshot;
|
|
||||||
if (this.api?.onSceneSnapshot && this._isReady) {
|
|
||||||
try { this.api.onSceneSnapshot(snapshot); } catch (e) {
|
|
||||||
console.error('[LuaSharedSandbox] onSceneSnapshot:', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
sendGuiSnapshot(snapshot) {
|
|
||||||
this._guiTree = snapshot;
|
|
||||||
if (this.api?.onGuiSnapshot && this._isReady) {
|
|
||||||
try { this.api.onGuiSnapshot(snapshot); } catch (_) {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
sendDataSnapshot(snapshot) {
|
|
||||||
if (this.api?.onDataSnapshot && this._isReady) {
|
|
||||||
try { this.api.onDataSnapshot(snapshot); } catch (_) {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
sendSkinsSnapshot(_) { /* no-op для Этапа 3 */ }
|
|
||||||
sendTerrainHeightmap(_) { /* no-op */ }
|
|
||||||
|
|
||||||
stop() {
|
|
||||||
this._isStopped = true;
|
|
||||||
if (this._loopHandle) {
|
|
||||||
clearTimeout(this._loopHandle);
|
|
||||||
this._loopHandle = null;
|
|
||||||
}
|
|
||||||
if (this.vm) {
|
|
||||||
try { this.vm.global.close(); } catch (_) {}
|
|
||||||
this.vm = null;
|
|
||||||
}
|
|
||||||
this.api = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default LuaSharedSandbox;
|
|
||||||
File diff suppressed because it is too large
Load Diff
@ -1,210 +0,0 @@
|
|||||||
/**
|
|
||||||
* rbxl-lua-integration.js — вспомогательные функции для импорта .rbxl-карт.
|
|
||||||
*
|
|
||||||
* Старый отдельный Worker-based runtime удалён (2026-06-08): импортированные
|
|
||||||
* Lua-скрипты теперь идут через тот же LuaSharedSandbox что и user-Lua
|
|
||||||
* (см. GameRuntime.start()). Этот файл оставлен только для:
|
|
||||||
* - unpackRobloxLuaCode() — распаковка Lua из JS-комментария-обёртки;
|
|
||||||
* - handleLuaCommand() — обработка partSet/sceneCreate/sceneDelete/playerCmd
|
|
||||||
* команд от Lua-VM в BabylonScene.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** Распаковка lua_source из packed-кода. */
|
|
||||||
export function unpackRobloxLuaCode(code) {
|
|
||||||
const openTag = '/[*] lua_source:\n'.replace('[*]', '*');
|
|
||||||
const i = code.indexOf(openTag);
|
|
||||||
if (i < 0) return null;
|
|
||||||
const start = i + openTag.length;
|
|
||||||
const closeIdx = code.lastIndexOf('\n*' + '/');
|
|
||||||
if (closeIdx < start) return null;
|
|
||||||
return code.slice(start, closeIdx);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Парсит JSON-метадату из 2-й строки packed-кода (`// {"roblox_class":..., "enabled": true}`). */
|
|
||||||
export function parseRobloxLuaMeta(code) {
|
|
||||||
if (typeof code !== 'string') return null;
|
|
||||||
const lines = code.split('\n');
|
|
||||||
if (lines.length < 2) return null;
|
|
||||||
const metaLine = lines[1];
|
|
||||||
if (!metaLine.startsWith('// ')) return null;
|
|
||||||
try {
|
|
||||||
return JSON.parse(metaLine.slice(3));
|
|
||||||
} catch (_) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Сцена → snap для shim'а (workspace:GetChildren). */
|
|
||||||
export function buildLuaSceneSnap(primitives) {
|
|
||||||
const out = { primitives: {} };
|
|
||||||
if (!Array.isArray(primitives)) return out;
|
|
||||||
for (const p of primitives) {
|
|
||||||
out.primitives[p.id] = {
|
|
||||||
id: p.id, type: p.type, name: p.name,
|
|
||||||
x: p.x, y: p.y, z: p.z,
|
|
||||||
sx: p.sx, sy: p.sy, sz: p.sz,
|
|
||||||
color: p.color, material: p.material,
|
|
||||||
anchored: !!p.anchored, canCollide: p.canCollide !== false,
|
|
||||||
opacity: typeof p.opacity === 'number' ? p.opacity : 1,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GUI-tree для shim'а. Mapping origin → __roblox_class.
|
|
||||||
* scene.gui — массив элементов с {id, type, name, parentId, ...origin}.
|
|
||||||
* Возвращаем массив сохраняя порядок parent → child (важно для tree-сборки).
|
|
||||||
*/
|
|
||||||
export function buildLuaGuiTree(guiElements) {
|
|
||||||
if (!Array.isArray(guiElements)) return [];
|
|
||||||
const out = [];
|
|
||||||
for (const el of guiElements) {
|
|
||||||
// origin = 'roblox-textbutton' → 'TextButton'
|
|
||||||
let rblClass = 'Frame';
|
|
||||||
const origin = el.origin || '';
|
|
||||||
if (origin.startsWith('roblox-')) {
|
|
||||||
const tail = origin.slice(7);
|
|
||||||
rblClass = tail.charAt(0).toUpperCase() + tail.slice(1);
|
|
||||||
// Camel-case "textbutton" → "TextButton"
|
|
||||||
if (rblClass.toLowerCase() === 'textbutton') rblClass = 'TextButton';
|
|
||||||
else if (rblClass.toLowerCase() === 'textlabel') rblClass = 'TextLabel';
|
|
||||||
else if (rblClass.toLowerCase() === 'imagebutton') rblClass = 'ImageButton';
|
|
||||||
else if (rblClass.toLowerCase() === 'imagelabel') rblClass = 'ImageLabel';
|
|
||||||
else if (rblClass.toLowerCase() === 'textbox') rblClass = 'TextBox';
|
|
||||||
else if (rblClass.toLowerCase() === 'scrollingframe') rblClass = 'ScrollingFrame';
|
|
||||||
else if (rblClass.toLowerCase() === 'frame') rblClass = 'Frame';
|
|
||||||
} else {
|
|
||||||
// Если origin не задан — гадаем по type
|
|
||||||
const t = el.type;
|
|
||||||
if (t === 'button') rblClass = 'TextButton';
|
|
||||||
else if (t === 'text') rblClass = 'TextLabel';
|
|
||||||
else if (t === 'image') rblClass = 'ImageLabel';
|
|
||||||
else if (t === 'textbox') rblClass = 'TextBox';
|
|
||||||
}
|
|
||||||
out.push({
|
|
||||||
id: el.id,
|
|
||||||
name: el.name || rblClass,
|
|
||||||
parentId: el.parentId || null,
|
|
||||||
visible: el.visible !== false,
|
|
||||||
text: el.text || '',
|
|
||||||
__roblox_class: rblClass,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Обработка IPC команд от worker'а — мапим на действия в Babylon-сцене.
|
|
||||||
*/
|
|
||||||
export function handleLuaCommand(_scriptId, cmd, payload, runtime) {
|
|
||||||
if (cmd === 'log') {
|
|
||||||
const fn = payload?.level === 'error' ? console.error
|
|
||||||
: payload?.level === 'warn' ? console.warn : console.log;
|
|
||||||
fn('[rbxl-lua]', payload?.text || '');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (cmd === 'partSet') {
|
|
||||||
const pm = runtime.scene3d?.primitiveManager;
|
|
||||||
if (!pm) {
|
|
||||||
console.warn('[partSet] no primitiveManager. scene3d=', !!runtime.scene3d);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const primId = payload?.primId;
|
|
||||||
const prop = payload?.prop;
|
|
||||||
const value = payload?.value;
|
|
||||||
const patch = {};
|
|
||||||
if (prop === 'position' && value) {
|
|
||||||
patch.x = value.x; patch.y = value.y; patch.z = value.z;
|
|
||||||
} else if (prop === 'cframe' && value) {
|
|
||||||
patch.x = value.x; patch.y = value.y; patch.z = value.z;
|
|
||||||
patch.rotationX = value.rx; patch.rotationY = value.ry; patch.rotationZ = value.rz;
|
|
||||||
} else if (prop === 'size' && value) {
|
|
||||||
patch.sx = value.sx; patch.sy = value.sy; patch.sz = value.sz;
|
|
||||||
} else if (prop === 'color') patch.color = value;
|
|
||||||
else if (prop === 'material') patch.material = value;
|
|
||||||
else if (prop === 'anchored') patch.anchored = value;
|
|
||||||
else if (prop === 'canCollide') patch.canCollide = value;
|
|
||||||
else if (prop === 'opacity') patch.opacity = value;
|
|
||||||
try {
|
|
||||||
if (typeof pm.updateInstance === 'function') pm.updateInstance(primId, patch);
|
|
||||||
else if (typeof pm.applyPatch === 'function') pm.applyPatch(primId, patch);
|
|
||||||
else if (typeof pm.update === 'function') pm.update(primId, patch);
|
|
||||||
} catch (e) {
|
|
||||||
console.error('[partSet] updateInstance failed:', e);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (cmd === 'sceneCreate') {
|
|
||||||
// Lua: Instance.new("Part") + part.Parent = workspace → создание примитива.
|
|
||||||
// payload: { primId (предложенный id), type, x, y, z, sx, sy, sz, color, anchored }
|
|
||||||
try {
|
|
||||||
const pm = runtime.scene3d?.primitiveManager;
|
|
||||||
if (!pm || typeof pm.addInstance !== 'function') return;
|
|
||||||
const opts = {
|
|
||||||
id: payload?.primId,
|
|
||||||
x: payload?.x || 0, y: payload?.y || 0, z: payload?.z || 0,
|
|
||||||
sx: payload?.sx || 1, sy: payload?.sy || 1, sz: payload?.sz || 1,
|
|
||||||
color: payload?.color,
|
|
||||||
anchored: payload?.anchored !== false,
|
|
||||||
canCollide: payload?.canCollide !== false,
|
|
||||||
};
|
|
||||||
pm.addInstance(payload?.type || 'cube', opts);
|
|
||||||
// Если unanchored — регистрируем в физике на лету, иначе он не падает.
|
|
||||||
if (opts.anchored === false) {
|
|
||||||
try {
|
|
||||||
const dm = runtime.scene3d?.dynamics;
|
|
||||||
const data = pm.instances?.get?.(opts.id);
|
|
||||||
if (dm && data && typeof dm.registerPrimitive === 'function') {
|
|
||||||
dm.registerPrimitive(data);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('[sceneCreate] registerPrimitive failed', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('[sceneCreate]', e);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (cmd === 'sceneDelete') {
|
|
||||||
// Lua: part:Destroy() → удаление примитива.
|
|
||||||
try {
|
|
||||||
const pm = runtime.scene3d?.primitiveManager;
|
|
||||||
if (!pm || typeof pm.removeInstance !== 'function') return;
|
|
||||||
const id = payload?.primId;
|
|
||||||
if (id != null) pm.removeInstance(Number(id));
|
|
||||||
} catch (e) {
|
|
||||||
console.error('[sceneDelete]', e);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (cmd === 'partVel') {
|
|
||||||
try {
|
|
||||||
const pm = runtime.scene3d?.primitiveManager;
|
|
||||||
if (pm && typeof pm.setVelocity === 'function') {
|
|
||||||
pm.setVelocity(payload.primId, payload.vx, payload.vy, payload.vz);
|
|
||||||
}
|
|
||||||
} catch (e) {}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (cmd === 'playerCmd') {
|
|
||||||
try {
|
|
||||||
const p = runtime.game?.player;
|
|
||||||
if (!p) return;
|
|
||||||
const method = payload?.method;
|
|
||||||
const args = payload?.args || [];
|
|
||||||
if (method === 'teleport') p.teleport && p.teleport(args[0], args[1], args[2]);
|
|
||||||
else if (method === 'setWalkSpeed') p.setWalkSpeed && p.setWalkSpeed(args[0]);
|
|
||||||
else if (method === 'setJumpPower') p.setJumpPower && p.setJumpPower(args[0]);
|
|
||||||
else if (method === 'setHealth') p.setHealth && p.setHealth(args[0]);
|
|
||||||
else if (method === 'die') p.die && p.die();
|
|
||||||
else if (method === 'damage' || method === 'takeDamage') p.damage && p.damage(args[0]);
|
|
||||||
} catch (e) {}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (cmd === 'guiUpdate') {
|
|
||||||
// TODO: scripts setting Visible/Text/Color on GUI → передать в GuiManager
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,243 +0,0 @@
|
|||||||
/**
|
|
||||||
* rbxl-lua-integration.test.js — реалистичные Roblox-сниппеты из obby/simulator карт.
|
|
||||||
*/
|
|
||||||
import { LuaFactory } from 'wasmoon';
|
|
||||||
import { registerRobloxApi } from '../src/engine/roblox-shim.js';
|
|
||||||
import { RobloxScheduler } from '../src/engine/roblox-scheduler.js';
|
|
||||||
import { installRobloxServices } from '../src/engine/roblox-services.js';
|
|
||||||
import { RobloxTweenManager } from '../src/engine/roblox-tween.js';
|
|
||||||
import { RobloxPhysicsManager } from '../src/engine/roblox-physics.js';
|
|
||||||
|
|
||||||
function makeScene() {
|
|
||||||
return {
|
|
||||||
primitives: {
|
|
||||||
10: { id: 10, type: 'cube', name: 'KillPart', x: 5, y: 1, z: 0, sx: 4, sy: 1, sz: 4,
|
|
||||||
color: '#ff0000', material: 'neon', anchored: true, canCollide: true, opacity: 1 },
|
|
||||||
11: { id: 11, type: 'cube', name: 'WinPart', x: 30, y: 1, z: 0, sx: 4, sy: 1, sz: 4,
|
|
||||||
color: '#00ff00', material: 'neon', anchored: true, canCollide: true, opacity: 1 },
|
|
||||||
12: { id: 12, type: 'cube', name: 'Conveyor', x: 15, y: 1, z: 0, sx: 8, sy: 0.5, sz: 4,
|
|
||||||
color: '#888888', material: 'metal', anchored: true, canCollide: true, opacity: 1 },
|
|
||||||
13: { id: 13, type: 'cube', name: 'Door', x: 20, y: 3, z: 0, sx: 2, sy: 6, sz: 4,
|
|
||||||
color: '#a0522d', material: 'matte', anchored: true, canCollide: true, opacity: 1 },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const STORE = new Map();
|
|
||||||
|
|
||||||
async function run(luaSource, targetPrimId = 10, ticks = []) {
|
|
||||||
const factory = new LuaFactory();
|
|
||||||
const lua = await factory.createEngine();
|
|
||||||
const sent = [];
|
|
||||||
const send = (cmd, payload) => sent.push({ cmd, payload });
|
|
||||||
let playerState = { x: 0, y: 5, z: 0, hp: 100 };
|
|
||||||
registerRobloxApi(lua, { getSceneSnap: makeScene, targetPrimitiveId: targetPrimId, send });
|
|
||||||
const sched = new RobloxScheduler(lua);
|
|
||||||
sched.install();
|
|
||||||
installRobloxServices(lua, {
|
|
||||||
send,
|
|
||||||
getPlayerState: () => playerState,
|
|
||||||
loadSave: (k) => STORE.get(k),
|
|
||||||
saveSave: (k, v) => STORE.set(k, v),
|
|
||||||
removeSave: (k) => STORE.delete(k),
|
|
||||||
});
|
|
||||||
const tween = new RobloxTweenManager();
|
|
||||||
tween.install(lua);
|
|
||||||
const phys = new RobloxPhysicsManager(send);
|
|
||||||
phys.install(lua);
|
|
||||||
|
|
||||||
await sched.spawnMain(luaSource);
|
|
||||||
for (const dt of ticks) {
|
|
||||||
await sched.tick(dt);
|
|
||||||
tween.tick(dt);
|
|
||||||
phys.tick(dt);
|
|
||||||
}
|
|
||||||
lua.global.close();
|
|
||||||
return {
|
|
||||||
logs: sent.filter(s => s.cmd === 'log').map(s => s.payload),
|
|
||||||
partSets: sent.filter(s => s.cmd === 'partSet').map(s => s.payload),
|
|
||||||
partVels: sent.filter(s => s.cmd === 'partVel').map(s => s.payload),
|
|
||||||
playerCmds: sent.filter(s => s.cmd === 'playerCmd').map(s => s.payload),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const TESTS = [
|
|
||||||
{
|
|
||||||
name: 'KillBrick (Touched → Humanoid.Health = 0)',
|
|
||||||
lua: `
|
|
||||||
local part = script.Parent
|
|
||||||
part.Touched:Connect(function(hit)
|
|
||||||
local hum = hit.Parent and hit.Parent:FindFirstChild("Humanoid")
|
|
||||||
if hum then hum.Health = 0 end
|
|
||||||
end)
|
|
||||||
print("kill brick armed")
|
|
||||||
`,
|
|
||||||
ticks: [],
|
|
||||||
check: (r) => r.logs.some(l => l.text === 'kill brick armed'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'WalkSpeed boost через trigger',
|
|
||||||
lua: `
|
|
||||||
local h = game:GetService("Players").LocalPlayer.Character.Humanoid
|
|
||||||
h.WalkSpeed = 32
|
|
||||||
print("speed boosted to", h.WalkSpeed)
|
|
||||||
`,
|
|
||||||
check: (r) => r.playerCmds.some(c => c.method === 'setWalkSpeed' && c.args[0] === 32)
|
|
||||||
&& r.logs.some(l => l.text.includes('speed boosted')),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Door open: TweenService двигает дверь вверх',
|
|
||||||
lua: `
|
|
||||||
local door = workspace:FindFirstChild("Door")
|
|
||||||
local TS = game:GetService("TweenService")
|
|
||||||
local goal = { Position = Vector3.new(door.Position.X, door.Position.Y + 10, door.Position.Z) }
|
|
||||||
local info = TweenInfo.new(1, Enum.EasingStyle.Linear, Enum.EasingDirection.Out)
|
|
||||||
local tw = TS:Create(door, info, goal)
|
|
||||||
tw:Play()
|
|
||||||
print("door opening")
|
|
||||||
`,
|
|
||||||
ticks: [0.5, 0.5, 0.1],
|
|
||||||
check: (r) => r.partSets.some(p => p.primId === 13 && p.prop === 'position'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Конвейер: BodyVelocity толкает игрока',
|
|
||||||
lua: `
|
|
||||||
local conv = workspace:FindFirstChild("Conveyor")
|
|
||||||
local bv = Instance.new("BodyVelocity", conv)
|
|
||||||
bv.Velocity = Vector3.new(20, 0, 0)
|
|
||||||
bv.MaxForce = Vector3.new(4000, 0, 4000)
|
|
||||||
print("conveyor started")
|
|
||||||
`,
|
|
||||||
ticks: [0.1],
|
|
||||||
check: (r) => r.partVels.some(v => v.primId === 12 && v.vx === 20),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'leaderstats (как в tycoon)',
|
|
||||||
lua: `
|
|
||||||
local Players = game:GetService("Players")
|
|
||||||
local plr = Players.LocalPlayer
|
|
||||||
local money = Instance.new("IntValue", plr.leaderstats)
|
|
||||||
money.Name = "Money"
|
|
||||||
money.Value = 100
|
|
||||||
print("money:", money.Value)
|
|
||||||
`,
|
|
||||||
check: (r) => r.logs.some(l => l.text === 'money:\t100'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Checkpoint сохраняется в DataStore',
|
|
||||||
lua: `
|
|
||||||
local DSS = game:GetService("DataStoreService")
|
|
||||||
local store = DSS:GetDataStore("checkpoints")
|
|
||||||
store:SetAsync("player1", 5)
|
|
||||||
local cp = store:GetAsync("player1")
|
|
||||||
print("checkpoint:", cp)
|
|
||||||
`,
|
|
||||||
check: (r) => r.logs.some(l => l.text === 'checkpoint:\t5'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Цикл с wait — подсчёт',
|
|
||||||
lua: `
|
|
||||||
for i = 1, 3 do
|
|
||||||
print("count:", i)
|
|
||||||
wait(0.3)
|
|
||||||
end
|
|
||||||
print("done")
|
|
||||||
`,
|
|
||||||
ticks: [0.3, 0.3, 0.3, 0.3],
|
|
||||||
check: (r) => {
|
|
||||||
const texts = r.logs.map(l => l.text);
|
|
||||||
return texts.includes('count:\t1') && texts.includes('count:\t2')
|
|
||||||
&& texts.includes('count:\t3') && texts.includes('done');
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'task.spawn — параллельные функции',
|
|
||||||
lua: `
|
|
||||||
task.spawn(function() print("parallel A") end)
|
|
||||||
task.spawn(function() print("parallel B") end)
|
|
||||||
print("main")
|
|
||||||
`,
|
|
||||||
check: (r) => {
|
|
||||||
const texts = r.logs.map(l => l.text);
|
|
||||||
return texts.includes('parallel A') && texts.includes('parallel B') && texts.includes('main');
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Color3 + Material смена при Touched',
|
|
||||||
lua: `
|
|
||||||
local part = workspace:FindFirstChild("KillPart")
|
|
||||||
part.Touched:Connect(function()
|
|
||||||
part.Color = Color3.fromRGB(0, 0, 255)
|
|
||||||
part.Material = "Neon"
|
|
||||||
end)
|
|
||||||
-- симулируем touch
|
|
||||||
part.Touched:Fire(workspace)
|
|
||||||
`,
|
|
||||||
check: (r) => r.partSets.some(p => p.primId === 10 && p.prop === 'color')
|
|
||||||
&& r.partSets.some(p => p.primId === 10 && p.prop === 'material'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'RemoteEvent: client→server message',
|
|
||||||
lua: `
|
|
||||||
local re = Instance.new("RemoteEvent", workspace)
|
|
||||||
re.Name = "Coins"
|
|
||||||
re.OnServerEvent:Connect(function(player, amount)
|
|
||||||
print("server received:", amount)
|
|
||||||
end)
|
|
||||||
re:FireServer(50)
|
|
||||||
`,
|
|
||||||
check: (r) => r.logs.some(l => l.text === 'server received:\t50'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Heartbeat: счётчик через RunService',
|
|
||||||
lua: `
|
|
||||||
local RS = game:GetService("RunService")
|
|
||||||
local count = 0
|
|
||||||
RS.Heartbeat:Connect(function(dt)
|
|
||||||
count = count + 1
|
|
||||||
if count == 3 then print("tick3") end
|
|
||||||
end)
|
|
||||||
`,
|
|
||||||
ticks: [0.1, 0.1, 0.1],
|
|
||||||
check: (r) => r.logs.some(l => l.text === 'tick3'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Math: Vector3 arithmetic',
|
|
||||||
lua: `
|
|
||||||
local a = Vector3.new(1, 2, 3)
|
|
||||||
local b = Vector3.new(4, 5, 6)
|
|
||||||
local sum = a:add(b)
|
|
||||||
print("sum:", sum.X, sum.Y, sum.Z)
|
|
||||||
local d = a:Dot(b)
|
|
||||||
print("dot:", d)
|
|
||||||
`,
|
|
||||||
check: (r) => {
|
|
||||||
const texts = r.logs.map(l => l.text);
|
|
||||||
return texts.some(t => t === 'sum:\t5\t7\t9') && texts.some(t => t === 'dot:\t32');
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
(async () => {
|
|
||||||
let passed = 0, failed = 0;
|
|
||||||
for (const t of TESTS) {
|
|
||||||
try {
|
|
||||||
const r = await run(t.lua, t.targetPrimId, t.ticks || []);
|
|
||||||
const ok = t.check(r);
|
|
||||||
if (ok) { console.log(`✓ ${t.name}`); passed++; }
|
|
||||||
else {
|
|
||||||
console.log(`✗ ${t.name}`);
|
|
||||||
console.log(` logs: ${JSON.stringify(r.logs.map(l => l.text))}`);
|
|
||||||
if (r.partSets.length) console.log(` partSets: ${JSON.stringify(r.partSets)}`);
|
|
||||||
if (r.partVels.length) console.log(` partVels: ${JSON.stringify(r.partVels)}`);
|
|
||||||
if (r.playerCmds.length) console.log(` playerCmds: ${JSON.stringify(r.playerCmds)}`);
|
|
||||||
failed++;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.log(`✗ ${t.name} — exception: ${e.message || e}`);
|
|
||||||
failed++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
console.log(`\n${passed} passed, ${failed} failed`);
|
|
||||||
process.exit(failed > 0 ? 1 : 0);
|
|
||||||
})();
|
|
||||||
@ -1,187 +0,0 @@
|
|||||||
/**
|
|
||||||
* rbxl-lua-mvp.test.js — headless smoke-тест Roblox Lua API shim.
|
|
||||||
*
|
|
||||||
* НЕ запускает Worker (это требует браузерного Worker API). Вместо этого
|
|
||||||
* напрямую импортирует roblox-shim.js и инициализирует Lua в текущем потоке.
|
|
||||||
*
|
|
||||||
* Запуск: node --experimental-vm-modules tests/rbxl-lua-mvp.test.js
|
|
||||||
*/
|
|
||||||
import { LuaFactory } from 'wasmoon';
|
|
||||||
import { registerRobloxApi } from '../src/engine/roblox-shim.js';
|
|
||||||
|
|
||||||
const FAKE_SCENE_SNAP = {
|
|
||||||
primitives: {
|
|
||||||
1: { id: 1, type: 'cube', name: 'Floor', x: 0, y: 0, z: 0, sx: 10, sy: 1, sz: 10,
|
|
||||||
color: '#888888', material: 'glossy', anchored: true, canCollide: true, opacity: 1 },
|
|
||||||
2: { id: 2, type: 'cube', name: 'KillBrick', x: 5, y: 1, z: 0, sx: 2, sy: 1, sz: 2,
|
|
||||||
color: '#ff0000', material: 'neon', anchored: true, canCollide: true, opacity: 1 },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const SNIPPETS = [
|
|
||||||
{
|
|
||||||
name: 'print hello',
|
|
||||||
lua: `print("Hello from Lua!")`,
|
|
||||||
expectLogs: [{ level: 'info', text: 'Hello from Lua!' }],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Vector3 math',
|
|
||||||
lua: `
|
|
||||||
local v = Vector3.new(3, 4, 0)
|
|
||||||
print("magnitude:", v.Magnitude)
|
|
||||||
local u = v.Unit
|
|
||||||
print("unit:", u.X, u.Y, u.Z)
|
|
||||||
`,
|
|
||||||
expectLogs: [
|
|
||||||
{ level: 'info', text: 'magnitude:\t5' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'workspace iteration',
|
|
||||||
lua: `
|
|
||||||
local children = workspace:GetChildren()
|
|
||||||
print("count:", #children)
|
|
||||||
for i, c in ipairs(children) do
|
|
||||||
print("child:", c.Name, "class:", c.ClassName)
|
|
||||||
end
|
|
||||||
`,
|
|
||||||
expectLogs: [
|
|
||||||
{ level: 'info', text: 'count:\t2' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'FindFirstChild',
|
|
||||||
lua: `
|
|
||||||
local kb = workspace:FindFirstChild("KillBrick")
|
|
||||||
if kb then print("found:", kb.Name)
|
|
||||||
else print("not found") end
|
|
||||||
`,
|
|
||||||
expectLogs: [{ level: 'info', text: 'found:\tKillBrick' }],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Part.Position get',
|
|
||||||
lua: `
|
|
||||||
local kb = workspace:FindFirstChild("KillBrick")
|
|
||||||
print("position:", kb.Position.X, kb.Position.Y, kb.Position.Z)
|
|
||||||
`,
|
|
||||||
expectLogs: [{ level: 'info', text: 'position:\t5\t1\t0' }],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Part.Color set',
|
|
||||||
lua: `
|
|
||||||
local kb = workspace:FindFirstChild("KillBrick")
|
|
||||||
kb.Color = Color3.new(0, 1, 0)
|
|
||||||
print("new color hex (via Position):", kb.Color.R, kb.Color.G, kb.Color.B)
|
|
||||||
`,
|
|
||||||
expectPartSet: { primId: 2, prop: 'color' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'CFrame.Angles',
|
|
||||||
lua: `
|
|
||||||
local cf = CFrame.Angles(0, math.pi/2, 0)
|
|
||||||
print("lookvector:", cf.LookVector.X, cf.LookVector.Y, cf.LookVector.Z)
|
|
||||||
`,
|
|
||||||
expectLogs: [],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Instance.new + Parent',
|
|
||||||
lua: `
|
|
||||||
local f = Instance.new("Folder", workspace)
|
|
||||||
f.Name = "MyFolder"
|
|
||||||
print("folder name:", f.Name, "parent:", f.Parent.Name)
|
|
||||||
`,
|
|
||||||
expectLogs: [{ level: 'info', text: 'folder name:\tMyFolder\tparent:\tWorkspace' }],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'IsA hierarchy',
|
|
||||||
lua: `
|
|
||||||
local kb = workspace:FindFirstChild("KillBrick")
|
|
||||||
print("isa Part:", kb:IsA("Part"))
|
|
||||||
print("isa BasePart:", kb:IsA("BasePart"))
|
|
||||||
print("isa Instance:", kb:IsA("Instance"))
|
|
||||||
print("isa Sound:", kb:IsA("Sound"))
|
|
||||||
`,
|
|
||||||
expectLogs: [
|
|
||||||
{ level: 'info', text: 'isa Part:\ttrue' },
|
|
||||||
{ level: 'info', text: 'isa BasePart:\ttrue' },
|
|
||||||
{ level: 'info', text: 'isa Instance:\ttrue' },
|
|
||||||
{ level: 'info', text: 'isa Sound:\tfalse' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
async function runSnippet(snippet) {
|
|
||||||
const factory = new LuaFactory();
|
|
||||||
const lua = await factory.createEngine();
|
|
||||||
|
|
||||||
const logs = [];
|
|
||||||
const sent = [];
|
|
||||||
const send = (cmd, payload) => sent.push({ cmd, payload });
|
|
||||||
|
|
||||||
registerRobloxApi(lua, {
|
|
||||||
getSceneSnap: () => FAKE_SCENE_SNAP,
|
|
||||||
targetPrimitiveId: 2, // как будто скрипт прикреплён к KillBrick
|
|
||||||
send,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Перехват print через send('log', ...)
|
|
||||||
let errMsg = null;
|
|
||||||
try {
|
|
||||||
await lua.doString(snippet.lua);
|
|
||||||
} catch (e) {
|
|
||||||
errMsg = e && e.message ? e.message : String(e);
|
|
||||||
}
|
|
||||||
lua.global.close();
|
|
||||||
|
|
||||||
const captured = sent.filter(s => s.cmd === 'log');
|
|
||||||
return { logs: captured.map(s => s.payload), partSets: sent.filter(s => s.cmd === 'partSet'), error: errMsg };
|
|
||||||
}
|
|
||||||
|
|
||||||
(async () => {
|
|
||||||
let passed = 0;
|
|
||||||
let failed = 0;
|
|
||||||
for (const s of SNIPPETS) {
|
|
||||||
const result = await runSnippet(s);
|
|
||||||
const ok = checkExpectations(s, result);
|
|
||||||
if (ok.success) {
|
|
||||||
console.log(`✓ ${s.name}`);
|
|
||||||
passed++;
|
|
||||||
} else {
|
|
||||||
console.log(`✗ ${s.name}`);
|
|
||||||
console.log(` error: ${result.error || 'none'}`);
|
|
||||||
console.log(` logs received:`);
|
|
||||||
for (const l of result.logs) console.log(` [${l.level}] ${JSON.stringify(l.text)}`);
|
|
||||||
if (result.partSets.length) {
|
|
||||||
console.log(` partSets:`, JSON.stringify(result.partSets));
|
|
||||||
}
|
|
||||||
console.log(` reason: ${ok.reason}`);
|
|
||||||
failed++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
console.log(`\n${passed} passed, ${failed} failed`);
|
|
||||||
process.exit(failed > 0 ? 1 : 0);
|
|
||||||
})();
|
|
||||||
|
|
||||||
function checkExpectations(snippet, result) {
|
|
||||||
if (result.error) {
|
|
||||||
return { success: false, reason: `lua error: ${result.error}` };
|
|
||||||
}
|
|
||||||
if (snippet.expectLogs) {
|
|
||||||
for (const exp of snippet.expectLogs) {
|
|
||||||
const found = result.logs.find(l => l.level === exp.level && l.text === exp.text);
|
|
||||||
if (!found) {
|
|
||||||
return { success: false, reason: `missing log: [${exp.level}] ${JSON.stringify(exp.text)}` };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (snippet.expectPartSet) {
|
|
||||||
const found = result.partSets.find(s =>
|
|
||||||
s.payload.primId === snippet.expectPartSet.primId &&
|
|
||||||
s.payload.prop === snippet.expectPartSet.prop
|
|
||||||
);
|
|
||||||
if (!found) {
|
|
||||||
return { success: false, reason: `missing partSet ${JSON.stringify(snippet.expectPartSet)}` };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { success: true };
|
|
||||||
}
|
|
||||||
@ -1,144 +0,0 @@
|
|||||||
/**
|
|
||||||
* rbxl-lua-services.test.js — тесты Humanoid, RemoteEvent, DataStore, HttpService.
|
|
||||||
*/
|
|
||||||
import { LuaFactory } from 'wasmoon';
|
|
||||||
import { registerRobloxApi } from '../src/engine/roblox-shim.js';
|
|
||||||
import { RobloxScheduler } from '../src/engine/roblox-scheduler.js';
|
|
||||||
import { installRobloxServices } from '../src/engine/roblox-services.js';
|
|
||||||
|
|
||||||
const SCENE = { primitives: {} };
|
|
||||||
|
|
||||||
const STORE = new Map();
|
|
||||||
|
|
||||||
async function run(luaSource, ticks = []) {
|
|
||||||
const factory = new LuaFactory();
|
|
||||||
const lua = await factory.createEngine();
|
|
||||||
const sent = [];
|
|
||||||
const send = (cmd, payload) => sent.push({ cmd, payload });
|
|
||||||
let playerState = { x: 0, y: 5, z: 0 };
|
|
||||||
|
|
||||||
registerRobloxApi(lua, { getSceneSnap: () => SCENE, targetPrimitiveId: null, send });
|
|
||||||
const sched = new RobloxScheduler(lua);
|
|
||||||
sched.install();
|
|
||||||
installRobloxServices(lua, {
|
|
||||||
send,
|
|
||||||
getPlayerState: () => playerState,
|
|
||||||
loadSave: (k) => STORE.get(k),
|
|
||||||
saveSave: (k, v) => STORE.set(k, v),
|
|
||||||
removeSave: (k) => STORE.delete(k),
|
|
||||||
});
|
|
||||||
|
|
||||||
await sched.spawnMain(luaSource);
|
|
||||||
for (const dt of ticks) await sched.tick(dt);
|
|
||||||
lua.global.close();
|
|
||||||
return { logs: sent.filter(s => s.cmd === 'log').map(s => s.payload),
|
|
||||||
playerCmds: sent.filter(s => s.cmd === 'playerCmd').map(s => s.payload),
|
|
||||||
broadcasts: sent.filter(s => s.cmd === 'broadcast').map(s => s.payload) };
|
|
||||||
}
|
|
||||||
|
|
||||||
const TESTS = [
|
|
||||||
{
|
|
||||||
name: 'Players.LocalPlayer.Character.Humanoid существует',
|
|
||||||
lua: `
|
|
||||||
local p = game:GetService("Players").LocalPlayer
|
|
||||||
local h = p.Character:WaitForChild("Humanoid")
|
|
||||||
print("hp:", h.Health, "ws:", h.WalkSpeed)
|
|
||||||
`,
|
|
||||||
expect: [{ level: 'info', text: 'hp:\t100\tws:\t16' }],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Humanoid.WalkSpeed = 50 → playerCmd setWalkSpeed',
|
|
||||||
lua: `
|
|
||||||
local h = game:GetService("Players").LocalPlayer.Character.Humanoid
|
|
||||||
h.WalkSpeed = 50
|
|
||||||
`,
|
|
||||||
expectPlayerCmd: { method: 'setWalkSpeed', argsCheck: (a) => a[0] === 50 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Humanoid:TakeDamage уменьшает HP',
|
|
||||||
lua: `
|
|
||||||
local h = game:GetService("Players").LocalPlayer.Character.Humanoid
|
|
||||||
h:TakeDamage(30)
|
|
||||||
print("after damage:", h.Health)
|
|
||||||
`,
|
|
||||||
expect: [{ level: 'info', text: 'after damage:\t70' }],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Humanoid.Health = 0 → Died fires',
|
|
||||||
lua: `
|
|
||||||
local h = game:GetService("Players").LocalPlayer.Character.Humanoid
|
|
||||||
h.Died:Connect(function() print("DIED") end)
|
|
||||||
h.Health = 0
|
|
||||||
`,
|
|
||||||
expect: [{ level: 'info', text: 'DIED' }],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'DataStoreService GetAsync/SetAsync',
|
|
||||||
lua: `
|
|
||||||
local DSS = game:GetService("DataStoreService")
|
|
||||||
local store = DSS:GetDataStore("coins")
|
|
||||||
store:SetAsync("player1", 100)
|
|
||||||
print("got:", store:GetAsync("player1"))
|
|
||||||
`,
|
|
||||||
expect: [{ level: 'info', text: 'got:\t100' }],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'DataStoreService IncrementAsync',
|
|
||||||
lua: `
|
|
||||||
local store = game:GetService("DataStoreService"):GetDataStore("score")
|
|
||||||
store:SetAsync("p1", 50)
|
|
||||||
store:IncrementAsync("p1", 25)
|
|
||||||
print("final:", store:GetAsync("p1"))
|
|
||||||
`,
|
|
||||||
expect: [{ level: 'info', text: 'final:\t75' }],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'HttpService:JSONEncode/Decode',
|
|
||||||
lua: `
|
|
||||||
local HS = game:GetService("HttpService")
|
|
||||||
local s = HS:JSONEncode({a=1, b="two"})
|
|
||||||
print("encoded len:", #s)
|
|
||||||
local d = HS:JSONDecode('{"x":42}')
|
|
||||||
print("decoded x:", d.x)
|
|
||||||
`,
|
|
||||||
expect: [{ level: 'info', text: 'decoded x:\t42' }],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'RemoteEvent FireServer + OnServerEvent',
|
|
||||||
lua: `
|
|
||||||
local re = Instance.new("RemoteEvent", workspace)
|
|
||||||
re.Name = "MyEvent"
|
|
||||||
re.OnServerEvent:Connect(function(player, msg)
|
|
||||||
print("server got:", msg)
|
|
||||||
end)
|
|
||||||
re:FireServer("hello")
|
|
||||||
`,
|
|
||||||
expect: [{ level: 'info', text: 'server got:\thello' }],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
(async () => {
|
|
||||||
let passed = 0, failed = 0;
|
|
||||||
for (const t of TESTS) {
|
|
||||||
try {
|
|
||||||
const r = await run(t.lua, t.ticks);
|
|
||||||
let ok = true; let reason = '';
|
|
||||||
for (const exp of (t.expect || [])) {
|
|
||||||
const found = r.logs.find(l => l.level === exp.level && l.text === exp.text);
|
|
||||||
if (!found) { ok = false; reason = `missing log: ${exp.text}; got: ${JSON.stringify(r.logs)}`; break; }
|
|
||||||
}
|
|
||||||
if (t.expectPlayerCmd) {
|
|
||||||
const found = r.playerCmds.find(c => c.method === t.expectPlayerCmd.method
|
|
||||||
&& (!t.expectPlayerCmd.argsCheck || t.expectPlayerCmd.argsCheck(c.args)));
|
|
||||||
if (!found) { ok = false; reason = `missing playerCmd ${t.expectPlayerCmd.method}; got: ${JSON.stringify(r.playerCmds)}`; }
|
|
||||||
}
|
|
||||||
if (ok) { console.log(`✓ ${t.name}`); passed++; }
|
|
||||||
else { console.log(`✗ ${t.name} — ${reason}`); failed++; }
|
|
||||||
} catch (e) {
|
|
||||||
console.log(`✗ ${t.name} — exception: ${e.message || e}`);
|
|
||||||
failed++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
console.log(`\n${passed} passed, ${failed} failed`);
|
|
||||||
process.exit(failed > 0 ? 1 : 0);
|
|
||||||
})();
|
|
||||||
@ -1,89 +0,0 @@
|
|||||||
/**
|
|
||||||
* rbxl-lua-tween.test.js — тесты TweenService.
|
|
||||||
*/
|
|
||||||
import { LuaFactory } from 'wasmoon';
|
|
||||||
import { registerRobloxApi } from '../src/engine/roblox-shim.js';
|
|
||||||
import { RobloxScheduler } from '../src/engine/roblox-scheduler.js';
|
|
||||||
import { RobloxTweenManager } from '../src/engine/roblox-tween.js';
|
|
||||||
|
|
||||||
const SCENE = {
|
|
||||||
primitives: {
|
|
||||||
1: { id: 1, type: 'cube', name: 'Movable', x: 0, y: 5, z: 0, sx: 1, sy: 1, sz: 1,
|
|
||||||
color: '#ffffff', material: 'glossy', anchored: false, canCollide: true, opacity: 1 },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
async function run(luaSource, ticks = []) {
|
|
||||||
const factory = new LuaFactory();
|
|
||||||
const lua = await factory.createEngine();
|
|
||||||
const sent = [];
|
|
||||||
const send = (cmd, payload) => sent.push({ cmd, payload });
|
|
||||||
registerRobloxApi(lua, { getSceneSnap: () => SCENE, targetPrimitiveId: 1, send });
|
|
||||||
const sched = new RobloxScheduler(lua);
|
|
||||||
sched.install();
|
|
||||||
const tweenMgr = new RobloxTweenManager();
|
|
||||||
tweenMgr.install(lua);
|
|
||||||
|
|
||||||
await sched.spawnMain(luaSource);
|
|
||||||
for (const dt of ticks) {
|
|
||||||
await sched.tick(dt);
|
|
||||||
tweenMgr.tick(dt);
|
|
||||||
}
|
|
||||||
lua.global.close();
|
|
||||||
return { logs: sent.filter(s => s.cmd === 'log').map(s => s.payload),
|
|
||||||
partSets: sent.filter(s => s.cmd === 'partSet').map(s => s.payload) };
|
|
||||||
}
|
|
||||||
|
|
||||||
const TESTS = [
|
|
||||||
{
|
|
||||||
name: 'TweenInfo создаётся',
|
|
||||||
lua: `
|
|
||||||
local info = TweenInfo.new(2, Enum.EasingStyle.Linear, Enum.EasingDirection.Out)
|
|
||||||
print("time:", info.Time, "style:", info.EasingStyle)
|
|
||||||
`,
|
|
||||||
ticks: [],
|
|
||||||
expectLogs: [{ level: 'info', text: 'time:\t2\tstyle:\tLinear' }],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'TweenService:Create + Play (Linear)',
|
|
||||||
lua: `
|
|
||||||
local TS = game:GetService("TweenService")
|
|
||||||
local p = workspace:FindFirstChild("Movable")
|
|
||||||
local info = TweenInfo.new(1, Enum.EasingStyle.Linear, Enum.EasingDirection.Out)
|
|
||||||
local tw = TS:Create(p, info, { Position = Vector3.new(10, 5, 0) })
|
|
||||||
tw:Play()
|
|
||||||
print("started")
|
|
||||||
`,
|
|
||||||
ticks: [0.5, 0.5, 0.1], // больше 1 сек — должен завершиться
|
|
||||||
// Ожидаем что хотя бы один partSet с prop=position
|
|
||||||
expectPartSet: { primId: 1, prop: 'position' },
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
(async () => {
|
|
||||||
let passed = 0, failed = 0;
|
|
||||||
for (const t of TESTS) {
|
|
||||||
try {
|
|
||||||
const r = await run(t.lua, t.ticks);
|
|
||||||
let ok = true;
|
|
||||||
let reason = '';
|
|
||||||
for (const exp of (t.expectLogs || [])) {
|
|
||||||
const found = r.logs.find(l => l.level === exp.level && l.text === exp.text);
|
|
||||||
if (!found) { ok = false; reason = `missing log: ${exp.text}`; break; }
|
|
||||||
}
|
|
||||||
if (t.expectPartSet) {
|
|
||||||
const found = r.partSets.find(p => p.primId === t.expectPartSet.primId && p.prop === t.expectPartSet.prop);
|
|
||||||
if (!found) {
|
|
||||||
ok = false; reason = `missing partSet: ${JSON.stringify(t.expectPartSet)}; got: ${JSON.stringify(r.partSets.slice(0,3))}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (ok) { console.log(`✓ ${t.name}`); passed++; }
|
|
||||||
else { console.log(`✗ ${t.name} — ${reason}`); failed++; }
|
|
||||||
} catch (e) {
|
|
||||||
console.log(`✗ ${t.name} — exception: ${e}`);
|
|
||||||
failed++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
console.log(`\n${passed} passed, ${failed} failed`);
|
|
||||||
process.exit(failed > 0 ? 1 : 0);
|
|
||||||
})();
|
|
||||||
@ -1,104 +0,0 @@
|
|||||||
/**
|
|
||||||
* rbxl-lua-wait.test.js — тесты wait/task.wait через шедулер.
|
|
||||||
*/
|
|
||||||
import { LuaFactory } from 'wasmoon';
|
|
||||||
import { registerRobloxApi } from '../src/engine/roblox-shim.js';
|
|
||||||
import { RobloxScheduler } from '../src/engine/roblox-scheduler.js';
|
|
||||||
|
|
||||||
const SCENE = { primitives: {} };
|
|
||||||
|
|
||||||
async function run(luaSource, ticks = [0.5, 0.5, 0.5, 0.5, 0.5]) {
|
|
||||||
const factory = new LuaFactory();
|
|
||||||
const lua = await factory.createEngine();
|
|
||||||
const sent = [];
|
|
||||||
const send = (cmd, payload) => sent.push({ cmd, payload });
|
|
||||||
registerRobloxApi(lua, { getSceneSnap: () => SCENE, targetPrimitiveId: null, send });
|
|
||||||
const sched = new RobloxScheduler(lua);
|
|
||||||
sched.install();
|
|
||||||
|
|
||||||
await sched.spawnMain(luaSource);
|
|
||||||
for (const dt of ticks) {
|
|
||||||
await sched.tick(dt);
|
|
||||||
}
|
|
||||||
lua.global.close();
|
|
||||||
return sent.filter(s => s.cmd === 'log').map(s => s.payload);
|
|
||||||
}
|
|
||||||
|
|
||||||
const TESTS = [
|
|
||||||
{
|
|
||||||
name: 'wait(0) — мгновенный',
|
|
||||||
lua: `
|
|
||||||
print("before")
|
|
||||||
wait(0)
|
|
||||||
print("after")
|
|
||||||
`,
|
|
||||||
expect: ['before', 'after'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'wait(1) — резюм после tick',
|
|
||||||
lua: `
|
|
||||||
print("step1")
|
|
||||||
wait(1)
|
|
||||||
print("step2")
|
|
||||||
`,
|
|
||||||
ticks: [0.5, 0.5, 0.5], // 1.5 сек суммарно
|
|
||||||
expect: ['step1', 'step2'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'task.wait(0.5)',
|
|
||||||
lua: `
|
|
||||||
print("a")
|
|
||||||
task.wait(0.5)
|
|
||||||
print("b")
|
|
||||||
`,
|
|
||||||
ticks: [0.5, 0.5],
|
|
||||||
expect: ['a', 'b'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'несколько wait подряд',
|
|
||||||
lua: `
|
|
||||||
print("p1")
|
|
||||||
wait(0.5)
|
|
||||||
print("p2")
|
|
||||||
wait(0.5)
|
|
||||||
print("p3")
|
|
||||||
`,
|
|
||||||
ticks: [0.5, 0.5, 0.5, 0.5], // 2 сек
|
|
||||||
expect: ['p1', 'p2', 'p3'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'task.delay (не блокирует)',
|
|
||||||
lua: `
|
|
||||||
print("immediate")
|
|
||||||
task.delay(0.3, function() print("delayed") end)
|
|
||||||
print("after delay-call")
|
|
||||||
`,
|
|
||||||
ticks: [0.5],
|
|
||||||
expect: ['immediate', 'after delay-call', 'delayed'],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
(async () => {
|
|
||||||
let passed = 0, failed = 0;
|
|
||||||
for (const t of TESTS) {
|
|
||||||
try {
|
|
||||||
const logs = await run(t.lua, t.ticks);
|
|
||||||
const texts = logs.map(l => l.text);
|
|
||||||
const ok = JSON.stringify(texts) === JSON.stringify(t.expect);
|
|
||||||
if (ok) {
|
|
||||||
console.log(`✓ ${t.name}`);
|
|
||||||
passed++;
|
|
||||||
} else {
|
|
||||||
console.log(`✗ ${t.name}`);
|
|
||||||
console.log(` expected: ${JSON.stringify(t.expect)}`);
|
|
||||||
console.log(` got: ${JSON.stringify(texts)}`);
|
|
||||||
failed++;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.log(`✗ ${t.name} — exception: ${e}`);
|
|
||||||
failed++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
console.log(`\n${passed} passed, ${failed} failed`);
|
|
||||||
process.exit(failed > 0 ? 1 : 0);
|
|
||||||
})();
|
|
||||||
Loading…
x
Reference in New Issue
Block a user