Django学习笔记-实现联机对战(下)

  1. 1. 编写移动同步函数move_to
  2. 2. 编写攻击同步函数shoot_fireball
  3. 3. 编写击中判定同步函数attack
  4. 4. 优化改进(玩家提示板、技能CD)
  5. 5. 闪现技能

本节内容是通过 Django Channels 框架使用 WebSocket 协议实现多人模式中的同步移动,攻击以及被击中判定函数。
此外实现房间内玩家数提示板、技能冷却时间以及闪现技能。

1. 编写移动同步函数move_to

与上一章中的 create_player 同步函数相似,移动函数的同步也需要在前端实现 send_move_toreceive_move_to 函数。我们修改 MultiPlayerSocket 类(在目录 ~/djangoapp/game/static/js/src/playground/socket/multiplayer 中):

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
class MultiPlayerSocket {
constructor(playground) {
this.playground = playground;

// 直接将网站链接复制过来,将https改成wss,如果没有配置https那就改成ws,然后最后加上wss的路由
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); // 将字符串变回JSON
let uuid = data.uuid;
if (uuid === outer.uuid) return false; // 如果是给自己发送消息就直接过滤掉

let event = data.event;
if (event === 'create_player') { // create_player路由
outer.receive_create_player(uuid, data.username, data.avatar);
} else if (event === 'move_to') { // move_to路由
outer.receive_move_to(uuid, data.tx, data.ty);
}
};
}

send_create_player(username, avatar) {
...
}

receive_create_player(uuid, username, avatar) {
...
}

// 根据uuid找到对应的Player
get_player(uuid) {
let players = this.playground.players;
for (let i = 0; i < players.length; i++) {
let player = players[i];
if (player.uuid === uuid)
return player;
}
return null;
}

send_move_to(tx, ty) {
let outer = this;
this.ws.send(JSON.stringify({
'event': 'move_to',
'uuid': outer.uuid,
'tx': tx,
'ty': ty,
}));
}

receive_move_to(uuid, tx, ty) {
let player = this.get_player(uuid);
if (player) { // 确保玩家存在再调用move_to函数
player.move_to(tx, ty);
}
}
}

然后修改一下后端通信代码(~/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
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):
...

async def disconnect(self, close_code):
...


async def create_player(self, data): # async表示异步函数
...

async def group_send_event(self, data): # 组内的每个连接接收到消息后直接发给前端即可
await self.send(text_data=json.dumps(data))

async def move_to(self, data): # 与create_player函数相似
await self.channel_layer.group_send(
self.room_name,
{
'type': 'group_send_event',
'event': 'move_to',
'uuid': data['uuid'],
'tx': data['tx'],
'ty': data['ty'],
}
)


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)
elif event == 'move_to': # move_to的路由
await self.move_to(data)

最后我们还需要调用函数,首先我们需要在 AcGamePlayground 类中记录下游戏模式 mode

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class AcGamePlayground {
...

// 显示playground界面
show(mode) {
...

this.mode = mode; // 需要将模式记录下来,之后玩家在不同的模式中需要调用不同的函数

this.resize(); // 界面打开后需要resize一次,需要将game_map也resize

...
}

...
}

然后在 Player 类中进行修改,当为多人模式时,需要广播发送 move_to 信号:

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 Player extends AcGameObject {
...

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表示右键
let tx = (e.clientX - rect.left) / outer.playground.scale;
let ty = (e.clientY - rect.top) / outer.playground.scale;
outer.move_to(tx, ty); // e.clientX/Y为鼠标点击坐标

if (outer.playground.mode === 'multi mode') {
outer.playground.mps.send_move_to(tx, ty);
}
} else if (e.which === 1) {
...
}
});
...
}

...
}

现在即可实现多名玩家的同步移动。当 A 窗口中的玩家移动时,首先该窗口(Player 类)的监听函数会控制该玩家自身进行移动,接着判定为多人模式,因此再调用 MultiPlayerSocket 类中的 send_move_to 函数向服务器发送信息(通过 WebSocket 向服务器发送一个事件),接着服务器端(~/djangoapp/game/consumers/multiplayer/index.py 文件中)的 receive 函数会接收到信息,发现事件 eventmove_to,就会调用 move_to 函数,该函数会向这个房间中的其他所有玩家群发消息,每个窗口都会在前端(MultiPlayerSocket 类中)的 receive 函数接收到信息,通过事件路由到 receive_move_to 函数,该函数就会通过 uuid 调用每名玩家的 move_to 函数。

