项目实战–拳皇

创建项目

新建文件夹KOF,子文件夹有static、templates,分别用来存放静态文件和html文件,static子文件夹有js,css,images,分别用来存放js代码,css文件和图片等资源

创建仓库

git init

初始化文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>拳皇</title>
</head>

<body>

</body>

</html>

设置网站图标

将图标文件放入/static/images/logo/下

在head里面添加以下代码

<link rel="icon" href="/static/images/logo/logo.png">

引入css文件

新建css文件在/static/css/base.css

在head里面添加以下代码

<link rel="stylesheet" href="/static/css/base.css">

引入jQuery库

在head里面添加以下代码

<script src="https://cdn.acwing.com/static/jquery/js/jquery-3.3.1.min.js"></script>

定义KOF类

在/static/js/base.js中

1
2
3
4
5
6
7
export class KOF {
// 参数id用于指定一个DOM元素的id,目的是后续通过这个id来获取对应的DOM元素
constructor(id) {
// 使用jQuery选择器,通过传入的id查找对应的DOM元素,并将其赋值给实例属性this.$kof
this.$kof = $('#' + id);
}
}

写html

body块中添加代码

1
2
3
4
5
6
7
8
<body>
<div id="kof"></div>

<script type="module">
import { KOF } from '/static/js/base.js';
let kof = new KOF('kof');
</script>
</body>

添加背景

在/static/css/base.css文件,给html中的id为kof的div添加样式

1
2
3
4
5
6
7
8
9
10
11
12
13
/* 选择id为kof的元素来应用以下样式规则 */
#kof {
/* 设置元素的宽度为1280像素 */
width: 1280px;
/* 设置元素的高度为720像素 */
height: 720px;
/* 设置元素的背景图片,图片的路径为/static/images/background/background.gif */
background-image: url(/static/images/background/background.gif);
/* 设置背景图片的尺寸,使其在水平方向拉伸为原本宽度的200%,垂直方向保持100%(即原本高度),这样可以对背景图片进行缩放以适应元素大小等情况 */
background-size: 200% 100%;
/* 设置背景图片在元素中的位置,这里将其定位在顶部,也就是垂直方向靠上的位置 */
background-position: top;
}

编写一个简易的游戏引擎

在/static/js/新建一个文件夹MyGameObject/,在里面新建一个base.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
//定义一个全局变量,用来存储所有的游戏对象,实现所有对象每一帧的更新
let MYGAMEOBJECTS = [];

//定义一个基类,所有的游戏对象都继承自这个类
class MyGameObject {
constructor() {
//将实例添加到全局变量MYGAMEOBJECTS中
MYGAMEOBJECTS.push(this);
//用来存储当前这帧和上一帧的时间差
this.timedelta = 0;
//表示游戏对象是否已经调用过start方法
this.has_called_start = false;
}

//初始执行一次
start() {

}

//每一帧都会执行
update() {

}

//销毁游戏对象
destroy() {
//从全局变量MYGAMEOBJECTS中移除当前对象
for (let i in MYGAMEOBJECTS) {
if (MYGAMEOBJECTS[i] === this) {
MYGAMEOBJECTS.splice(i, 1);
brreak;
}
}
}
}

// 上一帧的时间戳,用于记录上一次执行相关逻辑时的时间,
// 初始时它的值应该是未定义的(在代码开始执行前没有被赋值),后续会在函数中被更新赋值
let last_timestamp;

// 定义一个名为MYGAMEOBJECTSFRAME的函数,它接受一个参数timestamp,这个参数表示当前时间戳
// 该函数主要用于处理游戏对象(假设MYGAMEOBJECTS是一个游戏对象数组)每一帧的更新逻辑
let MYGAMEOBJECTSFRAME = (timestamp) => {
// 遍历游戏对象数组MYGAMEOBJECTS中的每一个对象
for (let obj of MYGAMEOBJECTS) {
// 如果当前游戏对象还没有调用过start方法(可能意味着它还没开始初始化相关逻辑)
if (!obj.has_called_start) {
// 调用该游戏对象的start方法,进行一些初始化相关的操作,比如设置初始状态等
obj.start();
// 将该对象的has_called_start属性标记为true,表示已经调用过start方法了,下次就不会再重复调用start
obj.has_called_start = true;
} else {
// 计算当前帧与上一帧的时间间隔(以时间戳差值来表示),
// 这个时间间隔可以用于后续根据时间来更新游戏对象的状态等,比如移动的距离等与时间相关的计算
obj.timedelta = timestamp - last_timestamp;
// 调用游戏对象的update方法,基于计算出的时间间隔等因素来更新游戏对象的状态,比如更新位置、动画等
obj.update();
}
}
// 将当前的时间戳赋值给last_timestamp,以便在下一帧计算时间间隔时使用,更新上一帧时间戳记录
last_timestamp = timestamp;
// 请求浏览器在下一次重绘之前调用MYGAMEOBJECTSFRAME函数,实现游戏循环,不断更新游戏对象状态
requestAnimationFrame(MYGAMEOBJECTSFRAME);
}

// 发起首次请求,让浏览器开始调用MYGAMEOBJECTSFRAME函数,启动整个游戏对象更新循环机制
requestAnimationFrame(MYGAMEOBJECTSFRAME);

export { MyGameObject }

实现地图对象

在/static/js/新建一个文件夹GameMap/,在里面新建一个base.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
import { MyGameObject } from '/static/js/MyGameObject/base.js';

export class GameMap extends MyGameObject {
// 类的构造函数,在创建GameMap类的实例时会被调用,接收一个参数root(即MyGameObject对象)
constructor(root) {
// 调用父类(MyGameObject)的构造函数,确保父类的初始化逻辑能够正常执行
super();
// 将传入的参数root赋值给当前类实例的root属性
this.root = root;
// 使用jQuery的操作方式,创建一个新的<canvas> HTML元素,设置其宽度为1280像素,高度为720像素,并设置tabindex属性为0,方便后续获取焦点等操作
// 这里将创建的<canvas>元素对象赋值给当前类实例的$canvas属性,方便后续对这个canvas元素进行各种操作
this.$canvas = $('<canvas width="1280" height="720" tabindex=0></canvas>');
// 获取<canvas>元素的2D绘图上下文对象,通过访问$canvas对象中实际的DOM元素(使用[0]索引,这也是类似jQuery操作获取原生DOM元素的常见方式),再调用getContext('2d')方法来获取。
// 将获取到的绘图上下文对象赋值给当前类实例的ctx属性,后续就可以使用这个ctx属性来在canvas上进行绘图相关的操作,比如绘制图形、清除区域等
this.ctx = this.$canvas[0].getContext('2d');
// 将创建好的<canvas>元素添加到this.root.$kof所指向的DOM元素内部(这里$kof是MyGameObject对象的一个属性,指向一个DOM元素,用于承载这个canvas元素),使其在页面上显示出来
this.root.$kof.append(this.$canvas);
// 让<canvas>元素获取焦点,是为了后续接收键盘事件
this.$canvas.focus();
}

start() {

}

update() {
this.render();
}

// 定义render方法,用于在canvas上进行实际的渲染操作。
// 这里调用了绘图上下文对象(this.ctx)的clearRect方法,用于清除整个canvas画布的内容,参数分别指定了清除矩形区域的左上角坐标(0, 0)以及矩形的宽度和高度(使用canvas的宽度和高度属性),
// 这样每次渲染前先清除上一次的画面内容,以便绘制新的内容,实现画面的动态更新效果
render() {
this.ctx.clearRect(0, 0, this.ctx.canvas.width, this.ctx.canvas.height);
}
}

将地图对象实例化出来

在/static/js/base.js中

1
2
3
4
5
6
7
8
9
10
11
12
import { GameMap } from '/static/js/GameMap/base.js';


