Django上课笔记(五)——联机对战的实现
也欢迎大家光临我另外项目课的其他博客:
Django上课笔记(一)——环境配置与项目创建(过程十分详细) - AcWing
(更新版)Django上课笔记(二)——菜单模块的实现, 含自动创建项目的脚本
Django上课笔记(三)——简单游戏的实现(模块拆分化详解) - AcWing
Django上课笔记(四)——(用户系统的实现) - AcWing
项目地址
https://git.acwing.com/codeRokie/acapp
前端度量标准的统一
原因:
- 由于要涉及到多人联机。
- 在客户端每个玩家的窗口大小可能不同,
- 对应的对象坐标,地图大小可能各不相同,
- 就无法实现多名玩家间的相互通信,为此,一定要统一在各种客户端情况下的
长度
概念
统一地图比例
1.原因:
- 考虑到客户端的各种设备不统一
- 浏览器窗口大小可以被用户调整为任意比例
- 方便定义度量单位
2.思路:
- 统一游戏地图比例为
16:9
- 以高度作为单位1
- 若浏览器窗口比例不符,则按照窗口长宽中的较小者作为地图的长
- 地图之外的部分可以做一些填充
3.实现:
game/static/js/src/playground/zbase.js
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();
}
我们看到,由于this.scale = this.height;
实际上地图的高度被设置为单位1
受此影响,每个玩家在被创建时传递的参数为:
new Player(this, this.width / 2 / this.scale, 0.5, 0.05, "white", 0.15, "me", this.root.settings.username, this.root.settings.photo);
仔细观察,画面中心高度的坐标为0,5
,所有坐标的参考系都以单位1
为基准
因此我们需要在game.js
中全局搜索关键字:
this.playground.height
:将其改为1
this.playground.width
:将其改为this.playground.width / this.playground.scale
同时,自己实现的特性的参数,也要酌情修改
使地图随着窗口大小变化
1.思路:
监听浏览器窗口大小变化的事件,每当有此事件发生,就调用resize()
2.实现
game/static/js/src/playground/zbase.js
add_listening_events() {
let outer = this;
//$(window).resize()在浏览器窗口大小改变时调用
$(window).resize(function () {
outer.resize();
});
}
并在start()
中调用
增加多人对战模式
实现
1.在game/static/js/src/menu/zbase.js
中实现模式选择
/**
* 监听用户选择了什么模式
*/
add_listening_events() {
let outer = this;
this.$single_mode.click(function(){
outer.hide();
outer.root.playground.show("single mode");
});
this.$multi_mode.click(function(){
outer.hide();
outer.root.playground.show("multi mode");
});
this.$settings.click(function(){
outer.root.settings.logout_on_remote();
});
}
2.在game/static/js/src/playground/zbase.js
中,根据用户选择展示对应菜单
/**
* 根据模式打开对应界面
* @param mode
*/
show(mode) {
// 打开playground界面
let outer = this;
this.$playground.show();
this.root.$ac_game.append(this.$playground);
this.width = this.$playground.width();
this.height = this.$playground.height();
//创建GameMap对象
this.game_map = new GameMap(this);
this.resize();
this.create_player();
}
配置django_channels
1.安装channels_redis
:
pip install channels_redis
2.配置acapp/asgi.py
内容如下:
import os
from channels.auth import AuthMiddlewareStack
from channels.routing import ProtocolTypeRouter, URLRouter
from django.core.asgi import get_asgi_application
from game.routing import websocket_urlpatterns
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'acapp.settings')
application = ProtocolTypeRouter({
"http": get_asgi_application(),
"websocket": AuthMiddlewareStack(URLRouter(websocket_urlpatterns))
})
3.配置acapp/settings.py
在INSTALLED_APPS
中添加channels
,添加后如下所示:
INSTALLED_APPS = [
'channels',
'game.apps.GameConfig',
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
]
然后在文件末尾添加:
ASGI_APPLICATION = 'acapp.asgi.application'
CHANNEL_LAYERS = {
"default": {
"BACKEND": "channels_redis.core.RedisChannelLayer",
"CONFIG": {
"hosts": [("127.0.0.1", 6379)],
},
},
}
4.配置game/routing.py
这一部分的作用相当于http
的urls
内容如下:
在game
下创建routing.py
from django.urls import path
websocket_urlpatterns = [
]
5.编写game/consumers
这一部分的作用相当于http
的views
。
在game/consumers/mutiplayer/index.py
中
参考示例:
from channels.generic.websocket import AsyncWebsocketConsumer
import json
class 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
在~/acapp
目录下执行:
daphne -b 0.0.0.0 -p 5015 acapp.asgi:application
联机对战架构
背景知识
websocket
详细请看:
基本的互联网通信协议都有在RFC文件内详细说明: websocket规范 RFC6455中文版
高质量博客:谈谈Websocket ,HTTP/TCP
观后总结(博主结合计网知识和网上的多篇博客和RFC)
1.TCP协议对应于传输层,而HTTP和websocket协议对应于应用层;HTTP和websocket都建立在TCP之上
2.Http会通过TCP建立起一个到服务器的连接通道,当本次请求需要的数据完毕后,Http会立即将TCP连接断开,这个过程是很短的。
3.Http连接是一种短连接,是一种无状态的连接。所谓的无状态,是指浏览器每次向服务器发起请求的时候,不是通过一个连接,而是每次都建立一个新的连接。
4.用http协议想实现双向通信的方法是轮询
和长轮询
,这两种方法有两大弊端:
- 例如假设服务器端的数据更新速度很快,服务器在传送一个数据包给客户端后必须等待客户端的下一个Get请求到来,才能传递第二个更新的数据包给客户端,如果在网络拥塞的情况下,这个时间用户是不能接受的
- 由于http数据包的头部数据量往往很大(通常有400多个字节),但是真正被服务器需要的数据却很少(有时只有10个字节左右),这样的数据包在网络上周期性的传输,难免对网络带宽是一种浪费。
5.WebSocket是HTTP协议的拓展,80和443端口可以同时支持WebSocket和HTTP,它必须依赖 HTTP 协议进行一次握手,握手成功后,数据就直接从 TCP 通道传输,与 HTTP 无关了。
6.一旦客户端和服务器都发送了他们的握手,如果握手成功,传输数据部分开始。这是一个双向传输通道,每个端都能独立、随意发送数据。且是一种长连接
7.在TCP上实现帧机制,来回到IP包机制,而没有长度限制。比http的请求头要小的多
关于acapp/wsgi.py
和acapp/asgi.py
思路
如何同步玩家信息
整合django_channels
,用websocket
实现:
- 每个客户端需要给主机实时发送请求,“主机”的建立会在后面详解
- 主机也需要实时向每个客户端发送广播。
- 客户端在收到广播后要更新自己的信息
需要同步哪些信息
1.每个物体的位置,通过向服务器发送move_to()
函数及其参数
2.每个玩家的指令
,包括释放各种技能,发送shoot_fireball()
函数
最终决定权的归属
由于实际中不同客户端的网速和性能差异,在判断是否击中了谁
和同一时刻,每个物体在客户端上的真实位置
时,会出现判断混乱
所以,统一把事件的决定权交给释放技能且命中的玩家
。只要在某客户端有技能命中,不管其他人的状态,,之和服务器发送xxx被击中
的事件
。一旦判断你有被击中的状态
,不管在客户端情况为何,都会强制更新你的状态
主机、连接和客户端
注意:与主机通信的单位不是物体
而是房间
前后端建立通信(架构部分)
还是经典的三大块:前端
、路由
、业务控制层(consumers)
后端业务控制层(consumers)
在game/consumers/multiplayer/index.py
中实现MultiPlayer
类。MultiPlayer
类的实体即为主机。
MultiPlayer
类继承自AsyncWebsocketConsumer
类
AsyncWebsocketConsumer
类的基本框架(模板):
class EchoConsumer(AsyncConsumer):
async def connect(self, event):
async def receive(self, event):
async def disconnect(self, close_code):
模板中固定要实现的三个函数:
connect
: 建立连接后执行的函数disconnect
:断开连接时执行的函数‘receive
: 主机在接收到客户端消息后调用的函数
主机只有在接收到消息后才会广播(即调用不同业务的send函数)
后端路由
在game/routing.py
中
from django.urls import path
from game.consumers.mutiplayer.index import MultiPlayer
websocket_urlpatterns = [
path("wss/mutiplayer/" ,MultiPlayer.as_asgi,name = "wss_multiplayer"),
]
前端架构
前端需要实现一个MultiPlayerSocket
类,去与主机连接,并实现一系列数据的发送,以及接收主机数据,并对每种作出一系列相应处理
在game/static/js/src/playground/socket/multiplayer/zbase.js
中
class MultiPlayerSocket {
constructor(playground) {
this.playground = playground;
//建立websocket连接
this.ws = new WebSocket("wss://app220.acapp.acwing.com.cn/wss/multiplayer/");
this.uuid = null;
this.start();
}
start() {
this.receive();
}
/**
* 通过每个物体的唯一id去找到对应的对象
* @param uuid
* @returns {null|*}
*/
get_player(uuid) {
let players = this.playground.players;
for (let i = 0; i < players.length; i++) {
if (players[i].uuid === uuid) {
return players[i]
}
}
return null
}
/**
* 接收主机发来请求,并控制实现各种业务逻辑
*/
receive() {
}
主机只有在接收到消息后才会在不同的模块中调用不同业务的send函数
联机对战具体业务实现
将玩家分配到不同房间
前后端的收发函数的对比
业务的实现
我们一共需要同步3类函数
- 每给物体的位置
- 每个玩家释放的技能
- 每个玩家被攻击后的事件
from channels.generic.websocket import AsyncWebsocketConsumer
import json
from django.conf import settings
from django.core.cache import cache
# 这个类就相当于与所有客户端连接的主机
class MultiPlayer(AsyncWebsocketConsumer):
# 主机与客户端建立连接时的函数
async def connect(self):
print("连接成功")
await self.accept()
# 主机与客户端断开连接时的函数
async def disconnect(self, close_code):
await self.channel_layer.group_discard(self.room_name, self.channel_name)
# 处理主机接收到的消息的函数
async def receive(self, text_data):
data = json.loads(text_data)
event = data['event']
# 每个事件交给不同函数处理
if event == "create_player":
await self.create_player(data)
elif event == "move_to":
await self.move_to(data)
elif event == "shoot_fireball":
await self.shoot_fireball(data)
elif event == "attack":
await self.attack(data)
elif event == "blink":
await self.blink(data)
async def group_send_event(self, data):
await self.send(text_data=json.dumps(data))
async def create_player(self, data):
self.room_name = None
# 遍历所有房间,房间上限暂定为1000
for i in range(100000000):
name = "room-%d" % (i)
# 如果redis中之前没有这个房间,且这个房间未满3人
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
if not cache.has_key(self.room_name):
# 在redis中创建一条房间数据{"房间号":[玩家uuid列表]}
cache.set(self.room_name, [], 3600) # 有效期1小时
# 官网对组的详解:https://channels.readthedocs.io/en/stable/topics/channel_layers.html#groups
# 将玩家以房间号分组
# 遍历当前房间中的所有玩家
for player in cache.get(self.room_name):
# 向每个客户端广播当前玩家信息
await self.send(text_data=json.dumps({
'event': "create_player",
'uuid': player['uuid'],
'username': player['username'],
'photo': player['photo'],
}))
await self.channel_layer.group_add(self.room_name, self.channel_name)
players = cache.get(self.room_name)
players.append({
'uuid': data['uuid'],
'username': data['username'],
'photo': data['photo']
})
cache.set(self.room_name, players, 3600) # 有效期1小时
await self.channel_layer.group_send(
self.room_name,
{
# type为处理这个消息的函数名,是默认必须写的
'type': "group_send_event",
# 以下为自定义发送的消息
'event': "create_player",
'uuid': data['uuid'],
'username': data['username'],
'photo': data['photo'],
}
)
# 模板来源于官网:https://channels.readthedocs.io/en/stable/topics/consumers.html#websocketconsumer
小提示:在增加新技能时,需要在这里补充相应的函数
前端
后端发送 | 前端发送 | 前端接收 | |
---|---|---|---|
移动 | async def move_to(self, data):[HTML_REMOVED] await self.channel_layer.group_send([HTML_REMOVED] self.room_name,[HTML_REMOVED] {[HTML_REMOVED] # type为处理这个消息的函数名,是默认必须写的[HTML_REMOVED] ‘type’: “group_send_event”,[HTML_REMOVED] # 以下为自定义发送的消息[HTML_REMOVED] ‘event’: “move_to”,[HTML_REMOVED] ‘uuid’: data[‘uuid’],[HTML_REMOVED] ‘tx’: data[‘tx’],[HTML_REMOVED] ‘ty’: data[‘ty’],[HTML_REMOVED][HTML_REMOVED] }[HTML_REMOVED] ) | send_move_to(tx, ty) {[HTML_REMOVED] let outer = this[HTML_REMOVED] this.ws.send(JSON.stringify({[HTML_REMOVED] ‘event’: “move_to”,[HTML_REMOVED] ‘uuid’: outer.uuid,[HTML_REMOVED] ‘tx’: tx,[HTML_REMOVED] ‘ty’: ty,[HTML_REMOVED][HTML_REMOVED] }))[HTML_REMOVED] } | receive_move_to(uuid, tx, ty) {[HTML_REMOVED] let player = this.get_player(uuid)[HTML_REMOVED] if (player) {[HTML_REMOVED] player.move_to(tx, ty);[HTML_REMOVED] }[HTML_REMOVED] } |
发射火球 | async def shoot_fireball(self, data):[HTML_REMOVED] await self.channel_layer.group_send([HTML_REMOVED] self.room_name,[HTML_REMOVED] {[HTML_REMOVED] # type为处理这个消息的函数名,是默认必须写的[HTML_REMOVED] ‘type’: “group_send_event”,[HTML_REMOVED] # 以下为自定义发送的消息[HTML_REMOVED] ‘event’: “shoot_fireball”,[HTML_REMOVED] ‘uuid’: data[‘uuid’],[HTML_REMOVED] ‘tx’: data[‘tx’],[HTML_REMOVED] ‘ty’: data[‘ty’],[HTML_REMOVED] “ball_uuid”: data[‘ball_uuid’],[HTML_REMOVED][HTML_REMOVED] }[HTML_REMOVED] ) | send_shoot_fireball(tx, ty, ball_uuid) {[HTML_REMOVED] let outer = this;[HTML_REMOVED] this.ws.send(JSON.stringify({[HTML_REMOVED] ‘event’: “shoot_fireball”,[HTML_REMOVED] ‘uuid’: outer.uuid,[HTML_REMOVED] ‘tx’: tx,[HTML_REMOVED] ‘ty’: ty,[HTML_REMOVED] ‘ball_uuid’: ball_uuid,[HTML_REMOVED] }));[HTML_REMOVED] } | receive_shoot_fireball(uuid, tx, ty, ball_uuid) {[HTML_REMOVED] let attacker = this.get_player(uuid);[HTML_REMOVED] if (attacker) {[HTML_REMOVED] let fireball = attacker.shoot_fireball(tx, ty)[HTML_REMOVED] fireball.uuid = ball_uuid;[HTML_REMOVED] }[HTML_REMOVED][HTML_REMOVED] } |
受到攻击 | async def attack(self, data):[HTML_REMOVED] await self.channel_layer.group_send([HTML_REMOVED] self.room_name,[HTML_REMOVED] {[HTML_REMOVED] # type为处理这个消息的函数名,是默认必须写的[HTML_REMOVED] ‘type’: “group_send_event”,[HTML_REMOVED] # 以下为自定义发送的消息[HTML_REMOVED] ‘event’: “shoot_fireball”,[HTML_REMOVED] ‘uuid’: data[‘uuid’],[HTML_REMOVED] ‘tx’: data[‘tx’],[HTML_REMOVED] ‘ty’: data[‘ty’],[HTML_REMOVED] ‘attacked_uuid’: data[‘attacked_uuid’],[HTML_REMOVED] ‘angle’: data[‘angle’],[HTML_REMOVED] ‘damage’: data[‘damage’],[HTML_REMOVED] ‘ball_uuid’: data[‘ball_uuid’],[HTML_REMOVED][HTML_REMOVED] }[HTML_REMOVED] ) | send_attack(attacked_uuid, x, y, angle, damage, ball_uuid) {[HTML_REMOVED] let outer = this;[HTML_REMOVED] this.ws.send(JSON.stringify({[HTML_REMOVED] ‘event’: “attack”,[HTML_REMOVED] ‘uuid’: outer.uuid,[HTML_REMOVED] ‘attacked_uuid’: attacked_uuid,[HTML_REMOVED] ‘x’: x,[HTML_REMOVED] ‘y’: y,[HTML_REMOVED] ‘angle’: angle,[HTML_REMOVED] ‘damage’: damage,[HTML_REMOVED] ‘ball_uuid’: ball_uuid,[HTML_REMOVED][HTML_REMOVED] }));[HTML_REMOVED] } | receive_attack(uuid, attacked_uuid, x, y, angle, damage, ball_uuid) {[HTML_REMOVED] let attacker = this.get_player(uuid);[HTML_REMOVED] let attacked = this.get_player(attacked_uuid);[HTML_REMOVED] if (attacker && attacked) {[HTML_REMOVED] attacked.receive_attack(x, y, angle, damage, “fireball”, ball_uuid, attacker);[HTML_REMOVED] }[HTML_REMOVED] } |
闪现 | async def blink(self, data):[HTML_REMOVED] await self.channel_layer.group_send([HTML_REMOVED] self.room_name,[HTML_REMOVED] {[HTML_REMOVED] ‘type’: “group_send_event”,[HTML_REMOVED] ‘event’: “blink”,[HTML_REMOVED] ‘uuid’: data[‘uuid’],[HTML_REMOVED] ‘tx’: data[‘tx’],[HTML_REMOVED] ‘ty’: data[‘ty’],[HTML_REMOVED] }[HTML_REMOVED] ) | send_blink(tx, ty) {[HTML_REMOVED] let outer = this;[HTML_REMOVED] this.ws.send(JSON.stringify({[HTML_REMOVED] ‘event’: “blink”,[HTML_REMOVED] ‘uuid’: outer.uuid,[HTML_REMOVED] ‘tx’: tx,[HTML_REMOVED] ‘ty’: ty,[HTML_REMOVED] }));[HTML_REMOVED] } | receive_blink(uuid, tx, ty) {[HTML_REMOVED] let player = this.get_player(uuid);[HTML_REMOVED] if (player) {[HTML_REMOVED] player.blink(tx, ty);[HTML_REMOVED] }[HTML_REMOVED] } |
let outer = this;
//$(window).resize()在浏览器窗口大小改变时调用 $(window).resize(function () {
outer.resize();
});
应该不是在add_listening_events函数里吧
start函数里
高质量文章,每次都能让我点赞加收藏
与主机通信的单位不是物体而是房间,大佬讲得太好
Websocket是真正实现了全双工通信的服务器向客户端的互联网技术,是单个TCP连接上进行全双工通信协议
全双工通讯传输协议
允许数据在两个方向上同时传输 。双向传输的意思
半双工:可以双向传输,但是同一时刻只能一个方向传输
半工:单向传输数据
参考博客:https://blog.csdn.net/weixin_39025679/article/details/110950998
未完,求点赞加速更新