基于Node.js、Koa、Typescript、Socket.io实现的ddz游戏服务端。
主要功能:游客登录、房间功能、准备功能、发牌功能、抢地主功能(抢地主流程)、出牌功能(牌型校验、牌型比较、出牌流程)、Socket对象管理
客户端(基于Cocos Creator2.x):https://gitee.com/baymax668/cocos-ddz-client
项目启动:
npm install
npm run server
目录结构:
|-- src
| |-- app.ts ## 【程序入口】
| |-- constant ## 【枚举和常量】
| | |-- GameStage.ts # 游戏状态/阶段
| | |-- PokerHand.ts # 牌型
| | |-- PokerPoints.ts # 牌点数
| | |-- PokerSuits.ts # 牌花色
| | |-- SocketGameEvent.ts # socket游戏事件枚举
| | |-- SocketRoomEvent.ts # socket房间事件枚举
| |-- controllers ## 【控制器】存放路由对应的程序逻辑
| | |-- ExampleController.ts
| | |-- GameController.ts # 游戏相关控制器
| | |-- RoomController.ts # 房间相关控制器
| | |-- UserController.ts # 用户相关控制器(用户登录)
| |-- data ## 【数据模型】
| | |-- Player.ts # 玩家
| | |-- Poker.ts # 卡牌
| | |-- Referee.ts # 裁判(每个房间都有一个裁判管理游戏流程)
| | |-- ResData.ts # http响应数据对象
| | |-- Room.ts # 房间
| | |-- SocketData.ts # socket通信数据对象
| | |-- User.ts # 用户
| |-- game ## 【游戏相关】
| | |-- PokerManager.ts # 扑克牌管理,生成一组扑克牌
| | |-- RoomManager.ts # 房间管理
| |-- middlewares ## 【中间件】
| | |-- ErrorHandle.ts # 异常处理
| | |-- NotFound.ts # 404处理
| | |-- Result.ts # 封装通用响应函数(succ、err、result)
| |-- routes ## 【http url路由】
| | |-- ExampleRoute.ts
| | |-- GameRoute.ts
| | |-- RoomRoute.ts
| | |-- UserRoute.ts
| | |-- index.ts # 自动导入所有路由
| |-- utils ## 【工具类】
| | |-- ArrayUtil.ts # 数组工具类(数据交互函数)
| | |-- PokerUtil.ts # 扑克牌工具类(排序、牌型校验、牌组比较等)
| | |-- RandomUtil.ts # 随机函数封装
| |-- websocket ## 【socket管理】
| |-- GameNotifier.ts # 游戏相关通知
| |-- SocketManager.ts # 用户管理所有socket对象,将user.id和socket对象关联
|-- test ## 单元测试
| |-- PokerUtil.test.ts # PokerUtil.ts的测试(测试牌组校验、排序、以及牌组大小比较)
| |-- TestCode.ts
|-- package-lock.json
|-- package.json
说明:整个系统使用主要使用B/S架构,后端执行流程为Koa的洋葱模型
。
执行流程主要为:中间件(middleware)->路由(route)->控制器(controller)->具体代码逻辑
基本架构图:
详细架构图:
其实Socket.io具备请求响应功能
通信基本模型图:
URL:http://localhost:3000/api/v1/user/login/guest
Method:Post
参数:None
返回值:
名称 | 类型 | 描述 |
---|---|---|
id | string | 用户id |
name | string | 用户名/昵称 |
balance | number | 余额 |
URL:http://localhost:3000/api/v1/room/join
Method:Post
参数:
名称 | 类型 | 描述 |
---|---|---|
user | object | 用户对象(包含id、name、balance) |
返回值:None
URL:http://localhost:3000/api/v1/room/leave
Method:Post
参数:
名称 | 类型 | 描述 |
---|---|---|
uid | string | 用户id |
roomId | string | 房间id |
准备阶段使用
URL:http://localhost:3000/api/v1/game/prepare
Method:Post
参数:
名称 | 类型 | 描述 |
---|---|---|
uid | string | 用户id |
roomId | string | 房间id |
isPrepare | boolean | 是否准备 |
发牌阶段。客户端整理好牌后,需要调用这个api,表示准备就绪
URL:http://localhost:3000/api/v1/game/ready
Method:Post
参数:
名称 | 类型 | 描述 |
---|---|---|
uid | string | 用户id |
roomId | string | 房间id |
抢/不抢地主也是这个api
URL:http://localhost:3000/api/v1/game/call
Method:Post
参数:
名称 | 类型 | 描述 |
---|---|---|
uid | string | 用户id |
roomId | string | 房间id |
isCall | boolean | 是否叫地主 |
URL:http://localhost:3000/api/v1/game/play
Method:Post
参数:
名称 | 类型 | 描述 |
---|---|---|
uid | string | 用户id |
roomId | string | 房间id |
isPlay | boolean | 是否是出牌(否则为不出牌) |
cards | Array<Poker> | 要出的牌 |
注意:客户端只通过Socket监听数据,不会通过Socket发任何数据
/**
* 游戏相关的Socket事件
*/
export enum SocketGameEvent {
CHANGE_STAGE = 'ctx-game-stage', // 修改游戏阶段
PREPARE = 'ctx-game-prepare', // 有人准备/取消准备
DEAL = 'ctx-game-deal', // 系统发牌
SET_CALL_PLAYER = 'ctx-game-set-call-player', // 系统通知轮到谁叫地主
CALL = 'ctx-game-call', // 有人叫地主/不叫地主
SET_LANDLORD = 'ctx-game-set-landlord', // 系统设置地主
SET_PLAY = 'ctx-game-set-play', // 系统通知轮到谁出牌
PLAY_CARDS = 'ctx-game-play-cards', // 有人出牌/不出牌
RESULT = 'ctx-game-result', // 游戏结果
}
系统通知进入某个阶段
Event:ctx-game-stage
返回值:
名称 | 类型 | 描述 |
---|---|---|
stage | number | 状态值 |
/**
* 游戏阶段
*/
export enum GameStage {
PREPARATION = 0, // 准备阶段
REFEREE_DEEL = 1, // 发牌阶段
CALL_LANDLORD = 2, // 叫地主阶段
GAMING = 3, // 游戏中(玩家轮流出牌)
SETTLEMENT = 4, // 结算中
}
Event:ctx-room-join
返回值:
名称 | 类型 | 描述 |
---|---|---|
player | object | 用户数据对象 |
index | number | 座位 |
Event:ctx-room-leave
返回值:
名称 | 类型 | 描述 |
---|---|---|
uid | string | 用户id |
index | number | 座位 |
Event:ctx-game-prepare
返回值:
名称 | 类型 | 描述 |
---|---|---|
uid | string | 用户id |
isPrepare | boolean | 准备/取消准备 |
Event:ctx-game-deal
返回值:
名称 | 类型 | 描述 |
---|---|---|
uid | string | 用户id |
cards | Array | 系统发给这个用户的牌 |
系统通知轮到哪个玩家叫地主
Event:ctx-game-set-call
返回值:
名称 | 类型 | 描述 |
---|---|---|
uid | string | 用户id |
系统通知谁点击了抢/不抢地主
Event:ctx-game-call
返回值:
名称 | 类型 | 描述 |
---|---|---|
uid | string | 用户id |
isCall | boolean | 是否抢地主 |
Event:ctx-game-set-landlord
返回值:
名称 | 类型 | 描述 |
---|---|---|
uid | string | 用户id |
cards | Array | 地主牌 |
系统通知轮到哪个玩家出牌
Event:ctx-game-set-play
返回值:
名称 | 类型 | 描述 |
---|---|---|
uid | string | 用户id |
Event:ctx-game-play-cards
返回值:
名称 | 类型 | 描述 |
---|---|---|
uid | string | 用户id |
isPlay | boolean | 是否出牌 |
cards | boolean | 出的牌 |
Event:ctx-game-result
返回值:
名称 | 类型 | 描述 |
---|---|---|
- | - | - |
说明:牌组排序是一个两级排序,先排序点数,相同的点数再根据花色排序。
思路:
关键代码:
// Poker.ts
export class Poker implements IPokerVo {
// ...
/**
* 用于比较大小
*/
public get value() {
return this.suit + this.pointWeight * 100
}
// ...
}
// PokerUtil.ts
/**
* 对一副牌从大到小排序
* @param cards
* @returns
*/
public static sort(cards: Array<IPokerVo>) {
return cards.sort((c1: IPokerVo, c2: IPokerVo) => {
const poker1 = new Poker(c1)
const poker2 = new Poker(c2)
return poker1.compare(poker2)
}).reverse()
}
说明:牌型校验用于判断一个牌组是否是符合游戏的牌型(单张、对子、三张、三带一...),因为不同牌型的大小比较方式不同,需要获取牌型才可以进行两组牌型大小比较。
大致思路:
牌型对应的牌数:
牌组校验思路:
核心代码:
// PokerUtil.ts
/**
* 检测一副牌的牌型(hand会将cards牌组排序)
* @param cards
*/
public static hand(cards: Array<IPokerVo>): PokerHand {
// 每张牌是否唯一(安全校验)
if (!this.isOnly(cards)) {
return PokerHand.NONE
}
// 排序
cards = this.sort(cards)
const count = cards.length
// 按牌数划分检测逻辑
if (count === 1) {
// 单张
return PokerHand.T_A
} else if (count === 2) {
if (this.isSame(cards)) {
// 对子
return PokerHand.T_AA
} else if (this.isRocket(cards)) {
// 王炸(火箭)
return PokerHand.T_ROCKET
}
} else if (count === 3) {
if (this.isSame(cards)) {
// 三张
return PokerHand.T_AAA
}
} else if (count === 4) {
if (this.isSame(cards)) {
// 炸弹
return PokerHand.T_AAAA
}
else if (this.isAAAB(cards)) {
// 三带一
return PokerHand.T_AAA_B
}
} else if (count >= 5) {
if (this.isABCDE(cards)) {
// 顺子
return PokerHand.T_ABCDE
} else if (this.isAAABB(cards)) {
// 三带二,三带两对 AAA BC / AAA BBCC
return PokerHand.T_AAA_BB
} else if (this.isAABBCC(cards)) {
// 双顺子 AABBCC
return PokerHand.T_AABBCC
} else if (this.isAAABBB(cards)) {
// 三顺子(飞机) AAABBB
return PokerHand.T_AAABBB
}else if (this.isAAABBBCD(cards)) {
// 飞机带翅膀 AAABBB CD / AAABBB CCDD
return PokerHand.T_AAABBB_CD
}
}
return PokerHand.NONE
}
说明:不同的牌型,比较大小的方式不一样,并且还要考虑两个牌型是否能进行比较。
火箭和炸弹的情况:火箭(王炸)>炸弹>其他牌型。火箭和炸弹可以压其他牌型
思路:
三带一取出3张相同的点数思路:牌组首先有序,使用长度为3的滑动窗口遍历数组,当3个牌的点数相同时,返回这张牌的点数。
核心代码:
// PokerUtil.ts
/**
* 判断cards2能否压cards1
* @param cards1
* @param cards2
*/
public static legal(cards1: Array<IPokerVo>, cards2: Array<IPokerVo>): boolean {
// 获取牌组牌型,并且对牌组进行排序
const hand1 = this.hand(cards1)
const hand2 = this.hand(cards2)
if (hand1 == PokerHand.T_ROCKET || hand2 == PokerHand.NONE) {
// cards1是王炸,直接false
// cards2没有牌型,直接false
return false
}
// && hand2 != PokerHand.NONE
if (hand1 == PokerHand.NONE) {
// cards1没有牌型,cards2有牌型(上一个if已确定) 直接true
return true
}
if (hand1 === hand2 && cards1.length === cards2.length) {
// 牌型相等、并且牌数相同
switch (hand1) {
// 单张
case PokerHand.T_A:
// 对子
case PokerHand.T_AA:
// 三张
case PokerHand.T_AAA:
// 炸弹
case PokerHand.T_AAAA:
// 顺子
case PokerHand.T_ABCDE:
// 双顺子
case PokerHand.T_AABBCC:
// 三顺子
case PokerHand.T_AAABBB:
// 比较最后一张点数权重的大小
if (this.compareLastByPointWeight(cards1, cards2) < 0) {
return true
}
break;
// 飞机带翅膀
case PokerHand.T_AAABBB_CD:
const cards1By3 = this.getThreeSomeArr(cards1)
const cards2By3 = this.getThreeSomeArr(cards2)
// 比较最后一张点数权重的大小
if (this.compareLastByPointWeight(cards1By3, cards2By3) < 0) {
return true
}
break;
// 三带一
case PokerHand.T_AAA_B:
// 三带二、三带两对
case PokerHand.T_AAA_BB:
const card1 = this.getThreeSome(cards1)
const card2 = this.getThreeSome(cards2)
if (!card1 || !card2) {
throw new Error('no three are the same')
}
if (this.compareLastByPointWeight([card1], [card2]) < 0) {
return true
}
break;
}
} else if (hand1 !== hand2) {
// 牌型不相等;牌数可相等,也可不相等
// cards2是王炸、炸弹,可以直接压
if (hand2 === PokerHand.T_ROCKET || hand2 === PokerHand.T_AAAA) {
return true
}
}
// 牌型相等,但是牌数不相等,直接false
return false
}
说明:需要生成一副牌(54张),洗牌后预留3张地主牌,并且获取3组牌(每组17张)用于分发给3名玩家
思路:封装PokerManger类,实现生成一副牌,并且实现洗牌功能。在房间的Referee类中,分发这副牌
洗牌思路:获取两个随机值(0-53),对54张牌两两随机交换。
核心代码:
// PokerManager.ts
/**
* 扑克牌管理类
*/
export default class PokerManager {
// ...
// 获取一副牌
public getDeck(): Poker[] {
let cards: Array<Poker> = []
// 花色
// SPADE = 3, // 黑桃♠
// HEARD = 2, // 红桃♥
// CLUB = 1, // 梅花♣
// DIAMOND = 0, // 方块♦
const suits = [PokerSuits.DIAMOND, PokerSuits.CLUB, PokerSuits.HEARD, PokerSuits.SPADE]
// 牌值
const points = [
PokerPoints.P_A, PokerPoints.P_2, PokerPoints.P_3, PokerPoints.P_4, PokerPoints.P_5,
PokerPoints.P_6, PokerPoints.P_7, PokerPoints.P_8, PokerPoints.P_9, PokerPoints.P_10,
PokerPoints.P_J, PokerPoints.P_Q, PokerPoints.P_K
]
suits.forEach(suit => {
points.forEach(point => {
cards.push(new Poker({ suit, point }))
})
})
// 大小王
cards.push(new Poker({
suit: PokerSuits.NONE,
point: PokerPoints.P_RJ
}))
cards.push(new Poker({
suit: PokerSuits.NONE,
point: PokerPoints.P_BJ
}))
// 洗牌
this.shuffle(cards)
return cards
}
/**
* 洗牌
* @param cards
*/
public shuffle(cards: Poker[]) {
// 两两随机交换
for (let i = 0; i < cards.length; i++) {
const i1 = RandomUtil.getRangeInt(0, cards.length)
const i2 = RandomUtil.getRangeInt(0, cards.length)
// 交换
ArrayUtil.swap(cards, i1, i2)
}
}
}
// Peferee.ts
/**
* 裁判类
* 管理房间的游戏流程和规则
*/
export default class Referee {
private players: Array<Player | undefined> = [] // 玩家集合
/**
* 发牌
*/
public deal() {
// 生成一副扑克牌
const cards = PokerManager.instance.getDeck()
// 取出3张地主牌
this._landlordCards = []
for (let i = 0; i < 3; i++) {
const card = cards.pop()
if (card != undefined) {
this._landlordCards.push(card)
}
}
// 发牌
for (let i = 0; i < 3; i++) {
const player = this.players[i]
if (player === undefined) {
throw new Error(`[referee] player is undefined`)
}
player.initCards(cards.splice(0, 17)) // 17张牌
// console.log('[referee] player.cards:', player.cards)
}
// 设为发牌阶段
this.stage = GameStage.REFEREE_DEAL
}
}
说明:系统随机选定一个玩家开始叫地主,以顺时针轮一圈执行抢地主,地主归属权会为最后一个执行叫地主的玩家。
抢地主情况:
思路:(灵感来自iterator的next函数)
nextCallLandPlayer()
函数,他会返回下一个进入【叫地主环节】的玩家。每次从玩家数组中,根据一个基准下标往右取一个玩家对象。核心代码:
//Referee.ts
/**
* 获取下一个叫地主的玩家
* 说明:
* - 地主归属权为最后一个执行【叫地主】的玩家;
* - 所有人都不叫地主,那么第一个开始执行【叫地主/不叫地主】操作的玩家不需要再执行操作,直接返回null,系统要重新发牌
* - 仅有1人叫地主,那么ta不需要再执行一次【叫地主/不叫地主】操作,直接归属ta为地主,并且函数返回null
* - 2人及以上(包含所有人)叫地主,那么第一次【叫地主】的玩家,需要再执行一次【叫地主/不叫地主】操作
* @returns 玩家对象 | null;玩家对象表示下一个执行【叫地主/不叫地主的玩家】;null表示没有下一个玩家了,叫地主环节结束。
* 可以通过get landlordPlayer() 获取地主玩家对象
*/
public nextCallLandloardPlayer(): Player | null {
// 结束叫地主阶段
if (this.isEndCall) {
return null
}
// 往右遍历为顺时针
this.currentRoundPlayerIndex++
if (this.currentRoundPlayerIndex >= this.players.length) {
this.currentRoundPlayerIndex = 0
}
// 表示轮完了一圈
if (this.beginCallPlayerIndex == this.currentRoundPlayerIndex) {
this.isEndCall = true
// 判断是否还需要由第一个【叫地主】的玩家确定地主归属权
if (this.isAllPlayerNotCallLand()) {
// 没人叫地主,直接返回null
return null
} else if (this.isOnePlayerCallLand()) {
console.log('this.isOnePlayerCallLand():', true)
// 只有一人叫地主,并且返回null
return null
} else {
// 2个或以上玩家叫地主,将由第一个执行【叫地主】的玩家决定地主归属权
this.currentRoundPlayerIndex = this._firstCallLandPlayerIndex
}
}
// 当前回合玩家对象
const player = this.players[this.currentRoundPlayerIndex]
if (player === undefined) {
throw new Error(`[referee] player is undefined`)
}
return player
}
说明:用户加入房间时,要进入一个每满的房间
思路:
使用mocha对PorkUtil.ts进行进行单元测试
目的:对每个模块单独测试,保证模块本身逻辑正确。减少运行时测试功能的繁琐性
运行单元测试:
npm test
说明:系统限制客户端使用Http发送请求。通过监听Websocket获取服务器主动推送的信息
难题:如何将http和socket对象关联起来
思路:SocketManager类中使用map根据用户的id和socket对象关联。在http请求函数中要处理对应用户的socket时,获取用户id根据SocketManger获取用户对应的socket对象即可。
核心代码:
// SocketManger.ts
/**
* 客户端socket对象管理类
* 根据用户id保存对应的socket对象,方便http和socket进行关系对应
* 注意:用户连接时必须在auth中携带用户uid才可以socket连接
*/
export default class SocketManager {
private clientSockets: Map<uid, Socket> // 根据用户id保存用户socket对象
private connectCallbacks: Set<ConnectCallbackFn> // 连接回调函数集合
private _io: Server = null!
/**
* 客户端连接回调
* @param socket
*/
private onConnect(socket: Socket): void {
// 获取用户id
const { uid } = socket.handshake.auth
// 无用户id,则不需进行下面的步骤(无效登录连接)
// token校验...
if (uid == null) {
socket.disconnect()
return
}
console.log('[socket] client connected, its uid is:', uid, socket.id)
// 保存socket
this.clientSockets.set(uid, socket)
// 通知监听事件
this.connectCallbacks.forEach(fn => { fn(uid, socket) })
// 监听离开
socket.on('disconnect', this.onDisconnect(uid, socket))
}
}
Solitaire: 纸牌游戏-学习Cocos项目 (gitee.com)
第二十三章《斗地主游戏》第3节:项目完整代码_扑克json_穆哥学堂的博客-CSDN博客
tinyshu/ddz_game: 斗地主游戏 (github.com)
游戏规则:./doc/2-游戏规则.md
功能设计与原型图:./doc/3-游戏功能与原型设计.md
游戏英文描述:英语介绍斗地主及其规则 - 田间小站 (tjxz.cc)
git commit规范:https://zhuanlan.zhihu.com/p/182553920
此处可能存在不合适展示的内容,页面不予展示。您可通过相关编辑功能自查并修改。
如您确认内容无涉及 不当用语 / 纯广告导流 / 暴力 / 低俗色情 / 侵权 / 盗版 / 虚假 / 无价值内容或违法国家有关法律法规的内容,可点击提交进行申诉,我们将尽快为您处理。