From 528c2b1db483f1416414da1dac809670ba378486 Mon Sep 17 00:00:00 2001
From: Iprok
Date: Mon, 18 Aug 2025 17:27:14 +0300
Subject: [PATCH] =?UTF-8?q?=D0=A1=D0=BB=D0=BE=D0=BC=D0=B0=D0=BD=D0=BD?=
=?UTF-8?q?=D0=B0=D1=8F=20=D0=B2=D0=B5=D1=80=D1=81=D0=B8=D1=8F?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
loading-overlay.patch | 150 +++++++
.../{Bar scene.glb => bar_scene.glb} | Bin
saves/game_time.json | 2 +-
server.js | 174 ++++++--
src/Game.js | 402 ++++++++++++++----
5 files changed, 588 insertions(+), 140 deletions(-)
create mode 100644 loading-overlay.patch
rename public/models/interiors/{Bar scene.glb => bar_scene.glb} (100%)
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}
-
+
{selectedHouse.organizationId && (
<>