1316 行
52 KiB
Markdown
1316 行
52 KiB
Markdown
|
|
# TDD-03 客户端架构设计
|
|||
|
|
|
|||
|
|
> **文档类型**:技术设计方案(Technical Design Document)
|
|||
|
|
> **版本**:v1.0
|
|||
|
|
> **日期**:2026-07-02
|
|||
|
|
> **关联文档**: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 |
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
*TDD-03 v1.0 | 2026-07-02 | 客户端架构设计*
|