Первый коммит после распаковки архива

This commit is contained in:
2025-08-14 20:14:42 +03:00
commit 5d4e9ba201
354 changed files with 40492 additions and 0 deletions

8
src/App.css Normal file
View File

@@ -0,0 +1,8 @@
/* src/App.css */
/* пока что пусто или добавьте что-нибудь простое */
body {
margin: 0;
padding: 0;
font-family: sans-serif;
}

84
src/App.js Normal file
View File

@@ -0,0 +1,84 @@
// src/App.js
import React, { useState } from 'react';
import { Routes, Route, Navigate } from 'react-router-dom';
import Login from './pages/Login';
import RegisterStep1 from './pages/RegisterStep1';
import RegisterStep2 from './pages/RegisterStep2';
import RegisterStep3 from './pages/RegisterStep3';
import GameWrapper from './components/GameWrapper';
import RequireProfile from './components/RequireProfile';
import MapEditor from './pages/MapEditor';
import InteriorEditor from './pages/InteriorEditor';
export default function App() {
const [isAuth, setIsAuth] = useState(!!localStorage.getItem('token'));
return (
<Routes>
{/* корень */}
<Route
path="/"
element={
isAuth
? <Navigate to="/game" replace />
: <Navigate to="/login" replace />
}
/>
{/* логин */}
<Route
path="/login"
element={
isAuth
? <Navigate to="/game" replace/>
: <Login onLogin={() => setIsAuth(true)} />
}
/>
{/* регистрация всегда с нуля */}
<Route path="/register/step1" element={<RegisterStep1 />} />
<Route path="/register/step2" element={<RegisterStep2 />} />
<Route path="/register/step3" element={<RegisterStep3 />} />
{/* игра: оборачиваем в RequireProfile */}
<Route
path="/game"
element={
isAuth
? <RequireProfile>
<GameWrapper />
</RequireProfile>
: <Navigate to="/login" replace/>
}
/>
{/* редактор карты */}
<Route
path="/editor"
element={
isAuth
? <RequireProfile>
<MapEditor />
</RequireProfile>
: <Navigate to="/login" replace/>
}
/>
{/* редактор интерьеров */}
<Route
path="/interior-editor"
element={
isAuth
? <RequireProfile>
<InteriorEditor />
</RequireProfile>
: <Navigate to="/login" replace/>
}
/>
{/* всё остальное */}
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
);
}

47
src/AvatarCreator.js Normal file
View File

@@ -0,0 +1,47 @@
import React, { useEffect, useRef } from 'react';
function AvatarCreator({ onAvatarExport }) {
const frameRef = useRef();
useEffect(() => {
const iframe = document.createElement('iframe');
iframe.src = 'https://readyplayer.me/avatar?frameApi';
iframe.style.position = 'absolute';
iframe.style.top = '0';
iframe.style.left = '0';
iframe.style.width = '100vw';
iframe.style.height = '100vh';
iframe.style.border = 'none';
frameRef.current.appendChild(iframe);
const subscribe = (event) => {
if (event.data?.source !== 'readyplayerme') return;
// готово к работе
if (event.data.eventName === 'v1.frame.ready') {
iframe.contentWindow.postMessage(
{
target: 'readyplayerme',
type: 'subscribe',
eventName: 'v1.avatar.exported',
},
'*'
);
}
// аватар создан
if (event.data.eventName === 'v1.avatar.exported') {
const avatarUrl = event.data.data.url;
onAvatarExport(avatarUrl); // переход в игру
}
};
window.addEventListener('message', subscribe);
return () => window.removeEventListener('message', subscribe);
}, [onAvatarExport]);
return <div ref={frameRef}></div>;
}
export default AvatarCreator;

95
src/CharacterSelect.js Normal file
View File

@@ -0,0 +1,95 @@
import React, { useState } from 'react';
const avatars = {
male: [
{ name: 'Мужчина 1', url: 'https://models.readyplayer.me/68013c216026f5144dce1613.glb' },
{ name: 'Мужчина 2', url: 'https://models.readyplayer.me/68013cf0647a08a2e39f842d.glb' },
],
female: [
{ name: 'Женщина 1', url: '/avatars/female1.glb' },
{ name: 'Женщина 2', url: '/avatars/female2.glb' },
],
};
export default function CharacterSelect({ onSelect }) {
const [gender, setGender] = useState(null);
const handleGenderSelect = (selected) => setGender(selected);
return (
<div style={styles.wrapper}>
{!gender ? (
<div style={styles.genderBlock}>
<h2>Выберите пол</h2>
<button onClick={() => handleGenderSelect('male')} style={styles.button}>Мужчина</button>
<button onClick={() => handleGenderSelect('female')} style={styles.button}>Женщина</button>
</div>
) : (
<div style={styles.avatarBlock}>
<h2>Выберите персонажа</h2>
<div style={styles.grid}>
{avatars[gender].map((a) => (
<div key={a.url} style={styles.avatarCard} onClick={() => onSelect(a.url, gender)}>
<p>{a.name}</p>
<model-viewer
src={a.url}
camera-controls
style={{ width: '150px', height: '200px' }}
/>
</div>
))}
</div>
<button onClick={() => setGender(null)} style={styles.back}>Назад</button>
</div>
)}
</div>
);
}
const styles = {
wrapper: {
width: '100vw',
height: '100vh',
background: '#111',
color: '#fff',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
},
genderBlock: {
display: 'flex',
flexDirection: 'column',
gap: '20px',
},
avatarBlock: {
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
},
grid: {
display: 'flex',
gap: '30px',
marginTop: '20px',
},
avatarCard: {
cursor: 'pointer',
textAlign: 'center',
},
button: {
fontSize: '18px',
padding: '10px 20px',
background: '#222',
color: '#fff',
border: '1px solid #555',
borderRadius: '8px',
},
back: {
marginTop: '20px',
fontSize: '16px',
background: 'transparent',
border: 'none',
color: '#aaa',
cursor: 'pointer',
},
};

4164
src/Game.js Normal file

File diff suppressed because it is too large Load Diff

36
src/api/auth.js Normal file
View File

