上一节我们把核心循环画成了一张闭合的有向图。但循环不会自己转——它靠玩家的行为推动,而玩家的行为是有节拍的:早上通勤时点两下,中午吃饭时收个菜,晚上八点联盟集结。节奏设计的任务,就是让循环的节拍和玩家的作息对上。
先划边界。站内的经济平衡长文 把「节奏」理解成成长曲线的时间尺度(放置类里就是离线收益多久到顶)。那套口径关注的是「资源随时间的总量」,本节关注的是行为在时间上的分布——同一批资源,玩家是集中在晚上八点一口气花掉,还是全天均匀地花,对服务器负载、活动排期和留存的影响完全不同。SLG 的技术架构专题 处理的是服务器怎么扛住这个峰值,本节处理的是峰值本身该怎么设计。
节奏是三层时钟叠在一起
SLG 的节奏不是一条曲线,而是三层时钟:
| 层 | 周期 | 作用 |
|---|---|---|
| 日节奏 | 24 小时 | 决定玩家一天内的行为分布,压出早晚双峰 |
| 周节奏 | 7 天 | 决定一周内的负载起伏,把大活动压到周末 |
| 赛季节奏 | 几十天 | 决定一个赛季分成几个阶段,每个阶段体验目标不同 |
三层时钟互相嵌套:日节奏叠在周节奏里,周节奏叠在赛季相位里。设计者最常犯的错,是只画了一条「成长曲线」就以为在管节奏——那只是赛季层的总量,漏掉了日和周两层的行为分布。
日节奏:行为密度曲线与行为锚点
日节奏的核心是一个函数:density(hour)——某个小时内玩家的行为密度(这里用任意单位,只关心相对形状)。它由两部分相加:
- 基础曲线:玩家自发的作息,凌晨最低、早高峰一个包、午间一个小包、晚间一个大包;
- 行为锚点:被系统定点触发的行为——行动力恢复重置、日常任务刷新、联盟活动开始。锚点会在对应的小时上叠加一个尖峰。
把这两部分写进配置,日节奏就成了可计算的量。下面的 S 层脚本读 config/ch03_rhythm.json,算出 24 小时密度曲线、峰均比,并一并给出周节奏与赛季节奏的汇总,最后画出 out/ch03_rhythm.svg。
# file: sim/ch03_rhythm.py
# tier: S
# verify: python3 sim/ch03_rhythm.py --config config/ --out out/
"""日/周/赛季三级节奏:把一天内的行为密度建成 24 小时曲线并叠加行为锚点。
产出:out/ch03_rhythm.json + out/ch03_rhythm_daily.csv + out/ch03_rhythm.svg
"""
from __future__ import annotations
import argparse, csv, json, os, xml.etree.ElementTree as ET
def load(cfg_dir):
with open(os.path.join(cfg_dir, "ch03_rhythm.json"), encoding="utf-8") as f:
return json.load(f)
def daily_density(cfg):
dens = list(cfg["daily"]["baseWeights"])
anchors = {}
for a in cfg["daily"]["anchors"]:
dens[a["hour"]] += a["weight"]
anchors[a["hour"]] = a["name"]
return dens, anchors
def weekly_peak(cfg):
days = cfg["weekly"]["days"]
total = sum(d["weight"] for d in days)
peak = max(days, key=lambda d: d["weight"])
mean = total // len(days)
return {"peakDow": peak["dow"], "peakName": peak["name"], "peakWeight": peak["weight"],
"meanWeight": mean, "peakToAvgPct": peak["weight"] * 100 // mean}
def season_total(cfg):
phases = cfg["season"]["phases"]
acc, bounds = 0, []
for p in phases:
acc += p["days"]; bounds.append({"id": p["id"], "name": p["name"], "startDay": acc - p["days"] + 1, "endDay": acc})
return {"totalDays": acc, "phases": bounds}
def draw_svg(path, dens, anchors):
W, H = 720, 360
PL, PR, PT, PB = 52, 20, 30, 42
hi = max(dens)
iw, ih = W - PL - PR, H - PT - PB
pts = [(PL + iw * i / (len(dens) - 1), PT + ih * (1 - v / hi)) for i, v in enumerate(dens)]
svg = ET.Element("svg", {"xmlns": "http://www.w3.org/2000/svg",
"viewBox": f"0 0 {W} {H}", "width": str(W), "height": str(H)})
ET.SubElement(svg, "rect", {"x": "0", "y": "0", "width": str(W), "height": str(H), "fill": "#ffffff"})
ET.SubElement(svg, "text", {"x": str(PL), "y": "20", "font-size": "14", "fill": "#111"}).text = \
"《王朝纪元》一天内的行为密度(24 小时 / 晚间双峰)"
ET.SubElement(svg, "line", {"x1": str(PL), "y1": str(PT), "x2": str(PL), "y2": str(H - PB), "stroke": "#333"})
ET.SubElement(svg, "line", {"x1": str(PL), "y1": str(H - PB), "x2": str(W - PR), "y2": str(H - PB), "stroke": "#333"})
ET.SubElement(svg, "polyline", {"points": " ".join(f"{x:.1f},{y:.1f}" for x, y in pts),
"fill": "none", "stroke": "#1f6feb", "stroke-width": "2"})
for h in range(0, 24, 4):
x = PL + iw * h / 23
ET.SubElement(svg, "text", {"x": f"{x:.1f}", "y": str(H - PB + 16), "font-size": "11",
"text-anchor": "middle", "fill": "#555"}).text = f"{h}:00"
for h, name in anchors.items():
x, y = pts[h]
ET.SubElement(svg, "circle", {"cx": f"{x:.1f}", "cy": f"{y:.1f}", "r": "4", "fill": "#d9480f"})
ET.SubElement(svg, "text", {"x": f"{x:.1f}", "y": f"{y - 8:.1f}", "font-size": "10",
"text-anchor": "middle", "fill": "#d9480f"}).text = name
os.makedirs(os.path.dirname(path), exist_ok=True)
ET.ElementTree(svg).write(path, encoding="utf-8", xml_declaration=True)
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--config", default="config/")
ap.add_argument("--out", default="out/")
args = ap.parse_args()
cfg = load(args.config)
dens, anchors = daily_density(cfg)
total = sum(dens)
avg = total // len(dens)
peak = max(dens)
peak_hour = dens.index(peak)
report = {
"version": 1, "hours": len(dens), "total": total, "avg": avg,
"peak": peak, "peakHour": peak_hour, "peakToAvgPct": peak * 100 // avg,
"anchors": [{"hour": h, "name": n} for h, n in sorted(anchors.items())],
"weekly": weekly_peak(cfg), "season": season_total(cfg),
}
os.makedirs(args.out, exist_ok=True)
with open(os.path.join(args.out, "ch03_rhythm.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2)
with open(os.path.join(args.out, "ch03_rhythm_daily.csv"), "w", encoding="utf-8", newline="") as f:
w = csv.writer(f); w.writerow(["hour", "density"])
for h, v in enumerate(dens):
w.writerow([h, v])
draw_svg(os.path.join(args.out, "ch03_rhythm.svg"), dens, anchors)
print(json.dumps(report, ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())
在本机跑它,输出(同机相对量级,绝对数值不可移植):
{"version": 1, "hours": 24, "total": 380, "avg": 15, "peak": 40, "peakHour": 20,
"peakToAvgPct": 266, "anchors": [{"hour": 0, "name": "行动力恢复重置"}, ...]}
密度总和 380,平均每小时 15,峰值 40 出现在 20:00(晚间联盟活动锚点),峰均比 266%。也就是说,晚八点这一个小时的行为密度是全天平均的近 2.7 倍。四个锚点分别压在 0:00(行动力重置)、8:00(早间集结)、12:00(日常刷新)、20:00(联盟晚高峰)——这就是曲线的「骨架」。
周节奏:把大负载压到周末
周节奏的模型更简单:给周一到周日各一个权重,权重高的日子承载大活动。脚本算出《王朝纪元》的峰值日是周六(权重 125),周均 103,周峰均比 121%。
这个 121% 是刻意的:日节奏的峰均比高达 266%,但周节奏只到 121%——因为日节奏负责制造「每天的爽点」,周节奏只负责「周末更热闹一点」。如果周节奏也做到 2 倍,玩家会觉得「平时没事干、周末累死」,一周内的工作日就会被拉空。
赛季节奏:相位划分
赛季节奏把几十天切成几个相位(phase),每个相位的体验目标不同。脚本从配置里读出《王朝纪元》的赛季结构,共 70 天,四个相位:
| 相位 | 天数 | 起止 |
|---|---|---|
| 开荒期 | 7 | 第 1–7 天 |
| 争夺期 | 42 | 第 8–49 天 |
| 决战期 | 14 | 第 50–63 天 |
| 结算期 | 7 | 第 64–70 天 |
注意相位是不平均的:争夺期占了 42 天(60%),因为它是玩家真正投入的阶段;开荒与结算各只有 7 天,一头一尾。相位划分的关键是让每个相位有明确的行为主题——开荒期是「抢地」,争夺期是「扩张」,决战期是「决战」,结算期是「清点」。
B 基准:把设计曲线和均匀基线对照
日节奏的峰均比 266% 是不是合理?光看一个数字没有意义,要有一个基准。最朴素的基准是均匀基线——每个小时密度都等于平均值,峰均比恰好 100%。设计曲线的峰均比越高,说明行为越集中。
下面这段是 B 层基准脚本:它读同一份配置,算出峰均比,和配置里的合理带 [180, 280] 对照,给出软告警(不硬失败,exit 恒为 0)。B 层的定位是「给一个可对照的稳态参照,超出就提醒,但不拦构建」。
# file: sim/ch03_rhythm_baseline.py
# tier: B
# verify: python3 sim/ch03_rhythm_baseline.py --config config/ --out out/
"""B 基准:把设计出的日行为密度与「均匀基线」对照,算峰均比与偏离,按目标带给软告警。
均匀基线 = 每个小时的密度都等于平均值(峰均比 100%)。软告警不硬失败:exit 恒为 0。
产出:out/ch03_rhythm_baseline.json
"""
from __future__ import annotations
import argparse, json, os
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--config", default="config/")
ap.add_argument("--out", default="out/")
args = ap.parse_args()
with open(os.path.join(args.config, "ch03_rhythm.json"), encoding="utf-8") as f:
cfg = json.load(f)
dens = list(cfg["daily"]["baseWeights"])
for a in cfg["daily"]["anchors"]:
dens[a["hour"]] += a["weight"]
total = sum(dens)
avg = total // len(dens)
peak = max(dens)
peak_to_avg = peak * 100 // avg
lo, hi = cfg["baseline"]["peakToAvgBandPct"]
report = {
"version": 1, "tier": "B", "baseline": "uniform(100%)",
"peakToAvgPct": peak_to_avg, "bandPct": [lo, hi],
"deviationFromUniformPct": peak_to_avg - 100,
"withinBand": lo <= peak_to_avg <= hi,
"warnings": [] if lo <= peak_to_avg <= hi else
[f"峰均比 {peak_to_avg}% 落在合理带 [{lo},{hi}] 之外"],
}
os.makedirs(args.out, exist_ok=True)
with open(os.path.join(args.out, "ch03_rhythm_baseline.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2)
print(json.dumps(report, ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())
跑出来的结果:peakToAvgPct=266,相对均匀基线偏离 +166,落在合理带 [180, 280] 内,warnings=[]。这意味着《王朝纪元》的日节奏既没有平得像白开水(那是 100%),也没有尖得离谱。如果哪天策划把晚八点的锚点权重从 12 加到 40,峰均比会蹿到 300% 以上,这条软告警就会亮——提醒你服务器峰值可能要重新压测。
P 层:原型侧也把三层节奏算一遍
节奏指标同样不该只活在 Python 里。原型层用同一份 config/ch03_rhythm.json 把总量、峰均比、周峰值、赛季天数再算一遍——两边一致,「唯一真源」才成立。下面这段是 P 层原型。
// file: proto/src/ch03_rhythm.ts
// tier: P
// verify: node --test 'proto/**/*.test.ts'
// 与 sim/ch03_rhythm.py 同构:读同一份 config/ch03_rhythm.json,算日/周/赛季三级节奏的整数指标。
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
const HERE = dirname(fileURLToPath(import.meta.url));
const CONFIG_DIR = join(HERE, "..", "..", "config");
interface Anchor { hour: number; name: string; weight: number }
interface DayWeight { dow: number; name: string; weight: number }
interface Phase { id: string; name: string; days: number }
interface RhythmConfig {
daily: { hours: number; baseWeights: number[]; anchors: Anchor[] };
weekly: { days: DayWeight[] };
season: { phases: Phase[] };
}
export function loadRhythmConfig(): RhythmConfig {
return JSON.parse(readFileSync(join(CONFIG_DIR, "ch03_rhythm.json"), "utf-8")) as RhythmConfig;
}
export function dailyDensity(cfg: RhythmConfig): number[] {
const dens = cfg.daily.baseWeights.slice();
for (const a of cfg.daily.anchors) dens[a.hour] += a.weight;
return dens;
}
export function rhythmReport() {
const cfg = loadRhythmConfig();
const dens = dailyDensity(cfg);
const total = dens.reduce((a, b) => a + b, 0);
const avg = Math.floor(total / dens.length);
const peak = Math.max(...dens);
const peakHour = dens.indexOf(peak);
const days = cfg.weekly.days;
const wmean = Math.floor(days.reduce((a, d) => a + d.weight, 0) / days.length);
const wpeak = days.reduce((m, d) => (d.weight > m.weight ? d : m));
return {
hours: dens.length, total, avg, peak, peakHour, peakToAvgPct: Math.floor((peak * 100) / avg),
weeklyPeakDow: wpeak.dow, weeklyPeakToAvgPct: Math.floor((wpeak.weight * 100) / wmean),
seasonDays: cfg.season.phases.reduce((a, p) => a + p.days, 0),
};
}
跑 node --test proto/test/ch03.test.ts,本节相关断言:total=380、avg=15、peak=40、peakHour=20、peakToAvgPct=266、weeklyPeakDow=6、weeklyPeakToAvgPct=121、seasonDays=70——与 Python 侧逐位一致。
三级节奏怎么对齐
三层时钟不是各算各的,而是嵌套对齐的:
- 日节奏决定「几点开活动」——联盟战、世界 boss 都压在晚间锚点上;
- 周节奏决定「哪天开大活动」——赛季结算、跨服战压在周末峰值日;
- 赛季节奏决定「这个赛季做什么」——开荒期玩家密度最高但单次行为最短,争夺期行为最重,结算期行为最轻(清点为主)。
对齐之后,玩家会感到一个稳定的节拍:每天晚上有事做,每周有高潮,每赛季有始有终。
这里还有一个容易被忽略的推论:三层节奏的「密度」不能同向拉满。日节奏已经在晚八点堆出了一个 2.7 倍的尖峰,如果周节奏再在周六堆 2 倍、赛季节奏又在决战期堆 2 倍,三个尖峰一叠加,那一天的瞬时负载就是平日的 8 倍——服务器扛不住,玩家也会累。所以成熟的 SLG 往往让日节奏尖、周节奏平、赛季节奏缓,把强度摊在不同尺度上,而不是每一层都往死里压。下一节我们看这个节拍里的目标——玩家在这条时间线上追的到底是什么。
阅读导航:上一节:资源循环与行为闭环 · 下一节:长线目标与追求感 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。