/* gjhghjhgjghj - Проблема с игроками они множатся - Проблема с перемещением между городами (исчезновение и появление игроков) - Проблема с Null полусферами */ 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 { 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'; import { getUsersStatus, loadUserInfo } from './api/auth.js'; function Game({ avatarUrl, gender }) { // 1) реф для хранилища сцены const sceneRef = useRef(new THREE.Scene()); // 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 overlayTimeoutRef = 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 [activeApp, setActiveApp] = useState(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 [connectionLost, setConnectionLost] = useState(false); const [latencyMs, setLatencyMs] = useState(null); const connectionLostRef = useRef(false); useEffect(() => { connectionLostRef.current = connectionLost; }, [connectionLost]); const [balance, setBalance] = useState(() => { const p = JSON.parse(sessionStorage.getItem('user_profile') || '{}'); return p.balance ?? 0; }); const [playerCoords, setPlayerCoords] = useState({ x: 0, y: 0, z: 0 }); const [programmingLanguages, setProgrammingLanguages] = useState([]); const [passwordCorrect, setPasswordCorrect] = useState(false); const [showMiniGame, setShowMiniGame] = useState(false); const [questsProgress, setQuestsProgress] = useState([]); const statsRef = useRef(null); const voiceConnections = useRef({}); const localStream = useRef(null); const voiceIcons = useRef({}); const [isPlaying, setIsPlaying] = useState(true); //Телефон\ const [audioUrl, setAudioUrl] = useState("/audio/firs.ogg"); // for Mini-game_2 const [showCleanupGame, setShowCleanupGame] = useState(false); const [cleanupGameData, setCleanupGameData] = useState(null); const [selectedTransaction, setSelectedTransaction] = useState(null); const [markedTransactions, setMarkedTransactions] = useState([]); const [decryptAttempts, setDecryptAttempts] = useState(3); const [timeLeft, setTimeLeft] = useState(180); // 3 минуты const [suspiciousFound, setSuspiciousFound] = useState(0); const [gameResult, setGameResult] = useState(null); const [personalArchive, setPersonalArchive] = useState([]); const [currentLevel, setCurrentLevel] = useState(1); const [gameCompleted, setGameCompleted] = useState(false); const [activeChat, setActiveChat] = useState(null); // Добавьте этот код в начало компонента Game, рядом с другими состояниями const [telegramContacts, setTelegramContacts] = useState([]); const [tgLoading, setTgLoading] = useState(false); const [tgError, setTgError] = useState(null); const [sysTime, setSysTime] = useState(new Date()); const isPhoneNarrow = true; // экран виртуального телефона — всегда узкий const [isIframeOpen, setIsIframeOpen] = useState(false); const [iframeUrl, setIframeUrl] = useState(''); const [appsHidden, setAppsHidden] = useState(false); const [isPhoneVisible, setIsPhoneVisible] = useState(true); const [isChatVisible, setIsChatVisible] = useState(true); const [seregaComments, setSeregaComments] = useState([]); const [currentExit, setCurrentExit] = useState(null); 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 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); //const [dialogIndex, setDialogIndex] = useState(0); //const [showDialog, setShowDialog] = useState(false); //const [formData, setFormData] = useState({}); //const [currentForm, setCurrentForm] = useState(null); //Телефон let scene, renderer; const playerRef = useRef(null); const cityMeshesRef = useRef([]); const cityObjectsDataRef = useRef([]); const loadedCityObjectsRef = useRef({}); const loadedInteriorMeshesRef = useRef({}); const interiorsDataRef = useRef([]); const groundRef = useRef(null); const cityGroup = new THREE.Group(); cityGroupRef.current = cityGroup; // группа интерьера создаётся при входе в здание const savedPositionRef = useRef(new THREE.Vector3()); const remotePlayersRef = useRef({}); const { currentDialog, dialogIndex, showDialog, formData, currentForm, loadDialog, handleAnswerSelect, handleFormSubmit, handleFormChange, setShowDialog } = useDialogManager(); useEffect(() => { const id = setInterval(() => { if (playerRef.current) { const p = playerRef.current.position; setPlayerCoords({ x: p.x.toFixed(1), y: p.y.toFixed(1), z: p.z.toFixed(1) }); } }, 100); return () => clearInterval(id); }, []); const handleAppClick = (appName) => { setAppsHidden(true); setActiveApp(appName); if (appName === "Telegram") { setTgError(null); setTgLoading(true); loadTelegramContacts().finally(() => setTgLoading(false)); } if (appName === "Chrome") { loadQuestsProgress(); } if (appName === "Settings") { setShowMiniGame(true); } }; const handlePasswordInput = (e) => { if (e.key === 'Enter') { const input = e.target.value.trim(); e.target.value = ""; const negativeComments = [ "Ты чё, братан, спишь?!", "Мимо кассы, как всегда!", "Это даже я знаю, что не так!", "Ну и лажа...", "Ты вообще в теме или как?", "Не-а, попробуй ещё раз!" ]; const positiveComments = [ "О, да ты в ударе сегодня!", "В точку, братишка!", "Ну наконец-то угадал!", "Так держать, хакер!", "Бинго! Правильный ответ!", "Ты меня удивляешь!" ]; if (input === "mN8 2kP 5zX") { setTimeout(() => { addSeregaComment(positiveComments[Math.floor(Math.random() * positiveComments.length)]); setPasswordCorrect(true); setProgrammingLanguages(["TR4 FG8 HJ2", "Z9 xC3 vB1", "mN8 2kP 5zX", "kL5 mN7 qW0"]); setAudioUrl("/audio/TR4-FG8-Hj2.ogg"); }, 800); } else if (input === "TR4 FG8 HJ2") { setTimeout(() => { addSeregaComment(positiveComments[Math.floor(Math.random() * positiveComments.length)]); setPasswordCorrect(true); setProgrammingLanguages(["X b7kG z3Lp", "vn4 Zq J8mr", "sW 1Rt yK 90", "q9 Xgd2 BwF"]); setAudioUrl("/audio/X-b7kG-z3Lp.ogg"); }, 800); } else if (input === "X b7kG z3Lp") { setTimeout(() => { addSeregaComment(positiveComments[Math.floor(Math.random() * positiveComments.length)]); setPasswordCorrect(true); setShowMiniGame(false); loadCleanupGame(); }, 800); } else { // Добавляем обработку неправильного ввода setTimeout(() => { addSeregaComment(negativeComments[Math.floor(Math.random() * negativeComments.length)]); }, 800); } } }; function addSeregaComment(text) { setSeregaComments(prev => [...prev, { text, id: Date.now() }]); } async function loadCleanupGame() { if (cleanupTimerRef.current) { clearInterval(cleanupTimerRef.current); } try { const token = localStorage.getItem('token'); if (!token) { console.error('No token found'); return; } if (gameCompleted) return; const res = await fetch(`/api/cleanup-game/data?level=${currentLevel}`, { headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' } }); cleanupTimerRef.current = setInterval(() => { setTimeLeft(prev => { if (prev <= 0) { clearInterval(cleanupTimerRef.current); handleGameFinish(false); return 0; } return prev - 1; }); }, 1000); // Добавьте проверку типа контента const contentType = res.headers.get('content-type'); if (!contentType || !contentType.includes('application/json')) { const text = await res.text(); throw new Error(`Ожидался JSON, получено: ${text.substring(0, 100)}...`); } const data = await res.json(); if (!data.success) { throw new Error(data.error || 'Неизвестная ошибка сервера'); } setCleanupGameData(data.transactions); if (!res.ok) { throw new Error(`Server error: ${res.status}`); } setCleanupGameData(data.transactions); setShowCleanupGame(true); setTimeLeft(180); setDecryptAttempts(3); setMarkedTransactions([]); setSuspiciousFound(0); setGameResult(null); setSeregaComments([]); setSelectedTransaction(null); return () => clearInterval(timer); } catch (err) { console.error('Ошибка загрузки игры:', err); if (cleanupTimerRef.current) { clearInterval(cleanupTimerRef.current); } } } useEffect(() => { return () => { if (cleanupTimerRef.current) { clearInterval(cleanupTimerRef.current); } }; }, []); function addSeregaComment(text) { setSeregaComments(prev => [...prev, { text, id: Date.now() }]); } function handleMarkTransaction(id) { setMarkedTransactions(prev => { const transaction = cleanupGameData.find(tx => tx.id === id); const isCurrentlyMarked = prev.includes(id); let newMarkedTransactions; let newSuspiciousFound = suspiciousFound; if (isCurrentlyMarked) { newMarkedTransactions = prev.filter(t => t !== id); if (transaction._isSuspicious) { newSuspiciousFound = Math.max(0, suspiciousFound - 1); addSeregaComment("Снята отметка с подозрительной транзакции."); } else { addSeregaComment("Снята отметка с транзакции."); } } else { newMarkedTransactions = [...prev, id]; if (transaction._isSuspicious) { newSuspiciousFound = suspiciousFound + 1; addSeregaComment("Верно! Это явно что-то нечистое."); } else { addSeregaComment("Эээ... Ты уверен? Это выглядит нормально."); } } // Обновляем suspiciousFound синхронно с markedTransactions setSuspiciousFound(newSuspiciousFound); // Проверяем завершение игры с новым значением if (transaction._isSuspicious && !isCurrentlyMarked && newSuspiciousFound >= 3) { handleGameFinish(true); } return newMarkedTransactions; }); } function handleDecryptField(transactionId, field) { if (decryptAttempts <= 0) return; setDecryptAttempts(prev => prev); setCleanupGameData(prev => { return prev.map(tx => { if (tx.id === transactionId) { return { ...tx, [field]: field === 'ip' ? tx._realIp : tx._realDevice }; } return tx; }); }); // Добавляем комментарий от Серёги addSeregaComment(field === 'ip' ? "Хм... Это VPN или прокси. Подозрительно!" : "Старое устройство или эмулятор. Нечисто!"); } function handleAddToArchive(id) { if (personalArchive.includes(id)) return; setPersonalArchive(prev => [...prev, id]); addSeregaComment("Опасно... но может пригодиться."); } function handleGameFinish(success) { if (success) { const correctMarks = cleanupGameData.filter(tx => markedTransactions.includes(tx.id) && tx._isSuspicious ).length; const score = Math.min(3, correctMarks); setGameResult('success'); addSeregaComment(`Уровень ${currentLevel} пройден! Найдено ${score} из 3 аномалий.`); // Отправка результата на сервер const token = localStorage.getItem('token'); fetch('/api/cleanup-game/finish', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, body: JSON.stringify({ success, score, markedTransactions, personalArchive, level: currentLevel }) }); // Если это 5 уровень - завершаем игру if (currentLevel >= 5) { setTimeout(() => { setGameResult('complete'); setShowCleanupGame(false); }, 3000); } else { // Иначе загружаем следующий уровень setTimeout(() => { setCurrentLevel(prev => prev + 1); loadCleanupGame(); }, 3000); } } else { setGameResult('fail'); addSeregaComment('Время вышло! Попробуй еще раз.'); // Добавляем таймер для автоматического перезапуска через 3 секунды setTimeout(() => { setGameResult(null); loadCleanupGame(); // Перезапускаем игру }, 3000); } } // Добавляем кнопку для запуска игры в интерфейс const cleanupGameButton = ( ); const buttonStyle = { padding: '10px 20px', background: '#444', color: 'white', border: 'none', borderRadius: '6px', cursor: 'pointer' }; /*const loadDialog = async (npcId) => { try { const response = await fetch(`/dialogs/${npcId}.json`); const data = await response.json(); setCurrentDialog(data); setDialogIndex(0); setShowDialog(true); } catch (error) { console.error('Ошибка загрузки диалога:', error); } };*/ const loader = new GLTFLoader(); // базовая геометрия для объектов типа "chair" const baseChairMesh = new THREE.Mesh( 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)); }); } 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 завершена'); } 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; } 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); } }; 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' }); if (!defRes.ok) { const errText = await defRes.text(); console.error(`Ошибка ${defRes.status} при загрузке определения интерьера: ${errText}`); return; } 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; } } }); // Построение коллайдеров интерьера (простые коробки по мешам) const colliders = []; gltf.scene.traverse((child) => { if (child.isMesh && child.geometry) { colliders.push(child); } }); interiorCollidersRef.current = colliders; // Добавляем объекты интерьера 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); } } // Добавляем освещение для интерьера const light = new THREE.AmbientLight(0xffffff, 1); intGroup.add(light); // Добавляем группу в сцену scene.add(intGroup); interiorGroupRef.current = intGroup; console.log('Модель интерьера загружена успешно'); } catch (e) { console.error('Ошибка загрузки модели интерьера:', e); } } // Кэш для загруженных текстурпаков const texturePackCache = new Map(); 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('Загружаем текстурпак для меша:', 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; } }); } // Предсоздаём материал в стиле 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; } 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; } // Особый режим: если это 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(); } 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; } 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 }); } // Удаляем маркер выхода, если был 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 (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; } } } window.addEventListener('mousedown', onDocumentClick); return () => window.removeEventListener('mousedown', onDocumentClick); }, [currentExit]); /*const handleAnswerSelect = (answer) => { if (answer.end) { setShowDialog(false); } else if (answer.next !== undefined) { // Если следующий узел - форма if (typeof answer.next === 'string' && answer.next.startsWith('form_')) { const nextNode = currentDialog.dialog.find(node => node.id === answer.next); if (nextNode && nextNode.type === 'form') { setCurrentForm(nextNode); return; } } const nextIndex = currentDialog.dialog.findIndex(node => node.id === answer.next); if (nextIndex !== -1) { setDialogIndex(nextIndex); } else { console.error('Диалоговый узел не найден:', answer.next); setShowDialog(false); } } else { setShowDialog(false); } }; // Добавьте эту функцию для обработки отправки формы const handleFormSubmit = (e) => { e.preventDefault(); if (currentForm.next) { const nextIndex = currentDialog.dialog.findIndex(node => node.id === currentForm.next); if (nextIndex !== -1) { setDialogIndex(nextIndex); setCurrentForm(null); // Здесь можно отправить данные формы на сервер console.log('Отправленные данные:', formData); // Например: socketRef.current?.emit('dialogFormSubmit', formData); } } }; // Добавьте эту функцию для обработки изменения полей формы const handleFormChange = (e) => { const { name, value } = e.target; setFormData(prev => ({ ...prev, [name]: value })); };*/ // Добавить функцию загрузки прогресса квестов: async function loadQuestsProgress() { const token = localStorage.getItem('token'); try { console.log("Попытка загрузить"); const res = await fetch('/api/quests/progress', { headers: { Authorization: `Bearer ${token}` } }); if (res.ok) { const data = await res.json(); setQuestsProgress(data); } else { console.error('Ошибка загрузки прогресса квестов'); } } catch (err) { console.error('Ошибка сети:', err); } } const closeApp = () => { setAppsHidden(false); setActiveApp(null); }; const bodyStyle = { margin: 0, fontFamily: "'Arial', sans-serif", background: '#f1f1f1', color: '#333', minHeight: '100vh' }; const headerStyle = { backgroundColor: '#0047ab', color: 'white', padding: '1em', textAlign: 'center' }; const mainStyle = { padding: '1em' }; const listingStyle = { background: 'white', borderRadius: '10px', padding: '1em', marginBottom: '1em', boxShadow: '0 2px 8px rgba(0,0,0,0.1)' }; const imageStyle = { width: '100%', borderRadius: '10px' }; const listingTitleStyle = { marginTop: '0.5em', marginBottom: '0.3em' }; const openIframe = (url) => { setIframeUrl(url); setIsIframeOpen(true); }; const closeIframe = () => { setIsIframeOpen(false); setIframeUrl(''); }; async function loadTelegramContacts() { const token = localStorage.getItem('token'); try { setTgError(null); const res = await fetch('/api/users/status', { headers: { Authorization: `Bearer ${token}` }, credentials: 'include', cache: 'no-cache' }); if (res.ok) { const data = await res.json(); // Добавляем счетчик непрочитанных сообщений для каждого пользователя const dataWithUnread = data.map(user => ({ ...user, unreadCount: 0 })); setTelegramContacts(dataWithUnread); } else { const txt = await res.text().catch(() => ''); console.error('Ошибка загрузки контактов Telegram', res.status, txt); setTgError('Не удалось загрузить контакты'); } } catch (err) { console.error('Ошибка сети:', err); setTgError('Проблема сети'); } } // Дополняем состояния const [newMessage, setNewMessage] = useState(""); const [messageInterval, setMessageInterval] = useState(null); const [messages, setMessages] = useState([]); //const [readmes, setReadmes] = useState('false'); const [userProfile, setUserProfile] = useState(null); // Функция показа уведомлений о сообщениях const showMessageNotification = async (senderId, messageText) => { try { // Сначала пытаемся найти отправителя в контактах let senderName = 'Неизвестный'; const contact = telegramContacts.find(c => c.id === senderId); if (contact) { senderName = contact.firstName || contact.lastName || 'Неизвестный'; } else { // Если не найден в контактах, загружаем информацию о пользователе try { const userInfo = await loadUserInfo(senderId, localStorage.getItem('token')); senderName = userInfo.firstName || userInfo.lastName || 'Неизвестный'; } catch (error) { console.error('Ошибка загрузки информации о пользователе:', error); senderName = 'Неизвестный'; } } // Создаем уведомление const notification = document.createElement('div'); notification.style.cssText = ` position: fixed; top: 20px; right: 20px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 15px 20px; border-radius: 10px; box-shadow: 0 8px 32px rgba(0,0,0,0.3); z-index: 10000; font-family: 'Arial', sans-serif; font-size: 14px; max-width: 300px; transform: translateX(400px); transition: transform 0.3s ease-out; backdrop-filter: blur(10px); border: 1px solid rgba(255,255,255,0.2); `; notification.innerHTML = `
${senderName}
${messageText.length > 50 ? messageText.substring(0, 50) + '...' : messageText}
`; document.body.appendChild(notification); // Анимация появления setTimeout(() => { notification.style.transform = 'translateX(0)'; }, 100); // Автоматическое скрытие через 5 секунд setTimeout(() => { notification.style.transform = 'translateX(400px)'; setTimeout(() => { if (notification.parentNode) { notification.parentNode.removeChild(notification); } }, 300); }, 5000); } catch (error) { console.error('Ошибка показа уведомления:', error); } }; // Функция для обновления счетчика непрочитанных сообщений const updateUnreadCount = async (senderId) => { try { const token = localStorage.getItem('token'); if (!token) return; // Получаем количество непрочитанных сообщений const response = await fetch(`/api/messages-read/${senderId}`, { headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' } }); if (response.ok) { const data = await response.json(); const unreadCount = data.unreadCount || 0; // Обновляем счетчик в контактах setTelegramContacts(prev => prev.map(contact => contact.id === senderId ? { ...contact, unreadCount: unreadCount } : contact ) ); } } catch (error) { console.error('Ошибка обновления счетчика непрочитанных сообщений:', error); } }; // Функция показа подсказки об управлении камерой function showCameraControlsHint() { const hint = document.createElement('div'); hint.style.cssText = ` position: fixed; bottom: 20px; left: 20px; background: rgba(0, 0, 0, 0.8); color: white; padding: 15px 20px; border-radius: 10px; font-family: system-ui, Arial, sans-serif; font-size: 14px; z-index: 9999; max-width: 300px; animation: fadeIn 0.5s ease-in; `; hint.innerHTML = `
🎮 Управление камерой:
Ctrl + колесо = вертикальный поворот
Shift + Ctrl + колесо = горизонтальный поворот
Подсказка исчезнет через 10 секунд
`; // Добавляем CSS анимацию const style = document.createElement('style'); style.textContent = ` @keyframes fadeIn { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } } `; document.head.appendChild(style); document.body.appendChild(hint); // Автоматически скрываем через 10 секунд setTimeout(() => { hint.style.animation = 'fadeOut 0.5s ease-out'; hint.style.opacity = '0'; setTimeout(() => hint.remove(), 500); }, 10000); // Добавляем CSS для fadeOut if (!document.querySelector('#hint-styles')) { const fadeOutStyle = document.createElement('style'); fadeOutStyle.id = 'hint-styles'; fadeOutStyle.textContent = ` @keyframes fadeOut { from { opacity: 1; transform: translateY(0); } to { opacity: 0; transform: translateY(20px); } } `; document.head.appendChild(fadeOutStyle); } } // Функция показа уведомления о перезагрузке сервера function showServerRestartNotification(message, restartIn) { const notification = document.createElement('div'); notification.style.cssText = ` position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); background: #dc2626; color: white; padding: 20px 30px; border-radius: 15px; box-shadow: 0 8px 32px rgba(0,0,0,0.5); z-index: 10001; max-width: 400px; font-family: system-ui, Arial, sans-serif; text-align: center; animation: serverRestartPulse 2s infinite; `; notification.innerHTML = `
⚠️ Перезагрузка сервера
${message}
Перезагрузка через: ${Math.ceil(restartIn/1000)} сек
`; // Добавляем CSS анимацию const style = document.createElement('style'); style.textContent = ` @keyframes serverRestartPulse { 0%, 100% { transform: translate(-50%, -50%) scale(1); } 50% { transform: translate(-50%, -50%) scale(1.05); } } `; document.head.appendChild(style); document.body.appendChild(notification); // Обновляем счетчик const countdownEl = notification.querySelector('#restart-countdown'); const startTime = Date.now(); const countdownInterval = setInterval(() => { const remaining = Math.max(0, restartIn - (Date.now() - startTime)); if (countdownEl) { countdownEl.textContent = Math.ceil(remaining/1000); } if (remaining <= 0) { clearInterval(countdownInterval); notification.remove(); } }, 100); // Автоматически скрываем через время перезагрузки setTimeout(() => { clearInterval(countdownInterval); notification.remove(); }, restartIn); } // Функция загрузки сообщений async function loadMessages(contactId) { if (!contactId) return; const token = localStorage.getItem('token'); async function markMessagesAsRead(contactId, token) { try { const res = await fetch('/api/messages-read-true-false', { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ contactId: contactId }) }); if (!res.ok) { console.error('Ошибка отметки сообщений как прочитанных'); } } catch (error) { console.error('Error marking as read:', error); } } try { // 1. Загружаем сообщения const res = await fetch(`/api/messages/${contactId}`, { headers: { Authorization: `Bearer ${token}` } }); if (res.ok) { const data = await res.json(); setMessages(data); console.log('Сообщения загружены'); // 2. Отмечаем сообщения как прочитанные await markMessagesAsRead(contactId, token); // Прокручиваем чат вниз setTimeout(() => { const chatContainer = document.getElementById('chatContainer'); if (chatContainer) { chatContainer.scrollTop = chatContainer.scrollHeight; } }, 1000); } else { console.error('Ошибка загрузки сообщений'); } } catch (err) { console.error('Ошибка:', err); } } /* async function readmessages(contactId) { if (!contactId) return; const token = localStorage.getItem('token'); try { const res = await fetch(`/api/messages-read/${contactId}`, { headers: { Authorization: `Bearer ${token}` } }); if (res.ok) { const data = await res.text(); if (data == "true") { readmes('true'); // Есть непрочитанные } else { readmes('false'); // Нет непрочитанных } console.log('Статус прочитанности проверен:', data); } else { console.error('Ошибка проверки сообщений'); readmes('false'); } } catch (err) { console.error('Ошибка:', err); readmes('false'); } }*/ // Функция отправки сообщения async function sendMessage() { if (!activeChat || !newMessage.trim()) return; const token = localStorage.getItem('token'); try { const res = await fetch('/api/messages/send', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, body: JSON.stringify({ receiverId: activeChat.id, message: newMessage }) }); if (res.ok) { setNewMessage(""); console.log("Сообщение ушло"); // После отправки сразу обновляем сообщения loadMessages(activeChat.id); } else { console.error('Ошибка отправки сообщения'); } } catch (err) { console.error('Ошибка сети:', err); } } // Запускаем интервал при открытии чата useEffect(() => { if (activeChat) { // Первоначальная загрузка сообщений loadMessages(activeChat.id); //readmessages(activeChat.id) // Запускаем интервал для проверки новых сообщений const interval = setInterval(() => { loadMessages(activeChat.id); //readmessages(activeChat.id); }, 1000); // Проверка каждую секунду setMessageInterval(interval); // Очищаем интервал при закрытии чата return () => { if (interval) clearInterval(interval); }; } else { // Останавливаем интервал, если чат закрыт if (messageInterval) { clearInterval(messageInterval); setMessageInterval(null); } setMessages([]); } }, [activeChat]); // Очищаем интервал при размонтировании компонента useEffect(() => { return () => { if (messageInterval) { clearInterval(messageInterval); } }; }, []); // Загружаем профиль при монтировании useEffect(() => { const profile = JSON.parse(sessionStorage.getItem('user_profile') || {}); 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); } }); } 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 (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; } // Если своих интерактивов не нашли, пробуем поймать 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); } }; 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; let isInitialLoad = true; // Флаг для определения начальной загрузки function createLoadingOverlay() { if (overlayEl) return; // Дополнительная проверка - не показываем overlay для очень маленьких загрузок if (!isInitialLoad && loadingManagerRef.current && loadingManagerRef.current.itemStart) { const currentTotal = loadingManagerRef.current.itemStart.length || 0; if (currentTotal <= 3) return; // Не показываем для загрузки 3 или меньше ресурсов } 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; // Очищаем все таймеры overlay if (overlayTimeoutRef.current) { clearTimeout(overlayTimeoutRef.current); overlayTimeoutRef.current = null; } 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) => { console.log(`LoadingManager.onStart: isInitialLoad=${isInitialLoad}, total=${total}, url=${_url}`); // Показываем оверлей только при начальной загрузке или при загрузке большого количества ресурсов if (isInitialLoad || total > 10) { console.log('Показываем overlay для загрузки'); createLoadingOverlay(); updateLoadingOverlay(total ? (loaded / total) * 100 : 5, 'Загрузка ресурсов...'); } else { console.log('Не показываем overlay - небольшая загрузка'); } }; loadingManager.onProgress = (_url, loaded, total) => { if (overlayEl && (isInitialLoad || total > 10)) { updateLoadingOverlay(total ? (loaded / total) * 100 : 50); } }; loadingManager.onLoad = () => { console.log(`LoadingManager.onLoad: isInitialLoad=${isInitialLoad}, overlayEl=${!!overlayEl}`); if (overlayEl) { // Показываем "Инициализация сцены" только для начальной загрузки if (isInitialLoad) { console.log('Показываем "Инициализация сцены" для начальной загрузки'); updateLoadingOverlay(100, 'Инициализация сцены...'); setTimeout(removeLoadingOverlay, 150); } else { // Для небольших загрузок просто скрываем overlay console.log('Скрываем overlay для небольшой загрузки'); removeLoadingOverlay(); } } isInitialLoad = false; // После первой загрузки сбрасываем флаг // Дополнительная защита - принудительно скрываем overlay через 3 секунды if (overlayEl) { overlayTimeoutRef.current = setTimeout(() => { if (overlayEl) { console.log('Принудительно скрываем overlay по таймауту'); removeLoadingOverlay(); } }, 3000); } // Глобальная защита - принудительно скрываем overlay через 5 секунд после начала игры overlayTimeoutRef.current = setTimeout(() => { if (overlayEl && !isInitialLoad) { console.log('Глобальная защита: принудительно скрываем overlay'); removeLoadingOverlay(); } }, 5000); }; 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); let baseAzimuth = Math.atan2(baseOffset.z, baseOffset.x); const baseAzimuth0 = baseAzimuth; let horizontalYaw = 0; // относительный поворот (±90°) от исходного const basePolar = Math.atan2(baseOffset.y, planarDist); let cameraPitchOffset = 0; 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; async function loadCustomCollidersForCity(cityIdParam) { try { const cityIdNum = Number(cityIdParam) || 0; const query = cityIdNum ? `?cityId=${encodeURIComponent(cityIdNum)}` : ''; const res = await fetch(`/api/colliders${query}`, { cache: 'no-store', headers: { Authorization: `Bearer ${token}` } }); if (!res.ok) return; const data = await res.json(); const list = Array.isArray(data?.colliders) ? data.colliders : []; // Удаляем старые кастомные коллайдеры obstacles = obstacles.filter(o => { const keep = !o?.mesh?.userData?.isCustomCollider; if (!keep && o.mesh) { scene.remove(o.mesh); } return keep; }); // Добавляем новые list.forEach(c => { let geometry; if (c.type === 'circle') geometry = new THREE.CylinderGeometry(1.5, 1.5, 2, 24); else if (c.type === 'capsule') geometry = new THREE.CapsuleGeometry(1, 2, 4, 12); else geometry = new THREE.BoxGeometry(2, 2, 2); const material = new THREE.MeshBasicMaterial({ color: 0x000000, transparent: true, opacity: 0.001, depthWrite: false }); const mesh = new THREE.Mesh(geometry, material); const p = c.position || {}; const r = c.rotation || {}; const s = c.scale || {}; mesh.position.set(p.x || 0, p.y || 0, p.z || 0); mesh.rotation.set(r.x || 0, r.y || 0, r.z || 0); mesh.scale.set(s.x || 1, s.y || 1, s.z || 1); mesh.userData.isCustomCollider = true; scene.add(mesh); obstacles.push({ mesh }); }); buildPathfindingGrid?.(); } catch (e) { console.warn('Не удалось загрузить colliders.json', e); } } console.log('socket инстанс:', socket); console.log('Подключение к серверу:', serverUrl); socket.on('connect', () => { console.log('✔ Socket connected, id=', socket.id); console.log('Подключение успешно установлено'); setConnectionLost(false); // Подписка на ping/pong менеджера Socket.IO для измерения задержки try { const mgr = socket.io; if (mgr && typeof mgr.on === 'function') { mgr.off?.('pong'); mgr.on('pong', (latency) => { if (typeof latency === 'number' && isFinite(latency)) { setLatencyMs(Math.round(latency)); } }); } } catch (e) { /* noop */ } }); // Загрузка пользовательских коллайдеров при старте (по текущему городу) try { const profile = JSON.parse(sessionStorage.getItem('user_profile') || '{}'); const initialCityId = profile.last_city_id || 1; loadCustomCollidersForCity(initialCityId); } catch {} socket.on('connect_error', err => { console.error('Socket connect_error:', err); console.error('Ошибка подключения к серверу:', serverUrl); console.error('Проверьте, что сервер запущен на порту 4000'); setConnectionLost(true); }); socket.on('disconnect', reason => { console.warn('Socket disconnected:', reason); console.warn('Соединение разорвано, причина:', reason); setConnectionLost(true); }); // Небольшой таймер для обновления latency при отсутствии событий const pingTimer = setInterval(() => { const s = socketRef.current; if (!s || s.disconnected) return; // менеджер сам шлёт ping с интервалом, мы лишь не даём UI "застывать" // если давно не было pong — считаем соединение деградировало setLatencyMs((prev) => (prev == null ? prev : Math.min(prev + 1, 999))); }, 1000); const profile = JSON.parse(sessionStorage.getItem('user_profile') || '{}'); if (profile?.id) { socket.emit('economy:getBalance', { userId: profile.id }); } const balanceInterval = setInterval(() => { const p = JSON.parse(sessionStorage.getItem('user_profile') || '{}'); if (p?.id) socket.emit('economy:getBalance', { userId: p.id }); }, 3000); // Периодическое обновление статуса пользователей для Telegram const statusInterval = setInterval(() => { if (activeApp === "Telegram" && telegramContacts.length > 0) { loadTelegramContacts(); // Обновляем счетчики непрочитанных сообщений для всех контактов telegramContacts.forEach(contact => { if (contact.id !== profile.id) { updateUnreadCount(contact.id); } }); } }, 30000); // Обновляем каждые 30 секунд 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)); // Обработчик изменения статуса пользователей для Telegram socket.on('userStatusChanged', ({ userId, isOnline }) => { console.log('Статус пользователя изменился:', { userId, isOnline }); setTelegramContacts(prev => prev.map(user => user.id === userId ? { ...user, isOnline } : user )); }); // Обработчик новых сообщений для уведомлений socket.on('newMessage', ({ id, text, senderId, timestamp, isRead }) => { console.log('Новое сообщение:', { id, text, senderId, timestamp, isRead }); // Показываем уведомление только если Telegram не открыт if (activeApp !== "Telegram") { showMessageNotification(senderId, text); } // Обновляем счетчик непрочитанных сообщений updateUnreadCount(senderId); // Обновляем список сообщений если открыт чат с этим пользователем if (activeChat && activeChat.id === senderId) { loadMessages(senderId); } }); // Обработчик перезагрузки сервера socket.on('serverRestart', ({ message, restartIn }) => { console.log('Сервер будет перезагружен:', { message, restartIn }); showServerRestartNotification(message, restartIn); }); // Лоадеры, учитывающиеся в прогрессе через 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 }); } } }); } 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 }); } }; 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]; 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) { // При нажатом Ctrl управляем и вертикальным, и горизонтальным углом камеры if (e.shiftKey) { // Shift + Ctrl + колесо = горизонтальный поворот (влево-вправо) относительно исходного азимута const horizontalDelta = delta * 2; // Увеличиваем чувствительность horizontalYaw = THREE.MathUtils.clamp( horizontalYaw + horizontalDelta, -Math.PI / 2, Math.PI / 2 ); } else { // Ctrl + колесо = вертикальный поворот (вверх-вниз) 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 }); } 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 }); } } 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; } } 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 { 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); } }); } 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; } } // Здания/объекты 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); } // Ctrl + Arrow keys for camera control if (event.ctrlKey) { const key = event.key.toLowerCase(); if (key === 'arrowleft') { horizontalYaw = THREE.MathUtils.clamp(horizontalYaw - 0.1, -Math.PI / 2, Math.PI / 2); } else if (key === 'arrowright') { horizontalYaw = THREE.MathUtils.clamp(horizontalYaw + 0.1, -Math.PI / 2, Math.PI / 2); } } // Сбрасываем назначение только если не в интерьере 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 = 1024; // Увеличиваем размер canvas canvas.height = 256; const ctx = canvas.getContext('2d'); // Добавляем фон для лучшей видимости ctx.fillStyle = 'rgba(0, 0, 0, 0.7)'; ctx.fillRect(0, 0, canvas.width, canvas.height); const fontSize = 72; // Увеличиваем размер шрифта 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(2.2, 0.55, 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 countIntersectionsAtPosition(pos, halfSize = 1) { const playerMin = new THREE.Vector2(pos.x - halfSize, pos.z - halfSize); const playerMax = new THREE.Vector2(pos.x + halfSize, pos.z + halfSize); let count = 0; for (let i = 0; i < obstacles.length; i++) { const mesh = obstacles[i]?.mesh; if (!mesh) continue; mesh.updateMatrixWorld(); const box = new THREE.Box3().setFromObject(mesh); const obstacleMin = new THREE.Vector2(box.min.x, box.min.z); const obstacleMax = new THREE.Vector2(box.max.x, box.max.z); if ((playerMin.x <= obstacleMax.x && playerMax.x >= obstacleMin.x) && (playerMin.y <= obstacleMax.y && playerMax.y >= obstacleMin.y)) { count++; } } return count; } function updateDestinationMovement(delta) { if (!player || currentPath.length === 0 || pathIndex >= currentPath.length) return; 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); // Жёсткое выравнивание по топ-поверхности, чтобы исключить спад до y=0 на остановке (function alignGroundFinal(p) { const downRay = new THREE.Raycaster( new THREE.Vector3(p.x, 100, p.z), new THREE.Vector3(0, -1, 0), 0, 300 ); downRay.camera = cameraRef.current; const walkables = [ ...(cityGroupRef.current ? [cityGroupRef.current] : []), groundPlane, ...(cityMeshesRef.current || []) ].filter(Boolean); const raw = downRay.intersectObjects(walkables, true); const isDescendantOf = (obj, ancestor) => { let c=obj; while(c){ if(c===ancestor) return true; c=c.parent;} return false; }; const hits = raw .filter(h => !h.object.isSprite && h.face && h.face.normal && h.face.normal.y > 0.6) .filter(h => !isDescendantOf(h.object, player)); if (hits.length) p.y = hits[0].point.y + 0.02; })(player.position); pathIndex++; blockedTime = 0; if (pathIndex >= currentPath.length) { 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); // 1) Поворот всегда догоняет, движение начинается сразу — естественное скольжение в сторону цели const desiredYaw = Math.atan2(dir.x, dir.z); const currentYaw = new THREE.Euler().setFromQuaternion(player.quaternion, 'YXZ').y; let yawDiff = desiredYaw - currentYaw; yawDiff = ((yawDiff + Math.PI) % (2 * Math.PI)) - Math.PI; // нормализация [-PI, PI] const maxTurnRate = 3.0; // рад/сек — ограничиваем скорость поворота const stepAngle = THREE.MathUtils.clamp(yawDiff, -maxTurnRate * delta, maxTurnRate * delta); const newYawFollow = currentYaw + stepAngle; player.quaternion.setFromEuler(new THREE.Euler(0, newYawFollow, 0)); // Кандидаты перемещения: прямо, слайд по X, слайд по Z const tryMoves = [ player.position.clone().add(step), 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 и целимся в корневую группу города + groundPlane const walkables = [ ...(cityGroupRef.current ? [cityGroupRef.current] : []), groundPlane, ...(cityMeshesRef.current || []) ].filter(Boolean); const raw = downRay.intersectObjects(walkables, true); const isDescendantOf = (obj, ancestor) => { let cur = obj; while (cur) { if (cur === ancestor) return true; cur = cur.parent; } return false; }; const hits = raw .filter(h => !h.object.isSprite && h.face && h.face.normal && h.face.normal.y > 0.6) .filter(h => !isDescendantOf(h.object, player)); if (hits.length) { pos.y = hits[0].point.y + 0.02; // лёгкий "антизалип" } }; 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 currentIntersections = countIntersectionsAtPosition(player.position, 1); if (currentIntersections > 0) { const radii = [stepDistance * 0.6, stepDistance * 1.0, stepDistance * 1.6]; const angles = 24; // 15° шаг let bestPos = null; let bestScore = currentIntersections; let bestDist = Infinity; const escapeHalf = 0.6; // слегка ужимаем хитбокс при выходе for (const r of radii) { for (let i = 0; i < angles; i++) { const a = (i / angles) * Math.PI * 2; const dir2 = new THREE.Vector3(Math.sin(a), 0, Math.cos(a)); const cand = player.position.clone().addScaledVector(dir2, r); const score = countIntersectionsAtPosition(cand, escapeHalf); const distToTarget = cand.distanceTo(target); if ( score < bestScore || (score === bestScore && distToTarget < bestDist) ) { bestScore = score; bestDist = distToTarget; bestPos = cand; if (bestScore === 0) break; } } if (bestScore === 0) break; } if (bestPos) { stickToTopSurface(bestPos); player.position.copy(bestPos); moved = true; blockedTime = 0; } else { // Последняя попытка: небольшая "встряска" вверх и повторное прилипание к поверхности const nudged = player.position.clone(); nudged.y += 0.05; stickToTopSurface(nudged); player.position.copy(nudged); } } } if (moved) { // Плавный доворот в сторону движения, но движение идёт сразу const curYaw = new THREE.Euler().setFromQuaternion(player.quaternion, 'YXZ').y; let d = desiredYaw - curYaw; d = ((d + Math.PI) % (2 * Math.PI)) - Math.PI; const rotStep = THREE.MathUtils.clamp(d, -maxTurnRate * delta, maxTurnRate * delta); const newYaw = curYaw + rotStep; player.quaternion.setFromEuler(new THREE.Euler(0, newYaw, 0)); socketRef.current?.emit('playerMovement', { x: player.position.x, y: player.position.y, z: player.position.z }); if (currentAction !== walkAction) { 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; } // Жёсткое выравнивание по топ-поверхности при переходе в idle (function alignGroundFinal(p) { const downRay = new THREE.Raycaster( new THREE.Vector3(p.x, 100, p.z), new THREE.Vector3(0, -1, 0), 0, 300 ); downRay.camera = cameraRef.current; const walkables = [ ...(cityGroupRef.current ? [cityGroupRef.current] : []), groundPlane, ...(cityMeshesRef.current || []) ].filter(Boolean); const raw = downRay.intersectObjects(walkables, true); const isDescendantOf = (obj, ancestor) => { let c=obj; while(c){ if(c===ancestor) return true; c=c.parent;} return false; }; const hits = raw .filter(h => !h.object.isSprite && h.face && h.face.normal && h.face.normal.y > 0.6) .filter(h => !isDescendantOf(h.object, player)); if (hits.length) p.y = hits[0].point.y + 0.02; })(player.position); } } // Всегда подравниваем Y к верхней поверхности, чтобы исключить проваливания на остановке stickToTopSurface(player.position); } 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 stepDistance = speed * delta; const candidate = player.position.clone().addScaledVector(dirVec, stepDistance); // Обновляем AABB игрока с меньшими размерами для предотвращения застревания const half = 0.2; // Уменьшаем размер для лучшего прохождения const height = 1.6; // Немного ниже для предотвращения застревания в потолке 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; let closestDistance = Infinity; let slideDirection = null; for (const mesh of blockingMeshes) { if (!mesh) continue; const box = new THREE.Box3().setFromObject(mesh); const expanded = box.clone().expandByScalar(0.05); // Увеличиваем зазор if (expanded.intersectsBox(playerBox)) { hits = true; // Вычисляем направление скольжения вдоль стены const center = box.getCenter(new THREE.Vector3()); const toPlayer = player.position.clone().sub(center); const distance = toPlayer.length(); if (distance < closestDistance) { closestDistance = distance; // Нормализуем и создаем направление скольжения toPlayer.normalize(); slideDirection = toPlayer; } } } if (!hits) { // Свободное движение player.position.copy(candidate); } else if (slideDirection) { // Скольжение вдоль стены const slideDistance = stepDistance * 0.7; // Уменьшаем дистанцию скольжения const slidePos = player.position.clone().addScaledVector(slideDirection, slideDistance); // Проверяем, можно ли двигаться в направлении скольжения const slideBox = new THREE.Box3( new THREE.Vector3(slidePos.x - half, slidePos.y, slidePos.z - half), new THREE.Vector3(slidePos.x + half, slidePos.y + height, slidePos.z + half) ); let canSlide = true; for (const mesh of blockingMeshes) { if (!mesh) continue; const box = new THREE.Box3().setFromObject(mesh); const expanded = box.clone().expandByScalar(0.05); if (expanded.intersectsBox(slideBox)) { canSlide = false; break; } } if (canSlide) { player.position.copy(slidePos); } } }; 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); // Горизонтальный угол = исходный азимут + относительный поворот (±90°) const azimuth = baseAzimuth0 + horizontalYaw; const xOff = planar * Math.cos(azimuth); const zOff = planar * Math.sin(azimuth); // Плавная интерполяция позиции камеры 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 (connectionLostRef.current) { // Останавливаем любые движения if (moveInputRef.current) { Object.keys(moveInputRef.current).forEach(k => moveInputRef.current[k] = false); } // Скрыть маркер назначения if (destinationMarker) destinationMarker.visible = false; } if (!clock || typeof clock.getDelta !== 'function') { console.warn('Clock не инициализирован'); return; } 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); // Отключаем браузерное масштабирование document.addEventListener('wheel', (e) => { if (e.ctrlKey) { e.preventDefault(); } }, { passive: false }); document.addEventListener('keydown', (e) => { if (e.ctrlKey && (e.key === '+' || e.key === '-' || e.key === '=')) { e.preventDefault(); } }); // Показываем подсказку об управлении камерой setTimeout(() => { showCameraControlsHint(); }, 3000); return () => { clearInterval(balanceInterval); clearInterval(statusInterval); // Очищаем overlay загрузки if (overlayEl) { removeLoadingOverlay(); } // Очищаем все таймеры overlay if (overlayTimeoutRef.current) { clearTimeout(overlayTimeoutRef.current); } // Очищаем таймеры 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) => (
{bar.label} {Math.round(bar.value)}%
))}
Баланс: {balance}
X: {playerCoords.x} Y: {playerCoords.y} Z: {playerCoords.z}
{/* Индикатор связи в правом нижнем углу */}
{connectionLost ? 'Связь: нет' : `Пинг: ${latencyMs ?? '—'} ms`}
{(() => { if (!gameTime) return 'Загрузка времени...'; // Сервер шлёт ISO (gameTime.js -> toISOString). Отображаем игровое время (ускоренное в 8 раз) const d = new Date(gameTime); return d.toLocaleString(); })()}
{/* Оверлей при потере соединения */} {connectionLost && (
Соединение потеряно
Связь с сервером была прервана. Пожалуйста, перезайдите в игру.
)} {/* Кнопка карты мира */} {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' ? (