@@ -0,0 +1,36 @@
// src/api/auth.js
export async function registerStep1(data) {
const r = await fetch('/api/register-step1', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify(data)
});
return r.json();
}
export async function registerStep2(data, token) {
const r = await fetch('/api/register-step2', {
method:'POST', headers:{
'Content-Type':'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify(data)
});
return r.json();
}
export async function registerStep3(data, token) {
const r = await fetch('/api/register-step3', {
method:'POST', headers:{
'Content-Type':'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify(data)
});
return r.json();
}
export async function login(data) {
const r = await fetch('/api/login', {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify(data)
});
return r.json();
}

View File

@@ -0,0 +1,126 @@
import { useState } from 'react';
export const useDialogManager = () => {
const [currentDialog, setCurrentDialog] = useState(null);
const [dialogIndex, setDialogIndex] = useState(0);
const [showDialog, setShowDialog] = useState(false);
const [formData, setFormData] = useState({});
const [currentForm, setCurrentForm] = useState(null);
// <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD>
const markDialogAsListened = async (jsonFilename) => {
try {
// <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD> <20><><EFBFBD><EFBFBD>
const filename = jsonFilename.split('/').pop().split('\\').pop();
console.log('Normalized filename:', filename);
console.log("<22><><EFBFBD><EFBFBD><EFBFBD> <20> <20><> <20><><EFBFBD>111<31>");
const token = localStorage.getItem('token');
const response = await fetch('/api/listen', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({
player_id: JSON.parse(sessionStorage.getItem('user_profile')).email,
json_filename: filename
})
});
console.log("<22><><EFBFBD><EFBFBD><EFBFBD> <20> <20><> <20><><EFBFBD><EFBFBD>3455654");
if (!response.ok) {
console.error('<27><><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>');
}
} catch (error) {
console.error('<27><><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD>:', error);
}
};
const loadDialog = async (npcId) => {
try {
const response = await fetch(`/dialogs/${npcId}.json`);
const data = await response.json();
setCurrentDialog(data);
setDialogIndex(0);
setShowDialog(true);
} catch (error) {
console.error('<27><><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>:', error);
}
};
const handleAnswerSelect = async (answer) => {
console.log('[Debug] Answer object:', answer); // <- <20><><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>?
console.log('[Debug] "end" in answer:', 'end' in answer); // <- <20><><EFBFBD><EFBFBD> <20><> <20><><EFBFBD><EFBFBD> end?
if (answer.end !== undefined) {
console.log('[Debug] Dialog end triggered!');
// <20><><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD> <20><><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>
if (currentDialog?.filename) {
await markDialogAsListened(currentDialog.filename);
console.log("<22><><EFBFBD><EFBFBD><EFBFBD> <20> <20><> <20><><EFBFBD><EFBFBD>");
}
setShowDialog(false);
} else if (answer.next !== undefined) {
if (typeof answer.next === 'string' && answer.next.startsWith('form_')) {
const nextNode = currentDialog.dialog.find(node => node.id === answer.next);
console.log("<22><><EFBFBD><EFBFBD><EFBFBD> <20> <20><> <20><><EFBFBD><EFBFBD>, <20><> <20><><EFBFBD><EFBFBD> <20><><EFBFBD> <20><><EFBFBD><EFBFBD>");
if (nextNode && nextNode.type === 'form') {
setCurrentForm(nextNode);
return;
}
}
const nextIndex = currentDialog.dialog.findIndex(node => node.id === answer.next);
if (nextIndex !== -1) {
setDialogIndex(nextIndex);
} else {
console.error('<27><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD> <20><> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD>:', answer.next);
setShowDialog(false);
}
}
else if (answer.next == answer.end) {
console.log("<22><><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD>");
}
else {
setShowDialog(false);
}
};
const handleFormSubmit = async (e) => {
e.preventDefault();
if (currentForm.next) {
const nextIndex = currentDialog.dialog.findIndex(node => node.id === currentForm.next);
if (nextIndex !== -1) {
setDialogIndex(nextIndex);
setCurrentForm(null);
console.log('<27><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD>:', formData);
// <20><><EFBFBD><EFBFBD> <20><><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD>, <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>
const nextNode = currentDialog.dialog[nextIndex];
if (nextNode.end && currentDialog?.filename) {
await markDialogAsListened(currentDialog.filename);
}
}
}
};
const handleFormChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: value
}));
};
return {
currentDialog,
dialogIndex,
showDialog,
formData,
currentForm,
loadDialog,
handleAnswerSelect,
handleFormSubmit,
handleFormChange,
setShowDialog
};
};

View File

@@ -0,0 +1,179 @@
import React from 'react';
export const DialogWindow = ({
currentDialog,
dialogIndex,
showDialog,
formData,
currentForm,
handleAnswerSelect,
handleFormSubmit,
handleFormChange,
setShowDialog
}) => {
if (!showDialog || !currentDialog) return null;
return (
<div style={{
position: 'fixed',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
background: 'rgba(0,0,0,0.85)',
color: 'white',
padding: '20px',
borderRadius: '10px',
zIndex: 3000,
minWidth: '300px',
border: '2px solid #555',
display: 'flex',
flexDirection: 'column'
}}>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '15px',
borderBottom: '1px solid #444',
paddingBottom: '10px'
}}>
<div style={{ display: 'flex', alignItems: 'center' }}>
{currentDialog.avatar && (
<img
src={currentDialog.avatar}
alt={currentDialog.name}
style={{
width: '50px',
height: '50px',
borderRadius: '50%',
marginRight: '10px',
objectFit: 'cover'
}}
/>
)}
<h3 style={{ margin: 0 }}>{currentDialog.name}</h3>
</div>
<button
onClick={() => setShowDialog(false)}
style={{
background: 'transparent',
border: 'none',
color: 'white',
fontSize: '20px',
cursor: 'pointer'
}}
>
X
</button>
</div>
{currentForm ? (
<form onSubmit={handleFormSubmit}>
<h4 style={{ marginTop: 0 }}>{currentForm.title}</h4>
{currentForm.fields.map((field, idx) => (
<div key={idx} style={{ marginBottom: '15px' }}>
<label style={{ display: 'block', marginBottom: '5px' }}>
{field.label}
</label>
{field.type === 'textarea' ? (
<textarea
name={field.name}
placeholder={field.placeholder}
required={field.required}
onChange={handleFormChange}
style={{
width: '100%',
minHeight: '80px',
padding: '8px',
borderRadius: '4px',
background: 'rgba(255,255,255,0.1)',
border: '1px solid #555',
color: 'white'
}}
/>
) : (
<input
type={field.type}
name={field.name}
placeholder={field.placeholder}
required={field.required}
onChange={handleFormChange}
style={{
width: '100%',
padding: '8px',
borderRadius: '4px',
background: 'rgba(255,255,255,0.1)',
border: '1px solid #555',
color: 'white'
}}
/>
)}
</div>
))}
<button
type="submit"
style={{
padding: '8px 16px',
background: '#3a5f8d',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
width: '100%'
}}
>
{currentForm.submit_text || '<27><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>'}
</button>
</form>
) : (
<>
<p style={{ marginBottom: '20px', minHeight: '60px' }}>
{currentDialog.dialog[dialogIndex].text}
</p>
{currentDialog.dialog[dialogIndex].answers?.length > 0 ? (
<div style={{
display: 'flex',
flexDirection: 'column',
gap: '8px',
marginBottom: '20px'
}}>
{currentDialog.dialog[dialogIndex].answers.map((answer, idx) => (
<button
key={idx}
onClick={() => handleAnswerSelect(answer)}
style={{
padding: '8px 16px',
background: '#3a5f8d',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
textAlign: 'left'
}}
>
{answer.text}
</button>
))}
</div>
) : (
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<button
onClick={() => setShowDialog(false)}
style={{
padding: '8px 16px',
background: '#4a76a8',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer'
}}
>
<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>
</button>
</div>
)}
</>
)}
</div>
);
};

View File

