Files
zeling_v2/Docs/Design/55_AnalyticsTelemetrySystem.md
2026-05-08 11:04:00 +08:00

13 KiB
Raw Permalink Blame History

55 · 遥测与分析系统Analytics & Telemetry System

命名空间 BaseGames.Analytics
所属文档集 ← 返回索引 · 总览
依赖 BaseGames.Core.Events · BaseGames.WorldSaveManager· BaseGames.Player
外部 SDK Unity Analytics可选/ 自建轻量后端(推荐,见 §3
关联 46_PlatformIntegration · 56_CrashRecoverySystem · 29_DifficultyModesGuide


目录

  1. 系统设计原则
  2. 隐私合规基础
  3. 数据收集架构选型
  4. 事件目录Event Catalog
  5. AnalyticsManager
  6. 热力图数据收集
  7. 难度调整数据分析
  8. 留存分析关键节点
  9. 数据看板设计
  10. 本地 Session 日志(离线分析)
  11. 编辑器友好设计

1. 系统设计原则

原则 含义
最小化数据 只收集决策所需的数据,不收集任何个人身份信息
玩家知情同意 游戏首次启动弹出遥测许可弹窗,玩家可选择退出
本地优先 即使玩家拒绝联网数据上传,本地 Session 日志仍然工作(供开发者内部分析)
不影响游戏逻辑 遥测调用异步,失败静默忽略,永不阻塞主线程
可关闭 IAnalyticsBackend.IsEnabled 可运行时切换,不需要重启

2. 隐私合规基础

2.1 必须遵守的法规

法规 适用地区 关键要求
GDPR 欧盟/欧洲 明确同意、可撤回同意、数据删除权
CCPA 美国加州 不出售个人信息、知情权
COPPA 美国13岁以下 不向儿童收集数据,游戏无年龄门控时必须完全匿名化
PIPA/PIPL 日本/中国 目的明确、最小化原则

2.2 数据匿名化规则

namespace BaseGames.Analytics
{
    public static class AnonymizationUtil
    {
        // 生成永久匿名 ID不与任何账号绑定存储在本地 PlayerPrefs
        public static string GetOrCreateAnonymousId()
        {
            const string key = "analytics_anon_id";
            if (!PlayerPrefs.HasKey(key))
                PlayerPrefs.SetString(key, Guid.NewGuid().ToString("N"));
            return PlayerPrefs.GetString(key);
        }

        // 数据上传时永远不包含以下字段:
        // - 玩家姓名、Steam ID、Switch 账户 ID
        // - IP 地址(由后端在接收层截断)
        // - 存档文件内容
        // - 购买记录
    }
}

2.3 首次启动同意弹窗

集成到 27_FirstLaunchSequence(见 10_UISystem.md §27。弹窗文案

帮助我们改善游戏体验

我们收集匿名游戏数据(如死亡位置、关卡完成时间),
帮助改善难度平衡和游戏体验。

数据不包含任何个人信息,不会被出售给第三方。
你可以随时在设置菜单中关闭数据分享。

[  同意分享数据  ]     [  不,谢谢  ]

玩家选择保存到 PlayerPrefs["analytics_consent"]AnalyticsManager.IsEnabled 据此决定是否上传。


3. 数据收集架构选型

推荐方案:自建轻量后端 + 本地 JSON 日志双轨

游戏客户端
  │
  ├── AnalyticsManager ──→ 本地 Session LogJSON始终记录
  │                             │
  │                             └──→ [游戏结束时] 压缩打包 .log.gz
  │
  └── (若玩家同意) ──→ EventQueue异步批量上传
                             │
                             └──→ 自建 HTTP 后端(或 Unity Analytics
                                    │
                                    └──→ 数据库ClickHouse / BigQuery
                                           │
                                           └──→ Grafana 看板

为什么不直接用 Unity Analytics

  • Unity Analytics 需要账号绑定GDPR 合规难度较高
  • 自建后端数据归我们所有,查询灵活性更高
  • 独立游戏规模下Hetzner VPS + ClickHouse 月费约 $20足够

备选方案(快速接入)GameAnalytics免费层——若团队不想维护后端这是最快的合规方案。


4. 事件目录Event Catalog

4.1 必须收集P0

事件名 触发时机 关键字段
session_start 游戏进入主菜单 anonymousId, platform, gameVersion, locale
session_end 游戏退出 sessionDuration, sceneOnExit
player_death 玩家死亡 sceneAddress, position, causeOfDeath, deathCount, playerHP, sessionTime
boss_attempt_start 进入 Boss 房间 bossId, playerLevel, equippedCharms
boss_defeated Boss 击败 bossId, attemptCount, timeElapsed, playerHP
ability_unlocked 玩家获得能力 abilityId, sessionTime, regionId
scene_entered 玩家进入新房间 sceneAddress, firstVisit, sessionTime
game_completed 通关结局触发 endingType, completionPercent, totalPlayTime
softlock_detected SoftlockDetector 触发 sceneAddress, stuckDuration, playerAbilities

4.2 推荐收集P1

事件名 触发时机 关键字段
lore_collected Lore 道具拾取 loreId, regionId
shop_purchase 商店购买 itemId, price, playerGeo, regionId
challenge_completed 挑战房间完成 challengeId, grade, attemptCount
sequence_break_suspected 玩家绕过 AbilityGate gateId, regionId, playerAbilities
settings_changed 设置项改变 settingKey, newValue(不记录敏感设置)
difficulty_changed 难度模式切换 from, to, sessionTime
npc_dialogue_viewed NPC 对话播放完成 npcId, dialogueVersionLabel

4.3 不收集的数据(显式排除)

// 禁止出现在事件 payload 中的字段
public static readonly HashSet<string> ForbiddenFields = new()
{
    "steamId", "switchUserId", "playerName", "saveFilePath",
    "localIpAddress", "macAddress", "hardwareDeviceId"
};

5. AnalyticsManager

namespace BaseGames.Analytics
{
    public class AnalyticsManager : MonoBehaviour
    {
        [SerializeField] AnalyticsConfigSO _config;

        public bool IsEnabled =>
            PlayerPrefs.GetInt("analytics_consent", 0) == 1 && _config.enableAnalytics;

        // 单例Persistent Scene 中唯一实例)
        public static AnalyticsManager Instance { get; private set; }

        // 本地日志(不依赖网络,始终运行)
        private SessionLogger _localLogger;

        // 在线上传队列(仅 IsEnabled 时激活)
        private EventQueue   _uploadQueue;

        void Awake()
        {
            if (Instance != null) { Destroy(gameObject); return; }
            Instance = this;
            _localLogger = new SessionLogger();
            if (IsEnabled)
                _uploadQueue = new EventQueue(_config.endpointUrl);
        }

        /// <summary>
        /// 记录分析事件(线程安全,异步上传,不阻塞调用方)
        /// </summary>
        public void Track(string eventName, Dictionary<string, object> properties = null)
        {
            if (Application.isEditor && !_config.trackInEditor) return;

            var payload = BuildPayload(eventName, properties);
            _localLogger.Log(payload);          // 始终写本地
            if (IsEnabled)
                _uploadQueue.Enqueue(payload);  // 异步上传
        }

        private AnalyticsEvent BuildPayload(string name, Dictionary<string, object> props)
        {
            return new AnalyticsEvent
            {
                EventName    = name,
                AnonymousId  = AnonymizationUtil.GetOrCreateAnonymousId(),
                Timestamp    = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
                GameVersion  = Application.version,
                Platform     = Application.platform.ToString(),
                Properties   = props ?? new Dictionary<string, object>()
            };
        }
    }
}

调用示例(在各系统中集成)

// 玩家死亡时(在 PlayerController 的死亡流程中)
AnalyticsManager.Instance?.Track("player_death", new Dictionary<string, object>
{
    ["sceneAddress"]  = SceneManager.GetActiveScene().name,
    ["position"]      = $"{_transform.position.x:F1},{_transform.position.y:F1}",
    ["causeOfDeath"]  = _lastDamageSource,
    ["deathCount"]    = _saveData.TotalDeaths,
    ["sessionTime"]   = Time.realtimeSinceStartup
});

6. 热力图数据收集

6.1 死亡热力图

player_death 事件的 position 字段在后端聚合,生成各房间的死亡密度图。

使用流程

  1. 后端按 sceneAddress 分组,统计 position 分布
  2. 将坐标映射到房间尺寸,生成热力图 PNG
  3. 叠加到关卡设计截图QA 检视高死亡区域

6.2 路线追踪

scene_entered 事件的序列,可还原玩家路线:

session_A: Forest_Main → Forest_SecretCave → Forest_Main → Cave_Entrance → ...

用于发现:

  • 玩家绕路频率(某房间被经过 N 次但不进入)
  • 迷失区域(某条走廊被多次往返)

7. 难度调整数据分析

关键指标与决策阈值:

指标 计算方式 调整触发阈值
Boss 平均尝试次数 boss_defeated.attemptCount 均值 > 15次 = 考虑降低 Boss 血量
区域卡关率 进入该区域后 > 3 天无进度的玩家% > 30% = 关卡设计问题,检查
死亡集中度 某房间死亡数 / 该房间访问数 > 60% = 该房间可能过难
首次10分钟流失 session_endsessionDuration < 600s% > 20% = 新手引导失效
完成度分布 game_completed.completionPercent 直方图 理想:正态分布 60~80%

8. 留存分析关键节点

关键节点 分析目标
第1分钟(游戏是否好玩) 玩家是否跳过开始动画后立即退出
第10分钟(新手引导) 玩家是否完成了基础操作教程
第1次死亡 玩家是否在第一次死亡后继续游戏(留存核心)
Forest Boss 前约30分钟 流失率峰值区域
进入 Cave(第一个大进程节点) 流失率是否显著下降(完成 Forest = 投入度高)
2小时标记 Day 1 留存率核心指标
通关目标约20~30小时 硬核玩家比例

9. 数据看板设计

9.1 开发阶段看板Grafana / Google Sheets

面板 数据来源
日活跃游玩时长 session_end.sessionDuration 均值
Boss 击败率(各 Boss boss_defeated / boss_attempt_start 比率
死亡热力图(房间级别) player_death.position 聚合
前10分钟流失漏斗 session 时间段的玩家数量漏斗
能力获取时间线 ability_unlocked.sessionTime 分布
难度模式分布 difficulty_changed 统计

9.2 发行阶段看板Steam 补充)

Steam 提供:

  • 游玩时长中位数不通过我们的遥测Steam 直接提供)
  • 成就解锁率(通过 Steam API 公开获取)
  • 玩家评价情感分布

10. 本地 Session 日志(离线分析)

即使玩家拒绝联网上传,本地 Session 日志仍帮助 PlayTest 分析:

路径:{Application.persistentDataPath}/Analytics/session_YYYY-MM-DD_HH-mm.jsonl
格式:每行一个 JSON 事件JSONL 格式,便于流式处理)
保留:最近 10 次 Session自动轮转删除旧文件

开发者可在 PlayTest 结束后:

# 提取所有死亡事件,按房间统计
cat session_*.jsonl | jq 'select(.event == "player_death") | .sceneAddress' | sort | uniq -c | sort -rn

11. 编辑器友好设计

  • AnalyticsConfigSO InspectortrackInEditor 开关PlayMode 中是否发送事件到本地日志)
  • Analytics Debug Overlay:在 Debug 模式下,屏幕右上角显示本 Session 已记录的事件计数
  • Event Inspector 工具EditorWindow 读取最新 session JSONL 文件,以表格展示所有事件(用于 PlayTest 后快速检视)
  • 隐私审计检查器CI 中运行静态分析,检查所有 Track() 调用的 payload确保不包含 ForbiddenFields 中的字段

本文档版本 1.0 · 2026-04 · 关联 46_PlatformIntegration / 56_CrashRecoverySystem / 29_DifficultyModesGuide