312 lines
11 KiB
Go
312 lines
11 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
|
||
)
|
||
|
||
// 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
|
||
|
||
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 {
|
||
return
|
||
}
|
||
var perSec float64
|
||
for i, def := range corpseStarchServitorDefs {
|
||
perSec += float64(g.ServitorCounts[i]) * def.YieldPerSec
|
||
}
|
||
g.earn(perSec * elapsed)
|
||
}
|
||
|
||
// 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")
|
||
return DipCorruption
|
||
}
|
||
}
|
||
|
||
// 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
|
||
}
|