276 lines
7 KiB
Go
276 lines
7 KiB
Go
package main
|
||
|
||
import (
|
||
"fmt"
|
||
"strings"
|
||
"time"
|
||
|
||
tea "github.com/charmbracelet/bubbletea"
|
||
)
|
||
|
||
// BlackjackModel — TUI поверх BlackjackGameState. Действий у
|
||
// игрока немного и они не требуют выбора карты (в отличие от
|
||
// Тонка/Дурака) — только hit/stand/double, поэтому курсор по руке
|
||
// не нужен.
|
||
type BlackjackModel struct {
|
||
game *BlackjackGameState
|
||
humanIdx int
|
||
bot BlackjackBot
|
||
|
||
message string
|
||
isError bool
|
||
|
||
quitting bool
|
||
backToMenu bool
|
||
}
|
||
|
||
// NewBlackjackModel создаёт новую партию: человек — игрок 0, дальше
|
||
// numPlayers-1 ботов со случайными именами (тот же пул, что и в
|
||
// Тонке — это просто имена, без всякого смысла про уровень игры).
|
||
func NewBlackjackModel(numPlayers, startingCapital, bet int) BlackjackModel {
|
||
if numPlayers < 1 {
|
||
numPlayers = 1
|
||
}
|
||
if numPlayers > 6 {
|
||
numPlayers = 6
|
||
}
|
||
names := []string{"Вы"}
|
||
if numPlayers > 1 {
|
||
botNames, _ := newBotRoster(numPlayers - 1)
|
||
names = append(names, botNames...)
|
||
}
|
||
|
||
return BlackjackModel{
|
||
game: NewBlackjackGame(names, 0, startingCapital, bet),
|
||
humanIdx: 0,
|
||
bot: BlackjackBot{},
|
||
}
|
||
}
|
||
|
||
func (m BlackjackModel) Init() tea.Cmd {
|
||
return m.maybeScheduleBot()
|
||
}
|
||
|
||
func (m BlackjackModel) maybeScheduleBot() tea.Cmd {
|
||
if m.game.Phase != BJPhasePlayerTurn {
|
||
return nil
|
||
}
|
||
if m.game.CurrentPlayerIdx == m.humanIdx {
|
||
return nil
|
||
}
|
||
return tea.Tick(botMoveDelay, func(time.Time) tea.Msg { return botMoveMsg{} })
|
||
}
|
||
|
||
func (m *BlackjackModel) setInfo(format string, args ...interface{}) {
|
||
m.message = fmt.Sprintf(format, args...)
|
||
m.isError = false
|
||
}
|
||
|
||
func (m *BlackjackModel) setError(err error) {
|
||
m.message = err.Error()
|
||
m.isError = true
|
||
}
|
||
|
||
func (m BlackjackModel) 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 BlackjackModel) handleBotMove() (tea.Model, tea.Cmd) {
|
||
if m.game.Phase != BJPhasePlayerTurn {
|
||
return m, nil
|
||
}
|
||
idx := m.game.CurrentPlayerIdx
|
||
if idx == m.humanIdx {
|
||
return m, nil
|
||
}
|
||
if err := m.bot.PlayFullTurn(m.game); err != nil {
|
||
m.setError(fmt.Errorf("бот %s: %w", m.game.Players[idx].Name, err))
|
||
return m, nil
|
||
}
|
||
m.setInfo("%s сходил(а).", m.game.Players[idx].Name)
|
||
return m, m.maybeScheduleBot()
|
||
}
|
||
|
||
func (m BlackjackModel) 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 == BJPhaseRoundOver {
|
||
if key == "n" {
|
||
m.game = m.game.NextRound()
|
||
m.message = ""
|
||
m.isError = false
|
||
return m, m.maybeScheduleBot()
|
||
}
|
||
return m, nil
|
||
}
|
||
|
||
if m.game.CurrentPlayerIdx != m.humanIdx {
|
||
// сейчас ходит бот — игнорируем остальной ввод
|
||
return m, nil
|
||
}
|
||
|
||
switch key {
|
||
case "h":
|
||
if err := m.game.Hit(); err != nil {
|
||
m.setError(err)
|
||
return m, nil
|
||
}
|
||
m.setInfo("Вы взяли карту.")
|
||
return m, m.maybeScheduleBot()
|
||
case "s":
|
||
if err := m.game.Stand(); err != nil {
|
||
m.setError(err)
|
||
return m, nil
|
||
}
|
||
m.setInfo("Вы остановились.")
|
||
return m, m.maybeScheduleBot()
|
||
case "d":
|
||
if err := m.game.DoubleDown(); err != nil {
|
||
m.setError(err)
|
||
return m, nil
|
||
}
|
||
m.setInfo("Вы удвоили ставку.")
|
||
return m, m.maybeScheduleBot()
|
||
}
|
||
return m, nil
|
||
}
|
||
|
||
// --- Отрисовка -----------------------------------------------------
|
||
|
||
func renderHandCards(hand []Card) string {
|
||
if len(hand) == 0 {
|
||
return dimStyle.Render("(нет карт)")
|
||
}
|
||
parts := make([]string, len(hand))
|
||
for i, c := range hand {
|
||
parts[i] = renderCard(c)
|
||
}
|
||
return strings.Join(parts, " ")
|
||
}
|
||
|
||
func statusLabel(s BlackjackStatus) string {
|
||
switch s {
|
||
case BJStatusStood:
|
||
return "стоп"
|
||
case BJStatusBusted:
|
||
return "перебор"
|
||
case BJStatusBlackjack:
|
||
return "блэкджек!"
|
||
default:
|
||
return "ходит"
|
||
}
|
||
}
|
||
|
||
func (m BlackjackModel) renderPlayers() string {
|
||
var b strings.Builder
|
||
for i, p := range m.game.Players {
|
||
marker := " "
|
||
if m.game.Phase == BJPhasePlayerTurn && i == m.game.CurrentPlayerIdx {
|
||
marker = "> "
|
||
}
|
||
name := p.Name
|
||
if i == m.humanIdx {
|
||
name += " (вы)"
|
||
}
|
||
total := handTotal(p.Hand)
|
||
line := fmt.Sprintf("%s%-20s ставка: %-4d карты: %s (%d) [%s]",
|
||
marker, name, p.Bet, renderHandCards(p.Hand), total, statusLabel(p.Status))
|
||
if m.game.Phase == BJPhasePlayerTurn && i == m.game.CurrentPlayerIdx {
|
||
line = turnStyle.Render(line)
|
||
}
|
||
fmt.Fprintln(&b, line)
|
||
}
|
||
return strings.TrimRight(b.String(), "\n")
|
||
}
|
||
|
||
func (m BlackjackModel) renderDealer() string {
|
||
if m.game.Phase == BJPhasePlayerTurn {
|
||
// вторая карта дилера скрыта, пока не завершится раунд
|
||
hidden := dimStyle.Render("??")
|
||
return fmt.Sprintf("%s %s", renderCard(m.game.Dealer[0]), hidden)
|
||
}
|
||
return fmt.Sprintf("%s (%d)", renderHandCards(m.game.Dealer), handTotal(m.game.Dealer))
|
||
}
|
||
|
||
func (m BlackjackModel) View() string {
|
||
if m.quitting {
|
||
return "До встречи!\n"
|
||
}
|
||
|
||
var b strings.Builder
|
||
fmt.Fprintln(&b, titleStyle.Render("=== БЛЭКДЖЕК ==="))
|
||
fmt.Fprintln(&b)
|
||
fmt.Fprintf(&b, "Дилер: %s\n\n", m.renderDealer())
|
||
fmt.Fprintln(&b, m.renderPlayers())
|
||
fmt.Fprintln(&b)
|
||
|
||
if m.game.Phase == BJPhaseRoundOver {
|
||
fmt.Fprintln(&b, m.renderResult())
|
||
fmt.Fprintln(&b)
|
||
fmt.Fprintln(&b, dimStyle.Render("[n] новый раунд [q] в меню"))
|
||
return b.String()
|
||
}
|
||
|
||
if m.game.CurrentPlayerIdx == m.humanIdx {
|
||
fmt.Fprintln(&b, "[h] взять карту [s] остановиться [d] удвоить ставку [q] в меню")
|
||
} else {
|
||
fmt.Fprintf(&b, "%s думает...\n", m.game.Players[m.game.CurrentPlayerIdx].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 BlackjackModel) renderResult() string {
|
||
res := m.game.Result
|
||
var b strings.Builder
|
||
dealerLine := fmt.Sprintf("Дилер: %s (%d)", renderHandCards(m.game.Dealer), res.DealerTotal)
|
||
if res.DealerBusted {
|
||
dealerLine += " — перебор!"
|
||
}
|
||
fmt.Fprintln(&b, dealerLine)
|
||
fmt.Fprintln(&b)
|
||
|
||
outcomeText := map[string]string{
|
||
"blackjack": "блэкджек! выигрыш 3:2",
|
||
"win": "выигрыш",
|
||
"push": "ничья, ставка возвращена",
|
||
"lose": "проигрыш",
|
||
"bust": "перебор, проигрыш",
|
||
}
|
||
for i, p := range m.game.Players {
|
||
r := res.PlayerResults[i]
|
||
style := infoStyle
|
||
if r.Delta < 0 {
|
||
style = errorStyle
|
||
} else if r.Delta > 0 {
|
||
style = winStyle
|
||
}
|
||
fmt.Fprintln(&b, style.Render(fmt.Sprintf(" %-20s %-24s изменение капитала: %+d (итого: %d)",
|
||
p.Name, outcomeText[r.Outcome], r.Delta, p.Balance)))
|
||
}
|
||
return strings.TrimRight(b.String(), "\n")
|
||
}
|