1.玩家位置同步
1.1后端修改
玩家的位置也要在服务端确定,确定完之后将每个玩家的位置传到前端。
添加一个玩家类
consumer.utils.Game.java
java
import java.util.List;
@Data
@AllArgsConstructor
@NoArgsConstructor
public class Player {
private Integer id;
private Integer sx;//起始x坐标
private Integer sy;//起始y坐标
private List<Integer> steps;//保存每一步操作---决定了蛇当前的形状
}
在初始化Game
的时候,实例化两个Player
对象
在WebSocketServer.java
中,为了方便管理,将与Game
相关的信息,封装成一个JSON
这样后端就可以将两名玩家的信息(包括生成的地图)传送给前端
1.2前端修改
在src\store\pk.js
中添加玩家信息的变量和更新函数
js
export default ({
state: {
status:"matching",//matching表示匹配界面 playing表示对战界面
socket:null,//存储前后端建立的connection
opponent_username:"",//对手名
opponent_photo:"",//对手头像
gamemap:null,
a_id:0,
a_sx:0,
a_sy:0,
b_id:0,
b_sx:0,
b_sy:0,
},
mutations: {
updateSocket(state, socket){
state.socket = socket;
},
updateOpponent(state, opponent){
state.opponent_username = opponent.username;
state.opponent_photo = opponent.photo;
},
updateStatus(state, status){
state.status = status;
},
updateGame(state, game){
state.a_id = game.a_id;
state.a_sx = game.a_sx;
state.a_sy = game.a_sy;
state.b_id = game.b_id;
state.b_sx = game.b_sx;
state.b_sy = game.b_sy;
state.gamemap = game.map;
},
},
actions: {
},
modules: {
}
})
在src\views\pk\PkIndexView.vue
中,在onmessage
中,调用updateGame
函数
js
<script>
import PlayGround from '../../components/PlayGround.vue'
import MatchGround from '../../components/MatchGround.vue'
import { onMounted } from 'vue'
import { onUnmounted } from 'vue'
import { useStore } from 'vuex'
export default {
components: {
PlayGround,
MatchGround
},
setup() {
const store = useStore();
const socketUrl = `ws://127.0.0.1:3000/websocket/${store.state.user.token}`;
let socket = null;
onMounted(() => {
....//省略
socket.onmessage = msg => {
const data = JSON.parse(msg.data);
console.log(data);
if (data.event === "start-matching") {
store.commit("updateOpponent", {
username: data.opponent_username,
photo: data.opponent_photo
});
//匹配成功后,延时2秒,进入对战页面
setTimeout(() => {
store.commit("updateStatus", "playing")
}, 2000);
store.commit("updateGame",data.game)//更新Game:包括玩家信息和地图
}
}
socket.onclose = () => {
console.log("disconnected!");
}
});
onUnmounted(() => {
socket.close();
store.commit("updateStatus", "matching");
})
}
}
</script>
运行项目,使用用户名sun和用户名hong的登录,两个浏览器控制台console.log(data.game)
的输出内容一致,均为,同步成功
2.游戏同步:多线程
2.1分析过程
之前只是两个棋盘,在浏览器本地通过wsad和上下左右来控制移动。
现在三个棋盘,两个client和一个server,需要实现三个棋盘的同步
再来梳理一下之前的游戏流程
对于从等待用户orBot输入到判别系统这一过程是独立的,
但是一般代码的执行是单线程,也就是按照顺序执行,例如如果在当前线程执行操作,当等待用户输入的时候,线程就会卡死,需要我们这样一个线程中有多个游戏在运行,只有Game1结束之后才能跑Game2,这样在第二个对局中,玩家就会漫长的等待。
因此,Game不能作为一个单线程来处理,因此,需要另起一个新的线程来做。
也就是将Game变成一个支持多线程的类
2.2多线程
首先为WebSocketServer
增加一个成员变量,用于记录链接中的Game
实例
在确定两名匹配的玩家之后,更新两名玩家的WebSocketServer
连接上的Game
实例值。
然后回到Game.java
,将Game变成一个支持多线程的类,只需将Game
继承Thread
类,就可以支持多线程
java
public class Game extends Thread
然后重写多线程的入口函数run()
在开启一个新线程执行game.start()
的时候,新线程中的入口函数,就是run()
初始化两个成员变量,用于表示两名玩家的下一步操作
java
private Integer nextStepA;
private Integer nextStepB;
public void setNextStepA(Integer nextStepA) {
this.nextStepA = nextStepA;
}
public void setNextStepB(Integer nextStepB) {
this.nextStepB = nextStepB;
}
未来会在WebSocketServer.java
中,接收到输入的时候,调用这两个函数
也就是在蓝色的线程里面修改nextStepA
和nextStepB
的值,而在红色的线程里面,会读取这两个线程的值
这就涉及到两个线程会同时读写一个变量,可能会产生读写冲突,需要枷锁
定义一个锁
java
private ReentrantLock lock = new ReentrantLock();
之后在setNextStepA
和setNextStepB
中
对两个变量进行更新之前,先锁上,操作完之后,解锁(不管有没有报异常)
java
public void setNextStepA(Integer nextStepA) {
lock.lock();
try {
this.nextStepA = nextStepA;
}finally {
lock.unlock();
}
}
public void setNextStepB(Integer nextStepB) {
lock.lock();
try {
this.nextStepB = nextStepB;
}finally {
lock.unlock();
}
}
在nextStep()
函数中,负责等待两名玩家的输入,如果都在指定时间内输入了,就返回true
java
private boolean nextStep(){//等待两名玩家的下一步操作
//由于前端动画200ms才能画一个格子
//如果在此期间接收到的输入多于一步 只会留最后一步 多余的会被覆盖
//因此在每一个下一步都要先休息200ms
try {
Thread.sleep(200);
} catch (InterruptedException e) {
e.printStackTrace();
}
//如果5秒内有玩家没有输入 就返回false
for (int i = 0; i < 5; i++) {
try {
Thread.sleep(1000);
lock.lock();
try {
if(nextStepA != null && nextStepB != null){
playerA.getSteps().add(nextStepA);
playerB.getSteps().add(nextStepB);
return true;
}
}finally {
lock.unlock();
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
return false;
}
如果其中一个超时没有输入,游戏就终止,并且分出胜负。
因此还需要定义一个游戏状态status
和谁输了loser
java
private String status = "playing";//游戏状态 playing-->finished
private String loser = "";//all:平; A:A输; B:B输了
最后,在线程的入口run()
中初始逻辑如下
java
@Override
public void run() {
for (int i = 0; i < 1000; i++) {//1000步之内游戏肯定结束
if(nextStep()){
//如果获取两个玩家的下一步操作
}else {
status = "finished";
if(nextStepA == null && nextStepB == null){
loser = "all";
} else if (nextStepA == null) {
loser = "A";
} else{
loser = "B";
}
}
}
}
但是上面这段逻辑有个问题,如果两名玩家在五秒内没有给出操作,就会进入else
判断,此时本应该是平均,也就是loser = "all"
,但如果下面这段代码执行时,用户给出了输入,结果就会不符合预期。
java
if(nextStepA == null && nextStepB == null){
loser = "all";
} else if (nextStepA == null) {
loser = "A";
} else{
loser = "B";
}
所以,由于这里涉及到变量的读操作,为了在读的过程中被修改,因此也需要加锁。读完之后再解锁。
java
if(nextStep()){
//如果获取两个玩家的下一步操作
System.out.println();
}else {
status = "finished";
lock.lock();
try {
if(nextStepA == null && nextStepB == null){
loser = "all";
} else if (nextStepA == null) {
loser = "A";
} else{
loser = "B";
}
}finally {
lock.lock();
}
}
然后来看if (nextStep())
判断,如果获取两个玩家的下一步操作
需要先进行judge()
,来判断输入是否合法
并且,虽然A和B都知道自己的操作,但是看不到对方的操作,因此需要中心服务器以广播的形式来告知。
java
@Override
public void run() {
for (int i = 0; i < 1000; i++) {//1000步之内游戏肯定结束
if (nextStep()) {
//如果获取两个玩家的下一步操作
judge();
if(status.equals("playing")){
sentMove();
}else {
sentResult();
break;
}
} else {
status = "finished";
lock.lock();
try {
if (nextStepA == null && nextStepB == null) {
loser = "all";
} else if (nextStepA == null) {
loser = "A";
} else {
loser = "B";
}
} finally {
lock.lock();
}
sentResult();
break;
}
}
}
而其中暂时不实现judge
的逻辑,其他辅助函数的逻辑如下
java
private void sentAllmessage(String message){//工具函数:向两名玩家广播信息
WebSocketServer.userConnectionInfo.get(playerA.getId()).sendMessage(message);
WebSocketServer.userConnectionInfo.get(playerB.getId()).sendMessage(message);
}
private void sentMove() {//向两个Client广播玩家操作信息
lock.lock();//凡是对操作进行读写的操作 都要加锁
try{
JSONObject resp = new JSONObject();
resp.put("event","move");
resp.put("a_direction",nextStepA);
resp.put("b_direction",nextStepB);
nextStepA = nextStepB = null;//清空操作
sentAllmessage(resp.toJSONString());
}finally {
lock.unlock();
}
}
private void sentResult() {//向两个client公布结果信息
JSONObject resp = new JSONObject();
resp.put("event","result");//定义事件
resp.put("loser",loser);
sentAllmessage(resp.toJSONString());
}
这样后端基本逻辑完成,接下来是前端与后端的通信,前端要将用户的操作发送过来,以及接收并处理中心服务器的广播
2.3前后端通信
此前判断蛇的移动,在scripts\GameMap.js
js
add_listening_events(){
this.ctx.canvas.focus();//聚焦
const [snake0, snake1] = this.snakes;
this.ctx.canvas.addEventListener("keydown",e=>{
console.log(e.key);
//wasd控制左下角球 上下左右控制右上角球
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);
});
}
这里,由于一个client负责一个玩家,只处理wsad即可。
修改如下,将玩家的操作操作传送到后端
js
add_listening_events(){
this.ctx.canvas.focus();//聚焦
const [snake0, snake1] = this.snakes;
this.ctx.canvas.addEventListener("keydown",e=>{
console.log(e.key);
//wasd控制移动
let d = -1;
if(e.key === 'w') d = 0;
else if (e.key === 'd') d = 1;
else if (e.key === 's') d = 2;
else if (e.key === 'a') d = 3;
if(d >= 0){//有效输入
this.store.state.pk.socket.sent(JSON.stringify({//将JSON转换为字符串
event:"move",
direction:d,
}))
}
});
}
后端接收并分配给专门的路由来进行处理
java
private void move(Integer direction) {
//判断是A玩家还是B玩家在操作
if(game.getPlayerA().getId().equals(user.getId())){
game.setNextStepA(direction);
}else if (game.getPlayerB().getId().equals(user.getId())) {
game.setNextStepB(direction);
} else {
Exception e = new Exception("Error");
e.printStackTrace();
}
}
@OnMessage
public void onMessage(String message, Session session) {//当做路由 分配任务
// Server从Client接收消息时触发
System.out.println("Receive message!");
JSONObject data = JSONObject.parseObject(message);//将字符串解析成JSON
String event = data.getString("event");
if("start-matching".equals(event)){//防止event为空的异常
startMatching();
} else if ("stop-matching".equals(event)) {
stopMatching();
} else if ("move".equals(event)) {
Integer direction = data.getInteger("direction");
System.out.println(direction);
move(direction);
}
}
此时,client端用户输入WSAD的时候,后端就能准确接收到信息。
同时,前端也要接收后端的广播来的信息,具体有两种event
,分别是move
和result
2.4(1) event == move
对操作进行更新需要用到Snack.js
中的set_direction
方法
两个玩家控制的snack
对象在保存在GameMap
对象中。
为了取到,需要将GameMap
对象,作为游戏对象,保存为全局变量
先在src\store\pk.js
中将gameObject
存入全局变量,并写好更新函数
这样就能获取到游戏对象,并且更新两个玩家控制的snack
的方向
此时,两个玩家都能够控制蛇正常移动
但是每次输入之后都会感觉到一些延迟,是因为输入之后可能线程还处于睡眠状态
调整为:
2.5(2) event == result
之前判断玩家输赢(蛇的状态)的逻辑在前端
js
//如果下一步操作撞了 蛇瞬间去世
if(!this.gamemap.check_valid(this.next_cell)){
this.status = "die";
}
将这段代码去掉。现在要交由后端来播报结果。
判断输赢有两部分逻辑:撞墙和超时,超时的逻辑已经写好,现在写判断撞墙的逻辑
参考前端GameMap.js
中的check_valid(cell)
函数
后端逻辑如下:
1)首先需要将两名玩家所控制的蛇取到:
新建Cell
类代表蛇的单元
java
@Data
@AllArgsConstructor
@NoArgsConstructor
public class Cell {
private Integer x;
private Integer y;
}
在Player.java
中,将蛇的身体返回
0、1、2、3位置表示表示上右下左
对于四种操作0(w), 1(d), 2(s), 3(a)
分别在行和列方向上的偏移量
java
int[] dx = {-1, 0, 1, 0};//行方向的偏移量
int[] dy = {0, 1, 0, -1}; //列方向的偏移量
所以Player.java
的逻辑更新为
java
@Data
@AllArgsConstructor
@NoArgsConstructor
public class Player {
private Integer id;
private Integer sx;//起始x坐标
private Integer sy;//起始y坐标
private List<Integer> steps;//保存每一步操作---决定了蛇当前的形状
//检验当前回合 蛇的长度是否增加
private boolean check_tail_increasing(int step){
if(step <= 10) return true;
else return step % 3 == 1;
}
//返回蛇的身体
public List<Cell> getCells(){
List<Cell> res = new ArrayList<>();
//对于四种操作0(w), 1(d), 2(s), 3(a)
// 在行和列方向上的计算偏移量
int[] dx = {-1, 0, 1, 0};
int[] dy = {0, 1, 0, -1};
int x = sx;
int y = sy;
int step = 0;//回合数
res.add(new Cell(x,y));//添加起点
//不断根据steps计算出整个蛇身体
for (Integer d : steps) {
x += dx[d];
y += dy[d];
res.add(new Cell(x,y));
if(!check_tail_increasing(++step)){
//如果蛇尾不增加 就删掉蛇尾
res.remove(0);//O(N)
}
}
return res;
}
}
2)判断两名玩家最后一步操作是否合法
- 没有撞到障碍物
- 没有撞到两条蛇的身体
- 没有撞到自己:最后一步与之前n-1个Cell是否重合
- 没有撞到别人:最后一步与之前n-1个Cell是否重合
- 由于A和B不可能走到同一个格子 因此不用判断最后一个格子是否重合
只需要判断最后一步,也就是蛇的最后一个Cell是否符合上面三种原则即可。
java
private boolean check_valid(List<Cell> cellsA, List<Cell> cellsB) {
int n = cellsA.size();
Cell cell = cellsA.get(n - 1);//取到A的最后一步
//三种不合法操作: A撞墙、A撞A、A撞B
//A撞墙
if(g[cell.getX()][cell.getY()] == 1)
return false;
//A撞A
for (int i = 0; i < n - 1; i++) {
if(cellsA.get(i).getX().equals(cell.getX())
&& cellsA.get(i).getY().equals(cell.getY())){
return false;
}
}
//A撞B
for (int i = 0; i < n - 1; i++) {
if(cellsB.get(i).getX().equals(cell.getX())
&& cellsB.get(i).getY().equals((cell.getY()))){
return false;
}
}
return true;
}
private void judge() {
List<Cell> cellsA = playerA.getCells();
List<Cell> cellsB = playerB.getCells();
//判断两名玩家最后一步操作是否合法
boolean validA = check_valid(cellsA, cellsB);
boolean validB = check_valid(cellsB, cellsA);
if(!validA || !validB){
status = "finished";
if(validA){
loser = "B";
} else if (validB) {
loser = "A";
} else {
loser = "all";
}
}
}
此时就能正常的进行合法性判断。
2.6游戏结果展示
最后,还需要将游戏的结果在前端展示,并且,设置一个重启按钮,点击重启之后,重新开始一局。
在pk.js
中新增变量,方便用于展示谁赢谁输
新增一个组件ResultBoard.vue
用于展示结果
核心代码如下:
然后在对战页面PkIndexView.vue
导入组件,使其在loser!=none
时展示出来
并且在收到后端播报结果时,更新全局变量中的loser
最终的结果如下,成功的实现了结果展示和重来一局。
点击重启
此时,再匹配的用户,又可以开始新的一轮对战。
这样,游戏同步功能就全部完成。
3.对局记录
接下来来实现另外一功能,就是将对局记录保存在数据库中。
3.1创建record
1)创建record
表用来记录每局对战的信息
表中的列:
id: int
- 非空 自增 唯一 主键
a_id: int
a_sx: int
a_sy: int
b_id: int
b_sx: int
b_sy: int
a_steps: varchar(1000)
b_steps: varchar(1000)
map: varchar(1000)
loser: varchar(10)
createtime: datetime
2)创建Pojo
注意,数据库中如果用下划线,则在pojo中要使用驼峰命名法
java
@Data
@AllArgsConstructor
@NoArgsConstructor
public class Record {
@TableId(type = IdType.AUTO)
private Integer id;
private Integer aId;
private Integer aSx;
private Integer aSy;
private Integer bId;
private Integer bSx;
private Integer bSy;
private String aSteps;
private String bSteps;
private String map;
private String loser;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Shanghai")
private Date createtime;
}
3)创建Mapper
java
@Mapper
public interface RecordMapper extends BaseMapper<Record> {
}
写入数据库
首先将RecordMapper
实例注入到WebSocketServer
中
在Game.java
中,在每次向client
播报结果之前,将记录保存到数据库
这样在每局游戏结束时,记录就被保存下来
后续就可以根据记录来复原游戏画面