348 lines
9 KiB
Go
348 lines
9 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
|
||
|
||
// 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 {
|
||
suits := []Suit{Clubs, Diamonds, Hearts, Spades}
|
||
boxes := make([][]string, 4)
|
||
for i, s := range suits {
|
||
rank := m.game.Foundations[s]
|
||
if rank == 0 {
|
||
top := "┌───┐"
|
||
bottom := "└───┘"
|
||
label := dimStyle.Render(" " + s.String() + " ")
|
||
boxes[i] = []string{dimStyle.Render(top), "│" + label + "│", "│ │", dimStyle.Render(bottom)}
|
||
continue
|
||
}
|
||
card := Card{Suit: s, Rank: Rank(rank)}
|
||
boxes[i] = renderCardBox(card, CardNormal)
|
||
}
|
||
lines := make([]string, 4)
|
||
for row := 0; row < 4; row++ {
|
||
parts := make([]string, 4)
|
||
for i := range suits {
|
||
parts[i] = boxes[i][row]
|
||
}
|
||
lines[row] = strings.Join(parts, " ")
|
||
}
|
||
return strings.Join(lines, "\n")
|
||
}
|
||
|
||
func (m KlondikeModel) renderStockWaste() string {
|
||
stockLine := Tf("klondike.stock_line", len(m.game.Stock))
|
||
if len(m.game.Waste) == 0 {
|
||
return stockLine + " " + dimStyle.Render(T("klondike.empty"))
|
||
}
|
||
top := m.game.Waste[len(m.game.Waste)-1]
|
||
highlight := CardNormal
|
||
if m.selected == klondikeWasteRef {
|
||
highlight = CardCursor
|
||
}
|
||
waste := strings.Join(renderCardBox(top, highlight), "\n")
|
||
return stockLine + " " + T("klondike.waste_label") + "\n" + waste
|
||
}
|
||
|
||
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()
|
||
}
|