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.
578 lines
14 KiB
578 lines
14 KiB
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()
|
|
}
|
|
|