lawless/docs/技术文档/TDD-03-客户端架构设计.md
徐勤民 521603a899
一些检测仍在等待运行
Docs Build / build-and-deploy (push) Waiting to run
refactor(client): 删除游戏核心管理器和场景脚本
- 移除 ConfigManager 配置管理器类
- 移除 GameManager 全局单例管理器类
- 移除 NetworkManager 网络连接管理器类
- 移除 CharacterData 和 ItemData 数据模型类
- 移除 BagScene、BattleScene、LobbyScene 等场景脚本
- 移除 EncounterBubble 和 EventFeedPanel UI组件脚本
- 更新代理邀请文档中的服务器连接方式
- 更新同步状态表格中的代理任务分配信息
- 添加 MiMo 任务完成总结和审查修复记录
2026-07-03 21:34:51 +08:00

52 KiB

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/                    # 配置 BundleJSON/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 对应一帧
    ├── 行动条填充动画
    ├── 伤害数字弹出
    └── 文字描述滚动
    │
    ▼
BattleUIRendererUI 渲染器)
    ├── 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 变体ViewPrefab/Scene+ ViewModelComponent 脚本)+ ModelStore

┌───────────────┐     ┌───────────────────┐     ┌──────────────┐
│    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 变体ViewPrefab+ ViewModelComponent+ ModelStore 本文确认
C02 状态管理采用单向数据流 + 观察者模式,不引入第三方库 本文确认
C03 场景管理采用栈式LobbyScene 常驻为栈底)+ 覆盖层BagScene 等) 本文确认
C04 Asset Bundle 按场景/功能切分core/ui/atlases/audio/configs/i18n 六大类 TDD-02 §3.1
C05 网络层三通道WebSocket实时推送+ HTTP RESTCRUD+ 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