笔记内容转载自 AcWing 的 Django 框架课讲义,课程链接:AcWing Django 框架课。
与上一章中的 create_player 同步函数相似,移动函数的同步也需要在前端实现 send_move_to 和 receive_move_to 函数。我们修改 MultiPlayerSocket 类(在目录 ~/djangoapp/game/static/js/src/playground/socket/multiplayer 中):
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 文件):
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:
class AcGamePlayground {
...
// 显示playground界面
show(mode) {
...
this.mode = mode; // 需要将模式记录下来,之后玩家在不同的模式中需要调用不同的函数
this.resize(); // 界面打开后需要resize一次,需要将game_map也resize
...
}
...
}
然后在 Player 类中进行修改,当为多人模式时,需要广播发送 move_to 信号:
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 函数会接收到信息,发现事件 event 为 move_to,就会调用 move_to 函数,该函数会向这个房间中的其他所有玩家群发消息,每个窗口都会在前端(MultiPlayerSocket 类中)的 receive 函数接收到信息,通过事件路由到 receive_move_to 函数,该函数就会通过 uuid 调用每名玩家的 move_to 函数。
由于发射的火球是会消失的,因此需要先将每名玩家发射的火球存下来,此外我们实现一个根据火球的 uuid 删除火球的函数,在 Player 类中进行修改:
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 中存了一份,因此我们在删除火球前需要将它从 Player 的 fire_balls 中删掉。且由于 FireBall 类中的 update 函数过于臃肿,可以先将其分成 update_move 以及 update_attack,我们修改 FireBall 类:
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_fireball 和 receive_shoot_fireball 函数:
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需要统一
}
}
}
现在我们需要实现后端函数:
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 类中调用函数:
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;
}
}
}
}
我们需要统一攻击这个动作,由一个窗口来唯一判断是否击中,若击中则广播给其他窗口,因此窗口中看到其他玩家发射的火球仅为动画,不应该有击中判定。我们先在 FireBall 类中进行修改:
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 表示接收到被攻击的信息:
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_attack 与 receive_attack 函数:
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);
}
}
}
然后实现后端函数如下:
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 类中调用攻击判定的同步函数:
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() {
...
}
}
我们限制在房间人数还没到3个时玩家不能移动,需要在 AcGamePlayground 类中添加一个状态机 state,一共有三种状态:waiting、fighting、over,且每个窗口的状态是独立的,提示板会在之后进行实现:
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 文件如下:
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 类中进行修改:
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 类中进行修改:
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 类中渲染技能图标:
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;
}
}
}
}
闪现技能的实现很简单,整体参考之前的火球技能即可,我们先实现单机模式下的闪现技能,在 Player 类中实现:
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 类中实现前端函数:
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 文件中实现:
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 类中调用一下广播闪现技能的函数即可:
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;
}
});
}
...
}