2.1 玩家类型与动机模型

把「玩家为什么玩」从一句访谈结论变成可仿真的权重向量:定义《王朝纪元》五种原型的动机权重,用带种子的确定性 PRNG 抽出万人样本,算出人群占比、动机均值与 P90,并把同一份配置在 Python 与 TypeScript 两侧跑到逐位一致。

第 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 这样的数,两侧的浮点舍入就可能分叉。

本节把「谁在玩」这件事量化了。但玩家不只是按动机分类的,他们还按愿意花多少钱分类——而且这两条分类线并不重合。下一节就来做付费意愿分层。

阅读导航:上一节:差异化定位与设计约束 · 下一节:付费意愿分层 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「slg」更多文章

  1. 12.3 留存与付费的联合分析
  2. 12.2 流失预警与召回
  3. 附录 C:全书公式、参数与数据集索引