3.2 日/周/赛季三级节奏

把 SLG 的节奏拆成日、周、赛季三层时钟:用 24 小时行为密度曲线与行为锚点算出峰均比 266%,用周权重找到峰值日,用赛季相位划开荒到结算共 70 天,并给一个 B 层基准把设计曲线与均匀基线对照,说明为什么日峰均比可以高、周峰均比却要克制。

上一节我们把核心循环画成了一张闭合的有向图。但循环不会自己转——它靠玩家的行为推动,而玩家的行为是有节拍的:早上通勤时点两下,中午吃饭时收个菜,晚上八点联盟集结。节奏设计的任务,就是让循环的节拍和玩家的作息对上。

先划边界。站内的经济平衡长文 把「节奏」理解成成长曲线的时间尺度(放置类里就是离线收益多久到顶)。那套口径关注的是「资源随时间的总量」,本节关注的是行为在时间上的分布——同一批资源,玩家是集中在晚上八点一口气花掉,还是全天均匀地花,对服务器负载、活动排期和留存的影响完全不同。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 往往让日节奏尖、周节奏平、赛季节奏缓,把强度摊在不同尺度上,而不是每一层都往死里压。下一节我们看这个节拍里的目标——玩家在这条时间线上追的到底是什么。

阅读导航:上一节:资源循环与行为闭环 · 下一节:长线目标与追求感 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「slg」更多文章

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