站内那篇游戏经济系统运营 是一份产业 checklist——它列的是「经济系统上线前该检查哪些项、活动运营该覆盖哪些方面」,是给你照单核对用的。本章不重复它:本章给的是可跑的活动奖励预算模型——赛季分几段、每段排哪些活动、这些活动要发多少奖励、发的总量和赛季产出比是多少,全部能用配置和脚本算出来。checklist 告诉你「别忘了排活动」,本章告诉你「排完之后预算会不会失控」。
先划三处边界。卷一 3.2《日/周/赛季三级节奏》讲的是玩家行为在时间上的分布——24 小时行为密度曲线、周权重、峰均比,那是「玩家什么时候上线」。本节讲的是赛季这个容器怎么切、活动往哪排——那是「运营什么时候放什么」,是同一根时间轴的产品侧。第 8 章《赛季数值膨胀控制》处理的是赛季与赛季之间的存量继承与压缩,本节只处理一个赛季内部的结构与节奏。第 15 章处理联盟与 GVG,本节的活动排期会用到「联盟战」这个活动,但不碰它的匹配与战场设计。
赛季是一个容器,不是一个数字
新手把「赛季 60 天」当成一个数字,到点开下一季。但 60 天不是均匀的:玩家第 1 天什么都没有,第 30 天有一支成型队伍,第 58 天在为排名冲刺。同一个「每日签到」,在开荒期是雪中送炭,在收官期是可有可无。赛季结构设计的第一件事,就是承认这 60 天内部不是平的。
《王朝纪元》的赛季被切成四段:
- 开荒期:所有人从
floor起步,抢地、抢资源点,节奏轻,活动少。 - 成长期:队伍成型、城建爬坡,是养成活动的主场。
- 对抗期:联盟开战,联盟战、跨服战这类重活动登场。
- 收官期:排名冲刺,活动密度最高,但也最不能出错——一旦峰值把玩家压垮,流失就发生在结算前。
这四段不是拍脑袋分的,得有一个占比(各占赛季多少天)和一条活动排期(哪些活动、多久一次、持续几天)。下面把它写成配置。
把结构与排期写进配置
下面这份是 D 层(静态层)配置。赛季长度、准备天数、结算小时都复用共享的 config/season.json,不在这里重复;本文件只补两样东西——阶段占比和活动模板:
{
"version": 1,
"note": "第 14 章 14.1 赛季结构与节奏(n14 前缀,独立于卷一 ch*)。赛季长度/准备/结算复用共享的 config/season.json;本文件补充赛季内阶段划分与活动排期。比率一律整数百分比,天数整数,禁浮点。",
"phases": [
{ "id": "opening", "name": "开荒期", "daySharePct": 25 },
{ "id": "growth", "name": "成长期", "daySharePct": 35 },
{ "id": "war", "name": "对抗期", "daySharePct": 30 },
{ "id": "finale", "name": "收官期", "daySharePct": 10 }
],
"activities": [
{ "id": "daily_signin", "name": "每日签到", "cadenceDays": 1, "durationDays": 1, "load": 1, "fromDay": 1, "toDay": 60 },
{ "id": "weekly_quest", "name": "周常任务", "cadenceDays": 7, "durationDays": 3, "load": 2, "fromDay": 1, "toDay": 60 },
{ "id": "growth_race", "name": "成长竞赛", "cadenceDays": 15, "durationDays": 5, "load": 3, "fromDay": 1, "toDay": 60 },
{ "id": "alliance_war", "name": "联盟战", "cadenceDays": 14, "durationDays": 3, "load": 4, "fromDay": 16, "toDay": 60 },
{ "id": "final_sprint", "name": "收官冲刺", "cadenceDays": 60, "durationDays": 6, "load": 5, "fromDay": 55, "toDay": 60 }
],
"pacingBand": {
"note": "节奏健康带:赛季内每日活动负载的峰均比(千分比)不宜过高,否则玩家在峰值日被压垮;收官期相对开荒期的负载抬升也不宜过猛。",
"peakAvgRatioWarnPermille": 3500,
"finaleOverOpeningWarnPermille": 2500
}
}
四个字段决定了每个活动的排期:cadenceDays(多久来一次)、durationDays(持续几天)、load(它占多少「同时进行中」的权重)、以及生效窗口 fromDay / toDay。某个活动在第 d 天是否进行,取决于 (d − fromDay) % cadenceDays < durationDays——这是最朴素的周期相位排期,够用且确定。
相位排期:一条公式管住所有周期活动
手排日历的问题是:赛季一改长度、活动一改次数,整张表就得重画。相位排期把「排期」变成一条公式:活动从 fromDay 起,每隔 cadenceDays 来一次,每次持续 durationDays 天。只要这三个数定了,任意一天是否在活动期内都能算出来,不依赖任何日历表。
举个具体的:联盟战 cadenceDays = 14、durationDays = 3、fromDay = 16,于是它在 D16、D30、D44、D58 各开一轮,每轮持续 3 天(D16–D18、D30–D32……)。把 fromDay 从 16 改成 18,整条联盟战的时间线就整体后移两天——要削峰,改一个数就够,不用重画表。
相位排期带来三个好处:
- 确定性:给定配置,任意一天的负载唯一确定,跨语言、跨设备一致(P 层原型据此可复现)。
- 可微调:削峰只需挪
fromDay或缩durationDays,改动局部、影响可控。 - 可校验:负载曲线是配置的函数,改配置立刻能看到峰均比和阶段斜率怎么变。
代价是灵活性:相位排期表达不了「这个活动只在有联盟的服务器开」这类条件逻辑。但那属于投放策略,不属于赛季结构——结构层保持简单可算,策略层再叠条件。
赛季长度变了,结构怎么跟着变
上面这套结构是按 60 天赛季定的。如果赛季改成 45 天或 90 天,阶段占比要不要跟着改?
- 阶段占比可以保持不变,但活动的绝对次数会变。 60 天里成长竞赛来 4 次(
cadenceDays = 15);赛季缩到 45 天就只来 3 次。如果希望「每个赛季的活动次数稳定」,赛季长度变化时就要同步调cadenceDays。 - 收官期不能按占比等比缩。 收官期 10% 在 60 天里是 6 天,缩到 45 天就只剩 4.5 天——但结算需要的时间(
settlementHours)是固定的 1 天,准备期固定 3 天。固定成本不随赛季长度缩放,这也是为什么短赛季的过渡期占比会更高(14.3 会算到 6% 这条线)。
所以阶段占比是「相对」的,但准备/结算这些「绝对」的固定天数是硬的。结构设计要同时管住相对占比和绝对天数两套刻度。
S 层:把活动铺满每一天
下面这段是 S 层仿真脚本。它读上面的配置与共享的 season.json,按阶段占比把赛季切段,逐日累加当天所有进行中活动的 load,算出每日活动负载曲线、峰均比、各阶段平均负载,落成 JSON + CSV + SVG:
# file: sim/n14_season_phases.py
# tier: S
# verify: python3 sim/n14_season_phases.py --config config/ --out out/
"""14.1 赛季结构与节奏:读共享的 config/season.json 拿到赛季长度/准备/结算,
按 config/n14_season_phases.json 的阶段占比把赛季切成若干阶段,再把活动排期铺到
每一天上,算出每日活动负载、峰均比与各阶段平均负载。
产出 out/n14_season_phases.json + out/n14_season_phases.csv + out/n14_season_phases.svg。整数运算,禁浮点。"""
from __future__ import annotations
import argparse
import csv
import json
import os
from svg_line import render
def load(cfg_dir: str, name: str) -> dict:
with open(os.path.join(cfg_dir, f"{name}.json"), encoding="utf-8") as fh:
return json.load(fh)
def ceil_div(a: int, b: int) -> int:
return (a + b - 1) // b
def split_days(total: int, share_pcts: list[int]) -> list[int]:
"""把 total 天按整数百分比切分,地板除的余数补给最后一个阶段。"""
counts = [total * p // 100 for p in share_pcts]
counts[-1] += total - sum(counts)
return counts
def is_active(act: dict, day: int) -> bool:
"""活动在第 day 天是否进行:落在窗口内,且落在周期相位的前 durationDays 天。"""
if day < act["fromDay"] or day > act["toDay"]:
return False
return (day - act["fromDay"]) % act["cadenceDays"] < act["durationDays"]
def daily_loads(activities: list[dict], season_days: int) -> list[int]:
return [sum(a["load"] for a in activities if is_active(a, d)) for d in range(1, season_days + 1)]
def main() -> int:
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, "n14_season_phases")
season = load(args.config, "season")
days = season["seasonLengthDays"]
prep = season["prepDays"]
settle_days = ceil_div(season["settlementHours"], 24)
counts = split_days(days, [p["daySharePct"] for p in cfg["phases"]])
loads = daily_loads(cfg["activities"], days)
peak, avg = max(loads), sum(loads) // days
peak_avg = peak * 1000 // avg
phases_out, cursor = [], 0
for p, cnt in zip(cfg["phases"], counts):
seg = loads[cursor:cursor + cnt]
phases_out.append({"id": p["id"], "name": p["name"], "daySharePct": p["daySharePct"],
"dayCount": cnt, "fromDay": cursor + 1, "toDay": cursor + cnt,
"avgLoad": sum(seg) // cnt if cnt else 0})
cursor += cnt
opening = phases_out[0]["avgLoad"]
finale = phases_out[-1]["avgLoad"]
finale_over_opening = finale * 1000 // opening if opening else 0
band = cfg["pacingBand"]
summary = {
"seasonLengthDays": days, "prepDays": prep, "settlementDays": settle_days,
"totalCycleDays": days + prep + settle_days,
"peakLoad": peak, "avgLoad": avg, "peakAvgRatioPermille": peak_avg,
"openingAvgLoad": opening, "finaleAvgLoad": finale,
"finaleOverOpeningPermille": finale_over_opening,
"peakAvgWarn": peak_avg > band["peakAvgRatioWarnPermille"],
"finaleOverOpeningWarn": finale_over_opening > band["finaleOverOpeningWarnPermille"],
}
with open(os.path.join(args.out, "n14_season_phases.csv"), "w", encoding="utf-8", newline="") as fh:
w = csv.writer(fh)
w.writerow(["day", "load"])
for d, lv in enumerate(loads, start=1):
w.writerow([d, lv])
render(loads, os.path.join(args.out, "n14_season_phases.svg"),
f"season activity load across {days} days")
report = {"version": 1, "phases": phases_out, "summary": summary,
"activities": [a["id"] for a in cfg["activities"]]}
with open(os.path.join(args.out, "n14_season_phases.json"), "w", encoding="utf-8") as fh:
json.dump(report, fh, ensure_ascii=False, indent=2)
print(json.dumps(summary, ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())
本机实跑(--config config/ --out out/)得到:
{"seasonLengthDays": 60, "prepDays": 3, "settlementDays": 1, "totalCycleDays": 64, "peakLoad": 12, "avgLoad": 4, "peakAvgRatioPermille": 3000, "openingAvgLoad": 2, "finaleAvgLoad": 9, "finaleOverOpeningPermille": 4500, "peakAvgWarn": false, "finaleOverOpeningWarn": true}
结果:峰均比合格,收官抬升亮红灯
把四个阶段摊开(out/n14_season_phases.json 的 phases):
| 阶段 | 占比 | 天数 | 区间 | 平均负载 |
|---|---|---|---|---|
| 开荒期 | 25% | 15 | D1–D15 | 2 |
| 成长期 | 35% | 21 | D16–D36 | 4 |
| 对抗期 | 30% | 18 | D37–D54 | 3 |
| 收官期 | 10% | 6 | D55–D60 | 9 |
每日负载在 1 到 12 之间摆动。抽几个典型日看(out/n14_season_phases.csv):
| 天 | D1 | D5 | D15 | D16 | D20 | D21 | D36 | D54 | D55 | D58 | D60 |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 负载 | 6 | 4 | 3 | 10 | 4 | 1 | 3 | 1 | 6 | 12 | 10 |
两个判据给了相反的结论:
- 峰均比 3000‰(
peakAvgWarn = false)。峰值日负载 12、平均负载 4,比值 3000‰,低于 3500‰ 的告警线。也就是说单看整季,负载分布还不算畸形——峰值日虽忙,但平均下来没到「天天高强度」的程度。 - 收官相对开荒抬升 4500‰(
finaleOverOpeningWarn = true)。收官期平均负载 9,是开荒期 2 的 4.5 倍,远超 2500‰ 的告警线。这才是真正的问题:赛季末玩家本来就在冲刺排名、心理压力最大,活动密度还比开局高 4.5 倍,等于在玩家最焦虑的 6 天里堆最多的事。
峰均比合格不代表节奏健康。 一个把全部重活动堆在最后一周的排期,整季峰均比可能仍然达标,但它在最不该加压的时候加了压。这正是为什么健康带里要放两条判据:一条看整体分布(峰均比),一条看阶段斜率(收官/开荒)。
结构设计的三个决定
把这一节压成三条可复用的判断:
- 阶段占比决定节奏的「底色」。 开荒 25%、成长 35%、对抗 30%、收官 10% 是一套偏「中段重、末段短」的分配——把最多天数给成长与对抗,收官只留 6 天。如果把收官拉到 20%(12 天),玩家冲刺的疲劳窗口就会翻倍。
- 活动排期要错峰,不能叠峰。 本例里 D58 负载冲到 12,是因为收官冲刺(load 5)与联盟战(load 4)、周常(load 2)、签到(load 1)在那几天叠在一起。把联盟战的相位往后挪两天,峰值就能被削平——排期的第一原则不是「排满」,是「错开」。
- 收官期的负载要克制。 健康带把「收官/开荒」抬升线设在 2500‰,本例的 4500‰ 已经越界。可行的修法有两种:要么削收官活动的
load,要么把重活动(联盟战最后一轮)提前到对抗期末。赛季越到后面,活动越要「轻而准」,而不是「多而重」。
下一节换个尺度:不谈「哪天排什么」,改谈「这些活动一共要发多少奖励」——把每个活动的面额、参与率、次数累起来,算出赛季奖励预算,再和赛季产出预算对账。
阅读导航:上一节:目录 · 下一节:14.2 活动模板与奖励预算 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。