package game import ( "fmt" "image/color" "math/rand" "time" "tetris/internal/tetromino" "tetris/internal/types" "tetris/pkg/config" "github.com/hajimehoshi/ebiten/v2" "github.com/hajimehoshi/ebiten/v2/ebitenutil" "github.com/hajimehoshi/ebiten/v2/inpututil" ) // Game represents the main game state type Game struct { board [][]types.BlockType currentPiece *tetromino.Tetromino nextPiece *tetromino.Tetromino dropCounter int dropInterval int score int level int gameOver bool lastMoveDown time.Time lastRotate time.Time } // New creates a new game instance func New() *Game { g := &Game{ board: make([][]types.BlockType, config.BoardHeight), dropInterval: config.InitialDropInterval, lastMoveDown: time.Now(), lastRotate: time.Now(), } for i := range g.board { g.board[i] = make([]types.BlockType, config.BoardWidth) } g.spawnNewPiece() return g } // Update handles game logic updates func (g *Game) Update() error { if g.gameOver { if inpututil.IsKeyJustPressed(ebiten.KeySpace) { *g = *New() 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(config.BoardWidth*config.BlockSize), float64(config.BoardHeight*config.BlockSize), color.RGBA{40, 40, 40, 255}) // Draw preview area background ebitenutil.DrawRect(screen, float64(config.PreviewX-config.BlockSize), 0, float64(6*config.BlockSize), float64(config.ScreenHeight), color.RGBA{30, 30, 30, 255}) // Draw grid lines gridColor := color.RGBA{60, 60, 60, 255} for x := 0; x <= config.BoardWidth; x++ { ebitenutil.DrawLine(screen, float64(x*config.BlockSize), 0, float64(x*config.BlockSize), float64(config.BoardHeight*config.BlockSize), gridColor) } for y := 0; y <= config.BoardHeight; y++ { ebitenutil.DrawLine(screen, 0, float64(y*config.BlockSize), float64(config.BoardWidth*config.BlockSize), float64(y*config.BlockSize), gridColor) } // Draw placed blocks for y := range config.BoardHeight { for x := range config.BoardWidth { if g.board[y][x] != config.EmptyBlock { 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]*config.BlockSize), float64(pos[1]*config.BlockSize), float64(config.BlockSize-1), float64(config.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, config.PreviewX-config.BlockSize, config.BlockSize) for _, block := range g.nextPiece.Blocks { x := config.PreviewX + block.X*config.BlockSize y := config.PreviewY + block.Y*config.BlockSize ebitenutil.DrawRect(screen, float64(x), float64(y), float64(config.BlockSize-1), float64(config.BlockSize-1), types.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, config.PreviewX-config.BlockSize, config.ScreenHeight-4*config.BlockSize) ebitenutil.DebugPrintAt(screen, levelStr, config.PreviewX-config.BlockSize, config.ScreenHeight-3*config.BlockSize) if g.gameOver { gameOverStr := "Game Over!" restartStr := "Press SPACE to restart" ebitenutil.DebugPrintAt(screen, gameOverStr, config.PreviewX-config.BlockSize, config.ScreenHeight/2) ebitenutil.DebugPrintAt(screen, restartStr, config.PreviewX-config.BlockSize, config.ScreenHeight/2+20) } } // Layout implements ebiten.Game interface func (g *Game) Layout(outsideWidth, outsideHeight int) (screenWidth, screenHeight int) { return config.ScreenWidth, config.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() { g.dropCounter++ if g.dropCounter >= g.dropInterval { g.dropCounter = 0 if !g.tryMove(0, 1) { g.lockPiece() } } } // tryMove attempts to move the current piece and returns success func (g *Game) tryMove(dx, dy int) bool { if g.currentPiece == nil { return false } originalX, originalY := g.currentPiece.X, g.currentPiece.Y g.currentPiece.Move(dx, dy) if g.isColliding() { g.currentPiece.X, g.currentPiece.Y = originalX, originalY return false } return true } // tryRotate attempts to rotate the current piece with wall kicks func (g *Game) tryRotate() bool { if g.currentPiece == nil { return false } // Store original state originalBlocks := make([]types.Block, len(g.currentPiece.Blocks)) copy(originalBlocks, g.currentPiece.Blocks) // Try rotation g.currentPiece.Rotate() // Try wall kicks var kicks [][2]int if g.currentPiece.BlockType == types.IBlock { kicks = tetromino.IKicks } else { kicks = tetromino.NormalKicks } for _, kick := range kicks { originalX, originalY := g.currentPiece.X, g.currentPiece.Y g.currentPiece.Move(kick[0], kick[1]) if !g.isColliding() { return true } // Reset position for next kick attempt g.currentPiece.X, g.currentPiece.Y = originalX, originalY } // All kicks failed, revert rotation g.currentPiece.Blocks = originalBlocks return false } // hardDrop drops the piece instantly to the bottom func (g *Game) hardDrop() { if g.currentPiece == nil { return } dropDistance := 0 for { if !g.tryMove(0, 1) { break } dropDistance++ } // Award extra points for hard drop g.score += dropDistance * 2 g.lockPiece() } // isColliding checks if the current piece is colliding 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 >= config.BoardWidth || y >= config.BoardHeight { return true } // Check collision with placed blocks (but allow pieces above the visible board) if y >= 0 && g.board[y][x] != config.EmptyBlock { return true } } return false } // lockPiece places the current piece on the board and spawns a new one func (g *Game) lockPiece() { if g.currentPiece == nil { return } // Place the piece on the board for _, pos := range g.currentPiece.GetAbsolutePositions() { x, y := pos[0], pos[1] if y >= 0 && y < config.BoardHeight && x >= 0 && x < config.BoardWidth { g.board[y][x] = g.currentPiece.BlockType } } // Clear completed lines linesCleared := g.clearLines() // Update score and level g.updateScore(linesCleared) // Spawn new piece g.spawnNewPiece() // Check for game over if g.isColliding() { g.gameOver = true } } // clearLines removes completed lines and returns the number cleared func (g *Game) clearLines() int { linesCleared := 0 for y := config.BoardHeight - 1; y >= 0; y-- { if g.isLineFull(y) { g.removeLine(y) linesCleared++ y++ // Check the same line again since everything moved down } } return linesCleared } // updateScore updates the score and level based on lines cleared func (g *Game) updateScore(linesCleared int) { points := []int{0, 40, 100, 300, 1200} // Points for 0, 1, 2, 3, 4 lines if linesCleared <= 4 { g.score += points[linesCleared] * (g.level + 1) } // Increase level every 10 lines newLevel := g.score / 1000 if newLevel > g.level { g.level = newLevel g.dropInterval = max(config.MinDropInterval, config.InitialDropInterval-g.level*3) } } // isLineFull checks if a line is completely filled func (g *Game) isLineFull(y int) bool { for x := 0; x < config.BoardWidth; x++ { if g.board[y][x] == config.EmptyBlock { return false } } return true } // removeLine removes a line and shifts everything above down func (g *Game) removeLine(lineY int) { for y := lineY; y > 0; y-- { copy(g.board[y], g.board[y-1]) } // Clear the top line for x := 0; x < config.BoardWidth; x++ { g.board[0][x] = config.EmptyBlock } } // spawnNewPiece creates a new falling piece func (g *Game) spawnNewPiece() { if g.nextPiece == nil { g.nextPiece = tetromino.New(types.BlockType(rand.Intn(7)+1), config.BoardWidth/2-1, 0) } g.currentPiece = g.nextPiece g.nextPiece = tetromino.New(types.BlockType(rand.Intn(7)+1), config.BoardWidth/2-1, 0) g.dropCounter = 0 } // wouldCollide checks if a piece would collide at its current position func (g *Game) wouldCollide(piece *tetromino.Tetromino) bool { for _, pos := range piece.GetAbsolutePositions() { x, y := pos[0], pos[1] // Check boundaries if x < 0 || x >= config.BoardWidth || y >= config.BoardHeight { return true } // Check collision with placed blocks if y >= 0 && g.board[y][x] != config.EmptyBlock { return true } } return false } // drawBlock draws a single block at the specified position func (g *Game) drawBlock(screen *ebiten.Image, x, y int, blockType types.BlockType) { ebitenutil.DrawRect(screen, float64(x*config.BlockSize), float64(y*config.BlockSize), float64(config.BlockSize-1), float64(config.BlockSize-1), types.BlockColors[blockType]) // Add a subtle border for 3D effect borderColor := color.RGBA{255, 255, 255, 50} ebitenutil.DrawRect(screen, float64(x*config.BlockSize), float64(y*config.BlockSize), float64(config.BlockSize-1), 2, borderColor) ebitenutil.DrawRect(screen, float64(x*config.BlockSize), float64(y*config.BlockSize), 2, float64(config.BlockSize-1), borderColor) } // max returns the maximum of two integers func max(a, b int) int { if a > b { return a } return b }