0709 with changes and CollEditor

This commit is contained in:
2025-09-07 18:18:35 +03:00
parent 70b9d456e1
commit e48eadf9c5
19 changed files with 4405 additions and 33 deletions

317
MIGRATION_GUIDE.md Normal file
View 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
View File

@@ -0,0 +1,3 @@
{
"colliders": []
}

View 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
}
}
]
}

View File

@@ -1 +1 @@
{"time":"2025-06-16T00:46:41.600Z","lastReal":1756826890758}
{"time":"2025-07-25T23:21:30.224Z","lastReal":1757258251836}

View File

@@ -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
View 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 в репозитории

View File

@@ -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>

View File

@@ -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
View 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
View 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>
);
};
```

View 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
View 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 очищен');
}
}

View 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();
}
}

View 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
View 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)

View 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
View 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;
}
}

View 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>Город:&nbsp;</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>Форма:&nbsp;</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
View 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');
});
});
});
```