2. 编写攻击同步函数shoot_fireball

由于发射的火球是会消失的,因此需要先将每名玩家发射的火球存下来,此外我们实现一个根据火球的 uuid 删除火球的函数,在 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
class Player extends AcGameObject {
constructor(playground, x, y, radius, color, speed, character, username, avatar) {
...

this.fire_balls = []; // 存下玩家发射的火球

...
}

...

// 向(tx, ty)位置发射火球
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;
let fire_ball = new FireBall(this.playground, this, x, y, radius, vx, vy, color, speed, move_length, 0.01);
this.fire_balls.push(fire_ball);
return fire_ball; // 返回fire_ball是为了获取自己创建这个火球的uuid
}

destroy_fireball(uuid) { // 删除火球
for (let i = 0; i < this.fire_balls.length; i++) {
let fire_ball = this.fire_balls[i];
if (fire_ball.uuid === uuid) {
fire_ball.destroy();
break;
}
}
}

...
}

由于火球在 Player 中存了一份,因此我们在删除火球前需要将它从 Playerfire_balls 中删掉。且由于 FireBall 类中的 update 函数过于臃肿,可以先将其分成 update_move 以及 update_attack,我们修改 FireBall 类:

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
class FireBall extends AcGameObject {
// 火球需要标记是哪个玩家发射的,且射出后的速度方向与大小是固定的,射程为move_length
constructor(playground, player, x, y, radius, vx, vy, color, speed, move_length, damage) {
...
}

start() {
}

update_move() {
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_attack() { // 攻击碰撞检测
for (let i = 0; i < this.playground.players.length; i++) {
let player = this.playground.players[i];
if (player !== this.player && this.is_collision(player)) {
this.attack(player); // this攻击player
}
}
}

update() {
if (this.move_length < this.eps) {
this.destroy();
return false;
}

this.update_move();
this.update_attack();

this.render();
}

get_dist(x1, y1, x2, y2) {
...
}

is_collision(player) {
...
}

attack(player) {
...
}

render() {
...
}

on_destroy() {
let fire_balls = this.player.fire_balls;
for (let i = 0; i < fire_balls.length; i++) {
if (fire_balls[i] === this) {
fire_balls.splice(i, 1);
break;
}
}
}
}

然后我们在 MultiPlayerSocket 类中实现 send_shoot_fireballreceive_shoot_fireball 函数:

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 MultiPlayerSocket {
...

receive() {
let outer = this;

this.ws.onmessage = function(e) {
let data = JSON.parse(e.data); // 将字符串变回JSON
let uuid = data.uuid;
if (uuid === outer.uuid) return false; // 如果是给自己发送消息就直接过滤掉

let event = data.event;
if (event === 'create_player') { // create_player路由
outer.receive_create_player(uuid, data.username, data.avatar);
} else if (event === 'move_to') { // move_to路由
outer.receive_move_to(uuid, data.tx, data.ty);
} else if (event === 'shoot_fireball') { // shoot_fireball路由
outer.receive_shoot_fireball(uuid, data.tx, data.ty, data.fireball_uuid);
}
};
}

...

send_shoot_fireball(tx, ty, fireball_uuid) {
let outer = this;
this.ws.send(JSON.stringify({
'event': 'shoot_fireball',
'uuid': outer.uuid,
'tx': tx,
'ty': ty,
'fireball_uuid': fireball_uuid,
}));
}

receive_shoot_fireball(uuid, tx, ty, fireball_uuid) {
let player = this.get_player(uuid);
if (player) {
let fire_ball = player.shoot_fireball(tx, ty);
fire_ball.uuid = fireball_uuid; // 所有窗口同一个火球的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
26
27
28
29
30
31
32
33
import json
from channels.generic.websocket import AsyncWebsocketConsumer
from django.conf import settings
from django.core.cache import cache

class MultiPlayer(AsyncWebsocketConsumer):
...

async def shoot_fireball(self, data):
await self.channel_layer.group_send(
self.room_name,
{
'type': 'group_send_event',
'event': 'shoot_fireball',
'uuid': data['uuid'],
'tx': data['tx'],
'ty': data['ty'],
'fireball_uuid': data['fireball_uuid'],
}
)


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)
elif event == 'move_to': # move_to的路由
await self.move_to(data)
elif event == 'shoot_fireball': # shoot_fireball的路由
await self.shoot_fireball(data)

最后是在 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
class Player extends AcGameObject {
constructor(playground, x, y, radius, color, speed, character, username, avatar) {
...
}

start() {
...
}

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表示右键
...
} else if (e.which === 1) {
let tx = (e.clientX - rect.left) / outer.playground.scale;
let ty = (e.clientY - rect.top) / outer.playground.scale;
if (outer.cur_skill === 'fireball') {
let fire_ball = outer.shoot_fireball(tx, ty);

if (outer.playground.mode === 'multi mode') {
outer.playground.mps.send_shoot_fireball(tx, ty, fire_ball.uuid);
}
}

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) {
...
}

destroy_fireball(uuid) { // 删除火球
...
}

move_to(tx, ty) {
...
}

is_attacked(theta, damage) { // 被攻击到
...
}

// 更新移动
update_move() {
...
}

update() {
...
}

render() {
...
}

on_destroy() {
for (let i = 0; i < this.playground.players.length; i++) {
if (this.playground.players[i] === this) {
this.playground.players.splice(i, 1);
break;
}
}
}
}

3. 编写击中判定同步函数attack

我们需要统一攻击这个动作,由一个窗口来唯一判断是否击中,若击中则广播给其他窗口,因此窗口中看到其他玩家发射的火球仅为动画,不应该有击中判定。我们先在 FireBall 类中进行修改:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class FireBall extends AcGameObject {
...

update() {
if (this.move_length < this.eps) {
this.destroy();
return false;
}

this.update_move();

if (this.player.character !== 'enemy') { // 在敌人的窗口中不进行攻击检测
this.update_attack();
}

this.render();
}

...
}

每名玩家还需要有一个函数 receive_attack 表示接收到被攻击的信息:

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
class Player extends AcGameObject {
...

destroy_fireball(uuid) { // 删除火球
for (let i = 0; i < this.fire_balls.length; i++) {
let fire_ball = this.fire_balls[i];
if (fire_ball.uuid === uuid) {
fire_ball.destroy();
break;
}
}
}

...

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) { // 半径小于eps认为已死
this.destroy();
return false;
}
this.damage_vx = Math.cos(theta);
this.damage_vy = Math.sin(theta);
this.damage_speed = damage * 90;
}

receive_attack(x, y, theta, damage, fireball_uuid, attacker) { // 接收被攻击到的消息
attacker.destroy_fireball(fireball_uuid);
this.x = x;
this.y = y;
this.is_attacked(theta, damage);
}

...
}

