在初始阶段,我觉得比较重要得一点是捋顺项目的逻辑,这个逻辑不仅仅是某一个函数的逻辑,更重要的是宏观层面上的逻辑,它每一个文件做了什么,文件之间如何联系起来的,靠什么联系起来的,这些我觉得也是非常重要的,只有搞懂了这些,项目才是活的,才会有可能去独立的写一个项目,或加一些东西,否则,我们就处于一种“不敢动”的状态,不知道加一些东西,或减一些东西意味着什么. 那么我这篇文章主要就是想讲一下这个小项目,它是怎么动起来的。
本项目一共有两个大文件夹, 分别是static文件夹和template文件夹,前者存放一些静态文件,包括css文件夹,image文件夹还有js文件夹等,可能还会有一些音频或其他的文件夹,在这里主要说一下js夹里的内容,这里面包含了实现本项目的大部分逻辑. 而后者template文件夹里面主要包含一个index.html文件,主要用来生成一个项目对象.
-
ac_game_object文件夹
-
controller文件夹
-
game_map文件夹
-
player文件夹
-
utils文件夹
这些文件夹里面都有一个base.js文件来负责项目的某一部分逻辑. 而js文件夹里面本身就有个base.js文件,这个文件里面的类就是生成项目对象的类。
首先我们在index.html文件里面,new一个对象出来,这个对象就是项目本身,id号来区分项目,也就是说id号是KOF类中构造函数里面的参数. index.html文件夹里面的内容如下:
<body>
<div id="kof">
</div>
<script type="module">
import { KOF } from '/static/js/base.js';
let kof = new KOF('kof');
</script>
</body>
现在,我们的视角转入js/base.js这个文件,这个文件中主要有一个KOF类, KOF类里面的构造函数, 主要是生成地图和角色,或者是其他什么东西. 如果有其他东西,就可以再加一些文件,写入一些逻辑.
就本项目而言,可以看到,我们在该文件里面导入了GameMap和Kyo类. 我们new了一个gamemap,同时也new了一个players数组. 同时我们用jquery的语法,使得我们能够操控某一个id号的div.
代码如下:
import {GameMap} from '/static/js/game_map/base.js';
import { Kyo } from './player/kyo.js';
class KOF {
constructor(id) {
this.$kof = $('#' + id);
// 这里是链接的关键. GameMap(this)使得, this.game_map有GameMap类里面的属性,
// 而我们要用到的就是this.game_map.ctx这块canvas画布
this.game_map = new GameMap(this);
this.players = [
new Kyo(this, {
id: 0,
x: 200,
y: 0,
width: 120,
height: 200,
color: 'blue',
}),
new Kyo(this, {
id: 1,
x: 900,
y: 0,
width: 120,
height: 200,
color: 'red',
})
];
}
}
export {
KOF
}
这个文件夹下有个base.js文件,这个文件是一个基类,是做出动画效果的关键所在,所有需要每帧渲染一次的组件都要继承这个类,比如地图类,比如角色类. 主要用到requestAnimationFrame()函数,浏览器会在刷新的下一帧,执行该函数里的内容. 我们给这个函数传入一个每一帧需要执行一次的函数AC_GAME_OBJECTS_FRAME,且在该函数末尾递归调用该函数requestAnimationFrame(AC_GAME_OBJECTS_FRAME),就能实现动画的效果. 代码如下:
let AC_GAME_OBJECTS = [];
class AcGameObject {
constructor() {
AC_GAME_OBJECTS.push(this);
this.timedelta = 0;
this.has_call_start = false;
}
start() { // 初始执行一次
}
update() { // 每一帧执行一次(除了第一帧以外)
}
destroy() { // 删除当前对象
for (let i in AC_GAME_OBJECTS) {
if (AC_GAME_OBJECTS[i] === this) {
AC_GAME_OBJECTS.splice(i, 1);
break;
}
}
}
}
let last_timestamp;
let AC_GAME_OBJECTS_FRAME = (timestamp) => {
for (let obj of AC_GAME_OBJECTS) {
if (!obj.has_call_start) {
obj.start();
obj.has_call_start = true;
} else {
obj.timedelta = timestamp - last_timestamp;
obj.update();
}
}
last_timestamp = timestamp;
requestAnimationFrame(AC_GAME_OBJECTS_FRAME);
}
requestAnimationFrame(AC_GAME_OBJECTS_FRAME);
export {
AcGameObject
}
这是一个关键的js文件,因为游戏的运行不是直接在div盒子上运行的,是在canvas画布上运行的,所以,这个文件里面的GmaeMap类很关键.
首先,我们需要建立一个索引,这个索引指向的是上一个类里面的this,而在这个类里面我们要把它记录下来,代码如下:
this.root = root(这里的root就是上一个类里传入的this);
这一点相当重要,这样的话就方便我们在类与类之间建立连接,在这个类里面,我们可以做到对players操作,只需要this.root.players就行了.
然后,它里面的构造函数创建了一个canvas,代码如下:
this.$canvas = $(`<canvas width="1280" height="720" tabindex=0></canvas>`);
然后,取出我们要操作的canvas属性,代码如下:
this.ctx = this.$canvas[0].getContext('2d');
然后,让div去append上这个canvas,代码如下:
this.root.$kof.append(this.$canvas);
然后,让canvas聚焦,代码如下:
this.$canvas.focus();
然后,new一个需要接受各种输入的controller对象,代码如下:
this.controller = new Controller(this.$canvas);
最后,在已有的div上再装饰一些东西,比如血条和时间,代码如下:
this.root.$kof.append($(
`<div class="kof-head">
<div class="kof-head-hp-0"><div><div></div></div></div>
<div class="kof-head-timer">60</div>
<div class="kof-head-hp-1"><div><div></div></div></div>
</div>`
))
this.time_left = 60000;
this.$timer = this.root.$kof.find(".kof-head-timer");
整体代码如下:
import {AcGameObject} from '/static/js/ac_game_object/base.js';
import { Controller } from '../controller/base.js';
export class GameMap extends AcGameObject {
constructor(root) {
super();
this.root = root;
this.$canvas = $(`<canvas width="1280" height="720" tabindex=0></canvas>`);
this.ctx = this.$canvas[0].getContext('2d');
this.root.$kof.append(this.$canvas);
this.$canvas.focus();
this.controller = new Controller(this.$canvas);
this.root.$kof.append($(
`<div class="kof-head">
<div class="kof-head-hp-0"><div><div></div></div></div>
<div class="kof-head-timer">60</div>
<div class="kof-head-hp-1"><div><div></div></div></div>
</div>`
))
this.time_left = 60000;
this.$timer = this.root.$kof.find(".kof-head-timer");
}
start() {
}
update() {
this.time_left -= this.timedelta;
if (this.time_left < 0) {
this.time_left = 0;
let [a, b] = this.root.players;
if (a.status !== 6 && b.status !== 6) {
a.status = b.status = 6;
a.frame_current_cnt = b.frame_current_cnt = 0;
a.vx = b.vx = 0;
}
}
this.$timer.text(parseInt(this.time_left / 1000));
this.render();
}
render() {
this.ctx.clearRect(0, 0, this.ctx.canvas.width, this.ctx.canvas.height);
// this.ctx.fillStyle = 'black';
// this.ctx.fillRect(0, 0, this.$canvas.width(), this.$canvas.height());
}
}
这个文件里的内容也是这个项目的主要部分,也就是角色的部分,里面定义了很多关于角色的属性,比如血量,为止,速度,等等,其实我更想说明的是下面两句代码:
this.ctx = this.root.game_map.ctx;
this.pressed_keys = this.root.game_map.controller.pressed_keys;
这两句代码很关键,也是上个小节里面我所说到的,我们通过this.root定位到上个类里面的this,而上个类里面又有game_map属性,game_map里面又有我们需要操作的画布game_map.ctx,同时里面也有controller. 而controller又有pressed_keys属性,这样的话,我们就可以对角色进行各种操作了.
代码如下:
import { AcGameObject } from "../ac_game_object/base.js";
export class Player extends AcGameObject {
constructor(root, info) { // 用root定位到KOF
super();
this.root = root;
this.id = info.id;
this.x = info.x;
this.y = info.y;
this.width = info.width;
this.height = info.height;
this.color = info.color;
this.direction = 1; // 方向
this.vx = 0;
this.vy = 0;
this.speedx = 400; // 水平速度
this.speedy = -1000; // 跳起初始速度
this.gravity = 50;
this.ctx = this.root.game_map.ctx;
this.pressed_keys = this.root.game_map.controller.pressed_keys;
// 角色状态 0: 静止, 1:向前, 2: 向后, 3: 跳跃, 4: 攻击, 5: 被打, 6: 死亡
this.status = 3; // 初始时是跳跃状态
this.animations = new Map();
this.frame_current_cnt = 0; // 每过一帧记录一下
this.frame_rate = 5;
this.hp = 100;
this.$hp = this.root.$kof.find(`.kof-head-hp-${this.id}>div`);
// this.$hp1 = this.root.$kof.find(`.kof-head-hp-${this.id}>div>div`)
this.$hp1 = this.$hp.find('div');
}
start() {
}
update_move() {
this.vy += this.gravity;
this.x += this.vx * this.timedelta / 1000;
this.y += this.vy * this.timedelta / 1000;
if (this.y > 450) {
this.y = 450
this.vy = 0;
if (this.status === 3) this.status = 0;
}
if (this.x < 0) {
this.x = 0;
} else if (this.x + this.width > this.root.game_map.$canvas.width()) {
this.x = this.root.game_map.$canvas.width() - this.width;
}
}
update_control() {
let w, a, d, space;
if (this.id === 0) {
w = this.pressed_keys.has('w');
a = this.pressed_keys.has('a');
d = this.pressed_keys.has('d');
space = this.pressed_keys.has(' ');
} else {
w = this.pressed_keys.has('ArrowUp');
a = this.pressed_keys.has('ArrowLeft');
d = this.pressed_keys.has('ArrowRight');
space = this.pressed_keys.has('Enter');
}
if (this.status === 0 || this.status === 1) {
if (space) {
this.status = 4;
this.vx = 0;
this.frame_current_cnt = 0;
} else if (w) {
if (d) {
this.vx = this.speedx;
} else if (a) {
this.vx = -this.speedx;
} else {
this.vx = 0;
}
this.vy = this.speedy;
this.status = 3;
this.frame_current_cnt = 0;
} else if (d) {
this.vx = this.speedx;
this.status = 1;
} else if (a) {
this.vx = -this.speedx;
this.status = 1;
} else {
this.vx = 0;
this.status = 0;
}
}
}
update_direction() {
if (this.direction === 6) return;
let players = this.root.players;
if (players[0] && players[1]) {
let me = this, you = players[1 - this.id];
if (me.x < you.x) me.direction = 1;
else me.direction = -1;
}
}
is_attack() {
if (this.status === 6) return;
this.status = 5;
this.frame_current_cnt = 0;
this.hp = Math.max(this.hp - 10, 0);
// this.$hp.width(this.$hp.parent().width() * this.hp / 100);
// 渐变效果
this.$hp1.animate({
width: this.$hp.parent().width() * this.hp / 100
}, 500);
this.$hp.animate({
width: this.$hp.parent().width() * this.hp / 100
}, 800);
if (this.hp <= 0) {
this.status = 6;
this.frame_current_cnt = 0;
this.vx = 0;
}
}
is_collision(r1, r2) {
if (Math.max(r1.x1, r2.x1) > Math.min(r1.x2, r2.x2))
return false;
if (Math.max(r1.y1, r2.y1) > Math.min(r1.y2, r2.y2))
return false;
return true;
}
update_attack() {
if (this.status === 4 && this.frame_current_cnt === 18) {
let me = this, you = this.root.players[1 - this.id];
let r1;
if (this.direction > 0) {
r1 = {
x1: me.x + 120,
y1: me.y + 40,
x2: me.x + 120 + 100,
y2: me.y + 40 + 20,
};
} else {
r1 = {
x1: me.x + me.width - 120 - 100,
y1: me.y + 40,
x2: me.x + me.width - 120 - 100 + 100,
y2: me.y + 40 + 20,
};
}
let r2 = {
x1: you.x,
y1: you.y,
x2: you.x + you.width,
y2: you.y + you.height
};
if (this.is_collision(r1, r2)) {
you.is_attack();
}
}
}
update() {
this.update_control();
this.update_move();
this.update_direction();
this.update_attack();
this.render();
}
render() {
// this.ctx.fillStyle = 'blue';
// this.ctx.fillRect(this.x, this.y, this.width, this.height);
// if (this.direction > 0) {
// this.ctx.fillStyle = 'red';
// this.ctx.fillRect(this.x + 120, this.y + 40, 100, 20);
// } else {
// this.ctx.fillStyle = 'red';
// this.ctx.fillRect(this.x + this.width - 120 - 100, this.y + 40, 100, 20);
// }
let status = this.status;
if (this.status === 1 && this.direction * this.vx < 0) status = 2;
let obj = this.animations.get(status);
if (obj && obj.loaded) {
if (this.direction > 0) {
let k = parseInt(this.frame_current_cnt / obj.frame_rate) % obj.frame_cnt;
let image = obj.gif.frames[k].image;
this.ctx.drawImage(image, this.x, this.y + obj.offset_y, image.width * obj.scale, image.height * obj.scale);
} else {
this.ctx.save();
this.ctx.scale(-1, 1);
this.ctx.translate(-this.root.game_map.$canvas.width(), 0);
let k = parseInt(this.frame_current_cnt / obj.frame_rate) % obj.frame_cnt;
let image = obj.gif.frames[k].image;
this.ctx.drawImage(image, this.root.game_map.$canvas.width() - this.x - this.width, this.y + obj.offset_y, image.width * obj.scale, image.height * obj.scale);
this.ctx.restore();
}
}
if (status === 4 || status == 5 || status === 6) {
if (this.frame_current_cnt === obj.frame_rate * (obj.frame_cnt - 1)) {
if (status === 6) {
this.frame_current_cnt--;
} else {
this.status = 0;
}
}
}
this.frame_current_cnt++;
}
}
这些文件是某个具体角色的文件,它们需要继承Player,但是因为每个人都有自己各自的动画,所以,需要单独开个类,来展示独特的效果. kyo的代码如下:
import { Player } from "./base.js";
import { GIF } from "../utils/gif.js";
export class Kyo extends Player {
constructor (root, info) {
super(root, info);
this.init_animations();
}
init_animations() {
let outer = this;
let offsets = [0, -22, -22, -140, 0, 0, 0];
for (let i = 0; i < 7; i++) {
let gif = GIF();
gif.load(`/static/images/player/kyo/${i}.gif`);
this.animations.set(i, {
gif: gif,
frame_cnt: 0, // 总图片数
frame_rate: 5, // 防止人物抖动过快
offset_y: offsets[i], // y方向偏移量
loaded: false, // 是否加载完整
scale: 2, // 放大多少倍
});
gif.onload = function() {
let obj = outer.animations.get(i);
obj.frame_cnt = gif.frames.length;
obj.loaded = true;
if (i === 3) {
obj.frame_rate = 4;
}
}
}
}
}
这个utils文件夹里面存的都是我们需要用到的辅助工具,比如如何在canvas上加上gif动图. 代码就在gif.js文件里面. 这里就不展示了.
这里面存的都是用户的案件输入,我们需要根据输入进行相应处理,代码如下:
export class Controller {
constructor($canvas) {
this.$canvas = $canvas;
// 因为会重复摁无数次, 所以Set来存储用户按的键
this.pressed_keys = new Set();
this.start();
}
start() {
let outer = this;
this.$canvas.keydown( (e) => {
outer.pressed_keys.add(e.key);
console.log(e.key);
});
this.$canvas.keyup( (e) => {
outer.pressed_keys.delete(e.key);
})
}
}