package main import ( "fmt" "strings" "time" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) // botMoveMsg сигнализирует о том, что пора сделать очередной ход // бота — используется, чтобы ходы ботов были видны человеку с // небольшой задержкой, а не проваливались мгновенно. type botMoveMsg struct{} // ColorChoice — выбор стороны в играх с двумя цветами: конкретный // цвет (первый или второй в списке для этой игры) либо случайный // выбор при старте партии. Общий тип для настроек Шашек, Шахмат, // Го, Нард, Ура и Сенета — какая КОНКРЕТНО пара цветов ему // соответствует ("белые/чёрные" и т.п.), решает уже сама игра. type ColorChoice int const ( ColorChoiceA ColorChoice = iota ColorChoiceB ColorChoiceRandom ) // colorChoiceLabel возвращает переведённую подпись для текущего // выбора цвета; labelA/labelB — ключи переводов конкретных цветов // этой игры (порядок зависит от игры — например, в Го традиционно // первый ход за чёрными, поэтому там ColorChoiceA — "Чёрные"). func colorChoiceLabel(choice ColorChoice, labelAKey, labelBKey string) string { switch choice { case ColorChoiceA: return T(labelAKey) case ColorChoiceB: return T(labelBKey) default: return T("color.random") } } func (c *ColorChoice) cycle(delta int) { v := int(*c) + delta if v < 0 { v = 0 } if v > int(ColorChoiceRandom) { v = int(ColorChoiceRandom) } *c = ColorChoice(v) } const botMoveDelay = 500 * time.Millisecond // wrapText переносит длинный текст по ширине терминала — с разумным // диапазоном: не уже 40 колонок (совсем узкие терминалы) и не шире // 100 (сплошной текст на весь широкий терминал читать неудобно). // Общая для всех игр — используется там, где в интерфейсе // встречаются длинные абзацы (тексты квестов, сюжетные вставки и // т.п.), которые иначе просто уезжали бы за правый край экрана без // переноса строк. func wrapText(text string, termWidth int) string { width := termWidth - 4 if width < 40 { width = 60 // разумный запасной вариант, пока реальный размер ещё не пришёл через WindowSizeMsg } if width > 100 { width = 100 } return lipgloss.NewStyle().Width(width).Render(text) } 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()) } // CardHighlight — состояние подсветки одной карты в ряду. type CardHighlight int const ( CardNormal CardHighlight = iota CardCursor // текущая позиция курсора CardStaged // отложена под мелд (Тонк) — подчёркнута, цвет масти сохраняется ) // cardBoxContentWidth — внутренняя ширина рамочной карты (вариант // "B": ранг в левом верхнем углу, масть в правом нижнем). Подобрана // как компромисс между похожестью на настоящую карту и тем, чтобы // рука из нескольких карт не выходила за пределы узкого мобильного // терминала. const cardBoxContentWidth = 3 // cardsPerBoxRow — после скольких карт подряд рамочный ряд руки // переносится на новую строку (см. renderCardBoxHand). Число // фиксировано и не зависит от реальной ширины терминала — // сознательное упрощение: 6 карт в ряд при content=3 укладываются в // разумные ~35 символов на большинстве терминалов, а курсор при // переносе остаётся линейным индексом — перенос влияет только на // раскладку, не на навигацию. const cardsPerBoxRow = 6 // renderCardBox рисует одну карту рамкой в 4 строки: верхняя рамка, // ранг (выровнен по левому краю), масть (выровнена по правому // краю — по диагонали от ранга), нижняя рамка. // // Курсор подсвечивается цветом РАМКИ (не текста) — иначе ранг и // масть было бы не различить поверх инвертированных цветов, как // раньше в однострочном варианте. "Отложена под мелд" — наоборот, // подчёркнутый и жирный текст при обычной рамке, без инверсии цвета // масти (важно для Тонка: там нужно видеть, какая именно карта // отложена, не теряя красный/чёрный цвет). func renderCardBox(c Card, highlight CardHighlight) []string { rank := c.Rank.String() for len([]rune(rank)) < cardBoxContentWidth { rank += " " } suit := c.Suit.String() for len([]rune(suit)) < cardBoxContentWidth { suit = " " + suit } var rankText, suitText string switch highlight { case CardStaged: // один стиль на весь текст, а не Render() поверх уже // отрендеренной строки — вложенные ANSI-последовательности // ломаются в некоторых терминалах (см. tui.go, renderHand) st := cardColorStyle(c).Underline(true).Bold(true) rankText, suitText = st.Render(rank), st.Render(suit) default: style := cardColorStyle(c) rankText, suitText = style.Render(rank), style.Render(suit) } top := "┌" + strings.Repeat("─", cardBoxContentWidth) + "┐" bottom := "└" + strings.Repeat("─", cardBoxContentWidth) + "┘" if highlight == CardCursor { top = turnStyle.Render(top) bottom = turnStyle.Render(bottom) } return []string{ top, "│" + rankText + "│", "│" + suitText + "│", bottom, } } // cursorHighlights строит срез подсветки для игр, которым не нужно // ничего, кроме курсора: все карты обычные, кроме позиции cursor. func cursorHighlights(n, cursor int) []CardHighlight { hl := make([]CardHighlight, n) for i := range hl { if i == cursor { hl[i] = CardCursor } } return hl } // renderFaceDownBox рисует рамку карты рубашкой вниз (масть и ранг // неизвестны) — той же ширины, что и обычная renderCardBox, чтобы // ряды из известных и скрытых карт выравнивались друг с другом. func renderFaceDownBox() []string { top := "┌" + strings.Repeat("─", cardBoxContentWidth) + "┐" bottom := "└" + strings.Repeat("─", cardBoxContentWidth) + "┘" mid := dimStyle.Render(" ? ") return []string{top, "│" + mid + "│", "│" + mid + "│", bottom} } // renderCardBoxRow рисует один ряд карт рамками (без переноса — см. // renderCardBoxHand для руки произвольной длины), склеенных по // горизонтали через один пробел. func renderCardBoxRow(cards []Card, highlights []CardHighlight) string { if len(cards) == 0 { return "" } boxes := make([][]string, len(cards)) for i, c := range cards { h := CardNormal if i < len(highlights) { h = highlights[i] } boxes[i] = renderCardBox(c, h) } lines := make([]string, 4) for row := 0; row < 4; row++ { parts := make([]string, len(cards)) for i := range cards { parts[i] = boxes[i][row] } lines[row] = strings.Join(parts, " ") } return strings.Join(lines, "\n") } // renderCardBoxHand рисует руку карт произвольной длины, перенося // каждые cardsPerBoxRow карт на новую строку. Курсор/отложенные // карты остаются привязаны к линейному индексу в cards — перенос // влияет только на раскладку, не на то, как считается позиция. func renderCardBoxHand(cards []Card, highlights []CardHighlight) string { if len(cards) == 0 { return "" } var rows []string for start := 0; start < len(cards); start += cardsPerBoxRow { end := start + cardsPerBoxRow if end > len(cards) { end = len(cards) } rowHighlights := highlights if end <= len(highlights) { rowHighlights = highlights[start:end] } else if start < len(highlights) { rowHighlights = highlights[start:] } else { rowHighlights = nil } rows = append(rows, renderCardBoxRow(cards[start:end], rowHighlights)) } return strings.Join(rows, "\n\n") } func (m Model) renderHand() string { hand := m.game.current().Hand if len(hand) == 0 { return dimStyle.Render(T("common.no_cards")) } highlights := make([]CardHighlight, len(hand)) for i, c := range hand { switch { case i == m.cursor: highlights[i] = CardCursor case m.staged[c]: highlights[i] = CardStaged } } return renderCardBoxHand(hand, highlights) } 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") }