我们假设发射火球的玩家为 attacker,被击中的玩家为 attackee,被击中者的位置也是由攻击者的窗口决定的,且火球在击中其他玩家后在其他玩家的窗口也应该消失,因此还需要传火球的 uuid。我们在 MultiPlayerSocket 类中实现 send_attackreceive_attack 函数:

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
class MultiPlayerSocket {
...

receive() {
let outer = this;

this.ws.onmessage = function(e) {
let data = JSON.parse(e.data); // 将字符串变回JSON
let uuid = data.uuid;
if (uuid === outer.uuid) return false; // 如果是给自己发送消息就直接过滤掉

let event = data.event;
if (event === 'create_player') { // create_player路由
outer.receive_create_player(uuid, data.username, data.avatar);
} else if (event === 'move_to') { // move_to路由
outer.receive_move_to(uuid, data.tx, data.ty);
} else if (event === 'shoot_fireball') { // shoot_fireball路由
outer.receive_shoot_fireball(uuid, data.tx, data.ty, data.fireball_uuid);
} else if (event === 'attack') { // attack路由
outer.receive_attack(uuid, data.attackee_uuid, data.x, data.y, data.theta, data.damage, data.fireball_uuid);
}
};
}

...

send_attack(attackee_uuid, x, y, theta, damage, fireball_uuid) {
let outer = this;
this.ws.send(JSON.stringify({
'event': 'attack',
'uuid': outer.uuid,
'attackee_uuid': attackee_uuid,
'x': x,
'y': y,
'theta': theta,
'damage': damage,
'fireball_uuid': fireball_uuid,
}));
}

receive_attack(uuid, attackee_uuid, x, y, theta, damage, fireball_uuid) {
let attacker = this.get_player(uuid);
let attackee = this.get_player(attackee_uuid);
if (attacker && attackee) { // 如果攻击者和被攻击者都还存在就判定攻击
attackee.receive_attack(x, y, theta, damage, fireball_uuid, attacker);
}
}
}

