├── .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 | 
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