欢迎光临
我们一直在努力

Cocos实现经典三消游戏-消消乐

引言

整体采用 Cocos Creator + TypeScript 构建客户端,负责棋子的绘制、交换、消除、下落、道具、计分与胜负判定等核心玩法;服务端则使用 C++ + cpp-httplib 搭建,配合 MySQL 数据库实现选关、关卡信息下发与游戏结果提交。阅读本文前,建议你具备一定的 TypeScript 基础、熟悉 Cocos Creator 的基本操作,并对 HTTP 请求与 JSON 数据交互有所了解。掌握了这些前置知识,你就能顺着文章脉络,一步步搭建出属于自己的三消游戏。

目录

  • Cocos实现经典三消游戏-消消乐
    • 目录
    • 客户端部分
      • 一、棋子
        • 1、绘制棋子
        • 2、棋子定义
        • 3、点击事件
        • 4、更新资源
        • 5、动画
          • (1)消除动画
          • (2)抖动动画
          • (3)使用道具动画
      • 二、棋盘
        • 1、绘制棋盘
        • 2、初始化棋盘数据
      • 三、交换
        • 1、交换数值
        • 2、交换动画
        • 3、交换位置
        • 4、交换棋子
          • (1)判断棋子交互
          • (2)不可消除回退
          • (3)重置点击状态
          • (4)死局检测
          • (5)洗牌
      • 四、消除判定
        • 1、消除
        • 2、连锁
      • 五、下落过程
        • 1、下落
        • 2、填充
        • 3、更新全棋盘
      • 六、道具
        • 1、任务道具
          • (1)生成
          • (2)消除
          • (3)检查
        • 2、道具
          • (1)生成
          • (2)效果
      • 七、计分与胜负
        • 1、计分板
          • (1)绘制计分板
          • (2)模式切换
        • 2、计时
        • 3、分数和任务道具
        • 4、绘制胜负界面
        • 5、判断胜负
          • (1)胜利条件
          • (2)失败条件
          • (3)游戏胜负界面切换
      • 八、网络
        • 1、网络脚本
          • (1)设置单例模式
          • (2)向服务端发送string类型数据
          • (3)向服务端发送Json类型数据
        • 2、发送初始化网络消息
        • 3、发送选关信息消息
          • (1)发送消息
          • (2)解析选关信息
          • (3)选关界面
        • 4、发送关卡信息消息
          • (1)发送消息
          • (2)解析关卡信息
        • 5、提交游戏结果信息
      • 九、音乐
        • 1、音乐节点
        • 2、音乐资源
      • 十、字体
    • 服务端部分
      • 一、httplib初始化
        • 1、cpp-httplib库
        • 2、初始化
      • 二、json解析初始化
      • 三、xml解析初始化
      • 四、mysql初始化
        • 1、mysql下载
        • 2、Navicat下载
        • 3、libmysql.dll和libmysql.lib
        • 4、初始化MysqlUtil类
      • 五、初始化请求
      • 六、选关信息请求
        • 1、处理请求
        • 2、从mysql中查询选关数据
        • 3、读取关卡状态
      • 七、关卡信息请求
        • 1、获取关卡信息数据
        • 2、获取指定关卡的关卡信息数据
        • 3、处理请求
      • 八、提交游戏结果请求
        • 1、处理请求
        • 2、更新mysql中的数据
          • (1)获取当前关卡和下一关卡的状态信息和历史最高分数信息
          • (2)判断是否通过
          • (3)修改关卡状态
          • (4)提交事务
    • 后话

客户端部分

一、棋子

1、绘制棋子

​ 用精灵做一个棋子,这里我是一个纯色的背景加上一个图片的精灵控制来控制元素。 请添加图片描述 请添加图片描述

对于这个图片,可以直接拖一整张图片在resources里面,简单快捷,但是资源多了的话占用空间很大。

请添加图片描述 请添加图片描述

​ 也可以用精灵图集,精灵图集我的理解就是把很多资源保存在一张图片里面,再通过配置文件定位资源位置,在资源存储方面就节省了很多,主要是一个png文件和一个plist文件,放进Cocos里面就会自动识别。

请添加图片描述

​ 获取图片的话我这里是直接用豆包生成的一些开心消消乐的卡通风图片,缺点是比较粗糙,练习还可以,如果要好一点的话可以试试holopix AI,地址:https://holopix.cn

​ 做精灵图集的话,可以用texturepacker,不过这个软件是付费的,有条件可以用。我用的是我学的时候评论区大佬分享的在线图集打包工具Flint,地址:https://www.flintbyte.com/texture/,网页版就可以用,图集打包和图集分割的基础功能是免费的。

请添加图片描述

2、棋子定义

​ 在三消游戏里面的棋子主要有普通棋子,道具,墙,任务道具,这里直接定义一个枚举,方便后续扩展

//棋子类型
export enum Element
{
Element_null = 0, //空
Element_chess_1, //普通棋子
Element_chess_2,
Element_chess_3,
Element_chess_4,
// Element_wall, //墙
Element_plan, //任务道具
Element_prop_1, //道具
Element_prop_2,
Element_prop_3,
};

​ 每一个棋子都对应这一个不同的图片,这里可以用一个Record<key,value>,和map差不多,不过这个强制要求每一个key都必须有value与之对应,这里我还没有用到墙,所以先把墙注释了,如果要解开注释在Record里面就必须加上一条对应图片url

//棋子->图片Map
export const ElementSpriteMap: Record<Element,string> =
{
[Element.Element_null]: "",
[Element.Element_chess_1]: "items/texture-5/texture_010",
[Element.Element_chess_2]: "items/texture-5/texture_015",
[Element.Element_chess_3]: "items/texture-5/texture_019",
[Element.Element_chess_4]: "items/texture-5/texture_021",
[Element.Element_plan]: "items/texture-6/texture_004",
[Element.Element_prop_1]: "items/texture-6/texture_000",
[Element.Element_prop_2]: "items/texture-6/texture_001",
[Element.Element_prop_3]: "items/texture-6/texture_003",
};

​ 这里要注意,是从resources目录下开始找,resources目录名字不能错,不然找不到。

3、点击事件

​ 创建一个棋子脚本拉到棋子上,在start处开启点击事件,其中NodeEventType.TOUCH_START表示当触摸到棋子的时候触发,这里用了一个回调函数,由调用者来决定点击后触发什么操作,我这里是准备点击后返回棋子的当前位置。点击后改变精灵背景的颜色来显示点击效果。

//当前位置回调
positionCallBack:Function = null;

//普通背景色
baseColor:Color = color(200,255,255,255);
//激活态背景色
activeColor:Color = color(150,150,150,255);

//组件
sprite:Sprite = null;

start() {
this.sprite = this.node.getComponent(Sprite);

this.node.on(NodeEventType.TOUCH_START,()=>{
this.hit();
});
}

//点击事件
hit()
{
this.sprite.color = (this.sprite.color == this.activeColor)?this.baseColor:this.activeColor;
this.positionCallBack();
}

4、更新资源

​ 在这里增加一个更新资源函数用于更新图片显示的资源,这样我们就可以通过resId来控制棋子的显示,在这里之前定义的Record就派上用场了,*GameDefine.ElementSpriteMap[this.resId]*可以直接定位到resId对应的图片资源。

​ 这里其实在updateRes()传入resId然后修改this.resId,然后增加一个*setResId()和getResId()*更好一点,回头要再改@v@

//图片资源标识
resId = 0;

//更新资源
updateRes()
{

let element = this.node.getChildByName("element").getComponent(Sprite);
let elementSpriteFrame = GameDefine.ElementSpriteMap[this.resId];
resources.load(elementSpriteFrame,SpriteFrame,(err,res)=>{
if(err)
{
console.log("资源加载失败");
}
element.spriteFrame = res;
});
}

​ 需要修改图片的时候直接修改resId,然后调用*updateRes()*就可以更新资源了

5、动画

​ 这里只做了几个比较粗糙的动画,练习的话还行,如果要精细这个不够

​ 选中棋子然后进入动画编辑器,开始做动画

(1)消除动画

​ 当达到消除条件的时候,棋子爆炸,这里我简化成了变成星星图案,在属性列表里增加一个spriteFrame属性,就是改变图片的显示,添加一个关键帧,在到达关键帧的时候改变图案,模拟一个爆炸效果,如果要更好的话可以和下面使用道具的动画结合一下,会好看一点

请添加图片描述

(2)抖动动画

​ 当选中了两个不能交换的棋子时触发抖动动画,这里在属性列表里加一个position修改位置属性实现抖动的效果,x是左右,y是上下

请添加图片描述

(3)使用道具动画

​ 双击道具时触发道具动画,这里是让棋子放大然后恢复,修改棋子的缩放scale值就可以了

请添加图片描述

​ 这里的动画都是简单实现,实际上动画是游戏体验很重要的一环,这么简单的是不够用的

​ 到这里棋子就实现的差不多了,保存一下预制体,方便后续调用

二、棋盘

1、绘制棋盘

​ 用精灵做一个棋盘,纯色就可以了,或者有图片也可以改Sprite Frame

请添加图片描述

2、初始化棋盘数据

​ 在棋盘挂载一个脚本,创建一个二维数组games[rows][cols] 作为棋盘存储每个棋子的值,其中rows为棋盘的行数,cols为棋盘的列数

