package main import ( "fmt" "strings" "time" tea "github.com/charmbracelet/bubbletea" ) // DurakModel — TUI поверх DurakGameState. Действующее лицо всегда // одно: атакующий (обязательный первый ход раунда), защищающийся // (отбить/забрать) либо очередной подкидывающий (подкинуть/пасовать // в фазе DurakPhaseThrowIn) — определяется через currentActor(). type DurakModel struct { game *DurakGameState humanIdx int bot DurakBot cursor int message string isError bool quitting bool backToMenu bool // storyNonFoolStreak — сколько подряд партий в этой сессии // человек НЕ оказывался дураком (сбрасывается при первом же // поражении). Нужно только сторимоду; на обычную игру не влияет. storyNonFoolStreak int } // NewDurakModel создаёт новую партию Дурака на numPlayers игроков // (2-6, включая человека) со случайными именами ботов. func NewDurakModel(numPlayers int) DurakModel { if numPlayers < 2 { numPlayers = 2 } if numPlayers > 6 { numPlayers = 6 } botNames := newDurakBotNames(numPlayers - 1) names := append([]string{T("common.you")}, botNames...) return DurakModel{ game: NewDurakGame(names, 0), humanIdx: 0, bot: DurakBot{}, } } func (m DurakModel) Init() tea.Cmd { return m.maybeScheduleBot() } // currentActor возвращает индекс игрока, который должен действовать // прямо сейчас: атакующий в фазе первого хода, защищающийся в фазе // защиты, очередной подкидывающий в фазе подкидывания. func (m DurakModel) currentActor() int { switch m.game.Phase { case DurakPhaseAttack: return m.game.AttackerIdx case DurakPhaseDefend: return m.game.DefenderIdx case DurakPhaseThrowIn: return m.game.CurrentThrower() } return -1 } func (m DurakModel) maybeScheduleBot() tea.Cmd { // В фазе подкидывания человеку, у которого нет ни одной карты // подходящего ранга (в том числе если рука вообще пуста), нечего // решать — пасуем за него автоматически, точно так же, как это // уже делают боты через DecideThrowOrPass. Цикл сам завершится: // либо очередь дойдёт до кого-то другого, либо резолвится отбой. for m.game.Phase == DurakPhaseThrowIn && m.currentActor() == m.humanIdx && !m.game.hasThrowableCard(m.humanIdx) { if err := m.game.Pass(); err != nil { break } } if m.game.Phase == DurakPhaseGameOver { return nil } if m.currentActor() == m.humanIdx { return nil } return tea.Tick(botMoveDelay, func(time.Time) tea.Msg { return botMoveMsg{} }) } func (m *DurakModel) resetSelection() { hand := m.currentHand() if len(hand) == 0 { m.cursor = 0 } else if m.cursor >= len(hand) { m.cursor = len(hand) - 1 } } func (m DurakModel) currentHand() []Card { idx := m.currentActor() if idx < 0 { return nil } return m.game.Players[idx].Hand } func (m *DurakModel) setInfo(format string, args ...interface{}) { m.message = fmt.Sprintf(format, args...) m.isError = false } func (m *DurakModel) setError(err error) { m.message = T(err.Error()) m.isError = true } func (m DurakModel) 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 DurakModel) handleBotMove() (tea.Model, tea.Cmd) { if m.game.Phase == DurakPhaseGameOver { return m, nil } actor := m.currentActor() if actor == m.humanIdx { return m, nil } if err := m.bot.PlayFullTurn(m.game); err != nil { m.setError(fmt.Errorf(T("бот %s: %w"), m.game.Players[actor].Name, err)) return m, nil } m.setInfo(T("tonk.msg.bot_moved"), m.game.Players[actor].Name) if m.game.Phase != DurakPhaseGameOver && m.currentActor() == m.humanIdx { m.resetSelection() } return m, m.maybeScheduleBot() } func (m DurakModel) 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 == DurakPhaseGameOver { switch key { case "n": numPlayers := len(m.game.Players) wasFool := m.game.Result != nil && !m.game.Result.NoLoser && !m.game.Result.Stalemate && m.game.Result.Loser == m.humanIdx streak := m.storyNonFoolStreak + 1 if wasFool { streak = 0 } m = NewDurakModel(numPlayers) m.storyNonFoolStreak = streak 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.currentActor() != m.humanIdx { // сейчас действует бот — игнорируем остальной ввод return m, nil } switch m.game.Phase { case DurakPhaseAttack: return m.handleAttackKey(key) case DurakPhaseDefend: return m.handleDefendKey(key) case DurakPhaseThrowIn: return m.handleThrowInKey(key) } return m, nil } func (m DurakModel) handleAttackKey(key string) (tea.Model, tea.Cmd) { hand := m.currentHand() switch key { case "left", "h": if m.cursor > 0 { m.cursor-- } case "right", "l": if m.cursor < len(hand)-1 { m.cursor++ } case "enter", " ": if len(hand) == 0 { return m, nil } card := hand[m.cursor] if err := m.game.ThrowIn(card); err != nil { m.setError(err) return m, nil } m.setInfo(T("durak.msg.threw_in"), card) if m.game.Phase != DurakPhaseGameOver { m.resetSelection() } return m, m.maybeScheduleBot() } return m, nil } func (m DurakModel) handleThrowInKey(key string) (tea.Model, tea.Cmd) { hand := m.currentHand() switch key { case "left", "h": if m.cursor > 0 { m.cursor-- } case "right", "l": if m.cursor < len(hand)-1 { m.cursor++ } case "enter", " ": if len(hand) == 0 { return m, nil } card := hand[m.cursor] if err := m.game.ThrowIn(card); err != nil { m.setError(err) return m, nil } m.setInfo(T("durak.msg.threw_in"), card) if m.game.Phase != DurakPhaseGameOver { m.resetSelection() } return m, m.maybeScheduleBot() case "p": if err := m.game.Pass(); err != nil { m.setError(err) return m, nil } m.setInfo(T("durak.msg.passed")) if m.game.Phase != DurakPhaseGameOver { m.resetSelection() } return m, m.maybeScheduleBot() } return m, nil } func (m DurakModel) handleDefendKey(key string) (tea.Model, tea.Cmd) { hand := m.currentHand() switch key { case "left", "h": if m.cursor > 0 { m.cursor-- } case "right", "l": if m.cursor < len(hand)-1 { m.cursor++ } case "enter", " ": if len(hand) == 0 { return m, nil } card := hand[m.cursor] if err := m.game.Defend(card); err != nil { m.setError(err) return m, nil } m.setInfo(T("durak.msg.defended"), card) if m.game.Phase != DurakPhaseGameOver { m.resetSelection() } return m, m.maybeScheduleBot() case "t": if err := m.game.Take(); err != nil { m.setError(err) return m, nil } m.setInfo(T("durak.msg.took")) if m.game.Phase != DurakPhaseGameOver { m.resetSelection() } return m, m.maybeScheduleBot() } return m, nil } // --- Отрисовка ----------------------------------------------------- func (m DurakModel) renderHand() string { hand := m.currentHand() if len(hand) == 0 { return dimStyle.Render(T("common.no_cards")) } return renderCardBoxHand(hand, cursorHighlights(len(hand), m.cursor)) } func (m DurakModel) renderTable() string { if len(m.game.Table) == 0 { return dimStyle.Render(T("tonk.table_empty")) } parts := make([]string, len(m.game.Table)) for i, pair := range m.game.Table { if pair.Defend != nil { parts[i] = fmt.Sprintf("%s/%s", renderCard(pair.Attack), renderCard(*pair.Defend)) } else { parts[i] = fmt.Sprintf("%s/__", renderCard(pair.Attack)) } } return strings.Join(parts, " ") } func (m DurakModel) renderPlayers() string { var b strings.Builder for i, p := range m.game.Players { marker := " " switch { case m.game.Phase == DurakPhaseThrowIn && i == m.game.CurrentThrower(): marker = "T " case i == m.game.AttackerIdx && m.game.Phase != DurakPhaseGameOver: marker = "A " case i == m.game.DefenderIdx && m.game.Phase != DurakPhaseGameOver: marker = "D " } name := p.Name status := "" if p.Out { status = T("durak.status_out") } line := fmt.Sprintf(T("durak.player_line"), marker, name, len(p.Hand), status) if i == m.game.AttackerIdx || i == m.game.DefenderIdx || (m.game.Phase == DurakPhaseThrowIn && i == m.game.CurrentThrower()) { line = turnStyle.Render(line) } fmt.Fprintln(&b, line) } return strings.TrimRight(b.String(), "\n") } func (m DurakModel) View() string { if m.quitting { return T("common.goodbye") } var b strings.Builder fmt.Fprintln(&b, titleStyle.Render(T("durak.title"))) fmt.Fprintln(&b) fmt.Fprintln(&b, m.renderPlayers()) fmt.Fprintln(&b, dimStyle.Render(T("durak.legend"))) fmt.Fprintln(&b) trumpStr := renderCard(m.game.TrumpCard) fmt.Fprintf(&b, T("durak.status_line"), trumpStr, len(m.game.Talon)) fmt.Fprintln(&b, T("durak.table_label")) fmt.Fprintln(&b, m.renderTable()) fmt.Fprintln(&b) if m.game.Phase == DurakPhaseGameOver { fmt.Fprintln(&b, m.renderResult()) fmt.Fprintln(&b) fmt.Fprintln(&b, dimStyle.Render(T("durak.hint.gameover"))) return b.String() } if m.currentActor() == m.humanIdx { fmt.Fprintln(&b, T("common.your_hand")) fmt.Fprintln(&b, m.renderHand()) fmt.Fprintln(&b) switch m.game.Phase { case DurakPhaseAttack: fmt.Fprintln(&b, T("durak.hint.attack")) case DurakPhaseDefend: fmt.Fprintln(&b, T("durak.hint.defend")) case DurakPhaseThrowIn: fmt.Fprintln(&b, T("durak.hint.throwin")) } } else { fmt.Fprintf(&b, T("common.thinking"), m.game.Players[m.currentActor()].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 DurakModel) renderResult() string { res := m.game.Result if res.Stalemate { return errorStyle.Render(T("durak.result.stalemate")) } if res.NoLoser { return winStyle.Render(T("durak.result.noloser")) } loserName := m.game.Players[res.Loser].Name if res.Loser == m.humanIdx { return errorStyle.Render(Tf("durak.result.loser", loserName)) } return winStyle.Render(Tf("durak.result.loser", loserName)) }