思维导图

实现联机对战

同步玩家

后端

backend/consumer/utils下定义玩家类,存储玩家信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.util.List;

@Data
@AllArgsConstructor
@NoArgsConstructor
public class Player {
private Integer id;
private Integer sx;
private Integer sy;
// 蛇每个球的方向
private List<Integer> steps;
}

在游戏类中导入玩家。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
private final Player playerA, playerB;

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 int[rows][cols];
playerA = new Player(idA, rows - 2, 1, new ArrayList<>());
playerB = new Player(idB, 1, cols - 2, new ArrayList<>());
}

public Player getPlayerA() {
return playerA;
}

public Player getPlayerB() {
return playerB;
}

在匹配完之后,初始化地图时,将玩家和地图一起传给前端。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 玩家的id以及横纵信息
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());

// 发送给A的信息
JSONObject respA = new JSONObject();
...
respA.put("game", respGame);
// 通过userId取出a的连接,给A发送respA
users.get(a.getId()).sendMessage(respA.toJSONString());

// 发送给B的信息
JSONObject respB = new JSONObject();
...
respB.put("game", respGame);
// 通过userId取出b的连接,给B发送respB
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
export default {
state: {
...

gamemap: null,
a_id: 0,
a_sx: 0,
a_sy: 0,
b_id: 0,
b_sx: 0,
b_sy: 0,
},

...

mutations: {
...

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

...
}

修改PK界面的onmessage函数

1
2
3
4
5
6
7
8
9
10
11
12
// 回调函数:接收到后端信息调用
socket.onmessage = msg => {
// 返回的信息格式由后端框架定义,django与spring定义的不一样
const data = JSON.parse(msg.data);
if(data.event === "start-matching") {
...
setTimeout(() => {
store.commit("updateStatus", "playing");
}, 200);
store.commit("updateGame", data.game);
}
}

游戏界面

在实现游戏界面前,先大致梳理一下整个项目的流程。

QQ图片20221103083508.jpg

匹配池里出现两个玩家,就让他们匹配,创建一局新游戏,所以当前项目大致是这样运行的:

1
2
3
4
5
6
7
int main() {
game1();
game2();
...
gamen();
return 0;
}

这就出现了一个问题,后开的游戏必须等到前面的游戏结束后才能执行,这会严重影响到用户体验。

因此,要让所有游戏并发执行,游戏类必须用多线程实现。

可能引发的问题

对于目前实现的贪吃蛇,只在一个页面控制,不会涉及到同步的问题。可是,现在对于一局游戏,实际上有三个进程:两个在客户端,一个在服务端。

为了更新玩家的状态,游戏类里需要存放一个变量nextStep用于记录玩家的下一步操作。客户端需要修改该变量以实现状态更新,服务端需要读取该变量以实现逻辑判断。这就涉及到多线程的读写冲突问题了。

为了解决这个问题,凡是要对nextStep变量进行操作的,都要上锁以防止读写冲突发生

1
2
3
4
5
lock.lock();
/*
使用nextStep
*/
lock.unlock();

如果中途可能抛出异常,就用finally,即使报异常也会解锁

1
2
3
4
5
6
lock.lock();
try {
...
} finally {
lock.unlock();
}

更改为多线程

Game类继承自Thread,这样就能够实现多线程。

Thread有两个函数需要用到,thread.start()用于新开一个线程。thread.run()中写的代码是并发的,也就是不需要执行完就能执行别的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class WebSocketServer {
...
// 将private改成public,因为Game类需要使用
public static ConcurrentHashMap<Integer, WebSocketServer> users = new ConcurrentHashMap<>();
...
private Game game = null;
...
private void startMatching() {
System.out.println("start matching!");
matchPool.add(this.user);
while(matchPool.size() >= 2) {
...
game.createMap();
// 一局游戏一个线程,会执行game类的run方法
game.start();

users.get(a.getId()).game = game; //维护用户A的websocket
users.get(b.getId()).game = game; //维护用户B的websocket
...
}
}
...
}

实现游戏逻辑

一局游戏的操作,首先执行 run 方法。只有读写、写写有冲突,此处关于 nextStep,我们会接收前端的 nextStep 输入 或 bots 代码的输入,而且会频繁的读,因此需要加锁。

游戏的主要逻辑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
@Override
public void run() { //重写Thread
for (int i = 0; i < 1000; i++) {
if (nextStep()) {
//玩家都执行了操作
judge();
if (status.equals("playing")) {
sendMove();
} else {
sendResult();
break;
}
}
else {
status = "finished";
lock.lock();
try {
if (nextStepA == null && nextStepB == null) {
loser = "all";
}
else if (nextStepA == null) {
loser = "A";
}
else if (nextStepB == null) {
loser = "B";
}
} finally {
lock.unlock();
}
sendResult();
break;

}
}
}

可以看到这里要实现两个功能:广播蛇的移动信息和广播对局结果。

为了实现广播信息,先定义一个辅助函数,用于向前端广播信息。

1
2
3
4
private void sendAllMessage(String message) { //辅助函数,广播信息
WebSocketServer.users.get(PlayerA.getId()).sendMessage(message);
WebSocketServer.users.get(PlayerB.getId()).sendMessage(message);
}

nextStep

蛇移动的前提是双方都执行了操作,判断对局是否结束也就相当于判断蛇的下一步操作是否合法。所以先实现nextStep()方法。

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
private Integer nextStepA = null;   // A的操作
private Integer nextStepB = null; // B的操作
private ReentrantLock lock = new ReentrantLock();

// 在主线程会读两个玩家的操作,并且玩家随时可能输入操作,存在读写冲突
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();
}
}

