laya游戏开发之贪吃蛇大作战(二)—— 贪吃蛇客户端

2022-11-18 14:21:40 浏览数 (1)

文章目录
  • 一 功能分析
  • 二 实现方案
    • 1. 代码结构
    • 2. 关键函数实现
      • 2.1 游戏主循环(GameLoop)
      • 2.2 数据层(Model)
      • 2.3 画面绘制层(View)
  • 帧同步的困难与解决方法
    • 1. 随机种子
    • 2. 服务器和序列化协议的选择

下面将介绍联网版贪吃蛇大作战的客户端代码,本项目用 laya 引擎开发,typescript 作为开发语言,具体的技术选型及项目准备可参考 上一篇文章

一 功能分析

基础的贪吃蛇大作战需要三个页面,分别是开始页面、结算页面以及游戏主页面:

目标以实现三个页面中的所有功能为准

二 实现方案

1. 代码结构

采用经典的 MVC 架构,model 层存储游戏中的关键数据、controller 层控制游戏逻辑、view 层负责根据 model 层的数据绘制游戏界面,代码结构如下图所示:

因为服务器同步方式选择了帧同步,那么客户端需要完成以下几件事情:

  1. 向服务器发送玩家输入
  2. 接收服务器下发的玩家操作序列,并执行游戏逻辑,更新游戏数据
  3. 根据游戏数据绘制游戏画面

其中非常重要的两个点是:把游戏逻辑和画面绘制分开把玩家输入与游戏逻辑分开

虽然以上两点在普通的非联机游戏中也应该要做到,但在帧同步的场景下尤为重要。帧同步场景下,是由服务器下发的玩家操作序列来驱动游戏进程的,而画面绘制则是定时更新的,两者之间并没有固定的时间顺序,不分开的话会造成调用逻辑混乱;而玩家输入有时为了方便是与某些游戏逻辑强绑定的,但在帧同步中,玩家的输入是需要先被同步给服务器再下发回来,所以最好绑定虚拟按键和指令,而非直接绑定逻辑

综上,整个游戏的调用关系如下所示: 由游戏主循环驱动游戏进程(接收服务器下发的帧序列),更新 Snake(贪吃蛇) 和 Food(食物) 的数据,然后由 View 层读取 Snake 和 Food 的数据进行绘制

下面将分模块介绍代码中的关键函数实现

2. 关键函数实现

以下说明均认为读者了解 typescript 的基本语法,只介绍相关的逻辑实现

2.1 游戏主循环(GameLoop)

以下为游戏主循环的代码:

代码语言:javascript复制
	// 以下为关键代码逻辑
	export default class GameLoop{
		
		constructor(gameid:string, gamemain:GameMain){
        	this.dataCollection = new DataCollection(); // 游戏数据集合
        	this.randomSeed = 10;						// 随机种子
			// ... 其余初始化操作, 包括初始化Snake和Food
        	this.netChannel = new NetWork(this);		// 初始化服务器信息
    	}
    	
    	startLoop(msg: Uint8Array) : Uint8Array {
    		// 接收服务器响应, 并确认是当前帧
        	if(response.gameId == this.gameId && response.frameNo == this.frameNumber){
            	this.frameNumber  ;
            	// Snake 移动
            	for(let i = 0; i < this.dataCollection.snakeList.length; i  ){
                	let player = this.dataCollection.snakeList[i];
                	for(let p = 0; p < response.cmdList.length; p  ){
                    	if(player.playerId == response.cmdList[p].playerId)
                        	// 根据指令执行每一个 Snake 的移动操作
                	}
            	}
    
            	
            	for(let j = this.dataCollection.snakeList.length - 1; j >= 0; j--){
                	let sPlayer = this.dataCollection.snakeList[j];
                	// 计算 Snake 的碰撞
                	if(sPlayer.tryHitBound()){
                    	// 当碰到边界时,执行 Snake 死亡操作
                    	// 根据死亡 Snake 的分数生成对应的食物
                    	continue;
                	}
                	if(sPlayer.tryHitEnemies(this.dataCollection.snakeList)){
                    	// 当撞到别的 Snake时, 执行死亡操作 (这里注意死亡的Snake的唯一性)
                    continue;
                	}
                	// 计算是否吃到食物
	                for(let t = this.dataCollection.foodList.length - 1; t >= 0; t--){
	                    let f = this.dataCollection.foodList[t];
	                    if(sPlayer.tryEatFood(f)){
	                        // 吃到食物则增长分数, 去除食物
	                    }
	                }
            	}

	            // 补齐地图中原本的食物(食物数量是固定的)
	            this.generateFood(this.dataCollection.maxFoodNum - this.dataCollection.foodList.length);
    
            	// 执行Snake死亡后的操作
	            for(let i = 0; i < diedSnakeList.length; i  ){
	                diedSnakeList[i].release(); 							// 释放资源
	                diedSnakeList[i].reborn(...); 							// 定时重新生成 Snake 
	            }
            	return msg;
        	}
        	return null;
    	}
	}

