King Of Bots - 菜单和游戏界面设计
当前进度代码:https://git.acwing.com/yuezi2048/kob/-/tree/66cd2e323de0325be54eaaf49b51e3b08be55cd5
一、导航栏界面与跳转
预期目标:我们要实现如下的页面以及响应的URL跳转(304),并且要求实现访问该界面时,对应的模块高亮。
页面我们一般放到view里面,我们每个模块建一个文件夹
1.1 公共子组件ContentField
每个Index界面的公共子组件ContentField.vue,这里有三个点
- 这里用到了bootstrap的card组件框起来,我们可以如下简写
- 我们待填充的部分使用的是slot标签
- 为了美化,我们通过content-field加上了一个上边距
<template>
<div class="container content-field">
<div class="card">
<div class="card-body">
<slot></slot>
</div>
</div>
</div>
</template>
<script>
</script>
<style scoped>
div.content-field {
margin-top: 20px;
}
</style>
最终的实现效果
1.2 实现route跳转
接下来我们要实现路由router跳转,这里主要是两个点
- 空路径的处理
- 其他路径 通过正则.* 匹配
- 我们导航栏在href 改路径从而实现重定向 但是这需要重新请求服务器
- 要实现不刷新,我们要改成router-link标签,:to来表示要跳转的路径名称和参数params(如果有的话)
import { createRouter, createWebHistory } from 'vue-router'
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 NotFoundView from '../views/error/NotFoundView'
const routes = [
{
path: "/",
name: "home",
redirect: "/pk/",
},
{
path: "/pk/",
name: "pk_index",
component: PkIndexView,
},
{
path: "/ranklist/",
name: "ranklist_index",
component: RankListIndexView,
},
{
path: "/record/",
name: "record_index",
component: RecordIndexView,
},
{
path: "/user/bot/",
name: "user_bot_index",
component: UserBotIndexView,
},
{
path: "/404/",
name: "404",
component: NotFoundView,
},
{
path: "/:catchAll(.*)",
redirect: "/404/",
}
]
const router = createRouter({
history: createWebHistory(),
routes
})
export default router
1.3 实现导航栏高亮
我们对NavBar.vue进行如下的优化:对每个选中的内容进行高亮(native)
-
用到了一个useRoute,通过setup()把路径取出来
-
还需要用到computed来进行简单计算(赋值)
-
也可以用route-active属性?这里的实现是用到了三元运算符和v-bind的结合了
<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>
<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">
Ling JunYi
</a>
<ul class="dropdown-menu">
<li><router-link class="dropdown-item" :to="{name: 'user_bot_index'}">我的Bot</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>
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>
最终实现效果
二、游戏地图
目标:游戏方分别在左下角和右上角,设置随机障碍物,要求关于中心对称,并且要确保他们起点能联通,并且每次刷新会生成一个随机地图。
待解决的问题
- 如何画出一个自适应的正方形对战平台?
- 如何设置随机障碍物,并且满足关于中心对称?
- 如何确保左下角和右上角的玩家之间可以联通?
后面可以再仔细思考的问题
- 怎么让蛇动起来?我们可以设置这些元器件每秒刷新多少帧(fps),后面的每一帧把前面的帧覆盖掉。
2.1 基类AcGameObject(公共)
在后面的内容进行之前,请注意,我们实际上是随着一帧一帧的往后移 不断迭代执行step函数来实现动画效果的,这一点务必要清晰。
2.1.1 帧的刷新
当然,我们每个组件每一帧都必然是要刷新一遍的,我们需要实现这个
浏览器默认一秒刷60帧,而我们可以用 requestAnimationFrame(step),其中参数是一个函数
我们可以让浏览器渲染前执行这个requestAnimationFrame,使得每一个帧都能执行以此step函数
const step = () => {
requestAnimationFrame(step); // 一帧一帧迭代执行step函数
}
requestAnimationFrame(step); // 在第一帧触发step函数
2.1.2 组件生命周期
考虑到地图中的每个子模块可能有公共部分,我们需要抽象出一个基类,后续我们在新建每个对象的时候继承这个基类AcGameObject.js,那么根据一个游戏开发的基本思想,基类里应该有start update destory这些生命周期.
接下来我们构造这个类需要一个构造方法constructor(),这里的逻辑是我们通过一个数组来存储游戏对象,每次构造的时候把对象加进去,并且附带两个属性,一个是是否调用了start函数(控制第一次和不是第一次的函数)以及两帧之间的时间间隔,我们用timedelta来表示。
然后我们销毁对象的时候用一个destory来遍历数组删除,用到了数组的splice这个方法。
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_destroty() { // 删除前执行
}
destroty() {
this.on_destroty();
for(let i in AC_GAME_OBJECTS) {
const obj = AC_GAME_OBJECTS[i];
if (obj === this) {
ACGameObject.splice(i); // 遍历数组,删除该游戏组件
break;
}
}
}
}
let last_timestamp; // 上一次执行的时刻 timestamp是现在的时刻
const step = (timestamp) => {
// 取出obj的值
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); // 一帧一帧迭代执行step函数
}
requestAnimationFrame(step); // 在第一帧触发step函数
2.2 子类GameMap(地图)
地图类 GameMap.js 继承基类
这里引申了另一个问题是,界面如何随窗口自适应,这也告诉我们不能用绝对像素来,应是相对的。
-
js里 import的时候 export default 不用加括号,非default要加括号(default类比java的public 每一个文件只能有一个default)
-
构造函数需要ctx画布 和 parent父类(有长宽等信息)
我们在GameMap.vue里通过onMounted来表示我组件挂载之后要执行新建地图的操作,这里我需要借助ref
<template>
<div ref="parent" class="gamemap">
<canvas ref="canvas">
</canvas>
</div>
</template>
<script>
import { GameMap } from '@/assets/scripts/GameMap';
import { ref, onMounted } from 'vue'; // onMounted是组件挂载后的操作
export default {
setup() {
let parent = ref(null);
let canvas = ref(null);
onMounted(() => {
new GameMap(canvas.value.getContext('2d'), parent.value) // new一个地图
});
return {
parent,
canvas,
}
}
}
</script>
2.2.1 绘制正方形自适应窗口GameMap
接下来就是要创建正方形区域的地图。
首先我们应当想到CSS里的vw和vh,我们可以首先绘制一个蓝色的自适应矩阵
我们新建一个组件PlayGround.vue代表我们的一个地图基底
-
vw vh是宽和搞占浏览器的百分之多少
-
margin: 0 auto 使其居中
<template>
<div class="playground"></div>
</template>
<script>
</script>
<style scoped>
div.playground {
width: 60vw;
height: 70vh;
background: lightblue;
margin: 0 auto;
margin-top: 40px;
}
</style>
我们在pk页面PkIndexView import进来
<template>
<PlayGround />
</template>
<script>
import PlayGround from '../../components/PlayGround'
export default {
components: {
PlayGround,
}
}
</script>
<style scoped>
</style>
至此,实现了一个矩阵的自适应
接下来,我们在这个矩阵的基础上绘制正方形,这就是我们所遇到的一个算法题,即:
我现在已知矩阵的宽width和高height,现在要让你生成矩阵里的13*13块的正方形,每块的最大边长this.L
具体的函数是这样的,ps:之所以js要用匿名函数,主要是为了避免this的指向问题
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; // 画布的高度
}
我们通过查找一下关于canvas的api来实现绘图功能
render() { // 渲染
this.ctx.fillStyle = 'green';
this.ctx.fillRect(0, 0, this.ctx.canvas.width, this.ctx.canvas.height);
}
然后我们只要重写update方法即可
update() {
this.update_size();
this.render();
}
这里我们要实现水平和垂直同时剧中,用到了flex,我们在GameMap.vue里更新一下
而我们之前都是margin是竖直剧中
<style scoped>
div.gamemap {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
}
</style>
我们把PlayGround展示去掉,就可以留下我们的游戏地图了。
而我们要实现最终的效果,我们要对奇数格和偶数格子的颜色进行判断渲染。ps:QQ截图ctrl+shift+c取色
render() { // 渲染
// this.ctx.fillStyle = 'green';
// this.ctx.fillRect(0, 0, this.ctx.canvas.width, this.ctx.canvas.height);
const color_even = "#A3D049", color_odd = "#A5DE4E";
for (let r = 0; r < this.cols; r ++ ) {
for (let c = 0; c < this.rows; 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);
}
}
}
2.3 子类Wall(障碍墙)
这是一个子组件,我们依旧是需要继承基类。
类似地,我们定义好宽度和高度,以及颜色,渲染函数。
后续我们通过其他组件向这个子类Wall.js传入我们的地图对象和宽度高度就可用生成这个墙了。
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 = "#AD702A";
}
update() {
this.render();
}
render() {
const L = this.gamemap.L; // 因为L是动态变化的,我们就需要动态取
const ctx = this.gamemap.ctx;
ctx.fillStyle = this.color;
ctx.fillRect(this.c * L, this.r * L, L, L);
}
}
2.3.1 生成四周封闭墙
首先,要明确一点,canvas坐标系横着是x,竖是y,和数组是行列反的
那么生成的函数如下,思路就是引入一个布尔数组判断这个下标是不是待定要放障碍物的(四周),接下来再遍历一遍数组,根据这个布尔来判断生成即可。这里要注意一下js的语法,每行的遍历需要再定义一个数组。
- 为了后续的操作,我们定义了一个墙的数组walls[],所以你会看到最后的一个push操作。墙的生成实际上是new出来的。
create_walls() {
new Wall(0, 0, this);
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 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();
}
最终效果
ps:注意每个小正方形可能有间隔,这是由于我们js的/不是整除,所以会有浮点数,我们把边长this.L转化成整型就可以了。
2.3.2 生成指定个数随机障碍(对称)
为了游戏公平,我们要求生成的时候是上下对称的,这就可以联系一下我们DS里压缩矩阵的上三角和下三角了。这给我们的启示是只要对一半的数组进行随机生成就可以了。
补充:js中随机数是用random表示[0,1)的数表示
(由于规模不算大,可以使用这个赌徒方法来解决)
问题1:生成的数重复了怎么办?重新生成这个数1000次 有一个符合要求就break掉
提示:不要写死循环,最好是写一个数比较大的循环,否则可能出现不可预料的卡死。
构造函数里面加入我们的障碍物数量
我们在创建障碍物里的函数在生成的循环前面加入 如下的代码块
注意一下,左下角和右上角应当始终为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;
}
}
实现效果
2.3.4 实现两点联通
我们还有一个问题是两个点不连通,这就会导致没法玩,所以需要解决这个问题。
我们的解决方法依旧是暴力:循环1000次我们所生成的地图,每次对其进行连通性判断,如果不连通就重新生成,否则break掉
我们在生成障碍物里加入这句话,实现如果不连通就重新生成:
if (!this.check_connectivity()) return false;
然后在start里循环1000次生成地图
start() {
for (let i = 0; i < 1000; i ++ ){
if (this.create_walls())
break;
}
}
这里还有一个问题,为了防止状态可能产生修改,我们需要在执行连通性之前转化成JSON再转化回来生成一个新的变量(相当于复制一遍),然后用这个变量来进行操作。
我们函数传入的是 g数组,起点横纵坐标,终点横纵坐标(注意减2是因为减1的部分已经是障碍物了)
const copy_g = JSON.parse(JSON.stringify(g));
if (!this.check_connectivity(copy_g, this.rows - 2, 1, 1, this.cols - 2))
return false;
接下来就是我们这个连通性的实现。我们可以用dfs算法
// check source => target connectivity
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;
}
2.4 思考:各子组件之间是否有冲突?
比如说为什么地图部分的渲染会被障碍物的渲染覆盖掉呢?前面的渲染操作失效了吗?
我的理解:对于后面新加进来的元素,就以上述情景为例,请看下面的分析,就可以解释为什么可以替换了,后面会调用update函数,在同样的地方进行渲染,造成了覆盖的效果。
地图组件生成墙的部分
新建墙调用父类的构造函数部分
父类会把其加入到游戏组件中去
而在后续随着帧的移动,后面的组件会执行update函数,会把前面组件执行update函数里面的渲染替换掉(如果是相同像素的话)