# 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( action: () => Promise, // 服务端请求 localApply: () => void, // 本地立即应用 rollback: () => void, // 回滚函数 showToast: (msg: string) => void // 错误提示 ): Promise { // 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 void> = new Map(); async connect(token: string): Promise { 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(config: RequestConfig): Promise { const headers: Record = { '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 = 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 = { '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 { // 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 { // 下载新 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 = new Map(); private polling: boolean = false; // 配置命名空间(TDD-02 §9.1) private readonly NAMESPACES = [ 'economy', // 经济参数 'combat', // 战斗参数 'event', // 事件参数 'map_gen', // 地图生成 'drop', // 掉落配置 ]; async init(): Promise { // 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(namespace: string, key: string, defaultValue: T): T { const config = this.configs.get(namespace); return config?.[key] ?? defaultValue; } // 长轮询:监听配置变更 private async startLongPolling(): Promise { 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 | 客户端架构设计*