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 角色数据缓存层¶
// 角色状态缓存结构
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 乐观更新与服务端权威回滚¶
对于玩家主动操作(突破、挂单、学习技能等),客户端采用乐观更新策略提升响应速度:
// 乐观更新流程
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)¶
// 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 请求封装¶
// 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 §九):
// 战报数据结构
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 动画,保持轻量:
// 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 弹窗管理器¶
// 弹窗管理器:队列 + 优先级 + 遮罩
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 列表虚拟化¶
背包、交易行、拍卖列表等长列表使用对象池 + 虚拟滚动优化:
// 虚拟列表组件
@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 刘海屏/打孔屏安全区¶
// 安全区适配组件
@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 折叠屏/平板横竖屏¶
// 屏幕方向适配管理器
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 模拟器键盘映射¶
// 键盘映射配置
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《客户端热更新技术方案》,本节定义客户端侧的集成细节。
// 热更新引导器(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 运行时配置集成¶
// 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)