5.2 动机模型与付费意愿分层

把「谁愿意花钱」从一句运营直觉变成一条可仿真的意愿轴:按动机把《合成小岛》用户切成五层,每层给出整数付费率与整数 ARPPU,用同一份配置算出总体付费率、总体 ARPPU 与总体 ARPDAU,产 JSON + CSV + SVG,并显式暴露整数地板除的 1 分钱缺口。

上一节把用户按使用场景分了画像,但场景只回答「什么时候玩、玩多久」,不回答「为什么玩、愿不愿意花钱」。本节做的是另一条正交的切分线:动机,以及由动机派生的付费意愿。先把分工讲清楚——站内那篇广告变现的清单式短文 给的是「广告位怎么放、频次怎么控」的经验清单;本章给的是用户动机与付费意愿的可量化分层模型,本节落地其中的「意愿分层」这一层。

动手前划两条硬边界。第一,本节只做意愿分层与人群画像,不做任何付费点设计——首充、礼包、通行证这些具体付费点的结构与定价,是第 10 章(变现设计前置)和卷三(上线变现与长线运营)的内容,本节一个付费点的位置都不写。第二,本节不碰 LTV 回本建模(那是卷三第 3 章),只回答「人群怎么按动机分层、每层愿意花多少钱」。

动机不等于钱包

很多团队会把「付费玩家」直接等同于「已经充过钱的人」。这个口径对设计几乎没有指导意义:一个玩家没充钱,可能是不想充,也可能是还没遇到值得充的时刻。这两种情况在数据看板上长得一模一样,在设计上却完全不同。

所以本节引入一个中间变量:动机层(motivation tier)。它描述玩家「为了什么而玩」,与「是否已经掏过钱」无关。动机决定了玩家会在什么时刻、为什么理由愿意付费:收集型玩家会为「差最后一块拼图」付费,效率型玩家会为「跳过等待」付费,竞技型玩家会为「排名与强度」付费。分层就是按动机给人群切几刀,再给每层一个付费率与一个 ARPPU。

需要强调:每层的付费率与 ARPPU 都是假设参数(X),来自对公开可观察的小游戏变现结构的归纳,不是任何产品的后台还原。本节的可复现部分,是「给定这套分层参数,总体 ARPDAU 是多少」;「真实人群的付费率是多少」需要真实业务环境,命令行不可复现。

五层动机与付费意愿分层

《合成小岛》把用户按「主导动机」切成五层,覆盖从完全不付费到高付费的整条谱:

层id主导动机设计含义(公开可观察的运营共识)
社交白嫖型social-free图热闹、蹭社交几乎不付费,靠玩法留存与分享贡献活跃
收集完成型collector集齐图鉴、完成度为「差一点」付费,对小额点敏感
效率加速型accelerator跳过等待、加速进度为省时间付费,是放置类的主力付费层
装扮展示型decorator个性化、炫耀为外观与展示付费,对稀缺敏感
竞技攀比型competitor排名、对抗为强度与排名付费,对体验中断敏感

这张表里的付费率与 ARPPU 都是设计假设(X),不是对任何具体产品的还原。它的价值在于把「大 R / 小 R」这类模糊说法,换成一条有刻度、可仿真、可校准的分层轴。

注意五层里**「社交白嫖型」单独一层且付费率为 0**——这不是凑数,而是刻意把「不付费的大多数」显式建模出来。它占 420‰,接近一半;如果把它混进平均值里,会系统性地高估付费率。分层的第一个作用,就是让不付费的人不被平均数掩盖。

D 层:把分层与参数写进配置

下面这份是 D 层配置。它把五层的占比(千分比)、付费率(整数百分比)与 ARPPU(单位:分)写成整数参数。付费率用整数百分比、ARPPU 用整数分,是为了全程避免浮点。

{
  "version": 1,
  "note": "示例分层:付费率与 ARPPU 均为整数假设参数(ARPPU 单位为分);真实分布需真实业务环境,命令行不可复现(X)",
  "tiers": [
    { "id": "social-free", "name": "社交白嫖型", "sharePermille": 420, "payRatePct": 0,  "arppuFen": 0 },
    { "id": "collector",   "name": "收集完成型", "sharePermille": 240, "payRatePct": 9,  "arppuFen": 1500 },
    { "id": "accelerator", "name": "效率加速型", "sharePermille": 160, "payRatePct": 14, "arppuFen": 2200 },
    { "id": "decorator",   "name": "装扮展示型", "sharePermille": 120, "payRatePct": 11, "arppuFen": 1800 },
    { "id": "competitor",  "name": "竞技攀比型", "sharePermille": 60,  "payRatePct": 18, "arppuFen": 3000 }
  ]
}

配置里没有浮点:占比、付费率、ARPPU 全是整数。千分比占比之和恰好 1000,这是后面公式能成立的前提。ARPPU 用「分」而不是「元」,是为了让 15 元、22 元这样的价格也能写成整数,避免 15.5 这类小数。

S 层:从分层参数算出总体 ARPDAU

