捕鱼中客户端结果数据如何处理

1 阅读6分钟

本文详细拆解在 Cocos Creator + TypeScript 环境下,捕鱼游戏客户端如何处理服务端下发的射击结果数据。核心原则:服务端绝对权威,客户端只做表现。所有捕获判定、金币增减均由服务端计算,客户端负责把结果流畅、精准、不丢不重地“演”出来,同时处理好网络延迟、鱼已消失、断线重连等异常状况。


一、结果数据的结构与接收

1. 服务端下发格式

通常一个射击结果包含本次子弹命中的鱼列表、每条鱼的捕获状态、奖励、死亡坐标,以及玩家最新余额等信息。

// ShootResult.ts
export interface FishHitInfo {
    fishId: number;         // 服务端生成的唯一鱼ID(全图唯一)
    fishType: number;       // 鱼的类型编号
    isCaught: boolean;      // 是否捕获
    rewardCoins: number;    // 奖励金币(未捕获为 0)
    diePosX: number;        // 鱼死亡时的 X 坐标(世界坐标)
    diePosY: number;        // 鱼死亡时的 Y 坐标
}

export interface ShootResultData {
    shootId: number;                // 对应哪一发子弹
    hitResults: FishHitInfo[];      // 命中的鱼列表
    balance: number;                // 操作后玩家的最新金币
    extraEffect?: string;           // 特殊效果,如 'bomb' 'freeze'
}

2. 网络层接收并存入队列

在 Cocos Creator 中,通常 WebSocket 回调可能在其他线程(JS 单线程但回调可能来自网络事件),为保证主线程安全,用数组作简单队列,update 中统一消费。

// NetworkManager.ts
import { ShootResultData } from './ShootResult';

export class NetworkManager {
    private resultQueue: ShootResultData[] = [];

    // WebSocket onmessage 回调
    onMessage(data: ShootResultData) {
        this.resultQueue.push(data);
    }

    // 每帧调用
    update() {
        while (this.resultQueue.length > 0) {
            const result = this.resultQueue.shift()!;
            ResultProcessor.instance.processShootResult(result);
        }
    }
}

二、鱼实体与全局管理器

每条鱼在生成时必须绑定服务端下发的 fishId,并存进字典,方便收到结果时快速索引。

// FishEntity.ts
import { _decorator, Component, Node, Vec3, Animation } from 'cc';
const { ccclass, property } = _decorator;

@ccclass('FishEntity')
export class FishEntity extends Component {
    public fishId: number = 0;
    public fishType: number = 0;
    private isDead: boolean = false;

    @property(Animation)
    anim: Animation = null!;

    init(id: number, type: number) {
        this.fishId = id;
        this.fishType = type;
        this.isDead = false;
    }

    playDeath(diePos: Vec3): Promise<void> {
        if (this.isDead) return Promise.resolve();
        this.isDead = true;
        this.node.setPosition(diePos); // 对齐服务端死亡坐标
        return new Promise(resolve => {
            this.anim.play('fish_die');
            this.scheduleOnce(() => {
                resolve();
                this.node.destroy();
            }, 1.5); // 死亡动画时长
        });
    }

    playMissEffect() {
        // 播放电击/弹开特效,鱼加速游走
        this.anim.play('fish_hit');
        // 可以暂时变色或添加粒子
    }

    playHitSpark() {
        // 子弹碰撞瞬间的溅射粒子,不确定生死
        // 只播放一个极短特效,不改变鱼状态
    }
}

鱼管理器负责注册/注销活鱼。

// FishManager.ts
import { FishEntity } from './FishEntity';

export class FishManager {
    private activeFishMap: Map<number, FishEntity> = new Map();

    register(fishId: number, fish: FishEntity) {
        this.activeFishMap.set(fishId, fish);
    }

    unregister(fishId: number) {
        this.activeFishMap.delete(fishId);
    }

    getFish(fishId: number): FishEntity | null {
        return this.activeFishMap.get(fishId) || null;
    }

    clearAll() {
        this.activeFishMap.forEach(fish => {
            if (fish.node) fish.node.destroy();
        });
        this.activeFishMap.clear();
    }
}

三、射击结果处理核心流程

ResultProcessor 负责按序表演:找到鱼 → 播放死亡/逃脱 → 生成金币飞行 → 同步余额。

// ResultProcessor.ts
import { _decorator, Component, Vec3, tween, UIOpacity, instantiate, Prefab, Label } from 'cc';
import { ShootResultData, FishHitInfo } from './ShootResult';
import { FishManager } from './FishManager';
import { FishEntity } from './FishEntity';
import { NetworkManager } from './NetworkManager';
const { ccclass, property } = _decorator;

