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

412 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"
)
// ThousandModel — TUI поверх ThousandGameState.
type ThousandModel struct {
game *ThousandGameState
humanIdx int
bot ThousandBot
cursor int
bidAmount int
message string
isError bool
quitting bool
backToMenu bool
}
// NewThousandModel создаёт новую партию 1000 на троих (человек +
// 2 бота со случайными именами).
func NewThousandModel() ThousandModel {
botNames := newThousandBotNames(2)
names := append([]string{"Вы"}, botNames...)
return ThousandModel{
game: NewThousandGame(names, 0),
humanIdx: 0,
bot: ThousandBot{},
}
}
func (m ThousandModel) Init() tea.Cmd {
return m.maybeScheduleBot()
}
func (m ThousandModel) actorForPhase() int {
switch m.game.Phase {
case ThousandPhaseBidding:
return m.game.CurrentBidderIdx
case ThousandPhaseDiscard:
return m.game.DeclarerIdx
case ThousandPhaseTrick:
return m.game.currentTrickActor()
}
return -1
}
func (m ThousandModel) maybeScheduleBot() tea.Cmd {
if m.game.Phase == ThousandPhaseHandOver {
return nil
}
if m.actorForPhase() == m.humanIdx {
return nil
}
return tea.Tick(botMoveDelay, func(time.Time) tea.Msg { return botMoveMsg{} })
}
func (m *ThousandModel) resetSelection() {
hand := m.currentHand()
if len(hand) == 0 {
m.cursor = 0
} else if m.cursor >= len(hand) {
m.cursor = len(hand) - 1
}
}
func (m ThousandModel) currentHand() []Card {
idx := m.actorForPhase()
if idx < 0 {
return nil
}
return m.game.Players[idx].Hand
}
func (m *ThousandModel) setInfo(format string, args ...interface{}) {
m.message = fmt.Sprintf(format, args...)
m.isError = false
}
func (m *ThousandModel) setError(err error) {
m.message = err.Error()
m.isError = true
}
func (m ThousandModel) 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 ThousandModel) handleBotMove() (tea.Model, tea.Cmd) {
if m.game.Phase == ThousandPhaseHandOver {
return m, nil
}
actor := m.actorForPhase()
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 != ThousandPhaseHandOver && m.actorForPhase() == m.humanIdx {
m.resetSelection()
m.bidAmount = m.game.HighBid + 5
if m.bidAmount < 100 {
m.bidAmount = 100
}
}
return m, m.maybeScheduleBot()
}
func (m ThousandModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
key := msg.String()
if key == "ctrl+c" {
m.quitting = true
return m, tea.Quit
}
if key == "q" {
m.backToMenu = true
return m, nil
}
if m.game.Phase == ThousandPhaseHandOver {
if key == "n" && !m.game.Result.MatchOver {
m.game.NextHand()
m.message = ""
m.isError = false
m.resetSelection()
return m, m.maybeScheduleBot()
}
return m, nil
}
if m.actorForPhase() != m.humanIdx {
return m, nil
}
switch m.game.Phase {
case ThousandPhaseBidding:
return m.handleBiddingKey(key)
case ThousandPhaseDiscard:
return m.handleDiscardKey(key)
case ThousandPhaseTrick:
return m.handleTrickKey(key)
}
return m, nil
}
func (m ThousandModel) handleBiddingKey(key string) (tea.Model, tea.Cmd) {
if m.bidAmount < 100 {
m.bidAmount = 100
}
switch key {
case "up", "right", "l", "k":
m.bidAmount += 5
case "down", "left", "h", "j":
if m.bidAmount > 100 {
m.bidAmount -= 5
}
case "enter":
if err := m.game.Bid(m.bidAmount); err != nil {
m.setError(err)
return m, nil
}
m.setInfo("Вы поставили %d.", m.bidAmount)
if m.game.Phase != ThousandPhaseHandOver {
m.bidAmount = m.game.HighBid + 5
}
return m, m.maybeScheduleBot()
case "p":
if err := m.game.Pass(); err != nil {
m.setError(err)
return m, nil
}
m.setInfo("Вы пасовали.")
return m, m.maybeScheduleBot()
}
return m, nil
}
func (m ThousandModel) handleDiscardKey(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.DiscardCard(card); err != nil {
m.setError(err)
return m, nil
}
m.setInfo("Вы отдали %s.", card)
m.resetSelection()
if m.game.Phase != ThousandPhaseDiscard {
return m, m.maybeScheduleBot()
}
}
return m, nil
}
func (m ThousandModel) handleTrickKey(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.PlayCard(card); err != nil {
m.setError(err)
return m, nil
}
m.setInfo("Вы сыграли %s.", card)
if m.game.Phase != ThousandPhaseHandOver {
m.resetSelection()
}
return m, m.maybeScheduleBot()
}
return m, nil
}
// --- Отрисовка -----------------------------------------------------
func (m ThousandModel) 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 ThousandModel) renderPlayers() string {
var b strings.Builder
for i, p := range m.game.Players {
marker := " "
if m.game.Phase != ThousandPhaseHandOver && i == m.actorForPhase() {
marker = "> "
}
name := p.Name
if i == m.humanIdx {
name += " (вы)"
}
role := ""
if m.game.Phase != ThousandPhaseBidding && i == m.game.DeclarerIdx {
role = " [торг]"
}
line := fmt.Sprintf("%s%-14s карт: %-2d очки за раздачу: %-4d счёт: %-5d%s",
marker, name, len(p.Hand), p.HandPoints, p.Score, role)
if m.game.Phase != ThousandPhaseHandOver && i == m.actorForPhase() {
line = turnStyle.Render(line)
}
fmt.Fprintln(&b, line)
}
return strings.TrimRight(b.String(), "\n")
}
func (m ThousandModel) renderTrick() string {
if len(m.game.CurrentTrick) == 0 {
return dimStyle.Render("(взятка ещё не начата)")
}
parts := make([]string, len(m.game.CurrentTrick))
for i, play := range m.game.CurrentTrick {
parts[i] = fmt.Sprintf("%s:%s", m.game.Players[play.PlayerIdx].Name, renderCard(play.Card))
}
return strings.Join(parts, " ")
}
func (m ThousandModel) View() string {
if m.quitting {
return "До встречи!\n"
}
var b strings.Builder
fmt.Fprintln(&b, titleStyle.Render("=== 1000 ==="))
fmt.Fprintln(&b)
fmt.Fprintln(&b, m.renderPlayers())
fmt.Fprintln(&b)
if m.game.Phase == ThousandPhaseHandOver {
fmt.Fprintln(&b, m.renderResult())
fmt.Fprintln(&b)
if m.game.Result.MatchOver {
fmt.Fprintln(&b, dimStyle.Render("[q] в меню"))
} else {
fmt.Fprintln(&b, dimStyle.Render("[n] новая раздача [q] в меню"))
}
return b.String()
}
switch m.game.Phase {
case ThousandPhaseBidding:
fmt.Fprintf(&b, "Текущая максимальная ставка: %d (у %s)\n\n", m.game.HighBid, m.bidderName())
case ThousandPhaseDiscard, ThousandPhaseTrick:
trumpStr := "ещё не назначен"
if m.game.TrumpSet {
trumpStr = suitName(m.game.TrumpSuit)
}
fmt.Fprintf(&b, "Ставка: %d (у %s) Козырь: %s\n", m.game.FinalBid, m.game.Players[m.game.DeclarerIdx].Name, trumpStr)
if m.game.Phase == ThousandPhaseTrick {
fmt.Fprintln(&b, "Взятка:", m.renderTrick())
}
fmt.Fprintln(&b)
}
if m.actorForPhase() == m.humanIdx {
fmt.Fprintln(&b, "Ваша рука:")
fmt.Fprintln(&b, m.renderHand())
fmt.Fprintln(&b)
switch m.game.Phase {
case ThousandPhaseBidding:
fmt.Fprintf(&b, "[↑→/kl] +5 [↓←/hj] -5 [enter] поставить %d [p] пас\n", m.bidAmount)
case ThousandPhaseDiscard:
fmt.Fprintln(&b, "[←→/hl] выбор карты [enter] отдать карту следующему сопернику")
case ThousandPhaseTrick:
fmt.Fprintln(&b, "[←→/hl] выбор карты [enter] сыграть карту (король/дама масти = объявить марьяж)")
}
fmt.Fprintln(&b, dimStyle.Render("[q] в меню"))
} else {
fmt.Fprintf(&b, "%s думает...\n", m.game.Players[m.actorForPhase()].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 ThousandModel) bidderName() string {
if m.game.HighBidderIdx < 0 {
return "никого — торги ещё не начались"
}
return m.game.Players[m.game.HighBidderIdx].Name
}
func suitName(s Suit) string {
switch s {
case Clubs:
return "трефы"
case Diamonds:
return "бубны"
case Hearts:
return "червы"
case Spades:
return "пики"
}
return "?"
}
func (m ThousandModel) renderResult() string {
res := m.game.Result
var b strings.Builder
declarerName := m.game.Players[res.DeclarerIdx].Name
if res.Made {
fmt.Fprintln(&b, winStyle.Render(fmt.Sprintf("%s выполнил(а) ставку %d!", declarerName, res.FinalBid)))
} else {
fmt.Fprintln(&b, errorStyle.Render(fmt.Sprintf("%s не выполнил(а) ставку %d.", declarerName, res.FinalBid)))
}
fmt.Fprintln(&b)
for i, p := range m.game.Players {
fmt.Fprintln(&b, fmt.Sprintf(" %-14s %+d очков за раздачу (итого: %d)", p.Name, res.ScoreDeltas[i], p.Score))
}
if res.MatchOver {
fmt.Fprintln(&b)
fmt.Fprintln(&b, winStyle.Render(fmt.Sprintf("Партия окончена! Победитель: %s", m.game.Players[res.MatchWinner].Name)))
}
return strings.TrimRight(b.String(), "\n")
}