7769 lines
386 KiB
JavaScript
7769 lines
386 KiB
JavaScript
/*
|
||
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';
|
||
import QuestSystem from './pages/QuestSystem ';
|
||
|
||
function Game({ avatarUrl, gender }) {
|
||
const [showQuests, setShowQuests] = useState(false);
|
||
// 1) реф для хранилища сцены
|
||
const sceneRef = useRef(new THREE.Scene());
|
||
|
||
// 2) реф для группы «города»
|
||
const cityGroupRef = useRef(null);
|
||
|
||
// 3) реф для группы «интерьера»
|
||
const interiorGroupRef = useRef(null);
|
||
const interiorCollidersRef = useRef([]);
|
||
const interiorColliderBoxesRef = useRef([]);
|
||
const jsonCollidersRef = useRef([]);
|
||
const visualCollidersRef = useRef([]);
|
||
const interiorExitPosRef = useRef(null);
|
||
const fpHiddenNodesRef = useRef([]);
|
||
const interiorDebugEnabledRef = useRef(false);
|
||
const interiorDebugHelpersRef = 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 COLLIDER_CONFIG = {
|
||
sizeMultiplier: 2.0, // Коэффициент увеличения размеров для полного покрытия объекта
|
||
debugMode: false, // Режим отладки для визуализации коллайдеров
|
||
minSize: 0.5, // Минимальный размер коллайдера
|
||
maxSize: 50.0, // Максимальный размер коллайдера
|
||
adaptiveScaling: true // Адаптивное масштабирование на основе размеров объекта
|
||
};
|
||
|
||
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 = (
|
||
<button
|
||
style={{
|
||
position: 'absolute',
|
||
top: 20,
|
||
right: 180,
|
||
zIndex: 1000,
|
||
padding: '10px 18px',
|
||
background: '#d35400',
|
||
color: 'white',
|
||
border: 'none',
|
||
borderRadius: '8px',
|
||
fontSize: '18px',
|
||
cursor: 'pointer',
|
||
boxShadow: '0 2px 8px rgba(0,0,0,0.15)'
|
||
}}
|
||
onClick={loadCleanupGame}
|
||
>
|
||
Чистка или компромат
|
||
</button>
|
||
);
|
||
|
||
|
||
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('Загружаем модель интерьера для ID:', interiorId);
|
||
try {
|
||
await loadInteriorModel(interiorId);
|
||
console.log('loadInteriorModel завершена успешно');
|
||
} catch (error) {
|
||
console.error('Ошибка в loadInteriorModel:', error);
|
||
}
|
||
|
||
// Загружаем коллизионные данные из JSON
|
||
console.log('Загружаем коллизионные данные из JSON...');
|
||
try {
|
||
const jsonColliders = await loadCollidersFromJSON(1); // Пока используем город 1
|
||
console.log('🔍 Результат loadCollidersFromJSON:', jsonColliders);
|
||
jsonCollidersRef.current = jsonColliders;
|
||
console.log('🔍 jsonCollidersRef.current установлен:', jsonCollidersRef.current?.length || 0, 'объектов');
|
||
console.log('Коллизионные данные загружены:', jsonColliders.length, 'объектов');
|
||
|
||
// Автоматически применяем цвета и прозрачность из JSON к объектам
|
||
console.log('🎨 Автоматически применяем цвета из JSON к объектам...');
|
||
setTimeout(() => {
|
||
if (window.applyJsonColorsToObjects) {
|
||
window.applyJsonColorsToObjects();
|
||
}
|
||
}, 100); // Небольшая задержка для завершения загрузки объектов
|
||
|
||
// Добавляем визуальные коллайдеры в сцену
|
||
const visualColliders = jsonColliders.map(collider => collider.visual);
|
||
visualCollidersRef.current = visualColliders;
|
||
|
||
visualColliders.forEach(collider => {
|
||
if (sceneRef.current) {
|
||
sceneRef.current.add(collider);
|
||
console.log('Добавлен визуальный коллайдер в сцену');
|
||
}
|
||
});
|
||
|
||
// Добавляем функции для настройки коллайдеров в глобальную область
|
||
window.colliderConfig = COLLIDER_CONFIG;
|
||
window.updateColliderSize = (multiplier) => {
|
||
COLLIDER_CONFIG.sizeMultiplier = multiplier;
|
||
console.log('🔧 Обновлен коэффициент размера коллайдеров:', multiplier);
|
||
reloadColliders();
|
||
};
|
||
|
||
window.toggleAdaptiveScaling = () => {
|
||
COLLIDER_CONFIG.adaptiveScaling = !COLLIDER_CONFIG.adaptiveScaling;
|
||
console.log('🔧 Адаптивное масштабирование:', COLLIDER_CONFIG.adaptiveScaling ? 'включено' : 'выключено');
|
||
reloadColliders();
|
||
};
|
||
|
||
window.setColliderLimits = (minSize, maxSize) => {
|
||
COLLIDER_CONFIG.minSize = minSize;
|
||
COLLIDER_CONFIG.maxSize = maxSize;
|
||
console.log('🔧 Установлены ограничения размеров:', { minSize, maxSize });
|
||
reloadColliders();
|
||
};
|
||
|
||
window.toggleColliderDebug = () => {
|
||
COLLIDER_CONFIG.debugMode = !COLLIDER_CONFIG.debugMode;
|
||
console.log('🔧 Режим отладки коллайдеров:', COLLIDER_CONFIG.debugMode ? 'включен' : 'выключен');
|
||
// Обновляем видимость визуальных коллайдеров
|
||
visualCollidersRef.current.forEach(collider => {
|
||
collider.visible = COLLIDER_CONFIG.debugMode;
|
||
});
|
||
};
|
||
|
||
// Функция для принудительной перезагрузки всех коллайдеров из базы данных
|
||
window.reloadAllColliders = async () => {
|
||
console.log('🔄 Принудительная перезагрузка всех коллайдеров...');
|
||
try {
|
||
// Перезагружаем коллизионные коллайдеры
|
||
await loadCollidersFromJSON(1);
|
||
console.log('✅ Коллизионные коллайдеры перезагружены');
|
||
|
||
// Перезагружаем визуальные коллайдеры
|
||
await loadCustomCollidersForCity(1);
|
||
console.log('✅ Визуальные коллайдеры перезагружены');
|
||
|
||
console.log('🎉 Все коллайдеры успешно перезагружены из базы данных');
|
||
} catch (error) {
|
||
console.error('❌ Ошибка при перезагрузке коллайдеров:', error);
|
||
}
|
||
};
|
||
|
||
// Функция для проверки состояния коллайдеров в базе данных
|
||
window.checkCollidersInDB = async () => {
|
||
console.log('🔍 Проверяем коллайдеры в базе данных...');
|
||
try {
|
||
const token = localStorage.getItem('token');
|
||
const response = await fetch('/api/colliders/city/1', {
|
||
headers: { Authorization: `Bearer ${token}` }
|
||
});
|
||
|
||
if (response.ok) {
|
||
const data = await response.json();
|
||
console.log('📊 Коллайдеры в БД:', data.colliders?.length || 0, 'штук');
|
||
console.log('🔍 Данные из БД:', data);
|
||
|
||
// Сравниваем с текущими коллайдерами в игре
|
||
console.log('📊 Коллизионные коллайдеры в игре:', jsonCollidersRef.current?.length || 0, 'штук');
|
||
console.log('📊 Визуальные коллайдеры в игре:', visualCollidersRef.current?.length || 0, 'штук');
|
||
|
||
return data;
|
||
} else {
|
||
console.error('❌ Ошибка загрузки коллайдеров из БД:', response.status);
|
||
}
|
||
} catch (error) {
|
||
console.error('❌ Ошибка при проверке коллайдеров в БД:', error);
|
||
}
|
||
};
|
||
|
||
// Функция для обновления прозрачности всех коллизионных объектов
|
||
window.updateColliderOpacity = (opacity) => {
|
||
console.log('👁️ Обновляем прозрачность всех коллизионных объектов:', opacity);
|
||
obstacles.forEach(obstacle => {
|
||
if (obstacle.mesh && obstacle.mesh.userData.isCustomCollider) {
|
||
obstacle.mesh.material.opacity = opacity;
|
||
if (opacity === 0) {
|
||
obstacle.mesh.material.visible = false;
|
||
obstacle.mesh.material.alphaTest = 0;
|
||
} else {
|
||
obstacle.mesh.material.visible = true;
|
||
obstacle.mesh.material.alphaTest = 0.1;
|
||
}
|
||
}
|
||
});
|
||
console.log('✅ Прозрачность обновлена для', obstacles.length, 'коллизионных объектов');
|
||
};
|
||
|
||
// Функция для включения/выключения отображения коллизионных объектов
|
||
window.toggleColliderVisibility = (visible) => {
|
||
console.log('👁️ Переключаем видимость коллизионных объектов:', visible);
|
||
obstacles.forEach(obstacle => {
|
||
if (obstacle.mesh && obstacle.mesh.userData.isCustomCollider) {
|
||
obstacle.mesh.visible = visible;
|
||
}
|
||
});
|
||
console.log('✅ Видимость коллизионных объектов:', visible ? 'включена' : 'выключена');
|
||
};
|
||
|
||
window.setColliderColor = (r, g, b) => {
|
||
console.log('🎨 Устанавливаем цвет коллайдеров:', { r, g, b });
|
||
visualCollidersRef.current.forEach(collider => {
|
||
if (collider.material) {
|
||
const color = (Math.floor(r * 255) << 16) | (Math.floor(g * 255) << 8) | Math.floor(b * 255);
|
||
collider.material.color.setHex(color);
|
||
}
|
||
});
|
||
};
|
||
|
||
window.setColliderOpacity = (opacity) => {
|
||
const clampedOpacity = Math.max(0, Math.min(1, opacity));
|
||
console.log('👁️ Устанавливаем прозрачность коллайдеров:', clampedOpacity);
|
||
visualCollidersRef.current.forEach(collider => {
|
||
if (collider.material) {
|
||
collider.material.opacity = clampedOpacity;
|
||
}
|
||
});
|
||
};
|
||
|
||
window.randomizeColliderColors = () => {
|
||
console.log('🌈 Случайные цвета для коллайдеров');
|
||
visualCollidersRef.current.forEach(collider => {
|
||
if (collider.material) {
|
||
const r = Math.random();
|
||
const g = Math.random();
|
||
const b = Math.random();
|
||
const color = (Math.floor(r * 255) << 16) | (Math.floor(g * 255) << 8) | Math.floor(b * 255);
|
||
collider.material.color.setHex(color);
|
||
}
|
||
});
|
||
};
|
||
|
||
window.setInteriorObjectColor = (r, g, b) => {
|
||
console.log('🎨 Устанавливаем цвет объектов интерьера:', { r, g, b });
|
||
const color = (Math.floor(r * 255) << 16) | (Math.floor(g * 255) << 8) | Math.floor(b * 255);
|
||
|
||
console.log('🔍 Проверяем interiorGroupRef.current:', interiorGroupRef.current);
|
||
console.log('🔍 Проверяем sceneRef.current:', sceneRef.current);
|
||
|
||
// Ищем группу интерьера в сцене
|
||
let interiorGroup = interiorGroupRef.current;
|
||
if (!interiorGroup && sceneRef.current) {
|
||
interiorGroup = sceneRef.current.getObjectByName('interiorGroup');
|
||
console.log('🔍 Найдена группа интерьера по имени:', interiorGroup);
|
||
}
|
||
|
||
if (!interiorGroup) {
|
||
console.warn('⚠️ Группа интерьера не найдена!');
|
||
return;
|
||
}
|
||
|
||
let meshCount = 0;
|
||
let materialCount = 0;
|
||
|
||
// Применяем цвет ко всем объектам интерьера
|
||
interiorGroup.traverse((child) => {
|
||
if (child.isMesh && child.material) {
|
||
meshCount++;
|
||
console.log('🔍 Обрабатываем меш:', child.name || 'unnamed', 'материал:', child.material);
|
||
|
||
if (Array.isArray(child.material)) {
|
||
child.material.forEach(mat => {
|
||
if (mat) {
|
||
materialCount++;
|
||
console.log('🔍 Изменяем материал массива:', mat);
|
||
mat.color.setHex(color);
|
||
mat.needsUpdate = true;
|
||
}
|
||
});
|
||
} else {
|
||
materialCount++;
|
||
console.log('🔍 Изменяем материал:', child.material);
|
||
child.material.color.setHex(color);
|
||
child.material.needsUpdate = true;
|
||
}
|
||
}
|
||
});
|
||
|
||
console.log(`✅ Обработано мешей: ${meshCount}, материалов: ${materialCount}`);
|
||
};
|
||
|
||
window.debugInteriorObjects = () => {
|
||
console.log('🔍 Диагностика объектов интерьера:');
|
||
console.log('interiorGroupRef.current:', interiorGroupRef.current);
|
||
console.log('sceneRef.current:', sceneRef.current);
|
||
|
||
if (sceneRef.current) {
|
||
console.log('Все объекты в сцене:');
|
||
sceneRef.current.traverse((child) => {
|
||
if (child.isMesh) {
|
||
console.log('Меш:', child.name || 'unnamed', 'позиция:', child.position, 'материал:', child.material);
|
||
} else if (child.isGroup) {
|
||
console.log('Группа:', child.name || 'unnamed', 'дети:', child.children.length);
|
||
}
|
||
});
|
||
}
|
||
|
||
// Ищем группу интерьера
|
||
if (sceneRef.current) {
|
||
const interiorGroup = sceneRef.current.getObjectByName('interiorGroup');
|
||
console.log('Группа интерьера найдена:', interiorGroup);
|
||
|
||
if (interiorGroup) {
|
||
console.log('Объекты в группе интерьера:');
|
||
interiorGroup.traverse((child) => {
|
||
if (child.isMesh) {
|
||
console.log('Меш в интерьере:', child.name || 'unnamed', 'материал:', child.material);
|
||
}
|
||
});
|
||
}
|
||
}
|
||
};
|
||
|
||
window.setColliderObjectsColor = (r, g, b) => {
|
||
console.log('🎨 Устанавливаем цвет только объектов из JSON коллайдеров:', { r, g, b });
|
||
const color = (Math.floor(r * 255) << 16) | (Math.floor(g * 255) << 8) | Math.floor(b * 255);
|
||
|
||
if (!jsonCollidersRef.current || jsonCollidersRef.current.length === 0) {
|
||
console.warn('⚠️ JSON коллайдеры не найдены!');
|
||
return;
|
||
}
|
||
|
||
if (!sceneRef.current) {
|
||
console.warn('⚠️ Сцена не найдена!');
|
||
return;
|
||
}
|
||
|
||
let processedCount = 0;
|
||
|
||
// Проходим по всем коллайдерам из JSON
|
||
jsonCollidersRef.current.forEach((colliderData, index) => {
|
||
const colliderPos = colliderData.data.position;
|
||
console.log(`🔍 Ищем объект для коллайдера ${index} в позиции:`, colliderPos);
|
||
|
||
// Ищем объекты в сцене, которые находятся рядом с позицией коллайдера
|
||
sceneRef.current.traverse((child) => {
|
||
if (child.isMesh && child.material) {
|
||
const distance = Math.sqrt(
|
||
Math.pow(child.position.x - colliderPos.x, 2) +
|
||
Math.pow(child.position.y - colliderPos.y, 2) +
|
||
Math.pow(child.position.z - colliderPos.z, 2)
|
||
);
|
||
|
||
// Если объект находится в радиусе 2 единиц от коллайдера
|
||
if (distance < 2.0) {
|
||
console.log(`🎯 Найден объект для коллайдера ${index}:`, child.name || 'unnamed', 'расстояние:', distance);
|
||
|
||
if (Array.isArray(child.material)) {
|
||
child.material.forEach(mat => {
|
||
if (mat) {
|
||
mat.color.setHex(color);
|
||
mat.needsUpdate = true;
|
||
}
|
||
});
|
||
} else {
|
||
child.material.color.setHex(color);
|
||
child.material.needsUpdate = true;
|
||
}
|
||
|
||
processedCount++;
|
||
}
|
||
}
|
||
});
|
||
});
|
||
|
||
console.log(`✅ Обработано объектов: ${processedCount}`);
|
||
};
|
||
|
||
window.applyJsonColorsToObjects = () => {
|
||
console.log('🎨 Применяем цвета и прозрачность из JSON к объектам в сцене');
|
||
|
||
if (!jsonCollidersRef.current || jsonCollidersRef.current.length === 0) {
|
||
console.warn('⚠️ JSON коллайдеры не найдены!');
|
||
return;
|
||
}
|
||
|
||
if (!sceneRef.current) {
|
||
console.warn('⚠️ Сцена не найдена!');
|
||
return;
|
||
}
|
||
|
||
let processedCount = 0;
|
||
|
||
// Проходим по всем коллайдерам из JSON
|
||
jsonCollidersRef.current.forEach((colliderData, index) => {
|
||
const colliderPos = colliderData.data.position;
|
||
const colliderData_obj = colliderData.data;
|
||
|
||
console.log(`🔍 Применяем настройки коллайдера ${index}:`, colliderData_obj);
|
||
|
||
// Определяем цвет и прозрачность из JSON данных
|
||
let color = 0xffffff; // Белый по умолчанию
|
||
let opacity = 1.0; // Полная непрозрачность по умолчанию
|
||
|
||
if (colliderData_obj.color) {
|
||
const r = Math.floor((colliderData_obj.color.r || 1.0) * 255);
|
||
const g = Math.floor((colliderData_obj.color.g || 1.0) * 255);
|
||
const b = Math.floor((colliderData_obj.color.b || 1.0) * 255);
|
||
color = (r << 16) | (g << 8) | b;
|
||
}
|
||
|
||
if (colliderData_obj.opacity !== undefined) {
|
||
opacity = Math.max(0, Math.min(1, colliderData_obj.opacity));
|
||
}
|
||
|
||
console.log(`🎨 Применяем цвет ${color.toString(16)} и прозрачность ${opacity} для коллайдера ${index}`);
|
||
|
||
// Ищем объекты в сцене, которые находятся рядом с позицией коллайдера
|
||
sceneRef.current.traverse((child) => {
|
||
if (child.isMesh && child.material) {
|
||
const distance = Math.sqrt(
|
||
Math.pow(child.position.x - colliderPos.x, 2) +
|
||
Math.pow(child.position.y - colliderPos.y, 2) +
|
||
Math.pow(child.position.z - colliderPos.z, 2)
|
||
);
|
||
|
||
// Если объект находится в радиусе 2 единиц от коллайдера
|
||
if (distance < 2.0) {
|
||
console.log(`🎯 Найден объект для коллайдера ${index}:`, child.name || 'unnamed', 'расстояние:', distance);
|
||
|
||
if (Array.isArray(child.material)) {
|
||
child.material.forEach(mat => {
|
||
if (mat) {
|
||
mat.color.setHex(color);
|
||
mat.transparent = opacity < 1.0;
|
||
mat.opacity = opacity;
|
||
mat.needsUpdate = true;
|
||
}
|
||
});
|
||
} else {
|
||
child.material.color.setHex(color);
|
||
child.material.transparent = opacity < 1.0;
|
||
child.material.opacity = opacity;
|
||
child.material.needsUpdate = true;
|
||
}
|
||
|
||
processedCount++;
|
||
}
|
||
}
|
||
});
|
||
});
|
||
|
||
console.log(`✅ Применены настройки к ${processedCount} объектам`);
|
||
};
|
||
|
||
window.setAllObjectsColor = (r, g, b) => {
|
||
console.log('🎨 Устанавливаем цвет ВСЕХ объектов в сцене:', { r, g, b });
|
||
const color = (Math.floor(r * 255) << 16) | (Math.floor(g * 255) << 8) | Math.floor(b * 255);
|
||
|
||
if (!sceneRef.current) {
|
||
console.warn('⚠️ Сцена не найдена!');
|
||
return;
|
||
}
|
||
|
||
let meshCount = 0;
|
||
let materialCount = 0;
|
||
|
||
// Применяем цвет ко всем объектам в сцене
|
||
sceneRef.current.traverse((child) => {
|
||
if (child.isMesh && child.material) {
|
||
meshCount++;
|
||
console.log('🔍 Обрабатываем меш:', child.name || 'unnamed');
|
||
|
||
if (Array.isArray(child.material)) {
|
||
child.material.forEach(mat => {
|
||
if (mat) {
|
||
materialCount++;
|
||
mat.color.setHex(color);
|
||
mat.needsUpdate = true;
|
||
}
|
||
});
|
||
} else {
|
||
materialCount++;
|
||
child.material.color.setHex(color);
|
||
child.material.needsUpdate = true;
|
||
}
|
||
}
|
||
});
|
||
|
||
console.log(`✅ Обработано мешей: ${meshCount}, материалов: ${materialCount}`);
|
||
};
|
||
|
||
window.setInteriorObjectOpacity = (opacity) => {
|
||
const clampedOpacity = Math.max(0, Math.min(1, opacity));
|
||
console.log('👁️ Устанавливаем прозрачность объектов интерьера:', clampedOpacity);
|
||
|
||
// Применяем прозрачность ко всем объектам интерьера
|
||
if (interiorGroupRef.current) {
|
||
interiorGroupRef.current.traverse((child) => {
|
||
if (child.isMesh && child.material) {
|
||
if (Array.isArray(child.material)) {
|
||
child.material.forEach(mat => {
|
||
if (mat) {
|
||
mat.transparent = clampedOpacity < 1.0;
|
||
mat.opacity = clampedOpacity;
|
||
mat.needsUpdate = true;
|
||
}
|
||
});
|
||
} else {
|
||
child.material.transparent = clampedOpacity < 1.0;
|
||
child.material.opacity = clampedOpacity;
|
||
child.material.needsUpdate = true;
|
||
}
|
||
}
|
||
});
|
||
}
|
||
};
|
||
|
||
const reloadColliders = () => {
|
||
loadCollidersFromJSON(1).then(newColliders => {
|
||
// Удаляем старые визуальные коллайдеры
|
||
visualCollidersRef.current.forEach(collider => {
|
||
if (sceneRef.current) {
|
||
sceneRef.current.remove(collider);
|
||
}
|
||
});
|
||
|
||
// Обновляем данные
|
||
jsonCollidersRef.current = newColliders;
|
||
visualCollidersRef.current = newColliders.map(collider => collider.visual);
|
||
|
||
// Добавляем новые визуальные коллайдеры
|
||
visualCollidersRef.current.forEach(collider => {
|
||
if (sceneRef.current) {
|
||
sceneRef.current.add(collider);
|
||
collider.visible = COLLIDER_CONFIG.debugMode;
|
||
}
|
||
});
|
||
|
||
console.log('✅ Коллайдеры перезагружены с новыми настройками');
|
||
});
|
||
};
|
||
|
||
window.testCollisions = () => {
|
||
console.log('🧪 Тестируем коллизии:');
|
||
console.log('JSON коллайдеров в ref:', jsonCollidersRef.current?.length || 0);
|
||
console.log('JSON коллайдеров в переменной:', jsonColliders.length);
|
||
console.log('Позиция игрока:', playerRef.current?.position);
|
||
console.log('jsonCollidersRef.current:', jsonCollidersRef.current);
|
||
|
||
if (jsonCollidersRef.current && jsonCollidersRef.current.length > 0) {
|
||
const testPos = playerRef.current?.position || new THREE.Vector3(-13.2, -100, -69.3);
|
||
const playerBox = new THREE.Box3();
|
||
const playerRadius = 0.4;
|
||
const playerHeight = 1.6;
|
||
|
||
playerBox.setFromPoints([
|
||
new THREE.Vector3(testPos.x - playerRadius, testPos.y, testPos.z - playerRadius),
|
||
new THREE.Vector3(testPos.x + playerRadius, testPos.y + playerHeight, testPos.z + playerRadius)
|
||
]);
|
||
|
||
console.log('Player box:', playerBox.min, '->', playerBox.max);
|
||
|
||
jsonCollidersRef.current.forEach((collider, i) => {
|
||
console.log(`Коллайдер ${i}:`, collider.box.min, '->', collider.box.max);
|
||
const intersects = playerBox.intersectsBox(collider.box);
|
||
console.log(`Пересекается: ${intersects}`);
|
||
});
|
||
}
|
||
};
|
||
|
||
} catch (error) {
|
||
console.error('Ошибка загрузки коллизионных данных:', error);
|
||
}
|
||
|
||
// Переключаемся на камеру от первого лица
|
||
console.log('Переключаемся на камеру от первого лица');
|
||
switchToFirstPersonCamera();
|
||
|
||
// Включаем управление мышью для интерьера
|
||
// Курсор оставляем активным (без pointer lock)
|
||
document.body.style.cursor = 'default';
|
||
|
||
// Устанавливаем состояние "в интерьере"
|
||
console.log('Устанавливаем setIsInInterior(true)');
|
||
setIsInInterior(true);
|
||
isInInteriorRef.current = true; // Важно! Устанавливаем ref для системы коллизий
|
||
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);
|
||
}
|
||
};
|
||
|
||
// Функция для создания визуального коллайдера
|
||
const createVisualCollider = (colliderData, index) => {
|
||
const geometry = new THREE.BoxGeometry(
|
||
colliderData.scale.x,
|
||
colliderData.scale.y,
|
||
colliderData.scale.z
|
||
);
|
||
|
||
const material = new THREE.MeshBasicMaterial({
|
||
color: 0xff0000, // Красный цвет
|
||
transparent: true,
|
||
opacity: 0.3, // Полупрозрачность
|
||
wireframe: false
|
||
});
|
||
|
||
const mesh = new THREE.Mesh(geometry, material);
|
||
mesh.position.set(
|
||
colliderData.position.x,
|
||
colliderData.position.y,
|
||
colliderData.position.z
|
||
);
|
||
mesh.rotation.set(
|
||
colliderData.rotation.x,
|
||
colliderData.rotation.y,
|
||
colliderData.rotation.z
|
||
);
|
||
|
||
// Добавляем метку для отладки
|
||
mesh.userData = {
|
||
isCollider: true,
|
||
colliderIndex: index,
|
||
originalData: colliderData
|
||
};
|
||
|
||
return mesh;
|
||
};
|
||
|
||
// Создаем визуальный коллайдер на основе реальных размеров мешей из модели
|
||
const createVisualColliderFromModel = (colliderData, index) => {
|
||
// Находим соответствующий меш в модели интерьера
|
||
let targetMesh = null;
|
||
if (interiorCollidersRef.current && interiorCollidersRef.current.length > 0) {
|
||
// Ищем меш, который ближе всего к позиции коллайдера
|
||
const targetPos = new THREE.Vector3(
|
||
colliderData.position.x,
|
||
colliderData.position.y,
|
||
colliderData.position.z
|
||
);
|
||
|
||
let minDistance = Infinity;
|
||
for (const mesh of interiorCollidersRef.current) {
|
||
if (!mesh.geometry) continue;
|
||
|
||
// Получаем реальные размеры меша
|
||
const box = new THREE.Box3().setFromObject(mesh);
|
||
const center = new THREE.Vector3();
|
||
box.getCenter(center);
|
||
|
||
const distance = targetPos.distanceTo(center);
|
||
if (distance < minDistance) {
|
||
minDistance = distance;
|
||
targetMesh = mesh;
|
||
}
|
||
}
|
||
}
|
||
|
||
let geometry, position, rotation, scale;
|
||
|
||
if (targetMesh) {
|
||
// Используем реальные размеры меша
|
||
const box = new THREE.Box3().setFromObject(targetMesh);
|
||
const size = new THREE.Vector3();
|
||
const center = new THREE.Vector3();
|
||
box.getSize(size);
|
||
box.getCenter(center);
|
||
|
||
geometry = new THREE.BoxGeometry(size.x, size.y, size.z);
|
||
position = center;
|
||
rotation = targetMesh.rotation;
|
||
scale = targetMesh.scale;
|
||
|
||
console.log('🎯 Используем реальные размеры меша:', {
|
||
size: size,
|
||
center: center,
|
||
rotation: rotation,
|
||
scale: scale
|
||
});
|
||
} else {
|
||
// Используем JSON данные с умным масштабированием
|
||
let adjustedSize = new THREE.Vector3(
|
||
colliderData.scale.x,
|
||
colliderData.scale.y,
|
||
colliderData.scale.z
|
||
);
|
||
|
||
if (COLLIDER_CONFIG.adaptiveScaling) {
|
||
// Адаптивное масштабирование на основе размеров объекта
|
||
const avgSize = (adjustedSize.x + adjustedSize.y + adjustedSize.z) / 3;
|
||
|
||
if (avgSize < 1.0) {
|
||
// Для маленьких объектов используем больший коэффициент
|
||
const adaptiveMultiplier = Math.max(COLLIDER_CONFIG.sizeMultiplier, 3.0);
|
||
adjustedSize.multiplyScalar(adaptiveMultiplier);
|
||
} else if (avgSize < 5.0) {
|
||
// Для средних объектов используем стандартный коэффициент
|
||
adjustedSize.multiplyScalar(COLLIDER_CONFIG.sizeMultiplier);
|
||
} else {
|
||
// Для больших объектов используем меньший коэффициент
|
||
adjustedSize.multiplyScalar(Math.max(COLLIDER_CONFIG.sizeMultiplier * 0.8, 1.5));
|
||
}
|
||
|
||
// Применяем минимальные и максимальные ограничения
|
||
adjustedSize.x = Math.max(Math.min(adjustedSize.x, COLLIDER_CONFIG.maxSize), COLLIDER_CONFIG.minSize);
|
||
adjustedSize.y = Math.max(Math.min(adjustedSize.y, COLLIDER_CONFIG.maxSize), COLLIDER_CONFIG.minSize);
|
||
adjustedSize.z = Math.max(Math.min(adjustedSize.z, COLLIDER_CONFIG.maxSize), COLLIDER_CONFIG.minSize);
|
||
} else {
|
||
// Простое масштабирование
|
||
adjustedSize.multiplyScalar(COLLIDER_CONFIG.sizeMultiplier);
|
||
}
|
||
|
||
geometry = new THREE.BoxGeometry(adjustedSize.x, adjustedSize.y, adjustedSize.z);
|
||
position = new THREE.Vector3(
|
||
colliderData.position.x,
|
||
colliderData.position.y,
|
||
colliderData.position.z
|
||
);
|
||
rotation = new THREE.Euler(
|
||
colliderData.rotation.x,
|
||
colliderData.rotation.y,
|
||
colliderData.rotation.z
|
||
);
|
||
scale = new THREE.Vector3(1, 1, 1);
|
||
|
||
console.log('⚠️ Используем JSON данные с адаптивными размерами для коллайдера', index, {
|
||
originalSize: colliderData.scale,
|
||
adjustedSize: adjustedSize,
|
||
avgOriginalSize: (colliderData.scale.x + colliderData.scale.y + colliderData.scale.z) / 3
|
||
});
|
||
}
|
||
|
||
// Определяем цвет и прозрачность из JSON данных или используем значения по умолчанию
|
||
let color = 0xff0000; // Красный по умолчанию
|
||
let opacity = 0.3; // Прозрачность по умолчанию
|
||
|
||
if (colliderData.color) {
|
||
// Конвертируем RGB значения (0-1) в hex цвет
|
||
const r = Math.floor((colliderData.color.r || 1.0) * 255);
|
||
const g = Math.floor((colliderData.color.g || 0.0) * 255);
|
||
const b = Math.floor((colliderData.color.b || 0.0) * 255);
|
||
color = (r << 16) | (g << 8) | b;
|
||
}
|
||
|
||
if (colliderData.opacity !== undefined) {
|
||
opacity = Math.max(0, Math.min(1, colliderData.opacity)); // Ограничиваем от 0 до 1
|
||
}
|
||
|
||
const material = new THREE.MeshBasicMaterial({
|
||
color: color,
|
||
transparent: true,
|
||
opacity: opacity,
|
||
wireframe: false
|
||
});
|
||
|
||
const mesh = new THREE.Mesh(geometry, material);
|
||
mesh.position.copy(position);
|
||
mesh.rotation.copy(rotation);
|
||
mesh.scale.copy(scale);
|
||
|
||
// Добавляем метку для отладки
|
||
mesh.userData = {
|
||
isCollider: true,
|
||
colliderIndex: index,
|
||
originalData: colliderData,
|
||
isFromModel: !!targetMesh
|
||
};
|
||
|
||
return mesh;
|
||
};
|
||
|
||
// Функция для применения цвета и прозрачности к объектам интерьера
|
||
const applyColliderColorAndOpacity = (scene, objectData) => {
|
||
// Ищем соответствующий коллайдер в JSON данных
|
||
const matchingCollider = jsonCollidersRef.current?.find(collider => {
|
||
const pos = collider.data.position;
|
||
const objPos = { x: objectData.x || 0, y: objectData.y || 0, z: objectData.z || 0 };
|
||
|
||
// Проверяем близость позиций (с небольшой погрешностью)
|
||
const distance = Math.sqrt(
|
||
Math.pow(pos.x - objPos.x, 2) +
|
||
Math.pow(pos.y - objPos.y, 2) +
|
||
Math.pow(pos.z - objPos.z, 2)
|
||
);
|
||
|
||
return distance < 2.0; // Если объекты находятся в радиусе 2 единиц
|
||
});
|
||
|
||
if (matchingCollider && matchingCollider.data) {
|
||
const colliderData = matchingCollider.data;
|
||
|
||
// Определяем цвет и прозрачность
|
||
let color = 0xffffff; // Белый по умолчанию
|
||
let opacity = 1.0; // Полная непрозрачность по умолчанию
|
||
|
||
if (colliderData.color) {
|
||
const r = Math.floor((colliderData.color.r || 1.0) * 255);
|
||
const g = Math.floor((colliderData.color.g || 1.0) * 255);
|
||
const b = Math.floor((colliderData.color.b || 1.0) * 255);
|
||
color = (r << 16) | (g << 8) | b;
|
||
}
|
||
|
||
if (colliderData.opacity !== undefined) {
|
||
opacity = Math.max(0, Math.min(1, colliderData.opacity));
|
||
}
|
||
|
||
// Применяем цвет и прозрачность ко всем мешам в сцене
|
||
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.color.setHex(color);
|
||
m.transparent = opacity < 1.0;
|
||
m.opacity = opacity;
|
||
m.needsUpdate = true;
|
||
return m;
|
||
});
|
||
} else {
|
||
child.material = child.material.clone();
|
||
child.material.color.setHex(color);
|
||
child.material.transparent = opacity < 1.0;
|
||
child.material.opacity = opacity;
|
||
child.material.needsUpdate = true;
|
||
}
|
||
}
|
||
});
|
||
|
||
console.log('🎨 Применен цвет и прозрачность к объекту:', {
|
||
position: { x: objectData.x, y: objectData.y, z: objectData.z },
|
||
color: color,
|
||
opacity: opacity,
|
||
colliderData: colliderData
|
||
});
|
||
}
|
||
};
|
||
|
||
// Функция для загрузки коллизионных данных из базы данных с fallback на JSON
|
||
const loadCollidersFromJSON = async (cityId = 1) => {
|
||
console.log('🔍 loadCollidersFromJSON вызвана для города:', cityId);
|
||
try {
|
||
// Сначала пробуем загрузить из базы данных
|
||
const token = localStorage.getItem('token');
|
||
let response = await fetch(`/api/colliders/city/${cityId}`, {
|
||
headers: { Authorization: `Bearer ${token}` }
|
||
});
|
||
|
||
// Если новый API недоступен (500 ошибка), пробуем старый JSON API
|
||
if (!response.ok && response.status === 500) {
|
||
console.log('🔄 Новый API недоступен, пробуем старый JSON API...');
|
||
const url = `/colliders_city_${cityId}.json`;
|
||
console.log('🔍 Загружаем URL:', url);
|
||
response = await fetch(url);
|
||
}
|
||
|
||
console.log('🔍 Ответ сервера:', response.status, response.ok);
|
||
|
||
if (!response.ok) {
|
||
console.warn('Не удалось загрузить коллизионные данные для города:', cityId);
|
||
return [];
|
||
}
|
||
|
||
const data = await response.json();
|
||
console.log('🔍 Загруженные данные:', data);
|
||
|
||
// Обрабатываем данные в зависимости от источника
|
||
let collidersData;
|
||
if (data.colliders) {
|
||
// Данные из базы данных (уже в правильном формате)
|
||
collidersData = data.colliders;
|
||
console.log('📊 Загружены коллайдеры из базы данных:', collidersData.length, 'объектов');
|
||
console.log('🔍 Пример коллайдера из БД:', collidersData[0]);
|
||
} else if (Array.isArray(data)) {
|
||
// Данные из JSON файла (прямой массив)
|
||
collidersData = data;
|
||
console.log('📄 Загружены коллайдеры из JSON файла:', collidersData.length, 'объектов');
|
||
console.log('🔍 Пример коллайдера из JSON:', collidersData[0]);
|
||
} else {
|
||
console.warn('Неизвестный формат данных коллайдеров:', data);
|
||
return [];
|
||
}
|
||
|
||
// Преобразуем данные в Box3 объекты
|
||
const colliderBoxes = collidersData.map((colliderData, index) => {
|
||
const box = new THREE.Box3();
|
||
|
||
// Создаем центр бокса
|
||
const center = new THREE.Vector3(
|
||
colliderData.position.x,
|
||
colliderData.position.y,
|
||
colliderData.position.z
|
||
);
|
||
|
||
// Увеличиваем размеры для полного покрытия объекта с адаптивным масштабированием
|
||
let size = new THREE.Vector3(
|
||
colliderData.scale.x,
|
||
colliderData.scale.y,
|
||
colliderData.scale.z
|
||
);
|
||
|
||
if (COLLIDER_CONFIG.adaptiveScaling) {
|
||
// Адаптивное масштабирование на основе размеров объекта
|
||
const avgSize = (size.x + size.y + size.z) / 3;
|
||
|
||
if (avgSize < 1.0) {
|
||
// Для маленьких объектов используем больший коэффициент
|
||
const adaptiveMultiplier = Math.max(COLLIDER_CONFIG.sizeMultiplier, 3.0);
|
||
size.multiplyScalar(adaptiveMultiplier);
|
||
} else if (avgSize < 5.0) {
|
||
// Для средних объектов используем стандартный коэффициент
|
||
size.multiplyScalar(COLLIDER_CONFIG.sizeMultiplier);
|
||
} else {
|
||
// Для больших объектов используем меньший коэффициент
|
||
size.multiplyScalar(Math.max(COLLIDER_CONFIG.sizeMultiplier * 0.8, 1.5));
|
||
}
|
||
|
||
// Применяем минимальные и максимальные ограничения
|
||
size.x = Math.max(Math.min(size.x, COLLIDER_CONFIG.maxSize), COLLIDER_CONFIG.minSize);
|
||
size.y = Math.max(Math.min(size.y, COLLIDER_CONFIG.maxSize), COLLIDER_CONFIG.minSize);
|
||
size.z = Math.max(Math.min(size.z, COLLIDER_CONFIG.maxSize), COLLIDER_CONFIG.minSize);
|
||
} else {
|
||
// Простое масштабирование
|
||
size.multiplyScalar(COLLIDER_CONFIG.sizeMultiplier);
|
||
}
|
||
|
||
// Устанавливаем min и max точки с увеличенными размерами
|
||
const min = center.clone().sub(size.clone().multiplyScalar(0.5));
|
||
const max = center.clone().add(size.clone().multiplyScalar(0.5));
|
||
|
||
box.setFromPoints([min, max]);
|
||
|
||
// Создаем визуальный коллайдер на основе реальных размеров мешей из модели
|
||
const visualCollider = createVisualColliderFromModel(colliderData, index);
|
||
|
||
console.log('Создан коллайдер с увеличенными размерами:', {
|
||
center: center,
|
||
originalSize: colliderData.scale,
|
||
adjustedSize: size,
|
||
min: min,
|
||
max: max,
|
||
visual: visualCollider
|
||
});
|
||
|
||
return {
|
||
box: box,
|
||
data: colliderData,
|
||
visual: visualCollider
|
||
};
|
||
});
|
||
|
||
console.log('🔍 Возвращаем colliderBoxes:', colliderBoxes.length, 'объектов');
|
||
console.log('🔍 Первый коллайдер:', colliderBoxes[0]);
|
||
|
||
// Автоматически применяем цвета к объектам интерьера, если они уже загружены
|
||
setTimeout(() => {
|
||
if (interiorGroupRef.current) {
|
||
console.log('🎨 JSON коллайдеры загружены, применяем цвета к объектам интерьера');
|
||
colliderBoxes.forEach((colliderData, index) => {
|
||
const colliderPos = colliderData.data.position;
|
||
const colliderData_obj = colliderData.data;
|
||
|
||
// Определяем цвет и прозрачность из JSON данных
|
||
let color = 0xffffff; // Белый по умолчанию
|
||
let opacity = 1.0; // Полная непрозрачность по умолчанию
|
||
|
||
if (colliderData_obj.color) {
|
||
const r = Math.floor((colliderData_obj.color.r || 1.0) * 255);
|
||
const g = Math.floor((colliderData_obj.color.g || 1.0) * 255);
|
||
const b = Math.floor((colliderData_obj.color.b || 1.0) * 255);
|
||
color = (r << 16) | (g << 8) | b;
|
||
}
|
||
|
||
if (colliderData_obj.opacity !== undefined) {
|
||
opacity = Math.max(0, Math.min(1, colliderData_obj.opacity));
|
||
}
|
||
|
||
console.log(`🎨 Применяем цвет ${color.toString(16)} и прозрачность ${opacity} к объектам интерьера`);
|
||
|
||
// Применяем цвет к объектам интерьера
|
||
interiorGroupRef.current.traverse((child) => {
|
||
if (child.isMesh && child.material) {
|
||
const distance = Math.sqrt(
|
||
Math.pow(child.position.x - colliderPos.x, 2) +
|
||
Math.pow(child.position.y - colliderPos.y, 2) +
|
||
Math.pow(child.position.z - colliderPos.z, 2)
|
||
);
|
||
|
||
if (distance < 2.0) {
|
||
console.log(`🎯 Применяем цвет к объекту интерьера:`, child.name || 'unnamed');
|
||
|
||
if (Array.isArray(child.material)) {
|
||
child.material.forEach(mat => {
|
||
if (mat) {
|
||
mat.color.setHex(color);
|
||
mat.transparent = opacity < 1.0;
|
||
mat.opacity = opacity;
|
||
mat.needsUpdate = true;
|
||
}
|
||
});
|
||
} else {
|
||
child.material.color.setHex(color);
|
||
child.material.transparent = opacity < 1.0;
|
||
child.material.opacity = opacity;
|
||
child.material.needsUpdate = true;
|
||
}
|
||
}
|
||
}
|
||
});
|
||
});
|
||
}
|
||
}, 100);
|
||
|
||
return colliderBoxes;
|
||
} catch (error) {
|
||
console.error('Ошибка загрузки коллизионных данных:', error);
|
||
return [];
|
||
}
|
||
};
|
||
|
||
async function loadInteriorModel(interiorId) {
|
||
console.log('loadInteriorModel вызвана для интерьера:', interiorId);
|
||
const token = localStorage.getItem('token');
|
||
console.log('Токен найден:', !!token);
|
||
|
||
try {
|
||
console.log('Запрашиваем определение интерьера с сервера...');
|
||
const defRes = await fetch(`/api/interiors/${interiorId}/definition`, {
|
||
headers: { Authorization: `Bearer ${token}` },
|
||
credentials: 'include',
|
||
cache: 'no-cache'
|
||
});
|
||
|
||
console.log('Ответ сервера:', defRes.status, defRes.ok);
|
||
|
||
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) {
|
||
// Пропускаем интерактивные объекты и сферы
|
||
if (child.userData && (child.userData.interactable || child.userData.payload)) return;
|
||
if (child.geometry.type === 'SphereGeometry') return;
|
||
colliders.push(child);
|
||
}
|
||
});
|
||
interiorCollidersRef.current = colliders;
|
||
console.log('Инициализировано коллайдеров интерьера:', colliders.length);
|
||
try {
|
||
const boxes = [];
|
||
for (const m of colliders) {
|
||
if (!m) continue;
|
||
const b = new THREE.Box3().setFromObject(m).expandByScalar(0.03);
|
||
const h = b.max.y - b.min.y;
|
||
if (h < 0.15) continue; // игнорируем пол/ковёр
|
||
boxes.push(b);
|
||
}
|
||
interiorColliderBoxesRef.current = boxes;
|
||
console.log('[INTERIOR] colliders boxes:', boxes.length);
|
||
|
||
// Визуализация (вкл/выкл через interiorDebugEnabledRef)
|
||
if (interiorDebugEnabledRef.current && sceneRef.current) {
|
||
// Очистим старые
|
||
if (Array.isArray(interiorDebugHelpersRef.current)) {
|
||
for (const h of interiorDebugHelpersRef.current) {
|
||
try { sceneRef.current.remove(h); } catch (_) {}
|
||
}
|
||
}
|
||
interiorDebugHelpersRef.current = [];
|
||
const mat = new THREE.LineBasicMaterial({ color: 0xff00ff });
|
||
for (const box of boxes) {
|
||
const size = new THREE.Vector3();
|
||
const center = new THREE.Vector3();
|
||
box.getSize(size);
|
||
box.getCenter(center);
|
||
const geom = new THREE.BoxGeometry(size.x, size.y, size.z);
|
||
const edges = new THREE.EdgesGeometry(geom);
|
||
const helper = new THREE.LineSegments(edges, mat);
|
||
helper.position.copy(center);
|
||
sceneRef.current.add(helper);
|
||
interiorDebugHelpersRef.current.push(helper);
|
||
}
|
||
}
|
||
} catch (_) {}
|
||
|
||
// Добавляем объекты интерьера
|
||
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);
|
||
|
||
// Применяем цвет и прозрачность из JSON данных коллайдеров
|
||
applyColliderColorAndOpacity(objGltf.scene, o);
|
||
|
||
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('Модель интерьера загружена успешно');
|
||
|
||
// Автоматически применяем цвета из JSON к объектам интерьера
|
||
console.log('🎨 Применяем цвета из JSON к объектам интерьера...');
|
||
setTimeout(() => {
|
||
// Применяем цвета к объектам интерьера, если JSON коллайдеры уже загружены
|
||
if (jsonCollidersRef.current && jsonCollidersRef.current.length > 0) {
|
||
console.log('🔍 JSON коллайдеры найдены, применяем цвета к объектам интерьера');
|
||
jsonCollidersRef.current.forEach((colliderData, index) => {
|
||
const colliderPos = colliderData.data.position;
|
||
const colliderData_obj = colliderData.data;
|
||
|
||
// Определяем цвет и прозрачность из JSON данных
|
||
let color = 0xffffff; // Белый по умолчанию
|
||
let opacity = 1.0; // Полная непрозрачность по умолчанию
|
||
|
||
if (colliderData_obj.color) {
|
||
const r = Math.floor((colliderData_obj.color.r || 1.0) * 255);
|
||
const g = Math.floor((colliderData_obj.color.g || 1.0) * 255);
|
||
const b = Math.floor((colliderData_obj.color.b || 1.0) * 255);
|
||
color = (r << 16) | (g << 8) | b;
|
||
}
|
||
|
||
if (colliderData_obj.opacity !== undefined) {
|
||
opacity = Math.max(0, Math.min(1, colliderData_obj.opacity));
|
||
}
|
||
|
||
console.log(`🎨 Применяем цвет ${color.toString(16)} и прозрачность ${opacity} к объектам интерьера`);
|
||
|
||
// Применяем цвет к объектам интерьера
|
||
intGroup.traverse((child) => {
|
||
if (child.isMesh && child.material) {
|
||
const distance = Math.sqrt(
|
||
Math.pow(child.position.x - colliderPos.x, 2) +
|
||
Math.pow(child.position.y - colliderPos.y, 2) +
|
||
Math.pow(child.position.z - colliderPos.z, 2)
|
||
);
|
||
|
||
if (distance < 2.0) {
|
||
console.log(`🎯 Применяем цвет к объекту интерьера:`, child.name || 'unnamed');
|
||
|
||
if (Array.isArray(child.material)) {
|
||
child.material.forEach(mat => {
|
||
if (mat) {
|
||
mat.color.setHex(color);
|
||
mat.transparent = opacity < 1.0;
|
||
mat.opacity = opacity;
|
||
mat.needsUpdate = true;
|
||
}
|
||
});
|
||
} else {
|
||
child.material.color.setHex(color);
|
||
child.material.transparent = opacity < 1.0;
|
||
child.material.opacity = opacity;
|
||
child.material.needsUpdate = true;
|
||
}
|
||
}
|
||
}
|
||
});
|
||
});
|
||
} else {
|
||
console.log('⚠️ JSON коллайдеры еще не загружены, цвета будут применены позже');
|
||
}
|
||
}, 200); // Задержка для завершения загрузки объектов
|
||
} 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('Группа интерьера удалена');
|
||
}
|
||
|
||
// Очищаем коллайдеры интерьера
|
||
interiorCollidersRef.current = [];
|
||
interiorColliderBoxesRef.current = [];
|
||
jsonCollidersRef.current = [];
|
||
|
||
// Удаляем визуальные коллайдеры из сцены
|
||
visualCollidersRef.current.forEach(collider => {
|
||
if (sceneRef.current) {
|
||
sceneRef.current.remove(collider);
|
||
console.log('Удален визуальный коллайдер из сцены');
|
||
}
|
||
});
|
||
visualCollidersRef.current = [];
|
||
|
||
console.log('Коллайдеры интерьера очищены');
|
||
|
||
// Сбрасываем флаги отладки
|
||
window.colliderDebugShown = false;
|
||
window.collisionDebugShown = false;
|
||
|
||
// Возвращаем третье лицо/камеру и актуализировать видимость объектов города
|
||
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);
|
||
isInInteriorRef.current = false; // Важно! Сбрасываем ref для системы коллизий
|
||
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 = `
|
||
<div style="font-weight: bold; margin-bottom: 5px; color: #ffd700;">${senderName}</div>
|
||
<div style="opacity: 0.9;">${messageText.length > 50 ? messageText.substring(0, 50) + '...' : messageText}</div>
|
||
`;
|
||
|
||
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 = `
|
||
<div style="font-weight: 600; margin-bottom: 8px;">🎮 Управление камерой:</div>
|
||
<div style="margin-bottom: 5px;">• <strong>Ctrl + колесо</strong> = вертикальный поворот</div>
|
||
<div style="margin-bottom: 5px;">• <strong>Shift + Ctrl + колесо</strong> = горизонтальный поворот</div>
|
||
<div style="font-size: 12px; opacity: 0.8;">Подсказка исчезнет через 10 секунд</div>
|
||
`;
|
||
|
||
// Добавляем 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 = `
|
||
<div style="font-size: 18px; font-weight: 600; margin-bottom: 10px;">⚠️ Перезагрузка сервера</div>
|
||
<div style="font-size: 14px; margin-bottom: 15px;">${message}</div>
|
||
<div style="font-size: 12px; opacity: 0.8;">Перезагрузка через: <span id="restart-countdown">${Math.ceil(restartIn/1000)}</span> сек</div>
|
||
`;
|
||
|
||
// Добавляем 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) {
|
||
console.log('startMove вызвана для направления:', dir);
|
||
moveInputRef.current[dir] = true;
|
||
console.log('moveInputRef.current после startMove:', moveInputRef.current);
|
||
}
|
||
|
||
function stopMove(dir) {
|
||
console.log('stopMove вызвана для направления:', dir);
|
||
moveInputRef.current[dir] = false;
|
||
console.log('moveInputRef.current после stopMove:', moveInputRef.current);
|
||
}
|
||
|
||
|
||
// ─────────────────────────────────────────────────────
|
||
// КЛИКИ ВНУТРИ ИНТЕРЬЕРА (интерактивные маркеры/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) || 1;
|
||
console.log('🔍 loadCustomCollidersForCity для города:', cityIdNum);
|
||
|
||
// Сначала пробуем новый API с базой данных
|
||
let res = await fetch(`/api/colliders/city/${cityIdNum}`, {
|
||
cache: 'no-store',
|
||
headers: { Authorization: `Bearer ${token}` }
|
||
});
|
||
|
||
// Если новый API недоступен (500 ошибка), пробуем старый JSON API
|
||
if (!res.ok && res.status === 500) {
|
||
console.log('🔄 Новый API недоступен в loadCustomCollidersForCity, пробуем старый JSON API...');
|
||
const query = cityIdNum ? `?cityId=${encodeURIComponent(cityIdNum)}` : '';
|
||
res = await fetch(`/api/colliders${query}`, { cache: 'no-store', headers: { Authorization: `Bearer ${token}` } });
|
||
}
|
||
|
||
if (!res.ok) {
|
||
console.warn('Не удалось загрузить кастомные коллайдеры для города:', cityIdNum);
|
||
return;
|
||
}
|
||
|
||
const data = await res.json();
|
||
console.log('🔍 Загруженные данные кастомных коллайдеров:', data);
|
||
|
||
// Обрабатываем данные в зависимости от источника
|
||
let list;
|
||
if (data.colliders) {
|
||
// Данные из базы данных
|
||
list = data.colliders;
|
||
console.log('📊 Загружены кастомные коллайдеры из базы данных:', list.length, 'объектов');
|
||
} else if (Array.isArray(data)) {
|
||
// Данные из JSON файла
|
||
list = data;
|
||
console.log('📄 Загружены кастомные коллайдеры из JSON файла:', list.length, 'объектов');
|
||
} else {
|
||
console.warn('Неизвестный формат данных кастомных коллайдеров:', data);
|
||
return;
|
||
}
|
||
// Удаляем старые кастомные коллайдеры
|
||
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 opacity = c.opacity !== undefined ? c.opacity : 0.001;
|
||
const material = new THREE.MeshBasicMaterial({
|
||
color: 0x000000,
|
||
transparent: true,
|
||
opacity: opacity,
|
||
depthWrite: false
|
||
});
|
||
|
||
// Если прозрачность 0, делаем материал невидимым
|
||
if (opacity === 0) {
|
||
material.visible = false;
|
||
material.alphaTest = 0;
|
||
} else {
|
||
material.visible = true;
|
||
material.alphaTest = 0.1;
|
||
}
|
||
|
||
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('Не удалось загрузить кастомные коллайдеры', 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 || !player) return;
|
||
|
||
const move = moveInputRef.current;
|
||
const speed = 3.0; // Скорость движения в интерьере
|
||
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 = (direction) => {
|
||
const moveDistance = speed * delta;
|
||
const playerRadius = 0.4; // Радиус игрока
|
||
const playerHeight = 1.6; // Высота игрока
|
||
|
||
// Получаем коллайдеры из JSON (приоритет)
|
||
let jsonColliders = jsonCollidersRef.current || [];
|
||
console.log('🔍 JSON коллайдеров:', jsonColliders.length);
|
||
|
||
// Если JSON коллайдеров нет, используем коллайдеры из модели
|
||
let colliders = interiorCollidersRef.current || [];
|
||
console.log('🔍 Модельных коллайдеров:', colliders.length);
|
||
|
||
// Если коллайдеров нет, собираем их из группы интерьера
|
||
if (colliders.length === 0 && interiorGroupRef.current) {
|
||
colliders = [];
|
||
interiorGroupRef.current.traverse((child) => {
|
||
if (child.isMesh && child.geometry && child.visible) {
|
||
// Пропускаем интерактивные объекты и сферы
|
||
if (child.userData && (child.userData.interactable || child.userData.payload)) return;
|
||
if (child.geometry.type === 'SphereGeometry') return;
|
||
colliders.push(child);
|
||
}
|
||
});
|
||
interiorCollidersRef.current = colliders;
|
||
console.log('Собрано коллайдеров интерьера:', colliders.length);
|
||
}
|
||
|
||
// Проверяем коллизии
|
||
const checkCollision = (testPosition) => {
|
||
// Создаем AABB для игрока
|
||
const playerBox = new THREE.Box3();
|
||
const playerMin = new THREE.Vector3(
|
||
testPosition.x - playerRadius,
|
||
testPosition.y,
|
||
testPosition.z - playerRadius
|
||
);
|
||
const playerMax = new THREE.Vector3(
|
||
testPosition.x + playerRadius,
|
||
testPosition.y + playerHeight,
|
||
testPosition.z + playerRadius
|
||
);
|
||
playerBox.setFromPoints([playerMin, playerMax]);
|
||
|
||
// Сначала проверяем JSON коллайдеры (приоритет)
|
||
if (jsonColliders.length > 0) {
|
||
console.log('🔍 Проверяем', jsonColliders.length, 'JSON коллайдеров');
|
||
console.log('🔍 Player box:', playerBox.min, '->', playerBox.max);
|
||
|
||
for (let i = 0; i < jsonColliders.length; i++) {
|
||
const jsonCollider = jsonColliders[i];
|
||
try {
|
||
console.log(`🔍 Проверяем коллайдер ${i}:`, jsonCollider.box.min, '->', jsonCollider.box.max);
|
||
|
||
// Проверяем пересечение с JSON коллайдером
|
||
const intersects = playerBox.intersectsBox(jsonCollider.box);
|
||
console.log(`🔍 Результат intersectsBox: ${intersects}`);
|
||
|
||
if (intersects) {
|
||
console.log('🚫 КОЛЛИЗИЯ с JSON коллайдером', i, '!');
|
||
console.log(' Player box:', playerBox.min, '->', playerBox.max);
|
||
console.log(' JSON Collider box:', jsonCollider.box.min, '->', jsonCollider.box.max);
|
||
return true;
|
||
}
|
||
|
||
// Ручная проверка пересечения
|
||
const manualX = playerBox.min.x <= jsonCollider.box.max.x && playerBox.max.x >= jsonCollider.box.min.x;
|
||
const manualY = playerBox.min.y <= jsonCollider.box.max.y && playerBox.max.y >= jsonCollider.box.min.y;
|
||
const manualZ = playerBox.min.z <= jsonCollider.box.max.z && playerBox.max.z >= jsonCollider.box.min.z;
|
||
const manualIntersects = manualX && manualY && manualZ;
|
||
|
||
console.log(`🔍 Ручная проверка - X: ${manualX}, Y: ${manualY}, Z: ${manualZ}, Результат: ${manualIntersects}`);
|
||
|
||
if (manualIntersects) {
|
||
console.log('🚫 РУЧНАЯ КОЛЛИЗИЯ с JSON коллайдером', i, '!');
|
||
return true;
|
||
}
|
||
} catch (error) {
|
||
console.warn('Ошибка при проверке JSON коллизии:', error);
|
||
continue;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Затем проверяем коллайдеры из модели (если JSON коллайдеров нет)
|
||
if (jsonColliders.length === 0) {
|
||
for (const collider of colliders) {
|
||
if (!collider.geometry || !collider.visible) continue;
|
||
|
||
try {
|
||
// Обновляем матрицу мира для коллайдера
|
||
collider.updateMatrixWorld(true);
|
||
|
||
// Создаем Box3 для коллайдера в мировых координатах
|
||
const colliderBox = new THREE.Box3();
|
||
colliderBox.setFromObject(collider);
|
||
|
||
// Проверяем пересечение
|
||
if (playerBox.intersectsBox(colliderBox)) {
|
||
console.log('🚫 КОЛЛИЗИЯ! Объект:', collider.name || 'unnamed');
|
||
console.log(' Player box:', playerBox.min, '->', playerBox.max);
|
||
console.log(' Collider box:', colliderBox.min, '->', colliderBox.max);
|
||
return true;
|
||
} else {
|
||
// Отладка: почему коллизия не обнаружена
|
||
if (!window.collisionDebugShown) {
|
||
console.log('🔍 Отладка коллизии для', collider.name || 'unnamed');
|
||
console.log(' Player box:', playerBox.min, '->', playerBox.max);
|
||
console.log(' Collider box:', colliderBox.min, '->', colliderBox.max);
|
||
console.log(' Пересечение по X:', playerBox.min.x <= colliderBox.max.x && playerBox.max.x >= colliderBox.min.x);
|
||
console.log(' Пересечение по Y:', playerBox.min.y <= colliderBox.max.y && playerBox.max.y >= colliderBox.min.y);
|
||
console.log(' Пересечение по Z:', playerBox.min.z <= colliderBox.max.z && playerBox.max.z >= colliderBox.min.z);
|
||
window.collisionDebugShown = true;
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.warn('Ошибка при проверке коллизии:', error);
|
||
continue;
|
||
}
|
||
}
|
||
}
|
||
return false;
|
||
};
|
||
|
||
// Применяем движение с проверкой коллизий
|
||
const targetPosition = player.position.clone();
|
||
targetPosition.add(direction.clone().multiplyScalar(moveDistance));
|
||
|
||
// Проверяем коллизии по осям отдельно для плавного движения
|
||
let safePosition = player.position.clone();
|
||
|
||
// Отладочная информация о коллайдерах (только при первом движении)
|
||
if ((jsonColliders.length > 0 || colliders.length > 0) && !window.colliderDebugShown) {
|
||
console.log('🔍 Проверяем коллизии с', jsonColliders.length, 'JSON коллайдерами и', colliders.length, 'модельными коллайдерами');
|
||
console.log('📍 Позиция игрока:', player.position);
|
||
|
||
// Показываем JSON коллайдеры
|
||
jsonColliders.forEach((col, i) => {
|
||
console.log(`JSON Коллайдер ${i}:`,
|
||
'Min:', col.box.min, 'Max:', col.box.max, 'Size:', col.box.getSize(new THREE.Vector3()));
|
||
console.log(` Игрок Y: ${player.position.y}, JSON Коллайдер Y: ${col.box.min.y} - ${col.box.max.y}`);
|
||
});
|
||
|
||
// Показываем модельные коллайдеры
|
||
colliders.forEach((col, i) => {
|
||
col.updateMatrixWorld(true);
|
||
const box = new THREE.Box3().setFromObject(col);
|
||
console.log(`Модельный Коллайдер ${i}:`, col.name || 'unnamed',
|
||
'Min:', box.min, 'Max:', box.max, 'Size:', box.getSize(new THREE.Vector3()));
|
||
console.log(` Игрок Y: ${player.position.y}, Модельный Коллайдер Y: ${box.min.y} - ${box.max.y}`);
|
||
});
|
||
window.colliderDebugShown = true;
|
||
}
|
||
console.log('Исходная позиция:', safePosition);
|
||
|
||
// Проверяем движение по X
|
||
if (Math.abs(direction.x) > 0.001) {
|
||
const xTestPosition = safePosition.clone();
|
||
xTestPosition.x = targetPosition.x;
|
||
const hasCollisionX = checkCollision(xTestPosition);
|
||
if (!hasCollisionX) {
|
||
safePosition.x = targetPosition.x;
|
||
} else {
|
||
console.log('🚫 X коллизия заблокирована');
|
||
}
|
||
}
|
||
|
||
// Проверяем движение по Z
|
||
if (Math.abs(direction.z) > 0.001) {
|
||
const zTestPosition = safePosition.clone();
|
||
zTestPosition.z = targetPosition.z;
|
||
const hasCollisionZ = checkCollision(zTestPosition);
|
||
if (!hasCollisionZ) {
|
||
safePosition.z = targetPosition.z;
|
||
} else {
|
||
console.log('🚫 Z коллизия заблокирована');
|
||
}
|
||
}
|
||
// Обновляем позицию игрока
|
||
player.position.copy(safePosition);
|
||
};
|
||
|
||
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 (
|
||
<div ref={mountRef} style={{ position: 'relative', width: '100vw', height: '100vh' }}>
|
||
<div style={{ position: 'absolute', top: 20, left: 20, zIndex: 1000, background: 'rgba(0,0,0,0.6)', color: '#fff', padding: '4px 8px', borderRadius: 4 }}>
|
||
Сытость: {satiety}
|
||
</div>
|
||
<div style={{ position: 'absolute', top: 50, left: 20, zIndex: 1000, background: 'rgba(0,0,0,0.6)', color: '#fff', padding: '4px 8px', borderRadius: 4 }}>
|
||
Жажда: {thirst}
|
||
</div>
|
||
{/* HUD: сытость/жажда */}
|
||
<div style={{
|
||
position: 'absolute',
|
||
left: 20, top: 20,
|
||
display: 'flex',
|
||
gap: 12,
|
||
flexDirection: 'column',
|
||
zIndex: 10000,
|
||
width: 260,
|
||
}}>
|
||
{[{ label: 'Сытость', value: satiety }, { label: 'Жажда', value: thirst }].map((bar) => (
|
||
<div key={bar.label} style={{
|
||
background: 'rgba(15,15,20,0.75)',
|
||
borderRadius: 12,
|
||
padding: '10px 12px',
|
||
boxShadow: '0 4px 16px rgba(0,0,0,0.35)',
|
||
backdropFilter: 'blur(4px)',
|
||
}}>
|
||
<div style={{
|
||
display: 'flex', justifyContent: 'space-between',
|
||
fontSize: 13, color: '#B8C0CC', marginBottom: 6,
|
||
fontWeight: 600, letterSpacing: 0.3,
|
||
}}>
|
||
<span>{bar.label}</span>
|
||
<span>{Math.round(bar.value)}%</span>
|
||
</div>
|
||
<div style={{
|
||
height: 10,
|
||
borderRadius: 999,
|
||
background: 'rgba(255,255,255,0.08)',
|
||
overflow: 'hidden',
|
||
}}>
|
||
<div style={{
|
||
height: '100%',
|
||
width: `${Math.max(0, Math.min(100, bar.value))}%`,
|
||
borderRadius: 999,
|
||
// красивый градиент: зелёный → жёлтый → красный
|
||
background: 'linear-gradient(90deg, #22c55e, #eab308, #ef4444)',
|
||
transition: 'width 300ms ease',
|
||
boxShadow: '0 0 6px rgba(255,255,255,0.35) inset',
|
||
}} />
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<button
|
||
style={{
|
||
position: 'absolute',
|
||
top: 20,
|
||
right: 250, // Измените позицию по необходимости
|
||
zIndex: 1000,
|
||
padding: '10px 18px',
|
||
background: '#8B4513',
|
||
color: 'white',
|
||
border: 'none',
|
||
borderRadius: '8px',
|
||
fontSize: '18px',
|
||
cursor: 'pointer',
|
||
boxShadow: '0 2px 8px rgba(0,0,0,0.15)'
|
||
}}
|
||
onClick={() => setShowQuests(true)}
|
||
>
|
||
Квесты
|
||
</button>
|
||
<div style={{ position: 'absolute', top: 80, left: 20, zIndex: 1000, background: 'rgba(0,0,0,0.6)', color: '#fff', padding: '4px 8px', borderRadius: 4 }}>
|
||
Баланс: {balance}
|
||
</div>
|
||
<div style={{ position: 'absolute', top: 20, right: 150, zIndex: 1000, background: 'rgba(0,0,0,0.6)', color: '#fff', padding: '4px 8px', borderRadius: 4 }}>
|
||
X: {playerCoords.x} Y: {playerCoords.y} Z: {playerCoords.z}
|
||
</div>
|
||
{/* Индикатор связи в правом нижнем углу */}
|
||
<div style={{ position: 'absolute', right: 20, bottom: 20, zIndex: 10000, display: 'flex', alignItems: 'center', gap: 8,
|
||
background: 'rgba(15,15,20,0.75)', color: '#fff', padding: '8px 10px', borderRadius: 10, backdropFilter: 'blur(4px)'}}>
|
||
<div style={{ width: 10, height: 10, borderRadius: '50%', background: connectionLost ? '#ef4444' : (latencyMs == null ? '#f59e0b' : (latencyMs < 80 ? '#22c55e' : latencyMs < 160 ? '#eab308' : '#ef4444')) }} />
|
||
<div style={{ fontSize: 12, opacity: 0.9 }}>
|
||
{connectionLost ? 'Связь: нет' : `Пинг: ${latencyMs ?? '—'} ms`}
|
||
</div>
|
||
</div>
|
||
<div style={{ position: 'absolute', bottom: 20, left: 20, zIndex: 1000, background: 'rgba(0,0,0,0.6)', color: '#fff', padding: '4px 8px', borderRadius: 4 }}>
|
||
{(() => {
|
||
if (!gameTime) return 'Загрузка времени...';
|
||
// Сервер шлёт ISO (gameTime.js -> toISOString). Отображаем игровое время (ускоренное в 8 раз)
|
||
const d = new Date(gameTime);
|
||
return d.toLocaleString();
|
||
})()}
|
||
</div>
|
||
|
||
{/* Оверлей при потере соединения */}
|
||
{connectionLost && (
|
||
<div style={{ position: 'absolute', inset: 0, zIndex: 20000, background: 'rgba(0,0,0,0.8)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||
<div style={{ background: 'rgba(20,20,25,0.95)', padding: '24px 28px', borderRadius: 12, color: '#fff', width: 420, textAlign: 'center', boxShadow: '0 12px 40px rgba(0,0,0,0.45)' }}>
|
||
<div style={{ fontSize: 20, fontWeight: 700, marginBottom: 10 }}>Соединение потеряно</div>
|
||
<div style={{ fontSize: 14, opacity: 0.9, marginBottom: 16 }}>Связь с сервером была прервана. Пожалуйста, перезайдите в игру.</div>
|
||
<button onClick={() => window.location.reload()} style={{
|
||
background: '#ef4444', border: 'none', color: '#fff', padding: '10px 14px', borderRadius: 8, cursor: 'pointer', fontWeight: 700
|
||
}}>Перезайти</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
{/* Кнопка карты мира */}
|
||
<button
|
||
style={{
|
||
position: 'absolute',
|
||
top: 20,
|
||
right: 20,
|
||
zIndex: 1000,
|
||
padding: '10px 18px',
|
||
background: '#0047ab',
|
||
color: 'white',
|
||
border: 'none',
|
||
borderRadius: '8px',
|
||
fontSize: '18px',
|
||
cursor: 'pointer',
|
||
boxShadow: '0 2px 8px rgba(0,0,0,0.15)'
|
||
}}
|
||
onClick={openWorldMap}
|
||
>
|
||
Карта мира
|
||
</button>
|
||
|
||
{isInInterior && (
|
||
<button
|
||
style={{
|
||
position: 'absolute',
|
||
top: 60,
|
||
right: 20,
|
||
zIndex: 1000,
|
||
padding: '10px 18px',
|
||
background: '#0047ab',
|
||
color: 'white',
|
||
border: 'none',
|
||
borderRadius: '8px',
|
||
fontSize: '18px',
|
||
cursor: 'pointer',
|
||
boxShadow: '0 2px 8px rgba(0,0,0,0.15)'
|
||
}}
|
||
onClick={exitInterior}
|
||
>
|
||
Выйти
|
||
</button>
|
||
)}
|
||
|
||
{isInInterior && isTouchDevice && (
|
||
<div style={{ position: 'absolute', bottom: 20, left: 20, zIndex: 1000 }}>
|
||
<div style={{ display: 'grid', gridTemplateColumns: '40px 40px', gridTemplateRows: '40px 40px 40px', gap: '5px', gridTemplateAreas: "'up up' 'left right' 'down down'" }}>
|
||
<button style={{ gridArea: 'up' }} onTouchStart={() => startMove('forward')} onTouchEnd={() => stopMove('forward')}>↑</button>
|
||
<button style={{ gridArea: 'left' }} onTouchStart={() => startMove('left')} onTouchEnd={() => stopMove('left')}>←</button>
|
||
<button style={{ gridArea: 'right' }} onTouchStart={() => startMove('right')} onTouchEnd={() => stopMove('right')}>→</button>
|
||
<button style={{ gridArea: 'down' }} onTouchStart={() => startMove('backward')} onTouchEnd={() => stopMove('backward')}>↓</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{selectedHouse && !isInInterior && (
|
||
<div style={{
|
||
position: 'absolute',
|
||
bottom: 20,
|
||
left: '50%',
|
||
transform: 'translateX(-50%)',
|
||
background: 'rgba(0,0,0,0.7)',
|
||
color: '#fff',
|
||
padding: '10px 20px',
|
||
borderRadius: '8px',
|
||
zIndex: 1000
|
||
}}>
|
||
<button
|
||
onClick={() => enterInteriorMode(selectedHouse.id)}
|
||
style={{
|
||
fontSize: '18px',
|
||
padding: '8px 16px',
|
||
background: '#00aaff',
|
||
border: 'none',
|
||
borderRadius: '6px',
|
||
cursor: 'pointer'
|
||
}}
|
||
>
|
||
Войти в здание
|
||
</button>
|
||
<button
|
||
onClick={() => setSelectedHouse(null)}
|
||
style={{
|
||
marginLeft: '10px',
|
||
fontSize: '18px',
|
||
background: '#aaa',
|
||
border: 'none',
|
||
borderRadius: '6px',
|
||
cursor: 'pointer'
|
||
}}
|
||
>
|
||
Отмена
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
|
||
{/* Модальное окно выбора города */}
|
||
{showWorldMap && (
|
||
<div style={{
|
||
position: 'fixed',
|
||
top: 0,
|
||
left: 0,
|
||
width: '100vw',
|
||
height: '100vh',
|
||
background: 'rgba(0,0,0,0.5)',
|
||
zIndex: 2000,
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center'
|
||
}}>
|
||
<div style={{
|
||
background: 'white',
|
||
borderRadius: '16px',
|
||
padding: '32px',
|
||
minWidth: '350px',
|
||
boxShadow: '0 4px 24px rgba(0,0,0,0.25)'
|
||
}}>
|
||
<h2 style={{ marginTop: 0 }}>Выберите город</h2>
|
||
<ul style={{ listStyle: 'none', padding: 0 }}>
|
||
{cities.map(city => (
|
||
<li key={city.id} style={{ margin: '12px 0' }}>
|
||
<button
|
||
style={{
|
||
width: '100%',
|
||
padding: '12px',
|
||
fontSize: '16px',
|
||
borderRadius: '8px',
|
||
border: '1px solid #0047ab',
|
||
background: '#f1f6ff',
|
||
color: '#0047ab',
|
||
cursor: 'pointer',
|
||
transition: 'background 0.2s'
|
||
}}
|
||
onClick={() => handleCitySelect(city.id)}
|
||
>
|
||
{city.name} ({city.country_name})
|
||
</button>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
<button onClick={closeWorldMap} style={{ marginTop: 16, background: '#eee', border: 'none', borderRadius: 8, padding: '8px 16px', cursor: 'pointer' }}>Закрыть</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
{showQuests && (
|
||
<QuestSystem onClose={() => setShowQuests(false)} />
|
||
)}
|
||
{selectedHouse && (
|
||
<div style={{
|
||
position: 'absolute',
|
||
top: 20, right: 20,
|
||
background: 'rgba(0,0,0,0.8)',
|
||
color: '#fff', padding: 16,
|
||
borderRadius: 8, minWidth: 220
|
||
}}>
|
||
<h3 style={{ margin: 0, marginBottom: 8 }}>🏠 {selectedHouse.type}</h3>
|
||
<p style={{ margin: '4px 0' }}>
|
||
<b>ID:</b> {selectedHouse.id}
|
||
</p>
|
||
<p style={{ margin: '4px 0' }}>
|
||
<b>Стоимость аренды:</b> {selectedHouse.rent}
|
||
</p>
|
||
<p style={{ margin: '4px 0' }}>
|
||
<b>Налог:</b> {selectedHouse.tax}
|
||
</p>
|
||
<div style={{ marginTop: 12, display: 'flex', gap: 8 }}>
|
||
<button onClick={() => enterHouse(selectedHouse)} style={btnStyle}>Войти</button>
|
||
<button onClick={() => viewStats(selectedHouse)} style={btnStyle}>Статистика</button>
|
||
{selectedHouse.organizationId && (
|
||
<>
|
||
<button onClick={() => openOrganizationMenu(selectedHouse.organizationId)} style={btnStyle}>Меню</button>
|
||
<button onClick={() => openOrganizationPanel(selectedHouse.organizationId)} style={btnStyle}>Управление</button>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
{showDialog && currentDialog && (
|
||
<div style={{
|
||
position: 'fixed',
|
||
top: '50%',
|
||
left: '50%',
|
||
transform: 'translate(-50%, -50%)',
|
||
background: 'rgba(0,0,0,0.85)',
|
||
color: 'white',
|
||
padding: '20px',
|
||
borderRadius: '10px',
|
||
zIndex: 3000,
|
||
minWidth: '300px',
|
||
border: '2px solid #555',
|
||
display: 'flex',
|
||
flexDirection: 'column'
|
||
}}>
|
||
<div style={{
|
||
display: 'flex',
|
||
justifyContent: 'space-between',
|
||
alignItems: 'center',
|
||
marginBottom: '15px',
|
||
borderBottom: '1px solid #444',
|
||
paddingBottom: '10px'
|
||
}}>
|
||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||
{currentDialog.avatar && (
|
||
<img
|
||
src={currentDialog.avatar}
|
||
alt={currentDialog.name}
|
||
style={{
|
||
width: '50px',
|
||
height: '50px',
|
||
borderRadius: '50%',
|
||
marginRight: '10px',
|
||
objectFit: 'cover'
|
||
}}
|
||
/>
|
||
)}
|
||
<h3 style={{ margin: 0 }}>{currentDialog.name}</h3>
|
||
</div>
|
||
<button
|
||
onClick={() => setShowDialog(false)}
|
||
style={{
|
||
background: 'transparent',
|
||
border: 'none',
|
||
color: 'white',
|
||
fontSize: '20px',
|
||
cursor: 'pointer'
|
||
}}
|
||
>
|
||
✕
|
||
</button>
|
||
</div>
|
||
|
||
{currentForm ? (
|
||
<form onSubmit={handleFormSubmit}>
|
||
<h4 style={{ marginTop: 0 }}>{currentForm.title}</h4>
|
||
{currentForm.fields.map((field, idx) => (
|
||
<div key={idx} style={{ marginBottom: '15px' }}>
|
||
<label style={{ display: 'block', marginBottom: '5px' }}>
|
||
{field.label}
|
||
</label>
|
||
{field.type === 'textarea' ? (
|
||
<textarea
|
||
name={field.name}
|
||
placeholder={field.placeholder}
|
||
required={field.required}
|
||
onChange={handleFormChange}
|
||
style={{
|
||
width: '100%',
|
||
minHeight: '80px',
|
||
padding: '8px',
|
||
borderRadius: '4px',
|
||
background: 'rgba(255,255,255,0.1)',
|
||
border: '1px solid #555',
|
||
color: 'white'
|
||
}}
|
||
/>
|
||
) : (
|
||
<input
|
||
type={field.type}
|
||
name={field.name}
|
||
placeholder={field.placeholder}
|
||
required={field.required}
|
||
onChange={handleFormChange}
|
||
style={{
|
||
width: '100%',
|
||
padding: '8px',
|
||
borderRadius: '4px',
|
||
background: 'rgba(255,255,255,0.1)',
|
||
border: '1px solid #555',
|
||
color: 'white'
|
||
}}
|
||
/>
|
||
)}
|
||
</div>
|
||
))}
|
||
<button
|
||
type="submit"
|
||
style={{
|
||
padding: '8px 16px',
|
||
background: '#3a5f8d',
|
||
color: 'white',
|
||
border: 'none',
|
||
borderRadius: '4px',
|
||
cursor: 'pointer',
|
||
width: '100%'
|
||
}}
|
||
>
|
||
{currentForm.submit_text || 'Отправить'}
|
||
</button>
|
||
</form>
|
||
) : (
|
||
<>
|
||
<p style={{ marginBottom: '20px', minHeight: '60px' }}>
|
||
{currentDialog.dialog[dialogIndex].text}
|
||
</p>
|
||
{currentDialog.dialog[dialogIndex].answers?.length > 0 ? (
|
||
<div style={{
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
gap: '8px',
|
||
marginBottom: '20px'
|
||
}}>
|
||
{currentDialog.dialog[dialogIndex].answers.map((answer, idx) => (
|
||
<button
|
||
key={idx}
|
||
onClick={() => handleAnswerSelect(answer)}
|
||
style={{
|
||
padding: '8px 16px',
|
||
background: '#3a5f8d',
|
||
color: 'white',
|
||
border: 'none',
|
||
borderRadius: '4px',
|
||
cursor: 'pointer',
|
||
textAlign: 'left'
|
||
}}
|
||
>
|
||
{answer.text}
|
||
</button>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||
<button
|
||
onClick={() => setShowDialog(false)}
|
||
style={{
|
||
padding: '8px 16px',
|
||
background: '#4a76a8',
|
||
color: 'white',
|
||
border: 'none',
|
||
borderRadius: '4px',
|
||
cursor: 'pointer'
|
||
}}
|
||
>
|
||
Закрыть
|
||
</button>
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
)}
|
||
{selectedPlayer && (
|
||
<div
|
||
ref={statsRef}
|
||
style={{
|
||
position: 'absolute',
|
||
top: 20, left: 20,
|
||
background: 'rgba(0,0,0,0.8)',
|
||
color: '#fff',
|
||
padding: 16,
|
||
borderRadius: 8,
|
||
minWidth: 260,
|
||
zIndex: 100
|
||
}}
|
||
>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||
<h3 style={{ margin: 0 }}>
|
||
{selectedPlayer.firstName} {selectedPlayer.lastName}
|
||
</h3>
|
||
<button
|
||
onClick={() => { setSelectedPlayer(null); setPlayerStats(null); }}
|
||
style={{
|
||
background: 'transparent',
|
||
border: 'none',
|
||
color: '#fff',
|
||
fontSize: '16px',
|
||
cursor: 'pointer'
|
||
}}
|
||
>
|
||
✕
|
||
</button>
|
||
</div>
|
||
|
||
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
|
||
<button onClick={viewStats} style={btnStyle}>Посмотреть статистику</button>
|
||
<button style={btnStyle} onClick={() => { /* познакомиться */ }}>Познакомиться</button>
|
||
</div>
|
||
|
||
{playerStats && (
|
||
<div style={{ marginTop: 12, lineHeight: '1.4em' }}>
|
||
<p><b>Баланс:</b> {playerStats.balance}</p>
|
||
<p><b>Часов игры:</b> {playerStats.hoursPlayed}</p>
|
||
<p><b>Репутация:</b> {playerStats.reputation}</p>
|
||
<p><b>Телефон:</b> {playerStats.phone || '—'}</p>
|
||
<p><b>Спортивность:</b> {playerStats.sportiness}</p>
|
||
<p><b>Уровень здоровья:</b> {playerStats.healthLevel}</p>
|
||
<p><b>Уровень стресса:</b> {playerStats.stressLevel}</p>
|
||
<p><b>Болезни:</b> {playerStats.diseases?.join(', ') || 'нет'}</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{orgMenu && (
|
||
<div style={{
|
||
position: 'absolute',
|
||
top: '50%', left: '50%',
|
||
transform: 'translate(-50%, -50%)',
|
||
background: 'rgba(0,0,0,0.85)',
|
||
color: '#fff',
|
||
padding: 16,
|
||
borderRadius: 10,
|
||
minWidth: 260,
|
||
maxWidth: 420,
|
||
zIndex: 3000
|
||
}}>
|
||
<h3 style={{ marginTop: 0, marginBottom: 10 }}>{orgMenu.name}</h3>
|
||
|
||
{/* orgMenu.menu теперь массив элементов */}
|
||
{(!orgMenu.menu || orgMenu.menu.length === 0) && <p>Меню пусто</p>}
|
||
{Array.isArray(orgMenu.menu) && orgMenu.menu.map(it => (
|
||
<div key={it.key} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
||
<div>
|
||
<div style={{ fontWeight: 600 }}>{it.title || it.key}</div>
|
||
{it.price != null && <div style={{ opacity: .8, fontSize: 12 }}>{Number(it.price)} ₽</div>}
|
||
</div>
|
||
<button onClick={() => purchaseItem(orgMenu.id, it.key)}>Купить</button>
|
||
</div>
|
||
))}
|
||
|
||
<div style={{ textAlign: 'right', marginTop: 10 }}>
|
||
<button onClick={() => setOrgMenu(null)}>Закрыть</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
|
||
{orgPanelId && (
|
||
<OrgControlPanel orgId={orgPanelId} onClose={() => setOrgPanelId(null)} />
|
||
)}
|
||
|
||
{showInventory && (
|
||
<Inventory items={inventory} onUse={handleItemAction} />
|
||
)}
|
||
{selectedTransaction && (
|
||
<div style={{
|
||
padding: '20px',
|
||
background: '#1a1a1a',
|
||
borderTop: '1px solid #333'
|
||
}}>
|
||
<h3 style={{ marginTop: 0 }}>Детали транзакции #{selectedTransaction.id}</h3>
|
||
<div style={{
|
||
display: 'grid',
|
||
gridTemplateColumns: '1fr 1fr',
|
||
gap: '10px',
|
||
marginBottom: '15px'
|
||
}}>
|
||
<div><strong>Дата:</strong> {selectedTransaction.date} {selectedTransaction.time}</div>
|
||
<div><strong>Сумма:</strong> {selectedTransaction.amount}</div>
|
||
<div><strong>Назначение:</strong> {selectedTransaction.purpose || '—'}</div>
|
||
<div><strong>IP-адрес:</strong> {selectedTransaction.ip || 'скрыто'}</div>
|
||
<div><strong>Город:</strong> {selectedTransaction.city}</div>
|
||
<div><strong>Устройство:</strong> {selectedTransaction.device || 'скрыто'}</div>
|
||
<div><strong>Получатель:</strong> {selectedTransaction.recipient}</div>
|
||
</div>
|
||
|
||
{/* Подсказки для подозрительных транзакций */}
|
||
{selectedTransaction._isSuspicious && markedTransactions.includes(selectedTransaction.id) && (
|
||
<div style={{
|
||
padding: '10px',
|
||
background: '#2a1a1a',
|
||
borderRadius: '5px',
|
||
marginBottom: '15px'
|
||
}}>
|
||
<h4 style={{ marginTop: 0 }}>🔍 Обнаруженная аномалия:</h4>
|
||
{selectedTransaction._anomalyType === 0 && (
|
||
<p>Географический прыжок: транзакция из {selectedTransaction.city} всего через час после предыдущей из другого города.</p>
|
||
)}
|
||
{selectedTransaction._anomalyType === 1 && (
|
||
<p>Подозрительное устройство ({selectedTransaction._realDevice}) и отсутствие назначения платежа.</p>
|
||
)}
|
||
{selectedTransaction._anomalyType === 2 && (
|
||
<p>Многократные переводы одному получателю ({selectedTransaction.recipient}) с большими суммами.</p>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
<div style={{ display: 'flex', gap: '10px' }}>
|
||
<button
|
||
style={{
|
||
background: '#3498db',
|
||
color: 'white',
|
||
border: 'none',
|
||
padding: '8px 15px',
|
||
borderRadius: '3px',
|
||
cursor: 'pointer'
|
||
}}
|
||
onClick={() => handleDecryptField(selectedTransaction.id, 'ip')}
|
||
disabled={decryptAttempts <= 0 || selectedTransaction.ip}
|
||
>
|
||
🕵️ Расшифровать IP ({decryptAttempts} осталось)
|
||
</button>
|
||
<button
|
||
style={{
|
||
background: '#3498db',
|
||
color: 'white',
|
||
border: 'none',
|
||
padding: '8px 15px',
|
||
borderRadius: '3px',
|
||
cursor: 'pointer'
|
||
}}
|
||
onClick={() => handleDecryptField(selectedTransaction.id, 'device')}
|
||
disabled={decryptAttempts <= 0 || selectedTransaction.device}
|
||
>
|
||
🕵️ Расшифровать устройство ({decryptAttempts} осталось)
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{gameResult === 'complete' && (
|
||
<div style={{
|
||
position: 'fixed',
|
||
top: 0,
|
||
left: 0,
|
||
width: '100vw',
|
||
height: '100vh',
|
||
background: 'rgba(0,0,0,0.9)',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
zIndex: 3000
|
||
}}>
|
||
<div style={{
|
||
background: '#1a2a1a',
|
||
padding: '40px',
|
||
borderRadius: '10px',
|
||
maxWidth: '600px',
|
||
textAlign: 'center'
|
||
}}>
|
||
<h2 style={{ color: '#4CAF50' }}>Этап пройден!</h2>
|
||
<p style={{ fontSize: '18px', margin: '20px 0' }}>
|
||
Поздравляем! Вы успешно завершили все уровни игры "Чистка или компромат".
|
||
</p>
|
||
<p style={{ marginBottom: '30px' }}>
|
||
Ваши навыки анализа транзакций на высоте!
|
||
</p>
|
||
<button
|
||
style={{
|
||
background: '#2196F3',
|
||
color: 'white',
|
||
border: 'none',
|
||
padding: '12px 24px',
|
||
borderRadius: '5px',
|
||
fontSize: '16px',
|
||
cursor: 'pointer'
|
||
}}
|
||
onClick={() => {
|
||
setGameResult(null);
|
||
setShowCleanupGame(false);
|
||
setCurrentLevel(1); // Сброс уровня
|
||
}}
|
||
>
|
||
Закрыть
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
{gameResult === 'fail' && (
|
||
<div style={{
|
||
position: 'fixed',
|
||
top: '50%',
|
||
left: '50%',
|
||
transform: 'translate(-50%, -50%)',
|
||
background: 'rgba(0,0,0,0.9)',
|
||
padding: '20px',
|
||
borderRadius: '10px',
|
||
zIndex: 3000,
|
||
textAlign: 'center'
|
||
}}>
|
||
<h2 style={{ color: '#e74c3c' }}>Время вышло!</h2>
|
||
<p style={{ fontSize: '18px' }}>Вы провалили задание, попробуйте еще раз</p>
|
||
<p style={{ color: '#aaa' }}>Игра перезапустится через 3 секунды...</p>
|
||
</div>
|
||
)}
|
||
{showCleanupGame && !gameCompleted && (
|
||
<div style={{
|
||
position: 'fixed',
|
||
top: 0,
|
||
left: 0,
|
||
width: '100vw',
|
||
height: '100vh',
|
||
background: 'rgba(0,0,0,0.9)',
|
||
zIndex: 2000,
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
color: '#0f0',
|
||
fontFamily: 'monospace',
|
||
padding: '20px'
|
||
}}>
|
||
<div style={{
|
||
width: '90%',
|
||
maxWidth: '1200px',
|
||
background: '#111',
|
||
border: '1px solid #333',
|
||
borderRadius: '5px',
|
||
overflow: 'hidden'
|
||
}}>
|
||
{/* Заголовок */}
|
||
<div style={{
|
||
display: 'flex',
|
||
justifyContent: 'space-between',
|
||
alignItems: 'center',
|
||
padding: '10px 20px',
|
||
background: '#222',
|
||
borderBottom: '1px solid #333'
|
||
}}>
|
||
|
||
<h2 style={{ margin: 0 }}>Чистка или компромат (Уровень {currentLevel})</h2>
|
||
<div style={{ display: 'flex', gap: '20px' }}>
|
||
<span>Время: {Math.floor(timeLeft / 60)}:{String(timeLeft % 60).padStart(2, '0')}</span>
|
||
<span>Расшифровки: {decryptAttempts}</span>
|
||
<span>Найдено: {suspiciousFound}/3</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Комментарии Серёги */}
|
||
{seregaComments.length > 0 && (
|
||
<div style={{
|
||
padding: '10px',
|
||
background: '#1a1a1a',
|
||
borderBottom: '1px solid #333',
|
||
fontStyle: 'italic'
|
||
}}>
|
||
{seregaComments[seregaComments.length - 1].text}
|
||
</div>
|
||
)}
|
||
|
||
{/* Таблица транзакций */}
|
||
<div style={{
|
||
maxHeight: '60vh',
|
||
overflowY: 'auto'
|
||
}}>
|
||
<table style={{
|
||
width: '100%',
|
||
borderCollapse: 'collapse'
|
||
}}>
|
||
<thead>
|
||
<tr style={{ background: '#1a1a1a' }}>
|
||
<th style={{ padding: '10px', textAlign: 'left' }}>Дата</th>
|
||
<th style={{ padding: '10px', textAlign: 'left' }}>Сумма</th>
|
||
<th style={{ padding: '10px', textAlign: 'left' }}>Назначение</th>
|
||
<th style={{ padding: '10px', textAlign: 'left' }}>IP-адрес</th>
|
||
<th style={{ padding: '10px', textAlign: 'left' }}>Город</th>
|
||
<th style={{ padding: '10px', textAlign: 'left' }}>Устройство</th>
|
||
<th style={{ padding: '10px', textAlign: 'left' }}>Получатель</th>
|
||
<th style={{ padding: '10px', textAlign: 'left' }}>Действия</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{cleanupGameData?.map((tx) => (
|
||
<tr
|
||
key={tx.id}
|
||
style={{
|
||
background: markedTransactions.includes(tx.id)
|
||
? (tx._isSuspicious ? '#2a1a1a' : '#3a1a1a')
|
||
: '#1a1a1a',
|
||
borderBottom: '1px solid #333',
|
||
cursor: 'pointer'
|
||
}}
|
||
onClick={() => setSelectedTransaction(tx)}
|
||
>
|
||
<td style={{ padding: '10px' }}>{tx.date}</td>
|
||
<td style={{ padding: '10px' }}>{tx.amount}</td>
|
||
<td style={{ padding: '10px' }}>{tx.purpose || '—'}</td>
|
||
<td style={{ padding: '10px' }}>{tx.ip || 'скрыто'}</td>
|
||
<td style={{ padding: '10px' }}>{tx.city}</td>
|
||
<td style={{ padding: '10px' }}>{tx.device || 'скрыто'}</td>
|
||
<td style={{ padding: '10px' }}>{tx.recipient}</td>
|
||
<td style={{ padding: '10px', display: 'flex', gap: '5px' }}>
|
||
<button
|
||
style={{
|
||
background: markedTransactions.includes(tx.id)
|
||
? (tx._isSuspicious ? '#27ae60' : '#e74c3c')
|
||
: '#333',
|
||
color: 'white',
|
||
border: 'none',
|
||
padding: '5px 10px',
|
||
borderRadius: '3px',
|
||
cursor: 'pointer'
|
||
}}
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
handleMarkTransaction(tx.id);
|
||
}}
|
||
>
|
||
{markedTransactions.includes(tx.id) ? '✓ Помечено' : 'Пометить'}
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
{/* Детали транзакции */}
|
||
{selectedTransaction && (
|
||
<div style={{
|
||
padding: '20px',
|
||
background: '#1a1a1a',
|
||
borderTop: '1px solid #333'
|
||
}}>
|
||
<h3 style={{ marginTop: 0 }}>Детали транзакции #{selectedTransaction.id}</h3>
|
||
<div style={{
|
||
display: 'grid',
|
||
gridTemplateColumns: '1fr 1fr',
|
||
gap: '10px',
|
||
marginBottom: '15px'
|
||
}}>
|
||
<div><strong>Дата:</strong> {selectedTransaction.date} {selectedTransaction.time}</div>
|
||
<div><strong>Сумма:</strong> {selectedTransaction.amount}</div>
|
||
<div><strong>Назначение:</strong> {selectedTransaction.purpose || '—'}</div>
|
||
<div><strong>IP-адрес:</strong> {selectedTransaction.ip || 'скрыто'}</div>
|
||
<div><strong>Город:</strong> {selectedTransaction.city}</div>
|
||
<div><strong>Устройство:</strong> {selectedTransaction.device || 'скрыто'}</div>
|
||
<div><strong>Получатель:</strong> {selectedTransaction.recipient}</div>
|
||
</div>
|
||
|
||
{/* Подсказки для подозрительных транзакций */}
|
||
{selectedTransaction._isSuspicious && markedTransactions.includes(selectedTransaction.id) && (
|
||
<div style={{
|
||
padding: '10px',
|
||
background: '#2a1a1a',
|
||
borderRadius: '5px',
|
||
marginBottom: '15px'
|
||
}}>
|
||
<h4 style={{ marginTop: 0 }}>🔍 Обнаруженная аномалия:</h4>
|
||
{selectedTransaction._anomalyType === 0 && (
|
||
<p>Географический прыжок: транзакция из {selectedTransaction.city} всего через час после предыдущей из другого города.</p>
|
||
)}
|
||
{selectedTransaction._anomalyType === 1 && (
|
||
<p>Подозрительное устройство ({selectedTransaction._realDevice}) и отсутствие назначения платежа.</p>
|
||
)}
|
||
{selectedTransaction._anomalyType === 2 && (
|
||
<p>Многократные переводы одному получателю ({selectedTransaction.recipient}) с большими суммами.</p>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
<div style={{ display: 'flex', gap: '10px' }}>
|
||
<button
|
||
style={{
|
||
background: '#3498db',
|
||
color: 'white',
|
||
border: 'none',
|
||
padding: '8px 15px',
|
||
borderRadius: '3px',
|
||
cursor: 'pointer'
|
||
}}
|
||
onClick={() => handleDecryptField(selectedTransaction.id, 'ip')}
|
||
disabled={decryptAttempts <= 0 || selectedTransaction.ip}
|
||
>
|
||
🕵️ Расшифровать IP ({decryptAttempts} осталось)
|
||
</button>
|
||
<button
|
||
style={{
|
||
background: '#3498db',
|
||
color: 'white',
|
||
border: 'none',
|
||
padding: '8px 15px',
|
||
borderRadius: '3px',
|
||
cursor: 'pointer'
|
||
}}
|
||
onClick={() => handleDecryptField(selectedTransaction.id, 'device')}
|
||
disabled={decryptAttempts <= 0 || selectedTransaction.device}
|
||
>
|
||
🕵️ Расшифровать устройство ({decryptAttempts} осталось)
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Результат игры */}
|
||
{gameResult === 'success' && (
|
||
<div style={{
|
||
margin: '20px 0',
|
||
textAlign: 'center',
|
||
fontSize: '18px'
|
||
}}>
|
||
<p>Текущий уровень: {currentLevel}</p>
|
||
<div style={{
|
||
width: '100%',
|
||
height: '20px',
|
||
backgroundColor: '#333',
|
||
borderRadius: '10px',
|
||
margin: '10px 0'
|
||
}}>
|
||
<div style={{
|
||
width: `${(currentLevel % 5) * 20}%`,
|
||
height: '100%',
|
||
backgroundColor: '#4CAF50',
|
||
borderRadius: '10px'
|
||
}}></div>
|
||
</div>
|
||
<p>Следующий уровень загружается...</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
<DialogWindow
|
||
currentDialog={currentDialog}
|
||
dialogIndex={dialogIndex}
|
||
showDialog={showDialog}
|
||
formData={formData}
|
||
currentForm={currentForm}
|
||
handleAnswerSelect={handleAnswerSelect}
|
||
handleFormSubmit={handleFormSubmit}
|
||
handleFormChange={handleFormChange}
|
||
setShowDialog={setShowDialog}
|
||
/>
|
||
{selectedPlayer && (
|
||
<div
|
||
ref={statsRef}
|
||
style={{
|
||
position: 'absolute',
|
||
top: 20, left: 20,
|
||
background: 'rgba(0,0,0,0.8)',
|
||
color: '#fff',
|
||
padding: 16,
|
||
borderRadius: 8,
|
||
minWidth: 260,
|
||
zIndex: 100
|
||
}}
|
||
>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||
<h3 style={{ margin: 0 }}>
|
||
{selectedPlayer.firstName} {selectedPlayer.lastName}
|
||
</h3>
|
||
<button
|
||
onClick={() => { setSelectedPlayer(null); setPlayerStats(null); }}
|
||
style={{
|
||
background: 'transparent',
|
||
border: 'none',
|
||
color: '#fff',
|
||
fontSize: '16px',
|
||
cursor: 'pointer'
|
||
}}
|
||
>
|
||
✕
|
||
</button>
|
||
</div>
|
||
|
||
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
|
||
<button onClick={viewStats} style={btnStyle}>Посмотреть статистику</button>
|
||
<button style={btnStyle} onClick={() => { /* познакомиться */ }}>Познакомиться</button>
|
||
</div>
|
||
|
||
{playerStats && (
|
||
<div style={{ marginTop: 12, lineHeight: '1.4em' }}>
|
||
<p><b>Баланс:</b> {playerStats.balance}</p>
|
||
<p><b>Часов игры:</b> {playerStats.hoursPlayed}</p>
|
||
<p><b>Репутация:</b> {playerStats.reputation}</p>
|
||
<p><b>Телефон:</b> {playerStats.phone || '—'}</p>
|
||
<p><b>Спортивность:</b> {playerStats.sportiness}</p>
|
||
<p><b>Уровень здоровья:</b> {playerStats.healthLevel}</p>
|
||
<p><b>Уровень стресса:</b> {playerStats.stressLevel}</p>
|
||
<p><b>Болезни:</b> {playerStats.diseases?.join(', ') || 'нет'}</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
{showMiniGame && (
|
||
<div style={{
|
||
position: 'fixed',
|
||
top: 0,
|
||
left: 0,
|
||
width: '100vw',
|
||
height: '100vh',
|
||
background: 'rgba(0,0,0,0.95)',
|
||
zIndex: 2000,
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
fontFamily: '"Courier New", monospace',
|
||
color: '#0f0',
|
||
backdropFilter: 'blur(5px)'
|
||
}}>
|
||
{/* Terminal-like header */}
|
||
<div style={{
|
||
width: '90%',
|
||
maxWidth: '800px',
|
||
background: '#111',
|
||
borderTopLeftRadius: '10px',
|
||
borderTopRightRadius: '10px',
|
||
padding: '10px 20px',
|
||
display: 'flex',
|
||
justifyContent: 'space-between',
|
||
alignItems: 'center',
|
||
borderBottom: '1px solid #333'
|
||
}}>
|
||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||
<div style={{
|
||
width: '12px',
|
||
height: '12px',
|
||
borderRadius: '50%',
|
||
background: '#ff5f56',
|
||
marginRight: '8px'
|
||
}}></div>
|
||
<div style={{
|
||
width: '12px',
|
||
height: '12px',
|
||
borderRadius: '50%',
|
||
background: '#ffbd2e',
|
||
marginRight: '8px'
|
||
}}></div>
|
||
<div style={{
|
||
width: '12px',
|
||
height: '12px',
|
||
borderRadius: '50%',
|
||
background: '#27c93f'
|
||
}}></div>
|
||
<span style={{ marginLeft: '15px', color: '#ccc' }}>terminal — hack_system</span>
|
||
</div>
|
||
<button
|
||
onClick={() => {
|
||
setShowMiniGame(false);
|
||
setPasswordCorrect(false);
|
||
setAudioUrl("/audio/firs.ogg");
|
||
}}
|
||
style={{
|
||
background: 'transparent',
|
||
border: 'none',
|
||
color: '#ccc',
|
||
fontSize: '18px',
|
||
cursor: 'pointer'
|
||
}}
|
||
>
|
||
✕
|
||
</button>
|
||
</div>
|
||
|
||
{/* Main terminal content */}
|
||
<div style={{
|
||
width: '90%',
|
||
maxWidth: '800px',
|
||
height: '60vh',
|
||
background: 'rgba(0, 20, 0, 0.2)',
|
||
padding: '20px',
|
||
overflowY: 'auto',
|
||
border: '1px solid #0a0',
|
||
boxShadow: '0 0 20px rgba(0, 255, 0, 0.1)',
|
||
position: 'relative'
|
||
}}>
|
||
{/* Terminal text */}
|
||
<div style={{ marginBottom: '20px' }}>
|
||
<p style={{ color: '#0f0', margin: '5px 0' }}>
|
||
<span style={{ color: '#0af' }}>user@hack-system:</span>~
|
||
<span style={{ color: '#0f0' }}>$</span> sudo access mainframe
|
||
</p>
|
||
<p style={{ color: '#f50', margin: '5px 0' }}>
|
||
[sudo] password for user: ********
|
||
</p>
|
||
<p style={{ color: '#0f0', margin: '5px 0' }}>
|
||
<span style={{ color: '#0af' }}>user@hack-system:</span>~
|
||
<span style={{ color: '#0f0' }}>$</span> Trying to bypass security...
|
||
</p>
|
||
</div>
|
||
|
||
{/* Waveform visualization */}
|
||
<div style={{
|
||
width: '100%',
|
||
height: '100px',
|
||
background: 'rgba(0, 30, 0, 0.3)',
|
||
margin: '20px 0',
|
||
border: '1px solid #0a0',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center'
|
||
}}>
|
||
<WaveformPlayer
|
||
url={audioUrl}
|
||
playing={isPlaying}
|
||
width={600}
|
||
height={80}
|
||
waveColor="#0f0"
|
||
progressColor="#0a0"
|
||
cursorColor="#0f0"
|
||
/>
|
||
</div>
|
||
|
||
{/* Serega's comment */}
|
||
<div style={{
|
||
padding: '10px',
|
||
background: 'rgba(0, 40, 0, 0.3)',
|
||
borderLeft: '3px solid #0f0',
|
||
margin: '20px 0'
|
||
}}>
|
||
<p style={{ color: '#ff0', margin: '0', fontStyle: 'italic' }}>
|
||
<span style={{ color: '#0af' }}>SEREGA_PIRAT:</span>
|
||
{seregaComments.length > 0 ? seregaComments[seregaComments.length - 1].text : "Ну чё, хакер, разберёшься?"}
|
||
</p>
|
||
</div>
|
||
|
||
{/* Password options */}
|
||
<div style={{ marginTop: '30px' }}>
|
||
<p style={{ color: '#0f0', marginBottom: '10px' }}>
|
||
Available password fragments:
|
||
</p>
|
||
<div style={{
|
||
display: 'grid',
|
||
gridTemplateColumns: 'repeat(2, 1fr)',
|
||
gap: '10px',
|
||
marginBottom: '20px'
|
||
}}>
|
||
{passwordCorrect ? (
|
||
programmingLanguages.map((lang, index) => (
|
||
<div key={index} style={{
|
||
padding: '10px',
|
||
background: 'rgba(0, 50, 0, 0.3)',
|
||
border: '1px solid #0a0',
|
||
borderRadius: '5px',
|
||
textAlign: 'center',
|
||
cursor: 'pointer',
|
||
transition: 'all 0.2s',
|
||
':hover': {
|
||
background: 'rgba(0, 80, 0, 0.5)',
|
||
boxShadow: '0 0 10px rgba(0, 255, 0, 0.3)'
|
||
}
|
||
}}>
|
||
{lang}
|
||
</div>
|
||
))
|
||
) : (
|
||
['ab3 Df7 Gh9', 'Q1 wE4 rT6', 'mN8 2kP 5zX', 'L0 p09 vB7'].map((item, index) => (
|
||
<div key={index} style={{
|
||
padding: '10px',
|
||
background: 'rgba(0, 50, 0, 0.3)',
|
||
border: '1px solid #0a0',
|
||
borderRadius: '5px',
|
||
textAlign: 'center'
|
||
}}>
|
||
{item}
|
||
</div>
|
||
))
|
||
)}
|
||
</div>
|
||
|
||
{/* Password input */}
|
||
<div style={{ position: 'relative' }}>
|
||
<span style={{ color: '#0f0' }}>Enter password:</span>
|
||
<input
|
||
type="text"
|
||
placeholder="Type here and press Enter..."
|
||
onKeyDown={handlePasswordInput}
|
||
style={{
|
||
width: '100%',
|
||
padding: '10px',
|
||
marginTop: '5px',
|
||
background: 'rgba(0, 0, 0, 0.5)',
|
||
border: '1px solid #0a0',
|
||
color: '#0f0',
|
||
fontFamily: '"Courier New", monospace',
|
||
fontSize: '16px',
|
||
outline: 'none'
|
||
}}
|
||
/>
|
||
<div style={{
|
||
position: 'absolute',
|
||
bottom: '-20px',
|
||
right: '0',
|
||
color: '#888',
|
||
fontSize: '12px'
|
||
}}>
|
||
Hint: Try common passwords first
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Controls */}
|
||
<div style={{
|
||
width: '90%',
|
||
maxWidth: '800px',
|
||
display: 'flex',
|
||
justifyContent: 'space-between',
|
||
padding: '15px 20px',
|
||
background: '#111',
|
||
borderBottomLeftRadius: '10px',
|
||
borderBottomRightRadius: '10px',
|
||
borderTop: '1px solid #333'
|
||
}}>
|
||
<button
|
||
onClick={() => setIsPlaying(!isPlaying)}
|
||
style={{
|
||
padding: '8px 15px',
|
||
background: isPlaying ? '#f50' : '#0a0',
|
||
border: 'none',
|
||
borderRadius: '5px',
|
||
color: '#fff',
|
||
cursor: 'pointer',
|
||
fontFamily: '"Courier New", monospace'
|
||
}}
|
||
>
|
||
{isPlaying ? 'Pause Sound' : 'Play Sound'}
|
||
</button>
|
||
<div style={{ color: '#888' }}>
|
||
Status: {passwordCorrect ? 'ACCESS GRANTED' : 'ACCESS DENIED'}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
{orgMenu && (
|
||
<div style={{
|
||
position: 'absolute',
|
||
top: 20,
|
||
right: 20,
|
||
background: 'rgba(0,0,0,0.8)',
|
||
color: '#fff',
|
||
padding: 16,
|
||
borderRadius: 8,
|
||
minWidth: 220
|
||
}}>
|
||
<h3 style={{ margin: 0, marginBottom: 8 }}>{orgMenu.name}</h3>
|
||
{orgMenu.menu && Object.keys(orgMenu.menu).map(key => (
|
||
<div key={key} style={{ marginBottom: 8 }}>
|
||
<span>{orgMenu.menu[key].title} — {orgMenu.menu[key].price}₽</span>
|
||
<button onClick={() => buyItem(key)} style={{ marginLeft: 8 }}>Купить</button>
|
||
</div>
|
||
))}
|
||
<button onClick={() => setOrgMenu(null)} style={{ marginTop: 8 }}>Закрыть</button>
|
||
</div>
|
||
)}
|
||
<DoubleTapWrapper
|
||
onDoubleTap={() => setIsChatVisible(false)}
|
||
onTap={() => { if (!isChatVisible) setIsChatVisible(true); }}
|
||
>
|
||
<div
|
||
style={{
|
||
position: 'absolute',
|
||
top: '20px',
|
||
left: '20px',
|
||
width: '25%',
|
||
height: '5%',
|
||
padding: '10px',
|
||
borderRadius: '15px',
|
||
fontSize: '14px',
|
||
zIndex: 10,
|
||
opacity: isChatVisible ? 1 : 0,
|
||
transition: 'opacity 0.3s ease',
|
||
// Разрешаем клики даже когда невидим
|
||
pointerEvents: 'auto',
|
||
// Прозрачная область для кликов когда скрыт
|
||
cursor: isChatVisible ? 'default' : 'pointer'
|
||
}}
|
||
onDoubleClick={() => setIsChatVisible(false)}
|
||
onClick={() => {
|
||
if (!isChatVisible) {
|
||
setIsChatVisible(true);
|
||
}
|
||
}
|
||
}
|
||
>
|
||
<div style={{
|
||
display: 'flex',
|
||
justifyContent: 'space-between',
|
||
alignItems: 'center',
|
||
marginBottom: '10px'
|
||
}}>
|
||
<span>Голосовой чат: {micEnabled ? 'Вкл' : 'Выкл'}</span>
|
||
<button
|
||
onClick={toggleMicrophone}
|
||
style={{
|
||
...btnStyle,
|
||
background: micEnabled ? '#dc3545' : '#28a745'
|
||
}}
|
||
>
|
||
{micEnabled ? 'Выключить микрофон' : 'Включить микрофон'}
|
||
</button>
|
||
</div>
|
||
<div id="chatMessages" style={{
|
||
height: '150px',
|
||
overflowY: 'auto',
|
||
padding: '5px',
|
||
borderRadius: '10px',
|
||
color: 'white'
|
||
}}>
|
||
</div>
|
||
</div>
|
||
</DoubleTapWrapper>
|
||
|
||
<DoubleTapWrapper
|
||
onDoubleTap={() => setIsChatVisible(false)}
|
||
onTap={() => { if (!isChatVisible) setIsChatVisible(true); }}
|
||
>
|
||
<div
|
||
style={{
|
||
position: 'absolute',
|
||
bottom: '20px',
|
||
left: '20px',
|
||
width: '25%',
|
||
height: '5%',
|
||
padding: '10px',
|
||
borderRadius: '15px',
|
||
fontSize: '14px',
|
||
zIndex: 10,
|
||
opacity: isChatVisible ? 1 : 0,
|
||
transition: 'opacity 0.3s ease',
|
||
// Разрешаем клики даже когда невидим
|
||
pointerEvents: 'auto',
|
||
// Прозрачная область для кликов когда скрыт
|
||
cursor: isChatVisible ? 'default' : 'pointer'
|
||
}}
|
||
onDoubleClick={() => setIsChatVisible(false)}
|
||
onClick={() => {
|
||
if (!isChatVisible) {
|
||
setIsChatVisible(true);
|
||
}
|
||
}
|
||
}
|
||
>
|
||
<input
|
||
id="chatInput"
|
||
type="text"
|
||
placeholder="Введите сообщение..."
|
||
style={{
|
||
width: '65%',
|
||
padding: '5px',
|
||
position: 'relative',
|
||
left: '10px',
|
||
bottom: '5%',
|
||
opacity: '50%'
|
||
}}
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Enter') {
|
||
const msg = e.target.value.trim();
|
||
if (msg) {
|
||
socketRef.current?.emit('chatMessage', {
|
||
message: msg,
|
||
name: mountRef.current
|
||
});
|
||
console.log('отправил', msg);
|
||
e.target.value = '';
|
||
}
|
||
}
|
||
}}
|
||
/>
|
||
</div>
|
||
</DoubleTapWrapper>
|
||
{/*Телефон*/}
|
||
<DoubleTapWrapper
|
||
onDoubleTap={() => setIsPhoneVisible(false)}
|
||
onTap={() => { if (!isPhoneVisible) setIsPhoneVisible(true); }}
|
||
>
|
||
<div
|
||
style={{
|
||
position: "absolute",
|
||
bottom: "20px",
|
||
right: "20px",
|
||
background: "linear-gradient(#e66465, #9198e5)",
|
||
width: "200px",
|
||
aspectRatio: "10 / 19.5",
|
||
borderRadius: "1.5em",
|
||
border: "0.5em solid black",
|
||
overflow: "hidden",
|
||
zIndex: 100,
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
justifyContent: "space-between",
|
||
opacity: isPhoneVisible ? 1 : 0,
|
||
transition: 'opacity 0.3s ease',
|
||
// Разрешаем клики даже когда невидим
|
||
pointerEvents: 'auto',
|
||
// Прозрачная область для кликов когда скрыт
|
||
cursor: isPhoneVisible ? 'default' : 'pointer'
|
||
}}
|
||
onDoubleClick={() => setIsPhoneVisible(false)}
|
||
onClick={() => {
|
||
if (!isPhoneVisible) {
|
||
setIsPhoneVisible(true);
|
||
}
|
||
}
|
||
}
|
||
>
|
||
{/* Содержимое телефона */}
|
||
<div style={{ flex: 1, position: "relative", pointerEvents: isPhoneVisible ? 'auto' : 'none' }}>
|
||
{!appsHidden ? (
|
||
// Иконки приложений
|
||
<div className="app-grid" style={{
|
||
display: "grid",
|
||
gridTemplateColumns: "repeat(3, 1fr)",
|
||
gap: "0.5em",
|
||
padding: "0.5em"
|
||
}}>
|
||
{[
|
||
{ src: "https://cdn-icons-png.flaticon.com/512/174/174855.png", alt: "YouTube", app: "YouTube" },
|
||
{ src: "https://cdn-icons-png.flaticon.com/512/732/732200.png", alt: "Gmail", app: "Gmail" },
|
||
{ src: "https://cdn-icons-png.flaticon.com/512/1828/1828864.png", alt: "Камера", app: "Camera" },
|
||
{ src: "https://cdn.iconscout.com/icon/free/png-512/free-telegram-logo-icon-download-in-svg-png-gif-file-formats--social-media-brand-pack-logos-icons-3073750.png?f=webp&w=512", alt: "Telegram", app: "Telegram" },
|
||
{ src: "https://cdn-icons-png.flaticon.com/512/732/732200.png", alt: "Gmail" },
|
||
{ src: "https://cdn-icons-png.flaticon.com/512/2111/2111398.png", alt: "Instagram" },
|
||
{ src: "https://cdn-icons-png.flaticon.com/512/732/732228.png", alt: "Google Drive" },
|
||
{ src: "https://cdn-icons-png.flaticon.com/512/732/732190.png", alt: "Chrome", app: "Chrome" },
|
||
{ src: "https://cdn-icons-png.flaticon.com/512/270/270798.png", alt: "Settings", app: "Settings" },
|
||
{
|
||
src: "https://cdn-icons-png.flaticon.com/512/1828/1828817.png",
|
||
alt: "Phone",
|
||
app: "Phone"
|
||
},
|
||
{ src: "https://cdn-icons-png.flaticon.com/512/1828/1828864.png", alt: "Камера" },
|
||
{ src: "https://cdn-icons-png.flaticon.com/512/1828/1828911.png", alt: "Gallery" },
|
||
{ src: "https://cdn-icons-png.flaticon.com/512/1828/1828970.png", alt: "Music" },
|
||
{ src: "https://cdn-icons-png.flaticon.com/512/1828/1828961.png", alt: "Notes" },
|
||
{ src: "https://cdn-icons-png.flaticon.com/512/1828/1828843.png", alt: "Clock" },
|
||
{ src: "https://cdn-icons-png.flaticon.com/512/1828/1828998.png", alt: "Files" }
|
||
].map((app, index) => (
|
||
<button
|
||
key={index}
|
||
style={{
|
||
width: "100%",
|
||
aspectRatio: "1 / 1",
|
||
borderRadius: "0.5em",
|
||
border: "none",
|
||
backgroundImage: `url(${app.src})`,
|
||
backgroundSize: "contain",
|
||
backgroundPosition: "center",
|
||
backgroundRepeat: "no-repeat",
|
||
cursor: "pointer"
|
||
}}
|
||
aria-label={app.alt}
|
||
onClick={() => handleAppClick(app.app)}
|
||
/>
|
||
))}
|
||
</div>
|
||
) : (
|
||
// Псевдо-сайт
|
||
<div style={{
|
||
position: "absolute",
|
||
top: 0,
|
||
left: 0,
|
||
padding: "1em",
|
||
width: "100%",
|
||
height: "93.175%",
|
||
background: "#fff",
|
||
color: "#000",
|
||
overflowY: "auto",
|
||
fontSize: "10px",
|
||
lineHeight: "1.4"
|
||
}}>
|
||
<div style={{ marginBottom: "1em", fontWeight: "bold" }}>{activeApp}</div>
|
||
{activeApp === "YouTube" && (
|
||
<div style={bodyStyle}>
|
||
<header style={headerStyle}>
|
||
<h1>Недвижимость в Санкт-Петербурге</h1>
|
||
<p>Лучшие предложения прямо сейчас</p>
|
||
</header>
|
||
<main style={mainStyle}>
|
||
<div style={listingStyle}>
|
||
<img
|
||
src="https://yandex-images.clstorage.net/V5t2lR153/5b1b76_Cs6Z/J2fT6H2GNMqQp5pP1PgV1n2hU6uO-QeqmIIO5oUFJLYGmDdlCheTdwp3Fes87_2cZGawZZUtHoYEDrfWOBlbiuYjgPmtwWLeQiBPTdQ5VVEq8ZfsmHgQ7AgVGTbHR7J3R1e4bddLCTyQvMi04j_pSmQy9iMF_IUd1JkuWinczlhhK1WtM5byh965VsSTMNfWbyFXJR71HOMX0Rw31Y_p6pfcemgeRsf2335F-O3zoYSuPrl1TTeCksKfLpcukMeRISgY6HjUd0NRNRyK1_QfkCfrkiYVc5oglB6Xt9-MYaLXmjWjFccHcRa2yvqouCvFazm99gHwwxdOGGMWIFgClmkiWaZ8EzXHnrUfhB24kdXm6F6qkDrZ5FiVEz5Uh2ipkFD0ZFNHwrRY88t4LbXqxOl9PrrANY-TgZnplSDSDJchKllhNdTzzdF2VYSduBHY465UYpy6EWqZ2NO51YUpKl3QvOPViYP-mHyNuupxbUPtv3V0TLPD1kAQ518omM_eJGCXoDpUMoFUNNlF1PHY0Ssg0aVVsF3h3xNeN9qKrqNR3faslMZOt9Z8jTtgu-gE5PH1vIh8TN8H3Cle5tPEWCRuW-553fpCnbfSTBR8FhujqZzqm7Dbq9QYkPBfh6evnhU6rl4HhbXRfkR26HpiAK7ydLMMcElZxV9unC3Zy1Tjq1Nu8ZU7i9c1HsXcvZ0aJWRRJJC5E-2b29H3GkjtZ5ibdy-eSAI30LbN-CT56cqlfzoyinGA2Y2ZoFutGUsXqiSdrXPecY5XfJnMU79enmUnUS1Q-VKgVFxZ-5LB56rfkXXsX0UHux0wwj8g9yrKofW0M4j3T5NCmqsdaVwMGCbpVqX0mTOL0Lfdwdyw0FovaJ9j3HbVqtVWXPMUACVh1Z757FJHzDyaO8I64LVpjGQ3PTSK9A4bhlap2igVChZqbxli91w-ipgxXcPWPBoTL-pRqBg3He7UUN_zms"
|
||
alt="Квартира у метро"
|
||
style={imageStyle}
|
||
/>
|
||
<h3 style={listingTitleStyle}>2-комнатная квартира у метро</h3>
|
||
<p>Площадь: 58 м² | Цена: 9 500 000 ₽</p>
|
||
</div>
|
||
<div style={listingStyle}>
|
||
<img
|
||
src="https://img.gta5-mods.com/q95/images/beach-apartment/69814f-GTA5%202016-03-06%2023-11-55-41.png"
|
||
alt="ЖК Комфорт"
|
||
style={imageStyle}
|
||
/>
|
||
<p>Студия 28 м² | Цена: 5 800 000 ₽</p>
|
||
</div>
|
||
</main>
|
||
</div>
|
||
)}
|
||
{activeApp === "Gmail" && (
|
||
<div>
|
||
<p>📧 Входящие:</p>
|
||
<ul>
|
||
<li><b>От:</b> Папа — "Где ты гуляешь?"</li>
|
||
<li><b>От:</b> Курьер — "Ваш заказ доставлен"</li>
|
||
<li><b>От:</b> Izя — "Ты идешь сегодня?" ❤️</li>
|
||
</ul>
|
||
</div>
|
||
)}
|
||
{activeApp === "Camera" && (
|
||
<div style={bodyStyle}>
|
||
<header style={headerStyle}>
|
||
<h1>Недвижимость в Санкт-Петербурге</h1>
|
||
<p>Лучшие предложения прямо сейчас</p>
|
||
</header>
|
||
<main style={mainStyle}>
|
||
<div style={listingStyle}>
|
||
<img
|
||
src="https://yandex-images.clstorage.net/V5t2lR153/5b1b76_Cs6Z/J2fT6H2GNMqQp5pP1PgV1n2hU6uO-QeqmIIO5oUFJLYGmDdlCheTdwp3Fes87_2cZGawZZUtHoYEDrfWOBlbiuYjgPmtwWLeQiBPTdQ5VVEq8ZfsmHgQ7AgVGTbHR7J3R1e4bddLCTyQvMi04j_pSmQy9iMF_IUd1JkuWinczlhhK1WtM5byh965VsSTMNfWbyFXJR71HOMX0Rw31Y_p6pfcemgeRsf2335F-O3zoYSuPrl1TTeCksKfLpcukMeRISgY6HjUd0NRNRyK1_QfkCfrkiYVc5oglB6Xt9-MYaLXmjWjFccHcRa2yvqouCvFazm99gHwwxdOGGMWIFgClmkiWaZ8EzXHnrUfhB24kdXm6F6qkDrZ5FiVEz5Uh2ipkFD0ZFNHwrRY88t4LbXqxOl9PrrANY-TgZnplSDSDJchKllhNdTzzdF2VYSduBHY465UYpy6EWqZ2NO51YUpKl3QvOPViYP-mHyNuupxbUPtv3V0TLPD1kAQ518omM_eJGCXoDpUMoFUNNlF1PHY0Ssg0aVVsF3h3xNeN9qKrqNR3faslMZOt9Z8jTtgu-gE5PH1vIh8TN8H3Cle5tPEWCRuW-553fpCnbfSTBR8FhujqZzqm7Dbq9QYkPBfh6evnhU6rl4HhbXRfkR26HpiAK7ydLMMcElZxV9unC3Zy1Tjq1Nu8ZU7i9c1HsXcvZ0aJWRRJJC5E-2b29H3GkjtZ5ibdy-eSAI30LbN-CT56cqlfzoyinGA2Y2ZoFutGUsXqiSdrXPecY5XfJnMU79enmUnUS1Q-VKgVFxZ-5LB56rfkXXsX0UHux0wwj8g9yrKofW0M4j3T5NCmqsdaVwMGCbpVqX0mTOL0Lfdwdyw0FovaJ9j3HbVqtVWXPMUACVh1Z757FJHzDyaO8I64LVpjGQ3PTSK9A4bhlap2igVChZqbxli91w-ipgxXcPWPBoTL-pRqBg3He7UUN_zms"
|
||
alt="Квартира у метро"
|
||
style={imageStyle}
|
||
/>
|
||
<h3 style={listingTitleStyle}>2-комнатная квартира у метро</h3>
|
||
<p>Площадь: 58 м² | Цена: 9 500 000 ₽</p>
|
||
</div>
|
||
<div style={listingStyle}>
|
||
<img
|
||
src="https://img.gta5-mods.com/q95/images/beach-apartment/69814f-GTA5%202016-03-06%2023-11-55-41.png"
|
||
alt="ЖК Комфорт"
|
||
style={imageStyle}
|
||
/>
|
||
<p>Студия 28 м² | Цена: 5 800 000 ₽</p>
|
||
</div>
|
||
</main>
|
||
</div>
|
||
)}
|
||
|
||
{activeApp === "Chrome" && (
|
||
<div style={bodyStyle}>
|
||
<header style={headerStyle}>
|
||
<h1>Прогресс квестов</h1>
|
||
</header>
|
||
<main style={mainStyle}>
|
||
{questsProgress.length === 0 ? (
|
||
<p>Нет активных квестов</p>
|
||
) : (
|
||
questsProgress.map(quest => (
|
||
<div key={quest.id} style={listingStyle}>
|
||
<h3 style={listingTitleStyle}>{quest.title}</h3>
|
||
<div style={{
|
||
width: '100%',
|
||
height: '20px',
|
||
backgroundColor: '#e0e0e0',
|
||
borderRadius: '10px',
|
||
margin: '10px 0'
|
||
}}>
|
||
<div style={{
|
||
width: `${quest.progress}%`,
|
||
height: '100%',
|
||
backgroundColor: quest.progress === 100 ? '#4CAF50' : '#2196F3',
|
||
borderRadius: '10px',
|
||
transition: 'width 0.3s ease'
|
||
}}></div>
|
||
</div>
|
||
<p>Выполнено: {quest.completed} из {quest.total} ({quest.progress}%)</p>
|
||
</div>
|
||
))
|
||
)}
|
||
</main>
|
||
</div>
|
||
)}
|
||
|
||
{activeApp === "Telegram" && (
|
||
<div style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column', background: '#fff' }}>
|
||
{/* Заголовок приложения */}
|
||
<div style={{ padding: '8px 12px', background: '#0088cc', color: '#fff', fontWeight: 700, textAlign: 'center' }}>Shipgram</div>
|
||
{/* Контент */}
|
||
<div style={{ flex: 1, display: 'flex', minHeight: 0 }}>
|
||
{/* Список контактов */}
|
||
<div style={{ width: isPhoneNarrow ? (activeChat ? '0%' : '100%') : '30%', display: isPhoneNarrow && activeChat ? 'none' : 'block', borderRight: '1px solid #ddd', overflowY: 'auto', background: '#fff' }}>
|
||
<div style={{ padding: 10, fontWeight: 600, borderBottom: '1px solid #eee' }}>Контакты</div>
|
||
{tgLoading && (
|
||
<div style={{ padding: 12, color: '#666' }}>Загрузка…</div>
|
||
)}
|
||
{tgError && (
|
||
<div style={{ padding: 12, color: '#b91c1c' }}>{tgError}</div>
|
||
)}
|
||
{!tgLoading && !tgError && telegramContacts.length === 0 && (
|
||
<div style={{ padding: 12, color: '#666' }}>Контакты не найдены</div>
|
||
)}
|
||
{telegramContacts.map((user) => (
|
||
<div key={user.id} onClick={() => setActiveChat(user)} style={{ padding: '10px 12px', display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', color: '#111' }}>
|
||
<div style={{ position: 'relative' }}>
|
||
<div style={{ width: 28, height: 28, borderRadius: 14, background: '#e5e7eb', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 12 }}>
|
||
{user.firstName?.[0]}{user.lastName?.[0]}
|
||
</div>
|
||
{user.isOnline && (
|
||
<div style={{
|
||
position: 'absolute',
|
||
bottom: 0,
|
||
right: 0,
|
||
width: 10,
|
||
height: 10,
|
||
borderRadius: 5,
|
||
background: '#10b981',
|
||
border: '2px solid #fff',
|
||
boxShadow: '0 0 0 1px #e5e7eb'
|
||
}} />
|
||
)}
|
||
</div>
|
||
<div style={{ overflow: 'hidden', flex: 1 }}>
|
||
<div style={{
|
||
whiteSpace: 'nowrap',
|
||
textOverflow: 'ellipsis',
|
||
overflow: 'hidden',
|
||
fontWeight: user.unreadCount > 0 ? 'bold' : 'normal'
|
||
}}>
|
||
{`${user.firstName || ''} ${user.lastName || ''}`}
|
||
</div>
|
||
<div style={{
|
||
fontSize: 12,
|
||
color: user.isOnline ? '#10b981' : '#6b7280',
|
||
fontWeight: user.isOnline ? 600 : 400
|
||
}}>
|
||
{user.isOnline ? 'Онлайн' : (
|
||
user.lastSeen ?
|
||
`Был(а) ${new Date(user.lastSeen).toLocaleString('ru-RU', {
|
||
month: 'short',
|
||
day: 'numeric',
|
||
hour: '2-digit',
|
||
minute: '2-digit'
|
||
})}` :
|
||
'Офлайн'
|
||
)}
|
||
</div>
|
||
</div>
|
||
{/* Счетчик непрочитанных сообщений */}
|
||
{user.unreadCount > 0 && (
|
||
<div style={{
|
||
background: '#ef4444',
|
||
color: 'white',
|
||
borderRadius: '50%',
|
||
minWidth: '20px',
|
||
height: '20px',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
fontSize: '12px',
|
||
fontWeight: 'bold',
|
||
padding: '2px'
|
||
}}>
|
||
{user.unreadCount > 99 ? '99+' : user.unreadCount}
|
||
</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
{/* Область чата */}
|
||
<div style={{ flex: 1, display: isPhoneNarrow && !activeChat ? 'none' : 'flex', flexDirection: 'column', background: '#fff', overflowX: 'hidden', overflowY: 'auto' }}>
|
||
{activeChat && (
|
||
<>
|
||
<div style={{ padding: '8px 12px', borderBottom: '1px solid #eee', display: 'flex', alignItems: 'center', gap: 8 }}>
|
||
{isPhoneNarrow && (
|
||
<button onClick={() => setActiveChat(null)} style={{ border: 'none', background: 'transparent', fontSize: 16, cursor: 'pointer' }}>←</button>
|
||
)}
|
||
<span style={{ fontWeight: 600 }}>{activeChat.firstName} {activeChat.lastName}</span>
|
||
</div>
|
||
<div id="chatContainer" style={{ flex: 1, overflowY: 'auto', padding: 10, background: '#fafafa' }}>
|
||
{console.log("UserProfile ID:", activeChat.id, "Type:", typeof activeChat.id)}
|
||
{console.log("First message sender_id:", messages[0]?.sender_id, "Type:", typeof messages[0]?.sender_id)}
|
||
{messages.length === 0 ? (
|
||
<p style={{ textAlign: 'center', color: '#666' }}>Нет сообщений</p>
|
||
) : (
|
||
messages.map(msg => (
|
||
<div key={msg.id} style={{ display: 'flex', justifyContent: (Number(msg.sender_id) == Number(activeChat.id)) ? 'flex-start' : 'flex-end', margin: '8px 0' }}>
|
||
<div style={{
|
||
maxWidth: 'min(40%, 30ch)', // Ограничение и по % и по символам
|
||
background: (Number(msg.sender_id) == Number(activeChat.id)) ? '#e5e5ea' : '#0084ff',
|
||
color: (Number(msg.sender_id) == Number(activeChat.id)) ? '#000' : '#fff',
|
||
padding: '8px 12px',
|
||
borderRadius: 12,
|
||
wordWrap: 'break-word',
|
||
overflowWrap: 'break-word',
|
||
whiteSpace: 'pre-wrap'
|
||
}}>
|
||
{msg.message}
|
||
</div>
|
||
</div>
|
||
))
|
||
)}
|
||
</div>
|
||
<div style={{
|
||
padding: 8,
|
||
display: 'flex',
|
||
gap: 8,
|
||
borderTop: '1px solid #eee',
|
||
background: '#fff',
|
||
width: '100%'
|
||
}}>
|
||
<input
|
||
type="text"
|
||
value={newMessage}
|
||
onChange={(e) => setNewMessage(e.target.value)}
|
||
placeholder="Сообщение"
|
||
onKeyDown={(e) => { if (e.key === 'Enter') sendMessage(); }}
|
||
style={{
|
||
flex: 1,
|
||
padding: '8px 8px',
|
||
width: '80%',
|
||
maxWidth: '140px',
|
||
borderRadius: 12,
|
||
border: '1px solid #ddd',
|
||
boxSizing: 'border-box',
|
||
wordWrap: 'break-word',
|
||
whiteSpace: 'pre-wrap'
|
||
}}
|
||
/>
|
||
<button onClick={sendMessage} style={{ padding: '8px 8px', background: '#0084ff', color: '#fff', border: 'none', borderRadius: 12, cursor: 'pointer' }}>➤</button>
|
||
</div>
|
||
</>
|
||
)}
|
||
{!activeChat && (
|
||
<div style={{ margin: 'auto', color: '#666' }}>Выберите контакт</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Нижняя кнопка */}
|
||
<div style={{
|
||
backgroundColor: "black",
|
||
width: "100%",
|
||
height: "10%",
|
||
borderTop: "0.5em solid black",
|
||
display: "flex",
|
||
justifyContent: "center",
|
||
alignItems: "center"
|
||
}}>
|
||
<div
|
||
style={{
|
||
backgroundColor: "white",
|
||
width: "15%",
|
||
aspectRatio: "1 / 1",
|
||
borderRadius: "50%",
|
||
border: "2px solid black"
|
||
}}
|
||
>
|
||
<button onClick={closeApp} style={{
|
||
opacity: 0,
|
||
position: "absolute",
|
||
bottom: "6px",
|
||
left: "50%",
|
||
transform: "translateX(-50%)",
|
||
padding: "0.5em 1em",
|
||
borderRadius: "10em",
|
||
background: "#000",
|
||
color: "white",
|
||
border: "none",
|
||
cursor: "pointer"
|
||
}}>
|
||
⬅ Назад
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</DoubleTapWrapper>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const btnStyle = {
|
||
flex: 1,
|
||
padding: '8px 12px',
|
||
background: '#17a2b8',
|
||
border: 'none',
|
||
borderRadius: 4,
|
||
color: '#fff',
|
||
cursor: 'pointer'
|
||
};
|
||
|
||
export default Game; |