# 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 ![image-20230524211318075](README.assets/image-20230524211318075.png) ![image-20230524211549976](README.assets/image-20230524211549976.png) # 目录结构 **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号座位),并且正确显示 image-20230527215722439 **思路**: 1. 先找到本机玩家的在玩家数组中的**下标**。 2. 根据本机玩家下标,往右遍历,第一个就是2号位置,第二个就是3号位置(超过数组长度时,需要归零) **座位号函数**: 系统通过Socket推送数据时,不会提供下标和座位号,需要封装一个函数,推算某个玩家的座位号 1. 定义getIndexToSeatNum(index:number)函数,根据玩家下标返回对应的座位号 2. 记录本机玩家的下标,并且根据这个下标为起点(座位号1)往后遍历,找到某名玩家号即可推算出座位号 ## 手牌显示 **说明**:本机玩家手牌以横线排列格式显示,并且获取新牌或减少牌时能动态排列格式 ![image-20230527221314076](README.assets/image-20230527221314076.png) **思路**: 1. 设定一个节点,用于存放所有手牌v 2. 添加**Layout**组件,设定**Type**自动布局为**Horizontal**横向排列;**ResizeMode**为**Container**缩放节点,实现整个牌组居中;设定**SpacingX**属性,让手牌之间实现层叠显示 ![image-20230527221539929](README.assets/image-20230527221539929.png) ## 卡牌预制体 **说明**:卡牌需要重复使用,并且要设定点击事件返回获取对应的卡牌数据,故封装成一个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)