Сломанная версия
This commit is contained in:
150
loading-overlay.patch
Normal file
150
loading-overlay.patch
Normal file
@@ -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) {
|
||||||
@@ -1 +1 @@
|
|||||||
{"time":"2025-01-15T14:56:30.000Z","lastReal":1755191664308}
|
{"time":"2025-02-14T12:10:49.296Z","lastReal":1755514421720}
|
||||||
162
server.js
162
server.js
@@ -1,38 +1,55 @@
|
|||||||
require('dotenv').config();
|
let dotenv, express, db, Economy, GameTime, pathLib, fs, virtualWorldPool;
|
||||||
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 {
|
try {
|
||||||
await virtualWorldPool.query(`
|
dotenv = require('dotenv').config();
|
||||||
CREATE TABLE IF NOT EXISTS messages (
|
console.log('dotenv успешно импортирован');
|
||||||
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) {
|
} catch (e) {
|
||||||
console.error('Ошибка создания таблицы messages', 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();
|
const app = express();
|
||||||
|
|
||||||
|
|
||||||
app.use(express.json());
|
|
||||||
app.use(express.urlencoded({ extended: true }));
|
|
||||||
app.use('/api/organizations', organizationsRouter);
|
|
||||||
|
|
||||||
const http = require('http').createServer(app);
|
const http = require('http').createServer(app);
|
||||||
const io = require('socket.io')(http, {
|
const io = require('socket.io')(http, {
|
||||||
@@ -47,11 +64,12 @@ const io = require('socket.io')(http, {
|
|||||||
methods: ['GET', 'POST']
|
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) => {
|
io.use((socket, next) => {
|
||||||
const token = socket.handshake.auth.token;
|
const token = socket.handshake.auth.token;
|
||||||
if (!token) return next(new Error('No token'));
|
if (!token) return next(new Error('No 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(
|
app.use(
|
||||||
'/models',
|
'/models',
|
||||||
express.static(path.join(__dirname, 'public', 'models'))
|
express.static(pathLib.join(__dirname, 'public', 'models'))
|
||||||
);
|
);
|
||||||
|
|
||||||
let players = {};
|
let players = {};
|
||||||
@@ -538,7 +556,10 @@ app.get('/api/players/:socketId', authenticate, async (req, res) => {
|
|||||||
stress_level AS "stressLevel",
|
stress_level AS "stressLevel",
|
||||||
satiety,
|
satiety,
|
||||||
thirst,
|
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
|
FROM users
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
`, [dbId]);
|
`, [dbId]);
|
||||||
@@ -634,7 +655,7 @@ app.get('/api/cities/:cityId/objects', authenticate, async (req, res) => {
|
|||||||
// Получить список доступных моделей из public/models/copied
|
// Получить список доступных моделей из public/models/copied
|
||||||
app.get('/api/models', authenticate, async (req, res) => {
|
app.get('/api/models', authenticate, async (req, res) => {
|
||||||
try {
|
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 files = await fs.promises.readdir(dir);
|
||||||
const glbs = files.filter(f => f.toLowerCase().endsWith('.glb'));
|
const glbs = files.filter(f => f.toLowerCase().endsWith('.glb'));
|
||||||
res.json(glbs);
|
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
|
// server.js, после маршрута /api/city_objects/:objectId/interior
|
||||||
app.get('/api/interiors/:interiorId/definition', authenticate, async (req, res) => {
|
app.get('/api/interiors/:interiorId/definition', authenticate, async (req, res) => {
|
||||||
const interiorId = parseInt(req.params.interiorId, 10);
|
const interiorId = parseInt(req.params.interiorId, 10);
|
||||||
try {
|
try {
|
||||||
const interior = (await db.query(
|
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]
|
[interiorId]
|
||||||
)).rows[0];
|
)).rows[0];
|
||||||
if (!interior) return res.status(404).json({ error: 'Интерьер не найден' });
|
if (!interior) return res.status(404).json({ error: 'Интерьер не найден' });
|
||||||
@@ -686,6 +737,7 @@ app.get('/api/interiors/:interiorId/definition', authenticate, async (req, res)
|
|||||||
res.json({
|
res.json({
|
||||||
glb: `/models/interiors/${interior.glb_filename}`,
|
glb: `/models/interiors/${interior.glb_filename}`,
|
||||||
position: { x: interior.pos_x, y: interior.pos_y, z: interior.pos_z },
|
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
|
objects
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -1113,10 +1165,10 @@ app.post('/api/save-map', authenticate, async (req, res) => {
|
|||||||
return res.status(400).json({ error: 'Invalid objects' });
|
return res.status(400).json({ error: 'Invalid objects' });
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const dir = path.join(__dirname, 'saves');
|
const dir = pathLib.join(__dirname, 'saves');
|
||||||
await fs.promises.mkdir(dir, { recursive: true });
|
await fs.promises.mkdir(dir, { recursive: true });
|
||||||
const file = `city_${cityId}_${Date.now()}.txt`;
|
const file = `city_${cityId}_${Date.now()}.txt`;
|
||||||
const filePath = path.join(dir, file);
|
const filePath = pathLib.join(dir, file);
|
||||||
await fs.promises.writeFile(
|
await fs.promises.writeFile(
|
||||||
filePath,
|
filePath,
|
||||||
JSON.stringify({ objects, removedIds }, null, 2),
|
JSON.stringify({ objects, removedIds }, null, 2),
|
||||||
@@ -1145,10 +1197,40 @@ app.get('/api/cities', authenticate, async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.use((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;
|
const PORT = process.env.PORT || 4000;
|
||||||
http.listen(PORT, () => {
|
http.listen(PORT, () => {
|
||||||
console.log(`Server is running on port ${PORT}`);
|
console.log(`Server is running on port ${PORT}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Логирование всех маршрутов и 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();
|
||||||
388
src/Game.js
388
src/Game.js
@@ -22,9 +22,48 @@ function Game({ avatarUrl, gender }) {
|
|||||||
// 2) реф для группы «города»
|
// 2) реф для группы «города»
|
||||||
const cityGroupRef = useRef(null);
|
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) реф для группы «интерьера»
|
// 3) реф для группы «интерьера»
|
||||||
const interiorGroupRef = useRef(null);
|
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 orthoCamRef = useRef(null);
|
||||||
const fpCamRef = useRef(null);
|
const fpCamRef = useRef(null);
|
||||||
@@ -40,8 +79,7 @@ function Game({ avatarUrl, gender }) {
|
|||||||
|
|
||||||
const [selectedHouse, setSelectedHouse] = useState(null);
|
const [selectedHouse, setSelectedHouse] = useState(null);
|
||||||
const [isInInterior, setIsInInterior] = useState(false);
|
const [isInInterior, setIsInInterior] = useState(false);
|
||||||
const [interiorGroup, setInteriorGroup] = useState(null);
|
const [mountRef, setMountRef] = useState(null);
|
||||||
const mountRef = useRef(null);
|
|
||||||
const socketRef = useRef(null);
|
const socketRef = useRef(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -104,6 +142,8 @@ function Game({ avatarUrl, gender }) {
|
|||||||
const [isChatVisible, setIsChatVisible] = useState(true);
|
const [isChatVisible, setIsChatVisible] = useState(true);
|
||||||
|
|
||||||
const [seregaComments, setSeregaComments] = useState([]);
|
const [seregaComments, setSeregaComments] = useState([]);
|
||||||
|
const [currentExit, setCurrentExit] = useState(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const decay = setInterval(() => {
|
const decay = setInterval(() => {
|
||||||
setSatiety(s => Math.max(0, s - 0.05));
|
setSatiety(s => Math.max(0, s - 0.05));
|
||||||
@@ -517,8 +557,13 @@ function Game({ avatarUrl, gender }) {
|
|||||||
const glbUrl = baseUrl + glb;
|
const glbUrl = baseUrl + glb;
|
||||||
console.log('Loading GLB from', glbUrl);
|
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 gltf = await loadGLTF(glbUrl);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const scene = sceneRef.current;
|
const scene = sceneRef.current;
|
||||||
savedPositionRef.current.copy(playerRef.current.position);
|
savedPositionRef.current.copy(playerRef.current.position);
|
||||||
toggleWorldVisibility(false);
|
toggleWorldVisibility(false);
|
||||||
@@ -528,6 +573,8 @@ function Game({ avatarUrl, gender }) {
|
|||||||
intGroup.name = 'interiorGroup';
|
intGroup.name = 'interiorGroup';
|
||||||
intGroup.add(gltf.scene);
|
intGroup.add(gltf.scene);
|
||||||
|
|
||||||
|
interiorInteractablesRef.current = []; // сбрасываем реестр интерактива
|
||||||
|
|
||||||
for (const o of objects) {
|
for (const o of objects) {
|
||||||
if (o.model_url) {
|
if (o.model_url) {
|
||||||
try {
|
try {
|
||||||
@@ -544,8 +591,25 @@ function Game({ avatarUrl, gender }) {
|
|||||||
mesh.position.set(o.x, o.y, o.z);
|
mesh.position.set(o.x, o.y, o.z);
|
||||||
mesh.rotation.set(o.rot_x, o.rot_y, o.rot_z);
|
mesh.rotation.set(o.rot_x, o.rot_y, o.rot_z);
|
||||||
mesh.scale.set(o.scale, o.scale, o.scale);
|
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);
|
const light = new THREE.AmbientLight(0xffffff, 1);
|
||||||
@@ -553,47 +617,123 @@ function Game({ avatarUrl, gender }) {
|
|||||||
|
|
||||||
scene.add(intGroup);
|
scene.add(intGroup);
|
||||||
interiorGroupRef.current = intGroup;
|
interiorGroupRef.current = intGroup;
|
||||||
setInteriorGroup(intGroup);
|
|
||||||
playerRef.current.position.set(0, 0, 0);
|
|
||||||
playerRef.current.quaternion.identity();
|
|
||||||
switchToFirstPersonCamera();
|
|
||||||
setIsInInterior(true);
|
setIsInInterior(true);
|
||||||
setSelectedHouse(null);
|
setSelectedHouse(null);
|
||||||
}
|
}
|
||||||
const enterInterior = async (houseId) => {
|
const enterInterior = async (interiorId) => {
|
||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem('token');
|
||||||
if (!token) {
|
if (!token) {
|
||||||
alert('Пожалуйста, войдите в систему, чтобы войти в здание');
|
alert('Пожалуйста, войдите в систему, чтобы войти в здание');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Сцена/игрок должны быть инициализированы
|
||||||
|
if (!ensureSceneAndPlayer()) return;
|
||||||
|
const scene = getScene();
|
||||||
|
const player = getPlayer();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(
|
const res = await fetch(`/api/interiors/${interiorId}/enter`, {
|
||||||
`/api/city_objects/${houseId}/interior`,
|
method: 'POST',
|
||||||
{
|
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
cache: 'no-cache'
|
cache: 'no-cache'
|
||||||
}
|
});
|
||||||
);
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const errText = await res.text();
|
const errText = await res.text();
|
||||||
console.error(`Ошибка ${res.status} при получении interior_id: ${errText}`);
|
console.error(`Ошибка ${res.status} при получении spawn-координат: ${errText}`);
|
||||||
alert(`Не удалось получить данные интерьера: ${errText}`);
|
alert(`Не удалось получить координаты интерьера: ${errText}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let { interiorId } = await res.json();
|
const data = await res.json();
|
||||||
|
const { spawn, exit, cityId } = data;
|
||||||
if (!interiorId || interiorId < 1) {
|
// Если интерьер в другом городе — переключаем город
|
||||||
alert('Для этого здания не задан интерьер');
|
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;
|
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) {
|
} catch (e) {
|
||||||
console.error('Failed to enter interior:', 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) => {
|
/*const handleAnswerSelect = (answer) => {
|
||||||
if (answer.end) {
|
if (answer.end) {
|
||||||
setShowDialog(false);
|
setShowDialog(false);
|
||||||
@@ -954,7 +1094,7 @@ function Game({ avatarUrl, gender }) {
|
|||||||
|
|
||||||
|
|
||||||
async function movePlayerToInterior(interiorId) {
|
async function movePlayerToInterior(interiorId) {
|
||||||
await loadInteriorScene(interiorId);
|
await enterInterior(interiorId);
|
||||||
}
|
}
|
||||||
|
|
||||||
function switchToFirstPersonCamera() {
|
function switchToFirstPersonCamera() {
|
||||||
@@ -985,6 +1125,50 @@ function stopMove(dir) {
|
|||||||
moveInputRef.current[dir] = false;
|
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) {
|
async function buyItem(key) {
|
||||||
if (!orgMenu) return;
|
if (!orgMenu) return;
|
||||||
const token = localStorage.getItem('token');
|
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(() => {
|
useEffect(() => {
|
||||||
console.log('[DEBUG] useEffect вызван');
|
console.log('[DEBUG] useEffect вызван');
|
||||||
const mount = mountRef.current;
|
const mount = mountRef.current;
|
||||||
@@ -1099,6 +1222,81 @@ function stopMove(dir) {
|
|||||||
return;
|
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 начало');
|
console.log('–– useEffect начало');
|
||||||
|
|
||||||
const baseOffset = new THREE.Vector3(-200, 150, -200);
|
const baseOffset = new THREE.Vector3(-200, 150, -200);
|
||||||
@@ -1168,8 +1366,9 @@ function stopMove(dir) {
|
|||||||
socket.emit('economy:getInventory', { userId: profile.id });
|
socket.emit('economy:getInventory', { userId: profile.id });
|
||||||
socket.on('economy:inventory', setInventory);
|
socket.on('economy:inventory', setInventory);
|
||||||
socket.on('gameTime:update', ({ time }) => setGameTime(time));
|
socket.on('gameTime:update', ({ time }) => setGameTime(time));
|
||||||
const gltfLoader = new GLTFLoader();
|
// Лоадеры, учитывающиеся в прогрессе через loadingManagerRef
|
||||||
const animLoader = new GLTFLoader();
|
const gltfLoader = new GLTFLoader(loadingManagerRef.current || undefined);
|
||||||
|
const animLoader = new GLTFLoader(loadingManagerRef.current || undefined);
|
||||||
|
|
||||||
async function loadPlayerModel(avatarUrl) {
|
async function loadPlayerModel(avatarUrl) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
@@ -1602,6 +1801,20 @@ function stopMove(dir) {
|
|||||||
cleanupVoiceConnection(id);
|
cleanupVoiceConnection(id);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// Мини-лоадер при загрузке интерьеров (обёртка поверх loadInteriorScene)
|
||||||
|
const _origLoadInteriorScene = loadInteriorScene;
|
||||||
|
loadInteriorScene = async (interiorId) => {
|
||||||
|
try {
|
||||||
|
// показываем мини-оверлей на время подзагрузки интерьера
|
||||||
|
createLoadingOverlay();
|
||||||
|
updateLoadingOverlay(30, 'Загрузка интерьера...');
|
||||||
|
await _origLoadInteriorScene(interiorId);
|
||||||
|
} finally {
|
||||||
|
setTimeout(removeLoadingOverlay, 120);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
function onMouseWheel(e) {
|
function onMouseWheel(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const delta = -e.deltaY * 0.001;
|
const delta = -e.deltaY * 0.001;
|
||||||
@@ -1792,12 +2005,15 @@ function stopMove(dir) {
|
|||||||
scene.add(player);
|
scene.add(player);
|
||||||
playerRef.current = player;
|
playerRef.current = player;
|
||||||
player.scale.set(1, 1, 1);
|
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 profile = JSON.parse(sessionStorage.getItem('user_profile') || '{}');
|
||||||
const myName = `${profile.firstName || ''} ${profile.lastName || ''}`.trim();
|
const myName = `${profile.firstName || ''} ${profile.lastName || ''}`.trim();
|
||||||
|
|
||||||
mountRef.current = myName;
|
setMountRef(myName);
|
||||||
|
|
||||||
const nameLabel = createPlayerLabel(myName);
|
const nameLabel = createPlayerLabel(myName);
|
||||||
nameLabel.position.set(0, 2.2, 0);
|
nameLabel.position.set(0, 2.2, 0);
|
||||||
@@ -2720,7 +2936,7 @@ function stopMove(dir) {
|
|||||||
<b>Налог:</b> {selectedHouse.tax}
|
<b>Налог:</b> {selectedHouse.tax}
|
||||||
</p>
|
</p>
|
||||||
<div style={{ marginTop: 12, display: 'flex', gap: 8 }}>
|
<div style={{ marginTop: 12, display: 'flex', gap: 8 }}>
|
||||||
<button onClick={() => enterHouse(selectedHouse)} style={btnStyle}>Войти</button>
|
<button onClick={() => enterInterior(selectedHouse.id)} style={btnStyle}>Войти</button>
|
||||||
<button onClick={() => viewStats(selectedHouse)} style={btnStyle}>Статистика</button>
|
<button onClick={() => viewStats(selectedHouse)} style={btnStyle}>Статистика</button>
|
||||||
{selectedHouse.organizationId && (
|
{selectedHouse.organizationId && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
Reference in New Issue
Block a user