8.2 进度感与「再来一局」

把「差一点」量化成千分比:从每局目标与进度判定,到近失驱动的「再来一局」给出概率与接受概率,用可播种内核写成能被 node --test 钉住的不变量,并让 Node 测试与 Chromium 原型跑出逐位一致的数字,文末附可直接打开的单文件 HTML 原型与无头验证脚本。

8.2 把 8.1 的成长曲线收进「一次会话」里:玩家打完一局,凭什么还愿意点下一局?通用的放置经济框架——离线时长、产能、衰减、封顶——在《游戏经济与数值平衡》 一文里已经讲透;本节不重复它,而是给出小游戏一次会话内、可运行可断言的进度与续玩模型:同一份 config/ch08_progress.json,Node 里跑内核、Chromium 里跑原型,两边数字逐位一致。

从「差一点」到「再来一局」

进度感不是「进度条越长越好」,而是**「差一点」的时刻密度**。本节的模型把每局进度量化成千分比(progressPermille),把「进度 ≥ 850‰ 且未达标」定义为「差一点」(isNearMiss),并让这个状态同时抬高「再来一局」的给出概率与接受概率(offerPermille / acceptPermille)——这就是近失驱动的续玩机制,也是 8.3 离线收益能接住玩家的前提。

P 层:进度与「再来一局」内核。 下面这段 TypeScript 是内核:可播种 LCG(core/ 禁 Math.random())、BigInt 幂、整数地板除,与 8.1 的成长曲线共享同一套「整数比递推」口径。

// file: core/src/ch08/progress.ts
// tier: P
// verify: node --test 'core/test/ch08_*.test.ts'
// 《合成小岛》进度感与「再来一局」内核:可播种 LCG、BigInt 幂、整数地板除,禁 enum / namespace。