export class KOF {
// 参数id用于指定一个DOM元素的id,目的是后续通过这个id来获取对应的DOM元素
constructor(id) {
// 使用jQuery选择器,通过传入的id查找对应的DOM元素,并将其赋值给实例属性this.$kof
this.$kof = $('#' + id);
// 创建一个GameMap对象实例
this.game_map = new GameMap(this);
}
}

实现玩家对象

新建文件/static/js/Player/base.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
import { MyGameObject } from '/static/js/MyGameObject/base.js';

export class Player extends MyGameObject {
// 类的构造函数,在创建GameMap类的实例时会被调用,接收一个参数root(即MyGameObject对象)和info(玩家的信息)
constructor(root, info) {
super();
this.root = root;
// 从info对象中获取玩家角色的id信息,并赋值给当前类实例的this.id属性,用于标识玩家角色的唯一性
this.id = info.id;
// 从info对象中获取玩家角色的初始x坐标信息,并赋值给当前类实例的this.x属性,用于后续确定玩家在游戏画面中的水平位置
this.x = info.x;
// 同理,从info对象中获取玩家角色的初始y坐标信息,并赋值给当前类实例的this.y属性,用于确定玩家在游戏画面中的垂直位置
this.y = info.y;
// 获取玩家角色的宽度信息,赋值给this.width属性,用于后续绘制玩家角色图形等操作中确定图形的宽度尺寸
this.width = info.width;
// 获取玩家角色的高度信息,赋值给this.height属性,用于确定图形的高度尺寸
this.height = info.height;
// 获取玩家角色的颜色信息,赋值给this.color属性,后续在绘制玩家角色时会使用这个颜色来填充图形
this.color = info.color;
// 初始化玩家角色在水平方向的速度分量为0,后续可以根据游戏中的操作或者逻辑来改变这个值,以实现水平方向的移动
this.vx = 0;
// 初始化玩家角色在垂直方向的速度分量为0,同样可根据实际情况改变,例如实现跳跃等垂直方向的运动
this.vy = 0;
// 定义玩家角色在水平方向的移动速度,这里设置为400,用于控制玩家角色在水平方向移动的快慢程度
this.speedx = 400;
// 定义玩家角色在跳跃时的初始垂直速度,设置为-1000,用于控制跳跃的初始力度等相关特性
this.speedy = -1000;
//获取游戏地图(通过this.root关联到游戏根对象,再访问其game_map属性获取游戏地图对象)的绘图上下文对象(ctx)
this.ctx = this.root.game_map.ctx;
}

start() {

}

update() {
this.render();
}

//表示玩家角色在游戏画面中的呈现形式(这里简单地将玩家角色用矩形来表示)
render() {
this.ctx.fillStyle = this.color;
this.ctx.fillRect(this.x, this.y, this.width, this.height);
}
}

将玩家对象实例化

在/static/js/base.js中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import { GameMap } from '/static/js/GameMap/base.js';
import { Player } from '/static/js/Player/base.js';


export class KOF {
// 参数id用于指定一个DOM元素的id,目的是后续通过这个id来获取对应的DOM元素
constructor(id) {
// 使用jQuery选择器,通过传入的id查找对应的DOM元素,并将其赋值给实例属性this.$kof
this.$kof = $('#' + id);
// 创建一个GameMap对象实例
this.game_map = new GameMap(this);
// 创建一个玩家对象数组,用于存储所有的玩家对象
this.players = [
new Player(this, {
id: 0,
x: 200,
y: 0,
width: 120,
height: 200,
color: 'red'
}),
new Player(this, {
id: 1,
x: 900,
y: 0,
width: 120,
height: 200,
color: 'blue'
})
];
}
}

让玩家落到指定地面

在player类中添加重力属性,通过move函数实现玩家下落到指定位置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
import { MyGameObject } from '/static/js/MyGameObject/base.js';

export class Player extends MyGameObject {
// 类的构造函数,在创建GameMap类的实例时会被调用,接收一个参数root(即MyGameObject对象)和info(玩家的信息)
constructor(root, info) {
super();
this.root = root;
// 从info对象中获取玩家角色的id信息,并赋值给当前类实例的this.id属性,用于标识玩家角色的唯一性
this.id = info.id;
// 从info对象中获取玩家角色的初始x坐标信息,并赋值给当前类实例的this.x属性,用于后续确定玩家在游戏画面中的水平位置
this.x = info.x;
// 同理,从info对象中获取玩家角色的初始y坐标信息,并赋值给当前类实例的this.y属性,用于确定玩家在游戏画面中的垂直位置
this.y = info.y;
// 获取玩家角色的宽度信息,赋值给this.width属性,用于后续绘制玩家角色图形等操作中确定图形的宽度尺寸
this.width = info.width;
// 获取玩家角色的高度信息,赋值给this.height属性,用于确定图形的高度尺寸
this.height = info.height;
// 获取玩家角色的颜色信息,赋值给this.color属性,后续在绘制玩家角色时会使用这个颜色来填充图形
this.color = info.color;
// 初始化玩家角色在水平方向的速度分量为0,后续可以根据游戏中的操作或者逻辑来改变这个值,以实现水平方向的移动
this.vx = 0;
// 初始化玩家角色在垂直方向的速度分量为0,同样可根据实际情况改变,例如实现跳跃等垂直方向的运动
this.vy = 0;
// 定义玩家角色在水平方向的移动速度,这里设置为400,用于控制玩家角色在水平方向移动的快慢程度
this.speedx = 400;
// 定义玩家角色在跳跃时的初始垂直速度,设置为-1000,用于控制跳跃的初始力度等相关特性
this.speedy = -1000;
//获取游戏地图(通过this.root关联到游戏根对象,再访问其game_map属性获取游戏地图对象)的绘图上下文对象(ctx)
this.ctx = this.root.game_map.ctx;
// 定义玩家角色所受的重力加速度,设置为50
this.gravity = 50;
// 定义玩家角色的方向,正方向为1
this.direction = 1;
}

start() {

}

// 定义move方法,用于处理玩家角色的移动逻辑,根据当前的速度、重力以及时间间隔等因素来更新玩家角色的位置坐标
move() {
// 在垂直方向上,根据重力加速度来更新垂直速度分量,每一次调用该方法时,垂直速度都会增加相应的重力值,
// 以此模拟现实世界中物体在重力作用下速度不断变化的情况,实现玩家角色下落等垂直方向的运动效果
this.vy += this.gravity;
// 在水平方向上,根据当前水平速度分量(this.vx)、时间间隔(this.timedelta),来更新玩家角色的水平坐标(this.x),
// 这样能使玩家角色在水平方向上按照设定的速度和实际经过的时间进行移动,保证移动距离与时间和速度的关系符合物理逻辑
this.x += this.vx * this.timedelta / 1000;
// 同理,在垂直方向上,依据垂直速度分量(this.vy)、时间间隔(this.timedelta)来更新玩家角色的垂直坐标(this.y),
// 使得玩家角色在垂直方向上也能根据速度和时间准确地移动,比如跳起后随着时间上升和下落等
this.y += this.vy * this.timedelta / 1000;

// 判断玩家角色的垂直坐标(this.y)是否超过了某个特定值(这里是450),如果超过了,说明玩家角色到达了地面,
// 此时将玩家角色的垂直坐标设置为450,使其固定在这个位置上,同时将垂直速度分量重置为0,模拟玩家角色落地停止的状态,
// 避免玩家角色持续下落穿透地面等不符合实际物理逻辑的情况出现
if (this.y > 450) {
this.y = 450;
this.vy = 0;
}
}

update() {
this.move();
this.render();
}

//表示玩家角色在游戏画面中的呈现形式(这里简单地将玩家角色用矩形来表示)
render() {
this.ctx.fillStyle = this.color;
this.ctx.fillRect(this.x, this.y, this.width, this.height);
}
}

实现玩家移动和跳跃

由于玩家不同的状态有不同的贴图,所以需要先判断状态。在player类中添加一个状态属性。添加到Player类的构造函数

