0%

AcWing SpringBoot项目实战02

学习平台

AcWing SpringBoot框架课

Vue组件介绍

组件作用: 当所有页面都共享一部分内容时,可将该内容写为一个组件。

components/ 目录下创建。

注意:组件名称必须有两个字母大写,没有或只有一个会报错

image-20220713210047829

  • 每个.vue文件由三部分组成:
1
2
3
4
5
6
7
8
9
10
11
12
<template>

</template>


<script>

</script>

<style scoped> /* scoped的作用是在本页面写的css会加上随机字符串,不会影响别的页面 */

</style>

页面设计

效果图:

2a9d61c76c0c44bd5ddb30e4522148c.png

添加导航栏

Bootstrap实现导航栏

Bootstrap 中搜索Navbar就可以找到各种样式的导航栏,选择其中一种即可。

components/目录下创建NavBar.vue组件,并修改组件中的一些名称信息。

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
<template>
<nav class="navbar navbar-expand-lg navbar-light bg-light">
<div class="container">
<a class="navbar-brand" href="#">King Of Bots</a>
<div class="collapse navbar-collapse" id="navbarText">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item">
<a class="nav-link" aria-current="page" href="#">对战</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#">对局列表</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#">排行榜</a>
</li>
</ul>

<ul class="navbar-nav">
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
xie zt
</a>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="#">我的Bot</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="#">退出</a></li>
</ul>
</li>
</ul>
</div>
</div>
</nav>
</template>

<script>

</script>

<!-- scoped作用:在这个css样式里面加上一个随机字符串,这样就不会影响其他页面的css了 -->
<style scoped>

</style>

在App.vue中添加组件

App.vue中导入该组件

  • 首先需要导入该组件import NavBar from '@/components/NavBar.vue'
  • 因为使用的是bootstrap,所以需要导入bootstrap的css和js。import "bootstrap/dist/css/boostrap.min.css"import "bootstrap/dist/js/bootstrap"
  • 需要在export default 中声明该组件components: {NavBar}
  • 将该组件加入到template中,进行使用
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
<!-- 编写html -->
<template>
<NavBar/>
<router-view/>
</template>

<!-- 编写js -->
<script>
import NavBar from '@/components/NavBar.vue'
import "bootstrap/dist/css/boostrap.min.css"
import "bootstrap/dist/js/bootstrap"

export default{
components: {
NavBar
}
}

</script>


<!-- 编写css -->
<style>
body{
background-image: url("@/assets/background.png");
background-size: 100%;
}
</style>

运行项目后会报错,需要安装一个依赖@popperjs/core

会遇到的问题

在创建好组件后,会提示需要安装依赖@popperjs/core,去vue页面下载安装即可。

image-20220721152928047

image-20220721153032064

实现效果

image-20230309093231779

页面和导航栏链接地址映射

创建页面

在vue中,一般将页面创建在views/目录下。不同的页面分别实现一个对应的.vue文件。这里是将不同的页面分别创建了一个文件夹,然后再自己的文件夹下创建.vue文件。

image-20230309094217501

以其中一个页面举例:RanklistIndexView.vue

1
2
3
4
5
6
7
8
9
10
11
12
<template>
<div>排行榜</div>
</template>


<script>

</script>

<style scoped>

</style>

页面链接映射

地址映射实现是需要在router/index.js中进行,具体步骤如下:

  • 初始的router/index.js代码
1
2
3
4
5
6
7
8
9
10
11
12
import { createRouter, createWebHistory } from 'vue-router'

const routes = [
]

const router = createRouter({
history: createWebHistory(),
routes
})

export default router

  • 导入所有需要映射的页面
1
2
3
4
5
import PkIndexView from '@/views/pk/PkIndexView'
import RanklistIndexView from '@/views/ranklist/RanklistIndexView'
import RecordIndexView from '@/views/record/RecordIndexView'
import UserBotIndexView from '@/views/user/bot/UserBotIndexView'
import NotFound from '@/views/error/NotFound'
  • const routes中进行页面和地址映射。
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
const routes = [
{
path: "/",
name: "home",
redirect: "/pk/", // 重定向到对战也面
},
{
path: "/pk/",
name: "pk_index",
component: PkIndexView, // 地址栏输入 localhost:8080/pk/ 即可显示 PkIndexView 的内容
},
{
path: "/record/",
name: "record_index",
component: RecordIndexView,
},
{
path: "/ranklist/",
name: "ranklist_index",
component: RanklistIndexView,
},
{
path: "/404/",
name: "404",
component: NotFound,
},
{
path: "/user/bot/",
name: "user_bot_index",
component: UserBotIndexView,
},
{
path: "/:catchAll(.*)", // 输入格式错误或乱码,则重定向至404页面
redirect: "/404/"
},
]

实现效果

image-20230309101234033

实现导航栏点击跳转至对应的链接

实现方法就是将NavBar.vue中对应<a>标签的href属性改为需要跳转的链接即可。例如:

1
<a class="nav-link" aria-current="page" href="/pk/">对战</a>

导航栏中存在的问题

存在问题

  • 导航栏每次点击页面都会刷新。
  • 导航栏点击某个链接后不会聚焦。显示当前正在此页面中。

解决方法

  • 每次点击某个链接,页面会进行刷新,可以将<a>标签改为<router-link>标签,避免这种问题
1
2
3
<router-link :class="route_name == 'pk_index' ? 'nav-link active' : 'nav-link'" :to="{ name: 'pk_index' }">
对战
</router-link>
  • :class 判断是否在当前页面,若在的话则可以添加active 实现该链接名高亮。
  • :to 点击时跳转的页面,通过上面router/index.js页面的映射名字进行跳转。