//随机获取棋子的数据
getGameDateCellAll()
{
this.games = [];
for(let i = 0; i < this.cols;i++)
{
this.games[i] = [];
for(let j = 0;j<this.rows;j++)
{
this.games[i][j] = Math.floor(Math.random() * GameDefine.BoardConfig.chess_number + GameDefine.Element.Element_chess_1);
}
}
}

​ 其中,GameDefine.BoardConfig.chess_number为普通棋子的个数,GameDefine.Element.Element_chess_1为第一个普通棋子的值,这个枚举会在后面列出来,就是用*Math.random()生成普通棋子对应的值,这样就可以初始化出一个随机的棋盘数组。这里因为枚举都是整数,所以用Math.floor()*向下取整

​ 这里就会出现一个问题,如果一开始生成的棋盘就有三个以上连续相同的棋子,那一开始就消除,就不太好,所以做一个策略就是生成完了以后检查棋盘的数据,如果出现可消除的棋子,在一定次数内整个棋盘重新生成,如果超过了这个次数就局部拆解连续的棋子,如果可以直接生成可用的棋盘那就很好,简单,如果不可以的话再局部处理,局部处理完了走循坏再检查消除,以免出现局部生成后重新出现连续的情况

//初始化棋盘数据
initGamesData()
{
//最多尝试生成次数
const maxCount:number = 20;
let count:number = 0;

do{
if(count < maxCount)//策略一、若随机有消除则全部重新生成
{
count++;
//全局随机生成
this.getGameDateCellAll();
}
else//策略二、若随机有消除则可消除的棋子局部重新生成
{
//局部随机生成
this.getGameDataCellPart();
}
}while(this.isRemoveCell());
}

​ 其中判断消除如下,只要遍历数组,查找是否有三个以上的连续就可以了

//判断是否有可消除的棋子
isRemoveCell():boolean
{
//逐列判断
for(let i:number = 0;i < this.cols; i++)
{
let count = 1;
let tmpCell:number = this.games[i][0];
for(let j:number = 1;j < this.rows; j++)
{
if(this.games[i][j] == tmpCell)
{
count++;
}
else
{
if(count >= 3)
{
return true;
}
tmpCell = this.games[i][j];
count = 1;
}
}
//末尾一段连续
if(count >= 3) return true;
}

//逐行判断
for(let j:number = 0;j < this.rows; j++)
{
let count = 1;
let tmpCell:number = this.games[0][j];
for(let i:number = 1;i < this.cols; i++)
{
//只消除普通棋子
if(this.games[i][j] == tmpCell)
{
count++;
}
else
{
if(count >= 3)
{
return true;
}
tmpCell = this.games[i][j];
count = 1;
}
}
//末尾一段连续
if(count >= 3) return true;
}

return false;
}

​ 局部重新生成如下,其实和判断消除是一样的,不一样的是在判断完后,将连续的那一部分重新生成

//可消除的棋子局部重新生成
getGameDataCellPart()
{
//逐列判断
for(let i:number = 0;i < this.cols; i++)
{
let count = 1;
let tmpCell:number = this.games[i][0];
for(let j:number = 1;j < this.rows; j++)
{
//只消除普通棋子
if(this.games[i][j] == tmpCell)
{
count++;
}
else
{
if(count >= 3)
{
//连续的这一部分重新生成
for(let k:number = 0;k < count;k++)
{
this.games[i][jk1] = Math.floor(Math.random() * GameDefine.BoardConfig.chess_number + GameDefine.Element.Element_chess_1);
}
}
tmpCell = this.games[i][j];
count = 1;
}
}
//末尾一段连续
if(count >= 3)
{
//连续的这一部分重新生成
for(let k:number = 0;k < count;k++)
{
this.games[i][this.rowsk1] = Math.floor(Math.random() * GameDefine.BoardConfig.chess_number + GameDefine.Element.Element_chess_1);
}
}
}

//逐行判断
for(let j:number = 0;j < this.rows; j++)
{
let count = 1;
let tmpCell:number = this.games[0][j];
for(let i:number = 1;i < this.cols; i++)
{
//只消除普通棋子
if(this.games[i][j] == tmpCell)
{
count++;
}
else
{
if(count >= 3)
{
//连续的这一部分重新生成
for(let k:number = 0;k < count;k++)
{
this.games[ik1][j] = Math.floor(Math.random() * GameDefine.BoardConfig.chess_number + GameDefine.Element.Element_chess_1);
}
}
tmpCell = this.games[i][j];
count = 1;
}
}
//末尾一段连续
if(count >= 3)
{
//连续的这一部分重新生成
for(let k:number = 0;k < count;k++)
{
this.games[this.colsk1][j] = Math.floor(Math.random() * GameDefine.BoardConfig.chess_number + GameDefine.Element.Element_chess_1);
}
}
}

return false;
}

​ 棋盘数据生成后,就可以开始生成棋盘了,先根据棋盘大小和行列数缩放棋子比例,其中cellBaseWidth和cellBaseHeight是棋子精灵的宽和高

//获取棋盘大小
let checkerBoardUI = this.node.getComponent(UITransform);
let width = checkerBoardUI.width;
let height = checkerBoardUI.height;

//计算棋子大小
let cellWidth:number = width / this.cols;
let cellHeight:number = height / this.rows;

//计算缩放比例
let scaleW = cellWidth / GameDefine.BoardConfig.cellBaseWidth;
let scaleH = cellHeight / GameDefine.BoardConfig.cellBaseHeight;

​ 再根据行列生成棋子,这里的cellPrefab要拖入之前保存的棋子的预制体,就可以用instantiate()生成棋子,初始化脚本的数据,这里的回调就是点击事件,具体内容在后面章节,在这里用一个gameActive控制在一次操作完成之前不能第二次点击

//棋子
@property(Prefab)
cellPrefab:Prefab = null;

//棋子脚本数组
AllCell:cellControl[][] = null;

//活动中 当值为true时不可操作
gameActive:boolean = false;

//初始化棋盘画面
initCheckerBoard()
{
//…缩放棋子…

this.AllCell = [];
for(let i = 0; i < this.cols;i++)
{
this.AllCell[i] = [];
for(let j = 0;j<this.rows;j++)
{
//创建棋子
let cell = instantiate(this.cellPrefab);

//脚本初始化
let script = cell.getComponent(cellControl);
script.i = i;
script.j = j;
script.resId = this.games[i][j];

//位置信息回调
script.positionCallBack = ()=>{
if(this.gameActive) return;

this.nowCell = script;
this.gameActive = true;

//交换判定
this.swapCell();
};

//加载图片资源
script.updateRes();

//保存
this.AllCell[i][j] = script;

//位置
cell.setPosition(cellWidth*i,cellHeight*j);

//缩放
cell.setScale(scaleW,scaleH);

cell.parent = this.node;
}
}
}

三、交换

1、交换数值

​ 因为每个棋子的值都存在games数组里面,所以只需要交换games数组的值就可以了

//交换两个棋子(只交换games数组)
swapCellGames(one:cellControl,two:cellControl)
{
let tmp:number = this.games[one.i][one.j];
this.games[one.i][one.j] = this.games[two.i][two.j];
this.games[two.i][two.j] = tmp;
}

2、交换动画

​ 使用tween来使棋子移动到指定位置,时间默认0.15s,用*Promise.all()*做异步并行处理,两个棋子都移动到指定位置后回调,这里用的是缓动tween,所以实际上这两个棋子的位置是互换了的

//两个棋子的交换动画(会连着节点一起移动,待改进)
swapAnimation(one:Node,two:Node,time:number = 0.15):Promise<void>
{
return Promise.all([
new Promise<void>(res=>tween(one).to(time,{position:two.position}).call(()=>{
res();
}).start()),
new Promise<void>(res=>tween(two).to(time,{position:one.position}).call(()=>{
res();
}).start())
]).then(()=>{});
}

3、交换位置

​ 这里我因为把每个棋子的位置写在了脚本里面,然后把所有棋子的脚本都保存在了一个数组里面,由于动画用的是tween,会产生实际上的移动,所以需要把脚本里面保存的位置信息也交换一下。但其实可以不用这么麻烦我发现,因为有这个节点的信息可以直接获取的这个节点的位置,所以位置信息不用刻意保存在脚本里面,如果只保存这些节点,然后统一用games来赋值,那么交换位置这一步可以直接省略,下次再改@v@

//交换两个棋子
async swapCellFormTwoCellControl(one:cellControl,two:cellControl)
{
this.swapCellGames(one,two);

let tmpCell:cellControl = this.AllCell[one.i][one.j];
this.AllCell[one.i][one.j] = this.AllCell[two.i][two.j];
this.AllCell[two.i][two.j] = tmpCell;

let tmpi = one.i;
let tmpj = one.j;
one.i = two.i;
one.j = two.j;
two.i = tmpi;
two.j = tmpj;

await this.swapAnimation(one.node,two.node);
}

4、交换棋子
(1)判断棋子交互

​ 主要就是临近棋子,普通棋子和道具,任务道具,双击道具之间的判断,后续如果要增加对墙的交互判断就在这里加一个条件就可以了

