408 lines
No EOL
16 KiB
Go
408 lines
No EOL
16 KiB
Go
package main
|
||
|
||
import "time"
|
||
|
||
// ПРИМЕЧАНИЕ: инкрементальная (idle/clicker) игра в духе Candy Box 2
|
||
// — с постепенным открытием механик по мере накопления ресурса.
|
||
// Антураж — пародийная стилизация под сеттинг мрачного далёкого
|
||
// будущего (сервиторы, Муниторум, Скверна, зачистки), без
|
||
// копирования конкретных защищённых текстов, персонажей или
|
||
// фракций — только тематическая лексика.
|
||
//
|
||
// Прогресс существует только в рамках текущего запуска приложения
|
||
// (как и во всех остальных играх коллекции) — между перезапусками
|
||
// ничего не сохраняется.
|
||
|
||
// corpseStarchServitorDef — статическое описание одного тира
|
||
// генератора пассивного дохода.
|
||
type corpseStarchServitorDef struct {
|
||
NameKey string
|
||
UnlockAt float64 // порог по TotalEarned, при котором тир становится виден
|
||
BaseCost float64
|
||
CostScale float64
|
||
YieldPerSec float64
|
||
}
|
||
|
||
var corpseStarchServitorDefs = []corpseStarchServitorDef{
|
||
{NameKey: "corpsestarch.servitor.skull", UnlockAt: 0, BaseCost: 10, CostScale: 1.15, YieldPerSec: 0.5},
|
||
{NameKey: "corpsestarch.servitor.menial", UnlockAt: 200, BaseCost: 100, CostScale: 1.15, YieldPerSec: 3},
|
||
{NameKey: "corpsestarch.servitor.cogitator", UnlockAt: 2000, BaseCost: 1000, CostScale: 1.15, YieldPerSec: 20},
|
||
}
|
||
|
||
// corpseStarchMissionDef — статическое описание одной доступной
|
||
// миссии зачистки.
|
||
type corpseStarchMissionDef struct {
|
||
NameKey string
|
||
UnlockAt float64
|
||
Duration time.Duration
|
||
RewardMin float64
|
||
RewardMax float64
|
||
}
|
||
|
||
var corpseStarchMissionDefs = []corpseStarchMissionDef{
|
||
{NameKey: "corpsestarch.mission.vermin", UnlockAt: 800, Duration: 20 * time.Second, RewardMin: 30, RewardMax: 60},
|
||
{NameKey: "corpsestarch.mission.chapel", UnlockAt: 800, Duration: 60 * time.Second, RewardMin: 150, RewardMax: 250},
|
||
{NameKey: "corpsestarch.mission.waaagh", UnlockAt: 800, Duration: 150 * time.Second, RewardMin: 500, RewardMax: 800},
|
||
}
|
||
|
||
// Пороги открытия основных разделов интерфейса (по TotalEarned).
|
||
const (
|
||
corpseStarchUnlockServitors = 20
|
||
corpseStarchUnlockShop = 80
|
||
corpseStarchUnlockSump = 300
|
||
corpseStarchUnlockMissions = 800
|
||
)
|
||
|
||
const (
|
||
corpseStarchClickUpgradeBaseCost = 25
|
||
corpseStarchClickUpgradeCostScale = 1.6
|
||
corpseStarchClickUpgradeGain = 1
|
||
)
|
||
|
||
// Параметры варп-выброса: при достижении этого порога Скверна
|
||
// "прорывается" наружу — половина текущего запаса крахмала и
|
||
// половина каждого тира сервиторов теряется, Скверна сбрасывается в
|
||
// 0, и начинается вторжение хаоситов на фиксированное реальное
|
||
// время. Пока оно идёт, сервиторы дают заметно меньше дохода — часть
|
||
// сил станции отвлечена на оборону — а активная защита (повторные
|
||
// нажатия) даёт шанс на бонус по окончании вторжения.
|
||
const (
|
||
corpseStarchCorruptionBreachThreshold = 3
|
||
corpseStarchIncursionDuration = 1 * time.Hour
|
||
corpseStarchIncursionYieldMultiplier = 0.5
|
||
corpseStarchDefendClicksForBonus = 10
|
||
corpseStarchDefendBonusReward = 200.0
|
||
)
|
||
|
||
// corpseStarchMission — активная (запущенная) миссия.
|
||
type corpseStarchMission struct {
|
||
DefIdx int
|
||
StartedAt time.Time
|
||
}
|
||
|
||
// CorpseStarchGameState — полное состояние партии.
|
||
type CorpseStarchGameState struct {
|
||
Starch float64 // текущий (тратимый) запас
|
||
TotalEarned float64 // накоплено за всю партию — используется для порогов открытия разделов
|
||
|
||
ClickYield float64
|
||
ClickUpgrades int
|
||
|
||
ServitorCounts []int // по одному счётчику на каждый элемент corpseStarchServitorDefs
|
||
|
||
Corruption int
|
||
|
||
// ChaosIncursionUntil — если не нулевое время и ещё не наступило,
|
||
// идёт вторжение хаоситов после варп-выброса (см. triggerWarpBreach).
|
||
// ChaosDefendClicks считает, сколько раз игрок активно отбивался
|
||
// за текущее вторжение — от этого зависит бонус по его окончании.
|
||
ChaosIncursionUntil time.Time
|
||
ChaosDefendClicks int
|
||
|
||
ActiveMission *corpseStarchMission
|
||
MissionsDone int
|
||
|
||
// BonusMultiplier / BonusUntil — временный множитель к клику от
|
||
// удачного окунания цепного меча в отстойник.
|
||
BonusMultiplier float64
|
||
BonusUntil time.Time
|
||
|
||
LastTick time.Time
|
||
|
||
Log []string // последние несколько строк лога событий, новые в конце
|
||
}
|
||
|
||
const corpseStarchLogLimit = 6
|
||
|
||
// NewCorpseStarchGame создаёт новую партию.
|
||
func NewCorpseStarchGame(now time.Time) *CorpseStarchGameState {
|
||
return &CorpseStarchGameState{
|
||
ClickYield: 1,
|
||
BonusMultiplier: 1,
|
||
ServitorCounts: make([]int, len(corpseStarchServitorDefs)),
|
||
LastTick: now,
|
||
}
|
||
}
|
||
|
||
func (g *CorpseStarchGameState) addLog(key string) {
|
||
g.Log = append(g.Log, key)
|
||
if len(g.Log) > corpseStarchLogLimit {
|
||
g.Log = g.Log[len(g.Log)-corpseStarchLogLimit:]
|
||
}
|
||
}
|
||
|
||
func (g *CorpseStarchGameState) earn(amount float64) {
|
||
if amount <= 0 {
|
||
return
|
||
}
|
||
g.Starch += amount
|
||
g.TotalEarned += amount
|
||
}
|
||
|
||
// currentClickYield учитывает временный бонусный множитель, если он
|
||
// ещё действует на момент now.
|
||
func (g *CorpseStarchGameState) currentClickYield(now time.Time) float64 {
|
||
if now.Before(g.BonusUntil) {
|
||
return g.ClickYield * g.BonusMultiplier
|
||
}
|
||
return g.ClickYield
|
||
}
|
||
|
||
// Requisition — ручное действие ("нажать кнопку"): начисляет один
|
||
// клик дохода.
|
||
func (g *CorpseStarchGameState) Requisition(now time.Time) {
|
||
g.earn(g.currentClickYield(now))
|
||
}
|
||
|
||
// Tick начисляет пассивный доход от сервиторов за время, прошедшее
|
||
// с последнего тика (now должен монотонно не убывать; для тестов
|
||
// можно подавать произвольные значения). Также сбрасывает истёкший
|
||
// временный бонус.
|
||
func (g *CorpseStarchGameState) Tick(now time.Time) {
|
||
elapsed := now.Sub(g.LastTick).Seconds()
|
||
g.LastTick = now
|
||
if elapsed <= 0 {
|
||
g.checkIncursionResolution(now)
|
||
return
|
||
}
|
||
var perSec float64
|
||
for i, def := range corpseStarchServitorDefs {
|
||
perSec += float64(g.ServitorCounts[i]) * def.YieldPerSec
|
||
}
|
||
if g.InChaosIncursion(now) {
|
||
perSec *= corpseStarchIncursionYieldMultiplier
|
||
}
|
||
g.earn(perSec * elapsed)
|
||
g.checkIncursionResolution(now)
|
||
}
|
||
|
||
// checkIncursionResolution проверяет, не истекло ли вторжение
|
||
// хаоситов, и если да — разрешает его (см. resolveChaosIncursion).
|
||
// Идемпотентно: повторные вызовы после разрешения ничего не делают,
|
||
// пока не начнётся новое вторжение.
|
||
func (g *CorpseStarchGameState) checkIncursionResolution(now time.Time) {
|
||
if g.ChaosIncursionUntil.IsZero() || now.Before(g.ChaosIncursionUntil) {
|
||
return
|
||
}
|
||
g.resolveChaosIncursion()
|
||
}
|
||
|
||
// servitorCost возвращает стоимость следующей покупки тира idx.
|
||
func (g *CorpseStarchGameState) servitorCost(idx int) float64 {
|
||
def := corpseStarchServitorDefs[idx]
|
||
cost := def.BaseCost
|
||
for i := 0; i < g.ServitorCounts[idx]; i++ {
|
||
cost *= def.CostScale
|
||
}
|
||
return cost
|
||
}
|
||
|
||
// BuyServitor покупает одну единицу тира idx, если хватает
|
||
// трупного крахмала и тир уже открыт по порогу.
|
||
func (g *CorpseStarchGameState) BuyServitor(idx int) bool {
|
||
if idx < 0 || idx >= len(corpseStarchServitorDefs) {
|
||
return false
|
||
}
|
||
if g.TotalEarned < corpseStarchServitorDefs[idx].UnlockAt {
|
||
return false
|
||
}
|
||
cost := g.servitorCost(idx)
|
||
if g.Starch < cost {
|
||
return false
|
||
}
|
||
g.Starch -= cost
|
||
g.ServitorCounts[idx]++
|
||
return true
|
||
}
|
||
|
||
func (g *CorpseStarchGameState) clickUpgradeCost() float64 {
|
||
cost := float64(corpseStarchClickUpgradeBaseCost)
|
||
for i := 0; i < g.ClickUpgrades; i++ {
|
||
cost *= corpseStarchClickUpgradeCostScale
|
||
}
|
||
return cost
|
||
}
|
||
|
||
// BuyClickUpgrade повышает базовый доход от Реквизиции ("заточка
|
||
// цепного меча").
|
||
func (g *CorpseStarchGameState) BuyClickUpgrade() bool {
|
||
cost := g.clickUpgradeCost()
|
||
if g.Starch < cost {
|
||
return false
|
||
}
|
||
g.Starch -= cost
|
||
g.ClickUpgrades++
|
||
g.ClickYield += corpseStarchClickUpgradeGain
|
||
return true
|
||
}
|
||
|
||
// CorpseStarchDipOutcome — исход окунания цепного меча в отстойник.
|
||
type CorpseStarchDipOutcome int
|
||
|
||
const (
|
||
DipBonusStarch CorpseStarchDipOutcome = iota
|
||
DipMachineSpiritBlessing
|
||
DipNothing
|
||
DipCorruption
|
||
)
|
||
|
||
// DipChainsword бросает цепной меч в отстойник; roll — случайное
|
||
// число в [0,1), передаётся вызывающим кодом (реальный TUI передаёт
|
||
// rand.Float64(), тесты — фиксированные значения для детерминизма).
|
||
func (g *CorpseStarchGameState) DipChainsword(now time.Time, roll float64) CorpseStarchDipOutcome {
|
||
switch {
|
||
case roll < 0.5:
|
||
bonus := 5 + roll*70 // немного разнообразия внутри диапазона [5,40)
|
||
g.earn(bonus)
|
||
g.addLog("corpsestarch.log.dip_bonus")
|
||
return DipBonusStarch
|
||
case roll < 0.75:
|
||
g.BonusMultiplier = 2
|
||
g.BonusUntil = now.Add(30 * time.Second)
|
||
g.addLog("corpsestarch.log.dip_blessing")
|
||
return DipMachineSpiritBlessing
|
||
case roll < 0.9:
|
||
g.addLog("corpsestarch.log.dip_nothing")
|
||
return DipNothing
|
||
default:
|
||
g.Corruption++
|
||
penalty := g.Starch * 0.1
|
||
g.Starch -= penalty
|
||
g.addLog("corpsestarch.log.dip_corruption")
|
||
if g.Corruption >= corpseStarchCorruptionBreachThreshold {
|
||
g.triggerWarpBreach(now)
|
||
}
|
||
return DipCorruption
|
||
}
|
||
}
|
||
|
||
// triggerWarpBreach — Скверна прорывается варп-выбросом: половина
|
||
// текущего запаса крахмала и половина каждого тира сервиторов
|
||
// теряется, Скверна сбрасывается в 0 (выброс её "выпустил"), и
|
||
// начинается вторжение хаоситов на фиксированное реальное время.
|
||
func (g *CorpseStarchGameState) triggerWarpBreach(now time.Time) {
|
||
g.Starch *= 0.5
|
||
for i := range g.ServitorCounts {
|
||
g.ServitorCounts[i] /= 2
|
||
}
|
||
g.Corruption = 0
|
||
g.ChaosIncursionUntil = now.Add(corpseStarchIncursionDuration)
|
||
g.ChaosDefendClicks = 0
|
||
g.addLog("corpsestarch.log.warp_breach")
|
||
}
|
||
|
||
// InChaosIncursion — идёт ли прямо сейчас вторжение хаоситов после
|
||
// варп-выброса.
|
||
func (g *CorpseStarchGameState) InChaosIncursion(now time.Time) bool {
|
||
return !g.ChaosIncursionUntil.IsZero() && now.Before(g.ChaosIncursionUntil)
|
||
}
|
||
|
||
// ChaosIncursionRemaining — сколько реального времени осталось до
|
||
// конца вторжения (0, если вторжения сейчас нет).
|
||
func (g *CorpseStarchGameState) ChaosIncursionRemaining(now time.Time) time.Duration {
|
||
if !g.InChaosIncursion(now) {
|
||
return 0
|
||
}
|
||
return g.ChaosIncursionUntil.Sub(now)
|
||
}
|
||
|
||
// DefendAgainstChaos — активная защита от хаоситов (повторные
|
||
// нажатия во время вторжения). Возвращает false, если вторжения
|
||
// сейчас нет — действие недоступно вне его рамок.
|
||
func (g *CorpseStarchGameState) DefendAgainstChaos(now time.Time) bool {
|
||
if !g.InChaosIncursion(now) {
|
||
return false
|
||
}
|
||
g.ChaosDefendClicks++
|
||
return true
|
||
}
|
||
|
||
// resolveChaosIncursion завершает текущее вторжение: если игрок
|
||
// набрал достаточно активных защит за время вторжения — начисляет
|
||
// бонус в благодарность за стойкость станции.
|
||
func (g *CorpseStarchGameState) resolveChaosIncursion() {
|
||
if g.ChaosDefendClicks >= corpseStarchDefendClicksForBonus {
|
||
g.earn(corpseStarchDefendBonusReward)
|
||
g.addLog("corpsestarch.log.incursion_repelled_bonus")
|
||
} else {
|
||
g.addLog("corpsestarch.log.incursion_repelled")
|
||
}
|
||
g.ChaosIncursionUntil = time.Time{}
|
||
g.ChaosDefendClicks = 0
|
||
}
|
||
|
||
// StartMission запускает миссию defIdx, если она открыта и сейчас
|
||
// нет другой активной миссии.
|
||
func (g *CorpseStarchGameState) StartMission(now time.Time, defIdx int) bool {
|
||
if g.ActiveMission != nil {
|
||
return false
|
||
}
|
||
if defIdx < 0 || defIdx >= len(corpseStarchMissionDefs) {
|
||
return false
|
||
}
|
||
if g.TotalEarned < corpseStarchMissionDefs[defIdx].UnlockAt {
|
||
return false
|
||
}
|
||
g.ActiveMission = &corpseStarchMission{DefIdx: defIdx, StartedAt: now}
|
||
return true
|
||
}
|
||
|
||
// MissionRemaining возвращает оставшееся время активной миссии (0,
|
||
// если миссии нет или она уже завершена).
|
||
func (g *CorpseStarchGameState) MissionRemaining(now time.Time) time.Duration {
|
||
if g.ActiveMission == nil {
|
||
return 0
|
||
}
|
||
def := corpseStarchMissionDefs[g.ActiveMission.DefIdx]
|
||
remaining := def.Duration - now.Sub(g.ActiveMission.StartedAt)
|
||
if remaining < 0 {
|
||
return 0
|
||
}
|
||
return remaining
|
||
}
|
||
|
||
// CollectMission забирает награду завершённой миссии (если она уже
|
||
// истекла); roll — случайное число в [0,1) для определения награды
|
||
// в диапазоне [RewardMin,RewardMax). Возвращает (награда, true) при
|
||
// успехе.
|
||
func (g *CorpseStarchGameState) CollectMission(now time.Time, roll float64) (float64, bool) {
|
||
if g.ActiveMission == nil {
|
||
return 0, false
|
||
}
|
||
if g.MissionRemaining(now) > 0 {
|
||
return 0, false
|
||
}
|
||
def := corpseStarchMissionDefs[g.ActiveMission.DefIdx]
|
||
reward := def.RewardMin + roll*(def.RewardMax-def.RewardMin)
|
||
g.earn(reward)
|
||
g.MissionsDone++
|
||
g.ActiveMission = nil
|
||
g.addLog("corpsestarch.log.mission_done")
|
||
return reward, true
|
||
}
|
||
|
||
// ServitorsUnlocked, ShopUnlocked, SumpUnlocked, MissionsUnlocked —
|
||
// проверки открытия соответствующих разделов интерфейса.
|
||
func (g *CorpseStarchGameState) ServitorsUnlocked() bool {
|
||
return g.TotalEarned >= corpseStarchUnlockServitors
|
||
}
|
||
func (g *CorpseStarchGameState) ShopUnlocked() bool {
|
||
return g.TotalEarned >= corpseStarchUnlockShop
|
||
}
|
||
func (g *CorpseStarchGameState) SumpUnlocked() bool {
|
||
return g.TotalEarned >= corpseStarchUnlockSump
|
||
}
|
||
func (g *CorpseStarchGameState) MissionsUnlocked() bool {
|
||
return g.TotalEarned >= corpseStarchUnlockMissions
|
||
}
|
||
|
||
// ServitorUnlocked проверяет, виден ли конкретный тир idx (может
|
||
// быть ещё не открыт, даже если раздел "Сервиторы" уже виден).
|
||
func (g *CorpseStarchGameState) ServitorUnlocked(idx int) bool {
|
||
return g.TotalEarned >= corpseStarchServitorDefs[idx].UnlockAt
|
||
}
|
||
|
||
// MissionUnlocked проверяет, видна ли конкретная миссия idx.
|
||
func (g *CorpseStarchGameState) MissionUnlocked(idx int) bool {
|
||
return g.TotalEarned >= corpseStarchMissionDefs[idx].UnlockAt
|
||
} |