然后实现后端函数如下:

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
import json
from channels.generic.websocket import AsyncWebsocketConsumer
from django.conf import settings
from django.core.cache import cache

class MultiPlayer(AsyncWebsocketConsumer):
...

async def attack(self, data):
await self.channel_layer.group_send(
self.room_name,
{
'type': 'group_send_event',
'event': 'attack',
'uuid': data['uuid'],
'attackee_uuid': data['attackee_uuid'],
'x': data['x'],
'y': data['y'],
'theta': data['theta'],
'damage': data['damage'],
'fireball_uuid': data['fireball_uuid'],
}
)


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)
elif event == 'move_to': # move_to的路由
await self.move_to(data)
elif event == 'shoot_fireball': # shoot_fireball的路由
await self.shoot_fireball(data)
elif event == 'attack': # attack的路由
await self.attack(data)

最后需要在火球 FireBall 类中调用攻击判定的同步函数:

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
class FireBall extends AcGameObject {
// 火球需要标记是哪个玩家发射的,且射出后的速度方向与大小是固定的,射程为move_length
constructor(playground, player, x, y, radius, vx, vy, color, speed, move_length, damage) {
...
}

start() {
}

update_move() {
...
}

update_attack() { // 攻击碰撞检测
for (let i = 0; i < this.playground.players.length; i++) {
let player = this.playground.players[i];
if (player !== this.player && this.is_collision(player)) {
this.attack(player); // this攻击player
}
}
}

update() {
if (this.move_length < this.eps) {
this.destroy();
return false;
}

this.update_move();

if (this.player.character !== 'enemy') { // 在敌人的窗口中不进行攻击检测
this.update_attack();
}

this.render();
}

get_dist(x1, y1, x2, y2) {
...
}

is_collision(player) {
let distance = this.get_dist(this.x, this.y, player.x, player.y);
if (distance < this.radius + player.radius)
return true;
return false;
}

attack(player) {
let theta = Math.atan2(player.y - this.y, player.x - this.x);
player.is_attacked(theta, this.damage);

if (this.playground.mode === 'multi mode') {
this.playground.mps.send_attack(player.uuid, player.x, player.y, theta, this.damage, this.uuid);
}

this.destroy();
}

render() {
...
}

on_destroy() {
...
}
}

4. 优化改进(玩家提示板、技能CD)

我们限制在房间人数还没到3个时玩家不能移动,需要在 AcGamePlayground 类中添加一个状态机 state,一共有三种状态:waitingfightingover,且每个窗口的状态是独立的,提示板会在之后进行实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class AcGamePlayground {
...

// 显示playground界面
show(mode) {
...

this.mode = mode; // 需要将模式记录下来,之后玩家在不同的模式中需要调用不同的函数
this.state = 'waiting'; // waiting -> fighting -> over
this.notice_board = new NoticeBoard(this); // 提示板
this.player_count = 0; // 玩家人数

this.resize(); // 界面打开后需要resize一次,需要将game_map也resize

...
}

...
}

接下来我们实现一个提示板,显示当前房间有多少名玩家在等待,在 ~/djangoapp/game/static/js/src/playground 目录下新建 notice_board 目录,然后进入该目录创建 zbase.js 文件如下:

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 NoticeBoard extends AcGameObject {
constructor(playground) {
super();

this.playground = playground;
this.ctx = this.playground.game_map.ctx;
this.text = '已就绪: 0人';
}

start() {
}

write(text) { // 更新this.text的信息
this.text = text;
}

update() {
this.render();
}

render() { // Canvas渲染文本
this.ctx.font = '20px serif';
this.ctx.fillStyle = 'white';
this.ctx.textAlign = 'center';
this.ctx.fillText(this.text, this.playground.width / 2, 20);
}
}

