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