10.1 放置的机制解剖

把放置的五个机制字段——自动产出、离线收益、升级成本、里程碑、转生——各绑到 core/genre/idle.ts 里一段确定性代码:增长曲线用整数递推与 BigInt 闭式两种口径,规格骨架与 play_spec.json 同构、只覆盖 rules / params 两段;W 层把五格画进单文件 HTML 并真玩,P 层逐格断言 15 条全绿。

10.1 放置的机制解剖

《小游戏开发实战》的成长曲线 讲的是产品口径的曲线设计:什么时候给玩家一次提升、曲线该陡还是该缓,读得到结论但跑不出数字。本节是机制口径:把「自动产出」「离线收益」「升级成本」「里程碑」「转生」写成整数可执行、可断言的规格字段,一行渲染代码都不写。本节后半的字段表也是 10.2 相变扫描与 10.3 动因代理指标的地基。

《灰烬牌库》是这套三卷本的贯穿案例。卷三要回答的是「同一份规格换成别的品类还成不成立」,所以本章不新造规格:照搬 config/play_spec.json 的六段骨架,新建 core/genre/idle.ts,只 import 共享底座的 rng.ts 与 digest.ts,只覆盖 rules / params 两段——同一份规格,另一个品类。放置与前几个变体有一处根本不同:它的增长是乘法的,所以增长曲线必须用整数幂或整数递推表达;一用浮点,跨语言对照立刻崩。

机制不是名词,是字段

「自动产出」「转生」这些词在策划案里到处都是,但词本身不可断言。可断言的形态是字段:一个字段 = 一段确定性的代码 + 一条可检验的性质。把放置拆开,恰好五个字段:

机制字段内核位置语义可断言的性质W 层证据
自动产出idleRate + idleRun 的逐 tick 循环每 tick 产出 = 基础速率 × 拥有数 × 里程碑乘子 × 转生乘子,逐级整数地板除owned=0 时产出恒为 0;里程碑命中后产出严格不降画布下方的黄色产出柱
离线收益idleRun 尾部的 offlineGold窗口 = min(offlineTicks, offlineCapTicks),再按 offlinePermille 打折效率置 0 或窗口置 0 时离线收益归 0底部「离线收益 = …」文字
升级成本idleCostRec / idleCostClosed第 n 个生产者的价格;递推每步上取整,闭式走 BigInt 幂成本单调不减;第 1 项等于 baseCost画布上方的双色成本柱
里程碑rules.milestones + 登记段拥有数越过 at 时乘子永久生效,并登记进 milestoneLog每个门槛只登记一次;milestonesHit <= milestoneTotal「里程碑 3/7 [45,75,115]」
转生idleRun 的 prestige 分支累计产出够 threshold 就烧掉手上金币、清空产出、保留 keepOwned 个生产者转生后金币守恒式仍成立;keepOwned=0 会死锁「转生 3 次」

这张表就是「机制解剖」的全部产出:五个字段,五行可断言的性质。10.2 在这张表的第三行上做文章(成本曲线的递推 / 闭式口径与整数溢出边界),10.3 在第四、五行上做文章(里程碑间隔与转生时机的代理指标)。

内核 core/genre/idle.ts 只 import 共享底座的 rng.ts 与 digest.ts,其余全是放置自己的整数算术。摘出四个字段各自的实现(snippet,非全文):

// core/genre/idle.ts(节选:整数地板除 / 两种成本口径 / 产出 / 转生)
/** 整数地板除:JS 的 `/` 是浮点除,凡派生量一律走这里,跨语言才逐位一致。 */
export function idiv(a: number, b: number): number { return Math.floor(a / b); }

/** 递推成本:c(1)=baseCost;c(n)=ceil(c(n-1)*g/1000)。全程 Number,无中间溢出但有舍入漂移。 */
export function idleCostRec(spec: IdleSpec, n: number): number {
  const g = spec.rules.producer.costGrowthPermille;
  let c = spec.rules.producer.baseCost;
  for (let i = 2; i <= n; i += 1) c = idiv(c * g + 999, 1000);
  return c;
}

/** 闭式成本:ceil(baseCost * g^(n-1) / 1000^(n-1))。幂走 BigInt,中间量再大也不会溢出。 */
export function idleCostClosed(spec: IdleSpec, n: number): number {
  if (n <= 1) return spec.rules.producer.baseCost;
  const g = BigInt(spec.rules.producer.costGrowthPermille);
  const den = 1000n ** BigInt(n - 1);
  const num = BigInt(spec.rules.producer.baseCost) * g ** BigInt(n - 1);
  return Number((num + den - 1n) / den);
}

