go_games_collection/chess_tui.go
2026-07-10 09:12:06 +03:00

343 lines
8.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package main
import (
"fmt"
"math/rand"
"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, colorChoice ColorChoice) ChessModel {
human := resolveChessColor(colorChoice)
return ChessModel{
game: NewChessGame(),
bot: ChessBot{Depth: depth},
human: human,
cursor: ChessPos{Row: 7, Col: 4},
}
}
// resolveChessColor переводит выбор цвета из настроек в конкретный
// ChessColor — при случайном выборе бросает монету одноразовым
// генератором (партия сама по себе детерминирована, отдельный rng в
// модели ради этого не нужен).
func resolveChessColor(choice ColorChoice) ChessColor {
switch choice {
case ColorChoiceA:
return ChessWhite
case ColorChoiceB:
return ChessBlack
default:
rnd := rand.New(rand.NewSource(time.Now().UnixNano()))
if rnd.Intn(2) == 0 {
return ChessWhite
}
return ChessBlack
}
}
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()
}