//是否相邻
isNeighbor(one:cellControl,two:cellControl):boolean
{
if(one.i + 1 == two.i && one.j == two.j) //右
{
return true;
}
else if(one.i 1 == two.i && one.j == two.j) //左
{
return true;
}else if(one.i == two.i && one.j + 1 == two.j) //上
{
return true;
}
else if(one.i == two.i && one.j 1 == two.j) //下
{
return true;
}
else
{
return false;
}
}

//判断棋子交互
interactionCell(one:cellControl,two:cellControl):boolean
{
if(one.resId >= GameDefine.Element.Element_prop_1 && one.resId <= GameDefine.Element.Element_prop_3
&& two.resId >= GameDefine.Element.Element_chess_1 && two.resId <= GameDefine.Element.Element_chess_4
)//普通棋子与道具不能交互
{
return false;
}
else if(two.resId >= GameDefine.Element.Element_prop_1 && two.resId <= GameDefine.Element.Element_prop_3
&& one.resId >= GameDefine.Element.Element_chess_1 && one.resId <= GameDefine.Element.Element_chess_4
)//普通棋子与道具不能交互
{
return false;
}else if(one.resId == GameDefine.Element.Element_plan
|| two.resId == GameDefine.Element.Element_plan
)//任务道具不能交互
{
return false;
}
else if(one.i == two.i && one.j == two.j) //可能是道具
{
return true;
}
else {
return this.isNeighbor(one,two);
}

}

​ 在交换逻辑里面做一个判断,如果判断不可交互则播放抖动动画,表示两个棋子不可交换,如果选中的是相同的棋子,则判断是否是双击道具,根据不同的道具实现相应的道具效果,如果不同棋子且可交互,则交换两个棋子

//交互判断
if(!this.interactionCell(this.lastCell,this.nowCell))
{
this.lastCell.playAnimationShake();
this.nowCell.playAnimationShake();
}
else{
//如果选中一样的
if(this.lastCell.i == this.nowCell.i && this.lastCell.j == this.nowCell.j)
{
switch(this.nowCell.resId)
{
case GameDefine.Element.Element_prop_2:
//**道具效果**
break;
default:
break;
}
}
else
{
//如果前一步已选中,且不同,则交换两个棋子
this.swapCellFormTwoCellControl(this.lastCell,this.nowCell);

//**不可消除回退**
}
//…
}

(2)不可消除回退

​ 交换后判断一下是否可消除,若不可消除则再交换回来

//如果前一步已选中,且不同,则交换两个棋子
this.swapCellFormTwoCellControl(this.lastCell,this.nowCell);

//如果交换后不可消除则回退
if(!this.isRemove())
{

await this.sleep(0.5);

await this.swapCellFormTwoCellControl(this.nowCell,this.lastCell);
this.lastCell.updateRes();
this.nowCell.updateRes();

//交换完的处理 重置状态 死局检测
this.resetCell();
return;
}

(3)重置点击状态

​ 完成一次消除后要重置一下swapFlag和gameActive的状态,为下一次交换做准备

//交换完一次后重置棋子选中的状态 并进行死局检测
resetCell()
{
if(this.swapFlag)
{
//交换完了重置状态
this.lastCell = null;
this.nowCell = null;

//死局检测
if(!this.deadlockDetection())
{
//洗牌
this.shuffle();
}
this.swapFlag = false;
}
this.gameActive = false;
}

(4)死局检测

​ 消除后有可能会出现没有可交换的棋子,即所有的棋子交换后都无法消除,就是遍历每一个格子,从右和下两个方向出发,测试交换后是否可消除,这里只需要测试games里的值就可以,不必实际交换

//死局检测
deadlockDetection():boolean
{
//遍历每一个格子
for(let i = 0;i<this.cols;i++)
{
for(let j=0;j<this.rows;j++)
{
//如果有道具,则不是死局
if(this.games[i][j] >= GameDefine.Element.Element_prop_1 && this.games[i][j] <= GameDefine.Element.Element_prop_3)
{
return true;
}
//右
if(j+1<this.rows)
{
this.swapCellGames(this.AllCell[i][j],this.AllCell[i][j+1]);
//交换后可消除
if(this.isRemoveCell())
{
this.swapCellGames(this.AllCell[i][j],this.AllCell[i][j+1]);
return true;
}
else
{
this.swapCellGames(this.AllCell[i][j],this.AllCell[i][j+1]);
}

}

//下
if(i+1<this.cols)
{
this.swapCellGames(this.AllCell[i][j],this.AllCell[i+1][j]);
//交换后可消除
if(this.isRemoveCell())
{
this.swapCellGames(this.AllCell[i][j],this.AllCell[i+1][j]);
return true;
}
else
{
this.swapCellGames(this.AllCell[i][j],this.AllCell[i+1][j]);
}
}
}
}
return false;
}

(5)洗牌

​ 出现死局或者使用特定道具的情况需要触发洗牌,只需要重置一下games数组的数据再更新一下棋盘就可以了

//洗牌
shuffle()
{
//重新生成棋盘
this.initGamesData();
this.updateAllCell();
}

四、消除判定

1、消除

​ 先创建一个标记数组,从行和列两个方向遍历棋盘,遇到三个以上的连续棋子就记录在数组里面,再遍历结束后再把标记数组统一更新资源。同时计分和生成道具也是放在这里

//消除判定
isRemove():boolean
{
//可消除标记
let removeFlag:boolean = false;
//标记数组
let removes:number[][] = Array.from({length: this.rows},()=>new Array(this.cols).fill(GameDefine.Element.Element_chess_1));

let tmpCell:number = 1;
//计数
let count:number = 0;

//逐列判断
for(let i:number = 0;i < this.cols; i++)
{
tmpCell = this.games[i][0];
count = 1;
for(let j:number = 1;j < this.rows; j++)
{
//只消除普通棋子
if(this.games[i][j] >= GameDefine.Element.Element_chess_1
&& this.games[i][j] <= GameDefine.Element.Element_chess_4
&& this.games[i][j] == tmpCell)
{
count++;
}
else
{
if(count >= 3)
{
removeFlag = true;
for(let k = 0;k < count;k++)
{
removes[i][jk1] = GameDefine.Element.Element_null;
}

//消除加分 (count-3+1)*100
this.scoreControl.setCurrentScore(this.scoreControl.getCurrentScore() + (count2)*100);
//判断道具
removes[i][j1] = this.isNoProp(count);

}
tmpCell = this.games[i][j];
count = 1;
}
}

//末尾一段连续
if(count>=3)
{
removeFlag = true;
for(let j = this.rowscount;j < this.rows;j++)
{
removes[i][j] = GameDefine.Element.Element_null;
}
//判断道具
removes[i][this.rows1] = this.isNoProp(count);

//消除加分 (count-3+1)*100
this.scoreControl.setCurrentScore(this.scoreControl.getCurrentScore() + (count2)*100);
}
}

//逐行判断
for(let j:number = 0;j < this.rows; j++)
{
tmpCell = this.games[0][j];
count = 1;
for(let i:number = 1;i < this.cols; i++)
{
if(this.games[i][j] >= GameDefine.Element.Element_chess_1
&& this.games[i][j] <= GameDefine.Element.Element_chess_4
&& this.games[i][j] == tmpCell)
{
count++;
}
else
{
if(count >= 3)
{
removeFlag = true;
for(let k = 0; k < count;k++)
{
removes[ik1][j] = GameDefine.Element.Element_null;
}

//消除加分 (count-3+1)*100
this.scoreControl.setCurrentScore(this.scoreControl.getCurrentScore() + (count2)*100);
//判断道具
removes[i1][j] = this.isNoProp(count);
}
tmpCell = this.games[i][j];
count = 1;
}
}

//末尾一段连续
if(count>=3)
{
removeFlag = true;
for(let i = this.colscount;i < this.cols;i++)
{
removes[i][j] = GameDefine.Element.Element_null;
}
//判断道具
removes[this.cols1][j] = this.isNoProp(count);

//消除加分 (count-3+1)*100
this.scoreControl.setCurrentScore(this.scoreControl.getCurrentScore() + (count2)*100);
}
}

if(removeFlag)
{
//将标记的位置删除(置零)
for(let i:number = 0;i<this.cols;i++)
{
for(let j:number = 0;j<this.rows;j++)
{
switch(removes[i][j])
{
case GameDefine.Element.Element_null:
this.games[i][j] = GameDefine.Element.Element_null;
this.AllCell[i][j].playAnimationRemove();

//消除任务道具
this.removePlanProp(this.AllCell[i][j]);
break;
case GameDefine.Element.Element_prop_2:
this.AllCell[i][j].playAnimationRemove();
this.games[i][j] = GameDefine.Element.Element_prop_2;
break;
default:
break;
}
}
}
}

return removeFlag;
}

2、连锁

​ 其实就是消除,下落,填充,更新循环,直至无法消除

//消除(连锁)
do{
await this.sleep(0.5);
//下落
this.dropCell();

//填充
this.fillNullCell();

//全棋盘更新
this.updateAllCell();

await this.sleep(0.5);
}while(this.isRemove());

​ 这里的sleep是用来给动画腾出时间的,可以让这个过程显得动态一点,注意TypeScript是没有sleep()这个函数的,应该用schedule(),我嫌写的麻烦就放在sleep里面了

//计时器函数
sleep(delay:number):Promise<void>
{
return new Promise(resolve=>{
this.scheduleOnce(resolve,delay);
})
}

