# cocos-ddz-client
**Repository Path**: michnus/cocos-ddz-client
## Basic Information
- **Project Name**: cocos-ddz-client
- **Description**: 基于Cocos Creator2.x、TypeScript、Socket.io实现的ddz游戏客户端
- **Primary Language**: Unknown
- **License**: Not specified
- **Default Branch**: master
- **Homepage**: None
- **GVP Project**: No
## Statistics
- **Stars**: 0
- **Forks**: 3
- **Created**: 2023-11-26
- **Last Updated**: 2023-11-26
## Categories & Tags
**Categories**: Uncategorized
**Tags**: None
## README
# 介绍
基于Cocos Creator 2.4.x、Socket.io、TypeScript实现的ddz游戏
实现了加入房间、抢地主、出牌、牌型比较、重新开始等功能。
**服务端**:https://gitee.com/baymax668/koa-ddz-server


# 目录结构
**script目录结构**:
```bash
|-- config # 【配置】
| |-- ServerConfig.ts # 连接服务器IP和端口配置
|-- constant # 【常量/枚举】
| |-- GameOption.ts
| |-- GameState.ts # 游戏阶段/状态
| |-- PokerHand.ts # 牌型
| |-- PokerPoints.ts # 牌点数
| |-- PokerSuits.ts # 牌花色
| |-- SocketGameEvent.ts # socket游戏相关事件枚举
| |-- SocketRoomEvent.ts # socket房间相关事件枚举
|-- controllers # 【控制类】会被挂载到预制体根节点上
| |-- CardCtrl.ts # 卡牌预制体控制类
| |-- LandCardsCtrl.ts # 地主牌预制体控制类
| |-- PlayerCtrl.ts # 局内玩家数据预制体控制类
| |-- TimerCtrl.ts # 定时器预制体控制类
|-- data #【模型/数据对象】
| |-- Player.ts # 玩家模型
| |-- Poker.ts # 卡牌模型
| |-- ResponseData.ts # http请求数据模型
| |-- Room.ts # 房间模型
| |-- SocketData.ts # socket数据模型
| |-- User.ts # 用户模型
|-- managers #【管理】
| |-- NetManager
| | |-- NetMgr.ts # Http Api请求封装
| |-- SceneManager # 场景管理类,会被挂载到对应的场景根节点上
| | |-- HallMgr.ts # 大厅场景管理类
| | |-- LoginMgr.ts # 登录场景管理类
| | |-- RoomMgr.ts # 房间场景管理类(*)
|-- store
| |-- Store.ts # 全局数据
|-- utils #【工具类】
| |-- HttpUtil.ts # Http Ajax封装,基于Promise封装Get Post
| |-- PokerUtil.ts # 牌型校验工具类
| |-- RandomUtil.ts # 随机函数工具类
```
# 难点、思路
## 座位显示
**说明**:本机玩家的座位永远在在屏幕的下方(1号座位),需要根据返回的房间玩家数组,推算上家下家座位(2号座位、3号座位),并且正确显示
**思路**:
1. 先找到本机玩家的在玩家数组中的**下标**。
2. 根据本机玩家下标,往右遍历,第一个就是2号位置,第二个就是3号位置(超过数组长度时,需要归零)
**座位号函数**:
系统通过Socket推送数据时,不会提供下标和座位号,需要封装一个函数,推算某个玩家的座位号
1. 定义getIndexToSeatNum(index:number)函数,根据玩家下标返回对应的座位号
2. 记录本机玩家的下标,并且根据这个下标为起点(座位号1)往后遍历,找到某名玩家号即可推算出座位号
## 手牌显示
**说明**:本机玩家手牌以横线排列格式显示,并且获取新牌或减少牌时能动态排列格式

**思路**:
1. 设定一个节点,用于存放所有手牌v
2. 添加**Layout**组件,设定**Type**自动布局为**Horizontal**横向排列;**ResizeMode**为**Container**缩放节点,实现整个牌组居中;设定**SpacingX**属性,让手牌之间实现层叠显示