上面会使用到判断当前页面是否某个页面的变量route_name,获取当前在哪个页面的解决方法:

NavBar.vue中添加如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<script>
import { useRoute } from 'vue-router'
import { computed } from 'vue' // 进行实时计算

export default {
setup() { // 入口
const route = useRoute(); // 取得当前是哪个页面
let route_name = computed(() => route.name)
return {
route_name
}
}
}

</script>

页面美化

实现步骤

  1. 首先每个页面会有一个公共的组件,因此我们需要在components/目录下创建这个公共组件ContentField.vue
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<!-- ContentField.vue -->
<template>
<div class="container">
<div class="card">
<div class="card-body">
<slot>
<!-- 存放每个页面需要渲染的东西 -->
</slot>
</div>
</div>
</div>
</template>

<script>

</script>

<style scoped>
div.container {
margin-top: 20px;
}
</style>

2.在每个页面引入这个公共组件,以对战页面PkIndex.vue为例:其中引入组件也分为三部,上面已经说明过了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<template>
<ContentField>
对战
</ContentField>
</template>

<script>

import ContentField from '@/components/ContentField.vue'

export default {
components: {
ContentField
}
}

</script>

<style scoped>

</style>

实现效果

对战页面

image-20230309104049555

对局列表页面

image-20230309104101252

创建对战地图⭐

地图要求

  • 首先两条蛇初始位置分别在左下角和右上角
  • 地图上分散着若干个障碍物,障碍物需要满足中心对称(为了公平)
  • 同时需要保证两条蛇之间的路径是连通的。

336af4ed02ac84321dea23ad81ba97a.png

创建游戏基类

实现目的

因为游戏中的很多资源需要实现每一帧重新渲染一遍,所以就可以将这部分提取出来,实现一个基类,在基类中实现每帧渲染的操作。后面实现的其他类(例如:蛇、墙、地图)都可以继承该基类,来实现自己的每帧渲染效果。

首先,所有的静态资源都会放置在assets/目录下

image-20230309162140554

assets/scripts/下创建一个基类AcGameObject.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
const AC_GAME_OBJECTS = [];  // 用来保存所有创建的对象, 可能包括 蛇、地图、墙

export 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 in AC_GAME_OBJECTS) {
const obj = AC_GAME_OBJECTS[i];
if(obj === this){
AC_GAME_OBJECTS.splice(i); // 删除该位置的元素
break;
}
}
}
}

let last_timestamp; // 记录上一次执行的时刻
const step = timestamp => {
for(let obj of AC_GAME_OBJECTS) { // of 遍历的是值,in遍历的是下标
if(!obj.has_called_start){
obj.start();
obj.has_called_start = true;
}
else {
obj.timedelta = timestamp - last_timestamp;
obj.update();
}
}
last_timestamp = timestamp; // 更新上一次执行的时刻
requestAnimationFrame(step);
}

requestAnimationFrame(step); // 每帧执行一次调用的函数

创建游戏地图类

实现步骤

  • assets/scripts/目录下创建GameMap.js文件,实现渲染的逻辑
  • components/目录下创建GameMap.vuePlayGround.vue 组件,实现游戏地图的页面创建
  • GameMap.vue:主要显示游戏地图,并调用 js 文件进行地图渲染
  • PlayGround.vue主要是显示PK页面的内容,除了游戏地图,后面可能还包含积分板等其他内容。所以在PlayGround组件中需要引入GameMap组件

具体实现

首先创建PlayGround.vue,用来显示pk页面的内容:

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
<template>
<div class="playground">
<GameMap>

</GameMap>
</div>
</template>

<script>
import GameMap from '@/components/GameMap.vue'

export default {
components: {
GameMap,
}
}

</script>

<style scoped>
div.playground{
width: 60vw;
height: 70vh;
background: lightblue;
margin: 40px auto 0;
}
</style>

实现效果如下:就是背景里蓝色的部分,绿色的部分是后面要绘制的地图,蓝色的部分就是PlayGround主要的效果,它里面除了地图,后期也可以加入比分扳或者聊天框之类的。

image-20230310102526377

可以看到,在PlayGround.vue中引入了GameMap组件,因此接下来就实现GameMap.vue组件:

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
<!-- GameMap.vue -->
<template>
<div ref="parent" class="gamemap">
<canvas ref="canvas">

</canvas>
</div>
</template>


<script>

import { GameMap } from '@/assets/scripts/GameMap';
import { ref, onMounted } from 'vue'; // 为了引入canvans

export default {
setup() {
let parent = ref(null);
let canvas = ref(null);

onMounted(() => {
new GameMap(canvas.value.getContext('2d'), parent.value); //创建一个GameMap对象
});

return {
parent,
canvas
}
}
}

</script>

<style scoped>
div.gamemap {
width: 100%;
height: 100%;
display: flex; /* 居中,可以使水平和垂直都居中 */
justify-content: center; /* 设置水平居中 */
align-items: center; /* 设置垂直居中 */
}

</style>

GameMap.vue组件中,主要是通过调用GameMap.js来进行相应地图的渲染,这里用到了canvas标签。然后将js中渲染的效果在回显至页面中。
下面介绍GameMap.js的实现:

