10.3 放置的动因

把自我决定理论的三要素映射成三个可算的代理指标——自主看可选升级路径数、胜任看升级成功间隔、关联看里程碑间隔——并在同一份规格上扫五档升级成本增长率(1020 至 1100),验证三个指标同向移动;全文显式声明它们只是 proxy,只做相对比较、禁绝对化、不证明好玩。

10.3 放置的动因

《小游戏开发实战》的成长曲线 讲的是曲线怎么设计、什么时候给一次提升;本节问的是更靠上的一层:玩家为什么愿意继续点下去。这个问题没有可测量,所以本节的做法不是给答案,而是给三个可算的代理指标(proxy)——它们只做相对比较,禁绝对化,也不证明「好玩」。10.1 拆的五个字段里,本节用第四、五行(里程碑与转生)。

先把话说死:本节所有数字都是代理指标。代理指标能回答「把增长率从 1020 调到 1100,三个指标是不是同向移动」,不能回答「这个数值好不好玩」。凡是看到「自主 / 胜任 / 关联」出现在本节,请一律读作「自主 / 胜任 / 关联的代理」。

三要素到三个可算的代理指标

自我决定理论说内在动机靠三样东西:自主(我能选)、胜任(我能做到)、关联(我和什么有联系)。三样都不可直接测量,但可以各自找一个在本内核里真的算得出来的量当代理:

要素代理指标怎么算为什么它算代理局限
自主可选升级路径数9 个候选转生时机各跑一局,数出多少个不同的结局摘要选择只有在「选了会不一样」时才成立,不同结局数就是「选择有分量」的代理结局数受候选数封顶,且不等价于「玩家感知到自由」
胜任升级成功间隔floor(1000 × ticks / purchases),即每 1000 次购买摊到多少 tick每次购买是一次「成功」,间隔越短反馈越密,是「我能做到」的代理间隔短也可能是无聊,不是单调越好
关联里程碑间隔相邻里程碑之间的 tick 数,再取均值里程碑是全品类共享的「事件」,间隔是玩家与这个事件节奏的关系命中里程碑少时样本退化,均值不可比

三个代理指标都在同一份规格上算,只改一个参数:升级成本增长率 costGrowthPermille。这正是 10.2 扫过的那根参数——只不过 10.2 关心它什么时候把进度掐死,本节关心它怎么同时推动三个动因代理。

代理指标怎么算

配置落在 config/gd_proto_10_motivation.json 的 motivation 段。proxyNote 那行不是装饰,测试里会逐字断言它存在——代理指标最怕的是写着写着被当成结论。

"motivation": {
  "proxyNote": "三个指标全是代理指标,只做相对比较,禁绝对化",
  "needs": {
    "autonomy": "可选升级路径数",
    "competence": "升级成功间隔",
    "relatedness": "里程碑间隔"
  },
  "prestigeTimings": [0, 100, 200, 300, 400, 500, 600, 700, 800],
  "sweep": [1020, 1040, 1060, 1080, 1100]
}

「自主」的 9 个候选转生时机是 0, 100, …, 800(0 表示不转生)。每个时机跑一遍,把结局摘要丢进一个集合,集合大小就是路径数。这是本节最像「可玩性」的一格,也最容易误读——它量的是结局的多样性,不是玩家的主观自由感。

结果:三个指标同向移动

// file: core/test/gd_proto_10_3_motivation.test.ts
// tier: P
// verify: node --test 'core/test/gd_proto_10_3_motivation.test.ts'
//
// 卷三 10.3 放置的动因(P 层):把自我决定理论的三要素映射成**可算的代理指标**——
//   自主 = 可选升级路径数(9 个转生时机给出多少个不同结局)
//   胜任 = 升级成功间隔(每多少次 tick 完成一次购买,千分比)
//   关联 = 里程碑间隔(相邻里程碑之间的 tick 数)
// 三个都只是 proxy:它们只做**相对比较**,禁绝对化,也不证明「好玩」。断言只写趋势与相对序。

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 { idleRun, idleDigest } from "../genre/idle.ts";

const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, "..", "..");
const M = JSON.parse(readFileSync(join(ROOT, "config", "gd_proto_10_motivation.json"), "utf8"));
const T: number[] = M.motivation.prestigeTimings;
const SWEEP: number[] = M.motivation.sweep;

const withG = (g: number): any => {
  const sp = JSON.parse(JSON.stringify(M));
  sp.rules.producer.costGrowthPermille = g;
  return sp;
};

