此部分的主要任务是同步的工作,主要是在后端同步两个玩家的位置移动轨迹,以及实现将对战结果的面板展示与结果的数据库存储
项目地址
一.同步玩家操纵的游戏对象的位置
在一个棋盘上,两个游戏玩家A和B其处在的位置一个在左下角一个是右上角
1.首先,至于说谁位于A谁位于B,需要在云端确定
当确定之后会把每一个玩家的位置传到前端
然后先暂时傻瓜式的将每一位玩家的位置在后端确定。暂时且直接的认为a在左下角,b在右上角
这也就需要在存储地图的时候,需要存储下玩家的Id与位置信息
在consumer
的util
的文件夹下,添加类player
首先给类添加注解,定义player
@Data
@AllArgsConstructor
@NoArgsConstrucotr
在里面首先需要用到id
,起点的x与y坐标
然后在用List
记录每名历史上执行过的方向的序列,以及每名玩家每一次的指令是什么,这样可以完全确定玩家对应的蛇的路径是什么
private List<Integer>steps;
回到Game
类。定义对象以存储两个对战双方的信息
private PlayerA PlayerB
随后初始化地图,需要传入双方玩家的Id
以及位置坐标,并且初始化
playerA=new Player(idA,rows-2,1,new ArrayList<>());
同理B也是这样的写法
在WebSocketServer
中,初始化Game
地图需要发送双方的Id
为了方便,将和地图有关的信息封装成JSON
,以保证逻辑连贯
JSONObject respGame =new JSONObject
在这里为了能够让WebSocketServer
访问到Player
类,需要在Game
类里写两个Get
函数
public player getPlayerA{
return PlayerA
}
同理B也是同样的写法
随后在WebSocketServer
中进行JSON
封装,将左下角右上角用户的Id、坐标以及地图信息传过去
respGame.put("a_id",game.getPlayerA().getId())
然后由于两名玩家对于得到的game
的信息是完全一致的因此将要传给前端用户链接的地图信息放到Game
里,写法为:
respA.put("game",respGame)
同样的B也做下相应修改,这样的话,后端就可以把两名玩家的全面活动以及信息传过去
2.前端做相应修改
首先在pk.js
中的全局变量status
里,存下a
和b
的id
与坐标
a_id:0
a_Sx:0
a_sy:0
同理B也是这样操作,初始情况下的所有变量都置为0
定义函数updateGame
用来实时更新game
信息
updateGame(state,gamemap){
state.game.map=game.map
state.a_id=game.a_id
state.a_sx=game.a_sx
state.a_sy=game.a_sy
}
同理B也是这样的写法
然后在PkIndexView
中获取前端信息的时候,传入全方位的信息
store.commit(data.game)
注:一定要把以前传递的地图的相关信息的updateGamemap
删去,至于原因我也不知道为啥
前端调试当可以正常返回的时候即为成功
二.游戏传递
在这之前,首先先上一下整个大微服务的流程图
1.前言:分析
对于目前实现的或者说之前实现的对于蛇的操作(上下左右)是只在一个页面控制,那就不会涉及到同步的问题。
对于目前的一个进行中的进程中,会有3个棋盘,两个在用户浏览器里,一个在云端
这3个棋盘需要保证状态同步
机制如左图
当服务器收到两条蛇的移动消息之后,同步给两名client
以实现3个棋盘同步。如果当操作的是bot
,当bot
发信息,Server
传递信息之后广播给client
端。
在如上图的大流程图的模式下,人机与AI与AI之间的对战很容易做。因为waiting-result-judging
相对来说是一个独立的流程。(个人理解是都在一个进程里Game
处理且不与外界联系)
准确来说上面的操作是一个单线程,在等待用户输入之后,线程会被卡死,多个游戏进入的话,只能会卡死一堆,只让一个进入。其余的进入waiting
阶段。这样会导致第二个游戏的双方用户的使用体验会很差。这样的话Game
的线程就不能来用单线程。那么就另起新线程。
具体操作:2.首先先让Game
能够支持多线程
让Game
类继承自Trend
,Trend
有入口函数
通过快捷键注入函数名为run
如何实现进入新线程
在WebSocketServer
中定义game.start()
就能实现多回合
game.start()
是trend
的一个API,另起线程的执行函数
定义对象,以存储Game
private Game game =null
由于Game
是两名玩家的Game
,因此可以把它赋值给两名玩家所对应的链接。
users.get(a.getId()).game=game;
users.get(b.getId()).game=game;
2.启动线程之后,执行等待下一步操作的操作
在Game
类中,首先先定义一个辅助函数,以实现下一步操作
private boolean nextStep(){
}
为了能够获取两名玩家下一步的操作,需要定义成员变量,初始这些变量为空,以表示没有获取到下一步的操作,如果不为空,0123显示为上下左右四个方向
private Integer nextStepA =null
private Integer nextStepB =null
如何获取:用来写两个set()
用来设置两个变量的值。这个函数未来会在链接里调用,用来设置对方变量
public void setNextStepA(Integer nextStepA){
}
同样B与之同理。
在客户端线程里会修改两个变量的值,在执行流程的大线程里提取两个变量的值。这样两个线程会去同时读写一个变量导致读写冲突。因此需要加一个锁
private Reentrantlock lock = new ReentrantLock()
然后在set()
里上锁
lock.lcok()
try{
}finally{
lock,unlock();//这样即便报异常也会解锁
}
这样就可以在nextstep()
中加个锁
要加锁,但是锁加在循环内。不加进循环外。因为开始的时候step值为空,当外面想给step
赋值的时候,到外面的锁就会被卡住,就不能被覆盖住
3.真实的实现等待两名玩家输入
如果都输入,执行下一步,如果当有一名玩家超过时间来输入,则比赛结束,同时告知
首先先用Java中的sleep
函数,睡眠一段时间等待输入。之后判断两名玩家有没有读入输入
if(nextStepA !=null&&nextStepB!=null)
这样表示两名玩家的操作都读到了,return true
否则最终return false
再把获取到的操作指令加给变量
playerA.getSteps().add(nextStepA)
小问题
如果说玩家操作的比较快,1秒中读入两次及以上,可能会漏掉一些操作
解释的原因有;假设规定的前端设定1秒走5格,当操作非常快的时候,后端会正常,但是前端是执行这一步再去执行下一步,如果说某一步移动的时间区间获取了很多的下一步操作。这样会把很多中间不的结果遮盖掉。最终显示的仅仅是下一步的最终结果。会在移动上不符合常理
(需要说明的是,动一格不代表输入操作里的一步,比如说一步动5格,也就是1s,在这1
s内的一开始的操作是动5次,但是在移动的过程中会不断获得一些新的操作,使得结果不断的更新)
因此在返回下一步操作的时候,一定要在step
里一个最小值,定义的1s5格,那也就是说,1格用200ms的时间。
最起码要定义成Thread.sleep(200)
对于这个加锁来说,try..catch
与try..finally
有些是必须写的,是为了处理异常,然而有些是没必要写的,属于保险措施。当然了,如果加抛出异常加在函数外面的话,也就是继承的话,在后续调用相关函数会很麻烦,也得再加异常
要判读每一步的话,由于run()函数是一步一步来运行的,一共是13*14-182个格子,每一步假设最慢长1格,最多的话两条蛇走600步,这样定义一个1000步的循环。每次的话都去判断下一步操作有没有获取到。if(nextStep())
如果没有获取到,整条蛇也就该结束了。
定义一个全局变量,用来表示现在的游戏的状态,初始为playing
表示正在进行中
playing-finished
除了要标注修改游戏的状态外,还需要判断哪条蛇的输入没有获取到,以方便判输
同样定义一个变量,存储下哪条蛇没有输入
private String loser="" //all:平局 A:A输,B:B输
其中有
if(nextStepA==null&&nextStepB==null){
loser="all"
}否则A为空,则A输,否则B为空则B输
如果两条蛇都有的话,进入nextStep()
函数继续执行
注:这一部分涉及到读的操作,因此需要加个锁
新的问题:如果在时间范围内没有读到,但是在上述的边界时间内(比如5.2s)的时候读到了,此时会进入上述判断,但是变量的情况为边界情况。两者获取到的输入其实都不为空
这种情况其实不用管:当在超时的边界情况判超时是合理的,而且卡在边界的概率极低。
在完成判断之后需要break
掉,在break
之前向两名玩家发送下信息。首先定义两个辅助函数
第一个是sendResult()
用来表示返回结果
直接在当状态为finish
的判断里调用。
此外还需要调用Judge
函数来判断下一步操作是否合法
当判断操作合法的话,下一步如果status
为playing
,则将操作广播给两名玩家,具体的广播操作为:
已知c1有,c2有,服务器s向c1广播c2,服务器s向c2广播c1
如下图所示:
这样的话要实现广播的功能需要另开新的辅助函数,sendmove()
来暂时调用
如果说整个游戏已经结束了status.equals("playing")
则返回结果
4.两个辅助函数怎么写
4.1sendResult()
以及向玩家广播结果信息。
根据run()
函数的需求,需要向本局比赛的对战双方分别广播这个信息。
已经知道了两个玩家A、B
的Id
,通过Id
广播到链接。
已知链接存到了user
里面。那么就需要外界的类来调用来自WebSocketServer
中的链接存储。改成public
类型以开放权限
再开一个辅助函数,用来帮助广播和传递信息:
sendAllMessage(String message){
WebOScketServer.users//这样即可获取到users
.get(playerA.getId())//获取到A的链接,同理B的也是如此
.sendMessage(message)
}
这样就可以借助函数来在sendResult()
中广播结果信息
JSONObject resp=new JSONObject();
resp.put("event","result");//表示传递的是什么信息
resp.put("loser",loser);//结果信息
sendAllMessage(resp.toJSONString());//一定要记得调用否则不会有信息返回
4.2sendMove()
首先先上一个JSON
封装
resp.put("event","move");///传递事件
resp.put("a_direction",nextStepA);//A移动
resp.put("b_direction",nextStepB);//B移动
对于这三个辅助函数,各司其职,sendAllMessage()
实现信息的传递功能,sendmove()
装载双方的移动信息,sendResult()
装载比赛的结果信息
关于要不要加锁,一般来说就是读写要加锁,写写要加锁,两个进程里有一个是写操作就加锁
由于sendmove()
要读写nextA nextB
,因此需要加一个锁
当传递完成后需要清空,否则会影响下一个变量
nextStepA=nextStepB =null
5.前端与后端通信
写一个地方能够向后端发送消息
之前移动的判断是在Gamemap.js
中判断的,因为以后是一个用户一个客户端,所以可以直接只用wsad
来执行输入操作
当按下wsad
的时候,从前端向后端发送移动指令。
定义变量d
并且取出移动方向。其中w=0,s=1,a=2,d=3
如果获取到了指令就可以向后端发送请求
this.store.state.pk.socket.send JSONStringfy({
event:'move',
direction:d,
)
当传递给后端,后端需要能够接收到请求
在后端WebSocketServer
中的onMessage
函数是一个信息传递的路由
5.1实现辅助函数move()
在onMessage()
中判断,如果发现事件名为move
,则move()
调用
move(data.getInteger("direction"))
在move
函数中需要传入move
的方向,此外还需要判断的是当前的这名玩家是A还是B,如果是蛇A,则设置下setNextStepA
否则要判断下是不是B,以防止出现bug,结果两个都不相等。如果玩家是B,那么就设置setMextStepB
,否则就不用管了。
写法如下:
这样就可以开始调试
当后端接收到消息,则说明成功
5.2返回前端内容
为了方便调试,需要把前端的内容返回回来
在PkIndexView
中需要添加两个判断
else if(data.event==="move")
else if(data.event==="result")
当为move
的时候,设置两条蛇的移动
由于在snake.js
中有一个函数set_direction()
用来统一接口以设置方向。
首先先存下来GameObject
,以方便取出两条蛇。这样的话才能够访问到蛇。
首先先在ok.js
中定义全局变量,然后写更新函数。将在GameMap.vue
中的绑定的数据更换样式为:
store.commit("updateGameObject",new GameMap(canvas.value.getContext('2d'),parent.value,store));
存下来就可以取到gameObject
然后回到PkIndexView.vue
中,在if
里面先把gameobject
取出来
const game=store.state.pk.gameObject
将在gamemap.js
中的属性解构出来,然后去设置snake0
与snake1
的方向,写法如下:
要注意的是,在这里不是将不同的用户放进不同的线程,而是将不同的比赛放进不同的线程,当两名玩家放进匹配池之后,是生成了一个新的类,名为Game
,执行完一次之后,会单独的开一个新的线程,每一句单独的游戏都会new
一个Game
类,Game
类都会开一个单独的线程。
在这调试出现问题,有输出得到的信息,且输出的信息是直接先给A判为输,如下图
原因在于之前写的时候没有让后端的sednmove()
函数把信息返回
在修改之后发现前端依旧没有移动变化。出现这种问题的事实情况在于,输入操作之后出现的延迟。
延迟的产生主要来自于nextStep
函数中,传递下一步信息的那个循环里面,每次等会先sleep
等待1秒
因此在按的时候,可能也许正在sleep
阶段,当sleep
完之后才会执行下一步操作,也就是添加移动指令来让前端渲染,操作与获取操作是一个很随机的过程,如果说在输入的时候sleep
了一半,那么可能就仅仅需要等待半秒。
因此为了优化用户体验,调整睡眠的时间以及循环次数,可以调整循环以及sleep的时间,比如循环50,sleep100,但是总时间还是要保持在1000ms
当然,这个可以去自我调节,不过后端循环的次数越多,后端服务器的压力就越大,但是用户的体验会更好两者之间由开发者自由平衡
5.3处理前端结果,当某条蛇死了,就把他变白
主要是来写pkIndexView
中的else if(data.event==='result)
的操作逻辑
首先与上面的方法一样,把两条蛇取出来。
把之前写在前端的处理蛇死亡的方法移到后端
通过后端判断返回过来的结果,在前端来判断是否死亡以及是哪条蛇死亡,并且更新状态。具体写法如下:
去前端进行调试。此时会发现并没有判断失败。当然如果说一条蛇动一条蛇不动,不动的蛇就会死,但是不会去判断撞墙
原因是后端的judge
函数还没有写
此外,目前如何判断出来用户的蛇是哪一条还判断不出来,后续需要给出标记。
如何写judge
逻辑
写法上其实可以参考前端的撞墙不撞墙的逻辑。
前端中当时在GameMap.js
中定义check_valid()
,来用作判别,要判别的话需要知道蛇的身体有哪些组成部分。
那么在后端就需要把蛇的身体画出来。
对于一条蛇来说其实不会很长,大概也就几百个长度,对于Java程序来说,计算10的6次不是问题,而且哪怕是说,每一次都去重新计算也不成问题。
因此先在player
中定义列表存储蛇身
private List<Integer>steps;
首先先定义辅助类Cell
,用来标注蛇的身体的每一个单元
首先先添加注解
@Data
@AllArgsContructor
@NoArgsConstructor
类里面只需要存两个变量x,y
,这两个变量代表了每一部分身体的横纵坐标
在Player
类里面定义一个List
并且返回。
public List<Cell>getCells(){
}
想返回蛇的身体也需要一个辅助函数,用来判断蛇在何时会变长
public boolean check_tail_increasing(int steps)
写法与前端一致,把前端的逻辑拿过来即可
在函数getCells()
中,创建蛇的身体,并且枚举循环增加蛇身
定义蛇身链表:List<Cell>res=new ArrayList()
关于链表选择LinkList
的时间复杂度为o(1),ArrayList
的时间复杂度为o(n),正是由于蛇的长度很短,因此可以不去计较这个
为了方便,需要存储下上下左右四个方向的偏移量,但是偏移量的坐标顺序不要变
int []dx={-1,0,1,0},dy={0,1,0,-1}
首先初始化,将起始点加进去
res.add(new Cell(x,y)
然后枚举下每一步,具体的枚举的操作如下所示:
在这当中,首先需要定义变量来记录步数/回合数
int steps=0
然后再判断回合数,以判断当前回合的蛇尾是否需要增加。如果当前回合蛇尾不需要增加,就需要把蛇尾增加的身体数组拿掉
res.remove(0)
再然后定义辅助函数check_valid()
去判断首先墙是否是同步的,然后再判断AB两条蛇的走的合法性
public boolean check_valid(List<Cell>cellsA,List<Cell>cellsB)
首先先求一下蛇的现在的长度。然后假设以A作为参考系,先把A的最后一位取出来Cell cell =cellsA.get(n-1)
首先先判断A身体数组的最后一位是不是墙,是墙那么就说明撞墙上了(最后一位按照逻辑应该是头的位置)
if[g[cell.x][cell.y]==1)return false
否则判断A的最后一位和A的身体是否有重合(也可以理解为蛇头撞蛇身或者说蛇倒着走)或者说A的蛇头撞蛇身,那么就会返回false
,否则返回true
具体写法如下:
这样就可以在judge
中进行判断
boolean validA =check_valid(cellsA,cellsB)
B写法与之相同
如果A和B但凡有一个不合法,那么整局游戏结束
if(!validA||!validB){
status="finished"
}
如果已经结束了之后,就需要判断是平局、A输还是B输。
如果AB都非法,都输则平局
如果A输:loser=A
如果B输:loser=B
重启后端进行调试
个人错误:发现相撞不仅没有死还会报错
当把循环的次数减少一半的循环,发现可以撞墙,但是相撞判死亡会慢半拍。
大概是sleep
时间的设置导致影响了体验。
6.完善:当一方输了之后,判断谁赢谁输,给用户一个界面可以重启
在前端中定义一个组件来判断谁赢谁输。
components
文件夹下写一个组件名为ResultBoard.vue
div.result-board
然后添加一些属性
去
PKIndexView中导入进去加到
components`中展示出来。
然后添加一些css样式把页面部分移动到中间来。
在设置完整体样式之后,添加部分标签用来写结果
div.result-board-text
div.result-board-btn
然后去bootstrap
里找一个合适的按钮样式,对整个页面进行调整。
然后就是逻辑部分:
首先:在pk.js
中的state
里加一个全局变量为loser
.
其中有如下的几个形态:
all:平局 A:左下角输 B:右上角输 none:正在进行中。
定义UpdateLoser()
用来更新变量
控制组件的出现,用v-if
为当前不处在正在进行中的时候显示组件
v-if="$store.state.pk.loser!='none'"
关于ResultBoard
的一些操作逻辑
首先是如何判断输赢。
将输赢平局分成三个div
,用v-if
和v-else-if
进行分割。
当$store.state.pk.loser=="all
平局。
当$store.state.pk.loser=='A'
要注意此时可不一定是当前游戏中的A
为了能够缜密判断,将其写成
$store.state.pk.a_id==$store.state.user.id
也就是a的id
就是用户的id
时,此时A为输
同理B也是写成这样B为输,否则最终就是赢
最终记得在PkIndexView
组件中更新提交下loser
store.commit("updateLoser",data.loser);
结果出现了双赢的情况,如下所示。
出现“双赢”的界面的原因在于:
a_id
与user.id
的类型不一样,提取出来会发现,一个是数字1,一个是字符串”1”,也就是不能够直接判断。
所以在等号两边的类型不一样的时候,就需要用到双等,此时的话,如果两个变量不一样,会把两个变量变长字符串来比较。
或者如果想用三等号,那就强制转化parseInt()
6.1实现重开一局
在ResultBoard
中添加setup
来触发操作,定义函数名为restart
.
在button
组件中添加@click="restart
,以绑定触发函数。
要想实现重开一局需要用到全局变量store
store.commit("updateStatus",matching);将界面更换成匹配界面。
此外进入新的界面,将对手头像变成初始的头像,而且重新更新胜负
store.commit('updateLoser',"none");
store.commit("updateOpponent",初始的相关头像等信息)
然后return函数,实现效果
三。数据库
1.把状态存下来是为了后续方便把录像展示给用户。
首先在数据库中建立一个表,表名为record
然后创建数据库的pojo
层record
首先添加一些构造注解,对照数据库来写即可。
注:再次强调在类里面一定要用驼峰命名法,在数据库里可用下划线
然后给唯一的Id以及时间添加注解
@TableId(type=IdType.AUTO)
@JSONFormat(pattern="yyyy-MM-dd")
再写一个mapper
为RecordMapper
作为接口,添加注解为@Mapper
,并且继承自extends BaseMapper<Record>
2.创建数据库
写在Game
类里在一个合适的地方实现这个功能。也就是在对战结束后,在向前端发送结果之前。将结果引到数据库里。
定义函数名为
sendToDatabase()
在WebSocketserver
中。将这个函数注入到当前的类中。
定义变量:
pubolic static RecordMapper recordMapper
随后添加set
函数给数据库(记得给数据库添加注解)
public void setRecordMapper(RecordMapper recordMapper){
WebSocketServer.recordMapper=recordMapper;
}
回到要写的函数:sendToDataBase
先定义一个对象record
,这个对象要传入对应的pojo
的Record
的数据
按照下图所示的传递方法:
在这其中,steps
是varchar
类型,需要转化成string
类型。
在Player
类中定义一个辅助函数
首先先枚举下每一个steos
,然后将每一步路径对应的数组转化成字符串追加到res
结果集中,最后return
返回。
通过这样的方法,将a_steps
写成了playerA.getStepString()
将b_steps
写成了playerB.getStepString()
变量map
也需要一个这样的函数,然后转化成string
类型。直接在Game
类里定义名为getMapString()
.与上面的写法一样,也是首先定义一个对象,然后枚举整个地图的每一位每一个坐标。最后以字符串的形式返回。最后写作getMapString(),
在对象定义完之后,就可以去存储进数据库。并且将这个函数放进用作返回结果信息的sendResult()
函数中。
最终在前端调试,并且查看数据库,当数据库有更新的数据,即为成功。如下所示。
大佬太强了,赞赞赞!