5.1 小游戏用户画像与使用场景

把「谁在玩」从一句口号拆成一张可仿真的画像表:定义《合成小岛》五类示例画像(通勤碎片族 / 睡前放松族 / 排队等待族 / 亲子陪伴族 / 深度成就族),给出各自的占比、日打开次数、单次时长、D1 留存与人均日广告次数,再用千分比占比加权出一组总体指标,产出 JSON + CSV + SVG,全程整数地板除,不含浮点。

第 4 章讲完了平台约束,从这一章开始进入「人」:小游戏到底是谁在玩、什么时候玩、一次玩多久。先把分工讲清楚——站内那篇广告变现的清单式短文 列的是「放哪些广告位、频次怎么控」的经验条目;本章不重复那张清单,给的是用户动机与付费意愿的可量化分层模型,本节先落地第一层:画像与使用场景。

画像这件事很容易写空。一份「25–35 岁、喜欢休闲游戏、碎片时间玩」的文档,读起来像那么回事,却指导不了任何设计决策:它既不能回答「首包该做多大」,也不能回答「第一次广告位放第几关」。本节要做的,是把画像从形容词换成一组可加权的数字,让「通勤时玩」这种描述变成「日均打开 4 次、单次 6 分钟、D1 留存 46%」这样的可计算量。

画像不是标签,是一组可加权的数字

小游戏没有端游那样长的会话时长,玩家与游戏的关系高度依赖碎片场景:地铁上两站路、睡前刷手机、排队等一杯奶茶。同一个玩家在不同场景下的行为差异,往往比不同玩家之间的差异还大。所以本节不谈「用户是谁」,只谈「用户处在什么场景、在这个场景下会怎么玩」。

每个画像给五个整数维度:

  • 占比(千分比)——这类场景在整体活跃里的份额。
  • 日打开次数——一天进来几次。
  • 单次时长(分钟)——每次停留多久。
  • D1 留存(整数百分比)——次日还回来的比例。
  • 人均单次广告次数——一次会话里能自然承接的广告观看量。

这五个数都是示例假设,不是任何产品的后台还原。它们的价值在于:一旦写成数字,就能加权、能比较、能改参重跑,而不是停在形容词层面。

五个示例画像

《合成小岛》是「合成 + 放置 + 关卡 + 装扮」的轻度小游戏,它面对的场景谱比纯消除类更宽。下面这张表给出五类示例画像,覆盖从最短碎片到最长沉浸的整条谱:

画像id占比‰日打开单次分钟D1%单次广告
通勤碎片族commute32046463
睡前放松族bedtime260215412
排队等待族queue18034524
亲子陪伴族family140111381
深度成就族deep100522632

这张表里的每一列都值得读出设计含义。通勤碎片族单次只有 6 分钟,却一天打开 4 次——它决定了核心循环必须能在 6 分钟内跑完一整圈。睡前放松族打开次数少但单次长,它才是放置收益与合成深度的主要消费者。排队等待族单次最短、广告位最多,是 IAA 变现的基本盘。亲子陪伴族打开最少、广告最少,对中断最敏感。深度成就族人数最少却单次时长最高,是长线内容与关卡难度的锚点。

需要强调:占比、时长、留存全部是设计假设(X),来自对公开可观察的小游戏使用习惯的归纳,而非任何后台数据。本节的可复现部分,是「给定这张假设表,加权后的总体指标是多少」;「真实玩家的分布是多少」需要真实业务环境,命令行不可复现。

为什么占比用千分比而不是百分比

一个细节:本节的占比用千分比整数(320 / 260 / 180 / 140 / 100),而不是百分比。原因是小游戏的人群结构里,尾部画像的份额往往小到个位数百分比——比如深度成就族只占 10%。如果占比只用整数百分比,10% 就是最小的可表达粒度,再往下(比如 5% 的某个细分)就只能写 0 或 1,精度不够。

千分比把粒度提高一个数量级,同时保持整数,不引入浮点。这与整套书「config 里禁浮点、跨语言逐位一致」的纪律是一致的:占比、时长、留存全部是整数,Python 与 TypeScript 两侧才能跑到逐位一致的结果。等到第 13 章做收入模型时,同一套千分比占比会再次出现,用来把画像折算成 DAU 结构。

D 层:把画像写进配置

