package main import ( "fmt" "math/rand" "strings" "time" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) var ( checkersDarkSquare = lipgloss.NewStyle().Background(lipgloss.Color("236")) checkersLightSquare = lipgloss.NewStyle().Background(lipgloss.Color("180")) checkersWhitePieceColor = lipgloss.Color("255") checkersBlackPieceColor = lipgloss.Color("124") checkersWhitePiece = lipgloss.NewStyle().Foreground(checkersWhitePieceColor).Bold(true) checkersBlackPiece = lipgloss.NewStyle().Foreground(checkersBlackPieceColor).Bold(true) checkersCursorStyle = lipgloss.NewStyle().Background(lipgloss.Color("82")).Foreground(lipgloss.Color("16")).Bold(true) checkersSelStyle = lipgloss.NewStyle().Background(lipgloss.Color("39")).Foreground(lipgloss.Color("16")).Bold(true) ) // CheckersModel — TUI поверх CheckersGameState. Человек всегда // играет белыми (ходят первыми), бот — чёрными. type CheckersModel struct { game *CheckersGameState bot CheckersBot human CheckersColor cursor CheckersPos selected *CheckersPos message string isError bool quitting bool backToMenu bool } // NewCheckersModel создаёт новую партию: человек играет белыми. // depth — глубина поиска бота в полуходах (0 -> используется // значение по умолчанию из CheckersBot). func NewCheckersModel(depth int, colorChoice ColorChoice) CheckersModel { human := resolveCheckersColor(colorChoice) startRow := 5 if human == CheckersBlack { startRow = 2 } return CheckersModel{ game: NewCheckersGame(), bot: CheckersBot{Depth: depth}, human: human, cursor: CheckersPos{Row: startRow, Col: 0}, } } // resolveCheckersColor переводит выбор цвета из настроек в // конкретный CheckersColor — при случайном выборе бросает монету // собственным одноразовым генератором (партия в шашки сама по себе // детерминирована, отдельный rng в модели ради этого не нужен). func resolveCheckersColor(choice ColorChoice) CheckersColor { switch choice { case ColorChoiceA: return CheckersWhite case ColorChoiceB: return CheckersBlack default: rnd := rand.New(rand.NewSource(time.Now().UnixNano())) if rnd.Intn(2) == 0 { return CheckersWhite } return CheckersBlack } } func (m CheckersModel) Init() tea.Cmd { return m.maybeScheduleBot() } func (m CheckersModel) maybeScheduleBot() tea.Cmd { if m.game.Result != nil { return nil } if m.game.Turn == m.human { return nil } return tea.Tick(botMoveDelay, func(time.Time) tea.Msg { return botMoveMsg{} }) } func (m *CheckersModel) setInfo(format string, args ...interface{}) { m.message = fmt.Sprintf(format, args...) m.isError = false } func (m *CheckersModel) setError(err error) { m.message = T(err.Error()) m.isError = true } func (m CheckersModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: return m.handleKey(msg) case botMoveMsg: return m.handleBotMove() } return m, nil } func (m CheckersModel) handleBotMove() (tea.Model, tea.Cmd) { if m.game.Result != nil || m.game.Turn == m.human { return m, nil } if err := m.bot.PlayFullTurn(m.game); err != nil { m.setError(err) return m, nil } m.setInfo(T("checkers.msg.bot_moved")) return m, m.maybeScheduleBot() } func (m CheckersModel) 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.Result != nil { if key == "n" { m.game = NewCheckersGame() m.selected = nil m.message = "" m.isError = false return m, m.maybeScheduleBot() } return m, nil } if m.game.Turn != m.human { return m, nil } flip := m.human == CheckersBlack switch key { case "up", "k": if flip { if m.cursor.Row < 7 { m.cursor.Row++ } } else if m.cursor.Row > 0 { m.cursor.Row-- } case "down", "j": if flip { if m.cursor.Row > 0 { m.cursor.Row-- } } else if m.cursor.Row < 7 { m.cursor.Row++ } case "left", "h": if flip { if m.cursor.Col < 7 { m.cursor.Col++ } } else if m.cursor.Col > 0 { m.cursor.Col-- } case "right", "l": if flip { if m.cursor.Col > 0 { m.cursor.Col-- } } else if m.cursor.Col < 7 { m.cursor.Col++ } case "esc": m.selected = nil m.message = "" case "enter", " ": return m.handleSelectOrMove() } return m, nil } func (m CheckersModel) handleSelectOrMove() (tea.Model, tea.Cmd) { if m.selected == nil { piece := m.game.Board[m.cursor.Row][m.cursor.Col] if piece == nil || piece.Color != m.human { m.setError(ErrCheckersNoPiece) return m, nil } if len(m.game.LegalMovesFrom(m.cursor)) == 0 { m.setError(ErrCheckersInvalidMove) return m, nil } pos := m.cursor m.selected = &pos m.message = "" return m, nil } if *m.selected == m.cursor { m.selected = nil return m, nil } from := *m.selected err := m.game.Move(from, m.cursor) if err != nil { m.setError(err) return m, nil } m.setInfo(T("checkers.msg.moved")) if m.game.ChainPos != nil { pos := *m.game.ChainPos m.selected = &pos m.cursor = pos } else { m.selected = nil } return m, m.maybeScheduleBot() } // --- Отрисовка ----------------------------------------------------- // pieceGlyphChar возвращает "сырой" (без цвета) символ фигуры — // используется вместе с pieceForegroundColor, чтобы построить один // комбинированный стиль (фон клетки + цвет фигуры) вместо вложенных // вызовов Render(), которые ломаются в некоторых терминалах (см. // аналогичный баг, исправленный ранее в отрисовке карт Тонка). func pieceGlyphChar(p *CheckersPiece) string { if p == nil { return " " } if p.King { return "O" } return "o" } // pieceForegroundColor возвращает цвет текста фигуры (второе // значение — false, если клетка пуста и раскрашивать нечего). func pieceForegroundColor(p *CheckersPiece) (lipgloss.Color, bool) { if p == nil { return "", false } if p.Color == CheckersWhite { return checkersWhitePieceColor, true } return checkersBlackPieceColor, true } func (m CheckersModel) legalDestinationSet() map[CheckersPos]bool { set := map[CheckersPos]bool{} if m.selected == nil { return set } for _, opt := range m.game.LegalMovesFrom(*m.selected) { set[opt.To] = true } return set } func (m CheckersModel) renderBoard() string { dests := m.legalDestinationSet() flip := m.human == CheckersBlack var b strings.Builder fmt.Fprint(&b, " ") for cc := 0; cc < 8; cc++ { col := cc if flip { col = 7 - cc } fmt.Fprintf(&b, "%c ", 'a'+col) } fmt.Fprintln(&b) for rr := 0; rr < 8; rr++ { r := rr if flip { r = 7 - rr } fmt.Fprintf(&b, "%d ", 8-r) for cc := 0; cc < 8; cc++ { c := cc if flip { c = 7 - cc } pos := CheckersPos{Row: r, Col: c} cellStyle := checkersLightSquare if (r+c)%2 == 1 { cellStyle = checkersDarkSquare } switch { case m.cursor == pos: cellStyle = checkersCursorStyle case m.selected != nil && *m.selected == pos: cellStyle = checkersSelStyle case dests[pos]: cellStyle = checkersSelStyle } piece := m.game.Board[r][c] text := pieceGlyphChar(piece) combined := cellStyle if fg, ok := pieceForegroundColor(piece); ok { combined = cellStyle.Foreground(fg).Bold(true) } fmt.Fprint(&b, combined.Render(" "+text+" ")) } fmt.Fprintln(&b) } return b.String() } func (m CheckersModel) View() string { if m.quitting { return T("common.goodbye") } var b strings.Builder fmt.Fprintln(&b, titleStyle.Render(T("checkers.title"))) fmt.Fprintln(&b) fmt.Fprintln(&b, m.renderBoard()) fmt.Fprintln(&b, dimStyle.Render(T("checkers.legend"))) fmt.Fprintln(&b) if m.game.Result != nil { res := m.game.Result switch { case res.Draw: fmt.Fprintln(&b, errorStyle.Render(T("checkers.result.draw"))) case res.Winner == m.human: fmt.Fprintln(&b, winStyle.Render(T("checkers.result.win"))) default: fmt.Fprintln(&b, errorStyle.Render(T("checkers.result.lose"))) } fmt.Fprintln(&b) fmt.Fprintln(&b, dimStyle.Render(T("checkers.hint.gameover"))) return b.String() } turnLabel := T("checkers.turn.you") if m.game.Turn != m.human { turnLabel = T("checkers.turn.bot") } fmt.Fprintln(&b, turnLabel) fmt.Fprintln(&b) if m.game.Turn == m.human { if m.selected == nil { fmt.Fprintln(&b, T("checkers.hint.noselect")) } else { fmt.Fprintln(&b, T("checkers.hint.selected")) } } fmt.Fprintln(&b) if m.message != "" { if m.isError { fmt.Fprintln(&b, errorStyle.Render("! "+m.message)) } else { fmt.Fprintln(&b, infoStyle.Render(m.message)) } } return b.String() }