0709 with changes and CollEditor
This commit is contained in:
317
MIGRATION_GUIDE.md
Normal file
317
MIGRATION_GUIDE.md
Normal file
@@ -0,0 +1,317 @@
|
||||
# Руководство по миграции на модульную архитектуру
|
||||
|
||||
## Обзор
|
||||
|
||||
Этот документ описывает процесс миграции проекта EEV_Proj с монолитной архитектуры на модульную. Миграция направлена на улучшение читаемости, поддерживаемости и переиспользования кода.
|
||||
|
||||
## Что было изменено
|
||||
|
||||
### До миграции
|
||||
- Весь код игры находился в одном файле `Game.js` (6271 строк)
|
||||
- Смешанная логика: 3D сцена, UI, бизнес-логика, API вызовы
|
||||
- Сложность отладки и внесения изменений
|
||||
- Низкая переиспользуемость компонентов
|
||||
|
||||
### После миграции
|
||||
- Код разбит на логические модули
|
||||
- Четкое разделение ответственности
|
||||
- Легкость тестирования и отладки
|
||||
- Возможность переиспользования модулей
|
||||
|
||||
## Структура модулей
|
||||
|
||||
### 1. Core (Ядро)
|
||||
```
|
||||
src/modules/
|
||||
├── GameCore.js # Основной игровой движок
|
||||
├── SceneManager.js # Управление 3D сценой
|
||||
├── CameraManager.js # Управление камерами
|
||||
├── PlayerManager.js # Управление игроком
|
||||
├── RendererManager.js # Управление рендерером
|
||||
└── InteriorManager.js # Управление интерьерами
|
||||
```
|
||||
|
||||
### 2. Дополнительные модули (планируются)
|
||||
```
|
||||
src/modules/
|
||||
├── DialogManager.js # Система диалогов
|
||||
├── InventoryManager.js # Управление инвентарем
|
||||
├── QuestManager.js # Система квестов
|
||||
├── PhoneManager.js # Виртуальный телефон
|
||||
├── AppManager.js # Управление приложениями
|
||||
├── NotificationManager.js # Система уведомлений
|
||||
├── SocketManager.js # WebSocket соединения
|
||||
├── VoiceChatManager.js # Голосовой чат
|
||||
├── MessageManager.js # Система сообщений
|
||||
├── Pathfinding.js # Поиск пути
|
||||
├── CollisionDetection.js # Обнаружение коллизий
|
||||
└── AnimationManager.js # Управление анимациями
|
||||
```
|
||||
|
||||
## Процесс миграции
|
||||
|
||||
### Шаг 1: Создание модулей
|
||||
|
||||
1. **SceneManager.js** - Выделен из логики управления сценой
|
||||
2. **CameraManager.js** - Выделен из логики управления камерами
|
||||
3. **PlayerManager.js** - Выделен из логики управления игроком
|
||||
4. **RendererManager.js** - Выделен из логики рендеринга
|
||||
5. **InteriorManager.js** - Выделен из логики интерьеров
|
||||
6. **GameCore.js** - Создан как координатор всех модулей
|
||||
|
||||
### Шаг 2: Обновление Game.js
|
||||
|
||||
- Создан `GameModular.js` с использованием модулей
|
||||
- Упрощена логика компонента
|
||||
- Улучшена читаемость кода
|
||||
|
||||
### Шаг 3: Создание документации
|
||||
|
||||
- README.md для каждого модуля
|
||||
- Общая документация проекта
|
||||
- Руководства по использованию
|
||||
|
||||
## Как использовать новые модули
|
||||
|
||||
### Импорт модулей
|
||||
|
||||
```javascript
|
||||
import { GameCore } from './modules/GameCore.js';
|
||||
import { SceneManager } from './modules/SceneManager.js';
|
||||
import { CameraManager } from './modules/CameraManager.js';
|
||||
```
|
||||
|
||||
### Создание экземпляра игры
|
||||
|
||||
```javascript
|
||||
const gameCore = new GameCore(containerElement);
|
||||
```
|
||||
|
||||
### Доступ к модулям
|
||||
|
||||
```javascript
|
||||
const sceneManager = gameCore.getSceneManager();
|
||||
const cameraManager = gameCore.getCameraManager();
|
||||
const playerManager = gameCore.getPlayerManager();
|
||||
```
|
||||
|
||||
## Преимущества новой архитектуры
|
||||
|
||||
### 1. Читаемость
|
||||
- Каждый модуль отвечает за одну область
|
||||
- Код легче понимать и анализировать
|
||||
- Упрощена отладка
|
||||
|
||||
### 2. Поддерживаемость
|
||||
- Изменения в одном модуле не влияют на другие
|
||||
- Легче добавлять новую функциональность
|
||||
- Проще исправлять ошибки
|
||||
|
||||
### 3. Переиспользование
|
||||
- Модули можно использовать в других частях проекта
|
||||
- Возможность создания библиотеки модулей
|
||||
- Легкость интеграции в новые проекты
|
||||
|
||||
### 4. Тестирование
|
||||
- Каждый модуль можно тестировать отдельно
|
||||
- Упрощено создание unit тестов
|
||||
- Лучшее покрытие кода
|
||||
|
||||
### 5. Масштабируемость
|
||||
- Легко добавлять новые модули
|
||||
- Возможность параллельной разработки
|
||||
- Лучшая организация команды
|
||||
|
||||
## Планы по дальнейшему развитию
|
||||
|
||||
### Краткосрочные цели (1-2 недели)
|
||||
1. Завершить миграцию основных компонентов
|
||||
2. Добавить недостающие модули
|
||||
3. Создать полную документацию
|
||||
4. Написать unit тесты для модулей
|
||||
|
||||
### Среднесрочные цели (1-2 месяца)
|
||||
1. Добавить TypeScript поддержку
|
||||
2. Создать систему плагинов
|
||||
3. Реализовать hot reload для модулей
|
||||
4. Добавить систему логирования
|
||||
|
||||
### Долгосрочные цели (3-6 месяцев)
|
||||
1. Создать редактор модулей
|
||||
2. Реализовать систему версионирования
|
||||
3. Добавить поддержку WebAssembly
|
||||
4. Создать marketplace модулей
|
||||
|
||||
## Обратная совместимость
|
||||
|
||||
### Что работает как раньше
|
||||
- Основной API игры
|
||||
- React компоненты
|
||||
- WebSocket соединения
|
||||
- API вызовы
|
||||
|
||||
### Что изменилось
|
||||
- Внутренняя архитектура
|
||||
- Структура файлов
|
||||
- Способ инициализации игры
|
||||
|
||||
### Миграция существующего кода
|
||||
|
||||
```javascript
|
||||
// Старый способ
|
||||
import Game from './Game.js';
|
||||
|
||||
// Новый способ
|
||||
import Game from './GameModular.js';
|
||||
// или
|
||||
import { GameCore } from './modules/GameCore.js';
|
||||
```
|
||||
|
||||
## Проблемы и решения
|
||||
|
||||
### Проблема: Циклические зависимости
|
||||
**Решение:** Правильное планирование архитектуры модулей
|
||||
|
||||
### Проблема: Производительность
|
||||
**Решение:** Ленивая загрузка модулей, оптимизация импортов
|
||||
|
||||
### Проблема: Размер бандла
|
||||
**Решение:** Tree shaking, динамические импорты
|
||||
|
||||
### Проблема: Отладка
|
||||
**Решение:** Улучшенное логирование, source maps
|
||||
|
||||
## Рекомендации по разработке
|
||||
|
||||
### 1. Создание новых модулей
|
||||
- Следуйте принципам SOLID
|
||||
- Используйте JSDoc для документирования
|
||||
- Добавляйте unit тесты
|
||||
- Следуйте принятым соглашениям
|
||||
|
||||
### 2. Изменение существующих модулей
|
||||
- Не нарушайте публичный API
|
||||
- Обновляйте документацию
|
||||
- Добавляйте тесты для новых функций
|
||||
- Проверяйте обратную совместимость
|
||||
|
||||
### 3. Интеграция модулей
|
||||
- Используйте события для слабой связанности
|
||||
- Избегайте прямых зависимостей
|
||||
- Создавайте четкие интерфейсы
|
||||
- Документируйте взаимодействие
|
||||
|
||||
## Тестирование
|
||||
|
||||
### Unit тесты
|
||||
```bash
|
||||
npm test
|
||||
```
|
||||
|
||||
### Интеграционные тесты
|
||||
```bash
|
||||
npm run test:integration
|
||||
```
|
||||
|
||||
### E2E тесты
|
||||
```bash
|
||||
npm run test:e2e
|
||||
```
|
||||
|
||||
## Отладка
|
||||
|
||||
### Логирование
|
||||
```javascript
|
||||
// Включение подробного логирования
|
||||
localStorage.setItem('debug', 'true');
|
||||
|
||||
// Логирование конкретного модуля
|
||||
localStorage.setItem('debug', 'SceneManager,CameraManager');
|
||||
```
|
||||
|
||||
### DevTools
|
||||
- React DevTools для компонентов
|
||||
- Three.js Inspector для 3D сцены
|
||||
- Chrome DevTools для отладки
|
||||
|
||||
## Производительность
|
||||
|
||||
### Оптимизации
|
||||
1. Ленивая загрузка модулей
|
||||
2. Кэширование результатов
|
||||
3. Оптимизация рендеринга
|
||||
4. Сжатие ресурсов
|
||||
|
||||
### Мониторинг
|
||||
```javascript
|
||||
// Включение профилирования
|
||||
localStorage.setItem('profile', 'true');
|
||||
|
||||
// Метрики производительности
|
||||
const metrics = gameCore.getPerformanceMetrics();
|
||||
console.log('FPS:', metrics.fps);
|
||||
console.log('Memory:', metrics.memory);
|
||||
```
|
||||
|
||||
## Безопасность
|
||||
|
||||
### Валидация входных данных
|
||||
- Проверка типов
|
||||
- Санитизация строк
|
||||
- Валидация координат
|
||||
|
||||
### Защита от эксплойтов
|
||||
- CSP заголовки
|
||||
- Валидация WebSocket сообщений
|
||||
- Проверка прав доступа
|
||||
|
||||
## Развертывание
|
||||
|
||||
### Сборка
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
### Анализ бандла
|
||||
```bash
|
||||
npm run analyze
|
||||
```
|
||||
|
||||
### Оптимизация
|
||||
```bash
|
||||
npm run optimize
|
||||
```
|
||||
|
||||
## Поддержка
|
||||
|
||||
### Документация
|
||||
- README файлы для каждого модуля
|
||||
- Примеры использования
|
||||
- API документация
|
||||
- Руководства по миграции
|
||||
|
||||
### Сообщество
|
||||
- Issues на GitHub
|
||||
- Обсуждения в Discord
|
||||
- Wiki проекта
|
||||
- Примеры кода
|
||||
|
||||
## Заключение
|
||||
|
||||
Миграция на модульную архитектуру значительно улучшает качество кода проекта EEV_Proj. Новый подход обеспечивает:
|
||||
|
||||
- Лучшую организацию кода
|
||||
- Упрощенную разработку
|
||||
- Повышенную надежность
|
||||
- Возможности для роста
|
||||
|
||||
Процесс миграции выполнен поэтапно, что минимизирует риски и обеспечивает плавный переход. Все существующие функции сохранены, а новые возможности легко добавляются через модульную систему.
|
||||
|
||||
## Следующие шаги
|
||||
|
||||
1. Изучите документацию модулей
|
||||
2. Попробуйте новый `GameModular.js`
|
||||
3. Создайте свой первый модуль
|
||||
4. Внесите вклад в развитие проекта
|
||||
|
||||
Удачи в разработке! 🚀
|
||||
3
public/colliders.json
Normal file
3
public/colliders.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"colliders": []
|
||||
}
|
||||
22
public/colliders_city_1.json
Normal file
22
public/colliders_city_1.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"colliders": [
|
||||
{
|
||||
"type": "box",
|
||||
"position": {
|
||||
"x": -15.894407457818183,
|
||||
"y": -98.46844400767294,
|
||||
"z": -74.33953239204133
|
||||
},
|
||||
"rotation": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0
|
||||
},
|
||||
"scale": {
|
||||
"x": 3.8820412781839853,
|
||||
"y": 1.9275391013076184,
|
||||
"z": 0.020423580261430187
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
{"time":"2025-06-16T00:46:41.600Z","lastReal":1756826890758}
|
||||
{"time":"2025-07-25T23:21:30.224Z","lastReal":1757258251836}
|
||||
40
server.js
40
server.js
@@ -1554,6 +1554,46 @@ app.post('/api/interiors/:id/save', authenticate, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Коллизии карты: загрузка/сохранение файла public/colliders.json
|
||||
app.get('/api/colliders', authenticate, async (req, res) => {
|
||||
const cityId = Number(req.query.cityId) || 0;
|
||||
try {
|
||||
const fileName = cityId ? `colliders_city_${cityId}.json` : 'colliders.json';
|
||||
const filePath = pathLib.join(__dirname, 'public', fileName);
|
||||
try {
|
||||
const raw = await fs.promises.readFile(filePath, 'utf8');
|
||||
const json = JSON.parse(raw);
|
||||
return res.json(json);
|
||||
} catch (e) {
|
||||
// Если нет файла — создаём пустой
|
||||
const empty = { colliders: [] };
|
||||
await fs.promises.mkdir(pathLib.join(__dirname, 'public'), { recursive: true });
|
||||
await fs.promises.writeFile(filePath, JSON.stringify(empty, null, 2), 'utf8');
|
||||
return res.json(empty);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Ошибка чтения colliders.json:', e);
|
||||
res.status(500).json({ error: 'Ошибка чтения коллизий' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/colliders', authenticate, async (req, res) => {
|
||||
try {
|
||||
const { colliders, cityId } = req.body || {};
|
||||
if (!Array.isArray(colliders)) {
|
||||
return res.status(400).json({ error: 'Invalid colliders' });
|
||||
}
|
||||
const fileName = cityId ? `colliders_city_${Number(cityId)}.json` : 'colliders.json';
|
||||
const filePath = pathLib.join(__dirname, 'public', fileName);
|
||||
await fs.promises.mkdir(pathLib.join(__dirname, 'public'), { recursive: true });
|
||||
await fs.promises.writeFile(filePath, JSON.stringify({ colliders }, null, 2), 'utf8');
|
||||
res.json({ ok: true });
|
||||
} catch (e) {
|
||||
console.error('Ошибка записи colliders.json:', e);
|
||||
res.status(500).json({ error: 'Ошибка сохранения коллизий' });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
|
||||
// Получить организацию по objectId
|
||||
|
||||
258
server/README.md
Normal file
258
server/README.md
Normal file
@@ -0,0 +1,258 @@
|
||||
# Серверная часть EEV_Proj
|
||||
|
||||
## Описание
|
||||
|
||||
Серверная часть проекта EEV_Proj построена на Node.js и Express. Сервер обеспечивает API для клиентской части, управляет WebSocket соединениями и взаимодействует с базой данных PostgreSQL.
|
||||
|
||||
## Структура
|
||||
|
||||
```
|
||||
server/
|
||||
├── server.js # Основной файл сервера
|
||||
├── db.js # Подключение к базе данных
|
||||
├── db1.js # Альтернативная конфигурация БД
|
||||
├── organizations.js # Логика организаций
|
||||
├── ecosystem.config.js # Конфигурация PM2
|
||||
└── README.md # Эта документация
|
||||
```
|
||||
|
||||
## Основные компоненты
|
||||
|
||||
### server.js
|
||||
Главный файл сервера, который:
|
||||
- Инициализирует Express приложение
|
||||
- Настраивает middleware
|
||||
- Определяет API маршруты
|
||||
- Запускает WebSocket сервер
|
||||
- Обрабатывает игровую логику
|
||||
|
||||
### db.js
|
||||
Модуль для работы с базой данных PostgreSQL:
|
||||
- Подключение к БД
|
||||
- Выполнение SQL запросов
|
||||
- Управление транзакциями
|
||||
|
||||
### organizations.js
|
||||
Логика для работы с организациями в игре:
|
||||
- Создание организаций
|
||||
- Управление меню организаций
|
||||
- Обработка покупок
|
||||
|
||||
## API эндпоинты
|
||||
|
||||
### Аутентификация
|
||||
- `POST /api/auth/login` - Вход в систему
|
||||
- `POST /api/auth/register` - Регистрация
|
||||
|
||||
### Интерьеры
|
||||
- `GET /api/interiors/:id/definition` - Получение определения интерьера
|
||||
- `POST /api/interiors/:id/enter` - Вход в интерьер
|
||||
|
||||
### Объекты города
|
||||
- `GET /api/city_objects/:id/interior` - Получение информации об объекте
|
||||
|
||||
### Экономика
|
||||
- `GET /api/economy/balance` - Получение баланса
|
||||
- `POST /api/economy/purchase` - Покупка предметов
|
||||
- `GET /api/economy/inventory` - Получение инвентаря
|
||||
|
||||
### Квесты
|
||||
- `GET /api/quests/progress` - Прогресс квестов
|
||||
- `POST /api/quests/complete` - Завершение квеста
|
||||
|
||||
### Пользователи
|
||||
- `GET /api/users/status` - Статус пользователей
|
||||
- `GET /api/users/:id/profile` - Профиль пользователя
|
||||
|
||||
### Сообщения
|
||||
- `GET /api/messages/:contactId` - Получение сообщений
|
||||
- `POST /api/messages/send` - Отправка сообщения
|
||||
- `POST /api/messages-read/:contactId` - Отметка сообщений как прочитанных
|
||||
|
||||
## WebSocket события
|
||||
|
||||
### Клиент → Сервер
|
||||
- `playerMovement` - Движение игрока
|
||||
- `interiorChange` - Смена интерьера
|
||||
- `economy:getBalance` - Запрос баланса
|
||||
- `economy:getInventory` - Запрос инвентаря
|
||||
- `economy:updateStats` - Обновление статистики
|
||||
- `economy:removeItem` - Удаление предмета
|
||||
- `voiceChatToggle` - Переключение голосового чата
|
||||
|
||||
### Сервер → Клиент
|
||||
- `economy:balanceChanged` - Изменение баланса
|
||||
- `economy:inventory` - Обновление инвентаря
|
||||
- `gameTime:update` - Обновление игрового времени
|
||||
- `us` - Обновление статуса пользователей
|
||||
|
||||
## Установка и запуск
|
||||
|
||||
### Зависимости
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### Переменные окружения
|
||||
Создайте файл `.env` в корне проекта:
|
||||
```dotenv
|
||||
# Сервер
|
||||
PORT=4000
|
||||
NODE_ENV=development
|
||||
|
||||
# База данных
|
||||
DB_HOST=localhost
|
||||
DB_PORT=5432
|
||||
DB_USER=your_username
|
||||
DB_PASS=your_password
|
||||
DB_NAME=your_database
|
||||
|
||||
# JWT
|
||||
JWT_SECRET=your_jwt_secret
|
||||
```
|
||||
|
||||
### Запуск
|
||||
```bash
|
||||
# Обычный запуск
|
||||
node server.js
|
||||
|
||||
# Через PM2
|
||||
pm2 start ecosystem.config.js
|
||||
|
||||
# В режиме разработки
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## База данных
|
||||
|
||||
### Основные таблицы
|
||||
|
||||
#### users
|
||||
- `id` - Уникальный идентификатор
|
||||
- `username` - Имя пользователя
|
||||
- `email` - Email адрес
|
||||
- `password_hash` - Хеш пароля
|
||||
- `created_at` - Дата создания
|
||||
|
||||
#### interiors
|
||||
- `id` - Уникальный идентификатор
|
||||
- `name` - Название интерьера
|
||||
- `glb_path` - Путь к 3D модели
|
||||
- `spawn_x`, `spawn_y`, `spawn_z` - Координаты входа
|
||||
- `exit_x`, `exit_y`, `exit_z` - Координаты выхода
|
||||
|
||||
#### city_objects
|
||||
- `id` - Уникальный идентификатор
|
||||
- `name` - Название объекта
|
||||
- `interior_id` - Ссылка на интерьер
|
||||
- `position_x`, `position_y`, `position_z` - Позиция в мире
|
||||
|
||||
#### organizations
|
||||
- `id` - Уникальный идентификатор
|
||||
- `name` - Название организации
|
||||
- `type` - Тип организации
|
||||
- `menu` - JSON с меню организации
|
||||
|
||||
#### inventory
|
||||
- `id` - Уникальный идентификатор
|
||||
- `user_id` - Ссылка на пользователя
|
||||
- `item_id` - Ссылка на предмет
|
||||
- `quantity` - Количество
|
||||
|
||||
## Безопасность
|
||||
|
||||
### JWT токены
|
||||
- Используются для аутентификации
|
||||
- Хранятся в localStorage клиента
|
||||
- Передаются в заголовке Authorization
|
||||
|
||||
### Валидация данных
|
||||
- Проверка входных данных
|
||||
- Санитизация SQL запросов
|
||||
- Защита от SQL инъекций
|
||||
|
||||
### CORS
|
||||
- Настроен для разрешения запросов с клиента
|
||||
- Ограничен по доменам в продакшене
|
||||
|
||||
## Мониторинг и логирование
|
||||
|
||||
### PM2
|
||||
- Управление процессами
|
||||
- Автоперезапуск при сбоях
|
||||
- Логирование
|
||||
|
||||
### Логи
|
||||
- Логирование ошибок
|
||||
- Логирование API запросов
|
||||
- Логирование WebSocket событий
|
||||
|
||||
## Масштабирование
|
||||
|
||||
### Кластеризация
|
||||
- Поддержка нескольких процессов через PM2
|
||||
- Балансировка нагрузки
|
||||
- Общие WebSocket соединения
|
||||
|
||||
### Кэширование
|
||||
- Redis для кэширования данных
|
||||
- Кэширование статических файлов
|
||||
- Кэширование результатов запросов
|
||||
|
||||
## Развертывание
|
||||
|
||||
### Docker
|
||||
```dockerfile
|
||||
FROM node:16-alpine
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci --only=production
|
||||
COPY . .
|
||||
EXPOSE 4000
|
||||
CMD ["node", "server.js"]
|
||||
```
|
||||
|
||||
### Nginx
|
||||
Настройте Nginx для проксирования API запросов на порт 4000.
|
||||
|
||||
### SSL
|
||||
Настройте HTTPS для безопасного соединения.
|
||||
|
||||
## Тестирование
|
||||
|
||||
### Unit тесты
|
||||
```bash
|
||||
npm test
|
||||
```
|
||||
|
||||
### Интеграционные тесты
|
||||
```bash
|
||||
npm run test:integration
|
||||
```
|
||||
|
||||
### Нагрузочное тестирование
|
||||
```bash
|
||||
npm run test:load
|
||||
```
|
||||
|
||||
## Отладка
|
||||
|
||||
### Логи
|
||||
- Проверьте логи PM2: `pm2 logs`
|
||||
- Проверьте логи приложения в консоли
|
||||
|
||||
### База данных
|
||||
- Проверьте подключение к БД
|
||||
- Выполните тестовые запросы
|
||||
|
||||
### WebSocket
|
||||
- Проверьте подключение клиента
|
||||
- Проверьте события в консоли браузера
|
||||
|
||||
## Поддержка
|
||||
|
||||
При возникновении проблем:
|
||||
1. Проверьте логи сервера
|
||||
2. Проверьте подключение к базе данных
|
||||
3. Проверьте конфигурацию
|
||||
4. Создайте issue в репозитории
|
||||
13
src/App.js
13
src/App.js
@@ -10,6 +10,7 @@ import GameWrapper from './components/GameWrapper';
|
||||
import RequireProfile from './components/RequireProfile';
|
||||
import MapEditor from './pages/MapEditor';
|
||||
import InteriorEditor from './pages/InteriorEditor';
|
||||
import CollisionEditor from './pages/CollisionEditor';
|
||||
|
||||
export default function App() {
|
||||
const [isAuth, setIsAuth] = useState(!!localStorage.getItem('token'));
|
||||
@@ -77,6 +78,18 @@ export default function App() {
|
||||
}
|
||||
/>
|
||||
|
||||
{/* редактор коллизий */}
|
||||
<Route
|
||||
path="/collision-editor"
|
||||
element={
|
||||
isAuth
|
||||
? <RequireProfile>
|
||||
<CollisionEditor />
|
||||
</RequireProfile>
|
||||
: <Navigate to="/login" replace/>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* всё остальное */}
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
|
||||
298
src/Game.js
298
src/Game.js
@@ -78,6 +78,11 @@ function Game({ avatarUrl, gender }) {
|
||||
const [inventory, setInventory] = useState([]);
|
||||
const [showInventory, setShowInventory] = useState(false);
|
||||
const [gameTime, setGameTime] = useState(null);
|
||||
// Сеть
|
||||
const [connectionLost, setConnectionLost] = useState(false);
|
||||
const [latencyMs, setLatencyMs] = useState(null);
|
||||
const connectionLostRef = useRef(false);
|
||||
useEffect(() => { connectionLostRef.current = connectionLost; }, [connectionLost]);
|
||||
const [balance, setBalance] = useState(() => {
|
||||
const p = JSON.parse(sessionStorage.getItem('user_profile') || '{}');
|
||||
return p.balance ?? 0;
|
||||
@@ -2274,6 +2279,8 @@ function Game({ avatarUrl, gender }) {
|
||||
const planarDist = Math.hypot(baseOffset.x, baseOffset.z);
|
||||
const radius = Math.hypot(planarDist, baseOffset.y);
|
||||
let baseAzimuth = Math.atan2(baseOffset.z, baseOffset.x);
|
||||
const baseAzimuth0 = baseAzimuth;
|
||||
let horizontalYaw = 0; // относительный поворот (±90°) от исходного
|
||||
const basePolar = Math.atan2(baseOffset.y, planarDist);
|
||||
|
||||
let cameraPitchOffset = 0;
|
||||
@@ -2326,24 +2333,93 @@ function Game({ avatarUrl, gender }) {
|
||||
});
|
||||
const socket = socketRef.current;
|
||||
|
||||
async function loadCustomCollidersForCity(cityIdParam) {
|
||||
try {
|
||||
const cityIdNum = Number(cityIdParam) || 0;
|
||||
const query = cityIdNum ? `?cityId=${encodeURIComponent(cityIdNum)}` : '';
|
||||
const res = await fetch(`/api/colliders${query}`, { cache: 'no-store', headers: { Authorization: `Bearer ${token}` } });
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
const list = Array.isArray(data?.colliders) ? data.colliders : [];
|
||||
// Удаляем старые кастомные коллайдеры
|
||||
obstacles = obstacles.filter(o => {
|
||||
const keep = !o?.mesh?.userData?.isCustomCollider;
|
||||
if (!keep && o.mesh) {
|
||||
scene.remove(o.mesh);
|
||||
}
|
||||
return keep;
|
||||
});
|
||||
// Добавляем новые
|
||||
list.forEach(c => {
|
||||
let geometry;
|
||||
if (c.type === 'circle') geometry = new THREE.CylinderGeometry(1.5, 1.5, 2, 24);
|
||||
else if (c.type === 'capsule') geometry = new THREE.CapsuleGeometry(1, 2, 4, 12);
|
||||
else geometry = new THREE.BoxGeometry(2, 2, 2);
|
||||
const material = new THREE.MeshBasicMaterial({ color: 0x000000, transparent: true, opacity: 0.001, depthWrite: false });
|
||||
const mesh = new THREE.Mesh(geometry, material);
|
||||
const p = c.position || {}; const r = c.rotation || {}; const s = c.scale || {};
|
||||
mesh.position.set(p.x || 0, p.y || 0, p.z || 0);
|
||||
mesh.rotation.set(r.x || 0, r.y || 0, r.z || 0);
|
||||
mesh.scale.set(s.x || 1, s.y || 1, s.z || 1);
|
||||
mesh.userData.isCustomCollider = true;
|
||||
scene.add(mesh);
|
||||
obstacles.push({ mesh });
|
||||
});
|
||||
buildPathfindingGrid?.();
|
||||
} catch (e) {
|
||||
console.warn('Не удалось загрузить colliders.json', e);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('socket инстанс:', socket);
|
||||
console.log('Подключение к серверу:', serverUrl);
|
||||
|
||||
socket.on('connect', () => {
|
||||
console.log('✔ Socket connected, id=', socket.id);
|
||||
console.log('Подключение успешно установлено');
|
||||
setConnectionLost(false);
|
||||
// Подписка на ping/pong менеджера Socket.IO для измерения задержки
|
||||
try {
|
||||
const mgr = socket.io;
|
||||
if (mgr && typeof mgr.on === 'function') {
|
||||
mgr.off?.('pong');
|
||||
mgr.on('pong', (latency) => {
|
||||
if (typeof latency === 'number' && isFinite(latency)) {
|
||||
setLatencyMs(Math.round(latency));
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (e) { /* noop */ }
|
||||
});
|
||||
|
||||
// Загрузка пользовательских коллайдеров при старте (по текущему городу)
|
||||
try {
|
||||
const profile = JSON.parse(sessionStorage.getItem('user_profile') || '{}');
|
||||
const initialCityId = profile.last_city_id || 1;
|
||||
loadCustomCollidersForCity(initialCityId);
|
||||
} catch {}
|
||||
|
||||
socket.on('connect_error', err => {
|
||||
console.error('Socket connect_error:', err);
|
||||
console.error('Ошибка подключения к серверу:', serverUrl);
|
||||
console.error('Проверьте, что сервер запущен на порту 4000');
|
||||
setConnectionLost(true);
|
||||
});
|
||||
|
||||
socket.on('disconnect', reason => {
|
||||
console.warn('Socket disconnected:', reason);
|
||||
console.warn('Соединение разорвано, причина:', reason);
|
||||
setConnectionLost(true);
|
||||
});
|
||||
|
||||
// Небольшой таймер для обновления latency при отсутствии событий
|
||||
const pingTimer = setInterval(() => {
|
||||
const s = socketRef.current;
|
||||
if (!s || s.disconnected) return;
|
||||
// менеджер сам шлёт ping с интервалом, мы лишь не даём UI "застывать"
|
||||
// если давно не было pong — считаем соединение деградировало
|
||||
setLatencyMs((prev) => (prev == null ? prev : Math.min(prev + 1, 999)));
|
||||
}, 1000);
|
||||
const profile = JSON.parse(sessionStorage.getItem('user_profile') || '{}');
|
||||
if (profile?.id) {
|
||||
socket.emit('economy:getBalance', { userId: profile.id });
|
||||
@@ -2949,12 +3025,12 @@ function Game({ avatarUrl, gender }) {
|
||||
if (e.ctrlKey) {
|
||||
// При нажатом Ctrl управляем и вертикальным, и горизонтальным углом камеры
|
||||
if (e.shiftKey) {
|
||||
// Shift + Ctrl + колесо = горизонтальный поворот (влево-вправо)
|
||||
// Shift + Ctrl + колесо = горизонтальный поворот (влево-вправо) относительно исходного азимута
|
||||
const horizontalDelta = delta * 2; // Увеличиваем чувствительность
|
||||
baseAzimuth = THREE.MathUtils.clamp(
|
||||
baseAzimuth + horizontalDelta,
|
||||
-Math.PI / 2, // -90 градусов
|
||||
Math.PI / 2 // +90 градусов
|
||||
horizontalYaw = THREE.MathUtils.clamp(
|
||||
horizontalYaw + horizontalDelta,
|
||||
-Math.PI / 2,
|
||||
Math.PI / 2
|
||||
);
|
||||
} else {
|
||||
// Ctrl + колесо = вертикальный поворот (вверх-вниз)
|
||||
@@ -3876,19 +3952,9 @@ function Game({ avatarUrl, gender }) {
|
||||
if (event.ctrlKey) {
|
||||
const key = event.key.toLowerCase();
|
||||
if (key === 'arrowleft') {
|
||||
const horizontalDelta = -0.1; // Поворот влево
|
||||
baseAzimuth = THREE.MathUtils.clamp(
|
||||
baseAzimuth + horizontalDelta,
|
||||
-Math.PI / 2, // -90 градусов
|
||||
Math.PI / 2 // +90 градусов
|
||||
);
|
||||
horizontalYaw = THREE.MathUtils.clamp(horizontalYaw - 0.1, -Math.PI / 2, Math.PI / 2);
|
||||
} else if (key === 'arrowright') {
|
||||
const horizontalDelta = 0.1; // Поворот вправо
|
||||
baseAzimuth = THREE.MathUtils.clamp(
|
||||
baseAzimuth + horizontalDelta,
|
||||
-Math.PI / 2, // -90 градусов
|
||||
Math.PI / 2 // +90 градусов
|
||||
);
|
||||
horizontalYaw = THREE.MathUtils.clamp(horizontalYaw + 0.1, -Math.PI / 2, Math.PI / 2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3915,15 +3981,15 @@ function Game({ avatarUrl, gender }) {
|
||||
|
||||
function createPlayerLabel(text) {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 512; // Увеличиваем размер canvas
|
||||
canvas.height = 128;
|
||||
canvas.width = 1024; // Увеличиваем размер canvas
|
||||
canvas.height = 256;
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
// Добавляем фон для лучшей видимости
|
||||
ctx.fillStyle = 'rgba(0, 0, 0, 0.7)';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
const fontSize = 32; // Увеличиваем размер шрифта
|
||||
const fontSize = 72; // Увеличиваем размер шрифта
|
||||
ctx.fillStyle = 'white';
|
||||
ctx.font = `bold ${fontSize}px Arial`;
|
||||
|
||||
@@ -3946,7 +4012,7 @@ function Game({ avatarUrl, gender }) {
|
||||
depthWrite: false
|
||||
});
|
||||
const sprite = new THREE.Sprite(spriteMaterial);
|
||||
sprite.scale.set(1, 0.25, 1); // Увеличиваем размер спрайта
|
||||
sprite.scale.set(2.2, 0.55, 1); // Увеличиваем размер спрайта
|
||||
|
||||
// ↓↓↓ добавь это ↓↓↓
|
||||
sprite.raycast = () => { };
|
||||
@@ -3994,6 +4060,27 @@ function Game({ avatarUrl, gender }) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Подсчёт количества пересечений с препятствиями для позиции (для "саморазблокировки")
|
||||
function countIntersectionsAtPosition(pos, halfSize = 1) {
|
||||
const playerMin = new THREE.Vector2(pos.x - halfSize, pos.z - halfSize);
|
||||
const playerMax = new THREE.Vector2(pos.x + halfSize, pos.z + halfSize);
|
||||
|
||||
let count = 0;
|
||||
for (let i = 0; i < obstacles.length; i++) {
|
||||
const mesh = obstacles[i]?.mesh;
|
||||
if (!mesh) continue;
|
||||
mesh.updateMatrixWorld();
|
||||
const box = new THREE.Box3().setFromObject(mesh);
|
||||
const obstacleMin = new THREE.Vector2(box.min.x, box.min.z);
|
||||
const obstacleMax = new THREE.Vector2(box.max.x, box.max.z);
|
||||
if ((playerMin.x <= obstacleMax.x && playerMax.x >= obstacleMin.x) &&
|
||||
(playerMin.y <= obstacleMax.y && playerMax.y >= obstacleMin.y)) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
function updateDestinationMovement(delta) {
|
||||
if (!player || currentPath.length === 0 || pathIndex >= currentPath.length) return;
|
||||
|
||||
@@ -4004,7 +4091,29 @@ function Game({ avatarUrl, gender }) {
|
||||
|
||||
const stepDistance = moveSpeed * delta;
|
||||
if (dist < stepDistance) {
|
||||
// Двигаем к точке и аккуратно выравниваем по верхней поверхности
|
||||
player.position.copy(target);
|
||||
// Жёсткое выравнивание по топ-поверхности, чтобы исключить спад до y=0 на остановке
|
||||
(function alignGroundFinal(p) {
|
||||
const downRay = new THREE.Raycaster(
|
||||
new THREE.Vector3(p.x, 100, p.z),
|
||||
new THREE.Vector3(0, -1, 0),
|
||||
0,
|
||||
300
|
||||
);
|
||||
downRay.camera = cameraRef.current;
|
||||
const walkables = [
|
||||
...(cityGroupRef.current ? [cityGroupRef.current] : []),
|
||||
groundPlane,
|
||||
...(cityMeshesRef.current || [])
|
||||
].filter(Boolean);
|
||||
const raw = downRay.intersectObjects(walkables, true);
|
||||
const isDescendantOf = (obj, ancestor) => { let c=obj; while(c){ if(c===ancestor) return true; c=c.parent;} return false; };
|
||||
const hits = raw
|
||||
.filter(h => !h.object.isSprite && h.face && h.face.normal && h.face.normal.y > 0.6)
|
||||
.filter(h => !isDescendantOf(h.object, player));
|
||||
if (hits.length) p.y = hits[0].point.y + 0.02;
|
||||
})(player.position);
|
||||
pathIndex++;
|
||||
blockedTime = 0;
|
||||
if (pathIndex >= currentPath.length) {
|
||||
@@ -4022,6 +4131,16 @@ function Game({ avatarUrl, gender }) {
|
||||
dir.normalize();
|
||||
const step = dir.clone().multiplyScalar(stepDistance);
|
||||
|
||||
// 1) Поворот всегда догоняет, движение начинается сразу — естественное скольжение в сторону цели
|
||||
const desiredYaw = Math.atan2(dir.x, dir.z);
|
||||
const currentYaw = new THREE.Euler().setFromQuaternion(player.quaternion, 'YXZ').y;
|
||||
let yawDiff = desiredYaw - currentYaw;
|
||||
yawDiff = ((yawDiff + Math.PI) % (2 * Math.PI)) - Math.PI; // нормализация [-PI, PI]
|
||||
const maxTurnRate = 3.0; // рад/сек — ограничиваем скорость поворота
|
||||
const stepAngle = THREE.MathUtils.clamp(yawDiff, -maxTurnRate * delta, maxTurnRate * delta);
|
||||
const newYawFollow = currentYaw + stepAngle;
|
||||
player.quaternion.setFromEuler(new THREE.Euler(0, newYawFollow, 0));
|
||||
|
||||
// Кандидаты перемещения: прямо, слайд по X, слайд по Z
|
||||
const tryMoves = [
|
||||
player.position.clone().add(step),
|
||||
@@ -4029,7 +4148,7 @@ function Game({ avatarUrl, gender }) {
|
||||
player.position.clone().add(new THREE.Vector3(0, 0, step.z))
|
||||
];
|
||||
|
||||
// Помощник: «привязка» к верхней поверхности
|
||||
// Помощник: «привязка» к верхней поверхности (учитываем всю геометрию города)
|
||||
const stickToTopSurface = (pos) => {
|
||||
const downRay = new THREE.Raycaster(
|
||||
new THREE.Vector3(pos.x, 100, pos.z),
|
||||
@@ -4039,12 +4158,21 @@ function Game({ avatarUrl, gender }) {
|
||||
);
|
||||
downRay.camera = cameraRef.current; // важное дополнение для спрайтов
|
||||
|
||||
// фильтруем null/undefined
|
||||
const walkables = [groundPlane, ...(cityMeshesRef.current || [])].filter(Boolean);
|
||||
// фильтруем null/undefined и целимся в корневую группу города + groundPlane
|
||||
const walkables = [
|
||||
...(cityGroupRef.current ? [cityGroupRef.current] : []),
|
||||
groundPlane,
|
||||
...(cityMeshesRef.current || [])
|
||||
].filter(Boolean);
|
||||
|
||||
const hits = downRay
|
||||
.intersectObjects(walkables, true)
|
||||
.filter(h => !h.object.isSprite && h.face && h.face.normal && h.face.normal.y > 0.6);
|
||||
const raw = downRay.intersectObjects(walkables, true);
|
||||
const isDescendantOf = (obj, ancestor) => {
|
||||
let cur = obj; while (cur) { if (cur === ancestor) return true; cur = cur.parent; }
|
||||
return false;
|
||||
};
|
||||
const hits = raw
|
||||
.filter(h => !h.object.isSprite && h.face && h.face.normal && h.face.normal.y > 0.6)
|
||||
.filter(h => !isDescendantOf(h.object, player));
|
||||
|
||||
if (hits.length) {
|
||||
pos.y = hits[0].point.y + 0.02; // лёгкий "антизалип"
|
||||
@@ -4063,10 +4191,59 @@ function Game({ avatarUrl, gender }) {
|
||||
}
|
||||
}
|
||||
|
||||
// Саморазблокировка: если не удалось пройти обычной проверкой, но текущая клетка непроходима,
|
||||
// ищем ближайшее направление с уменьшением количества пересечений и прогрессом к цели
|
||||
if (!moved) {
|
||||
const currentIntersections = countIntersectionsAtPosition(player.position, 1);
|
||||
if (currentIntersections > 0) {
|
||||
const radii = [stepDistance * 0.6, stepDistance * 1.0, stepDistance * 1.6];
|
||||
const angles = 24; // 15° шаг
|
||||
let bestPos = null;
|
||||
let bestScore = currentIntersections;
|
||||
let bestDist = Infinity;
|
||||
const escapeHalf = 0.6; // слегка ужимаем хитбокс при выходе
|
||||
for (const r of radii) {
|
||||
for (let i = 0; i < angles; i++) {
|
||||
const a = (i / angles) * Math.PI * 2;
|
||||
const dir2 = new THREE.Vector3(Math.sin(a), 0, Math.cos(a));
|
||||
const cand = player.position.clone().addScaledVector(dir2, r);
|
||||
const score = countIntersectionsAtPosition(cand, escapeHalf);
|
||||
const distToTarget = cand.distanceTo(target);
|
||||
if (
|
||||
score < bestScore ||
|
||||
(score === bestScore && distToTarget < bestDist)
|
||||
) {
|
||||
bestScore = score;
|
||||
bestDist = distToTarget;
|
||||
bestPos = cand;
|
||||
if (bestScore === 0) break;
|
||||
}
|
||||
}
|
||||
if (bestScore === 0) break;
|
||||
}
|
||||
if (bestPos) {
|
||||
stickToTopSurface(bestPos);
|
||||
player.position.copy(bestPos);
|
||||
moved = true;
|
||||
blockedTime = 0;
|
||||
} else {
|
||||
// Последняя попытка: небольшая "встряска" вверх и повторное прилипание к поверхности
|
||||
const nudged = player.position.clone();
|
||||
nudged.y += 0.05;
|
||||
stickToTopSurface(nudged);
|
||||
player.position.copy(nudged);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (moved) {
|
||||
const angle = Math.atan2(dir.x, dir.z);
|
||||
const targetQuat = new THREE.Quaternion().setFromEuler(new THREE.Euler(0, angle, 0));
|
||||
player.quaternion.slerp(targetQuat, Math.min(1, 10 * delta));
|
||||
// Плавный доворот в сторону движения, но движение идёт сразу
|
||||
const curYaw = new THREE.Euler().setFromQuaternion(player.quaternion, 'YXZ').y;
|
||||
let d = desiredYaw - curYaw;
|
||||
d = ((d + Math.PI) % (2 * Math.PI)) - Math.PI;
|
||||
const rotStep = THREE.MathUtils.clamp(d, -maxTurnRate * delta, maxTurnRate * delta);
|
||||
const newYaw = curYaw + rotStep;
|
||||
player.quaternion.setFromEuler(new THREE.Euler(0, newYaw, 0));
|
||||
socketRef.current?.emit('playerMovement', { x: player.position.x, y: player.position.y, z: player.position.z });
|
||||
|
||||
if (currentAction !== walkAction) {
|
||||
@@ -4102,8 +4279,32 @@ function Game({ avatarUrl, gender }) {
|
||||
idleAction.reset().fadeIn(0.2).play();
|
||||
currentAction = idleAction;
|
||||
}
|
||||
// Жёсткое выравнивание по топ-поверхности при переходе в idle
|
||||
(function alignGroundFinal(p) {
|
||||
const downRay = new THREE.Raycaster(
|
||||
new THREE.Vector3(p.x, 100, p.z),
|
||||
new THREE.Vector3(0, -1, 0),
|
||||
0,
|
||||
300
|
||||
);
|
||||
downRay.camera = cameraRef.current;
|
||||
const walkables = [
|
||||
...(cityGroupRef.current ? [cityGroupRef.current] : []),
|
||||
groundPlane,
|
||||
...(cityMeshesRef.current || [])
|
||||
].filter(Boolean);
|
||||
const raw = downRay.intersectObjects(walkables, true);
|
||||
const isDescendantOf = (obj, ancestor) => { let c=obj; while(c){ if(c===ancestor) return true; c=c.parent;} return false; };
|
||||
const hits = raw
|
||||
.filter(h => !h.object.isSprite && h.face && h.face.normal && h.face.normal.y > 0.6)
|
||||
.filter(h => !isDescendantOf(h.object, player));
|
||||
if (hits.length) p.y = hits[0].point.y + 0.02;
|
||||
})(player.position);
|
||||
}
|
||||
}
|
||||
|
||||
// Всегда подравниваем Y к верхней поверхности, чтобы исключить проваливания на остановке
|
||||
stickToTopSurface(player.position);
|
||||
}
|
||||
|
||||
|
||||
@@ -4330,8 +4531,10 @@ function Game({ avatarUrl, gender }) {
|
||||
const polar = basePolar + cameraPitchOffset;
|
||||
const planar = radius * Math.cos(polar);
|
||||
const yOff = radius * Math.sin(polar);
|
||||
const xOff = planar * Math.cos(baseAzimuth);
|
||||
const zOff = planar * Math.sin(baseAzimuth);
|
||||
// Горизонтальный угол = исходный азимут + относительный поворот (±90°)
|
||||
const azimuth = baseAzimuth0 + horizontalYaw;
|
||||
const xOff = planar * Math.cos(azimuth);
|
||||
const zOff = planar * Math.sin(azimuth);
|
||||
|
||||
// Плавная интерполяция позиции камеры
|
||||
const targetPosition = new THREE.Vector3(
|
||||
@@ -4353,6 +4556,16 @@ function Game({ avatarUrl, gender }) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Блокировка управления при потере соединения
|
||||
if (connectionLostRef.current) {
|
||||
// Останавливаем любые движения
|
||||
if (moveInputRef.current) {
|
||||
Object.keys(moveInputRef.current).forEach(k => moveInputRef.current[k] = false);
|
||||
}
|
||||
// Скрыть маркер назначения
|
||||
if (destinationMarker) destinationMarker.visible = false;
|
||||
}
|
||||
|
||||
if (!clock || typeof clock.getDelta !== 'function') {
|
||||
console.warn('Clock не инициализирован');
|
||||
return;
|
||||
@@ -4606,6 +4819,14 @@ function Game({ avatarUrl, gender }) {
|
||||
<div style={{ position: 'absolute', top: 20, right: 150, zIndex: 1000, background: 'rgba(0,0,0,0.6)', color: '#fff', padding: '4px 8px', borderRadius: 4 }}>
|
||||
X: {playerCoords.x} Y: {playerCoords.y} Z: {playerCoords.z}
|
||||
</div>
|
||||
{/* Индикатор связи в правом нижнем углу */}
|
||||
<div style={{ position: 'absolute', right: 20, bottom: 20, zIndex: 10000, display: 'flex', alignItems: 'center', gap: 8,
|
||||
background: 'rgba(15,15,20,0.75)', color: '#fff', padding: '8px 10px', borderRadius: 10, backdropFilter: 'blur(4px)'}}>
|
||||
<div style={{ width: 10, height: 10, borderRadius: '50%', background: connectionLost ? '#ef4444' : (latencyMs == null ? '#f59e0b' : (latencyMs < 80 ? '#22c55e' : latencyMs < 160 ? '#eab308' : '#ef4444')) }} />
|
||||
<div style={{ fontSize: 12, opacity: 0.9 }}>
|
||||
{connectionLost ? 'Связь: нет' : `Пинг: ${latencyMs ?? '—'} ms`}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ position: 'absolute', bottom: 20, left: 20, zIndex: 1000, background: 'rgba(0,0,0,0.6)', color: '#fff', padding: '4px 8px', borderRadius: 4 }}>
|
||||
{(() => {
|
||||
if (!gameTime) return 'Загрузка времени...';
|
||||
@@ -4614,6 +4835,19 @@ function Game({ avatarUrl, gender }) {
|
||||
return d.toLocaleString();
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* Оверлей при потере соединения */}
|
||||
{connectionLost && (
|
||||
<div style={{ position: 'absolute', inset: 0, zIndex: 20000, background: 'rgba(0,0,0,0.8)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<div style={{ background: 'rgba(20,20,25,0.95)', padding: '24px 28px', borderRadius: 12, color: '#fff', width: 420, textAlign: 'center', boxShadow: '0 12px 40px rgba(0,0,0,0.45)' }}>
|
||||
<div style={{ fontSize: 20, fontWeight: 700, marginBottom: 10 }}>Соединение потеряно</div>
|
||||
<div style={{ fontSize: 14, opacity: 0.9, marginBottom: 16 }}>Связь с сервером была прервана. Пожалуйста, перезайдите в игру.</div>
|
||||
<button onClick={() => window.location.reload()} style={{
|
||||
background: '#ef4444', border: 'none', color: '#fff', padding: '10px 14px', borderRadius: 8, cursor: 'pointer', fontWeight: 700
|
||||
}}>Перезайти</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Кнопка карты мира */}
|
||||
<button
|
||||
style={{
|
||||
|
||||
611
src/api/README.md
Normal file
611
src/api/README.md
Normal file
@@ -0,0 +1,611 @@
|
||||
# API EEV_Proj
|
||||
|
||||
## Обзор
|
||||
|
||||
API функции для взаимодействия с серверной частью проекта EEV_Proj. Все функции используют современный JavaScript и обеспечивают единообразный интерфейс для работы с сервером.
|
||||
|
||||
## Структура
|
||||
|
||||
```
|
||||
api/
|
||||
├── auth.js # Функции аутентификации
|
||||
└── README.md # Эта документация
|
||||
```
|
||||
|
||||
## auth.js
|
||||
|
||||
### Функции аутентификации
|
||||
|
||||
#### getUsersStatus(token)
|
||||
Получает статус всех пользователей в системе.
|
||||
|
||||
**Параметры:**
|
||||
- `token` (string) - JWT токен аутентификации
|
||||
|
||||
**Возвращает:**
|
||||
- Promise<Array> - Массив пользователей с их статусами
|
||||
|
||||
**Пример использования:**
|
||||
```javascript
|
||||
import { getUsersStatus } from './api/auth.js';
|
||||
|
||||
const token = localStorage.getItem('token');
|
||||
try {
|
||||
const users = await getUsersStatus(token);
|
||||
console.log('Пользователи:', users);
|
||||
} catch (error) {
|
||||
console.error('Ошибка получения статуса пользователей:', error);
|
||||
}
|
||||
```
|
||||
|
||||
#### loadUserInfo(userId, token)
|
||||
Загружает информацию о конкретном пользователе.
|
||||
|
||||
**Параметры:**
|
||||
- `userId` (string|number) - ID пользователя
|
||||
- `token` (string) - JWT токен аутентификации
|
||||
|
||||
**Возвращает:**
|
||||
- Promise<Object> - Объект с информацией о пользователе
|
||||
|
||||
**Пример использования:**
|
||||
```javascript
|
||||
import { loadUserInfo } from './api/auth.js';
|
||||
|
||||
const token = localStorage.getItem('token');
|
||||
try {
|
||||
const userInfo = await loadUserInfo('123', token);
|
||||
console.log('Информация о пользователе:', userInfo);
|
||||
} catch (error) {
|
||||
console.error('Ошибка загрузки информации о пользователе:', error);
|
||||
}
|
||||
```
|
||||
|
||||
## Создание новых API функций
|
||||
|
||||
### Шаблон API функции
|
||||
|
||||
```javascript
|
||||
/**
|
||||
* Описание функции
|
||||
* @param {string} param1 - Описание параметра 1
|
||||
* @param {number} param2 - Описание параметра 2
|
||||
* @returns {Promise<Object>} Описание возвращаемого значения
|
||||
*/
|
||||
export async function apiFunction(param1, param2) {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) {
|
||||
throw new Error('Токен не найден');
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/endpoint`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({ param1, param2 })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.message || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Ошибка API функции:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Принципы
|
||||
|
||||
1. **Единообразие** - Все функции следуют одному паттерну
|
||||
2. **Обработка ошибок** - Всегда обрабатывайте ошибки
|
||||
3. **Валидация** - Проверяйте входные параметры
|
||||
4. **Логирование** - Логируйте ошибки для отладки
|
||||
5. **Типизация** - Используйте JSDoc для документирования типов
|
||||
|
||||
## Обработка ошибок
|
||||
|
||||
### Типы ошибок
|
||||
|
||||
```javascript
|
||||
// Сетевые ошибки
|
||||
class NetworkError extends Error {
|
||||
constructor(message, status) {
|
||||
super(message);
|
||||
this.name = 'NetworkError';
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
// Ошибки аутентификации
|
||||
class AuthError extends Error {
|
||||
constructor(message) {
|
||||
super(message);
|
||||
this.name = 'AuthError';
|
||||
}
|
||||
}
|
||||
|
||||
// Ошибки валидации
|
||||
class ValidationError extends Error {
|
||||
constructor(message, field) {
|
||||
super(message);
|
||||
this.name = 'ValidationError';
|
||||
this.field = field;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Обработка в компонентах
|
||||
|
||||
```javascript
|
||||
import { apiFunction } from './api/api.js';
|
||||
|
||||
const MyComponent = () => {
|
||||
const [data, setData] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleApiCall = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const result = await apiFunction('param1', 'param2');
|
||||
setData(result);
|
||||
} catch (error) {
|
||||
if (error.name === 'AuthError') {
|
||||
// Перенаправление на страницу входа
|
||||
navigate('/login');
|
||||
} else if (error.name === 'ValidationError') {
|
||||
// Показать ошибку валидации
|
||||
setError(`Ошибка в поле ${error.field}: ${error.message}`);
|
||||
} else {
|
||||
// Общая ошибка
|
||||
setError(error.message);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{loading && <div>Загрузка...</div>}
|
||||
{error && <div className="error">{error}</div>}
|
||||
{data && <div>{/* Отображение данных */}</div>}
|
||||
<button onClick={handleApiCall}>Вызвать API</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
## Кэширование
|
||||
|
||||
### Простое кэширование
|
||||
|
||||
```javascript
|
||||
const cache = new Map();
|
||||
|
||||
export async function cachedApiCall(key, apiFunction) {
|
||||
if (cache.has(key)) {
|
||||
const { data, timestamp } = cache.get(key);
|
||||
const now = Date.now();
|
||||
|
||||
// Кэш действителен 5 минут
|
||||
if (now - timestamp < 5 * 60 * 1000) {
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await apiFunction();
|
||||
cache.set(key, { data, timestamp: Date.now() });
|
||||
return data;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Использование
|
||||
|
||||
```javascript
|
||||
import { cachedApiCall } from './api/cache.js';
|
||||
import { getUsersStatus } from './api/auth.js';
|
||||
|
||||
const loadUsers = async () => {
|
||||
const token = localStorage.getItem('token');
|
||||
return await cachedApiCall('users-status', () => getUsersStatus(token));
|
||||
};
|
||||
```
|
||||
|
||||
## Retry логика
|
||||
|
||||
### Автоматические повторы
|
||||
|
||||
```javascript
|
||||
/**
|
||||
* Выполняет API вызов с автоматическими повторами
|
||||
* @param {Function} apiFunction - Функция API
|
||||
* @param {number} maxRetries - Максимальное количество повторов
|
||||
* @param {number} delay - Задержка между повторами в мс
|
||||
*/
|
||||
export async function retryApiCall(apiFunction, maxRetries = 3, delay = 1000) {
|
||||
let lastError;
|
||||
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
return await apiFunction();
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
|
||||
if (attempt === maxRetries) {
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
// Ждем перед следующим попыткой
|
||||
await new Promise(resolve => setTimeout(resolve, delay * attempt));
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Использование
|
||||
|
||||
```javascript
|
||||
import { retryApiCall } from './api/retry.js';
|
||||
import { getUsersStatus } from './api/auth.js';
|
||||
|
||||
const loadUsersWithRetry = async () => {
|
||||
const token = localStorage.getItem('token');
|
||||
return await retryApiCall(() => getUsersStatus(token), 3, 1000);
|
||||
};
|
||||
```
|
||||
|
||||
## Batch запросы
|
||||
|
||||
### Группировка запросов
|
||||
|
||||
```javascript
|
||||
/**
|
||||
* Выполняет несколько API запросов параллельно
|
||||
* @param {Array<Function>} apiFunctions - Массив функций API
|
||||
* @returns {Promise<Array>} Массив результатов
|
||||
*/
|
||||
export async function batchApiCalls(apiFunctions) {
|
||||
try {
|
||||
const results = await Promise.allSettled(apiFunctions.map(fn => fn()));
|
||||
|
||||
return results.map((result, index) => {
|
||||
if (result.status === 'fulfilled') {
|
||||
return result.value;
|
||||
} else {
|
||||
console.error(`Ошибка в запросе ${index}:`, result.reason);
|
||||
return null;
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Ошибка batch запросов:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Использование
|
||||
|
||||
```javascript
|
||||
import { batchApiCalls } from './api/batch.js';
|
||||
import { getUsersStatus, loadUserInfo } from './api/auth.js';
|
||||
|
||||
const loadAllData = async () => {
|
||||
const token = localStorage.getItem('token');
|
||||
|
||||
const results = await batchApiCalls([
|
||||
() => getUsersStatus(token),
|
||||
() => loadUserInfo('123', token),
|
||||
() => loadUserInfo('456', token)
|
||||
]);
|
||||
|
||||
const [users, user1, user2] = results;
|
||||
return { users, user1, user2 };
|
||||
};
|
||||
```
|
||||
|
||||
## WebSocket API
|
||||
|
||||
### Подключение
|
||||
|
||||
```javascript
|
||||
import { io } from 'socket.io-client';
|
||||
|
||||
class WebSocketAPI {
|
||||
constructor() {
|
||||
this.socket = null;
|
||||
this.isConnected = false;
|
||||
}
|
||||
|
||||
connect(token) {
|
||||
const serverUrl = window.location.hostname === 'localhost'
|
||||
? 'http://localhost:4000'
|
||||
: window.location.origin;
|
||||
|
||||
this.socket = io(serverUrl, {
|
||||
transports: ['websocket', 'polling'],
|
||||
auth: { token },
|
||||
timeout: 20000
|
||||
});
|
||||
|
||||
this.socket.on('connect', () => {
|
||||
this.isConnected = true;
|
||||
console.log('WebSocket подключен');
|
||||
});
|
||||
|
||||
this.socket.on('disconnect', () => {
|
||||
this.isConnected = false;
|
||||
console.log('WebSocket отключен');
|
||||
});
|
||||
|
||||
return this.socket;
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
if (this.socket) {
|
||||
this.socket.disconnect();
|
||||
this.socket = null;
|
||||
this.isConnected = false;
|
||||
}
|
||||
}
|
||||
|
||||
emit(event, data) {
|
||||
if (this.socket && this.isConnected) {
|
||||
this.socket.emit(event, data);
|
||||
} else {
|
||||
console.warn('WebSocket не подключен');
|
||||
}
|
||||
}
|
||||
|
||||
on(event, callback) {
|
||||
if (this.socket) {
|
||||
this.socket.on(event, callback);
|
||||
}
|
||||
}
|
||||
|
||||
off(event, callback) {
|
||||
if (this.socket) {
|
||||
this.socket.off(event, callback);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const wsAPI = new WebSocketAPI();
|
||||
```
|
||||
|
||||
### Использование
|
||||
|
||||
```javascript
|
||||
import { wsAPI } from './api/websocket.js';
|
||||
|
||||
// Подключение
|
||||
const token = localStorage.getItem('token');
|
||||
wsAPI.connect(token);
|
||||
|
||||
// Отправка события
|
||||
wsAPI.emit('playerMovement', { x: 100, y: 0, z: 200 });
|
||||
|
||||
// Подписка на события
|
||||
wsAPI.on('economy:balanceChanged', ({ userId, newBalance }) => {
|
||||
console.log('Баланс изменился:', newBalance);
|
||||
});
|
||||
|
||||
// Отписка
|
||||
wsAPI.off('economy:balanceChanged');
|
||||
```
|
||||
|
||||
## Тестирование API
|
||||
|
||||
### Mock функции
|
||||
|
||||
```javascript
|
||||
// __mocks__/api/auth.js
|
||||
export const getUsersStatus = jest.fn();
|
||||
export const loadUserInfo = jest.fn();
|
||||
|
||||
// Сброс моков
|
||||
beforeEach(() => {
|
||||
getUsersStatus.mockClear();
|
||||
loadUserInfo.mockClear();
|
||||
});
|
||||
```
|
||||
|
||||
### Тесты
|
||||
|
||||
```javascript
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { getUsersStatus } from './api/auth.js';
|
||||
|
||||
// Мокаем модуль
|
||||
jest.mock('./api/auth.js');
|
||||
|
||||
describe('API Functions', () => {
|
||||
it('загружает статус пользователей', async () => {
|
||||
const mockUsers = [
|
||||
{ id: 1, name: 'User 1', status: 'online' },
|
||||
{ id: 2, name: 'User 2', status: 'offline' }
|
||||
];
|
||||
|
||||
getUsersStatus.mockResolvedValue(mockUsers);
|
||||
|
||||
const result = await getUsersStatus('token');
|
||||
|
||||
expect(result).toEqual(mockUsers);
|
||||
expect(getUsersStatus).toHaveBeenCalledWith('token');
|
||||
});
|
||||
|
||||
it('обрабатывает ошибки API', async () => {
|
||||
const errorMessage = 'Unauthorized';
|
||||
getUsersStatus.mockRejectedValue(new Error(errorMessage));
|
||||
|
||||
await expect(getUsersStatus('invalid-token')).rejects.toThrow(errorMessage);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Мониторинг и метрики
|
||||
|
||||
### Логирование API вызовов
|
||||
|
||||
```javascript
|
||||
class APIMonitor {
|
||||
constructor() {
|
||||
this.calls = [];
|
||||
this.errors = [];
|
||||
}
|
||||
|
||||
logCall(endpoint, method, duration, success) {
|
||||
const call = {
|
||||
endpoint,
|
||||
method,
|
||||
duration,
|
||||
success,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
this.calls.push(call);
|
||||
|
||||
// Ограничиваем размер массива
|
||||
if (this.calls.length > 1000) {
|
||||
this.calls.shift();
|
||||
}
|
||||
}
|
||||
|
||||
logError(endpoint, method, error) {
|
||||
const errorLog = {
|
||||
endpoint,
|
||||
method,
|
||||
error: error.message,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
this.errors.push(errorLog);
|
||||
|
||||
if (this.errors.length > 100) {
|
||||
this.errors.shift();
|
||||
}
|
||||
}
|
||||
|
||||
getStats() {
|
||||
const totalCalls = this.calls.length;
|
||||
const successfulCalls = this.calls.filter(call => call.success).length;
|
||||
const errorCalls = this.errors.length;
|
||||
const avgDuration = this.calls.reduce((sum, call) => sum + call.duration, 0) / totalCalls;
|
||||
|
||||
return {
|
||||
totalCalls,
|
||||
successfulCalls,
|
||||
errorCalls,
|
||||
successRate: (successfulCalls / totalCalls) * 100,
|
||||
avgDuration
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const apiMonitor = new APIMonitor();
|
||||
```
|
||||
|
||||
### Использование в API функциях
|
||||
|
||||
```javascript
|
||||
import { apiMonitor } from './api/monitor.js';
|
||||
|
||||
export async function monitoredApiCall(endpoint, options) {
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const response = await fetch(endpoint, options);
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
apiMonitor.logCall(endpoint, options.method || 'GET', duration, response.ok);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
const duration = Date.now() - startTime;
|
||||
apiMonitor.logCall(endpoint, options.method || 'GET', duration, false);
|
||||
apiMonitor.logError(endpoint, options.method || 'GET', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Безопасность
|
||||
|
||||
### Валидация токенов
|
||||
|
||||
```javascript
|
||||
export function validateToken(token) {
|
||||
if (!token) {
|
||||
throw new Error('Токен не предоставлен');
|
||||
}
|
||||
|
||||
// Проверяем формат JWT токена
|
||||
const tokenParts = token.split('.');
|
||||
if (tokenParts.length !== 3) {
|
||||
throw new Error('Неверный формат токена');
|
||||
}
|
||||
|
||||
try {
|
||||
// Декодируем payload
|
||||
const payload = JSON.parse(atob(tokenParts[1]));
|
||||
|
||||
// Проверяем срок действия
|
||||
if (payload.exp && Date.now() >= payload.exp * 1000) {
|
||||
throw new Error('Токен истек');
|
||||
}
|
||||
|
||||
return payload;
|
||||
} catch (error) {
|
||||
throw new Error('Неверный токен');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Санитизация данных
|
||||
|
||||
```javascript
|
||||
export function sanitizeInput(input) {
|
||||
if (typeof input !== 'string') {
|
||||
return input;
|
||||
}
|
||||
|
||||
// Удаляем потенциально опасные символы
|
||||
return input
|
||||
.replace(/[<>]/g, '')
|
||||
.replace(/javascript:/gi, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function sanitizeObject(obj) {
|
||||
const sanitized = {};
|
||||
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
if (typeof value === 'string') {
|
||||
sanitized[key] = sanitizeInput(value);
|
||||
} else if (typeof value === 'object' && value !== null) {
|
||||
sanitized[key] = sanitizeObject(value);
|
||||
} else {
|
||||
sanitized[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
```
|
||||
340
src/components/README.md
Normal file
340
src/components/README.md
Normal file
@@ -0,0 +1,340 @@
|
||||
# Компоненты EEV_Proj
|
||||
|
||||
## Обзор
|
||||
|
||||
Компоненты React, используемые в проекте EEV_Proj. Все компоненты следуют принципам функционального программирования и используют хуки React.
|
||||
|
||||
## Структура
|
||||
|
||||
```
|
||||
components/
|
||||
├── DialogSystem/ # Система диалогов
|
||||
│ ├── DialogManager.js # Хук для управления диалогами
|
||||
│ └── DialogWindow.js # Окно диалога
|
||||
├── GameWrapper.jsx # Обертка для игровой сцены
|
||||
├── Inventory.jsx # Инвентарь игрока
|
||||
├── Loading.jsx # Компонент загрузки
|
||||
├── LoginScene.jsx # Сцена входа
|
||||
├── OrgControlPanel.jsx # Панель управления организацией
|
||||
├── RequireProfile.jsx # Компонент для профиля
|
||||
└── README.md # Эта документация
|
||||
```
|
||||
|
||||
## DialogSystem
|
||||
|
||||
### DialogManager.js
|
||||
Хук для управления диалоговой системой.
|
||||
|
||||
**Использование:**
|
||||
```javascript
|
||||
const {
|
||||
currentDialog,
|
||||
dialogIndex,
|
||||
showDialog,
|
||||
loadDialog,
|
||||
handleAnswerSelect,
|
||||
setShowDialog
|
||||
} = useDialogManager();
|
||||
```
|
||||
|
||||
**Методы:**
|
||||
- `loadDialog(npcId)` - Загружает диалог для NPC
|
||||
- `handleAnswerSelect(answer)` - Обрабатывает выбор ответа
|
||||
- `setShowDialog(show)` - Показывает/скрывает диалог
|
||||
|
||||
### DialogWindow.js
|
||||
Компонент окна диалога.
|
||||
|
||||
**Props:**
|
||||
- `dialog` - Объект диалога
|
||||
- `dialogIndex` - Индекс текущего узла
|
||||
- `onAnswerSelect` - Callback для выбора ответа
|
||||
- `onClose` - Callback для закрытия
|
||||
|
||||
## GameWrapper.jsx
|
||||
|
||||
Обертка для игровой сцены, обеспечивающая правильное монтирование и размонтирование.
|
||||
|
||||
**Props:**
|
||||
- `children` - Дочерние компоненты
|
||||
|
||||
**Особенности:**
|
||||
- Автоматическое управление жизненным циклом
|
||||
- Обработка ошибок
|
||||
- Логирование
|
||||
|
||||
## Inventory.jsx
|
||||
|
||||
Компонент инвентаря игрока.
|
||||
|
||||
**Props:**
|
||||
- `inventory` - Массив предметов
|
||||
- `onClose` - Callback для закрытия
|
||||
- `onItemAction` - Callback для действий с предметами
|
||||
|
||||
**Функциональность:**
|
||||
- Отображение списка предметов
|
||||
- Действия с предметами (использовать, выкинуть)
|
||||
- Фильтрация и сортировка
|
||||
|
||||
## Loading.jsx
|
||||
|
||||
Компонент загрузки с анимацией.
|
||||
|
||||
**Props:**
|
||||
- `message` - Сообщение загрузки
|
||||
- `progress` - Прогресс (0-100)
|
||||
|
||||
**Особенности:**
|
||||
- Анимированный спиннер
|
||||
- Прогресс-бар
|
||||
- Кастомные сообщения
|
||||
|
||||
## LoginScene.jsx
|
||||
|
||||
Сцена входа в игру.
|
||||
|
||||
**Функциональность:**
|
||||
- Форма входа
|
||||
- Валидация данных
|
||||
- Обработка ошибок
|
||||
- Перенаправление после входа
|
||||
|
||||
## OrgControlPanel.jsx
|
||||
|
||||
Панель управления организацией.
|
||||
|
||||
**Props:**
|
||||
- `org` - Объект организации
|
||||
- `onClose` - Callback для закрытия
|
||||
- `onBuyItem` - Callback для покупки
|
||||
|
||||
**Функциональность:**
|
||||
- Отображение меню организации
|
||||
- Покупка предметов
|
||||
- Управление настройками
|
||||
|
||||
## RequireProfile.jsx
|
||||
|
||||
Компонент для проверки профиля пользователя.
|
||||
|
||||
**Props:**
|
||||
- `children` - Дочерние компоненты
|
||||
- `redirectTo` - Путь для перенаправления
|
||||
|
||||
**Функциональность:**
|
||||
- Проверка авторизации
|
||||
- Перенаправление неавторизованных пользователей
|
||||
- Защита маршрутов
|
||||
|
||||
## Создание новых компонентов
|
||||
|
||||
### Шаблон компонента
|
||||
|
||||
```javascript
|
||||
import React from 'react';
|
||||
|
||||
/**
|
||||
* Описание компонента
|
||||
* @param {Object} props - Свойства компонента
|
||||
* @param {string} props.title - Заголовок
|
||||
* @param {Function} props.onClick - Callback для клика
|
||||
*/
|
||||
const NewComponent = ({ title, onClick }) => {
|
||||
return (
|
||||
<div className="new-component">
|
||||
<h2>{title}</h2>
|
||||
<button onClick={onClick}>
|
||||
Нажми меня
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NewComponent;
|
||||
```
|
||||
|
||||
### Принципы
|
||||
|
||||
1. **Функциональные компоненты** - Используйте функциональные компоненты с хуками
|
||||
2. **Props валидация** - Добавляйте PropTypes или TypeScript
|
||||
3. **JSDoc** - Документируйте публичные API
|
||||
4. **Единая ответственность** - Каждый компонент должен делать одну вещь
|
||||
5. **Переиспользование** - Создавайте компоненты для переиспользования
|
||||
|
||||
### Стили
|
||||
|
||||
- Используйте CSS-in-JS или CSS модули
|
||||
- Следуйте дизайн-системе проекта
|
||||
- Обеспечивайте адаптивность
|
||||
- Поддерживайте темную/светлую тему
|
||||
|
||||
## Тестирование
|
||||
|
||||
### Unit тесты
|
||||
|
||||
```javascript
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import NewComponent from './NewComponent';
|
||||
|
||||
describe('NewComponent', () => {
|
||||
it('отображает заголовок', () => {
|
||||
render(<NewComponent title="Тест" />);
|
||||
expect(screen.getByText('Тест')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('вызывает onClick при клике', () => {
|
||||
const mockOnClick = jest.fn();
|
||||
render(<NewComponent title="Тест" onClick={mockOnClick} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button'));
|
||||
expect(mockOnClick).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Интеграционные тесты
|
||||
|
||||
```javascript
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import App from '../App';
|
||||
|
||||
const renderWithRouter = (component) => {
|
||||
return render(
|
||||
<BrowserRouter>
|
||||
{component}
|
||||
</BrowserRouter>
|
||||
);
|
||||
};
|
||||
|
||||
describe('App Integration', () => {
|
||||
it('отображает главную страницу', () => {
|
||||
renderWithRouter(<App />);
|
||||
expect(screen.getByText('EEV_Proj')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Производительность
|
||||
|
||||
### Оптимизация
|
||||
|
||||
1. **React.memo** - Мемоизация компонентов
|
||||
2. **useMemo** - Мемоизация вычислений
|
||||
3. **useCallback** - Мемоизация функций
|
||||
4. **Lazy loading** - Ленивая загрузка компонентов
|
||||
|
||||
### Пример оптимизации
|
||||
|
||||
```javascript
|
||||
import React, { useMemo, useCallback } from 'react';
|
||||
|
||||
const OptimizedComponent = React.memo(({ items, onItemClick }) => {
|
||||
const sortedItems = useMemo(() => {
|
||||
return items.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}, [items]);
|
||||
|
||||
const handleItemClick = useCallback((item) => {
|
||||
onItemClick(item);
|
||||
}, [onItemClick]);
|
||||
|
||||
return (
|
||||
<ul>
|
||||
{sortedItems.map(item => (
|
||||
<li key={item.id} onClick={() => handleItemClick(item)}>
|
||||
{item.name}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
## Доступность
|
||||
|
||||
### ARIA атрибуты
|
||||
|
||||
```javascript
|
||||
const AccessibleComponent = ({ label, value, onChange }) => {
|
||||
return (
|
||||
<div>
|
||||
<label htmlFor="input">{label}</label>
|
||||
<input
|
||||
id="input"
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
aria-describedby="help-text"
|
||||
/>
|
||||
<div id="help-text">Дополнительная информация</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
### Клавиатурная навигация
|
||||
|
||||
```javascript
|
||||
const KeyboardComponent = ({ onEnter, onEscape }) => {
|
||||
const handleKeyDown = (event) => {
|
||||
switch (event.key) {
|
||||
case 'Enter':
|
||||
onEnter();
|
||||
break;
|
||||
case 'Escape':
|
||||
onEscape();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div tabIndex={0} onKeyDown={handleKeyDown}>
|
||||
Нажмите Enter или Escape
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
## Логирование
|
||||
|
||||
### Отладочная информация
|
||||
|
||||
```javascript
|
||||
import { useEffect } from 'react';
|
||||
|
||||
const DebugComponent = ({ data }) => {
|
||||
useEffect(() => {
|
||||
console.log('Component mounted with data:', data);
|
||||
return () => {
|
||||
console.log('Component unmounting');
|
||||
};
|
||||
}, [data]);
|
||||
|
||||
return <div>Debug Component</div>;
|
||||
};
|
||||
```
|
||||
|
||||
### Обработка ошибок
|
||||
|
||||
```javascript
|
||||
import { ErrorBoundary } from 'react-error-boundary';
|
||||
|
||||
const ErrorFallback = ({ error, resetErrorBoundary }) => {
|
||||
return (
|
||||
<div role="alert">
|
||||
<p>Что-то пошло не так:</p>
|
||||
<pre>{error.message}</pre>
|
||||
<button onClick={resetErrorBoundary}>Попробовать снова</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const AppWithErrorBoundary = () => {
|
||||
return (
|
||||
<ErrorBoundary FallbackComponent={ErrorFallback}>
|
||||
<App />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
};
|
||||
```
|
||||
255
src/modules/CameraManager.js
Normal file
255
src/modules/CameraManager.js
Normal file
@@ -0,0 +1,255 @@
|
||||
import * as THREE from 'three';
|
||||
|
||||
/**
|
||||
* Менеджер камер
|
||||
* Отвечает за создание, переключение и управление камерами
|
||||
*/
|
||||
export class CameraManager {
|
||||
constructor() {
|
||||
this.orthoCamera = null;
|
||||
this.fpCamera = null;
|
||||
this.currentCamera = null;
|
||||
this.fpPitch = 0;
|
||||
this.baseOffset = new THREE.Vector3(-200, 150, -200);
|
||||
this.planarDist = Math.hypot(this.baseOffset.x, this.baseOffset.z);
|
||||
this.radius = Math.hypot(this.planarDist, this.baseOffset.y);
|
||||
this.baseAzimuth = Math.atan2(this.baseOffset.z, this.baseOffset.x);
|
||||
this.basePolar = Math.atan2(this.baseOffset.y, this.planarDist);
|
||||
this.cameraPitchOffset = 0;
|
||||
this.maxPitch = THREE.MathUtils.degToRad(10);
|
||||
this.zoom = 10;
|
||||
this.minZoom = this.zoom * 0.1;
|
||||
this.maxZoom = this.zoom * 3.5;
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
/**
|
||||
* Инициализация камер
|
||||
*/
|
||||
init() {
|
||||
this.createOrthoCamera();
|
||||
this.createFirstPersonCamera();
|
||||
this.currentCamera = this.orthoCamera;
|
||||
}
|
||||
|
||||
/**
|
||||
* Создание ортографической камеры (вид сверху)
|
||||
*/
|
||||
createOrthoCamera() {
|
||||
const aspect = window.innerWidth / window.innerHeight;
|
||||
const frustumSize = 50;
|
||||
|
||||
this.orthoCamera = new THREE.OrthographicCamera(
|
||||
frustumSize * aspect / -2,
|
||||
frustumSize * aspect / 2,
|
||||
frustumSize / 2,
|
||||
frustumSize / -2,
|
||||
1,
|
||||
1000
|
||||
);
|
||||
|
||||
this.orthoCamera.position.copy(this.baseOffset);
|
||||
this.orthoCamera.lookAt(0, 0, 0);
|
||||
this.orthoCamera.updateProjectionMatrix();
|
||||
}
|
||||
|
||||
/**
|
||||
* Создание камеры от первого лица
|
||||
*/
|
||||
createFirstPersonCamera() {
|
||||
this.fpCamera = new THREE.PerspectiveCamera(
|
||||
75,
|
||||
window.innerWidth / window.innerHeight,
|
||||
0.1,
|
||||
1000
|
||||
);
|
||||
|
||||
this.fpCamera.position.set(0, 1.6, 0);
|
||||
this.fpCamera.updateProjectionMatrix();
|
||||
}
|
||||
|
||||
/**
|
||||
* Переключение на камеру от первого лица
|
||||
*/
|
||||
switchToFirstPersonCamera(playerPosition, playerRotation) {
|
||||
if (!this.fpCamera || !playerPosition) return;
|
||||
|
||||
this.currentCamera = this.fpCamera;
|
||||
|
||||
// Устанавливаем позицию камеры на уровне глаз игрока
|
||||
const headHeight = 1.6;
|
||||
this.fpCamera.position.set(
|
||||
playerPosition.x,
|
||||
playerPosition.y + headHeight,
|
||||
playerPosition.z
|
||||
);
|
||||
|
||||
// Небольшой сдвиг камеры вперед
|
||||
const forward = new THREE.Vector3(0, 0, -0.08).applyEuler(
|
||||
new THREE.Euler(0, playerRotation.y, 0)
|
||||
);
|
||||
this.fpCamera.position.add(forward);
|
||||
|
||||
// Направляем камеру в том же направлении, что и игрок
|
||||
const direction = new THREE.Vector3(0, 0, -1);
|
||||
direction.applyEuler(new THREE.Euler(0, playerRotation.y, 0));
|
||||
this.fpCamera.lookAt(
|
||||
this.fpCamera.position.clone().add(direction)
|
||||
);
|
||||
|
||||
this.fpPitch = 0;
|
||||
this.fpCamera.updateProjectionMatrix();
|
||||
}
|
||||
|
||||
/**
|
||||
* Переключение на ортографическую камеру
|
||||
*/
|
||||
switchToOrthoCamera() {
|
||||
this.currentCamera = this.orthoCamera;
|
||||
this.fpPitch = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновление позиции ортографической камеры
|
||||
*/
|
||||
updateOrthoCameraPosition(playerPosition) {
|
||||
if (!this.orthoCamera || !playerPosition) return;
|
||||
|
||||
const offset = this.baseOffset.clone();
|
||||
offset.x += playerPosition.x;
|
||||
offset.z += playerPosition.z;
|
||||
|
||||
this.orthoCamera.position.copy(offset);
|
||||
this.orthoCamera.lookAt(playerPosition.x, 0, playerPosition.z);
|
||||
this.orthoCamera.updateProjectionMatrix();
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновление позиции камеры от первого лица
|
||||
*/
|
||||
updateFirstPersonCameraPosition(playerPosition, playerRotation) {
|
||||
if (!this.fpCamera || !playerPosition) return;
|
||||
|
||||
const headHeight = 1.6;
|
||||
this.fpCamera.position.set(
|
||||
playerPosition.x,
|
||||
playerPosition.y + headHeight,
|
||||
playerPosition.z
|
||||
);
|
||||
|
||||
// Сдвиг вперед
|
||||
const forward = new THREE.Vector3(0, 0, -0.08).applyEuler(
|
||||
new THREE.Euler(0, playerRotation.y, 0)
|
||||
);
|
||||
this.fpCamera.position.add(forward);
|
||||
|
||||
// Обновляем направление взгляда
|
||||
const direction = new THREE.Vector3(0, 0, -1);
|
||||
direction.applyEuler(new THREE.Euler(0, playerRotation.y, 0));
|
||||
this.fpCamera.lookAt(
|
||||
this.fpCamera.position.clone().add(direction)
|
||||
);
|
||||
|
||||
this.fpCamera.updateProjectionMatrix();
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработка движения мыши для камеры от первого лица
|
||||
*/
|
||||
handleMouseMove(deltaX, deltaY, sensitivity = 0.002) {
|
||||
if (this.currentCamera !== this.fpCamera) return;
|
||||
|
||||
// Горизонтальный поворот
|
||||
this.fpCamera.rotation.y -= deltaX * sensitivity;
|
||||
|
||||
// Вертикальный поворот с ограничениями
|
||||
this.fpPitch -= deltaY * sensitivity;
|
||||
this.fpPitch = Math.max(-Math.PI / 2, Math.min(Math.PI / 2, this.fpPitch));
|
||||
this.fpCamera.rotation.x = this.fpPitch;
|
||||
|
||||
this.fpCamera.updateProjectionMatrix();
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработка колеса мыши для зума
|
||||
*/
|
||||
handleWheel(delta, sensitivity = 0.1) {
|
||||
if (this.currentCamera !== this.orthoCamera) return;
|
||||
|
||||
this.zoom += delta * sensitivity;
|
||||
this.zoom = Math.max(this.minZoom, Math.min(this.maxZoom, this.zoom));
|
||||
|
||||
// Обновляем размеры frustum
|
||||
const aspect = window.innerWidth / window.innerHeight;
|
||||
const frustumSize = 50 / this.zoom;
|
||||
|
||||
this.orthoCamera.left = frustumSize * aspect / -2;
|
||||
this.orthoCamera.right = frustumSize * aspect / 2;
|
||||
this.orthoCamera.top = frustumSize / 2;
|
||||
this.orthoCamera.bottom = frustumSize / -2;
|
||||
|
||||
this.orthoCamera.updateProjectionMatrix();
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработка изменения размера окна
|
||||
*/
|
||||
handleResize() {
|
||||
const aspect = window.innerWidth / window.innerHeight;
|
||||
|
||||
// Обновляем перспективную камеру
|
||||
if (this.fpCamera) {
|
||||
this.fpCamera.aspect = aspect;
|
||||
this.fpCamera.updateProjectionMatrix();
|
||||
}
|
||||
|
||||
// Обновляем ортографическую камеру
|
||||
if (this.orthoCamera) {
|
||||
const frustumSize = 50 / this.zoom;
|
||||
this.orthoCamera.left = frustumSize * aspect / -2;
|
||||
this.orthoCamera.right = frustumSize * aspect / 2;
|
||||
this.orthoCamera.top = frustumSize / 2;
|
||||
this.orthoCamera.bottom = frustumSize / -2;
|
||||
this.orthoCamera.updateProjectionMatrix();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Получение текущей активной камеры
|
||||
*/
|
||||
getCurrentCamera() {
|
||||
return this.currentCamera;
|
||||
}
|
||||
|
||||
/**
|
||||
* Получение ортографической камеры
|
||||
*/
|
||||
getOrthoCamera() {
|
||||
return this.orthoCamera;
|
||||
}
|
||||
|
||||
/**
|
||||
* Получение камеры от первого лица
|
||||
*/
|
||||
getFirstPersonCamera() {
|
||||
return this.fpCamera;
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверка, активна ли камера от первого лица
|
||||
*/
|
||||
isFirstPersonActive() {
|
||||
return this.currentCamera === this.fpCamera;
|
||||
}
|
||||
|
||||
/**
|
||||
* Очистка ресурсов
|
||||
*/
|
||||
dispose() {
|
||||
// Камеры автоматически очищаются при удалении сцены
|
||||
this.orthoCamera = null;
|
||||
this.fpCamera = null;
|
||||
this.currentCamera = null;
|
||||
}
|
||||
}
|
||||
438
src/modules/GameCore.js
Normal file
438
src/modules/GameCore.js
Normal file
@@ -0,0 +1,438 @@
|
||||
import * as THREE from 'three';
|
||||
import { SceneManager } from './SceneManager.js';
|
||||
import { CameraManager } from './CameraManager.js';
|
||||
import { PlayerManager } from './PlayerManager.js';
|
||||
import { RendererManager } from './RendererManager.js';
|
||||
import { InteriorManager } from './InteriorManager.js';
|
||||
|
||||
/**
|
||||
* Основной класс игры
|
||||
* Координирует все модули и управляет игровым циклом
|
||||
*/
|
||||
export class GameCore {
|
||||
constructor(container) {
|
||||
this.container = container;
|
||||
this.isRunning = false;
|
||||
this.clock = new THREE.Clock();
|
||||
|
||||
// Инициализация модулей
|
||||
this.sceneManager = new SceneManager();
|
||||
this.cameraManager = new CameraManager();
|
||||
this.playerManager = new PlayerManager(this.sceneManager);
|
||||
this.rendererManager = new RendererManager(container);
|
||||
this.interiorManager = new InteriorManager(this.sceneManager);
|
||||
|
||||
// Состояние игры
|
||||
this.moveInput = {
|
||||
forward: false,
|
||||
backward: false,
|
||||
left: false,
|
||||
right: false,
|
||||
strafeLeft: false,
|
||||
strafeRight: false
|
||||
};
|
||||
|
||||
this.isInInterior = false;
|
||||
this.currentExit = null;
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
/**
|
||||
* Инициализация игры
|
||||
*/
|
||||
async init() {
|
||||
try {
|
||||
// Ждем инициализации всех модулей
|
||||
await this.playerManager.init();
|
||||
|
||||
// Настраиваем обработчики событий
|
||||
this.setupEventListeners();
|
||||
|
||||
// Запускаем игровой цикл
|
||||
this.start();
|
||||
|
||||
console.log('GameCore инициализирован успешно');
|
||||
} catch (error) {
|
||||
console.error('Ошибка инициализации GameCore:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Настройка обработчиков событий
|
||||
*/
|
||||
setupEventListeners() {
|
||||
// Обработка клавиатуры
|
||||
document.addEventListener('keydown', this.handleKeyDown.bind(this));
|
||||
document.addEventListener('keyup', this.handleKeyUp.bind(this));
|
||||
|
||||
// Обработка мыши
|
||||
document.addEventListener('mousemove', this.handleMouseMove.bind(this));
|
||||
document.addEventListener('wheel', this.handleWheel.bind(this));
|
||||
|
||||
// Обработка изменения размера окна
|
||||
window.addEventListener('resize', this.handleResize.bind(this));
|
||||
|
||||
// Обработка кликов по сцене
|
||||
this.setupClickHandlers();
|
||||
}
|
||||
|
||||
/**
|
||||
* Настройка обработчиков кликов
|
||||
*/
|
||||
setupClickHandlers() {
|
||||
const canvas = this.rendererManager.getDomElement();
|
||||
if (!canvas) return;
|
||||
|
||||
canvas.addEventListener('click', this.handleSceneClick.bind(this));
|
||||
canvas.addEventListener('pointerdown', this.handleSceneClick.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработка нажатия клавиш
|
||||
*/
|
||||
handleKeyDown(event) {
|
||||
switch (event.code) {
|
||||
case 'KeyW':
|
||||
case 'ArrowUp':
|
||||
this.moveInput.forward = true;
|
||||
break;
|
||||
case 'KeyS':
|
||||
case 'ArrowDown':
|
||||
this.moveInput.backward = true;
|
||||
break;
|
||||
case 'KeyA':
|
||||
case 'ArrowLeft':
|
||||
this.moveInput.left = true;
|
||||
break;
|
||||
case 'KeyD':
|
||||
case 'ArrowRight':
|
||||
this.moveInput.right = true;
|
||||
break;
|
||||
case 'KeyQ':
|
||||
this.moveInput.strafeLeft = true;
|
||||
break;
|
||||
case 'KeyE':
|
||||
this.moveInput.strafeRight = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработка отпускания клавиш
|
||||
*/
|
||||
handleKeyUp(event) {
|
||||
switch (event.code) {
|
||||
case 'KeyW':
|
||||
case 'ArrowUp':
|
||||
this.moveInput.forward = false;
|
||||
break;
|
||||
case 'KeyS':
|
||||
case 'ArrowDown':
|
||||
this.moveInput.backward = false;
|
||||
break;
|
||||
case 'KeyA':
|
||||
case 'ArrowLeft':
|
||||
this.moveInput.left = false;
|
||||
break;
|
||||
case 'KeyD':
|
||||
case 'ArrowRight':
|
||||
this.moveInput.right = false;
|
||||
break;
|
||||
case 'KeyQ':
|
||||
this.moveInput.strafeLeft = false;
|
||||
break;
|
||||
case 'KeyE':
|
||||
this.moveInput.strafeRight = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработка движения мыши
|
||||
*/
|
||||
handleMouseMove(event) {
|
||||
if (this.cameraManager.isFirstPersonActive()) {
|
||||
this.cameraManager.handleMouseMove(event.movementX, event.movementY);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработка колеса мыши
|
||||
*/
|
||||
handleWheel(event) {
|
||||
this.cameraManager.handleWheel(event.deltaY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработка изменения размера окна
|
||||
*/
|
||||
handleResize() {
|
||||
this.rendererManager.handleResize();
|
||||
this.cameraManager.handleResize();
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработка кликов по сцене
|
||||
*/
|
||||
handleSceneClick(event) {
|
||||
if (!this.rendererManager.isReady() || !this.cameraManager.getCurrentCamera()) return;
|
||||
|
||||
const rect = this.rendererManager.getDomElement().getBoundingClientRect();
|
||||
const mouse = new THREE.Vector2(
|
||||
((event.clientX - rect.left) / rect.width) * 2 - 1,
|
||||
-((event.clientY - rect.top) / rect.height) * 2 + 1
|
||||
);
|
||||
|
||||
const raycaster = new THREE.Raycaster();
|
||||
raycaster.setFromCamera(mouse, this.cameraManager.getCurrentCamera());
|
||||
|
||||
// Проверяем клики по интерактивным объектам интерьера
|
||||
if (this.isInInterior) {
|
||||
this.handleInteriorClick(raycaster);
|
||||
}
|
||||
|
||||
// Проверяем клики по объектам города
|
||||
this.handleCityClick(raycaster);
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработка кликов по объектам интерьера
|
||||
*/
|
||||
handleInteriorClick(raycaster) {
|
||||
const interactables = this.sceneManager.getInteriorInteractables();
|
||||
const hits = raycaster.intersectObjects(interactables, true);
|
||||
|
||||
if (hits.length > 0) {
|
||||
const hit = hits[0];
|
||||
const payload = this.getInteractablePayload(hit.object);
|
||||
|
||||
if (payload) {
|
||||
this.handleInteriorInteraction(payload);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработка кликов по объектам города
|
||||
*/
|
||||
handleCityClick(raycaster) {
|
||||
const cityMeshes = this.sceneManager.cityMeshes;
|
||||
const hits = raycaster.intersectObjects(cityMeshes, true);
|
||||
|
||||
if (hits.length > 0) {
|
||||
const hit = hits[0];
|
||||
const objectId = hit.object.userData.id;
|
||||
|
||||
if (objectId) {
|
||||
this.handleCityObjectClick(objectId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Получение данных интерактивного объекта
|
||||
*/
|
||||
getInteractablePayload(object) {
|
||||
let node = object;
|
||||
while (node && !node.userData?.payload && node.parent) {
|
||||
node = node.parent;
|
||||
}
|
||||
return node?.userData?.payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработка взаимодействия с объектами интерьера
|
||||
*/
|
||||
handleInteriorInteraction(payload) {
|
||||
switch (payload.type) {
|
||||
case 'npc':
|
||||
console.log('Взаимодействие с NPC:', payload.id);
|
||||
// Здесь можно вызвать систему диалогов
|
||||
break;
|
||||
case 'marker':
|
||||
console.log('Взаимодействие с маркером:', payload.label);
|
||||
break;
|
||||
default:
|
||||
console.log('Неизвестный тип взаимодействия:', payload);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработка клика по объекту города
|
||||
*/
|
||||
async handleCityObjectClick(objectId) {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const response = await fetch(`/api/city_objects/${objectId}/interior`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
credentials: 'include',
|
||||
cache: 'no-cache'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const { interiorId } = await response.json();
|
||||
if (interiorId) {
|
||||
await this.enterInterior(interiorId);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка при обработке клика по объекту города:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Вход в интерьер
|
||||
*/
|
||||
async enterInterior(interiorId) {
|
||||
try {
|
||||
this.isInInterior = true;
|
||||
|
||||
// Сохраняем позицию игрока
|
||||
const playerPosition = this.playerManager.getPlayerPosition();
|
||||
this.playerManager.savePosition();
|
||||
|
||||
// Переключаемся на камеру от первого лица
|
||||
this.cameraManager.switchToFirstPersonCamera(
|
||||
playerPosition,
|
||||
this.playerManager.getPlayerRotation()
|
||||
);
|
||||
|
||||
// Загружаем интерьер
|
||||
await this.interiorManager.enterInteriorMode(interiorId, playerPosition);
|
||||
|
||||
console.log('Вход в интерьер завершен');
|
||||
} catch (error) {
|
||||
console.error('Ошибка входа в интерьер:', error);
|
||||
this.isInInterior = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Выход из интерьера
|
||||
*/
|
||||
exitInterior() {
|
||||
if (!this.isInInterior) return;
|
||||
|
||||
try {
|
||||
this.isInInterior = false;
|
||||
|
||||
// Восстанавливаем позицию игрока
|
||||
this.playerManager.restorePosition();
|
||||
|
||||
// Переключаемся на ортографическую камеру
|
||||
this.cameraManager.switchToOrthoCamera();
|
||||
|
||||
// Удаляем интерьер
|
||||
this.sceneManager.removeInteriorGroup();
|
||||
|
||||
console.log('Выход из интерьера завершен');
|
||||
} catch (error) {
|
||||
console.error('Ошибка выхода из интерьера:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Запуск игры
|
||||
*/
|
||||
start() {
|
||||
this.isRunning = true;
|
||||
this.gameLoop();
|
||||
}
|
||||
|
||||
/**
|
||||
* Остановка игры
|
||||
*/
|
||||
stop() {
|
||||
this.isRunning = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Основной игровой цикл
|
||||
*/
|
||||
gameLoop() {
|
||||
if (!this.isRunning) return;
|
||||
|
||||
const deltaTime = this.clock.getDelta();
|
||||
|
||||
// Обновляем игрока
|
||||
this.playerManager.movePlayer(this.moveInput, deltaTime);
|
||||
|
||||
// Обновляем камеру
|
||||
if (!this.isInInterior) {
|
||||
const playerPosition = this.playerManager.getPlayerPosition();
|
||||
this.cameraManager.updateOrthoCameraPosition(playerPosition);
|
||||
} else {
|
||||
const playerPosition = this.playerManager.getPlayerPosition();
|
||||
const playerRotation = this.playerManager.getPlayerRotation();
|
||||
this.cameraManager.updateFirstPersonCameraPosition(playerPosition, playerRotation);
|
||||
}
|
||||
|
||||
// Рендерим сцену
|
||||
this.rendererManager.render(
|
||||
this.sceneManager.getScene(),
|
||||
this.cameraManager.getCurrentCamera()
|
||||
);
|
||||
|
||||
// Продолжаем цикл
|
||||
requestAnimationFrame(() => this.gameLoop());
|
||||
}
|
||||
|
||||
/**
|
||||
* Получение менеджера сцены
|
||||
*/
|
||||
getSceneManager() {
|
||||
return this.sceneManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Получение менеджера камер
|
||||
*/
|
||||
getCameraManager() {
|
||||
return this.cameraManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Получение менеджера игрока
|
||||
*/
|
||||
getPlayerManager() {
|
||||
return this.playerManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Получение менеджера рендерера
|
||||
*/
|
||||
getRendererManager() {
|
||||
return this.rendererManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Получение менеджера интерьеров
|
||||
*/
|
||||
getInteriorManager() {
|
||||
return this.interiorManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Очистка ресурсов
|
||||
*/
|
||||
dispose() {
|
||||
this.stop();
|
||||
|
||||
// Очищаем все модули
|
||||
this.sceneManager.dispose();
|
||||
this.cameraManager.dispose();
|
||||
this.playerManager.dispose();
|
||||
this.rendererManager.dispose();
|
||||
this.interiorManager.dispose();
|
||||
|
||||
// Удаляем обработчики событий
|
||||
document.removeEventListener('keydown', this.handleKeyDown.bind(this));
|
||||
document.removeEventListener('keyup', this.handleKeyUp.bind(this));
|
||||
document.removeEventListener('mousemove', this.handleMouseMove.bind(this));
|
||||
document.removeEventListener('wheel', this.handleWheel.bind(this));
|
||||
window.removeEventListener('resize', this.handleResize.bind(this));
|
||||
|
||||
console.log('GameCore очищен');
|
||||
}
|
||||
}
|
||||
358
src/modules/InteriorManager.js
Normal file
358
src/modules/InteriorManager.js
Normal file
@@ -0,0 +1,358 @@
|
||||
import * as THREE from 'three';
|
||||
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
|
||||
|
||||
/**
|
||||
* Менеджер интерьеров
|
||||
* Отвечает за загрузку, управление и взаимодействие с интерьерами
|
||||
*/
|
||||
export class InteriorManager {
|
||||
constructor(sceneManager) {
|
||||
this.sceneManager = sceneManager;
|
||||
this.loader = new GLTFLoader();
|
||||
this.baseChairMesh = this.createBaseChairMesh();
|
||||
this.texturePackCache = new Map();
|
||||
this.cityPackMaterialCache = new Map();
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
/**
|
||||
* Инициализация менеджера интерьеров
|
||||
*/
|
||||
init() {
|
||||
console.log('InteriorManager инициализирован');
|
||||
}
|
||||
|
||||
/**
|
||||
* Создание базового меша для стула
|
||||
*/
|
||||
createBaseChairMesh() {
|
||||
return new THREE.Mesh(
|
||||
new THREE.BoxGeometry(1, 1, 1),
|
||||
new THREE.MeshBasicMaterial({ visible: false })
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Загрузка GLTF модели
|
||||
*/
|
||||
async loadGLTF(url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.loader.load(url, resolve, undefined, reject);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Вход в режим интерьера
|
||||
*/
|
||||
async enterInteriorMode(interiorId, playerPosition) {
|
||||
console.log('Вход в интерьер:', interiorId);
|
||||
|
||||
// Сохраняем позицию игрока
|
||||
if (playerPosition) {
|
||||
this.sceneManager.savedPosition = playerPosition.clone();
|
||||
}
|
||||
|
||||
// Загружаем модель интерьера
|
||||
await this.loadInteriorModel(interiorId);
|
||||
|
||||
// Создаем группу интерьера
|
||||
const interiorGroup = this.sceneManager.createInteriorGroup();
|
||||
|
||||
return interiorGroup;
|
||||
}
|
||||
|
||||
/**
|
||||
* Загрузка модели интерьера
|
||||
*/
|
||||
async loadInteriorModel(interiorId) {
|
||||
console.log('Загрузка модели интерьера:', interiorId);
|
||||
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) {
|
||||
throw new Error('Токен не найден');
|
||||
}
|
||||
|
||||
try {
|
||||
// Получаем определение интерьера с сервера
|
||||
const defRes = await fetch(`/api/interiors/${interiorId}/definition`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
credentials: 'include',
|
||||
cache: 'no-cache'
|
||||
});
|
||||
|
||||
if (!defRes.ok) {
|
||||
throw new Error(`Ошибка ${defRes.status} при загрузке определения интерьера`);
|
||||
}
|
||||
|
||||
const { glb, objects } = await defRes.json();
|
||||
const baseUrl = window.location.origin;
|
||||
const glbUrl = baseUrl + glb;
|
||||
|
||||
console.log('Загрузка GLB из:', glbUrl);
|
||||
|
||||
// Проверяем доступность GLB файла
|
||||
const headResp = await fetch(glbUrl, { method: 'HEAD', cache: 'no-cache' });
|
||||
if (!headResp.ok) {
|
||||
throw new Error(`GLB недоступен: HTTP ${headResp.status}`);
|
||||
}
|
||||
|
||||
// Загружаем GLTF модель
|
||||
const gltf = await this.loadGLTF(glbUrl);
|
||||
const scene = this.sceneManager.getScene();
|
||||
|
||||
// Создаем группу для интерьера
|
||||
const intGroup = new THREE.Group();
|
||||
intGroup.name = 'interiorGroup';
|
||||
intGroup.add(gltf.scene);
|
||||
|
||||
// Обрабатываем материалы интерьера
|
||||
this.processInteriorMaterials(gltf.scene);
|
||||
|
||||
// Строим коллайдеры интерьера
|
||||
this.buildInteriorColliders(gltf.scene);
|
||||
|
||||
// Добавляем объекты интерьера
|
||||
await this.addInteriorObjects(objects, intGroup);
|
||||
|
||||
// Добавляем освещение для интерьера
|
||||
this.addInteriorLighting(intGroup);
|
||||
|
||||
// Добавляем группу в сцену
|
||||
scene.add(intGroup);
|
||||
this.sceneManager.interiorGroup = intGroup;
|
||||
|
||||
console.log('Модель интерьера загружена успешно');
|
||||
return intGroup;
|
||||
|
||||
} catch (error) {
|
||||
console.error('Ошибка загрузки модели интерьера:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработка материалов интерьера
|
||||
*/
|
||||
processInteriorMaterials(scene) {
|
||||
scene.traverse((child) => {
|
||||
if (child.isMesh && child.material) {
|
||||
if (Array.isArray(child.material)) {
|
||||
child.material = child.material.map(mat => {
|
||||
if (!mat) return mat;
|
||||
const m = mat.clone();
|
||||
m.transparent = false;
|
||||
m.opacity = 1;
|
||||
m.depthWrite = true;
|
||||
m.needsUpdate = true;
|
||||
return m;
|
||||
});
|
||||
} else {
|
||||
child.material = child.material.clone();
|
||||
child.material.transparent = false;
|
||||
child.material.opacity = 1;
|
||||
child.material.depthWrite = true;
|
||||
child.material.needsUpdate = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Построение коллайдеров интерьера
|
||||
*/
|
||||
buildInteriorColliders(scene) {
|
||||
const colliders = [];
|
||||
scene.traverse((child) => {
|
||||
if (child.isMesh && child.geometry) {
|
||||
colliders.push(child);
|
||||
}
|
||||
});
|
||||
this.sceneManager.interiorColliders = colliders;
|
||||
}
|
||||
|
||||
/**
|
||||
* Добавление объектов интерьера
|
||||
*/
|
||||
async addInteriorObjects(objects, intGroup) {
|
||||
this.sceneManager.interiorInteractables = [];
|
||||
|
||||
for (const obj of objects) {
|
||||
if (obj.model_url) {
|
||||
try {
|
||||
const objGltf = await this.loadGLTF(window.location.origin + obj.model_url);
|
||||
objGltf.scene.position.set(obj.x, obj.y, obj.z);
|
||||
objGltf.scene.rotation.set(obj.rot_x, obj.rot_y, obj.rot_z);
|
||||
objGltf.scene.scale.set(obj.scale, obj.scale, obj.scale);
|
||||
intGroup.add(objGltf.scene);
|
||||
|
||||
// Добавляем меши объекта как коллайдеры
|
||||
objGltf.scene.traverse((child) => {
|
||||
if (child.isMesh && child.geometry) {
|
||||
this.sceneManager.interiorColliders.push(child);
|
||||
}
|
||||
});
|
||||
|
||||
// Обрабатываем NPC
|
||||
if (this.isNPC(obj)) {
|
||||
this.processNPC(obj, objGltf.scene, intGroup);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.warn('Не удалось загрузить объект интерьера:', obj.model_url, error);
|
||||
}
|
||||
} else {
|
||||
// Создаем плейсхолдер
|
||||
const mesh = this.baseChairMesh.clone();
|
||||
mesh.position.set(obj.x, obj.y, obj.z);
|
||||
mesh.rotation.set(obj.rot_x, obj.rot_y, obj.rot_z);
|
||||
mesh.scale.set(obj.scale, obj.scale, obj.scale);
|
||||
intGroup.add(mesh);
|
||||
mesh.visible = false;
|
||||
this.sceneManager.interiorColliders.push(mesh);
|
||||
}
|
||||
|
||||
// Обрабатываем интерактивные маркеры
|
||||
if (obj.interactable || obj.marker) {
|
||||
this.createInteriorMarker(obj, intGroup);
|
||||
}
|
||||
|
||||
// Сохраняем позицию внутреннего выхода
|
||||
if (obj.exit_int_x !== undefined && obj.exit_int_y !== undefined && obj.exit_int_z !== undefined) {
|
||||
this.sceneManager.setInteriorExitPos(new THREE.Vector3(obj.exit_int_x, obj.exit_int_y, obj.exit_int_z));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверка, является ли объект NPC
|
||||
*/
|
||||
isNPC(obj) {
|
||||
return (obj.type === 'npc') ||
|
||||
(typeof obj.model_url === 'string' && obj.model_url.includes('/models/npc/'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработка NPC
|
||||
*/
|
||||
processNPC(obj, scene, intGroup) {
|
||||
const npcId = obj.id || this.getNpcIdFromModel(obj.model_url);
|
||||
console.log('Обнаружен NPC:', npcId, 'в позиции:', { x: obj.x, y: obj.y, z: obj.z });
|
||||
|
||||
// Создаем хит-зону для NPC
|
||||
const hit = new THREE.Mesh(
|
||||
new THREE.SphereGeometry(1.2),
|
||||
new THREE.MeshBasicMaterial({
|
||||
color: 0x00ff00,
|
||||
transparent: true,
|
||||
opacity: 0.0001,
|
||||
depthWrite: false
|
||||
})
|
||||
);
|
||||
hit.position.set(obj.x, (obj.y ?? 0) + 1.0, obj.z);
|
||||
hit.userData.interactable = true;
|
||||
hit.userData.payload = { type: 'npc', id: npcId };
|
||||
hit.visible = true;
|
||||
intGroup.add(hit);
|
||||
this.sceneManager.interiorInteractables.push(hit);
|
||||
|
||||
// Помечаем корень модели как кликабельный NPC
|
||||
try {
|
||||
scene.userData = scene.userData || {};
|
||||
scene.userData.interactable = true;
|
||||
scene.userData.payload = { type: 'npc', id: npcId };
|
||||
scene.userData.isNpc = true;
|
||||
scene.userData.npcId = npcId;
|
||||
this.sceneManager.interiorInteractables.push(scene);
|
||||
} catch (error) {
|
||||
console.warn('Ошибка при обработке NPC:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Получение ID NPC из пути к модели
|
||||
*/
|
||||
getNpcIdFromModel(url) {
|
||||
if (!url || typeof url !== 'string') return null;
|
||||
|
||||
const lower = url.toLowerCase();
|
||||
if (lower.includes('/models/npc/galina.glb')) return 'Adventurer';
|
||||
if (lower.includes('/models/npc/oxranik.glb')) return 'Oxranik';
|
||||
if (lower.includes('/models/npc/guard.glb')) return 'guard';
|
||||
if (lower.includes('/models/npc/beachcharacter.glb')) return 'BeachCharacter';
|
||||
if (lower.includes('/models/npc/bartender.glb')) return 'bartender';
|
||||
if (lower.includes('/models/npc/computer.glb')) return 'Computer';
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Создание интерактивного маркера
|
||||
*/
|
||||
createInteriorMarker(obj, intGroup) {
|
||||
const hit = new THREE.Mesh(
|
||||
new THREE.SphereGeometry(0.6),
|
||||
new THREE.MeshBasicMaterial({
|
||||
color: 0x00ff00,
|
||||
transparent: true,
|
||||
opacity: 0.0001,
|
||||
depthWrite: false
|
||||
})
|
||||
);
|
||||
hit.position.set(obj.x, obj.y + 1.0, obj.z);
|
||||
hit.userData.interactable = true;
|
||||
hit.userData.payload = {
|
||||
type: obj.type || 'marker',
|
||||
id: obj.id || null,
|
||||
label: obj.label || 'Интерактив'
|
||||
};
|
||||
hit.visible = true;
|
||||
|
||||
try {
|
||||
if (hit.material) hit.material.visible = false;
|
||||
} catch (error) {
|
||||
// Игнорируем ошибки
|
||||
}
|
||||
|
||||
intGroup.add(hit);
|
||||
this.sceneManager.interiorInteractables.push(hit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Добавление освещения для интерьера
|
||||
*/
|
||||
addInteriorLighting(intGroup) {
|
||||
const light = new THREE.AmbientLight(0xffffff, 1);
|
||||
intGroup.add(light);
|
||||
}
|
||||
|
||||
/**
|
||||
* Выход из интерьера
|
||||
*/
|
||||
exitInterior(playerPosition, exitPosition) {
|
||||
console.log('Выход из интерьера');
|
||||
|
||||
// Телепортируем игрока
|
||||
if (playerPosition && exitPosition) {
|
||||
playerPosition.set(
|
||||
exitPosition.x,
|
||||
typeof exitPosition.y === 'number' ? exitPosition.y : playerPosition.y,
|
||||
exitPosition.z
|
||||
);
|
||||
}
|
||||
|
||||
// Удаляем группу интерьера
|
||||
this.sceneManager.removeInteriorGroup();
|
||||
|
||||
// Возвращаем видимость мира
|
||||
this.sceneManager.toggleWorldVisibility(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Очистка ресурсов
|
||||
*/
|
||||
dispose() {
|
||||
this.texturePackCache.clear();
|
||||
this.cityPackMaterialCache.clear();
|
||||
}
|
||||
}
|
||||
108
src/modules/PlayerManager.js
Normal file
108
src/modules/PlayerManager.js
Normal file
@@ -0,0 +1,108 @@
|
||||
import * as THREE from 'three';
|
||||
|
||||
/**
|
||||
* Менеджер игрока
|
||||
* Отвечает за создание, управление и анимацию игрока
|
||||
*/
|
||||
export class PlayerManager {
|
||||
constructor(sceneManager) {
|
||||
this.sceneManager = sceneManager;
|
||||
this.player = null;
|
||||
this.mixer = null;
|
||||
this.moveSpeed = 2.5;
|
||||
this.savedPosition = new THREE.Vector3();
|
||||
this.remotePlayers = {};
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
/**
|
||||
* Инициализация игрока
|
||||
*/
|
||||
async init() {
|
||||
await this.createPlayer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Создание модели игрока
|
||||
*/
|
||||
async createPlayer() {
|
||||
try {
|
||||
// Создаем простую геометрию для игрока
|
||||
const geometry = new THREE.BoxGeometry(1, 2, 1);
|
||||
const material = new THREE.MeshBasicMaterial({ color: 0x0000ff });
|
||||
this.player = new THREE.Mesh(geometry, material);
|
||||
|
||||
this.player.position.set(0, 0, 0);
|
||||
this.player.castShadow = true;
|
||||
|
||||
// Добавляем игрока на сцену
|
||||
this.sceneManager.getScene().add(this.player);
|
||||
|
||||
console.log('Игрок создан успешно');
|
||||
} catch (error) {
|
||||
console.error('Ошибка создания игрока:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Движение игрока
|
||||
*/
|
||||
movePlayer(direction, deltaTime) {
|
||||
if (!this.player) return;
|
||||
|
||||
const moveDistance = this.moveSpeed * deltaTime;
|
||||
const moveVector = new THREE.Vector3();
|
||||
|
||||
if (direction.forward) moveVector.z -= moveDistance;
|
||||
if (direction.backward) moveVector.z += moveDistance;
|
||||
if (direction.left) moveVector.x -= moveDistance;
|
||||
if (direction.right) moveVector.x += moveDistance;
|
||||
|
||||
this.player.position.add(moveVector);
|
||||
|
||||
// Поворачиваем игрока в направлении движения
|
||||
if (moveVector.length() > 0) {
|
||||
const angle = Math.atan2(moveVector.x, moveVector.z);
|
||||
this.player.rotation.y = angle;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Телепортация игрока
|
||||
*/
|
||||
teleportPlayer(position, rotation = null) {
|
||||
if (!this.player) return;
|
||||
|
||||
this.player.position.copy(position);
|
||||
if (rotation !== null) {
|
||||
this.player.rotation.y = rotation;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Получение позиции игрока
|
||||
*/
|
||||
getPlayerPosition() {
|
||||
return this.player ? this.player.position.clone() : new THREE.Vector3();
|
||||
}
|
||||
|
||||
/**
|
||||
* Получение объекта игрока
|
||||
*/
|
||||
getPlayer() {
|
||||
return this.player;
|
||||
}
|
||||
|
||||
/**
|
||||
* Очистка ресурсов
|
||||
*/
|
||||
dispose() {
|
||||
if (this.player) {
|
||||
this.sceneManager.getScene().remove(this.player);
|
||||
}
|
||||
|
||||
this.player = null;
|
||||
this.remotePlayers = {};
|
||||
}
|
||||
}
|
||||
67
src/modules/README.md
Normal file
67
src/modules/README.md
Normal file
@@ -0,0 +1,67 @@
|
||||
# Модульная структура проекта EEV_Proj
|
||||
|
||||
## Обзор
|
||||
Проект разбит на логические модули для улучшения читаемости, поддерживаемости и переиспользования кода.
|
||||
|
||||
## Структура модулей
|
||||
|
||||
### 1. Core (Ядро)
|
||||
- **GameCore.js** - Основная логика игры
|
||||
- **SceneManager.js** - Управление 3D сценой
|
||||
- **CameraManager.js** - Управление камерами
|
||||
- **PlayerManager.js** - Управление игроком
|
||||
|
||||
### 2. Rendering (Рендеринг)
|
||||
- **RendererManager.js** - Управление рендерером
|
||||
- **MaterialManager.js** - Управление материалами
|
||||
- **TextureManager.js** - Управление текстурами
|
||||
- **ModelLoader.js** - Загрузка 3D моделей
|
||||
|
||||
### 3. Gameplay (Игровой процесс)
|
||||
- **InteriorManager.js** - Управление интерьерами
|
||||
- **DialogManager.js** - Система диалогов
|
||||
- **InventoryManager.js** - Управление инвентарем
|
||||
- **QuestManager.js** - Система квестов
|
||||
|
||||
### 4. UI (Пользовательский интерфейс)
|
||||
- **PhoneManager.js** - Виртуальный телефон
|
||||
- **AppManager.js** - Управление приложениями
|
||||
- **NotificationManager.js** - Система уведомлений
|
||||
|
||||
### 5. Networking (Сетевое взаимодействие)
|
||||
- **SocketManager.js** - Управление WebSocket соединениями
|
||||
- **VoiceChatManager.js** - Голосовой чат
|
||||
- **MessageManager.js** - Система сообщений
|
||||
|
||||
### 6. Utils (Утилиты)
|
||||
- **Pathfinding.js** - Поиск пути
|
||||
- **CollisionDetection.js** - Обнаружение коллизий
|
||||
- **AnimationManager.js** - Управление анимациями
|
||||
|
||||
## Принципы модулизации
|
||||
|
||||
1. **Единая ответственность** - каждый модуль отвечает за одну область функциональности
|
||||
2. **Слабая связанность** - модули минимально зависят друг от друга
|
||||
3. **Высокая когезия** - связанные функции находятся в одном модуле
|
||||
4. **Интерфейсы** - четко определенные API между модулями
|
||||
5. **Переиспользование** - модули можно использовать в других частях проекта
|
||||
|
||||
## Импорты и экспорты
|
||||
|
||||
```javascript
|
||||
// Пример импорта
|
||||
import { SceneManager } from './modules/SceneManager.js';
|
||||
import { CameraManager } from './modules/CameraManager.js';
|
||||
|
||||
// Пример экспорта
|
||||
export class GameCore {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
## Конфигурация
|
||||
|
||||
Каждый модуль может иметь свой конфигурационный файл:
|
||||
- `config.js` - основные настройки
|
||||
- `constants.js` - константы
|
||||
- `types.js` - типы данных (для TypeScript)
|
||||
132
src/modules/RendererManager.js
Normal file
132
src/modules/RendererManager.js
Normal file
@@ -0,0 +1,132 @@
|
||||
import * as THREE from 'three';
|
||||
|
||||
/**
|
||||
* Менеджер рендерера
|
||||
* Отвечает за создание, настройку и управление WebGL рендерером
|
||||
*/
|
||||
export class RendererManager {
|
||||
constructor(container) {
|
||||
this.container = container;
|
||||
this.renderer = null;
|
||||
this.isInitialized = false;
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
/**
|
||||
* Инициализация рендерера
|
||||
*/
|
||||
init() {
|
||||
try {
|
||||
this.createRenderer();
|
||||
this.setupRenderer();
|
||||
this.addToContainer();
|
||||
this.isInitialized = true;
|
||||
|
||||
console.log('Рендерер инициализирован успешно');
|
||||
} catch (error) {
|
||||
console.error('Ошибка инициализации рендерера:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Создание WebGL рендерера
|
||||
*/
|
||||
createRenderer() {
|
||||
this.renderer = new THREE.WebGLRenderer({
|
||||
antialias: true,
|
||||
alpha: false,
|
||||
powerPreference: "high-performance"
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Настройка рендерера
|
||||
*/
|
||||
setupRenderer() {
|
||||
if (!this.renderer) return;
|
||||
|
||||
// Настройка размера
|
||||
this.renderer.setSize(window.innerWidth, window.innerHeight);
|
||||
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||
|
||||
// Настройка теней
|
||||
this.renderer.shadowMap.enabled = true;
|
||||
this.renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
||||
|
||||
// Настройка цвета фона
|
||||
this.renderer.setClearColor(0x87ceeb);
|
||||
|
||||
// Настройка гамма
|
||||
this.renderer.outputColorSpace = THREE.SRGBColorSpace;
|
||||
this.renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
||||
this.renderer.toneMappingExposure = 1.0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Добавление рендерера в контейнер
|
||||
*/
|
||||
addToContainer() {
|
||||
if (this.container && this.renderer) {
|
||||
this.container.appendChild(this.renderer.domElement);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Рендеринг сцены
|
||||
*/
|
||||
render(scene, camera) {
|
||||
if (this.renderer && scene && camera) {
|
||||
this.renderer.render(scene, camera);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработка изменения размера окна
|
||||
*/
|
||||
handleResize() {
|
||||
if (!this.renderer) return;
|
||||
|
||||
const width = window.innerWidth;
|
||||
const height = window.innerHeight;
|
||||
|
||||
this.renderer.setSize(width, height);
|
||||
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||
}
|
||||
|
||||
/**
|
||||
* Получение DOM элемента рендерера
|
||||
*/
|
||||
getDomElement() {
|
||||
return this.renderer ? this.renderer.domElement : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Получение рендерера
|
||||
*/
|
||||
getRenderer() {
|
||||
return this.renderer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверка инициализации
|
||||
*/
|
||||
isReady() {
|
||||
return this.isInitialized && this.renderer !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Очистка ресурсов
|
||||
*/
|
||||
dispose() {
|
||||
if (this.renderer) {
|
||||
this.renderer.dispose();
|
||||
if (this.container && this.renderer.domElement) {
|
||||
this.container.removeChild(this.renderer.domElement);
|
||||
}
|
||||
}
|
||||
|
||||
this.renderer = null;
|
||||
this.isInitialized = false;
|
||||
}
|
||||
}
|
||||
212
src/modules/SceneManager.js
Normal file
212
src/modules/SceneManager.js
Normal file
@@ -0,0 +1,212 @@
|
||||
import * as THREE from 'three';
|
||||
|
||||
/**
|
||||
* Менеджер 3D сцены
|
||||
* Отвечает за создание, управление и обновление 3D сцены
|
||||
*/
|
||||
export class SceneManager {
|
||||
constructor() {
|
||||
this.scene = new THREE.Scene();
|
||||
this.cityGroup = new THREE.Group();
|
||||
this.interiorGroup = null;
|
||||
this.ground = null;
|
||||
this.cityMeshes = [];
|
||||
this.cityObjectsData = [];
|
||||
this.loadedCityObjects = {};
|
||||
this.loadedInteriorMeshes = {};
|
||||
this.interiorsData = [];
|
||||
this.npcMeshes = [];
|
||||
this.interiorColliders = [];
|
||||
this.interiorInteractables = [];
|
||||
this.interiorExitPos = null;
|
||||
this.fpHiddenNodes = [];
|
||||
this.cleanupTimer = null;
|
||||
this.overlayTimeout = null;
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
/**
|
||||
* Инициализация сцены
|
||||
*/
|
||||
init() {
|
||||
this.scene.add(this.cityGroup);
|
||||
this.setupLighting();
|
||||
this.setupGround();
|
||||
}
|
||||
|
||||
/**
|
||||
* Настройка освещения сцены
|
||||
*/
|
||||
setupLighting() {
|
||||
const ambientLight = new THREE.AmbientLight(0xffffff, 0.6);
|
||||
this.scene.add(ambientLight);
|
||||
|
||||
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8);
|
||||
directionalLight.position.set(100, 100, 50);
|
||||
directionalLight.castShadow = true;
|
||||
this.scene.add(directionalLight);
|
||||
}
|
||||
|
||||
/**
|
||||
* Создание поверхности земли
|
||||
*/
|
||||
setupGround() {
|
||||
const groundGeometry = new THREE.PlaneGeometry(1000, 1000);
|
||||
const groundMaterial = new THREE.MeshStandardMaterial({
|
||||
color: 0x3a5f3a,
|
||||
roughness: 0.8,
|
||||
metalness: 0.1
|
||||
});
|
||||
|
||||
this.ground = new THREE.Mesh(groundGeometry, groundMaterial);
|
||||
this.ground.rotation.x = -Math.PI / 2;
|
||||
this.ground.receiveShadow = true;
|
||||
this.scene.add(this.ground);
|
||||
}
|
||||
|
||||
/**
|
||||
* Добавление объекта в город
|
||||
*/
|
||||
addCityObject(mesh, data) {
|
||||
this.cityGroup.add(mesh);
|
||||
this.cityMeshes.push(mesh);
|
||||
this.cityObjectsData.push(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Удаление объекта из города
|
||||
*/
|
||||
removeCityObject(mesh) {
|
||||
this.cityGroup.remove(mesh);
|
||||
const index = this.cityMeshes.indexOf(mesh);
|
||||
if (index > -1) {
|
||||
this.cityMeshes.splice(index, 1);
|
||||
this.cityObjectsData.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Создание группы интерьера
|
||||
*/
|
||||
createInteriorGroup() {
|
||||
this.interiorGroup = new THREE.Group();
|
||||
this.interiorGroup.name = 'interiorGroup';
|
||||
this.scene.add(this.interiorGroup);
|
||||
return this.interiorGroup;
|
||||
}
|
||||
|
||||
/**
|
||||
* Удаление группы интерьера
|
||||
*/
|
||||
removeInteriorGroup() {
|
||||
if (this.interiorGroup) {
|
||||
this.scene.remove(this.interiorGroup);
|
||||
this.interiorGroup = null;
|
||||
this.interiorColliders = [];
|
||||
this.interiorInteractables = [];
|
||||
this.interiorExitPos = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Добавление коллайдера интерьера
|
||||
*/
|
||||
addInteriorCollider(mesh) {
|
||||
this.interiorColliders.push(mesh);
|
||||
}
|
||||
|
||||
/**
|
||||
* Добавление интерактивного объекта интерьера
|
||||
*/
|
||||
addInteriorInteractable(mesh) {
|
||||
this.interiorInteractables.push(mesh);
|
||||
}
|
||||
|
||||
/**
|
||||
* Установка позиции выхода из интерьера
|
||||
*/
|
||||
setInteriorExitPos(position) {
|
||||
this.interiorExitPos = position;
|
||||
}
|
||||
|
||||
/**
|
||||
* Переключение видимости мира
|
||||
*/
|
||||
toggleWorldVisibility(visible) {
|
||||
if (this.ground) this.ground.visible = visible;
|
||||
this.cityMeshes.forEach(mesh => mesh.visible = visible);
|
||||
}
|
||||
|
||||
/**
|
||||
* Очистка ресурсов
|
||||
*/
|
||||
dispose() {
|
||||
// Очистка таймеров
|
||||
if (this.cleanupTimer) {
|
||||
clearInterval(this.cleanupTimer);
|
||||
}
|
||||
if (this.overlayTimeout) {
|
||||
clearTimeout(this.overlayTimeout);
|
||||
}
|
||||
|
||||
// Очистка геометрий и материалов
|
||||
this.scene.traverse((object) => {
|
||||
if (object.geometry) {
|
||||
object.geometry.dispose();
|
||||
}
|
||||
if (object.material) {
|
||||
if (Array.isArray(object.material)) {
|
||||
object.material.forEach(material => material.dispose());
|
||||
} else {
|
||||
object.material.dispose();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Очистка сцены
|
||||
this.scene.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Получение сцены
|
||||
*/
|
||||
getScene() {
|
||||
return this.scene;
|
||||
}
|
||||
|
||||
/**
|
||||
* Получение группы города
|
||||
*/
|
||||
getCityGroup() {
|
||||
return this.cityGroup;
|
||||
}
|
||||
|
||||
/**
|
||||
* Получение группы интерьера
|
||||
*/
|
||||
getInteriorGroup() {
|
||||
return this.interiorGroup;
|
||||
}
|
||||
|
||||
/**
|
||||
* Получение коллайдеров интерьера
|
||||
*/
|
||||
getInteriorColliders() {
|
||||
return this.interiorColliders;
|
||||
}
|
||||
|
||||
/**
|
||||
* Получение интерактивных объектов интерьера
|
||||
*/
|
||||
getInteriorInteractables() {
|
||||
return this.interiorInteractables;
|
||||
}
|
||||
|
||||
/**
|
||||
* Получение позиции выхода из интерьера
|
||||
*/
|
||||
getInteriorExitPos() {
|
||||
return this.interiorExitPos;
|
||||
}
|
||||
}
|
||||
419
src/pages/CollisionEditor.jsx
Normal file
419
src/pages/CollisionEditor.jsx
Normal file
@@ -0,0 +1,419 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import * as THREE from 'three';
|
||||
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls';
|
||||
import { TransformControls } from 'three/examples/jsm/controls/TransformControls';
|
||||
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader';
|
||||
|
||||
export default function CollisionEditor() {
|
||||
const mountRef = useRef(null);
|
||||
const sceneRef = useRef();
|
||||
const cameraRef = useRef();
|
||||
const rendererRef = useRef();
|
||||
const orbitRef = useRef();
|
||||
const transformRef = useRef();
|
||||
const backgroundGroupRef = useRef(new THREE.Group());
|
||||
const gltfLoaderRef = useRef(new GLTFLoader());
|
||||
|
||||
const [shapeType, setShapeType] = useState('box');
|
||||
const [mode, setMode] = useState('translate');
|
||||
const [selected, setSelected] = useState(null);
|
||||
const [cursorXZ, setCursorXZ] = useState({ x: 0, z: 0 });
|
||||
const [cities, setCities] = useState([]);
|
||||
const [cityId, setCityId] = useState(null);
|
||||
const [lockUniformXZ, setLockUniformXZ] = useState(true);
|
||||
const collidersRef = useRef([]);
|
||||
|
||||
const colliderMaterial = useMemo(() => new THREE.MeshBasicMaterial({ color: 0x00aaff, transparent: true, opacity: 0.25, depthWrite: false }), []);
|
||||
const colliderEdgeMaterial = useMemo(() => new THREE.LineBasicMaterial({ color: 0x00aaff }), []);
|
||||
|
||||
useEffect(() => {
|
||||
const scene = new THREE.Scene();
|
||||
scene.background = new THREE.Color(0x9aa7b1);
|
||||
sceneRef.current = scene;
|
||||
|
||||
const camera = new THREE.PerspectiveCamera(60, mountRef.current.clientWidth / mountRef.current.clientHeight, 0.1, 2000);
|
||||
camera.position.set(20, 20, 20);
|
||||
camera.lookAt(0, 0, 0);
|
||||
cameraRef.current = camera;
|
||||
|
||||
const renderer = new THREE.WebGLRenderer({ antialias: true });
|
||||
renderer.setSize(mountRef.current.clientWidth, mountRef.current.clientHeight);
|
||||
mountRef.current.appendChild(renderer.domElement);
|
||||
rendererRef.current = renderer;
|
||||
|
||||
const hemi = new THREE.HemisphereLight(0xffffff, 0x444444, 0.9);
|
||||
hemi.position.set(0, 50, 0);
|
||||
scene.add(hemi);
|
||||
|
||||
const grid = new THREE.GridHelper(1000, 100);
|
||||
scene.add(grid);
|
||||
|
||||
scene.add(backgroundGroupRef.current);
|
||||
|
||||
const orbit = new OrbitControls(camera, renderer.domElement);
|
||||
orbit.enableDamping = true;
|
||||
orbitRef.current = orbit;
|
||||
|
||||
const transform = new TransformControls(camera, renderer.domElement);
|
||||
transform.addEventListener('dragging-changed', e => {
|
||||
orbit.enabled = !e.value;
|
||||
});
|
||||
transform.addEventListener('change', () => {
|
||||
renderer.render(scene, camera);
|
||||
});
|
||||
scene.add(transform);
|
||||
transformRef.current = transform;
|
||||
|
||||
const raycaster = new THREE.Raycaster();
|
||||
const mouse = new THREE.Vector2();
|
||||
const groundPlane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0);
|
||||
|
||||
const onPointerDown = (event) => {
|
||||
if (transform.dragging) return;
|
||||
const rect = renderer.domElement.getBoundingClientRect();
|
||||
mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
|
||||
mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
|
||||
raycaster.setFromCamera(mouse, camera);
|
||||
const meshes = collidersRef.current.map(c => c.mesh);
|
||||
const hits = raycaster.intersectObjects(meshes, true);
|
||||
if (hits.length > 0) {
|
||||
let obj = hits[0].object;
|
||||
while (obj && !meshes.includes(obj) && obj.parent) obj = obj.parent;
|
||||
if (obj) {
|
||||
setSelected(obj);
|
||||
transform.attach(obj);
|
||||
}
|
||||
} else {
|
||||
setSelected(null);
|
||||
transform.detach();
|
||||
}
|
||||
};
|
||||
renderer.domElement.addEventListener('pointerdown', onPointerDown);
|
||||
|
||||
const onPointerMove = (event) => {
|
||||
const rect = renderer.domElement.getBoundingClientRect();
|
||||
mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
|
||||
mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
|
||||
raycaster.setFromCamera(mouse, camera);
|
||||
const p = new THREE.Vector3();
|
||||
raycaster.ray.intersectPlane(groundPlane, p);
|
||||
if (isFinite(p.x) && isFinite(p.z)) setCursorXZ({ x: p.x, z: p.z });
|
||||
};
|
||||
renderer.domElement.addEventListener('pointermove', onPointerMove);
|
||||
|
||||
const onKeyDown = (e) => {
|
||||
// Перемещение целевой точки орбиты стрелками
|
||||
const step = e.shiftKey ? 2 : 0.5;
|
||||
if (!orbitRef.current) return;
|
||||
const tgt = orbitRef.current.target;
|
||||
if (e.key === 'ArrowUp') { tgt.z -= step; e.preventDefault(); }
|
||||
if (e.key === 'ArrowDown') { tgt.z += step; e.preventDefault(); }
|
||||
if (e.key === 'ArrowLeft') { tgt.x -= step; e.preventDefault(); }
|
||||
if (e.key === 'ArrowRight') { tgt.x += step; e.preventDefault(); }
|
||||
orbitRef.current.update();
|
||||
};
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
|
||||
const onResize = () => {
|
||||
if (!mountRef.current) return;
|
||||
camera.aspect = mountRef.current.clientWidth / mountRef.current.clientHeight;
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.setSize(mountRef.current.clientWidth, mountRef.current.clientHeight);
|
||||
};
|
||||
window.addEventListener('resize', onResize);
|
||||
|
||||
const animate = () => {
|
||||
requestAnimationFrame(animate);
|
||||
orbit.update();
|
||||
renderer.render(scene, camera);
|
||||
};
|
||||
animate();
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', onResize);
|
||||
window.removeEventListener('keydown', onKeyDown);
|
||||
renderer.domElement.removeEventListener('pointerdown', onPointerDown);
|
||||
renderer.domElement.removeEventListener('pointermove', onPointerMove);
|
||||
mountRef.current && mountRef.current.removeChild(renderer.domElement);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
transformRef.current?.setMode(mode);
|
||||
}, [mode]);
|
||||
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('token');
|
||||
fetch('/api/cities', { headers: { Authorization: `Bearer ${token}` } })
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
setCities(data);
|
||||
const profile = JSON.parse(sessionStorage.getItem('user_profile') || '{}');
|
||||
const defaultCity = profile.last_city_id || data[0]?.id;
|
||||
setCityId(defaultCity || null);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!cityId || !sceneRef.current) return;
|
||||
const token = localStorage.getItem('token');
|
||||
const bg = backgroundGroupRef.current;
|
||||
while (bg.children.length) {
|
||||
const ch = bg.children.pop();
|
||||
ch.traverse(n => {
|
||||
if (n.isMesh) {
|
||||
n.geometry?.dispose?.();
|
||||
if (n.material) {
|
||||
if (Array.isArray(n.material)) n.material.forEach(m => m.dispose?.());
|
||||
else n.material.dispose?.();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
fetch(`/api/cities/${cityId}/objects`, { headers: { Authorization: `Bearer ${token}` } })
|
||||
.then(r => r.json())
|
||||
.then(async data => {
|
||||
for (const obj of data) {
|
||||
try {
|
||||
const gltf = await gltfLoaderRef.current.loadAsync(obj.model_url);
|
||||
const m = gltf.scene;
|
||||
m.position.set(obj.pos_x, obj.pos_y, obj.pos_z);
|
||||
m.rotation.set(obj.rot_x, obj.rot_y, obj.rot_z);
|
||||
m.scale.set(obj.scale_x || 1, obj.scale_y || 1, obj.scale_z || 1);
|
||||
m.traverse(child => {
|
||||
if (child.isMesh && child.material) {
|
||||
if (Array.isArray(child.material)) child.material.forEach(mat => { if (mat) mat.transparent = true; if (mat) mat.opacity = 0.9; });
|
||||
else { child.material.transparent = true; child.material.opacity = 0.9; }
|
||||
child.raycast = () => {};
|
||||
}
|
||||
});
|
||||
bg.add(m);
|
||||
} catch (e) {}
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [cityId]);
|
||||
|
||||
// Автозагрузка коллизий из API
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('token');
|
||||
const q = cityId ? `?cityId=${encodeURIComponent(cityId)}` : '';
|
||||
fetch(`/api/colliders${q}`, { headers: { 'Authorization': `Bearer ${token}` } })
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
collidersRef.current.forEach(c => sceneRef.current.remove(c.mesh));
|
||||
collidersRef.current = [];
|
||||
const list = Array.isArray(data?.colliders) ? data.colliders : [];
|
||||
list.forEach(c => {
|
||||
let geom;
|
||||
if (c.type === 'circle') geom = new THREE.CylinderGeometry(1.5, 1.5, 2, 32);
|
||||
else if (c.type === 'capsule') geom = new THREE.CapsuleGeometry(1, 2, 4, 12);
|
||||
else geom = new THREE.BoxGeometry(2, 2, 2);
|
||||
const mesh = new THREE.Mesh(geom, colliderMaterial.clone());
|
||||
const edges = new THREE.EdgesGeometry(mesh.geometry);
|
||||
const line = new THREE.LineSegments(edges, colliderEdgeMaterial.clone());
|
||||
mesh.add(line);
|
||||
mesh.position.set(c.position?.x || 0, c.position?.y || 0, c.position?.z || 0);
|
||||
mesh.rotation.set(c.rotation?.x || 0, c.rotation?.y || 0, c.rotation?.z || 0);
|
||||
mesh.scale.set(c.scale?.x || 1, c.scale?.y || 1, c.scale?.z || 1);
|
||||
mesh.userData = { type: c.type || 'box' };
|
||||
sceneRef.current.add(mesh);
|
||||
collidersRef.current.push({ mesh });
|
||||
});
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [cityId]);
|
||||
|
||||
// Авто-сохранение (дебаунс) при изменениях
|
||||
const saveTimer = useRef(null);
|
||||
const requestSave = () => {
|
||||
clearTimeout(saveTimer.current);
|
||||
saveTimer.current = setTimeout(() => {
|
||||
const token = localStorage.getItem('token');
|
||||
const payload = { colliders: serializeColliders(), cityId };
|
||||
fetch('/api/colliders', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
}).catch(() => {});
|
||||
}, 400);
|
||||
};
|
||||
|
||||
const addCollider = () => {
|
||||
if (!sceneRef.current) return;
|
||||
let mesh;
|
||||
if (shapeType === 'box') {
|
||||
const geom = new THREE.BoxGeometry(2, 2, 2);
|
||||
mesh = new THREE.Mesh(geom, colliderMaterial.clone());
|
||||
} else if (shapeType === 'circle') {
|
||||
const geom = new THREE.CylinderGeometry(1.5, 1.5, 2, 32);
|
||||
mesh = new THREE.Mesh(geom, colliderMaterial.clone());
|
||||
} else if (shapeType === 'capsule') {
|
||||
const geom = new THREE.CapsuleGeometry(1, 2, 4, 12);
|
||||
mesh = new THREE.Mesh(geom, colliderMaterial.clone());
|
||||
} else {
|
||||
const geom = new THREE.BoxGeometry(2, 2, 2);
|
||||
mesh = new THREE.Mesh(geom, colliderMaterial.clone());
|
||||
}
|
||||
|
||||
const edges = new THREE.EdgesGeometry(mesh.geometry);
|
||||
const line = new THREE.LineSegments(edges, colliderEdgeMaterial.clone());
|
||||
mesh.add(line);
|
||||
|
||||
mesh.position.set(0, 1, 0);
|
||||
mesh.userData = { type: shapeType };
|
||||
sceneRef.current.add(mesh);
|
||||
collidersRef.current.push({ mesh });
|
||||
transformRef.current.attach(mesh);
|
||||
setSelected(mesh);
|
||||
requestSave();
|
||||
};
|
||||
|
||||
const deleteSelected = () => {
|
||||
if (!selected) return;
|
||||
transformRef.current.detach();
|
||||
sceneRef.current.remove(selected);
|
||||
collidersRef.current = collidersRef.current.filter(c => c.mesh !== selected);
|
||||
setSelected(null);
|
||||
requestSave();
|
||||
};
|
||||
|
||||
const serializeColliders = () => {
|
||||
return collidersRef.current.map(({ mesh }) => {
|
||||
const type = mesh.userData?.type || 'box';
|
||||
return {
|
||||
type,
|
||||
position: { x: mesh.position.x, y: mesh.position.y, z: mesh.position.z },
|
||||
rotation: { x: mesh.rotation.x, y: mesh.rotation.y, z: mesh.rotation.z },
|
||||
scale: { x: mesh.scale.x, y: mesh.scale.y, z: mesh.scale.z }
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const exportJSON = () => {
|
||||
const data = serializeColliders();
|
||||
const blob = new Blob([JSON.stringify({ colliders: data }, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'colliders.json';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const importJSON = (file) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
try {
|
||||
const parsed = JSON.parse(reader.result);
|
||||
collidersRef.current.forEach(c => sceneRef.current.remove(c.mesh));
|
||||
collidersRef.current = [];
|
||||
if (Array.isArray(parsed?.colliders)) {
|
||||
parsed.colliders.forEach(c => {
|
||||
let geom;
|
||||
if (c.type === 'circle') geom = new THREE.CylinderGeometry(1.5, 1.5, 2, 32);
|
||||
else if (c.type === 'capsule') geom = new THREE.CapsuleGeometry(1, 2, 4, 12);
|
||||
else geom = new THREE.BoxGeometry(2, 2, 2);
|
||||
const mesh = new THREE.Mesh(geom, colliderMaterial.clone());
|
||||
const edges = new THREE.EdgesGeometry(mesh.geometry);
|
||||
const line = new THREE.LineSegments(edges, colliderEdgeMaterial.clone());
|
||||
mesh.add(line);
|
||||
mesh.position.set(c.position?.x || 0, c.position?.y || 0, c.position?.z || 0);
|
||||
mesh.rotation.set(c.rotation?.x || 0, c.rotation?.y || 0, c.rotation?.z || 0);
|
||||
mesh.scale.set(c.scale?.x || 1, c.scale?.y || 1, c.scale?.z || 1);
|
||||
mesh.userData = { type: c.type || 'box' };
|
||||
sceneRef.current.add(mesh);
|
||||
collidersRef.current.push({ mesh });
|
||||
});
|
||||
requestSave();
|
||||
}
|
||||
} catch (e) {
|
||||
alert('Некорректный JSON');
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
};
|
||||
|
||||
const setSelectedPosition = (axis, value) => {
|
||||
if (!selected) return;
|
||||
const v = parseFloat(value);
|
||||
if (!isFinite(v)) return;
|
||||
selected.position[axis] = v;
|
||||
requestSave();
|
||||
};
|
||||
|
||||
const setSelectedScale = (axis, value) => {
|
||||
if (!selected) return;
|
||||
const v = Math.max(0.01, parseFloat(value));
|
||||
if (!isFinite(v)) return;
|
||||
if (lockUniformXZ && (axis === 'x' || axis === 'z')) {
|
||||
selected.scale.x = v;
|
||||
selected.scale.z = v;
|
||||
} else {
|
||||
selected.scale[axis] = v;
|
||||
}
|
||||
requestSave();
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ width: '100%', height: '100vh', position: 'relative' }} ref={mountRef}>
|
||||
<div style={{ position: 'absolute', top: 10, left: 10, background: 'rgba(255,255,255,0.9)', padding: 8, display: 'grid', gap: 8, minWidth: 320 }}>
|
||||
<div>
|
||||
<label>Город: </label>
|
||||
<select value={cityId || ''} onChange={e => setCityId(Number(e.target.value))}>
|
||||
{cities.map(c => (
|
||||
<option key={c.id} value={c.id}>{c.name} ({c.country_name})</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label>Форма: </label>
|
||||
<select value={shapeType} onChange={e => setShapeType(e.target.value)}>
|
||||
<option value="box">Прямоугольник</option>
|
||||
<option value="circle">Круг (цилиндр)</option>
|
||||
<option value="capsule">Капсула</option>
|
||||
</select>
|
||||
<button onClick={addCollider} style={{ marginLeft: 8 }}>Добавить</button>
|
||||
</div>
|
||||
<div>
|
||||
<button onClick={() => setMode(mode === 'translate' ? 'rotate' : mode === 'rotate' ? 'scale' : 'translate')}>
|
||||
Режим: {mode === 'translate' ? 'Перемещение' : mode === 'rotate' ? 'Вращение' : 'Масштаб'}
|
||||
</button>
|
||||
<button onClick={deleteSelected} style={{ marginLeft: 8 }}>Удалить выделенный</button>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(6, auto)', gap: 6, alignItems: 'center' }}>
|
||||
<b>Позиция</b>
|
||||
<span>X</span><input type="number" step="0.1" value={selected ? selected.position.x : ''} onChange={e => setSelectedPosition('x', e.target.value)} />
|
||||
<span>Y</span><input type="number" step="0.1" value={selected ? selected.position.y : ''} onChange={e => setSelectedPosition('y', e.target.value)} />
|
||||
<span>Z</span><input type="number" step="0.1" value={selected ? selected.position.z : ''} onChange={e => setSelectedPosition('z', e.target.value)} />
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, auto)', gap: 6, alignItems: 'center' }}>
|
||||
<b>Размер</b>
|
||||
<span>X</span><input type="number" min="0.01" step="0.1" value={selected ? selected.scale.x : ''} onChange={e => setSelectedScale('x', e.target.value)} />
|
||||
<span>Y</span><input type="number" min="0.01" step="0.1" value={selected ? selected.scale.y : ''} onChange={e => setSelectedScale('y', e.target.value)} />
|
||||
<span>Z</span><input type="number" min="0.01" step="0.1" value={selected ? selected.scale.z : ''} onChange={e => setSelectedScale('z', e.target.value)} />
|
||||
<label style={{ marginLeft: 6 }}>
|
||||
<input type="checkbox" checked={lockUniformXZ} onChange={e => setLockUniformXZ(e.target.checked)} /> XZ равны
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<b>Курсор</b>: X {cursorXZ.x.toFixed(2)} | Z {cursorXZ.z.toFixed(2)}
|
||||
</div>
|
||||
<div>
|
||||
<button onClick={exportJSON}>Экспорт JSON</button>
|
||||
<label style={{ marginLeft: 8 }}>
|
||||
Импорт JSON
|
||||
<input type="file" accept="application/json" style={{ display: 'block' }} onChange={(e) => e.target.files && e.target.files[0] && importJSON(e.target.files[0])} />
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
Подсказка: стрелки двигают точку прицеливания камеры (Shift — быстрее)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
545
src/pages/README.md
Normal file
545
src/pages/README.md
Normal file
@@ -0,0 +1,545 @@
|
||||
# Страницы EEV_Proj
|
||||
|
||||
## Обзор
|
||||
|
||||
Страницы React приложения EEV_Proj. Каждая страница представляет собой отдельный маршрут в приложении и отвечает за определенную функциональность.
|
||||
|
||||
## Структура
|
||||
|
||||
```
|
||||
pages/
|
||||
├── DoubleTapWrapper.jsx # Обертка для двойного тапа
|
||||
├── InteriorEditor.jsx # Редактор интерьеров
|
||||
├── Login.jsx # Страница входа
|
||||
├── Login copy.jsx # Копия страницы входа
|
||||
├── MapEditor.jsx # Редактор карты
|
||||
├── RegisterStep1.jsx # Регистрация - шаг 1
|
||||
├── RegisterStep2.jsx # Регистрация - шаг 2
|
||||
├── RegisterStep3.jsx # Регистрация - шаг 3
|
||||
├── WaveformPlayer.jsx # Плеер аудио
|
||||
└── README.md # Эта документация
|
||||
```
|
||||
|
||||
## DoubleTapWrapper.jsx
|
||||
|
||||
Обертка для обработки двойного тапа на мобильных устройствах.
|
||||
|
||||
**Функциональность:**
|
||||
- Обработка двойного тапа
|
||||
- Предотвращение случайных нажатий
|
||||
- Настройка задержки между тапами
|
||||
|
||||
**Использование:**
|
||||
```javascript
|
||||
<DoubleTapWrapper onDoubleTap={handleDoubleTap}>
|
||||
<div>Контент для двойного тапа</div>
|
||||
</DoubleTapWrapper>
|
||||
```
|
||||
|
||||
## InteriorEditor.jsx
|
||||
|
||||
Редактор интерьеров для создания и настройки внутренних пространств зданий.
|
||||
|
||||
**Функциональность:**
|
||||
- Загрузка 3D моделей интерьеров
|
||||
- Размещение объектов
|
||||
- Настройка коллайдеров
|
||||
- Экспорт конфигурации
|
||||
|
||||
**Компоненты:**
|
||||
- 3D сцена для предварительного просмотра
|
||||
- Панель инструментов
|
||||
- Список доступных объектов
|
||||
- Настройки материалов
|
||||
|
||||
## Login.jsx
|
||||
|
||||
Основная страница входа в систему.
|
||||
|
||||
**Функциональность:**
|
||||
- Форма входа с email/паролем
|
||||
- Валидация данных
|
||||
- Обработка ошибок
|
||||
- Перенаправление после входа
|
||||
- Ссылка на регистрацию
|
||||
|
||||
**Состояния:**
|
||||
- `email` - Email пользователя
|
||||
- `password` - Пароль
|
||||
- `loading` - Состояние загрузки
|
||||
- `error` - Сообщение об ошибке
|
||||
|
||||
## MapEditor.jsx
|
||||
|
||||
Редактор карты мира для настройки игрового пространства.
|
||||
|
||||
**Функциональность:**
|
||||
- Создание и редактирование карты
|
||||
- Размещение объектов города
|
||||
- Настройка путей и маршрутов
|
||||
- Экспорт карты
|
||||
|
||||
**Инструменты:**
|
||||
- Кисть для рисования
|
||||
- Ластик для удаления
|
||||
- Выбор объектов
|
||||
- Масштабирование и панорамирование
|
||||
|
||||
## RegisterStep1.jsx
|
||||
|
||||
Первый шаг регистрации - основная информация.
|
||||
|
||||
**Функциональность:**
|
||||
- Ввод имени пользователя
|
||||
- Ввод email адреса
|
||||
- Ввод пароля
|
||||
- Подтверждение пароля
|
||||
- Валидация данных
|
||||
|
||||
**Валидация:**
|
||||
- Уникальность имени пользователя
|
||||
- Корректность email
|
||||
- Сложность пароля
|
||||
- Совпадение паролей
|
||||
|
||||
## RegisterStep2.jsx
|
||||
|
||||
Второй шаг регистрации - профиль персонажа.
|
||||
|
||||
**Функциональность:**
|
||||
- Выбор аватара
|
||||
- Выбор пола персонажа
|
||||
- Настройка внешности
|
||||
- Предварительный просмотр
|
||||
|
||||
**Опции:**
|
||||
- Готовые аватары
|
||||
- Настройка цветов
|
||||
- Выбор причесок
|
||||
- Настройка лица
|
||||
|
||||
## RegisterStep3.jsx
|
||||
|
||||
Третий шаг регистрации - завершение.
|
||||
|
||||
**Функциональность:**
|
||||
- Подтверждение данных
|
||||
- Создание аккаунта
|
||||
- Активация
|
||||
- Перенаправление в игру
|
||||
|
||||
**Процесс:**
|
||||
- Проверка всех данных
|
||||
- Отправка на сервер
|
||||
- Обработка ответа
|
||||
- Успешная регистрация
|
||||
|
||||
## WaveformPlayer.jsx
|
||||
|
||||
Аудио плеер с визуализацией волновой формы.
|
||||
|
||||
**Функциональность:**
|
||||
- Воспроизведение аудио
|
||||
- Визуализация волновой формы
|
||||
- Управление воспроизведением
|
||||
- Настройка громкости
|
||||
|
||||
**Контролы:**
|
||||
- Play/Pause
|
||||
- Стоп
|
||||
- Регулировка громкости
|
||||
- Прогресс-бар
|
||||
- Временные метки
|
||||
|
||||
## Создание новых страниц
|
||||
|
||||
### Шаблон страницы
|
||||
|
||||
```javascript
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
|
||||
/**
|
||||
* Описание страницы
|
||||
* @param {Object} props - Свойства страницы
|
||||
*/
|
||||
const NewPage = (props) => {
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams();
|
||||
|
||||
// Состояния
|
||||
const [data, setData] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
// Эффекты
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [id]);
|
||||
|
||||
// Функции
|
||||
const loadData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
// Загрузка данных
|
||||
setData(result);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = (event) => {
|
||||
event.preventDefault();
|
||||
// Обработка отправки
|
||||
};
|
||||
|
||||
// Рендер
|
||||
if (loading) return <div>Загрузка...</div>;
|
||||
if (error) return <div>Ошибка: {error}</div>;
|
||||
|
||||
return (
|
||||
<div className="new-page">
|
||||
<h1>Новая страница</h1>
|
||||
<form onSubmit={handleSubmit}>
|
||||
{/* Форма */}
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NewPage;
|
||||
```
|
||||
|
||||
### Принципы
|
||||
|
||||
1. **Единая ответственность** - Каждая страница отвечает за одну функциональность
|
||||
2. **Маршрутизация** - Используйте React Router для навигации
|
||||
3. **Состояние** - Управляйте состоянием через хуки React
|
||||
4. **Обработка ошибок** - Всегда обрабатывайте ошибки и состояния загрузки
|
||||
5. **Доступность** - Обеспечивайте доступность для всех пользователей
|
||||
|
||||
## Маршрутизация
|
||||
|
||||
### Настройка маршрутов
|
||||
|
||||
```javascript
|
||||
import { BrowserRouter, Routes, Route } from 'react-router-dom';
|
||||
import Login from './pages/Login';
|
||||
import RegisterStep1 from './pages/RegisterStep1';
|
||||
import RegisterStep2 from './pages/RegisterStep2';
|
||||
import RegisterStep3 from './pages/RegisterStep3';
|
||||
import Game from './pages/Game';
|
||||
|
||||
const App = () => {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/register/step1" element={<RegisterStep1 />} />
|
||||
<Route path="/register/step2" element={<RegisterStep2 />} />
|
||||
<Route path="/register/step3" element={<RegisterStep3 />} />
|
||||
<Route path="/game" element={<Game />} />
|
||||
<Route path="/" element={<Navigate to="/login" />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
### Защищенные маршруты
|
||||
|
||||
```javascript
|
||||
import RequireProfile from '../components/RequireProfile';
|
||||
|
||||
const ProtectedRoute = ({ children }) => {
|
||||
return (
|
||||
<RequireProfile redirectTo="/login">
|
||||
{children}
|
||||
</RequireProfile>
|
||||
);
|
||||
};
|
||||
|
||||
// В маршрутах
|
||||
<Route
|
||||
path="/game"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Game />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
```
|
||||
|
||||
## Навигация
|
||||
|
||||
### Программная навигация
|
||||
|
||||
```javascript
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
const LoginPage = () => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleLogin = async (credentials) => {
|
||||
try {
|
||||
const response = await loginUser(credentials);
|
||||
if (response.success) {
|
||||
navigate('/game');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка входа:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button onClick={() => navigate('/register/step1')}>
|
||||
Регистрация
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
### Передача параметров
|
||||
|
||||
```javascript
|
||||
// Передача через navigate
|
||||
navigate('/user/123', { state: { userData } });
|
||||
|
||||
// Получение в компоненте
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
const UserPage = () => {
|
||||
const location = useLocation();
|
||||
const userData = location.state?.userData;
|
||||
|
||||
return <div>Данные пользователя: {userData?.name}</div>;
|
||||
};
|
||||
```
|
||||
|
||||
## Состояние страниц
|
||||
|
||||
### Локальное состояние
|
||||
|
||||
```javascript
|
||||
const [formData, setFormData] = useState({
|
||||
username: '',
|
||||
email: '',
|
||||
password: ''
|
||||
});
|
||||
|
||||
const handleChange = (event) => {
|
||||
const { name, value } = event.target;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: value
|
||||
}));
|
||||
};
|
||||
```
|
||||
|
||||
### Глобальное состояние
|
||||
|
||||
```javascript
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
|
||||
const ProfilePage = () => {
|
||||
const { user, updateUser } = useAuth();
|
||||
|
||||
const handleUpdate = async (newData) => {
|
||||
await updateUser(newData);
|
||||
};
|
||||
|
||||
return <div>Профиль: {user?.username}</div>;
|
||||
};
|
||||
```
|
||||
|
||||
## Валидация
|
||||
|
||||
### Формы
|
||||
|
||||
```javascript
|
||||
import { useState } from 'react';
|
||||
|
||||
const LoginForm = () => {
|
||||
const [errors, setErrors] = useState({});
|
||||
|
||||
const validateForm = (data) => {
|
||||
const newErrors = {};
|
||||
|
||||
if (!data.email) {
|
||||
newErrors.email = 'Email обязателен';
|
||||
} else if (!/\S+@\S+\.\S+/.test(data.email)) {
|
||||
newErrors.email = 'Email некорректен';
|
||||
}
|
||||
|
||||
if (!data.password) {
|
||||
newErrors.password = 'Пароль обязателен';
|
||||
} else if (data.password.length < 6) {
|
||||
newErrors.password = 'Пароль должен быть не менее 6 символов';
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = (event) => {
|
||||
event.preventDefault();
|
||||
if (validateForm(formData)) {
|
||||
// Отправка формы
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
className={errors.email ? 'error' : ''}
|
||||
/>
|
||||
{errors.email && <span className="error-text">{errors.email}</span>}
|
||||
</form>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
## Обработка ошибок
|
||||
|
||||
### Error Boundary
|
||||
|
||||
```javascript
|
||||
import { ErrorBoundary } from 'react-error-boundary';
|
||||
|
||||
const ErrorFallback = ({ error, resetErrorBoundary }) => {
|
||||
return (
|
||||
<div role="alert">
|
||||
<h2>Что-то пошло не так</h2>
|
||||
<pre>{error.message}</pre>
|
||||
<button onClick={resetErrorBoundary}>Попробовать снова</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const App = () => {
|
||||
return (
|
||||
<ErrorBoundary FallbackComponent={ErrorFallback}>
|
||||
<Router>
|
||||
<Routes>
|
||||
{/* Маршруты */}
|
||||
</Routes>
|
||||
</Router>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
### Try-Catch в компонентах
|
||||
|
||||
```javascript
|
||||
const DataPage = () => {
|
||||
const [data, setData] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const result = await api.getData();
|
||||
setData(result);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
console.error('Ошибка загрузки данных:', err);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="error-container">
|
||||
<h3>Ошибка загрузки</h3>
|
||||
<p>{error}</p>
|
||||
<button onClick={() => window.location.reload()}>
|
||||
Обновить страницу
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <div>{/* Контент */}</div>;
|
||||
};
|
||||
```
|
||||
|
||||
## Тестирование
|
||||
|
||||
### Unit тесты для страниц
|
||||
|
||||
```javascript
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import Login from './Login';
|
||||
|
||||
const renderWithRouter = (component) => {
|
||||
return render(
|
||||
<BrowserRouter>
|
||||
{component}
|
||||
</BrowserRouter>
|
||||
);
|
||||
};
|
||||
|
||||
describe('Login Page', () => {
|
||||
it('отображает форму входа', () => {
|
||||
renderWithRouter(<Login />);
|
||||
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/пароль/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('обрабатывает отправку формы', () => {
|
||||
const mockOnSubmit = jest.fn();
|
||||
renderWithRouter(<Login onSubmit={mockOnSubmit} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /войти/i }));
|
||||
expect(mockOnSubmit).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Интеграционные тесты
|
||||
|
||||
```javascript
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { AuthProvider } from '../contexts/AuthContext';
|
||||
import Login from './Login';
|
||||
|
||||
const renderWithProviders = (component) => {
|
||||
return render(
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
{component}
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
);
|
||||
};
|
||||
|
||||
describe('Login Integration', () => {
|
||||
it('выполняет вход и перенаправляет', async () => {
|
||||
renderWithProviders(<Login />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/email/i), {
|
||||
target: { value: 'test@example.com' }
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText(/пароль/i), {
|
||||
target: { value: 'password123' }
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /войти/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(window.location.pathname).toBe('/game');
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
Reference in New Issue
Block a user