343 lines
13 KiB
Markdown
343 lines
13 KiB
Markdown
# 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*
|