commit 95d3f05079a08fe4525ea8eec13dc82b67a5743b Author: yinqiang Date: Sat Jun 7 17:16:40 2025 +0800 Initial commit: Tetris game implementation in Go diff --git a/README.md b/README.md new file mode 100644 index 0000000..14dc618 --- /dev/null +++ b/README.md @@ -0,0 +1,38 @@ +# Tetris Game in Go + +A simple Tetris game implementation using Go and the Ebiten game engine. + +## Requirements + +- Go 1.16 or later +- Ebiten v2 + +## Installation + +1. Clone the repository +2. Install dependencies: +```bash +go mod tidy +``` + +## Running the Game + +```bash +go run . +``` + +## Controls + +- Left Arrow: Move piece left +- Right Arrow: Move piece right +- Down Arrow: Move piece down faster +- Up Arrow: Rotate piece +- Space: Drop piece instantly +- ESC: Quit game + +## Features + +- Classic Tetris gameplay +- Score tracking +- Next piece preview +- Level system \ No newline at end of file diff --git a/block.go b/block.go new file mode 100644 index 0000000..ad276ea --- /dev/null +++ b/block.go @@ -0,0 +1,190 @@ +package main + +import ( + "image/color" +) + +// Block represents a single block in the game +type Block struct { + X, Y int + Type BlockType +} + +// BlockType represents different types of tetrominos +type BlockType int + +const ( + IBlock BlockType = iota + JBlock + LBlock + OBlock + SBlock + TBlock + ZBlock +) + +// Tetromino represents a complete tetris piece +type Tetromino struct { + Blocks []Block + BlockType BlockType + X, Y int +} + +// Colors for different block types +var BlockColors = map[BlockType]color.Color{ + IBlock: color.RGBA{0, 255, 255, 255}, // Cyan + JBlock: color.RGBA{0, 0, 255, 255}, // Blue + LBlock: color.RGBA{255, 165, 0, 255}, // Orange + OBlock: color.RGBA{255, 255, 0, 255}, // Yellow + SBlock: color.RGBA{0, 255, 0, 255}, // Green + TBlock: color.RGBA{128, 0, 128, 255}, // Purple + ZBlock: color.RGBA{255, 0, 0, 255}, // Red +} + +// TetrominoShapes defines the shape of each tetromino type +var TetrominoShapes = map[BlockType][][]bool{ + IBlock: { + {false, false, false, false}, + {false, false, false, false}, + {true, true, true, true}, + {false, false, false, false}, + }, + JBlock: { + {true, false, false}, + {true, true, true}, + {false, false, false}, + }, + LBlock: { + {false, false, true}, + {true, true, true}, + {false, false, false}, + }, + OBlock: { + {true, true}, + {true, true}, + }, + SBlock: { + {false, true, true}, + {true, true, false}, + {false, false, false}, + }, + TBlock: { + {false, true, false}, + {true, true, true}, + {false, false, false}, + }, + ZBlock: { + {true, true, false}, + {false, true, true}, + {false, false, false}, + }, +} + +// Rotation offsets for I piece wall kicks +var IKicks = [][2]int{ + {0, 0}, + {-2, 0}, + {1, 0}, + {-2, -1}, + {1, 2}, +} + +// Rotation offsets for other pieces wall kicks +var NormalKicks = [][2]int{ + {0, 0}, + {-1, 0}, + {1, 0}, + {0, -1}, +} + +// NewTetromino creates a new tetromino of the specified type +func NewTetromino(blockType BlockType, x, y int) *Tetromino { + shape := TetrominoShapes[blockType] + blocks := make([]Block, 0) + + for i := 0; i < len(shape); i++ { + for j := 0; j < len(shape[i]); j++ { + if shape[i][j] { + blocks = append(blocks, Block{ + X: j, + Y: i, + Type: blockType, + }) + } + } + } + + // Adjust initial position for I piece + if blockType == IBlock { + y-- // Start one row higher + } + + return &Tetromino{ + Blocks: blocks, + BlockType: blockType, + X: x, + Y: y, + } +} + +// Move moves the tetromino by the specified delta +func (t *Tetromino) Move(dx, dy int) { + t.X += dx + t.Y += dy +} + +// Rotate rotates the tetromino clockwise +func (t *Tetromino) Rotate() { + if t.BlockType == OBlock { + return // O block doesn't need rotation + } + + // Get the size of the shape matrix + size := 3 + if t.BlockType == IBlock { + size = 4 + } + + // Create a temporary matrix for rotation + matrix := make([][]bool, size) + for i := range matrix { + matrix[i] = make([]bool, size) + } + + // Fill the matrix with current block positions + for _, block := range t.Blocks { + if block.Y >= 0 && block.Y < size && block.X >= 0 && block.X < size { + matrix[block.Y][block.X] = true + } + } + + // Create a new rotated shape + newBlocks := make([]Block, 0) + + // Perform rotation + for y := 0; y < size; y++ { + for x := 0; x < size; x++ { + if matrix[y][x] { + // Rotate coordinates 90 degrees clockwise + newX := size - 1 - y + newY := x + newBlocks = append(newBlocks, Block{ + X: newX, + Y: newY, + Type: t.BlockType, + }) + } + } + } + + t.Blocks = newBlocks +} + +// GetAbsolutePositions returns the absolute positions of all blocks in the tetromino +func (t *Tetromino) GetAbsolutePositions() [][2]int { + positions := make([][2]int, len(t.Blocks)) + for i, block := range t.Blocks { + positions[i] = [2]int{t.X + block.X, t.Y + block.Y} + } + return positions +} diff --git a/game.go b/game.go new file mode 100644 index 0000000..aa9896f --- /dev/null +++ b/game.go @@ -0,0 +1,468 @@ +package main + +import ( + "fmt" + "image/color" + "math/rand" + "time" + + "github.com/hajimehoshi/ebiten/v2" + "github.com/hajimehoshi/ebiten/v2/ebitenutil" + "github.com/hajimehoshi/ebiten/v2/inpututil" +) + +const ( + // Size constants + BlockSize = 30 + BoardWidth = 10 + BoardHeight = 20 + ScreenWidth = BlockSize * (BoardWidth + 6) // Extra space for next piece preview + ScreenHeight = BlockSize * BoardHeight + PreviewX = BlockSize * (BoardWidth + 1) + PreviewY = BlockSize * 2 + + // Game constants + InitialDropInterval = 60 + MinDropInterval = 5 +) + +// Game represents the main game state +type Game struct { + board [][]BlockType + currentPiece *Tetromino + nextPiece *Tetromino + dropCounter int + dropInterval int + score int + level int + gameOver bool + lastMoveDown time.Time + lastRotate time.Time +} + +// NewGame creates a new game instance +func NewGame() *Game { + g := &Game{ + board: make([][]BlockType, BoardHeight), + dropInterval: InitialDropInterval, + lastMoveDown: time.Now(), + lastRotate: time.Now(), + } + + for i := range g.board { + g.board[i] = make([]BlockType, BoardWidth) + } + + g.spawnNewPiece() + return g +} + +// Update handles game logic updates +func (g *Game) Update() error { + if g.gameOver { + if inpututil.IsKeyJustPressed(ebiten.KeySpace) { + *g = *NewGame() + return nil + } + return nil + } + + g.handleInput() + g.updateDropCounter() + return nil +} + +// Draw renders the game state +func (g *Game) Draw(screen *ebiten.Image) { + // Draw board background + ebitenutil.DrawRect(screen, 0, 0, float64(BoardWidth*BlockSize), float64(BoardHeight*BlockSize), color.RGBA{40, 40, 40, 255}) + + // Draw preview area background + ebitenutil.DrawRect(screen, float64(PreviewX-BlockSize), 0, float64(6*BlockSize), float64(ScreenHeight), color.RGBA{30, 30, 30, 255}) + + // Draw grid lines + gridColor := color.RGBA{60, 60, 60, 255} + for x := 0; x <= BoardWidth; x++ { + ebitenutil.DrawLine(screen, float64(x*BlockSize), 0, float64(x*BlockSize), float64(BoardHeight*BlockSize), gridColor) + } + for y := 0; y <= BoardHeight; y++ { + ebitenutil.DrawLine(screen, 0, float64(y*BlockSize), float64(BoardWidth*BlockSize), float64(y*BlockSize), gridColor) + } + + // Draw placed blocks + for y := 0; y < BoardHeight; y++ { + for x := 0; x < BoardWidth; x++ { + if g.board[y][x] != 0 { + g.drawBlock(screen, x, y, g.board[y][x]) + } + } + } + + // Draw current piece and its ghost + if g.currentPiece != nil { + // Draw ghost piece + ghostPiece := *g.currentPiece + for { + ghostPiece.Move(0, 1) + if g.wouldCollide(&ghostPiece) { + ghostPiece.Move(0, -1) + break + } + } + for _, pos := range ghostPiece.GetAbsolutePositions() { + if pos[1] >= 0 { + ghostColor := color.RGBA{128, 128, 128, 128} + ebitenutil.DrawRect(screen, + float64(pos[0]*BlockSize), + float64(pos[1]*BlockSize), + float64(BlockSize-1), + float64(BlockSize-1), + ghostColor) + } + } + + // Draw actual piece + for _, pos := range g.currentPiece.GetAbsolutePositions() { + if pos[1] >= 0 { + g.drawBlock(screen, pos[0], pos[1], g.currentPiece.BlockType) + } + } + } + + // Draw next piece preview + if g.nextPiece != nil { + previewLabel := "NEXT:" + ebitenutil.DebugPrintAt(screen, previewLabel, PreviewX-BlockSize, BlockSize) + + for _, block := range g.nextPiece.Blocks { + x := PreviewX + block.X*BlockSize + y := PreviewY + block.Y*BlockSize + ebitenutil.DrawRect(screen, + float64(x), + float64(y), + float64(BlockSize-1), + float64(BlockSize-1), + BlockColors[g.nextPiece.BlockType]) + } + } + + // Draw score and level with larger font + scoreStr := fmt.Sprintf("Score: %d", g.score) + levelStr := fmt.Sprintf("Level: %d", g.level) + ebitenutil.DebugPrintAt(screen, scoreStr, PreviewX-BlockSize, ScreenHeight-4*BlockSize) + ebitenutil.DebugPrintAt(screen, levelStr, PreviewX-BlockSize, ScreenHeight-3*BlockSize) + + if g.gameOver { + gameOverStr := "Game Over!" + restartStr := "Press SPACE to restart" + ebitenutil.DebugPrintAt(screen, gameOverStr, PreviewX-BlockSize, ScreenHeight/2) + ebitenutil.DebugPrintAt(screen, restartStr, PreviewX-BlockSize, ScreenHeight/2+20) + } +} + +// Layout implements ebiten.Game interface +func (g *Game) Layout(outsideWidth, outsideHeight int) (screenWidth, screenHeight int) { + return ScreenWidth, ScreenHeight +} + +// handleInput processes player input +func (g *Game) handleInput() { + // Move left/right + if inpututil.IsKeyJustPressed(ebiten.KeyLeft) { + g.tryMove(-1, 0) + } + if inpututil.IsKeyJustPressed(ebiten.KeyRight) { + g.tryMove(1, 0) + } + + // Rotate + if inpututil.IsKeyJustPressed(ebiten.KeyUp) { + if time.Since(g.lastRotate) > time.Millisecond*100 { + g.tryRotate() + g.lastRotate = time.Now() + } + } + + // Soft drop + if ebiten.IsKeyPressed(ebiten.KeyDown) { + if time.Since(g.lastMoveDown) > time.Millisecond*50 { + g.tryMove(0, 1) + g.lastMoveDown = time.Now() + } + } + + // Hard drop + if inpututil.IsKeyJustPressed(ebiten.KeySpace) { + g.hardDrop() + } +} + +// updateDropCounter handles automatic piece dropping +func (g *Game) updateDropCounter() { + if g.currentPiece == nil { + return + } + + g.dropCounter++ + if g.dropCounter >= g.dropInterval { + g.dropCounter = 0 + + // Try to move down + g.currentPiece.Move(0, 1) + if g.isColliding() { + // Move back up and lock the piece + g.currentPiece.Move(0, -1) + g.lockPiece() + if !g.gameOver { + g.clearLines() + g.spawnNewPiece() + } + } + } +} + +// tryMove attempts to move the current piece +func (g *Game) tryMove(dx, dy int) bool { + if g.currentPiece == nil { + return false + } + + g.currentPiece.Move(dx, dy) + if g.isColliding() { + g.currentPiece.Move(-dx, -dy) + return false + } + return true +} + +// tryRotate attempts to rotate the current piece +func (g *Game) tryRotate() bool { + if g.currentPiece == nil { + return false + } + + g.currentPiece.Rotate() + if g.isColliding() { + // Try wall kicks + kicks := NormalKicks + if g.currentPiece.BlockType == IBlock { + kicks = IKicks + } + + success := false + for _, kick := range kicks { + g.currentPiece.Move(kick[0], kick[1]) + if !g.isColliding() { + success = true + break + } + g.currentPiece.Move(-kick[0], -kick[1]) + } + + if !success { + // Rotate back to original position + g.currentPiece.Rotate() + g.currentPiece.Rotate() + g.currentPiece.Rotate() + return false + } + } + return true +} + +// hardDrop drops the piece to the bottom instantly +func (g *Game) hardDrop() { + if g.currentPiece == nil { + return + } + + // Move down until collision + dropDistance := 0 + for { + g.currentPiece.Move(0, 1) + if g.isColliding() { + g.currentPiece.Move(0, -1) + break + } + dropDistance++ + } + + // Add bonus points for hard drop + g.score += dropDistance * 2 + + g.lockPiece() + if !g.gameOver { + g.clearLines() + g.spawnNewPiece() + } +} + +// isColliding checks if the current piece collides with the board or boundaries +func (g *Game) isColliding() bool { + if g.currentPiece == nil { + return false + } + + for _, pos := range g.currentPiece.GetAbsolutePositions() { + x, y := pos[0], pos[1] + // Check boundaries + if x < 0 || x >= BoardWidth || y >= BoardHeight { + return true + } + // Check collision with other pieces + if y >= 0 && x >= 0 && x < BoardWidth && y < BoardHeight { + if g.board[y][x] != 0 { + return true + } + } + } + return false +} + +// lockPiece fixes the current piece to the board +func (g *Game) lockPiece() { + if g.currentPiece == nil { + return + } + + positions := g.currentPiece.GetAbsolutePositions() + for _, pos := range positions { + x, y := pos[0], pos[1] + if y < 0 { + g.gameOver = true + return + } + if x >= 0 && x < BoardWidth && y >= 0 && y < BoardHeight { + g.board[y][x] = g.currentPiece.BlockType + } + } +} + +// clearLines removes completed lines and updates the score +func (g *Game) clearLines() { + linesCleared := 0 + for y := BoardHeight - 1; y >= 0; y-- { + if g.isLineFull(y) { + g.removeLine(y) + linesCleared++ + y++ // Check the same line again after shifting + } + } + + // Update score and level + if linesCleared > 0 { + // 计分规则:一次消除的行数越多,得分越高 + baseScore := 0 + switch linesCleared { + case 1: + baseScore = 100 + case 2: + baseScore = 300 + case 3: + baseScore = 500 + case 4: + baseScore = 800 + } + + // 根据当前等级增加得分 + levelMultiplier := g.level + 1 + g.score += baseScore * levelMultiplier + + // 更新等级 + g.level = g.score / 1000 + + // 随等级提高,方块下落速度加快 + g.dropInterval = max(MinDropInterval, InitialDropInterval-g.level*5) + } +} + +// isLineFull checks if a line is completely filled +func (g *Game) isLineFull(y int) bool { + for x := 0; x < BoardWidth; x++ { + if g.board[y][x] == 0 { + return false + } + } + return true +} + +// removeLine removes a line and shifts everything above down +func (g *Game) removeLine(y int) { + for i := y; i > 0; i-- { + copy(g.board[i], g.board[i-1]) + } + for x := 0; x < BoardWidth; x++ { + g.board[0][x] = 0 + } +} + +// spawnNewPiece creates a new piece at the top of the board +func (g *Game) spawnNewPiece() { + if g.nextPiece == nil { + g.nextPiece = NewTetromino(BlockType(rand.Intn(7)), 0, 0) + } + + g.currentPiece = g.nextPiece + g.currentPiece.X = BoardWidth/2 - 2 + if g.currentPiece.BlockType != IBlock { + g.currentPiece.X++ // Adjust for non-I pieces + } + g.currentPiece.Y = 0 + + g.nextPiece = NewTetromino(BlockType(rand.Intn(7)), 0, 0) + + if g.isColliding() { + g.gameOver = true + } +} + +// wouldCollide checks if a piece would collide in its current position +func (g *Game) wouldCollide(piece *Tetromino) bool { + for _, pos := range piece.GetAbsolutePositions() { + x, y := pos[0], pos[1] + if x < 0 || x >= BoardWidth || y >= BoardHeight { + return true + } + if y >= 0 && y < BoardHeight && x >= 0 && x < BoardWidth && g.board[y][x] != 0 { + return true + } + } + return false +} + +// drawBlock draws a single block at the specified position +func (g *Game) drawBlock(screen *ebiten.Image, x, y int, blockType BlockType) { + if y < 0 { + return + } + + // Draw block background + ebitenutil.DrawRect(screen, + float64(x*BlockSize), + float64(y*BlockSize), + float64(BlockSize-1), + float64(BlockSize-1), + BlockColors[blockType]) + + // Draw highlight (3D effect) + highlightColor := color.RGBA{255, 255, 255, 64} + ebitenutil.DrawLine(screen, + float64(x*BlockSize), + float64(y*BlockSize), + float64(x*BlockSize+BlockSize-1), + float64(y*BlockSize), + highlightColor) + ebitenutil.DrawLine(screen, + float64(x*BlockSize), + float64(y*BlockSize), + float64(x*BlockSize), + float64(y*BlockSize+BlockSize-1), + highlightColor) +} + +func max(a, b int) int { + if a > b { + return a + } + return b +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..c2ead58 --- /dev/null +++ b/go.mod @@ -0,0 +1,14 @@ +module tetris + +go 1.24.2 + +require github.com/hajimehoshi/ebiten/v2 v2.8.8 + +require ( + github.com/ebitengine/gomobile v0.0.0-20240911145611-4856209ac325 // indirect + github.com/ebitengine/hideconsole v1.0.0 // indirect + github.com/ebitengine/purego v0.8.0 // indirect + github.com/jezek/xgb v1.1.1 // indirect + golang.org/x/sync v0.8.0 // indirect + golang.org/x/sys v0.25.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..df10e34 --- /dev/null +++ b/go.sum @@ -0,0 +1,16 @@ +github.com/ebitengine/gomobile v0.0.0-20240911145611-4856209ac325 h1:Gk1XUEttOk0/hb6Tq3WkmutWa0ZLhNn/6fc6XZpM7tM= +github.com/ebitengine/gomobile v0.0.0-20240911145611-4856209ac325/go.mod h1:ulhSQcbPioQrallSuIzF8l1NKQoD7xmMZc5NxzibUMY= +github.com/ebitengine/hideconsole v1.0.0 h1:5J4U0kXF+pv/DhiXt5/lTz0eO5ogJ1iXb8Yj1yReDqE= +github.com/ebitengine/hideconsole v1.0.0/go.mod h1:hTTBTvVYWKBuxPr7peweneWdkUwEuHuB3C1R/ielR1A= +github.com/ebitengine/purego v0.8.0 h1:JbqvnEzRvPpxhCJzJJ2y0RbiZ8nyjccVUrSM3q+GvvE= +github.com/ebitengine/purego v0.8.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/hajimehoshi/ebiten/v2 v2.8.8 h1:xyMxOAn52T1tQ+j3vdieZ7auDBOXmvjUprSrxaIbsi8= +github.com/hajimehoshi/ebiten/v2 v2.8.8/go.mod h1:durJ05+OYnio9b8q0sEtOgaNeBEQG7Yr7lRviAciYbs= +github.com/jezek/xgb v1.1.1 h1:bE/r8ZZtSv7l9gk6nU0mYx51aXrvnyb44892TwSaqS4= +github.com/jezek/xgb v1.1.1/go.mod h1:nrhwO0FX/enq75I7Y7G8iN1ubpSGZEiA3v9e9GyRFlk= +golang.org/x/image v0.20.0 h1:7cVCUjQwfL18gyBJOmYvptfSHS8Fb3YUDtfLIZ7Nbpw= +golang.org/x/image v0.20.0/go.mod h1:0a88To4CYVBAHp5FXJm8o7QbUl37Vd85ply1vyD8auM= +golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= +golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= +golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= diff --git a/main.go b/main.go new file mode 100644 index 0000000..b2f5fa8 --- /dev/null +++ b/main.go @@ -0,0 +1,21 @@ +package main + +import ( + "log" + "math/rand" + "time" + + "github.com/hajimehoshi/ebiten/v2" +) + +func main() { + rand.Seed(time.Now().UnixNano()) + + ebiten.SetWindowSize(ScreenWidth, ScreenHeight) + ebiten.SetWindowTitle("Tetris") + + game := NewGame() + if err := ebiten.RunGame(game); err != nil { + log.Fatal(err) + } +}