Django框架课-创建游戏界面 (1)

创建游戏界面 (1)

最后的结构:

playground/
|-- ac_game_object
|   `-- zbase.js
|-- game_map
|   `-- zbase.js
|-- particle
|   `-- zbase.js
|-- player
|   `-- zbase.js
|-- skill
|   `-- fireball
|       `-- zbse.js
`-- zbase.js

修改成以模块单位引入js代码

前面的工作都是直接把js代码简单拼接,但是如果有重名的变量是有可能出错的。

所以修改成以模块形式导入js代码

修改: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>

然后修改static/js/src/zbase.js最前面加上一个export

export class AcGame {
    constructor(id){
        this.id = id;
        this.$ac_game = $('#' + id);
        this.menu = new AcGameMenu(this);
        this.playground = new AcGamePlayground(this);

        this.start();
    }
    
    start(){
    }
}

写游戏界面

写游戏界面的时候可以先把菜单界面关闭掉,让他只显示游戏界面,这样不用每次刷新都要点击一下单人模式才能查看自己写的效果。

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.start();
    }
    

    start(){
    }

    show(){ // 打开playground界面
        this.$playground.show();
    }
    
    hide(){ // 关闭playground界面
        this.$playground.hide();
    }
}

添加基本的playground的css (添加到game.css文件的末尾即可)

.ac-game-playground{
    width: 100%;
    height: 100%;
    user-select: none;
}

playground/zbase.js

游戏在一个画布之中,我们要先把其宽高记下来以备使用(作为基本属性写在AcGamePlayground类的构造函数中)

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();
        

        this.start();
    }
    

    start(){
    }

    show(){ // 打开playground界面
        this.$playground.show();
    }
    
    hide(){ // 关闭playground界面
        this.$playground.hide();
    }
}

游戏人物、物体的“动”是如何实现?

电影是一张张图片每秒钟快速切换n张图片,连续起来就成为了电影。

游戏也是如此。

每秒钟刷新60次(播放60张图片),每一张图片让人物/物体稍微一动一点点,这样就看着是“动”起来了。

要写的游戏是术士之战。每一个角色小球,每一个技能小球等等都要在每秒钟刷新60次,我们可以写一个js基类AcGameObject来实现每一帧去渲染出一个图片

AcGameObject基类

进入js/src/playground/ac_game_object新建zbase.js

每一个是这个AcGameObject基类的物体,每一帧都要画出来。我们搞个全局变量数组存起来这些物体对象,每秒中调用这个数组里的对象60次(60帧)。

这样的对象应该有三个函数:

一开始就执行一次的函数。比如一个小球刚出现的时候其基本信息,颜色、大小、昵称等(从服务器端接受过来初始化),一般在第一帧执行。

总是更新的函数,比如实现移动。每一帧都执行的函数。

删除函数,删除这个对象的函数。删除对象就直接从全局数组中删除这个对象就行。从全局数组中删除之后就不会再去渲染它,而且删除后也会释放内存。

一个物体被删掉之前可能需要恢复现场,比如一个玩家被打死了,血量变成0了,但是在删除之前可能需要给对手加分啊什么的,这个时候就还需要再添加一个函数,某个物体被删除前需要执行的函数。

一个简易的游戏引擎就是这样了。

let AC_GAME_OBJECTS = [];

class AcGameObject{
    constructor() {
        AC_GAME_OBJECTS.push(this);
    }

    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;
            }
        }
    }
}

实现每一帧去循环一次数组里的所有对象,使用js中的一个api: requestAnimationFrame()

使用这个api调用我们写的动画函数(回调函数),可以保证每一秒60帧,每一帧执行一次我们的动画函数。

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) {
	// 遍历数组中每一个对象,刷新动画
    for(let i = 0; i < AC_GAME_OBJECTS.length; i ++) {
        let obj = AC_GAME_OBJECTS[i];
        if(!obj.has_called_start) {
            obj.start();
            obj.has_called_start = true;
        } else {
            obj.timedelta = timestamp - last_timestamp;
            obj.update();
        }
    }
    last_timestamp = timestamp;

	// 递归下去能不断每一帧刷新
    requestAnimationFrame(AC_GAME_ANIMATION);
}

requestAnimationFrame(AC_GAME_ANIMATION);

创建游戏地图

之前已经创建了一个AcGamePlayground类(playground/zbase.js),这个里面存了游戏界面的宽和高,创建游戏地图要用canvas画布,先新建一个game_map/zbase.js中写一个GameMap类来生成画canvas画布

GameMap是我们画游戏地图的一个类,这个游戏地图也要一帧一帧的刷新所以也要继承AcGameObject游戏对象基类

GameMap: (playground/game_map/zbase.js)

class GameMap extends AcGameObject {
    constructor(playground) { // 传入AcGamePlayground的对象实例
        super(); // 继承基类的构造函数
        this.playground = playground;
        this.$canvas = $(`<canvas></canvas>`); // canvas相关的api可以去百度、菜鸟教程
        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);
    }

    start() {
    }

    update() {
    }
}

然后修改AcGamePlayground: (playground/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();
        this.game_map = new GameMap(this); // 创建一个地图

        this.start();
    }
    

    start(){
    }

    show(){ // 打开playground界面
        this.$playground.show();
    }
    
    hide(){ // 关闭playground界面
        this.$playground.hide();
    }
}

现在访问http://47.94.107.232:8000/ F12就可以看到ac-game-playground下生成的canvas标签了

画地图背景:
每一帧渲染出黑色背景,写一个render函数,放在update中。修改playground/game_map/zbase.js :

class GameMap extends AcGameObject {
    constructor(playground) {
        super(); // 继承基类的构造函数
        this.playground = playground;
        this.$canvas = $(`<canvas></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);
    }

    start() {
    }

    update() {
        // 每一帧都要花一次所以rander在这里执行
        this.render();
    }

    // 渲染游戏背景
    render() {
        this.ctx.fillStyle = "rgba(0,0,0)";
        this.ctx.fillRect(0,0,this.ctx.canvas.width,this.ctx.canvas.height);
    }
}