/** 单位产出(千分比速率):baseRate × 拥有数 × 里程碑乘子 × 转生乘子,逐级整数地板除。 */
export function idleRate(spec: IdleSpec, owned: number, prestige: number): number {
  const r = spec.rules;
  let mult = 1000;
  for (const m of r.milestones) if (owned >= m.at) mult = idiv(mult * m.multPermille, 1000);
  mult = idiv(mult * (1000 + r.prestige.bonusPermille * prestige), 1000);
  return idiv(idiv(r.producer.baseRate * owned, 1000) * mult, 1000);
}

// 转生分支(idleRun 内,压行摘录):烧掉手上金币、清空已产出,但保留 keepOwned 个生产者
} else if (o.op === "prestige") {
  const pts = idiv(produced, r.prestige.threshold);
  if (pts > 0) { prestige += pts; burned += gold; owned = r.prestige.keepOwned; gold = 0; produced = 0; }
}

规格骨架与 play_spec.json 同构

放置变体的规格不是另起炉灶,而是把贯穿案例的六段骨架原样搬过来,只重写 rules / params:

  • id / spec_version / name / seed:标识与随机源,seed=10101;
  • naming:把共享摘要契约的八个键映射到品类词——turn→tick、energy→gold、playerHp→prestige、enemyHp→remainingMilestones、block→milestonesHit、played→purchases、hand→milestoneLog、cardCount→milestoneTotal;
  • state:{gold: 10, owned: 0, prestige: 0}——10 金币刚好够买第一个生产者,这是放置品类唯一的「手动点击」替代品;
  • rules:producer(baseCost / costGrowthPermille / baseRate)/ milestones / prestige / jitterPermille / autoBuyEvery;
  • params:ticks / offlineTicks / offlineCapTicks / offlinePermille;
  • invariant:goldConserved / gold>=0 / spent>=0 / lifetime>=0 / milestonesHit<=milestoneTotal / purchases==buyTicks.length。

naming 那一段是关键:它让放置状态能投影回共享的 12 键摘要契约(idleCommonState),于是同一个 digestSnap 能摘要另一个品类。金币守恒式也值得单说:在手 + 花掉 + 转生烧掉 == 初始 + 累计产出。转生会把手上金币烧成 0,所以守恒式里必须留一个 burned 项——少了它,转生一发生守恒就假失败。

W 层:把五个字段画出来

web/build_proto10.mjs 复用 web/pack.mjs 的 bundle(),把 rng.ts / digest.ts / idle.ts 三个模块擦类型后内联进单文件 out/proto10.html,再把五个字段画到 canvas 上:双色成本柱(浅蓝 = 递推、绿 = 闭式,前 12 项并排对照)、逐段累加的产出柱、里程碑与转生计数、离线收益文字。页面暴露 globalThis.__ashdeck,供 Playwright 取回 digest。

// file: web/build_proto10.mjs
// tier: W
// verify: NODE_PATH=/opt/homebrew/lib/node_modules node web/build_proto10.mjs
//
// 卷三 10.1 放置的机制解剖(W 层):把品类内核 core/genre/idle.ts 与共享底座
// rng.ts / digest.ts 按手工拓扑序擦类型后,连同 config/gd_proto_10_idle.json 一起内联进
// **一个** HTML(out/proto10.html)。页面把五个机制字段画出来——自动产出曲线(逐 tick 金币)、
// 升级成本曲线(递推 vs 闭式两条)、里程碑门槛、转生次数、离线收益条——并暴露 globalThis.__ashdeck:
//   run(seq?) -> 品类摘要(与 P 层测试冻结的那条逐字相同);snapshot() -> 最近快照;
//   curve() -> 递推与闭式成本的前 12 项;canvasDrawn。
// 构建期顺手在 Node 侧跑一遍内核,把 nodeDigest 落进 out/proto10_build.json 作为「P 侧」证据。
// 擦类型复用 web/pack.mjs 的 bundle(),本章不重写任何擦除逻辑。

import { bundle, page, writePage, inlineConfig } from "./pack.mjs";
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import { idleRun, idleDigest, idleCostRec, idleCostClosed } from "../core/genre/idle.ts";

const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, ".."); // $HOME/.gdbook/ashdeck

// 手工拓扑序:先声明后使用。去掉跨文件 import 后同一作用域内不得重名。
const MODULES = [
  "core/src/ashdeck/rng.ts",
  "core/src/ashdeck/digest.ts",
  "core/genre/idle.ts",
];

