SpringBoot学习笔记-创建菜单与游戏页面(上)

  1. 1. 页面创建与导航栏实现
  2. 2. 导航栏高亮功能
  3. 3. 运动目标基类实现
  4. 4. 游戏地图随机生成

本节内容为实现游戏界面的导航栏功能以及游戏地图的随机生成功能。

1. 页面创建与导航栏实现

首先我们解除一下 Vue 的文件驼峰命名检查,在前端的根目录 web 下的 package.json 文件的 rules 中添加一行代码,然后重启前端项目:

1
"vue/multi-word-component-names": "off"

我们先在 src/views 目录下创建每个页面的组件存放的目录:pkrecordranklistuser/mybotserror,分别表示对战页面、对战记录页面、排行榜页面、我的 Bots 页面、报错页面。然后在这些目录中分别创建对应的索引页面组件:PKIndexView.vueRecordIndexView.vueRanklistIndexView.vueMyBotsIndexView.vueNotFoundView.vue(之后组件称呼也可能不带 .vue 后缀),每个页面参照以下代码先初始化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<template>
<Card>
对战
</Card>
</template>

<script>
import Card from "@/components/Card.vue";

export default {
components: {
Card,
},
};
</script>

<style scoped></style>

其中 Card 组件我们创建在 src/components 目录下,实现的效果是将内容包裹在一个卡片中,内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<template>
<div class="container">
<div class="card">
<div class="card-body">
<slot></slot>
</div>
</div>
</div>
</template>

<script></script>

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

接下来我们需要设置路由,即根据不同的 URL 渲染不同的组件,Vue 的路由在 src/router 目录下的 index.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
import { createRouter, createWebHistory } from "vue-router";
import PKIndexView from "@/views/pk/PKIndexView";
import RecordIndexView from "@/views/record/RecordIndexView";
import RanklistIndexView from "@/views/ranklist/RanklistIndexView";
import MyBotsIndexView from "@/views/user/mybots/MyBotsIndexView";
import NotFoundView from "@/views/error/NotFoundView";

const routes = [
{
path: "/",
name: "home",
redirect: "/pk/", // 如果是根路径则重定向到对战页面
},
{
path: "/pk/",
name: "pk_index",
component: PKIndexView,
},
{
path: "/record/",
name: "record_index",
component: RecordIndexView,
},
{
path: "/ranklist/",
name: "ranklist_index",
component: RanklistIndexView,
},
{
path: "/user/mybots/",
name: "user_mybots_index",
component: MyBotsIndexView,
},
{
path: "/404/",
name: "404",
component: NotFoundView,
},
{
path: "/:catchAll(.*)",
name: "others",
redirect: "/404/", // 如果不是以上路径之一说明不合法,重定向到404页面
},
];

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

export default router;

src/components 目录下创建导航栏组件 NavBar,然后可以通过 Bootstrap 快速构建一个导航栏。

首先在根 JS 文件 src/main.js 中引入 Bootstrap:

1
2
3
4
5
6
7
8
import { createApp } from "vue";
import App from "./App.vue";
import router from "./router";
import store from "./store";
import "bootstrap/dist/css/bootstrap.css";
import "bootstrap/dist/js/bootstrap";

createApp(App).use(store).use(router).mount("#app");

这时候会报错,提示我们没有找到模块 @popperjs/core,这时我们需要去项目管理页面的依赖页面中安装 @popperjs/core 依赖。

然后我们实现导航栏 NavBar,其中我们使用 <router-link> 标签替代 <a> 标签可以将后端渲染改成前端渲染:

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
<template>
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
<div class="container">
<router-link class="navbar-brand" :to="{ name: 'home' }">King of Bots</router-link>
<div class="collapse navbar-collapse" id="navbarText">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item">
<router-link class="nav-link" aria-current="page" :to="{ name: 'pk_index' }">对战</router-link>
</li>
<li class="nav-item">
<router-link class="nav-link" :to="{ name: 'record_index' }">对局列表</router-link>
</li>
<li class="nav-item">
<router-link class="nav-link" :to="{ name: 'ranklist_index' }">排行榜</router-link>
</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">
AsanoSaki
</a>
<ul class="dropdown-menu">
<li>
<router-link class="dropdown-item" :to="{ name: 'user_mybots_index' }">My Bots</router-link>
</li>
<li><hr class="dropdown-divider" /></li>
<li><a class="dropdown-item" href="#">退出</a></li>
</ul>
</li>
</ul>
</div>
</div>
</nav>
</template>