下面这段是 S 层仿真脚本。它读上面那份配置,用三个加权量算出总体付费率、总体 ARPPU 与总体 ARPDAU,并写出 JSON、CSV 与一张 ARPPU 柱状图 SVG。全程整数地板除,没有任何浮点。

#!/usr/bin/env python3
# file: sim/ch05_motivation.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/ch05_motivation.py --config config/ --out out/
"""动机 → 付费意愿分层模型:读 config/ch05_motivation.json 的示例分层,
每层给出「占比(千分比)+ 付费率(整数百分比)+ ARPPU(分)」,算出
总体付费率、总体 ARPPU 与总体 ARPDAU,产出 JSON + CSV + SVG。
全程整数地板除,无浮点。

注意:付费率与 ARPPU 均为假设参数,真实分布需真实业务环境,命令行不可复现(X)。
"""

import argparse
import json
import os
import xml.etree.ElementTree as ET


def load_config(config_dir, name):
    with open(os.path.join(config_dir, name), encoding="utf-8") as f:
        return json.load(f)


def analyze(cfg):
    rows = cfg["tiers"]
    share_sum = sum(r["sharePermille"] for r in rows)
    # 每层的「付费权重」= 占比(‰) × 付费率(%),单位是「‰·%」,全体加总后 //10 得整体付费率(‰)
    pay_weight = sum(r["sharePermille"] * r["payRatePct"] for r in rows)
    # 每层的收入权重 = 占比(‰) × 付费率(%) × ARPPU(分),单位是「‰·%·分」
    rev_weight = sum(r["sharePermille"] * r["payRatePct"] * r["arppuFen"] for r in rows)

    payer_share_permille = pay_weight // 10          # 整体付费率,千分比
    arppu_fen = rev_weight // pay_weight if pay_weight else 0  # 付费用户平均 ARPPU(分)
    arpdau_fen = rev_weight // (1000 * 100)          # 全体日活 ARPDAU(分)

    per_tier = []
    for r in rows:
        contrib = r["sharePermille"] * r["payRatePct"] * r["arppuFen"] // (1000 * 100)
        per_tier.append(
            {
                "id": r["id"],
                "name": r["name"],
                "sharePermille": r["sharePermille"],
                "payRatePct": r["payRatePct"],
                "arppuFen": r["arppuFen"],
                "contributionFen": contrib,
            }
        )
    contrib_sum = sum(t["contributionFen"] for t in per_tier)
    return {
        "version": 1,
        "shareSumPermille": share_sum,
        "payerSharePermille": payer_share_permille,
        "arppuFen": arppu_fen,
        "arpdauFen": arpdau_fen,
        "contributionSumFen": contrib_sum,
        "roundingGapFen": arpdau_fen - contrib_sum,
        "tiers": per_tier,
    }


