1205 lines
No EOL
41 KiB
Go
1205 lines
No EOL
41 KiB
Go
package main
|
||
|
||
import (
|
||
"fmt"
|
||
"math/rand"
|
||
"strings"
|
||
"time"
|
||
|
||
tea "github.com/charmbracelet/bubbletea"
|
||
)
|
||
|
||
// --- Соответствие сложности сторимода уровню сложности конкретной
|
||
// игры. Двухступенчатые игры (Шашки, Шахматы) получают "простой" на
|
||
// лёгком и среднем сторимода и "сильный" только на сложном;
|
||
// трёхступенчатые — соответствие один-в-один.
|
||
|
||
func storyDifficultyToTicTacToe(d StoryDifficulty) TicTacToeDifficulty {
|
||
switch d {
|
||
case StoryDifficultyEasy:
|
||
return TicTacToeDifficultyEasy
|
||
case StoryDifficultyMedium:
|
||
return TicTacToeDifficultyMedium
|
||
default:
|
||
return TicTacToeDifficultyHard
|
||
}
|
||
}
|
||
|
||
func storyDifficultyToCheckersDepth(d StoryDifficulty) int {
|
||
if d == StoryDifficultyHard {
|
||
return CheckersDifficultyHard
|
||
}
|
||
return CheckersDifficultyEasy
|
||
}
|
||
|
||
func storyDifficultyToChessDepth(d StoryDifficulty) int {
|
||
if d == StoryDifficultyHard {
|
||
return ChessDifficultyHard
|
||
}
|
||
return ChessDifficultyEasy
|
||
}
|
||
|
||
func storyDifficultyToGo(d StoryDifficulty) GoDifficulty {
|
||
switch d {
|
||
case StoryDifficultyEasy:
|
||
return GoDifficultyEasy
|
||
case StoryDifficultyMedium:
|
||
return GoDifficultyMedium
|
||
default:
|
||
return GoDifficultyHard
|
||
}
|
||
}
|
||
|
||
func storyDifficultyToUr(d StoryDifficulty) UrDifficulty {
|
||
switch d {
|
||
case StoryDifficultyEasy:
|
||
return UrDifficultyEasy
|
||
case StoryDifficultyMedium:
|
||
return UrDifficultyMedium
|
||
default:
|
||
return UrDifficultyHard
|
||
}
|
||
}
|
||
|
||
func storyDifficultyToSenet(d StoryDifficulty) SenetDifficulty {
|
||
switch d {
|
||
case StoryDifficultyEasy:
|
||
return SenetDifficultyEasy
|
||
case StoryDifficultyMedium:
|
||
return SenetDifficultyMedium
|
||
default:
|
||
return SenetDifficultyHard
|
||
}
|
||
}
|
||
|
||
func storyDifficultyToNardy(d StoryDifficulty) NardyDifficulty {
|
||
switch d {
|
||
case StoryDifficultyEasy:
|
||
return NardyDifficultyEasy
|
||
case StoryDifficultyMedium:
|
||
return NardyDifficultyMedium
|
||
default:
|
||
return NardyDifficultyHard
|
||
}
|
||
}
|
||
|
||
// --- Определение исхода партии для каждой подключённой игры: партия
|
||
// завершилась и (если да) выиграл ли человек. Возвращает (false,
|
||
// false), если партия ещё идёт или тип модели не тот, что ожидался.
|
||
|
||
func storyOutcomeTicTacToe(m tea.Model) (finished, won bool) {
|
||
tm, ok := m.(TicTacToeModel)
|
||
if !ok || tm.game.Result == nil {
|
||
return false, false
|
||
}
|
||
winner := tm.game.Result.Winner
|
||
// в сторимоде ничья приравнена к победе — партия не проиграна,
|
||
// только если противник выиграл явно
|
||
return true, winner == tm.game.HumanMark || winner == TicTacToeEmpty
|
||
}
|
||
|
||
func storyOutcomeCheckers(m tea.Model) (finished, won bool) {
|
||
tm, ok := m.(CheckersModel)
|
||
if !ok || tm.game.Result == nil {
|
||
return false, false
|
||
}
|
||
return true, tm.game.Result.Winner == tm.human
|
||
}
|
||
|
||
func storyOutcomeChess(m tea.Model) (finished, won bool) {
|
||
tm, ok := m.(ChessModel)
|
||
if !ok || tm.game.Result == nil {
|
||
return false, false
|
||
}
|
||
return true, tm.game.Result.Winner == tm.human
|
||
}
|
||
|
||
func storyOutcomeNardy(m tea.Model) (finished, won bool) {
|
||
tm, ok := m.(NardyModel)
|
||
if !ok || tm.game.Result == nil {
|
||
return false, false
|
||
}
|
||
return true, tm.game.Result.Winner == tm.humanColor
|
||
}
|
||
|
||
func storyOutcomeGo(m tea.Model) (finished, won bool) {
|
||
tm, ok := m.(GoModel)
|
||
if !ok || tm.game.Result == nil {
|
||
return false, false
|
||
}
|
||
return true, tm.game.Result.Winner == tm.human
|
||
}
|
||
|
||
func storyOutcomeUr(m tea.Model) (finished, won bool) {
|
||
tm, ok := m.(UrModel)
|
||
if !ok || tm.game.Result == nil {
|
||
return false, false
|
||
}
|
||
return true, tm.game.Result.Winner == tm.humanColor
|
||
}
|
||
|
||
func storyOutcomeSenet(m tea.Model) (finished, won bool) {
|
||
tm, ok := m.(SenetModel)
|
||
if !ok || tm.game.Result == nil {
|
||
return false, false
|
||
}
|
||
return true, tm.game.Result.Winner == tm.humanColor
|
||
}
|
||
|
||
// storyOutcomeBlackjack — правило для карточных игр на деньги:
|
||
// партия засчитывается только если сыграно не меньше трёх раундов и
|
||
// баланс человека в итоге выше стартового капитала. Если раундов
|
||
// меньше трёх (сдался раньше) или баланс не вырос — засчитывается
|
||
// как незавершённая попытка, ресурс не начисляется.
|
||
func storyOutcomeBlackjack(m tea.Model) (finished, won bool) {
|
||
bm, ok := m.(BlackjackModel)
|
||
if !ok || bm.game.Phase != BJPhaseRoundOver {
|
||
return false, false
|
||
}
|
||
human := bm.game.Players[bm.humanIdx]
|
||
won = bm.game.RoundsPlayed >= 3 && human.Balance > bm.game.StartingCapital
|
||
return true, won
|
||
}
|
||
|
||
// storyOutcomeTonk — тот же принцип, что и в Блэкджеке: минимум три
|
||
// раунда сыграно, и на момент выхода баланс человека не ниже, чем у
|
||
// любого другого игрока за столом (ничья за первое место тоже
|
||
// засчитывается).
|
||
func storyOutcomeTonk(m tea.Model) (finished, won bool) {
|
||
tm, ok := m.(Model)
|
||
if !ok || tm.game.Phase != PhaseGameOver {
|
||
return false, false
|
||
}
|
||
if tm.game.RoundsPlayed < 3 {
|
||
return true, false
|
||
}
|
||
human := tm.game.Players[tm.humanIdx]
|
||
maxBalance := human.Balance
|
||
for _, p := range tm.game.Players {
|
||
if p.Balance > maxBalance {
|
||
maxBalance = p.Balance
|
||
}
|
||
}
|
||
return true, human.Balance == maxBalance
|
||
}
|
||
|
||
// storyOutcomeDurak — нужно 3 подряд партии в этой сессии без
|
||
// поражения (не быть дураком). storyNonFoolStreak хранит серию ДО
|
||
// текущей партии; здесь отдельно учитывается исход именно этой,
|
||
// последней партии — иначе выход сразу после решающей победы (не
|
||
// через "n") потерял бы её из серии.
|
||
func storyOutcomeDurak(m tea.Model) (finished, won bool) {
|
||
dm, ok := m.(DurakModel)
|
||
if !ok || dm.game.Result == nil {
|
||
return false, false
|
||
}
|
||
wasFool := !dm.game.Result.NoLoser && !dm.game.Result.Stalemate && dm.game.Result.Loser == dm.humanIdx
|
||
effectiveStreak := dm.storyNonFoolStreak + 1
|
||
if wasFool {
|
||
effectiveStreak = 0
|
||
}
|
||
return true, effectiveStreak >= 3
|
||
}
|
||
|
||
// storyOutcomeMinesweeper — нужно 3 подряд партии в этой сессии без
|
||
// взрыва. Как и в Дураке, последняя (текущая) партия учитывается
|
||
// отдельно от уже накопленной серии — иначе выход сразу после
|
||
// решающей победы потерял бы её из счёта.
|
||
func storyOutcomeMinesweeper(m tea.Model) (finished, won bool) {
|
||
mm, ok := m.(MinesweeperModel)
|
||
if !ok || mm.game.Result == nil {
|
||
return false, false
|
||
}
|
||
effectiveStreak := mm.storyWinStreak
|
||
if mm.game.Result.Won {
|
||
effectiveStreak++
|
||
} else {
|
||
effectiveStreak = 0
|
||
}
|
||
return true, effectiveStreak >= 3
|
||
}
|
||
|
||
// storyOutcomeOneOhOne — простая победа, но во всём МАТЧЕ (в 101
|
||
// счёт идёт через несколько раундов, отдельный раунд ничего не
|
||
// решает), а не в одном отдельном раунде.
|
||
func storyOutcomeOneOhOne(m tea.Model) (finished, won bool) {
|
||
om, ok := m.(OneOhOneModel)
|
||
if !ok || om.game.Phase != OneOhOnePhaseRoundOver || om.game.Result == nil {
|
||
return false, false
|
||
}
|
||
if !om.game.Result.MatchOver {
|
||
return false, false // матч ещё продолжается — рано подводить итог
|
||
}
|
||
return true, !om.game.Result.NoWinner && om.game.Result.MatchWinner == om.humanIdx
|
||
}
|
||
|
||
// storyOutcomeThousand — тот же принцип, что и в Блэкджеке/Тонке:
|
||
// минимум 3 раздачи сыграно, и на момент выхода счёт человека не
|
||
// ниже, чем у любого другого игрока (ничья за первое место тоже
|
||
// засчитывается).
|
||
func storyOutcomeThousand(m tea.Model) (finished, won bool) {
|
||
tm, ok := m.(ThousandModel)
|
||
if !ok || tm.game.Phase != ThousandPhaseHandOver {
|
||
return false, false
|
||
}
|
||
if tm.game.HandNumber < 3 {
|
||
return true, false
|
||
}
|
||
human := tm.game.Players[tm.humanIdx]
|
||
maxScore := human.Score
|
||
for _, p := range tm.game.Players {
|
||
if p.Score > maxScore {
|
||
maxScore = p.Score
|
||
}
|
||
}
|
||
return true, human.Score == maxScore
|
||
}
|
||
|
||
// storyOutcomeKlondike сообщает только "выиграна ли партия" — сам
|
||
// подсчёт "3 победы за день" происходит отдельно, на уровне
|
||
// диспетчера в menu.go (см. KlondikeWinsToday), а не здесь.
|
||
func storyOutcomeKlondike(m tea.Model) (finished, won bool) {
|
||
km, ok := m.(KlondikeModel)
|
||
if !ok || !km.game.Won {
|
||
return false, false
|
||
}
|
||
return true, true
|
||
}
|
||
|
||
// storyOutcomePoker — выигрыш ВСЕГО турнира (последний оставшийся
|
||
// игрок с фишками), а не отдельной раздачи. Сложность сторимода на
|
||
// покер сейчас не влияет — уровень каждого бота и так назначается
|
||
// случайно независимо от общей сложности (см. NewPokerModel).
|
||
func storyOutcomePoker(m tea.Model) (finished, won bool) {
|
||
pm, ok := m.(PokerModel)
|
||
if !ok || pm.game.Phase != PokerPhaseTournamentOver {
|
||
return false, false
|
||
}
|
||
return true, !pm.game.Players[pm.humanSeat].Eliminated
|
||
}
|
||
|
||
// storyGameDef — описание одной игры, доступной в сторимоде: как её
|
||
// запустить (уже с фиксированными настройками и сложностью
|
||
// сторимода вместо обычного экрана настройки) и как определить,
|
||
// выиграл ли человек, когда партия завершилась.
|
||
type storyGameDef struct {
|
||
NameKey string
|
||
Launch func(difficulty StoryDifficulty) tea.Model
|
||
Outcome func(m tea.Model) (finished, won bool)
|
||
}
|
||
|
||
// storyGameDefs — только подключённые на сегодня игры (чистое
|
||
// условие победы: Result != nil, сравнение Winner с самим собой).
|
||
// Игры со составной семантикой исхода партии (Дурак, Блэкджек, 101,
|
||
// 1000, Косынка, Сапёр, Покер) требуют отдельного решения по
|
||
// каждой и будут подключены следующим шагом — намеренно не
|
||
// угадываю их условие победы второпях.
|
||
var storyGameDefs = map[StoryResourceID]storyGameDef{
|
||
StoryResTicTacToe: {
|
||
NameKey: "game.tictactoe.name",
|
||
Launch: func(d StoryDifficulty) tea.Model {
|
||
return NewTicTacToeModel(storyDifficultyToTicTacToe(d))
|
||
},
|
||
Outcome: storyOutcomeTicTacToe,
|
||
},
|
||
StoryResCheckers: {
|
||
NameKey: "game.checkers.name",
|
||
Launch: func(d StoryDifficulty) tea.Model {
|
||
return NewCheckersModel(storyDifficultyToCheckersDepth(d), ColorChoiceRandom)
|
||
},
|
||
Outcome: storyOutcomeCheckers,
|
||
},
|
||
StoryResChess: {
|
||
NameKey: "game.chess.name",
|
||
Launch: func(d StoryDifficulty) tea.Model {
|
||
return NewChessModel(storyDifficultyToChessDepth(d), ColorChoiceRandom)
|
||
},
|
||
Outcome: storyOutcomeChess,
|
||
},
|
||
StoryResNardy: {
|
||
NameKey: "game.nardy.name",
|
||
Launch: func(d StoryDifficulty) tea.Model {
|
||
return NewNardyModel(NardyShort, storyDifficultyToNardy(d), ColorChoiceRandom)
|
||
},
|
||
Outcome: storyOutcomeNardy,
|
||
},
|
||
StoryResGo: {
|
||
NameKey: "game.go.name",
|
||
Launch: func(d StoryDifficulty) tea.Model {
|
||
return NewGoModel(9, storyDifficultyToGo(d), ColorChoiceRandom)
|
||
},
|
||
Outcome: storyOutcomeGo,
|
||
},
|
||
StoryResUr: {
|
||
NameKey: "game.ur.name",
|
||
Launch: func(d StoryDifficulty) tea.Model {
|
||
return NewUrModel(storyDifficultyToUr(d), ColorChoiceRandom)
|
||
},
|
||
Outcome: storyOutcomeUr,
|
||
},
|
||
StoryResSenet: {
|
||
NameKey: "game.senet.name",
|
||
Launch: func(d StoryDifficulty) tea.Model {
|
||
return NewSenetModel(storyDifficultyToSenet(d), ColorChoiceRandom)
|
||
},
|
||
Outcome: storyOutcomeSenet,
|
||
},
|
||
StoryResBlackjack: {
|
||
NameKey: "game.blackjack.name",
|
||
Launch: func(d StoryDifficulty) tea.Model {
|
||
// сложность сторимода на Блэкджек не влияет — у дилера
|
||
// нет уровня "бота", он всегда играет по фиксированным
|
||
// правилам казино; человек играет один на один с дилером
|
||
return NewBlackjackModel(1, 500, 25)
|
||
},
|
||
Outcome: storyOutcomeBlackjack,
|
||
},
|
||
StoryResTonk: {
|
||
NameKey: "game.tonk.name",
|
||
Launch: func(d StoryDifficulty) tea.Model {
|
||
// у ботов Тонка нет уровня сложности — фиксированные стратегии
|
||
return NewModel()
|
||
},
|
||
Outcome: storyOutcomeTonk,
|
||
},
|
||
StoryResDurak: {
|
||
NameKey: "game.durak.name",
|
||
Launch: func(d StoryDifficulty) tea.Model {
|
||
// у бота Дурака тоже нет уровня сложности
|
||
return NewDurakModel(4)
|
||
},
|
||
Outcome: storyOutcomeDurak,
|
||
},
|
||
StoryResMinesweeper: {
|
||
NameKey: "game.minesweeper.name",
|
||
Launch: func(d StoryDifficulty) tea.Model {
|
||
// Сапёр без противника — сложность сторимода не при делах;
|
||
// берём "новичок" (9x9, 10 мин) — задача про серию из 3
|
||
// побед и так достаточно требовательна
|
||
return NewMinesweeperModel(9, 9, 10)
|
||
},
|
||
Outcome: storyOutcomeMinesweeper,
|
||
},
|
||
StoryResOneOhOne: {
|
||
NameKey: "game.oneohone.name",
|
||
Launch: func(d StoryDifficulty) tea.Model {
|
||
return NewOneOhOneModel(3)
|
||
},
|
||
Outcome: storyOutcomeOneOhOne,
|
||
},
|
||
StoryResThousand: {
|
||
NameKey: "game.thousand.name",
|
||
Launch: func(d StoryDifficulty) tea.Model {
|
||
// 1000 поддерживает только троих игроков (правила игры)
|
||
return NewThousandModel()
|
||
},
|
||
Outcome: storyOutcomeThousand,
|
||
},
|
||
StoryResKlondike: {
|
||
NameKey: "game.klondike.name",
|
||
Launch: func(d StoryDifficulty) tea.Model {
|
||
// Косынка — головоломка без противника, сложность не при делах
|
||
return NewKlondikeModel()
|
||
},
|
||
Outcome: storyOutcomeKlondike,
|
||
},
|
||
StoryResPoker: {
|
||
NameKey: "game.poker.name",
|
||
Launch: func(d StoryDifficulty) tea.Model {
|
||
return NewPokerModel(4)
|
||
},
|
||
Outcome: storyOutcomePoker,
|
||
},
|
||
}
|
||
|
||
// storyHubOrder — порядок показа ресурсов в хабе (стабильный,
|
||
// отдельный от порядка iota, чтобы можно было перегруппировать
|
||
// показ, не трогая сами значения StoryResourceID).
|
||
var storyHubOrder = []StoryResourceID{
|
||
StoryResTonk, StoryResDurak, StoryResCheckers, StoryResNardy,
|
||
StoryResBlackjack, StoryResChess, StoryResMinesweeper, StoryResGo,
|
||
StoryResUr, StoryResSenet, StoryResOneOhOne, StoryResThousand,
|
||
StoryResKlondike, StoryResTicTacToe, StoryResPoker,
|
||
}
|
||
|
||
func (m AppModel) updateStoryDifficulty(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 = stateModeSelect
|
||
return m, nil
|
||
case "up", "k":
|
||
if m.storyDifficultyCursor > 0 {
|
||
m.storyDifficultyCursor--
|
||
}
|
||
case "down", "j":
|
||
if m.storyDifficultyCursor < 2 {
|
||
m.storyDifficultyCursor++
|
||
}
|
||
case "enter":
|
||
m.story = NewStoryModeState(StoryDifficulty(m.storyDifficultyCursor), time.Now())
|
||
_ = SaveStoryModeState(m.story)
|
||
m.state = stateStoryHub
|
||
}
|
||
return m, nil
|
||
}
|
||
|
||
func (m AppModel) viewStoryDifficulty() string {
|
||
var b strings.Builder
|
||
fmt.Fprintln(&b, menuTitleStyle.Render(T("story.difficulty.title")))
|
||
fmt.Fprintln(&b)
|
||
|
||
options := []string{T("story.difficulty.easy"), T("story.difficulty.medium"), T("story.difficulty.hard")}
|
||
for i, opt := range options {
|
||
cursor := " "
|
||
label := menuItemStyle.Render(opt)
|
||
if i == m.storyDifficultyCursor {
|
||
cursor = menuCursorStyle.Render("> ")
|
||
label = menuCursorStyle.Render(opt)
|
||
}
|
||
fmt.Fprintf(&b, "%s%s\n", cursor, label)
|
||
}
|
||
fmt.Fprintln(&b)
|
||
fmt.Fprintln(&b, menuHintStyle.Render(wrapText(T("modeselect.hint"), m.termWidth)))
|
||
return b.String()
|
||
}
|
||
|
||
// storyEscapeUnlocked проверяет условие скрытой концовки — все три
|
||
// разом: 50 кусков мыла (Крестики-нолики), 50 верёвок (Сенет) и
|
||
// стресс 60% и выше.
|
||
func (m AppModel) storyEscapeUnlocked() bool {
|
||
enoughSoap := m.story.Resources[StoryResTicTacToe] >= 50
|
||
enoughRope := m.story.Resources[StoryResSenet] >= 50
|
||
highStress := m.story.Stress >= 60
|
||
return enoughSoap && enoughRope && highStress
|
||
}
|
||
|
||
// storySurvivalResources — семь конкретных ресурсов, нужных для
|
||
// "спокойной" секретной концовки: реально необходимые для жизни
|
||
// снаружи вещи, а не любые 7 из 15.
|
||
var storySurvivalResources = [7]StoryResourceID{
|
||
StoryResTonk, StoryResCheckers, StoryResChess, StoryResMinesweeper,
|
||
StoryResGo, StoryResDurak, StoryResKlondike,
|
||
}
|
||
|
||
// storySurvivalEndingUnlocked проверяет, заперты ли в сейф все семь
|
||
// ресурсов выживания разом.
|
||
func (m AppModel) storySurvivalEndingUnlocked() bool {
|
||
for _, id := range storySurvivalResources {
|
||
if !m.story.Locked[id] {
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
// storyExtraHubItem — один дополнительный пункт в конце списка игр
|
||
// хаба (скрытая концовка), доступный только при выполнении своего
|
||
// условия.
|
||
type storyExtraHubItem struct {
|
||
LabelKey string
|
||
State appState
|
||
}
|
||
|
||
// storyExtraHubItems возвращает дополнительные пункты хаба, доступные
|
||
// прямо сейчас — порядок стабильный: сначала "спокойная" концовка
|
||
// (собраны вещи для жизни снаружи), потом побег от отчаяния.
|
||
func (m AppModel) storyExtraHubItems() []storyExtraHubItem {
|
||
var items []storyExtraHubItem
|
||
if m.storySurvivalEndingUnlocked() {
|
||
items = append(items, storyExtraHubItem{LabelKey: "story.hub.survival_option", State: stateStorySurvivalAttempt})
|
||
}
|
||
if m.storyEscapeUnlocked() {
|
||
items = append(items, storyExtraHubItem{LabelKey: "story.hub.escape_option", State: stateStoryEscapeAttempt})
|
||
}
|
||
return items
|
||
}
|
||
|
||
// storyVisibleGames возвращает список игр, доступных прямо сейчас —
|
||
// на этапе знакомства это укороченный список (только крестики-нолики,
|
||
// затем плюс шашки), после его завершения — обычный полный список.
|
||
func (m AppModel) storyVisibleGames() []StoryResourceID {
|
||
switch m.story.Onboarding {
|
||
case StoryOnboardingTicTacToeOnly:
|
||
return []StoryResourceID{StoryResTicTacToe}
|
||
case StoryOnboardingWithCheckers:
|
||
return []StoryResourceID{StoryResTicTacToe, StoryResCheckers}
|
||
default:
|
||
return storyHubOrder
|
||
}
|
||
}
|
||
|
||
func (m AppModel) updateStoryHub(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||
keyMsg, ok := msg.(tea.KeyMsg)
|
||
if !ok {
|
||
return m, nil
|
||
}
|
||
if m.storyConfirmingReset {
|
||
switch keyMsg.String() {
|
||
case "ctrl+c":
|
||
m.quitting = true
|
||
return m, tea.Quit
|
||
case "y", "Y":
|
||
_ = DeleteStoryModeSave()
|
||
m.story = nil
|
||
m.storyConfirmingReset = false
|
||
m.storyPostGameFlavor = ""
|
||
m.storyHubCursor = 0
|
||
m.state = stateStoryDifficulty
|
||
case "n", "N", "esc":
|
||
m.storyConfirmingReset = false
|
||
}
|
||
return m, nil
|
||
}
|
||
visible := m.storyVisibleGames()
|
||
onboarding := m.story.Onboarding != StoryOnboardingDone
|
||
var extras []storyExtraHubItem
|
||
if !onboarding {
|
||
extras = m.storyExtraHubItems()
|
||
}
|
||
maxCursor := len(visible) + len(extras) - 1
|
||
switch keyMsg.String() {
|
||
case "ctrl+c":
|
||
m.quitting = true
|
||
return m, tea.Quit
|
||
case "N":
|
||
m.storyConfirmingReset = true
|
||
return m, nil
|
||
case "esc", "q":
|
||
m.state = stateModeSelect
|
||
return m, nil
|
||
case "up", "k":
|
||
if m.storyHubCursor > 0 {
|
||
m.storyHubCursor--
|
||
}
|
||
case "down", "j":
|
||
if m.storyHubCursor < maxCursor {
|
||
m.storyHubCursor++
|
||
}
|
||
case "c":
|
||
if onboarding {
|
||
return m, nil
|
||
}
|
||
m.state = stateStoryConditions
|
||
return m, nil
|
||
case "u":
|
||
if onboarding {
|
||
return m, nil
|
||
}
|
||
m.state = stateStoryUseItem
|
||
return m, nil
|
||
case "enter":
|
||
if m.storyHubCursor >= len(visible) {
|
||
idx := m.storyHubCursor - len(visible)
|
||
if idx >= 0 && idx < len(extras) {
|
||
m.state = extras[idx].State
|
||
}
|
||
return m, nil
|
||
}
|
||
id := visible[m.storyHubCursor]
|
||
def, playable := storyGameDefs[id]
|
||
if !playable {
|
||
return m, nil
|
||
}
|
||
resource := id
|
||
m.storyActiveResource = &resource
|
||
m.activeGame = def.Launch(m.story.Difficulty)
|
||
m.state = statePlaying
|
||
return m, m.activeGame.Init()
|
||
}
|
||
return m, nil
|
||
}
|
||
|
||
func (m AppModel) viewStoryHub() string {
|
||
var b strings.Builder
|
||
fmt.Fprintln(&b, menuTitleStyle.Render(T("story.hub.title")))
|
||
fmt.Fprintln(&b)
|
||
|
||
if m.storyConfirmingReset {
|
||
fmt.Fprintln(&b, errorStyle.Render(T("story.hub.reset_confirm")))
|
||
fmt.Fprintln(&b)
|
||
fmt.Fprintln(&b, menuHintStyle.Render(T("story.hub.reset_confirm_hint")))
|
||
return b.String()
|
||
}
|
||
|
||
onboarding := m.story.Onboarding != StoryOnboardingDone
|
||
visible := m.storyVisibleGames()
|
||
var extras []storyExtraHubItem
|
||
if !onboarding {
|
||
extras = m.storyExtraHubItems()
|
||
}
|
||
|
||
if !onboarding {
|
||
fmt.Fprintln(&b, dimStyle.Render(T("story.hub.difficulty_label")+" "+T(storyDifficultyLabelKey(m.story.Difficulty))))
|
||
fmt.Fprintln(&b, Tf("story.hub.vitals", m.story.Stress, m.story.Poisoning, m.story.ToxinLevel))
|
||
if m.storyPostGameFlavor != "" {
|
||
fmt.Fprintln(&b, infoStyle.Render(T(m.storyPostGameFlavor)))
|
||
}
|
||
fmt.Fprintln(&b)
|
||
}
|
||
|
||
for i, id := range visible {
|
||
resDef := storyResourceDefs[id]
|
||
cursor := " "
|
||
var line string
|
||
if onboarding {
|
||
// на этапе знакомства — только название игры, без чисел
|
||
// ресурсов и прочего антуража обычного бункера; и именно
|
||
// название ИГРЫ, а не ресурса — до объяснения от ИИ
|
||
// "кусок мыла"/"банка тушёнки" ничего не значат для игрока
|
||
line = T(storyGameDefs[id].NameKey)
|
||
} else {
|
||
_, playable := storyGameDefs[id]
|
||
status := fmt.Sprintf("%d/%d", m.story.Resources[id], resDef.Target)
|
||
if m.story.Locked[id] {
|
||
status = T("story.hub.locked")
|
||
}
|
||
line = fmt.Sprintf("%-24s %s", T(resDef.NameKey), status)
|
||
if !playable {
|
||
line += " " + dimStyle.Render(T("story.hub.notyet"))
|
||
}
|
||
if !playable && i != m.storyHubCursor {
|
||
line = dimStyle.Render(line)
|
||
}
|
||
}
|
||
if i == m.storyHubCursor {
|
||
cursor = menuCursorStyle.Render("> ")
|
||
line = menuCursorStyle.Render(line)
|
||
}
|
||
fmt.Fprintf(&b, "%s%s\n", cursor, line)
|
||
}
|
||
|
||
if !onboarding {
|
||
for i, item := range extras {
|
||
cursor := " "
|
||
line := errorStyle.Render(T(item.LabelKey))
|
||
if m.storyHubCursor == len(visible)+i {
|
||
cursor = menuCursorStyle.Render("> ")
|
||
}
|
||
fmt.Fprintf(&b, "%s%s\n", cursor, line)
|
||
}
|
||
}
|
||
|
||
fmt.Fprintln(&b)
|
||
if onboarding {
|
||
fmt.Fprintln(&b, menuHintStyle.Render(wrapText(T("story.hub.hint.onboarding"), m.termWidth)))
|
||
} else {
|
||
fmt.Fprintln(&b, menuHintStyle.Render(wrapText(T("story.hub.hint"), m.termWidth)))
|
||
}
|
||
return b.String()
|
||
}
|
||
|
||
// storyGreetingKind — какое из трёх приветствий дня показать при
|
||
// входе в сторимод именно через меню режимов (выбор "Режим
|
||
// истории") — это про начало новой сессии, а не про возврат из
|
||
// только что сыгранной партии внутри уже идущей сессии.
|
||
type storyGreetingKind int
|
||
|
||
const (
|
||
storyGreetingNone storyGreetingKind = iota
|
||
storyGreetingWokeUp // прошёл ровно один день
|
||
storyGreetingPlayingAgain // 0 дней — снова зашёл в тот же день
|
||
storyGreetingWorried // 2 и более дня
|
||
)
|
||
|
||
func storyGreetingForDays(days int) storyGreetingKind {
|
||
switch {
|
||
case days == 0:
|
||
return storyGreetingPlayingAgain
|
||
case days == 1:
|
||
return storyGreetingWokeUp
|
||
default:
|
||
return storyGreetingWorried
|
||
}
|
||
}
|
||
|
||
func (k storyGreetingKind) textKey() string {
|
||
switch k {
|
||
case storyGreetingWokeUp:
|
||
return "story.morning.text.wokeup"
|
||
case storyGreetingPlayingAgain:
|
||
return "story.morning.text.again"
|
||
case storyGreetingWorried:
|
||
return "story.morning.text.worried"
|
||
default:
|
||
return "story.morning.text.wokeup"
|
||
}
|
||
}
|
||
|
||
// storyPostGameFlavorKeys — короткие фразы-наблюдения от лица ИИ
|
||
// убежища, одна из которых случайно показывается в бункере после
|
||
// возврата из только что сыгранной партии — заметки о
|
||
// физиологической реакции игрока по показаниям датчиков.
|
||
var storyPostGameFlavorKeys = []string{
|
||
"story.postgame.flavor1",
|
||
"story.postgame.flavor2",
|
||
"story.postgame.flavor3",
|
||
"story.postgame.flavor4",
|
||
"story.postgame.flavor5",
|
||
"story.postgame.flavor6",
|
||
"story.postgame.flavor7",
|
||
"story.postgame.flavor8",
|
||
"story.postgame.flavor9",
|
||
"story.postgame.flavor10",
|
||
"story.postgame.flavor11",
|
||
"story.postgame.flavor12",
|
||
}
|
||
|
||
func randomStoryPostGameFlavor() string {
|
||
return storyPostGameFlavorKeys[rand.Intn(len(storyPostGameFlavorKeys))]
|
||
}
|
||
|
||
// enterStoryHubFromModeSelect применяет накопившийся суточный расход
|
||
// (и очистку воздуха/восстановление стресса) и показывает одно из
|
||
// трёх приветствий дня, в зависимости от того, сколько суток прошло
|
||
// с последнего входа именно через меню режимов.
|
||
func (m AppModel) enterStoryHubFromModeSelect() AppModel {
|
||
days := m.story.Tick(time.Now())
|
||
m.story.ApplyAirPurification(time.Now())
|
||
m.story.ApplyStressRecovery(time.Now())
|
||
_ = SaveStoryModeState(m.story)
|
||
if m.story.Complete() {
|
||
m.state = stateStoryTrueEnding
|
||
return m
|
||
}
|
||
if m.story.Onboarding != StoryOnboardingDone {
|
||
// на этапе знакомства ИИ убежища ещё не представилась игроку
|
||
// (это происходит только в момент завершения знакомства, см.
|
||
// AdvanceOnboarding) — рано показывать её приветствия дня и
|
||
// суточные события; они тихо копятся (Tick выше их всё равно
|
||
// обсчитал) и всплывут при первом же входе после завершения
|
||
m.state = stateStoryHub
|
||
return m
|
||
}
|
||
m.storyGreeting = storyGreetingForDays(days)
|
||
m.state = stateStoryMorning
|
||
return m
|
||
}
|
||
|
||
// enterStoryHub используется при возврате из только что сыгранной
|
||
// партии: применяет тот же накопившийся расход и очистку/восстановление
|
||
// молча, без приветствия дня (это продолжение уже идущей сессии, а
|
||
// не новый вход) — но показывает случайную короткую фразу-наблюдение
|
||
// поверх обычного хаба (см. storyPostGameFlavorKeys).
|
||
func (m AppModel) enterStoryHub() AppModel {
|
||
m.story.Tick(time.Now())
|
||
m.story.ApplyAirPurification(time.Now())
|
||
m.story.ApplyStressRecovery(time.Now())
|
||
_ = SaveStoryModeState(m.story)
|
||
if m.story.Complete() {
|
||
m.state = stateStoryTrueEnding
|
||
return m
|
||
}
|
||
m.state = stateStoryHub
|
||
return m
|
||
}
|
||
|
||
// advanceStoryEventQueue решает, куда вести дальше после закрытия
|
||
// текущего экрана-уведомления (приветствие дня, выброс нейротоксина,
|
||
// стук в дверь): если есть ещё не показанное событие — на него (по
|
||
// порядку выброс, затем стук), иначе — в обычный хаб.
|
||
func (m AppModel) advanceStoryEventQueue() appState {
|
||
if m.story.PendingNeurotoxinAlert {
|
||
return stateStoryNeurotoxinAlert
|
||
}
|
||
if m.story.PendingDoorKnockAlert {
|
||
return stateStoryDoorKnockAlert
|
||
}
|
||
return stateStoryHub
|
||
}
|
||
|
||
func (m AppModel) updateStoryMorning(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||
keyMsg, ok := msg.(tea.KeyMsg)
|
||
if !ok {
|
||
return m, nil
|
||
}
|
||
if keyMsg.String() == "ctrl+c" {
|
||
m.quitting = true
|
||
return m, tea.Quit
|
||
}
|
||
m.state = m.advanceStoryEventQueue()
|
||
return m, nil
|
||
}
|
||
|
||
func (m AppModel) viewStoryMorning() string {
|
||
var b strings.Builder
|
||
fmt.Fprintln(&b, menuTitleStyle.Render(T("story.morning.title")))
|
||
fmt.Fprintln(&b)
|
||
fmt.Fprintln(&b, wrapText(T(m.storyGreeting.textKey()), m.termWidth))
|
||
fmt.Fprintln(&b)
|
||
fmt.Fprintln(&b, menuHintStyle.Render(T("story.morning.hint")))
|
||
return b.String()
|
||
}
|
||
|
||
func (m AppModel) updateStoryNeurotoxinAlert(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||
keyMsg, ok := msg.(tea.KeyMsg)
|
||
if !ok {
|
||
return m, nil
|
||
}
|
||
if keyMsg.String() == "ctrl+c" {
|
||
m.quitting = true
|
||
return m, tea.Quit
|
||
}
|
||
m.story.PendingNeurotoxinAlert = false
|
||
_ = SaveStoryModeState(m.story)
|
||
m.state = m.advanceStoryEventQueue()
|
||
return m, nil
|
||
}
|
||
|
||
func (m AppModel) viewStoryNeurotoxinAlert() string {
|
||
var b strings.Builder
|
||
fmt.Fprintln(&b, errorStyle.Render(T("story.event.neurotoxin.title")))
|
||
fmt.Fprintln(&b)
|
||
fmt.Fprintln(&b, wrapText(Tf("story.event.neurotoxin.text", m.story.ToxinLevel), m.termWidth))
|
||
fmt.Fprintln(&b)
|
||
fmt.Fprintln(&b, menuHintStyle.Render(T("story.morning.hint")))
|
||
return b.String()
|
||
}
|
||
|
||
func (m AppModel) updateStoryDoorKnockAlert(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||
keyMsg, ok := msg.(tea.KeyMsg)
|
||
if !ok {
|
||
return m, nil
|
||
}
|
||
if keyMsg.String() == "ctrl+c" {
|
||
m.quitting = true
|
||
return m, tea.Quit
|
||
}
|
||
m.story.PendingDoorKnockAlert = false
|
||
_ = SaveStoryModeState(m.story)
|
||
m.state = m.advanceStoryEventQueue()
|
||
return m, nil
|
||
}
|
||
|
||
func (m AppModel) viewStoryDoorKnockAlert() string {
|
||
var b strings.Builder
|
||
fmt.Fprintln(&b, errorStyle.Render(T("story.event.doorknock.title")))
|
||
fmt.Fprintln(&b)
|
||
fmt.Fprintln(&b, wrapText(T("story.event.doorknock.text"), m.termWidth))
|
||
fmt.Fprintln(&b)
|
||
fmt.Fprintln(&b, menuHintStyle.Render(T("story.morning.hint")))
|
||
return b.String()
|
||
}
|
||
|
||
// storyWelcomeTickMsg сигнализирует, что пора показать ещё одно
|
||
// слово текста приветствия (эффект постепенного появления).
|
||
type storyWelcomeTickMsg struct{}
|
||
|
||
// storyWelcomeWordInterval — 4 слова в секунду.
|
||
const storyWelcomeWordInterval = 250 * time.Millisecond
|
||
|
||
func storyWelcomeTick() tea.Cmd {
|
||
return tea.Tick(storyWelcomeWordInterval, func(time.Time) tea.Msg { return storyWelcomeTickMsg{} })
|
||
}
|
||
|
||
// storyTotalWords считает слова во всём тексте, не различая абзацы
|
||
// (разделитель абзацев "\n\n" сам по себе словом не считается).
|
||
func storyTotalWords(text string) int {
|
||
return len(strings.Fields(text))
|
||
}
|
||
|
||
// storyRevealPartialText показывает первые n слов text, сохраняя
|
||
// структуру абзацев (текст может состоять из нескольких абзацев,
|
||
// разделённых "\n\n") — используется для эффекта постепенного
|
||
// появления текста. Абзацы показываются по порядку целиком, кроме,
|
||
// возможно, самого последнего показанного — он может быть показан
|
||
// частично.
|
||
func storyRevealPartialText(text string, n int) string {
|
||
if n <= 0 {
|
||
return ""
|
||
}
|
||
paragraphs := strings.Split(text, "\n\n")
|
||
var shown []string
|
||
remaining := n
|
||
for _, p := range paragraphs {
|
||
if remaining <= 0 {
|
||
break
|
||
}
|
||
words := strings.Fields(p)
|
||
if remaining >= len(words) {
|
||
shown = append(shown, p)
|
||
remaining -= len(words)
|
||
} else {
|
||
shown = append(shown, strings.Join(words[:remaining], " "))
|
||
remaining = 0
|
||
}
|
||
}
|
||
return strings.Join(shown, "\n\n")
|
||
}
|
||
|
||
func (m AppModel) updateStoryWelcome(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||
total := storyTotalWords(T("story.welcome.text"))
|
||
switch msg := msg.(type) {
|
||
case storyWelcomeTickMsg:
|
||
if m.storyWelcomeWordsShown >= total {
|
||
return m, nil
|
||
}
|
||
m.storyWelcomeWordsShown++
|
||
if m.storyWelcomeWordsShown < total {
|
||
return m, storyWelcomeTick()
|
||
}
|
||
return m, nil
|
||
case tea.KeyMsg:
|
||
if msg.String() == "ctrl+c" {
|
||
m.quitting = true
|
||
return m, tea.Quit
|
||
}
|
||
if m.storyWelcomeWordsShown < total {
|
||
// текст ещё печатается — любая клавиша сразу показывает
|
||
// его целиком, а не продолжает в бункер
|
||
m.storyWelcomeWordsShown = total
|
||
return m, nil
|
||
}
|
||
return m.enterStoryHub(), nil
|
||
}
|
||
return m, nil
|
||
}
|
||
|
||
func (m AppModel) viewStoryWelcome() string {
|
||
var b strings.Builder
|
||
fmt.Fprintln(&b, menuTitleStyle.Render(T("story.welcome.title")))
|
||
fmt.Fprintln(&b)
|
||
partial := storyRevealPartialText(T("story.welcome.text"), m.storyWelcomeWordsShown)
|
||
fmt.Fprintln(&b, wrapText(partial, m.termWidth))
|
||
fmt.Fprintln(&b)
|
||
if m.storyWelcomeWordsShown < storyTotalWords(T("story.welcome.text")) {
|
||
fmt.Fprintln(&b, menuHintStyle.Render(T("story.welcome.hint.typing")))
|
||
} else {
|
||
fmt.Fprintln(&b, menuHintStyle.Render(T("story.welcome.hint")))
|
||
}
|
||
return b.String()
|
||
}
|
||
|
||
func (m AppModel) updateStoryConditions(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||
keyMsg, ok := msg.(tea.KeyMsg)
|
||
if !ok {
|
||
return m, nil
|
||
}
|
||
if keyMsg.String() == "ctrl+c" {
|
||
m.quitting = true
|
||
return m, tea.Quit
|
||
}
|
||
m.state = stateStoryHub
|
||
return m, nil
|
||
}
|
||
|
||
// storyConditionKeys — ключ перевода условия начисления ресурса, по
|
||
// каждой игре в том же порядке, что и storyHubOrder.
|
||
var storyConditionKeys = map[StoryResourceID]string{
|
||
StoryResTonk: "story.condition.tonk",
|
||
StoryResDurak: "story.condition.durak",
|
||
StoryResCheckers: "story.condition.win",
|
||
StoryResNardy: "story.condition.win",
|
||
StoryResBlackjack: "story.condition.blackjack",
|
||
StoryResChess: "story.condition.win",
|
||
StoryResMinesweeper: "story.condition.minesweeper",
|
||
StoryResGo: "story.condition.win",
|
||
StoryResUr: "story.condition.win",
|
||
StoryResSenet: "story.condition.win",
|
||
StoryResOneOhOne: "story.condition.oneohone",
|
||
StoryResThousand: "story.condition.thousand",
|
||
StoryResKlondike: "story.condition.klondike",
|
||
StoryResTicTacToe: "story.condition.tictactoe",
|
||
StoryResPoker: "story.condition.poker",
|
||
}
|
||
|
||
func (m AppModel) viewStoryConditions() string {
|
||
var b strings.Builder
|
||
fmt.Fprintln(&b, menuTitleStyle.Render(T("story.conditions.title")))
|
||
fmt.Fprintln(&b)
|
||
for _, id := range storyHubOrder {
|
||
resDef := storyResourceDefs[id]
|
||
fmt.Fprintf(&b, "%s\n %s\n", T(resDef.NameKey), dimStyle.Render(T(storyConditionKeys[id])))
|
||
}
|
||
fmt.Fprintln(&b)
|
||
fmt.Fprintln(&b, menuHintStyle.Render(T("story.conditions.hint")))
|
||
return b.String()
|
||
}
|
||
|
||
// storyUseItemDef — одно расходуемое действие на экране
|
||
// "использовать": какой ресурс тратится, что при этом происходит.
|
||
type storyUseItemDef struct {
|
||
ResourceID StoryResourceID
|
||
EffectKey string
|
||
Spend func(s *StoryModeState) bool
|
||
}
|
||
|
||
var storyUseItemDefs = []storyUseItemDef{
|
||
{ResourceID: StoryResKlondike, EffectKey: "story.useitem.antidepressant", Spend: func(s *StoryModeState) bool { return s.SpendAntidepressant(time.Now()) }},
|
||
{ResourceID: StoryResMinesweeper, EffectKey: "story.useitem.antidote", Spend: func(s *StoryModeState) bool { return s.SpendAntidote() }},
|
||
{ResourceID: StoryResGo, EffectKey: "story.useitem.airfilter", Spend: func(s *StoryModeState) bool { return s.SpendAirFilter() }},
|
||
{ResourceID: StoryResOneOhOne, EffectKey: "story.useitem.battery", Spend: func(s *StoryModeState) bool { return s.SpendBattery(time.Now()) }},
|
||
}
|
||
|
||
func (m AppModel) updateStoryUseItem(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 = stateStoryHub
|
||
return m, nil
|
||
case "1", "2", "3", "4":
|
||
idx := int(keyMsg.String()[0] - '1')
|
||
if idx < 0 || idx >= len(storyUseItemDefs) {
|
||
return m, nil
|
||
}
|
||
if storyUseItemDefs[idx].Spend(m.story) {
|
||
_ = SaveStoryModeState(m.story)
|
||
}
|
||
}
|
||
return m, nil
|
||
}
|
||
|
||
func (m AppModel) viewStoryUseItem() string {
|
||
var b strings.Builder
|
||
fmt.Fprintln(&b, menuTitleStyle.Render(T("story.useitem.title")))
|
||
fmt.Fprintln(&b)
|
||
fmt.Fprintln(&b, Tf("story.hub.vitals", m.story.Stress, m.story.Poisoning, m.story.ToxinLevel))
|
||
fmt.Fprintln(&b)
|
||
for i, def := range storyUseItemDefs {
|
||
resDef := storyResourceDefs[def.ResourceID]
|
||
count := m.story.Resources[def.ResourceID]
|
||
fmt.Fprintf(&b, "[%d] %s: %d — %s\n", i+1, T(resDef.NameKey), count, T(def.EffectKey))
|
||
}
|
||
fmt.Fprintln(&b)
|
||
fmt.Fprintln(&b, menuHintStyle.Render(T("story.useitem.hint")))
|
||
return b.String()
|
||
}
|
||
|
||
func (m AppModel) updateStorySurvivalAttempt(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 "1", "esc", "q":
|
||
m.state = stateStoryHub
|
||
case "2":
|
||
m.state = stateStoryEscapeEnding
|
||
}
|
||
return m, nil
|
||
}
|
||
|
||
func (m AppModel) viewStorySurvivalAttempt() string {
|
||
var b strings.Builder
|
||
fmt.Fprintln(&b, menuTitleStyle.Render(T("story.survival.title")))
|
||
fmt.Fprintln(&b)
|
||
fmt.Fprintln(&b, wrapText(T("story.survival.text"), m.termWidth))
|
||
fmt.Fprintln(&b)
|
||
fmt.Fprintln(&b, T("story.survival.stay"))
|
||
fmt.Fprintln(&b, errorStyle.Render(T("story.survival.leave")))
|
||
return b.String()
|
||
}
|
||
|
||
func (m AppModel) updateStoryEscapeAttempt(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 "1":
|
||
// таблетка успокоительного — стресс сразу и без остатка
|
||
// падает до нуля (в отличие от антидепрессанта, здесь нет
|
||
// последующего часового восстановления к полу)
|
||
m.story.Stress = 0
|
||
m.story.StressRecoveryActive = false
|
||
_ = SaveStoryModeState(m.story)
|
||
m.state = stateStoryHub
|
||
case "2":
|
||
m.state = stateStoryEscapeEnding
|
||
case "esc", "q":
|
||
m.state = stateStoryHub
|
||
}
|
||
return m, nil
|
||
}
|
||
|
||
func (m AppModel) viewStoryEscapeAttempt() string {
|
||
var b strings.Builder
|
||
fmt.Fprintln(&b, menuTitleStyle.Render(T("story.escape.title")))
|
||
fmt.Fprintln(&b)
|
||
fmt.Fprintln(&b, wrapText(T("story.escape.text"), m.termWidth))
|
||
fmt.Fprintln(&b)
|
||
fmt.Fprintln(&b, T("story.escape.stay"))
|
||
fmt.Fprintln(&b, errorStyle.Render(T("story.escape.leave")))
|
||
return b.String()
|
||
}
|
||
|
||
func (m AppModel) updateStoryEscapeEnding(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||
keyMsg, ok := msg.(tea.KeyMsg)
|
||
if !ok {
|
||
return m, nil
|
||
}
|
||
if keyMsg.String() == "ctrl+c" {
|
||
m.quitting = true
|
||
return m, tea.Quit
|
||
}
|
||
// любая концовка — следующий заход в сторимод должен начинаться
|
||
// с самого начала, а не продолжать прежний прогресс
|
||
_ = DeleteStoryModeSave()
|
||
m.story = nil
|
||
m.state = stateModeSelect
|
||
return m, nil
|
||
}
|
||
|
||
func (m AppModel) updateStoryTrueEnding(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||
keyMsg, ok := msg.(tea.KeyMsg)
|
||
if !ok {
|
||
return m, nil
|
||
}
|
||
if keyMsg.String() == "ctrl+c" {
|
||
m.quitting = true
|
||
return m, tea.Quit
|
||
}
|
||
_ = DeleteStoryModeSave()
|
||
m.story = nil
|
||
m.state = stateModeSelect
|
||
return m, nil
|
||
}
|
||
|
||
func (m AppModel) viewStoryTrueEnding() string {
|
||
var b strings.Builder
|
||
fmt.Fprintln(&b, menuTitleStyle.Render(T("story.trueending.title")))
|
||
fmt.Fprintln(&b)
|
||
fmt.Fprintln(&b, wrapText(T("story.trueending.text"), m.termWidth))
|
||
fmt.Fprintln(&b)
|
||
fmt.Fprintln(&b, menuHintStyle.Render(T("story.trueending.hint")))
|
||
return b.String()
|
||
}
|
||
|
||
func (m AppModel) viewStoryEscapeEnding() string {
|
||
var b strings.Builder
|
||
fmt.Fprintln(&b, menuTitleStyle.Render(T("story.escapeending.title")))
|
||
fmt.Fprintln(&b)
|
||
fmt.Fprintln(&b, wrapText(T("story.escapeending.text"), m.termWidth))
|
||
fmt.Fprintln(&b)
|
||
fmt.Fprintln(&b, menuHintStyle.Render(T("story.escapeending.hint")))
|
||
return b.String()
|
||
}
|
||
|
||
func storyDifficultyLabelKey(d StoryDifficulty) string {
|
||
switch d {
|
||
case StoryDifficultyEasy:
|
||
return "story.difficulty.easy"
|
||
case StoryDifficultyMedium:
|
||
return "story.difficulty.medium"
|
||
default:
|
||
return "story.difficulty.hard"
|
||
}
|
||
} |