diff --git a/db1.js b/db1.js index 51c3429..ec72a76 100644 --- a/db1.js +++ b/db1.js @@ -2,15 +2,22 @@ require('dotenv').config(); const { Pool } = require('pg'); const connectionString = - process.env.DATABASE_URL_VIRTUAL_WORLD || process.env.DATABASE_URL; - + process.env.DATABASE_URL_VIRTUAL_WORLD; +console.log(' : ', connectionString); const virtualWorldPool = new Pool({ connectionString, ssl: false }); + +// +virtualWorldPool.on('error', (err) => { + console.error(' :', err); +}); + +virtualWorldPool.on('connect', () => { + console.log(' '); +}); module.exports = { - virtualWorldPool: { query: (text, params) => virtualWorldPool.query(text, params) - } }; \ No newline at end of file diff --git a/server.js b/server.js index c76c7fd..694e8d0 100644 --- a/server.js +++ b/server.js @@ -1,4 +1,4 @@ -let dotenv, express, db, Economy, GameTime, pathLib, fs, virtualWorldPool; +let dotenv, express, db, Economy, GameTime, pathLib, fs, virtualWorldPool, new_quest_Base; try { dotenv = require('dotenv').config(); console.log('dotenv успешно импортирован'); @@ -24,6 +24,25 @@ try { console.error('Ошибка при импорте express:', e); throw e; } + +try { + virtualWorldPool = require('./db1'); + console.log('db1 - virtualWorld - успешно импротирован'); +} +catch (e) { + console.error('Ошибка при импорте db1 - virtual_World:', e); + throw e; +} + +try { + new_quest_Base = require('./db2'); + console.log('db2 - new_quest_Base - успешно импортирован'); +} +catch (e) { + console.error('Ошибка при импорте db2 - new_quest_Base: ', e); + throw e; +} + try { db = require('./db'); console.log('db успешно импортирован'); @@ -424,10 +443,9 @@ app.get('/api/users', authenticate, async (req, res) => { app.get('/api/messages/:contactId', authenticate, async (req, res) => { const userId = req.user.id; const contactId = parseInt(req.params.contactId, 10); - const pool = (typeof virtualWorldPool !== 'undefined' && virtualWorldPool) ? virtualWorldPool : db; try { // Ensure table exists - await pool.query(` + await virtualWorldPool.query(` CREATE TABLE IF NOT EXISTS messages ( id SERIAL PRIMARY KEY, sender_id INT NOT NULL, @@ -445,7 +463,7 @@ app.get('/api/messages/:contactId', authenticate, async (req, res) => { WHERE (sender_id = $1 AND receiver_id = $2) OR (sender_id = $2 AND receiver_id = $1) ORDER BY created_at ASC`; - const messagesRes = await pool.query(sql, [userId, contactId]); + const messagesRes = await virtualWorldPool.query(sql, [userId, contactId]); res.json(messagesRes.rows); } catch (err) { console.error('[GET /api/messages/:contactId] error:', err); @@ -457,15 +475,14 @@ app.post('/api/messages/send', authenticate, async (req, res) => { const senderId = req.user.id; const { receiverId, message } = req.body || {}; const recvId = parseInt(receiverId, 10); - const pool = (typeof virtualWorldPool !== 'undefined' && virtualWorldPool) ? virtualWorldPool : db; console.log('[POST /api/messages/send] sender:', senderId, 'receiver:', recvId); try { - const receiverCheck = await db.query('SELECT id FROM users WHERE id = $1', [recvId]); + const receiverCheck = await db.query('SELECT id FROM users WHERE id = $1', [recvId]); if (receiverCheck.rows.length === 0) { return res.status(404).json({ error: 'Пользователь не найден' }); } try { - await pool.query(` + await virtualWorldPool.query(` CREATE TABLE IF NOT EXISTS messages ( id SERIAL PRIMARY KEY, sender_id INT NOT NULL, @@ -478,7 +495,7 @@ app.post('/api/messages/send', authenticate, async (req, res) => { console.warn('[POST /api/messages/send] ensure table failed:', e.message); } - const result = await pool.query( + const result = await virtualWorldPool.query( `INSERT INTO messages (sender_id, receiver_id, message, created_at) VALUES ($1, $2, $3, NOW()) RETURNING id, created_at, is_read`, @@ -505,9 +522,8 @@ app.post('/api/messages/send', authenticate, async (req, res) => { app.get('/api/messages', authenticate, async (req, res) => { const userId = req.user.id; - const pool = (typeof virtualWorldPool !== 'undefined' && virtualWorldPool) ? virtualWorldPool : db; - try { - await pool.query(` + try { + await virtualWorldPool.query(` CREATE TABLE IF NOT EXISTS messages ( id SERIAL PRIMARY KEY, sender_id INT NOT NULL, @@ -520,7 +536,7 @@ app.get('/api/messages', authenticate, async (req, res) => { console.warn('[GET /api/messages] ensure table failed:', e.message); } try { - const messagesRes = await pool.query( + const messagesRes = await virtualWorldPool.query( `SELECT * FROM messages WHERE sender_id = $1 OR receiver_id = $1 ORDER BY created_at DESC`, @@ -531,7 +547,7 @@ app.get('/api/messages', authenticate, async (req, res) => { } const userIds = new Set(); messagesRes.rows.forEach(msg => { userIds.add(msg.sender_id); userIds.add(msg.receiver_id); }); - const usersRes = await db.query( + const usersRes = await virtualWorldPool.query( `SELECT id, first_name, last_name, avatar_url FROM users WHERE id = ANY($1)`, [Array.from(userIds)] ); @@ -559,9 +575,8 @@ app.get('/api/messages', authenticate, async (req, res) => { app.patch('/api/messages/:id/read', authenticate, async (req, res) => { const messageId = req.params.id; const userId = req.user.id; - const pool = (typeof virtualWorldPool !== 'undefined' && virtualWorldPool) ? virtualWorldPool : db; try { - await pool.query(` + await virtualWorldPool.query(` CREATE TABLE IF NOT EXISTS messages ( id SERIAL PRIMARY KEY, sender_id INT NOT NULL, @@ -574,11 +589,11 @@ app.patch('/api/messages/:id/read', authenticate, async (req, res) => { console.warn('[PATCH /api/messages/:id/read] ensure table failed:', e.message); } try { - const checkRes = await pool.query(`SELECT id FROM messages WHERE id = $1 AND receiver_id = $2`, [messageId, userId]); + const checkRes = await virtualWorldPool.query(`SELECT id FROM messages WHERE id = $1 AND receiver_id = $2`, [messageId, userId]); if (checkRes.rows.length === 0) { return res.status(404).json({ error: 'Сообщение не найдено или доступ запрещен' }); } - await pool.query(`UPDATE messages SET is_read = true WHERE id = $1`, [messageId]); + await virtualWorldPool.query(`UPDATE messages SET is_read = true WHERE id = $1`, [messageId]); res.status(204).end(); } catch (err) { console.error('[PATCH /api/messages/:id/read] error:', err); @@ -979,10 +994,9 @@ app.post('/api/listen', authenticate, async (req, res) => { } // Выбираем пул: если есть отдельный пул, берём его, иначе общий db - const pool = (typeof virtualWorldPool !== 'undefined' && virtualWorldPool) ? virtualWorldPool : db; try { // Создаём таблицу при необходимости - await pool.query(` + await virtualWorldPool.query(` CREATE TABLE IF NOT EXISTS json_listened ( id SERIAL PRIMARY KEY, player_id TEXT NOT NULL, @@ -994,7 +1008,7 @@ app.post('/api/listen', authenticate, async (req, res) => { } const q = `INSERT INTO json_listened (player_id, json_filename, listened_at) VALUES ($1, $2, NOW())`; - await pool.query(q, [String(effectivePlayerId), String(json_filename)]); + await virtualWorldPool.query(q, [String(effectivePlayerId), String(json_filename)]); console.log('[API /api/listen] Saved listened:', { player: effectivePlayerId, json: json_filename }); return res.status(200).json({ success: true }); } catch (err) { @@ -1179,50 +1193,29 @@ app.get('/api/quests/progress', authenticate, async (req, res) => { console.log("Загрузка на сервере. ID пользователя:", req.user.id); try { - // Получаем email пользователя из основной БД (или из токена) - const fallbackEmail = req.user?.email || req.user?.username || null; - const userRes = await db.query('SELECT email FROM users WHERE id = $1', [req.user.id]); - if (userRes.rows.length === 0) { - if (!fallbackEmail) { - return res.status(404).json({ error: 'Пользователь не найден' }); - } - } - const userEmail = userRes.rows[0]?.email || fallbackEmail; + // ВМЕСТО получения email, используем напрямую ID пользователя + const userId = req.user.id.toString(); // Преобразуем в строку для consistency // Получаем список всех квестов с их JSON файлами - const pool = (typeof virtualWorldPool !== 'undefined' && virtualWorldPool) ? virtualWorldPool : db; - const questsQuery = await pool.query(` + const questsQuery = await virtualWorldPool.query(` SELECT q.id, q.title, qj.json_filename FROM quests q JOIN quest_jsons qj ON q.id = qj.quest_id ORDER BY q.id `); - // Получаем JSON файлы, которые прослушал игрок - // Гарантируем наличие таблицы json_listened - try { - await pool.query(` - CREATE TABLE IF NOT EXISTS json_listened ( - id SERIAL PRIMARY KEY, - player_id TEXT NOT NULL, - json_filename TEXT NOT NULL, - listened_at TIMESTAMPTZ NOT NULL DEFAULT NOW() - )`); - } catch (e) { - console.warn('[/api/quests/progress] ensure table json_listened failed:', e.message); - } - - const listenedQuery = await pool.query(` + // Получаем JSON файлы, которые прослушал игрок по его ID + const listenedQuery = await virtualWorldPool.query(` SELECT json_filename FROM json_listened WHERE player_id = $1 - `, [userEmail]); + `, [userId]); // Используем ID вместо email - console.log("Результат запроса listenedQuery:", listenedQuery.rows); + console.log("Результат запроса listenedQuery для ID", userId, ":", listenedQuery.rows); const listenedFiles = new Set(listenedQuery.rows.map(row => row.json_filename)); console.log("Прослушанные файлы:", Array.from(listenedFiles)); - // Остальной код остается без изменений... + // Остальной код без изменений... const questsMap = new Map(); questsQuery.rows.forEach(row => { if (!questsMap.has(row.id)) { diff --git a/src/Game.js b/src/Game.js index 9525663..f12e769 100644 --- a/src/Game.js +++ b/src/Game.js @@ -1,83 +1,84 @@ /* +gjhghjhgjghj - Проблема с игроками они множатся - Проблема с перемещением между городами (исчезновение и появление игроков) - Проблема с Null полусферами */ -import React, { useState, useEffect, useRef } from 'react'; +import PF from 'pathfinding'; +import React, { useEffect, useRef, useState } from 'react'; +import { io } from 'socket.io-client'; import * as THREE from 'three'; import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'; -import PF from 'pathfinding'; -import { io } from 'socket.io-client'; -import DoubleTapWrapper from './pages/DoubleTapWrapper'; -import OrgControlPanel from './components/OrgControlPanel'; -import Inventory from './components/Inventory'; import { useDialogManager } from './components/DialogSystem/DialogManager'; import { DialogWindow } from './components/DialogSystem/DialogWindow'; +import Inventory from './components/Inventory'; +import OrgControlPanel from './components/OrgControlPanel'; +import DoubleTapWrapper from './pages/DoubleTapWrapper'; import WaveformPlayer from './pages/WaveformPlayer'; function Game({ avatarUrl, gender }) { - // 1) реф для хранилища сцены - const sceneRef = useRef(new THREE.Scene()); + // 1) реф для хранилища сцены + const sceneRef = useRef(new THREE.Scene()); - // 2) реф для группы «города» - const cityGroupRef = useRef(null); + // 2) реф для группы «города» + const cityGroupRef = useRef(null); - // 3) реф для группы «интерьера» - const interiorGroupRef = useRef(null); - const interiorCollidersRef = useRef([]); - const interiorExitPosRef = useRef(null); - const fpHiddenNodesRef = useRef([]); - const cleanupTimerRef = useRef(null); - // Глобальный менеджер прогресса загрузки (используем в GLTFLoader) - const loadingManagerRef = useRef(null); - // Кликабельные объекты внутри интерьера - const interiorInteractablesRef = useRef([]); - const npcMeshesRef = useRef([]); + // 3) реф для группы «интерьера» + const interiorGroupRef = useRef(null); + const interiorCollidersRef = useRef([]); + const interiorExitPosRef = useRef(null); + const fpHiddenNodesRef = useRef([]); + const cleanupTimerRef = useRef(null); + // Глобальный менеджер прогресса загрузки (используем в GLTFLoader) + const loadingManagerRef = useRef(null); + // Кликабельные объекты внутри интерьера + const interiorInteractablesRef = useRef([]); + const npcMeshesRef = useRef([]); - // камеры - const orthoCamRef = useRef(null); - const fpCamRef = useRef(null); - const cameraRef = useRef(null); - const rendererRef = useRef(null); - const moveInputRef = useRef({ forward: false, backward: false, left: false, right: false, strafeLeft: false, strafeRight: false }); - const fpPitchRef = useRef(0); - const isTouchDevice = typeof window !== 'undefined' && ('ontouchstart' in window || navigator.maxTouchPoints > 0); - const isInInteriorRef = useRef(false); - const altHeldRef = useRef(false); - const LOAD_RADIUS = 120; + // камеры + const orthoCamRef = useRef(null); + const fpCamRef = useRef(null); + const cameraRef = useRef(null); + const rendererRef = useRef(null); + const moveInputRef = useRef({ forward: false, backward: false, left: false, right: false, strafeLeft: false, strafeRight: false }); + const fpPitchRef = useRef(0); + const isTouchDevice = typeof window !== 'undefined' && ('ontouchstart' in window || navigator.maxTouchPoints > 0); + const isInInteriorRef = useRef(false); + const altHeldRef = useRef(false); + const LOAD_RADIUS = 120; - const [activeApp, setActiveApp] = useState(null); + const [activeApp, setActiveApp] = useState(null); - const [selectedHouse, setSelectedHouse] = useState(null); - const [isInInterior, setIsInInterior] = useState(false); - const mountRef = useRef(null); - const socketRef = useRef(null); + const [selectedHouse, setSelectedHouse] = useState(null); + const [isInInterior, setIsInInterior] = useState(false); + const mountRef = useRef(null); + const socketRef = useRef(null); - useEffect(() => { - console.log('useEffect isInInterior изменился:', isInInterior); - isInInteriorRef.current = isInInterior; - console.log('isInInteriorRef.current установлен в:', isInInteriorRef.current); - }, [isInInterior]); - const [selectedPlayer, setSelectedPlayer] = useState(null); - const [playerStats, setPlayerStats] = useState(null); - const [micEnabled, setMicEnabled] = useState(false); - const [orgMenu, setOrgMenu] = useState(null); - const [orgPanelId, setOrgPanelId] = useState(null); - const [satiety, setSatiety] = useState(() => { - const p = JSON.parse(sessionStorage.getItem('user_profile') || '{}'); - return p.satiety ?? 100; - }); - const [thirst, setThirst] = useState(() => { - const p = JSON.parse(sessionStorage.getItem('user_profile') || '{}'); - return p.thirst ?? 100; - }); - const [inventory, setInventory] = useState([]); - const [showInventory, setShowInventory] = useState(false); - const [gameTime, setGameTime] = useState(null); - const [balance, setBalance] = useState(() => { - const p = JSON.parse(sessionStorage.getItem('user_profile') || '{}'); - return p.balance ?? 0; - }); + useEffect(() => { + console.log('useEffect isInInterior изменился:', isInInterior); + isInInteriorRef.current = isInInterior; + console.log('isInInteriorRef.current установлен в:', isInInteriorRef.current); + }, [isInInterior]); + const [selectedPlayer, setSelectedPlayer] = useState(null); + const [playerStats, setPlayerStats] = useState(null); + const [micEnabled, setMicEnabled] = useState(false); + const [orgMenu, setOrgMenu] = useState(null); + const [orgPanelId, setOrgPanelId] = useState(null); + const [satiety, setSatiety] = useState(() => { + const p = JSON.parse(sessionStorage.getItem('user_profile') || '{}'); + return p.satiety ?? 100; + }); + const [thirst, setThirst] = useState(() => { + const p = JSON.parse(sessionStorage.getItem('user_profile') || '{}'); + return p.thirst ?? 100; + }); + const [inventory, setInventory] = useState([]); + const [showInventory, setShowInventory] = useState(false); + const [gameTime, setGameTime] = useState(null); + const [balance, setBalance] = useState(() => { + const p = JSON.parse(sessionStorage.getItem('user_profile') || '{}'); + return p.balance ?? 0; + }); const [playerCoords, setPlayerCoords] = useState({ x: 0, y: 0, z: 0 }); const [programmingLanguages, setProgrammingLanguages] = useState([]); const [passwordCorrect, setPasswordCorrect] = useState(false); @@ -123,21 +124,21 @@ function Game({ avatarUrl, gender }) { const currentExitRef = useRef(null); useEffect(() => { currentExitRef.current = currentExit; }, [currentExit]); - useEffect(() => { - const decay = setInterval(() => { - setSatiety(s => Math.max(0, s - 0.05)); - setThirst(t => Math.max(0, t - 0.07)); - }, 10000); - return () => clearInterval(decay); - }, []); + useEffect(() => { + const decay = setInterval(() => { + setSatiety(s => Math.max(0, s - 0.05)); + setThirst(t => Math.max(0, t - 0.07)); + }, 10000); + return () => clearInterval(decay); + }, []); - useEffect(() => { - const profile = JSON.parse(sessionStorage.getItem('user_profile') || '{}'); - profile.satiety = satiety; - profile.thirst = thirst; - sessionStorage.setItem('user_profile', JSON.stringify(profile)); - socketRef.current?.emit('economy:updateStats', { satiety, thirst }); - }, [satiety, thirst]); + useEffect(() => { + const profile = JSON.parse(sessionStorage.getItem('user_profile') || '{}'); + profile.satiety = satiety; + profile.thirst = thirst; + sessionStorage.setItem('user_profile', JSON.stringify(profile)); + socketRef.current?.emit('economy:updateStats', { satiety, thirst }); + }, [satiety, thirst]); //const [currentDialog, setCurrentDialog] = useState(null); @@ -146,7 +147,7 @@ function Game({ avatarUrl, gender }) { //const [formData, setFormData] = useState({}); //const [currentForm, setCurrentForm] = useState(null); - //Телефон + //Телефон let scene, renderer; const playerRef = useRef(null); const cityMeshesRef = useRef([]); @@ -508,334 +509,334 @@ function Game({ avatarUrl, gender }) { const loader = new GLTFLoader(); // базовая геометрия для объектов типа "chair" const baseChairMesh = new THREE.Mesh( - new THREE.BoxGeometry(1, 1, 1), - new THREE.MeshBasicMaterial({ visible: false }) + new THREE.BoxGeometry(1, 1, 1), + new THREE.MeshBasicMaterial({ visible: false }) ); async function loadGLTF(url) { - return new Promise((resolve, reject) => { - loader.load(url, gltf => resolve(gltf), undefined, err => reject(err)); - }); + return new Promise((resolve, reject) => { + loader.load(url, gltf => resolve(gltf), undefined, err => reject(err)); + }); } async function enterInteriorMode(interiorId) { - console.log('enterInteriorMode вызвана для интерьера:', interiorId); - - // Сохраняем текущую позицию игрока - if (playerRef.current) { - savedPositionRef.current.copy(playerRef.current.position); - } - - // Загружаем модель интерьера - console.log('Загружаем модель интерьера'); - await loadInteriorModel(interiorId); - - // Переключаемся на камеру от первого лица - console.log('Переключаемся на камеру от первого лица'); - switchToFirstPersonCamera(); - - // Включаем управление мышью для интерьера - // Курсор оставляем активным (без pointer lock) - document.body.style.cursor = 'default'; - - // Устанавливаем состояние "в интерьере" - console.log('Устанавливаем setIsInInterior(true)'); - setIsInInterior(true); - setSelectedHouse(null); - - console.log('isInInterior установлен в true'); - // Сброс кликово-путевого движения и визуальных маркеров - if (typeof currentPath !== 'undefined') currentPath = []; - if (typeof pathIndex !== 'undefined') pathIndex = 0; - if (typeof destination !== 'undefined') destination = null; - if (typeof blockedTime !== 'undefined') blockedTime = 0; - if (typeof destinationMarker !== 'undefined' && destinationMarker) destinationMarker.visible = false; - // Сброс нажатых направлений - if (moveInputRef.current) { - Object.keys(moveInputRef.current).forEach(k => { moveInputRef.current[k] = false; }); - } - - // Телепортируем игрока в интерьер (если нужно) - console.log('Вызываем teleportPlayerToInterior для интерьера:', interiorId); - await teleportPlayerToInterior(interiorId); - // Отправляем мгновенное обновление позиции перед уведомлением об интерьере - if (socketRef.current && playerRef.current) { - socketRef.current.emit('playerMovement', { x: playerRef.current.position.x, y: playerRef.current.position.y, z: playerRef.current.position.z }); - } - // Сообщаем серверу о смене интерьера, чтобы видимость игроков фильтровалась по interiorId - socketRef.current?.emit('interiorChange', { interiorId }); - console.log('teleportPlayerToInterior завершена'); + console.log('enterInteriorMode вызвана для интерьера:', interiorId); + + // Сохраняем текущую позицию игрока + if (playerRef.current) { + savedPositionRef.current.copy(playerRef.current.position); + } + + // Загружаем модель интерьера + console.log('Загружаем модель интерьера'); + await loadInteriorModel(interiorId); + + // Переключаемся на камеру от первого лица + console.log('Переключаемся на камеру от первого лица'); + switchToFirstPersonCamera(); + + // Включаем управление мышью для интерьера + // Курсор оставляем активным (без pointer lock) + document.body.style.cursor = 'default'; + + // Устанавливаем состояние "в интерьере" + console.log('Устанавливаем setIsInInterior(true)'); + setIsInInterior(true); + setSelectedHouse(null); + + console.log('isInInterior установлен в true'); + // Сброс кликово-путевого движения и визуальных маркеров + if (typeof currentPath !== 'undefined') currentPath = []; + if (typeof pathIndex !== 'undefined') pathIndex = 0; + if (typeof destination !== 'undefined') destination = null; + if (typeof blockedTime !== 'undefined') blockedTime = 0; + if (typeof destinationMarker !== 'undefined' && destinationMarker) destinationMarker.visible = false; + // Сброс нажатых направлений + if (moveInputRef.current) { + Object.keys(moveInputRef.current).forEach(k => { moveInputRef.current[k] = false; }); + } + + // Телепортируем игрока в интерьер (если нужно) + console.log('Вызываем teleportPlayerToInterior для интерьера:', interiorId); + await teleportPlayerToInterior(interiorId); + // Отправляем мгновенное обновление позиции перед уведомлением об интерьере + if (socketRef.current && playerRef.current) { + socketRef.current.emit('playerMovement', { x: playerRef.current.position.x, y: playerRef.current.position.y, z: playerRef.current.position.z }); + } + // Сообщаем серверу о смене интерьера, чтобы видимость игроков фильтровалась по interiorId + socketRef.current?.emit('interiorChange', { interiorId }); + console.log('teleportPlayerToInterior завершена'); } const teleportPlayerToInterior = async (interiorId) => { - console.log('teleportPlayerToInterior вызвана для интерьера:', interiorId); - console.log('playerRef.current:', playerRef.current); - const token = localStorage.getItem('token'); - if (!token) { - alert('Пожалуйста, войдите в систему, чтобы войти в здание'); - return; - } - try { - const res = await fetch(`/api/interiors/${interiorId}/enter`, { - method: 'POST', - headers: { Authorization: `Bearer ${token}` }, - credentials: 'include', - cache: 'no-cache' - }); - if (!res.ok) { - const errText = await res.text(); - console.error(`Ошибка ${res.status} при получении spawn-координат: ${errText}`); - alert(`Не удалось получить координаты интерьера: ${errText}`); - return; + console.log('teleportPlayerToInterior вызвана для интерьера:', interiorId); + console.log('playerRef.current:', playerRef.current); + const token = localStorage.getItem('token'); + if (!token) { + alert('Пожалуйста, войдите в систему, чтобы войти в здание'); + return; } - const { spawn, exit, exitInt } = await res.json(); - if (!spawn) { - alert('Для этого интерьера не заданы координаты входа'); - return; + try { + const res = await fetch(`/api/interiors/${interiorId}/enter`, { + method: 'POST', + headers: { Authorization: `Bearer ${token}` }, + credentials: 'include', + cache: 'no-cache' + }); + if (!res.ok) { + const errText = await res.text(); + console.error(`Ошибка ${res.status} при получении spawn-координат: ${errText}`); + alert(`Не удалось получить координаты интерьера: ${errText}`); + return; + } + const { spawn, exit, exitInt } = await res.json(); + if (!spawn) { + alert('Для этого интерьера не заданы координаты входа'); + return; + } + // Нормализуем типы в числа (pg для NUMERIC отдает строки) + const nSpawn = { + x: Number(spawn.x), + y: Number(spawn.y), + z: Number(spawn.z), + rot: Number(spawn.rot) || 0 + }; + const nExit = exit && typeof exit === 'object' ? { + x: Number(exit.x), + y: Number(exit.y), + z: Number(exit.z), + rot: Number(exit.rot) || 0 + } : null; + const nExitInt = exitInt && typeof exitInt === 'object' ? { + x: Number(exitInt.x), + y: Number(exitInt.y), + z: Number(exitInt.z) + } : null; + // Телепортируем игрока в интерьер + if (playerRef.current) { + console.log('[ENTER INTERIOR] spawn from server:', nSpawn); + playerRef.current.position.set(nSpawn.x, nSpawn.y, nSpawn.z); + playerRef.current.rotation.set(0, nSpawn.rot || 0, 0); + // Полный сброс движения/целей при входе + if (typeof currentPath !== 'undefined') currentPath = []; + if (typeof pathIndex !== 'undefined') pathIndex = 0; + if (typeof destination !== 'undefined') destination = null; + if (typeof blockedTime !== 'undefined') blockedTime = 0; + if (typeof destinationMarker !== 'undefined' && destinationMarker) destinationMarker.visible = false; + if (moveInputRef.current) { + Object.keys(moveInputRef.current).forEach(k => { moveInputRef.current[k] = false; }); + } + } + console.log('[ENTER INTERIOR] exit from server:', nExit); + setCurrentExit(nExit || null); + // Визуализируем маркер выхода внутри интерьера, чтобы по клику можно было выйти + if (nExit && typeof nExit.x === 'number' && typeof nExit.z === 'number') { + try { addExitMarker(nExit); } catch (e) { console.warn('[ENTER INTERIOR] addExitMarker failed', e); } + } + // Запоминаем позицию внутреннего триггера выхода, если пришла + if (nExitInt && typeof nExitInt.x === 'number') { + console.log('[ENTER INTERIOR] exitInt (internal exit trigger):', nExitInt); + interiorExitPosRef.current = new THREE.Vector3(nExitInt.x, nExitInt.y || 0, nExitInt.z); + } + console.log('teleportPlayerToInterior завершена успешно'); + } catch (e) { + console.error('Failed to enter interior:', e); } - // Нормализуем типы в числа (pg для NUMERIC отдает строки) - const nSpawn = { - x: Number(spawn.x), - y: Number(spawn.y), - z: Number(spawn.z), - rot: Number(spawn.rot) || 0 - }; - const nExit = exit && typeof exit === 'object' ? { - x: Number(exit.x), - y: Number(exit.y), - z: Number(exit.z), - rot: Number(exit.rot) || 0 - } : null; - const nExitInt = exitInt && typeof exitInt === 'object' ? { - x: Number(exitInt.x), - y: Number(exitInt.y), - z: Number(exitInt.z) - } : null; - // Телепортируем игрока в интерьер - if (playerRef.current) { - console.log('[ENTER INTERIOR] spawn from server:', nSpawn); - playerRef.current.position.set(nSpawn.x, nSpawn.y, nSpawn.z); - playerRef.current.rotation.set(0, nSpawn.rot || 0, 0); - // Полный сброс движения/целей при входе - if (typeof currentPath !== 'undefined') currentPath = []; - if (typeof pathIndex !== 'undefined') pathIndex = 0; - if (typeof destination !== 'undefined') destination = null; - if (typeof blockedTime !== 'undefined') blockedTime = 0; - if (typeof destinationMarker !== 'undefined' && destinationMarker) destinationMarker.visible = false; - if (moveInputRef.current) { - Object.keys(moveInputRef.current).forEach(k => { moveInputRef.current[k] = false; }); - } - } - console.log('[ENTER INTERIOR] exit from server:', nExit); - setCurrentExit(nExit || null); - // Визуализируем маркер выхода внутри интерьера, чтобы по клику можно было выйти - if (nExit && typeof nExit.x === 'number' && typeof nExit.z === 'number') { - try { addExitMarker(nExit); } catch (e) { console.warn('[ENTER INTERIOR] addExitMarker failed', e); } - } - // Запоминаем позицию внутреннего триггера выхода, если пришла - if (nExitInt && typeof nExitInt.x === 'number') { - console.log('[ENTER INTERIOR] exitInt (internal exit trigger):', nExitInt); - interiorExitPosRef.current = new THREE.Vector3(nExitInt.x, nExitInt.y || 0, nExitInt.z); - } - console.log('teleportPlayerToInterior завершена успешно'); - } catch (e) { - console.error('Failed to enter interior:', e); - } }; async function loadInteriorModel(interiorId) { - console.log('loadInteriorModel вызвана для интерьера:', interiorId); - const token = localStorage.getItem('token'); - - try { - const defRes = await fetch(`/api/interiors/${interiorId}/definition`, { - headers: { Authorization: `Bearer ${token}` }, - credentials: 'include', - cache: 'no-cache' - }); + console.log('loadInteriorModel вызвана для интерьера:', interiorId); + const token = localStorage.getItem('token'); - if (!defRes.ok) { - const errText = await defRes.text(); - console.error(`Ошибка ${defRes.status} при загрузке определения интерьера: ${errText}`); - return; - } + try { + const defRes = await fetch(`/api/interiors/${interiorId}/definition`, { + headers: { Authorization: `Bearer ${token}` }, + credentials: 'include', + cache: 'no-cache' + }); - const { glb, objects } = await defRes.json(); - const baseUrl = window.location.origin; - const glbUrl = baseUrl + glb; - console.log('Loading interior GLB from', glbUrl); - - // Проверяем доступность GLB файла - const headResp = await fetch(glbUrl, { method: 'HEAD', cache: 'no-cache' }); - if (!headResp.ok) { - console.error(`GLB not reachable: HTTP ${headResp.status}`); - return; - } - - const gltf = await loadGLTF(glbUrl); - const scene = sceneRef.current; - - // Создаем группу для интерьера - const intGroup = new THREE.Group(); - intGroup.name = 'interiorGroup'; - intGroup.add(gltf.scene); - - // Декуплируем и гарантируем непрозрачность материалов интерьера - gltf.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; + if (!defRes.ok) { + const errText = await defRes.text(); + console.error(`Ошибка ${defRes.status} при загрузке определения интерьера: ${errText}`); + return; } - } - }); - // Построение коллайдеров интерьера (простые коробки по мешам) - const colliders = []; - gltf.scene.traverse((child) => { - if (child.isMesh && child.geometry) { - colliders.push(child); - } - }); - interiorCollidersRef.current = colliders; + const { glb, objects } = await defRes.json(); + const baseUrl = window.location.origin; + const glbUrl = baseUrl + glb; + console.log('Loading interior GLB from', glbUrl); - // Добавляем объекты интерьера - interiorInteractablesRef.current = []; // сбрасываем реестр интерактива + // Проверяем доступность GLB файла + const headResp = await fetch(glbUrl, { method: 'HEAD', cache: 'no-cache' }); + if (!headResp.ok) { + console.error(`GLB not reachable: HTTP ${headResp.status}`); + return; + } - // Хелпер для определения ID NPC по пути к модели - const 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; - }; + const gltf = await loadGLTF(glbUrl); + const scene = sceneRef.current; - for (const o of objects) { - if (o.model_url) { - try { - const objGltf = await loadGLTF(baseUrl + o.model_url); - objGltf.scene.position.set(o.x, o.y, o.z); - objGltf.scene.rotation.set(o.rot_x, o.rot_y, o.rot_z); - objGltf.scene.scale.set(o.scale, o.scale, o.scale); - intGroup.add(objGltf.scene); + // Создаем группу для интерьера + const intGroup = new THREE.Group(); + intGroup.name = 'interiorGroup'; + intGroup.add(gltf.scene); - // Добавляем меши объекта как коллайдеры интерьера - objGltf.scene.traverse((child) => { - if (child.isMesh && child.geometry) { - colliders.push(child); + // Декуплируем и гарантируем непрозрачность материалов интерьера + gltf.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; + } } - }); + }); - // Если это NPC внутри интерьера — добавим кликабельную хит‑зону - const isNpc = (o.type === 'npc') || (typeof o.model_url === 'string' && o.model_url.includes('/models/npc/')); - if (isNpc) { - const npcId = o.id || getNpcIdFromModel(o.model_url); - console.log('[INTERIOR NPC] detected npc, id:', npcId, 'at', { x: o.x, y: o.y, z: o.z }); - 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(o.x, (o.y ?? 0) + 1.0, o.z); - hit.userData.interactable = true; - hit.userData.payload = { type: 'npc', id: npcId }; - hit.visible = true; - intGroup.add(hit); - interiorInteractablesRef.current.push(hit); + // Построение коллайдеров интерьера (простые коробки по мешам) + const colliders = []; + gltf.scene.traverse((child) => { + if (child.isMesh && child.geometry) { + colliders.push(child); + } + }); + interiorCollidersRef.current = colliders; - // Также помечаем сам корень модели как кликабельный NPC - try { - objGltf.scene.userData = objGltf.scene.userData || {}; - objGltf.scene.userData.interactable = true; - objGltf.scene.userData.payload = { type: 'npc', id: npcId }; - interiorInteractablesRef.current.push(objGltf.scene); - // и помечаем как isNpc/npcId для fallback - objGltf.scene.userData.isNpc = true; - objGltf.scene.userData.npcId = npcId; - } catch (_) {} - } - } catch (e) { - console.warn('Не удалось загрузить объект интерьера', o.model_url, e); + // Добавляем объекты интерьера + interiorInteractablesRef.current = []; // сбрасываем реестр интерактива + + // Хелпер для определения ID NPC по пути к модели + const 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; + }; + + for (const o of objects) { + if (o.model_url) { + try { + const objGltf = await loadGLTF(baseUrl + o.model_url); + objGltf.scene.position.set(o.x, o.y, o.z); + objGltf.scene.rotation.set(o.rot_x, o.rot_y, o.rot_z); + objGltf.scene.scale.set(o.scale, o.scale, o.scale); + intGroup.add(objGltf.scene); + + // Добавляем меши объекта как коллайдеры интерьера + objGltf.scene.traverse((child) => { + if (child.isMesh && child.geometry) { + colliders.push(child); + } + }); + + // Если это NPC внутри интерьера — добавим кликабельную хит‑зону + const isNpc = (o.type === 'npc') || (typeof o.model_url === 'string' && o.model_url.includes('/models/npc/')); + if (isNpc) { + const npcId = o.id || getNpcIdFromModel(o.model_url); + console.log('[INTERIOR NPC] detected npc, id:', npcId, 'at', { x: o.x, y: o.y, z: o.z }); + 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(o.x, (o.y ?? 0) + 1.0, o.z); + hit.userData.interactable = true; + hit.userData.payload = { type: 'npc', id: npcId }; + hit.visible = true; + intGroup.add(hit); + interiorInteractablesRef.current.push(hit); + + // Также помечаем сам корень модели как кликабельный NPC + try { + objGltf.scene.userData = objGltf.scene.userData || {}; + objGltf.scene.userData.interactable = true; + objGltf.scene.userData.payload = { type: 'npc', id: npcId }; + interiorInteractablesRef.current.push(objGltf.scene); + // и помечаем как isNpc/npcId для fallback + objGltf.scene.userData.isNpc = true; + objGltf.scene.userData.npcId = npcId; + } catch (_) { } + } + } catch (e) { + console.warn('Не удалось загрузить объект интерьера', o.model_url, e); + } + } else { + const mesh = baseChairMesh.clone(); + mesh.position.set(o.x, o.y, o.z); + mesh.rotation.set(o.rot_x, o.rot_y, o.rot_z); + mesh.scale.set(o.scale, o.scale, o.scale); + if (mesh.material) { + if (Array.isArray(mesh.material)) { + mesh.material = mesh.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 { + mesh.material = mesh.material.clone(); + mesh.material.transparent = false; + mesh.material.opacity = 1; + mesh.material.depthWrite = true; + mesh.material.needsUpdate = true; + } + } + intGroup.add(mesh); + // Плейсхолдер не рендерим, но используем как коллайдер + try { mesh.visible = false; } catch (_) { } + // Плейсхолдер без GLTF тоже участвует в коллизиях + colliders.push(mesh); + } + + // Если сервер пометил объект как «интерактивный/маркер» — кликабельная зона + if (o.interactable || o.marker) { + 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(o.x, o.y + 1.0, o.z); + hit.userData.interactable = true; + hit.userData.payload = { type: o.type || 'marker', id: o.id || null, label: o.label || 'Интерактив' }; + hit.visible = true; // кликабелен + try { if (hit.material) hit.material.visible = false; } catch (_) { } + intGroup.add(hit); + interiorInteractablesRef.current.push(hit); + } + // Сохраним позицию внутреннего выхода, если есть + if (typeof o.exit_int_x === 'number' && typeof o.exit_int_y === 'number' && typeof o.exit_int_z === 'number') { + interiorExitPosRef.current = new THREE.Vector3(o.exit_int_x, o.exit_int_y, o.exit_int_z); + } } - } else { - const mesh = baseChairMesh.clone(); - mesh.position.set(o.x, o.y, o.z); - mesh.rotation.set(o.rot_x, o.rot_y, o.rot_z); - mesh.scale.set(o.scale, o.scale, o.scale); - if (mesh.material) { - if (Array.isArray(mesh.material)) { - mesh.material = mesh.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 { - mesh.material = mesh.material.clone(); - mesh.material.transparent = false; - mesh.material.opacity = 1; - mesh.material.depthWrite = true; - mesh.material.needsUpdate = true; - } - } - intGroup.add(mesh); - // Плейсхолдер не рендерим, но используем как коллайдер - try { mesh.visible = false; } catch (_) {} - // Плейсхолдер без GLTF тоже участвует в коллизиях - colliders.push(mesh); - } - - // Если сервер пометил объект как «интерактивный/маркер» — кликабельная зона - if (o.interactable || o.marker) { - 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(o.x, o.y + 1.0, o.z); - hit.userData.interactable = true; - hit.userData.payload = { type: o.type || 'marker', id: o.id || null, label: o.label || 'Интерактив' }; - hit.visible = true; // кликабелен - try { if (hit.material) hit.material.visible = false; } catch (_) {} - intGroup.add(hit); - interiorInteractablesRef.current.push(hit); - } - // Сохраним позицию внутреннего выхода, если есть - if (typeof o.exit_int_x === 'number' && typeof o.exit_int_y === 'number' && typeof o.exit_int_z === 'number') { - interiorExitPosRef.current = new THREE.Vector3(o.exit_int_x, o.exit_int_y, o.exit_int_z); - } + + // Добавляем освещение для интерьера + const light = new THREE.AmbientLight(0xffffff, 1); + intGroup.add(light); + + // Добавляем группу в сцену + scene.add(intGroup); + interiorGroupRef.current = intGroup; + + console.log('Модель интерьера загружена успешно'); + } catch (e) { + console.error('Ошибка загрузки модели интерьера:', e); } - - // Добавляем освещение для интерьера - const light = new THREE.AmbientLight(0xffffff, 1); - intGroup.add(light); - - // Добавляем группу в сцену - scene.add(intGroup); - interiorGroupRef.current = intGroup; - - console.log('Модель интерьера загружена успешно'); - } catch (e) { - console.error('Ошибка загрузки модели интерьера:', e); - } } // Кэш для загруженных текстурпаков @@ -844,375 +845,375 @@ function Game({ avatarUrl, gender }) { function loadTexturePackForMesh(texturePackUrl, mesh, forceReplace = false) { - console.log('loadTexturePackForMesh вызвана:', { texturePackUrl, mesh }); - - // Проверяем, есть ли уже загруженный текстурпак в кэше - if (texturePackCache.has(texturePackUrl)) { - console.log('Используем кэшированный текстурпак:', texturePackUrl); - const cachedTextures = texturePackCache.get(texturePackUrl); - applyTexturesToMesh(mesh, cachedTextures, forceReplace, texturePackUrl); - return; - } + console.log('loadTexturePackForMesh вызвана:', { texturePackUrl, mesh }); - console.log('Загружаем текстурпак для меша:', texturePackUrl); - - // Загружаем текстурпак асинхронно - const baseUrl = window.location.origin; - const fullUrl = texturePackUrl.startsWith('http') ? texturePackUrl : baseUrl + texturePackUrl; - console.log('Полный URL для загрузки:', fullUrl); - - fetch(fullUrl) - .then(response => { - console.log('Ответ сервера для текстурпака:', response.status, response.statusText); - if (!response.ok) { - throw new Error(`HTTP ${response.status}: ${response.statusText}`); - } - console.log('Начинаем парсинг JSON...'); - return response.json(); - }) - .then(texturePack => { - console.log('Загруженный текстурпак:', texturePack); - - // Кэшируем загруженный текстурпак - texturePackCache.set(texturePackUrl, texturePack); - - // Проверяем, что меш все еще существует и валиден - if (mesh && mesh.isMesh && mesh.material) { - // Применяем текстуры к мешу (функция сама проверит типы материалов/массивы) - applyTexturesToMesh(mesh, texturePack, forceReplace, texturePackUrl); - } else { - console.warn('Меш не подходит для применения текстурпака:', { - hasMesh: !!mesh, - isMesh: mesh?.isMesh, - hasMaterial: !!mesh?.material + // Проверяем, есть ли уже загруженный текстурпак в кэше + if (texturePackCache.has(texturePackUrl)) { + console.log('Используем кэшированный текстурпак:', texturePackUrl); + const cachedTextures = texturePackCache.get(texturePackUrl); + applyTexturesToMesh(mesh, cachedTextures, forceReplace, texturePackUrl); + return; + } + + console.log('Загружаем текстурпак для меша:', texturePackUrl); + + // Загружаем текстурпак асинхронно + const baseUrl = window.location.origin; + const fullUrl = texturePackUrl.startsWith('http') ? texturePackUrl : baseUrl + texturePackUrl; + console.log('Полный URL для загрузки:', fullUrl); + + fetch(fullUrl) + .then(response => { + console.log('Ответ сервера для текстурпака:', response.status, response.statusText); + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + console.log('Начинаем парсинг JSON...'); + return response.json(); + }) + .then(texturePack => { + console.log('Загруженный текстурпак:', texturePack); + + // Кэшируем загруженный текстурпак + texturePackCache.set(texturePackUrl, texturePack); + + // Проверяем, что меш все еще существует и валиден + if (mesh && mesh.isMesh && mesh.material) { + // Применяем текстуры к мешу (функция сама проверит типы материалов/массивы) + applyTexturesToMesh(mesh, texturePack, forceReplace, texturePackUrl); + } else { + console.warn('Меш не подходит для применения текстурпака:', { + hasMesh: !!mesh, + isMesh: mesh?.isMesh, + hasMaterial: !!mesh?.material + }); + } + }) + .catch(error => { + console.error('Ошибка загрузки текстурпака:', texturePackUrl, error); + // В случае ошибки оставляем оригинальные материалы + if (mesh.material) { + mesh.material.needsUpdate = true; + } }); - } - }) - .catch(error => { - console.error('Ошибка загрузки текстурпака:', texturePackUrl, error); - // В случае ошибки оставляем оригинальные материалы - if (mesh.material) { - mesh.material.needsUpdate = true; - } - }); } // Предсоздаём материал в стиле MapEditor для citypack.json const cityPackMaterialCache = new Map(); // url -> material function getCityPackMaterial(texturePackUrl, texturePack) { - if (cityPackMaterialCache.has(texturePackUrl)) return cityPackMaterialCache.get(texturePackUrl); - const mat = new THREE.MeshStandardMaterial(); - if (typeof texturePack.baseColor === 'string') { - const loader = new THREE.TextureLoader(); - const tex = loader.load(texturePack.baseColor); - if (THREE.SRGBColorSpace) tex.colorSpace = THREE.SRGBColorSpace; - mat.map = tex; - } - mat.roughness = typeof texturePack.roughness === 'number' ? texturePack.roughness : 0.5; - mat.metalness = typeof texturePack.metalness === 'number' ? texturePack.metalness : 0.1; - cityPackMaterialCache.set(texturePackUrl, mat); - return mat; + if (cityPackMaterialCache.has(texturePackUrl)) return cityPackMaterialCache.get(texturePackUrl); + const mat = new THREE.MeshStandardMaterial(); + if (typeof texturePack.baseColor === 'string') { + const loader = new THREE.TextureLoader(); + const tex = loader.load(texturePack.baseColor); + if (THREE.SRGBColorSpace) tex.colorSpace = THREE.SRGBColorSpace; + mat.map = tex; + } + mat.roughness = typeof texturePack.roughness === 'number' ? texturePack.roughness : 0.5; + mat.metalness = typeof texturePack.metalness === 'number' ? texturePack.metalness : 0.1; + cityPackMaterialCache.set(texturePackUrl, mat); + return mat; } function applyTexturesToMesh(mesh, texturePack, forceReplace = false, texturePackUrl) { - console.log('applyTexturesToMesh вызвана:', { mesh, texturePack }); - - if (!mesh || !texturePack) { - console.warn('applyTexturesToMesh: отсутствует меш или текстурпак', { - hasMesh: !!mesh, - hasTexturePack: !!texturePack - }); - return; - } - - if (!mesh.material) { - console.warn('У меша нет материала'); - return; - } - - const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material]; - const targetMaterials = materials.filter(m => m && m.isMaterial && (m.type === 'MeshStandardMaterial' || m.type === 'MeshPhysicalMaterial' || m.type === 'MeshPhongMaterial')); - if (targetMaterials.length === 0) { - console.warn('Нет подходящих материалов для применения текстур:', mesh.material); - return; - } + console.log('applyTexturesToMesh вызвана:', { mesh, texturePack }); - // Особый режим: если это citypack.json — ведём себя как MapEditor: заменяем материал на единый стандартный - if (texturePackUrl === '/packs/citypack.json') { - const mat = getCityPackMaterial(texturePackUrl, texturePack).clone(); - if (Array.isArray(mesh.material)) { - mesh.material = mesh.material.map(() => mat.clone()); - } else { - mesh.material = mat.clone(); + if (!mesh || !texturePack) { + console.warn('applyTexturesToMesh: отсутствует меш или текстурпак', { + hasMesh: !!mesh, + hasTexturePack: !!texturePack + }); + return; } - mesh.traverse?.((child) => { - if (child.isMesh) { - child.material = Array.isArray(child.material) ? child.material.map(() => mat.clone()) : mat.clone(); - } - }); - return; - } - - // baseColor map — по умолчанию не перетираем; при forceReplace перезаписываем - if (typeof texturePack.baseColor === 'string') { - console.log('Загружаем baseColor текстуру:', texturePack.baseColor); - const textureLoader = new THREE.TextureLoader(); - textureLoader.load(texturePack.baseColor, (texture) => { - if (THREE.SRGBColorSpace) { - texture.colorSpace = THREE.SRGBColorSpace; - } - targetMaterials.forEach(mat => { - if (mat.type === 'MeshStandardMaterial' || mat.type === 'MeshPhysicalMaterial') { - if (forceReplace || !mat.map) { - mat.map = texture; - if (mat.color && mat.color.set) mat.color.set(0xffffff); - mat.needsUpdate = true; - } + + if (!mesh.material) { + console.warn('У меша нет материала'); + return; + } + + const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material]; + const targetMaterials = materials.filter(m => m && m.isMaterial && (m.type === 'MeshStandardMaterial' || m.type === 'MeshPhysicalMaterial' || m.type === 'MeshPhongMaterial')); + if (targetMaterials.length === 0) { + console.warn('Нет подходящих материалов для применения текстур:', mesh.material); + return; + } + + // Особый режим: если это citypack.json — ведём себя как MapEditor: заменяем материал на единый стандартный + if (texturePackUrl === '/packs/citypack.json') { + const mat = getCityPackMaterial(texturePackUrl, texturePack).clone(); + if (Array.isArray(mesh.material)) { + mesh.material = mesh.material.map(() => mat.clone()); + } else { + mesh.material = mat.clone(); } - }); - }, undefined, (error) => { - console.error('Ошибка загрузки baseColor текстуры:', error); - }); - } - - // normal map - if (typeof texturePack.normal === 'string') { - console.log('Загружаем normal текстуру:', texturePack.normal); - const textureLoader = new THREE.TextureLoader(); - textureLoader.load(texturePack.normal, (texture) => { - targetMaterials.forEach(mat => { - if (mat.type === 'MeshStandardMaterial' || mat.type === 'MeshPhysicalMaterial') { - if (forceReplace || !mat.normalMap) { - mat.normalMap = texture; - mat.needsUpdate = true; - } - } - }); - }, undefined, (error) => { - console.error('Ошибка загрузки normal текстуры:', error); - }); - } - - // roughness map or value - if (typeof texturePack.roughness === 'string') { - const textureLoader = new THREE.TextureLoader(); - textureLoader.load(texturePack.roughness, (texture) => { - targetMaterials.forEach(mat => { - if (mat.type === 'MeshStandardMaterial' || mat.type === 'MeshPhysicalMaterial') { - if (forceReplace || !mat.roughnessMap) { - mat.roughnessMap = texture; - mat.needsUpdate = true; - } - } - }); - }, undefined, (error) => { - console.error('Ошибка загрузки roughness текстуры:', error); - }); - } else if (typeof texturePack.roughness === 'number') { - targetMaterials.forEach(mat => { - if (mat.type === 'MeshStandardMaterial' || mat.type === 'MeshPhysicalMaterial') { - if (forceReplace || mat.roughnessMap == null) { - mat.roughness = texturePack.roughness; - mat.needsUpdate = true; - } - } - }); - } - - // metalness map or value (key metallic for map, metalness for value) - if (typeof texturePack.metallic === 'string') { - const textureLoader = new THREE.TextureLoader(); - textureLoader.load(texturePack.metallic, (texture) => { - targetMaterials.forEach(mat => { - if (mat.type === 'MeshStandardMaterial' || mat.type === 'MeshPhysicalMaterial') { - if (forceReplace || !mat.metalnessMap) { - mat.metalnessMap = texture; - mat.needsUpdate = true; - } - } - }); - }, undefined, (error) => { - console.error('Ошибка загрузки metallic текстуры:', error); - }); - } - if (typeof texturePack.metalness === 'number') { - targetMaterials.forEach(mat => { - if (mat.type === 'MeshStandardMaterial' || mat.type === 'MeshPhysicalMaterial') { - if (forceReplace || mat.metalnessMap == null) { - mat.metalness = texturePack.metalness; - mat.needsUpdate = true; - } - } - }); - } - - // ambient occlusion map - if (typeof texturePack.ao === 'string') { - const textureLoader = new THREE.TextureLoader(); - textureLoader.load(texturePack.ao, (texture) => { - targetMaterials.forEach(mat => { - if (mat.type === 'MeshStandardMaterial' || mat.type === 'MeshPhysicalMaterial') { - if (forceReplace || !mat.aoMap) { - mat.aoMap = texture; - mat.needsUpdate = true; - } - } - }); - }, undefined, (error) => { - console.error('Ошибка загрузки ao текстуры:', error); - }); - } - - // specular only for Phong - if (typeof texturePack.specular === 'string') { - const textureLoader = new THREE.TextureLoader(); - textureLoader.load(texturePack.specular, (texture) => { - targetMaterials.forEach(mat => { - if (mat.type === 'MeshPhongMaterial') { - mat.specularMap = texture; - mat.needsUpdate = true; - } - }); - }, undefined, (error) => { - console.error('Ошибка загрузки specular текстуры:', error); - }); - } + mesh.traverse?.((child) => { + if (child.isMesh) { + child.material = Array.isArray(child.material) ? child.material.map(() => mat.clone()) : mat.clone(); + } + }); + return; + } + + // baseColor map — по умолчанию не перетираем; при forceReplace перезаписываем + if (typeof texturePack.baseColor === 'string') { + console.log('Загружаем baseColor текстуру:', texturePack.baseColor); + const textureLoader = new THREE.TextureLoader(); + textureLoader.load(texturePack.baseColor, (texture) => { + if (THREE.SRGBColorSpace) { + texture.colorSpace = THREE.SRGBColorSpace; + } + targetMaterials.forEach(mat => { + if (mat.type === 'MeshStandardMaterial' || mat.type === 'MeshPhysicalMaterial') { + if (forceReplace || !mat.map) { + mat.map = texture; + if (mat.color && mat.color.set) mat.color.set(0xffffff); + mat.needsUpdate = true; + } + } + }); + }, undefined, (error) => { + console.error('Ошибка загрузки baseColor текстуры:', error); + }); + } + + // normal map + if (typeof texturePack.normal === 'string') { + console.log('Загружаем normal текстуру:', texturePack.normal); + const textureLoader = new THREE.TextureLoader(); + textureLoader.load(texturePack.normal, (texture) => { + targetMaterials.forEach(mat => { + if (mat.type === 'MeshStandardMaterial' || mat.type === 'MeshPhysicalMaterial') { + if (forceReplace || !mat.normalMap) { + mat.normalMap = texture; + mat.needsUpdate = true; + } + } + }); + }, undefined, (error) => { + console.error('Ошибка загрузки normal текстуры:', error); + }); + } + + // roughness map or value + if (typeof texturePack.roughness === 'string') { + const textureLoader = new THREE.TextureLoader(); + textureLoader.load(texturePack.roughness, (texture) => { + targetMaterials.forEach(mat => { + if (mat.type === 'MeshStandardMaterial' || mat.type === 'MeshPhysicalMaterial') { + if (forceReplace || !mat.roughnessMap) { + mat.roughnessMap = texture; + mat.needsUpdate = true; + } + } + }); + }, undefined, (error) => { + console.error('Ошибка загрузки roughness текстуры:', error); + }); + } else if (typeof texturePack.roughness === 'number') { + targetMaterials.forEach(mat => { + if (mat.type === 'MeshStandardMaterial' || mat.type === 'MeshPhysicalMaterial') { + if (forceReplace || mat.roughnessMap == null) { + mat.roughness = texturePack.roughness; + mat.needsUpdate = true; + } + } + }); + } + + // metalness map or value (key metallic for map, metalness for value) + if (typeof texturePack.metallic === 'string') { + const textureLoader = new THREE.TextureLoader(); + textureLoader.load(texturePack.metallic, (texture) => { + targetMaterials.forEach(mat => { + if (mat.type === 'MeshStandardMaterial' || mat.type === 'MeshPhysicalMaterial') { + if (forceReplace || !mat.metalnessMap) { + mat.metalnessMap = texture; + mat.needsUpdate = true; + } + } + }); + }, undefined, (error) => { + console.error('Ошибка загрузки metallic текстуры:', error); + }); + } + if (typeof texturePack.metalness === 'number') { + targetMaterials.forEach(mat => { + if (mat.type === 'MeshStandardMaterial' || mat.type === 'MeshPhysicalMaterial') { + if (forceReplace || mat.metalnessMap == null) { + mat.metalness = texturePack.metalness; + mat.needsUpdate = true; + } + } + }); + } + + // ambient occlusion map + if (typeof texturePack.ao === 'string') { + const textureLoader = new THREE.TextureLoader(); + textureLoader.load(texturePack.ao, (texture) => { + targetMaterials.forEach(mat => { + if (mat.type === 'MeshStandardMaterial' || mat.type === 'MeshPhysicalMaterial') { + if (forceReplace || !mat.aoMap) { + mat.aoMap = texture; + mat.needsUpdate = true; + } + } + }); + }, undefined, (error) => { + console.error('Ошибка загрузки ao текстуры:', error); + }); + } + + // specular only for Phong + if (typeof texturePack.specular === 'string') { + const textureLoader = new THREE.TextureLoader(); + textureLoader.load(texturePack.specular, (texture) => { + targetMaterials.forEach(mat => { + if (mat.type === 'MeshPhongMaterial') { + mat.specularMap = texture; + mat.needsUpdate = true; + } + }); + }, undefined, (error) => { + console.error('Ошибка загрузки specular текстуры:', error); + }); + } } function addExitMarker(exit) { - // Удаляем старый маркер, если был - if (window.exitMarkerMesh && sceneRef.current) { - sceneRef.current.remove(window.exitMarkerMesh); - window.exitMarkerMesh = null; - } - // Создаём маркер выхода - const marker = new THREE.Mesh( - new THREE.SphereGeometry(0.5), - new THREE.MeshBasicMaterial({ color: 0xff0000, transparent: true, opacity: 0.5 }) - ); - marker.position.set(exit.x, exit.y, exit.z); - marker.userData.isExitMarker = true; - if (sceneRef.current) sceneRef.current.add(marker); - window.exitMarkerMesh = marker; + // Удаляем старый маркер, если был + if (window.exitMarkerMesh && sceneRef.current) { + sceneRef.current.remove(window.exitMarkerMesh); + window.exitMarkerMesh = null; + } + // Создаём маркер выхода + const marker = new THREE.Mesh( + new THREE.SphereGeometry(0.5), + new THREE.MeshBasicMaterial({ color: 0xff0000, transparent: true, opacity: 0.5 }) + ); + marker.position.set(exit.x, exit.y, exit.z); + marker.userData.isExitMarker = true; + if (sceneRef.current) sceneRef.current.add(marker); + window.exitMarkerMesh = marker; } - const exitInterior = () => { - console.log('exitInterior вызвана'); - - // Телепортируем на координаты выхода из интерьера, если заданы; иначе возвращаем на сохранённую позицию - if (playerRef.current) { - const cx = currentExitRef.current; - console.log('[EXIT] currentExit before teleport:', cx); - if (cx && typeof cx.x === 'number') { - playerRef.current.position.set( - cx.x, - typeof cx.y === 'number' ? cx.y : playerRef.current.position.y, - cx.z - ); - playerRef.current.rotation.set(0, cx.rot || 0, 0); - console.log('[EXIT] Teleported to exit coords'); - // Гарантируем выход из интерьера на сервере - socketRef.current?.emit('interiorChange', { interiorId: null }); - // Включаем мир (закрытие могло скрыть город) - try { toggleWorldVisibility(true); } catch (_) {} - } else if (savedPositionRef.current) { - console.log('[EXIT] No exit coords, using savedPositionRef'); - playerRef.current.position.copy(savedPositionRef.current); + const exitInterior = () => { + console.log('exitInterior вызвана'); + + // Телепортируем на координаты выхода из интерьера, если заданы; иначе возвращаем на сохранённую позицию + if (playerRef.current) { + const cx = currentExitRef.current; + console.log('[EXIT] currentExit before teleport:', cx); + if (cx && typeof cx.x === 'number') { + playerRef.current.position.set( + cx.x, + typeof cx.y === 'number' ? cx.y : playerRef.current.position.y, + cx.z + ); + playerRef.current.rotation.set(0, cx.rot || 0, 0); + console.log('[EXIT] Teleported to exit coords'); + // Гарантируем выход из интерьера на сервере + socketRef.current?.emit('interiorChange', { interiorId: null }); + // Включаем мир (закрытие могло скрыть город) + try { toggleWorldVisibility(true); } catch (_) { } + } else if (savedPositionRef.current) { + console.log('[EXIT] No exit coords, using savedPositionRef'); + playerRef.current.position.copy(savedPositionRef.current); + } + // Сразу шлём позицию наружу + socketRef.current?.emit('playerMovement', { + x: playerRef.current.position.x, + y: playerRef.current.position.y, + z: playerRef.current.position.z + }); } - // Сразу шлём позицию наружу - socketRef.current?.emit('playerMovement', { - x: playerRef.current.position.x, - y: playerRef.current.position.y, - z: playerRef.current.position.z - }); - } - // Удаляем маркер выхода, если был - if (window.exitMarkerMesh && sceneRef.current) { - sceneRef.current.remove(window.exitMarkerMesh); - window.exitMarkerMesh = null; - } - - // Удаляем группу интерьера, если она есть - if (interiorGroupRef.current && sceneRef.current) { - sceneRef.current.remove(interiorGroupRef.current); - interiorGroupRef.current = null; - console.log('Группа интерьера удалена'); - } - - // Возвращаем третье лицо/камеру и актуализировать видимость объектов города - switchToThirdPersonCamera?.(); - // Безопасный вызов без ReferenceError, даже если функция ещё не определена - if (typeof updateCityObjectVisibility === 'function') { - updateCityObjectVisibility(); - } - // Повторно закрепляем телепорт на выход уже после очистки интерьера (на случай перезаписи позы) - if (playerRef.current) { - const cx2 = currentExitRef.current; - console.log('[EXIT AFTER CLEANUP] currentExit:', cx2); - if (cx2 && typeof cx2.x === 'number') { - playerRef.current.position.set( - cx2.x, - typeof cx2.y === 'number' ? cx2.y : playerRef.current.position.y, - cx2.z - ); - playerRef.current.rotation.set(0, cx2.rot || 0, 0); - console.log('[EXIT AFTER CLEANUP] Position applied'); + // Удаляем маркер выхода, если был + if (window.exitMarkerMesh && sceneRef.current) { + sceneRef.current.remove(window.exitMarkerMesh); + window.exitMarkerMesh = null; } - if (typeof lastPlayerPosition !== 'undefined') { - try { lastPlayerPosition = playerRef.current.position.clone(); } catch (_) {} - } - socketRef.current?.emit('playerMovement', { - x: playerRef.current.position.x, - y: playerRef.current.position.y, - z: playerRef.current.position.z - }); - } - // Полный сброс путевого движения и ввода - if (typeof currentPath !== 'undefined') currentPath = []; - if (typeof pathIndex !== 'undefined') pathIndex = 0; - if (typeof destination !== 'undefined') destination = null; - if (typeof blockedTime !== 'undefined') blockedTime = 0; - if (typeof destinationMarker !== 'undefined' && destinationMarker) destinationMarker.visible = false; - if (moveInputRef.current) { - Object.keys(moveInputRef.current).forEach(k => { moveInputRef.current[k] = false; }); - } - // Сообщаем серверу, что покинули интерьер - socketRef.current?.emit('interiorChange', { interiorId: null }); - - // Возвращаем курсор и отключаем pointer lock - document.body.style.cursor = 'default'; - document.exitPointerLock(); - setIsInInterior(false); - setCurrentExit(null); - interiorExitPosRef.current = null; + // Удаляем группу интерьера, если она есть + if (interiorGroupRef.current && sceneRef.current) { + sceneRef.current.remove(interiorGroupRef.current); + interiorGroupRef.current = null; + console.log('Группа интерьера удалена'); + } + + // Возвращаем третье лицо/камеру и актуализировать видимость объектов города + switchToThirdPersonCamera?.(); + // Безопасный вызов без ReferenceError, даже если функция ещё не определена + if (typeof updateCityObjectVisibility === 'function') { + updateCityObjectVisibility(); + } + // Повторно закрепляем телепорт на выход уже после очистки интерьера (на случай перезаписи позы) + if (playerRef.current) { + const cx2 = currentExitRef.current; + console.log('[EXIT AFTER CLEANUP] currentExit:', cx2); + if (cx2 && typeof cx2.x === 'number') { + playerRef.current.position.set( + cx2.x, + typeof cx2.y === 'number' ? cx2.y : playerRef.current.position.y, + cx2.z + ); + playerRef.current.rotation.set(0, cx2.rot || 0, 0); + console.log('[EXIT AFTER CLEANUP] Position applied'); + } + if (typeof lastPlayerPosition !== 'undefined') { + try { lastPlayerPosition = playerRef.current.position.clone(); } catch (_) { } + } + socketRef.current?.emit('playerMovement', { + x: playerRef.current.position.x, + y: playerRef.current.position.y, + z: playerRef.current.position.z + }); + } + // Полный сброс путевого движения и ввода + if (typeof currentPath !== 'undefined') currentPath = []; + if (typeof pathIndex !== 'undefined') pathIndex = 0; + if (typeof destination !== 'undefined') destination = null; + if (typeof blockedTime !== 'undefined') blockedTime = 0; + if (typeof destinationMarker !== 'undefined' && destinationMarker) destinationMarker.visible = false; + if (moveInputRef.current) { + Object.keys(moveInputRef.current).forEach(k => { moveInputRef.current[k] = false; }); + } + // Сообщаем серверу, что покинули интерьер + socketRef.current?.emit('interiorChange', { interiorId: null }); + + // Возвращаем курсор и отключаем pointer lock + document.body.style.cursor = 'default'; + document.exitPointerLock(); + + setIsInInterior(false); + setCurrentExit(null); + interiorExitPosRef.current = null; }; // В useEffect для кликов по сцене: useEffect(() => { - function onDocumentClick(event) { - if (!rendererRef.current || !cameraRef.current) return; - const rect = rendererRef.current.domElement.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, cameraRef.current); - const intersects = raycaster.intersectObjects(sceneRef.current.children, true); - for (let i = 0; i < intersects.length; i++) { - const obj = intersects[i].object; - if (obj.userData.isExitMarker) { - exitInterior(); - break; - } + function onDocumentClick(event) { + if (!rendererRef.current || !cameraRef.current) return; + const rect = rendererRef.current.domElement.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, cameraRef.current); + const intersects = raycaster.intersectObjects(sceneRef.current.children, true); + for (let i = 0; i < intersects.length; i++) { + const obj = intersects[i].object; + if (obj.userData.isExitMarker) { + exitInterior(); + break; + } + } } - } - window.addEventListener('mousedown', onDocumentClick); - return () => window.removeEventListener('mousedown', onDocumentClick); + window.addEventListener('mousedown', onDocumentClick); + return () => window.removeEventListener('mousedown', onDocumentClick); }, [currentExit]); /*const handleAnswerSelect = (answer) => { @@ -1273,7 +1274,6 @@ function Game({ avatarUrl, gender }) { headers: { Authorization: `Bearer ${token}` } }); if (res.ok) { - console.log("Попытка не удалась"); const data = await res.json(); setQuestsProgress(data); } else { @@ -1337,27 +1337,27 @@ function Game({ avatarUrl, gender }) { setIframeUrl(''); }; - async function loadTelegramContacts() { - const token = localStorage.getItem('token'); - try { - setTgError(null); - const res = await fetch('/api/users', { - headers: { Authorization: `Bearer ${token}` }, - credentials: 'include', - cache: 'no-cache' - }); - if (res.ok) { - const data = await res.json(); - setTelegramContacts(data); - } else { - const txt = await res.text().catch(()=> ''); - console.error('Ошибка загрузки контактов Telegram', res.status, txt); - setTgError('Не удалось загрузить контакты'); - } - } catch (err) { - console.error('Ошибка сети:', err); - setTgError('Проблема сети'); - } + async function loadTelegramContacts() { + const token = localStorage.getItem('token'); + try { + setTgError(null); + const res = await fetch('/api/users', { + headers: { Authorization: `Bearer ${token}` }, + credentials: 'include', + cache: 'no-cache' + }); + if (res.ok) { + const data = await res.json(); + setTelegramContacts(data); + } else { + const txt = await res.text().catch(() => ''); + console.error('Ошибка загрузки контактов Telegram', res.status, txt); + setTgError('Не удалось загрузить контакты'); + } + } catch (err) { + console.error('Ошибка сети:', err); + setTgError('Проблема сети'); + } } // Дополняем состояния @@ -1468,1408 +1468,616 @@ function Game({ avatarUrl, gender }) { setUserProfile(profile); }, []); - //Телефон конец + //Телефон конец - async function viewStats() { - if (!selectedPlayer) return; - const token = localStorage.getItem('token'); - const res = await fetch(`/api/players/${selectedPlayer.socketId}`, { - headers: { Authorization: `Bearer ${token}` } - }); - if (!res.ok) { - console.error('Ошибка при загрузке статистики'); - return; - } - const data = await res.json(); - setPlayerStats(data); - } - - async function toggleMicrophone() { - try { - if (!micEnabled) { - localStream.current = await navigator.mediaDevices.getUserMedia({ audio: true }); - setMicEnabled(true); - socketRef.current?.emit('voiceChatToggle', { enabled: true }); - - const track = localStream.current.getAudioTracks()[0]; - Object.values(voiceConnections.current).forEach(conn => { - if (conn.audioSender && track) { - conn.audioSender.replaceTrack(track); - } + async function viewStats() { + if (!selectedPlayer) return; + const token = localStorage.getItem('token'); + const res = await fetch(`/api/players/${selectedPlayer.socketId}`, { + headers: { Authorization: `Bearer ${token}` } }); - } else { - if (localStream.current) { - localStream.current.getTracks().forEach(track => track.stop()); + if (!res.ok) { + console.error('Ошибка при загрузке статистики'); + return; } - Object.values(voiceConnections.current).forEach(conn => { - if (conn.audioSender) { - conn.audioSender.replaceTrack(null); - } - }); - localStream.current = null; - setMicEnabled(false); - socketRef.current?.emit('voiceChatToggle', { enabled: false }); - } - } catch (err) { - console.error('Ошибка доступа к микрофону:', err); - } - } - - async function onObjectClick(mesh) { - const objectId = mesh.userData.id; // <-- USER DATA ID из city_objects - const token = localStorage.getItem('token'); - - try { - const resp = await fetch( - `/api/city_objects/${objectId}/interior`, // <-- обязательно "/interior" - { - headers: { Authorization: `Bearer ${token}` }, - credentials: 'include', - cache: 'no-cache' - } - ); - if (!resp.ok) { - console.warn(`Для объекта ${objectId} не задан interior_id (status ${resp.status})`); - return; - } - const { interiorId } = await resp.json(); - if (!interiorId) return; - - console.log(`Переходим в интерьер ${interiorId} из объекта ${objectId}`); - movePlayerToInterior(interiorId); - } catch (err) { - console.error(`Ошибка при запросе interior_id для объекта ${objectId}:`, err); - } -} - - - async function openOrganizationMenu(orgId) { - const token = localStorage.getItem('token'); - try { - const orgRes = await fetch(`/api/organizations/${orgId}`, { - headers: { Authorization: `Bearer ${token}` } - }); - - let name = 'Организация'; - if (orgRes.ok) { - const org = await orgRes.json(); - name = org.name; - } - - const setRes = await fetch(`/api/organizations/${orgId}/settings`, { - headers: { Authorization: `Bearer ${token}` } - }); - const settings = setRes.ok ? await setRes.json() : { menu: [] }; - - // сервер уже отдаёт menu как массив - const menuArray = Array.isArray(settings.menu) ? settings.menu : []; - - setOrgMenu({ id: orgId, name, menu: menuArray }); - setSelectedHouse(null); - } catch (e) { - console.error('Не удалось загрузить меню организации', orgId, e); - alert('Ошибка загрузки меню организации'); - } - } - - - function openOrganizationPanel(orgId) { - setOrgPanelId(orgId); - setOrgMenu(null); - setSelectedHouse(null); - } - - -async function movePlayerToInterior(interiorId) { - await enterInteriorMode(interiorId); -} - -function switchToFirstPersonCamera() { - console.log('switchToFirstPersonCamera вызвана'); - console.log('isInInteriorRef.current:', isInInteriorRef.current); - - if (fpCamRef.current) { - cameraRef.current = fpCamRef.current; - console.log('Камера переключена на fpCamRef'); - } - if (playerRef.current) { - // Скрываем полностью собственную модель в режиме FPV - playerRef.current.visible = false; - // На всякий случай также скрываем голову/шею (если модель будет вновь показана без выхода из режима) - const hidden = []; - playerRef.current.traverse((child) => { - if (!child.isMesh) return; - const name = (child.name || '').toLowerCase(); - if (name.includes('head') || name.includes('neck') || name.includes('helmet') || name.includes('hair')) { - child.visible = false; - hidden.push(child); - } - }); - fpHiddenNodesRef.current = hidden; - console.log('Скрыты узлы для FPV:', hidden.map(n => n.name)); - } - fpPitchRef.current = 0; - - // Настраиваем камеру от первого лица для интерьера - if (isInInteriorRef.current) { - console.log('Настраиваем камеру для интерьера'); - // Устанавливаем позицию камеры на уровне глаз игрока - const headHeight = 1.6; - fpCamRef.current.position.set( - playerRef.current.position.x, - playerRef.current.position.y + headHeight, - playerRef.current.position.z - ); - // Не большой сдвиг камеры вперёд, чтобы не упираться в скрытую голову - const forward = new THREE.Vector3(0, 0, -0.08).applyEuler(new THREE.Euler(0, playerRef.current.rotation.y, 0)); - fpCamRef.current.position.add(forward); - - // Направляем камеру в том же направлении, что и игрок - const direction = new THREE.Vector3(0, 0, -1); - direction.applyEuler(new THREE.Euler(0, playerRef.current.rotation.y, 0)); - fpCamRef.current.lookAt( - fpCamRef.current.position.clone().add(direction) - ); - console.log('Камера настроена для интерьера'); - } -} - -function switchToThirdPersonCamera() { - console.log('switchToThirdPersonCamera вызвана'); - if (orthoCamRef.current) { - cameraRef.current = orthoCamRef.current; - console.log('Камера переключена на orthoCamRef'); - } - if (playerRef.current) { - playerRef.current.visible = true; - // Вернуть видимость скрытых для FPV узлов - if (Array.isArray(fpHiddenNodesRef.current)) { - fpHiddenNodesRef.current.forEach(n => { n.visible = true; }); - fpHiddenNodesRef.current = []; - } - console.log('Игрок показан'); - } - fpPitchRef.current = 0; -} - -function startMove(dir) { - moveInputRef.current[dir] = true; -} - -function stopMove(dir) { - moveInputRef.current[dir] = false; -} - - -// ───────────────────────────────────────────────────── -// КЛИКИ ВНУТРИ ИНТЕРЬЕРА (интерактивные маркеры/NPC) -// ───────────────────────────────────────────────────── -useEffect(() => { - const onClick = (e) => { - console.log('[INTERIOR CLICK] handler start; isInInterior:', isInInteriorRef.current); - if (!isInInteriorRef.current) return; - const mount = mountRef.current; - if (!mount || !cameraRef.current) return; - - // координаты мыши в NDC - // Пытаемся получить координаты из элемента рендера (FP вид) - const canvas = rendererRef.current && rendererRef.current.domElement; - const rect = (canvas || mount).getBoundingClientRect(); - const mouse = new THREE.Vector2( - ((e.clientX - rect.left) / rect.width) * 2 - 1, - -((e.clientY - rect.top) / rect.height) * 2 + 1 - ); - const raycaster = new THREE.Raycaster(); - raycaster.setFromCamera(mouse, cameraRef.current); - // Ищем пересечения по интерактивам (включая NPC) - const objects = interiorInteractablesRef.current.filter(obj => obj?.isObject3D); - // Добавим в список интерактивов саму группу интерьера, чтобы traverse детектил payload у вложенных узлов - const extraTargets = []; - if (interiorGroupRef.current) extraTargets.push(interiorGroupRef.current); - const rayHits = raycaster.intersectObjects(objects.concat(extraTargets), true); - console.log('[INTERIOR CLICK] rayHits count:', rayHits.length); - const hits = rayHits.filter(h => { - const obj = h.object; - // учитываем payload на мешах и на родителях - if (obj && obj.userData && (obj.userData.interactable || obj.userData.payload || obj.userData.isNpc)) return true; - let p = obj; - while (p && p.parent) { - p = p.parent; - if (p.userData && (p.userData.interactable || p.userData.payload || p.userData.isNpc)) return true; - } - return false; - }); - console.log('[INTERIOR CLICK] interactable hits count:', hits.length); - if (hits.length) { - const top = hits[0].object; - // поднимаем до узла, где лежит payload - let node = top; - while (node && !node.userData?.payload && node.parent) node = node.parent; - let payload = (node && node.userData && node.userData.payload) || (top.userData.payload) || {}; - // Если у попавшего меша нет payload, но это часть NPC, поднимемся до isNpc - if ((!payload || !payload.type) && node) { - let p = node; - while (p && !p.userData?.isNpc && p.parent) p = p.parent; - if (p && p.userData?.npcId) { - payload = { type: 'npc', id: p.userData.npcId }; - } - } - console.log('[INTERIOR CLICK] payload:', payload); - if (payload.type === 'marker') { - console.log('Нажат маркер:', payload); - } else if (payload.type === 'npc') { - console.log('Нажат NPC:', payload); - try { if (payload.id) { loadDialog(payload.id); } } catch (_) {} - } else { - console.log('Интерактив:', payload); - } - return; + const data = await res.json(); + setPlayerStats(data); } - // Если своих интерактивов не нашли, пробуем поймать NPC из общего массива npcMeshes - try { - const npcHit = raycaster.intersectObjects(npcMeshesRef.current || [], true); - console.log('[INTERIOR CLICK] npcMeshes hits:', npcHit.length); - if (npcHit.length) { - let root = npcHit[0].object; - while (root.parent && !root.userData?.isNpc) root = root.parent; - if (root.userData && root.userData.npcId) { - console.log('[INTERIOR CLICK] NPC root found:', root.userData.npcId); - if (root.userData.npcId === 'Computer') { - setShowMiniGame(true); - setPasswordCorrect(false); - setAudioUrl('/audio/firs.ogg'); - addSeregaComment('Ну чё, хакер, разберёшься?'); - } else { - loadDialog(root.userData.npcId); - } - return; - } - } - } catch (e) { - console.warn('[INTERIOR CLICK] npcMeshes raycast failed:', e); - } - }; + async function toggleMicrophone() { + try { + if (!micEnabled) { + localStream.current = await navigator.mediaDevices.getUserMedia({ audio: true }); + setMicEnabled(true); + socketRef.current?.emit('voiceChatToggle', { enabled: true }); - const target = rendererRef.current ? rendererRef.current.domElement : window; - target.addEventListener('click', onClick); - target.addEventListener('pointerdown', onClick); - return () => { target.removeEventListener('click', onClick); target.removeEventListener('pointerdown', onClick); }; - }, []); - - async function buyItem(key) { - if (!orgMenu) return; - const token = localStorage.getItem('token'); - const res = await fetch(`/api/organizations/${orgMenu.id}/purchase`, { - method: 'POST', - headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, - body: JSON.stringify({ itemKey: key }) - }); - if (res.ok) { - const data = await res.json(); - setSatiety(data.satiety); - setThirst(data.thirst); - setBalance(data.balance); - const profile = JSON.parse(sessionStorage.getItem('user_profile') || '{}'); - profile.satiety = data.satiety; - profile.thirst = data.thirst; - profile.balance = data.balance; - sessionStorage.setItem('user_profile', JSON.stringify(profile)); - socketRef.current.emit('economy:getInventory', { userId: profile.id }); - } - } - - function handleItemAction(item) { - const act = window.prompt('1 - использовать, 2 - выкинуть'); - const prof = JSON.parse(sessionStorage.getItem('user_profile') || '{}'); - if (act === '1') { - if (item.name.toLowerCase().includes('вода')) { - setThirst(t => Math.min(100, t + 20)); - } else { - setSatiety(s => Math.min(100, s + 20)); - } - socketRef.current.emit('economy:removeItem', { userId: prof.id, itemId: item.item_id, quantity: 1 }); - } else if (act === '2') { - socketRef.current.emit('economy:removeItem', { userId: prof.id, itemId: item.item_id, quantity: 1 }); - } - socketRef.current.emit('economy:getInventory', { userId: prof.id }); - } - function toggleWorldVisibility(visible) { - groundRef.current && (groundRef.current.visible = visible); - cityMeshesRef.current.forEach(m => m.visible = visible); - } - - function createInterior() { - const group = new THREE.Group(); - const floorMat = new THREE.MeshStandardMaterial({ color: 0x808080 }); - const floor = new THREE.Mesh(new THREE.PlaneGeometry(20, 20), floorMat); - floor.rotation.x = -Math.PI / 2; - group.add(floor); - - const wallMat = new THREE.MeshStandardMaterial({ color: 0x999999 }); - const wallGeo = new THREE.PlaneGeometry(20, 10); - const back = new THREE.Mesh(wallGeo, wallMat); - back.position.set(0, 5, -10); - group.add(back); - const front = back.clone(); - front.position.set(0, 5, 10); - front.rotation.y = Math.PI; - group.add(front); - const left = back.clone(); - left.position.set(-10, 5, 0); - left.rotation.y = Math.PI / 2; - group.add(left); - const right = back.clone(); - right.position.set(10, 5, 0); - right.rotation.y = -Math.PI / 2; - group.add(right); - - const light = new THREE.PointLight(0xffffff, 1); - light.position.set(0, 5, 0); - group.add(light); - - return group; - } - - function enterHouse(house) { - if (!house || !sceneRef.current || !playerRef.current) return; - const id = parseInt(house.id, 10); - if (id === 9) { - savedPositionRef.current.copy(playerRef.current.position); - toggleWorldVisibility(false); - interiorGroupRef.current = createInterior(); - sceneRef.current.add(interiorGroupRef.current); - playerRef.current.position.set(0, 0, 0); - playerRef.current.quaternion.identity(); - setSelectedHouse(null); - switchToFirstPersonCamera(); - setIsInInterior(true); - } - } - - useEffect(() => { - console.log('[DEBUG] useEffect вызван'); - const mount = mountRef.current; - if (!mount) { - console.log('[DEBUG] mountRef.current не определён!'); - return; - } - - // ───────────────────────────────────────────── - // Красивый загрузочный оверлей + LoadingManager - // ───────────────────────────────────────────── - let overlayEl = null, barEl = null, textEl = null; - function createLoadingOverlay() { - if (overlayEl) return; - overlayEl = document.createElement('div'); - Object.assign(overlayEl.style, { - position: 'fixed', inset: '0', zIndex: 2000, - display: 'flex', flexDirection: 'column', - alignItems: 'center', justifyContent: 'center', - background: 'linear-gradient(135deg,#0f172a,#1e293b)', - color: '#fff', fontFamily: 'system-ui, Arial, sans-serif' - }); - textEl = document.createElement('div'); - Object.assign(textEl.style, { - fontSize: '24px', fontWeight: 700, opacity: 0.9, marginBottom: '16px' - }); - textEl.textContent = 'Загрузка ресурсов...'; - overlayEl.appendChild(textEl); - const barWrap = document.createElement('div'); - Object.assign(barWrap.style, { - width: '320px', height: '10px', - background: 'rgba(255,255,255,0.15)', - borderRadius: '999px', overflow: 'hidden', - boxShadow: '0 6px 20px rgba(0,0,0,0.35)' - }); - barEl = document.createElement('div'); - Object.assign(barEl.style, { - width: '0%', height: '100%', - transition: 'width .15s ease', - background: 'linear-gradient(90deg,#22d3ee,#38bdf8,#60a5fa)' - }); - barWrap.appendChild(barEl); - overlayEl.appendChild(barWrap); - const pct = document.createElement('div'); - Object.assign(pct.style, { marginTop: '12px', fontSize: '14px', opacity: 0.8 }); - pct.id = 'loadingPct'; - pct.textContent = '0%'; - overlayEl.appendChild(pct); - document.body.appendChild(overlayEl); - } - function updateLoadingOverlay(percent, text) { - if (!overlayEl) return; - const p = Math.max(0, Math.min(100, Math.round(percent || 0))); - if (barEl) barEl.style.width = p + '%'; - const pct = overlayEl.querySelector('#loadingPct'); - if (pct) pct.textContent = p + '%'; - if (text && textEl) textEl.textContent = text; - } - function removeLoadingOverlay() { - if (!overlayEl) return; - overlayEl.style.transition = 'opacity .2s ease'; - overlayEl.style.opacity = '0'; - setTimeout(() => { - overlayEl && overlayEl.remove(); - overlayEl = barEl = textEl = null; - }, 220); - } - // Общий менеджер загрузки (для GLTF/Texture и т.п.) - const loadingManager = new THREE.LoadingManager(); - loadingManagerRef.current = loadingManager; - loadingManager.onStart = (_url, loaded, total) => { - createLoadingOverlay(); - updateLoadingOverlay(total ? (loaded / total) * 100 : 5, 'Загрузка ресурсов...'); - }; - loadingManager.onProgress = (_url, loaded, total) => { - updateLoadingOverlay(total ? (loaded / total) * 100 : 50); - }; - loadingManager.onLoad = () => { - updateLoadingOverlay(100, 'Инициализация сцены...'); - setTimeout(removeLoadingOverlay, 150); - }; - - - console.log('–– useEffect начало'); - - const baseOffset = new THREE.Vector3(-200, 150, -200); - const planarDist = Math.hypot(baseOffset.x, baseOffset.z); - const radius = Math.hypot(planarDist, baseOffset.y); - const baseAzimuth = Math.atan2(baseOffset.z, baseOffset.x); - const basePolar = Math.atan2(baseOffset.y, planarDist); - - let cameraPitchOffset = 0; - const maxPitch = THREE.MathUtils.degToRad(10); - - let zoom = 10; - const minZoom = zoom * 0.1; - const maxZoom = zoom * 3.5; - - let orthoCamera, fpCamera; - let player, mixer; - let idleAction, walkAction, currentAction; - let remotePlayers = remotePlayersRef.current; - let obstacles = []; - let destination = null; - let blockedTime = 0; - const moveSpeed = 2.5; - const WALK_ANIM_SPEED_MPS = 2; - let clock; - try { - clock = new THREE.Clock(); - } catch (error) { - console.error('Ошибка создания THREE.Clock:', error); - return; - } - const keys = {}; - let npcMeshes = []; - const territorySize = 500; - const boundary = territorySize / 2; - const gridSize = 300; - const nodeSize = territorySize / gridSize; - - let pathfinderGrid; - let currentPath = []; - let pathIndex = 0; - let groundPlane; - let destinationMarker; - let customMaterial; - - const token = localStorage.getItem('token'); - // Подключаемся к локальному серверу - const serverUrl = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1' - ? 'http://localhost:4000' - : window.location.origin; - - socketRef.current = io(serverUrl, { - transports: ['websocket','polling'], - auth: { token }, - timeout: 20000 // Увеличиваем timeout до 20 секунд - }); - const socket = socketRef.current; - - console.log('socket инстанс:', socket); - console.log('Подключение к серверу:', serverUrl); - - socket.on('connect', () => { - console.log('✔ Socket connected, id=', socket.id); - console.log('Подключение успешно установлено'); - }); - - socket.on('connect_error', err => { - console.error('Socket connect_error:', err); - console.error('Ошибка подключения к серверу:', serverUrl); - console.error('Проверьте, что сервер запущен на порту 4000'); - }); - - socket.on('disconnect', reason => { - console.warn('Socket disconnected:', reason); - console.warn('Соединение разорвано, причина:', reason); - }); - const profile = JSON.parse(sessionStorage.getItem('user_profile') || '{}'); - if (profile?.id) { - socket.emit('economy:getBalance', { userId: profile.id }); - } - const balanceInterval = setInterval(() => { - const p = JSON.parse(sessionStorage.getItem('user_profile') || '{}'); - if (p?.id) socket.emit('economy:getBalance', { userId: p.id }); - }, 3000); - socket.on('economy:balanceChanged', ({ userId, newBalance }) => { - if (userId === profile.id) { - setBalance(newBalance); - const upd = { ...(profile || {}), balance: newBalance }; - sessionStorage.setItem('user_profile', JSON.stringify(upd)); - } - }); - socket.emit('economy:getInventory', { userId: profile.id }); - socket.on('economy:inventory', setInventory); - socket.on('gameTime:update', ({ time }) => setGameTime(time)); - // Лоадеры, учитывающиеся в прогрессе через loadingManagerRef - const gltfLoader = new GLTFLoader(loadingManagerRef.current || undefined); - const animLoader = new GLTFLoader(loadingManagerRef.current || undefined); - - async function loadPlayerModel(avatarUrl) { - return new Promise((resolve, reject) => { - console.log('GLTFLoader загружает:', avatarUrl); - - // Проверяем, что URL начинается с правильного пути - if (!avatarUrl.startsWith('/') && !avatarUrl.startsWith('http')) { - console.error('Неправильный формат URL:', avatarUrl); - reject(new Error('Неправильный формат URL')); - return; - } - - gltfLoader.load( - avatarUrl, - (gltf) => { - console.log('GLTF загружен успешно:', gltf); - if (!gltf.scene) { - console.error('GLTF.scene отсутствует в загруженном файле'); - return reject('GLTF.scene отсутствует'); - } - resolve(gltf); - }, - (progress) => { - console.log('Прогресс загрузки:', progress); - }, - (err) => { - console.error('Ошибка загрузки GLTF:', err); - reject(err); - } - ); - }); - } - - async function addOtherPlayer(id, x, z, avatarURL, genderRemote = 'male', firstName = '', lastName = '', y = 0) { - if (remotePlayers[id]) { - // Уже есть — не пересоздаём - return; - } - let model; - try { - if (!avatarURL) throw new Error('no avatarURL'); - const gltf = await loadPlayerModel(avatarURL); - model = gltf.scene; - - // Проверяем и исправляем материалы модели - model.traverse((child) => { - if (child.isMesh && child.material) { - if (Array.isArray(child.material)) { - child.material.forEach(mat => { - if (!mat || !mat.isMaterial) { - console.warn(`Неправильный материал в аватаре ${id}, заменяем на стандартный`); - if (THREE.MeshStandardMaterial) { - child.material = new THREE.MeshStandardMaterial({ color: 0x808080 }); - } else { - console.error('THREE.MeshStandardMaterial не доступен для замены материала'); - } - } - }); - } else if (!child.material.isMaterial) { - console.warn(`Неправильный материал в аватаре ${id}, заменяем на стандартный`); - child.material = new THREE.MeshStandardMaterial({ color: 0x808080 }); + const track = localStream.current.getAudioTracks()[0]; + Object.values(voiceConnections.current).forEach(conn => { + if (conn.audioSender && track) { + conn.audioSender.replaceTrack(track); + } + }); + } else { + if (localStream.current) { + localStream.current.getTracks().forEach(track => track.stop()); } + Object.values(voiceConnections.current).forEach(conn => { + if (conn.audioSender) { + conn.audioSender.replaceTrack(null); + } + }); + localStream.current = null; + setMicEnabled(false); + socketRef.current?.emit('voiceChatToggle', { enabled: false }); } - }); - } catch (e) { - console.warn(`Не удалось загрузить аватар ${id}, рисуем сферу`, e); - model = new THREE.Mesh( - new THREE.SphereGeometry(1), - new THREE.MeshBasicMaterial({ color: 0x888888 }) - ); - } - model.scale.set(1, 1, 1); - model.position.set(x, y || 0, z); - scene.add(model); - - const fullname = `${firstName} ${lastName}`.trim(); - if (fullname) { - const label = createPlayerLabel(fullname); - label.position.set(0, 2.2, 0); - model.add(label); - } - - // Add voice chat icon (initially hidden) - const voiceIcon = createVoiceIcon(); - voiceIcon.position.set(0, 2.7, 0); - voiceIcon.visible = false; - model.add(voiceIcon); - voiceIcons.current[id] = voiceIcon; - - const mixerRemote = new THREE.AnimationMixer(model); - - const isFemale = genderRemote === 'female'; - const animGender = isFemale ? 'feminine' : 'masculine'; - - const idleFile = isFemale ? 'F_Standing_Idle_001.glb' : 'M_Standing_Idle_001.glb'; - const walkFile = isFemale ? 'F_Walk_002.glb' : 'M_Walk_001.glb'; - - const idlePath = `/animations/${animGender}/glb/idle/${idleFile}`; - const walkPath = `/animations/${animGender}/glb/locomotion/${walkFile}`; - - const [idleGltf, walkGltf] = await Promise.all([ - animLoader.loadAsync(idlePath), - animLoader.loadAsync(walkPath) - ]); - - idleGltf.animations.forEach(stripPositionTracks); - walkGltf.animations.forEach(stripPositionTracks); - - const remoteIdleAction = mixerRemote.clipAction(idleGltf.animations[0], model); - const remoteWalkAction = mixerRemote.clipAction(walkGltf.animations[0], model); - - remoteIdleAction.play(); - - remotePlayers[id] = { - model, - mixer: mixerRemote, - idleAction: remoteIdleAction, - walkAction: remoteWalkAction, - currentAction: remoteIdleAction, - firstName, - lastName, - gender: genderRemote, - avatarURL, - _idleTimeout: null - }; - - // Синхронизируем анимацию ходьбы с скоростью перемещения - remotePlayers[id].walkAction.setEffectiveTimeScale(moveSpeed / WALK_ANIM_SPEED_MPS); - } - - function createVoiceIcon() { - const canvas = document.createElement('canvas'); - canvas.width = 64; - canvas.height = 64; - const ctx = canvas.getContext('2d'); - - ctx.fillStyle = '#00ff00'; - ctx.beginPath(); - ctx.arc(32, 32, 20, 0, 2 * Math.PI); - ctx.fill(); - ctx.fillStyle = '#000'; - ctx.font = '24px Arial'; - ctx.textAlign = 'center'; - ctx.textBaseline = 'middle'; - ctx.fillText('🎤', 32, 32); - - const texture = new THREE.CanvasTexture(canvas); - - texture.generateMipmaps = false; - texture.minFilter = THREE.LinearFilter; - texture.magFilter = THREE.LinearFilter; - texture.anisotropy = 1; - - texture.needsUpdate = true; - - const spriteMaterial = new THREE.SpriteMaterial({ - map: texture, - transparent: true, - depthTest: false, // рисуем поверх геометрии - depthWrite: false, - toneMapped: false, // чтобы белый не «теплился» тон-меппингом - sizeAttenuation: false - }); - const sprite = new THREE.Sprite(spriteMaterial); - sprite.scale.set(0.5, 0.5, 1); - - // ↓↓↓ добавь это ↓↓↓ - sprite.raycast = () => {}; - sprite.userData.isUiSprite = true; - - return sprite; - } - - async function initiateVoiceChat(peerId) { - if (voiceConnections.current[peerId]) return; - - const peerConnection = new RTCPeerConnection({ - iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] - }); - - - voiceConnections.current[peerId] = { - peerConnection, - audioElement: document.createElement('audio'), - pendingCandidates: [], - audioSender: null - }; - - voiceConnections.current[peerId].audioElement.autoplay = true; - document.body.appendChild(voiceConnections.current[peerId].audioElement); - - peerConnection.ontrack = (event) => { - voiceConnections.current[peerId].audioElement.srcObject = event.streams[0]; - }; - - // В функции initiateVoiceChat, перед peerConnection.onicecandidate, добавьте (18.05.2025): - voiceConnections.current[peerId].pendingCandidates = []; - - peerConnection.onicecandidate = (event) => { - if (event.candidate) { - socket.emit('voiceChatIceCandidate', { - to: peerId, - candidate: event.candidate - }); + } catch (err) { + console.error('Ошибка доступа к микрофону:', err); } - }; - - peerConnection.onconnectionstatechange = () => { - if (peerConnection.connectionState === 'disconnected' || peerConnection.connectionState === 'failed') { - cleanupVoiceConnection(peerId); - } - }; - - try { - const offer = await peerConnection.createOffer(); - await peerConnection.setLocalDescription(offer); - socket.emit('voiceChatOffer', { to: peerId, offer }); - } catch (err) { - console.error('Ошибка создания WebRTC предложения:', err); - } } - - function cleanupVoiceConnection(peerId) { - if (voiceConnections.current[peerId]) { - const conn = voiceConnections.current[peerId]; + + async function onObjectClick(mesh) { + const objectId = mesh.userData.id; // <-- USER DATA ID из city_objects + const token = localStorage.getItem('token'); + try { - conn.audioSender?.replaceTrack(null); - } catch {} - conn.peerConnection.close(); - conn.audioElement.remove(); - delete voiceConnections.current[peerId]; - } + const resp = await fetch( + `/api/city_objects/${objectId}/interior`, // <-- обязательно "/interior" + { + headers: { Authorization: `Bearer ${token}` }, + credentials: 'include', + cache: 'no-cache' + } + ); + if (!resp.ok) { + console.warn(`Для объекта ${objectId} не задан interior_id (status ${resp.status})`); + return; + } + const { interiorId } = await resp.json(); + if (!interiorId) return; + + console.log(`Переходим в интерьер ${interiorId} из объекта ${objectId}`); + movePlayerToInterior(interiorId); + } catch (err) { + console.error(`Ошибка при запросе interior_id для объекта ${objectId}:`, err); + } } - socket.on('voiceChatNearby', ({ playerId }) => { - if (remotePlayers[playerId] && !voiceConnections.current[playerId]) { - if (socket.id < playerId) { - initiateVoiceChat(playerId); - } - } - }); - socket.on('voiceChatOffer', async ({ from, offer }) => { - if (!voiceConnections.current[from]) { - const peerConnection = new RTCPeerConnection({ - iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] - }); - - voiceConnections.current[from] = { - peerConnection, - audioElement: document.createElement('audio'), - pendingCandidates: [], - audioSender: null - }; - - voiceConnections.current[from].audioElement.autoplay = true; - document.body.appendChild(voiceConnections.current[from].audioElement); - - peerConnection.ontrack = (event) => { - voiceConnections.current[from].audioElement.srcObject = event.streams[0]; - }; - - peerConnection.onicecandidate = (event) => { - if (event.candidate) { - socket.emit('voiceChatIceCandidate', { - to: from, - candidate: event.candidate + async function openOrganizationMenu(orgId) { + const token = localStorage.getItem('token'); + try { + const orgRes = await fetch(`/api/organizations/${orgId}`, { + headers: { Authorization: `Bearer ${token}` } }); - } - }; - peerConnection.onconnectionstatechange = () => { - if (peerConnection.connectionState === 'disconnected' || peerConnection.connectionState === 'failed') { - cleanupVoiceConnection(from); - } - }; - - try { - await peerConnection.setRemoteDescription(new RTCSessionDescription(offer)); - const remoteTransceiver = peerConnection.getTransceivers().find( - t => t.receiver && t.receiver.track && t.receiver.track.kind === 'audio' - ); - if (remoteTransceiver) { - remoteTransceiver.direction = 'sendrecv'; - voiceConnections.current[from].audioSender = remoteTransceiver.sender; - if (localStream.current) { - const track = localStream.current.getAudioTracks()[0]; - if (track) { - await remoteTransceiver.sender.replaceTrack(track); - } + let name = 'Организация'; + if (orgRes.ok) { + const org = await orgRes.json(); + name = org.name; } - } - // В обработчике voiceChatOffer, после await peerConnection.setRemoteDescription, добавьте (18.05.2025): - const pendingCandidates = voiceConnections.current[from].pendingCandidates || []; - for (const candidate of pendingCandidates) { - try { - await voiceConnections.current[from].peerConnection.addIceCandidate( - new RTCIceCandidate(candidate) - ); - } catch (err) { - console.error('Ошибка добавления буферизованного ICE кандидата:', err); - } - } - voiceConnections.current[from].pendingCandidates = []; - const answer = await peerConnection.createAnswer(); - await peerConnection.setLocalDescription(answer); - socket.emit('voiceChatAnswer', { to: from, answer }); - } catch (err) { - console.error('Ошибка обработки WebRTC предложения:', err); - } - } - }); - socket.on('voiceChatAnswer', async ({ from, answer }) => { - if (voiceConnections.current[from]) { - try { - await voiceConnections.current[from].peerConnection.setRemoteDescription( - new RTCSessionDescription(answer) - ); - const pending = voiceConnections.current[from].pendingCandidates || []; - for (const candidate of pending) { - try { - await voiceConnections.current[from].peerConnection.addIceCandidate( - new RTCIceCandidate(candidate) - ); - } catch (err) { - console.error('Ошибка добавления буферизованного ICE кандидата:', err); - } - } - voiceConnections.current[from].pendingCandidates = []; - } catch (err) { - console.error('Ошибка установки WebRTC ответа:', err); - } - } - }); - - - // Замените обработчик voiceChatIceCandidate на (18.05.2025): - socket.on('voiceChatIceCandidate', async ({ from, candidate }) => { - if (!voiceConnections.current[from]) { - console.warn('Соединение для', from, 'не существует, пропущен ICE кандидат'); - return; - } - - const peerConnection = voiceConnections.current[from].peerConnection; - - if (peerConnection.remoteDescription) { - try { - await peerConnection.addIceCandidate(new RTCIceCandidate(candidate)); - } catch (err) { - console.error('Ошибка добавления ICE кандидата:', err); - } - } else { - console.log('Буферизация ICE кандидата для', from); - voiceConnections.current[from].pendingCandidates.push(candidate); - } - }); - - socket.on('voiceChatStatus', ({ playerId, enabled }) => { - if (voiceIcons.current[playerId]) { - voiceIcons.current[playerId].visible = enabled; - } - }); - - socket.on('connect', () => console.log('Socket connected, id=', socket.id)); - socket.on('currentPlayers', (players) => { - console.log('currentPlayers', players); - // Получаем профиль (только для ФИО/аватара) - const myProfile = JSON.parse(sessionStorage.getItem('user_profile') || '{}'); - // Добавляем/обновляем игроков из пришедшего списка - Object.keys(players).forEach(id => { - if (id === socket.id) return; - const { x, y, z, avatarURL, gender, firstName, lastName } = players[id]; - if (!remotePlayers[id]) { - addOtherPlayer(id, x, z, avatarURL, gender, firstName, lastName, y); - } - }); - // Удаляем тех, кого нет в актуальном списке (после входа/выхода из интерьера и т.п.) - const validIds = new Set(Object.keys(players)); - Object.keys(remotePlayers).forEach((rid) => { - if (rid === socket.id) return; - if (!validIds.has(rid)) { - if (remotePlayers[rid] && remotePlayers[rid].model) { - scene.remove(remotePlayers[rid].model); - } - delete remotePlayers[rid]; - if (voiceIcons.current[rid]) delete voiceIcons.current[rid]; - cleanupVoiceConnection(rid); - } - }); - - // После получения списка игроков, отправляем newPlayer о себе ТОЛЬКО когда мы не в интерьере - // Отправляем себя только если это первый коннект и ещё не отправляли - if (!window.__newPlayerSentOnce) { - const profile = myProfile; - socket.emit('newPlayer', { - x: player?.position?.x || 0, - y: player?.position?.y || 0, - z: player?.position?.z || 0, - avatarURL: avatarUrl, - firstName: profile.firstName, - lastName: profile.lastName, - userId: profile.id - }); - window.__newPlayerSentOnce = true; - } - }); - - socket.on('chatMessage', ({ playerId, name, message, position }) => { - console.log('← chatMessage получил:', message); - if (!player || !cameraRef.current || !scene || !obstacles) return; - - const origin = cameraRef.current.position.clone(); - const targetPos = new THREE.Vector3(position.x, player.position.y, position.z); - const direction = new THREE.Vector3().subVectors(targetPos, origin).normalize(); - - const raycaster = new THREE.Raycaster(origin, direction); - raycaster.camera = cameraRef.current; // ← ВАЖНО для спрайтов - - const obstacleMeshes = obstacles.map(o => o.mesh).filter(Boolean); // ← фильтр от null - const intersects = raycaster.intersectObjects(obstacleMeshes, true); - - const distanceToTarget = origin.distanceTo(targetPos); - - if (intersects.length > 0 && intersects[0].distance < distanceToTarget) { - console.log(`🔕 ${name} за препятствием — сообщение скрыто`); - return; - } - - const div = document.getElementById('chatMessages'); - if (!div) return; - - const p = document.createElement('p'); - p.textContent = `${name || 'Игрок'}: ${message}`; - p.style.color = 'white'; - p.style.padding = '5px'; - p.style.margin = '2px 0'; - p.style.fontSize = '14px'; - p.style.borderRadius = '10px'; - div.appendChild(p); - div.scrollTop = div.scrollHeight; - }); - - socket.on('playerMoved', (data) => { - const remote = remotePlayers[data.playerId]; - if (!remote) return; - - const newPos = new THREE.Vector3(data.x, typeof data.y === 'number' ? data.y : remote.model.position.y, data.z); - const dir = new THREE.Vector3().subVectors(newPos, remote.model.position); - if (dir.lengthSq() > 1e-4) { - const angle = Math.atan2(dir.x, dir.z); - const targetQuat = new THREE.Quaternion().setFromEuler( - new THREE.Euler(0, angle, 0) - ); - remote.model.quaternion.slerp(targetQuat, 0.2); - } - - remote.targetPosition = newPos.clone(); - - if (remote.currentAction !== remote.walkAction) { - // Более плавный переход к анимации ходьбы - const fadeTime = 0.3; - remote.currentAction.fadeOut(fadeTime); - remote.walkAction.reset().fadeIn(fadeTime).play(); - remote.currentAction = remote.walkAction; - - // Синхронизируем время анимации - remote.walkAction.time = 0; - } - - clearTimeout(remote._idleTimeout); - remote._idleTimeout = setTimeout(() => { - if (remote.currentAction !== remote.idleAction) { - // Более плавный переход к idle анимации - const fadeTime = 0.3; - remote.currentAction.fadeOut(fadeTime); - remote.idleAction.reset().fadeIn(fadeTime).play(); - remote.currentAction = remote.idleAction; - } - }, 500); - - // Update voice chat volume based on distance - if (voiceConnections.current[data.playerId]) { - const dist = player.position.distanceTo(newPos); - const maxDist = 50; - const volume = Math.max(0, 1 - dist / maxDist); - voiceConnections.current[data.playerId].audioElement.volume = volume; - } - }); - - socket.on('newPlayer', (data) => { - console.log('newPlayer', data); - const { playerId, x, z, avatarURL, gender, firstName, lastName } = data; - - // Проверяем, не существует ли уже игрок с таким ID - if (remotePlayers[playerId]) { - console.log(`Игрок ${playerId} уже существует, обновляем позицию`); - // Обновляем позицию существующего игрока - remotePlayers[playerId].model.position.set(x, 0, z); - return; - } - - // Если мы сейчас внутри интерьера, показывать новых игроков следует только когда они тоже будут в нашем списке currentPlayers, - // который уже фильтруется сервером по interiorId. Здесь просто добавляем как обычно. - addOtherPlayer(playerId, x, z, avatarURL, gender, firstName, lastName); - }); - - socket.on('playerDisconnected', (id) => { - if (remotePlayers[id]) { - scene.remove(remotePlayers[id].model); - delete remotePlayers[id]; - } - if (voiceIcons.current[id]) { - delete voiceIcons.current[id]; - } - cleanupVoiceConnection(id); - }); - - - - - // Throttling для колеса мыши - let wheelTimeout = null; - - function onMouseWheel(e) { - e.preventDefault(); - - // Throttling - обрабатываем только каждые 16ms (60fps) - if (wheelTimeout) return; - - wheelTimeout = setTimeout(() => { - wheelTimeout = null; - }, 16); - - const delta = -e.deltaY * 0.001; - - if (e.ctrlKey) { - cameraPitchOffset = THREE.MathUtils.clamp( - cameraPitchOffset + delta, - -maxPitch, - maxPitch - ); - } else { - if (cameraRef.current === orthoCamRef.current) { - zoom = THREE.MathUtils.clamp(zoom * (1 + delta), minZoom, maxZoom); - orthoCamRef.current.zoom = zoom; - orthoCamRef.current.updateProjectionMatrix(); - } - } - } - - // Throttling для движения мыши - let mouseMoveTimeout = null; - - function onMouseLookMove(e) { - if (!isInInteriorRef.current || cameraRef.current !== fpCamRef.current || !playerRef.current) return; - if (altHeldRef.current) return; // при зажатом Alt не вращаем камеру - - // Throttling - обрабатываем только каждые 8ms (120fps для более плавного движения) - if (mouseMoveTimeout) return; - - mouseMoveTimeout = setTimeout(() => { - mouseMoveTimeout = null; - }, 8); - - const movementX = e.movementX || e.mozMovementX || e.webkitMovementX || 0; - const movementY = e.movementY || e.mozMovementY || e.webkitMovementY || 0; - - // Уменьшаем чувствительность для более плавного движения - const sensitivity = 0.0015; - - // В интерьере поворачиваем только камеру, не игрока - if (isInInteriorRef.current) { - // Поворачиваем камеру по горизонтали (влево-вправо) - const yawDelta = -movementX * sensitivity; - const currentYaw = playerRef.current.rotation.y; - playerRef.current.rotation.y = currentYaw + yawDelta; - - // Поворачиваем камеру по вертикали (вверх-вниз) - const pitchDelta = -movementY * sensitivity; - fpPitchRef.current = THREE.MathUtils.clamp( - fpPitchRef.current + pitchDelta, - -Math.PI / 2 + 0.1, - Math.PI / 2 - 0.1 - ); - } else { - // В обычном режиме поворачиваем игрока - playerRef.current.rotation.y -= movementX * sensitivity; - fpPitchRef.current = THREE.MathUtils.clamp( - fpPitchRef.current - movementY * sensitivity, - -Math.PI / 2 + 0.1, - Math.PI / 2 - 0.1 - ); - } - } - - async function init() { - console.log('[DEBUG] init вызван'); - - // Проверяем, что THREE загружен - if (!THREE) { - console.error('THREE.js не загружен'); - return; - } - - // Проверяем, что THREE.Clock доступен - if (!THREE.Clock) { - console.error('THREE.Clock не доступен'); - return; - } - - // Проверяем, что THREE.Scene доступен - if (!THREE.Scene) { - console.error('THREE.Scene не доступен'); - return; - } - - scene = new THREE.Scene(); - //scene.fog = new THREE.FogExp2(0xcce0ff, 0.002); - sceneRef.current = scene; - const aspect = window.innerWidth / window.innerHeight; - const d = 200; - - // Проверяем, что THREE.OrthographicCamera доступен - if (!THREE.OrthographicCamera) { - console.error('THREE.OrthographicCamera не доступен'); - return; - } - - // Проверяем, что THREE.PerspectiveCamera доступен - if (!THREE.PerspectiveCamera) { - console.error('THREE.PerspectiveCamera не доступен'); - return; - } - - orthoCamera = new THREE.OrthographicCamera(-d * aspect, d * aspect, d, -d, 1, 1000); - orthoCamera.position.set(200, 200, 200); - orthoCamera.zoom = zoom; - orthoCamera.updateProjectionMatrix(); - orthoCamera.lookAt(scene.position); - - fpCamera = new THREE.PerspectiveCamera(75, aspect, 0.1, 1000); - - cameraRef.current = orthoCamera; - orthoCamRef.current = orthoCamera; - fpCamRef.current = fpCamera; - - // Проверяем поддержку WebGL - if (!window.WebGLRenderingContext) { - console.error('WebGL не поддерживается в этом браузере'); - return; - } - - // Проверяем, что THREE.WebGLRenderer доступен - if (!THREE.WebGLRenderer) { - console.error('THREE.WebGLRenderer не доступен'); - return; - } - - try { - renderer = new THREE.WebGLRenderer({ - antialias: true, - alpha: true, - preserveDrawingBuffer: false - }); - } catch (error) { - console.error('Ошибка создания WebGL renderer:', error); - // Попытка создать renderer без antialias - try { - renderer = new THREE.WebGLRenderer({ - antialias: false, - alpha: true, - preserveDrawingBuffer: false - }); - } catch (secondError) { - console.error('Не удалось создать WebGL renderer даже без antialias:', secondError); - return; - } - } - renderer.setSize(window.innerWidth, window.innerHeight); - renderer.setClearColor(0x87CEEB, 1); // Голубое небо - renderer.shadowMap.enabled = true; - renderer.shadowMap.type = THREE.PCFSoftShadowMap; - renderer.outputColorSpace = THREE.SRGBColorSpace; - renderer.toneMapping = THREE.ACESFilmicToneMapping; - renderer.toneMappingExposure = 1.0; - rendererRef.current = renderer; - - if (mountRef.current) { - mountRef.current.appendChild(renderer.domElement); - } else { - console.error('mountRef.current не найден'); - return; - } - - if (renderer && renderer.domElement) { - renderer.domElement.addEventListener('wheel', onMouseWheel, { passive: false }); - renderer.domElement.addEventListener('mousemove', onMouseLookMove); - } else { - console.error('renderer или renderer.domElement не найден'); - return; - } - - // Pointer lock больше не используется в интерьере — курсор всегда активен - - // Проверяем, что THREE.PlaneGeometry доступен - if (!THREE.PlaneGeometry) { - console.error('THREE.PlaneGeometry не доступен'); - return; - } - - // Проверяем, что THREE.MeshBasicMaterial доступен - if (!THREE.MeshBasicMaterial) { - console.error('THREE.MeshBasicMaterial не доступен'); - return; - } - - const planeGeometry = new THREE.PlaneGeometry(territorySize, territorySize); - const planeMaterial = new THREE.MeshBasicMaterial({ - color: 0x00aa00, - transparent: true, - opacity: 0, // невидим - depthWrite: false // не трогает Z-буфер - }); - - // Проверяем, что THREE.Mesh доступен - if (!THREE.Mesh) { - console.error('THREE.Mesh не доступен'); - return; - } - - groundPlane = new THREE.Mesh(planeGeometry, planeMaterial); - groundPlane.rotation.x = -Math.PI / 2; - scene.add(groundPlane); - groundRef.current = groundPlane; - - // Проверяем, что THREE.AmbientLight доступен - if (!THREE.AmbientLight) { - console.error('THREE.AmbientLight не доступен'); - return; - } - - // Проверяем, что THREE.DirectionalLight доступен - if (!THREE.DirectionalLight) { - console.error('THREE.DirectionalLight не доступен'); - return; - } - - const ambientLight = new THREE.AmbientLight(0xffffff, 0.5); - scene.add(ambientLight); - const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8); - directionalLight.position.set(50, 100, 50); - scene.add(directionalLight); - - // Проверяем, что THREE.SphereGeometry доступен - if (!THREE.SphereGeometry) { - console.error('THREE.SphereGeometry не доступен'); - return; - } - - const markerGeometry = new THREE.SphereGeometry(0.5, 16, 16); - const markerMaterial = new THREE.MeshBasicMaterial({ color: 0xffff00 }); - destinationMarker = new THREE.Mesh(markerGeometry, markerMaterial); - destinationMarker.visible = false; - scene.add(destinationMarker); - - // Проверяем, что THREE.LoadingManager доступен - if (!THREE.LoadingManager) { - console.error('THREE.LoadingManager не доступен'); - return; - } - - // Проверяем, что THREE.TextureLoader доступен - if (!THREE.TextureLoader) { - console.error('THREE.TextureLoader не доступен'); - return; - } - - const loadingManager = new THREE.LoadingManager(() => { - console.log("Все текстуры загружены"); - }); - const textureLoader = new THREE.TextureLoader(loadingManager); - const baseTexture = textureLoader.load('textures/base.png', - // onLoad callback - (texture) => { - console.log('Текстура base.png загружена успешно'); - if (THREE.SRGBColorSpace) { - texture.colorSpace = THREE.SRGBColorSpace; - } - }, - // onProgress callback - (progress) => { - console.log('Прогресс загрузки текстуры:', progress); - }, - // onError callback - (error) => { - console.error('Ошибка загрузки текстуры base.png:', error); - // Создаем материал без текстуры в случае ошибки - if (THREE.MeshStandardMaterial) { - customMaterial = new THREE.MeshStandardMaterial({ - color: 0x808080 + const setRes = await fetch(`/api/organizations/${orgId}/settings`, { + headers: { Authorization: `Bearer ${token}` } }); - } else { - console.error('THREE.MeshStandardMaterial не доступен'); - } + const settings = setRes.ok ? await setRes.json() : { menu: [] }; + + // сервер уже отдаёт menu как массив + const menuArray = Array.isArray(settings.menu) ? settings.menu : []; + + setOrgMenu({ id: orgId, name, menu: menuArray }); + setSelectedHouse(null); + } catch (e) { + console.error('Не удалось загрузить меню организации', orgId, e); + alert('Ошибка загрузки меню организации'); } - ); - - // Проверяем, что THREE.MeshStandardMaterial доступен - if (!THREE.MeshStandardMaterial) { - console.error('THREE.MeshStandardMaterial не доступен'); - return; - } - - customMaterial = new THREE.MeshStandardMaterial({ - map: baseTexture, - roughness: 0.5, - metalness: 0.1 - }); + } - const npcMixersArray = []; - // Добавление персонажей - const npcData = [ - { id: 'bartender', model: '/models/npc/bartender.glb', position: [0, 0, 10] }, - { id: 'guard', model: '/models/npc/guard.glb', position: [0, 0, 5] }, - { id: 'Adventurer', model: '/models/npc/galina.glb', position: [-16.5, -100, -68.8] }, - { id: 'BeachCharacter', model: '/models/npc/BeachCharacter.glb', position: [0, 0, 3] }, - { id: 'Oxranik', model: '/models/npc/Oxranik.glb', position: [0, 0, -3] }, - { id: 'Computer', model: '/models/npc/Computer.glb', position: [0.1, 0.1, 2.1] } - ]; - for (const npc of npcData) { + function openOrganizationPanel(orgId) { + setOrgPanelId(orgId); + setOrgMenu(null); + setSelectedHouse(null); + } + + + async function movePlayerToInterior(interiorId) { + await enterInteriorMode(interiorId); + } + + function switchToFirstPersonCamera() { + console.log('switchToFirstPersonCamera вызвана'); + console.log('isInInteriorRef.current:', isInInteriorRef.current); + + if (fpCamRef.current) { + cameraRef.current = fpCamRef.current; + console.log('Камера переключена на fpCamRef'); + } + if (playerRef.current) { + // Скрываем полностью собственную модель в режиме FPV + playerRef.current.visible = false; + // На всякий случай также скрываем голову/шею (если модель будет вновь показана без выхода из режима) + const hidden = []; + playerRef.current.traverse((child) => { + if (!child.isMesh) return; + const name = (child.name || '').toLowerCase(); + if (name.includes('head') || name.includes('neck') || name.includes('helmet') || name.includes('hair')) { + child.visible = false; + hidden.push(child); + } + }); + fpHiddenNodesRef.current = hidden; + console.log('Скрыты узлы для FPV:', hidden.map(n => n.name)); + } + fpPitchRef.current = 0; + + // Настраиваем камеру от первого лица для интерьера + if (isInInteriorRef.current) { + console.log('Настраиваем камеру для интерьера'); + // Устанавливаем позицию камеры на уровне глаз игрока + const headHeight = 1.6; + fpCamRef.current.position.set( + playerRef.current.position.x, + playerRef.current.position.y + headHeight, + playerRef.current.position.z + ); + // Не большой сдвиг камеры вперёд, чтобы не упираться в скрытую голову + const forward = new THREE.Vector3(0, 0, -0.08).applyEuler(new THREE.Euler(0, playerRef.current.rotation.y, 0)); + fpCamRef.current.position.add(forward); + + // Направляем камеру в том же направлении, что и игрок + const direction = new THREE.Vector3(0, 0, -1); + direction.applyEuler(new THREE.Euler(0, playerRef.current.rotation.y, 0)); + fpCamRef.current.lookAt( + fpCamRef.current.position.clone().add(direction) + ); + console.log('Камера настроена для интерьера'); + } + } + + function switchToThirdPersonCamera() { + console.log('switchToThirdPersonCamera вызвана'); + if (orthoCamRef.current) { + cameraRef.current = orthoCamRef.current; + console.log('Камера переключена на orthoCamRef'); + } + if (playerRef.current) { + playerRef.current.visible = true; + // Вернуть видимость скрытых для FPV узлов + if (Array.isArray(fpHiddenNodesRef.current)) { + fpHiddenNodesRef.current.forEach(n => { n.visible = true; }); + fpHiddenNodesRef.current = []; + } + console.log('Игрок показан'); + } + fpPitchRef.current = 0; + } + + function startMove(dir) { + moveInputRef.current[dir] = true; + } + + function stopMove(dir) { + moveInputRef.current[dir] = false; + } + + + // ───────────────────────────────────────────────────── + // КЛИКИ ВНУТРИ ИНТЕРЬЕРА (интерактивные маркеры/NPC) + // ───────────────────────────────────────────────────── + useEffect(() => { + const onClick = (e) => { + console.log('[INTERIOR CLICK] handler start; isInInterior:', isInInteriorRef.current); + if (!isInInteriorRef.current) return; + const mount = mountRef.current; + if (!mount || !cameraRef.current) return; + + // координаты мыши в NDC + // Пытаемся получить координаты из элемента рендера (FP вид) + const canvas = rendererRef.current && rendererRef.current.domElement; + const rect = (canvas || mount).getBoundingClientRect(); + const mouse = new THREE.Vector2( + ((e.clientX - rect.left) / rect.width) * 2 - 1, + -((e.clientY - rect.top) / rect.height) * 2 + 1 + ); + const raycaster = new THREE.Raycaster(); + raycaster.setFromCamera(mouse, cameraRef.current); + // Ищем пересечения по интерактивам (включая NPC) + const objects = interiorInteractablesRef.current.filter(obj => obj?.isObject3D); + // Добавим в список интерактивов саму группу интерьера, чтобы traverse детектил payload у вложенных узлов + const extraTargets = []; + if (interiorGroupRef.current) extraTargets.push(interiorGroupRef.current); + const rayHits = raycaster.intersectObjects(objects.concat(extraTargets), true); + console.log('[INTERIOR CLICK] rayHits count:', rayHits.length); + const hits = rayHits.filter(h => { + const obj = h.object; + // учитываем payload на мешах и на родителях + if (obj && obj.userData && (obj.userData.interactable || obj.userData.payload || obj.userData.isNpc)) return true; + let p = obj; + while (p && p.parent) { + p = p.parent; + if (p.userData && (p.userData.interactable || p.userData.payload || p.userData.isNpc)) return true; + } + return false; + }); + console.log('[INTERIOR CLICK] interactable hits count:', hits.length); + if (hits.length) { + const top = hits[0].object; + // поднимаем до узла, где лежит payload + let node = top; + while (node && !node.userData?.payload && node.parent) node = node.parent; + let payload = (node && node.userData && node.userData.payload) || (top.userData.payload) || {}; + // Если у попавшего меша нет payload, но это часть NPC, поднимемся до isNpc + if ((!payload || !payload.type) && node) { + let p = node; + while (p && !p.userData?.isNpc && p.parent) p = p.parent; + if (p && p.userData?.npcId) { + payload = { type: 'npc', id: p.userData.npcId }; + } + } + console.log('[INTERIOR CLICK] payload:', payload); + if (payload.type === 'marker') { + console.log('Нажат маркер:', payload); + } else if (payload.type === 'npc') { + console.log('Нажат NPC:', payload); + try { if (payload.id) { loadDialog(payload.id); } } catch (_) { } + } else { + console.log('Интерактив:', payload); + } + return; + } + + // Если своих интерактивов не нашли, пробуем поймать NPC из общего массива npcMeshes try { - const gltf = await gltfLoader.loadAsync(npc.model); - const model = gltf.scene; - + const npcHit = raycaster.intersectObjects(npcMeshesRef.current || [], true); + console.log('[INTERIOR CLICK] npcMeshes hits:', npcHit.length); + if (npcHit.length) { + let root = npcHit[0].object; + while (root.parent && !root.userData?.isNpc) root = root.parent; + if (root.userData && root.userData.npcId) { + console.log('[INTERIOR CLICK] NPC root found:', root.userData.npcId); + if (root.userData.npcId === 'Computer') { + setShowMiniGame(true); + setPasswordCorrect(false); + setAudioUrl('/audio/firs.ogg'); + addSeregaComment('Ну чё, хакер, разберёшься?'); + } else { + loadDialog(root.userData.npcId); + } + return; + } + } + } catch (e) { + console.warn('[INTERIOR CLICK] npcMeshes raycast failed:', e); + } + }; + + const target = rendererRef.current ? rendererRef.current.domElement : window; + target.addEventListener('click', onClick); + target.addEventListener('pointerdown', onClick); + return () => { target.removeEventListener('click', onClick); target.removeEventListener('pointerdown', onClick); }; + }, []); + + async function buyItem(key) { + if (!orgMenu) return; + const token = localStorage.getItem('token'); + const res = await fetch(`/api/organizations/${orgMenu.id}/purchase`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, + body: JSON.stringify({ itemKey: key }) + }); + if (res.ok) { + const data = await res.json(); + setSatiety(data.satiety); + setThirst(data.thirst); + setBalance(data.balance); + const profile = JSON.parse(sessionStorage.getItem('user_profile') || '{}'); + profile.satiety = data.satiety; + profile.thirst = data.thirst; + profile.balance = data.balance; + sessionStorage.setItem('user_profile', JSON.stringify(profile)); + socketRef.current.emit('economy:getInventory', { userId: profile.id }); + } + } + + function handleItemAction(item) { + const act = window.prompt('1 - использовать, 2 - выкинуть'); + const prof = JSON.parse(sessionStorage.getItem('user_profile') || '{}'); + if (act === '1') { + if (item.name.toLowerCase().includes('вода')) { + setThirst(t => Math.min(100, t + 20)); + } else { + setSatiety(s => Math.min(100, s + 20)); + } + socketRef.current.emit('economy:removeItem', { userId: prof.id, itemId: item.item_id, quantity: 1 }); + } else if (act === '2') { + socketRef.current.emit('economy:removeItem', { userId: prof.id, itemId: item.item_id, quantity: 1 }); + } + socketRef.current.emit('economy:getInventory', { userId: prof.id }); + } + function toggleWorldVisibility(visible) { + groundRef.current && (groundRef.current.visible = visible); + cityMeshesRef.current.forEach(m => m.visible = visible); + } + + function createInterior() { + const group = new THREE.Group(); + const floorMat = new THREE.MeshStandardMaterial({ color: 0x808080 }); + const floor = new THREE.Mesh(new THREE.PlaneGeometry(20, 20), floorMat); + floor.rotation.x = -Math.PI / 2; + group.add(floor); + + const wallMat = new THREE.MeshStandardMaterial({ color: 0x999999 }); + const wallGeo = new THREE.PlaneGeometry(20, 10); + const back = new THREE.Mesh(wallGeo, wallMat); + back.position.set(0, 5, -10); + group.add(back); + const front = back.clone(); + front.position.set(0, 5, 10); + front.rotation.y = Math.PI; + group.add(front); + const left = back.clone(); + left.position.set(-10, 5, 0); + left.rotation.y = Math.PI / 2; + group.add(left); + const right = back.clone(); + right.position.set(10, 5, 0); + right.rotation.y = -Math.PI / 2; + group.add(right); + + const light = new THREE.PointLight(0xffffff, 1); + light.position.set(0, 5, 0); + group.add(light); + + return group; + } + + function enterHouse(house) { + if (!house || !sceneRef.current || !playerRef.current) return; + const id = parseInt(house.id, 10); + if (id === 9) { + savedPositionRef.current.copy(playerRef.current.position); + toggleWorldVisibility(false); + interiorGroupRef.current = createInterior(); + sceneRef.current.add(interiorGroupRef.current); + playerRef.current.position.set(0, 0, 0); + playerRef.current.quaternion.identity(); + setSelectedHouse(null); + switchToFirstPersonCamera(); + setIsInInterior(true); + } + } + + useEffect(() => { + console.log('[DEBUG] useEffect вызван'); + const mount = mountRef.current; + if (!mount) { + console.log('[DEBUG] mountRef.current не определён!'); + return; + } + + // ───────────────────────────────────────────── + // Красивый загрузочный оверлей + LoadingManager + // ───────────────────────────────────────────── + let overlayEl = null, barEl = null, textEl = null; + function createLoadingOverlay() { + if (overlayEl) return; + overlayEl = document.createElement('div'); + Object.assign(overlayEl.style, { + position: 'fixed', inset: '0', zIndex: 2000, + display: 'flex', flexDirection: 'column', + alignItems: 'center', justifyContent: 'center', + background: 'linear-gradient(135deg,#0f172a,#1e293b)', + color: '#fff', fontFamily: 'system-ui, Arial, sans-serif' + }); + textEl = document.createElement('div'); + Object.assign(textEl.style, { + fontSize: '24px', fontWeight: 700, opacity: 0.9, marginBottom: '16px' + }); + textEl.textContent = 'Загрузка ресурсов...'; + overlayEl.appendChild(textEl); + const barWrap = document.createElement('div'); + Object.assign(barWrap.style, { + width: '320px', height: '10px', + background: 'rgba(255,255,255,0.15)', + borderRadius: '999px', overflow: 'hidden', + boxShadow: '0 6px 20px rgba(0,0,0,0.35)' + }); + barEl = document.createElement('div'); + Object.assign(barEl.style, { + width: '0%', height: '100%', + transition: 'width .15s ease', + background: 'linear-gradient(90deg,#22d3ee,#38bdf8,#60a5fa)' + }); + barWrap.appendChild(barEl); + overlayEl.appendChild(barWrap); + const pct = document.createElement('div'); + Object.assign(pct.style, { marginTop: '12px', fontSize: '14px', opacity: 0.8 }); + pct.id = 'loadingPct'; + pct.textContent = '0%'; + overlayEl.appendChild(pct); + document.body.appendChild(overlayEl); + } + function updateLoadingOverlay(percent, text) { + if (!overlayEl) return; + const p = Math.max(0, Math.min(100, Math.round(percent || 0))); + if (barEl) barEl.style.width = p + '%'; + const pct = overlayEl.querySelector('#loadingPct'); + if (pct) pct.textContent = p + '%'; + if (text && textEl) textEl.textContent = text; + } + function removeLoadingOverlay() { + if (!overlayEl) return; + overlayEl.style.transition = 'opacity .2s ease'; + overlayEl.style.opacity = '0'; + setTimeout(() => { + overlayEl && overlayEl.remove(); + overlayEl = barEl = textEl = null; + }, 220); + } + // Общий менеджер загрузки (для GLTF/Texture и т.п.) + const loadingManager = new THREE.LoadingManager(); + loadingManagerRef.current = loadingManager; + loadingManager.onStart = (_url, loaded, total) => { + createLoadingOverlay(); + updateLoadingOverlay(total ? (loaded / total) * 100 : 5, 'Загрузка ресурсов...'); + }; + loadingManager.onProgress = (_url, loaded, total) => { + updateLoadingOverlay(total ? (loaded / total) * 100 : 50); + }; + loadingManager.onLoad = () => { + updateLoadingOverlay(100, 'Инициализация сцены...'); + setTimeout(removeLoadingOverlay, 150); + }; + + + console.log('–– useEffect начало'); + + const baseOffset = new THREE.Vector3(-200, 150, -200); + const planarDist = Math.hypot(baseOffset.x, baseOffset.z); + const radius = Math.hypot(planarDist, baseOffset.y); + const baseAzimuth = Math.atan2(baseOffset.z, baseOffset.x); + const basePolar = Math.atan2(baseOffset.y, planarDist); + + let cameraPitchOffset = 0; + const maxPitch = THREE.MathUtils.degToRad(10); + + let zoom = 10; + const minZoom = zoom * 0.1; + const maxZoom = zoom * 3.5; + + let orthoCamera, fpCamera; + let player, mixer; + let idleAction, walkAction, currentAction; + let remotePlayers = remotePlayersRef.current; + let obstacles = []; + let destination = null; + let blockedTime = 0; + const moveSpeed = 2.5; + const WALK_ANIM_SPEED_MPS = 2; + let clock; + try { + clock = new THREE.Clock(); + } catch (error) { + console.error('Ошибка создания THREE.Clock:', error); + return; + } + const keys = {}; + let npcMeshes = []; + const territorySize = 500; + const boundary = territorySize / 2; + const gridSize = 300; + const nodeSize = territorySize / gridSize; + + let pathfinderGrid; + let currentPath = []; + let pathIndex = 0; + let groundPlane; + let destinationMarker; + let customMaterial; + + const token = localStorage.getItem('token'); + // Подключаемся к локальному серверу + const serverUrl = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1' + ? 'http://localhost:4000' + : window.location.origin; + + socketRef.current = io(serverUrl, { + transports: ['websocket', 'polling'], + auth: { token }, + timeout: 20000 // Увеличиваем timeout до 20 секунд + }); + const socket = socketRef.current; + + console.log('socket инстанс:', socket); + console.log('Подключение к серверу:', serverUrl); + + socket.on('connect', () => { + console.log('✔ Socket connected, id=', socket.id); + console.log('Подключение успешно установлено'); + }); + + socket.on('connect_error', err => { + console.error('Socket connect_error:', err); + console.error('Ошибка подключения к серверу:', serverUrl); + console.error('Проверьте, что сервер запущен на порту 4000'); + }); + + socket.on('disconnect', reason => { + console.warn('Socket disconnected:', reason); + console.warn('Соединение разорвано, причина:', reason); + }); + const profile = JSON.parse(sessionStorage.getItem('user_profile') || '{}'); + if (profile?.id) { + socket.emit('economy:getBalance', { userId: profile.id }); + } + const balanceInterval = setInterval(() => { + const p = JSON.parse(sessionStorage.getItem('user_profile') || '{}'); + if (p?.id) socket.emit('economy:getBalance', { userId: p.id }); + }, 3000); + socket.on('economy:balanceChanged', ({ userId, newBalance }) => { + if (userId === profile.id) { + setBalance(newBalance); + const upd = { ...(profile || {}), balance: newBalance }; + sessionStorage.setItem('user_profile', JSON.stringify(upd)); + } + }); + socket.emit('economy:getInventory', { userId: profile.id }); + socket.on('economy:inventory', setInventory); + socket.on('gameTime:update', ({ time }) => setGameTime(time)); + // Лоадеры, учитывающиеся в прогрессе через loadingManagerRef + const gltfLoader = new GLTFLoader(loadingManagerRef.current || undefined); + const animLoader = new GLTFLoader(loadingManagerRef.current || undefined); + + async function loadPlayerModel(avatarUrl) { + return new Promise((resolve, reject) => { + console.log('GLTFLoader загружает:', avatarUrl); + + // Проверяем, что URL начинается с правильного пути + if (!avatarUrl.startsWith('/') && !avatarUrl.startsWith('http')) { + console.error('Неправильный формат URL:', avatarUrl); + reject(new Error('Неправильный формат URL')); + return; + } + + gltfLoader.load( + avatarUrl, + (gltf) => { + console.log('GLTF загружен успешно:', gltf); + if (!gltf.scene) { + console.error('GLTF.scene отсутствует в загруженном файле'); + return reject('GLTF.scene отсутствует'); + } + resolve(gltf); + }, + (progress) => { + console.log('Прогресс загрузки:', progress); + }, + (err) => { + console.error('Ошибка загрузки GLTF:', err); + reject(err); + } + ); + }); + } + + async function addOtherPlayer(id, x, z, avatarURL, genderRemote = 'male', firstName = '', lastName = '', y = 0) { + if (remotePlayers[id]) { + // Уже есть — не пересоздаём + return; + } + let model; + try { + if (!avatarURL) throw new Error('no avatarURL'); + const gltf = await loadPlayerModel(avatarURL); + model = gltf.scene; + // Проверяем и исправляем материалы модели model.traverse((child) => { if (child.isMesh && child.material) { if (Array.isArray(child.material)) { child.material.forEach(mat => { if (!mat || !mat.isMaterial) { - console.warn(`Неправильный материал в ${npc.id}, заменяем на стандартный`); + console.warn(`Неправильный материал в аватаре ${id}, заменяем на стандартный`); if (THREE.MeshStandardMaterial) { child.material = new THREE.MeshStandardMaterial({ color: 0x808080 }); } else { @@ -2878,2808 +2086,3604 @@ useEffect(() => { } }); } else if (!child.material.isMaterial) { - console.warn(`Неправильный материал в ${npc.id}, заменяем на стандартный`); - if (THREE.MeshStandardMaterial) { - child.material = new THREE.MeshStandardMaterial({ color: 0x808080 }); - } else { - console.error('THREE.MeshStandardMaterial не доступен для замены материала'); - } + console.warn(`Неправильный материал в аватаре ${id}, заменяем на стандартный`); + child.material = new THREE.MeshStandardMaterial({ color: 0x808080 }); } } }); - - model.position.set(...npc.position); - model.userData.npcId = npc.id; - model.userData.isNpc = true; + } catch (e) { + console.warn(`Не удалось загрузить аватар ${id}, рисуем сферу`, e); + model = new THREE.Mesh( + new THREE.SphereGeometry(1), + new THREE.MeshBasicMaterial({ color: 0x888888 }) + ); + } + model.scale.set(1, 1, 1); + model.position.set(x, y || 0, z); + scene.add(model); - // Добавляем метку с именем - let label; - if (npc.id == 'bartender') { - label = createPlayerLabel('Серега Пират'); - } - else if (npc.id == 'guard') { - label = createPlayerLabel('Саша Белый'); - } - else if (npc.id == 'Adventurer') { - label = createPlayerLabel('Галина'); - } - else if (npc.id == 'BeachCharacter') { - label = createPlayerLabel('Костя Ключник'); - } - else if (npc.id == 'Oxranik') { - label = createPlayerLabel('Охранник'); - } + const fullname = `${firstName} ${lastName}`.trim(); + if (fullname) { + const label = createPlayerLabel(fullname); + label.position.set(0, 2.2, 0); + model.add(label); + } - if (label) { - label.position.set(0, 2.2, 0); - model.add(label); + // Add voice chat icon (initially hidden) + const voiceIcon = createVoiceIcon(); + voiceIcon.position.set(0, 2.7, 0); + voiceIcon.visible = false; + model.add(voiceIcon); + voiceIcons.current[id] = voiceIcon; + + const mixerRemote = new THREE.AnimationMixer(model); + + const isFemale = genderRemote === 'female'; + const animGender = isFemale ? 'feminine' : 'masculine'; + + const idleFile = isFemale ? 'F_Standing_Idle_001.glb' : 'M_Standing_Idle_001.glb'; + const walkFile = isFemale ? 'F_Walk_002.glb' : 'M_Walk_001.glb'; + + const idlePath = `/animations/${animGender}/glb/idle/${idleFile}`; + const walkPath = `/animations/${animGender}/glb/locomotion/${walkFile}`; + + const [idleGltf, walkGltf] = await Promise.all([ + animLoader.loadAsync(idlePath), + animLoader.loadAsync(walkPath) + ]); + + idleGltf.animations.forEach(stripPositionTracks); + walkGltf.animations.forEach(stripPositionTracks); + + const remoteIdleAction = mixerRemote.clipAction(idleGltf.animations[0], model); + const remoteWalkAction = mixerRemote.clipAction(walkGltf.animations[0], model); + + remoteIdleAction.play(); + + remotePlayers[id] = { + model, + mixer: mixerRemote, + idleAction: remoteIdleAction, + walkAction: remoteWalkAction, + currentAction: remoteIdleAction, + firstName, + lastName, + gender: genderRemote, + avatarURL, + _idleTimeout: null + }; + + // Синхронизируем анимацию ходьбы с скоростью перемещения + remotePlayers[id].walkAction.setEffectiveTimeScale(moveSpeed / WALK_ANIM_SPEED_MPS); + } + + function createVoiceIcon() { + const canvas = document.createElement('canvas'); + canvas.width = 64; + canvas.height = 64; + const ctx = canvas.getContext('2d'); + + ctx.fillStyle = '#00ff00'; + ctx.beginPath(); + ctx.arc(32, 32, 20, 0, 2 * Math.PI); + ctx.fill(); + ctx.fillStyle = '#000'; + ctx.font = '24px Arial'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText('🎤', 32, 32); + + const texture = new THREE.CanvasTexture(canvas); + + texture.generateMipmaps = false; + texture.minFilter = THREE.LinearFilter; + texture.magFilter = THREE.LinearFilter; + texture.anisotropy = 1; + + texture.needsUpdate = true; + + const spriteMaterial = new THREE.SpriteMaterial({ + map: texture, + transparent: true, + depthTest: false, // рисуем поверх геометрии + depthWrite: false, + toneMapped: false, // чтобы белый не «теплился» тон-меппингом + sizeAttenuation: false + }); + const sprite = new THREE.Sprite(spriteMaterial); + sprite.scale.set(0.5, 0.5, 1); + + // ↓↓↓ добавь это ↓↓↓ + sprite.raycast = () => { }; + sprite.userData.isUiSprite = true; + + return sprite; + } + + async function initiateVoiceChat(peerId) { + if (voiceConnections.current[peerId]) return; + + const peerConnection = new RTCPeerConnection({ + iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] + }); + + + voiceConnections.current[peerId] = { + peerConnection, + audioElement: document.createElement('audio'), + pendingCandidates: [], + audioSender: null + }; + + voiceConnections.current[peerId].audioElement.autoplay = true; + document.body.appendChild(voiceConnections.current[peerId].audioElement); + + peerConnection.ontrack = (event) => { + voiceConnections.current[peerId].audioElement.srcObject = event.streams[0]; + }; + + // В функции initiateVoiceChat, перед peerConnection.onicecandidate, добавьте (18.05.2025): + voiceConnections.current[peerId].pendingCandidates = []; + + peerConnection.onicecandidate = (event) => { + if (event.candidate) { + socket.emit('voiceChatIceCandidate', { + to: peerId, + candidate: event.candidate + }); } + }; - model.rotateY(Math.PI); // Развернуть персонажа - scene.add(model); - npcMeshes.push(model); // Правильное добавление в массив - npcMeshesRef.current.push(model); - cityMeshesRef.current.push(model); - - if (npc.id == 'Computer') { - model.scale.set(0.001, 0.001, 0.001); + peerConnection.onconnectionstatechange = () => { + if (peerConnection.connectionState === 'disconnected' || peerConnection.connectionState === 'failed') { + cleanupVoiceConnection(peerId); } + }; - if (npc.id == 'Oxranik') { - model.scale.set(0.2, 0.2, 0.2); + try { + const offer = await peerConnection.createOffer(); + await peerConnection.setLocalDescription(offer); + socket.emit('voiceChatOffer', { to: peerId, offer }); + } catch (err) { + console.error('Ошибка создания WebRTC предложения:', err); + } + } + + function cleanupVoiceConnection(peerId) { + if (voiceConnections.current[peerId]) { + const conn = voiceConnections.current[peerId]; + try { + conn.audioSender?.replaceTrack(null); + } catch { } + conn.peerConnection.close(); + conn.audioElement.remove(); + delete voiceConnections.current[peerId]; + } + } + + socket.on('voiceChatNearby', ({ playerId }) => { + if (remotePlayers[playerId] && !voiceConnections.current[playerId]) { + if (socket.id < playerId) { + initiateVoiceChat(playerId); } + } + }); + socket.on('voiceChatOffer', async ({ from, offer }) => { + if (!voiceConnections.current[from]) { + const peerConnection = new RTCPeerConnection({ + iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] + }); + + voiceConnections.current[from] = { + peerConnection, + audioElement: document.createElement('audio'), + pendingCandidates: [], + audioSender: null + }; + + voiceConnections.current[from].audioElement.autoplay = true; + document.body.appendChild(voiceConnections.current[from].audioElement); + + peerConnection.ontrack = (event) => { + voiceConnections.current[from].audioElement.srcObject = event.streams[0]; + }; + + peerConnection.onicecandidate = (event) => { + if (event.candidate) { + socket.emit('voiceChatIceCandidate', { + to: from, + candidate: event.candidate + }); + } + }; + + peerConnection.onconnectionstatechange = () => { + if (peerConnection.connectionState === 'disconnected' || peerConnection.connectionState === 'failed') { + cleanupVoiceConnection(from); + } + }; + + try { + await peerConnection.setRemoteDescription(new RTCSessionDescription(offer)); + const remoteTransceiver = peerConnection.getTransceivers().find( + t => t.receiver && t.receiver.track && t.receiver.track.kind === 'audio' + ); + if (remoteTransceiver) { + remoteTransceiver.direction = 'sendrecv'; + voiceConnections.current[from].audioSender = remoteTransceiver.sender; + if (localStream.current) { + const track = localStream.current.getAudioTracks()[0]; + if (track) { + await remoteTransceiver.sender.replaceTrack(track); + } + } + } + // В обработчике voiceChatOffer, после await peerConnection.setRemoteDescription, добавьте (18.05.2025): + const pendingCandidates = voiceConnections.current[from].pendingCandidates || []; + for (const candidate of pendingCandidates) { + try { + await voiceConnections.current[from].peerConnection.addIceCandidate( + new RTCIceCandidate(candidate) + ); + } catch (err) { + console.error('Ошибка добавления буферизованного ICE кандидата:', err); + } + } + voiceConnections.current[from].pendingCandidates = []; + const answer = await peerConnection.createAnswer(); + await peerConnection.setLocalDescription(answer); + socket.emit('voiceChatAnswer', { to: from, answer }); + } catch (err) { + console.error('Ошибка обработки WebRTC предложения:', err); + } + } + }); + + socket.on('voiceChatAnswer', async ({ from, answer }) => { + if (voiceConnections.current[from]) { + try { + await voiceConnections.current[from].peerConnection.setRemoteDescription( + new RTCSessionDescription(answer) + ); + const pending = voiceConnections.current[from].pendingCandidates || []; + for (const candidate of pending) { + try { + await voiceConnections.current[from].peerConnection.addIceCandidate( + new RTCIceCandidate(candidate) + ); + } catch (err) { + console.error('Ошибка добавления буферизованного ICE кандидата:', err); + } + } + voiceConnections.current[from].pendingCandidates = []; + } catch (err) { + console.error('Ошибка установки WebRTC ответа:', err); + } + } + }); + + + // Замените обработчик voiceChatIceCandidate на (18.05.2025): + socket.on('voiceChatIceCandidate', async ({ from, candidate }) => { + if (!voiceConnections.current[from]) { + console.warn('Соединение для', from, 'не существует, пропущен ICE кандидат'); + return; + } + + const peerConnection = voiceConnections.current[from].peerConnection; + + if (peerConnection.remoteDescription) { + try { + await peerConnection.addIceCandidate(new RTCIceCandidate(candidate)); + } catch (err) { + console.error('Ошибка добавления ICE кандидата:', err); + } + } else { + console.log('Буферизация ICE кандидата для', from); + voiceConnections.current[from].pendingCandidates.push(candidate); + } + }); + + socket.on('voiceChatStatus', ({ playerId, enabled }) => { + if (voiceIcons.current[playerId]) { + voiceIcons.current[playerId].visible = enabled; + } + }); + + socket.on('connect', () => console.log('Socket connected, id=', socket.id)); + socket.on('currentPlayers', (players) => { + console.log('currentPlayers', players); + // Получаем профиль (только для ФИО/аватара) + const myProfile = JSON.parse(sessionStorage.getItem('user_profile') || '{}'); + // Добавляем/обновляем игроков из пришедшего списка + Object.keys(players).forEach(id => { + if (id === socket.id) return; + const { x, y, z, avatarURL, gender, firstName, lastName } = players[id]; + if (!remotePlayers[id]) { + addOtherPlayer(id, x, z, avatarURL, gender, firstName, lastName, y); + } + }); + // Удаляем тех, кого нет в актуальном списке (после входа/выхода из интерьера и т.п.) + const validIds = new Set(Object.keys(players)); + Object.keys(remotePlayers).forEach((rid) => { + if (rid === socket.id) return; + if (!validIds.has(rid)) { + if (remotePlayers[rid] && remotePlayers[rid].model) { + scene.remove(remotePlayers[rid].model); + } + delete remotePlayers[rid]; + if (voiceIcons.current[rid]) delete voiceIcons.current[rid]; + cleanupVoiceConnection(rid); + } + }); + + // После получения списка игроков, отправляем newPlayer о себе ТОЛЬКО когда мы не в интерьере + // Отправляем себя только если это первый коннект и ещё не отправляли + if (!window.__newPlayerSentOnce) { + const profile = myProfile; + socket.emit('newPlayer', { + x: player?.position?.x || 0, + y: player?.position?.y || 0, + z: player?.position?.z || 0, + avatarURL: avatarUrl, + firstName: profile.firstName, + lastName: profile.lastName, + userId: profile.id + }); + window.__newPlayerSentOnce = true; + } + }); + + socket.on('chatMessage', ({ playerId, name, message, position }) => { + console.log('← chatMessage получил:', message); + if (!player || !cameraRef.current || !scene || !obstacles) return; + + const origin = cameraRef.current.position.clone(); + const targetPos = new THREE.Vector3(position.x, player.position.y, position.z); + const direction = new THREE.Vector3().subVectors(targetPos, origin).normalize(); + + const raycaster = new THREE.Raycaster(origin, direction); + raycaster.camera = cameraRef.current; // ← ВАЖНО для спрайтов + + const obstacleMeshes = obstacles.map(o => o.mesh).filter(Boolean); // ← фильтр от null + const intersects = raycaster.intersectObjects(obstacleMeshes, true); + + const distanceToTarget = origin.distanceTo(targetPos); + + if (intersects.length > 0 && intersects[0].distance < distanceToTarget) { + console.log(`🔕 ${name} за препятствием — сообщение скрыто`); + return; + } + + const div = document.getElementById('chatMessages'); + if (!div) return; + + const p = document.createElement('p'); + p.textContent = `${name || 'Игрок'}: ${message}`; + p.style.color = 'white'; + p.style.padding = '5px'; + p.style.margin = '2px 0'; + p.style.fontSize = '14px'; + p.style.borderRadius = '10px'; + div.appendChild(p); + div.scrollTop = div.scrollHeight; + }); + + socket.on('playerMoved', (data) => { + const remote = remotePlayers[data.playerId]; + if (!remote) return; + + const newPos = new THREE.Vector3(data.x, typeof data.y === 'number' ? data.y : remote.model.position.y, data.z); + const dir = new THREE.Vector3().subVectors(newPos, remote.model.position); + if (dir.lengthSq() > 1e-4) { + const angle = Math.atan2(dir.x, dir.z); + const targetQuat = new THREE.Quaternion().setFromEuler( + new THREE.Euler(0, angle, 0) + ); + remote.model.quaternion.slerp(targetQuat, 0.2); + } + + remote.targetPosition = newPos.clone(); + + if (remote.currentAction !== remote.walkAction) { + // Более плавный переход к анимации ходьбы + const fadeTime = 0.3; + remote.currentAction.fadeOut(fadeTime); + remote.walkAction.reset().fadeIn(fadeTime).play(); + remote.currentAction = remote.walkAction; + + // Синхронизируем время анимации + remote.walkAction.time = 0; + } + + clearTimeout(remote._idleTimeout); + remote._idleTimeout = setTimeout(() => { + if (remote.currentAction !== remote.idleAction) { + // Более плавный переход к idle анимации + const fadeTime = 0.3; + remote.currentAction.fadeOut(fadeTime); + remote.idleAction.reset().fadeIn(fadeTime).play(); + remote.currentAction = remote.idleAction; + } + }, 500); + + // Update voice chat volume based on distance + if (voiceConnections.current[data.playerId]) { + const dist = player.position.distanceTo(newPos); + const maxDist = 50; + const volume = Math.max(0, 1 - dist / maxDist); + voiceConnections.current[data.playerId].audioElement.volume = volume; + } + }); + + socket.on('newPlayer', (data) => { + console.log('newPlayer', data); + const { playerId, x, z, avatarURL, gender, firstName, lastName } = data; + + // Проверяем, не существует ли уже игрок с таким ID + if (remotePlayers[playerId]) { + console.log(`Игрок ${playerId} уже существует, обновляем позицию`); + // Обновляем позицию существующего игрока + remotePlayers[playerId].model.position.set(x, 0, z); + return; + } + + // Если мы сейчас внутри интерьера, показывать новых игроков следует только когда они тоже будут в нашем списке currentPlayers, + // который уже фильтруется сервером по interiorId. Здесь просто добавляем как обычно. + addOtherPlayer(playerId, x, z, avatarURL, gender, firstName, lastName); + }); + + socket.on('playerDisconnected', (id) => { + if (remotePlayers[id]) { + scene.remove(remotePlayers[id].model); + delete remotePlayers[id]; + } + if (voiceIcons.current[id]) { + delete voiceIcons.current[id]; + } + cleanupVoiceConnection(id); + }); + + + + + // Throttling для колеса мыши + let wheelTimeout = null; + + function onMouseWheel(e) { + e.preventDefault(); + + // Throttling - обрабатываем только каждые 16ms (60fps) + if (wheelTimeout) return; + + wheelTimeout = setTimeout(() => { + wheelTimeout = null; + }, 16); + + const delta = -e.deltaY * 0.001; + + if (e.ctrlKey) { + cameraPitchOffset = THREE.MathUtils.clamp( + cameraPitchOffset + delta, + -maxPitch, + maxPitch + ); + } else { + if (cameraRef.current === orthoCamRef.current) { + zoom = THREE.MathUtils.clamp(zoom * (1 + delta), minZoom, maxZoom); + orthoCamRef.current.zoom = zoom; + orthoCamRef.current.updateProjectionMatrix(); + } + } + } + + // Throttling для движения мыши + let mouseMoveTimeout = null; + + function onMouseLookMove(e) { + if (!isInInteriorRef.current || cameraRef.current !== fpCamRef.current || !playerRef.current) return; + if (altHeldRef.current) return; // при зажатом Alt не вращаем камеру + + // Throttling - обрабатываем только каждые 8ms (120fps для более плавного движения) + if (mouseMoveTimeout) return; + + mouseMoveTimeout = setTimeout(() => { + mouseMoveTimeout = null; + }, 8); + + const movementX = e.movementX || e.mozMovementX || e.webkitMovementX || 0; + const movementY = e.movementY || e.mozMovementY || e.webkitMovementY || 0; + + // Уменьшаем чувствительность для более плавного движения + const sensitivity = 0.0015; + + // В интерьере поворачиваем только камеру, не игрока + if (isInInteriorRef.current) { + // Поворачиваем камеру по горизонтали (влево-вправо) + const yawDelta = -movementX * sensitivity; + const currentYaw = playerRef.current.rotation.y; + playerRef.current.rotation.y = currentYaw + yawDelta; + + // Поворачиваем камеру по вертикали (вверх-вниз) + const pitchDelta = -movementY * sensitivity; + fpPitchRef.current = THREE.MathUtils.clamp( + fpPitchRef.current + pitchDelta, + -Math.PI / 2 + 0.1, + Math.PI / 2 - 0.1 + ); + } else { + // В обычном режиме поворачиваем игрока + playerRef.current.rotation.y -= movementX * sensitivity; + fpPitchRef.current = THREE.MathUtils.clamp( + fpPitchRef.current - movementY * sensitivity, + -Math.PI / 2 + 0.1, + Math.PI / 2 - 0.1 + ); + } + } + + async function init() { + console.log('[DEBUG] init вызван'); + + // Проверяем, что THREE загружен + if (!THREE) { + console.error('THREE.js не загружен'); + return; + } + + // Проверяем, что THREE.Clock доступен + if (!THREE.Clock) { + console.error('THREE.Clock не доступен'); + return; + } + + // Проверяем, что THREE.Scene доступен + if (!THREE.Scene) { + console.error('THREE.Scene не доступен'); + return; + } + + scene = new THREE.Scene(); + //scene.fog = new THREE.FogExp2(0xcce0ff, 0.002); + sceneRef.current = scene; + const aspect = window.innerWidth / window.innerHeight; + const d = 200; + + // Проверяем, что THREE.OrthographicCamera доступен + if (!THREE.OrthographicCamera) { + console.error('THREE.OrthographicCamera не доступен'); + return; + } + + // Проверяем, что THREE.PerspectiveCamera доступен + if (!THREE.PerspectiveCamera) { + console.error('THREE.PerspectiveCamera не доступен'); + return; + } + + orthoCamera = new THREE.OrthographicCamera(-d * aspect, d * aspect, d, -d, 1, 1000); + orthoCamera.position.set(200, 200, 200); + orthoCamera.zoom = zoom; + orthoCamera.updateProjectionMatrix(); + orthoCamera.lookAt(scene.position); + + fpCamera = new THREE.PerspectiveCamera(75, aspect, 0.1, 1000); + + cameraRef.current = orthoCamera; + orthoCamRef.current = orthoCamera; + fpCamRef.current = fpCamera; + + // Проверяем поддержку WebGL + if (!window.WebGLRenderingContext) { + console.error('WebGL не поддерживается в этом браузере'); + return; + } + + // Проверяем, что THREE.WebGLRenderer доступен + if (!THREE.WebGLRenderer) { + console.error('THREE.WebGLRenderer не доступен'); + return; + } + + try { + renderer = new THREE.WebGLRenderer({ + antialias: true, + alpha: true, + preserveDrawingBuffer: false + }); } catch (error) { - console.error(`Ошибка загрузки NPC ${npc.id}:`, error); + console.error('Ошибка создания WebGL renderer:', error); + // Попытка создать renderer без antialias + try { + renderer = new THREE.WebGLRenderer({ + antialias: false, + alpha: true, + preserveDrawingBuffer: false + }); + } catch (secondError) { + console.error('Не удалось создать WebGL renderer даже без antialias:', secondError); + return; + } + } + renderer.setSize(window.innerWidth, window.innerHeight); + renderer.setClearColor(0x87CEEB, 1); // Голубое небо + renderer.shadowMap.enabled = true; + renderer.shadowMap.type = THREE.PCFSoftShadowMap; + renderer.outputColorSpace = THREE.SRGBColorSpace; + renderer.toneMapping = THREE.ACESFilmicToneMapping; + renderer.toneMappingExposure = 1.0; + rendererRef.current = renderer; + + if (mountRef.current) { + mountRef.current.appendChild(renderer.domElement); + } else { + console.error('mountRef.current не найден'); + return; + } + + if (renderer && renderer.domElement) { + renderer.domElement.addEventListener('wheel', onMouseWheel, { passive: false }); + renderer.domElement.addEventListener('mousemove', onMouseLookMove); + } else { + console.error('renderer или renderer.domElement не найден'); + return; + } + + // Pointer lock больше не используется в интерьере — курсор всегда активен + + // Проверяем, что THREE.PlaneGeometry доступен + if (!THREE.PlaneGeometry) { + console.error('THREE.PlaneGeometry не доступен'); + return; + } + + // Проверяем, что THREE.MeshBasicMaterial доступен + if (!THREE.MeshBasicMaterial) { + console.error('THREE.MeshBasicMaterial не доступен'); + return; + } + + const planeGeometry = new THREE.PlaneGeometry(territorySize, territorySize); + const planeMaterial = new THREE.MeshBasicMaterial({ + color: 0x00aa00, + transparent: true, + opacity: 0, // невидим + depthWrite: false // не трогает Z-буфер + }); + + // Проверяем, что THREE.Mesh доступен + if (!THREE.Mesh) { + console.error('THREE.Mesh не доступен'); + return; + } + + groundPlane = new THREE.Mesh(planeGeometry, planeMaterial); + groundPlane.rotation.x = -Math.PI / 2; + scene.add(groundPlane); + groundRef.current = groundPlane; + + // Проверяем, что THREE.AmbientLight доступен + if (!THREE.AmbientLight) { + console.error('THREE.AmbientLight не доступен'); + return; + } + + // Проверяем, что THREE.DirectionalLight доступен + if (!THREE.DirectionalLight) { + console.error('THREE.DirectionalLight не доступен'); + return; + } + + const ambientLight = new THREE.AmbientLight(0xffffff, 0.5); + scene.add(ambientLight); + const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8); + directionalLight.position.set(50, 100, 50); + scene.add(directionalLight); + + // Проверяем, что THREE.SphereGeometry доступен + if (!THREE.SphereGeometry) { + console.error('THREE.SphereGeometry не доступен'); + return; + } + + const markerGeometry = new THREE.SphereGeometry(0.5, 16, 16); + const markerMaterial = new THREE.MeshBasicMaterial({ color: 0xffff00 }); + destinationMarker = new THREE.Mesh(markerGeometry, markerMaterial); + destinationMarker.visible = false; + scene.add(destinationMarker); + + // Проверяем, что THREE.LoadingManager доступен + if (!THREE.LoadingManager) { + console.error('THREE.LoadingManager не доступен'); + return; + } + + // Проверяем, что THREE.TextureLoader доступен + if (!THREE.TextureLoader) { + console.error('THREE.TextureLoader не доступен'); + return; + } + + const loadingManager = new THREE.LoadingManager(() => { + console.log("Все текстуры загружены"); + }); + const textureLoader = new THREE.TextureLoader(loadingManager); + const baseTexture = textureLoader.load('textures/base.png', + // onLoad callback + (texture) => { + console.log('Текстура base.png загружена успешно'); + if (THREE.SRGBColorSpace) { + texture.colorSpace = THREE.SRGBColorSpace; + } + }, + // onProgress callback + (progress) => { + console.log('Прогресс загрузки текстуры:', progress); + }, + // onError callback + (error) => { + console.error('Ошибка загрузки текстуры base.png:', error); + // Создаем материал без текстуры в случае ошибки + if (THREE.MeshStandardMaterial) { + customMaterial = new THREE.MeshStandardMaterial({ + color: 0x808080 + }); + } else { + console.error('THREE.MeshStandardMaterial не доступен'); + } + } + ); + + // Проверяем, что THREE.MeshStandardMaterial доступен + if (!THREE.MeshStandardMaterial) { + console.error('THREE.MeshStandardMaterial не доступен'); + return; + } + + customMaterial = new THREE.MeshStandardMaterial({ + map: baseTexture, + roughness: 0.5, + metalness: 0.1 + }); + + + const npcMixersArray = []; + // Добавление персонажей + const npcData = [ + { id: 'bartender', model: '/models/npc/bartender.glb', position: [0, 0, 10] }, + { id: 'guard', model: '/models/npc/guard.glb', position: [0, 0, 5] }, + { id: 'Adventurer', model: '/models/npc/galina.glb', position: [-16.5, -100, -68.8] }, + { id: 'BeachCharacter', model: '/models/npc/BeachCharacter.glb', position: [0, 0, 3] }, + { id: 'Oxranik', model: '/models/npc/Oxranik.glb', position: [0, 0, -3] }, + { id: 'Computer', model: '/models/npc/Computer.glb', position: [0.1, 0.1, 2.1] } + ]; + for (const npc of npcData) { + try { + const gltf = await gltfLoader.loadAsync(npc.model); + const model = gltf.scene; + + // Проверяем и исправляем материалы модели + model.traverse((child) => { + if (child.isMesh && child.material) { + if (Array.isArray(child.material)) { + child.material.forEach(mat => { + if (!mat || !mat.isMaterial) { + console.warn(`Неправильный материал в ${npc.id}, заменяем на стандартный`); + if (THREE.MeshStandardMaterial) { + child.material = new THREE.MeshStandardMaterial({ color: 0x808080 }); + } else { + console.error('THREE.MeshStandardMaterial не доступен для замены материала'); + } + } + }); + } else if (!child.material.isMaterial) { + console.warn(`Неправильный материал в ${npc.id}, заменяем на стандартный`); + if (THREE.MeshStandardMaterial) { + child.material = new THREE.MeshStandardMaterial({ color: 0x808080 }); + } else { + console.error('THREE.MeshStandardMaterial не доступен для замены материала'); + } + } + } + }); + + model.position.set(...npc.position); + model.userData.npcId = npc.id; + model.userData.isNpc = true; + + // Добавляем метку с именем + let label; + if (npc.id == 'bartender') { + label = createPlayerLabel('Серега Пират'); + } + else if (npc.id == 'guard') { + label = createPlayerLabel('Саша Белый'); + } + else if (npc.id == 'Adventurer') { + label = createPlayerLabel('Галина'); + } + else if (npc.id == 'BeachCharacter') { + label = createPlayerLabel('Костя Ключник'); + } + else if (npc.id == 'Oxranik') { + label = createPlayerLabel('Охранник'); + } + + if (label) { + label.position.set(0, 2.2, 0); + model.add(label); + } + + model.rotateY(Math.PI); // Развернуть персонажа + scene.add(model); + npcMeshes.push(model); // Правильное добавление в массив + npcMeshesRef.current.push(model); + cityMeshesRef.current.push(model); + + if (npc.id == 'Computer') { + model.scale.set(0.001, 0.001, 0.001); + } + + if (npc.id == 'Oxranik') { + model.scale.set(0.2, 0.2, 0.2); + } + + } catch (error) { + console.error(`Ошибка загрузки NPC ${npc.id}:`, error); + } + } + // Загрузка объектов города из базы данных + let cityObjects = []; + try { + const profile = JSON.parse(sessionStorage.getItem('user_profile') || '{}'); + const cityId = profile.last_city_id || 1; + const token = localStorage.getItem('token'); + const res = await fetch(`/api/cities/${cityId}/objects`, { + headers: { Authorization: `Bearer ${token}` } + }); + cityObjects = await res.json(); + } catch (e) { + console.error('[DEBUG] Ошибка загрузки объектов города:', e); + cityObjects = []; + } + + cityObjectsDataRef.current = cityObjects; + let interiors = []; + try { + const token = localStorage.getItem('token'); + const resInt = await fetch('/api/interiors', { headers: { Authorization: `Bearer ${token}` } }); + interiors = await resInt.json(); + } catch (e) { + console.error('Ошибка загрузки списка интерьеров', e); + } + interiorsDataRef.current = interiors; + updateCityObjectVisibility(); + + window.addEventListener('keydown', onKeyDown); + window.addEventListener('keyup', onKeyUp); + renderer.domElement.addEventListener('pointerdown', onDocumentMouseDown); + renderer.domElement.addEventListener('mousemove', onMouseLookMove); + + try { + // Проверяем, что avatarUrl существует и валиден + let modelUrl = avatarUrl; + if (!avatarUrl || avatarUrl === 'undefined' || avatarUrl === 'null') { + console.warn('avatarUrl не определен, используем fallback модель'); + modelUrl = '/models/character.glb'; + } + + console.log('Загружаем модель игрока:', modelUrl); + const gltf = await loadPlayerModel(modelUrl); + player = gltf.scene; + scene.add(player); + playerRef.current = player; + player.scale.set(1, 1, 1); + player.position.set(0, 0, 0); + + const profile = JSON.parse(sessionStorage.getItem('user_profile') || '{}'); + const myName = `${profile.firstName || ''} ${profile.lastName || ''}`.trim(); + + // Устанавливаем имя игрока в mountRef для отладки + if (mountRef.current) { + mountRef.current.setAttribute('data-player-name', myName); + } + + const nameLabel = createPlayerLabel(myName); + nameLabel.position.set(0, 2.2, 0); + player.add(nameLabel); + + mixer = new THREE.AnimationMixer(player); + + const isFemale = gender === 'female'; + const animGender = isFemale ? 'feminine' : 'masculine'; + + const idlePath = `/animations/${animGender}/glb/idle/${isFemale ? 'F_Standing_Idle_001.glb' : 'M_Standing_Idle_001.glb' + }`; + const walkPath = `/animations/${animGender}/glb/locomotion/${isFemale ? 'F_Walk_002.glb' : 'M_Walk_001.glb' + }`; + + console.log('Загружаем анимации:', { idlePath, walkPath }); + + const [idleGltf, walkGltf] = await Promise.all([ + animLoader.loadAsync(idlePath).catch(err => { + console.error('Ошибка загрузки idle анимации:', err); + throw err; + }), + animLoader.loadAsync(walkPath).catch(err => { + console.error('Ошибка загрузки walk анимации:', err); + throw err; + }) + ]); + + idleGltf.animations.forEach(stripPositionTracks); + walkGltf.animations.forEach(stripPositionTracks); + + console.log('Idle GLB анимации:', idleGltf.animations); + console.log('Walk GLB анимации:', walkGltf.animations); + + // Проверяем, что анимации загружены + if (idleGltf.animations.length === 0) { + console.warn('Idle анимации не найдены, создаем пустую анимацию'); + const emptyClip = new THREE.AnimationClip('idle', 1, []); + idleGltf.animations.push(emptyClip); + } + + if (walkGltf.animations.length === 0) { + console.warn('Walk анимации не найдены, создаем пустую анимацию'); + const emptyClip = new THREE.AnimationClip('walk', 1, []); + walkGltf.animations.push(emptyClip); + } + + idleAction = mixer.clipAction(idleGltf.animations[0], player); + walkAction = mixer.clipAction(walkGltf.animations[0], player); + + // синхронизация темпа шага с линейной скоростью + walkAction.setEffectiveTimeScale(moveSpeed / WALK_ANIM_SPEED_MPS); + + idleAction.play(); + currentAction = idleAction; + + updateCameraFollow(); + + // Не отправляем здесь newPlayer — делаем это централизованно после currentPlayers + } catch (err) { + console.error("Ошибка загрузки модели игрока:", err); + console.error("Детали ошибки:", { + avatarUrl, + gender, + error: err.message, + stack: err.stack + }); + + // Создаем простую модель-заглушку в случае ошибки + console.log("Создаем fallback модель для игрока"); + + // Пробуем загрузить локальную модель + try { + const fallbackGltf = await loadPlayerModel('/models/character.glb'); + player = fallbackGltf.scene; + console.log("Fallback модель загружена успешно"); + } catch (fallbackErr) { + console.error("Ошибка загрузки fallback модели:", fallbackErr); + + // Создаем простую геометрию + const fallbackGeometry = new THREE.BoxGeometry(1, 2, 1); + const fallbackMaterial = new THREE.MeshBasicMaterial({ color: 0xff0000 }); + player = new THREE.Mesh(fallbackGeometry, fallbackMaterial); + console.log("Создана простая модель-заглушка"); + } + + scene.add(player); + playerRef.current = player; + player.scale.set(1, 1, 1); + player.position.set(0, 0, 0); + + // Создаем простые анимации для fallback + mixer = new THREE.AnimationMixer(player); + const emptyIdleClip = new THREE.AnimationClip('idle', 1, []); + const emptyWalkClip = new THREE.AnimationClip('walk', 1, []); + + idleAction = mixer.clipAction(emptyIdleClip, player); + walkAction = mixer.clipAction(emptyWalkClip, player); + + idleAction.play(); + currentAction = idleAction; + + updateCameraFollow(); + + // Отправляем данные о новом игроке + const profile = JSON.parse(sessionStorage.getItem('user_profile') || '{}'); + socketRef.current?.emit('newPlayer', { + x: player.position.x, + z: player.position.z, + avatarURL: avatarUrl || '/models/character.glb', + firstName: profile.firstName, + lastName: profile.lastName, + userId: profile.id + }); } } - // Загрузка объектов города из базы данных - let cityObjects = []; - try { - const profile = JSON.parse(sessionStorage.getItem('user_profile') || '{}'); - const cityId = profile.last_city_id || 1; - const token = localStorage.getItem('token'); - const res = await fetch(`/api/cities/${cityId}/objects`, { - headers: { Authorization: `Bearer ${token}` } - }); - cityObjects = await res.json(); - } catch (e) { - console.error('[DEBUG] Ошибка загрузки объектов города:', e); - cityObjects = []; - } - cityObjectsDataRef.current = cityObjects; - let interiors = []; - try { - const token = localStorage.getItem('token'); - const resInt = await fetch('/api/interiors', { headers: { Authorization: `Bearer ${token}` } }); - interiors = await resInt.json(); - } catch (e) { - console.error('Ошибка загрузки списка интерьеров', e); - } - interiorsDataRef.current = interiors; - updateCityObjectVisibility(); - - window.addEventListener('keydown', onKeyDown); - window.addEventListener('keyup', onKeyUp); - renderer.domElement.addEventListener('pointerdown', onDocumentMouseDown); - renderer.domElement.addEventListener('mousemove', onMouseLookMove); - - try { - // Проверяем, что avatarUrl существует и валиден - let modelUrl = avatarUrl; - if (!avatarUrl || avatarUrl === 'undefined' || avatarUrl === 'null') { - console.warn('avatarUrl не определен, используем fallback модель'); - modelUrl = '/models/character.glb'; - } - - console.log('Загружаем модель игрока:', modelUrl); - const gltf = await loadPlayerModel(modelUrl); - player = gltf.scene; - scene.add(player); - playerRef.current = player; - player.scale.set(1, 1, 1); - player.position.set(0, 0, 0); - - const profile = JSON.parse(sessionStorage.getItem('user_profile') || '{}'); - const myName = `${profile.firstName || ''} ${profile.lastName || ''}`.trim(); - - // Устанавливаем имя игрока в mountRef для отладки - if (mountRef.current) { - mountRef.current.setAttribute('data-player-name', myName); + function stripPositionTracks(clip) { + clip.tracks = clip.tracks.filter(track => !track.name.endsWith('.position')); + return clip; } - const nameLabel = createPlayerLabel(myName); - nameLabel.position.set(0, 2.2, 0); - player.add(nameLabel); + function computePath(fromVec3, toVec3) { + const startX = Math.floor((fromVec3.x + boundary) / nodeSize); + const startZ = Math.floor((fromVec3.z + boundary) / nodeSize); + const endX = Math.floor((toVec3.x + boundary) / nodeSize); + const endZ = Math.floor((toVec3.z + boundary) / nodeSize); - mixer = new THREE.AnimationMixer(player); + const finder = new PF.AStarFinder({ + allowDiagonal: true, + dontCrossCorners: true, + diagonalMovement: PF.DiagonalMovement.OnlyWhenNoObstacles + }); + if (!pathfinderGrid) { + console.warn('Pathfinder grid not ready'); + return []; + } + const gridClone = pathfinderGrid.clone(); - const isFemale = gender === 'female'; - const animGender = isFemale ? 'feminine' : 'masculine'; + if (!gridClone.isWalkableAt(startX, startZ)) { + gridClone.setWalkableAt(startX, startZ, true); + } - const idlePath = `/animations/${animGender}/glb/idle/${ - isFemale ? 'F_Standing_Idle_001.glb' : 'M_Standing_Idle_001.glb' - }`; - const walkPath = `/animations/${animGender}/glb/locomotion/${ - isFemale ? 'F_Walk_002.glb' : 'M_Walk_001.glb' - }`; + if (!gridClone.isWalkableAt(endX, endZ)) { + gridClone.setWalkableAt(endX, endZ, true); + } - console.log('Загружаем анимации:', { idlePath, walkPath }); - - const [idleGltf, walkGltf] = await Promise.all([ - animLoader.loadAsync(idlePath).catch(err => { - console.error('Ошибка загрузки idle анимации:', err); - throw err; - }), - animLoader.loadAsync(walkPath).catch(err => { - console.error('Ошибка загрузки walk анимации:', err); - throw err; - }) - ]); + const rawPath = finder.findPath(startX, startZ, endX, endZ, gridClone); + if (!rawPath.length) return []; - idleGltf.animations.forEach(stripPositionTracks); - walkGltf.animations.forEach(stripPositionTracks); - - console.log('Idle GLB анимации:', idleGltf.animations); - console.log('Walk GLB анимации:', walkGltf.animations); - - // Проверяем, что анимации загружены - if (idleGltf.animations.length === 0) { - console.warn('Idle анимации не найдены, создаем пустую анимацию'); - const emptyClip = new THREE.AnimationClip('idle', 1, []); - idleGltf.animations.push(emptyClip); - } - - if (walkGltf.animations.length === 0) { - console.warn('Walk анимации не найдены, создаем пустую анимацию'); - const emptyClip = new THREE.AnimationClip('walk', 1, []); - walkGltf.animations.push(emptyClip); + const smooth = PF.Util.smoothenPath(gridClone, rawPath); + return smooth.map(([x, z]) => new THREE.Vector3( + x * nodeSize - boundary + nodeSize / 2, + fromVec3.y, + z * nodeSize - boundary + nodeSize / 2 + )); } - idleAction = mixer.clipAction(idleGltf.animations[0], player); - walkAction = mixer.clipAction(walkGltf.animations[0], player); + function buildPathfindingGrid() { + pathfinderGrid = new PF.Grid(gridSize, gridSize); - // синхронизация темпа шага с линейной скоростью - walkAction.setEffectiveTimeScale(moveSpeed / WALK_ANIM_SPEED_MPS); + obstacles.forEach(o => { + const box = new THREE.Box3().setFromObject(o.mesh); - idleAction.play(); - currentAction = idleAction; + let minX = Math.floor((box.min.x + boundary) / nodeSize); + let maxX = Math.floor((box.max.x + boundary) / nodeSize); + let minZ = Math.floor((box.min.z + boundary) / nodeSize); + let maxZ = Math.floor((box.max.z + boundary) / nodeSize); - updateCameraFollow(); + minX = Math.max(0, Math.min(gridSize - 1, minX)); + maxX = Math.max(0, Math.min(gridSize - 1, maxX)); + minZ = Math.max(0, Math.min(gridSize - 1, minZ)); + maxZ = Math.max(0, Math.min(gridSize - 1, maxZ)); - // Не отправляем здесь newPlayer — делаем это централизованно после currentPlayers - } catch (err) { - console.error("Ошибка загрузки модели игрока:", err); - console.error("Детали ошибки:", { - avatarUrl, - gender, - error: err.message, - stack: err.stack - }); - - // Создаем простую модель-заглушку в случае ошибки - console.log("Создаем fallback модель для игрока"); - - // Пробуем загрузить локальную модель - try { - const fallbackGltf = await loadPlayerModel('/models/character.glb'); - player = fallbackGltf.scene; - console.log("Fallback модель загружена успешно"); - } catch (fallbackErr) { - console.error("Ошибка загрузки fallback модели:", fallbackErr); - - // Создаем простую геометрию - const fallbackGeometry = new THREE.BoxGeometry(1, 2, 1); - const fallbackMaterial = new THREE.MeshBasicMaterial({ color: 0xff0000 }); - player = new THREE.Mesh(fallbackGeometry, fallbackMaterial); - console.log("Создана простая модель-заглушка"); - } - - scene.add(player); - playerRef.current = player; - player.scale.set(1, 1, 1); - player.position.set(0, 0, 0); - - // Создаем простые анимации для fallback - mixer = new THREE.AnimationMixer(player); - const emptyIdleClip = new THREE.AnimationClip('idle', 1, []); - const emptyWalkClip = new THREE.AnimationClip('walk', 1, []); - - idleAction = mixer.clipAction(emptyIdleClip, player); - walkAction = mixer.clipAction(emptyWalkClip, player); - - idleAction.play(); - currentAction = idleAction; - - updateCameraFollow(); - - // Отправляем данные о новом игроке - const profile = JSON.parse(sessionStorage.getItem('user_profile') || '{}'); - socketRef.current?.emit('newPlayer', { - x: player.position.x, - z: player.position.z, - avatarURL: avatarUrl || '/models/character.glb', - firstName: profile.firstName, - lastName: profile.lastName, - userId: profile.id - }); - } - } - - function stripPositionTracks(clip) { - clip.tracks = clip.tracks.filter(track => !track.name.endsWith('.position')); - return clip; - } - - function computePath(fromVec3, toVec3) { - const startX = Math.floor((fromVec3.x + boundary) / nodeSize); - const startZ = Math.floor((fromVec3.z + boundary) / nodeSize); - const endX = Math.floor((toVec3.x + boundary) / nodeSize); - const endZ = Math.floor((toVec3.z + boundary) / nodeSize); - - const finder = new PF.AStarFinder({ - allowDiagonal: true, - dontCrossCorners: true, - diagonalMovement: PF.DiagonalMovement.OnlyWhenNoObstacles - }); - if (!pathfinderGrid) { - console.warn('Pathfinder grid not ready'); - return []; - } - const gridClone = pathfinderGrid.clone(); - - if (!gridClone.isWalkableAt(startX, startZ)) { - gridClone.setWalkableAt(startX, startZ, true); - } - - if (!gridClone.isWalkableAt(endX, endZ)) { - gridClone.setWalkableAt(endX, endZ, true); - } - - const rawPath = finder.findPath(startX, startZ, endX, endZ, gridClone); - if (!rawPath.length) return []; - - const smooth = PF.Util.smoothenPath(gridClone, rawPath); - return smooth.map(([x, z]) => new THREE.Vector3( - x * nodeSize - boundary + nodeSize / 2, - fromVec3.y, - z * nodeSize - boundary + nodeSize / 2 - )); - } - - function buildPathfindingGrid() { - pathfinderGrid = new PF.Grid(gridSize, gridSize); - - obstacles.forEach(o => { - const box = new THREE.Box3().setFromObject(o.mesh); - - let minX = Math.floor((box.min.x + boundary) / nodeSize); - let maxX = Math.floor((box.max.x + boundary) / nodeSize); - let minZ = Math.floor((box.min.z + boundary) / nodeSize); - let maxZ = Math.floor((box.max.z + boundary) / nodeSize); - - minX = Math.max(0, Math.min(gridSize - 1, minX)); - maxX = Math.max(0, Math.min(gridSize - 1, maxX)); - minZ = Math.max(0, Math.min(gridSize - 1, minZ)); - maxZ = Math.max(0, Math.min(gridSize - 1, maxZ)); - - for (let x = minX; x <= maxX; x++) { - for (let z = minZ; z <= maxZ; z++) { - pathfinderGrid.setWalkableAt(x, z, false); - } - } - }); - } - - function loadCityObject(obj) { - console.log('loadCityObject вызвана для объекта:', { - id: obj.id, - name: obj.name, - textures: obj.textures, - model_url: obj.model_url - }); - - gltfLoader.load( - obj.model_url, - (gltf) => { - const model = gltf.scene; - - // Проверяем и исправляем материалы модели - model.traverse((child) => { - if (child.isMesh && child.material) { - if (Array.isArray(child.material)) { - child.material.forEach(mat => { - if (!mat || !mat.isMaterial) { - console.warn(`Неправильный материал в объекте ${obj.name}, заменяем на стандартный`); - child.material = new THREE.MeshStandardMaterial({ color: 0x808080 }); - } - }); - } else if (!child.material.isMaterial) { - console.warn(`Неправильный материал в объекте ${obj.name}, заменяем на стандартный`); - child.material = new THREE.MeshStandardMaterial({ color: 0x808080 }); - } - } - }); - - model.userData = { - id: obj.id, - type: obj.name, - organizationId: obj.organization_id, - rent: obj.rent, - tax: obj.tax - }; - // Применяем масштаб из БД, если есть - const sx = (obj.scale_x ?? 1) || 1; - const sy = (obj.scale_y ?? 1) || 1; - const sz = (obj.scale_z ?? 1) || 1; - model.scale.set(sx, sy, sz); - model.position.set(obj.pos_x, obj.pos_y, obj.pos_z); - model.rotation.set(obj.rot_x, obj.rot_y, obj.rot_z); - - console.log('Обрабатываем материалы для объекта:', obj.name); - - // Обрабатываем материалы в зависимости от поля textures - model.traverse(child => { - if (child.isMesh) { - console.log('Найден меш в объекте:', obj.name, { - hasMaterial: !!child.material, - materialType: child.material ? child.material.type : 'none' - }); - - // Сохраняем оригинальные материалы для интерьеров - if (obj.name && obj.name.toLowerCase().includes('interior')) { - console.log('Объект интерьера - оставляем оригинальные материалы'); - // Для интерьеров оставляем оригинальные материалы - if (child.material) { - child.material.needsUpdate = true; + for (let x = minX; x <= maxX; x++) { + for (let z = minZ; z <= maxZ; z++) { + pathfinderGrid.setWalkableAt(x, z, false); + } } - } else { - // Проверяем поле textures - if (obj.textures && obj.textures !== '-') { - console.log('Загружаем текстурпак для объекта:', obj.name, 'текстурпак:', obj.textures); - - // Для citypack.json используем тот же принцип, что в MapEditor: единый стандартный материал с baseColor - if (obj.textures === '/packs/citypack.json') { - // Присваиваем клон стандартного материала с базовой текстурой из пака - const forceReplace = true; - loadTexturePackForMesh(obj.textures, child, forceReplace); + }); + } + + function loadCityObject(obj) { + console.log('loadCityObject вызвана для объекта:', { + id: obj.id, + name: obj.name, + textures: obj.textures, + model_url: obj.model_url + }); + + gltfLoader.load( + obj.model_url, + (gltf) => { + const model = gltf.scene; + + // Проверяем и исправляем материалы модели + model.traverse((child) => { + if (child.isMesh && child.material) { + if (Array.isArray(child.material)) { + child.material.forEach(mat => { + if (!mat || !mat.isMaterial) { + console.warn(`Неправильный материал в объекте ${obj.name}, заменяем на стандартный`); + child.material = new THREE.MeshStandardMaterial({ color: 0x808080 }); + } + }); + } else if (!child.material.isMaterial) { + console.warn(`Неправильный материал в объекте ${obj.name}, заменяем на стандартный`); + child.material = new THREE.MeshStandardMaterial({ color: 0x808080 }); + } + } + }); + + model.userData = { + id: obj.id, + type: obj.name, + organizationId: obj.organization_id, + rent: obj.rent, + tax: obj.tax + }; + // Применяем масштаб из БД, если есть + const sx = (obj.scale_x ?? 1) || 1; + const sy = (obj.scale_y ?? 1) || 1; + const sz = (obj.scale_z ?? 1) || 1; + model.scale.set(sx, sy, sz); + model.position.set(obj.pos_x, obj.pos_y, obj.pos_z); + model.rotation.set(obj.rot_x, obj.rot_y, obj.rot_z); + + console.log('Обрабатываем материалы для объекта:', obj.name); + + // Обрабатываем материалы в зависимости от поля textures + model.traverse(child => { + if (child.isMesh) { + console.log('Найден меш в объекте:', obj.name, { + hasMaterial: !!child.material, + materialType: child.material ? child.material.type : 'none' + }); + + // Сохраняем оригинальные материалы для интерьеров + if (obj.name && obj.name.toLowerCase().includes('interior')) { + console.log('Объект интерьера - оставляем оригинальные материалы'); + // Для интерьеров оставляем оригинальные материалы + if (child.material) { + child.material.needsUpdate = true; + } + } else { + // Проверяем поле textures + if (obj.textures && obj.textures !== '-') { + console.log('Загружаем текстурпак для объекта:', obj.name, 'текстурпак:', obj.textures); + + // Для citypack.json используем тот же принцип, что в MapEditor: единый стандартный материал с baseColor + if (obj.textures === '/packs/citypack.json') { + // Присваиваем клон стандартного материала с базовой текстурой из пака + const forceReplace = true; + loadTexturePackForMesh(obj.textures, child, forceReplace); + } else { + loadTexturePackForMesh(obj.textures, child); + } + } else { + console.log('Оставляем встроенные текстуры для объекта:', obj.name); + // Если textures = '-' или не указано, оставляем встроенные текстуры + if (child.material) { + child.material.needsUpdate = true; + } + } + } + } + }); + + scene.add(model); + cityMeshesRef.current.push(model); + const boundingBox = new THREE.Box3().setFromObject(model); + const isCollidable = obj.collidable !== false && !/road/i.test(obj.name); + if (isCollidable) { + obstacles.push({ mesh: model, box: boundingBox }); + } + loadedCityObjectsRef.current[obj.id] = { mesh: model, data: obj }; + buildPathfindingGrid(); + }, + undefined, + (error) => console.error('Ошибка загрузки объекта', obj.name, error) + ); + } + + function unloadCityObject(id) { + const entry = loadedCityObjectsRef.current[id]; + if (!entry) return; + const { mesh } = entry; + scene.remove(mesh); + cityMeshesRef.current = cityMeshesRef.current.filter(m => m !== mesh); + obstacles = obstacles.filter(o => o.mesh !== mesh); + delete loadedCityObjectsRef.current[id]; + buildPathfindingGrid(); + } + + // Кэш для оптимизации вычислений расстояний + let lastPlayerPosition = null; + let lastVisibilityUpdate = 0; + + function updateCityObjectVisibility() { + if (!player) return; + + const p = player.position; + const now = Date.now(); + + // Проверяем, изменилась ли позиция игрока значительно + if (lastPlayerPosition && + Math.abs(lastPlayerPosition.x - p.x) < 5 && + Math.abs(lastPlayerPosition.z - p.z) < 5 && + now - lastVisibilityUpdate < 1000) { + return; // Пропускаем обновление, если игрок не двигался значительно + } + + lastPlayerPosition = p.clone(); + lastVisibilityUpdate = now; + + // Оптимизированные вычисления расстояний + const loadRadiusSq = LOAD_RADIUS * LOAD_RADIUS; + + cityObjectsDataRef.current.forEach(obj => { + const dx = obj.pos_x - p.x; + const dz = obj.pos_z - p.z; + const distSq = dx * dx + dz * dz; // Используем квадрат расстояния для избежания sqrt + + if (distSq <= loadRadiusSq) { + if (!loadedCityObjectsRef.current[obj.id]) { + console.log('Загружаем объект:', { id: obj.id, name: obj.name, textures: obj.textures }); + loadCityObject(obj); + } } else { - loadTexturePackForMesh(obj.textures, child); + if (loadedCityObjectsRef.current[obj.id]) unloadCityObject(obj.id); } - } else { - console.log('Оставляем встроенные текстуры для объекта:', obj.name); - // Если textures = '-' или не указано, оставляем встроенные текстуры - if (child.material) { - child.material.needsUpdate = true; + }); + + interiorsDataRef.current.forEach(int => { + const dx = int.pos_x - p.x; + const dz = int.pos_z - p.z; + const distSq = dx * dx + dz * dz; + + if (distSq <= loadRadiusSq) { + if (!loadedInteriorMeshesRef.current[int.id]) loadInteriorPlaceholder(int); + } else if (loadedInteriorMeshesRef.current[int.id]) { + unloadInteriorPlaceholder(int.id); + } + }); + } + + function loadInteriorPlaceholder(int) { + // Упрощённый невидимый placeholder с кликабельной зоной + const mesh = new THREE.Mesh( + new THREE.BoxGeometry(2, 2, 2), + new THREE.MeshBasicMaterial({ visible: false }) + ); + mesh.position.set(int.pos_x, int.pos_y, int.pos_z); + mesh.userData.interiorId = int.id; + scene.add(mesh); + cityMeshesRef.current.push(mesh); + loadedInteriorMeshesRef.current[int.id] = mesh; + } + + function unloadInteriorPlaceholder(id) { + const mesh = loadedInteriorMeshesRef.current[id]; + if (!mesh) return; + scene.remove(mesh); + cityMeshesRef.current = cityMeshesRef.current.filter(m => m !== mesh); + delete loadedInteriorMeshesRef.current[id]; + } + + // В функции onDocumentMouseDown заменяем существующий код на: + async function onDocumentMouseDown(event) { + if (!player) return; + if (isInInteriorRef.current) return; // disable clicks when inside + event.preventDefault(); + + const rect = renderer.domElement.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, cameraRef.current); + + // NPC + const npcHit = raycaster.intersectObjects(npcMeshes, true); + if (npcHit.length) { + let root = npcHit[0].object; + while (root.parent && !root.userData.isNpc) root = root.parent; + if (root.userData.npcId) { + if (root.userData.npcId === 'Computer') { + setShowMiniGame(true); + setPasswordCorrect(false); + setAudioUrl("/audio/firs.ogg"); + addSeregaComment("Ну чё, хакер, разберёшься?"); + } else { + loadDialog(root.userData.npcId); + } + return; } - } - } } - }); - - scene.add(model); - cityMeshesRef.current.push(model); - const boundingBox = new THREE.Box3().setFromObject(model); - const isCollidable = obj.collidable !== false && !/road/i.test(obj.name); - if (isCollidable) { - obstacles.push({ mesh: model, box: boundingBox }); - } - loadedCityObjectsRef.current[obj.id] = { mesh: model, data: obj }; - buildPathfindingGrid(); - }, - undefined, - (error) => console.error('Ошибка загрузки объекта', obj.name, error) - ); - } - function unloadCityObject(id) { - const entry = loadedCityObjectsRef.current[id]; - if (!entry) return; - const { mesh } = entry; - scene.remove(mesh); - cityMeshesRef.current = cityMeshesRef.current.filter(m => m !== mesh); - obstacles = obstacles.filter(o => o.mesh !== mesh); - delete loadedCityObjectsRef.current[id]; - buildPathfindingGrid(); - } + // Здания/объекты + const houseHit = raycaster.intersectObjects(obstacles.map(o => o.mesh).filter(Boolean), true); + if (houseHit.length) { + let obj = houseHit[0].object; + while (obj && !obj.userData.id && !obj.userData.interiorId) obj = obj.parent; + if (obj && obj.userData.id) { + setSelectedHouse(obj.userData); + return; + } + if (obj && obj.userData.interiorId) { + console.log('Клик по интерьеру:', obj.userData.interiorId); + await enterInteriorMode(obj.userData.interiorId); + return; + } + } - // Кэш для оптимизации вычислений расстояний - let lastPlayerPosition = null; - let lastVisibilityUpdate = 0; - - function updateCityObjectVisibility() { - if (!player) return; - - const p = player.position; - const now = Date.now(); - - // Проверяем, изменилась ли позиция игрока значительно - if (lastPlayerPosition && - Math.abs(lastPlayerPosition.x - p.x) < 5 && - Math.abs(lastPlayerPosition.z - p.z) < 5 && - now - lastVisibilityUpdate < 1000) { - return; // Пропускаем обновление, если игрок не двигался значительно - } - - lastPlayerPosition = p.clone(); - lastVisibilityUpdate = now; - - // Оптимизированные вычисления расстояний - const loadRadiusSq = LOAD_RADIUS * LOAD_RADIUS; - - cityObjectsDataRef.current.forEach(obj => { - const dx = obj.pos_x - p.x; - const dz = obj.pos_z - p.z; - const distSq = dx * dx + dz * dz; // Используем квадрат расстояния для избежания sqrt - - if (distSq <= loadRadiusSq) { - if (!loadedCityObjectsRef.current[obj.id]) { - console.log('Загружаем объект:', { id: obj.id, name: obj.name, textures: obj.textures }); - loadCityObject(obj); - } - } else { - if (loadedCityObjectsRef.current[obj.id]) unloadCityObject(obj.id); - } - }); - - interiorsDataRef.current.forEach(int => { - const dx = int.pos_x - p.x; - const dz = int.pos_z - p.z; - const distSq = dx * dx + dz * dz; - - if (distSq <= loadRadiusSq) { - if (!loadedInteriorMeshesRef.current[int.id]) loadInteriorPlaceholder(int); - } else if (loadedInteriorMeshesRef.current[int.id]) { - unloadInteriorPlaceholder(int.id); - } - }); - } + // 3. Проверка игроков + const remoteModels = Object.values(remotePlayers).map(r => r.model); + const playerIntersects = raycaster.intersectObjects(remoteModels, true); + if (playerIntersects.length) { + let mesh = playerIntersects[0].object; + while (mesh && !remoteModels.includes(mesh)) mesh = mesh.parent; + const entry = Object.entries(remotePlayers).find(([, r]) => r.model === mesh); + if (entry) { + const [id, r] = entry; + setSelectedPlayer({ socketId: id, firstName: r.firstName, lastName: r.lastName }); + setPlayerStats(null); + return; + } + } - function loadInteriorPlaceholder(int) { - // Упрощённый невидимый placeholder с кликабельной зоной - const mesh = new THREE.Mesh( - new THREE.BoxGeometry(2, 2, 2), - new THREE.MeshBasicMaterial({ visible: false }) - ); - mesh.position.set(int.pos_x, int.pos_y, int.pos_z); - mesh.userData.interiorId = int.id; - scene.add(mesh); - cityMeshesRef.current.push(mesh); - loadedInteriorMeshesRef.current[int.id] = mesh; - } + // Сброс выделений + setSelectedHouse(null); + setOrgMenu(null); + setSelectedPlayer(null); - function unloadInteriorPlaceholder(id) { - const mesh = loadedInteriorMeshesRef.current[id]; - if (!mesh) return; - scene.remove(mesh); - cityMeshesRef.current = cityMeshesRef.current.filter(m => m !== mesh); - delete loadedInteriorMeshesRef.current[id]; - } + // 4. Проверка земли + if (!groundPlane) { + console.warn('groundPlane ещё не готов'); + return; + } - // В функции onDocumentMouseDown заменяем существующий код на: - async function onDocumentMouseDown(event) { - if (!player) return; - if (isInInteriorRef.current) return; // disable clicks when inside - event.preventDefault(); + const groundIntersects = raycaster.intersectObject(groundPlane); + if (groundIntersects.length === 0) { + console.log("Клик не попал по плоскости"); + return; + } - const rect = renderer.domElement.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, cameraRef.current); + destination = groundIntersects[0].point.clone(); + destination.y = player.position.y; - // NPC - const npcHit = raycaster.intersectObjects(npcMeshes, true); - if (npcHit.length) { - let root = npcHit[0].object; - while (root.parent && !root.userData.isNpc) root = root.parent; - if (root.userData.npcId) { - if (root.userData.npcId === 'Computer') { - setShowMiniGame(true); - setPasswordCorrect(false); - setAudioUrl("/audio/firs.ogg"); - addSeregaComment("Ну чё, хакер, разберёшься?"); - } else { - loadDialog(root.userData.npcId); - } - return; - } - } - - // Здания/объекты - const houseHit = raycaster.intersectObjects(obstacles.map(o => o.mesh).filter(Boolean), true); - if (houseHit.length) { - let obj = houseHit[0].object; - while (obj && !obj.userData.id && !obj.userData.interiorId) obj = obj.parent; - if (obj && obj.userData.id) { - setSelectedHouse(obj.userData); - return; - } - if (obj && obj.userData.interiorId) { - console.log('Клик по интерьеру:', obj.userData.interiorId); - await enterInteriorMode(obj.userData.interiorId); - return; - } - } - - // 3. Проверка игроков - const remoteModels = Object.values(remotePlayers).map(r => r.model); - const playerIntersects = raycaster.intersectObjects(remoteModels, true); - if (playerIntersects.length) { - let mesh = playerIntersects[0].object; - while (mesh && !remoteModels.includes(mesh)) mesh = mesh.parent; - const entry = Object.entries(remotePlayers).find(([, r]) => r.model === mesh); - if (entry) { - const [id, r] = entry; - setSelectedPlayer({ socketId: id, firstName: r.firstName, lastName: r.lastName }); - setPlayerStats(null); - return; - } - } - - // Сброс выделений - setSelectedHouse(null); - setOrgMenu(null); - setSelectedPlayer(null); - - // 4. Проверка земли - if (!groundPlane) { - console.warn('groundPlane ещё не готов'); - return; - } - - const groundIntersects = raycaster.intersectObject(groundPlane); - if (groundIntersects.length === 0) { - console.log("Клик не попал по плоскости"); - return; - } - - destination = groundIntersects[0].point.clone(); - destination.y = player.position.y; - - const newPath = computePath(player.position, destination); - if (newPath.length === 0) { - console.warn("Путь не найден"); - return; - } - currentPath = newPath; - pathIndex = 0; - - if (destinationMarker) { - destinationMarker.position.copy(destination); - destinationMarker.visible = true; - } - } - - function onKeyDown(event) { - keys[event.key] = true; - if (event.key === 'Alt') altHeldRef.current = true; - - console.log('onKeyDown:', event.key, 'isInInteriorRef.current:', isInInteriorRef.current); - - // ESC больше не выходит из интерьера - - if (isInInteriorRef.current) { - console.log('Обрабатываем клавишу в интерьере:', event.key); - const k = event.key.toLowerCase(); - if (k === 'arrowup' || k === 'w') startMove('forward'); - if (k === 'arrowdown' || k === 's') startMove('backward'); - if (k === 'arrowleft' || k === 'a') startMove('left'); - if (k === 'arrowright' || k === 'd') startMove('right'); - if (k === 'q') startMove('strafeLeft'); - if (k === 'e') startMove('strafeRight'); - } - - if (event.key.toLowerCase() === 'i') { - const prof = JSON.parse(sessionStorage.getItem('user_profile') || '{}'); - socket.emit('economy:getInventory', { userId: prof.id }); - setShowInventory(v => !v); - } - - // Сбрасываем назначение только если не в интерьере - if (!isInInteriorRef.current) { - destination = null; - destinationMarker.visible = false; - } - } - - function onKeyUp(event) { - keys[event.key] = false; - if (event.key === 'Alt') altHeldRef.current = false; - if (isInInteriorRef.current) { - const k = event.key.toLowerCase(); - if (k === 'arrowup' || k === 'w') stopMove('forward'); - if (k === 'arrowdown' || k === 's') stopMove('backward'); - if (k === 'arrowleft' || k === 'a') stopMove('left'); - if (k === 'arrowright' || k === 'd') stopMove('right'); - if (k === 'q') stopMove('strafeLeft'); - if (k === 'e') stopMove('strafeRight'); - } - } - - function createPlayerLabel(text) { - const canvas = document.createElement('canvas'); - canvas.width = 512; // Увеличиваем размер canvas - canvas.height = 128; - const ctx = canvas.getContext('2d'); - - // Добавляем фон для лучшей видимости - ctx.fillStyle = 'rgba(0, 0, 0, 0.7)'; - ctx.fillRect(0, 0, canvas.width, canvas.height); - - const fontSize = 32; // Увеличиваем размер шрифта - ctx.fillStyle = 'white'; - ctx.font = `bold ${fontSize}px Arial`; - - ctx.textAlign = 'center'; - ctx.textBaseline = 'middle'; - - // Добавляем обводку для лучшей видимости - ctx.strokeStyle = 'black'; - ctx.lineWidth = 2; - ctx.strokeText(text, canvas.width / 2, canvas.height / 2); - ctx.fillText(text, canvas.width / 2, canvas.height / 2); - - const texture = new THREE.CanvasTexture(canvas); - texture.needsUpdate = true; - - const spriteMaterial = new THREE.SpriteMaterial({ - map: texture, - transparent: true, - depthTest: false, // Рисуем поверх всего - depthWrite: false - }); - const sprite = new THREE.Sprite(spriteMaterial); - sprite.scale.set(1, 0.25, 1); // Увеличиваем размер спрайта - - // ↓↓↓ добавь это ↓↓↓ - sprite.raycast = () => {}; - sprite.userData.isUiSprite = true; - - return sprite; - } - - function switchAnimation(newAction) { - if (!newAction || !currentAction || newAction === currentAction) return; - - // Увеличиваем время перехода для более плавной анимации - const fadeTime = 0.3; - - // Плавно убираем текущую анимацию - currentAction.fadeOut(fadeTime); - - // Плавно включаем новую анимацию - newAction.reset().fadeIn(fadeTime).play(); - - // Обновляем текущую анимацию - currentAction = newAction; - - // Синхронизируем время для избежания подлагов - if (newAction === walkAction) { - newAction.time = 0; - } - } - - function canMove(newPosition) { - const halfSize = 1; - const playerMin = new THREE.Vector2(newPosition.x - halfSize, newPosition.z - halfSize); - const playerMax = new THREE.Vector2(newPosition.x + halfSize, newPosition.z + halfSize); - - for (let i = 0; i < obstacles.length; i++) { - obstacles[i].mesh.updateMatrixWorld(); - const box = new THREE.Box3().setFromObject(obstacles[i].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)) { - return false; - } - } - return true; - } - - function updateDestinationMovement(delta) { - if (!player || currentPath.length === 0 || pathIndex >= currentPath.length) return; - - const target = currentPath[pathIndex]; - const dir = new THREE.Vector3().subVectors(target, player.position); - dir.y = 0; - const dist = dir.length(); - - const stepDistance = moveSpeed * delta; - if (dist < stepDistance) { - player.position.copy(target); - pathIndex++; - blockedTime = 0; - if (pathIndex >= currentPath.length) { - currentPath = []; - destination = null; - if (currentAction !== idleAction) { - currentAction.fadeOut(0.2); - idleAction.reset().fadeIn(0.2).play(); - currentAction = idleAction; - } - } - return; - } - - dir.normalize(); - const step = dir.clone().multiplyScalar(stepDistance); - - // Кандидаты перемещения: прямо, слайд по X, слайд по Z - const tryMoves = [ - player.position.clone().add(step), - player.position.clone().add(new THREE.Vector3(step.x, 0, 0)), - 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), - new THREE.Vector3(0, -1, 0), - 0, - 300 - ); - downRay.camera = cameraRef.current; // важное дополнение для спрайтов - - // фильтруем null/undefined - const walkables = [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); - - if (hits.length) { - pos.y = hits[0].point.y + 0.02; // лёгкий "антизалип" - } - }; - - - let moved = false; - for (const candidate of tryMoves) { - if (canMove(candidate)) { - stickToTopSurface(candidate); - player.position.copy(candidate); - moved = true; - blockedTime = 0; - break; - } - } - - 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)); - socketRef.current?.emit('playerMovement', { x: player.position.x, y: player.position.y, z: player.position.z }); - - if (currentAction !== walkAction) { - currentAction.fadeOut(0.2); - walkAction.reset().fadeIn(0.2).play(); - currentAction = walkAction; - } - } else { - // полностью заблокированы - blockedTime += delta; - - // Пробуем перепроложить путь к текущей цели, - // либо через 0.35с сдаёмся и ставим idle - if (destination && blockedTime > 0.1) { - const newPath = computePath(player.position, destination); - if (newPath.length > 0) { + const newPath = computePath(player.position, destination); + if (newPath.length === 0) { + console.warn("Путь не найден"); + return; + } currentPath = newPath; pathIndex = 0; - // оставляем walk - if (currentAction !== walkAction) { - currentAction.fadeOut(0.2); - walkAction.reset().fadeIn(0.2).play(); - currentAction = walkAction; + + if (destinationMarker) { + destinationMarker.position.copy(destination); + destinationMarker.visible = true; } - return; - } } - if (blockedTime > 0.35) { - currentPath = []; - destination = null; - if (currentAction !== idleAction) { - currentAction.fadeOut(0.2); - idleAction.reset().fadeIn(0.2).play(); - currentAction = idleAction; - } + + function onKeyDown(event) { + keys[event.key] = true; + if (event.key === 'Alt') altHeldRef.current = true; + + console.log('onKeyDown:', event.key, 'isInInteriorRef.current:', isInInteriorRef.current); + + // ESC больше не выходит из интерьера + + if (isInInteriorRef.current) { + console.log('Обрабатываем клавишу в интерьере:', event.key); + const k = event.key.toLowerCase(); + if (k === 'arrowup' || k === 'w') startMove('forward'); + if (k === 'arrowdown' || k === 's') startMove('backward'); + if (k === 'arrowleft' || k === 'a') startMove('left'); + if (k === 'arrowright' || k === 'd') startMove('right'); + if (k === 'q') startMove('strafeLeft'); + if (k === 'e') startMove('strafeRight'); + } + + if (event.key.toLowerCase() === 'i') { + const prof = JSON.parse(sessionStorage.getItem('user_profile') || '{}'); + socket.emit('economy:getInventory', { userId: prof.id }); + setShowInventory(v => !v); + } + + // Сбрасываем назначение только если не в интерьере + if (!isInInteriorRef.current) { + destination = null; + destinationMarker.visible = false; + } } - } - } - - function updateTransparency() { - if (!player) return; - - // Если мы в интерьере, не применяем прозрачность - if (isInInteriorRef.current) return; - - obstacles.forEach(obstacle => { - obstacle.mesh.traverse(child => { - if (child.isMesh && child.material) { - if (Array.isArray(child.material)) { - child.material.forEach(mat => { - if (!mat) return; - mat.transparent = false; - mat.opacity = 1.0; - mat.depthWrite = true; - mat.needsUpdate = true; - }); - } else { - child.material.transparent = false; - child.material.opacity = 1.0; - child.material.depthWrite = true; - child.material.needsUpdate = true; + function onKeyUp(event) { + keys[event.key] = false; + if (event.key === 'Alt') altHeldRef.current = false; + if (isInInteriorRef.current) { + const k = event.key.toLowerCase(); + if (k === 'arrowup' || k === 'w') stopMove('forward'); + if (k === 'arrowdown' || k === 's') stopMove('backward'); + if (k === 'arrowleft' || k === 'a') stopMove('left'); + if (k === 'arrowright' || k === 'd') stopMove('right'); + if (k === 'q') stopMove('strafeLeft'); + if (k === 'e') stopMove('strafeRight'); } - } - }); - }); - const direction = new THREE.Vector3() - .subVectors(player.position, cameraRef.current.position) - .normalize(); + } - const raycaster = new THREE.Raycaster(cameraRef.current.position, direction); - raycaster.camera = cameraRef.current; // ← ВАЖНО для спрайтов + function createPlayerLabel(text) { + const canvas = document.createElement('canvas'); + canvas.width = 512; // Увеличиваем размер canvas + canvas.height = 128; + const ctx = canvas.getContext('2d'); - const camToPlayerDist = cameraRef.current.position.distanceTo(player.position); + // Добавляем фон для лучшей видимости + ctx.fillStyle = 'rgba(0, 0, 0, 0.7)'; + ctx.fillRect(0, 0, canvas.width, canvas.height); - const obstacleMeshes = obstacles.map(ob => ob.mesh).filter(Boolean); // ← фильтр от null - if (obstacleMeshes.length === 0) return; + const fontSize = 32; // Увеличиваем размер шрифта + ctx.fillStyle = 'white'; + ctx.font = `bold ${fontSize}px Arial`; - const intersects = raycaster.intersectObjects(obstacleMeshes, true); + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; - intersects.forEach(hit => { - if (hit.object === player) return; - if (hit.distance < camToPlayerDist) { - if (hit.object.parent === scene) { - if (hit.object.isMesh && hit.object.material) { - if (Array.isArray(hit.object.material)) { - hit.object.material.forEach(mat => { - if (!mat) return; - mat.transparent = true; - mat.opacity = 0.3; - mat.depthWrite = false; - mat.needsUpdate = true; - }); - } else { - hit.object.material.transparent = true; - hit.object.material.opacity = 0.3; - hit.object.material.depthWrite = false; - hit.object.material.needsUpdate = true; - } - } - } else { - hit.object.parent.traverse(child => { - if (child.isMesh && child.material) { - if (Array.isArray(child.material)) { - child.material.forEach(mat => { - if (!mat) return; - mat.transparent = true; - mat.opacity = 0.3; - mat.depthWrite = false; - mat.needsUpdate = true; - }); - } else { - child.material.transparent = true; - child.material.opacity = 0.3; - child.material.depthWrite = false; - child.material.needsUpdate = true; - } - } + // Добавляем обводку для лучшей видимости + ctx.strokeStyle = 'black'; + ctx.lineWidth = 2; + ctx.strokeText(text, canvas.width / 2, canvas.height / 2); + ctx.fillText(text, canvas.width / 2, canvas.height / 2); + + const texture = new THREE.CanvasTexture(canvas); + texture.needsUpdate = true; + + const spriteMaterial = new THREE.SpriteMaterial({ + map: texture, + transparent: true, + depthTest: false, // Рисуем поверх всего + depthWrite: false }); - } + const sprite = new THREE.Sprite(spriteMaterial); + sprite.scale.set(1, 0.25, 1); // Увеличиваем размер спрайта + + // ↓↓↓ добавь это ↓↓↓ + sprite.raycast = () => { }; + sprite.userData.isUiSprite = true; + + return sprite; + } + + function switchAnimation(newAction) { + if (!newAction || !currentAction || newAction === currentAction) return; + + // Увеличиваем время перехода для более плавной анимации + const fadeTime = 0.3; + + // Плавно убираем текущую анимацию + currentAction.fadeOut(fadeTime); + + // Плавно включаем новую анимацию + newAction.reset().fadeIn(fadeTime).play(); + + // Обновляем текущую анимацию + currentAction = newAction; + + // Синхронизируем время для избежания подлагов + if (newAction === walkAction) { + newAction.time = 0; + } + } + + function canMove(newPosition) { + const halfSize = 1; + const playerMin = new THREE.Vector2(newPosition.x - halfSize, newPosition.z - halfSize); + const playerMax = new THREE.Vector2(newPosition.x + halfSize, newPosition.z + halfSize); + + for (let i = 0; i < obstacles.length; i++) { + obstacles[i].mesh.updateMatrixWorld(); + const box = new THREE.Box3().setFromObject(obstacles[i].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)) { + return false; + } + } + return true; + } + + function updateDestinationMovement(delta) { + if (!player || currentPath.length === 0 || pathIndex >= currentPath.length) return; + + const target = currentPath[pathIndex]; + const dir = new THREE.Vector3().subVectors(target, player.position); + dir.y = 0; + const dist = dir.length(); + + const stepDistance = moveSpeed * delta; + if (dist < stepDistance) { + player.position.copy(target); + pathIndex++; + blockedTime = 0; + if (pathIndex >= currentPath.length) { + currentPath = []; + destination = null; + if (currentAction !== idleAction) { + currentAction.fadeOut(0.2); + idleAction.reset().fadeIn(0.2).play(); + currentAction = idleAction; + } + } + return; + } + + dir.normalize(); + const step = dir.clone().multiplyScalar(stepDistance); + + // Кандидаты перемещения: прямо, слайд по X, слайд по Z + const tryMoves = [ + player.position.clone().add(step), + player.position.clone().add(new THREE.Vector3(step.x, 0, 0)), + 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), + new THREE.Vector3(0, -1, 0), + 0, + 300 + ); + downRay.camera = cameraRef.current; // важное дополнение для спрайтов + + // фильтруем null/undefined + const walkables = [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); + + if (hits.length) { + pos.y = hits[0].point.y + 0.02; // лёгкий "антизалип" + } + }; + + + let moved = false; + for (const candidate of tryMoves) { + if (canMove(candidate)) { + stickToTopSurface(candidate); + player.position.copy(candidate); + moved = true; + blockedTime = 0; + break; + } + } + + 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)); + socketRef.current?.emit('playerMovement', { x: player.position.x, y: player.position.y, z: player.position.z }); + + if (currentAction !== walkAction) { + currentAction.fadeOut(0.2); + walkAction.reset().fadeIn(0.2).play(); + currentAction = walkAction; + } + } else { + // полностью заблокированы + blockedTime += delta; + + // Пробуем перепроложить путь к текущей цели, + // либо через 0.35с сдаёмся и ставим idle + if (destination && blockedTime > 0.1) { + const newPath = computePath(player.position, destination); + if (newPath.length > 0) { + currentPath = newPath; + pathIndex = 0; + // оставляем walk + if (currentAction !== walkAction) { + currentAction.fadeOut(0.2); + walkAction.reset().fadeIn(0.2).play(); + currentAction = walkAction; + } + return; + } + } + if (blockedTime > 0.35) { + currentPath = []; + destination = null; + if (currentAction !== idleAction) { + currentAction.fadeOut(0.2); + idleAction.reset().fadeIn(0.2).play(); + currentAction = idleAction; + } + } + } + } + + + function updateTransparency() { + if (!player) return; + + // Если мы в интерьере, не применяем прозрачность + if (isInInteriorRef.current) return; + + obstacles.forEach(obstacle => { + obstacle.mesh.traverse(child => { + if (child.isMesh && child.material) { + if (Array.isArray(child.material)) { + child.material.forEach(mat => { + if (!mat) return; + mat.transparent = false; + mat.opacity = 1.0; + mat.depthWrite = true; + mat.needsUpdate = true; + }); + } else { + child.material.transparent = false; + child.material.opacity = 1.0; + child.material.depthWrite = true; + child.material.needsUpdate = true; + } + } + }); + }); + const direction = new THREE.Vector3() + .subVectors(player.position, cameraRef.current.position) + .normalize(); + + const raycaster = new THREE.Raycaster(cameraRef.current.position, direction); + raycaster.camera = cameraRef.current; // ← ВАЖНО для спрайтов + + const camToPlayerDist = cameraRef.current.position.distanceTo(player.position); + + const obstacleMeshes = obstacles.map(ob => ob.mesh).filter(Boolean); // ← фильтр от null + if (obstacleMeshes.length === 0) return; + + const intersects = raycaster.intersectObjects(obstacleMeshes, true); + + intersects.forEach(hit => { + if (hit.object === player) return; + if (hit.distance < camToPlayerDist) { + if (hit.object.parent === scene) { + if (hit.object.isMesh && hit.object.material) { + if (Array.isArray(hit.object.material)) { + hit.object.material.forEach(mat => { + if (!mat) return; + mat.transparent = true; + mat.opacity = 0.3; + mat.depthWrite = false; + mat.needsUpdate = true; + }); + } else { + hit.object.material.transparent = true; + hit.object.material.opacity = 0.3; + hit.object.material.depthWrite = false; + hit.object.material.needsUpdate = true; + } + } + } else { + hit.object.parent.traverse(child => { + if (child.isMesh && child.material) { + if (Array.isArray(child.material)) { + child.material.forEach(mat => { + if (!mat) return; + mat.transparent = true; + mat.opacity = 0.3; + mat.depthWrite = false; + mat.needsUpdate = true; + }); + } else { + child.material.transparent = true; + child.material.opacity = 0.3; + child.material.depthWrite = false; + child.material.needsUpdate = true; + } + } + }); + } + } + }); + } + + function updateFirstPersonMovement(delta) { + if (!isInInteriorRef.current || cameraRef.current !== fpCamRef.current || !player) return; + + const move = moveInputRef.current; + const speed = 2; // Уменьшаем скорость для более плавного движения в интерьере + const rotSpeed = Math.PI * 0.5; // Уменьшаем скорость поворота + + // Проверка триггера выхода по внутренней точке + if (interiorExitPosRef.current && player.position.distanceTo(interiorExitPosRef.current) < 0.7) { + exitInterior(); + return; + } + + // Поворот влево-вправо (A/D или стрелки) + if (move.left) player.rotation.y += rotSpeed * delta; + if (move.right) player.rotation.y -= rotSpeed * delta; + // Камера следует за вращением тела + const headHeight = 1.6; + const camBase = new THREE.Vector3(player.position.x, player.position.y + headHeight, player.position.z); + const camForward = new THREE.Vector3(0, 0, -0.08).applyEuler(new THREE.Euler(0, player.rotation.y, 0)); + fpCamRef.current.position.copy(camBase.add(camForward)); + const lookForward = new THREE.Vector3(0, 0, -1).applyEuler(new THREE.Euler(0, player.rotation.y, 0)); + fpCamRef.current.lookAt(fpCamRef.current.position.clone().add(lookForward)); + + // Движение с проверкой коллизий + const tryMove = (dirVec) => { + const candidate = player.position.clone().addScaledVector(dirVec, speed * delta); + // Обновляем AABB игрока (простая капсула не используется, только коробка) + const half = 0.25; // чуточку уже, чтобы не цепляться за стены + const height = 1.7; // немного ниже, чтобы не пересекать потолок + const playerBox = new THREE.Box3( + new THREE.Vector3(candidate.x - half, candidate.y, candidate.z - half), + new THREE.Vector3(candidate.x + half, candidate.y + height, candidate.z + half) + ); + // Обновляем мировые матрицы статических коллайдеров для корректных AABB + try { interiorGroupRef.current && interiorGroupRef.current.updateMatrixWorld(true); } catch (_) { } + + // В интерьере учитываем только внутренние коллайдеры, без городских объектов + const blockingMeshes = Array.isArray(interiorCollidersRef.current) + ? interiorCollidersRef.current + : []; + + let hits = false; + for (const mesh of blockingMeshes) { + if (!mesh) continue; + const box = new THREE.Box3().setFromObject(mesh); + // небольшой зазор, чтобы скользить вдоль стен + const expanded = box.clone().expandByScalar(0.01); + if (expanded.intersectsBox(playerBox)) { hits = true; break; } + } + if (!hits) { + player.position.copy(candidate); + } + }; + + const forward = new THREE.Vector3(0, 0, -1).applyQuaternion(player.quaternion); + const right = new THREE.Vector3(1, 0, 0).applyQuaternion(player.quaternion); + if (move.forward) tryMove(forward); + if (move.backward) tryMove(forward.clone().multiplyScalar(-1)); + if (move.strafeLeft) tryMove(right.clone().multiplyScalar(-1)); + if (move.strafeRight) tryMove(right); + + // Отправляем позицию внутри интерьера, чтобы нас видели другие внутри + if (socketRef.current) { + socketRef.current.emit('playerMovement', { x: player.position.x, y: player.position.y, z: player.position.z }); + } + } + + function updateCameraFollow() { + if (!player) return; + + const target = player.position.clone(); + if (cameraRef.current === fpCamRef.current) { + const yaw = player.rotation.y; + const pitch = fpPitchRef.current; + const headPos = target.clone().add(new THREE.Vector3(0, 1.6, 0)); + cameraRef.current.position.copy(headPos); + const forward = new THREE.Vector3(0, 0, -1).applyEuler( + new THREE.Euler(pitch, yaw, 0, 'YXZ') + ); + cameraRef.current.lookAt(headPos.clone().add(forward)); + return; + } + + 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); + + // Плавная интерполяция позиции камеры + const targetPosition = new THREE.Vector3( + target.x + xOff, + target.y + yOff, + target.z + zOff + ); + + cameraRef.current.position.lerp(targetPosition, 0.1); + cameraRef.current.lookAt(target); + } + + function animate() { + requestAnimationFrame(animate); + + // Проверяем, что все необходимые объекты инициализированы + if (!renderer || !scene || !cameraRef.current) { + console.warn('Пропускаем анимацию - не все объекты инициализированы'); + return; + } + + if (!clock || typeof clock.getDelta !== 'function') { + console.warn('Clock не инициализирован'); + return; + } + const delta = Math.min(clock.getDelta(), 0.1); // Ограничиваем delta для стабильности + + // Обновляем анимации + if (mixer && typeof mixer.update === 'function') { + mixer.update(delta); + } + + // Обновляем движение игрока + // В интерьере отключаем автодвижение по кликам (двигаемся только WASD) + if (!isInInteriorRef.current && typeof updateDestinationMovement === 'function') { + updateDestinationMovement(delta); + } + if (typeof updateFirstPersonMovement === 'function') { + updateFirstPersonMovement(delta); + } + + // Обновляем других игроков + if (remotePlayers) { + for (let id in remotePlayers) { + const r = remotePlayers[id]; + if (r && r.model && r.targetPosition) { + r.model.position.lerp(r.targetPosition, 0.15); // Увеличиваем скорость интерполяции + } + if (r && r.mixer && typeof r.mixer.update === 'function') { + r.mixer.update(delta); + } + } + } + + // Обновляем прозрачность и видимость объектов (реже) + if (Math.floor(Date.now() / 100) % 3 === 0) { + if (typeof updateTransparency === 'function') { + updateTransparency(); + } + if (typeof updateCityObjectVisibility === 'function') { + updateCityObjectVisibility(); + } + } + + // Обновляем камеру + if (typeof updateCameraFollow === 'function') { + updateCameraFollow(); + } + + // Рендерим сцену + if (renderer && scene && cameraRef.current) { + try { + renderer.render(scene, cameraRef.current); + } catch (error) { + console.error('Ошибка рендеринга:', error); + // Не освобождаем материалы здесь, чтобы не усугублять ошибку на следующих кадрах + } + } else { + console.warn('Renderer, scene или camera не инициализированы:', { + renderer: !!renderer, + scene: !!scene, + camera: !!cameraRef.current + }); + } + } + + (async () => { + await init(); + animate(); + })(); + + function onWindowResize() { + const aspect = window.innerWidth / window.innerHeight; + if (orthoCamRef.current) { + orthoCamRef.current.left = -200 * aspect; + orthoCamRef.current.right = 200 * aspect; + orthoCamRef.current.top = 200; + orthoCamRef.current.bottom = -200; + orthoCamRef.current.updateProjectionMatrix(); + } + if (fpCamRef.current) { + fpCamRef.current.aspect = aspect; + fpCamRef.current.updateProjectionMatrix(); + } + if (rendererRef.current) { + rendererRef.current.setSize(window.innerWidth, window.innerHeight); + } + } + window.addEventListener('resize', onWindowResize, false); + + return () => { + clearInterval(balanceInterval); + + // Очищаем таймеры throttling + if (wheelTimeout) { + clearTimeout(wheelTimeout); + wheelTimeout = null; + } + if (mouseMoveTimeout) { + clearTimeout(mouseMoveTimeout); + mouseMoveTimeout = null; + } + + window.removeEventListener('keydown', onKeyDown); + window.removeEventListener('keyup', onKeyUp); + if (renderer && renderer.domElement) { + renderer.domElement.removeEventListener('pointerdown', onDocumentMouseDown); + renderer.domElement.removeEventListener('wheel', onMouseWheel); + renderer.domElement.removeEventListener('mousemove', onMouseLookMove); + } + document.removeEventListener('pointerlockchange'); + window.removeEventListener('resize', onWindowResize); + if (renderer && renderer.domElement && renderer.domElement.parentNode) { + renderer.domElement.parentNode.removeChild(renderer.domElement); + } + if (localStream.current) { + localStream.current.getTracks().forEach(track => track.stop()); + } + Object.keys(voiceConnections.current).forEach(peerId => { + cleanupVoiceConnection(peerId); + }); + if (interiorGroupRef.current) { + scene.remove(interiorGroupRef.current); + interiorGroupRef.current = null; + } + }; + }, []); + + const [showWorldMap, setShowWorldMap] = useState(false); + const [cities, setCities] = useState([]); + + // Получить список городов при открытии карты мира + async function openWorldMap() { + setShowWorldMap(true); + const token = localStorage.getItem('token'); + const res = await fetch('/api/cities', { headers: { Authorization: `Bearer ${token}` } }); + console.log('Ответ /api/cities:', res); + if (res.ok) { + const data = await res.json(); + console.log('Данные городов:', data); + setCities(data); + } else { + console.warn('Ошибка загрузки городов:', res.status, res.statusText); } - }); } - function updateFirstPersonMovement(delta) { - if (!isInInteriorRef.current || cameraRef.current !== fpCamRef.current || !player) return; - - const move = moveInputRef.current; - const speed = 2; // Уменьшаем скорость для более плавного движения в интерьере - const rotSpeed = Math.PI * 0.5; // Уменьшаем скорость поворота - - // Проверка триггера выхода по внутренней точке - if (interiorExitPosRef.current && player.position.distanceTo(interiorExitPosRef.current) < 0.7) { - exitInterior(); - return; - } - - // Поворот влево-вправо (A/D или стрелки) - if (move.left) player.rotation.y += rotSpeed * delta; - if (move.right) player.rotation.y -= rotSpeed * delta; - // Камера следует за вращением тела - const headHeight = 1.6; - const camBase = new THREE.Vector3(player.position.x, player.position.y + headHeight, player.position.z); - const camForward = new THREE.Vector3(0, 0, -0.08).applyEuler(new THREE.Euler(0, player.rotation.y, 0)); - fpCamRef.current.position.copy(camBase.add(camForward)); - const lookForward = new THREE.Vector3(0, 0, -1).applyEuler(new THREE.Euler(0, player.rotation.y, 0)); - fpCamRef.current.lookAt(fpCamRef.current.position.clone().add(lookForward)); - - // Движение с проверкой коллизий - const tryMove = (dirVec) => { - const candidate = player.position.clone().addScaledVector(dirVec, speed * delta); - // Обновляем AABB игрока (простая капсула не используется, только коробка) - const half = 0.25; // чуточку уже, чтобы не цепляться за стены - const height = 1.7; // немного ниже, чтобы не пересекать потолок - const playerBox = new THREE.Box3( - new THREE.Vector3(candidate.x - half, candidate.y, candidate.z - half), - new THREE.Vector3(candidate.x + half, candidate.y + height, candidate.z + half) - ); - // Обновляем мировые матрицы статических коллайдеров для корректных AABB - try { interiorGroupRef.current && interiorGroupRef.current.updateMatrixWorld(true); } catch (_) {} - - // В интерьере учитываем только внутренние коллайдеры, без городских объектов - const blockingMeshes = Array.isArray(interiorCollidersRef.current) - ? interiorCollidersRef.current - : []; - - let hits = false; - for (const mesh of blockingMeshes) { - if (!mesh) continue; - const box = new THREE.Box3().setFromObject(mesh); - // небольшой зазор, чтобы скользить вдоль стен - const expanded = box.clone().expandByScalar(0.01); - if (expanded.intersectsBox(playerBox)) { hits = true; break; } - } - if (!hits) { - player.position.copy(candidate); - } - }; - - const forward = new THREE.Vector3(0, 0, -1).applyQuaternion(player.quaternion); - const right = new THREE.Vector3(1, 0, 0).applyQuaternion(player.quaternion); - if (move.forward) tryMove(forward); - if (move.backward) tryMove(forward.clone().multiplyScalar(-1)); - if (move.strafeLeft) tryMove(right.clone().multiplyScalar(-1)); - if (move.strafeRight) tryMove(right); - - // Отправляем позицию внутри интерьера, чтобы нас видели другие внутри - if (socketRef.current) { - socketRef.current.emit('playerMovement', { x: player.position.x, y: player.position.y, z: player.position.z }); - } + function closeWorldMap() { + setShowWorldMap(false); } - function updateCameraFollow() { - if (!player) return; - - const target = player.position.clone(); - if (cameraRef.current === fpCamRef.current) { - const yaw = player.rotation.y; - const pitch = fpPitchRef.current; - const headPos = target.clone().add(new THREE.Vector3(0, 1.6, 0)); - cameraRef.current.position.copy(headPos); - const forward = new THREE.Vector3(0, 0, -1).applyEuler( - new THREE.Euler(pitch, yaw, 0, 'YXZ') - ); - cameraRef.current.lookAt(headPos.clone().add(forward)); - return; - } - - 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); - - // Плавная интерполяция позиции камеры - const targetPosition = new THREE.Vector3( - target.x + xOff, - target.y + yOff, - target.z + zOff - ); - - cameraRef.current.position.lerp(targetPosition, 0.1); - cameraRef.current.lookAt(target); - } - - function animate() { - requestAnimationFrame(animate); - - // Проверяем, что все необходимые объекты инициализированы - if (!renderer || !scene || !cameraRef.current) { - console.warn('Пропускаем анимацию - не все объекты инициализированы'); - return; - } - - if (!clock || typeof clock.getDelta !== 'function') { - console.warn('Clock не инициализирован'); - return; - } - const delta = Math.min(clock.getDelta(), 0.1); // Ограничиваем delta для стабильности - - // Обновляем анимации - if (mixer && typeof mixer.update === 'function') { - mixer.update(delta); - } - - // Обновляем движение игрока - // В интерьере отключаем автодвижение по кликам (двигаемся только WASD) - if (!isInInteriorRef.current && typeof updateDestinationMovement === 'function') { - updateDestinationMovement(delta); - } - if (typeof updateFirstPersonMovement === 'function') { - updateFirstPersonMovement(delta); - } - - // Обновляем других игроков - if (remotePlayers) { - for (let id in remotePlayers) { - const r = remotePlayers[id]; - if (r && r.model && r.targetPosition) { - r.model.position.lerp(r.targetPosition, 0.15); // Увеличиваем скорость интерполяции - } - if (r && r.mixer && typeof r.mixer.update === 'function') { - r.mixer.update(delta); - } + async function handleCitySelect(cityId) { + setShowWorldMap(false); + // Отправляем событие на сервер + socketRef.current?.emit('cityChange', { cityId }); + // Обновляем профиль в sessionStorage + const token = localStorage.getItem('token'); + const res = await fetch('/api/me', { headers: { Authorization: `Bearer ${token}` } }); + if (res.ok) { + const profile = await res.json(); + profile.last_city_id = cityId; // явно обновляем поле + sessionStorage.setItem('user_profile', JSON.stringify(profile)); } - } - - // Обновляем прозрачность и видимость объектов (реже) - if (Math.floor(Date.now() / 100) % 3 === 0) { - if (typeof updateTransparency === 'function') { - updateTransparency(); - } - if (typeof updateCityObjectVisibility === 'function') { - updateCityObjectVisibility(); - } - } - - // Обновляем камеру - if (typeof updateCameraFollow === 'function') { - updateCameraFollow(); - } - - // Рендерим сцену - if (renderer && scene && cameraRef.current) { - try { - renderer.render(scene, cameraRef.current); - } catch (error) { - console.error('Ошибка рендеринга:', error); - // Не освобождаем материалы здесь, чтобы не усугублять ошибку на следующих кадрах - } - } else { - console.warn('Renderer, scene или camera не инициализированы:', { - renderer: !!renderer, - scene: !!scene, - camera: !!cameraRef.current - }); - } + window.location.reload(); } - (async () => { - await init(); - animate(); - })(); - - function onWindowResize() { - const aspect = window.innerWidth / window.innerHeight; - if (orthoCamRef.current) { - orthoCamRef.current.left = -200 * aspect; - orthoCamRef.current.right = 200 * aspect; - orthoCamRef.current.top = 200; - orthoCamRef.current.bottom = -200; - orthoCamRef.current.updateProjectionMatrix(); - } - if (fpCamRef.current) { - fpCamRef.current.aspect = aspect; - fpCamRef.current.updateProjectionMatrix(); - } - if (rendererRef.current) { - rendererRef.current.setSize(window.innerWidth, window.innerHeight); - } - } - window.addEventListener('resize', onWindowResize, false); - - return () => { - clearInterval(balanceInterval); - - // Очищаем таймеры throttling - if (wheelTimeout) { - clearTimeout(wheelTimeout); - wheelTimeout = null; - } - if (mouseMoveTimeout) { - clearTimeout(mouseMoveTimeout); - mouseMoveTimeout = null; - } - - window.removeEventListener('keydown', onKeyDown); - window.removeEventListener('keyup', onKeyUp); - if (renderer && renderer.domElement) { - renderer.domElement.removeEventListener('pointerdown', onDocumentMouseDown); - renderer.domElement.removeEventListener('wheel', onMouseWheel); - renderer.domElement.removeEventListener('mousemove', onMouseLookMove); - } - document.removeEventListener('pointerlockchange'); - window.removeEventListener('resize', onWindowResize); - if (renderer && renderer.domElement && renderer.domElement.parentNode) { - renderer.domElement.parentNode.removeChild(renderer.domElement); - } - if (localStream.current) { - localStream.current.getTracks().forEach(track => track.stop()); - } - Object.keys(voiceConnections.current).forEach(peerId => { - cleanupVoiceConnection(peerId); - }); - if (interiorGroupRef.current) { - scene.remove(interiorGroupRef.current); - interiorGroupRef.current = null; - } - }; - }, []); - - const [showWorldMap, setShowWorldMap] = useState(false); - const [cities, setCities] = useState([]); - - // Получить список городов при открытии карты мира - async function openWorldMap() { - setShowWorldMap(true); - const token = localStorage.getItem('token'); - const res = await fetch('/api/cities', { headers: { Authorization: `Bearer ${token}` } }); - console.log('Ответ /api/cities:', res); - if (res.ok) { - const data = await res.json(); - console.log('Данные городов:', data); - setCities(data); - } else { - console.warn('Ошибка загрузки городов:', res.status, res.statusText); - } - } - - function closeWorldMap() { - setShowWorldMap(false); - } - - async function handleCitySelect(cityId) { - setShowWorldMap(false); - // Отправляем событие на сервер - socketRef.current?.emit('cityChange', { cityId }); - // Обновляем профиль в sessionStorage - const token = localStorage.getItem('token'); - const res = await fetch('/api/me', { headers: { Authorization: `Bearer ${token}` } }); - if (res.ok) { - const profile = await res.json(); - profile.last_city_id = cityId; // явно обновляем поле - sessionStorage.setItem('user_profile', JSON.stringify(profile)); - } - window.location.reload(); - } - - return ( -
-
- Сытость: {satiety} -
-
- Жажда: {thirst} -
- {/* HUD: сытость/жажда */} -
- {[{label:'Сытость', value:satiety}, {label:'Жажда', value:thirst}].map((bar) => ( -
+
+ Сытость: {satiety} +
+
+ Жажда: {thirst} +
+ {/* HUD: сытость/жажда */} +
-
- {bar.label} - {Math.round(bar.value)}% -
-
-
-
+ {[{ label: 'Сытость', value: satiety }, { label: 'Жажда', value: thirst }].map((bar) => ( +
+
+ {bar.label} + {Math.round(bar.value)}% +
+
+
+
+
+ ))}
- ))} -
-
- Баланс: {balance} -
-
- X: {playerCoords.x} Y: {playerCoords.y} Z: {playerCoords.z} -
-
- {(() => { - if (!gameTime) return 'Загрузка времени...'; - // Сервер шлёт ISO (gameTime.js -> toISOString). Отображаем игровое время (ускоренное в 8 раз) - const d = new Date(gameTime); - return d.toLocaleString(); - })()} -
- {/* Кнопка карты мира */} - - - {isInInterior && ( - - )} - - {isInInterior && isTouchDevice && ( -
-
- - - - -
-
- )} - - {selectedHouse && !isInInterior && ( -
- - -
- )} - - - {/* Модальное окно выбора города */} - {showWorldMap && ( -
-
-

Выберите город

-
    - {cities.map(city => ( -
  • - -
  • - ))} -
- -
-
- )} - - {selectedHouse && ( -
-

🏠 {selectedHouse.type}

-

- ID: {selectedHouse.id} -

-

- Стоимость аренды: {selectedHouse.rent} -

-

- Налог: {selectedHouse.tax} -

-
- - - {selectedHouse.organizationId && ( - <> - - - - )} -
-
- )} - {showDialog && currentDialog && ( -
-
-
- {currentDialog.avatar && ( - {currentDialog.name} - )} -

{currentDialog.name}

-
- -
- - {currentForm ? ( -
-

{currentForm.title}

- {currentForm.fields.map((field, idx) => ( -
- - {field.type === 'textarea' ? ( -