首先,GameMap.js需要继承基类AcGameObject.js,实现每帧进行刷新。

  • 在js中,首先确定了地图的行数和列数,this.rowsthis.cols,因为是正方形,所以都规定为13,这个可以随便改的。
  • 然后通过update_size()函数确定地图中每个单元格的大小this.L 以及地图的宽高(this.widththis.height),因为如果页面大小改变的话,需要地图大小一起进行改变,所以需要将该函数在update()函数中调用。
  • 实现地图的渲染render()函数,需要实现明亮交替的块状颜色,所以首先规定两种颜色,然后遍历整个地图,如果行数和列数相加是奇数则是一种颜色,否则是另一种颜色。在对地图渲染时,需要使用到ctx的渲染方法。同时也需要将该函数加入到update()函数中。

需要注意的是,ctx从左朝右是横坐标,从上朝下是纵坐标。

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 { AcGameObject } from "./AcGameObject";

export class GameMap extends AcGameObject {
constructor(ctx, parent) {
super(); // 执行基类的构造函数,就是AcGameObject的构造函数

this.ctx = ctx; // 一个画布
this.parent = parent;
this.L = 0; // 表示地图中每一个小块的宽度

this.rows = 13; // 地图的行数
this.cols = 13; // 地图的列数
}

start() {

}

update_size() { // 更新地图的大小
this.L = Math.min(this.parent.clientWidth / this.cols, this.parent.clientHeight / this.rows); // 计算每个单元格的大小。
this.ctx.canvas.width = this.L * this.cols; // 单元格的大小乘以列数
this.ctx.canvas.height = this.L * this.rows; // 单元格的大小乘以行数
}

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

render() {
// 画图
const color_even = '#AAD751', color_odd = '#A2D149' // 定义两种颜色
for(let r = 0; r < this.rows; r ++) {
for(let c = 0; c < this.cols; c ++) {
if((r + c) % 2 == 0) this.ctx.fillStyle = color_even;
else this.ctx.fillStyle = color_odd;
this.ctx.fillRect(c * this.L, r * this.L, this.L, this.L);
}
}
}

destroy() {

}
}

创建障碍物类

实现步骤

  • 首先障碍物也是需要每帧进行刷新的,所以需要在scripts/目录下创建一个Wall.js继承基类AcGameObject.js,实现地图的绘制功能。
  • 然后在GameMap.js中确定障碍物的位置以及数量,通过调用Wall.js绘制墙。

具体实现

创建Wall.js

需要在构造函数中传入障碍物的左上方顶点坐标,和地图对象,传入坐上方顶点坐标是为了使用ctx.fillRect进行绘制,传入地图对象gamemap是为了使用其中的 ctx 和上面确定的单元格大小this.L

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import { AcGameObject } from "./AcGameObject";

export class Wall extends AcGameObject {
constructor(r, c, gamemap) {
super();

this.r = r;
this.c = c;
this.gamemap = gamemap;
this.color = "#B37226"; // 颜色
}

update() {
this.render();
}

render() {
const L = this.gamemap.L; // 取出单元格的大小
const ctx = this.gamemap.ctx;

ctx.fillStyle = this.color;
ctx.fillRect(this.c * L, this.r * L, L, L);
}
}

修改GameMap.js

首先先实现给地图的四周添加墙。

  • 首先需要在构造函数中添加几个全局变量,this.walls = []用来保存所有生成的Wall对象,this.inner_walls_count用来保存想要在地图中随机生成的障碍物数量。
  • 实现创建障碍物函数create_walls(),然后再start()函数中调用。实现障碍物函数的步骤如下:
    • 首先定义一个bool类型的数组,用来保存[r][c]位置是否是障碍物,如果是则为true。全部先初始化为false
    • 然后使用两个for循环将g数组的上下左右四个边界设为true
    • 最后循环遍历g数组中的每个元素,若为true,则创建一个Wall对象,并存入this.walls
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
constructor(ctx, parent) {
super(); // 执行基类的构造函数,就是AcGameObject的构造函数

this.ctx = ctx; // 一个画布
this.parent = parent;
this.L = 0; // 表示地图中每一个小块的宽度

this.rows = 13; // 地图的行数
this.cols = 13; // 地图的列数

this.walls = []; // 保存创建的每个障碍物的对象
this.inner_walls_count = 40; // 定义在地图里面障碍物数量
}

create_walls() {
const g = []; // 布尔数组,如果某个位置是墙,则为true,否则为false
for(let r = 0; r < this.rows; r ++) {
g[r] = [];
for(let c = 0; c < this.cols; c ++) {
g[r][c] = false;
}
}

// 给四周加上墙
for(let r = 0; r < this.rows; r ++)
g[r][0] = g[r][this.cols - 1] = true;
for(let c = 0; c < this.cols; c ++)
g[0][c] = g[this.rows - 1][c] = true;

// 绘制墙
for(let r = 0; r < this.rows; r ++) {
for(let c = 0; c < this.cols; c ++) {
if(g[r][c])
this.walls.push(new Wall(r, c, this));
}
}
}

start() {
this.create_walls();
}

实现效果如下:

image-20230310110636815

在地图中随机生成障碍物

实现步骤:

使用随机函数,随机生成两个横纵坐标,然后判断两个横纵坐标对应的位置是否有障碍物,如果有需要重新生成。总共需要生成this.inner_walls_count / 2个随机位置,因为保证对角线对称的话需要同时设置两个位置为true。

需要注意的:

  • 在地图中(除四周的墙之外),左上角和右下角不应该生成障碍物,因为是蛇的起点。所以在生成随机障碍物时需要进行判断。
  • 需要保证从左下角到右上角是连通的,使用flood fill算法判断连通性,如果发现不连通,则需要重新生成障碍物。
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
// 判断两个蛇的起点是否连通, 使用Flodd Fill 算法
check_connectivity(g, sx, sy, tx, ty) {
if(sx == tx && sy == ty) return true;
g[sx][sy] = true;

let dx = [-1, 0, 1, 0], dy = [0, 1, 0, -1];
for(let i = 0; i < 4; i ++) {
let x = sx + dx[i], y = sy + dy[i];
if(!g[x][y] && this.check_connectivity(g, x, y, tx, ty))
return true;
}
return false;
}

