go_games_collection/klondike_tui.go
2026-07-04 13:51:04 +03:00

306 lines
7.6 KiB
Go

package main
import (
"fmt"
"strings"
tea "github.com/charmbracelet/bubbletea"
)
// klondikePileRef — ссылка на стопку: колонна (0-6), отбой (-1) или
// "ничего не выбрано" (-2).
type klondikePileRef int
const (
klondikeNoSelection klondikePileRef = -2
klondikeWasteRef klondikePileRef = -1
)
// KlondikeModel — TUI поверх KlondikeGameState. Управление в два
// шага: сначала выбираем источник (стопку, откуда берём карты),
// затем — куда их переложить.
type KlondikeModel struct {
game *KlondikeGameState
selected klondikePileRef
message string
isError bool
quitting bool
backToMenu bool
}
// NewKlondikeModel создаёт новую партию пасьянса.
func NewKlondikeModel() KlondikeModel {
return KlondikeModel{
game: NewKlondikeGame(),
selected: klondikeNoSelection,
}
}
func (m KlondikeModel) Init() tea.Cmd {
return nil
}
func (m *KlondikeModel) setInfo(format string, args ...interface{}) {
m.message = fmt.Sprintf(format, args...)
m.isError = false
}
func (m *KlondikeModel) setError(err error) {
m.message = err.Error()
m.isError = true
}
func (m *KlondikeModel) clearSelection() {
m.selected = klondikeNoSelection
}
func (m KlondikeModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
keyMsg, ok := msg.(tea.KeyMsg)
if !ok {
return m, nil
}
return m.handleKey(keyMsg)
}
func (m KlondikeModel) 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.Won {
if key == "n" {
m.game = NewKlondikeGame()
m.clearSelection()
m.message = ""
m.isError = false
}
return m, nil
}
switch key {
case "n":
m.game = NewKlondikeGame()
m.clearSelection()
m.message = ""
m.isError = false
return m, nil
case "u":
if err := m.game.Undo(); err != nil {
m.setError(err)
return m, nil
}
m.clearSelection()
m.setInfo("Ход отменён.")
return m, nil
case "esc":
m.clearSelection()
m.message = ""
return m, nil
case "d":
m.clearSelection()
if err := m.game.DrawStock(); err != nil {
m.setError(err)
return m, nil
}
m.setInfo("Взяли карту из колоды.")
return m, nil
case "1", "2", "3", "4", "5", "6", "7":
col := int(key[0] - '1')
return m.handlePileKey(klondikePileRef(col))
case "0":
return m.handlePileKey(klondikeWasteRef)
case "f":
return m.handleFoundationKey()
}
return m, nil
}
// handlePileKey обрабатывает нажатие клавиши колонны (1-7) или
// отбоя (0): если ничего не выбрано — пытается выбрать эту стопку
// как источник; если что-то уже выбрано — пытается переложить туда.
func (m KlondikeModel) handlePileKey(ref klondikePileRef) (tea.Model, tea.Cmd) {
if m.selected == klondikeNoSelection {
if !m.pileHasMovableCard(ref) {
m.setError(ErrKlondikeEmptySource)
return m, nil
}
m.selected = ref
m.message = ""
return m, nil
}
if m.selected == ref {
m.clearSelection()
return m, nil
}
var err error
if m.selected == klondikeWasteRef {
err = m.game.MoveWasteToTableau(int(ref))
} else {
err = m.game.MoveTableauToTableau(int(m.selected), int(ref))
}
if err != nil {
m.setError(err)
return m, nil
}
m.setInfo("Ход выполнен.")
m.clearSelection()
return m, nil
}
func (m KlondikeModel) handleFoundationKey() (tea.Model, tea.Cmd) {
if m.selected == klondikeNoSelection {
m.setError(ErrKlondikeEmptySource)
return m, nil
}
var err error
if m.selected == klondikeWasteRef {
err = m.game.MoveWasteToFoundation()
} else {
if m.game.movableRun(int(m.selected)) != 1 {
err = ErrKlondikeMultiToFoundation
} else {
err = m.game.MoveTableauToFoundation(int(m.selected))
}
}
if err != nil {
m.setError(err)
return m, nil
}
m.setInfo("Карта отправлена на фундамент.")
m.clearSelection()
return m, nil
}
func (m KlondikeModel) pileHasMovableCard(ref klondikePileRef) bool {
if ref == klondikeWasteRef {
return len(m.game.Waste) > 0
}
return m.game.movableRun(int(ref)) > 0
}
// --- Отрисовка -----------------------------------------------------
func (m KlondikeModel) renderFoundations() string {
var parts []string
suits := []Suit{Clubs, Diamonds, Hearts, Spades}
for _, s := range suits {
rank := m.game.Foundations[s]
if rank == 0 {
parts = append(parts, dimStyle.Render("["+s.String()+" --]"))
continue
}
card := Card{Suit: s, Rank: Rank(rank)}
parts = append(parts, "["+renderCard(card)+"]")
}
return strings.Join(parts, " ")
}
func (m KlondikeModel) renderStockWaste() string {
stockStr := dimStyle.Render(fmt.Sprintf("(%d)", len(m.game.Stock)))
wasteStr := dimStyle.Render("(пусто)")
if len(m.game.Waste) > 0 {
top := m.game.Waste[len(m.game.Waste)-1]
wasteStr = renderCard(top)
if m.selected == klondikeWasteRef {
wasteStr = cursorStyle.Render(top.String())
}
}
return fmt.Sprintf("Колода: %s Отбой: %s", stockStr, wasteStr)
}
func (m KlondikeModel) renderTableau() string {
var rows []string
maxLen := 0
for _, col := range m.game.Tableau {
if len(col) > maxLen {
maxLen = len(col)
}
}
header := make([]string, 7)
for i := range header {
label := fmt.Sprintf(" %d ", i+1)
if m.selected == klondikePileRef(i) {
label = cursorStyle.Render(label)
} else {
label = dimStyle.Render(label)
}
header[i] = label
}
rows = append(rows, strings.Join(header, " "))
for row := 0; row < maxLen; row++ {
cells := make([]string, 7)
for col := 0; col < 7; col++ {
if row >= len(m.game.Tableau[col]) {
cells[col] = " "
continue
}
kc := m.game.Tableau[col][row]
if !kc.FaceUp {
cells[col] = dimStyle.Render("[?]")
continue
}
text := renderCard(kc.Card)
isTopOfSelectedRun := m.selected == klondikePileRef(col) &&
row >= len(m.game.Tableau[col])-m.game.movableRun(col)
if isTopOfSelectedRun {
text = cursorStyle.Render(kc.Card.String())
}
cells[col] = text
}
rows = append(rows, strings.Join(cells, " "))
}
return strings.Join(rows, "\n")
}
func (m KlondikeModel) View() string {
if m.quitting {
return "До встречи!\n"
}
var b strings.Builder
fmt.Fprintln(&b, titleStyle.Render("=== КОСЫНКА ==="))
fmt.Fprintln(&b)
fmt.Fprintf(&b, "Фундаменты: %s\n", m.renderFoundations())
fmt.Fprintln(&b, m.renderStockWaste())
fmt.Fprintf(&b, "Ходов: %d\n\n", m.game.Moves)
fmt.Fprintln(&b, m.renderTableau())
fmt.Fprintln(&b)
if m.game.Won {
fmt.Fprintln(&b, winStyle.Render(fmt.Sprintf("Пасьянс сошёлся за %d ходов! Поздравляем!", m.game.Moves)))
fmt.Fprintln(&b)
fmt.Fprintln(&b, dimStyle.Render("[n] новая партия [q] в меню"))
return b.String()
}
if m.selected == klondikeNoSelection {
fmt.Fprintln(&b, "[1-7] выбрать колонну [0] выбрать отбой [d] взять из колоды")
} else {
fmt.Fprintln(&b, "[1-7] переложить в колонну [f] на фундамент [esc] отменить выбор [d] взять из колоды")
}
fmt.Fprintln(&b, dimStyle.Render("[u] отменить ход [n] новая партия [q] в меню"))
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()
}