上一节把单个活动做成了「模板 + 预算」。这一节把尺度放大一档:赛季——它不是一个大活动,而是一串活动套在一段时间轴上,中间隔着节奏、衰减和阶梯。边界仍与本章一致:赛季的奖励发放上限沿用 12.1 的预算口径,具体买量、留存、变现不在本节展开(那是第 1–8 章)。要先说明与站内的分工:小游戏服务平台专题 那 25 篇讲的是怎么搭小游戏平台,H5 游戏产业全景 那类产业综述讲的是大盘;本节始终只回答一款游戏的问题——这八周的赛季,节奏该怎么排。
赛季为什么需要「结构」
如果赛季只是「把所有活动堆在一起」,会立刻出两个问题。第一,用户没有节奏感:今天有赛、明天还有赛,活动变成背景噪声,参与率反而下滑。第二,团队没有产能感:内容一条接一条地压上来,排期崩掉。所以赛季必须结构化——先切成几个阶段,每个阶段给一个明确的活跃目标,中间用衰减曲线把「自然回落」显式写出来,再用奖励阶梯把「爬到哪一档」定量。
《合成小岛》的第一赛季是八周,切成三段:**开局冲刺(第 1–2 周)**趁新鲜感把指数顶到峰值、**中期维持(第 3–6 周)**靠日常活动托住、季末冲刺(第 7–8 周)用一波加成把已经衰减的曲线重新拉起来。三段之间不是简单拼接,而是同一条衰减曲线上的三段目标——这才是「节奏」的含义。
三个阶段与一条衰减曲线
节奏的数学形式很简单,但容易做错。每周指数不是拍脑袋定的,而是由三样东西乘出来:基准指数、当前阶段的目标系数、已经过掉几周的自然衰减:
weeklyIndex[w] = base × 阶段目标 // 1000 × max(0, 1000 - 衰减 × (w-1)) // 1000
三个阶段的数字不是随便拍的,各自对应一个意图:
- 开局冲刺(W1–W2,目标 1100‰):比赛季均值高 10%,趁新鲜感把指数顶到峰值。
- 中期维持(W3–W6,目标 950‰):低于均值,承认自然回落,靠日常活动托底。
- 季末冲刺(W7–W8,目标 1200‰):三档最高,把已经衰减的曲线拉出一个明显的回勾。
衰减项 max(0, 1000 - 衰减 × (w-1)) 是关键:它让每一周都比上一周低,哪怕阶段目标不变。这是对真实用户疲劳的建模——同样的活动,第 6 周的效果不可能和第 1 周一样。如果不用衰减项,模型会给出「中期维持阶段指数和开局一样」的假象,排期就会按错误的乐观值去做。
季末冲刺再叠加一个 sprintBoostPermille。它不是一个新阶段目标,而是在衰减后的曲线上直接加一段——这样冲刺的绝对效果会随赛季进行而变小(衰减越大,加成占的比重越明显),符合「越晚的冲刺越贵」。
阶梯奖励:达成率决定预算
赛季奖励不是人人都有,而是按赛季积分分档:达到 500 分给 50 币、1500 分给 200 币、3000 分给 600 币、6000 分给 1500 币。听起来很像 12.1 的奖励档位,但有一个本质区别——档位不是「覆盖比例」,而是「积分阈值」。覆盖比例是运营拍的,积分阈值则取决于玩家的积分分布:分布定下来,每一档的达成率就定下来了,进而奖励总发放也定下来了。
所以这一节必须有一份玩家积分分布。用种子化 LCG 生成 20000 名玩家、均值 3800 分、均匀噪声 ±2600 分的积分(无 Math.random,可复现),就能算出每档的达成率与赛季奖励总发放。这个数一算出来,阶梯该定多高就不再是拍脑袋——如果最高档达成率是 0,说明门槛虚设;如果最低档 100%、最高档也很高,说明预算要爆。
季间衔接:别让空窗期吃掉一切
赛季和赛季之间有一段空窗(offSeasonDays)。空窗期没有赛季目标、没有阶梯,活跃会额外掉一截。模型里用一个 offSeasonDecayPermille 刻画:期末指数 = 末周指数 × (1000 - 空窗衰减) // 1000。这个数决定了下个赛季开局要补多大的缺口——空窗衰减设 250‰,意味着新赛季开局是上赛季末的 75%,运营得靠开局冲刺把这段缺口补回来。
D 层:赛季结构与节奏配置
下面这份是 D 层(静态层)配置:phases 是三个阶段(各带目标指数)、weeklyDecayPermille 是每周自然衰减、sprintBoostPermille 是季末冲刺加成、ladder 是奖励阶梯、playerPoints 是玩家积分分布、offSeasonDecayPermille 是季间衔接衰减。全整数,禁浮点。
{
"version": 1,
"series": "《小游戏开发实战》",
"volume": "卷三《上线变现与长线运营》",
"game": "merge-island",
"observedAt": "2026-10",
"seed": 20261012,
"note": "赛季结构与节奏配置(D 层)。season 声明赛季周数、阶段划分(各带 targetIndexPermille)、每周自然衰减 weeklyDecayPermille、季末冲刺加成 sprintBoostPermille、赛季奖励阶梯 ladder 与季间衔接 offSeasonDecayPermille。playerPoints 用种子化 LCG 生成 players 名玩家的积分分布(均值 ± 均匀噪声)算阶梯达成率。全整数,禁浮点;无 Math.random,随机数由 seed 决定。各指数 / 均值 / 噪声均为示例假设值,真实数值需真实业务环境,命令行不可复现(X)。",
"economy": { "baseDau": 12000 },
"season": {
"id": "s1", "name": "第一赛季·珊瑚季", "weeks": 8,
"baseWeeklyIndexPermille": 1000, "weeklyDecayPermille": 60, "sprintBoostPermille": 350,
"phases": [
{ "id": "opening", "name": "开局冲刺", "weekStart": 1, "weekEnd": 2, "targetIndexPermille": 1100 },
{ "id": "mid", "name": "中期维持", "weekStart": 3, "weekEnd": 6, "targetIndexPermille": 950 },
{ "id": "sprint", "name": "季末冲刺", "weekStart": 7, "weekEnd": 8, "targetIndexPermille": 1200 }
],
"ladder": [
{ "thresholdPoints": 500, "rewardCoins": 50 },
{ "thresholdPoints": 1500, "rewardCoins": 200 },
{ "thresholdPoints": 3000, "rewardCoins": 600 },
{ "thresholdPoints": 6000, "rewardCoins": 1500 }
],
"players": 20000,
"playerPoints": { "meanPoints": 3800, "spreadPoints": 2600 },
"offSeasonDays": 3, "offSeasonDecayPermille": 250
}
}
W 层:赛季节奏看板
要把「衰减 + 阶段目标 + 冲刺加成」这三样东西的相互影响变成能上手拨的直觉,最好的办法是做成本地打开就能拖的看板。这段逻辑的 P 层内核是 core/src/l12/season.ts(周活跃指数 + 种子化 LCG 阶梯达成率,与 S 层 Python 脚本 sim/l12_season.py 逐位一致,由 core/test/l12_season.test.ts 锁住)。下面这段是 W 层的构建脚本,把它打包成单文件 HTML。
#!/usr/bin/env node
// file: web/build_l12_season.mjs
// tier: W
// verify: node web/build_l12_season.mjs
// 把 core/src/l12/season.ts 打成单文件 HTML(赛季节奏看板)。复用 pack.mjs 的擦除逻辑。
import { readFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { bundle, page, writePage, inlineConfig } from "./pack.mjs";
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const cfgRaw = JSON.parse(readFileSync(join(ROOT, "config", "l12_season.json"), "utf8"));
const cfg = { seed: cfgRaw.seed, season: cfgRaw.season };
const js = bundle(ROOT, ["core/src/l12/season.ts"]);
const script = `const cfg = ${inlineConfig(cfg)};
${js}
const season = cfg.season, seed = cfg.seed;
const cv = document.getElementById("stage"), g = cv.getContext("2d");
const COLORS = { opening: "#2563eb", mid: "#10b981", sprint: "#f59e0b" };
let state = { weeklyDecayPermille: season.weeklyDecayPermille, sprintBoostPermille: season.sprintBoostPermille };
let d = null;
function compute() {
const s = { ...season, weeklyDecayPermille: state.weeklyDecayPermille, sprintBoostPermille: state.sprintBoostPermille };
const wi = weeklySeries(s);
const ladder = ladderRows(season, samplePoints(season, seed));
d = { wi, ladder, total: totalRewardCoins(season, samplePoints(season, seed)),
off: Math.floor(wi[wi.length - 1] * (1000 - season.offSeasonDecayPermille) / 1000) };
draw();
}
function draw() {
const W = cv.width, wi = d.wi, maxv = Math.max(...wi, 1), bw = (W - 40) / wi.length;
g.clearRect(0, 0, W, cv.height);
g.font = "12px system-ui";
g.fillStyle = "#e2e8f0"; g.fillText("周活跃指数(基准 1000)", 8, 16);
for (let i = 0; i < wi.length; i += 1) {
const h = Math.floor(150 * wi[i] / maxv), x = 20 + i * bw;
g.fillStyle = COLORS[phaseOf(season, i + 1).id] ?? "#6b7280";
g.fillRect(x, 150 - h, bw - 6, h);
g.fillStyle = "#94a3b8"; g.fillText("W" + (i + 1), x + 2, 166);
g.fillStyle = "#64748b"; g.fillText(String(wi[i]), x + 2, 150 - h - 4);
}
g.fillStyle = "#e2e8f0"; g.fillText("阶梯达成率‰", 8, 196);
d.ladder.forEach((r, i) => { g.fillStyle = "#38bdf8"; g.fillText("≥" + r.thresholdPoints + ":" + r.attainmentPermille + "‰", 8 + i * 118, 214); });
document.getElementById("last").textContent = String(wi[wi.length - 1]);
document.getElementById("off").textContent = String(d.off);
document.getElementById("total").textContent = String(d.total);
document.getElementById("p3").textContent = d.ladder[2].attainmentPermille + "‰";
}
const api = {
get() { return { weeklyIndex: d.wi.slice(), attainment: d.ladder.map((r) => r.attainmentPermille),
totalRewardCoins: d.total, offSeasonIndexPermille: d.off, canvasDrawn: true }; },
set(x) { Object.assign(state, x); compute(); return { weeklyIndex: d.wi.slice(), offSeasonIndexPermille: d.off }; },
};
compute();
globalThis.__pebble = { canvasDrawn: true, api };
`;
const body = ` <h1>《合成小岛》· 赛季节奏看板</h1>
<div class="sub">core/src/l12/season.ts:周活跃指数 + 种子化 LCG 阶梯达成率,无 Math.random,与 S 层 Python 逐位一致</div>
<div class="card row"><div><div class="k">末周指数</div><div class="big" id="last">0</div></div><div><div class="k">季间期末指数</div><div class="big" id="off">0</div></div><div><div class="k">奖励总发放</div><div class="big" id="total">0</div></div><div><div class="k">3000 分达成率</div><div class="big" id="p3">-</div></div></div>
<div class="card"><canvas id="stage" width="480" height="240"></canvas></div>
<div class="sub">蓝=开局冲刺 绿=中期维持 橙=季末冲刺(冲刺加成把衰减曲线拉回峰值以上)</div>`;
const bytes = writePage(join(ROOT, "web/dist/l12.html"), page({ title: "《合成小岛》赛季节奏看板 · l12", body, script, css: "#stage { width: 480px; height: 240px; }" }));
console.log(JSON.stringify({ html: "web/dist/l12.html", htmlBytes: bytes, bundleLines: js.split("\n").length }));
单文件 HTML 的产出要靠 Playwright 无头验证:读回默认指数曲线与阶梯达成率,再把衰减调大、冲刺加成归零,确认「曲线确实被拉平」。下面这段是 W 层验证脚本。
#!/usr/bin/env node
// file: web/verify_l12_season.mjs
// tier: W
// verify: NODE_PATH=/opt/homebrew/lib/node_modules node web/verify_l12_season.mjs web/dist/l12.html
// W 层验收:Playwright 无头打开单文件 HTML,调 api 改衰减/冲刺参数,读回断言 + 截图。
// playwright 用 createRequire 加载——NODE_PATH 对 ESM 的 import 无效。
import { createRequire } from "node:module";
import { pathToFileURL, fileURLToPath } from "node:url";
import { dirname, join, resolve } from "node:path";
import { mkdirSync } from "node:fs";
import assert from "node:assert/strict";
const { chromium } = createRequire(import.meta.url)("playwright");
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const url = pathToFileURL(resolve(ROOT, process.argv[2] ?? "web/dist/l12.html")).href;
mkdirSync(join(ROOT, "out/shots"), { recursive: true });
const browser = await chromium.launch();
const warm = await browser.newPage(); await warm.goto(url); await warm.close(); // 预热:冷启动首个 page 读 canvas 像素恒为空白
const errors = [];
const p = await browser.newPage({ viewport: { width: 800, height: 760 } });
p.on("pageerror", (e) => errors.push(String(e)));
await p.goto(url);
await p.waitForFunction(() => globalThis.__pebble?.canvasDrawn === true, null, { timeout: 10000 });
const base = await p.evaluate(() => globalThis.__pebble.api.get());
const domLast = (await p.textContent("#last")).trim();
const domOff = (await p.textContent("#off")).trim();
const domTotal = (await p.textContent("#total")).trim();
const domP3 = (await p.textContent("#p3")).trim();
const flat = await p.evaluate(() => globalThis.__pebble.api.set({ weeklyDecayPermille: 120, sprintBoostPermille: 0 }));
const painted = await p.evaluate(() => {
const d = document.getElementById("stage").getContext("2d").getImageData(0, 0, 480, 240).data;
let n = 0; for (let i = 0; i < d.length; i += 4) if (d[i + 3] > 0 && d[i] < 250) n += 1; return n;
});
await p.screenshot({ path: join(ROOT, "out/shots/l12_season.png"), fullPage: true });
await p.close();
const p2 = await browser.newPage();
await p2.goto(url);
await p2.waitForFunction(() => globalThis.__pebble?.canvasDrawn === true, null, { timeout: 10000 });
const rerun = await p2.evaluate(() => globalThis.__pebble.api.get());
await p2.close();
await browser.close();
assert.equal(errors.length, 0, `页面错误: ${errors.join("; ")}`);
assert.deepEqual(base.weeklyIndex, [1100, 1034, 836, 779, 722, 665, 1118, 1046]);
assert.deepEqual(base.attainment, [1000, 941, 654, 78]);
assert.equal(base.totalRewardCoins, 14956200);
assert.equal(base.offSeasonIndexPermille, 784);
assert.equal(flat.weeklyIndex[6], 336); assert.equal(flat.weeklyIndex[7], 192);
assert.equal(flat.offSeasonIndexPermille, 144);
assert.equal(domLast, "1046"); assert.equal(domOff, "784"); assert.equal(domTotal, "14956200");
assert.equal(domP3, "654‰");
assert.ok(painted > 500, `canvas 绘制 ${painted}px`);
assert.deepEqual(rerun.weeklyIndex, base.weeklyIndex, "同一次加载应可复现");
console.log(JSON.stringify({
page: "web/dist/l12.html", base, flat, domLast, domOff, domTotal, domP3,
canvasPaintedPx: painted, rerunMatches: true, screenshot: "out/shots/l12_season.png",
}));
console.log("SMOKE-W12: PASS");
在本机跑它(示例参数),输出如下(同机相对量级,绝对数值不可移植):
{"page":"web/dist/l12.html","base":{"weeklyIndex":[1100,1034,836,779,722,665,1118,1046],"attainment":[1000,941,654,78],"totalRewardCoins":14956200,"offSeasonIndexPermille":784,"canvasDrawn":true},"flat":{"weeklyIndex":[1100,968,722,608,494,380,336,192],"offSeasonIndexPermille":144},"domLast":"1046","domOff":"784","domTotal":"14956200","domP3":"654‰","canvasPaintedPx":36716,"rerunMatches":true,"screenshot":"out/shots/l12_season.png"}
SMOKE-W12: PASS
读这条曲线
衰减项让中期必然下滑,冲刺项再把它拉回来。八周指数是 1100 → 1034 → 836 → 779 → 722 → 665 → 1118 → 1046。前两周开局最高(1100),随后自然衰减一路降到第 6 周的 665,季末冲刺把第 7 周直接顶到 1118——比开局的 1100 还高。这就是「节奏」的量化含义:不是每周都高,而是有落有起,季末有一个明显的回勾。如果去掉冲刺加成(把 sprintBoostPermille 调 0、衰减调到 120),曲线会变成 1100 → 968 → 722 → 608 → 494 → 380 → 336 → 192,单调下滑到底——赛季后半段完全没有第二次高峰,用户看不到「值得回来」的理由。
阶梯达成率把预算问题提前暴露。四档达成率是 1000 / 941 / 654 / 78(‰)。最低档 500 分人人可达(1000‰),这没问题——它是保底;第二档 1500 分覆盖 94.1%,第三档 3000 分覆盖 65.4%,都还算健康;最高档 6000 分只有 78‰(约 1562 人)。这个数说明 6000 分的门槛定得偏高了:只有 7.8% 的人能摸到,绝大多数玩家会觉得这一档「与我无关」。要么把门槛降到 5000 分让覆盖回到 15% 左右,要么把这一档的奖励提到足够诱人、让它变成少数人的炫耀目标——两条路都行,但必须知道 78‰ 这个数再决定,而不是凭感觉。
季间空窗是隐形的第二段衰减。末周指数 1046,空窗衰减 250‰ 后,新赛季开局只有 784——掉了四分之一。这意味着新赛季的开局冲刺(1100)其实是从 784 补起的,而不是从 1046 续上。很多赛季运营的失误就出在这里:只看赛季内曲线很漂亮,忘了两个赛季之间还有一段没人管的空窗。缩短空窗(offSeasonDays 从 3 调到 1)或给空窗期安排轻量的过渡活动,都能把这个缺口压小。
小结与下一步
这一节把赛季做成了「阶段 + 衰减 + 阶梯 + 衔接」四件套:三段目标各自定义峰值,每周衰减显式建模疲劳,季末冲刺在衰减曲线上叠加回勾,奖励阶梯用达成率反推预算,季间空窗单独算一段衰减。三件事记住就够——**没有衰减项,模型会高估中期;阶梯门槛要靠达成率验证,78‰ 就是虚设;空窗期会吃掉四分之一活跃,别只看赛季内曲线。**下一节把视野再放大一档:多个赛季、多个节日、多个版本怎么在一张内容日历上排开、不撞车、不超产能。
阅读导航:上一节:活动模板与奖励预算 · 下一节:节点运营与内容日历 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。