Full-screen Tetris game using Bubbletea with title screen, ghost piece, lock delay, NES-style scoring, configurable difficulty (easy/normal/hard), and honeypot event logging. Bumps version to 0.17.0. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
211 lines
4.9 KiB
Go
211 lines
4.9 KiB
Go
package tetris
|
|
|
|
import "math/rand/v2"
|
|
|
|
const (
|
|
boardRows = 20
|
|
boardCols = 10
|
|
)
|
|
|
|
// cell represents a single board cell. Zero value is empty.
|
|
type cell struct {
|
|
filled bool
|
|
piece pieceType // which piece type filled this cell (for color)
|
|
}
|
|
|
|
// gameState holds all mutable state for a Tetris game.
|
|
type gameState struct {
|
|
board [boardRows][boardCols]cell
|
|
current pieceType
|
|
currentRot int
|
|
currentRow int
|
|
currentCol int
|
|
next pieceType
|
|
score int
|
|
level int
|
|
lines int
|
|
gameOver bool
|
|
}
|
|
|
|
// newGame creates a new game state, optionally starting at a given level.
|
|
func newGame(startLevel int) *gameState {
|
|
g := &gameState{
|
|
level: startLevel,
|
|
next: pieceType(rand.IntN(numPieceTypes)),
|
|
}
|
|
g.spawnPiece()
|
|
return g
|
|
}
|
|
|
|
// spawnPiece pulls the next piece and generates a new next.
|
|
func (g *gameState) spawnPiece() {
|
|
g.current = g.next
|
|
g.next = pieceType(rand.IntN(numPieceTypes))
|
|
g.currentRot = 0
|
|
g.currentRow = 0
|
|
g.currentCol = spawnCol(g.current, 0)
|
|
|
|
if !g.canPlace(g.current, g.currentRot, g.currentRow, g.currentCol) {
|
|
g.gameOver = true
|
|
}
|
|
}
|
|
|
|
// canPlace checks whether the piece fits at the given position.
|
|
func (g *gameState) canPlace(pt pieceType, rot, row, col int) bool {
|
|
shape := pieces[pt][rot]
|
|
for _, off := range shape {
|
|
r, c := row+off[0], col+off[1]
|
|
if r < 0 || r >= boardRows || c < 0 || c >= boardCols {
|
|
return false
|
|
}
|
|
if g.board[r][c].filled {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// moveLeft moves the current piece left if possible.
|
|
func (g *gameState) moveLeft() bool {
|
|
if g.canPlace(g.current, g.currentRot, g.currentRow, g.currentCol-1) {
|
|
g.currentCol--
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// moveRight moves the current piece right if possible.
|
|
func (g *gameState) moveRight() bool {
|
|
if g.canPlace(g.current, g.currentRot, g.currentRow, g.currentCol+1) {
|
|
g.currentCol++
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// moveDown moves the current piece down one row. Returns false if it cannot.
|
|
func (g *gameState) moveDown() bool {
|
|
if g.canPlace(g.current, g.currentRot, g.currentRow+1, g.currentCol) {
|
|
g.currentRow++
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// rotate rotates the current piece clockwise with wall kick attempts.
|
|
func (g *gameState) rotate() bool {
|
|
newRot := (g.currentRot + 1) % 4
|
|
|
|
// Try in-place first.
|
|
if g.canPlace(g.current, newRot, g.currentRow, g.currentCol) {
|
|
g.currentRot = newRot
|
|
return true
|
|
}
|
|
|
|
// Wall kick: try +-1 column offset.
|
|
for _, offset := range []int{-1, 1} {
|
|
if g.canPlace(g.current, newRot, g.currentRow, g.currentCol+offset) {
|
|
g.currentRot = newRot
|
|
g.currentCol += offset
|
|
return true
|
|
}
|
|
}
|
|
|
|
// I piece: try +-2.
|
|
if g.current == pieceI {
|
|
for _, offset := range []int{-2, 2} {
|
|
if g.canPlace(g.current, newRot, g.currentRow, g.currentCol+offset) {
|
|
g.currentRot = newRot
|
|
g.currentCol += offset
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// ghostRow returns the row where the piece would land.
|
|
func (g *gameState) ghostRow() int {
|
|
row := g.currentRow
|
|
for g.canPlace(g.current, g.currentRot, row+1, g.currentCol) {
|
|
row++
|
|
}
|
|
return row
|
|
}
|
|
|
|
// hardDrop drops the piece to the bottom and returns the number of rows dropped.
|
|
func (g *gameState) hardDrop() int {
|
|
ghost := g.ghostRow()
|
|
dropped := ghost - g.currentRow
|
|
g.currentRow = ghost
|
|
return dropped
|
|
}
|
|
|
|
// lockPiece writes the current piece into the board.
|
|
func (g *gameState) lockPiece() {
|
|
shape := pieces[g.current][g.currentRot]
|
|
for _, off := range shape {
|
|
r, c := g.currentRow+off[0], g.currentCol+off[1]
|
|
if r >= 0 && r < boardRows && c >= 0 && c < boardCols {
|
|
g.board[r][c] = cell{filled: true, piece: g.current}
|
|
}
|
|
}
|
|
}
|
|
|
|
// clearLines removes completed rows and returns how many were cleared.
|
|
func (g *gameState) clearLines() int {
|
|
cleared := 0
|
|
for r := boardRows - 1; r >= 0; r-- {
|
|
full := true
|
|
for c := range boardCols {
|
|
if !g.board[r][c].filled {
|
|
full = false
|
|
break
|
|
}
|
|
}
|
|
if full {
|
|
cleared++
|
|
// Shift everything above down.
|
|
for rr := r; rr > 0; rr-- {
|
|
g.board[rr] = g.board[rr-1]
|
|
}
|
|
g.board[0] = [boardCols]cell{}
|
|
r++ // re-check this row since we shifted
|
|
}
|
|
}
|
|
return cleared
|
|
}
|
|
|
|
// NES-style scoring multipliers per lines cleared.
|
|
var lineScoreMultipliers = [5]int{0, 40, 100, 300, 1200}
|
|
|
|
// addScore updates score, lines, and level after clearing rows.
|
|
func (g *gameState) addScore(linesCleared int) {
|
|
if linesCleared > 0 && linesCleared <= 4 {
|
|
g.score += lineScoreMultipliers[linesCleared] * (g.level + 1)
|
|
}
|
|
g.lines += linesCleared
|
|
|
|
// Level up every 10 lines.
|
|
newLevel := g.lines / 10
|
|
if newLevel > g.level {
|
|
g.level = newLevel
|
|
}
|
|
}
|
|
|
|
// afterLock locks the piece, clears lines, scores, and spawns the next piece.
|
|
// Returns the number of lines cleared.
|
|
func (g *gameState) afterLock() int {
|
|
g.lockPiece()
|
|
cleared := g.clearLines()
|
|
g.addScore(cleared)
|
|
g.spawnPiece()
|
|
return cleared
|
|
}
|
|
|
|
// tickInterval returns the gravity interval in milliseconds for the current level.
|
|
func tickInterval(level int) int {
|
|
return max(800-level*60, 100)
|
|
}
|