go_games_collection/tui.go
2026-07-10 09:12:06 +03:00

729 lines
24 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{}
// 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")
}