├── .gitignore ├── docs ├── client-sync.png ├── connections.png ├── server-sync.png ├── conversations-action.png ├── conversations-status.png ├── conversations-action.gv ├── connections.gv ├── server-sync.gv ├── conversations-status.gv └── client-sync.gv ├── tsconfig.json ├── browser-client ├── public │ ├── index.html │ └── styles.css ├── client-sync.ts └── app.tsx ├── gulpfile.js ├── package.json ├── common ├── types.ts └── game.ts ├── master-client ├── server.ts └── server-sync.ts └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | common/*.js 2 | browser-client/*.js 3 | browser-client/public/*.js 4 | -------------------------------------------------------------------------------- /docs/client-sync.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jysperm/play-cards/HEAD/docs/client-sync.png -------------------------------------------------------------------------------- /docs/connections.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jysperm/play-cards/HEAD/docs/connections.png -------------------------------------------------------------------------------- /docs/server-sync.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jysperm/play-cards/HEAD/docs/server-sync.png -------------------------------------------------------------------------------- /docs/conversations-action.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jysperm/play-cards/HEAD/docs/conversations-action.png -------------------------------------------------------------------------------- /docs/conversations-status.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jysperm/play-cards/HEAD/docs/conversations-status.png -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "jsx": "react" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /docs/conversations-action.gv: -------------------------------------------------------------------------------- 1 | digraph ConversationsAction { 2 | Master -> Conversation; 3 | Player1 -> Conversation; 4 | Player2 -> Conversation; 5 | Player3 -> Conversation; 6 | 7 | Conversation [shape=box] 8 | Master [label="MasterClient"]; 9 | } 10 | -------------------------------------------------------------------------------- /docs/connections.gv: -------------------------------------------------------------------------------- 1 | digraph Connections { 2 | Browser -> Server [label="HTTP"]; 3 | Browser -> Play [label="WebSocket"]; 4 | Server -> Play [label="WebSocket"]; 5 | 6 | Browser [label="Browser Client"]; 7 | Server [label="Play Server Container"]; 8 | Play [label="RTM Server"]; 9 | } 10 | -------------------------------------------------------------------------------- /browser-client/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Play Cards 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /docs/server-sync.gv: -------------------------------------------------------------------------------- 1 | digraph Sync { 2 | Game -> Controller [label="event: stateChanged"] 3 | Controller -> Clients [label="conv.send()"] 4 | 5 | Controller -> Game [label="performAction"] 6 | Clients -> Controller [label="conv.on('mssage')"] 7 | 8 | Game [shape=box] 9 | Clients [shape=box] 10 | Controller [shape=box, label="statusSyncController"] 11 | } 12 | -------------------------------------------------------------------------------- /docs/conversations-status.gv: -------------------------------------------------------------------------------- 1 | digraph ConversationsStatus { 2 | Master -> ConversationA; 3 | Master -> ConversationB; 4 | Master -> ConversationC; 5 | 6 | ConversationA -> Player1; 7 | ConversationB -> Player2; 8 | ConversationC -> Player3; 9 | 10 | ConversationA [shape=box] 11 | ConversationB [shape=box] 12 | ConversationC [shape=box] 13 | Master [label="MasterClient"]; 14 | } 15 | -------------------------------------------------------------------------------- /docs/client-sync.gv: -------------------------------------------------------------------------------- 1 | digraph Sync { 2 | UI -> Game [label="performAction()"] 3 | 4 | Game -> UI [label="event: stateChanged, getState()"] 5 | Game -> Controller [label="event: action"] 6 | 7 | Controller -> Server [label="conv.send()"] 8 | Controller -> Game [label="applyAction (actionSync)"] 9 | Controller -> Game [label="setState (statusSync)"] 10 | 11 | Server -> Controller [label="conv.on('message')"] 12 | 13 | UI [shape=box] 14 | Game [shape=box] 15 | Server [shape=box] 16 | Controller [shape=box, label="actionSyncController \n statusSyncController"] 17 | } 18 | -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | const gulp = require('gulp'); 2 | const ts = require('gulp-typescript'); 3 | const webpack = require('webpack-stream'); 4 | 5 | gulp.task('common', () => { 6 | return gulp.src('common/**/*.ts') 7 | .pipe(ts()) 8 | .pipe(gulp.dest('common')); 9 | }); 10 | 11 | gulp.task('browser-client', ['common'], () => { 12 | return gulp.src(['browser-client/*.tsx', 'browser-client/*.ts']) 13 | .pipe(ts(require('./tsconfig').compilerOptions)) 14 | .pipe(gulp.dest('browser-client')); 15 | }); 16 | 17 | gulp.task('browser-bundled', ['browser-client'], () => { 18 | return gulp.src('browser-client/app.js') 19 | .pipe(webpack({ 20 | output: { 21 | filename: 'bundled.js' 22 | } 23 | })) 24 | .pipe(gulp.dest('browser-client/public')); 25 | }) 26 | 27 | gulp.task('watch', ['default'], () => { 28 | gulp.watch(['common/**/*.ts', 'browser-client/*.tsx', 'browser-client/*.ts'], ['browser-bundled']); 29 | }); 30 | 31 | gulp.task('default', ['browser-bundled']); 32 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "engines": { 3 | "node": "8.x" 4 | }, 5 | "dependencies": { 6 | "@leancloud/play": "0.12.1", 7 | "@types/bluebird": "^3.5.20", 8 | "@types/es6-shim": "^0.31.37", 9 | "@types/eventemitter3": "^2.0.2", 10 | "@types/express": "^4.16.0", 11 | "@types/lodash": "^4.14.109", 12 | "@types/seedrandom": "^2.4.27", 13 | "bluebird": "^3.5.1", 14 | "eventemitter3": "^3.1.0", 15 | "express": "^4.16.3", 16 | "leancloud-realtime": "^4.0.1", 17 | "lodash": "^4.17.10", 18 | "seedrandom": "^2.4.3", 19 | "ts-node": "^6.1.1", 20 | "typescript": "^2.9.1" 21 | }, 22 | "devDependencies": { 23 | "@types/react-dom": "^16.0.6", 24 | "@types/react": "^16.3.16", 25 | "gulp-typescript": "^4.0.2", 26 | "gulp": "^3.9.1", 27 | "react-dom": "^16.4.0", 28 | "react": "^16.4.0", 29 | "webpack-stream": "^4.0.3" 30 | }, 31 | "scripts": { 32 | "build": "gulp", 33 | "start": "ts-node play-server/server.ts" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /common/types.ts: -------------------------------------------------------------------------------- 1 | export enum Suit { 2 | Spade = 'Spade', 3 | Club = 'Club', 4 | Heart = 'Heart', 5 | Diamond = 'Diamond' 6 | } 7 | 8 | export interface Card { 9 | suit: Suit 10 | rank: number 11 | selected?: boolean 12 | } 13 | 14 | export interface PlayersCards { 15 | [player: string]: Card[] 16 | } 17 | 18 | export interface PlayersCardsCount { 19 | [player: string]: number 20 | } 21 | 22 | export type Player = string 23 | 24 | export interface GameState { 25 | players: Player[] 26 | playersCardsCount: PlayersCardsCount 27 | 28 | myCards: Card[] 29 | 30 | previousCards: Card[] 31 | previousCardsPlayer?: Player 32 | currentPlayer?: Player 33 | 34 | winer?: Player 35 | } 36 | 37 | export type GameAction = PlayCardsAction | PassAction 38 | 39 | export interface PlayCardsAction { 40 | action: 'playCards' 41 | player: Player 42 | cards: Card[] 43 | } 44 | 45 | export interface PassAction { 46 | action: 'pass' 47 | player: Player 48 | } 49 | 50 | export interface RoomState { 51 | roomId: string 52 | players: Player[] 53 | seed: string 54 | reconnected?(player: Player) 55 | } 56 | -------------------------------------------------------------------------------- /browser-client/public/styles.css: -------------------------------------------------------------------------------- 1 | .peer-players { 2 | text-align: center; 3 | } 4 | 5 | .peer-player { 6 | display: inline-block; 7 | border: 1px solid #ddd; 8 | margin: .5em; 9 | padding: .5em; 10 | 11 | font-size: 32px; 12 | } 13 | 14 | .previous-cards { 15 | clear: both; 16 | border: 1px solid #ddd; 17 | min-height: 12em; 18 | margin: 1em auto; 19 | } 20 | 21 | .my-cards button, .my-cards .message { 22 | font-size: 24px; 23 | margin: .5em 1em; 24 | } 25 | 26 | .cards { 27 | overflow: auto; 28 | } 29 | 30 | .card { 31 | float: left; 32 | border: 1px solid #ddd; 33 | cursor: pointer; 34 | 35 | padding: 2em 1em; 36 | margin: .2em; 37 | } 38 | 39 | .card.selected { 40 | background-color: #ddd 41 | } 42 | 43 | .card-suit { 44 | font-size: 32px; 45 | } 46 | 47 | .card-rank { 48 | font-size: 24px; 49 | font-weight: bold; 50 | } 51 | 52 | .overlay { 53 | position: fixed; 54 | width: 100%; 55 | height: 100%; 56 | top: 0; 57 | left: 0; 58 | right: 0; 59 | bottom: 0; 60 | background-color: rgba(0,0,0,0.8); 61 | z-index: 2; 62 | cursor: pointer; 63 | 64 | font-size: 32px; 65 | font-weight: bold; 66 | text-align: center; 67 | color: white; 68 | padding-top: 10em; 69 | } 70 | -------------------------------------------------------------------------------- /master-client/server.ts: -------------------------------------------------------------------------------- 1 | import * as express from 'express' 2 | import * as path from 'path' 3 | import * as _ from 'lodash' 4 | 5 | import {Player, RoomState} from '../common/types' 6 | import {actionSyncController, statusSyncContorller} from './server-sync' 7 | 8 | const app: express.Application = express() 9 | 10 | app.use(express.static(path.join(__dirname, '../browser-client/public'))); 11 | 12 | let waiting: {[player: string]: express.Response} = {} 13 | 14 | app.post('/join', (req, res) => { 15 | const {playerName} = req.query 16 | 17 | console.log(`${playerName} is waiting`) 18 | 19 | waiting[playerName] = res 20 | 21 | if (_.keys(waiting).length >= 3) { 22 | const players = _.keys(waiting).splice(0, 3) 23 | const responses = _.values(_.pick(waiting, players)) 24 | waiting = _.omit(waiting, players) 25 | 26 | const roomState: RoomState = { 27 | roomId: _.uniqueId(), 28 | players: players, 29 | seed: Math.random().toString() 30 | } 31 | 32 | console.log(`created room for ${players.join(',')}`) 33 | 34 | statusSyncContorller(roomState).then( play => { 35 | responses.forEach( res => { 36 | res.json({ 37 | roomName: play.room.name 38 | }) 39 | }) 40 | }).catch( err => { 41 | console.error(err) 42 | }) 43 | } 44 | }) 45 | 46 | app.listen(process.env.LEANCLOUD_APP_PORT || 3000) 47 | -------------------------------------------------------------------------------- /master-client/server-sync.ts: -------------------------------------------------------------------------------- 1 | import {Play, Event, Region} from '@leancloud/play' 2 | import * as _ from 'lodash' 3 | import * as Promise from 'bluebird' 4 | 5 | import Game from '../common/game' 6 | import {RoomState, GameAction} from '../common/types' 7 | 8 | export function actionSyncController(roomState: RoomState): Promise { 9 | return new Promise( (resolve, reject) => { 10 | const play = initPlay(`master-${roomState.roomId}`) 11 | 12 | play.on(Event.CONNECT_FAILED, err => { 13 | console.error(err) 14 | }) 15 | 16 | play.once(Event.LOBBY_JOINED, () => { 17 | play.createRoom({ 18 | expectedUserIds: roomState.players 19 | }) 20 | }) 21 | 22 | play.once(Event.ROOM_JOIN_FAILED, err => { 23 | reject(err) 24 | }); 25 | 26 | play.once(Event.ROOM_JOINED, () => { 27 | resolve(play) 28 | }); 29 | 30 | play.on(Event.PLAYER_ROOM_JOINED, ({newPlayer}) => { 31 | play.sendEvent('gameStarted', { 32 | players: roomState.players, 33 | seed: roomState.seed 34 | }, { 35 | targetActorIds: [newPlayer.actorId] 36 | }) 37 | }) 38 | 39 | play.connect() 40 | }) 41 | } 42 | 43 | export function statusSyncContorller(roomState: RoomState): Promise { 44 | return new Promise( (resolve, reject) => { 45 | const play = initPlay(`master-${roomState.roomId}`) 46 | const game = new Game(roomState.seed, roomState.players) 47 | 48 | play.on(Event.CONNECT_FAILED, err => { 49 | console.error(err) 50 | }) 51 | 52 | play.once(Event.LOBBY_JOINED, () => { 53 | play.createRoom({ 54 | expectedUserIds: roomState.players 55 | }) 56 | }) 57 | 58 | play.on(Event.ROOM_JOIN_FAILED, err => { 59 | reject(err) 60 | }); 61 | 62 | play.on(Event.ROOM_JOINED, () => { 63 | game.dealCards() 64 | resolve(play) 65 | }); 66 | 67 | play.on(Event.PLAYER_ROOM_JOINED, ({newPlayer}) => { 68 | play.sendEvent('gameStarted', { 69 | players: roomState.players 70 | }, { 71 | targetActorIds: [newPlayer.actorId] 72 | }) 73 | 74 | play.sendEvent('stateChanged', { 75 | player: newPlayer.userId, 76 | state: game.getState(newPlayer.userId) 77 | }, { 78 | targetActorIds: [newPlayer.actorId] 79 | }) 80 | }) 81 | 82 | play.on(Event.CUSTOM_EVENT, ({eventId, eventData, senderId}) => { 83 | eventData.action = eventId 84 | eventData.player = play.room.getPlayer(senderId).userId 85 | game.performAction(eventData as GameAction) 86 | }) 87 | 88 | game.on('error', err => { 89 | console.error(err) 90 | }) 91 | 92 | game.on('stateChanged', () => { 93 | roomState.players.map( playerName => { 94 | const player = _.find(play.room.playerList, {userId: playerName}) 95 | 96 | if (player) { 97 | play.sendEvent('stateChanged', { 98 | player: playerName, 99 | state: game.getState(playerName) 100 | }, { 101 | targetActorIds: [player.actorId] 102 | }) 103 | } 104 | }) 105 | }) 106 | 107 | play.connect() 108 | }) 109 | } 110 | 111 | function initPlay(userId: string): Play { 112 | const play = new Play() 113 | 114 | play.init({ 115 | appId: 'AaU1irN3dpcBUb9VINnB0yot-gzGzoHsz', 116 | appKey: '6R0akkHpnHe7kOr3Kz6PJTcO', 117 | region: Region.NorthChina 118 | }) 119 | 120 | play.userId = userId 121 | 122 | return play 123 | } 124 | -------------------------------------------------------------------------------- /browser-client/client-sync.ts: -------------------------------------------------------------------------------- 1 | import {play, Event, Region, ReceiverGroup} from '@leancloud/play' 2 | import Game from '../common/game' 3 | 4 | play.init({ 5 | appId: 'AaU1irN3dpcBUb9VINnB0yot-gzGzoHsz', 6 | appKey: '6R0akkHpnHe7kOr3Kz6PJTcO', 7 | region: Region.NorthChina 8 | }) 9 | 10 | export function actionSyncController(roomName: string, playerName: string): Promise { 11 | return new Promise( (resolve, reject) => { 12 | let game 13 | 14 | play.on(Event.CONNECT_FAILED, err => { 15 | console.error(err) 16 | }) 17 | 18 | play.once(Event.LOBBY_JOINED, () => { 19 | play.joinRoom(roomName) 20 | }) 21 | 22 | play.on(Event.ROOM_JOIN_FAILED, err => { 23 | console.error(err) 24 | }) 25 | 26 | play.on(Event.CUSTOM_EVENT, ({eventId, eventData, senderId}) => { 27 | try { 28 | console.log('[Received]', senderId, eventId, eventData) 29 | 30 | switch (eventId) { 31 | case 'gameStarted': 32 | if (!game) { 33 | game = new Game(eventData.seed, eventData.players) 34 | 35 | game.on('action', payload => { 36 | console.log('[Send]', JSON.stringify(payload)) 37 | 38 | play.sendEvent(payload.action, payload, { 39 | receiverGroup: ReceiverGroup.Others 40 | }) 41 | }) 42 | 43 | game.on('error', err => { 44 | console.error(err) 45 | }) 46 | 47 | game.dealCards() 48 | 49 | resolve(game) 50 | } 51 | break 52 | default: 53 | if (!game) { 54 | console.error('Game have not started') 55 | } else { 56 | eventData.action = eventId 57 | game.applyAction(eventData) 58 | } 59 | } 60 | } catch (err) { 61 | console.error(err) 62 | } 63 | }) 64 | 65 | play.userId = playerName 66 | play.connect() 67 | }) 68 | } 69 | 70 | export function statusSyncContorller(roomName: string, playerName: string): Promise { 71 | return new Promise( (resolve, reject) => { 72 | let game 73 | 74 | play.on(Event.CONNECT_FAILED, err => { 75 | console.error(err) 76 | }) 77 | 78 | play.once(Event.LOBBY_JOINED, () => { 79 | play.joinRoom(roomName) 80 | }) 81 | 82 | play.on(Event.ROOM_JOIN_FAILED, err => { 83 | console.error(err) 84 | }) 85 | 86 | play.on(Event.CUSTOM_EVENT, ({eventId, eventData, senderId}) => { 87 | try { 88 | console.log('[Received]', senderId, eventId, eventData) 89 | 90 | switch (eventId) { 91 | case 'gameStarted': 92 | if (!game) { 93 | game = new Game('', eventData.players) 94 | 95 | game.on('action', payload => { 96 | console.log('[Send]', JSON.stringify(payload)) 97 | 98 | play.sendEvent(payload.action, payload, { 99 | receiverGroup: ReceiverGroup.MasterClient 100 | }) 101 | }) 102 | 103 | game.on('error', err => { 104 | console.error(err) 105 | }) 106 | 107 | resolve(game) 108 | } 109 | break 110 | case 'stateChanged': 111 | if (!game) { 112 | console.error('Game have not started') 113 | } else { 114 | game.setState(eventData.player, eventData.state) 115 | } 116 | } 117 | } catch (err) { 118 | console.error(err) 119 | } 120 | }) 121 | 122 | play.userId = playerName 123 | play.connect() 124 | }) 125 | } 126 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Play Cards 2 | 这是我对基于 LeanCloud 实现 **卡牌对战游戏** 的一个实践,卡牌对战游戏具有回合制(没有并发动作)和非对称博弈(看不到其他玩家手牌)的特点。在完成这个项目之前我对游戏(服务器端)开发几乎完全没有概念,很多实践是我「独立发现」的,所以可能并不能代表游戏开发者的习惯和最佳实践。 3 | 4 | 目前实现了「斗地主」规则的一个子集,每局游戏有三个玩家参与,支持:单子、对子、三不带、三带一、三带二、炸弹、单顺、双顺;不支持:大小王、叫地主、抢地主、四带一、四带二、飞机。 5 | 6 | 目前有两种实现: 7 | 8 | - `realtime` 分支:基于 LeanCloud [实时通信服务](https://leancloud.cn/docs/realtime_v2.html) 实现 9 | - `play` 分支:基于 LeanCloud [游戏解决方案](https://leancloud.cn/docs/play.html) 实现,线上版本在 [play-cards.leanapp.cn](https://play-cards.leanapp.cn) 10 | 11 | 每种实现都同时支持「动作同步(帧同步)」和「状态同步(C/S 同步)」,服务器端会创建一个 masterClient 参与每局游戏。 12 | 13 | 演示视频: 14 | 15 | ## 项目结构 16 | 17 | ``` 18 | common -- 公共模块(客户端、服务器共用) 19 | ├── game.ts -- 游戏业务逻辑 20 | └── types.ts 21 | browser-client -- 客户端项目 22 | ├── app.tsx -- 客户端入口、界面 23 | └── client-sync.ts -- 客户端同步逻辑 24 | master-client -- 服务器项目 25 | ├── server-sync.ts -- 服务器同步逻辑 26 | └── server.ts -- 服务器入口、房间匹配 27 | ``` 28 | 29 | 除 UI 外主要代码都在 `game.ts` 中,包括游戏规则、游戏状态、游戏动作的管理;同步逻辑(`{client,server}-sync.ts`)中同时实现了动作同步和状态同步,只要在服务器和客户端各改一行代码即可切换,其中的代码也并不多。 30 | 31 | ## 本地调试 32 | 33 | 启动项目: 34 | 35 | ```bash 36 | npm install 37 | npm run build 38 | lean up 39 | ``` 40 | 41 | 然后可以在浏览器中开三个窗口来分别模拟三个客户端。 42 | 43 | ## 网络连接结构 44 | 45 | ![connections.png](https://github.com/jysperm/play-cards/blob/realtime/docs/connections.png?raw=true) 46 | 47 | - 客户端(Browser Client)和服务器(Play Server Contianer)都通过 WebSocket 连接到实时通讯或 Play(RTM Server) 48 | - 服务器负责创建房间,并在每个房间设置一个 masterClient 参与游戏 49 | - 客户端通过 HTTP 调用服务器来匹配并加入房间 50 | 51 | ## 游戏逻辑 52 | 53 | `common/game.ts` 中导出了一个 `Game` 类,是对游戏核心逻辑的封装,包括游戏业务逻辑(扑克的规则)和游戏状态(State)、游戏动作(Action)的管理,这个类会同时运行于客户端和服务器。 54 | 55 | ``` 56 | // 事件:action(当前玩家的动作)、stateChanged(游戏状态变化)、error 57 | class Game extends EventEmitter { 58 | constructor(seed: string, players: Player[]); 59 | 60 | // 获取游戏状态(供 UI 调用) 61 | public getState(player: Player): GameState; 62 | // 设置游戏状态(状态同步时设置服务器发来的状态) 63 | public setState(player: Player, state: GameState); 64 | 65 | // 当前玩家执行动作 66 | public performAction(action: GameAction); 67 | // 应用其他玩家的动作(动作同步时) 68 | public applyAction(action: GameAction); 69 | } 70 | ``` 71 | 72 | 至于游戏业务逻辑,其实主要就是「一组牌能否管上另一组牌」: 73 | 74 | ``` 75 | function ableToUseCards(playerCards: Card[], playingCards: Card[]): boolean {} 76 | function ableToBeatCards(previousCards: Card[], playingCards: Card[]): boolean {} 77 | 78 | function isSoloOrPairCards(playingCards: Card[]): boolean {} 79 | function isTrioCards(playingCards: Card[]): boolean {} 80 | function isChainCards(playingCards: Card[]): boolean {} 81 | function isBomb(playingCards: Card[]): boolean {} 82 | 83 | function ableToPlaySoloOrPairCards(previousCards: Card[], playingCards: Card[]): boolean {} 84 | function ableToPlayTrioCards(previousCards: Card[], playingCards: Card[]): boolean {} 85 | function ableToPlayChainCards(previousCards: Card[], playingCards: Card[]): boolean {} 86 | function ableToPlayBomb(previousCards: Card[], playingCards: Card[]): boolean {} 87 | ``` 88 | 89 | `GameState` 是对游戏状态的描述,包括自己的手牌、每个玩家的手牌数量、前一次出牌的玩家和牌、当前轮到哪个玩家等: 90 | 91 | ``` 92 | export interface GameState { 93 | players: Player[] 94 | playersCardsCount: PlayersCardsCount 95 | 96 | myCards: Card[] 97 | 98 | previousCards: Card[] 99 | previousCardsPlayer?: Player 100 | currentPlayer?: Player 101 | 102 | winer?: Player 103 | } 104 | ``` 105 | 106 | `GameAction` 是对游戏动作的描述,这个游戏中只有两种动作:出牌和放弃出牌: 107 | 108 | ``` 109 | type GameAction = PlayCardsAction | PassAction 110 | 111 | interface PlayCardsAction { 112 | action: 'playCards' 113 | player: Player 114 | cards: Card[] 115 | } 116 | 117 | interface PassAction { 118 | action: 'pass' 119 | player: Player 120 | } 121 | ``` 122 | 123 | ## 数据同步 124 | 125 | 其实这个项目的重点就是游戏的数据同步,`client-sync.ts` 和 `server-sync.ts` 中分别是客户端和服务器的数据同步逻辑,其中的函数名是一一对应的: 126 | 127 | - actionSyncController 实现的是「动作同步(帧同步)」,这种模式下客户端发送动作(Action),服务器只转发动作,游戏逻辑主要在客户端运行,客户端掌握所有的数据(包括其他玩家的手牌)。 128 | - statusSyncContorller 实现的是「状态同步(C/S 同步)」,这种模式下客户端发送动作(Action),服务器运行游戏逻辑后,转发计算后的游戏状态(State),游戏逻辑主要在服务器运行,客户端只做展现,只掌握自己的手牌。 129 | 130 | ### actionSyncController(动作同步) 131 | 132 | 客户端的工作: 133 | 134 | - `game.on('action')` 时,转发用户动作到服务器 `play.sendEvent(action)` 135 | - `play.on('customEvent')` 时,应用其他玩家的动作 `game.applyAction(action)` 136 | 137 | 服务器的工作: 138 | 139 | - 创建一个 Room,生成 randomSeed,有新玩家加入时发送玩家列表和 randomSeed 140 | 141 | ### statusSyncContorller(状态同步) 142 | 143 | 客户端的工作: 144 | 145 | - `game.on('action')` 时,转发用户动作到服务器 `play.sendEvent(action)` 146 | - `play.on('customEvent')` 时,应用服务器发来的游戏状态 `game.setState(state)` 147 | 148 | 服务器的工作: 149 | 150 | - 创建一个 Room,生成 randomSeed,有新玩家加入时发送玩家列表 151 | - `play.on('customEvent')` 时,在游戏对象上执行动作 `game.performAction(action)` 152 | - `game.on('stateChanged')` 时,给每一个玩家发送最新的状态 `play.sendEvent(state)` 153 | 154 | ## 房间匹配 155 | 156 | `master-client/server.ts` 中的 `POST /join` 实现了一个非常 **简易** 的自定义房间匹配,会在内存中记录请求匹配的玩家(并将请求挂起),待凑齐 3 个玩家后再给玩家响应创建好的房间名字,让客户端加入房间。 157 | 158 | 云引擎的 HTTP 连接有 60 秒的超时,所以客户端会在收到 504 的超时错误后用同一个 playerName 重试 `POST /join`。 159 | 160 | ## 断线重连 161 | 162 | - 动作同步(未实现):需要服务器记录每个房间的全部动作,在客户端重连后发给客户端断线期间的动作或全部动作。 163 | - 状态同步(已实现):客户端重连后,服务器发送一次游戏状态数据。 164 | 165 | 这里的断线重连不仅是网络断开,也包括客户端重启(这意味着所有状态都丢失了)。 166 | -------------------------------------------------------------------------------- /browser-client/app.tsx: -------------------------------------------------------------------------------- 1 | import {play} from '@leancloud/play' 2 | import * as _ from 'lodash' 3 | import * as React from 'react' 4 | import * as ReactDOM from 'react-dom' 5 | 6 | import {actionSyncController, statusSyncContorller} from './client-sync' 7 | import {Card, Player, GameState} from '../common/types' 8 | import Game from '../common/game' 9 | 10 | interface GameComponentState extends GameState { 11 | playerName?: Player 12 | } 13 | 14 | class GameComponent extends React.Component { 15 | state: GameComponentState = { 16 | players: [], 17 | playersCardsCount: {}, 18 | myCards: [], 19 | previousCards: [] 20 | } 21 | 22 | game: Game 23 | 24 | public render() { 25 | const peerPlayers = _.without(this.state.players, this.state.playerName) 26 | 27 | return
28 | {this.state.currentPlayer ? undefined :
等待游戏开始
} 29 | {this.state.winer &&
玩家 {this.state.winer} 获胜
} 30 |
31 | 33 | 35 |
36 | 37 | 42 |
43 | } 44 | 45 | public componentDidMount() { 46 | const querys = (new URL(location.href)).searchParams 47 | 48 | const playerName = querys.get('playerName') || prompt(`What's your name?`) 49 | 50 | this.setState({playerName}) 51 | 52 | const joinRoom = () => { 53 | return fetch(`/join?playerName=${playerName}`, {method: 'post'}).then( res => { 54 | if (!res.ok) { 55 | if (res.status === 504) { 56 | return joinRoom() 57 | } else { 58 | return res.text().then( body => { 59 | throw new Error(body) 60 | }) 61 | } 62 | } 63 | 64 | return res.json() 65 | }) 66 | } 67 | 68 | joinRoom().then( ({roomName}) => { 69 | return statusSyncContorller(roomName, playerName).then( game => { 70 | this.game = game 71 | 72 | game.on('stateChanged', () => { 73 | this.setState(game.getState(playerName)) 74 | }) 75 | 76 | this.setState(game.getState(playerName)) 77 | }) 78 | }).catch( err => { 79 | console.error(err) 80 | }) 81 | } 82 | 83 | public playCards(cards: Card[]) { 84 | this.game.performAction({ 85 | action: 'playCards', 86 | player: this.state.playerName, 87 | cards: cards 88 | }) 89 | } 90 | 91 | public pass() { 92 | this.game.performAction({ 93 | action: 'pass', 94 | player: this.state.playerName 95 | }) 96 | } 97 | } 98 | 99 | interface PeerPlayerProps { 100 | playerName: Player 101 | currentPlayer: Player 102 | cardsCount: number 103 | } 104 | 105 | class PeerPlayerComponent extends React.Component { 106 | public render() { 107 | return
108 | 109 | {this.props.playerName} 🃏 x {this.props.cardsCount} 110 | {this.props.currentPlayer === this.props.playerName ? '⏰(正在出牌)' : ''} 111 | 112 |
113 | } 114 | } 115 | 116 | interface PreviousCardsProps { 117 | playerName: Player 118 | cards: Card[] 119 | } 120 | 121 | class PreviousCardsComponent extends React.Component { 122 | public render() { 123 | return
124 |

前一玩家出牌({this.props.playerName})

125 | 126 |
127 | } 128 | } 129 | 130 | interface MyCardsProps { 131 | cards: Card[] 132 | ableToPlay: boolean 133 | ableToPass: boolean 134 | 135 | ableToBeatCards(cards: Card[]) 136 | playCards(cards: Card[]) 137 | pass() 138 | } 139 | 140 | interface MyCardState { 141 | selectedCards: Card[] 142 | } 143 | 144 | class MyCardsComponent extends React.Component { 145 | state: MyCardState = { 146 | selectedCards: [] 147 | } 148 | 149 | public render() { 150 | const ableToBeat = this.props.ableToBeatCards(this.state.selectedCards) 151 | 152 | return
153 |
154 | 155 | 156 | {!_.isEmpty(this.state.selectedCards) && !ableToBeat ? '无法出牌 / 管不上' : ''} 157 |
158 |

我的手牌 {this.props.ableToPlay ? '⏰(正在出牌)' : ''}

159 | 160 |
161 | } 162 | 163 | protected onCardClicked(card: Card) { 164 | if (this.state.selectedCards.indexOf(card) !== -1) { 165 | this.setState({ 166 | selectedCards: _.without(this.state.selectedCards, card) 167 | }) 168 | } else { 169 | this.setState({ 170 | selectedCards: this.state.selectedCards.concat(card) 171 | }) 172 | } 173 | } 174 | 175 | protected onPlay() { 176 | this.props.playCards(this.state.selectedCards) 177 | 178 | this.setState({ 179 | selectedCards: [] 180 | }) 181 | } 182 | 183 | protected onPass() { 184 | this.props.pass() 185 | 186 | this.setState({ 187 | selectedCards: [] 188 | }) 189 | } 190 | } 191 | 192 | interface CardsProps { 193 | cards: Card[] 194 | selectedCards?: Card[] 195 | onCardClick?(card: Card) 196 | } 197 | 198 | class CardsComponent extends React.Component { 199 | public render() { 200 | return
201 | {_.sortBy(this.props.cards, 'rank').map( card => { 202 | return 205 | })} 206 |
207 | } 208 | } 209 | 210 | interface CardProps { 211 | card: Card 212 | selected: boolean 213 | onClick() 214 | } 215 | 216 | class CardComponent extends React.Component { 217 | emojiOfCard: {[suit: string]: string} = { 218 | 'Spade': '♠️', 219 | 'Club': '♣️', 220 | 'Heart': '♥️', 221 | 'Diamond': '♦️' 222 | } 223 | 224 | public render() { 225 | return
226 | {this.emojiOfCard[this.props.card.suit]} 227 | {this.props.card.rank} 228 |
229 | } 230 | } 231 | 232 | if (typeof document == 'object') { 233 | ReactDOM.render(, document.getElementById('game-component')) 234 | } 235 | -------------------------------------------------------------------------------- /common/game.ts: -------------------------------------------------------------------------------- 1 | import * as _ from 'lodash' 2 | import * as EventEmitter from 'eventemitter3' 3 | import * as seedrandom from 'seedrandom' 4 | 5 | import {Suit, Card, PlayersCards, Player, GameState, GameAction, PlayersCardsCount} from '../common/types' 6 | 7 | const suits: Suit[] = [Suit.Spade, Suit.Club, Suit.Heart, Suit.Diamond] 8 | 9 | // events: action, stateChanged, error 10 | export default class Game extends EventEmitter { 11 | private random: () => number 12 | 13 | private players: Player[] 14 | private playersCards: PlayersCards 15 | private playersCardsCount?: PlayersCardsCount 16 | 17 | private previousCards: Card[] 18 | private previousCardsPlayer?: Player 19 | private currentPlayer?: Player 20 | 21 | private winer?: Player 22 | 23 | constructor(seed: string, players: Player[]) { 24 | super() 25 | 26 | this.random = seedrandom(seed.toString()) 27 | 28 | if (players.length !== 3) { 29 | throw new Error('Only 3 players is allowd') 30 | } 31 | 32 | this.playersCards = _.zipObject(players, [[], [], []]) 33 | this.previousCards = [] 34 | this.players = players.sort() 35 | } 36 | 37 | public getState(player: Player): GameState { 38 | return { 39 | players: this.players, 40 | playersCardsCount: this.playersCardsCount || _.mapValues(this.playersCards, cards => cards.length), 41 | 42 | myCards: this.playersCards[player], 43 | 44 | previousCards: this.previousCards, 45 | previousCardsPlayer: this.previousCardsPlayer, 46 | currentPlayer: this.currentPlayer, 47 | 48 | winer: this.winer 49 | } 50 | } 51 | 52 | public setState(player: Player, state: GameState) { 53 | this.players = state.players 54 | this.playersCardsCount = state.playersCardsCount 55 | this.playersCards[player] = state.myCards 56 | this.previousCards = state.previousCards 57 | this.previousCardsPlayer = state.previousCardsPlayer 58 | this.currentPlayer = state.currentPlayer 59 | this.winer = state.winer 60 | 61 | this.emit('stateChanged') 62 | } 63 | 64 | public dealCards() { 65 | const cards = _.flatten(_.range(1, 14).map( rank => { 66 | return suits.map( suit => { 67 | return newCard(suit, rank) 68 | }) 69 | })) 70 | 71 | const players = _.keys(this.playersCards).sort() 72 | this.currentPlayer = players[0] 73 | 74 | while(true) { 75 | for (let i in players) { 76 | if (cards.length === 0) { 77 | this.emit('stateChanged') 78 | return 79 | } 80 | 81 | const cardIndex = Math.floor(this.random() * cards.length) 82 | 83 | this.playersCards[players[i]].push(cards[cardIndex]) 84 | _.pullAt(cards, cardIndex) 85 | } 86 | } 87 | } 88 | 89 | public ableToBeatCards(cards: Card[]) { 90 | return ableToBeatCards(this.previousCards, cards) 91 | } 92 | 93 | public performAction(payload: GameAction) { 94 | this.applyAction(payload) 95 | this.emit('action', payload) 96 | } 97 | 98 | public applyAction(payload: GameAction) { 99 | switch (payload.action) { 100 | case 'playCards': 101 | return this.playCards(payload.player, payload.cards) 102 | case 'pass': 103 | return this.pass(payload.player) 104 | } 105 | } 106 | 107 | private playCards(player: Player, cards: Card[]) { 108 | if (this.currentPlayer !== player) { 109 | this.emit('error', new Error(`playCards: currentPlayer is not ${player}`)) 110 | return 111 | } 112 | 113 | if (!ableToUseCards(this.playersCards[player], cards)) { 114 | this.emit('error', new Error(`playCards: No such cards to play: ${JSON.stringify(cards)}`)) 115 | return 116 | } 117 | 118 | if (!ableToBeatCards(this.previousCards, cards)) { 119 | this.emit('error', new Error('playCards: Can not beat previous cards')) 120 | return 121 | } 122 | 123 | let latestPlayerCards = this.playersCards[player] 124 | 125 | cards.forEach( card => { 126 | latestPlayerCards = withoutFirst(latestPlayerCards, card) 127 | }) 128 | 129 | this.playersCards[player] = latestPlayerCards 130 | this.previousCards = cards 131 | this.previousCardsPlayer = player 132 | 133 | if (_.isEmpty(latestPlayerCards)) { 134 | this.winer = player 135 | } 136 | 137 | this.nextPlayer() 138 | } 139 | 140 | private pass(player: Player) { 141 | if (this.currentPlayer !== player) { 142 | this.emit('error', new Error(`pass: currentPlayer is not ${player}`)) 143 | return 144 | } 145 | 146 | if (_.isEmpty(this.previousCards)) { 147 | this.emit('error', new Error('pass: previous cards is empty')) 148 | return 149 | } 150 | 151 | this.nextPlayer() 152 | } 153 | 154 | private nextPlayer() { 155 | const currentPlayerIndex = this.players.indexOf(this.currentPlayer) 156 | 157 | if (currentPlayerIndex >= 2) { 158 | this.currentPlayer = this.players[0] 159 | } else { 160 | this.currentPlayer = this.players[currentPlayerIndex + 1] 161 | } 162 | 163 | if (this.currentPlayer === this.previousCardsPlayer) { 164 | this.previousCards = [] 165 | } 166 | 167 | this.emit('stateChanged') 168 | } 169 | } 170 | 171 | function newCard(suit: Suit, rank: number): Card { 172 | return {suit, rank} 173 | } 174 | 175 | function ableToUseCards(playerCards: Card[], playingCards: Card[]): boolean { 176 | for (let i in playingCards) { 177 | if (_.find(playerCards, playingCards[i])) { 178 | playerCards = withoutFirst(playerCards, playingCards[i]) 179 | } else { 180 | return false 181 | } 182 | } 183 | 184 | return true 185 | } 186 | 187 | function ableToBeatCards(previousCards: Card[], playingCards: Card[]): boolean { 188 | if (_.isEmpty(previousCards)) { 189 | return _.some([ 190 | isSoloOrPairCards(playingCards), 191 | isTrioCards(playingCards), 192 | isChainCards(playingCards), 193 | isBomb(playingCards) 194 | ]) 195 | } else { 196 | return _.some([ 197 | ableToPlaySoloOrPairCards(previousCards, playingCards), 198 | ableToPlayTrioCards(previousCards, playingCards), 199 | ableToPlayChainCards(previousCards, playingCards), 200 | ableToPlayBomb(previousCards, playingCards) 201 | ]) 202 | } 203 | } 204 | 205 | function withoutFirst(array: Array, item: T): Array { 206 | let found = false 207 | 208 | return _.filter(array, (i) => { 209 | if (_.isMatch(i as Object, item as Object) && !found) { 210 | found = true 211 | return false 212 | } else { 213 | return true 214 | } 215 | }) 216 | } 217 | 218 | function isSoloOrPairCards(playingCards: Card[]): boolean { 219 | if (playingCards.length === 2) { 220 | return playingCards[0].rank === playingCards[1].rank 221 | } else if (playingCards.length === 1) { 222 | return true 223 | } 224 | 225 | return false 226 | } 227 | 228 | function ableToPlaySoloOrPairCards(previousCards: Card[], playingCards: Card[]): boolean { 229 | if (isSoloOrPairCards(previousCards) && isSoloOrPairCards(playingCards)) { 230 | if (_.includes([1, 2], previousCards.length) && previousCards.length === playingCards.length) { 231 | return adjustedCardRank(playingCards[0]) > adjustedCardRank(previousCards[0]) 232 | } 233 | } 234 | 235 | return false 236 | } 237 | 238 | function isTrioCards(playingCards: Card[]): boolean { 239 | const groups = _.groupBy(playingCards, 'rank') 240 | 241 | if (playingCards.length === 3) { 242 | return _.keys(groups).length === 1 243 | } else if (playingCards.length === 4) { 244 | return _.keys(groups).length === 2 && !!_.find(groups, {length: 3}) && !!_.find(groups, {length: 1}) 245 | } else if (playingCards.length === 5) { 246 | return _.keys(groups).length === 2 && !!_.find(groups, {length: 3}) && !!_.find(groups, {length: 2}) 247 | } 248 | 249 | return false 250 | } 251 | 252 | function ableToPlayTrioCards(previousCards: Card[], playingCards: Card[]): boolean { 253 | const previousTrioCards = _.find(_.groupBy(previousCards, 'rank'), {length: 3}) 254 | const playingTrioCards = _.find(_.groupBy(playingCards, 'rank'), {length: 3}) 255 | 256 | if (isTrioCards(previousCards) && isTrioCards(playingCards)) { 257 | if (previousCards.length === playingCards.length) { 258 | return adjustedCardRank(playingTrioCards[0]) > adjustedCardRank(previousTrioCards[0]) 259 | } 260 | } 261 | 262 | return false 263 | } 264 | 265 | function isChainCards(playingCards: Card[]): boolean { 266 | const groups = _.groupBy(playingCards, adjustedCardRank) 267 | 268 | if (_.keys(_.groupBy(groups, 'length')).length === 1) { 269 | const ranks = _.sortBy(_.keys(groups).map( rank => parseInt(rank))) 270 | return _.isEqual(ranks, _.range(ranks[0], ranks[ranks.length - 1] + 1)) && _.keys(groups).length >= 3 271 | } 272 | 273 | return false 274 | } 275 | 276 | function ableToPlayChainCards(previousCards: Card[], playingCards: Card[]): boolean { 277 | const previousRanks = _.keys(_.groupBy(previousCards, 'rank')).map( rank => parseInt(rank)).sort() 278 | const playingRanks = _.keys(_.groupBy(playingCards, 'rank')).map( rank => parseInt(rank)).sort() 279 | 280 | if (isChainCards(previousCards) && isChainCards(playingCards)) { 281 | if (previousRanks.length === 0 || previousRanks.length === playingRanks.length) { 282 | return adjustedRank(playingRanks[0]) > adjustedRank(previousRanks[0]) 283 | } 284 | } 285 | 286 | return false 287 | } 288 | 289 | function isBomb(playingCards: Card[]): boolean { 290 | const groups = _.groupBy(playingCards, 'rank') 291 | return playingCards.length === 4 && _.keys(groups).length === 1 292 | } 293 | 294 | function ableToPlayBomb(previousCards: Card[], playingCards: Card[]): boolean { 295 | return isBomb(playingCards) && (!isBomb(previousCards) || adjustedCardRank(playingCards[0]) >= adjustedCardRank(previousCards[0])) 296 | } 297 | 298 | function adjustedCardRank(card: Card): number { 299 | return adjustedRank(card.rank) 300 | } 301 | 302 | function adjustedRank(rank: number): number { 303 | if (rank <= 2) { 304 | return rank + 13 305 | } else { 306 | return rank 307 | } 308 | } 309 | --------------------------------------------------------------------------------