Merge manuel bientot finis

This commit is contained in:
2026-03-03 21:01:49 +01:00
parent 7fda24a6cc
commit 3769ee27a8
49 changed files with 9582 additions and 7 deletions
Binary file not shown.
@@ -0,0 +1,184 @@
import 'dotenv/config';
import { Pool } from 'pg';
const pool = new Pool
({
user: process.env.POSTGRES_USER,
host: process.env.POSTGRES_HOST,
database: process.env.POSTGRES_DB,
password: process.env.POSTGRES_PASSWORD,
port: 5432,
});
async function waitForDb(retries = 10, delay = 2000)
{
for (let i = 0; i < retries; i++)
{
try
{
await pool.query('SELECT 1');
console.log('Database is ready!');
return ;
}
catch (err)
{
await new Promise(r => setTimeout(r, delay));
}
}
throw new Error('Could not connect to database after multiple attempts');
}
async function runMigrations()
{
try
{
// Add total_points column if it doesn't exist
await pool.query(`
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='users' AND column_name='total_points') THEN
ALTER TABLE users ADD COLUMN total_points INT DEFAULT 0;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='users' AND column_name='games_played') THEN
ALTER TABLE users ADD COLUMN games_played INT DEFAULT 0;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='users' AND column_name='games_won') THEN
ALTER TABLE users ADD COLUMN games_won INT DEFAULT 0;
END IF;
END $$;
`);
console.log('Migrations completed!');
}
catch (err)
{
console.error('Error running migrations:', err);
}
}
async function createTables()
{
try
{
await pool.query(`
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
username VARCHAR(50) UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
email VARCHAR(100),
avatar_url TEXT DEFAULT '/avatar/default.png',
total_points INT DEFAULT 0,
games_played INT DEFAULT 0,
games_won INT DEFAULT 0,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS messages(
id SERIAL PRIMARY KEY,
sender_id INT REFERENCES users(id),
received_id INT REFERENCES users(id),
content TEXT,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS friendship (
id_user1 INT NOT NULL,
id_user2 INT NOT NULL,
status VARCHAR(20) NOT NULL,
created_at TIMESTAMP DEFAULT NOW(),
CHECK (id_user1 < id_user2),
PRIMARY KEY (id_user1, id_user2),
FOREIGN KEY (id_user1) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (id_user2) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS oauth_clients (
id SERIAL PRIMARY KEY,
provider VARCHAR(50) NOT NULL,
client_id VARCHAR(200) NOT NULL,
client_secret TEXT,
redirect_uri VARCHAR(255),
created_at TIMESTAMP DEFAULT NOW(),
UNIQUE(provider, client_id)
);
CREATE TABLE IF NOT EXISTS game_rooms (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
status VARCHAR(20) DEFAULT 'waiting',
max_players INT DEFAULT 8,
current_round INT DEFAULT 0,
max_rounds INT DEFAULT 3,
round_duration INT DEFAULT 90,
created_at TIMESTAMP DEFAULT NOW(),
started_at TIMESTAMP,
ended_at TIMESTAMP
);
CREATE TABLE IF NOT EXISTS game_players (
id SERIAL PRIMARY KEY,
room_id INT REFERENCES game_rooms(id) ON DELETE CASCADE,
user_id INT REFERENCES users(id) ON DELETE CASCADE,
score INT DEFAULT 0,
is_drawing BOOLEAN DEFAULT FALSE,
joined_at TIMESTAMP DEFAULT NOW(),
UNIQUE(room_id, user_id)
);
CREATE TABLE IF NOT EXISTS words (
id SERIAL PRIMARY KEY,
word VARCHAR(50) NOT NULL UNIQUE
);
CREATE TABLE IF NOT EXISTS game_rounds (
id SERIAL PRIMARY KEY,
room_id INT REFERENCES game_rooms(id) ON DELETE CASCADE,
round_number INT NOT NULL,
word_id INT REFERENCES words(id),
drawer_id INT REFERENCES users(id),
started_at TIMESTAMP DEFAULT NOW(),
ended_at TIMESTAMP
);
`);
console.log('Tables created!');
}
catch (err)
{
console.error('Error creating tables:', err);
}
}
async function query(text, params)
{
return (pool.query(text, params));
}
async function ensureOauthClient(provider, client_id, client_secret, redirect_uri)
{
try
{
const res = await pool.query(
`SELECT id FROM oauth_clients WHERE provider = $1 AND client_id = $2`, [provider, client_id]
);
if (res.rows.length > 0)
return res.rows[0];
const insert = await pool.query(
`INSERT INTO oauth_clients (provider, client_id, client_secret, redirect_uri) VALUES ($1, $2, $3, $4) RETURNING id`,
[provider, client_id, client_secret, redirect_uri]
);
return insert.rows[0];
}
catch (err)
{
console.error('Error ensuring oauth client:', err);
throw err;
}
}
export
{
waitForDb,
createTables,
runMigrations,
query,
ensureOauthClient
};
@@ -0,0 +1,15 @@
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 3001
ENV NODE_ENV=development
CMD ["node", "index.js"]
@@ -0,0 +1,59 @@
import express from 'express';
import http from 'http';
import cors from 'cors';
import {Server} from 'socket.io';
import authRouter from './routes/auth.js';
import chatRouter from './routes/global_chat.js';
import gameRoomRouter from './routes/game_room.js';
import avatarRouter from './routes/avatar.js';
import friendsRouter from './routes/friends.js';
import playerStatsRouter from './routes/player_stats.js';
import {waitForDb, createTables, runMigrations, ensureOauthClient} from './db.js';
import setupSocketIO from './services/socket.js';
import avatarService from './services/avatar.js';
const app = express();
const server = http.createServer(app);
const io = new Server(server,
{
cors:
{
origin: "*",
methods: ["GET", "POST"]
}
});
app.use(cors());
app.use(express.json());
setupSocketIO(io);
async function startServer()
{
await waitForDb();
await createTables();
await runMigrations();
// Ensure GitHub OAuth client is registered in DB
try {
await ensureOauthClient('github', process.env.GITHUB_CLIENT_ID, process.env.GITHUB_CLIENT_SECRET, process.env.GITHUB_CALLBACK_URL || process.env.GITHUB_REDIRECT_URI);
} catch (e) {
console.warn('OAuth client might already exist or failed to register:', e.message);
}
app.use('/avatar', express.static(avatarService.AVATAR_DIR));
app.use('/api/auth', authRouter);
app.use('/api/global_chat', chatRouter);
app.use('/api/rooms', gameRoomRouter);
app.use('/api/avatar', avatarRouter);
app.use('/api/friends', friendsRouter);
app.use('/api/stats', playerStatsRouter);
app.get('/api', (req, res) => res.send('Backend running'));
server.listen(3001, () =>
{
console.log('Server ready and listening');
});
}
startServer();
@@ -0,0 +1,18 @@
{
"type": "module",
"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"
}
}
@@ -0,0 +1,88 @@
import express from 'express';
import authService from '../services/auth.js';
import fetch from 'node-fetch';
import bcrypt from 'bcrypt';
import jwt from 'jsonwebtoken';
import {query} from '../db.js';
import crypto from 'crypto';
const router = express.Router();
router.post('/register', async(req, res) =>
{
const {username, password} = req.body;
if (!username || !password)
return (res.status(400).json({error: 'Missing fields'}));
const result = await authService.register(username, password);
res.status(result.status).json(result.data);
});
router.post('/login', async(req, res) =>
{
console.log("received login!");
const {username, password} = req.body;
const result = await authService.login(username, password);
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}&` +
`redirect_uri=${encodeURIComponent(process.env.GITHUB_CALLBACK_URL || process.env.GITHUB_REDIRECT_URI)}&` +
`scope=user:email`;
res.redirect(githubAuthUrl);
});
router.get('/github/callback', async (req, res) => {
const code = req.query.code;
if (!code) {
return res.status(400).send('Missing code');
}
try {
const tokenResponse = await fetch('https://github.com/login/oauth/access_token', {
method: 'POST',
headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({
client_id: process.env.GITHUB_CLIENT_ID,
client_secret: process.env.GITHUB_CLIENT_SECRET,
code: code
})
});
const tokenData = await tokenResponse.json();
const accessToken = tokenData.access_token;
if (!accessToken) throw new Error('No access token');
const userResponse = await fetch('https://api.github.com/user', {
headers: { 'Authorization': `Bearer ${accessToken}`, 'User-Agent': 'Transcendence' }
});
const ghUser = await userResponse.json();
const ghUsername = ghUser.login || `github_${ghUser.id}`;
let result = await query(`SELECT id FROM users WHERE username = $1`, [ghUsername]);
let userId;
if (result.rows.length > 0) {
userId = result.rows[0].id;
} else {
const randomPwd = crypto.randomBytes(16).toString('hex');
const passwordHash = await bcrypt.hash(randomPwd, 10);
await query(`INSERT INTO users (username, password_hash) VALUES ($1, $2)`, [ghUsername, passwordHash]);
const inserted = await query(`SELECT id FROM users WHERE username = $1`, [ghUsername]);
userId = inserted.rows[0].id;
}
// Issue JWT
const token = jwt.sign({ userId: userId, username: ghUsername }, process.env.JWT_SECRET, { expiresIn: '1h' });
// Send token to opener window and close popup
res.send(`<!doctype html><html><body><script>window.opener && window.opener.postMessage({token: '${token}'}, '*'); window.close();</script></body></html>`);
} catch (err) {
console.error(err);
res.status(500).send('GitHub OAuth error');
}
});
export default router;
@@ -0,0 +1,51 @@
import express from 'express';
import multer from 'multer';
import avatarService from '../services/avatar.js';
import authenticateToken from '../middleware/auth.js';
const router = express.Router();
// Configue multer to use RAM
const storage = multer.memoryStorage();
const upload = multer
({
storage: storage,
limits:
{
fileSize: 5 * 1024 * 1024 // 5mb
}
});
router.post('/upload', authenticateToken, upload.single('avatar'), async(req, res) =>
{
if (!req.file)
return res.status(400).json({ error: 'No file uploaded' });
const result = await avatarService.uploadAvatar(req.user.userId, req.file);
res.status(result.status).json(result.data);
});
router.delete('/', authenticateToken, async(req, res) =>
{
const result = await avatarService.deleteAvatar(req.user.userId);
res.status(result.status).json(result.data);
});
router.get('/me', authenticateToken, async(req, res) =>
{
console.log('GET /me hit, user:', req.user);
const result = await avatarService.getAvatarUrl(req.user.userId);
res.status(result.status).json(result.data);
});
router.get('/user/:userId', async(req, res) =>
{
const userId = parseInt(req.params.userId);
if (isNaN(userId))
return res.status(400).json({ error: 'Invalid user ID' });
const result = await avatarService.getAvatarUrl(userId);
res.status(result.status).json(result.data);
});
export default router;
@@ -0,0 +1,69 @@
import express from 'express';
import friendsService from '../services/friends.js';
import authenticateToken from '../middleware/auth.js';
const router = express.Router();
// Get friends list
router.get('/', authenticateToken, async (req, res) => {
const result = await friendsService.getFriends(req.user.userId);
res.status(result.status).json(result.data);
});
// Get pending friend requests
router.get('/requests', authenticateToken, async (req, res) => {
const result = await friendsService.getPendingRequests(req.user.userId);
res.status(result.status).json(result.data);
});
// Search users
router.get('/search', authenticateToken, async (req, res) => {
const { q } = req.query;
if (!q || q.trim().length === 0) {
return res.status(400).json({ error: 'Search query required' });
}
const result = await friendsService.searchUsers(req.user.userId, q.trim());
res.status(result.status).json(result.data);
});
// Send friend request
router.post('/request/:userId', authenticateToken, async (req, res) => {
const toUserId = parseInt(req.params.userId);
if (isNaN(toUserId)) {
return res.status(400).json({ error: 'Invalid user ID' });
}
const result = await friendsService.sendFriendRequest(req.user.userId, toUserId);
res.status(result.status).json(result.data);
});
// Accept friend request
router.post('/accept/:userId', authenticateToken, async (req, res) => {
const fromUserId = parseInt(req.params.userId);
if (isNaN(fromUserId)) {
return res.status(400).json({ error: 'Invalid user ID' });
}
const result = await friendsService.acceptFriendRequest(req.user.userId, fromUserId);
res.status(result.status).json(result.data);
});
// Decline friend request
router.post('/decline/:userId', authenticateToken, async (req, res) => {
const fromUserId = parseInt(req.params.userId);
if (isNaN(fromUserId)) {
return res.status(400).json({ error: 'Invalid user ID' });
}
const result = await friendsService.declineFriendRequest(req.user.userId, fromUserId);
res.status(result.status).json(result.data);
});
// Remove friend
router.delete('/:userId', authenticateToken, async (req, res) => {
const friendId = parseInt(req.params.userId);
if (isNaN(friendId)) {
return res.status(400).json({ error: 'Invalid user ID' });
}
const result = await friendsService.removeFriend(req.user.userId, friendId);
res.status(result.status).json(result.data);
});
export default router;
@@ -0,0 +1,185 @@
import express from 'express';
import gameRoomService from '../services/game_room.js';
import authenticateToken from '../middleware/auth.js';
import { getIO, broadcastRoomsList } from '../services/socket.js';
const router = express.Router();
router.get('/', authenticateToken, async(req, res) =>
{
try
{
const rooms = await gameRoomService.listActiveRooms();
res.json(rooms);
}
catch (err)
{
console.error(err);
res.status(500).json({error: 'Server error'});
}
});
// Get list of rooms currently being played (for spectators)
router.get('/playing', authenticateToken, async(req, res) =>
{
try
{
const rooms = await gameRoomService.listPlayingRooms();
res.json(rooms);
}
catch (err)
{
console.error(err);
res.status(500).json({error: 'Server error'});
}
});
// IMPORTANT: This route must be before /:roomId to avoid "current" being interpreted as a roomId
router.get('/current', authenticateToken, async(req, res) =>
{
try
{
const room = await gameRoomService.getCurrentRoom(req.user.userId);
if (!room)
return res.status(204).send(); // No content - user is not in any room
res.json(room);
}
catch(err)
{
console.error(err);
res.status(500).json({error: 'Server error'});
}
});
router.get('/:roomId', authenticateToken, async(req, res) =>
{
try
{
const room = await gameRoomService.getRoomById(req.params.roomId);
if (!room)
return (res.status(404).json({error: 'Room not found'}));
res.json(room);
}
catch(err)
{
console.error(err);
res.status(500).json({error: 'Server error'});
}
});
router.get('/:roomId/players', authenticateToken, async(req, res) =>
{
try
{
const players = await gameRoomService.getRoomPlayers(req.params.roomId);
res.json(players);
}
catch(err)
{
console.error(err);
res.status(500).json({error: 'Server error'});
}
});
router.post('/', authenticateToken, async(req, res) =>
{
try
{
const {name} = req.body;
if (!name)
return (res.status(400).json({error: 'Room name required'}));
const room = await gameRoomService.createRoom(name, req.user.userId);
// Broadcast updated rooms list to all clients
const io = getIO();
if (io) {
broadcastRoomsList(io);
}
res.status(201).json(room);
}
catch(err)
{
console.error(err);
res.status(500).json({error: 'Server error'});
}
});
router.post('/:roomId/join', authenticateToken, async(req, res) =>
{
try
{
const player = await gameRoomService.joinRoom(req.params.roomId, req.user.userId);
// Broadcast updated rooms list to all clients
const io = getIO();
if (io) {
broadcastRoomsList(io);
}
res.json(player);
}
catch(err)
{
console.error(err);
if (err.message.includes('full') || err.message.includes('already'))
res.status(400).json({error: err.message});
else
res.status(500).json({error: err.message});
}
});
router.post('/:roomId/leave', authenticateToken, async(req, res) =>
{
try
{
await gameRoomService.leaveRoom(req.params.roomId, req.user.userId);
// Broadcast updated rooms list to all clients
const io = getIO();
if (io) {
broadcastRoomsList(io);
}
res.json({message: 'Left room successfully'});
}
catch(err)
{
console.error(err);
res.status(500).json({error: 'Server error'});
}
});
// Join a room as spectator
router.post('/:roomId/spectate', authenticateToken, async(req, res) =>
{
try
{
const room = await gameRoomService.spectateRoom(req.params.roomId, req.user.userId);
res.json(room);
}
catch(err)
{
console.error(err);
if (err.message.includes('not found') || err.message.includes('not in playing') || err.message.includes('already in'))
res.status(400).json({error: err.message});
else
res.status(500).json({error: err.message});
}
});
// Leave spectator mode
router.post('/:roomId/leave-spectate', authenticateToken, async(req, res) =>
{
try
{
await gameRoomService.leaveSpectateRoom(req.params.roomId, req.user.userId);
res.json({message: 'Left spectator mode successfully'});
}
catch(err)
{
console.error(err);
res.status(500).json({error: 'Server error'});
}
});
export default router;
@@ -0,0 +1,20 @@
import express from 'express';
import chatService from '../services/global_chat.js';
import authenticateToken from '../middleware/auth.js';
const router = express.Router();
router.get('/messages', authenticateToken, async(req, res) =>
{
try
{
const messages = await chatService.getRecentMessages(50);
res.json(messages);
}
catch(err)
{
console.error(err);
res.status(500).json({error: 'Server error'});
}
});
export default router;
@@ -0,0 +1,46 @@
import express from 'express';
import playerStatsService from '../services/player_stats.js';
import authenticateToken from '../middleware/auth.js';
const router = express.Router();
// Get current user's stats
router.get('/me', authenticateToken, async (req, res) => {
try {
const stats = await playerStatsService.getStatsByUserId(req.user.userId);
if (!stats) {
return res.status(404).json({ error: 'User not found' });
}
res.json(stats);
} catch (err) {
console.error('Error getting user stats:', err);
res.status(500).json({ error: 'Server error' });
}
});
// Get stats by username
router.get('/user/:username', authenticateToken, async (req, res) => {
try {
const stats = await playerStatsService.getStatsByUsername(req.params.username);
if (!stats) {
return res.status(404).json({ error: 'User not found' });
}
res.json(stats);
} catch (err) {
console.error('Error getting user stats:', err);
res.status(500).json({ error: 'Server error' });
}
});
// Get leaderboard
router.get('/leaderboard', authenticateToken, async (req, res) => {
try {
const limit = Math.min(parseInt(req.query.limit) || 10, 50);
const leaderboard = await playerStatsService.getLeaderboard(limit);
res.json(leaderboard);
} catch (err) {
console.error('Error getting leaderboard:', err);
res.status(500).json({ error: 'Server error' });
}
});
export default router;
@@ -0,0 +1,63 @@
import bcrypt from 'bcrypt';
import jwt from 'jsonwebtoken';
import {query} from '../db.js';
async function login(username, password)
{
try
{
const result = await query
(
`SELECT id, password_hash FROM users WHERE username = $1`,
[username]
);
if (result.rows.length === 0)
return ({status: 401, data: {error: 'Invalid credentials'}});
const user = result.rows[0];
const match = await bcrypt.compare(password, user.password_hash);
if (!match)
return ({status: 401, data: {error: 'Invalid credentials'}});
const token = jwt.sign
(
{
userId: user.id,
username: username
},
process.env.JWT_SECRET,
{expiresIn: '1h'}
);
return ({status: 200, data: {token}});
}
catch (err)
{
console.error(err);
return ({status: 500, data: {error: 'Server error'}});
}
};
async function register(username, password)
{
try
{
const password_hash = await bcrypt.hash(password, 10);
await query
(
`INSERT INTO users (username, password_hash) VALUES ($1, $2)`,
[username, password_hash]
);
return ({status: 201, data: {message: 'User created'}});
}
catch (err)
{
if (err.code === '23505')
return ({status: 409, data: {error: 'Username already exists'}});
console.error(err);
return ({status: 500, data: {error: 'Server error'}});
}
};
export default {register, login};
@@ -0,0 +1,148 @@
import {query} from '../db.js';
import path from 'path';
import fs from 'fs';
import {fileURLToPath} from 'url';
import {fileTypeFromBuffer} from 'file-type';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const AVATAR_DIR = path.join(__dirname, '../avatar');
const DEFAULT_AVATAR = '/avatar/default.png';
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5mb
const ALLOWED_TYPES = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp'];
// Verify that the avatar folder already exists
if (!fs.existsSync(AVATAR_DIR))
{
fs.mkdirSync(AVATAR_DIR, { recursive: true });
}
async function uploadAvatar(userId, file)
{
try
{
// File check (type and size)
if (!file)
return ({status: 400, data: {error: 'No file provided'}});
if (!ALLOWED_TYPES.includes(file.mimetype))
return ({status: 400, data: { error: 'Invalid file type. Only JPEG, PNG, GIF, and WebP allowed'}});
const fileType = await fileTypeFromBuffer(file.buffer);
if (!fileType || !['image/jpeg', 'image/png', 'image/gif', 'image/webp'].includes(fileType.mime))
return ({status: 400, data: {error: 'Invalid file content. File does not match allowed image types'}});
if (file.size > MAX_FILE_SIZE)
return ({status: 400, data: {error: 'File too large. Maximum size is 5MB'}});
const currentAvatar = await getCurrentAvatar(userId);
if (currentAvatar === null)
return ({status: 404, data: {error: 'User not found'}});
// Create a unique name for the new avatar to avoid duplicates
const fileExt = path.extname(file.originalname);
const fileName = `user_${userId}_${Date.now()}${fileExt}`;
const avatarPath = `/avatar/${fileName}`;
// Save the new avatar in the folder
const filePath = path.join(AVATAR_DIR, fileName);
fs.writeFileSync(filePath, file.buffer);
await setAvatar(avatarPath, userId);
deleteNonDefault(currentAvatar);
return ({status: 200, data: {avatar_url: avatarPath}});
}
catch (err)
{
console.error('Avatar upload error:', err);
return { status: 500, data: { error: 'Server error' } };
}
}
async function deleteAvatar(userId) {
try
{
const currentAvatar = await getCurrentAvatar(userId);
if (currentAvatar === null)
return ({status: 404, data: {error: 'User not found'}});
// Reset the avatar to the default one
await setAvatar(DEFAULT_AVATAR, userId);
deleteNonDefault(currentAvatar);
return ({status: 200, data: {avatar_url: DEFAULT_AVATAR}});
}
catch (err)
{
console.error('Avatar delete error:', err);
return ({status: 500, data: {error: 'Server error'}});
}
}
async function setAvatar(newAvatar, userId)
{
await query
(
'UPDATE users SET avatar_url = $1 WHERE id = $2',
[newAvatar, userId]
);
}
async function getCurrentAvatar(userId)
{
const res = await query
(
'SELECT avatar_url FROM users WHERE id = $1',
[userId]
);
if (res.rows.length === 0)
return (null);
return (res.rows[0].avatar_url);
}
function deleteNonDefault(curAvatar)
{
if (curAvatar && curAvatar !== DEFAULT_AVATAR)
{
const fileName = path.basename(curAvatar);
const filePath = path.join(AVATAR_DIR, fileName);
if (fs.existsSync(filePath))
fs.unlinkSync(filePath);
}
}
async function getAvatarUrl(userId)
{
try
{
const result = await query
(
'SELECT avatar_url FROM users WHERE id = $1',
[userId]
);
if (result.rows.length === 0)
return ({status: 404, data: {error: 'User not found'}});
const avatarUrl = result.rows[0].avatar_url || DEFAULT_AVATAR;
return ({status: 200, data: {avatar_url: avatarUrl}});
}
catch (err)
{
console.error('Get avatar error:', err);
return ({status: 500, data: {error: 'Server error'}});
}
}
export default
{
uploadAvatar,
deleteAvatar,
getAvatarUrl,
AVATAR_DIR,
DEFAULT_AVATAR
};
@@ -0,0 +1,240 @@
import { query } from '../db.js';
/**
* Get list of friends for a user
*/
async function getFriends(userId) {
try {
const result = await query(
`SELECT u.id, u.username, u.avatar_url, u.total_points, u.games_played, u.games_won
FROM friendship f
JOIN users u ON (
CASE
WHEN f.id_user1 = $1 THEN f.id_user2 = u.id
ELSE f.id_user1 = u.id
END
)
WHERE (f.id_user1 = $1 OR f.id_user2 = $1)
AND f.status = 'accepted'`,
[userId]
);
return { status: 200, data: { friends: result.rows } };
} catch (err) {
console.error('Get friends error:', err);
return { status: 500, data: { error: 'Server error' } };
}
}
/**
* Get pending friend requests received by user
*/
async function getPendingRequests(userId) {
try {
const result = await query(
`SELECT u.id, u.username, u.avatar_url, f.created_at
FROM friendship f
JOIN users u ON (
CASE
WHEN f.id_user1 = $1 THEN f.id_user2 = u.id
ELSE f.id_user1 = u.id
END
)
WHERE (f.id_user1 = $1 OR f.id_user2 = $1)
AND f.status = 'pending_' || $1::text`,
[userId]
);
return { status: 200, data: { requests: result.rows } };
} catch (err) {
console.error('Get pending requests error:', err);
return { status: 500, data: { error: 'Server error' } };
}
}
/**
* Search users by username
*/
async function searchUsers(userId, searchTerm) {
try {
const result = await query(
`SELECT id, username, avatar_url
FROM users
WHERE username ILIKE $1
AND id != $2
LIMIT 20`,
[`%${searchTerm}%`, userId]
);
return { status: 200, data: { users: result.rows } };
} catch (err) {
console.error('Search users error:', err);
return { status: 500, data: { error: 'Server error' } };
}
}
/**
* Send a friend request
*/
async function sendFriendRequest(fromUserId, toUserId) {
try {
if (fromUserId === toUserId) {
return { status: 400, data: { error: 'Cannot add yourself as friend' } };
}
// Check if user exists
const userCheck = await query('SELECT id FROM users WHERE id = $1', [toUserId]);
if (userCheck.rows.length === 0) {
return { status: 404, data: { error: 'User not found' } };
}
// Ensure id_user1 < id_user2 for the constraint
const id1 = Math.min(fromUserId, toUserId);
const id2 = Math.max(fromUserId, toUserId);
// Check existing friendship
const existing = await query(
'SELECT status FROM friendship WHERE id_user1 = $1 AND id_user2 = $2',
[id1, id2]
);
if (existing.rows.length > 0) {
const status = existing.rows[0].status;
if (status === 'accepted') {
return { status: 400, data: { error: 'Already friends' } };
}
if (status.startsWith('pending_')) {
return { status: 400, data: { error: 'Friend request already exists' } };
}
}
// Status indicates who needs to accept: pending_<receiver_id>
const status = `pending_${toUserId}`;
await query(
`INSERT INTO friendship (id_user1, id_user2, status)
VALUES ($1, $2, $3)
ON CONFLICT (id_user1, id_user2)
DO UPDATE SET status = $3`,
[id1, id2, status]
);
return { status: 200, data: { message: 'Friend request sent' } };
} catch (err) {
console.error('Send friend request error:', err);
return { status: 500, data: { error: 'Server error' } };
}
}
/**
* Accept a friend request
*/
async function acceptFriendRequest(userId, fromUserId) {
try {
const id1 = Math.min(userId, fromUserId);
const id2 = Math.max(userId, fromUserId);
const result = await query(
`UPDATE friendship
SET status = 'accepted'
WHERE id_user1 = $1 AND id_user2 = $2
AND status = $3
RETURNING *`,
[id1, id2, `pending_${userId}`]
);
if (result.rows.length === 0) {
return { status: 404, data: { error: 'Friend request not found' } };
}
return { status: 200, data: { message: 'Friend request accepted' } };
} catch (err) {
console.error('Accept friend request error:', err);
return { status: 500, data: { error: 'Server error' } };
}
}
/**
* Decline a friend request
*/
async function declineFriendRequest(userId, fromUserId) {
try {
const id1 = Math.min(userId, fromUserId);
const id2 = Math.max(userId, fromUserId);
const result = await query(
`DELETE FROM friendship
WHERE id_user1 = $1 AND id_user2 = $2
AND status = $3
RETURNING *`,
[id1, id2, `pending_${userId}`]
);
if (result.rows.length === 0) {
return { status: 404, data: { error: 'Friend request not found' } };
}
return { status: 200, data: { message: 'Friend request declined' } };
} catch (err) {
console.error('Decline friend request error:', err);
return { status: 500, data: { error: 'Server error' } };
}
}
/**
* Get list of friend IDs for a user (for quick lookup)
*/
async function getFriendIds(userId) {
try {
const result = await query(
`SELECT
CASE
WHEN f.id_user1 = $1 THEN f.id_user2
ELSE f.id_user1
END as friend_id
FROM friendship f
WHERE (f.id_user1 = $1 OR f.id_user2 = $1)
AND f.status = 'accepted'`,
[userId]
);
return result.rows.map(row => row.friend_id);
} catch (err) {
console.error('Get friend IDs error:', err);
return [];
}
}
/**
* Remove a friend
*/
async function removeFriend(userId, friendId) {
try {
const id1 = Math.min(userId, friendId);
const id2 = Math.max(userId, friendId);
const result = await query(
`DELETE FROM friendship
WHERE id_user1 = $1 AND id_user2 = $2
AND status = 'accepted'
RETURNING *`,
[id1, id2]
);
if (result.rows.length === 0) {
return { status: 404, data: { error: 'Friendship not found' } };
}
return { status: 200, data: { message: 'Friend removed' } };
} catch (err) {
console.error('Remove friend error:', err);
return { status: 500, data: { error: 'Server error' } };
}
}
export default {
getFriends,
getFriendIds,
getPendingRequests,
searchUsers,
sendFriendRequest,
acceptFriendRequest,
declineFriendRequest,
removeFriend
};
@@ -0,0 +1,254 @@
import {query} from '../db.js';
// Create the room with name as the only parameter
// max_players, status and the other variables have their default values defined in db.js
async function createRoom(name, userId)
{
const result = await query
(
`INSERT INTO game_rooms (name) VALUES ($1) RETURNING *`,
[name]
);
const room = result.rows[0];
await query
(
'INSERT INTO game_players (room_id, user_id) VALUES ($1, $2)',
[room.id, userId]
);
return (room);
}
async function getRoomById(roomId)
{
const result = await query
(
'SELECT * FROM game_rooms WHERE id = $1',
[roomId]
);
return (result.rows[0]);
}
// List all the waiting rooms and the player amount in each of them
async function listActiveRooms()
{
const result = await query
(
`SELECT r.*, COUNT(p.id) as player_count
FROM game_rooms r
LEFT JOIN game_players p ON r.id = p.room_id
WHERE r.status = 'waiting'
GROUP BY r.id
ORDER BY player_count DESC, r.created_at DESC`
);
return (result.rows);
}
async function listPlayingRooms()
{
const result = await query
(
`SELECT r.*, COUNT(p.id) as player_count
FROM game_rooms r
LEFT JOIN game_players p ON r.id = p.room_id
WHERE r.status = 'playing'
GROUP BY r.id
ORDER BY player_count DESC, r.created_at DESC`
);
return (result.rows);
}
async function spectateRoom(roomId, userId)
{
const room = await getRoomById(roomId);
if (!room)
throw new Error('Room not found');
if (room.status !== 'playing')
throw new Error('Room is not in playing status');
// Check if user is already a player in any active game
const playerInGame = await query
(
`SELECT r.id, r.name, r.status
FROM game_rooms r
JOIN game_players gp ON r.id = gp.room_id
WHERE gp.user_id = $1 AND r.status IN ('waiting', 'playing')
LIMIT 1`,
[userId]
);
if (playerInGame.rows.length > 0)
{
const gameRoom = playerInGame.rows[0];
if (gameRoom.id === parseInt(roomId))
throw new Error('You cannot spectate a game you are playing in');
else
throw new Error('You are already in an active game');
}
return (room);
}
async function leaveSpectateRoom(roomId, userId)
{
const playerCount = await query
(
'SELECT COUNT(*) FROM game_players WHERE room_id = $1',
[roomId]
);
if (parseInt(playerCount.rows[0].count) === 0)
{
await query
(
'DELETE FROM game_rooms WHERE id = $1',
[roomId]
);
}
}
async function joinRoom(roomId, userId)
{
const room = await getRoomById(roomId);
if (!room)
throw new Error('Room not found');
const playerCount = await query
(
'SELECT COUNT(*) FROM game_players WHERE room_id = $1',
[roomId]
);
if (parseInt(playerCount.rows[0].count) >= 8)
throw new Error('Room is full');
if (room.status !== 'waiting')
throw new Error('Game already started or ended');
const result = await query
(
'INSERT INTO game_players (room_id, user_id) VALUES ($1, $2) RETURNING *',
[roomId, userId]
);
return (result.rows[0]);
}
async function leaveRoom(roomId, userId)
{
await query
(
'DELETE FROM game_players WHERE room_id = $1 AND user_id = $2',
[roomId, userId]
);
const playerCount = await query
(
'SELECT COUNT(*) FROM game_players WHERE room_id = $1',
[roomId]
);
if (parseInt(playerCount.rows[0].count) === 0)
{
await query
(
'DELETE FROM game_rooms WHERE id = $1',
[roomId]
);
}
}
// List the players in the room and their scores
// Useful for the scoreboard and also tell which player is currently drawing
async function getRoomPlayers(roomId)
{
const result = await query
(
`SELECT gp.*, u.username, u.avatar_url, u.total_points, u.games_played, u.games_won
FROM game_players gp
JOIN users u ON gp.user_id = u.id
WHERE gp.room_id = $1
ORDER BY gp.score DESC`,
[roomId]
);
return (result.rows);
}
// Get the current room of a user (if any)
async function getCurrentRoom(userId)
{
const result = await query
(
`SELECT r.*
FROM game_rooms r
JOIN game_players gp ON r.id = gp.room_id
WHERE gp.user_id = $1 AND r.status IN ('waiting', 'playing')
LIMIT 1`,
[userId]
);
return (result.rows[0] || null);
}
// Update room status (waiting, playing, ended)
async function updateRoomStatus(roomId, status)
{
const validStatuses = ['waiting', 'playing', 'ended'];
if (!validStatuses.includes(status))
throw new Error('Invalid status');
let updateQuery = 'UPDATE game_rooms SET status = $1';
const params = [status, roomId];
if (status === 'playing')
{
updateQuery += ', started_at = NOW()';
}
else if (status === 'ended')
{
updateQuery += ', ended_at = NOW()';
}
updateQuery += ' WHERE id = $2 RETURNING *';
const result = await query(updateQuery, params);
return (result.rows[0]);
}
async function resetRoomScores(roomId)
{
await query
(
'UPDATE game_players SET score = 0 WHERE room_id = $1',
[roomId]
);
}
async function cleanupEndedRooms()
{
await query
(
'DELETE FROM game_players WHERE room_id IN (SELECT id FROM game_rooms WHERE status = $1)',
['ended']
);
await query
(
'DELETE FROM game_rooms WHERE status = $1',
['ended']
);
}
export default
{
createRoom,
getRoomById,
listActiveRooms,
listPlayingRooms,
spectateRoom,
leaveSpectateRoom,
joinRoom,
leaveRoom,
getRoomPlayers,
getCurrentRoom,
updateRoomStatus,
resetRoomScores,
cleanupEndedRooms
};
@@ -0,0 +1,27 @@
import {query} from '../db.js';
async function saveMessage(userId, content)
{
const result = await query
(
'INSERT INTO messages (sender_id, content) VALUES ($1 ,$2) RETURNING *',
[userId, content]
);
return (result.rows[0]);
}
async function getRecentMessages(limit = 50)
{
const result = await query
(
`SELECT m.sender_id, m.content, m.created_at, u.username
FROM messages m
JOIN users u ON m.sender_id = u.id
ORDER BY m.created_at DESC
LIMIT $1`,
[limit]
);
return (result.rows.reverse());
}
export default {saveMessage, getRecentMessages};
@@ -0,0 +1,88 @@
import { query } from '../db.js';
// Get player stats by user ID
async function getStatsByUserId(userId) {
const result = await query(
`SELECT id, username, avatar_url, total_points, games_played, games_won, created_at
FROM users WHERE id = $1`,
[userId]
);
return result.rows[0] || null;
}
// Get player stats by username
async function getStatsByUsername(username) {
const result = await query(
`SELECT id, username, avatar_url, total_points, games_played, games_won, created_at
FROM users WHERE username = $1`,
[username]
);
return result.rows[0] || null;
}
// Update player points (add points to total)
async function addPoints(userId, points) {
const result = await query(
`UPDATE users SET total_points = COALESCE(total_points, 0) + $1 WHERE id = $2 RETURNING total_points`,
[points, userId]
);
return result.rows[0]?.total_points || 0;
}
// Update player points by username
async function addPointsByUsername(username, points) {
const result = await query(
`UPDATE users SET total_points = COALESCE(total_points, 0) + $1 WHERE username = $2 RETURNING total_points`,
[points, username]
);
return result.rows[0]?.total_points || 0;
}
// Increment games played
async function incrementGamesPlayed(userId) {
await query(
`UPDATE users SET games_played = COALESCE(games_played, 0) + 1 WHERE id = $1`,
[userId]
);
}
// Increment games won
async function incrementGamesWon(userId) {
await query(
`UPDATE users SET games_won = COALESCE(games_won, 0) + 1 WHERE id = $1`,
[userId]
);
}
// Get leaderboard (top players by points)
async function getLeaderboard(limit = 10) {
const result = await query(
`SELECT id, username, avatar_url, total_points, games_played, games_won
FROM users
WHERE total_points > 0
ORDER BY total_points DESC
LIMIT $1`,
[limit]
);
return result.rows;
}
// Get user ID by username
async function getUserIdByUsername(username) {
const result = await query(
`SELECT id FROM users WHERE username = $1`,
[username]
);
return result.rows[0]?.id || null;
}
export default {
getStatsByUserId,
getStatsByUsername,
addPoints,
addPointsByUsername,
incrementGamesPlayed,
incrementGamesWon,
getLeaderboard,
getUserIdByUsername
};
@@ -0,0 +1,795 @@
import jwt from 'jsonwebtoken';
import chatService from './global_chat.js';
import friendsService from './friends.js';
import gameRoomService from './game_room.js';
import playerStatsService from './player_stats.js';
// Store game state per room
const gameRooms = new Map();
// Store tetris duel rooms { roomCode → Map<socketId, socket> }
const tetrisRooms = new Map();
// Store io instance globally for use in routes
let ioInstance = null;
export function getIO() {
return ioInstance;
}
// Broadcast rooms list to all connected clients
async function broadcastRoomsList(io) {
try {
const rooms = await gameRoomService.listActiveRooms();
io.emit('game-rooms-updated', { rooms });
} catch (err) {
console.error('Error broadcasting rooms list:', err);
}
}
// Check if a playing game has only 1 player left and auto-stop it
async function checkAndStopSinglePlayerGame(io, roomId, dbRoomId) {
if (!dbRoomId) return;
try {
// Check if room is in 'playing' status
const room = await gameRoomService.getRoomById(dbRoomId);
if (!room || room.status !== 'playing') return;
// Count remaining players
const players = await gameRoomService.getRoomPlayers(dbRoomId);
if (players.length <= 1) {
console.log(`Room ${dbRoomId} has only ${players.length} player(s) left, ending game`);
// Update room status to 'ended'
await gameRoomService.updateRoomStatus(dbRoomId, 'waiting');
await gameRoomService.resetRoomScores(dbRoomId);
// Remove from game state
gameRooms.delete(roomId);
// Notify remaining player(s)
io.to(roomId).emit('game-ended');
io.to(roomId).emit('game-message', {
message: 'La partie s\'est terminée car il ne reste qu\'un seul joueur',
type: 'info'
});
// Broadcast updated rooms list
broadcastRoomsList(io);
}
} catch (err) {
console.error('Error checking single player game:', err);
}
}
// Save round points to database (only the difference from round start)
async function saveRoundPoints(currentScores, roundStartScores) {
for (const [username, currentPoints] of Object.entries(currentScores)) {
const startPoints = roundStartScores[username] || 0;
const pointsEarned = currentPoints - startPoints;
if (pointsEarned !== 0) {
try {
await playerStatsService.addPointsByUsername(username, pointsEarned);
console.log(`Saved ${pointsEarned} points for ${username}`);
} catch (err) {
console.error(`Error saving points for ${username}:`, err);
}
}
}
}
function setupSocketIO(io)
{
ioInstance = io;
io.use((socket, next) =>
{
const token = socket.handshake.auth.token;
if (!token)
return (next(new Error('Authentication error')));
try
{
const decoded = jwt.verify(token, process.env.JWT_SECRET);
socket.user = decoded;
next();
}
catch(err)
{
next(new Error('Authentication error'));
}
});
io.on('connection', async (socket) =>
{
console.log(`User connected: ${socket.user.username}`);
socket.join('general-chat');
// Send recent messages and friend IDs on connection
try {
const [recentMessages, friendIds] = await Promise.all([
chatService.getRecentMessages(50),
friendsService.getFriendIds(socket.user.userId)
]);
socket.emit('chat-init', {
messages: recentMessages,
friendIds: friendIds
});
} catch (err) {
console.error('Error fetching initial data:', err);
}
socket.on('chat-message', async(data) =>
{
try
{
const message = await chatService.saveMessage(socket.user.userId, data.content);
socket.broadcast.to('general-chat').emit('chat-message',
{
id: message.id,
sender_id: socket.user.userId,
username: socket.user.username,
content: message.content,
created_at: message.created_at
});
}
catch (err)
{
console.error('Error saving message:', err);
socket.emit('error', {message: 'Failed to send message'});
}
});
// ============================================
// GAME ROOM EVENTS
// ============================================
// Join a game room
socket.on('game-join-room', async (data) => {
console.log('Received game-join-room from', socket.user.username, 'data:', data);
const roomId = `game-room-${data.roomId}`;
socket.join(roomId);
socket.gameRoomId = roomId;
socket.gameRoomDbId = data.roomId;
console.log(`${socket.user.username} joined ${roomId}, socket.gameRoomId set to:`, socket.gameRoomId);
// Send confirmation to the socket that joined
socket.emit('game-room-joined', {
roomId: data.roomId,
success: true
});
// Get updated player list from DB
try {
const players = await gameRoomService.getRoomPlayers(data.roomId);
// Notify ALL players in the room (including the one who joined) with updated player list
io.to(roomId).emit('game-players-updated', { players });
} catch (err) {
console.error('Error getting room players:', err);
}
// Notify others in the room that someone joined
socket.to(roomId).emit('game-player-joined', {
username: socket.user.username,
userId: socket.user.userId
});
// Broadcast rooms list update to everyone
broadcastRoomsList(io);
// Send current game state if game is in progress
const gameState = gameRooms.get(roomId);
if (gameState && gameState.isPlaying) {
socket.emit('game-state-sync', {
isPlaying: gameState.isPlaying,
drawer: gameState.drawer,
wordLength: gameState.currentWord ? gameState.currentWord.length : 0,
revealedLetters: gameState.revealedLetters,
revealedWord: gameState.revealedWord || [],
guessedLetters: gameState.guessedLetters,
players: gameState.players
});
}
});
// Leave a game room
socket.on('game-leave-room', async () => {
if (socket.gameRoomId) {
const roomId = socket.gameRoomId;
const dbRoomId = socket.gameRoomDbId;
socket.to(roomId).emit('game-player-left', {
username: socket.user.username,
userId: socket.user.userId
});
socket.leave(roomId);
console.log(`${socket.user.username} left ${roomId}`);
// Get updated player list and broadcast to remaining players
if (dbRoomId) {
try {
const players = await gameRoomService.getRoomPlayers(dbRoomId);
io.to(roomId).emit('game-players-updated', { players });
} catch (err) {
// Room may have been deleted
console.log('Room may have been deleted:', err.message);
}
}
socket.gameRoomId = null;
socket.gameRoomDbId = null;
// Check if game should auto-stop due to single player
await checkAndStopSinglePlayerGame(io, roomId, dbRoomId);
// Broadcast updated rooms list
broadcastRoomsList(io);
}
});
// Join a game room as spectator
socket.on('game-spectate-room', async (data) => {
console.log('Received game-spectate-room from', socket.user.username, 'data:', data);
const roomId = `game-room-${data.roomId}`;
// Verify room exists and is in playing status, and user is not already in a game
try {
const room = await gameRoomService.spectateRoom(data.roomId, socket.user.userId);
socket.join(roomId);
socket.gameRoomId = roomId;
socket.gameRoomDbId = data.roomId;
socket.isSpectator = true;
console.log(`${socket.user.username} joined ${roomId} as spectator`);
// Send confirmation
socket.emit('game-spectate-joined', {
roomId: data.roomId,
success: true
});
// Notify others that a spectator joined
socket.to(roomId).emit('game-spectator-joined', {
username: socket.user.username
});
// Send current game state
const gameState = gameRooms.get(roomId);
if (gameState && gameState.isPlaying) {
socket.emit('game-state-sync', {
isPlaying: gameState.isPlaying,
drawer: gameState.drawer,
wordLength: gameState.currentWord ? gameState.currentWord.length : 0,
revealedLetters: gameState.revealedLetters,
revealedWord: gameState.revealedWord || [],
guessedLetters: gameState.guessedLetters,
players: gameState.players,
scores: gameState.scores || {}
});
}
} catch (err) {
console.error('Error joining as spectator:', err);
socket.emit('game-spectate-error', {
error: err.message || 'Cannot spectate this room'
});
}
});
// Leave spectator mode
socket.on('game-leave-spectate', () => {
if (socket.gameRoomId && socket.isSpectator) {
const roomId = socket.gameRoomId;
socket.to(roomId).emit('game-spectator-left', {
username: socket.user.username
});
socket.leave(roomId);
console.log(`${socket.user.username} left spectator mode in ${roomId}`);
socket.gameRoomId = null;
socket.gameRoomDbId = null;
socket.isSpectator = false;
}
});
// Start the game
socket.on('game-start', async (data) => {
console.log('Received game-start event from', socket.user.username);
console.log('socket.gameRoomId:', socket.gameRoomId);
// Security check: need at least 2 players
if (!data.players || data.players.length < 2) {
console.log('Game start rejected: not enough players');
socket.emit('game-start-error', {
error: 'Il faut au moins 2 joueurs pour commencer'
});
return;
}
const gameStartedData = {
drawer: data.drawer,
players: data.players
};
const roomId = socket.gameRoomId;
// If no roomId, still start the game for this socket only
if (!roomId) {
console.log('WARNING: No roomId for socket, starting game for this socket only');
socket.emit('game-started', gameStartedData);
return;
}
// Verify player count from database
const dbRoomId = socket.gameRoomDbId;
if (dbRoomId) {
try {
const players = await gameRoomService.getRoomPlayers(dbRoomId);
if (players.length < 2) {
console.log(`Game start rejected: only ${players.length} player(s) in room`);
socket.emit('game-start-error', {
error: 'Il faut au moins 2 joueurs pour commencer'
});
return;
}
} catch (err) {
console.error('Error checking player count:', err);
}
}
// Update room status to 'playing' in database
if (dbRoomId) {
try {
await gameRoomService.updateRoomStatus(dbRoomId, 'playing');
console.log(`Room ${dbRoomId} status updated to 'playing'`);
} catch (err) {
console.error('Error updating room status to playing:', err);
}
}
// Initialize scores for all players
const scores = {};
data.players.forEach(p => scores[p] = 0);
const gameState = {
isPlaying: true,
currentWord: '',
revealedLetters: [],
drawer: data.drawer,
players: data.players,
currentPlayerIndex: 0,
guessedLetters: [],
scores: scores,
roundStartScores: { ...scores }
};
gameRooms.set(roomId, gameState);
// Emit to OTHER players in the room
socket.to(roomId).emit('game-started', gameStartedData);
// Emit directly to this socket (the one who started the game)
socket.emit('game-started', gameStartedData);
console.log(`Game started in ${roomId} by ${socket.user.username}`);
// Broadcast updated rooms list (this room should no longer appear)
broadcastRoomsList(io);
});
// Drawer sets the word
socket.on('game-set-word', (data) => {
const roomId = socket.gameRoomId;
if (!roomId) return;
const gameState = gameRooms.get(roomId);
if (!gameState) return;
gameState.currentWord = data.word.toLowerCase();
gameState.revealedLetters = new Array(data.word.length).fill(false);
gameState.revealedWord = new Array(data.word.length).fill('_');
gameState.guessedLetters = [];
gameState.wrongGuesses = 0;
// Initialize scores if not already done
if (!gameState.scores) {
gameState.scores = {};
gameState.players.forEach(p => gameState.scores[p] = 0);
}
// Notify all players (without revealing the word)
io.to(roomId).emit('game-word-set', {
wordLength: data.word.length,
drawer: socket.user.username,
revealedWord: gameState.revealedWord,
scores: gameState.scores
});
});
// Drawing data (real-time)
socket.on('game-draw', (data) => {
const roomId = socket.gameRoomId;
if (!roomId) return;
// Spectators cannot draw
if (socket.isSpectator) {
console.log(`Spectator ${socket.user.username} tried to draw - blocked`);
return;
}
// Broadcast drawing to all other players in the room
socket.to(roomId).emit('game-draw', {
x1: data.x1,
y1: data.y1,
x2: data.x2,
y2: data.y2,
color: data.color,
lineWidth: data.lineWidth
});
});
// Clear canvas
socket.on('game-clear-canvas', () => {
const roomId = socket.gameRoomId;
if (!roomId) return;
// Spectators cannot clear canvas
if (socket.isSpectator) return;
socket.to(roomId).emit('game-clear-canvas');
});
// Player makes a guess
socket.on('game-guess', (data) => {
const roomId = socket.gameRoomId;
if (!roomId) return;
// Spectators cannot make guesses
if (socket.isSpectator) {
console.log(`Spectator ${socket.user.username} tried to guess - blocked`);
return;
}
const gameState = gameRooms.get(roomId);
if (!gameState || !gameState.currentWord) return;
const guess = data.guess.toLowerCase();
const isLetter = guess.length === 1;
let success = false;
let points = 0;
const username = socket.user.username;
// Initialize scores if needed
if (!gameState.scores) {
gameState.scores = {};
gameState.players.forEach(p => gameState.scores[p] = 0);
}
if (!gameState.scores[username]) {
gameState.scores[username] = 0;
}
if (isLetter) {
// Check if letter was already guessed
if (gameState.guessedLetters.includes(guess)) {
socket.emit('game-guess-result', {
guess,
success: false,
type: 'letter',
message: 'Lettre deja proposee',
username: username,
scores: gameState.scores
});
return;
}
gameState.guessedLetters.push(guess);
// Check each position and reveal the actual letter
let lettersFound = 0;
for (let i = 0; i < gameState.currentWord.length; i++) {
if (gameState.currentWord[i] === guess) {
gameState.revealedLetters[i] = true;
gameState.revealedWord[i] = guess;
success = true;
lettersFound++;
}
}
// Points: 10 per letter found, -5 for wrong guess
if (success) {
points = lettersFound * 10;
gameState.scores[username] += points;
} else {
points = -5;
gameState.scores[username] += points;
gameState.wrongGuesses++;
}
} else {
// Full word guess
success = guess === gameState.currentWord;
if (success) {
gameState.revealedLetters = gameState.revealedLetters.map(() => true);
gameState.revealedWord = gameState.currentWord.split('');
// Bonus points for guessing the whole word
const remainingLetters = gameState.revealedLetters.filter(r => !r).length;
points = 50 + (remainingLetters * 5);
gameState.scores[username] += points;
} else {
points = -10;
gameState.scores[username] += points;
gameState.wrongGuesses++;
}
}
// Broadcast result to all players with the revealed word (actual letters)
io.to(roomId).emit('game-guess-result', {
guess,
success,
type: isLetter ? 'letter' : 'word',
username: username,
revealedLetters: gameState.revealedLetters,
revealedWord: gameState.revealedWord,
points: points,
scores: gameState.scores
});
// Check if word is complete
if (gameState.revealedLetters.every(r => r)) {
// Bonus points for the drawer
const drawerBonus = Math.max(0, 30 - (gameState.wrongGuesses * 5));
if (gameState.scores[gameState.drawer]) {
gameState.scores[gameState.drawer] += drawerBonus;
}
// Save points to database for all players
saveRoundPoints(gameState.scores, gameState.roundStartScores || {});
// Update round start scores for next round
gameState.roundStartScores = { ...gameState.scores };
io.to(roomId).emit('game-word-found', {
word: gameState.currentWord,
winner: username,
scores: gameState.scores,
drawerBonus: drawerBonus
});
}
});
// Next round
socket.on('game-next-round', (data) => {
const roomId = socket.gameRoomId;
if (!roomId) return;
const gameState = gameRooms.get(roomId);
if (!gameState) return;
gameState.currentWord = '';
gameState.revealedLetters = [];
gameState.guessedLetters = [];
gameState.drawer = data.drawer;
io.to(roomId).emit('game-new-round', {
drawer: data.drawer
});
});
socket.on('leave-room-during-game', async () => {
const roomId = socket.gameRoomId;
const dbRoomId = socket.gameRoomDbId;
const userId = socket.user.userId;
const username = socket.user.username;
if (!roomId || !dbRoomId || !userId) return;
console.log(`Player ${username} leaving room ${roomId} during game`);
try
{
socket.leave(roomId);
await gameRoomService.leaveRoom(dbRoomId, userId);
io.to(roomId).emit('game-player-left', {
username: username,
message: `${username} a quitté la partie`
});
const gameState = gameRooms.get(roomId);
if (gameState)
{
gameState.players = gameState.players.filter(p => p !== username);
delete gameState.scores[username];
io.to(roomId).emit('scores-updated', gameState.scores);
}
await checkAndStopSinglePlayerGame(io, roomId, dbRoomId);
socket.gameRoomId = null;
socket.gameRoomDbId = null;
broadcastRoomsList(io);
}
catch (err)
{
console.error('Error leaving room during game:', err);
}
});
// End game
socket.on('game-end', async () => {
const roomId = socket.gameRoomId;
if (!roomId) return;
// Update room status to 'waiting' in database
const dbRoomId = socket.gameRoomDbId;
if (dbRoomId) {
try {
await gameRoomService.updateRoomStatus(dbRoomId, 'waiting');
await gameRoomService.resetRoomScores(dbRoomId);
console.log(`Room ${dbRoomId} status updated to 'waiting'`);
} catch (err) {
console.error('Error updating room status to waiting:', err);
}
}
gameRooms.delete(roomId);
io.to(roomId).emit('game-ended');
// Broadcast updated rooms list
broadcastRoomsList(io);
});
// ============================================
// TETRIS DUEL EVENTS
// ============================================
socket.on('tetris:join', ({ roomCode }) => {
const code = String(roomCode).toUpperCase().slice(0, 8);
// Quitter l'ancienne room tetris si besoin
if (socket.tetrisRoomCode) {
_tetrisLeave(socket);
}
if (!tetrisRooms.has(code)) {
tetrisRooms.set(code, new Map());
}
const room = tetrisRooms.get(code);
if (room.size >= 2) {
socket.emit('tetris:room-status', { status: 'full', players: [] });
return;
}
room.set(socket.id, socket);
socket.tetrisRoomCode = code;
const players = [...room.values()].map(s => s.user.username);
if (room.size === 1) {
socket.emit('tetris:room-status', { status: 'waiting', players });
} else {
// Notifier les deux joueurs
for (const s of room.values()) {
s.emit('tetris:room-status', { status: 'ready', players });
}
// Notifier l'adversaire qu'un nouveau joueur a rejoint
for (const [id, s] of room) {
if (id !== socket.id) {
s.emit('tetris:opponent-joined', { username: socket.user.username });
}
}
}
});
socket.on('tetris:leave', () => {
_tetrisLeave(socket);
});
// Relay pur : grid-update → adversaire uniquement
socket.on('tetris:grid-update', (data) => {
_tetrisRelayToOpponent(socket, 'tetris:grid-update', data);
});
// Relay pur : lines-cleared → adversaire uniquement
socket.on('tetris:lines-cleared', (data) => {
_tetrisRelayToOpponent(socket, 'tetris:lines-cleared', data);
});
// start-duel → relayé aux DEUX joueurs de la room (inclut l'émetteur)
socket.on('tetris:start-duel', () => {
const code = socket.tetrisRoomCode;
if (!code) return;
const room = tetrisRooms.get(code);
if (!room || room.size < 2) return;
for (const s of room.values()) {
s.emit('tetris:start-duel');
}
});
// game-over → relayé en opponent-game-over chez l'adversaire
socket.on('tetris:game-over', (data) => {
_tetrisRelayToOpponent(socket, 'tetris:opponent-game-over', data);
});
socket.on('disconnect', async () =>
{
// Nettoyage room tetris
if (socket.tetrisRoomCode) {
_tetrisLeave(socket);
}
console.log(`User disconnected: ${socket.user.username}`);
// Notify game room if player/spectator was in one
if (socket.gameRoomId) {
const roomId = socket.gameRoomId;
const dbRoomId = socket.gameRoomDbId;
// If spectator, just notify and leave
if (socket.isSpectator) {
socket.to(roomId).emit('game-spectator-left', {
username: socket.user.username
});
console.log(`Spectator ${socket.user.username} disconnected from ${roomId}`);
} else {
// Regular player disconnect
socket.to(roomId).emit('game-player-left', {
username: socket.user.username,
userId: socket.user.userId
});
// Get updated player list and broadcast
if (dbRoomId) {
try {
const players = await gameRoomService.getRoomPlayers(dbRoomId);
io.to(roomId).emit('game-players-updated', { players });
} catch (err) {
console.log('Room may have been deleted on disconnect:', err.message);
}
}
// Check if game should auto-stop due to single player
await checkAndStopSinglePlayerGame(io, roomId, dbRoomId);
// Broadcast updated rooms list
broadcastRoomsList(io);
}
}
});
});
}
// ── Helpers tetris duel ──────────────────────────────────────────────────
function _tetrisLeave(socket) {
const code = socket.tetrisRoomCode;
if (!code) return;
const room = tetrisRooms.get(code);
if (room) {
room.delete(socket.id);
// Notifier l'adversaire restant
for (const s of room.values()) {
s.emit('tetris:opponent-left');
s.emit('tetris:room-status', { status: 'waiting', players: [s.user.username] });
}
if (room.size === 0) tetrisRooms.delete(code);
}
socket.tetrisRoomCode = null;
}
function _tetrisRelayToOpponent(socket, event, data) {
const code = socket.tetrisRoomCode;
if (!code) return;
const room = tetrisRooms.get(code);
if (!room) return;
for (const [id, s] of room) {
if (id !== socket.id) s.emit(event, data);
}
}
export { broadcastRoomsList };
export default setupSocketIO;
@@ -0,0 +1,5 @@
FROM nginx:alpine
COPY src /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
@@ -0,0 +1,38 @@
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
# Frontend
location / {
try_files $uri /index.html;
}
# Backend API
location /api/ {
proxy_pass http://backend:3001;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# Socket.IO WebSocket proxying
location /socket.io/ {
proxy_pass http://backend:3001;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /avatar/ {
proxy_pass http://backend:3001/avatar/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_hide_header Content-Type;
add_header Cache-Control "public, max-age=3600";
}
}
@@ -0,0 +1,45 @@
Je veux faire un mode duel du tetris,
il est fonctionnel est solo.
Pour commencer, je vais devoir
creer une div qui sera le rendu
du joueur second joueur. il sera a droite de la
div principale qui lui meme sera legerement decale vers la gauche,
cette div sera grossierement identitique en style qui la div principale
je ne vais pas voir en temps reelle
les pieces du joueur qui tombe.
A la place quand le joueur 2 a mis une piece, il envoie un signal,
le joueur 1 envoie un signal egalement quand la piece tombe.
en mode duel, quand une ligne est clear, il envoie la ligne
moins la cellule qui a provoquer le clear (donc avec un trou pile
la ou la derniere piece est arrive) au joueur opposant.
Ce qui a pour effet de decaler toute les lignes vers le haut
a l'opposant pour recevoir la ligne recu avec le trou.
Pour se faire je vais devoir connecter les deux joueurs.
Il me faudra :
une class Duel il aura pour methode et membre:
action_queue: c'est un tableau qui repertorie tous les
signaux a traiter, c'est un tableau qui sera partager
entre le joueur1 et le joueur2
Syncronise_game: fonction qui traite les
actions de action_queue et qui verifie l'integrite du duel,
il va par exemple regarder l'etat du jeu de chaque joueur
pour voir s'il correspond bien a ce qui est attendu
il y aura different type de signaux.
Le signal bloc pose avec le type de bloc,
sa rotation et as position
le signal line cleared, avec le nombre de ligne
cleared et on ajoute le trou aussi
Aucune idee de si je dois utiliser web socket ou autre
@@ -0,0 +1,113 @@
/**
* Application entry point
* Initializes windows and handles menu interactions
*/
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';
/**
* Main application class
* Handles initialization and menu interactions
*/
class App {
constructor() {
this.initWindows();
this.initMenu();
this.initPage();
this.initEasterEgg();
}
/**
* Initializes all windows
*/
initWindows() {
new LoginWindow();
new GlobalChat();
new AvatarWindow();
new FriendsWindow();
new GameRoomWindow();
}
/**
* Initializes the main menu
* Uses event delegation instead of IDs
*/
initMenu() {
const menu = document.querySelector('.menu');
if (!menu) {
console.warn('Menu not found');
return;
}
const actionMap = {
'login': 'login',
'chat': 'chat',
'avatar': 'avatar',
'friends': 'friends'
};
// Event delegation on the menu
menu.addEventListener('click', (e) => {
const button = e.target.closest('.menu__item');
if (!button) return;
const action = button.dataset.action;
// Actions with associated windows
if (actionMap[action]) {
windowRegistry.toggle(actionMap[action]);
return;
}
});
}
initPage() {
const page = document.querySelector('.page');
if (!page) {
return;
}
const actionMap = {
'gameroom': 'gameroom'
};
// Event delegation on the menu
page.addEventListener('click', (e) => {
const button = e.target.closest('.page__item');
if (!button) return;
const action = button.dataset.action;
// Actions with associated windows
if (actionMap[action]) {
windowRegistry.toggle(actionMap[action]);
return;
}
});
}
/**
* Initializes the easter egg button
*/
initEasterEgg() {
const easterEgg = document.querySelector('.easter-egg');
if (easterEgg) {
easterEgg.addEventListener('click', () => {
alert('DONT CLICK!');
});
}
}
}
// Start the application when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => new App());
} else {
new App();
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1006 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

@@ -0,0 +1,271 @@
import { Window } from './windows.js';
import { API, STORAGE_KEYS, CSS } from './config.js';
import { eventBus, Events } from './events.js';
/**
* Avatar management window
* Allows viewing and modifying the user's avatar
*/
export class AvatarWindow extends Window {
constructor() {
super({
name: 'avatar',
title: 'Avatar',
cssClasses: ['avatar-window']
});
this.buildUI();
this.bindEvents();
if (localStorage.getItem(STORAGE_KEYS.AUTH_TOKEN)) {
this.loadAvatar();
}
// Listen for login events
eventBus.on(Events.USER_LOGGED_IN, () => this.loadAvatar());
}
/**
* Builds the user interface
*/
buildUI() {
// Avatar preview
this.preview = this.createElement('img', CSS.AVATAR_PREVIEW, {
alt: 'Avatar'
});
// Username display
this.username = this.createElement('div', CSS.AVATAR_USERNAME);
// Stats display
this.statsContainer = this.createElement('div', 'avatar__stats');
this.pointsDisplay = this.createElement('div', 'avatar__stat');
this.gamesPlayedDisplay = this.createElement('div', 'avatar__stat');
this.gamesWonDisplay = this.createElement('div', 'avatar__stat');
this.statsContainer.append(this.pointsDisplay, this.gamesPlayedDisplay, this.gamesWonDisplay);
// Hidden file input
this.fileInput = this.createElement('input', 'avatar__file-input', {
type: 'file',
accept: 'image/*'
});
// Controls
this.controls = this.createElement('div', CSS.AVATAR_CONTROLS);
this.chooseBtn = this.createElement('button', [CSS.BTN, CSS.BTN_SECONDARY], {
text: 'Choose image'
});
this.saveBtn = this.createElement('button', [CSS.BTN, CSS.BTN_PRIMARY], {
text: 'Save avatar'
});
this.refreshBtn = this.createElement('button', [CSS.BTN, CSS.BTN_SECONDARY], {
text: 'Refresh'
});
this.controls.append(this.chooseBtn, this.saveBtn, this.refreshBtn);
// Feedback message
this.message = this.createElement('div', CSS.MESSAGE);
// Assembly
this.body.append(
this.preview,
this.username,
this.statsContainer,
this.fileInput,
this.controls,
this.message
);
}
/**
* Attaches event handlers
*/
bindEvents() {
this.fileInput.addEventListener('change', (e) => this.handleFileSelect(e));
this.chooseBtn.addEventListener('click', () => this.fileInput.click());
this.saveBtn.addEventListener('click', () => this.uploadAvatar());
this.refreshBtn.addEventListener('click', () => this.loadAvatar());
}
/**
* Handles file selection
* @param {Event} e
*/
handleFileSelect(e) {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (ev) => {
this.preview.src = ev.target.result;
};
reader.readAsDataURL(file);
}
/**
* Decodes a JWT token and returns the payload
* @param {string} token
* @returns {object|null}
*/
decodeToken(token) {
try {
const payload = token.split('.')[1];
return JSON.parse(atob(payload));
} catch {
return null;
}
}
/**
* Loads avatar from the server
*/
async loadAvatar() {
const token = localStorage.getItem(STORAGE_KEYS.AUTH_TOKEN);
if (!token) {
console.log('No token, skipping avatar load');
return;
}
// Extract username from JWT token
const tokenData = this.decodeToken(token);
if (tokenData?.username) {
this.username.textContent = tokenData.username;
}
try {
const response = await fetch(API.AVATAR.GET, {
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`
}
});
if (!response.ok) {
console.warn('Failed to load avatar, status:', response.status);
return;
}
const data = await response.json();
if (data?.avatar_url) {
this.preview.src = data.avatar_url;
} else {
console.warn('Avatar URL not found in response');
}
} catch (error) {
console.error('Error loading avatar:', error);
}
// Load stats
await this.loadStats();
}
/**
* Loads player stats from the server
*/
async loadStats() {
const token = localStorage.getItem(STORAGE_KEYS.AUTH_TOKEN);
if (!token) return;
try {
const response = await fetch(API.STATS.ME, {
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`
}
});
if (!response.ok) {
console.warn('Failed to load stats, status:', response.status);
return;
}
const data = await response.json();
this.updateStatsDisplay(data);
} catch (error) {
console.error('Error loading stats:', error);
}
}
/**
* Updates the stats display
* @param {object} stats
*/
updateStatsDisplay(stats) {
this.pointsDisplay.innerHTML = `<span class="avatar__stat-label">Points:</span> <span class="avatar__stat-value">${stats.total_points || 0}</span>`;
this.gamesPlayedDisplay.innerHTML = `<span class="avatar__stat-label">Parties:</span> <span class="avatar__stat-value">${stats.games_played || 0}</span>`;
this.gamesWonDisplay.innerHTML = `<span class="avatar__stat-label">Victoires:</span> <span class="avatar__stat-value">${stats.games_won || 0}</span>`;
}
/**
* Uploads avatar to the server
*/
async uploadAvatar() {
const token = localStorage.getItem(STORAGE_KEYS.AUTH_TOKEN);
if (!token) {
this.showMessage('You must be logged in', 'error');
return;
}
const file = this.fileInput.files?.[0];
if (!file) {
this.showMessage('Select an image first', 'error');
return;
}
const formData = new FormData();
formData.append('avatar', file);
try {
this.showMessage('Uploading...', 'info');
const response = await fetch(API.AVATAR.UPLOAD, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`
},
body: formData
});
const data = await response.json();
if (!response.ok) {
const errorMsg = data?.error || data?.message || 'Upload failed';
this.showMessage(errorMsg, 'error');
return;
}
if (data?.avatar_url) {
this.preview.src = data.avatar_url;
}
this.showMessage('Avatar saved!', 'success');
eventBus.emit(Events.AVATAR_UPDATED, { url: data?.avatar_url });
} catch (error) {
console.error('Avatar upload error:', error);
this.showMessage('Upload error', 'error');
}
}
/**
* Displays a feedback message
* @param {string} text - Message text
* @param {'success'|'error'|'info'} type - Message type
*/
showMessage(text, type = 'info') {
this.message.textContent = text;
this.message.className = CSS.MESSAGE;
if (type === 'success') {
this.message.classList.add(CSS.MESSAGE_SUCCESS);
} else if (type === 'error') {
this.message.classList.add(CSS.MESSAGE_ERROR);
} else {
this.message.classList.add(CSS.MESSAGE_INFO);
}
}
}
@@ -0,0 +1,145 @@
/**
* Centralized application configuration
*/
// API Endpoints
export const API = {
AUTH: {
LOGIN: '/api/auth/login',
REGISTER: '/api/auth/register',
GITHUB: '/api/auth/github'
},
AVATAR: {
GET: '/api/avatar/me',
UPLOAD: '/api/avatar/upload'
},
FRIENDS: {
LIST: '/api/friends',
REQUESTS: '/api/friends/requests',
SEARCH: '/api/friends/search',
REQUEST: '/api/friends/request',
ACCEPT: '/api/friends/accept',
DECLINE: '/api/friends/decline'
},
ROOMS: {
LIST: '/api/rooms',
PLAYING: '/api/rooms/playing',
CREATE: '/api/rooms',
GET: (id) => `/api/rooms/${id}`,
PLAYERS: (id) => `/api/rooms/${id}/players`,
JOIN: (id) => `/api/rooms/${id}/join`,
LEAVE: (id) => `/api/rooms/${id}/leave`,
SPECTATE: (id) => `/api/rooms/${id}/spectate`,
LEAVE_SPECTATE: (id) => `/api/rooms/${id}/leave-spectate`,
CURRENT: '/api/rooms/current'
},
STATS: {
ME: '/api/stats/me',
USER: (username) => `/api/stats/user/${username}`,
LEADERBOARD: '/api/stats/leaderboard'
}
};
// localStorage keys
export const STORAGE_KEYS = {
AUTH_TOKEN: 'auth_token'
};
// Window configuration
export const WINDOW_CONFIG = {
DEFAULT_WIDTH: 320,
DEFAULT_HEIGHT: 220,
Z_INDEX_BASE: 100
};
// CSS classes (BEM convention)
export const CSS = {
// Menu
MENU: 'menu',
MENU_ITEM: 'menu__item',
MENU_ITEM_ACTIVE: 'menu__item--active',
// Windows
WINDOW: 'window',
WINDOW_VISIBLE: 'window--visible',
WINDOW_HEADER: 'window__header',
WINDOW_TITLE: 'window__title',
WINDOW_CLOSE: 'window__close',
WINDOW_BODY: 'window__body',
// Buttons
BTN: 'btn',
BTN_PRIMARY: 'btn--primary',
BTN_SECONDARY: 'btn--secondary',
BTN_SUCCESS: 'btn--success',
BTN_DANGER: 'btn--danger',
BTN_GITHUB: 'btn--github',
// Forms
INPUT: 'input',
INPUT_GROUP: 'input-group',
// Messages
MESSAGE: 'message',
MESSAGE_SUCCESS: 'message--success',
MESSAGE_ERROR: 'message--error',
MESSAGE_INFO: 'message--info',
// Chat
CHAT: 'chat',
CHAT_OUTPUT: 'chat__output',
CHAT_INPUT: 'chat__input',
CHAT_CONTROLS: 'chat__controls',
CHAT_MESSAGE: 'chat__message',
CHAT_SYSTEM: 'chat__system',
// Avatar
AVATAR: 'avatar',
AVATAR_PREVIEW: 'avatar__preview',
AVATAR_CONTROLS: 'avatar__controls',
AVATAR_USERNAME: 'avatar__username',
// Friends
FRIENDS: 'friends',
FRIENDS_TABS: 'friends__tabs',
FRIENDS_TAB: 'friends__tab',
FRIENDS_TAB_ACTIVE: 'friends__tab--active',
FRIENDS_CONTENT: 'friends__content',
FRIENDS_LIST: 'friends__list',
FRIENDS_ITEM: 'friends__item',
FRIENDS_AVATAR: 'friends__avatar',
FRIENDS_NAME: 'friends__name',
FRIENDS_ACTIONS: 'friends__actions',
FRIENDS_SEARCH: 'friends__search',
FRIENDS_EMPTY: 'friends__empty',
// Game Rooms
GAMEROOM: 'gameroom',
GAMEROOM_TABS: 'gameroom__tabs',
GAMEROOM_TAB: 'gameroom__tab',
GAMEROOM_TAB_ACTIVE: 'gameroom__tab--active',
GAMEROOM_CONTENT: 'gameroom__content',
GAMEROOM_LIST: 'gameroom__list',
GAMEROOM_ITEM: 'gameroom__item',
GAMEROOM_NAME: 'gameroom__name',
GAMEROOM_PLAYERS: 'gameroom__players',
GAMEROOM_ACTIONS: 'gameroom__actions',
GAMEROOM_CREATE: 'gameroom__create',
GAMEROOM_LOBBY: 'gameroom__lobby',
GAMEROOM_PLAYER_LIST: 'gameroom__player-list',
GAMEROOM_PLAYER: 'gameroom__player',
GAMEROOM_PLAYER_AVATAR: 'gameroom__player-avatar',
GAMEROOM_PLAYER_NAME: 'gameroom__player-name',
GAMEROOM_PLAYER_SCORE: 'gameroom__player-score'
};
// Colors (for reference, mainly used in CSS)
export const COLORS = {
PRIMARY: '#0066cc',
SUCCESS: '#3cff01',
ERROR: '#ff4d4d',
GITHUB: '#24292e',
BACKGROUND: '#000',
SURFACE: '#222',
TEXT: '#fff'
};
@@ -0,0 +1,148 @@
// ─────────────────────────────────────────────
// DUEL
// ─────────────────────────────────────────────
class Duel {
constructor(socket, tetrisGame, onStatusChange, onStart) {
this.socket = socket;
this.tetrisGame = tetrisGame;
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.roomCode = null;
this.isReady = false;
this._bindSocketEvents();
}
// ─── Connexion ────────────────────────────
join(roomCode) {
this.roomCode = roomCode;
this.socket.emit('tetris:join', { roomCode });
}
startDuel() {
if (!this.isReady) return;
this.socket.emit('tetris:start-duel');
}
leave() {
if (!this.roomCode) return;
this.socket.emit('tetris:leave');
this.roomCode = null;
this.isReady = false;
this.opponentGrid = this._emptyGrid();
this.opponentScore = 0;
}
// ─── Hooks appelés par tetris.js ──────────
onLocalBlockPlaced(grid, score) {
if (!this.isReady) return;
this.socket.emit('tetris:grid-update', { grid, score });
}
onLocalLinesCleared(count, holeCol) {
if (!this.isReady) return;
const garbageLines = [];
for (let i = 0; i < count; i++)
garbageLines.push(this._buildGarbageLine(holeCol));
this.socket.emit('tetris:lines-cleared', { count, holeCol, garbageLines });
}
onLocalGameOver(score) {
if (!this.isReady) return;
this.socket.emit('tetris:game-over', { score });
}
// ─── Traitement de la queue ───────────────
synchronize_game() {
while (this.action_queue.length > 0) {
const action = this.action_queue.shift();
this._processAction(action);
}
}
_processAction(action) {
switch (action.type) {
case 'GRID_UPDATE':
this.opponentGrid = action.grid;
this.opponentScore = action.score;
document.getElementById('opponent-score').textContent = action.score;
renderOpponent(this.opponentGrid);
break;
case 'LINES_CLEARED':
this.tetrisGame.addGarbageLines(action.garbageLines);
break;
case 'OPPONENT_GAME_OVER':
this._showOpponentOverlay('YOU WIN', action.score);
break;
}
}
// ─── Liaison socket ───────────────────────
_bindSocketEvents() {
this.socket.on('tetris:room-status', (data) => {
this.isReady = data.status === 'ready';
const opponentName = data.players.find(p => p !== this.socket.username) || 'Adversaire';
document.getElementById('opponent-name').textContent = opponentName;
this.onStatusChange(data.status, opponentName);
});
this.socket.on('tetris:opponent-joined', (data) => {
document.getElementById('opponent-name').textContent = data.username;
});
this.socket.on('tetris:opponent-left', () => {
this.isReady = false;
this.onStatusChange('waiting', null);
this._showOpponentOverlay('DÉCONNECTÉ');
});
this.socket.on('tetris:grid-update', (data) => {
this.action_queue.push({ type: 'GRID_UPDATE', grid: data.grid, score: data.score });
});
this.socket.on('tetris:lines-cleared', (data) => {
this.action_queue.push({ type: 'LINES_CLEARED', garbageLines: data.garbageLines });
});
this.socket.on('tetris:opponent-game-over', (data) => {
this.action_queue.push({ type: 'OPPONENT_GAME_OVER', score: data.score });
});
this.socket.on('tetris:start-duel', () => {
if (this.onStart) this.onStart();
});
}
// ─── Utilitaires ─────────────────────────
_buildGarbageLine(holeCol) {
return Array.from({ length: 10 }, (_, i) => i === holeCol ? 0 : 8);
}
_emptyGrid() {
return Array.from({ length: 20 }, () => Array(10).fill(0));
}
_showOpponentOverlay(title, score) {
const overlayEl = document.getElementById('overlay-opponent');
document.getElementById('overlay-opponent-title').textContent = title;
const scoreEl = document.getElementById('overlay-opponent-score');
if (scoreEl) scoreEl.textContent = score !== undefined ? `Score : ${score}` : '';
overlayEl.classList.add('visible');
}
hideOpponentOverlay() {
document.getElementById('overlay-opponent').classList.remove('visible');
}
}
@@ -0,0 +1,114 @@
/**
* DOM element utilities
* This module provides helper functions for creating elements
* without depending on specific HTML IDs
*/
/**
* Creates a DOM element with options
* @param {string} tag - HTML tag
* @param {Object} options - Configuration options
* @param {string|string[]} [options.classes] - CSS classes
* @param {string} [options.text] - Element text
* @param {string} [options.html] - Inner HTML
* @param {Object} [options.attrs] - Additional attributes
* @param {Object} [options.style] - Inline styles (avoid using)
* @param {Object} [options.events] - Events to attach
* @param {HTMLElement[]} [options.children] - Child elements
* @returns {HTMLElement}
*/
export function createElement(tag, options = {}) {
const element = document.createElement(tag);
// Classes
if (options.classes) {
const classes = Array.isArray(options.classes) ? options.classes : [options.classes];
element.className = classes.filter(Boolean).join(' ');
}
// Text
if (options.text) {
element.textContent = options.text;
}
// HTML
if (options.html) {
element.innerHTML = options.html;
}
// Attributes
if (options.attrs) {
Object.entries(options.attrs).forEach(([key, value]) => {
element.setAttribute(key, value);
});
}
// Styles (use sparingly)
if (options.style) {
Object.assign(element.style, options.style);
}
// Events
if (options.events) {
Object.entries(options.events).forEach(([event, handler]) => {
element.addEventListener(event, handler);
});
}
// Children
if (options.children) {
options.children.forEach(child => {
if (child) element.appendChild(child);
});
}
return element;
}
/**
* Selects an element by its data-attribute
* @param {string} attr - Attribute name (without 'data-')
* @param {string} value - Value to search for
* @param {HTMLElement} [parent=document] - Parent element
* @returns {HTMLElement|null}
*/
export function findByData(attr, value, parent = document) {
return parent.querySelector(`[data-${attr}="${value}"]`);
}
/**
* Selects all elements by their data-attribute
* @param {string} attr - Attribute name (without 'data-')
* @param {string} [value] - Value to search for (optional)
* @param {HTMLElement} [parent=document] - Parent element
* @returns {HTMLElement[]}
*/
export function findAllByData(attr, value, parent = document) {
const selector = value ? `[data-${attr}="${value}"]` : `[data-${attr}]`;
return Array.from(parent.querySelectorAll(selector));
}
/**
* Adds or removes a class based on a condition
* @param {HTMLElement} element
* @param {string} className
* @param {boolean} condition
*/
export function toggleClass(element, className, condition) {
if (condition) {
element.classList.add(className);
} else {
element.classList.remove(className);
}
}
/**
* Escapes HTML to prevent XSS
* @param {string} text
* @returns {string}
*/
export function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
@@ -0,0 +1,98 @@
/**
* EventBus - Centralized event system
* Enables communication between modules without circular dependencies
*/
class EventBus {
constructor() {
this.listeners = new Map();
}
/**
* Subscribe to an event
* @param {string} event - Event name
* @param {Function} callback - Function to call
* @returns {Function} Unsubscribe function
*/
on(event, callback) {
if (!this.listeners.has(event)) {
this.listeners.set(event, new Set());
}
this.listeners.get(event).add(callback);
return () => this.off(event, callback);
}
/**
* Subscribe to an event once
* @param {string} event - Event name
* @param {Function} callback - Function to call
*/
once(event, callback) {
const wrapper = (data) => {
this.off(event, wrapper);
callback(data);
};
this.on(event, wrapper);
}
/**
* Unsubscribe from an event
* @param {string} event - Event name
* @param {Function} callback - Function to remove
*/
off(event, callback) {
if (this.listeners.has(event)) {
this.listeners.get(event).delete(callback);
}
}
/**
* Emit an event
* @param {string} event - Event name
* @param {*} data - Data to transmit
*/
emit(event, data) {
console.log(`EventBus: Emitting event "${event}"`, data);
if (this.listeners.has(event)) {
const listeners = this.listeners.get(event);
console.log(`EventBus: "${event}" has ${listeners.size} listener(s)`);
this.listeners.get(event).forEach(callback => {
try {
callback(data);
} catch (error) {
console.error(`Error in listener for "${event}":`, error);
}
});
} else {
console.warn(`EventBus: No listeners for event "${event}"`);
}
}
}
// Exported singleton instance
export const eventBus = new EventBus();
// Available events (for documentation and autocompletion)
export const Events = {
// Authentication
USER_LOGGED_IN: 'user:logged-in',
USER_LOGGED_OUT: 'user:logged-out',
USER_REGISTERED: 'user:registered',
// Windows
WINDOW_OPENED: 'window:opened',
WINDOW_CLOSED: 'window:closed',
// Avatar
AVATAR_UPDATED: 'avatar:updated',
// Chat
CHAT_CONNECTED: 'chat:connected',
CHAT_DISCONNECTED: 'chat:disconnected',
CHAT_MESSAGE_RECEIVED: 'chat:message-received',
// Game Rooms
ROOM_JOINED: 'room:joined',
ROOM_LEFT: 'room:left',
ROOM_CREATED: 'room:created'
};
@@ -0,0 +1,453 @@
import { Window } from './windows.js';
import { API, STORAGE_KEYS, CSS } from './config.js';
import { eventBus, Events } from './events.js';
/**
* Friends management window
* Allows viewing friends, requests, and searching users
*/
export class FriendsWindow extends Window {
constructor() {
super({
name: 'friends',
title: 'Amis',
cssClasses: ['friends-window']
});
this.currentTab = 'friends';
this.buildUI();
this.bindEvents();
eventBus.on(Events.USER_LOGGED_IN, () => this.loadCurrentTab());
}
/**
* Builds the user interface
*/
buildUI() {
// Tabs
this.tabs = this.createElement('div', CSS.FRIENDS_TABS);
this.friendsTab = this.createElement('button', [CSS.FRIENDS_TAB, CSS.FRIENDS_TAB_ACTIVE], {
text: 'Amis'
});
this.friendsTab.dataset.tab = 'friends';
this.requestsTab = this.createElement('button', CSS.FRIENDS_TAB, {
text: 'Demandes'
});
this.requestsTab.dataset.tab = 'requests';
this.searchTab = this.createElement('button', CSS.FRIENDS_TAB, {
text: 'Rechercher'
});
this.searchTab.dataset.tab = 'search';
this.tabs.append(this.friendsTab, this.requestsTab, this.searchTab);
// Content area
this.content = this.createElement('div', CSS.FRIENDS_CONTENT);
// Search input (hidden by default)
this.searchContainer = this.createElement('div', CSS.FRIENDS_SEARCH);
this.searchInput = this.createElement('input', CSS.INPUT, {
type: 'text',
placeholder: 'Rechercher un utilisateur...'
});
this.searchBtn = this.createElement('button', [CSS.BTN, CSS.BTN_PRIMARY], {
text: 'Chercher'
});
this.searchContainer.append(this.searchInput, this.searchBtn);
this.searchContainer.style.display = 'none';
// List container
this.list = this.createElement('div', CSS.FRIENDS_LIST);
// Message
this.message = this.createElement('div', CSS.MESSAGE);
this.content.append(this.searchContainer, this.list, this.message);
// Assembly
this.body.append(this.tabs, this.content);
}
/**
* Attaches event handlers
*/
bindEvents() {
this.tabs.addEventListener('click', (e) => {
const tab = e.target.closest(`.${CSS.FRIENDS_TAB}`);
if (tab) {
this.switchTab(tab.dataset.tab);
}
});
this.searchBtn.addEventListener('click', () => this.searchUsers());
this.searchInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') this.searchUsers();
});
}
/**
* Switches between tabs
*/
switchTab(tabName) {
this.currentTab = tabName;
// Update tab styles
[this.friendsTab, this.requestsTab, this.searchTab].forEach(tab => {
tab.classList.toggle(CSS.FRIENDS_TAB_ACTIVE, tab.dataset.tab === tabName);
});
// Show/hide search
this.searchContainer.style.display = tabName === 'search' ? 'flex' : 'none';
this.loadCurrentTab();
}
/**
* Loads data for current tab
*/
loadCurrentTab() {
switch (this.currentTab) {
case 'friends':
this.loadFriends();
break;
case 'requests':
this.loadRequests();
break;
case 'search':
this.list.innerHTML = '';
this.showMessage('Entrez un nom pour rechercher', 'info');
break;
}
}
/**
* Gets auth headers
*/
getHeaders() {
const token = localStorage.getItem(STORAGE_KEYS.AUTH_TOKEN);
return {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
};
}
/**
* Loads friends list
*/
async loadFriends() {
const token = localStorage.getItem(STORAGE_KEYS.AUTH_TOKEN);
if (!token) {
this.showMessage('Connectez-vous pour voir vos amis', 'info');
return;
}
try {
const response = await fetch(API.FRIENDS.LIST, {
headers: this.getHeaders()
});
const data = await response.json();
if (!response.ok) {
this.showMessage(data.error || 'Erreur', 'error');
return;
}
this.renderFriendsList(data.friends || []);
} catch (error) {
console.error('Load friends error:', error);
this.showMessage('Erreur de connexion', 'error');
}
}
/**
* Loads pending requests
*/
async loadRequests() {
const token = localStorage.getItem(STORAGE_KEYS.AUTH_TOKEN);
if (!token) {
this.showMessage('Connectez-vous pour voir les demandes', 'info');
return;
}
try {
const response = await fetch(API.FRIENDS.REQUESTS, {
headers: this.getHeaders()
});
const data = await response.json();
if (!response.ok) {
this.showMessage(data.error || 'Erreur', 'error');
return;
}
this.renderRequestsList(data.requests || []);
} catch (error) {
console.error('Load requests error:', error);
this.showMessage('Erreur de connexion', 'error');
}
}
/**
* Searches users
*/
async searchUsers() {
const query = this.searchInput.value.trim();
if (!query) {
this.showMessage('Entrez un nom pour rechercher', 'info');
return;
}
const token = localStorage.getItem(STORAGE_KEYS.AUTH_TOKEN);
if (!token) {
this.showMessage('Connectez-vous pour rechercher', 'info');
return;
}
try {
const response = await fetch(`${API.FRIENDS.SEARCH}?q=${encodeURIComponent(query)}`, {
headers: this.getHeaders()
});
const data = await response.json();
if (!response.ok) {
this.showMessage(data.error || 'Erreur', 'error');
return;
}
this.renderSearchResults(data.users || []);
} catch (error) {
console.error('Search error:', error);
this.showMessage('Erreur de connexion', 'error');
}
}
/**
* Renders friends list
*/
renderFriendsList(friends) {
this.list.innerHTML = '';
this.message.textContent = '';
if (friends.length === 0) {
this.showMessage('Aucun ami pour le moment', 'info');
return;
}
friends.forEach(friend => {
const item = this.createFriendItem(friend, 'friend');
this.list.appendChild(item);
});
}
/**
* Renders requests list
*/
renderRequestsList(requests) {
this.list.innerHTML = '';
this.message.textContent = '';
if (requests.length === 0) {
this.showMessage('Aucune demande en attente', 'info');
return;
}
requests.forEach(request => {
const item = this.createFriendItem(request, 'request');
this.list.appendChild(item);
});
}
/**
* Renders search results
*/
renderSearchResults(users) {
this.list.innerHTML = '';
this.message.textContent = '';
if (users.length === 0) {
this.showMessage('Aucun utilisateur trouve', 'info');
return;
}
users.forEach(user => {
const item = this.createFriendItem(user, 'search');
this.list.appendChild(item);
});
}
/**
* Creates a friend/user item
*/
createFriendItem(user, type) {
const item = this.createElement('div', CSS.FRIENDS_ITEM);
const avatar = this.createElement('img', CSS.FRIENDS_AVATAR, {
alt: user.username
});
avatar.src = user.avatar_url || '/avatar/default.png';
const infoContainer = this.createElement('div', 'friends__info');
const name = this.createElement('span', CSS.FRIENDS_NAME, {
text: user.username
});
infoContainer.appendChild(name);
// Show stats for friends
if (type === 'friend' && user.total_points !== undefined) {
const stats = this.createElement('span', 'friends__stats', {
text: `${user.total_points || 0} pts`
});
infoContainer.appendChild(stats);
}
const actions = this.createElement('div', CSS.FRIENDS_ACTIONS);
if (type === 'friend') {
const removeBtn = this.createElement('button', [CSS.BTN, CSS.BTN_DANGER], {
text: 'Retirer'
});
removeBtn.addEventListener('click', () => this.removeFriend(user.id));
actions.appendChild(removeBtn);
} else if (type === 'request') {
const acceptBtn = this.createElement('button', [CSS.BTN, CSS.BTN_SUCCESS], {
text: 'Accepter'
});
acceptBtn.addEventListener('click', () => this.acceptRequest(user.id));
const declineBtn = this.createElement('button', [CSS.BTN, CSS.BTN_DANGER], {
text: 'Refuser'
});
declineBtn.addEventListener('click', () => this.declineRequest(user.id));
actions.append(acceptBtn, declineBtn);
} else if (type === 'search') {
const addBtn = this.createElement('button', [CSS.BTN, CSS.BTN_PRIMARY], {
text: 'Ajouter'
});
addBtn.addEventListener('click', () => this.sendRequest(user.id, addBtn));
actions.appendChild(addBtn);
}
item.append(avatar, infoContainer, actions);
return item;
}
/**
* Sends a friend request
*/
async sendRequest(userId, button) {
try {
const response = await fetch(`${API.FRIENDS.REQUEST}/${userId}`, {
method: 'POST',
headers: this.getHeaders()
});
const data = await response.json();
if (!response.ok) {
this.showMessage(data.error || 'Erreur', 'error');
return;
}
button.textContent = 'Envoye';
button.disabled = true;
this.showMessage('Demande envoyee', 'success');
} catch (error) {
console.error('Send request error:', error);
this.showMessage('Erreur', 'error');
}
}
/**
* Accepts a friend request
*/
async acceptRequest(userId) {
try {
const response = await fetch(`${API.FRIENDS.ACCEPT}/${userId}`, {
method: 'POST',
headers: this.getHeaders()
});
const data = await response.json();
if (!response.ok) {
this.showMessage(data.error || 'Erreur', 'error');
return;
}
this.showMessage('Ami ajoute', 'success');
this.loadRequests();
} catch (error) {
console.error('Accept request error:', error);
this.showMessage('Erreur', 'error');
}
}
/**
* Declines a friend request
*/
async declineRequest(userId) {
try {
const response = await fetch(`${API.FRIENDS.DECLINE}/${userId}`, {
method: 'POST',
headers: this.getHeaders()
});
const data = await response.json();
if (!response.ok) {
this.showMessage(data.error || 'Erreur', 'error');
return;
}
this.showMessage('Demande refusee', 'success');
this.loadRequests();
} catch (error) {
console.error('Decline request error:', error);
this.showMessage('Erreur', 'error');
}
}
/**
* Removes a friend
*/
async removeFriend(userId) {
try {
const response = await fetch(`${API.FRIENDS.LIST}/${userId}`, {
method: 'DELETE',
headers: this.getHeaders()
});
const data = await response.json();
if (!response.ok) {
this.showMessage(data.error || 'Erreur', 'error');
return;
}
this.showMessage('Ami retire', 'success');
this.loadFriends();
} catch (error) {
console.error('Remove friend error:', error);
this.showMessage('Erreur', 'error');
}
}
/**
* Shows a message
*/
showMessage(text, type = 'info') {
this.message.textContent = text;
this.message.className = CSS.MESSAGE;
if (type === 'success') {
this.message.classList.add(CSS.MESSAGE_SUCCESS);
} else if (type === 'error') {
this.message.classList.add(CSS.MESSAGE_ERROR);
} else {
this.message.classList.add(CSS.MESSAGE_INFO);
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,34 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Lobby</title>
<link rel="stylesheet" href="game.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">Lobby</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>
</nav>
<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>
</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>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,320 @@
import { Window } from './windows.js';
import { STORAGE_KEYS, CSS } from './config.js';
import { eventBus, Events } from './events.js';
/**
* Global chat window
* Uses Socket.IO for real-time communication
*/
export class GlobalChat extends Window {
constructor() {
super({
name: 'chat',
title: 'Global Chat',
cssClasses: ['chat']
});
this.socket = null;
this.connected = false;
this.friendIds = new Set();
this.currentUserId = null;
this.currentUsername = null;
this.buildUI();
this.bindEvents();
}
/**
* Builds the user interface
*/
buildUI() {
// Message display area
this.output = this.createElement('div', CSS.CHAT_OUTPUT);
// Input container
this.inputContainer = this.createElement('div', 'chat__input-container');
this.input = this.createElement('input', [CSS.INPUT, CSS.CHAT_INPUT], {
type: 'text',
placeholder: 'Type your message...'
});
this.sendBtn = this.createElement('button', [CSS.BTN, CSS.BTN_PRIMARY], {
text: 'Send'
});
this.inputContainer.append(this.input, this.sendBtn);
// Connection controls
this.controls = this.createElement('div', CSS.CHAT_CONTROLS);
this.connectBtn = this.createElement('button', [CSS.BTN, CSS.BTN_SUCCESS], {
text: 'Connect'
});
this.reconnectBtn = this.createElement('button', [CSS.BTN, CSS.BTN_PRIMARY], {
text: 'Reconnect'
});
this.controls.append(this.connectBtn, this.reconnectBtn);
// Assembly
this.body.append(this.output, this.inputContainer, this.controls);
}
/**
* Attaches event handlers
*/
bindEvents() {
this.sendBtn.addEventListener('click', () => this.sendMessage());
this.connectBtn.addEventListener('click', () => this.connect());
this.reconnectBtn.addEventListener('click', () => this.reconnect());
// Send with Enter
this.input.addEventListener('keypress', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
this.sendMessage();
}
});
}
/**
* Displays a system message
* @param {string} text - Message text
* @param {'info'|'error'|'success'} type - Message type
*/
addSystemMessage(text, type = 'info') {
const msg = this.createElement('div', CSS.CHAT_SYSTEM);
if (type === 'error') {
msg.classList.add('chat__system--error');
} else if (type === 'success') {
msg.classList.add('chat__system--success');
}
msg.textContent = text;
this.output.appendChild(msg);
this.scrollToBottom();
}
/**
* Displays a chat message
* @param {string} username - User name
* @param {string} content - Message content
* @param {boolean} isOwn - Is this the current user's message
* @param {boolean} isFriend - Is this user a friend
*/
addChatMessage(username, content, isOwn = false, isFriend = false) {
const msg = this.createElement('div', CSS.CHAT_MESSAGE);
if (isOwn) {
msg.classList.add('chat__message--own');
}
const friendIndicator = isFriend ? '<span class="chat__friend-indicator"></span>' : '';
msg.innerHTML = `${friendIndicator}<strong>${this.escapeHtml(username)}:</strong> ${this.escapeHtml(content)}`;
this.output.appendChild(msg);
this.scrollToBottom();
}
/**
* Escapes HTML to prevent XSS
* @param {string} text
* @returns {string}
*/
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
/**
* Scrolls the message area to the bottom
*/
scrollToBottom() {
this.output.scrollTop = this.output.scrollHeight;
}
/**
* Sends a message
*/
sendMessage() {
const content = this.input.value.trim();
if (!content) return;
if (!this.socket?.connected) {
this.addSystemMessage('Error: you are not connected to the global chat', 'error');
return;
}
this.socket.emit('chat-message', { content });
this.addChatMessage('Me', content, true);
this.input.value = '';
}
/**
* Reconnects to the server
*/
async reconnect() {
if (this.socket) {
try {
this.socket.close();
} catch (e) {
// Ignore
}
this.socket = null;
}
this.connected = false;
this.addSystemMessage('Reconnecting...');
await this.connect();
}
decodeToken(token)
{
try
{
const payload = token.split('.')[1];
return (JSON.parse(atob(payload)));
}
catch
{
return (null);
}
}
/**
* Connects to the Socket.IO server
*/
async connect() {
const token = localStorage.getItem(STORAGE_KEYS.AUTH_TOKEN);
if (!token) {
this.addSystemMessage('Error: you must be logged in to use the global chat', 'error');
return;
}
const tokenData = this.decodeToken(token);
if (tokenData) {
this.currentUserId = tokenData.id || tokenData.userId || tokenData.user_id || tokenData.sub || null;
this.currentUsername = tokenData.username || tokenData.name || null;
}
if (this.socket?.connected) {
this.addSystemMessage('Already connected to global chat');
return;
}
// Load Socket.IO if needed
await this.loadSocketIO();
const ioConfig = {
auth: { token },
reconnection: true,
reconnectionAttempts: 5,
reconnectionDelay: 1000,
transports: ['websocket', 'polling']
};
// Optional alternative port
const altPort = window.GLOBAL_CHAT_ALT_PORT;
if (altPort) {
const host = location.hostname || 'localhost';
this.socket = io(`http://${host}:${altPort}`, ioConfig);
} else {
this.socket = io(ioConfig);
}
this.setupSocketListeners();
}
/**
* Loads the Socket.IO script if needed
*/
async loadSocketIO() {
if (window.io) return;
return new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = '/socket.io/socket.io.js';
script.onload = () => {
console.log('Socket.IO loaded');
resolve();
};
script.onerror = () => {
console.error('Failed to load Socket.IO');
reject(new Error('Socket.IO load failed'));
};
document.head.appendChild(script);
});
}
/**
* Sets up Socket.IO listeners
*/
setupSocketListeners() {
this.socket.on('connect', () => {
console.log('Socket connected, ID:', this.socket.id);
this.connected = true;
this.output.innerHTML = '';
this.addSystemMessage('Connected to global chat', 'success');
eventBus.emit(Events.CHAT_CONNECTED, { socketId: this.socket.id });
});
this.socket.on('connect_error', (err) => {
console.error('Socket connection error:', err.message);
this.addSystemMessage(`Connection error: ${err.message}`, 'error');
});
this.socket.on('disconnect', (reason) => {
console.log('Socket disconnected:', reason);
this.connected = false;
this.addSystemMessage(`Disconnected (${reason})`);
eventBus.emit(Events.CHAT_DISCONNECTED, { reason });
});
this.socket.on('chat-init', (data) => {
this.friendIds = new Set(data.friendIds || []);
// Display recent messages
data.messages.forEach(msg => {
const isOwn = this.isOwnMessage(msg);
const isFriend = !isOwn && this.friendIds.has(msg.sender_id);
const displayUsername = isOwn ? 'Me' : msg.username;
this.addChatMessage(displayUsername, msg.content, isOwn, isFriend);
});
});
this.socket.on('chat-message', (msg) => {
const isOwn = this.isOwnMessage(msg);
if (isOwn)
return;
const isFriend = this.friendIds.has(msg.sender_id);
this.addChatMessage(msg.username, msg.content, false, isFriend);
eventBus.emit(Events.CHAT_MESSAGE_RECEIVED, msg);
});
}
isOwnMessage(msg)
{
if (this.currentUserId !== null && msg.sender_id !== undefined && msg.sender_id !== null)
{
if (String(this.currentUserId) === String(msg.sender_id))
return (true);
}
if (this.currentUsername && msg.username)
{
if (this.currentUsername.toLowerCase() === msg.username.toLowerCase())
return (true);
}
return (false);
}
}
@@ -0,0 +1,682 @@
/* ============================================
TRANSCENDENCE - Main Stylesheet
Convention: BEM (Block__Element--Modifier)
============================================ */
/* ============================================
CSS VARIABLES
============================================ */
:root {
--color-primary: #0066cc;
--color-primary-hover: #0052a3;
--color-success: #3cff01;
--color-success-dark: #28a745;
--color-error: #ff4d4d;
--color-warning: #ffc107;
--color-github: #24292e;
--color-bg: #a3a3a3;
--app-background-base: radial-gradient(
circle at top,
#000000,
#4d4d4d
);
--app-background-image: url("./assets/background.png");
--color-surface: #222;
--color-surface-light: #333;
--color-text: #fff;
--color-text-muted: #aaa;
--font-size-base: 10px;
--font-size-sm: 1.2rem;
--font-size-md: 1.4rem;
--font-size-lg: 1.6rem;
--font-size-xl: 3rem;
--spacing-xs: 4px;
--spacing-sm: 8px;
--spacing-md: 12px;
--spacing-lg: 16px;
--spacing-xl: 24px;
--radius-sm: 4px;
--radius-md: 6px;
--radius-lg: 12px;
--radius-full: 50%;
--shadow-sm: 0 2px 4px rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 8px rgba(0, 0, 0, 0.5);
--shadow-lg: 0 8px 16px rgba(0, 0, 0, 0.5);
--transition-fast: 150ms ease;
--transition-normal: 250ms ease;
--z-menu: 2;
--z-window: 100;
--z-modal: 200;
}
/* ============================================
RESET & BASE
============================================ */
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
height: 100%;
background-image:
var(--app-background-image),
var(--app-background-base);
background-size:
contain,
cover;
background-position:
center,
center;
background-repeat:
no-repeat,
no-repeat;
}
body {
margin: 0;
width: 70%;
min-width: 800px;
margin: 0 auto;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
color: var(--color-text);
line-height: 1.5;
}
/* ============================================
TYPOGRAPHY
============================================ */
.title {
position: absolute;
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: rgba(248, 252, 2, 0.6);
margin: 0;
padding: var(--spacing-md);
/* Rectangle + rounded corners */
background-color: rgba(247, 7, 67, 0.6);
border: 2px solid rgba(0, 0, 0, 0.6);
border-radius: 15px;
}
/* ============================================
MENU
============================================ */
.menu {
position: fixed;
top: 0;
left: 50px;
padding: 0;
margin: 0;
z-index: var(--z-menu);
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
}
.menu__item {
background: var(--color-surface);
color: var(--color-text);
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;
}
.menu__item:hover {
background: var(--color-surface-light);
font-size: var(--font-size-lg);
}
.menu__item--active {
background: var(--color-primary);
border-color: var(--color-primary);
}
/* ============================================
GAME
============================================ */
.game {
position: fixed;
top: 0;
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: 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;
}
.game__item:hover {
background: var(--color-surface-light);
font-size: var(--font-size-lg);
}
.game__item--active {
background: var(--color-primary);
border-color: var(--color-primary);
}
/* ============================================
BUTTONS
============================================ */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: var(--spacing-sm) var(--spacing-md);
font-size: var(--font-size-md);
font-weight: 500;
border: none;
border-radius: var(--radius-md);
cursor: pointer;
transition: all var(--transition-fast);
text-decoration: none;
}
.btn:hover {
opacity: 0.9;
transform: translateY(-1px);
}
.btn:active {
transform: translateY(0);
}
.btn--primary {
background: var(--color-primary);
color: var(--color-text);
}
.btn--primary:hover {
background: var(--color-primary-hover);
}
.btn--secondary {
background: var(--color-surface-light);
color: var(--color-text);
}
.btn--success {
background: var(--color-success-dark);
color: var(--color-text);
}
.btn--danger {
background: var(--color-error);
color: var(--color-text);
}
.btn--github {
background: var(--color-github);
color: var(--color-text);
}
.btn--ghost {
background: transparent;
color: var(--color-text);
border: 1px solid var(--color-surface-light);
}
/* ============================================
INPUTS
============================================ */
.input {
width: 100%;
padding: var(--spacing-sm) var(--spacing-md);
font-size: var(--font-size-md);
background: var(--color-surface);
color: var(--color-text);
border: 1px solid var(--color-surface-light);
border-radius: var(--radius-md);
transition: border-color var(--transition-fast);
}
.input:focus {
outline: none;
border-color: var(--color-primary);
}
.input::placeholder {
color: var(--color-text-muted);
}
.input-group {
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
}
/* ============================================
WINDOWS
============================================ */
.window {
position: fixed;
top: 50%;
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);
}
.window--visible {
display: flex;
}
.window--left {
left: 25%;
}
.window--right {
left: 75%;
}
.window__header {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--spacing-sm) var(--spacing-md);
background: var(--color-surface);
cursor: move;
user-select: none;
}
.window__title {
font-weight: 500;
font-size: var(--font-size-md);
}
.window__close {
cursor: pointer;
font-size: var(--font-size-lg);
opacity: 0.8;
transition: opacity var(--transition-fast);
background: none;
border: none;
color: var(--color-text);
padding: 0;
line-height: 1;
}
.window__close:hover {
opacity: 1;
}
.window__body {
padding: var(--spacing-md);
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
flex: 1;
overflow: auto;
}
/* ============================================
MESSAGES
============================================ */
.message {
font-size: var(--font-size-sm);
padding: var(--spacing-xs);
border-radius: var(--radius-sm);
}
.message--success {
color: var(--color-success);
}
.message--error {
color: var(--color-error);
}
.message--info {
color: var(--color-text-muted);
}
/* ============================================
LOGIN WINDOW
============================================ */
.login {
width: 320px;
}
.login__form {
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
}
.login__actions {
display: flex;
gap: var(--spacing-sm);
margin-top: var(--spacing-xs);
}
.login__divider {
display: flex;
align-items: center;
gap: var(--spacing-sm);
color: var(--color-text-muted);
font-size: var(--font-size-sm);
margin: var(--spacing-sm) 0;
}
.login__divider::before,
.login__divider::after {
content: '';
flex: 1;
height: 1px;
background: var(--color-surface-light);
}
/* ============================================
CHAT WINDOW
============================================ */
.chat {
width: 380px;
height: 400px;
}
.chat__output {
flex: 1;
overflow-y: auto;
padding: var(--spacing-sm);
background: var(--color-surface);
border-radius: var(--radius-md);
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
min-height: 150px;
}
.chat__message {
padding: var(--spacing-xs) var(--spacing-sm);
background: var(--color-surface-light);
border-radius: var(--radius-sm);
font-size: var(--font-size-sm);
}
.chat__message--own {
background: var(--color-primary);
align-self: flex-end;
}
.chat__friend-indicator {
display: inline-block;
width: 8px;
height: 8px;
background-color: var(--color-success);
border-radius: 50%;
margin-right: var(--spacing-xs);
vertical-align: middle;
}
.chat__system {
color: var(--color-text-muted);
font-size: var(--font-size-sm);
font-style: italic;
text-align: center;
}
.chat__system--error {
color: var(--color-error);
}
.chat__system--success {
color: var(--color-success);
}
.chat__input-container {
display: flex;
gap: var(--spacing-sm);
margin-top: var(--spacing-sm);
}
.chat__input {
flex: 1;
}
.chat__controls {
display: flex;
gap: var(--spacing-sm);
margin-top: var(--spacing-sm);
}
/* ============================================
AVATAR WINDOW
============================================ */
.avatar-window {
width: 360px;
}
.avatar__preview {
width: 120px;
height: 120px;
object-fit: cover;
border-radius: var(--radius-full);
border: 3px solid var(--color-text);
box-shadow: var(--shadow-md);
background: var(--color-surface);
align-self: center;
}
.avatar__username {
font-size: var(--font-size-lg);
font-weight: 600;
text-align: center;
color: var(--color-text);
margin-top: var(--spacing-sm);
}
.avatar__controls {
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
align-items: center;
}
.avatar__file-input {
display: none;
}
/* ============================================
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
============================================ */
.hidden {
display: none !important;
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
border: 0;
}
.text-center {
text-align: center;
}
.flex-center {
display: flex;
align-items: center;
justify-content: center;
}
/* ============================================
FRIENDS WINDOW
============================================ */
.friends-window {
width: 400px;
height: 450px;
}
.friends__tabs {
display: flex;
gap: var(--spacing-xs);
margin-bottom: var(--spacing-sm);
}
.friends__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);
}
.friends__tab:hover {
background: var(--color-surface-light);
}
.friends__tab--active {
background: var(--color-primary);
border-color: var(--color-primary);
}
.friends__content {
display: flex;
flex-direction: column;
flex: 1;
overflow: hidden;
}
.friends__search {
display: flex;
gap: var(--spacing-sm);
margin-bottom: var(--spacing-sm);
}
.friends__search .input {
flex: 1;
}
.friends__list {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
}
.friends__item {
display: flex;
align-items: center;
gap: var(--spacing-sm);
padding: var(--spacing-sm);
background: var(--color-surface);
border-radius: var(--radius-md);
}
.friends__avatar {
width: 40px;
height: 40px;
border-radius: var(--radius-full);
object-fit: cover;
border: 2px solid var(--color-surface-light);
}
.friends__name {
flex: 1;
font-size: var(--font-size-md);
font-weight: 500;
}
.friends__actions {
display: flex;
gap: var(--spacing-xs);
}
.friends__actions .btn {
padding: var(--spacing-xs) var(--spacing-sm);
font-size: var(--font-size-sm);
}
.friends__empty {
text-align: center;
color: var(--color-text-muted);
padding: var(--spacing-lg);
}
@@ -0,0 +1,31 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Transcendence.io</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>
<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>
</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.html'">Start new game</button>
<button class="game__item" data-action="tetris" aria-label="Tetris"
onclick="window.location.href='tetris.html'">Tetris</button>
</nav>
<script type="module" src="app.js"></script>
</body>
</html>
@@ -0,0 +1,239 @@
import { Window } from './windows.js';
import { API, STORAGE_KEYS, CSS } from './config.js';
import { eventBus, Events } from './events.js';
/**
* Login and registration window
* Emits events instead of directly importing other windows
*/
export class LoginWindow extends Window {
constructor() {
super({
name: 'login',
title: 'Login',
cssClasses: ['login']
});
this.buildUI();
this.bindEvents();
this.checkIfAlreadyLoggedIn();
}
/**
* Builds the user interface
*/
buildUI() {
// Main form
this.form = this.createElement('div', 'login__form');
// Username field
this.usernameInput = this.createElement('input', CSS.INPUT, {
type: 'text',
placeholder: 'Username'
});
// Password field
this.passwordInput = this.createElement('input', CSS.INPUT, {
type: 'password',
placeholder: 'Password'
});
// Action buttons
this.actions = this.createElement('div', 'login__actions');
this.loginBtn = this.createElement('button', [CSS.BTN, CSS.BTN_PRIMARY], {
text: 'Sign in'
});
this.registerBtn = this.createElement('button', [CSS.BTN, CSS.BTN_SECONDARY], {
text: 'Register'
});
this.actions.append(this.loginBtn, this.registerBtn);
// Feedback message
this.message = this.createElement('div', CSS.MESSAGE);
// Divider
this.divider = this.createElement('div', 'login__divider', {
text: 'or'
});
// GitHub button
this.githubBtn = this.createElement('button', [CSS.BTN, CSS.BTN_GITHUB], {
text: 'Sign in with GitHub'
});
// Assembly
this.form.append(
this.usernameInput,
this.passwordInput,
this.actions,
this.message,
this.divider,
this.githubBtn
);
this.body.appendChild(this.form);
}
/**
* Attaches event handlers
*/
bindEvents() {
this.loginBtn.addEventListener('click', () => this.handleLogin());
this.registerBtn.addEventListener('click', () => this.handleRegister());
this.githubBtn.addEventListener('click', () => this.handleGitHubLogin());
// Login with Enter
this.passwordInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
this.handleLogin();
}
});
}
/**
* Checks if user is already logged in
*/
checkIfAlreadyLoggedIn() {
const token = localStorage.getItem(STORAGE_KEYS.AUTH_TOKEN);
if (token) {
this.showMessage('You are already logged in!', 'success');
}
}
/**
* Handles login
*/
async handleLogin() {
const username = this.usernameInput.value.trim();
const password = this.passwordInput.value;
if (!username || !password) {
this.showMessage('Please fill in all fields', 'error');
return;
}
this.showMessage('Signing in...', 'info');
try {
const response = await fetch(API.AUTH.LOGIN, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
});
const data = await response.json();
if (response.ok && data.token) {
console.log('Login successful, storing token');
localStorage.setItem(STORAGE_KEYS.AUTH_TOKEN, data.token);
this.showMessage('Login successful! Welcome.', 'success');
console.log('Emitting USER_LOGGED_IN event');
// Emit login event
eventBus.emit(Events.USER_LOGGED_IN, { username, token: data.token });
console.log('Token stored:', !!localStorage.getItem(STORAGE_KEYS.AUTH_TOKEN));
// Close window after delay
setTimeout(() => this.hide(), 1500);
} else {
const errorMsg = data?.message || 'Login failed';
this.showMessage(errorMsg, 'error');
}
} catch (error) {
console.error('Login error:', error);
this.showMessage('Server connection error', 'error');
}
}
/**
* Handles registration
*/
async handleRegister() {
const username = this.usernameInput.value.trim();
const password = this.passwordInput.value;
if (!username || !password) {
this.showMessage('Please fill in all fields', 'error');
return;
}
this.showMessage('Registering...', 'info');
try {
const response = await fetch(API.AUTH.REGISTER, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
});
const data = await response.json();
if (response.ok) {
this.showMessage('Registration successful! You can now sign in.', 'success');
eventBus.emit(Events.USER_REGISTERED, { username });
} else {
const errorMsg = data?.message || 'Registration failed';
this.showMessage(errorMsg, 'error');
}
} catch (error) {
console.error('Registration error:', error);
this.showMessage('Server connection error', 'error');
}
}
/**
* Handles GitHub OAuth login
*/
handleGitHubLogin() {
const width = 600;
const height = 700;
const left = (screen.width - width) / 2;
const top = (screen.height - height) / 2;
const popup = window.open(
API.AUTH.GITHUB,
'githubOAuth',
`width=${width},height=${height},left=${left},top=${top}`
);
const handleMessage = (event) => {
if (event.data?.token) {
localStorage.setItem(STORAGE_KEYS.AUTH_TOKEN, event.data.token);
this.showMessage('GitHub login successful! Welcome.', 'success');
// Emit login event
eventBus.emit(Events.USER_LOGGED_IN, {
provider: 'github',
token: event.data.token
});
window.removeEventListener('message', handleMessage);
if (popup) popup.close();
}
};
window.addEventListener('message', handleMessage, { once: true });
}
/**
* Displays a feedback message
* @param {string} text - Message text
* @param {'success'|'error'|'info'} type - Message type
*/
showMessage(text, type = 'info') {
this.message.textContent = text;
this.message.className = CSS.MESSAGE;
if (type === 'success') {
this.message.classList.add(CSS.MESSAGE_SUCCESS);
} else if (type === 'error') {
this.message.classList.add(CSS.MESSAGE_ERROR);
} else {
this.message.classList.add(CSS.MESSAGE_INFO);
}
}
}
@@ -0,0 +1,99 @@
// ─────────────────────────────────────────────
// PIÈCES
// ─────────────────────────────────────────────
class Piece {
constructor(startX, startY) {
this.position = { x: startX, y: startY };
this.currentRotation = 0;
this.rotations = this.defineRotations();
this.shape = this.rotations[0];
this.color = this.getColor();
}
defineRotations() { return [[[1]]]; }
getColor() { return 1; }
getPosition() { return { ...this.position }; }
getShape() { return this.shape; }
moveDown() { this.position.y++; }
moveLeft() { this.position.x--; }
moveRight() { this.position.x++; }
rotateLeft() {
this.currentRotation = (this.currentRotation - 1 + this.rotations.length) % this.rotations.length;
this.shape = this.rotations[this.currentRotation];
}
rotateRight() {
this.currentRotation = (this.currentRotation + 1) % this.rotations.length;
this.shape = this.rotations[this.currentRotation];
}
}
class PieceT extends Piece {
defineRotations() {
return [
[[0,1,0],[1,1,1],[0,0,0]],
[[0,1,0],[0,1,1],[0,1,0]],
[[0,0,0],[1,1,1],[0,1,0]],
[[0,1,0],[1,1,0],[0,1,0]]
];
}
getColor() { return 1; }
}
class PieceL extends Piece {
defineRotations() {
return [
[[0,0,1],[1,1,1],[0,0,0]],
[[0,1,0],[0,1,0],[0,1,1]],
[[0,0,0],[1,1,1],[1,0,0]],
[[1,1,0],[0,1,0],[0,1,0]]
];
}
getColor() { return 2; }
}
class PieceReverseL extends Piece {
defineRotations() {
return [
[[1,0,0],[1,1,1],[0,0,0]],
[[0,1,1],[0,1,0],[0,1,0]],
[[0,0,0],[1,1,1],[0,0,1]],
[[0,1,0],[0,1,0],[1,1,0]]
];
}
getColor() { return 3; }
}
class PieceI extends Piece {
defineRotations() {
return [
[[0,0,0,0],[1,1,1,1],[0,0,0,0],[0,0,0,0]],
[[0,0,1,0],[0,0,1,0],[0,0,1,0],[0,0,1,0]]
];
}
getColor() { return 4; }
}
class PieceZ extends Piece {
defineRotations() {
return [
[[1,1,0],[0,1,1],[0,0,0]],
[[0,0,1],[0,1,1],[0,1,0]]
];
}
getColor() { return 5; }
}
class PieceReverseZ extends Piece {
defineRotations() {
return [
[[0,1,1],[1,1,0],[0,0,0]],
[[0,1,0],[0,1,1],[0,0,1]]
];
}
getColor() { return 6; }
}
class PieceO extends Piece {
defineRotations() { return [[[1,1],[1,1]]]; }
getColor() { return 7; }
}
@@ -0,0 +1,126 @@
// ─────────────────────────────────────────────
// RENDU
// ─────────────────────────────────────────────
const CELL = 30;
const COLORS = ['#070712','#a855f7','#f97316','#3b82f6','#06b6d4','#ef4444','#22c55e','#eab308','#555577'];
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;
ctx.fillStyle = COLORS[colorIndex];
ctx.fillRect(x * size + p, y * size + p, size - p * 2, size - p * 2);
// Highlight
ctx.fillStyle = 'rgba(255,255,255,0.25)';
ctx.fillRect(x * size + p, y * size + p, size - p * 2, 3);
ctx.fillRect(x * size + p, y * size + p, 3, size - p * 2);
// Ombre
ctx.fillStyle = 'rgba(0,0,0,0.35)';
ctx.fillRect(x * size + p, (y + 1) * size - p - 3, size - p * 2, 3);
ctx.fillRect((x + 1) * size - p - 3, y * size + p, 3, size - p * 2);
}
function clearCanvas(ctx, w, h) {
ctx.fillStyle = '#070712';
ctx.fillRect(0, 0, w, h);
}
function drawGridLines(ctx, cols, rows, size) {
ctx.strokeStyle = 'rgba(255,255,255,0.04)';
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 >= grid.length || grid[ny][nx] !== 0) valid = false;
}
if (!valid) { ghost.y--; break; }
}
if (ghost.y === piece.getPosition().y) return;
ctx.strokeStyle = 'rgba(255,255,255,0.15)';
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);
}
@@ -0,0 +1,355 @@
:root {
--bg: #070712;
--panel: #0d0d1f;
--border: #1a1a3e;
--accent: #00ffe7;
--accent2:#ff00aa;
--dim: #3a3a6a;
--text: #c0c0e0;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: var(--bg);
font-family: 'Share Tech Mono', monospace;
color: var(--text);
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
overflow: hidden;
}
body::before {
content: '';
position: fixed;
inset: 0;
background-image:
linear-gradient(rgba(0,255,231,0.03) 1px, transparent 1px),
linear-gradient(90deg, rgba(0,255,231,0.03) 1px, transparent 1px);
background-size: 40px 40px;
pointer-events: none;
z-index: 0;
}
h1 {
font-family: 'Orbitron', monospace;
font-weight: 900;
font-size: 2.2rem;
letter-spacing: 0.4em;
color: var(--accent);
text-shadow: 0 0 20px var(--accent), 0 0 40px var(--accent);
margin-bottom: 20px;
position: relative;
z-index: 1;
}
/* ── Zone de jeu globale ── */
#game-area {
display: flex;
gap: 32px;
align-items: flex-start;
position: relative;
z-index: 1;
}
/* ── Section locale (légèrement décalée à gauche par le flex naturel) ── */
#local-section {
display: flex;
flex-direction: column;
align-items: center;
}
#app {
display: flex;
gap: 16px;
align-items: flex-start;
}
/* ── Section adversaire ── */
#opponent-section {
display: none; /* masqué jusqu'à connexion duel */
gap: 16px;
align-items: flex-start;
}
#opponent-section.visible {
display: flex;
}
.opponent-info-panel {
width: 130px;
}
/* ── Panneaux ── */
.panel {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 6px;
padding: 14px;
width: 130px;
box-shadow: 0 0 20px rgba(0,255,231,0.05);
}
.panel-title {
font-family: 'Orbitron', monospace;
font-size: 0.6rem;
letter-spacing: 0.2em;
color: var(--accent);
text-transform: uppercase;
margin-bottom: 10px;
text-align: center;
}
canvas { display: block; border-radius: 4px; }
#canvas-main {
border: 1px solid var(--border);
box-shadow: 0 0 30px rgba(0,255,231,0.08), inset 0 0 30px rgba(0,0,0,0.5);
}
#canvas-next, #canvas-hold {
border: 1px solid var(--border);
margin: 0 auto;
}
/* ── Canvas adversaire ── */
#canvas-opponent {
border: 1px solid var(--accent2);
box-shadow: 0 0 30px rgba(255,0,170,0.08), inset 0 0 30px rgba(0,0,0,0.5);
}
/* ── Score ── */
.score-block {
margin-top: 14px;
text-align: center;
}
.score-label {
font-size: 0.55rem;
letter-spacing: 0.2em;
color: var(--dim);
text-transform: uppercase;
margin-bottom: 4px;
}
.score-value {
font-family: 'Orbitron', monospace;
font-size: 1.4rem;
font-weight: 700;
color: var(--accent);
text-shadow: 0 0 10px var(--accent);
}
/* ── Boutons ── */
.btn-group {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 14px;
}
button {
font-family: 'Orbitron', monospace;
font-size: 0.55rem;
letter-spacing: 0.15em;
font-weight: 700;
text-transform: uppercase;
padding: 10px 8px;
border: 1px solid;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
background: transparent;
width: 100%;
}
#btn-start { color: var(--accent); border-color: var(--accent); }
#btn-start:hover:not(:disabled) { background: var(--accent); color: var(--bg); box-shadow: 0 0 15px var(--accent); }
#btn-pause { color: var(--accent2); border-color: var(--accent2); }
#btn-pause:hover:not(:disabled) { background: var(--accent2); color: var(--bg); box-shadow: 0 0 15px var(--accent2); }
#btn-stop { color: #ef4444; border-color: #ef4444; }
#btn-stop:hover:not(:disabled) { background: #ef4444; color: var(--bg); box-shadow: 0 0 15px #ef4444; }
button:disabled { opacity: 0.3; cursor: not-allowed; }
/* ── Contrôles ── */
.controls-list {
margin-top: 14px;
font-size: 0.6rem;
line-height: 2;
color: var(--dim);
}
.controls-list span { color: var(--text); }
/* ── Overlays ── */
#main-wrapper,
#opponent-wrapper { position: relative; }
#overlay,
#overlay-opponent {
display: none;
position: absolute;
top: 0; left: 0;
width: 300px;
height: 600px;
background: rgba(7,7,18,0.88);
border-radius: 4px;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
z-index: 10;
pointer-events: none;
}
#overlay.visible,
#overlay-opponent.visible { display: flex; }
#overlay-title {
font-family: 'Orbitron', monospace;
font-size: 1.4rem;
font-weight: 900;
letter-spacing: 0.2em;
color: var(--accent2);
text-shadow: 0 0 20px var(--accent2);
}
#overlay-score {
font-family: 'Orbitron', monospace;
font-size: 0.9rem;
color: var(--accent);
}
#overlay-opponent-title {
font-family: 'Orbitron', monospace;
font-size: 1.4rem;
font-weight: 900;
letter-spacing: 0.2em;
color: var(--accent);
text-shadow: 0 0 20px var(--accent);
}
#overlay-opponent-score {
font-family: 'Orbitron', monospace;
font-size: 0.9rem;
color: var(--accent2);
}
/* ── Panneau duel ── */
#duel-panel {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 6px;
padding: 12px 20px;
margin-bottom: 14px;
position: relative;
z-index: 1;
display: flex;
align-items: center;
gap: 14px;
box-shadow: 0 0 20px rgba(255,0,170,0.04);
}
.duel-row {
display: flex;
gap: 8px;
align-items: center;
}
#input-room-code {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 4px;
color: var(--accent2);
font-family: 'Orbitron', monospace;
font-size: 0.7rem;
letter-spacing: 0.15em;
padding: 6px 10px;
width: 120px;
text-transform: uppercase;
outline: none;
transition: border-color 0.2s;
}
#input-room-code:focus {
border-color: var(--accent2);
box-shadow: 0 0 8px rgba(255,0,170,0.2);
}
#btn-join-duel { color: var(--accent2); border-color: var(--accent2); width: auto; padding: 6px 14px; }
#btn-join-duel:hover:not(:disabled) { background: var(--accent2); color: var(--bg); box-shadow: 0 0 12px var(--accent2); }
#btn-leave-duel { color: #ef4444; border-color: #ef4444; width: auto; padding: 6px 14px; }
#btn-leave-duel:hover:not(:disabled) { background: #ef4444; color: var(--bg); box-shadow: 0 0 12px #ef4444; }
#duel-status {
font-size: 0.6rem;
letter-spacing: 0.1em;
color: var(--dim);
min-width: 120px;
}
#duel-status.waiting { color: #f97316; }
#duel-status.ready { color: var(--accent); }
/* ── Settings Panel ── */
#settings-panel {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 6px;
padding: 14px 20px;
margin-top: -250px;
margin-left: -600px;
box-shadow: 0 0 20px rgba(0,255,231,0.05);
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
gap: 10px;
width: fit-content;
}
.settings-title {
font-family: 'Orbitron', monospace;
font-size: 0.6rem;
letter-spacing: 0.2em;
color: var(--accent);
text-transform: uppercase;
text-align: center;
margin-bottom: 4px;
}
.settings-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
font-size: 0.6rem;
color: var(--dim);
letter-spacing: 0.05em;
}
#settings-panel input[type="number"] {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 4px;
color: var(--accent);
font-family: 'Orbitron', monospace;
font-size: 0.65rem;
padding: 4px 8px;
width: 80px;
text-align: right;
outline: none;
transition: border-color 0.2s;
}
#settings-panel input[type="number"]:focus {
border-color: var(--accent);
box-shadow: 0 0 8px rgba(0,255,231,0.2);
}
#settings-panel input[type="number"]:disabled {
opacity: 0.3;
cursor: not-allowed;
}
@@ -0,0 +1,121 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>TETRIS</title>
<link href="https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=Orbitron:wght@400;700;900&display=swap" rel="stylesheet">
<link rel="stylesheet" href="tetris.css">
</head>
<body>
<h1>TETRIS</h1>
<!-- Panneau de connexion duel -->
<div id="duel-panel">
<span class="settings-title">Duel</span>
<div class="duel-row">
<input id="input-room-code" placeholder="Code de salle" maxlength="8" spellcheck="false">
<button id="btn-join-duel">Rejoindre</button>
<button id="btn-leave-duel" disabled>Quitter</button>
</div>
<div id="duel-status"></div>
</div>
<div id="game-area">
<!-- ── JOUEUR LOCAL ── -->
<div id="local-section">
<div id="app">
<!-- Panneau gauche : Hold + Score + Boutons -->
<div class="panel">
<div class="panel-title">Hold</div>
<canvas id="canvas-hold" width="100" height="80"></canvas>
<div class="score-block">
<div class="score-label">Score</div>
<div class="score-value" id="score-display">0</div>
</div>
<div class="btn-group">
<button id="btn-start">Start</button>
<button id="btn-pause" disabled>Pause</button>
<button id="btn-stop" disabled>Stop</button>
</div>
</div>
<!-- Grille principale -->
<div id="main-wrapper">
<canvas id="canvas-main" width="300" height="600"></canvas>
<div id="overlay">
<div id="overlay-title">GAME OVER</div>
<div id="overlay-score"></div>
</div>
</div>
<!-- Panneau droit : Next + Contrôles -->
<div class="panel">
<div class="panel-title">Next</div>
<canvas id="canvas-next" width="100" height="80"></canvas>
<div class="controls-list">
<div><span>← →</span> Déplacer</div>
<div><span></span> Descendre</div>
<div><span>Q</span> Rot. gauche</div>
<div><span>W</span> Rot. droite</div>
<div><span>Espace</span> Drop</div>
<div><span>C</span> Hold</div>
</div>
</div>
</div>
</div>
<!-- ── JOUEUR ADVERSAIRE ── -->
<div id="opponent-section">
<div class="panel opponent-info-panel">
<div class="panel-title" id="opponent-name">Adversaire</div>
<div class="score-block">
<div class="score-label">Score</div>
<div class="score-value" id="opponent-score"></div>
</div>
</div>
<div id="opponent-wrapper">
<canvas id="canvas-opponent" width="300" height="600"></canvas>
<div id="overlay-opponent">
<div id="overlay-opponent-title"></div>
<div id="overlay-opponent-score"></div>
</div>
</div>
</div>
</div>
<!-- Panneau de configuration -->
<div id="settings-panel">
<div class="settings-title">Paramètres</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">
</div>
<div class="settings-row">
<label for="input-hardening">Points avant accélération</label>
<input type="number" id="input-hardening" min="100" max="5000" step="100" value="1000">
</div>
<div class="settings-row">
<label for="input-decrement">Réduction vitesse (ms)</label>
<input type="number" id="input-decrement" min="10" max="500" step="10" value="100">
</div>
</div>
<script src="/socket.io/socket.io.js"></script>
<script src="pieces.js"></script>
<script src="tetris.js"></script>
<script src="renderer.js"></script>
<script src="duel.js"></script>
<script src="ui.js"></script>
</body>
</html>
@@ -0,0 +1,366 @@
// ─────────────────────────────────────────────
// LOGIQUE TETRIS
// ─────────────────────────────────────────────
class Tetris {
constructor(onRender, onGameOver, onBlockPlaced = null, onLinesCleared = null) {
this.onRender = onRender;
this.onGameOver = onGameOver;
this.onBlockPlaced = onBlockPlaced;
this.onLinesCleared = onLinesCleared;
this.grid = this._createGrid(10, 20);
this.bufferGrid = this._createGrid(10, 5);
this.currentPiece = null;
this.storedPiece = null;
this.nextPiece = null;
this.score = 0;
this.initialTimeToDown = 1000;
this.timeToDown = 1000;
this.hardening = 1000;
this.count = 0;
this.decrementTTD = 100;
this.lastLandingCol = 4;
this.isRunning = false;
this.isPaused = false;
this.canStore = true;
this.animationFrameId = null;
this.lastTime = 0;
this.accumulator = 0;
this._keyHandler = this._handleKey.bind(this);
}
configure({ timeToDown, hardening, decrementTTD }) {
if (timeToDown !== undefined) this.initialTimeToDown = this.timeToDown = timeToDown;
if (hardening !== undefined) this.hardening = hardening;
if (decrementTTD !== undefined) this.decrementTTD = decrementTTD;
}
_createGrid(w, h) {
return Array.from({ length: h }, () => Array(w).fill(0));
}
start() {
if (this.isRunning) return;
this.isRunning = true;
this.isPaused = false;
this.grid = this._createGrid(10, 20);
this.score = 0;
this.count = 0;
this.timeToDown = this.initialTimeToDown;
this.storedPiece = null;
this.canStore = true;
this._spawnNewPiece();
document.addEventListener('keydown', this._keyHandler);
this._startGameLoop();
}
stop() {
this.isRunning = false;
this.isPaused = false;
if (this.animationFrameId !== null) {
cancelAnimationFrame(this.animationFrameId);
this.animationFrameId = null;
}
this.accumulator = 0;
this.lastTime = 0;
document.removeEventListener('keydown', this._keyHandler);
}
pause() {
if (!this.isRunning) return;
this.isPaused = !this.isPaused;
if (!this.isPaused) {
this.lastTime = 0;
this._startGameLoop();
}
}
_startGameLoop() {
this.lastTime = 0;
this.accumulator = 0;
const gameLoop = (currentTime) => {
if (!this.isRunning) return;
if (this.isPaused) {
this.animationFrameId = requestAnimationFrame(gameLoop);
return;
}
if (this.lastTime === 0) {
this.lastTime = currentTime;
this.animationFrameId = requestAnimationFrame(gameLoop);
return;
}
const deltaTime = currentTime - this.lastTime;
this.lastTime = currentTime;
this.accumulator += deltaTime;
while (this.isRunning && this.accumulator >= this.timeToDown) {
this._tick();
this.accumulator -= this.timeToDown;
if (this.accumulator > this.timeToDown * 3) {
this.accumulator = 0;
break;
}
}
this.onRender();
this.animationFrameId = requestAnimationFrame(gameLoop);
};
this.animationFrameId = requestAnimationFrame(gameLoop);
}
_tick() {
if (!this.currentPiece) return;
if (this._canMoveDown()) {
this.currentPiece.moveDown();
} else {
this._lockPiece();
this.verifierLignes();
this._makeHarder();
this._spawnNewPiece();
this.canStore = true;
if (!this._canSpawn()) this._gameOver();
}
}
_handleKey(e) {
if (!this.isRunning || !this.currentPiece) return;
switch (e.key) {
case 'ArrowLeft':
e.preventDefault();
if (!this.isPaused && this._canMoveLeft()) this.currentPiece.moveLeft();
break;
case 'ArrowRight':
e.preventDefault();
if (!this.isPaused && this._canMoveRight()) this.currentPiece.moveRight();
break;
case 'ArrowDown':
e.preventDefault();
if (!this.isPaused && this._canMoveDown()) {
this.currentPiece.moveDown();
this.score += 1;
this.accumulator = 0;
}
break;
case ' ':
e.preventDefault();
if (!this.isPaused) this._hardDrop();
break;
case 'q': case 'Q':
e.preventDefault();
if (!this.isPaused) this._rotatePiece(-1);
break;
case 'w': case 'W':
e.preventDefault();
if (!this.isPaused) this._rotatePiece(1);
break;
case 'c': case 'C':
e.preventDefault();
if (!this.isPaused) this._storePiece();
break;
}
this.onRender();
}
_hardDrop() {
if (!this.currentPiece) return;
let dist = 0;
while (this._canMoveDown()) { this.currentPiece.moveDown(); dist++; }
this.score += dist * 2;
this._lockPiece();
this.verifierLignes();
this._makeHarder();
this._spawnNewPiece();
this.canStore = true;
this.accumulator = 0;
if (!this._canSpawn()) this._gameOver();
}
_rotatePiece(direction) {
if (!this.currentPiece) return;
const originalPos = { ...this.currentPiece.getPosition() };
if (direction === -1) this.currentPiece.rotateLeft();
else this.currentPiece.rotateRight();
if (!this._isValidPosition()) {
this.currentPiece.moveRight();
if (this._isValidPosition()) return;
this.currentPiece.moveLeft();
this.currentPiece.moveLeft();
if (this._isValidPosition()) return;
this.currentPiece.moveLeft();
if (this._isValidPosition()) return;
this.currentPiece.moveRight();
this.currentPiece.moveRight();
this.currentPiece.position.y--;
if (this._isValidPosition()) return;
this.currentPiece.position.y = originalPos.y;
this.currentPiece.position.x = originalPos.x;
if (direction === -1) this.currentPiece.rotateRight();
else this.currentPiece.rotateLeft();
}
}
_storePiece() {
if (!this.canStore || !this.currentPiece) return;
if (this.storedPiece === null) {
this.storedPiece = this.currentPiece;
this._spawnNewPiece();
} else {
const temp = this.storedPiece;
this.storedPiece = this.currentPiece;
this.currentPiece = temp;
this.currentPiece.position.x = 3;
this.currentPiece.position.y = 0;
}
this.canStore = false;
this.accumulator = 0;
}
_spawnNewPiece() {
this.currentPiece = this.nextPiece || this._createRandomPiece();
this.nextPiece = this._createRandomPiece();
this._updateBufferGrid();
}
_createRandomPiece() {
const types = [PieceT, PieceL, PieceReverseL, PieceI, PieceZ, PieceReverseZ, PieceO];
return new types[Math.floor(Math.random() * types.length)](3, 0);
}
_updateBufferGrid() {
this.bufferGrid = this._createGrid(10, 5);
if (!this.nextPiece) return;
const shape = this.nextPiece.getShape();
const offsetX = Math.floor((10 - shape[0].length) / 2);
for (let y = 0; y < shape.length; y++)
for (let x = 0; x < shape[y].length; x++)
if (shape[y][x] !== 0)
this.bufferGrid[y + 1][x + offsetX] = this.nextPiece.getColor();
}
verifierLignes() {
let cleared = 0;
for (let y = this.grid.length - 1; y >= 0; y--) {
if (this.grid[y].every(c => c !== 0)) {
this.grid.splice(y, 1);
this.grid.unshift(Array(10).fill(0));
cleared++;
y++;
}
}
const points = [0, 100, 300, 500, 800];
this.score += points[cleared];
this.count += points[cleared];
if (this.onLinesCleared && cleared > 0)
this.onLinesCleared(cleared, this.lastLandingCol);
}
_makeHarder() {
if (this.count >= this.hardening) {
this.count = 0;
this.timeToDown = Math.max(100, this.timeToDown - this.decrementTTD);
}
}
_canMoveDown() {
if (!this.currentPiece) return false;
const { x, y } = this.currentPiece.getPosition();
const shape = this.currentPiece.getShape();
for (let row = 0; row < shape.length; row++)
for (let col = 0; col < shape[row].length; col++)
if (shape[row][col] !== 0) {
const ny = y + row + 1;
const nx = x + col;
if (ny >= this.grid.length || this.grid[ny][nx] !== 0) return false;
}
return true;
}
_canMoveLeft() {
if (!this.currentPiece) return false;
const { x, y } = this.currentPiece.getPosition();
const shape = this.currentPiece.getShape();
for (let row = 0; row < shape.length; row++)
for (let col = 0; col < shape[row].length; col++)
if (shape[row][col] !== 0) {
const nx = x + col - 1;
if (nx < 0 || this.grid[y + row][nx] !== 0) return false;
}
return true;
}
_canMoveRight() {
if (!this.currentPiece) return false;
const { x, y } = this.currentPiece.getPosition();
const shape = this.currentPiece.getShape();
for (let row = 0; row < shape.length; row++)
for (let col = 0; col < shape[row].length; col++)
if (shape[row][col] !== 0) {
const nx = x + col + 1;
if (nx >= this.grid[0].length || this.grid[y + row][nx] !== 0) return false;
}
return true;
}
_isValidPosition() {
if (!this.currentPiece) return false;
const { x, y } = this.currentPiece.getPosition();
const shape = this.currentPiece.getShape();
for (let row = 0; row < shape.length; row++)
for (let col = 0; col < shape[row].length; col++)
if (shape[row][col] !== 0) {
const gx = x + col;
const gy = y + row;
if (gx < 0 || gx >= this.grid[0].length ||
gy < 0 || gy >= this.grid.length ||
this.grid[gy][gx] !== 0) return false;
}
return true;
}
_canSpawn() { return this._isValidPosition(); }
_lockPiece() {
if (!this.currentPiece) return;
const { x, y } = this.currentPiece.getPosition();
const shape = this.currentPiece.getShape();
const color = this.currentPiece.getColor();
for (let row = 0; row < shape.length; row++)
for (let col = 0; col < shape[row].length; col++)
if (shape[row][col] !== 0)
this.grid[y + row][x + col] = color;
this.lastLandingCol = x + Math.floor(shape[0].length / 2);
if (this.onBlockPlaced) this.onBlockPlaced(this.grid.map(r => [...r]));
}
addGarbageLines(lines) {
if (!this.isRunning || !lines.length) return;
this.grid.splice(0, lines.length);
for (const line of lines) this.grid.push([...line]);
if (!this._isValidPosition()) this._gameOver();
}
_gameOver() {
this.stop();
this.onGameOver(this.score);
}
}
@@ -0,0 +1,162 @@
// ─────────────────────────────────────────────
// 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');
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();
}
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);
});
// ─────────────────────────────────────────────
// INIT
// ─────────────────────────────────────────────
const game = new Tetris(
// onRender
() => {
if (duel) duel.synchronize_game();
render();
updateButtons();
},
// onGameOver
(score) => {
if (duel) duel.onLocalGameOver(score);
render();
updateButtons();
showOverlay('GAME OVER', score);
},
// 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', () => {
game.pause();
updateButtons();
if (game.isPaused) showOverlay('PAUSE');
else hideOverlay();
});
btnStop.addEventListener('click', () => {
game.stop();
updateButtons();
render();
showOverlay('STOPPED');
});
function applySettings() {
game.configure({
timeToDown: parseInt(inputTTD.value, 10),
hardening: parseInt(inputHardening.value, 10),
decrementTTD: parseInt(inputDecrement.value, 10),
});
}
inputTTD.addEventListener('change', applySettings);
inputHardening.addEventListener('change', applySettings);
inputDecrement.addEventListener('change', applySettings);
render();
updateButtons();
@@ -0,0 +1,234 @@
import { CSS } from './config.js';
import { eventBus, Events } from './events.js';
/**
* Centralized window registry
* Manages window visibility and positioning
*/
class WindowRegistry {
constructor() {
this.windows = new Map();
}
/**
* Registers a window in the registry
* @param {string} name - Unique window name
* @param {Window} window - Window instance
*/
register(name, window) {
this.windows.set(name, window);
}
/**
* Gets a window by its name
* @param {string} name - Window name
* @returns {Window|undefined}
*/
get(name) {
return this.windows.get(name);
}
/**
* Returns all visible windows
* @returns {Window[]}
*/
getVisible() {
return Array.from(this.windows.values()).filter(w => w.isVisible());
}
/**
* Reorganizes visible windows
*/
reorganize() {
const visible = this.getVisible();
if (visible.length === 0) return;
if (visible.length === 1) {
visible[0].setPosition('center');
} else if (visible.length === 2) {
visible[0].setPosition('left');
visible[1].setPosition('right');
} else {
visible.forEach(w => w.setPosition('center'));
}
}
/**
* Shows a window and reorganizes
* @param {string} name - Window name
*/
show(name) {
const window = this.get(name);
if (window) {
window.show();
}
}
/**
* Hides a window and reorganizes
* @param {string} name - Window name
*/
hide(name) {
const window = this.get(name);
if (window) {
window.hide();
}
}
/**
* Toggles window visibility
* @param {string} name - Window name
*/
toggle(name) {
const window = this.get(name);
if (window) {
window.toggle();
}
}
}
// Singleton registry instance
export const windowRegistry = new WindowRegistry();
/**
* Base class for all windows
* Uses CSS classes instead of inline styles
*/
export class Window {
/**
* @param {Object} options - Configuration options
* @param {string} options.name - Unique name for the registry
* @param {string} options.title - Title displayed in the header
* @param {string[]} [options.cssClasses] - Additional CSS classes
*/
constructor({ name, title, cssClasses = [] }) {
this.name = name;
// Create main container
this.element = document.createElement('div');
this.element.className = [CSS.WINDOW, ...cssClasses].join(' ');
// Header
this.header = document.createElement('div');
this.header.className = CSS.WINDOW_HEADER;
this.titleElement = document.createElement('span');
this.titleElement.className = CSS.WINDOW_TITLE;
this.titleElement.textContent = title;
this.closeBtn = document.createElement('button');
this.closeBtn.className = CSS.WINDOW_CLOSE;
this.closeBtn.textContent = '✖';
this.closeBtn.setAttribute('aria-label', 'Close');
this.closeBtn.addEventListener('click', () => this.hide());
this.header.append(this.titleElement, this.closeBtn);
// Body
this.body = document.createElement('div');
this.body.className = CSS.WINDOW_BODY;
// Assembly
this.element.append(this.header, this.body);
document.body.appendChild(this.element);
// Register in the registry
windowRegistry.register(name, this);
}
/**
* Checks if the window is visible
* @returns {boolean}
*/
isVisible() {
return this.element.classList.contains(CSS.WINDOW_VISIBLE);
}
/**
* Sets the window position
* @param {'center'|'left'|'right'} position
*/
setPosition(position) {
this.element.classList.remove('window--left', 'window--right');
if (position === 'left') {
this.element.classList.add('window--left');
} else if (position === 'right') {
this.element.classList.add('window--right');
}
}
/**
* Shows the window
*/
show() {
const wasHidden = !this.isVisible();
this.element.classList.add(CSS.WINDOW_VISIBLE);
if (wasHidden) {
windowRegistry.reorganize();
eventBus.emit(Events.WINDOW_OPENED, { name: this.name });
}
}
/**
* Hides the window
*/
hide() {
const wasVisible = this.isVisible();
this.element.classList.remove(CSS.WINDOW_VISIBLE);
if (wasVisible) {
windowRegistry.reorganize();
eventBus.emit(Events.WINDOW_CLOSED, { name: this.name });
}
}
/**
* Toggles visibility
*/
toggle() {
if (this.isVisible()) {
this.hide();
} else {
this.show();
}
}
/**
* Updates the window title
* @param {string} title
*/
setTitle(title) {
this.titleElement.textContent = title;
}
/**
* Creates an element with CSS classes
* @param {string} tag - HTML tag
* @param {string|string[]} classes - CSS classes
* @param {Object} [attrs] - Additional attributes
* @returns {HTMLElement}
*/
createElement(tag, classes, attrs = {}) {
const element = document.createElement(tag);
const classList = Array.isArray(classes) ? classes : [classes];
element.className = classList.filter(Boolean).join(' ');
Object.entries(attrs).forEach(([key, value]) => {
if (key === 'text') {
element.textContent = value;
} else if (key === 'html') {
element.innerHTML = value;
} else {
element.setAttribute(key, value);
}
});
return element;
}
}
// Export old class name for compatibility (alias)
export { Window as fenetre };
+52 -1
View File
@@ -18,6 +18,22 @@ router.get('/', authenticateToken, async(req, res) =>
} }
}); });
// Get list of rooms currently being played (for spectators)
router.get('/playing', authenticateToken, async(req, res) =>
{
try
{
const rooms = await gameRoomService.listPlayingRooms();
res.json(rooms);
}
catch (err)
{
console.error(err);
res.status(500).json({error: 'Server error'});
}
});
// IMPORTANT: This route must be before /:roomId to avoid "current" being interpreted as a roomId // IMPORTANT: This route must be before /:roomId to avoid "current" being interpreted as a roomId
router.get('/current', authenticateToken, async(req, res) => router.get('/current', authenticateToken, async(req, res) =>
{ {
@@ -134,4 +150,39 @@ router.post('/:roomId/leave', authenticateToken, async(req, res) =>
} }
}); });
export default router;
// Join a room as spectator
router.post('/:roomId/spectate', authenticateToken, async(req, res) =>
{
try
{
const room = await gameRoomService.spectateRoom(req.params.roomId, req.user.userId);
res.json(room);
}
catch(err)
{
console.error(err);
if (err.message.includes('not found') || err.message.includes('not in playing') || err.message.includes('already in'))
res.status(400).json({error: err.message});
else
res.status(500).json({error: err.message});
}
});
// Leave spectator mode
router.post('/:roomId/leave-spectate', authenticateToken, async(req, res) =>
{
try
{
await gameRoomService.leaveSpectateRoom(req.params.roomId, req.user.userId);
res.json({message: 'Left spectator mode successfully'});
}
catch(err)
{
console.error(err);
res.status(500).json({error: 'Server error'});
}
});
export default router;
@@ -44,6 +44,71 @@ async function listActiveRooms()
return (result.rows); return (result.rows);
} }
async function listPlayingRooms()
{
const result = await query
(
`SELECT r.*, COUNT(p.id) as player_count
FROM game_rooms r
LEFT JOIN game_players p ON r.id = p.room_id
WHERE r.status = 'playing'
GROUP BY r.id
ORDER BY player_count DESC, r.created_at DESC`
);
return (result.rows);
}
async function spectateRoom(roomId, userId)
{
const room = await getRoomById(roomId);
if (!room)
throw new Error('Room not found');
if (room.status !== 'playing')
throw new Error('Room is not in playing status');
// Check if user is already a player in any active game
const playerInGame = await query
(
`SELECT r.id, r.name, r.status
FROM game_rooms r
JOIN game_players gp ON r.id = gp.room_id
WHERE gp.user_id = $1 AND r.status IN ('waiting', 'playing')
LIMIT 1`,
[userId]
);
if (playerInGame.rows.length > 0)
{
const gameRoom = playerInGame.rows[0];
if (gameRoom.id === parseInt(roomId))
throw new Error('You cannot spectate a game you are playing in');
else
throw new Error('You are already in an active game');
}
return (room);
}
async function leaveSpectateRoom(roomId, userId)
{
const playerCount = await query
(
'SELECT COUNT(*) FROM game_players WHERE room_id = $1',
[roomId]
);
if (parseInt(playerCount.rows[0].count) === 0)
{
await query
(
'DELETE FROM game_rooms WHERE id = $1',
[roomId]
);
}
}
async function joinRoom(roomId, userId) async function joinRoom(roomId, userId)
{ {
const room = await getRoomById(roomId); const room = await getRoomById(roomId);
@@ -123,13 +188,68 @@ async function getCurrentRoom(userId)
return (result.rows[0] || null); return (result.rows[0] || null);
} }
// Update room status (waiting, playing, ended)
async function updateRoomStatus(roomId, status)
{
const validStatuses = ['waiting', 'playing', 'ended'];
if (!validStatuses.includes(status))
throw new Error('Invalid status');
let updateQuery = 'UPDATE game_rooms SET status = $1';
const params = [status, roomId];
if (status === 'playing')
{
updateQuery += ', started_at = NOW()';
}
else if (status === 'ended')
{
updateQuery += ', ended_at = NOW()';
}
updateQuery += ' WHERE id = $2 RETURNING *';
const result = await query(updateQuery, params);
return (result.rows[0]);
}
async function resetRoomScores(roomId)
{
await query
(
'UPDATE game_players SET score = 0 WHERE room_id = $1',
[roomId]
);
}
async function cleanupEndedRooms()
{
await query
(
'DELETE FROM game_players WHERE room_id IN (SELECT id FROM game_rooms WHERE status = $1)',
['ended']
);
await query
(
'DELETE FROM game_rooms WHERE status = $1',
['ended']
);
}
export default export default
{ {
createRoom, createRoom,
getRoomById, getRoomById,
listActiveRooms, listActiveRooms,
listPlayingRooms,
spectateRoom,
leaveSpectateRoom,
joinRoom, joinRoom,
leaveRoom, leaveRoom,
getRoomPlayers, getRoomPlayers,
getCurrentRoom getCurrentRoom,
}; updateRoomStatus,
resetRoomScores,
cleanupEndedRooms
};
+250 -4
View File
@@ -27,6 +27,42 @@ async function broadcastRoomsList(io) {
} }
} }
// Check if a playing game has only 1 player left and auto-stop it
async function checkAndStopSinglePlayerGame(io, roomId, dbRoomId) {
if (!dbRoomId) return;
try {
// Check if room is in 'playing' status
const room = await gameRoomService.getRoomById(dbRoomId);
if (!room || room.status !== 'playing') return;
// Count remaining players
const players = await gameRoomService.getRoomPlayers(dbRoomId);
if (players.length <= 1) {
console.log(`Room ${dbRoomId} has only ${players.length} player(s) left, ending game`);
// Update room status to 'ended'
await gameRoomService.updateRoomStatus(dbRoomId, 'waiting');
await gameRoomService.resetRoomScores(dbRoomId);
// Remove from game state
gameRooms.delete(roomId);
// Notify remaining player(s)
io.to(roomId).emit('game-ended');
io.to(roomId).emit('game-message', {
message: 'La partie s\'est terminée car il ne reste qu\'un seul joueur',
type: 'info'
});
// Broadcast updated rooms list
broadcastRoomsList(io);
}
} catch (err) {
console.error('Error checking single player game:', err);
}
}
// Save round points to database (only the difference from round start) // Save round points to database (only the difference from round start)
async function saveRoundPoints(currentScores, roundStartScores) { async function saveRoundPoints(currentScores, roundStartScores) {
for (const [username, currentPoints] of Object.entries(currentScores)) { for (const [username, currentPoints] of Object.entries(currentScores)) {
@@ -185,16 +221,94 @@ function setupSocketIO(io)
socket.gameRoomId = null; socket.gameRoomId = null;
socket.gameRoomDbId = null; socket.gameRoomDbId = null;
// Check if game should auto-stop due to single player
await checkAndStopSinglePlayerGame(io, roomId, dbRoomId);
// Broadcast updated rooms list // Broadcast updated rooms list
broadcastRoomsList(io); broadcastRoomsList(io);
} }
}); });
// Join a game room as spectator
socket.on('game-spectate-room', async (data) => {
console.log('Received game-spectate-room from', socket.user.username, 'data:', data);
const roomId = `game-room-${data.roomId}`;
// Verify room exists and is in playing status, and user is not already in a game
try {
const room = await gameRoomService.spectateRoom(data.roomId, socket.user.userId);
socket.join(roomId);
socket.gameRoomId = roomId;
socket.gameRoomDbId = data.roomId;
socket.isSpectator = true;
console.log(`${socket.user.username} joined ${roomId} as spectator`);
// Send confirmation
socket.emit('game-spectate-joined', {
roomId: data.roomId,
success: true
});
// Notify others that a spectator joined
socket.to(roomId).emit('game-spectator-joined', {
username: socket.user.username
});
// Send current game state
const gameState = gameRooms.get(roomId);
if (gameState && gameState.isPlaying) {
socket.emit('game-state-sync', {
isPlaying: gameState.isPlaying,
drawer: gameState.drawer,
wordLength: gameState.currentWord ? gameState.currentWord.length : 0,
revealedLetters: gameState.revealedLetters,
revealedWord: gameState.revealedWord || [],
guessedLetters: gameState.guessedLetters,
players: gameState.players,
scores: gameState.scores || {}
});
}
} catch (err) {
console.error('Error joining as spectator:', err);
socket.emit('game-spectate-error', {
error: err.message || 'Cannot spectate this room'
});
}
});
// Leave spectator mode
socket.on('game-leave-spectate', () => {
if (socket.gameRoomId && socket.isSpectator) {
const roomId = socket.gameRoomId;
socket.to(roomId).emit('game-spectator-left', {
username: socket.user.username
});
socket.leave(roomId);
console.log(`${socket.user.username} left spectator mode in ${roomId}`);
socket.gameRoomId = null;
socket.gameRoomDbId = null;
socket.isSpectator = false;
}
});
// Start the game // Start the game
socket.on('game-start', (data) => { socket.on('game-start', async (data) => {
console.log('Received game-start event from', socket.user.username); console.log('Received game-start event from', socket.user.username);
console.log('socket.gameRoomId:', socket.gameRoomId); console.log('socket.gameRoomId:', socket.gameRoomId);
// Security check: need at least 2 players
if (!data.players || data.players.length < 2) {
console.log('Game start rejected: not enough players');
socket.emit('game-start-error', {
error: 'Il faut au moins 2 joueurs pour commencer'
});
return;
}
const gameStartedData = { const gameStartedData = {
drawer: data.drawer, drawer: data.drawer,
players: data.players players: data.players
@@ -209,6 +323,33 @@ function setupSocketIO(io)
return; return;
} }
// Verify player count from database
const dbRoomId = socket.gameRoomDbId;
if (dbRoomId) {
try {
const players = await gameRoomService.getRoomPlayers(dbRoomId);
if (players.length < 2) {
console.log(`Game start rejected: only ${players.length} player(s) in room`);
socket.emit('game-start-error', {
error: 'Il faut au moins 2 joueurs pour commencer'
});
return;
}
} catch (err) {
console.error('Error checking player count:', err);
}
}
// Update room status to 'playing' in database
if (dbRoomId) {
try {
await gameRoomService.updateRoomStatus(dbRoomId, 'playing');
console.log(`Room ${dbRoomId} status updated to 'playing'`);
} catch (err) {
console.error('Error updating room status to playing:', err);
}
}
// Initialize scores for all players // Initialize scores for all players
const scores = {}; const scores = {};
data.players.forEach(p => scores[p] = 0); data.players.forEach(p => scores[p] = 0);
@@ -233,6 +374,9 @@ function setupSocketIO(io)
socket.emit('game-started', gameStartedData); socket.emit('game-started', gameStartedData);
console.log(`Game started in ${roomId} by ${socket.user.username}`); console.log(`Game started in ${roomId} by ${socket.user.username}`);
// Broadcast updated rooms list (this room should no longer appear)
broadcastRoomsList(io);
}); });
// Drawer sets the word // Drawer sets the word
@@ -269,6 +413,12 @@ function setupSocketIO(io)
const roomId = socket.gameRoomId; const roomId = socket.gameRoomId;
if (!roomId) return; if (!roomId) return;
// Spectators cannot draw
if (socket.isSpectator) {
console.log(`Spectator ${socket.user.username} tried to draw - blocked`);
return;
}
// Broadcast drawing to all other players in the room // Broadcast drawing to all other players in the room
socket.to(roomId).emit('game-draw', { socket.to(roomId).emit('game-draw', {
x1: data.x1, x1: data.x1,
@@ -285,6 +435,9 @@ function setupSocketIO(io)
const roomId = socket.gameRoomId; const roomId = socket.gameRoomId;
if (!roomId) return; if (!roomId) return;
// Spectators cannot clear canvas
if (socket.isSpectator) return;
socket.to(roomId).emit('game-clear-canvas'); socket.to(roomId).emit('game-clear-canvas');
}); });
@@ -293,6 +446,13 @@ function setupSocketIO(io)
const roomId = socket.gameRoomId; const roomId = socket.gameRoomId;
if (!roomId) return; if (!roomId) return;
// Spectators cannot make guesses
if (socket.isSpectator) {
console.log(`Spectator ${socket.user.username} tried to guess - blocked`);
return;
}
const gameState = gameRooms.get(roomId); const gameState = gameRooms.get(roomId);
if (!gameState || !gameState.currentWord) return; if (!gameState || !gameState.currentWord) return;
@@ -416,13 +576,71 @@ function setupSocketIO(io)
}); });
}); });
socket.on('leave-room-during-game', async () => {
const roomId = socket.gameRoomId;
const dbRoomId = socket.gameRoomDbId;
const userId = socket.user.userId;
const username = socket.user.username;
if (!roomId || !dbRoomId || !userId) return;
console.log(`Player ${username} leaving room ${roomId} during game`);
try
{
socket.leave(roomId);
await gameRoomService.leaveRoom(dbRoomId, userId);
io.to(roomId).emit('game-player-left', {
username: username,
message: `${username} a quitté la partie`
});
const gameState = gameRooms.get(roomId);
if (gameState)
{
gameState.players = gameState.players.filter(p => p !== username);
delete gameState.scores[username];
io.to(roomId).emit('scores-updated', gameState.scores);
}
await checkAndStopSinglePlayerGame(io, roomId, dbRoomId);
socket.gameRoomId = null;
socket.gameRoomDbId = null;
broadcastRoomsList(io);
}
catch (err)
{
console.error('Error leaving room during game:', err);
}
});
// End game // End game
socket.on('game-end', () => { socket.on('game-end', async () => {
const roomId = socket.gameRoomId; const roomId = socket.gameRoomId;
if (!roomId) return; if (!roomId) return;
// Update room status to 'waiting' in database
const dbRoomId = socket.gameRoomDbId;
if (dbRoomId) {
try {
await gameRoomService.updateRoomStatus(dbRoomId, 'waiting');
await gameRoomService.resetRoomScores(dbRoomId);
console.log(`Room ${dbRoomId} status updated to 'waiting'`);
} catch (err) {
console.error('Error updating room status to waiting:', err);
}
}
gameRooms.delete(roomId); gameRooms.delete(roomId);
io.to(roomId).emit('game-ended'); io.to(roomId).emit('game-ended');
// Broadcast updated rooms list
broadcastRoomsList(io);
}); });
// ============================================ // ============================================
@@ -540,11 +758,39 @@ function setupSocketIO(io)
console.log(`User disconnected: ${socket.user.username}`); console.log(`User disconnected: ${socket.user.username}`);
// Notify game room if player was in one // Notify game room if player/spectator was in one
if (socket.gameRoomId) { if (socket.gameRoomId) {
const roomId = socket.gameRoomId; const roomId = socket.gameRoomId;
const dbRoomId = socket.gameRoomDbId; const dbRoomId = socket.gameRoomDbId;
// If spectator, just notify and leave
if (socket.isSpectator) {
socket.to(roomId).emit('game-spectator-left', {
username: socket.user.username
});
console.log(`Spectator ${socket.user.username} disconnected from ${roomId}`);
} else {
// Regular player disconnect
socket.to(roomId).emit('game-player-left', {
username: socket.user.username,
userId: socket.user.userId
});
// Get updated player list and broadcast
if (dbRoomId) {
try {
const players = await gameRoomService.getRoomPlayers(dbRoomId);
io.to(roomId).emit('game-players-updated', { players });
} catch (err) {
console.log('Room may have been deleted on disconnect:', err.message);
}
}
// Check if game should auto-stop due to single player
await checkAndStopSinglePlayerGame(io, roomId, dbRoomId);
socket.to(roomId).emit('game-player-left', { socket.to(roomId).emit('game-player-left', {
username: socket.user.username, username: socket.user.username,
userId: socket.user.userId userId: socket.user.userId
@@ -596,4 +842,4 @@ function _tetrisRelayToOpponent(socket, event, data) {
} }
export { broadcastRoomsList }; export { broadcastRoomsList };
export default setupSocketIO; export default setupSocketIO;
@@ -14,9 +14,17 @@ export class GameRoomWindow extends Window {
this.currentRoom = null; this.currentRoom = null;
this.roomsList = []; this.roomsList = [];
this.socket = null; this.socket = null;
this.isSpectating = false;
this.messageTimeout = null;
this.buildUI(); this.buildUI();
this.bindEvents(); this.bindEvents();
// Handle page close/refresh to disconnect socket
window.addEventListener('beforeunload', () => {
if (this.socket?.connected) {
this.socket.disconnect();
}
});
eventBus.on(Events.USER_LOGGED_IN, () => { eventBus.on(Events.USER_LOGGED_IN, () => {
this.updateTabsAccess(); this.updateTabsAccess();
this.checkCurrentRoom(); this.checkCurrentRoom();