@ccclass('ResultProcessor')
export class ResultProcessor extends Component {
    private static _instance: ResultProcessor;
    public static get instance(): ResultProcessor { return this._instance; }

    @property(FishManager)
    fishManager: FishManager = null!;

    @property(Prefab)
    coinFlyPrefab: Prefab = null!;      // 金币飞入特效预制体

    @property(Node)
    coinTarget: Node = null!;           // UI 金币显示位置(世界坐标)

    @property(Label)
    coinLabel: Label = null!;           // 金币数字文本

    private clientBalance: number = 0;  // 本地当前显示余额

    onLoad() {
        ResultProcessor._instance = this;
    }

    // 处理一次射击的全部结果
    processShootResult(data: ShootResultData) {
        // 为防止连续射击导致表现混乱,先缓存到队列逐一播放
        this.startCoroutine(this.playResultsAsync(data));
    }

    private async playResultsAsync(data: ShootResultData) {
        // 1. 锁定射击输入
        ShootingInput.lock(data.shootId);

        // 2. 依次表现每条鱼的结果
        for (const hit of data.hitResults) {
            await this.handleOneFish(hit);
        }

        // 3. 特殊效果(如全屏炸弹)
        if (data.extraEffect) {
            await this.playExtraEffect(data.extraEffect);
        }

        // 4. 解锁射击
        ShootingInput.unlock(data.shootId);

        // 5. 最终余额强制同步
        this.setBalance(data.balance);
    }

    private async handleOneFish(hit: FishHitInfo) {
        let fish = this.fishManager.getFish(hit.fishId);
        const diePos = new Vec3(hit.diePosX, hit.diePosY, 0);

        // 鱼已经消失(出屏/提前销毁)时,创建一个临时幽灵鱼用于表现
        let ghostFish: FishEntity | null = null;
        if (!fish) {
            ghostFish = await this.createGhostFish(hit.fishType, diePos);
            fish = ghostFish;
        }

        if (hit.isCaught) {
            // 捕获:死亡动画 + 金币飞入
            const deathPromise = fish!.playDeath(diePos);
            // 延迟一小段时间让死亡动画开头先播,再飞金币,节奏更好
            await this.delay(0.3);
            if (hit.rewardCoins > 0) {
                // 金币飞入不阻塞死亡动画,但要保证数字滚动完成
                await this.flyCoins(diePos, hit.rewardCoins);
            }
            await deathPromise; // 确保死亡动画完整播放
        } else {
            // 未捕获:播放受击特效
            fish!.playMissEffect();
            await this.delay(0.2);
        }

        // 如果使用的是临时幽灵鱼,销毁它
        if (ghostFish && ghostFish.isValid) {
            ghostFish.node.destroy();
        }
    }

    // 创建一个临时鱼对象,只播放死亡动画,用于补偿已消失的鱼
    private async createGhostFish(fishType: number, pos: Vec3): Promise<FishEntity> {
        // 根据配置实例化对应类型的鱼预制体
        const ghostNode = instantiate(this.getFishPrefabByType(fishType));
        ghostNode.setPosition(pos);
        ghostNode.parent = this.node; // 找个容器节点
        const ghostFish = ghostNode.getComponent(FishEntity)!;
        ghostFish.init(0, fishType);  // Ghost ID 无意义
        // 注册不是必须的,因为不会再用到
        return ghostFish;
    }

    // 金币飞行协程:移动 + 数字滚动
    private async flyCoins(fromWorldPos: Vec3, amount: number) {
        // 世界坐标转屏幕坐标,再转 UI 节点坐标(需根据实际相机处理)
        const camera = this.node.scene.getComponentInChildren(Camera)!;
        const screenPos = camera.worldToScreen(fromWorldPos);
        const uiFromPos = this.coinTarget.parent.getComponent(UITransform)?.convertToNodeSpaceAR(screenPos) ?? Vec3.ZERO;

        // 创建金币飞入 icon
        const coinNode = instantiate(this.coinFlyPrefab);
        coinNode.setPosition(uiFromPos);
        coinNode.parent = this.coinTarget.parent;

        // 移动动画:飞向目标金币栏
        const targetPos = this.coinTarget.position;
        const duration = 0.6;
        tween(coinNode)
            .to(duration, { position: targetPos }, { easing: 'sineIn' })
            .call(() => coinNode.destroy())
            .start();

        // 数字滚动:平滑增加余额
        const startBalance = this.clientBalance;
        const endBalance = startBalance + amount;
        let elapsed = 0;
        return new Promise<void>(resolve => {
            const updateNum = () => {
                elapsed += 0.03; // 近似每帧时间
                const t = Math.min(elapsed / duration, 1);
                this.setBalance(Math.floor(startBalance + amount * t));
                if (t < 1) {
                    this.scheduleOnce(updateNum, 0.03);
                } else {
                    this.setBalance(endBalance);
                    resolve();
                }
            };
            updateNum();
        });
    }

