346 lines
7.9 KiB
Go
346 lines
7.9 KiB
Go
package main
|
||
|
||
import (
|
||
"fmt"
|
||
"strings"
|
||
"time"
|
||
|
||
tea "github.com/charmbracelet/bubbletea"
|
||
)
|
||
|
||
// OneOhOneModel — TUI поверх OneOhOneGameState.
|
||
type OneOhOneModel struct {
|
||
game *OneOhOneGameState
|
||
humanIdx int
|
||
bot OneOhOneBot
|
||
|
||
cursor int
|
||
message string
|
||
isError bool
|
||
|
||
quitting bool
|
||
backToMenu bool
|
||
}
|
||
|
||
// NewOneOhOneModel создаёт новую партию 101 на numPlayers игроков
|
||
// (2-6, включая человека) со случайными именами ботов.
|
||
func NewOneOhOneModel(numPlayers int) OneOhOneModel {
|
||
if numPlayers < 2 {
|
||
numPlayers = 2
|
||
}
|
||
if numPlayers > 6 {
|
||
numPlayers = 6
|
||
}
|
||
botNames := newOneOhOneBotNames(numPlayers - 1)
|
||
names := append([]string{T("common.you")}, botNames...)
|
||
|
||
return OneOhOneModel{
|
||
game: NewOneOhOneGame(names, 0),
|
||
humanIdx: 0,
|
||
bot: OneOhOneBot{},
|
||
}
|
||
}
|
||
|
||
func (m OneOhOneModel) Init() tea.Cmd {
|
||
return m.maybeScheduleBot()
|
||
}
|
||
|
||
func (m OneOhOneModel) maybeScheduleBot() tea.Cmd {
|
||
if m.game.Phase != OneOhOnePhasePlay && m.game.Phase != OneOhOnePhaseChooseSuit {
|
||
return nil
|
||
}
|
||
if m.game.CurrentPlayerIdx == m.humanIdx {
|
||
return nil
|
||
}
|
||
return tea.Tick(botMoveDelay, func(time.Time) tea.Msg { return botMoveMsg{} })
|
||
}
|
||
|
||
func (m *OneOhOneModel) resetSelection() {
|
||
hand := m.currentHand()
|
||
if len(hand) == 0 {
|
||
m.cursor = 0
|
||
} else if m.cursor >= len(hand) {
|
||
m.cursor = len(hand) - 1
|
||
}
|
||
}
|
||
|
||
func (m OneOhOneModel) currentHand() []Card {
|
||
if m.game.Phase != OneOhOnePhasePlay {
|
||
return nil
|
||
}
|
||
return m.game.Players[m.game.CurrentPlayerIdx].Hand
|
||
}
|
||
|
||
func (m *OneOhOneModel) setInfo(format string, args ...interface{}) {
|
||
m.message = fmt.Sprintf(format, args...)
|
||
m.isError = false
|
||
}
|
||
|
||
func (m *OneOhOneModel) setError(err error) {
|
||
m.message = T(err.Error())
|
||
m.isError = true
|
||
}
|
||
|
||
func (m OneOhOneModel) 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 OneOhOneModel) handleBotMove() (tea.Model, tea.Cmd) {
|
||
if m.game.Phase != OneOhOnePhasePlay && m.game.Phase != OneOhOnePhaseChooseSuit {
|
||
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(T("бот %s: %w"), m.game.Players[idx].Name, err))
|
||
return m, nil
|
||
}
|
||
m.setInfo(T("tonk.msg.bot_moved"), m.game.Players[idx].Name)
|
||
if m.game.Phase == OneOhOnePhasePlay && m.game.CurrentPlayerIdx == m.humanIdx {
|
||
m.resetSelection()
|
||
}
|
||
return m, m.maybeScheduleBot()
|
||
}
|
||
|
||
func (m OneOhOneModel) 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 == OneOhOnePhaseRoundOver {
|
||
if key == "n" && !m.game.Result.MatchOver {
|
||
m.game.NextRound()
|
||
m.message = ""
|
||
m.isError = false
|
||
m.resetSelection()
|
||
return m, m.maybeScheduleBot()
|
||
}
|
||
return m, nil
|
||
}
|
||
|
||
if m.game.Phase == OneOhOnePhaseChooseSuit {
|
||
if m.game.CurrentPlayerIdx != m.humanIdx {
|
||
return m, nil
|
||
}
|
||
var suit Suit
|
||
switch key {
|
||
case "1":
|
||
suit = Clubs
|
||
case "2":
|
||
suit = Diamonds
|
||
case "3":
|
||
suit = Hearts
|
||
case "4":
|
||
suit = Spades
|
||
default:
|
||
return m, nil
|
||
}
|
||
if err := m.game.ChooseSuit(suit); err != nil {
|
||
m.setError(err)
|
||
return m, nil
|
||
}
|
||
m.setInfo(T("oneohone.msg.suit_chosen"), suitName(suit))
|
||
m.resetSelection()
|
||
return m, m.maybeScheduleBot()
|
||
}
|
||
|
||
if m.game.CurrentPlayerIdx != m.humanIdx {
|
||
return m, nil
|
||
}
|
||
|
||
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("oneohone.msg.played"), card)
|
||
if m.game.Phase == OneOhOnePhasePlay {
|
||
m.resetSelection()
|
||
}
|
||
return m, m.maybeScheduleBot()
|
||
case "d":
|
||
if err := m.game.Draw(); err != nil {
|
||
m.setError(err)
|
||
return m, nil
|
||
}
|
||
m.setInfo(T("oneohone.msg.drew"))
|
||
if m.game.Phase == OneOhOnePhasePlay {
|
||
m.resetSelection()
|
||
}
|
||
return m, m.maybeScheduleBot()
|
||
}
|
||
return m, nil
|
||
}
|
||
|
||
// --- Отрисовка -----------------------------------------------------
|
||
|
||
func (m OneOhOneModel) 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 OneOhOneModel) renderPlayers() string {
|
||
var b strings.Builder
|
||
for i, p := range m.game.Players {
|
||
marker := " "
|
||
if m.game.Phase == OneOhOnePhasePlay && i == m.game.CurrentPlayerIdx {
|
||
marker = "> "
|
||
}
|
||
name := p.Name
|
||
status := ""
|
||
if p.Out {
|
||
status = T("oneohone.status.out")
|
||
}
|
||
line := fmt.Sprintf(T("oneohone.player_line"), marker, name, len(p.Hand), p.Score, status)
|
||
if m.game.Phase == OneOhOnePhasePlay && i == m.game.CurrentPlayerIdx {
|
||
line = turnStyle.Render(line)
|
||
}
|
||
fmt.Fprintln(&b, line)
|
||
}
|
||
return strings.TrimRight(b.String(), "\n")
|
||
}
|
||
|
||
func (m OneOhOneModel) View() string {
|
||
if m.quitting {
|
||
return T("common.goodbye")
|
||
}
|
||
|
||
var b strings.Builder
|
||
fmt.Fprintln(&b, titleStyle.Render(T("oneohone.title")))
|
||
fmt.Fprintln(&b)
|
||
fmt.Fprintln(&b, m.renderPlayers())
|
||
fmt.Fprintln(&b)
|
||
fmt.Fprintf(&b, T("oneohone.status_line"), renderCard(m.game.topDiscard()), len(m.game.Stock))
|
||
|
||
if m.game.Phase == OneOhOnePhaseRoundOver {
|
||
fmt.Fprintln(&b, m.renderResult())
|
||
fmt.Fprintln(&b)
|
||
if m.game.Result.MatchOver {
|
||
fmt.Fprintln(&b, dimStyle.Render(T("oneohone.hint.gameover_match")))
|
||
} else {
|
||
fmt.Fprintln(&b, dimStyle.Render(T("oneohone.hint.gameover")))
|
||
}
|
||
return b.String()
|
||
}
|
||
|
||
if m.game.Phase == OneOhOnePhaseChooseSuit {
|
||
if m.game.CurrentPlayerIdx == m.humanIdx {
|
||
fmt.Fprintln(&b, T("oneohone.choose_suit_prompt"))
|
||
} else {
|
||
fmt.Fprintf(&b, T("common.thinking"), 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()
|
||
}
|
||
|
||
if m.game.CurrentPlayerIdx == m.humanIdx {
|
||
fmt.Fprintln(&b, T("common.your_hand"))
|
||
fmt.Fprintln(&b, m.renderHand())
|
||
fmt.Fprintln(&b)
|
||
fmt.Fprintln(&b, T("oneohone.hint.turn"))
|
||
} else {
|
||
fmt.Fprintf(&b, T("common.thinking"), 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 OneOhOneModel) renderResult() string {
|
||
res := m.game.Result
|
||
var b strings.Builder
|
||
if res.Stalemate {
|
||
fmt.Fprintln(&b, errorStyle.Render(T("oneohone.result.stalemate")))
|
||
return strings.TrimRight(b.String(), "\n")
|
||
}
|
||
|
||
winnerName := m.game.Players[res.Winner].Name
|
||
winLine := Tf("oneohone.result.win", winnerName)
|
||
if res.QueenBonus {
|
||
winLine += T("oneohone.result.queen_bonus")
|
||
}
|
||
fmt.Fprintln(&b, winStyle.Render(winLine))
|
||
fmt.Fprintln(&b)
|
||
|
||
for i, p := range m.game.Players {
|
||
delta := res.ScoreDeltas[i]
|
||
note := ""
|
||
for _, e := range res.Eliminated {
|
||
if e == i {
|
||
note = T("oneohone.result.eliminated")
|
||
}
|
||
}
|
||
for _, r := range res.Reset {
|
||
if r == i {
|
||
note = T("oneohone.result.reset")
|
||
}
|
||
}
|
||
fmt.Fprintln(&b, fmt.Sprintf(T("oneohone.result.delta_line"), p.Name, delta, p.Score, note))
|
||
}
|
||
|
||
if res.MatchOver {
|
||
fmt.Fprintln(&b)
|
||
if res.NoWinner {
|
||
fmt.Fprintln(&b, errorStyle.Render(T("oneohone.result.no_winner")))
|
||
} else {
|
||
fmt.Fprintln(&b, winStyle.Render(Tf("oneohone.result.match_over", m.game.Players[res.MatchWinner].Name)))
|
||
}
|
||
}
|
||
|
||
return strings.TrimRight(b.String(), "\n")
|
||
}
|