1
2
3
//0:idle, 1:forward, 2:backward, 3:jump, 4:attack, 5:injured, 6:dead
//初始值为3是因为玩家角色在游戏开始时是跳跃状态
this.status = 3;

另外需要实现一个控制器对象用来读取键盘输入,新建文件夹/static/js/controller/,新建文件base.js
/static/js/controller/base.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// 定义一个名为 Controller 的类,用于处理用户输入(如键盘事件)
export class Controller {
// 构造函数,接受一个 canvas 元素作为参数
constructor($canvas) {
// 将传入的 canvas 元素存储在实例变量中
this.$canvas = $canvas;

// 创建一个 Set 对象来存储当前按下的键
this.pressed_keys = new Set();

// 调用 start 方法,初始化事件监听
this.start();
}

// start 方法用于设置键盘事件监听器
start() {
// 保存当前 Controller 实例的引用,以便在事件回调中使用
let outer = this;

// 监听 canvas 元素的 keydown 事件
this.$canvas.keydown(function (e) {
// 当按键按下时,将按下的键(e.key)添加到 pressed_keys Set 中
outer.pressed_keys.add(e.key);
});

// 监听 canvas 元素的 keyup 事件
this.$canvas.keyup(function (e) {
// 当按键释放时,从 pressed_keys Set 中删除该键
outer.pressed_keys.delete(e.key);
});
}
}

把controller对象加到地图里,在GameMap类的构造函数里加入

1
this.controller = new Controller(this.$canvas);

把按下的键添加到Player类,在构造函数中。并且实现更新控制的函数:update_control()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
import { MyGameObject } from '/static/js/MyGameObject/base.js';

export class Player extends MyGameObject {
// 类的构造函数,在创建GameMap类的实例时会被调用,接收一个参数root(即MyGameObject对象)和info(玩家的信息)
constructor(root, info) {
super();
this.root = root;
// 从info对象中获取玩家角色的id信息,并赋值给当前类实例的this.id属性,用于标识玩家角色的唯一性
this.id = info.id;
// 从info对象中获取玩家角色的初始x坐标信息,并赋值给当前类实例的this.x属性,用于后续确定玩家在游戏画面中的水平位置
this.x = info.x;
// 同理,从info对象中获取玩家角色的初始y坐标信息,并赋值给当前类实例的this.y属性,用于确定玩家在游戏画面中的垂直位置
this.y = info.y;
// 获取玩家角色的宽度信息,赋值给this.width属性,用于后续绘制玩家角色图形等操作中确定图形的宽度尺寸
this.width = info.width;
// 获取玩家角色的高度信息,赋值给this.height属性,用于确定图形的高度尺寸
this.height = info.height;
// 获取玩家角色的颜色信息,赋值给this.color属性,后续在绘制玩家角色时会使用这个颜色来填充图形
this.color = info.color;
// 初始化玩家角色在水平方向的速度分量为0,后续可以根据游戏中的操作或者逻辑来改变这个值,以实现水平方向的移动
this.vx = 0;
// 初始化玩家角色在垂直方向的速度分量为0,同样可根据实际情况改变,例如实现跳跃等垂直方向的运动
this.vy = 0;
// 定义玩家角色在水平方向的移动速度,这里设置为400,用于控制玩家角色在水平方向移动的快慢程度
this.speedx = 400;
// 定义玩家角色在跳跃时的初始垂直速度,设置为-1000,用于控制跳跃的初始力度等相关特性
this.speedy = -1000;
//获取游戏地图(通过this.root关联到游戏根对象,再访问其game_map属性获取游戏地图对象)的绘图上下文对象(ctx)
this.ctx = this.root.game_map.ctx;
// 定义玩家角色所受的重力加速度,设置为50
this.gravity = 50;
// 定义玩家角色的方向,正方向为1
this.direction = 1;
//0:idle, 1:forward, 2:backward, 3:jump, 4:attack, 5:injured, 6:dead
//初始值为3是因为玩家角色在游戏开始时是跳跃状态
this.status = 3;
//用来存放玩家角色的按键状态
this.pressed_Keys = this.root.game_map.controller.pressed_Keys;
}

start() {

}

// 定义move方法,用于处理玩家角色的移动逻辑,根据当前的速度、重力以及时间间隔等因素来更新玩家角色的位置坐标
move() {
// 在垂直方向上,根据重力加速度来更新垂直速度分量,每一次调用该方法时,垂直速度都会增加相应的重力值,
// 以此模拟现实世界中物体在重力作用下速度不断变化的情况,实现玩家角色下落等垂直方向的运动效果
this.vy += this.gravity;
// 在水平方向上,根据当前水平速度分量(this.vx)、时间间隔(this.timedelta),来更新玩家角色的水平坐标(this.x),
// 这样能使玩家角色在水平方向上按照设定的速度和实际经过的时间进行移动,保证移动距离与时间和速度的关系符合物理逻辑
this.x += this.vx * this.timedelta / 1000;
// 同理,在垂直方向上,依据垂直速度分量(this.vy)、时间间隔(this.timedelta)来更新玩家角色的垂直坐标(this.y),
// 使得玩家角色在垂直方向上也能根据速度和时间准确地移动,比如跳起后随着时间上升和下落等
this.y += this.vy * this.timedelta / 1000;

// 判断玩家角色的垂直坐标(this.y)是否超过了某个特定值(这里是450),如果超过了,说明玩家角色到达了地面,
// 此时将玩家角色的垂直坐标设置为450,使其固定在这个位置上,同时将垂直速度分量重置为0,模拟玩家角色落地停止的状态,
// 避免玩家角色持续下落穿透地面等不符合实际物理逻辑的情况出现,落地后将玩家角色的状态设置为0
if (this.y > 450) {
this.y = 450;
this.vy = 0;
this.status = 0;
}
// 限制玩家角色在水平方向上的移动范围,避免超出画布边界
if (this.x < 0) {
this.x = 0;
} else if (this.x + this.width > this.root.game_map.$canvas.width()) {
this.x = 1280 - this.width;
}
}

// update_control 方法,用于根据按键状态更新玩家角色的控制逻辑
update_control() {
let w, a, d, space;
// 根据玩家角色的 id 确定按键映射
if (this.id === 0) {
w = this.pressed_Keys.has('w'); // 玩家 1 的跳跃键
a = this.pressed_Keys.has('a'); // 玩家 1 的向左键
d = this.pressed_Keys.has('d'); // 玩家 1 的向右键
space = this.pressed_Keys.has(' ');
} else {
w = this.pressed_Keys.has('ArrowUp'); // 玩家 2 的跳跃键
a = this.pressed_Keys.has('ArrowLeft'); // 玩家 2 的向左键
d = this.pressed_Keys.has('ArrowRight'); // 玩家 2 的向右键
space = this.pressed_Keys.has('Enter');
}

// 根据按键状态更新玩家角色的速度和状态
if (this.status === 0 || this.status === 1) {
if (w) { // 如果按下跳跃键
if (d) { // 同时按下向右键
this.vx = this.speedx; // 设置水平速度为正方向
} else if (a) { // 同时按下向左键
this.vx = -this.speedx; // 设置水平速度为负方向
} else {
this.vx = 0; // 否则水平速度为 0
}
this.vy = this.speedy; // 设置垂直速度为跳跃速度
this.status = 3; // 状态设置为跳跃状态
} else if (d) { // 如果按下向右键
this.vx = this.speedx; // 设置水平速度为正方向
this.status = 1; // 状态设置为移动状态
} else if (a) { // 如果按下向左键
this.vx = -this.speedx; // 设置水平速度为负方向
this.status = 1; // 状态设置为移动状态
} else { // 如果没有按下任何移动键
this.vx = 0; // 水平速度为 0
this.status = 0; // 状态设置为空闲状态
}
}
}

update() {
this.update_control();
this.move();
this.render();
}

//表示玩家角色在游戏画面中的呈现形式(这里简单地将玩家角色用矩形来表示)
render() {
this.ctx.fillStyle = this.color;
this.ctx.fillRect(this.x, this.y, this.width, this.height);
}
}

