425 lines
10 KiB
Go
425 lines
10 KiB
Go
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"))
|
||
}
|
||
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
|
||
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")
|
||
}
|