SpringBoot学习笔记-实现微服务:匹配系统(中)

  1. 1. 同步玩家位置
    1. 1.1 游戏信息的记录
    2. 1.2 实现多线程同步移动
  2. 2. 同步碰撞检测
  3. 3. 实现游戏结束界面
  4. 4. 持久化游戏状态
    1. 4.1 创建数据库表
    2. 4.2 保存游戏对局信息

本节内容为同步玩家的移动与碰撞检测,并实现游戏结束的前端界面以及持久化游戏对局信息。

1. 同步玩家位置

1.1 游戏信息的记录

两名玩家初始位置需要由服务器确定,且之后的每次移动都需要在服务器上判断。我们需要在 Game 类中添加 Player 类用来记录玩家的信息,在 consumer.utils 包下创建 Player 类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package com.kob.backend.consumer.utils;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.util.List;

@Data
@NoArgsConstructor
@AllArgsConstructor
public class Player {
private Integer id;
private Integer sx;
private Integer sy;
private List<Integer> steps; // 记录历史走过的每一步方向
}

然后就可以在 Game 中创建玩家:

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
package com.kob.backend.consumer.utils;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.Random;

public class Game {
...
private final Player playerA, playerB;

public Game(Integer rows, Integer cols, Integer inner_walls_count, Integer idA, Integer idB) {
...
playerA = new Player(idA, rows - 2, 1, new ArrayList<>()); // 默认A在左下角B在右上角
playerB = new Player(idB, 1, cols - 2, new ArrayList<>());
}

public Player getPlayerA() {
return playerA;
}

public Player getPlayerB() {
return playerB;
}
...
}

WebSocketServer 中创建 Game 时传入两名玩家的 ID,并且我们将与游戏内容相关的信息全部包装到一个 JSONObject 类中:

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
package com.kob.backend.consumer;

import com.alibaba.fastjson2.JSONObject;
import com.kob.backend.consumer.utils.Game;
import com.kob.backend.consumer.utils.JwtAuthentication;
import com.kob.backend.mapper.UserMapper;
import com.kob.backend.pojo.User;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

import javax.websocket.*;
import javax.websocket.server.PathParam;
import javax.websocket.server.ServerEndpoint;

import java.io.IOException;
import java.util.Iterator;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArraySet;

@Component
@ServerEndpoint("/websocket/{token}") // 注意不要以'/'结尾
public class WebSocketServer {
...

private void startMatching() {
System.out.println("Start matching!");
matchPool.add(this.user);

while (matchPool.size() >= 2) { // 临时调试用的,未来要替换成微服务
Iterator<User> it = matchPool.iterator();
User a = it.next(), b = it.next();
matchPool.remove(a);
matchPool.remove(b);

Game game = new Game(13, 14, 20, a.getId(), b.getId());
game.createMap();

JSONObject respGame = new JSONObject();
respGame.put("a_id", game.getPlayerA().getId());
respGame.put("a_sx", game.getPlayerA().getSx());
respGame.put("a_sy", game.getPlayerA().getSy());
respGame.put("b_id", game.getPlayerB().getId());
respGame.put("b_sx", game.getPlayerB().getSx());
respGame.put("b_sy", game.getPlayerB().getSy());
respGame.put("map", game.getG());

JSONObject respA = new JSONObject(); // 发送给A的信息
respA.put("event", "match_success");
respA.put("opponent_username", b.getUsername());
respA.put("opponent_photo", b.getPhoto());
respA.put("game", respGame);
users.get(a.getId()).sendMessage(respA.toJSONString()); // A不一定是当前链接,因此要在users中获取

JSONObject respB = new JSONObject(); // 发送给B的信息
respB.put("event", "match_success");
respB.put("opponent_username", a.getUsername());
respB.put("opponent_photo", a.getPhoto());
respB.put("game", respGame);
users.get(b.getId()).sendMessage(respB.toJSONString());
}
}

...
}

前端也需要进行相应的修改,在 store/pk.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
export default {
state: {
...
a_id: 0,
a_sx: 0,
a_sy: 0,
b_id: 0,
b_sx: 0,
b_sy: 0,
gameObject: null, // 整个GameMap对象
},
getters: {},
mutations: {
...

updateGame(state, game) {
state.game_map = game.map;
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;
},

updateGameObject(state, gameObject) {
state.gameObject = gameObject;
},
},
actions: {},
modules: {},
};

