上一节按动机把玩家分了层,但动机不等于钱包。一个把「竞争」权重拉满的玩家未必愿意花钱,一个只想安静造房子的玩家也可能为了一块好地一掷千金。所以第二节要做的是另一条正交的切分线:付费意愿。
动手前先划两条硬边界。第一,本节只做意愿分层与人群画像,不做任何定价——首充、月卡、通行证这些具体付费点的设计是卷二第 9 章的内容,本节一个价格数字都不写。第二,本节不碰 LTV 建模(那是卷二第 11 章),只回答「人群怎么分层、每层长什么样」。如果你想要的是通用的付费设计清单,站内的经济平衡长文 用的是放置类口径、以挂机收益为起点;SLG 的付费意愿和大地图节奏、联盟关系强耦合,所以本卷换用 SLG 大地图的口径,从人群画像讲起。
意愿 ≠ 已付费:为什么先分层再定价
把「付费玩家」直接等同于「已经充过钱的人」,是很多数据看板的默认口径,但它对设计几乎没有指导意义:一个玩家没充钱,可能是不想充,也可能是还没遇到值得充的时刻。这两种情况在数据上长得一样,在设计上完全不同。
所以本节引入一个中间变量:付费意愿分(willingness score)。它是一个 0–100 的整数,描述玩家「在遇到合适付费点时掏钱的倾向」,与「是否已经掏过钱」无关。意愿分高但尚未付费的人,是设计要争取的对象;意愿分低的人,再好的付费点也很难撬动。分层就是给这条意愿轴切几刀。
需要强调:意愿分本身是一个潜变量(X),它不可直接观测,只能通过调研或行为代理指标间接估计。本节的可复现部分,是「给定意愿分的分布假设,人群会分成什么样的比例」,而不是「真实玩家的意愿分是多少」——后者需要真实业务环境,命令行不可复现。
五层意愿分层
《王朝纪元》把 0–100 的意愿轴切成五层,覆盖从完全不付费到超高付费的整条谱:
| 层 | id | 意愿分区间 | 设计含义(公开可观察的运营共识) |
|---|---|---|---|
| 免费玩家 | free | 0–39 | 几乎不会付费,靠玩法留存与口碑贡献活跃 |
| 小额玩家 | minnow | 40–64 | 偶尔为小额点付费,看重性价比与低门槛 |
| 中额玩家 | mid | 65–84 | 有稳定付费习惯,是营收的基本盘 |
| 高额玩家 | high | 85–94 | 为强度与排名付费,对体验中断敏感 |
| 超高额玩家 | whale | 95–100 | 极端少数,贡献不成比例的收入,需单独服务 |
这张表里的区间边界是设计假设(X),不是对任何具体产品的还原。它的价值在于把「大 R / 小 R」这类模糊说法,换成一条有刻度、可仿真、可校准的轴。
意愿与动机的耦合
意愿分不是凭空抽的。上一节的五种原型,对「掏钱倾向」有系统性的差异:竞争者和征服者为了赢和扩张更愿意付费,建设者次之,社交者与休闲者最低。所以本节的做法是以原型为条件抽意愿——每种原型给一个意愿基准,再叠加个体扰动:
willingness = archetypeWillingnessBase[原型] + noise(±spread)
这样两条分类线就耦合起来了:动机决定意愿的起点,扰动决定同一类人内部的离散度。耦合的好处是,当你调整某类原型的人群占比时,付费分层的比例会自动跟着变,而不是两套数据各说各话。
分层粒度:为什么是五层
分层切几刀,是一个粒度权衡。切得太粗(比如只分「付费 / 不付费」),信息量不足以指导设计;切得太细(比如按每 5 分一档切成 20 层),每层的样本量会小到没有统计意义,运营也记不住。
五层是 SLG 里一个常见的平衡点,理由是它把两条尾巴单独切了出来:
- 免费层单独一层,因为它的人群占比最大、设计目标也最特殊(不追求付费,追求留存与传播)。
- 超高额层单独一层,因为它人数极少但贡献不成比例的收入,服务方式与其余各层完全不同,混进「高额层」会被平均数掩盖。
中间三层(小额 / 中额 / 高额)承担的是「连续谱」的刻画,边界不必精确到分,只要能把「偶尔充一点」和「有稳定付费习惯」区分开即可。本节的配置把这五个区间写成整数边界,正是为了让这条轴可复现、可校准——你随时可以改边界重跑,看漏斗怎么变。
D 层:把分层与基准写进配置
下面这份是 D 层配置:它定义了五层的意愿区间、各原型的意愿基准、扰动半径,以及一组目标占比(千分比整数),用于后面的校准对照。
{
"version": 1,
"note": "意愿分(willingness)为 0–100 整数;目标占比用千分比整数,避免浮点",
"seed": 20261012,
"population": 10000,
"willingnessSpreadPct": 18,
"tiers": [
{ "id": "free", "name": "免费玩家", "willingnessLo": 0, "willingnessHi": 39 },
{ "id": "minnow", "name": "小额玩家", "willingnessLo": 40, "willingnessHi": 64 },
{ "id": "mid", "name": "中额玩家", "willingnessLo": 65, "willingnessHi": 84 },
{ "id": "high", "name": "高额玩家", "willingnessLo": 85, "willingnessHi": 94 },
{ "id": "whale", "name": "超高额玩家", "willingnessLo": 95, "willingnessHi": 100 }
],
"archetypeWillingnessBase": {
"conqueror": 52,
"builder": 30,
"socializer": 25,
"competitor": 78,
"casual": 18
},
"targetSharePermille": {
"free": 570,
"minnow": 255,
"mid": 125,
"high": 40,
"whale": 10
}
}
配置里再一次没有浮点:意愿基准、扰动半径、目标占比全是整数,占比用千分比。这是为了让本节与上一节共用同一套整数 PRNG,Python 与 TypeScript 两侧能跑到逐位一致。
S 层:抽出万人样本并分层
下面这段是 S 层仿真脚本。它复用上一节同一套 xorshift32(同一份种子、同一条序列),先按原型占比抽原型、再按原型意愿基准加扰动抽意愿分、最后按区间落到五层,并统计每层的主导原型作为人群画像。
# file: sim/ch02_payer_tiers.py
# tier: S
# verify: python3 sim/ch02_payer_tiers.py --config config/
"""付费意愿分层仿真:把每个玩家的「意愿分」映射到分层,输出各层占比(千分比)、
相对目标占比的偏差,以及每层的主导原型(人群画像)。全程整数运算。"""
from __future__ import annotations
import argparse
import json
import os
import xml.etree.ElementTree as ET
def xorshift32(seed: int):
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 tier_of(tiers: list[dict], score: int) -> int:
for i, t in enumerate(tiers):
if t["willingnessLo"] <= score <= t["willingnessHi"]:
return i
return len(tiers) - 1
def simulate(cfg: dict, seg_cfg: dict) -> dict:
rand = xorshift32(cfg["seed"])
pop, tiers = cfg["population"], cfg["tiers"]
archetypes = seg_cfg["archetypes"]
shares = [a["sharePct"] for a in archetypes]
base, spread = cfg["archetypeWillingnessBase"], cfg["willingnessSpreadPct"]
counts = [0] * len(tiers)
arch_by_tier = [[0] * len(archetypes) for _ in tiers]
for _ in range(pop):
ai = pick(rand, shares)
score = base[archetypes[ai]["id"]] + (rand() % (2 * spread + 1) - spread)
score = 0 if score < 0 else (100 if score > 100 else score)
ti = tier_of(tiers, score)
counts[ti] += 1
arch_by_tier[ti][ai] += 1
realized = [c * 1000 // pop for c in counts]
target = [cfg["targetSharePermille"][t["id"]] for t in tiers]
dominant = []
for row in arch_by_tier:
best = max(range(len(row)), key=lambda j: row[j])
dominant.append(archetypes[best]["id"] if row[best] > 0 else None)
return {"version": 1, "population": pop, "tiers": [t["id"] for t in tiers],
"realizedSharePermille": realized, "targetSharePermille": target,
"deviationPermille": [realized[i] - target[i] for i in range(len(tiers))],
"dominantArchetype": dominant}
def write_svg(path: str, cfg: dict, res: dict) -> int:
W, H, pl, pr, pt, pb = 640, 360, 56, 24, 32, 48
labels = [t["name"] for t in cfg["tiers"]]
realized, target = res["realizedSharePermille"], res["targetSharePermille"]
hi, iw, ih, n = max(realized + target) or 1, W - pl - pr, H - pt - pb, len(labels)
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"})
slot, bw = iw // n, (iw // n) // 3
for i in range(n):
cx = pl + slot * i + slot // 2
for k, (series, color) in enumerate(((realized, "#1f6feb"), (target, "#d29922"))):
h = ih * series[i] // hi
ET.SubElement(svg, "rect", {"x": str(cx - bw + k * bw), "y": str(pt + ih - h), "width": str(bw), "height": str(h), "fill": color})
lab = ET.SubElement(svg, "text", {"x": str(cx), "y": str(H - pb + 18), "font-size": "11", "text-anchor": "middle", "fill": "#333"})
lab.text = labels[i]
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_payer.json")
seg_cfg = load_config(args.config, "ch02_segments.json")
res = simulate(cfg, seg_cfg)
with open(os.path.join(args.out, "ch02_payer.json"), "w", encoding="utf-8") as f:
json.dump(res, f, ensure_ascii=False, indent=2)
with open(os.path.join(args.out, "ch02_payer.csv"), "w", encoding="utf-8") as f:
f.write("tier,realizedPermille,targetPermille,deviationPermille,dominantArchetype\n")
for i, t in enumerate(cfg["tiers"]):
f.write(f"{t['id']},{res['realizedSharePermille'][i]},{res['targetSharePermille'][i]},{res['deviationPermille'][i]},{res['dominantArchetype'][i]}\n")
size = write_svg(os.path.join(args.out, "ch02_payer.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_payer.csv(每层的占比与主导原型)和 out/ch02_payer.svg(仿真占比 vs 目标占比的双柱图)。上面同样是完整文件,与上一节共用同一套整数 PRNG。
P 层:同一份配置在 TypeScript 侧复算
分层逻辑如果只在分析脚本里跑,就永远是个「报告」而不是「产品」。要让它进服务端或工具链,必须能被第二语言复算。下面这段是 P 层 TypeScript 原型,它读同一份 ch02_payer.json 与上一节的 ch02_segments.json,用与 Python 完全相同的整数 PRNG 和取模抽样复算分层,node --test 的断言里写死了与 Python 完全一致的占比。
// file: proto/src/ch02_payer.ts
// tier: P
// verify: node --test 'proto/**/*.test.ts'
// 第 2 章原型层(付费意愿):意愿分 → 分层映射与人群画像,与 sim/ch02_payer_tiers.py 逐位一致。
import { loadConfig } from "./core.ts";
import { xorshift32, pick, type SegmentsConfig } from "./ch02_motivation.ts";
export interface PayerTierDef {
id: string; name: string; willingnessLo: number; willingnessHi: number;
}
export interface PayerConfig {
seed: number; population: number; willingnessSpreadPct: number;
tiers: PayerTierDef[];
archetypeWillingnessBase: Record<string, number>;
targetSharePermille: Record<string, number>;
}
export interface PayerResult {
version: number; population: number; tiers: string[];
realizedSharePermille: number[]; targetSharePermille: number[];
deviationPermille: number[]; dominantArchetype: (string | null)[];
}
function tierOf(tiers: PayerTierDef[], score: number): number {
for (let i = 0; i < tiers.length; i++) {
if (tiers[i].willingnessLo <= score && score <= tiers[i].willingnessHi) return i;
}
return tiers.length - 1;
}
export function simulatePayer(cfg: PayerConfig, seg: SegmentsConfig): PayerResult {
const rand = xorshift32(cfg.seed);
const pop = cfg.population;
const tiers = cfg.tiers;
const archetypes = seg.archetypes;
const shares = archetypes.map((a) => a.sharePct);
const base = cfg.archetypeWillingnessBase;
const spread = cfg.willingnessSpreadPct;
const counts = new Array(tiers.length).fill(0) as number[];
const archByTier: number[][] = tiers.map(() => new Array(archetypes.length).fill(0));
for (let n = 0; n < pop; n++) {
const ai = pick(rand, shares);
const noise = (rand() % (2 * spread + 1)) - spread;
let score = base[archetypes[ai].id] + noise;
score = score < 0 ? 0 : score > 100 ? 100 : score;
const ti = tierOf(tiers, score);
counts[ti]++;
archByTier[ti][ai]++;
}
const realizedSharePermille = counts.map((c) => Math.floor((c * 1000) / pop));
const targetSharePermille = tiers.map((t) => cfg.targetSharePermille[t.id]);
const dominantArchetype = archByTier.map((row) => {
let best = 0;
for (let j = 1; j < row.length; j++) if (row[j] > row[best]) best = j;
return row[best] > 0 ? archetypes[best].id : null;
});
return {
version: 1, population: pop, tiers: tiers.map((t) => t.id),
realizedSharePermille, targetSharePermille,
deviationPermille: realizedSharePermille.map((v, i) => v - targetSharePermille[i]),
dominantArchetype,
};
}
export function loadPayer(): PayerConfig {
return loadConfig<PayerConfig>("ch02_payer");
}
两侧能对齐,靠的还是上一节那三条硬规则:整数系数、无浮点、整数地板除。分层这一步只用到取模抽样与整数比较,所以只要 PRNG 一致,两侧的占比就逐位一致。
读结果:一个高免费、长尾付费的漏斗
在本机(Python 3.14.6,种子 20261012,N=10000)跑出来的结果是:
| 层 | 仿真占比‰ | 目标占比‰ | 偏差‰ | 主导原型 |
|---|---|---|---|---|
| 免费玩家 | 572 | 570 | +2 | socializer |
| 小额玩家 | 252 | 255 | −3 | conqueror |
| 中额玩家 | 125 | 125 | 0 | competitor |
| 高额玩家 | 42 | 40 | +2 | competitor |
| 超高额玩家 | 7 | 10 | −3 | competitor |
三个可以直接读出来的结论:
- 漏斗是「高免费、长尾付费」:免费层 572‰ 占了近六成,而 high + whale 合计不到 50‰。这和公开可观察的 SLG 付费结构一致——大多数人不付费,少数人贡献大头。注意这里说的是意愿,不是实际流水。
- 偏差全在个位数千分比:仿真的实测占比与目标占比最大差 3 个千分比,说明这套「原型意愿基准 + 扰动」的模型能稳定复现设计假设。这里同样是同机相对量级,绝对数值不可移植。
- 高意愿层被竞争者主导:中额及以上的三层主导原型都是
competitor。这不是巧合——竞争者的意愿基准 78 最高,且其在免费层的占比几乎为零。它给出一条明确的画像:为强度付费的核心人群,是冲着排名与对抗来的。而免费层的主导是socializer,说明社交型玩家是活跃盘的主体,却不是营收盘的主体。
分层只到画像为止
必须再强调一次边界:本节产出的是人群画像,不是付费点方案。知道了「谁是高意愿人群」,接下来「给他们设计什么付费点、定什么价、放什么位置」是另一卷的事——具体付费点设计在卷二第 9 章,LTV 与付费点的映射建模在卷二第 11 章。本节只保证一件事:当你在后续章节里说「大 R」时,背后有一条 0–100 的意愿轴和一份可复现的人群占比。
人群画像回答的是「谁在玩、谁愿意花钱」。但玩家不是静态的——同一个人在第 1 天、第 7 天、第 30 天的体验目标完全不同。下一节就把时间轴加上,讲生命周期阶段与体验目标。
阅读导航:上一节:玩家类型与动机模型 · 下一节:生命周期阶段与体验目标 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。