/* - Проблема с игроками они множатся - Проблема с перемещением между городами (исчезновение и появление игроков) - Проблема с Null полусферами */ import React, { useState, useEffect, useRef } from 'react'; import * as THREE from 'three'; import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'; import PF from 'pathfinding'; import { io } from 'socket.io-client'; import DoubleTapWrapper from './pages/DoubleTapWrapper'; import OrgControlPanel from './components/OrgControlPanel'; import Inventory from './components/Inventory'; import { useDialogManager } from './components/DialogSystem/DialogManager'; import { DialogWindow } from './components/DialogSystem/DialogWindow'; import WaveformPlayer from './pages/WaveformPlayer'; function Game({ avatarUrl, gender }) { // 1) реф для хранилища сцены const sceneRef = useRef(new THREE.Scene()); // 2) реф для группы «города» const cityGroupRef = useRef(null); /** * Безопасно получает .current у рефа. Если сам ref == null ИЛИ ref.current == null, * вернёт null и залогирует понятную причину. */ function getRef(ref, name = 'ref') { if (ref === null) { console.error(`[REF] ${name} variable is null (handler called before init?)`); return null; } if (typeof ref !== 'object' || !('current' in ref)) { console.error(`[REF] ${name} is not a ref-like object`); return null; } if (ref.current == null) { console.warn(`[REF] ${name}.current is not ready yet`); return null; } return ref.current; } /** * Удобные однотипные геттеры — сокращают повтор. */ const getScene = () => getRef(sceneRef, 'sceneRef'); const getPlayer = () => getRef(playerRef, 'playerRef'); const getCityGroup = () => getRef(cityGroupRef, 'cityGroupRef'); const getExitMarker = () => getRef(exitMarkerRef, 'exitMarkerRef'); /** * Быстрые проверки перед действиями, требующими инициализации 3D. */ const ensureSceneAndPlayer = () => !!(getScene() && getPlayer()); // 3) реф для группы «интерьера» const interiorGroupRef = useRef(null); const cleanupTimerRef = useRef(null); // Глобальный менеджер прогресса загрузки (используем в GLTFLoader) const loadingManagerRef = useRef(null); // Кликабельные объекты внутри интерьера const interiorInteractablesRef = 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 }); const fpPitchRef = useRef(0); const isTouchDevice = typeof window !== 'undefined' && ('ontouchstart' in window || navigator.maxTouchPoints > 0); const isInInteriorRef = useRef(false); const LOAD_RADIUS = 120; const [activeApp, setActiveApp] = useState(null); const [selectedHouse, setSelectedHouse] = useState(null); const [isInInterior, setIsInInterior] = useState(false); const [mountRef, setMountRef] = useState(null); const socketRef = useRef(null); useEffect(() => { isInInteriorRef.current = isInInterior; }, [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(''); 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 [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); 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); //Телефон const scene = new THREE.Scene(); 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") { loadTelegramContacts(); // Загрузка контактов при открытии } 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.MeshStandardMaterial({ color: 0x888888 }) ); async function loadGLTF(url) { return new Promise((resolve, reject) => { loader.load(url, gltf => resolve(gltf), undefined, err => reject(err)); }); } async function loadInteriorScene(interiorId) { const token = localStorage.getItem('token'); 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}`); alert(`Не удалось загрузить определение интерьера: ${errText}`); return; } const { glb, objects } = await defRes.json(); const baseUrl = window.location.origin; const glbUrl = baseUrl + glb; console.log('Loading GLB from', glbUrl); // подстраховка: перед загрузкой проверяем, что URL физически отдает не HTML const headResp = await fetch(glbUrl, { method: 'HEAD', cache: 'no-cache' }); if (!headResp.ok) throw new Error(`GLB not reachable: HTTP ${headResp.status}`); const gltf = await loadGLTF(glbUrl); const scene = sceneRef.current; savedPositionRef.current.copy(playerRef.current.position); toggleWorldVisibility(false); scene.remove(cityGroupRef.current); const intGroup = new THREE.Group(); intGroup.name = 'interiorGroup'; intGroup.add(gltf.scene); interiorInteractablesRef.current = []; // сбрасываем реестр интерактива for (const o of objects) { if (o.model_url) { try { const objGltf = await loadGLTF(baseUrl + o.model_url); objGltf.scene.position.set(o.x, o.y, o.z); objGltf.scene.rotation.set(o.rot_x, o.rot_y, o.rot_z); objGltf.scene.scale.set(o.scale, o.scale, o.scale); intGroup.add(objGltf.scene); } 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); // по умолчанию делаем «чистую» геометрию… intGroup.add(mesh); } // Если сервер прислал «маркер»/NPC — пометим кликабельным // (ожидаем флаг o.interactable и/или o.marker === true) if (o.interactable || o.marker) { // добавим небольшой «хитбокс» для клика const hit = new THREE.Mesh( new THREE.SphereGeometry(0.6), new THREE.MeshBasicMaterial({ color: 0x00ff00, transparent: true, opacity: 0.15, 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.raycast = hit.raycast; // оставим по умолчанию; это НЕ Sprite intGroup.add(hit); interiorInteractablesRef.current.push(hit); } } const light = new THREE.AmbientLight(0xffffff, 1); intGroup.add(light); scene.add(intGroup); interiorGroupRef.current = intGroup; setIsInInterior(true); setSelectedHouse(null); } const enterInterior = async (interiorId) => { const token = localStorage.getItem('token'); if (!token) { alert('Пожалуйста, войдите в систему, чтобы войти в здание'); return; } // Сцена/игрок должны быть инициализированы if (!ensureSceneAndPlayer()) return; const scene = getScene(); const player = getPlayer(); 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 data = await res.json(); const { spawn, exit, cityId } = data; // Если интерьер в другом городе — переключаем город const profile0 = JSON.parse(sessionStorage.getItem('user_profile') || '{}'); const myCityId0 = profile0.last_city_id || 1; if (cityId && cityId !== myCityId0) { socketRef.current?.emit('cityChange', { cityId }); profile0.last_city_id = cityId; sessionStorage.setItem('user_profile', JSON.stringify(profile0)); } if (!spawn) { alert('Для этого интерьера не заданы координаты входа'); return; } // Телепортируем игрока в интерьер // Телепорт игрока player.position.set(spawn.x, spawn.y, spawn.z); player.rotation.y = THREE.MathUtils.degToRad(spawn.rot); // Можно добавить сброс скорости, анимации и т.д. при необходимости setCurrentExit(exit || null); // Добавляем маркер выхода if (exit) { addExitMarker(exit); } } catch (e) { console.error('Failed to enter interior:', e); } }; 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 = () => { if (!currentExit) { alert('Не заданы координаты выхода из интерьера!'); return; } if (playerRef.current) { playerRef.current.position.set(currentExit.x, currentExit.y, currentExit.z); playerRef.current.rotation.set(0, currentExit.rot || 0, 0); } // Удаляем маркер выхода if (window.exitMarkerMesh && sceneRef.current) { sceneRef.current.remove(window.exitMarkerMesh); window.exitMarkerMesh = null; } setCurrentExit(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) { console.log("Попытка не удалась"); 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 { const res = await fetch('/api/users', { headers: { Authorization: `Bearer ${token}` } }); if (res.ok) { const data = await res.json(); setTelegramContacts(data); } else { console.error('Ошибка загрузки контактов Telegram'); } } catch (err) { console.error('Ошибка сети:', err); } } // Дополняем состояния const [newMessage, setNewMessage] = useState(""); const [messageInterval, setMessageInterval] = useState(null); const [messages, setMessages] = useState([]); const [userProfile, setUserProfile] = useState(null); // Функция загрузки сообщений async function loadMessages(contactId) { if (!contactId) return; const token = localStorage.getItem('token'); try { const res = await fetch(`/api/messages/${contactId}`, { headers: { Authorization: `Bearer ${token}` } }); if (res.ok) { const data = await res.json(); setMessages(data); console.log('Сообщение загружено'); // Прокручиваем чат вниз setTimeout(() => { const chatContainer = document.getElementById('chatContainer'); if (chatContainer) { chatContainer.scrollTop = chatContainer.scrollHeight; } }, 100); } else { console.error('Ошибка загрузки сообщений'); } } catch (err) { console.error('Ошибка сети:', err); } } // Функция отправки сообщения 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); // Запускаем интервал для проверки новых сообщений const interval = setInterval(() => { loadMessages(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 enterInterior(interiorId); } function switchToFirstPersonCamera() { if (fpCamRef.current) { cameraRef.current = fpCamRef.current; } if (playerRef.current) { playerRef.current.visible = false; } fpPitchRef.current = 0; } function switchToThirdPersonCamera() { if (orthoCamRef.current) { cameraRef.current = orthoCamRef.current; } if (playerRef.current) { playerRef.current.visible = true; } fpPitchRef.current = 0; } function startMove(dir) { moveInputRef.current[dir] = true; } function stopMove(dir) { moveInputRef.current[dir] = false; } // ───────────────────────────────────────────────────── // КЛИКИ ВНУТРИ ИНТЕРЬЕРА (интерактивные маркеры/NPC) // ───────────────────────────────────────────────────── useEffect(() => { const onClick = (e) => { if (!isInInteriorRef.current) return; const mount = mountRef.current; if (!mount || !cameraRef.current) return; // координаты мыши в NDC const rect = 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); // Ищем пересечения по интерактивам const objects = interiorInteractablesRef.current.filter(obj => obj?.isObject3D); if (!objects.length) return; const hits = raycaster.intersectObjects(objects, true) .filter(h => h.object && h.object.userData && h.object.userData.interactable); if (!hits.length) return; const top = hits[0].object; const payload = top.userData.payload || {}; // Дальше делай что нужно: диалог, меню, действие и т.п. if (payload.type === 'marker') { console.log('Нажат маркер:', payload); // например, открыть окно диалога/описания // setCurrentDialog(...); setShowDialog(true); } else if (payload.type === 'npc') { console.log('Нажат NPC:', payload); // loadDialog(payload.id) и т.п. } else { console.log('Интерактив:', payload); } }; window.addEventListener('click', onClick); return () => window.removeEventListener('click', 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); Object.values(remotePlayersRef.current).forEach(p => { if (p.model) p.model.visible = visible; }); } useEffect(() => { console.log('[DEBUG] useEffect вызван'); const mount = mountRef.current; if (!mount) { console.log('[DEBUG] mountRef.current не определён!'); return; } // ───────────────────────────────────────────── // Красивый загрузочный оверлей + LoadingManager // ───────────────────────────────────────────── let overlayEl = null, barEl = null, textEl = null; function createLoadingOverlay() { if (overlayEl) return; overlayEl = document.createElement('div'); Object.assign(overlayEl.style, { position: 'fixed', inset: '0', zIndex: 2000, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', background: 'linear-gradient(135deg,#0f172a,#1e293b)', color: '#fff', fontFamily: 'system-ui, Arial, sans-serif' }); textEl = document.createElement('div'); Object.assign(textEl.style, { fontSize: '24px', fontWeight: 700, opacity: 0.9, marginBottom: '16px' }); textEl.textContent = 'Загрузка ресурсов...'; overlayEl.appendChild(textEl); const barWrap = document.createElement('div'); Object.assign(barWrap.style, { width: '320px', height: '10px', background: 'rgba(255,255,255,0.15)', borderRadius: '999px', overflow: 'hidden', boxShadow: '0 6px 20px rgba(0,0,0,0.35)' }); barEl = document.createElement('div'); Object.assign(barEl.style, { width: '0%', height: '100%', transition: 'width .15s ease', background: 'linear-gradient(90deg,#22d3ee,#38bdf8,#60a5fa)' }); barWrap.appendChild(barEl); overlayEl.appendChild(barWrap); const pct = document.createElement('div'); Object.assign(pct.style, { marginTop: '12px', fontSize: '14px', opacity: 0.8 }); pct.id = 'loadingPct'; pct.textContent = '0%'; overlayEl.appendChild(pct); document.body.appendChild(overlayEl); } function updateLoadingOverlay(percent, text) { if (!overlayEl) return; const p = Math.max(0, Math.min(100, Math.round(percent || 0))); if (barEl) barEl.style.width = p + '%'; const pct = overlayEl.querySelector('#loadingPct'); if (pct) pct.textContent = p + '%'; if (text && textEl) textEl.textContent = text; } function removeLoadingOverlay() { if (!overlayEl) return; overlayEl.style.transition = 'opacity .2s ease'; overlayEl.style.opacity = '0'; setTimeout(() => { overlayEl && overlayEl.remove(); overlayEl = barEl = textEl = null; }, 220); } // Общий менеджер загрузки (для GLTF/Texture и т.п.) const loadingManager = new THREE.LoadingManager(); loadingManagerRef.current = loadingManager; loadingManager.onStart = (_url, loaded, total) => { createLoadingOverlay(); updateLoadingOverlay(total ? (loaded / total) * 100 : 5, 'Загрузка ресурсов...'); }; loadingManager.onProgress = (_url, loaded, total) => { updateLoadingOverlay(total ? (loaded / total) * 100 : 50); }; loadingManager.onLoad = () => { updateLoadingOverlay(100, 'Инициализация сцены...'); setTimeout(removeLoadingOverlay, 150); }; console.log('–– useEffect начало'); const baseOffset = new THREE.Vector3(-200, 150, -200); const planarDist = Math.hypot(baseOffset.x, baseOffset.z); const radius = Math.hypot(planarDist, baseOffset.y); const baseAzimuth = Math.atan2(baseOffset.z, baseOffset.x); const basePolar = Math.atan2(baseOffset.y, planarDist); let cameraPitchOffset = 0; const maxPitch = THREE.MathUtils.degToRad(10); let zoom = 10; const minZoom = zoom * 0.1; const maxZoom = zoom * 3.5; let scene, renderer; 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; const clock = new THREE.Clock(); 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'); socketRef.current = io({ transports: ['websocket','polling'], auth: { token } }); const socket = socketRef.current; console.log('socket инстанс:', socket); socket.on('connect', () => console.log('✔ Socket connected, id=', socket.id)); socket.on('connect_error', err => console.error('Socket connect_error:', err)); socket.on('disconnect', reason => console.warn('Socket disconnected:', reason)); const profile = JSON.parse(sessionStorage.getItem('user_profile') || '{}'); if (profile?.id) { socket.emit('economy:getBalance', { userId: profile.id }); } const balanceInterval = setInterval(() => { const p = JSON.parse(sessionStorage.getItem('user_profile') || '{}'); if (p?.id) socket.emit('economy:getBalance', { userId: p.id }); }, 3000); socket.on('economy:balanceChanged', ({ userId, newBalance }) => { if (userId === profile.id) { setBalance(newBalance); const upd = { ...(profile || {}), balance: newBalance }; sessionStorage.setItem('user_profile', JSON.stringify(upd)); } }); socket.emit('economy:getInventory', { userId: profile.id }); socket.on('economy:inventory', setInventory); socket.on('gameTime:update', ({ time }) => setGameTime(time)); // Лоадеры, учитывающиеся в прогрессе через loadingManagerRef const gltfLoader = new GLTFLoader(loadingManagerRef.current || undefined); const animLoader = new GLTFLoader(loadingManagerRef.current || undefined); async function loadPlayerModel(avatarUrl) { return new Promise((resolve, reject) => { gltfLoader.load(avatarUrl, (gltf) => { if (!gltf.scene) return reject('GLTF.scene отсутствует'); resolve(gltf); }, undefined, (err) => reject(err)); }); } async function addOtherPlayer(id, x, z, avatarURL, genderRemote = 'male', firstName = '', lastName = '') { let model; try { if (!avatarURL) throw new Error('no avatarURL'); const gltf = await loadPlayerModel(avatarURL); model = gltf.scene; } 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, 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(0.6); } 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); // Получаем cityId текущего игрока из профиля const myProfile = JSON.parse(sessionStorage.getItem('user_profile') || '{}'); const myCityId = myProfile.last_city_id || 1; Object.keys(players).forEach(id => { if (id === socket.id) return; const { x, z, avatarURL, gender, firstName, lastName, cityId } = players[id]; if (cityId && cityId !== myCityId) return; // показываем только игроков своего города addOtherPlayer(id, x, z, avatarURL, gender, firstName, lastName); }); // После получения списка игроков, отправляем newPlayer о себе const profile = myProfile; socket.emit('newPlayer', { x: player?.position?.x || 0, z: player?.position?.z || 0, avatarURL: avatarUrl, firstName: profile.firstName, lastName: profile.lastName, userId: profile.id, cityId: myCityId }); }); 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, 0, 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) { remote.currentAction.fadeOut(0.2); remote.walkAction.reset().fadeIn(0.2).play(); remote.currentAction = remote.walkAction; } clearTimeout(remote._idleTimeout); remote._idleTimeout = setTimeout(() => { if (remote.currentAction !== remote.idleAction) { remote.currentAction.fadeOut(0.2); remote.idleAction.reset().fadeIn(0.2).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; 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); }); // Мини-лоадер при загрузке интерьеров (обёртка поверх loadInteriorScene) const _origLoadInteriorScene = loadInteriorScene; loadInteriorScene = async (interiorId) => { try { // показываем мини-оверлей на время подзагрузки интерьера createLoadingOverlay(); updateLoadingOverlay(30, 'Загрузка интерьера...'); await _origLoadInteriorScene(interiorId); } finally { setTimeout(removeLoadingOverlay, 120); } }; function onMouseWheel(e) { e.preventDefault(); const delta = -e.deltaY * 0.001; if (e.ctrlKey) { cameraPitchOffset = THREE.MathUtils.clamp( cameraPitchOffset + delta, -maxPitch, maxPitch ); } else { if (cameraRef.current === orthoCamRef.current) { zoom = THREE.MathUtils.clamp(zoom * (1 + delta), minZoom, maxZoom); orthoCamRef.current.zoom = zoom; orthoCamRef.current.updateProjectionMatrix(); } } } function onMouseLookMove(e) { if (!isInInteriorRef.current || cameraRef.current !== fpCamRef.current || !playerRef.current) return; const movementX = e.movementX || e.mozMovementX || e.webkitMovementX || 0; const movementY = e.movementY || e.mozMovementY || e.webkitMovementY || 0; playerRef.current.rotation.y -= movementX * 0.002; fpPitchRef.current = THREE.MathUtils.clamp( fpPitchRef.current - movementY * 0.002, -Math.PI / 2 + 0.1, Math.PI / 2 - 0.1 ); } async function init() { console.log('[DEBUG] init вызван'); scene = new THREE.Scene(); //scene.fog = new THREE.FogExp2(0xcce0ff, 0.002); sceneRef.current = scene; const aspect = window.innerWidth / window.innerHeight; const d = 200; 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; renderer = new THREE.WebGLRenderer({ antialias: true }); renderer.setSize(window.innerWidth, window.innerHeight); //renderer.setClearColor(0xcce0ff); rendererRef.current = renderer; mountRef.current.appendChild(renderer.domElement); renderer.domElement.addEventListener('wheel', onMouseWheel, { passive: false }); renderer.domElement.addEventListener('mousemove', onMouseLookMove); const planeGeometry = new THREE.PlaneGeometry(territorySize, territorySize); const planeMaterial = new THREE.MeshBasicMaterial({ color: 0x00aa00, transparent: true, opacity: 0, // невидим depthWrite: false // не трогает Z-буфер }); groundPlane = new THREE.Mesh(planeGeometry, planeMaterial); groundPlane.rotation.x = -Math.PI / 2; scene.add(groundPlane); groundRef.current = groundPlane; 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); 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); const loadingManager = new THREE.LoadingManager(() => { console.log("Все текстуры загружены"); }); const textureLoader = new THREE.TextureLoader(loadingManager); const baseTexture = textureLoader.load('textures/base.png'); customMaterial = new THREE.MeshStandardMaterial({ map: baseTexture, }); 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/Adventurer.glb', position: [0, 0, -5] }, { 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.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); // Правильное добавление в массив 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 { const gltf = await loadPlayerModel(avatarUrl); player = gltf.scene; scene.add(player); playerRef.current = player; player.scale.set(1, 1, 1); const profPos = JSON.parse(sessionStorage.getItem('user_profile') || '{}'); const startX = Number(profPos.last_pos_x ?? 0); const startZ = Number(profPos.last_pos_z ?? 0); player.position.set(startX, 0, startZ); const profile = JSON.parse(sessionStorage.getItem('user_profile') || '{}'); const myName = `${profile.firstName || ''} ${profile.lastName || ''}`.trim(); setMountRef(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' }`; const [idleGltf, walkGltf] = await Promise.all([ animLoader.loadAsync(idlePath), animLoader.loadAsync(walkPath) ]); idleGltf.animations.forEach(stripPositionTracks); walkGltf.animations.forEach(stripPositionTracks); console.log('Idle GLB анимации:', idleGltf.animations); console.log('Walk GLB анимации:', walkGltf.animations); 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(); socketRef.current?.emit('newPlayer', { x: player.position.x, z: player.position.z, avatarURL: avatarUrl, firstName: profile.firstName, lastName: profile.lastName, userId: profile.id }); } catch (err) { console.error("Ошибка загрузки модели игрока:", err); } } 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) { gltfLoader.load( obj.model_url, (gltf) => { const model = gltf.scene; model.userData = { id: obj.id, type: obj.name, organizationId: obj.organization_id, rent: obj.rent, tax: obj.tax }; model.scale.set(1, 1, 1); model.position.set(obj.pos_x, obj.pos_y, obj.pos_z); model.rotation.set(obj.rot_x, obj.rot_y, obj.rot_z); model.traverse(child => { if (child.isMesh) { child.material = customMaterial.clone(); 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(); } function updateCityObjectVisibility() { if (!player) return; const p = player.position; cityObjectsDataRef.current.forEach(obj => { const dist = Math.hypot(obj.pos_x - p.x, obj.pos_z - p.z); if (dist <= LOAD_RADIUS) { if (!loadedCityObjectsRef.current[obj.id]) loadCityObject(obj); } else { if (loadedCityObjectsRef.current[obj.id]) unloadCityObject(obj.id); } }); interiorsDataRef.current.forEach(int => { const dist = Math.hypot(int.pos_x - p.x, int.pos_z - p.z); if (dist <= LOAD_RADIUS) { if (!loadedInteriorMeshesRef.current[int.id]) loadInteriorPlaceholder(int); } else if (loadedInteriorMeshesRef.current[int.id]) { unloadInteriorPlaceholder(int.id); } }); } function loadInteriorPlaceholder(int) { const mesh = new THREE.Mesh( new THREE.BoxGeometry(2, 2, 2), new THREE.MeshStandardMaterial({ color: 0x00ffcc }) ); 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) { await loadInteriorScene(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 (isInInteriorRef.current) { 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 (event.key.toLowerCase() === 'i') { const prof = JSON.parse(sessionStorage.getItem('user_profile') || '{}'); socket.emit('economy:getInventory', { userId: prof.id }); setShowInventory(v => !v); } destination = null; destinationMarker.visible = false; } function onKeyUp(event) { keys[event.key] = 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'); } } function createPlayerLabel(text) { const canvas = document.createElement('canvas'); canvas.width = 256; canvas.height = 64; const ctx = canvas.getContext('2d'); const fontSize = 15; ctx.fillStyle = 'white'; ctx.font = `${fontSize}px Arial`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; 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 }); const sprite = new THREE.Sprite(spriteMaterial); sprite.scale.set(0.5, 0.5, 1); // ↓↓↓ добавь это ↓↓↓ sprite.raycast = () => {}; sprite.userData.isUiSprite = true; return sprite; } function switchAnimation(newAction) { if (!newAction || !currentAction || newAction === currentAction) return; currentAction.fadeOut(0.2); newAction.reset().fadeIn(0.2).play(); currentAction = newAction; } function canMove(newPosition) { const halfSize = 1; const playerMin = new THREE.Vector2(newPosition.x - halfSize, newPosition.z - halfSize); const playerMax = new THREE.Vector2(newPosition.x + halfSize, newPosition.z + halfSize); for (let i = 0; i < obstacles.length; i++) { obstacles[i].mesh.updateMatrixWorld(); const box = new THREE.Box3().setFromObject(obstacles[i].mesh); const obstacleMin = new THREE.Vector2(box.min.x, box.min.z); const obstacleMax = new THREE.Vector2(box.max.x, box.max.z); if ((playerMin.x <= obstacleMax.x && playerMax.x >= obstacleMin.x) && (playerMin.y <= obstacleMax.y && playerMax.y >= obstacleMin.y)) { return false; } } return true; } function updateDestinationMovement(delta) { if (!player || currentPath.length === 0 || pathIndex >= currentPath.length) return; const target = currentPath[pathIndex]; const dir = new THREE.Vector3().subVectors(target, player.position); dir.y = 0; const dist = dir.length(); const stepDistance = moveSpeed * delta; if (dist < stepDistance) { player.position.copy(target); pathIndex++; blockedTime = 0; if (pathIndex >= currentPath.length) { currentPath = []; destination = null; if (currentAction !== idleAction) { currentAction.fadeOut(0.2); idleAction.reset().fadeIn(0.2).play(); currentAction = idleAction; } } return; } dir.normalize(); const step = dir.clone().multiplyScalar(stepDistance); // Кандидаты перемещения: прямо, слайд по X, слайд по Z const tryMoves = [ player.position.clone().add(step), player.position.clone().add(new THREE.Vector3(step.x, 0, 0)), player.position.clone().add(new THREE.Vector3(0, 0, step.z)) ]; // Помощник: «привязка» к верхней поверхности const stickToTopSurface = (pos) => { const downRay = new THREE.Raycaster( new THREE.Vector3(pos.x, 100, pos.z), new THREE.Vector3(0, -1, 0), 0, 300 ); downRay.camera = cameraRef.current; // важное дополнение для спрайтов // фильтруем null/undefined const walkables = [groundPlane, ...(cityMeshesRef.current || [])].filter(Boolean); const hits = downRay .intersectObjects(walkables, true) .filter(h => !h.object.isSprite && h.face && h.face.normal && h.face.normal.y > 0.6); if (hits.length) { pos.y = hits[0].point.y + 0.02; // лёгкий "антизалип" } }; let moved = false; for (const candidate of tryMoves) { if (canMove(candidate)) { stickToTopSurface(candidate); player.position.copy(candidate); moved = true; blockedTime = 0; break; } } if (moved) { const angle = Math.atan2(dir.x, dir.z); const targetQuat = new THREE.Quaternion().setFromEuler(new THREE.Euler(0, angle, 0)); player.quaternion.slerp(targetQuat, Math.min(1, 10 * delta)); socketRef.current?.emit('playerMovement', { x: player.position.x, z: player.position.z }); if (currentAction !== walkAction) { currentAction.fadeOut(0.2); walkAction.reset().fadeIn(0.2).play(); currentAction = walkAction; } } else { // полностью заблокированы blockedTime += delta; // Пробуем перепроложить путь к текущей цели, // либо через 0.35с сдаёмся и ставим idle if (destination && blockedTime > 0.1) { const newPath = computePath(player.position, destination); if (newPath.length > 0) { currentPath = newPath; pathIndex = 0; // оставляем walk if (currentAction !== walkAction) { currentAction.fadeOut(0.2); walkAction.reset().fadeIn(0.2).play(); currentAction = walkAction; } return; } } if (blockedTime > 0.35) { currentPath = []; destination = null; if (currentAction !== idleAction) { currentAction.fadeOut(0.2); idleAction.reset().fadeIn(0.2).play(); currentAction = idleAction; } } } } function updateTransparency() { if (!player) return; obstacles.forEach(obstacle => { obstacle.mesh.traverse(child => { if (child.isMesh && child.material) { 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) { 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) { 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 = 3; const rot = Math.PI; if (move.left) player.rotation.y += rot * delta; if (move.right) player.rotation.y -= rot * delta; const forward = new THREE.Vector3(0, 0, -1).applyQuaternion(player.quaternion); if (move.forward) player.position.addScaledVector(forward, speed * delta); if (move.backward) player.position.addScaledVector(forward, -speed * delta); } function updateCameraFollow() { if (!player) return; const target = player.position.clone(); if (cameraRef.current === fpCamRef.current) { const yaw = player.rotation.y; const pitch = fpPitchRef.current; const headPos = target.clone().add(new THREE.Vector3(0, 1.6, 0)); cameraRef.current.position.copy(headPos); const forward = new THREE.Vector3(0, 0, -1).applyEuler( new THREE.Euler(pitch, yaw, 0, 'YXZ') ); cameraRef.current.lookAt(headPos.clone().add(forward)); return; } const polar = basePolar + cameraPitchOffset; const planar = radius * Math.cos(polar); const yOff = radius * Math.sin(polar); const xOff = planar * Math.cos(baseAzimuth); const zOff = planar * Math.sin(baseAzimuth); cameraRef.current.position.set( target.x + xOff, target.y + yOff, target.z + zOff ); cameraRef.current.lookAt(target); } function animate() { requestAnimationFrame(animate); const delta = clock.getDelta(); updateDestinationMovement(delta); updateFirstPersonMovement(delta); if (mixer) mixer.update(delta); updateTransparency(); updateCityObjectVisibility(); updateCameraFollow(); for (let id in remotePlayers) { const r = remotePlayers[id]; if (r.targetPosition) { r.model.position.lerp(r.targetPosition, 0.1); } r.mixer.update(delta); } renderer.render(scene, 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(); } rendererRef.current.setSize(window.innerWidth, window.innerHeight); } window.addEventListener('resize', onWindowResize, false); return () => { clearInterval(balanceInterval); window.removeEventListener('keydown', onKeyDown); window.removeEventListener('keyup', onKeyUp); renderer.domElement.removeEventListener('pointerdown', onDocumentMouseDown); renderer.domElement.removeEventListener('wheel', onMouseWheel); renderer.domElement.removeEventListener('mousemove', onMouseLookMove); 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}
{new Date(gameTime).toLocaleString()}
{/* Кнопка карты мира */} {isInInterior && ( )} {isInInterior && isTouchDevice && (
)} {selectedHouse && !isInInterior && (
)} {/* Модальное окно выбора города */} {showWorldMap && (

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

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

🏠 {selectedHouse.type}

ID: {selectedHouse.id}

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

Налог: {selectedHouse.tax}

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

{currentDialog.name}

{currentForm ? (

{currentForm.title}

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