GameMap.vue 中需要先将 GameMap 对象存下来,之后会在 PKIndexView 中用到:

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
...

<script>
import { ref, onMounted } from "vue";
import { GameMap } from "@/assets/scripts/GameMap";
import { useStore } from "vuex";

export default {
setup() {
const store = useStore();
let parent = ref(null);
let canvas = ref(null);

onMounted(() => {
store.commit(
"updateGameObject",
new GameMap(canvas.value.getContext("2d"), parent.value, store));
});

return {
parent,
canvas,
};
},
};
</script>

...

PKIndexView 中要传入从后端获取到的 game 数据:

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
...

<script>
...

export default {
...
setup() {
...

onMounted(() => {
...

socket.onmessage = (msg) => { // 接收到后端消息时会执行
...

if (data.event === "match_success") { // 匹配成功
...

store.commit("updateGame", data.game); // 更新游戏内容

...
}
};

...
});

...
},
};
</script>

<style scoped></style>

1.2 实现多线程同步移动

我们需要实现两名玩家的客户端以及服务器端的移动同步,假如 Client1 发出了移动指令,那么就会将这个消息发送给服务器,同理另一个客户端 Client2 发出移动指令时也会将消息发送给服务器,服务器在接收到两名玩家的消息后再将消息同步给两名玩家。

我们在 WebSocketServer 中会维护一个游戏 Game,这个 Game 也有自己的执行流程,它会先创建地图 creatMap,接着会一步一步执行,即 nextStep,每一步会等待两名玩家的操作,这个操作可以是键盘输入,也可以是由 Bot 代码执行的微服务返回回来的结果。获取输入后会将结果发送给一个评判系统 judge,来判断两名玩家下一步是不是合法的,如果有一方不合法就游戏结束。

在等待用户输入时会有一个时间限制,比如5秒,如果有一方还没有输入则表示输了,同样也是游戏结束。否则如果两方输入的下一步都是合法的则继续循环 nextStep。这个 nextStep 流程是比较独立的,而且每个游戏对局都有这个独立的过程,如果 Game 是单线程的,那么在等待用户输入时这个线程就会卡死,如果有多个游戏对局的话那么只能先卡死在某个对局中,其他对局的玩家体验就会很差,因此 Game 不能作为一个单线程来处理,每次在等待用户输入时都需要另起一个新的线程,这就涉及到了多线程的通信以及加锁的问题。

ReentrantLock 是 Java 中常用的锁,属于乐观锁类型,多线程并发情况下能保证共享数据安全性,线程间有序性。它的作用和 synchronize 是一样的,都是实现锁的功能,但是 ReentrantLock 需要手写代码对锁进行获取和释放(一定要在 finally 块里面释放),要不然就永远死锁了。ReentrantLock 通过原子操作和阻塞实现锁原理,一般使用 lock() 获取锁,unlock() 释放锁。

我们将 Game 类继承自 Thread 类即可转为多线程,然后需要实现 Thread 的入口函数,使用快捷键 Alt + Insert,选择重写方法,需要重写的是 run() 方法,这是新线程的入口函数:

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
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
package com.kob.backend.consumer.utils;

import com.alibaba.fastjson2.JSONObject;
import com.kob.backend.consumer.WebSocketServer;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.Random;
import java.util.concurrent.locks.ReentrantLock;

