314 lines
8.9 KiB
Go
314 lines
8.9 KiB
Go
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) — необратимое действие, требует явного согласия
|
||
|
||
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 m.confirmingReset {
|
||
switch key {
|
||
case "y", "Y":
|
||
_ = DeleteCorpseStarchSave()
|
||
m.game = NewCorpseStarchGame(time.Now())
|
||
m.confirmingReset = false
|
||
m.setInfo("corpsestarch.msg.reset_done")
|
||
case "n", "N", "esc":
|
||
m.confirmingReset = false
|
||
m.message = ""
|
||
}
|
||
return m, nil
|
||
}
|
||
|
||
if key == "q" {
|
||
_ = SaveCorpseStarchGame(m.game)
|
||
m.backToMenu = true
|
||
return m, nil
|
||
}
|
||
if key == "N" {
|
||
m.confirmingReset = 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)
|
||
|
||
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()
|
||
}
|
||
|
||
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()
|
||
}
|