从玩家反馈说起

“我昨天晚上攒了100万金币去睡觉,结果今天早上只收到了1万金币,是不是BUG?”

这是上周收到的玩家反馈。排查后发现,问题不在代码,而在我们的离线收益公式设计——收益曲线太平缓,玩家离线8小时和离线1小时的收益差距只有2倍,完全没有“离线时间越长收益越高”的正反馈。

公式藏在Rate里

先看基础实现(internal/idle/idle.go 第114-121行):

1
2
3
4
5
// 计算离线收益
elapsed := now - state.LastClaim // 离线秒数
earned := state.Rate * float64(elapsed) // earned = 速率 × 时间
state.Resources += earned // 累加到资源
state.LastClaim = now // 更新时间戳

表面看很简单:earned = Rate × elapsed。但关键在Rate的计算(第336-345行):

1
2
3
4
5
6
7
8
9
10
func (s *Store) calcRate(upgrades map[string]int, prestigeMult float64) float64 {
rate := baseRate * prestigeMult // 基础速率 × 重生加成
for _, def := range s.upgrades {
level := upgrades[def.Name]
if level > 0 {
rate *= math.Pow(def.EffectMult, float64(level)) // 每个升级的指数效果
}
}
return rate
}

展开完整公式:

1
earned = (baseRate × prestigeMult × Π effectMult_i^level_i) × elapsed_seconds

其中baseRate = 1.0,所以:

1
earned = (1.0 × prestigeMult × Π effectMult_i^level_i) × elapsed_seconds

为什么用指数而不是线性? 线性增长会让玩家觉得“升级没什么用”,而指数增长会创造“指数爆炸”的爽感——等级越高,每级提升越明显。

三个升级怎么选

升级定义:成本与效果的平衡

1
2
3
4
5
6
7
8
9
10
11
12
13
type UpgradeDef struct {
Name string // 升级名称
BaseCost float64 // 基础价格
CostMult float64 // 价格增长倍率(每级 ×costMult)
EffectMult float64 // 效果倍率(每级 ×effectMult)
MaxLevel int // 最大等级
}

var defaultUpgrades = []UpgradeDef{
{Name: "click", BaseCost: 10, CostMult: 1.5, EffectMult: 2.0, MaxLevel: 100},
{Name: "generator", BaseCost: 50, CostMult: 1.6, EffectMult: 1.5, MaxLevel: 100},
{Name: "speed", BaseCost: 100, CostMult: 1.8, EffectMult: 1.3, MaxLevel: 50},
}

三个升级的区别:

  • click:点击收益,指数2.0,便宜但上限低
  • generator:自动产出,指数1.5,中等成本
  • speed:速度加成,指数1.3,最贵但效果稳定

购买逻辑:价格曲线

购买时的价格计算(第179行):

1
cost := def.BaseCost * math.Pow(def.CostMult, float64(currentLevel))

举例:click升级

  • Lv0→1:10 × 1.5⁰ = 10
  • Lv1→2:10 × 1.5¹ = 15
  • Lv2→3:10 × 1.5² = 22.5

前期便宜让玩家快速体验,后期昂贵制造稀缺性。

数值验证:测试用例

测试文件(idle_test.go 第392-418行)展示了组合效果:

场景 输入 期望速率
无升级 {}, ×1.0 1.0
click lv1 click:1, ×1.0 2.0 (1×2¹)
click lv2 click:2, ×1.0 4.0 (1×2²)
generator lv1 generator:1, ×1.0 1.5 (1×1.5¹)
重生加成 {}, ×1.5 1.5
组合 click:1+generator:1, ×2.0 6.0 (2×2×1.5)

升级组合是乘法关系,不是加法!这意味着策略性——玩家需要选择升级哪个属性。

重生按钮:重置还是保留

为什么是1000

1
2
3
if state.Resources < 1000 {  // 至少持有 1000 资源
// 返回错误
}

为什么是1000? 这个数字需要让玩家:

  1. 有足够的投入感(至少玩了几小时)
  2. 又不至于太长(让玩家有频繁重生的可能)

重生加成公式

1
2
state.PrestigeLevel++
state.PrestigeMult = 1.0 + float64(state.PrestigeLevel) * 0.5

这里选择线性(+0.5/次),而不是指数。原因:

  1. 指数增长会让早期重生收益过高,玩家会不断重置,失去游戏节奏
  2. 线性增长保持稳定预期,玩家知道每次重生固定提升50%
  3. 配合升级系统的指数增长,形成“永久线性 + 临时指数”的双层结构

清零什么保留什么

1
2
3
state.Resources = 0                    // 资源清零
state.Upgrades = make(map[string]int) // 所有升级清零
state.Rate = s.calcRate(state.Upgrades, state.PrestigeMult) // 速率用新倍率重算

只保留prestigeLevel和prestigeMult。这是核心设计:

  • 清零升级:让玩家重新体验升级快感
  • 保留倍率:让每次重生都有实际收益
  • 重算速率:确保重生后立即感受到提升

客户端怎么显示重生结果

Godot客户端的重生反馈(demo-godot/scripts/idle.gd 第34-43行):

1
2
3
4
5
6
7
func _on_prestige_pressed():
var r = await GameBaaS.idle_prestige()
if r.ok:
var d = r.data
$StatusLabel.text = "重生! Lv%d x%.1f 倍率" % [d.prestige_level, d.prestige_mult]
$PrestigeLabel.text = "重生次数: %d | 当前倍率: x%.1f" % [d.prestige_level, d.prestige_mult]
$ResourcesLabel.text = "金币: 0 | 产出: %.1f/秒" % d.rate

立即显示新倍率和重置后的速率。玩家能直观感受到“重生后产出从1.0/秒变成1.5/秒”。

防崩设计

资源通胀控制

  • 升级成本指数增长(CostMult > 1.0)
  • 产出指数增长(EffectMult > 1.0)
  • 但产出指数 < 成本指数,确保不会无限通胀

重生频率控制

  • 阈值1000确保至少玩1-2小时
  • 线性倍率避免频繁重置的诱惑
  • 最大等级限制防止数值爆炸

离线收益上限

虽然代码中没有明确的上限,但实际通过以下方式控制:

  • 离线时间越长,收益边际效益递减
  • 升级需要在线操作,离线只是积累资源
  • 重生需要手动操作,不能自动

一个玩家的例子

玩家A的升级路径:

1
2
3
Day 1: click:10, generator:5 → 产出 2.0×1.5^5 ≈ 15.2/秒
Day 2: 重生1次 → 产出 1.5×15.2 ≈ 22.8/秒
Day 3: 重生2次 → 产出 2.0×15.2 ≈ 30.4/秒

观察:每次重生提升50%,但升级的指数增长才是主要收益来源。重生是“锦上添花”,不是“雪中送炭”。

新问题

放置游戏的核心是“让玩家感觉时间在为自己工作”。离线收益公式的设计,本质上是在回答一个问题:如何用最小的代码量,创造最强烈的正反馈?

我们的答案是:指数增长的升级 + 线性增长的永久加成 + 1000资源的重生门槛。这套组合拳让玩家有持续的目标感,又不会陷入数值膨胀的陷阱。

但有个隐患:重生次数多了以后,50 倍的线性加成会让游戏失去挑战。我们目前还没处理这个问题,玩家反馈也暂时没提到。可能等 DAU 再上一个量级时,得加个倍率衰减或者重生上限。