本节内容为实现游戏界面的导航栏功能以及游戏地图的随机生成功能。
1. 页面创建与导航栏实现
首先我们解除一下 Vue 的文件驼峰命名检查,在前端的根目录 web
下的 package.json
文件的 rules
中添加一行代码,然后重启前端项目:
1 "vue/multi-word-component-names" : "off"
我们先在 src/views
目录下创建每个页面的组件存放的目录:pk
、record
、ranklist
、user/mybots
、error
,分别表示对战页面、对战记录页面、排行榜页面、我的 Bots 页面、报错页面。然后在这些目录中分别创建对应的索引页面组件:PKIndexView.vue
、RecordIndexView.vue
、RanklistIndexView.vue
、MyBotsIndexView.vue
、NotFoundView.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/" , }, ]; 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 ( ) { } 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 ) { 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 ) { 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 ) { 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 ) { ... } 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; } 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 ) { 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 ) { super (); ... this .inner_walls_count = 20 ; this .walls = []; } 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 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)); 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 即可。