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.
468 lines
10 KiB
468 lines
10 KiB
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"image/color"
|
|
"math/rand"
|
|
"time"
|
|
|
|
"github.com/hajimehoshi/ebiten/v2"
|
|
"github.com/hajimehoshi/ebiten/v2/ebitenutil"
|
|
"github.com/hajimehoshi/ebiten/v2/inpututil"
|
|
)
|
|
|
|
const (
|
|
// Size constants
|
|
BlockSize = 30
|
|
BoardWidth = 10
|
|
BoardHeight = 20
|
|
ScreenWidth = BlockSize * (BoardWidth + 6) // Extra space for next piece preview
|
|
ScreenHeight = BlockSize * BoardHeight
|
|
PreviewX = BlockSize * (BoardWidth + 1)
|
|
PreviewY = BlockSize * 2
|
|
|
|
// Game constants
|
|
InitialDropInterval = 60
|
|
MinDropInterval = 5
|
|
)
|
|
|
|
// Game represents the main game state
|
|
type Game struct {
|
|
board [][]BlockType
|
|
currentPiece *Tetromino
|
|
nextPiece *Tetromino
|
|
dropCounter int
|
|
dropInterval int
|
|
score int
|
|
level int
|
|
gameOver bool
|
|
lastMoveDown time.Time
|
|
lastRotate time.Time
|
|
}
|
|
|
|
// NewGame creates a new game instance
|
|
func NewGame() *Game {
|
|
g := &Game{
|
|
board: make([][]BlockType, BoardHeight),
|
|
dropInterval: InitialDropInterval,
|
|
lastMoveDown: time.Now(),
|
|
lastRotate: time.Now(),
|
|
}
|
|
|
|
for i := range g.board {
|
|
g.board[i] = make([]BlockType, BoardWidth)
|
|
}
|
|
|
|
g.spawnNewPiece()
|
|
return g
|
|
}
|
|
|
|
// Update handles game logic updates
|
|
func (g *Game) Update() error {
|
|
if g.gameOver {
|
|
if inpututil.IsKeyJustPressed(ebiten.KeySpace) {
|
|
*g = *NewGame()
|
|
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(BoardWidth*BlockSize), float64(BoardHeight*BlockSize), color.RGBA{40, 40, 40, 255})
|
|
|
|
// Draw preview area background
|
|
ebitenutil.DrawRect(screen, float64(PreviewX-BlockSize), 0, float64(6*BlockSize), float64(ScreenHeight), color.RGBA{30, 30, 30, 255})
|
|
|
|
// Draw grid lines
|
|
gridColor := color.RGBA{60, 60, 60, 255}
|
|
for x := 0; x <= BoardWidth; x++ {
|
|
ebitenutil.DrawLine(screen, float64(x*BlockSize), 0, float64(x*BlockSize), float64(BoardHeight*BlockSize), gridColor)
|
|
}
|
|
for y := 0; y <= BoardHeight; y++ {
|
|
ebitenutil.DrawLine(screen, 0, float64(y*BlockSize), float64(BoardWidth*BlockSize), float64(y*BlockSize), gridColor)
|
|
}
|
|
|
|
// Draw placed blocks
|
|
for y := 0; y < BoardHeight; y++ {
|
|
for x := 0; x < BoardWidth; x++ {
|
|
if g.board[y][x] != 0 {
|
|
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]*BlockSize),
|
|
float64(pos[1]*BlockSize),
|
|
float64(BlockSize-1),
|
|
float64(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, PreviewX-BlockSize, BlockSize)
|
|
|
|
for _, block := range g.nextPiece.Blocks {
|
|
x := PreviewX + block.X*BlockSize
|
|
y := PreviewY + block.Y*BlockSize
|
|
ebitenutil.DrawRect(screen,
|
|
float64(x),
|
|
float64(y),
|
|
float64(BlockSize-1),
|
|
float64(BlockSize-1),
|
|
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, PreviewX-BlockSize, ScreenHeight-4*BlockSize)
|
|
ebitenutil.DebugPrintAt(screen, levelStr, PreviewX-BlockSize, ScreenHeight-3*BlockSize)
|
|
|
|
if g.gameOver {
|
|
gameOverStr := "Game Over!"
|
|
restartStr := "Press SPACE to restart"
|
|
ebitenutil.DebugPrintAt(screen, gameOverStr, PreviewX-BlockSize, ScreenHeight/2)
|
|
ebitenutil.DebugPrintAt(screen, restartStr, PreviewX-BlockSize, ScreenHeight/2+20)
|
|
}
|
|
}
|
|
|
|
// Layout implements ebiten.Game interface
|
|
func (g *Game) Layout(outsideWidth, outsideHeight int) (screenWidth, screenHeight int) {
|
|
return ScreenWidth, 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() {
|
|
if g.currentPiece == nil {
|
|
return
|
|
}
|
|
|
|
g.dropCounter++
|
|
if g.dropCounter >= g.dropInterval {
|
|
g.dropCounter = 0
|
|
|
|
// Try to move down
|
|
g.currentPiece.Move(0, 1)
|
|
if g.isColliding() {
|
|
// Move back up and lock the piece
|
|
g.currentPiece.Move(0, -1)
|
|
g.lockPiece()
|
|
if !g.gameOver {
|
|
g.clearLines()
|
|
g.spawnNewPiece()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// tryMove attempts to move the current piece
|
|
func (g *Game) tryMove(dx, dy int) bool {
|
|
if g.currentPiece == nil {
|
|
return false
|
|
}
|
|
|
|
g.currentPiece.Move(dx, dy)
|
|
if g.isColliding() {
|
|
g.currentPiece.Move(-dx, -dy)
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// tryRotate attempts to rotate the current piece
|
|
func (g *Game) tryRotate() bool {
|
|
if g.currentPiece == nil {
|
|
return false
|
|
}
|
|
|
|
g.currentPiece.Rotate()
|
|
if g.isColliding() {
|
|
// Try wall kicks
|
|
kicks := NormalKicks
|
|
if g.currentPiece.BlockType == IBlock {
|
|
kicks = IKicks
|
|
}
|
|
|
|
success := false
|
|
for _, kick := range kicks {
|
|
g.currentPiece.Move(kick[0], kick[1])
|
|
if !g.isColliding() {
|
|
success = true
|
|
break
|
|
}
|
|
g.currentPiece.Move(-kick[0], -kick[1])
|
|
}
|
|
|
|
if !success {
|
|
// Rotate back to original position
|
|
g.currentPiece.Rotate()
|
|
g.currentPiece.Rotate()
|
|
g.currentPiece.Rotate()
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// hardDrop drops the piece to the bottom instantly
|
|
func (g *Game) hardDrop() {
|
|
if g.currentPiece == nil {
|
|
return
|
|
}
|
|
|
|
// Move down until collision
|
|
dropDistance := 0
|
|
for {
|
|
g.currentPiece.Move(0, 1)
|
|
if g.isColliding() {
|
|
g.currentPiece.Move(0, -1)
|
|
break
|
|
}
|
|
dropDistance++
|
|
}
|
|
|
|
// Add bonus points for hard drop
|
|
g.score += dropDistance * 2
|
|
|
|
g.lockPiece()
|
|
if !g.gameOver {
|
|
g.clearLines()
|
|
g.spawnNewPiece()
|
|
}
|
|
}
|
|
|
|
// isColliding checks if the current piece collides 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 >= BoardWidth || y >= BoardHeight {
|
|
return true
|
|
}
|
|
// Check collision with other pieces
|
|
if y >= 0 && x >= 0 && x < BoardWidth && y < BoardHeight {
|
|
if g.board[y][x] != 0 {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// lockPiece fixes the current piece to the board
|
|
func (g *Game) lockPiece() {
|
|
if g.currentPiece == nil {
|
|
return
|
|
}
|
|
|
|
positions := g.currentPiece.GetAbsolutePositions()
|
|
for _, pos := range positions {
|
|
x, y := pos[0], pos[1]
|
|
if y < 0 {
|
|
g.gameOver = true
|
|
return
|
|
}
|
|
if x >= 0 && x < BoardWidth && y >= 0 && y < BoardHeight {
|
|
g.board[y][x] = g.currentPiece.BlockType
|
|
}
|
|
}
|
|
}
|
|
|
|
// clearLines removes completed lines and updates the score
|
|
func (g *Game) clearLines() {
|
|
linesCleared := 0
|
|
for y := BoardHeight - 1; y >= 0; y-- {
|
|
if g.isLineFull(y) {
|
|
g.removeLine(y)
|
|
linesCleared++
|
|
y++ // Check the same line again after shifting
|
|
}
|
|
}
|
|
|
|
// Update score and level
|
|
if linesCleared > 0 {
|
|
// 计分规则:一次消除的行数越多,得分越高
|
|
baseScore := 0
|
|
switch linesCleared {
|
|
case 1:
|
|
baseScore = 100
|
|
case 2:
|
|
baseScore = 300
|
|
case 3:
|
|
baseScore = 500
|
|
case 4:
|
|
baseScore = 800
|
|
}
|
|
|
|
// 根据当前等级增加得分
|
|
levelMultiplier := g.level + 1
|
|
g.score += baseScore * levelMultiplier
|
|
|
|
// 更新等级
|
|
g.level = g.score / 1000
|
|
|
|
// 随等级提高,方块下落速度加快
|
|
g.dropInterval = max(MinDropInterval, InitialDropInterval-g.level*5)
|
|
}
|
|
}
|
|
|
|
// isLineFull checks if a line is completely filled
|
|
func (g *Game) isLineFull(y int) bool {
|
|
for x := 0; x < BoardWidth; x++ {
|
|
if g.board[y][x] == 0 {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// removeLine removes a line and shifts everything above down
|
|
func (g *Game) removeLine(y int) {
|
|
for i := y; i > 0; i-- {
|
|
copy(g.board[i], g.board[i-1])
|
|
}
|
|
for x := 0; x < BoardWidth; x++ {
|
|
g.board[0][x] = 0
|
|
}
|
|
}
|
|
|
|
// spawnNewPiece creates a new piece at the top of the board
|
|
func (g *Game) spawnNewPiece() {
|
|
if g.nextPiece == nil {
|
|
g.nextPiece = NewTetromino(BlockType(rand.Intn(7)), 0, 0)
|
|
}
|
|
|
|
g.currentPiece = g.nextPiece
|
|
g.currentPiece.X = BoardWidth/2 - 2
|
|
if g.currentPiece.BlockType != IBlock {
|
|
g.currentPiece.X++ // Adjust for non-I pieces
|
|
}
|
|
g.currentPiece.Y = 0
|
|
|
|
g.nextPiece = NewTetromino(BlockType(rand.Intn(7)), 0, 0)
|
|
|
|
if g.isColliding() {
|
|
g.gameOver = true
|
|
}
|
|
}
|
|
|
|
// wouldCollide checks if a piece would collide in its current position
|
|
func (g *Game) wouldCollide(piece *Tetromino) bool {
|
|
for _, pos := range piece.GetAbsolutePositions() {
|
|
x, y := pos[0], pos[1]
|
|
if x < 0 || x >= BoardWidth || y >= BoardHeight {
|
|
return true
|
|
}
|
|
if y >= 0 && y < BoardHeight && x >= 0 && x < BoardWidth && g.board[y][x] != 0 {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// drawBlock draws a single block at the specified position
|
|
func (g *Game) drawBlock(screen *ebiten.Image, x, y int, blockType BlockType) {
|
|
if y < 0 {
|
|
return
|
|
}
|
|
|
|
// Draw block background
|
|
ebitenutil.DrawRect(screen,
|
|
float64(x*BlockSize),
|
|
float64(y*BlockSize),
|
|
float64(BlockSize-1),
|
|
float64(BlockSize-1),
|
|
BlockColors[blockType])
|
|
|
|
// Draw highlight (3D effect)
|
|
highlightColor := color.RGBA{255, 255, 255, 64}
|
|
ebitenutil.DrawLine(screen,
|
|
float64(x*BlockSize),
|
|
float64(y*BlockSize),
|
|
float64(x*BlockSize+BlockSize-1),
|
|
float64(y*BlockSize),
|
|
highlightColor)
|
|
ebitenutil.DrawLine(screen,
|
|
float64(x*BlockSize),
|
|
float64(y*BlockSize),
|
|
float64(x*BlockSize),
|
|
float64(y*BlockSize+BlockSize-1),
|
|
highlightColor)
|
|
}
|
|
|
|
func max(a, b int) int {
|
|
if a > b {
|
|
return a
|
|
}
|
|
return b
|
|
}
|
|
|