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 quitting bool backToMenu bool } // NewCorpseStarchModel загружает сохранённую партию, если она есть // (начисляя честный пассивный доход за время отсутствия), либо // создаёт новую. func NewCorpseStarchModel() CorpseStarchModel { now := time.Now() g, err := LoadCorpseStarchGame() if err != nil { return CorpseStarchModel{game: NewCorpseStarchGame(now)} } before := g.Starch g.Tick(now) // честно начисляем доход за время, пока партия была закрыта m := CorpseStarchModel{game: g} if earned := g.Starch - before; earned >= 1 { m.message = Tf("corpsestarch.msg.welcome_back", 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("corpsestarch.action.requisition", formatStarch(g.currentClickYield(now))), Handle: func(m *CorpseStarchModel, now time.Time) { m.game.Requisition(now) m.setInfo("corpsestarch.msg.requisitioned") }, }) if g.ServitorsUnlocked() { 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.NameKey), g.ServitorCounts[i], formatStarch(cost)), Handle: func(m *CorpseStarchModel, now time.Time) { if m.game.BuyServitor(i) { m.setInfo("corpsestarch.msg.servitor_bought") } else { m.setInfo("corpsestarch.msg.cannot_afford") } }, }) } } 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("corpsestarch.msg.cannot_afford") } }, }) } if g.SumpUnlocked() { 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("corpsestarch.log.dip_bonus") case DipMachineSpiritBlessing: m.setInfo("corpsestarch.log.dip_blessing") case DipNothing: m.setInfo("corpsestarch.log.dip_nothing") case DipCorruption: m.setInfo("corpsestarch.log.dip_corruption") } }, }) } 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.NameKey), 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.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 } if key == "q" { _ = SaveCorpseStarchGame(m.game) m.backToMenu = true return m, nil } now := time.Now() 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) m := int(d.Minutes()) s := int(d.Seconds()) % 60 if m > 0 { return fmt.Sprintf("%dm%02ds", m, s) } 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) fmt.Fprintln(&b, Tf("corpsestarch.header.starch", formatStarch(g.Starch), formatStarch(g.TotalEarned))) if g.SumpUnlocked() { fmt.Fprintln(&b, Tf("corpsestarch.header.corruption", g.Corruption)) } 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.NameKey), formatDuration(remaining))) } else { fmt.Fprintln(&b, winStyle.Render(Tf("corpsestarch.mission.ready", T(def.NameKey)))) } 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("corpsestarch.log.title"))) for _, key := range g.Log { fmt.Fprintln(&b, dimStyle.Render(" "+T(key))) } fmt.Fprintln(&b) } if m.message != "" { fmt.Fprintln(&b, infoStyle.Render(m.message)) fmt.Fprintln(&b) } fmt.Fprintln(&b, dimStyle.Render(T("corpsestarch.hint"))) return b.String() }