每次有玩家创建时就将 player_count 的数量加一,当玩家数量大于等于3时将游戏状态转换成 Fighting,且设置除了在 Fighting 状态下点击鼠标或按下按键才有效果,否则无效。在 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
class Player extends AcGameObject {
...

start() {
this.playground.player_count++;
this.playground.notice_board.write('已就绪: ' + this.playground.player_count + '人');

if (this.playground.player_count >= 3) {
this.playground.state = 'fighting';
this.playground.notice_board.write('Fighting');
}

...
}

add_listening_events() {
let outer = this;
this.playground.game_map.$canvas.on('contextmenu', function() {
return false;
}); // 取消右键的菜单功能
this.playground.game_map.$canvas.mousedown(function(e) {
if (outer.playground.state !== 'fighting')
return false; // 点击事件不往后传

...
}
});
$(window).keydown(function(e) {
if (outer.playground.state !== 'fighting')
return true;

...
});
}

...
}

现在对局一开始就能攻击,显然不太合适,因此还需要设定在游戏刚开始的前若干秒无法攻击,即技能冷却。每个窗口只有自己才有技能冷却,也就是只能看到自己的冷却时间。现在我们给火球技能设置一秒的冷却时间,在 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
class Player extends AcGameObject {
constructor(playground, x, y, radius, color, speed, character, username, avatar) {
...

if (this.character === 'me') { // 如果是自己的话则加上技能CD
this.fireball_coldtime = 1; // 单位: s
}
}

...

add_listening_events() {
let outer = this;
this.playground.game_map.$canvas.on('contextmenu', function() {
return false;
}); // 取消右键的菜单功能
this.playground.game_map.$canvas.mousedown(function(e) {
if (outer.playground.state !== 'fighting')
return false; // 点击事件不往后传

const rect = outer.ctx.canvas.getBoundingClientRect();
if (e.which === 3) { // 1表示左键,2表示滚轮,3表示右键
let tx = (e.clientX - rect.left) / outer.playground.scale;
let ty = (e.clientY - rect.top) / outer.playground.scale;
outer.move_to(tx, ty); // e.clientX/Y为鼠标点击坐标

if (outer.playground.mode === 'multi mode') {
outer.playground.mps.send_move_to(tx, ty);
}
} else if (e.which === 1) {
let tx = (e.clientX - rect.left) / outer.playground.scale;
let ty = (e.clientY - rect.top) / outer.playground.scale;
if (outer.cur_skill === 'fireball') {
let fire_ball = outer.shoot_fireball(tx, ty);

if (outer.playground.mode === 'multi mode') {
outer.playground.mps.send_shoot_fireball(tx, ty, fire_ball.uuid);
}

outer.fireball_coldtime = 1; // 用完技能后重置冷却时间
}

outer.cur_skill = null; // 释放完一次技能后还原
}
});
$(window).keydown(function(e) {
if (outer.playground.state !== 'fighting')
return true;

if (e.which === 81 && outer.fireball_coldtime < outer.eps) { // Q键
outer.cur_skill = 'fireball';
return false;
}
});
}

...

update_coldtime() { // 更新技能冷却时间
this.fireball_coldtime -= this.timedelta / 1000;
this.fireball_coldtime = Math.max(this.fireball_coldtime, 0); // 防止变为负数
}

update() {
this.spent_time += this.timedelta / 1000; // 将这行代码从update_move函数移动到update函数中
this.update_move();
if (this.character === 'me' && this.playground.state === 'fighting') { // 只有自己且开始战斗后才更新冷却时间
this.update_coldtime();
}
this.render();
}

...
}

