10.2 产出与时间的关系(在线/离线/回归)

同一份产出速率表,在线玩家与离线玩家拿到的资源并不一样。本节讨论离线产出该不该有、上限怎么设、回归玩家怎么补,并用一段 S 层脚本对比无离线、有离线、有上限离线三种方案 30 天的资源曲线,输出 CSV 与纯 Python SVG,再用 TypeScript 原型断言同一组数字。

上一节把产出端拆成了速率、可控性、方差三个维度,算出了每小时的产出表。但那张表默认了一件事:时间被均匀地消耗。站内那篇通用的经济平衡框架 用放置类举例,产出几乎是「一直在涨」的连续函数,不区分玩家在不在;而 SLG 的大地图口径里,玩家一天只在线几个小时,剩下十几个小时建筑还在不在产、产了算不算,是必须先定下来的规则。这一节就把「时间」这个变量引进来。

同一份速率表,两种时间口径

上一节算出的连续速率(伐木 5 级 = 132 木材/小时)是一个瞬时量:它描述「如果这一小时玩家在结算,产出多少」。而玩家的一天并不是 24 小时都在结算:

  • 在线:玩家在游戏里,建筑产出实时入账,这部分没有争议。
  • 离线:玩家退出游戏,建筑理论上还能继续产,但要不要让它产、产多少、上限多久,全是设计选择。

这两段时间的产出规则一旦不同,同一份速率表就会分裂出完全不同的资源曲线。所以设计产出端时,「离线产出」是一个必须先回答的产品问题,而不是一个可以后补的技术细节。

在线与离线:产出到底按什么算

《王朝纪元》的口径是:连续型产出(建筑、地块)离线也照常累积,但有两个约束——一是离线上限,二是结算粒度。这里的取舍直接决定了游戏的社交压力和留存曲线:

  • 如果离线完全不产:玩家必须长时间在线才能跟上进度,SLG 会变成「点卡式」的挂机压力,对上班族不友好,也与「放置一段、回来收割」的 SLG 节奏相悖。
  • 如果离线全额产:产出与在线时长解耦,玩家离线越久攒得越多,反而削弱了「在线才有互动」的动力,联盟战、抢夺的动机都会被稀释。
  • 如果离线有上限地产:取一个中间值——离线能攒,但攒到一定小时数就封顶,逼玩家定期上线「清仓」。这是绝大多数成熟 SLG 的选择。

本节的仿真就把这三种方案放在同一张图上对比。

三种离线方案:无 / 有 / 有上限

下面这份是 D 层配置,定义了在线小时数、离线上限与回归加成参数:

{
  "version": 1,
  "note": "产出与时间的关系:在线小时、离线产出上限、回归加成曲线。全部整数口径。",
  "days": 30,
  "resource": "wood",
  "levels": { "lumberyard": 5, "quarry": 5, "ironworks": 3, "farm": 6 },
  "onlineHoursPerDay": 4,
  "offlineCapHours": 8,
  "returnBonus": {
    "offlineDayPoints": [1, 3, 7, 14, 30],
    "bonusPermillePerDay": 150,
    "capPermille": 3000,
    "activeDays": 7
  }
}

下面这段是 S 层仿真脚本:固定伐木场 5 级(132 木材/小时)、玩家每天在线 4 小时,分别跑三种方案 30 天,输出 CSV 曲线与纯 Python SVG 图:

# file: sim/ch10_online_offline.py
# tier: S
# verify: python3 sim/ch10_online_offline.py --config config/ --out out/
"""产出与时间的关系:对比「无离线产出 / 有离线产出 / 有上限离线产出」三种方案的资源曲线,
并给出回归玩家加成曲线。图用纯 Python(xml.etree)画 SVG,零依赖。
产出:out/ch10_online_offline.json + out/ch10_online_offline.csv + out/ch10_online_offline.svg
"""

from __future__ import annotations
import argparse, csv, json, os
import xml.etree.ElementTree as ET

SVG_NS = "http://www.w3.org/2000/svg"


def load(cfg_dir, name):
    with open(os.path.join(cfg_dir, f"{name}.json"), encoding="utf-8") as f:
        return json.load(f)


def building_rate(cfg_dir, resource, levels):
    rate = 0
    for b in load(cfg_dir, "buildings")["buildings"]:
        lv = levels.get(b["id"], 0)
        if b["resource"] == resource and lv > 0:
            rate += (b["outputBase"] * b["outputGrowthNum"] ** (lv - 1)) // b["outputGrowthDen"] ** (lv - 1)
    return rate