export interface ProgressConfig {
  readonly seed: number; readonly demoSeed: number;
  readonly goalBase: number; readonly goalNum: number; readonly goalDen: number;
  readonly skillBasePermille: number; readonly skillJitterPermille: number;
  readonly maxRuns: number; readonly nearMissPermille: number;
  readonly offerBasePermille: number; readonly offerNearPermille: number;
  readonly acceptBasePermille: number; readonly acceptNearPermille: number;
  readonly trials: number; readonly rngMultiplier: number; readonly rngIncrement: number;
}
export interface Rng { next(): number; state(): number }
export interface SessionStat {
  readonly runs: number; readonly nearMisses: number; readonly cleared: number;
  readonly offerRolls: number; readonly offers: number; readonly accepted: number;
}
export interface BatchStat {
  readonly trials: number; readonly avgRunsTimes100: number; readonly avgNearMissTimes100: number;
  readonly offerRatePermille: number; readonly acceptRatePermille: number;
  readonly maxRunsReached: number; readonly histogram: number[]; readonly counterfactualAvgRunsTimes100: number;
}
export function createRng(seed: number, cfg: ProgressConfig): Rng {
  let s = seed >>> 0;
  return { next() { s = (Math.imul(s, cfg.rngMultiplier) + cfg.rngIncrement) >>> 0; return s; }, state() { return s; } };
}
// 第 run 局目标:goalBase * num^(run-1) // den^(run-1)(BigInt 幂,禁浮点)。
export function goalForRun(run: number, cfg: ProgressConfig): number {
  if (run < 1) return 0;
  const n = BigInt(cfg.goalNum) ** BigInt(run - 1), d = BigInt(cfg.goalDen) ** BigInt(run - 1);
  return Number((BigInt(cfg.goalBase) * n) / d);
}
// 进度千分比 gain/goal,上限 1000(达标封顶)。
export function progressPermille(gain: number, goal: number): number {
  if (goal <= 0) return 0;
  const pm = Math.floor((gain * 1000) / goal);
  return pm > 1000 ? 1000 : pm;
}
// 「差一点」落在 [nearMissPermille, 1000);1000 是达标不算。isCleared 反之。
export function isNearMiss(pm: number, cfg: ProgressConfig): boolean { return pm >= cfg.nearMissPermille && pm < 1000; }
export function isCleared(pm: number): boolean { return pm >= 1000; }
// 「再来一局」给出 / 接受概率:近失或达标时更高,这是近失驱动的续玩机制。
export function offerPermille(pm: number, cfg: ProgressConfig): number {
  return pm >= cfg.nearMissPermille ? cfg.offerNearPermille : cfg.offerBasePermille;
}
export function acceptPermille(pm: number, cfg: ProgressConfig): number {
  return pm >= cfg.nearMissPermille ? cfg.acceptNearPermille : cfg.acceptBasePermille;
}
export function rollPermille(rng: Rng, probPermille: number): boolean { return rng.next() % 1000 < probPermille; }
export function skillPermille(rng: Rng, cfg: ProgressConfig): number {
  return cfg.skillBasePermille - cfg.skillJitterPermille + (rng.next() % (2 * cfg.skillJitterPermille + 1));
}
// 一次会话:一局一局打,靠近失驱动的「再来一局」决定续玩。同 seed 同结果。
export function simulateSession(seed: number, cfg: ProgressConfig): SessionStat {
  const rng = createRng(seed, cfg);
  let runs = 0, nearMisses = 0, cleared = 0, offerRolls = 0, offers = 0, accepted = 0;
  for (let run = 1; run <= cfg.maxRuns; run += 1) {
    runs = run;
    const goal = goalForRun(run, cfg);
    const pm = progressPermille(Math.floor((goal * skillPermille(rng, cfg)) / 1000), goal);
    if (isCleared(pm)) cleared += 1; else if (isNearMiss(pm, cfg)) nearMisses += 1;
    if (run >= cfg.maxRuns) break;
    offerRolls += 1;
    if (!rollPermille(rng, offerPermille(pm, cfg))) break;
    offers += 1;
    if (!rollPermille(rng, acceptPermille(pm, cfg))) break;
    accepted += 1;
  }
  return { runs, nearMisses, cleared, offerRolls, offers, accepted };
}
// 一批会话的统计,外加「关掉近失加成」的对照(counterfactual)。
export function simulateBatch(cfg: ProgressConfig): BatchStat {
  const n = cfg.trials, hist = new Array(cfg.maxRuns + 1).fill(0);
  let runs = 0, near = 0, offers = 0, accepted = 0, rolls = 0, capped = 0;
  for (let i = 0; i < n; i += 1) {
    const s = simulateSession(cfg.seed + i, cfg);
    runs += s.runs; near += s.nearMisses; offers += s.offers; accepted += s.accepted; rolls += s.offerRolls;
    if (s.runs >= cfg.maxRuns) capped += 1;
    hist[s.runs] += 1;
  }
  const flat = { ...cfg, offerNearPermille: cfg.offerBasePermille, acceptNearPermille: cfg.acceptBasePermille };
  let flatRuns = 0;
  for (let i = 0; i < n; i += 1) flatRuns += simulateSession(flat.seed + i, flat).runs;
  return { trials: n, avgRunsTimes100: Math.floor((runs * 100) / n), avgNearMissTimes100: Math.floor((near * 100) / n),
    offerRatePermille: rolls ? Math.floor((offers * 1000) / rolls) : 0,
    acceptRatePermille: offers ? Math.floor((accepted * 1000) / offers) : 0, maxRunsReached: capped, histogram: hist,
    counterfactualAvgRunsTimes100: Math.floor((flatRuns * 100) / n) };
}

P 层:把不变量写成断言。 内核的关键边界——差一点 / 达标互斥、近失阈值跳档、同 seed 复现——都要能被 node --test 钉住。

// file: core/test/ch08_progress.test.ts
// tier: P
// verify: node --test 'core/test/ch08_*.test.ts'
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import {
  createRng, goalForRun, progressPermille, isNearMiss, isCleared,
  offerPermille, acceptPermille, rollPermille, skillPermille, simulateSession, simulateBatch,
} from "../src/ch08/progress.ts";

const HERE = dirname(fileURLToPath(import.meta.url));
const raw = readFileSync(join(HERE, "..", "..", "config", "ch08_progress.json"), "utf-8");
const cfg = JSON.parse(raw) as Parameters<typeof createRng>[1];