/** 自主 proxy:9 个转生时机各跑一遍,数出多少个不同的结局摘要。 */
function autonomy(g: number): number {
  const digs = new Set<string>();
  for (const t of T) {
    const sp = withG(g);
    sp.strategy = t > 0 ? [{ t, op: "prestige" }] : [];
    digs.add(idleDigest(idleRun(sp)));
  }
  return digs.size;
}

/** 胜任 proxy:每 1000 次购买需要多少 tick(越大表示两次「成功」之间等得越久)。 */
function competence(g: number): number {
  const s = idleRun(withG(g));
  return Math.floor((1000 * s.tick) / s.purchases);
}

/** 关联 proxy:相邻里程碑之间的 tick 间隔(先给序列,再取均值)。 */
function relatedness(g: number): { gaps: number[]; mean: number } {
  const tk: number[] = idleRun(withG(g)).milestoneTicks;
  const gaps: number[] = tk.map((v, i) => (i === 0 ? v : v - tk[i - 1]));
  const mean = gaps.length === 0 ? 0 : Math.floor(gaps.reduce((a, b) => a + b, 0) / gaps.length);
  return { gaps, mean };
}

const AUTO = SWEEP.map(autonomy);
const COMP = SWEEP.map(competence);
const REL = SWEEP.map((g) => relatedness(g).mean);

test("代理指标已在配置里显式声明,且三个映射都有名字", () => {
  assert.equal(M.motivation.proxyNote, "三个指标全是代理指标,只做相对比较,禁绝对化");
  assert.deepEqual(Object.keys(M.motivation.needs).sort(), ["autonomy", "competence", "relatedness"]);
  assert.equal(M.motivation.needs.autonomy, "可选升级路径数");
  assert.equal(M.motivation.needs.competence, "升级成功间隔");
  assert.equal(M.motivation.needs.relatedness, "里程碑间隔");
});

test("自主 proxy(可选升级路径数):随增长率单调不增", () => {
  assert.deepEqual(AUTO, [7, 6, 5, 4, 3]);
  assert.equal(T.length, 9);
  for (let i = 0; i + 1 < AUTO.length; i += 1) {
    assert.equal(AUTO[i] >= AUTO[i + 1], true, `g=${SWEEP[i]} -> ${SWEEP[i + 1]} 不应回升`);
  }
  assert.equal(AUTO[0] > AUTO[AUTO.length - 1], true);
});

test("胜任 proxy(升级成功间隔):随增长率单调不减", () => {
  assert.deepEqual(COMP, [2083, 2867, 4571, 10000, 12698]);
  for (let i = 0; i + 1 < COMP.length; i += 1) {
    assert.equal(COMP[i] <= COMP[i + 1], true, `g=${SWEEP[i]} -> ${SWEEP[i + 1]} 不应回落`);
  }
  assert.equal(COMP[COMP.length - 1] > 6 * COMP[0], true);
});

test("关联 proxy(里程碑间隔):可测区间内单调递增,尾部因样本退化而回落", () => {
  assert.deepEqual(REL, [95, 104, 144, 328, 262]);
  for (let i = 0; i < 3; i += 1) {
    assert.equal(REL[i] < REL[i + 1], true, `g=${SWEEP[i]} -> ${SWEEP[i + 1]} 应严格增大`);
  }
  // 尾格只命中 1 个里程碑,间隔序列退化成 1 个数——代理指标在这里不可比,如实报告。
  assert.equal(relatedness(SWEEP[4]).gaps.length, 1);
  assert.equal(REL[4] < REL[3], true);
  assert.equal(relatedness(SWEEP[0]).gaps.length, 5);
});

test("三个 proxy 的原始序列(供正文表格逐格引用)", () => {
  assert.deepEqual(relatedness(SWEEP[0]).gaps, [116, 60, 80, 100, 120]);
  assert.deepEqual(relatedness(SWEEP[1]).gaps, [116, 60, 136]);
  assert.deepEqual(relatedness(SWEEP[2]).gaps, [122, 166]);
  assert.deepEqual(relatedness(SWEEP[3]).gaps, [168, 488]);
  assert.deepEqual(relatedness(SWEEP[4]).gaps, [262]);
});

test("proxy 只做相对比较:同一份规格换增长率,三个指标同向移动", () => {
  // 增长率越大(升级越贵):可选路径越少、两次成功间隔越长、里程碑间隔越大。
  assert.equal(AUTO[0] > AUTO[4], true);
  assert.equal(COMP[0] < COMP[4], true);
  assert.equal(REL[0] < REL[3], true);
  // 反过来把增长率压到 1020 以下,路径数顶到 7 就不再涨了:9 个候选转生时机里
  // 有两个给出同一结局(低增长端已饱和)。上限是候选数,实际天花板更低。
  assert.equal(autonomy(1000), 7);
  assert.equal(autonomy(1010), 7);
  assert.equal(AUTO[0] <= T.length, true);
  assert.equal(AUTO[0] < T.length, true);
});

