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