test("LCG 可播种 + 掷骰确定性 + 概率边界", () => {
  const rng = createRng(cfg.seed, cfg);
  assert.deepEqual([rng.next(), rng.next(), rng.next()], [1891695331, 1516053222, 1468488973]);
  const a = createRng(cfg.seed, cfg), b = createRng(cfg.seed, cfg);
  assert.deepEqual([rollPermille(a, 500), rollPermille(a, 500)], [rollPermille(b, 500), rollPermille(b, 500)]);
  assert.equal(rollPermille(a, 0), false);    // 概率 0 永不命中
  assert.equal(rollPermille(a, 1000), true);  // 概率 1000 必命中
});
test("目标按整数比 130/100 递增(BigInt 幂)+ 进度千分比封顶 + 边界互斥", () => {
  assert.deepEqual([1, 2, 3, 4, 5, 6, 7, 8].map((r) => goalForRun(r, cfg)),
    [1000, 1300, 1690, 2197, 2856, 3712, 4826, 6274]);
  assert.equal(progressPermille(850, 1000), 850);
  assert.equal(progressPermille(1200, 1000), 1000);
  assert.equal(progressPermille(5, 0), 0);
  assert.equal(isNearMiss(849, cfg), false);
  assert.equal(isNearMiss(850, cfg), true);
  assert.equal(isNearMiss(1000, cfg), false); // 1000 是达标,不算差一点
  assert.equal(isCleared(1000), true);
  assert.equal(offerPermille(849, cfg), cfg.offerBasePermille);
  assert.equal(offerPermille(850, cfg), cfg.offerNearPermille);
  assert.equal(acceptPermille(900, cfg), cfg.acceptNearPermille);
});
test("本局表现落在 base ± jitter 区间内,且会话确定性可复现", () => {
  const rng = createRng(cfg.seed, cfg);
  for (let i = 0; i < 200; i += 1) {
    const s = skillPermille(rng, cfg);
    assert.ok(s >= cfg.skillBasePermille - cfg.skillJitterPermille);
    assert.ok(s <= cfg.skillBasePermille + cfg.skillJitterPermille);
  }
  assert.deepEqual(simulateSession(cfg.seed, cfg),
    { runs: 1, nearMisses: 0, cleared: 0, offerRolls: 1, offers: 1, accepted: 0 });
  assert.deepEqual(simulateSession(cfg.seed + 1, cfg),
    { runs: 1, nearMisses: 0, cleared: 1, offerRolls: 1, offers: 0, accepted: 0 });
  assert.deepEqual(simulateSession(cfg.seed + 7, cfg), simulateSession(cfg.seed + 7, cfg));
});
test("批量统计 + 近失加成拉长会话(同机相对量级,不作绝对倍数)", () => {
  const b = simulateBatch(cfg);
  assert.equal(b.trials, 2000);
  assert.equal(b.avgRunsTimes100, 225);         // 平均 2.25 局/会话
  assert.equal(b.avgNearMissTimes100, 140);     // 平均 1.40 次差一点
  assert.equal(b.offerRatePermille, 728);       // 72.8% 的判定会给出「再来一局」
  assert.equal(b.acceptRatePermille, 769);      // 76.9% 的给出被接受
  assert.equal(b.maxRunsReached, 36);           // 36/2000 打满 8 局上限
  assert.equal(b.counterfactualAvgRunsTimes100, 104);  // 关掉加成只有 1.04 局
  assert.ok(b.avgRunsTimes100 > b.counterfactualAvgRunsTimes100);
});

W 层:打包成单文件 HTML。 把内核内联进一个浏览器可打开的页面,复用 web/pack.mjs 的擦除器与外壳,产物 web/dist/ch08.html。

#!/usr/bin/env node
// file: web/build_ch08.mjs
// tier: W
// verify: node web/build_ch08.mjs
// 把 core/src/ch08/progress.ts 的进度与「再来一局」内核打成单文件 HTML 原型。产物 web/dist/ch08.html。
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 config = JSON.parse(readFileSync(join(ROOT, "config", "ch08_progress.json"), "utf8"));
const js = bundle(ROOT, ["core/src/ch08/progress.ts"]);