test("代理指标是确定性的:同 g 跑两遍三个值逐字相同", () => {
  for (const g of SWEEP) {
    assert.equal(autonomy(g), autonomy(g));
    assert.equal(competence(g), competence(g));
    assert.deepEqual(relatedness(g), relatedness(g));
  }
});

console.log("=== 10.3 动因代理指标(禁绝对化,只做相对比较)===");
console.log("  g       自主(路径数/9)  胜任(间隔‰)  关联(平均里程碑间隔)");
for (let i = 0; i < SWEEP.length; i += 1) {
  console.log("  " + String(SWEEP[i]).padEnd(8) + String(AUTO[i]).padEnd(16) +
    String(COMP[i]).padEnd(13) + String(REL[i]));
}
=== 10.3 动因代理指标(禁绝对化,只做相对比较)===
  g       自主(路径数/9)  胜任(间隔‰)  关联(平均里程碑间隔)
  1020    7               2083         95
  1040    6               2867         104
  1060    5               4571         144
  1080    4               10000        328
  1100    3               12698        262
✔ 代理指标已在配置里显式声明,且三个映射都有名字 (0.871625ms)
✔ 自主 proxy(可选升级路径数):随增长率单调不增 (0.095083ms)
✔ 胜任 proxy(升级成功间隔):随增长率单调不减 (0.069959ms)
✔ 关联 proxy(里程碑间隔):可测区间内单调递增,尾部因样本退化而回落 (1.333209ms)
✔ 三个 proxy 的原始序列(供正文表格逐格引用) (2.92ms)
✔ proxy 只做相对比较:同一份规格换增长率,三个指标同向移动 (15.150333ms)
✔ 代理指标是确定性的:同 g 跑两遍三个值逐字相同 (50.663416ms)
ℹ tests 7
ℹ pass 7
ℹ fail 0

七个测试全绿。三条趋势都是单调的,而且方向一致:增长率越大(升级越贵),自主代理从 7 掉到 3、胜任代理从 2083 涨到 12698、关联代理在前四格从 95 涨到 328。也就是说,同一份规格只拧一个旋钮,三个动因代理是同向动的——这是「机制参数 → 动因代理」这条映射链在整数层面成立的证据。

读法与边界

增长率自主(路径数/9)胜任(间隔‰)关联(平均间隔)读法
10207208395选择多、成功密、里程碑挨得紧
1040(基线)62867104略松一档
106054571144中间档
1080410000328选择少、成功稀、里程碑拉得很开
1100312698262关联代理回落,见下

三处必须写明的边界。第一,自主代理的天花板是 7 而不是 9:把增长率压到 1000 甚至 1010,路径数也只到 7 就不再涨——9 个候选转生时机里有两个给出同一结局,低增长端已经饱和。第二,关联代理在末格回落(328 → 262):增长率 1100 时只命中 1 个里程碑,间隔序列退化成单个数,均值不再可比。这不是「关联变好了」,是样本退化。第三,三个指标都是整数,跨语言逐位一致,但逐位一致不等于有效——它们只是 proxy。

要真验证动因,需要真实玩家测试数据(停留时长、主动选择率、流失点分布)与真实测试服;那类证据命令行不可复现,本节不做任何声称。

三个代理的适用范围

  • 可比:同一份规格、同一台机器、同一内核版本下的相对序——本节所有断言都只写这个。
  • 不可比:跨规格、跨游戏、跨玩家的绝对值。2083 不能拿去说「这个游戏比别的难」。
  • 不可外推:低增长端已饱和(路径数封顶 7),末格已退化(间隔序列只剩 1 个数),两端都不能读。

本节实跑清单

命令产物实测
node --test 'core/test/gd_proto_10_3_motivation.test.ts'断言结果7 测试全绿,0 失败
自主代理motivation.prestigeTimings 9 个时机路径数 7 → 3 单调不增
胜任代理ticks / purchases 千分比2083 → 12698 单调不减
关联代理里程碑 tick 间隔均值95 → 328 → 262(尾部退化)

放置品类解剖完毕:字段拆在 10.1、曲线与相变在 10.2、动因代理在 10.3。下一章换一个完全不同的品类——战斗,把伤害、时序、资源写成机制而不是手感实现。

阅读导航:上一节:10.2 放置的曲线与相变 · 下一节:11.1 战斗的机制解剖:伤害、时序、资源 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gamedesign」更多文章

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