def simulate(cfg, rate):
    """三种离线方案的每日产出与累计曲线。"""
    days, online, cap = cfg["days"], cfg["onlineHoursPerDay"], cfg["offlineCapHours"]
    offline = 24 - online
    daily = {
        "noOffline": rate * online,
        "fullOffline": rate * (online + offline),
        "cappedOffline": rate * (online + min(offline, cap)),
    }
    curves = {}
    for k, d in daily.items():
        acc, seq = 0, []
        for _ in range(days):
            acc += d; seq.append(acc)
        curves[k] = seq
    return daily, curves


def return_bonus(cfg, rate):
    rb, online = cfg["returnBonus"], cfg["onlineHoursPerDay"]
    base = rate * online
    rows = []
    for od in rb["offlineDayPoints"]:
        bonus = min(rb["capPermille"], rb["bonusPermillePerDay"] * od)
        boosted = base * (1000 + bonus) // 1000
        rows.append({"offlineDays": od, "bonusPermille": bonus, "baseDaily": base,
                     "boostedDaily": boosted, "extraOverActiveDays": (boosted - base) * rb["activeDays"]})
    return rows


def render_svg(path, curves):
    width, height, pl, pr, pt, pb = 680, 400, 56, 24, 28, 44
    iw, ih = width - pl - pr, height - pt - pb
    days = len(next(iter(curves.values())))
    hi = max(max(v) for v in curves.values()) or 1
    colors = {"noOffline": "#c0392b", "fullOffline": "#1f6feb", "cappedOffline": "#27ae60"}
    svg = ET.Element("svg", {"xmlns": SVG_NS, "viewBox": f"0 0 {width} {height}", "width": str(width), "height": str(height)})
    ET.SubElement(svg, "rect", {"width": str(width), "height": str(height), "fill": "#ffffff"})
    ET.SubElement(svg, "line", {"x1": str(pl), "y1": str(pt), "x2": str(pl), "y2": str(height - pb), "stroke": "#333"})
    ET.SubElement(svg, "line", {"x1": str(pl), "y1": str(height - pb), "x2": str(width - pr), "y2": str(height - pb), "stroke": "#333"})
    for k, series in curves.items():
        pts = " ".join(f"{pl + iw * (i / max(days - 1, 1)):.1f},{pt + ih * (1 - v / hi):.1f}" for i, v in enumerate(series))
        ET.SubElement(svg, "polyline", {"points": pts, "fill": "none", "stroke": colors[k], "stroke-width": "2"})
    for i, k in enumerate(curves):
        yy = pt + 4 + i * 18
        ET.SubElement(svg, "line", {"x1": str(width - 190), "y1": str(yy), "x2": str(width - 160), "y2": str(yy), "stroke": colors[k], "stroke-width": "3"})
        ET.SubElement(svg, "text", {"x": str(width - 154), "y": str(yy + 4), "font-size": "12", "fill": "#111"}).text = k
    ET.SubElement(svg, "text", {"x": str(pl), "y": "16", "font-size": "13", "fill": "#111"}).text = "cumulative production: 3 offline schemes"
    os.makedirs(os.path.dirname(path), exist_ok=True)
    ET.ElementTree(svg).write(path, encoding="utf-8", xml_declaration=True)


