chore: initial commit

This commit is contained in:
2026-05-08 11:04:00 +08:00
commit f55d2a57c3
6278 changed files with 866081 additions and 0 deletions

View 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 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*