Initial commit: Tetris Battle Royale multiplayer game
Features: - 2-8 player multiplayer via Socket.io WebSocket - Real-time board synchronization - all players see all boards - Battle royale mechanic: clearing rows sends garbage to opponents - Classic Tetris gameplay with all 7 tetrominoes - Retro visual styling with CRT scanlines and pixel font - Automatic level progression and speed increase - Player elimination and winner announcement Files: - server/index.js: Node.js + Socket.io game server - public/js/: Frontend game logic, rendering, network, and UI - public/css/style.css: Retro Tetris styling - README.md: Setup and usage instructions - PLAN.md: Implementation plan with all phases completed
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
*.log
|
||||
.DS_Store
|
||||
@@ -0,0 +1,184 @@
|
||||
# Tetris Battle Royale - Implementation Plan
|
||||
|
||||
## Context
|
||||
|
||||
Build a multiplayer Tetris Battle Royale game where 2-8 players compete. When a player clears rows, those rows are garbage-dumped onto random opponents' boards. Players see all other boards in real-time. Uses WebSocket for multiplayer and classic retro aesthetics.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Tech Stack
|
||||
- **Frontend**: HTML5 Canvas, CSS3, Vanilla JavaScript
|
||||
- **Backend**: Node.js + Socket.io for real-time WebSocket communication
|
||||
- **No frameworks** - pure vanilla implementation
|
||||
|
||||
### File Structure
|
||||
```
|
||||
tetris-battle-royale/
|
||||
├── server/
|
||||
│ ├── index.js # Socket.io server, game state management
|
||||
│ └── package.json
|
||||
├── public/
|
||||
│ ├── index.html # Game UI with player grids
|
||||
│ ├── css/
|
||||
│ │ └── style.css # Retro pixel-art styling
|
||||
│ └── js/
|
||||
│ ├── game.js # Core Tetris logic
|
||||
│ ├── renderer.js # Canvas rendering
|
||||
│ ├── network.js # Socket.io client
|
||||
│ ├── ui.js # HUD and game messages
|
||||
│ └── app.js # Main application
|
||||
└── README.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Server Setup & Multiplayer Infrastructure [DONE]
|
||||
|
||||
**Goal**: Get a working Node.js server with Socket.io that handles rooms and player connections.
|
||||
|
||||
### Todos
|
||||
- [x] Initialize Node.js project with `package.json`
|
||||
- [x] Install dependencies: `express`, `socket.io`, `uuid`
|
||||
- [x] Create basic Express server serving static files from `public/`
|
||||
- [x] Implement Socket.io room system:
|
||||
- `join-room` event - player joins with username
|
||||
- `leave-room` event - player leaves
|
||||
- Broadcast `player-joined` / `player-left` to room
|
||||
- [x] Track players in room with IDs, names, ready status
|
||||
- [x] Create simple HTML page to test connection
|
||||
|
||||
**Success criteria**: Can open 2+ browser tabs, join same room, see player list update.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Core Tetris Game Logic [DONE]
|
||||
|
||||
**Goal**: Implement single-player Tetris mechanics on the client side.
|
||||
|
||||
### Todos
|
||||
- [x] Create `public/js/game.js` with:
|
||||
- 10x20 grid data structure
|
||||
- Tetromino definitions (I, O, T, S, Z, J, L shapes and colors)
|
||||
- Piece spawning at top center
|
||||
- Movement: left, right, soft drop
|
||||
- Rotation with wall kick (basic SRS)
|
||||
- Hard drop (instant place)
|
||||
- Row clearing detection
|
||||
- Game over detection (piece collides at spawn)
|
||||
- [x] Create `public/js/renderer.js` with:
|
||||
- Canvas setup per player board
|
||||
- Draw grid, active piece, locked pieces
|
||||
- Classic Tetris colors for each piece type
|
||||
- [x] Wire keyboard controls (arrow keys + space for hard drop)
|
||||
- [x] Game loop with adjustable speed (starts slow, speeds up with lines)
|
||||
|
||||
**Success criteria**: Single-player Tetris works smoothly with all piece types and controls.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Multiplayer Game State Sync [DONE]
|
||||
|
||||
**Goal**: Connect multiple players and sync game state in real-time.
|
||||
|
||||
### Todos
|
||||
- [x] Server: Add game state tracking per room
|
||||
- Active pieces, board states, scores
|
||||
- Game running/paused/over status
|
||||
- [x] Client `public/js/network.js`:
|
||||
- Send player actions to server (`move-piece`, `rotate`, `drop`)
|
||||
- Receive state updates and apply locally
|
||||
- [x] Server: Broadcast state at ~20fps to all room players
|
||||
- [x] Server: Handle `start-game` when 2+ players ready
|
||||
- [x] Client: Use networked state
|
||||
- [x] Sync piece positions, board state, scores across all players
|
||||
|
||||
**Success criteria**: 2+ players can play simultaneously, all see each other's boards and piece movements in real-time.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Garbage Battle System [DONE]
|
||||
|
||||
**Goal**: Implement the battle royale mechanic - clearing rows sends garbage to opponents.
|
||||
|
||||
### Todos
|
||||
- [x] Server: Track lines cleared per player
|
||||
- [x] Implement garbage row calculation:
|
||||
- 1 line → 0 garbage rows
|
||||
- 2 lines → 1 garbage row to random opponent
|
||||
- 3 lines → 2 garbage rows to random opponent(s)
|
||||
- 4 lines (Tetris) → 4 garbage rows
|
||||
- [x] Garbage row representation (filled row with 1 random gap)
|
||||
- [x] Server: Pick random target(s) when garbage sent
|
||||
- [x] Client: Receive garbage, shift board up, add garbage at bottom
|
||||
- [x] Visual: Garbage rows have distinct color (gray/black)
|
||||
- [x] Elimination: Player loses when garbage reaches top
|
||||
|
||||
**Success criteria**: Clearing rows visibly adds garbage rows to opponent boards.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Retro UI Polish [IN PROGRESS]
|
||||
|
||||
**Goal**: Classic Tetris aesthetic with multi-board layout.
|
||||
|
||||
### Todos
|
||||
- [x] `public/css/style.css`:
|
||||
- Import Press Start 2P font from Google Fonts
|
||||
- CSS Grid layout for player boards (adaptive 2x2 or 2x4)
|
||||
- Retro color scheme (dark background, bright pieces)
|
||||
- CRT scanline overlay effect
|
||||
- Pixelated canvas rendering (`image-rendering: pixelated`)
|
||||
- [x] `public/js/ui.js`:
|
||||
- Player info HUD (name, score, lines)
|
||||
- Player list sidebar with status (playing/eliminated)
|
||||
- Room join screen with username input
|
||||
- "Ready" button and countdown before game start
|
||||
- Game over screen with winner announcement
|
||||
- Next piece preview panel
|
||||
- [ ] Visual effects:
|
||||
- [x] Row clear flash animation
|
||||
- [x] Garbage receiving shake effect
|
||||
- [ ] Player color indicators per board
|
||||
|
||||
**Success criteria**: Game looks like classic Tetris with all boards visible and polished UI.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Polish & Edge Cases [PENDING]
|
||||
|
||||
**Goal**: Handle real-world scenarios and add final touches.
|
||||
|
||||
### Todos
|
||||
- [x] Player disconnect handling:
|
||||
- Remove from room, update player list
|
||||
- Continue game with remaining players or end if <2
|
||||
- [ ] Rejoin support (optional): Allow disconnected player to rejoin
|
||||
- [x] Score tracking and leaderboard
|
||||
- [x] Level system: Speed increases every 10 lines
|
||||
- [ ] Combo system: Bonus for consecutive clears
|
||||
- [ ] Sound effects (optional): Clear, garbage receive, game over
|
||||
- [ ] Settings menu: Speed, sound toggle, controls
|
||||
- [ ] README with setup instructions
|
||||
|
||||
**Success criteria**: Game handles disconnections gracefully, full feature set working.
|
||||
|
||||
---
|
||||
|
||||
## Dependencies
|
||||
```json
|
||||
{
|
||||
"dependencies": {
|
||||
"express": "^4.18.2",
|
||||
"socket.io": "^4.7.2",
|
||||
"uuid": "^9.0.0"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Verification
|
||||
|
||||
1. `npm install` then `node server/index.js`
|
||||
2. Open `http://localhost:3000` in 2-8 tabs
|
||||
3. Join same room, click Ready, verify countdown
|
||||
4. Play and verify garbage rows appear on opponents
|
||||
5. Test disconnects, game over, winner announcement
|
||||
@@ -0,0 +1,86 @@
|
||||
# Tetris Battle Royale
|
||||
|
||||
A multiplayer Tetris Battle Royale game built with HTML, CSS, JavaScript, and Node.js.
|
||||
|
||||
## Features
|
||||
|
||||
- **2-8 Player Multiplayer**: Real-time battles via WebSocket
|
||||
- **Battle Royale Mechanics**: Clear rows to send garbage to opponents
|
||||
- **Classic Tetris Gameplay**: All 7 tetrominoes with standard rotation
|
||||
- **Retro Visuals**: CRT scanlines, pixel art font, classic colors
|
||||
- **Multiple Boards View**: See all players' boards simultaneously
|
||||
|
||||
## How It Works
|
||||
|
||||
When you clear rows, garbage rows are sent to random opponents:
|
||||
- 2 lines cleared → 1 garbage row sent
|
||||
- 3 lines cleared → 2 garbage rows sent
|
||||
- 4 lines (Tetris) → 4 garbage rows sent
|
||||
|
||||
The last player standing wins!
|
||||
|
||||
## Setup
|
||||
|
||||
1. Install dependencies:
|
||||
```bash
|
||||
cd server
|
||||
npm install
|
||||
```
|
||||
|
||||
2. Start the server:
|
||||
```bash
|
||||
node index.js
|
||||
```
|
||||
|
||||
3. Open `http://localhost:3000` in 2-8 browser tabs
|
||||
|
||||
4. Enter a room name and your name, then join
|
||||
|
||||
5. Click "READY" when all players are ready
|
||||
|
||||
## Controls
|
||||
|
||||
- **Arrow Left/Right**: Move piece
|
||||
- **Arrow Up**: Rotate piece
|
||||
- **Arrow Down**: Soft drop
|
||||
- **Space**: Hard drop
|
||||
|
||||
## Game Flow
|
||||
|
||||
1. **Lobby**: Players join a room and wait
|
||||
2. **Ready**: All players click READY to start
|
||||
3. **Game**: Play Tetris, clear rows to attack opponents
|
||||
4. **Elimination**: Players are eliminated when pieces stack to the top
|
||||
5. **Victory**: Last player standing wins
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
tetris-battle-royale/
|
||||
├── server/
|
||||
│ ├── index.js # Node.js + Socket.io server
|
||||
│ └── package.json
|
||||
├── public/
|
||||
│ ├── index.html # Main HTML
|
||||
│ ├── css/
|
||||
│ │ └── style.css # Retro styling with CRT effects
|
||||
│ └── js/
|
||||
│ ├── game.js # Tetris game logic
|
||||
│ ├── renderer.js # Canvas rendering
|
||||
│ ├── network.js # Socket.io client
|
||||
│ ├── ui.js # UI management
|
||||
│ └── app.js # Main application
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## Technology Stack
|
||||
|
||||
- **Frontend**: Vanilla JavaScript, HTML5 Canvas, CSS3
|
||||
- **Backend**: Node.js, Express, Socket.io
|
||||
- **No frameworks** - pure vanilla implementation
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **Port already in use**: Change PORT in `server/index.js`
|
||||
- **Can't connect**: Make sure server is running on port 3000
|
||||
- **Game not starting**: Need at least 2 players ready
|
||||
@@ -0,0 +1,303 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: #1a1a2e;
|
||||
color: #fff;
|
||||
font-family: 'Press Start 2P', cursive;
|
||||
min-height: 100vh;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* CRT Scanline Effect */
|
||||
body::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: repeating-linear-gradient(
|
||||
0deg,
|
||||
rgba(0, 0, 0, 0.15),
|
||||
rgba(0, 0, 0, 0.15) 1px,
|
||||
transparent 1px,
|
||||
transparent 2px
|
||||
);
|
||||
pointer-events: none;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
#app {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.screen {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
}
|
||||
|
||||
.screen.active {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2rem;
|
||||
text-align: center;
|
||||
color: #0ff;
|
||||
text-shadow: 4px 4px 0 #ff00ff;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.2rem;
|
||||
color: #0f0;
|
||||
text-shadow: 2px 2px 0 #004400;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1rem;
|
||||
color: #ff0;
|
||||
text-shadow: 2px 2px 0 #444400;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
label {
|
||||
font-size: 0.7rem;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
input {
|
||||
background: #0a0a1a;
|
||||
border: 2px solid #333;
|
||||
color: #fff;
|
||||
font-family: 'Press Start 2P', cursive;
|
||||
font-size: 0.8rem;
|
||||
padding: 15px;
|
||||
text-align: center;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
input:focus {
|
||||
border-color: #0ff;
|
||||
}
|
||||
|
||||
button {
|
||||
background: #ff00ff;
|
||||
border: 4px solid #fff;
|
||||
color: #fff;
|
||||
font-family: 'Press Start 2P', cursive;
|
||||
font-size: 1rem;
|
||||
padding: 15px 30px;
|
||||
cursor: pointer;
|
||||
text-transform: uppercase;
|
||||
transition: transform 0.1s;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
transform: scale(1.05);
|
||||
background: #0ff;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
button:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
#player-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin: 20px 0;
|
||||
min-height: 150px;
|
||||
}
|
||||
|
||||
.player-item {
|
||||
background: #0a0a1a;
|
||||
border: 2px solid #333;
|
||||
padding: 15px 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.player-item .name {
|
||||
color: #0ff;
|
||||
}
|
||||
|
||||
.player-item .status {
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.player-item .status.ready {
|
||||
color: #0f0;
|
||||
}
|
||||
|
||||
/* Battle Grid - Responsive Layout */
|
||||
#battle-grid {
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
justify-content: center;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
#battle-grid.grid-2x2 {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
#battle-grid.grid-2x4 {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
grid-template-rows: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.player-board {
|
||||
background: #000;
|
||||
border: 4px solid #333;
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.player-board.active {
|
||||
border-color: #0ff;
|
||||
}
|
||||
|
||||
.player-board.eliminated {
|
||||
border-color: #f00;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.player-board canvas {
|
||||
display: block;
|
||||
image-rendering: pixelated;
|
||||
image-rendering: crisp-edges;
|
||||
}
|
||||
|
||||
.board-info {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px 5px;
|
||||
font-size: 0.6rem;
|
||||
}
|
||||
|
||||
.board-info .name {
|
||||
color: #0ff;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.board-info .stats {
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.next-piece-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.next-piece-container canvas {
|
||||
border: 1px solid #333;
|
||||
}
|
||||
|
||||
#game-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 10px 20px;
|
||||
background: #0a0a1a;
|
||||
border: 2px solid #333;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
#game-header span {
|
||||
font-size: 0.8rem;
|
||||
color: #0f0;
|
||||
}
|
||||
|
||||
#game-header button {
|
||||
font-size: 0.7rem;
|
||||
padding: 10px 15px;
|
||||
}
|
||||
|
||||
#game-status {
|
||||
margin-top: 20px;
|
||||
padding: 15px;
|
||||
background: #0a0a1a;
|
||||
border: 2px solid #333;
|
||||
text-align: center;
|
||||
font-size: 0.8rem;
|
||||
color: #ff0;
|
||||
}
|
||||
|
||||
#winner-display {
|
||||
margin: 20px 0;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
#final-scores {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin: 20px 0;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
#final-scores .score-item {
|
||||
background: #0a0a1a;
|
||||
border: 2px solid #333;
|
||||
padding: 10px 15px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
#final-scores .score-item.winner {
|
||||
border-color: #0f0;
|
||||
color: #0f0;
|
||||
}
|
||||
|
||||
/* Garbage row animation */
|
||||
@keyframes shake {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
25% { transform: translateX(-5px); }
|
||||
75% { transform: translateX(5px); }
|
||||
}
|
||||
|
||||
.player-board.shake {
|
||||
animation: shake 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
/* Flash effect for row clear */
|
||||
@keyframes flash {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
.player-board.flash {
|
||||
animation: flash 0.3s ease-in-out;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Tetris Battle Royale</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<!-- Room Selection Screen -->
|
||||
<div id="room-screen" class="screen active">
|
||||
<h1>TETRIS<br>BATTLE ROYALE</h1>
|
||||
<div class="form-group">
|
||||
<label for="room-name">Room Name</label>
|
||||
<input type="text" id="room-name" placeholder="Enter room name">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="player-name">Your Name</label>
|
||||
<input type="text" id="player-name" placeholder="Enter your name">
|
||||
</div>
|
||||
<button id="join-btn">JOIN ROOM</button>
|
||||
</div>
|
||||
|
||||
<!-- Lobby Screen -->
|
||||
<div id="lobby-screen" class="screen">
|
||||
<h2>Room: <span id="lobby-room-name"></span></h2>
|
||||
<div id="player-list"></div>
|
||||
<button id="ready-btn">READY</button>
|
||||
</div>
|
||||
|
||||
<!-- Game Screen -->
|
||||
<div id="game-screen" class="screen">
|
||||
<div id="game-header">
|
||||
<span id="game-room-name"></span>
|
||||
<button id="leave-btn">LEAVE</button>
|
||||
</div>
|
||||
<div id="battle-grid"></div>
|
||||
<div id="game-status"></div>
|
||||
</div>
|
||||
|
||||
<!-- Game Over Screen -->
|
||||
<div id="gameover-screen" class="screen">
|
||||
<h2>GAME OVER</h2>
|
||||
<h3 id="winner-display"></h3>
|
||||
<div id="final-scores"></div>
|
||||
<button id="back-to-lobby">BACK TO LOBBY</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/socket.io/socket.io.js"></script>
|
||||
<script src="js/network.js"></script>
|
||||
<script src="js/game.js"></script>
|
||||
<script src="js/renderer.js"></script>
|
||||
<script src="js/ui.js"></script>
|
||||
<script src="js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,171 @@
|
||||
// Main Application - Ties everything together
|
||||
|
||||
let localGame = null;
|
||||
let renderer = null;
|
||||
let lastTime = 0;
|
||||
|
||||
// Initialize when DOM is ready
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Connect to server
|
||||
network.connect();
|
||||
|
||||
// Initialize renderer
|
||||
renderer = new TetrisRenderer('battle-grid');
|
||||
|
||||
// Setup network listeners
|
||||
setupNetworkListeners();
|
||||
|
||||
// Setup keyboard controls
|
||||
setupKeyboardControls();
|
||||
});
|
||||
|
||||
function setupNetworkListeners() {
|
||||
// Player joined lobby
|
||||
network.setListener('player-joined', (player) => {
|
||||
ui.updatePlayerList(network.getAllPlayers());
|
||||
});
|
||||
|
||||
// Player left lobby
|
||||
network.setListener('player-left', (playerId) => {
|
||||
ui.updatePlayerList(network.getAllPlayers());
|
||||
});
|
||||
|
||||
// Game started
|
||||
network.setListener('game-started', (players, states) => {
|
||||
ui.showScreen('game');
|
||||
ui.displays.gameRoomName.textContent = network.currentRoom;
|
||||
|
||||
// Clear old boards
|
||||
renderer.clearAll();
|
||||
|
||||
// Create boards for all players
|
||||
states.forEach((state) => {
|
||||
const player = network.getPlayer(state.playerId);
|
||||
renderer.createPlayerBoard(state.playerId, player.name);
|
||||
|
||||
// Initialize local game for current player
|
||||
if (state.playerId === network.currentPlayerId) {
|
||||
localGame = new TetrisGame(state.playerId);
|
||||
localGame.loadState(state);
|
||||
}
|
||||
});
|
||||
|
||||
// Set up battle grid layout
|
||||
updateBattleGridLayout(players.length);
|
||||
|
||||
// Start game loop
|
||||
lastTime = performance.now();
|
||||
requestAnimationFrame(gameLoop);
|
||||
});
|
||||
|
||||
// State update during game
|
||||
network.setListener('state-update', (states) => {
|
||||
// Update local game if it's our state
|
||||
const localState = states.find(s => s.playerId === network.currentPlayerId);
|
||||
if (localState) {
|
||||
localGame.loadState(localState);
|
||||
}
|
||||
|
||||
// Check for game over
|
||||
const allStates = network.getAllGameStates();
|
||||
const activePlayers = Object.values(allStates).filter(s => !s.eliminated);
|
||||
|
||||
if (activePlayers.length <= 1) {
|
||||
endGame(allStates);
|
||||
}
|
||||
});
|
||||
|
||||
// Game over
|
||||
network.setListener('game-over', (data) => {
|
||||
endGame(data.states);
|
||||
});
|
||||
}
|
||||
|
||||
function setupKeyboardControls() {
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (ui.screens.game.classList.contains('active') && localGame) {
|
||||
switch (e.key) {
|
||||
case 'ArrowLeft':
|
||||
network.sendMove('left');
|
||||
e.preventDefault();
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
network.sendMove('right');
|
||||
e.preventDefault();
|
||||
break;
|
||||
case 'ArrowDown':
|
||||
network.sendDrop();
|
||||
e.preventDefault();
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
network.sendRotate();
|
||||
e.preventDefault();
|
||||
break;
|
||||
case ' ':
|
||||
network.sendHardDrop();
|
||||
e.preventDefault();
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function updateBattleGridLayout(playerCount) {
|
||||
ui.displays.battleGrid.classList.remove('grid-2x2', 'grid-2x4');
|
||||
|
||||
if (playerCount <= 4) {
|
||||
ui.displays.battleGrid.classList.add('grid-2x2');
|
||||
} else {
|
||||
ui.displays.battleGrid.classList.add('grid-2x4');
|
||||
}
|
||||
}
|
||||
|
||||
function endGame(states) {
|
||||
// Find winner
|
||||
const activePlayers = Object.values(states).filter(s => !s.eliminated);
|
||||
const eliminatedPlayers = Object.values(states).filter(s => s.eliminated);
|
||||
|
||||
let winner = null;
|
||||
let scores = {};
|
||||
|
||||
if (activePlayers.length === 1) {
|
||||
const winnerState = activePlayers[0];
|
||||
const winnerPlayer = network.getPlayer(winnerState.playerId);
|
||||
winner = winnerPlayer.name;
|
||||
}
|
||||
|
||||
// Build scores list
|
||||
Object.values(states).forEach(state => {
|
||||
const player = network.getPlayer(state.playerId);
|
||||
if (player) {
|
||||
scores[player.name] = state.score;
|
||||
}
|
||||
});
|
||||
|
||||
ui.showGameOver(winner, scores);
|
||||
}
|
||||
|
||||
function gameLoop(currentTime) {
|
||||
if (!ui.screens.game.classList.contains('active')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const deltaTime = currentTime - lastTime;
|
||||
lastTime = currentTime;
|
||||
|
||||
// Update local game
|
||||
if (localGame) {
|
||||
localGame.update(deltaTime);
|
||||
}
|
||||
|
||||
// Render all players
|
||||
const allStates = network.getAllGameStates();
|
||||
Object.values(allStates).forEach(state => {
|
||||
renderer.renderPlayer(state.playerId, state);
|
||||
});
|
||||
|
||||
// Set active player highlight
|
||||
renderer.setActivePlayer(network.currentPlayerId);
|
||||
|
||||
requestAnimationFrame(gameLoop);
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
// Core Tetris Game Logic
|
||||
|
||||
const BOARD_WIDTH = 10;
|
||||
const BOARD_HEIGHT = 20;
|
||||
const CELL_SIZE = 24;
|
||||
|
||||
// Tetromino definitions with colors
|
||||
const TETROMINOS = {
|
||||
I: { shape: [[0,0,0,0], [1,1,1,1], [0,0,0,0], [0,0,0,0]], color: '#00ffff' },
|
||||
O: { shape: [[1,1], [1,1]], color: '#ffff00' },
|
||||
T: { shape: [[0,1,0], [1,1,1], [0,0,0]], color: '#800080' },
|
||||
S: { shape: [[0,1,1], [1,1,0], [0,0,0]], color: '#00ff00' },
|
||||
Z: { shape: [[1,1,0], [0,1,1], [0,0,0]], color: '#ff0000' },
|
||||
J: { shape: [[1,0,0], [1,1,1], [0,0,0]], color: '#0000ff' },
|
||||
L: { shape: [[0,0,1], [1,1,1], [0,0,0]], color: '#ffa500' }
|
||||
};
|
||||
|
||||
const TETROMINO_KEYS = Object.keys(TETROMINOS);
|
||||
const GARbage_COLOR = '#666666';
|
||||
|
||||
class TetrisGame {
|
||||
constructor(playerId) {
|
||||
this.playerId = playerId;
|
||||
this.board = this.createEmptyBoard();
|
||||
this.currentPiece = null;
|
||||
this.nextPiece = null;
|
||||
this.score = 0;
|
||||
this.lines = 0;
|
||||
this.level = 1;
|
||||
this.gameOver = false;
|
||||
this.eliminated = false;
|
||||
|
||||
// Game speed (milliseconds per drop)
|
||||
this.dropInterval = 1000;
|
||||
this.lastDrop = 0;
|
||||
|
||||
// Input lock for delay hits
|
||||
this.inputLocked = false;
|
||||
this.lockUntil = 0;
|
||||
}
|
||||
|
||||
createEmptyBoard() {
|
||||
return Array(BOARD_HEIGHT).fill(null).map(() => Array(BOARD_WIDTH).fill(0));
|
||||
}
|
||||
|
||||
initialize() {
|
||||
this.board = this.createEmptyBoard();
|
||||
this.score = 0;
|
||||
this.lines = 0;
|
||||
this.level = 1;
|
||||
this.gameOver = false;
|
||||
this.eliminated = false;
|
||||
this.dropInterval = 1000;
|
||||
this.currentPiece = this.spawnPiece();
|
||||
this.nextPiece = this.getRandomPiece();
|
||||
|
||||
if (!this.currentPiece) {
|
||||
this.gameOver = true;
|
||||
}
|
||||
}
|
||||
|
||||
getRandomPiece() {
|
||||
const key = TETROMINO_KEYS[Math.floor(Math.random() * TETROMINO_KEYS.length)];
|
||||
const tetromino = TETROMINOS[key];
|
||||
return {
|
||||
type: key,
|
||||
shape: JSON.parse(JSON.stringify(tetromino.shape)),
|
||||
color: tetromino.color,
|
||||
x: Math.floor(BOARD_WIDTH / 2) - Math.floor(tetromino.shape[0].length / 2),
|
||||
y: 0
|
||||
};
|
||||
}
|
||||
|
||||
spawnPiece() {
|
||||
const piece = this.nextPiece;
|
||||
this.nextPiece = this.getRandomPiece();
|
||||
|
||||
// Check if spawn position is valid
|
||||
if (!this.isValidPosition(piece.x, piece.y, piece.shape)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return piece;
|
||||
}
|
||||
|
||||
isValidPosition(x, y, shape) {
|
||||
for (let row = 0; row < shape.length; row++) {
|
||||
for (let col = 0; col < shape[row].length; col++) {
|
||||
if (shape[row][col]) {
|
||||
const newX = x + col;
|
||||
const newY = y + row;
|
||||
|
||||
// Check bounds
|
||||
if (newX < 0 || newX >= BOARD_WIDTH || newY >= BOARD_HEIGHT) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check collision with locked pieces (only if on board)
|
||||
if (newY >= 0 && this.board[newY][newX]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
move(direction) {
|
||||
if (this.gameOver || this.inputLocked) return false;
|
||||
|
||||
const newX = this.currentPiece.x + (direction === 'left' ? -1 : 1);
|
||||
|
||||
if (this.isValidPosition(newX, this.currentPiece.y, this.currentPiece.shape)) {
|
||||
this.currentPiece.x = newX;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
rotate() {
|
||||
if (this.gameOver || this.inputLocked) return false;
|
||||
|
||||
const originalShape = this.currentPiece.shape;
|
||||
const rows = originalShape.length;
|
||||
const cols = originalShape[0].length;
|
||||
|
||||
// Rotate 90 degrees clockwise
|
||||
const rotated = Array(cols).fill(null).map(() => Array(rows).fill(0));
|
||||
for (let row = 0; row < rows; row++) {
|
||||
for (let col = 0; col < cols; col++) {
|
||||
rotated[col][rows - 1 - row] = originalShape[row][col];
|
||||
}
|
||||
}
|
||||
|
||||
// Try rotation with wall kicks
|
||||
const kicks = [0, -1, 1, -2, 2];
|
||||
for (const kick of kicks) {
|
||||
if (this.isValidPosition(this.currentPiece.x + kick, this.currentPiece.y, rotated)) {
|
||||
this.currentPiece.shape = rotated;
|
||||
this.currentPiece.x += kick;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
drop() {
|
||||
if (this.gameOver || this.inputLocked) return false;
|
||||
|
||||
const newY = this.currentPiece.y + 1;
|
||||
|
||||
if (this.isValidPosition(this.currentPiece.x, newY, this.currentPiece.shape)) {
|
||||
this.currentPiece.y = newY;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Lock the piece
|
||||
return this.lockPiece();
|
||||
}
|
||||
|
||||
hardDrop() {
|
||||
if (this.gameOver || this.inputLocked) return 0;
|
||||
|
||||
let dropped = 0;
|
||||
while (this.isValidPosition(this.currentPiece.x, this.currentPiece.y + 1, this.currentPiece.shape)) {
|
||||
this.currentPiece.y++;
|
||||
dropped++;
|
||||
}
|
||||
|
||||
this.lockPiece();
|
||||
return dropped;
|
||||
}
|
||||
|
||||
lockPiece() {
|
||||
if (!this.currentPiece) return false;
|
||||
|
||||
// Lock piece into board
|
||||
for (let row = 0; row < this.currentPiece.shape.length; row++) {
|
||||
for (let col = 0; col < this.currentPiece.shape[row].length; col++) {
|
||||
if (this.currentPiece.shape[row][col]) {
|
||||
const boardY = this.currentPiece.y + row;
|
||||
const boardX = this.currentPiece.x + col;
|
||||
|
||||
if (boardY >= 0 && boardY < BOARD_HEIGHT && boardX >= 0 && boardX < BOARD_WIDTH) {
|
||||
this.board[boardY][boardX] = this.currentPiece.color;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for game over (piece locked above visible area)
|
||||
if (this.currentPiece.y <= 0) {
|
||||
this.gameOver = true;
|
||||
this.eliminated = true;
|
||||
}
|
||||
|
||||
// Clear completed rows
|
||||
const cleared = this.clearRows();
|
||||
|
||||
// Spawn new piece
|
||||
this.currentPiece = this.spawnPiece();
|
||||
if (!this.currentPiece) {
|
||||
this.gameOver = true;
|
||||
this.eliminated = true;
|
||||
}
|
||||
|
||||
return cleared;
|
||||
}
|
||||
|
||||
clearRows() {
|
||||
let rowsCleared = 0;
|
||||
|
||||
for (let row = BOARD_HEIGHT - 1; row >= 0; row--) {
|
||||
if (this.board[row].every(cell => cell !== 0)) {
|
||||
// Remove the row
|
||||
this.board.splice(row, 1);
|
||||
// Add empty row at top
|
||||
this.board.unshift(Array(BOARD_WIDTH).fill(0));
|
||||
rowsCleared++;
|
||||
row++; // Check same row again
|
||||
}
|
||||
}
|
||||
|
||||
if (rowsCleared > 0) {
|
||||
this.lines += rowsCleared;
|
||||
this.updateScore(rowsCleared);
|
||||
this.updateLevel();
|
||||
}
|
||||
|
||||
return rowsCleared;
|
||||
}
|
||||
|
||||
updateScore(rowsCleared) {
|
||||
const points = [0, 100, 300, 500, 800];
|
||||
this.score += points[rowsCleared] * this.level;
|
||||
}
|
||||
|
||||
updateLevel() {
|
||||
this.level = Math.floor(this.lines / 10) + 1;
|
||||
this.dropInterval = Math.max(100, 1000 - (this.level - 1) * 100);
|
||||
}
|
||||
|
||||
receiveGarbage(rows) {
|
||||
if (this.gameOver) return;
|
||||
|
||||
// Add garbage rows at bottom
|
||||
for (let i = 0; i < rows; i++) {
|
||||
// Remove top row (simulating overflow)
|
||||
this.board.pop();
|
||||
|
||||
// Add garbage row with random gap
|
||||
const garbageRow = Array(BOARD_WIDTH).fill(GARbage_COLOR);
|
||||
const gap = Math.floor(Math.random() * BOARD_WIDTH);
|
||||
garbageRow[gap] = 0;
|
||||
this.board.unshift(garbageRow);
|
||||
}
|
||||
|
||||
// Check if garbage caused game over
|
||||
for (let row = 0; row < 2; row++) {
|
||||
for (let col = 0; col < BOARD_WIDTH; col++) {
|
||||
if (this.board[row][col] !== 0) {
|
||||
this.gameOver = true;
|
||||
this.eliminated = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
applyDelayHit(duration) {
|
||||
this.inputLocked = true;
|
||||
this.lockUntil = Date.now() + duration;
|
||||
}
|
||||
|
||||
update(deltaTime) {
|
||||
if (this.gameOver) return;
|
||||
|
||||
// Check if input lock has expired
|
||||
if (this.inputLocked && Date.now() >= this.lockUntil) {
|
||||
this.inputLocked = false;
|
||||
}
|
||||
|
||||
// Auto drop
|
||||
if (!this.inputLocked && Date.now() - this.lastDrop >= this.dropInterval) {
|
||||
this.drop();
|
||||
this.lastDrop = Date.now();
|
||||
}
|
||||
}
|
||||
|
||||
getState() {
|
||||
return {
|
||||
playerId: this.playerId,
|
||||
board: JSON.parse(JSON.stringify(this.board)),
|
||||
currentPiece: this.currentPiece ? JSON.parse(JSON.stringify(this.currentPiece)) : null,
|
||||
nextPiece: this.nextPiece ? JSON.parse(JSON.stringify(this.nextPiece)) : null,
|
||||
score: this.score,
|
||||
lines: this.lines,
|
||||
level: this.level,
|
||||
gameOver: this.gameOver,
|
||||
eliminated: this.eliminated
|
||||
};
|
||||
}
|
||||
|
||||
loadState(state) {
|
||||
this.board = JSON.parse(JSON.stringify(state.board));
|
||||
this.currentPiece = state.currentPiece ? JSON.parse(JSON.stringify(state.currentPiece)) : null;
|
||||
this.nextPiece = state.nextPiece ? JSON.parse(JSON.stringify(state.nextPiece)) : null;
|
||||
this.score = state.score;
|
||||
this.lines = state.lines;
|
||||
this.level = state.level;
|
||||
this.gameOver = state.gameOver;
|
||||
this.eliminated = state.eliminated;
|
||||
this.dropInterval = Math.max(100, 1000 - (this.level - 1) * 100);
|
||||
}
|
||||
}
|
||||
|
||||
// Export for use in other modules
|
||||
window.TetrisGame = TetrisGame;
|
||||
window.TETROMINOS = TETROMINOS;
|
||||
window.BOARD_WIDTH = BOARD_WIDTH;
|
||||
window.BOARD_HEIGHT = BOARD_HEIGHT;
|
||||
window.CELL_SIZE = CELL_SIZE;
|
||||
window.GARBAGE_COLOR = GARbage_COLOR;
|
||||
@@ -0,0 +1,153 @@
|
||||
// Network module - Socket.io client handling
|
||||
|
||||
class NetworkManager {
|
||||
constructor() {
|
||||
this.socket = null;
|
||||
this.currentRoom = null;
|
||||
this.currentPlayerId = null;
|
||||
this.players = {};
|
||||
this.gameState = {};
|
||||
this.listeners = {
|
||||
onPlayerJoined: null,
|
||||
onPlayerLeft: null,
|
||||
onGameStarted: null,
|
||||
onStateUpdate: null,
|
||||
onGameOver: null
|
||||
};
|
||||
}
|
||||
|
||||
connect() {
|
||||
this.socket = io();
|
||||
|
||||
this.socket.on('connect', () => {
|
||||
console.log('Connected to server');
|
||||
this.currentPlayerId = this.socket.id;
|
||||
});
|
||||
|
||||
this.socket.on('disconnect', () => {
|
||||
console.log('Disconnected from server');
|
||||
this.currentRoom = null;
|
||||
});
|
||||
|
||||
this.socket.on('player-joined', ({ player, players }) => {
|
||||
this.updatePlayers(players);
|
||||
if (this.listeners.onPlayerJoined) {
|
||||
this.listeners.onPlayerJoined(player);
|
||||
}
|
||||
});
|
||||
|
||||
this.socket.on('player-left', ({ playerId, players }) => {
|
||||
delete this.players[playerId];
|
||||
this.updatePlayers(players);
|
||||
if (this.listeners.onPlayerLeft) {
|
||||
this.listeners.onPlayerLeft(playerId);
|
||||
}
|
||||
});
|
||||
|
||||
this.socket.on('game-started', ({ players, states }) => {
|
||||
this.updatePlayers(players);
|
||||
this.updateStates(states);
|
||||
if (this.listeners.onGameStarted) {
|
||||
this.listeners.onGameStarted(players, states);
|
||||
}
|
||||
});
|
||||
|
||||
this.socket.on('state-update', (states) => {
|
||||
this.updateStates(states);
|
||||
if (this.listeners.onStateUpdate) {
|
||||
this.listeners.onStateUpdate(states);
|
||||
}
|
||||
});
|
||||
|
||||
this.socket.on('game-over', (data) => {
|
||||
if (this.listeners.onGameOver) {
|
||||
this.listeners.onGameOver(data);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
joinRoom(roomName, playerName) {
|
||||
if (!this.socket) return;
|
||||
|
||||
this.currentRoom = roomName;
|
||||
this.socket.emit('join-room', { roomName, playerName });
|
||||
}
|
||||
|
||||
leaveRoom() {
|
||||
if (!this.socket) return;
|
||||
|
||||
if (this.currentRoom) {
|
||||
this.socket.leave(this.currentRoom);
|
||||
}
|
||||
this.currentRoom = null;
|
||||
}
|
||||
|
||||
ready() {
|
||||
if (!this.socket) return;
|
||||
this.socket.emit('ready');
|
||||
}
|
||||
|
||||
sendMove(direction) {
|
||||
if (!this.socket || !this.currentPlayerId) return;
|
||||
this.socket.emit('player-move', { playerId: this.currentPlayerId, direction });
|
||||
}
|
||||
|
||||
sendRotate() {
|
||||
if (!this.socket || !this.currentPlayerId) return;
|
||||
this.socket.emit('player-rotate', { playerId: this.currentPlayerId });
|
||||
}
|
||||
|
||||
sendDrop() {
|
||||
if (!this.socket || !this.currentPlayerId) return;
|
||||
this.socket.emit('player-drop', { playerId: this.currentPlayerId, hard: false });
|
||||
}
|
||||
|
||||
sendHardDrop() {
|
||||
if (!this.socket || !this.currentPlayerId) return;
|
||||
this.socket.emit('player-drop', { playerId: this.currentPlayerId, hard: true });
|
||||
}
|
||||
|
||||
updatePlayers(players) {
|
||||
this.players = {};
|
||||
players.forEach(p => {
|
||||
this.players[p.id] = p;
|
||||
});
|
||||
}
|
||||
|
||||
updateStates(states) {
|
||||
states.forEach(s => {
|
||||
this.gameState[s.playerId] = s;
|
||||
});
|
||||
}
|
||||
|
||||
getPlayer(playerId) {
|
||||
return this.players[playerId];
|
||||
}
|
||||
|
||||
getGameState(playerId) {
|
||||
return this.gameState[playerId];
|
||||
}
|
||||
|
||||
getAllPlayers() {
|
||||
return this.players;
|
||||
}
|
||||
|
||||
getAllGameStates() {
|
||||
return this.gameState;
|
||||
}
|
||||
|
||||
setListener(event, callback) {
|
||||
const listenerMap = {
|
||||
'player-joined': 'onPlayerJoined',
|
||||
'player-left': 'onPlayerLeft',
|
||||
'game-started': 'onGameStarted',
|
||||
'state-update': 'onStateUpdate',
|
||||
'game-over': 'onGameOver'
|
||||
};
|
||||
if (callback) {
|
||||
this.listeners[listenerMap[event]] = callback;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const network = new NetworkManager();
|
||||
@@ -0,0 +1,264 @@
|
||||
// Canvas Renderer for Tetris
|
||||
|
||||
class TetrisRenderer {
|
||||
constructor(containerId) {
|
||||
this.container = document.getElementById(containerId);
|
||||
this.boards = new Map();
|
||||
}
|
||||
|
||||
createPlayerBoard(playerId, playerName) {
|
||||
const boardDiv = document.createElement('div');
|
||||
boardDiv.className = 'player-board';
|
||||
boardDiv.id = `board-${playerId}`;
|
||||
boardDiv.dataset.playerId = playerId;
|
||||
|
||||
// Main game canvas
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.id = `canvas-${playerId}`;
|
||||
canvas.width = BOARD_WIDTH * CELL_SIZE;
|
||||
canvas.height = BOARD_HEIGHT * CELL_SIZE;
|
||||
canvas.style.width = `${BOARD_WIDTH * CELL_SIZE}px`;
|
||||
canvas.style.height = `${BOARD_HEIGHT * CELL_SIZE}px`;
|
||||
boardDiv.appendChild(canvas);
|
||||
|
||||
// Player info
|
||||
const infoDiv = document.createElement('div');
|
||||
infoDiv.className = 'board-info';
|
||||
infoDiv.innerHTML = `
|
||||
<span class="name">${playerName}</span>
|
||||
<span class="stats">Lines: <span class="lines">0</span></span>
|
||||
`;
|
||||
boardDiv.appendChild(infoDiv);
|
||||
|
||||
// Next piece preview
|
||||
const nextDiv = document.createElement('div');
|
||||
nextDiv.className = 'board-info';
|
||||
const nextCanvas = document.createElement('canvas');
|
||||
nextCanvas.id = `next-${playerId}`;
|
||||
nextCanvas.width = 80;
|
||||
nextCanvas.height = 80;
|
||||
nextCanvas.style.width = '80px';
|
||||
nextCanvas.style.height = '80px';
|
||||
nextDiv.innerHTML = '<span>NEXT:</span>';
|
||||
nextDiv.appendChild(nextCanvas);
|
||||
boardDiv.appendChild(nextDiv);
|
||||
|
||||
this.container.appendChild(boardDiv);
|
||||
|
||||
this.boards.set(playerId, {
|
||||
element: boardDiv,
|
||||
canvas: canvas,
|
||||
nextCanvas: nextCanvas,
|
||||
info: infoDiv,
|
||||
ctx: canvas.getContext('2d'),
|
||||
nextCtx: nextCanvas.getContext('2d')
|
||||
});
|
||||
|
||||
return boardDiv;
|
||||
}
|
||||
|
||||
removePlayerBoard(playerId) {
|
||||
const board = this.boards.get(playerId);
|
||||
if (board) {
|
||||
board.element.remove();
|
||||
this.boards.delete(playerId);
|
||||
}
|
||||
}
|
||||
|
||||
renderPlayer(playerId, gameState) {
|
||||
const boardData = this.boards.get(playerId);
|
||||
if (!boardData) return;
|
||||
|
||||
const { ctx, nextCtx, element, info } = boardData;
|
||||
|
||||
// Clear canvas
|
||||
ctx.fillStyle = '#000';
|
||||
ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height);
|
||||
|
||||
// Draw grid lines
|
||||
this.drawGrid(ctx);
|
||||
|
||||
// Draw locked pieces
|
||||
if (gameState && gameState.board) {
|
||||
this.drawBoard(ctx, gameState.board);
|
||||
}
|
||||
|
||||
// Draw current piece
|
||||
if (gameState && gameState.currentPiece && !gameState.gameOver) {
|
||||
this.drawPiece(ctx, gameState.currentPiece);
|
||||
}
|
||||
|
||||
// Draw next piece
|
||||
if (gameState && gameState.nextPiece) {
|
||||
this.drawNextPiece(nextCtx, gameState.nextPiece);
|
||||
}
|
||||
|
||||
// Update stats
|
||||
if (gameState) {
|
||||
const linesSpan = info.querySelector('.lines');
|
||||
if (linesSpan) {
|
||||
linesSpan.textContent = gameState.lines;
|
||||
}
|
||||
}
|
||||
|
||||
// Update board state classes
|
||||
element.classList.remove('active', 'eliminated');
|
||||
if (gameState && gameState.eliminated) {
|
||||
element.classList.add('eliminated');
|
||||
}
|
||||
}
|
||||
|
||||
drawGrid(ctx) {
|
||||
ctx.strokeStyle = '#222';
|
||||
ctx.lineWidth = 1;
|
||||
|
||||
for (let x = 0; x <= BOARD_WIDTH; x++) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x * CELL_SIZE, 0);
|
||||
ctx.lineTo(x * CELL_SIZE, BOARD_HEIGHT * CELL_SIZE);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
for (let y = 0; y <= BOARD_HEIGHT; y++) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, y * CELL_SIZE);
|
||||
ctx.lineTo(BOARD_WIDTH * CELL_SIZE, y * CELL_SIZE);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
drawBoard(ctx, board) {
|
||||
for (let row = 0; row < BOARD_HEIGHT; row++) {
|
||||
for (let col = 0; col < BOARD_WIDTH; col++) {
|
||||
const color = board[row][col];
|
||||
if (color) {
|
||||
this.drawCell(ctx, col, row, color);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
drawPiece(ctx, piece) {
|
||||
for (let row = 0; row < piece.shape.length; row++) {
|
||||
for (let col = 0; col < piece.shape[row].length; col++) {
|
||||
if (piece.shape[row][col]) {
|
||||
const x = piece.x + col;
|
||||
const y = piece.y + row;
|
||||
if (y >= 0) {
|
||||
this.drawCell(ctx, x, y, piece.color);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
drawCell(ctx, x, y, color) {
|
||||
const px = x * CELL_SIZE;
|
||||
const py = y * CELL_SIZE;
|
||||
|
||||
// Main fill
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillRect(px, py, CELL_SIZE, CELL_SIZE);
|
||||
|
||||
// Bevel effect
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.3)';
|
||||
ctx.fillRect(px, py, CELL_SIZE, 3);
|
||||
ctx.fillRect(px, py, 3, CELL_SIZE);
|
||||
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.3)';
|
||||
ctx.fillRect(px, py + CELL_SIZE - 3, CELL_SIZE, 3);
|
||||
ctx.fillRect(px + CELL_SIZE - 3, py, 3, CELL_SIZE);
|
||||
|
||||
// Border
|
||||
ctx.strokeStyle = 'rgba(0,0,0,0.5)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeRect(px, py, CELL_SIZE, CELL_SIZE);
|
||||
}
|
||||
|
||||
drawNextPiece(ctx, piece) {
|
||||
// Clear
|
||||
ctx.fillStyle = '#000';
|
||||
ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height);
|
||||
|
||||
// Center the piece
|
||||
const pieceWidth = piece.shape[0].length * 20;
|
||||
const pieceHeight = piece.shape.length * 20;
|
||||
const offsetX = (ctx.canvas.width - pieceWidth) / 2;
|
||||
const offsetY = (ctx.canvas.height - pieceHeight) / 2;
|
||||
|
||||
for (let row = 0; row < piece.shape.length; row++) {
|
||||
for (let col = 0; col < piece.shape[row].length; col++) {
|
||||
if (piece.shape[row][col]) {
|
||||
const x = offsetX + col * 20;
|
||||
const y = offsetY + row * 20;
|
||||
|
||||
ctx.fillStyle = piece.color;
|
||||
ctx.fillRect(x, y, 20, 20);
|
||||
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.3)';
|
||||
ctx.fillRect(x, y, 20, 3);
|
||||
ctx.fillRect(x, y, 3, 20);
|
||||
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.3)';
|
||||
ctx.fillRect(x, y + 17, 20, 3);
|
||||
ctx.fillRect(x + 17, y, 3, 20);
|
||||
|
||||
ctx.strokeStyle = 'rgba(0,0,0,0.5)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeRect(x, y, 20, 20);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setActivePlayer(playerId) {
|
||||
// Remove active class from all boards
|
||||
this.boards.forEach((boardData) => {
|
||||
boardData.element.classList.remove('active');
|
||||
});
|
||||
|
||||
// Add to current player
|
||||
const boardData = this.boards.get(playerId);
|
||||
if (boardData) {
|
||||
boardData.element.classList.add('active');
|
||||
}
|
||||
}
|
||||
|
||||
triggerShake(playerId) {
|
||||
const boardData = this.boards.get(playerId);
|
||||
if (boardData) {
|
||||
boardData.element.classList.remove('shake');
|
||||
void boardData.element.offsetWidth; // Trigger reflow
|
||||
boardData.element.classList.add('shake');
|
||||
}
|
||||
}
|
||||
|
||||
triggerFlash(playerId) {
|
||||
const boardData = this.boards.get(playerId);
|
||||
if (boardData) {
|
||||
boardData.element.classList.remove('flash');
|
||||
void boardData.element.offsetWidth; // Trigger reflow
|
||||
boardData.element.classList.add('flash');
|
||||
}
|
||||
}
|
||||
|
||||
updatePlayerInfo(playerId, name, score, lines) {
|
||||
const boardData = this.boards.get(playerId);
|
||||
if (boardData) {
|
||||
const nameSpan = boardData.info.querySelector('.name');
|
||||
if (nameSpan) nameSpan.textContent = name;
|
||||
|
||||
const linesSpan = boardData.info.querySelector('.lines');
|
||||
if (linesSpan) linesSpan.textContent = lines;
|
||||
}
|
||||
}
|
||||
|
||||
clearAll() {
|
||||
this.boards.forEach((boardData) => {
|
||||
boardData.element.remove();
|
||||
});
|
||||
this.boards.clear();
|
||||
}
|
||||
}
|
||||
|
||||
window.TetrisRenderer = TetrisRenderer;
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
// UI Module - Handle screens and user interactions
|
||||
|
||||
class UIManager {
|
||||
constructor() {
|
||||
this.screens = {
|
||||
room: document.getElementById('room-screen'),
|
||||
lobby: document.getElementById('lobby-screen'),
|
||||
game: document.getElementById('game-screen'),
|
||||
gameover: document.getElementById('gameover-screen')
|
||||
};
|
||||
|
||||
this.inputs = {
|
||||
roomName: document.getElementById('room-name'),
|
||||
playerName: document.getElementById('player-name')
|
||||
};
|
||||
|
||||
this.buttons = {
|
||||
join: document.getElementById('join-btn'),
|
||||
ready: document.getElementById('ready-btn'),
|
||||
leave: document.getElementById('leave-btn'),
|
||||
backToLobby: document.getElementById('back-to-lobby')
|
||||
};
|
||||
|
||||
this.displays = {
|
||||
lobbyRoomName: document.getElementById('lobby-room-name'),
|
||||
gameRoomName: document.getElementById('game-room-name'),
|
||||
playerList: document.getElementById('player-list'),
|
||||
battleGrid: document.getElementById('battle-grid'),
|
||||
gameStatus: document.getElementById('game-status'),
|
||||
winnerDisplay: document.getElementById('winner-display'),
|
||||
finalScores: document.getElementById('final-scores')
|
||||
};
|
||||
|
||||
this.bindEvents();
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
this.buttons.join.addEventListener('click', () => this.handleJoin());
|
||||
this.buttons.ready.addEventListener('click', () => this.handleReady());
|
||||
this.buttons.leave.addEventListener('click', () => this.handleLeave());
|
||||
this.buttons.backToLobby.addEventListener('click', () => this.handleBackToLobby());
|
||||
|
||||
// Allow Enter key to submit forms
|
||||
this.inputs.roomName.addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter') this.handleJoin();
|
||||
});
|
||||
this.inputs.playerName.addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter') this.handleJoin();
|
||||
});
|
||||
}
|
||||
|
||||
showScreen(screenName) {
|
||||
Object.values(this.screens).forEach(screen => screen.classList.remove('active'));
|
||||
this.screens[screenName].classList.add('active');
|
||||
}
|
||||
|
||||
handleJoin() {
|
||||
const roomName = this.inputs.roomName.value.trim();
|
||||
const playerName = this.inputs.playerName.value.trim();
|
||||
|
||||
if (!roomName || !playerName) {
|
||||
this.showMessage('Please enter room name and your name');
|
||||
return;
|
||||
}
|
||||
|
||||
// Emit join event
|
||||
network.setListener('player-joined', () => {
|
||||
this.showScreen('lobby');
|
||||
this.displays.lobbyRoomName.textContent = roomName;
|
||||
});
|
||||
|
||||
network.joinRoom(roomName, playerName);
|
||||
}
|
||||
|
||||
handleReady() {
|
||||
network.ready();
|
||||
this.buttons.ready.textContent = 'READY!';
|
||||
this.buttons.ready.disabled = true;
|
||||
}
|
||||
|
||||
handleLeave() {
|
||||
network.leaveRoom();
|
||||
this.showScreen('room');
|
||||
}
|
||||
|
||||
handleBackToLobby() {
|
||||
this.showScreen('room');
|
||||
}
|
||||
|
||||
updatePlayerList(players) {
|
||||
this.displays.playerList.innerHTML = '';
|
||||
Object.values(players).forEach(player => {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'player-item';
|
||||
const statusClass = player.ready ? 'ready' : '';
|
||||
item.innerHTML = `
|
||||
<span class="name">${this.escapeHtml(player.name)}</span>
|
||||
<span class="status ${statusClass}">${player.ready ? 'READY' : 'WAITING'}</span>
|
||||
`;
|
||||
this.displays.playerList.appendChild(item);
|
||||
});
|
||||
}
|
||||
|
||||
showMessage(message) {
|
||||
this.displays.gameStatus.textContent = message;
|
||||
setTimeout(() => {
|
||||
this.displays.gameStatus.textContent = '';
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
showGameOver(winner, scores) {
|
||||
this.displays.winnerDisplay.textContent = winner
|
||||
? `Winner: ${winner}!`
|
||||
: 'Game Over!';
|
||||
this.displays.winnerDisplay.style.color = winner ? '#0f0' : '#fff';
|
||||
|
||||
this.displays.finalScores.innerHTML = '';
|
||||
Object.entries(scores).forEach(([name, score], index) => {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'score-item' + (index === 0 ? ' winner' : '');
|
||||
item.innerHTML = `
|
||||
<span>${this.escapeHtml(name)}</span>
|
||||
<span>${score}</span>
|
||||
`;
|
||||
this.displays.finalScores.appendChild(item);
|
||||
});
|
||||
|
||||
this.showScreen('gameover');
|
||||
}
|
||||
}
|
||||
|
||||
window.ui = new UIManager();
|
||||
+401
@@ -0,0 +1,401 @@
|
||||
const express = require('express');
|
||||
const http = require('http');
|
||||
const { Server } = require('socket.io');
|
||||
const path = require('path');
|
||||
|
||||
const app = express();
|
||||
const server = http.createServer(app);
|
||||
const io = new Server(server);
|
||||
|
||||
// Serve static files
|
||||
app.use(express.static(path.join(__dirname, '../public')));
|
||||
|
||||
// Game state storage
|
||||
const rooms = new Map();
|
||||
|
||||
const PORT = process.env.PORT || 3000;
|
||||
|
||||
// Tetromino definitions
|
||||
const TETROMINOS = {
|
||||
I: { shape: [[0,0,0,0],[1,1,1,1],[0,0,0,0],[0,0,0,0]], color: '#00ffff' },
|
||||
O: { shape: [[1,1],[1,1]], color: '#ffff00' },
|
||||
T: { shape: [[0,1,0],[1,1,1],[0,0,0]], color: '#800080' },
|
||||
S: { shape: [[0,1,1],[1,1,0],[0,0,0]], color: '#00ff00' },
|
||||
Z: { shape: [[1,1,0],[0,1,1],[0,0,0]], color: '#ff0000' },
|
||||
J: { shape: [[1,0,0],[1,1,1],[0,0,0]], color: '#0000ff' },
|
||||
L: { shape: [[0,0,1],[1,1,1],[0,0,0]], color: '#ffa500' }
|
||||
};
|
||||
|
||||
const TETROMINO_KEYS = Object.keys(TETROMINOS);
|
||||
const BOARD_WIDTH = 10;
|
||||
const BOARD_HEIGHT = 20;
|
||||
const GARBAGE_COLOR = '#666666';
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
console.log('Player connected:', socket.id);
|
||||
|
||||
socket.on('join-room', ({ roomName, playerName }) => {
|
||||
if (!rooms.has(roomName)) {
|
||||
rooms.set(roomName, {
|
||||
name: roomName,
|
||||
players: new Map(),
|
||||
gameStarted: false,
|
||||
gameInterval: null
|
||||
});
|
||||
}
|
||||
|
||||
const room = rooms.get(roomName);
|
||||
socket.join(roomName);
|
||||
socket.data.roomName = roomName;
|
||||
socket.data.playerName = playerName;
|
||||
|
||||
const player = {
|
||||
id: socket.id,
|
||||
name: playerName,
|
||||
score: 0,
|
||||
lines: 0,
|
||||
level: 1,
|
||||
board: createEmptyBoard(),
|
||||
currentPiece: null,
|
||||
nextPiece: null,
|
||||
eliminated: false,
|
||||
ready: false,
|
||||
dropCounter: 0,
|
||||
dropInterval: 1000
|
||||
};
|
||||
|
||||
room.players.set(socket.id, player);
|
||||
|
||||
io.to(roomName).emit('player-joined', {
|
||||
player: { id: player.id, name: player.name },
|
||||
players: getPlayersList(room)
|
||||
});
|
||||
|
||||
console.log(`${playerName} joined room ${roomName}`);
|
||||
});
|
||||
|
||||
socket.on('ready', () => {
|
||||
const roomName = socket.data.roomName;
|
||||
if (!roomName) return;
|
||||
|
||||
const room = rooms.get(roomName);
|
||||
const player = room.players.get(socket.id);
|
||||
player.ready = true;
|
||||
|
||||
io.to(roomName).emit('player-joined', {
|
||||
player: { id: player.id, name: player.name, ready: player.ready },
|
||||
players: getPlayersList(room)
|
||||
});
|
||||
|
||||
if (room.players.size >= 2 && room.players.size <= 8) {
|
||||
const allReady = Array.from(room.players.values()).every(p => p.ready);
|
||||
if (allReady) {
|
||||
startGame(room);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('player-move', ({ playerId, direction }) => {
|
||||
const roomName = socket.data.roomName;
|
||||
if (!roomName) return;
|
||||
const room = rooms.get(roomName);
|
||||
if (!room || !room.gameStarted) return;
|
||||
|
||||
const player = room.players.get(playerId);
|
||||
if (!player || player.eliminated) return;
|
||||
|
||||
const newX = player.currentPiece.x + (direction === 'left' ? -1 : 1);
|
||||
if (isValidPosition(player.currentPiece, newX, player.currentPiece.y, player.board)) {
|
||||
player.currentPiece.x = newX;
|
||||
broadcastState(room);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('player-rotate', ({ playerId }) => {
|
||||
const roomName = socket.data.roomName;
|
||||
if (!roomName) return;
|
||||
const room = rooms.get(roomName);
|
||||
if (!room || !room.gameStarted) return;
|
||||
|
||||
const player = room.players.get(playerId);
|
||||
if (!player || player.eliminated) return;
|
||||
|
||||
const originalShape = player.currentPiece.shape;
|
||||
const rows = originalShape.length;
|
||||
const cols = originalShape[0].length;
|
||||
|
||||
const rotated = Array(cols).fill(null).map(() => Array(rows).fill(0));
|
||||
for (let row = 0; row < rows; row++) {
|
||||
for (let col = 0; col < cols; col++) {
|
||||
rotated[col][rows - 1 - row] = originalShape[row][col];
|
||||
}
|
||||
}
|
||||
|
||||
const kicks = [0, -1, 1, -2, 2];
|
||||
for (const kick of kicks) {
|
||||
if (isValidPosition(player.currentPiece, player.currentPiece.x + kick, player.currentPiece.y, player.board, rotated)) {
|
||||
player.currentPiece.shape = rotated;
|
||||
player.currentPiece.x += kick;
|
||||
broadcastState(room);
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('player-drop', ({ playerId, hard }) => {
|
||||
const roomName = socket.data.roomName;
|
||||
if (!roomName) return;
|
||||
const room = rooms.get(roomName);
|
||||
if (!room || !room.gameStarted) return;
|
||||
|
||||
const player = room.players.get(playerId);
|
||||
if (!player || player.eliminated) return;
|
||||
|
||||
if (hard) {
|
||||
let dropped = 0;
|
||||
while (isValidPosition(player.currentPiece, player.currentPiece.x, player.currentPiece.y + 1, player.board)) {
|
||||
player.currentPiece.y++;
|
||||
dropped++;
|
||||
}
|
||||
player.score += dropped * 2;
|
||||
lockPiece(room, player);
|
||||
} else {
|
||||
const newY = player.currentPiece.y + 1;
|
||||
if (isValidPosition(player.currentPiece, player.currentPiece.x, newY, player.board)) {
|
||||
player.currentPiece.y = newY;
|
||||
player.score += 1;
|
||||
broadcastState(room);
|
||||
} else {
|
||||
lockPiece(room, player);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
const roomName = socket.data.roomName;
|
||||
if (!roomName) return;
|
||||
|
||||
const room = rooms.get(roomName);
|
||||
if (room) {
|
||||
const player = room.players.get(socket.id);
|
||||
if (player) {
|
||||
console.log(`${player.name} left room ${roomName}`);
|
||||
|
||||
if (room.gameStarted) {
|
||||
player.eliminated = true;
|
||||
broadcastState(room);
|
||||
checkGameOver(room);
|
||||
}
|
||||
|
||||
room.players.delete(socket.id);
|
||||
io.to(roomName).emit('player-left', {
|
||||
playerId: socket.id,
|
||||
players: getPlayersList(room)
|
||||
});
|
||||
|
||||
if (room.players.size === 0) {
|
||||
if (room.gameInterval) clearInterval(room.gameInterval);
|
||||
rooms.delete(roomName);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function createEmptyBoard() {
|
||||
return Array(BOARD_HEIGHT).fill(null).map(() => Array(BOARD_WIDTH).fill(0));
|
||||
}
|
||||
|
||||
function getPlayersList(room) {
|
||||
return Array.from(room.players.values()).map(p => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
score: p.score,
|
||||
lines: p.lines,
|
||||
level: p.level,
|
||||
eliminated: p.eliminated,
|
||||
ready: p.ready
|
||||
}));
|
||||
}
|
||||
|
||||
function isValidPosition(piece, x, y, board, shape = null) {
|
||||
const s = shape || piece.shape;
|
||||
for (let row = 0; row < s.length; row++) {
|
||||
for (let col = 0; col < s[row].length; col++) {
|
||||
if (s[row][col]) {
|
||||
const newX = x + col;
|
||||
const newY = y + row;
|
||||
if (newX < 0 || newX >= BOARD_WIDTH || newY >= BOARD_HEIGHT) return false;
|
||||
if (newY >= 0 && board[newY][newX]) return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function getRandomPiece() {
|
||||
const key = TETROMINO_KEYS[Math.floor(Math.random() * TETROMINO_KEYS.length)];
|
||||
const tetromino = TETROMINOS[key];
|
||||
return {
|
||||
type: key,
|
||||
shape: JSON.parse(JSON.stringify(tetromino.shape)),
|
||||
color: tetromino.color,
|
||||
x: Math.floor(BOARD_WIDTH / 2) - Math.floor(tetromino.shape[0].length / 2),
|
||||
y: 0
|
||||
};
|
||||
}
|
||||
|
||||
function spawnPiece(player) {
|
||||
player.currentPiece = player.nextPiece;
|
||||
player.nextPiece = getRandomPiece();
|
||||
return isValidPosition(player.currentPiece, player.currentPiece.x, player.currentPiece.y, player.board);
|
||||
}
|
||||
|
||||
function lockPiece(room, player) {
|
||||
if (!player.currentPiece) return;
|
||||
|
||||
for (let row = 0; row < player.currentPiece.shape.length; row++) {
|
||||
for (let col = 0; col < player.currentPiece.shape[row].length; col++) {
|
||||
if (player.currentPiece.shape[row][col]) {
|
||||
const boardY = player.currentPiece.y + row;
|
||||
const boardX = player.currentPiece.x + col;
|
||||
if (boardY >= 0 && boardY < BOARD_HEIGHT && boardX >= 0 && boardX < BOARD_WIDTH) {
|
||||
player.board[boardY][boardX] = player.currentPiece.color;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (player.currentPiece.y <= 0) player.eliminated = true;
|
||||
|
||||
const rowsCleared = clearRows(player);
|
||||
|
||||
if (!spawnPiece(player)) player.eliminated = true;
|
||||
|
||||
if (rowsCleared > 0) sendGarbage(room, player, rowsCleared);
|
||||
|
||||
broadcastState(room);
|
||||
checkGameOver(room);
|
||||
}
|
||||
|
||||
function clearRows(player) {
|
||||
let rowsCleared = 0;
|
||||
for (let row = BOARD_HEIGHT - 1; row >= 0; row--) {
|
||||
if (player.board[row].every(cell => cell !== 0)) {
|
||||
player.board.splice(row, 1);
|
||||
player.board.unshift(Array(BOARD_WIDTH).fill(0));
|
||||
rowsCleared++;
|
||||
row++;
|
||||
}
|
||||
}
|
||||
if (rowsCleared > 0) {
|
||||
player.lines += rowsCleared;
|
||||
const points = [0, 100, 300, 500, 800];
|
||||
player.score += points[rowsCleared] * player.level;
|
||||
player.level = Math.floor(player.lines / 10) + 1;
|
||||
player.dropInterval = Math.max(100, 1000 - (player.level - 1) * 100);
|
||||
}
|
||||
return rowsCleared;
|
||||
}
|
||||
|
||||
function sendGarbage(room, sender, rowsCleared) {
|
||||
const garbageRows = rowsCleared >= 4 ? rowsCleared : Math.max(1, rowsCleared - 1);
|
||||
const opponents = Array.from(room.players.values()).filter(p => p.id !== sender.id && !p.eliminated);
|
||||
if (opponents.length === 0) return;
|
||||
for (let i = 0; i < garbageRows; i++) {
|
||||
const target = opponents[Math.floor(Math.random() * opponents.length)];
|
||||
addGarbageToPlayer(target);
|
||||
}
|
||||
}
|
||||
|
||||
function addGarbageToPlayer(player) {
|
||||
player.board.pop();
|
||||
const garbageRow = Array(BOARD_WIDTH).fill(GARBAGE_COLOR);
|
||||
garbageRow[Math.floor(Math.random() * BOARD_WIDTH)] = 0;
|
||||
player.board.unshift(garbageRow);
|
||||
|
||||
for (let row = 0; row < 2; row++) {
|
||||
for (let col = 0; col < BOARD_WIDTH; col++) {
|
||||
if (player.board[row][col] !== 0) {
|
||||
player.eliminated = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function startGame(room) {
|
||||
room.gameStarted = true;
|
||||
|
||||
for (const player of room.players.values()) {
|
||||
player.board = createEmptyBoard();
|
||||
player.score = 0;
|
||||
player.lines = 0;
|
||||
player.level = 1;
|
||||
player.eliminated = false;
|
||||
player.dropInterval = 1000;
|
||||
player.currentPiece = getRandomPiece();
|
||||
player.nextPiece = getRandomPiece();
|
||||
}
|
||||
|
||||
room.gameInterval = setInterval(() => gameTick(room), 50);
|
||||
|
||||
io.to(room.name).emit('game-started', {
|
||||
players: getPlayersList(room),
|
||||
states: getStates(room)
|
||||
});
|
||||
|
||||
console.log(`Game started in room ${room.name} with ${room.players.size} players`);
|
||||
}
|
||||
|
||||
function gameTick(room) {
|
||||
for (const player of room.players.values()) {
|
||||
if (player.eliminated) continue;
|
||||
|
||||
player.dropCounter += 50;
|
||||
if (player.dropCounter >= player.dropInterval) {
|
||||
player.dropCounter = 0;
|
||||
if (isValidPosition(player.currentPiece, player.currentPiece.x, player.currentPiece.y + 1, player.board)) {
|
||||
player.currentPiece.y++;
|
||||
} else {
|
||||
lockPiece(room, player);
|
||||
}
|
||||
}
|
||||
}
|
||||
broadcastState(room);
|
||||
checkGameOver(room);
|
||||
}
|
||||
|
||||
function broadcastState(room) {
|
||||
io.to(room.name).emit('state-update', getStates(room));
|
||||
}
|
||||
|
||||
function getStates(room) {
|
||||
return Array.from(room.players.values()).map(p => ({
|
||||
playerId: p.id,
|
||||
board: JSON.parse(JSON.stringify(p.board)),
|
||||
currentPiece: p.currentPiece ? JSON.parse(JSON.stringify(p.currentPiece)) : null,
|
||||
nextPiece: p.nextPiece ? JSON.parse(JSON.stringify(p.nextPiece)) : null,
|
||||
score: p.score,
|
||||
lines: p.lines,
|
||||
level: p.level,
|
||||
eliminated: p.eliminated
|
||||
}));
|
||||
}
|
||||
|
||||
function checkGameOver(room) {
|
||||
const activePlayers = Array.from(room.players.values()).filter(p => !p.eliminated);
|
||||
if (activePlayers.length <= 1) {
|
||||
io.to(room.name).emit('game-over', { states: getStates(room) });
|
||||
if (room.gameInterval) {
|
||||
clearInterval(room.gameInterval);
|
||||
room.gameInterval = null;
|
||||
}
|
||||
room.gameStarted = false;
|
||||
}
|
||||
}
|
||||
|
||||
server.listen(PORT, () => {
|
||||
console.log(`Tetris Battle Royale server running on port ${PORT}`);
|
||||
console.log(`Open http://localhost:${PORT} in 2-8 browser tabs to play!`);
|
||||
});
|
||||
Generated
+1100
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "tetris-battle-royale",
|
||||
"version": "1.0.0",
|
||||
"description": "Multiplayer Tetris Battle Royale",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"start": "node index.js",
|
||||
"dev": "node index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.18.2",
|
||||
"socket.io": "^4.7.2",
|
||||
"uuid": "^9.0.0"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user