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:
286
internal/shell/tetris/style.go
Normal file
286
internal/shell/tetris/style.go
Normal file
@@ -0,0 +1,286 @@
|
||||
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())
|
||||
}
|
||||
Reference in New Issue
Block a user