public class Game extends Thread {
private final Integer rows;
private final Integer cols;
private final Integer inner_walls_count;
private final boolean[][] g;
private static final int[] dx = { -1, 0, 1, 0 }, dy = { 0, 1, 0, -1 };
private final Player playerA, playerB;
private Integer nextStepA = null; // 下一步操作,0、1、2、3分别表示四个方向,null表示还没有获取到
private Integer nextStepB = null;
private ReentrantLock lock = new ReentrantLock(); // 需要给nextStep变量上锁防止读写冲突
private String status = "playing"; // 整局游戏的状态,结束后为finished
private String loser = ""; // 输的一方是谁,all表示平局

public Game(Integer rows, Integer cols, Integer inner_walls_count, Integer idA, Integer idB) {
this.rows = rows;
this.cols = cols;
this.inner_walls_count = inner_walls_count;
this.g = new boolean[rows][cols];
playerA = new Player(idA, rows - 2, 1, new ArrayList<>()); // 默认A在左下角B在右上角
playerB = new Player(idB, 1, cols - 2, new ArrayList<>());
}

public Player getPlayerA() {
return playerA;
}

public Player getPlayerB() {
return playerB;
}

public void setNextStepA(Integer nextStepA) { // 未来会在另一个线程中调用
lock.lock(); // 操作nextStep变量前先上锁
try {
this.nextStepA = nextStepA;
} finally {
lock.unlock(); // 操作完后无论是否有异常都解锁
}
}

public void setNextStepB(Integer nextStepB) {
lock.lock();
try {
this.nextStepB = nextStepB;
} finally {
lock.unlock();
}
}

public boolean[][] getG() {
return g;
}

private boolean check_connectivity(int sx, int sy, int tx, int ty) {
if (sx == tx && sy == ty) return true;
g[sx][sy] = true;
for (int i = 0; i < 4; i++) {
int nx = sx + dx[i], ny = sy + dy[i];
if (!g[nx][ny] && check_connectivity(nx, ny, tx, ty)) {
g[sx][sy] = false; // 注意在这里我们用的g就是原始数组,因此修改后要记得还原
return true;
}
}
g[sx][sy] = false; // 记得还原
return false;
}

private boolean drawMap() {
// 初始化障碍物标记数组
for (int i = 0; i < this.rows; i++) {
Arrays.fill(g[i], false);
}

// 给地图四周加上障碍物
for (int r = 0; r < this.rows; r++) {
g[r][0] = g[r][this.cols - 1] = true;
}
for (int c = 0; c < this.cols; c++) {
g[0][c] = g[this.rows - 1][c] = true;
}

// 添加地图内部的随机障碍物,需要有对称性因此枚举一半即可,另一半对称生成
Random random = new Random();
for (int i = 0; i < this.inner_walls_count / 2; i++) {
for (int j = 0; j < 10000; j++) {
int r = random.nextInt(this.rows); // 返回0~this.rows-1的随机整数
int c = random.nextInt(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;
}
}

return check_connectivity(this.rows - 2, 1, 1, this.cols - 2);
}

public void createMap() {
for (int i = 0; i < 10000; i++) {
if (drawMap()) {
break;
}
}
}

private boolean nextStep() { // 等待两名玩家的下一步操作,在该方法中也会操作nextStep变量
try {
Thread.sleep(500); // 前端的蛇每秒走2格,因此走一格需要500ms,每次后端执行下一步时需要先sleep,否则快速的多次输入将会覆盖掉之前输入的信息
} catch (InterruptedException e) {
e.printStackTrace();
}

for (int i = 0; i < 50; i++) {
try {
Thread.sleep(100); // 每回合循环50次,每次睡眠100ms,即一回合等待用户输入的时间为5s
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;
}

private void judge() { // 判断两名玩家下一步操作是否合法
}

private void sendAllMessage(String message) { // 向两个Client发送消息
WebSocketServer.users.get(playerA.getId()).sendMessage(message);
WebSocketServer.users.get(playerB.getId()).sendMessage(message);
}

private void sendMove() { // 向两个Client发送移动消息
lock.lock();
try {
JSONObject resp = new JSONObject();
resp.put("event", "move");
resp.put("a_direction", nextStepA);
resp.put("b_direction", nextStepB);
sendAllMessage(resp.toJSONString());
nextStepA = nextStepB = null;
} finally {
lock.unlock();
}
}

private void sendResult() { // 向两个Client公布结果
JSONObject resp = new JSONObject();
resp.put("event", "result");
resp.put("loser", loser);
sendAllMessage(resp.toJSONString());
}

@Override
public void run() {
for (int i = 0; i < 1000; i++) { // 游戏最多走的步数不会超过1000
if (nextStep()) { // 是否获取了两条蛇的下一步操作
judge();
if ("playing".equals(status)) { // 如果游戏还在进行中则需要将两名玩家的操作广播给两个Client
sendMove();
} else {
sendResult();
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.unlock();
}
sendResult(); // 这一步结束后需要给两个Client发送消息
break;
}
}
}
}

然后前端 GameMap.js 中在移动时需要向后端通信,现在两名玩家的键盘输入操作就只需要 W/S/A/D 了:

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
import { AcGameObject } from "./AcGameObject";
import { Wall } from "./Wall";
import { Snake } from "./Snake";

export class GameMap extends AcGameObject {
...

add_listening_events() {
this.ctx.canvas.focus(); // 使Canvas聚焦

this.ctx.canvas.addEventListener("keydown", e => {
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 !== -1) {
this.store.state.pk.socket.send(JSON.stringify({
event: "move",
direction: d,
}));
}
});
}

...
}

WebSocketServer 对于每局游戏对局都会创建一个 Game 类,通过 start() 方法可以新开一个线程运行 Game 中的 run() 方法,由于我们需要在 Game 中使用 WebSocketServerusers,还需要将 users 修改为 public,然后需要接收前端的移动请求:

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
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
package com.kob.backend.consumer;

import com.alibaba.fastjson2.JSONObject;
import com.kob.backend.consumer.utils.Game;
import com.kob.backend.consumer.utils.JwtAuthentication;
import com.kob.backend.mapper.UserMapper;
import com.kob.backend.pojo.User;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

import javax.websocket.*;
import javax.websocket.server.PathParam;
import javax.websocket.server.ServerEndpoint;

import java.io.IOException;
import java.util.Iterator;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArraySet;

@Component
@ServerEndpoint("/websocket/{token}") // 注意不要以'/'结尾
public class WebSocketServer {
// ConcurrentHashMap是一个线程安全的哈希表,用于将用户ID映射到WS实例
public static final ConcurrentHashMap<Integer, WebSocketServer> users = new ConcurrentHashMap<>();
// CopyOnWriteArraySet也是线程安全的
private static final CopyOnWriteArraySet<User> matchPool = new CopyOnWriteArraySet<>(); // 匹配池
private User user;
private Session session = null;
private Game game = null;

private static UserMapper userMapper;

@Autowired
public void setUserMapper(UserMapper userMapper) {
WebSocketServer.userMapper = userMapper;
}

@OnOpen
public void onOpen(Session session, @PathParam("token") String token) throws IOException {
this.session = session;
System.out.println("Connected!");

Integer userId = JwtAuthentication.getUserId(token);
this.user = userMapper.selectById(userId);

if (user != null) {
users.put(userId, this);
} else {
this.session.close();
}
}

@OnClose
public void onClose() {
System.out.println("Disconnected!");
if (this.user != null) {
users.remove(this.user.getId());
matchPool.remove(this.user);
}
}

@OnMessage
public void onMessage(String message, Session session) { // 一般会把onMessage()当作路由
System.out.println("Receive message!");
JSONObject data = JSONObject.parseObject(message);
String event = data.getString("event"); // 取出event的内容

if ("start_match".equals(event)) { // 开始匹配
this.startMatching();
} else if ("stop_match".equals(event)) { // 取消匹配
this.stopMatching();
} else if ("move".equals(event)) { // 移动
move(data.getInteger("direction"));
}
}

@OnError
public void onError(Session session, Throwable error) {
error.printStackTrace();
}

public void sendMessage(String message) { // 从后端向当前链接发送消息
synchronized (this.session) { // 由于是异步通信,需要加一个锁
try {
this.session.getBasicRemote().sendText(message);
} catch (IOException e) {
e.printStackTrace();
}
}
}

private void startMatching() {
System.out.println("Start matching!");
matchPool.add(this.user);

while (matchPool.size() >= 2) { // 临时调试用的,未来要替换成微服务
Iterator<User> it = matchPool.iterator();
User a = it.next(), b = it.next();
matchPool.remove(a);
matchPool.remove(b);

game = new Game(13, 14, 20, a.getId(), b.getId());
game.createMap();
users.get(a.getId()).game = game;
users.get(b.getId()).game = game;

game.start(); // 开一个新的线程

JSONObject respGame = new JSONObject();
respGame.put("a_id", game.getPlayerA().getId());
respGame.put("a_sx", game.getPlayerA().getSx());
respGame.put("a_sy", game.getPlayerA().getSy());
respGame.put("b_id", game.getPlayerB().getId());
respGame.put("b_sx", game.getPlayerB().getSx());
respGame.put("b_sy", game.getPlayerB().getSy());
respGame.put("map", game.getG());

JSONObject respA = new JSONObject(); // 发送给A的信息
respA.put("event", "match_success");
respA.put("opponent_username", b.getUsername());
respA.put("opponent_photo", b.getPhoto());
respA.put("game", respGame);
users.get(a.getId()).sendMessage(respA.toJSONString()); // A不一定是当前链接,因此要在users中获取

JSONObject respB = new JSONObject(); // 发送给B的信息
respB.put("event", "match_success");
respB.put("opponent_username", a.getUsername());
respB.put("opponent_photo", a.getPhoto());
respB.put("game", respGame);
users.get(b.getId()).sendMessage(respB.toJSONString());
}
}

private void stopMatching() {
System.out.println("Stop matching!");
matchPool.remove(this.user);
}

private void move(Integer direction) {
if (game.getPlayerA().getId().equals(user.getId())) {
game.setNextStepA(direction);
} else if (game.getPlayerB().getId().equals(user.getId())) {
game.setNextStepB(direction);
}
}
}

最后在 PKIndexView 中处理接收到后端发来的移动消息以及游戏结束消息:

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
<template>
<PlayGround v-if="$store.state.pk.status === 'playing'" />
<MatchGround v-else />
</template>

<script>
import PlayGround from "@/components/PlayGround.vue";
import MatchGround from "@/components/MatchGround.vue";
import { onMounted, onUnmounted } from "vue";
import { useStore } from "vuex";

export default {
components: {
PlayGround,
MatchGround,
},
setup() {
const store = useStore();

let socket = null;
let socket_url = `ws://localhost:3000/websocket/${store.state.user.jwt_token}/`;

onMounted(() => {
socket = new WebSocket(socket_url);

store.commit("updateOpponent", {
username: "我的对手",
photo: "https://cdn.acwing.com/media/article/image/2022/08/09/1_1db2488f17-anonymous.png",
});

socket.onopen = () => { // 链接成功建立后会执行
console.log("Connected!");
store.commit("updateSocket", socket);
};

socket.onmessage = (msg) => { // 接收到后端消息时会执行
const data = JSON.parse(msg.data); // Spring传过来的数据是放在消息的data中
console.log(data);

if (data.event === "match_success") { // 匹配成功
store.commit("updateOpponent", { // 更新对手信息
username: data.opponent_username,
photo: data.opponent_photo,
});
store.commit("updateGame", data.game); // 更新游戏内容

setTimeout(() => { // 3秒后再进入游戏地图界面
store.commit("updateStatus", "playing");
}, 3000);
} else if (data.event === "move") { // 两名玩家的移动
const gameObject = store.state.pk.gameObject;
const [snake0, snake1] = gameObject.snakes;
snake0.set_direction(data.a_direction);
snake1.set_direction(data.b_direction);
} else if (data.event === "result") { // 游戏结束
const gameObject = store.state.pk.gameObject;
const [snake0, snake1] = gameObject.snakes;
if (data.loser === "all" || data.loser === "A") {
snake0.status = "die";
}
if (data.loser === "all" || data.loser === "B") {
snake1.status = "die";
}
}
};

socket.onclose = () => { // 关闭链接后会执行
console.log("Disconnected!");
store.commit("updateStatus", "matching"); // 进入游戏地图后玩家点击其他页面应该是默认退出游戏
};
});

onUnmounted(() => {
socket.close(); // 如果不断开链接每次切换页面都会创建新链接,就会导致有很多冗余链接
});
},
};
</script>

<style scoped></style>

2. 同步碰撞检测

现在还需要将碰撞检测放到后端进行判断,先将 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
23
24
25
26
...

export class Snake extends AcGameObject {
...

next_step() { // 将蛇的状态变为走下一步
...

// if (!this.gamemap.check_next_valid(this.next_cell)) { // 下一步不合法
// this.status = "die";
// }
}

...

render() {
...

ctx.fillStyle = this.color;
if (this.status === "die") {
ctx.fillStyle = "white";
}

...
}
}

接下来需要实现后端中的 judge() 方法,在判断的时候需要知道当前蛇的身体有哪些,先在 comsumer.utils 包下创建 Cell 类表示身体的每一格:

1
2
3
4
5
6
7
8
9
10
11
12
13
package com.kob.backend.consumer.utils;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@AllArgsConstructor
@NoArgsConstructor
public class Cell {
int x;
int y;
}

然后在 Player 类中创建一个方法能够根据玩家历史走过的路径找出当前这条蛇身体的每一格:

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
package com.kob.backend.consumer.utils;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.util.ArrayList;
import java.util.List;

@Data
@NoArgsConstructor
@AllArgsConstructor
public class Player {
private Integer id;
private Integer sx;
private Integer sy;
private List<Integer> steps; // 记录历史走过的每一步方向

private boolean check_tail_increasing(int step) { // 检测当前回合蛇的长度是否增加
if (step <= 7) return true; // 前7回合每一回合长度都增加
return step % 3 == 1; // 之后每3回合增加一次长度
}

public List<Cell> getCells() { // 返回蛇的身体,每次都根据蛇历史走的方向将其每一格找出来
List<Cell> cells = new ArrayList<>();
int[] dx = { -1, 0, 1, 0 }, dy = { 0, 1, 0, -1 };
int x = sx, y = sy;
int step = 0;
cells.add(new Cell(x, y));
for (int d: steps) {
x += dx[d];
y += dy[d];
cells.add(new Cell(x, y));
if (!check_tail_increasing(++step)) {
cells.remove(0); // 删掉蛇尾,即第一个起始的位置
}
}
return cells;
}
}

最后即可在 Game 类中实现 judge() 方法:

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
...

public class Game extends Thread {
...

private boolean check_valid(List<Cell> cellsA, List<Cell> cellsB) { // 判断A是否合法
int n = cellsA.size();
Cell headCellA = cellsA.get(n - 1); // A的头,也就是最后一个Cell
if (g[headCellA.x][headCellA.y]) {
return false;
}
for (int i = 0; i < n - 1; i++) { // 判断除了头以外的其他身体部分
if (cellsA.get(i).x == headCellA.x && cellsA.get(i).y == headCellA.y) {
return false;
}
if (cellsB.get(i).x == headCellA.x && cellsB.get(i).y == headCellA.y) {
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 && !validB) {
loser = "all";
} else if (!validA) {
loser = "A";
} else {
loser = "B";
}
}
}

...
}

3. 实现游戏结束界面

首先我们需要将输的玩家记录到前端的全局变量中,在 store/pk.js 中添加 loser 变量:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
export default {
state: {
...
loser: "none", // none表示没人输,all表示平局,A/B表示A/B赢
},
getters: {},
mutations: {
...
updateLoser(state, loser) {
state.loser = loser;
},
},
actions: {},
modules: {},
};

然后在 PKIndexView 组件的游戏结束处理语句块中添加更新 loser 的语句:

1
store.commit("updateLoser", data.loser);

游戏结束后需要给用户展示谁赢谁输的界面,并提供一个重开按钮,在 components 目录下创建 ResultBoard.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
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
<template>
<div class="card text-bg-secondary text-center">
<div class="card-header" style="font-size: 26px;">
游戏结束
</div>
<div class="card-body" style="background-color: rgba(255, 255, 255, 0.4);">
<div class="result_board_text" v-if="$store.state.pk.loser === 'all'">
Draw
</div>
<div class="result_board_text" v-else-if="$store.state.pk.loser === 'A' && $store.state.pk.a_id.toString() === $store.state.user.id">
Lose
</div>
<div class="result_board_text" v-else-if="$store.state.pk.loser === 'B' && $store.state.pk.b_id.toString() === $store.state.user.id">
Lose
</div>
<div class="result_board_text" v-else>
Win
</div>
<div class="result_board_btn">
<button @click="returnHome" type="button" class="btn btn-info btn-lg">
返回主页
</button>
</div>
</div>
</div>
</template>

<script>
import { useStore } from "vuex";

export default {
setup() {
const store = useStore();

const returnHome = () => { // 需要复原一些全局变量
store.commit("updateStatus", "matching");
store.commit("updateLoser", "none");
store.commit("updateOpponent", {
username: "我的对手",
photo: "https://cdn.acwing.com/media/article/image/2022/08/09/1_1db2488f17-anonymous.png",
});
};

return {
returnHome,
};
},
};
</script>

<style scoped>
.card {
width: 30vw;
position: absolute;
top: 25vh;
left: 35vw;
}

.result_board_text {
color: white;
font-size: 50px;
font-weight: bold;
font-style: italic;
padding: 5vh 0;
}

.result_board_btn {
padding: 3vh 0;
}
</style>

注意 state.pk 中的 a_idb_id 是整数,而之前 state.user 中的 id 是字符串,因此需要做类型转换再判断是否相等。

4. 持久化游戏状态

4.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

创建该数据库表的 SQL 语句如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
CREATE TABLE `kob`.`record` (
`id` int NOT NULL AUTO_INCREMENT,
`a_id` int NULL,
`a_sx` int NULL,
`a_sy` int NULL,
`b_id` int NULL,
`b_sx` int NULL,
`b_sy` int NULL,
`a_steps` varchar(1000) NULL,
`b_steps` varchar(1000) NULL,
`map` varchar(1000) NULL,
`loser` varchar(10) NULL,
`createtime` datetime NULL,
PRIMARY KEY (`id`)
);

pojo 包下创建 Record 类如下:

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
package com.kob.backend.pojo;

import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.util.Date;

@Data
@NoArgsConstructor
@AllArgsConstructor
public class Record {
@TableId(value = "id", 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;
}

mapper 包下创建 RecordMapper 类如下:

1
2
3
4
5
6
7
8
9
package com.kob.backend.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.kob.backend.pojo.Record;
import org.apache.ibatis.annotations.Mapper;

@Mapper
public interface RecordMapper extends BaseMapper<Record> {
}

4.2 保存游戏对局信息

可以在向前端发送游戏结果消息之前将对局信息存下来,首先需要在 WebSocketServer 中将 RecordMapper 创建出来:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
...

@Component
@ServerEndpoint("/websocket/{token}") // 注意不要以'/'结尾
public class WebSocketServer {
...

public static RecordMapper recordMapper; // 要在Game中调用

@Autowired
public void setRecordMapper(RecordMapper recordMapper) {
WebSocketServer.recordMapper = recordMapper;
}

...
}

然后在 Player 中创建辅助函数用来返回 steps 的字符串形式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
...

@Data
@NoArgsConstructor
@AllArgsConstructor
public class Player {
...

private List<Integer> steps; // 记录历史走过的每一步方向

...

public String getStringSteps() { // 将steps转换成字符串
StringBuilder res = new StringBuilder();
for (int d: steps) {
res.append(d);
}
return res.toString();
}
}

最后就可以在 Game 中将游戏记录保存至数据库中:

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
...

public class Game extends Thread {
...

private String getStringMap() { // 将g转换成01字符串
StringBuilder res = new StringBuilder();
for (int i = 0; i < rows; i++) {
for (int j = 0; j < cols; j++) {
res.append(g[i][j] ? 1 : 0);
}
}
return res.toString();
}

private void saveRecord() { // 将对局信息存到数据库中
Record record = new Record(
null,
playerA.getId(),
playerA.getSx(),
playerA.getSy(),
playerB.getId(),
playerB.getSx(),
playerB.getSy(),
playerA.getStringSteps(),
playerB.getStringSteps(),
getStringMap(),
loser,
new Date()
);
WebSocketServer.recordMapper.insert(record);
}

private void sendResult() { // 向两个Client公布结果
JSONObject resp = new JSONObject();
resp.put("event", "result");
resp.put("loser", loser);
saveRecord(); // 在发送结束消息给前端之前先将游戏记录存下来
sendAllMessage(resp.toJSONString());
}

...
}