学习平台 AcWing SpringBoot框架课
Vue组件介绍 组件作用: 当所有页面都共享一部分内容时,可将该内容写为一个组件。
在 components/
目录下创建。
注意: 组件名称必须有两个字母大写,没有或只有一个会报错
1 2 3 4 5 6 7 8 9 10 11 12 <template> </template> <script> </script> <style scoped> /* scoped的作用是在本页面写的css会加上随机字符串,不会影响别的页面 */ </style>
页面设计 效果图:
添加导航栏 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页面下载安装即可。
实现效果
页面和导航栏链接地址映射 创建页面 在vue中,一般将页面创建在views/
目录下。不同的页面分别实现一个对应的.vue
文件。这里是将不同的页面分别创建了一个文件夹,然后再自己的文件夹下创建.vue
文件。
以其中一个页面举例: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
中进行,具体步骤如下:
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'
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 , }, { 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(.*)" , redirect : "/404/" }, ]
实现效果
实现导航栏点击跳转至对应的链接 实现方法就是将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>
页面美化 实现步骤
首先每个页面会有一个公共的组件,因此我们需要在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>
实现效果 对战页面
对局列表页面
创建对战地图⭐ 地图要求
首先两条蛇初始位置分别在左下角和右上角
地图上分散着若干个障碍物,障碍物需要满足中心对称(为了公平)
同时需要保证两条蛇之间的路径是连通的。
创建游戏基类 实现目的 因为游戏中的很多资源需要实现每一帧重新渲染一遍,所以就可以将这部分提取出来,实现一个基类,在基类中实现每帧渲染的操作。后面实现的其他类(例如:蛇、墙、地图)都可以继承该基类,来实现自己的每帧渲染效果。
首先,所有的静态资源都会放置在assets/
目录下
在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 ; 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 ) { 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.vue
和 PlayGround.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
主要的效果,它里面除了地图,后期也可以加入比分扳或者聊天框之类的。
可以看到,在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.rows
和this.cols
,因为是正方形,所以都规定为13,这个可以随便改的。
然后通过update_size()
函数确定地图中每个单元格的大小this.L
以及地图的宽高(this.width
和 this.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 (); 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 (); 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 = []; 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 (); }
实现效果如下:
在地图中随机生成障碍物
实现步骤:
使用随机函数,随机生成两个横纵坐标,然后判断两个横纵坐标对应的位置是否有障碍物,如果有需要重新生成。总共需要生成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 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 = []; 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)); 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 ; } }
最终实现效果如下:
创建菜单和游戏界面⭐ 修改地图 原因: 上次实现的地图是一个13 * 13
的正方形,可能会造成一种情况:在某一时刻后,两个选手的操作会造成两条蛇头走到同一个格子。降低了游戏的公平性。因此需要进行修改。
思想: 我们只需要将两条蛇的坐标 改为和为奇数 的情况,也就是说将整张地图改为13 * 14
大小。
修改代码:
1 2 3 this .rows = 13 ;this .cols = 14 ;
连带修改: 原因: 修改地图后会造成游戏地图变成长方形,不能实现主对角线轴对称,所以需要把轴对称改为 中心对称 。实现方式如下:
在GameMap.js
中修改随机障碍物的生成办法。
1 2 3 4 5 6 7 8 9 10 11 12 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; 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 ) { super (); this .id = info.id ; this .color = info.color ; this .gamemap = gamemap; this .cells = [new Cell (info.r , info.c )]; } 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 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 ), }
实现效果
实现蛇的移动 实现思想:
移动应该是连贯的,但是蛇的身体是由一格一格连续的格子组成的
中间保持不动,头和尾动,在头部创建一个新的节点,朝着目的地移动。尾巴朝着目的地动
蛇移动的条件
同时获取到 两个人 / 两个机器 的操作才能够移动
最简单的移动 在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 ) { super (); this .id = info.id ; this .color = info.color ; this .gamemap = gamemap; this .cells = [new Cell (info.r , info.c )]; this .speed = 5 ; } update_move ( ) { this .cells [0 ].x += this .speed * this .timedelta / 1000 ; } 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 ) { super (); this .id = info.id ; this .color = info.color ; this .gamemap = gamemap; this .cells = [new Cell (info.r , info.c )]; this .next_cell = null ; this .speed = 5 ; this .direction = -1 ; this .status = "idle" ; } }
然后需要有一个“裁判”来判断两条蛇是否可以进入下一回合,但是参赛者不能当“裁判”,因此需要把判断的代码写在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
需要定义dr
和dc
来获得蛇头的偏移量。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 ) { super (); this .id = info.id ; this .color = info.color ; this .gamemap = gamemap; this .cells = [new Cell (info.r , info.c )]; this .next_cell = null ; this .speed = 5 ; this .direction = -1 ; this .status = "idle" ; 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 (); } } 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 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 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 update ( ) { if (this .status === 'move' ) { this .uppdate_move () } this .render (); }
实现真正的移动 蛇头移动 之前在Snake.js
的next_step()
函数中已经获得了移动的目标位置this.next_cell
,同时需要修改next_step()
函数,将蛇的每个节点(格子)在数组向后移动一位。
然后需要修改update_move()
函数。
首先获得当前蛇头和目标位置之间x方向和y方向的偏移量dx
和 dy
,然后用勾股定理求出直线偏移量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 ) { super (); this .id = info.id ; this .color = info.color ; this .gamemap = gamemap; this .cells = [new Cell (info.r , info.c )]; this .next_cell = null ; this .speed = 5 ; this .direction = -1 ; this .status = "idle" ; this .dr = [-1 , 0 , 1 , 0 ]; this .dc = [0 , 1 , 0 , -1 ]; this .step = 0 ; this .eps = 1e-2 ; } 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 ( ) { 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 check_tail_increasing ( ) { if (this .step < 10 ) return true ; if (this .step % 3 === 1 ) return true ; 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; } } }
实现效果
优化蛇 蛇身美化
为了使蛇美观一点,需要将蛇的身体连贯一点。具体的实现方法是在相邻的两个蛇之间使用一个矩形对其进行覆盖。
在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 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); } } }
实现效果
可以看到,蛇有点粗,当转弯的时候,会和自己的身体紧贴。因此,需要在render()
函数中对蛇的宽度进行修改。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 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 ); } } }
实现效果
撞墙判定
首先撞墙判定属于“裁判”的工作,所以需要写在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 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.js
的next_step()
函数中判断下一位置是否合法,如果不合法,需要将蛇的状态修改为die.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 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 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。
实现眼睛 需要注意的是,蛇上一刻移动的方向决定了眼睛的位置。因此需要在Snake.js
中的构造函数中定义几个变量:
this.eye_direction
保存蛇眼睛的方向。起始就是下一步移动的方向。需要在next_step()
函数中进行更新,两条蛇的初始方向不同。
this.eye_dx
和this.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 this .eye_direction = 0 ;if (this .id === 1 ) this .eye_direction = 2 ; this .eye_dx = [ [-1 , 1 ], [1 , 1 ], [1 , -1 ], [-1 , -1 ], ]; this .eye_dy = [ [-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 (); } }
实现效果