go_games_collection/corpsestarch_tui.go
2026-07-15 18:02:34 +03:00

421 lines
14 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"
"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()
}