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) | 胜任(间隔‰) | 关联(平均间隔) | 读法 |
|---|---|---|---|---|
| 1020 | 7 | 2083 | 95 | 选择多、成功密、里程碑挨得紧 |
| 1040(基线) | 6 | 2867 | 104 | 略松一档 |
| 1060 | 5 | 4571 | 144 | 中间档 |
| 1080 | 4 | 10000 | 328 | 选择少、成功稀、里程碑拉得很开 |
| 1100 | 3 | 12698 | 262 | 关联代理回落,见下 |
三处必须写明的边界。第一,自主代理的天花板是 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 战斗的机制解剖:伤害、时序、资源 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。