本节内容是通过 Django Channels 框架使用 WebSocket 协议实现多人模式中的同步创建玩家函数。
1. 统一长度单位
多人模式中每个玩家所看到的地图相对来说应该是一样的,因此需要固定地图的长宽比,一般固定为16:9。我们需要在游戏窗口的长宽中取最小值,然后将地图渲染为16:9的大小。
我们在 AcGamePlayground
类中实现一个 resize
函数用于将长宽比调整为16:9并且达到最大:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 class AcGamePlayground { constructor (root ) { this .root = root; this .$playground = $(` <div class='ac_game_playground'> </div> ` ); this .root .$ac_game .append (this .$playground ); this .start (); } get_random_color ( ) { ... } start ( ) { this .hide (); let outer = this ; $(window ).resize (function ( ) { outer.resize (); }); } resize ( ) { this .width = this .$playground .width (); this .height = this .$playground .height (); let unit = Math .min (this .width / 16 , this .height / 9 ); this .width = unit * 16 ; this .height = unit * 9 ; this .scale = this .height ; if (this .game_map ) this .game_map .resize (); } show ( ) { this .$playground .show (); this .width = this .$playground .width (); this .height = this .$playground .height (); this .game_map = new GameMap (this ); this .resize (); ... } hide ( ) { this .$playground .hide (); } }
现在需要将窗口大小的修改效果作用到黑色背景上,因此我们在 GameMap
类中也实现一个 resize
函数用于修改背景大小:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 class GameMap extends AcGameObject { constructor (playground ) { super (); this .playground = playground; this .$canvas = $(`<canvas></canvas>` ); this .ctx = this .$canvas [0 ].getContext ('2d' ); this .ctx .canvas .width = this .playground .width ; this .ctx .canvas .height = this .playground .height ; this .playground .$playground .append (this .$canvas ); } start ( ) { } resize ( ) { this .ctx .canvas .width = this .playground .width ; this .ctx .canvas .height = this .playground .height ; this .ctx .fillStyle = 'rgba(0, 0, 0, 1)' ; this .ctx .fillRect (0 , 0 , this .ctx .canvas .width , this .ctx .canvas .height ); } update ( ) { this .render (); } render ( ) { this .ctx .fillStyle = 'rgba(0, 0, 0, 0.2)' ; this .ctx .fillRect (0 , 0 , this .ctx .canvas .width , this .ctx .canvas .height ); } }
我们修改一下 game.css
文件,添加以下内容,实现将地图居中:
1 2 3 4 5 6 .ac_game_playground > canvas { position : relative; top : 50% ; left : 50% ; transform : translate (-50% , -50% ); }
现在我们还需要修改地图里面的目标,一共有三种分别是玩家、火球、被击中的粒子效果。
首先修改一下 AcGamePlayground
类中的玩家初始化代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 class AcGamePlayground { constructor (root ) { ... } get_random_color ( ) { ... } start ( ) { ... } resize ( ) { ... } show ( ) { this .$playground .show (); this .width = this .$playground .width (); this .height = this .$playground .height (); this .game_map = new GameMap (this ); this .resize (); this .players = []; this .players .push (new Player (this , this .width / 2 / this .scale , 0.5 , 0.05 , 'white' , 0.15 , true )); for (let i = 0 ; i < 8 ; i++) { this .players .push (new Player (this , this .width / 2 / this .scale , 0.5 , 0.05 , this .get_random_color (), 0.15 , false )); } } hide ( ) { this .$playground .hide (); } }
然后我们修改 Player
类,将所有绝对变量替换为相对变量:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 class Player extends AcGameObject { constructor (playground, x, y, radius, color, speed, is_me ) { ... this .eps = 0.01 ; ... } start ( ) { if (this .is_me ) { this .add_listening_events (); } else { let tx = Math .random () * this .playground .width / this .playground .scale ; let ty = Math .random () * this .playground .height / this .playground .scale ; this .move_to (tx, ty); } } add_listening_events ( ) { let outer = this ; this .playground .game_map .$canvas .on ('contextmenu' , function ( ) { return false ; }); this .playground .game_map .$canvas .mousedown (function (e ) { const rect = outer.ctx .canvas .getBoundingClientRect (); if (e.which === 3 ) { outer.move_to ((e.clientX - rect.left ) / outer.playground .scale , (e.clientY - rect.top ) / outer.playground .scale ); } else if (e.which === 1 ) { if (outer.cur_skill === 'fireball' ) { outer.shoot_fireball ((e.clientX - rect.left ) / outer.playground .scale , (e.clientY - rect.top ) / outer.playground .scale ); } outer.cur_skill = null ; } }); $(window ).keydown (function (e ) { if (e.which === 81 ) { outer.cur_skill = 'fireball' ; return false ; } }); } get_dist (x1, y1, x2, y2 ) { ... } shoot_fireball (tx, ty ) { let x = this .x , y = this .y ; let radius = 0.01 ; let theta = Math .atan2 (ty - this .y , tx - this .x ); let vx = Math .cos (theta), vy = Math .sin (theta); let color = 'orange' ; let speed = 0.5 ; let move_length = 0.8 ; new FireBall (this .playground , this , x, y, radius, vx, vy, color, speed, move_length, 0.01 ); } move_to (tx, ty ) { ... } is_attacked (theta, damage ) { for (let i = 0 ; i < 10 + Math .random () * 5 ; i++) { let x = this .x , y = this .y ; let radius = this .radius * Math .random () * 0.2 ; let theta = Math .PI * 2 * Math .random (); let vx = Math .cos (theta), vy = Math .sin (theta); let color = this .color ; let speed = this .speed * 10 ; let move_length = this .radius * Math .random () * 10 ; new Particle (this .playground , x, y, radius, vx, vy, color, speed, move_length); } this .radius -= damage; this .speed *= 1.08 ; if (this .radius < this .eps ) { this .destroy (); return false ; } this .damage_vx = Math .cos (theta); this .damage_vy = Math .sin (theta); this .damage_speed = damage * 90 ; } update_move ( ) { this .spent_time += this .timedelta / 1000 ; if (this .spent_time > 3 && !this .is_me && Math .random () < 1 / 360.0 ) { let player = this .playground .players [0 ]; this .shoot_fireball (player.x , player.y ); } if (this .damage_speed > this .eps ) { this .vx = this .vy = 0 ; this .move_length = 0 ; this .x += this .damage_vx * this .damage_speed * this .timedelta / 1000 ; this .y += this .damage_vy * this .damage_speed * this .timedelta / 1000 ; this .damage_speed *= this .friction ; } else { if (this .move_length < this .eps ) { this .move_length = 0 ; this .vx = this .vy = 0 ; if (!this .is_me ) { let tx = Math .random () * this .playground .width / this .playground .scale ; let ty = Math .random () * this .playground .height / this .playground .scale ; this .move_to (tx, ty); } } else { let true_move = Math .min (this .move_length , this .speed * this .timedelta / 1000 ); this .x += this .vx * true_move; this .y += this .vy * true_move; this .move_length -= true_move; } } } update ( ) { this .update_move (); this .render (); } render ( ) { let scale = this .playground .scale ; if (this .is_me ) { this .ctx .save (); this .ctx .beginPath (); this .ctx .arc (this .x * scale, this .y * scale, this .radius * scale, 0 , Math .PI * 2 , false ); this .ctx .stroke (); this .ctx .clip (); this .ctx .drawImage (this .img , (this .x - this .radius ) * scale, (this .y - this .radius ) * scale, this .radius * 2 * scale, this .radius * 2 * scale); this .ctx .restore (); } else { this .ctx .beginPath (); this .ctx .arc (this .x * scale, this .y * scale, this .radius * scale, 0 , Math .PI * 2 , false ); this .ctx .fillStyle = this .color ; this .ctx .fill (); } } }
然后修改 FireBall
类,只需要修改 eps
以及 render
函数即可:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 class FireBall extends AcGameObject { constructor (playground, player, x, y, radius, vx, vy, color, speed, move_length, damage ) { ... this .eps = 0.01 ; } start ( ) { } update ( ) { ... } get_dist (x1, y1, x2, y2 ) { ... } is_collision (player ) { ... } attack (player ) { ... } render ( ) { let scale = this .playground .scale ; this .ctx .beginPath (); this .ctx .arc (this .x * scale, this .y * scale, this .radius * scale, 0 , Math .PI * 2 , false ); this .ctx .fillStyle = this .color ; this .ctx .fill (); } }
最后修改 Particle
类,同样也是只需要修改 eps
以及 render
函数即可:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 class Particle extends AcGameObject { constructor (playground, x, y, radius, vx, vy, color, speed, move_length ) { ... this .eps = 0.01 ; this .friction = 0.9 ; } start ( ) { } update ( ) { ... } render ( ) { let scale = this .playground .scale ; this .ctx .beginPath (); this .ctx .arc (this .x * scale, this .y * scale, this .radius * scale, 0 , Math .PI * 2 , false ); this .ctx .fillStyle = this .color ; this .ctx .fill (); } }
2. 增加联机对战模式
我们先修改 AcGameMenu
类,实现多人模式按钮的逻辑:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 class AcGameMenu { constructor (root ) { ... } start ( ) { this .hide (); this .add_listening_events (); } add_listening_events ( ) { let outer = this ; this .$single .click (function ( ) { outer.hide (); outer.root .playground .show ('single mode' ); }); this .$multi .click (function ( ) { outer.hide (); outer.root .playground .show ('multi mode' ); }); this .$settings .click (function ( ) { outer.root .settings .logout_on_remote (); }); } show ( ) { this .$menu .show (); } hide ( ) { this .$menu .hide (); } }
然后修改 AcGamePlayground
类,区分两种模式,且需要进一步区分玩家类别,之前使用 True/False 表示是否是玩家本人,现在可以用字符串区分玩家本人(me
)、其他玩家(enemy
)以及人机(robot
):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 class AcGamePlayground { constructor (root ) { ... } get_random_color ( ) { ... } start ( ) { ... } resize ( ) { ... } show (mode ) { this .$playground .show (); this .width = this .$playground .width (); this .height = this .$playground .height (); this .game_map = new GameMap (this ); this .resize (); this .players = []; this .players .push (new Player (this , this .width / 2 / this .scale , 0.5 , 0.05 , 'white' , 0.15 , 'me' , this .root .settings .username , this .root .settings .avatar )); if (mode === 'single mode' ){ for (let i = 0 ; i < 8 ; i++) { this .players .push (new Player (this , this .width / 2 / this .scale , 0.5 , 0.05 , this .get_random_color (), 0.15 , 'robot' )); } } else if (mode === 'multi mode' ) { } } hide ( ) { this .$playground .hide (); } }
然后还需要修改一下 Player
类,将原本的 this.is_me
判断进行修改:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 class Player extends AcGameObject { constructor (playground, x, y, radius, color, speed, character, username, avatar ) { ... this .character = character; this .username = username; this .avatar = avatar; ... if (this .character !== 'robot' ) { this .img = new Image (); this .img .src = this .avatar ; } } start ( ) { if (this .character === 'me' ) { this .add_listening_events (); } else if (this .character === 'robot' ) { ... } } add_listening_events ( ) { ... } get_dist (x1, y1, x2, y2 ) { ... } shoot_fireball (tx, ty ) { ... } move_to (tx, ty ) { ... } is_attacked (theta, damage ) { ... } update_move ( ) { this .spent_time += this .timedelta / 1000 ; if (this .character === 'robot' && this .spent_time > 3 && Math .random () < 1 / 360.0 ) { ... } if (this .damage_speed > this .eps ) { ... } else { if (this .move_length < this .eps ) { ... if (this .character === 'robot' ) { ... } } else { ... } } } update ( ) { this .update_move (); this .render (); } render ( ) { let scale = this .playground .scale ; if (this .character !== 'robot' ) { ... } else { ... } } }
3. 配置Django Channels
假设有三名玩家编号为1、2、3进行多人游戏,那么每个玩家都有自己的一个窗口,且窗口中都能看到三名玩家。如果当前玩家1、2在进行游戏,3加入了游戏,那么需要告诉1、2两名玩家3来了,且还要告诉3当前已经有玩家1、2了。
要实现这一点,可以通过一个中心服务器 Server (可以就是自己租的云服务器),即3向服务器发送他来了,服务器给1、2发送消息,且服务器给3发送消息说之前已经有1、2两名玩家了。因此服务器中需要存储每个地图中的玩家信息,用于完成第一个同步事件:生成玩家事件。
我们之后一共需要实现四个同步函数:create_player
、move_to
、shoot_fireball
、attack
。前三个函数顾名思义,最后的 attack
函数是因为服务器存在延迟,比如3发射一个火球在本地看打中了1,但是由于延迟在1那边可能是没被打中的。
攻击判断是一个权衡问题,一般的游戏都是选择在本地进行攻击判断,而不是云服务器,即以发起攻击的玩家窗口进行判断,如果击中了则通过 attack
函数在服务器上广播信息。
在此之前我们使用的是 HTTP 协议,该协议为单向的,即客户端需要先向服务器请求信息后服务器才会返回信息,而服务器是不会主动向客户端发送信息的。
因此此处我们需要使用 WebSocket 协议(WS),同理该协议也有对应的加密协议 WSS,Django Channels 即为 Django 支持 WSS 协议的一种实现方式。
(1)安装 channels_redis
:
1 pip install channels_redis
(2)配置 djangoapp/djangoapp/asgi.py
文件:
文件内容如下(注意 djangoapp
需要改成自己项目的名称):
1 2 3 4 5 6 7 8 9 10 11 12 13 import osfrom channels.auth import AuthMiddlewareStackfrom channels.routing import ProtocolTypeRouter, URLRouterfrom django.core.asgi import get_asgi_applicationfrom game.routing import websocket_urlpatternsos.environ.setdefault('DJANGO_SETTINGS_MODULE' , 'djangoapp.settings' ) application = ProtocolTypeRouter({ "http" : get_asgi_application(), "websocket" : AuthMiddlewareStack(URLRouter(websocket_urlpatterns)) })
(3)配置 djangoapp/djangoapp/settings.py
文件:
在 INSTALLED_APPS
中添加 channels
,添加后如下所示(注意 djangoapp
需要改成自己项目的名称):
1 2 3 4 5 6 7 8 9 10 INSTALLED_APPS = [ 'channels' , 'game.apps.GameConfig' , 'django.contrib.admin' , 'django.contrib.auth' , 'django.contrib.contenttypes' , 'django.contrib.sessions' , 'django.contrib.messages' , 'django.contrib.staticfiles' , ]
然后在文件末尾添加:
1 2 3 4 5 6 7 8 9 ASGI_APPLICATION = 'djangoapp.asgi.application' CHANNEL_LAYERS = { "default" : { "BACKEND" : "channels_redis.core.RedisChannelLayer" , "CONFIG" : { "hosts" : [("127.0.0.1" , 6379 )], }, }, }
(4)配置 game/routing.py
文件:
这一部分的作用相当于 HTTP 的 urls
,文件内容如下:
1 2 3 4 from django.urls import pathwebsocket_urlpatterns = [ ]
(5)编写 game/consumers
,这一部分的作用相当于 HTTP 的 views
。
在 game
目录下创建 consumers
目录,然后进入该目录,先创建好 __init__.py
文件。由于我们未来会使用 WSS 协议支持联机对战和聊天室,因此我们需要再创建两个目录,先创建 multiplayer
目录,进入该目录创建 __init__.py
文件,然后编写 index.py
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 from channels.generic.websocket import AsyncWebsocketConsumerimport jsonclass MultiPlayer (AsyncWebsocketConsumer ): async def connect (self ): await self.accept() print ('accept' ) self.room_name = "room" await self.channel_layer.group_add(self.room_name, self.channel_name) async def disconnect (self, close_code ): print ('disconnect' ) await self.channel_layer.group_discard(self.room_name, self.channel_name) async def receive (self, text_data ): data = json.loads(text_data) print (data)
(6)启动 django_channels
:
首先安装 daphne
:
输入 daphne
查看是否可用,如果不可用说明应该是没有配置环境变量,按如下方式修改环境变量(需要重启系统):
1 2 3 4 5 sudo vim /etc/environment 在 PATH='xxx' 后面添加 ':/home/<用户名>/.local/bin' 即: 'xxx:/home/<用户名>/.local/bin' source /etc/environment # 应用更新
HTTP 有 uwsgi
启动服务,WS 同样也需要启动,使用的是 asgi
,在 ~/djangoapp
目录下执行(注意 djangoapp
需要改成自己项目的名称):
1 daphne -b 0.0.0.0 -p 5015 djangoapp.asgi:application
项目进行到这里已经需要启动多个服务了,顺序是:
1 2 3 4 NginX: sudo /etc/init.d/nginx start Redis-server: sudo redis-server /etc/redis/redis.conf uWSGI: uwsgi --ini scripts/uwsgi.ini Django_channels: daphne -b 0.0.0.0 -p 5015 djangoapp.asgi:application
4. 前端创建连接
前端(Playground)需要跟后端(WS)连接,我们需要在每个客户端中建立一个和服务器的连接,一般是使用 Web Socket 连接。
首先配置一下 /djangoapp/game
目录下的路由 routing.py
:
1 2 3 4 5 6 from django.urls import pathfrom game.consumers.multiplayer.index import MultiPlayerwebsocket_urlpatterns = [ path('wss/multiplayer/' , MultiPlayer.as_asgi(), name='wss_multiplayer' ), ]
我们在 /djangoapp/game/static/js/src/playground
目录下创建 socket
目录,Socket 也分为两个模块,分别为联机模式和聊天室,还是先实现联机模式,在 socket
目录中创建 multiplayer
目录,进入该目录后创建 zbase.js
:
1 2 3 4 5 6 7 8 9 10 11 12 13 class MultiPlayerSocket { constructor (playground ) { this .playground = playground; this .ws = new WebSocket ('wss://app4007.acapp.acwing.com.cn/wss/multiplayer/' ); this .start (); } start ( ) { } }
当前端创建连接,即执行 new WebSocket
时,会调用 consumers
的 MultiPlayer
类(之后直接称为 MultiPlayer
)中的 connect
函数;当前端断开连接(刷新或者关闭页面)时,会调用 disconnect
函数;receive
函数用于接收前端向后端发送的请求。
由于服务器需要向多个客户端群发消息,Django Channels 中有一个概念叫做组(group),可以将多个不同的连接放到同一个组里,可以使用相关函数统一操作组里的连接,例如 group_send
群发消息。
现在在 AcGamePlayground
类中添加我们刚实现的 MultiPlayerSocket
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 class AcGamePlayground { constructor (root ) { ... } get_random_color ( ) { ... } start ( ) { ... } resize ( ) { ... } show (mode ) { ... if (mode === 'single mode' ){ for (let i = 0 ; i < 8 ; i++) { this .players .push (new Player (this , this .width / 2 / this .scale , 0.5 , 0.05 , this .get_random_color (), 0.15 , 'robot' )); } } else if (mode === 'multi mode' ) { this .mps = new MultiPlayerSocket (this ); } } hide ( ) { this .$playground .hide (); } }
然后我们打包静态文件并同步到发行版本,复习一下:
1 2 3 cd ~/djangoapp ./scripts/compress_game_js.sh python3 manage.py collectstatic
重启一下服务,现在我们每次都需要重启两个服务,即 HTTPS 和 WSS:
1 2 uwsgi --ini scripts/uwsgi.ini daphne -b 0.0.0.0 -p 5015 djangoapp.asgi:application
打开游戏进入多人模式,即可在后端的 WSS 服务看到输出信息。
5. 前端发送请求
有玩家进来时,需要两个函数,一个是实现客户端向服务器发送 create_player
请求,另一个是实现服务器从客户端接收请求的功能。
先实现发送请求功能,在 MultiPlayerSocket
类中编写 send_create_player
函数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 class MultiPlayerSocket { constructor (playground ) { this .playground = playground; this .ws = new WebSocket ('wss://app4007.acapp.acwing.com.cn/wss/multiplayer/' ); this .start (); } start ( ) { } send_create_player ( ) { this .ws .send (JSON .stringify ({ 'message' : 'hello world!' })); } receive_create_player ( ) { } }
然后在 AcGamePlayground
类中调用:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 class AcGamePlayground { constructor (root ) { ... } get_random_color ( ) { ... } start ( ) { ... } resize ( ) { ... } show (mode ) { let outer = this ; ... if (mode === 'single mode' ){ for (let i = 0 ; i < 8 ; i++) { this .players .push (new Player (this , this .width / 2 / this .scale , 0.5 , 0.05 , this .get_random_color (), 0.15 , 'robot' )); } } else if (mode === 'multi mode' ) { this .mps = new MultiPlayerSocket (this ); this .mps .ws .onopen = function ( ) { outer.mps .send_create_player (); }; } } hide ( ) { this .$playground .hide (); } }
没有修改过后端代码,因此不需要重启服务,直接重新打包一下静态文件即可,然后进入多人模式即可看到后端的输出信息,说明现在连接就已经创建成功了。
6. 编写同步函数create_player
我们每一个地图的所有信息都会有一个备份,比如1号玩家在2、3号窗口都会有一个备份,在不同的地图里我们需要能够判断出来谁是谁,比如在1号窗口中玩家1击中了玩家2,那么把信息发送到第二个窗口后,需要知道谁是2。
因此我们需要给所有信息一个唯一的编号(可以使用一个随机的八位数,如果怕重复可以使用更多位数),使得我们未来知道需要同步哪些东西。在 AcGameObject
类中进行修改:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 let AC_GAME_OBJECTS = []; class AcGameObject { constructor ( ) { AC_GAME_OBJECTS .push (this ); this .has_called_start = false ; this .timedelta = 0 ; this .uuid = this .create_uuid (); } create_uuid ( ) { let res = '' ; for (let i = 0 ; i < 8 ; i++) { let x = parseInt (Math .floor (Math .random () * 10 )); res += x; } return res; } start ( ) { } update ( ) { } on_destroy ( ) { } destroy ( ) { ... } } let last_timestamp; let AC_GAME_ANIMATION = function (timestamp ) { ... } requestAnimationFrame (AC_GAME_ANIMATION );
现在又有一个问题,每次有一名新玩家进入时都会创建若干个编号,但是和之前其它窗口中的编号不一致,我们需要用通信的方式将他们保持一致,原则为谁创建的对象就用谁那边产生的编号 ,比如1号窗口创建了1号玩家,那么其它玩家窗口中的1号玩家的编号就由1号窗口发送过来。
由于某个客户端(假设是1号窗口)向服务器发送消息后服务器会转播给所有 客户端,也就是会发给自己(1号窗口),这种情况1号窗口应该要 Pass 掉这条信息,因此我们需要判断信息是哪个客户端发的 。这边我们就可以用每个玩家的唯一编号 ,这样就可以保证每个窗口不一样,我们在 AcGamePlayground
中修改:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 class AcGamePlayground { constructor (root ) { ... } get_random_color ( ) { ... } start ( ) { ... } resize ( ) { ... } show (mode ) { let outer = this ; ... if (mode === 'single mode' ){ for (let i = 0 ; i < 8 ; i++) { this .players .push (new Player (this , this .width / 2 / this .scale , 0.5 , 0.05 , this .get_random_color (), 0.15 , 'robot' )); } } else if (mode === 'multi mode' ) { this .mps = new MultiPlayerSocket (this ); this .mps .uuid = this .players [0 ].uuid ; this .mps .ws .onopen = function ( ) { outer.mps .send_create_player (); }; } } hide ( ) { this .$playground .hide (); } }
现在客户端向服务器端发送消息的时候就要带上自己的 uuid
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 class MultiPlayerSocket { constructor (playground ) { this .playground = playground; this .ws = new WebSocket ('wss://app4007.acapp.acwing.com.cn/wss/multiplayer/' ); this .start (); } start ( ) { } send_create_player ( ) { let outer = this ; this .ws .send (JSON .stringify ({ 'event' : 'create_player' , 'uuid' : outer.uuid , })); } receive_create_player ( ) { } }
现在再测试一下即可在后端看到服务器接收到的客户端信息。
接下来我们需要同步 create_player
这个事件,即当有新玩家来的时候,在所有窗口里创建这个玩家,同时将已有的玩家渲染到当前窗口里。
首先我们需要在服务器端存下每一个游戏房间的信息,可以存在 Redis 里,我们可以用房间(room)的概念。每个房间有人数上限,这是一个通用配置,可以写在 settings.py
里,我们往该文件里添加一行:
然后修改后端文件(/djangoapp/game/consumers/multiplayer
目录中的 index.py
):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 from channels.generic.websocket import AsyncWebsocketConsumerimport jsonfrom django.conf import settingsfrom django.core.cache import cacheclass MultiPlayer (AsyncWebsocketConsumer ): async def connect (self ): self.room_name = None for i in range (1000 ): name = 'room-%d' % (i) if not cache.has_key(name) or len (cache.get(name)) < settings.ROOM_CAPACITY: self.room_name = name break if not self.room_name: return await self.accept() print ('accept' ) if not cache.has_key(self.room_name): cache.set (self.room_name, [], 3600 ) for player in cache.get(self.room_name): await self.send(text_data=json.dumps({ 'event' : 'create_player' , 'uuid' : player['uuid' ], 'username' : player['username' ], 'avatar' : player['avatar' ], })) await self.channel_layer.group_add(self.room_name, self.channel_name) async def disconnect (self, close_code ): print ('disconnect' ) await self.channel_layer.group_discard(self.room_name, self.channel_name) async def receive (self, text_data ): data = json.loads(text_data) print (data)
现在我们需要在 AcGamePlayground
中传送用户名和头像参数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 class AcGamePlayground { constructor (root ) { ... } get_random_color ( ) { ... } start ( ) { ... } resize ( ) { ... } show (mode ) { let outer = this ; ... if (mode === 'single mode' ){ ... } else if (mode === 'multi mode' ) { this .mps = new MultiPlayerSocket (this ); this .mps .uuid = this .players [0 ].uuid ; this .mps .ws .onopen = function ( ) { outer.mps .send_create_player (outer.root .settings .username , outer.root .settings .avatar ); }; } } hide ( ) { this .$playground .hide (); } }
然后在 MultiPlayerSocket
中接收信息并向服务器后端发送请求:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 class MultiPlayerSocket { constructor (playground ) { this .playground = playground; this .ws = new WebSocket ('wss://app4007.acapp.acwing.com.cn/wss/multiplayer/' ); this .start (); } start ( ) { } send_create_player (username, avatar ) { let outer = this ; this .ws .send (JSON .stringify ({ 'event' : 'create_player' , 'uuid' : outer.uuid , 'username' : username, 'avatar' : avatar, })); } receive_create_player ( ) { } }
现在编写后端接收到信息后的处理逻辑,接收到创建玩家的信息后需要将该玩家添加到房间中,并给房间中其他连接群发消息,组内各个连接接收到消息后再发送给前端即可:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 from channels.generic.websocket import AsyncWebsocketConsumerimport jsonfrom django.conf import settingsfrom django.core.cache import cacheclass MultiPlayer (AsyncWebsocketConsumer ): async def connect (self ): self.room_name = None for i in range (1000 ): name = 'room-%d' % (i) if not cache.has_key(name) or len (cache.get(name)) < settings.ROOM_CAPACITY: self.room_name = name break if not self.room_name: return await self.accept() print ('accept' ) if not cache.has_key(self.room_name): cache.set (self.room_name, [], 3600 ) for player in cache.get(self.room_name): await self.send(text_data=json.dumps({ 'event' : 'create_player' , 'uuid' : player['uuid' ], 'username' : player['username' ], 'avatar' : player['avatar' ], })) await self.channel_layer.group_add(self.room_name, self.channel_name) async def disconnect (self, close_code ): print ('disconnect' ) await self.channel_layer.group_discard(self.room_name, self.channel_name) async def create_player (self, data ): players = cache.get(self.room_name) players.append({ 'uuid' : data['uuid' ], 'username' : data['username' ], 'avatar' : data['avatar' ], }) cache.set (self.room_name, players, 3600 ) await self.channel_layer.group_send( self.room_name, { 'type' : 'group_send_event' , 'event' : 'create_player' , 'uuid' : data['uuid' ], 'username' : data['username' ], 'avatar' : data['avatar' ], } ) async def group_send_event (self, data ): await self.send(text_data=json.dumps(data)) async def receive (self, text_data ): data = json.loads(text_data) print (data) event = data['event' ] if event == 'create_player' : await self.create_player(data)
现在我们需要在前端处理接收 WSS 协议的信息,修改一下 MultiPlayerSocket
类:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 class MultiPlayerSocket { constructor (playground ) { this .playground = playground; this .ws = new WebSocket ('wss://app4007.acapp.acwing.com.cn/wss/multiplayer/' ); this .start (); } start ( ) { this .receive (); } receive ( ) { let outer = this ; this .ws .onmessage = function (e ) { let data = JSON .parse (e.data ); let uuid = data.uuid ; if (uuid === outer.uuid ) return false ; let event = data.event ; if (event === 'create_player' ) { outer.receive_create_player (uuid, data.username , data.avatar ); } }; } send_create_player (username, avatar ) { let outer = this ; this .ws .send (JSON .stringify ({ 'event' : 'create_player' , 'uuid' : outer.uuid , 'username' : username, 'avatar' : avatar, })); } receive_create_player (uuid, username, avatar ) { let player = new Player (this .playground , this .playground .width / 2 / this .playground .scale , 0.5 , 0.05 , 'white' , 0.15 , 'enemy' , username, avatar); player.uuid = uuid; this .playground .players .push (player); } }
现在我们多开窗口即可看到多名玩家同步到一个地图上了。
上一章:Django学习笔记-AcApp端授权AcWing一键登录 。
下一章:Django学习笔记-实现联机对战(下) 。