const script = `const cfg = ${inlineConfig(config)};
/* ---- core bundle: TS -> strip -> inline ---- */
${js}
/* ---- end bundle ---- */
const cv = document.getElementById("timeline"), g = cv.getContext("2d");
const COLORS = { cleared: "#22c55e", near: "#eab308", far: "#64748b" };
let rng = createRng(cfg.demoSeed, cfg), run = 0, nearMisses = 0, cleared = 0, offerRolls = 0;
let offers = 0, accepted = 0, goal = 0, pm = 0, done = false, hist = [];
const set = (id, v) => { document.getElementById(id).textContent = String(v); };
function draw() {
  const W = cv.width; g.clearRect(0, 0, W, cv.height);
  g.fillStyle = "#1e293b"; g.fillRect(20, 30, W - 40, 34);           // 当前局进度条
  g.fillStyle = pm >= 1000 ? COLORS.cleared : (pm >= cfg.nearMissPermille ? COLORS.near : "#38bdf8");
  g.fillRect(20, 30, Math.floor(((W - 40) * Math.min(pm, 1000)) / 1000), 34);
  g.fillStyle = "#e2e8f0"; g.font = "bold 15px system-ui"; g.textAlign = "center"; g.textBaseline = "middle"; g.fillText(pm + " / 1000", W / 2, 47);
  const slot = (W - 40) / Math.max(cfg.maxRuns, hist.length, 1);     // 时间线:每局一根柱
  for (let i = 0; i < hist.length; i += 1) {
    const h = Math.max(6, Math.floor((140 * hist[i].pm) / 1000)); g.fillStyle = COLORS[hist[i].oc];
    g.fillRect(20 + slot * i + slot * 0.15, 200 - h, slot * 0.7, h);
  }
  for (const [id, v] of [["run", run], ["goal", goal], ["pm", pm], ["near", nearMisses], ["cleared", cleared], ["offers", offers], ["accepted", accepted]]) set(id, v);
  set("state", done ? "会话结束" : (run === 0 ? "未开始" : "进行中"));
  globalThis.__pebble = { run, runs: run, goal, pm, nearMisses, cleared, offerRolls, offers, accepted, done,
    rngState: rng.state(), timeline: hist.slice(), api, canvasDrawn: true };
}
// 与 core 的 simulateSession 同序:算进度 → 判定是否给出「再来一局」→ 判定是否接受。
function step() {
  if (done || run >= cfg.maxRuns) { done = true; draw(); return null; }
  run += 1; goal = goalForRun(run, cfg);
  const gain = Math.floor((goal * skillPermille(rng, cfg)) / 1000); pm = progressPermille(gain, goal);
  const oc = isCleared(pm) ? "cleared" : (isNearMiss(pm, cfg) ? "near" : "far");
  if (oc === "cleared") cleared += 1; else if (oc === "near") nearMisses += 1;
  hist.push({ pm, oc });
  if (run >= cfg.maxRuns) { done = true; draw(); return { pm, oc }; }
  offerRolls += 1;
  const offered = rollPermille(rng, offerPermille(pm, cfg)); let acc = false;
  if (offered) { offers += 1; acc = rollPermille(rng, acceptPermille(pm, cfg)); if (acc) accepted += 1; }
  if (!offered || !acc) done = true;
  draw(); return { pm, oc, offered, accepted: acc };
}
const api = {
  step,
  auto() { let n = 0; while (!done && n <= cfg.maxRuns + 1) { step(); n += 1; } return { runs: run, offers, accepted }; },
  reset() { rng = createRng(cfg.demoSeed, cfg); run = 0; nearMisses = 0; cleared = 0; offerRolls = 0; offers = 0; accepted = 0; goal = 0; pm = 0; done = false; hist = []; draw(); },
};
document.getElementById("step").addEventListener("click", () => step());
document.getElementById("auto").addEventListener("click", () => api.auto());
document.getElementById("reset").addEventListener("click", () => api.reset());
draw();
`;
const body = `  <h1>《合成小岛》· 进度感与「再来一局」</h1>
  <div class="sub">内核由 core/src/ch08/progress.ts 提供:可播种、无 Math.random</div>
  <div class="card row"><div><div class="k">当前局</div><div class="big" id="run">0</div></div><div><div class="k">目标</div><div class="big" id="goal">0</div></div><div><div class="k">进度(‰)</div><div class="big" id="pm">0</div></div><div><div class="k">差一点</div><div class="big" id="near">0</div></div><div><div class="k">达标</div><div class="big" id="cleared">0</div></div></div>
  <div class="card"><canvas id="timeline" width="720" height="240"></canvas></div>
  <div class="card row"><div><div class="k">给出</div><div class="big" id="offers">0</div></div><div><div class="k">被接受</div><div class="big" id="accepted">0</div></div><div><div class="k">会话状态</div><div class="big" id="state">未开始</div></div></div>
  <div class="card row"><button id="step">跑一局</button><button id="auto">自动会话</button><button id="reset">重置</button></div>`;
const bytes = writePage(join(ROOT, "web/dist/ch08.html"), page({ title: "《合成小岛》进度感与「再来一局」原型 · ch08", css: "#timeline { height: auto; }", body, script }));
console.log(JSON.stringify({ html: "web/dist/ch08.html", htmlBytes: bytes, bundleLines: js.split("\n").length }, null, 2));

W 层:无头验证。 用 Playwright 真实点击「自动会话」按钮,采样 globalThis.__pebble、读 DOM、检查 canvas 真的画了东西、跑两遍验确定性、截图存档。

