13 KiB
13 KiB
55 · 遥测与分析系统(Analytics & Telemetry System)
命名空间
BaseGames.Analytics
所属文档集 ← 返回索引 · 总览
依赖BaseGames.Core.Events·BaseGames.World(SaveManager)·BaseGames.Player
外部 SDK Unity Analytics(可选)/ 自建轻量后端(推荐,见 §3)
关联 46_PlatformIntegration · 56_CrashRecoverySystem · 29_DifficultyModesGuide
目录
- 系统设计原则
- 隐私合规基础
- 数据收集架构选型
- 事件目录(Event Catalog)
- AnalyticsManager
- 热力图数据收集
- 难度调整数据分析
- 留存分析关键节点
- 数据看板设计
- 本地 Session 日志(离线分析)
- 编辑器友好设计
1. 系统设计原则
| 原则 | 含义 |
|---|---|
| 最小化数据 | 只收集决策所需的数据,不收集任何个人身份信息 |
| 玩家知情同意 | 游戏首次启动弹出遥测许可弹窗,玩家可选择退出 |
| 本地优先 | 即使玩家拒绝联网数据上传,本地 Session 日志仍然工作(供开发者内部分析) |
| 不影响游戏逻辑 | 遥测调用异步,失败静默忽略,永不阻塞主线程 |
| 可关闭 | IAnalyticsBackend.IsEnabled 可运行时切换,不需要重启 |
2. 隐私合规基础
2.1 必须遵守的法规
| 法规 | 适用地区 | 关键要求 |
|---|---|---|
| GDPR | 欧盟/欧洲 | 明确同意、可撤回同意、数据删除权 |
| CCPA | 美国加州 | 不出售个人信息、知情权 |
| COPPA | 美国(13岁以下) | 不向儿童收集数据,游戏无年龄门控时必须完全匿名化 |
| PIPA/PIPL | 日本/中国 | 目的明确、最小化原则 |
2.2 数据匿名化规则
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 不收集的数据(显式排除)
// 禁止出现在事件 payload 中的字段
public static readonly HashSet<string> ForbiddenFields = new()
{
"steamId", "switchUserId", "playerName", "saveFilePath",
"localIpAddress", "macAddress", "hardwareDeviceId"
};
5. AnalyticsManager
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>()
};
}
}
}
调用示例(在各系统中集成)
// 玩家死亡时(在 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 字段在后端聚合,生成各房间的死亡密度图。
使用流程:
- 后端按
sceneAddress分组,统计position分布 - 将坐标映射到房间尺寸,生成热力图 PNG
- 叠加到关卡设计截图,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 结束后:
# 提取所有死亡事件,按房间统计
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