现在访问就可以看到黑色的背景。
地图创建完毕,下面创建玩家

创建游戏玩家

每一个玩家是一个小球

玩家也是需要每一帧画出来的东西,所以也继承AcGameObject游戏对象基类

构造函数需要传入,地图、横纵坐标(如果是三维的游戏就要再传一个z轴坐标)、小球半径、小球颜色、移动速度、是否是玩家自己。

关于玩家的移动速度参数:其表示应该是每秒移动百分之多少,屏幕高度的百分比去表示,每秒移动其百分之多少。因为是一个联机游戏,如果用每秒移动了多少个像素来表示速度,不同玩家的屏幕分辨率不同,分辨率高的屏幕像素就多,就会显得移动的慢,这样对于联机的其他玩家是不公平的。

关于是否是玩家自己参数:玩家们的小球的移动方式有两种,一种是通过键盘鼠标(玩家自己),另一种是通过网络传输(联网对局中的其他玩家的移动信息传输过来),所以要加一个is_me参数来判断是否是玩家自己

playground/zbase.js :

class Player extends AcGameObject {
    constructor(playground, x, y, radius, color, speed, is_me) {
        super();
        this.playground = playground;
        this.ctx = this.playground.game_map.ctx;
        this.x = x;
        this.y = y;
        this.radius = radius;
        this.color = color;
        this.speed = speed;
        this.is_me = is_me;
        this.eps = 0.01; // 移动的时候会涉及到浮点运算,小于0.01判断为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();
    }
}

然后修改AcGamePlayground: (playground/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();
        this.game_map = new GameMap(this); // 创建地图
        this.players = []; // 新建一个玩家数组
        this.players.push(new Player(this, this.width / 2, this.height / 2, this.height * 0.05, "white", this.height * 0.15, true)); // 创建玩家自己,is_me为true表示是玩家自己

        this.start();
    }
    

    start(){
    }

    show(){ // 打开playground界面
        this.$playground.show();
    }
    
    hide(){ // 关闭playground界面
        this.$playground.hide();
    }
}

效果如下:

image

为了更加美观,可以将背景每次刷新的透明度改成半透明的背景,这样就可以是一种渐变式的渲染。
GameMap类中:render()时

this.ctx.fillStyle = "rgba(0,0,0,0.2)"; 
// rgba再加一个透明度参数(值为0.2)就行

实现玩家移动

给这个类加一个速度属性,并且update的时候每次让坐标加上速度就行

class Player extends AcGameObject {
    constructor(playground, x, y, radius, color, speed, is_me) {
        super();
        this.playground = playground;
        this.ctx = this.playground.game_map.ctx;
        this.x = x;
        this.y = y;
        this.vx = 1; // 加一个速度属性
        this.vy = 1;
        this.radius = radius;
        this.color = color;
        this.speed = speed;
        this.is_me = is_me;
        this.eps = 0.1;
    }

