From ebf7e012610636b61f701878baf13632e797b323 Mon Sep 17 00:00:00 2001
From: Iprok
Date: Mon, 25 Aug 2025 22:39:29 +0300
Subject: [PATCH] Interiors Fix Begin
---
.env | 4 +-
db.js | 17 +-
ecosystem.config.js | 16 +-
package.json | 7 +-
public/packs/citypack.json | 7 +
server.js | 176 +++-
src/Game.js | 1503 +++++++++++++++++++++++++++++------
src/pages/MapEditor.jsx | 5 +-
src/pages/RegisterStep3.jsx | 255 +++---
9 files changed, 1589 insertions(+), 401 deletions(-)
create mode 100644 public/packs/citypack.json
diff --git a/.env b/.env
index 63eb7c1..e6f0fe2 100644
--- a/.env
+++ b/.env
@@ -1,3 +1,3 @@
-DATABASE_URL=postgres://my_user:scupAs2s@91.107.120.205:5432/game_db
+DATABASE_URL=postgres://my_user:scupAs2s@188.120.243.108:5432/game_db
JWT_SECRET=tgkkkxd2131
-DATABASE_URL_VIRTUAL_WORLD=postgres://my_user:scupAs2s@91.107.120.205:5432/virtual_world
+DATABASE_URL_VIRTUAL_WORLD=postgres://my_user:scupAs2s@188.120.243.108:5432/virtual_world
diff --git a/db.js b/db.js
index 8c1ee24..8778c5d 100644
--- a/db.js
+++ b/db.js
@@ -2,9 +2,24 @@
require('dotenv').config();
const { Pool } = require('pg');
+console.log('Подключение к базе данных:', process.env.DATABASE_URL);
+
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
- ssl: false
+ ssl: false,
+ // Добавляем обработку ошибок подключения
+ connectionTimeoutMillis: 10000,
+ idleTimeoutMillis: 30000,
+ max: 20
+});
+
+// Обработка ошибок подключения
+pool.on('error', (err) => {
+ console.error('Ошибка подключения к базе данных:', err);
+});
+
+pool.on('connect', () => {
+ console.log('Успешное подключение к базе данных');
});
module.exports = {
diff --git a/ecosystem.config.js b/ecosystem.config.js
index c93550a..cffc01b 100644
--- a/ecosystem.config.js
+++ b/ecosystem.config.js
@@ -1,17 +1,13 @@
module.exports = {
apps: [
{
- name: 'threenew-api',
+ name: 'eev-api',
script: 'server.js',
- cwd: '/threenew',
- env: { NODE_ENV: 'production', PORT: 4000 }
- },
- {
- name: 'threenew-web',
- script: 'serve',
- cwd: '/threenew',
- args: '-s build -l 3000',
- env: { NODE_ENV: 'production' }
+ cwd: '/three/EEV_Proj',
+ env: {
+ NODE_ENV: 'production',
+ PORT: 4000
+ }
}
]
}
diff --git a/package.json b/package.json
index 533af70..aaf62d1 100644
--- a/package.json
+++ b/package.json
@@ -21,11 +21,14 @@
"socket.io": "^4.8.1",
"socket.io-client": "^4.6.1",
"three": "0.166.1",
- "wavesurfer.js": "^7.10.1"
+ "wavesurfer.js": "^7.10.1",
+ "concurrently": "^8.2.2"
},
"scripts": {
"start": "react-scripts start",
- "build": "react-scripts build"
+ "build": "react-scripts build",
+ "server": "node server.js",
+ "dev": "concurrently \"npm run server\" \"npm run start\""
},
"overrides": {
"nth-check": "^2.0.1",
diff --git a/public/packs/citypack.json b/public/packs/citypack.json
new file mode 100644
index 0000000..6671878
--- /dev/null
+++ b/public/packs/citypack.json
@@ -0,0 +1,7 @@
+{
+ "baseColor": "/textures/base.png",
+ "normal": "/textures/grid.png",
+ "specular": "/textures/specular.png",
+ "roughness": 0.5,
+ "metalness": 0.1
+}
\ No newline at end of file
diff --git a/server.js b/server.js
index 9cc9a92..ca94bfc 100644
--- a/server.js
+++ b/server.js
@@ -4,7 +4,18 @@ try {
console.log('dotenv успешно импортирован');
} catch (e) {
console.error('Ошибка при импорте dotenv:', e);
- throw e;
+ console.log('Продолжаем без .env файла');
+}
+
+// Устанавливаем fallback значения для критических переменных окружения
+if (!process.env.JWT_SECRET) {
+ process.env.JWT_SECRET = 'fallback-secret-key-for-development';
+ console.warn('JWT_SECRET не найден, используем fallback ключ (НЕ ДЛЯ ПРОДАКШЕНА!)');
+}
+
+if (!process.env.DATABASE_URL) {
+ process.env.DATABASE_URL = 'postgresql://postgres:password@localhost:5432/revproj';
+ console.warn('DATABASE_URL не найден, используем fallback (НЕ ДЛЯ ПРОДАКШЕНА!)');
}
try {
express = require('express');
@@ -51,6 +62,10 @@ try {
const app = express();
+app.use(express.json());
+app.use(express.urlencoded({ extended: true }));
+
+
const http = require('http').createServer(app);
const io = require('socket.io')(http, {
cors: {
@@ -520,7 +535,25 @@ app.get('/api/me', authenticate, async (req, res) => {
WHERE id = $1
`, [userId]);
if (!rows.length) return res.status(404).json({ error: 'User not found' });
- res.json(rows[0]);
+
+ const user = rows[0];
+
+ // Автоматически исправляем неправильный avatarURL
+ if (!user.avatarURL || user.avatarURL === 'try' || user.avatarURL === 'undefined' || user.avatarURL === 'null') {
+ console.log(`Исправляем неправильный avatarURL для пользователя ${userId}: ${user.avatarURL} -> /models/character.glb`);
+
+ try {
+ await db.query(
+ 'UPDATE users SET avatar_url = $1 WHERE id = $2',
+ ['/models/character.glb', userId]
+ );
+ user.avatarURL = '/models/character.glb';
+ } catch (e) {
+ console.error('Ошибка обновления avatarURL:', e);
+ }
+ }
+
+ res.json(user);
});
app.get('/api/players/:socketId', authenticate, async (req, res) => {
@@ -556,40 +589,72 @@ app.get('/api/players/:socketId', authenticate, async (req, res) => {
stress_level AS "stressLevel",
satiety,
thirst,
- diseases,
- last_city_id AS "last_city_id",
- last_pos_x AS "last_pos_x",
- last_pos_z AS "last_pos_z"
+ diseases
FROM users
WHERE id = $1
`, [dbId]);
if (!rows.length) return res.status(404).json({ error: 'User not found in database' });
- res.json(rows[0]);
+
+ const user = rows[0];
+
+ // Автоматически исправляем неправильный avatarURL
+ if (!user.avatarURL || user.avatarURL === 'try' || user.avatarURL === 'undefined' || user.avatarURL === 'null') {
+ console.log(`Исправляем неправильный avatarURL для игрока ${dbId}: ${user.avatarURL} -> /models/character.glb`);
+
+ try {
+ await db.query(
+ 'UPDATE users SET avatar_url = $1 WHERE id = $2',
+ ['/models/character.glb', dbId]
+ );
+ user.avatarURL = '/models/character.glb';
+ } catch (e) {
+ console.error('Ошибка обновления avatarURL:', e);
+ }
+ }
+
+ res.json(user);
});
app.post('/api/register', async (req, res) => {
- console.log('register request:');
- const { email, password, firstName, lastName, gender, age, city, avatarURL } = req.body;
- const { rowCount } = await db.query(`SELECT 1 FROM users WHERE email = $1`, [email]);
- if (rowCount) return res.status(400).json({ error: 'Почта уже занята' });
+ try {
+ console.log('register request:', req.body?.email);
+ const { email, password, firstName, lastName, gender, age, city, avatarURL } = req.body || {};
- const hash = await bcrypt.hash(password, 10);
- const insertSQL = `
- INSERT INTO users(email, password_hash, first_name, last_name, gender, age, city, avatar_url)
- VALUES($1,$2,$3,$4,$5,$6,$7,$8)
- RETURNING id, email, created_at
- `;
- const result = await db.query(insertSQL, [
- email, hash, firstName, lastName, gender, age, city, avatarURL
- ]);
+ if (!email || !password || !firstName || !lastName) {
+ return res.status(400).json({ error: 'Не заполнены обязательные поля' });
+ }
- const user = result.rows[0];
- await economy.createAccount(user.id, 'USD');
- const token = jwt.sign({ id: user.id, email: user.email }, process.env.JWT_SECRET, {
- expiresIn: '12h'
- });
- res.json({ success: true, token });
+ const { rowCount } = await db.query(`SELECT 1 FROM users WHERE email = $1`, [email]);
+ if (rowCount) return res.status(400).json({ error: 'Почта уже занята' });
+ const hash = await bcrypt.hash(password, 10);
+ const insertSQL = `
+ INSERT INTO users(email, password_hash, first_name, last_name, gender, age, city, avatar_url)
+ VALUES($1,$2,$3,$4,$5,$6,$7,$8)
+ RETURNING id, email, created_at
+ `;
+ const result = await db.query(insertSQL, [
+ email, hash, firstName, lastName, gender ?? null, age ?? null, city ?? null, avatarURL ?? null
+ ]);
+
+ const user = result.rows[0];
+ // Не даём регистрации упасть, если экономика не завелась
+ try {
+ await Economy.createAccount(user.id, 'USD');
+ } catch (e) {
+ console.error('Economy.createAccount failed:', e);
+ }
+
+ if (!process.env.JWT_SECRET) {
+ console.error('JWT_SECRET не задан в окружении (.env)');
+ return res.status(500).json({ error: 'Ошибка конфигурации сервера' });
+ }
+ const token = jwt.sign({ id: user.id, email: user.email }, process.env.JWT_SECRET, { expiresIn: '12h' });
+ res.json({ success: true, token });
+ } catch (e) {
+ console.error('Ошибка регистрации:', e);
+ res.status(500).json({ error: 'Внутренняя ошибка регистрации' });
+ }
});
app.post('/api/login', async (req, res) => {
@@ -640,8 +705,17 @@ app.get('/api/cities/:cityId/objects', authenticate, async (req, res) => {
const cityId = req.params.cityId;
try {
const { rows } = await db.query(`
- SELECT id, name, model_url, pos_x, pos_y, pos_z, rot_x, rot_y, rot_z, organization_id,
- COALESCE(collidable, true) AS collidable
+ SELECT id,
+ name,
+ model_url,
+ pos_x, pos_y, pos_z,
+ rot_x, rot_y, rot_z,
+ COALESCE(scale_x, 1) AS scale_x,
+ COALESCE(scale_y, 1) AS scale_y,
+ COALESCE(scale_z, 1) AS scale_z,
+ organization_id,
+ COALESCE(collidable, true) AS collidable,
+ COALESCE(textures, '-') AS textures
FROM city_objects
WHERE city_id = $1
`, [cityId]);
@@ -664,6 +738,29 @@ app.get('/api/models', authenticate, async (req, res) => {
}
});
+// Обновить avatarURL пользователя
+app.put('/api/profile/avatar', authenticate, async (req, res) => {
+ const { avatarURL } = req.body;
+ const userId = req.user.id;
+
+ try {
+ // Проверяем, что avatarURL не пустой и валидный
+ if (!avatarURL || avatarURL === 'try' || avatarURL === 'undefined' || avatarURL === 'null') {
+ return res.status(400).json({ error: 'Неправильный avatarURL' });
+ }
+
+ await db.query(
+ 'UPDATE users SET avatar_url = $1 WHERE id = $2',
+ [avatarURL, userId]
+ );
+
+ res.json({ success: true, avatarURL });
+ } catch (e) {
+ console.error('Ошибка обновления avatarURL:', e);
+ res.status(500).json({ error: 'Ошибка обновления avatarURL' });
+ }
+});
+
// Регистрируем маршрут на старте приложения:
app.get(
'/api/city_objects/:objectId/interior',
@@ -691,12 +788,11 @@ 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];
+ 'SELECT spawn_x, spawn_y, spawn_z, spawn_rot, exit_x, exit_y, exit_z, exit_rot FROM interiors WHERE id = $1',
+ [interiorId]
+ )).rows[0];
if (!interior) return res.status(404).json({ error: 'Интерьер не найден' });
res.json({
- cityId: interior.city_id || 1,
spawn: {
x: interior.spawn_x,
y: interior.spawn_y,
@@ -708,7 +804,7 @@ app.post('/api/interiors/:interiorId/enter', authenticate, async (req, res) => {
y: interior.exit_y,
z: interior.exit_z,
rot: interior.exit_rot
- },
+ }
});
} catch (e) {
console.error(e);
@@ -1233,4 +1329,16 @@ async function ensureInteriorsSpawnColumns() {
console.error('Ошибка добавления spawn/exit-колонок в interiors', e);
}
}
-ensureInteriorsSpawnColumns();
\ No newline at end of file
+ensureInteriorsSpawnColumns();
+
+// Добавляем колонки масштаба для городских объектов, если ещё не созданы
+async function ensureCityObjectsScaleColumns() {
+ try {
+ await db.query('ALTER TABLE city_objects ADD COLUMN IF NOT EXISTS scale_x NUMERIC DEFAULT 1');
+ await db.query('ALTER TABLE city_objects ADD COLUMN IF NOT EXISTS scale_y NUMERIC DEFAULT 1');
+ await db.query('ALTER TABLE city_objects ADD COLUMN IF NOT EXISTS scale_z NUMERIC DEFAULT 1');
+ } catch (e) {
+ console.error('Ошибка добавления scale колонок в city_objects', e);
+ }
+}
+ensureCityObjectsScaleColumns();
\ No newline at end of file
diff --git a/src/Game.js b/src/Game.js
index 98264b1..dc8a2db 100644
--- a/src/Game.js
+++ b/src/Game.js
@@ -22,40 +22,6 @@ 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);
@@ -69,7 +35,7 @@ function Game({ avatarUrl, gender }) {
const fpCamRef = useRef(null);
const cameraRef = useRef(null);
const rendererRef = useRef(null);
- const moveInputRef = useRef({ forward: false, backward: false, left: false, right: false });
+ const moveInputRef = useRef({ forward: false, backward: false, left: false, right: false, strafeLeft: false, strafeRight: false });
const fpPitchRef = useRef(0);
const isTouchDevice = typeof window !== 'undefined' && ('ontouchstart' in window || navigator.maxTouchPoints > 0);
const isInInteriorRef = useRef(false);
@@ -79,11 +45,13 @@ function Game({ avatarUrl, gender }) {
const [selectedHouse, setSelectedHouse] = useState(null);
const [isInInterior, setIsInInterior] = useState(false);
- const [mountRef, setMountRef] = useState(null);
+ const mountRef = useRef(null);
const socketRef = useRef(null);
useEffect(() => {
+ console.log('useEffect isInInterior изменился:', isInInterior);
isInInteriorRef.current = isInInterior;
+ console.log('isInInteriorRef.current установлен в:', isInInteriorRef.current);
}, [isInInterior]);
const [selectedPlayer, setSelectedPlayer] = useState(null);
const [playerStats, setPlayerStats] = useState(null);
@@ -168,7 +136,7 @@ function Game({ avatarUrl, gender }) {
//const [currentForm, setCurrentForm] = useState(null);
//Телефон
- const scene = new THREE.Scene();
+ let scene, renderer;
const playerRef = useRef(null);
const cityMeshesRef = useRef([]);
const cityObjectsDataRef = useRef([]);
@@ -537,101 +505,48 @@ function Game({ avatarUrl, gender }) {
});
}
- async function loadInteriorScene(interiorId) {
- const token = localStorage.getItem('token');
- const defRes = await fetch(`/api/interiors/${interiorId}/definition`, {
- headers: { Authorization: `Bearer ${token}` },
- credentials: 'include',
- cache: 'no-cache'
- });
-
- if (!defRes.ok) {
- const errText = await defRes.text();
- console.error(`Ошибка ${defRes.status} при загрузке определения интерьера: ${errText}`);
- alert(`Не удалось загрузить определение интерьера: ${errText}`);
- return;
+ async function enterInteriorMode(interiorId) {
+ console.log('enterInteriorMode вызвана для интерьера:', interiorId);
+
+ // Сохраняем текущую позицию игрока
+ if (playerRef.current) {
+ savedPositionRef.current.copy(playerRef.current.position);
}
-
- const { glb, objects } = await defRes.json();
- const baseUrl = window.location.origin;
- 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);
- scene.remove(cityGroupRef.current);
-
- const intGroup = new THREE.Group();
- intGroup.name = 'interiorGroup';
- intGroup.add(gltf.scene);
-
- interiorInteractablesRef.current = []; // сбрасываем реестр интерактива
-
- for (const o of objects) {
- if (o.model_url) {
- try {
- const objGltf = await loadGLTF(baseUrl + o.model_url);
- objGltf.scene.position.set(o.x, o.y, o.z);
- objGltf.scene.rotation.set(o.rot_x, o.rot_y, o.rot_z);
- objGltf.scene.scale.set(o.scale, o.scale, o.scale);
- intGroup.add(objGltf.scene);
- } catch (e) {
- console.warn('Не удалось загрузить объект интерьера', o.model_url, e);
- }
- } else {
- const mesh = baseChairMesh.clone();
- mesh.position.set(o.x, o.y, o.z);
- mesh.rotation.set(o.rot_x, o.rot_y, o.rot_z);
- mesh.scale.set(o.scale, o.scale, o.scale);
- // по умолчанию делаем «чистую» геометрию…
- 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);
- }
+
+ // Загружаем модель интерьера
+ console.log('Загружаем модель интерьера');
+ await loadInteriorModel(interiorId);
+
+ // Переключаемся на камеру от первого лица
+ console.log('Переключаемся на камеру от первого лица');
+ switchToFirstPersonCamera();
+
+ // Включаем управление мышью для интерьера
+ document.body.style.cursor = 'none'; // Скрываем курсор
+ if (rendererRef.current) {
+ rendererRef.current.domElement.requestPointerLock();
}
-
- const light = new THREE.AmbientLight(0xffffff, 1);
- intGroup.add(light);
-
- scene.add(intGroup);
- interiorGroupRef.current = intGroup;
+
+ // Устанавливаем состояние "в интерьере"
+ console.log('Устанавливаем setIsInInterior(true)');
setIsInInterior(true);
setSelectedHouse(null);
+
+ console.log('isInInterior установлен в true');
+
+ // Телепортируем игрока в интерьер (если нужно)
+ console.log('Вызываем teleportPlayerToInterior для интерьера:', interiorId);
+ await teleportPlayerToInterior(interiorId);
+ console.log('teleportPlayerToInterior завершена');
}
- const enterInterior = async (interiorId) => {
+ const teleportPlayerToInterior = async (interiorId) => {
+ console.log('teleportPlayerToInterior вызвана для интерьера:', interiorId);
+ console.log('playerRef.current:', playerRef.current);
const token = localStorage.getItem('token');
if (!token) {
alert('Пожалуйста, войдите в систему, чтобы войти в здание');
return;
}
-
- // Сцена/игрок должны быть инициализированы
- if (!ensureSceneAndPlayer()) return;
- const scene = getScene();
- const player = getPlayer();
-
try {
const res = await fetch(`/api/interiors/${interiorId}/enter`, {
method: 'POST',
@@ -645,37 +560,357 @@ function Game({ avatarUrl, gender }) {
alert(`Не удалось получить координаты интерьера: ${errText}`);
return;
}
- 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));
- }
+ const { spawn, exit } = await res.json();
if (!spawn) {
alert('Для этого интерьера не заданы координаты входа');
return;
}
// Телепортируем игрока в интерьер
-
- // Телепорт игрока
- player.position.set(spawn.x, spawn.y, spawn.z);
- player.rotation.y = THREE.MathUtils.degToRad(spawn.rot);
- // Можно добавить сброс скорости, анимации и т.д. при необходимости
-
+ if (playerRef.current) {
+ playerRef.current.position.set(spawn.x, spawn.y, spawn.z);
+ playerRef.current.rotation.set(0, spawn.rot || 0, 0);
+ // Можно добавить сброс скорости, анимации и т.д. при необходимости
+ }
setCurrentExit(exit || null);
// Добавляем маркер выхода
if (exit) {
addExitMarker(exit);
}
+ console.log('teleportPlayerToInterior завершена успешно');
} catch (e) {
console.error('Failed to enter interior:', e);
}
};
+ async function loadInteriorModel(interiorId) {
+ console.log('loadInteriorModel вызвана для интерьера:', interiorId);
+ const token = localStorage.getItem('token');
+
+ try {
+ const defRes = await fetch(`/api/interiors/${interiorId}/definition`, {
+ headers: { Authorization: `Bearer ${token}` },
+ credentials: 'include',
+ cache: 'no-cache'
+ });
+
+ if (!defRes.ok) {
+ const errText = await defRes.text();
+ console.error(`Ошибка ${defRes.status} при загрузке определения интерьера: ${errText}`);
+ return;
+ }
+
+ const { glb, objects } = await defRes.json();
+ const baseUrl = window.location.origin;
+ const glbUrl = baseUrl + glb;
+ console.log('Loading interior GLB from', glbUrl);
+
+ // Проверяем доступность GLB файла
+ const headResp = await fetch(glbUrl, { method: 'HEAD', cache: 'no-cache' });
+ if (!headResp.ok) {
+ console.error(`GLB not reachable: HTTP ${headResp.status}`);
+ return;
+ }
+
+ const gltf = await loadGLTF(glbUrl);
+ const scene = sceneRef.current;
+
+ // Создаем группу для интерьера
+ const intGroup = new THREE.Group();
+ intGroup.name = 'interiorGroup';
+ intGroup.add(gltf.scene);
+
+ // Добавляем объекты интерьера
+ interiorInteractablesRef.current = []; // сбрасываем реестр интерактива
+
+ for (const o of objects) {
+ if (o.model_url) {
+ try {
+ const objGltf = await loadGLTF(baseUrl + o.model_url);
+ objGltf.scene.position.set(o.x, o.y, o.z);
+ objGltf.scene.rotation.set(o.rot_x, o.rot_y, o.rot_z);
+ objGltf.scene.scale.set(o.scale, o.scale, o.scale);
+ intGroup.add(objGltf.scene);
+ } catch (e) {
+ console.warn('Не удалось загрузить объект интерьера', o.model_url, e);
+ }
+ } else {
+ const mesh = baseChairMesh.clone();
+ mesh.position.set(o.x, o.y, o.z);
+ mesh.rotation.set(o.rot_x, o.rot_y, o.rot_z);
+ mesh.scale.set(o.scale, o.scale, o.scale);
+ intGroup.add(mesh);
+ }
+
+ // Если сервер прислал «маркер»/NPC — пометим кликабельным
+ if (o.interactable || o.marker) {
+ const hit = new THREE.Mesh(
+ new THREE.SphereGeometry(0.6),
+ new THREE.MeshBasicMaterial({ color: 0x00ff00, transparent: true, opacity: 0.15, depthWrite: false })
+ );
+ 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 || 'Интерактив' };
+ intGroup.add(hit);
+ interiorInteractablesRef.current.push(hit);
+ }
+ }
+
+ // Добавляем освещение для интерьера
+ const light = new THREE.AmbientLight(0xffffff, 1);
+ intGroup.add(light);
+
+ // Добавляем группу в сцену
+ scene.add(intGroup);
+ interiorGroupRef.current = intGroup;
+
+ console.log('Модель интерьера загружена успешно');
+ } catch (e) {
+ console.error('Ошибка загрузки модели интерьера:', e);
+ }
+ }
+
+ // Кэш для загруженных текстурпаков
+ const texturePackCache = new Map();
+
+
+
+ function loadTexturePackForMesh(texturePackUrl, mesh, forceReplace = false) {
+ console.log('loadTexturePackForMesh вызвана:', { texturePackUrl, mesh });
+
+ // Проверяем, есть ли уже загруженный текстурпак в кэше
+ if (texturePackCache.has(texturePackUrl)) {
+ console.log('Используем кэшированный текстурпак:', texturePackUrl);
+ const cachedTextures = texturePackCache.get(texturePackUrl);
+ applyTexturesToMesh(mesh, cachedTextures, forceReplace, texturePackUrl);
+ return;
+ }
+
+ console.log('Загружаем текстурпак для меша:', texturePackUrl);
+
+ // Загружаем текстурпак асинхронно
+ const baseUrl = window.location.origin;
+ const fullUrl = texturePackUrl.startsWith('http') ? texturePackUrl : baseUrl + texturePackUrl;
+ console.log('Полный URL для загрузки:', fullUrl);
+
+ fetch(fullUrl)
+ .then(response => {
+ console.log('Ответ сервера для текстурпака:', response.status, response.statusText);
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
+ }
+ console.log('Начинаем парсинг JSON...');
+ return response.json();
+ })
+ .then(texturePack => {
+ console.log('Загруженный текстурпак:', texturePack);
+
+ // Кэшируем загруженный текстурпак
+ texturePackCache.set(texturePackUrl, texturePack);
+
+ // Проверяем, что меш все еще существует и валиден
+ if (mesh && mesh.isMesh && mesh.material) {
+ // Применяем текстуры к мешу (функция сама проверит типы материалов/массивы)
+ applyTexturesToMesh(mesh, texturePack, forceReplace, texturePackUrl);
+ } else {
+ console.warn('Меш не подходит для применения текстурпака:', {
+ hasMesh: !!mesh,
+ isMesh: mesh?.isMesh,
+ hasMaterial: !!mesh?.material
+ });
+ }
+ })
+ .catch(error => {
+ console.error('Ошибка загрузки текстурпака:', texturePackUrl, error);
+ // В случае ошибки оставляем оригинальные материалы
+ if (mesh.material) {
+ mesh.material.needsUpdate = true;
+ }
+ });
+ }
+
+ // Предсоздаём материал в стиле MapEditor для citypack.json
+ const cityPackMaterialCache = new Map(); // url -> material
+
+ function getCityPackMaterial(texturePackUrl, texturePack) {
+ if (cityPackMaterialCache.has(texturePackUrl)) return cityPackMaterialCache.get(texturePackUrl);
+ const mat = new THREE.MeshStandardMaterial();
+ if (typeof texturePack.baseColor === 'string') {
+ const loader = new THREE.TextureLoader();
+ const tex = loader.load(texturePack.baseColor);
+ if (THREE.SRGBColorSpace) tex.colorSpace = THREE.SRGBColorSpace;
+ mat.map = tex;
+ }
+ mat.roughness = typeof texturePack.roughness === 'number' ? texturePack.roughness : 0.5;
+ mat.metalness = typeof texturePack.metalness === 'number' ? texturePack.metalness : 0.1;
+ cityPackMaterialCache.set(texturePackUrl, mat);
+ return mat;
+ }
+
+ function applyTexturesToMesh(mesh, texturePack, forceReplace = false, texturePackUrl) {
+ console.log('applyTexturesToMesh вызвана:', { mesh, texturePack });
+
+ if (!mesh || !texturePack) {
+ console.warn('applyTexturesToMesh: отсутствует меш или текстурпак', {
+ hasMesh: !!mesh,
+ hasTexturePack: !!texturePack
+ });
+ return;
+ }
+
+ if (!mesh.material) {
+ console.warn('У меша нет материала');
+ return;
+ }
+
+ const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
+ const targetMaterials = materials.filter(m => m && m.isMaterial && (m.type === 'MeshStandardMaterial' || m.type === 'MeshPhysicalMaterial' || m.type === 'MeshPhongMaterial'));
+ if (targetMaterials.length === 0) {
+ console.warn('Нет подходящих материалов для применения текстур:', mesh.material);
+ return;
+ }
+
+ // Особый режим: если это citypack.json — ведём себя как MapEditor: заменяем материал на единый стандартный
+ if (texturePackUrl === '/packs/citypack.json') {
+ const mat = getCityPackMaterial(texturePackUrl, texturePack).clone();
+ if (Array.isArray(mesh.material)) {
+ mesh.material = mesh.material.map(() => mat.clone());
+ } else {
+ mesh.material = mat.clone();
+ }
+ mesh.traverse?.((child) => {
+ if (child.isMesh) {
+ child.material = Array.isArray(child.material) ? child.material.map(() => mat.clone()) : mat.clone();
+ }
+ });
+ return;
+ }
+
+ // baseColor map — по умолчанию не перетираем; при forceReplace перезаписываем
+ if (typeof texturePack.baseColor === 'string') {
+ console.log('Загружаем baseColor текстуру:', texturePack.baseColor);
+ const textureLoader = new THREE.TextureLoader();
+ textureLoader.load(texturePack.baseColor, (texture) => {
+ if (THREE.SRGBColorSpace) {
+ texture.colorSpace = THREE.SRGBColorSpace;
+ }
+ targetMaterials.forEach(mat => {
+ if (mat.type === 'MeshStandardMaterial' || mat.type === 'MeshPhysicalMaterial') {
+ if (forceReplace || !mat.map) {
+ mat.map = texture;
+ if (mat.color && mat.color.set) mat.color.set(0xffffff);
+ mat.needsUpdate = true;
+ }
+ }
+ });
+ }, undefined, (error) => {
+ console.error('Ошибка загрузки baseColor текстуры:', error);
+ });
+ }
+
+ // normal map
+ if (typeof texturePack.normal === 'string') {
+ console.log('Загружаем normal текстуру:', texturePack.normal);
+ const textureLoader = new THREE.TextureLoader();
+ textureLoader.load(texturePack.normal, (texture) => {
+ targetMaterials.forEach(mat => {
+ if (mat.type === 'MeshStandardMaterial' || mat.type === 'MeshPhysicalMaterial') {
+ if (forceReplace || !mat.normalMap) {
+ mat.normalMap = texture;
+ mat.needsUpdate = true;
+ }
+ }
+ });
+ }, undefined, (error) => {
+ console.error('Ошибка загрузки normal текстуры:', error);
+ });
+ }
+
+ // roughness map or value
+ if (typeof texturePack.roughness === 'string') {
+ const textureLoader = new THREE.TextureLoader();
+ textureLoader.load(texturePack.roughness, (texture) => {
+ targetMaterials.forEach(mat => {
+ if (mat.type === 'MeshStandardMaterial' || mat.type === 'MeshPhysicalMaterial') {
+ if (forceReplace || !mat.roughnessMap) {
+ mat.roughnessMap = texture;
+ mat.needsUpdate = true;
+ }
+ }
+ });
+ }, undefined, (error) => {
+ console.error('Ошибка загрузки roughness текстуры:', error);
+ });
+ } else if (typeof texturePack.roughness === 'number') {
+ targetMaterials.forEach(mat => {
+ if (mat.type === 'MeshStandardMaterial' || mat.type === 'MeshPhysicalMaterial') {
+ if (forceReplace || mat.roughnessMap == null) {
+ mat.roughness = texturePack.roughness;
+ mat.needsUpdate = true;
+ }
+ }
+ });
+ }
+
+ // metalness map or value (key metallic for map, metalness for value)
+ if (typeof texturePack.metallic === 'string') {
+ const textureLoader = new THREE.TextureLoader();
+ textureLoader.load(texturePack.metallic, (texture) => {
+ targetMaterials.forEach(mat => {
+ if (mat.type === 'MeshStandardMaterial' || mat.type === 'MeshPhysicalMaterial') {
+ if (forceReplace || !mat.metalnessMap) {
+ mat.metalnessMap = texture;
+ mat.needsUpdate = true;
+ }
+ }
+ });
+ }, undefined, (error) => {
+ console.error('Ошибка загрузки metallic текстуры:', error);
+ });
+ }
+ if (typeof texturePack.metalness === 'number') {
+ targetMaterials.forEach(mat => {
+ if (mat.type === 'MeshStandardMaterial' || mat.type === 'MeshPhysicalMaterial') {
+ if (forceReplace || mat.metalnessMap == null) {
+ mat.metalness = texturePack.metalness;
+ mat.needsUpdate = true;
+ }
+ }
+ });
+ }
+
+ // ambient occlusion map
+ if (typeof texturePack.ao === 'string') {
+ const textureLoader = new THREE.TextureLoader();
+ textureLoader.load(texturePack.ao, (texture) => {
+ targetMaterials.forEach(mat => {
+ if (mat.type === 'MeshStandardMaterial' || mat.type === 'MeshPhysicalMaterial') {
+ if (forceReplace || !mat.aoMap) {
+ mat.aoMap = texture;
+ mat.needsUpdate = true;
+ }
+ }
+ });
+ }, undefined, (error) => {
+ console.error('Ошибка загрузки ao текстуры:', error);
+ });
+ }
+
+ // specular only for Phong
+ if (typeof texturePack.specular === 'string') {
+ const textureLoader = new THREE.TextureLoader();
+ textureLoader.load(texturePack.specular, (texture) => {
+ targetMaterials.forEach(mat => {
+ if (mat.type === 'MeshPhongMaterial') {
+ mat.specularMap = texture;
+ mat.needsUpdate = true;
+ }
+ });
+ }, undefined, (error) => {
+ console.error('Ошибка загрузки specular текстуры:', error);
+ });
+ }
+ }
+
function addExitMarker(exit) {
// Удаляем старый маркер, если был
if (window.exitMarkerMesh && sceneRef.current) {
@@ -693,23 +928,43 @@ function Game({ avatarUrl, gender }) {
window.exitMarkerMesh = marker;
}
- const exitInterior = () => {
- if (!currentExit) {
- alert('Не заданы координаты выхода из интерьера!');
- return;
+ const exitInterior = () => {
+ console.log('exitInterior вызвана');
+
+ // Возвращаем игрока на исходную позицию (если не телепортировали)
+ if (playerRef.current && savedPositionRef.current) {
+ playerRef.current.position.copy(savedPositionRef.current);
}
- 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;
}
+
+ // Удаляем группу интерьера, если она есть
+ if (interiorGroupRef.current && sceneRef.current) {
+ sceneRef.current.remove(interiorGroupRef.current);
+ interiorGroupRef.current = null;
+ console.log('Группа интерьера удалена');
+ }
+
+ // Возвращаем третье лицо/камеру и актуализировать видимость объектов города
+ switchToThirdPersonCamera?.();
+ // Безопасный вызов без ReferenceError, даже если функция ещё не определена
+ if (typeof updateCityObjectVisibility === 'function') {
+ updateCityObjectVisibility();
+ }
+
+ // Возвращаем курсор и отключаем pointer lock
+ document.body.style.cursor = 'default';
+ document.exitPointerLock();
+
+ setIsInInterior(false);
setCurrentExit(null);
};
+
// В useEffect для кликов по сцене:
useEffect(() => {
function onDocumentClick(event) {
@@ -1094,25 +1349,53 @@ function Game({ avatarUrl, gender }) {
async function movePlayerToInterior(interiorId) {
- await enterInterior(interiorId);
+ await enterInteriorMode(interiorId);
}
function switchToFirstPersonCamera() {
+ console.log('switchToFirstPersonCamera вызвана');
+ console.log('isInInteriorRef.current:', isInInteriorRef.current);
+
if (fpCamRef.current) {
cameraRef.current = fpCamRef.current;
+ console.log('Камера переключена на fpCamRef');
}
if (playerRef.current) {
playerRef.current.visible = false;
+ console.log('Игрок скрыт');
}
fpPitchRef.current = 0;
+
+ // Настраиваем камеру от первого лица для интерьера
+ if (isInInteriorRef.current) {
+ console.log('Настраиваем камеру для интерьера');
+ // Устанавливаем позицию камеры на уровне глаз игрока
+ const headHeight = 1.6;
+ fpCamRef.current.position.set(
+ playerRef.current.position.x,
+ playerRef.current.position.y + headHeight,
+ playerRef.current.position.z
+ );
+
+ // Направляем камеру в том же направлении, что и игрок
+ const direction = new THREE.Vector3(0, 0, -1);
+ direction.applyEuler(new THREE.Euler(0, playerRef.current.rotation.y, 0));
+ fpCamRef.current.lookAt(
+ fpCamRef.current.position.clone().add(direction)
+ );
+ console.log('Камера настроена для интерьера');
+ }
}
function switchToThirdPersonCamera() {
+ console.log('switchToThirdPersonCamera вызвана');
if (orthoCamRef.current) {
cameraRef.current = orthoCamRef.current;
+ console.log('Камера переключена на orthoCamRef');
}
if (playerRef.current) {
playerRef.current.visible = true;
+ console.log('Игрок показан');
}
fpPitchRef.current = 0;
}
@@ -1214,6 +1497,54 @@ useEffect(() => {
});
}
+ function createInterior() {
+ const group = new THREE.Group();
+ const floorMat = new THREE.MeshStandardMaterial({ color: 0x808080 });
+ const floor = new THREE.Mesh(new THREE.PlaneGeometry(20, 20), floorMat);
+ floor.rotation.x = -Math.PI / 2;
+ group.add(floor);
+
+ const wallMat = new THREE.MeshStandardMaterial({ color: 0x999999 });
+ const wallGeo = new THREE.PlaneGeometry(20, 10);
+ const back = new THREE.Mesh(wallGeo, wallMat);
+ back.position.set(0, 5, -10);
+ group.add(back);
+ const front = back.clone();
+ front.position.set(0, 5, 10);
+ front.rotation.y = Math.PI;
+ group.add(front);
+ const left = back.clone();
+ left.position.set(-10, 5, 0);
+ left.rotation.y = Math.PI / 2;
+ group.add(left);
+ const right = back.clone();
+ right.position.set(10, 5, 0);
+ right.rotation.y = -Math.PI / 2;
+ group.add(right);
+
+ const light = new THREE.PointLight(0xffffff, 1);
+ light.position.set(0, 5, 0);
+ group.add(light);
+
+ return group;
+ }
+
+ function enterHouse(house) {
+ if (!house || !sceneRef.current || !playerRef.current) return;
+ const id = parseInt(house.id, 10);
+ if (id === 9) {
+ savedPositionRef.current.copy(playerRef.current.position);
+ toggleWorldVisibility(false);
+ interiorGroupRef.current = createInterior();
+ sceneRef.current.add(interiorGroupRef.current);
+ playerRef.current.position.set(0, 0, 0);
+ playerRef.current.quaternion.identity();
+ setSelectedHouse(null);
+ switchToFirstPersonCamera();
+ setIsInInterior(true);
+ }
+ }
+
useEffect(() => {
console.log('[DEBUG] useEffect вызван');
const mount = mountRef.current;
@@ -1312,7 +1643,6 @@ useEffect(() => {
const minZoom = zoom * 0.1;
const maxZoom = zoom * 3.5;
- let scene, renderer;
let orthoCamera, fpCamera;
let player, mixer;
let idleAction, walkAction, currentAction;
@@ -1322,7 +1652,13 @@ useEffect(() => {
let blockedTime = 0;
const moveSpeed = 2.5;
const WALK_ANIM_SPEED_MPS = 2;
- const clock = new THREE.Clock();
+ let clock;
+ try {
+ clock = new THREE.Clock();
+ } catch (error) {
+ console.error('Ошибка создания THREE.Clock:', error);
+ return;
+ }
const keys = {};
let npcMeshes = [];
const territorySize = 500;
@@ -1338,16 +1674,36 @@ useEffect(() => {
let customMaterial;
const token = localStorage.getItem('token');
- socketRef.current = io({
- transports: ['websocket','polling'],
- auth: { token }
+ // Подключаемся к локальному серверу
+ const serverUrl = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'
+ ? 'http://localhost:4000'
+ : window.location.origin;
+
+ socketRef.current = io(serverUrl, {
+ transports: ['websocket','polling'],
+ auth: { token },
+ timeout: 20000 // Увеличиваем timeout до 20 секунд
});
const socket = socketRef.current;
console.log('socket инстанс:', socket);
- socket.on('connect', () => console.log('✔ Socket connected, id=', socket.id));
- socket.on('connect_error', err => console.error('Socket connect_error:', err));
- socket.on('disconnect', reason => console.warn('Socket disconnected:', reason));
+ console.log('Подключение к серверу:', serverUrl);
+
+ socket.on('connect', () => {
+ console.log('✔ Socket connected, id=', socket.id);
+ console.log('Подключение успешно установлено');
+ });
+
+ socket.on('connect_error', err => {
+ console.error('Socket connect_error:', err);
+ console.error('Ошибка подключения к серверу:', serverUrl);
+ console.error('Проверьте, что сервер запущен на порту 4000');
+ });
+
+ socket.on('disconnect', reason => {
+ console.warn('Socket disconnected:', reason);
+ console.warn('Соединение разорвано, причина:', reason);
+ });
const profile = JSON.parse(sessionStorage.getItem('user_profile') || '{}');
if (profile?.id) {
socket.emit('economy:getBalance', { userId: profile.id });
@@ -1372,10 +1728,33 @@ useEffect(() => {
async function loadPlayerModel(avatarUrl) {
return new Promise((resolve, reject) => {
- gltfLoader.load(avatarUrl, (gltf) => {
- if (!gltf.scene) return reject('GLTF.scene отсутствует');
- resolve(gltf);
- }, undefined, (err) => reject(err));
+ console.log('GLTFLoader загружает:', avatarUrl);
+
+ // Проверяем, что URL начинается с правильного пути
+ if (!avatarUrl.startsWith('/') && !avatarUrl.startsWith('http')) {
+ console.error('Неправильный формат URL:', avatarUrl);
+ reject(new Error('Неправильный формат URL'));
+ return;
+ }
+
+ gltfLoader.load(
+ avatarUrl,
+ (gltf) => {
+ console.log('GLTF загружен успешно:', gltf);
+ if (!gltf.scene) {
+ console.error('GLTF.scene отсутствует в загруженном файле');
+ return reject('GLTF.scene отсутствует');
+ }
+ resolve(gltf);
+ },
+ (progress) => {
+ console.log('Прогресс загрузки:', progress);
+ },
+ (err) => {
+ console.error('Ошибка загрузки GLTF:', err);
+ reject(err);
+ }
+ );
});
}
@@ -1385,6 +1764,27 @@ useEffect(() => {
if (!avatarURL) throw new Error('no avatarURL');
const gltf = await loadPlayerModel(avatarURL);
model = gltf.scene;
+
+ // Проверяем и исправляем материалы модели
+ model.traverse((child) => {
+ if (child.isMesh && child.material) {
+ if (Array.isArray(child.material)) {
+ child.material.forEach(mat => {
+ if (!mat || !mat.isMaterial) {
+ console.warn(`Неправильный материал в аватаре ${id}, заменяем на стандартный`);
+ if (THREE.MeshStandardMaterial) {
+ child.material = new THREE.MeshStandardMaterial({ color: 0x808080 });
+ } else {
+ console.error('THREE.MeshStandardMaterial не доступен для замены материала');
+ }
+ }
+ });
+ } else if (!child.material.isMaterial) {
+ console.warn(`Неправильный материал в аватаре ${id}, заменяем на стандартный`);
+ child.material = new THREE.MeshStandardMaterial({ color: 0x808080 });
+ }
+ }
+ });
} catch (e) {
console.warn(`Не удалось загрузить аватар ${id}, рисуем сферу`, e);
model = new THREE.Mesh(
@@ -1447,7 +1847,8 @@ useEffect(() => {
_idleTimeout: null
};
- remotePlayers[id].walkAction.setEffectiveTimeScale(0.6);
+ // Синхронизируем анимацию ходьбы с скоростью перемещения
+ remotePlayers[id].walkAction.setEffectiveTimeScale(moveSpeed / WALK_ANIM_SPEED_MPS);
}
function createVoiceIcon() {
@@ -1761,16 +2162,23 @@ useEffect(() => {
remote.targetPosition = newPos.clone();
if (remote.currentAction !== remote.walkAction) {
- remote.currentAction.fadeOut(0.2);
- remote.walkAction.reset().fadeIn(0.2).play();
+ // Более плавный переход к анимации ходьбы
+ const fadeTime = 0.3;
+ remote.currentAction.fadeOut(fadeTime);
+ remote.walkAction.reset().fadeIn(fadeTime).play();
remote.currentAction = remote.walkAction;
+
+ // Синхронизируем время анимации
+ remote.walkAction.time = 0;
}
clearTimeout(remote._idleTimeout);
remote._idleTimeout = setTimeout(() => {
if (remote.currentAction !== remote.idleAction) {
- remote.currentAction.fadeOut(0.2);
- remote.idleAction.reset().fadeIn(0.2).play();
+ // Более плавный переход к idle анимации
+ const fadeTime = 0.3;
+ remote.currentAction.fadeOut(fadeTime);
+ remote.idleAction.reset().fadeIn(fadeTime).play();
remote.currentAction = remote.idleAction;
}
}, 500);
@@ -1787,6 +2195,15 @@ useEffect(() => {
socket.on('newPlayer', (data) => {
console.log('newPlayer', data);
const { playerId, x, z, avatarURL, gender, firstName, lastName } = data;
+
+ // Проверяем, не существует ли уже игрок с таким ID
+ if (remotePlayers[playerId]) {
+ console.log(`Игрок ${playerId} уже существует, обновляем позицию`);
+ // Обновляем позицию существующего игрока
+ remotePlayers[playerId].model.position.set(x, 0, z);
+ return;
+ }
+
addOtherPlayer(playerId, x, z, avatarURL, gender, firstName, lastName);
});
@@ -1802,21 +2219,21 @@ useEffect(() => {
});
- // Мини-лоадер при загрузке интерьеров (обёртка поверх loadInteriorScene)
- const _origLoadInteriorScene = loadInteriorScene;
- loadInteriorScene = async (interiorId) => {
- try {
- // показываем мини-оверлей на время подзагрузки интерьера
- createLoadingOverlay();
- updateLoadingOverlay(30, 'Загрузка интерьера...');
- await _origLoadInteriorScene(interiorId);
- } finally {
- setTimeout(removeLoadingOverlay, 120);
- }
- };
+
+ // Throttling для колеса мыши
+ let wheelTimeout = null;
+
function onMouseWheel(e) {
e.preventDefault();
+
+ // Throttling - обрабатываем только каждые 16ms (60fps)
+ if (wheelTimeout) return;
+
+ wheelTimeout = setTimeout(() => {
+ wheelTimeout = null;
+ }, 16);
+
const delta = -e.deltaY * 0.001;
if (e.ctrlKey) {
@@ -1834,26 +2251,89 @@ useEffect(() => {
}
}
+ // Throttling для движения мыши
+ let mouseMoveTimeout = null;
+
function onMouseLookMove(e) {
if (!isInInteriorRef.current || cameraRef.current !== fpCamRef.current || !playerRef.current) return;
+
+ // Throttling - обрабатываем только каждые 8ms (120fps для более плавного движения)
+ if (mouseMoveTimeout) return;
+
+ mouseMoveTimeout = setTimeout(() => {
+ mouseMoveTimeout = null;
+ }, 8);
+
const movementX = e.movementX || e.mozMovementX || e.webkitMovementX || 0;
const movementY = e.movementY || e.mozMovementY || e.webkitMovementY || 0;
- playerRef.current.rotation.y -= movementX * 0.002;
- fpPitchRef.current = THREE.MathUtils.clamp(
- fpPitchRef.current - movementY * 0.002,
- -Math.PI / 2 + 0.1,
- Math.PI / 2 - 0.1
- );
+
+ // Уменьшаем чувствительность для более плавного движения
+ const sensitivity = 0.0015;
+
+ // В интерьере поворачиваем только камеру, не игрока
+ if (isInInteriorRef.current) {
+ // Поворачиваем камеру по горизонтали (влево-вправо)
+ const yawDelta = -movementX * sensitivity;
+ const currentYaw = playerRef.current.rotation.y;
+ playerRef.current.rotation.y = currentYaw + yawDelta;
+
+ // Поворачиваем камеру по вертикали (вверх-вниз)
+ const pitchDelta = -movementY * sensitivity;
+ fpPitchRef.current = THREE.MathUtils.clamp(
+ fpPitchRef.current + pitchDelta,
+ -Math.PI / 2 + 0.1,
+ Math.PI / 2 - 0.1
+ );
+ } else {
+ // В обычном режиме поворачиваем игрока
+ playerRef.current.rotation.y -= movementX * sensitivity;
+ fpPitchRef.current = THREE.MathUtils.clamp(
+ fpPitchRef.current - movementY * sensitivity,
+ -Math.PI / 2 + 0.1,
+ Math.PI / 2 - 0.1
+ );
+ }
}
async function init() {
console.log('[DEBUG] init вызван');
+
+ // Проверяем, что THREE загружен
+ if (!THREE) {
+ console.error('THREE.js не загружен');
+ return;
+ }
+
+ // Проверяем, что THREE.Clock доступен
+ if (!THREE.Clock) {
+ console.error('THREE.Clock не доступен');
+ return;
+ }
+
+ // Проверяем, что THREE.Scene доступен
+ if (!THREE.Scene) {
+ console.error('THREE.Scene не доступен');
+ return;
+ }
+
scene = new THREE.Scene();
//scene.fog = new THREE.FogExp2(0xcce0ff, 0.002);
sceneRef.current = scene;
const aspect = window.innerWidth / window.innerHeight;
const d = 200;
+ // Проверяем, что THREE.OrthographicCamera доступен
+ if (!THREE.OrthographicCamera) {
+ console.error('THREE.OrthographicCamera не доступен');
+ return;
+ }
+
+ // Проверяем, что THREE.PerspectiveCamera доступен
+ if (!THREE.PerspectiveCamera) {
+ console.error('THREE.PerspectiveCamera не доступен');
+ return;
+ }
+
orthoCamera = new THREE.OrthographicCamera(-d * aspect, d * aspect, d, -d, 1, 1000);
orthoCamera.position.set(200, 200, 200);
orthoCamera.zoom = zoom;
@@ -1866,14 +2346,90 @@ useEffect(() => {
orthoCamRef.current = orthoCamera;
fpCamRef.current = fpCamera;
- renderer = new THREE.WebGLRenderer({ antialias: true });
+ // Проверяем поддержку WebGL
+ if (!window.WebGLRenderingContext) {
+ console.error('WebGL не поддерживается в этом браузере');
+ return;
+ }
+
+ // Проверяем, что THREE.WebGLRenderer доступен
+ if (!THREE.WebGLRenderer) {
+ console.error('THREE.WebGLRenderer не доступен');
+ return;
+ }
+
+ try {
+ renderer = new THREE.WebGLRenderer({
+ antialias: true,
+ alpha: true,
+ preserveDrawingBuffer: false
+ });
+ } catch (error) {
+ console.error('Ошибка создания WebGL renderer:', error);
+ // Попытка создать renderer без antialias
+ try {
+ renderer = new THREE.WebGLRenderer({
+ antialias: false,
+ alpha: true,
+ preserveDrawingBuffer: false
+ });
+ } catch (secondError) {
+ console.error('Не удалось создать WebGL renderer даже без antialias:', secondError);
+ return;
+ }
+ }
renderer.setSize(window.innerWidth, window.innerHeight);
- //renderer.setClearColor(0xcce0ff);
+ renderer.setClearColor(0x87CEEB, 1); // Голубое небо
+ renderer.shadowMap.enabled = true;
+ renderer.shadowMap.type = THREE.PCFSoftShadowMap;
+ renderer.outputColorSpace = THREE.SRGBColorSpace;
+ renderer.toneMapping = THREE.ACESFilmicToneMapping;
+ renderer.toneMappingExposure = 1.0;
rendererRef.current = renderer;
- mountRef.current.appendChild(renderer.domElement);
+
+ if (mountRef.current) {
+ mountRef.current.appendChild(renderer.domElement);
+ } else {
+ console.error('mountRef.current не найден');
+ return;
+ }
- renderer.domElement.addEventListener('wheel', onMouseWheel, { passive: false });
- renderer.domElement.addEventListener('mousemove', onMouseLookMove);
+ if (renderer && renderer.domElement) {
+ renderer.domElement.addEventListener('wheel', onMouseWheel, { passive: false });
+ renderer.domElement.addEventListener('mousemove', onMouseLookMove);
+ } else {
+ console.error('renderer или renderer.domElement не найден');
+ return;
+ }
+
+ // Обработка событий pointer lock
+ if (renderer && renderer.domElement) {
+ renderer.domElement.addEventListener('click', () => {
+ if (isInInteriorRef.current && !document.pointerLockElement) {
+ renderer.domElement.requestPointerLock();
+ }
+ });
+ }
+
+ document.addEventListener('pointerlockchange', () => {
+ if (renderer && renderer.domElement && document.pointerLockElement === renderer.domElement) {
+ console.log('Pointer lock activated');
+ } else {
+ console.log('Pointer lock deactivated');
+ }
+ });
+
+ // Проверяем, что THREE.PlaneGeometry доступен
+ if (!THREE.PlaneGeometry) {
+ console.error('THREE.PlaneGeometry не доступен');
+ return;
+ }
+
+ // Проверяем, что THREE.MeshBasicMaterial доступен
+ if (!THREE.MeshBasicMaterial) {
+ console.error('THREE.MeshBasicMaterial не доступен');
+ return;
+ }
const planeGeometry = new THREE.PlaneGeometry(territorySize, territorySize);
const planeMaterial = new THREE.MeshBasicMaterial({
@@ -1882,30 +2438,100 @@ useEffect(() => {
opacity: 0, // невидим
depthWrite: false // не трогает Z-буфер
});
+
+ // Проверяем, что THREE.Mesh доступен
+ if (!THREE.Mesh) {
+ console.error('THREE.Mesh не доступен');
+ return;
+ }
+
groundPlane = new THREE.Mesh(planeGeometry, planeMaterial);
groundPlane.rotation.x = -Math.PI / 2;
scene.add(groundPlane);
groundRef.current = groundPlane;
+ // Проверяем, что THREE.AmbientLight доступен
+ if (!THREE.AmbientLight) {
+ console.error('THREE.AmbientLight не доступен');
+ return;
+ }
+
+ // Проверяем, что THREE.DirectionalLight доступен
+ if (!THREE.DirectionalLight) {
+ console.error('THREE.DirectionalLight не доступен');
+ return;
+ }
+
const ambientLight = new THREE.AmbientLight(0xffffff, 0.5);
scene.add(ambientLight);
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8);
directionalLight.position.set(50, 100, 50);
scene.add(directionalLight);
+ // Проверяем, что THREE.SphereGeometry доступен
+ if (!THREE.SphereGeometry) {
+ console.error('THREE.SphereGeometry не доступен');
+ return;
+ }
+
const markerGeometry = new THREE.SphereGeometry(0.5, 16, 16);
const markerMaterial = new THREE.MeshBasicMaterial({ color: 0xffff00 });
destinationMarker = new THREE.Mesh(markerGeometry, markerMaterial);
destinationMarker.visible = false;
scene.add(destinationMarker);
+ // Проверяем, что THREE.LoadingManager доступен
+ if (!THREE.LoadingManager) {
+ console.error('THREE.LoadingManager не доступен');
+ return;
+ }
+
+ // Проверяем, что THREE.TextureLoader доступен
+ if (!THREE.TextureLoader) {
+ console.error('THREE.TextureLoader не доступен');
+ return;
+ }
+
const loadingManager = new THREE.LoadingManager(() => {
console.log("Все текстуры загружены");
});
const textureLoader = new THREE.TextureLoader(loadingManager);
- const baseTexture = textureLoader.load('textures/base.png');
+ const baseTexture = textureLoader.load('textures/base.png',
+ // onLoad callback
+ (texture) => {
+ console.log('Текстура base.png загружена успешно');
+ if (THREE.SRGBColorSpace) {
+ texture.colorSpace = THREE.SRGBColorSpace;
+ }
+ },
+ // onProgress callback
+ (progress) => {
+ console.log('Прогресс загрузки текстуры:', progress);
+ },
+ // onError callback
+ (error) => {
+ console.error('Ошибка загрузки текстуры base.png:', error);
+ // Создаем материал без текстуры в случае ошибки
+ if (THREE.MeshStandardMaterial) {
+ customMaterial = new THREE.MeshStandardMaterial({
+ color: 0x808080
+ });
+ } else {
+ console.error('THREE.MeshStandardMaterial не доступен');
+ }
+ }
+ );
+
+ // Проверяем, что THREE.MeshStandardMaterial доступен
+ if (!THREE.MeshStandardMaterial) {
+ console.error('THREE.MeshStandardMaterial не доступен');
+ return;
+ }
+
customMaterial = new THREE.MeshStandardMaterial({
map: baseTexture,
+ roughness: 0.5,
+ metalness: 0.1
});
@@ -1923,6 +2549,32 @@ useEffect(() => {
try {
const gltf = await gltfLoader.loadAsync(npc.model);
const model = gltf.scene;
+
+ // Проверяем и исправляем материалы модели
+ model.traverse((child) => {
+ if (child.isMesh && child.material) {
+ if (Array.isArray(child.material)) {
+ child.material.forEach(mat => {
+ if (!mat || !mat.isMaterial) {
+ console.warn(`Неправильный материал в ${npc.id}, заменяем на стандартный`);
+ if (THREE.MeshStandardMaterial) {
+ child.material = new THREE.MeshStandardMaterial({ color: 0x808080 });
+ } else {
+ console.error('THREE.MeshStandardMaterial не доступен для замены материала');
+ }
+ }
+ });
+ } else if (!child.material.isMaterial) {
+ console.warn(`Неправильный материал в ${npc.id}, заменяем на стандартный`);
+ if (THREE.MeshStandardMaterial) {
+ child.material = new THREE.MeshStandardMaterial({ color: 0x808080 });
+ } else {
+ console.error('THREE.MeshStandardMaterial не доступен для замены материала');
+ }
+ }
+ }
+ });
+
model.position.set(...npc.position);
model.userData.npcId = npc.id;
model.userData.isNpc = true;
@@ -2000,20 +2652,28 @@ useEffect(() => {
renderer.domElement.addEventListener('mousemove', onMouseLookMove);
try {
- const gltf = await loadPlayerModel(avatarUrl);
+ // Проверяем, что avatarUrl существует и валиден
+ let modelUrl = avatarUrl;
+ if (!avatarUrl || avatarUrl === 'undefined' || avatarUrl === 'null') {
+ console.warn('avatarUrl не определен, используем fallback модель');
+ modelUrl = '/models/character.glb';
+ }
+
+ console.log('Загружаем модель игрока:', modelUrl);
+ const gltf = await loadPlayerModel(modelUrl);
player = gltf.scene;
scene.add(player);
playerRef.current = player;
player.scale.set(1, 1, 1);
- 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);
+ player.position.set(0, 0, 0);
const profile = JSON.parse(sessionStorage.getItem('user_profile') || '{}');
const myName = `${profile.firstName || ''} ${profile.lastName || ''}`.trim();
- setMountRef(myName);
+ // Устанавливаем имя игрока в mountRef для отладки
+ if (mountRef.current) {
+ mountRef.current.setAttribute('data-player-name', myName);
+ }
const nameLabel = createPlayerLabel(myName);
nameLabel.position.set(0, 2.2, 0);
@@ -2031,9 +2691,17 @@ useEffect(() => {
isFemale ? 'F_Walk_002.glb' : 'M_Walk_001.glb'
}`;
+ console.log('Загружаем анимации:', { idlePath, walkPath });
+
const [idleGltf, walkGltf] = await Promise.all([
- animLoader.loadAsync(idlePath),
- animLoader.loadAsync(walkPath)
+ animLoader.loadAsync(idlePath).catch(err => {
+ console.error('Ошибка загрузки idle анимации:', err);
+ throw err;
+ }),
+ animLoader.loadAsync(walkPath).catch(err => {
+ console.error('Ошибка загрузки walk анимации:', err);
+ throw err;
+ })
]);
idleGltf.animations.forEach(stripPositionTracks);
@@ -2042,6 +2710,19 @@ useEffect(() => {
console.log('Idle GLB анимации:', idleGltf.animations);
console.log('Walk GLB анимации:', walkGltf.animations);
+ // Проверяем, что анимации загружены
+ if (idleGltf.animations.length === 0) {
+ console.warn('Idle анимации не найдены, создаем пустую анимацию');
+ const emptyClip = new THREE.AnimationClip('idle', 1, []);
+ idleGltf.animations.push(emptyClip);
+ }
+
+ if (walkGltf.animations.length === 0) {
+ console.warn('Walk анимации не найдены, создаем пустую анимацию');
+ const emptyClip = new THREE.AnimationClip('walk', 1, []);
+ walkGltf.animations.push(emptyClip);
+ }
+
idleAction = mixer.clipAction(idleGltf.animations[0], player);
walkAction = mixer.clipAction(walkGltf.animations[0], player);
@@ -2063,6 +2744,59 @@ useEffect(() => {
});
} catch (err) {
console.error("Ошибка загрузки модели игрока:", err);
+ console.error("Детали ошибки:", {
+ avatarUrl,
+ gender,
+ error: err.message,
+ stack: err.stack
+ });
+
+ // Создаем простую модель-заглушку в случае ошибки
+ console.log("Создаем fallback модель для игрока");
+
+ // Пробуем загрузить локальную модель
+ try {
+ const fallbackGltf = await loadPlayerModel('/models/character.glb');
+ player = fallbackGltf.scene;
+ console.log("Fallback модель загружена успешно");
+ } catch (fallbackErr) {
+ console.error("Ошибка загрузки fallback модели:", fallbackErr);
+
+ // Создаем простую геометрию
+ const fallbackGeometry = new THREE.BoxGeometry(1, 2, 1);
+ const fallbackMaterial = new THREE.MeshBasicMaterial({ color: 0xff0000 });
+ player = new THREE.Mesh(fallbackGeometry, fallbackMaterial);
+ console.log("Создана простая модель-заглушка");
+ }
+
+ scene.add(player);
+ playerRef.current = player;
+ player.scale.set(1, 1, 1);
+ player.position.set(0, 0, 0);
+
+ // Создаем простые анимации для fallback
+ mixer = new THREE.AnimationMixer(player);
+ const emptyIdleClip = new THREE.AnimationClip('idle', 1, []);
+ const emptyWalkClip = new THREE.AnimationClip('walk', 1, []);
+
+ idleAction = mixer.clipAction(emptyIdleClip, player);
+ walkAction = mixer.clipAction(emptyWalkClip, player);
+
+ idleAction.play();
+ currentAction = idleAction;
+
+ updateCameraFollow();
+
+ // Отправляем данные о новом игроке
+ const profile = JSON.parse(sessionStorage.getItem('user_profile') || '{}');
+ socketRef.current?.emit('newPlayer', {
+ x: player.position.x,
+ z: player.position.z,
+ avatarURL: avatarUrl || '/models/character.glb',
+ firstName: profile.firstName,
+ lastName: profile.lastName,
+ userId: profile.id
+ });
}
}
@@ -2132,10 +2866,35 @@ useEffect(() => {
}
function loadCityObject(obj) {
+ console.log('loadCityObject вызвана для объекта:', {
+ id: obj.id,
+ name: obj.name,
+ textures: obj.textures,
+ model_url: obj.model_url
+ });
+
gltfLoader.load(
obj.model_url,
(gltf) => {
const model = gltf.scene;
+
+ // Проверяем и исправляем материалы модели
+ model.traverse((child) => {
+ if (child.isMesh && child.material) {
+ if (Array.isArray(child.material)) {
+ child.material.forEach(mat => {
+ if (!mat || !mat.isMaterial) {
+ console.warn(`Неправильный материал в объекте ${obj.name}, заменяем на стандартный`);
+ child.material = new THREE.MeshStandardMaterial({ color: 0x808080 });
+ }
+ });
+ } else if (!child.material.isMaterial) {
+ console.warn(`Неправильный материал в объекте ${obj.name}, заменяем на стандартный`);
+ child.material = new THREE.MeshStandardMaterial({ color: 0x808080 });
+ }
+ }
+ });
+
model.userData = {
id: obj.id,
type: obj.name,
@@ -2143,15 +2902,55 @@ useEffect(() => {
rent: obj.rent,
tax: obj.tax
};
- model.scale.set(1, 1, 1);
+ // Применяем масштаб из БД, если есть
+ const sx = (obj.scale_x ?? 1) || 1;
+ const sy = (obj.scale_y ?? 1) || 1;
+ const sz = (obj.scale_z ?? 1) || 1;
+ model.scale.set(sx, sy, sz);
model.position.set(obj.pos_x, obj.pos_y, obj.pos_z);
model.rotation.set(obj.rot_x, obj.rot_y, obj.rot_z);
+
+ console.log('Обрабатываем материалы для объекта:', obj.name);
+
+ // Обрабатываем материалы в зависимости от поля textures
model.traverse(child => {
if (child.isMesh) {
- child.material = customMaterial.clone();
- child.material.needsUpdate = true;
+ console.log('Найден меш в объекте:', obj.name, {
+ hasMaterial: !!child.material,
+ materialType: child.material ? child.material.type : 'none'
+ });
+
+ // Сохраняем оригинальные материалы для интерьеров
+ if (obj.name && obj.name.toLowerCase().includes('interior')) {
+ console.log('Объект интерьера - оставляем оригинальные материалы');
+ // Для интерьеров оставляем оригинальные материалы
+ if (child.material) {
+ child.material.needsUpdate = true;
+ }
+ } else {
+ // Проверяем поле textures
+ if (obj.textures && obj.textures !== '-') {
+ console.log('Загружаем текстурпак для объекта:', obj.name, 'текстурпак:', obj.textures);
+
+ // Для citypack.json используем тот же принцип, что в MapEditor: единый стандартный материал с baseColor
+ if (obj.textures === '/packs/citypack.json') {
+ // Присваиваем клон стандартного материала с базовой текстурой из пака
+ const forceReplace = true;
+ loadTexturePackForMesh(obj.textures, child, forceReplace);
+ } else {
+ loadTexturePackForMesh(obj.textures, child);
+ }
+ } else {
+ console.log('Оставляем встроенные текстуры для объекта:', obj.name);
+ // Если textures = '-' или не указано, оставляем встроенные текстуры
+ if (child.material) {
+ child.material.needsUpdate = true;
+ }
+ }
+ }
}
});
+
scene.add(model);
cityMeshesRef.current.push(model);
const boundingBox = new THREE.Box3().setFromObject(model);
@@ -2178,20 +2977,51 @@ useEffect(() => {
buildPathfindingGrid();
}
+ // Кэш для оптимизации вычислений расстояний
+ let lastPlayerPosition = null;
+ let lastVisibilityUpdate = 0;
+
function updateCityObjectVisibility() {
if (!player) return;
+
const p = player.position;
+ const now = Date.now();
+
+ // Проверяем, изменилась ли позиция игрока значительно
+ if (lastPlayerPosition &&
+ Math.abs(lastPlayerPosition.x - p.x) < 5 &&
+ Math.abs(lastPlayerPosition.z - p.z) < 5 &&
+ now - lastVisibilityUpdate < 1000) {
+ return; // Пропускаем обновление, если игрок не двигался значительно
+ }
+
+ lastPlayerPosition = p.clone();
+ lastVisibilityUpdate = now;
+
+ // Оптимизированные вычисления расстояний
+ const loadRadiusSq = LOAD_RADIUS * LOAD_RADIUS;
+
cityObjectsDataRef.current.forEach(obj => {
- const dist = Math.hypot(obj.pos_x - p.x, obj.pos_z - p.z);
- if (dist <= LOAD_RADIUS) {
- if (!loadedCityObjectsRef.current[obj.id]) loadCityObject(obj);
+ const dx = obj.pos_x - p.x;
+ const dz = obj.pos_z - p.z;
+ const distSq = dx * dx + dz * dz; // Используем квадрат расстояния для избежания sqrt
+
+ if (distSq <= loadRadiusSq) {
+ if (!loadedCityObjectsRef.current[obj.id]) {
+ console.log('Загружаем объект:', { id: obj.id, name: obj.name, textures: obj.textures });
+ loadCityObject(obj);
+ }
} else {
if (loadedCityObjectsRef.current[obj.id]) unloadCityObject(obj.id);
}
});
+
interiorsDataRef.current.forEach(int => {
- const dist = Math.hypot(int.pos_x - p.x, int.pos_z - p.z);
- if (dist <= LOAD_RADIUS) {
+ const dx = int.pos_x - p.x;
+ const dz = int.pos_z - p.z;
+ const distSq = dx * dx + dz * dz;
+
+ if (distSq <= loadRadiusSq) {
if (!loadedInteriorMeshesRef.current[int.id]) loadInteriorPlaceholder(int);
} else if (loadedInteriorMeshesRef.current[int.id]) {
unloadInteriorPlaceholder(int.id);
@@ -2261,7 +3091,8 @@ useEffect(() => {
return;
}
if (obj && obj.userData.interiorId) {
- await loadInteriorScene(obj.userData.interiorId);
+ console.log('Клик по интерьеру:', obj.userData.interiorId);
+ await enterInteriorMode(obj.userData.interiorId);
return;
}
}
@@ -2317,20 +3148,38 @@ useEffect(() => {
function onKeyDown(event) {
keys[event.key] = true;
+
+ console.log('onKeyDown:', event.key, 'isInInteriorRef.current:', isInInteriorRef.current);
+
+ // Обработка клавиши Escape для выхода из интерьера
+ if (event.key === 'Escape' && isInInteriorRef.current) {
+ console.log('Escape нажата - выходим из интерьера');
+ exitInterior();
+ return;
+ }
+
if (isInInteriorRef.current) {
+ console.log('Обрабатываем клавишу в интерьере:', event.key);
const k = event.key.toLowerCase();
if (k === 'arrowup' || k === 'w') startMove('forward');
if (k === 'arrowdown' || k === 's') startMove('backward');
if (k === 'arrowleft' || k === 'a') startMove('left');
if (k === 'arrowright' || k === 'd') startMove('right');
+ if (k === 'q') startMove('strafeLeft');
+ if (k === 'e') startMove('strafeRight');
}
+
if (event.key.toLowerCase() === 'i') {
const prof = JSON.parse(sessionStorage.getItem('user_profile') || '{}');
socket.emit('economy:getInventory', { userId: prof.id });
setShowInventory(v => !v);
}
- destination = null;
- destinationMarker.visible = false;
+
+ // Сбрасываем назначение только если не в интерьере
+ if (!isInInteriorRef.current) {
+ destination = null;
+ destinationMarker.visible = false;
+ }
}
function onKeyUp(event) {
@@ -2341,30 +3190,45 @@ useEffect(() => {
if (k === 'arrowdown' || k === 's') stopMove('backward');
if (k === 'arrowleft' || k === 'a') stopMove('left');
if (k === 'arrowright' || k === 'd') stopMove('right');
+ if (k === 'q') stopMove('strafeLeft');
+ if (k === 'e') stopMove('strafeRight');
}
}
function createPlayerLabel(text) {
const canvas = document.createElement('canvas');
- canvas.width = 256;
- canvas.height = 64;
+ canvas.width = 512; // Увеличиваем размер canvas
+ canvas.height = 128;
const ctx = canvas.getContext('2d');
- const fontSize = 15;
+ // Добавляем фон для лучшей видимости
+ ctx.fillStyle = 'rgba(0, 0, 0, 0.7)';
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
+
+ const fontSize = 32; // Увеличиваем размер шрифта
ctx.fillStyle = 'white';
- ctx.font = `${fontSize}px Arial`;
+ ctx.font = `bold ${fontSize}px Arial`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
+ // Добавляем обводку для лучшей видимости
+ ctx.strokeStyle = 'black';
+ ctx.lineWidth = 2;
+ ctx.strokeText(text, canvas.width / 2, canvas.height / 2);
ctx.fillText(text, canvas.width / 2, canvas.height / 2);
const texture = new THREE.CanvasTexture(canvas);
texture.needsUpdate = true;
- const spriteMaterial = new THREE.SpriteMaterial({ map: texture });
+ const spriteMaterial = new THREE.SpriteMaterial({
+ map: texture,
+ transparent: true,
+ depthTest: false, // Рисуем поверх всего
+ depthWrite: false
+ });
const sprite = new THREE.Sprite(spriteMaterial);
- sprite.scale.set(0.5, 0.5, 1);
+ sprite.scale.set(1, 0.25, 1); // Увеличиваем размер спрайта
// ↓↓↓ добавь это ↓↓↓
sprite.raycast = () => {};
@@ -2376,9 +3240,22 @@ useEffect(() => {
function switchAnimation(newAction) {
if (!newAction || !currentAction || newAction === currentAction) return;
- currentAction.fadeOut(0.2);
- newAction.reset().fadeIn(0.2).play();
+ // Увеличиваем время перехода для более плавной анимации
+ const fadeTime = 0.3;
+
+ // Плавно убираем текущую анимацию
+ currentAction.fadeOut(fadeTime);
+
+ // Плавно включаем новую анимацию
+ newAction.reset().fadeIn(fadeTime).play();
+
+ // Обновляем текущую анимацию
currentAction = newAction;
+
+ // Синхронизируем время для избежания подлагов
+ if (newAction === walkAction) {
+ newAction.time = 0;
+ }
}
function canMove(newPosition) {
@@ -2514,13 +3391,27 @@ useEffect(() => {
function updateTransparency() {
if (!player) return;
+
+ // Если мы в интерьере, не применяем прозрачность
+ if (isInInteriorRef.current) return;
+
obstacles.forEach(obstacle => {
obstacle.mesh.traverse(child => {
if (child.isMesh && child.material) {
- child.material.transparent = false;
- child.material.opacity = 1.0;
- child.material.depthWrite = true;
- child.material.needsUpdate = true;
+ if (Array.isArray(child.material)) {
+ child.material.forEach(mat => {
+ if (!mat) return;
+ mat.transparent = false;
+ mat.opacity = 1.0;
+ mat.depthWrite = true;
+ mat.needsUpdate = true;
+ });
+ } else {
+ child.material.transparent = false;
+ child.material.opacity = 1.0;
+ child.material.depthWrite = true;
+ child.material.needsUpdate = true;
+ }
}
});
});
@@ -2543,18 +3434,38 @@ useEffect(() => {
if (hit.distance < camToPlayerDist) {
if (hit.object.parent === scene) {
if (hit.object.isMesh && hit.object.material) {
- hit.object.material.transparent = true;
- hit.object.material.opacity = 0.3;
- hit.object.material.depthWrite = false;
- hit.object.material.needsUpdate = true;
+ if (Array.isArray(hit.object.material)) {
+ hit.object.material.forEach(mat => {
+ if (!mat) return;
+ mat.transparent = true;
+ mat.opacity = 0.3;
+ mat.depthWrite = false;
+ mat.needsUpdate = true;
+ });
+ } else {
+ hit.object.material.transparent = true;
+ hit.object.material.opacity = 0.3;
+ hit.object.material.depthWrite = false;
+ hit.object.material.needsUpdate = true;
+ }
}
} else {
hit.object.parent.traverse(child => {
if (child.isMesh && child.material) {
- child.material.transparent = true;
- child.material.opacity = 0.3;
- child.material.depthWrite = false;
- child.material.needsUpdate = true;
+ if (Array.isArray(child.material)) {
+ child.material.forEach(mat => {
+ if (!mat) return;
+ mat.transparent = true;
+ mat.opacity = 0.3;
+ mat.depthWrite = false;
+ mat.needsUpdate = true;
+ });
+ } else {
+ child.material.transparent = true;
+ child.material.opacity = 0.3;
+ child.material.depthWrite = false;
+ child.material.needsUpdate = true;
+ }
}
});
}
@@ -2564,14 +3475,24 @@ useEffect(() => {
function updateFirstPersonMovement(delta) {
if (!isInInteriorRef.current || cameraRef.current !== fpCamRef.current || !player) return;
+
const move = moveInputRef.current;
- const speed = 3;
- const rot = Math.PI;
- if (move.left) player.rotation.y += rot * delta;
- if (move.right) player.rotation.y -= rot * delta;
+ const speed = 2; // Уменьшаем скорость для более плавного движения в интерьере
+ const rotSpeed = Math.PI * 0.5; // Уменьшаем скорость поворота
+
+ // Поворот влево-вправо (A/D или стрелки)
+ if (move.left) player.rotation.y += rotSpeed * delta;
+ if (move.right) player.rotation.y -= rotSpeed * delta;
+
+ // Движение вперед-назад (W/S или стрелки)
const forward = new THREE.Vector3(0, 0, -1).applyQuaternion(player.quaternion);
if (move.forward) player.position.addScaledVector(forward, speed * delta);
if (move.backward) player.position.addScaledVector(forward, -speed * delta);
+
+ // Боковое движение (Q/E для strafe, если нужно)
+ const right = new THREE.Vector3(1, 0, 0).applyQuaternion(player.quaternion);
+ if (move.strafeLeft) player.position.addScaledVector(right, -speed * delta);
+ if (move.strafeRight) player.position.addScaledVector(right, speed * delta);
}
function updateCameraFollow() {
@@ -2591,39 +3512,93 @@ useEffect(() => {
}
const polar = basePolar + cameraPitchOffset;
-
const planar = radius * Math.cos(polar);
const yOff = radius * Math.sin(polar);
-
const xOff = planar * Math.cos(baseAzimuth);
const zOff = planar * Math.sin(baseAzimuth);
- cameraRef.current.position.set(
+ // Плавная интерполяция позиции камеры
+ const targetPosition = new THREE.Vector3(
target.x + xOff,
target.y + yOff,
target.z + zOff
);
-
+
+ cameraRef.current.position.lerp(targetPosition, 0.1);
cameraRef.current.lookAt(target);
}
function animate() {
requestAnimationFrame(animate);
- const delta = clock.getDelta();
- updateDestinationMovement(delta);
- updateFirstPersonMovement(delta);
- if (mixer) mixer.update(delta);
- updateTransparency();
- updateCityObjectVisibility();
- updateCameraFollow();
- for (let id in remotePlayers) {
- const r = remotePlayers[id];
- if (r.targetPosition) {
- r.model.position.lerp(r.targetPosition, 0.1);
- }
- r.mixer.update(delta);
+
+ // Проверяем, что все необходимые объекты инициализированы
+ if (!renderer || !scene || !cameraRef.current) {
+ console.warn('Пропускаем анимацию - не все объекты инициализированы');
+ return;
+ }
+
+ if (!clock || typeof clock.getDelta !== 'function') {
+ console.warn('Clock не инициализирован');
+ return;
+ }
+ const delta = Math.min(clock.getDelta(), 0.1); // Ограничиваем delta для стабильности
+
+ // Обновляем анимации
+ if (mixer && typeof mixer.update === 'function') {
+ mixer.update(delta);
+ }
+
+ // Обновляем движение игрока
+ if (typeof updateDestinationMovement === 'function') {
+ updateDestinationMovement(delta);
+ }
+ if (typeof updateFirstPersonMovement === 'function') {
+ updateFirstPersonMovement(delta);
+ }
+
+ // Обновляем других игроков
+ if (remotePlayers) {
+ for (let id in remotePlayers) {
+ const r = remotePlayers[id];
+ if (r && r.model && r.targetPosition) {
+ r.model.position.lerp(r.targetPosition, 0.15); // Увеличиваем скорость интерполяции
+ }
+ if (r && r.mixer && typeof r.mixer.update === 'function') {
+ r.mixer.update(delta);
+ }
+ }
+ }
+
+ // Обновляем прозрачность и видимость объектов (реже)
+ if (Math.floor(Date.now() / 100) % 3 === 0) {
+ if (typeof updateTransparency === 'function') {
+ updateTransparency();
+ }
+ if (typeof updateCityObjectVisibility === 'function') {
+ updateCityObjectVisibility();
+ }
+ }
+
+ // Обновляем камеру
+ if (typeof updateCameraFollow === 'function') {
+ updateCameraFollow();
+ }
+
+ // Рендерим сцену
+ if (renderer && scene && cameraRef.current) {
+ try {
+ renderer.render(scene, cameraRef.current);
+ } catch (error) {
+ console.error('Ошибка рендеринга:', error);
+ // Не освобождаем материалы здесь, чтобы не усугублять ошибку на следующих кадрах
+ }
+ } else {
+ console.warn('Renderer, scene или camera не инициализированы:', {
+ renderer: !!renderer,
+ scene: !!scene,
+ camera: !!cameraRef.current
+ });
}
- renderer.render(scene, cameraRef.current);
}
(async () => {
@@ -2644,17 +3619,33 @@ useEffect(() => {
fpCamRef.current.aspect = aspect;
fpCamRef.current.updateProjectionMatrix();
}
- rendererRef.current.setSize(window.innerWidth, window.innerHeight);
+ if (rendererRef.current) {
+ rendererRef.current.setSize(window.innerWidth, window.innerHeight);
+ }
}
window.addEventListener('resize', onWindowResize, false);
return () => {
clearInterval(balanceInterval);
+
+ // Очищаем таймеры throttling
+ if (wheelTimeout) {
+ clearTimeout(wheelTimeout);
+ wheelTimeout = null;
+ }
+ if (mouseMoveTimeout) {
+ clearTimeout(mouseMoveTimeout);
+ mouseMoveTimeout = null;
+ }
+
window.removeEventListener('keydown', onKeyDown);
window.removeEventListener('keyup', onKeyUp);
- renderer.domElement.removeEventListener('pointerdown', onDocumentMouseDown);
- renderer.domElement.removeEventListener('wheel', onMouseWheel);
- renderer.domElement.removeEventListener('mousemove', onMouseLookMove);
+ if (renderer && renderer.domElement) {
+ renderer.domElement.removeEventListener('pointerdown', onDocumentMouseDown);
+ renderer.domElement.removeEventListener('wheel', onMouseWheel);
+ renderer.domElement.removeEventListener('mousemove', onMouseLookMove);
+ }
+ document.removeEventListener('pointerlockchange');
window.removeEventListener('resize', onWindowResize);
if (renderer && renderer.domElement && renderer.domElement.parentNode) {
renderer.domElement.parentNode.removeChild(renderer.domElement);
@@ -2839,7 +3830,7 @@ useEffect(() => {
zIndex: 1000
}}>
enterInterior(selectedHouse.id)}
+ onClick={() => enterInteriorMode(selectedHouse.id)}
style={{
fontSize: '18px',
padding: '8px 16px',
@@ -2936,7 +3927,7 @@ useEffect(() => {
Налог: {selectedHouse.tax}
-
enterInterior(selectedHouse.id)} style={btnStyle}>Войти
+
enterHouse(selectedHouse)} style={btnStyle}>Войти
viewStats(selectedHouse)} style={btnStyle}>Статистика
{selectedHouse.organizationId && (
<>
diff --git a/src/pages/MapEditor.jsx b/src/pages/MapEditor.jsx
index 7905919..b84b3c8 100644
--- a/src/pages/MapEditor.jsx
+++ b/src/pages/MapEditor.jsx
@@ -207,7 +207,10 @@ export default function MapEditor() {
pos_z: obj.position.z,
rot_x: obj.rotation.x,
rot_y: obj.rotation.y,
- rot_z: obj.rotation.z
+ rot_z: obj.rotation.z,
+ scale_x: obj.scale.x,
+ scale_y: obj.scale.y,
+ scale_z: obj.scale.z
}));
const token = localStorage.getItem('token');
fetch('/api/save-map', {
diff --git a/src/pages/RegisterStep3.jsx b/src/pages/RegisterStep3.jsx
index 9de4277..e9e9dc7 100644
--- a/src/pages/RegisterStep3.jsx
+++ b/src/pages/RegisterStep3.jsx
@@ -1,118 +1,183 @@
// src/pages/RegisterStep3.jsx
-import React, { useState, useEffect } from 'react';
-import { useNavigate } from 'react-router-dom';
+import React, { useEffect, useMemo, useState } from "react";
+import { useNavigate } from "react-router-dom";
-// данные аватаров
-const avatars = {
+// ======= ПРЕСЕТЫ GLB-МОДЕЛЕЙ =======
+// Поменяй url на свои (например, на хостинге или CDN с включённым CORS).
+const AVATAR_PRESETS = {
male: [
- { name:'Мужчина 1', url:'https://models.readyplayer.me/68013c216026f5144dce1613.glb' },
- { name:'Мужчина 2', url:'https://models.readyplayer.me/68013cf0647a08a2e39f842d.glb' },
+ { name: "Astronaut (demo)", url: "https://modelviewer.dev/shared-assets/models/Astronaut.glb" },
+ { name: "Robot Expressive", url: "https://modelviewer.dev/shared-assets/models/RobotExpressive.glb" },
+ { name: "Person", url: "https://models.readyplayer.me/68a96d372185159c38c47c19.glb" },
],
female: [
- { name:'Женщина 1', url:'https://models.readyplayer.me/680d174ea4d963314ffdd26d.glb' },
- { name:'Женщина 2', url:'https://models.readyplayer.me/680d16d12c0e4a08e3b1de22.glb' },
+ { name: "Virtual Human 1 (demo)", url: "https://models.readyplayer.me/68a96e884dd25e5878afb28f.glb" }, // при желании замени на свой GLB
+ { name: "Virtual Human 2 (demo)", url: "https://modelviewer.dev/shared-assets/models/Woman.glb" }, // при желании замени на свой GLB
],
};
+// простые стили (без внешних css)
+const styles = {
+ page: { minHeight: "100vh", display: "flex", justifyContent: "center", background: "#0b0f1a", color: "#e9eef5", padding: 24 },
+ card: { width: "min(1000px,96vw)", background: "#111827", border: "1px solid #1f2937", borderRadius: 16, padding: 24 },
+ header: { fontSize: 28, fontWeight: 800, margin: "0 0 20px" },
+ row: { display: "flex", gap: 12, alignItems: "center", marginBottom: 12 },
+ select: { background: "#0f172a", color: "#e5e7eb", border: "1px solid #334155", borderRadius: 10, padding: "10px 12px", outline: "none" },
+ grid: { display: "grid", gridTemplateColumns: "repeat(auto-fill,minmax(220px,1fr))", gap: 12, margin: "8px 0 18px" },
+ cardItem: (active) => ({
+ border: `2px solid ${active ? "#22d3ee" : "transparent"}`,
+ background: "#0b1220",
+ borderRadius: 14,
+ padding: 12,
+ cursor: "pointer",
+ }),
+ mvWrap: { width: "100%", aspectRatio: "1 / 1", borderRadius: 10, overflow: "hidden", background: "#0a0f1a", marginBottom: 8 },
+ input: { width: "100%", padding: "10px 12px", borderRadius: 10, border: "1px solid #334155", outline: "none", background: "#0f172a", color: "#e5e7eb" },
+ hint: { fontSize: 13, opacity: 0.8, marginTop: 6 },
+ actions: { display: "flex", gap: 12, marginTop: 10, flexWrap: "wrap" },
+ primaryBtn: { background: "linear-gradient(90deg,#2563eb,#06b6d4)", border: "none", color: "#fff", padding: "12px 18px", fontWeight: 700, borderRadius: 12, cursor: "pointer" },
+ secondaryBtn: { background: "transparent", border: "1px solid #334155", color: "#e5e7eb", padding: "12px 18px", fontWeight: 600, borderRadius: 12, cursor: "pointer" },
+ error: { marginTop: 10, padding: "10px 12px", background: "#7f1d1d", border: "1px solid #ef4444", color: "#fff", borderRadius: 10, whiteSpace: "pre-wrap" },
+};
+
export default function RegisterStep3() {
const navigate = useNavigate();
- const [gender, setGender] = useState('male');
- const [avatarURL, setAvatarURL] = useState('');
- async function handleSubmit(e) {
- e.preventDefault();
+ // Достаём шаги 1–2
+ const step1 = useMemo(() => { try { return JSON.parse(sessionStorage.getItem("reg_step1") || "{}"); } catch { return {}; } }, []);
+ const step2 = useMemo(() => { try { return JSON.parse(sessionStorage.getItem("reg_step2") || "{}"); } catch { return {}; } }, []);
+
+ // если шаги не пройдены — назад
+ useEffect(() => {
+ if (!step1?.email || !step1?.password || !step2?.firstName || !step2?.lastName) {
+ navigate("/register/step1");
+ }
+ }, [navigate, step1, step2]);
+
+ // подключаем
(один раз)
+ useEffect(() => {
+ if (!customElements.get("model-viewer")) {
+ const s = document.createElement("script");
+ s.type = "module";
+ s.src = "https://unpkg.com/@google/model-viewer/dist/model-viewer.min.js";
+ document.head.appendChild(s);
+ }
+ }, []);
+
+ const [gender, setGender] = useState(step2?.gender === "female" ? "female" : "male");
+ const [avatarURL, setAvatarURL] = useState(
+ step2?.avatarURL || AVATAR_PRESETS[step2?.gender === "female" ? "female" : "male"][0].url
+ );
+ const [submitting, setSubmitting] = useState(false);
+ const [err, setErr] = useState("");
+
+ // при смене пола подставляем первый пресет
+ useEffect(() => {
+ setAvatarURL(AVATAR_PRESETS[gender][0].url);
+ }, [gender]);
+
+ async function handleSubmit() {
+ setErr("");
+ if (submitting) return;
+ setSubmitting(true);
+
+ const payload = {
+ email: step1.email,
+ password: step1.password,
+ firstName: step2.firstName,
+ lastName: step2.lastName,
+ gender,
+ age: step2.age ?? null,
+ city: step2.city ?? null,
+ // ВАЖНО: тут лежит URL на GLB-модель будущего персонажа
+ avatarURL: avatarURL || null,
+ };
+
try {
- // завершающий вызов регистрации (оставь твой URL/тело запроса как было)
- const res = await fetch('/api/register', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ gender, avatarURL })
+ const res = await fetch("/api/register", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(payload),
});
- const data = await res.json();
- if (!res.ok || !data.success) {
- alert('Ошибка регистрации');
+
+ const text = await res.text();
+ let data = null;
+ try { data = JSON.parse(text); } catch {}
+
+ if (!res.ok || !data?.success) {
+ const msg = (data && (data.error || data.message)) || text.slice(0, 400) || "Ошибка регистрации";
+ setErr(msg);
+ setSubmitting(false);
return;
}
- // сохраняем токен
- localStorage.setItem('token', data.token);
-
- // добираем профиль
- const meRes = await fetch('/api/me', {
- headers: { Authorization: `Bearer ${data.token}` }
- });
- const me = meRes.ok ? await meRes.json() : null;
-
- // собираем профиль для игры
- const user_profile = {
- id: me?.id,
- email: me?.email,
- firstName: me?.firstName,
- lastName: me?.lastName,
- gender: me?.gender ?? gender,
- age: me?.age,
- city: me?.city,
- avatarURL: avatarURL || me?.avatarURL,
- balance: me?.balance ?? 0,
- satiety: me?.satiety ?? 100,
- thirst: me?.thirst ?? 100,
- last_city_id: me?.lastCityId ?? 1
- };
-
- sessionStorage.setItem('user_profile', JSON.stringify(user_profile));
- navigate('/game');
+ if (data.token) localStorage.setItem("token", data.token);
+ sessionStorage.removeItem("reg_step1");
+ sessionStorage.removeItem("reg_step2");
+ navigate("/game");
} catch (e) {
- console.error(e);
- alert('Ошибка регистрации (шаг 3)');
+ setErr(String(e));
+ setSubmitting(false);
}
}
return (
-
+
+
+
Шаг 3: профиль (3D-аватар GLB)
+
+
+
Пол:
+
setGender(e.target.value)} style={styles.select}>
+ Мужской
+ Женский
+
+
+
+
+ {AVATAR_PRESETS[gender].map((a) => (
+
setAvatarURL(a.url)}>
+
+
+
+
{a.name}
+
{a.url}
+
+ ))}
+
+
+
+
Или свой GLB-URL:
+
setAvatarURL(e.target.value)}
+ style={styles.input}
+ />
+
+ Вставь ссылку на .glb с включённым CORS (должен грузиться в браузере). Это значение сохранится в поле
+ avatarURL профиля — затем клиент игры сможет подхватить и загрузить модель в Three.js.
+
+
+
+ {err ?
{err}
: null}
+
+
+
+ {submitting ? "Сохраняю..." : "Завершить"}
+
+ navigate("/register/step2")} disabled={submitting}>
+ ← Назад
+
+
+
+
);
}
-
-const styles = {
- wrapper: {
- width: '100vw', minHeight:'100vh',
- background: '#111', color:'#fff',
- display: 'flex', flexDirection:'column',
- alignItems:'center', paddingTop:40,
- gap:20
- },
- grid: {
- display:'flex', gap:30, marginBottom:20
- },
- avatarCard: {
- cursor:'pointer', textAlign:'center',
- padding:10, borderRadius:8, background:'#222'
- },
- button: {
- padding:'10px 30px',
- background:'#17a2b8',
- color:'#fff',
- border:'none',
- borderRadius:4
- },
- back: {
- marginTop:10,
- background:'transparent',
- border:'none',
- color:'#aaa',
- cursor:'pointer'
- }
-};