Merge manuel bientot finis
This commit is contained in:
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;
|
||||
+51
@@ -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;
|
||||
+69
@@ -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;
|
||||
+185
@@ -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;
|
||||
+20
@@ -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;
|
||||
+46
@@ -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;
|
||||
+63
@@ -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};
|
||||
+148
@@ -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
|
||||
};
|
||||
+240
@@ -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
|
||||
};
|
||||
+254
@@ -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
|
||||
};
|
||||
+27
@@ -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};
|
||||
+88
@@ -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
|
||||
};
|
||||
+795
@@ -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";
|
||||
}
|
||||
}
|
||||
+45
@@ -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();
|
||||
}
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 1006 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
+271
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+145
@@ -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');
|
||||
}
|
||||
}
|
||||
+114
@@ -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'
|
||||
};
|
||||
+453
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+1021
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>
|
||||
+1580
File diff suppressed because it is too large
Load Diff
+320
@@ -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);
|
||||
}
|
||||
}
|
||||
+682
@@ -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);
|
||||
}
|
||||
+31
@@ -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; }
|
||||
}
|
||||
+126
@@ -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);
|
||||
}
|
||||
+355
@@ -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;
|
||||
}
|
||||
+121
@@ -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>
|
||||
+366
@@ -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();
|
||||
+234
@@ -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 };
|
||||
@@ -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
|
||||
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);
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -123,13 +188,68 @@ async function getCurrentRoom(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
|
||||
};
|
||||
getCurrentRoom,
|
||||
updateRoomStatus,
|
||||
resetRoomScores,
|
||||
cleanupEndedRooms
|
||||
};
|
||||
|
||||
@@ -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)
|
||||
async function saveRoundPoints(currentScores, roundStartScores) {
|
||||
for (const [username, currentPoints] of Object.entries(currentScores)) {
|
||||
@@ -185,16 +221,94 @@ function setupSocketIO(io)
|
||||
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', (data) => {
|
||||
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
|
||||
@@ -209,6 +323,33 @@ function setupSocketIO(io)
|
||||
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);
|
||||
@@ -233,6 +374,9 @@ function setupSocketIO(io)
|
||||
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
|
||||
@@ -269,6 +413,12 @@ function setupSocketIO(io)
|
||||
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,
|
||||
@@ -285,6 +435,9 @@ function setupSocketIO(io)
|
||||
const roomId = socket.gameRoomId;
|
||||
if (!roomId) return;
|
||||
|
||||
// Spectators cannot clear canvas
|
||||
if (socket.isSpectator) return;
|
||||
|
||||
socket.to(roomId).emit('game-clear-canvas');
|
||||
});
|
||||
|
||||
@@ -293,6 +446,13 @@ function setupSocketIO(io)
|
||||
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;
|
||||
|
||||
@@ -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
|
||||
socket.on('game-end', () => {
|
||||
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);
|
||||
});
|
||||
|
||||
// ============================================
|
||||
@@ -540,11 +758,39 @@ function setupSocketIO(io)
|
||||
|
||||
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) {
|
||||
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);
|
||||
|
||||
|
||||
|
||||
socket.to(roomId).emit('game-player-left', {
|
||||
username: socket.user.username,
|
||||
userId: socket.user.userId
|
||||
@@ -596,4 +842,4 @@ function _tetrisRelayToOpponent(socket, event, data) {
|
||||
}
|
||||
|
||||
export { broadcastRoomsList };
|
||||
export default setupSocketIO;
|
||||
export default setupSocketIO;
|
||||
|
||||
@@ -14,9 +14,17 @@ export class GameRoomWindow extends Window {
|
||||
this.currentRoom = null;
|
||||
this.roomsList = [];
|
||||
this.socket = null;
|
||||
this.isSpectating = false;
|
||||
this.messageTimeout = null;
|
||||
this.buildUI();
|
||||
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, () => {
|
||||
this.updateTabsAccess();
|
||||
this.checkCurrentRoom();
|
||||
|
||||
Reference in New Issue
Block a user