create_walls() {
const g = []; // 布尔数组,如果某个位置是墙,则为true,否则为false
for(let r = 0; r < this.rows; r ++) {
g[r] = [];
for(let c = 0; c < this.cols; c ++) {
g[r][c] = false;
}
}

// 给四周加上墙
for(let r = 0; r < this.rows; r ++)
g[r][0] = g[r][this.cols - 1] = true;
for(let c = 0; c < this.cols; c ++)
g[0][c] = g[this.rows - 1][c] = true;

// 创建随机障碍物
for(let i = 0; i < this.inner_walls_count / 2; i ++) {
for(let j = 0; j < 1000; j ++) { // 获得随机位置
let r = parseInt(Math.random() * this.rows);
let c = parseInt(Math.random() * this.cols);
if(g[r][c] || g[c][r]) continue; // 该位置已经是障碍物了
if(r == this.rows - 2 && c == 1 || r == 1 && c == this.cols - 2) // 如果是左下角或者是右上角
continue;

g[r][c] = g[c][r] = true;
break;
}
}

const copy_g = JSON.parse(JSON.stringify(g)); // 将g复制一遍
if(!this.check_connectivity(copy_g, this.rows - 2, 1, 1, this.cols - 2)) {
return false; // 不连通
}
// 绘制墙
for(let r = 0; r < this.rows; r ++) {
for(let c = 0; c < this.cols; c ++) {
if(g[r][c])
this.walls.push(new Wall(r, c, this));
}
}
return true;
}

start() {
for(let i = 0; i <= 1000; i ++){ // 循环一千次,绘制障碍物,直到连通
if (this.create_walls())
break;
}
}

最终实现效果如下:

image-20230310151851129

创建菜单和游戏界面⭐

修改地图

原因:上次实现的地图是一个13 * 13的正方形,可能会造成一种情况:在某一时刻后,两个选手的操作会造成两条蛇头走到同一个格子。降低了游戏的公平性。因此需要进行修改。

思想:我们只需要将两条蛇的坐标 改为和为奇数 的情况,也就是说将整张地图改为13 * 14大小。

修改代码:

1
2
3
// GameMap.js 中
this.rows = 13;
this.cols = 14;

连带修改:

原因:修改地图后会造成游戏地图变成长方形,不能实现主对角线轴对称,所以需要把轴对称改为 中心对称 。实现方式如下:

GameMap.js中修改随机障碍物的生成办法。

1
2
3
4
5
6
7
8
9
10
11
12
// GameMap.js 中
// 创建随机障碍物
for(let i = 0; i < this.inner_walls_count / 2; i ++) {
for(let j = 0; j < 1000; j ++) {
let r = parseInt(Math.random() * this.rows);
let c = parseInt(Math.random() * this.cols);
if(g[r][c] || g[this.rows - 1 - r][this.cols - 1 - c]) continue; // 因为地图是长方形,所以需要实现障碍物中心对称
if(r == this.rows - 2 && c == 1 || r == 1 && c == this.cols - 2) continue;
g[r][c] = g[this.rows - 1 - r][this.cols - 1 - c] = true;
break;
}
}

实现蛇头

实现思想:本质上蛇是由一堆联系的格子组成的序列,所以只需要把格子记录下来即可。所以需要实现一个类Cell.js来保存组成蛇的格子信息。

创建Cell.js用于存储蛇所占用的格子信息

1
2
3
4
5
6
7
8
9
export class Cell{
constructor(r, c) {
this.r = r;
this.c = c;
// 将横纵坐标替换为canvas的坐标系
this.x = c + 0.5;
this.y = r + 0.5;
}
}

实现Snake.js

  • 创建Snake.js对象,进行蛇的绘制渲染。其中会使用数组cells来保存该条蛇所占用的格子信息。cells[0]是蛇头。
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 { AcGameObject } from "./AcGameObject";
import { Cell } from "./Cell";

export class Snake extends AcGameObject {
constructor(info, gamemap) { // info 是一个哈希表,存放蛇的各种信息{id,color,r,c}
super();

this.id = info.id; // 取出对应蛇的id
this.color = info.color;
this.gamemap = gamemap;

this.cells = [new Cell(info.r, info.c)]; // 用来保存组成该条蛇的所有格子,其中cells[0]存放蛇头
}

start() {

}

update() {
this.render();
}

render() {
const L = this.gamemap.L;
const ctx = this.gamemap.ctx;
ctx.fillStyle = this.color;
for(const cell of this.cells) { // 遍历画蛇的所有身体
ctx.beginPath(); // 开启画圆的路径
ctx.arc(cell.x * L, cell.y * L, L / 2, 0, Math.PI * 2); // (圆心横坐标,圆心纵坐标,半径,起始角度,终止角度)
ctx.fill(); // 填充下颜色
}
}
}
  • 修改GameMap.js,在GameMap.js中的构造函数创建两条蛇的对象。
1
2
3
4
5
// GameMap.js constructor 中添加
this.Snakes = {
new Snake({id : 0, color : "#4876ec", r : this.rows - 2, c : 1}, this),
new Snake({id : 1, color : "#f94848", r : 1, c : this.cols - 2}, this),
}

实现效果

image-20220723145947427

实现蛇的移动

实现思想:

移动应该是连贯的,但是蛇的身体是由一格一格连续的格子组成的