下面这份是 D 层配置。它只做一件事:把上表的五个画像写成整数参数,占比用千分比,避免浮点。它是后面 S 层仿真的唯一输入。

{
  "version": 1,
  "note": "示例画像:占比用千分比整数;所有数值均为假设参数,真实分布需真实业务环境,命令行不可复现(X)",
  "personas": [
    { "id": "commute", "name": "通勤碎片族", "sharePermille": 320, "sessionsPerDay": 4, "minutesPerSession": 6,  "d1RetentionPct": 46, "adViewsPerSession": 3 },
    { "id": "bedtime", "name": "睡前放松族", "sharePermille": 260, "sessionsPerDay": 2, "minutesPerSession": 15, "d1RetentionPct": 41, "adViewsPerSession": 2 },
    { "id": "queue",   "name": "排队等待族", "sharePermille": 180, "sessionsPerDay": 3, "minutesPerSession": 4,  "d1RetentionPct": 52, "adViewsPerSession": 4 },
    { "id": "family",  "name": "亲子陪伴族", "sharePermille": 140, "sessionsPerDay": 1, "minutesPerSession": 11, "d1RetentionPct": 38, "adViewsPerSession": 1 },
    { "id": "deep",    "name": "深度成就族", "sharePermille": 100, "sessionsPerDay": 5, "minutesPerSession": 22, "d1RetentionPct": 63, "adViewsPerSession": 2 }
  ]
}

配置里没有浮点:占比、次数、时长、留存、广告数全是整数。千分比占比之和恰好 1000,这是后面加权公式能成立的前提——加权均值 = Σ(占比‰ × 指标) / 1000。

S 层:按占比加权成总体指标

下面这段是 S 层仿真脚本。它读上面那份配置,把每个画像的指标按占比加权,算出五个总体指标,并写出 JSON、CSV 与一张柱状图 SVG。全程整数地板除,没有任何浮点。

