diff --git a/public/models/npc/galina.glb b/public/models/npc/galina.glb new file mode 100644 index 0000000..443ae01 Binary files /dev/null and b/public/models/npc/galina.glb differ diff --git a/server.js b/server.js index ca94bfc..e4aebd3 100644 --- a/server.js +++ b/server.js @@ -138,6 +138,7 @@ io.on('connection', socket => { socketId: socket.id, userId: socket.userId, x, + y: 0, z, cityId, avatarURL: null, @@ -148,6 +149,7 @@ io.on('connection', socket => { players[socket.id] = playersByCity[cityId][socket.id]; socket.cityId = cityId; socket.x = x; + socket.y = 0; socket.z = z; // Отправляем только игроков этого города socket.emit('currentPlayers', playersByCity[cityId]); @@ -155,11 +157,12 @@ io.on('connection', socket => { // --- Новый игрок --- socket.on('newPlayer', data => { - const cityId = data.cityId || socket.cityId || 1; + const cityId = socket.cityId || 1; if (!playersByCity[cityId]) playersByCity[cityId] = {}; const p = playersByCity[cityId][socket.id] || {}; Object.assign(p, { x: data.x, + y: data.y ?? p.y ?? 0, z: data.z, cityId, avatarURL: data.avatarURL || null, @@ -170,19 +173,19 @@ io.on('connection', socket => { playersByCity[cityId][socket.id] = p; players[socket.id] = p; socket.cityId = cityId; - // Сообщаем только игрокам этого города + // Сообщаем всем игрокам этого города (без учёта интерьера) for (const id in playersByCity[cityId]) { - if (id !== socket.id) { - io.to(id).emit('newPlayer', { - playerId: socket.id, - x: p.x, - z: p.z, - avatarURL: p.avatarURL, - gender: p.gender, - firstName: p.firstName, - lastName: p.lastName - }); - } + if (id === socket.id) continue; + io.to(id).emit('newPlayer', { + playerId: socket.id, + x: p.x, + y: p.y ?? 0, + z: p.z, + avatarURL: p.avatarURL, + gender: p.gender, + firstName: p.firstName, + lastName: p.lastName + }); } }); @@ -191,21 +194,27 @@ io.on('connection', socket => { const cityId = socket.cityId; if (playersByCity[cityId] && playersByCity[cityId][socket.id]) { playersByCity[cityId][socket.id].x = movementData.x; + if (typeof movementData.y === 'number') { + playersByCity[cityId][socket.id].y = movementData.y; + } playersByCity[cityId][socket.id].z = movementData.z; - // Сообщаем только игрокам этого города + // Сообщаем только игрокам этого города (без учёта интерьера) for (const id in playersByCity[cityId]) { if (id !== socket.id) { io.to(id).emit('playerMoved', { playerId: socket.id, x: movementData.x, + y: typeof movementData.y === 'number' ? movementData.y : (playersByCity[cityId][socket.id].y ?? 0), z: movementData.z }); } } // Voice chat nearby только в этом городе + const norm = v => (v == null ? null : String(v)); const sender = playersByCity[cityId][socket.id]; for (const [id, other] of Object.entries(playersByCity[cityId])) { if (id === socket.id) continue; + // Голосовой чат больше не фильтруем по интерьеру const dx = sender.x - other.x; const dz = sender.z - other.z; const dist = Math.sqrt(dx * dx + dz * dz); @@ -217,6 +226,46 @@ io.on('connection', socket => { } }); + // --- Смена интерьера (вход/выход) --- + socket.on('interiorChange', ({ interiorId }) => { + const cityId = socket.cityId; + if (!playersByCity[cityId] || !playersByCity[cityId][socket.id]) return; + const norm = v => (v == null ? null : String(v)); + const prevInterior = playersByCity[cityId][socket.id].interiorId ?? null; + const nextInterior = norm(interiorId); + playersByCity[cityId][socket.id].interiorId = nextInterior; + socket.interiorId = nextInterior; + + // Сообщаем игрокам старого интерьера, что этот игрок ушёл + for (const id in playersByCity[cityId]) { + if (id === socket.id) continue; + const other = playersByCity[cityId][id]; + const samePrev = norm(other?.interiorId) === norm(prevInterior); + if (samePrev && prevInterior !== nextInterior) { + io.to(id).emit('playerDisconnected', socket.id); + } + } + + // Сообщаем всем игрокам города о появлении (без учёта интерьера) + for (const id in playersByCity[cityId]) { + if (id === socket.id) continue; + const p = playersByCity[cityId][socket.id]; + io.to(id).emit('newPlayer', { + playerId: socket.id, + x: p.x, + z: p.z, + avatarURL: p.avatarURL, + gender: p.gender, + firstName: p.firstName, + lastName: p.lastName + }); + } + + // Отправляем текущий список видимых игроков только для этого сокета + // Отправляем полный список игроков города + socket.emit('currentPlayers', playersByCity[cityId]); + }); + socket.on('sendMessage', async ({ receiverId, message }, callback) => { try { const senderId = socket.userId; @@ -363,151 +412,168 @@ app.get('/api/users', authenticate, async (req, res) => { // Новый маршрут для получения сообщений с конкретным контактом app.get('/api/messages/:contactId', authenticate, async (req, res) => { - const userId = req.user.id; - const contactId = parseInt(req.params.contactId, 10); + const userId = req.user.id; + const contactId = parseInt(req.params.contactId, 10); + const pool = (typeof virtualWorldPool !== 'undefined' && virtualWorldPool) ? virtualWorldPool : db; + try { + // Ensure table exists + await pool.query(` + CREATE TABLE IF NOT EXISTS messages ( + id SERIAL PRIMARY KEY, + sender_id INT NOT NULL, + receiver_id INT NOT NULL, + message TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + is_read BOOLEAN NOT NULL DEFAULT FALSE + )`); + } catch (e) { + console.warn('[GET /api/messages/:contactId] ensure table failed:', e.message); + } - try { - const messagesRes = await virtualWorldPool.query( - `SELECT * FROM messages - WHERE (sender_id = $1 AND receiver_id = $2) - OR (sender_id = $2 AND receiver_id = $1) - ORDER BY created_at ASC`, - [userId, contactId] - ); - - res.json(messagesRes.rows); - } catch (err) { - console.error(err); - res.status(500).json({ error: 'Ошибка получения сообщений' }); - } + try { + const sql = `SELECT * FROM messages + WHERE (sender_id = $1 AND receiver_id = $2) + OR (sender_id = $2 AND receiver_id = $1) + ORDER BY created_at ASC`; + const messagesRes = await pool.query(sql, [userId, contactId]); + res.json(messagesRes.rows); + } catch (err) { + console.error('[GET /api/messages/:contactId] error:', err); + res.status(500).json({ error: 'Ошибка получения сообщений' }); + } }); app.post('/api/messages/send', authenticate, async (req, res) => { - const senderId = req.user.id; - const { receiverId, message } = req.body; - const recvId = parseInt(receiverId, 10); - console.log("Запрос пошел"); - try { - // Проверка существования получателя в основной БД - const receiverCheck = await db.query('SELECT id FROM users WHERE id = $1', [recvId]); - if (receiverCheck.rows.length === 0) { - return res.status(404).json({ error: 'Пользователь не найден' }); - } - - // Сохранение сообщения в virtual_world - const result = await virtualWorldPool.query( - `INSERT INTO messages (sender_id, receiver_id, message, created_at) - VALUES ($1, $2, $3, NOW()) - RETURNING id, created_at, is_read`, - [senderId, recvId, message] - ); - - const newMessage = result.rows[0]; - - // Отправка через сокеты, если получатель онлайн - const receiverSocketId = onlineUsers[recvId]; - if (receiverSocketId) { - io.to(receiverSocketId).emit('newMessage', { - id: newMessage.id, - text: message, - senderId, - timestamp: newMessage.created_at, - isRead: newMessage.is_read - }); - } - - res.status(201).json(newMessage); - } catch (err) { - console.error(err); - res.status(500).json({ error: 'Ошибка отправки сообщения' }); + const senderId = req.user.id; + const { receiverId, message } = req.body || {}; + const recvId = parseInt(receiverId, 10); + const pool = (typeof virtualWorldPool !== 'undefined' && virtualWorldPool) ? virtualWorldPool : db; + console.log('[POST /api/messages/send] sender:', senderId, 'receiver:', recvId); + try { + const receiverCheck = await db.query('SELECT id FROM users WHERE id = $1', [recvId]); + if (receiverCheck.rows.length === 0) { + return res.status(404).json({ error: 'Пользователь не найден' }); } + try { + await pool.query(` + CREATE TABLE IF NOT EXISTS messages ( + id SERIAL PRIMARY KEY, + sender_id INT NOT NULL, + receiver_id INT NOT NULL, + message TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + is_read BOOLEAN NOT NULL DEFAULT FALSE + )`); + } catch (e) { + console.warn('[POST /api/messages/send] ensure table failed:', e.message); + } + + const result = await pool.query( + `INSERT INTO messages (sender_id, receiver_id, message, created_at) + VALUES ($1, $2, $3, NOW()) + RETURNING id, created_at, is_read`, + [senderId, recvId, message] + ); + const newMessage = result.rows[0]; + + const receiverSocketId = onlineUsers[recvId]; + if (receiverSocketId) { + io.to(receiverSocketId).emit('newMessage', { + id: newMessage.id, + text: message, + senderId, + timestamp: newMessage.created_at, + isRead: newMessage.is_read + }); + } + res.status(201).json(newMessage); + } catch (err) { + console.error('[POST /api/messages/send] error:', err); + res.status(500).json({ error: 'Ошибка отправки сообщения' }); + } }); app.get('/api/messages', authenticate, async (req, res) => { - const userId = req.user.id; - - try { - // Получение сообщений из virtual_world - const messagesRes = await virtualWorldPool.query( - `SELECT * FROM messages + const userId = req.user.id; + const pool = (typeof virtualWorldPool !== 'undefined' && virtualWorldPool) ? virtualWorldPool : db; + try { + await pool.query(` + CREATE TABLE IF NOT EXISTS messages ( + id SERIAL PRIMARY KEY, + sender_id INT NOT NULL, + receiver_id INT NOT NULL, + message TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + is_read BOOLEAN NOT NULL DEFAULT FALSE + )`); + } catch (e) { + console.warn('[GET /api/messages] ensure table failed:', e.message); + } + try { + const messagesRes = await pool.query( + `SELECT * FROM messages WHERE sender_id = $1 OR receiver_id = $1 ORDER BY created_at DESC`, - [userId] - ); - - if (messagesRes.rows.length === 0) { - return res.json([]); - } - - // Сбор ID пользователей - const userIds = new Set(); - messagesRes.rows.forEach(msg => { - userIds.add(msg.sender_id); - userIds.add(msg.receiver_id); - }); - - // Получение данных пользователей из основной БД - const usersRes = await db.query( - `SELECT id, first_name, last_name, avatar_url - FROM users - WHERE id = ANY($1)`, - [Array.from(userIds)] - ); - - // Создание карты пользователей - const userMap = {}; - usersRes.rows.forEach(user => { - userMap[user.id] = { - name: `${user.first_name} ${user.last_name}`, - avatar: user.avatar_url - }; - }); - - // Формирование ответа - const messages = messagesRes.rows.map(msg => ({ - id: msg.id, - text: msg.message, - senderId: msg.sender_id, - receiverId: msg.receiver_id, - sender: userMap[msg.sender_id] || { name: 'Неизвестный', avatar: null }, - receiver: userMap[msg.receiver_id] || { name: 'Неизвестный', avatar: null }, - timestamp: msg.created_at, - isRead: msg.is_read - })); - - res.json(messages); - } catch (err) { - console.error(err); - res.status(500).json({ error: 'Ошибка получения сообщений' }); + [userId] + ); + if (messagesRes.rows.length === 0) { + return res.json([]); } + const userIds = new Set(); + messagesRes.rows.forEach(msg => { userIds.add(msg.sender_id); userIds.add(msg.receiver_id); }); + const usersRes = await db.query( + `SELECT id, first_name, last_name, avatar_url FROM users WHERE id = ANY($1)`, + [Array.from(userIds)] + ); + const userMap = {}; + usersRes.rows.forEach(user => { + userMap[user.id] = { name: `${user.first_name} ${user.last_name}`, avatar: user.avatar_url }; + }); + const messages = messagesRes.rows.map(msg => ({ + id: msg.id, + text: msg.message, + senderId: msg.sender_id, + receiverId: msg.receiver_id, + sender: userMap[msg.sender_id] || { name: 'Неизвестный', avatar: null }, + receiver: userMap[msg.receiver_id] || { name: 'Неизвестный', avatar: null }, + timestamp: msg.created_at, + isRead: msg.is_read + })); + res.json(messages); + } catch (err) { + console.error('[GET /api/messages] error:', err); + res.status(500).json({ error: 'Ошибка получения сообщений' }); + } }); app.patch('/api/messages/:id/read', authenticate, async (req, res) => { - const messageId = req.params.id; - const userId = req.user.id; - - try { - // Проверка прав доступа - const checkRes = await virtualWorldPool.query( - `SELECT id FROM messages WHERE id = $1 AND receiver_id = $2`, - [messageId, userId] - ); - - if (checkRes.rows.length === 0) { - return res.status(404).json({ error: 'Сообщение не найдено или доступ запрещен' }); - } - - // Обновление статуса - await virtualWorldPool.query( - `UPDATE messages SET is_read = true WHERE id = $1`, - [messageId] - ); - - res.status(204).end(); - } catch (err) { - console.error(err); - res.status(500).json({ error: 'Ошибка обновления сообщения' }); + const messageId = req.params.id; + const userId = req.user.id; + const pool = (typeof virtualWorldPool !== 'undefined' && virtualWorldPool) ? virtualWorldPool : db; + try { + await pool.query(` + CREATE TABLE IF NOT EXISTS messages ( + id SERIAL PRIMARY KEY, + sender_id INT NOT NULL, + receiver_id INT NOT NULL, + message TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + is_read BOOLEAN NOT NULL DEFAULT FALSE + )`); + } catch (e) { + console.warn('[PATCH /api/messages/:id/read] ensure table failed:', e.message); + } + try { + const checkRes = await pool.query(`SELECT id FROM messages WHERE id = $1 AND receiver_id = $2`, [messageId, userId]); + if (checkRes.rows.length === 0) { + return res.status(404).json({ error: 'Сообщение не найдено или доступ запрещен' }); } + await pool.query(`UPDATE messages SET is_read = true WHERE id = $1`, [messageId]); + res.status(204).end(); + } catch (err) { + console.error('[PATCH /api/messages/:id/read] error:', err); + res.status(500).json({ error: 'Ошибка обновления сообщения' }); + } }); app.get('/api/me', authenticate, async (req, res) => { @@ -787,24 +853,73 @@ app.get( app.post('/api/interiors/:interiorId/enter', authenticate, async (req, res) => { const interiorId = parseInt(req.params.interiorId, 10); try { - const interior = (await db.query( + const base = await db.query( 'SELECT spawn_x, spawn_y, spawn_z, spawn_rot, exit_x, exit_y, exit_z, exit_rot FROM interiors WHERE id = $1', [interiorId] - )).rows[0]; + ); + const interior = base.rows[0]; if (!interior) return res.status(404).json({ error: 'Интерьер не найден' }); + + let exitInt = null; + try { + const extra = await db.query( + 'SELECT exit_int_x, exit_int_y, exit_int_z FROM interiors WHERE id = $1', + [interiorId] + ); + if (extra.rows[0] && (extra.rows[0].exit_int_x != null || extra.rows[0].exit_int_y != null || extra.rows[0].exit_int_z != null)) { + exitInt = { + x: extra.rows[0].exit_int_x, + y: extra.rows[0].exit_int_y, + z: extra.rows[0].exit_int_z + }; + } + } catch (e2) { + // Колонки exit_int_* могут отсутствовать — это не ошибка для клиента + console.warn('exit_int_* columns are not available yet:', e2.message); + } + + // Эффективная точка входа: если spawn_x/y/z не заданы (или равны 0), + // пробуем использовать внутреннюю точку выхода (exit_int_*), + // иначе в крайнем случае используем координаты внешнего выхода. + const rawSpawn = { + x: interior.spawn_x, + y: interior.spawn_y, + z: interior.spawn_z, + rot: interior.spawn_rot + }; + const isZeroOrNull = v => v == null || Number(v) === 0; + const spawnLooksEmpty = isZeroOrNull(rawSpawn.x) && isZeroOrNull(rawSpawn.y) && isZeroOrNull(rawSpawn.z); + + let effectiveSpawn = { ...rawSpawn }; + if (spawnLooksEmpty) { + if (exitInt && typeof exitInt.x === 'number' && typeof exitInt.z === 'number') { + effectiveSpawn = { x: exitInt.x, y: exitInt.y ?? 0, z: exitInt.z, rot: rawSpawn.rot }; + } else if (!isZeroOrNull(interior.exit_x) || !isZeroOrNull(interior.exit_y) || !isZeroOrNull(interior.exit_z)) { + // Фолбэк на внешние координаты выхода, если они заданы + effectiveSpawn = { x: interior.exit_x, y: interior.exit_y, z: interior.exit_z, rot: rawSpawn.rot }; + } + } + + // Логируем вычисленные координаты для отладки + try { + console.log('[INTERIOR ENTER]', { + interiorId, + spawn: effectiveSpawn, + rawSpawn, + exit: { x: interior.exit_x, y: interior.exit_y, z: interior.exit_z, rot: interior.exit_rot }, + exitInt + }); + } catch (_) {} + res.json({ - spawn: { - x: interior.spawn_x, - y: interior.spawn_y, - z: interior.spawn_z, - rot: interior.spawn_rot - }, + spawn: effectiveSpawn, exit: { x: interior.exit_x, y: interior.exit_y, z: interior.exit_z, rot: interior.exit_rot - } + }, + exitInt }); } catch (e) { console.error(e); @@ -844,31 +959,38 @@ app.get('/api/interiors/:interiorId/definition', authenticate, async (req, res) // Начало копи app.post('/api/listen', authenticate, async (req, res) => { - console.log('Request data:', req.body); - const { player_id, json_filename } = req.body; - - if (!player_id || !json_filename) { - return res.status(400).json({ - success: false, - error: 'player_id and json_filename are required' - }); + try { + console.log('[API /api/listen] Request body:', req.body); + const { player_id: bodyPlayerId, json_filename } = req.body || {}; + const authUser = req.user || {}; + const effectivePlayerId = bodyPlayerId || authUser.email || authUser.id || authUser.userId; + if (!effectivePlayerId || !json_filename) { + return res.status(400).json({ success: false, error: 'player_id and json_filename are required' }); } + // Выбираем пул: если есть отдельный пул, берём его, иначе общий db + const pool = (typeof virtualWorldPool !== 'undefined' && virtualWorldPool) ? virtualWorldPool : db; try { - await virtualWorldPool.query(` - INSERT INTO json_listened (player_id, json_filename, listened_at) - VALUES ($1, $2, NOW()) - `, [player_id, json_filename]); - - res.status(200).json({ success: true }); - } catch (err) { - console.error('Full DB error:', err); - res.status(500).json({ - success: false, - error: 'Database operation failed', - details: process.env.NODE_ENV === 'development' ? err.message : null - }); + // Создаём таблицу при необходимости + await pool.query(` + CREATE TABLE IF NOT EXISTS json_listened ( + id SERIAL PRIMARY KEY, + player_id TEXT NOT NULL, + json_filename TEXT NOT NULL, + listened_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + )`); + } catch (e) { + console.warn('[API /api/listen] ensure table failed:', e.message); } + + const q = `INSERT INTO json_listened (player_id, json_filename, listened_at) VALUES ($1, $2, NOW())`; + await pool.query(q, [String(effectivePlayerId), String(json_filename)]); + console.log('[API /api/listen] Saved listened:', { player: effectivePlayerId, json: json_filename }); + return res.status(200).json({ success: true }); + } catch (err) { + console.error('[API /api/listen] error:', err); + return res.status(500).json({ success: false, error: 'Database operation failed', details: err.message }); + } }); //Конец копи //Начало копи @@ -1047,15 +1169,19 @@ app.get('/api/quests/progress', authenticate, async (req, res) => { console.log("Загрузка на сервере. ID пользователя:", req.user.id); try { - // Получаем email пользователя из основной БД + // Получаем email пользователя из основной БД (или из токена) + const fallbackEmail = req.user?.email || req.user?.username || null; const userRes = await db.query('SELECT email FROM users WHERE id = $1', [req.user.id]); if (userRes.rows.length === 0) { - return res.status(404).json({ error: 'Пользователь не найден' }); + if (!fallbackEmail) { + return res.status(404).json({ error: 'Пользователь не найден' }); + } } - const userEmail = userRes.rows[0].email; + const userEmail = userRes.rows[0]?.email || fallbackEmail; // Получаем список всех квестов с их JSON файлами - const questsQuery = await virtualWorldPool.query(` + const pool = (typeof virtualWorldPool !== 'undefined' && virtualWorldPool) ? virtualWorldPool : db; + const questsQuery = await pool.query(` SELECT q.id, q.title, qj.json_filename FROM quests q JOIN quest_jsons qj ON q.id = qj.quest_id @@ -1063,7 +1189,20 @@ app.get('/api/quests/progress', authenticate, async (req, res) => { `); // Получаем JSON файлы, которые прослушал игрок - const listenedQuery = await virtualWorldPool.query(` + // Гарантируем наличие таблицы json_listened + try { + await pool.query(` + CREATE TABLE IF NOT EXISTS json_listened ( + id SERIAL PRIMARY KEY, + player_id TEXT NOT NULL, + json_filename TEXT NOT NULL, + listened_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + )`); + } catch (e) { + console.warn('[/api/quests/progress] ensure table json_listened failed:', e.message); + } + + const listenedQuery = await pool.query(` SELECT json_filename FROM json_listened WHERE player_id = $1 `, [userEmail]); diff --git a/src/Game.js b/src/Game.js index dc8a2db..ff12580 100644 --- a/src/Game.js +++ b/src/Game.js @@ -24,11 +24,15 @@ function Game({ avatarUrl, gender }) { // 3) реф для группы «интерьера» const interiorGroupRef = useRef(null); + const interiorCollidersRef = useRef([]); + const interiorExitPosRef = useRef(null); + const fpHiddenNodesRef = useRef([]); const cleanupTimerRef = useRef(null); // Глобальный менеджер прогресса загрузки (используем в GLTFLoader) const loadingManagerRef = useRef(null); // Кликабельные объекты внутри интерьера const interiorInteractablesRef = useRef([]); + const npcMeshesRef = useRef([]); // камеры const orthoCamRef = useRef(null); @@ -39,6 +43,7 @@ function Game({ avatarUrl, gender }) { const fpPitchRef = useRef(0); const isTouchDevice = typeof window !== 'undefined' && ('ontouchstart' in window || navigator.maxTouchPoints > 0); const isInInteriorRef = useRef(false); + const altHeldRef = useRef(false); const LOAD_RADIUS = 120; const [activeApp, setActiveApp] = useState(null); @@ -111,6 +116,8 @@ function Game({ avatarUrl, gender }) { const [seregaComments, setSeregaComments] = useState([]); const [currentExit, setCurrentExit] = useState(null); + const currentExitRef = useRef(null); + useEffect(() => { currentExitRef.current = currentExit; }, [currentExit]); useEffect(() => { const decay = setInterval(() => { @@ -496,7 +503,7 @@ function Game({ avatarUrl, gender }) { // базовая геометрия для объектов типа "chair" const baseChairMesh = new THREE.Mesh( new THREE.BoxGeometry(1, 1, 1), - new THREE.MeshStandardMaterial({ color: 0x888888 }) + new THREE.MeshBasicMaterial({ visible: false }) ); async function loadGLTF(url) { @@ -522,10 +529,8 @@ function Game({ avatarUrl, gender }) { switchToFirstPersonCamera(); // Включаем управление мышью для интерьера - document.body.style.cursor = 'none'; // Скрываем курсор - if (rendererRef.current) { - rendererRef.current.domElement.requestPointerLock(); - } + // Курсор оставляем активным (без pointer lock) + document.body.style.cursor = 'default'; // Устанавливаем состояние "в интерьере" console.log('Устанавливаем setIsInInterior(true)'); @@ -533,10 +538,26 @@ function Game({ avatarUrl, gender }) { setSelectedHouse(null); console.log('isInInterior установлен в true'); + // Сброс кликово-путевого движения и визуальных маркеров + if (typeof currentPath !== 'undefined') currentPath = []; + if (typeof pathIndex !== 'undefined') pathIndex = 0; + if (typeof destination !== 'undefined') destination = null; + if (typeof blockedTime !== 'undefined') blockedTime = 0; + if (typeof destinationMarker !== 'undefined' && destinationMarker) destinationMarker.visible = false; + // Сброс нажатых направлений + if (moveInputRef.current) { + Object.keys(moveInputRef.current).forEach(k => { moveInputRef.current[k] = false; }); + } // Телепортируем игрока в интерьер (если нужно) console.log('Вызываем teleportPlayerToInterior для интерьера:', interiorId); await teleportPlayerToInterior(interiorId); + // Отправляем мгновенное обновление позиции перед уведомлением об интерьере + if (socketRef.current && playerRef.current) { + socketRef.current.emit('playerMovement', { x: playerRef.current.position.x, y: playerRef.current.position.y, z: playerRef.current.position.z }); + } + // Сообщаем серверу о смене интерьера, чтобы видимость игроков фильтровалась по interiorId + socketRef.current?.emit('interiorChange', { interiorId }); console.log('teleportPlayerToInterior завершена'); } const teleportPlayerToInterior = async (interiorId) => { @@ -560,21 +581,54 @@ function Game({ avatarUrl, gender }) { alert(`Не удалось получить координаты интерьера: ${errText}`); return; } - const { spawn, exit } = await res.json(); + const { spawn, exit, exitInt } = await res.json(); if (!spawn) { alert('Для этого интерьера не заданы координаты входа'); return; } + // Нормализуем типы в числа (pg для NUMERIC отдает строки) + const nSpawn = { + x: Number(spawn.x), + y: Number(spawn.y), + z: Number(spawn.z), + rot: Number(spawn.rot) || 0 + }; + const nExit = exit && typeof exit === 'object' ? { + x: Number(exit.x), + y: Number(exit.y), + z: Number(exit.z), + rot: Number(exit.rot) || 0 + } : null; + const nExitInt = exitInt && typeof exitInt === 'object' ? { + x: Number(exitInt.x), + y: Number(exitInt.y), + z: Number(exitInt.z) + } : null; // Телепортируем игрока в интерьер if (playerRef.current) { - playerRef.current.position.set(spawn.x, spawn.y, spawn.z); - playerRef.current.rotation.set(0, spawn.rot || 0, 0); - // Можно добавить сброс скорости, анимации и т.д. при необходимости + 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; }); + } } - setCurrentExit(exit || null); - // Добавляем маркер выхода - if (exit) { - addExitMarker(exit); + console.log('[ENTER INTERIOR] exit from server:', nExit); + setCurrentExit(nExit || null); + // Визуализируем маркер выхода внутри интерьера, чтобы по клику можно было выйти + if (nExit && typeof nExit.x === 'number' && typeof nExit.z === 'number') { + try { addExitMarker(nExit); } catch (e) { console.warn('[ENTER INTERIOR] addExitMarker failed', e); } + } + // Запоминаем позицию внутреннего триггера выхода, если пришла + if (nExitInt && typeof nExitInt.x === 'number') { + console.log('[ENTER INTERIOR] exitInt (internal exit trigger):', nExitInt); + interiorExitPosRef.current = new THREE.Vector3(nExitInt.x, nExitInt.y || 0, nExitInt.z); } console.log('teleportPlayerToInterior завершена успешно'); } catch (e) { @@ -619,9 +673,54 @@ function Game({ avatarUrl, gender }) { intGroup.name = 'interiorGroup'; intGroup.add(gltf.scene); + // Декуплируем и гарантируем непрозрачность материалов интерьера + gltf.scene.traverse((child) => { + if (child.isMesh && child.material) { + if (Array.isArray(child.material)) { + child.material = child.material.map(mat => { + if (!mat) return mat; + const m = mat.clone(); + m.transparent = false; + m.opacity = 1; + m.depthWrite = true; + m.needsUpdate = true; + return m; + }); + } else { + child.material = child.material.clone(); + child.material.transparent = false; + child.material.opacity = 1; + child.material.depthWrite = true; + child.material.needsUpdate = true; + } + } + }); + + // Построение коллайдеров интерьера (простые коробки по мешам) + const colliders = []; + gltf.scene.traverse((child) => { + if (child.isMesh && child.geometry) { + colliders.push(child); + } + }); + interiorCollidersRef.current = colliders; + // Добавляем объекты интерьера interiorInteractablesRef.current = []; // сбрасываем реестр интерактива + // Хелпер для определения ID NPC по пути к модели + const getNpcIdFromModel = (url) => { + if (!url || typeof url !== 'string') return null; + const lower = url.toLowerCase(); + if (lower.includes('/models/npc/galina.glb')) return 'Adventurer'; + if (lower.includes('/models/npc/oxranik.glb')) return 'Oxranik'; + if (lower.includes('/models/npc/guard.glb')) return 'guard'; + if (lower.includes('/models/npc/beachcharacter.glb')) return 'BeachCharacter'; + if (lower.includes('/models/npc/bartender.glb')) return 'bartender'; + if (lower.includes('/models/npc/computer.glb')) return 'Computer'; + return null; + }; + for (const o of objects) { if (o.model_url) { try { @@ -630,6 +729,34 @@ function Game({ avatarUrl, gender }) { 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); + + // Если это NPC внутри интерьера — добавим кликабельную хит‑зону + const isNpc = (o.type === 'npc') || (typeof o.model_url === 'string' && o.model_url.includes('/models/npc/')); + if (isNpc) { + const npcId = o.id || getNpcIdFromModel(o.model_url); + console.log('[INTERIOR NPC] detected npc, id:', npcId, 'at', { x: o.x, y: o.y, z: o.z }); + const hit = new THREE.Mesh( + new THREE.SphereGeometry(1.2), + new THREE.MeshBasicMaterial({ color: 0x00ff00, transparent: true, opacity: 0.0001, depthWrite: false }) + ); + hit.position.set(o.x, (o.y ?? 0) + 1.0, o.z); + hit.userData.interactable = true; + hit.userData.payload = { type: 'npc', id: npcId }; + hit.visible = true; + intGroup.add(hit); + interiorInteractablesRef.current.push(hit); + + // Также помечаем сам корень модели как кликабельный NPC + try { + objGltf.scene.userData = objGltf.scene.userData || {}; + objGltf.scene.userData.interactable = true; + objGltf.scene.userData.payload = { type: 'npc', id: npcId }; + interiorInteractablesRef.current.push(objGltf.scene); + // и помечаем как isNpc/npcId для fallback + objGltf.scene.userData.isNpc = true; + objGltf.scene.userData.npcId = npcId; + } catch (_) {} + } } catch (e) { console.warn('Не удалось загрузить объект интерьера', o.model_url, e); } @@ -638,21 +765,45 @@ function Game({ avatarUrl, gender }) { mesh.position.set(o.x, o.y, o.z); mesh.rotation.set(o.rot_x, o.rot_y, o.rot_z); mesh.scale.set(o.scale, o.scale, o.scale); + if (mesh.material) { + if (Array.isArray(mesh.material)) { + mesh.material = mesh.material.map(mat => { + if (!mat) return mat; + const m = mat.clone(); + m.transparent = false; + m.opacity = 1; + m.depthWrite = true; + m.needsUpdate = true; + return m; + }); + } else { + mesh.material = mesh.material.clone(); + mesh.material.transparent = false; + mesh.material.opacity = 1; + mesh.material.depthWrite = true; + mesh.material.needsUpdate = true; + } + } intGroup.add(mesh); } - // Если сервер прислал «маркер»/NPC — пометим кликабельным + // Если сервер пометил объект как «интерактивный/маркер» — кликабельная зона if (o.interactable || o.marker) { const hit = new THREE.Mesh( new THREE.SphereGeometry(0.6), - new THREE.MeshBasicMaterial({ color: 0x00ff00, transparent: true, opacity: 0.15, depthWrite: false }) + new THREE.MeshBasicMaterial({ color: 0x00ff00, transparent: true, opacity: 0.0001, depthWrite: false }) ); hit.position.set(o.x, o.y + 1.0, o.z); hit.userData.interactable = true; hit.userData.payload = { type: o.type || 'marker', id: o.id || null, label: o.label || 'Интерактив' }; + hit.visible = true; // невидим визуально (opacity≈0), но кликабелен intGroup.add(hit); interiorInteractablesRef.current.push(hit); } + // Сохраним позицию внутреннего выхода, если есть + if (typeof o.exit_int_x === 'number' && typeof o.exit_int_y === 'number' && typeof o.exit_int_z === 'number') { + interiorExitPosRef.current = new THREE.Vector3(o.exit_int_x, o.exit_int_y, o.exit_int_z); + } } // Добавляем освещение для интерьера @@ -931,9 +1082,32 @@ function Game({ avatarUrl, gender }) { const exitInterior = () => { console.log('exitInterior вызвана'); - // Возвращаем игрока на исходную позицию (если не телепортировали) - if (playerRef.current && savedPositionRef.current) { - playerRef.current.position.copy(savedPositionRef.current); + // Телепортируем на координаты выхода из интерьера, если заданы; иначе возвращаем на сохранённую позицию + 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 + }); } // Удаляем маркер выхода, если был @@ -955,6 +1129,39 @@ function Game({ avatarUrl, gender }) { if (typeof updateCityObjectVisibility === 'function') { updateCityObjectVisibility(); } + // Повторно закрепляем телепорт на выход уже после очистки интерьера (на случай перезаписи позы) + if (playerRef.current) { + const cx2 = currentExitRef.current; + console.log('[EXIT AFTER CLEANUP] currentExit:', cx2); + if (cx2 && typeof cx2.x === 'number') { + playerRef.current.position.set( + cx2.x, + typeof cx2.y === 'number' ? cx2.y : playerRef.current.position.y, + cx2.z + ); + playerRef.current.rotation.set(0, cx2.rot || 0, 0); + console.log('[EXIT AFTER CLEANUP] Position applied'); + } + if (typeof lastPlayerPosition !== 'undefined') { + try { lastPlayerPosition = playerRef.current.position.clone(); } catch (_) {} + } + socketRef.current?.emit('playerMovement', { + x: playerRef.current.position.x, + y: playerRef.current.position.y, + z: playerRef.current.position.z + }); + } + // Полный сброс путевого движения и ввода + if (typeof currentPath !== 'undefined') currentPath = []; + if (typeof pathIndex !== 'undefined') pathIndex = 0; + if (typeof destination !== 'undefined') destination = null; + if (typeof blockedTime !== 'undefined') blockedTime = 0; + if (typeof destinationMarker !== 'undefined' && destinationMarker) destinationMarker.visible = false; + if (moveInputRef.current) { + Object.keys(moveInputRef.current).forEach(k => { moveInputRef.current[k] = false; }); + } + // Сообщаем серверу, что покинули интерьер + socketRef.current?.emit('interiorChange', { interiorId: null }); // Возвращаем курсор и отключаем pointer lock document.body.style.cursor = 'default'; @@ -962,6 +1169,7 @@ function Game({ avatarUrl, gender }) { setIsInInterior(false); setCurrentExit(null); + interiorExitPosRef.current = null; }; @@ -1361,8 +1569,20 @@ function switchToFirstPersonCamera() { console.log('Камера переключена на fpCamRef'); } if (playerRef.current) { + // Скрываем полностью собственную модель в режиме FPV playerRef.current.visible = false; - console.log('Игрок скрыт'); + // На всякий случай также скрываем голову/шею (если модель будет вновь показана без выхода из режима) + 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; @@ -1376,6 +1596,9 @@ function switchToFirstPersonCamera() { playerRef.current.position.y + headHeight, playerRef.current.position.z ); + // Не большой сдвиг камеры вперёд, чтобы не упираться в скрытую голову + const forward = new THREE.Vector3(0, 0, -0.08).applyEuler(new THREE.Euler(0, playerRef.current.rotation.y, 0)); + fpCamRef.current.position.add(forward); // Направляем камеру в том же направлении, что и игрок const direction = new THREE.Vector3(0, 0, -1); @@ -1395,6 +1618,11 @@ function switchToThirdPersonCamera() { } if (playerRef.current) { playerRef.current.visible = true; + // Вернуть видимость скрытых для FPV узлов + if (Array.isArray(fpHiddenNodesRef.current)) { + fpHiddenNodesRef.current.forEach(n => { n.visible = true; }); + fpHiddenNodesRef.current = []; + } console.log('Игрок показан'); } fpPitchRef.current = 0; @@ -1414,42 +1642,95 @@ function stopMove(dir) { // ───────────────────────────────────────────────────── useEffect(() => { const onClick = (e) => { + console.log('[INTERIOR CLICK] handler start; isInInterior:', isInInteriorRef.current); if (!isInInteriorRef.current) return; const mount = mountRef.current; if (!mount || !cameraRef.current) return; // координаты мыши в NDC - const rect = mount.getBoundingClientRect(); + // Пытаемся получить координаты из элемента рендера (FP вид) + const canvas = rendererRef.current && rendererRef.current.domElement; + const rect = (canvas || mount).getBoundingClientRect(); const mouse = new THREE.Vector2( ((e.clientX - rect.left) / rect.width) * 2 - 1, -((e.clientY - rect.top) / rect.height) * 2 + 1 ); const raycaster = new THREE.Raycaster(); raycaster.setFromCamera(mouse, cameraRef.current); - // Ищем пересечения по интерактивам + // Ищем пересечения по интерактивам (включая NPC) const objects = interiorInteractablesRef.current.filter(obj => obj?.isObject3D); - if (!objects.length) return; - const hits = raycaster.intersectObjects(objects, true) - .filter(h => h.object && h.object.userData && h.object.userData.interactable); - if (!hits.length) return; - + // Добавим в список интерактивов саму группу интерьера, чтобы traverse детектил payload у вложенных узлов + const extraTargets = []; + if (interiorGroupRef.current) extraTargets.push(interiorGroupRef.current); + const rayHits = raycaster.intersectObjects(objects.concat(extraTargets), true); + console.log('[INTERIOR CLICK] rayHits count:', rayHits.length); + const hits = rayHits.filter(h => { + const obj = h.object; + // учитываем payload на мешах и на родителях + if (obj && obj.userData && (obj.userData.interactable || obj.userData.payload || obj.userData.isNpc)) return true; + let p = obj; + while (p && p.parent) { + p = p.parent; + if (p.userData && (p.userData.interactable || p.userData.payload || p.userData.isNpc)) return true; + } + return false; + }); + console.log('[INTERIOR CLICK] interactable hits count:', hits.length); + if (hits.length) { const top = hits[0].object; - const payload = top.userData.payload || {}; - // Дальше делай что нужно: диалог, меню, действие и т.п. + // поднимаем до узла, где лежит payload + let node = top; + while (node && !node.userData?.payload && node.parent) node = node.parent; + let payload = (node && node.userData && node.userData.payload) || (top.userData.payload) || {}; + // Если у попавшего меша нет payload, но это часть NPC, поднимемся до isNpc + if ((!payload || !payload.type) && node) { + let p = node; + while (p && !p.userData?.isNpc && p.parent) p = p.parent; + if (p && p.userData?.npcId) { + payload = { type: 'npc', id: p.userData.npcId }; + } + } + console.log('[INTERIOR CLICK] payload:', payload); if (payload.type === 'marker') { console.log('Нажат маркер:', payload); - // например, открыть окно диалога/описания - // setCurrentDialog(...); setShowDialog(true); } else if (payload.type === 'npc') { console.log('Нажат NPC:', payload); - // loadDialog(payload.id) и т.п. + try { if (payload.id) { loadDialog(payload.id); } } catch (_) {} } else { console.log('Интерактив:', payload); } + return; + } + + // Если своих интерактивов не нашли, пробуем поймать NPC из общего массива npcMeshes + try { + const npcHit = raycaster.intersectObjects(npcMeshesRef.current || [], true); + console.log('[INTERIOR CLICK] npcMeshes hits:', npcHit.length); + if (npcHit.length) { + let root = npcHit[0].object; + while (root.parent && !root.userData?.isNpc) root = root.parent; + if (root.userData && root.userData.npcId) { + console.log('[INTERIOR CLICK] NPC root found:', root.userData.npcId); + if (root.userData.npcId === 'Computer') { + setShowMiniGame(true); + setPasswordCorrect(false); + setAudioUrl('/audio/firs.ogg'); + addSeregaComment('Ну чё, хакер, разберёшься?'); + } else { + loadDialog(root.userData.npcId); + } + return; + } + } + } catch (e) { + console.warn('[INTERIOR CLICK] npcMeshes raycast failed:', e); + } }; - window.addEventListener('click', onClick); - return () => window.removeEventListener('click', onClick); + const target = rendererRef.current ? rendererRef.current.domElement : window; + target.addEventListener('click', onClick); + target.addEventListener('pointerdown', onClick); + return () => { target.removeEventListener('click', onClick); target.removeEventListener('pointerdown', onClick); }; }, []); async function buyItem(key) { @@ -1492,9 +1773,6 @@ useEffect(() => { function toggleWorldVisibility(visible) { groundRef.current && (groundRef.current.visible = visible); cityMeshesRef.current.forEach(m => m.visible = visible); - Object.values(remotePlayersRef.current).forEach(p => { - if (p.model) p.model.visible = visible; - }); } function createInterior() { @@ -1758,7 +2036,11 @@ useEffect(() => { }); } - async function addOtherPlayer(id, x, z, avatarURL, genderRemote = 'male', firstName = '', lastName = '') { + 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'); @@ -1793,7 +2075,7 @@ useEffect(() => { ); } model.scale.set(1, 1, 1); - model.position.set(x, 0, z); + model.position.set(x, y || 0, z); scene.add(model); const fullname = `${firstName} ${lastName}`.trim(); @@ -2088,26 +2370,45 @@ useEffect(() => { socket.on('connect', () => console.log('Socket connected, id=', socket.id)); socket.on('currentPlayers', (players) => { console.log('currentPlayers', players); - // Получаем cityId текущего игрока из профиля + // Получаем профиль (только для ФИО/аватара) const myProfile = JSON.parse(sessionStorage.getItem('user_profile') || '{}'); - const myCityId = myProfile.last_city_id || 1; + // Добавляем/обновляем игроков из пришедшего списка Object.keys(players).forEach(id => { if (id === socket.id) return; - const { x, z, avatarURL, gender, firstName, lastName, cityId } = players[id]; - if (cityId && cityId !== myCityId) return; // показываем только игроков своего города - addOtherPlayer(id, x, z, avatarURL, gender, firstName, lastName); + const { x, y, z, avatarURL, gender, firstName, lastName } = players[id]; + if (!remotePlayers[id]) { + addOtherPlayer(id, x, z, avatarURL, gender, firstName, lastName, y); + } }); - // После получения списка игроков, отправляем newPlayer о себе - const profile = myProfile; - socket.emit('newPlayer', { - x: player?.position?.x || 0, - z: player?.position?.z || 0, - avatarURL: avatarUrl, - firstName: profile.firstName, - lastName: profile.lastName, - userId: profile.id, - cityId: myCityId + // Удаляем тех, кого нет в актуальном списке (после входа/выхода из интерьера и т.п.) + const validIds = new Set(Object.keys(players)); + Object.keys(remotePlayers).forEach((rid) => { + if (rid === socket.id) return; + if (!validIds.has(rid)) { + if (remotePlayers[rid] && remotePlayers[rid].model) { + scene.remove(remotePlayers[rid].model); + } + delete remotePlayers[rid]; + if (voiceIcons.current[rid]) delete voiceIcons.current[rid]; + cleanupVoiceConnection(rid); + } }); + + // После получения списка игроков, отправляем newPlayer о себе ТОЛЬКО когда мы не в интерьере + // Отправляем себя только если это первый коннект и ещё не отправляли + if (!window.__newPlayerSentOnce) { + const profile = myProfile; + socket.emit('newPlayer', { + x: player?.position?.x || 0, + y: player?.position?.y || 0, + z: player?.position?.z || 0, + avatarURL: avatarUrl, + firstName: profile.firstName, + lastName: profile.lastName, + userId: profile.id + }); + window.__newPlayerSentOnce = true; + } }); socket.on('chatMessage', ({ playerId, name, message, position }) => { @@ -2149,7 +2450,7 @@ useEffect(() => { const remote = remotePlayers[data.playerId]; if (!remote) return; - const newPos = new THREE.Vector3(data.x, 0, data.z); + 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); @@ -2204,6 +2505,8 @@ useEffect(() => { return; } + // Если мы сейчас внутри интерьера, показывать новых игроков следует только когда они тоже будут в нашем списке currentPlayers, + // который уже фильтруется сервером по interiorId. Здесь просто добавляем как обычно. addOtherPlayer(playerId, x, z, avatarURL, gender, firstName, lastName); }); @@ -2256,6 +2559,7 @@ useEffect(() => { function onMouseLookMove(e) { if (!isInInteriorRef.current || cameraRef.current !== fpCamRef.current || !playerRef.current) return; + if (altHeldRef.current) return; // при зажатом Alt не вращаем камеру // Throttling - обрабатываем только каждые 8ms (120fps для более плавного движения) if (mouseMoveTimeout) return; @@ -2402,22 +2706,7 @@ useEffect(() => { return; } - // Обработка событий pointer lock - if (renderer && renderer.domElement) { - renderer.domElement.addEventListener('click', () => { - if (isInInteriorRef.current && !document.pointerLockElement) { - renderer.domElement.requestPointerLock(); - } - }); - } - - document.addEventListener('pointerlockchange', () => { - if (renderer && renderer.domElement && document.pointerLockElement === renderer.domElement) { - console.log('Pointer lock activated'); - } else { - console.log('Pointer lock deactivated'); - } - }); + // Pointer lock больше не используется в интерьере — курсор всегда активен // Проверяем, что THREE.PlaneGeometry доступен if (!THREE.PlaneGeometry) { @@ -2540,7 +2829,7 @@ useEffect(() => { const npcData = [ { id: 'bartender', model: '/models/npc/bartender.glb', position: [0, 0, 10] }, { id: 'guard', model: '/models/npc/guard.glb', position: [0, 0, 5] }, - { id: 'Adventurer', model: '/models/npc/Adventurer.glb', position: [0, 0, -5] }, + { id: '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] } @@ -2605,6 +2894,7 @@ useEffect(() => { model.rotateY(Math.PI); // Развернуть персонажа scene.add(model); npcMeshes.push(model); // Правильное добавление в массив + npcMeshesRef.current.push(model); cityMeshesRef.current.push(model); if (npc.id == 'Computer') { @@ -2734,14 +3024,7 @@ useEffect(() => { updateCameraFollow(); - socketRef.current?.emit('newPlayer', { - x: player.position.x, - z: player.position.z, - avatarURL: avatarUrl, - firstName: profile.firstName, - lastName: profile.lastName, - userId: profile.id - }); + // Не отправляем здесь newPlayer — делаем это централизованно после currentPlayers } catch (err) { console.error("Ошибка загрузки модели игрока:", err); console.error("Детали ошибки:", { @@ -3148,15 +3431,11 @@ useEffect(() => { function onKeyDown(event) { keys[event.key] = true; + if (event.key === 'Alt') altHeldRef.current = true; console.log('onKeyDown:', event.key, 'isInInteriorRef.current:', isInInteriorRef.current); - // Обработка клавиши Escape для выхода из интерьера - if (event.key === 'Escape' && isInInteriorRef.current) { - console.log('Escape нажата - выходим из интерьера'); - exitInterior(); - return; - } + // ESC больше не выходит из интерьера if (isInInteriorRef.current) { console.log('Обрабатываем клавишу в интерьере:', event.key); @@ -3184,6 +3463,7 @@ useEffect(() => { function onKeyUp(event) { keys[event.key] = false; + if (event.key === 'Alt') altHeldRef.current = false; if (isInInteriorRef.current) { const k = event.key.toLowerCase(); if (k === 'arrowup' || k === 'w') stopMove('forward'); @@ -3349,7 +3629,7 @@ useEffect(() => { const angle = Math.atan2(dir.x, dir.z); const targetQuat = new THREE.Quaternion().setFromEuler(new THREE.Euler(0, angle, 0)); player.quaternion.slerp(targetQuat, Math.min(1, 10 * delta)); - socketRef.current?.emit('playerMovement', { x: player.position.x, z: player.position.z }); + socketRef.current?.emit('playerMovement', { x: player.position.x, y: player.position.y, z: player.position.z }); if (currentAction !== walkAction) { currentAction.fadeOut(0.2); @@ -3480,19 +3760,53 @@ useEffect(() => { const speed = 2; // Уменьшаем скорость для более плавного движения в интерьере const rotSpeed = Math.PI * 0.5; // Уменьшаем скорость поворота + // Проверка триггера выхода по внутренней точке + if (interiorExitPosRef.current && player.position.distanceTo(interiorExitPosRef.current) < 0.7) { + exitInterior(); + return; + } + // Поворот влево-вправо (A/D или стрелки) if (move.left) player.rotation.y += rotSpeed * delta; if (move.right) player.rotation.y -= rotSpeed * delta; + // Камера следует за вращением тела + const headHeight = 1.6; + const camBase = new THREE.Vector3(player.position.x, player.position.y + headHeight, player.position.z); + const camForward = new THREE.Vector3(0, 0, -0.08).applyEuler(new THREE.Euler(0, player.rotation.y, 0)); + fpCamRef.current.position.copy(camBase.add(camForward)); + const lookForward = new THREE.Vector3(0, 0, -1).applyEuler(new THREE.Euler(0, player.rotation.y, 0)); + fpCamRef.current.lookAt(fpCamRef.current.position.clone().add(lookForward)); - // Движение вперед-назад (W/S или стрелки) + // Движение с проверкой коллизий + 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); - if (move.forward) player.position.addScaledVector(forward, speed * delta); - if (move.backward) player.position.addScaledVector(forward, -speed * delta); - - // Боковое движение (Q/E для strafe, если нужно) const right = new THREE.Vector3(1, 0, 0).applyQuaternion(player.quaternion); - if (move.strafeLeft) player.position.addScaledVector(right, -speed * delta); - if (move.strafeRight) player.position.addScaledVector(right, speed * delta); + 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() { @@ -3549,7 +3863,8 @@ useEffect(() => { } // Обновляем движение игрока - if (typeof updateDestinationMovement === 'function') { + // В интерьере отключаем автодвижение по кликам (двигаемся только WASD) + if (!isInInteriorRef.current && typeof updateDestinationMovement === 'function') { updateDestinationMovement(delta); } if (typeof updateFirstPersonMovement === 'function') { diff --git a/src/components/DialogSystem/DialogManager.js b/src/components/DialogSystem/DialogManager.js index c8083ba..520bc08 100644 --- a/src/components/DialogSystem/DialogManager.js +++ b/src/components/DialogSystem/DialogManager.js @@ -7,13 +7,13 @@ export const useDialogManager = () => { const [formData, setFormData] = useState({}); const [currentForm, setCurrentForm] = useState(null); - // + // ������� ��� �������� ������ � ������������ ������� �� ������ const markDialogAsListened = async (jsonFilename) => { try { - // + // ��������� ������ ��� ����� ��� ���� const filename = jsonFilename.split('/').pop().split('\\').pop(); console.log('Normalized filename:', filename); - console.log(" 111"); + console.log("����� � �� ���111�"); const token = localStorage.getItem('token'); const response = await fetch('/api/listen', { method: 'POST', @@ -22,17 +22,18 @@ export const useDialogManager = () => { 'Authorization': `Bearer ${token}` }, body: JSON.stringify({ - player_id: JSON.parse(sessionStorage.getItem('user_profile')).email, + // player_id больше не обязателен: сервер возьмёт его из токена/сессии при наличии json_filename: filename }) }); - console.log(" 3455654"); + console.log("����� � �� ����3455654"); if (!response.ok) { - console.error(' '); + const txt = await response.text().catch(()=> ''); + console.error('Ошибка при записи прослушанного:', response.status, txt); } } catch (error) { - console.error(' :', error); + console.error('Ошибка сети при записи прослушанного:', error); } }; @@ -44,25 +45,25 @@ export const useDialogManager = () => { setDialogIndex(0); setShowDialog(true); } catch (error) { - console.error(' :', error); + console.error('������ �������� �������:', error); } }; const handleAnswerSelect = async (answer) => { - console.log('[Debug] Answer object:', answer); // <- ? - console.log('[Debug] "end" in answer:', 'end' in answer); // <- end? + console.log('[Debug] Answer object:', answer); // <- ��� ����� ���������? + console.log('[Debug] "end" in answer:', 'end' in answer); // <- ���� �� ���� end? if (answer.end !== undefined) { console.log('[Debug] Dialog end triggered!'); - // + // ��� ���������� ������� �������� ��� ��� ������������ if (currentDialog?.filename) { await markDialogAsListened(currentDialog.filename); - console.log(" "); + console.log("����� � �� ����"); } 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); - console.log(" , "); + console.log("����� � �� ����, �� ���� ��� ����"); if (nextNode && nextNode.type === 'form') { setCurrentForm(nextNode); return; @@ -73,12 +74,12 @@ export const useDialogManager = () => { if (nextIndex !== -1) { setDialogIndex(nextIndex); } else { - console.error(' :', answer.next); + console.error('���������� ���� �� ������:', answer.next); setShowDialog(false); } } else if (answer.next == answer.end) { - console.log(" "); + console.log("���� ���� ����"); } else { setShowDialog(false); @@ -92,9 +93,9 @@ export const useDialogManager = () => { if (nextIndex !== -1) { setDialogIndex(nextIndex); setCurrentForm(null); - console.log(' :', formData); + console.log('������������ ������:', formData); - // , + // ���� ��� ��������� ���� �����, �������� ������ ��� ������������ const nextNode = currentDialog.dialog[nextIndex]; if (nextNode.end && currentDialog?.filename) { await markDialogAsListened(currentDialog.filename);