package main import ( "fmt" "math/rand" "strings" "time" tea "github.com/charmbracelet/bubbletea" ) // corpseStarchTickMsg — сигнал раз в секунду для начисления // пассивного дохода в реальном времени, пока открыт экран игры. type corpseStarchTickMsg struct{ At time.Time } const corpseStarchTickInterval = 1 * time.Second // corpseStarchAutosaveMsg — сигнал периодического автосохранения. type corpseStarchAutosaveMsg struct{} const corpseStarchAutosaveInterval = 15 * time.Second // CorpseStarchModel — TUI поверх CorpseStarchGameState. type CorpseStarchModel struct { game *CorpseStarchGameState message string confirmingReset bool // ждём подтверждения "начать заново" (y/n) — необратимое действие, требует явного согласия termWidth int // для переноса длинного текста (например, предложения Хаоса) по ширине реального терминала quitting bool backToMenu bool } // NewCorpseStarchModel загружает сохранённую партию, если она есть // (начисляя честный пассивный доход за время отсутствия), либо // создаёт новую. func NewCorpseStarchModel() CorpseStarchModel { now := time.Now() g, err := LoadCorpseStarchGame() if err != nil { return CorpseStarchModel{game: NewCorpseStarchGame(now), termWidth: 80} } before := g.Starch g.Tick(now) // честно начисляем доход за время, пока партия была закрыта m := CorpseStarchModel{game: g, termWidth: 80} if earned := g.Starch - before; earned >= 1 { key := g.modeKey("corpsestarch.msg.welcome_back", "corpsestarch.msg.welcome_back.demonic") m.message = Tf(key, formatStarch(earned)) } return m } func (m CorpseStarchModel) Init() tea.Cmd { return tea.Batch(corpseStarchScheduleTick(), corpseStarchScheduleAutosave()) } func corpseStarchScheduleTick() tea.Cmd { return tea.Tick(corpseStarchTickInterval, func(t time.Time) tea.Msg { return corpseStarchTickMsg{At: t} }) } func corpseStarchScheduleAutosave() tea.Cmd { return tea.Tick(corpseStarchAutosaveInterval, func(time.Time) tea.Msg { return corpseStarchAutosaveMsg{} }) } func (m *CorpseStarchModel) setInfo(key string) { m.message = T(key) } // corpseStarchAction — один пункт динамического пронумерованного // меню действий: что показать и что сделать по нажатию цифры. type corpseStarchAction struct { Label string Handle func(m *CorpseStarchModel, now time.Time) } // buildActions собирает список доступных прямо сейчас действий — // от этого зависит, какая цифра что делает; список пересобирается // на каждый рендер/обработку ввода, поэтому нумерация всегда // соответствует актуально показанным пунктам. func (m CorpseStarchModel) buildActions(now time.Time) []corpseStarchAction { g := m.game var actions []corpseStarchAction actions = append(actions, corpseStarchAction{ Label: Tf(g.modeKey("corpsestarch.action.requisition", "corpsestarch.action.requisition.demonic"), formatStarch(g.currentClickYield(now))), Handle: func(m *CorpseStarchModel, now time.Time) { m.game.Requisition(now) m.setInfo(m.game.modeKey("corpsestarch.msg.requisitioned", "corpsestarch.msg.requisitioned.demonic")) }, }) if g.ServitorsUnlocked() && !g.InChaosIncursion(now) { for i, def := range corpseStarchServitorDefs { if !g.ServitorUnlocked(i) { continue } i := i cost := g.servitorCost(i) actions = append(actions, corpseStarchAction{ Label: Tf("corpsestarch.action.buy_servitor", T(def.effectiveNameKey(g.Mode)), g.ServitorCounts[i], formatStarch(cost)), Handle: func(m *CorpseStarchModel, now time.Time) { if m.game.BuyServitor(i, now) { m.setInfo("corpsestarch.msg.servitor_bought") } else { m.setInfo(m.game.modeKey("corpsestarch.msg.cannot_afford", "corpsestarch.msg.cannot_afford.demonic")) } }, }) } } if g.ShopUnlocked() { cost := g.clickUpgradeCost() actions = append(actions, corpseStarchAction{ Label: Tf("corpsestarch.action.upgrade_click", formatStarch(cost)), Handle: func(m *CorpseStarchModel, now time.Time) { if m.game.BuyClickUpgrade() { m.setInfo("corpsestarch.msg.upgrade_bought") } else { m.setInfo(m.game.modeKey("corpsestarch.msg.cannot_afford", "corpsestarch.msg.cannot_afford.demonic")) } }, }) } if g.SumpUnlocked() && !g.InChaosIncursion(now) { actions = append(actions, corpseStarchAction{ Label: T("corpsestarch.action.dip"), Handle: func(m *CorpseStarchModel, now time.Time) { outcome := m.game.DipChainsword(now, rand.Float64()) switch outcome { case DipBonusStarch: m.setInfo(m.game.modeKey("corpsestarch.log.dip_bonus", "corpsestarch.log.dip_bonus.demonic")) case DipMachineSpiritBlessing: m.setInfo("corpsestarch.log.dip_blessing") case DipNothing: m.setInfo("corpsestarch.log.dip_nothing") case DipCorruption: if m.game.InChaosIncursion(now) { // именно этот бросок пробил порог и запустил // вторжение прямо сейчас — раньше несколько // таких пробоев подряд могли перезапускать // вторжение, сбрасывая его таймер и прогресс // защиты; теперь пока идёт вторжение, само // действие окунания недоступно (см. условие // добавления этого действия выше), так что // сюда мы попадаем только один раз — в момент // самого пробоя m.setInfo("corpsestarch.log.warp_breach") } else { m.setInfo("corpsestarch.log.dip_corruption") } } }, }) } if g.InChaosIncursion(now) { defendKey := "corpsestarch.action.defend" if g.Mode == CorpseStarchModeDemonic { defendKey = "corpsestarch.action.defend.demonic" } actions = append(actions, corpseStarchAction{ Label: Tf(defendKey, g.ChaosDefendClicks), Handle: func(m *CorpseStarchModel, now time.Time) { m.game.DefendAgainstChaos(now) m.setInfo("corpsestarch.msg.defended") }, }) } if g.MissionsUnlocked() { if g.ActiveMission == nil { for i, def := range corpseStarchMissionDefs { if !g.MissionUnlocked(i) { continue } i := i actions = append(actions, corpseStarchAction{ Label: Tf("corpsestarch.action.start_mission", T(def.effectiveNameKey(g.Mode)), formatDuration(def.Duration)), Handle: func(m *CorpseStarchModel, now time.Time) { if m.game.StartMission(now, i) { m.setInfo("corpsestarch.msg.mission_started") } }, }) } } else if g.MissionRemaining(now) == 0 { actions = append(actions, corpseStarchAction{ Label: T("corpsestarch.action.collect_mission"), Handle: func(m *CorpseStarchModel, now time.Time) { if _, ok := m.game.CollectMission(now, rand.Float64()); ok { m.setInfo("corpsestarch.log.mission_done") } }, }) } } return actions } func (m CorpseStarchModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: m.termWidth = msg.Width return m, nil case tea.KeyMsg: return m.handleKey(msg) case corpseStarchTickMsg: m.game.Tick(msg.At) return m, corpseStarchScheduleTick() case corpseStarchAutosaveMsg: _ = SaveCorpseStarchGame(m.game) return m, corpseStarchScheduleAutosave() } return m, nil } func (m CorpseStarchModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { key := msg.String() if key == "ctrl+c" { _ = SaveCorpseStarchGame(m.game) m.quitting = true return m, tea.Quit } now := time.Now() // Подтверждение "начать заново" проверяется в первую очередь — // это модальный вопрос, который может быть вызван из любого // экрана (включая перерождение), и должен работать одинаково // везде. if m.confirmingReset { switch key { case "y", "Y": _ = DeleteCorpseStarchSave() m.game = NewCorpseStarchGame(now) m.confirmingReset = false m.setInfo("corpsestarch.msg.reset_done") case "n", "N", "esc": m.confirmingReset = false m.message = "" } return m, nil } // Во время перерождения в самой игре нельзя ничего сделать, но // сохраниться и выйти (q), либо начать всё заново (N) — можно, // как и в любой другой момент партии. if m.game.InRebirth(now) { switch key { case "q": _ = SaveCorpseStarchGame(m.game) m.backToMenu = true case "N": m.confirmingReset = true } return m, nil } // Предложение перейти на сторону Хаоса — блокирует обычные // действия, пока не будет дан явный ответ (или можно сохраниться // и обдумать это позже — предложение никуда не денется). if m.game.ChaosOfferPending() { switch key { case "y", "Y": m.game.AcceptChaosOffer(now) case "n", "N": m.game.DeclineChaosOffer() case "q": _ = SaveCorpseStarchGame(m.game) m.backToMenu = true } return m, nil } if key == "q" { _ = SaveCorpseStarchGame(m.game) m.backToMenu = true return m, nil } if key == "N" { m.confirmingReset = true return m, nil } actions := m.buildActions(now) for i := 0; i < len(actions) && i < 9; i++ { if key == fmt.Sprintf("%d", i+1) { actions[i].Handle(&m, now) return m, nil } } return m, nil } // --- Отрисовка ----------------------------------------------------- // formatStarch форматирует значение ресурса компактно (без длинного // хвоста дробной части). func formatStarch(v float64) string { if v == float64(int64(v)) { return fmt.Sprintf("%d", int64(v)) } return fmt.Sprintf("%.1f", v) } func formatDuration(d time.Duration) string { d = d.Round(time.Second) h := int(d.Hours()) m := int(d.Minutes()) % 60 s := int(d.Seconds()) % 60 switch { case h > 0: return fmt.Sprintf("%dh%02dm%02ds", h, m, s) case m > 0: return fmt.Sprintf("%dm%02ds", m, s) default: return fmt.Sprintf("%ds", s) } } func (m CorpseStarchModel) View() string { if m.quitting { return T("common.goodbye") } g := m.game now := time.Now() var b strings.Builder fmt.Fprintln(&b, titleStyle.Render(T("corpsestarch.title"))) fmt.Fprintln(&b) if m.confirmingReset { fmt.Fprintln(&b, errorStyle.Render(T("corpsestarch.confirm_reset"))) fmt.Fprintln(&b) fmt.Fprintln(&b, dimStyle.Render(T("corpsestarch.confirm_reset.hint"))) return b.String() } if g.InRebirth(now) { fmt.Fprintln(&b, errorStyle.Render(Tf("corpsestarch.rebirth.active", formatDuration(g.RebirthRemaining(now))))) fmt.Fprintln(&b) fmt.Fprintln(&b, dimStyle.Render(T("corpsestarch.rebirth.hint"))) return b.String() } if g.ChaosOfferPending() { fmt.Fprintln(&b, errorStyle.Render(T("corpsestarch.chaos.offer.title"))) fmt.Fprintln(&b) fmt.Fprintln(&b, wrapText(T("corpsestarch.chaos.offer.text"), m.termWidth)) fmt.Fprintln(&b) fmt.Fprintln(&b, T("corpsestarch.chaos.offer.accept")) fmt.Fprintln(&b, T("corpsestarch.chaos.offer.decline")) return b.String() } if g.Mode == CorpseStarchModeDemonic { fmt.Fprintln(&b, Tf("corpsestarch.header.starch.demonic", formatStarch(g.Starch), formatStarch(g.TotalEarned))) } else { fmt.Fprintln(&b, Tf("corpsestarch.header.starch", formatStarch(g.Starch), formatStarch(g.TotalEarned))) } if g.SumpUnlocked() { if g.Mode == CorpseStarchModeDemonic { fmt.Fprintln(&b, Tf("corpsestarch.header.astronomican", g.Corruption)) } else { fmt.Fprintln(&b, Tf("corpsestarch.header.corruption", g.Corruption)) } } fmt.Fprintln(&b) if g.InChaosIncursion(now) { bannerKey := "corpsestarch.incursion.active" if g.Mode == CorpseStarchModeDemonic { bannerKey = "corpsestarch.incursion.active.demonic" } fmt.Fprintln(&b, errorStyle.Render(Tf(bannerKey, formatDuration(g.ChaosIncursionRemaining(now))))) fmt.Fprintln(&b) } if g.MissionsUnlocked() && g.ActiveMission != nil { def := corpseStarchMissionDefs[g.ActiveMission.DefIdx] remaining := g.MissionRemaining(now) if remaining > 0 { fmt.Fprintln(&b, Tf("corpsestarch.mission.inprogress", T(def.effectiveNameKey(g.Mode)), formatDuration(remaining))) } else { fmt.Fprintln(&b, winStyle.Render(Tf("corpsestarch.mission.ready", T(def.effectiveNameKey(g.Mode))))) } fmt.Fprintln(&b) } actions := m.buildActions(now) for i, a := range actions { if i >= 9 { break } fmt.Fprintf(&b, "[%d] %s\n", i+1, a.Label) } fmt.Fprintln(&b) if len(g.Log) > 0 { fmt.Fprintln(&b, dimStyle.Render(T(m.game.modeKey("corpsestarch.log.title", "corpsestarch.log.title.demonic")))) for _, key := range g.Log { fmt.Fprintln(&b, dimStyle.Render(" "+wrapText(T(key), m.termWidth))) } fmt.Fprintln(&b) } if m.message != "" { fmt.Fprintln(&b, infoStyle.Render(wrapText(m.message, m.termWidth))) fmt.Fprintln(&b) } fmt.Fprintln(&b, dimStyle.Render(T("corpsestarch.hint"))) return b.String() }