7 Commits

Author SHA1 Message Date
H3XploR d3e2d9bdf9 cleaned 2026-03-22 13:48:16 +01:00
H3XploR 9c1e8e03bb Merge pull request #20 from OlaketalAmigo/TETRIS
Tetris
2026-03-20 23:39:05 +01:00
Georges-Leonard Prunet 55c241fd61 notification for login/register 2026-03-20 17:57:17 +01:00
Georges-Leonard Prunet 592bb38c0d fixed next drawer 2026-03-20 17:29:06 +01:00
H3XploR 72bc9ea628 added shield 2026-03-19 14:38:56 +01:00
H3XploR 557cf23f71 reset before join 2026-03-19 14:14:20 +01:00
H3XploR b51b711b10 ajout de theme 2026-03-19 14:00:20 +01:00
27 changed files with 967 additions and 678 deletions
-2
View File
@@ -24,8 +24,6 @@ services:
build: ./srcs/backend build: ./srcs/backend
expose: expose:
- "3001" - "3001"
# ports:
# - "3001:3001"
depends_on: depends_on:
- database - database
volumes: volumes:
@@ -730,6 +730,16 @@ function setupSocketIO(io)
_tetrisRelayToOpponent(socket, 'tetris:lines-cleared', data); _tetrisRelayToOpponent(socket, 'tetris:lines-cleared', data);
}); });
// Relay pur : shield-activated → adversaire uniquement
socket.on('tetris:shield-activated', () => {
_tetrisRelayToOpponent(socket, 'tetris:shield-activated', {});
});
// Relay pur : shield-deactivated → adversaire uniquement
socket.on('tetris:shield-deactivated', () => {
_tetrisRelayToOpponent(socket, 'tetris:shield-deactivated', {});
});
// start-duel → relayé aux DEUX joueurs de la room (inclut l'émetteur) // start-duel → relayé aux DEUX joueurs de la room (inclut l'émetteur)
socket.on('tetris:start-duel', () => { socket.on('tetris:start-duel', () => {
const code = socket.tetrisRoomCode; const code = socket.tetrisRoomCode;
+7 -7
View File
@@ -2,13 +2,13 @@
* Application entry point * Application entry point
* Initializes windows and handles menu interactions * Initializes windows and handles menu interactions
*/ */
import { windowRegistry } from './windows.js'; import { windowRegistry } from './core/windows.js';
import { LoginWindow } from './login.js'; import { LoginWindow } from './windows/login.js';
import { GlobalChat } from './global_chat.js'; import { GlobalChat } from './windows/global_chat.js';
import { AvatarWindow } from './avatar.js'; import { AvatarWindow } from './windows/avatar.js';
import { FriendsWindow } from './friends.js'; import { FriendsWindow } from './windows/friends.js';
import { GameRoomWindow } from './game_room.js'; import { GameRoomWindow } from './windows/game_room.js';
import { StatsWindow } from './stats.js'; import { StatsWindow } from './windows/stats.js';
/** /**
* Main application class * Main application class
@@ -21,7 +21,7 @@
<nav class="game" aria-label="Game"> <nav class="game" aria-label="Game">
<button class="game__item" data-action="Home page" aria-label="Home Page" <button class="game__item" data-action="Home page" aria-label="Home Page"
onclick="window.location.href='index.html'">Home Page</button> onclick="window.location.href='../index.html'">Home Page</button>
</nav> </nav>
<div class="page" aria-label="Page"> <div class="page" aria-label="Page">
@@ -29,6 +29,6 @@
</div> </div>
<script type="module" src="app.js"></script> <script type="module" src="../app.js"></script>
</body> </body>
</html> </html>
+2 -2
View File
@@ -21,9 +21,9 @@
<nav class="game" aria-label="Game"> <nav class="game" aria-label="Game">
<button class="game__item" data-action="new_game" aria-label="Start new game" <button class="game__item" data-action="new_game" aria-label="Start new game"
onclick="window.location.href='game.html'">Start new game</button> onclick="window.location.href='game/game.html'">Start new game</button>
<button class="game__item" data-action="tetris" aria-label="Tetris" <button class="game__item" data-action="tetris" aria-label="Tetris"
onclick="window.location.href='tetris.html'">Tetris</button> onclick="window.location.href='tetris/tetris.html'">Tetris</button>
</nav> </nav>
<script type="module" src="app.js"></script> <script type="module" src="app.js"></script>
-133
View File
@@ -1,133 +0,0 @@
// ─────────────────────────────────────────────
// RENDU
// ─────────────────────────────────────────────
const CELL = 30;
const COLORS = ['#000500','#00ff41','#39ff14','#00e676','#76ff03','#b2ff59','#00ffaa','#ccff00','#2d5a2d'];
const ctxMain = document.getElementById('canvas-main').getContext('2d');
const ctxNext = document.getElementById('canvas-next').getContext('2d');
const ctxHold = document.getElementById('canvas-hold').getContext('2d');
const ctxOpponent = document.getElementById('canvas-opponent').getContext('2d');
function drawCell(ctx, x, y, colorIndex, size) {
const p = 1;
const color = COLORS[colorIndex];
ctx.fillStyle = color;
ctx.fillRect(x * size + p, y * size + p, size - p * 2, size - p * 2);
// Glow inner
ctx.shadowColor = color;
ctx.shadowBlur = 6;
ctx.fillStyle = color;
ctx.fillRect(x * size + p + 2, y * size + p + 2, size - p * 2 - 4, size - p * 2 - 4);
ctx.shadowBlur = 0;
// Highlight top/left
ctx.fillStyle = 'rgba(200,255,200,0.2)';
ctx.fillRect(x * size + p, y * size + p, size - p * 2, 2);
ctx.fillRect(x * size + p, y * size + p, 2, size - p * 2);
// Shadow bottom/right
ctx.fillStyle = 'rgba(0,0,0,0.5)';
ctx.fillRect(x * size + p, (y + 1) * size - p - 2, size - p * 2, 2);
ctx.fillRect((x + 1) * size - p - 2, y * size + p, 2, size - p * 2);
}
function clearCanvas(ctx, w, h) {
ctx.fillStyle = '#000500';
ctx.fillRect(0, 0, w, h);
}
function drawGridLines(ctx, cols, rows, size) {
ctx.strokeStyle = 'rgba(0,255,65,0.06)';
ctx.lineWidth = 1;
for (let x = 0; x <= cols; x++) {
ctx.beginPath(); ctx.moveTo(x * size, 0); ctx.lineTo(x * size, rows * size); ctx.stroke();
}
for (let y = 0; y <= rows; y++) {
ctx.beginPath(); ctx.moveTo(0, y * size); ctx.lineTo(cols * size, y * size); ctx.stroke();
}
}
function drawGhost(ctx, piece, grid) {
if (!piece) return;
const ghost = { x: piece.getPosition().x, y: piece.getPosition().y };
const shape = piece.getShape();
while (true) {
ghost.y++;
let valid = true;
for (let row = 0; row < shape.length && valid; row++)
for (let col = 0; col < shape[row].length && valid; col++)
if (shape[row][col] !== 0) {
const ny = ghost.y + row;
const nx = ghost.x + col;
if (ny < 0 || ny >= grid.length || nx < 0 || nx >= grid[ny].length || grid[ny][nx] !== 0) valid = false;
}
if (!valid) { ghost.y--; break; }
}
if (ghost.y === piece.getPosition().y) return;
ctx.strokeStyle = 'rgba(0,255,65,0.25)';
ctx.lineWidth = 1;
for (let row = 0; row < shape.length; row++)
for (let col = 0; col < shape[row].length; col++)
if (shape[row][col] !== 0)
ctx.strokeRect(
(ghost.x + col) * CELL + 2,
(ghost.y + row) * CELL + 2,
CELL - 4, CELL - 4
);
}
function drawMiniPiece(ctx, piece, canvasW, canvasH) {
clearCanvas(ctx, canvasW, canvasH);
if (!piece) return;
const shape = piece.getShape();
const color = piece.getColor();
const s = 20;
const offsetX = Math.floor((canvasW / s - shape[0].length) / 2);
const offsetY = Math.floor((canvasH / s - shape.length) / 2);
for (let row = 0; row < shape.length; row++)
for (let col = 0; col < shape[row].length; col++)
if (shape[row][col] !== 0)
drawCell(ctx, offsetX + col, offsetY + row, color, s);
}
function render() {
// Grille principale
clearCanvas(ctxMain, 300, 600);
drawGridLines(ctxMain, 10, 20, CELL);
for (let y = 0; y < game.grid.length; y++)
for (let x = 0; x < game.grid[y].length; x++)
if (game.grid[y][x] !== 0)
drawCell(ctxMain, x, y, game.grid[y][x], CELL);
// Ghost + pièce courante
if (game.currentPiece) {
drawGhost(ctxMain, game.currentPiece, game.grid);
const { x, y } = game.currentPiece.getPosition();
const shape = game.currentPiece.getShape();
const color = game.currentPiece.getColor();
for (let row = 0; row < shape.length; row++)
for (let col = 0; col < shape[row].length; col++)
if (shape[row][col] !== 0)
drawCell(ctxMain, x + col, y + row, color, CELL);
}
// Panneaux miniatures
drawMiniPiece(ctxNext, game.nextPiece, 100, 80);
drawMiniPiece(ctxHold, game.storedPiece, 100, 80);
// Score
document.getElementById('score-display').textContent = game.score;
}
function renderOpponent(opponentGrid) {
clearCanvas(ctxOpponent, 300, 600);
drawGridLines(ctxOpponent, 10, 20, CELL);
for (let y = 0; y < opponentGrid.length; y++)
for (let x = 0; x < opponentGrid[y].length; x++)
if (opponentGrid[y][x] !== 0)
drawCell(ctxOpponent, x, y, opponentGrid[y][x], CELL);
}
@@ -3,17 +3,20 @@
// ───────────────────────────────────────────── // ─────────────────────────────────────────────
class Duel { class Duel {
constructor(socket, tetrisGame, onStatusChange, onStart) { // ui : { showOverlay, hideOverlay, render, renderOpponent, updateButtons }
constructor(socket, tetrisGame, onStatusChange, onStart, ui) {
this.socket = socket; this.socket = socket;
this.tetrisGame = tetrisGame; this.tetrisGame = tetrisGame;
this.onStatusChange = onStatusChange; // (status, opponentName) => void this.onStatusChange = onStatusChange;
this.onStart = onStart; // () => void — déclenche le début du jeu local this.onStart = onStart;
this.ui = ui;
this.action_queue = []; this.action_queue = [];
this.opponentGrid = this._emptyGrid(); this.opponentGrid = this._emptyGrid();
this.opponentScore = 0; this.opponentScore = 0;
this.roomCode = null; this.opponentShieldActive = false;
this.isReady = false; this.roomCode = null;
this.isReady = false;
this._bindSocketEvents(); this._bindSocketEvents();
} }
@@ -33,10 +36,11 @@ class Duel {
leave() { leave() {
if (!this.roomCode) return; if (!this.roomCode) return;
this.socket.emit('tetris:leave'); this.socket.emit('tetris:leave');
this.roomCode = null; this.roomCode = null;
this.isReady = false; this.isReady = false;
this.opponentGrid = this._emptyGrid(); this.opponentGrid = this._emptyGrid();
this.opponentScore = 0; this.opponentScore = 0;
this.opponentShieldActive = false;
} }
// ─── Hooks appelés par tetris.js ────────── // ─── Hooks appelés par tetris.js ──────────
@@ -48,9 +52,7 @@ class Duel {
onLocalLinesCleared(count, holeCol) { onLocalLinesCleared(count, holeCol) {
if (!this.isReady) return; if (!this.isReady) return;
const garbageLines = []; const garbageLines = Array.from({ length: count }, () => this._buildGarbageLine(holeCol));
for (let i = 0; i < count; i++)
garbageLines.push(this._buildGarbageLine(holeCol));
this.socket.emit('tetris:lines-cleared', { count, holeCol, garbageLines }); this.socket.emit('tetris:lines-cleared', { count, holeCol, garbageLines });
} }
@@ -60,6 +62,12 @@ class Duel {
this.endDuel(); this.endDuel();
} }
onLocalShieldChanged(event) {
if (!this.isReady) return;
if (event === 'activated') this.socket.emit('tetris:shield-activated');
else if (event === 'deactivated') this.socket.emit('tetris:shield-deactivated');
}
endDuel() { endDuel() {
this.isReady = false; this.isReady = false;
this.action_queue = []; this.action_queue = [];
@@ -70,8 +78,7 @@ class Duel {
synchronize_game() { synchronize_game() {
while (this.action_queue.length > 0) { while (this.action_queue.length > 0) {
const action = this.action_queue.shift(); this._processAction(this.action_queue.shift());
this._processAction(action);
} }
} }
@@ -81,7 +88,7 @@ class Duel {
this.opponentGrid = action.grid; this.opponentGrid = action.grid;
this.opponentScore = action.score; this.opponentScore = action.score;
document.getElementById('opponent-score').textContent = action.score; document.getElementById('opponent-score').textContent = action.score;
renderOpponent(this.opponentGrid); this.ui.renderOpponent(this.opponentGrid, this.opponentShieldActive);
break; break;
case 'LINES_CLEARED': case 'LINES_CLEARED':
@@ -89,9 +96,17 @@ class Duel {
break; break;
case 'OPPONENT_GAME_OVER': case 'OPPONENT_GAME_OVER':
showOverlay('YOU WIN', action.score); this.ui.showOverlay('YOU WIN', action.score);
this.endDuel(); this.endDuel();
break; break;
case 'OPPONENT_SHIELD_ACTIVATED':
this.opponentShieldActive = true;
break;
case 'OPPONENT_SHIELD_DEACTIVATED':
this.opponentShieldActive = false;
break;
} }
} }
@@ -127,28 +142,36 @@ class Duel {
this.action_queue.push({ type: 'OPPONENT_GAME_OVER', score: data.score, validBlock: data.validBlock }); this.action_queue.push({ type: 'OPPONENT_GAME_OVER', score: data.score, validBlock: data.validBlock });
}); });
this.socket.on('tetris:shield-activated', () => {
this.action_queue.push({ type: 'OPPONENT_SHIELD_ACTIVATED' });
});
this.socket.on('tetris:shield-deactivated', () => {
this.action_queue.push({ type: 'OPPONENT_SHIELD_DEACTIVATED' });
});
this.socket.on('tetris:start-duel', () => { this.socket.on('tetris:start-duel', () => {
if (this.onStart) this.onStart(); if (this.onStart) this.onStart();
}); });
this.socket.on('tetris:pause', () => { this.socket.on('tetris:pause', () => {
this.tetrisGame.pause(); this.tetrisGame.pause();
updateButtons(); this.ui.updateButtons();
if (this.tetrisGame.isPaused) showOverlay('PAUSE'); if (this.tetrisGame.isPaused) this.ui.showOverlay('PAUSE');
else hideOverlay(); else this.ui.hideOverlay();
}); });
this.socket.on('tetris:stop', () => { this.socket.on('tetris:stop', () => {
this.tetrisGame.stop(); this.tetrisGame.stop();
updateButtons(); this.ui.updateButtons();
render(); this.ui.render();
showOverlay('STOPPED'); this.ui.showOverlay('STOPPED');
}); });
this.socket.on('tetris:settings', (data) => { this.socket.on('tetris:settings', (data) => {
document.getElementById('input-ttd').value = data.timeToDown; document.getElementById('input-ttd').value = data.timeToDown;
document.getElementById('input-hardening').value = data.hardening; document.getElementById('input-hardening').value = data.hardening;
document.getElementById('input-decrement').value = data.decrementTTD; document.getElementById('input-decrement').value = data.decrementTTD;
this.tetrisGame.configure(data); this.tetrisGame.configure(data);
}); });
} }
+49
View File
@@ -0,0 +1,49 @@
// ─────────────────────────────────────────────
// EFFETS VISUELS : SCALING RESPONSIVE + MATRIX RAIN
// ─────────────────────────────────────────────
// ── Responsive scaling ──
(function() {
const container = document.getElementById('scale-container');
const NAT_W = 640;
const NAT_H = 1020;
function resize() {
const s = Math.min(window.innerWidth / NAT_W, window.innerHeight / NAT_H);
container.style.transform = 'scale(' + s + ')';
container.style.transformOrigin = 'top center';
container.style.marginBottom = ((s - 1) * NAT_H) + 'px';
}
resize();
window.addEventListener('resize', resize);
})();
// ── Matrix rain ──
(function() {
const canvas = document.getElementById('matrix-bg');
const ctx = canvas.getContext('2d');
const chars = 'アイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワヲン0123456789ABCDEF>_{}[]|\\/#@$%^&*01';
const fs = 14;
let drops = [];
function resize() { canvas.width = window.innerWidth; canvas.height = window.innerHeight; }
function initDrops() { drops = Array(Math.floor(canvas.width / fs)).fill(1); }
resize();
initDrops();
window.addEventListener('resize', () => { resize(); initDrops(); });
setInterval(function() {
ctx.fillStyle = 'rgba(0,5,0,0.05)';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.font = fs + 'px monospace';
for (let i = 0; i < drops.length; i++) {
const ch = chars[Math.floor(Math.random() * chars.length)];
ctx.fillStyle = drops[i] * fs < 50 ? '#aaffaa' : '#00ff41';
ctx.fillText(ch, i * fs, drops[i] * fs);
if (drops[i] * fs > canvas.height && Math.random() > 0.975) drops[i] = 0;
drops[i]++;
}
}, 40);
})();
@@ -0,0 +1,124 @@
// ─────────────────────────────────────────────
// LEADERBOARDS & HISTORIQUE
// ─────────────────────────────────────────────
function escapeHtml(str) {
return String(str).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
// ── Historique ───────────────────────────────
async function loadGameHistory() {
const token = localStorage.getItem('auth_token');
if (!token) return;
try {
const res = await fetch('/api/stats/tetris/history', {
headers: { 'Authorization': `Bearer ${token}` }
});
if (!res.ok) return;
renderGameHistory(await res.json());
} catch (err) {
console.error('Erreur chargement historique:', err);
}
}
function renderGameHistory(history) {
const tbody = document.getElementById('lb-history-body');
if (!tbody) return;
if (!history.length) {
tbody.innerHTML = '<tr><td colspan="5">Aucune partie jouée</td></tr>';
return;
}
tbody.innerHTML = history.map((entry, i) => {
const date = new Date(entry.played_at).toLocaleDateString('fr-FR', {
day: '2-digit', month: '2-digit', year: '2-digit',
hour: '2-digit', minute: '2-digit'
});
const type = entry.game_type === 'duel' ? 'Duel' : 'Solo';
let resultHtml = '—';
if (entry.result === 'win') resultHtml = '<span class="hist-win">Victoire</span>';
if (entry.result === 'loss') resultHtml = '<span class="hist-loss">Défaite</span>';
return `<tr>
<td>${i + 1}</td>
<td>${date}</td>
<td>${type}</td>
<td>${entry.score}</td>
<td>${resultHtml}</td>
</tr>`;
}).join('');
}
// ── Classements ──────────────────────────────
async function loadLeaderboards() {
const token = localStorage.getItem('auth_token');
if (!token) return;
const headers = { 'Authorization': `Bearer ${token}` };
try {
const [scoresRes, winsRes, meRes, rankScoreRes, rankWinsRes] = await Promise.all([
fetch('/api/stats/tetris/leaderboard/score', { headers }),
fetch('/api/stats/tetris/leaderboard/wins', { headers }),
fetch('/api/stats/me', { headers }),
fetch('/api/stats/tetris/rank/score', { headers }),
fetch('/api/stats/tetris/rank/wins', { headers })
]);
const me = meRes.ok ? await meRes.json() : null;
const rankScore = rankScoreRes.ok ? (await rankScoreRes.json()).rank : null;
const rankWins = rankWinsRes.ok ? (await rankWinsRes.json()).rank : null;
if (scoresRes.ok) renderLeaderboard('lb-scores-body', await scoresRes.json(), ['tetris_best_score', 'tetris_games_played'], me, rankScore);
if (winsRes.ok) renderLeaderboard('lb-wins-body', await winsRes.json(), ['tetris_wins', 'tetris_games_played'], me, rankWins);
} catch (err) {
console.error('Erreur chargement leaderboards:', err);
}
}
function renderLeaderboard(tbodyId, rows, [col1, col2], me, myRank) {
const tbody = document.getElementById(tbodyId);
if (!tbody) return;
if (!rows.length && !me) {
tbody.innerHTML = '<tr><td colspan="4">Aucun résultat</td></tr>';
return;
}
const myUsername = me?.username;
const inTop = rows.some(r => r.username === myUsername);
let html = rows.map((r, i) => {
const isMe = r.username === myUsername;
return `<tr class="${isMe ? 'lb-me' : ''}">
<td>${i + 1}</td>
<td>${escapeHtml(r.username)}${isMe ? ' <span class="lb-you">(vous)</span>' : ''}</td>
<td>${r[col1] ?? 0}</td>
<td>${r[col2] ?? 0}</td>
</tr>`;
}).join('');
if (!inTop && me && myRank !== null) {
html += `<tr class="lb-separator"><td colspan="4">· · ·</td></tr>`;
html += `<tr class="lb-me">
<td>${myRank}</td>
<td>${escapeHtml(myUsername)} <span class="lb-you">(vous)</span></td>
<td>${me[col1] ?? 0}</td>
<td>${me[col2] ?? 0}</td>
</tr>`;
}
tbody.innerHTML = html || '<tr><td colspan="4">Aucun résultat</td></tr>';
}
// ── Tabs ─────────────────────────────────────
document.querySelectorAll('.lb-tab').forEach(tab => {
tab.addEventListener('click', () => {
document.querySelectorAll('.lb-tab').forEach(t => t.classList.remove('lb-tab--active'));
document.querySelectorAll('.lb-content').forEach(c => c.classList.remove('lb-content--active'));
tab.classList.add('lb-tab--active');
document.getElementById(`lb-${tab.dataset.tab}`).classList.add('lb-content--active');
if (tab.dataset.tab === 'history') loadGameHistory();
});
});
loadLeaderboards();
loadGameHistory();
@@ -0,0 +1,228 @@
// ─────────────────────────────────────────────
// RENDU
// ─────────────────────────────────────────────
const CELL = 30;
const THEMES = {
green: {
bg: '#000500', panel: '#000d00', border: '#004400',
accent: '#00ff41', accent2: '#39ff14', dim: '#1a5c1a', text: '#00cc26',
grid: 'rgba(0,255,65,0.06)', ghost: 'rgba(0,255,65,0.25)', highlight: 'rgba(200,255,200,0.2)',
colors: ['#000500','#00ff41','#39ff14','#00e676','#76ff03','#b2ff59','#00ffaa','#ccff00','#2d5a2d']
},
red: {
bg: '#050000', panel: '#0d0000', border: '#440000',
accent: '#ff1744', accent2: '#ff4569', dim: '#5c1a1a', text: '#cc2626',
grid: 'rgba(255,23,68,0.06)', ghost: 'rgba(255,23,68,0.25)', highlight: 'rgba(255,200,200,0.2)',
colors: ['#050000','#ff1744','#ff4569','#e53935','#ff6d00','#ff8a65','#ff5252','#ff6e40','#5a2d2d']
},
yellow: {
bg: '#050500', panel: '#0d0d00', border: '#444400',
accent: '#ffd600', accent2: '#ffea00', dim: '#5c5c1a', text: '#ccaa00',
grid: 'rgba(255,214,0,0.06)', ghost: 'rgba(255,214,0,0.25)', highlight: 'rgba(255,255,200,0.2)',
colors: ['#050500','#ffd600','#ffea00','#ffab00','#fff176','#ffe57f','#ffff00','#ffc400','#5a5a2d']
},
blue: {
bg: '#000005', panel: '#00000d', border: '#000044',
accent: '#00b0ff', accent2: '#40c4ff', dim: '#1a1a5c', text: '#2626cc',
grid: 'rgba(0,176,255,0.06)', ghost: 'rgba(0,176,255,0.25)', highlight: 'rgba(200,200,255,0.2)',
colors: ['#000005','#00b0ff','#40c4ff','#0091ea','#448aff','#82b1ff','#00e5ff','#2979ff','#2d2d5a']
}
};
let currentTheme = THEMES.green;
let COLORS = [...currentTheme.colors];
function setColorTheme(themeName) {
currentTheme = THEMES[themeName] || THEMES.green;
COLORS = [...currentTheme.colors];
const root = document.documentElement;
root.style.setProperty('--bg', currentTheme.bg);
root.style.setProperty('--panel', currentTheme.panel);
root.style.setProperty('--border', currentTheme.border);
root.style.setProperty('--accent', currentTheme.accent);
root.style.setProperty('--accent2', currentTheme.accent2);
root.style.setProperty('--dim', currentTheme.dim);
root.style.setProperty('--text', currentTheme.text);
localStorage.setItem('tetris-theme', themeName);
document.querySelectorAll('.theme-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.theme === themeName);
});
}
const ctxMain = document.getElementById('canvas-main').getContext('2d');
const ctxNext = document.getElementById('canvas-next').getContext('2d');
const ctxHold = document.getElementById('canvas-hold').getContext('2d');
const ctxOpponent = document.getElementById('canvas-opponent').getContext('2d');
function drawCell(ctx, x, y, colorIndex, size) {
const p = 1;
const color = COLORS[colorIndex];
ctx.fillStyle = color;
ctx.fillRect(x * size + p, y * size + p, size - p * 2, size - p * 2);
ctx.shadowColor = color;
ctx.shadowBlur = 6;
ctx.fillStyle = color;
ctx.fillRect(x * size + p + 2, y * size + p + 2, size - p * 2 - 4, size - p * 2 - 4);
ctx.shadowBlur = 0;
ctx.fillStyle = currentTheme.highlight;
ctx.fillRect(x * size + p, y * size + p, size - p * 2, 2);
ctx.fillRect(x * size + p, y * size + p, 2, size - p * 2);
ctx.fillStyle = 'rgba(0,0,0,0.5)';
ctx.fillRect(x * size + p, (y + 1) * size - p - 2, size - p * 2, 2);
ctx.fillRect((x + 1) * size - p - 2, y * size + p, 2, size - p * 2);
}
function clearCanvas(ctx, w, h) {
ctx.fillStyle = currentTheme.bg;
ctx.fillRect(0, 0, w, h);
}
function drawGridLines(ctx, cols, rows, size) {
ctx.strokeStyle = currentTheme.grid;
ctx.lineWidth = 1;
for (let x = 0; x <= cols; x++) {
ctx.beginPath(); ctx.moveTo(x * size, 0); ctx.lineTo(x * size, rows * size); ctx.stroke();
}
for (let y = 0; y <= rows; y++) {
ctx.beginPath(); ctx.moveTo(0, y * size); ctx.lineTo(cols * size, y * size); ctx.stroke();
}
}
function drawGhost(ctx, piece, grid) {
if (!piece) return;
const ghost = { x: piece.getPosition().x, y: piece.getPosition().y };
const shape = piece.getShape();
while (true) {
ghost.y++;
let valid = true;
for (let row = 0; row < shape.length && valid; row++)
for (let col = 0; col < shape[row].length && valid; col++)
if (shape[row][col] !== 0) {
const ny = ghost.y + row;
const nx = ghost.x + col;
if (ny < 0 || ny >= grid.length || nx < 0 || nx >= grid[ny].length || grid[ny][nx] !== 0) valid = false;
}
if (!valid) { ghost.y--; break; }
}
if (ghost.y === piece.getPosition().y) return;
ctx.strokeStyle = currentTheme.ghost;
ctx.lineWidth = 1;
for (let row = 0; row < shape.length; row++)
for (let col = 0; col < shape[row].length; col++)
if (shape[row][col] !== 0)
ctx.strokeRect(
(ghost.x + col) * CELL + 2,
(ghost.y + row) * CELL + 2,
CELL - 4, CELL - 4
);
}
function drawMiniPiece(ctx, piece, canvasW, canvasH) {
clearCanvas(ctx, canvasW, canvasH);
if (!piece) return;
const shape = piece.getShape();
const color = piece.getColor();
const s = 20;
const offsetX = Math.floor((canvasW / s - shape[0].length) / 2);
const offsetY = Math.floor((canvasH / s - shape.length) / 2);
for (let row = 0; row < shape.length; row++)
for (let col = 0; col < shape[row].length; col++)
if (shape[row][col] !== 0)
drawCell(ctx, offsetX + col, offsetY + row, color, s);
}
function _drawShieldOverlay(ctx, w, h, alpha) {
ctx.save();
ctx.strokeStyle = `rgba(0,212,255,${alpha})`;
ctx.lineWidth = 4;
ctx.shadowColor = '#00d4ff';
ctx.shadowBlur = 16;
ctx.strokeRect(2, 2, w - 4, h - 4);
ctx.shadowBlur = 0;
ctx.restore();
}
// ── Rendu joueur local ────────────────────────────────────────────────────────
// Prend l'objet game explicitement — aucun accès à des globaux externes.
function render(game) {
clearCanvas(ctxMain, 300, 600);
drawGridLines(ctxMain, 10, 20, CELL);
for (let y = 0; y < game.grid.length; y++)
for (let x = 0; x < game.grid[y].length; x++)
if (game.grid[y][x] !== 0)
drawCell(ctxMain, x, y, game.grid[y][x], CELL);
if (game.currentPiece) {
drawGhost(ctxMain, game.currentPiece, game.grid);
const { x, y } = game.currentPiece.getPosition();
const shape = game.currentPiece.getShape();
const color = game.currentPiece.getColor();
for (let row = 0; row < shape.length; row++)
for (let col = 0; col < shape[row].length; col++)
if (shape[row][col] !== 0)
drawCell(ctxMain, x + col, y + row, color, CELL);
}
if (game.shieldActive) {
const pulse = 0.6 + 0.4 * Math.sin(Date.now() / 150);
_drawShieldOverlay(ctxMain, 300, 600, pulse);
}
drawMiniPiece(ctxNext, game.nextPiece, 100, 80);
drawMiniPiece(ctxHold, game.storedPiece, 100, 80);
document.getElementById('score-display').textContent = game.score;
const shieldEl = document.getElementById('shield-status-display');
const shieldBar = document.getElementById('shield-bar');
if (shieldEl) {
if (game.shieldActive) {
const secs = Math.ceil(game.shieldActiveMs / 1000);
shieldEl.textContent = `ACTIF ${secs}s`;
shieldEl.className = 'score-value shield-active';
if (shieldBar) shieldBar.style.width = (game.shieldActiveMs / 3000 * 100) + '%';
} else if (game.shieldReady) {
shieldEl.textContent = 'PRÊT';
shieldEl.className = 'score-value shield-ready';
if (shieldBar) shieldBar.style.width = '100%';
} else {
const secs = Math.ceil(game.shieldCooldownMs / 1000);
shieldEl.textContent = `${secs}s`;
shieldEl.className = 'score-value shield-cooldown';
if (shieldBar) shieldBar.style.width = ((1 - game.shieldCooldownMs / 60000) * 100) + '%';
}
}
}
// ── Rendu adversaire ─────────────────────────────────────────────────────────
// Prend grid et shieldActive explicitement — aucun accès à l'objet duel global.
function renderOpponent(grid, shieldActive) {
clearCanvas(ctxOpponent, 300, 600);
drawGridLines(ctxOpponent, 10, 20, CELL);
for (let y = 0; y < grid.length; y++)
for (let x = 0; x < grid[y].length; x++)
if (grid[y][x] !== 0)
drawCell(ctxOpponent, x, y, grid[y][x], CELL);
if (shieldActive) {
const pulse = 0.6 + 0.4 * Math.sin(Date.now() / 150);
_drawShieldOverlay(ctxOpponent, 300, 600, pulse);
}
const oppShieldEl = document.getElementById('opponent-shield-indicator');
if (oppShieldEl) oppShieldEl.style.display = shieldActive ? 'block' : 'none';
}
// Restaure le thème sauvegardé
(function() {
const saved = localStorage.getItem('tetris-theme');
if (saved && THEMES[saved]) setColorTheme(saved);
})();
@@ -445,6 +445,37 @@ button:disabled { opacity: 0.3; cursor: not-allowed; }
letter-spacing: 0.05em; letter-spacing: 0.05em;
} }
/* ── Theme color picker ── */
.theme-btns {
display: flex;
gap: 6px;
margin-top: 2px;
}
.theme-btn {
width: 22px;
height: 22px;
min-width: 22px;
padding: 0;
border-radius: 50%;
border: 2px solid transparent;
cursor: pointer;
transition: transform 0.15s, box-shadow 0.15s;
}
.theme-btn[data-theme="green"] { background: #00ff41; }
.theme-btn[data-theme="red"] { background: #ff1744; }
.theme-btn[data-theme="yellow"] { background: #ffd600; }
.theme-btn[data-theme="blue"] { background: #00b0ff; }
.theme-btn:hover { transform: scale(1.2); }
.theme-btn.active {
border-color: #ffffff;
box-shadow: 0 0 8px currentColor;
transform: scale(1.15);
}
#settings-panel input[type="number"] { #settings-panel input[type="number"] {
background: var(--bg); background: var(--bg);
border: 1px solid var(--border); border: 1px solid var(--border);
@@ -620,3 +651,36 @@ button:disabled { opacity: 0.3; cursor: not-allowed; }
} }
body { overflow: hidden; } body { overflow: hidden; }
/* ── Shield ───────────────────────────────── */
.shield-bar-bg {
width: 100%;
height: 4px;
background: rgba(0,212,255,0.15);
border-radius: 2px;
margin-top: 4px;
overflow: hidden;
}
.shield-bar {
height: 100%;
background: #00d4ff;
border-radius: 2px;
transition: width 0.1s linear;
box-shadow: 0 0 6px #00d4ff;
}
.shield-ready { color: #00d4ff !important; }
.shield-active { color: #00ffff !important; text-shadow: 0 0 8px #00ffff; }
.shield-cooldown { color: var(--dim) !important; }
kbd {
display: inline-block;
padding: 0 3px;
border: 1px solid var(--border);
border-radius: 2px;
font-size: 0.6rem;
font-family: inherit;
color: var(--dim);
}
@@ -15,10 +15,9 @@
<h1 data-text="TETRIS">TETRIS<span class="cursor">_</span></h1> <h1 data-text="TETRIS">TETRIS<span class="cursor">_</span></h1>
<!-- Bouton home --> <a id="btn-home" href="/">Home</a>
<a id="btn-home" href="/">Home</a>
<!-- Panneau de connexion duel --> <!-- Panneau duel -->
<div id="duel-panel"> <div id="duel-panel">
<span class="settings-title">Duel</span> <span class="settings-title">Duel</span>
<div class="duel-row"> <div class="duel-row">
@@ -40,7 +39,7 @@
<div id="local-section"> <div id="local-section">
<div id="app"> <div id="app">
<!-- Colonne gauche : Hold + Score + Boutons + Settings --> <!-- Colonne gauche : Hold + Score + Boutons + Paramètres -->
<div id="left-column"> <div id="left-column">
<div class="panel"> <div class="panel">
<div class="panel-title">Hold</div> <div class="panel-title">Hold</div>
@@ -51,6 +50,12 @@
<div class="score-value" id="score-display">0</div> <div class="score-value" id="score-display">0</div>
</div> </div>
<div class="score-block">
<div class="score-label">Shield <kbd>E</kbd></div>
<div class="score-value shield-ready" id="shield-status-display">PRÊT</div>
<div class="shield-bar-bg"><div class="shield-bar" id="shield-bar"></div></div>
</div>
<div class="btn-group"> <div class="btn-group">
<button id="btn-start">Start</button> <button id="btn-start">Start</button>
<button id="btn-pause" disabled>Pause</button> <button id="btn-pause" disabled>Pause</button>
@@ -58,9 +63,18 @@
</div> </div>
</div> </div>
<!-- Panneau de configuration --> <!-- Paramètres -->
<div id="settings-panel"> <div id="settings-panel">
<div class="settings-title">Paramètres</div> <div class="settings-title">Paramètres</div>
<div class="settings-row">
<label>Couleur</label>
<div class="theme-btns">
<button class="theme-btn active" data-theme="green" title="Vert"></button>
<button class="theme-btn" data-theme="red" title="Rouge"></button>
<button class="theme-btn" data-theme="yellow" title="Jaune"></button>
<button class="theme-btn" data-theme="blue" title="Bleu"></button>
</div>
</div>
<div class="settings-row"> <div class="settings-row">
<label for="input-ttd">Vitesse initiale (ms)</label> <label for="input-ttd">Vitesse initiale (ms)</label>
<input type="number" id="input-ttd" min="100" max="3000" step="50" value="1000"> <input type="number" id="input-ttd" min="100" max="3000" step="50" value="1000">
@@ -97,6 +111,7 @@
<div><span>W</span> Rot. droite</div> <div><span>W</span> Rot. droite</div>
<div><span>Espace</span> Drop</div> <div><span>Espace</span> Drop</div>
<div><span>C</span> Hold</div> <div><span>C</span> Hold</div>
<div><span>E</span> Shield</div>
</div> </div>
</div> </div>
@@ -111,6 +126,7 @@
<div class="score-label">Score</div> <div class="score-label">Score</div>
<div class="score-value" id="opponent-score"></div> <div class="score-value" id="opponent-score"></div>
</div> </div>
<div id="opponent-shield-indicator" style="display:none;color:#00d4ff;font-size:0.75rem;text-align:center;letter-spacing:1px;margin-top:4px;">&#x1F6E1; SHIELD ACTIF</div>
</div> </div>
<div id="opponent-wrapper"> <div id="opponent-wrapper">
@@ -134,34 +150,22 @@
<div id="lb-scores" class="lb-content lb-content--active"> <div id="lb-scores" class="lb-content lb-content--active">
<table class="lb-table"> <table class="lb-table">
<thead> <thead><tr><th>#</th><th>Joueur</th><th>Meilleur score</th><th>Parties</th></tr></thead>
<tr><th>#</th><th>Joueur</th><th>Meilleur score</th><th>Parties</th></tr> <tbody id="lb-scores-body"><tr><td colspan="4">Chargement…</td></tr></tbody>
</thead>
<tbody id="lb-scores-body">
<tr><td colspan="4">Chargement…</td></tr>
</tbody>
</table> </table>
</div> </div>
<div id="lb-wins" class="lb-content"> <div id="lb-wins" class="lb-content">
<table class="lb-table"> <table class="lb-table">
<thead> <thead><tr><th>#</th><th>Joueur</th><th>Victoires</th><th>Parties</th></tr></thead>
<tr><th>#</th><th>Joueur</th><th>Victoires</th><th>Parties</th></tr> <tbody id="lb-wins-body"><tr><td colspan="4">Chargement…</td></tr></tbody>
</thead>
<tbody id="lb-wins-body">
<tr><td colspan="4">Chargement…</td></tr>
</tbody>
</table> </table>
</div> </div>
<div id="lb-history" class="lb-content"> <div id="lb-history" class="lb-content">
<table class="lb-table"> <table class="lb-table">
<thead> <thead><tr><th>#</th><th>Date</th><th>Type</th><th>Score</th><th>Résultat</th></tr></thead>
<tr><th>#</th><th>Date</th><th>Type</th><th>Score</th><th>Résultat</th></tr> <tbody id="lb-history-body"><tr><td colspan="5">Chargement…</td></tr></tbody>
</thead>
<tbody id="lb-history-body">
<tr><td colspan="5">Chargement…</td></tr>
</tbody>
</table> </table>
</div> </div>
</div> </div>
@@ -173,59 +177,9 @@
<script src="tetris.js"></script> <script src="tetris.js"></script>
<script src="renderer.js"></script> <script src="renderer.js"></script>
<script src="duel.js"></script> <script src="duel.js"></script>
<script src="leaderboard.js"></script>
<script src="ui.js"></script> <script src="ui.js"></script>
<script src="effects.js"></script>
<script>
// ── Responsive scaling ──────────────────────────
(function() {
const container = document.getElementById('scale-container');
// Dimensions naturelles du contenu (single-player)
const NAT_W = 640;
const NAT_H = 1020;
function resize() {
const s = Math.min(
window.innerWidth / NAT_W,
window.innerHeight / NAT_H
);
container.style.transform = 'scale(' + s + ')';
container.style.transformOrigin = 'top center';
// Compense l'espace de layout non affecté par transform
container.style.marginBottom = ((s - 1) * NAT_H) + 'px';
}
resize();
window.addEventListener('resize', resize);
})();
</script>
<script>
// ── Matrix rain ──────────────────────────────────
(function() {
const canvas = document.getElementById('matrix-bg');
const ctx = canvas.getContext('2d');
function resize() { canvas.width = window.innerWidth; canvas.height = window.innerHeight; }
resize();
window.addEventListener('resize', resize);
const chars = 'アイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワヲン0123456789ABCDEF>_{}[]|\\/#@$%^&*01';
const fs = 14;
let drops = [];
function initDrops() { drops = Array(Math.floor(canvas.width / fs)).fill(1); }
initDrops();
window.addEventListener('resize', initDrops);
setInterval(function() {
ctx.fillStyle = 'rgba(0,5,0,0.05)';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.font = fs + 'px monospace';
for (let i = 0; i < drops.length; i++) {
const ch = chars[Math.floor(Math.random() * chars.length)];
ctx.fillStyle = drops[i] * fs < 50 ? '#aaffaa' : '#00ff41';
ctx.fillText(ch, i * fs, drops[i] * fs);
if (drops[i] * fs > canvas.height && Math.random() > 0.975) drops[i] = 0;
drops[i]++;
}
}, 40);
})();
</script>
</body> </body>
</html> </html>
@@ -3,11 +3,12 @@
// ─────────────────────────────────────────── // ───────────────────────────────────────────
class Tetris { class Tetris {
constructor(onRender, onGameOver, onBlockPlaced = null, onLinesCleared = null) { constructor(onRender, onGameOver, onBlockPlaced = null, onLinesCleared = null, onShieldChanged = null) {
this.onRender = onRender; this.onRender = onRender;
this.onGameOver = onGameOver; this.onGameOver = onGameOver;
this.onBlockPlaced = onBlockPlaced; this.onBlockPlaced = onBlockPlaced;
this.onLinesCleared = onLinesCleared; this.onLinesCleared = onLinesCleared;
this.onShieldChanged = onShieldChanged;
this.grid = this._createGrid(10, 20); this.grid = this._createGrid(10, 20);
this.bufferGrid = this._createGrid(10, 5); this.bufferGrid = this._createGrid(10, 5);
@@ -28,6 +29,12 @@ class Tetris {
this.isPaused = false; this.isPaused = false;
this.canStore = true; this.canStore = true;
// Shield
this.shieldActive = false;
this.shieldActiveMs = 0;
this.shieldCooldownMs = 0;
this.shieldReady = true; // prêt dès le début
this.animationFrameId = null; this.animationFrameId = null;
this.lastTime = 0; this.lastTime = 0;
this.accumulator = 0; this.accumulator = 0;
@@ -55,6 +62,10 @@ class Tetris {
this.timeToDown = this.initialTimeToDown; this.timeToDown = this.initialTimeToDown;
this.storedPiece = null; this.storedPiece = null;
this.canStore = true; this.canStore = true;
this.shieldActive = false;
this.shieldActiveMs = 0;
this.shieldCooldownMs = 0;
this.shieldReady = true;
this._spawnNewPiece(); this._spawnNewPiece();
document.addEventListener('keydown', this._keyHandler); document.addEventListener('keydown', this._keyHandler);
this._startGameLoop(); this._startGameLoop();
@@ -108,6 +119,8 @@ class Tetris {
this.lastTime = currentTime; this.lastTime = currentTime;
this.accumulator += deltaTime; this.accumulator += deltaTime;
this._updateShield(deltaTime);
while (this.isRunning && this.accumulator >= this.timeToDown) { while (this.isRunning && this.accumulator >= this.timeToDown) {
this._tick(); this._tick();
this.accumulator -= this.timeToDown; this.accumulator -= this.timeToDown;
@@ -174,11 +187,42 @@ class Tetris {
e.preventDefault(); e.preventDefault();
if (!this.isPaused) this._storePiece(); if (!this.isPaused) this._storePiece();
break; break;
case 'e': case 'E':
e.preventDefault();
if (!this.isPaused) this._activateShield();
break;
} }
this.onRender(); this.onRender();
} }
_activateShield() {
if (!this.shieldReady || this.shieldActive) return;
this.shieldActive = true;
this.shieldActiveMs = 3000;
this.shieldReady = false;
if (this.onShieldChanged) this.onShieldChanged('activated');
}
_updateShield(deltaTime) {
if (this.shieldActive) {
this.shieldActiveMs -= deltaTime;
if (this.shieldActiveMs <= 0) {
this.shieldActive = false;
this.shieldActiveMs = 0;
this.shieldCooldownMs = 60000;
if (this.onShieldChanged) this.onShieldChanged('deactivated');
}
} else if (!this.shieldReady) {
this.shieldCooldownMs -= deltaTime;
if (this.shieldCooldownMs <= 0) {
this.shieldCooldownMs = 0;
this.shieldReady = true;
if (this.onShieldChanged) this.onShieldChanged('ready');
}
}
}
_hardDrop() { _hardDrop() {
if (!this.currentPiece) return; if (!this.currentPiece) return;
let dist = 0; let dist = 0;
@@ -275,8 +319,17 @@ class Tetris {
const points = [0, 100, 300, 500, 800]; const points = [0, 100, 300, 500, 800];
this.score += points[cleared]; this.score += points[cleared];
this.count += points[cleared]; this.count += points[cleared];
if (this.onLinesCleared && cleared > 0) if (cleared > 0) {
this.onLinesCleared(cleared, this.lastLandingCol); // Chaque ligne remplie réduit le cooldown du shield de 10s
if (!this.shieldActive && !this.shieldReady) {
this.shieldCooldownMs = Math.max(0, this.shieldCooldownMs - cleared * 10000);
if (this.shieldCooldownMs === 0) {
this.shieldReady = true;
if (this.onShieldChanged) this.onShieldChanged('ready');
}
}
if (this.onLinesCleared) this.onLinesCleared(cleared, this.lastLandingCol);
}
} }
_makeHarder() { _makeHarder() {
@@ -361,6 +414,7 @@ class Tetris {
} }
addGarbageLines(lines) { addGarbageLines(lines) {
if (this.shieldActive) return; // shield bloque les lignes garbage
if (!this.isRunning || !lines.length) return; if (!this.isRunning || !lines.length) return;
this.grid.splice(0, lines.length); this.grid.splice(0, lines.length);
for (const line of lines) this.grid.push([...line]); // ...line pour faire une copie independante for (const line of lines) this.grid.push([...line]); // ...line pour faire une copie independante
@@ -0,0 +1,265 @@
// ─────────────────────────────────────────────
// UI — Contrôles, socket, duel, matchmaking
// ─────────────────────────────────────────────
// ── Références DOM ───────────────────────────
const btnStart = document.getElementById('btn-start');
const btnPause = document.getElementById('btn-pause');
const btnStop = document.getElementById('btn-stop');
const btnRestart = document.getElementById('btn-restart');
const overlay = document.getElementById('overlay');
const inputTTD = document.getElementById('input-ttd');
const inputHardening = document.getElementById('input-hardening');
const inputDecrement = document.getElementById('input-decrement');
const btnJoinDuel = document.getElementById('btn-join-duel');
const btnLeaveDuel = document.getElementById('btn-leave-duel');
const inputRoomCode = document.getElementById('input-room-code');
const duelStatusEl = document.getElementById('duel-status');
const opponentSection = document.getElementById('opponent-section');
const btnMatchmaking = document.getElementById('btn-matchmaking');
const btnMatchmakingCancel = document.getElementById('btn-matchmaking-cancel');
const matchmakingStatusEl = document.getElementById('matchmaking-status');
// ── Overlay ──────────────────────────────────
function showOverlay(title, score) {
document.getElementById('overlay-title').textContent = title;
document.getElementById('overlay-score').textContent = score !== undefined ? `Score : ${score}` : '';
overlay.classList.add('visible');
}
function hideOverlay() {
overlay.classList.remove('visible');
}
// ── Boutons ──────────────────────────────────
function updateButtons() {
btnStart.disabled = game.isRunning;
btnPause.disabled = !game.isRunning;
btnStop.disabled = !game.isRunning;
btnPause.textContent = game.isPaused ? 'Resume' : 'Pause';
inputTTD.disabled = game.isRunning;
inputHardening.disabled = game.isRunning;
inputDecrement.disabled = game.isRunning;
}
// ── Socket ───────────────────────────────────
const socket = io({
auth: { token: localStorage.getItem('auth_token') },
reconnection: true,
reconnectionAttempts: 5,
reconnectionDelay: 1000,
transports: ['websocket', 'polling']
});
// ── Duel ─────────────────────────────────────
let duel = null;
// Callbacks passés au Duel pour qu'il pilote l'UI sans accéder à des globaux.
function _makeDuelUI() {
return {
showOverlay,
hideOverlay,
updateButtons,
render: () => render(game),
renderOpponent: (grid, shieldActive) => renderOpponent(grid, shieldActive),
};
}
function updateDuelStatus(status, opponentName) {
duelStatusEl.className = '';
if (status === 'waiting') {
duelStatusEl.textContent = "En attente d'un adversaire…";
duelStatusEl.classList.add('waiting');
opponentSection.classList.remove('visible');
} else if (status === 'ready') {
duelStatusEl.textContent = `Prêt — ${opponentName}`;
duelStatusEl.classList.add('ready');
opponentSection.classList.add('visible');
if (duel) duel.hideOpponentOverlay();
const grid = duel ? duel.opponentGrid : Array.from({ length: 20 }, () => Array(10).fill(0));
const shieldActive = duel ? duel.opponentShieldActive : false;
renderOpponent(grid, shieldActive);
} else {
duelStatusEl.textContent = '—';
opponentSection.classList.remove('visible');
}
}
function startLocalGame() {
hideOverlay();
game.start();
updateButtons();
render(game);
}
// Crée un Duel et rejoint la salle — mutualisé entre le bouton et le matchmaking.
function _joinDuelRoom(code) {
if (duel) duel.leave();
if (game.isRunning) { game.stop(); hideOverlay(); render(game); updateButtons(); }
duel = new Duel(socket, game, updateDuelStatus, startLocalGame, _makeDuelUI());
duel.join(code);
btnJoinDuel.disabled = true;
btnLeaveDuel.disabled = false;
inputRoomCode.disabled = true;
updateDuelStatus('waiting', null);
}
btnJoinDuel.addEventListener('click', () => {
const code = inputRoomCode.value.trim().toUpperCase();
if (!code) return;
_joinDuelRoom(code);
});
btnLeaveDuel.addEventListener('click', () => {
if (duel) { duel.leave(); duel = null; }
btnJoinDuel.disabled = false;
btnLeaveDuel.disabled = true;
inputRoomCode.disabled = false;
updateDuelStatus(null, null);
});
// ── Matchmaking ──────────────────────────────
btnMatchmaking.addEventListener('click', () => {
socket.emit('tetris:matchmaking-join');
btnMatchmaking.disabled = true;
btnMatchmakingCancel.disabled = false;
btnJoinDuel.disabled = true;
matchmakingStatusEl.textContent = 'Recherche en cours…';
matchmakingStatusEl.className = 'waiting';
});
btnMatchmakingCancel.addEventListener('click', () => {
socket.emit('tetris:matchmaking-leave');
btnMatchmaking.disabled = false;
btnMatchmakingCancel.disabled = true;
btnJoinDuel.disabled = false;
matchmakingStatusEl.textContent = '';
});
socket.on('tetris:matchmaking-status', (data) => {
if (data.status === 'searching') {
matchmakingStatusEl.textContent = `Recherche… (${data.position} joueur(s) en attente)`;
} else if (data.status === 'idle') {
matchmakingStatusEl.textContent = '';
btnMatchmaking.disabled = false;
btnMatchmakingCancel.disabled = true;
btnJoinDuel.disabled = false;
}
});
socket.on('tetris:matched', (data) => {
matchmakingStatusEl.textContent = `Adversaire trouvé : ${data.opponent} !`;
matchmakingStatusEl.className = 'ready';
btnMatchmaking.disabled = false;
btnMatchmakingCancel.disabled = true;
inputRoomCode.value = data.roomCode;
_joinDuelRoom(data.roomCode);
});
// ── Jeu ──────────────────────────────────────
function saveTetrisScore(score) {
const token = localStorage.getItem('auth_token');
if (!token) return;
fetch('/api/stats/tetris/score', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
body: JSON.stringify({ score })
})
.then(r => r.json())
.then(data => { if (data.bestScore !== undefined) console.log('Meilleur score tetris:', data.bestScore); })
.catch(err => console.error('Erreur sauvegarde score tetris:', err));
}
const game = new Tetris(
// onRender
() => {
if (duel) duel.synchronize_game();
render(game);
updateButtons();
},
// onGameOver
(score, validBlock) => {
if (duel && duel.isReady) duel.onLocalGameOver(score, validBlock);
else saveTetrisScore(score);
render(game);
updateButtons();
showOverlay('GAME OVER', score);
loadLeaderboards();
loadGameHistory();
},
// onBlockPlaced
(grid) => { if (duel) duel.onLocalBlockPlaced(grid, game.score); },
// onLinesCleared
(count, holeCol) => { if (duel) duel.onLocalLinesCleared(count, holeCol); },
// onShieldChanged
(event) => { if (duel) duel.onLocalShieldChanged(event); }
);
// ── Boutons de contrôle ──────────────────────
btnStart.addEventListener('click', () => {
if (duel && duel.isReady) duel.startDuel();
else startLocalGame();
});
btnPause.addEventListener('click', () => {
if (duel && duel.isReady) {
duel.togglePause();
} else {
game.pause();
updateButtons();
if (game.isPaused) showOverlay('PAUSE');
else hideOverlay();
}
});
btnStop.addEventListener('click', () => {
if (duel && duel.isReady) {
duel.stop();
} else {
game.stop();
updateButtons();
render(game);
showOverlay('STOPPED');
}
});
if (btnRestart) {
btnRestart.addEventListener('click', () => {
if (duel && duel.isReady) return;
game.restart();
updateButtons();
render(game);
});
}
// ── Paramètres ───────────────────────────────
function applySettings() {
const settings = {
timeToDown: parseInt(inputTTD.value, 10),
hardening: parseInt(inputHardening.value, 10),
decrementTTD: parseInt(inputDecrement.value, 10),
};
game.configure(settings);
if (duel && duel.isReady) duel.syncSettings(settings);
}
inputTTD.addEventListener('change', applySettings);
inputHardening.addEventListener('change', applySettings);
inputDecrement.addEventListener('change', applySettings);
// ── Thème ────────────────────────────────────
document.querySelectorAll('.theme-btn').forEach(btn => {
btn.addEventListener('click', () => setColorTheme(btn.dataset.theme));
});
-406
View File
@@ -1,406 +0,0 @@
// ─────────────────────────────────────────────
// UI
// ─────────────────────────────────────────────
const btnStart = document.getElementById('btn-start');
const btnPause = document.getElementById('btn-pause');
const btnStop = document.getElementById('btn-stop');
const overlay = document.getElementById('overlay');
const inputTTD = document.getElementById('input-ttd');
const inputHardening = document.getElementById('input-hardening');
const inputDecrement = document.getElementById('input-decrement');
// Duel UI
const btnJoinDuel = document.getElementById('btn-join-duel');
const btnLeaveDuel = document.getElementById('btn-leave-duel');
const inputRoomCode = document.getElementById('input-room-code');
const duelStatusEl = document.getElementById('duel-status');
const opponentSection = document.getElementById('opponent-section');
// Matchmaking UI
const btnMatchmaking = document.getElementById('btn-matchmaking');
const btnMatchmakingCancel = document.getElementById('btn-matchmaking-cancel');
const matchmakingStatusEl = document.getElementById('matchmaking-status');
function updateButtons() {
btnStart.disabled = game.isRunning;
btnPause.disabled = !game.isRunning;
btnStop.disabled = !game.isRunning;
btnPause.textContent = game.isPaused ? 'Resume' : 'Pause';
inputTTD.disabled = game.isRunning;
inputHardening.disabled = game.isRunning;
inputDecrement.disabled = game.isRunning;
}
function showOverlay(title, score) {
document.getElementById('overlay-title').textContent = title;
document.getElementById('overlay-score').textContent = score !== undefined ? `Score : ${score}` : '';
overlay.classList.add('visible');
}
function hideOverlay() {
overlay.classList.remove('visible');
}
// ─────────────────────────────────────────────
// SOCKET + DUEL
// ─────────────────────────────────────────────
const socket = io({
auth: { token: localStorage.getItem('auth_token') },
reconnection: true,
reconnectionAttempts: 5,
reconnectionDelay: 1000,
transports: ['websocket', 'polling']
});
let duel = null;
function updateDuelStatus(status, opponentName) {
duelStatusEl.className = '';
if (status === 'waiting') {
duelStatusEl.textContent = 'En attente d\'un adversaire…';
duelStatusEl.classList.add('waiting');
opponentSection.classList.remove('visible');
} else if (status === 'ready') {
duelStatusEl.textContent = `Prêt — ${opponentName}`;
duelStatusEl.classList.add('ready');
opponentSection.classList.add('visible');
if (duel) duel.hideOpponentOverlay();
renderOpponent(duel ? duel.opponentGrid : Array.from({length:20}, () => Array(10).fill(0)));
} else {
duelStatusEl.textContent = '—';
opponentSection.classList.remove('visible');
}
}
function startLocalGame() {
hideOverlay();
game.start();
updateButtons();
render();
}
// ─────────────────────────────────────────────
// SCORE SAVE (solo)
// ─────────────────────────────────────────────
function saveTetrisScore(score) {
const token = localStorage.getItem('auth_token');
if (!token) return;
fetch('/api/stats/tetris/score', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ score })
})
.then(r => r.json())
.then(data => {
if (data.bestScore !== undefined) {
console.log('Meilleur score tetris:', data.bestScore);
}
})
.catch(err => console.error('Erreur sauvegarde score tetris:', err));
}
// ─────────────────────────────────────────────
// DUEL BUTTONS
// ─────────────────────────────────────────────
btnJoinDuel.addEventListener('click', () => {
const code = inputRoomCode.value.trim().toUpperCase();
if (!code) return;
if (duel) { duel.leave(); }
duel = new Duel(socket, game, updateDuelStatus, startLocalGame);
duel.join(code);
btnJoinDuel.disabled = true;
btnLeaveDuel.disabled = false;
inputRoomCode.disabled = true;
updateDuelStatus('waiting', null);
});
btnLeaveDuel.addEventListener('click', () => {
if (duel) { duel.leave(); duel = null; }
btnJoinDuel.disabled = false;
btnLeaveDuel.disabled = true;
inputRoomCode.disabled = false;
updateDuelStatus(null, null);
});
// ─────────────────────────────────────────────
// MATCHMAKING
// ─────────────────────────────────────────────
btnMatchmaking.addEventListener('click', () => {
socket.emit('tetris:matchmaking-join');
btnMatchmaking.disabled = true;
btnMatchmakingCancel.disabled = false;
btnJoinDuel.disabled = true;
matchmakingStatusEl.textContent = 'Recherche en cours…';
matchmakingStatusEl.className = 'waiting';
});
btnMatchmakingCancel.addEventListener('click', () => {
socket.emit('tetris:matchmaking-leave');
btnMatchmaking.disabled = false;
btnMatchmakingCancel.disabled = true;
btnJoinDuel.disabled = false;
matchmakingStatusEl.textContent = '';
});
socket.on('tetris:matchmaking-status', (data) => {
if (data.status === 'searching') {
matchmakingStatusEl.textContent = `Recherche… (${data.position} joueur(s) en attente)`;
} else if (data.status === 'idle') {
matchmakingStatusEl.textContent = '';
btnMatchmaking.disabled = false;
btnMatchmakingCancel.disabled = true;
btnJoinDuel.disabled = false;
}
});
socket.on('tetris:matched', (data) => {
matchmakingStatusEl.textContent = `Adversaire trouvé : ${data.opponent} !`;
matchmakingStatusEl.className = 'ready';
btnMatchmaking.disabled = false;
btnMatchmakingCancel.disabled = true;
btnJoinDuel.disabled = false;
// Auto-rejoindre la salle générée
if (duel) { duel.leave(); }
duel = new Duel(socket, game, updateDuelStatus, startLocalGame);
duel.join(data.roomCode);
inputRoomCode.value = data.roomCode;
btnJoinDuel.disabled = true;
btnLeaveDuel.disabled = false;
inputRoomCode.disabled = true;
updateDuelStatus('waiting', null);
});
// ─────────────────────────────────────────────
// INIT
// ─────────────────────────────────────────────
const game = new Tetris(
// onRender
() => {
if (duel) duel.synchronize_game();
render();
updateButtons();
},
// onGameOver
(score, validBlock) => {
const isDuel = duel && duel.isReady;
if (isDuel) {
duel.onLocalGameOver(score, validBlock);
} else {
saveTetrisScore(score);
}
render();
updateButtons();
showOverlay('GAME OVER', score);
loadLeaderboards();
loadGameHistory();
},
// onBlockPlaced — relay duel
(grid) => {
if (duel) duel.onLocalBlockPlaced(grid, game.score);
},
// onLinesCleared — relay duel
(count, holeCol) => {
if (duel) duel.onLocalLinesCleared(count, holeCol);
}
);
btnStart.addEventListener('click', () => {
if (duel && duel.isReady) {
duel.startDuel(); // déclenche les deux parties via le serveur
} else {
startLocalGame(); // solo
}
});
btnPause.addEventListener('click', () => {
if (duel && duel.isReady) {
duel.togglePause();
} else {
game.pause();
updateButtons();
if (game.isPaused) showOverlay('PAUSE');
else hideOverlay();
}
});
btnStop.addEventListener('click', () => {
if (duel && duel.isReady) {
duel.stop();
} else {
game.stop();
updateButtons();
render();
showOverlay('STOPPED');
}
});
function applySettings() {
const settings = {
timeToDown: parseInt(inputTTD.value, 10),
hardening: parseInt(inputHardening.value, 10),
decrementTTD: parseInt(inputDecrement.value, 10),
};
game.configure(settings);
if (duel && duel.isReady) duel.syncSettings(settings);
}
inputTTD.addEventListener('change', applySettings);
inputHardening.addEventListener('change', applySettings);
inputDecrement.addEventListener('change', applySettings);
const btnRestart = document.getElementById('btn-restart');
if (btnRestart) {
btnRestart.addEventListener('click', () => {
if (duel && duel.isReady) return;
game.restart();
updateButtons();
render();
});
}
// ─────────────────────────────────────────────
// GAME HISTORY
// ─────────────────────────────────────────────
async function loadGameHistory() {
const token = localStorage.getItem('auth_token');
if (!token) return;
try {
const res = await fetch('/api/stats/tetris/history', {
headers: { 'Authorization': `Bearer ${token}` }
});
if (!res.ok) return;
const history = await res.json();
renderGameHistory(history);
} catch (err) {
console.error('Erreur chargement historique:', err);
}
}
function renderGameHistory(history) {
const tbody = document.getElementById('lb-history-body');
if (!tbody) return;
if (!history.length) {
tbody.innerHTML = '<tr><td colspan="5">Aucune partie jouée</td></tr>';
return;
}
tbody.innerHTML = history.map((entry, i) => {
const date = new Date(entry.played_at).toLocaleDateString('fr-FR', {
day: '2-digit', month: '2-digit', year: '2-digit',
hour: '2-digit', minute: '2-digit'
});
const type = entry.game_type === 'duel' ? 'Duel' : 'Solo';
let resultHtml = '—';
if (entry.result === 'win') resultHtml = '<span class="hist-win">Victoire</span>';
if (entry.result === 'loss') resultHtml = '<span class="hist-loss">Défaite</span>';
return `<tr>
<td>${i + 1}</td>
<td>${date}</td>
<td>${type}</td>
<td>${entry.score}</td>
<td>${resultHtml}</td>
</tr>`;
}).join('');
}
// ─────────────────────────────────────────────
// LEADERBOARDS
// ─────────────────────────────────────────────
async function loadLeaderboards() {
const token = localStorage.getItem('auth_token');
if (!token) return;
const headers = { 'Authorization': `Bearer ${token}` };
try {
const [scoresRes, winsRes, meRes, rankScoreRes, rankWinsRes] = await Promise.all([
fetch('/api/stats/tetris/leaderboard/score', { headers }),
fetch('/api/stats/tetris/leaderboard/wins', { headers }),
fetch('/api/stats/me', { headers }),
fetch('/api/stats/tetris/rank/score', { headers }),
fetch('/api/stats/tetris/rank/wins', { headers })
]);
const me = meRes.ok ? await meRes.json() : null;
const rankScore = rankScoreRes.ok ? (await rankScoreRes.json()).rank : null;
const rankWins = rankWinsRes.ok ? (await rankWinsRes.json()).rank : null;
if (scoresRes.ok) {
const scores = await scoresRes.json();
renderLeaderboard('lb-scores-body', scores, ['tetris_best_score', 'tetris_games_played'], me, rankScore);
}
if (winsRes.ok) {
const wins = await winsRes.json();
renderLeaderboard('lb-wins-body', wins, ['tetris_wins', 'tetris_games_played'], me, rankWins);
}
} catch (err) {
console.error('Erreur chargement leaderboards:', err);
}
}
function renderLeaderboard(tbodyId, rows, [col1, col2], me, myRank) {
const tbody = document.getElementById(tbodyId);
if (!tbody) return;
if (!rows.length && !me) {
tbody.innerHTML = '<tr><td colspan="4">Aucun résultat</td></tr>';
return;
}
const myUsername = me?.username;
const inTop = rows.some(r => r.username === myUsername);
let html = rows.map((r, i) => {
const isMe = r.username === myUsername;
return `<tr class="${isMe ? 'lb-me' : ''}">
<td>${i + 1}</td>
<td>${escapeHtml(r.username)}${isMe ? ' <span class="lb-you">(vous)</span>' : ''}</td>
<td>${r[col1] ?? 0}</td>
<td>${r[col2] ?? 0}</td>
</tr>`;
}).join('');
if (!inTop && me && myRank !== null) {
html += `<tr class="lb-separator"><td colspan="4">· · ·</td></tr>`;
html += `<tr class="lb-me">
<td>${myRank}</td>
<td>${escapeHtml(myUsername)} <span class="lb-you">(vous)</span></td>
<td>${me[col1] ?? 0}</td>
<td>${me[col2] ?? 0}</td>
</tr>`;
}
tbody.innerHTML = html || '<tr><td colspan="4">Aucun résultat</td></tr>';
}
function escapeHtml(str) {
return String(str).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
// Tabs leaderboard
document.querySelectorAll('.lb-tab').forEach(tab => {
tab.addEventListener('click', () => {
document.querySelectorAll('.lb-tab').forEach(t => t.classList.remove('lb-tab--active'));
document.querySelectorAll('.lb-content').forEach(c => c.classList.remove('lb-content--active'));
tab.classList.add('lb-tab--active');
document.getElementById(`lb-${tab.dataset.tab}`).classList.add('lb-content--active');
if (tab.dataset.tab === 'history') loadGameHistory();
});
});
// Chargement initial des leaderboards
loadLeaderboards();
loadGameHistory();
@@ -1,6 +1,6 @@
import { Window, windowRegistry } from './windows.js'; import { Window, windowRegistry } from '../core/windows.js';
import { API, STORAGE_KEYS, CSS } from './config.js'; import { API, STORAGE_KEYS, CSS } from '../core/config.js';
import { eventBus, Events } from './events.js'; import { eventBus, Events } from '../core/events.js';
/** /**
* Avatar management window * Avatar management window
@@ -1,6 +1,6 @@
import { Window, windowRegistry } from './windows.js'; import { Window, windowRegistry } from '../core/windows.js';
import { API, STORAGE_KEYS, CSS } from './config.js'; import { API, STORAGE_KEYS, CSS } from '../core/config.js';
import { eventBus, Events } from './events.js'; import { eventBus, Events } from '../core/events.js';
/** /**
* Friends management window * Friends management window
@@ -1,6 +1,6 @@
import { Window } from './windows.js'; import { Window } from '../core/windows.js';
import { API, STORAGE_KEYS, CSS } from './config.js'; import { API, STORAGE_KEYS, CSS } from '../core/config.js';
import { eventBus, Events } from './events.js'; import { eventBus, Events } from '../core/events.js';
export class GameRoomWindow extends Window { export class GameRoomWindow extends Window {
constructor() { constructor() {
@@ -194,7 +194,8 @@ export class GameRoomWindow extends Window {
players: [], players: [],
currentPlayerIndex: 0, currentPlayerIndex: 0,
guessedLetters: [], guessedLetters: [],
scores: {} scores: {},
counter: 0
}; };
this.initDrawing(); this.initDrawing();
@@ -1568,8 +1569,11 @@ export class GameRoomWindow extends Window {
nextRound() { nextRound() {
// Move to next player // Move to next player
this.gameState.currentPlayerIndex = (this.gameState.currentPlayerIndex + 1) % this.gameState.players.length; this.gameState.counter++;
const nextDrawer = this.gameState.players[this.gameState.currentPlayerIndex]; if (this.gameState.counter >= this.gameState.players.length) {
this.gameState.counter = 0;
}
const nextDrawer = this.gameState.players[this.gameState.counter];
if (this.socket?.connected) { if (this.socket?.connected) {
this.socket.emit('game-next-round', { drawer: nextDrawer }); this.socket.emit('game-next-round', { drawer: nextDrawer });
@@ -1,6 +1,6 @@
import { Window } from './windows.js'; import { Window } from '../core/windows.js';
import { STORAGE_KEYS, CSS } from './config.js'; import { STORAGE_KEYS, CSS } from '../core/config.js';
import { eventBus, Events } from './events.js'; import { eventBus, Events } from '../core/events.js';
/** /**
* Global chat window * Global chat window
@@ -1,6 +1,6 @@
import { Window } from './windows.js'; import { Window } from '../core/windows.js';
import { API, STORAGE_KEYS, CSS } from './config.js'; import { API, STORAGE_KEYS, CSS } from '../core/config.js';
import { eventBus, Events } from './events.js'; import { eventBus, Events } from '../core/events.js';
/** /**
* Login and registration window * Login and registration window
@@ -17,6 +17,7 @@ export class LoginWindow extends Window {
this.buildUI(); this.buildUI();
this.bindEvents(); this.bindEvents();
this.checkIfAlreadyLoggedIn(); this.checkIfAlreadyLoggedIn();
this.NotficationContainer();
} }
/** /**
@@ -129,6 +130,7 @@ export class LoginWindow extends Window {
if (response.ok && data.token) { if (response.ok && data.token) {
localStorage.setItem(STORAGE_KEYS.AUTH_TOKEN, data.token); localStorage.setItem(STORAGE_KEYS.AUTH_TOKEN, data.token);
this.showMessage('Login successful! Welcome.', 'success'); this.showMessage('Login successful! Welcome.', 'success');
this.showNotification('Login successful', 'green');
// Emit login event // Emit login event
eventBus.emit(Events.USER_LOGGED_IN, { username, token: data.token }); eventBus.emit(Events.USER_LOGGED_IN, { username, token: data.token });
@@ -138,6 +140,7 @@ export class LoginWindow extends Window {
} else { } else {
const errorMsg = data?.message || 'Login failed'; const errorMsg = data?.message || 'Login failed';
this.showMessage(errorMsg, 'error'); this.showMessage(errorMsg, 'error');
this.showNotification(errorMsg, 'red');
} }
} catch (error) { } catch (error) {
console.error('Login error:', error); console.error('Login error:', error);
@@ -170,10 +173,12 @@ export class LoginWindow extends Window {
if (response.ok) { if (response.ok) {
this.showMessage('Registration successful! You can now sign in.', 'success'); this.showMessage('Registration successful! You can now sign in.', 'success');
this.showNotification('Registration successful', 'green');
eventBus.emit(Events.USER_REGISTERED, { username }); eventBus.emit(Events.USER_REGISTERED, { username });
} else { } else {
const errorMsg = data?.message || 'Registration failed'; const errorMsg = data?.message || 'Registration failed';
this.showMessage(errorMsg, 'error'); this.showMessage(errorMsg, 'error');
this.showNotification(errorMsg, 'red');
} }
} catch (error) { } catch (error) {
console.error('Registration error:', error); console.error('Registration error:', error);
@@ -200,6 +205,7 @@ export class LoginWindow extends Window {
if (event.data?.token) { if (event.data?.token) {
localStorage.setItem(STORAGE_KEYS.AUTH_TOKEN, event.data.token); localStorage.setItem(STORAGE_KEYS.AUTH_TOKEN, event.data.token);
this.showMessage('GitHub login successful! Welcome.', 'success'); this.showMessage('GitHub login successful! Welcome.', 'success');
this.showNotification('GitHub login successful', 'green');
// Emit login event // Emit login event
eventBus.emit(Events.USER_LOGGED_IN, { eventBus.emit(Events.USER_LOGGED_IN, {
@@ -215,6 +221,55 @@ export class LoginWindow extends Window {
window.addEventListener('message', handleMessage, { once: true }); window.addEventListener('message', handleMessage, { once: true });
} }
NotficationContainer()
{
if (document.getElementById('notification-container')) return;
const container = this.createElement('div');
container.id = 'notification-container';
Object.assign(container.style, {
position: 'fixed',
top: '20px',
right: '20px',
zIndex: 1000,
display: 'flex',
flexDirection: 'column',
gap: '10px'
});
document.body.appendChild(container);
}
showNotification(message, color) {
const container = document.getElementById('notification-container');
if (!container) return;
const notification = document.createElement('div');
notification.textContent = message;
Object.assign(notification.style, {
backgroundColor: color,
color: 'white',
padding: '10px 20px',
borderRadius: '5px',
boxShadow: '0 2px 6px rgba(0,0,0,0.3)',
opacity: '0',
transform: 'translateY(-8px)',
transition: 'opacity 0.5s ease, transform 0.5s ease'
});
container.appendChild(notification);
requestAnimationFrame(() => {
notification.style.opacity = '1';
notification.style.transform = 'translateY(0)';
});
setTimeout(() => {
notification.style.opacity = '0';
notification.style.transform = 'translateY(-8px)';
setTimeout(() => notification.remove(), 500);
}, 2200);
}
/** /**
* Displays a feedback message * Displays a feedback message
* @param {string} text - Message text * @param {string} text - Message text
@@ -1,5 +1,5 @@
import { Window } from './windows.js'; import { Window } from '../core/windows.js';
import { API, STORAGE_KEYS } from './config.js'; import { API, STORAGE_KEYS } from '../core/config.js';
/** /**
* Stats window displays Scribble + Tetris stats for any user * Stats window displays Scribble + Tetris stats for any user