842 lines
23 KiB
Go
842 lines
23 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"strconv"
|
|
"time"
|
|
)
|
|
|
|
// Tetrimino types
|
|
const (
|
|
EMPTY = iota
|
|
I_PIECE
|
|
O_PIECE
|
|
T_PIECE
|
|
S_PIECE
|
|
Z_PIECE
|
|
J_PIECE
|
|
L_PIECE
|
|
)
|
|
|
|
// Tetrimino shapes (4 rotations each)
|
|
var tetriminoShapes = map[int][4][4][4]int{
|
|
I_PIECE: {
|
|
{{0, 0, 0, 0}, {1, 1, 1, 1}, {0, 0, 0, 0}, {0, 0, 0, 0}},
|
|
{{0, 0, 1, 0}, {0, 0, 1, 0}, {0, 0, 1, 0}, {0, 0, 1, 0}},
|
|
{{0, 0, 0, 0}, {0, 0, 0, 0}, {1, 1, 1, 1}, {0, 0, 0, 0}},
|
|
{{0, 1, 0, 0}, {0, 1, 0, 0}, {0, 1, 0, 0}, {0, 1, 0, 0}},
|
|
},
|
|
O_PIECE: {
|
|
{{0, 1, 1, 0}, {0, 1, 1, 0}, {0, 0, 0, 0}, {0, 0, 0, 0}},
|
|
{{0, 1, 1, 0}, {0, 1, 1, 0}, {0, 0, 0, 0}, {0, 0, 0, 0}},
|
|
{{0, 1, 1, 0}, {0, 1, 1, 0}, {0, 0, 0, 0}, {0, 0, 0, 0}},
|
|
{{0, 1, 1, 0}, {0, 1, 1, 0}, {0, 0, 0, 0}, {0, 0, 0, 0}},
|
|
},
|
|
T_PIECE: {
|
|
{{0, 1, 0, 0}, {1, 1, 1, 0}, {0, 0, 0, 0}, {0, 0, 0, 0}},
|
|
{{0, 1, 0, 0}, {0, 1, 1, 0}, {0, 1, 0, 0}, {0, 0, 0, 0}},
|
|
{{0, 0, 0, 0}, {1, 1, 1, 0}, {0, 1, 0, 0}, {0, 0, 0, 0}},
|
|
{{0, 1, 0, 0}, {1, 1, 0, 0}, {0, 1, 0, 0}, {0, 0, 0, 0}},
|
|
},
|
|
S_PIECE: {
|
|
{{0, 1, 1, 0}, {1, 1, 0, 0}, {0, 0, 0, 0}, {0, 0, 0, 0}},
|
|
{{0, 1, 0, 0}, {0, 1, 1, 0}, {0, 0, 1, 0}, {0, 0, 0, 0}},
|
|
{{0, 0, 0, 0}, {0, 1, 1, 0}, {1, 1, 0, 0}, {0, 0, 0, 0}},
|
|
{{1, 0, 0, 0}, {1, 1, 0, 0}, {0, 1, 0, 0}, {0, 0, 0, 0}},
|
|
},
|
|
Z_PIECE: {
|
|
{{1, 1, 0, 0}, {0, 1, 1, 0}, {0, 0, 0, 0}, {0, 0, 0, 0}},
|
|
{{0, 0, 1, 0}, {0, 1, 1, 0}, {0, 1, 0, 0}, {0, 0, 0, 0}},
|
|
{{0, 0, 0, 0}, {1, 1, 0, 0}, {0, 1, 1, 0}, {0, 0, 0, 0}},
|
|
{{0, 1, 0, 0}, {1, 1, 0, 0}, {1, 0, 0, 0}, {0, 0, 0, 0}},
|
|
},
|
|
J_PIECE: {
|
|
{{1, 0, 0, 0}, {1, 1, 1, 0}, {0, 0, 0, 0}, {0, 0, 0, 0}},
|
|
{{0, 1, 1, 0}, {0, 1, 0, 0}, {0, 1, 0, 0}, {0, 0, 0, 0}},
|
|
{{0, 0, 0, 0}, {1, 1, 1, 0}, {0, 0, 1, 0}, {0, 0, 0, 0}},
|
|
{{0, 1, 0, 0}, {0, 1, 0, 0}, {1, 1, 0, 0}, {0, 0, 0, 0}},
|
|
},
|
|
L_PIECE: {
|
|
{{0, 0, 1, 0}, {1, 1, 1, 0}, {0, 0, 0, 0}, {0, 0, 0, 0}},
|
|
{{0, 1, 0, 0}, {0, 1, 0, 0}, {0, 1, 1, 0}, {0, 0, 0, 0}},
|
|
{{0, 0, 0, 0}, {1, 1, 1, 0}, {1, 0, 0, 0}, {0, 0, 0, 0}},
|
|
{{1, 1, 0, 0}, {0, 1, 0, 0}, {0, 1, 0, 0}, {0, 0, 0, 0}},
|
|
},
|
|
}
|
|
|
|
type Tetrimino struct {
|
|
Type int `json:"type"`
|
|
X int `json:"x"`
|
|
Y int `json:"y"`
|
|
Rotation int `json:"rotation"`
|
|
}
|
|
|
|
type GameState struct {
|
|
Board [40][10]int `json:"board"`
|
|
CurrentPiece *Tetrimino `json:"current_piece"`
|
|
NextPieces []int `json:"next_pieces"`
|
|
HoldPiece *int `json:"hold_piece"`
|
|
Score int `json:"score"`
|
|
Level int `json:"level"`
|
|
LinesCleared int `json:"lines_cleared"`
|
|
GameRunning bool `json:"game_running"`
|
|
GameOver bool `json:"game_over"`
|
|
CanHold bool `json:"can_hold"`
|
|
LinesForLevel int `json:"lines_for_level"`
|
|
}
|
|
|
|
type Game struct {
|
|
state *GameState
|
|
bag []int
|
|
bagIndex int
|
|
clients map[chan string]bool
|
|
}
|
|
|
|
func NewGame() *Game {
|
|
g := &Game{
|
|
state: &GameState{
|
|
NextPieces: make([]int, 6),
|
|
Level: 1,
|
|
GameRunning: false,
|
|
GameOver: false,
|
|
CanHold: true,
|
|
LinesForLevel: 5,
|
|
},
|
|
clients: make(map[chan string]bool),
|
|
}
|
|
g.fillBag()
|
|
g.fillNextPieces()
|
|
return g
|
|
}
|
|
|
|
func (g *Game) fillBag() {
|
|
g.bag = []int{I_PIECE, O_PIECE, T_PIECE, S_PIECE, Z_PIECE, J_PIECE, L_PIECE}
|
|
// Simple shuffle
|
|
for i := len(g.bag) - 1; i > 0; i-- {
|
|
j := int(time.Now().UnixNano()) % (i + 1)
|
|
g.bag[i], g.bag[j] = g.bag[j], g.bag[i]
|
|
}
|
|
g.bagIndex = 0
|
|
}
|
|
|
|
func (g *Game) getNextPiece() int {
|
|
if g.bagIndex >= len(g.bag) {
|
|
g.fillBag()
|
|
}
|
|
piece := g.bag[g.bagIndex]
|
|
g.bagIndex++
|
|
return piece
|
|
}
|
|
|
|
func (g *Game) fillNextPieces() {
|
|
for i := 0; i < 6; i++ {
|
|
g.state.NextPieces[i] = g.getNextPiece()
|
|
}
|
|
}
|
|
|
|
func (g *Game) spawnPiece() {
|
|
pieceType := g.state.NextPieces[0]
|
|
// Shift next pieces
|
|
copy(g.state.NextPieces[:5], g.state.NextPieces[1:])
|
|
g.state.NextPieces[5] = g.getNextPiece()
|
|
|
|
// Spawn position based on guidelines
|
|
x := 3 // left-middle for most pieces
|
|
if pieceType == I_PIECE || pieceType == O_PIECE {
|
|
x = 4 // middle for I and O
|
|
}
|
|
|
|
g.state.CurrentPiece = &Tetrimino{
|
|
Type: pieceType,
|
|
X: x,
|
|
Y: 21,
|
|
Rotation: 0,
|
|
}
|
|
|
|
// Drop one space if possible
|
|
if g.canMove(g.state.CurrentPiece.X, g.state.CurrentPiece.Y-1, g.state.CurrentPiece.Rotation) {
|
|
g.state.CurrentPiece.Y--
|
|
}
|
|
|
|
// Check for game over
|
|
if !g.canMove(g.state.CurrentPiece.X, g.state.CurrentPiece.Y, g.state.CurrentPiece.Rotation) {
|
|
g.state.GameOver = true
|
|
g.state.GameRunning = false
|
|
}
|
|
|
|
g.state.CanHold = true
|
|
}
|
|
|
|
func (g *Game) canMove(x, y, rotation int) bool {
|
|
shape := tetriminoShapes[g.state.CurrentPiece.Type][rotation]
|
|
for py := 0; py < 4; py++ {
|
|
for px := 0; px < 4; px++ {
|
|
if shape[py][px] == 0 {
|
|
continue
|
|
}
|
|
boardX := x + px
|
|
boardY := y + py
|
|
if boardX < 0 || boardX >= 10 || boardY < 0 {
|
|
return false
|
|
}
|
|
if boardY < 40 && g.state.Board[boardY][boardX] != EMPTY {
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func (g *Game) lockPiece() {
|
|
shape := tetriminoShapes[g.state.CurrentPiece.Type][g.state.CurrentPiece.Rotation]
|
|
for py := 0; py < 4; py++ {
|
|
for px := 0; px < 4; px++ {
|
|
if shape[py][px] == 0 {
|
|
continue
|
|
}
|
|
boardX := g.state.CurrentPiece.X + px
|
|
boardY := g.state.CurrentPiece.Y + py
|
|
if boardY < 40 {
|
|
g.state.Board[boardY][boardX] = g.state.CurrentPiece.Type
|
|
}
|
|
}
|
|
}
|
|
g.clearLines()
|
|
g.spawnPiece()
|
|
}
|
|
|
|
func (g *Game) clearLines() {
|
|
linesCleared := 0
|
|
for y := 0; y < 40; y++ {
|
|
full := true
|
|
for x := 0; x < 10; x++ {
|
|
if g.state.Board[y][x] == EMPTY {
|
|
full = false
|
|
break
|
|
}
|
|
}
|
|
if full {
|
|
// Remove line
|
|
for moveY := y; moveY < 39; moveY++ {
|
|
for x := 0; x < 10; x++ {
|
|
g.state.Board[moveY][x] = g.state.Board[moveY+1][x]
|
|
}
|
|
}
|
|
// Clear top line
|
|
for x := 0; x < 10; x++ {
|
|
g.state.Board[39][x] = EMPTY
|
|
}
|
|
linesCleared++
|
|
y-- // Check same line again
|
|
}
|
|
}
|
|
|
|
if linesCleared > 0 {
|
|
g.state.LinesCleared += linesCleared
|
|
g.updateScore(linesCleared)
|
|
g.updateLevel()
|
|
}
|
|
}
|
|
|
|
func (g *Game) updateScore(lines int) {
|
|
baseScore := map[int]int{1: 100, 2: 300, 3: 500, 4: 800}
|
|
if score, ok := baseScore[lines]; ok {
|
|
g.state.Score += score * g.state.Level
|
|
}
|
|
}
|
|
|
|
func (g *Game) updateLevel() {
|
|
requiredLines := g.state.Level * g.state.LinesForLevel
|
|
if g.state.LinesCleared >= requiredLines {
|
|
g.state.Level++
|
|
}
|
|
}
|
|
|
|
func (g *Game) move(direction string) {
|
|
if !g.state.GameRunning || g.state.CurrentPiece == nil {
|
|
return
|
|
}
|
|
|
|
newX := g.state.CurrentPiece.X
|
|
newY := g.state.CurrentPiece.Y
|
|
|
|
switch direction {
|
|
case "left":
|
|
newX--
|
|
case "right":
|
|
newX++
|
|
case "down":
|
|
newY--
|
|
}
|
|
|
|
if g.canMove(newX, newY, g.state.CurrentPiece.Rotation) {
|
|
g.state.CurrentPiece.X = newX
|
|
g.state.CurrentPiece.Y = newY
|
|
}
|
|
}
|
|
|
|
func (g *Game) rotate(direction string) {
|
|
if !g.state.GameRunning || g.state.CurrentPiece == nil {
|
|
return
|
|
}
|
|
|
|
newRotation := g.state.CurrentPiece.Rotation
|
|
if direction == "cw" {
|
|
newRotation = (newRotation + 1) % 4
|
|
} else {
|
|
newRotation = (newRotation + 3) % 4
|
|
}
|
|
|
|
if g.canMove(g.state.CurrentPiece.X, g.state.CurrentPiece.Y, newRotation) {
|
|
g.state.CurrentPiece.Rotation = newRotation
|
|
}
|
|
}
|
|
|
|
func (g *Game) hardDrop() {
|
|
if !g.state.GameRunning || g.state.CurrentPiece == nil {
|
|
return
|
|
}
|
|
|
|
for g.canMove(g.state.CurrentPiece.X, g.state.CurrentPiece.Y-1, g.state.CurrentPiece.Rotation) {
|
|
g.state.CurrentPiece.Y--
|
|
}
|
|
g.lockPiece()
|
|
}
|
|
|
|
func (g *Game) hold() {
|
|
if !g.state.GameRunning || g.state.CurrentPiece == nil || !g.state.CanHold {
|
|
return
|
|
}
|
|
|
|
if g.state.HoldPiece == nil {
|
|
g.state.HoldPiece = &g.state.CurrentPiece.Type
|
|
g.spawnPiece()
|
|
} else {
|
|
temp := *g.state.HoldPiece
|
|
g.state.HoldPiece = &g.state.CurrentPiece.Type
|
|
|
|
x := 3
|
|
if temp == I_PIECE || temp == O_PIECE {
|
|
x = 4
|
|
}
|
|
|
|
g.state.CurrentPiece = &Tetrimino{
|
|
Type: temp,
|
|
X: x,
|
|
Y: 21,
|
|
Rotation: 0,
|
|
}
|
|
|
|
if g.canMove(g.state.CurrentPiece.X, g.state.CurrentPiece.Y-1, g.state.CurrentPiece.Rotation) {
|
|
g.state.CurrentPiece.Y--
|
|
}
|
|
}
|
|
g.state.CanHold = false
|
|
}
|
|
|
|
func (g *Game) startGame() {
|
|
g.state = &GameState{
|
|
NextPieces: make([]int, 6),
|
|
Level: 1,
|
|
GameRunning: true,
|
|
GameOver: false,
|
|
CanHold: true,
|
|
LinesForLevel: 5,
|
|
}
|
|
g.fillBag()
|
|
g.fillNextPieces()
|
|
g.spawnPiece()
|
|
}
|
|
|
|
func (g *Game) tick() {
|
|
if !g.state.GameRunning || g.state.CurrentPiece == nil {
|
|
return
|
|
}
|
|
|
|
if g.canMove(g.state.CurrentPiece.X, g.state.CurrentPiece.Y-1, g.state.CurrentPiece.Rotation) {
|
|
g.state.CurrentPiece.Y--
|
|
} else {
|
|
g.lockPiece()
|
|
}
|
|
}
|
|
|
|
func (g *Game) broadcast() {
|
|
data, _ := json.Marshal(g.state)
|
|
message := fmt.Sprintf("data: %s\n\n", string(data))
|
|
|
|
for client := range g.clients {
|
|
select {
|
|
case client <- message:
|
|
default:
|
|
close(client)
|
|
delete(g.clients, client)
|
|
}
|
|
}
|
|
}
|
|
|
|
var game = NewGame()
|
|
|
|
func main() {
|
|
http.HandleFunc("/", serveHTML)
|
|
http.HandleFunc("/events", handleSSE)
|
|
http.HandleFunc("/action", handleAction)
|
|
|
|
// Game loop
|
|
go func() {
|
|
ticker := time.NewTicker(time.Second)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-ticker.C:
|
|
if game.state.GameRunning {
|
|
game.tick()
|
|
game.broadcast()
|
|
}
|
|
}
|
|
}
|
|
}()
|
|
|
|
fmt.Println("Server starting on :8080")
|
|
log.Fatal(http.ListenAndServe(":8080", nil))
|
|
}
|
|
|
|
func handleSSE(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "text/event-stream")
|
|
w.Header().Set("Cache-Control", "no-cache")
|
|
w.Header().Set("Connection", "keep-alive")
|
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
|
|
client := make(chan string)
|
|
game.clients[client] = true
|
|
|
|
// Send initial state
|
|
data, _ := json.Marshal(game.state)
|
|
fmt.Fprintf(w, "data: %s\n\n", string(data))
|
|
w.(http.Flusher).Flush()
|
|
|
|
for {
|
|
select {
|
|
case message := <-client:
|
|
fmt.Fprint(w, message)
|
|
w.(http.Flusher).Flush()
|
|
case <-r.Context().Done():
|
|
delete(game.clients, client)
|
|
close(client)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func handleAction(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
w.Header().Set("Access-Control-Allow-Methods", "POST")
|
|
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
|
|
|
if r.Method == "OPTIONS" {
|
|
return
|
|
}
|
|
|
|
action := r.URL.Query().Get("action")
|
|
direction := r.URL.Query().Get("direction")
|
|
|
|
switch action {
|
|
case "move":
|
|
game.move(direction)
|
|
case "rotate":
|
|
game.rotate(direction)
|
|
case "hard_drop":
|
|
game.hardDrop()
|
|
case "hold":
|
|
game.hold()
|
|
case "start":
|
|
game.startGame()
|
|
}
|
|
|
|
game.broadcast()
|
|
w.WriteHeader(http.StatusOK)
|
|
}
|
|
|
|
func serveHTML(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "text/html")
|
|
fmt.Fprint(w, htmlContent)
|
|
}
|
|
|
|
const htmlContent = `<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Tetris</title>
|
|
<style>
|
|
body {
|
|
margin: 0;
|
|
padding: 20px;
|
|
background: #000;
|
|
color: #fff;
|
|
font-family: 'Courier New', monospace;
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: flex-start;
|
|
min-height: 100vh;
|
|
}
|
|
|
|
.game-container {
|
|
display: flex;
|
|
gap: 20px;
|
|
align-items: flex-start;
|
|
}
|
|
|
|
.main-game {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
}
|
|
|
|
canvas {
|
|
border: 2px solid #333;
|
|
background: #111;
|
|
}
|
|
|
|
.info-panel {
|
|
width: 200px;
|
|
background: #222;
|
|
padding: 15px;
|
|
border-radius: 8px;
|
|
}
|
|
|
|
.info-section {
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.info-section h3 {
|
|
margin: 0 0 10px 0;
|
|
color: #ccc;
|
|
font-size: 14px;
|
|
}
|
|
|
|
.next-canvas, .hold-canvas {
|
|
border: 1px solid #444;
|
|
background: #111;
|
|
}
|
|
|
|
.controls {
|
|
margin-top: 15px;
|
|
text-align: center;
|
|
}
|
|
|
|
.controls h3 {
|
|
margin-bottom: 10px;
|
|
}
|
|
|
|
.controls p {
|
|
margin: 5px 0;
|
|
font-size: 12px;
|
|
color: #aaa;
|
|
}
|
|
|
|
button {
|
|
background: #333;
|
|
color: #fff;
|
|
border: 1px solid #555;
|
|
padding: 8px 16px;
|
|
margin: 5px;
|
|
cursor: pointer;
|
|
border-radius: 4px;
|
|
}
|
|
|
|
button:hover {
|
|
background: #555;
|
|
}
|
|
|
|
.game-over {
|
|
position: absolute;
|
|
top: 50%;
|
|
left: 50%;
|
|
transform: translate(-50%, -50%);
|
|
background: rgba(0, 0, 0, 0.9);
|
|
padding: 30px;
|
|
border-radius: 10px;
|
|
text-align: center;
|
|
border: 2px solid #f00;
|
|
}
|
|
|
|
.score-info {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
gap: 10px;
|
|
font-size: 12px;
|
|
}
|
|
|
|
.score-info div {
|
|
text-align: center;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="game-container">
|
|
<div class="info-panel">
|
|
<div class="info-section">
|
|
<h3>Hold</h3>
|
|
<canvas id="holdCanvas" class="hold-canvas" width="80" height="80"></canvas>
|
|
</div>
|
|
|
|
<div class="info-section">
|
|
<h3>Next</h3>
|
|
<canvas id="nextCanvas" class="next-canvas" width="80" height="320"></canvas>
|
|
</div>
|
|
|
|
<div class="info-section">
|
|
<div class="score-info">
|
|
<div>Score<br><span id="score">0</span></div>
|
|
<div>Level<br><span id="level">1</span></div>
|
|
<div>Lines<br><span id="lines">0</span></div>
|
|
<div>Goal<br><span id="goal">5</span></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="main-game">
|
|
<canvas id="gameCanvas" width="300" height="600"></canvas>
|
|
|
|
<div class="controls">
|
|
<button onclick="startGame()">Start Game</button>
|
|
|
|
<h3>Controls</h3>
|
|
<p>← → Move</p>
|
|
<p>↓ Soft Drop</p>
|
|
<p>Space Hard Drop</p>
|
|
<p>Z Rotate Left</p>
|
|
<p>X Rotate Right</p>
|
|
<p>C Hold</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="gameOver" class="game-over" style="display: none;">
|
|
<h2>Game Over</h2>
|
|
<p>Final Score: <span id="finalScore">0</span></p>
|
|
<button onclick="startGame()">Play Again</button>
|
|
</div>
|
|
|
|
<script>
|
|
const canvas = document.getElementById('gameCanvas');
|
|
const ctx = canvas.getContext('2d');
|
|
const nextCanvas = document.getElementById('nextCanvas');
|
|
const nextCtx = nextCanvas.getContext('2d');
|
|
const holdCanvas = document.getElementById('holdCanvas');
|
|
const holdCtx = holdCanvas.getContext('2d');
|
|
|
|
const CELL_SIZE = 30;
|
|
const COLORS = [
|
|
'#000000', // Empty
|
|
'#00f5ff', // I - Cyan
|
|
'#ffff00', // O - Yellow
|
|
'#800080', // T - Purple
|
|
'#00ff00', // S - Green
|
|
'#ff0000', // Z - Red
|
|
'#0000ff', // J - Blue
|
|
'#ffa500' // L - Orange
|
|
];
|
|
|
|
let gameState = null;
|
|
let eventSource = null;
|
|
|
|
// Tetrimino shapes for rendering
|
|
const shapes = {
|
|
1: [[0,0,0,0],[1,1,1,1],[0,0,0,0],[0,0,0,0]], // I
|
|
2: [[0,1,1,0],[0,1,1,0],[0,0,0,0],[0,0,0,0]], // O
|
|
3: [[0,1,0,0],[1,1,1,0],[0,0,0,0],[0,0,0,0]], // T
|
|
4: [[0,1,1,0],[1,1,0,0],[0,0,0,0],[0,0,0,0]], // S
|
|
5: [[1,1,0,0],[0,1,1,0],[0,0,0,0],[0,0,0,0]], // Z
|
|
6: [[1,0,0,0],[1,1,1,0],[0,0,0,0],[0,0,0,0]], // J
|
|
7: [[0,0,1,0],[1,1,1,0],[0,0,0,0],[0,0,0,0]] // L
|
|
};
|
|
|
|
function connectEventSource() {
|
|
eventSource = new EventSource('/events');
|
|
eventSource.onmessage = function(event) {
|
|
gameState = JSON.parse(event.data);
|
|
render();
|
|
updateUI();
|
|
};
|
|
|
|
eventSource.onerror = function(event) {
|
|
console.log('EventSource failed:', event);
|
|
setTimeout(connectEventSource, 1000);
|
|
};
|
|
}
|
|
|
|
function sendAction(action, direction = '') {
|
|
const url = '/action?action=' + action + (direction ? '&direction=' + direction : '');
|
|
fetch(url, { method: 'POST' }).catch(console.error);
|
|
}
|
|
|
|
function startGame() {
|
|
document.getElementById('gameOver').style.display = 'none';
|
|
sendAction('start');
|
|
}
|
|
|
|
function render() {
|
|
if (!gameState) return;
|
|
|
|
// Clear main canvas
|
|
ctx.fillStyle = '#111';
|
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
|
|
// Draw board (only visible part: rows 0-19, but our array is 0-39)
|
|
for (let y = 0; y < 20; y++) {
|
|
for (let x = 0; x < 10; x++) {
|
|
const cellValue = gameState.board[y][x];
|
|
if (cellValue > 0) {
|
|
ctx.fillStyle = COLORS[cellValue];
|
|
ctx.fillRect(x * CELL_SIZE, (19 - y) * CELL_SIZE, CELL_SIZE, CELL_SIZE);
|
|
ctx.strokeStyle = '#333';
|
|
ctx.strokeRect(x * CELL_SIZE, (19 - y) * CELL_SIZE, CELL_SIZE, CELL_SIZE);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Draw current piece
|
|
if (gameState.current_piece && gameState.game_running) {
|
|
const piece = gameState.current_piece;
|
|
const shape = shapes[piece.type];
|
|
if (shape) {
|
|
nextCtx.fillStyle = COLORS[pieceType];
|
|
const offsetY = i * 50 + 5;
|
|
for (let py = 0; py < 4; py++) {
|
|
for (let px = 0; px < 4; px++) {
|
|
if (shape[py][px]) {
|
|
nextCtx.fillRect(px * 15 + 10, py * 15 + offsetY, 15, 15);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function renderHoldPiece() {
|
|
holdCtx.fillStyle = '#111';
|
|
holdCtx.fillRect(0, 0, holdCanvas.width, holdCanvas.height);
|
|
|
|
if (!gameState || !gameState.hold_piece) return;
|
|
|
|
const pieceType = gameState.hold_piece;
|
|
const shape = shapes[pieceType];
|
|
if (shape) {
|
|
holdCtx.fillStyle = COLORS[pieceType];
|
|
for (let py = 0; py < 4; py++) {
|
|
for (let px = 0; px < 4; px++) {
|
|
if (shape[py][px]) {
|
|
holdCtx.fillRect(px * 15 + 10, py * 15 + 10, 15, 15);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function updateUI() {
|
|
if (!gameState) return;
|
|
|
|
document.getElementById('score').textContent = gameState.score;
|
|
document.getElementById('level').textContent = gameState.level;
|
|
document.getElementById('lines').textContent = gameState.lines_cleared;
|
|
document.getElementById('goal').textContent = gameState.level * gameState.lines_for_level;
|
|
|
|
if (gameState.game_over) {
|
|
document.getElementById('finalScore').textContent = gameState.score;
|
|
document.getElementById('gameOver').style.display = 'block';
|
|
}
|
|
}
|
|
|
|
// Keyboard controls
|
|
document.addEventListener('keydown', function(event) {
|
|
if (!gameState || !gameState.game_running) return;
|
|
|
|
switch(event.code) {
|
|
case 'ArrowLeft':
|
|
event.preventDefault();
|
|
sendAction('move', 'left');
|
|
break;
|
|
case 'ArrowRight':
|
|
event.preventDefault();
|
|
sendAction('move', 'right');
|
|
break;
|
|
case 'ArrowDown':
|
|
event.preventDefault();
|
|
sendAction('move', 'down');
|
|
break;
|
|
case 'Space':
|
|
event.preventDefault();
|
|
sendAction('hard_drop');
|
|
break;
|
|
case 'KeyZ':
|
|
event.preventDefault();
|
|
sendAction('rotate', 'ccw');
|
|
break;
|
|
case 'KeyX':
|
|
case 'ArrowUp':
|
|
event.preventDefault();
|
|
sendAction('rotate', 'cw');
|
|
break;
|
|
case 'KeyC':
|
|
event.preventDefault();
|
|
sendAction('hold');
|
|
break;
|
|
}
|
|
});
|
|
|
|
// Initialize
|
|
connectEventSource();
|
|
</script>
|
|
</body>
|
|
</html>`
|
|
ctx.fillStyle = COLORS[piece.type];
|
|
for (let py = 0; py < 4; py++) {
|
|
for (let px = 0; px < 4; px++) {
|
|
if (shape[py][px]) {
|
|
const boardX = piece.x + px;
|
|
const boardY = piece.y + py;
|
|
if (boardY < 20) { // Only draw visible part
|
|
ctx.fillRect(boardX * CELL_SIZE, (19 - boardY) * CELL_SIZE, CELL_SIZE, CELL_SIZE);
|
|
ctx.strokeStyle = '#666';
|
|
ctx.strokeRect(boardX * CELL_SIZE, (19 - boardY) * CELL_SIZE, CELL_SIZE, CELL_SIZE);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Draw grid
|
|
ctx.strokeStyle = '#333';
|
|
for (let x = 0; x <= 10; x++) {
|
|
ctx.beginPath();
|
|
ctx.moveTo(x * CELL_SIZE, 0);
|
|
ctx.lineTo(x * CELL_SIZE, canvas.height);
|
|
ctx.stroke();
|
|
}
|
|
for (let y = 0; y <= 20; y++) {
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, y * CELL_SIZE);
|
|
ctx.lineTo(canvas.width, y * CELL_SIZE);
|
|
ctx.stroke();
|
|
}
|
|
|
|
renderNextPieces();
|
|
renderHoldPiece();
|
|
}
|
|
|
|
function renderNextPieces() {
|
|
nextCtx.fillStyle = '#111';
|
|
nextCtx.fillRect(0, 0, nextCanvas.width, nextCanvas.height);
|
|
|
|
if (!gameState || !gameState.next_pieces) return;
|
|
|
|
for (let i = 0; i < Math.min(6, gameState.next_pieces.length); i++) {
|
|
const pieceType = gameState.next_pieces[i];
|
|
const shape = shapes[pieceType];
|
|
if (shape) { |