贴上动画

草薙京的动画素材放在/static/images/player/kyo/
因为用的素材是gif格式,所以需要用别人写好的处理gif的函数,我们引入进来

新建文件夹/static/js/utils/,在此新建一个gif.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
const GIF = function () {
// **NOT** for commercial use.
var timerID; // timer handle for set time out usage
var st; // holds the stream object when loading.
var interlaceOffsets = [0, 4, 2, 1]; // used in de-interlacing.
var interlaceSteps = [8, 8, 4, 2];
var interlacedBufSize; // this holds a buffer to de interlace. Created on the first frame and when size changed
var deinterlaceBuf;
var pixelBufSize; // this holds a buffer for pixels. Created on the first frame and when size changed
var pixelBuf;
const GIF_FILE = { // gif file data headers
GCExt: 0xF9,
COMMENT: 0xFE,
APPExt: 0xFF,
UNKNOWN: 0x01, // not sure what this is but need to skip it in parser
IMAGE: 0x2C,
EOF: 59, // This is entered as decimal
EXT: 0x21,
};
// simple buffered stream used to read from the file
var Stream = function (data) {
this.data = new Uint8ClampedArray(data);
this.pos = 0;
var len = this.data.length;
this.getString = function (count) { // returns a string from current pos of len count
var s = "";
while (count--) { s += String.fromCharCode(this.data[this.pos++]) }
return s;
};
this.readSubBlocks = function () { // reads a set of blocks as a string
var size, count, data = "";
do {
count = size = this.data[this.pos++];
while (count--) { data += String.fromCharCode(this.data[this.pos++]) }
} while (size !== 0 && this.pos < len);
return data;
}
this.readSubBlocksB = function () { // reads a set of blocks as binary
var size, count, data = [];
do {
count = size = this.data[this.pos++];
while (count--) { data.push(this.data[this.pos++]); }
} while (size !== 0 && this.pos < len);
return data;
}
};
// LZW decoder uncompressed each frames pixels
// this needs to be optimised.
// minSize is the min dictionary as powers of two
// size and data is the compressed pixels
function lzwDecode(minSize, data) {
var i, pixelPos, pos, clear, eod, size, done, dic, code, last, d, len;
pos = pixelPos = 0;
dic = [];
clear = 1 << minSize;
eod = clear + 1;
size = minSize + 1;
done = false;
while (!done) { // JavaScript optimisers like a clear exit though I never use 'done' apart from fooling the optimiser
last = code;
code = 0;
for (i = 0; i < size; i++) {
if (data[pos >> 3] & (1 << (pos & 7))) { code |= 1 << i }
pos++;
}
if (code === clear) { // clear and reset the dictionary
dic = [];
size = minSize + 1;
for (i = 0; i < clear; i++) { dic[i] = [i] }
dic[clear] = [];
dic[eod] = null;
} else {
if (code === eod) { done = true; return }
if (code >= dic.length) { dic.push(dic[last].concat(dic[last][0])) }
else if (last !== clear) { dic.push(dic[last].concat(dic[code][0])) }
d = dic[code];
len = d.length;
for (i = 0; i < len; i++) { pixelBuf[pixelPos++] = d[i] }
if (dic.length === (1 << size) && size < 12) { size++ }
}
}
};
function parseColourTable(count) { // get a colour table of length count Each entry is 3 bytes, for RGB.
var colours = [];
for (var i = 0; i < count; i++) { colours.push([st.data[st.pos++], st.data[st.pos++], st.data[st.pos++]]) }
return colours;
}
function parse() { // read the header. This is the starting point of the decode and async calls parseBlock
var bitField;
st.pos += 6;
gif.width = (st.data[st.pos++]) + ((st.data[st.pos++]) << 8);
gif.height = (st.data[st.pos++]) + ((st.data[st.pos++]) << 8);
bitField = st.data[st.pos++];
gif.colorRes = (bitField & 0b1110000) >> 4;
gif.globalColourCount = 1 << ((bitField & 0b111) + 1);
gif.bgColourIndex = st.data[st.pos++];
st.pos++; // ignoring pixel aspect ratio. if not 0, aspectRatio = (pixelAspectRatio + 15) / 64
if (bitField & 0b10000000) { gif.globalColourTable = parseColourTable(gif.globalColourCount) } // global colour flag
setTimeout(parseBlock, 0);
}
function parseAppExt() { // get application specific data. Netscape added iterations and terminator. Ignoring that
st.pos += 1;
if ('NETSCAPE' === st.getString(8)) { st.pos += 8 } // ignoring this data. iterations (word) and terminator (byte)
else {
st.pos += 3; // 3 bytes of string usually "2.0" when identifier is NETSCAPE
st.readSubBlocks(); // unknown app extension
}
};
function parseGCExt() { // get GC data
var bitField;
st.pos++;
bitField = st.data[st.pos++];
gif.disposalMethod = (bitField & 0b11100) >> 2;
gif.transparencyGiven = bitField & 0b1 ? true : false; // ignoring bit two that is marked as userInput???
gif.delayTime = (st.data[st.pos++]) + ((st.data[st.pos++]) << 8);
gif.transparencyIndex = st.data[st.pos++];
st.pos++;
};
function parseImg() { // decodes image data to create the indexed pixel image
var deinterlace, frame, bitField;
deinterlace = function (width) { // de interlace pixel data if needed
var lines, fromLine, pass, toline;
lines = pixelBufSize / width;
fromLine = 0;
if (interlacedBufSize !== pixelBufSize) { // create the buffer if size changed or undefined.
deinterlaceBuf = new Uint8Array(pixelBufSize);
interlacedBufSize = pixelBufSize;
}
for (pass = 0; pass < 4; pass++) {
for (toLine = interlaceOffsets[pass]; toLine < lines; toLine += interlaceSteps[pass]) {
deinterlaceBuf.set(pixelBuf.subarray(fromLine, fromLine + width), toLine * width);
fromLine += width;
}
}
};
frame = {}
gif.frames.push(frame);
frame.disposalMethod = gif.disposalMethod;
frame.time = gif.length;
frame.delay = gif.delayTime * 10;
gif.length += frame.delay;
if (gif.transparencyGiven) { frame.transparencyIndex = gif.transparencyIndex }
else { frame.transparencyIndex = undefined }
frame.leftPos = (st.data[st.pos++]) + ((st.data[st.pos++]) << 8);
frame.topPos = (st.data[st.pos++]) + ((st.data[st.pos++]) << 8);
frame.width = (st.data[st.pos++]) + ((st.data[st.pos++]) << 8);
frame.height = (st.data[st.pos++]) + ((st.data[st.pos++]) << 8);
bitField = st.data[st.pos++];
frame.localColourTableFlag = bitField & 0b10000000 ? true : false;
if (frame.localColourTableFlag) { frame.localColourTable = parseColourTable(1 << ((bitField & 0b111) + 1)) }
if (pixelBufSize !== frame.width * frame.height) { // create a pixel buffer if not yet created or if current frame size is different from previous
pixelBuf = new Uint8Array(frame.width * frame.height);
pixelBufSize = frame.width * frame.height;
}
lzwDecode(st.data[st.pos++], st.readSubBlocksB()); // decode the pixels
if (bitField & 0b1000000) { // de interlace if needed
frame.interlaced = true;
deinterlace(frame.width);
} else { frame.interlaced = false }
processFrame(frame); // convert to canvas image
};
function processFrame(frame) { // creates a RGBA canvas image from the indexed pixel data.
var ct, cData, dat, pixCount, ind, useT, i, pixel, pDat, col, frame, ti;
frame.image = document.createElement('canvas');
frame.image.width = gif.width;
frame.image.height = gif.height;
frame.image.ctx = frame.image.getContext("2d");
ct = frame.localColourTableFlag ? frame.localColourTable : gif.globalColourTable;
if (gif.lastFrame === null) { gif.lastFrame = frame }
useT = (gif.lastFrame.disposalMethod === 2 || gif.lastFrame.disposalMethod === 3) ? true : false;
if (!useT) { frame.image.ctx.drawImage(gif.lastFrame.image, 0, 0, gif.width, gif.height) }
cData = frame.image.ctx.getImageData(frame.leftPos, frame.topPos, frame.width, frame.height);
ti = frame.transparencyIndex;
dat = cData.data;
if (frame.interlaced) { pDat = deinterlaceBuf }
else { pDat = pixelBuf }
pixCount = pDat.length;
ind = 0;
for (i = 0; i < pixCount; i++) {
pixel = pDat[i];
col = ct[pixel];
if (ti !== pixel) {
dat[ind++] = col[0];
dat[ind++] = col[1];
dat[ind++] = col[2];
dat[ind++] = 255; // Opaque.
} else
if (useT) {
dat[ind + 3] = 0; // Transparent.
ind += 4;
} else { ind += 4 }
}
frame.image.ctx.putImageData(cData, frame.leftPos, frame.topPos);
gif.lastFrame = frame;
if (!gif.waitTillDone && typeof gif.onload === "function") { doOnloadEvent() }// if !waitTillDone the call onload now after first frame is loaded
};
// **NOT** for commercial use.
function finnished() { // called when the load has completed
gif.loading = false;
gif.frameCount = gif.frames.length;
gif.lastFrame = null;
st = undefined;
gif.complete = true;
gif.disposalMethod = undefined;
gif.transparencyGiven = undefined;
gif.delayTime = undefined;
gif.transparencyIndex = undefined;
gif.waitTillDone = undefined;
pixelBuf = undefined; // dereference pixel buffer
deinterlaceBuf = undefined; // dereference interlace buff (may or may not be used);
pixelBufSize = undefined;
deinterlaceBuf = undefined;
gif.currentFrame = 0;
if (gif.frames.length > 0) { gif.image = gif.frames[0].image }
doOnloadEvent();
if (typeof gif.onloadall === "function") {
(gif.onloadall.bind(gif))({ type: 'loadall', path: [gif] });
}
if (gif.playOnLoad) { gif.play() }
}
function canceled() { // called if the load has been cancelled
finnished();
if (typeof gif.cancelCallback === "function") { (gif.cancelCallback.bind(gif))({ type: 'canceled', path: [gif] }) }
}
function parseExt() { // parse extended blocks
const blockID = st.data[st.pos++];
if (blockID === GIF_FILE.GCExt) { parseGCExt() }
else if (blockID === GIF_FILE.COMMENT) { gif.comment += st.readSubBlocks() }
else if (blockID === GIF_FILE.APPExt) { parseAppExt() }
else {
if (blockID === GIF_FILE.UNKNOWN) { st.pos += 13; } // skip unknow block
st.readSubBlocks();
}

}
function parseBlock() { // parsing the blocks
if (gif.cancel !== undefined && gif.cancel === true) { canceled(); return }

const blockId = st.data[st.pos++];
if (blockId === GIF_FILE.IMAGE) { // image block
parseImg();
if (gif.firstFrameOnly) { finnished(); return }
} else if (blockId === GIF_FILE.EOF) { finnished(); return }
else { parseExt() }
if (typeof gif.onprogress === "function") {
gif.onprogress({ bytesRead: st.pos, totalBytes: st.data.length, frame: gif.frames.length });
}
setTimeout(parseBlock, 0); // parsing frame async so processes can get some time in.
};
function cancelLoad(callback) { // cancels the loading. This will cancel the load before the next frame is decoded
if (gif.complete) { return false }
gif.cancelCallback = callback;
gif.cancel = true;
return true;
}
function error(type) {
if (typeof gif.onerror === "function") { (gif.onerror.bind(this))({ type: type, path: [this] }) }
gif.onload = gif.onerror = undefined;
gif.loading = false;
}
function doOnloadEvent() { // fire onload event if set
gif.currentFrame = 0;
gif.nextFrameAt = gif.lastFrameAt = new Date().valueOf(); // just sets the time now
if (typeof gif.onload === "function") { (gif.onload.bind(gif))({ type: 'load', path: [gif] }) }
gif.onerror = gif.onload = undefined;
}
function dataLoaded(data) { // Data loaded create stream and parse
st = new Stream(data);
parse();
}
function loadGif(filename) { // starts the load
var ajax = new XMLHttpRequest();
ajax.responseType = "arraybuffer";
ajax.onload = function (e) {
if (e.target.status === 404) { error("File not found") }
else if (e.target.status >= 200 && e.target.status < 300) { dataLoaded(ajax.response) }
else { error("Loading error : " + e.target.status) }
};
ajax.open('GET', filename, true);
ajax.send();
ajax.onerror = function (e) { error("File error") };
this.src = filename;
this.loading = true;
}
function play() { // starts play if paused
if (!gif.playing) {
gif.paused = false;
gif.playing = true;
playing();
}
}
function pause() { // stops play
gif.paused = true;
gif.playing = false;
clearTimeout(timerID);
}
function togglePlay() {
if (gif.paused || !gif.playing) { gif.play() }
else { gif.pause() }
}
function seekFrame(frame) { // seeks to frame number.
clearTimeout(timerID);
gif.currentFrame = frame % gif.frames.length;
if (gif.playing) { playing() }
else { gif.image = gif.frames[gif.currentFrame].image }
}
function seek(time) { // time in Seconds // seek to frame that would be displayed at time
clearTimeout(timerID);
if (time < 0) { time = 0 }
time *= 1000; // in ms
time %= gif.length;
var frame = 0;
while (time > gif.frames[frame].time + gif.frames[frame].delay && frame < gif.frames.length) { frame += 1 }
gif.currentFrame = frame;
if (gif.playing) { playing() }
else { gif.image = gif.frames[gif.currentFrame].image }
}
function playing() {
var delay;
var frame;
if (gif.playSpeed === 0) {
gif.pause();
return;
} else {
if (gif.playSpeed < 0) {
gif.currentFrame -= 1;
if (gif.currentFrame < 0) { gif.currentFrame = gif.frames.length - 1 }
frame = gif.currentFrame;
frame -= 1;
if (frame < 0) { frame = gif.frames.length - 1 }
delay = -gif.frames[frame].delay * 1 / gif.playSpeed;
} else {
gif.currentFrame += 1;
gif.currentFrame %= gif.frames.length;
delay = gif.frames[gif.currentFrame].delay * 1 / gif.playSpeed;
}
gif.image = gif.frames[gif.currentFrame].image;
timerID = setTimeout(playing, delay);
}
}
var gif = { // the gif image object
onload: null, // fire on load. Use waitTillDone = true to have load fire at end or false to fire on first frame
onerror: null, // fires on error
onprogress: null, // fires a load progress event
onloadall: null, // event fires when all frames have loaded and gif is ready
paused: false, // true if paused
playing: false, // true if playing
waitTillDone: true, // If true onload will fire when all frames loaded, if false, onload will fire when first frame has loaded
loading: false, // true if still loading
firstFrameOnly: false, // if true only load the first frame
width: null, // width in pixels
height: null, // height in pixels
frames: [], // array of frames
comment: "", // comments if found in file. Note I remember that some gifs have comments per frame if so this will be all comment concatenated
length: 0, // gif length in ms (1/1000 second)
currentFrame: 0, // current frame.
frameCount: 0, // number of frames
playSpeed: 1, // play speed 1 normal, 2 twice 0.5 half, -1 reverse etc...
lastFrame: null, // temp hold last frame loaded so you can display the gif as it loads
image: null, // the current image at the currentFrame
playOnLoad: true, // if true starts playback when loaded
// functions
load: loadGif, // call this to load a file
cancel: cancelLoad, // call to stop loading
play: play, // call to start play
pause: pause, // call to pause
seek: seek, // call to seek to time
seekFrame: seekFrame, // call to seek to frame
togglePlay: togglePlay, // call to toggle play and pause state
};
return gif;
}

