Initial commit: Tetris game implementation in Go

main
yinqiang 1 week ago
commit 95d3f05079
  1. 38
      README.md
  2. 190
      block.go
  3. 468
      game.go
  4. 14
      go.mod
  5. 16
      go.sum
  6. 21
      main.go

@ -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

@ -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
}

@ -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
}

@ -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
)

@ -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=

@ -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)
}
}
Loading…
Cancel
Save