go_games_collection/menu.go
2026-07-12 13:49:34 +03:00

1996 lines
59 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 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, colorChoice ColorChoice) tea.Model
// UsesTicTacToeSettings — включает экран выбора уровня сложности
// бота (лёгкий/средний/непобедимый) перед партией.
UsesTicTacToeSettings bool
NewTicTacToeWithDifficulty func(difficulty TicTacToeDifficulty) tea.Model
// UsesNardySettings — включает экран выбора варианта правил
// (короткие/длинные) и уровня сложности бота перед партией.
UsesNardySettings bool
NewNardyWithSettings func(variant NardyVariant, difficulty NardyDifficulty, colorChoice ColorChoice) tea.Model
// UsesUrSettings — включает экран выбора уровня сложности бота
// перед партией в Ур.
UsesUrSettings bool
NewUrWithDifficulty func(difficulty UrDifficulty, colorChoice ColorChoice) tea.Model
// UsesSenetSettings — включает экран выбора уровня сложности
// бота перед партией в Сенет.
UsesSenetSettings bool
NewSenetWithDifficulty func(difficulty SenetDifficulty, colorChoice ColorChoice) tea.Model
// UsesChessSettings — то же самое, но для шахмат (свой уровень
// глубины поиска, т.к. ветвление в шахматах намного шире), плюс
// выбор цвета.
UsesChessSettings bool
NewChessWithDifficulty func(depth int, colorChoice ColorChoice) tea.Model
// UsesGoSettings — экран с двумя полями: размер доски (9/13/19)
// и уровень сложности бота.
UsesGoSettings bool
NewGoWithSettings func(size int, difficulty GoDifficulty, colorChoice ColorChoice) 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, colorChoice ColorChoice) tea.Model {
return NewCheckersModel(depth, colorChoice)
},
},
{
Name: func() string { return T("game.chess.name") },
Description: func() string { return T("game.chess.desc") },
Rules: chessRules,
UsesChessSettings: true,
NewChessWithDifficulty: func(depth int, colorChoice ColorChoice) tea.Model {
return NewChessModel(depth, colorChoice)
},
},
{
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, colorChoice ColorChoice) tea.Model {
return NewGoModel(size, difficulty, colorChoice)
},
},
{
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, colorChoice ColorChoice) tea.Model {
return NewNardyModel(variant, difficulty, colorChoice)
},
},
{
Name: func() string { return T("game.ur.name") },
Description: func() string { return T("game.ur.desc") },
Rules: urRules,
UsesUrSettings: true,
NewUrWithDifficulty: func(difficulty UrDifficulty, colorChoice ColorChoice) tea.Model {
return NewUrModel(difficulty, colorChoice)
},
},
{
Name: func() string { return T("game.senet.name") },
Description: func() string { return T("game.senet.desc") },
Rules: senetRules,
UsesSenetSettings: true,
NewSenetWithDifficulty: func(difficulty SenetDifficulty, colorChoice ColorChoice) tea.Model {
return NewSenetModel(difficulty, colorChoice)
},
},
{
Name: func() string { return T("game.poker.name") },
Description: func() string { return T("game.poker.desc") },
Rules: pokerRules,
New: func() tea.Model { return NewPokerModel(4) },
UsesPlayerCount: true,
NewWithPlayerCount: func(n int) tea.Model { return NewPokerModel(n) },
},
}
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
colorChoice ColorChoice
fieldCursor int // 0 = сложность, 1 = цвет
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) {
switch s.fieldCursor {
case 0:
s.levelIdx += delta
if s.levelIdx < 0 {
s.levelIdx = 0
}
if s.levelIdx > len(checkersLevels)-1 {
s.levelIdx = len(checkersLevels) - 1
}
case 1:
s.colorChoice.cycle(delta)
}
}
// 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
colorChoice ColorChoice
fieldCursor int // 0 = сложность, 1 = цвет
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) {
switch s.fieldCursor {
case 0:
s.levelIdx += delta
if s.levelIdx < 0 {
s.levelIdx = 0
}
if s.levelIdx > len(chessLevels)-1 {
s.levelIdx = len(chessLevels) - 1
}
case 1:
s.colorChoice.cycle(delta)
}
}
// goSettings — экран настройки партии в Го: два поля (размер доски
// и уровень сложности бота).
type goSettings struct {
sizeOptions []int
sizeIdx int
difficultyIdx int
colorChoice ColorChoice
fieldCursor int // 0 = размер доски, 1 = сложность, 2 = цвет
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)
case 2:
s.colorChoice.cycle(delta)
}
}
// nardySettings — экран настройки партии в нарды: выбор варианта
// правил (короткие/длинные) и уровня сложности бота.
type nardySettings struct {
variantIdx int
difficultyIdx int
colorChoice ColorChoice
fieldCursor int // 0 = вариант правил, 1 = сложность, 2 = цвет
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)
case 2:
s.colorChoice.cycle(delta)
}
}
// urSettings — экран выбора уровня сложности бота перед партией в Ур.
type urSettings struct {
levelIdx int
colorChoice ColorChoice
fieldCursor int // 0 = сложность, 1 = цвет
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) {
switch s.fieldCursor {
case 0:
s.levelIdx += delta
if s.levelIdx < 0 {
s.levelIdx = 0
}
if s.levelIdx > len(urDifficultyLevels)-1 {
s.levelIdx = len(urDifficultyLevels) - 1
}
case 1:
s.colorChoice.cycle(delta)
}
}
// senetSettings — экран выбора уровня сложности бота перед партией
// в Сенет.
type senetSettings struct {
levelIdx int
colorChoice ColorChoice
fieldCursor int // 0 = сложность, 1 = цвет
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) {
switch s.fieldCursor {
case 0:
s.levelIdx += delta
if s.levelIdx < 0 {
s.levelIdx = 0
}
if s.levelIdx > len(senetDifficultyLevels)-1 {
s.levelIdx = len(senetDifficultyLevels) - 1
}
case 1:
s.colorChoice.cycle(delta)
}
}
// 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":
if m.checkers.fieldCursor > 0 {
m.checkers.fieldCursor--
}
case "down", "j":
if m.checkers.fieldCursor < 1 {
m.checkers.fieldCursor++
}
case "left", "h":
m.checkers.cycle(-1)
case "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.checkers.colorChoice)
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 < 2 {
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.nardy.colorChoice)
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":
if m.ur.fieldCursor > 0 {
m.ur.fieldCursor--
}
case "down", "j":
if m.ur.fieldCursor < 1 {
m.ur.fieldCursor++
}
case "left", "h":
m.ur.cycle(-1)
case "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.ur.colorChoice)
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":
if m.senet.fieldCursor > 0 {
m.senet.fieldCursor--
}
case "down", "j":
if m.senet.fieldCursor < 1 {
m.senet.fieldCursor++
}
case "left", "h":
m.senet.cycle(-1)
case "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.senet.colorChoice)
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":
if m.chess.fieldCursor > 0 {
m.chess.fieldCursor--
}
case "down", "j":
if m.chess.fieldCursor < 1 {
m.chess.fieldCursor++
}
case "left", "h":
m.chess.cycle(-1)
case "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.chess.colorChoice)
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 < 2 {
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.goSetup.colorChoice)
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
case PokerModel:
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 != "" {
wrapped := wrapText(d, m.termWidth)
indented := strings.ReplaceAll(wrapped, "\n", "\n ")
fmt.Fprintf(&b, " %s\n", menuDescStyle.Render(indented))
}
}
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)
lvl := checkersLevels[m.checkers.levelIdx]
fields := []struct{ label, value string }{
{T("checkerslvl.field_label"), T(lvl.LabelKey)},
{T("color.field_label"), colorChoiceLabel(m.checkers.colorChoice, "color.white", "color.black")},
}
for i, f := range fields {
cursor := " "
line := fmt.Sprintf("%-30s ← %s →", f.label, f.value)
if i == m.checkers.fieldCursor {
cursor = menuCursorStyle.Render("> ")
line = menuCursorStyle.Render(line)
}
fmt.Fprintf(&b, "%s%s\n", cursor, line)
}
fmt.Fprintln(&b)
fmt.Fprintln(&b, dimStyle.Render(T(lvl.DescKey)))
fmt.Fprintln(&b)
fmt.Fprintln(&b, menuHintStyle.Render(T("gosettings.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)},
{T("color.field_label"), colorChoiceLabel(m.nardy.colorChoice, "color.white", "color.black")},
}
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)
lvl := urDifficultyLevels[m.ur.levelIdx]
fields := []struct{ label, value string }{
{T("checkerslvl.field_label"), T(lvl.LabelKey)},
{T("color.field_label"), colorChoiceLabel(m.ur.colorChoice, "color.white", "color.black")},
}
for i, f := range fields {
cursor := " "
line := fmt.Sprintf("%-30s ← %s →", f.label, f.value)
if i == m.ur.fieldCursor {
cursor = menuCursorStyle.Render("> ")
line = menuCursorStyle.Render(line)
}
fmt.Fprintf(&b, "%s%s\n", cursor, line)
}
fmt.Fprintln(&b)
fmt.Fprintln(&b, dimStyle.Render(T(lvl.DescKey)))
fmt.Fprintln(&b)
fmt.Fprintln(&b, menuHintStyle.Render(T("gosettings.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)
lvl := senetDifficultyLevels[m.senet.levelIdx]
fields := []struct{ label, value string }{
{T("checkerslvl.field_label"), T(lvl.LabelKey)},
{T("color.field_label"), colorChoiceLabel(m.senet.colorChoice, "color.white", "color.black")},
}
for i, f := range fields {
cursor := " "
line := fmt.Sprintf("%-30s ← %s →", f.label, f.value)
if i == m.senet.fieldCursor {
cursor = menuCursorStyle.Render("> ")
line = menuCursorStyle.Render(line)
}
fmt.Fprintf(&b, "%s%s\n", cursor, line)
}
fmt.Fprintln(&b)
fmt.Fprintln(&b, dimStyle.Render(T(lvl.DescKey)))
fmt.Fprintln(&b)
fmt.Fprintln(&b, menuHintStyle.Render(T("gosettings.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)
lvl := chessLevels[m.chess.levelIdx]
fields := []struct{ label, value string }{
{T("checkerslvl.field_label"), T(lvl.LabelKey)},
{T("color.field_label"), colorChoiceLabel(m.chess.colorChoice, "color.white", "color.black")},
}
for i, f := range fields {
cursor := " "
line := fmt.Sprintf("%-30s ← %s →", f.label, f.value)
if i == m.chess.fieldCursor {
cursor = menuCursorStyle.Render("> ")
line = menuCursorStyle.Render(line)
}
fmt.Fprintf(&b, "%s%s\n", cursor, line)
}
fmt.Fprintln(&b)
fmt.Fprintln(&b, dimStyle.Render(T(lvl.DescKey)))
fmt.Fprintln(&b)
fmt.Fprintln(&b, menuHintStyle.Render(T("gosettings.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)},
{T("color.field_label"), colorChoiceLabel(m.goSetup.colorChoice, "color.black", "color.white")},
}
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()
}