我们还不知道技能什么时候冷却好,因此还需要加上一个技能图标与 CD 提示,可以模仿其他 MOBA 类游戏,在技能图标上添加一层 CD 涂层即可。假设我们的技能图标资源存放在 ~/djangoapp/game/static/image/playground 目录下,那么我们在 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
class Player extends AcGameObject {
constructor(playground, x, y, radius, color, speed, character, username, avatar) {
...

if (this.character === 'me') { // 如果是自己的话则加上技能CD
this.fireball_coldtime = 1; // 单位: s
this.fireball_img = new Image();
this.fireball_img.src = 'https://app4007.acapp.acwing.com.cn/static/image/playground/fireball.png'; // 技能图标资源链接
}
}

...

render() {
let scale = this.playground.scale; // 要将相对值恢复成绝对值
if (this.character !== 'robot') {
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();
}

if (this.character === 'me' && this.playground.state === 'fighting') {
this.render_skill_coldtime();
}
}

render_skill_coldtime() { // 渲染技能图标与冷却时间
let x = 1.5, y = 0.95, r = 0.03;
let scale = this.playground.scale;

this.ctx.save();
this.ctx.beginPath();
this.ctx.arc(x * scale, y * scale, r * scale, 0, Math.PI * 2, false);
this.ctx.stroke();
this.ctx.clip();
this.ctx.drawImage(this.fireball_img, (x - r) * scale, (y - r) * scale, r * 2 * scale, r * 2 * scale);
this.ctx.restore();

if (this.fireball_coldtime > 0) { // 技能还在冷却中则绘制冷却蒙版
this.ctx.beginPath();
// 角度由冷却时间决定
let fireball_coldtime_ratio = this.fireball_coldtime / 1; // 剩余冷却时间占总冷却时间的比例
this.ctx.moveTo(x * scale, y * scale); // 设置圆心从(x, y)开始画
// 减去PI/2的目的是为了从PI/2处开始转圈,而不是从0度开始
// 最后的参数为false为取逆时针方向,反之为顺时针,但为true后相当于绘制的是冷却时间对立的另一段,因此需要调换一下冷却时间
this.ctx.arc(x * scale, y * scale, r * scale, 0 - Math.PI / 2, Math.PI * 2 * (1 - fireball_coldtime_ratio) - Math.PI / 2, true);
this.ctx.lineTo(x * scale, y * scale); // 画完之后向圆心画一条线
this.ctx.fillStyle = 'rgba(0, 0, 255, 0.6)';
this.ctx.fill();
}
}

on_destroy() {
if (this.character === 'me')
this.playground.state = 'over'; // 玩家寄了之后更新状态为over

for (let i = 0; i < this.playground.players.length; i++) {
if (this.playground.players[i] === this) {
this.playground.players.splice(i, 1);
break;
}
}
}
}

5. 闪现技能

