370 lines
12 KiB
Go
370 lines
12 KiB
Go
package main
|
||
|
||
import (
|
||
"fmt"
|
||
"math/rand"
|
||
"strings"
|
||
"time"
|
||
|
||
tea "github.com/charmbracelet/bubbletea"
|
||
)
|
||
|
||
// NardyModel — TUI поверх NardyGameState. Ходы выбираются из
|
||
// пронумерованного списка легальных ходов (а не курсором по доске)
|
||
// — так же, как уже сделано для боевых действий Странника и
|
||
// действий Трупных батончиков: в нардах набор легальных ходов после
|
||
// броска костей обычно невелик (как правило до 8-9), и пронумерованный
|
||
// список полностью снимает вопрос двумерной навигации по доске.
|
||
type NardyModel struct {
|
||
game *NardyGameState
|
||
bot NardyBot
|
||
rng *rand.Rand
|
||
humanColor NardyPlayer
|
||
|
||
message string
|
||
isError bool
|
||
|
||
// choosingDie / pendingSourceMoves — подрежим выбора кости, когда
|
||
// у выбранного источника больше одного варианта хода (см.
|
||
// nardySourceGroups). Разделение на два шага нужно, потому что
|
||
// плоский список всех легальных ходов в мидгейме коротких нард
|
||
// может заметно превышать 9 пунктов, а различных ИСТОЧНИКОВ хода
|
||
// (с какой шашки) на практике почти никогда не бывает больше 8.
|
||
choosingDie bool
|
||
pendingSourceMoves []NardyMove
|
||
|
||
quitting bool
|
||
backToMenu bool
|
||
}
|
||
|
||
// NewNardyModel начинает новую партию. Кто ходит первым — решается
|
||
// случайным броском одного зара каждой стороне (по правилам, у кого
|
||
// больше — тот и первый); при равенстве — переброс, как и
|
||
// предписывают правила. Человек всегда играет белыми в этой версии
|
||
// (цвет — условность отображения, на баланс сил не влияет).
|
||
func NewNardyModel(variant NardyVariant, difficulty NardyDifficulty, colorChoice ColorChoice) NardyModel {
|
||
rng := rand.New(rand.NewSource(time.Now().UnixNano()))
|
||
humanColor := resolveNardyColor(colorChoice, rng)
|
||
firstPlayer := NardyWhite
|
||
for {
|
||
human, bot := rng.Intn(6)+1, rng.Intn(6)+1
|
||
if human != bot {
|
||
if human > bot {
|
||
firstPlayer = humanColor
|
||
} else {
|
||
firstPlayer = nardyOpponent(humanColor)
|
||
}
|
||
break
|
||
}
|
||
}
|
||
return NardyModel{
|
||
game: NewNardyGame(variant, firstPlayer),
|
||
bot: NardyBot{Difficulty: difficulty},
|
||
rng: rng,
|
||
humanColor: humanColor,
|
||
}
|
||
}
|
||
|
||
// resolveNardyColor переводит выбор цвета из настроек в конкретный
|
||
// NardyPlayer, используя уже созданный генератор модели для
|
||
// случайного выбора (в отличие от Шашек/Шахмат/Го, у Нард и так уже
|
||
// есть постоянный rng для бросков костей).
|
||
func resolveNardyColor(choice ColorChoice, rng *rand.Rand) NardyPlayer {
|
||
switch choice {
|
||
case ColorChoiceA:
|
||
return NardyWhite
|
||
case ColorChoiceB:
|
||
return NardyBlack
|
||
default:
|
||
if rng.Intn(2) == 0 {
|
||
return NardyWhite
|
||
}
|
||
return NardyBlack
|
||
}
|
||
}
|
||
|
||
func (m NardyModel) Init() tea.Cmd {
|
||
return m.maybeScheduleBot()
|
||
}
|
||
|
||
func (m NardyModel) maybeScheduleBot() tea.Cmd {
|
||
if m.game.Phase == NardyPhaseOver {
|
||
return nil
|
||
}
|
||
if m.game.CurrentPlayer == m.humanColor {
|
||
return nil
|
||
}
|
||
return tea.Tick(botMoveDelay, func(time.Time) tea.Msg { return botMoveMsg{} })
|
||
}
|
||
|
||
func (m *NardyModel) setInfo(key string) {
|
||
m.message = T(key)
|
||
m.isError = false
|
||
}
|
||
|
||
func (m *NardyModel) setError(err error) {
|
||
m.message = err.Error()
|
||
m.isError = true
|
||
}
|
||
|
||
func (m NardyModel) 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 NardyModel) handleBotMove() (tea.Model, tea.Cmd) {
|
||
if m.game.Phase == NardyPhaseOver || 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("nardy.msg.bot_moved")
|
||
return m, m.maybeScheduleBot()
|
||
}
|
||
|
||
// nardySourceGroups группирует легальные ходы по источнику (с бара
|
||
// или с конкретной точки) — на практике различных источников редко
|
||
// бывает больше 8, даже когда общий список ходов (по всем костям и
|
||
// точкам вместе) заметно превышает 9.
|
||
func nardySourceGroups(moves []NardyMove) [][]NardyMove {
|
||
var order []string
|
||
groups := map[string][]NardyMove{}
|
||
for _, mv := range moves {
|
||
key := fmt.Sprintf("%v-%d", mv.FromBar, mv.From)
|
||
if _, ok := groups[key]; !ok {
|
||
order = append(order, key)
|
||
}
|
||
groups[key] = append(groups[key], mv)
|
||
}
|
||
out := make([][]NardyMove, 0, len(order))
|
||
for _, key := range order {
|
||
out = append(out, groups[key])
|
||
}
|
||
return out
|
||
}
|
||
|
||
func nardySourceLabel(mv NardyMove) string {
|
||
if mv.FromBar {
|
||
return T("nardy.source_bar")
|
||
}
|
||
return fmt.Sprintf("%d", mv.From+1)
|
||
}
|
||
|
||
func (m NardyModel) 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 == NardyPhaseOver || m.game.CurrentPlayer != m.humanColor {
|
||
return m, nil
|
||
}
|
||
|
||
if m.choosingDie {
|
||
if key == "esc" {
|
||
m.choosingDie = false
|
||
m.pendingSourceMoves = nil
|
||
return m, nil
|
||
}
|
||
for i := 1; i <= 9; i++ {
|
||
if key != fmt.Sprintf("%d", i) || i > len(m.pendingSourceMoves) {
|
||
continue
|
||
}
|
||
mv := m.pendingSourceMoves[i-1]
|
||
m.choosingDie = false
|
||
m.pendingSourceMoves = nil
|
||
return m.applyHumanMove(mv)
|
||
}
|
||
return m, nil
|
||
}
|
||
|
||
if m.game.Phase == NardyPhaseRoll {
|
||
if key == "r" || key == "enter" || key == " " {
|
||
m.game.Roll(m.rng.Intn(6)+1, m.rng.Intn(6)+1)
|
||
if m.game.CurrentPlayer != m.humanColor {
|
||
m.setInfo("nardy.msg.no_legal_moves")
|
||
return m, m.maybeScheduleBot()
|
||
}
|
||
m.message = ""
|
||
return m, nil
|
||
}
|
||
return m, nil
|
||
}
|
||
|
||
// NardyPhaseMove: выбор источника хода (шаг 1 из потенциально двух)
|
||
groups := nardySourceGroups(m.game.LegalMovesNow())
|
||
for i := 1; i <= 9; i++ {
|
||
if key != fmt.Sprintf("%d", i) || i > len(groups) {
|
||
continue
|
||
}
|
||
group := groups[i-1]
|
||
if len(group) == 1 {
|
||
return m.applyHumanMove(group[0])
|
||
}
|
||
m.choosingDie = true
|
||
m.pendingSourceMoves = group
|
||
return m, nil
|
||
}
|
||
return m, nil
|
||
}
|
||
|
||
// applyHumanMove применяет один выбранный ход человека и планирует
|
||
// следующий шаг (ход бота, если дошла его очередь, либо ожидание
|
||
// следующего выбора).
|
||
func (m NardyModel) applyHumanMove(mv NardyMove) (tea.Model, tea.Cmd) {
|
||
if err := m.game.ApplyMove(mv); err != nil {
|
||
m.setError(err)
|
||
return m, nil
|
||
}
|
||
m.message = ""
|
||
if m.game.Phase == NardyPhaseOver {
|
||
m.setInfo("nardy.msg.game_over")
|
||
return m, nil
|
||
}
|
||
return m, m.maybeScheduleBot()
|
||
}
|
||
|
||
// nardyPlayerSymbol — короткое обозначение цвета для отображения.
|
||
func nardyPlayerSymbol(p NardyPlayer) string {
|
||
if p == NardyWhite {
|
||
return "○"
|
||
}
|
||
return "●"
|
||
}
|
||
|
||
// nardyMoveLabel формирует человекочитаемое описание одного хода в
|
||
// традиционной нотации нард (точки нумеруются 1-24 от дома игрока,
|
||
// а не 0-23 как во внутреннем представлении).
|
||
func nardyMoveLabel(mv NardyMove) string {
|
||
from := "с бара"
|
||
var dest string
|
||
if mv.FromBar {
|
||
entryRel := 24 - mv.Die
|
||
dest = fmt.Sprintf("%d", entryRel+1)
|
||
} else {
|
||
from = fmt.Sprintf("%d", mv.From+1)
|
||
if mv.BearOff {
|
||
dest = "снятие"
|
||
} else {
|
||
dest = fmt.Sprintf("%d", mv.From-mv.Die+1)
|
||
}
|
||
}
|
||
return Tf("nardy.move_label", from, dest, mv.Die)
|
||
}
|
||
|
||
func (m NardyModel) View() string {
|
||
if m.quitting {
|
||
return T("common.goodbye")
|
||
}
|
||
var b strings.Builder
|
||
title := T("nardy.title.short")
|
||
if m.game.Variant == NardyLong {
|
||
title = T("nardy.title.long")
|
||
}
|
||
fmt.Fprintln(&b, titleStyle.Render(title))
|
||
fmt.Fprintln(&b)
|
||
fmt.Fprintln(&b, m.renderBoard())
|
||
fmt.Fprintln(&b)
|
||
fmt.Fprintln(&b, Tf("nardy.bar_off_line",
|
||
nardyPlayerSymbol(NardyWhite), m.game.Bar[NardyWhite],
|
||
nardyPlayerSymbol(NardyBlack), m.game.Bar[NardyBlack],
|
||
nardyPlayerSymbol(NardyWhite), m.game.BorneOff[NardyWhite],
|
||
nardyPlayerSymbol(NardyBlack), m.game.BorneOff[NardyBlack]))
|
||
fmt.Fprintln(&b)
|
||
|
||
you := Tf("nardy.you_are", nardyPlayerSymbol(m.humanColor))
|
||
fmt.Fprintln(&b, dimStyle.Render(you))
|
||
|
||
switch m.game.Phase {
|
||
case NardyPhaseOver:
|
||
if m.game.Result.Winner == m.humanColor {
|
||
fmt.Fprintln(&b, winStyle.Render(T("nardy.outcome.win")))
|
||
} else {
|
||
fmt.Fprintln(&b, errorStyle.Render(T("nardy.outcome.lose")))
|
||
}
|
||
case NardyPhaseRoll:
|
||
if m.game.CurrentPlayer == m.humanColor {
|
||
fmt.Fprintln(&b, T("nardy.hint.roll"))
|
||
} else {
|
||
fmt.Fprintln(&b, T("common.thinking_generic"))
|
||
}
|
||
case NardyPhaseMove:
|
||
fmt.Fprintln(&b, Tf("nardy.dice_line", m.game.Dice[0], m.game.Dice[1], strings.Trim(fmt.Sprint(m.game.DiceLeft), "[]")))
|
||
if m.game.CurrentPlayer == m.humanColor {
|
||
if m.choosingDie {
|
||
fmt.Fprintln(&b, T("nardy.hint.pick_die"))
|
||
for i, mv := range m.pendingSourceMoves {
|
||
if i >= 9 {
|
||
break
|
||
}
|
||
fmt.Fprintf(&b, "[%d] %s\n", i+1, nardyMoveLabel(mv))
|
||
}
|
||
fmt.Fprintln(&b, dimStyle.Render(T("nardy.hint.cancel_source")))
|
||
} else {
|
||
fmt.Fprintln(&b, T("nardy.hint.pick_source"))
|
||
groups := nardySourceGroups(m.game.LegalMovesNow())
|
||
for i, group := range groups {
|
||
if i >= 9 {
|
||
break
|
||
}
|
||
fmt.Fprintf(&b, "[%d] %s\n", i+1, nardySourceLabel(group[0]))
|
||
}
|
||
}
|
||
} 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("nardy.hint.quit")))
|
||
return b.String()
|
||
}
|
||
|
||
// renderBoard рисует компактное текстовое представление доски: две
|
||
// строки по 12 точек, всегда с точки зрения играющего за белых
|
||
// (традиционная нумерация 1-24) — единая система отсчёта на экране
|
||
// независимо от того, за кого в этой партии играет человек.
|
||
func (m NardyModel) renderBoard() string {
|
||
var b strings.Builder
|
||
cell := func(abs int) string {
|
||
pt := m.game.Board[abs]
|
||
if pt.Count == 0 {
|
||
return dimStyle.Render(" . ")
|
||
}
|
||
return fmt.Sprintf("%s%-2d", nardyPlayerSymbol(pt.Owner), pt.Count)
|
||
}
|
||
|
||
var top []string
|
||
for abs := 12; abs <= 23; abs++ {
|
||
top = append(top, cell(abs))
|
||
}
|
||
fmt.Fprintln(&b, "13 14 15 16 17 18 19 20 21 22 23 24")
|
||
fmt.Fprintln(&b, strings.Join(top, " "))
|
||
fmt.Fprintln(&b)
|
||
var bottom []string
|
||
for abs := 11; abs >= 0; abs-- {
|
||
bottom = append(bottom, cell(abs))
|
||
}
|
||
fmt.Fprintln(&b, strings.Join(bottom, " "))
|
||
fmt.Fprint(&b, "12 11 10 9 8 7 6 5 4 3 2 1")
|
||
return b.String()
|
||
}
|