feat: add tetris shell (Tetris game TUI)
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>
This commit is contained in:
210
internal/shell/tetris/game.go
Normal file
210
internal/shell/tetris/game.go
Normal file
@@ -0,0 +1,210 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user