King Of Bots - 菜单和游戏界面设计

当前进度代码:https://git.acwing.com/yuezi2048/kob/-/tree/66cd2e323de0325be54eaaf49b51e3b08be55cd5

一、导航栏界面与跳转

预期目标:我们要实现如下的页面以及响应的URL跳转(304),并且要求实现访问该界面时,对应的模块高亮。

image-20240109145337008

页面我们一般放到view里面,我们每个模块建一个文件夹

image-20240109205400211

1.1 公共子组件ContentField

每个Index界面的公共子组件ContentField.vue,这里有三个点

  • 这里用到了bootstrap的card组件框起来,我们可以如下简写

image-20240109151410573 image-20240109151434397

  • 我们待填充的部分使用的是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>

最终的实现效果

image-20240109210051854

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>

最终实现效果

image-20240109210159067

image-20240109210204575

image-20240109210208876

image-20240109210214001

二、游戏地图

目标:游戏方分别在左下角和右上角,设置随机障碍物,要求关于中心对称,并且要确保他们起点能联通,并且每次刷新会生成一个随机地图。

待解决的问题

  • 如何画出一个自适应的正方形对战平台?
  • 如何设置随机障碍物,并且满足关于中心对称?
  • 如何确保左下角和右上角的玩家之间可以联通?

后面可以再仔细思考的问题

  • 怎么让蛇动起来?我们可以设置这些元器件每秒刷新多少帧(fps),后面的每一帧把前面的帧覆盖掉。
image-20240109155334938

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父类(有长宽等信息)

image-20240110202118043

我们在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>

至此,实现了一个矩阵的自适应

image-20240110153411834

接下来,我们在这个矩阵的基础上绘制正方形,这就是我们所遇到的一个算法题,即:

我现在已知矩阵的宽width和高height,现在要让你生成矩阵里的13*13块的正方形,每块的最大边长this.L

image-20240110191109715

具体的函数是这样的,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; // 画布的高度
    }

img

我们通过查找一下关于canvas的api来实现绘图功能

image-20240110190827160

    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>

image-20240110192931231

我们把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);
            }
        }
    }

image-20240110194113245

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();
    }

最终效果

image-20240110204437909

ps:注意每个小正方形可能有间隔,这是由于我们js的/不是整除,所以会有浮点数,我们把边长this.L转化成整型就可以了。

2.3.2 生成指定个数随机障碍(对称)

为了游戏公平,我们要求生成的时候是上下对称的,这就可以联系一下我们DS里压缩矩阵的上三角和下三角了。这给我们的启示是只要对一半的数组进行随机生成就可以了。

补充:js中随机数是用random表示[0,1)的数表示

(由于规模不算大,可以使用这个赌徒方法来解决)

问题1:生成的数重复了怎么办?重新生成这个数1000次 有一个符合要求就break掉

提示:不要写死循环,最好是写一个数比较大的循环,否则可能出现不可预料的卡死。

构造函数里面加入我们的障碍物数量

image-20240110205938976

我们在创建障碍物里的函数在生成的循环前面加入 如下的代码块

注意一下,左下角和右上角应当始终为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;
            }
        }

实现效果

image-20240110205958469

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函数,在同样的地方进行渲染,造成了覆盖的效果。

地图组件生成墙的部分

image-20240110203230103

新建墙调用父类的构造函数部分

image-20240110203209212

​ 父类会把其加入到游戏组件中去

image-20240110203330676

而在后续随着帧的移动,后面的组件会执行update函数,会把前面组件执行update函数里面的渲染替换掉(如果是相同像素的话)

image-20240110202801858

posted @ 2024-01-10 21:43  yuezi2048  阅读(4)  评论(0编辑  收藏  举报