adding validated services? patching forcad_local.py
This commit is contained in:
2
OmCTF-2025/services/block_game/frontend/.dockerignore
Normal file
2
OmCTF-2025/services/block_game/frontend/.dockerignore
Normal file
@@ -0,0 +1,2 @@
|
||||
/node_modules
|
||||
/build
|
||||
2
OmCTF-2025/services/block_game/frontend/.gitignore
vendored
Normal file
2
OmCTF-2025/services/block_game/frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/node_modules
|
||||
/build
|
||||
19
OmCTF-2025/services/block_game/frontend/Dockerfile
Normal file
19
OmCTF-2025/services/block_game/frontend/Dockerfile
Normal file
@@ -0,0 +1,19 @@
|
||||
FROM node:24-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
|
||||
RUN npm install
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:latest
|
||||
|
||||
COPY --from=builder /app/build /usr/share/nginx/html
|
||||
|
||||
COPY nginx.conf /etc/nginx/nginx.conf
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
10
OmCTF-2025/services/block_game/frontend/Dockerfile.dev
Normal file
10
OmCTF-2025/services/block_game/frontend/Dockerfile.dev
Normal file
@@ -0,0 +1,10 @@
|
||||
FROM node:24-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
|
||||
RUN npm install
|
||||
|
||||
# Start development server with hot reload
|
||||
CMD ["npm", "start"]
|
||||
7
OmCTF-2025/services/block_game/frontend/README.md
Normal file
7
OmCTF-2025/services/block_game/frontend/README.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# Frontend
|
||||
|
||||
WARNING: Heavily vibe-coded, but NO client-side exploits intended!
|
||||
|
||||
If you find any, text @maximxlss on telegram and get my appreciation.
|
||||
|
||||
NOTE: the checker mimics a PLAYER, only the things accessible from the app ;)
|
||||
55
OmCTF-2025/services/block_game/frontend/nginx.conf
Normal file
55
OmCTF-2025/services/block_game/frontend/nginx.conf
Normal file
@@ -0,0 +1,55 @@
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
sendfile on;
|
||||
tcp_nopush on;
|
||||
tcp_nodelay on;
|
||||
keepalive_timeout 65;
|
||||
client_max_body_size 10M;
|
||||
|
||||
server {
|
||||
listen 8080 default_server;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html index.htm;
|
||||
|
||||
location /api/ {
|
||||
rewrite ^/api/(.*) /$1 break;
|
||||
proxy_pass http://backend:8080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_connect_timeout 75s;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_proxied any;
|
||||
gzip_types
|
||||
text/plain
|
||||
text/css
|
||||
application/javascript
|
||||
application/json
|
||||
image/svg+xml;
|
||||
}
|
||||
}
|
||||
29984
OmCTF-2025/services/block_game/frontend/package-lock.json
generated
Normal file
29984
OmCTF-2025/services/block_game/frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
43
OmCTF-2025/services/block_game/frontend/package.json
Normal file
43
OmCTF-2025/services/block_game/frontend/package.json
Normal file
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"name": "block-game-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@types/node": "24.6.0",
|
||||
"@types/react": "19.1.16",
|
||||
"@types/react-dom": "19.1.9",
|
||||
"react": "19.1.1",
|
||||
"react-dom": "19.1.1",
|
||||
"react-router-dom": "7.9.3",
|
||||
"typescript": "^4.9.0",
|
||||
"web-vitals": "5.1.0",
|
||||
"axios": "1.12.2"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"test": "react-scripts test",
|
||||
"eject": "react-scripts eject"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
"react-app",
|
||||
"react-app/jest"
|
||||
]
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"react-scripts": "5.0.1"
|
||||
}
|
||||
}
|
||||
BIN
OmCTF-2025/services/block_game/frontend/public/favicon.ico
Normal file
BIN
OmCTF-2025/services/block_game/frontend/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
18
OmCTF-2025/services/block_game/frontend/public/index.html
Normal file
18
OmCTF-2025/services/block_game/frontend/public/index.html
Normal file
@@ -0,0 +1,18 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta name="description" content="Block Game - A puzzle game where you move blocks to reach the exit" />
|
||||
<title>Block Game</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
79
OmCTF-2025/services/block_game/frontend/src/App.css
Normal file
79
OmCTF-2025/services/block_game/frontend/src/App.css
Normal file
@@ -0,0 +1,79 @@
|
||||
@import './shared.css';
|
||||
|
||||
/* Global App Styles */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||
monospace;
|
||||
}
|
||||
|
||||
.app-loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.app-loading p {
|
||||
font-size: 1.2rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.app-error {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
background: linear-gradient(135deg, #dc3545 0%, #c82333 100%);
|
||||
color: white;
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.app-error h1 {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.app-error button {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border: 1px solid rgba(255, 255, 255, 0.4);
|
||||
color: white;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.app-error button:hover {
|
||||
background: rgba(255, 255, 255, 0.4);
|
||||
border-color: rgba(255, 255, 255, 0.6);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
152
OmCTF-2025/services/block_game/frontend/src/App.tsx
Normal file
152
OmCTF-2025/services/block_game/frontend/src/App.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import axios from 'axios';
|
||||
import Login from './components/Auth/Login';
|
||||
import Register from './components/Auth/Register';
|
||||
import LevelList from './components/Levels/LevelList';
|
||||
import LevelEditor from './components/Levels/LevelEditor';
|
||||
import GamePlayer from './components/Game/GamePlayer';
|
||||
import { authAPI } from './api';
|
||||
import { Level } from './types';
|
||||
import './App.css';
|
||||
|
||||
type AppState = 'login' | 'register' | 'levels' | 'playing' | 'editing';
|
||||
|
||||
const App: React.FC = () => {
|
||||
const [appState, setAppState] = useState<AppState>('login');
|
||||
const [currentLevelId, setCurrentLevelId] = useState<number | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [authStatusMessage, setAuthStatusMessage] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Check if user is already logged in via cookie
|
||||
validateAuth();
|
||||
}, []);
|
||||
|
||||
const validateAuth = async () => {
|
||||
try {
|
||||
await authAPI.whoami();
|
||||
setAppState('levels');
|
||||
setAuthStatusMessage(null);
|
||||
} catch (error: unknown) {
|
||||
const message = axios.isAxiosError(error) && error.response?.status === 400
|
||||
? (typeof error.response.data === 'string' ? error.response.data : error.response.data?.message || 'Bad Request')
|
||||
: null;
|
||||
setAuthStatusMessage(message ? `Auth check failed: ${message}` : null);
|
||||
setAppState('login');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAuthSuccess = () => {
|
||||
setAppState('levels');
|
||||
setAuthStatusMessage(null);
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await authAPI.logout();
|
||||
} catch (error) {
|
||||
console.error('Logout failed:', error);
|
||||
} finally {
|
||||
setAppState('login');
|
||||
setCurrentLevelId(null);
|
||||
setAuthStatusMessage(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePlayLevel = (levelId: number) => {
|
||||
setCurrentLevelId(levelId);
|
||||
setAppState('playing');
|
||||
};
|
||||
|
||||
const handleCreateLevel = () => {
|
||||
setAppState('editing');
|
||||
};
|
||||
|
||||
const handleCancelEditor = () => {
|
||||
setAppState('levels');
|
||||
};
|
||||
|
||||
const handleLevelSaved = (level: Level) => {
|
||||
console.log('Level saved:', level);
|
||||
setAppState('levels');
|
||||
};
|
||||
|
||||
const handleBackToLevels = () => {
|
||||
setCurrentLevelId(null);
|
||||
setAppState('levels');
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="app-loading">
|
||||
<div className="loading-spinner"></div>
|
||||
<p>Loading Block Game...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
switch (appState) {
|
||||
case 'login':
|
||||
return (
|
||||
<Login
|
||||
onLogin={handleAuthSuccess}
|
||||
authStatusMessage={authStatusMessage ?? undefined}
|
||||
onSwitchToRegister={() => {
|
||||
setAuthStatusMessage(null);
|
||||
setAppState('register');
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'register':
|
||||
return (
|
||||
<Register
|
||||
onRegister={handleAuthSuccess}
|
||||
onSwitchToLogin={() => setAppState('login')}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'levels':
|
||||
return (
|
||||
<LevelList
|
||||
onPlayLevel={handlePlayLevel}
|
||||
onCreateLevel={handleCreateLevel}
|
||||
onLogout={handleLogout}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'editing':
|
||||
return (
|
||||
<LevelEditor
|
||||
onCancel={handleCancelEditor}
|
||||
onSaved={handleLevelSaved}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'playing':
|
||||
if (!currentLevelId) {
|
||||
setAppState('levels');
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<GamePlayer
|
||||
levelId={currentLevelId}
|
||||
onBack={handleBackToLevels}
|
||||
/>
|
||||
);
|
||||
|
||||
default:
|
||||
return (
|
||||
<div className="app-error">
|
||||
<h1>Something went wrong</h1>
|
||||
<button onClick={() => setAppState('login')}>
|
||||
Go to Login
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default App;
|
||||
65
OmCTF-2025/services/block_game/frontend/src/api.ts
Normal file
65
OmCTF-2025/services/block_game/frontend/src/api.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import axios from 'axios';
|
||||
import { LoginRequest, RegisterRequest, User, Level, LevelSummary, LevelCreateRequest, LevelVisibility } from './types';
|
||||
|
||||
const { protocol, host } = window.location;
|
||||
const HTTP_BASE = `${protocol}//${host}/api`;
|
||||
const WS_BASE = `ws${protocol === 'https:' ? 's' : ''}://${host}/api`;
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: HTTP_BASE,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
export const authAPI = {
|
||||
login: async (credentials: LoginRequest): Promise<User> => {
|
||||
const response = await api.post('/auth/login', credentials);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
register: async (credentials: RegisterRequest): Promise<User> => {
|
||||
const response = await api.post('/auth/register', credentials);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
logout: async (): Promise<void> => {
|
||||
await api.post('/auth/logout');
|
||||
},
|
||||
|
||||
whoami: async (): Promise<User> => {
|
||||
const response = await api.get('/user');
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
export const levelAPI = {
|
||||
listLevels: async (page?: number): Promise<LevelSummary[]> => {
|
||||
const response = await api.get(`/user/levels`, {
|
||||
params: { page },
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getLevel: async (levelId: number): Promise<Level> => {
|
||||
const response = await api.get(`/user/level/${levelId}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
findLevel: async (name: string): Promise<Level> => {
|
||||
const response = await api.get(`/user/level`, {
|
||||
params: { name },
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
createLevel: async (levelData: LevelCreateRequest): Promise<Level> => {
|
||||
const response = await api.post('/user/level', levelData);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getPlayLevelWS: (levelId: number): string => {
|
||||
return `${WS_BASE}/user/level/${levelId}/play`;
|
||||
},
|
||||
};
|
||||
|
||||
export default api;
|
||||
@@ -0,0 +1,67 @@
|
||||
@import '../../shared.css';
|
||||
|
||||
.auth-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.auth-form {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
background: white;
|
||||
color: #333;
|
||||
padding: 2rem;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.auth-form h2 {
|
||||
text-align: center;
|
||||
margin-bottom: 1.5rem;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.auth-button {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.3s;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.auth-button:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.auth-button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.link-button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #667eea;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
.link-button:hover {
|
||||
color: #764ba2;
|
||||
}
|
||||
|
||||
p {
|
||||
text-align: center;
|
||||
color: #666;
|
||||
margin: 0;
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import React, { useState } from 'react';
|
||||
import './Auth.css';
|
||||
|
||||
interface AuthFormProps {
|
||||
title: string;
|
||||
onSubmit: (credentials: { username: string; password: string }) => Promise<void>;
|
||||
submitText: string;
|
||||
loadingText: string;
|
||||
additionalFields?: React.ReactNode;
|
||||
footer: React.ReactNode;
|
||||
authStatusMessage?: string;
|
||||
}
|
||||
|
||||
const AuthForm: React.FC<AuthFormProps> = ({
|
||||
title,
|
||||
onSubmit,
|
||||
submitText,
|
||||
loadingText,
|
||||
additionalFields,
|
||||
footer,
|
||||
authStatusMessage
|
||||
}) => {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
await onSubmit({ username, password });
|
||||
} catch (err: any) {
|
||||
setError(`${title} failed: ${err}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="auth-container">
|
||||
<div className="auth-form">
|
||||
<h2>{title}</h2>
|
||||
{authStatusMessage && (
|
||||
<div className="info-message">{authStatusMessage}</div>
|
||||
)}
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-group">
|
||||
<label htmlFor="username">Username:</label>
|
||||
<input
|
||||
type="text"
|
||||
id="username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="password">Password:</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
{additionalFields}
|
||||
{error && <div className="error-message">{error}</div>}
|
||||
<button type="submit" disabled={loading} className="auth-button">
|
||||
{loading ? loadingText : submitText}
|
||||
</button>
|
||||
</form>
|
||||
{footer}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuthForm;
|
||||
@@ -0,0 +1,36 @@
|
||||
import React from 'react';
|
||||
import { authAPI } from '../../api';
|
||||
import AuthForm from './AuthForm';
|
||||
|
||||
interface LoginProps {
|
||||
onLogin: () => void;
|
||||
onSwitchToRegister: () => void;
|
||||
authStatusMessage?: string;
|
||||
}
|
||||
|
||||
const Login: React.FC<LoginProps> = ({ onLogin, onSwitchToRegister, authStatusMessage }) => {
|
||||
const handleLogin = async (credentials: { username: string; password: string }) => {
|
||||
await authAPI.login(credentials);
|
||||
onLogin();
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthForm
|
||||
title="Login to Block Game"
|
||||
onSubmit={handleLogin}
|
||||
submitText="Login"
|
||||
loadingText="Logging in..."
|
||||
authStatusMessage={authStatusMessage}
|
||||
footer={
|
||||
<p>
|
||||
Don't have an account?{' '}
|
||||
<button onClick={onSwitchToRegister} className="link-button">
|
||||
Register here
|
||||
</button>
|
||||
</p>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default Login;
|
||||
@@ -0,0 +1,51 @@
|
||||
import React, { useState } from 'react';
|
||||
import { authAPI } from '../../api';
|
||||
import AuthForm from './AuthForm';
|
||||
|
||||
interface RegisterProps {
|
||||
onRegister: () => void;
|
||||
onSwitchToLogin: () => void;
|
||||
}
|
||||
|
||||
const Register: React.FC<RegisterProps> = ({ onRegister, onSwitchToLogin }) => {
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
|
||||
const handleRegister = async (credentials: { username: string; password: string }) => {
|
||||
if (credentials.password !== confirmPassword) {
|
||||
throw new Error('Passwords do not match');
|
||||
}
|
||||
await authAPI.register(credentials);
|
||||
onRegister();
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthForm
|
||||
title="Register for Block Game"
|
||||
onSubmit={handleRegister}
|
||||
submitText="Register"
|
||||
loadingText="Creating Account..."
|
||||
additionalFields={
|
||||
<div className="form-group">
|
||||
<label htmlFor="confirmPassword">Confirm Password:</label>
|
||||
<input
|
||||
type="password"
|
||||
id="confirmPassword"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
footer={
|
||||
<p>
|
||||
Already have an account?{' '}
|
||||
<button onClick={onSwitchToLogin} className="link-button">
|
||||
Login here
|
||||
</button>
|
||||
</p>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default Register;
|
||||
@@ -0,0 +1,356 @@
|
||||
/* Game Container */
|
||||
.game-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.game-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem 2rem;
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.game-header h1 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.game-controls {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.connection-status {
|
||||
padding: 0.5rem 1rem;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 5px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.back-button,
|
||||
.restart-button {
|
||||
padding: 0.5rem 1rem;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border: 1px solid rgba(255, 255, 255, 0.4);
|
||||
color: white;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
font-weight: 500;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.back-button:hover,
|
||||
.restart-button:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.4);
|
||||
border-color: rgba(255, 255, 255, 0.6);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.restart-button {
|
||||
background: rgba(255, 165, 0, 0.9);
|
||||
border-color: rgba(255, 165, 0, 0.9);
|
||||
color: white;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.restart-button:hover:not(:disabled) {
|
||||
background: rgba(255, 140, 0, 1);
|
||||
border-color: rgba(255, 140, 0, 1);
|
||||
box-shadow: 0 2px 8px rgba(255, 165, 0, 0.4);
|
||||
}
|
||||
|
||||
.restart-button:disabled {
|
||||
background: rgba(200, 200, 200, 0.3);
|
||||
border-color: rgba(200, 200, 200, 0.3);
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
text-shadow: none;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* Game Main Area */
|
||||
.game-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 2rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Game Board */
|
||||
.game-board {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border-radius: 10px;
|
||||
padding: 1rem;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
/* Button and Door State Indicators */
|
||||
.game-cell.button-pressed {
|
||||
background: #ffd700 !important;
|
||||
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.2) !important;
|
||||
}
|
||||
|
||||
.game-cell.door-opened {
|
||||
background: #90ee90 !important;
|
||||
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.1) !important;
|
||||
}
|
||||
|
||||
.game-grid {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
background: #ddd;
|
||||
border: 2px solid #999;
|
||||
}
|
||||
|
||||
.game-cell {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: #f0f0f0;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.3s ease;
|
||||
}
|
||||
|
||||
.tile {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.5rem;
|
||||
z-index: 1;
|
||||
transition: background 0.3s ease, transform 0.2s ease, opacity 0.3s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.tile-player {
|
||||
z-index: 6;
|
||||
}
|
||||
|
||||
.tile-box {
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.tile-wall {
|
||||
background: #654321;
|
||||
border: 2px solid #8b4513;
|
||||
z-index: 4;
|
||||
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.tile-door {
|
||||
background: #8b4513;
|
||||
border: 2px solid #cd853f;
|
||||
z-index: 3;
|
||||
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.tile-button {
|
||||
background: #ffd700;
|
||||
border: 2px solid #ffb300;
|
||||
z-index: 2;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.tile-button.pressed {
|
||||
background: #ff8c00;
|
||||
border-color: #ff6600;
|
||||
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.4);
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.tile-door.opened {
|
||||
background: #90ee90;
|
||||
border-color: #32cd32;
|
||||
opacity: 0.8;
|
||||
box-shadow: 0 0 8px rgba(50, 205, 50, 0.4);
|
||||
}
|
||||
|
||||
.tile-exit {
|
||||
background: #228b22;
|
||||
border: 2px solid #32cd32;
|
||||
z-index: 2;
|
||||
box-shadow: 0 0 8px rgba(50, 205, 50, 0.3);
|
||||
}
|
||||
|
||||
/* Level Complete Modal */
|
||||
.level-complete-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.level-complete-modal {
|
||||
background: white;
|
||||
color: #333;
|
||||
padding: 2rem;
|
||||
border-radius: 15px;
|
||||
text-align: center;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||
max-width: 400px;
|
||||
border: 2px solid rgba(102, 126, 234, 0.2);
|
||||
}
|
||||
|
||||
/* Dark mode support (respects user preference but doesn't override our design) */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.level-complete-modal {
|
||||
background: #f8f9fa;
|
||||
border-color: rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
.level-complete-modal h2 {
|
||||
margin: 0 0 1rem 0;
|
||||
color: #32CD32;
|
||||
}
|
||||
|
||||
.prize {
|
||||
font-size: 1.1rem;
|
||||
margin: 1rem 0;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.continue-button {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.75rem 1.5rem;
|
||||
font-size: 1rem;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
margin-top: 1rem;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.continue-button:hover {
|
||||
background: linear-gradient(135deg, #5a6fd8 0%, #6a4190 100%);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.continue-button:active {
|
||||
transform: translateY(0);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
/* Game Instructions */
|
||||
.game-instructions {
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
padding: 1rem 2rem;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.game-instructions h3 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
}
|
||||
|
||||
.controls-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.controls-grid div {
|
||||
font-size: 0.9rem;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* Loading and Error States */
|
||||
.game-loading,
|
||||
.game-error {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 4px solid rgba(255, 255, 255, 0.3);
|
||||
border-top: 4px solid white;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.game-error h2 {
|
||||
margin: 0 0 1rem 0;
|
||||
}
|
||||
|
||||
.game-error p {
|
||||
margin: 0 0 1rem 0;
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 768px) {
|
||||
.game-header {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.game-controls {
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.game-main {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.game-cell {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
.tile {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.controls-grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import React, { useMemo, useCallback } from 'react';
|
||||
import { Tiles, Tile } from '../../types';
|
||||
import './Game.css';
|
||||
|
||||
interface GameBoardProps {
|
||||
tiles: Tiles;
|
||||
}
|
||||
|
||||
const TILE_PRIORITY = {
|
||||
floor: 0,
|
||||
button: 1,
|
||||
exit: 2,
|
||||
door: 3,
|
||||
wall: 4,
|
||||
box: 5,
|
||||
player: 6
|
||||
} as const;
|
||||
|
||||
type TileKind = keyof typeof TILE_PRIORITY;
|
||||
|
||||
const TILE_SYMBOLS: Record<string, string> = {
|
||||
'player': '🚶',
|
||||
'wall': '🧱',
|
||||
'box': '📦',
|
||||
'door': '🚪',
|
||||
'button': '🔘',
|
||||
'exit': '🏁',
|
||||
'floor': ''
|
||||
};
|
||||
|
||||
function areSetsEqual<T>(setA: Set<T>, setB: Set<T>): boolean {
|
||||
if (setA.size !== setB.size) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const item of setA) {
|
||||
if (!setB.has(item)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
const GameCell: React.FC<{
|
||||
tileKinds: Set<TileKind>;
|
||||
hasButton?: boolean;
|
||||
isButtonPressed?: boolean;
|
||||
isDoorOpened?: boolean;
|
||||
}> = React.memo(({ tileKinds, hasButton, isButtonPressed, isDoorOpened }) => {
|
||||
const tileKindsWithButton: TileKind[] = hasButton ? [...tileKinds, 'button'] : [...tileKinds];
|
||||
|
||||
const sortedTiles = [...tileKindsWithButton].sort((a, b) => {
|
||||
const priorityA = TILE_PRIORITY[a] ?? 0;
|
||||
const priorityB = TILE_PRIORITY[b] ?? 0;
|
||||
return priorityA - priorityB;
|
||||
});
|
||||
|
||||
const classList = [
|
||||
'game-cell',
|
||||
...sortedTiles.map(kind => `has-${kind}`)
|
||||
];
|
||||
|
||||
if (isButtonPressed) classList.push('button-pressed');
|
||||
if (isDoorOpened) classList.push('door-opened');
|
||||
|
||||
const cellClasses = classList.join(' ');
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cellClasses}
|
||||
role="gridcell"
|
||||
>
|
||||
{hasButton && (
|
||||
<div
|
||||
key="button"
|
||||
className={[`tile`, `tile-button`, isButtonPressed ? 'pressed' : ''].filter(Boolean).join(' ')}
|
||||
role="img"
|
||||
>
|
||||
{TILE_SYMBOLS['button']}
|
||||
</div>
|
||||
)}
|
||||
{sortedTiles.map((tileKind, idx) => {
|
||||
const tileClasses = [`tile`, `tile-${tileKind}`];
|
||||
|
||||
if (tileKind === 'button' && isButtonPressed) {
|
||||
tileClasses.push('pressed');
|
||||
}
|
||||
if (tileKind === 'door' && isDoorOpened) {
|
||||
tileClasses.push('opened');
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${tileKind}-${idx}`}
|
||||
className={tileClasses.join(' ')}
|
||||
role="img"
|
||||
>
|
||||
{TILE_SYMBOLS[tileKind] || ''}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}, (prevProps, nextProps) => {
|
||||
return (
|
||||
prevProps.hasButton === nextProps.hasButton &&
|
||||
prevProps.isButtonPressed === nextProps.isButtonPressed &&
|
||||
prevProps.isDoorOpened === nextProps.isDoorOpened &&
|
||||
areSetsEqual(prevProps.tileKinds, nextProps.tileKinds)
|
||||
);
|
||||
});
|
||||
|
||||
GameCell.displayName = 'GameCell';
|
||||
|
||||
const GameBoard: React.FC<GameBoardProps> = React.memo(({ tiles }) => {
|
||||
const size = tiles?.size || 1;
|
||||
|
||||
// Create efficient tile lookup map
|
||||
const tileMap = useMemo(() => {
|
||||
const map = new Map<string, Tile[]>();
|
||||
if (tiles?.tiles) {
|
||||
for (const tile of tiles.tiles) {
|
||||
const key = `${tile.pos.x},${tile.pos.y}`;
|
||||
if (!map.has(key)) {
|
||||
map.set(key, []);
|
||||
}
|
||||
map.get(key)!.push(tile);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [tiles]);
|
||||
|
||||
// Memo all button positions and their states
|
||||
const { buttonStates, doorStates, buttonPositions } = useMemo(() => {
|
||||
const buttonPressed = new Map<string, boolean>();
|
||||
const doorOpened = new Map<string, boolean>();
|
||||
const buttonPositions = new Map<string, boolean>();
|
||||
|
||||
if (!tiles?.tiles) return { buttonStates: buttonPressed, doorStates: doorOpened, buttonPositions };
|
||||
|
||||
const doors = tiles.tiles.filter(tile => tile.kind === 'door');
|
||||
doors.forEach(door => {
|
||||
const doorKey = `${door.pos.x},${door.pos.y}`;
|
||||
let isOpened = false;
|
||||
|
||||
if (door.data && door.data.button_position) {
|
||||
const buttonPos = door.data.button_position;
|
||||
const buttonKey = `${buttonPos.x},${buttonPos.y}`;
|
||||
|
||||
buttonPositions.set(buttonKey, true);
|
||||
|
||||
const tilesAtButtonPosition = tileMap.get(buttonKey) || [];
|
||||
const isPressed = tilesAtButtonPosition.some(tile =>
|
||||
tile.kind === 'player' || tile.kind === 'box'
|
||||
);
|
||||
|
||||
buttonPressed.set(buttonKey, isPressed);
|
||||
|
||||
isOpened = isPressed;
|
||||
}
|
||||
|
||||
doorOpened.set(doorKey, isOpened);
|
||||
});
|
||||
|
||||
return { buttonStates: buttonPressed, doorStates: doorOpened, buttonPositions };
|
||||
}, [tiles, tileMap]);
|
||||
|
||||
const getTileKindsAtPosition = useCallback((x: number, y: number): Set<TileKind> => {
|
||||
const positionKey = `${x},${y}`;
|
||||
const existingTiles = tileMap.get(positionKey) || [];
|
||||
return new Set(existingTiles.map(tile => tile.kind as TileKind));
|
||||
}, [tileMap]);
|
||||
|
||||
const pointHasButton = useCallback((x: number, y: number): boolean => {
|
||||
return buttonPositions.has(`${x},${y}`);
|
||||
}, [buttonPositions]);
|
||||
|
||||
const isButtonPressed = useCallback((x: number, y: number): boolean => {
|
||||
const positionKey = `${x},${y}`;
|
||||
return buttonPositions.has(positionKey) && (buttonStates.get(positionKey) || false);
|
||||
}, [buttonStates, buttonPositions]);
|
||||
|
||||
const isDoorOpened = useCallback((x: number, y: number): boolean => {
|
||||
return doorStates.get(`${x},${y}`) || false;
|
||||
}, [doorStates]);
|
||||
|
||||
return (
|
||||
<div className="game-board">
|
||||
<div
|
||||
className="game-grid"
|
||||
role="grid"
|
||||
aria-label={`Game board ${size} by ${size}`}
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(${size}, 1fr)`,
|
||||
gridTemplateRows: `repeat(${size}, 1fr)`
|
||||
}}
|
||||
>
|
||||
{Array.from({ length: size * size }, (_, index) => {
|
||||
const x = index % size;
|
||||
const y = Math.floor(index / size);
|
||||
return (
|
||||
<GameCell
|
||||
key={`${x}-${y}`}
|
||||
tileKinds={getTileKindsAtPosition(x, y)}
|
||||
hasButton={pointHasButton(x, y)}
|
||||
isButtonPressed={isButtonPressed(x, y)}
|
||||
isDoorOpened={isDoorOpened(x, y)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default GameBoard;
|
||||
@@ -0,0 +1,214 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import { useWebSocketGame } from '../../hooks/useWebSocketGame';
|
||||
import { levelAPI } from '../../api';
|
||||
import { Level, Direction, Tiles } from '../../types';
|
||||
import GameBoard from './GameBoard';
|
||||
import './Game.css';
|
||||
|
||||
interface GamePlayerProps {
|
||||
levelId: number;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
const GamePlayer: React.FC<GamePlayerProps> = ({ levelId, onBack }) => {
|
||||
const [level, setLevel] = useState<Level | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [tiles, setTiles] = useState<Tiles | null>(null);
|
||||
const [wsUrl, setWsUrl] = useState<string | null>(null);
|
||||
|
||||
const {
|
||||
connected,
|
||||
levelComplete,
|
||||
prize,
|
||||
sendMove
|
||||
} = useWebSocketGame(wsUrl, tiles, setTiles);
|
||||
|
||||
useEffect(() => {
|
||||
loadLevel();
|
||||
}, [levelId]);
|
||||
|
||||
const loadLevel = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const levelData = await levelAPI.getLevel(levelId);
|
||||
setLevel(levelData);
|
||||
|
||||
// Set tiles first
|
||||
setTiles(levelData.data);
|
||||
|
||||
// Only after tiles are loaded, set WebSocket URL to initiate connection
|
||||
const url = levelAPI.getPlayLevelWS(levelId);
|
||||
setWsUrl(url);
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.message || 'Failed to load level');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestart = useCallback(() => {
|
||||
// Force reconnection by clearing the WebSocket URL and resetting state
|
||||
setWsUrl(null);
|
||||
|
||||
// Reset tiles to the original level data
|
||||
if (level) {
|
||||
setTiles(level.data);
|
||||
}
|
||||
|
||||
// Force a new WebSocket connection by setting the URL again
|
||||
// We need to do this after a small delay to ensure the old connection is fully closed
|
||||
setTimeout(() => {
|
||||
const url = levelAPI.getPlayLevelWS(levelId);
|
||||
setWsUrl(url);
|
||||
}, 100);
|
||||
}, [level, levelId]);
|
||||
|
||||
const handleKeyPress = useCallback((event: KeyboardEvent) => {
|
||||
if (!connected || levelComplete || !tiles) return;
|
||||
|
||||
let direction: Direction;
|
||||
switch (event.key.toLowerCase()) {
|
||||
case 'arrowup':
|
||||
case 'w':
|
||||
direction = 'up';
|
||||
break;
|
||||
case 'arrowdown':
|
||||
case 's':
|
||||
direction = 'down';
|
||||
break;
|
||||
case 'arrowleft':
|
||||
case 'a':
|
||||
direction = 'left';
|
||||
break;
|
||||
case 'arrowright':
|
||||
case 'd':
|
||||
direction = 'right';
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the player's current position
|
||||
const player = tiles.tiles.find(tile => tile.kind === 'player');
|
||||
if (!player) {
|
||||
console.warn('Player not found on board');
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate the new position
|
||||
let newX = player.pos.x;
|
||||
let newY = player.pos.y;
|
||||
|
||||
switch (direction) {
|
||||
case 'up':
|
||||
newY -= 1;
|
||||
break;
|
||||
case 'down':
|
||||
newY += 1;
|
||||
break;
|
||||
case 'left':
|
||||
newX -= 1;
|
||||
break;
|
||||
case 'right':
|
||||
newX += 1;
|
||||
break;
|
||||
}
|
||||
|
||||
// Check bounds - prevent moves outside the board
|
||||
if (newX < 0 || newX >= tiles.size || newY < 0 || newY >= tiles.size) {
|
||||
console.log(`Move blocked: would go out of bounds (${newX}, ${newY}) on ${tiles.size}x${tiles.size} board`);
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
sendMove(direction);
|
||||
}, [connected, levelComplete, tiles, sendMove]);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('keydown', handleKeyPress);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyPress);
|
||||
};
|
||||
}, [handleKeyPress]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="game-loading">
|
||||
<div className="loading-spinner"></div>
|
||||
<p>Loading level...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="game-error">
|
||||
<h2>Error</h2>
|
||||
<p>{error}</p>
|
||||
<button onClick={onBack} className="back-button">
|
||||
Back to Levels
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!level) {
|
||||
return (
|
||||
<div className="game-error">
|
||||
<h2>Level not found</h2>
|
||||
<button onClick={onBack} className="back-button">
|
||||
Back to Levels
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="game-container">
|
||||
<div className="game-header">
|
||||
<h1>{level.name}</h1>
|
||||
<div className="game-controls">
|
||||
<div className="connection-status">
|
||||
Status: {connected ? 'Connected' : 'Disconnected'}
|
||||
</div>
|
||||
<button onClick={handleRestart} className="restart-button" disabled={!level}>
|
||||
🔄 Restart
|
||||
</button>
|
||||
<button onClick={onBack} className="back-button">
|
||||
Back to Levels
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="game-main">
|
||||
{tiles && <GameBoard tiles={tiles} />}
|
||||
|
||||
{levelComplete && (
|
||||
<div className="level-complete-overlay">
|
||||
<div className="level-complete-modal">
|
||||
<h2>🎉 Level Complete!</h2>
|
||||
<p className="prize">Prize: {prize}</p>
|
||||
<button onClick={onBack} className="continue-button">
|
||||
Continue
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="game-instructions">
|
||||
<h3>Controls</h3>
|
||||
<div className="controls-grid">
|
||||
<div>↑ / W - Move Up</div>
|
||||
<div>↓ / S - Move Down</div>
|
||||
<div>← / A - Move Left</div>
|
||||
<div>→ / D - Move Right</div>
|
||||
</div>
|
||||
<p>Push boxes to buttons to open doors. Reach the exit to win!</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GamePlayer;
|
||||
@@ -0,0 +1,472 @@
|
||||
/* Level Editor Styles */
|
||||
|
||||
.level-editor {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #f5f5f5;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.level-editor-header {
|
||||
background: #2c3e50;
|
||||
color: white;
|
||||
padding: 1rem 2rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.level-editor-header h1 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.cancel-button {
|
||||
background: #e74c3c;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.cancel-button:hover {
|
||||
background: #c0392b;
|
||||
}
|
||||
|
||||
.level-editor-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Sidebar */
|
||||
.editor-sidebar {
|
||||
width: 320px;
|
||||
background: white;
|
||||
border-right: 1px solid #ddd;
|
||||
padding: 1.5rem;
|
||||
overflow-y: auto;
|
||||
box-shadow: 2px 0 4px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.editor-section {
|
||||
margin-bottom: 2rem;
|
||||
padding-bottom: 1.5rem;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.editor-section:last-child {
|
||||
border-bottom: none;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.editor-section h3 {
|
||||
margin: 0 0 1rem 0;
|
||||
color: #2c3e50;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.cancel-link-button {
|
||||
background: #f39c12;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 3px;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cancel-link-button:hover {
|
||||
background: #e67e22;
|
||||
}
|
||||
|
||||
/* Form Elements */
|
||||
.form-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #555;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group textarea:focus {
|
||||
outline: none;
|
||||
border-color: #3498db;
|
||||
box-shadow: 0 0 0 2px rgba(52, 152, 219, 0.2);
|
||||
}
|
||||
|
||||
.form-group textarea {
|
||||
resize: vertical;
|
||||
min-height: 60px;
|
||||
}
|
||||
|
||||
.visibility-toggle {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.visibility-option {
|
||||
border: 1px solid #d0d7de;
|
||||
border-radius: 6px;
|
||||
padding: 0.6rem 0.75rem;
|
||||
font-size: 0.9rem;
|
||||
background: #f8fafc;
|
||||
color: #2c3e50;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.visibility-option:hover {
|
||||
background: #eef5ff;
|
||||
border-color: #3498db;
|
||||
}
|
||||
|
||||
.visibility-option.active {
|
||||
background: #3498db;
|
||||
color: white;
|
||||
border-color: #1f6fb2;
|
||||
box-shadow: 0 2px 6px rgba(52, 152, 219, 0.35);
|
||||
}
|
||||
|
||||
.form-helper {
|
||||
margin-top: 0.35rem;
|
||||
font-size: 0.8rem;
|
||||
color: #6b7280;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.form-group input[type="range"] {
|
||||
padding: 0;
|
||||
height: 6px;
|
||||
background: #ddd;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.clear-button {
|
||||
background: #e74c3c;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.clear-button:hover {
|
||||
background: #c0392b;
|
||||
}
|
||||
|
||||
/* Linking Instructions */
|
||||
.linking-instruction {
|
||||
background: #f39c12;
|
||||
color: white;
|
||||
padding: 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Tool Grid */
|
||||
.tool-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.tool-button {
|
||||
background: white;
|
||||
border: 2px solid #ddd;
|
||||
border-radius: 6px;
|
||||
padding: 0.75rem 0.5rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
transition: all 0.2s;
|
||||
min-height: 60px;
|
||||
}
|
||||
|
||||
.tool-button:hover:not(:disabled) {
|
||||
border-color: #3498db;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.tool-button.selected {
|
||||
border-color: #2c3e50;
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tool-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.tool-symbol {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.tool-name {
|
||||
font-size: 0.75rem;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
/* Save Section */
|
||||
.save-button {
|
||||
background: #27ae60;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
width: 100%;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.save-button:hover:not(:disabled) {
|
||||
background: #229954;
|
||||
}
|
||||
|
||||
.save-button:disabled {
|
||||
background: #95a5a6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background: #e74c3c;
|
||||
color: white;
|
||||
padding: 0.5rem;
|
||||
border-radius: 4px;
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* Main Editor Area */
|
||||
.editor-main {
|
||||
flex: 1;
|
||||
padding: 1.5rem;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.editor-board-container {
|
||||
max-width: 100%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.editor-instructions {
|
||||
background: white;
|
||||
padding: 1rem;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 1rem;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.editor-instructions p {
|
||||
margin: 0;
|
||||
color: #555;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* Editor Board */
|
||||
.editor-board {
|
||||
background: white;
|
||||
padding: 1.5rem;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.editor-grid {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
max-width: 500px;
|
||||
margin: 0 auto;
|
||||
aspect-ratio: 1;
|
||||
background: #34495e;
|
||||
padding: 2px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.editor-cell {
|
||||
background: #ecf0f1;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
border-radius: 2px;
|
||||
transition: all 0.1s;
|
||||
min-height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.editor-cell:hover {
|
||||
background: #d5dbdb;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.editor-cell.has-selected-tool {
|
||||
border: 2px solid #2c3e50;
|
||||
}
|
||||
|
||||
.editor-cell.has-button-indicator {
|
||||
box-shadow: inset 0 0 0 2px #ff6347;
|
||||
}
|
||||
|
||||
.editor-cell.pending-door {
|
||||
box-shadow: inset 0 0 0 3px #f39c12;
|
||||
animation: pulse 1s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
.editor-tile {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
font-size: 1.2rem;
|
||||
z-index: 2;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.editor-cell-coords {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
font-size: 0.6rem;
|
||||
color: #95a5a6;
|
||||
line-height: 1;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.button-indicator {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
font-size: 0.8rem;
|
||||
z-index: 3;
|
||||
background: rgba(255, 99, 71, 0.9);
|
||||
border-radius: 50%;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Preview Section */
|
||||
.editor-preview {
|
||||
background: white;
|
||||
padding: 1.5rem;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.editor-preview h4 {
|
||||
margin: 0 0 1rem 0;
|
||||
color: #2c3e50;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.preview-container {
|
||||
max-width: 300px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.preview-container .game-board {
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 1200px) {
|
||||
.editor-sidebar {
|
||||
width: 280px;
|
||||
}
|
||||
|
||||
.tool-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.level-editor-content {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.editor-sidebar {
|
||||
width: 100%;
|
||||
max-height: 300px;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid #ddd;
|
||||
}
|
||||
|
||||
.level-editor-header {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.level-editor-header h1 {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.editor-main {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.editor-grid {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,412 @@
|
||||
import React, { useState, useCallback, useMemo } from 'react';
|
||||
import { Tile, Tiles, Point, Level, LevelVisibility, LevelCreateRequest } from '../../types';
|
||||
import { levelAPI } from '../../api';
|
||||
import GameBoard from '../Game/GameBoard';
|
||||
import './LevelEditor.css';
|
||||
|
||||
interface LevelEditorProps {
|
||||
onCancel: () => void;
|
||||
onSaved: (level: Level) => void;
|
||||
}
|
||||
|
||||
type TileType = 'wall' | 'box' | 'player' | 'exit' | 'door';
|
||||
|
||||
const TILE_CONFIG: Record<TileType, { symbol: string; color: string }> = {
|
||||
wall: { symbol: '🧱', color: '#8b4513' },
|
||||
box: { symbol: '📦', color: '#daa520' },
|
||||
player: { symbol: '🚶', color: '#4169e1' },
|
||||
exit: { symbol: '🏁', color: '#32cd32' },
|
||||
door: { symbol: '🚪', color: '#9932cc' }
|
||||
};
|
||||
|
||||
const LevelEditor: React.FC<LevelEditorProps> = ({ onCancel, onSaved }) => {
|
||||
const [boardSize, setBoardSize] = useState<number>(5);
|
||||
const [selectedTool, setSelectedTool] = useState<TileType>('wall');
|
||||
const [levelName, setLevelName] = useState<string>('');
|
||||
const [levelDescription, setLevelDescription] = useState<string>('');
|
||||
const [levelPrize, setLevelPrize] = useState<string>('');
|
||||
const [tiles, setTiles] = useState<Tile[]>([]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string>('');
|
||||
const [visibility, setVisibility] = useState<LevelVisibility>('public');
|
||||
|
||||
const [linkingMode, setLinkingMode] = useState<'none' | 'selecting-button-position'>('none');
|
||||
const [pendingDoorPosition, setPendingDoorPosition] = useState<Point | null>(null);
|
||||
|
||||
const boardTiles: Tiles = useMemo(() => ({
|
||||
size: boardSize,
|
||||
tiles: tiles
|
||||
}), [boardSize, tiles]);
|
||||
|
||||
const getTilesAt = useCallback((x: number, y: number): Tile[] => {
|
||||
return tiles.filter(tile => tile.pos.x === x && tile.pos.y === y);
|
||||
}, [tiles]);
|
||||
|
||||
const hasTileType = useCallback((x: number, y: number, type: TileType): boolean => {
|
||||
return getTilesAt(x, y).some(tile => tile.kind === type);
|
||||
}, [getTilesAt]);
|
||||
|
||||
const getButtonPositions = useCallback((): Set<string> => {
|
||||
const buttonPositions = new Set<string>();
|
||||
tiles.forEach(tile => {
|
||||
if (tile.kind === 'door' && tile.data?.button_position) {
|
||||
const pos = tile.data.button_position;
|
||||
buttonPositions.add(`${pos.x},${pos.y}`);
|
||||
}
|
||||
});
|
||||
return buttonPositions;
|
||||
}, [tiles]);
|
||||
|
||||
const hasButtonAt = useCallback((x: number, y: number): boolean => {
|
||||
const buttonPositions = getButtonPositions();
|
||||
return buttonPositions.has(`${x},${y}`);
|
||||
}, [getButtonPositions]);
|
||||
|
||||
const toggleTile = useCallback((x: number, y: number, tileType: TileType) => {
|
||||
setTiles(prevTiles => {
|
||||
let newTiles = [...prevTiles];
|
||||
|
||||
newTiles = newTiles.filter(tile =>
|
||||
!(tile.pos.x === x && tile.pos.y === y && tile.kind === tileType)
|
||||
);
|
||||
|
||||
if (tileType === 'player') {
|
||||
newTiles = newTiles.filter(tile => tile.kind !== 'player');
|
||||
} else if (tileType === 'exit') {
|
||||
newTiles = newTiles.filter(tile => tile.kind !== 'exit');
|
||||
}
|
||||
|
||||
const wasRemoved = prevTiles.some(tile =>
|
||||
tile.pos.x === x && tile.pos.y === y && tile.kind === tileType
|
||||
);
|
||||
|
||||
if (!wasRemoved) {
|
||||
const newTile: Tile = {
|
||||
kind: tileType,
|
||||
pos: { x, y },
|
||||
data: {}
|
||||
};
|
||||
newTiles.push(newTile);
|
||||
}
|
||||
|
||||
return newTiles;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleCellClick = useCallback((x: number, y: number) => {
|
||||
if (linkingMode === 'selecting-button-position' && pendingDoorPosition) {
|
||||
setTiles(prevTiles => {
|
||||
return prevTiles.map(tile => {
|
||||
if (tile.kind === 'door' &&
|
||||
tile.pos.x === pendingDoorPosition.x &&
|
||||
tile.pos.y === pendingDoorPosition.y) {
|
||||
return {
|
||||
...tile,
|
||||
data: { button_position: { x, y } }
|
||||
};
|
||||
}
|
||||
return tile;
|
||||
});
|
||||
});
|
||||
|
||||
setLinkingMode('none');
|
||||
setPendingDoorPosition(null);
|
||||
} else {
|
||||
const doorAtPosition = getTilesAt(x, y).find(tile => tile.kind === 'door');
|
||||
|
||||
if (doorAtPosition && selectedTool === 'door') {
|
||||
setTiles(prevTiles => {
|
||||
return prevTiles.filter(tile =>
|
||||
!(tile.kind === 'door' && tile.pos.x === x && tile.pos.y === y)
|
||||
);
|
||||
});
|
||||
} else if (doorAtPosition) {
|
||||
setPendingDoorPosition({ x, y });
|
||||
setLinkingMode('selecting-button-position');
|
||||
} else if (selectedTool === 'door') {
|
||||
const newDoor: Tile = {
|
||||
kind: 'door',
|
||||
pos: { x, y },
|
||||
data: {}
|
||||
};
|
||||
|
||||
setTiles(prevTiles => {
|
||||
const filtered = prevTiles.filter(tile =>
|
||||
!(tile.pos.x === x && tile.pos.y === y)
|
||||
);
|
||||
return [...filtered, newDoor];
|
||||
});
|
||||
|
||||
setPendingDoorPosition({ x, y });
|
||||
setLinkingMode('selecting-button-position');
|
||||
} else {
|
||||
toggleTile(x, y, selectedTool);
|
||||
}
|
||||
}
|
||||
}, [selectedTool, linkingMode, pendingDoorPosition, toggleTile, getTilesAt]);
|
||||
|
||||
const clearBoard = useCallback(() => {
|
||||
setTiles([]);
|
||||
setLinkingMode('none');
|
||||
setPendingDoorPosition(null);
|
||||
}, []);
|
||||
|
||||
const validateLevel = useCallback((): string | null => {
|
||||
if (!levelName.trim()) return 'Level name is required';
|
||||
if (!levelPrize.trim()) return 'Level prize is required';
|
||||
|
||||
const tileKinds = new Set(tiles.map(t => t.kind));
|
||||
if (!tileKinds.has('player')) return 'Level must have a player start position';
|
||||
if (!tileKinds.has('exit')) return 'Level must have an exit';
|
||||
|
||||
return null;
|
||||
}, [levelName, levelPrize, tiles]);
|
||||
|
||||
const saveLevel = useCallback(async () => {
|
||||
const validationError = validateLevel();
|
||||
if (validationError) {
|
||||
setError(validationError);
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const levelData: LevelCreateRequest = {
|
||||
name: levelName.trim(),
|
||||
description: levelDescription.trim(),
|
||||
prize: levelPrize.trim(),
|
||||
visibility,
|
||||
data: {
|
||||
size: boardSize,
|
||||
tiles: tiles
|
||||
}
|
||||
};
|
||||
|
||||
const savedLevel = await levelAPI.createLevel(levelData);
|
||||
onSaved(savedLevel);
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.message || 'Failed to save level');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [validateLevel, levelName, levelDescription, levelPrize, boardSize, tiles, onSaved]);
|
||||
|
||||
const cancelLinking = useCallback(() => {
|
||||
setLinkingMode('none');
|
||||
setPendingDoorPosition(null);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="level-editor">
|
||||
<div className="level-editor-header">
|
||||
<h1>Level Editor</h1>
|
||||
<div className="header-actions">
|
||||
<button onClick={onCancel} className="cancel-button">
|
||||
❌ Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="level-editor-content">
|
||||
<div className="editor-sidebar">
|
||||
<div className="editor-section">
|
||||
<h3>Level Information</h3>
|
||||
<div className="form-group">
|
||||
<label>Name:</label>
|
||||
<input
|
||||
type="text"
|
||||
value={levelName}
|
||||
onChange={(e) => setLevelName(e.target.value)}
|
||||
placeholder="Enter level name"
|
||||
maxLength={50}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Description:</label>
|
||||
<textarea
|
||||
value={levelDescription}
|
||||
onChange={(e) => setLevelDescription(e.target.value)}
|
||||
placeholder="Describe your level (optional)"
|
||||
rows={3}
|
||||
maxLength={200}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Prize:</label>
|
||||
<input
|
||||
type="text"
|
||||
value={levelPrize}
|
||||
onChange={(e) => setLevelPrize(e.target.value)}
|
||||
placeholder="What does the player win?"
|
||||
maxLength={30}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="editor-section">
|
||||
<h3>Board Settings</h3>
|
||||
<div className="form-group">
|
||||
<label>Size: {boardSize}x{boardSize}</label>
|
||||
<input
|
||||
type="range"
|
||||
min="3"
|
||||
max="10"
|
||||
value={boardSize}
|
||||
onChange={(e) => setBoardSize(parseInt(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
<button onClick={clearBoard} className="clear-button">
|
||||
🗑️ Clear Board
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="editor-section">
|
||||
<h3>
|
||||
Tools
|
||||
{linkingMode !== 'none' && (
|
||||
<button onClick={cancelLinking} className="cancel-link-button">
|
||||
Cancel Linking
|
||||
</button>
|
||||
)}
|
||||
</h3>
|
||||
{linkingMode === 'selecting-button-position' && (
|
||||
<div className="linking-instruction">
|
||||
Click where you want the button for this door (position {pendingDoorPosition?.x},{pendingDoorPosition?.y})
|
||||
</div>
|
||||
)}
|
||||
<div className="tool-grid">
|
||||
{Object.entries(TILE_CONFIG).map(([type, config]) => (
|
||||
<button
|
||||
key={type}
|
||||
className={`tool-button ${selectedTool === type ? 'selected' : ''}`}
|
||||
onClick={() => setSelectedTool(type as TileType)}
|
||||
style={{
|
||||
backgroundColor: selectedTool === type ? config.color : undefined,
|
||||
opacity: linkingMode !== 'none' ? 0.5 : 1
|
||||
}}
|
||||
disabled={linkingMode !== 'none'}
|
||||
title={type}
|
||||
>
|
||||
<span className="tool-symbol">{config.symbol}</span>
|
||||
<span className="tool-name">{type}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="editor-section">
|
||||
<button
|
||||
onClick={saveLevel}
|
||||
className="save-button"
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? '💾 Saving...' : '💾 Save Level'}
|
||||
</button>
|
||||
{error && <div className="error-message">{error}</div>}
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Visibility:</label>
|
||||
<div className="visibility-toggle" role="radiogroup" aria-label="Level visibility">
|
||||
<button
|
||||
type="button"
|
||||
className={`visibility-option ${visibility === 'public' ? 'active' : ''}`}
|
||||
onClick={() => setVisibility('public')}
|
||||
aria-pressed={visibility === 'public'}
|
||||
>
|
||||
🌍 Public
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`visibility-option ${visibility === 'private' ? 'active' : ''}`}
|
||||
onClick={() => setVisibility('private')}
|
||||
aria-pressed={visibility === 'private'}
|
||||
>
|
||||
🔒 Private
|
||||
</button>
|
||||
</div>
|
||||
<p className="form-helper">
|
||||
Public levels can be discovered by other players. Switch to private if you want to keep it for yourself.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="editor-main">
|
||||
<div className="editor-board-container">
|
||||
<div className="editor-instructions">
|
||||
<p>
|
||||
{linkingMode === 'none'
|
||||
? selectedTool === 'door'
|
||||
? 'Click to place a door, then you\'ll choose its button position. Click existing doors with door tool to DELETE them, or click with other tools to edit their button position.'
|
||||
: `Click on the board to place ${selectedTool} tiles. Click again to remove.`
|
||||
: 'Click where you want the button for the selected door'
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="editor-board">
|
||||
<div
|
||||
className="editor-grid"
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(${boardSize}, 1fr)`,
|
||||
gridTemplateRows: `repeat(${boardSize}, 1fr)`
|
||||
}}
|
||||
>
|
||||
{Array.from({ length: boardSize }, (_, row) =>
|
||||
Array.from({ length: boardSize }, (_, col) => {
|
||||
const tilesAtPosition = getTilesAt(col, row);
|
||||
const hasCurrentTool = hasTileType(col, row, selectedTool);
|
||||
const hasButton = hasButtonAt(col, row);
|
||||
const isPendingDoor = pendingDoorPosition?.x === col && pendingDoorPosition?.y === row;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${col},${row}`}
|
||||
className={`editor-cell ${hasCurrentTool ? 'has-selected-tool' : ''
|
||||
} ${hasButton ? 'has-button-indicator' : ''
|
||||
} ${isPendingDoor ? 'pending-door' : ''
|
||||
}`}
|
||||
onClick={() => handleCellClick(col, row)}
|
||||
style={{
|
||||
backgroundColor: hasCurrentTool ? TILE_CONFIG[selectedTool].color : undefined
|
||||
}}
|
||||
>
|
||||
{tilesAtPosition.map((tile, idx) => (
|
||||
<div
|
||||
key={`${tile.kind}-${idx}`}
|
||||
className={`editor-tile tile-${tile.kind}`}
|
||||
title={tile.kind}
|
||||
>
|
||||
{TILE_CONFIG[tile.kind as TileType]?.symbol || ''}
|
||||
</div>
|
||||
))}
|
||||
{hasButton && (
|
||||
<div className="button-indicator" title="Button position">
|
||||
🔘
|
||||
</div>
|
||||
)}
|
||||
<div className="editor-cell-coords">
|
||||
{col},{row}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="editor-preview">
|
||||
<h4>Game Preview:</h4>
|
||||
<div className="preview-container">
|
||||
<GameBoard tiles={boardTiles} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LevelEditor;
|
||||
@@ -0,0 +1,275 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { LevelSummary } from '../../types';
|
||||
import { levelAPI } from '../../api';
|
||||
import './Levels.css';
|
||||
|
||||
interface LevelListProps {
|
||||
onPlayLevel: (levelId: number) => void;
|
||||
onCreateLevel: () => void;
|
||||
onLogout: () => void;
|
||||
}
|
||||
|
||||
const LevelList: React.FC<LevelListProps> = ({ onPlayLevel, onCreateLevel, onLogout }) => {
|
||||
const [levels, setLevels] = useState<LevelSummary[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [page, setPage] = useState(0);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [isFindOpen, setIsFindOpen] = useState(false);
|
||||
const [findName, setFindName] = useState('');
|
||||
const [findError, setFindError] = useState('');
|
||||
const [findLoading, setFindLoading] = useState(false);
|
||||
const [loadMoreError, setLoadMoreError] = useState('');
|
||||
|
||||
const fetchLevels = useCallback(async (pageToLoad: number, append = false) => {
|
||||
try {
|
||||
if (append) {
|
||||
setLoadingMore(true);
|
||||
setLoadMoreError('');
|
||||
} else {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
setLoadMoreError('');
|
||||
setHasMore(true);
|
||||
setPage(0);
|
||||
}
|
||||
|
||||
const levelData = await levelAPI.listLevels(pageToLoad);
|
||||
|
||||
if (append) {
|
||||
setLevels((prevLevels) => {
|
||||
const existingIds = new Set(prevLevels.map((level) => level.id));
|
||||
const uniqueLevels = levelData.filter((level) => !existingIds.has(level.id));
|
||||
return uniqueLevels.length > 0 ? [...prevLevels, ...uniqueLevels] : prevLevels;
|
||||
});
|
||||
} else {
|
||||
setLevels(levelData);
|
||||
}
|
||||
|
||||
if (levelData.length === 0) {
|
||||
setHasMore(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setPage(pageToLoad);
|
||||
setHasMore(true);
|
||||
} catch (err: any) {
|
||||
const message = err.response?.data?.message || (append ? 'Failed to load more levels' : 'Failed to load levels');
|
||||
if (append) {
|
||||
setLoadMoreError(message);
|
||||
} else {
|
||||
setError(message);
|
||||
}
|
||||
} finally {
|
||||
if (append) {
|
||||
setLoadingMore(false);
|
||||
} else {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchLevels(0, false);
|
||||
}, [fetchLevels]);
|
||||
|
||||
const handleRefresh = () => {
|
||||
fetchLevels(0, false);
|
||||
};
|
||||
|
||||
const handleLoadMore = () => {
|
||||
if (!hasMore || loadingMore) {
|
||||
return;
|
||||
}
|
||||
fetchLevels(page + 1, true);
|
||||
};
|
||||
|
||||
const handleToggleFind = () => {
|
||||
setFindError('');
|
||||
setIsFindOpen((prev) => {
|
||||
const next = !prev;
|
||||
if (!next) {
|
||||
setFindName('');
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleFindLevel = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const trimmedName = findName.trim();
|
||||
|
||||
if (!trimmedName) {
|
||||
setFindError('Enter a level name to search');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setFindLoading(true);
|
||||
setFindError('');
|
||||
const level = await levelAPI.findLevel(trimmedName);
|
||||
setFindName('');
|
||||
setIsFindOpen(false);
|
||||
onPlayLevel(level.id);
|
||||
} catch (err: any) {
|
||||
setFindError(err.response?.data?.message || 'Level not found');
|
||||
} finally {
|
||||
setFindLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="levels-loading">
|
||||
<div className="loading-spinner"></div>
|
||||
<p>Loading levels...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="levels-error">
|
||||
<h2>Error</h2>
|
||||
<p>{error}</p>
|
||||
<button onClick={handleRefresh} className="retry-button">
|
||||
Retry
|
||||
</button>
|
||||
<button onClick={onLogout} className="logout-button">
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const getLevelNameSizeClass = (name: string) => {
|
||||
if (name.length > 36) {
|
||||
return 'level-name--xsmall';
|
||||
}
|
||||
|
||||
if (name.length > 26) {
|
||||
return 'level-name--small';
|
||||
}
|
||||
|
||||
if (name.length > 18) {
|
||||
return 'level-name--medium';
|
||||
}
|
||||
|
||||
return 'level-name--regular';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="levels-container">
|
||||
<div className="levels-header">
|
||||
<h1>Block Game - Levels</h1>
|
||||
<div className="header-actions">
|
||||
<button onClick={onCreateLevel} className="create-button">
|
||||
✨ Create Level
|
||||
</button>
|
||||
<button onClick={handleRefresh} className="refresh-button">
|
||||
🔄 Refresh
|
||||
</button>
|
||||
<button onClick={handleToggleFind} className="find-button" disabled={findLoading}>
|
||||
🔍 Find Level
|
||||
</button>
|
||||
<button onClick={onLogout} className="logout-button">
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isFindOpen && (
|
||||
<div className="find-level-panel">
|
||||
<form onSubmit={handleFindLevel} className="find-level-form">
|
||||
<input
|
||||
type="text"
|
||||
value={findName}
|
||||
onChange={(event) => setFindName(event.target.value)}
|
||||
placeholder="Enter level name"
|
||||
className="find-level-input"
|
||||
disabled={findLoading}
|
||||
/>
|
||||
<div className="find-level-controls">
|
||||
<button type="submit" className="find-level-submit" disabled={findLoading}>
|
||||
{findLoading ? 'Searching…' : 'Play'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="find-level-cancel"
|
||||
onClick={handleToggleFind}
|
||||
disabled={findLoading}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{findError && <p className="find-level-error">{findError}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="levels-content">
|
||||
{levels.length === 0 ? (
|
||||
<div className="no-levels">
|
||||
<h2>No levels available</h2>
|
||||
<p>Create a level to get started!</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="levels-grid">
|
||||
{levels.map((level) => (
|
||||
<div key={level.id} className="level-card">
|
||||
<div className="level-card-header">
|
||||
<h3 className={`level-name ${getLevelNameSizeClass(level.name)}`}>
|
||||
{level.name}
|
||||
</h3>
|
||||
<span className="level-id">#{level.id}</span>
|
||||
</div>
|
||||
<div className="level-card-content">
|
||||
<p className="level-description">
|
||||
{level.description?.trim() || 'No description available'}
|
||||
</p>
|
||||
{level.visibility === 'private' && (
|
||||
<p className="level-visibility">🔒 Private level</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="level-card-actions">
|
||||
<button
|
||||
onClick={() => onPlayLevel(level.id)}
|
||||
className="play-button"
|
||||
>
|
||||
🎮 Play Level
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{loadMoreError && <p className="load-more-error">{loadMoreError}</p>}
|
||||
{hasMore && levels.length > 0 && (
|
||||
<div className="load-more-container">
|
||||
<button
|
||||
onClick={handleLoadMore}
|
||||
className="load-more-button"
|
||||
disabled={loadingMore}
|
||||
>
|
||||
{loadingMore ? 'Loading more…' : 'Load more levels'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="levels-footer">
|
||||
<p>Select a level to start playing!</p>
|
||||
<div className="game-info">
|
||||
<div>🎯 Goal: Reach the exit</div>
|
||||
<div>📦 Push boxes to press buttons</div>
|
||||
<div>🚪 Open doors by pressing buttons</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LevelList;
|
||||
@@ -0,0 +1,491 @@
|
||||
/* Levels Container */
|
||||
.levels-container {
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.levels-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem 2rem;
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.levels-header h1 {
|
||||
margin: 0;
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.create-button,
|
||||
.refresh-button,
|
||||
.logout-button,
|
||||
.retry-button,
|
||||
.find-button {
|
||||
padding: 0.5rem 1rem;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border: 1px solid rgba(255, 255, 255, 0.4);
|
||||
color: white;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.create-button:hover,
|
||||
.refresh-button:hover,
|
||||
.logout-button:hover,
|
||||
.retry-button:hover,
|
||||
.find-button:hover {
|
||||
background: rgba(255, 255, 255, 0.4);
|
||||
border-color: rgba(255, 255, 255, 0.6);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.create-button {
|
||||
background: rgba(40, 167, 69, 0.8);
|
||||
border-color: rgba(40, 167, 69, 0.8);
|
||||
}
|
||||
|
||||
.create-button:hover {
|
||||
background: rgba(40, 167, 69, 1);
|
||||
}
|
||||
|
||||
.logout-button {
|
||||
background: rgba(220, 53, 69, 0.8);
|
||||
border-color: rgba(220, 53, 69, 0.8);
|
||||
}
|
||||
|
||||
.logout-button:hover {
|
||||
background: rgba(220, 53, 69, 1);
|
||||
}
|
||||
|
||||
.find-button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.find-level-panel {
|
||||
margin: 0 2rem;
|
||||
padding: 1rem 1.5rem;
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.find-level-form {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.find-level-input {
|
||||
flex: 1;
|
||||
min-width: 240px;
|
||||
padding: 0.65rem 0.85rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.4);
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
color: #333;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.find-level-input:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.find-level-controls {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.find-level-submit {
|
||||
background: linear-gradient(135deg, #17a2b8 0%, #6610f2 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.6rem 1.1rem;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.find-level-submit:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(102, 16, 242, 0.35);
|
||||
}
|
||||
|
||||
.find-level-submit:disabled {
|
||||
opacity: 0.65;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.find-level-cancel {
|
||||
padding: 0.6rem 0.95rem;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border: 1px solid rgba(255, 255, 255, 0.4);
|
||||
color: white;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.find-level-cancel:hover {
|
||||
background: rgba(255, 255, 255, 0.4);
|
||||
border-color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.find-level-cancel:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.find-level-error {
|
||||
margin: 0;
|
||||
color: #ffdddf;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* Levels Content */
|
||||
.levels-content {
|
||||
flex: 1;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.no-levels {
|
||||
text-align: center;
|
||||
padding: 4rem 2rem;
|
||||
}
|
||||
|
||||
.no-levels h2 {
|
||||
margin: 0 0 1rem 0;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.no-levels p {
|
||||
margin: 0;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Levels Grid */
|
||||
.levels-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 1.5rem;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.level-card {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
color: #333;
|
||||
border-radius: 10px;
|
||||
padding: 1.5rem;
|
||||
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
|
||||
transition: transform 0.3s, box-shadow 0.3s;
|
||||
}
|
||||
|
||||
.level-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.level-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: start;
|
||||
margin-bottom: 1rem;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.level-name {
|
||||
margin: 0;
|
||||
color: #333;
|
||||
line-height: 1.2;
|
||||
flex: 1;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.level-name--regular {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.level-name--medium {
|
||||
font-size: 1.08rem;
|
||||
}
|
||||
|
||||
.level-name--small {
|
||||
font-size: 0.98rem;
|
||||
}
|
||||
|
||||
.level-name--xsmall {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.level-id {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 15px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.level-card-content {
|
||||
margin-bottom: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.level-description {
|
||||
margin: 0;
|
||||
font-size: 0.98rem;
|
||||
color: #4a4a4a;
|
||||
background: linear-gradient(135deg, rgba(102, 126, 234, 0.15) 0%, rgba(118, 75, 162, 0.15) 100%);
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem 1rem;
|
||||
border-left: 4px solid #667eea;
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.4);
|
||||
line-height: 1.5;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.level-visibility {
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
color: #555;
|
||||
font-weight: 500;
|
||||
background: rgba(102, 126, 234, 0.15);
|
||||
padding: 0.45rem 0.85rem;
|
||||
border-radius: 999px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.level-stats {
|
||||
font-size: 0.9rem;
|
||||
color: #666;
|
||||
background: #f8f9fa;
|
||||
padding: 0.5rem;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.level-card-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.play-button {
|
||||
background: linear-gradient(135deg, #28a745 0%, #20c997 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
width: 100%;
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
.play-button:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 15px rgba(40, 167, 69, 0.3);
|
||||
}
|
||||
|
||||
.load-more-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.load-more-button {
|
||||
padding: 0.75rem 2.5rem;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border: 1px solid rgba(255, 255, 255, 0.4);
|
||||
color: white;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s ease;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.load-more-button:hover {
|
||||
background: rgba(255, 255, 255, 0.35);
|
||||
border-color: rgba(255, 255, 255, 0.6);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.load-more-button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.load-more-error {
|
||||
margin-top: 1.5rem;
|
||||
text-align: center;
|
||||
color: #ffdde0;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
/* Levels Footer */
|
||||
.levels-footer {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
padding: 1rem 2rem;
|
||||
text-align: center;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.2);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.levels-footer p {
|
||||
margin: 0 0 1rem 0;
|
||||
font-size: 1.1rem;
|
||||
color: white;
|
||||
font-weight: 500;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.game-info {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 2rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.game-info div {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 5px;
|
||||
font-size: 0.9rem;
|
||||
color: white;
|
||||
font-weight: 500;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
/* Loading and Error States */
|
||||
.levels-loading,
|
||||
.levels-error {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 4px solid rgba(255, 255, 255, 0.3);
|
||||
border-top: 4px solid white;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.levels-error h2 {
|
||||
margin: 0 0 1rem 0;
|
||||
}
|
||||
|
||||
.levels-error p {
|
||||
margin: 0 0 1.5rem 0;
|
||||
}
|
||||
|
||||
.levels-error .retry-button {
|
||||
margin-right: 1rem;
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 768px) {
|
||||
.levels-header {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
flex-direction: row;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.find-level-panel {
|
||||
margin: 0 1rem;
|
||||
}
|
||||
|
||||
.find-level-form {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.find-level-controls {
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.levels-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.level-card {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.game-info {
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.levels-content {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { GameMessage, Tiles, Direction } from '../types';
|
||||
|
||||
interface UseWebSocketGameReturn {
|
||||
connected: boolean;
|
||||
levelComplete: boolean;
|
||||
prize: string;
|
||||
sendMove: (direction: Direction) => void;
|
||||
}
|
||||
|
||||
export const useWebSocketGame = (
|
||||
wsUrl: string | null,
|
||||
tiles: Tiles | null,
|
||||
setTiles: React.Dispatch<React.SetStateAction<Tiles | null>>
|
||||
): UseWebSocketGameReturn => {
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [levelComplete, setLevelComplete] = useState(false);
|
||||
const [prize, setPrize] = useState('');
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!wsUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ws = new WebSocket(wsUrl);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
setConnected(true);
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const message: GameMessage = JSON.parse(event.data);
|
||||
handleMessage(message);
|
||||
} catch (error) {
|
||||
console.error('Failed to parse WebSocket message:', error);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = (event) => {
|
||||
setConnected(false);
|
||||
console.log('WebSocket disconnected:', event.code, event.reason);
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
console.error('WebSocket error:', error);
|
||||
setConnected(false);
|
||||
};
|
||||
|
||||
return () => {
|
||||
ws.close(1000, 'Component cleanup');
|
||||
setConnected(false);
|
||||
setLevelComplete(false);
|
||||
setPrize('');
|
||||
};
|
||||
}, [wsUrl]);
|
||||
|
||||
const handleMessage = (message: GameMessage) => {
|
||||
switch (message.type) {
|
||||
case 'update':
|
||||
const { idx, new_tile } = message.option;
|
||||
setTiles(prevTiles => {
|
||||
if (!prevTiles || idx < 0 || idx >= prevTiles.tiles.length) {
|
||||
console.error('Invalid tile update:', { idx, tilesLength: prevTiles?.tiles.length });
|
||||
return prevTiles || null;
|
||||
}
|
||||
const newTiles = [...prevTiles.tiles];
|
||||
newTiles[idx] = new_tile;
|
||||
return { size: prevTiles.size, tiles: newTiles };
|
||||
});
|
||||
break;
|
||||
case 'level_complete':
|
||||
setLevelComplete(true);
|
||||
setPrize(message.option.prize);
|
||||
break;
|
||||
case 'error':
|
||||
console.error('Game error:', message.option);
|
||||
break;
|
||||
default:
|
||||
console.error('Unknown message type:', message.type);
|
||||
}
|
||||
};
|
||||
|
||||
const sendMove = (direction: Direction) => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(JSON.stringify({ type: 'move', option: { direction } }));
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
connected,
|
||||
levelComplete,
|
||||
prize,
|
||||
sendMove
|
||||
};
|
||||
};
|
||||
32
OmCTF-2025/services/block_game/frontend/src/index.css
Normal file
32
OmCTF-2025/services/block_game/frontend/src/index.css
Normal file
@@ -0,0 +1,32 @@
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||
monospace;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
#root {
|
||||
min-height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
13
OmCTF-2025/services/block_game/frontend/src/index.tsx
Normal file
13
OmCTF-2025/services/block_game/frontend/src/index.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import './index.css';
|
||||
import App from './App';
|
||||
|
||||
const root = ReactDOM.createRoot(
|
||||
document.getElementById('root') as HTMLElement
|
||||
);
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
157
OmCTF-2025/services/block_game/frontend/src/shared.css
Normal file
157
OmCTF-2025/services/block_game/frontend/src/shared.css
Normal file
@@ -0,0 +1,157 @@
|
||||
/* Common button styles to avoid repetition */
|
||||
.btn-base {
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
transition: all 0.3s ease;
|
||||
border: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.btn-glass {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border: 1px solid rgba(255, 255, 255, 0.4);
|
||||
color: white;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.btn-glass:hover {
|
||||
background: rgba(255, 255, 255, 0.4);
|
||||
border-color: rgba(255, 255, 255, 0.6);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background: linear-gradient(135deg, #28a745 0%, #20c997 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-success:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 15px rgba(40, 167, 69, 0.3);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: rgba(220, 53, 69, 0.8);
|
||||
border: 1px solid rgba(220, 53, 69, 0.8);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: rgba(220, 53, 69, 1);
|
||||
}
|
||||
|
||||
.btn-link {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #667eea;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
.btn-link:hover {
|
||||
color: #764ba2;
|
||||
}
|
||||
|
||||
/* Common loading spinner */
|
||||
.spinner,
|
||||
.loading-spinner {
|
||||
border: 4px solid rgba(255, 255, 255, 0.3);
|
||||
border-top: 4px solid white;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Common form styles */
|
||||
.form-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #555;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border: 2px solid #ddd;
|
||||
border-radius: 5px;
|
||||
font-size: 1rem;
|
||||
transition: border-color 0.3s;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group textarea:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
.form-group input:disabled,
|
||||
.form-group textarea:disabled {
|
||||
background-color: #f5f5f5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Common message styles */
|
||||
.error-message {
|
||||
background-color: #fee;
|
||||
color: #b91c1c;
|
||||
padding: 0.75rem;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 1rem;
|
||||
border: 2px solid #fca5a5;
|
||||
font-weight: 500;
|
||||
box-shadow: 0 1px 3px rgba(185, 28, 28, 0.1);
|
||||
}
|
||||
|
||||
.info-message {
|
||||
background-color: #eff6ff;
|
||||
color: #1e40af;
|
||||
padding: 0.75rem;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 1rem;
|
||||
border: 2px solid #93c5fd;
|
||||
font-weight: 500;
|
||||
box-shadow: 0 1px 3px rgba(30, 64, 175, 0.1);
|
||||
}
|
||||
|
||||
/* Common gradient background */
|
||||
.gradient-bg {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
|
||||
/* Common card styles */
|
||||
.card {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border-radius: 10px;
|
||||
padding: 1.5rem;
|
||||
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
|
||||
transition: transform 0.3s, box-shadow 0.3s;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
83
OmCTF-2025/services/block_game/frontend/src/types.ts
Normal file
83
OmCTF-2025/services/block_game/frontend/src/types.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
export interface Point {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export type Direction = 'up' | 'down' | 'left' | 'right';
|
||||
|
||||
export interface Tile {
|
||||
kind: string;
|
||||
pos: Point;
|
||||
data?: any;
|
||||
}
|
||||
|
||||
export type LevelVisibility = 'public' | 'private';
|
||||
|
||||
export interface Tiles {
|
||||
size: number;
|
||||
tiles: Tile[];
|
||||
}
|
||||
export interface MoveMessage {
|
||||
type: 'move';
|
||||
option: {
|
||||
direction: Direction;
|
||||
};
|
||||
}
|
||||
|
||||
export interface UpdateMessage {
|
||||
type: 'update';
|
||||
option: {
|
||||
idx: number;
|
||||
new_tile: Tile;
|
||||
};
|
||||
}
|
||||
|
||||
export interface LevelCompleteMessage {
|
||||
type: 'level_complete';
|
||||
option: any;
|
||||
}
|
||||
|
||||
export interface ErrorMessage {
|
||||
type: 'error';
|
||||
option: {
|
||||
message: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type GameMessage = MoveMessage | UpdateMessage | LevelCompleteMessage | ErrorMessage;
|
||||
|
||||
// API types
|
||||
export interface User {
|
||||
id: number;
|
||||
username: string;
|
||||
}
|
||||
|
||||
export interface LevelSummary {
|
||||
id: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
visibility: LevelVisibility;
|
||||
}
|
||||
|
||||
export interface Level extends LevelSummary {
|
||||
data: Tiles;
|
||||
prize: string;
|
||||
}
|
||||
|
||||
export interface LevelCreateRequest {
|
||||
name: string;
|
||||
description?: string;
|
||||
prize: string;
|
||||
visibility: LevelVisibility;
|
||||
data: Tiles;
|
||||
}
|
||||
|
||||
export interface LoginRequest {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface RegisterRequest {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
26
OmCTF-2025/services/block_game/frontend/tsconfig.json
Normal file
26
OmCTF-2025/services/block_game/frontend/tsconfig.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2015",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"es6"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user