闪现技能的实现很简单,整体参考之前的火球技能即可,我们先实现单机模式下的闪现技能,在 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
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
class Player extends AcGameObject {
constructor(playground, x, y, radius, color, speed, character, username, avatar) {
...

if (this.character === 'me') { // 如果是自己的话则加上技能CD
this.fireball_coldtime = 1; // 单位: s
this.fireball_img = new Image();
this.fireball_img.src = 'https://app4007.acapp.acwing.com.cn/static/image/playground/fireball.png'; // 技能图标资源链接

this.blink_coldtime = 10; // 闪现技能冷却时间
this.blink_img = new Image();
this.blink_img.src = 'https://app4007.acapp.acwing.com.cn/static/image/playground/blink.png';
}
}

...

add_listening_events() {
let outer = this;
this.playground.game_map.$canvas.on('contextmenu', function() {
return false;
}); // 取消右键的菜单功能
this.playground.game_map.$canvas.mousedown(function(e) {
if (outer.playground.state !== 'fighting')
return false; // 点击事件不往后传

const rect = outer.ctx.canvas.getBoundingClientRect();
if (e.which === 3) { // 1表示左键,2表示滚轮,3表示右键
let tx = (e.clientX - rect.left) / outer.playground.scale;
let ty = (e.clientY - rect.top) / outer.playground.scale;
outer.move_to(tx, ty); // e.clientX/Y为鼠标点击坐标

if (outer.playground.mode === 'multi mode') {
outer.playground.mps.send_move_to(tx, ty);
}
} else if (e.which === 1) {
let tx = (e.clientX - rect.left) / outer.playground.scale;
let ty = (e.clientY - rect.top) / outer.playground.scale;
if (outer.cur_skill === 'fireball') {
let fire_ball = outer.shoot_fireball(tx, ty);

if (outer.playground.mode === 'multi mode') {
outer.playground.mps.send_shoot_fireball(tx, ty, fire_ball.uuid);
}

outer.fireball_coldtime = 1; // 用完技能后重置冷却时间
} else if (outer.cur_skill === 'blink') {
outer.blink(tx, ty);
outer.blink_coldtime = 10;
}

outer.cur_skill = null; // 释放完一次技能后还原
}
});
$(window).keydown(function(e) {
if (outer.playground.state !== 'fighting')
return true;

if (e.which === 81 && outer.fireball_coldtime < outer.eps) { // Q键
outer.cur_skill = 'fireball';
return false;
} else if (e.which === 70 && outer.blink_coldtime < outer.eps) { // F键
outer.cur_skill = 'blink';
return false;
}
});
}

// 计算两点之间的欧几里得距离
get_dist(x1, y1, x2, y2) {
return Math.sqrt((x1 - x2) * (x1 - x2) + (y1 - y2) * (y1 - y2));
}

...

blink(tx, ty) { // 闪现到(tx, ty)
let x = this.x, y = this.y;
let dist = this.get_dist(x, y, tx, ty);
dist = Math.min(dist, 0.3); // 最大闪现距离为0.3
let theta = Math.atan2(ty - y, tx - x);
this.x += dist * Math.cos(theta);
this.y += dist * Math.sin(theta);

this.move_length = 0; // 闪现完之后应该停下来而不是继续移动
}

...

update_coldtime() { // 更新技能冷却时间
this.fireball_coldtime -= this.timedelta / 1000;
this.fireball_coldtime = Math.max(this.fireball_coldtime, 0); // 防止变为负数

this.blink_coldtime -= this.timedelta / 1000;
this.blink_coldtime = Math.max(this.blink_coldtime, 0);
}

update() {
this.spent_time += this.timedelta / 1000; // 将这行代码从update_move函数移动到update函数中
this.update_move();
if (this.character === 'me' && this.playground.state === 'fighting') { // 只有自己且开始战斗后才更新冷却时间
this.update_coldtime();
}
this.render();
}

render() {
let scale = this.playground.scale; // 要将相对值恢复成绝对值
if (this.character !== 'robot') {
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();
}

if (this.character === 'me' && this.playground.state === 'fighting') {
this.render_fireball_coldtime();
this.render_blink_coldtime();
}
}

render_fireball_coldtime() { // 渲染火球技能图标与冷却时间
let x = 1.5, y = 0.95, r = 0.03;
let scale = this.playground.scale;

this.ctx.save();
this.ctx.beginPath();
this.ctx.arc(x * scale, y * scale, r * scale, 0, Math.PI * 2, false);
this.ctx.stroke();
this.ctx.clip();
this.ctx.drawImage(this.fireball_img, (x - r) * scale, (y - r) * scale, r * 2 * scale, r * 2 * scale);
this.ctx.restore();

if (this.fireball_coldtime > 0) { // 技能还在冷却中则绘制冷却蒙版
this.ctx.beginPath();
// 角度由冷却时间决定
let coldtime_ratio = this.fireball_coldtime / 1; // 剩余冷却时间占总冷却时间的比例
this.ctx.moveTo(x * scale, y * scale); // 设置圆心从(x, y)开始画
// 减去PI/2的目的是为了从PI/2处开始转圈,而不是从0度开始
// 最后的参数为false为取逆时针方向,反之为顺时针,但为true后相当于绘制的是冷却时间对立的另一段,因此需要调换一下冷却时间
this.ctx.arc(x * scale, y * scale, r * scale, 0 - Math.PI / 2, Math.PI * 2 * (1 - coldtime_ratio) - Math.PI / 2, true);
this.ctx.lineTo(x * scale, y * scale); // 画完之后向圆心画一条线
this.ctx.fillStyle = 'rgba(0, 0, 255, 0.6)';
this.ctx.fill();
}
}

render_blink_coldtime() { // 渲染闪现技能图标与冷却时间
let x = 1.6, y = 0.95, r = 0.03;
let scale = this.playground.scale;

this.ctx.save();
this.ctx.beginPath();
this.ctx.arc(x * scale, y * scale, r * scale, 0, Math.PI * 2, false);
this.ctx.stroke();
this.ctx.clip();
this.ctx.drawImage(this.blink_img, (x - r) * scale, (y - r) * scale, r * 2 * scale, r * 2 * scale);
this.ctx.restore();

if (this.blink_coldtime > 0) {
this.ctx.beginPath();
let coldtime_ratio = this.blink_coldtime / 10;
this.ctx.moveTo(x * scale, y * scale); // 设置圆心从(x, y)开始画
this.ctx.arc(x * scale, y * scale, r * scale, 0 - Math.PI / 2, Math.PI * 2 * (1 - coldtime_ratio) - Math.PI / 2, true);
this.ctx.lineTo(x * scale, y * scale); // 画完之后向圆心画一条线
this.ctx.fillStyle = 'rgba(0, 0, 255, 0.6)';
this.ctx.fill();
}
}

on_destroy() {
if (this.character === 'me')
this.playground.state = 'over'; // 玩家寄了之后更新状态为over

for (let i = 0; i < this.playground.players.length; i++) {
if (this.playground.players[i] === this) {
this.playground.players.splice(i, 1);
break;
}
}
}
}