private boolean nextStep() { //辅助函数, 两名玩家下一步操作是否就绪
try {
Thread.sleep(200); //最多200ms一次操作,频率在高的话操作可能会丢失
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
// 因为会读玩家的nextStep操作,因此加锁
for (int i = 0; i < 50; i++) {
try {
Thread.sleep(100);
lock.lock();
try {
if (nextStepA != null && nextStepB != null) {
PlayerA.getSteps().add(nextStepA);
PlayerB.getSteps().add(nextStepB);
return true;
}
} finally {
lock.unlock();
}
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
return false;
}

实现移动

这个就很简单了,向前端传递两个玩家移动的方向即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
private void sendMove() { //辅助函数, 向两名玩家发送move操作结果
lock.lock();
try {
JSONObject jsonObject = new JSONObject();
jsonObject.put("event", "move");
jsonObject.put("a_direction", nextStepA);
jsonObject.put("b_direction", nextStepB);
sendAllMessage(jsonObject.toJSONString());
nextStepA = nextStepB = null; //清空操作,避免影响接下来的结果
} finally {
lock.unlock();
}
}

判断游戏结果

将前端的裁判程序转移到后端。

consumer/utils里新建Cell类,用于辅助。

1
2
3
4
5
6
7
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Cell {
private int x;
private int y;
}

修改consumer/utils下的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
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 <= 10) return true;
return step % 3 == 1;
}
public List<Cell> getCells() { //获取组成蛇的方块坐标
List<Cell> res = new ArrayList<>();
int[] dx = {-1, 0, 1, 0}, dy = {0, 1, 0, -1};
int x = sx, y = sy;
int step = 0; //回合数
res.add(new Cell(x, y));
for (int i = 0; i < steps.size(); i++) {
x = x + dx[steps.get(i)];
y = y + dy[steps.get(i)];
res.add(new Cell(x, y));
if (!check_tail_increasing(++step)) {
res.remove(0); //如果当前回合蛇的长度不增加,把组成蛇尾的方块删掉
}
}
return res;
}
}

在后端重新实现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
43
44
45
private boolean checkValid(List<Cell> cellsA, List<Cell> cellsB) {
//辅助函数,判断cellsA的蛇头是否合法
int n = cellsA.size();
Cell cell = cellsA.get(n - 1);
if (g[cell.getX()][cell.getY()] == 1) { //撞墙
return false;
}
for (int i = 0; i < n - 1; i++) {
if (cellsA.get(i).getX() == cell.getX() &&
cellsA.get(i).getY() == cell.getY()) { //和蛇身重合
return false;
}
if (cellsB.get(i).getX() == cell.getY() &&
cellsB.get(i).getY() == cell.getY()) { //和蛇身重合
return false;
}
}
return true;
}

private void judge() { //辅助函数,判断两名玩家下步操作是否合法
List<Cell> cellsA = PlayerA.getCells();
List<Cell> cellsB = PlayerB.getCells();
boolean validA = checkValid(cellsA, cellsB);
boolean validB = checkValid(cellsB, cellsA);
if (!validA || !validB) {
status = "finished";
if (!validA && !validB) {
loser = "all";
}
else if (!validA) {
loser = "A";
}
else {
loser = "B";
}
}
}

private void sendResult() { //辅助函数,向两名玩家发送游戏结果,并将游戏记录保存到数据库
JSONObject jsonObject = new JSONObject();
jsonObject.put("event", "result");
jsonObject.put("loser", loser);
sendAllMessage(jsonObject.toJSONString());
}

前后端交互

实现移动

前端发送移动指令给后端

修改GameMap.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
export class GameMap extends AcGameObject {
...

add_listening_events() {
this.ctx.canvas.focus();
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;
// 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);

// 若移动了,发送给后端
if(d >= 0) {
this.store.state.pk.socket.send(JSON.stringify({
event: "move",
direction: d,
}));
}
});
}
...
}

后端在WebSocket类里处理接受移动的事件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@OnMessage
public void onMessage(String message, Session session) {
// 从Client接收消息
System.out.println("receive message: " + message);
JSONObject data = JSONObject.parseObject(message);
String event = data.getString("event");
if("start-matching".equals(event)) {
startMatching();
} else if("stop-matching".equals(event)) {
stopMatching();
} else if("move".equals(event)) {
move(data.getInteger("direction"));
}
}

前端接收后端发送过来的移动事件

store/pk.js设立GameMap的存储

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
export default {
state: {
...

gameObject: null,
},
getters: {
},
mutations: {
...

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

},
modules: {
}
}

GameMap.vue中引入该函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
export default {
setup() {
...

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

...
}
}

