第 1 章把产品定位翻译成了一组设计约束,但那组约束回答的是「我们要做一款什么样的 SLG」,还没有回答「我们到底在为谁做」。这一节开始补上后半句:先有一份能站得住的玩家画像,后面的系统地图、资源循环、付费分层才有落脚点。动机模型就是这份画像的第一块砖。
动手前先划边界。站内已有一个 SLG 技术架构专题 ,那 9 篇讲的是前后端分层、模块拆分与协议设计,明确写了「不讨论玩法数值」;本节讲的正好是它没碰的那一半——产品与数值里的人群建模。站内的经济平衡长文 则给了一套通用的经济平衡框架,但它举例用的是放置类:离线收益、线性挂机。SLG 的玩家不是一直挂着的,他们被动机驱动着上线、出征、结盟,所以本章换用 SLG 大地图的口径,从「谁在玩」讲起。
为什么要先有动机模型,再谈玩法
很多团队的做法是反过来的:先把玩法做出来,再找一批玩家来测,测完发现「留存不行」,于是回头猜玩家想要什么。这个顺序的问题在于,玩法是动机的容器,容器设计得再好,如果和里面要装的东西对不上,玩家照样会走。
动机模型要解决的不是「玩家喜不喜欢这个功能」,而是三个更靠前的问题:
- 玩家分几类?他们各自的动机组合长什么样?
- 每类人占多少?这决定了资源该往哪个方向倾斜。
- 同一类人内部的方差有多大?如果一类人内部差异比类间差异还大,那「分类」本身就没有意义。
这三个问题都能量化,也都能仿真。本节就把它做成一份可跑的产物。
五种原型:公开可观察的 SLG 人群画像
玩家分类不是 SLG 的发明。学术界最早被广泛引用的是 Bartle 的四分法(成就者、探索者、社交者、杀手),这是公开可观察的经典分类,任何一本游戏设计教材都能查到。SLG 品类在这个基础上做了适配,因为 SLG 的「探索」被大地图与迷雾替代,「杀手」被联盟战争放大,于是常见的收敛结果是五种原型:
| 原型 | 英文 | 核心动机 | 公开可观察的行为特征 |
|---|---|---|---|
| 征服者 | conqueror | 扩张、占领、压倒对手 | 长期挂机出征、抢地、攻城 |
| 建设者 | builder | 经营、优化、把家底做大 | 沉迷城建与科技树、少出战 |
| 社交者 | socializer | 结盟、聊天、群体归属 | 泡联盟频道、参与组织动员 |
| 竞争者 | competitor | 排名、对抗、赢 | 冲榜、打 GVG、盯战力差 |
| 休闲者 | casual | 消磨、陪伴、低负担 | 日活但低强度、随缘上线 |
需要说明的是:这五种原型是公开可观察的机制抽象,不是对任何一款具体产品后台数据的还原。至于《王朝纪元》里「每类人占多少、动机权重多高」,那是设计假设(X)——它需要真实业务环境下的用户调研或行为埋点才能验证,命令行不可复现。本节能复现的是「给定这份假设,人群分布会长什么样」。
动机不是标签,是权重向量
一个常见的错误是把玩家贴成单一标签:「他是征服者」。可真实玩家是混合的——一个征服者也会聊天、也会造房子,只是权重不同。所以正确的建模方式不是「属于哪一类」,而是「在五个动机维度上的得分向量」。
《王朝纪元》取五个动机维度:成就(achievement)、权力(power)、社交(social)、沉浸(immersion)、竞争(competition)。每种原型给出一组均值权重(0–100 的整数),再叠加一个个体扰动(同一类人内部的方差)。这样得到的不是一个标签,而是一个点云:原型决定点云的中心,扰动决定点云的半径。
半径这个参数很关键。如果扰动设得太大,五个原型会互相重叠,分类就失去意义;设得太小,又会把玩家框死。下面这份配置就同时编码了中心与半径。
D 层:把人群与动机写进配置
下面这份是 D 层(静态层)配置:它只描述人群结构与动机权重,本身不产生数字,但可以被仿真脚本消费,也可以被 TypeScript 原型读取,两侧读到的是同一份真源。
{
"version": 1,
"note": "占比与动机权重均为整数百分比,跨语言用整数运算(xorshift32 + 整数取模)保证逐位一致",
"seed": 20261012,
"population": 10000,
"motivations": ["achievement", "power", "social", "immersion", "competition"],
"motivationNames": { "achievement": "成就", "power": "权力", "social": "社交", "immersion": "沉浸", "competition": "竞争" },
"archetypes": [
{ "id": "conqueror", "name": "征服者", "sharePct": 22, "spreadPct": 12,
"weights": { "achievement": 90, "power": 95, "social": 40, "immersion": 45, "competition": 85 } },
{ "id": "builder", "name": "建设者", "sharePct": 26, "spreadPct": 10,
"weights": { "achievement": 80, "power": 50, "social": 45, "immersion": 85, "competition": 35 } },
{ "id": "socializer", "name": "社交者", "sharePct": 24, "spreadPct": 11,
"weights": { "achievement": 45, "power": 40, "social": 95, "immersion": 55, "competition": 30 } },
{ "id": "competitor", "name": "竞争者", "sharePct": 16, "spreadPct": 13,
"weights": { "achievement": 75, "power": 80, "social": 50, "immersion": 35, "competition": 95 } },
{ "id": "casual", "name": "休闲者", "sharePct": 12, "spreadPct": 14,
"weights": { "achievement": 35, "power": 25, "social": 60, "immersion": 65, "competition": 20 } }
]
}
配置里没有一处浮点:占比是整数百分比,权重是 0–100 的整数,扰动半径也是整数。这不是洁癖,而是为了后面 Python 与 TypeScript 两侧能跑到逐位一致——只要涉及浮点,两侧的舍入就可能分叉。
S 层:用确定性 PRNG 抽一份万人样本
下面这段是 S 层仿真脚本。它自己实现了一个 32 位的 xorshift32 随机数发生器(零外部依赖),按 sharePct 抽出每个玩家的原型,再按原型的权重中心加扰动抽出动机得分,最后汇总成人群占比、动机均值与 P90。
# file: sim/ch02_motivation_mix.py
# tier: S
# verify: python3 sim/ch02_motivation_mix.py --config config/
"""玩家分层人群仿真:用带种子的确定性 PRNG 抽出 N 个玩家的原型归属与动机得分,
输出人群占比、动机均值/P90 与柱状 SVG。所有运算为整数,保证与 TS 侧逐位一致。"""
from __future__ import annotations
import argparse
import json
import os
import xml.etree.ElementTree as ET
def xorshift32(seed: int):
"""32 位 xorshift,纯整数,Python / TS 产出同一条序列。"""
s = seed & 0xFFFFFFFF or 0x9E3779B9
def nxt() -> int:
nonlocal s
s ^= (s << 13) & 0xFFFFFFFF
s ^= s >> 17
s ^= (s << 5) & 0xFFFFFFFF
s &= 0xFFFFFFFF
return s
return nxt
def pick(rand, weights: list[int]) -> int:
total = sum(weights)
r = rand() % total
acc = 0
for i, w in enumerate(weights):
acc += w
if r < acc:
return i
return len(weights) - 1
def load_config(cfg_dir: str, name: str) -> dict:
with open(os.path.join(cfg_dir, name), encoding="utf-8") as f:
return json.load(f)
def simulate(cfg: dict) -> dict:
rand = xorshift32(cfg["seed"])
pop = cfg["population"]
motivations = cfg["motivations"]
archetypes = cfg["archetypes"]
shares = [a["sharePct"] for a in archetypes]
counts = [0] * len(archetypes)
sums = [0] * len(motivations)
samples: list[list[int]] = [[] for _ in motivations]
for _ in range(pop):
ai = pick(rand, shares)
counts[ai] += 1
spread = archetypes[ai]["spreadPct"]
for mi, m in enumerate(motivations):
noise = rand() % (2 * spread + 1) - spread
v = archetypes[ai]["weights"][m] + noise
v = 0 if v < 0 else (100 if v > 100 else v)
sums[mi] += v
samples[mi].append(v)
p90 = [sorted(samples[mi])[int(0.9 * (pop - 1))] for mi in range(len(motivations))]
return {
"version": 1,
"population": pop,
"archetypes": [a["id"] for a in archetypes],
"realizedSharePermille": [c * 1000 // pop for c in counts],
"targetSharePermille": [a["sharePct"] * 10 for a in archetypes],
"motivations": motivations,
"motivationMean": [s // pop for s in sums],
"motivationP90": p90,
}
def write_svg(path: str, cfg: dict, res: dict) -> int:
W, H, pl, pr, pt, pb = 640, 360, 70, 24, 32, 44
labels = [cfg["motivationNames"][m] for m in cfg["motivations"]]
vals = res["motivationMean"]
hi = max(vals) or 1
iw, ih, n = W - pl - pr, H - pt - pb, len(vals)
svg = ET.Element("svg", {"xmlns": "http://www.w3.org/2000/svg", "viewBox": f"0 0 {W} {H}", "width": str(W), "height": str(H)})
ET.SubElement(svg, "rect", {"x": "0", "y": "0", "width": str(W), "height": str(H), "fill": "#fff"})
ET.SubElement(svg, "line", {"x1": str(pl), "y1": str(pt), "x2": str(pl), "y2": str(H - pb), "stroke": "#333"})
ET.SubElement(svg, "line", {"x1": str(pl), "y1": str(H - pb), "x2": str(W - pr), "y2": str(H - pb), "stroke": "#333"})
bw = iw // (n * 2)
for i, v in enumerate(vals):
x = pl + iw * (2 * i + 1) // (2 * n)
h = ih * v // hi
ET.SubElement(svg, "rect", {"x": str(x - bw // 2), "y": str(pt + ih - h), "width": str(bw), "height": str(h), "fill": "#1f6feb"})
t = ET.SubElement(svg, "text", {"x": str(x), "y": str(pt + ih - h - 4), "font-size": "11", "text-anchor": "middle", "fill": "#111"})
t.text = str(v)
lab = ET.SubElement(svg, "text", {"x": str(x), "y": str(H - pb + 18), "font-size": "12", "text-anchor": "middle", "fill": "#333"})
lab.text = labels[i]
ttl = ET.SubElement(svg, "text", {"x": str(pl), "y": "20", "font-size": "13", "fill": "#111"})
ttl.text = f"《王朝纪元》人群动机均值(N={res['population']},种子 {cfg['seed']})"
ET.ElementTree(svg).write(path, encoding="utf-8", xml_declaration=True)
assert ET.parse(path).getroot().tag.endswith("svg")
return os.path.getsize(path)
def main() -> int:
here = os.path.dirname(os.path.abspath(__file__))
ap = argparse.ArgumentParser()
ap.add_argument("--config", default=os.path.join(here, "..", "config"))
ap.add_argument("--out", default=os.path.join(here, "..", "out"))
args = ap.parse_args()
os.makedirs(args.out, exist_ok=True)
cfg = load_config(args.config, "ch02_segments.json")
res = simulate(cfg)
with open(os.path.join(args.out, "ch02_motivation.json"), "w", encoding="utf-8") as f:
json.dump(res, f, ensure_ascii=False, indent=2)
with open(os.path.join(args.out, "ch02_motivation.csv"), "w", encoding="utf-8") as f:
f.write("archetype,sharePermille,sharePct," + ",".join(cfg["motivations"]) + "\n")
for i, a in enumerate(cfg["archetypes"]):
row = [a["id"], str(res["realizedSharePermille"][i]), str(a["sharePct"])] + [str(a["weights"][m]) for m in cfg["motivations"]]
f.write(",".join(row) + "\n")
size = write_svg(os.path.join(args.out, "ch02_motivation.svg"), cfg, res)
print(json.dumps(res, ensure_ascii=False))
print(f"# svg bytes={size}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
脚本除了 JSON,还会写出 out/ch02_motivation.csv(每个原型的动机向量)和 out/ch02_motivation.svg(动机均值柱状图)。上面就是完整文件——从 PRNG 到 SVG 一条龙,零外部依赖。
读结果:占比贴近目标,均值揭示结构
在本机(Python 3.14.6,种子 20261012,N=10000)跑出来的结果是:
realizedSharePermille: [218, 257, 240, 163, 121] # 征服者/建设者/社交者/竞争者/休闲者
targetSharePermille: [220, 260, 240, 160, 120]
motivationMean: [67, 59, 58, 58, 52] # 成就/权力/社交/沉浸/竞争
motivationP90: [91, 96, 97, 87, 95]
先看占比。抽样的实测占比是 218/257/240/163/121 千分比,目标占比是 220/260/240/160/120,最大偏差 3 个千分比——这正是「用确定性 PRNG 按权重抽样」应该有的表现:样本足够大时,实测占比收敛到目标占比。这里没有「提升几倍」的说法,只有同机相对量级,绝对数值不可移植。
再看均值,有三个值得注意的点:
- 成就均值 67 最高,因为五种原型在成就维度上普遍不低(最低的休闲者也有 35)。这说明「成就」是一个跨人群的公共动机——几乎所有 SLG 玩家都想看到自己的家底变大,区别只在于「变大的方式」。
- 竞争均值 52 最低,但 P90 高达 95。这是一条典型的长尾动机:多数人对竞争不敏感,但有一小撮人(竞争者的 95 权重)把它当成全部。SLG 的 GVG、排行榜、赛季,服务的就是这条尾巴。
- 社交均值 58,P90 97,方差同样很大。社交动机的分布是双峰的:社交者贡献了高分端,而征服者、竞争者则普遍偏低(40–50)。这解释了为什么联盟系统必须同时服务「来找人的」和「来打仗的」。
动机向量不是给设计看的热闹,它直接决定玩法预算的分配:按「动机均值 × 人群占比」排优先级,成就动机加权需求最高,竞争动机均值虽低但 P90 极高、且集中在大 R 人群,所以在深度上要够、在广度上可以窄。第 4 章的系统地图会据此判断「哪些系统服务哪些人群」;本节只负责把画像立住,它本身不决定任何玩法。
同一份配置,两侧跑到逐位一致
动机模型最容易出错的地方,是「Python 算一套、前端或服务端算另一套」。本卷的做法是让两侧读同一份配置、跑同一套整数算法。下面这段是 P 层 TypeScript 原型,它复刻了 Python 侧的 xorshift32 与整数抽样,node --test 可以直接跑,断言里写死了与 Python 完全相同的数字。
// file: proto/src/ch02_motivation.ts
// tier: P
// verify: node --test 'proto/**/*.test.ts'
// 第 2 章原型层(动机):确定性 PRNG + 人群动机分布仿真,与 sim/ch02_motivation_mix.py 逐位一致。
import { loadConfig } from "./core.ts";
export interface ArchetypeDef {
id: string; name: string; sharePct: number; spreadPct: number;
weights: Record<string, number>;
}
export interface SegmentsConfig {
seed: number; population: number; motivations: string[];
motivationNames: Record<string, string>; archetypes: ArchetypeDef[];
}
// 32 位 xorshift,纯整数,Python / TS 产出同一条序列。
export function xorshift32(seed: number): () => number {
let s = seed >>> 0;
if (s === 0) s = 0x9e3779b9;
return () => {
s = (s ^ ((s << 13) >>> 0)) >>> 0;
s = (s ^ (s >>> 17)) >>> 0;
s = (s ^ ((s << 5) >>> 0)) >>> 0;
return s >>> 0;
};
}
// 按权重整数取模抽一个下标(权重和即模数)。
export function pick(rand: () => number, weights: number[]): number {
const total = weights.reduce((a, b) => a + b, 0);
const r = rand() % total;
let acc = 0;
for (let i = 0; i < weights.length; i++) {
acc += weights[i];
if (r < acc) return i;
}
return weights.length - 1;
}
export function simulateSegments(cfg: SegmentsConfig) {
const rand = xorshift32(cfg.seed);
const pop = cfg.population;
const motivations = cfg.motivations;
const archetypes = cfg.archetypes;
const shares = archetypes.map((a) => a.sharePct);
const counts = new Array(archetypes.length).fill(0) as number[];
const sums = new Array(motivations.length).fill(0) as number[];
const samples: number[][] = motivations.map(() => []);
for (let n = 0; n < pop; n++) {
const ai = pick(rand, shares);
counts[ai]++;
const arch = archetypes[ai];
const spread = arch.spreadPct;
for (let mi = 0; mi < motivations.length; mi++) {
const mean = arch.weights[motivations[mi]];
const noise = (rand() % (2 * spread + 1)) - spread;
let v = mean + noise;
v = v < 0 ? 0 : v > 100 ? 100 : v;
sums[mi] += v;
samples[mi].push(v);
}
}
const motivationMean = sums.map((s) => Math.floor(s / pop));
const motivationP90 = samples.map((arr) => {
const sorted = [...arr].sort((a, b) => a - b);
return sorted[Math.floor(0.9 * (pop - 1))];
});
return {
version: 1, population: pop,
archetypes: archetypes.map((a) => a.id),
realizedSharePermille: counts.map((c) => Math.floor((c * 1000) / pop)),
targetSharePermille: archetypes.map((a) => a.sharePct * 10),
motivations, motivationMean, motivationP90,
};
}
export function loadSegments(): SegmentsConfig {
return loadConfig<SegmentsConfig>("ch02_segments");
}
两侧能对齐,靠的是三条硬规则:增长率与系数一律用整数、幂运算不出现浮点、除法一律整数地板除。本节只用到抽样与求和,三条规则里最关键的是「不出现浮点」——一旦在配置里写了 1.22 这样的数,两侧的浮点舍入就可能分叉。
本节把「谁在玩」这件事量化了。但玩家不只是按动机分类的,他们还按愿意花多少钱分类——而且这两条分类线并不重合。下一节就来做付费意愿分层。
阅读导航:上一节:差异化定位与设计约束 · 下一节:付费意愿分层 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。