    start() {
    }

    update() {
		// 每次update让其坐标加上每一帧移动的距离就行
        this.x += this.vx;
        this.y += this.vy;
        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();
    }
}

下面的图就是移动过程中的截图,可以看到移动方向尾部会有一个模糊化的光圈,这个就是因为尾端只渲染了透明度为0.2的背景,而头端(方向朝向端)渲染的多就显示的更加清楚。

image

鼠标实现控制移动方向

现在只是向一个方向移动。MOBA类游戏是鼠标控制移动方向,那我们就让鼠标控制移动速度就行。

前面说了,球的移动方式有两种,一种是键盘鼠标,另一种是网络传参。
玩家想要控制自己的球,要用键盘鼠标,第一步就是先判断此球是否是玩家自己。

start(){}中判断是否是玩家自己。

if(this.is_me){
	this.add_listening_events(); // 如果是自己就添加事件
}

先将鼠标的右键菜单隐藏,然后将鼠标右键的位置传进来

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(e.which === 3){ // 左键1 中键2 右键3
            outer.move_to(e.clientX,e.clientY); // 这里要用outer才能调用到move_to方法,直接用this代表的是这个函数本身
        }
    });
}
move_to(tx,ty) {
    console.log("move to",tx,ty);
}

随便鼠标右击几个位置:

image

实现鼠标控制移动

起始坐标为(x,y),终点坐标为(tx,ty)

Player的构造函数加一个

this.move_length = 0;

,这个是保存距离目标地点的距离。

求距离,需要求出两点间的距离(x1,y1)到(x2,y2):

get_dist(x1, y1, x2, y2) {
let dx = x1 - x2;
let dy = y1 - y2;
return Math.sqrt(dx * dx + dy * dy);
}

求出横纵方向上的速度,先求角度,然后根据角度的cos和sin值乘1得到x、y轴上的速度。

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);
}

改写update()让其移动
如果需要移动的距离(move_length)小于了eps,就不需要移动了,认为到达了目标地点。
move_lengthvxvy置0

否则就计算出真实移动的距离,进行移动。
真实移动的距离是move_length与每一帧移动距离(speed*timedela/1000单位为毫秒,要除以1000)的最小值
让x、y加上该方向上的速度乘以真实移动的距离
最后让move_length减去已经移动的距离(更新成还需要移动的距离)

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();
}

最后代码:
js/src/playground/player/zbase.js

class Player extends AcGameObject {
    constructor(playground, x, y, radius, color, speed, is_me) {
        super();
        this.playground = playground;
        this.ctx = this.playground.game_map.ctx;
        this.x = x;
        this.y = y;
        this.vx = 0;
        this.vy = 0;
        this.move_length = 0;
        this.radius = radius;
        this.color = color;
        this.speed = speed;
        this.is_me = is_me;
        this.eps = 0.1;
    }

    start() {
        if(this.is_me) {
            // 如果这个球是玩家自己,玩家就可以控制鼠标移动此球
            this.add_listening_events();
        }
    }

    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(e.which === 3) {
                outer.move_to(e.clientX,e.clientY); // 这里要用outer才能调用到move_to方法,直接用this代表的是这个函数本身
            }
        });
    }

    get_dist(x1, y1, x2, y2) {
        let dx = x1 - x2;
        let dy = y1 - y2;
        return Math.sqrt(dx * dx + dy * dy);
    }

    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);
    }

    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();
    }

    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();
    }
}

最后效果:

鼠标右键某个位置,小球会自动跑到那个位置

(下图是右键了地图左上角区域,小球移动过程中的图片)
image

实现火球技能

火球类

playground中创建skill文件夹
进入到skill文件夹中,创建fireball文件夹,进入fireball文件夹
写火球对象zbase.js

火球和刚才的玩家类似,传入的参数有地图,玩家(用来判断火球是谁发出的,要不要对这个玩家造成伤害如果是自己发出的,就不可以对自己造成伤害),x,y坐标,速度vx,vy,颜色,speed,移动距离(至目的地的距离,会不断减小,减小到0就不再移动,说明到达了目的地)

class FireBall extends AcGameObject {
    constructor(playground, player, x, y, radius, vx, vy, color, speed, move_length) {
        super();
        this.playground = playground;
        this.player = player;
        this.ctx = this.playground.game_map.ctx;
        this.x = x;
        this.y = y;
		this.radius = radius;
        this.vx = vx;
        this.vy = vy;
        this.color = color;
        this.speed = speed;
        this.move_length = move_length; // 移动距离
        this.eps = 0.1; // 距离小于0.1表示为0
    }

