package main import ( "fmt" "math/rand" "strings" "time" tea "github.com/charmbracelet/bubbletea" ) // TicTacToeModel — TUI поверх TicTacToeGameState. type TicTacToeModel struct { game *TicTacToeGameState bot TicTacToeBot rng *rand.Rand cursor int message string isError bool quitting bool backToMenu bool } // NewTicTacToeModel начинает новую партию. Каким знаком играет // человек — решается случайно (50/50): если человеку достаются // нолики, первым ходом автоматически становится ход бота (крестики // всегда идут первыми). func NewTicTacToeModel(difficulty TicTacToeDifficulty) TicTacToeModel { rng := rand.New(rand.NewSource(time.Now().UnixNano())) human := TicTacToeX if rng.Intn(2) == 0 { human = TicTacToeO } return TicTacToeModel{ game: NewTicTacToeGame(human), bot: TicTacToeBot{Difficulty: difficulty}, rng: rng, cursor: 4, } } func (m TicTacToeModel) Init() tea.Cmd { return m.maybeScheduleBot() } func (m TicTacToeModel) maybeScheduleBot() tea.Cmd { if m.game.Phase != TicTacToePhasePlay || m.game.CurrentTurn == m.game.HumanMark { return nil } return tea.Tick(botMoveDelay, func(time.Time) tea.Msg { return botMoveMsg{} }) } func (m *TicTacToeModel) setInfo(key string) { m.message = T(key) m.isError = false } func (m *TicTacToeModel) setError(err error) { m.message = err.Error() m.isError = true } func (m TicTacToeModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case botMoveMsg: return m.handleBotMove() case tea.KeyMsg: return m.handleKey(msg) } return m, nil } func (m TicTacToeModel) handleBotMove() (tea.Model, tea.Cmd) { if m.game.Phase != TicTacToePhasePlay || m.game.CurrentTurn == m.game.HumanMark { return m, nil } if err := m.bot.PlayFullTurn(m.game, m.rng); err != nil { m.setError(err) return m, nil } m.setInfo("tictactoe.msg.bot_moved") return m, m.maybeScheduleBot() } func (m TicTacToeModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { key := msg.String() if key == "ctrl+c" { m.quitting = true return m, tea.Quit } if key == "q" { m.backToMenu = true return m, nil } if m.game.Phase == TicTacToePhaseOver { if key == "n" { next := NewTicTacToeModel(m.bot.Difficulty) return next, next.Init() } return m, nil } if m.game.CurrentTurn != m.game.HumanMark { return m, nil } switch key { case "up", "k": if m.cursor >= 3 { m.cursor -= 3 } case "down", "j": if m.cursor < 6 { m.cursor += 3 } case "left", "h": if m.cursor%3 != 0 { m.cursor-- } case "right", "l": if m.cursor%3 != 2 { m.cursor++ } case "enter", " ": return m.place(m.cursor) case "1", "2", "3", "4", "5", "6", "7", "8", "9": pos := int(key[0] - '1') m.cursor = pos return m.place(pos) } return m, nil } func (m TicTacToeModel) place(pos int) (tea.Model, tea.Cmd) { if err := m.game.PlaceMark(pos); err != nil { m.setError(err) return m, nil } m.message = "" m.isError = false return m, m.maybeScheduleBot() } func ticTacToeMarkGlyph(mark TicTacToeMark) string { switch mark { case TicTacToeX: return "X" case TicTacToeO: return "O" default: return " " } } func (m TicTacToeModel) View() string { if m.quitting { return T("common.goodbye") } var b strings.Builder fmt.Fprintln(&b, titleStyle.Render(T("tictactoe.title"))) fmt.Fprintln(&b) fmt.Fprintln(&b, Tf("tictactoe.your_mark", ticTacToeMarkGlyph(m.game.HumanMark))) fmt.Fprintln(&b) winSet := map[int]bool{} if m.game.Result != nil && !m.game.Result.Draw { for _, idx := range m.game.Result.Line { winSet[idx] = true } } for row := 0; row < 3; row++ { cells := make([]string, 3) for col := 0; col < 3; col++ { idx := row*3 + col cells[col] = m.renderCell(idx, winSet[idx]) } fmt.Fprintln(&b, " "+strings.Join(cells, " │ ")+" ") if row < 2 { fmt.Fprintln(&b, "───┼───┼───") } } fmt.Fprintln(&b) switch { case m.game.Phase == TicTacToePhaseOver && m.game.Result.Draw: fmt.Fprintln(&b, infoStyle.Render(T("tictactoe.outcome.draw"))) case m.game.Phase == TicTacToePhaseOver && m.game.Result.Winner == m.game.HumanMark: fmt.Fprintln(&b, winStyle.Render(T("tictactoe.outcome.win"))) case m.game.Phase == TicTacToePhaseOver: fmt.Fprintln(&b, errorStyle.Render(T("tictactoe.outcome.lose"))) case m.game.CurrentTurn == m.game.HumanMark: fmt.Fprintln(&b, T("tictactoe.hint.turn")) default: fmt.Fprintln(&b, T("common.thinking_generic")) } if m.message != "" { if m.isError { fmt.Fprintln(&b, errorStyle.Render("! "+m.message)) } else { fmt.Fprintln(&b, infoStyle.Render(m.message)) } } fmt.Fprintln(&b) if m.game.Phase == TicTacToePhaseOver { fmt.Fprintln(&b, dimStyle.Render(T("tictactoe.hint.over"))) } else { fmt.Fprintln(&b, dimStyle.Render(T("tictactoe.hint.play"))) } return b.String() } // renderCell отрисовывает одну клетку доски: цифра-подсказка (1-9), // если пусто, X/O если занято, с подсветкой курсора (пока пусто и // сейчас ход человека) или выигрышной линии. func (m TicTacToeModel) renderCell(idx int, isWinCell bool) string { mark := m.game.Board[idx] if mark == TicTacToeEmpty { text := fmt.Sprintf("%d", idx+1) if m.game.Phase == TicTacToePhasePlay && m.game.CurrentTurn == m.game.HumanMark && idx == m.cursor { return cursorStyle.Render(text) } return dimStyle.Render(text) } text := ticTacToeMarkGlyph(mark) if isWinCell { return winStyle.Render(text) } return text }