go_games_collection/tui.go

554 lines
17 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
// 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())
}
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")
}