本文详细拆解在 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、类型、位置)、玩家余额、炮台状态等。客户端收到后:
- 清空所有等待中的结果队列和输入锁。
- 销毁当前所有鱼,重建鱼群。
- 直接设置余额,不播放任何飞行动画。
// 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);
// 正常射击恢复
}
七、整体流程总结
- 玩家开火 → 发送开火协议,锁定输入,子弹飞出。
- 子弹碰撞 → 只播溅射,不做生死判定。
- 服务端返回
ShootResultData→ 网络层推入队列,主线程update逐帧消费。 ResultProcessor按序表演:- 通过
fishId找到鱼实体;若已消失,创建临时幽灵鱼补偿。 - 捕获:播放死亡动画 → 延迟后金币飞行 + 数字缓动。
- 未捕获:播放受击效果。
- 所有命中鱼处理完 → 特殊效果 → 解锁输入 → 强制同步余额。
- 通过
- 超时与重连:自动清理等待状态,重建场景,保证客户端永不卡死。