diff --git a/loading-overlay.patch b/loading-overlay.patch new file mode 100644 index 0000000..a857ab1 --- /dev/null +++ b/loading-overlay.patch @@ -0,0 +1,150 @@ +diff --git a/src/Game.js b/src/Game.js +--- a/src/Game.js ++++ b/src/Game.js +@@ -28,9 +28,12 @@ function Game({ avatarUrl, gender }) { + // 2) реф для группы «города» + const cityGroupRef = useRef(null); + + // 3) реф для группы «интерьера» + const interiorGroupRef = useRef(null); +- const cleanupTimerRef = useRef(null); ++ const cleanupTimerRef = useRef(null); ++ // Глобальный менеджер прогресса загрузки (используем в GLTFLoader) ++ const loadingManagerRef = useRef(null); ++ + // камеры + const orthoCamRef = useRef(null); + const fpCamRef = useRef(null); + const cameraRef = useRef(null); + const rendererRef = useRef(null); +@@ -347,6 +350,7 @@ function Game({ avatarUrl, gender }) { + })); + }, []); + + //Телефон ++ + const scene = new THREE.Scene(); + const playerRef = useRef(null); + const cityMeshesRef = useRef([]); + const cityObjectsDataRef = useRef([]); + const loadedCityObjectsRef = useRef({}); +@@ -744,6 +748,59 @@ function Game({ avatarUrl, gender }) { + if (!mount) { + console.log('[DEBUG] mountRef.current не определён!'); + return; + } + ++ // ───────────────────────────────────────────── ++ // Красивый загрузочный оверлей + LoadingManager ++ // ───────────────────────────────────────────── ++ let overlayEl = null, barEl = null, textEl = null; ++ function createLoadingOverlay() { ++ if (overlayEl) return; ++ overlayEl = document.createElement('div'); ++ Object.assign(overlayEl.style, { ++ position: 'fixed', inset: '0', zIndex: 2000, ++ display: 'flex', flexDirection: 'column', ++ alignItems: 'center', justifyContent: 'center', ++ background: 'linear-gradient(135deg,#0f172a,#1e293b)', ++ color: '#fff', fontFamily: 'system-ui, Arial, sans-serif' ++ }); ++ textEl = document.createElement('div'); ++ Object.assign(textEl.style, { ++ fontSize: '24px', fontWeight: 700, opacity: 0.9, marginBottom: '16px' ++ }); ++ textEl.textContent = 'Загрузка ресурсов...'; ++ overlayEl.appendChild(textEl); ++ const barWrap = document.createElement('div'); ++ Object.assign(barWrap.style, { ++ width: '320px', height: '10px', ++ background: 'rgba(255,255,255,0.15)', ++ borderRadius: '999px', overflow: 'hidden', ++ boxShadow: '0 6px 20px rgba(0,0,0,0.35)' ++ }); ++ barEl = document.createElement('div'); ++ Object.assign(barEl.style, { ++ width: '0%', height: '100%', ++ transition: 'width .15s ease', ++ background: 'linear-gradient(90deg,#22d3ee,#38bdf8,#60a5fa)' ++ }); ++ barWrap.appendChild(barEl); ++ overlayEl.appendChild(barWrap); ++ const pct = document.createElement('div'); ++ Object.assign(pct.style, { marginTop: '12px', fontSize: '14px', opacity: 0.8 }); ++ pct.id = 'loadingPct'; ++ pct.textContent = '0%'; ++ overlayEl.appendChild(pct); ++ document.body.appendChild(overlayEl); ++ } ++ function updateLoadingOverlay(percent, text) { ++ if (!overlayEl) return; ++ const p = Math.max(0, Math.min(100, Math.round(percent || 0))); ++ if (barEl) barEl.style.width = p + '%'; ++ const pct = overlayEl.querySelector('#loadingPct'); ++ if (pct) pct.textContent = p + '%'; ++ if (text && textEl) textEl.textContent = text; ++ } ++ function removeLoadingOverlay() { ++ if (!overlayEl) return; ++ overlayEl.style.transition = 'opacity .2s ease'; ++ overlayEl.style.opacity = '0'; ++ setTimeout(() => { ++ overlayEl && overlayEl.remove(); ++ overlayEl = barEl = textEl = null; ++ }, 220); ++ } ++ // Общий менеджер загрузки (для GLTF/Texture и т.п.) ++ const loadingManager = new THREE.LoadingManager(); ++ loadingManagerRef.current = loadingManager; ++ loadingManager.onStart = (_url, loaded, total) => { ++ createLoadingOverlay(); ++ updateLoadingOverlay(total ? (loaded / total) * 100 : 5, 'Загрузка ресурсов...'); ++ }; ++ loadingManager.onProgress = (_url, loaded, total) => { ++ updateLoadingOverlay(total ? (loaded / total) * 100 : 50); ++ }; ++ loadingManager.onLoad = () => { ++ updateLoadingOverlay(100, 'Инициализация сцены...'); ++ setTimeout(removeLoadingOverlay, 150); ++ }; ++ + console.log('–– useEffect начало'); + + const baseOffset = new THREE.Vector3(-200, 150, -200); + const planarDist = Math.hypot(baseOffset.x, baseOffset.z); + const radius = Math.hypot(planarDist, baseOffset.y); +@@ -825,8 +882,9 @@ function Game({ avatarUrl, gender }) { + socket.on('economy:inventory', setInventory); + socket.on('gameTime:update', ({ time }) => setGameTime(time)); +- const gltfLoader = new GLTFLoader(); +- const animLoader = new GLTFLoader(); ++ // Лоадеры, учитывающиеся в прогрессе через loadingManagerRef ++ const gltfLoader = new GLTFLoader(loadingManagerRef.current || undefined); ++ const animLoader = new GLTFLoader(loadingManagerRef.current || undefined); + + async function loadPlayerModel(avatarUrl) { + return new Promise((resolve, reject) => { + gltfLoader.load(avatarUrl, (gltf) => { + if (!gltf.scene) return reject('GLTF.scene отсутствует'); +@@ -1168,6 +1226,18 @@ function Game({ avatarUrl, gender }) { + setSelectedHouse(null); + } + ++ // Мини-лоадер при загрузке интерьеров (обёртка поверх loadInteriorScene) ++ const _origLoadInteriorScene = loadInteriorScene; ++ loadInteriorScene = async (interiorId) => { ++ try { ++ // показываем мини-оверлей на время подзагрузки интерьера ++ createLoadingOverlay(); ++ updateLoadingOverlay(30, 'Загрузка интерьера...'); ++ await _origLoadInteriorScene(interiorId); ++ } finally { ++ setTimeout(removeLoadingOverlay, 120); ++ } ++ }; ++ + function onMouseWheel(e) { + e.preventDefault(); + const delta = -e.deltaY * 0.001; + + if (e.ctrlKey) { diff --git a/public/models/interiors/Bar scene.glb b/public/models/interiors/bar_scene.glb similarity index 100% rename from public/models/interiors/Bar scene.glb rename to public/models/interiors/bar_scene.glb diff --git a/saves/game_time.json b/saves/game_time.json index 12e9629..afdd7c8 100644 --- a/saves/game_time.json +++ b/saves/game_time.json @@ -1 +1 @@ -{"time":"2025-01-15T14:56:30.000Z","lastReal":1755191664308} \ No newline at end of file +{"time":"2025-02-14T12:10:49.296Z","lastReal":1755514421720} \ No newline at end of file diff --git a/server.js b/server.js index 7037f37..9cc9a92 100644 --- a/server.js +++ b/server.js @@ -1,38 +1,55 @@ -require('dotenv').config(); -const express = require('express'); -const db = require('./db'); -const Economy = require('./economy'); -const GameTime = require('./gameTime'); -const path = require('path'); -const fs = require('fs'); -const app = express(); -const organizationsRouter = require('./server/organizations'); - -const { virtualWorldPool } = require('./db1'); - -async function ensureMessagesTable() { - try { - await virtualWorldPool.query(` - CREATE TABLE IF NOT EXISTS messages ( - id SERIAL PRIMARY KEY, - sender_id INTEGER NOT NULL, - receiver_id INTEGER NOT NULL, - message TEXT NOT NULL, - created_at TIMESTAMPTZ DEFAULT NOW(), - is_read BOOLEAN DEFAULT FALSE - ) - `); - } catch (e) { - console.error('Ошибка создания таблицы messages', e); - } +let dotenv, express, db, Economy, GameTime, pathLib, fs, virtualWorldPool; +try { + dotenv = require('dotenv').config(); + console.log('dotenv успешно импортирован'); +} catch (e) { + console.error('Ошибка при импорте dotenv:', e); + throw e; +} +try { + express = require('express'); + console.log('express успешно импортирован'); +} catch (e) { + console.error('Ошибка при импорте express:', e); + throw e; +} +try { + db = require('./db'); + console.log('db успешно импортирован'); +} catch (e) { + console.error('Ошибка при импорте db:', e); + throw e; +} +try { + Economy = require('./economy'); + console.log('Economy успешно импортирован'); +} catch (e) { + console.error('Ошибка при импорте economy:', e); + throw e; +} +try { + GameTime = require('./gameTime'); + console.log('GameTime успешно импортирован'); +} catch (e) { + console.error('Ошибка при импорте gameTime:', e); + throw e; +} +try { + pathLib = require('path'); + console.log('path успешно импортирован'); +} catch (e) { + console.error('Ошибка при импорте path:', e); + throw e; +} +try { + fs = require('fs'); + console.log('fs успешно импортирован'); +} catch (e) { + console.error('Ошибка при импорте fs:', e); + throw e; } -ensureMessagesTable(); - - -app.use(express.json()); -app.use(express.urlencoded({ extended: true })); -app.use('/api/organizations', organizationsRouter); +const app = express(); const http = require('http').createServer(app); const io = require('socket.io')(http, { @@ -47,10 +64,11 @@ const io = require('socket.io')(http, { methods: ['GET', 'POST'] } }); -const economy = new Economy(io, db); -const gameTime = new GameTime(io, 8); -let onlineUsers = {}; +let onlineUsers = {}; + +const organizationsRouter = require('./server/organizations')(io, onlineUsers); +app.use('/api/organizations', organizationsRouter); io.use((socket, next) => { const token = socket.handshake.auth.token; @@ -58,7 +76,7 @@ io.use((socket, next) => { try { const payload = jwt.verify(token, process.env.JWT_SECRET); socket.userId = payload.id; - onlineUsers[socket.userId] = socket.id; // Добавить пользователя в онлайн + onlineUsers[socket.userId] = socket.id; // Добавить пользователя в онлайн next(); } catch (err) { next(new Error('Invalid token')); @@ -80,10 +98,10 @@ function authenticate(req, res, next) { } } -app.use(express.static(path.join(__dirname, 'build'))); +app.use(express.static(pathLib.join(__dirname, 'build'))); app.use( '/models', - express.static(path.join(__dirname, 'public', 'models')) + express.static(pathLib.join(__dirname, 'public', 'models')) ); let players = {}; @@ -538,7 +556,10 @@ app.get('/api/players/:socketId', authenticate, async (req, res) => { stress_level AS "stressLevel", satiety, thirst, - diseases + diseases, + last_city_id AS "last_city_id", + last_pos_x AS "last_pos_x", + last_pos_z AS "last_pos_z" FROM users WHERE id = $1 `, [dbId]); @@ -634,7 +655,7 @@ app.get('/api/cities/:cityId/objects', authenticate, async (req, res) => { // Получить список доступных моделей из public/models/copied app.get('/api/models', authenticate, async (req, res) => { try { - const dir = path.join(__dirname, 'public', 'models', 'copied'); + const dir = pathLib.join(__dirname, 'public', 'models', 'copied'); const files = await fs.promises.readdir(dir); const glbs = files.filter(f => f.toLowerCase().endsWith('.glb')); res.json(glbs); @@ -665,12 +686,42 @@ 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( + 'SELECT city_id, spawn_x, spawn_y, spawn_z, spawn_rot, exit_x, exit_y, exit_z, exit_rot FROM interiors WHERE id = $1', + [interiorId] + )).rows[0]; + if (!interior) return res.status(404).json({ error: 'Интерьер не найден' }); + res.json({ + cityId: interior.city_id || 1, + spawn: { + x: interior.spawn_x, + y: interior.spawn_y, + z: interior.spawn_z, + rot: interior.spawn_rot + }, + exit: { + x: interior.exit_x, + y: interior.exit_y, + z: interior.exit_z, + rot: interior.exit_rot + }, + }); + } catch (e) { + console.error(e); + res.status(500).json({ error: 'Не удалось получить координаты интерьера' }); + } +}); + // server.js, после маршрута /api/city_objects/:objectId/interior app.get('/api/interiors/:interiorId/definition', authenticate, async (req, res) => { const interiorId = parseInt(req.params.interiorId, 10); try { const interior = (await db.query( - 'SELECT glb_filename, pos_x, pos_y, pos_z FROM interiors WHERE id = $1', + 'SELECT glb_filename, pos_x, pos_y, pos_z, spawn_x, spawn_y, spawn_z, spawn_rot FROM interiors WHERE id = $1', [interiorId] )).rows[0]; if (!interior) return res.status(404).json({ error: 'Интерьер не найден' }); @@ -686,6 +737,7 @@ app.get('/api/interiors/:interiorId/definition', authenticate, async (req, res) res.json({ glb: `/models/interiors/${interior.glb_filename}`, position: { x: interior.pos_x, y: interior.pos_y, z: interior.pos_z }, + spawn: { x: interior.spawn_x, y: interior.spawn_y, z: interior.spawn_z, rot: interior.spawn_rot }, objects }); } catch (e) { @@ -1113,10 +1165,10 @@ app.post('/api/save-map', authenticate, async (req, res) => { return res.status(400).json({ error: 'Invalid objects' }); } try { - const dir = path.join(__dirname, 'saves'); + const dir = pathLib.join(__dirname, 'saves'); await fs.promises.mkdir(dir, { recursive: true }); const file = `city_${cityId}_${Date.now()}.txt`; - const filePath = path.join(dir, file); + const filePath = pathLib.join(dir, file); await fs.promises.writeFile( filePath, JSON.stringify({ objects, removedIds }, null, 2), @@ -1145,10 +1197,40 @@ app.get('/api/cities', authenticate, async (req, res) => { }); app.use((req, res) => { - res.sendFile(path.join(__dirname, 'build', 'index.html')); + res.sendFile(pathLib.join(__dirname, 'build', 'index.html')); }); const PORT = process.env.PORT || 4000; http.listen(PORT, () => { console.log(`Server is running on port ${PORT}`); -}); \ No newline at end of file +}); + +// Логирование всех маршрутов и middleware +['get', 'post', 'put', 'delete', 'use'].forEach(method => { + const orig = app[method]; + app[method] = function(path, ...args) { + if (typeof path === 'string') { + console.log(`Регистрируется ${method.toUpperCase()} маршрут:`, path); + } else if (typeof path === 'function') { + console.log(`Регистрируется middleware (без пути) через ${method}`); + } + return orig.call(this, path, ...args); + }; +}); + +// После ensureMessagesTable(); +async function ensureInteriorsSpawnColumns() { + try { + await db.query('ALTER TABLE interiors ADD COLUMN IF NOT EXISTS spawn_x NUMERIC DEFAULT 0'); + await db.query('ALTER TABLE interiors ADD COLUMN IF NOT EXISTS spawn_y NUMERIC DEFAULT 0'); + await db.query('ALTER TABLE interiors ADD COLUMN IF NOT EXISTS spawn_z NUMERIC DEFAULT 0'); + await db.query('ALTER TABLE interiors ADD COLUMN IF NOT EXISTS spawn_rot NUMERIC DEFAULT 0'); + await db.query('ALTER TABLE interiors ADD COLUMN IF NOT EXISTS exit_x NUMERIC DEFAULT 0'); + await db.query('ALTER TABLE interiors ADD COLUMN IF NOT EXISTS exit_y NUMERIC DEFAULT 0'); + await db.query('ALTER TABLE interiors ADD COLUMN IF NOT EXISTS exit_z NUMERIC DEFAULT 0'); + await db.query('ALTER TABLE interiors ADD COLUMN IF NOT EXISTS exit_rot NUMERIC DEFAULT 0'); + } catch (e) { + console.error('Ошибка добавления spawn/exit-колонок в interiors', e); + } +} +ensureInteriorsSpawnColumns(); \ No newline at end of file diff --git a/src/Game.js b/src/Game.js index b90e809..98264b1 100644 --- a/src/Game.js +++ b/src/Game.js @@ -22,9 +22,48 @@ function Game({ avatarUrl, gender }) { // 2) реф для группы «города» const cityGroupRef = useRef(null); + /** + * Безопасно получает .current у рефа. Если сам ref == null ИЛИ ref.current == null, + * вернёт null и залогирует понятную причину. + */ + function getRef(ref, name = 'ref') { + if (ref === null) { + console.error(`[REF] ${name} variable is null (handler called before init?)`); + return null; + } + if (typeof ref !== 'object' || !('current' in ref)) { + console.error(`[REF] ${name} is not a ref-like object`); + return null; + } + if (ref.current == null) { + console.warn(`[REF] ${name}.current is not ready yet`); + return null; + } + return ref.current; + } + + /** + * Удобные однотипные геттеры — сокращают повтор. + */ + const getScene = () => getRef(sceneRef, 'sceneRef'); + const getPlayer = () => getRef(playerRef, 'playerRef'); + const getCityGroup = () => getRef(cityGroupRef, 'cityGroupRef'); + const getExitMarker = () => getRef(exitMarkerRef, 'exitMarkerRef'); + + /** + * Быстрые проверки перед действиями, требующими инициализации 3D. + */ + const ensureSceneAndPlayer = () => !!(getScene() && getPlayer()); + + // 3) реф для группы «интерьера» const interiorGroupRef = useRef(null); - const cleanupTimerRef = useRef(null); + const cleanupTimerRef = useRef(null); + // Глобальный менеджер прогресса загрузки (используем в GLTFLoader) + const loadingManagerRef = useRef(null); + // Кликабельные объекты внутри интерьера + const interiorInteractablesRef = useRef([]); + // камеры const orthoCamRef = useRef(null); const fpCamRef = useRef(null); @@ -40,8 +79,7 @@ function Game({ avatarUrl, gender }) { const [selectedHouse, setSelectedHouse] = useState(null); const [isInInterior, setIsInInterior] = useState(false); - const [interiorGroup, setInteriorGroup] = useState(null); - const mountRef = useRef(null); + const [mountRef, setMountRef] = useState(null); const socketRef = useRef(null); useEffect(() => { @@ -104,6 +142,8 @@ function Game({ avatarUrl, gender }) { const [isChatVisible, setIsChatVisible] = useState(true); const [seregaComments, setSeregaComments] = useState([]); + const [currentExit, setCurrentExit] = useState(null); + useEffect(() => { const decay = setInterval(() => { setSatiety(s => Math.max(0, s - 0.05)); @@ -517,8 +557,13 @@ function Game({ avatarUrl, gender }) { const glbUrl = baseUrl + glb; console.log('Loading GLB from', glbUrl); + // подстраховка: перед загрузкой проверяем, что URL физически отдает не HTML + const headResp = await fetch(glbUrl, { method: 'HEAD', cache: 'no-cache' }); + if (!headResp.ok) throw new Error(`GLB not reachable: HTTP ${headResp.status}`); const gltf = await loadGLTF(glbUrl); + + const scene = sceneRef.current; savedPositionRef.current.copy(playerRef.current.position); toggleWorldVisibility(false); @@ -528,6 +573,8 @@ function Game({ avatarUrl, gender }) { intGroup.name = 'interiorGroup'; intGroup.add(gltf.scene); + interiorInteractablesRef.current = []; // сбрасываем реестр интерактива + for (const o of objects) { if (o.model_url) { try { @@ -544,8 +591,25 @@ 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); - intGroup.add(mesh); - } + // по умолчанию делаем «чистую» геометрию… + intGroup.add(mesh); + } + // Если сервер прислал «маркер»/NPC — пометим кликабельным + // (ожидаем флаг o.interactable и/или o.marker === true) + if (o.interactable || o.marker) { + // добавим небольшой «хитбокс» для клика + const hit = new THREE.Mesh( + new THREE.SphereGeometry(0.6), + new THREE.MeshBasicMaterial({ color: 0x00ff00, transparent: true, opacity: 0.15, depthWrite: false }) + ); + hit.position.set(o.x, o.y + 1.0, o.z); + hit.userData.interactable = true; + hit.userData.payload = { type: o.type || 'marker', id: o.id || null, label: o.label || 'Интерактив' }; + // не даем этому спрайту мешать внешним лучам + hit.raycast = hit.raycast; // оставим по умолчанию; это НЕ Sprite + intGroup.add(hit); + interiorInteractablesRef.current.push(hit); + } } const light = new THREE.AmbientLight(0xffffff, 1); @@ -553,47 +617,123 @@ function Game({ avatarUrl, gender }) { scene.add(intGroup); interiorGroupRef.current = intGroup; - setInteriorGroup(intGroup); - playerRef.current.position.set(0, 0, 0); - playerRef.current.quaternion.identity(); - switchToFirstPersonCamera(); setIsInInterior(true); setSelectedHouse(null); } - const enterInterior = async (houseId) => { + const enterInterior = async (interiorId) => { const token = localStorage.getItem('token'); if (!token) { alert('Пожалуйста, войдите в систему, чтобы войти в здание'); return; } + + // Сцена/игрок должны быть инициализированы + if (!ensureSceneAndPlayer()) return; + const scene = getScene(); + const player = getPlayer(); + try { - const res = await fetch( - `/api/city_objects/${houseId}/interior`, - { - headers: { Authorization: `Bearer ${token}` }, - credentials: 'include', - cache: 'no-cache' - } - ); + const res = await fetch(`/api/interiors/${interiorId}/enter`, { + method: 'POST', + headers: { Authorization: `Bearer ${token}` }, + credentials: 'include', + cache: 'no-cache' + }); if (!res.ok) { const errText = await res.text(); - console.error(`Ошибка ${res.status} при получении interior_id: ${errText}`); - alert(`Не удалось получить данные интерьера: ${errText}`); + console.error(`Ошибка ${res.status} при получении spawn-координат: ${errText}`); + alert(`Не удалось получить координаты интерьера: ${errText}`); return; } - let { interiorId } = await res.json(); - - if (!interiorId || interiorId < 1) { - alert('Для этого здания не задан интерьер'); + const data = await res.json(); + const { spawn, exit, cityId } = data; + // Если интерьер в другом городе — переключаем город + const profile0 = JSON.parse(sessionStorage.getItem('user_profile') || '{}'); + const myCityId0 = profile0.last_city_id || 1; + if (cityId && cityId !== myCityId0) { + socketRef.current?.emit('cityChange', { cityId }); + profile0.last_city_id = cityId; + sessionStorage.setItem('user_profile', JSON.stringify(profile0)); + } + if (!spawn) { + alert('Для этого интерьера не заданы координаты входа'); return; } - - await loadInteriorScene(interiorId); + // Телепортируем игрока в интерьер + + // Телепорт игрока + player.position.set(spawn.x, spawn.y, spawn.z); + player.rotation.y = THREE.MathUtils.degToRad(spawn.rot); + // Можно добавить сброс скорости, анимации и т.д. при необходимости + + setCurrentExit(exit || null); + // Добавляем маркер выхода + if (exit) { + addExitMarker(exit); + } } catch (e) { console.error('Failed to enter interior:', e); } }; + function addExitMarker(exit) { + // Удаляем старый маркер, если был + if (window.exitMarkerMesh && sceneRef.current) { + sceneRef.current.remove(window.exitMarkerMesh); + window.exitMarkerMesh = null; + } + // Создаём маркер выхода + const marker = new THREE.Mesh( + new THREE.SphereGeometry(0.5), + new THREE.MeshBasicMaterial({ color: 0xff0000, transparent: true, opacity: 0.5 }) + ); + marker.position.set(exit.x, exit.y, exit.z); + marker.userData.isExitMarker = true; + if (sceneRef.current) sceneRef.current.add(marker); + window.exitMarkerMesh = marker; + } + + const exitInterior = () => { + if (!currentExit) { + alert('Не заданы координаты выхода из интерьера!'); + return; + } + if (playerRef.current) { + playerRef.current.position.set(currentExit.x, currentExit.y, currentExit.z); + playerRef.current.rotation.set(0, currentExit.rot || 0, 0); + } + // Удаляем маркер выхода + if (window.exitMarkerMesh && sceneRef.current) { + sceneRef.current.remove(window.exitMarkerMesh); + window.exitMarkerMesh = null; + } + setCurrentExit(null); + }; + + // В useEffect для кликов по сцене: + useEffect(() => { + function onDocumentClick(event) { + if (!rendererRef.current || !cameraRef.current) return; + const rect = rendererRef.current.domElement.getBoundingClientRect(); + const mouse = new THREE.Vector2( + ((event.clientX - rect.left) / rect.width) * 2 - 1, + -((event.clientY - rect.top) / rect.height) * 2 + 1 + ); + const raycaster = new THREE.Raycaster(); + raycaster.setFromCamera(mouse, cameraRef.current); + const intersects = raycaster.intersectObjects(sceneRef.current.children, true); + for (let i = 0; i < intersects.length; i++) { + const obj = intersects[i].object; + if (obj.userData.isExitMarker) { + exitInterior(); + break; + } + } + } + window.addEventListener('mousedown', onDocumentClick); + return () => window.removeEventListener('mousedown', onDocumentClick); + }, [currentExit]); + /*const handleAnswerSelect = (answer) => { if (answer.end) { setShowDialog(false); @@ -954,7 +1094,7 @@ function Game({ avatarUrl, gender }) { async function movePlayerToInterior(interiorId) { - await loadInteriorScene(interiorId); + await enterInterior(interiorId); } function switchToFirstPersonCamera() { @@ -985,6 +1125,50 @@ function stopMove(dir) { moveInputRef.current[dir] = false; } + +// ───────────────────────────────────────────────────── +// КЛИКИ ВНУТРИ ИНТЕРЬЕРА (интерактивные маркеры/NPC) +// ───────────────────────────────────────────────────── +useEffect(() => { + const onClick = (e) => { + if (!isInInteriorRef.current) return; + const mount = mountRef.current; + if (!mount || !cameraRef.current) return; + + // координаты мыши в NDC + const rect = mount.getBoundingClientRect(); + const mouse = new THREE.Vector2( + ((e.clientX - rect.left) / rect.width) * 2 - 1, + -((e.clientY - rect.top) / rect.height) * 2 + 1 + ); + const raycaster = new THREE.Raycaster(); + raycaster.setFromCamera(mouse, cameraRef.current); + // Ищем пересечения по интерактивам + const objects = interiorInteractablesRef.current.filter(obj => obj?.isObject3D); + if (!objects.length) return; + const hits = raycaster.intersectObjects(objects, true) + .filter(h => h.object && h.object.userData && h.object.userData.interactable); + if (!hits.length) return; + + const top = hits[0].object; + const payload = top.userData.payload || {}; + // Дальше делай что нужно: диалог, меню, действие и т.п. + if (payload.type === 'marker') { + console.log('Нажат маркер:', payload); + // например, открыть окно диалога/описания + // setCurrentDialog(...); setShowDialog(true); + } else if (payload.type === 'npc') { + console.log('Нажат NPC:', payload); + // loadDialog(payload.id) и т.п. + } else { + console.log('Интерактив:', payload); + } + }; + + window.addEventListener('click', onClick); + return () => window.removeEventListener('click', onClick); + }, []); + async function buyItem(key) { if (!orgMenu) return; const token = localStorage.getItem('token'); @@ -1030,67 +1214,6 @@ function stopMove(dir) { }); } - 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); - } - } - - function exitInterior() { - if (!isInInterior || !playerRef.current) return; - sceneRef.current.remove(interiorGroupRef.current); - interiorGroupRef.current = null; - setInteriorGroup(null); - toggleWorldVisibility(true); - sceneRef.current.add(cityGroupRef.current); - playerRef.current.position.copy(savedPositionRef.current); - switchToThirdPersonCamera(); - setIsInInterior(false); - updateCityObjectVisibility(); - } - useEffect(() => { console.log('[DEBUG] useEffect вызван'); const mount = mountRef.current; @@ -1099,6 +1222,81 @@ function stopMove(dir) { return; } + // ───────────────────────────────────────────── + // Красивый загрузочный оверлей + LoadingManager + // ───────────────────────────────────────────── + let overlayEl = null, barEl = null, textEl = null; + function createLoadingOverlay() { + if (overlayEl) return; + overlayEl = document.createElement('div'); + Object.assign(overlayEl.style, { + position: 'fixed', inset: '0', zIndex: 2000, + display: 'flex', flexDirection: 'column', + alignItems: 'center', justifyContent: 'center', + background: 'linear-gradient(135deg,#0f172a,#1e293b)', + color: '#fff', fontFamily: 'system-ui, Arial, sans-serif' + }); + textEl = document.createElement('div'); + Object.assign(textEl.style, { + fontSize: '24px', fontWeight: 700, opacity: 0.9, marginBottom: '16px' + }); + textEl.textContent = 'Загрузка ресурсов...'; + overlayEl.appendChild(textEl); + const barWrap = document.createElement('div'); + Object.assign(barWrap.style, { + width: '320px', height: '10px', + background: 'rgba(255,255,255,0.15)', + borderRadius: '999px', overflow: 'hidden', + boxShadow: '0 6px 20px rgba(0,0,0,0.35)' + }); + barEl = document.createElement('div'); + Object.assign(barEl.style, { + width: '0%', height: '100%', + transition: 'width .15s ease', + background: 'linear-gradient(90deg,#22d3ee,#38bdf8,#60a5fa)' + }); + barWrap.appendChild(barEl); + overlayEl.appendChild(barWrap); + const pct = document.createElement('div'); + Object.assign(pct.style, { marginTop: '12px', fontSize: '14px', opacity: 0.8 }); + pct.id = 'loadingPct'; + pct.textContent = '0%'; + overlayEl.appendChild(pct); + document.body.appendChild(overlayEl); + } + function updateLoadingOverlay(percent, text) { + if (!overlayEl) return; + const p = Math.max(0, Math.min(100, Math.round(percent || 0))); + if (barEl) barEl.style.width = p + '%'; + const pct = overlayEl.querySelector('#loadingPct'); + if (pct) pct.textContent = p + '%'; + if (text && textEl) textEl.textContent = text; + } + function removeLoadingOverlay() { + if (!overlayEl) return; + overlayEl.style.transition = 'opacity .2s ease'; + overlayEl.style.opacity = '0'; + setTimeout(() => { + overlayEl && overlayEl.remove(); + overlayEl = barEl = textEl = null; + }, 220); + } + // Общий менеджер загрузки (для GLTF/Texture и т.п.) + const loadingManager = new THREE.LoadingManager(); + loadingManagerRef.current = loadingManager; + loadingManager.onStart = (_url, loaded, total) => { + createLoadingOverlay(); + updateLoadingOverlay(total ? (loaded / total) * 100 : 5, 'Загрузка ресурсов...'); + }; + loadingManager.onProgress = (_url, loaded, total) => { + updateLoadingOverlay(total ? (loaded / total) * 100 : 50); + }; + loadingManager.onLoad = () => { + updateLoadingOverlay(100, 'Инициализация сцены...'); + setTimeout(removeLoadingOverlay, 150); + }; + + console.log('–– useEffect начало'); const baseOffset = new THREE.Vector3(-200, 150, -200); @@ -1168,8 +1366,9 @@ function stopMove(dir) { socket.emit('economy:getInventory', { userId: profile.id }); socket.on('economy:inventory', setInventory); socket.on('gameTime:update', ({ time }) => setGameTime(time)); - const gltfLoader = new GLTFLoader(); - const animLoader = new GLTFLoader(); + // Лоадеры, учитывающиеся в прогрессе через loadingManagerRef + const gltfLoader = new GLTFLoader(loadingManagerRef.current || undefined); + const animLoader = new GLTFLoader(loadingManagerRef.current || undefined); async function loadPlayerModel(avatarUrl) { return new Promise((resolve, reject) => { @@ -1602,6 +1801,20 @@ function stopMove(dir) { cleanupVoiceConnection(id); }); + + // Мини-лоадер при загрузке интерьеров (обёртка поверх loadInteriorScene) + const _origLoadInteriorScene = loadInteriorScene; + loadInteriorScene = async (interiorId) => { + try { + // показываем мини-оверлей на время подзагрузки интерьера + createLoadingOverlay(); + updateLoadingOverlay(30, 'Загрузка интерьера...'); + await _origLoadInteriorScene(interiorId); + } finally { + setTimeout(removeLoadingOverlay, 120); + } + }; + function onMouseWheel(e) { e.preventDefault(); const delta = -e.deltaY * 0.001; @@ -1792,12 +2005,15 @@ function stopMove(dir) { scene.add(player); playerRef.current = player; player.scale.set(1, 1, 1); - player.position.set(0, 0, 0); + const profPos = JSON.parse(sessionStorage.getItem('user_profile') || '{}'); + const startX = Number(profPos.last_pos_x ?? 0); + const startZ = Number(profPos.last_pos_z ?? 0); + player.position.set(startX, 0, startZ); const profile = JSON.parse(sessionStorage.getItem('user_profile') || '{}'); const myName = `${profile.firstName || ''} ${profile.lastName || ''}`.trim(); - mountRef.current = myName; + setMountRef(myName); const nameLabel = createPlayerLabel(myName); nameLabel.position.set(0, 2.2, 0); @@ -2720,7 +2936,7 @@ function stopMove(dir) { Налог: {selectedHouse.tax}