    start() {
    }
    
    update() {
        if(this.move_length < this.eps) { // 如果移动距离为0,就不再移动,调用基类的destory函数销毁火球
            this.destroy();
            return false;
        }
        
        let moved = Math.min(this.move_length, this.speed * this.timedelta / 1000); // thimedelta是一帧的时间单位毫秒,要模1000
        this.x += this.vx * moved;
        this.y += this.vy * moved;
        this.move_length -= moved;

        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();
    }

}

火球类实现完了

实现发火球的功能

去Player类里写

lol实现技能:键盘上按一个选择技能,鼠标左键点击方向确定施法

首先在构造函数里加一个this.cur_skill = null来表示目前选择的技能是什么

然后写一个事件监听,接受键盘键入和鼠标左键键入

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(e.which === 3) {
                outer.move_to(e.clientX,e.clientY); // 这里要用outer才能调用到move_to方法,直接用this代表的是这个函数本身
            } else if(e.which === 1) { // 如果点击了鼠标左键
                if(outer.cur_skill === "fireball") {
                    outer.shoot_fireball(e.clientX,e.clientY);
                }

				outer.cur_skill = null; // 释放完该技能,取消选中该技能
            }
        });

        // 使用window来获取键盘事件,(上面的鼠标点击事件是用canvas来获取,这里不可以使用canvas来获取,canvas不能聚焦
        $(window).keydown(function (e) {
            if(e.which === 81) { // 如果键盘获取的keycode是81(代表的是q),就使用火球技能
                outer.cur_skill = "fireball";
                return false;
            }
        });
    }

    shoot_fireball(tx,ty) {
        console.log("shoot fireball", tx, ty);
    }

现在先按q,鼠标左键点击任意一个位置,就会输出这个位置的坐标

image

火球移动的具体逻辑

修改Player类里的shoot_fireball函数

shoot_fireball(tx,ty) {
        let x = this.x, y = this.y;
        let radius = this.playground.height * 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 = this.playground.height * 0.5;
        let move_dist = this.playground.height * 1;
        
        new FireBall(this.playground, this, x, y, radius, vx, vy, color, speed, move_dist);
    }

image

实现单机版(添加人机)

单机要加入其他敌人,进入 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();
        this.game_map = new GameMap(this);
        this.players = [];
        this.players.push(new Player(this, this.width / 2, this.height / 2, this.height * 0.05, "white", this.height * 0.15, true));
        
		// 把上面的抄下来,添加几个敌人,后面的is_me参数要传false
        for(let i = 0; i < 5; i ++ ) {
            this.players.push(new Player(this, this.width / 2, this.height / 2, this.height * 0.05, "blue", this.height * 0.15, false));
        }

        this.start();
    }

现在的敌人都是蓝色的,且不能动。我们需要写简单的ai,让他们动起来。

最简单的方法是让他们随机游走,每次生成一个随机的目的地。

进入 Player 类,修改start和update函数
在start()和update()让人机每次获得一个随机目的地就可以。

start() {
        if(this.is_me) {
            // 如果这个球是玩家自己,玩家就可以控制鼠标移动此球
            this.add_listening_events();
        } else { // 如果这个玩家是人机,就让他走到一个随机目的地
            let tx = Math.random() * this.playground.width; // js的Math.random()返回的是一个0~1的数
            let ty = Math.random() * this.playground.height;
            this.move_to(tx,ty);
        }
    }
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();
    }

image

之后可以加上攻击的伤害,人机攻击等功能。

增加火球伤害与击退效果

碰撞:两小球的中心点的距离小于两小球的半径之和就视为发生了碰撞

判断火球是否打击到敌人,需要在每一帧都要进行碰撞检测。枚举每个player,如果不是自己,就判断是否发生碰撞,若发生碰撞,就attack一下这个player(减小被攻击玩家生命值,造成击退效果等)

判断火球是否击中,就写在FireBall类里,作用在player的行为就写在Player类里,把is_attacked写在Player类里。

需要给is_attacked传入击退方向、damage值。对于击退方向,就可以传一个角度。

FireBall类:

class FireBall extends AcGameObject {
    constructor(playground, player, x, y, radius, vx, vy, color, speed, move_length, damage) {
        super();
        this.playground = playground;
        this.player = player;
        this.ctx = this.playground.game_map.ctx;
        this.x = x;
        this.y = y;
        this.radius = radius;
        this.vx = vx;
        this.vy = vy;
        this.color = color;
        this.speed = speed;
        this.move_length = move_length;
        this.eps = 0.1; // 距离小于0.1表示为0
    }

    start() {
    }

    update() {
        if(this.move_length < this.eps) { // 如果移动距离为0,就不再移动,调用基类的destory函数销毁火球
            this.destroy();
            return false;
        }

        let moved = Math.min(this.move_length, this.speed * this.timedelta / 1000); // thimedelta是一帧的时间单位毫秒,要模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 && is_collision(player)) {
                this.attack(player);
            }
        }

        this.render();
    }

    get_dist(x1,y1,x2,y2) { // 计算两点的距离
        let dx = x1 - x2;
        let dy = y1 - y2;
        return Math.sqrt(dx * dx + dy * dy);
    }

    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(palyer) { // 击中玩家
        let angle = Math.atan2(player.y - this.y, player.x - this.x);
        player.is_attacked(angle,this.damage); // 调用被击中的函数实现击中效果
        this.destroy(); // 火球消失
    }


    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();
    }
}

让玩家的血量就是半径。
火球加一个伤害参数damage。创建火球的时候,伤害是一个具体的值,可以定为this.playground.height * 0.01,玩家半径是this.playground.height * 0.05,每击中一次,让玩家半径减小这个值,即火球的伤害为血量的20%,高度变,玩家大小、火球伤害都会发生改变,比较合理。

火球击中了玩家,玩家会被击退,击退期间玩家应该是失去控制的。朝着一个方向推一段距离。这个时候玩家自身的vx和vy都应该置为0。
玩家被击退刚开始击退速度应该比较快后来应该是越来越慢变成0,这样子更加丝滑,需要添加一个类似于摩擦力的参数去实现。

构造函数中加一些新的基础属性damage_xdamage_ydamage_speedfriction(击退摩擦力)

Player类:

class Player extends AcGameObject {
    constructor(playground, x, y, radius, color, speed, is_me) {
        super();
        this.playground = playground;
        this.ctx = this.playground.game_map.ctx;
        this.x = x;
        this.y = y;
        this.vx = 0;
        this.vy = 0;
        this.move_length = 0;
        this.radius = radius;
        this.color = color;
        this.speed = speed;
        this.is_me = is_me;
        this.eps = 0.1;
        this.cur_skill = null;
		
		// 新添的: 伤害方向、击退速度、击退摩擦力
        this.damage_x = 0; 
        this.damage_y = 0;
        this.damage_speed = 0;
        this.friction = 0.9;
    }

    start() {
        ...
    }

    add_listening_events() {
        ...
    }

    shoot_fireball(tx,ty) {
        ...
    }

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

    move_to(tx,ty) {
        ...
    }

    is_attacked(angle,damage) { // 玩家被击中,作用于玩家身上的效果
        this.radius -= damage;
        if(this.radius < 10){
            this.destroy();
            return false;
        }
        this.damage_x = Math.cos(angle);
        this.damage_y = Math.sin(angle);
        this.damage_speed = damage * 80; // 可以调一下
		this.speed *= 0.8; // 被击中之后速度变慢,小球速度变慢也合理
    }

    update() {
        if (this.damage_speed > 10){ // 如果受到伤害
            this.vx = this.vy = 0;
            this.move_length = 0;
            // 朝着damage的方向移动
            this.x += this.damage_x * this.damage_speed * this.timedelta / 1000;
            this.y += this.damage_y * 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) { // 如果是人机,走到了目的地就接着走到下一个随机地点
                    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();
    }

    render() {
		...
    }
}

也可以加击中之后,冰球,绳索等等其他各种各样的效果

增加火球粒子效果

火球打中之后会有粒子爆炸效果

在playground下新建paritcle文件夹

写Particle粒子类
需要传入地图(因为要画出来),粒子生成位置x、y,粒子半径,粒子颜色,粒子速度,粒子最大运动长度。

Particle粒子类:

class Particle extends AcGameObject {
    constructor(playground, x, y, radius, vx, vy, color, speed, move_length) {
        super();
        this.playground = playground;
        this.ctx = this.playground.game_map.ctx;
        this.x = x;
        this.y = y;
        this.radius = radius;
        this.vx = vx;
        this.vy = vy;
        this.color = color;
        this.speed = speed;
        this.move_length = move_length;
        this.friction = 0.9; // 粒子也需要逐渐变慢,需要一个摩擦力参数
        this.eps = 1;
    }

