412 lines
10 KiB
Go
412 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{"Вы"}, 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")
|
||
}
|