Compare commits
5 Commits
main
..
LosGringos
| Author | SHA1 | Date | |
|---|---|---|---|
| 0a6e9a25ed | |||
| cb1fc01ad6 | |||
| 27704b97f8 | |||
| 938d4cf3b5 | |||
| 167896aedd |
@@ -0,0 +1,10 @@
|
||||
POSTGRES_PASSWORD=coucou
|
||||
JWT_SECRET=superlongsecretkeyatleast32characterspleasenevercommitthis
|
||||
POSTGRES_DB=database
|
||||
POSTGRES_HOST=database
|
||||
POSTGRES_USER=user
|
||||
GITHUB_CLIENT_ID=Ov23li6ovg3fzec5IO5D
|
||||
GITHUB_CLIENT_SECRET=0345e959e8f0e9f784061c5c90ee227ddb2ef9ab
|
||||
GITHUB_CALLBACK_URL=http://localhost:8080/api/auth/github/callback
|
||||
|
||||
pogpog
|
||||
@@ -1,138 +0,0 @@
|
||||
*This project has been created as part of the 42 curriculum by agallon, gprunet, yantoine and tfauve-p*
|
||||
|
||||
***DESCRIPTION***
|
||||
|
||||
For starters, ft_transcendence is a wonderful project based on building a web-application running from Docker containers where the goal is, for the first time to do whatever we want, yet still we need to follow multiples criteria based on a number of points to grind to set the project as finished.
|
||||
|
||||
For such a project our group thought about the "CATETRIBBL.IO". We chose to make a web application featuring multiples games such as Tetris one of the very first game ever developed and Skkribl.io the amazing drawing game !
|
||||
|
||||
But beware ! A mysterious noble cat named Wiskas The Third is gone ... It is said that he's been lurking around trapping 42's students into infinite conversation known as "tunnel". If you see him please report to us as soon as possible !
|
||||
|
||||
***INSTRUCTIONS***
|
||||
|
||||
Like every 42 project you will need to git clone it into a valid repository, then add our functional .env file at the root of the repository. After all that, make use of the "make" command and watch our fabulous containers building themselves ! Look for https://localhost:8443/ once it's built, remember that you need to login in order to play on our web app !
|
||||
|
||||
Outside of 42 environment you will obviously need Docker and Make installed.
|
||||
|
||||
***RESOURCES***
|
||||
|
||||
https://www.geeksforgeeks.org
|
||||
https://developer.mozilla.org/fr/docs/Web/JavaScript
|
||||
https://www.w3schools.com/js/
|
||||
https://www.tigerdata.com/learn/postgres-cheat-sheet
|
||||
https://www.programiz.com/css/button-styling
|
||||
https://developer.mozilla.org/fr/docs/Web/CSS
|
||||
https://chatgpt.com/
|
||||
https://www.gimp.org/tutorials/
|
||||
|
||||
AI was mostly used to ask questions and deepen understanding, it was also used to generate multiple samples of what we could do front-end wise.
|
||||
|
||||
**FEATURES:**
|
||||
|
||||
- Login
|
||||
- Avatar
|
||||
- Global Chat
|
||||
- Skribbl.io + Spectator mode
|
||||
- Tetris + Duels
|
||||
- Wiskas the Third
|
||||
|
||||
Use of the framework Express for the back-end because its compatible with jsonwebtoken(JWT) and contains solid and well tested features.
|
||||
|
||||
**DEPENDENCIES**
|
||||
|
||||
"express": "^4.18.2",
|
||||
"pg": "^8.11.3",
|
||||
"bcrypt": "^5.1.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"dotenv": "^17.2.3",
|
||||
"socket.io": "^4.6.1",
|
||||
"cors": "^2.8.5",
|
||||
"passport": "0.7.0",
|
||||
"passport-github2": "0.1.12",
|
||||
"express-session": "1.18.0",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"file-type": "^19.0.0"
|
||||
|
||||
***TEAM INFORMATION***
|
||||
|
||||
Tfauve-p : The project manager, is in lead of organizing all the meeting with the team which changed over time, including then some recruitment. There was some adjustments to make over our vision of the project while coding it on GitHub.
|
||||
|
||||
Yantoine : The project owner, is in lead of both games, Tetris and Skkribl.io, made core decisions on features about these and got the work completed. His communication skills were very important due to the front-end / back-end relationship needed in order to achieve this glorious project.
|
||||
|
||||
Gprunet : The technical lead, is in charge of the back-end, made some strong decisions on the architectures of the project in terms of technology used. Created the entire database and most of the foundation of this project such as the builder files.
|
||||
|
||||
Agallon : Developer in the front-end action, he joined the team after the project was done but managed to innovate and brought to life the marvelous Wiskas the Third. Furthermore he also cared about the integrity of the web application and greatly improved the user experience through logical decisions.
|
||||
|
||||
***Project Management***
|
||||
|
||||
The task's sharing was based on our own advance of the 42 cursus, meaning that Gprunet and Yantoine started coding the app sooner than Tfauve-p and Agallon.
|
||||
|
||||
Gprunet: Back-end + spectator mode
|
||||
Yantoine: Github auth, games and Front/Back sockets
|
||||
Tfauve-p: Front-end Designer
|
||||
Agallon: Adjustements on Front-end and some new features.
|
||||
|
||||
|
||||
***TECHNICAL STACK***
|
||||
|
||||
Front-end: JavaScript, HTML, CSS, NGINX
|
||||
Back-end: JavaScript, Express, JWT, multer, etc...
|
||||
Database: PostgreSQL because it uses a permissibe open-source licence and is feature-rich and powerful for the scale of our project
|
||||
|
||||
Since python doesn't have many front-end framework we opted to use JavaScript for both front and back.
|
||||
After learning about JWT and learning that Express had a great synergy with it the choice was natural.
|
||||
|
||||
|
||||
***DATABASES SCHEMA***
|
||||
|
||||
|
||||
|
||||
***FEATURES LIST***
|
||||
|
||||
***MODULES**
|
||||
|
||||
Total : 24pts ( 14pts for 100% 19pts for 125% )
|
||||
|
||||
**WEB**
|
||||
|
||||
Major : Use a back end and front end framework
|
||||
|
||||
Major : Implement real-time features
|
||||
|
||||
Major : Allow users to interact with others
|
||||
|
||||
Major : A public API to interact with the database
|
||||
|
||||
Minor : A complete notification system for all creation, update and deletion account
|
||||
|
||||
**ACCESSIBILITY**
|
||||
|
||||
Minor : Support for additional browsers
|
||||
|
||||
**USER MANAGEMENT**
|
||||
|
||||
Major : Standard user management and authentication
|
||||
|
||||
Minor : Game statistics and match history ???
|
||||
|
||||
Minor : Implement remote authentication
|
||||
|
||||
**GAMING AND USER EXPERIENCE**
|
||||
|
||||
Major : Implement a complete web-based game where users can play against each other
|
||||
|
||||
Major : Remote players, Enable two players on separate computers to play the same game
|
||||
|
||||
Major : Multiplayer game
|
||||
|
||||
Major : Add another game with user history and matchmaking
|
||||
|
||||
Minor : Advanced chat features ????
|
||||
|
||||
Minor : Game customization options
|
||||
|
||||
Minor : Spectator mode for games
|
||||
|
||||
**INDIVIDUAL CONTRIBUTION**
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
all : up
|
||||
all :
|
||||
@docker compose -f ./docker-compose.yml up -d
|
||||
|
||||
up :
|
||||
no_cache :
|
||||
@docker compose -f ./docker-compose.yml build --no-cache
|
||||
@docker compose -f ./docker-compose.yml up -d
|
||||
|
||||
clean :
|
||||
@@ -10,5 +12,6 @@ fclean :
|
||||
@docker compose -f ./docker-compose.yml down -v -t 1
|
||||
@docker system prune -af --volumes
|
||||
|
||||
re : fclean up
|
||||
re : fclean no_cache
|
||||
|
||||
.PHONY : all no_cache clean fclean re
|
||||
|
||||
@@ -24,6 +24,8 @@ services:
|
||||
build: ./srcs/backend
|
||||
expose:
|
||||
- "3001"
|
||||
# ports:
|
||||
# - "3001:3001"
|
||||
depends_on:
|
||||
- database
|
||||
volumes:
|
||||
@@ -38,8 +40,7 @@ services:
|
||||
container_name: frontend
|
||||
build: ./srcs/frontend/
|
||||
ports:
|
||||
- "8080:8080"
|
||||
- "8443:8443"
|
||||
- "8080:80"
|
||||
depends_on:
|
||||
- backend
|
||||
networks:
|
||||
|
||||
@@ -26,17 +26,6 @@ router.post('/login', async(req, res) =>
|
||||
res.status(result.status).json(result.data);
|
||||
});
|
||||
|
||||
router.post('/logout', async(req, res) =>
|
||||
{
|
||||
const authHeader = req.headers['authorization'];
|
||||
const token = authHeader && authHeader.split(' ')[1];
|
||||
if (!token)
|
||||
return (res.status(401).json({error: 'Missing token'}));
|
||||
|
||||
const result = await authService.logout(token);
|
||||
res.status(result.status).json(result.data);
|
||||
});
|
||||
|
||||
router.get('/github', (req, res) => {
|
||||
const githubAuthUrl = `https://github.com/login/oauth/authorize?` +
|
||||
`client_id=${process.env.GITHUB_CLIENT_ID}&` +
|
||||
|
||||
@@ -25,7 +25,7 @@ router.post('/upload', authenticateToken, upload.single('avatar'), async(req, re
|
||||
res.status(result.status).json(result.data);
|
||||
});
|
||||
|
||||
router.delete('/delete', authenticateToken, async(req, res) =>
|
||||
router.delete('/', authenticateToken, async(req, res) =>
|
||||
{
|
||||
const result = await avatarService.deleteAvatar(req.user.userId);
|
||||
res.status(result.status).json(result.data);
|
||||
|
||||
@@ -2,30 +2,6 @@ import bcrypt from 'bcrypt';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import {query} from '../db.js';
|
||||
|
||||
async function logout(token)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!token)
|
||||
return ({status: 400, data: {error: 'Missing token'}});
|
||||
try
|
||||
{
|
||||
jwt.verify(token, process.env.JWT_SECRET);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return ({status: 401, data: {error: 'Invalid token'}});
|
||||
}
|
||||
|
||||
return ({status: 200, data: {message: 'Logged out'}});
|
||||
}
|
||||
catch (err)
|
||||
{
|
||||
console.error(err);
|
||||
return ({status: 500, data: {error: 'Server error'}});
|
||||
}
|
||||
}
|
||||
|
||||
async function login(username, password)
|
||||
{
|
||||
try
|
||||
@@ -84,4 +60,4 @@ async function register(username, password)
|
||||
}
|
||||
};
|
||||
|
||||
export default {register, login, logout};
|
||||
export default {register, login};
|
||||
|
||||
@@ -69,9 +69,6 @@ async function deleteAvatar(userId) {
|
||||
if (currentAvatar === null)
|
||||
return ({status: 404, data: {error: 'User not found'}});
|
||||
|
||||
if (currentAvatar === DEFAULT_AVATAR)
|
||||
return ({status: 400, data: {error: 'Cannot delete default avatar'}});
|
||||
|
||||
// Reset the avatar to the default one
|
||||
await setAvatar(DEFAULT_AVATAR, userId);
|
||||
|
||||
|
||||
@@ -730,16 +730,6 @@ function setupSocketIO(io)
|
||||
_tetrisRelayToOpponent(socket, 'tetris:lines-cleared', data);
|
||||
});
|
||||
|
||||
// Relay pur : shield-activated → adversaire uniquement
|
||||
socket.on('tetris:shield-activated', () => {
|
||||
_tetrisRelayToOpponent(socket, 'tetris:shield-activated', {});
|
||||
});
|
||||
|
||||
// Relay pur : shield-deactivated → adversaire uniquement
|
||||
socket.on('tetris:shield-deactivated', () => {
|
||||
_tetrisRelayToOpponent(socket, 'tetris:shield-deactivated', {});
|
||||
});
|
||||
|
||||
// start-duel → relayé aux DEUX joueurs de la room (inclut l'émetteur)
|
||||
socket.on('tetris:start-duel', () => {
|
||||
const code = socket.tetrisRoomCode;
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
FROM nginx:alpine
|
||||
RUN apk add --no-cache openssl && \
|
||||
mkdir -p /etc/nginx/ssl && \
|
||||
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
|
||||
-keyout /etc/nginx/ssl/key.pem \
|
||||
-out /etc/nginx/ssl/cert.pem \
|
||||
-subj "/CN=localhost" \
|
||||
-addext "subjectAltName=DNS:localhost,IP:127.0.0.1"
|
||||
COPY src /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
EXPOSE 8080 8443
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -1,13 +1,5 @@
|
||||
server {
|
||||
listen 8080;
|
||||
return 301 https://$host:8443$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 8443 ssl;
|
||||
|
||||
ssl_certificate /etc/nginx/ssl/cert.pem;
|
||||
ssl_certificate_key /etc/nginx/ssl/key.pem;
|
||||
listen 80;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
@@ -22,7 +14,6 @@ server {
|
||||
proxy_pass http://backend:3001;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
}
|
||||
|
||||
# Socket.IO WebSocket proxying
|
||||
@@ -34,9 +25,7 @@ server {
|
||||
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 https;
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_send_timeout 3600s;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /avatar/ {
|
||||
|
||||
@@ -2,14 +2,13 @@
|
||||
* Application entry point
|
||||
* Initializes windows and handles menu interactions
|
||||
*/
|
||||
import { windowRegistry } from './core/windows.js';
|
||||
import { LoginWindow } from './windows/login.js';
|
||||
import { LogoutWindow } from './windows/logout.js';
|
||||
import { GlobalChat } from './windows/global_chat.js';
|
||||
import { AvatarWindow } from './windows/avatar.js';
|
||||
import { FriendsWindow } from './windows/friends.js';
|
||||
import { GameRoomWindow } from './windows/game_room.js';
|
||||
import { StatsWindow } from './windows/stats.js';
|
||||
import { windowRegistry } from './windows.js';
|
||||
import { LoginWindow } from './login.js';
|
||||
import { GlobalChat } from './global_chat.js';
|
||||
import { AvatarWindow } from './avatar.js';
|
||||
import { FriendsWindow } from './friends.js';
|
||||
import { GameRoomWindow } from './game_room.js';
|
||||
import { StatsWindow } from './stats.js';
|
||||
|
||||
/**
|
||||
* Main application class
|
||||
@@ -17,10 +16,12 @@ import { StatsWindow } from './windows/stats.js';
|
||||
*/
|
||||
class App {
|
||||
constructor() {
|
||||
console.log("APP STARTED");
|
||||
this.initWindows();
|
||||
this.initMenu();
|
||||
this.initPage();
|
||||
this.initEasterEgg();
|
||||
this.colorizeUI();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -33,7 +34,6 @@ class App {
|
||||
new FriendsWindow();
|
||||
new GameRoomWindow();
|
||||
new StatsWindow();
|
||||
new LogoutWindow();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -51,8 +51,7 @@ class App {
|
||||
'login': 'login',
|
||||
'chat': 'chat',
|
||||
'avatar': 'avatar',
|
||||
'friends': 'friends',
|
||||
'logout': 'logout'
|
||||
'friends': 'friends'
|
||||
};
|
||||
|
||||
// Event delegation on the menu
|
||||
@@ -108,6 +107,39 @@ class App {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
colorizeUI() {
|
||||
|
||||
const elements = document.querySelectorAll(".title, .menu__item, .game__item, .page__item");
|
||||
|
||||
const colorizeText = (el) => {
|
||||
const text = el.textContent;
|
||||
el.innerHTML = "";
|
||||
|
||||
const baseHue = Math.random() * 360;
|
||||
|
||||
// 🎲 random step = makes rainbow "scrambled"
|
||||
const step = (Math.random() * 60) + 10; // 10 → 70
|
||||
|
||||
// 🎲 random direction (left or right rainbow)
|
||||
const direction = Math.random() < 0.5 ? 1 : -1;
|
||||
|
||||
[...text].forEach((char, i) => {
|
||||
const span = document.createElement("span");
|
||||
span.textContent = char;
|
||||
|
||||
const hue = baseHue + (i * step * direction);
|
||||
|
||||
span.style.color = `hsl(${hue}, 90%, 60%)`;
|
||||
|
||||
span.style.textShadow = `1px 1px 0 rgba(0,0,0,0.3)`;
|
||||
|
||||
el.appendChild(span);
|
||||
});
|
||||
};
|
||||
elements.forEach(colorizeText);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Start the application when DOM is ready
|
||||
@@ -115,4 +147,4 @@ if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', () => new App());
|
||||
} else {
|
||||
new App();
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 134 KiB |
|
After Width: | Height: | Size: 134 KiB |
|
After Width: | Height: | Size: 134 KiB |
|
After Width: | Height: | Size: 134 KiB |
|
After Width: | Height: | Size: 134 KiB |
|
After Width: | Height: | Size: 134 KiB |
|
After Width: | Height: | Size: 134 KiB |
|
After Width: | Height: | Size: 134 KiB |
|
After Width: | Height: | Size: 134 KiB |
|
After Width: | Height: | Size: 134 KiB |
|
After Width: | Height: | Size: 134 KiB |
|
After Width: | Height: | Size: 134 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 994 B |
|
After Width: | Height: | Size: 1018 B |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 955 B |
|
After Width: | Height: | Size: 1022 B |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 887 B |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1000 B |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
@@ -1,6 +1,6 @@
|
||||
import { Window, windowRegistry } from '../core/windows.js';
|
||||
import { API, STORAGE_KEYS, CSS } from '../core/config.js';
|
||||
import { eventBus, Events } from '../core/events.js';
|
||||
import { Window, windowRegistry } from './windows.js';
|
||||
import { API, STORAGE_KEYS, CSS } from './config.js';
|
||||
import { eventBus, Events } from './events.js';
|
||||
|
||||
/**
|
||||
* Avatar management window
|
||||
@@ -67,12 +67,8 @@ export class AvatarWindow extends Window {
|
||||
this.refreshBtn = this.createElement('button', [CSS.BTN, CSS.BTN_SECONDARY], {
|
||||
text: 'Refresh'
|
||||
});
|
||||
|
||||
this.deleteBtn = this.createElement('button', [CSS.BTN, CSS.BTN_SECONDARY], {
|
||||
text: 'Delete avatar'
|
||||
});
|
||||
|
||||
this.controls.append(this.statsBtn, this.chooseBtn, this.saveBtn, this.refreshBtn, this.deleteBtn);
|
||||
this.controls.append(this.statsBtn, this.chooseBtn, this.saveBtn, this.refreshBtn);
|
||||
|
||||
// Feedback message
|
||||
this.message = this.createElement('div', CSS.MESSAGE);
|
||||
@@ -97,7 +93,6 @@ export class AvatarWindow extends Window {
|
||||
this.chooseBtn.addEventListener('click', () => this.fileInput.click());
|
||||
this.saveBtn.addEventListener('click', () => this.uploadAvatar());
|
||||
this.refreshBtn.addEventListener('click', () => this.loadAvatar());
|
||||
this.deleteBtn.addEventListener('click', () => this.deleteAvatar());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -217,14 +212,12 @@ export class AvatarWindow extends Window {
|
||||
const token = localStorage.getItem(STORAGE_KEYS.AUTH_TOKEN);
|
||||
if (!token) {
|
||||
this.showMessage('You must be logged in', 'error');
|
||||
this.showNotification('You must be logged in to change your avatar', 'red');
|
||||
return;
|
||||
}
|
||||
|
||||
const file = this.fileInput.files?.[0];
|
||||
if (!file) {
|
||||
this.showMessage('Select an image first', 'error');
|
||||
this.showNotification('Please select an image to upload', 'red');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -247,7 +240,6 @@ export class AvatarWindow extends Window {
|
||||
if (!response.ok) {
|
||||
const errorMsg = data?.error || data?.message || 'Upload failed';
|
||||
this.showMessage(errorMsg, 'error');
|
||||
this.showNotification('Failed to upload avatar.', 'red');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -256,47 +248,11 @@ export class AvatarWindow extends Window {
|
||||
}
|
||||
|
||||
this.showMessage('Avatar saved!', 'success');
|
||||
this.showNotification('Avatar updated successfully!', 'green');
|
||||
eventBus.emit(Events.AVATAR_UPDATED, { url: data?.avatar_url });
|
||||
|
||||
} catch (error) {
|
||||
console.error('Avatar upload error:', error);
|
||||
this.showMessage('Upload error', 'error');
|
||||
this.showNotification('Failed to upload avatar.', 'red');
|
||||
}
|
||||
}
|
||||
|
||||
async deleteAvatar() {
|
||||
const token = localStorage.getItem(STORAGE_KEYS.AUTH_TOKEN);
|
||||
if (!token) {
|
||||
this.showMessage('You must be logged in', 'error');
|
||||
this.showNotification('You must be logged in to delete your avatar', 'red');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(API.AVATAR.DELETE, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
this.showMessage('Failed to delete avatar', 'error');
|
||||
this.showNotification('Failed to delete avatar.', 'red');
|
||||
return;
|
||||
}
|
||||
|
||||
this.preview.src = '';
|
||||
this.showMessage('Avatar deleted!', 'success');
|
||||
this.showNotification('Avatar deleted successfully!', 'green');
|
||||
eventBus.emit(Events.AVATAR_DELETED);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Avatar delete error:', error);
|
||||
this.showMessage('Delete error', 'error');
|
||||
this.showNotification('Failed to delete avatar.', 'red');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,14 +6,12 @@
|
||||
export const API = {
|
||||
AUTH: {
|
||||
LOGIN: '/api/auth/login',
|
||||
LOGOUT: '/api/auth/logout',
|
||||
REGISTER: '/api/auth/register',
|
||||
GITHUB: '/api/auth/github'
|
||||
},
|
||||
AVATAR: {
|
||||
GET: '/api/avatar/me',
|
||||
UPLOAD: '/api/avatar/upload',
|
||||
DELETE: '/api/avatar/delete'
|
||||
UPLOAD: '/api/avatar/upload'
|
||||
},
|
||||
FRIENDS: {
|
||||
LIST: '/api/friends',
|
||||
@@ -3,20 +3,17 @@
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
class Duel {
|
||||
// ui : { showOverlay, hideOverlay, render, renderOpponent, updateButtons }
|
||||
constructor(socket, tetrisGame, onStatusChange, onStart, ui) {
|
||||
constructor(socket, tetrisGame, onStatusChange, onStart) {
|
||||
this.socket = socket;
|
||||
this.tetrisGame = tetrisGame;
|
||||
this.onStatusChange = onStatusChange;
|
||||
this.onStart = onStart;
|
||||
this.ui = ui;
|
||||
this.onStatusChange = onStatusChange; // (status, opponentName) => void
|
||||
this.onStart = onStart; // () => void — déclenche le début du jeu local
|
||||
|
||||
this.action_queue = [];
|
||||
this.opponentGrid = this._emptyGrid();
|
||||
this.opponentScore = 0;
|
||||
this.opponentShieldActive = false;
|
||||
this.roomCode = null;
|
||||
this.isReady = false;
|
||||
this.action_queue = [];
|
||||
this.opponentGrid = this._emptyGrid();
|
||||
this.opponentScore = 0;
|
||||
this.roomCode = null;
|
||||
this.isReady = false;
|
||||
|
||||
this._bindSocketEvents();
|
||||
}
|
||||
@@ -36,11 +33,10 @@ class Duel {
|
||||
leave() {
|
||||
if (!this.roomCode) return;
|
||||
this.socket.emit('tetris:leave');
|
||||
this.roomCode = null;
|
||||
this.isReady = false;
|
||||
this.opponentGrid = this._emptyGrid();
|
||||
this.opponentScore = 0;
|
||||
this.opponentShieldActive = false;
|
||||
this.roomCode = null;
|
||||
this.isReady = false;
|
||||
this.opponentGrid = this._emptyGrid();
|
||||
this.opponentScore = 0;
|
||||
}
|
||||
|
||||
// ─── Hooks appelés par tetris.js ──────────
|
||||
@@ -52,7 +48,9 @@ class Duel {
|
||||
|
||||
onLocalLinesCleared(count, holeCol) {
|
||||
if (!this.isReady) return;
|
||||
const garbageLines = Array.from({ length: count }, () => this._buildGarbageLine(holeCol));
|
||||
const garbageLines = [];
|
||||
for (let i = 0; i < count; i++)
|
||||
garbageLines.push(this._buildGarbageLine(holeCol));
|
||||
this.socket.emit('tetris:lines-cleared', { count, holeCol, garbageLines });
|
||||
}
|
||||
|
||||
@@ -62,12 +60,6 @@ class Duel {
|
||||
this.endDuel();
|
||||
}
|
||||
|
||||
onLocalShieldChanged(event) {
|
||||
if (!this.isReady) return;
|
||||
if (event === 'activated') this.socket.emit('tetris:shield-activated');
|
||||
else if (event === 'deactivated') this.socket.emit('tetris:shield-deactivated');
|
||||
}
|
||||
|
||||
endDuel() {
|
||||
this.isReady = false;
|
||||
this.action_queue = [];
|
||||
@@ -78,7 +70,8 @@ class Duel {
|
||||
|
||||
synchronize_game() {
|
||||
while (this.action_queue.length > 0) {
|
||||
this._processAction(this.action_queue.shift());
|
||||
const action = this.action_queue.shift();
|
||||
this._processAction(action);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,7 +81,7 @@ class Duel {
|
||||
this.opponentGrid = action.grid;
|
||||
this.opponentScore = action.score;
|
||||
document.getElementById('opponent-score').textContent = action.score;
|
||||
this.ui.renderOpponent(this.opponentGrid, this.opponentShieldActive);
|
||||
renderOpponent(this.opponentGrid);
|
||||
break;
|
||||
|
||||
case 'LINES_CLEARED':
|
||||
@@ -96,17 +89,9 @@ class Duel {
|
||||
break;
|
||||
|
||||
case 'OPPONENT_GAME_OVER':
|
||||
this.ui.showOverlay('YOU WIN', action.score);
|
||||
showOverlay('YOU WIN', action.score);
|
||||
this.endDuel();
|
||||
break;
|
||||
|
||||
case 'OPPONENT_SHIELD_ACTIVATED':
|
||||
this.opponentShieldActive = true;
|
||||
break;
|
||||
|
||||
case 'OPPONENT_SHIELD_DEACTIVATED':
|
||||
this.opponentShieldActive = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,36 +127,28 @@ class Duel {
|
||||
this.action_queue.push({ type: 'OPPONENT_GAME_OVER', score: data.score, validBlock: data.validBlock });
|
||||
});
|
||||
|
||||
this.socket.on('tetris:shield-activated', () => {
|
||||
this.action_queue.push({ type: 'OPPONENT_SHIELD_ACTIVATED' });
|
||||
});
|
||||
|
||||
this.socket.on('tetris:shield-deactivated', () => {
|
||||
this.action_queue.push({ type: 'OPPONENT_SHIELD_DEACTIVATED' });
|
||||
});
|
||||
|
||||
this.socket.on('tetris:start-duel', () => {
|
||||
if (this.onStart) this.onStart();
|
||||
});
|
||||
|
||||
this.socket.on('tetris:pause', () => {
|
||||
this.tetrisGame.pause();
|
||||
this.ui.updateButtons();
|
||||
if (this.tetrisGame.isPaused) this.ui.showOverlay('PAUSE');
|
||||
else this.ui.hideOverlay();
|
||||
updateButtons();
|
||||
if (this.tetrisGame.isPaused) showOverlay('PAUSE');
|
||||
else hideOverlay();
|
||||
});
|
||||
|
||||
this.socket.on('tetris:stop', () => {
|
||||
this.tetrisGame.stop();
|
||||
this.ui.updateButtons();
|
||||
this.ui.render();
|
||||
this.ui.showOverlay('STOPPED');
|
||||
updateButtons();
|
||||
render();
|
||||
showOverlay('STOPPED');
|
||||
});
|
||||
|
||||
this.socket.on('tetris:settings', (data) => {
|
||||
document.getElementById('input-ttd').value = data.timeToDown;
|
||||
document.getElementById('input-hardening').value = data.hardening;
|
||||
document.getElementById('input-decrement').value = data.decrementTTD;
|
||||
document.getElementById('input-ttd').value = data.timeToDown;
|
||||
document.getElementById('input-hardening').value = data.hardening;
|
||||
document.getElementById('input-decrement').value = data.decrementTTD;
|
||||
this.tetrisGame.configure(data);
|
||||
});
|
||||
}
|
||||
@@ -82,7 +82,6 @@ export const Events = {
|
||||
|
||||
// Avatar
|
||||
AVATAR_UPDATED: 'avatar:updated',
|
||||
AVATAR_DELETED: 'avatar:deleted',
|
||||
|
||||
// Chat
|
||||
CHAT_CONNECTED: 'chat:connected',
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Window, windowRegistry } from '../core/windows.js';
|
||||
import { API, STORAGE_KEYS, CSS } from '../core/config.js';
|
||||
import { eventBus, Events } from '../core/events.js';
|
||||
import { Window, windowRegistry } from './windows.js';
|
||||
import { API, STORAGE_KEYS, CSS } from './config.js';
|
||||
import { eventBus, Events } from './events.js';
|
||||
|
||||
/**
|
||||
* Friends management window
|
||||
@@ -1,26 +1,27 @@
|
||||
:root {
|
||||
--color-primary: #0066cc;
|
||||
--color-primary-hover: #0052a3;
|
||||
--color-primary: #ffc75e;
|
||||
--color-primary-hover: #ffc75e;
|
||||
--color-success: #3cff01;
|
||||
--color-success-dark: #28a745;
|
||||
--color-success-dark: #ffc75e;
|
||||
--color-error: #ff4d4d;
|
||||
--color-warning: #ffc107;
|
||||
--color-github: #24292e;
|
||||
--color-warning: #ffc75e;
|
||||
--color-github: #ffc75e;
|
||||
|
||||
--color-bg: #000;
|
||||
--color-bg: #ffe5b5;
|
||||
|
||||
--app-background-base: radial-gradient(
|
||||
circle at top,
|
||||
#1b2735,
|
||||
#090a0f
|
||||
#3fc9ff,
|
||||
#21fcc5
|
||||
|
||||
);
|
||||
|
||||
/* --app-background-image: url("./assets/background.png"); */
|
||||
--app-background-image: url("./assets/Frame1.png");
|
||||
|
||||
--color-surface: #222;
|
||||
--color-surface-light: #333;
|
||||
--color-text: #fff;
|
||||
--color-text-muted: #aaa;
|
||||
--color-surface: #ffcc00;
|
||||
--color-surface-light: #feffa6;
|
||||
--color-text: #000000;
|
||||
--color-text-muted: #353535;
|
||||
|
||||
--font-size-base: 10px;
|
||||
--font-size-sm: 1.2rem;
|
||||
@@ -63,18 +64,24 @@
|
||||
html {
|
||||
height: 100%;
|
||||
background-image:
|
||||
var(--app-background-image),
|
||||
var(--app-background-base);
|
||||
|
||||
animation: bg-animation 12s steps(1) infinite;
|
||||
|
||||
background-size: contain, cover;
|
||||
background-position: center, center;
|
||||
background-repeat: no-repeat, no-repeat;
|
||||
|
||||
|
||||
background-size:
|
||||
contain,
|
||||
cover;
|
||||
|
||||
background-position:
|
||||
center,
|
||||
center;
|
||||
|
||||
background-repeat:
|
||||
no-repeat,
|
||||
no-repeat;
|
||||
}
|
||||
|
||||
@@ -93,54 +100,136 @@ body {
|
||||
}
|
||||
|
||||
|
||||
/* ============================================
|
||||
ANIMATIONS
|
||||
============================================ */
|
||||
|
||||
@keyframes wobble {
|
||||
0% { transform: translate(0%, 0) rotate(0deg); }
|
||||
25% { transform: translate(-5%, -1px) rotate(-0.5deg); }
|
||||
50% { transform: translate(0%, 1px) rotate(0.5deg); }
|
||||
75% { transform: translate(+5%, -1px) rotate(0.5deg); }
|
||||
100% { transform: translate(0%, 0) rotate(0deg); }
|
||||
}
|
||||
|
||||
@keyframes bounce {
|
||||
0% { transform: translateY(0) rotate(var(--rot)); }
|
||||
33% { transform: translateY(-6px) rotate(var(--rot)); }
|
||||
66% { transform: translateY(-8px) rotate(var(--rot)); }
|
||||
100% { transform: translateY(0) rotate(var(--rot)); }
|
||||
}
|
||||
|
||||
@keyframes bg-animation {
|
||||
0% {
|
||||
background-image: url("./assets/Frame1.png"), var(--app-background-base);
|
||||
}
|
||||
8.33% {
|
||||
background-image: url("./assets/Frame2.png"), var(--app-background-base);
|
||||
}
|
||||
16.66% {
|
||||
background-image: url("./assets/Frame3.png"), var(--app-background-base);
|
||||
}
|
||||
25% {
|
||||
background-image: url("./assets/Frame4.png"), var(--app-background-base);
|
||||
}
|
||||
33.33% {
|
||||
background-image: url("./assets/Frame5.png"), var(--app-background-base);
|
||||
}
|
||||
41.66% {
|
||||
background-image: url("./assets/Frame6.png"), var(--app-background-base);
|
||||
}
|
||||
50% {
|
||||
background-image: url("./assets/Frame7.png"), var(--app-background-base);
|
||||
}
|
||||
58.33% {
|
||||
background-image: url("./assets/Frame8.png"), var(--app-background-base);
|
||||
}
|
||||
66.66% {
|
||||
background-image: url("./assets/Frame9.png"), var(--app-background-base);
|
||||
}
|
||||
75% {
|
||||
background-image: url("./assets/Frame10.png"), var(--app-background-base);
|
||||
}
|
||||
83.33% {
|
||||
background-image: url("./assets/Frame11.png"), var(--app-background-base);
|
||||
}
|
||||
91.66% {
|
||||
background-image: url("./assets/Frame12.png"), var(--app-background-base);
|
||||
}
|
||||
100% {
|
||||
background-image: url("./assets/Frame1.png"), var(--app-background-base);
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
TYPOGRAPHY
|
||||
============================================ */
|
||||
|
||||
.title {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
top: 20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
text-transform: uppercase;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 20px;
|
||||
font-size: var(--font-size-xl);
|
||||
text-align: center;
|
||||
text-shadow: 2px 2px 10px black;
|
||||
z-index: 1;
|
||||
font-family: "Cinzel Decorative", cursive;
|
||||
color: var(--color-success);
|
||||
margin: 0;
|
||||
padding: var(--spacing-md);
|
||||
translate: -50% 0;
|
||||
background: #ffcc00;
|
||||
color: #000;
|
||||
|
||||
border: 4px solid #feffa6;
|
||||
border-radius: 18px;
|
||||
|
||||
padding: 0.6rem 1.2rem;
|
||||
|
||||
animation: wobble 2s infinite ease-in-out;
|
||||
}
|
||||
|
||||
.title span {
|
||||
display: inline-block;
|
||||
transform-origin: center;
|
||||
font-size: 4rem;
|
||||
font-weight: bold;
|
||||
text-shadow: 2px 2px 6px rgba(0, 0, 0, 0.5);
|
||||
|
||||
animation: bounce 1.2s infinite alternate;
|
||||
animation-timing-function: ease-in-out;
|
||||
}
|
||||
|
||||
.title span:nth-child(1) { --rot: -5deg; color: #ff4d4d; }
|
||||
.title span:nth-child(2) { --rot: 3deg; color: #5beb67; }
|
||||
.title span:nth-child(3) { --rot: -3deg; color: #ca8dfc; }
|
||||
.title span:nth-child(4) { --rot: 2deg; color: #6698f5; }
|
||||
.title span:nth-child(5) { --rot: -4deg; color: #ff66cc; }
|
||||
|
||||
.title span:nth-child(2) { animation-delay: 0.2s; }
|
||||
.title span:nth-child(3) { animation-delay: 0.4s; }
|
||||
.title span:nth-child(4) { animation-delay: 0.6s; }
|
||||
.title span:nth-child(5) { animation-delay: 0.8s; }
|
||||
|
||||
.title span { will-change: transform; }
|
||||
|
||||
/* ============================================
|
||||
MENU
|
||||
============================================ */
|
||||
|
||||
.menu {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
top: var(--spacing-lg);
|
||||
left: 50px;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
z-index: var(--z-menu);
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xs);
|
||||
gap: var(--spacing-lg);
|
||||
|
||||
z-index: var(--z-menu);
|
||||
}
|
||||
|
||||
.menu__item {
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--color-surface-light);
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
font-size: var(--font-size-md);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
text-align: left;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.menu__item:hover {
|
||||
@@ -159,25 +248,22 @@ body {
|
||||
|
||||
.game {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
top: var(--spacing-lg);
|
||||
right: 50px;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
z-index: var(--z-menu);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.game__item {
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--color-surface-light);
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
font-size: var(--font-size-md);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
text-align: right;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.game__item:hover {
|
||||
@@ -208,6 +294,8 @@ body {
|
||||
}
|
||||
|
||||
.page__item {
|
||||
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
border: 1px solid var(--color-surface-light);
|
||||
@@ -215,7 +303,7 @@ body {
|
||||
font-size: var(--font-size-md);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
text-align: right;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.page__item:hover {
|
||||
@@ -228,10 +316,10 @@ body {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
|
||||
/* ============================================
|
||||
BUTTONS
|
||||
============================================ */
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -328,13 +416,15 @@ body {
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background: var(--color-bg);
|
||||
border: 2px ridge var(--color-text);
|
||||
color: var(--color-text);
|
||||
z-index: var(--z-window);
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
min-width: 280px;
|
||||
box-shadow: var(--shadow-lg);
|
||||
border-radius: 5px;
|
||||
border-color: #aa1f1f;
|
||||
border: 6px solid #faac37;
|
||||
}
|
||||
|
||||
.window--visible {
|
||||
@@ -395,7 +485,8 @@ body {
|
||||
.message {
|
||||
font-size: var(--font-size-sm);
|
||||
padding: var(--spacing-xs);
|
||||
border-radius: var(--radius-sm);
|
||||
border-radius: var(--radius-lg);
|
||||
border-color: #000;
|
||||
}
|
||||
|
||||
.message--success {
|
||||
@@ -415,6 +506,11 @@ body {
|
||||
============================================ */
|
||||
.login {
|
||||
width: 320px;
|
||||
border-radius: 5px;
|
||||
border-color: #aa1f1f;
|
||||
border: 6px solid #faac37;
|
||||
background: #ffffff;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.login__form {
|
||||
@@ -557,28 +653,74 @@ body {
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
EASTER EGG BUTTON
|
||||
STATS WINDOW
|
||||
============================================ */
|
||||
/* .easter-egg {
|
||||
position: absolute;
|
||||
top: 20%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 1;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
border: 1px solid var(--color-surface-light);
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
cursor: pointer;
|
||||
font-size: var(--font-size-md);
|
||||
border-radius: var(--radius-md);
|
||||
transition: all var(--transition-fast);
|
||||
.stats-window {
|
||||
width: 320px;
|
||||
}
|
||||
|
||||
.easter-egg:hover {
|
||||
background: var(--color-error);
|
||||
border-color: var(--color-error);
|
||||
} */
|
||||
.stats__avatar {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
object-fit: cover;
|
||||
border-radius: var(--radius-full);
|
||||
border: 2px solid var(--color-text);
|
||||
align-self: center;
|
||||
display: block;
|
||||
margin: 0 auto var(--spacing-xs);
|
||||
}
|
||||
|
||||
.stats__username {
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
color: #000;
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.stats__section {
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.stats__section-title {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--color-primary);
|
||||
border-bottom: 1px solid var(--color-surface-light);
|
||||
padding-bottom: var(--spacing-xs);
|
||||
margin-bottom: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.stats__section-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.stats__row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: var(--font-size-sm);
|
||||
padding: 3px 0;
|
||||
}
|
||||
|
||||
.stats__label {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.stats__value {
|
||||
font-weight: 600;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.stats__loading {
|
||||
font-size: var(--font-size-sm);
|
||||
color: #333;
|
||||
text-align: center;
|
||||
padding: var(--spacing-sm) 0;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
UTILITIES
|
||||
@@ -625,7 +767,7 @@ body {
|
||||
.friends__tab {
|
||||
flex: 1;
|
||||
padding: var(--spacing-sm);
|
||||
background: var(--color-surface);
|
||||
background: var(--color-surface-light);
|
||||
border: 1px solid var(--color-surface-light);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
@@ -705,317 +847,3 @@ body {
|
||||
color: var(--color-text-muted);
|
||||
padding: var(--spacing-lg);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
GAME ROOM WINDOW
|
||||
============================================ */
|
||||
.gameroom-window {
|
||||
width: 600px;
|
||||
height: 800px;
|
||||
}
|
||||
|
||||
.gameroom__tabs {
|
||||
display: flex;
|
||||
gap: var(--spacing-xs);
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.gameroom__tab {
|
||||
flex: 1;
|
||||
padding: var(--spacing-sm);
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-surface-light);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
font-size: var(--font-size-sm);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.gameroom__tab:hover {
|
||||
background: var(--color-surface-light);
|
||||
}
|
||||
|
||||
.gameroom__tab--active {
|
||||
background: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.gameroom__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.gameroom__create {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-sm);
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.gameroom__list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.gameroom__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-sm);
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.gameroom__name {
|
||||
flex: 1;
|
||||
font-size: var(--font-size-md);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.gameroom__players {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-muted);
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
background: var(--color-surface-light);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.gameroom__actions {
|
||||
display: flex;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.gameroom__actions .btn {
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.gameroom__lobby {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.gameroom__lobby-title {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-lg);
|
||||
text-align: center;
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.gameroom__player-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-sm);
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.gameroom__player {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
background: var(--color-surface-light);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.gameroom__player-avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: var(--radius-full);
|
||||
object-fit: cover;
|
||||
border: 2px solid var(--color-surface-light);
|
||||
}
|
||||
|
||||
.gameroom__player-name {
|
||||
flex: 1;
|
||||
font-size: var(--font-size-md);
|
||||
}
|
||||
|
||||
.gameroom__player-stats {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.gameroom__player-score {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-success);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.gameroom__player-total {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.gameroom__empty {
|
||||
text-align: center;
|
||||
color: var(--color-text-muted);
|
||||
padding: var(--spacing-lg);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
GAME - JEU DU PENDU/DESSIN
|
||||
============================================ */
|
||||
|
||||
.gameroom__lobby-buttons {
|
||||
display: flex;
|
||||
gap: var(--spacing-sm);
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.gameroom__lobby-buttons .btn {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.gameroom__game {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-sm);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.gameroom__game-info {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.gameroom__drawer-info {
|
||||
font-size: var(--font-size-md);
|
||||
color: var(--color-text-muted);
|
||||
padding: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.gameroom__scores-display {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-success);
|
||||
padding: var(--spacing-xs);
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius-sm);
|
||||
margin-top: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.gameroom__drawer-info--winner {
|
||||
color: var(--color-success);
|
||||
font-weight: bold;
|
||||
animation: pulse 0.5s ease-in-out 3;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { transform: scale(1); }
|
||||
50% { transform: scale(1.05); }
|
||||
}
|
||||
|
||||
.gameroom__word-display {
|
||||
font-size: var(--font-size-xl);
|
||||
font-family: monospace;
|
||||
text-align: center;
|
||||
letter-spacing: 8px;
|
||||
padding: var(--spacing-md);
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius-md);
|
||||
min-height: 60px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.gameroom__canvas-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.gameroom__canvas {
|
||||
background: var(--color-surface-light);
|
||||
border-radius: var(--radius-md);
|
||||
cursor: crosshair;
|
||||
border: 2px solid var(--color-surface-light);
|
||||
}
|
||||
|
||||
.gameroom__draw-tools {
|
||||
display: flex;
|
||||
gap: var(--spacing-sm);
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.gameroom__color-picker {
|
||||
width: 40px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.gameroom__word-input-container,
|
||||
.gameroom__guess-container {
|
||||
display: flex;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.gameroom__word-input-container .input,
|
||||
.gameroom__guess-container .input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.gameroom__guess-container .input:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.gameroom__guess-container .btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.gameroom__guess-history {
|
||||
flex: 1;
|
||||
min-height: 60px;
|
||||
max-height: 100px;
|
||||
overflow-y: auto;
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--spacing-sm);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.gameroom__guess-item {
|
||||
font-size: var(--font-size-sm);
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.gameroom__guess-item--success {
|
||||
background: rgba(60, 255, 1, 0.2);
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.gameroom__guess-item--fail {
|
||||
background: rgba(255, 77, 77, 0.2);
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.gameroom__game-buttons {
|
||||
display: flex;
|
||||
gap: var(--spacing-sm);
|
||||
margin-top: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.gameroom__game-buttons .btn {
|
||||
flex: 1;
|
||||
}
|
||||
@@ -9,8 +9,15 @@
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Cinzel+Decorative:wght@400;700&display=swap" rel="stylesheet" />
|
||||
</head>
|
||||
<script type="module" src="app.js"></script>
|
||||
<body>
|
||||
<h1 class="title">Lobby</h1>
|
||||
<h1 class="title">
|
||||
<span>L</span>
|
||||
<span>o</span>
|
||||
<span>b</span>
|
||||
<span>b</span>
|
||||
<span>y</span>
|
||||
</h1>
|
||||
|
||||
<nav class="menu" aria-label="Menu principal">
|
||||
<button class="menu__item" data-action="login" aria-label="Login">Login</button>
|
||||
@@ -21,14 +28,11 @@
|
||||
|
||||
<nav class="game" aria-label="Game">
|
||||
<button class="game__item" data-action="Home page" aria-label="Home Page"
|
||||
onclick="window.location.href='../index.html'">Home Page</button>
|
||||
onclick="window.location.href='index.html'">Home Page</button>
|
||||
</nav>
|
||||
|
||||
<div class="page" aria-label="Page">
|
||||
<button class="page__item" data-action="gameroom" aria-label="Game Rooms">Game Rooms</button>
|
||||
</div>
|
||||
|
||||
|
||||
<script type="module" src="../app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Window } from '../core/windows.js';
|
||||
import { API, STORAGE_KEYS, CSS } from '../core/config.js';
|
||||
import { eventBus, Events } from '../core/events.js';
|
||||
import { Window } from './windows.js';
|
||||
import { API, STORAGE_KEYS, CSS } from './config.js';
|
||||
import { eventBus, Events } from './events.js';
|
||||
|
||||
export class GameRoomWindow extends Window {
|
||||
constructor() {
|
||||
@@ -194,8 +194,7 @@ export class GameRoomWindow extends Window {
|
||||
players: [],
|
||||
currentPlayerIndex: 0,
|
||||
guessedLetters: [],
|
||||
scores: {},
|
||||
counter: 0
|
||||
scores: {}
|
||||
};
|
||||
|
||||
this.initDrawing();
|
||||
@@ -718,7 +717,7 @@ export class GameRoomWindow extends Window {
|
||||
const altPort = window.GLOBAL_CHAT_ALT_PORT;
|
||||
if (altPort) {
|
||||
const host = location.hostname || 'localhost';
|
||||
this.socket = io(`${location.protocol}//${host}:${altPort}`, ioConfig);
|
||||
this.socket = io(`http://${host}:${altPort}`, ioConfig);
|
||||
} else {
|
||||
this.socket = io(ioConfig);
|
||||
}
|
||||
@@ -840,20 +839,17 @@ export class GameRoomWindow extends Window {
|
||||
const name = this.roomNameInput.value.trim();
|
||||
if (!name) {
|
||||
this.showMessage('Entrez un nom pour le salon', 'error');
|
||||
this.showNotification('Entrez un nom pour le salon', 'red');
|
||||
return;
|
||||
}
|
||||
|
||||
const token = localStorage.getItem(STORAGE_KEYS.AUTH_TOKEN);
|
||||
if (!token) {
|
||||
this.showMessage('Connectez-vous pour creer un salon', 'info');
|
||||
this.showNotification('Connectez-vous pour créer un salon', 'red');
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.currentRoom) {
|
||||
this.showMessage('Vous etes deja dans un salon. Quittez-le d\'abord.', 'error');
|
||||
this.showNotification('Vous êtes déjà dans un salon. Quittez-le d\'abord.', 'red');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -867,7 +863,6 @@ export class GameRoomWindow extends Window {
|
||||
this.currentRoom = currentData;
|
||||
this.enterLobby(currentData);
|
||||
this.showMessage('Vous etes deja dans un salon', 'error');
|
||||
this.showNotification('Vous êtes déjà dans un salon. Quittez-le d\'abord.', 'red');
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -888,7 +883,6 @@ export class GameRoomWindow extends Window {
|
||||
|
||||
if (this.roomNameExists(name)) {
|
||||
this.showMessage('Un salon avec ce nom existe deja', 'error');
|
||||
this.showNotification('Un salon avec ce nom existe deja', 'red');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -910,7 +904,6 @@ export class GameRoomWindow extends Window {
|
||||
this.showMessage('Salon cree', 'success');
|
||||
eventBus.emit(Events.ROOM_CREATED, data);
|
||||
this.enterLobby(data);
|
||||
this.showNotification(`Vous avez créé le salon "${data.name}"`, 'green');
|
||||
} catch (error) {
|
||||
console.error('Create room error:', error);
|
||||
this.showMessage('Erreur de connexion', 'error');
|
||||
@@ -1043,7 +1036,6 @@ export class GameRoomWindow extends Window {
|
||||
|
||||
if (this.currentRoom) {
|
||||
this.showMessage('Vous etes deja dans un salon. Quittez-le d\'abord.', 'error');
|
||||
this.showNotification('Vous êtes déjà dans un salon. Quittez-le d\'abord.', 'red');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1057,7 +1049,6 @@ export class GameRoomWindow extends Window {
|
||||
this.currentRoom = currentData;
|
||||
this.enterLobby(currentData);
|
||||
this.showMessage('Vous etes deja dans un salon', 'error');
|
||||
this.showNotification('Vous êtes déjà dans un salon. Quittez-le d\'abord.', 'red');
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -1577,11 +1568,8 @@ export class GameRoomWindow extends Window {
|
||||
|
||||
nextRound() {
|
||||
// Move to next player
|
||||
this.gameState.counter++;
|
||||
if (this.gameState.counter >= this.gameState.players.length) {
|
||||
this.gameState.counter = 0;
|
||||
}
|
||||
const nextDrawer = this.gameState.players[this.gameState.counter];
|
||||
this.gameState.currentPlayerIndex = (this.gameState.currentPlayerIndex + 1) % this.gameState.players.length;
|
||||
const nextDrawer = this.gameState.players[this.gameState.currentPlayerIndex];
|
||||
|
||||
if (this.socket?.connected) {
|
||||
this.socket.emit('game-next-round', { drawer: nextDrawer });
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Window } from '../core/windows.js';
|
||||
import { STORAGE_KEYS, CSS } from '../core/config.js';
|
||||
import { eventBus, Events } from '../core/events.js';
|
||||
import { Window } from './windows.js';
|
||||
import { STORAGE_KEYS, CSS } from './config.js';
|
||||
import { eventBus, Events } from './events.js';
|
||||
|
||||
/**
|
||||
* Global chat window
|
||||
@@ -222,7 +222,7 @@ export class GlobalChat extends Window {
|
||||
const altPort = window.GLOBAL_CHAT_ALT_PORT;
|
||||
if (altPort) {
|
||||
const host = location.hostname || 'localhost';
|
||||
this.socket = io(`${location.protocol}//${host}:${altPort}`, ioConfig);
|
||||
this.socket = io(`http://${host}:${altPort}`, ioConfig);
|
||||
} else {
|
||||
this.socket = io(ioConfig);
|
||||
}
|
||||
@@ -7,28 +7,28 @@
|
||||
CSS VARIABLES
|
||||
============================================ */
|
||||
:root {
|
||||
--color-primary: #0066cc;
|
||||
--color-primary-hover: #0052a3;
|
||||
--color-primary: #ffc75e;
|
||||
--color-primary-hover: #ffc75e;
|
||||
--color-success: #3cff01;
|
||||
--color-success-dark: #28a745;
|
||||
--color-success-dark: #ffc75e;
|
||||
--color-error: #ff4d4d;
|
||||
--color-warning: #ffc107;
|
||||
--color-github: #24292e;
|
||||
--color-warning: #ffc75e;
|
||||
--color-github: #ffc75e;
|
||||
|
||||
--color-bg: #a3a3a3;
|
||||
--color-bg: #ffe5b5;
|
||||
|
||||
--app-background-base: radial-gradient(
|
||||
circle at top,
|
||||
#000000,
|
||||
#4d4d4d
|
||||
#fff787,
|
||||
#ff8080
|
||||
);
|
||||
|
||||
--app-background-image: url("./assets/background.png");
|
||||
|
||||
--color-surface: #222;
|
||||
--color-surface-light: #333;
|
||||
--color-text: #fff;
|
||||
--color-text-muted: #aaa;
|
||||
--color-surface: #ffefce;
|
||||
--color-surface-light: #ffc75e;
|
||||
--color-text: #000000;
|
||||
--color-text-muted: #000000;
|
||||
|
||||
--font-size-base: 10px;
|
||||
--font-size-sm: 1.2rem;
|
||||
@@ -117,16 +117,16 @@ body {
|
||||
text-align: center;
|
||||
text-shadow: 2px 2px 10px black;
|
||||
z-index: 1;
|
||||
font-family: "Cinzel Decorative", cursive;
|
||||
font-family: "Roboto";
|
||||
letter-spacing: -10px;
|
||||
color: rgba(248, 252, 2, 0.6);
|
||||
|
||||
margin: 0;
|
||||
padding: var(--spacing-md);
|
||||
padding: 0.6rem 1.2rem;
|
||||
|
||||
/* Rectangle + rounded corners */
|
||||
background-color: rgba(247, 7, 67, 0.6);
|
||||
background-color: #ffefce;
|
||||
border: 2px solid rgba(0, 0, 0, 0.6);
|
||||
border-radius: 15px;
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
|
||||
@@ -134,7 +134,7 @@ body {
|
||||
MENU
|
||||
============================================ */
|
||||
|
||||
.menu {
|
||||
/* .menu {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 50px;
|
||||
@@ -144,17 +144,31 @@ body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xs);
|
||||
} */
|
||||
|
||||
.menu {
|
||||
position: fixed;
|
||||
top: var(--spacing-lg);
|
||||
left: 50px;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-lg);
|
||||
|
||||
z-index: var(--z-menu);
|
||||
}
|
||||
|
||||
.menu__item {
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
border: 1px solid var(--color-surface-light);
|
||||
border-radius: var(--radius-lg);
|
||||
border-color: #000;
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
font-size: var(--font-size-md);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
text-align: left;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.menu__item:hover {
|
||||
@@ -171,7 +185,7 @@ body {
|
||||
GAME
|
||||
============================================ */
|
||||
|
||||
.game {
|
||||
/* .game {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 50px;
|
||||
@@ -181,17 +195,31 @@ body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xs);
|
||||
} */
|
||||
|
||||
.game {
|
||||
position: fixed;
|
||||
top: var(--spacing-lg);
|
||||
right: 50px;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-lg);
|
||||
|
||||
z-index: var(--z-menu);
|
||||
}
|
||||
|
||||
.game__item {
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
border: 1px solid var(--color-surface-light);
|
||||
border-radius: var(--radius-lg);
|
||||
border-color: #000;
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
font-size: var(--font-size-md);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
text-align: right;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.game__item:hover {
|
||||
@@ -303,13 +331,15 @@ body {
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background: var(--color-bg);
|
||||
border: 2px ridge var(--color-text);
|
||||
color: var(--color-text);
|
||||
z-index: var(--z-window);
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
min-width: 280px;
|
||||
box-shadow: var(--shadow-lg);
|
||||
border-radius: 5px;
|
||||
border-color: #aa1f1f;
|
||||
border: 6px solid #faac37;
|
||||
}
|
||||
|
||||
.window--visible {
|
||||
@@ -370,7 +400,8 @@ body {
|
||||
.message {
|
||||
font-size: var(--font-size-sm);
|
||||
padding: var(--spacing-xs);
|
||||
border-radius: var(--radius-sm);
|
||||
border-radius: var(--radius-lg);
|
||||
border-color: #000;
|
||||
}
|
||||
|
||||
.message--success {
|
||||
@@ -390,6 +421,11 @@ body {
|
||||
============================================ */
|
||||
.login {
|
||||
width: 320px;
|
||||
border-radius: 5px;
|
||||
border-color: #aa1f1f;
|
||||
border: 6px solid #faac37;
|
||||
background: #ffffff;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.login__form {
|
||||
@@ -601,30 +637,6 @@ body {
|
||||
padding: var(--spacing-sm) 0;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
EASTER EGG BUTTON
|
||||
============================================ */
|
||||
/* .easter-egg {
|
||||
position: absolute;
|
||||
top: 20%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 1;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
border: 1px solid var(--color-surface-light);
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
cursor: pointer;
|
||||
font-size: var(--font-size-md);
|
||||
border-radius: var(--radius-md);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.easter-egg:hover {
|
||||
background: var(--color-error);
|
||||
border-color: var(--color-error);
|
||||
} */
|
||||
|
||||
/* ============================================
|
||||
UTILITIES
|
||||
============================================ */
|
||||
@@ -670,7 +682,7 @@ body {
|
||||
.friends__tab {
|
||||
flex: 1;
|
||||
padding: var(--spacing-sm);
|
||||
background: var(--color-surface);
|
||||
background: var(--color-surface-light);
|
||||
border: 1px solid var(--color-surface-light);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
|
||||
@@ -3,28 +3,27 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Transcendence.io</title>
|
||||
<title>Transcendence</title>
|
||||
<link rel="stylesheet" href="index.css" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Cinzel+Decorative:wght@400;700&display=swap" rel="stylesheet" />
|
||||
</head>
|
||||
<body>
|
||||
<h1 class="title">Transcendence.io</h1>
|
||||
<h1 class="title">Transcendence</h1>
|
||||
|
||||
<nav class="menu" aria-label="Menu principal">
|
||||
<button class="menu__item" data-action="login" aria-label="Login">Login</button>
|
||||
<button class="menu__item" data-action="chat" aria-label="Global chat">Global chat</button>
|
||||
<button class="menu__item" data-action="avatar" aria-label="Avatar">Avatar</button>
|
||||
<button class="menu__item" data-action="friends" aria-label="Amis">Amis</button>
|
||||
<button class="menu__item" data-action="logout" aria-label="Logout">Logout</button>
|
||||
</nav>
|
||||
|
||||
<nav class="game" aria-label="Game">
|
||||
<button class="game__item" data-action="new_game" aria-label="Start new game"
|
||||
onclick="window.location.href='game/game.html'">Start new game</button>
|
||||
<button class="game__item" data-action="new_game" aria-label="Skkrrribl.io"
|
||||
onclick="window.location.href='game.html'">Skkrrribl.io</button>
|
||||
<button class="game__item" data-action="tetris" aria-label="Tetris"
|
||||
onclick="window.location.href='tetris/tetris.html'">Tetris</button>
|
||||
onclick="window.location.href='tetris.html'">Tetris</button>
|
||||
</nav>
|
||||
|
||||
<script type="module" src="app.js"></script>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Window } from '../core/windows.js';
|
||||
import { API, STORAGE_KEYS, CSS } from '../core/config.js';
|
||||
import { eventBus, Events } from '../core/events.js';
|
||||
import { Window } from './windows.js';
|
||||
import { API, STORAGE_KEYS, CSS } from './config.js';
|
||||
import { eventBus, Events } from './events.js';
|
||||
|
||||
/**
|
||||
* Login and registration window
|
||||
@@ -129,7 +129,6 @@ export class LoginWindow extends Window {
|
||||
if (response.ok && data.token) {
|
||||
localStorage.setItem(STORAGE_KEYS.AUTH_TOKEN, data.token);
|
||||
this.showMessage('Login successful! Welcome.', 'success');
|
||||
this.showNotification('Login successful', 'green');
|
||||
|
||||
// Emit login event
|
||||
eventBus.emit(Events.USER_LOGGED_IN, { username, token: data.token });
|
||||
@@ -139,7 +138,6 @@ export class LoginWindow extends Window {
|
||||
} else {
|
||||
const errorMsg = data?.message || 'Login failed';
|
||||
this.showMessage(errorMsg, 'error');
|
||||
this.showNotification(errorMsg, 'red');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
@@ -172,12 +170,10 @@ export class LoginWindow extends Window {
|
||||
|
||||
if (response.ok) {
|
||||
this.showMessage('Registration successful! You can now sign in.', 'success');
|
||||
this.showNotification('Registration successful', 'green');
|
||||
eventBus.emit(Events.USER_REGISTERED, { username });
|
||||
} else {
|
||||
const errorMsg = data?.message || 'Registration failed';
|
||||
this.showMessage(errorMsg, 'error');
|
||||
this.showNotification(errorMsg, 'red');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Registration error:', error);
|
||||
@@ -204,7 +200,6 @@ export class LoginWindow extends Window {
|
||||
if (event.data?.token) {
|
||||
localStorage.setItem(STORAGE_KEYS.AUTH_TOKEN, event.data.token);
|
||||
this.showMessage('GitHub login successful! Welcome.', 'success');
|
||||
this.showNotification('GitHub login successful', 'green');
|
||||
|
||||
// Emit login event
|
||||
eventBus.emit(Events.USER_LOGGED_IN, {
|
||||
@@ -0,0 +1,133 @@
|
||||
// ─────────────────────────────────────────────
|
||||
// RENDU
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
const CELL = 30;
|
||||
const COLORS = ['#000500','#00ff41','#39ff14','#00e676','#76ff03','#b2ff59','#00ffaa','#ccff00','#2d5a2d'];
|
||||
|
||||
const ctxMain = document.getElementById('canvas-main').getContext('2d');
|
||||
const ctxNext = document.getElementById('canvas-next').getContext('2d');
|
||||
const ctxHold = document.getElementById('canvas-hold').getContext('2d');
|
||||
const ctxOpponent = document.getElementById('canvas-opponent').getContext('2d');
|
||||
|
||||
function drawCell(ctx, x, y, colorIndex, size) {
|
||||
const p = 1;
|
||||
const color = COLORS[colorIndex];
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillRect(x * size + p, y * size + p, size - p * 2, size - p * 2);
|
||||
// Glow inner
|
||||
ctx.shadowColor = color;
|
||||
ctx.shadowBlur = 6;
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillRect(x * size + p + 2, y * size + p + 2, size - p * 2 - 4, size - p * 2 - 4);
|
||||
ctx.shadowBlur = 0;
|
||||
// Highlight top/left
|
||||
ctx.fillStyle = 'rgba(200,255,200,0.2)';
|
||||
ctx.fillRect(x * size + p, y * size + p, size - p * 2, 2);
|
||||
ctx.fillRect(x * size + p, y * size + p, 2, size - p * 2);
|
||||
// Shadow bottom/right
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.5)';
|
||||
ctx.fillRect(x * size + p, (y + 1) * size - p - 2, size - p * 2, 2);
|
||||
ctx.fillRect((x + 1) * size - p - 2, y * size + p, 2, size - p * 2);
|
||||
}
|
||||
|
||||
function clearCanvas(ctx, w, h) {
|
||||
ctx.fillStyle = '#000500';
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
}
|
||||
|
||||
function drawGridLines(ctx, cols, rows, size) {
|
||||
ctx.strokeStyle = 'rgba(0,255,65,0.06)';
|
||||
ctx.lineWidth = 1;
|
||||
for (let x = 0; x <= cols; x++) {
|
||||
ctx.beginPath(); ctx.moveTo(x * size, 0); ctx.lineTo(x * size, rows * size); ctx.stroke();
|
||||
}
|
||||
for (let y = 0; y <= rows; y++) {
|
||||
ctx.beginPath(); ctx.moveTo(0, y * size); ctx.lineTo(cols * size, y * size); ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
function drawGhost(ctx, piece, grid) {
|
||||
if (!piece) return;
|
||||
const ghost = { x: piece.getPosition().x, y: piece.getPosition().y };
|
||||
const shape = piece.getShape();
|
||||
|
||||
while (true) {
|
||||
ghost.y++;
|
||||
let valid = true;
|
||||
for (let row = 0; row < shape.length && valid; row++)
|
||||
for (let col = 0; col < shape[row].length && valid; col++)
|
||||
if (shape[row][col] !== 0) {
|
||||
const ny = ghost.y + row;
|
||||
const nx = ghost.x + col;
|
||||
if (ny < 0 || ny >= grid.length || nx < 0 || nx >= grid[ny].length || grid[ny][nx] !== 0) valid = false;
|
||||
}
|
||||
if (!valid) { ghost.y--; break; }
|
||||
}
|
||||
|
||||
if (ghost.y === piece.getPosition().y) return;
|
||||
|
||||
ctx.strokeStyle = 'rgba(0,255,65,0.25)';
|
||||
ctx.lineWidth = 1;
|
||||
for (let row = 0; row < shape.length; row++)
|
||||
for (let col = 0; col < shape[row].length; col++)
|
||||
if (shape[row][col] !== 0)
|
||||
ctx.strokeRect(
|
||||
(ghost.x + col) * CELL + 2,
|
||||
(ghost.y + row) * CELL + 2,
|
||||
CELL - 4, CELL - 4
|
||||
);
|
||||
}
|
||||
|
||||
function drawMiniPiece(ctx, piece, canvasW, canvasH) {
|
||||
clearCanvas(ctx, canvasW, canvasH);
|
||||
if (!piece) return;
|
||||
const shape = piece.getShape();
|
||||
const color = piece.getColor();
|
||||
const s = 20;
|
||||
const offsetX = Math.floor((canvasW / s - shape[0].length) / 2);
|
||||
const offsetY = Math.floor((canvasH / s - shape.length) / 2);
|
||||
for (let row = 0; row < shape.length; row++)
|
||||
for (let col = 0; col < shape[row].length; col++)
|
||||
if (shape[row][col] !== 0)
|
||||
drawCell(ctx, offsetX + col, offsetY + row, color, s);
|
||||
}
|
||||
|
||||
function render() {
|
||||
// Grille principale
|
||||
clearCanvas(ctxMain, 300, 600);
|
||||
drawGridLines(ctxMain, 10, 20, CELL);
|
||||
|
||||
for (let y = 0; y < game.grid.length; y++)
|
||||
for (let x = 0; x < game.grid[y].length; x++)
|
||||
if (game.grid[y][x] !== 0)
|
||||
drawCell(ctxMain, x, y, game.grid[y][x], CELL);
|
||||
|
||||
// Ghost + pièce courante
|
||||
if (game.currentPiece) {
|
||||
drawGhost(ctxMain, game.currentPiece, game.grid);
|
||||
const { x, y } = game.currentPiece.getPosition();
|
||||
const shape = game.currentPiece.getShape();
|
||||
const color = game.currentPiece.getColor();
|
||||
for (let row = 0; row < shape.length; row++)
|
||||
for (let col = 0; col < shape[row].length; col++)
|
||||
if (shape[row][col] !== 0)
|
||||
drawCell(ctxMain, x + col, y + row, color, CELL);
|
||||
}
|
||||
|
||||
// Panneaux miniatures
|
||||
drawMiniPiece(ctxNext, game.nextPiece, 100, 80);
|
||||
drawMiniPiece(ctxHold, game.storedPiece, 100, 80);
|
||||
|
||||
// Score
|
||||
document.getElementById('score-display').textContent = game.score;
|
||||
}
|
||||
|
||||
function renderOpponent(opponentGrid) {
|
||||
clearCanvas(ctxOpponent, 300, 600);
|
||||
drawGridLines(ctxOpponent, 10, 20, CELL);
|
||||
for (let y = 0; y < opponentGrid.length; y++)
|
||||
for (let x = 0; x < opponentGrid[y].length; x++)
|
||||
if (opponentGrid[y][x] !== 0)
|
||||
drawCell(ctxOpponent, x, y, opponentGrid[y][x], CELL);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Window } from '../core/windows.js';
|
||||
import { API, STORAGE_KEYS } from '../core/config.js';
|
||||
import { Window } from './windows.js';
|
||||
import { API, STORAGE_KEYS } from './config.js';
|
||||
|
||||
/**
|
||||
* Stats window — displays Scribble + Tetris stats for any user
|
||||
@@ -445,37 +445,6 @@ button:disabled { opacity: 0.3; cursor: not-allowed; }
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
/* ── Theme color picker ── */
|
||||
.theme-btns {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.theme-btn {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
min-width: 22px;
|
||||
padding: 0;
|
||||
border-radius: 50%;
|
||||
border: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: transform 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.theme-btn[data-theme="green"] { background: #00ff41; }
|
||||
.theme-btn[data-theme="red"] { background: #ff1744; }
|
||||
.theme-btn[data-theme="yellow"] { background: #ffd600; }
|
||||
.theme-btn[data-theme="blue"] { background: #00b0ff; }
|
||||
|
||||
.theme-btn:hover { transform: scale(1.2); }
|
||||
|
||||
.theme-btn.active {
|
||||
border-color: #ffffff;
|
||||
box-shadow: 0 0 8px currentColor;
|
||||
transform: scale(1.15);
|
||||
}
|
||||
|
||||
#settings-panel input[type="number"] {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
@@ -651,36 +620,3 @@ button:disabled { opacity: 0.3; cursor: not-allowed; }
|
||||
}
|
||||
|
||||
body { overflow: hidden; }
|
||||
|
||||
|
||||
/* ── Shield ───────────────────────────────── */
|
||||
.shield-bar-bg {
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
background: rgba(0,212,255,0.15);
|
||||
border-radius: 2px;
|
||||
margin-top: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.shield-bar {
|
||||
height: 100%;
|
||||
background: #00d4ff;
|
||||
border-radius: 2px;
|
||||
transition: width 0.1s linear;
|
||||
box-shadow: 0 0 6px #00d4ff;
|
||||
}
|
||||
|
||||
.shield-ready { color: #00d4ff !important; }
|
||||
.shield-active { color: #00ffff !important; text-shadow: 0 0 8px #00ffff; }
|
||||
.shield-cooldown { color: var(--dim) !important; }
|
||||
|
||||
kbd {
|
||||
display: inline-block;
|
||||
padding: 0 3px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 2px;
|
||||
font-size: 0.6rem;
|
||||
font-family: inherit;
|
||||
color: var(--dim);
|
||||
}
|
||||
@@ -15,9 +15,10 @@
|
||||
|
||||
<h1 data-text="TETRIS">TETRIS<span class="cursor">_</span></h1>
|
||||
|
||||
<a id="btn-home" href="/">Home</a>
|
||||
<!-- Bouton home -->
|
||||
<a id="btn-home" href="/">Home</a>
|
||||
|
||||
<!-- Panneau duel -->
|
||||
<!-- Panneau de connexion duel -->
|
||||
<div id="duel-panel">
|
||||
<span class="settings-title">Duel</span>
|
||||
<div class="duel-row">
|
||||
@@ -39,7 +40,7 @@
|
||||
<div id="local-section">
|
||||
<div id="app">
|
||||
|
||||
<!-- Colonne gauche : Hold + Score + Boutons + Paramètres -->
|
||||
<!-- Colonne gauche : Hold + Score + Boutons + Settings -->
|
||||
<div id="left-column">
|
||||
<div class="panel">
|
||||
<div class="panel-title">Hold</div>
|
||||
@@ -50,12 +51,6 @@
|
||||
<div class="score-value" id="score-display">0</div>
|
||||
</div>
|
||||
|
||||
<div class="score-block">
|
||||
<div class="score-label">Shield <kbd>E</kbd></div>
|
||||
<div class="score-value shield-ready" id="shield-status-display">PRÊT</div>
|
||||
<div class="shield-bar-bg"><div class="shield-bar" id="shield-bar"></div></div>
|
||||
</div>
|
||||
|
||||
<div class="btn-group">
|
||||
<button id="btn-start">Start</button>
|
||||
<button id="btn-pause" disabled>Pause</button>
|
||||
@@ -63,18 +58,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Paramètres -->
|
||||
<!-- Panneau de configuration -->
|
||||
<div id="settings-panel">
|
||||
<div class="settings-title">Paramètres</div>
|
||||
<div class="settings-row">
|
||||
<label>Couleur</label>
|
||||
<div class="theme-btns">
|
||||
<button class="theme-btn active" data-theme="green" title="Vert"></button>
|
||||
<button class="theme-btn" data-theme="red" title="Rouge"></button>
|
||||
<button class="theme-btn" data-theme="yellow" title="Jaune"></button>
|
||||
<button class="theme-btn" data-theme="blue" title="Bleu"></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-row">
|
||||
<label for="input-ttd">Vitesse initiale (ms)</label>
|
||||
<input type="number" id="input-ttd" min="100" max="3000" step="50" value="1000">
|
||||
@@ -111,7 +97,6 @@
|
||||
<div><span>W</span> Rot. droite</div>
|
||||
<div><span>Espace</span> Drop</div>
|
||||
<div><span>C</span> Hold</div>
|
||||
<div><span>E</span> Shield</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -126,7 +111,6 @@
|
||||
<div class="score-label">Score</div>
|
||||
<div class="score-value" id="opponent-score">—</div>
|
||||
</div>
|
||||
<div id="opponent-shield-indicator" style="display:none;color:#00d4ff;font-size:0.75rem;text-align:center;letter-spacing:1px;margin-top:4px;">🛡 SHIELD ACTIF</div>
|
||||
</div>
|
||||
|
||||
<div id="opponent-wrapper">
|
||||
@@ -150,22 +134,34 @@
|
||||
|
||||
<div id="lb-scores" class="lb-content lb-content--active">
|
||||
<table class="lb-table">
|
||||
<thead><tr><th>#</th><th>Joueur</th><th>Meilleur score</th><th>Parties</th></tr></thead>
|
||||
<tbody id="lb-scores-body"><tr><td colspan="4">Chargement…</td></tr></tbody>
|
||||
<thead>
|
||||
<tr><th>#</th><th>Joueur</th><th>Meilleur score</th><th>Parties</th></tr>
|
||||
</thead>
|
||||
<tbody id="lb-scores-body">
|
||||
<tr><td colspan="4">Chargement…</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div id="lb-wins" class="lb-content">
|
||||
<table class="lb-table">
|
||||
<thead><tr><th>#</th><th>Joueur</th><th>Victoires</th><th>Parties</th></tr></thead>
|
||||
<tbody id="lb-wins-body"><tr><td colspan="4">Chargement…</td></tr></tbody>
|
||||
<thead>
|
||||
<tr><th>#</th><th>Joueur</th><th>Victoires</th><th>Parties</th></tr>
|
||||
</thead>
|
||||
<tbody id="lb-wins-body">
|
||||
<tr><td colspan="4">Chargement…</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div id="lb-history" class="lb-content">
|
||||
<table class="lb-table">
|
||||
<thead><tr><th>#</th><th>Date</th><th>Type</th><th>Score</th><th>Résultat</th></tr></thead>
|
||||
<tbody id="lb-history-body"><tr><td colspan="5">Chargement…</td></tr></tbody>
|
||||
<thead>
|
||||
<tr><th>#</th><th>Date</th><th>Type</th><th>Score</th><th>Résultat</th></tr>
|
||||
</thead>
|
||||
<tbody id="lb-history-body">
|
||||
<tr><td colspan="5">Chargement…</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
@@ -177,9 +173,59 @@
|
||||
<script src="tetris.js"></script>
|
||||
<script src="renderer.js"></script>
|
||||
<script src="duel.js"></script>
|
||||
<script src="leaderboard.js"></script>
|
||||
<script src="ui.js"></script>
|
||||
<script src="effects.js"></script>
|
||||
|
||||
<script>
|
||||
// ── Responsive scaling ──────────────────────────
|
||||
(function() {
|
||||
const container = document.getElementById('scale-container');
|
||||
// Dimensions naturelles du contenu (single-player)
|
||||
const NAT_W = 640;
|
||||
const NAT_H = 1020;
|
||||
|
||||
function resize() {
|
||||
const s = Math.min(
|
||||
window.innerWidth / NAT_W,
|
||||
window.innerHeight / NAT_H
|
||||
);
|
||||
container.style.transform = 'scale(' + s + ')';
|
||||
container.style.transformOrigin = 'top center';
|
||||
// Compense l'espace de layout non affecté par transform
|
||||
container.style.marginBottom = ((s - 1) * NAT_H) + 'px';
|
||||
}
|
||||
|
||||
resize();
|
||||
window.addEventListener('resize', resize);
|
||||
})();
|
||||
</script>
|
||||
|
||||
<script>
|
||||
// ── Matrix rain ──────────────────────────────────
|
||||
(function() {
|
||||
const canvas = document.getElementById('matrix-bg');
|
||||
const ctx = canvas.getContext('2d');
|
||||
function resize() { canvas.width = window.innerWidth; canvas.height = window.innerHeight; }
|
||||
resize();
|
||||
window.addEventListener('resize', resize);
|
||||
const chars = 'アイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワヲン0123456789ABCDEF>_{}[]|\\/#@$%^&*01';
|
||||
const fs = 14;
|
||||
let drops = [];
|
||||
function initDrops() { drops = Array(Math.floor(canvas.width / fs)).fill(1); }
|
||||
initDrops();
|
||||
window.addEventListener('resize', initDrops);
|
||||
setInterval(function() {
|
||||
ctx.fillStyle = 'rgba(0,5,0,0.05)';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.font = fs + 'px monospace';
|
||||
for (let i = 0; i < drops.length; i++) {
|
||||
const ch = chars[Math.floor(Math.random() * chars.length)];
|
||||
ctx.fillStyle = drops[i] * fs < 50 ? '#aaffaa' : '#00ff41';
|
||||
ctx.fillText(ch, i * fs, drops[i] * fs);
|
||||
if (drops[i] * fs > canvas.height && Math.random() > 0.975) drops[i] = 0;
|
||||
drops[i]++;
|
||||
}
|
||||
}, 40);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -3,12 +3,11 @@
|
||||
// ───────────────────────────────────────────
|
||||
|
||||
class Tetris {
|
||||
constructor(onRender, onGameOver, onBlockPlaced = null, onLinesCleared = null, onShieldChanged = null) {
|
||||
constructor(onRender, onGameOver, onBlockPlaced = null, onLinesCleared = null) {
|
||||
this.onRender = onRender;
|
||||
this.onGameOver = onGameOver;
|
||||
this.onBlockPlaced = onBlockPlaced;
|
||||
this.onLinesCleared = onLinesCleared;
|
||||
this.onShieldChanged = onShieldChanged;
|
||||
|
||||
this.grid = this._createGrid(10, 20);
|
||||
this.bufferGrid = this._createGrid(10, 5);
|
||||
@@ -29,12 +28,6 @@ class Tetris {
|
||||
this.isPaused = false;
|
||||
this.canStore = true;
|
||||
|
||||
// Shield
|
||||
this.shieldActive = false;
|
||||
this.shieldActiveMs = 0;
|
||||
this.shieldCooldownMs = 0;
|
||||
this.shieldReady = true; // prêt dès le début
|
||||
|
||||
this.animationFrameId = null;
|
||||
this.lastTime = 0;
|
||||
this.accumulator = 0;
|
||||
@@ -62,10 +55,6 @@ class Tetris {
|
||||
this.timeToDown = this.initialTimeToDown;
|
||||
this.storedPiece = null;
|
||||
this.canStore = true;
|
||||
this.shieldActive = false;
|
||||
this.shieldActiveMs = 0;
|
||||
this.shieldCooldownMs = 0;
|
||||
this.shieldReady = true;
|
||||
this._spawnNewPiece();
|
||||
document.addEventListener('keydown', this._keyHandler);
|
||||
this._startGameLoop();
|
||||
@@ -119,8 +108,6 @@ class Tetris {
|
||||
this.lastTime = currentTime;
|
||||
this.accumulator += deltaTime;
|
||||
|
||||
this._updateShield(deltaTime);
|
||||
|
||||
while (this.isRunning && this.accumulator >= this.timeToDown) {
|
||||
this._tick();
|
||||
this.accumulator -= this.timeToDown;
|
||||
@@ -187,42 +174,11 @@ class Tetris {
|
||||
e.preventDefault();
|
||||
if (!this.isPaused) this._storePiece();
|
||||
break;
|
||||
case 'e': case 'E':
|
||||
e.preventDefault();
|
||||
if (!this.isPaused) this._activateShield();
|
||||
break;
|
||||
}
|
||||
|
||||
this.onRender();
|
||||
}
|
||||
|
||||
_activateShield() {
|
||||
if (!this.shieldReady || this.shieldActive) return;
|
||||
this.shieldActive = true;
|
||||
this.shieldActiveMs = 3000;
|
||||
this.shieldReady = false;
|
||||
if (this.onShieldChanged) this.onShieldChanged('activated');
|
||||
}
|
||||
|
||||
_updateShield(deltaTime) {
|
||||
if (this.shieldActive) {
|
||||
this.shieldActiveMs -= deltaTime;
|
||||
if (this.shieldActiveMs <= 0) {
|
||||
this.shieldActive = false;
|
||||
this.shieldActiveMs = 0;
|
||||
this.shieldCooldownMs = 60000;
|
||||
if (this.onShieldChanged) this.onShieldChanged('deactivated');
|
||||
}
|
||||
} else if (!this.shieldReady) {
|
||||
this.shieldCooldownMs -= deltaTime;
|
||||
if (this.shieldCooldownMs <= 0) {
|
||||
this.shieldCooldownMs = 0;
|
||||
this.shieldReady = true;
|
||||
if (this.onShieldChanged) this.onShieldChanged('ready');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_hardDrop() {
|
||||
if (!this.currentPiece) return;
|
||||
let dist = 0;
|
||||
@@ -319,17 +275,8 @@ class Tetris {
|
||||
const points = [0, 100, 300, 500, 800];
|
||||
this.score += points[cleared];
|
||||
this.count += points[cleared];
|
||||
if (cleared > 0) {
|
||||
// Chaque ligne remplie réduit le cooldown du shield de 10s
|
||||
if (!this.shieldActive && !this.shieldReady) {
|
||||
this.shieldCooldownMs = Math.max(0, this.shieldCooldownMs - cleared * 10000);
|
||||
if (this.shieldCooldownMs === 0) {
|
||||
this.shieldReady = true;
|
||||
if (this.onShieldChanged) this.onShieldChanged('ready');
|
||||
}
|
||||
}
|
||||
if (this.onLinesCleared) this.onLinesCleared(cleared, this.lastLandingCol);
|
||||
}
|
||||
if (this.onLinesCleared && cleared > 0)
|
||||
this.onLinesCleared(cleared, this.lastLandingCol);
|
||||
}
|
||||
|
||||
_makeHarder() {
|
||||
@@ -414,7 +361,6 @@ class Tetris {
|
||||
}
|
||||
|
||||
addGarbageLines(lines) {
|
||||
if (this.shieldActive) return; // shield bloque les lignes garbage
|
||||
if (!this.isRunning || !lines.length) return;
|
||||
this.grid.splice(0, lines.length);
|
||||
for (const line of lines) this.grid.push([...line]); // ...line pour faire une copie independante
|
||||
@@ -1,49 +0,0 @@
|
||||
// ─────────────────────────────────────────────
|
||||
// EFFETS VISUELS : SCALING RESPONSIVE + MATRIX RAIN
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
// ── Responsive scaling ──
|
||||
(function() {
|
||||
const container = document.getElementById('scale-container');
|
||||
const NAT_W = 640;
|
||||
const NAT_H = 1020;
|
||||
|
||||
function resize() {
|
||||
const s = Math.min(window.innerWidth / NAT_W, window.innerHeight / NAT_H);
|
||||
container.style.transform = 'scale(' + s + ')';
|
||||
container.style.transformOrigin = 'top center';
|
||||
container.style.marginBottom = ((s - 1) * NAT_H) + 'px';
|
||||
}
|
||||
|
||||
resize();
|
||||
window.addEventListener('resize', resize);
|
||||
})();
|
||||
|
||||
// ── Matrix rain ──
|
||||
(function() {
|
||||
const canvas = document.getElementById('matrix-bg');
|
||||
const ctx = canvas.getContext('2d');
|
||||
const chars = 'アイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワヲン0123456789ABCDEF>_{}[]|\\/#@$%^&*01';
|
||||
const fs = 14;
|
||||
let drops = [];
|
||||
|
||||
function resize() { canvas.width = window.innerWidth; canvas.height = window.innerHeight; }
|
||||
function initDrops() { drops = Array(Math.floor(canvas.width / fs)).fill(1); }
|
||||
|
||||
resize();
|
||||
initDrops();
|
||||
window.addEventListener('resize', () => { resize(); initDrops(); });
|
||||
|
||||
setInterval(function() {
|
||||
ctx.fillStyle = 'rgba(0,5,0,0.05)';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.font = fs + 'px monospace';
|
||||
for (let i = 0; i < drops.length; i++) {
|
||||
const ch = chars[Math.floor(Math.random() * chars.length)];
|
||||
ctx.fillStyle = drops[i] * fs < 50 ? '#aaffaa' : '#00ff41';
|
||||
ctx.fillText(ch, i * fs, drops[i] * fs);
|
||||
if (drops[i] * fs > canvas.height && Math.random() > 0.975) drops[i] = 0;
|
||||
drops[i]++;
|
||||
}
|
||||
}, 40);
|
||||
})();
|
||||
@@ -1,124 +0,0 @@
|
||||
// ─────────────────────────────────────────────
|
||||
// LEADERBOARDS & HISTORIQUE
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
function escapeHtml(str) {
|
||||
return String(str).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
}
|
||||
|
||||
// ── Historique ───────────────────────────────
|
||||
|
||||
async function loadGameHistory() {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
if (!token) return;
|
||||
try {
|
||||
const res = await fetch('/api/stats/tetris/history', {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
if (!res.ok) return;
|
||||
renderGameHistory(await res.json());
|
||||
} catch (err) {
|
||||
console.error('Erreur chargement historique:', err);
|
||||
}
|
||||
}
|
||||
|
||||
function renderGameHistory(history) {
|
||||
const tbody = document.getElementById('lb-history-body');
|
||||
if (!tbody) return;
|
||||
if (!history.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="5">Aucune partie jouée</td></tr>';
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = history.map((entry, i) => {
|
||||
const date = new Date(entry.played_at).toLocaleDateString('fr-FR', {
|
||||
day: '2-digit', month: '2-digit', year: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit'
|
||||
});
|
||||
const type = entry.game_type === 'duel' ? 'Duel' : 'Solo';
|
||||
let resultHtml = '—';
|
||||
if (entry.result === 'win') resultHtml = '<span class="hist-win">Victoire</span>';
|
||||
if (entry.result === 'loss') resultHtml = '<span class="hist-loss">Défaite</span>';
|
||||
return `<tr>
|
||||
<td>${i + 1}</td>
|
||||
<td>${date}</td>
|
||||
<td>${type}</td>
|
||||
<td>${entry.score}</td>
|
||||
<td>${resultHtml}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// ── Classements ──────────────────────────────
|
||||
|
||||
async function loadLeaderboards() {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
if (!token) return;
|
||||
const headers = { 'Authorization': `Bearer ${token}` };
|
||||
try {
|
||||
const [scoresRes, winsRes, meRes, rankScoreRes, rankWinsRes] = await Promise.all([
|
||||
fetch('/api/stats/tetris/leaderboard/score', { headers }),
|
||||
fetch('/api/stats/tetris/leaderboard/wins', { headers }),
|
||||
fetch('/api/stats/me', { headers }),
|
||||
fetch('/api/stats/tetris/rank/score', { headers }),
|
||||
fetch('/api/stats/tetris/rank/wins', { headers })
|
||||
]);
|
||||
|
||||
const me = meRes.ok ? await meRes.json() : null;
|
||||
const rankScore = rankScoreRes.ok ? (await rankScoreRes.json()).rank : null;
|
||||
const rankWins = rankWinsRes.ok ? (await rankWinsRes.json()).rank : null;
|
||||
|
||||
if (scoresRes.ok) renderLeaderboard('lb-scores-body', await scoresRes.json(), ['tetris_best_score', 'tetris_games_played'], me, rankScore);
|
||||
if (winsRes.ok) renderLeaderboard('lb-wins-body', await winsRes.json(), ['tetris_wins', 'tetris_games_played'], me, rankWins);
|
||||
} catch (err) {
|
||||
console.error('Erreur chargement leaderboards:', err);
|
||||
}
|
||||
}
|
||||
|
||||
function renderLeaderboard(tbodyId, rows, [col1, col2], me, myRank) {
|
||||
const tbody = document.getElementById(tbodyId);
|
||||
if (!tbody) return;
|
||||
if (!rows.length && !me) {
|
||||
tbody.innerHTML = '<tr><td colspan="4">Aucun résultat</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
const myUsername = me?.username;
|
||||
const inTop = rows.some(r => r.username === myUsername);
|
||||
|
||||
let html = rows.map((r, i) => {
|
||||
const isMe = r.username === myUsername;
|
||||
return `<tr class="${isMe ? 'lb-me' : ''}">
|
||||
<td>${i + 1}</td>
|
||||
<td>${escapeHtml(r.username)}${isMe ? ' <span class="lb-you">(vous)</span>' : ''}</td>
|
||||
<td>${r[col1] ?? 0}</td>
|
||||
<td>${r[col2] ?? 0}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
|
||||
if (!inTop && me && myRank !== null) {
|
||||
html += `<tr class="lb-separator"><td colspan="4">· · ·</td></tr>`;
|
||||
html += `<tr class="lb-me">
|
||||
<td>${myRank}</td>
|
||||
<td>${escapeHtml(myUsername)} <span class="lb-you">(vous)</span></td>
|
||||
<td>${me[col1] ?? 0}</td>
|
||||
<td>${me[col2] ?? 0}</td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
tbody.innerHTML = html || '<tr><td colspan="4">Aucun résultat</td></tr>';
|
||||
}
|
||||
|
||||
// ── Tabs ─────────────────────────────────────
|
||||
|
||||
document.querySelectorAll('.lb-tab').forEach(tab => {
|
||||
tab.addEventListener('click', () => {
|
||||
document.querySelectorAll('.lb-tab').forEach(t => t.classList.remove('lb-tab--active'));
|
||||
document.querySelectorAll('.lb-content').forEach(c => c.classList.remove('lb-content--active'));
|
||||
tab.classList.add('lb-tab--active');
|
||||
document.getElementById(`lb-${tab.dataset.tab}`).classList.add('lb-content--active');
|
||||
if (tab.dataset.tab === 'history') loadGameHistory();
|
||||
});
|
||||
});
|
||||
|
||||
loadLeaderboards();
|
||||
loadGameHistory();
|
||||
@@ -1,228 +0,0 @@
|
||||
// ─────────────────────────────────────────────
|
||||
// RENDU
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
const CELL = 30;
|
||||
|
||||
const THEMES = {
|
||||
green: {
|
||||
bg: '#000500', panel: '#000d00', border: '#004400',
|
||||
accent: '#00ff41', accent2: '#39ff14', dim: '#1a5c1a', text: '#00cc26',
|
||||
grid: 'rgba(0,255,65,0.06)', ghost: 'rgba(0,255,65,0.25)', highlight: 'rgba(200,255,200,0.2)',
|
||||
colors: ['#000500','#00ff41','#39ff14','#00e676','#76ff03','#b2ff59','#00ffaa','#ccff00','#2d5a2d']
|
||||
},
|
||||
red: {
|
||||
bg: '#050000', panel: '#0d0000', border: '#440000',
|
||||
accent: '#ff1744', accent2: '#ff4569', dim: '#5c1a1a', text: '#cc2626',
|
||||
grid: 'rgba(255,23,68,0.06)', ghost: 'rgba(255,23,68,0.25)', highlight: 'rgba(255,200,200,0.2)',
|
||||
colors: ['#050000','#ff1744','#ff4569','#e53935','#ff6d00','#ff8a65','#ff5252','#ff6e40','#5a2d2d']
|
||||
},
|
||||
yellow: {
|
||||
bg: '#050500', panel: '#0d0d00', border: '#444400',
|
||||
accent: '#ffd600', accent2: '#ffea00', dim: '#5c5c1a', text: '#ccaa00',
|
||||
grid: 'rgba(255,214,0,0.06)', ghost: 'rgba(255,214,0,0.25)', highlight: 'rgba(255,255,200,0.2)',
|
||||
colors: ['#050500','#ffd600','#ffea00','#ffab00','#fff176','#ffe57f','#ffff00','#ffc400','#5a5a2d']
|
||||
},
|
||||
blue: {
|
||||
bg: '#000005', panel: '#00000d', border: '#000044',
|
||||
accent: '#00b0ff', accent2: '#40c4ff', dim: '#1a1a5c', text: '#2626cc',
|
||||
grid: 'rgba(0,176,255,0.06)', ghost: 'rgba(0,176,255,0.25)', highlight: 'rgba(200,200,255,0.2)',
|
||||
colors: ['#000005','#00b0ff','#40c4ff','#0091ea','#448aff','#82b1ff','#00e5ff','#2979ff','#2d2d5a']
|
||||
}
|
||||
};
|
||||
|
||||
let currentTheme = THEMES.green;
|
||||
let COLORS = [...currentTheme.colors];
|
||||
|
||||
function setColorTheme(themeName) {
|
||||
currentTheme = THEMES[themeName] || THEMES.green;
|
||||
COLORS = [...currentTheme.colors];
|
||||
const root = document.documentElement;
|
||||
root.style.setProperty('--bg', currentTheme.bg);
|
||||
root.style.setProperty('--panel', currentTheme.panel);
|
||||
root.style.setProperty('--border', currentTheme.border);
|
||||
root.style.setProperty('--accent', currentTheme.accent);
|
||||
root.style.setProperty('--accent2', currentTheme.accent2);
|
||||
root.style.setProperty('--dim', currentTheme.dim);
|
||||
root.style.setProperty('--text', currentTheme.text);
|
||||
localStorage.setItem('tetris-theme', themeName);
|
||||
document.querySelectorAll('.theme-btn').forEach(btn => {
|
||||
btn.classList.toggle('active', btn.dataset.theme === themeName);
|
||||
});
|
||||
}
|
||||
|
||||
const ctxMain = document.getElementById('canvas-main').getContext('2d');
|
||||
const ctxNext = document.getElementById('canvas-next').getContext('2d');
|
||||
const ctxHold = document.getElementById('canvas-hold').getContext('2d');
|
||||
const ctxOpponent = document.getElementById('canvas-opponent').getContext('2d');
|
||||
|
||||
function drawCell(ctx, x, y, colorIndex, size) {
|
||||
const p = 1;
|
||||
const color = COLORS[colorIndex];
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillRect(x * size + p, y * size + p, size - p * 2, size - p * 2);
|
||||
ctx.shadowColor = color;
|
||||
ctx.shadowBlur = 6;
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillRect(x * size + p + 2, y * size + p + 2, size - p * 2 - 4, size - p * 2 - 4);
|
||||
ctx.shadowBlur = 0;
|
||||
ctx.fillStyle = currentTheme.highlight;
|
||||
ctx.fillRect(x * size + p, y * size + p, size - p * 2, 2);
|
||||
ctx.fillRect(x * size + p, y * size + p, 2, size - p * 2);
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.5)';
|
||||
ctx.fillRect(x * size + p, (y + 1) * size - p - 2, size - p * 2, 2);
|
||||
ctx.fillRect((x + 1) * size - p - 2, y * size + p, 2, size - p * 2);
|
||||
}
|
||||
|
||||
function clearCanvas(ctx, w, h) {
|
||||
ctx.fillStyle = currentTheme.bg;
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
}
|
||||
|
||||
function drawGridLines(ctx, cols, rows, size) {
|
||||
ctx.strokeStyle = currentTheme.grid;
|
||||
ctx.lineWidth = 1;
|
||||
for (let x = 0; x <= cols; x++) {
|
||||
ctx.beginPath(); ctx.moveTo(x * size, 0); ctx.lineTo(x * size, rows * size); ctx.stroke();
|
||||
}
|
||||
for (let y = 0; y <= rows; y++) {
|
||||
ctx.beginPath(); ctx.moveTo(0, y * size); ctx.lineTo(cols * size, y * size); ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
function drawGhost(ctx, piece, grid) {
|
||||
if (!piece) return;
|
||||
const ghost = { x: piece.getPosition().x, y: piece.getPosition().y };
|
||||
const shape = piece.getShape();
|
||||
|
||||
while (true) {
|
||||
ghost.y++;
|
||||
let valid = true;
|
||||
for (let row = 0; row < shape.length && valid; row++)
|
||||
for (let col = 0; col < shape[row].length && valid; col++)
|
||||
if (shape[row][col] !== 0) {
|
||||
const ny = ghost.y + row;
|
||||
const nx = ghost.x + col;
|
||||
if (ny < 0 || ny >= grid.length || nx < 0 || nx >= grid[ny].length || grid[ny][nx] !== 0) valid = false;
|
||||
}
|
||||
if (!valid) { ghost.y--; break; }
|
||||
}
|
||||
|
||||
if (ghost.y === piece.getPosition().y) return;
|
||||
|
||||
ctx.strokeStyle = currentTheme.ghost;
|
||||
ctx.lineWidth = 1;
|
||||
for (let row = 0; row < shape.length; row++)
|
||||
for (let col = 0; col < shape[row].length; col++)
|
||||
if (shape[row][col] !== 0)
|
||||
ctx.strokeRect(
|
||||
(ghost.x + col) * CELL + 2,
|
||||
(ghost.y + row) * CELL + 2,
|
||||
CELL - 4, CELL - 4
|
||||
);
|
||||
}
|
||||
|
||||
function drawMiniPiece(ctx, piece, canvasW, canvasH) {
|
||||
clearCanvas(ctx, canvasW, canvasH);
|
||||
if (!piece) return;
|
||||
const shape = piece.getShape();
|
||||
const color = piece.getColor();
|
||||
const s = 20;
|
||||
const offsetX = Math.floor((canvasW / s - shape[0].length) / 2);
|
||||
const offsetY = Math.floor((canvasH / s - shape.length) / 2);
|
||||
for (let row = 0; row < shape.length; row++)
|
||||
for (let col = 0; col < shape[row].length; col++)
|
||||
if (shape[row][col] !== 0)
|
||||
drawCell(ctx, offsetX + col, offsetY + row, color, s);
|
||||
}
|
||||
|
||||
function _drawShieldOverlay(ctx, w, h, alpha) {
|
||||
ctx.save();
|
||||
ctx.strokeStyle = `rgba(0,212,255,${alpha})`;
|
||||
ctx.lineWidth = 4;
|
||||
ctx.shadowColor = '#00d4ff';
|
||||
ctx.shadowBlur = 16;
|
||||
ctx.strokeRect(2, 2, w - 4, h - 4);
|
||||
ctx.shadowBlur = 0;
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// ── Rendu joueur local ────────────────────────────────────────────────────────
|
||||
// Prend l'objet game explicitement — aucun accès à des globaux externes.
|
||||
|
||||
function render(game) {
|
||||
clearCanvas(ctxMain, 300, 600);
|
||||
drawGridLines(ctxMain, 10, 20, CELL);
|
||||
|
||||
for (let y = 0; y < game.grid.length; y++)
|
||||
for (let x = 0; x < game.grid[y].length; x++)
|
||||
if (game.grid[y][x] !== 0)
|
||||
drawCell(ctxMain, x, y, game.grid[y][x], CELL);
|
||||
|
||||
if (game.currentPiece) {
|
||||
drawGhost(ctxMain, game.currentPiece, game.grid);
|
||||
const { x, y } = game.currentPiece.getPosition();
|
||||
const shape = game.currentPiece.getShape();
|
||||
const color = game.currentPiece.getColor();
|
||||
for (let row = 0; row < shape.length; row++)
|
||||
for (let col = 0; col < shape[row].length; col++)
|
||||
if (shape[row][col] !== 0)
|
||||
drawCell(ctxMain, x + col, y + row, color, CELL);
|
||||
}
|
||||
|
||||
if (game.shieldActive) {
|
||||
const pulse = 0.6 + 0.4 * Math.sin(Date.now() / 150);
|
||||
_drawShieldOverlay(ctxMain, 300, 600, pulse);
|
||||
}
|
||||
|
||||
drawMiniPiece(ctxNext, game.nextPiece, 100, 80);
|
||||
drawMiniPiece(ctxHold, game.storedPiece, 100, 80);
|
||||
|
||||
document.getElementById('score-display').textContent = game.score;
|
||||
|
||||
const shieldEl = document.getElementById('shield-status-display');
|
||||
const shieldBar = document.getElementById('shield-bar');
|
||||
if (shieldEl) {
|
||||
if (game.shieldActive) {
|
||||
const secs = Math.ceil(game.shieldActiveMs / 1000);
|
||||
shieldEl.textContent = `ACTIF ${secs}s`;
|
||||
shieldEl.className = 'score-value shield-active';
|
||||
if (shieldBar) shieldBar.style.width = (game.shieldActiveMs / 3000 * 100) + '%';
|
||||
} else if (game.shieldReady) {
|
||||
shieldEl.textContent = 'PRÊT';
|
||||
shieldEl.className = 'score-value shield-ready';
|
||||
if (shieldBar) shieldBar.style.width = '100%';
|
||||
} else {
|
||||
const secs = Math.ceil(game.shieldCooldownMs / 1000);
|
||||
shieldEl.textContent = `${secs}s`;
|
||||
shieldEl.className = 'score-value shield-cooldown';
|
||||
if (shieldBar) shieldBar.style.width = ((1 - game.shieldCooldownMs / 60000) * 100) + '%';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Rendu adversaire ─────────────────────────────────────────────────────────
|
||||
// Prend grid et shieldActive explicitement — aucun accès à l'objet duel global.
|
||||
|
||||
function renderOpponent(grid, shieldActive) {
|
||||
clearCanvas(ctxOpponent, 300, 600);
|
||||
drawGridLines(ctxOpponent, 10, 20, CELL);
|
||||
for (let y = 0; y < grid.length; y++)
|
||||
for (let x = 0; x < grid[y].length; x++)
|
||||
if (grid[y][x] !== 0)
|
||||
drawCell(ctxOpponent, x, y, grid[y][x], CELL);
|
||||
|
||||
if (shieldActive) {
|
||||
const pulse = 0.6 + 0.4 * Math.sin(Date.now() / 150);
|
||||
_drawShieldOverlay(ctxOpponent, 300, 600, pulse);
|
||||
}
|
||||
|
||||
const oppShieldEl = document.getElementById('opponent-shield-indicator');
|
||||
if (oppShieldEl) oppShieldEl.style.display = shieldActive ? 'block' : 'none';
|
||||
}
|
||||
|
||||
// Restaure le thème sauvegardé
|
||||
(function() {
|
||||
const saved = localStorage.getItem('tetris-theme');
|
||||
if (saved && THEMES[saved]) setColorTheme(saved);
|
||||
})();
|
||||
@@ -1,265 +0,0 @@
|
||||
// ─────────────────────────────────────────────
|
||||
// UI — Contrôles, socket, duel, matchmaking
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
// ── Références DOM ───────────────────────────
|
||||
|
||||
const btnStart = document.getElementById('btn-start');
|
||||
const btnPause = document.getElementById('btn-pause');
|
||||
const btnStop = document.getElementById('btn-stop');
|
||||
const btnRestart = document.getElementById('btn-restart');
|
||||
const overlay = document.getElementById('overlay');
|
||||
const inputTTD = document.getElementById('input-ttd');
|
||||
const inputHardening = document.getElementById('input-hardening');
|
||||
const inputDecrement = document.getElementById('input-decrement');
|
||||
|
||||
const btnJoinDuel = document.getElementById('btn-join-duel');
|
||||
const btnLeaveDuel = document.getElementById('btn-leave-duel');
|
||||
const inputRoomCode = document.getElementById('input-room-code');
|
||||
const duelStatusEl = document.getElementById('duel-status');
|
||||
const opponentSection = document.getElementById('opponent-section');
|
||||
|
||||
const btnMatchmaking = document.getElementById('btn-matchmaking');
|
||||
const btnMatchmakingCancel = document.getElementById('btn-matchmaking-cancel');
|
||||
const matchmakingStatusEl = document.getElementById('matchmaking-status');
|
||||
|
||||
// ── Overlay ──────────────────────────────────
|
||||
|
||||
function showOverlay(title, score) {
|
||||
document.getElementById('overlay-title').textContent = title;
|
||||
document.getElementById('overlay-score').textContent = score !== undefined ? `Score : ${score}` : '';
|
||||
overlay.classList.add('visible');
|
||||
}
|
||||
|
||||
function hideOverlay() {
|
||||
overlay.classList.remove('visible');
|
||||
}
|
||||
|
||||
// ── Boutons ──────────────────────────────────
|
||||
|
||||
function updateButtons() {
|
||||
btnStart.disabled = game.isRunning;
|
||||
btnPause.disabled = !game.isRunning;
|
||||
btnStop.disabled = !game.isRunning;
|
||||
btnPause.textContent = game.isPaused ? 'Resume' : 'Pause';
|
||||
inputTTD.disabled = game.isRunning;
|
||||
inputHardening.disabled = game.isRunning;
|
||||
inputDecrement.disabled = game.isRunning;
|
||||
}
|
||||
|
||||
// ── Socket ───────────────────────────────────
|
||||
|
||||
const socket = io({
|
||||
auth: { token: localStorage.getItem('auth_token') },
|
||||
reconnection: true,
|
||||
reconnectionAttempts: 5,
|
||||
reconnectionDelay: 1000,
|
||||
transports: ['websocket', 'polling']
|
||||
});
|
||||
|
||||
// ── Duel ─────────────────────────────────────
|
||||
|
||||
let duel = null;
|
||||
|
||||
// Callbacks passés au Duel pour qu'il pilote l'UI sans accéder à des globaux.
|
||||
function _makeDuelUI() {
|
||||
return {
|
||||
showOverlay,
|
||||
hideOverlay,
|
||||
updateButtons,
|
||||
render: () => render(game),
|
||||
renderOpponent: (grid, shieldActive) => renderOpponent(grid, shieldActive),
|
||||
};
|
||||
}
|
||||
|
||||
function updateDuelStatus(status, opponentName) {
|
||||
duelStatusEl.className = '';
|
||||
if (status === 'waiting') {
|
||||
duelStatusEl.textContent = "En attente d'un adversaire…";
|
||||
duelStatusEl.classList.add('waiting');
|
||||
opponentSection.classList.remove('visible');
|
||||
} else if (status === 'ready') {
|
||||
duelStatusEl.textContent = `Prêt — ${opponentName}`;
|
||||
duelStatusEl.classList.add('ready');
|
||||
opponentSection.classList.add('visible');
|
||||
if (duel) duel.hideOpponentOverlay();
|
||||
const grid = duel ? duel.opponentGrid : Array.from({ length: 20 }, () => Array(10).fill(0));
|
||||
const shieldActive = duel ? duel.opponentShieldActive : false;
|
||||
renderOpponent(grid, shieldActive);
|
||||
} else {
|
||||
duelStatusEl.textContent = '—';
|
||||
opponentSection.classList.remove('visible');
|
||||
}
|
||||
}
|
||||
|
||||
function startLocalGame() {
|
||||
hideOverlay();
|
||||
game.start();
|
||||
updateButtons();
|
||||
render(game);
|
||||
}
|
||||
|
||||
// Crée un Duel et rejoint la salle — mutualisé entre le bouton et le matchmaking.
|
||||
function _joinDuelRoom(code) {
|
||||
if (duel) duel.leave();
|
||||
if (game.isRunning) { game.stop(); hideOverlay(); render(game); updateButtons(); }
|
||||
duel = new Duel(socket, game, updateDuelStatus, startLocalGame, _makeDuelUI());
|
||||
duel.join(code);
|
||||
btnJoinDuel.disabled = true;
|
||||
btnLeaveDuel.disabled = false;
|
||||
inputRoomCode.disabled = true;
|
||||
updateDuelStatus('waiting', null);
|
||||
}
|
||||
|
||||
btnJoinDuel.addEventListener('click', () => {
|
||||
const code = inputRoomCode.value.trim().toUpperCase();
|
||||
if (!code) return;
|
||||
_joinDuelRoom(code);
|
||||
});
|
||||
|
||||
btnLeaveDuel.addEventListener('click', () => {
|
||||
if (duel) { duel.leave(); duel = null; }
|
||||
btnJoinDuel.disabled = false;
|
||||
btnLeaveDuel.disabled = true;
|
||||
inputRoomCode.disabled = false;
|
||||
updateDuelStatus(null, null);
|
||||
});
|
||||
|
||||
// ── Matchmaking ──────────────────────────────
|
||||
|
||||
btnMatchmaking.addEventListener('click', () => {
|
||||
socket.emit('tetris:matchmaking-join');
|
||||
btnMatchmaking.disabled = true;
|
||||
btnMatchmakingCancel.disabled = false;
|
||||
btnJoinDuel.disabled = true;
|
||||
matchmakingStatusEl.textContent = 'Recherche en cours…';
|
||||
matchmakingStatusEl.className = 'waiting';
|
||||
});
|
||||
|
||||
btnMatchmakingCancel.addEventListener('click', () => {
|
||||
socket.emit('tetris:matchmaking-leave');
|
||||
btnMatchmaking.disabled = false;
|
||||
btnMatchmakingCancel.disabled = true;
|
||||
btnJoinDuel.disabled = false;
|
||||
matchmakingStatusEl.textContent = '';
|
||||
});
|
||||
|
||||
socket.on('tetris:matchmaking-status', (data) => {
|
||||
if (data.status === 'searching') {
|
||||
matchmakingStatusEl.textContent = `Recherche… (${data.position} joueur(s) en attente)`;
|
||||
} else if (data.status === 'idle') {
|
||||
matchmakingStatusEl.textContent = '';
|
||||
btnMatchmaking.disabled = false;
|
||||
btnMatchmakingCancel.disabled = true;
|
||||
btnJoinDuel.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('tetris:matched', (data) => {
|
||||
matchmakingStatusEl.textContent = `Adversaire trouvé : ${data.opponent} !`;
|
||||
matchmakingStatusEl.className = 'ready';
|
||||
btnMatchmaking.disabled = false;
|
||||
btnMatchmakingCancel.disabled = true;
|
||||
inputRoomCode.value = data.roomCode;
|
||||
_joinDuelRoom(data.roomCode);
|
||||
});
|
||||
|
||||
// ── Jeu ──────────────────────────────────────
|
||||
|
||||
function saveTetrisScore(score) {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
if (!token) return;
|
||||
fetch('/api/stats/tetris/score', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
|
||||
body: JSON.stringify({ score })
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => { if (data.bestScore !== undefined) console.log('Meilleur score tetris:', data.bestScore); })
|
||||
.catch(err => console.error('Erreur sauvegarde score tetris:', err));
|
||||
}
|
||||
|
||||
const game = new Tetris(
|
||||
// onRender
|
||||
() => {
|
||||
if (duel) duel.synchronize_game();
|
||||
render(game);
|
||||
updateButtons();
|
||||
},
|
||||
// onGameOver
|
||||
(score, validBlock) => {
|
||||
if (duel && duel.isReady) duel.onLocalGameOver(score, validBlock);
|
||||
else saveTetrisScore(score);
|
||||
render(game);
|
||||
updateButtons();
|
||||
showOverlay('GAME OVER', score);
|
||||
loadLeaderboards();
|
||||
loadGameHistory();
|
||||
},
|
||||
// onBlockPlaced
|
||||
(grid) => { if (duel) duel.onLocalBlockPlaced(grid, game.score); },
|
||||
// onLinesCleared
|
||||
(count, holeCol) => { if (duel) duel.onLocalLinesCleared(count, holeCol); },
|
||||
// onShieldChanged
|
||||
(event) => { if (duel) duel.onLocalShieldChanged(event); }
|
||||
);
|
||||
|
||||
// ── Boutons de contrôle ──────────────────────
|
||||
|
||||
btnStart.addEventListener('click', () => {
|
||||
if (duel && duel.isReady) duel.startDuel();
|
||||
else startLocalGame();
|
||||
});
|
||||
|
||||
btnPause.addEventListener('click', () => {
|
||||
if (duel && duel.isReady) {
|
||||
duel.togglePause();
|
||||
} else {
|
||||
game.pause();
|
||||
updateButtons();
|
||||
if (game.isPaused) showOverlay('PAUSE');
|
||||
else hideOverlay();
|
||||
}
|
||||
});
|
||||
|
||||
btnStop.addEventListener('click', () => {
|
||||
if (duel && duel.isReady) {
|
||||
duel.stop();
|
||||
} else {
|
||||
game.stop();
|
||||
updateButtons();
|
||||
render(game);
|
||||
showOverlay('STOPPED');
|
||||
}
|
||||
});
|
||||
|
||||
if (btnRestart) {
|
||||
btnRestart.addEventListener('click', () => {
|
||||
if (duel && duel.isReady) return;
|
||||
game.restart();
|
||||
updateButtons();
|
||||
render(game);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Paramètres ───────────────────────────────
|
||||
|
||||
function applySettings() {
|
||||
const settings = {
|
||||
timeToDown: parseInt(inputTTD.value, 10),
|
||||
hardening: parseInt(inputHardening.value, 10),
|
||||
decrementTTD: parseInt(inputDecrement.value, 10),
|
||||
};
|
||||
game.configure(settings);
|
||||
if (duel && duel.isReady) duel.syncSettings(settings);
|
||||
}
|
||||
|
||||
inputTTD.addEventListener('change', applySettings);
|
||||
inputHardening.addEventListener('change', applySettings);
|
||||
inputDecrement.addEventListener('change', applySettings);
|
||||
|
||||
// ── Thème ────────────────────────────────────
|
||||
|
||||
document.querySelectorAll('.theme-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => setColorTheme(btn.dataset.theme));
|
||||
});
|
||||
@@ -0,0 +1,406 @@
|
||||
// ─────────────────────────────────────────────
|
||||
// UI
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
const btnStart = document.getElementById('btn-start');
|
||||
const btnPause = document.getElementById('btn-pause');
|
||||
const btnStop = document.getElementById('btn-stop');
|
||||
const overlay = document.getElementById('overlay');
|
||||
const inputTTD = document.getElementById('input-ttd');
|
||||
const inputHardening = document.getElementById('input-hardening');
|
||||
const inputDecrement = document.getElementById('input-decrement');
|
||||
|
||||
// Duel UI
|
||||
const btnJoinDuel = document.getElementById('btn-join-duel');
|
||||
const btnLeaveDuel = document.getElementById('btn-leave-duel');
|
||||
const inputRoomCode = document.getElementById('input-room-code');
|
||||
const duelStatusEl = document.getElementById('duel-status');
|
||||
const opponentSection = document.getElementById('opponent-section');
|
||||
|
||||
// Matchmaking UI
|
||||
const btnMatchmaking = document.getElementById('btn-matchmaking');
|
||||
const btnMatchmakingCancel = document.getElementById('btn-matchmaking-cancel');
|
||||
const matchmakingStatusEl = document.getElementById('matchmaking-status');
|
||||
|
||||
function updateButtons() {
|
||||
btnStart.disabled = game.isRunning;
|
||||
btnPause.disabled = !game.isRunning;
|
||||
btnStop.disabled = !game.isRunning;
|
||||
btnPause.textContent = game.isPaused ? 'Resume' : 'Pause';
|
||||
inputTTD.disabled = game.isRunning;
|
||||
inputHardening.disabled = game.isRunning;
|
||||
inputDecrement.disabled = game.isRunning;
|
||||
}
|
||||
|
||||
function showOverlay(title, score) {
|
||||
document.getElementById('overlay-title').textContent = title;
|
||||
document.getElementById('overlay-score').textContent = score !== undefined ? `Score : ${score}` : '';
|
||||
overlay.classList.add('visible');
|
||||
}
|
||||
|
||||
function hideOverlay() {
|
||||
overlay.classList.remove('visible');
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// SOCKET + DUEL
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
const socket = io({
|
||||
auth: { token: localStorage.getItem('auth_token') },
|
||||
reconnection: true,
|
||||
reconnectionAttempts: 5,
|
||||
reconnectionDelay: 1000,
|
||||
transports: ['websocket', 'polling']
|
||||
});
|
||||
|
||||
let duel = null;
|
||||
|
||||
function updateDuelStatus(status, opponentName) {
|
||||
duelStatusEl.className = '';
|
||||
if (status === 'waiting') {
|
||||
duelStatusEl.textContent = 'En attente d\'un adversaire…';
|
||||
duelStatusEl.classList.add('waiting');
|
||||
opponentSection.classList.remove('visible');
|
||||
} else if (status === 'ready') {
|
||||
duelStatusEl.textContent = `Prêt — ${opponentName}`;
|
||||
duelStatusEl.classList.add('ready');
|
||||
opponentSection.classList.add('visible');
|
||||
if (duel) duel.hideOpponentOverlay();
|
||||
renderOpponent(duel ? duel.opponentGrid : Array.from({length:20}, () => Array(10).fill(0)));
|
||||
} else {
|
||||
duelStatusEl.textContent = '—';
|
||||
opponentSection.classList.remove('visible');
|
||||
}
|
||||
}
|
||||
|
||||
function startLocalGame() {
|
||||
hideOverlay();
|
||||
game.start();
|
||||
updateButtons();
|
||||
render();
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// SCORE SAVE (solo)
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
function saveTetrisScore(score) {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
if (!token) return;
|
||||
fetch('/api/stats/tetris/score', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({ score })
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.bestScore !== undefined) {
|
||||
console.log('Meilleur score tetris:', data.bestScore);
|
||||
}
|
||||
})
|
||||
.catch(err => console.error('Erreur sauvegarde score tetris:', err));
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// DUEL BUTTONS
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
btnJoinDuel.addEventListener('click', () => {
|
||||
const code = inputRoomCode.value.trim().toUpperCase();
|
||||
if (!code) return;
|
||||
if (duel) { duel.leave(); }
|
||||
duel = new Duel(socket, game, updateDuelStatus, startLocalGame);
|
||||
duel.join(code);
|
||||
btnJoinDuel.disabled = true;
|
||||
btnLeaveDuel.disabled = false;
|
||||
inputRoomCode.disabled = true;
|
||||
updateDuelStatus('waiting', null);
|
||||
});
|
||||
|
||||
btnLeaveDuel.addEventListener('click', () => {
|
||||
if (duel) { duel.leave(); duel = null; }
|
||||
btnJoinDuel.disabled = false;
|
||||
btnLeaveDuel.disabled = true;
|
||||
inputRoomCode.disabled = false;
|
||||
updateDuelStatus(null, null);
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// MATCHMAKING
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
btnMatchmaking.addEventListener('click', () => {
|
||||
socket.emit('tetris:matchmaking-join');
|
||||
btnMatchmaking.disabled = true;
|
||||
btnMatchmakingCancel.disabled = false;
|
||||
btnJoinDuel.disabled = true;
|
||||
matchmakingStatusEl.textContent = 'Recherche en cours…';
|
||||
matchmakingStatusEl.className = 'waiting';
|
||||
});
|
||||
|
||||
btnMatchmakingCancel.addEventListener('click', () => {
|
||||
socket.emit('tetris:matchmaking-leave');
|
||||
btnMatchmaking.disabled = false;
|
||||
btnMatchmakingCancel.disabled = true;
|
||||
btnJoinDuel.disabled = false;
|
||||
matchmakingStatusEl.textContent = '';
|
||||
});
|
||||
|
||||
socket.on('tetris:matchmaking-status', (data) => {
|
||||
if (data.status === 'searching') {
|
||||
matchmakingStatusEl.textContent = `Recherche… (${data.position} joueur(s) en attente)`;
|
||||
} else if (data.status === 'idle') {
|
||||
matchmakingStatusEl.textContent = '';
|
||||
btnMatchmaking.disabled = false;
|
||||
btnMatchmakingCancel.disabled = true;
|
||||
btnJoinDuel.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('tetris:matched', (data) => {
|
||||
matchmakingStatusEl.textContent = `Adversaire trouvé : ${data.opponent} !`;
|
||||
matchmakingStatusEl.className = 'ready';
|
||||
btnMatchmaking.disabled = false;
|
||||
btnMatchmakingCancel.disabled = true;
|
||||
btnJoinDuel.disabled = false;
|
||||
|
||||
// Auto-rejoindre la salle générée
|
||||
if (duel) { duel.leave(); }
|
||||
duel = new Duel(socket, game, updateDuelStatus, startLocalGame);
|
||||
duel.join(data.roomCode);
|
||||
inputRoomCode.value = data.roomCode;
|
||||
btnJoinDuel.disabled = true;
|
||||
btnLeaveDuel.disabled = false;
|
||||
inputRoomCode.disabled = true;
|
||||
updateDuelStatus('waiting', null);
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// INIT
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
const game = new Tetris(
|
||||
// onRender
|
||||
() => {
|
||||
if (duel) duel.synchronize_game();
|
||||
render();
|
||||
updateButtons();
|
||||
},
|
||||
// onGameOver
|
||||
(score, validBlock) => {
|
||||
const isDuel = duel && duel.isReady;
|
||||
if (isDuel) {
|
||||
duel.onLocalGameOver(score, validBlock);
|
||||
} else {
|
||||
saveTetrisScore(score);
|
||||
}
|
||||
render();
|
||||
updateButtons();
|
||||
showOverlay('GAME OVER', score);
|
||||
loadLeaderboards();
|
||||
loadGameHistory();
|
||||
},
|
||||
// onBlockPlaced — relay duel
|
||||
(grid) => {
|
||||
if (duel) duel.onLocalBlockPlaced(grid, game.score);
|
||||
},
|
||||
// onLinesCleared — relay duel
|
||||
(count, holeCol) => {
|
||||
if (duel) duel.onLocalLinesCleared(count, holeCol);
|
||||
}
|
||||
);
|
||||
|
||||
btnStart.addEventListener('click', () => {
|
||||
if (duel && duel.isReady) {
|
||||
duel.startDuel(); // déclenche les deux parties via le serveur
|
||||
} else {
|
||||
startLocalGame(); // solo
|
||||
}
|
||||
});
|
||||
|
||||
btnPause.addEventListener('click', () => {
|
||||
if (duel && duel.isReady) {
|
||||
duel.togglePause();
|
||||
} else {
|
||||
game.pause();
|
||||
updateButtons();
|
||||
if (game.isPaused) showOverlay('PAUSE');
|
||||
else hideOverlay();
|
||||
}
|
||||
});
|
||||
|
||||
btnStop.addEventListener('click', () => {
|
||||
if (duel && duel.isReady) {
|
||||
duel.stop();
|
||||
} else {
|
||||
game.stop();
|
||||
updateButtons();
|
||||
render();
|
||||
showOverlay('STOPPED');
|
||||
}
|
||||
});
|
||||
|
||||
function applySettings() {
|
||||
const settings = {
|
||||
timeToDown: parseInt(inputTTD.value, 10),
|
||||
hardening: parseInt(inputHardening.value, 10),
|
||||
decrementTTD: parseInt(inputDecrement.value, 10),
|
||||
};
|
||||
game.configure(settings);
|
||||
if (duel && duel.isReady) duel.syncSettings(settings);
|
||||
}
|
||||
|
||||
inputTTD.addEventListener('change', applySettings);
|
||||
inputHardening.addEventListener('change', applySettings);
|
||||
inputDecrement.addEventListener('change', applySettings);
|
||||
|
||||
const btnRestart = document.getElementById('btn-restart');
|
||||
if (btnRestart) {
|
||||
btnRestart.addEventListener('click', () => {
|
||||
if (duel && duel.isReady) return;
|
||||
game.restart();
|
||||
updateButtons();
|
||||
render();
|
||||
});
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// GAME HISTORY
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
async function loadGameHistory() {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
if (!token) return;
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/stats/tetris/history', {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
if (!res.ok) return;
|
||||
const history = await res.json();
|
||||
renderGameHistory(history);
|
||||
} catch (err) {
|
||||
console.error('Erreur chargement historique:', err);
|
||||
}
|
||||
}
|
||||
|
||||
function renderGameHistory(history) {
|
||||
const tbody = document.getElementById('lb-history-body');
|
||||
if (!tbody) return;
|
||||
if (!history.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="5">Aucune partie jouée</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = history.map((entry, i) => {
|
||||
const date = new Date(entry.played_at).toLocaleDateString('fr-FR', {
|
||||
day: '2-digit', month: '2-digit', year: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit'
|
||||
});
|
||||
const type = entry.game_type === 'duel' ? 'Duel' : 'Solo';
|
||||
let resultHtml = '—';
|
||||
if (entry.result === 'win') resultHtml = '<span class="hist-win">Victoire</span>';
|
||||
if (entry.result === 'loss') resultHtml = '<span class="hist-loss">Défaite</span>';
|
||||
return `<tr>
|
||||
<td>${i + 1}</td>
|
||||
<td>${date}</td>
|
||||
<td>${type}</td>
|
||||
<td>${entry.score}</td>
|
||||
<td>${resultHtml}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// LEADERBOARDS
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
async function loadLeaderboards() {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
if (!token) return;
|
||||
|
||||
const headers = { 'Authorization': `Bearer ${token}` };
|
||||
|
||||
try {
|
||||
const [scoresRes, winsRes, meRes, rankScoreRes, rankWinsRes] = await Promise.all([
|
||||
fetch('/api/stats/tetris/leaderboard/score', { headers }),
|
||||
fetch('/api/stats/tetris/leaderboard/wins', { headers }),
|
||||
fetch('/api/stats/me', { headers }),
|
||||
fetch('/api/stats/tetris/rank/score', { headers }),
|
||||
fetch('/api/stats/tetris/rank/wins', { headers })
|
||||
]);
|
||||
|
||||
const me = meRes.ok ? await meRes.json() : null;
|
||||
const rankScore = rankScoreRes.ok ? (await rankScoreRes.json()).rank : null;
|
||||
const rankWins = rankWinsRes.ok ? (await rankWinsRes.json()).rank : null;
|
||||
|
||||
if (scoresRes.ok) {
|
||||
const scores = await scoresRes.json();
|
||||
renderLeaderboard('lb-scores-body', scores, ['tetris_best_score', 'tetris_games_played'], me, rankScore);
|
||||
}
|
||||
|
||||
if (winsRes.ok) {
|
||||
const wins = await winsRes.json();
|
||||
renderLeaderboard('lb-wins-body', wins, ['tetris_wins', 'tetris_games_played'], me, rankWins);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Erreur chargement leaderboards:', err);
|
||||
}
|
||||
}
|
||||
|
||||
function renderLeaderboard(tbodyId, rows, [col1, col2], me, myRank) {
|
||||
const tbody = document.getElementById(tbodyId);
|
||||
if (!tbody) return;
|
||||
if (!rows.length && !me) {
|
||||
tbody.innerHTML = '<tr><td colspan="4">Aucun résultat</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
const myUsername = me?.username;
|
||||
const inTop = rows.some(r => r.username === myUsername);
|
||||
|
||||
let html = rows.map((r, i) => {
|
||||
const isMe = r.username === myUsername;
|
||||
return `<tr class="${isMe ? 'lb-me' : ''}">
|
||||
<td>${i + 1}</td>
|
||||
<td>${escapeHtml(r.username)}${isMe ? ' <span class="lb-you">(vous)</span>' : ''}</td>
|
||||
<td>${r[col1] ?? 0}</td>
|
||||
<td>${r[col2] ?? 0}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
|
||||
if (!inTop && me && myRank !== null) {
|
||||
html += `<tr class="lb-separator"><td colspan="4">· · ·</td></tr>`;
|
||||
html += `<tr class="lb-me">
|
||||
<td>${myRank}</td>
|
||||
<td>${escapeHtml(myUsername)} <span class="lb-you">(vous)</span></td>
|
||||
<td>${me[col1] ?? 0}</td>
|
||||
<td>${me[col2] ?? 0}</td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
tbody.innerHTML = html || '<tr><td colspan="4">Aucun résultat</td></tr>';
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
return String(str).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
}
|
||||
|
||||
// Tabs leaderboard
|
||||
document.querySelectorAll('.lb-tab').forEach(tab => {
|
||||
tab.addEventListener('click', () => {
|
||||
document.querySelectorAll('.lb-tab').forEach(t => t.classList.remove('lb-tab--active'));
|
||||
document.querySelectorAll('.lb-content').forEach(c => c.classList.remove('lb-content--active'));
|
||||
tab.classList.add('lb-tab--active');
|
||||
document.getElementById(`lb-${tab.dataset.tab}`).classList.add('lb-content--active');
|
||||
if (tab.dataset.tab === 'history') loadGameHistory();
|
||||
});
|
||||
});
|
||||
|
||||
// Chargement initial des leaderboards
|
||||
loadLeaderboards();
|
||||
loadGameHistory();
|
||||
@@ -228,56 +228,6 @@ export class Window {
|
||||
|
||||
return element;
|
||||
}
|
||||
|
||||
NotficationContainer()
|
||||
{
|
||||
if (document.getElementById('notification-container')) return;
|
||||
|
||||
const container = this.createElement('div');
|
||||
container.id = 'notification-container';
|
||||
Object.assign(container.style, {
|
||||
position: 'fixed',
|
||||
top: '20px',
|
||||
right: '20px',
|
||||
zIndex: 1000,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '10px'
|
||||
});
|
||||
document.body.appendChild(container);
|
||||
}
|
||||
|
||||
showNotification(message, color) {
|
||||
this.NotficationContainer();
|
||||
const container = document.getElementById('notification-container');
|
||||
if (!container) return;
|
||||
|
||||
const notification = document.createElement('div');
|
||||
notification.textContent = message;
|
||||
Object.assign(notification.style, {
|
||||
backgroundColor: color,
|
||||
color: 'white',
|
||||
padding: '10px 20px',
|
||||
borderRadius: '5px',
|
||||
boxShadow: '0 2px 6px rgba(0,0,0,0.3)',
|
||||
opacity: '0',
|
||||
transform: 'translateY(-8px)',
|
||||
transition: 'opacity 0.5s ease, transform 0.5s ease'
|
||||
});
|
||||
|
||||
container.appendChild(notification);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
notification.style.opacity = '1';
|
||||
notification.style.transform = 'translateY(0)';
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
notification.style.opacity = '0';
|
||||
notification.style.transform = 'translateY(-8px)';
|
||||
setTimeout(() => notification.remove(), 500);
|
||||
}, 2200);
|
||||
}
|
||||
}
|
||||
|
||||
// Export old class name for compatibility (alias)
|
||||
@@ -1,76 +0,0 @@
|
||||
import { Window } from '../core/windows.js';
|
||||
import { API, STORAGE_KEYS, CSS } from '../core/config.js';
|
||||
import { eventBus, Events } from '../core/events.js';
|
||||
|
||||
export class LogoutWindow extends Window {
|
||||
constructor() {
|
||||
super({
|
||||
name: 'logout',
|
||||
title: 'Logout',
|
||||
cssClasses: ['logout-window']
|
||||
});
|
||||
|
||||
this.buildUI();
|
||||
this.bindEvents();
|
||||
}
|
||||
|
||||
buildUI() {
|
||||
this.text = this.createElement('div', 'logout__text', {
|
||||
text: 'Are you sure you want to log out?'
|
||||
});
|
||||
this.actions = this.createElement('div', 'logout__actions');
|
||||
|
||||
this.yesBtn = this.createElement('button', [CSS.BTN, CSS.BTN_PRIMARY], {
|
||||
text: 'Yes'
|
||||
});
|
||||
this.noBtn = this.createElement('button', [CSS.BTN, CSS.BTN_SECONDARY], {
|
||||
text: 'No'
|
||||
});
|
||||
|
||||
this.actions.append(this.yesBtn, this.noBtn);
|
||||
this.body.append(this.text, this.actions);
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
this.yesBtn.addEventListener('click', () => this.confirmLogout());
|
||||
this.noBtn.addEventListener('click', () => this.hide());
|
||||
}
|
||||
|
||||
show () {
|
||||
const token = localStorage.getItem(STORAGE_KEYS.AUTH_TOKEN);
|
||||
if (!token) {
|
||||
this.text.textContent = 'You need to login first';
|
||||
this.yesBtn.style.display = 'none';
|
||||
this.noBtn.textContent = 'OK';
|
||||
} else {
|
||||
this.text.textContent = 'Are you sure you want to log out?';
|
||||
this.yesBtn.style.display = 'inline-flex';
|
||||
this.noBtn.textContent = 'No';
|
||||
}
|
||||
super.show();
|
||||
}
|
||||
|
||||
async confirmLogout() {
|
||||
const token = localStorage.getItem(STORAGE_KEYS.AUTH_TOKEN);
|
||||
if (token)
|
||||
{
|
||||
try
|
||||
{
|
||||
await fetch(API.AUTH.LOGOUT, {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
}
|
||||
catch (err)
|
||||
{
|
||||
console.warn('Logout failed:', err);
|
||||
this.showNotification('Logout failed. Please try again.', 'red');
|
||||
return;
|
||||
}
|
||||
}
|
||||
localStorage.removeItem(STORAGE_KEYS.AUTH_TOKEN);
|
||||
eventBus.emit(Events.USER_LOGGED_OUT);
|
||||
setTimeout(() => window.location.reload(), 500);
|
||||
this.showNotification('You have been logged out successfully.', 'green');
|
||||
}
|
||||
}
|
||||