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>
287 lines
7.3 KiB
Go
287 lines
7.3 KiB
Go
package tetris
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/charmbracelet/lipgloss"
|
|
)
|
|
|
|
const termWidth = 80
|
|
|
|
var (
|
|
colorWhite = lipgloss.Color("#FFFFFF")
|
|
colorDim = lipgloss.Color("#555555")
|
|
colorBlack = lipgloss.Color("#000000")
|
|
colorGhost = lipgloss.Color("#333333")
|
|
)
|
|
|
|
var (
|
|
baseStyle = lipgloss.NewStyle().
|
|
Foreground(colorWhite).
|
|
Background(colorBlack)
|
|
|
|
dimStyle = lipgloss.NewStyle().
|
|
Foreground(colorDim).
|
|
Background(colorBlack)
|
|
|
|
titleStyle = lipgloss.NewStyle().
|
|
Foreground(lipgloss.Color("#00FFFF")).
|
|
Background(colorBlack).
|
|
Bold(true)
|
|
|
|
sidebarLabelStyle = lipgloss.NewStyle().
|
|
Foreground(colorDim).
|
|
Background(colorBlack)
|
|
|
|
sidebarValueStyle = lipgloss.NewStyle().
|
|
Foreground(colorWhite).
|
|
Background(colorBlack).
|
|
Bold(true)
|
|
)
|
|
|
|
// cellStyle returns a style for a filled cell of a given piece type.
|
|
func cellStyle(pt pieceType) lipgloss.Style {
|
|
return lipgloss.NewStyle().
|
|
Foreground(pieceColors[pt]).
|
|
Background(colorBlack)
|
|
}
|
|
|
|
// ghostStyle returns a dimmed style for the ghost piece.
|
|
func ghostCellStyle() lipgloss.Style {
|
|
return lipgloss.NewStyle().
|
|
Foreground(colorGhost).
|
|
Background(colorBlack)
|
|
}
|
|
|
|
// renderBoard renders the board, current piece, and ghost piece as a string.
|
|
func renderBoard(g *gameState) string {
|
|
// Build a display grid that includes the current piece and ghost.
|
|
type displayCell struct {
|
|
filled bool
|
|
ghost bool
|
|
piece pieceType
|
|
}
|
|
var grid [boardRows][boardCols]displayCell
|
|
|
|
// Copy locked cells.
|
|
for r := range boardRows {
|
|
for c := range boardCols {
|
|
if g.board[r][c].filled {
|
|
grid[r][c] = displayCell{filled: true, piece: g.board[r][c].piece}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Ghost piece.
|
|
ghostR := g.ghostRow()
|
|
if ghostR != g.currentRow {
|
|
shape := pieces[g.current][g.currentRot]
|
|
for _, off := range shape {
|
|
r, c := ghostR+off[0], g.currentCol+off[1]
|
|
if r >= 0 && r < boardRows && c >= 0 && c < boardCols && !grid[r][c].filled {
|
|
grid[r][c] = displayCell{ghost: true, piece: g.current}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Current piece.
|
|
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 {
|
|
grid[r][c] = displayCell{filled: true, piece: g.current}
|
|
}
|
|
}
|
|
|
|
// Render grid.
|
|
var b strings.Builder
|
|
borderStyle := dimStyle
|
|
|
|
for _, row := range grid {
|
|
b.WriteString(borderStyle.Render("|"))
|
|
for _, dc := range row {
|
|
switch {
|
|
case dc.filled:
|
|
b.WriteString(cellStyle(dc.piece).Render("[]"))
|
|
case dc.ghost:
|
|
b.WriteString(ghostCellStyle().Render("::"))
|
|
default:
|
|
b.WriteString(baseStyle.Render(" "))
|
|
}
|
|
}
|
|
b.WriteString(borderStyle.Render("|"))
|
|
b.WriteString("\n")
|
|
}
|
|
b.WriteString(borderStyle.Render("+" + strings.Repeat("--", boardCols) + "+"))
|
|
|
|
return b.String()
|
|
}
|
|
|
|
// renderNextPiece renders the "next piece" preview box.
|
|
func renderNextPiece(pt pieceType) string {
|
|
shape := pieces[pt][0]
|
|
// Determine bounding box.
|
|
minR, maxR := shape[0][0], shape[0][0]
|
|
minC, maxC := shape[0][1], shape[0][1]
|
|
for _, off := range shape {
|
|
if off[0] < minR {
|
|
minR = off[0]
|
|
}
|
|
if off[0] > maxR {
|
|
maxR = off[0]
|
|
}
|
|
if off[1] < minC {
|
|
minC = off[1]
|
|
}
|
|
if off[1] > maxC {
|
|
maxC = off[1]
|
|
}
|
|
}
|
|
|
|
rows := maxR - minR + 1
|
|
cols := maxC - minC + 1
|
|
|
|
// Build a small grid.
|
|
grid := make([][]bool, rows)
|
|
for i := range grid {
|
|
grid[i] = make([]bool, cols)
|
|
}
|
|
for _, off := range shape {
|
|
grid[off[0]-minR][off[1]-minC] = true
|
|
}
|
|
|
|
var b strings.Builder
|
|
boxWidth := 8 // chars for the box interior
|
|
b.WriteString(dimStyle.Render("+" + strings.Repeat("-", boxWidth) + "+"))
|
|
b.WriteString("\n")
|
|
|
|
for r := range rows {
|
|
b.WriteString(dimStyle.Render("|"))
|
|
// Center the piece in the box.
|
|
pieceWidth := cols * 2
|
|
leftPad := (boxWidth - pieceWidth) / 2
|
|
rightPad := boxWidth - pieceWidth - leftPad
|
|
b.WriteString(baseStyle.Render(strings.Repeat(" ", leftPad)))
|
|
for c := range cols {
|
|
if grid[r][c] {
|
|
b.WriteString(cellStyle(pt).Render("[]"))
|
|
} else {
|
|
b.WriteString(baseStyle.Render(" "))
|
|
}
|
|
}
|
|
b.WriteString(baseStyle.Render(strings.Repeat(" ", rightPad)))
|
|
b.WriteString(dimStyle.Render("|"))
|
|
b.WriteString("\n")
|
|
}
|
|
|
|
// Fill remaining rows in the box (max 4 rows for I piece).
|
|
for r := rows; r < 2; r++ {
|
|
b.WriteString(dimStyle.Render("|"))
|
|
b.WriteString(baseStyle.Render(strings.Repeat(" ", boxWidth)))
|
|
b.WriteString(dimStyle.Render("|"))
|
|
b.WriteString("\n")
|
|
}
|
|
|
|
b.WriteString(dimStyle.Render("+" + strings.Repeat("-", boxWidth) + "+"))
|
|
return b.String()
|
|
}
|
|
|
|
// formatScore formats a score with comma separators.
|
|
func formatScore(n int) string {
|
|
s := fmt.Sprintf("%d", n)
|
|
if len(s) <= 3 {
|
|
return s
|
|
}
|
|
var parts []string
|
|
for len(s) > 3 {
|
|
parts = append([]string{s[len(s)-3:]}, parts...)
|
|
s = s[:len(s)-3]
|
|
}
|
|
parts = append([]string{s}, parts...)
|
|
return strings.Join(parts, ",")
|
|
}
|
|
|
|
// gameView combines the board and sidebar into the game screen.
|
|
func gameView(g *gameState) string {
|
|
boardStr := renderBoard(g)
|
|
boardLines := strings.Split(boardStr, "\n")
|
|
|
|
nextStr := renderNextPiece(g.next)
|
|
nextLines := strings.Split(nextStr, "\n")
|
|
|
|
// Build sidebar lines.
|
|
var sidebar []string
|
|
sidebar = append(sidebar, sidebarLabelStyle.Render(" NEXT:"))
|
|
sidebar = append(sidebar, nextLines...)
|
|
sidebar = append(sidebar, "")
|
|
sidebar = append(sidebar, sidebarLabelStyle.Render(" SCORE: ")+sidebarValueStyle.Render(formatScore(g.score)))
|
|
sidebar = append(sidebar, sidebarLabelStyle.Render(" LEVEL: ")+sidebarValueStyle.Render(fmt.Sprintf("%d", g.level)))
|
|
sidebar = append(sidebar, sidebarLabelStyle.Render(" LINES: ")+sidebarValueStyle.Render(fmt.Sprintf("%d", g.lines)))
|
|
sidebar = append(sidebar, "")
|
|
sidebar = append(sidebar, dimStyle.Render(" Controls:"))
|
|
sidebar = append(sidebar, dimStyle.Render(" <- -> Move"))
|
|
sidebar = append(sidebar, dimStyle.Render(" Up/Z Rotate"))
|
|
sidebar = append(sidebar, dimStyle.Render(" Down Soft drop"))
|
|
sidebar = append(sidebar, dimStyle.Render(" Space Hard drop"))
|
|
sidebar = append(sidebar, dimStyle.Render(" Q Quit"))
|
|
|
|
// Combine board and sidebar side by side.
|
|
var b strings.Builder
|
|
maxLines := max(len(boardLines), len(sidebar))
|
|
|
|
for i := range maxLines {
|
|
boardLine := ""
|
|
if i < len(boardLines) {
|
|
boardLine = boardLines[i]
|
|
}
|
|
sidebarLine := ""
|
|
if i < len(sidebar) {
|
|
sidebarLine = sidebar[i]
|
|
}
|
|
|
|
// Pad board to fixed width (| + 10*2 + | = 22 chars visual).
|
|
b.WriteString(boardLine)
|
|
b.WriteString(sidebarLine)
|
|
b.WriteString("\n")
|
|
}
|
|
|
|
return b.String()
|
|
}
|
|
|
|
// padLine pads a single line to termWidth.
|
|
func padLine(line string) string {
|
|
w := lipgloss.Width(line)
|
|
if w >= termWidth {
|
|
return line
|
|
}
|
|
return line + baseStyle.Render(strings.Repeat(" ", termWidth-w))
|
|
}
|
|
|
|
// padLines pads every line in a multi-line string to termWidth.
|
|
func padLines(s string) string {
|
|
lines := strings.Split(s, "\n")
|
|
for i, line := range lines {
|
|
lines[i] = padLine(line)
|
|
}
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
|
|
// gameFrame wraps content with padding to fill the terminal.
|
|
func gameFrame(content string, height int) string {
|
|
var b strings.Builder
|
|
b.WriteString(content)
|
|
|
|
// Pad with blank lines to fill terminal height.
|
|
if height > 0 {
|
|
contentLines := strings.Count(content, "\n") + 1
|
|
blankLine := baseStyle.Render(strings.Repeat(" ", termWidth))
|
|
for i := contentLines; i < height; i++ {
|
|
b.WriteString(blankLine)
|
|
b.WriteString("\n")
|
|
}
|
|
}
|
|
|
|
return padLines(b.String())
|
|
}
|