PK界面中接收后端的移动事件

1
2
3
4
5
6
else if(data.event === "move") {
const game = store.state.pk.gameObject;
const [snake0, snake1] = game.snakes;
snake0.set_direction(data.a_direction);
snake1.set_direction(data.b_direction);
}

实现蛇的死亡

删除前端判断蛇的操作是否有效,已经在后端重写了。

后端随时有可能将对局结束的信息发给前端,前端需要根据后端结果更改蛇的状态。

1
2
3
4
5
6
7
8
9
10
else if(data.event === "result") {
const game = store.state.pk.gameObject;
const [snake0, snake1] = game.snakes;
if(data.loser === "all" || data.loser === "A") {
snake0.status = "die";
}
if(data.loser === "all" || data.loser === "B") {
snake1.status = "die";
}
}

实现对局结束后的计分板

pk.js中新建一个变量loser

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
export default {
state: {
...
loser: "none", // all、A、B
},
getters: {
},
mutations: {
...
updateLoser(state, loser) {
state.loser = loser;
}
},
actions: {

},
modules: {
}
}

新建组件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
<template>
<div class="result-board">
<div class="result-board-text" v-if="$store.state.pk.loser === 'all'">
平局
</div>
<div class="result-board-text"
v-else-if="$store.state.pk.loser === 'A' && $store.state.pk.a_id === parseInt($store.state.user.id)">
铁菜逼
</div>
<div class="result-board-text"
v-else-if="$store.state.pk.loser === 'B' && $store.state.pk.b_id === parseInt($store.state.user.id)">
铁菜逼
</div>
<div class="result-board-text" v-else>
胜利
</div>
<div class="result-board-btn">
<button @click="restart" type="button" class="btn btn-success btn-lg">
退出
</button>
</div>
</div>
</template>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<script>
import { useStore } from 'vuex';

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

const restart = () => {
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 {
restart,
}
}
}
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<style scoped>
div.result-board {
height: 30vh;
width: 30vw;
background-color: rgba(50, 50, 50, 0.5);
position: absolute;
top: 30vh;
left: 35vw;
}

div.result-board-text {
text-align: center;
color: white;
font-size: 50px;
font-weight: 600;
padding-top: 5vh;
}

div.result-board-btn {
padding-top: 7vh;
text-align: center;
}
</style>

在PK界面中导入该组件,并在onMessage函数里收到对局结束的信息后,更新loser变量。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<ResultBoard v-if="$store.state.pk.loser != 'none'" />

import ResultBoard from '../../components/ResultBoard.vue';

components: {
...

ResultBoard,
},

else if (data.event === "result") {
const game = store.state.pk.gameObject; //取出游戏界面
const [snake0, snake1] = game.snakes;
if (data.loser === "all" || data.loser === "A") { //A死
snake0.status = "die";
}
if (data.loser === "all" || data.loser === "B") { //B死
snake1.status = "die";
}
store.commit("updateLoser", data.loser);
}

对局回放

乍一听很玄乎,其实就是把对局时,每个状态都保存在数据库里,使得前端能够根据这些状态复现游戏结果。

新建数据库

2.png

实现pojo层,pojo/Record.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Data
@NoArgsConstructor
@AllArgsConstructor
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;
}

实现mapper层,mapper/RecordMapper

1
2
3
4
@Mapper
public interface RecordMapper extends BaseMapper<Record> {

}

WebsocketServer类里注入RecordMapper接口,保存对局信息

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

private static UserMapper userMapper;
public static RecordMapper recordMapper;

...

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

...

}

更改consumer/utils/Player,新增函数,将Player类中存储的List<Interger> steps转化为字符串,这样才能存到数据库里面

1
2
3
4
5
6
7
public String getStepsString() {
StringBuilder res = new StringBuilder();
for (int d : steps) {
res.append(d);
}
return res.toString();
}

同样的道理,存储在consumer/utils/Game类里的地图信息,也要新建函数将其转化为字符串。

1
2
3
4
5
6
7
8
9
private String getMapString() { //辅助函数,将游戏地图类型转化为string
StringBuilder res = new StringBuilder();
for (int i = 0; i < rows; i++) {
for (int j = 0; j < cols; j++) {
res.append(g[i][j]);
}
}
return res.toString();
}

然后就可以编写保存数据库函数,并在sendResult里调用了

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
private void saveToDatabase() { //向数据库保存对局记录
Record record = new Record(
null,
getPlayerA().getId(),
getPlayerA().getSx(),
getPlayerA().getSy(),
getPlayerB().getId(),
getPlayerB().getSx(),
getPlayerB().getSy(),
getPlayerA().getStepsString(),
getPlayerB().getStepsString(),
getMapString(),
loser,
new Date()
);
WebSocketServer.recordMapper.insert(record);
}

private void sendResult() { //辅助函数,向两名玩家发送游戏结果,并将游戏记录保存到数据库
JSONObject jsonObject = new JSONObject();
jsonObject.put("event", "result");
jsonObject.put("loser", loser);
sendAllMessage(jsonObject.toJSONString());
saveToDatabase(); //把游戏记录保存到数据库
}

最终成果

最终成果