游戏开始时实例化一个 GameLoop对象(如上),gameloop 在初始化时会创建游戏数据集合、生成一个随机种子(随机种子会在第3小节详细介绍)、设置服务器信息并创建和服务器的连接

代码语言:javascript复制
	constructor(gameid:string, gamemain:GameMain){
       this.dataCollection = new DataCollection(); // 游戏数据集合
       this.randomSeed = 10;						// 随机种子
		// ... 其余初始化操作, 包括初始化Snake和Food
        this.netChannel = new NetWork(this);		// 初始化服务器信息
   }

负责服务器连接的 Network类在构造函数中会调用 startListen方法,向服务器发起长连接,并且设置好当收到新消息时的回调方法

代码语言:javascript复制
	export default class NetWork{
		// ...
		constructor(gl: GameLoop){
	        this.gameLoop = gl;
	        this.startGame();
    	}
    	
	    startListen(): void{
	        this.client = io.connect("http://127.0.0.1:8118");
	        if(this.client){
	            this.client.on("open",()=>{
	                console.log("connect succeed");
	            });
	            this.client.on("response", (msg: Uint8Array)=>{
	                let newFrame = this.gameLoop.startLoop(msg);
	                this.client.emit("news", newFrame);
	            });
	            this.client.emit("news", "framestate");
	        }
	    }
   	}

回调方法包括Snake的移动、碰撞判定、食物的生成、Snake死亡等功能,完整代码见上面的 GameLoop

代码语言:javascript复制
		startLoop(msg: Uint8Array) : Uint8Array {
    		// 接收服务器响应, 并确认是当前帧
        	if(response.gameId == this.gameId && response.frameNo == this.frameNumber){
            	this.frameNumber  ;
            	// Snake 移动
                // 计算 Snake 的碰撞
	            // 补齐地图中原本的食物(食物数量是固定的)
            	// 执行Snake死亡后的操作
    	}

游戏主流程的逻辑并不复杂,初始化时建立连接,当对端发送帧序列时,解析帧序列中的虚拟指令并执行游戏逻辑。简单来说就是,游戏流程的驱动并不是靠时间来tick,而是通过服务器下发的帧来tick

2.2 数据层(Model)

游戏中的数据主要包括 Snake 和 Food 两种,Food 的结构比较简单,存储所在位置、半径、以及代表的分数,数据结构如下所示:

代码语言:javascript复制
export default class Food{

    // draw sprite
    sprite: Laya.Sprite;

    // data
    positionX: number;
    positionY: number;

    type: number;
    score: number; // score of this food
    color: number;
    radius: number;
  }

而 Snake 包含的属性则更加复杂,这里将 Snake 本身的属性(比如击杀数、得分、速度等)和 Snake 的组成节点分开,具体的数据结构如下所示:

a) SnakeNode 数据结构

Snake可以分为头部(Head)和身体(Body)两个部分,但合起来来看其实就是由多个节点组成的,可以把这些节点抽象化为 SnakeNode,身体就是一个SnakeNode数组,头部也只是一个特殊的节点而已

每一个节点都被视为一个SnakeNode,那么 Snake 每一次移动的过程中,只要把 Snake 中每一个节点移动到上一个节点的位置即可。这里采用了链表的方式,用数组也同样可以实现

代码语言:javascript复制
export default class SnakeNode{

    positionX: number;		// x轴位置
    positionY: number;		// y轴位置
    index: number;			// 在整个身体中的次序
    next: SnakeNode;		// 下一个Node

    constructor(x: number, y: number, i: number){
        this.positionX = x;
        this.positionY = y;
        this.index = i;
        this.next = null;
    }

    moveTo(x: number, y: number): void{
        let oldX = this.positionX;
        let oldY = this.positionY;
        this.positionX = x;
        this.positionY = y;
        if(this.next)
            this.next.moveTo(oldX, oldY);
    }
}

b)Snake 的移动方向

Snake 在接收指令输入时,需要向固定方向移动一段距离。按照常规做法,用户输入的是一个0-360°之间的角度,应该按照移动速度向指定角度移动单位向量的距离

但这里有个很大的风险,用户输入的角度是一个浮点数,计算移动距离时分解单位向量也涉及到浮点数计算,而在帧同步的客户端里,如果一旦出现两个客户端浮点数计算不一致的情况,就会导致客户端之间游戏进程完全不同

因此,这里为了避免多次的浮点数计算采取了一个简易方法,就是将 Snake 的移动分解成36个方向,用一个数组存储每一个方向 x,y 轴需要移动的距离,然后把方向除以10选择对应的向量进行移动,这样可以避免浮点数计算(当然这样会有误差,只能算是取巧了)

