go_games_collection/corpsestarch_game.go
2026-07-07 09:58:26 +03:00

408 lines
No EOL
16 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 "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
}