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()) }