    start() {
    }

    update() {
        if(this.move_length < this.eps || this.speed < this.eps) { // 速度小于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;
        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();
    }

}

在Player类中对于is_attacked函数补充:new粒子,并传入一些随机的参数

// 粒子爆炸烟花特效
for(let i = 0; i < 20 + Math.random() * 10; i ++) {
	let x = this.x, y = this.y;
	let radius = this.radius * Math.random() * 0.1;
	let angle = Math.PI * 2 * Math.random();
	let vx = Math.cos(angle), vy = Math.sin(angle);
	let color = this.color;
	let speed = this.speed * 10;
	let move_length = this.radius * Math.random() * 5;
	new Particle(this.playground, x, y, radius, vx, vy, color, speed, move_length);
}

人机随机颜色

最好是自己选定几个颜色,然后随机。纯随机数选出颜色可能并不好看。

打开AcGamePlayground基类
添加函数,然后在生成人机传入颜色参数时直接this.get_random_color()即可

get_random_color() {
        let colors = ["blue", "red", "pink", "grey", "green"];
        return colors[Math.floor(Math.random() * 5)];
    }

调整火球粒子出现顺序

击中后先出现粒子效果,然后再去扣血(减小被击中玩家半径),否则当给玩家最后一击的时候,radius < 10,玩家直接被销毁,return false

这样就不会再出现粒子效果,所以粒子效果代码放在扣血前面。

is_attacked(angle,damage) { // 玩家被击中,作用于玩家身上的效果

        // 粒子爆炸烟花特效 , 把粒子特效放在最前面要!
        for(let i = 0; i < 20 + Math.random() * 10; i ++) {
            let x = this.x, y = this.y;
            let radius = this.radius * Math.random() * 0.1;
            let angle = Math.PI * 2 * Math.random();
            let vx = Math.cos(angle), vy = Math.sin(angle);
            let color = this.color;
            let speed = this.speed * 10;
            let move_length = this.radius * Math.random() * 5;
            new Particle(this.playground, x, y, radius, vx, vy, color, speed, move_length);
        }

        this.radius -= damage;
        if(this.radius < 10){
            this.destroy();
            return false;
        }
        this.damage_x = Math.cos(angle);
        this.damage_y = Math.sin(angle);
        this.damage_speed = damage * 80; // 可以调一下
        this.speed *= 0.8; // 被击中以后,速度变慢,小球速度慢也比较合理

    }

image

image

人机向随机一个其他敌人发射火球

2:11:30

让人机随机向一个人发射火球

if (!this.is_me){
	let player = this.playground.players[Math.floor(Math.random() *this.playground.players.length)];
	this.shoot_fireball(player.x,player.y);
}

目前是有可能向自己发射的(虽然没有伤害),可以加一个特判,如果是自己就重新发射。

但是这样游戏刚开始,玩家还没有反应过来人机就已经开火了,并且会一直开火。所以加一个游戏开始冷静期,并给人机开火加一个时间限制。
让人机游戏开始4s后,大约每4s向随机一个人发射一个火球。

构造函数中加一个this.spent_time = 0;

update() {
	this.spent_time += this.timedelta / 1000;
	if( !this.is_me && this.spent_time > 4 && Math.random() < 1 / 240.0){
		let player = this.playground.players[Math.floor(Math.random() *this.playground.players.length)];
		this.shoot_fireball(player.x,player.y);
	}
}

此外可以加一个0.3s的预判,让人机往敌人移动方向的未来0.3s位置发射火球

let player = this.playground.players[Math.floor(Math.random() * this.playground.players.length)];
let tx = player.x + player.speed * player.vx * this.timedelta / 1000 * 0.3; // 预判0.3s后的位置
let ty = player.y + player.speed * player.vy * this.timedelta / 1000 * 0.3;
this.shoot_fireball(tx,ty);

Player中添加on_destroy

如果某个玩家死亡就将其从players中删除掉

on_destroy() { // 玩家死后调用这个函数,将玩家删除掉
        for (let i = 0; i < this.playground.players.length; i ++) {
            if (this.playground.players[i] === this) {
                this.playground.players.splice(i,1);
            }   
        }   
    }

还有一些小bug

比如玩家死后还能发火球,人机发射火球也会朝着自己移动方向发射火球等等

posted @ 2023-02-21 18:13  r涤生  阅读(62)  评论(0编辑  收藏  举报