五、下落过程

​ 下落过程分为下落、填充两个步骤

1、下落

​ 消除判定结束后棋盘上会出现一些消除后的空格子Element_null,只需要逐列处理,把这一列所有的棋子记录下来,再从下至上填充,数组中的棋子数量不足说明这一列有棋子被消除了,上方填充空格。

//下落逻辑
dropCell()
{
//逐列
for(let i:number = 0; i < this.cols; i++)
{
let tmpElement:number[] = [];
for(let j:number = 0; j < this.rows; j++)
{
//记录非零元素
if(this.games[i][j] != GameDefine.Element.Element_null)
{
tmpElement.push(this.games[i][j]);
}
}

//填充
for(let j:number = 0; j < this.rows;j++)
{
this.games[i][j] = (j < tmpElement.length) ? tmpElement[j] : GameDefine.Element.Element_null;
}
}
}

2、填充

​ 遍历遇空重新生成棋子就可以了

//填充空棋子
fillNullCell()
{
for(let i:number = 0;i<this.cols;i++)
{
for(let j:number = 0;j<this.rows;j++)
{
if(this.games[i][j] == GameDefine.Element.Element_null)
{
this.games[i][j] = Math.floor(Math.random()*GameDefine.BoardConfig.chess_number + 1);
}
}
}
}

3、更新全棋盘

​ 消除、下落、填充一整个过程都是在处理games数组,完成后资源需要更新一下

//更新全棋盘
updateAllCell()
{
//填充
this.fillNullCell();

for(let i:number = 0;i<this.cols;i++)
{
for(let j:number = 0;j<this.rows;j++)
{
//…
this.AllCell[i][j].resId = this.games[i][j];
this.AllCell[i][j].updateRes();
}
}

//…

//判断胜利条件
this.judgeSuccessOrDefeat();
}

六、道具

1、任务道具

有的关卡需要消除任务道具

(1)生成

​ 生成棋盘后直接替换,我这里是直接简单粗暴的放在了棋盘的底层,如果要优化的话这里可以改成随机位置或者更有创意的玩法,改一下games里面的值就可以了

//初始化棋盘数据
initGamesData()
{
\\\\**生成棋盘**

//任务道具
for(let x = 0;x<this.planPropNum;x++)
{
this.games[Math.floor(x%this.cols)][Math.floor(x/this.cols)] = GameDefine.Element.Element_plan;
}
}

(2)消除

​ 这里我是直接判定棋子附近有消除,则自动消除附近的任务道具

//消除任务道具
removePlanProp(cell:cellControl)
{
if(cell.i + 1 < this.cols && this.games[cell.i + 1][cell.j] == GameDefine.Element.Element_plan) //右
{
this.games[cell.i + 1][cell.j] = GameDefine.Element.Element_null;
}
if(cell.i1 >= 0 && this.games[cell.i 1][cell.j] == GameDefine.Element.Element_plan) //左
{
this.games[cell.i 1][cell.j] = GameDefine.Element.Element_null;
}
if(cell.j + 1 < this.rows && this.games[cell.i][cell.j+1] == GameDefine.Element.Element_plan) //上
{
this.games[cell.i][cell.j + 1] = GameDefine.Element.Element_null;
}
if(cell.j 1 >= 0 && this.games[cell.i][cell.j1] == GameDefine.Element.Element_plan) //下
{
this.games[cell.i][cell.j 1] = GameDefine.Element.Element_null;
}
}

(3)检查

​ 在更新棋盘的时候记录和设置一下当前任务道具的数量

//更新全棋盘
updateAllCell()
{
let planNum:number = 0;
for(let i:number = 0;i<this.cols;i++)
{
for(let j:number = 0;j<this.rows;j++)
{
if(this.games[i][j] == GameDefine.Element.Element_plan)
{
planNum++;
}
//…
}
}

this.scoreControl.setCurrentPlanProp(planNum);

//…
}

2、道具
(1)生成

​ 这里我是只写了一个当同时消除数量大于4的时候,将最后一位变成道具Element_prop_2,这里完全可以做更有创意的修改

//返回消除标记数组removes的状态 Element_null:不消除 Element_prop_2,道具
isNoProp(count:number):number
{
//道具2
if(count >= 4)
{
return GameDefine.Element.Element_prop_2;
}
else
{
return GameDefine.Element.Element_null;
}
}

//消除判定
isRemove():boolean
{
//…

//逐列判断
for(let i:number = 0;i < this.cols; i++)
{
//…
for(let j:number = 1;j < this.rows; j++)
{
//只消除普通棋子
if(this.games[i][j] >= GameDefine.Element.Element_chess_1
&& this.games[i][j] <= GameDefine.Element.Element_chess_4
&& this.games[i][j] == tmpCell)
{
count++;
}
else
{
if(count >= 3)
{
//…
//判断道具
removes[i][j1] = this.isNoProp(count);
}
//..
}
}

//末尾一段连续
if(count>=3)
{
//…
for(let j = this.rowscount;j < this.rows;j++)
{
removes[i][j] = GameDefine.Element.Element_null;
}
//判断道具
removes[i][this.rows1] = this.isNoProp(count);

//…
}
}
//…

return removeFlag;
}

(2)效果

​ 双击道具后就可以使用道具,这里是一个消除一整行的道具的使用,要注意这里的道具相当于替换了原本连锁消除过程中的第一步消除,所以要执行一次下落,填充,更新过程,如果后续可以继续连锁则继续连锁

//使用道具2 消除一整行
useProp2(one:cellControl)
{
for(let i = 0;i<this.cols;i++)
{
this.games[i][one.j] = GameDefine.Element.Element_null;
this.AllCell[i][one.j].playAnimationRemove();
}
}

//交换棋子
async swapCell()
{
if(this.lastCell == null)
{
//前一步没有选中,则记录本次
this.lastCell = this.nowCell;

this.swapFlag = false;
}
else
{
//…
//交互判断
if(!this.interactionCell(this.lastCell,this.nowCell))
{
//…
}
else{
//如果选中一样的
if(this.lastCell.i == this.nowCell.i && this.lastCell.j == this.nowCell.j)
{
switch(this.nowCell.resId)
{
case GameDefine.Element.Element_prop_2:
this.nowCell.playAnimationUseProp();
await this.sleep(0.5);

//使用道具2效果
this.useProp2(this.nowCell);

//执行一次下落
await this.sleep(0.5);
this.dropCell();
this.fillNullCell();

//全棋盘更新
this.updateAllCell();
break;
default:
break;
}
}
else
{
//…
}
//…
}
}
//…
}

​ 这里我只写了一个消除一整行的道具,后续要加的话也是按这个流程在这几个函数里面增加就可以了,比如增加一个洗牌道具就只要switch里面增加一条,再写一个道具效果函数,再根据规则处理生成条件,最后再添加动画,图片之类的资源就可以了,下次再加@v@

七、计分与胜负

1、计分板
(1)绘制计分板

请添加图片描述

请添加图片描述

​ 目前是两种模式就两种计分板,第一个是计时+计分,第二个是计时+剩余任务道具数

(2)模式切换

​ 创建一个脚本,拖进计分板,在游戏开始的时候初始化一下节点,节点的active属性可以控制节点的属性,不同的模式只将对应的节点显示出来就可以,这里只写了计时,道具,计分三种模式,其实还可以加一个步数,消除类型之类的玩法,下次再加@v@

//根据游戏类型初始化节点
initFormType(type:number)
{
switch(type)
{
case GameDefine.LevelType.LevelType_TimeAndNum:
this.node.getChildByPath("time").active = true;
this.node.getChildByPath("planProp").active = true;
this.node.getChildByPath("score").active = false;
break;
case GameDefine.LevelType.LevelType_TimeAndScore:
this.node.getChildByPath("time").active = true;
this.node.getChildByPath("planProp").active = false;
this.node.getChildByPath("score").active = true;
break;
default:
break;
}
}

2、计时

用*schedule()*就可以计时,*unschedule()*停止计时,每秒更新一下计分板上的计时,当时间小于0时计时结束,触发计时结束回调

//计时结束回调
timeOverCallBack:Function = null;

//开始计时器
startTimer(time:number)
{
if(time <= 0) return;

this.setTime(time);

this.schedule(this.timeSchedule,1);
}

//计时任务(每秒)
timeSchedule()
{
this.time;
if(this.time<0)
{
this.time = 0;
this.timeOverCallBack();
this.stopTimer();
return;
}
this.node.getChildByPath("time/timeData").getComponent(Label).string = this.time.toString();
}

//停止计时器
stopTimer()
{
this.unschedule(this.timeSchedule);
}

3、分数和任务道具

​ 用参数记录下来,再写*get()和set()函数以供调用就可以了,在set()*里面写更新计分板,这里还写了两个工具函数,比较目标和当前的大小

//目标任务道具数量
targetPlanProp:number = 0;
//当前任务道具数量
currentPlanProp:number = 0;

//目标分数
targetScore:number = 0;
//当前分数
currentScore:number = 0;

//对比目标分数和当前分数
compareScore():boolean
{
return this.currentScore >= this.targetScore;
}

//对比任务道具
comparePlanProp():number
{
return this.targetPlanProp this.currentPlanProp;
}