## 卡牌预制体
**说明**:卡牌需要重复使用,并且要设定点击事件返回获取对应的卡牌数据,故封装成一个Prefab
**思路**:
1. 创建一个Prefab,设置`public init(poker:Poker)`函数,用于初始化卡牌数据和显示的资源
2. 提供`public addTouchListener(callback)`添加点击事件,并给回调函数返回内部**poker**对象
**核心代码**:
```typescript
import { PokerPoints } from "../constant/PokerPoints";
import { Poker } from "../data/Poker";
const { ccclass, property } = cc._decorator;
export type CardTouchCallback = (poker: Poker, isSelected: boolean) => void
/**
* 卡牌控制类
* 会被挂载到card预制体上
* 实现触摸上升,和触摸事件通知
*/
@ccclass
export default class CardCtrl extends cc.Component {
@property(cc.SpriteAtlas)
cardSpriteAtlas: cc.SpriteAtlas = null
public poker: Poker = null
private isSelected = false
private touchListeners: Set = new Set()
public init(poker?: Poker): void {
this.poker = poker
this.initTouchEvent()
if (this.poker) {
this.show()
}
}
/**
* 初始化卡牌触摸事件
*/
private initTouchEvent() {
this.node.on(cc.Node.EventType.TOUCH_START, this.onTouchEvent.bind(this))
}
/**
* 触摸事件
* 设置卡片上升或下降
*/
private onTouchEvent() {
const distance = 20
// 上升或下降动画
if (this.isSelected) {
// 下降
cc.tween(this.node)
.to(0.1, { y: 0 })
.start()
} else {
// 上升
cc.tween(this.node)
.to(0.1, { y: distance })
.start()
}
this.isSelected = !this.isSelected
// 触发所有监听器
this.touchListeners.forEach(callback => {
callback(this.poker, this.isSelected)
})
}
// 添加点击触摸事件
public addTouchListener(callback: Function | CardTouchCallback): Function {
this.touchListeners.add(callback)
return callback
}
// ...
}
```
## 场景跳转传参
**说明**:场景之间需要共有一些数据,Cocos默认没有场景传参,可以设定一个全局变量实现场景传参
**思路**:设定一个Store类,内部定义全局变量,任意脚本需要使用时引用该类进行使用(变量放在类中可以避免全局变量污染)
```typescript
// Store.ts
import { IRoomVo } from "../data/Room"
import { IUserVo } from "../data/User"
/**
* 全局数据
*/
export default class Store {
// 用户数据
public static user: IUserVo
// 房间数据
public static room: IRoomVo
public static index: number // 本机玩家在房间的座位号
private constructor() { }
}
```
## 牌型两端校验
**说明**:牌型校验会在客户端校验一次,并且进行比较大小,这一步会防止客户端给服务端发送无效牌组,减少服务器校验错误率。服务端为了安全还会再校验一次,防止网络数据篡改。
**校验工具类**:PokerUtil.ts
## 定时器预制体
**坑**:`this.schedule`在浏览器中离开页面后会停止定时,所有需要使用`setInterval`进行定时
**定时思路**:使用`setInterval`定时,每秒执行回调函数,并且对定时变量减1,当定时变量小于0时,关闭定时器
**关键代码**:
```typescript
// TimerCtrl.ts
const { ccclass, property } = cc._decorator;
@ccclass
export default class TimerCtrl extends cc.Component {
private _second: number = 0
private callbacks: Set = new Set()
private interval = null
public init(second: number) {
this._second = second
this.callbacks = new Set()
this.setViewTimeNum(this._second)
// 启动定时
this.interval = setInterval(this.timeCallback.bind(this), 1000);
}
private timeCallback() {
this._second--
// 触发超时回调
if (this._second < 0) {
clearInterval(this.interval) // 关闭定定时
console.log('run callback')
this.callbacks.forEach(callback => {
callback()
})
} else {
this.setViewTimeNum(this._second)
}
}
}
```
## Ajax封装
**说明**:对Xhr使用Promise进行封装
**核心代码**:
```typescript
// HttpUtil.ts
/**
* HTTP AJAX请求封装
*/
export default class HttpUtil {
public static baseUrl: string = 'http://localhost:3000'
public static timeout: number = 3000
/**
* HTTP Get请求
* @param url
* @param query
* @returns
*/
public static get(url: string, query?: object): Promise {
const _url = this.baseUrl + url
const xhr = new XMLHttpRequest()
// 参数解析
let queryStr = ''
if (query) {
queryStr = this.queryToString(query)
}
const promise = new Promise((resolve, reject) => {
// init xhr
xhr.open('GET', _url + queryStr)
xhr.timeout = this.timeout
xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded;charset=utf-8");
// xhr.setRequestHeader("Content-Type", "application/JSON");
// 响应处理
xhr.onreadystatechange = () => {
if (xhr.readyState != 4) { return }
let response: any = xhr.responseText
// 尝试变为JSON数据
try {
response = JSON.parse(xhr.responseText)
} catch { }
if (xhr.status >= 200 && xhr.status < 400) {
resolve(response)
} else {
console.error(`post request error\nstatus:${xhr.status}, url:${_url}\nresponse:`, response)
reject()
}
}
xhr.send()
})
return promise
}
/**
* Http Post请求
* @param url
* @param params
*/
public static post(url: string, params?: object): Promise {
const _url = this.baseUrl + url
const xhr = new XMLHttpRequest()
const promise = new Promise((resolve, reject) => {
// init xhr
xhr.open('POST', _url)
xhr.timeout = this.timeout
xhr.setRequestHeader("Content-Type", "application/JSON"); // JSON格式
// 响应处理
xhr.onreadystatechange = () => {
if (xhr.readyState != 4) { return }
let response: any = xhr.responseText
// 尝试变为JSON数据
try {
response = JSON.parse(xhr.responseText)
} catch { }
if (xhr.status >= 200 && xhr.status < 400) {
resolve(response)
}
else {
console.error(`post request error\nstatus:${xhr.status}, url:${_url}\nresponse:`, response)
reject()
}
}
xhr.send(JSON.stringify(params))
})
return promise
}
/**
* 对象参数转成get请求的参数形式
* @param query
* @returns
*/
private static queryToString(query: object): string {
let queryStr = ''
for (const key in query) {
if (typeof query[key] !== "object") {
queryStr += `${key}=${query[key]}`
}
}
if (queryStr == '') {
return queryStr
}
return '?' + queryStr
}
}
```
# 参考
[Solitaire: 纸牌游戏-学习Cocos项目 (gitee.com)](https://gitee.com/pimple_village_secretary/Solitaire)
[tinyshu/ddz_game: 斗地主游戏 (github.com)](https://github.com/tinyshu/ddz_game)
# 更多
**功能设计与原型图**:[./doc/游戏功能与原型设计.md](./doc/游戏功能与原型设计.md)