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

337 lines
8.5 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"
)
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)
return CheckersModel{
game: NewCheckersGame(),
bot: CheckersBot{Depth: depth},
human: human,
cursor: CheckersPos{Row: 5, 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
}
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 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()
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 := 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()
}