    private setBalance(value: number) {
        this.clientBalance = value;
        this.coinLabel.string = value.toString();
    }

    private delay(seconds: number): Promise<void> {
        return new Promise(resolve => this.scheduleOnce(resolve, seconds));
    }

    // 特殊效果处理(例如全屏炸弹)
    private async playExtraEffect(effect: string) {
        // 激活全屏特效节点
        console.log('Extra effect:', effect);
        await this.delay(1.0); // 模拟特效播放时长
    }

    // 根据类型获取鱼预制体,此处略
    private getFishPrefabByType(type: number): Prefab { /* ... */ return null!; }
}

四、子弹碰撞:只播溅射,不做判定

子弹飞行检测到鱼时,绝对不能自行决定捕获,只能播放一个极小特效并记录命中,等待服务端确认。

// Bullet.ts
import { _decorator, Component, Collider2D, Contact2DType, IPhysics2DContact } from 'cc';
import { FishEntity } from './FishEntity';
const { ccclass } = _decorator;

@ccclass('Bullet')
export class Bullet extends Component {
    onLoad() {
        const collider = this.getComponent(Collider2D);
        if (collider) {
            collider.on(Contact2DType.BEGIN_CONTACT, this.onContact, this);
        }
    }

    private onContact(selfCollider: Collider2D, otherCollider: Collider2D, contact: IPhysics2DContact | null) {
        const fish = otherCollider.node.getComponent(FishEntity);
        if (fish) {
            // 仅播放溅射粒子,不改变鱼的行为
            fish.playHitSpark();
            // 子弹消失
            this.node.active = false;
        }
    }
}

五、射击输入锁定与超时保护

每一次射击都有一个 shootId,在结果返回前锁定对应的炮台输入,防止连续发射导致表现混乱。同时设置超时机制,避免网络卡死。

// ShootingInput.ts
export class ShootingInput {
    private static pendingShoots: Map<number, number> = new Map(); // shootId -> 开始时间
    private static timeout: number = 5.0; // 超时秒数

    static lock(shootId: number) {
        this.pendingShoots.set(shootId, Date.now() / 1000);
        // 禁用炮台 UI 交互
    }

    static unlock(shootId: number) {
        this.pendingShoots.delete(shootId);
        // 恢复 UI
    }

    static update(dt: number) {
        const now = Date.now() / 1000;
        for (const [shootId, startTime] of this.pendingShoots) {
            if (now - startTime >= this.timeout) {
                console.warn(`射击 ${shootId} 超时未返回`);
                this.unlock(shootId);
                // 可提示用户网络异常
            }
        }
    }
}

主循环中调用 ShootingInput.update(dt)


六、断线重连与状态重建

重连时,服务端应当推送当前完整场景快照:所有活鱼(ID、类型、位置)、玩家余额、炮台状态等。客户端收到后:

  1. 清空所有等待中的结果队列和输入锁。
  2. 销毁当前所有鱼,重建鱼群。
  3. 直接设置余额,不播放任何飞行动画。
// GameMain.ts 重连处理
onReconnect(sceneData: FullSceneData) {
    // 清空队列
    this.networkManager.clearQueue();
    // 清除输入锁
    ShootingInput.clearAll();
    // 清空鱼群
    this.fishManager.clearAll();
    // 根据 sceneData 重建鱼
    sceneData.fishes.forEach(f => {
        const fish = FishFactory.create(f.fishId, f.type, f.pos);
        this.fishManager.register(f.fishId, fish);
    });
    // 强制对齐余额
    this.resultProcessor.setBalance(sceneData.balance);
    // 正常射击恢复
}

七、整体流程总结

  1. 玩家开火 → 发送开火协议,锁定输入,子弹飞出。
  2. 子弹碰撞 → 只播溅射,不做生死判定。
  3. 服务端返回 ShootResultData → 网络层推入队列,主线程 update 逐帧消费。
  4. ResultProcessor 按序表演
    • 通过 fishId 找到鱼实体;若已消失,创建临时幽灵鱼补偿。
    • 捕获:播放死亡动画 → 延迟后金币飞行 + 数字缓动。
    • 未捕获:播放受击效果。
    • 所有命中鱼处理完 → 特殊效果 → 解锁输入 → 强制同步余额。
  5. 超时与重连:自动清理等待状态,重建场景,保证客户端永不卡死。