go_games_collection/klondike_tui.go

336 lines
8.7 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"
"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
// runLen — сколько карт с конца выбранной колонны-источника
// сейчас включено в перенос (от 1 до полной корректной
// последовательности movableRun). Меняется клавишами ↑/↓, пока
// выбрана колонна (для отбоя не имеет смысла — там всегда 1).
runLen int
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 = T(err.Error())
m.isError = true
}
func (m *KlondikeModel) clearSelection() {
m.selected = klondikeNoSelection
m.runLen = 0
}
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(T("klondike.msg.undone"))
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(T("klondike.msg.drew"))
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()
case "up", "k":
if m.selected != klondikeNoSelection && m.selected != klondikeWasteRef && m.runLen < m.game.movableRun(int(m.selected)) {
m.runLen++
}
return m, nil
case "down", "j":
if m.selected != klondikeNoSelection && m.selected != klondikeWasteRef && m.runLen > 1 {
m.runLen--
}
return m, nil
}
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
if ref == klondikeWasteRef {
m.runLen = 1
} else {
m.runLen = m.game.movableRun(int(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.MoveTableauToTableauN(int(m.selected), int(ref), m.runLen)
}
if err != nil {
m.setError(err)
return m, nil
}
m.setInfo(T("klondike.msg.moved"))
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.runLen != 1 {
err = ErrKlondikeMultiToFoundation
} else {
err = m.game.MoveTableauToFoundation(int(m.selected))
}
}
if err != nil {
m.setError(err)
return m, nil
}
m.setInfo(T("klondike.msg.to_foundation"))
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(T("klondike.empty"))
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(T("klondike.status_line"), 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
}
// паддинг до фиксированной ширины 3 (как у "[?]" и самой
// длинной карты "10♠") ДО применения цвета — иначе более
// короткие карты (например "A♠", 2 символа против 3 у
// "10♠") сдвигали бы соседние колонки в той же строке
padded := fmt.Sprintf("%-3s", kc.Card.String())
text := cardColorStyle(kc.Card).Render(padded)
isTopOfSelectedRun := m.selected == klondikePileRef(col) &&
row >= len(m.game.Tableau[col])-m.runLen
if isTopOfSelectedRun {
text = cursorStyle.Render(padded)
}
cells[col] = text
}
rows = append(rows, strings.Join(cells, " "))
}
return strings.Join(rows, "\n")
}
func (m KlondikeModel) View() string {
if m.quitting {
return T("common.goodbye")
}
var b strings.Builder
fmt.Fprintln(&b, titleStyle.Render(T("klondike.title")))
fmt.Fprintln(&b)
fmt.Fprintf(&b, T("klondike.foundations_line"), m.renderFoundations())
fmt.Fprintln(&b, m.renderStockWaste())
fmt.Fprintf(&b, T("klondike.moves_line"), m.game.Moves)
fmt.Fprintln(&b, m.renderTableau())
fmt.Fprintln(&b)
if m.game.Won {
fmt.Fprintln(&b, winStyle.Render(Tf("klondike.won", m.game.Moves)))
fmt.Fprintln(&b)
fmt.Fprintln(&b, dimStyle.Render(T("klondike.hint.gameover")))
return b.String()
}
if m.selected == klondikeNoSelection {
fmt.Fprintln(&b, T("klondike.hint.noselect"))
} else {
fmt.Fprintln(&b, T("klondike.hint.selected"))
if m.selected != klondikeWasteRef && m.game.movableRun(int(m.selected)) > 1 {
fmt.Fprintln(&b, dimStyle.Render(Tf("klondike.hint.runlen", m.runLen, m.game.movableRun(int(m.selected)))))
}
}
fmt.Fprintln(&b, dimStyle.Render(T("klondike.hint.footer")))
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()
}