package main import ( "fmt" "strings" "time" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) // ChessModel — TUI поверх ChessGameState. Человек всегда играет // белыми (ходят первыми), бот — чёрными. Превращение пешки // автоматическое (всегда в ферзя, см. примечание в chess_game.go), // поэтому отдельного шага выбора фигуры превращения не требуется. type ChessModel struct { game *ChessGameState bot ChessBot human ChessColor cursor ChessPos selected *ChessPos message string isError bool quitting bool backToMenu bool } // NewChessModel создаёт новую партию: человек играет белыми. depth — // глубина поиска бота в полуходах (0 -> значение по умолчанию). func NewChessModel(depth int) ChessModel { return ChessModel{ game: NewChessGame(), bot: ChessBot{Depth: depth}, human: ChessWhite, cursor: ChessPos{Row: 7, Col: 4}, } } func (m ChessModel) Init() tea.Cmd { return m.maybeScheduleBot() } func (m ChessModel) 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 *ChessModel) setInfo(format string, args ...interface{}) { m.message = fmt.Sprintf(format, args...) m.isError = false } func (m *ChessModel) setError(err error) { m.message = T(err.Error()) m.isError = true } func (m ChessModel) 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 ChessModel) 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("chess.msg.bot_moved")) return m, m.maybeScheduleBot() } func (m ChessModel) 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 = NewChessGame() m.selected = nil m.message = "" m.isError = false return m, m.maybeScheduleBot() } return m, nil } if m.game.Turn != m.human { return m, nil } switch key { case "up", "k": if m.cursor.Row > 0 { m.cursor.Row-- } case "down", "j": if m.cursor.Row < 7 { m.cursor.Row++ } case "left", "h": if m.cursor.Col > 0 { m.cursor.Col-- } case "right", "l": 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 ChessModel) 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(ErrChessNoPiece) return m, nil } if len(m.game.LegalMovesFrom(m.cursor)) == 0 { m.setError(ErrChessInvalidMove) 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("chess.msg.moved")) m.selected = nil return m, m.maybeScheduleBot() } // --- Отрисовка ----------------------------------------------------- // chessGlyphs — буквы фигур по стандартной шахматной нотации: // заглавные для белых, строчные для чёрных (цвет дополнительно // подчёркивается стилем текста). Сознательно НЕ используются // символы Unicode ♔♕♖♗♘♙ — у них неоднозначная ширина отображения // в разных шрифтах/рендерерах (где-то один символ на ширину клетки, // где-то два), из-за чего клетки с фигурами визуально "сжимались" // относительно пустых клеток. Буквы ASCII гарантированно имеют // одинаковую ширину везде. var chessGlyphs = map[ChessPieceType][2]string{ ChessKing: {"K", "k"}, ChessQueen: {"Q", "q"}, ChessRook: {"R", "r"}, ChessBishop: {"B", "b"}, ChessKnight: {"N", "n"}, ChessPawn: {"P", "p"}, } // chessGlyphChar возвращает "сырую" (без цвета) букву фигуры — // используется вместе с chessPieceForegroundColor, чтобы построить // один комбинированный стиль (фон клетки + цвет фигуры) вместо // вложенных вызовов Render(), которые ломаются в некоторых // терминалах. func chessGlyphChar(p *ChessPiece) string { if p == nil { return " " } pair := chessGlyphs[p.Type] if p.Color == ChessBlack { return pair[1] } return pair[0] } // chessPieceForegroundColor возвращает цвет буквы фигуры (второе // значение — false, если клетка пуста). func chessPieceForegroundColor(p *ChessPiece) (lipgloss.Color, bool) { if p == nil { return "", false } if p.Color == ChessWhite { return checkersWhitePieceColor, true } return checkersBlackPieceColor, true } func (m ChessModel) legalDestinationSet() map[ChessPos]bool { set := map[ChessPos]bool{} if m.selected == nil { return set } for _, opt := range m.game.LegalMovesFrom(*m.selected) { set[opt.To] = true } return set } func (m ChessModel) renderBoard() string { dests := m.legalDestinationSet() var b strings.Builder fmt.Fprintln(&b, " a b c d e f g h") for r := 0; r < 8; r++ { fmt.Fprintf(&b, "%d ", 8-r) for c := 0; c < 8; c++ { pos := ChessPos{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 := chessGlyphChar(piece) combined := cellStyle if fg, ok := chessPieceForegroundColor(piece); ok { combined = cellStyle.Foreground(fg).Bold(true) } fmt.Fprint(&b, combined.Render(" "+text+" ")) } fmt.Fprintln(&b) } return b.String() } func (m ChessModel) View() string { if m.quitting { return T("common.goodbye") } var b strings.Builder fmt.Fprintln(&b, titleStyle.Render(T("chess.title"))) fmt.Fprintln(&b) fmt.Fprintln(&b, m.renderBoard()) fmt.Fprintln(&b, dimStyle.Render(T("chess.legend"))) fmt.Fprintln(&b) if m.game.Result != nil { res := m.game.Result switch { case res.Checkmate && res.Winner == m.human: fmt.Fprintln(&b, winStyle.Render(T("chess.result.win_checkmate"))) case res.Checkmate: fmt.Fprintln(&b, errorStyle.Render(T("chess.result.lose_checkmate"))) case res.Draw: fmt.Fprintln(&b, errorStyle.Render(Tf("chess.result.draw", T("chess.drawreason."+res.DrawReason)))) } fmt.Fprintln(&b) fmt.Fprintln(&b, dimStyle.Render(T("chess.hint.gameover"))) return b.String() } if m.game.inCheck(m.game.Turn) { fmt.Fprintln(&b, errorStyle.Render(T("chess.check"))) } turnLabel := T("chess.turn.you") if m.game.Turn != m.human { turnLabel = T("chess.turn.bot") } fmt.Fprintln(&b, turnLabel) fmt.Fprintln(&b) if m.game.Turn == m.human { if m.selected == nil { fmt.Fprintln(&b, T("chess.hint.noselect")) } else { fmt.Fprintln(&b, T("chess.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() }