#!/usr/bin/env node
// file: web/verify_ch08.mjs
// tier: W
// verify: NODE_PATH=/opt/homebrew/lib/node_modules node web/verify_ch08.mjs web/dist/ch08.html
// W 层验收:Playwright 无头打开单文件 HTML,点真实按钮 + 调暴露 api,采样状态、读 DOM、
// 检查 canvas 真画了东西、跑两遍验确定性、截图。
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/ch08.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 open = async () => {
  const p = await browser.newPage({ viewport: { width: 800, height: 900 } });
  p.on("pageerror", (e) => errors.push(String(e)));
  await p.goto(url);
  await p.waitForFunction(() => globalThis.__pebble?.canvasDrawn === true, null, { timeout: 10000 });
  return p;
};

// 第一遍:真实点击「自动会话」按钮,采样状态并截图
const page = await open();
await page.click("#auto");
await page.waitForFunction(() => globalThis.__pebble?.done === true, null, { timeout: 10000 });
const snap = await page.evaluate(() => {
  const s = globalThis.__pebble, cv = document.getElementById("timeline");
  const d = cv.getContext("2d").getImageData(0, 0, cv.width, cv.height).data;
  let painted = 0;
  for (let i = 0; i < d.length; i += 4) if (d[i + 3] > 0) painted += 1;
  return { runs: s.runs, nearMisses: s.nearMisses, cleared: s.cleared, offers: s.offers, accepted: s.accepted,
    goal: s.goal, pm: s.pm, rngState: s.rngState, timeline: s.timeline.length, painted };
});
const dom = await page.evaluate(() => ({ run: document.getElementById("run").textContent,
  offers: document.getElementById("offers").textContent, state: document.getElementById("state").textContent }));
await page.screenshot({ path: join(ROOT, "out/shots/ch08_prototype.png"), fullPage: true });
await page.close();

// 第二遍:全新加载,调用暴露的 api 再跑一次,验确定性
const page2 = await open();
const again = await page2.evaluate(() => {
  globalThis.__pebble.api.auto();
  const s = globalThis.__pebble;
  return { runs: s.runs, offers: s.offers, accepted: s.accepted, rngState: s.rngState };
});
await page2.close();
await browser.close();

assert.equal(errors.length, 0, `页面错误: ${errors.join("; ")}`);
assert.equal(snap.runs, 7, "演示会话应为 7 局");
assert.equal(snap.nearMisses, 5, "演示会话应有 5 次「差一点」");
assert.equal(snap.cleared, 2, "演示会话应有 2 次达标");
assert.equal(snap.offers, 7, "演示会话应给出 7 次「再来一局」");
assert.equal(snap.accepted, 6, "演示会话应有 6 次被接受");
assert.equal(dom.state, "会话结束", "DOM 状态应为「会话结束」");
assert.ok(snap.painted > 500, `canvas 应有绘制内容,实测 ${snap.painted} px`);
assert.equal(again.runs, snap.runs, "同 seed 重跑局数应一致");
assert.equal(again.rngState, snap.rngState, "同 seed 重跑 RNG 状态应一致");

console.log(JSON.stringify({ page: "web/dist/ch08.html", runs: snap.runs, nearMisses: snap.nearMisses,
  cleared: snap.cleared, offers: snap.offers, accepted: snap.accepted, goal: snap.goal, pm: snap.pm,
  domRun: dom.run, domState: dom.state, rngState: snap.rngState, canvasPaintedPx: snap.painted,
  deterministicRerun: { runs: again.runs, offers: again.offers, accepted: again.accepted },
  screenshot: "out/shots/ch08_prototype.png" }, null, 2));
console.log("SMOKE-W8: PASS");

跑出来(同机相对量级,绝对数值不可移植):演示会话 7 局、5 次「差一点」、2 次达标,给出 7 次「再来一局」、被接受 6 次,rngState=4049735997,canvas 绘制 79301 px,第二遍全新加载重跑数字逐位一致,SMOKE-W8: PASS。同一份配置的 2000 次批量仿真则给出平均 2.25 局/会话、给出率 72.8%、接受率 76.9%,而关掉近失加成的对照只有平均 1.04 局——同机相对量级,说明近失确实是「再来一局」的主要驱动之一。8.1 的成长线加上本节的进度与续玩内核构成本章底座,8.3 再把它接上「离线收益」。

阅读导航:上一节:成长线与数值曲线 · 下一节:离线收益与回归设计 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

  1. 附录 F:全书公式、参数与数据集索引
  2. 9.3 同意管理与数据出境
  3. 9.2 COPPA 与儿童向游戏的限制