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