setCurrentPlanProp(planProp:number)
{
this.currentPlanProp = planProp;

this.node.getChildByPath("planProp/planPropData").getComponent(Label).string = this.currentPlanProp.toString();
}
getCurrentPlanProp():number|null
{
return this.currentPlanProp;
}

//…

4、绘制胜负界面

​ 胜利界面

请添加图片描述

​ 失败界面

请添加图片描述

​ 就是界面+下一关按钮+再来一次按钮+返回按钮+背景遮挡

5、判断胜负
(1)胜利条件

​ 根据不同模式判断是否胜利

//判断胜利条件
judgeSuccessOrDefeat()
{
switch(this.type)
{
case GameDefine.LevelType.LevelType_TimeAndNum:
//道具全消除
if(this.scoreControl.getCurrentPlanProp() <= 0)
{
this.gameOver();
}
break;
case GameDefine.LevelType.LevelType_TimeAndScore:
//当前分数达到目标分数
if(this.scoreControl.compareScore())
{
this.gameOver();
}
break;
default:
break;
}
}

(2)失败条件

​ 目前是只有一个计时结束后失败结算,在初始化计时的时候设置计时结束回调

//计时结束事件
this.scoreControl.timeOverCallBack = ()=>{
this.gameOver();
}

(3)游戏胜负界面切换

​ 停止计时器再修改界面的显示就可以了

//游戏结束 胜利
gameOverSuccess()
{
//停止计时器
this.scoreControl.stopTimer();

this.resultNode.active = true;
this.resultNode.getChildByName("success").active = true;
this.resultNode.getChildByName("defeat").active = false;
}

//游戏结束 失败
gameOverDefeat()
{
//停止计时器
this.scoreControl.stopTimer();

this.resultNode.active = true;
this.resultNode.getChildByName("success").active = false;
this.resultNode.getChildByName("defeat").active = true;
}

​ 本来这里应该写一个提交结果的,因为这个是在gameOver()中将结果传递给服务端,再由服务端传回来的胜负决定显示胜利或失败,但是感觉这个内容写在下一章网络里面更合适,就不写在这里了

八、网络

1、网络脚本
(1)设置单例模式

​ 由于整个客户端只需要一个脚本主负责网络相关的内容,所以设置一下单例模式,私有静态成员_instance作为唯一单例对象,公有静态get函数用于外部调用网络

//单例
private static _instance:netControl|null = null;
public static get Instance():netControl
{
if(!netControl._instance)
{
netControl._instance = new netControl();
}
return netControl._instance;
}

(2)向服务端发送string类型数据

​ ip地址自测用的是本地127.0.0.1,端口号是自己写的6001,这里是发送http请求,主要是fetch(),其中headers中的method表示请求方式,这里用的是POST,Content-Type表示发送的格式,text/plain表示字符串类型,请求发送后将服务端传回的消息保存在returnMsg中,方便后续解析使用

ip:string = GameDefine.NetConfig.netConfig_ip;
port:string = GameDefine.NetConfig.netConfig_port;

//从服务端返回来的消息
public returnMsg:any = null;

//向服务端发送string消息, 输入:1、消息 2、地址
async sendPostMsg(msg:string,addr:string):Promise<any>
{
let url:string = "http://" + this.ip +":" + this.port + addr;
const rep = await fetch(url,
{
method:"POST",
headers:{
"Content-Type":"text/plain"
},
body:msg
}
)
let text = await rep.text();
//保存服务端传过来的消息
this.returnMsg = text;
return text;
}

(3)向服务端发送Json类型数据

​ 这里的Content-Type是application/json即json类型数据,发送Json类型的数据是主要的数据传递方式,因为Json类型的数据封装和解析起来都很方便。这里有做一个网络未连接的判断,发送string类型的没有,因为这里我是用string类型发送初始化消息,再用Json发送其他消息,所以就只把发送string当初始化用了,实际上应该再额外专门写一个初始化的函数,然后在sendPostMsg中也增加这个网络未连接的判断,就是把sendPostMsg和sendPostJsonMsg作为工具函数来用

//向服务端发送Json消息, 输入:1、消息 2、地址
async sendPostJsonMsg(msg:any,addr:string):Promise<any>
{
if(!netControl.Instance.isNetOk)
{
console.log("网络未连接");
return;
}
let url:string = "http://" + this.ip +":" + this.port + addr;
const rep = await fetch(url,
{
method:"POST",
headers:{
"Content-Type":"application/json"
},
body:JSON.stringify(msg)
}
)
let json = await rep.json();
this.returnMsg = json;
return json;
}

2、发送初始化网络消息

​ 在游戏刚开始,主页界面调用初始化网络,向服务端发送一条/init消息,如果收到了服务端消息则将isNetOk改为true,表示网络已连接,这里要注意要用try-catch包裹起来,主要是因为如果网络未连接就调用*fetch()*的话程序会报错崩溃,话说是不是应该把try-catch写在sendPostMsg()和sendPostJsonMsg()里面,下次再改@v@

//初始化网络(测试网络连接)
async initNet()
{
try{
await netControl.Instance.sendPostMsg("","/init");

if(netControl.Instance.returnMsg != null)
{
console.log(netControl.Instance.returnMsg);
netControl.Instance.isNetOk = true;
netControl.Instance.returnMsg = null;
}else{
console.log("连接服务端失败");
}
}catch{
console.log("网络未连接");
}
}

3、发送选关信息消息
(1)发送消息

​ 在进入选关界面之前,发送选关信息/levelSelect消息,若获取到了则用loadScene(“levelSelect”)转到选关界面

//前往选关界面
async gotoLevelSelect()
{
try
{
if(!netControl.Instance.isNetOk)
{
console.log("网络未连接");
return;
}

//发送消息
await netControl.Instance.sendPostJsonMsg({uid:this.uid},"/levelSelect");
if(netControl.Instance.returnMsg == null)
{
console.log("关卡数据获取失败");
}
else
{
director.loadScene("levelSelect");
}
}
catch
{
console.log("网络异常");
}
}

(2)解析选关信息

​ 获取到选关信息后转成json解析,这里从服务端拿到的是关卡数量levelNum和关卡信息levels,获取后解析出来保存起来后续使用

//获取关卡信息
getLevelData()
{
let jsonMsg = netControl.Instance.returnMsg;

if(!jsonMsg)
{
console.log("返回选关数据为空");
return;
}
else
{
this.levelNum = jsonMsg["levelNum"];
let levels = jsonMsg["levels"];
for(let item of levels)
{
this.levelMap.set(item["id"],item["levelState"]);
}
}
}

(3)选关界面

​ 关卡按钮就是精灵+文本

请添加图片描述

​ 选关界面就是背景+显示位置+返回按钮

请添加图片描述

​ 这里的实现方式和棋盘的初始化是一样的,设置按钮缩放,然后用instantiate()生成关卡的预制体。

@property(Prefab)
levelPrefab:Prefab = null;

initLevel()
{
this.getLevelData();

//显示界面大小
let ui = this.node.getChildByName("pos").getComponent(UITransform);
let uiWidth = ui.width;
let uiHeight = ui.height;

//按钮大小
let levelWidth = uiWidth / this.rows;
let levelHeight = uiHeight / this.cols;

//按钮缩放
let levelScaleW = levelWidth / ((1+GameDefine.LevelSelectConfig.level_btn_Border_W)*GameDefine.LevelSelectConfig.level_Width);
let levelScaleH = levelHeight / ((1+GameDefine.LevelSelectConfig.level_btn_Border_H)* GameDefine.LevelSelectConfig.level_Width);

let tmpId:number = 0;
for(let j:number=0;j<this.cols;j++)
{
for(let i:number=0;i<this.rows;i++)
{

if(tmpId >= this.levelNum) break;

let level = instantiate(this.levelPrefab);

//脚本初始化
let levelCtl = level.getComponent(levelControl);
levelCtl.id = tmpId + 1;
levelCtl.resId = this.levelMap.get(tmpId + 1);
levelCtl.initLevel();

//点击事件
levelCtl.selectedCallBack = ()=>{
//未解锁的关卡
if(levelCtl.resId == GameDefine.LevelState.level_lock)
{
console.log("未解锁的关卡");
return;
}
this.gotoLevel(levelCtl.id);
}

//位置
level.setPosition((1+GameDefine.LevelSelectConfig.level_btn_Border_W)*levelWidth * i, (1+GameDefine.LevelSelectConfig.level_btn_Border_H)*levelHeight * j);

//缩放
level.setScale(levelScaleW,levelScaleW);

level.parent = this.node.getChildByName("pos");

tmpId++;
}
if(tmpId > this.levelNum) break;
}
}

​ 这里应该再加一个上一页和下一页的按钮,以及界面判断页数和相应页数关卡的显示的,只要这里初始化的时候初始化一个页数的值,后续根据页数更新各个关卡的文本和id就可以了,直接复用这些按钮,然后再加上一页和下一页的函数改变页数的值,更新的时候根据页数 page * page_MaxNum + i 更新每一个按钮的文本就可以了,下次再改@v@

4、发送关卡信息消息
(1)发送消息

​ 进入游戏关卡界面之前,先发送/level关卡数据消息获取关卡数据

