package main import ( "fmt" "strings" "time" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) // botMoveMsg сигнализирует о том, что пора сделать очередной ход // бота — используется, чтобы ходы ботов были видны человеку с // небольшой задержкой, а не проваливались мгновенно. type botMoveMsg struct{} const botMoveDelay = 500 * time.Millisecond var ( titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("212")) redCardStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("203")) blackCardStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("255")) cursorStyle = lipgloss.NewStyle().Reverse(true).Bold(true) dimStyle = lipgloss.NewStyle().Faint(true) infoStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("87")) errorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Bold(true) turnStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("228")).Bold(true) winStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("46")).Bold(true) ) // Model — состояние TUI поверх GameState. type Model struct { game *GameState humanIdx int bots map[int]BotStrategy cursor int staged map[Card]bool message string isError bool quitting bool backToMenu bool } // NewModel создаёт новую партию со стандартными денежными // параметрами: человек — игрок 0, дальше три бота со случайными // именами и случайной (не подсказывающей ничего) комбинацией // уровней сложности. func NewModel() Model { return NewModelWithStakes(DefaultStartingCapital, DefaultAnte, DefaultTonkMultiplier) } // NewModelWithStakes создаёт новую партию с явно заданными // денежными параметрами (стартовый капитал, размер ставки, множитель // банка при победе через Тонк) — используется экраном настроек // перед началом игры. func NewModelWithStakes(startingCapital, ante, tonkMultiplier int) Model { botNames, botStrategies := newBotRoster(3) names := append([]string{T("common.you")}, botNames...) game := NewGameWithStakes(names, 0, startingCapital, ante, tonkMultiplier) m := Model{ game: game, humanIdx: 0, bots: map[int]BotStrategy{ 1: botStrategies[0], 2: botStrategies[1], 3: botStrategies[2], }, staged: make(map[Card]bool), } return m } func (m Model) Init() tea.Cmd { return m.maybeScheduleBot() } // maybeScheduleBot возвращает команду, запускающую ход бота, если // сейчас не очередь человека и раунд не завершён. func (m Model) maybeScheduleBot() tea.Cmd { if m.game.Phase == PhaseGameOver { return nil } if m.game.CurrentPlayer == m.humanIdx { return nil } return tea.Tick(botMoveDelay, func(time.Time) tea.Msg { return botMoveMsg{} }) } func (m *Model) resetSelectionState() { m.staged = make(map[Card]bool) if len(m.game.current().Hand) == 0 { m.cursor = 0 } else if m.cursor >= len(m.game.current().Hand) { m.cursor = len(m.game.current().Hand) - 1 } } func (m *Model) setInfo(format string, args ...interface{}) { m.message = fmt.Sprintf(format, args...) m.isError = false } func (m *Model) setError(err error) { m.message = T(err.Error()) m.isError = true } func (m Model) 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 Model) handleBotMove() (tea.Model, tea.Cmd) { if m.game.Phase == PhaseGameOver { return m, nil } idx := m.game.CurrentPlayer bot, ok := m.bots[idx] if !ok { // на текущего игрока бот не назначен (это человек) — // ничего не делаем, ждём ввода с клавиатуры return m, nil } if err := bot.PlayTurn(m.game); err != nil { m.setError(fmt.Errorf(T("бот %s: %w"), m.game.Players[idx].Name, err)) return m, nil } m.setInfo(T("tonk.msg.bot_moved"), m.game.Players[idx].Name) if m.game.Phase != PhaseGameOver && m.game.CurrentPlayer == m.humanIdx { m.resetSelectionState() } return m, m.maybeScheduleBot() } func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { key := msg.String() if key == "ctrl+c" { m.quitting = true return m, tea.Quit } if m.game.Phase == PhaseGameOver { switch key { case "n": m.startNewRound() return m, m.maybeScheduleBot() case "q": m.backToMenu = true return m, nil } return m, nil } if key == "q" { m.backToMenu = true return m, nil } if m.game.CurrentPlayer != m.humanIdx { // сейчас ходит бот — игнорируем ввод return m, nil } switch m.game.Phase { case PhaseDraw: return m.handleDrawKey(key) case PhaseAction: return m.handleActionKey(key) } return m, nil } func (m Model) handleDrawKey(key string) (tea.Model, tea.Cmd) { switch key { case "d": if _, err := m.game.DrawFromDeck(); err != nil { m.setError(err) return m, nil } m.setInfo(T("tonk.msg.drew_deck")) m.resetSelectionState() case "p": card, err := m.game.DrawFromDiscard() if err != nil { m.setError(err) return m, nil } m.setInfo(T("tonk.msg.drew_discard"), card) m.resetSelectionState() case "k": res, err := m.game.Drop() if err != nil { m.setError(err) return m, nil } if res.DropCaught { m.setInfo(T("tonk.msg.drop_caught"), m.game.Players[res.Winner].Name) } else { m.setInfo(T("tonk.msg.drop_success")) } } return m, nil } func (m Model) handleActionKey(key string) (tea.Model, tea.Cmd) { hand := m.game.current().Hand switch key { case "left", "h": if m.cursor > 0 { m.cursor-- } case "right", "l": if m.cursor < len(hand)-1 { m.cursor++ } case " ": if len(hand) == 0 { return m, nil } c := hand[m.cursor] if m.staged[c] { delete(m.staged, c) } else { m.staged[c] = true } case "m": return m.handleLayMeld() case "t": res, err := m.game.DeclareTonk() if err != nil { m.setError(err) return m, nil } m.setInfo(T("tonk.msg.tonk_declared")) _ = res return m, m.maybeScheduleBot() case "enter": if len(hand) == 0 { return m, nil } card := hand[m.cursor] if err := m.game.Discard(card); err != nil { m.setError(err) return m, nil } m.setInfo(T("tonk.msg.discarded"), card) if m.game.Phase != PhaseGameOver { m.resetSelectionState() } return m, m.maybeScheduleBot() case "1", "2", "3", "4", "5", "6", "7", "8", "9": idx := int(key[0] - '1') return m.handleAddToMeld(idx) } return m, nil } func (m Model) handleLayMeld() (tea.Model, tea.Cmd) { if len(m.staged) < 3 { m.setError(fmt.Errorf(T("выберите (пробел) минимум 3 карты для комбинации"))) return m, nil } cards := make([]Card, 0, len(m.staged)) for c := range m.staged { cards = append(cards, c) } if err := m.game.LayNewMeld(cards); err != nil { m.setError(err) return m, nil } m.setInfo(T("tonk.msg.meld_laid")) if m.game.Phase != PhaseGameOver { m.resetSelectionState() return m, nil } return m, m.maybeScheduleBot() } func (m Model) handleAddToMeld(meldIdx int) (tea.Model, tea.Cmd) { hand := m.game.current().Hand if len(hand) == 0 { return m, nil } if meldIdx < 0 || meldIdx >= len(m.game.Table) { m.setError(fmt.Errorf(T("на столе нет комбинации №%d"), meldIdx+1)) return m, nil } card := hand[m.cursor] if err := m.game.AddToMeld(meldIdx, card); err != nil { m.setError(err) return m, nil } m.setInfo(T("tonk.msg.card_added"), card, meldIdx+1) if m.game.Phase != PhaseGameOver { m.resetSelectionState() return m, nil } return m, m.maybeScheduleBot() } // startNewRound начинает новый раунд, перенося капитал игроков из // завершившегося раунда и заново собирая анте в банк. Перед этим // проверяет, не остался ли кто-то без денег (см. checkBankruptcies) // — такие игроки заменяются новыми ботами случайного уровня со // стартовым капиталом. func (m *Model) startNewRound() { bankruptMsg := m.checkBankruptcies() m.game = m.game.NextRound() m.staged = make(map[Card]bool) m.cursor = 0 if bankruptMsg != "" { m.setInfo("%s", bankruptMsg) } else { m.message = "" m.isError = false } } // checkBankruptcies проверяет капитал каждого игрока после // завершившегося раунда: у кого баланс опустился до нуля или ниже, // тот выбывает и заменяется новым ботом — со случайным именем // (по возможности не повторяющим тех, кто уже сидит за столом) и // случайным уровнем сложности, который нигде не показывается. Если // выбывает сам человек, его место тоже занимает бот, и humanIdx // становится -1 (партия продолжается уже полностью автоматически, // человек может понаблюдать или выйти в меню). Возвращает текстовое // сообщение об произошедших заменах (пустая строка, если // банкротств не было). func (m *Model) checkBankruptcies() string { var messages []string for i, p := range m.game.Players { if p.Balance > 0 { continue } used := make(map[string]bool, len(m.game.Players)) for _, other := range m.game.Players { used[other.Name] = true } newName := pickBotName(used) bot, _ := randomBotStrategy() wasHuman := p.IsHuman oldName := p.Name p.Name = newName p.Balance = m.game.StartingCapital p.IsHuman = false m.bots[i] = bot if wasHuman { m.humanIdx = -1 messages = append(messages, fmt.Sprintf(T("tonk.bankrupt.human"), newName)) } else { messages = append(messages, fmt.Sprintf(T("tonk.bankrupt.bot"), oldName, newName, p.Balance)) } } return strings.Join(messages, " ") } func isRed(s Suit) bool { return s == Hearts || s == Diamonds } // cardColorStyle возвращает базовый (только цвет) стиль карты, не // применяя его — используется, когда нужно доклеить к цвету ещё // какие-то атрибуты (подчёркивание и т.п.) одним стилем, вместо // вложенных вызовов Render (что ломается в некоторых терминалах). func cardColorStyle(c Card) lipgloss.Style { if isRed(c.Suit) { return redCardStyle } return blackCardStyle } func renderCard(c Card) string { return cardColorStyle(c).Render(c.String()) } func (m Model) renderHand() string { hand := m.game.current().Hand if len(hand) == 0 { return dimStyle.Render(T("common.no_cards")) } parts := make([]string, len(hand)) for i, c := range hand { var text string switch { case i == m.cursor: // курсор — поверх обычного (нецветного) текста карты, // как и раньше text = cursorStyle.Render(c.String()) case m.staged[c]: // важно: цвет и подчёркивание в ОДНОМ стиле, а не // Render() поверх уже отрендеренной (цветной) строки — // вложенные ANSI-последовательности ломаются в // некоторых терминалах (вложенный сброс обрывает // внешний стиль раньше времени) text = cardColorStyle(c).Underline(true).Bold(true).Render(c.String()) default: text = renderCard(c) } parts[i] = text } return strings.Join(parts, " ") } func (m Model) renderTable() string { if len(m.game.Table) == 0 { return dimStyle.Render(T("tonk.table_empty")) } var b strings.Builder for i, meld := range m.game.Table { kind := T("tonk.set") if meld.Kind == KindSequence { kind = T("tonk.sequence") } cardsStr := make([]string, len(meld.Cards)) for j, c := range meld.Cards { cardsStr[j] = renderCard(c) } fmt.Fprintf(&b, T("tonk.meld_line"), i+1, kind, m.game.Players[meld.Owner].Name, strings.Join(cardsStr, " ")) } return strings.TrimRight(b.String(), "\n") } func (m Model) renderPlayers() string { var b strings.Builder for i, p := range m.game.Players { marker := " " if i == m.game.CurrentPlayer { marker = "» " } name := p.Name line := fmt.Sprintf(T("tonk.player_line"), marker, name, len(p.Hand), p.Balance) if i == m.game.CurrentPlayer { line = turnStyle.Render(line) } fmt.Fprintln(&b, line) } return strings.TrimRight(b.String(), "\n") } func (m Model) View() string { if m.quitting { return T("common.goodbye") } var b strings.Builder fmt.Fprintln(&b, titleStyle.Render(T("tonk.title"))) fmt.Fprintln(&b) fmt.Fprintln(&b, m.renderPlayers()) fmt.Fprintln(&b) top, ok := m.game.PeekDiscard() topStr := "—" if ok { topStr = renderCard(top) } fmt.Fprintf(&b, T("tonk.status_line"), m.game.Deck.Len(), topStr, m.game.Pot, m.game.AnteAmount, m.game.TonkMultiplier) fmt.Fprintln(&b, T("tonk.melds_label")) fmt.Fprintln(&b, m.renderTable()) fmt.Fprintln(&b) if m.game.Phase == PhaseGameOver { fmt.Fprintln(&b, m.renderRoundResult()) fmt.Fprintln(&b) fmt.Fprintln(&b, dimStyle.Render(T("tonk.hint.gameover"))) return b.String() } if m.game.CurrentPlayer == m.humanIdx { fmt.Fprintln(&b, T("common.your_hand")) fmt.Fprintln(&b, m.renderHand()) fmt.Fprintf(&b, T("tonk.hand_weight"), HandValue(m.game.current().Hand)) switch m.game.Phase { case PhaseDraw: fmt.Fprintln(&b, Tf("tonk.hint.draw", topStr)) case PhaseAction: tonkHint := "" if CanDeclareTonk(m.game.current().Hand, m.game.TonkThreshold) { tonkHint = winStyle.Render(T("tonk.hint.tonk_available")) } fmt.Fprintln(&b, T("tonk.hint.action1")) fmt.Fprintln(&b, T("tonk.hint.action2")+tonkHint) } } else { fmt.Fprintf(&b, T("common.thinking"), m.game.Players[m.game.CurrentPlayer].Name) } 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() } func (m Model) renderRoundResult() string { res := m.game.Result var b strings.Builder switch { case res.DeadRound: fmt.Fprintln(&b, errorStyle.Render(Tf("tonk.result.dead", res.Pot))) case res.TonkCall: fmt.Fprintln(&b, winStyle.Render(Tf("tonk.result.tonk", m.game.Players[res.Winner].Name, m.game.TonkMultiplier, res.Pot*m.game.TonkMultiplier))) case res.DropCall && res.DropCaught: fmt.Fprintln(&b, errorStyle.Render(Tf("tonk.result.drop_caught", m.game.Players[res.Winner].Name))) case res.DropCall: fmt.Fprintln(&b, winStyle.Render(Tf("tonk.result.drop_success", m.game.Players[res.Winner].Name, res.Pot))) default: fmt.Fprintln(&b, winStyle.Render(Tf("tonk.result.win", m.game.Players[res.Winner].Name, res.Pot))) } for i, p := range m.game.Players { fmt.Fprintf(&b, T("tonk.result.delta_line"), p.Name, res.BalanceDeltas[i], p.Balance) } return strings.TrimRight(b.String(), "\n") }