代码语言:javascript复制
    static directionList = [{x:0, y:8}, {x:1,y:8},...]; // 36 directions
    
    // ......
    let dir = Snake.directionList[Math.floor(this.direction/10)];

c)Snake 数据结构

Snake 结构体则包含 Snake 本身的一些属性,包括移动相关属性、排行榜相关属性等,并提供了一些生成新的 Snake 以及 SnakeNode 的方法

代码语言:javascript复制
export default class Snake{
	bodyArr: Array<Laya.Sprite> = [];
	direction: number;
    speed: number;
    // ... 
   	killCnt: number;
    score: number;  // food number
    
	newSnake(initLength: number, headX:number, headY:number): void
	addNode(preNode: SnakeNode): SnakeNode
}

d)Snake 表里节点

在 Snake 中还有一个比较重要的优化点,如果 Snake 的节点直接由 SnakeNode 组成,那么在 Snake 移动时就会出现一格一格移动的情况,看起来很卡顿,为了让 Snake 移动表现更流畅一点,可以选择为每条 Snake 设置一个隐形的 SnakeNode 队列,如下图红色圆圈;而实际上的表现层,是每隔一个隐形 SnakeNode 才画一个表面的节点

2.3 画面绘制层(View)

绘制层主要负责绘制 Food 和 Snake,Food 这里不再赘述, Snake 的绘制则有需要注意的地方:确认每隔多少个隐形节点绘制一个表面节点、当 Snake 身体变长时身体不但会变长还会变宽,此时要注意重新绘制所有节点

代码语言:javascript复制
export default class GameMain extends ui.test.GameMainUI{
	// ...
    drawSnakes(): void {
        for(let i = 0; i < this.dc.snakeList.length; i  ){
            let snake = this.dc.snakeList[i];
            // 绘制新节点
            if(snake.bodyArr.length < snake.bLength){
                let end = snake.sHead;
                for(let j = 0; j < snake.bodyArr.length * snake.keyStep; j  )
                    end = end.next;
                while(snake.bodyArr.length < snake.bLength){
                    if(snake.bodyArr.length == 0){
                        // 绘制头节点
                        snake.bodyArr.push(p);
                    }
                    else{
                        // 绘制身体节点
                        snake.bodyArr.push(p);
                    }
                }
				// 当蛇的身体变大
                for(let j = 0; j < snake.bodyArr.length; j  )
                    snake.bodyArr[j].size((snake.bradius * 2) / (this.dc.mapWidth/this.map.width),(snake.bradius * 2) / (this.dc.mapHeight/this.map.height));
            }

            // 绘制所有节点
            let ar =  snake.bodyArr;
            let node = snake.sHead;
            for(let j = 0; j < ar.length; j  ){
                ar[j].pos(node.positionX/(this.dc.mapWidth/this.map.width) , node.positionY/(this.dc.mapHeight/this.map.height));
                for(let k = 0; k < snake.keyStep; k  )
                    if(node != null)
                        node = node.next;
            }
        }
    }
}

整个项目中的关键代码已经介绍完了,但其实在开发额过程中也遇到了一些以往在单机游戏开发中没有碰到的问题,下面也花一些篇幅总结一下

帧同步的困难与解决方法

1. 随机种子

在帧同步的场景下,所有客户端的数值计算必须保持强一致,但由于游戏中必须存在一些随机因素,如何保持这些随机的一致呢?这里就要用到带种子的伪随机了

下面提供了一个确定型的 Random 函数,可以看到在种子确定的前提下,rnd()函数一定会生成确定的值,这样多个客户端就能得到相同的随机值了

代码语言:javascript复制
export default class Random{
    seed: number;

    constructor(seedNum: number){
        this.seed = seedNum;
    }

    rand(n: number): number{
        return  Math.ceil(this.rnd() * n);
    }

    randBetween(min: number, max: number) : number{
        return min   Math.ceil(this.rnd() * (max - min));
    }

    rnd(): number {
        this.seed = ( this.seed * 9301   49297 ) % 233280;
        return this.seed / ( 233280.0 );
    }

}
2. 服务器和序列化协议的选择

服务器和客户端的通信协议一般可以选择 http 或者 pb 协议,这里还是选择了 pb 协议,主要是为了以后服务器可以方便做迁移,http 的通信效率、安全性以及可迁移性还是比不上 pb 协议

而这里选择 nodejs 作为服务器的原因主要是方便开发,技术栈也相似,而且在没有很吃 CPU 计算的功能的情况下, nodejs 的效率不一定比其他语言差

下一篇文章会讲解帧同步服务器开发中的一些感悟,欢迎交流

0 人点赞