@@ -0,0 +1,39 @@
import React, { useState, useEffect } from 'react';
import { Navigate } from 'react-router-dom';
import Loading from './Loading';
export default function GameWrapper() {
const [GameComponent, setGameComponent] = useState(null);
const [timerDone, setTimerDone] = useState(false);
// Проверяем наличие профиля
const raw = sessionStorage.getItem('user_profile');
if (!raw) {
return <Navigate to="/login" replace />;
}
const profile = JSON.parse(raw);
useEffect(() => {
let cancelled = false;
// Начинаем динамическую загрузку модуля Game сразу
import('../Game').then(module => {
if (!cancelled) setGameComponent(() => module.default);
});
// Минимальное время загрузочного экрана
const timeoutId = setTimeout(() => {
setTimerDone(true);
}, 2000);
return () => {
cancelled = true;
clearTimeout(timeoutId);
};
}, []);
// Когда оба условия выполнены, рендерим игру
if (GameComponent && timerDone) {
return <GameComponent avatarUrl={profile.avatarURL} gender={profile.gender} />;
}
// Иначе показываем экран загрузки
return <Loading />;
}

View File

@@ -0,0 +1,35 @@
import React from 'react';
export default function Inventory({ items = [], onUse }) {
return (
<div style={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
background: 'rgba(0,0,0,0.7)',
padding: 16,
borderRadius: 8,
color: '#fff',
zIndex: 1000
}}>
<h3 style={{ marginTop: 0 }}>Инвентарь</h3>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr>
<th style={{ textAlign: 'left', paddingBottom: 4 }}>Предмет</th>
<th style={{ textAlign: 'right', paddingBottom: 4 }}>Кол-во</th>
</tr>
</thead>
<tbody>
{items.map(it => (
<tr key={it.item_id} onClick={() => onUse(it)} style={{ cursor: 'pointer' }}>
<td style={{ paddingRight: 12 }}>{it.name}</td>
<td style={{ textAlign: 'right' }}>{it.quantity}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}

View File

@@ -0,0 +1,66 @@
// src/components/Loading.jsx
import React, { useEffect, useState } from 'react';
export default function Loading() {
const [showFirst, setShowFirst] = useState(true);
useEffect(() => {
const id = setInterval(() => setShowFirst(f => !f), 5000);
return () => clearInterval(id);
}, []);
return (
<div style={{
backgroundColor: '#474747',
width: '100%',
height: '100vh',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
margin: 0
}}>
<div style={{
position: 'relative',
width: '80%',
height: '80%',
overflow: 'hidden'
}}>
<img
src="/images/photo_2025-02-22_19-35-03.jpg"
alt=""
style={{
position: 'absolute',
top: 0, left: 0,
width: '100%', height: '100%',
objectFit: 'contain',
transition: 'opacity 2s ease',
opacity: showFirst ? 1 : 0
}}
/>
<img
src="/images/photo_2025-02-22_19-39-34.jpg"
alt=""
style={{
position: 'absolute',
top: 0, left: 0,
width: '100%', height: '100%',
objectFit: 'contain',
transition: 'opacity 2s ease',
opacity: showFirst ? 0 : 1
}}
/>
</div>
<img
src="/images/222.gif"
alt=""
style={{
height: '30%',
width: '30%',
objectFit: 'contain',
marginTop: 20
}}
/>
</div>
);
}

View File

@@ -0,0 +1,127 @@
// src/components/LoginScene.jsx
import React, { useEffect, useRef } from 'react';
import * as THREE from 'three';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader';
import { FontLoader } from 'three/examples/jsm/loaders/FontLoader.js';
import { TextGeometry } from 'three/examples/jsm/geometries/TextGeometry.js';
import { CSS3DRenderer, CSS3DObject } from 'three/examples/jsm/renderers/CSS3DRenderer.js';
import init from '../three/init'; // ваш старый init.js
import './LoginScene.css'; // стили можно вынести
export default function LoginScene() {
const mountRef = useRef(null);
useEffect(() => {
// 1) Инициализируем сцену, камеру и WebGL-рендерер
const { sizes, scene, canvas, camera, renderer, controls } = init();
mountRef.current.appendChild(renderer.domElement);
// 2) CSS3DRenderer для формы
const cssRenderer = new CSS3DRenderer();
cssRenderer.setSize(sizes.width, sizes.height);
cssRenderer.domElement.style.position = 'absolute';
cssRenderer.domElement.style.top = 0;
mountRef.current.appendChild(cssRenderer.domElement);
// 3) Добавляем пол, свет, текст и модели (скопируйте из вашего script.js)
// … ваш код Floor, lights, FontLoader, skyscrapers …
// 4) Форма логина
let currentForm = null;
function createForm(html, position, scale = 0.005) {
if (currentForm) scene.remove(currentForm);
const div = document.createElement('div');
div.style.width = '300px';
div.style.background = 'rgba(34,34,51,0.9)';
div.style.padding = '35px';
div.innerHTML = html;
div.querySelectorAll('input, button').forEach(el => {
el.style.width = '100%';
el.style.boxSizing = 'border-box';
el.style.marginBottom = '10px';
});
const cssObj = new CSS3DObject(div);
cssObj.position.copy(position);
cssObj.scale.set(scale, scale, scale);
currentForm = cssObj;
scene.add(cssObj);
return div;
}
function showLoginForm() {
const html = `
<h2 style="color:#fff; text-align:center; margin-bottom:20px;">Войти</h2>
<input id="loginEmail" type="email" placeholder="Email" />
<input id="loginPass" type="password" placeholder="Пароль" />
<button id="loginBtn">Войти</button>
<p style="text-align:center; margin-top:10px;">
<a href="#" id="toRegister" style="color:#557788;">Регистрация</a>
</p>`;
const el = createForm(html, new THREE.Vector3(-1.2, 1.3, 2.95));
el.querySelector('#loginBtn').onclick = async () => {
const email = el.querySelector('#loginEmail').value;
const password = el.querySelector('#loginPass').value;
// вызвать вашу логику /api/login и перенаправление
// fetch('/api/login')...
};
el.querySelector('#toRegister').onclick = e => {
e.preventDefault();
showRegisterForm();
};
}
function showRegisterForm() {
const html = `
<h2 style="color:#fff; text-align:center; margin-bottom:20px;">Регистрация</h2>
<input id="regEmail" type="email" placeholder="Email" />
<input id="regAge" type="number" placeholder="Возраст" />
<input id="regPass" type="password" placeholder="Пароль" />
<input id="regPassConf" type="password" placeholder="Повтор пароля" />
<button id="regBtn">Зарегистрироваться</button>
<p style="text-align:center; margin-top:10px;">
<a href="#" id="toLogin" style="color:#557788;">← Войти</a>
</p>`;
const el = createForm(html, new THREE.Vector3(-1.2, 1.3, 2.95));
el.querySelector('#toLogin').onclick = e => {
e.preventDefault();
showLoginForm();
};
el.querySelector('#regBtn').onclick = async () => {
// fetch('/api/register')...
};
}
// 5) Подгружаем модель пользователя и сразу показываем форму логина
const loader = new GLTFLoader();
let mixer = null;
loader.load('/models/Punk.glb', gltf => {
const punk = gltf.scene;
punk.position.set(0, 0, 3);
scene.add(punk);
mixer = new THREE.AnimationMixer(punk);
mixer.clipAction(gltf.animations[0]).play();
showLoginForm();
});
// 6) Запуск анимации
const clock = new THREE.Clock();
function tick() {
const delta = clock.getDelta();
controls.update();
renderer.render(scene, camera);
cssRenderer.render(scene, camera);
if (mixer) mixer.update(delta);
requestAnimationFrame(tick);
}
tick();
// 7) Очистка при размонтировании
return () => {
mountRef.current.innerHTML = '';
window.removeEventListener('resize', /*…*/);
};
}, []);
return <div ref={mountRef} style={{ width: '100vw', height: '100vh', position: 'relative' }} />;
}

View File

@@ -0,0 +1,92 @@
import React, { useState, useEffect } from 'react';
export default function OrgControlPanel({ orgId, onClose }) {
const [org, setOrg] = useState(null);
const [menuText, setMenuText] = useState('{}');
const [workHours, setWorkHours] = useState('');
useEffect(() => {
const token = localStorage.getItem('token');
async function load() {
try {
const oRes = await fetch(`/api/organizations/${orgId}`, {
headers: { Authorization: `Bearer ${token}` }
});
if (!oRes.ok) throw new Error('org fetch');
const orgData = await oRes.json();
setOrg(orgData);
const sRes = await fetch(`/api/organizations/${orgId}/settings`, {
headers: { Authorization: `Bearer ${token}` }
});
if (sRes.ok) {
const data = await sRes.json();
// сервер уже нормализует menu в массив — красиво показывать в textarea как объект:
const asObject = Array.isArray(data.menu)
? Object.fromEntries(data.menu.map(it => [it.key, { title: it.title, price: it.price, itemId: it.itemId }]))
: (data.menu || {});
setMenuText(JSON.stringify(asObject, null, 2));
setWorkHours(data.work_hours || '');
}
} catch (e) {
console.error('Failed to load organization info', e);
}
}
load();
}, [orgId]);
async function save() {
let menu;
try {
menu = JSON.parse(menuText); // отправляем как объект — сервер понимает и объект, и массив
} catch (e) {
alert('Неверный JSON меню');
return;
}
const token = localStorage.getItem('token');
await fetch(`/api/organizations/${orgId}/settings`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
body: JSON.stringify({ menu, workHours })
});
onClose();
}
if (!org) return null;
const me = JSON.parse(sessionStorage.getItem('user_profile') || '{}');
const isOwner =
String(org.owner_id ?? '') === String(me.id ?? '') ||
String(org.owner ?? '') === String(me.id ?? '') || // совместимость, если owner — текстом ид
String(org.owner ?? '') === String(me.email ?? ''); // на всякий случай
return (
<div style={{
position: 'absolute', top: 20, right: 20,
background: 'rgba(0,0,0,0.85)', color: '#fff',
padding: 16, borderRadius: 8, minWidth: 280, zIndex: 3000
}}>
<h3 style={{ marginTop: 0 }}>{org.name} управление</h3>
{isOwner ? (
<>
<label style={{ display: 'block', marginBottom: 6 }}>
Часы работы:
<input value={workHours} onChange={e => setWorkHours(e.target.value)} style={{ width: '100%' }} />
</label>
<label style={{ display: 'block', marginBottom: 6 }}>Меню (JSON):</label>
<textarea value={menuText} onChange={e => setMenuText(e.target.value)} style={{ width: '100%', height: 160 }} />
<div style={{ marginTop: 10 }}>
<button onClick={save} style={{ marginRight: 8 }}>Сохранить</button>
<button onClick={onClose}>Закрыть</button>
</div>
</>
) : (
<>
<p>Вы не владелец этой организации.</p>
<button onClick={onClose}>Закрыть</button>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,57 @@
// src/components/RequireProfile.jsx
import React, { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
export default function RequireProfile({ children }) {
const navigate = useNavigate();
const [loading, setLoading] = useState(true);
useEffect(() => {
// Если уже есть профиль в sessionStorage — не фетчим
if (sessionStorage.getItem('user_profile')) {
setLoading(false);
return;
}
// Иначе — берём токен и запрашиваем профиль
const token = localStorage.getItem('token');
if (!token) {
// на всякий случай, если токена вдруг нет
return navigate('/login', { replace: true });
}
fetch('/api/me', {
headers: { Authorization: `Bearer ${token}` }
})
.then(res => {
if (!res.ok) throw new Error('Не авторизован');
return res.json();
})
.then(profile => {
sessionStorage.setItem('user_profile', JSON.stringify(profile));
setLoading(false);
})
.catch(err => {
console.error('Ошибка загрузки профиля:', err);
// скидываем токен и отправляем на логин
localStorage.removeItem('token');
navigate('/login', { replace: true });
});
}, [navigate]);
if (loading) {
return (
<div style={{
width:'100vw', height:'100vh',
display:'flex', alignItems:'center',
justifyContent:'center', color:'#fff',
background:'#000'
}}>
Загрузка профиля
</div>
);
}
// Когда профиль загружен, рендерим детей
return <>{children}</>;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 255 KiB

20
src/index.js Normal file
View File

@@ -0,0 +1,20 @@
// src/index.js
import React from 'react';
import ReactDOM from 'react-dom/client'; // если у вас React 18+
import { BrowserRouter } from 'react-router-dom';
import App from './App';
// Удаляем старые service worker'ы, чтобы исключить проблемы с кэшированием
if ('serviceWorker' in navigator) {
navigator.serviceWorker
.getRegistrations()
.then(regs => regs.forEach(reg => reg.unregister()))
.catch(() => {});
}
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<BrowserRouter>
<App />
</BrowserRouter>
);

1
src/js/pathfinding-browser.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,21 @@
import React, { useRef } from 'react';
/* */
export default function DoubleTapWrapper({ onDoubleTap, onTap, children, threshold = 300 }) {
const lastTap = useRef(0);
const handleClick = () => {
const now = Date.now();
if (now - lastTap.current < threshold) {
onDoubleTap?.();
} else {
onTap?.();
}
lastTap.current = now;
};
return (
<div onClick={handleClick} style={{ touchAction: 'manipulation' }}>
{children}
</div>
);
}

View File

@@ -0,0 +1,254 @@
import React, { useRef, useEffect, useState } from 'react';
import * as THREE from 'three';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls';
import { TransformControls } from 'three/examples/jsm/controls/TransformControls';
export default function InteriorEditor() {
const mountRef = useRef(null);
const [modelList, setModelList] = useState([]);
const [mode, setMode] = useState('translate');
const [interiors, setInteriors] = useState([]);
const [interiorId, setInteriorId] = useState(null);
const sceneRef = useRef();
const cameraRef = useRef();
const rendererRef = useRef();
const controlsRef = useRef();
const transformRef = useRef();
const objectsRef = useRef([]);
const removedIdsRef = useRef([]);
const selectedRef = useRef(null);
const loader = useRef(new GLTFLoader()).current;
const materialRef = useRef();
useEffect(() => {
const scene = new THREE.Scene();
scene.background = new THREE.Color(0xaaaaaa);
sceneRef.current = scene;
const camera = new THREE.PerspectiveCamera(
60,
mountRef.current.clientWidth / mountRef.current.clientHeight,
0.1,
1000
);
camera.position.set(5, 5, 5);
camera.lookAt(0, 0, 0);
cameraRef.current = camera;
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(mountRef.current.clientWidth, mountRef.current.clientHeight);
mountRef.current.appendChild(renderer.domElement);
rendererRef.current = renderer;
const controls = new OrbitControls(camera, renderer.domElement);
controlsRef.current = controls;
const loadingManager = new THREE.LoadingManager();
const textureLoader = new THREE.TextureLoader(loadingManager);
const baseTexture = textureLoader.load('textures/base.png');
materialRef.current = new THREE.MeshStandardMaterial({ map: baseTexture });
const hemi = new THREE.HemisphereLight(0xffffff, 0x444444, 1);
hemi.position.set(0, 20, 0);
scene.add(hemi);
const grid = new THREE.GridHelper(100, 100);
scene.add(grid);
const transform = new TransformControls(camera, renderer.domElement);
transform.addEventListener('dragging-changed', e => {
controls.enabled = !e.value;
});
scene.add(transform);
transformRef.current = transform;
const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2();
const onPointerDown = event => {
// Если начинаем тянуть гизм TransformControls, не переопределяем выбор
if (transform.dragging) return;
const rect = renderer.domElement.getBoundingClientRect();
mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
raycaster.setFromCamera(mouse, camera);
const intersects = raycaster.intersectObjects(objectsRef.current, true);
if (intersects.length > 0) {
let obj = intersects[0].object;
// find top-level object stored in objectsRef
while (obj.parent && !objectsRef.current.includes(obj)) {
obj = obj.parent;
}
transform.attach(obj);
selectedRef.current = obj;
} else {
selectedRef.current = null;
transform.detach();
}
};
renderer.domElement.addEventListener('pointerdown', onPointerDown);
const animate = () => {
requestAnimationFrame(animate);
renderer.render(scene, camera);
};
animate();
const onResize = () => {
if (!mountRef.current) return;
camera.aspect = mountRef.current.clientWidth / mountRef.current.clientHeight;
camera.updateProjectionMatrix();
renderer.setSize(mountRef.current.clientWidth, mountRef.current.clientHeight);
};
window.addEventListener('resize', onResize);
return () => {
renderer.domElement.removeEventListener('pointerdown', onPointerDown);
window.removeEventListener('resize', onResize);
mountRef.current.removeChild(renderer.domElement);
};
}, []);
useEffect(() => {
transformRef.current?.setMode(mode);
}, [mode]);
useEffect(() => {
const token = localStorage.getItem('token');
fetch('/api/models', { headers: { Authorization: `Bearer ${token}` } })
.then(r => r.json())
.then(setModelList)
.catch(err => console.error('Ошибка загрузки моделей', err));
fetch('/api/interiors', { headers: { Authorization: `Bearer ${token}` } })
.then(r => r.json())
.then(data => {
setInteriors(data);
setInteriorId(data[0]?.id || null);
})
.catch(err => console.error('Ошибка загрузки интерьеров', err));
}, []);
useEffect(() => {
if (!interiorId) return;
const token = localStorage.getItem('token');
objectsRef.current.forEach(o => sceneRef.current.remove(o));
objectsRef.current = [];
removedIdsRef.current = [];
fetch(`/api/interiors/${interiorId}/objects`, {
headers: { Authorization: `Bearer ${token}` }
})
.then(r => r.json())
.then(data => {
data.forEach(obj => {
loader.load(obj.model_url, gltf => {
const m = gltf.scene;
m.position.set(obj.x, obj.y, obj.z);
m.rotation.set(obj.rot_x, obj.rot_y, obj.rot_z);
m.scale.set(obj.scale, obj.scale, obj.scale);
m.userData = { id: obj.id, model_url: obj.model_url };
m.traverse(child => {
if (child.isMesh && materialRef.current) {
child.material = materialRef.current.clone();
child.material.needsUpdate = true;
}
});
sceneRef.current.add(m);
objectsRef.current.push(m);
});
});
})
.catch(err => console.error('Ошибка загрузки объектов', err));
}, [interiorId, loader]);
const addModel = name => {
if (!name) return;
const url = `/models/copied/${name}`;
loader.load(url, gltf => {
const m = gltf.scene;
m.userData = { model_url: url, name };
m.traverse(child => {
if (child.isMesh && materialRef.current) {
child.material = materialRef.current.clone();
child.material.needsUpdate = true;
}
});
sceneRef.current.add(m);
objectsRef.current.push(m);
transformRef.current.attach(m);
});
};
const deleteSelected = () => {
const obj = selectedRef.current;
if (!obj) return;
transformRef.current.detach();
if (obj.parent) {
obj.parent.remove(obj);
} else {
sceneRef.current.remove(obj);
}
objectsRef.current = objectsRef.current.filter(o => o !== obj);
if (obj.userData.id) {
removedIdsRef.current.push(obj.userData.id);
}
selectedRef.current = null;
};
const saveInterior = () => {
const objects = objectsRef.current.map(obj => ({
id: obj.userData.id,
model_url: obj.userData.model_url,
x: obj.position.x,
y: obj.position.y,
z: obj.position.z,
rot_x: obj.rotation.x,
rot_y: obj.rotation.y,
rot_z: obj.rotation.z,
scale: obj.scale.x || 1
}));
const token = localStorage.getItem('token');
fetch(`/api/interiors/${interiorId}/save`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
body: JSON.stringify({ objects, removedIds: removedIdsRef.current })
})
.then(r => {
if (!r.ok) throw new Error('fail');
alert('Интерьер сохранён');
})
.catch(() => alert('Ошибка сохранения'));
};
return (
<div style={{ width: '100%', height: '100vh', position: 'relative' }} ref={mountRef}>
<div style={{ position: 'absolute', top: 10, left: 10, background: 'rgba(255,255,255,0.8)', padding: 8 }}>
<div style={{ marginBottom: 4 }}>
<select value={interiorId || ''} onChange={e => setInteriorId(Number(e.target.value))}>
{interiors.map(i => (
<option key={i.id} value={i.id}>Интерьер {i.id}</option>
))}
</select>
</div>
<select id="modelSelect">
<option value="">-- модель --</option>
{modelList.map(m => (
<option key={m} value={m}>{m}</option>
))}
</select>
<button onClick={() => {
const select = document.getElementById('modelSelect');
addModel(select.value);
}}>Добавить</button>
<button onClick={() => setMode(mode === 'translate' ? 'rotate' : 'translate')}>
{mode === 'translate' ? 'Перемещение' : 'Вращение'}
</button>
<button onClick={deleteSelected}>Удалить</button>
<button onClick={saveInterior}>Сохранить</button>
</div>
</div>
);
}

110
src/pages/Login copy.jsx Normal file
View File

@@ -0,0 +1,110 @@
// src/pages/Login.jsx
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
export default function Login({ onLogin }) {
const navigate = useNavigate();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState(null);
const handleSubmit = async e => {
e.preventDefault();
setError(null);
try {
// 1) логинимся
const res1 = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type':'application/json' },
body: JSON.stringify({ email, password })
});
if (!res1.ok) {
const err = await res1.json().catch(() => ({ error: res1.statusText }));
return setError(err.error || 'Ошибка входа');
}
const { token } = await res1.json();
// 2) сохраняем токен
localStorage.setItem('token', token);
// 3) подтягиваем профиль сразу из /api/me
const res2 = await fetch('/api/me', {
headers: { Authorization: `Bearer ${token}` }
});
if (!res2.ok) {
console.error('Не смогли получить профиль:', await res2.text());
return setError('Не удалось загрузить профиль');
}
const profile = await res2.json();
// 4) сохраняем профиль в sessionStorage
sessionStorage.setItem('user_profile', JSON.stringify(profile));
// 5) уведомляем App, что логин состоялся
onLogin();
} catch (e) {
console.error(e);
setError('Сетевая ошибка');
}
};
return (
<div style={styles.wrapper}>
<h2>Вход</h2>
<form onSubmit={handleSubmit} style={styles.form}>
<label>
Почта:
<input
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
required
/>
</label>
<label>
Пароль:
<input
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
required
/>
</label>
{error && <p style={styles.error}>{error}</p>}
<button type="submit" style={styles.button}>Войти</button>
</form>
<p>
Нет аккаунта? <a href="/register/step1">Зарегистрироваться</a>
</p>
</div>
);
}
const styles = {
wrapper: {
width:'100vw', minHeight:'100vh',
background:'#111', color:'#fff',
display:'flex', flexDirection:'column',
alignItems:'center', justifyContent:'center',
gap:20,
},
form: {
display:'flex',
flexDirection:'column',
gap:12,
background:'#222',
padding:20,
borderRadius:8,
},
button: {
padding:'10px 20px',
background:'#17a2b8',
color:'#fff',
border:'none',
borderRadius:4,
cursor:'pointer'
},
error: {
color:'salmon',
}
};

129
src/pages/Login.jsx Normal file
View File

@@ -0,0 +1,129 @@
// src/pages/Login.jsx
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
export default function Login({ onLogin }) {
const navigate = useNavigate();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState(null);
const handleSubmit = async e => {
e.preventDefault();
setError(null);
try {
// 1) логинимся
const res1 = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type':'application/json' },
body: JSON.stringify({ email, password })
});
if (!res1.ok) {
const err = await res1.json().catch(() => ({ error: res1.statusText }));
return setError(err.error || 'Ошибка входа');
}
const { token } = await res1.json();
// 2) сохраняем токен
localStorage.setItem('token', token);
// 3) подтягиваем профиль сразу из /api/me
const res2 = await fetch('/api/me', {
headers: { Authorization: `Bearer ${token}` }
});
if (!res2.ok) {
console.error('Не смогли получить профиль:', await res2.text());
return setError('Не удалось загрузить профиль');
}
const profile = await res2.json();
// 4) сохраняем профиль в sessionStorage
sessionStorage.setItem('user_profile', JSON.stringify(profile));
// 5) уведомляем App, что логин состоялся
onLogin();
} catch (e) {
console.error(e);
setError('Сетевая ошибка');
}
};
return (
<div style={styles.wrapper}>
<h2>Вход</h2>
<form onSubmit={handleSubmit} style={styles.form}>
<label>
Почта:
<input
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
required
style={{
margin: '13px',
borderColor: email.trim() !== '' ? 'limegreen' : 'gray',
borderWidth: '2px',
borderStyle: 'solid',
padding: '5px',
borderRadius: '5px',
transition: 'border-color 0.3s ease',
}}
/>
</label>
<label>
Пароль:
<input
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
required
style={{
margin: '5px',
borderColor: password.trim() !== '' ? 'limegreen' : 'gray',
borderWidth: '2px',
borderStyle: 'solid',
padding: '5px',
borderRadius: '5px',
transition: 'border-color 0.3s ease',
}}
/>
</label>
{error && <p style={styles.error}>{error}</p>}
<button type="submit" style={styles.button}>Войти</button>
</form>
<p>
Нет аккаунта? <a href="/register/step1">Зарегистрироваться</a>
</p>
</div>
);
}
const styles = {
wrapper: {
width:'100vw', minHeight:'100vh',
background:'#111', color:'#fff',
display:'flex', flexDirection:'column',
alignItems:'center', justifyContent:'center',
gap:20,
},
form: {
display:'flex',
flexDirection:'column',
gap:12,
background:'#222',
padding:20,
borderRadius:8,
},
button: {
padding:'10px 20px',
background:'#17a2b8',
color:'#fff',
border:'none',
borderRadius:4,
cursor:'pointer'
},
error: {
color:'salmon',
}
};

256
src/pages/MapEditor.jsx Normal file
View File

@@ -0,0 +1,256 @@
import React, { useRef, useEffect, useState } from 'react';
import * as THREE from 'three';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls';
import { TransformControls } from 'three/examples/jsm/controls/TransformControls';
export default function MapEditor() {
const mountRef = useRef(null);
const [modelList, setModelList] = useState([]);
const [mode, setMode] = useState('translate');
const [cities, setCities] = useState([]);
const [cityId, setCityId] = useState(null);
const sceneRef = useRef();
const cameraRef = useRef();
const rendererRef = useRef();
const controlsRef = useRef();
const transformRef = useRef();
const objectsRef = useRef([]);
const removedIdsRef = useRef([]);
const selectedRef = useRef(null);
const loader = useRef(new GLTFLoader()).current;
const materialRef = useRef();
useEffect(() => {
const scene = new THREE.Scene();
scene.background = new THREE.Color(0xaaaaaa);
sceneRef.current = scene;
const camera = new THREE.PerspectiveCamera(
60,
mountRef.current.clientWidth / mountRef.current.clientHeight,
0.1,
1000
);
camera.position.set(5, 5, 5);
camera.lookAt(0, 0, 0);
cameraRef.current = camera;
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(mountRef.current.clientWidth, mountRef.current.clientHeight);
mountRef.current.appendChild(renderer.domElement);
rendererRef.current = renderer;
const controls = new OrbitControls(camera, renderer.domElement);
controlsRef.current = controls;
const loadingManager = new THREE.LoadingManager();
const textureLoader = new THREE.TextureLoader(loadingManager);
const baseTexture = textureLoader.load('textures/base.png');
materialRef.current = new THREE.MeshStandardMaterial({ map: baseTexture });
const hemi = new THREE.HemisphereLight(0xffffff, 0x444444, 1);
hemi.position.set(0, 20, 0);
scene.add(hemi);
const grid = new THREE.GridHelper(100, 100);
scene.add(grid);
const transform = new TransformControls(camera, renderer.domElement);
transform.addEventListener('dragging-changed', e => {
controls.enabled = !e.value;
});
scene.add(transform);
transformRef.current = transform;
const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2();
const onPointerDown = event => {
// Если начинаем тянуть гизм TransformControls, не переопределяем выбор
if (transform.dragging) return;
const rect = renderer.domElement.getBoundingClientRect();
mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
raycaster.setFromCamera(mouse, camera);
const intersects = raycaster.intersectObjects(objectsRef.current, true);
if (intersects.length > 0) {
let obj = intersects[0].object;
// find top-level object stored in objectsRef
while (obj.parent && !objectsRef.current.includes(obj)) {
obj = obj.parent;
}
transform.attach(obj);
selectedRef.current = obj;
} else {
selectedRef.current = null;
transform.detach();
}
};
renderer.domElement.addEventListener('pointerdown', onPointerDown);
const animate = () => {
requestAnimationFrame(animate);
renderer.render(scene, camera);
};
animate();
const onResize = () => {
if (!mountRef.current) return;
camera.aspect = mountRef.current.clientWidth / mountRef.current.clientHeight;
camera.updateProjectionMatrix();
renderer.setSize(mountRef.current.clientWidth, mountRef.current.clientHeight);
};
window.addEventListener('resize', onResize);
return () => {
renderer.domElement.removeEventListener('pointerdown', onPointerDown);
window.removeEventListener('resize', onResize);
mountRef.current.removeChild(renderer.domElement);
};
}, []);
useEffect(() => {
transformRef.current?.setMode(mode);
}, [mode]);
useEffect(() => {
const token = localStorage.getItem('token');
fetch('/api/models', { headers: { Authorization: `Bearer ${token}` } })
.then(r => r.json())
.then(setModelList)
.catch(err => console.error('Ошибка загрузки моделей', err));
fetch('/api/cities', { headers: { Authorization: `Bearer ${token}` } })
.then(r => r.json())
.then(data => {
setCities(data);
const profile = JSON.parse(sessionStorage.getItem('user_profile') || '{}');
const defaultCity = profile.last_city_id || data[0]?.id;
setCityId(defaultCity);
})
.catch(err => console.error('Ошибка загрузки городов', err));
}, []);
useEffect(() => {
if (!cityId) return;
const token = localStorage.getItem('token');
// очистка текущих объектов
objectsRef.current.forEach(o => sceneRef.current.remove(o));
objectsRef.current = [];
removedIdsRef.current = [];
fetch(`/api/cities/${cityId}/objects`, {
headers: { Authorization: `Bearer ${token}` }
})
.then(r => r.json())
.then(data => {
data.forEach(obj => {
loader.load(obj.model_url, gltf => {
const m = gltf.scene;
m.position.set(obj.pos_x, obj.pos_y, obj.pos_z);
m.rotation.set(obj.rot_x, obj.rot_y, obj.rot_z);
m.userData = { id: obj.id, model_url: obj.model_url, name: obj.name };
m.traverse(child => {
if (child.isMesh && materialRef.current) {
child.material = materialRef.current.clone();
child.material.needsUpdate = true;
}
});
sceneRef.current.add(m);
objectsRef.current.push(m);
});
});
})
.catch(err => console.error('Ошибка загрузки объектов', err));
}, [cityId, loader]);
const addModel = name => {
if (!name) return;
const url = `/models/copied/${name}`;
loader.load(url, gltf => {
const m = gltf.scene;
m.userData = { model_url: url, name };
m.traverse(child => {
if (child.isMesh && materialRef.current) {
child.material = materialRef.current.clone();
child.material.needsUpdate = true;
}
});
sceneRef.current.add(m);
objectsRef.current.push(m);
transformRef.current.attach(m);
});
};
const deleteSelected = () => {
const obj = selectedRef.current;
if (!obj) return;
transformRef.current.detach();
if (obj.parent) {
obj.parent.remove(obj);
} else {
sceneRef.current.remove(obj);
}
objectsRef.current = objectsRef.current.filter(o => o !== obj);
if (obj.userData.id) {
removedIdsRef.current.push(obj.userData.id);
}
selectedRef.current = null;
};
const saveMap = () => {
const objects = objectsRef.current.map(obj => ({
id: obj.userData.id,
name: obj.userData.name || '',
model_url: obj.userData.model_url,
pos_x: obj.position.x,
pos_y: obj.position.y,
pos_z: obj.position.z,
rot_x: obj.rotation.x,
rot_y: obj.rotation.y,
rot_z: obj.rotation.z
}));
const token = localStorage.getItem('token');
fetch('/api/save-map', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
body: JSON.stringify({ cityId, objects, removedIds: removedIdsRef.current })
})
.then(r => {
if (!r.ok) throw new Error('fail');
alert('Карта сохранена');
})
.catch(() => alert('Ошибка сохранения'));
};
return (
<div style={{ width: '100%', height: '100vh', position: 'relative' }} ref={mountRef}>
<div style={{ position: 'absolute', top: 10, left: 10, background: 'rgba(255,255,255,0.8)', padding: 8 }}>
<div style={{ marginBottom: 4 }}>
<select value={cityId || ''} onChange={e => setCityId(Number(e.target.value))}>
{cities.map(c => (
<option key={c.id} value={c.id}>{c.name} ({c.country_name})</option>
))}
</select>
</div>
<select id="modelSelect">
<option value="">-- модель --</option>
{modelList.map(m => (
<option key={m} value={m}>{m}</option>
))}
</select>
<button onClick={() => {
const select = document.getElementById('modelSelect');
addModel(select.value);
}}>Добавить</button>
<button onClick={() => setMode(mode === 'translate' ? 'rotate' : 'translate')}>
{mode === 'translate' ? 'Перемещение' : 'Вращение'}
</button>
<button onClick={deleteSelected}>Удалить</button>
<button onClick={saveMap}>Сохранить</button>
</div>
</div>
);
}

131
src/pages/RegisterStep1.jsx Normal file
View File

@@ -0,0 +1,131 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
export default function RegisterStep1() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirm, setConfirm] = useState('');
const [agree, setAgree] = useState(false);
const navigate = useNavigate();
const handleNext = e => {
e.preventDefault();
if (password !== confirm) return alert('Пароли не совпадают');
if (!agree) return alert('Нужно согласиться с условиями');
sessionStorage.setItem('reg_step1', JSON.stringify({ email, password }));
navigate('/register/step2');
};
return (
<div style={{
minHeight: '100vh',
backgroundColor: 'black',
padding: '20px',
color: 'white' // Устанавливаем белый цвет для всего текста в контейнере
}}>
<form onSubmit={handleNext} style={styles.form}>
<h2 style={{ textAlign: 'center', marginBottom: '20px' }}>Регистрация шаг 1</h2>
<label style={styles.label}>
Почта
<input
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
required
style={styles.input}
/>
</label>
<label style={styles.label}>
Пароль
<input
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
required
style={styles.input}
/>
</label>
<label style={styles.label}>
Подтверждение пароля
<input
type="password"
value={confirm}
onChange={e => setConfirm(e.target.value)}
required
style={styles.input}
/>
</label>
<label style={styles.checkbox}>
<input
type="checkbox"
checked={agree}
onChange={e => setAgree(e.target.checked)}
style={{
marginRight: '8px',
padding: '8px',
borderRadius: '7px',
border: '2px solid #444',
backgroundColor: '#333',
color: 'white', // Белый цвет текста в инпутах
fontSize: '14px' }}
/>
Я принимаю условия пользовательского соглашения
</label>
<button type="submit" style={styles.button}>Далее </button>
</form>
</div>
);
}
const styles = {
form: {
maxWidth: 400,
margin: '50px auto',
padding: 20,
display: 'flex',
flexDirection: 'column',
gap: 15,
background: '#222',
borderRadius: 8
},
label: {
display: 'flex',
flexDirection: 'column',
fontSize: '14px',
gap: '5px'
},
input: {
padding: '8px',
borderRadius: '7px',
border: '2px solid #444',
backgroundColor: '#333',
color: 'white', // Белый цвет текста в инпутах
fontSize: '14px'
},
checkbox: {
display: 'flex',
alignItems: 'center',
gap: 8,
fontSize: '14px',
margin: '10px 0'
},
button: {
padding: '12px 20px',
background: '#007bff',
color: '#fff',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '16px',
fontWeight: '500',
transition: 'background 0.3s',
':hover': {
background: '#0056b3'
}
}
};

