295 lines
6.8 KiB
Go
295 lines
6.8 KiB
Go
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"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("94"))
|
|
checkersWhitePiece = lipgloss.NewStyle().Foreground(lipgloss.Color("255")).Bold(true)
|
|
checkersBlackPiece = lipgloss.NewStyle().Foreground(lipgloss.Color("16")).Bold(true)
|
|
checkersCursorStyle = lipgloss.NewStyle().Background(lipgloss.Color("220")).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) CheckersModel {
|
|
return CheckersModel{
|
|
game: NewCheckersGame(),
|
|
bot: CheckersBot{Depth: depth},
|
|
human: CheckersWhite,
|
|
cursor: CheckersPos{Row: 5, Col: 0},
|
|
}
|
|
}
|
|
|
|
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 = 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("Бот сходил.")
|
|
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("Ход выполнен.")
|
|
if m.game.ChainPos != nil {
|
|
pos := *m.game.ChainPos
|
|
m.selected = &pos
|
|
m.cursor = pos
|
|
} else {
|
|
m.selected = nil
|
|
}
|
|
return m, m.maybeScheduleBot()
|
|
}
|
|
|
|
// --- Отрисовка -----------------------------------------------------
|
|
|
|
func pieceGlyph(p *CheckersPiece) string {
|
|
if p == nil {
|
|
return " "
|
|
}
|
|
glyph := "o"
|
|
if p.King {
|
|
glyph = "O"
|
|
}
|
|
style := checkersBlackPiece
|
|
if p.Color == CheckersWhite {
|
|
style = checkersWhitePiece
|
|
}
|
|
return style.Render(glyph)
|
|
}
|
|
|
|
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
|
|
}
|
|
glyph := pieceGlyph(m.game.Board[r][c])
|
|
|
|
switch {
|
|
case m.selected != nil && *m.selected == pos:
|
|
cellStyle = checkersSelStyle
|
|
case dests[pos]:
|
|
cellStyle = checkersSelStyle
|
|
case m.cursor == pos:
|
|
cellStyle = checkersCursorStyle
|
|
}
|
|
fmt.Fprint(&b, cellStyle.Render(" "+glyph+" "))
|
|
}
|
|
fmt.Fprintln(&b)
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
func (m CheckersModel) View() string {
|
|
if m.quitting {
|
|
return "До встречи!\n"
|
|
}
|
|
|
|
var b strings.Builder
|
|
fmt.Fprintln(&b, titleStyle.Render("=== ШАШКИ ==="))
|
|
fmt.Fprintln(&b)
|
|
fmt.Fprintln(&b, m.renderBoard())
|
|
fmt.Fprintln(&b)
|
|
|
|
if m.game.Result != nil {
|
|
res := m.game.Result
|
|
switch {
|
|
case res.Draw:
|
|
fmt.Fprintln(&b, errorStyle.Render("Ничья (слишком долго не было взятий)."))
|
|
case res.Winner == m.human:
|
|
fmt.Fprintln(&b, winStyle.Render("Вы выиграли!"))
|
|
default:
|
|
fmt.Fprintln(&b, errorStyle.Render("Победил бот."))
|
|
}
|
|
fmt.Fprintln(&b)
|
|
fmt.Fprintln(&b, dimStyle.Render("[n] новая партия [q] в меню"))
|
|
return b.String()
|
|
}
|
|
|
|
turnLabel := "Ваш ход (белые)"
|
|
if m.game.Turn != m.human {
|
|
turnLabel = "Бот думает (чёрные)..."
|
|
}
|
|
fmt.Fprintln(&b, turnLabel)
|
|
fmt.Fprintln(&b)
|
|
|
|
if m.game.Turn == m.human {
|
|
if m.selected == nil {
|
|
fmt.Fprintln(&b, "[↑↓←→/hjkl] курсор [enter] выбрать шашку [q] в меню")
|
|
} else {
|
|
fmt.Fprintln(&b, "[↑↓←→/hjkl] курсор [enter] переместить (или отменить выбор) [esc] отменить выбор")
|
|
}
|
|
}
|
|
|
|
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()
|
|
}
|