const spec = JSON.parse(readFileSync(join(ROOT, "config/gd_proto_10_idle.json"), "utf8"));
const core = bundle(ROOT, MODULES);
const OUT = join(ROOT, "out", "proto10.html");

const script = core + `

const SPEC = ` + inlineConfig(spec) + `;
const cv = document.getElementById("cv");
const ctx = cv.getContext("2d");
let snap = null;

// 逐 tick 重放一遍,只为了拿「金币随时间」这条自动产出曲线(画图用,不改内核)。
function goldSeries(s) {
  const out = [];
  for (let t = 1; t <= SPEC.params.ticks; t += 10) out.push(s.buyTicks.filter((b) => b <= t).length);
  return out;
}

// 升级成本曲线:递推(每步上取整,无溢出但有舍入漂移)与闭式(BigInt 幂,精确)。
function costCurve(n) {
  const rec = [], closed = [];
  for (let i = 1; i <= n; i += 1) { rec.push(idleCostRec(SPEC, i)); closed.push(idleCostClosed(SPEC, i)); }
  return { rec, closed };
}

function bar(x, y, w, h, num, den, color) {
  ctx.fillStyle = "#334155";
  ctx.fillRect(x, y, w, h);
  ctx.fillStyle = color;
  ctx.fillRect(x, y, Math.floor((w * num) / Math.max(1, den)), h);
}

// 五个机制字段的画法:产出曲线、成本曲线(两条)、里程碑门槛、转生、离线收益条。
function render(s) {
  const W = cv.width, H = cv.height, r = SPEC.rules, p = SPEC.params;
  ctx.fillStyle = "#111827";
  ctx.fillRect(0, 0, W, H);
  ctx.font = "13px system-ui, sans-serif";
  ctx.fillStyle = "#94a3b8";
  ctx.fillText("升级成本(前 12 项):递推=浅蓝 / 闭式=绿", 16, 20);
  const cc = costCurve(12), maxC = Math.max(...cc.closed);
  for (let i = 0; i < 12; i += 1) {
    const x = 16 + i * 48, h = Math.max(1, Math.floor((140 * cc.closed[i]) / maxC));
    ctx.fillStyle = "#38bdf8";
    ctx.fillRect(x, 176 - h, 18, h);
    ctx.fillStyle = "#34d399";
    ctx.fillRect(x + 20, 176 - Math.max(1, Math.floor((140 * cc.rec[i]) / maxC)), 18,
      Math.max(1, Math.floor((140 * cc.rec[i]) / maxC)));
    ctx.fillStyle = "#64748b";
    ctx.fillText(String(i + 1), x + 12, 190);
  }
  const series = goldSeries(s), maxS = Math.max(1, ...series);
  ctx.fillStyle = "#94a3b8";
  ctx.fillText("自动产出(每 10 tick 累计购买数,峰值 " + maxS + ")", 16, 216);
  for (let i = 0; i < series.length; i += 1) {
    const x = 16 + i * 4, h = Math.max(1, Math.floor((70 * series[i]) / maxS));
    ctx.fillStyle = "#fbbf24";
    ctx.fillRect(x, 290 - h, 3, h);
  }
  ctx.fillStyle = "#e2e8f0";
  ctx.fillText("tick " + s.tick + " · 拥有生产者 " + s.owned + " · 购买 " + s.purchases +
    " · 转生 " + s.prestige + " 次", 16, 312);
  ctx.fillText("里程碑 " + s.milestonesHit + "/" + s.milestoneTotal + " " +
    JSON.stringify(s.milestoneLog) + " · 手上金币 " + s.gold, 16, 332);
  ctx.fillText("离线收益(窗口 " + Math.min(p.offlineTicks, p.offlineCapTicks) + " tick," +
    p.offlinePermille + "‰ 效率)=" + s.offlineGold, 16, 352);
  document.getElementById("out").textContent = idleDigest(s);
  document.getElementById("cost").textContent = "递推 " + cc.rec[11] + " / 闭式 " + cc.closed[11];
}

globalThis.__ashdeck = {
  spec: SPEC,
  run(seq) {
    const sp = JSON.parse(JSON.stringify(SPEC));
    snap = idleRun(sp, Array.isArray(seq) ? seq : undefined);
    render(snap);
    return idleDigest(snap);
  },
  snapshot() { return snap; },
  curve() { const c = costCurve(12); return { rec: c.rec, closed: c.closed }; },
  canvasDrawn: false,
};

globalThis.__ashdeck.run();
// 冷启动首个 page 的 canvas 在首帧合成前读像素恒为空白;推迟到两次 rAF 之后再置真,
// 让通用验证器的 waitForFunction 等到「已合成一帧」而不是「已调用 draw」。
requestAnimationFrame(() => requestAnimationFrame(() => {
  globalThis.__ashdeck.canvasDrawn = true;
}));
`;

