# 55 · 遥测与分析系统(Analytics & Telemetry System) > **命名空间** `BaseGames.Analytics` > **所属文档集** [← 返回索引](./README.md) · [总览](./00_Overview.md) > **依赖** `BaseGames.Core.Events` · `BaseGames.World`(SaveManager)· `BaseGames.Player` > **外部 SDK** Unity Analytics(可选)/ 自建轻量后端(推荐,见 §3) > **关联** 46_PlatformIntegration · 56_CrashRecoverySystem · 29_DifficultyModesGuide --- ## 目录 1. [系统设计原则](#1-系统设计原则) 2. [隐私合规基础](#2-隐私合规基础) 3. [数据收集架构选型](#3-数据收集架构选型) 4. [事件目录(Event Catalog)](#4-事件目录event-catalog) 5. [AnalyticsManager](#5-analyticsmanager) 6. [热力图数据收集](#6-热力图数据收集) 7. [难度调整数据分析](#7-难度调整数据分析) 8. [留存分析关键节点](#8-留存分析关键节点) 9. [数据看板设计](#9-数据看板设计) 10. [本地 Session 日志(离线分析)](#10-本地-session-日志离线分析) 11. [编辑器友好设计](#11-编辑器友好设计) --- ## 1. 系统设计原则 | 原则 | 含义 | |------|------| | **最小化数据** | 只收集决策所需的数据,不收集任何个人身份信息 | | **玩家知情同意** | 游戏首次启动弹出遥测许可弹窗,玩家可选择退出 | | **本地优先** | 即使玩家拒绝联网数据上传,本地 Session 日志仍然工作(供开发者内部分析)| | **不影响游戏逻辑** | 遥测调用异步,失败静默忽略,永不阻塞主线程 | | **可关闭** | `IAnalyticsBackend.IsEnabled` 可运行时切换,不需要重启 | --- ## 2. 隐私合规基础 ### 2.1 必须遵守的法规 | 法规 | 适用地区 | 关键要求 | |------|---------|---------| | **GDPR** | 欧盟/欧洲 | 明确同意、可撤回同意、数据删除权 | | **CCPA** | 美国加州 | 不出售个人信息、知情权 | | **COPPA** | 美国(13岁以下)| 不向儿童收集数据,游戏无年龄门控时必须完全匿名化 | | **PIPA/PIPL** | 日本/中国 | 目的明确、最小化原则 | ### 2.2 数据匿名化规则 ```csharp 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 Log(JSON,始终记录) │ │ │ └──→ [游戏结束时] 压缩打包 .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 不收集的数据(显式排除) ```csharp // 禁止出现在事件 payload 中的字段 public static readonly HashSet ForbiddenFields = new() { "steamId", "switchUserId", "playerName", "saveFilePath", "localIpAddress", "macAddress", "hardwareDeviceId" }; ``` --- ## 5. AnalyticsManager ```csharp 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); } /// /// 记录分析事件(线程安全,异步上传,不阻塞调用方) /// public void Track(string eventName, Dictionary 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 props) { return new AnalyticsEvent { EventName = name, AnonymousId = AnonymizationUtil.GetOrCreateAnonymousId(), Timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), GameVersion = Application.version, Platform = Application.platform.ToString(), Properties = props ?? new Dictionary() }; } } } ``` ### 调用示例(在各系统中集成) ```csharp // 玩家死亡时(在 PlayerController 的死亡流程中) AnalyticsManager.Instance?.Track("player_death", new Dictionary { ["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_end`(sessionDuration < 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 结束后: ```bash # 提取所有死亡事件,按房间统计 cat session_*.jsonl | jq 'select(.event == "player_death") | .sceneAddress' | sort | uniq -c | sort -rn ``` --- ## 11. 编辑器友好设计 - **AnalyticsConfigSO** Inspector:`trackInEditor` 开关(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*