408 lines
9.6 KiB
Go
408 lines
9.6 KiB
Go
package main
|
||
|
||
import (
|
||
"fmt"
|
||
"strings"
|
||
"time"
|
||
|
||
tea "github.com/charmbracelet/bubbletea"
|
||
)
|
||
|
||
// DurakModel — TUI поверх DurakGameState. Действующее лицо всегда
|
||
// одно: атакующий (обязательный первый ход раунда), защищающийся
|
||
// (отбить/забрать) либо очередной подкидывающий (подкинуть/пасовать
|
||
// в фазе DurakPhaseThrowIn) — определяется через currentActor().
|
||
type DurakModel struct {
|
||
game *DurakGameState
|
||
humanIdx int
|
||
bot DurakBot
|
||
|
||
cursor int
|
||
message string
|
||
isError bool
|
||
|
||
quitting bool
|
||
backToMenu bool
|
||
}
|
||
|
||
// NewDurakModel создаёт новую партию Дурака на numPlayers игроков
|
||
// (2-6, включая человека) со случайными именами ботов.
|
||
func NewDurakModel(numPlayers int) DurakModel {
|
||
if numPlayers < 2 {
|
||
numPlayers = 2
|
||
}
|
||
if numPlayers > 6 {
|
||
numPlayers = 6
|
||
}
|
||
botNames := newDurakBotNames(numPlayers - 1)
|
||
names := append([]string{T("common.you")}, botNames...)
|
||
|
||
return DurakModel{
|
||
game: NewDurakGame(names, 0),
|
||
humanIdx: 0,
|
||
bot: DurakBot{},
|
||
}
|
||
}
|
||
|
||
func (m DurakModel) Init() tea.Cmd {
|
||
return m.maybeScheduleBot()
|
||
}
|
||
|
||
// currentActor возвращает индекс игрока, который должен действовать
|
||
// прямо сейчас: атакующий в фазе первого хода, защищающийся в фазе
|
||
// защиты, очередной подкидывающий в фазе подкидывания.
|
||
func (m DurakModel) currentActor() int {
|
||
switch m.game.Phase {
|
||
case DurakPhaseAttack:
|
||
return m.game.AttackerIdx
|
||
case DurakPhaseDefend:
|
||
return m.game.DefenderIdx
|
||
case DurakPhaseThrowIn:
|
||
return m.game.CurrentThrower()
|
||
}
|
||
return -1
|
||
}
|
||
|
||
func (m DurakModel) maybeScheduleBot() tea.Cmd {
|
||
if m.game.Phase == DurakPhaseGameOver {
|
||
return nil
|
||
}
|
||
if m.currentActor() == m.humanIdx {
|
||
return nil
|
||
}
|
||
return tea.Tick(botMoveDelay, func(time.Time) tea.Msg { return botMoveMsg{} })
|
||
}
|
||
|
||
func (m *DurakModel) resetSelection() {
|
||
hand := m.currentHand()
|
||
if len(hand) == 0 {
|
||
m.cursor = 0
|
||
} else if m.cursor >= len(hand) {
|
||
m.cursor = len(hand) - 1
|
||
}
|
||
}
|
||
|
||
func (m DurakModel) currentHand() []Card {
|
||
idx := m.currentActor()
|
||
if idx < 0 {
|
||
return nil
|
||
}
|
||
return m.game.Players[idx].Hand
|
||
}
|
||
|
||
func (m *DurakModel) setInfo(format string, args ...interface{}) {
|
||
m.message = fmt.Sprintf(format, args...)
|
||
m.isError = false
|
||
}
|
||
|
||
func (m *DurakModel) setError(err error) {
|
||
m.message = T(err.Error())
|
||
m.isError = true
|
||
}
|
||
|
||
func (m DurakModel) 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 DurakModel) handleBotMove() (tea.Model, tea.Cmd) {
|
||
if m.game.Phase == DurakPhaseGameOver {
|
||
return m, nil
|
||
}
|
||
actor := m.currentActor()
|
||
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 != DurakPhaseGameOver && m.currentActor() == m.humanIdx {
|
||
m.resetSelection()
|
||
}
|
||
return m, m.maybeScheduleBot()
|
||
}
|
||
|
||
func (m DurakModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||
key := msg.String()
|
||
|
||
if key == "ctrl+c" {
|
||
m.quitting = true
|
||
return m, tea.Quit
|
||
}
|
||
|
||
if m.game.Phase == DurakPhaseGameOver {
|
||
switch key {
|
||
case "n":
|
||
numPlayers := len(m.game.Players)
|
||
m = NewDurakModel(numPlayers)
|
||
return m, m.maybeScheduleBot()
|
||
case "q":
|
||
m.backToMenu = true
|
||
return m, nil
|
||
}
|
||
return m, nil
|
||
}
|
||
|
||
if key == "q" {
|
||
m.backToMenu = true
|
||
return m, nil
|
||
}
|
||
|
||
if m.currentActor() != m.humanIdx {
|
||
// сейчас действует бот — игнорируем остальной ввод
|
||
return m, nil
|
||
}
|
||
|
||
switch m.game.Phase {
|
||
case DurakPhaseAttack:
|
||
return m.handleAttackKey(key)
|
||
case DurakPhaseDefend:
|
||
return m.handleDefendKey(key)
|
||
case DurakPhaseThrowIn:
|
||
return m.handleThrowInKey(key)
|
||
}
|
||
return m, nil
|
||
}
|
||
|
||
func (m DurakModel) handleAttackKey(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.ThrowIn(card); err != nil {
|
||
m.setError(err)
|
||
return m, nil
|
||
}
|
||
m.setInfo(T("durak.msg.threw_in"), card)
|
||
if m.game.Phase != DurakPhaseGameOver {
|
||
m.resetSelection()
|
||
}
|
||
return m, m.maybeScheduleBot()
|
||
}
|
||
return m, nil
|
||
}
|
||
|
||
func (m DurakModel) handleThrowInKey(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.ThrowIn(card); err != nil {
|
||
m.setError(err)
|
||
return m, nil
|
||
}
|
||
m.setInfo(T("durak.msg.threw_in"), card)
|
||
if m.game.Phase != DurakPhaseGameOver {
|
||
m.resetSelection()
|
||
}
|
||
return m, m.maybeScheduleBot()
|
||
case "p":
|
||
if err := m.game.Pass(); err != nil {
|
||
m.setError(err)
|
||
return m, nil
|
||
}
|
||
m.setInfo(T("durak.msg.passed"))
|
||
if m.game.Phase != DurakPhaseGameOver {
|
||
m.resetSelection()
|
||
}
|
||
return m, m.maybeScheduleBot()
|
||
}
|
||
return m, nil
|
||
}
|
||
|
||
func (m DurakModel) handleDefendKey(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.Defend(card); err != nil {
|
||
m.setError(err)
|
||
return m, nil
|
||
}
|
||
m.setInfo(T("durak.msg.defended"), card)
|
||
if m.game.Phase != DurakPhaseGameOver {
|
||
m.resetSelection()
|
||
}
|
||
return m, m.maybeScheduleBot()
|
||
case "t":
|
||
if err := m.game.Take(); err != nil {
|
||
m.setError(err)
|
||
return m, nil
|
||
}
|
||
m.setInfo(T("durak.msg.took"))
|
||
if m.game.Phase != DurakPhaseGameOver {
|
||
m.resetSelection()
|
||
}
|
||
return m, m.maybeScheduleBot()
|
||
}
|
||
return m, nil
|
||
}
|
||
|
||
// --- Отрисовка -----------------------------------------------------
|
||
|
||
func (m DurakModel) 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 DurakModel) renderTable() string {
|
||
if len(m.game.Table) == 0 {
|
||
return dimStyle.Render(T("tonk.table_empty"))
|
||
}
|
||
parts := make([]string, len(m.game.Table))
|
||
for i, pair := range m.game.Table {
|
||
if pair.Defend != nil {
|
||
parts[i] = fmt.Sprintf("%s/%s", renderCard(pair.Attack), renderCard(*pair.Defend))
|
||
} else {
|
||
parts[i] = fmt.Sprintf("%s/__", renderCard(pair.Attack))
|
||
}
|
||
}
|
||
return strings.Join(parts, " ")
|
||
}
|
||
|
||
func (m DurakModel) renderPlayers() string {
|
||
var b strings.Builder
|
||
for i, p := range m.game.Players {
|
||
marker := " "
|
||
switch {
|
||
case m.game.Phase == DurakPhaseThrowIn && i == m.game.CurrentThrower():
|
||
marker = "T "
|
||
case i == m.game.AttackerIdx && m.game.Phase != DurakPhaseGameOver:
|
||
marker = "A "
|
||
case i == m.game.DefenderIdx && m.game.Phase != DurakPhaseGameOver:
|
||
marker = "D "
|
||
}
|
||
name := p.Name
|
||
status := ""
|
||
if p.Out {
|
||
status = T("durak.status_out")
|
||
}
|
||
line := fmt.Sprintf(T("durak.player_line"), marker, name, len(p.Hand), status)
|
||
if i == m.game.AttackerIdx || i == m.game.DefenderIdx || (m.game.Phase == DurakPhaseThrowIn && i == m.game.CurrentThrower()) {
|
||
line = turnStyle.Render(line)
|
||
}
|
||
fmt.Fprintln(&b, line)
|
||
}
|
||
return strings.TrimRight(b.String(), "\n")
|
||
}
|
||
|
||
func (m DurakModel) View() string {
|
||
if m.quitting {
|
||
return T("common.goodbye")
|
||
}
|
||
|
||
var b strings.Builder
|
||
fmt.Fprintln(&b, titleStyle.Render(T("durak.title")))
|
||
fmt.Fprintln(&b)
|
||
fmt.Fprintln(&b, m.renderPlayers())
|
||
fmt.Fprintln(&b, dimStyle.Render(T("durak.legend")))
|
||
fmt.Fprintln(&b)
|
||
|
||
trumpStr := renderCard(m.game.TrumpCard)
|
||
fmt.Fprintf(&b, T("durak.status_line"), trumpStr, len(m.game.Talon))
|
||
|
||
fmt.Fprintln(&b, T("durak.table_label"))
|
||
fmt.Fprintln(&b, m.renderTable())
|
||
fmt.Fprintln(&b)
|
||
|
||
if m.game.Phase == DurakPhaseGameOver {
|
||
fmt.Fprintln(&b, m.renderResult())
|
||
fmt.Fprintln(&b)
|
||
fmt.Fprintln(&b, dimStyle.Render(T("durak.hint.gameover")))
|
||
return b.String()
|
||
}
|
||
|
||
if m.currentActor() == m.humanIdx {
|
||
fmt.Fprintln(&b, T("common.your_hand"))
|
||
fmt.Fprintln(&b, m.renderHand())
|
||
fmt.Fprintln(&b)
|
||
|
||
switch m.game.Phase {
|
||
case DurakPhaseAttack:
|
||
fmt.Fprintln(&b, T("durak.hint.attack"))
|
||
case DurakPhaseDefend:
|
||
fmt.Fprintln(&b, T("durak.hint.defend"))
|
||
case DurakPhaseThrowIn:
|
||
fmt.Fprintln(&b, T("durak.hint.throwin"))
|
||
}
|
||
} else {
|
||
fmt.Fprintf(&b, T("common.thinking"), m.game.Players[m.currentActor()].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 DurakModel) renderResult() string {
|
||
res := m.game.Result
|
||
if res.Stalemate {
|
||
return errorStyle.Render(T("durak.result.stalemate"))
|
||
}
|
||
if res.NoLoser {
|
||
return winStyle.Render(T("durak.result.noloser"))
|
||
}
|
||
loserName := m.game.Players[res.Loser].Name
|
||
if res.Loser == m.humanIdx {
|
||
return errorStyle.Render(Tf("durak.result.loser", loserName))
|
||
}
|
||
return winStyle.Render(Tf("durak.result.loser", loserName))
|
||
}
|