go_games_collection/menu.go

1430 lines
42 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) 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() },
},
}
type appState int
const (
stateLanguage appState = iota
stateMenu
stateRulesMenu
stateRules
stateSettings
statePlayerCount
stateBlackjackSettings
stateCheckersSettings
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
}
}
// 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)
}
}
// 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
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 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.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) 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
}
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 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) 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()
}