然后我们还需要将闪现技能在多人模式中进行同步,原理和移动的同步是一样的,先在 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
class MultiPlayerSocket {
...

receive() {
let outer = this;

this.ws.onmessage = function(e) {
let data = JSON.parse(e.data); // 将字符串变回JSON
let uuid = data.uuid;
if (uuid === outer.uuid) return false; // 如果是给自己发送消息就直接过滤掉

let event = data.event;
if (event === 'create_player') { // create_player路由
outer.receive_create_player(uuid, data.username, data.avatar);
} else if (event === 'move_to') { // move_to路由
outer.receive_move_to(uuid, data.tx, data.ty);
} else if (event === 'shoot_fireball') { // shoot_fireball路由
outer.receive_shoot_fireball(uuid, data.tx, data.ty, data.fireball_uuid);
} else if (event === 'attack') { // attack路由
outer.receive_attack(uuid, data.attackee_uuid, data.x, data.y, data.theta, data.damage, data.fireball_uuid);
} else if (event === 'blink') { // blink路由
outer.receive_blink(uuid, data.tx, data.ty);
}
};
}

...

send_blink(tx, ty) {
let outer = this;
this.ws.send(JSON.stringify({
'event': 'blink',
'uuid': outer.uuid,
'tx': tx,
'ty': ty,
}));
}

receive_blink(uuid, tx, ty) {
let player = this.get_player(uuid);
if (player) {
player.blink(tx, ty);
}
}
}

然后实现一下后端,在 ~/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
import json
from channels.generic.websocket import AsyncWebsocketConsumer
from django.conf import settings
from django.core.cache import cache

class MultiPlayer(AsyncWebsocketConsumer):
...

async def blink(self, data):
await self.channel_layer.group_send(
self.room_name,
{
'type': 'group_send_event',
'event': 'blink',
'uuid': data['uuid'],
'tx': data['tx'],
'ty': data['ty'],
}
)


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)
elif event == 'move_to': # move_to的路由
await self.move_to(data)
elif event == 'shoot_fireball': # shoot_fireball的路由
await self.shoot_fireball(data)
elif event == 'attack': # attack的路由
await self.attack(data)
elif event == 'blink': # blink的路由
await self.blink(data)

最后在 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
class Player extends AcGameObject {
...

add_listening_events() {
let outer = this;
this.playground.game_map.$canvas.on('contextmenu', function() {
return false;
}); // 取消右键的菜单功能
this.playground.game_map.$canvas.mousedown(function(e) {
if (outer.playground.state !== 'fighting')
return false; // 点击事件不往后传

const rect = outer.ctx.canvas.getBoundingClientRect();
if (e.which === 3) { // 1表示左键,2表示滚轮,3表示右键
let tx = (e.clientX - rect.left) / outer.playground.scale;
let ty = (e.clientY - rect.top) / outer.playground.scale;
outer.move_to(tx, ty); // e.clientX/Y为鼠标点击坐标

if (outer.playground.mode === 'multi mode') {
outer.playground.mps.send_move_to(tx, ty);
}
} else if (e.which === 1) {
let tx = (e.clientX - rect.left) / outer.playground.scale;
let ty = (e.clientY - rect.top) / outer.playground.scale;
if (outer.cur_skill === 'fireball') {
let fire_ball = outer.shoot_fireball(tx, ty);

if (outer.playground.mode === 'multi mode') {
outer.playground.mps.send_shoot_fireball(tx, ty, fire_ball.uuid);
}

outer.fireball_coldtime = 1; // 用完技能后重置冷却时间
} else if (outer.cur_skill === 'blink') {
outer.blink(tx, ty);

if (outer.playground.mode === 'multi mode') {
outer.playground.mps.send_blink(tx, ty);
}

outer.blink_coldtime = 10;
}

outer.cur_skill = null; // 释放完一次技能后还原
}
});
$(window).keydown(function(e) {
if (outer.playground.state !== 'fighting')
return true;

if (e.which === 81 && outer.fireball_coldtime < outer.eps) { // Q键
outer.cur_skill = 'fireball';
return false;
} else if (e.which === 70 && outer.blink_coldtime < outer.eps) { // F键
outer.cur_skill = 'blink';
return false;
}
});
}

...
}

上一章:Django学习笔记-实现联机对战(上)

下一章:Django学习笔记-实现聊天系统