export {
GIF
}

添加一个map用来存放玩家每一个状态的动画,在Player类的构造函数中

1
2
//用来存放玩家每一个状态的动画
this.animation = new Map();

新建一个草薙京类在player文件夹中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import { Player } from '/static/js/Player/base.js';
import { GIF } from '/static/js/utils/gif.js';

// 定义 Kyo 类,继承自 Player
export class Kyo extends Player {
// 构造函数,接收两个参数:root(游戏根对象)和 info(玩家角色的初始信息)
constructor(root, info) {
super(root, info); // 调用父类的构造函数,初始化角色的基本信息
this.init_animation(); // 初始化动画
}

// 初始化动画方法
init_animation() {
let outer = this; // 保存当前类的引用,用于回调函数中访问实例属性
for (let i = 0; i < 7; i++) { // 遍历 7 种动画状态(例如:站立、行走、攻击等)
let gif = GIF(); // 创建一个 GIF 对象
gif.load(`/static/images/player/kyo/${i}.gif`); // 加载对应编号的 GIF 文件
this.animations.set(i, {
gif: gif,
frame_cnt: 0, // 总图片数
frame_rate: 5, // 每5帧过度一次
offset_y: 0, // y方向偏移量
loaded: false, // 是否加载完整
scale: 2, // 放大多少倍
});
gif.onload = () => { // 设置 GIF 加载完成后的回调函数
let obj = outer.animations.get(i); // 获取当前动画状态的对象
obj.frame_cnt = gif.frames.length; // 更新帧数为 GIF 的总帧数
obj.loaded = true; // 标记为已加载
};
}
}
}