#!/usr/bin/env python3
# file: sim/ch05_personas.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/ch05_personas.py --config config/ --out out/
"""画像加权仿真:读 config/ch05_personas.json 的示例画像表,
按千分比占比把「人均日时长 / 日打开次数 / D1 留存 / 人均日广告次数」
加权成一组总体指标,产出 JSON + CSV + SVG。全程整数地板除,无浮点。

注意:画像与占比均为假设参数,真实分布需真实业务环境,命令行不可复现(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 weighted_permille(rows, share_key, value_key):
    """Σ(share‰ × value),因 Σshare‰=1000,结果即加权均值的千分比(value×1000)。"""
    return sum(r[share_key] * r[value_key] for r in rows)


def daily_minutes_permille(r):
    """人均日时长(分钟)的千分比:sessionsPerDay * minutesPerSession。"""
    return r["sessionsPerDay"] * r["minutesPerSession"]


def analyze(cfg):
    rows = cfg["personas"]
    share_sum = sum(r["sharePermille"] for r in rows)
    per_persona = []
    for r in rows:
        per_persona.append(
            {
                "id": r["id"],
                "name": r["name"],
                "sharePermille": r["sharePermille"],
                "sessionsPerDay": r["sessionsPerDay"],
                "minutesPerSession": r["minutesPerSession"],
                "dailyMinutes": daily_minutes_permille(r),
                "d1RetentionPct": r["d1RetentionPct"],
                "adViewsPerSession": r["adViewsPerSession"],
                "adViewsPerDay": r["sessionsPerDay"] * r["adViewsPerSession"],
            }
        )
    totals = {
        "shareSumPermille": share_sum,
        "weightedSessionsPerDayPermille": weighted_permille(rows, "sharePermille", "sessionsPerDay"),
        "weightedMinutesPerSessionPermille": weighted_permille(rows, "sharePermille", "minutesPerSession"),
        "weightedD1RetentionPctPermille": weighted_permille(rows, "sharePermille", "d1RetentionPct"),
        "weightedAdViewsPerSessionPermille": weighted_permille(rows, "sharePermille", "adViewsPerSession"),
        "weightedDailyMinutesPermille": sum(p["sharePermille"] * p["dailyMinutes"] for p in per_persona),
    }
    return {"version": 1, "personas": per_persona, "totals": totals}


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["dailyMinutes"] 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": "#2563eb"})
        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['sharePermille']}‰"
    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_personas.json")
    res = analyze(cfg)

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

    with open(os.path.join(args.out, "ch05_personas.csv"), "w", encoding="utf-8") as f:
        f.write("id,name,sharePermille,dailyMinutes,d1RetentionPct,adViewsPerDay\n")
        for r in res["personas"]:
            f.write(
                f"{r['id']},{r['name']},{r['sharePermille']},{r['dailyMinutes']},"
                f"{r['d1RetentionPct']},{r['adViewsPerDay']}\n"
            )

    size = write_svg(os.path.join(args.out, "ch05_personas.svg"), res["personas"], "《合成小岛》示例画像人均日时长(分钟)")
    print(json.dumps(res["totals"], ensure_ascii=False))
    print(f"# svg bytes={size}")


if __name__ == "__main__":
    main()

脚本除 JSON 外还写出 out/ch05_personas.csv(每个画像的日时长、D1、日广告次数)和 out/ch05_personas.svg(人均日时长柱状图,柱下标出占比)。运行命令:

/opt/homebrew/bin/python3 sim/ch05_personas.py --config config/ --out out/
{"shareSumPermille": 1000, "weightedSessionsPerDayPermille": 2980, "weightedMinutesPerSessionPermille": 10280, "weightedD1RetentionPctPermille": 46360, "weightedAdViewsPerSessionPermille": 2540, "weightedDailyMinutesPermille": 30180}
# svg bytes=1951

读结果:加权后的总体画像

加权指标以千分比输出,除以 1000 就是日常读法。本机(Python 3.14.6)跑出来的结果是:

总体指标千分比日常读法
人均日打开次数29802.98 次/日
人均单次时长1028010.28 分/次
加权 D1 留存4636046.36%
人均单次广告次数25402.54 次/次
人均日时长3018030.18 分/日

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

  • 人均日时长约 30 分钟,但它是被长尾拉起来的:深度成就族只占 100‰,日时长却高达 110 分钟;而占 320‰ 的通勤碎片族只有 24 分钟。这说明「人均 30 分钟」这个数字对设计几乎没有指导意义——真正决定内容深度的是那 100‰ 的重度用户,真正决定循环时长的是那 320‰ 的碎片用户。
  • 打开次数与单次时长此消彼长:通勤族一天 4 次、每次 6 分钟,睡前族一天 2 次、每次 15 分钟,两者日时长接近(24 对 30 分钟),但对设计的要求完全相反。前者要求循环极短、可随时中断,后者要求有可沉淀的放置收益。
  • 留存与沉浸度正相关:D1 从亲子族的 38% 一路升到深度成就族的 63%,加权后 46.36%。这不是巧合——越沉浸的画像,次日回来的动机越强。它给出一条校准线:当你的加权 D1 明显偏离 46% 时,多半是画像结构变了,而不只是「游戏变好了」。

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

场景反向决定了哪些设计约束

画像的价值不在画像本身,而在它反向约束了后面的设计。这张假设表至少给出三条硬约束:

  1. 核心循环必须能在 6 分钟内跑完——否则占 320‰ 的通勤碎片族根本进不来。这条约束会在第 6 章「核心循环」里被再次量化。
  2. 首包必须够小、冷启动必须够快——碎片场景下玩家在地铁里点开,等 5 秒就会退出。这条在第 4 章已经算过预算,画像只是给出了「为什么是 5 秒而不是 30 秒」的用户侧理由。
  3. 广告位要能承接「高频短会话」而不是「低频长会话」——排队族一天 3 次、每次看 4 个广告,通勤族一天 4 次、每次 3 个;把广告位设计成「看完一次长视频才给奖励」会直接劝退这两类人。

画像只到假设为止

必须把边界画清楚:本节产出的是一张可加权的假设画像表,不是真实用户数据。它有三个明确的不可复现点(X):真实的占比分布、真实的时长与留存、真实的广告承接量,都需要真实业务环境,命令行不可复现。本节能复现的只有一件事:给定这张表,加权指标是确定的一组整数。

下一节把这批画像再切一刀——同样是「玩」,有人图放松、有人图成就、有人图社交,动机不同,掏钱的意愿也完全不同。那一条切分线,才是付费意愿分层。

阅读导航:上一节:平台能力矩阵与审核红线 · 下一节:动机模型与付费意愿分层 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

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