//前往游戏关卡界面
async gotoGame(id:number)
{
try{
if(!netControl.Instance.isNetOk)
{
console.log("网络未连接");
return;
}
await netControl.Instance.sendPostJsonMsg({id:id},"/level");
if(netControl.Instance.returnMsg == null)
{
console.log("关卡数据获取失败");
}
else
{
director.loadScene("game");
}
}
catch{
console.log("网络异常");
}
}

(2)解析关卡信息

​ 获取到关卡数据后转成json解析,这里用了as断言,因为确保发送过来的数据和定义的这个结构体是一样的所以可行,主要是图方便,这个是不安全的写法

//初始化关卡数据
initLevelData()
{
let jsonMsg = netControl.Instance.returnMsg;
if(!jsonMsg)
{
console.log("返回关卡数据为空");
return;
}
try{
let levelData:GameDefine.LevelData = jsonMsg as GameDefine.LevelData;

this.id = levelData.id;
this.cols = levelData.cols;
this.rows = levelData.rows;
this.time = levelData.time;
this.planPropNum = levelData.planNum;
this.type = levelData.type;
this.targetScore = levelData.targetScore;
this.scoreControl.setTargetPlanProp(this.planPropNum);
}catch{
console.log("解析levelData json失败");
return;
}
}

5、提交游戏结果信息

​ 在达成条件后进入gameOver(),向服务端发送提交游戏结果消息/result,服务端会返回游戏更新结果updateResult和游戏胜负判断结果gameResult,如果游戏结果更新失败会返回到选关界面,成功则根据返回的胜负结果进入相应的胜利或失败界面。

async gameOver()
{
await this.sendResult(gotoControl.Instance.uid,this.id,this.scoreControl.comparePlanProp(),this.scoreControl.getCurrentScore());
}

//发送游戏结果
async sendResult(uid:number,level_id:number,planProp:number,score:number)
{
try{
await netControl.Instance.sendPostJsonMsg({uid:uid,level_id:level_id,planProp:planProp,score:score},"/result");
if(netControl.Instance.returnMsg == null)
{
console.log("游戏结果发送失败");
}
else
{
console.log("游戏结果已更新");
let jsonMsg = netControl.Instance.returnMsg;
let updateResult = jsonMsg["updateResult"];
if(updateResult == 0)
{
console.log("游戏结果更新失败");
gotoControl.Instance.gotoLevelSelect();
}
else
{
let gameResult = jsonMsg["gameResult"];
if(gameResult == GameDefine.LevelState.level_Pass)
{
//胜利
this.gameOverSuccess();
}
else
{
//失败
this.gameOverDefeat();
}
}
}
}
catch
{
console.log("网络异常");
}
}

九、音乐

1、音乐节点

​ 添加一个专门管理音乐的节点,添加AudioSource组件,然后创建一个专门管理音乐的脚本,这里只要拖入音乐片段就可以使用AudioSource中的函数来对音乐播放器进行操作,clip是音乐资源,loop是是否循环播放,volume是音量,然后还有start()和stop()操作音乐的播放和暂停,这里其实应该要设置成单例的,让整个游戏的音乐都由这个脚本来控制,这样不容易乱,下次再改@v@

@property(AudioClip)
bgmClip:AudioClip = null;

bgm:AudioSource = null;

protected onLoad(): void {
this.bgm = this.node.getComponent(AudioSource);
this.bgm.clip = this.bgmClip;
this.bgm.loop = true;
}

//开始播放
startBgm():boolean
{
if(!this.bgm || !this.bgm.clip) return false;
if(this.bgm.playing) return false;

this.bgm.play();
return true;
}

//暂停或继续
stopOrResumeBgm()
{
if(!this.bgm) return;

if(this.bgm.playing){
this.bgm.stop();
}else{
this.bgm.play();
}
}

//设置音量
setBgmVolume(volume:number)
{
if(!this.bgm) return;

this.bgm.volume = volume;
}

2、音乐资源

​ 如果只是自己练习的话,只要是音乐就可以,但是如果是为了发布游戏的话就要注意是否可以商用

​ 标签是CC0的是可商用,CC-BY的是可商用但是商用时要加上署名鸣谢

​ 我这里用的音乐素材是从Pixabay上下载的,地址:pixabay.com/zh/music,好处是全部可商用而且不止有音乐,还有其他类型的比如图片、视频之类的资源

十、字体

​ 注意系统自带的微软字体是侵权的,不可商用。这里我用了两种免费字体

​ 一种是从站酷下载的站酷快乐体和站酷高端黑,下载地址:https://www.zcool.com.cn/,但是用的使用出了点问题,好像是因为这个字体在打包的时候是在mac系统里面压缩的,导致解压出来后会乱码找不到.tff文件,然后用7-zip打开解压的方式解压出来的.ttf文件放进来后,在CocosCreater里面可以正常显示字体,运行的时候会变回默认字体

​ 一种是阿里巴巴普惠体,下载地址:https://www.alibabafonts.com/#/font,这个把.ttf文件放进项目里面直接就可以用。

​ 由于是后面才改的字体,项目里挺多文本应该还是用的是默认的,等以后真的准备上线了再检查再改@v@


服务端部分

一、httplib初始化

1、cpp-httplib库

​ 这里http的实现用了一个cpp-httplib库,下载地址:https://raw.githubusercontent.com/yhirose/cpp-httplib/master/httplib.h,要注意这个点开的时候可能会卡,把网页的内容全部复制下来,然后创建一个http.h文件把内容复制进去就可以用了,把头文件放进项目里面就可以了,我这里是放进了include文件夹里面引用

请添加图片描述

2、初始化

​ SetConsoleOutputCP(CP_UTF8)设置控制台输出编码防止乱码, svr.Options()设置浏览器跨域请求,如果没有设置这个的话客户端会报跨域请求错误,注意这里面的R"(.*)"其实是不太好的写法,象征着捕获所有的OPTIONS预检请求,在实际业务中还是要区分开,比如说/xxx/*更合理一点,这里是图方便@V@

SetConsoleOutputCP(CP_UTF8);//设置控制台输出为UTF-8编码

httplib::Server svr;

svr.Options(R"(.*)", [](const httplib::Request&, httplib::Response& res) {
res.set_header("Access-Control-Allow-Origin", "*");//允许跨域请求
res.set_header("Access-Control-Allow-Methods", "GET,POST,OPTIONS");//允许的请求方法
res.set_header("Access-Control-Allow-Headers", "Content-Type");//允许的请求头
res.status = 204;//响应状态码为204,表示没有内容
});

二、json解析初始化

​ 这里用了一个json.hpp库,这也是一个头文件,直接放进项目里就可以,依旧是放进include里面,这里要注意这个的路径一定要是*/nlohmann/json.hpp*才可以,下载地址:https://github.com/nlohmann/json/releases

请添加图片描述

三、xml解析初始化

​ 其实可以不用xml解析,这个是刚开始没有引入mysql数据库的时候用xml保存关卡信息,按理说现在应该改成从mysql中获取关卡信息。下次再改@v@

​ 这里用了一个tinyxml2库,这里只要下载头文件tinyxml2.h和cpp文件tinyxml2.cpp然后放进项目里就可以了,这里我依然是放在了include里面,但是会报错,要把这两个文件添加现有项才可以。下载地址:https://github.com/leethomason/tinyxml2

请添加图片描述

请添加图片描述

四、mysql初始化

1、mysql下载

​ 下载地址:https://dev.mysql.com/downloads/mysql/

​ 直接解压,初始化就可以了,网上有很多,这里只贴出步骤

cd D:\\mysql\\mysql-26.7.0-winx64\\mysql-26.7.0-winx64\\bin
mysqld –initialize‑insecure//初始化data
mysqld –install MySQL84//注册服务
net start MySQL84//启动服务
mysql -u root//登录mysql
ALTER USER 'root'@'localhost' IDENTIFIED BY '123456';//改密码

2、Navicat下载

​ 官网:https://www.navicat.com.cn/download/navicat-premium

​ 破解的话网上有很多,这里贴一个推荐的b站up主的视频链接:https://www.bilibili.com/video/BV1TL7GzkE59/?spm_id_from=333.1391.0.0&vd_source=b6da47f369c857c4d1004e7f61c5fa6e

3、libmysql.dll和libmysql.lib

​ 这两个文件可以直接在mysql的安装路径里的D:\\mysql\\mysql-26.7.0-winx64\\mysql-26.7.0-winx64\\lib中找到,创建一个lib文件夹把libmysql.lib放到里面,在程序运行目录exe文件同目录下放libmysql.dll文件,再在项目属性里面配置一下

附加包含目录:

请添加图片描述

附加库目录:

请添加图片描述

附加依赖项:

请添加图片描述

4、初始化MysqlUtil类

​ 这里我创建了类MysqlUtil专门处理和mysql数据库有交互的请求,在构造函数中做一下初始化,用mysql.h库中的mysql_init()和mysql_real_connect()进行初始化,其中mysql_real_connect()中的第五个参数CLIENT_MULTI_STATEMENTS代表允许一次性执行多条sql语句,这个方便后面查询的时候把一些语句连接起来一次性查询,因为分开调用多次mysql_query()是很耗时的,一次mysql_query()查询多条语句更好,析构函数里面用mysql_close()防止内存泄漏

