Django框架课-多人联机对战 (一)
Django框架课-多人联机对战(一)
统一度量单位
比如这两个地图,有的玩家是第一种,有的玩家的屏幕是第二种。
第一种地图的玩家移动到地图的左部分,在第二种地图里就可能已经出界了。
我们要在联机对战中统一所有地图的长宽比。不同玩家打开地图的长宽比应该是一样的都。
屏幕的长宽比一般是16:9
固定地图尺寸16:9
对于地图,首先要定义一个单位长度unit
,用来表示一个unit有多长或多宽
但是对于长宽比并不是16:9的窗口
好比这窗口,不管他长宽比是多少,绝对不是16:9咯,假设里面红色的框框是16:9(应该不会差不了多少的)。那么限制这个红框的就是这个窗口的高度,如果再高一点,那么他还能在16:9时再宽一点,更大一点。
现在看来,宽高哪个小,就要按照哪个去计算unit
,于是可以像下面这样去计算,宽/16,高/9取min就是unit的值
定义了unit,宽就是unit * 16
,高就是unit * 9
有了unit
,还要有一个scale
,用来以后计算,画出各个物体之间的距离等,这里直接把scale的值取为height的值。
playground/zbase.js:
class AcGamePlayground{
constructor(root){
..
this.root.$ac_game.append(this.$playground);
..
}
}
start() {
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.resize();
..
}
playground/game_map/zbase.js
resize() {
this.ctx.canvas.width = this.playground.width;
this.ctx.canvas.height = this.playground.height;
this.ctx.fillStyle = "rgba(0, 0, 0, 1)"; // 保证每次刷新不让他渐变成background-color,而是直接刷新成这个颜色(透明度变成了1)
this.ctx.fillRect(0, 0, this.ctx.canvas.width, this.ctx.canvas.height);
}
css居中canvas:
.ac-game-playground > canvas {
position: relative;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
调整窗口大小的时候使得地图等比例变化
调窗口大小,某个元素可能看不到了就。
比如这样子,我把窗口拉成这样,一些元素就看不到了,这个时候要把所有的数值改成相对大小就好了。
地图里的角色有:particle
,player
,skill
(粒子,玩家,火球技能),只要改变这三个就可以了,将所有的度量单位改成相对值
包括在初始的时候,将所有的度量单位都改成相对值(除以一个scale)。
不过在最后使用canvas画的时候再将相对值改成绝对值去画(乘一个scale),这时画的时候是画相对于背景窗口大小的绝对坐标
注意什么需要改,什么不需要改。
玩家 Player
初始化的时候,转为传递 scale 的比例值
js/src/playground/zbase.js
class AcGamePlayground {
...
show() { //打开 playground 界面
...
this.players.push(new Player(this, this.width / 2 / this.scale, 0.5, 0.05, "white", 0.15, true));
for (let i = 0; i < 5; i ++ ) {
this.players.push(new Player(this, this.width / 2 / this.scale, 0.5, 0.05, this.get_random_color(), 0.15, false));
}
}
}
js/src/playground/player/zbase.js
class Player {
...
start() {
if (this.is_me) {
...
} else {
let tx = Math.random() * this.playground.width / this.playground.scale;
let ty = Math.random() * this.playground.height / this.playground.scale;
...
}
}
add_listening_events() {
...
this.playground.game_map.$canvas.mousedown(function(e) {
...
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);
}
}
...
});
...
}
shoot_fireball(tx, ty) {
let x = this.x, y = this.y;
let radius = 0.01;
let angle = Math.atan2(ty - this.y, tx - this.x);
let vx = Math.cos(angle), vy = Math.sin(angle);
let color = "orange";
let speed = 0.5;
let move_length = 1.0;
let damage = 0.01;
new FireBall(this.playground, this, x, y, radius, vx, vy, color, speed, move_length, damage);
}
...
update() {
this.update_move();
this.render();
}
update_move() { //更新玩家移动
...
if (!this.is_me && this.spent_time > 4 && Math.random() * 180 < 1) {
...
}
if (this.damage_speed > this.eps) {
...
} else {
if (this.move_length < this.eps) {
...
if (!this.is_me) {
let tx = Math.random() * this.playground.width / this.playground.scale;
let ty = Math.random() * this.playground.height / this.playground.scale;
...
}
} else {
...
}
}
}
render() {
let scale = this.playground.scale;
if (this.is_me) {
...
this.ctx.arc(this.x * scale, this.y * scale, this.radius * scale, 0, Math.PI * 2, false);
...
this.ctx.drawImage(this.img, (this.x - this.radius) * scale, (this.y - this.radius) * scale, this.radius * 2 * scale, this.radius * 2 * scale);
...
} else {
...
this.ctx.arc(this.x * scale, this.y * scale, this.radius * scale, 0, 2 * Math.PI, false);
...
}
}
}
火球 Fireball
js/src/playground/skill/fireball/zbase.js
class Fireball {
...
render() {
let scale = this.playground.scale;
this.ctx.beginPath();
this.ctx.arc(this.x * scale, this.y * scale, this.radius * scale, 0, 2 * Math.PI, false);
this.ctx.fillStyle = this.color;
this.ctx.fill();
}
}
粒子 particle
js/src/playground/particle/zbase.js
class Particle {
...
render() {
let scale = this.playground.scale;
this.ctx.beginPath();
this.ctx.arc(this.x * scale, this.y * scale, this.radius * scale, 0, 2 * Math.PI, false);
this.ctx.fillStyle = this.color;
this.ctx.fill();
}
}
重新git clone之后502了
bug: 粒子效果消失,窗口变化游戏地图没有等比例变化,刚开始碰到了这个bug
不知道为什么,playground/zbase.js的构造函数好像没有执行,或者是start()未执行
找了好久没找到,然后将项目恢复到上次实现acpp端acwing一键登录的现场之后,直接502错误了
找到错误原因了,服务没开启,scripts下的文件
文件并没被删除,但是需要重新输入一下启动服务的代码
原因是这样的:我将整个acapp文件删除后,重新clone过来项目,和原来的路径并不一样的,而我tmux中的路径还是原来上次删除之前的路径qwq
重新写后没问题了
增加联机对战模式
修改is_me为character
两种游戏模式:单机(single mode),联机(multi mode)
先将两种模式在menu菜单初始化的时候传一个参数用以区分玩家是选择了哪个模式。然后以后渲染页面是否渲染人机。
有三种角色:人机(robot),玩家自己(me),其他玩家(enemy)
给player添加一个新的属性character
,如果值是"robot"说明是人机,值为"me"说明是玩家自己,值为"enemy"说明是其他玩家
然后将所有文件的传参改成character
menu/zbase.js
class AcGameMenu{
...
add_listening_events() {
...
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");
});
...
}
...
}
playground/zbase.js
class Playground {
...
show(mode) { //打开 playground 界面
...
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.photo)));
if (mode === "single mode") {
for (let i = 0; i < 5; 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") {
}
}
...
}
playground/player/zbase.js
class Player extends AcGameObject {
constructor(playground, x, y, radius, color, speed, character, username, photo) {
...
this.character = character;
this.username = username;
this.photo = photo;
...
if (this.character !== "robot") {
this.img = new Image();
this.img.src = this.photo;
}
}
...
//同理,根据对应的逻辑,修改后面所有的 is_me 为 character
}
联机对战原理
需要实现四个事件的同步:
1.create player
2.move to
3.shoot fireball
4.attack
比如添加玩家这个事件同步是这样的:
刚开始这个地图里只有1号和2号玩家。3号玩家想要加入到这个对战,那么3号玩家向服务器发送请求,告诉服务器自己要加进去这个对战。
服务器接收到3号玩家的请求之后,向已经在地图中的1号2号两名玩发送信息,告诉1号和2号,3号要加进来了。服务器将1号2号及其信息状态加入到3号的地图里,将3号及其信息和状态加入到1号和2号的地图里。
然后就实现了同步。
可以发现是存在延迟的,一般的fps游戏是否攻击到某名玩家都是在客户端本地判断,不是在服务器端判断。缺点是可以作弊,安全性不强,好处是延迟会小,玩家体验会好一些。比如在我们自己的窗口已经真的击中了,而服务器还在判断是否击中,这样就会有延迟很影响体验感。
比如3号向某个敌人发射了一个火球,是3号的窗口去判断(3号玩家本地判断)有没有击中,如果击中了,就告诉服务器,服务器告诉1号和2号说谁被击中了。
websocket协议
想要实现这四个事件的同步,我们需要一台中心服务器,这个中心服务器要求可以双向通信。之前实现的功能都是使用的http协议,是一种单向协议。
需要使用ws协议(websocket协议),是一个双向协议,支持双向通信。
http对应的加密协议是https协议,ws协议也有其加密协议->websockets协议(wss协议)。
django有一个让django支持wss协议的django_channels。
补充:
Django Channels 是一个用于构建实时 Web 应用程序和网络应用程序的 Django 扩展库。它允许 Django 开发人员使用 WebSocket,HTTP/2 和 ASGI(异步服务器网关接口)等协议来构建具有实时交互性的应用程序
WebSocket 是一种网络通信协议,它在客户端和服务器之间建立双向通信通道,使得客户端和服务器可以进行实时、持久性的数据交换。
WebSocket 协议通过在客户端和服务器之间建立一条长连接,使得服务器可以主动向客户端推送数据,而不需要等待客户端的请求。这种双向通信的方式可以大大提高 Web 应用程序的实时性和交互性,为实时聊天、实时数据更新、在线游戏等应用场景提供了便利。
wss 是 WebSocket Secure 的简称,它是 WebSocket 协议的一种加密协议,使用了 SSL/TLS 加密通信,可以在 WebSocket 连接中提供安全性。
配置channels_redis
channels_redis简介
Django Channels 和 channels_redis 来处理 WebSocket 连接,可以实现双向通信.
配置
参考链接:https://www.acwing.com/blog/content/12692/
- 安装channels_redis:
pip install channels_redis
- 配置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))
})
- 配置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)],
},
},
}
- 配置game/routing.py
这一部分的作用相当于http的urls。
内容如下:
from django.urls import path
websocket_urlpatterns = [
]
- 编写game/consumers
这一部分的作用相当于http的views。
参考示例:
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)
- 启动django_channels
在~/acapp目录下执行:
daphne -b 0.0.0.0 -p 5015 acapp.asgi:application
此时已经将wss协议的服务端启动起来了。
此时启动了什么服务都
记录一下这里的坑点,项目进行到这里已经需要多个服务了,顺序是:
启动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 acapp.asgi:application
写前端js
窗口和服务器是双向通信的,需要从窗口向服务器通过socket建立连接一个连接
建立连接后,向后端发送一个create player
的请求,需要实现两个函数,第一个函数实现向后端发送请求。当玩家进入地图需要告诉服务器通知其他玩家的终端。
第二个函数实现从后端接受这个请求。当其他玩家进入地图的时候,服务器向自己发来的请求。
playground/zbase.js
class AcGamePlayground {
...
show(mode) { //打开 playground 界面
...
if (mode === "single mode") {
...
} else if (mode === "multi mode") {
this.mps = new MultiPlayerSocket(this);
this.mps.ws.onopen = function() {
outer.mps.send_create_player();
};
}
}
}
playground/socket/multiplayer/zbase.js
class MultiPlayerSocket {
constructor(playground) {
this.playground = playground;
this.ws = new WebSocket("wss://app1117.acapp.acwing.com.cn/wss/multiplayer/");
this.start();
}
start() {
}
send_create_player() {
this.ws.send(JSON.stringify({
'message': 'hello acapp server',
}));
}
receive_create_player() {
}
}