go_games_collection/durak_tui.go
2026-07-04 13:51:04 +03:00

410 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}
// 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{"Вы"}, 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 {
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 = 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("бот %s: %w", m.game.Players[actor].Name, err))
return m, nil
}
m.setInfo("%s сходил(а).", 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)
m = NewDurakModel(numPlayers)
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("Вы подкинули %s.", 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("Вы подкинули %s.", 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("Вы пасовали.")
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("Вы отбились картой %s.", 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("Вы забрали стол себе в руку.")
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("(нет карт)")
}
parts := make([]string, len(hand))
for i, c := range hand {
text := renderCard(c)
if i == m.cursor {
text = cursorStyle.Render(c.String())
}
parts[i] = text
}
return strings.Join(parts, " ")
}
func (m DurakModel) renderTable() string {
if len(m.game.Table) == 0 {
return dimStyle.Render("(стол пуст)")
}
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
if i == m.humanIdx {
name += " (вы)"
}
status := ""
if p.Out {
status = " [отбился]"
}
line := fmt.Sprintf("%s%-20s карт: %-2d%s", 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 "До встречи!\n"
}
var b strings.Builder
fmt.Fprintln(&b, titleStyle.Render("=== ДУРАК ==="))
fmt.Fprintln(&b)
fmt.Fprintln(&b, m.renderPlayers())
fmt.Fprintln(&b)
trumpStr := renderCard(m.game.TrumpCard)
fmt.Fprintf(&b, "Козырь: %s В колоде: %d карт\n\n", trumpStr, len(m.game.Talon))
fmt.Fprintln(&b, "Стол (атака/защита):")
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("[n] новая партия [q] в меню"))
return b.String()
}
if m.currentActor() == m.humanIdx {
fmt.Fprintln(&b, "Ваша рука:")
fmt.Fprintln(&b, m.renderHand())
fmt.Fprintln(&b)
switch m.game.Phase {
case DurakPhaseAttack:
fmt.Fprintln(&b, "[←→/hl] выбор карты [enter] подкинуть карту (обязательный первый ход) [q] в меню")
case DurakPhaseDefend:
fmt.Fprintln(&b, "[←→/hl] выбор карты [enter] отбить карту [t] забрать стол [q] в меню")
case DurakPhaseThrowIn:
fmt.Fprintln(&b, "[←→/hl] выбор карты [enter] подкинуть ещё карту [p] пасовать [q] в меню")
}
} else {
fmt.Fprintf(&b, "%s думает...\n", 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("Партия зашла в тупик (карты слишком долго не выходили из игры) и объявлена ничьей.")
}
if res.NoLoser {
return winStyle.Render("Ничья! Все избавились от карт одновременно — дурака нет.")
}
loserName := m.game.Players[res.Loser].Name
if res.Loser == m.humanIdx {
return errorStyle.Render(fmt.Sprintf("%s остался(лась) с картами на руках — дурак!", loserName))
}
return winStyle.Render(fmt.Sprintf("%s остался(лась) с картами на руках — дурак!", loserName))
}