笔记内容转载自 AcWing 的 Django 框架课讲义,课程链接:AcWing Django 框架课。
多人模式中每个玩家所看到的地图相对来说应该是一样的,因此需要固定地图的长宽比,一般固定为16:9。我们需要在游戏窗口的长宽中取最小值,然后将地图渲染为16:9的大小。
我们在 AcGamePlayground
类中实现一个 resize
函数用于将长宽比调整为16:9并且达到最大:
class AcGamePlayground {
constructor(root) {
this.root = root;
this.$playground = $(`
`);
this.root.$ac_game.append(this.$playground);
this.start();
}
get_random_color() {
...
}
start() {
this.hide(); // 初始化时需要先关闭playground界面
let outer = this;
$(window).resize(function() {
outer.resize();
}); // 用户改变窗口大小时改函数会触发
}
// 将长宽比调整为16:9
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(); // 如果地图存在需要调用地图的resize函数
}
// 显示playground界面
show() {
this.$playground.show();
// 将界面的宽高先存下来
this.width = this.$playground.width();
this.height = this.$playground.height();
this.game_map = new GameMap(this); // 创建游戏画面
this.resize(); // 界面打开后需要resize一次,需要将game_map也resize
...
}
// 关闭playground界面
hide() {
this.$playground.hide();
}
}
现在需要将窗口大小的修改效果作用到黑色背景上,因此我们在 GameMap
类中也实现一个 resize
函数用于修改背景大小:
class GameMap extends AcGameObject {
constructor(playground) { // 需要将AcGamePlayground传进来
super(); // 调用基类构造函数,相当于将自己添加到了AC_GAME_OBJECTS中
this.playground = playground;
this.$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); // 将画布添加到HTML中
}
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)'; // 黑色背景
// 左上角坐标(0, 0),右下角坐标(w, h)
this.ctx.fillRect(0, 0, this.ctx.canvas.width, this.ctx.canvas.height);
}
}
我们修改一下 game.css
文件,添加以下内容,实现将地图居中:
.ac_game_playground > canvas {
position: relative;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
现在我们还需要修改地图里面的目标,一共有三种分别是玩家、火球、被击中的粒子效果。
首先修改一下 AcGamePlayground
类中的玩家初始化代码:
class AcGamePlayground {
constructor(root) {
...
}
get_random_color() {
...
}
start() {
...
}
// 将长宽比调整为16:9
resize() {
...
}
// 显示playground界面
show() {
this.$playground.show();
// 将界面的宽高先存下来
this.width = this.$playground.width();
this.height = this.$playground.height();
this.game_map = new GameMap(this); // 创建游戏画面
this.resize(); // 界面打开后需要resize一次,需要将game_map也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));
}
}
// 关闭playground界面
hide() {
this.$playground.hide();
}
}
然后我们修改 Player
类,将所有绝对变量替换为相对变量:
class Player extends AcGameObject {
constructor(playground, x, y, radius, color, speed, is_me) {
...
this.eps = 0.01; // 误差小于0.01认为是0
...
}
start() {
if (this.is_me) {
this.add_listening_events();
} else {
// Math.random()返回一个0~1之间的数,随机初始化AI的位置
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) { // 1表示左键,2表示滚轮,3表示右键
outer.move_to((e.clientX - rect.left) / outer.playground.scale, (e.clientY - rect.top) / outer.playground.scale); // e.clientX/Y为鼠标点 击坐标
} 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) { // Q键
outer.cur_skill = 'fireball';
return false;
}
});
}
// 计算两点之间的欧几里得距离
get_dist(x1, y1, x2, y2) {
...
}
// 向(tx, ty)位置发射火球
shoot_fireball(tx, ty) {
...
}
move_to(tx, ty) {
...
}
is_attacked(theta, damage) { // 被攻击到
...
}
// 更新移动
update_move() {
this.spent_time += this.timedelta / 1000;
// AI敌人随机向玩家射击,游戏刚开始前三秒AI不能射击
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) { // AI敌人不能停下来
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 {
// 计算真实移动距离,与一帧的移动距离取min防止移出界
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 { // AI
this.ctx.beginPath();
// 角度从0画到2PI,是否逆时针为false
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
函数即可:
class FireBall extends AcGameObject {
// 火球需要标记是哪个玩家发射的,且射出后的速度方向与大小是固定的,射程为move_length
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
函数即可:
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();
}
}
我们先修改 AcGameMenu
类,实现多人模式按钮的逻辑:
class AcGameMenu {
constructor(root) { // root用来传AcGame对象
...
}
start() {
this.hide();
this.add_listening_events();
}
// 给按钮绑定监听函数
add_listening_events() {
let outer = this;
// 注意在function中调用this指的是function本身,因此需要先将外面的this存起来
this.$single.click(function() {
outer.hide(); // 关闭menu界面
outer.root.playground.show('single mode'); // 显示playground界面,加入参数用于区分
});
this.$multi.click(function() {
outer.hide();
outer.root.playground.show('multi mode'); // 多人模式
});
this.$settings.click(function() {
outer.root.settings.logout_on_remote();
});
}
// 显示menu界面
show() {
this.$menu.show();
}
// 关闭menu界面
hide() {
this.$menu.hide();
}
}
然后修改 AcGamePlayground
类,区分两种模式,且需要进一步区分玩家类别,之前使用 True/False 表示是否是玩家本人,现在可以用字符串区分玩家本人、其他玩家以及人机:
class AcGamePlayground {
constructor(root) {
...
}
get_random_color() {
...
}
start() {
...
}
// 将长宽比调整为16:9
resize() {
...
}
// 显示playground界面
show(mode) {
this.$playground.show();
// 将界面的宽高先存下来
this.width = this.$playground.width();
this.height = this.$playground.height();
this.game_map = new GameMap(this); // 创建游戏画面
this.resize(); // 界面打开后需要resize一次,需要将game_map也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)); // 创建自己,自己的用户名和头像从settings中获得
// 单人模式下创建AI敌人
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') {
}
}
// 关闭playground界面
hide() {
this.$playground.hide();
}
}
然后还需要修改一下 Player
类,将原本的 this.is_me
判断进行修改:
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') { // 只有AI不用渲染图片
this.img = new Image();
this.img.src = this.avatar;
}
}
start() {
if (this.character === 'me') { // 只给自己添加监听函数
this.add_listening_events();
} else {
...
}
}
add_listening_events() {
...
}
// 计算两点之间的欧几里得距离
get_dist(x1, y1, x2, y2) {
...
}
// 向(tx, ty)位置发射火球
shoot_fireball(tx, ty) {
...
}
move_to(tx, ty) {
...
}
is_attacked(theta, damage) { // 被攻击到
...
}
// 更新移动
update_move() {
this.spent_time += this.timedelta / 1000;
// AI敌人随机向玩家射击,游戏刚开始前三秒AI不能射击
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') { // AI敌人不能停下来
...
}
} else {
// 计算真实移动距离,与一帧的移动距离取min防止移出界
...
}
}
}
update() {
this.update_move();
this.render();
}
render() {
let scale = this.playground.scale; // 要将相对值恢复成绝对值
if (this.character !== 'robot') {
...
} else { // AI
...
}
}
}
假设有三名玩家编号为1、2、3进行多人游戏,那么每个玩家都有自己的一个窗口,且窗口中都能看到三名玩家。如果当前玩家1、2在进行游戏,3加入了游戏,那么需要告诉1、2两名玩家3来了,且还要告诉3当前已经有玩家1、2了。
要实现这一点,可以通过一个中心服务器(可以就是自己租的云服务器),即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 协议的一种实现方式。