def write_svg(path, rows, title):
    W, H, pl, pr, pt, pb = 640, 360, 72, 24, 32, 56
    labels = [r["name"] for r in rows]
    values = [r["arppuFen"] for r in rows]
    hi = max(values) or 1
    iw, ih, n = 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": "#ffffff"})
    t = ET.SubElement(svg, "text", {"x": str(pl), "y": "22", "font-size": "14", "fill": "#111827"})
    t.text = title
    ET.SubElement(svg, "line", {"x1": str(pl), "y1": str(pt + ih), "x2": str(W - pr), "y2": str(pt + ih), "stroke": "#9ca3af"})
    slot = iw // n
    bw = (iw // n) * 2 // 3
    for i, r in enumerate(rows):
        cx = pl + slot * i + slot // 2
        h = ih * values[i] // hi
        ET.SubElement(svg, "rect", {"x": str(cx - bw // 2), "y": str(pt + ih - h), "width": str(bw), "height": str(h), "fill": "#059669"})
        v = ET.SubElement(svg, "text", {"x": str(cx), "y": str(pt + ih - h - 6), "font-size": "11", "text-anchor": "middle", "fill": "#111827"})
        v.text = str(values[i])
        lab = ET.SubElement(svg, "text", {"x": str(cx), "y": str(H - pb + 18), "font-size": "11", "text-anchor": "middle", "fill": "#374151"})
        lab.text = labels[i]
        sh = ET.SubElement(svg, "text", {"x": str(cx), "y": str(H - pb + 34), "font-size": "10", "text-anchor": "middle", "fill": "#6b7280"})
        sh.text = f"{r['payRatePct']}%"
    xml = ET.tostring(svg, encoding="unicode")
    with open(path, "w", encoding="utf-8") as f:
        f.write(xml)
    ET.fromstring(xml)
    return len(xml.encode())


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--config", default="config/")
    ap.add_argument("--out", default="out/")
    args = ap.parse_args()
    os.makedirs(args.out, exist_ok=True)
    cfg = load_config(args.config, "ch05_motivation.json")
    res = analyze(cfg)

    with open(os.path.join(args.out, "ch05_motivation.json"), "w", encoding="utf-8") as f:
        json.dump(res, f, ensure_ascii=False, indent=2)

    with open(os.path.join(args.out, "ch05_motivation.csv"), "w", encoding="utf-8") as f:
        f.write("id,name,sharePermille,payRatePct,arppuFen,contributionFen\n")
        for t in res["tiers"]:
            f.write(
                f"{t['id']},{t['name']},{t['sharePermille']},{t['payRatePct']},"
                f"{t['arppuFen']},{t['contributionFen']}\n"
            )

    size = write_svg(os.path.join(args.out, "ch05_motivation.svg"), res["tiers"], "《合成小岛》示例分层 ARPPU(分)")
    summary = {k: res[k] for k in ("payerSharePermille", "arppuFen", "arpdauFen", "contributionSumFen", "roundingGapFen")}
    print(json.dumps(summary, ensure_ascii=False))
    print(f"# svg bytes={size}")


if __name__ == "__main__":
    main()

脚本除 JSON 外还写出 out/ch05_motivation.csv(每层的付费率、ARPPU 与收入贡献)和 out/ch05_motivation.svg(每层 ARPPU 柱状图,柱下标出付费率)。运行命令:

/opt/homebrew/bin/python3 sim/ch05_motivation.py --config config/ --out out/
{"payerSharePermille": 680, "arppuFen": 2027, "arpdauFen": 137, "contributionSumFen": 136, "roundingGapFen": 1}
# svg bytes=1929

读结果:一个高免费、长尾付费的结构

在本机(Python 3.14.6)跑出来的结果是:

层占比‰付费率%ARPPU(分)收入贡献(分/日活)
社交白嫖型420000
收集完成型2409150032
效率加速型16014220049
装扮展示型12011180023
竞技攀比型6018300032
总体10006.82027137

四个可以直接读出来的结论:

  • 总体付费率 6.8%、总体 ARPPU 20.27 元、总体 ARPDAU 1.37 元。这三个数是同一套分层参数的三张面孔:付费率描述「多少人掏钱」,ARPPU 描述「掏钱的人平均花多少」,ARPDAU 描述「平摊到每个日活上每天赚多少」。
  • 付费率随动机单调上升,但人数单调下降:竞技攀比型付费率最高(18%),却只占 60‰;社交白嫖型占 420‰,付费率为 0。付费率与人群规模反向,这是分层模型最有价值的一条结构。
  • 收入贡献高度集中在两层:效率加速型(49 分)与收集完成型、竞技攀比型(各 32 分)合计贡献了绝大部分。这三层加起来只占 460‰,却撑起了几乎全部收入——不到一半的人在养整个盘子。
  • 付费率不是 ARPPU 的替代品:装扮展示型付费率(11%)低于效率加速型(14%),ARPPU(1800 分)也低于它(2200 分),收入贡献自然只有 23 分。付费率与 ARPPU 是两个独立旋钮,设计时不能只看一个。

再次强调:以上全部是同机相对量级,绝对数值不可移植,且输入是假设参数而非真实后台数据。

还要注意:这套模型只用到「占比 × 付费率 × ARPPU」三层乘法,它不预测流水。真实的 ARPDAU 还受广告变现(第 10 章)、付费点结构(卷三第 2 章)与留存曲线(下一节)共同影响。本节给的是内购侧的一个下界分量,把它当成整体收入会系统性低估。

整数地板除留下的 1 分钱缺口

结果里有一个容易被忽略、但必须点出来的细节:arpdauFen 是 137,而五层收入贡献相加是 contributionSumFen = 136,两者差 1 分(roundingGapFen = 1)。

这不是 bug,而是整数地板除的必然结果。总体 ARPDAU 用一次除法 rev_weight // 100000 算出,得到 137;而每层贡献各自先做一次 // 100000 再相加,五次的截断误差累积,得到 136。先除后加 ≠ 先加后除,在整数运算里这两者可以差 1 个最小单位。

这个 1 分钱本身不重要,重要的是它提醒一件事:当你在报表里看到「分项之和 ≠ 合计」时,先别急着找数据 bug——先确认两侧是不是用了不同的取整口径。整套书坚持「整数地板除」,就是为了让这类缺口可预期、可复算,而不是让浮点误差藏在第 6 位小数里。第 13 章做收入模型时,同一个缺口会以「分层收入之和 vs 总体 ARPDAU」的形式再次出现。

分层只到画像为止

必须再强调一次边界:本节产出的是意愿分层与人群画像,不是付费点方案。知道了「谁是高意愿人群」,接下来「给他们设计什么付费点、定什么价、放什么位置」是另外两章的事——具体付费点结构在第 10 章,LTV 与付费点的映射建模在卷三第 3 章。本节只保证一件事:当你在后续章节里说「大 R」时,背后有一条由动机切开的五层轴,和一份可复现的分层参数。

画像与意愿回答了「谁在玩、谁愿意花钱」,但它们都是某个时间点的横截面。真实玩家是流动的——同一个人在第 1 天、第 7 天、第 30 天的行为完全不同。下一节就把时间轴加上,讲生命周期阶段与留存结构。

阅读导航:上一节:小游戏用户画像与使用场景 · 下一节:生命周期阶段与留存结构 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

  1. 附录 F:全书公式、参数与数据集索引
  2. 9.3 同意管理与数据出境
  3. 9.2 COPPA 与儿童向游戏的限制