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.
441 lines
11 KiB
441 lines
11 KiB
1 week ago
|
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
|
||
|
}
|