322 lines
8.1 KiB
Go
322 lines
8.1 KiB
Go
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()
|
||
}
|