const body = `<h1>《灰烬牌库》· 放置变体(第 10 章)</h1>
<div class="sub">同一份规格骨架,只覆盖 rules / params 两段:自动产出、离线收益、升级成本、里程碑、转生</div>
<canvas id="cv" width="640" height="380"></canvas>
<div class="card"><div class="k">品类摘要 digest(与 P 层测试冻结的那条逐字相同)</div><div id="out" class="k">—</div></div>
<div class="card"><div class="k">第 12 项升级成本 · 递推 / 闭式</div><div class="big" id="cost">—</div></div>`;

const html = page({
  title: "《灰烬牌库》· 第 10.1 节放置的机制解剖",
  subtitle: "品类内核 + 共享底座:读同一份骨架,产出同一条摘要",
  body,
  script,
});
const bytes = writePage(OUT, html);

const nodeDigest = idleDigest(idleRun(spec));
mkdirSync(join(ROOT, "out"), { recursive: true });
writeFileSync(join(ROOT, "out", "proto10_build.json"),
  JSON.stringify({
    spec: spec.id, modules: MODULES.length, kernelChars: core.length,
    page: "out/proto10.html", pageBytes: bytes,
    costRec12: idleCostRec(spec, 12), costClosed12: idleCostClosed(spec, 12), nodeDigest,
  }, null, 2) + "\n");

console.log("modules=" + MODULES.length + " kernelChars=" + core.length);
console.log("built " + OUT + "  " + bytes + " B");
console.log("NODE_DIGEST=" + nodeDigest);

构建与真玩分两步跑——先构建、再断言,只跑断言等于在验上一版页面:

# snippet: 构建 + 无头真玩
NODE_PATH=/opt/homebrew/lib/node_modules node web/build_proto10.mjs
NODE_PATH=/opt/homebrew/lib/node_modules node web/verify_mech.mjs --page out/proto10.html --shot out/shots/proto10.png
modules=3 kernelChars=6940
built /Users/bingrong.yan/.gdbook/ashdeck/out/proto10.html  14500 B
NODE_DIGEST={"block":3,"cardCount":7,"damageConserved":true,"dealt":324619,"enemyHp":4,"energy":64807,"hand":["m45","m75","m115"],"over":true,"played":279,"playerHp":3,"turn":800,"won":false}
=== 通用断言 ===
  ok   两遍独立加载 digest 逐字一致(确定性)
  ok   canvas 已绘制(243200 px >= 1)
  ok   截图已落盘(92824 B)
PAGE_DIGEST={"block":3,"cardCount":7,"damageConserved":true,"dealt":324619,"enemyHp":4,"energy":64807,"hand":["m45","m75","m115"],"over":true,"played":279,"playerHp":3,"turn":800,"won":false}
OK W 层通过

三条断言全绿。canvas 已绘制(243200 px) 是 640×380 画布的全部像素,断言只写「≥ 1 px」——冷启动首个 page 读 canvas 恒为空白,canvasDrawn 被推迟到两次 requestAnimationFrame 之后才置真,靠这一手绕开。PAGE_DIGEST 与构建期 Node 侧算出的 NODE_DIGEST 一字不差:页面与内核本来就是同一段源码的两个宿主。

P 层:逐字段断言

W 层把字段画出来,P 层把字段断言掉。断言方式是「改一格,摘要必须变」——改不动,就说明那一格是文档装饰而不是机制。五格里每一格都拆成 2~3 个子参数,逐格验:

// snippet: core/test/gd_proto_10_1_anatomy.test.ts(节选:五格机制对账)
const BASE_DIGEST =
  '{"block":3,"cardCount":7,"damageConserved":true,"dealt":324619,"enemyHp":4,"energy":64807,' +
  '"hand":["m45","m75","m115"],"over":true,"played":279,"playerHp":3,"turn":800,"won":false}';

const mutate = (fn: (s: any) => void) => {
  const s = JSON.parse(JSON.stringify(BASE));
  fn(s);
  return idleDigest(idleRun(s));
};

