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

417 lines
9.9 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{T("common.you")}, 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 = T(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(T("бот %s: %w"), m.game.Players[actor].Name, err))
return m, nil
}
m.setInfo(T("tonk.msg.bot_moved"), 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(T("thousand.msg.bid"), 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(T("thousand.msg.passed"))
return m, m.maybeScheduleBot()
case "v":
if err := m.game.DeclareBlind(); err != nil {
m.setError(err)
return m, nil
}
m.setInfo(T("thousand.msg.blind_declared"))
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(T("thousand.msg.gave_card"), 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(T("thousand.msg.played"), 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(T("common.no_cards"))
}
return renderCardBoxHand(hand, cursorHighlights(len(hand), m.cursor))
}
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
role := ""
if m.game.Phase != ThousandPhaseBidding && i == m.game.DeclarerIdx {
role = T("thousand.declarer_tag")
}
if p.OnBarrel {
role += " " + Tf("thousand.barrel_tag", p.BarrelAttempts, ThousandBarrelMaxAttempts)
}
line := fmt.Sprintf(T("thousand.player_line"),
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(T("thousand.trick_not_started"))
}
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 T("common.goodbye")
}
var b strings.Builder
fmt.Fprintln(&b, titleStyle.Render(T("thousand.title")))
fmt.Fprintln(&b)
if m.game.HandNumber <= ThousandGoldenHands {
fmt.Fprintln(&b, dimStyle.Render(T("thousand.golden_round_tag")))
}
if m.game.PlayedBlind {
fmt.Fprintln(&b, dimStyle.Render(T("thousand.blind_round_tag")))
}
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(T("thousand.hint.gameover_match")))
} else {
fmt.Fprintln(&b, dimStyle.Render(T("thousand.hint.gameover")))
}
return b.String()
}
switch m.game.Phase {
case ThousandPhaseBidding:
fmt.Fprintf(&b, T("thousand.bidding_status"), m.game.HighBid, m.bidderName())
case ThousandPhaseDiscard, ThousandPhaseTrick:
trumpStr := T("thousand.trump_unset")
if m.game.TrumpSet {
trumpStr = suitName(m.game.TrumpSuit)
}
fmt.Fprintf(&b, T("thousand.play_status"), m.game.FinalBid, m.game.Players[m.game.DeclarerIdx].Name, trumpStr)
if m.game.Phase == ThousandPhaseTrick {
fmt.Fprintln(&b, T("thousand.trick_label"), m.renderTrick())
}
fmt.Fprintln(&b)
}
if m.actorForPhase() == m.humanIdx {
fmt.Fprintln(&b, T("thousand.your_hand"))
fmt.Fprintln(&b, m.renderHand())
fmt.Fprintln(&b)
switch m.game.Phase {
case ThousandPhaseBidding:
fmt.Fprintf(&b, T("thousand.hint.bid"), m.bidAmount)
case ThousandPhaseDiscard:
fmt.Fprintln(&b, T("thousand.hint.discard"))
case ThousandPhaseTrick:
fmt.Fprintln(&b, T("thousand.hint.trick"))
}
fmt.Fprintln(&b, dimStyle.Render(T("thousand.hint.menu")))
} else {
fmt.Fprintf(&b, T("common.thinking"), 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 T("thousand.no_bidder")
}
return m.game.Players[m.game.HighBidderIdx].Name
}
func suitName(s Suit) string {
switch s {
case Clubs:
return T("thousand.suit.clubs")
case Diamonds:
return T("thousand.suit.diamonds")
case Hearts:
return T("thousand.suit.hearts")
case Spades:
return T("thousand.suit.spades")
}
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(Tf("thousand.result.made", declarerName, res.FinalBid)))
} else {
fmt.Fprintln(&b, errorStyle.Render(Tf("thousand.result.failed", declarerName, res.FinalBid)))
}
fmt.Fprintln(&b)
for i, p := range m.game.Players {
fmt.Fprintln(&b, fmt.Sprintf(T("thousand.result.delta_line"), p.Name, res.ScoreDeltas[i], p.Score))
}
if res.MatchOver {
fmt.Fprintln(&b)
fmt.Fprintln(&b, winStyle.Render(Tf("thousand.result.match_over", m.game.Players[res.MatchWinner].Name)))
}
return strings.TrimRight(b.String(), "\n")
}