//初始化数据库 host:服务器地址 username:mysql登录用户名 password:密码 db:数据库名
bool MysqlUtil::initMySQL(std::string host, std::string username, std::string password,std::string db)
{
//初始化
m_mysql = mysql_init(nullptr);
if (!m_mysql)
{
printf("mysql_init error");
return false;
}

//连接
if (!mysql_real_connect(m_mysql, host.c_str(), username.c_str(), password.c_str(), db.c_str(), 0, NULL, CLIENT_MULTI_STATEMENTS))
{
printf("mysql_real_connect error");
return false;
}

return true;
}

void MysqlUtil::closeMySQL()
{
if (m_mysql)
{
mysql_close(m_mysql);
m_mysql = nullptr;
}
}

五、初始化请求

​ 客户端发送初始化请求/init,这里是测试连接联通的请求,只要返回一个消息就可以了,只要返回了消息,客户端的isNetOk值就会置为true

//初始化成功
svr.Post("/init", [](const httplib::Request& req, httplib::Response& res) {
std::cout << "连接成功" << std::endl;
res.set_header("Access-Control-Allow-Origin", "*");
res.set_content("服务端连接成功", "text/plain;charset=utf-8");
});

六、选关信息请求

1、处理请求

​ 在main()中处理请求,客户端发送选关信息请求,这个请求就是客户端发送用户uid过来,服务端根据这个用户的通关情况从user_level表中获取这个玩家各个关卡的通关状态信息,然后返回给客户端,这里我创建了一个类match3GameModel来专门处理游戏有关的内容

//选关信息
svr.Post("/levelSelect", [](const httplib::Request& req, httplib::Response& res) {
auto req_json = json::parse(req.body);
int uid = req_json["uid"];

std::cout << "用户" << uid << "获取选关信息" << std::endl;
//获取关卡信息
std::vector<LevelData> vecLevelSelectData = match3GameModel.getLevelSelectData(uid);

json out;
out["levelNum"] = vecLevelSelectData.size();
for (auto item : vecLevelSelectData)
{
json o;
o["id"] = item.id;
o["levelState"] = item.levelState;

out["levels"].push_back(o);
}

res.set_header("Access-Control-Allow-Origin", "*");
res.set_content(out.dump(), "application/json;charset=utf-8");
});

2、从mysql中查询选关数据

​ 使用select \\* from user_level where uid=%d查询user_level表中的数据再保存起来,步骤就是先mysql_query()查询数据库,再mysql_store_result()将查询结果加载到内存,再通过mysql_fetch_row()一行行地取出来解析,最后mysql_free_result()销毁一下result指针就可以了

bool MysqlUtil::queryUserLevel(int uid, std::map<int,PlayerLevelData>& out)
{
if (!m_mysql)
{
return false;
}

char sql[512] = { 0 };
sprintf_s(sql,"select * from user_level where uid=%d", uid);

//查询
if (mysql_query(m_mysql, sql))
{
printf("mysql_query error");
return false;
}

//加载到内存
MYSQL_RES* result = mysql_store_result(m_mysql);
if (!result)
{
printf("mysql_store_result error");
return false;
}

//解析
MYSQL_ROW row;
out.clear();
while ((row = mysql_fetch_row(result)) != nullptr)
{
PlayerLevelData item{};
item.id = atoi(row[0]);
item.uid = atoi(row[1]);
item.level_id = atoi(row[2]);
item.level_state = atoi(row[3]);

out[item.level_id] = item;
}

mysql_free_result(result);
return true;
}

user_level表:

请添加图片描述

3、读取关卡状态

​ 调用queryUserLevel()后读取出来的关卡信息会保存在第二个std::map<int,PlayerLevelData>类型的参数里面,将里面的关卡数据取出来按关卡顺序排列存到std::vector<LevelData>里,如果遇到数据库里没有查到的记录就相当于玩家没有玩过或者解锁这一关,则默认是这一关未解锁

//获取选关数据 根据用户uid 获取关卡状态
std::vector<LevelData> Match3GameModel::getLevelSelectData(int uid)
{
std::vector<LevelData> out;
out.clear();
std::map<int,PlayerLevelData> mapPlayerLevelData;

if (!m_mysqlUtil.queryUserLevel(uid, mapPlayerLevelData))
{
printf("queryUserLevel error\\n");
return out;
}

if (mapPlayerLevelData.size() == 0)
{
printf("mapPlayerLevelData is null");
return out;
}

out.clear();

int maxLevel = mapPlayerLevelData.rbegin()->first;

//从数据库读取关卡状态
for (const auto &item : m_vecLevelData)
{
LevelData tmp{0};
tmp.id = item.id;

auto it = mapPlayerLevelData.find(item.id);
if (it != mapPlayerLevelData.end())
{
tmp.levelState = it->second.level_state;
}
else
{
//如果没有这关的记录 默认未解锁
tmp.levelState = level_lock;
}

out.push_back(tmp);
}

return out;
}

七、关卡信息请求

1、获取关卡信息数据

​ 这里是通过读取xml文件中的数据来获得关卡信息,用的主要就是tinyxml2::XMLDocument中的函数,通过FirstChildElement()读取一个元素,通过QueryIntAttribute()来读取元素的属性,NextSiblingElement()下一个元素

//从XML文件中获取关卡数据 用于初始化
bool Match3GameModel::initLevelDataFromXML(const std::string fileName)
{
tinyxml2::XMLDocument xml;
XMLError res = xml.LoadFile(fileName.c_str());
if (res != XML_SUCCESS)
{
return false;
}

m_vecLevelData.clear();

XMLElement *levels = xml.FirstChildElement("levels");
if (!levels) return false;

XMLElement* level = levels->FirstChildElement("level");
while (level)
{
LevelData item{};

level->QueryIntAttribute("id", &item.id);
level->QueryIntAttribute("type", &item.type);
level->QueryIntAttribute("rows", &item.rows);
level->QueryIntAttribute("cols", &item.cols);
level->QueryIntAttribute("planNum", &item.planNum);
level->QueryIntAttribute("time", &item.time);
level->QueryIntAttribute("targetScore",&item.targetScore);

m_vecLevelData.push_back(item);
level = level->NextSiblingElement("level");
}
}

levelData.xml文件:

请添加图片描述

​ 读取关卡信息要在客户端发送请求之前就读取到存储起来,因为关卡信息的配置是固定的,防止后续需要关卡信息的时候重复读文件。

int main()
{
//…
//读取关卡信息
if (!match3GameModel.initLevelDataFromXML("./levelData.xml")) {
std::cout << "levelData.xml读取失败" << std::endl;
return -1;
}

//svr.Post()…
return 0;
}

