729 lines
24 KiB
Go
729 lines
24 KiB
Go
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")
|
||
}
|