tetris fonctionnel sans bug

This commit is contained in:
2026-03-01 15:07:53 +01:00
parent a4210af235
commit eeb9e7bf4d
10 changed files with 280 additions and 462 deletions
-348
View File
@@ -1,348 +0,0 @@
# Transcendence
---
## Configuration — Fichier `.env`
```env
POSTGRES_PASSWORD=coucou
JWT_SECRET=superlongsecretkeyatleast32characterspleasenevercommitthis
POSTGRES_DB=database
POSTGRES_HOST=database
POSTGRES_USER=user
GITHUB_CLIENT_ID=Iv1.xxxxxxxxxxxxxxxxxxxxxxxxxxx
GITHUB_CLIENT_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
GITHUB_CALLBACK_URL=http://localhost:8080/api/auth/github/callback
```
> Les variables `GITHUB_*` sont à générer sur [github.com/settings/applications/new](https://github.com/settings/applications/new)
---
## Gestion des amitiés (PostgreSQL)
| Statut | Signification |
|-------------|----------------------------|
| `pending` | Demande envoyée |
| `accepted` | Amis |
| `blocked` | Utilisateur bloqué |
| `rejected` | Demande refusée |
---
## Ressources
- [Documentation PostgreSQL](https://www.postgresql.org/docs/)
- [Autoriser les OAuth Apps — GitHub](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps)
- [Créer une OAuth App — GitHub](https://docs.github.com/fr/apps/oauth-apps/building-oauth-apps/creating-an-oauth-app)
---
## Journal des modifications
### BACKEND
| Date | Description |
|-------|-------------|
| 17/01 | Ajout du service/route pour le système de `game_room` — création/rejoindre une room, destruction automatique si vide, liste des rooms joignables et des joueurs avec scores et état |
| 21/01 | Ajout du service/route pour le système d'avatar — changement, suppression, et récupération de l'avatar |
### DATABASE
| Date | Description |
|-------|-------------|
| 17/01 | Ajout des tables `game_rooms`, `game_players`, `game_rounds`, `words` — nom/statut/paramètres de game, joueurs/scores/rôles, historique des rounds, liste des mots |
| 21/01 | Ajout de `avatar_url` dans la table `users` |
---
---
# TETRIS
```
████████╗███████╗████████╗██████╗ ██╗███████╗
██╔══╝██╔════╝╚══██╔══╝██╔══██╗██║██╔════╝
██║ █████╗ ██║ ██████╔╝██║███████╗
██║ ██╔══╝ ██║ ██╔══██╗██║╚════██║
██║ ███████╗ ██║ ██║ ██║██║███████║
╚═╝ ╚══════╝ ╚═╝ ╚═╝ ╚═╝╚═╝╚══════╝
```
Implémentation du jeu Tetris avec un thème cyberpunk, entièrement découpée en modules JS séparés.
---
## Architecture des fichiers
```
srcs/frontend/src/
├── pieces.js ← Définition des 7 pièces Tetris et leurs rotations
├── tetris.js ← Logique du jeu (classe Tetris)
├── renderer.js ← Rendu canvas (grille principale, hold, next)
└── ui.js ← Glue UI : boutons, overlay, liaison game ↔ DOM
```
```
srcs/frontend/
└── tetris.html ← Structure HTML de la page
tetris.css ← Styles (thème cyberpunk)
```
---
## Contrôles clavier
| Touche | Action |
|-------------|-------------------------------------|
| `←` `→` | Déplacer la pièce horizontalement |
| `↓` | Descente douce (+1 pt) |
| `Espace` | Hard drop — chute instantanée (+2 pts par case) |
| `Q` | Rotation gauche |
| `W` | Rotation droite |
| `C` | Hold — stocker / échanger la pièce courante |
---
## Flux de jeu
```
spawn()
tick() ──── toutes les timeToDown ms ────┐
│ │
├─ canMoveDown ? ──── oui ──► moveDown()
└─ non ──► lockPiece()
├─► verifierLignes() (efface + score)
├─► _makeHarder() (accélération)
└─► spawn()
└─ canSpawn ? ──── non ──► GAME OVER
```
---
## `pieces.js` — Les pièces Tetris
### Classe de base : `Piece`
Classe abstraite dont héritent toutes les pièces du jeu.
```
┌─────────────────────────────────────────────────────────────┐
│ class Piece │
├─────────────────────────────────────────────────────────────┤
│ position { x, y } — coordonnées dans la grille │
│ currentRotation index de la rotation active │
│ rotations tableau de toutes les formes rotées │
│ shape forme actuellement active │
└─────────────────────────────────────────────────────────────┘
```
| Méthode | Description |
|--------------------|-------------|
| `defineRotations()`| Retourne le tableau de toutes les matrices de rotation de la pièce. Surchargée dans chaque sous-classe. |
| `getColor()` | Retourne l'index couleur de la pièce (1 à 7). Surchargée dans chaque sous-classe. |
| `getPosition()` | Retourne une copie de `{ x, y }` — position actuelle dans la grille. |
| `getShape()` | Retourne la matrice 2D de la forme active (rotation courante). |
| `moveDown()` | Incrémente `y` de 1 — descend la pièce d'une case. |
| `moveLeft()` | Décrémente `x` de 1 — déplace la pièce d'une case à gauche. |
| `moveRight()` | Incrémente `x` de 1 — déplace la pièce d'une case à droite. |
| `rotateLeft()` | Passe à la rotation précédente dans le tableau (sens anti-horaire). |
| `rotateRight()` | Passe à la rotation suivante dans le tableau (sens horaire). |
---
### Les 7 pièces — sous-classes de `Piece`
Chaque pièce définit ses rotations via une matrice `1`/`0` et son index couleur.
| Classe | Forme | Couleur (index) | Rotations |
|-----------------|-------|-----------------|-----------|
| `PieceT` | T | Violet `1` | 4 |
| `PieceL` | L | Orange `2` | 4 |
| `PieceReverseL` | J | Bleu `3` | 4 |
| `PieceI` | I | Cyan `4` | 2 |
| `PieceZ` | Z | Rouge `5` | 2 |
| `PieceReverseZ` | S | Vert `6` | 2 |
| `PieceO` | O | Jaune `7` | 1 |
**Exemple — `PieceT` (4 rotations) :**
```
Rotation 0 Rotation 1 Rotation 2 Rotation 3
0 1 0 0 1 0 0 0 0 0 1 0
1 1 1 0 1 1 1 1 1 1 1 0
0 0 0 0 1 0 0 1 0 0 1 0
```
---
## `tetris.js` — Logique du jeu
### Classe `Tetris`
```
┌─────────────────────────────────────────────────────────────────────┐
│ new Tetris(onRender, onGameOver) │
├─────────────────────────────────────────────────────────────────────┤
│ onRender : () => void — callback de rendu à chaque frame │
│ onGameOver : (score) => void — callback appelé en fin de partie │
└─────────────────────────────────────────────────────────────────────┘
```
#### État interne
| Propriété | Type | Description |
|----------------------|-----------|-------------|
| `grid` | `number[][]` | Grille principale 10×20 — `0` = vide, sinon index couleur |
| `bufferGrid` | `number[][]` | Grille 10×5 utilisée pour afficher la pièce suivante |
| `currentPiece` | `Piece` | Pièce en cours de chute |
| `nextPiece` | `Piece` | Prochaine pièce à spawner |
| `storedPiece` | `Piece` | Pièce en hold (stockée par le joueur) |
| `score` | `number` | Score courant |
| `timeToDown` | `number` | Intervalle (ms) entre deux descentes automatiques |
| `hardening` | `number` | Seuil de points avant chaque accélération |
| `decrementTTD` | `number` | Réduction de `timeToDown` à chaque palier |
| `count` | `number` | Accumulateur de points depuis le dernier palier |
| `isRunning` | `boolean` | Vrai si une partie est en cours |
| `isPaused` | `boolean` | Vrai si la partie est en pause |
| `canStore` | `boolean` | Faux si le hold a déjà été utilisé depuis le dernier spawn |
---
### Méthodes publiques
| Méthode | Description |
|------------------------|-------------|
| `configure(options)` | Applique les paramètres de difficulté — `timeToDown`, `hardening`, `decrementTTD`. Doit être appelé **avant** `start()` pour que `timeToDown` soit pris en compte. |
| `start()` | Initialise et démarre une nouvelle partie (réinitialise la grille, le score, et spawn la première pièce). |
| `stop()` | Arrête la partie — annule la boucle `requestAnimationFrame` et retire l'écouteur clavier. |
| `pause()` | Bascule entre pause et reprise. En reprise, réinitialise `lastTime` pour éviter un saut d'accumulation. |
---
### Méthodes privées
#### Boucle de jeu
| Méthode | Description |
|----------------------|-------------|
| `_startGameLoop()` | Lance la boucle via `requestAnimationFrame`. Calcule le `deltaTime` entre chaque frame et accumule le temps. Déclenche `_tick()` dès que l'accumulateur dépasse `timeToDown`. |
| `_tick()` | Un pas logique du jeu : descend la pièce si possible, sinon la verrouille, vérifie les lignes, accélère le jeu, puis spawne la suivante. Si le spawn est impossible → game over. |
#### Gestion des pièces
| Méthode | Description |
|------------------------|-------------|
| `_spawnNewPiece()` | Fait de `nextPiece` la pièce courante, génère une nouvelle `nextPiece`, et met à jour la `bufferGrid`. |
| `_createRandomPiece()` | Instancie aléatoirement l'une des 7 pièces à la position de départ `(3, 0)`. |
| `_updateBufferGrid()` | Recrée la grille miniature `bufferGrid` (10×5) centrée sur la forme de `nextPiece`. |
| `_lockPiece()` | Grave la forme et la couleur de `currentPiece` dans `grid` à sa position actuelle. |
| `_storePiece()` | Échange `currentPiece` et `storedPiece` (ou stocke la pièce courante si hold vide). Désactive le hold jusqu'au prochain spawn. |
| `_rotatePiece(dir)` | Tente une rotation (`-1` = gauche, `1` = droite). En cas de collision, essaie des décalages latéraux ou verticaux (wall kick) avant d'annuler. |
| `_hardDrop()` | Téléporte la pièce au bas de sa trajectoire, ajoute +2 pts par case parcourue, puis la verrouille immédiatement. |
#### Collisions
| Méthode | Description |
|----------------------|-------------|
| `_canMoveDown()` | Vérifie que chaque cellule de la pièce peut descendre d'une ligne (pas de mur bas, pas de case occupée). |
| `_canMoveLeft()` | Vérifie que chaque cellule peut se déplacer d'une colonne à gauche. |
| `_canMoveRight()` | Vérifie que chaque cellule peut se déplacer d'une colonne à droite. |
| `_isValidPosition()` | Vérifie que la pièce est entièrement dans les limites de la grille et n'occupe aucune case déjà remplie. |
| `_canSpawn()` | Alias de `_isValidPosition()` — utilisé pour détecter le game over après un spawn. |
#### Score & difficulté
| Méthode | Description |
|------------------|-------------|
| `verifierLignes()` | Parcourt la grille de bas en haut. Supprime chaque ligne complète, insère une ligne vide en haut. Ajoute les points selon le nombre de lignes effacées simultanément : `0→0 / 1→100 / 2→300 / 3→500 / 4→800`. |
| `_makeHarder()` | Si `count` (points accumulés depuis le dernier palier) dépasse `hardening`, remet `count` à 0 et réduit `timeToDown` de `decrementTTD` (minimum 100 ms). |
| `_gameOver()` | Appelle `stop()` puis déclenche le callback `onGameOver(score)`. |
#### Entrées clavier
| Méthode | Description |
|-------------------|-------------|
| `_handleKey(e)` | Écouteur `keydown`. Dispatche l'action selon la touche pressée. Appelle `onRender()` après chaque action. Ignoré si la partie est en pause (sauf pour les touches déjà bloquées par `isRunning`). |
---
### Paramètres de difficulté
| Paramètre | Défaut | Description |
|---------------|----------|-------------|
| `timeToDown` | `1000 ms`| Intervalle entre deux descentes automatiques. Plus faible = plus rapide. |
| `hardening` | `1000 pts`| Points cumulés avant chaque accélération. Plus élevé = progression plus lente. |
| `decrementTTD`| `100 ms` | Réduction de `timeToDown` à chaque palier. Plus élevé = accélération plus brutale. |
---
## `renderer.js` — Rendu canvas
### Constantes
| Constante | Valeur | Description |
|-----------|--------|-------------|
| `CELL` | `30` | Taille en pixels d'une cellule dans la grille principale |
| `COLORS` | tableau| 8 couleurs indexées de `0` (fond) à `7` (jaune) — thème cyberpunk |
### Fonctions
| Fonction | Description |
|---------------------------------------|-------------|
| `drawCell(ctx, x, y, colorIndex, size)` | Dessine une cellule colorée à la position `(x, y)` avec un effet 3D : highlight blanc en haut/gauche, ombre noire en bas/droite. |
| `clearCanvas(ctx, w, h)` | Efface un canvas en le remplissant avec la couleur de fond `#070712`. |
| `drawGridLines(ctx, cols, rows, size)`| Trace la grille de fond en lignes semi-transparentes (opacité 4 %). |
| `drawGhost(ctx, piece, grid)` | Calcule et affiche la "pièce fantôme" — projection de la pièce courante au bas de sa trajectoire, dessinée en contour blanc semi-transparent. |
| `drawMiniPiece(ctx, piece, w, h)` | Dessine une pièce centrée dans un petit canvas (pour les panneaux **Next** et **Hold**), en utilisant des cellules de 20 px. |
| `render()` | Fonction de rendu principale appelée à chaque frame. Redessine : la grille principale, les cellules verrouillées, la pièce ghost, la pièce courante, les panneaux Next/Hold, et le score. |
---
## `ui.js` — Interface utilisateur
### Fonctions
| Fonction | Description |
|-----------------------------|-------------|
| `updateButtons()` | Met à jour l'état des boutons et des inputs selon `game.isRunning` et `game.isPaused` — désactive Start si en cours, active Pause/Stop seulement pendant une partie, bascule le label entre "Pause" et "Resume", verrouille les inputs de settings pendant le jeu. |
| `showOverlay(title, score)` | Affiche l'overlay (écran superposé) avec un titre et optionnellement un score. Utilisé pour "GAME OVER", "PAUSE", et "STOPPED". |
| `hideOverlay()` | Retire la classe `visible` de l'overlay pour le masquer. |
| `applySettings()` | Lit les valeurs des trois inputs de configuration et appelle `game.configure(...)` pour mettre à jour les paramètres de difficulté. |
### Initialisation
```js
const game = new Tetris(
() => { render(); updateButtons(); }, // onRender
(score) => { render(); updateButtons(); showOverlay('GAME OVER', score); } // onGameOver
);
```
| Événement | Action |
|----------------------------------|--------|
| Clic `btn-start` | Masque l'overlay, démarre la partie, met à jour UI et canvas |
| Clic `btn-pause` | Bascule pause, affiche ou masque l'overlay "PAUSE" |
| Clic `btn-stop` | Arrête la partie, affiche l'overlay "STOPPED" |
| `change` sur les inputs settings | Appelle `applySettings()` pour reconfigurer la difficulté |
---
## Système de couleurs
```
Index Couleur Hex Pièce
0 Fond #070712 (vide)
1 Violet #a855f7 PieceT
2 Orange #f97316 PieceL
3 Bleu #3b82f6 PieceReverseL
4 Cyan #06b6d4 PieceI
5 Rouge #ef4444 PieceZ
6 Vert #22c55e PieceReverseZ
7 Jaune #eab308 PieceO
```
+64
View File
@@ -0,0 +1,64 @@
le you win apparait sur la grille de l'adversaire, elle doit apparaitre sur la grille principale du joueur qui gagne FIXED ? [OUI]
undefined is not an object (evaluating 'grid[ny][nx]')
a la ligne 56 de renderer.js FIXED ? [CA_EN_A_L'AIR]
A tester plusieurs game, si ca freeze et que l'erreur reviens, NAN, j'ai changer les limite
sur les bord gauche droit de la grid, a voir
Quand je fais pause,
ca ne fait pas pose pour tout le monde FIXED[OUI]
Quand je fait stop,
ca ne fais pas stop pour tout le monde FIXED[OUI]
durant le duel du tetris en ligne:
j'ai plusieurs probleme:
Les parametre doivent etre les memes pour tout le monde
FIXED[OUI]
DES GAMES OVER ARRIVE COMME CA SANS RAISON durant le duel FIXED[OUI]
est-ce du au valid-block ? au addGarbage ?
Bug A — Faux game over via garbage (tetris.js)
addGarbageLines appelait _isValidPosition() qui retourne false si gy < 0.
Or après garbage, la pièce monte légitimement au-dessus de la grille
(y négatif).
Fix : nouvelle méthode _isValidPositionAllowTop() qui
ignore les cellules au-dessus de la grille (zone tampon) et
ne vérifie que les collisions réelles dans la grille.
Bug B — Crash si la pièce est au-dessus de la grille (tetris.js)
_canMoveDown, _canMoveLeft, _canMoveRight, et _lockPiece accèdent à grid[y + row]
sans vérifier si y + row < 0 → undefined → crash. Fix :
skip des rangées hors grille avec continue.
Bug C — Game over par garbage ne termine pas le duel (duel.js)
onLocalGameOver ne faisait endDuel() que si validBlock=true. Un game over réel dû à du garbage (validBlock=false) laissait le duel dans un état cassé et l'adversaire ne voyait jamais "YOU WIN". Fix : endDuel() systématique, et OPPONENT_GAME_OVER affiche toujours "YOU WIN".
separer le code tetris du reste, de meme pour les sockets. FAIT[?]
error:
renderer.js:56 Uncaught TypeError: Cannot read properties of undefined (reading 'length')
at drawGhost (renderer.js:56:71)
at render (renderer.js:101:9)
at Tetris.onRender (ui.js:107:9)
at gameLoop (tetris.js:115:18)
FIXED[ON DIRAIS BIEN]
Il faut verifier si le garbage si retrouve a la premiere ligne,
si c'est le cas, game-over
Il faut un bouton restart
system d'attribution de point et d'enregistrement de point
@@ -493,6 +493,39 @@ function setupSocketIO(io)
} }
}); });
// pause → relayé aux DEUX joueurs de la room
socket.on('tetris:pause', () => {
const code = socket.tetrisRoomCode;
if (!code) return;
const room = tetrisRooms.get(code);
if (!room) return;
for (const s of room.values()) {
s.emit('tetris:pause');
}
});
// stop → relayé aux DEUX joueurs de la room
socket.on('tetris:stop', () => {
const code = socket.tetrisRoomCode;
if (!code) return;
const room = tetrisRooms.get(code);
if (!room) return;
for (const s of room.values()) {
s.emit('tetris:stop');
}
});
// settings → relayé aux DEUX joueurs de la room
socket.on('tetris:settings', (data) => {
const code = socket.tetrisRoomCode;
if (!code) return;
const room = tetrisRooms.get(code);
if (!room) return;
for (const s of room.values()) {
s.emit('tetris:settings', data);
}
});
// game-over → relayé en opponent-game-over chez l'adversaire // game-over → relayé en opponent-game-over chez l'adversaire
socket.on('tetris:game-over', (data) => { socket.on('tetris:game-over', (data) => {
_tetrisRelayToOpponent(socket, 'tetris:opponent-game-over', data); _tetrisRelayToOpponent(socket, 'tetris:opponent-game-over', data);
@@ -1,45 +0,0 @@
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
+48 -4
View File
@@ -54,9 +54,16 @@ class Duel {
this.socket.emit('tetris:lines-cleared', { count, holeCol, garbageLines }); this.socket.emit('tetris:lines-cleared', { count, holeCol, garbageLines });
} }
onLocalGameOver(score) { onLocalGameOver(score, validBlock) {
if (!this.isReady) return; if (!this.isReady) return;
this.socket.emit('tetris:game-over', { score }); this.socket.emit('tetris:game-over', { score, validBlock });
this.endDuel();
}
endDuel() {
this.isReady = false;
this.action_queue = [];
if (this.tetrisGame.isRunning) this.tetrisGame.stop();
} }
// ─── Traitement de la queue ─────────────── // ─── Traitement de la queue ───────────────
@@ -82,7 +89,8 @@ class Duel {
break; break;
case 'OPPONENT_GAME_OVER': case 'OPPONENT_GAME_OVER':
this._showOpponentOverlay('YOU WIN', action.score); showOverlay('YOU WIN', action.score);
this.endDuel();
break; break;
} }
} }
@@ -116,12 +124,48 @@ class Duel {
}); });
this.socket.on('tetris:opponent-game-over', (data) => { this.socket.on('tetris:opponent-game-over', (data) => {
this.action_queue.push({ type: 'OPPONENT_GAME_OVER', score: data.score }); this.action_queue.push({ type: 'OPPONENT_GAME_OVER', score: data.score, validBlock: data.validBlock });
}); });
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.tetrisGame.pause();
updateButtons();
if (this.tetrisGame.isPaused) showOverlay('PAUSE');
else hideOverlay();
});
this.socket.on('tetris:stop', () => {
this.tetrisGame.stop();
updateButtons();
render();
showOverlay('STOPPED');
});
this.socket.on('tetris:settings', (data) => {
document.getElementById('input-ttd').value = data.timeToDown;
document.getElementById('input-hardening').value = data.hardening;
document.getElementById('input-decrement').value = data.decrementTTD;
this.tetrisGame.configure(data);
});
}
togglePause() {
if (!this.isReady) return;
this.socket.emit('tetris:pause');
}
stop() {
if (!this.isReady) return;
this.socket.emit('tetris:stop');
}
syncSettings(settings) {
if (!this.isReady) return;
this.socket.emit('tetris:settings', settings);
} }
// ─── Utilitaires ───────────────────────── // ─── Utilitaires ─────────────────────────
+1 -1
View File
@@ -53,7 +53,7 @@ function drawGhost(ctx, piece, grid) {
if (shape[row][col] !== 0) { if (shape[row][col] !== 0) {
const ny = ghost.y + row; const ny = ghost.y + row;
const nx = ghost.x + col; const nx = ghost.x + col;
if (ny >= grid.length || grid[ny][nx] !== 0) valid = false; if (ny < 0 || ny >= grid.length || nx < 0 || nx >= grid[ny].length || grid[ny][nx] !== 0) valid = false;
} }
if (!valid) { ghost.y--; break; } if (!valid) { ghost.y--; break; }
} }
+47 -17
View File
@@ -55,11 +55,11 @@ h1 {
z-index: 1; z-index: 1;
} }
/* ── Section locale (légèrement décalée à gauche par le flex naturel) ── */ /* ── Section locale ── */
#local-section { #local-section {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: flex-start;
} }
#app { #app {
@@ -165,11 +165,37 @@ button {
width: 100%; width: 100%;
} }
#btn-start { color: var(--accent); border-color: var(--accent); } #btn-start {
#btn-start:hover:not(:disabled) { background: var(--accent); color: var(--bg); box-shadow: 0 0 15px var(--accent); } color: var(--accent);
border-color: var(--accent);
}
#btn-pause { color: var(--accent2); border-color: var(--accent2); } #btn-start:hover:not(:disabled)
#btn-pause:hover:not(:disabled) { background: var(--accent2); color: var(--bg); box-shadow: 0 0 15px var(--accent2); } {
background: var(--accent);
color: var(--bg);
box-shadow: 0 0 15px var(--accent);
}
#btn-restart {
color: var(--accent);
border-color: var(--accent);
}
#btn-restart: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 { color: #ef4444; border-color: #ef4444; }
#btn-stop:hover:not(:disabled) { background: #ef4444; color: var(--bg); box-shadow: 0 0 15px #ef4444; } #btn-stop:hover:not(:disabled) { background: #ef4444; color: var(--bg); box-shadow: 0 0 15px #ef4444; }
@@ -293,21 +319,26 @@ button:disabled { opacity: 0.3; cursor: not-allowed; }
#duel-status.waiting { color: #f97316; } #duel-status.waiting { color: #f97316; }
#duel-status.ready { color: var(--accent); } #duel-status.ready { color: var(--accent); }
/* ── Colonne gauche (panel + settings empilés) ── */
#left-column {
display: flex;
flex-direction: column;
gap: 16px;
width: 130px;
flex-shrink: 0;
}
/* ── Settings Panel ── */ /* ── Settings Panel ── */
#settings-panel { #settings-panel {
background: var(--panel); background: var(--panel);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 6px; border-radius: 6px;
padding: 14px 20px; padding: 14px;
margin-top: -250px;
margin-left: -600px;
box-shadow: 0 0 20px rgba(0,255,231,0.05); box-shadow: 0 0 20px rgba(0,255,231,0.05);
position: relative;
z-index: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 10px; gap: 10px;
width: fit-content; width: 130px;
} }
.settings-title { .settings-title {
@@ -322,10 +353,9 @@ button:disabled { opacity: 0.3; cursor: not-allowed; }
.settings-row { .settings-row {
display: flex; display: flex;
align-items: center; flex-direction: column;
justify-content: space-between; gap: 4px;
gap: 16px; font-size: 0.55rem;
font-size: 0.6rem;
color: var(--dim); color: var(--dim);
letter-spacing: 0.05em; letter-spacing: 0.05em;
} }
@@ -338,7 +368,7 @@ button:disabled { opacity: 0.3; cursor: not-allowed; }
font-family: 'Orbitron', monospace; font-family: 'Orbitron', monospace;
font-size: 0.65rem; font-size: 0.65rem;
padding: 4px 8px; padding: 4px 8px;
width: 80px; width: 100%;
text-align: right; text-align: right;
outline: none; outline: none;
transition: border-color 0.2s; transition: border-color 0.2s;
+31 -28
View File
@@ -28,20 +28,40 @@
<div id="local-section"> <div id="local-section">
<div id="app"> <div id="app">
<!-- Panneau gauche : Hold + Score + Boutons --> <!-- Colonne gauche : Hold + Score + Boutons + Settings -->
<div class="panel"> <div id="left-column">
<div class="panel-title">Hold</div> <div class="panel">
<canvas id="canvas-hold" width="100" height="80"></canvas> <div class="panel-title">Hold</div>
<canvas id="canvas-hold" width="100" height="80"></canvas>
<div class="score-block"> <div class="score-block">
<div class="score-label">Score</div> <div class="score-label">Score</div>
<div class="score-value" id="score-display">0</div> <div class="score-value" id="score-display">0</div>
</div>
<div class="btn-group">
<button id="btn-start">Start</button>
<button id="btn-restart" disabled>Restart</button>
<button id="btn-pause" disabled>Pause</button>
<button id="btn-stop" disabled>Stop</button>
</div>
</div> </div>
<div class="btn-group"> <!-- Panneau de configuration -->
<button id="btn-start">Start</button> <div id="settings-panel">
<button id="btn-pause" disabled>Pause</button> <div class="settings-title">Paramètres</div>
<button id="btn-stop" disabled>Stop</button> <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> </div>
</div> </div>
@@ -93,23 +113,6 @@
</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="/socket.io/socket.io.js"></script>
<script src="pieces.js"></script> <script src="pieces.js"></script>
+34 -7
View File
@@ -129,7 +129,7 @@ class Tetris {
this._makeHarder(); this._makeHarder();
this._spawnNewPiece(); this._spawnNewPiece();
this.canStore = true; this.canStore = true;
if (!this._canSpawn()) this._gameOver(); if (!this._canSpawn()) this._gameOver(true);
} }
} }
@@ -185,7 +185,7 @@ class Tetris {
this._spawnNewPiece(); this._spawnNewPiece();
this.canStore = true; this.canStore = true;
this.accumulator = 0; this.accumulator = 0;
if (!this._canSpawn()) this._gameOver(); if (!this._canSpawn()) this._gameOver(true);
} }
_rotatePiece(direction) { _rotatePiece(direction) {
@@ -290,6 +290,7 @@ class Tetris {
if (shape[row][col] !== 0) { if (shape[row][col] !== 0) {
const ny = y + row + 1; const ny = y + row + 1;
const nx = x + col; const nx = x + col;
if (ny < 0) continue; // encore au-dessus de la grille
if (ny >= this.grid.length || this.grid[ny][nx] !== 0) return false; if (ny >= this.grid.length || this.grid[ny][nx] !== 0) return false;
} }
return true; return true;
@@ -302,6 +303,7 @@ class Tetris {
for (let row = 0; row < shape.length; row++) for (let row = 0; row < shape.length; row++)
for (let col = 0; col < shape[row].length; col++) for (let col = 0; col < shape[row].length; col++)
if (shape[row][col] !== 0) { if (shape[row][col] !== 0) {
if (y + row < 0) continue; // au-dessus de la grille
const nx = x + col - 1; const nx = x + col - 1;
if (nx < 0 || this.grid[y + row][nx] !== 0) return false; if (nx < 0 || this.grid[y + row][nx] !== 0) return false;
} }
@@ -315,6 +317,7 @@ class Tetris {
for (let row = 0; row < shape.length; row++) for (let row = 0; row < shape.length; row++)
for (let col = 0; col < shape[row].length; col++) for (let col = 0; col < shape[row].length; col++)
if (shape[row][col] !== 0) { if (shape[row][col] !== 0) {
if (y + row < 0) continue; // au-dessus de la grille
const nx = x + col + 1; const nx = x + col + 1;
if (nx >= this.grid[0].length || this.grid[y + row][nx] !== 0) return false; if (nx >= this.grid[0].length || this.grid[y + row][nx] !== 0) return false;
} }
@@ -346,7 +349,7 @@ class Tetris {
const color = this.currentPiece.getColor(); const color = this.currentPiece.getColor();
for (let row = 0; row < shape.length; row++) for (let row = 0; row < shape.length; row++)
for (let col = 0; col < shape[row].length; col++) for (let col = 0; col < shape[row].length; col++)
if (shape[row][col] !== 0) if (shape[row][col] !== 0 && y + row >= 0)
this.grid[y + row][x + col] = color; this.grid[y + row][x + col] = color;
this.lastLandingCol = x + Math.floor(shape[0].length / 2); this.lastLandingCol = x + Math.floor(shape[0].length / 2);
if (this.onBlockPlaced) this.onBlockPlaced(this.grid.map(r => [...r])); if (this.onBlockPlaced) this.onBlockPlaced(this.grid.map(r => [...r]));
@@ -355,12 +358,36 @@ class Tetris {
addGarbageLines(lines) { addGarbageLines(lines) {
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]); for (const line of lines) this.grid.push([...line]); // ...line pour faire une copie independante
if (!this._isValidPosition()) this._gameOver(); // La grille a remonté de lines.length lignes — on remonte la pièce du même décalage
// pour qu'elle reste dans la même position relative aux blocs verrouillés.
if (this.currentPiece) {
this.currentPiece.position.y -= lines.length;
}
if (this.grid[0].some(c => c !== 0)) { this._gameOver(false); return; }
if (!this._isValidPositionAllowTop()) this._gameOver(false);
} }
_gameOver() { // Comme _isValidPosition mais tolère gy < 0 (zone tampon au-dessus de la grille après garbage)
_isValidPositionAllowTop() {
if (!this.currentPiece) return true;
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 gy = y + row;
const gx = x + col;
if (gy < 0) continue; // au-dessus de la grille : OK
if (gx < 0 || gx >= this.grid[0].length ||
gy >= this.grid.length ||
this.grid[gy][gx] !== 0) return false;
}
return true;
}
_gameOver(validBlock = false) {
this.stop(); this.stop();
this.onGameOver(this.score); this.onGameOver(this.score, validBlock);
} }
} }
+22 -12
View File
@@ -108,8 +108,8 @@ const game = new Tetris(
updateButtons(); updateButtons();
}, },
// onGameOver // onGameOver
(score) => { (score, validBlock) => {
if (duel) duel.onLocalGameOver(score); if (duel) duel.onLocalGameOver(score, validBlock);
render(); render();
updateButtons(); updateButtons();
showOverlay('GAME OVER', score); showOverlay('GAME OVER', score);
@@ -133,25 +133,35 @@ btnStart.addEventListener('click', () => {
}); });
btnPause.addEventListener('click', () => { btnPause.addEventListener('click', () => {
game.pause(); if (duel && duel.isReady) {
updateButtons(); duel.togglePause();
if (game.isPaused) showOverlay('PAUSE'); } else {
else hideOverlay(); game.pause();
updateButtons();
if (game.isPaused) showOverlay('PAUSE');
else hideOverlay();
}
}); });
btnStop.addEventListener('click', () => { btnStop.addEventListener('click', () => {
game.stop(); if (duel && duel.isReady) {
updateButtons(); duel.stop();
render(); } else {
showOverlay('STOPPED'); game.stop();
updateButtons();
render();
showOverlay('STOPPED');
}
}); });
function applySettings() { function applySettings() {
game.configure({ const settings = {
timeToDown: parseInt(inputTTD.value, 10), timeToDown: parseInt(inputTTD.value, 10),
hardening: parseInt(inputHardening.value, 10), hardening: parseInt(inputHardening.value, 10),
decrementTTD: parseInt(inputDecrement.value, 10), decrementTTD: parseInt(inputDecrement.value, 10),
}); };
game.configure(settings);
if (duel && duel.isReady) duel.syncSettings(settings);
} }
inputTTD.addEventListener('change', applySettings); inputTTD.addEventListener('change', applySettings);