go_games_collection/senet_tui.go
2026-07-10 09:12:06 +03:00

272 lines
6.5 KiB
Go

package main
import (
"fmt"
"math/rand"
"strings"
"time"
tea "github.com/charmbracelet/bubbletea"
)
// SenetModel — TUI поверх SenetGameState.
type SenetModel struct {
game *SenetGameState
bot SenetBot
rng *rand.Rand
humanColor SenetPlayer
message string
isError bool
quitting bool
backToMenu bool
}
// NewSenetModel начинает новую партию; кто ходит первым — решается
// случайным броском.
func NewSenetModel(difficulty SenetDifficulty, colorChoice ColorChoice) SenetModel {
rng := rand.New(rand.NewSource(time.Now().UnixNano()))
humanColor := resolveSenetColor(colorChoice, rng)
first := SenetPlayerA
if rng.Intn(2) == 1 {
first = SenetPlayerB
}
return SenetModel{
game: NewSenetGame(first),
bot: SenetBot{Difficulty: difficulty},
rng: rng,
humanColor: humanColor,
}
}
// resolveSenetColor переводит выбор цвета из настроек в конкретного
// SenetPlayer, используя уже созданный генератор модели.
func resolveSenetColor(choice ColorChoice, rng *rand.Rand) SenetPlayer {
switch choice {
case ColorChoiceA:
return SenetPlayerA
case ColorChoiceB:
return SenetPlayerB
default:
if rng.Intn(2) == 0 {
return SenetPlayerA
}
return SenetPlayerB
}
}
func (m SenetModel) Init() tea.Cmd {
return m.maybeScheduleBot()
}
func (m SenetModel) maybeScheduleBot() tea.Cmd {
if m.game.Result != nil || m.game.CurrentPlayer == m.humanColor {
return nil
}
return tea.Tick(botMoveDelay, func(time.Time) tea.Msg { return botMoveMsg{} })
}
func (m *SenetModel) setInfo(key string) {
m.message = T(key)
m.isError = false
}
func (m *SenetModel) setError(err error) {
m.message = err.Error()
m.isError = true
}
func (m SenetModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case botMoveMsg:
return m.handleBotMove()
case tea.KeyMsg:
return m.handleKey(msg)
}
return m, nil
}
func (m SenetModel) handleBotMove() (tea.Model, tea.Cmd) {
if m.game.Result != nil || m.game.CurrentPlayer == m.humanColor {
return m, nil
}
if err := m.bot.PlayFullTurn(m.game, m.rng); err != nil {
m.setError(err)
return m, nil
}
m.setInfo("senet.msg.bot_moved")
return m, m.maybeScheduleBot()
}
func (m SenetModel) 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.Result != nil || m.game.CurrentPlayer != m.humanColor {
return m, nil
}
if !m.game.Rolled {
if key == "r" || key == "enter" || key == " " {
if err := m.game.Roll(senetRollSticks(m.rng)); err != nil {
m.setError(err)
return m, nil
}
if m.game.CurrentPlayer != m.humanColor {
m.setInfo("senet.msg.no_legal_moves")
return m, m.maybeScheduleBot()
}
m.message = ""
return m, nil
}
return m, nil
}
for i := 1; i <= 9; i++ {
if key != fmt.Sprintf("%d", i) {
continue
}
moves := m.game.LegalMoves()
if i > len(moves) {
return m, nil
}
if err := m.game.Move(moves[i-1].From); err != nil {
m.setError(err)
return m, nil
}
m.message = ""
if m.game.Result != nil {
m.setInfo("senet.msg.game_over")
return m, nil
}
return m, m.maybeScheduleBot()
}
return m, nil
}
func senetPlayerSymbol(p SenetPlayer) string {
if p == SenetPlayerA {
return "○"
}
return "●"
}
// senetMoveLabel описывает один ход человекочитаемо.
func senetMoveLabel(g *SenetGameState, mv SenetMove) string {
step := 1
if mv.Backward {
step = -1
}
dest := mv.From + step*g.DiceValue
to := T("senet.off")
if dest <= SenetBoardSize {
to = fmt.Sprintf("%d", dest)
}
dir := ""
if mv.Backward {
dir = " " + T("senet.backward")
}
return Tf("senet.move_label", mv.From, to, dir)
}
func (m SenetModel) View() string {
if m.quitting {
return T("common.goodbye")
}
var b strings.Builder
fmt.Fprintln(&b, titleStyle.Render(T("senet.title")))
fmt.Fprintln(&b)
fmt.Fprintln(&b, m.renderBoard())
fmt.Fprintln(&b)
fmt.Fprintln(&b, Tf("senet.you_are", senetPlayerSymbol(m.humanColor)))
fmt.Fprintln(&b, Tf("senet.off_line",
senetPlayerSymbol(SenetPlayerA), m.game.BorneOff[SenetPlayerA],
senetPlayerSymbol(SenetPlayerB), m.game.BorneOff[SenetPlayerB]))
switch {
case m.game.Result != nil:
if m.game.Result.Winner == m.humanColor {
fmt.Fprintln(&b, winStyle.Render(T("senet.outcome.win")))
} else {
fmt.Fprintln(&b, errorStyle.Render(T("senet.outcome.lose")))
}
case !m.game.Rolled:
if m.game.CurrentPlayer == m.humanColor {
fmt.Fprintln(&b, T("senet.hint.roll"))
} else {
fmt.Fprintln(&b, T("common.thinking_generic"))
}
default:
fmt.Fprintln(&b, Tf("senet.dice_line", m.game.DiceValue))
if m.game.CurrentPlayer == m.humanColor {
moves := m.game.LegalMoves()
if len(moves) == 0 {
fmt.Fprintln(&b, T("senet.hint.no_moves"))
} else {
fmt.Fprintln(&b, T("senet.hint.pick_move"))
for i, mv := range moves {
if i >= 9 {
break
}
fmt.Fprintf(&b, "[%d] %s\n", i+1, senetMoveLabel(m.game, mv))
}
}
} else {
fmt.Fprintln(&b, T("common.thinking_generic"))
}
}
if m.message != "" {
if m.isError {
fmt.Fprintln(&b, errorStyle.Render("! "+m.message))
} else {
fmt.Fprintln(&b, infoStyle.Render(m.message))
}
}
fmt.Fprintln(&b)
fmt.Fprintln(&b, dimStyle.Render(T("senet.hint.quit")))
return b.String()
}
// renderBoard рисует доску Сенета: три ряда по десять клеток, путь
// змейкой (2-й ряд отображается в обратном порядке номеров).
func (m SenetModel) renderBoard() string {
cell := func(sq int) string {
owner, occupied := senetCellOwner(m.game.Board[sq])
if occupied {
return " " + senetPlayerSymbol(owner) + " "
}
switch sq {
case SenetHouseWater:
return dimStyle.Render(" ~ ")
case SenetHouseRebirth, SenetHouseBeauty, SenetHouseThree, SenetHouseTwo:
return dimStyle.Render(" ✿ ")
}
return dimStyle.Render(fmt.Sprintf("%2d ", sq))
}
var row1, row2, row3 []string
for sq := 1; sq <= 10; sq++ {
row1 = append(row1, cell(sq))
}
for sq := 20; sq >= 11; sq-- {
row2 = append(row2, cell(sq))
}
for sq := 21; sq <= 30; sq++ {
row3 = append(row3, cell(sq))
}
var b strings.Builder
fmt.Fprintln(&b, strings.Join(row1, ""))
fmt.Fprintln(&b, strings.Join(row2, ""))
fmt.Fprint(&b, strings.Join(row3, ""))
return b.String()
}