Первый коммит после распаковки архива
This commit is contained in:
8
src/App.css
Normal file
8
src/App.css
Normal file
@@ -0,0 +1,8 @@
|
||||
/* src/App.css */
|
||||
/* пока что пусто или добавьте что-нибудь простое */
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: sans-serif;
|
||||
}
|
||||
|
||||
84
src/App.js
Normal file
84
src/App.js
Normal 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
47
src/AvatarCreator.js
Normal 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
95
src/CharacterSelect.js
Normal 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
4164
src/Game.js
Normal file
File diff suppressed because it is too large
Load Diff
36
src/api/auth.js
Normal file
36
src/api/auth.js
Normal 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();
|
||||
}
|
||||
|
||||
126
src/components/DialogSystem/DialogManager.js
Normal file
126
src/components/DialogSystem/DialogManager.js
Normal 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
|
||||
};
|
||||
};
|
||||
179
src/components/DialogSystem/DialogWindow.js
Normal file
179
src/components/DialogSystem/DialogWindow.js
Normal 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>
|
||||
);
|
||||
};
|
||||
39
src/components/GameWrapper.jsx
Normal file
39
src/components/GameWrapper.jsx
Normal 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 />;
|
||||
}
|
||||
35
src/components/Inventory.jsx
Normal file
35
src/components/Inventory.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
66
src/components/Loading.jsx
Normal file
66
src/components/Loading.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
127
src/components/LoginScene.jsx
Normal file
127
src/components/LoginScene.jsx
Normal 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' }} />;
|
||||
}
|
||||
92
src/components/OrgControlPanel.jsx
Normal file
92
src/components/OrgControlPanel.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
57
src/components/RequireProfile.jsx
Normal file
57
src/components/RequireProfile.jsx
Normal 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}</>;
|
||||
}
|
||||
BIN
src/components/images/222.gif
Normal file
BIN
src/components/images/222.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 77 KiB |
BIN
src/components/images/photo_2025-02-22_19-35-03.jpg
Normal file
BIN
src/components/images/photo_2025-02-22_19-35-03.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 101 KiB |
BIN
src/components/images/photo_2025-02-22_19-39-34.jpg
Normal file
BIN
src/components/images/photo_2025-02-22_19-39-34.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 255 KiB |
20
src/index.js
Normal file
20
src/index.js
Normal 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
1
src/js/pathfinding-browser.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
21
src/pages/DoubleTapWrapper.jsx
Normal file
21
src/pages/DoubleTapWrapper.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
254
src/pages/InteriorEditor.jsx
Normal file
254
src/pages/InteriorEditor.jsx
Normal 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
110
src/pages/Login copy.jsx
Normal 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
129
src/pages/Login.jsx
Normal 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
256
src/pages/MapEditor.jsx
Normal 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
131
src/pages/RegisterStep1.jsx
Normal 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
159
src/pages/RegisterStep2.jsx
Normal 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
118
src/pages/RegisterStep3.jsx
Normal 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'
|
||||
}
|
||||
};
|
||||
52
src/pages/WaveformPlayer.jsx
Normal file
52
src/pages/WaveformPlayer.jsx
Normal 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;
|
||||
Reference in New Issue
Block a user