package game import ( "fmt" "image/color" "math/rand" "os" "time" "tetris/internal/font" "tetris/internal/i18n" "tetris/internal/tetromino" "tetris/internal/types" "tetris/pkg/config" "github.com/hajimehoshi/ebiten/v2" "github.com/hajimehoshi/ebiten/v2/inpututil" "github.com/hajimehoshi/ebiten/v2/vector" ) // 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 lastMoveLeft time.Time lastMoveRight time.Time fontRenderer *font.FontRenderer hardDropping bool hardDropCounter int } // 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(), lastMoveLeft: time.Now(), lastMoveRight: time.Now(), fontRenderer: font.NewFontRenderer(), } 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.KeyR) { *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 vector.DrawFilledRect(screen, 0, 0, float32(config.BoardWidth*config.BlockSize), float32(config.BoardHeight*config.BlockSize), color.RGBA{40, 40, 40, 255}, false) // Draw preview area background vector.DrawFilledRect(screen, float32(config.PreviewX-config.BlockSize), 0, float32(6*config.BlockSize), float32(config.ScreenHeight), color.RGBA{30, 30, 30, 255}, false) // Draw grid lines gridColor := color.RGBA{60, 60, 60, 255} for x := 0; x <= config.BoardWidth; x++ { vector.StrokeLine(screen, float32(x*config.BlockSize), 0, float32(x*config.BlockSize), float32(config.BoardHeight*config.BlockSize), 1, gridColor, false) } for y := 0; y <= config.BoardHeight; y++ { vector.StrokeLine(screen, 0, float32(y*config.BlockSize), float32(config.BoardWidth*config.BlockSize), float32(y*config.BlockSize), 1, gridColor, false) } // 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} vector.DrawFilledRect(screen, float32(pos[0]*config.BlockSize), float32(pos[1]*config.BlockSize), float32(config.BlockSize-1), float32(config.BlockSize-1), ghostColor, false) } } // Draw actual piece with special effect during hard drop for _, pos := range g.currentPiece.GetAbsolutePositions() { if pos[1] >= 0 { if g.hardDropping { g.drawHardDropBlock(screen, pos[0], pos[1], g.currentPiece.BlockType) } else { g.drawBlock(screen, pos[0], pos[1], g.currentPiece.BlockType) } } } } // Draw next piece preview if g.nextPiece != nil { text := i18n.GetText() g.fontRenderer.DrawText(screen, text.Next, float64(config.PreviewX-config.BlockSize), float64(config.BlockSize), color.RGBA{255, 255, 255, 255}) for _, block := range g.nextPiece.Blocks { x := config.PreviewX + block.X*config.BlockSize y := config.PreviewY + block.Y*config.BlockSize vector.DrawFilledRect(screen, float32(x), float32(y), float32(config.BlockSize-1), float32(config.BlockSize-1), types.BlockColors[g.nextPiece.BlockType], false) } } // Draw score and level with larger font text := i18n.GetText() scoreStr := fmt.Sprintf(text.Score, g.score) levelStr := fmt.Sprintf(text.Level, g.level) g.fontRenderer.DrawText(screen, scoreStr, float64(config.PreviewX-config.BlockSize), float64(config.ScreenHeight-4*config.BlockSize), color.RGBA{255, 255, 255, 255}) g.fontRenderer.DrawText(screen, levelStr, float64(config.PreviewX-config.BlockSize), float64(config.ScreenHeight-3*config.BlockSize), color.RGBA{255, 255, 255, 255}) // Draw controls instructions g.drawControls(screen) if g.gameOver { text := i18n.GetText() g.fontRenderer.DrawText(screen, text.GameOver, float64(config.ScreenWidth/2-20), float64(config.ScreenHeight/2-100), color.RGBA{255, 255, 255, 255}) g.fontRenderer.DrawText(screen, text.Restart, float64(config.ScreenWidth/2-20), float64(config.ScreenHeight/2-80), color.RGBA{255, 0, 0, 255}) } } // 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() { // Language switch if inpututil.IsKeyJustPressed(ebiten.KeyL) { i18n.SwitchLanguage() } // Restart game anytime if inpututil.IsKeyJustPressed(ebiten.KeyR) { g.restart() return } // Skip other inputs when game over if g.gameOver { return } // Hard drop if inpututil.IsKeyJustPressed(ebiten.KeySpace) { g.hardDrop() } // Skip movement inputs during hard drop animation if g.hardDropping { return } // Move left/right - support continuous movement when holding keys if ebiten.IsKeyPressed(ebiten.KeyLeft) { // First press moves immediately, then add delay for continuous movement if inpututil.IsKeyJustPressed(ebiten.KeyLeft) { g.tryMove(-1, 0) g.lastMoveLeft = time.Now() } else if time.Since(g.lastMoveLeft) > time.Millisecond*120 { g.tryMove(-1, 0) g.lastMoveLeft = time.Now() } } if ebiten.IsKeyPressed(ebiten.KeyRight) { // First press moves immediately, then add delay for continuous movement if inpututil.IsKeyJustPressed(ebiten.KeyRight) { g.tryMove(1, 0) g.lastMoveRight = time.Now() } else if time.Since(g.lastMoveRight) > time.Millisecond*120 { g.tryMove(1, 0) g.lastMoveRight = time.Now() } } // 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() } } // Exit game if inpututil.IsKeyJustPressed(ebiten.KeyEscape) { os.Exit(0) } } // updateDropCounter handles automatic piece dropping and hard drop animation func (g *Game) updateDropCounter() { if g.hardDropping { // Hard drop animation - move faster g.hardDropCounter++ if g.hardDropCounter >= 2 { // Move every 2 frames for visible animation g.hardDropCounter = 0 if !g.tryMove(0, 1) { g.hardDropping = false g.lockPiece() } } } else { // Normal drop 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 starts the hard drop animation func (g *Game) hardDrop() { if g.currentPiece == nil || g.hardDropping { return } g.hardDropping = true g.hardDropCounter = 0 } // 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) { vector.DrawFilledRect(screen, float32(x*config.BlockSize), float32(y*config.BlockSize), float32(config.BlockSize-1), float32(config.BlockSize-1), types.BlockColors[blockType], false) // Add a subtle border for 3D effect borderColor := color.RGBA{255, 255, 255, 50} vector.DrawFilledRect(screen, float32(x*config.BlockSize), float32(y*config.BlockSize), float32(config.BlockSize-1), 2, borderColor, false) vector.DrawFilledRect(screen, float32(x*config.BlockSize), float32(y*config.BlockSize), 2, float32(config.BlockSize-1), borderColor, false) } // drawHardDropBlock draws a block with special hard drop effect func (g *Game) drawHardDropBlock(screen *ebiten.Image, x, y int, blockType types.BlockType) { // Get original color and make it brighter for hard drop effect originalColor := types.BlockColors[blockType].(color.RGBA) hardDropColor := color.RGBA{ min(255, originalColor.R+50), min(255, originalColor.G+50), min(255, originalColor.B+50), 255, } vector.DrawFilledRect(screen, float32(x*config.BlockSize), float32(y*config.BlockSize), float32(config.BlockSize-1), float32(config.BlockSize-1), hardDropColor, false) // Add bright white border for hard drop effect borderColor := color.RGBA{255, 255, 255, 200} vector.DrawFilledRect(screen, float32(x*config.BlockSize), float32(y*config.BlockSize), float32(config.BlockSize-1), 3, borderColor, false) vector.DrawFilledRect(screen, float32(x*config.BlockSize), float32(y*config.BlockSize), 3, float32(config.BlockSize-1), borderColor, false) } // max returns the maximum of two integers func max(a, b int) int { if a > b { return a } return b } // min returns the minimum of two uint8 values func min(a, b uint8) uint8 { if a < b { return a } return b } // drawControls renders the control instructions on the right side of the screen func (g *Game) drawControls(screen *ebiten.Image) { // Starting position for controls display controlsX := float64(config.PreviewX - config.BlockSize) controlsY := float64(config.BlockSize * 8) // Below the next piece preview text := i18n.GetText() // Controls title g.fontRenderer.DrawText(screen, text.Controls, controlsX, controlsY, color.RGBA{255, 255, 255, 255}) controlsY += 25 // Control instructions controls := []string{ text.Move, text.Rotate, text.SoftDrop, text.HardDrop, text.PressE, "", text.Language, text.PressL, } for _, control := range controls { g.fontRenderer.DrawText(screen, control, controlsX, controlsY, color.RGBA{255, 255, 255, 255}) controlsY += 18 } } // restart restarts the game func (g *Game) restart() { *g = *New() }