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 = T(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(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() } 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(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.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(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 } 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 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")) } 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() }