272 lines
6.5 KiB
Go
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()
|
|
}
|