Django上课笔记(三)——简单游戏的实现(模块拆分化详解)
更新(新特性)
- 可以通过配置文件(一个对象)改动参数
- 增加了火球抵消的功能
- 修复了死后还能攻击的bug
- 加入了击中人就回血的特点
- 增加了物理特性:血越多体积范围越大,移动速度越慢,攻击范围越大;血越少移速越快,攻击范围越小,体积越小。
- 加入快捷施法,直接按q就可以放火球
- 设定了地图边界,小球不能被打出画布外
新特性的实现方法放在文章末尾
项目地址
https://git.acwing.com/codeRokie/acapp
上完这节课。我只想说一句话[HTML_REMOVED]y总永远滴神!![HTML_REMOVED]
这节课真的好难鸭/(ㄒoㄒ)/~~
为了从y总浩瀚的知识中吸取那么一点点,我会逐步拆分这次的代码,let’s go!!
也欢迎大家光临我另外项目课的其他博客:
Django上课笔记(一)——环境配置与项目创建(过程十分详细) - AcWing
(更新版)Django上课笔记(二)——菜单模块的实现, 含自动创建项目的脚本
Django上课笔记(三)——简单游戏的实现(模块拆分化详解) - AcWing
上次课的补充
改动~/acapp/game/templates/multiends
下的web.html
{% load static %}
<head>
<link rel="stylesheet" href="https://cdn.acwing.com/static/jquery-ui-dist/jquery-ui.min.css">
<script src="https://cdn.acwing.com/static/jquery/js/jquery-3.3.1.min.js"></script>
<link rel="stylesheet" href="{% static 'css/game.css' %}">
</head>
<body style="margin: 0">
<div id="ac_game_12345678"></div>
<script type="module">
//-----------------------------------------------------------------------------------
import {AcGame} from "{% static 'js/dist/game.js' %}";
//--------------------------------------------------------------------------------------
$(document).ready(function(){
let ac_game = new AcGame("ac_game_12345678");
});
</script>
</body>
并在~/acapp/game/static/js/src
下的zbase.js
的开头加上export
准备工作
为了调试方便(刷新后直接看到首页),将菜单界面关闭,只显示游戏界面
注释掉~/acapp/game/static/js/src
下的zbase.js
中的this.menu = new AcGameMenu(this);
游戏的模块功能划分
想要把整个代码拆分成模块,需要关注以下几个问题
https://mm.edrawsoft.cn/map.html?sharecode=6184c3120616a8a41170828
下面来逐一讨论以下这些问题
游戏动画的实现
思想
模仿电影的原理,每秒让电脑“画”60张图,就实现了一个60帧的动画
语法知识补充
1.这里start()
,update()
,on_destroy()
应该是参考了各大框架中生命周期
的设计思想,可以参考Vue 实例 — Vue.js (vuejs.org)
2.数组的splice()
函数,参考JavaScript splice() 方法
3.requestAnimationFrame()
函数的优势,参考requestAnimationFrame详解
实现
一个渲染的基类
//存放所有对象(物体)的数组
let AC_GAME_OBJECTS = [];
class AcGameObject {
constructor() {
//每创建一个对象都把它加进数组里
AC_GAME_OBJECTS.push(this);
this.has_called_start = false; // 是否执行过start函数
this.timedelta = 0; // 当前帧距离上一帧的时间间隔
}
start() { // 只会在第一帧执行一次
}
update() { // 每一帧均会执行一次
}
on_destroy() { // 在被销毁前执行一次
}
destroy() { // 删掉该物体
this.on_destroy();
//遍历一遍所有对象,找到当前对象并删除
for (let i = 0; i < AC_GAME_OBJECTS.length; i ++ ) {
if (AC_GAME_OBJECTS[i] === this) {
AC_GAME_OBJECTS.splice(i, 1);
break;
}
}
}
}
let last_timestamp;
//用递归的结构,保证每一帧都调用一次函数,即一直无限渲染
let AC_GAME_ANIMATION = function(timestamp) {
//每一帧要遍历所有物体,让每个物体执行update函数
for (let i = 0; i < AC_GAME_OBJECTS.length; i ++ ) {
let obj = AC_GAME_OBJECTS[i];
//用has_called_start标记每个物体,保证每一帧,每个物体只执行一次函数
if (!obj.has_called_start) {
obj.start();
obj.has_called_start = true;
} else {
//算出2次调用的间隔时间,为计算速度做准备
obj.timedelta = timestamp - last_timestamp;
obj.update();
}
}
last_timestamp = timestamp;
requestAnimationFrame(AC_GAME_ANIMATION);
}
requestAnimationFrame(AC_GAME_ANIMATION);
destroy()
函数: 在AC_GAME_OBJECTS = []
删除该对象。
on_destroy()
:在其他数组中删除该对象。
destroy()
函数的开头调用了on_destroy()
函数。所以要在其他数组中删除删除对象,只需要实现on_destroy()
函数
游戏地图的实现
语法补充:
1.canvas标签:HTML5 Canvas | 菜鸟教程 (runoob.com)
HTML 画布 | 菜鸟教程 (runoob.com)
用canvas实现,继承渲染的基类
实现
game/static/js/src/playground/game_map/zbase.js
class AcGamePlayground {
constructor(root) {
this.root = root;
this.$playground = $(`<div class="ac-game-playground"></div>`);
// this.hide();
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.players = [];
this.fireballs = [];
this.players.push(new Player(this, this.width / 2, this.height / 2, this.height * gameParameters.players_size_percent, "white", this.height * gameParameters.player_speed_percent, true));
//创建5个电脑玩家
for (let i = 0; i < gameParameters.AIs_number; i++) {
this.players.push(new Player(this, this.width / 2, this.height / 2, this.height * gameParameters.players_size_percent, this.get_random_color(), this.height * gameParameters.player_speed_percent, false));
}
this.start();
}
start() {
}
show() { // 打开playground界面
this.$playground.show();
}
hide() { // 关闭playground界面
this.$playground.hide();
}
get_random_color(){
return gameParameters.color_select[Math.floor((Math.random()*gameParameters.color_select.length))];
}
/**
* 某玩家死亡调用的函数
* @param player Player 对象
*/
player_is_killed(player){
for (let i = 0; i < this.players.length; i++) {
if (this.player[i]===player){
player.destroy();
this.players.splice(i,1);
break;
}
}
}
}
移动残影的实现
改变背景的不透明度
游戏角色的实现
思想
建立玩家对象时,最重要的就是考虑角色有哪些属性
语法知识补充
实现
class Player extends AcGameObject{
/**
*
* @param playground 该玩家在哪个地图上
* @param x 玩家的位置坐标,将来还可能有3d的z轴和朝向坐标
* @param y
* @param radius 圆的半径,每个玩家用圆表示
* @param color 圆的颜色
* @param speed 玩家的移动速度,用每秒移动高度的百分比表示,因为每个浏览器的像素表示不一样
* @param is_me 判断当前角色是自己还是敌人
*/
constructor(playground,x,y,radius,color,speed,is_me) {
super();
this.playground = playground;
this.x = x;
this.y = y;
this.color = color;
this.speed = speed;
this.is_me = is_me;
//玩家所处地图的画布
this.ctx = this.playground.game_map.ctx;
this.vx = 0;
this.vy = 0;
this.damage_x = 0;
this.damage_y = 0;
this.damage_speed = 0;
this.move_length = 0;
this.radius = radius;
//表示精度,误差在eps内就算0
this.eps = 0.1;
this.friction = 0.9;
this.spent_time = 0;
}
start(){
}
update(){
//每一帧都要渲染圆
this.render();
}
render(){
//渲染一个圆
this.ctx.beginPath();
this.ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2, false);
this.ctx.fillStyle = this.color;
this.ctx.fill();
//--------------------------------------------------------------
}
}
游戏角色移动的实现
思路
通过监听鼠标点击,获取鼠标点击的坐标。通过计算原位置到鼠标点击位置的速度,使每一帧的圆刷新在不同的位置,从而实现鼠标点击操控角色移动
计算每一帧圆所在位置的过程:
1.在基类AcGameObject
中计算出了两帧之间的间隔时间
:timedelta
2.move_to
函数计算出移动方向,用到了三角函数的知识
3.移动的平均速度
是在建立该对象时传入的
,值为地图宽度的%5每秒
4.两帧之间的移动距离
等于两帧之间的间隔时间
:timedelta
*移动的平均速度
5.新的一帧圆的位置是上一帧的圆位置加上两帧之间的移动距离
在x,y方向上的分量,这个位置不能超过目标点的位置
语法知识
实现
/**
* 计算2点间的距离
* @returns 两点间的直线距离
*/
get_dist(x1, y1, x2, y2) {
let dx = x1 - x2;
let dy = y1 - y2;
return Math.sqrt(dx * dx + dy * dy);
}
/**
* 计算出移动方向的函数
* @param tx 目标点的横坐标
* @param ty 目标点的纵坐标
*/
move_to(tx, ty) {
this.move_length = this.get_dist(this.x, this.y, tx, ty);
let angle = Math.atan2(ty - this.y, tx - this.x);
this.vx = Math.cos(angle);
this.vy = Math.sin(angle);
}
/**
* 鼠标点击的操作
*/
add_listening_events() {
let outer = this;
//禁用鼠标右键点击显示菜单的事件
this.playground.game_map.$canvas.on("contextmenu", function () {
return false;
});
this.playground.game_map.$canvas.mousedown(function (e) {
//e.which === 3,点击鼠标右键的事件
//e.which === 1,点击鼠标左键的事件
if (e.which === 3) {
// console.log(e.clientX, e.clientY);
//每一次点击都要计算出当前点到点击位置的距离
outer.move_to(e.clientX, e.clientY);
}
});
}
update() {
if (this.move_length < this.eps) {
this.move_length = 0;
this.vx = this.vy = 0;
} else {
//计算出两帧间的移动距离
let moved = Math.min(this.move_length, this.speed * this.timedelta / 1000);
//计算这一帧位置的横纵坐标
this.x += this.vx * moved;
this.y += this.vy * moved;
this.move_length -= moved;
}
//每一帧都要渲染圆
this.render();
}
游戏火球技能的实现
思路
每个技能是一个单独的类,类内部管理了技能的渲染
,打击效果
,碰撞检测
建立技能类的思路和建立玩家类的思路差不多
技能的属性一般要包括:技能的释放范围,技能的冷却时间,技能的伤害,技能的释放方向,技能的弹道速度
技能释放的按键操作方式:利用which 事件属性,和对应的Keycode设置特定的操作方式实现
实现
class FireBall extends AcGameObject {
/**
*
* @param playground
* @param player
* @param x
* @param y
* @param radius
* @param vx
* @param vy
* @param speed
* @param move_length 技能范围
* @param damage 技能伤害
* @param color
*/
constructor(playground, player, x, y, radius, vx, vy, speed, move_length, damage, color) {
super();
this.playground = playground;
this.player = player;
this.x = x;
this.y = y;
this.radius = radius;
this.vx = vx;
this.vy = vy;
this.speed = speed;
this.move_length = move_length;
this.damage = damage;
this.color = color;
this.ctx = this.playground.game_map.ctx;
this.eps = 0.1;
}
/**
* 只在第一帧执行
*/
start() {
}
/**
* 每一帧都执行
*/
update() {
if (this.move_length < this.eps) {
this.destroy();
return false;
}
//每一帧都刷新火球的位置
let moved = Math.min(this.move_length, this.speed * this.timedelta / 1000);
this.x += this.vx * moved;
this.y += this.vy * moved;
this.move_length -= moved;
//遍历所有玩家。所有非攻击者且与火球碰撞的玩家都被攻击
for (let i = 0; i < this.playground.players.length; i++) {
let player = this.playground.players[i];
if (this.player !== player && this.is_collision(player)) {
this.attack(player);
}
}
this.render();
}
/**
* 在每一帧渲染画面
*/
render() {
//渲染一个圆
this.ctx.beginPath();
this.ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2, false);
this.ctx.fillStyle = this.color;
this.ctx.fill();
//--------------------------------------------------------------
}
/**
* 实现碰撞检测
* @param player
* @returns {boolean}
*/
is_collision(player){
let dis = this.get_dist(this.x,this.y,player.x,player.y);
return dis<this.radius+player.radius;
}
/**
* 实现攻击效果
* @param player
*/
attack(player) {
let angle = Math.atan2(player.y - this.y, player.x - this.x);
player.is_attacked(angle, this.damage);
this.destroy();
}
/**
* 计算2点间的距离
* @returns 两点间的直线距离
*/
get_dist(x1, y1, x2, y2) {
let dx = x1 - x2;
let dy = y1 - y2;
return Math.sqrt(dx * dx + dy * dy);
}
}
调用
在Player
类中的shoot_fireball()
函数中创建火球对象
电脑玩家随机移动的实现
思路
让电脑玩家在第一帧随机一个目标地点,在到达目标地点的帧再随机一个新的目标地点
语法知识
实现
/**
* 每一帧都执行
*/
update()
{
//玩家已经走到了目标地点
if (this.move_length < this.eps) {
this.move_length = 0;
this.vx = this.vy = 0;
if (!this.is_me) {
//如果是电脑玩家,在到达目标位置的帧都随机一个新的目标位置
let tx = Math.random() * this.playground.width;
let ty = Math.random() * this.playground.height;
this.move_to(tx, ty);
}
} else {
//玩家没有走到目标位置,则计算新的一帧玩家的位置
//计算出两帧间的移动距离
let moved = Math.min(this.move_length, this.speed * this.timedelta / 1000);
//计算这一帧位置的横纵坐标
this.x += this.vx * moved;
this.y += this.vy * moved;
this.move_length -= moved;
}
//每一帧都要渲染圆
this.render();
}
碰撞检测的实现
思路
圆的碰撞检测思路很简单,就是判断两个圆是否相交,即两圆心的直线距离等于两圆心的半径和
实现
game/static/js/src/playground/skill/fireball/zbase.js
中的is_collision()函数
/**
* 实现碰撞检测
* @param player
* @returns {boolean}
*/
is_collision(obj) {
let dis = this.get_dist(this.x, this.y, obj.x, obj.y);
return dis < this.radius + obj.radius;
}
这个函数在game/static/js/src/playground/skill/fireball/zbase.js
中的update()函数
中被使用
击退效果的实现
思路
计算火球来的方向,以火球的弹道方向作为玩家的被击退方向,玩家体积大小有一定变化,移动速度也要有一定变化
实现
由game/static/js/src/playground/player/zbase.js
中的is_attacked()
实现
/** * 被攻击后的效果 * @param angle 受到攻击后的角度,用于实现击退效果 * @param damage 技能的伤害 */ is_attacked(angle, damage) { //实现被攻击后的粒子效果 for (let i = 0; i < gameParameters.particle_number[0] + Math.random() * gameParameters.particle_number[1]; i++) { //这里参考了大佬的代码,比y总的传参更合理 new Particle(this.playground, this); } //受到攻击的玩家,移速变慢,体积变小,发射技能的弹道速度变慢 this.radius -= damage; this.speed *= gameParameters.reduce_ratio; if (this.radius < gameParameters.dead_szie) { this.destroy(); return false; } //计算受到攻击后的击退方向 this.damage_x = Math.cos(angle); this.damage_y = Math.sin(angle); this.damage_speed = this.radius * 50; }
粒子效果的实现
思路
粒子也如同玩家一样,被看作地图上的一个对象,每个粒子从玩家上产生,会随机移动,逐渐消失
实现
game/static/js/src/playground/particle/zbase
中的Particle
类
class Particle extends AcGameObject { /** * 粒子类 * @param playground 在哪张地图上 * @param player 哪个玩家扩散出的粒子 */ constructor(playground, player) { super(); this.playground = playground; this.player = player; //粒子的画布 this.ctx = this.playground.game_map.ctx; //粒子的位置 this.x = player.x; this.y = player.y; //粒子的颜色应该和玩家的颜色相等 this.color = player.color; // 粒子半径 this.radius = Math.random() * player.radius * gameParameters.particle_size_percent; // 释放速度 this.speed = player.speed * gameParameters.particle_speed_percent; // 固定参数,粒子的移动距离 this.move_length = Math.max(gameParameters.particle_move_length[0], Math.random()) * player.radius * gameParameters.particle_move_length[1]; // 减速摩擦力 this.friction = gameParameters.particle_friction; // 误差范围 this.eps = 1; // 随机参数,释放方向 // 弧度制 this.angle = Math.PI * 2 * Math.random(); //粒子的随机移动方向 this.vx = Math.cos(this.angle); this.vy = Math.sin(this.angle); } /** * 只在第一帧执行 */ start() { } /** * 每一帧都执行 */ update() { if (this.radius < this.eps) { this.destroy(); return false; } this.radius *= gameParameters.particle_feed; //每一帧都刷新粒子的位置 let moved = Math.min(this.move_length, this.speed * this.timedelta / 1000); this.x += this.vx * moved; this.y += this.vy * moved; this.move_length -= moved; this.speed *= this.friction; this.render(); } /** * 在每一帧渲染画面 */ render() { //渲染一个圆 this.ctx.beginPath(); this.ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2, false); this.ctx.fillStyle = this.color; this.ctx.fill(); //-------------------------------------------------------------- }}
这个对象在game/static/js/src/playground/player/zbase.js
中的is_attacked()
中被创建
简单移动预测的实现
思路
攻击某玩家时,传入其移动方向,计算某玩家在x秒后的位置,朝该位置发射火球
实现
if (!this.is_me && Math.random() < gameParameters.AIs_attack_frequency) { let player = this.playground.players[Math.floor(Math.random() * this.playground.players.length)]; //是否开启相互攻击 if (!gameParameters.attack_eachother) { player = this.playground.players[0]; } //实现简单的移动预测 let tx = player.x + player.speed * this.vx * this.timedelta / 1000 * 0.3; let ty = player.y + player.speed * this.vy * this.timedelta / 1000 * 0.3; this.shoot_fireball(tx, ty); }
火球相互抵消的实现
思路
1.该游戏中的所有对象都放在AC_GAME_OBJECTS = []
中
2.每个platground
对象中都有players
和fireball
等数组,储存了该地图下的各种对象
3.火球相互抵消这个操作就是在每个对象所在platground
中的fireball
数组下进行操作从而实现的
4.删除一个对象,如火球。在AC_GAME_OBJECTS = []
中删除时,要调用对象的destroy()
函数。
在其他数组,如platground
对象中的fireball
数组中删除时,删除过程要写在on_destroy()
函数中。
实现
/** * 从playground.fireballs中将火球删除 */ on_destroy() { for (let i = 0; i < this.playground.fireballs.length; i++) { if (this.playground.fireballs[i] === this) { this.playground.fireballs.splice(i, 1); } } } update() { if (this.move_length < this.eps) { this.destroy(); return false; } //每一帧都刷新火球的位置 let moved = Math.min(this.move_length, this.speed * this.timedelta / 1000); this.x += this.vx * moved; this.y += this.vy * moved; this.move_length -= moved; //实现火球碰撞后相互抵消,将火球从AC_GAME_OBJECTS = [],中删除 for (let i = 0; i < this.playground.fireballs.length; i++) { let fireball = this.playground.fireballs[i]; if (fireball != this && this.is_collision(fireball)) { this.destroy(); fireball.destroy(); break; } } this.render(); }
对y总代码的bug改动
自己的一些想法
加入配置类,统一管理参数
y总上课时候说过游戏的体验最重要的就是参数的设置
。
既然这样,我们为什么不把所有游戏参数集中到一个文件中呢?
这样既方便我们调试,又给后期,前后端的数据沟通带来了方便
只需要在该文件下改动参数即可生效
配置文件game/static/js/src/config.js
var gameParameters = {//--------------playground/game_map/zbase.js---------------- //背景颜色和不透明度(rgba值) "background_color": "rgba(0, 0, 0, 0.2)",//--------------------------------------------------------------//--------------playground/particle/zbase.js---------------- //粒子效果的最大粒子半径/玩家半径的比值 "particle_size_percent": 0.4, //粒子速度/其玩家速度,的比值 "particle_speed_percent": 20, //粒子移动距离参数Math.max(0.5, Math.random()) * player.radius * 4 "particle_move_length": [0.5, 4], //减速摩擦力 "particle_friction": 0.85, //粒子每帧的消失比例 "particle_feed": 0.98,//----------------------------------------------------------------------//--------------playground/player/zbase.js---------------- //电脑玩家自动攻击的频率 "AIs_attack_frequency": 1/360, //最小击退速度 "damage_speed":10, //是否开启互相攻击 "attack_eachother":false, //火球大小/画布高度 "fireball_size":0.01, //火球弹道速度/画布高度 "fire_speed": 0.5, //开场后的冷静时间(多少秒内不能攻击) "calm_time": 4, //随机粒子数量,20 + Math.random() * 10 //最小为[0,1],是无粒子 "particle_number":[20,10], //火球的攻击范围/画布高度 "fireball_range" : 5, //火球技能的伤害/画布高度 "fireball_damage": 0.01, //火球的颜色 "fireball_color":"orange", //火球技能的伤害(被攻击后减移速的比例) "reduce_ratio": 0.8, //玩家的死亡大小 "dead_szie": 10,//------------------------playground/zbase.js------------------------- //玩家初始大小百分比(相对于浏览器的宽) "players_size_percent": 0.05, //玩家自己的颜色 "self_color": "white", //玩家的移动速度,用每秒移动高度的百分比表示 "player_speed_percent": 0.15, //电脑玩家的数量 "AIs_number": 5, //所有玩家的颜色列表 "color_select": ["#c66f35", "gree", "#c0d6cb", "#1cce31", "#9fa0d7", "#cc99ff"]//------------------------------------------------------------------------}
让死了的小球不会继续攻击
为什么会出现死亡后还能继续攻击这个bug
?经过反复研究观察,终于搞明白了。
我们回想一下,发射火球说通过调用shoot_fireball()
实现的。死了以后还能放火球,就意味着死亡后还能调用shootfireball()
函数。为什么会这样?我们已经把死亡的对象删除了鸭?我们来看一下调用shoot_fireball
的地方:
this.playground.game_map.$canvas.mousedown(function (e) { //e.which === 3,点击鼠标右键的事件 //e.which === 1,点击鼠标左键的事件 if (e.which === 3) { // console.log(e.clientX, e.clientY); //每一次点击都要计算出当前点到点击位置的距离 outer.move_to(e.clientX, e.clientY); } else if (e.which === 1) { //只有当按下键盘选中技能后,点击鼠标才能释放技能 if (outer.cur_skill === "fireball") { outer.shoot_fireball(e.clientX, e.clientY); } //在释放完技能后取消技能选中 outer.cur_skill = null; } }); //监听键盘事件 $(window).keydown(function (e) { if (e.which === 81) { // q outer.cur_skill = "fireball"; return false; } });
仔细观察谁调用了shoot_fireball()
?答案是outer
,而outer
指的是当前对象
!!
虽然我们在数组中删除了当前对象
,但是当前对象并不知道自己已经被删除了!!
知道了问题,解决办法就很容易了:就是在Player
对象中添加一个表明状态的属性this.status
实现
//监听键盘事件 $(window).keydown(function (e) { if (e.which === 81) { // q outer.cur_skill = "fireball"; outer.playground.game_map.$canvas.mousemove(function (e) { //只有当按下键盘选中技能后,点击鼠标才能释放技能 if (outer.cur_skill === "fireball"&&outer.status!="die") { outer.shoot_fireball(e.clientX - rect.left, e.clientY - rect.top); } //在释放完技能后取消技能选中 outer.cur_skill = null; }) return false; } });
在Player
对象中的is_attacked()
的函数中:
if (this.radius < gameParameters.dead_szie) { this.status = "die"; this.destroy(); return false; }
命中回血机制
这个功能比较简单,我们以体积为血量的衡量标准
,即血量越大体积越大
这个机制加在