287 lines
6.9 KiB
Go
287 lines
6.9 KiB
Go
package main
|
||
|
||
import (
|
||
"fmt"
|
||
"math/rand"
|
||
"strings"
|
||
"time"
|
||
|
||
tea "github.com/charmbracelet/bubbletea"
|
||
)
|
||
|
||
// UrModel — TUI поверх UrGameState.
|
||
type UrModel struct {
|
||
game *UrGameState
|
||
bot UrBot
|
||
rng *rand.Rand
|
||
humanColor UrPlayer
|
||
|
||
message string
|
||
isError bool
|
||
|
||
quitting bool
|
||
backToMenu bool
|
||
}
|
||
|
||
// NewUrModel начинает новую партию; кто ходит первым — решается
|
||
// случайным броском (как и предписывают правила).
|
||
func NewUrModel(difficulty UrDifficulty) UrModel {
|
||
rng := rand.New(rand.NewSource(time.Now().UnixNano()))
|
||
first := UrPlayerA
|
||
if rng.Intn(2) == 1 {
|
||
first = UrPlayerB
|
||
}
|
||
return UrModel{
|
||
game: NewUrGame(first),
|
||
bot: UrBot{Difficulty: difficulty},
|
||
rng: rng,
|
||
humanColor: UrPlayerA,
|
||
}
|
||
}
|
||
|
||
func (m UrModel) Init() tea.Cmd {
|
||
return m.maybeScheduleBot()
|
||
}
|
||
|
||
func (m UrModel) 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 *UrModel) setInfo(key string) {
|
||
m.message = T(key)
|
||
m.isError = false
|
||
}
|
||
|
||
func (m *UrModel) setError(err error) {
|
||
m.message = err.Error()
|
||
m.isError = true
|
||
}
|
||
|
||
func (m UrModel) 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 UrModel) 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("ur.msg.bot_moved")
|
||
return m, m.maybeScheduleBot()
|
||
}
|
||
|
||
func (m UrModel) 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(urRollDice(m.rng)); err != nil {
|
||
m.setError(err)
|
||
return m, nil
|
||
}
|
||
if m.game.CurrentPlayer != m.humanColor {
|
||
m.setInfo("ur.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].PieceIdx); err != nil {
|
||
m.setError(err)
|
||
return m, nil
|
||
}
|
||
m.message = ""
|
||
if m.game.Result != nil {
|
||
m.setInfo("ur.msg.game_over")
|
||
return m, nil
|
||
}
|
||
return m, m.maybeScheduleBot()
|
||
}
|
||
return m, nil
|
||
}
|
||
|
||
func urPlayerSymbol(p UrPlayer) string {
|
||
if p == UrPlayerA {
|
||
return "○"
|
||
}
|
||
return "●"
|
||
}
|
||
|
||
// urMoveLabel описывает один ход человекочитаемо: откуда и куда
|
||
// (в терминах 1-индексированного пути игрока, 0=рука, 15=снятие).
|
||
func urMoveLabel(g *UrGameState, mv UrMove) string {
|
||
pos := g.Path[g.CurrentPlayer][mv.PieceIdx]
|
||
dest := pos + g.DiceValue
|
||
from := T("ur.hand")
|
||
if pos > 0 {
|
||
from = fmt.Sprintf("%d", pos)
|
||
}
|
||
to := T("ur.off")
|
||
if dest <= UrPathLength {
|
||
to = fmt.Sprintf("%d", dest)
|
||
}
|
||
return Tf("ur.move_label", from, to)
|
||
}
|
||
|
||
func (m UrModel) View() string {
|
||
if m.quitting {
|
||
return T("common.goodbye")
|
||
}
|
||
var b strings.Builder
|
||
fmt.Fprintln(&b, titleStyle.Render(T("ur.title")))
|
||
fmt.Fprintln(&b)
|
||
fmt.Fprintln(&b, m.renderBoard())
|
||
fmt.Fprintln(&b)
|
||
fmt.Fprintln(&b, Tf("ur.you_are", urPlayerSymbol(m.humanColor)))
|
||
fmt.Fprintln(&b, Tf("ur.hand_off_line",
|
||
urPlayerSymbol(UrPlayerA), m.countInHand(UrPlayerA),
|
||
urPlayerSymbol(UrPlayerB), m.countInHand(UrPlayerB),
|
||
urPlayerSymbol(UrPlayerA), m.countBorneOff(UrPlayerA),
|
||
urPlayerSymbol(UrPlayerB), m.countBorneOff(UrPlayerB)))
|
||
|
||
switch {
|
||
case m.game.Result != nil:
|
||
if m.game.Result.Winner == m.humanColor {
|
||
fmt.Fprintln(&b, winStyle.Render(T("ur.outcome.win")))
|
||
} else {
|
||
fmt.Fprintln(&b, errorStyle.Render(T("ur.outcome.lose")))
|
||
}
|
||
case !m.game.Rolled:
|
||
if m.game.CurrentPlayer == m.humanColor {
|
||
fmt.Fprintln(&b, T("ur.hint.roll"))
|
||
} else {
|
||
fmt.Fprintln(&b, T("common.thinking_generic"))
|
||
}
|
||
default:
|
||
fmt.Fprintln(&b, Tf("ur.dice_line", m.game.DiceValue))
|
||
if m.game.CurrentPlayer == m.humanColor {
|
||
moves := m.game.LegalMoves()
|
||
if len(moves) == 0 {
|
||
fmt.Fprintln(&b, T("ur.hint.no_moves"))
|
||
} else {
|
||
fmt.Fprintln(&b, T("ur.hint.pick_move"))
|
||
for i, mv := range moves {
|
||
if i >= 9 {
|
||
break
|
||
}
|
||
fmt.Fprintf(&b, "[%d] %s\n", i+1, urMoveLabel(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("ur.hint.quit")))
|
||
return b.String()
|
||
}
|
||
|
||
func (m UrModel) countInHand(p UrPlayer) int {
|
||
n := 0
|
||
for _, pos := range m.game.Path[p] {
|
||
if pos == 0 {
|
||
n++
|
||
}
|
||
}
|
||
return n
|
||
}
|
||
|
||
func (m UrModel) countBorneOff(p UrPlayer) int {
|
||
n := 0
|
||
for _, pos := range m.game.Path[p] {
|
||
if pos == UrPathLength+1 {
|
||
n++
|
||
}
|
||
}
|
||
return n
|
||
}
|
||
|
||
// renderBoard рисует схематичную доску Ура: два частных ряда (по 4
|
||
// клетки входа и 2 выхода, с разрывом на месте "мостика", которого
|
||
// в этих рядах физически нет) и общий средний ряд из восьми клеток.
|
||
func (m UrModel) renderBoard() string {
|
||
var b strings.Builder
|
||
|
||
privateCell := func(player UrPlayer, pathIdx int) string {
|
||
if _, occupied := m.game.occupantOnPrivate(player, pathIdx); occupied {
|
||
return " " + urPlayerSymbol(player) + " "
|
||
}
|
||
if urRosettePathIndices[pathIdx] {
|
||
return dimStyle.Render(" ✿ ")
|
||
}
|
||
return dimStyle.Render(" · ")
|
||
}
|
||
sharedCell := func(sharedIdx int) string {
|
||
pathIdx := sharedIdx + 5
|
||
if occ, _, occupied := m.game.occupantOnShared(sharedIdx); occupied {
|
||
return " " + urPlayerSymbol(occ) + " "
|
||
}
|
||
if urRosettePathIndices[pathIdx] {
|
||
return dimStyle.Render(" ✿ ")
|
||
}
|
||
return dimStyle.Render(" · ")
|
||
}
|
||
|
||
var topRow, midRow, botRow []string
|
||
for i := 1; i <= 4; i++ {
|
||
topRow = append(topRow, privateCell(UrPlayerA, i))
|
||
botRow = append(botRow, privateCell(UrPlayerB, i))
|
||
}
|
||
topRow = append(topRow, " ")
|
||
botRow = append(botRow, " ")
|
||
for i := 13; i <= 14; i++ {
|
||
topRow = append(topRow, privateCell(UrPlayerA, i))
|
||
botRow = append(botRow, privateCell(UrPlayerB, i))
|
||
}
|
||
for i := 0; i <= 7; i++ {
|
||
midRow = append(midRow, sharedCell(i))
|
||
}
|
||
|
||
fmt.Fprintln(&b, strings.Join(topRow, ""))
|
||
fmt.Fprintln(&b, strings.Join(midRow, ""))
|
||
fmt.Fprint(&b, strings.Join(botRow, ""))
|
||
return b.String()
|
||
}
|