​ 同时这里可以优化一下,因为关卡信息在数据库中有个level表,按理说关卡信息也在mysql中读取会比较好,也方便以后联表查询,但是用xml文件存储关卡信息是一开始没有引入mysql的时候做的,当时没有考虑这方面,如果要改的话就是增加在MysqlUtil和Match3GameModel中添加关卡信息查询的函数,在MysqlUtil中的实现方式和选关数据查询类似,只是查询语句改成select \\* from level`就可以,然后把查到的数据读出来,然后再在Match3GameModel中把数据存储在m_vecLevelData*中就可以,然后这个函数可以直接替换掉现在的initLevelDataFromXML(),这样就从xml文件中读取改成了从mysql数据库中读取。目前xml读取也可用,下次再改@v@

2、获取指定关卡的关卡信息数据

​ 在接收请求之前关卡数据局已经读取出来保存起来了,直接找出来就可以了

LevelData Match3GameModel::getLevelData(int id)
{
LevelData out{};

for (const auto &item : m_vecLevelData)
{
if (item.id == id)
{
out = item;
break;
}
}

return out;
}

3、处理请求

​ 根据客户端的需要返回参数就可以了,这里要注意因为客户端那里使用了as断言,所以要求这个返回的格式一定要和约定的结构体能够对应上,否则客户端解析的时候会有问题

//关卡信息
svr.Post("/level", [](const httplib::Request& req, httplib::Response& res) {
auto req_json = json::parse(req.body);
int id = req_json["id"];

std::cout << "获取第" << id << "关的数据" << std::endl;

//获取关卡信息
LevelData levelData = match3GameModel.getLevelData(id);

//输出
json out;
out["id"] = levelData.id;//关卡id
out["type"] = levelData.type;//关卡类型

out["rows"] = levelData.rows;//行数
out["cols"] = levelData.cols;//列数

out["planNum"] = levelData.planNum;//关卡任务道具数量
out["time"] = levelData.time;//关卡时间 单位:s
out["targetScore"] = levelData.targetScore;//目标分数

res.set_header("Access-Control-Allow-Origin", "*");
res.set_content(out.dump(), "application/json;charset=utf-8");
});

八、提交游戏结果请求

1、处理请求

​ 从客户端获取用户uid、关卡level_id、任务道具数planProp、分数score信息,更新后返回提交结果和游戏胜负

//提交游戏结果
svr.Post("/result", [](const httplib::Request& req, httplib::Response& res) {
auto req_json = json::parse(req.body);
int uid = req_json["uid"];
int level_id = req_json["level_id"];
int planProp = req_json["planProp"];
int score = req_json["score"];

std::cout << "用户" << uid << "提交第" << level_id << "关的数据" << std::endl;

//更新关卡信息
bool result = match3GameModel.updateLevelData(uid, level_id, planProp, score);

//输出
json out;
out["updateResult"] = result;//提交结果 1:成功,0:失败
out["gameResult"] = match3GameModel.getIsPass();

res.set_header("Access-Control-Allow-Origin", "*");
res.set_content(out.dump(), "application/json;charset=utf-8");
});

2、更新mysql中的数据
(1)获取当前关卡和下一关卡的状态信息和历史最高分数信息

​ 根据是否传了下一个的数据判断是否查询下一关的信息,这里这么写主要是防止nextLevelData是nullptr然后调用nextLevelData->id的时候溢出,查询后加载进内存然后解析,如果读出来一条数据,判断读出来的是否是当前关卡,如果是则存储关卡状态和历史最高分数信息,如果不是则说明没有这一条记录,则insert into user_level(uid,level_id,level_state,maxScore) value(%d,%d,%d,%d) on duplicate key update level_state=%d;新建一条数据,因为设置了唯一索引所以用on duplicate key update level_state=%d如果里面有这条数据则只修改状态,但是其实这里可以不用on dyokucate key,因为已经判断过了,这个唯一索引的应用主要是在后面修改下一关的关卡状态的时候,这里的话这么写也没啥影响就不改了@v@

​ 根据是否传了下一个的数据判断是否查询下一关的信息,这里这么写主要是防止nextLevelData是nullptr然后调用nextLevelData->id的时候溢出,查询后加载进内存

bool MysqlUtil::updateUserLevel(int uid, int planProp,int score, LevelData &nowLevelData, LevelData *nextLevelData)
{
if (!m_mysql)
{
return false;
}

char sql[1024] = { 0 };
int ret = 0;
//查询当前关卡状态信息
if (nextLevelData != nullptr)
{
sprintf_s(sql, "select level_id,level_state,maxScore from user_level where uid=%d and (level_id=%d or level_id=%d);",
uid, nowLevelData.id, nextLevelData->id);
}
else
{
sprintf_s(sql, "select level_id,level_state,maxScore from user_level where uid=%d and level_id=%d;",
uid, nowLevelData.id);
}

ret = mysql_query(m_mysql, sql);
if (ret != 0)
{
printf("mysql_query error");
return false;
}

//从这里之后sql用拼接追加方式 不再做查询,方便后续一次性修改操作
memset(&sql, 0, sizeof(sql));
char tmpSql[256] = { 0 };

//加载到内存
MYSQL_RES* result = mysql_store_result(m_mysql);
if (!result)
{
printf("mysql_store_result error");
return false;
}

//…
}

​ 然后解析,如果读出来一条数据,判断读出来的是否是当前关卡,如果是则存储关卡状态和历史最高分数信息,如果不是则说明没有这一条记录,则insert into user_level(uid,level_id,level_state,maxScore) value(%d,%d,%d,%d) on duplicate key update level_state=%d;新建一条数据,因为设置了唯一索引所以用on duplicate key update level_state=%d如果里面有这条数据则只修改状态,但是其实这里可以不用on dyokucate key,因为已经判断过了,这个唯一索引的应用主要是在后面修改下一关的关卡状态的时候,这里的话这么写也没啥影响就不改了@v@,

​ 如果是下一关的数据则保存一下下一关的状态。如果一条都没查出来说明没有当前关卡的记录,则创建一条,然后就完成了第一条记录的解析,再用(row = mysql_fetch_row(result)) != nullptr判断一下是否有第二条记录,如果有就说明是下一关的数据,保存一下就可以了

bool MysqlUtil::updateUserLevel(int uid, int planProp,int score, LevelData &nowLevelData, LevelData *nextLevelData)
{
//…

//解析
MYSQL_ROW row;
int iLevelState = 0;//当前关卡状态
int iNextLevelState = 0;//下一关卡
int iMaxScore=0xffffffff;//最高分数

if ((row = mysql_fetch_row(result)) != nullptr)
{
int tmpLevelId = atoi(row[0]);
if (tmpLevelId == nowLevelData.id)
{
iLevelState = atoi(row[1]);
iMaxScore = atoi(row[2]);
}
else if (nextLevelData != nullptr)
{
//若无此记录则新建一条,因为有唯一索引约束,所以使用insert on duplicate key update
sprintf_s(tmpSql, "insert into user_level(uid,level_id,level_state,maxScore) value(%d,%d,%d,%d) on duplicate key update level_state=%d;",
uid, nowLevelData.id, level_noPass, score);
strcat_s(sql, tmpSql);

iLevelState = level_noPass;
iMaxScore = score;

if (tmpLevelId == nextLevelData->id)
{
iNextLevelState = atoi(row[1]);
}
}
}
else
{
//若无此记录则新建一条,因为有唯一索引约束,所以使用insert on duplicate key update
sprintf_s(tmpSql, "insert into user_level(uid,level_id,level_state,maxScore) value(%d,%d,%d,%d) on duplicate key update level_state=%d;",
uid, nowLevelData.id, level_noPass, score);
strcat_s(sql, tmpSql);

iLevelState = level_noPass;
iMaxScore = score;
}

//是否有下一关
if ((row = mysql_fetch_row(result)) != nullptr)
{
iNextLevelState = atoi(row[0]);
}

mysql_free_result(result);

//…
}

(2)判断是否通过

​ 根据不同的模式和传过来的数据判断一下胜负就可以了

//判断是否通过
switch (nowLevelData.type)
{
case LevelType_TimeAndNum:
if (planProp >= nowLevelData.planNum)
{
m_isPass = level_Pass;
}
break;
case LevelType_TimeAndScore:
if (score > nowLevelData.targetScore)
{
m_isPass = level_Pass;
}
break;
}

(3)修改关卡状态

​ 若当前关卡状态是未通过则修改为通过,sql拼接一下update user_level set level_state=%d where uid=%d and level_id=%d;*`语句。

​ 若下一关卡状态是未解锁则修改为未通过,即解锁,sql再拼接一下update user_level set level_state=%d where uid=%d and level_id=%d;sql语句是一样的,传的数值不一样

​ 无论是否通过,若当前分数大于历史最高分,则修改历史最高分,sql拼接一下update user_level set maxScore=%d where uid=%d and level_id=%d;

bool MysqlUtil::updateUserLevel(int uid, int planProp,int score, LevelData &nowLevelData, LevelData *nextLevelData)
{
//…

//若通过
if (m_isPass == level_Pass)
{
//若当前关卡状态为未通过则修改为通过
if (iLevelState == level_noPass)
{
sprintf_s(tmpSql, "update user_level set level_state=%d where uid=%d and level_id=%d;",
level_Pass, uid, nowLevelData.id);
strcat_s(sql, tmpSql);
}
//若下一关未解锁则解锁下一关
if ((nextLevelData != nullptr) && iNextLevelState == level_lock)
{
sprintf_s(tmpSql, "update user_level set level_state=%d where uid=%d and level_id=%d;",
level_noPass, uid, nextLevelData->id);
strcat_s(sql, tmpSql);
}
}

//若分数大于当前最高分
if (score > iMaxScore)
{
sprintf_s(tmpSql, "update user_level set maxScore=%d where uid=%d and level_id=%d;",
score, uid, nowLevelData.id);
strcat_s(sql, tmpSql);
}

//…
return true;
}

(4)提交事务

​ sql拼接commit;提交事务,

​ mysql_query()一次性查询多条语句,因为我们在mysql_init()第五个参数写的是CLIENT_MULTI_STATEMENTS才可以拼接,否则会在这里报错,查询失败则执行一下rollback;做一下事务回滚

​ 这里注意无论是否执行成功,都要用mysql_next_result()循环执行一下mysql_store_result()然后mysql_free_result()把结果集消耗掉,不然后续再查询会出问题

strcat_s(sql, "commit;");

if (mysql_query(m_mysql, sql) != 0)
{
printf("执行失败 %s\\n",mysql_error(m_mysql));
//回滚
mysql_query(m_mysql, "rollback;");
}

//多语句执行要消耗掉全部结果集
do
{
result = mysql_store_result(m_mysql);
mysql_free_result(result);
}while (mysql_next_result(m_mysql) == 0);

然后就可以了,只要返回的是true,就是更新成功了

后话

​ 虽然我自认为是C/S架构,但是其实我测试的时候Cocos客户端是运行在浏览器上面的,感觉也像是B/S架构

​ 这个项目有一个很大的问题就是高并发问题,因为这个服务端的网络通信是用的cpp-httplib,只适合轻量级的项目,这个库其实是有自带线程池的调用,只是我这里没写,下次一定@v@,如果写了的话其实是可以解决中小型后台的并发问题的,对于三消小游戏而言完全够用。

​ 这里的用户名uid是写死的,但是mysql数据库中有user表而且user_level表的数据也与uid强相关,因为我一开始写这个项目是以微信小游戏为目标写的,微信小游戏中可以调用微信提供的api,以微信的uid作为用户名uid来写,这样的话就完全不用注册,登录的常规流程,当然如果要写的话也是很简单的,在客户端增加注册和登录的界面,一样的发送http请求给服务端,服务端再对user表进行增删改查就可以了,但是我后续应该是不会再加这个,现在的情况是我的图片之类的资源很普通很容易侵权,所以没有办法上线微信小游戏,待我捣鼓一下AI再给他用微信api的方式增加用户@v@

赞(0)
未经允许不得转载:171主机测评 » Cocos实现经典三消游戏-消消乐
分享到: 更多 (0)

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址