Web学习(五)中期项目-简易拳皇
项目参考地址:https://git.acwing.com/yxc/kof
游戏的基本原理:主要依靠requestAnimationFrame实现,该函数会在下次浏览器刷新页面之前执行一次,通常会用递归写法使其每秒执行60次func函数,每秒画60次物体,通过坐标改变物体位置,人眼看到的结果就是物体在移动。
1.创建表示游戏窗口的类
html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="/static/css/base.css">
<script src="https://cdn.acwing.com/static/jquery/js/jquery-3.3.1.min.js"></script>
<title>拳皇</title>
</head>
<body>
<div id="kof"></div>
<script type="module">
import { KOF } from '/static/js/base.js'
let kof = new KOF('kof');
</script>
</body>
</html>
css:
#kof {
width: 1280px;
height: 720px;
background-image: url('/static/images/background/0.gif');
background-size: 200% 100%;
background-position: top;
position: absolute;
}
base.js:
KOF窗口大类包含两个小类:玩家与地图
// 创建一个类KOF表示当前的大窗口
class KOF {
constructor(id) { //构造函数,需要传入id
this.$kof = $('#' + id);//jQuery选择器
}
}
export {
KOF
}
2.创建ac_game_object类
game_map,player等对象都需要在每秒画60次以达到移动效果,所以我们创建一个共同的基类ac_game_object, game_map,player等对象继承ac_game_object基类即可。
let AC_GAME_OBJECTS = []
class AcGameObject {
constructor() {
AC_GAME_OBJECTS.push(this); //存储对象
this.timedelta = 0; //时间间隔,每个object对象都需要存储当前一帧与上一帧的时间间隔,每个物体的速度取决于时间间隔
this.has_call_start = false; //表示当前对象是否调用过start函数
}
start() { //初始执行一次
}
updats() { //每一帧执行一次(第一帧除外)
}
destroy() { //删除对象
for (let i in AC_GAME_OBJECTS) { //for in 是枚举下标,for of是枚举值
if (AC_GAME_OBJECTS[i] === this) {
AC_GAME_OBJECTS.splice(i, 1);
break;
}
}
}
}
let last_timestamp; //上一帧执行时刻
let AC_GAME_OBJECTS_FRAME = (timestamp) => { //timestamp它表示requestAnimationFrame() 开始去执行回调函数的时刻,时间戳
for (let obj of AC_GAME_OBJECTS) {
if (!obj.has_call_start) { //如果对象没有执行过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
}
3.定义game_map对象
import { AcGameObject } from '/static/js/ac_game_object/base.js';
export class GameMap extends AcGameObject { //继承
constructor(root) {
super();
this.root = root;
this.$canvas = $('<canvas width="1280" height="720" tabindex=0></canvas>'); //jQuery用法,tabindex=0使得可以聚焦读取键盘输入
this.ctx = this.$canvas[0].getContext('2d'); //取出canvas,这里参考canvas用法
this.root.$kof.append(this.$canvas); //将canvas加入id为kof的div里
this.$canvas.focus(); //使得convas可以聚焦
}
start() { //开始时执行
}
update() { //每一帧执行
this.render();
}
render() { //渲染函数中需要将每一帧地图都清空,否则物体运动过程会一直停留在地图上
this.ctx.clearRect(0, 0, this.ctx.canvas.width, this.ctx.canvas.height);
}
}
4.定义player对象
import { AcGameObject } from "/static/js/ac_game_object/base.js";
export class Player extends AcGameObject {
constructor(root, info) { //将初始参数放至构造函数中,这里root表示大类KOF,大类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; //这里引入canvas便于下一步操作
}
start() {
}
update() {
this.render();
}
render() {
//测试代码
this.ctx.fillstyle = this.color;
this.ctx.fillRect(this.x, this.y, this.width, this.height);
}
}
在主js中创建地图与玩家对象
import { GameMap } from '/static/js/game_map/base.js';
import { Player } from '/static/js/player/base.js';
// 创建一个类KOF表示当前的大窗口,此大类包含玩家对象,地图对象
class KOF {
constructor(id) { //构造函数,需要传入id
this.$kof = $('#' + id);//jQuery选择器
//创建地图
this.game_map = new GameMap(this);
//创建玩家
this.players = [ //player对象需要传入root与info
new Player(this, {
id: 0,
x: 200,
y: 0,
width: 120,
height: 200,
color: 'blue',
}),
new Player(this, {
id: 1,
x: 900,
y: 0,
width: 120,
height: 200,
color: 'red',
}),
];
}
}
export {
KOF
}
5.定义controller对象
controller对象用于读取键盘输入
export class Controller {
constructor($canvas) {
this.$canvas = $canvas;
this.pressed_keys = new Set(); //set用于存储当前按住了哪个键
this.start();
}
start() {
let outer = this; //这里pressed_keys是Controller对象下的值,不是convas的,所以要使用outer
this.$canvas.keydown(function (e) { //按下
outer.pressed_keys.add(e.key); //这里this指的是canvas,outer指的是controller
});
this.$canvas.keyup(function (e) { //放开
outer.pressed_keys.delete(e.key);
});
}
}
6.在player对象中添加update_control函数
update_control() { //更新按下的键位
let w, a, d, space;
if (this.id === 0) { // 玩家1
w = this.pressed_keys.has('w');
a = this.pressed_keys.has('a');
d = this.pressed_keys.has('d');
space = this.pressed_keys.has(' ');
} else { //玩家2
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;
}
}
}
7.添加动画(人物图片)
参考:https://stackoverflow.com/questions/48234696/how-to-put-a-gif-with-canvas
新建utils文件夹中存放参考的gif.js
player中新建kyo.js:
import { Player } from '/static/js/player/base.js';
import { GIF } from '/static/js/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]; //偏移量,因为图片高度的原因,对应7种不同状态时回到同一水平线需要的偏移量
for (let i = 0; i < 7; i++) { //对图片循环处理
let gif = GIF();
gif.load(`/static/images/player/kyo/${i}.gif`);
this.animations.set(i, { //这里的animations是在player中定义过的Map用来存储键值对,set是插入键值对
gif: gif,
frame_cnt: 0, // 总图片数
frame_rate: 5, // 每5帧过度一次
offset_y: offsets[i], // y方向偏移量
loaded: false, // 是否加载完整
scale: 2, // 放大多少倍
});
gif.onload = function () {
let obj = outer.animations.get(i); //Map用法get是查找关键字
obj.frame_cnt = gif.frames.length;
obj.loaded = true; //表示加载完整
if (i === 3) { //如果是第3种图片即跳跃状态,更改帧率
obj.frame_rate = 4;
}
}
}
}
}
在player中的base,js修改render渲染函数:
render() { //渲染函数
// //测试代码,画出矩形
// this.ctx.fillstyle = this.color;
// this.ctx.fillRect(this.x, this.y, this.width, this.height);
let status = this.status; //取出当前状态
//如果和速度反方向,状态变为2(后退)
if (this.status === 1 && this.direction * this.vx < 0) status = 2;
let obj = this.animations.get(status); //将当前状态status传入animations中
if (obj && obj.loaded) {
if (this.direction > 0) { //如果是正向
//frame_current_cnt表示当前记录了多少帧
//frame_current_cnt % obj.frame_cnt 当前帧数 % 图片总帧数,因为图片需要循环播放
//frame_rate控制图片播放速率
let k = parseInt(this.frame_current_cnt / obj.frame_rate) % obj.frame_cnt;
//取出第k张图片
let image = obj.gif.frames[k].image;
//画出图像, this.y + obj.offset_y:纵方向加上偏移量, image.width * obj.scale:图片大小乘以缩放倍数
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); // x坐标乘-1,y坐标不变,即水平反转
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++; //下一帧
}
8.让俩位玩家位置对称
update_direction() { //更新位置(面朝方向)
if (this.status === 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;
}
}
9.攻击与被攻击实现
攻击函数:
update_attack() { //更新攻击函数
if (this.status === 4 && this.frame_current_cnt === 18) { //处于攻击状态,且在18帧时(18帧时拳头挥出去)
let me = this, you = this.root.players[1 - this.id];
let r1;
//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,
};
}
//r2为人物模型的长方形
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(); //调用被攻击函数
}
}
}
碰撞检测函数:
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;
}
被攻击函数:
is_attack() { //被攻击函数
if (this.status === 6) return;
this.status = 5;
this.frame_current_cnt = 0;
}
10.增加血条
更改css
#kof {
width: 1280px;
height: 720px;
background-image: url('/static/images/background/0.gif');
background-size: 200% 100%;
background-position: top;
position: absolute;
}
#kof>.kof-head {
width: 100%;
height: 80px;
position: absolute;
top: 0;
display: flex;
align-items: center;
}
/* 血条0框 */
#kof>.kof-head>.kof-head-hp-0 {
height: 40px;
width: calc(50% - 60px);
margin-left: 20px;
border: white 5px solid;
border-right: none;
box-sizing: border-box;
}
/* 计时器 */
#kof>.kof-head>.kof-head-timer {
height: 60px;
width: 80px;
background-color: orange;
border: white 5px solid;
box-sizing: border-box;
color: white;
font-size: 30px;
font-weight: 800;
text-align: center;
line-height: 50px;
user-select: none;
}
/* 血条1框 */
#kof>.kof-head>.kof-head-hp-1 {
height: 40px;
width: calc(50% - 60px);
border: white 5px solid;
border-left: none;
box-sizing: border-box;
}
/* 血条0 -掉血*/
#kof>.kof-head>.kof-head-hp-0>div {
background-color: red;
height: 100%;
width: 100%;
float: right;
}
/* 血条1 -掉血*/
#kof>.kof-head>.kof-head-hp-1>div {
background-color: red;
height: 100%;
width: 100%;
}
/* 血条0 */
#kof>.kof-head>.kof-head-hp-0>div>div {
background-color: lightgreen;
height: 100%;
width: 100%;
float: right;
}
/* 血条1 */
#kof>.kof-head>.kof-head-hp-1>div>div {
background-color: lightgreen;
height: 100%;
width: 100%;
}
gamemap中base.js地图中添加血条元素:
import { AcGameObject } from '/static/js/ac_game_object/base.js';
import { Controller } from '/static/js/controller/base.js';
export class GameMap extends AcGameObject { //继承
constructor(root) {
super();
this.root = root;
this.$canvas = $('<canvas width="1280" height="720" tabindex=0></canvas>'); //jQuery用法,tabindex=0使得可以聚焦读取键盘输入
this.ctx = this.$canvas[0].getContext('2d'); //取出canvas,这里参考canvas用法
this.root.$kof.append(this.$canvas); //将canvas加入id为kof的div里
this.$canvas.focus(); //使得convas可以聚焦
this.controller = new Controller(this.$canvas); //将controller加入地图中,用于读取键盘输入
//增加地图上血条div
//地图上增加计时器div
//增加地图上血条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>`));
}
start() { //开始时执行
}
update() { //每一帧执行
this.render();
}
render() { //渲染函数中需要将每一帧地图都清空,否则物体运动过程会一直停留在地图上
this.ctx.clearRect(0, 0, this.ctx.canvas.width, this.ctx.canvas.height);
}
}
player文件夹下base.js增加被攻击函数,实现扣血逻辑:
is_attack() { //被攻击函数
if (this.status === 6) return;
this.status = 5;
this.frame_current_cnt = 0;
this.hp = Math.max(this.hp - 20, 0);
this.$hp_div.animate({ //jquery中.animate可以实现渐变效果
width: this.$hp.parent().width() * this.hp / 100 //通过更改血条宽度实现扣血的显示
}, 300); //里面的div变化慢一些,实现掉血效果
this.$hp.animate({
width: this.$hp.parent().width() * this.hp / 100
}, 600); //外面的div变化快一些
if (this.hp <= 0) { //死亡
this.status = 6;
this.frame_current_cnt = 0;
this.vx = 0;
}
}
11.增加计时器
在gamemap文件夹base.js中实现计时器逻辑
import { AcGameObject } from '/static/js/ac_game_object/base.js';
import { Controller } from '/static/js/controller/base.js';
export class GameMap extends AcGameObject { //继承
constructor(root) {
super();
this.root = root;
this.$canvas = $('<canvas width="1280" height="720" tabindex=0></canvas>'); //jQuery用法,tabindex=0使得可以聚焦读取键盘输入
this.ctx = this.$canvas[0].getContext('2d'); //取出canvas,这里参考canvas用法
this.root.$kof.append(this.$canvas); //将canvas加入id为kof的div里
this.$canvas.focus(); //使得convas可以聚焦
this.controller = new Controller(this.$canvas); //将controller加入地图中,用于读取键盘输入
//增加地图上血条div
//地图上增加计时器div
//增加地图上血条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");
}
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)); //修改time文本时间
this.render();
}
render() { //渲染函数中需要将每一帧地图都清空,否则物体运动过程会一直停留在地图上
this.ctx.clearRect(0, 0, this.ctx.canvas.width, this.ctx.canvas.height);
}
}