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

343 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 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 不收集的数据(显式排除)
```csharp
// 禁止出现在事件 payload 中的字段
public static readonly HashSet<string> 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);
}
/// <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>()
};
}
}
}
```
### 调用示例(在各系统中集成)
```csharp
// 玩家死亡时(在 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_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*