<script></script>

<style scoped></style>

2. 导航栏高亮功能

我们可以判断当前在哪个页面,将对应的导航栏图标高亮,高亮的实现是在对应的标签中添加 active 类。因此我们需要取得当前页面,可以用 vue-router 中的 useRoute 实现,然后通过 computed 实时计算其 name 是什么。在 HTML 标签中如果我们想让某个属性的值是一个表达式可以加一个 :,例如将 class 改成 :class

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
<template>
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
<div class="container">
<router-link class="navbar-brand" :to="{ name: 'home' }">King of Bots</router-link>
<div class="collapse navbar-collapse" id="navbarText">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item">
<router-link :class="route_name == 'pk_index' ? 'nav-link active' : 'nav-link'" aria-current="page" :to="{ name: 'pk_index' }">对战</router-link>
</li>
<li class="nav-item">
<router-link :class="route_name == 'record_index' ? 'nav-link active' : 'nav-link'" :to="{ name: 'record_index' }">对局列表</router-link>
</li>
<li class="nav-item">
<router-link :class="route_name == 'ranklist_index' ? 'nav-link active' : 'nav-link'" :to="{ name: 'ranklist_index' }">排行榜</router-link>
</li>
</ul>
...
</div>
</div>
</nav>
</template>

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

<style scoped></style>

3. 运动目标基类实现

看过 Django 或者 Web 笔记应该对这个概念不陌生了,游戏中运动的目标实现原理是利用浏览器每秒的刷新机制绘制若干帧图片,假设浏览器每秒钟刷新60次也就是60帧,那么 JS 的 requestAnimationFrame(func) 函数会在浏览器渲染下一帧前执行一遍 func 函数,然后我们在 func 函数内递归调用 requestAnimationFrame 函数即可实现每一帧执行一遍。

我们在 src/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
const AC_GAME_OBJECTS = []; // 存储所有运动对象

export class AcGameObject {
constructor() {
AC_GAME_OBJECTS.push(this);
this.timedelta = 0; // 当前帧距离上一帧的时间间隔,浏览器每一帧时间间隔可能有误差不一样因此需要记录
this.has_called_start = false; // 是否执行过start函数
}

start() { // 只在对象创建时执行一次
}

update() { // 除了第一帧外每一帧执行一次
}

on_destroy() { // 在删除对象之前可能需要执行的某些操作
}

destroy() { // 将当前对象删除,即从AC_GAME_OBJECTS中移除
this.on_destroy();

for (let i in AC_GAME_OBJECTS) { // 用in遍历下标
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遍历值
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);

4. 游戏地图随机生成

我们设置一个正方形的游戏地图,最外围一圈为障碍物,中间的区域随机生成几个障碍物,因为需要具备公平性因此障碍物需要对称设计,左下角和右上角为两名玩家的起始位置。

地图和障碍物每帧都需要渲染(render)一遍,首先在 scripts 目录下创建 GameMap.js 表示游戏地图的对象,我们在设计的时候需要用相对距离,要能够根据网页的变化动态调整画布的大小,假设我们的地图是13*13个单位的大小,可以用 L 表示一个单位的长度:

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

export class GameMap extends AcGameObject {
constructor(ctx, parent) { // ctx表示画布,parent表示画布的父元素
super();

this.ctx = ctx;
this.parent = parent;
this.L = 0; // 一个单位的绝对长度
}

start() {}

update() {
this.render();
}

render() {}
}

PKIndexView 中需要存一个东西叫做游戏区域,我们也可以将其写成一个组件,在 components 目录下创建 PlayGround 组件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<template>
<div class="playground">
<GameMap />
</div>
</template>

<script>
import GameMap from "@/components/GameMap.vue";

export default {
components: {
GameMap,
},
};
</script>

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

由于游戏区域可能不仅只有地图,还可能有记分板之类的组件,因此我们再创建一个单独的地图组件 GameMap,地图是绘制在 Canvas 上的,可以使用 HTML 标签 <canvas> 实现,然后我们要引入之前创建的 GameMap.js 中的对象,在标签中使用 ref 属性可以让标签和我们定义的对象关联在一起,组件挂载完后我们需要创建游戏地图对象,可以用 onMounted 实现,表示组件挂载完后需要执行的操作:

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
<template>
<div ref="parent" class="gamemap">
<canvas ref="canvas"></canvas>
</div>
</template>

<script>
import { ref, onMounted } from "vue";
import { GameMap } from "@/assets/scripts/GameMap";

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

onMounted(() => {
new GameMap(canvas.value.getContext("2d"), parent.value);
});

return {
parent,
canvas,
};
},
};
</script>