159
src/pages/RegisterStep2.jsx Normal file
View File

@@ -0,0 +1,159 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
export default function RegisterStep2() {
const navigate = useNavigate();
useEffect(() => {
if (!sessionStorage.getItem('reg_step1')) {
navigate('/register/step1');
}
}, [navigate]);
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [gender, setGender] = useState('male');
const [age, setAge] = useState(18);
const [city, setCity] = useState('Moscow');
const handleNext = e => {
e.preventDefault();
sessionStorage.setItem(
'reg_step2',
JSON.stringify({ firstName, lastName, gender, age, city })
);
navigate('/register/step3');
};
return (
<div style={{
minHeight: '100vh',
backgroundColor: 'black',
padding: '20px',
color: 'white'
}}>
<form onSubmit={handleNext} style={styles.form}>
<h2 style={{ textAlign: 'center', marginBottom: '20px' }}>Регистрация шаг 2</h2>
<label style={styles.label}>
Имя
<input
value={firstName}
onChange={e => setFirstName(e.target.value)}
required
style={styles.input}
/>
</label>
<label style={styles.label}>
Фамилия
<input
value={lastName}
onChange={e => setLastName(e.target.value)}
required
style={styles.input}
/>
</label>
<label style={styles.label}>
Пол
<select
value={gender}
onChange={e => setGender(e.target.value)}
style={styles.select}
>
<option value="male">Мужчина</option>
<option value="female">Женщина</option>
</select>
</label>
<label style={styles.label}>
Возраст
<input
type="number"
min={18}
value={age}
onChange={e => setAge(+e.target.value)}
required
style={styles.input}
/>
</label>
<label style={styles.label}>
Город
<select
value={city}
onChange={e => setCity(e.target.value)}
style={styles.select}
>
<option value="Moscow">Москва</option>
<option value="SaintP">Санкт-Петербург</option>
<option value="NewYork">Нью-Йорк</option>
<option value="LA">Лос-Анджелес</option>
</select>
</label>
<button type="submit" style={styles.button}>Далее </button>
</form>
</div>
);
}
const styles = {
form: {
maxWidth: 400,
margin: '50px auto',
padding: 20,
display: 'flex',
flexDirection: 'column',
gap: 15,
background: '#222',
borderRadius: 8
},
label: {
display: 'flex',
flexDirection: 'column',
fontSize: '14px',
gap: '5px',
marginBottom: '10px'
},
input: {
backgroundColor: 'gray',
padding: '8px',
borderRadius: '7px',
border: '2px solid #444',
backgroundColor: '#333',
color: 'white',
fontSize: '14px',
':hover': {
background: '#218838'
}
},
select: {
padding: '8px',
borderRadius: '7px',
border: '2px solid #444',
backgroundColor: '#333',
color: 'white',
fontSize: '14px',
cursor: 'pointer',
':hover': {
background: '#218838'
}
},
button: {
padding: '12px 20px',
background: '#007bff',
color: '#fff',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '16px',
fontWeight: '500',
transition: 'background 0.3s',
marginTop: '15px',
':hover': {
background: '#218838'
}
}
};

