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

370 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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()
}