chore: initial commit
This commit is contained in:
342
Docs/Design/55_AnalyticsTelemetrySystem.md
Normal file
342
Docs/Design/55_AnalyticsTelemetrySystem.md
Normal file
@@ -0,0 +1,342 @@
|
||||
# 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<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*
|
||||
Reference in New Issue
Block a user