一些检测仍在等待运行
Docs Build / build-and-deploy (push) Waiting to run
- 移除 ConfigManager 配置管理器类 - 移除 GameManager 全局单例管理器类 - 移除 NetworkManager 网络连接管理器类 - 移除 CharacterData 和 ItemData 数据模型类 - 移除 BagScene、BattleScene、LobbyScene 等场景脚本 - 移除 EncounterBubble 和 EventFeedPanel UI组件脚本 - 更新代理邀请文档中的服务器连接方式 - 更新同步状态表格中的代理任务分配信息 - 添加 MiMo 任务完成总结和审查修复记录
1317 行
52 KiB
Markdown
1317 行
52 KiB
Markdown
# TDD-03 客户端架构设计
|
||
|
||
> **文档类型**:技术设计方案(Technical Design Document)
|
||
> **版本**:v1.1
|
||
> **日期**:2026-07-06
|
||
> **关联文档**:TDD-02《客户端热更新技术方案》、TDD-04《数据库表结构设计》、TDD-05《API接口设计》、TDD-06《离线挂机结算系统设计》、TDD-07《反作弊与安全设计》、GDD-03《战斗系统设计》、GDD-06《经济系统设计》、GDD-23《能量体系与功法相性设计》
|
||
|
||
---
|
||
|
||
## 1. 文档信息
|
||
|
||
| 项目 | 说明 |
|
||
|------|------|
|
||
| 目标 | 为《洪荒大陆》挂机手游定义 Cocos Creator 3.x 客户端架构方案,覆盖场景管理、状态管理、网络层、文字战报渲染、UI 框架、多端适配、热更新集成与性能优化。 |
|
||
| 读者 | 客户端开发、技术负责人、QA |
|
||
| 技术栈 | Cocos Creator 3.8.x + TypeScript + protobufjs |
|
||
| 核心约束 | 无任务系统、无赛季重置、概率/机遇驱动、文字战报(GDD-02 ✅1)、ATB 行动条、功法加持、能量体系(非体力)、服务端权威(TDD-07 §2) |
|
||
| 游戏时间 | 现实:游戏 = 1:3 |
|
||
|
||
---
|
||
|
||
## 2. Cocos Creator 3.x 项目架构
|
||
|
||
### 2.1 项目目录结构
|
||
|
||
```
|
||
assets/
|
||
├── bundles/ # Asset Bundle 分包(热更粒度)
|
||
│ ├── core/ # 核心 Bundle:启动器、网络、状态管理、基础工具
|
||
│ │ ├── scripts/
|
||
│ │ │ ├── launcher/ # 启动场景脚本
|
||
│ │ │ ├── net/ # 网络层(WebSocket/HTTP/gRPC)
|
||
│ │ │ ├── state/ # 状态管理(角色缓存、离线数据)
|
||
│ │ │ ├── config/ # 配置加载器(Nacos + 本地 JSON)
|
||
│ │ │ └── utils/ # 工具类(加密、压缩、时间换算)
|
||
│ │ └── prefabs/ # 核心 UI 基座(Loading、Toast、遮罩)
|
||
│ ├── ui/ # UI Bundle:各系统界面
|
||
│ │ ├── login/ # 登录/创角
|
||
│ │ ├── lobby/ # 大厅/主界面
|
||
│ │ ├── map/ # 地图/游历/副本
|
||
│ │ ├── battle/ # 战斗/战报
|
||
│ │ ├── bag/ # 背包/装备/功法
|
||
│ │ ├── social/ # 社交/组织/交易行/拍卖
|
||
│ │ ├── setting/ # 设置/账号
|
||
│ │ └── common/ # 公共弹窗、列表组件
|
||
│ ├── atlases/ # 图集 Bundle:按功能分包
|
||
│ │ ├── race_portraits/ # 种族头像(19 种族 × N 境界)
|
||
│ │ ├── map_tiles/ # 地图地块
|
||
│ │ ├── skill_icons/ # 技能/功法图标
|
||
│ │ └── ui_common/ # 通用 UI 元素
|
||
│ ├── audio/ # 音频 Bundle
|
||
│ │ ├── bgm/ # 背景音乐(按世界层级分)
|
||
│ │ └── sfx/ # 音效(战斗/事件/UI)
|
||
│ ├── configs/ # 配置 Bundle(JSON/Excel 导出)
|
||
│ │ ├── race_config.json # 种族配置
|
||
│ │ ├── realm_config.json # 境界配置
|
||
│ │ ├── skill_pool.json # 技能池
|
||
│ │ ├── event_pool.json # 事件池
|
||
│ │ └── battle_text/ # 战报文案模板
|
||
│ └── i18n/ # 国际化 Bundle
|
||
│ ├── zh-CN.json
|
||
│ └── en-US.json
|
||
├── scenes/ # 场景文件
|
||
│ ├── LaunchScene.scene # 启动场景(首包,不可热更)
|
||
│ ├── LoginScene.scene # 登录场景
|
||
│ ├── LobbyScene.scene # 大厅场景
|
||
│ ├── MapScene.scene # 地图场景
|
||
│ ├── BattleScene.scene # 战斗场景
|
||
│ ├── BagScene.scene # 背包场景
|
||
│ ├── SocialScene.scene # 社交场景
|
||
│ └── SettingScene.scene # 设置场景
|
||
├── scripts/ # 首包脚本(不可热更)
|
||
│ ├── AppEntry.ts # 应用入口
|
||
│ ├── HotfixBoot.ts # 热更新引导
|
||
│ └── NativeBridge.ts # 原生桥接(支付/推送)
|
||
└── resources/ # 首包内置资源(不可热更)
|
||
├── launch/ # 启动图/图标
|
||
└── builtin/ # 引擎内置 shader/font
|
||
```
|
||
|
||
### 2.2 场景生命周期与切换策略
|
||
|
||
| 场景 | 职责 | 加载时机 | 常驻内存 | 依赖 Bundle |
|
||
|------|------|----------|----------|-------------|
|
||
| LaunchScene | 启动器:热更检查、版本校验、资源下载进度 | 首包启动 | 否(检查完毕后卸载) | core(首包内置) |
|
||
| LoginScene | 登册/登录/创角/选服 | LaunchScene 完成后 | 否 | core + ui/login |
|
||
| LobbyScene | 主界面:角色状态、修炼进度、事件入口、社交入口 | 登录成功后 | **是**(主场景常驻) | core + ui/lobby + atlases |
|
||
| MapScene | 地图/游历/副本/区域事件 | 进入地图时 | 否(切换回 Lobby 时卸载) | core + ui/map + atlases/map_tiles |
|
||
| BattleScene | 战报展示/ATB 行动条/战斗结果 | 进入战斗时 | 否 | core + ui/battle + configs/battle_text |
|
||
| BagScene | 背包/装备/功法/技能/玉简 | 打开背包时 | 否 | core + ui/bag |
|
||
| SocialScene | 社交/组织/交易行/拍卖/佣兵/悬赏 | 打开社交时 | 否 | core + ui/social |
|
||
| SettingScene | 设置/账号/热更修复/客服 | 打开设置时 | 否 | core + ui/setting |
|
||
|
||
**场景切换流程**:
|
||
|
||
```
|
||
LaunchScene
|
||
│ 热更检查通过
|
||
▼
|
||
LoginScene
|
||
│ 登录成功 + 角色数据加载完毕
|
||
▼
|
||
LobbyScene(常驻)
|
||
│
|
||
├──► MapScene(压入栈,Lobby 隐藏但不卸载)
|
||
│ │ 返回
|
||
│ ▼
|
||
│ LobbyScene 恢复显示
|
||
│
|
||
├──► BattleScene(压入栈)
|
||
│ │ 战斗结束
|
||
│ ▼
|
||
│ 回到前一场景(MapScene 或 LobbyScene)
|
||
│
|
||
├──► BagScene(覆盖层,不替换当前场景)
|
||
│
|
||
├──► SocialScene(覆盖层)
|
||
│
|
||
└──► SettingScene(覆盖层)
|
||
```
|
||
|
||
**切换策略**:
|
||
|
||
- **栈式管理**:LobbyScene 为栈底常驻;MapScene/BattleScene 为栈式压入,返回时弹出。
|
||
- **覆盖层**:BagScene/SocialScene/SettingScene 以全屏遮罩形式覆盖在当前场景之上,关闭时销毁。
|
||
- **预加载**:进入 LobbyScene 后,后台预加载 ui/bag、ui/social 等高频 Bundle,减少后续打开延迟。
|
||
- **内存回收**:场景弹出后,调用 `assetManager.releaseUnusedAssets()` 释放该场景独占资源。
|
||
|
||
### 2.3 资源管理方案(Asset Bundle 切分)
|
||
|
||
| Bundle | 粒度 | 热更频率 | 预估大小 | 加载策略 |
|
||
|--------|------|----------|----------|----------|
|
||
| core | 首包内置 | 仅随整包更新 | ~2MB | 启动即加载 |
|
||
| ui/login | 按场景 | 低 | ~1MB | 登录前按需加载 |
|
||
| ui/lobby | 按场景 | 中 | ~3MB | 登录后加载并常驻 |
|
||
| ui/map | 按场景 | 中 | ~2MB | 进入地图时加载 |
|
||
| ui/battle | 按场景 | 中 | ~1.5MB | 进入战斗时加载 |
|
||
| ui/bag | 按场景 | 低 | ~1MB | 预加载 |
|
||
| ui/social | 按场景 | 中 | ~2MB | 预加载 |
|
||
| ui/setting | 按场景 | 低 | ~0.5MB | 按需加载 |
|
||
| atlases/* | 按功能 | 低~中 | 各 1~5MB | 预加载 + 按需 |
|
||
| audio/bgm | 按类型 | 低 | ~10MB | 按世界层级按需加载 |
|
||
| audio/sfx | 按类型 | 中 | ~5MB | 预加载高频音效 |
|
||
| configs/* | 按功能 | 高(数值热更) | 各 0.1~1MB | 启动后加载 + Nacos 运行时覆盖 |
|
||
| i18n | 按语言 | 低 | ~0.5MB | 启动时加载对应语言 |
|
||
|
||
**资源加载优先级**:core > configs > ui/lobby > atlases/ui_common > 其他按需。
|
||
|
||
---
|
||
|
||
## 3. 状态管理方案
|
||
|
||
### 3.1 状态管理架构
|
||
|
||
采用 **单向数据流 + 观察者模式**,不引入第三方状态管理库,保持轻量。
|
||
|
||
```
|
||
┌──────────────────────────────────────────────────────────┐
|
||
│ Store(全局状态树) │
|
||
│ ┌─────────────┐ ┌──────────────┐ ┌─────────────────┐ │
|
||
│ │ CharacterStore│ │ InventoryStore│ │ EconomyStore │ │
|
||
│ │ - state │ │ - items │ │ - currencies │ │
|
||
│ │ - skills │ │ - equipment │ │ - market_orders │ │
|
||
│ │ - manuals │ │ │ │ │ │
|
||
│ └──────┬──────┘ └──────┬───────┘ └────────┬─────────┘ │
|
||
│ │ │ │ │
|
||
│ ▼ ▼ ▼ │
|
||
│ ┌─────────────────────────────────────────────────────┐ │
|
||
│ │ EventEmitter(事件总线) │ │
|
||
│ │ emit('character:updated') / on('inventory:changed') │ │
|
||
│ └─────────────────────────────────────────────────────┘ │
|
||
│ │ │ │ │
|
||
│ ▼ ▼ ▼ │
|
||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
||
│ │ UI Layer │ │ UI Layer │ │ UI Layer │ │
|
||
│ │ (Observer)│ │ (Observer)│ │ (Observer)│ │
|
||
│ └──────────┘ └──────────┘ └──────────┘ │
|
||
└──────────────────────────────────────────────────────────┘
|
||
▲ │
|
||
│ ▼
|
||
┌────────┴───────────────────────────────────────────────┐
|
||
│ Network Layer │
|
||
│ 服务端推送 ──► Store.dispatch(action) ──► UI 自动刷新 │
|
||
└────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
### 3.2 角色数据缓存层
|
||
|
||
```typescript
|
||
// 角色状态缓存结构
|
||
interface CharacterState {
|
||
// 基础信息(来自 TDD-04 characters 表)
|
||
characterId: string;
|
||
name: string;
|
||
raceId: string;
|
||
worldTier: number;
|
||
realmTier: number;
|
||
minorRealm: number;
|
||
realmStatus: 'normal' | 'tribulation_pending' | 'breakthrough_ready';
|
||
level: number;
|
||
exp: bigint;
|
||
|
||
// 六维属性(GDD-02 ✅8)
|
||
baseStats: {
|
||
str: number; // 力
|
||
vit: number; // 体
|
||
wis: number; // 悟
|
||
agi: number; // 速
|
||
spi: number; // 灵(巫族为"血")
|
||
luk: number; // 命
|
||
};
|
||
|
||
// 战斗属性快照
|
||
battleStats: {
|
||
hpMax: number;
|
||
atk: number;
|
||
def: number;
|
||
speed: number;
|
||
};
|
||
|
||
// 状态值
|
||
sanCurrent: number;
|
||
sanMax: number;
|
||
crimeScore: number;
|
||
heavenlyValue: number;
|
||
karmaValue: number;
|
||
reputationScore: number;
|
||
|
||
// 能量(GDD-23 ✅11/✅23)
|
||
energyCurrent: number;
|
||
energyMax: number;
|
||
energyRegenRate: number; // 每现实秒恢复量
|
||
|
||
// 时间戳
|
||
lastOnlineAt: Date;
|
||
dailyResetAt: Date;
|
||
|
||
// 版本号(用于乐观更新回滚判断)
|
||
serverVersion: number;
|
||
localVersion: number;
|
||
}
|
||
```
|
||
|
||
### 3.3 离线数据与在线数据同步策略
|
||
|
||
**数据分层**:
|
||
|
||
| 数据类别 | 离线可用 | 同步策略 | 冲突处理 |
|
||
|----------|----------|----------|----------|
|
||
| 角色基础状态 | 是(本地快照) | 上线时全量拉取 | 服务端覆盖本地 |
|
||
| 背包/装备 | 是(本地快照) | 上线时增量同步 | 服务端覆盖本地 |
|
||
| 货币余额 | 否(仅展示上次值) | 每次操作实时查询 | 服务端权威 |
|
||
| 境界/修为 | 是(本地快照) | 上线时全量拉取 | 服务端覆盖 |
|
||
| 市场/拍卖 | 否 | 实时查询 | 服务端权威 |
|
||
| 社交关系 | 是(本地快照) | 上线时增量同步 | 服务端覆盖 |
|
||
| 配置参数(Nacos) | 是(本地缓存) | 登录后长轮询更新 | 服务端推送覆盖 |
|
||
|
||
**同步流程**:
|
||
|
||
```
|
||
玩家上线
|
||
│
|
||
▼
|
||
拉取离线结算报告(TDD-06 SettlementReport)
|
||
│
|
||
▼
|
||
全量拉取角色状态 GET /api/v1/characters/{id}
|
||
│
|
||
▼
|
||
增量同步背包/技能/功法(对比本地 serverVersion)
|
||
│
|
||
▼
|
||
建立 WebSocket 长连接,订阅频道
|
||
│
|
||
▼
|
||
启动 Nacos 配置长轮询
|
||
│
|
||
▼
|
||
数据就绪,渲染主界面
|
||
```
|
||
|
||
### 3.4 乐观更新与服务端权威回滚
|
||
|
||
对于玩家主动操作(突破、挂单、学习技能等),客户端采用**乐观更新**策略提升响应速度:
|
||
|
||
```typescript
|
||
// 乐观更新流程
|
||
async function optimisticUpdate<T>(
|
||
action: () => Promise<T>, // 服务端请求
|
||
localApply: () => void, // 本地立即应用
|
||
rollback: () => void, // 回滚函数
|
||
showToast: (msg: string) => void // 错误提示
|
||
): Promise<T | null> {
|
||
// 1. 乐观应用本地状态
|
||
localApply();
|
||
|
||
try {
|
||
// 2. 发送服务端请求
|
||
const result = await action();
|
||
|
||
// 3. 服务端确认,用服务端数据覆盖本地
|
||
Store.dispatch('SYNC_FROM_SERVER', result);
|
||
return result;
|
||
} catch (error) {
|
||
// 4. 服务端拒绝,回滚本地状态
|
||
rollback();
|
||
showToast(error.message || '操作失败,请重试');
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// 使用示例:突破
|
||
async function attemptBreakthrough(targetMinorRealm: number) {
|
||
await optimisticUpdate(
|
||
() => api.realm.breakthrough(characterId, {
|
||
target_minor_realm: targetMinorRealm,
|
||
idempotency_key: generateIdempotencyKey(),
|
||
}),
|
||
() => {
|
||
// 乐观:显示突破中动画
|
||
ui.showBreakthroughAnimation();
|
||
},
|
||
() => {
|
||
// 回滚:关闭动画,恢复原状态
|
||
ui.hideBreakthroughAnimation();
|
||
Store.dispatch('RELOAD_CHARACTER');
|
||
},
|
||
(msg) => ui.showToast(msg)
|
||
);
|
||
}
|
||
```
|
||
|
||
**回滚触发条件**:
|
||
|
||
| 条件 | 处理 |
|
||
|------|------|
|
||
| 服务端返回错误码(非 0) | 回滚本地状态,显示错误信息 |
|
||
| 网络超时(>10s) | 回滚本地状态,提示网络异常 |
|
||
| 服务端返回的数据与本地不一致 | 以服务端数据为准覆盖本地 |
|
||
| WebSocket 推送了状态变更通知 | 触发全量刷新对应模块 |
|
||
|
||
---
|
||
|
||
## 4. 网络层设计
|
||
|
||
### 4.1 网络层架构
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────┐
|
||
│ NetworkManager │
|
||
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │
|
||
│ │ WebSocketClient│ │ HttpClient │ │ GrpcWebClient │ │
|
||
│ │ (Nakama实时) │ │ (RESTful) │ │ (可选,低延迟战斗)│ │
|
||
│ └───────┬──────┘ └───────┬──────┘ └────────┬─────────┘ │
|
||
│ │ │ │ │
|
||
│ ▼ ▼ ▼ │
|
||
│ ┌─────────────────────────────────────────────────────┐ │
|
||
│ │ RequestQueue & RetryManager │ │
|
||
│ │ - 请求排队(防止并发冲突) │ │
|
||
│ │ - 自动重试(指数退避) │ │
|
||
│ │ - 断线重连(指数退避 + 心跳检测) │ │
|
||
│ │ - 请求超时控制 │ │
|
||
│ └─────────────────────────────────────────────────────┘ │
|
||
└─────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
### 4.2 WebSocket 长连接(Nakama 实时 API)
|
||
|
||
```typescript
|
||
// WebSocket 客户端封装
|
||
class NakamaWsClient {
|
||
private ws: WebSocket | null = null;
|
||
private heartbeatTimer: number = 0;
|
||
private reconnectAttempts: number = 0;
|
||
private maxReconnectAttempts: number = 10;
|
||
private reconnectDelay: number = 1000; // 初始 1s,指数退避
|
||
|
||
// 连接地址
|
||
private url: string = 'wss://nakama.honghuang.example.com/ws';
|
||
|
||
// 订阅的频道列表
|
||
private subscriptions: Map<string, (data: any) => void> = new Map();
|
||
|
||
async connect(token: string): Promise<void> {
|
||
this.ws = new WebSocket(`${this.url}?token=${encodeURIComponent(token)}`);
|
||
|
||
this.ws.onopen = () => {
|
||
console.log('[WS] Connected');
|
||
this.reconnectAttempts = 0;
|
||
this.startHeartbeat();
|
||
this.resubscribeAll();
|
||
};
|
||
|
||
this.ws.onmessage = (event) => {
|
||
const msg = JSON.parse(event.data);
|
||
this.handleMessage(msg);
|
||
};
|
||
|
||
this.ws.onclose = (event) => {
|
||
console.log('[WS] Disconnected:', event.code, event.reason);
|
||
this.stopHeartbeat();
|
||
if (event.code !== 1000) { // 非主动关闭
|
||
this.scheduleReconnect();
|
||
}
|
||
};
|
||
|
||
this.ws.onerror = (error) => {
|
||
console.error('[WS] Error:', error);
|
||
};
|
||
}
|
||
|
||
// 心跳:每 30s 发送一次
|
||
private startHeartbeat(): void {
|
||
this.heartbeatTimer = setInterval(() => {
|
||
if (this.ws?.readyState === WebSocket.OPEN) {
|
||
this.ws.send(JSON.stringify({ type: 'heartbeat' }));
|
||
}
|
||
}, 30000);
|
||
}
|
||
|
||
// 断线重连:指数退避
|
||
private scheduleReconnect(): void {
|
||
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
||
console.error('[WS] Max reconnect attempts reached');
|
||
EventEmitter.emit('ws:permanent_disconnect');
|
||
return;
|
||
}
|
||
|
||
const delay = Math.min(
|
||
this.reconnectDelay * Math.pow(2, this.reconnectAttempts),
|
||
30000 // 最大 30s
|
||
);
|
||
this.reconnectAttempts++;
|
||
|
||
console.log(`[WS] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})`);
|
||
setTimeout(() => this.connect(Store.get('sessionToken')), delay);
|
||
}
|
||
|
||
// 频道订阅
|
||
subscribe(channel: string, callback: (data: any) => void): void {
|
||
this.subscriptions.set(channel, callback);
|
||
if (this.ws?.readyState === WebSocket.OPEN) {
|
||
this.ws.send(JSON.stringify({
|
||
type: 'subscribe',
|
||
channel: channel,
|
||
}));
|
||
}
|
||
}
|
||
|
||
// 处理服务端推送消息(对齐 TDD-05 §4.2)
|
||
private handleMessage(msg: any): void {
|
||
const { channel, event_type, payload } = msg;
|
||
|
||
// 分发到对应频道的回调
|
||
const callback = this.subscriptions.get(channel);
|
||
if (callback) {
|
||
callback(payload);
|
||
}
|
||
|
||
// 全局事件分发
|
||
EventEmitter.emit(`ws:${event_type}`, payload);
|
||
}
|
||
}
|
||
```
|
||
|
||
**频道订阅对照表**(对齐 TDD-05 §4.1):
|
||
|
||
| 频道 | 客户端处理 | UI 刷新 |
|
||
|------|-----------|---------|
|
||
| `region:{world_tier}:{region_id}` | 区域广播 → 走马灯/系统消息 | MapScene 广播栏 |
|
||
| `events:{character_id}` | 个人事件 → 弹窗通知 | 全局 Toast + 事件面板 |
|
||
| `battle:{battle_id}` | 战斗结算 → 跳转战报 | BattleScene 自动打开 |
|
||
| `social:{character_id}` | 社交通知 → 弹窗确认 | 全局弹窗队列 |
|
||
| `org:{guild_id}` | 组织消息 → 聊天频道 | SocialScene 聊天栏 |
|
||
|
||
### 4.3 HTTP RESTful 请求封装
|
||
|
||
```typescript
|
||
// HTTP 客户端封装(对齐 TDD-05 §2.3 统一响应格式)
|
||
class HttpClient {
|
||
private baseUrl: string = 'https://api.honghuang.example.com';
|
||
private defaultTimeout: number = 10000; // 10s
|
||
|
||
async request<T>(config: RequestConfig): Promise<T> {
|
||
const headers: Record<string, string> = {
|
||
'Content-Type': 'application/json',
|
||
'Authorization': `Bearer ${Store.get('sessionToken')}`,
|
||
'X-Client-Version': AppVersion,
|
||
'X-Trace-Id': generateUUID(),
|
||
...config.headers,
|
||
};
|
||
|
||
// 幂等键(TDD-05 §5 高敏感写操作)
|
||
if (config.idempotencyKey) {
|
||
headers['Idempotency-Key'] = config.idempotencyKey;
|
||
}
|
||
|
||
const controller = new AbortController();
|
||
const timeoutId = setTimeout(() => controller.abort(), config.timeout || this.defaultTimeout);
|
||
|
||
try {
|
||
const response = await fetch(`${this.baseUrl}${config.url}`, {
|
||
method: config.method,
|
||
headers,
|
||
body: config.data ? JSON.stringify(config.data) : undefined,
|
||
signal: controller.signal,
|
||
});
|
||
|
||
clearTimeout(timeoutId);
|
||
|
||
// HTTP 层错误(TDD-05 §2.3)
|
||
if (response.status === 401) {
|
||
// Token 无效/过期 → 跳转登录
|
||
EventEmitter.emit('auth:expired');
|
||
throw new NetworkError(1002, 'Token 已过期,请重新登录');
|
||
}
|
||
if (response.status === 429) {
|
||
// 限流 → 提示玩家稍后重试
|
||
throw new NetworkError(9001, '操作过于频繁,请稍后重试');
|
||
}
|
||
|
||
const body = await response.json();
|
||
|
||
// 业务层错误
|
||
if (body.code !== 0) {
|
||
throw new NetworkError(body.code, body.message);
|
||
}
|
||
|
||
return body.data as T;
|
||
} catch (error) {
|
||
clearTimeout(timeoutId);
|
||
if (error.name === 'AbortError') {
|
||
throw new NetworkError(-1, '请求超时,请检查网络');
|
||
}
|
||
throw error;
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
### 4.4 gRPC-Web(可选,低延迟战斗)
|
||
|
||
对于需要低延迟的战斗接口(TDD-05 §2.1),可选接入 gRPC-Web:
|
||
|
||
| 场景 | 协议 | 理由 |
|
||
|------|------|------|
|
||
| 发起战斗 / PVP 挑战 | gRPC-Web | 低延迟 + 二进制序列化 |
|
||
| 查询战报 | HTTP REST | 查询类,无实时性要求 |
|
||
| 突破/渡劫/天启 | gRPC-Web | 关键写操作,需幂等+低延迟 |
|
||
| 交易行/拍卖 | HTTP REST | 查询密集,缓存友好 |
|
||
| 聊天/社交 | WebSocket | 已有长连接,复用 |
|
||
|
||
> 实现优先级:先完成 HTTP REST + WebSocket 两条通道,gRPC-Web 作为后续优化项。
|
||
|
||
### 4.5 请求重试/超时/断线重连策略
|
||
|
||
| 策略 | 参数 | 说明 |
|
||
|------|------|------|
|
||
| **请求超时** | GET: 10s, POST: 15s, 战斗: 30s | 超时后回滚乐观更新 |
|
||
| **自动重试** | 最多 3 次,指数退避 1s/2s/4s | 仅对网络错误(非业务错误)重试 |
|
||
| **幂等保护** | 每次写操作生成 `idempotency_key` | 重试时复用同一 key,服务端去重(TDD-05 §5) |
|
||
| **断线重连** | WebSocket 断线后指数退避 1s~30s,最多 10 次 | 重连后自动重新订阅频道 |
|
||
| **心跳检测** | 每 30s 发送 heartbeat | 连续 3 次无响应判定断线 |
|
||
| **离线模式** | 断线后切换为只读模式 | 本地缓存数据可浏览,操作提示"网络不可用" |
|
||
|
||
---
|
||
|
||
## 5. 文字战报渲染引擎
|
||
|
||
### 5.1 战报数据结构
|
||
|
||
服务端返回的战报数据结构(对齐 TDD-05 §3.3.2 和 GDD-03 §九):
|
||
|
||
```typescript
|
||
// 战报数据结构
|
||
interface BattleReport {
|
||
battleId: string;
|
||
type: 'expedition_pve' | 'dungeon_pve' | 'pvp' | 'bounty';
|
||
worldTier: number;
|
||
realmTier: number;
|
||
gameTimestamp: string;
|
||
|
||
// 参战双方
|
||
attacker: CombatantInfo;
|
||
defender: CombatantInfo;
|
||
|
||
// ATB 行动序列
|
||
rounds: RoundLog[];
|
||
|
||
// 结果
|
||
result: {
|
||
winner: 'attacker' | 'defender' | 'draw';
|
||
endCondition: 'hp_zero' | 'timeout' | 'surrender';
|
||
};
|
||
|
||
// 掉落
|
||
drops: {
|
||
currency: Array<{ currencyCode: string; amount: number }>;
|
||
items: Array<{ itemId: string; quantity: number }>;
|
||
};
|
||
|
||
// 特殊事件
|
||
specialEvents: SpecialEvent[];
|
||
|
||
// 死亡惩罚
|
||
deathPenaltyApplied: boolean;
|
||
}
|
||
|
||
// 单次行动记录
|
||
interface RoundLog {
|
||
tick: number; // ATB 行动时间单位
|
||
actor: string; // 'attacker' | 'defender'
|
||
skillId: string;
|
||
skillName: string; // 文案热更名
|
||
action: 'attack' | 'skill' | 'miss' | 'stunned' | 'dodge' | 'heal' | 'buff';
|
||
damage: number;
|
||
isCrit: boolean;
|
||
hpAfter: { attacker: number; defender: number };
|
||
energyAfter: { attacker: number; defender: number };
|
||
message: string; // 文字战报文案(种族差异化,GDD-03 ✅7)
|
||
}
|
||
|
||
// 参战者信息
|
||
interface CombatantInfo {
|
||
id: string;
|
||
name: string;
|
||
race: string;
|
||
realmTier: number;
|
||
level: number;
|
||
hpMax: number;
|
||
energyMax: number;
|
||
}
|
||
```
|
||
|
||
### 5.2 文字战报渲染管线
|
||
|
||
```
|
||
战报数据 JSON
|
||
│
|
||
▼
|
||
BattleReportParser(解析器)
|
||
├── 解析 RoundLog → 生成可视化事件序列
|
||
├── 插入特殊事件(天赋触发/元素反应/天罚等)
|
||
└── 计算 ATB 行动条时间线
|
||
│
|
||
▼
|
||
BattleTimeline(时间线模型)
|
||
├── 每个 tick 对应一帧
|
||
├── 行动条填充动画
|
||
├── 伤害数字弹出
|
||
└── 文字描述滚动
|
||
│
|
||
▼
|
||
BattleUIRenderer(UI 渲染器)
|
||
├── ATB 行动条组件(§5.3)
|
||
├── 文字滚动区域(ScrollView + LabelPool)
|
||
├── 伤害飘字组件
|
||
├── 角色状态面板(HP/能量条)
|
||
└── 结算面板
|
||
```
|
||
|
||
### 5.3 ATB 行动条客户端动画
|
||
|
||
ATB 行动条采用 **纯 CSS/Shader 实现**,不使用 Spine 动画,保持轻量:
|
||
|
||
```typescript
|
||
// ATB 行动条组件
|
||
@Component
|
||
class ATBGauge extends Component {
|
||
@property(ProgressBar)
|
||
attackerGauge: ProgressBar = null; // 攻击方行动条
|
||
|
||
@property(ProgressBar)
|
||
defenderGauge: ProgressBar = null; // 防守方行动条
|
||
|
||
@property(Node)
|
||
attackerReadyIcon: Node = null; // 攻击方就绪图标
|
||
|
||
@property(Node)
|
||
defenderReadyIcon: Node = null; // 防守方就绪图标
|
||
|
||
private timeline: BattleTimeline;
|
||
private currentTick: number = 0;
|
||
private playbackSpeed: number = 1; // 1x / 2x / 5x
|
||
|
||
// 根据战报数据预计算每帧的 ATB 值
|
||
initFromReport(report: BattleReport): void {
|
||
this.timeline = new BattleTimeline(report);
|
||
this.currentTick = 0;
|
||
}
|
||
|
||
// 逐帧播放
|
||
update(dt: number): void {
|
||
if (!this.timeline || this.timeline.isFinished()) return;
|
||
|
||
// 按播放速度推进 tick
|
||
const tickAdvance = dt * this.playbackSpeed * 60; // 60 tick/s 基准
|
||
this.currentTick += tickAdvance;
|
||
|
||
// 获取当前帧的 ATB 值
|
||
const frame = this.timeline.getFrameAtTick(this.currentTick);
|
||
if (!frame) return;
|
||
|
||
// 更新行动条进度(0~100 映射到 0~1)
|
||
this.attackerGauge.progress = frame.attackerATB / 100;
|
||
this.defenderGauge.progress = frame.defenderATB / 100;
|
||
|
||
// 行动条满时显示就绪图标(带缩放动画)
|
||
this.attackerReadyIcon.active = frame.attackerATB >= 100;
|
||
this.defenderReadyIcon.active = frame.defenderATB >= 100;
|
||
|
||
// 如果当前 tick 触发了行动,播放行动效果
|
||
if (frame.action) {
|
||
this.playAction(frame.action);
|
||
}
|
||
}
|
||
|
||
// 播放单次行动效果
|
||
private playAction(action: BattleAction): void {
|
||
// 1. 高亮行动者
|
||
// 2. 显示技能名(Label 弹出动画)
|
||
// 3. 伤害数字飘字(tween + 缓动)
|
||
// 4. 更新 HP/能量条(平滑过渡 tween)
|
||
// 5. 战斗文字滚动到最新一行
|
||
}
|
||
}
|
||
```
|
||
|
||
**行动条视觉设计**:
|
||
|
||
- 行动条为水平进度条,左方攻击方(蓝色),右方防守方(红色)。
|
||
- 填充过程为匀速线性动画,速度与角色 speed 属性成正比。
|
||
- 行动条满时,对应端出现"就绪"闪烁图标。
|
||
- 行动执行后,行动条归零并重新开始填充。
|
||
- 支持 1x / 2x / 5x 三种播放速度。
|
||
|
||
### 5.4 战报回放/加速/跳过
|
||
|
||
| 功能 | 实现方式 | 说明 |
|
||
|------|----------|------|
|
||
| **正常播放** | 逐帧 tick 推进,每 tick 60fps | 默认速度 |
|
||
| **2x 加速** | tickAdvance × 2 | 行动间隔缩短 |
|
||
| **5x 加速** | tickAdvance × 5 | 快速跳过 |
|
||
| **跳过** | 直接跳到最后一帧,显示结算面板 | 玩家点击"跳过"按钮 |
|
||
| **暂停** | 暂停 tick 推进 | 玩家点击暂停 |
|
||
| **回放** | 重置 currentTick = 0,重新播放 | 战斗结束后可回放 |
|
||
| **战报详情** | 点击单次行动展开详情(命中/暴击/元素克制) | 详情面板 |
|
||
|
||
---
|
||
|
||
## 6. UI 框架
|
||
|
||
### 6.1 MVC/MVVM 分层
|
||
|
||
采用 **MVVM 变体**:View(Prefab/Scene)+ ViewModel(Component 脚本)+ Model(Store)。
|
||
|
||
```
|
||
┌───────────────┐ ┌───────────────────┐ ┌──────────────┐
|
||
│ View │ │ ViewModel │ │ Model │
|
||
│ (Prefab) │◄───►│ (Component脚本) │◄───►│ (Store) │
|
||
│ │ │ │ │ │
|
||
│ - 节点树 │ │ - 数据绑定 │ │ - 角色状态 │
|
||
│ - 动画 │ │ - 事件处理 │ │ - 背包数据 │
|
||
│ - 布局 │ │ - 业务逻辑 │ │ - 经济数据 │
|
||
│ │ │ - UI 状态管理 │ │ - 配置缓存 │
|
||
└───────────────┘ └───────────────────┘ └──────────────┘
|
||
```
|
||
|
||
**分层规范**:
|
||
|
||
| 层级 | 职责 | 禁止事项 |
|
||
|------|------|----------|
|
||
| View | 节点布局、动画播放、用户输入捕获 | 不直接访问 Store,不包含业务逻辑 |
|
||
| ViewModel | 数据转换、事件分发、UI 状态管理 | 不直接操作网络层,不持有 UI 节点引用 |
|
||
| Model | 数据存储、数据变更通知 | 不包含 UI 逻辑 |
|
||
|
||
### 6.2 弹窗管理器
|
||
|
||
```typescript
|
||
// 弹窗管理器:队列 + 优先级 + 遮罩
|
||
class DialogManager {
|
||
private queue: DialogItem[] = [];
|
||
private currentDialog: DialogItem | null = null;
|
||
private maskNode: Node | null = null;
|
||
|
||
// 弹窗优先级
|
||
enum DialogPriority {
|
||
LOW = 0, // 普通提示(可被跳过)
|
||
NORMAL = 1, // 常规弹窗(背包满、邮件通知)
|
||
HIGH = 2, // 重要弹窗(战斗结算、突破结果)
|
||
CRITICAL = 3, // 紧急弹窗(断线重连、版本强更)
|
||
SYSTEM = 4, // 系统弹窗(强制退出、封号通知)
|
||
}
|
||
|
||
// 入队
|
||
enqueue(item: DialogItem): void {
|
||
// CRITICAL/SYSTEM 级别直接插入队首
|
||
if (item.priority >= DialogPriority.CRITICAL) {
|
||
this.queue.unshift(item);
|
||
} else {
|
||
this.queue.push(item);
|
||
}
|
||
this.queue.sort((a, b) => b.priority - a.priority);
|
||
this.tryShowNext();
|
||
}
|
||
|
||
// 显示下一个
|
||
private tryShowNext(): void {
|
||
if (this.currentDialog) return; // 当前有弹窗,等待关闭
|
||
if (this.queue.length === 0) return;
|
||
|
||
this.currentDialog = this.queue.shift()!;
|
||
this.showMask(this.currentDialog.priority >= DialogPriority.HIGH);
|
||
this.loadAndShow(this.currentDialog);
|
||
}
|
||
|
||
// 关闭当前弹窗
|
||
close(dialogId: string): void {
|
||
if (this.currentDialog?.id === dialogId) {
|
||
this.currentDialog = null;
|
||
this.hideMask();
|
||
this.tryShowNext();
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
**弹窗类型对照表**:
|
||
|
||
| 弹窗类型 | 优先级 | 队列行为 | 遮罩 |
|
||
|----------|--------|----------|------|
|
||
| 离线结算面板 | HIGH | 登录后第一个弹出 | 全屏遮罩 |
|
||
| 战斗结算 | HIGH | 覆盖层弹出 | 全屏遮罩 |
|
||
| 突破/渡劫结果 | HIGH | 覆盖层弹出 | 全屏遮罩 |
|
||
| 社交通知道侣/结义请求 | NORMAL | 排队弹出 | 半透明遮罩 |
|
||
| 市场交易确认 | NORMAL | 排队弹出 | 半透明遮罩 |
|
||
| 背包满提醒 | LOW | 排队弹出 | 无遮罩 |
|
||
| Toast 提示 | LOW | 不入队,直接显示 | 无遮罩 |
|
||
| 断线重连 | CRITICAL | 插入队首 | 全屏遮罩 |
|
||
| 版本强更 | SYSTEM | 插入队首 | 全屏遮罩 |
|
||
|
||
### 6.3 列表虚拟化
|
||
|
||
背包、交易行、拍卖列表等长列表使用**对象池 + 虚拟滚动**优化:
|
||
|
||
```typescript
|
||
// 虚拟列表组件
|
||
@Component
|
||
class VirtualList extends Component {
|
||
@property(ScrollView)
|
||
scrollView: ScrollView = null;
|
||
|
||
@property(Prefab)
|
||
itemPrefab: Prefab = null;
|
||
|
||
private pool: NodePool; // 对象池
|
||
private dataItems: any[] = []; // 全量数据
|
||
private visibleItems: Map<number, Node> = new Map(); // 可见项
|
||
private itemHeight: number = 80; // 单项高度
|
||
private bufferCount: number = 5; // 上下缓冲区
|
||
|
||
// 初始化
|
||
init(data: any[], itemHeight: number): void {
|
||
this.dataItems = data;
|
||
this.itemHeight = itemHeight;
|
||
|
||
// 计算内容总高度
|
||
const totalHeight = data.length * itemHeight;
|
||
this.scrollView.content.getComponent(UITransform)!.height = totalHeight;
|
||
|
||
// 初始化对象池(预创建屏幕可见数量 + 缓冲区)
|
||
const visibleCount = Math.ceil(this.node.getComponent(UITransform)!.height / itemHeight);
|
||
this.pool = new NodePool();
|
||
for (let i = 0; i < visibleCount + this.bufferCount * 2; i++) {
|
||
const node = instantiate(this.itemPrefab);
|
||
this.pool.put(node);
|
||
}
|
||
}
|
||
|
||
// 滚动时更新可见项
|
||
private onUpdateVisibleRange(): void {
|
||
const scrollOffset = this.scrollView.getScrollOffset();
|
||
const startIndex = Math.max(0, Math.floor(scrollOffset.y / this.itemHeight) - this.bufferCount);
|
||
const endIndex = Math.min(
|
||
this.dataItems.length,
|
||
startIndex + Math.ceil(this.node.getComponent(UITransform)!.height / this.itemHeight) + this.bufferCount * 2
|
||
);
|
||
|
||
// 回收不在可见范围内的节点
|
||
for (const [index, node] of this.visibleItems) {
|
||
if (index < startIndex || index >= endIndex) {
|
||
this.pool.put(node);
|
||
this.visibleItems.delete(index);
|
||
}
|
||
}
|
||
|
||
// 创建新可见节点
|
||
for (let i = startIndex; i < endIndex; i++) {
|
||
if (!this.visibleItems.has(i)) {
|
||
const node = this.pool.get() || instantiate(this.itemPrefab);
|
||
node.setPosition(0, -i * this.itemHeight, 0);
|
||
node.getComponent('VirtualListItem')!.updateItem(this.dataItems[i], i);
|
||
this.scrollView.content.addChild(node);
|
||
this.visibleItems.set(i, node);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 7. 适配方案
|
||
|
||
### 7.1 刘海屏/打孔屏安全区
|
||
|
||
```typescript
|
||
// 安全区适配组件
|
||
@Component
|
||
class SafeAreaAdapter extends Component {
|
||
onLoad(): void {
|
||
// 获取安全区域(Cocos 3.x 内置 API)
|
||
const safeArea = sys.getSafeAreaRect();
|
||
|
||
// 适配方式:将 UI 内容区域收缩到安全区内
|
||
const widget = this.node.getComponent(Widget);
|
||
if (widget) {
|
||
widget.top = sys.windowHeight - safeArea.y - safeArea.height;
|
||
widget.bottom = safeArea.y;
|
||
widget.left = safeArea.x;
|
||
widget.right = sys.windowWidth - safeArea.x - safeArea.width;
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
**适配层级**:
|
||
|
||
| 层级 | 内容 | 安全区处理 |
|
||
|------|------|-----------|
|
||
| 背景层 | 地图/场景背景 | 延伸到全屏(忽略安全区) |
|
||
| 内容层 | 主 UI 面板 | 收缩到安全区内 |
|
||
| 顶层 | 状态栏/导航栏 | 固定在安全区顶部/底部 |
|
||
|
||
### 7.2 折叠屏/平板横竖屏
|
||
|
||
```typescript
|
||
// 屏幕方向适配管理器
|
||
class ScreenAdapter {
|
||
private currentOrientation: 'portrait' | 'landscape' = 'portrait';
|
||
|
||
// 设计分辨率
|
||
private readonly DESIGN_WIDTH = 720;
|
||
private readonly DESIGN_HEIGHT = 1280;
|
||
|
||
init(): void {
|
||
// 监听屏幕方向变化
|
||
screen.on('orientation-change', this.onOrientationChange.bind(this));
|
||
|
||
// 监听窗口大小变化(折叠屏展开/折叠)
|
||
view.on('canvas-resize', this.onCanvasResize.bind(this));
|
||
}
|
||
|
||
private onOrientationChange(orientation: screen.Orientation): void {
|
||
if (orientation === screen.Orientation.LANDSCAPE) {
|
||
this.switchToLandscape();
|
||
} else {
|
||
this.switchToPortrait();
|
||
}
|
||
}
|
||
|
||
private switchToLandscape(): void {
|
||
this.currentOrientation = 'landscape';
|
||
// 横屏布局:左侧地图/战斗,右侧状态面板
|
||
view.setDesignResolutionSize(1280, 720, ResolutionPolicy.FIXED_HEIGHT);
|
||
EventEmitter.emit('layout:landscape');
|
||
}
|
||
|
||
private switchToPortrait(): void {
|
||
this.currentOrientation = 'portrait';
|
||
// 竖屏布局:上方内容,下方操作栏
|
||
view.setDesignResolutionSize(720, 1280, ResolutionPolicy.FIXED_WIDTH);
|
||
EventEmitter.emit('layout:portrait');
|
||
}
|
||
|
||
// 折叠屏展开检测
|
||
private onCanvasResize(): void {
|
||
const ratio = screen.width / screen.height;
|
||
if (ratio > 1.5) {
|
||
// 类似平板比例,使用横屏布局
|
||
this.switchToLandscape();
|
||
} else if (ratio < 0.7) {
|
||
// 细长屏(折叠屏折叠态),使用竖屏布局
|
||
this.switchToPortrait();
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
**适配策略**:
|
||
|
||
| 设备类型 | 比例范围 | 布局策略 |
|
||
|----------|----------|----------|
|
||
| 标准手机 | 16:9 ~ 20:9 | 竖屏布局,固定宽度适配 |
|
||
| 折叠屏(折叠) | 21:9 ~ 25:9 | 竖屏布局,内容区加长 |
|
||
| 折叠屏(展开) | 4:3 ~ 3:2 | 横屏布局或自适应双栏 |
|
||
| 平板 | 4:3 ~ 16:10 | 横屏布局,双栏显示 |
|
||
| PC 模拟器 | 16:9 ~ 21:9 | 横屏布局,键鼠映射 |
|
||
|
||
### 7.3 PC 模拟器键盘映射
|
||
|
||
```typescript
|
||
// 键盘映射配置
|
||
const KEYBOARD_MAP: Record<string, string> = {
|
||
'KeyW': 'move_up',
|
||
'KeyS': 'move_down',
|
||
'KeyA': 'move_left',
|
||
'KeyD': 'move_right',
|
||
'Space': 'confirm',
|
||
'Escape': 'back',
|
||
'KeyB': 'open_bag',
|
||
'KeyM': 'open_map',
|
||
'KeyJ': 'open_quest', // 预留(当前无任务系统)
|
||
'KeyG': 'open_guild',
|
||
'KeyT': 'open_trade',
|
||
'Digit1': 'skill_slot_1',
|
||
'Digit2': 'skill_slot_2',
|
||
'Digit3': 'skill_slot_3',
|
||
'Digit4': 'skill_slot_4',
|
||
'F1': 'help',
|
||
'F5': 'toggle_speed', // 战斗倍速切换
|
||
};
|
||
|
||
// 键盘输入管理器
|
||
class KeyboardInputManager {
|
||
init(): void {
|
||
input.on(Input.EventType.KEY_DOWN, this.onKeyDown, this);
|
||
}
|
||
|
||
private onKeyDown(event: EventKeyboard): void {
|
||
const action = KEYBOARD_MAP[event.code];
|
||
if (action) {
|
||
EventEmitter.emit('input:action', action);
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 8. 热更新客户端集成
|
||
|
||
### 8.1 接入 TDD-02 方案
|
||
|
||
客户端热更新完全遵循 TDD-02《客户端热更新技术方案》,本节定义客户端侧的集成细节。
|
||
|
||
```typescript
|
||
// 热更新引导器(LaunchScene 中运行)
|
||
class HotfixBoot {
|
||
// 本地 manifest 存储键
|
||
private readonly MANIFEST_KEY = 'hotfix_manifest_';
|
||
|
||
async run(): Promise<void> {
|
||
// 1. 读取本地各 Bundle manifest 版本
|
||
const localVersions = this.loadLocalVersions();
|
||
|
||
// 2. 请求服务端版本接口(TDD-02 §5.1)
|
||
const versionResp = await http.get('/hotfix/version', {
|
||
params: {
|
||
major_minor: AppVersion, // 主.次版本
|
||
bundle_versions: localVersions,
|
||
device_id: getDeviceId(),
|
||
account_id: Store.get('accountId') || '',
|
||
channel: getChannel(),
|
||
}
|
||
});
|
||
|
||
// 3. 版本兼容性检查
|
||
if (isIncompatible(AppVersion, versionResp.min_compatible_version)) {
|
||
DialogManager.enqueue({
|
||
type: 'force_update',
|
||
priority: DialogPriority.SYSTEM,
|
||
message: '请前往商店下载最新版本',
|
||
actions: [
|
||
{ text: '前往商店', callback: () => openStore() },
|
||
],
|
||
});
|
||
return;
|
||
}
|
||
|
||
// 4. 下载 manifest + 差分包
|
||
for (const bundle of versionResp.bundles) {
|
||
await this.updateBundle(bundle);
|
||
}
|
||
|
||
// 5. 标记热更完成
|
||
this.saveLocalVersions(versionResp.target_versions);
|
||
}
|
||
|
||
// 单 Bundle 更新流程(TDD-02 §5.1)
|
||
private async updateBundle(bundle: BundleUpdateInfo): Promise<void> {
|
||
// 下载新 manifest
|
||
const newManifest = await http.get(bundle.manifest_url);
|
||
|
||
// 签名校验(TDD-02 §8.1)
|
||
if (!verifySignature(newManifest, BUILTIN_PUBLIC_KEY)) {
|
||
throw new Error('Manifest 签名校验失败');
|
||
}
|
||
|
||
// 比对文件列表,生成待下载列表
|
||
const localManifest = this.getLocalManifest(bundle.name);
|
||
const diffFiles = this.diffManifests(localManifest, newManifest);
|
||
|
||
if (diffFiles.length === 0) return; // 无需更新
|
||
|
||
// 并行下载差分包(支持断点续传)
|
||
const tempDir = `${bundle.name}/_temp/`;
|
||
for (const file of diffFiles) {
|
||
await this.downloadWithRetry(file, tempDir, 3);
|
||
}
|
||
|
||
// 逐文件 MD5 校验
|
||
for (const file of diffFiles) {
|
||
const md5 = await calculateMD5(`${tempDir}/${file.path}`);
|
||
if (md5 !== file.md5) {
|
||
throw new Error(`文件校验失败: ${file.path}`);
|
||
}
|
||
}
|
||
|
||
// 原子移动到正式目录
|
||
await atomicMove(tempDir, bundle.name);
|
||
|
||
// 更新本地 manifest
|
||
this.saveLocalManifest(bundle.name, newManifest);
|
||
|
||
// 如果涉及脚本变更,触发 Bundle 重载
|
||
if (newManifest.has_script_changes) {
|
||
await this.reloadBundle(bundle.name);
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
### 8.2 Nacos 运行时配置集成
|
||
|
||
```typescript
|
||
// Nacos 配置客户端(对齐 TDD-02 §9.2)
|
||
class NacosConfigClient {
|
||
private configs: Map<string, any> = new Map();
|
||
private polling: boolean = false;
|
||
|
||
// 配置命名空间(TDD-02 §9.1)
|
||
private readonly NAMESPACES = [
|
||
'economy', // 经济参数
|
||
'combat', // 战斗参数
|
||
'event', // 事件参数
|
||
'map_gen', // 地图生成
|
||
'drop', // 掉落配置
|
||
];
|
||
|
||
async init(): Promise<void> {
|
||
// 1. 拉取全量配置
|
||
for (const ns of this.NAMESPACES) {
|
||
const config = await this.fetchConfig(ns);
|
||
this.configs.set(ns, config);
|
||
}
|
||
|
||
// 2. 启动长轮询(对齐 TDD-02 §9.2)
|
||
this.startLongPolling();
|
||
}
|
||
|
||
// 获取配置值
|
||
get<T>(namespace: string, key: string, defaultValue: T): T {
|
||
const config = this.configs.get(namespace);
|
||
return config?.[key] ?? defaultValue;
|
||
}
|
||
|
||
// 长轮询:监听配置变更
|
||
private async startLongPolling(): Promise<void> {
|
||
this.polling = true;
|
||
while (this.polling) {
|
||
try {
|
||
const changes = await http.get('/nacos/listen', {
|
||
params: { namespaces: this.NAMESPACES.join(',') },
|
||
timeout: 30000, // 长轮询 30s
|
||
});
|
||
|
||
for (const change of changes) {
|
||
// 合法性校验
|
||
if (this.validateConfig(change.namespace, change.data)) {
|
||
this.configs.set(change.namespace, change.data);
|
||
EventEmitter.emit(`config:${change.namespace}:updated`, change.data);
|
||
}
|
||
}
|
||
} catch (error) {
|
||
// 轮询失败,等待 5s 后重试
|
||
await sleep(5000);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 9. 性能优化
|
||
|
||
### 9.1 内存管理
|
||
|
||
| 策略 | 实现方式 | 触发时机 |
|
||
|------|----------|----------|
|
||
| **场景资源释放** | 场景切换时调用 `assetManager.releaseUnusedAssets()` | 场景弹出/切换 |
|
||
| **图集按需加载** | 种族头像/地图地块等按需加载,用完释放 | 进入对应功能模块 |
|
||
| **音频分段加载** | BGM 按世界层级分段,切换时卸载旧段加载新段 | 切换世界层级 |
|
||
| **对象池复用** | 列表项/飘字/弹窗节点使用对象池 | 全局 |
|
||
| **纹理压缩** | 移动端使用 ETC2/ASTC,PC 使用 DXT/BC7 | 构建时自动选择 |
|
||
| **大图拆分** | 超过 2048x2048 的纹理拆分为多个小图 | 美术资源规范 |
|
||
|
||
### 9.2 GC 优化
|
||
|
||
| 策略 | 说明 |
|
||
|------|------|
|
||
| **减少临时对象** | 高频调用路径(update 循环、战报渲染)避免创建临时对象,使用预分配缓冲区 |
|
||
| **字符串拼接** | 使用模板字符串或 StringBuilder 模式,避免 `+` 拼接大量字符串 |
|
||
| **数组复用** | 列表数据变更时,优先清空复用而非创建新数组 |
|
||
| **TypedArray** | 数值密集型数据(战报序列、属性数组)使用 Float32Array/Int32Array |
|
||
| **手动触发 GC** | 在场景切换完成、结算面板关闭后手动触发 `cc.gc()` |
|
||
|
||
### 9.3 渲染批处理
|
||
|
||
| 优化项 | 说明 |
|
||
|--------|------|
|
||
| **动态合批** | 同一图集的相邻节点自动合批,减少 Draw Call |
|
||
| **静态合批** | 不移动的 UI 元素标记为静态合批 |
|
||
| **减少材质切换** | 相同材质的节点排列在一起,减少 GPU 状态切换 |
|
||
| **Label 优化** | 静态文本使用 Bitmap 字体;动态文本使用系统字体缓存 |
|
||
| **Mask 裁剪** | 减少 Mask 节点嵌套,每层 Mask 增加一个 Draw Call |
|
||
|
||
### 9.4 图集策略
|
||
|
||
| 图集类型 | 粒度 | 最大大小 | 说明 |
|
||
|----------|------|----------|------|
|
||
| UI 通用图集 | 按功能模块 | 2048x2048 | 按钮/图标/边框等 |
|
||
| 种族头像图集 | 按阵营(3 组) | 2048x2048 | 天道/洪荒/幽冥各一组 |
|
||
| 技能图标图集 | 按功法域 | 1024x1024 | 剑/体/法/丹/器/阵等 |
|
||
| 地图地块图集 | 按世界层级 | 2048x2048 | 每层世界一组 |
|
||
| 战报 UI 图集 | 统一 | 1024x1024 | 战斗相关 UI 元素 |
|
||
|
||
**图集加载策略**:
|
||
|
||
- 首包内置 ui_common 图集(通用 UI 元素)。
|
||
- 登录后预加载当前种族所属阵营的头像图集。
|
||
- 进入地图时按需加载对应世界层级的地块图集。
|
||
- 技能图标图集在打开功法/战斗界面时加载。
|
||
|
||
---
|
||
|
||
## 10. 已确认决策记录
|
||
|
||
| # | 决策 | 来源 |
|
||
|---|------|------|
|
||
| ✅C01 | 客户端架构采用 MVVM 变体:View(Prefab)+ ViewModel(Component)+ Model(Store) | 本文确认 |
|
||
| ✅C02 | 状态管理采用单向数据流 + 观察者模式,不引入第三方库 | 本文确认 |
|
||
| ✅C03 | 场景管理采用栈式(LobbyScene 常驻为栈底)+ 覆盖层(BagScene 等) | 本文确认 |
|
||
| ✅C04 | Asset Bundle 按场景/功能切分:core/ui/atlases/audio/configs/i18n 六大类 | TDD-02 §3.1 |
|
||
| ✅C05 | 网络层三通道:WebSocket(实时推送)+ HTTP REST(CRUD)+ gRPC-Web(可选低延迟) | TDD-05 §2.1 |
|
||
| ✅C06 | WebSocket 断线重连采用指数退避(1s~30s),最多 10 次 | 本文确认 |
|
||
| ✅C07 | 写操作使用幂等键(idempotency_key)防重复提交 | TDD-05 §5 |
|
||
| ✅C08 | 乐观更新 + 服务端权威回滚:玩家操作立即反馈,服务端拒绝时回滚 | 本文确认 |
|
||
| ✅C09 | 文字战报渲染采用逐帧 tick 推进,支持 1x/2x/5x 速度与跳过 | GDD-03 ✅1 |
|
||
| ✅C10 | ATB 行动条采用纯 CSS/Shader 动画,不使用 Spine | 本文确认 |
|
||
| ✅C11 | 弹窗管理器支持队列/优先级(5 级)/遮罩,CRITICAL 级别插队 | 本文确认 |
|
||
| ✅C12 | 长列表使用对象池 + 虚拟滚动优化 | 本文确认 |
|
||
| ✅C13 | 安全区适配:背景层全屏,内容层收缩到安全区内 | 本文确认 |
|
||
| ✅C14 | 折叠屏/平板根据宽高比自动切换横竖屏布局 | 本文确认 |
|
||
| ✅C15 | PC 模拟器支持 WASD + 快捷键映射 | 本文确认 |
|
||
| ✅C16 | 热更新接入 TDD-02 方案:Asset Bundle 差量更新 + Nacos 运行时配置 | TDD-02 |
|
||
| ✅C17 | Nacos 配置客户端长轮询 30s,配置变更即时生效 | TDD-02 §9.2 |
|
||
| ✅C18 | 纹理压缩:移动端 ETC2/ASTC,PC 端 DXT/BC7 | 本文确认 |
|
||
| ✅C19 | 战报数据结构与 TDD-05 §3.3.2 对齐,支持完整回放 | TDD-05 |
|
||
| ✅C20 | 离线数据同步:上线时全量拉取角色状态 + 增量同步背包/技能 | TDD-06 |
|
||
|
||
---
|
||
|
||
## 11. 验收标准
|
||
|
||
| # | 验收条目 | 测试方法 |
|
||
|---|----------|----------|
|
||
| AC-01 | 场景切换正常:LoginScene → LobbyScene → MapScene → BattleScene 各场景加载/卸载无内存泄漏 | 反复切换 50 次,监控内存曲线 |
|
||
| AC-02 | Asset Bundle 按 TDD-02 manifest 正确加载,热更后新资源生效 | 修改 Bundle 资源 → 热更 → 验证新资源 |
|
||
| AC-03 | WebSocket 断线后自动重连,重连后频道订阅恢复 | 模拟断网 → 恢复 → 验证消息接收 |
|
||
| AC-04 | 乐观更新:突破操作本地立即显示结果,服务端拒绝时正确回滚 | 模拟服务端返回错误码 |
|
||
| AC-05 | 战报渲染:完整战报正确逐帧播放,1x/2x/5x 速度正确,跳过直接显示结算 | 使用标准战报数据回放 |
|
||
| AC-06 | ATB 行动条动画流畅:填充速度与 speed 属性成正比,行动条满时就绪图标闪烁 | 录屏验证动画帧率 ≥ 30fps |
|
||
| AC-07 | 弹窗队列:同时触发 5 个弹窗(含 CRITICAL 级别),正确按优先级排序弹出 | 批量触发弹窗验证顺序 |
|
||
| AC-08 | 虚拟列表:背包 500 个物品滚动流畅,内存占用稳定 | 填充 500 项滚动,监控 FPS ≥ 55 |
|
||
| AC-09 | 安全区适配:iPhone 15 Pro(灵动岛)、华为 Mate 60(打孔屏)UI 不被遮挡 | 多设备实机测试 |
|
||
| AC-10 | 折叠屏适配:折叠/展开时布局自动切换,无 UI 错位 | 模拟器折叠/展开测试 |
|
||
| AC-11 | PC 模拟器:WASD 移动、快捷键打开背包/地图等功能正常 | 模拟器键盘测试 |
|
||
| AC-12 | Nacos 配置热更:修改 combat 参数后客户端 30s 内生效 | 修改 Nacos → 观察客户端行为变化 |
|
||
| AC-13 | 内存控制:连续游玩 2 小时,内存增长不超过初始的 30% | 长时间运行监控 |
|
||
| AC-14 | Draw Call 控制:主界面 Draw Call ≤ 50,战斗界面 ≤ 30 | 使用 Cocos Profiler 验证 |
|
||
| AC-15 | 离线数据同步:断线 10 分钟后重连,角色状态/背包/货币与服务端一致 | 断线 mock + 重连验证 |
|
||
|
||
---
|
||
|
||
## 12. 版本记录
|
||
|
||
| 版本 | 日期 | 修订内容 | 作者 |
|
||
|------|------|----------|------|
|
||
| v1.0 | 2026-07-02 | 初稿:项目架构、场景管理、状态管理、网络层、文字战报渲染、UI 框架、适配方案、热更新集成、性能优化、决策记录与验收标准 | Claude Code |
|
||
| v1.1 | 2026-07-06 | 术语对齐:gRPC-Web 接口表"破界"→"天启"(✅122) | MiMoCode |
|
||
|
||
---
|
||
|
||
*TDD-03 v1.1 | 2026-07-06 | 术语对齐:gRPC-Web 接口表"破界"→"天启"(✅122)*
|