go_games_collection/menu.go
2026-07-04 13:51:04 +03:00

772 lines
24 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"
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
// GameEntry — запись в реестре игр коллекции. Регистр сделан так,
// чтобы добавление новой игры сводилось к добавлению одной записи
// в gameRegistry, без изменений в логике меню.
type GameEntry struct {
Name string
Description string
Rules string
New func() tea.Model
// UsesStakes — включает экран настройки ставки перед запуском:
// стартовый капитал, размер анте и множитель банка при особой
// победе (в Тонке — объявление Тонка). Если false, игра
// запускается сразу через New(), без экрана настроек.
UsesStakes bool
NewWithStakes func(startingCapital, ante, bonusMultiplier int) tea.Model
// UsesPlayerCount — включает экран выбора количества игроков
// (2-6, включая человека) перед запуском.
UsesPlayerCount bool
NewWithPlayerCount func(numPlayers int) tea.Model
// UsesBlackjackSettings — включает совмещённый экран настройки
// перед запуском: число игроков, стартовый капитал и размер
// ставки за раунд. Отдельный флаг вместо переиспользования
// UsesStakes/UsesPlayerCount, потому что Блэкджеку нужны все три
// параметра сразу на одном экране.
UsesBlackjackSettings bool
NewBlackjackWithSettings func(numPlayers, startingCapital, bet int) tea.Model
// UsesCheckersSettings — включает экран выбора уровня сложности
// бота (простой/сильный) перед началом партии.
UsesCheckersSettings bool
NewCheckersWithDifficulty func(depth int) tea.Model
}
// gameRegistry — список доступных игр коллекции.
var gameRegistry = []GameEntry{
{
Name: "Тонк (Tonk)",
Description: "Карточная игра на 1 человека + 3 бота разного уровня сложности",
Rules: tonkRules,
New: func() tea.Model { return NewModel() },
UsesStakes: true,
NewWithStakes: func(capital, ante, mult int) tea.Model { return NewModelWithStakes(capital, ante, mult) },
},
{
Name: "Дурак (Durak)",
Description: "Классическая карточная игра на 2-6 игроков (подкидной вариант)",
Rules: durakRules,
New: func() tea.Model { return NewDurakModel(4) },
UsesPlayerCount: true,
NewWithPlayerCount: func(n int) tea.Model { return NewDurakModel(n) },
},
{
Name: "Блэкджек (Blackjack)",
Description: "Каждый играет против дилера, 1-6 игроков включая человека",
Rules: blackjackRules,
UsesBlackjackSettings: true,
NewBlackjackWithSettings: func(numPlayers, capital, bet int) tea.Model {
return NewBlackjackModel(numPlayers, capital, bet)
},
},
{
Name: "101",
Description: "Избавьтесь от карт первым; штрафные очки за оставшиеся на руках, до 101 — выбывание",
Rules: oneOhOneRules,
New: func() tea.Model { return NewOneOhOneModel(4) },
UsesPlayerCount: true,
NewWithPlayerCount: func(n int) tea.Model { return NewOneOhOneModel(n) },
},
{
Name: "1000 (Тысяча)",
Description: "Взяточная игра с торгами и марьяжами, строго на троих (вы + 2 бота)",
Rules: thousandRules,
New: func() tea.Model { return NewThousandModel() },
},
{
Name: "Косынка (Klondike)",
Description: "Классический пасьянс на одного игрока, без ботов",
Rules: klondikeRules,
New: func() tea.Model { return NewKlondikeModel() },
},
{
Name: "Шашки (Checkers)",
Description: "Русские шашки против бота (минимакс), обязательное взятие",
Rules: checkersRules,
UsesCheckersSettings: true,
NewCheckersWithDifficulty: func(depth int) tea.Model {
return NewCheckersModel(depth)
},
},
}
type appState int
const (
stateMenu appState = iota
stateRulesMenu
stateRules
stateSettings
statePlayerCount
stateBlackjackSettings
stateCheckersSettings
statePlaying
)
// stakeSettings — экран настройки ставки перед игрой: значения
// перебираются по пресетам (а не вводятся текстом), чтобы обойтись
// без отдельного text-input компонента в TUI.
type stakeSettings struct {
capitalOptions []int
anteOptions []int
multiplierOptions []int
capitalIdx int
anteIdx int
multiplierIdx int
fieldCursor int // 0 = капитал, 1 = анте, 2 = множитель
gameIdx int // на какую игру из gameRegistry применится
}
func newStakeSettings(gameIdx int) stakeSettings {
return stakeSettings{
capitalOptions: []int{50, 100, 200, 500, 1000},
anteOptions: []int{1, 5, 10, 20, 50},
multiplierOptions: []int{2, 3, 4},
capitalIdx: 1, // 100
anteIdx: 1, // 5
multiplierIdx: 0, // 2
gameIdx: gameIdx,
}
}
func (s stakeSettings) capital() int { return s.capitalOptions[s.capitalIdx] }
func (s stakeSettings) ante() int { return s.anteOptions[s.anteIdx] }
func (s stakeSettings) multiplier() int { return s.multiplierOptions[s.multiplierIdx] }
// cycle сдвигает значение текущего выбранного поля на delta шагов
// (±1), с ограничением по краям списка пресетов (без зацикливания).
func (s *stakeSettings) cycle(delta int) {
clamp := func(idx, max int) int {
idx += delta
if idx < 0 {
return 0
}
if idx > max {
return max
}
return idx
}
switch s.fieldCursor {
case 0:
s.capitalIdx = clamp(s.capitalIdx, len(s.capitalOptions)-1)
case 1:
s.anteIdx = clamp(s.anteIdx, len(s.anteOptions)-1)
case 2:
s.multiplierIdx = clamp(s.multiplierIdx, len(s.multiplierOptions)-1)
}
}
// playerCountSettings — экран выбора количества игроков (2-6,
// включая человека) перед игрой вроде Дурака.
type playerCountSettings struct {
count int
gameIdx int
}
func newPlayerCountSettings(gameIdx int) playerCountSettings {
return playerCountSettings{count: 4, gameIdx: gameIdx}
}
func (s *playerCountSettings) cycle(delta int) {
s.count += delta
if s.count < 2 {
s.count = 2
}
if s.count > 6 {
s.count = 6
}
}
// blackjackSettings — совмещённый экран настройки перед Блэкджеком:
// число игроков (1-6, включая человека — можно играть и в одиночку
// против дилера), стартовый капитал и размер ставки за раунд.
// checkersSettings — экран выбора уровня сложности бота перед
// партией в шашки: два уровня, разница только в глубине поиска.
type checkersSettings struct {
levelIdx int
gameIdx int
}
var checkersLevels = []struct {
Label string
Depth int
Desc string
}{
{Label: "Простой", Depth: CheckersDifficultyEasy, Desc: "быстрый, ощутимо слабее"},
{Label: "Сильный", Depth: CheckersDifficultyHard, Desc: "заметно сильнее, чуть медленнее думает"},
}
func newCheckersSettings(gameIdx int) checkersSettings {
return checkersSettings{levelIdx: 0, gameIdx: gameIdx}
}
func (s *checkersSettings) cycle(delta int) {
s.levelIdx += delta
if s.levelIdx < 0 {
s.levelIdx = 0
}
if s.levelIdx > len(checkersLevels)-1 {
s.levelIdx = len(checkersLevels) - 1
}
}
type blackjackSettings struct {
capitalOptions []int
betOptions []int
playerCount int
capitalIdx int
betIdx int
fieldCursor int // 0 = число игроков, 1 = капитал, 2 = ставка
gameIdx int
}
func newBlackjackSettings(gameIdx int) blackjackSettings {
return blackjackSettings{
capitalOptions: []int{50, 100, 200, 500, 1000},
betOptions: []int{5, 10, 25, 50, 100},
playerCount: 1,
capitalIdx: 1, // 100
betIdx: 1, // 10
gameIdx: gameIdx,
}
}
func (s blackjackSettings) capital() int { return s.capitalOptions[s.capitalIdx] }
func (s blackjackSettings) bet() int { return s.betOptions[s.betIdx] }
func (s *blackjackSettings) cycle(delta int) {
clamp := func(idx, max int) int {
idx += delta
if idx < 0 {
return 0
}
if idx > max {
return max
}
return idx
}
switch s.fieldCursor {
case 0:
s.playerCount += delta
if s.playerCount < 1 {
s.playerCount = 1
}
if s.playerCount > 6 {
s.playerCount = 6
}
case 1:
s.capitalIdx = clamp(s.capitalIdx, len(s.capitalOptions)-1)
case 2:
s.betIdx = clamp(s.betIdx, len(s.betOptions)-1)
}
}
var (
menuTitleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("212")).Bold(true)
menuSubtitleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("245")).Italic(true)
menuItemStyle = lipgloss.NewStyle()
menuCursorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("228")).Bold(true)
menuDescStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("245"))
menuHintStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("245")).Faint(true)
)
// menuOption — один пункт меню: либо запуск игры из реестра, либо
// служебное действие (правила/выход).
type menuOption struct {
label string
desc string
kind string // "game", "rules", "quit"
gameIdx int // индекс в gameRegistry, если kind == "game"
}
// AppModel — корневая модель приложения: меню, экран правил или
// запущенная игра.
type AppModel struct {
state appState
cursor int
rulesCursor int
options []menuOption
rulesText string
settings stakeSettings
playerCount playerCountSettings
blackjack blackjackSettings
checkers checkersSettings
activeGame tea.Model
quitting bool
}
func NewAppModel() AppModel {
options := make([]menuOption, 0, len(gameRegistry)+2)
for i, g := range gameRegistry {
options = append(options, menuOption{label: g.Name, desc: g.Description, kind: "game", gameIdx: i})
}
options = append(options, menuOption{label: "Правила", desc: "Прочитать правила выбранной игры", kind: "rules"})
options = append(options, menuOption{label: "Выход", desc: "Закрыть коллекцию игр", kind: "quit"})
return AppModel{
state: stateMenu,
options: options,
}
}
func (m AppModel) Init() tea.Cmd {
return nil
}
func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch m.state {
case statePlaying:
return m.updatePlaying(msg)
case stateRulesMenu:
return m.updateRulesMenu(msg)
case stateRules:
return m.updateRules(msg)
case stateSettings:
return m.updateSettings(msg)
case statePlayerCount:
return m.updatePlayerCount(msg)
case stateBlackjackSettings:
return m.updateBlackjackSettings(msg)
case stateCheckersSettings:
return m.updateCheckersSettings(msg)
default:
return m.updateMenu(msg)
}
}
func (m AppModel) updateMenu(msg tea.Msg) (tea.Model, tea.Cmd) {
keyMsg, ok := msg.(tea.KeyMsg)
if !ok {
return m, nil
}
switch keyMsg.String() {
case "ctrl+c", "q":
m.quitting = true
return m, tea.Quit
case "up", "k":
if m.cursor > 0 {
m.cursor--
}
case "down", "j":
if m.cursor < len(m.options)-1 {
m.cursor++
}
case "enter":
selected := m.options[m.cursor]
switch selected.kind {
case "game":
entry := gameRegistry[selected.gameIdx]
switch {
case entry.UsesStakes:
m.settings = newStakeSettings(selected.gameIdx)
m.state = stateSettings
return m, nil
case entry.UsesPlayerCount:
m.playerCount = newPlayerCountSettings(selected.gameIdx)
m.state = statePlayerCount
return m, nil
case entry.UsesBlackjackSettings:
m.blackjack = newBlackjackSettings(selected.gameIdx)
m.state = stateBlackjackSettings
return m, nil
case entry.UsesCheckersSettings:
m.checkers = newCheckersSettings(selected.gameIdx)
m.state = stateCheckersSettings
return m, nil
}
m.state = statePlaying
m.activeGame = entry.New()
return m, m.activeGame.Init()
case "rules":
m.rulesCursor = 0
m.state = stateRulesMenu
case "quit":
m.quitting = true
return m, tea.Quit
}
}
return m, nil
}
func (m AppModel) updateRulesMenu(msg tea.Msg) (tea.Model, tea.Cmd) {
keyMsg, ok := msg.(tea.KeyMsg)
if !ok {
return m, nil
}
switch keyMsg.String() {
case "ctrl+c":
m.quitting = true
return m, tea.Quit
case "esc", "q":
m.state = stateMenu
case "up", "k":
if m.rulesCursor > 0 {
m.rulesCursor--
}
case "down", "j":
if m.rulesCursor < len(gameRegistry)-1 {
m.rulesCursor++
}
case "enter":
m.rulesText = gameRegistry[m.rulesCursor].Rules
m.state = stateRules
}
return m, nil
}
func (m AppModel) updateRules(msg tea.Msg) (tea.Model, tea.Cmd) {
if keyMsg, ok := msg.(tea.KeyMsg); ok {
switch keyMsg.String() {
case "q", "esc", "enter", "ctrl+c":
m.state = stateRulesMenu
}
}
return m, nil
}
func (m AppModel) updatePlayerCount(msg tea.Msg) (tea.Model, tea.Cmd) {
keyMsg, ok := msg.(tea.KeyMsg)
if !ok {
return m, nil
}
switch keyMsg.String() {
case "ctrl+c":
m.quitting = true
return m, tea.Quit
case "esc", "q":
m.state = stateMenu
return m, nil
case "left", "h", "down", "j":
m.playerCount.cycle(-1)
case "right", "l", "up", "k":
m.playerCount.cycle(1)
case "enter":
entry := gameRegistry[m.playerCount.gameIdx]
m.activeGame = entry.NewWithPlayerCount(m.playerCount.count)
m.state = statePlaying
return m, m.activeGame.Init()
}
return m, nil
}
func (m AppModel) updateBlackjackSettings(msg tea.Msg) (tea.Model, tea.Cmd) {
keyMsg, ok := msg.(tea.KeyMsg)
if !ok {
return m, nil
}
switch keyMsg.String() {
case "ctrl+c":
m.quitting = true
return m, tea.Quit
case "esc", "q":
m.state = stateMenu
return m, nil
case "up", "k":
if m.blackjack.fieldCursor > 0 {
m.blackjack.fieldCursor--
}
case "down", "j":
if m.blackjack.fieldCursor < 2 {
m.blackjack.fieldCursor++
}
case "left", "h":
m.blackjack.cycle(-1)
case "right", "l":
m.blackjack.cycle(1)
case "enter":
entry := gameRegistry[m.blackjack.gameIdx]
m.activeGame = entry.NewBlackjackWithSettings(m.blackjack.playerCount, m.blackjack.capital(), m.blackjack.bet())
m.state = statePlaying
return m, m.activeGame.Init()
}
return m, nil
}
func (m AppModel) updateCheckersSettings(msg tea.Msg) (tea.Model, tea.Cmd) {
keyMsg, ok := msg.(tea.KeyMsg)
if !ok {
return m, nil
}
switch keyMsg.String() {
case "ctrl+c":
m.quitting = true
return m, tea.Quit
case "esc", "q":
m.state = stateMenu
return m, nil
case "up", "k", "left", "h":
m.checkers.cycle(-1)
case "down", "j", "right", "l":
m.checkers.cycle(1)
case "enter":
entry := gameRegistry[m.checkers.gameIdx]
depth := checkersLevels[m.checkers.levelIdx].Depth
m.activeGame = entry.NewCheckersWithDifficulty(depth)
m.state = statePlaying
return m, m.activeGame.Init()
}
return m, nil
}
func (m AppModel) updateSettings(msg tea.Msg) (tea.Model, tea.Cmd) {
keyMsg, ok := msg.(tea.KeyMsg)
if !ok {
return m, nil
}
switch keyMsg.String() {
case "ctrl+c":
m.quitting = true
return m, tea.Quit
case "esc", "q":
m.state = stateMenu
return m, nil
case "up", "k":
if m.settings.fieldCursor > 0 {
m.settings.fieldCursor--
}
case "down", "j":
if m.settings.fieldCursor < 2 {
m.settings.fieldCursor++
}
case "left", "h":
m.settings.cycle(-1)
case "right", "l":
m.settings.cycle(1)
case "enter":
entry := gameRegistry[m.settings.gameIdx]
m.activeGame = entry.NewWithStakes(m.settings.capital(), m.settings.ante(), m.settings.multiplier())
m.state = statePlaying
return m, m.activeGame.Init()
}
return m, nil
}
func (m AppModel) updatePlaying(msg tea.Msg) (tea.Model, tea.Cmd) {
// Ctrl+C всегда выходит из всего приложения целиком, а не
// только из текущей игры.
if keyMsg, ok := msg.(tea.KeyMsg); ok && keyMsg.String() == "ctrl+c" {
m.quitting = true
return m, tea.Quit
}
updated, cmd := m.activeGame.Update(msg)
m.activeGame = updated
backToMenu := false
switch tm := updated.(type) {
case Model:
backToMenu = tm.backToMenu
case DurakModel:
backToMenu = tm.backToMenu
case BlackjackModel:
backToMenu = tm.backToMenu
case OneOhOneModel:
backToMenu = tm.backToMenu
case ThousandModel:
backToMenu = tm.backToMenu
case KlondikeModel:
backToMenu = tm.backToMenu
case CheckersModel:
backToMenu = tm.backToMenu
}
if backToMenu {
m.state = stateMenu
m.activeGame = nil
return m, nil
}
return m, cmd
}
func (m AppModel) View() string {
if m.quitting {
return "До встречи!\n"
}
switch m.state {
case statePlaying:
return m.activeGame.View()
case stateRulesMenu:
return m.viewRulesMenu()
case stateRules:
return m.viewRules()
case stateSettings:
return m.viewSettings()
case statePlayerCount:
return m.viewPlayerCount()
case stateBlackjackSettings:
return m.viewBlackjackSettings()
case stateCheckersSettings:
return m.viewCheckersSettings()
default:
return m.viewMenu()
}
}
func (m AppModel) viewMenu() string {
var b strings.Builder
fmt.Fprintln(&b, menuTitleStyle.Render(asciiBanner))
fmt.Fprintln(&b, menuSubtitleStyle.Render("Коллекция консольных игр на Go"))
fmt.Fprintln(&b)
for i, opt := range m.options {
cursor := " "
label := menuItemStyle.Render(opt.label)
if i == m.cursor {
cursor = menuCursorStyle.Render("> ")
label = menuCursorStyle.Render(opt.label)
}
fmt.Fprintf(&b, "%s%s\n", cursor, label)
if opt.desc != "" {
fmt.Fprintf(&b, " %s\n", menuDescStyle.Render(opt.desc))
}
}
fmt.Fprintln(&b)
fmt.Fprintln(&b, menuHintStyle.Render("[↑↓/kj] выбор [enter] подтвердить [q] выход"))
return b.String()
}
func (m AppModel) viewRulesMenu() string {
var b strings.Builder
fmt.Fprintln(&b, menuTitleStyle.Render("=== ПРАВИЛА: ВЫБЕРИТЕ ИГРУ ==="))
fmt.Fprintln(&b)
for i, entry := range gameRegistry {
cursor := " "
label := menuItemStyle.Render(entry.Name)
if i == m.rulesCursor {
cursor = menuCursorStyle.Render("> ")
label = menuCursorStyle.Render(entry.Name)
}
fmt.Fprintf(&b, "%s%s\n", cursor, label)
}
fmt.Fprintln(&b)
fmt.Fprintln(&b, menuHintStyle.Render("[↑↓/kj] выбор [enter] показать правила [esc/q] назад в меню"))
return b.String()
}
func (m AppModel) viewRules() string {
var b strings.Builder
fmt.Fprintln(&b, menuTitleStyle.Render("=== ПРАВИЛА ==="))
fmt.Fprintln(&b)
fmt.Fprintln(&b, m.rulesText)
fmt.Fprintln(&b, menuHintStyle.Render("[enter/esc/q] назад к списку игр"))
return b.String()
}
func (m AppModel) viewSettings() string {
var b strings.Builder
entry := gameRegistry[m.settings.gameIdx]
fmt.Fprintln(&b, menuTitleStyle.Render(fmt.Sprintf("=== НАСТРОЙКА СТАВКИ: %s ===", entry.Name)))
fmt.Fprintln(&b)
fields := []struct {
label string
value string
}{
{"Стартовый капитал", fmt.Sprintf("%d", m.settings.capital())},
{"Ставка (анте) за раунд", fmt.Sprintf("%d", m.settings.ante())},
{"Множитель банка при Тонке", fmt.Sprintf("×%d", m.settings.multiplier())},
}
for i, f := range fields {
cursor := " "
line := fmt.Sprintf("%-28s ← %s →", f.label, f.value)
if i == m.settings.fieldCursor {
cursor = menuCursorStyle.Render("> ")
line = menuCursorStyle.Render(line)
}
fmt.Fprintf(&b, "%s%s\n", cursor, line)
}
fmt.Fprintln(&b)
pot := m.settings.ante() * 4
fmt.Fprintf(&b, "%s\n", menuDescStyle.Render(fmt.Sprintf(
"Банк на 4 игроков: %d. Победа Тонком: ×%d = %d.",
pot, m.settings.multiplier(), pot*m.settings.multiplier())))
fmt.Fprintln(&b)
fmt.Fprintln(&b, menuHintStyle.Render("[↑↓/kj] поле [←→/hl] изменить значение [enter] начать игру [esc/q] назад"))
return b.String()
}
func (m AppModel) viewPlayerCount() string {
var b strings.Builder
entry := gameRegistry[m.playerCount.gameIdx]
fmt.Fprintln(&b, menuTitleStyle.Render(fmt.Sprintf("=== ЧИСЛО ИГРОКОВ: %s ===", entry.Name)))
fmt.Fprintln(&b)
fmt.Fprintf(&b, "%s\n", menuCursorStyle.Render(fmt.Sprintf(" ← %d игроков (включая вас) →", m.playerCount.count)))
fmt.Fprintln(&b)
fmt.Fprintf(&b, "%s\n", menuDescStyle.Render(fmt.Sprintf("Вы + %d бот(ов) со случайными именами.", m.playerCount.count-1)))
fmt.Fprintln(&b)
fmt.Fprintln(&b, menuHintStyle.Render("[←→/hl] изменить число игроков (2-6) [enter] начать игру [esc/q] назад"))
return b.String()
}
func (m AppModel) viewBlackjackSettings() string {
var b strings.Builder
entry := gameRegistry[m.blackjack.gameIdx]
fmt.Fprintln(&b, menuTitleStyle.Render(fmt.Sprintf("=== НАСТРОЙКА: %s ===", entry.Name)))
fmt.Fprintln(&b)
fields := []struct {
label string
value string
}{
{"Число игроков (включая вас)", fmt.Sprintf("%d", m.blackjack.playerCount)},
{"Стартовый капитал", fmt.Sprintf("%d", m.blackjack.capital())},
{"Ставка за раунд", fmt.Sprintf("%d", m.blackjack.bet())},
}
for i, f := range fields {
cursor := " "
line := fmt.Sprintf("%-30s ← %s →", f.label, f.value)
if i == m.blackjack.fieldCursor {
cursor = menuCursorStyle.Render("> ")
line = menuCursorStyle.Render(line)
}
fmt.Fprintf(&b, "%s%s\n", cursor, line)
}
fmt.Fprintln(&b)
botsCount := m.blackjack.playerCount - 1
if botsCount > 0 {
fmt.Fprintf(&b, "%s\n", menuDescStyle.Render(fmt.Sprintf("Вы + %d бот(ов) со случайными именами, каждый играет против дилера отдельно.", botsCount)))
} else {
fmt.Fprintln(&b, menuDescStyle.Render("Вы играете в одиночку против дилера."))
}
fmt.Fprintln(&b)
fmt.Fprintln(&b, menuHintStyle.Render("[↑↓/kj] поле [←→/hl] изменить значение [enter] начать игру [esc/q] назад"))
return b.String()
}
func (m AppModel) viewCheckersSettings() string {
var b strings.Builder
entry := gameRegistry[m.checkers.gameIdx]
fmt.Fprintln(&b, menuTitleStyle.Render(fmt.Sprintf("=== УРОВЕНЬ СЛОЖНОСТИ: %s ===", entry.Name)))
fmt.Fprintln(&b)
for i, lvl := range checkersLevels {
cursor := " "
line := fmt.Sprintf("%s (%s)", lvl.Label, lvl.Desc)
if i == m.checkers.levelIdx {
cursor = menuCursorStyle.Render("> ")
line = menuCursorStyle.Render(line)
}
fmt.Fprintf(&b, "%s%s\n", cursor, line)
}
fmt.Fprintln(&b)
fmt.Fprintln(&b, menuHintStyle.Render("[↑↓/←→] выбор уровня [enter] начать игру [esc/q] назад"))
return b.String()
}