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

514 lines
15 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"
"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)
stagedStyle = lipgloss.NewStyle().Underline(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{"Вы"}, 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 = 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("бот %s: %w", m.game.Players[idx].Name, err))
return m, nil
}
m.setInfo("%s сходил(а).", 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("Вы взяли карту из колоды.")
m.resetSelectionState()
case "p":
card, err := m.game.DrawFromDiscard()
if err != nil {
m.setError(err)
return m, nil
}
m.setInfo("Вы взяли из сброса: %s", card)
m.resetSelectionState()
}
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("Вы объявили Тонк!")
_ = 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("Вы сбросили %s.", 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("выберите (пробел) минимум 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("Комбинация выложена на стол.")
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("на столе нет комбинации №%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("Карта %s подложена к комбинации №%d.", 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(
"Вы обанкротились и выбыли! Ваше место занял новый игрок: %s. Дальше играют боты — можно наблюдать или [q] выйти в меню.",
newName))
} else {
messages = append(messages, fmt.Sprintf(
"%s обанкротился(лась) и выбыл(а). За стол сел новый игрок: %s (капитал %d).",
oldName, newName, p.Balance))
}
}
return strings.Join(messages, " ")
}
func isRed(s Suit) bool {
return s == Hearts || s == Diamonds
}
func renderCard(c Card) string {
style := blackCardStyle
if isRed(c.Suit) {
style = redCardStyle
}
return style.Render(c.String())
}
func (m Model) renderHand() string {
hand := m.game.current().Hand
if len(hand) == 0 {
return dimStyle.Render("(нет карт)")
}
parts := make([]string, len(hand))
for i, c := range hand {
text := renderCard(c)
if m.staged[c] {
text = stagedStyle.Render(text)
}
if i == m.cursor {
text = cursorStyle.Render(c.String())
}
parts[i] = text
}
return strings.Join(parts, " ")
}
func (m Model) renderTable() string {
if len(m.game.Table) == 0 {
return dimStyle.Render("(стол пуст)")
}
var b strings.Builder
for i, meld := range m.game.Table {
kind := "Сет"
if meld.Kind == KindSequence {
kind = "Сиквенс"
}
cardsStr := make([]string, len(meld.Cards))
for j, c := range meld.Cards {
cardsStr[j] = renderCard(c)
}
fmt.Fprintf(&b, " [%d] %s (%s): %s\n", 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
if i == m.humanIdx {
name += " (вы)"
}
line := fmt.Sprintf("%s%-24s карт: %-2d капитал: %d", 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 "До встречи!\n"
}
var b strings.Builder
fmt.Fprintln(&b, titleStyle.Render("=== ТОНК ==="))
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, "Колода: %d карт Сброс сверху: %s Банк раунда: %d (ставка: %d, Тонк ×%d)\n\n",
m.game.Deck.Len(), topStr, m.game.Pot, m.game.AnteAmount, m.game.TonkMultiplier)
fmt.Fprintln(&b, "Комбинации на столе:")
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("[n] новый раунд [q] в меню"))
return b.String()
}
if m.game.CurrentPlayer == m.humanIdx {
fmt.Fprintln(&b, "Ваша рука:")
fmt.Fprintln(&b, m.renderHand())
fmt.Fprintf(&b, "Вес руки: %d\n\n", HandValue(m.game.current().Hand))
switch m.game.Phase {
case PhaseDraw:
fmt.Fprintln(&b, "Ваш ход. [d] взять из колоды [p] взять из сброса ("+topStr+") [q] в меню")
case PhaseAction:
tonkHint := ""
if CanDeclareTonk(m.game.current().Hand, m.game.TonkThreshold) {
tonkHint = winStyle.Render(" [t] Тонк доступен!")
}
fmt.Fprintln(&b, "[←→/hl] выбор карты [space] отметить для комбинации [m] выложить комбинацию")
fmt.Fprintln(&b, "[1-9] подложить текущую карту к комбинации №N [enter] сбросить текущую карту"+tonkHint)
}
} else {
fmt.Fprintf(&b, "%s думает...\n", 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(fmt.Sprintf("Раунд закончился вничью: колода и сброс исчерпаны. Банк (%d) возвращён игрокам.", res.Pot)))
case res.TonkCall:
fmt.Fprintln(&b, winStyle.Render(fmt.Sprintf("%s объявил(а) Тонк и забрал(а) банк ×%d (%d)!",
m.game.Players[res.Winner].Name, m.game.TonkMultiplier, res.Pot*m.game.TonkMultiplier)))
default:
fmt.Fprintln(&b, winStyle.Render(fmt.Sprintf("%s избавился(лась) от всех карт и забрал(а) банк (%d)!",
m.game.Players[res.Winner].Name, res.Pot)))
}
for i, p := range m.game.Players {
fmt.Fprintf(&b, " %-24s изменение капитала: %+d (итого: %d)\n", p.Name, res.BalanceDeltas[i], p.Balance)
}
return strings.TrimRight(b.String(), "\n")
}