package main import ( "fmt" "strings" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) // GameEntry — запись в реестре игр коллекции. Регистр сделан так, // чтобы добавление новой игры сводилось к добавлению одной записи // в gameRegistry, без изменений в логике меню. type GameEntry struct { Name func() string Description func() string Rules func() 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 // UsesTicTacToeSettings — включает экран выбора уровня сложности // бота (лёгкий/средний/непобедимый) перед партией. UsesTicTacToeSettings bool NewTicTacToeWithDifficulty func(difficulty TicTacToeDifficulty) tea.Model // UsesNardySettings — включает экран выбора варианта правил // (короткие/длинные) и уровня сложности бота перед партией. UsesNardySettings bool NewNardyWithSettings func(variant NardyVariant, difficulty NardyDifficulty) tea.Model // UsesUrSettings — включает экран выбора уровня сложности бота // перед партией в Ур. UsesUrSettings bool NewUrWithDifficulty func(difficulty UrDifficulty) tea.Model // UsesSenetSettings — включает экран выбора уровня сложности // бота перед партией в Сенет. UsesSenetSettings bool NewSenetWithDifficulty func(difficulty SenetDifficulty) tea.Model // UsesChessSettings — то же самое, но для шахмат (свой уровень // глубины поиска, т.к. ветвление в шахматах намного шире). UsesChessSettings bool NewChessWithDifficulty func(depth int) tea.Model // UsesGoSettings — экран с двумя полями: размер доски (9/13/19) // и уровень сложности бота. UsesGoSettings bool NewGoWithSettings func(size int, difficulty GoDifficulty) tea.Model // UsesMinesweeperSettings — экран выбора пресета размера поля // (Новичок/Любитель/Эксперт). UsesMinesweeperSettings bool NewMinesweeperWithPreset func(width, height, mines int) tea.Model } // gameRegistry — список доступных игр коллекции. var gameRegistry = []GameEntry{ { Name: func() string { return T("game.tonk.name") }, Description: func() string { return T("game.tonk.desc") }, Rules: tonkRules, New: func() tea.Model { return NewModel() }, UsesStakes: true, NewWithStakes: func(capital, ante, mult int) tea.Model { return NewModelWithStakes(capital, ante, mult) }, }, { Name: func() string { return T("game.durak.name") }, Description: func() string { return T("game.durak.desc") }, Rules: durakRules, New: func() tea.Model { return NewDurakModel(4) }, UsesPlayerCount: true, NewWithPlayerCount: func(n int) tea.Model { return NewDurakModel(n) }, }, { Name: func() string { return T("game.bj.name") }, Description: func() string { return T("game.bj.desc") }, Rules: blackjackRules, UsesBlackjackSettings: true, NewBlackjackWithSettings: func(numPlayers, capital, bet int) tea.Model { return NewBlackjackModel(numPlayers, capital, bet) }, }, { Name: func() string { return T("game.101.name") }, Description: func() string { return T("game.101.desc") }, Rules: oneOhOneRules, New: func() tea.Model { return NewOneOhOneModel(4) }, UsesPlayerCount: true, NewWithPlayerCount: func(n int) tea.Model { return NewOneOhOneModel(n) }, }, { Name: func() string { return T("game.1000.name") }, Description: func() string { return T("game.1000.desc") }, Rules: thousandRules, New: func() tea.Model { return NewThousandModel() }, }, { Name: func() string { return T("game.klondike.name") }, Description: func() string { return T("game.klondike.desc") }, Rules: klondikeRules, New: func() tea.Model { return NewKlondikeModel() }, }, { Name: func() string { return T("game.checkers.name") }, Description: func() string { return T("game.checkers.desc") }, Rules: checkersRules, UsesCheckersSettings: true, NewCheckersWithDifficulty: func(depth int) tea.Model { return NewCheckersModel(depth) }, }, { Name: func() string { return T("game.chess.name") }, Description: func() string { return T("game.chess.desc") }, Rules: chessRules, UsesChessSettings: true, NewChessWithDifficulty: func(depth int) tea.Model { return NewChessModel(depth) }, }, { Name: func() string { return T("game.corpsestarch.name") }, Description: func() string { return T("game.corpsestarch.desc") }, Rules: corpseStarchRules, New: func() tea.Model { return NewCorpseStarchModel() }, }, { Name: func() string { return T("game.go.name") }, Description: func() string { return T("game.go.desc") }, Rules: goRules, UsesGoSettings: true, NewGoWithSettings: func(size int, difficulty GoDifficulty) tea.Model { return NewGoModel(size, difficulty) }, }, { Name: func() string { return T("game.minesweeper.name") }, Description: func() string { return T("game.minesweeper.desc") }, Rules: minesweeperRules, UsesMinesweeperSettings: true, NewMinesweeperWithPreset: func(width, height, mines int) tea.Model { return NewMinesweeperModel(width, height, mines) }, }, { Name: func() string { return T("game.wayfarer.name") }, Description: func() string { return T("game.wayfarer.desc") }, Rules: wayfarerRules, New: func() tea.Model { return NewWayfarerModel() }, }, { Name: func() string { return T("game.tictactoe.name") }, Description: func() string { return T("game.tictactoe.desc") }, Rules: ticTacToeRules, UsesTicTacToeSettings: true, NewTicTacToeWithDifficulty: func(difficulty TicTacToeDifficulty) tea.Model { return NewTicTacToeModel(difficulty) }, }, { Name: func() string { return T("game.nardy.name") }, Description: func() string { return T("game.nardy.desc") }, Rules: nardyRules, UsesNardySettings: true, NewNardyWithSettings: func(variant NardyVariant, difficulty NardyDifficulty) tea.Model { return NewNardyModel(variant, difficulty) }, }, { Name: func() string { return T("game.ur.name") }, Description: func() string { return T("game.ur.desc") }, Rules: urRules, UsesUrSettings: true, NewUrWithDifficulty: func(difficulty UrDifficulty) tea.Model { return NewUrModel(difficulty) }, }, { Name: func() string { return T("game.senet.name") }, Description: func() string { return T("game.senet.desc") }, Rules: senetRules, UsesSenetSettings: true, NewSenetWithDifficulty: func(difficulty SenetDifficulty) tea.Model { return NewSenetModel(difficulty) }, }, } type appState int const ( stateLanguage appState = iota stateMenu stateRulesMenu stateRules stateSettings statePlayerCount stateBlackjackSettings stateCheckersSettings stateTicTacToeSettings stateNardySettings stateUrSettings stateSenetSettings stateChessSettings stateGoSettings stateMinesweeperSettings 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 { LabelKey string Depth int DescKey string }{ {LabelKey: "checkerslvl.easy", Depth: CheckersDifficultyEasy, DescKey: "checkerslvl.easy_desc"}, {LabelKey: "checkerslvl.hard", Depth: CheckersDifficultyHard, DescKey: "checkerslvl.hard_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 } } // ticTacToeSettings — экран выбора уровня сложности бота перед // партией в крестики-нолики: три уровня (лёгкий/средний/непобедимый). type ticTacToeSettings struct { levelIdx int gameIdx int } var ticTacToeLevels = []struct { LabelKey string Difficulty TicTacToeDifficulty DescKey string }{ {LabelKey: "tictactoelvl.easy", Difficulty: TicTacToeDifficultyEasy, DescKey: "tictactoelvl.easy_desc"}, {LabelKey: "tictactoelvl.medium", Difficulty: TicTacToeDifficultyMedium, DescKey: "tictactoelvl.medium_desc"}, {LabelKey: "tictactoelvl.hard", Difficulty: TicTacToeDifficultyHard, DescKey: "tictactoelvl.hard_desc"}, } func newTicTacToeSettings(gameIdx int) ticTacToeSettings { return ticTacToeSettings{levelIdx: 0, gameIdx: gameIdx} } func (s *ticTacToeSettings) cycle(delta int) { s.levelIdx += delta if s.levelIdx < 0 { s.levelIdx = 0 } if s.levelIdx > len(ticTacToeLevels)-1 { s.levelIdx = len(ticTacToeLevels) - 1 } } // chessSettings — тот же самый экран выбора сложности, но для // шахмат (свой уровень глубины поиска, т.к. ветвление в шахматах // намного шире, чем в шашках). type chessSettings struct { levelIdx int gameIdx int } var chessLevels = []struct { LabelKey string Depth int DescKey string }{ {LabelKey: "checkerslvl.easy", Depth: ChessDifficultyEasy, DescKey: "checkerslvl.easy_desc"}, {LabelKey: "checkerslvl.hard", Depth: ChessDifficultyHard, DescKey: "checkerslvl.hard_desc"}, } func newChessSettings(gameIdx int) chessSettings { return chessSettings{levelIdx: 0, gameIdx: gameIdx} } func (s *chessSettings) cycle(delta int) { s.levelIdx += delta if s.levelIdx < 0 { s.levelIdx = 0 } if s.levelIdx > len(chessLevels)-1 { s.levelIdx = len(chessLevels) - 1 } } // goSettings — экран настройки партии в Го: два поля (размер доски // и уровень сложности бота). type goSettings struct { sizeOptions []int sizeIdx int difficultyIdx int fieldCursor int // 0 = размер доски, 1 = сложность gameIdx int } var goDifficultyLevels = []struct { LabelKey string Level GoDifficulty }{ {LabelKey: "gosettings.level.easy", Level: GoDifficultyEasy}, {LabelKey: "gosettings.level.medium", Level: GoDifficultyMedium}, {LabelKey: "gosettings.level.hard", Level: GoDifficultyHard}, } func newGoSettings(gameIdx int) goSettings { return goSettings{ sizeOptions: []int{9, 13, 19}, sizeIdx: 0, difficultyIdx: 0, gameIdx: gameIdx, } } func (s goSettings) size() int { return s.sizeOptions[s.sizeIdx] } func (s goSettings) difficulty() GoDifficulty { return goDifficultyLevels[s.difficultyIdx].Level } func (s *goSettings) 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.sizeIdx = clamp(s.sizeIdx, len(s.sizeOptions)-1) case 1: s.difficultyIdx = clamp(s.difficultyIdx, len(goDifficultyLevels)-1) } } // nardySettings — экран настройки партии в нарды: выбор варианта // правил (короткие/длинные) и уровня сложности бота. type nardySettings struct { variantIdx int difficultyIdx int fieldCursor int // 0 = вариант правил, 1 = сложность gameIdx int } var nardyVariantOptions = []struct { LabelKey string Variant NardyVariant }{ {LabelKey: "nardysettings.variant.short", Variant: NardyShort}, {LabelKey: "nardysettings.variant.long", Variant: NardyLong}, } var nardyDifficultyLevels = []struct { LabelKey string DescKey string Difficulty NardyDifficulty }{ {LabelKey: "nardylvl.easy", DescKey: "nardylvl.easy_desc", Difficulty: NardyDifficultyEasy}, {LabelKey: "nardylvl.medium", DescKey: "nardylvl.medium_desc", Difficulty: NardyDifficultyMedium}, {LabelKey: "nardylvl.hard", DescKey: "nardylvl.hard_desc", Difficulty: NardyDifficultyHard}, } func newNardySettings(gameIdx int) nardySettings { return nardySettings{gameIdx: gameIdx} } func (s nardySettings) variant() NardyVariant { return nardyVariantOptions[s.variantIdx].Variant } func (s nardySettings) difficulty() NardyDifficulty { return nardyDifficultyLevels[s.difficultyIdx].Difficulty } func (s *nardySettings) 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.variantIdx = clamp(s.variantIdx, len(nardyVariantOptions)-1) case 1: s.difficultyIdx = clamp(s.difficultyIdx, len(nardyDifficultyLevels)-1) } } // urSettings — экран выбора уровня сложности бота перед партией в Ур. type urSettings struct { levelIdx int gameIdx int } var urDifficultyLevels = []struct { LabelKey string DescKey string Difficulty UrDifficulty }{ {LabelKey: "urlvl.easy", DescKey: "urlvl.easy_desc", Difficulty: UrDifficultyEasy}, {LabelKey: "urlvl.medium", DescKey: "urlvl.medium_desc", Difficulty: UrDifficultyMedium}, {LabelKey: "urlvl.hard", DescKey: "urlvl.hard_desc", Difficulty: UrDifficultyHard}, } func newUrSettings(gameIdx int) urSettings { return urSettings{gameIdx: gameIdx} } func (s *urSettings) cycle(delta int) { s.levelIdx += delta if s.levelIdx < 0 { s.levelIdx = 0 } if s.levelIdx > len(urDifficultyLevels)-1 { s.levelIdx = len(urDifficultyLevels) - 1 } } // senetSettings — экран выбора уровня сложности бота перед партией // в Сенет. type senetSettings struct { levelIdx int gameIdx int } var senetDifficultyLevels = []struct { LabelKey string DescKey string Difficulty SenetDifficulty }{ {LabelKey: "senetlvl.easy", DescKey: "senetlvl.easy_desc", Difficulty: SenetDifficultyEasy}, {LabelKey: "senetlvl.medium", DescKey: "senetlvl.medium_desc", Difficulty: SenetDifficultyMedium}, {LabelKey: "senetlvl.hard", DescKey: "senetlvl.hard_desc", Difficulty: SenetDifficultyHard}, } func newSenetSettings(gameIdx int) senetSettings { return senetSettings{gameIdx: gameIdx} } func (s *senetSettings) cycle(delta int) { s.levelIdx += delta if s.levelIdx < 0 { s.levelIdx = 0 } if s.levelIdx > len(senetDifficultyLevels)-1 { s.levelIdx = len(senetDifficultyLevels) - 1 } } // minesweeperSettings — экран выбора пресета размера поля Сапёра. type minesweeperSettings struct { presetIdx int gameIdx int } var minesweeperPresets = []struct { LabelKey string Width, Height, Mines int }{ {"mssettings.beginner", 9, 9, 10}, {"mssettings.intermediate", 16, 16, 40}, {"mssettings.expert", 30, 16, 99}, } func newMinesweeperSettings(gameIdx int) minesweeperSettings { return minesweeperSettings{presetIdx: 0, gameIdx: gameIdx} } func (s *minesweeperSettings) cycle(delta int) { s.presetIdx += delta if s.presetIdx < 0 { s.presetIdx = 0 } if s.presetIdx > len(minesweeperPresets)-1 { s.presetIdx = len(minesweeperPresets) - 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 { kind string // "game", "rules", "quit" gameIdx int // индекс в gameRegistry, если kind == "game" } // label возвращает актуальный (для текущего языка) текст пункта меню. func (opt menuOption) label() string { switch opt.kind { case "game": return gameRegistry[opt.gameIdx].Name() case "rules": return T("menu.rules") case "language": return T("menu.language") case "quit": return T("menu.quit") } return "" } // desc возвращает актуальное (для текущего языка) описание пункта меню. func (opt menuOption) desc() string { switch opt.kind { case "game": return gameRegistry[opt.gameIdx].Description() case "rules": return T("menu.rules_desc") case "language": return T("menu.language_desc") case "quit": return T("menu.quit_desc") } return "" } // AppModel — корневая модель приложения: меню, экран правил или // запущенная игра. type AppModel struct { state appState langCursor int cursor int menuScroll int // индекс первого видимого пункта главного меню rulesCursor int rulesMenuScroll int // индекс первого видимого пункта подменю правил rulesScroll int // сколько строк текста правил прокручено вниз termWidth int termHeight int options []menuOption rulesText string settings stakeSettings playerCount playerCountSettings blackjack blackjackSettings checkers checkersSettings ticTacToe ticTacToeSettings nardy nardySettings ur urSettings senet senetSettings chess chessSettings goSetup goSettings minesweeper minesweeperSettings activeGame tea.Model quitting bool } func NewAppModel() AppModel { options := make([]menuOption, 0, len(gameRegistry)+3) for i := range gameRegistry { options = append(options, menuOption{kind: "game", gameIdx: i}) } options = append(options, menuOption{kind: "rules"}) options = append(options, menuOption{kind: "language"}) options = append(options, menuOption{kind: "quit"}) return AppModel{ state: stateLanguage, langCursor: int(CurrentLang()), options: options, termHeight: 24, termWidth: 80, } } func (m AppModel) Init() tea.Cmd { return nil } func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if sizeMsg, ok := msg.(tea.WindowSizeMsg); ok { m.termWidth = sizeMsg.Width m.termHeight = sizeMsg.Height // на случай, если терминал реально изменили размером прямо во // время сессии — курсор мог оказаться за пределами уже другого // видимого окна, пересчитываем прокрутку под новую высоту m.menuScroll = clampWindowScroll(m.cursor, m.menuScroll, m.menuVisibleItems(), len(m.options)) m.rulesMenuScroll = clampWindowScroll(m.rulesCursor, m.rulesMenuScroll, m.rulesMenuVisibleItems(), len(gameRegistry)) m.clampRulesScroll() // пропускаем дальше — активная игра тоже может интересоваться // размером окна в будущем; сейчас просто не теряем ход if m.state == statePlaying && m.activeGame != nil { updated, cmd := m.activeGame.Update(msg) m.activeGame = updated return m, cmd } return m, nil } switch m.state { case statePlaying: return m.updatePlaying(msg) case stateLanguage: return m.updateLanguage(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) case stateTicTacToeSettings: return m.updateTicTacToeSettings(msg) case stateNardySettings: return m.updateNardySettings(msg) case stateUrSettings: return m.updateUrSettings(msg) case stateSenetSettings: return m.updateSenetSettings(msg) case stateChessSettings: return m.updateChessSettings(msg) case stateGoSettings: return m.updateGoSettings(msg) case stateMinesweeperSettings: return m.updateMinesweeperSettings(msg) default: return m.updateMenu(msg) } } func (m AppModel) updateLanguage(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 "up", "k": if m.langCursor > 0 { m.langCursor-- } case "down", "j": if m.langCursor < 1 { m.langCursor++ } case "enter": SetLanguage(Lang(m.langCursor)) m.state = stateMenu } return m, nil } 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-- } m.menuScroll = clampWindowScroll(m.cursor, m.menuScroll, m.menuVisibleItems(), len(m.options)) case "down", "j": if m.cursor < len(m.options)-1 { m.cursor++ } m.menuScroll = clampWindowScroll(m.cursor, m.menuScroll, m.menuVisibleItems(), len(m.options)) 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 case entry.UsesTicTacToeSettings: m.ticTacToe = newTicTacToeSettings(selected.gameIdx) m.state = stateTicTacToeSettings return m, nil case entry.UsesNardySettings: m.nardy = newNardySettings(selected.gameIdx) m.state = stateNardySettings return m, nil case entry.UsesUrSettings: m.ur = newUrSettings(selected.gameIdx) m.state = stateUrSettings return m, nil case entry.UsesSenetSettings: m.senet = newSenetSettings(selected.gameIdx) m.state = stateSenetSettings return m, nil case entry.UsesChessSettings: m.chess = newChessSettings(selected.gameIdx) m.state = stateChessSettings return m, nil case entry.UsesGoSettings: m.goSetup = newGoSettings(selected.gameIdx) m.state = stateGoSettings return m, nil case entry.UsesMinesweeperSettings: m.minesweeper = newMinesweeperSettings(selected.gameIdx) m.state = stateMinesweeperSettings return m, nil } m.state = statePlaying m.activeGame = entry.New() return m, m.activeGame.Init() case "rules": m.rulesCursor = 0 m.rulesMenuScroll = 0 m.state = stateRulesMenu case "language": m.langCursor = int(CurrentLang()) m.state = stateLanguage 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-- } m.rulesMenuScroll = clampWindowScroll(m.rulesCursor, m.rulesMenuScroll, m.rulesMenuVisibleItems(), len(gameRegistry)) case "down", "j": if m.rulesCursor < len(gameRegistry)-1 { m.rulesCursor++ } m.rulesMenuScroll = clampWindowScroll(m.rulesCursor, m.rulesMenuScroll, m.rulesMenuVisibleItems(), len(gameRegistry)) case "enter": m.rulesText = gameRegistry[m.rulesCursor].Rules() m.rulesScroll = 0 m.state = stateRules } return m, nil } // clampWindowScroll подбирает минимальную корректировку scroll так, // чтобы cursor гарантированно попадал в видимое окно // [scroll, scroll+visibleCount) — используется и для списка игр в // главном меню, и для подменю правил, чтобы курсор при навигации // стрелками никогда не убегал за пределы экрана. func clampWindowScroll(cursor, scroll, visibleCount, total int) int { if visibleCount <= 0 { return 0 } if cursor < scroll { scroll = cursor } if cursor >= scroll+visibleCount { scroll = cursor - visibleCount + 1 } if scroll < 0 { scroll = 0 } maxScroll := total - visibleCount if maxScroll < 0 { maxScroll = 0 } if scroll > maxScroll { scroll = maxScroll } return scroll } // asciiBannerLines — число строк в ASCII-баннере (для расчёта, сколько // места остаётся под сам список пунктов меню). func asciiBannerLines() int { return strings.Count(asciiBanner, "\n") + 1 } // menuBannerCompactThreshold — если высота терминала меньше этого // значения, полноразмерный ASCII-баннер (13 строк) заменяется на // компактную однострочную надпись — иначе на невысоких терминалах // под сам список игр совсем не остаётся места. const menuBannerCompactThreshold = 30 const menuBannerCompact = "=== GO GAMES COLLECTION ===" // menuUsesCompactBanner решает, показывать ли компактный заголовок // вместо полного ASCII-баннера при текущей высоте терминала. func (m AppModel) menuUsesCompactBanner() bool { return m.termHeight < menuBannerCompactThreshold } // menuBannerLineCount — сколько строк реально займёт заголовок меню // (баннер или его компактная замена) при текущей высоте терминала. func (m AppModel) menuBannerLineCount() int { if m.menuUsesCompactBanner() { return 1 } return asciiBannerLines() } // menuVisibleItems — сколько пунктов главного меню помещается на // экран одновременно. Каждый пункт занимает 2 строки (название + // описание), плюс заголовок, подзаголовок, пустая строка и подсказка // снизу — фиксированный "хвост" интерфейса. func (m AppModel) menuVisibleItems() int { overhead := m.menuBannerLineCount() + 4 + 2 // подзаголовок + 2 пустые строки + подсказка + место под ▲/▼ available := m.termHeight - overhead items := available / 2 if items < 1 { items = 1 } return items } // rulesMenuVisibleItems — сколько пунктов подменю правил помещается // на экран одновременно (по одной строке на пункт, без описания). func (m AppModel) rulesMenuVisibleItems() int { overhead := 4 + 2 // заголовок + пустая строка + пустая строка + подсказка + место под ▲/▼ available := m.termHeight - overhead if available < 1 { available = 1 } return available } // rulesVisibleLines — сколько строк текста правил помещается на // экран одновременно (с учётом заголовка и подсказки снизу). func (m AppModel) rulesVisibleLines() int { h := m.termHeight - 4 if h < 3 { h = 3 } return h } func (m AppModel) rulesTotalLines() int { return len(strings.Split(m.rulesText, "\n")) } func (m AppModel) rulesMaxScroll() int { max := m.rulesTotalLines() - m.rulesVisibleLines() if max < 0 { max = 0 } return max } func (m *AppModel) clampRulesScroll() { if m.rulesScroll < 0 { m.rulesScroll = 0 } if max := m.rulesMaxScroll(); m.rulesScroll > max { m.rulesScroll = max } } func (m AppModel) updateRules(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 "q", "esc": m.state = stateRulesMenu return m, nil case "up", "k": m.rulesScroll-- case "down", "j": m.rulesScroll++ case "pgup", "b": m.rulesScroll -= m.rulesVisibleLines() case "pgdown", "f", " ": m.rulesScroll += m.rulesVisibleLines() case "home", "g": m.rulesScroll = 0 case "end", "G": m.rulesScroll = m.rulesMaxScroll() } m.clampRulesScroll() 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) updateTicTacToeSettings(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.ticTacToe.cycle(-1) case "down", "j", "right", "l": m.ticTacToe.cycle(1) case "enter": entry := gameRegistry[m.ticTacToe.gameIdx] difficulty := ticTacToeLevels[m.ticTacToe.levelIdx].Difficulty m.activeGame = entry.NewTicTacToeWithDifficulty(difficulty) m.state = statePlaying return m, m.activeGame.Init() } return m, nil } func (m AppModel) updateNardySettings(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.nardy.fieldCursor > 0 { m.nardy.fieldCursor-- } case "down", "j": if m.nardy.fieldCursor < 1 { m.nardy.fieldCursor++ } case "left", "h": m.nardy.cycle(-1) case "right", "l": m.nardy.cycle(1) case "enter": entry := gameRegistry[m.nardy.gameIdx] m.activeGame = entry.NewNardyWithSettings(m.nardy.variant(), m.nardy.difficulty()) m.state = statePlaying return m, m.activeGame.Init() } return m, nil } func (m AppModel) updateUrSettings(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.ur.cycle(-1) case "down", "j", "right", "l": m.ur.cycle(1) case "enter": entry := gameRegistry[m.ur.gameIdx] difficulty := urDifficultyLevels[m.ur.levelIdx].Difficulty m.activeGame = entry.NewUrWithDifficulty(difficulty) m.state = statePlaying return m, m.activeGame.Init() } return m, nil } func (m AppModel) updateSenetSettings(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.senet.cycle(-1) case "down", "j", "right", "l": m.senet.cycle(1) case "enter": entry := gameRegistry[m.senet.gameIdx] difficulty := senetDifficultyLevels[m.senet.levelIdx].Difficulty m.activeGame = entry.NewSenetWithDifficulty(difficulty) m.state = statePlaying return m, m.activeGame.Init() } return m, nil } func (m AppModel) updateChessSettings(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.chess.cycle(-1) case "down", "j", "right", "l": m.chess.cycle(1) case "enter": entry := gameRegistry[m.chess.gameIdx] depth := chessLevels[m.chess.levelIdx].Depth m.activeGame = entry.NewChessWithDifficulty(depth) m.state = statePlaying return m, m.activeGame.Init() } return m, nil } func (m AppModel) updateGoSettings(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.goSetup.fieldCursor > 0 { m.goSetup.fieldCursor-- } case "down", "j": if m.goSetup.fieldCursor < 1 { m.goSetup.fieldCursor++ } case "left", "h": m.goSetup.cycle(-1) case "right", "l": m.goSetup.cycle(1) case "enter": entry := gameRegistry[m.goSetup.gameIdx] m.activeGame = entry.NewGoWithSettings(m.goSetup.size(), m.goSetup.difficulty()) m.state = statePlaying return m, m.activeGame.Init() } return m, nil } func (m AppModel) updateMinesweeperSettings(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.minesweeper.cycle(-1) case "down", "j", "right", "l": m.minesweeper.cycle(1) case "enter": entry := gameRegistry[m.minesweeper.gameIdx] p := minesweeperPresets[m.minesweeper.presetIdx] m.activeGame = entry.NewMinesweeperWithPreset(p.Width, p.Height, p.Mines) 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 case ChessModel: backToMenu = tm.backToMenu case CorpseStarchModel: backToMenu = tm.backToMenu case GoModel: backToMenu = tm.backToMenu case MinesweeperModel: backToMenu = tm.backToMenu case WayfarerModel: backToMenu = tm.backToMenu case TicTacToeModel: backToMenu = tm.backToMenu case NardyModel: backToMenu = tm.backToMenu case SenetModel: backToMenu = tm.backToMenu case UrModel: 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 T("common.goodbye") } switch m.state { case statePlaying: return m.activeGame.View() case stateLanguage: return m.viewLanguage() 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() case stateTicTacToeSettings: return m.viewTicTacToeSettings() case stateNardySettings: return m.viewNardySettings() case stateUrSettings: return m.viewUrSettings() case stateSenetSettings: return m.viewSenetSettings() case stateChessSettings: return m.viewChessSettings() case stateGoSettings: return m.viewGoSettings() case stateMinesweeperSettings: return m.viewMinesweeperSettings() default: return m.viewMenu() } } func (m AppModel) viewLanguage() string { var b strings.Builder fmt.Fprintln(&b, menuTitleStyle.Render(T("lang.title"))) fmt.Fprintln(&b) options := []string{T("lang.ru"), T("lang.en")} for i, opt := range options { cursor := " " label := menuItemStyle.Render(opt) if i == m.langCursor { cursor = menuCursorStyle.Render("> ") label = menuCursorStyle.Render(opt) } fmt.Fprintf(&b, "%s%s\n", cursor, label) } fmt.Fprintln(&b) fmt.Fprintln(&b, menuHintStyle.Render(T("lang.hint"))) return b.String() } func (m AppModel) viewMenu() string { var b strings.Builder if m.menuUsesCompactBanner() { fmt.Fprintln(&b, menuTitleStyle.Render(menuBannerCompact)) } else { fmt.Fprintln(&b, menuTitleStyle.Render(asciiBanner)) } fmt.Fprintln(&b, menuSubtitleStyle.Render(T("app.subtitle"))) fmt.Fprintln(&b) visible := m.menuVisibleItems() start := m.menuScroll end := start + visible if end > len(m.options) { end = len(m.options) } if start > 0 { fmt.Fprintln(&b, dimStyle.Render(" ▲")) } for i := start; i < end; i++ { opt := m.options[i] 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 d := opt.desc(); d != "" { fmt.Fprintf(&b, " %s\n", menuDescStyle.Render(d)) } } if end < len(m.options) { fmt.Fprintln(&b, dimStyle.Render(" ▼")) } fmt.Fprintln(&b) fmt.Fprintln(&b, menuHintStyle.Render(T("menu.hint"))) return b.String() } func (m AppModel) viewRulesMenu() string { var b strings.Builder fmt.Fprintln(&b, menuTitleStyle.Render(T("rulesmenu.title"))) fmt.Fprintln(&b) visible := m.rulesMenuVisibleItems() start := m.rulesMenuScroll end := start + visible if end > len(gameRegistry) { end = len(gameRegistry) } if start > 0 { fmt.Fprintln(&b, dimStyle.Render(" ▲")) } for i := start; i < end; i++ { entry := gameRegistry[i] 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) } if end < len(gameRegistry) { fmt.Fprintln(&b, dimStyle.Render(" ▼")) } fmt.Fprintln(&b) fmt.Fprintln(&b, menuHintStyle.Render(T("rulesmenu.hint"))) return b.String() } func (m AppModel) viewRules() string { var b strings.Builder fmt.Fprintln(&b, menuTitleStyle.Render(T("rules.title"))) fmt.Fprintln(&b) lines := strings.Split(m.rulesText, "\n") visible := m.rulesVisibleLines() start := m.rulesScroll if start > len(lines) { start = len(lines) } end := start + visible if end > len(lines) { end = len(lines) } fmt.Fprintln(&b, strings.Join(lines[start:end], "\n")) maxScroll := m.rulesMaxScroll() scrollInfo := "" if maxScroll > 0 { scrollInfo = " " + Tf("rules.scroll_info", start+1, end, len(lines)) } fmt.Fprintln(&b, menuHintStyle.Render(T("rules.hint")+scrollInfo)) 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: %s ===", T("stake.title"), entry.Name()))) fmt.Fprintln(&b) fields := []struct { label string value string }{ {T("stake.capital"), fmt.Sprintf("%d", m.settings.capital())}, {T("stake.ante"), fmt.Sprintf("%d", m.settings.ante())}, {T("stake.tonk_mult"), 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( "%s: %d. %s: ×%d = %d.", T("stake.pot_info"), pot, T("stake.tonk_win"), m.settings.multiplier(), pot*m.settings.multiplier()))) fmt.Fprintln(&b) fmt.Fprintln(&b, menuHintStyle.Render(T("stake.hint"))) 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: %s ===", T("playercount.title"), entry.Name()))) fmt.Fprintln(&b) fmt.Fprintf(&b, "%s\n", menuCursorStyle.Render(fmt.Sprintf(" ← %d %s →", m.playerCount.count, T("playercount.line")))) fmt.Fprintln(&b) fmt.Fprintf(&b, "%s\n", menuDescStyle.Render(Tf("playercount.bots", m.playerCount.count-1))) fmt.Fprintln(&b) fmt.Fprintln(&b, menuHintStyle.Render(T("playercount.hint"))) 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: %s ===", T("bjsettings.title"), entry.Name()))) fmt.Fprintln(&b) fields := []struct { label string value string }{ {T("bjsettings.players"), fmt.Sprintf("%d", m.blackjack.playerCount)}, {T("bjsettings.capital"), fmt.Sprintf("%d", m.blackjack.capital())}, {T("bjsettings.bet"), 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(Tf("bjsettings.multi", botsCount))) } else { fmt.Fprintln(&b, menuDescStyle.Render(T("bjsettings.solo"))) } fmt.Fprintln(&b) fmt.Fprintln(&b, menuHintStyle.Render(T("bjsettings.hint"))) 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: %s ===", T("checkerslvl.title"), entry.Name()))) fmt.Fprintln(&b) for i, lvl := range checkersLevels { cursor := " " line := fmt.Sprintf("%s (%s)", T(lvl.LabelKey), T(lvl.DescKey)) 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(T("checkerslvl.hint"))) return b.String() } func (m AppModel) viewTicTacToeSettings() string { var b strings.Builder entry := gameRegistry[m.ticTacToe.gameIdx] fmt.Fprintln(&b, menuTitleStyle.Render(fmt.Sprintf("=== %s: %s ===", T("tictactoelvl.title"), entry.Name()))) fmt.Fprintln(&b) for i, lvl := range ticTacToeLevels { cursor := " " line := fmt.Sprintf("%s (%s)", T(lvl.LabelKey), T(lvl.DescKey)) if i == m.ticTacToe.levelIdx { cursor = menuCursorStyle.Render("> ") line = menuCursorStyle.Render(line) } fmt.Fprintf(&b, "%s%s\n", cursor, line) } fmt.Fprintln(&b) fmt.Fprintln(&b, menuHintStyle.Render(T("checkerslvl.hint"))) return b.String() } func (m AppModel) viewNardySettings() string { var b strings.Builder entry := gameRegistry[m.nardy.gameIdx] fmt.Fprintln(&b, menuTitleStyle.Render(fmt.Sprintf("=== %s: %s ===", T("nardysettings.title"), entry.Name()))) fmt.Fprintln(&b) fields := []struct { label string value string }{ {T("nardysettings.variant"), T(nardyVariantOptions[m.nardy.variantIdx].LabelKey)}, {T("nardysettings.difficulty"), T(nardyDifficultyLevels[m.nardy.difficultyIdx].LabelKey)}, } for i, f := range fields { cursor := " " line := fmt.Sprintf("%-30s ← %s →", f.label, f.value) if i == m.nardy.fieldCursor { cursor = menuCursorStyle.Render("> ") line = menuCursorStyle.Render(line) } fmt.Fprintf(&b, "%s%s\n", cursor, line) } fmt.Fprintln(&b) desc := nardyDifficultyLevels[m.nardy.difficultyIdx].DescKey fmt.Fprintln(&b, dimStyle.Render(T(desc))) fmt.Fprintln(&b) fmt.Fprintln(&b, menuHintStyle.Render(T("gosettings.hint"))) return b.String() } func (m AppModel) viewUrSettings() string { var b strings.Builder entry := gameRegistry[m.ur.gameIdx] fmt.Fprintln(&b, menuTitleStyle.Render(fmt.Sprintf("=== %s: %s ===", T("checkerslvl.title"), entry.Name()))) fmt.Fprintln(&b) for i, lvl := range urDifficultyLevels { cursor := " " line := fmt.Sprintf("%s (%s)", T(lvl.LabelKey), T(lvl.DescKey)) if i == m.ur.levelIdx { cursor = menuCursorStyle.Render("> ") line = menuCursorStyle.Render(line) } fmt.Fprintf(&b, "%s%s\n", cursor, line) } fmt.Fprintln(&b) fmt.Fprintln(&b, menuHintStyle.Render(T("checkerslvl.hint"))) return b.String() } func (m AppModel) viewSenetSettings() string { var b strings.Builder entry := gameRegistry[m.senet.gameIdx] fmt.Fprintln(&b, menuTitleStyle.Render(fmt.Sprintf("=== %s: %s ===", T("checkerslvl.title"), entry.Name()))) fmt.Fprintln(&b) for i, lvl := range senetDifficultyLevels { cursor := " " line := fmt.Sprintf("%s (%s)", T(lvl.LabelKey), T(lvl.DescKey)) if i == m.senet.levelIdx { cursor = menuCursorStyle.Render("> ") line = menuCursorStyle.Render(line) } fmt.Fprintf(&b, "%s%s\n", cursor, line) } fmt.Fprintln(&b) fmt.Fprintln(&b, menuHintStyle.Render(T("checkerslvl.hint"))) return b.String() } func (m AppModel) viewChessSettings() string { var b strings.Builder entry := gameRegistry[m.chess.gameIdx] fmt.Fprintln(&b, menuTitleStyle.Render(fmt.Sprintf("=== %s: %s ===", T("checkerslvl.title"), entry.Name()))) fmt.Fprintln(&b) for i, lvl := range chessLevels { cursor := " " line := fmt.Sprintf("%s (%s)", T(lvl.LabelKey), T(lvl.DescKey)) if i == m.chess.levelIdx { cursor = menuCursorStyle.Render("> ") line = menuCursorStyle.Render(line) } fmt.Fprintf(&b, "%s%s\n", cursor, line) } fmt.Fprintln(&b) fmt.Fprintln(&b, menuHintStyle.Render(T("checkerslvl.hint"))) return b.String() } func (m AppModel) viewGoSettings() string { var b strings.Builder entry := gameRegistry[m.goSetup.gameIdx] fmt.Fprintln(&b, menuTitleStyle.Render(fmt.Sprintf("=== %s: %s ===", T("gosettings.title"), entry.Name()))) fmt.Fprintln(&b) fields := []struct { label string value string }{ {T("gosettings.boardsize"), fmt.Sprintf("%dx%d", m.goSetup.size(), m.goSetup.size())}, {T("gosettings.difficulty"), T(goDifficultyLevels[m.goSetup.difficultyIdx].LabelKey)}, } for i, f := range fields { cursor := " " line := fmt.Sprintf("%-30s ← %s →", f.label, f.value) if i == m.goSetup.fieldCursor { cursor = menuCursorStyle.Render("> ") line = menuCursorStyle.Render(line) } fmt.Fprintf(&b, "%s%s\n", cursor, line) } fmt.Fprintln(&b) fmt.Fprintln(&b, menuHintStyle.Render(T("gosettings.hint"))) return b.String() } func (m AppModel) viewMinesweeperSettings() string { var b strings.Builder entry := gameRegistry[m.minesweeper.gameIdx] fmt.Fprintln(&b, menuTitleStyle.Render(fmt.Sprintf("=== %s: %s ===", T("mssettings.title"), entry.Name()))) fmt.Fprintln(&b) for i, p := range minesweeperPresets { cursor := " " line := fmt.Sprintf("%s — %dx%d, %d 💣", T(p.LabelKey), p.Width, p.Height, p.Mines) if i == m.minesweeper.presetIdx { cursor = menuCursorStyle.Render("> ") line = menuCursorStyle.Render(line) } fmt.Fprintf(&b, "%s%s\n", cursor, line) } fmt.Fprintln(&b) fmt.Fprintln(&b, menuHintStyle.Render(T("mssettings.hint"))) return b.String() }