中间保持不动,头和尾动,在头部创建一个新的节点,朝着目的地移动。尾巴朝着目的地动

蛇移动的条件

同时获取到 两个人 / 两个机器 的操作才能够移动

最简单的移动

Snake.js中添加代码,实现蛇头的向右移动。

  • 首先定义一个变量this.speed用来保存 蛇的移动速度,
  • 添加update_move()函数,实现小球的位置变化,在update()函数中调用,实现每帧刷新一次位置。
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
import { AcGameObject } from "./AcGameObject";
import { Cell } from "./Cell";

export class Snake extends AcGameObject {
constructor(info, gamemap) { // info 是一个哈希表,存放蛇的各种信息{id,color,r,c}
super();

this.id = info.id; // 取出对应蛇的id
this.color = info.color;
this.gamemap = gamemap;

this.cells = [new Cell(info.r, info.c)]; // 用来保存组成该条蛇的所有格子,其中cells[0]存放蛇头

this.speed = 5; // 蛇每秒钟走5个格子
}

update_move() { // 实现每帧蛇头的位置变化
this.cells[0].x += this.speed * this.timedelta / 1000; // this.timedelta在AcGameObject中定义,表示当前帧距离上一帧的时间间隔。
}


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

所呈现的效果是:两个小球(蛇头)一直向右移动。

实现连贯的移动⭐

可能存在的问题:也就是中间某个状态,没有完全移出去,蛇的身子会出现问题。

解决办法

  • 中间不动,首尾动!创建的虚拟节点朝着目的地移动。只有两个点动。
  • 考虑蛇什么时候动? 回合制游戏,两个人都有输入的时候,才可以移动。

修改Snake.js
需要在Snake.js的构造函数中加入两个新的变量:

  • this.direction:用来保存蛇的移动方向,-1表示没有指令,0,1,2,3分别表示上右下左。初始为-1.
  • this.status:表示蛇的装填,idle表示静止,move表示正在移动,die表示死亡。初始为idle
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import { AcGameObject } from "./AcGameObject";
import { Cell } from "./Cell";

export class Snake extends AcGameObject {
constructor(info, gamemap) { // info 是一个哈希表,存放蛇的各种信息{id,color,r,c}
super();

this.id = info.id; // 取出对应蛇的id
this.color = info.color;
this.gamemap = gamemap;

this.cells = [new Cell(info.r, info.c)]; // 用来保存组成该条蛇的所有格子,其中cells[0]存放蛇头
this.next_cell = null; // 下一步的目标位置

this.speed = 5; // 蛇每秒钟走5个格子

this.direction = -1; // -1 表示没有指令,0,1,2,3 分别表示上右下左
this.status = "idle"; // idle 表示静止,move 表示正在移动,die表示死亡。
}
}

然后需要有一个“裁判”来判断两条蛇是否可以进入下一回合,但是参赛者不能当“裁判”,因此需要把判断的代码写在GameMap.js中。代码如下:

  • 当某一条蛇的状态还是idle静止的时候,则不能进入下一回合。
  • 当某条蛇的移动方向还没有确认的时候,则不能进入下一回合。
1
2
3
4
5
6
7
check_ready() {  // 判断两条蛇是否都准备好下一回合
for(const snake of this.snakes) {
if(snake.status !== "idle") return false; // 不是静止,不能进入下一回合
if(snake.direction === -1) return false; // 没有给出下一步的指令,则不能进入下一回合
}
return true;
}

然后在Snake.js中实现一个函数next_step(),作用是将该条蛇的状态改为走下一步,并且记录下一步格子的信息。

  • 首先需要定义一个变量this.next_cell来记录蛇下一步走的格子信息,初始为null
  • 需要定义drdc来获得蛇头的偏移量。this.step来记录当前正在进行的回合数,因为有的回合需要增加设的长度,有的回合不需要。
  • 实现函数next_step()来讲该条蛇的状态改为走下一步。
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
import { AcGameObject } from "./AcGameObject";
import { Cell } from "./Cell";

export class Snake extends AcGameObject {
constructor(info, gamemap) { // info 是一个哈希表,存放蛇的各种信息{id,color,r,c}
super();

this.id = info.id; // 取出对应蛇的id
this.color = info.color;
this.gamemap = gamemap;

this.cells = [new Cell(info.r, info.c)]; // 用来保存组成该条蛇的所有格子,其中cells[0]存放蛇头
this.next_cell = null; // 下一步的目标位置

this.speed = 5; // 蛇每秒钟走5个格子

this.direction = -1; // -1 表示没有指令,0,1,2,3 分别表示上右下左
this.status = "idle"; // idle 表示静止,move 表示正在移动,die表示死亡。

// 蛇头移动方向上的偏移量坐标
this.dr = [-1, 0, 1, 0];
this.dc = [0, 1, 0, -1];

this.step = 0; //表示回合数,用来判断蛇是否需要变长
}

next_step() { // 将蛇的状态变为走下一步
const d = this.direction; // 获得蛇的偏移方向
this.next_cell = new Cell(this.cells[0].r + this.dr[d], this.cells[0].c + this.dc[d]); // 下一步的格子
this.direction = -1; // 清空方向
this.status = "move";
this.step ++;
}
}

GameMap.js中实现每帧更新蛇的状态。