添加一个this.frame_current_cnt表示当前帧数,在Player类的构造函数中

1
2
//表示当前帧数
this.frame_current_cnt = 0;

不渲染矩形,根据当前的状态来渲染,修改Player类的render函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//表示玩家角色在游戏画面中的呈现形式
render() {
//this.ctx.fillStyle = this.color;
//this.ctx.fillRect(this.x, this.y, this.width, this.height);

let status = this.status;
let obj = this.animations.get(status);
if (obj && obj.loaded) {
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, image.width * obj.scale, image.height * obj.scale);
}

this.frame_current_cnt++;
}

在主类中玩家不实例化Player类的对象,而是Kyo类的对象

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import { GameMap } from '/static/js/GameMap/base.js';
import { Kyo } from '/static/js/Player/kyo.js';

export class KOF {
// 参数id用于指定一个DOM元素的id,目的是后续通过这个id来获取对应的DOM元素
constructor(id) {
// 使用jQuery选择器,通过传入的id查找对应的DOM元素,并将其赋值给实例属性this.$kof
this.$kof = $('#' + id);
// 创建一个GameMap对象实例
this.game_map = new GameMap(this);
// 创建一个玩家对象数组,用于存储所有的玩家对象
this.players = [
new Kyo(this, {
id: 0,
x: 200,
y: 0,
width: 120,
height: 200,
color: 'red'
}),
new Kyo(this, {
id: 1,
x: 900,
y: 0,
width: 120,
height: 200,
color: 'blue'
})
];
}
}

在Player类中修改移动没有动作的bug,只有在空中才增加重力,将move函数改为

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// 定义move方法,用于处理玩家角色的移动逻辑,根据当前的速度、重力以及时间间隔等因素来更新玩家角色的位置坐标
move() {
if (this.status === 3) {
// 在垂直方向上,根据重力加速度来更新垂直速度分量,每一次调用该方法时,垂直速度都会增加相应的重力值,
// 以此模拟现实世界中物体在重力作用下速度不断变化的情况,实现玩家角色下落等垂直方向的运动效果
this.vy += this.gravity;
}
// 在水平方向上,根据当前水平速度分量(this.vx)、时间间隔(this.timedelta),来更新玩家角色的水平坐标(this.x),
// 这样能使玩家角色在水平方向上按照设定的速度和实际经过的时间进行移动,保证移动距离与时间和速度的关系符合物理逻辑
this.x += this.vx * this.timedelta / 1000;
// 同理,在垂直方向上,依据垂直速度分量(this.vy)、时间间隔(this.timedelta)来更新玩家角色的垂直坐标(this.y),
// 使得玩家角色在垂直方向上也能根据速度和时间准确地移动,比如跳起后随着时间上升和下落等
this.y += this.vy * this.timedelta / 1000;

// 判断玩家角色的垂直坐标(this.y)是否超过了某个特定值(这里是450),如果超过了,说明玩家角色到达了地面,
// 此时将玩家角色的垂直坐标设置为450,使其固定在这个位置上,同时将垂直速度分量重置为0,模拟玩家角色落地停止的状态,
// 避免玩家角色持续下落穿透地面等不符合实际物理逻辑的情况出现,落地后将玩家角色的状态设置为0
if (this.y > 450) {
this.y = 450;
this.vy = 0;
this.status = 0;
}
// 限制玩家角色在水平方向上的移动范围,避免超出画布边界
if (this.x < 0) {
this.x = 0;
} else if (this.x + this.width > this.root.game_map.$canvas.width()) {
this.x = 1280 - this.width;
}
}

这里发现玩家移动时回往下平移一点,修改此bug,在Kyo类中定义一个数组来记录偏移量,在Player类的render函数中来抵消这个偏移量

Kyo类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 初始化动画方法
init_animation() {
let outer = this; // 保存当前类的引用,用于回调函数中访问实例属性
let offsets = [0, -22, -22, 0, 0, 0, 0];//每个动画不在同一高度,设置偏移量
for (let i = 0; i < 7; i++) { // 遍历 7 种动画状态(例如:站立、行走、攻击等)
let gif = GIF(); // 创建一个 GIF 对象
gif.load(`/static/images/player/kyo/${i}.gif`); // 加载对应编号的 GIF 文件
this.animations.set(i, {
gif: gif,
frame_cnt: 0, // 总图片数
frame_rate: 5, // 每5帧过度一次
offset_y: offsets[i], // y方向偏移量
loaded: false, // 是否加载完整
scale: 2, // 放大多少倍
});
gif.onload = () => { // 设置 GIF 加载完成后的回调函数
let obj = outer.animations.get(i); // 获取当前动画状态的对象
obj.frame_cnt = gif.frames.length; // 更新帧数为 GIF 的总帧数
obj.loaded = true; // 标记为已加载
};
}
}

Player类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// render方法,表示玩家角色在游戏画面中的呈现形式,这里不是简单地使用矩形填充绘制,而是根据玩家角色当前的状态,从对应的动画信息中获取相应帧的图像,并绘制到游戏画面上,实现更丰富的动画效果展示
render() {
// 以下代码被注释掉了,原本可能是用于简单地以矩形填充颜色的方式绘制玩家角色,但现在采用动画绘制方式替代了
// this.ctx.fillStyle = this.color;
// this.ctx.fillRect(this.x, this.y, this.width, this.height);

let status = this.status;
let obj = this.animations.get(status);
if (obj && obj.loaded) {
// 根据当前帧数(frame_current_cnt)和当前状态动画的帧率(frame_rate)计算出当前应该显示的动画帧索引k,通过取余操作确保帧索引在有效范围内(不超过总帧数)
let k = parseInt(this.frame_current_cnt / obj.frame_rate) % obj.frame_cnt;
let image = obj.gif.frames[k].image;
// 使用绘图上下文对象(ctx)的drawImage方法,将计算出的当前帧图像绘制到游戏画面上,同时根据动画的缩放比例(scale)和垂直方向的偏移量(offset_y)调整图像的大小和位置,使其正确显示在玩家角色对应的坐标位置上
this.ctx.drawImage(image, this.x, this.y + obj.offset_y, image.width * obj.scale, image.height * obj.scale);
}

this.frame_current_cnt++;
// 每渲染一次,当前帧数自增1,用于控制动画的逐帧播放,使得下一帧渲染时能切换到下一张动画帧(根据帧率等因素决定)
}

