You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
tetris/internal/game/game.go

497 lines
12 KiB

package game
import (
"fmt"
"image/color"
"math/rand"
"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
fontRenderer *font.FontRenderer
}
// 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(),
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.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
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
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 {
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.PreviewX-config.BlockSize), float64(config.ScreenHeight/2), color.RGBA{255, 255, 255, 255})
g.fontRenderer.DrawText(screen, text.Restart, float64(config.PreviewX-config.BlockSize), float64(config.ScreenHeight/2+20), color.RGBA{255, 255, 255, 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 when game over
if g.gameOver && inpututil.IsKeyJustPressed(ebiten.KeyR) {
g.restart()
return
}
// Skip other inputs when game over
if g.gameOver {
return
}
// 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) {
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)
}
// max returns the maximum of two integers
func max(a, b int) int {
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.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()
}