  • update()函数中判断两条蛇是否已经准好进入下一回合了(由check_ready()判断)
  • 如果已经准备好了,则调用next_step()函数让两条蛇进入下一回合。这里进行封装。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
check_ready() { // 判断两条蛇是否准备下一回合了
for (const snake of this.snakes) {
if (snake.status !== "idle") return false;
if (snake.direction === -1) return false;
}
return true;
}

next_step() { // 让两条蛇进入下一回合
for(const snake of this.snakes) {
snake.next_step(); // 调用Snake.js中的next_step函数
}
}

update() {
this.update_size();
if(this.check_ready()) { // 两条蛇都准备好进入下一回合了
this.next_step(); // 让两条蛇进入下一回合
}
this.render();
}

实现读取键盘的操作

从键盘获取w a s d 来控制两条蛇。

GameMap.vue中修改,添加tabindex = "0"就可以输入用户操作。

1
2
3
<canvas ref="canvas" tabindex="0">

</canvas>

绑定事件

Snake.js中加入一个辅助函数set_direction(),用来设置蛇的移动方向。

1
2
3
4
5
// Snake.js
//辅助函数
set_direction(d) {
this.direction = d;
}

GameMap.js中修改,添加监听事件add_listening_events()函数,用于监听用户的输入。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// GameMap.js
add_listening_events() {
this.ctx.canvas.focus();

const [snake0, snake1] = this.snakes;
this.ctx.canvas.addEventListener("keydown", e => {
// 设置第一条蛇的移动方向
if (e.key === 'w') snake0.set_direction(0);
else if (e.key === 'd') snake0.set_direction(1);
else if (e.key === 's') snake0.set_direction(2);
else if (e.key === 'a') snake0.set_direction(3);
// 设置第二条蛇的移动方向
else if (e.key === 'ArrowUp') snake1.set_direction(0);
else if (e.key === 'ArrowRight') snake1.set_direction(1);
else if (e.key === 'ArrowDown') snake1.set_direction(2);
else if (e.key === 'ArrowLeft') snake1.set_direction(3);
});
}

Snake.js中更新状态,让每一帧执行一次更新状态。就是在update()函数中调用add_listening_events()

1
2
3
4
5
6
7
8
// Snake.js
update() { // 每一帧执行一次
if (this.status === 'move') {
this.uppdate_move()
}

this.render();
}

实现真正的移动

蛇头移动

之前在Snake.jsnext_step()函数中已经获得了移动的目标位置this.next_cell,同时需要修改next_step()函数,将蛇的每个节点(格子)在数组向后移动一位。

然后需要修改update_move()函数。