118
src/pages/RegisterStep3.jsx Normal file
View File

@@ -0,0 +1,118 @@
// src/pages/RegisterStep3.jsx
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
// данные аватаров
const avatars = {
male: [
{ name:'Мужчина 1', url:'https://models.readyplayer.me/68013c216026f5144dce1613.glb' },
{ name:'Мужчина 2', url:'https://models.readyplayer.me/68013cf0647a08a2e39f842d.glb' },
],
female: [
{ name:'Женщина 1', url:'https://models.readyplayer.me/680d174ea4d963314ffdd26d.glb' },
{ name:'Женщина 2', url:'https://models.readyplayer.me/680d16d12c0e4a08e3b1de22.glb' },
],
};
export default function RegisterStep3() {
const navigate = useNavigate();
const [gender, setGender] = useState('male');
const [avatarURL, setAvatarURL] = useState('');
async function handleSubmit(e) {
e.preventDefault();
try {
// завершающий вызов регистрации (оставь твой URL/тело запроса как было)
const res = await fetch('/api/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ gender, avatarURL })
});
const data = await res.json();
if (!res.ok || !data.success) {
alert('Ошибка регистрации');
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');
} catch (e) {
console.error(e);
alert('Ошибка регистрации (шаг 3)');
}
}
return (
<form onSubmit={handleSubmit} style={{ maxWidth: 400, margin: '40px auto' }}>
<h2>Шаг 3: профиль</h2>
<label style={{ display: 'block', marginBottom: 8 }}>
Пол:
<select value={gender} onChange={e => setGender(e.target.value)} style={{ marginLeft: 8 }}>
<option value="male">Мужской</option>
<option value="female">Женский</option>
</select>
</label>
<label style={{ display: 'block', marginBottom: 8 }}>
URL аватара:
<input value={avatarURL} onChange={e => setAvatarURL(e.target.value)} style={{ width: '100%' }} />
</label>
<button type="submit">Завершить</button>
</form>
);
}
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'
}
};

View File

@@ -0,0 +1,52 @@
import React, { useRef, useEffect } from 'react';
import WaveSurfer from 'wavesurfer.js';
const WaveformPlayer = ({ url, playing }) => {
const waveformRef = useRef(null);
const wavesurfer = useRef(null);
useEffect(() => {
wavesurfer.current = WaveSurfer.create({
container: waveformRef.current,
waveColor: '#999',
progressColor: '#0f0',
height: 80,
barWidth: 2,
scrollParent: true,
responsive: true,
minPxPerSec: 120, // волна станет длиннее
});
wavesurfer.current.load(url)
.catch(error => {
console.error('Ошибка загрузки аудио:', error);
});
return () => {
wavesurfer.current.destroy();
};
}, [url]);
useEffect(() => {
if (!wavesurfer.current) return;
if (playing) {
wavesurfer.current.play();
} else {
wavesurfer.current.pause();
}
}, [playing]);
return (
<div
ref={waveformRef}
style={{
width: '100%',
height: '100%',
overflowX: 'auto',
overflowY: 'hidden'
}}
/>
);
};
export default WaveformPlayer;