Files
rltn/src/Game.js

5701 lines
247 KiB
JavaScript
Raw Normal View History

/*
- Проблема с игроками они множатся
- Проблема с перемещением между городами (исчезновение и появление игроков)
- Проблема с Null полусферами
*/
import React, { useState, useEffect, useRef } from 'react';
import * as THREE from 'three';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
import PF from 'pathfinding';
import { io } from 'socket.io-client';
import DoubleTapWrapper from './pages/DoubleTapWrapper';
import OrgControlPanel from './components/OrgControlPanel';
import Inventory from './components/Inventory';
import { useDialogManager } from './components/DialogSystem/DialogManager';
import { DialogWindow } from './components/DialogSystem/DialogWindow';
import WaveformPlayer from './pages/WaveformPlayer';
function Game({ avatarUrl, gender }) {
// 1) реф для хранилища сцены
const sceneRef = useRef(new THREE.Scene());
// 2) реф для группы «города»
const cityGroupRef = useRef(null);
// 3) реф для группы «интерьера»
const interiorGroupRef = useRef(null);
2025-08-26 15:56:28 +03:00
const interiorCollidersRef = useRef([]);
const interiorExitPosRef = useRef(null);
const fpHiddenNodesRef = useRef([]);
2025-08-18 17:27:14 +03:00
const cleanupTimerRef = useRef(null);
// Глобальный менеджер прогресса загрузки (используем в GLTFLoader)
const loadingManagerRef = useRef(null);
// Кликабельные объекты внутри интерьера
const interiorInteractablesRef = useRef([]);
2025-08-26 15:56:28 +03:00
const npcMeshesRef = useRef([]);
2025-08-18 17:27:14 +03:00
// камеры
const orthoCamRef = useRef(null);
const fpCamRef = useRef(null);
const cameraRef = useRef(null);
const rendererRef = useRef(null);
2025-08-25 22:39:29 +03:00
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);
2025-08-26 15:56:28 +03:00
const altHeldRef = useRef(false);
const LOAD_RADIUS = 120;
const [activeApp, setActiveApp] = useState(null);
const [selectedHouse, setSelectedHouse] = useState(null);
const [isInInterior, setIsInInterior] = useState(false);
2025-08-25 22:39:29 +03:00
const mountRef = useRef(null);
const socketRef = useRef(null);
useEffect(() => {
2025-08-25 22:39:29 +03:00
console.log('useEffect isInInterior изменился:', isInInterior);
isInInteriorRef.current = isInInterior;
2025-08-25 22:39:29 +03:00
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('');
const [balance, setBalance] = useState(() => {
const p = JSON.parse(sessionStorage.getItem('user_profile') || '{}');
return p.balance ?? 0;
});
const [playerCoords, setPlayerCoords] = useState({ x: 0, y: 0, z: 0 });
const [programmingLanguages, setProgrammingLanguages] = useState([]);
const [passwordCorrect, setPasswordCorrect] = useState(false);
const [showMiniGame, setShowMiniGame] = useState(false);
const [questsProgress, setQuestsProgress] = useState([]);
const statsRef = useRef(null);
const voiceConnections = useRef({});
const localStream = useRef(null);
const voiceIcons = useRef({});
const [isPlaying, setIsPlaying] = useState(true);
//Телефон\
const [audioUrl, setAudioUrl] = useState("/audio/firs.ogg");
// for Mini-game_2
const [showCleanupGame, setShowCleanupGame] = useState(false);
const [cleanupGameData, setCleanupGameData] = useState(null);
const [selectedTransaction, setSelectedTransaction] = useState(null);
const [markedTransactions, setMarkedTransactions] = useState([]);
const [decryptAttempts, setDecryptAttempts] = useState(3);
const [timeLeft, setTimeLeft] = useState(180); // 3 минуты
const [suspiciousFound, setSuspiciousFound] = useState(0);
const [gameResult, setGameResult] = useState(null);
const [personalArchive, setPersonalArchive] = useState([]);
const [currentLevel, setCurrentLevel] = useState(1);
const [gameCompleted, setGameCompleted] = useState(false);
const [activeChat, setActiveChat] = useState(null);
// Добавьте этот код в начало компонента Game, рядом с другими состояниями
const [telegramContacts, setTelegramContacts] = useState([]);
const [isIframeOpen, setIsIframeOpen] = useState(false);
const [iframeUrl, setIframeUrl] = useState('');
const [appsHidden, setAppsHidden] = useState(false);
const [isPhoneVisible, setIsPhoneVisible] = useState(true);
const [isChatVisible, setIsChatVisible] = useState(true);
const [seregaComments, setSeregaComments] = useState([]);
2025-08-18 17:27:14 +03:00
const [currentExit, setCurrentExit] = useState(null);
2025-08-26 15:56:28 +03:00
const currentExitRef = useRef(null);
useEffect(() => { currentExitRef.current = currentExit; }, [currentExit]);
2025-08-18 17:27:14 +03:00
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);
//Телефон
2025-08-25 22:39:29 +03:00
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") {
loadTelegramContacts(); // Загрузка контактов при открытии
}
if (appName === "Chrome") {
loadQuestsProgress();
}
if (appName === "Settings") {
setShowMiniGame(true);
}
};
const handlePasswordInput = (e) => {
if (e.key === 'Enter') {
const input = e.target.value.trim();
e.target.value = "";
const negativeComments = [
"Ты чё, братан, спишь?!",
"Мимо кассы, как всегда!",
"Это даже я знаю, что не так!",
"Ну и лажа...",
"Ты вообще в теме или как?",
"Не-а, попробуй ещё раз!"
];
const positiveComments = [
"О, да ты в ударе сегодня!",
"В точку, братишка!",
"Ну наконец-то угадал!",
"Так держать, хакер!",
"Бинго! Правильный ответ!",
"Ты меня удивляешь!"
];
if (input === "mN8 2kP 5zX") {
setTimeout(() => {
addSeregaComment(positiveComments[Math.floor(Math.random() * positiveComments.length)]);
setPasswordCorrect(true);
setProgrammingLanguages(["TR4 FG8 HJ2", "Z9 xC3 vB1", "mN8 2kP 5zX", "kL5 mN7 qW0"]);
setAudioUrl("/audio/TR4-FG8-Hj2.ogg");
}, 800);
}
else if (input === "TR4 FG8 HJ2") {
setTimeout(() => {
addSeregaComment(positiveComments[Math.floor(Math.random() * positiveComments.length)]);
setPasswordCorrect(true);
setProgrammingLanguages(["X b7kG z3Lp", "vn4 Zq J8mr", "sW 1Rt yK 90", "q9 Xgd2 BwF"]);
setAudioUrl("/audio/X-b7kG-z3Lp.ogg");
}, 800);
}
else if (input === "X b7kG z3Lp") {
setTimeout(() => {
addSeregaComment(positiveComments[Math.floor(Math.random() * positiveComments.length)]);
setPasswordCorrect(true);
setShowMiniGame(false);
loadCleanupGame();
}, 800);
}
else {
// Добавляем обработку неправильного ввода
setTimeout(() => {
addSeregaComment(negativeComments[Math.floor(Math.random() * negativeComments.length)]);
}, 800);
}
}
};
function addSeregaComment(text) {
setSeregaComments(prev => [...prev, { text, id: Date.now() }]);
}
async function loadCleanupGame() {
if (cleanupTimerRef.current) {
clearInterval(cleanupTimerRef.current);
}
try {
const token = localStorage.getItem('token');
if (!token) {
console.error('No token found');
return;
}
if (gameCompleted) return;
const res = await fetch(`/api/cleanup-game/data?level=${currentLevel}`, {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
cleanupTimerRef.current = setInterval(() => {
setTimeLeft(prev => {
if (prev <= 0) {
clearInterval(cleanupTimerRef.current);
handleGameFinish(false);
return 0;
}
return prev - 1;
});
}, 1000);
// Добавьте проверку типа контента
const contentType = res.headers.get('content-type');
if (!contentType || !contentType.includes('application/json')) {
const text = await res.text();
throw new Error(`Ожидался JSON, получено: ${text.substring(0, 100)}...`);
}
const data = await res.json();
if (!data.success) {
throw new Error(data.error || 'Неизвестная ошибка сервера');
}
setCleanupGameData(data.transactions);
if (!res.ok) {
throw new Error(`Server error: ${res.status}`);
}
setCleanupGameData(data.transactions);
setShowCleanupGame(true);
setTimeLeft(180);
setDecryptAttempts(3);
setMarkedTransactions([]);
setSuspiciousFound(0);
setGameResult(null);
setSeregaComments([]);
setSelectedTransaction(null);
return () => clearInterval(timer);
} catch (err) {
console.error('Ошибка загрузки игры:', err);
if (cleanupTimerRef.current) {
clearInterval(cleanupTimerRef.current);
}
}
}
useEffect(() => {
return () => {
if (cleanupTimerRef.current) {
clearInterval(cleanupTimerRef.current);
}
};
}, []);
function addSeregaComment(text) {
setSeregaComments(prev => [...prev, { text, id: Date.now() }]);
}
function handleMarkTransaction(id) {
setMarkedTransactions(prev => {
const transaction = cleanupGameData.find(tx => tx.id === id);
const isCurrentlyMarked = prev.includes(id);
let newMarkedTransactions;
let newSuspiciousFound = suspiciousFound;
if (isCurrentlyMarked) {
newMarkedTransactions = prev.filter(t => t !== id);
if (transaction._isSuspicious) {
newSuspiciousFound = Math.max(0, suspiciousFound - 1);
addSeregaComment("Снята отметка с подозрительной транзакции.");
} else {
addSeregaComment("Снята отметка с транзакции.");
}
} else {
newMarkedTransactions = [...prev, id];
if (transaction._isSuspicious) {
newSuspiciousFound = suspiciousFound + 1;
addSeregaComment("Верно! Это явно что-то нечистое.");
} else {
addSeregaComment("Эээ... Ты уверен? Это выглядит нормально.");
}
}
// Обновляем suspiciousFound синхронно с markedTransactions
setSuspiciousFound(newSuspiciousFound);
// Проверяем завершение игры с новым значением
if (transaction._isSuspicious && !isCurrentlyMarked && newSuspiciousFound >= 3) {
handleGameFinish(true);
}
return newMarkedTransactions;
});
}
function handleDecryptField(transactionId, field) {
if (decryptAttempts <= 0) return;
setDecryptAttempts(prev => prev);
setCleanupGameData(prev => {
return prev.map(tx => {
if (tx.id === transactionId) {
return {
...tx,
[field]: field === 'ip' ? tx._realIp : tx._realDevice
};
}
return tx;
});
});
// Добавляем комментарий от Серёги
addSeregaComment(field === 'ip'
? "Хм... Это VPN или прокси. Подозрительно!"
: "Старое устройство или эмулятор. Нечисто!");
}
function handleAddToArchive(id) {
if (personalArchive.includes(id)) return;
setPersonalArchive(prev => [...prev, id]);
addSeregaComment("Опасно... но может пригодиться.");
}
function handleGameFinish(success) {
if (success) {
const correctMarks = cleanupGameData.filter(tx =>
markedTransactions.includes(tx.id) && tx._isSuspicious
).length;
const score = Math.min(3, correctMarks);
setGameResult('success');
addSeregaComment(`Уровень ${currentLevel} пройден! Найдено ${score} из 3 аномалий.`);
// Отправка результата на сервер
const token = localStorage.getItem('token');
fetch('/api/cleanup-game/finish', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
body: JSON.stringify({
success,
score,
markedTransactions,
personalArchive,
level: currentLevel
})
});
// Если это 5 уровень - завершаем игру
if (currentLevel >= 5) {
setTimeout(() => {
setGameResult('complete');
setShowCleanupGame(false);
}, 3000);
} else {
// Иначе загружаем следующий уровень
setTimeout(() => {
setCurrentLevel(prev => prev + 1);
loadCleanupGame();
}, 3000);
}
} else {
setGameResult('fail');
addSeregaComment('Время вышло! Попробуй еще раз.');
// Добавляем таймер для автоматического перезапуска через 3 секунды
setTimeout(() => {
setGameResult(null);
loadCleanupGame(); // Перезапускаем игру
}, 3000);
}
}
// Добавляем кнопку для запуска игры в интерфейс
const cleanupGameButton = (
<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),
2025-08-26 15:56:28 +03:00
new THREE.MeshBasicMaterial({ visible: false })
);
async function loadGLTF(url) {
return new Promise((resolve, reject) => {
loader.load(url, gltf => resolve(gltf), undefined, err => reject(err));
});
}
2025-08-25 22:39:29 +03:00
async function enterInteriorMode(interiorId) {
console.log('enterInteriorMode вызвана для интерьера:', interiorId);
// Сохраняем текущую позицию игрока
if (playerRef.current) {
savedPositionRef.current.copy(playerRef.current.position);
}
2025-08-25 22:39:29 +03:00
// Загружаем модель интерьера
console.log('Загружаем модель интерьера');
await loadInteriorModel(interiorId);
// Переключаемся на камеру от первого лица
console.log('Переключаемся на камеру от первого лица');
switchToFirstPersonCamera();
// Включаем управление мышью для интерьера
2025-08-26 15:56:28 +03:00
// Курсор оставляем активным (без pointer lock)
document.body.style.cursor = 'default';
2025-08-25 22:39:29 +03:00
// Устанавливаем состояние "в интерьере"
console.log('Устанавливаем setIsInInterior(true)');
setIsInInterior(true);
setSelectedHouse(null);
2025-08-25 22:39:29 +03:00
console.log('isInInterior установлен в true');
2025-08-26 15:56:28 +03:00
// Сброс кликово-путевого движения и визуальных маркеров
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; });
}
2025-08-25 22:39:29 +03:00
// Телепортируем игрока в интерьер (если нужно)
console.log('Вызываем teleportPlayerToInterior для интерьера:', interiorId);
await teleportPlayerToInterior(interiorId);
2025-08-26 15:56:28 +03:00
// Отправляем мгновенное обновление позиции перед уведомлением об интерьере
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 });
2025-08-25 22:39:29 +03:00
console.log('teleportPlayerToInterior завершена');
}
2025-08-25 22:39:29 +03:00
const teleportPlayerToInterior = async (interiorId) => {
console.log('teleportPlayerToInterior вызвана для интерьера:', interiorId);
console.log('playerRef.current:', playerRef.current);
const token = localStorage.getItem('token');
if (!token) {
alert('Пожалуйста, войдите в систему, чтобы войти в здание');
return;
}
try {
2025-08-18 17:27:14 +03:00
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();
2025-08-18 17:27:14 +03:00
console.error(`Ошибка ${res.status} при получении spawn-координат: ${errText}`);
alert(`Не удалось получить координаты интерьера: ${errText}`);
return;
}
2025-08-26 15:56:28 +03:00
const { spawn, exit, exitInt } = await res.json();
2025-08-18 17:27:14 +03:00
if (!spawn) {
alert('Для этого интерьера не заданы координаты входа');
return;
}
2025-08-26 15:56:28 +03:00
// Нормализуем типы в числа (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;
2025-08-18 17:27:14 +03:00
// Телепортируем игрока в интерьер
2025-08-25 22:39:29 +03:00
if (playerRef.current) {
2025-08-26 15:56:28 +03:00
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); }
2025-08-25 22:39:29 +03:00
}
2025-08-26 15:56:28 +03:00
// Запоминаем позицию внутреннего триггера выхода, если пришла
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);
2025-08-18 17:27:14 +03:00
}
2025-08-25 22:39:29 +03:00
console.log('teleportPlayerToInterior завершена успешно');
} catch (e) {
console.error('Failed to enter interior:', e);
}
};
2025-08-25 22:39:29 +03:00
async function loadInteriorModel(interiorId) {
console.log('loadInteriorModel вызвана для интерьера:', interiorId);
const token = localStorage.getItem('token');
try {
const defRes = await fetch(`/api/interiors/${interiorId}/definition`, {
headers: { Authorization: `Bearer ${token}` },
credentials: 'include',
cache: 'no-cache'
});
if (!defRes.ok) {
const errText = await defRes.text();
console.error(`Ошибка ${defRes.status} при загрузке определения интерьера: ${errText}`);
return;
}
const { glb, objects } = await defRes.json();
const baseUrl = window.location.origin;
const glbUrl = baseUrl + glb;
console.log('Loading interior GLB from', glbUrl);
// Проверяем доступность GLB файла
const headResp = await fetch(glbUrl, { method: 'HEAD', cache: 'no-cache' });
if (!headResp.ok) {
console.error(`GLB not reachable: HTTP ${headResp.status}`);
return;
}
const gltf = await loadGLTF(glbUrl);
const scene = sceneRef.current;
// Создаем группу для интерьера
const intGroup = new THREE.Group();
intGroup.name = 'interiorGroup';
intGroup.add(gltf.scene);
2025-08-26 15:56:28 +03:00
// Декуплируем и гарантируем непрозрачность материалов интерьера
gltf.scene.traverse((child) => {
if (child.isMesh && child.material) {
if (Array.isArray(child.material)) {
child.material = child.material.map(mat => {
if (!mat) return mat;
const m = mat.clone();
m.transparent = false;
m.opacity = 1;
m.depthWrite = true;
m.needsUpdate = true;
return m;
});
} else {
child.material = child.material.clone();
child.material.transparent = false;
child.material.opacity = 1;
child.material.depthWrite = true;
child.material.needsUpdate = true;
}
}
});
// Построение коллайдеров интерьера (простые коробки по мешам)
const colliders = [];
gltf.scene.traverse((child) => {
if (child.isMesh && child.geometry) {
colliders.push(child);
}
});
interiorCollidersRef.current = colliders;
2025-08-25 22:39:29 +03:00
// Добавляем объекты интерьера
interiorInteractablesRef.current = []; // сбрасываем реестр интерактива
2025-08-26 15:56:28 +03:00
// Хелпер для определения 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;
};
2025-08-25 22:39:29 +03:00
for (const o of objects) {
if (o.model_url) {
try {
const objGltf = await loadGLTF(baseUrl + o.model_url);
objGltf.scene.position.set(o.x, o.y, o.z);
objGltf.scene.rotation.set(o.rot_x, o.rot_y, o.rot_z);
objGltf.scene.scale.set(o.scale, o.scale, o.scale);
intGroup.add(objGltf.scene);
2025-08-26 15:56:28 +03:00
// Если это 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 (_) {}
}
2025-08-25 22:39:29 +03:00
} 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);
2025-08-26 15:56:28 +03:00
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;
}
}
2025-08-25 22:39:29 +03:00
intGroup.add(mesh);
}
2025-08-26 15:56:28 +03:00
// Если сервер пометил объект как «интерактивный/маркер» — кликабельная зона
2025-08-25 22:39:29 +03:00
if (o.interactable || o.marker) {
const hit = new THREE.Mesh(
new THREE.SphereGeometry(0.6),
2025-08-26 15:56:28 +03:00
new THREE.MeshBasicMaterial({ color: 0x00ff00, transparent: true, opacity: 0.0001, depthWrite: false })
2025-08-25 22:39:29 +03:00
);
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 || 'Интерактив' };
2025-08-26 15:56:28 +03:00
hit.visible = true; // невидим визуально (opacity≈0), но кликабелен
2025-08-25 22:39:29 +03:00
intGroup.add(hit);
interiorInteractablesRef.current.push(hit);
}
2025-08-26 15:56:28 +03:00
// Сохраним позицию внутреннего выхода, если есть
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);
}
2025-08-25 22:39:29 +03:00
}
// Добавляем освещение для интерьера
const light = new THREE.AmbientLight(0xffffff, 1);
intGroup.add(light);
// Добавляем группу в сцену
scene.add(intGroup);
interiorGroupRef.current = intGroup;
console.log('Модель интерьера загружена успешно');
} catch (e) {
console.error('Ошибка загрузки модели интерьера:', e);
}
}
// Кэш для загруженных текстурпаков
const texturePackCache = new Map();
function loadTexturePackForMesh(texturePackUrl, mesh, forceReplace = false) {
console.log('loadTexturePackForMesh вызвана:', { texturePackUrl, mesh });
// Проверяем, есть ли уже загруженный текстурпак в кэше
if (texturePackCache.has(texturePackUrl)) {
console.log('Используем кэшированный текстурпак:', texturePackUrl);
const cachedTextures = texturePackCache.get(texturePackUrl);
applyTexturesToMesh(mesh, cachedTextures, forceReplace, texturePackUrl);
return;
}
console.log('Загружаем текстурпак для меша:', texturePackUrl);
// Загружаем текстурпак асинхронно
const baseUrl = window.location.origin;
const fullUrl = texturePackUrl.startsWith('http') ? texturePackUrl : baseUrl + texturePackUrl;
console.log('Полный URL для загрузки:', fullUrl);
fetch(fullUrl)
.then(response => {
console.log('Ответ сервера для текстурпака:', response.status, response.statusText);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
console.log('Начинаем парсинг JSON...');
return response.json();
})
.then(texturePack => {
console.log('Загруженный текстурпак:', texturePack);
// Кэшируем загруженный текстурпак
texturePackCache.set(texturePackUrl, texturePack);
// Проверяем, что меш все еще существует и валиден
if (mesh && mesh.isMesh && mesh.material) {
// Применяем текстуры к мешу (функция сама проверит типы материалов/массивы)
applyTexturesToMesh(mesh, texturePack, forceReplace, texturePackUrl);
} else {
console.warn('Меш не подходит для применения текстурпака:', {
hasMesh: !!mesh,
isMesh: mesh?.isMesh,
hasMaterial: !!mesh?.material
});
}
})
.catch(error => {
console.error('Ошибка загрузки текстурпака:', texturePackUrl, error);
// В случае ошибки оставляем оригинальные материалы
if (mesh.material) {
mesh.material.needsUpdate = true;
}
});
}
// Предсоздаём материал в стиле MapEditor для citypack.json
const cityPackMaterialCache = new Map(); // url -> material
function getCityPackMaterial(texturePackUrl, texturePack) {
if (cityPackMaterialCache.has(texturePackUrl)) return cityPackMaterialCache.get(texturePackUrl);
const mat = new THREE.MeshStandardMaterial();
if (typeof texturePack.baseColor === 'string') {
const loader = new THREE.TextureLoader();
const tex = loader.load(texturePack.baseColor);
if (THREE.SRGBColorSpace) tex.colorSpace = THREE.SRGBColorSpace;
mat.map = tex;
}
mat.roughness = typeof texturePack.roughness === 'number' ? texturePack.roughness : 0.5;
mat.metalness = typeof texturePack.metalness === 'number' ? texturePack.metalness : 0.1;
cityPackMaterialCache.set(texturePackUrl, mat);
return mat;
}
function applyTexturesToMesh(mesh, texturePack, forceReplace = false, texturePackUrl) {
console.log('applyTexturesToMesh вызвана:', { mesh, texturePack });
if (!mesh || !texturePack) {
console.warn('applyTexturesToMesh: отсутствует меш или текстурпак', {
hasMesh: !!mesh,
hasTexturePack: !!texturePack
});
return;
}
if (!mesh.material) {
console.warn('У меша нет материала');
return;
}
const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
const targetMaterials = materials.filter(m => m && m.isMaterial && (m.type === 'MeshStandardMaterial' || m.type === 'MeshPhysicalMaterial' || m.type === 'MeshPhongMaterial'));
if (targetMaterials.length === 0) {
console.warn('Нет подходящих материалов для применения текстур:', mesh.material);
return;
}
// Особый режим: если это citypack.json — ведём себя как MapEditor: заменяем материал на единый стандартный
if (texturePackUrl === '/packs/citypack.json') {
const mat = getCityPackMaterial(texturePackUrl, texturePack).clone();
if (Array.isArray(mesh.material)) {
mesh.material = mesh.material.map(() => mat.clone());
} else {
mesh.material = mat.clone();
}
mesh.traverse?.((child) => {
if (child.isMesh) {
child.material = Array.isArray(child.material) ? child.material.map(() => mat.clone()) : mat.clone();
}
});
return;
}
// baseColor map — по умолчанию не перетираем; при forceReplace перезаписываем
if (typeof texturePack.baseColor === 'string') {
console.log('Загружаем baseColor текстуру:', texturePack.baseColor);
const textureLoader = new THREE.TextureLoader();
textureLoader.load(texturePack.baseColor, (texture) => {
if (THREE.SRGBColorSpace) {
texture.colorSpace = THREE.SRGBColorSpace;
}
targetMaterials.forEach(mat => {
if (mat.type === 'MeshStandardMaterial' || mat.type === 'MeshPhysicalMaterial') {
if (forceReplace || !mat.map) {
mat.map = texture;
if (mat.color && mat.color.set) mat.color.set(0xffffff);
mat.needsUpdate = true;
}
}
});
}, undefined, (error) => {
console.error('Ошибка загрузки baseColor текстуры:', error);
});
}
// normal map
if (typeof texturePack.normal === 'string') {
console.log('Загружаем normal текстуру:', texturePack.normal);
const textureLoader = new THREE.TextureLoader();
textureLoader.load(texturePack.normal, (texture) => {
targetMaterials.forEach(mat => {
if (mat.type === 'MeshStandardMaterial' || mat.type === 'MeshPhysicalMaterial') {
if (forceReplace || !mat.normalMap) {
mat.normalMap = texture;
mat.needsUpdate = true;
}
}
});
}, undefined, (error) => {
console.error('Ошибка загрузки normal текстуры:', error);
});
}
// roughness map or value
if (typeof texturePack.roughness === 'string') {
const textureLoader = new THREE.TextureLoader();
textureLoader.load(texturePack.roughness, (texture) => {
targetMaterials.forEach(mat => {
if (mat.type === 'MeshStandardMaterial' || mat.type === 'MeshPhysicalMaterial') {
if (forceReplace || !mat.roughnessMap) {
mat.roughnessMap = texture;
mat.needsUpdate = true;
}
}
});
}, undefined, (error) => {
console.error('Ошибка загрузки roughness текстуры:', error);
});
} else if (typeof texturePack.roughness === 'number') {
targetMaterials.forEach(mat => {
if (mat.type === 'MeshStandardMaterial' || mat.type === 'MeshPhysicalMaterial') {
if (forceReplace || mat.roughnessMap == null) {
mat.roughness = texturePack.roughness;
mat.needsUpdate = true;
}
}
});
}
// metalness map or value (key metallic for map, metalness for value)
if (typeof texturePack.metallic === 'string') {
const textureLoader = new THREE.TextureLoader();
textureLoader.load(texturePack.metallic, (texture) => {
targetMaterials.forEach(mat => {
if (mat.type === 'MeshStandardMaterial' || mat.type === 'MeshPhysicalMaterial') {
if (forceReplace || !mat.metalnessMap) {
mat.metalnessMap = texture;
mat.needsUpdate = true;
}
}
});
}, undefined, (error) => {
console.error('Ошибка загрузки metallic текстуры:', error);
});
}
if (typeof texturePack.metalness === 'number') {
targetMaterials.forEach(mat => {
if (mat.type === 'MeshStandardMaterial' || mat.type === 'MeshPhysicalMaterial') {
if (forceReplace || mat.metalnessMap == null) {
mat.metalness = texturePack.metalness;
mat.needsUpdate = true;
}
}
});
}
// ambient occlusion map
if (typeof texturePack.ao === 'string') {
const textureLoader = new THREE.TextureLoader();
textureLoader.load(texturePack.ao, (texture) => {
targetMaterials.forEach(mat => {
if (mat.type === 'MeshStandardMaterial' || mat.type === 'MeshPhysicalMaterial') {
if (forceReplace || !mat.aoMap) {
mat.aoMap = texture;
mat.needsUpdate = true;
}
}
});
}, undefined, (error) => {
console.error('Ошибка загрузки ao текстуры:', error);
});
}
// specular only for Phong
if (typeof texturePack.specular === 'string') {
const textureLoader = new THREE.TextureLoader();
textureLoader.load(texturePack.specular, (texture) => {
targetMaterials.forEach(mat => {
if (mat.type === 'MeshPhongMaterial') {
mat.specularMap = texture;
mat.needsUpdate = true;
}
});
}, undefined, (error) => {
console.error('Ошибка загрузки specular текстуры:', error);
});
}
}
2025-08-18 17:27:14 +03:00
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;
}
2025-08-25 22:39:29 +03:00
const exitInterior = () => {
console.log('exitInterior вызвана');
2025-08-26 15:56:28 +03:00
// Телепортируем на координаты выхода из интерьера, если заданы; иначе возвращаем на сохранённую позицию
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
});
2025-08-18 17:27:14 +03:00
}
2025-08-25 22:39:29 +03:00
// Удаляем маркер выхода, если был
2025-08-18 17:27:14 +03:00
if (window.exitMarkerMesh && sceneRef.current) {
sceneRef.current.remove(window.exitMarkerMesh);
window.exitMarkerMesh = null;
}
2025-08-25 22:39:29 +03:00
// Удаляем группу интерьера, если она есть
if (interiorGroupRef.current && sceneRef.current) {
sceneRef.current.remove(interiorGroupRef.current);
interiorGroupRef.current = null;
console.log('Группа интерьера удалена');
}
// Возвращаем третье лицо/камеру и актуализировать видимость объектов города
switchToThirdPersonCamera?.();
// Безопасный вызов без ReferenceError, даже если функция ещё не определена
if (typeof updateCityObjectVisibility === 'function') {
updateCityObjectVisibility();
}
2025-08-26 15:56:28 +03:00
// Повторно закрепляем телепорт на выход уже после очистки интерьера (на случай перезаписи позы)
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 });
2025-08-25 22:39:29 +03:00
// Возвращаем курсор и отключаем pointer lock
document.body.style.cursor = 'default';
document.exitPointerLock();
setIsInInterior(false);
2025-08-18 17:27:14 +03:00
setCurrentExit(null);
2025-08-26 15:56:28 +03:00
interiorExitPosRef.current = null;
2025-08-18 17:27:14 +03:00
};
2025-08-25 22:39:29 +03:00
2025-08-18 17:27:14 +03:00
// В useEffect для кликов по сцене:
useEffect(() => {
function onDocumentClick(event) {
if (!rendererRef.current || !cameraRef.current) return;
const rect = rendererRef.current.domElement.getBoundingClientRect();
const mouse = new THREE.Vector2(
((event.clientX - rect.left) / rect.width) * 2 - 1,
-((event.clientY - rect.top) / rect.height) * 2 + 1
);
const raycaster = new THREE.Raycaster();
raycaster.setFromCamera(mouse, cameraRef.current);
const intersects = raycaster.intersectObjects(sceneRef.current.children, true);
for (let i = 0; i < intersects.length; i++) {
const obj = intersects[i].object;
if (obj.userData.isExitMarker) {
exitInterior();
break;
}
}
}
window.addEventListener('mousedown', onDocumentClick);
return () => window.removeEventListener('mousedown', onDocumentClick);
}, [currentExit]);
/*const handleAnswerSelect = (answer) => {
if (answer.end) {
setShowDialog(false);
} else if (answer.next !== undefined) {
// Если следующий узел - форма
if (typeof answer.next === 'string' && answer.next.startsWith('form_')) {
const nextNode = currentDialog.dialog.find(node => node.id === answer.next);
if (nextNode && nextNode.type === 'form') {
setCurrentForm(nextNode);
return;
}
}
const nextIndex = currentDialog.dialog.findIndex(node => node.id === answer.next);
if (nextIndex !== -1) {
setDialogIndex(nextIndex);
} else {
console.error('Диалоговый узел не найден:', answer.next);
setShowDialog(false);
}
} else {
setShowDialog(false);
}
};
// Добавьте эту функцию для обработки отправки формы
const handleFormSubmit = (e) => {
e.preventDefault();
if (currentForm.next) {
const nextIndex = currentDialog.dialog.findIndex(node => node.id === currentForm.next);
if (nextIndex !== -1) {
setDialogIndex(nextIndex);
setCurrentForm(null);
// Здесь можно отправить данные формы на сервер
console.log('Отправленные данные:', formData);
// Например: socketRef.current?.emit('dialogFormSubmit', formData);
}
}
};
// Добавьте эту функцию для обработки изменения полей формы
const handleFormChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: value
}));
};*/
// Добавить функцию загрузки прогресса квестов:
async function loadQuestsProgress() {
const token = localStorage.getItem('token');
try {
console.log("Попытка загрузить");
const res = await fetch('/api/quests/progress', {
headers: { Authorization: `Bearer ${token}` }
});
if (res.ok) {
console.log("Попытка не удалась");
const data = await res.json();
setQuestsProgress(data);
} else {
console.error('Ошибка загрузки прогресса квестов');
}
} catch (err) {
console.error('Ошибка сети:', err);
}
}
const closeApp = () => {
setAppsHidden(false);
setActiveApp(null);
};
const bodyStyle = {
margin: 0,
fontFamily: "'Arial', sans-serif",
background: '#f1f1f1',
color: '#333',
minHeight: '100vh'
};
const headerStyle = {
backgroundColor: '#0047ab',
color: 'white',
padding: '1em',
textAlign: 'center'
};
const mainStyle = {
padding: '1em'
};
const listingStyle = {
background: 'white',
borderRadius: '10px',
padding: '1em',
marginBottom: '1em',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)'
};
const imageStyle = {
width: '100%',
borderRadius: '10px'
};
const listingTitleStyle = {
marginTop: '0.5em',
marginBottom: '0.3em'
};
const openIframe = (url) => {
setIframeUrl(url);
setIsIframeOpen(true);
};
const closeIframe = () => {
setIsIframeOpen(false);
setIframeUrl('');
};
async function loadTelegramContacts() {
const token = localStorage.getItem('token');
try {
const res = await fetch('/api/users', {
headers: { Authorization: `Bearer ${token}` }
});
if (res.ok) {
const data = await res.json();
setTelegramContacts(data);
} else {
console.error('Ошибка загрузки контактов Telegram');
}
} catch (err) {
console.error('Ошибка сети:', err);
}
}
// Дополняем состояния
const [newMessage, setNewMessage] = useState("");
const [messageInterval, setMessageInterval] = useState(null);
const [messages, setMessages] = useState([]);
const [userProfile, setUserProfile] = useState(null);
// Функция загрузки сообщений
async function loadMessages(contactId) {
if (!contactId) return;
const token = localStorage.getItem('token');
try {
const res = await fetch(`/api/messages/${contactId}`, {
headers: { Authorization: `Bearer ${token}` }
});
if (res.ok) {
const data = await res.json();
setMessages(data);
console.log('Сообщение загружено');
// Прокручиваем чат вниз
setTimeout(() => {
const chatContainer = document.getElementById('chatContainer');
if (chatContainer) {
chatContainer.scrollTop = chatContainer.scrollHeight;
}
}, 100);
} else {
console.error('Ошибка загрузки сообщений');
}
} catch (err) {
console.error('Ошибка сети:', err);
}
}
// Функция отправки сообщения
async function sendMessage() {
if (!activeChat || !newMessage.trim()) return;
const token = localStorage.getItem('token');
try {
const res = await fetch('/api/messages/send', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
body: JSON.stringify({
receiverId: activeChat.id,
message: newMessage
})
});
if (res.ok) {
setNewMessage("");
console.log("Сообщение ушло");
// После отправки сразу обновляем сообщения
loadMessages(activeChat.id);
} else {
console.error('Ошибка отправки сообщения');
}
} catch (err) {
console.error('Ошибка сети:', err);
}
}
// Запускаем интервал при открытии чата
useEffect(() => {
if (activeChat) {
// Первоначальная загрузка сообщений
loadMessages(activeChat.id);
// Запускаем интервал для проверки новых сообщений
const interval = setInterval(() => {
loadMessages(activeChat.id);
}, 1000); // Проверка каждую секунду
setMessageInterval(interval);
// Очищаем интервал при закрытии чата
return () => {
if (interval) clearInterval(interval);
};
} else {
// Останавливаем интервал, если чат закрыт
if (messageInterval) {
clearInterval(messageInterval);
setMessageInterval(null);
}
setMessages([]);
}
}, [activeChat]);
// Очищаем интервал при размонтировании компонента
useEffect(() => {
return () => {
if (messageInterval) {
clearInterval(messageInterval);
}
};
}, []);
// Загружаем профиль при монтировании
useEffect(() => {
const profile = JSON.parse(sessionStorage.getItem('user_profile') || {});
setUserProfile(profile);
}, []);
//Телефон конец
async function viewStats() {
if (!selectedPlayer) return;
const token = localStorage.getItem('token');
const res = await fetch(`/api/players/${selectedPlayer.socketId}`, {
headers: { Authorization: `Bearer ${token}` }
});
if (!res.ok) {
console.error('Ошибка при загрузке статистики');
return;
}
const data = await res.json();
setPlayerStats(data);
}
async function toggleMicrophone() {
try {
if (!micEnabled) {
localStream.current = await navigator.mediaDevices.getUserMedia({ audio: true });
setMicEnabled(true);
socketRef.current?.emit('voiceChatToggle', { enabled: true });
const track = localStream.current.getAudioTracks()[0];
Object.values(voiceConnections.current).forEach(conn => {
if (conn.audioSender && track) {
conn.audioSender.replaceTrack(track);
}
});
} else {
if (localStream.current) {
localStream.current.getTracks().forEach(track => track.stop());
}
Object.values(voiceConnections.current).forEach(conn => {
if (conn.audioSender) {
conn.audioSender.replaceTrack(null);
}
});
localStream.current = null;
setMicEnabled(false);
socketRef.current?.emit('voiceChatToggle', { enabled: false });
}
} catch (err) {
console.error('Ошибка доступа к микрофону:', err);
}
}
async function onObjectClick(mesh) {
const objectId = mesh.userData.id; // <-- USER DATA ID из city_objects
const token = localStorage.getItem('token');
try {
const resp = await fetch(
`/api/city_objects/${objectId}/interior`, // <-- обязательно "/interior"
{
headers: { Authorization: `Bearer ${token}` },
credentials: 'include',
cache: 'no-cache'
}
);
if (!resp.ok) {
console.warn(`Для объекта ${objectId} не задан interior_id (status ${resp.status})`);
return;
}
const { interiorId } = await resp.json();
if (!interiorId) return;
console.log(`Переходим в интерьер ${interiorId} из объекта ${objectId}`);
movePlayerToInterior(interiorId);
} catch (err) {
console.error(`Ошибка при запросе interior_id для объекта ${objectId}:`, err);
}
}
async function openOrganizationMenu(orgId) {
const token = localStorage.getItem('token');
try {
const orgRes = await fetch(`/api/organizations/${orgId}`, {
headers: { Authorization: `Bearer ${token}` }
});
let name = 'Организация';
if (orgRes.ok) {
const org = await orgRes.json();
name = org.name;
}
const setRes = await fetch(`/api/organizations/${orgId}/settings`, {
headers: { Authorization: `Bearer ${token}` }
});
const settings = setRes.ok ? await setRes.json() : { menu: [] };
// сервер уже отдаёт menu как массив
const menuArray = Array.isArray(settings.menu) ? settings.menu : [];
setOrgMenu({ id: orgId, name, menu: menuArray });
setSelectedHouse(null);
} catch (e) {
console.error('Не удалось загрузить меню организации', orgId, e);
alert('Ошибка загрузки меню организации');
}
}
function openOrganizationPanel(orgId) {
setOrgPanelId(orgId);
setOrgMenu(null);
setSelectedHouse(null);
}
async function movePlayerToInterior(interiorId) {
2025-08-25 22:39:29 +03:00
await enterInteriorMode(interiorId);
}
function switchToFirstPersonCamera() {
2025-08-25 22:39:29 +03:00
console.log('switchToFirstPersonCamera вызвана');
console.log('isInInteriorRef.current:', isInInteriorRef.current);
if (fpCamRef.current) {
cameraRef.current = fpCamRef.current;
2025-08-25 22:39:29 +03:00
console.log('Камера переключена на fpCamRef');
}
if (playerRef.current) {
2025-08-26 15:56:28 +03:00
// Скрываем полностью собственную модель в режиме FPV
playerRef.current.visible = false;
2025-08-26 15:56:28 +03:00
// На всякий случай также скрываем голову/шею (если модель будет вновь показана без выхода из режима)
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;
2025-08-25 22:39:29 +03:00
// Настраиваем камеру от первого лица для интерьера
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
);
2025-08-26 15:56:28 +03:00
// Не большой сдвиг камеры вперёд, чтобы не упираться в скрытую голову
const forward = new THREE.Vector3(0, 0, -0.08).applyEuler(new THREE.Euler(0, playerRef.current.rotation.y, 0));
fpCamRef.current.position.add(forward);
2025-08-25 22:39:29 +03:00
// Направляем камеру в том же направлении, что и игрок
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() {
2025-08-25 22:39:29 +03:00
console.log('switchToThirdPersonCamera вызвана');
if (orthoCamRef.current) {
cameraRef.current = orthoCamRef.current;
2025-08-25 22:39:29 +03:00
console.log('Камера переключена на orthoCamRef');
}
if (playerRef.current) {
playerRef.current.visible = true;
2025-08-26 15:56:28 +03:00
// Вернуть видимость скрытых для FPV узлов
if (Array.isArray(fpHiddenNodesRef.current)) {
fpHiddenNodesRef.current.forEach(n => { n.visible = true; });
fpHiddenNodesRef.current = [];
}
2025-08-25 22:39:29 +03:00
console.log('Игрок показан');
}
fpPitchRef.current = 0;
}
function startMove(dir) {
moveInputRef.current[dir] = true;
}
function stopMove(dir) {
moveInputRef.current[dir] = false;
}
2025-08-18 17:27:14 +03:00
// ─────────────────────────────────────────────────────
// КЛИКИ ВНУТРИ ИНТЕРЬЕРА (интерактивные маркеры/NPC)
// ─────────────────────────────────────────────────────
useEffect(() => {
const onClick = (e) => {
2025-08-26 15:56:28 +03:00
console.log('[INTERIOR CLICK] handler start; isInInterior:', isInInteriorRef.current);
2025-08-18 17:27:14 +03:00
if (!isInInteriorRef.current) return;
const mount = mountRef.current;
if (!mount || !cameraRef.current) return;
// координаты мыши в NDC
2025-08-26 15:56:28 +03:00
// Пытаемся получить координаты из элемента рендера (FP вид)
const canvas = rendererRef.current && rendererRef.current.domElement;
const rect = (canvas || mount).getBoundingClientRect();
2025-08-18 17:27:14 +03:00
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);
2025-08-26 15:56:28 +03:00
// Ищем пересечения по интерактивам (включая NPC)
2025-08-18 17:27:14 +03:00
const objects = interiorInteractablesRef.current.filter(obj => obj?.isObject3D);
2025-08-26 15:56:28 +03:00
// Добавим в список интерактивов саму группу интерьера, чтобы 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) {
2025-08-18 17:27:14 +03:00
const top = hits[0].object;
2025-08-26 15:56:28 +03:00
// поднимаем до узла, где лежит 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);
2025-08-18 17:27:14 +03:00
if (payload.type === 'marker') {
console.log('Нажат маркер:', payload);
} else if (payload.type === 'npc') {
console.log('Нажат NPC:', payload);
2025-08-26 15:56:28 +03:00
try { if (payload.id) { loadDialog(payload.id); } } catch (_) {}
2025-08-18 17:27:14 +03:00
} else {
console.log('Интерактив:', payload);
}
2025-08-26 15:56:28 +03:00
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);
}
2025-08-18 17:27:14 +03:00
};
2025-08-26 15:56:28 +03:00
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); };
2025-08-18 17:27:14 +03:00
}, []);
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);
}
2025-08-25 22:39:29 +03:00
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;
}
2025-08-18 17:27:14 +03:00
// ─────────────────────────────────────────────
// Красивый загрузочный оверлей + LoadingManager
// ─────────────────────────────────────────────
let overlayEl = null, barEl = null, textEl = null;
function createLoadingOverlay() {
if (overlayEl) return;
overlayEl = document.createElement('div');
Object.assign(overlayEl.style, {
position: 'fixed', inset: '0', zIndex: 2000,
display: 'flex', flexDirection: 'column',
alignItems: 'center', justifyContent: 'center',
background: 'linear-gradient(135deg,#0f172a,#1e293b)',
color: '#fff', fontFamily: 'system-ui, Arial, sans-serif'
});
textEl = document.createElement('div');
Object.assign(textEl.style, {
fontSize: '24px', fontWeight: 700, opacity: 0.9, marginBottom: '16px'
});
textEl.textContent = 'Загрузка ресурсов...';
overlayEl.appendChild(textEl);
const barWrap = document.createElement('div');
Object.assign(barWrap.style, {
width: '320px', height: '10px',
background: 'rgba(255,255,255,0.15)',
borderRadius: '999px', overflow: 'hidden',
boxShadow: '0 6px 20px rgba(0,0,0,0.35)'
});
barEl = document.createElement('div');
Object.assign(barEl.style, {
width: '0%', height: '100%',
transition: 'width .15s ease',
background: 'linear-gradient(90deg,#22d3ee,#38bdf8,#60a5fa)'
});
barWrap.appendChild(barEl);
overlayEl.appendChild(barWrap);
const pct = document.createElement('div');
Object.assign(pct.style, { marginTop: '12px', fontSize: '14px', opacity: 0.8 });
pct.id = 'loadingPct';
pct.textContent = '0%';
overlayEl.appendChild(pct);
document.body.appendChild(overlayEl);
}
function updateLoadingOverlay(percent, text) {
if (!overlayEl) return;
const p = Math.max(0, Math.min(100, Math.round(percent || 0)));
if (barEl) barEl.style.width = p + '%';
const pct = overlayEl.querySelector('#loadingPct');
if (pct) pct.textContent = p + '%';
if (text && textEl) textEl.textContent = text;
}
function removeLoadingOverlay() {
if (!overlayEl) return;
overlayEl.style.transition = 'opacity .2s ease';
overlayEl.style.opacity = '0';
setTimeout(() => {
overlayEl && overlayEl.remove();
overlayEl = barEl = textEl = null;
}, 220);
}
// Общий менеджер загрузки (для GLTF/Texture и т.п.)
const loadingManager = new THREE.LoadingManager();
loadingManagerRef.current = loadingManager;
loadingManager.onStart = (_url, loaded, total) => {
createLoadingOverlay();
updateLoadingOverlay(total ? (loaded / total) * 100 : 5, 'Загрузка ресурсов...');
};
loadingManager.onProgress = (_url, loaded, total) => {
updateLoadingOverlay(total ? (loaded / total) * 100 : 50);
};
loadingManager.onLoad = () => {
updateLoadingOverlay(100, 'Инициализация сцены...');
setTimeout(removeLoadingOverlay, 150);
};
console.log(' useEffect начало');
const baseOffset = new THREE.Vector3(-200, 150, -200);
const planarDist = Math.hypot(baseOffset.x, baseOffset.z);
const radius = Math.hypot(planarDist, baseOffset.y);
const baseAzimuth = Math.atan2(baseOffset.z, baseOffset.x);
const basePolar = Math.atan2(baseOffset.y, planarDist);
let cameraPitchOffset = 0;
const maxPitch = THREE.MathUtils.degToRad(10);
let zoom = 10;
const minZoom = zoom * 0.1;
const maxZoom = zoom * 3.5;
let orthoCamera, fpCamera;
let player, mixer;
let idleAction, walkAction, currentAction;
let remotePlayers = remotePlayersRef.current;
let obstacles = [];
let destination = null;
let blockedTime = 0;
const moveSpeed = 2.5;
const WALK_ANIM_SPEED_MPS = 2;
2025-08-25 22:39:29 +03:00
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');
2025-08-25 22:39:29 +03:00
// Подключаемся к локальному серверу
const serverUrl = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'
? 'http://localhost:4000'
: window.location.origin;
socketRef.current = io(serverUrl, {
transports: ['websocket','polling'],
auth: { token },
timeout: 20000 // Увеличиваем timeout до 20 секунд
});
const socket = socketRef.current;
console.log('socket инстанс:', socket);
2025-08-25 22:39:29 +03:00
console.log('Подключение к серверу:', serverUrl);
socket.on('connect', () => {
console.log('✔ Socket connected, id=', socket.id);
console.log('Подключение успешно установлено');
});
socket.on('connect_error', err => {
console.error('Socket connect_error:', err);
console.error('Ошибка подключения к серверу:', serverUrl);
console.error('Проверьте, что сервер запущен на порту 4000');
});
socket.on('disconnect', reason => {
console.warn('Socket disconnected:', reason);
console.warn('Соединение разорвано, причина:', reason);
});
const profile = JSON.parse(sessionStorage.getItem('user_profile') || '{}');
if (profile?.id) {
socket.emit('economy:getBalance', { userId: profile.id });
}
const balanceInterval = setInterval(() => {
const p = JSON.parse(sessionStorage.getItem('user_profile') || '{}');
if (p?.id) socket.emit('economy:getBalance', { userId: p.id });
}, 3000);
socket.on('economy:balanceChanged', ({ userId, newBalance }) => {
if (userId === profile.id) {
setBalance(newBalance);
const upd = { ...(profile || {}), balance: newBalance };
sessionStorage.setItem('user_profile', JSON.stringify(upd));
}
});
socket.emit('economy:getInventory', { userId: profile.id });
socket.on('economy:inventory', setInventory);
socket.on('gameTime:update', ({ time }) => setGameTime(time));
2025-08-18 17:27:14 +03:00
// Лоадеры, учитывающиеся в прогрессе через loadingManagerRef
const gltfLoader = new GLTFLoader(loadingManagerRef.current || undefined);
const animLoader = new GLTFLoader(loadingManagerRef.current || undefined);
async function loadPlayerModel(avatarUrl) {
return new Promise((resolve, reject) => {
2025-08-25 22:39:29 +03:00
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);
}
);
});
}
2025-08-26 15:56:28 +03:00
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;
2025-08-25 22:39:29 +03:00
// Проверяем и исправляем материалы модели
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);
2025-08-26 15:56:28 +03:00
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
};
2025-08-25 22:39:29 +03:00
// Синхронизируем анимацию ходьбы с скоростью перемещения
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);
2025-08-26 15:56:28 +03:00
// Получаем профиль (только для ФИО/аватара)
const myProfile = JSON.parse(sessionStorage.getItem('user_profile') || '{}');
2025-08-26 15:56:28 +03:00
// Добавляем/обновляем игроков из пришедшего списка
Object.keys(players).forEach(id => {
if (id === socket.id) return;
2025-08-26 15:56:28 +03:00
const { x, y, z, avatarURL, gender, firstName, lastName } = players[id];
if (!remotePlayers[id]) {
addOtherPlayer(id, x, z, avatarURL, gender, firstName, lastName, y);
}
});
2025-08-26 15:56:28 +03:00
// Удаляем тех, кого нет в актуальном списке (после входа/выхода из интерьера и т.п.)
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);
}
});
2025-08-26 15:56:28 +03:00
// После получения списка игроков, отправляем 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;
2025-08-26 15:56:28 +03:00
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) {
2025-08-25 22:39:29 +03:00
// Более плавный переход к анимации ходьбы
const fadeTime = 0.3;
remote.currentAction.fadeOut(fadeTime);
remote.walkAction.reset().fadeIn(fadeTime).play();
remote.currentAction = remote.walkAction;
2025-08-25 22:39:29 +03:00
// Синхронизируем время анимации
remote.walkAction.time = 0;
}
clearTimeout(remote._idleTimeout);
remote._idleTimeout = setTimeout(() => {
if (remote.currentAction !== remote.idleAction) {
2025-08-25 22:39:29 +03:00
// Более плавный переход к 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;
2025-08-25 22:39:29 +03:00
// Проверяем, не существует ли уже игрок с таким ID
if (remotePlayers[playerId]) {
console.log(`Игрок ${playerId} уже существует, обновляем позицию`);
// Обновляем позицию существующего игрока
remotePlayers[playerId].model.position.set(x, 0, z);
return;
}
2025-08-26 15:56:28 +03:00
// Если мы сейчас внутри интерьера, показывать новых игроков следует только когда они тоже будут в нашем списке 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);
});
2025-08-18 17:27:14 +03:00
2025-08-25 22:39:29 +03:00
// Throttling для колеса мыши
let wheelTimeout = null;
function onMouseWheel(e) {
e.preventDefault();
2025-08-25 22:39:29 +03:00
// Throttling - обрабатываем только каждые 16ms (60fps)
if (wheelTimeout) return;
wheelTimeout = setTimeout(() => {
wheelTimeout = null;
}, 16);
const delta = -e.deltaY * 0.001;
if (e.ctrlKey) {
cameraPitchOffset = THREE.MathUtils.clamp(
cameraPitchOffset + delta,
-maxPitch,
maxPitch
);
} else {
if (cameraRef.current === orthoCamRef.current) {
zoom = THREE.MathUtils.clamp(zoom * (1 + delta), minZoom, maxZoom);
orthoCamRef.current.zoom = zoom;
orthoCamRef.current.updateProjectionMatrix();
}
}
}
2025-08-25 22:39:29 +03:00
// Throttling для движения мыши
let mouseMoveTimeout = null;
function onMouseLookMove(e) {
if (!isInInteriorRef.current || cameraRef.current !== fpCamRef.current || !playerRef.current) return;
2025-08-26 15:56:28 +03:00
if (altHeldRef.current) return; // при зажатом Alt не вращаем камеру
2025-08-25 22:39:29 +03:00
// 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;
2025-08-25 22:39:29 +03:00
// Уменьшаем чувствительность для более плавного движения
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 вызван');
2025-08-25 22:39:29 +03:00
// Проверяем, что 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;
2025-08-25 22:39:29 +03:00
// Проверяем, что 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;
2025-08-25 22:39:29 +03:00
// Проверяем поддержку 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);
2025-08-25 22:39:29 +03:00
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;
2025-08-25 22:39:29 +03:00
if (mountRef.current) {
mountRef.current.appendChild(renderer.domElement);
} else {
console.error('mountRef.current не найден');
return;
}
2025-08-25 22:39:29 +03:00
if (renderer && renderer.domElement) {
renderer.domElement.addEventListener('wheel', onMouseWheel, { passive: false });
renderer.domElement.addEventListener('mousemove', onMouseLookMove);
} else {
console.error('renderer или renderer.domElement не найден');
return;
}
2025-08-26 15:56:28 +03:00
// Pointer lock больше не используется в интерьере — курсор всегда активен
2025-08-25 22:39:29 +03:00
// Проверяем, что 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-буфер
});
2025-08-25 22:39:29 +03:00
// Проверяем, что 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;
2025-08-25 22:39:29 +03:00
// Проверяем, что 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);
2025-08-25 22:39:29 +03:00
// Проверяем, что 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);
2025-08-25 22:39:29 +03:00
// Проверяем, что 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);
2025-08-25 22:39:29 +03:00
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,
2025-08-25 22:39:29 +03:00
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] },
2025-08-26 15:56:28 +03:00
{ 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;
2025-08-25 22:39:29 +03:00
// Проверяем и исправляем материалы модели
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); // Правильное добавление в массив
2025-08-26 15:56:28 +03:00
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 {
2025-08-25 22:39:29 +03:00
// Проверяем, что 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);
2025-08-25 22:39:29 +03:00
player.position.set(0, 0, 0);
const profile = JSON.parse(sessionStorage.getItem('user_profile') || '{}');
const myName = `${profile.firstName || ''} ${profile.lastName || ''}`.trim();
2025-08-25 22:39:29 +03:00
// Устанавливаем имя игрока в 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'
}`;
2025-08-25 22:39:29 +03:00
console.log('Загружаем анимации:', { idlePath, walkPath });
const [idleGltf, walkGltf] = await Promise.all([
2025-08-25 22:39:29 +03:00
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);
2025-08-25 22:39:29 +03:00
// Проверяем, что анимации загружены
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();
2025-08-26 15:56:28 +03:00
// Не отправляем здесь newPlayer — делаем это централизованно после currentPlayers
} catch (err) {
console.error("Ошибка загрузки модели игрока:", err);
2025-08-25 22:39:29 +03:00
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) {
2025-08-25 22:39:29 +03:00
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;
2025-08-25 22:39:29 +03:00
// Проверяем и исправляем материалы модели
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
};
2025-08-25 22:39:29 +03:00
// Применяем масштаб из БД, если есть
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);
2025-08-25 22:39:29 +03:00
console.log('Обрабатываем материалы для объекта:', obj.name);
// Обрабатываем материалы в зависимости от поля textures
model.traverse(child => {
if (child.isMesh) {
2025-08-25 22:39:29 +03:00
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;
}
}
}
}
});
2025-08-25 22:39:29 +03:00
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();
}
2025-08-25 22:39:29 +03:00
// Кэш для оптимизации вычислений расстояний
let lastPlayerPosition = null;
let lastVisibilityUpdate = 0;
function updateCityObjectVisibility() {
if (!player) return;
2025-08-25 22:39:29 +03:00
const p = player.position;
2025-08-25 22:39:29 +03:00
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 => {
2025-08-25 22:39:29 +03:00
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);
}
});
2025-08-25 22:39:29 +03:00
interiorsDataRef.current.forEach(int => {
2025-08-25 22:39:29 +03:00
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) {
const mesh = new THREE.Mesh(
new THREE.BoxGeometry(2, 2, 2),
new THREE.MeshStandardMaterial({ color: 0x00ffcc })
);
mesh.position.set(int.pos_x, int.pos_y, int.pos_z);
mesh.userData.interiorId = int.id;
scene.add(mesh);
cityMeshesRef.current.push(mesh);
loadedInteriorMeshesRef.current[int.id] = mesh;
}
function unloadInteriorPlaceholder(id) {
const mesh = loadedInteriorMeshesRef.current[id];
if (!mesh) return;
scene.remove(mesh);
cityMeshesRef.current = cityMeshesRef.current.filter(m => m !== mesh);
delete loadedInteriorMeshesRef.current[id];
}
// В функции onDocumentMouseDown заменяем существующий код на:
async function onDocumentMouseDown(event) {
if (!player) return;
if (isInInteriorRef.current) return; // disable clicks when inside
event.preventDefault();
const rect = renderer.domElement.getBoundingClientRect();
const mouse = new THREE.Vector2(
((event.clientX - rect.left) / rect.width) * 2 - 1,
-((event.clientY - rect.top) / rect.height) * 2 + 1
);
const raycaster = new THREE.Raycaster();
raycaster.setFromCamera(mouse, cameraRef.current);
// NPC
const npcHit = raycaster.intersectObjects(npcMeshes, true);
if (npcHit.length) {
let root = npcHit[0].object;
while (root.parent && !root.userData.isNpc) root = root.parent;
if (root.userData.npcId) {
if (root.userData.npcId === 'Computer') {
setShowMiniGame(true);
setPasswordCorrect(false);
setAudioUrl("/audio/firs.ogg");
addSeregaComment("Ну чё, хакер, разберёшься?");
} else {
loadDialog(root.userData.npcId);
}
return;
}
}
// Здания/объекты
const houseHit = raycaster.intersectObjects(obstacles.map(o => o.mesh).filter(Boolean), true);
if (houseHit.length) {
let obj = houseHit[0].object;
while (obj && !obj.userData.id && !obj.userData.interiorId) obj = obj.parent;
if (obj && obj.userData.id) {
setSelectedHouse(obj.userData);
return;
}
if (obj && obj.userData.interiorId) {
2025-08-25 22:39:29 +03:00
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;
2025-08-26 15:56:28 +03:00
if (event.key === 'Alt') altHeldRef.current = true;
2025-08-25 22:39:29 +03:00
console.log('onKeyDown:', event.key, 'isInInteriorRef.current:', isInInteriorRef.current);
2025-08-26 15:56:28 +03:00
// ESC больше не выходит из интерьера
2025-08-25 22:39:29 +03:00
if (isInInteriorRef.current) {
2025-08-25 22:39:29 +03:00
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');
2025-08-25 22:39:29 +03:00
if (k === 'q') startMove('strafeLeft');
if (k === 'e') startMove('strafeRight');
}
2025-08-25 22:39:29 +03:00
if (event.key.toLowerCase() === 'i') {
const prof = JSON.parse(sessionStorage.getItem('user_profile') || '{}');
socket.emit('economy:getInventory', { userId: prof.id });
setShowInventory(v => !v);
}
2025-08-25 22:39:29 +03:00
// Сбрасываем назначение только если не в интерьере
if (!isInInteriorRef.current) {
destination = null;
destinationMarker.visible = false;
}
}
function onKeyUp(event) {
keys[event.key] = false;
2025-08-26 15:56:28 +03:00
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');
2025-08-25 22:39:29 +03:00
if (k === 'q') stopMove('strafeLeft');
if (k === 'e') stopMove('strafeRight');
}
}
function createPlayerLabel(text) {
const canvas = document.createElement('canvas');
2025-08-25 22:39:29 +03:00
canvas.width = 512; // Увеличиваем размер canvas
canvas.height = 128;
const ctx = canvas.getContext('2d');
2025-08-25 22:39:29 +03:00
// Добавляем фон для лучшей видимости
ctx.fillStyle = 'rgba(0, 0, 0, 0.7)';
ctx.fillRect(0, 0, canvas.width, canvas.height);
const fontSize = 32; // Увеличиваем размер шрифта
ctx.fillStyle = 'white';
2025-08-25 22:39:29 +03:00
ctx.font = `bold ${fontSize}px Arial`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
2025-08-25 22:39:29 +03:00
// Добавляем обводку для лучшей видимости
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;
2025-08-25 22:39:29 +03:00
const spriteMaterial = new THREE.SpriteMaterial({
map: texture,
transparent: true,
depthTest: false, // Рисуем поверх всего
depthWrite: false
});
const sprite = new THREE.Sprite(spriteMaterial);
2025-08-25 22:39:29 +03:00
sprite.scale.set(1, 0.25, 1); // Увеличиваем размер спрайта
// ↓↓↓ добавь это ↓↓↓
sprite.raycast = () => {};
sprite.userData.isUiSprite = true;
return sprite;
}
function switchAnimation(newAction) {
if (!newAction || !currentAction || newAction === currentAction) return;
2025-08-25 22:39:29 +03:00
// Увеличиваем время перехода для более плавной анимации
const fadeTime = 0.3;
// Плавно убираем текущую анимацию
currentAction.fadeOut(fadeTime);
// Плавно включаем новую анимацию
newAction.reset().fadeIn(fadeTime).play();
// Обновляем текущую анимацию
currentAction = newAction;
2025-08-25 22:39:29 +03:00
// Синхронизируем время для избежания подлагов
if (newAction === walkAction) {
newAction.time = 0;
}
}
function canMove(newPosition) {
const halfSize = 1;
const playerMin = new THREE.Vector2(newPosition.x - halfSize, newPosition.z - halfSize);
const playerMax = new THREE.Vector2(newPosition.x + halfSize, newPosition.z + halfSize);
for (let i = 0; i < obstacles.length; i++) {
obstacles[i].mesh.updateMatrixWorld();
const box = new THREE.Box3().setFromObject(obstacles[i].mesh);
const obstacleMin = new THREE.Vector2(box.min.x, box.min.z);
const obstacleMax = new THREE.Vector2(box.max.x, box.max.z);
if ((playerMin.x <= obstacleMax.x && playerMax.x >= obstacleMin.x) &&
(playerMin.y <= obstacleMax.y && playerMax.y >= obstacleMin.y)) {
return false;
}
}
return true;
}
function updateDestinationMovement(delta) {
if (!player || currentPath.length === 0 || pathIndex >= currentPath.length) return;
const target = currentPath[pathIndex];
const dir = new THREE.Vector3().subVectors(target, player.position);
dir.y = 0;
const dist = dir.length();
const stepDistance = moveSpeed * delta;
if (dist < stepDistance) {
player.position.copy(target);
pathIndex++;
blockedTime = 0;
if (pathIndex >= currentPath.length) {
currentPath = [];
destination = null;
if (currentAction !== idleAction) {
currentAction.fadeOut(0.2);
idleAction.reset().fadeIn(0.2).play();
currentAction = idleAction;
}
}
return;
}
dir.normalize();
const step = dir.clone().multiplyScalar(stepDistance);
// Кандидаты перемещения: прямо, слайд по X, слайд по Z
const tryMoves = [
player.position.clone().add(step),
player.position.clone().add(new THREE.Vector3(step.x, 0, 0)),
player.position.clone().add(new THREE.Vector3(0, 0, step.z))
];
// Помощник: «привязка» к верхней поверхности
const stickToTopSurface = (pos) => {
const downRay = new THREE.Raycaster(
new THREE.Vector3(pos.x, 100, pos.z),
new THREE.Vector3(0, -1, 0),
0,
300
);
downRay.camera = cameraRef.current; // важное дополнение для спрайтов
// фильтруем null/undefined
const walkables = [groundPlane, ...(cityMeshesRef.current || [])].filter(Boolean);
const hits = downRay
.intersectObjects(walkables, true)
.filter(h => !h.object.isSprite && h.face && h.face.normal && h.face.normal.y > 0.6);
if (hits.length) {
pos.y = hits[0].point.y + 0.02; // лёгкий "антизалип"
}
};
let moved = false;
for (const candidate of tryMoves) {
if (canMove(candidate)) {
stickToTopSurface(candidate);
player.position.copy(candidate);
moved = true;
blockedTime = 0;
break;
}
}
if (moved) {
const angle = Math.atan2(dir.x, dir.z);
const targetQuat = new THREE.Quaternion().setFromEuler(new THREE.Euler(0, angle, 0));
player.quaternion.slerp(targetQuat, Math.min(1, 10 * delta));
2025-08-26 15:56:28 +03:00
socketRef.current?.emit('playerMovement', { x: player.position.x, y: player.position.y, z: player.position.z });
if (currentAction !== walkAction) {
currentAction.fadeOut(0.2);
walkAction.reset().fadeIn(0.2).play();
currentAction = walkAction;
}
} else {
// полностью заблокированы
blockedTime += delta;
// Пробуем перепроложить путь к текущей цели,
// либо через 0.35с сдаёмся и ставим idle
if (destination && blockedTime > 0.1) {
const newPath = computePath(player.position, destination);
if (newPath.length > 0) {
currentPath = newPath;
pathIndex = 0;
// оставляем walk
if (currentAction !== walkAction) {
currentAction.fadeOut(0.2);
walkAction.reset().fadeIn(0.2).play();
currentAction = walkAction;
}
return;
}
}
if (blockedTime > 0.35) {
currentPath = [];
destination = null;
if (currentAction !== idleAction) {
currentAction.fadeOut(0.2);
idleAction.reset().fadeIn(0.2).play();
currentAction = idleAction;
}
}
}
}
function updateTransparency() {
if (!player) return;
2025-08-25 22:39:29 +03:00
// Если мы в интерьере, не применяем прозрачность
if (isInInteriorRef.current) return;
obstacles.forEach(obstacle => {
obstacle.mesh.traverse(child => {
if (child.isMesh && child.material) {
2025-08-25 22:39:29 +03:00
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) {
2025-08-25 22:39:29 +03:00
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) {
2025-08-25 22:39:29 +03:00
if (Array.isArray(child.material)) {
child.material.forEach(mat => {
if (!mat) return;
mat.transparent = true;
mat.opacity = 0.3;
mat.depthWrite = false;
mat.needsUpdate = true;
});
} else {
child.material.transparent = true;
child.material.opacity = 0.3;
child.material.depthWrite = false;
child.material.needsUpdate = true;
}
}
});
}
}
});
}
function updateFirstPersonMovement(delta) {
if (!isInInteriorRef.current || cameraRef.current !== fpCamRef.current || !player) return;
2025-08-25 22:39:29 +03:00
const move = moveInputRef.current;
2025-08-25 22:39:29 +03:00
const speed = 2; // Уменьшаем скорость для более плавного движения в интерьере
const rotSpeed = Math.PI * 0.5; // Уменьшаем скорость поворота
2025-08-26 15:56:28 +03:00
// Проверка триггера выхода по внутренней точке
if (interiorExitPosRef.current && player.position.distanceTo(interiorExitPosRef.current) < 0.7) {
exitInterior();
return;
}
2025-08-25 22:39:29 +03:00
// Поворот влево-вправо (A/D или стрелки)
if (move.left) player.rotation.y += rotSpeed * delta;
if (move.right) player.rotation.y -= rotSpeed * delta;
2025-08-26 15:56:28 +03:00
// Камера следует за вращением тела
const headHeight = 1.6;
const camBase = new THREE.Vector3(player.position.x, player.position.y + headHeight, player.position.z);
const camForward = new THREE.Vector3(0, 0, -0.08).applyEuler(new THREE.Euler(0, player.rotation.y, 0));
fpCamRef.current.position.copy(camBase.add(camForward));
const lookForward = new THREE.Vector3(0, 0, -1).applyEuler(new THREE.Euler(0, player.rotation.y, 0));
fpCamRef.current.lookAt(fpCamRef.current.position.clone().add(lookForward));
// Движение с проверкой коллизий
const tryMove = (dirVec) => {
const candidate = player.position.clone().addScaledVector(dirVec, speed * delta);
// Обновляем AABB игрока (простая капсула не используется, только коробка)
const half = 0.3; // половина ширины
const height = 1.8;
const playerBox = new THREE.Box3(
new THREE.Vector3(candidate.x - half, candidate.y, candidate.z - half),
new THREE.Vector3(candidate.x + half, candidate.y + height, candidate.z + half)
);
const hits = (interiorCollidersRef.current || []).some((mesh) => {
const box = new THREE.Box3().setFromObject(mesh);
return box.intersectsBox(playerBox);
});
if (!hits) {
player.position.copy(candidate);
}
};
const forward = new THREE.Vector3(0, 0, -1).applyQuaternion(player.quaternion);
2025-08-25 22:39:29 +03:00
const right = new THREE.Vector3(1, 0, 0).applyQuaternion(player.quaternion);
2025-08-26 15:56:28 +03:00
if (move.forward) tryMove(forward);
if (move.backward) tryMove(forward.clone().multiplyScalar(-1));
if (move.strafeLeft) tryMove(right.clone().multiplyScalar(-1));
if (move.strafeRight) tryMove(right);
// Отправляем позицию внутри интерьера, чтобы нас видели другие внутри
if (socketRef.current) {
socketRef.current.emit('playerMovement', { x: player.position.x, y: player.position.y, z: player.position.z });
}
}
function updateCameraFollow() {
if (!player) return;
const target = player.position.clone();
if (cameraRef.current === fpCamRef.current) {
const yaw = player.rotation.y;
const pitch = fpPitchRef.current;
const headPos = target.clone().add(new THREE.Vector3(0, 1.6, 0));
cameraRef.current.position.copy(headPos);
const forward = new THREE.Vector3(0, 0, -1).applyEuler(
new THREE.Euler(pitch, yaw, 0, 'YXZ')
);
cameraRef.current.lookAt(headPos.clone().add(forward));
return;
}
const polar = basePolar + cameraPitchOffset;
const planar = radius * Math.cos(polar);
const yOff = radius * Math.sin(polar);
const xOff = planar * Math.cos(baseAzimuth);
const zOff = planar * Math.sin(baseAzimuth);
2025-08-25 22:39:29 +03:00
// Плавная интерполяция позиции камеры
const targetPosition = new THREE.Vector3(
target.x + xOff,
target.y + yOff,
target.z + zOff
);
2025-08-25 22:39:29 +03:00
cameraRef.current.position.lerp(targetPosition, 0.1);
cameraRef.current.lookAt(target);
}
function animate() {
requestAnimationFrame(animate);
2025-08-25 22:39:29 +03:00
// Проверяем, что все необходимые объекты инициализированы
if (!renderer || !scene || !cameraRef.current) {
console.warn('Пропускаем анимацию - не все объекты инициализированы');
return;
}
if (!clock || typeof clock.getDelta !== 'function') {
console.warn('Clock не инициализирован');
return;
}
const delta = Math.min(clock.getDelta(), 0.1); // Ограничиваем delta для стабильности
// Обновляем анимации
if (mixer && typeof mixer.update === 'function') {
mixer.update(delta);
}
// Обновляем движение игрока
2025-08-26 15:56:28 +03:00
// В интерьере отключаем автодвижение по кликам (двигаемся только WASD)
if (!isInInteriorRef.current && typeof updateDestinationMovement === 'function') {
2025-08-25 22:39:29 +03:00
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();
}
}
2025-08-25 22:39:29 +03:00
// Обновляем камеру
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();
}
2025-08-25 22:39:29 +03:00
if (rendererRef.current) {
rendererRef.current.setSize(window.innerWidth, window.innerHeight);
}
}
window.addEventListener('resize', onWindowResize, false);
return () => {
clearInterval(balanceInterval);
2025-08-25 22:39:29 +03:00
// Очищаем таймеры throttling
if (wheelTimeout) {
clearTimeout(wheelTimeout);
wheelTimeout = null;
}
if (mouseMoveTimeout) {
clearTimeout(mouseMoveTimeout);
mouseMoveTimeout = null;
}
window.removeEventListener('keydown', onKeyDown);
window.removeEventListener('keyup', onKeyUp);
2025-08-25 22:39:29 +03:00
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>
<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', bottom: 20, left: 20, zIndex: 1000, background: 'rgba(0,0,0,0.6)', color: '#fff', padding: '4px 8px', borderRadius: 4 }}>
{new Date(gameTime).toLocaleString()}
</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
2025-08-25 22:39:29 +03:00
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>
)}
{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 }}>
2025-08-25 22:39:29 +03:00
<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" }}>
<div style={{ width: "100%", height: "10%", backgroundColor: "#0088cc", display: "flex", alignItems: "center", justifyContent: "center" }}>
<div style={{ fontSize: "150%", color: "white" }}>Shipgram Messenger</div>
</div>
<div style={{ width: "100%", height: "90%", display: "flex" }}>
<div style={{ width: "30%", height: "100%", borderRight: "1px solid #ddd", overflowY: "auto" }}>
<div style={{ padding: "10px", fontWeight: "bold", borderBottom: "1px solid #ddd" }}>Contacts</div>
<div id="user-list" style={{ overflowY: "auto" }}>
{telegramContacts.length === 0 && (
<div style={{ padding: 10, textAlign: "center" }}>
{telegramContacts.length === 0
? "Загрузка контактов..."
: "Контакты не найдены"}
</div>
)}
{telegramContacts.map((user, index) => (
<div
key={index}
style={{
padding: "10px",
borderBottom: "1px solid #eee",
cursor: "pointer",
display: "flex",
alignItems: "center"
}}
onClick={() => setActiveChat(user)}
>
<div>
{user.firstName} {user.lastName}
</div>
</div>
))}
</div>
</div>
<div style={{ width: "70%", height: "100%" }}>
{activeChat && (
<div style={{ padding: "10px" }}>
<h3>Чат с {activeChat.firstName} {activeChat.lastName}</h3>
{/* Контейнер сообщений с прокруткой */}
<div
id="chatContainer"
style={{
flex: 1,
border: "1px solid #ddd",
padding: "10px",
overflowY: "auto",
marginBottom: "10px"
}}
>
{messages.length === 0 ? (
<p style={{ textAlign: 'center', color: '#888' }}>Нет сообщений</p>
) : (
messages.map((msg) => (
<div
key={msg.id}
style={{
textAlign: msg.sender_id === userProfile?.id ? 'right' : 'left',
margin: '10px 0'
}}
>
<div style={{
display: 'inline-block',
padding: '8px 12px',
borderRadius: '12px',
background: msg.sender_id === userProfile?.id ? '#0084ff' : '#e5e5ea',
color: msg.sender_id === userProfile?.id ? '#fff' : '#000',
maxWidth: '80%'
}}>
{msg.message}
</div>
<div style={{
fontSize: '0.8em',
color: '#666',
marginTop: '4px'
}}>
{new Date(msg.created_at).toLocaleTimeString()}
</div>
</div>
))
)}
</div>
{/* Поле ввода и кнопка отправки */}
<div style={{ display: 'flex' }}>
<input
type="text"
value={newMessage}
onChange={(e) => setNewMessage(e.target.value)}
placeholder="Введите сообщение..."
style={{
flex: 1,
padding: '8px',
borderRadius: '20px',
border: '1px solid #ddd'
}}
onKeyDown={(e) => {
if (e.key === 'Enter') sendMessage();
}}
/>
<button
onClick={sendMessage}
style={{
marginLeft: '8px',
padding: '8px 16px',
background: '#0084ff',
color: 'white',
border: 'none',
borderRadius: '20px',
cursor: 'pointer'
}}
>
Отправить
</button>
</div>
</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;