区分前进和后退的动画,在Player类的render方法中修改

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// render方法,表示玩家角色在游戏画面中的呈现形式,这里不是简单地使用矩形填充绘制,而是根据玩家角色当前的状态,从对应的动画信息中获取相应帧的图像,并绘制到游戏画面上,实现更丰富的动画效果展示
render() {
// 以下代码被注释掉了,原本可能是用于简单地以矩形填充颜色的方式绘制玩家角色,但现在采用动画绘制方式替代了
// this.ctx.fillStyle = this.color;
// this.ctx.fillRect(this.x, this.y, this.width, this.height);

let status = this.status;

// 这里添加了一个逻辑判断,当角色当前处于向前移动状态(status === 1),并且角色的方向(direction)与水平速度(vx)的乘积小于 0,
// 意味着角色实际在朝与当前设定方向相反的方向移动(比如角色面朝右,但正在向左移动),此时将状态调整为向后移动状态(status = 2),
// 是为了切换到对应的向后移动动画来展示更符合实际视觉效果的动画表现
if (this.status === 1 && this.direction * this.vx < 0) {
status = 2;
}

let obj = this.animations.get(status);
if (obj && obj.loaded) {
// 根据当前帧数(frame_current_cnt)和当前状态动画的帧率(frame_rate)计算出当前应该显示的动画帧索引k,通过取余操作确保帧索引在有效范围内(不超过总帧数)
let k = parseInt(this.frame_current_cnt / obj.frame_rate) % obj.frame_cnt;
let image = obj.gif.frames[k].image;
// 使用绘图上下文对象(ctx)的drawImage方法,将计算出的当前帧图像绘制到游戏画面上,同时根据动画的缩放比例(scale)和垂直方向的偏移量(offset_y)调整图像的大小和位置,使其正确显示在玩家角色对应的坐标位置上
this.ctx.drawImage(image, this.x, this.y + obj.offset_y, image.width * obj.scale, image.height * obj.scale);
}

this.frame_current_cnt++;
// 每渲染一次,当前帧数自增1,用于控制动画的逐帧播放,使得下一帧渲染时能切换到下一张动画帧(根据帧率等因素决定)
}

去除黑色背景,在GameMap类中,修改render函数

1
2
3
4
5
6
// 定义render方法,用于在canvas上进行实际的渲染操作。
// 这里调用了绘图上下文对象(this.ctx)的clearRect方法,用于清除整个canvas画布的内容,参数分别指定了清除矩形区域的左上角坐标(0, 0)以及矩形的宽度和高度(使用canvas的宽度和高度属性),
// 这样每次渲染前先清除上一次的画面内容,以便绘制新的内容,实现画面的动态更新效果
render() {
this.ctx.clearRect(0, 0, this.ctx.canvas.width, this.ctx.canvas.height);
}

修改跳跃时的bug,修改跳跃时的偏移量

1
let offsets = [0, -22, -22, -100, 0, 0, 0];

实现攻击动画

修改Player类的update_control函数和render函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
    // 根据按键状态更新玩家角色的速度和状态
if (this.status === 0 || this.status === 1) {
// 若按下特定功能键(space 或 Enter,依玩家不同),将角色状态设为攻击状态(4),并将水平速度设为 0,同时重置当前帧数为 0,为了从头播放攻击动画
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; // 否则水平速度为 0
}
this.vy = this.speedy; // 设置垂直速度为跳跃速度
this.status = 3; // 状态设置为跳跃状态
} else if (d) { // 如果按下向右键
this.vx = this.speedx; // 设置水平速度为正方向
this.status = 1; // 状态设置为移动状态
} else if (a) { // 如果按下向左键
this.vx = -this.speedx; // 设置水平速度为负方向
this.status = 1; // 状态设置为移动状态
} else { // 如果没有按下任何移动键
this.vx = 0; // 水平速度为 0
this.status = 0; // 状态设置为空闲状态
}
}

// render方法,表示玩家角色在游戏画面中的呈现形式,这里不是简单地使用矩形填充绘制,而是根据玩家角色当前的状态,从对应的动画信息中获取相应帧的图像,并绘制到游戏画面上,实现更丰富的动画效果展示
render() {
// 以下代码被注释掉了,原本可能是用于简单地以矩形填充颜色的方式绘制玩家角色,但现在采用动画绘制方式替代了
// this.ctx.fillStyle = this.color;
// this.ctx.fillRect(this.x, this.y, this.width, this.height);

let status = this.status;

// 这里添加了一个逻辑判断,当角色当前处于向前移动状态(status === 1),并且角色的方向(direction)与水平速度(vx)的乘积小于 0,
// 意味着角色实际在朝与当前设定方向相反的方向移动(比如角色面朝右,但正在向左移动),此时将状态调整为向后移动状态(status = 2),
// 是为了切换到对应的向后移动动画来展示更符合实际视觉效果的动画表现
if (this.status === 1 && this.direction * this.vx < 0) {
status = 2;
}

let obj = this.animations.get(status);
if (obj && obj.loaded) {
// 根据当前帧数(frame_current_cnt)和当前状态动画的帧率(frame_rate)计算出当前应该显示的动画帧索引k,通过取余操作确保帧索引在有效范围内(不超过总帧数)
let k = parseInt(this.frame_current_cnt / obj.frame_rate) % obj.frame_cnt;
let image = obj.gif.frames[k].image;
// 使用绘图上下文对象(ctx)的drawImage方法,将计算出的当前帧图像绘制到游戏画面上,同时根据动画的缩放比例(scale)和垂直方向的偏移量(offset_y)调整图像的大小和位置,使其正确显示在玩家角色对应的坐标位置上
this.ctx.drawImage(image, this.x, this.y + obj.offset_y, image.width * obj.scale, image.height * obj.scale);
}
// 当角色处于攻击状态(status === 4)时,判断当前帧数是否达到该攻击状态动画最后一帧(通过比较当前帧数与帧率乘以总帧数减 1 的值),若达到,说明攻击动画播放完毕,将角色状态设为空闲状态(0)
if (status === 4) {
if (this.frame_current_cnt == obj.frame_rate * (obj.frame_cnt - 1)) {
this.status = 0;
}
}

this.frame_current_cnt++;
// 每渲染一次,当前帧数自增1,用于控制动画的逐帧播放,使得下一帧渲染时能切换到下一张动画帧(根据帧率等因素决定)
}

解决跳跃动画问题

加一个特判,当状态为跳跃,修改rate参数,在Kyo类的init_animation方法的末尾加入以下代码

1
2
3
4
// 如果当前动画状态的索引 i 等于 3,单独设置该状态下的 frame_rate 为 4,即调整该动画状态下的播放速度,使其每 4 帧过渡一次,为了让这个特定状态的动画效果更符合预期,让跳跃动作看起来更流畅或者更有节奏感
if (i === 3) {
obj.frame_rate = 4;
}

让玩家对称

当玩家交换位置的时候就改变朝向的方向,修改Player类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
import { MyGameObject } from '/static/js/MyGameObject/base.js';