def main():
    here = os.path.dirname(os.path.abspath(__file__))
    ap = argparse.ArgumentParser()
    ap.add_argument("--config", default=os.path.join(here, "..", "config"))
    ap.add_argument("--out", default=os.path.join(here, "..", "out"))
    args = ap.parse_args()
    os.makedirs(args.out, exist_ok=True)

    cfg = load(args.config, "ch10_timing")
    rate = building_rate(args.config, cfg["resource"], cfg["levels"])
    daily, curves = simulate(cfg, rate)
    rb = return_bonus(cfg, rate)

    with open(os.path.join(args.out, "ch10_online_offline.csv"), "w", encoding="utf-8", newline="") as f:
        w = csv.writer(f); w.writerow(["day", "noOffline", "fullOffline", "cappedOffline"])
        for i in range(cfg["days"]):
            w.writerow([i + 1, curves["noOffline"][i], curves["fullOffline"][i], curves["cappedOffline"][i]])
    with open(os.path.join(args.out, "ch10_return_bonus.csv"), "w", encoding="utf-8", newline="") as f:
        w = csv.writer(f); w.writerow(["offlineDays", "bonusPermille", "baseDaily", "boostedDaily", "extraOverActiveDays"])
        for r in rb:
            w.writerow([r["offlineDays"], r["bonusPermille"], r["baseDaily"], r["boostedDaily"], r["extraOverActiveDays"]])

    report = {
        "version": 1, "resource": cfg["resource"], "ratePerHour": rate, "days": cfg["days"],
        "onlineHoursPerDay": cfg["onlineHoursPerDay"], "offlineCapHours": cfg["offlineCapHours"],
        "daily": daily,
        "cumulativeFinal": {k: curves[k][-1] for k in curves},
        "fullToNoRatioPct": daily["fullOffline"] * 100 // daily["noOffline"],
        "cappedToFullPct": daily["cappedOffline"] * 100 // daily["fullOffline"],
        "returnBonus": rb,
    }
    with open(os.path.join(args.out, "ch10_online_offline.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2)
    render_svg(os.path.join(args.out, "ch10_online_offline.svg"), curves)
    print(json.dumps({k: report[k] for k in ("ratePerHour", "daily", "cumulativeFinal", "fullToNoRatioPct", "cappedToFullPct")}, ensure_ascii=False))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

本机实跑(Python 3.14.6)得到:

ratePerHour: 132
daily: noOffline=528  fullOffline=3168  cappedOffline=1584
cumulativeFinal(30d): noOffline=15840  fullOffline=95040  cappedOffline=47520
fullToNoRatioPct: 600   cappedToFullPct: 50

这三行数字就是「离线规则」的全部威力。逐条读:

  • 无离线:每天只产 132 × 4 = 528,30 天累计 15840。
  • 全额离线:每天 132 × 24 = 3168,30 天累计 95040——是前者的 6 倍(fullToNoRatioPct = 600)。
  • 上限离线:每天 132 × (4 + min(20, 8)) = 1584,30 天累计 47520——正好是全额的 50%(cappedToFullPct = 50)。

换句话说,「离线要不要产」这一个开关,能把玩家 30 天的资源总量在三倍区间内来回拉。它比调任何一条产出增长率都更省事、影响更大。SVG 图(out/ch10_online_offline.svg)把三条累计曲线画在一起:红线最平、蓝线最陡、绿线居中,视觉上一眼能看出方案之间的差距。这些是同机相对量级,绝对数值不可移植。

离线产出该不该有:三条判据

三种方案没有绝对的对错,但可以用三条判据来选:

  1. 进度感:无离线产出的方案,玩家一旦几天不上线就会「掉队感」强烈;有离线(哪怕是上限版)能兜住这一点。
  2. 互动动机:全额离线会让「在线互动」失去稀缺性——反正离线也照样产,联盟战、抢夺的动力被稀释。上限版把「清仓」变成一个需要上线的动作,保住了互动钩子。
  3. 经济水位:离线产出直接抬高全服资源总量,进而抬高消耗端压力(第 11 章)与通胀风险(第 13 章)。上限越高,闭环越难收敛。

《王朝纪元》选的是上限版,上限取 8 小时——大约是「一个正常睡眠周期 + 一次通勤」的尺度,逼玩家每天至少上线一次。

离线上限怎么设

上限不是一个拍脑袋的小时数,它对应一个明确的设计意图。可以这样反推:

  • 若上限 < 在线时长,离线几乎不产,等于变相逼在线。
  • 若上限 ≈ 在线时长,离线与在线权重相当,玩家「上线一次」就能拿到接近全天的产出。
  • 若上限 > 在线时长,离线成为主要来源,玩家可以「离线挂机」,互动被削弱。

本方案在线 4 小时、离线上限 8 小时,上限是在线时长的 2 倍,让「每天上线清一次仓」能拿到 (4+8)/24 = 50% 的满产。这个比例(本方案取 50%)是运营侧最直观的调节旋钮:想更逼在线就往下调,想更宽容就往上调。

回归玩家:加成曲线

离线规则的极端情形是回归玩家——离线了几天甚至几周的人回来。如果只按离线上限补,一个离线 30 天的玩家回来只能拿到 8 小时产出,等于永久损失了几周的进度,回归意愿会很低。所以成熟 SLG 会给一条回归加成曲线:离线越久,回来后一段时间内的产出加成越高,但封顶。

本方案的规则是:每离线 1 天给 150‰ 的产出加成,封顶 3000‰(3 倍),加成持续 7 天。实跑结果:

离线天数加成(‰)每日产出(在线 4h)加成后每日7 天累计多拿
1150528607553
34505287651659
7105052810823878
14210052816367756
303000528211211088

读法:加成按离线天数线性增长,到 20 天触顶(150 × 20 = 3000),之后不再增加。这个封顶很关键——没有封顶的话,一个离线半年的玩家回来就能碾压全场,会摧毁在线玩家的竞争公平。封顶把「补偿」和「破坏平衡」隔开了。

原型层:把离线结算搬进 TypeScript

同一份 config/,仿真层算曲线,原型层跑可玩逻辑。下面是本章的 P 层断言(完整文件 proto/test/ch10.test.ts),它把三种方案的每日产出、累计产出、回归加成逐条钉死,且数值与 Python 侧逐位一致:

// file: proto/test/ch10.test.ts
// tier: P
// verify: node --test proto/test/ch10.test.ts
// 第 10 章断言:产出源表、离线方案曲线、回归加成、N 天产出曲线,数值与 Python 侧逐位一致。

import { test } from "node:test";
import assert from "node:assert/strict";
import { productionTable, sourceInventory, offlineSchemes, returnBonus, curveReport } from "../src/ch10_production.ts";

test("10.1 建筑小时产出表:4 建筑 × 10 级 = 40 行,关键点对齐", () => {
  const rows = productionTable();
  assert.equal(rows.length, 40);
  const at = (bid: string, lv: number) => rows.find((r) => r.building === bid && r.level === lv)!.outputPerHour;
  assert.equal(at("lumberyard", 1), 60);
  assert.equal(at("lumberyard", 5), 132);
  assert.equal(at("lumberyard", 10), 359);
  assert.equal(at("ironworks", 1), 18);
  assert.equal(at("farm", 10), 412);
});

test("10.1 产出源汇总:7 源(2 连续 / 5 脉冲),基础等级连续速率", () => {
  const r = sourceInventory();
  assert.equal(r.sourceCount, 7);
  assert.equal(r.continuousCount, 2);
  assert.equal(r.burstCount, 5);
  assert.equal(r.levelRows, 40);
  assert.equal(r.maxOutputPerHour, 412);
  assert.deepEqual(r.continuousRatePerHour, { wood: 132, stone: 110, iron: 27, grain: 199 });
});

test("10.2 三种离线方案:每日与 30 天累计产出", () => {
  const r = offlineSchemes();
  assert.equal(r.ratePerHour, 132);
  assert.deepEqual(r.daily, { noOffline: 528, fullOffline: 3168, cappedOffline: 1584 });
  assert.deepEqual(r.cumulativeFinal, { noOffline: 15840, fullOffline: 95040, cappedOffline: 47520 });
});

test("10.2 回归加成曲线:按离线天数线性增长并封顶", () => {
  const rows = returnBonus();
  assert.deepEqual(rows.map((x) => x.offlineDays), [1, 3, 7, 14, 30]);
  assert.deepEqual(rows.map((x) => x.bonusPermille), [150, 450, 1050, 2100, 3000]);
  assert.deepEqual(rows.map((x) => x.boostedDaily), [607, 765, 1082, 1636, 2112]);
  assert.deepEqual(rows.map((x) => x.extraOverActiveDays), [553, 1659, 3878, 7756, 11088]);
});

test("10.3 N 天产出曲线:首末日与累计产出对齐 Python 侧", () => {
  const r = curveReport();
  assert.equal(r.days, 60);
  assert.equal(r.dailyFirst, 8424);
  assert.equal(r.dailyLast, 15066);
  assert.equal(r.cumulativeFinal, 705272);
});

用 node --test proto/test/ch10.test.ts 跑,5 条全绿。同一份配置在 Python 与 TypeScript 两侧算出逐位一致的整数,这正是第 9 章账本模型要求的「产出可对账」在代码层面的落实。

小结

产出与时间的关系,本质上是一个产品选择:离线产不产、产多少、封顶多久、回归补多少。四个旋钮一起,把同一份速率表扭曲成了完全不同的资源曲线。本节把「在线 4h / 离线上限 8h / 回归加成封顶 3 倍」这组选择跑成了可复现的数字;至于这条曲线在整个经济闭环里合不合理,要等第 11 章的消耗端和第 12 章的闭环仿真来回答。

下一节把 10.1 的产出源和 10.2 的时间规则汇总成一条 N 天的产出曲线,并做拟合与残差检验。

阅读导航:上一节:产出源清单与产出速率 · 下一节:产出曲线仿真 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「slg」更多文章

  1. 12.3 留存与付费的联合分析
  2. 12.2 流失预警与召回
  3. 附录 C:全书公式、参数与数据集索引