adding validated services? patching forcad_local.py

This commit is contained in:
Your Name
2026-08-13 08:32:35 +07:00
parent d485f61169
commit e795614e45
1459 changed files with 420036 additions and 436 deletions

View File

@@ -0,0 +1,2 @@
/node_modules
/build

View File

@@ -0,0 +1,2 @@
/node_modules
/build

View 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;"]

View 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"]

View 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 ;)

View 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;
}
}

File diff suppressed because it is too large Load Diff

View 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"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View 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>

View 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);
}

View 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;

View 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;

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;
}
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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%;
}
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;
}
}

View File

@@ -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
};
};

View 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;
}

View 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>
);

View 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);
}

View 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;
}

View 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"
]
}