export class Player extends MyGameObject {
// 类的构造函数,在创建GameMap类的实例时会被调用,接收一个参数root(即MyGameObject对象)和info(玩家的信息)
constructor(root, info) {
super();
this.root = root;
// 从info对象中获取玩家角色的id信息,并赋值给当前类实例的this.id属性,用于标识玩家角色的唯一性
this.id = info.id;
// 从info对象中获取玩家角色的初始x坐标信息,并赋值给当前类实例的this.x属性,用于后续确定玩家在游戏画面中的水平位置
this.x = info.x;
// 同理,从info对象中获取玩家角色的初始y坐标信息,并赋值给当前类实例的this.y属性,用于确定玩家在游戏画面中的垂直位置
this.y = info.y;
// 获取玩家角色的宽度信息,赋值给this.width属性,用于后续绘制玩家角色图形等操作中确定图形的宽度尺寸
this.width = info.width;
// 获取玩家角色的高度信息,赋值给this.height属性,用于确定图形的高度尺寸
this.height = info.height;
// 获取玩家角色的颜色信息,赋值给this.color属性,后续在绘制玩家角色时会使用这个颜色来填充图形
this.color = info.color;
// 初始化玩家角色在水平方向的速度分量为0,后续可以根据游戏中的操作或者逻辑来改变这个值,以实现水平方向的移动
this.vx = 0;
// 初始化玩家角色在垂直方向的速度分量为0,同样可根据实际情况改变,例如实现跳跃等垂直方向的运动
this.vy = 0;
// 定义玩家角色在水平方向的移动速度,这里设置为400,用于控制玩家角色在水平方向移动的快慢程度
this.speedx = 400;
// 定义玩家角色在跳跃时的初始垂直速度,设置为-1000,用于控制跳跃的初始力度等相关特性
this.speedy = -1000;
//获取游戏地图(通过this.root关联到游戏根对象,再访问其game_map属性获取游戏地图对象)的绘图上下文对象(ctx)
this.ctx = this.root.game_map.ctx;
// 定义玩家角色所受的重力加速度,设置为50
this.gravity = 50;
// 定义玩家角色的方向,正方向为1
this.direction = 1;
//0:idle, 1:forward, 2:backward, 3:jump, 4:attack, 5:injured, 6:dead
//初始值为3是因为玩家角色在游戏开始时是跳跃状态
this.status = 3;
//用来存放玩家角色的按键状态
this.pressed_Keys = this.root.game_map.controller.pressed_Keys;
//用来存放玩家每一个状态的动画
this.animations = new Map();
//表示当前帧数
this.frame_current_cnt = 0;
}

start() {

}

// 定义move方法,用于处理玩家角色的移动逻辑,根据当前的速度、重力以及时间间隔等因素来更新玩家角色的位置坐标
move() {
if (this.status === 3) {
// 在垂直方向上,根据重力加速度来更新垂直速度分量,每一次调用该方法时,垂直速度都会增加相应的重力值,
// 以此模拟现实世界中物体在重力作用下速度不断变化的情况,实现玩家角色下落等垂直方向的运动效果
this.vy += this.gravity;
}
// 在水平方向上,根据当前水平速度分量(this.vx)、时间间隔(this.timedelta),来更新玩家角色的水平坐标(this.x),
// 这样能使玩家角色在水平方向上按照设定的速度和实际经过的时间进行移动,保证移动距离与时间和速度的关系符合物理逻辑
this.x += this.vx * this.timedelta / 1000;
// 同理,在垂直方向上,依据垂直速度分量(this.vy)、时间间隔(this.timedelta)来更新玩家角色的垂直坐标(this.y),
// 使得玩家角色在垂直方向上也能根据速度和时间准确地移动,比如跳起后随着时间上升和下落等
this.y += this.vy * this.timedelta / 1000;

// 判断玩家角色的垂直坐标(this.y)是否超过了某个特定值(这里是450),如果超过了,说明玩家角色到达了地面,
// 此时将玩家角色的垂直坐标设置为450,使其固定在这个位置上,同时将垂直速度分量重置为0,模拟玩家角色落地停止的状态,
// 避免玩家角色持续下落穿透地面等不符合实际物理逻辑的情况出现,落地后将玩家角色的状态设置为0
if (this.y > 450) {
this.y = 450;
this.vy = 0;
this.status = 0;
}
// 限制玩家角色在水平方向上的移动范围,避免超出画布边界
if (this.x < 0) {
this.x = 0;
} else if (this.x + this.width > this.root.game_map.$canvas.width()) {
this.x = 1280 - this.width;
}
}

// update_control 方法,用于根据按键状态更新玩家角色的控制逻辑
update_control() {
let w, a, d, space;
// 根据玩家角色的 id 确定按键映射
if (this.id === 0) {
w = this.pressed_Keys.has('w'); // 玩家 1 的跳跃键
a = this.pressed_Keys.has('a'); // 玩家 1 的向左键
d = this.pressed_Keys.has('d'); // 玩家 1 的向右键
space = this.pressed_Keys.has(' ');
} else {
w = this.pressed_Keys.has('ArrowUp'); // 玩家 2 的跳跃键
a = this.pressed_Keys.has('ArrowLeft'); // 玩家 2 的向左键
d = this.pressed_Keys.has('ArrowRight'); // 玩家 2 的向右键
space = this.pressed_Keys.has('Enter');
}

// 根据按键状态更新玩家角色的速度和状态
if (this.status === 0 || this.status === 1) {
// 若按下特定功能键(space 或 Enter,依玩家不同),将角色状态设为攻击状态(4),并将水平速度设为 0,同时重置当前帧数为 0,为了从头播放攻击动画
this.status = 4;
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; // 否则水平速度为 0
}
this.vy = this.speedy; // 设置垂直速度为跳跃速度
this.status = 3; // 状态设置为跳跃状态
} else if (d) { // 如果按下向右键
this.vx = this.speedx; // 设置水平速度为正方向
this.status = 1; // 状态设置为移动状态
} else if (a) { // 如果按下向左键
this.vx = -this.speedx; // 设置水平速度为负方向
this.status = 1; // 状态设置为移动状态
} else { // 如果没有按下任何移动键
this.vx = 0; // 水平速度为 0
this.status = 0; // 状态设置为空闲状态
}
}
}

// update_direction 方法,用于更新玩家角色的方向
update_direction() {
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; // 否则方向为负(向左)
}
}

update() {
this.update_control();
this.move();
this.update_direction();
this.render();
}

// render方法,表示玩家角色在游戏画面中的呈现形式,这里不是简单地使用矩形填充绘制,而是根据玩家角色当前的状态,从对应的动画信息中获取相应帧的图像,并绘制到游戏画面上,实现更丰富的动画效果展示
render() {
// 以下代码被注释掉了,原本可能是用于简单地以矩形填充颜色的方式绘制玩家角色,但现在采用动画绘制方式替代了
// this.ctx.fillStyle = this.color;
// this.ctx.fillRect(this.x, this.y, this.width, this.height);

let status = this.status;

// 这里添加了一个逻辑判断,当角色当前处于向前移动状态(status === 1),并且角色的方向(direction)与水平速度(vx)的乘积小于 0,
// 意味着角色实际在朝与当前设定方向相反的方向移动(比如角色面朝右,但正在向左移动),此时将状态调整为向后移动状态(status = 2),
// 是为了切换到对应的向后移动动画来展示更符合实际视觉效果的动画表现
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) {
// 根据当前帧数(frame_current_cnt)和当前状态动画的帧率(frame_rate)计算出当前应该显示的动画帧索引k,通过取余操作确保帧索引在有效范围内(不超过总帧数)
let k = parseInt(this.frame_current_cnt / obj.frame_rate) % obj.frame_cnt;
let image = obj.gif.frames[k].image;
// 使用绘图上下文对象(ctx)的drawImage方法,将计算出的当前帧图像绘制到游戏画面上,同时根据动画的缩放比例(scale)和垂直方向的偏移量(offset_y)调整图像的大小和位置,使其正确显示在玩家角色对应的坐标位置上
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(); // 恢复画布状态
}
}
// 当角色处于攻击状态(status === 4)时,判断当前帧数是否达到该攻击状态动画最后一帧(通过比较当前帧数与帧率乘以总帧数减 1 的值),若达到,说明攻击动画播放完毕,将角色状态设为空闲状态(0)
if (status === 4) {
if (this.frame_current_cnt == obj.frame_rate * (obj.frame_cnt - 1)) {
this.status = 0;
}
}

this.frame_current_cnt++;
// 每渲染一次,当前帧数自增1,用于控制动画的逐帧播放,使得下一帧渲染时能切换到下一张动画帧(根据帧率等因素决定)
}
}