diff --git a/MIGRATION_GUIDE.md b/MIGRATION_GUIDE.md new file mode 100644 index 0000000..1b99871 --- /dev/null +++ b/MIGRATION_GUIDE.md @@ -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. Внесите вклад в развитие проекта + +Удачи в разработке! 🚀 diff --git a/public/colliders.json b/public/colliders.json new file mode 100644 index 0000000..8d551d5 --- /dev/null +++ b/public/colliders.json @@ -0,0 +1,3 @@ +{ + "colliders": [] +} diff --git a/public/colliders_city_1.json b/public/colliders_city_1.json new file mode 100644 index 0000000..176eb0f --- /dev/null +++ b/public/colliders_city_1.json @@ -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 + } + } + ] +} \ No newline at end of file diff --git a/saves/game_time.json b/saves/game_time.json index 14ae2d3..389cf07 100644 --- a/saves/game_time.json +++ b/saves/game_time.json @@ -1 +1 @@ -{"time":"2025-06-16T00:46:41.600Z","lastReal":1756826890758} \ No newline at end of file +{"time":"2025-07-25T23:21:30.224Z","lastReal":1757258251836} \ No newline at end of file diff --git a/server.js b/server.js index 99c6a96..0d4b618 100644 --- a/server.js +++ b/server.js @@ -1554,6 +1554,46 @@ app.post('/api/interiors/:id/save', authenticate, async (req, res) => { } }); +// Коллизии карты: загрузка/сохранение файла public/colliders.json +app.get('/api/colliders', authenticate, async (req, res) => { + const cityId = Number(req.query.cityId) || 0; + try { + const fileName = cityId ? `colliders_city_${cityId}.json` : 'colliders.json'; + const filePath = pathLib.join(__dirname, 'public', fileName); + try { + const raw = await fs.promises.readFile(filePath, 'utf8'); + const json = JSON.parse(raw); + return res.json(json); + } catch (e) { + // Если нет файла — создаём пустой + const empty = { colliders: [] }; + await fs.promises.mkdir(pathLib.join(__dirname, 'public'), { recursive: true }); + await fs.promises.writeFile(filePath, JSON.stringify(empty, null, 2), 'utf8'); + return res.json(empty); + } + } catch (e) { + console.error('Ошибка чтения colliders.json:', e); + res.status(500).json({ error: 'Ошибка чтения коллизий' }); + } +}); + +app.post('/api/colliders', authenticate, async (req, res) => { + try { + const { colliders, cityId } = req.body || {}; + if (!Array.isArray(colliders)) { + return res.status(400).json({ error: 'Invalid colliders' }); + } + const fileName = cityId ? `colliders_city_${Number(cityId)}.json` : 'colliders.json'; + const filePath = pathLib.join(__dirname, 'public', fileName); + await fs.promises.mkdir(pathLib.join(__dirname, 'public'), { recursive: true }); + await fs.promises.writeFile(filePath, JSON.stringify({ colliders }, null, 2), 'utf8'); + res.json({ ok: true }); + } catch (e) { + console.error('Ошибка записи colliders.json:', e); + res.status(500).json({ error: 'Ошибка сохранения коллизий' }); + } +}); + // Получить организацию по objectId diff --git a/server/README.md b/server/README.md new file mode 100644 index 0000000..f925485 --- /dev/null +++ b/server/README.md @@ -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 в репозитории diff --git a/src/App.js b/src/App.js index 92a38bb..645ccd1 100644 --- a/src/App.js +++ b/src/App.js @@ -10,6 +10,7 @@ import GameWrapper from './components/GameWrapper'; import RequireProfile from './components/RequireProfile'; import MapEditor from './pages/MapEditor'; import InteriorEditor from './pages/InteriorEditor'; +import CollisionEditor from './pages/CollisionEditor'; export default function App() { const [isAuth, setIsAuth] = useState(!!localStorage.getItem('token')); @@ -77,6 +78,18 @@ export default function App() { } /> + {/* редактор коллизий */} + + + + : + } + /> + {/* всё остальное */} } /> diff --git a/src/Game.js b/src/Game.js index ade4600..8a84da6 100644 --- a/src/Game.js +++ b/src/Game.js @@ -78,6 +78,11 @@ function Game({ avatarUrl, gender }) { const [inventory, setInventory] = useState([]); const [showInventory, setShowInventory] = useState(false); const [gameTime, setGameTime] = useState(null); + // Сеть + const [connectionLost, setConnectionLost] = useState(false); + const [latencyMs, setLatencyMs] = useState(null); + const connectionLostRef = useRef(false); + useEffect(() => { connectionLostRef.current = connectionLost; }, [connectionLost]); const [balance, setBalance] = useState(() => { const p = JSON.parse(sessionStorage.getItem('user_profile') || '{}'); return p.balance ?? 0; @@ -2274,6 +2279,8 @@ function Game({ avatarUrl, gender }) { const planarDist = Math.hypot(baseOffset.x, baseOffset.z); const radius = Math.hypot(planarDist, baseOffset.y); let baseAzimuth = Math.atan2(baseOffset.z, baseOffset.x); + const baseAzimuth0 = baseAzimuth; + let horizontalYaw = 0; // относительный поворот (±90°) от исходного const basePolar = Math.atan2(baseOffset.y, planarDist); let cameraPitchOffset = 0; @@ -2326,24 +2333,93 @@ function Game({ avatarUrl, gender }) { }); const socket = socketRef.current; + async function loadCustomCollidersForCity(cityIdParam) { + try { + const cityIdNum = Number(cityIdParam) || 0; + const query = cityIdNum ? `?cityId=${encodeURIComponent(cityIdNum)}` : ''; + const res = await fetch(`/api/colliders${query}`, { cache: 'no-store', headers: { Authorization: `Bearer ${token}` } }); + if (!res.ok) return; + const data = await res.json(); + const list = Array.isArray(data?.colliders) ? data.colliders : []; + // Удаляем старые кастомные коллайдеры + obstacles = obstacles.filter(o => { + const keep = !o?.mesh?.userData?.isCustomCollider; + if (!keep && o.mesh) { + scene.remove(o.mesh); + } + return keep; + }); + // Добавляем новые + list.forEach(c => { + let geometry; + if (c.type === 'circle') geometry = new THREE.CylinderGeometry(1.5, 1.5, 2, 24); + else if (c.type === 'capsule') geometry = new THREE.CapsuleGeometry(1, 2, 4, 12); + else geometry = new THREE.BoxGeometry(2, 2, 2); + const material = new THREE.MeshBasicMaterial({ color: 0x000000, transparent: true, opacity: 0.001, depthWrite: false }); + const mesh = new THREE.Mesh(geometry, material); + const p = c.position || {}; const r = c.rotation || {}; const s = c.scale || {}; + mesh.position.set(p.x || 0, p.y || 0, p.z || 0); + mesh.rotation.set(r.x || 0, r.y || 0, r.z || 0); + mesh.scale.set(s.x || 1, s.y || 1, s.z || 1); + mesh.userData.isCustomCollider = true; + scene.add(mesh); + obstacles.push({ mesh }); + }); + buildPathfindingGrid?.(); + } catch (e) { + console.warn('Не удалось загрузить colliders.json', e); + } + } + console.log('socket инстанс:', socket); console.log('Подключение к серверу:', serverUrl); socket.on('connect', () => { console.log('✔ Socket connected, id=', socket.id); console.log('Подключение успешно установлено'); + setConnectionLost(false); + // Подписка на ping/pong менеджера Socket.IO для измерения задержки + try { + const mgr = socket.io; + if (mgr && typeof mgr.on === 'function') { + mgr.off?.('pong'); + mgr.on('pong', (latency) => { + if (typeof latency === 'number' && isFinite(latency)) { + setLatencyMs(Math.round(latency)); + } + }); + } + } catch (e) { /* noop */ } }); + // Загрузка пользовательских коллайдеров при старте (по текущему городу) + try { + const profile = JSON.parse(sessionStorage.getItem('user_profile') || '{}'); + const initialCityId = profile.last_city_id || 1; + loadCustomCollidersForCity(initialCityId); + } catch {} + socket.on('connect_error', err => { console.error('Socket connect_error:', err); console.error('Ошибка подключения к серверу:', serverUrl); console.error('Проверьте, что сервер запущен на порту 4000'); + setConnectionLost(true); }); socket.on('disconnect', reason => { console.warn('Socket disconnected:', reason); console.warn('Соединение разорвано, причина:', reason); + setConnectionLost(true); }); + + // Небольшой таймер для обновления latency при отсутствии событий + const pingTimer = setInterval(() => { + const s = socketRef.current; + if (!s || s.disconnected) return; + // менеджер сам шлёт ping с интервалом, мы лишь не даём UI "застывать" + // если давно не было pong — считаем соединение деградировало + setLatencyMs((prev) => (prev == null ? prev : Math.min(prev + 1, 999))); + }, 1000); const profile = JSON.parse(sessionStorage.getItem('user_profile') || '{}'); if (profile?.id) { socket.emit('economy:getBalance', { userId: profile.id }); @@ -2949,12 +3025,12 @@ function Game({ avatarUrl, gender }) { if (e.ctrlKey) { // При нажатом Ctrl управляем и вертикальным, и горизонтальным углом камеры if (e.shiftKey) { - // Shift + Ctrl + колесо = горизонтальный поворот (влево-вправо) + // Shift + Ctrl + колесо = горизонтальный поворот (влево-вправо) относительно исходного азимута const horizontalDelta = delta * 2; // Увеличиваем чувствительность - baseAzimuth = THREE.MathUtils.clamp( - baseAzimuth + horizontalDelta, - -Math.PI / 2, // -90 градусов - Math.PI / 2 // +90 градусов + horizontalYaw = THREE.MathUtils.clamp( + horizontalYaw + horizontalDelta, + -Math.PI / 2, + Math.PI / 2 ); } else { // Ctrl + колесо = вертикальный поворот (вверх-вниз) @@ -3876,19 +3952,9 @@ function Game({ avatarUrl, gender }) { if (event.ctrlKey) { const key = event.key.toLowerCase(); if (key === 'arrowleft') { - const horizontalDelta = -0.1; // Поворот влево - baseAzimuth = THREE.MathUtils.clamp( - baseAzimuth + horizontalDelta, - -Math.PI / 2, // -90 градусов - Math.PI / 2 // +90 градусов - ); + horizontalYaw = THREE.MathUtils.clamp(horizontalYaw - 0.1, -Math.PI / 2, Math.PI / 2); } else if (key === 'arrowright') { - const horizontalDelta = 0.1; // Поворот вправо - baseAzimuth = THREE.MathUtils.clamp( - baseAzimuth + horizontalDelta, - -Math.PI / 2, // -90 градусов - Math.PI / 2 // +90 градусов - ); + horizontalYaw = THREE.MathUtils.clamp(horizontalYaw + 0.1, -Math.PI / 2, Math.PI / 2); } } @@ -3915,15 +3981,15 @@ function Game({ avatarUrl, gender }) { function createPlayerLabel(text) { const canvas = document.createElement('canvas'); - canvas.width = 512; // Увеличиваем размер canvas - canvas.height = 128; + canvas.width = 1024; // Увеличиваем размер canvas + canvas.height = 256; const ctx = canvas.getContext('2d'); // Добавляем фон для лучшей видимости ctx.fillStyle = 'rgba(0, 0, 0, 0.7)'; ctx.fillRect(0, 0, canvas.width, canvas.height); - const fontSize = 32; // Увеличиваем размер шрифта + const fontSize = 72; // Увеличиваем размер шрифта ctx.fillStyle = 'white'; ctx.font = `bold ${fontSize}px Arial`; @@ -3946,7 +4012,7 @@ function Game({ avatarUrl, gender }) { depthWrite: false }); const sprite = new THREE.Sprite(spriteMaterial); - sprite.scale.set(1, 0.25, 1); // Увеличиваем размер спрайта + sprite.scale.set(2.2, 0.55, 1); // Увеличиваем размер спрайта // ↓↓↓ добавь это ↓↓↓ sprite.raycast = () => { }; @@ -3994,6 +4060,27 @@ function Game({ avatarUrl, gender }) { return true; } + // Подсчёт количества пересечений с препятствиями для позиции (для "саморазблокировки") + function countIntersectionsAtPosition(pos, halfSize = 1) { + const playerMin = new THREE.Vector2(pos.x - halfSize, pos.z - halfSize); + const playerMax = new THREE.Vector2(pos.x + halfSize, pos.z + halfSize); + + let count = 0; + for (let i = 0; i < obstacles.length; i++) { + const mesh = obstacles[i]?.mesh; + if (!mesh) continue; + mesh.updateMatrixWorld(); + const box = new THREE.Box3().setFromObject(mesh); + const obstacleMin = new THREE.Vector2(box.min.x, box.min.z); + const obstacleMax = new THREE.Vector2(box.max.x, box.max.z); + if ((playerMin.x <= obstacleMax.x && playerMax.x >= obstacleMin.x) && + (playerMin.y <= obstacleMax.y && playerMax.y >= obstacleMin.y)) { + count++; + } + } + return count; + } + function updateDestinationMovement(delta) { if (!player || currentPath.length === 0 || pathIndex >= currentPath.length) return; @@ -4004,7 +4091,29 @@ function Game({ avatarUrl, gender }) { const stepDistance = moveSpeed * delta; if (dist < stepDistance) { + // Двигаем к точке и аккуратно выравниваем по верхней поверхности player.position.copy(target); + // Жёсткое выравнивание по топ-поверхности, чтобы исключить спад до y=0 на остановке + (function alignGroundFinal(p) { + const downRay = new THREE.Raycaster( + new THREE.Vector3(p.x, 100, p.z), + new THREE.Vector3(0, -1, 0), + 0, + 300 + ); + downRay.camera = cameraRef.current; + const walkables = [ + ...(cityGroupRef.current ? [cityGroupRef.current] : []), + groundPlane, + ...(cityMeshesRef.current || []) + ].filter(Boolean); + const raw = downRay.intersectObjects(walkables, true); + const isDescendantOf = (obj, ancestor) => { let c=obj; while(c){ if(c===ancestor) return true; c=c.parent;} return false; }; + const hits = raw + .filter(h => !h.object.isSprite && h.face && h.face.normal && h.face.normal.y > 0.6) + .filter(h => !isDescendantOf(h.object, player)); + if (hits.length) p.y = hits[0].point.y + 0.02; + })(player.position); pathIndex++; blockedTime = 0; if (pathIndex >= currentPath.length) { @@ -4022,6 +4131,16 @@ function Game({ avatarUrl, gender }) { dir.normalize(); const step = dir.clone().multiplyScalar(stepDistance); + // 1) Поворот всегда догоняет, движение начинается сразу — естественное скольжение в сторону цели + const desiredYaw = Math.atan2(dir.x, dir.z); + const currentYaw = new THREE.Euler().setFromQuaternion(player.quaternion, 'YXZ').y; + let yawDiff = desiredYaw - currentYaw; + yawDiff = ((yawDiff + Math.PI) % (2 * Math.PI)) - Math.PI; // нормализация [-PI, PI] + const maxTurnRate = 3.0; // рад/сек — ограничиваем скорость поворота + const stepAngle = THREE.MathUtils.clamp(yawDiff, -maxTurnRate * delta, maxTurnRate * delta); + const newYawFollow = currentYaw + stepAngle; + player.quaternion.setFromEuler(new THREE.Euler(0, newYawFollow, 0)); + // Кандидаты перемещения: прямо, слайд по X, слайд по Z const tryMoves = [ player.position.clone().add(step), @@ -4029,7 +4148,7 @@ function Game({ avatarUrl, gender }) { player.position.clone().add(new THREE.Vector3(0, 0, step.z)) ]; - // Помощник: «привязка» к верхней поверхности + // Помощник: «привязка» к верхней поверхности (учитываем всю геометрию города) const stickToTopSurface = (pos) => { const downRay = new THREE.Raycaster( new THREE.Vector3(pos.x, 100, pos.z), @@ -4039,12 +4158,21 @@ function Game({ avatarUrl, gender }) { ); downRay.camera = cameraRef.current; // важное дополнение для спрайтов - // фильтруем null/undefined - const walkables = [groundPlane, ...(cityMeshesRef.current || [])].filter(Boolean); + // фильтруем null/undefined и целимся в корневую группу города + groundPlane + const walkables = [ + ...(cityGroupRef.current ? [cityGroupRef.current] : []), + groundPlane, + ...(cityMeshesRef.current || []) + ].filter(Boolean); - const hits = downRay - .intersectObjects(walkables, true) - .filter(h => !h.object.isSprite && h.face && h.face.normal && h.face.normal.y > 0.6); + const raw = downRay.intersectObjects(walkables, true); + const isDescendantOf = (obj, ancestor) => { + let cur = obj; while (cur) { if (cur === ancestor) return true; cur = cur.parent; } + return false; + }; + const hits = raw + .filter(h => !h.object.isSprite && h.face && h.face.normal && h.face.normal.y > 0.6) + .filter(h => !isDescendantOf(h.object, player)); if (hits.length) { pos.y = hits[0].point.y + 0.02; // лёгкий "антизалип" @@ -4063,10 +4191,59 @@ function Game({ avatarUrl, gender }) { } } + // Саморазблокировка: если не удалось пройти обычной проверкой, но текущая клетка непроходима, + // ищем ближайшее направление с уменьшением количества пересечений и прогрессом к цели + if (!moved) { + const currentIntersections = countIntersectionsAtPosition(player.position, 1); + if (currentIntersections > 0) { + const radii = [stepDistance * 0.6, stepDistance * 1.0, stepDistance * 1.6]; + const angles = 24; // 15° шаг + let bestPos = null; + let bestScore = currentIntersections; + let bestDist = Infinity; + const escapeHalf = 0.6; // слегка ужимаем хитбокс при выходе + for (const r of radii) { + for (let i = 0; i < angles; i++) { + const a = (i / angles) * Math.PI * 2; + const dir2 = new THREE.Vector3(Math.sin(a), 0, Math.cos(a)); + const cand = player.position.clone().addScaledVector(dir2, r); + const score = countIntersectionsAtPosition(cand, escapeHalf); + const distToTarget = cand.distanceTo(target); + if ( + score < bestScore || + (score === bestScore && distToTarget < bestDist) + ) { + bestScore = score; + bestDist = distToTarget; + bestPos = cand; + if (bestScore === 0) break; + } + } + if (bestScore === 0) break; + } + if (bestPos) { + stickToTopSurface(bestPos); + player.position.copy(bestPos); + moved = true; + blockedTime = 0; + } else { + // Последняя попытка: небольшая "встряска" вверх и повторное прилипание к поверхности + const nudged = player.position.clone(); + nudged.y += 0.05; + stickToTopSurface(nudged); + player.position.copy(nudged); + } + } + } + if (moved) { - const angle = Math.atan2(dir.x, dir.z); - const targetQuat = new THREE.Quaternion().setFromEuler(new THREE.Euler(0, angle, 0)); - player.quaternion.slerp(targetQuat, Math.min(1, 10 * delta)); + // Плавный доворот в сторону движения, но движение идёт сразу + const curYaw = new THREE.Euler().setFromQuaternion(player.quaternion, 'YXZ').y; + let d = desiredYaw - curYaw; + d = ((d + Math.PI) % (2 * Math.PI)) - Math.PI; + const rotStep = THREE.MathUtils.clamp(d, -maxTurnRate * delta, maxTurnRate * delta); + const newYaw = curYaw + rotStep; + player.quaternion.setFromEuler(new THREE.Euler(0, newYaw, 0)); socketRef.current?.emit('playerMovement', { x: player.position.x, y: player.position.y, z: player.position.z }); if (currentAction !== walkAction) { @@ -4102,8 +4279,32 @@ function Game({ avatarUrl, gender }) { idleAction.reset().fadeIn(0.2).play(); currentAction = idleAction; } + // Жёсткое выравнивание по топ-поверхности при переходе в idle + (function alignGroundFinal(p) { + const downRay = new THREE.Raycaster( + new THREE.Vector3(p.x, 100, p.z), + new THREE.Vector3(0, -1, 0), + 0, + 300 + ); + downRay.camera = cameraRef.current; + const walkables = [ + ...(cityGroupRef.current ? [cityGroupRef.current] : []), + groundPlane, + ...(cityMeshesRef.current || []) + ].filter(Boolean); + const raw = downRay.intersectObjects(walkables, true); + const isDescendantOf = (obj, ancestor) => { let c=obj; while(c){ if(c===ancestor) return true; c=c.parent;} return false; }; + const hits = raw + .filter(h => !h.object.isSprite && h.face && h.face.normal && h.face.normal.y > 0.6) + .filter(h => !isDescendantOf(h.object, player)); + if (hits.length) p.y = hits[0].point.y + 0.02; + })(player.position); } } + + // Всегда подравниваем Y к верхней поверхности, чтобы исключить проваливания на остановке + stickToTopSurface(player.position); } @@ -4330,8 +4531,10 @@ function Game({ avatarUrl, gender }) { const polar = basePolar + cameraPitchOffset; const planar = radius * Math.cos(polar); const yOff = radius * Math.sin(polar); - const xOff = planar * Math.cos(baseAzimuth); - const zOff = planar * Math.sin(baseAzimuth); + // Горизонтальный угол = исходный азимут + относительный поворот (±90°) + const azimuth = baseAzimuth0 + horizontalYaw; + const xOff = planar * Math.cos(azimuth); + const zOff = planar * Math.sin(azimuth); // Плавная интерполяция позиции камеры const targetPosition = new THREE.Vector3( @@ -4353,6 +4556,16 @@ function Game({ avatarUrl, gender }) { return; } + // Блокировка управления при потере соединения + if (connectionLostRef.current) { + // Останавливаем любые движения + if (moveInputRef.current) { + Object.keys(moveInputRef.current).forEach(k => moveInputRef.current[k] = false); + } + // Скрыть маркер назначения + if (destinationMarker) destinationMarker.visible = false; + } + if (!clock || typeof clock.getDelta !== 'function') { console.warn('Clock не инициализирован'); return; @@ -4606,6 +4819,14 @@ function Game({ avatarUrl, gender }) {
X: {playerCoords.x} Y: {playerCoords.y} Z: {playerCoords.z}
+ {/* Индикатор связи в правом нижнем углу */} +
+
+
+ {connectionLost ? 'Связь: нет' : `Пинг: ${latencyMs ?? '—'} ms`} +
+
{(() => { if (!gameTime) return 'Загрузка времени...'; @@ -4614,6 +4835,19 @@ function Game({ avatarUrl, gender }) { return d.toLocaleString(); })()}
+ + {/* Оверлей при потере соединения */} + {connectionLost && ( +
+
+
Соединение потеряно
+
Связь с сервером была прервана. Пожалуйста, перезайдите в игру.
+ +
+
+ )} {/* Кнопка карты мира */}