  • 首先获得当前蛇头和目标位置之间x方向和y方向的偏移量dxdy,然后用勾股定理求出直线偏移量distance
  • 如果直线偏移量已经足够小(< 1e-2)则已经到达目标节点,就需要更新蛇头位目标节点。并将目标节点清空。
  • 如果还没有走到目标节点,则需将蛇头位置进行偏移

最后,修改update()函数,当检测到该蛇的状态是”move”的时候,才进行移动(调用update_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
72
73
74
75
76
77
78
79
80
81
82
import { AcGameObject } from "./AcGameObject";
import { Cell } from "./Cell";

export class Snake extends AcGameObject {
constructor(info, gamemap) { // info 是一个哈希表,存放蛇的各种信息{id,color,r,c}
super();

this.id = info.id; // 取出对应蛇的id
this.color = info.color;
this.gamemap = gamemap;

this.cells = [new Cell(info.r, info.c)]; // 用来保存组成该条蛇的所有格子,其中cells[0]存放蛇头
this.next_cell = null; // 下一步的目标位置

this.speed = 5; // 蛇每秒钟走5个格子

this.direction = -1; // -1 表示没有指令,0,1,2,3 分别表示上右下左
this.status = "idle"; // idle 表示静止,move 表示正在移动,die表示死亡。

// 蛇头移动方向上的偏移量坐标
this.dr = [-1, 0, 1, 0];
this.dc = [0, 1, 0, -1];

this.step = 0; //表示回合数,用来判断蛇是否需要变长
this.eps = 1e-2; // 允许的误差,当两个点的坐标相差0.01的时候,则认为两个点走在一起了
}


start() {
}

set_direction(d) { // 设置下一步的移动方向
this.direction = d;
}

next_step() { // 将蛇的状态变为走下一步
const d = this.direction;
this.next_cell = new Cell(this.cells[0].r + this.dr[d], this.cells[0].c + this.dc[d]); // 下一步的格子
this.direction = -1; // 清空方向
this.status = "move";
this.step ++;

const k = this.cells.length;
for(let i = k; i > 0; i --) { // 蛇的所有节点(格子)向后移动一位
this.cells[i] = JSON.parse(JSON.stringify(this.cells[i - 1]));
}
}

update_move() {
const dx = this.next_cell.x - this.cells[0].x;
const dy = this.next_cell.y - this.cells[0].y;
const distance = Math.sqrt(dx * dx + dy * dy);

if(distance < this.eps) { // 当已经走到目标节点了
this.cells[0] = this.next_cell; // 更新蛇头位置
this.next_cell = null; // 将目标节点清空
this.status = "idle"; // 走完了,停下来
} else {
const move_distance = this.speed * this.timedelta /1000; // 没两帧之间走过的距离
this.cells[0].x += move_distance * dx / distance;
this.cells[0].y += move_distance * dy / distance;
}
}


update() { // 每一帧执行一次,每秒钟执行60次
if(this.status === "move")
this.update_move();
this.render();
}

render() {
const L = this.gamemap.L;
const ctx = this.gamemap.ctx;
ctx.fillStyle = this.color;
for(const cell of this.cells) { // 遍历画蛇的所有身体
ctx.beginPath(); // 开启画圆的路径
ctx.arc(cell.x * L, cell.y * L, L / 2, 0, Math.PI * 2); // (圆心横坐标,圆心纵坐标,半径,起始角度,终止角度)
ctx.fill(); // 填充下颜色
}
}
}

蛇尾移动

蛇尾在长度需要增加的回合时,不需要移动;在长度不增加的回合时,是需要移动的。

这里时在前10回合时每回合蛇增加长度1,在大于10回合时,每三回合长度增加1。

因此首先需要判断在当前回合中,蛇的长度是否需要增加。在Snake.js中添加函数check_tail_increasing()函数。

1
2
3
4
5
6
// Snake.js
check_tail_increasing() { // 检查当前回合蛇的长度是否需要增加。
if(this.step < 10) return true; // 前10步增加长度
if(this.step % 3 === 1) return true; // 大于10步每三步增加一次长度
return false;
}

然后在update_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
update_move() {
const dx = this.next_cell.x - this.cells[0].x;
const dy = this.next_cell.y - this.cells[0].y;
const distance = Math.sqrt(dx * dx + dy * dy);

if(distance < this.eps) { // 当已经走到目标节点了
this.cells[0] = this.next_cell; // 更新蛇头位置
this.next_cell = null; // 将目标节点清空
this.status = "idle"; // 走完了,停下来

if (!this.check_tail_increasing()) { // 蛇不变长
this.cells.pop();
}
} else {
const move_distance = this.speed * this.timedelta /1000; // 没两帧之间走过的距离
this.cells[0].x += move_distance * dx / distance;
this.cells[0].y += move_distance * dy / distance;
if (!this.check_tail_increasing()) { // 不增加长度
const k = this.cells.length;
const tail = this.cells[k - 1], tail_target = this.cells[k - 2]; // 此时蛇尾的位置,和蛇尾前一个位置。
const tail_dx = tail.tail_target.x - tail.x;
const tail_dy = tail_target.y - tail.y;
this.x += move_distance * tail_dx / distance;
this.y += move_distance * tail_dy / distance;
}
}
}

实现效果

image-20220723164245732

优化蛇

蛇身美化

为了使蛇美观一点,需要将蛇的身体连贯一点。具体的实现方法是在相邻的两个蛇之间使用一个矩形对其进行覆盖。

Snake.js中的render()函数中修改:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// Snake.js
render() {
const L = this.gamemap.L;
const ctx = this.gamemap.ctx;
ctx.fillStyle = this.color;
for(const cell of this.cells) { // 遍历画蛇的所有身体
ctx.beginPath(); // 开启画圆的路径
ctx.arc(cell.x * L, cell.y * L, L / 2, 0, Math.PI * 2); // (圆心横坐标,圆心纵坐标,半径,起始角度,终止角度)
ctx.fill(); // 填充下颜色
}

for(let i = 1; i < this.cells.length; i ++) {
const a = this.cells[i - 1], b = this.cells[i];
if(Math.abs(a.x - b.x) < this.eps && Math.abs(a.y - b.y) < this.eps) // 当两个节点足够重合,则不用覆盖
continue;
if(Math.abs(a.x - b.x) < this.eps) {
ctx.fillRect((a.x - 0.5) * L, Math.min(a.y, b.y) * L, L, Math.abs(a.y - b.y) * L);
} else {
ctx.fillRect(Math.min(a.x, b.x) * L, (a.y - 0.5) * L, Math.abs(a.x - b.x) * L, L);
}
}
}

实现效果

image-20230310204431685

可以看到,蛇有点粗,当转弯的时候,会和自己的身体紧贴。因此,需要在render()函数中对蛇的宽度进行修改。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// Snake.js
render() {
const L = this.gamemap.L;
const ctx = this.gamemap.ctx;
ctx.fillStyle = this.color;
for(const cell of this.cells) { // 遍历画蛇的所有身体
ctx.beginPath(); // 开启画圆的路径
ctx.arc(cell.x * L, cell.y * L, L / 2 * 0.8, 0, Math.PI * 2); // (圆心横坐标,圆心纵坐标,半径,起始角度,终止角度)
ctx.fill(); // 填充下颜色
}

for(let i = 1; i < this.cells.length; i ++) {
const a = this.cells[i - 1], b = this.cells[i];
if(Math.abs(a.x - b.x) < this.eps && Math.abs(a.y - b.y) < this.eps)
continue;
if(Math.abs(a.x - b.x) < this.eps) {
ctx.fillRect((a.x - 0.4) * L, Math.min(a.y, b.y) * L, L * 0.8, Math.abs(a.y - b.y) * L);
} else {
ctx.fillRect(Math.min(a.x, b.x) * L, (a.y - 0.4) * L, Math.abs(a.x - b.x) * L, L * 0.8);
}
}
}

实现效果

image-20230310205139904

撞墙判定

  • 首先撞墙判定属于“裁判”的工作,所以需要写在GameMap.js中,实现check_valid(cell)函数,判定下一位置是否合法:
    • 当下一位置是障碍物,则不合法
    • 当下一位置是蛇的身体,如果是尾巴,需要判断尾巴是否会前进(调用check_tail_increasing()函数),如果会前进,则合法,否则遍历两个蛇的身体,如果是任何一处,都不合法。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// GameMap.js
check_valid(cell) { // 检测目标位置是否合法: 没有撞到两条蛇的身体,或者障碍物
for(const wall of this.walls) {
if(wall.r === cell.r && wall.c === cell.c) return false; // 如果下一位置是障碍物
}
for(const snake of this.snakes) {
let k = snake.cells.length;
if(!snake.check_tail_increasing()) { // 当蛇尾会前进的时候
k --;
}
for(let i = 0; i < k; i ++) { // 遍历蛇的每个节点,判断是否会装上。
if(snake.cells[i].r === cell.r && snake.cells[i].c === cell.c)
return false;
}
}
return true;
}

Snake.jsnext_step()函数中判断下一位置是否合法,如果不合法,需要将蛇的状态修改为die.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// Snake.js
next_step() { // 将蛇的状态变为走下一步
const d = this.direction;
this.next_cell = new Cell(this.cells[0].r + this.dr[d], this.cells[0].c + this.dc[d]); // 下一步的格子
this.direction = -1; // 清空方向
this.status = "move";
this.step ++;

const k = this.cells.length;
for(let i = k; i > 0; i --) { // 蛇的所有节点(格子)向后移动一位
this.cells[i] = JSON.parse(JSON.stringify(this.cells[i - 1]));
}

if(!this.gamemap.check_valid(this.next_cell)) { // 如果下一位置不合法
this.status = "die";
}
}

同时需要在render()函数中进行修改,如果判断到该条蛇的状态时die时,将蛇的颜色渲染为白色。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// Snake.js
render() {
const L = this.gamemap.L;
const ctx = this.gamemap.ctx;
ctx.fillStyle = this.color;
if(this.status === "die") // 新增
ctx.fillStyle = "white";
for(const cell of this.cells) { // 遍历画蛇的所有身体
ctx.beginPath(); // 开启画圆的路径
ctx.arc(cell.x * L, cell.y * L, L / 2 * 0.8, 0, Math.PI * 2); // (圆心横坐标,圆心纵坐标,半径,起始角度,终止角度)
ctx.fill(); // 填充下颜色
}

for(let i = 1; i < this.cells.length; i ++) {
const a = this.cells[i - 1], b = this.cells[i];
if(Math.abs(a.x - b.x) < this.eps && Math.abs(a.y - b.y) < this.eps)
continue;
if(Math.abs(a.x - b.x) < this.eps) {
ctx.fillRect((a.x - 0.4) * L, Math.min(a.y, b.y) * L, L * 0.8, Math.abs(a.y - b.y) * L);
} else {
ctx.fillRect(Math.min(a.x, b.x) * L, (a.y - 0.4) * L, Math.abs(a.x - b.x) * L, L * 0.8);
}
}
}

实现后效果,当判定失败后,不能进行任何操作,因为此时状态为die。

image-20230310210422868

实现眼睛

需要注意的是,蛇上一刻移动的方向决定了眼睛的位置。因此需要在Snake.js中的构造函数中定义几个变量:

  • this.eye_direction 保存蛇眼睛的方向。起始就是下一步移动的方向。需要在next_step()函数中进行更新,两条蛇的初始方向不同。
  • this.eye_dxthis.eye_dy 蛇眼睛不同方向的偏移量
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
// Snake.js
this.eye_direction = 0;
if(this.id === 1) this.eye_direction = 2; // 左下角蛇初始朝上,右上角的蛇朝下

this.eye_dx = [ // 蛇眼睛不同方向的x的偏移量(两个眼睛)
[-1, 1],
[1, 1],
[1, -1],
[-1, -1],
];
this.eye_dy = [ // 蛇眼睛不同方向的y的偏移量
[-1, -1],
[-1, 1,],
[1, 1],
[1, -1],
];

next_step() { // 将蛇的状态变为走下一步
const d = this.direction;
this.next_cell = new Cell(this.cells[0].r + this.dr[d], this.cells[0].c + this.dc[d]); // 下一步的格子
this.direction = -1; // 清空方向
this.status = "move";
this.step ++;

const k = this.cells.length;
for(let i = k; i > 0; i --) { // 蛇的所有节点(格子)向后移动一位
this.cells[i] = JSON.parse(JSON.stringify(this.cells[i - 1]));
}

if(!this.gamemap.check_valid(this.next_cell)) { // 如果下一位置不合法
this.status = "die";
}

this.eye_direction = d; // 更新蛇眼的方向。
}

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
render() {
const L = this.gamemap.L;
const ctx = this.gamemap.ctx;
ctx.fillStyle = this.color;
if(this.status === "die")
ctx.fillStyle = "white";
for(const cell of this.cells) { // 遍历画蛇的所有身体
ctx.beginPath(); // 开启画圆的路径
ctx.arc(cell.x * L, cell.y * L, L / 2 * 0.8, 0, Math.PI * 2); // (圆心横坐标,圆心纵坐标,半径,起始角度,终止角度)
ctx.fill(); // 填充下颜色
}

for(let i = 1; i < this.cells.length; i ++) {
const a = this.cells[i - 1], b = this.cells[i];
if(Math.abs(a.x - b.x) < this.eps && Math.abs(a.y - b.y) < this.eps)
continue;
if(Math.abs(a.x - b.x) < this.eps) {
ctx.fillRect((a.x - 0.4) * L, Math.min(a.y, b.y) * L, L * 0.8, Math.abs(a.y - b.y) * L);
} else {
ctx.fillRect(Math.min(a.x, b.x) * L, (a.y - 0.4) * L, Math.abs(a.x - b.x) * L, L * 0.8);
}
}

// 绘制眼睛
ctx.fillStyle = "black";
for(let i = 0; i < 2; i ++) {
const eye_x = (this.cells[0].x + this.eye_dx[this.eye_direction][i] * 0.15) * L;
const eye_y = (this.cells[0].y + this.eye_dy[this.eye_direction][i] * 0.15) * L;
ctx.beginPath();
ctx.arc(eye_x, eye_y, L * 0.05, 0, Math.PI * 2);
ctx.fill();
}
}

实现效果

image-20230310213019942

正在加载今日诗词....