<style scoped>
div.gamemap {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
}
</style>

这样在 GameMap.js 中我们就可以动态计算画布区域的大小了,我们需要求出 PlayGround 组件的区域中面积最大的正方形区域(该组件的长宽会随浏览器大小的变化而变化):

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

export class GameMap extends AcGameObject {
constructor(ctx, parent) { // ctx表示画布,parent表示画布的父元素
super();

this.ctx = ctx;
this.parent = parent;
this.L = 0; // 一个单位的绝对长度
this.rows = 13; // 地图的行数
this.cols = 13; // 地图的列数
}

start() {}

update_size() { // 每一帧更新地图大小
this.L = parseInt(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() {
this.ctx.fillStyle = "green";
this.ctx.fillRect(0, 0, this.ctx.canvas.width, this.ctx.canvas.height); // 参数分别表示左上角横纵坐标和边长
}
}

现在我们要绘制地图的每一个格子,相邻的格子采用深浅不一样的绿色绘制,可以判断格子的横纵坐标相加的奇偶来区分:

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

export class GameMap extends AcGameObject {
constructor(ctx, parent) { // ctx表示画布,parent表示画布的父元素
...
}

start() {}

update_size() { // 每一帧更新地图大小
...
}

update() {
...
}

render() {
const color_even = "#AAD752", color_odd = "#A2D048";
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;
}
// canvas坐标系横轴是x,纵轴是y,与数组坐标系不同
this.ctx.fillRect(c * this.L, r * this.L, this.L, this.L);
}
}
}
}

我们创建障碍物对象,在 scripts 目录下创建 Wall.js

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

export class Wall extends AcGameObject {
constructor(r, c, gamemap) { // 需要传入第几行第几列以及GameMap
super();

this.r = r;
this.c = c;
this.gamemap = gamemap;
this.color = "#B47226";
}

update() {
this.render();
}

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

接着我们在 GameMap.js 中即可将障碍物绘制出来,我们创建 Wall 对象是在创建完 GameMap 对象之后进行的,根据 AcGameObject 的定义,Wall 每次会在 GameMap 之后刷新,因此会覆盖 GameMap。此处我们还需要判断两名玩家的出生地即左下角和右上角不能被障碍物覆盖,且两名玩家必须要连通:

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

export class GameMap extends AcGameObject {
constructor(ctx, parent) { // ctx表示画布,parent表示画布的父元素
super();

...

this.inner_walls_count = 20; // 地图内部的随机障碍物数量,需要是偶数
this.walls = []; // 所有的障碍物
}

check_connectivity(g, sx, sy, tx, ty) { // 用flood fill算法判断两名玩家是否连通
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 nx = sx + dx[i], ny = sy + dy[i];
if (!g[nx][ny] && this.check_connectivity(g, nx, ny, tx, ty)) // 地图已经有边界标记了因此无需判断越界
return true;
}
return false;
}

create_walls() {
const g = []; // 表示每个位置是否是障碍物

// 初始化障碍物标记数组
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 < 10000; 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 g_copy = JSON.parse(JSON.stringify(g)); // 复制一份g,可以先转换成json再转回来就是一份新的了
if (!this.check_connectivity(g_copy, 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 < 10000; i++) { // 暴力枚举直至生成合法的地图
if (this.create_walls())
break;
}
}

update_size() { // 每一帧更新地图大小
...
}

update() {
...
}

render() {
...
}
}

最后我们将网页的图标替换一下,将 LOGO 图标命名为 favicon.ico 然后替换掉 public 目录下的 favicon.ico 即可。然后修改一下网页的标题,如果 public 目录下的 index.html 中的标题为 <title><%= htmlWebpackPlugin.options.title %></title>,那么可以通过修改根目录的 vue.config.js 来修改标题,添加 chainWebpack 配置:

1
2
3
4
5
6
7
8
9
10
const { defineConfig } = require("@vue/cli-service");
module.exports = defineConfig({
transpileDependencies: true,
chainWebpack: (config) => {
config.plugin("html").tap((args) => {
args[0].title = "King of Bots";
return args;
});
},
});

修改后重启一下 Vue 即可。