test("自动产出是机制:速率、购买节奏、抖动三格都改变摘要", () => {
  assert.notEqual(mutate((s) => { s.rules.producer.baseRate = 750; }), BASE_DIGEST);
  assert.notEqual(mutate((s) => { s.rules.autoBuyEvery = 4; }), BASE_DIGEST);
  assert.notEqual(mutate((s) => { s.rules.jitterPermille = 0; }), BASE_DIGEST);
});

test("离线收益是机制:效率、窗口上限、窗口长度三格都改变摘要", () => {
  assert.notEqual(mutate((s) => { s.params.offlinePermille = 0; }), BASE_DIGEST);
  assert.notEqual(mutate((s) => { s.params.offlineCapTicks = 0; }), BASE_DIGEST);
  assert.notEqual(mutate((s) => { s.params.offlineTicks = 0; }), BASE_DIGEST);
});

test("转生是机制:阈值、加成、保留生产者三格都改变摘要", () => {
  assert.notEqual(mutate((s) => { s.rules.prestige.threshold = 1000; }), BASE_DIGEST);
  assert.notEqual(mutate((s) => { s.rules.prestige.bonusPermille = 0; }), BASE_DIGEST);
  // keepOwned = 0 会让转生后 owned 归零、产出恒为 0,从此永远买不起下一个——这是死锁,
  // 摘要必然变;内核默认保留 1 个生产者正是为了绕开它。
  assert.notEqual(mutate((s) => { s.rules.prestige.keepOwned = 0; }), BASE_DIGEST);
});
✔ 基线摘要冻结:与实跑逐字一致 (2.300875ms)
✔ 确定性:同规格同种子两次运行摘要相同 (4.250917ms)
✔ 不变量在基线、空策略、提前转生三种策略下都成立 (2.306875ms)
✔ 金币守恒:在手 + 花掉 + 转生烧掉 == 初始 + 累计产出(四种策略各验一次) (3.428959ms)
✔ 自动产出是机制:速率、购买节奏、抖动三格都改变摘要 (2.153333ms)
✔ 离线收益是机制:效率、窗口上限、窗口长度三格都改变摘要 (2.620583ms)
✔ 升级成本是机制:增长率与基准价两格都改变摘要 (1.444167ms)
✔ 里程碑是机制:门槛、乘子、数量三格都改变摘要 (3.243416ms)
✔ 转生是机制:阈值、加成、保留生产者三格都改变摘要 (2.735125ms)
✔ 初始金币是机制:state.gold 10 -> 200 改变摘要 (1.071791ms)
✔ 品类状态投影回共享的 12 键摘要契约 (1.215792ms)
✔ 里程碑总数守恒:投影里的 cardCount 恒等于里程碑条数 (1.965958ms)
✔ 产出公式逐级整数地板除:owned=0 恒为 0,里程碑命中后严格不降 (0.09175ms)
✔ 成本递推单调不减且第 1 项等于基准价 (0.069084ms)
✔ 规格骨架与 play_spec.json 同构:六段一个不缺 (3.566167ms)
ℹ tests 15
ℹ pass 15
ℹ fail 0

十五条全绿。有一条特别值得记下来:idleRate(45, 0) 的期望值是 80 而不是 81。逐级地板除算的是 floor(floor(1500×45/1000)×1200/1000) = floor(67×1200/1000) = 80,而一次性地板除 floor(1500×45×1200/1000000) = 81。差 1 就是「逐级」的代价——它是故意的:只有逐级地板除才能让 Python 与 TS 在每一步都落在同一个整数上,一次性合并会让中间量在两种语言里各按各的顺序舍入。

本节实跑清单

命令产物实测
node web/build_proto10.mjsout/proto10.html3 模块内联,内核 6940 字符,页面 14500 B
node web/verify_mech.mjs --page out/proto10.html --shot out/shots/proto10.pngout/shots/proto10.png3 断言全绿,截图 92824 B
node --test 'core/test/gd_proto_10_1_anatomy.test.ts'断言结果15 测试全绿,0 失败

五个字段已经拆开并各自可断言。但字段拆开只说明「机制是什么」,不说明「机制跑起来会怎样」——下一节把第三行那根成本曲线拿出来量:递推与闭式到底差多少、整数在哪里溢出、增长率推到哪儿进度会被卡死。

阅读导航:上一节:9.3 自走棋的涌现 · 下一节:10.2 放置的曲线与相变 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gamedesign」更多文章

  1. 附录 A 品类参数表、机制距离矩阵与数据集索引
  2. 9.3 自走棋的涌现
  3. 9.2 自走棋的经济与平衡