Update Go module path and all import references to reflect the migration from Gitea (git.t-juice.club) to Forgejo (code.t-juice.club). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
332 lines
8.5 KiB
Go
332 lines
8.5 KiB
Go
package tetris
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
|
|
"code.t-juice.club/torjus/oubliette/internal/shell"
|
|
)
|
|
|
|
type screen int
|
|
|
|
const (
|
|
screenTitle screen = iota
|
|
screenGame
|
|
screenGameOver
|
|
)
|
|
|
|
type tickMsg time.Time
|
|
type lockMsg time.Time
|
|
|
|
const lockDelay = 500 * time.Millisecond
|
|
|
|
type model struct {
|
|
sess *shell.SessionContext
|
|
difficulty string
|
|
screen screen
|
|
game *gameState
|
|
quitting bool
|
|
height int
|
|
keypresses int
|
|
locking bool // true when piece has landed and lock delay is active
|
|
}
|
|
|
|
func newModel(sess *shell.SessionContext, difficulty string) *model {
|
|
return &model{
|
|
sess: sess,
|
|
difficulty: difficulty,
|
|
screen: screenTitle,
|
|
}
|
|
}
|
|
|
|
func (m *model) Init() tea.Cmd {
|
|
return nil
|
|
}
|
|
|
|
func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
if m.quitting {
|
|
return m, tea.Quit
|
|
}
|
|
|
|
switch msg := msg.(type) {
|
|
case tea.WindowSizeMsg:
|
|
m.height = msg.Height
|
|
return m, nil
|
|
case tea.KeyMsg:
|
|
m.keypresses++
|
|
if msg.Type == tea.KeyCtrlC {
|
|
m.quitting = true
|
|
return m, tea.Batch(
|
|
logAction(m.sess, fmt.Sprintf("QUIT score=%d level=%d lines=%d keys=%d", m.gameScore(), m.gameLevel(), m.gameLines(), m.keypresses), "SESSION ENDED"),
|
|
tea.Quit,
|
|
)
|
|
}
|
|
}
|
|
|
|
switch m.screen {
|
|
case screenTitle:
|
|
return m.updateTitle(msg)
|
|
case screenGame:
|
|
return m.updateGame(msg)
|
|
case screenGameOver:
|
|
return m.updateGameOver(msg)
|
|
}
|
|
|
|
return m, nil
|
|
}
|
|
|
|
func (m *model) View() string {
|
|
var content string
|
|
switch m.screen {
|
|
case screenTitle:
|
|
content = m.titleView()
|
|
case screenGame:
|
|
content = gameView(m.game)
|
|
case screenGameOver:
|
|
content = m.gameOverView()
|
|
}
|
|
|
|
return gameFrame(content, m.height)
|
|
}
|
|
|
|
// --- Title screen ---
|
|
|
|
func (m *model) titleView() string {
|
|
var b strings.Builder
|
|
b.WriteString("\n")
|
|
b.WriteString(titleStyle.Render(" ████████╗███████╗████████╗██████╗ ██╗███████╗"))
|
|
b.WriteString("\n")
|
|
b.WriteString(titleStyle.Render(" ╚══██╔══╝██╔════╝╚══██╔══╝██╔══██╗██║██╔════╝"))
|
|
b.WriteString("\n")
|
|
b.WriteString(titleStyle.Render(" ██║ █████╗ ██║ ██████╔╝██║███████╗"))
|
|
b.WriteString("\n")
|
|
b.WriteString(titleStyle.Render(" ██║ ██╔══╝ ██║ ██╔══██╗██║╚════██║"))
|
|
b.WriteString("\n")
|
|
b.WriteString(titleStyle.Render(" ██║ ███████╗ ██║ ██║ ██║██║███████║"))
|
|
b.WriteString("\n")
|
|
b.WriteString(titleStyle.Render(" ╚═╝ ╚══════╝ ╚═╝ ╚═╝ ╚═╝╚═╝╚══════╝"))
|
|
b.WriteString("\n\n")
|
|
b.WriteString(baseStyle.Render(" Press any key to start"))
|
|
b.WriteString("\n")
|
|
return b.String()
|
|
}
|
|
|
|
func (m *model) updateTitle(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
if _, ok := msg.(tea.KeyMsg); ok {
|
|
m.screen = screenGame
|
|
var startLevel int
|
|
if m.difficulty == "hard" {
|
|
startLevel = 5
|
|
}
|
|
m.game = newGame(startLevel)
|
|
return m, tea.Batch(
|
|
tea.ClearScreen,
|
|
m.scheduleTick(),
|
|
logAction(m.sess, "GAME START", fmt.Sprintf("difficulty=%s", m.difficulty)),
|
|
)
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
// --- Game screen ---
|
|
|
|
func (m *model) scheduleTick() tea.Cmd {
|
|
ms := tickInterval(m.game.level)
|
|
if m.difficulty == "easy" {
|
|
ms = max(1000-m.game.level*60, 150)
|
|
}
|
|
return tea.Tick(time.Duration(ms)*time.Millisecond, func(t time.Time) tea.Msg {
|
|
return tickMsg(t)
|
|
})
|
|
}
|
|
|
|
func (m *model) scheduleLock() tea.Cmd {
|
|
return tea.Tick(lockDelay, func(t time.Time) tea.Msg {
|
|
return lockMsg(t)
|
|
})
|
|
}
|
|
|
|
// performLock locks the piece, clears lines, and returns commands for logging
|
|
// and scheduling the next tick. Returns nil if game over (goToGameOver is
|
|
// included in the returned batch).
|
|
func (m *model) performLock() tea.Cmd {
|
|
m.locking = false
|
|
cleared := m.game.afterLock()
|
|
if m.game.gameOver {
|
|
return tea.Batch(
|
|
logAction(m.sess, fmt.Sprintf("GAME OVER score=%d level=%d lines=%d keys=%d", m.game.score, m.game.level, m.game.lines, m.keypresses), "GAME OVER"),
|
|
m.goToGameOver(),
|
|
)
|
|
}
|
|
var cmds []tea.Cmd
|
|
cmds = append(cmds, m.scheduleTick())
|
|
if cleared > 0 {
|
|
cmds = append(cmds, logAction(m.sess, fmt.Sprintf("LINES %d score=%d", cleared, m.game.score), fmt.Sprintf("total=%d", m.game.lines)))
|
|
prevLevel := (m.game.lines - cleared) / 10
|
|
if m.game.level > prevLevel {
|
|
cmds = append(cmds, logAction(m.sess, fmt.Sprintf("LEVEL UP %d", m.game.level), fmt.Sprintf("score=%d", m.game.score)))
|
|
}
|
|
}
|
|
return tea.Batch(cmds...)
|
|
}
|
|
|
|
func (m *model) updateGame(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
switch msg := msg.(type) {
|
|
case lockMsg:
|
|
if m.game.gameOver || !m.locking {
|
|
return m, nil
|
|
}
|
|
// Lock delay expired — lock the piece now.
|
|
return m, m.performLock()
|
|
|
|
case tickMsg:
|
|
if m.game.gameOver || m.locking {
|
|
return m, nil
|
|
}
|
|
if !m.game.moveDown() {
|
|
// Piece landed — start lock delay instead of locking immediately.
|
|
m.locking = true
|
|
return m, m.scheduleLock()
|
|
}
|
|
return m, m.scheduleTick()
|
|
|
|
case tea.KeyMsg:
|
|
if m.game.gameOver {
|
|
return m, nil
|
|
}
|
|
|
|
switch msg.String() {
|
|
case "left":
|
|
m.game.moveLeft()
|
|
// If piece can now drop further, cancel lock delay.
|
|
if m.locking && m.game.canPlace(m.game.current, m.game.currentRot, m.game.currentRow+1, m.game.currentCol) {
|
|
m.locking = false
|
|
}
|
|
case "right":
|
|
m.game.moveRight()
|
|
if m.locking && m.game.canPlace(m.game.current, m.game.currentRot, m.game.currentRow+1, m.game.currentCol) {
|
|
m.locking = false
|
|
}
|
|
case "down":
|
|
if m.game.moveDown() {
|
|
m.game.score++ // soft drop bonus
|
|
if m.locking {
|
|
m.locking = false
|
|
}
|
|
}
|
|
case "up", "z":
|
|
m.game.rotate()
|
|
if m.locking && m.game.canPlace(m.game.current, m.game.currentRot, m.game.currentRow+1, m.game.currentCol) {
|
|
m.locking = false
|
|
}
|
|
case " ":
|
|
m.locking = false
|
|
dropped := m.game.hardDrop()
|
|
m.game.score += dropped * 2
|
|
return m, m.performLock()
|
|
case "q":
|
|
m.quitting = true
|
|
return m, tea.Batch(
|
|
logAction(m.sess, fmt.Sprintf("QUIT score=%d level=%d lines=%d keys=%d", m.game.score, m.game.level, m.game.lines, m.keypresses), "PLAYER QUIT"),
|
|
tea.Quit,
|
|
)
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
return m, nil
|
|
}
|
|
|
|
// --- Game over screen ---
|
|
|
|
func (m *model) goToGameOver() tea.Cmd {
|
|
m.screen = screenGameOver
|
|
return tea.ClearScreen
|
|
}
|
|
|
|
func (m *model) gameOverView() string {
|
|
var b strings.Builder
|
|
b.WriteString("\n")
|
|
b.WriteString(titleStyle.Render(" GAME OVER"))
|
|
b.WriteString("\n\n")
|
|
b.WriteString(baseStyle.Render(fmt.Sprintf(" Score: %s", formatScore(m.game.score))))
|
|
b.WriteString("\n")
|
|
b.WriteString(baseStyle.Render(fmt.Sprintf(" Level: %d", m.game.level)))
|
|
b.WriteString("\n")
|
|
b.WriteString(baseStyle.Render(fmt.Sprintf(" Lines: %d", m.game.lines)))
|
|
b.WriteString("\n\n")
|
|
b.WriteString(dimStyle.Render(" R - Play again"))
|
|
b.WriteString("\n")
|
|
b.WriteString(dimStyle.Render(" Q - Quit"))
|
|
b.WriteString("\n")
|
|
return b.String()
|
|
}
|
|
|
|
func (m *model) updateGameOver(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
if keyMsg, ok := msg.(tea.KeyMsg); ok {
|
|
switch keyMsg.String() {
|
|
case "r":
|
|
startLevel := 0
|
|
if m.difficulty == "hard" {
|
|
startLevel = 5
|
|
}
|
|
m.game = newGame(startLevel)
|
|
m.screen = screenGame
|
|
m.keypresses = 0
|
|
return m, tea.Batch(
|
|
tea.ClearScreen,
|
|
m.scheduleTick(),
|
|
logAction(m.sess, "RESTART", fmt.Sprintf("difficulty=%s", m.difficulty)),
|
|
)
|
|
case "q":
|
|
m.quitting = true
|
|
return m, tea.Batch(
|
|
logAction(m.sess, fmt.Sprintf("QUIT score=%d level=%d lines=%d keys=%d", m.game.score, m.game.level, m.game.lines, m.keypresses), "PLAYER QUIT"),
|
|
tea.Quit,
|
|
)
|
|
}
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
// Helper methods for safe access when game may be nil.
|
|
func (m *model) gameScore() int {
|
|
if m.game != nil {
|
|
return m.game.score
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func (m *model) gameLevel() int {
|
|
if m.game != nil {
|
|
return m.game.level
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func (m *model) gameLines() int {
|
|
if m.game != nil {
|
|
return m.game.lines
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// logAction returns a tea.Cmd that logs an action to the session store.
|
|
func logAction(sess *shell.SessionContext, input, output string) tea.Cmd {
|
|
return func() tea.Msg {
|
|
if sess.Store != nil {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
defer cancel()
|
|
_ = sess.Store.AppendSessionLog(ctx, sess.SessionID, input, output)
|
|
}
|
|
if sess.OnCommand != nil {
|
|
sess.OnCommand("tetris")
|
|
}
|
|
return nil
|
|
}
|
|
}
|