思维导图
同步玩家
后端
在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
| 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(); ... respA.put("game", respGame);
users.get(a.getId()).sendMessage(respA.toJSONString());
JSONObject respB = new JSONObject(); ... 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
| 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 => { const data = JSON.parse(msg.data); if(data.event === "start-matching") { ... setTimeout(() => { store.commit("updateStatus", "playing"); }, 200); store.commit("updateGame", data.game); } }
|
游戏界面
在实现游戏界面前,先大致梳理一下整个项目的流程。
匹配池里出现两个玩家,就让他们匹配,创建一局新游戏,所以当前项目大致是这样运行的:
1 2 3 4 5 6 7
| int main() { game1(); game2(); ... gamen(); return 0; }
|
这就出现了一个问题,后开的游戏必须等到前面的游戏结束后才能执行,这会严重影响到用户体验。
因此,要让所有游戏并发执行,游戏类必须用多线程实现。
可能引发的问题
对于目前实现的贪吃蛇,只在一个页面控制,不会涉及到同步的问题。可是,现在对于一局游戏,实际上有三个进程:两个在客户端,一个在服务端。
为了更新玩家的状态,游戏类里需要存放一个变量nextStep
用于记录玩家的下一步操作。客户端需要修改该变量以实现状态更新,服务端需要读取该变量以实现逻辑判断。这就涉及到多线程的读写冲突问题了。
为了解决这个问题,凡是要对nextStep
变量进行操作的,都要上锁以防止读写冲突发生
1 2 3 4 5
| lock.lock();
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 { ... 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.start();
users.get(a.getId()).game = game; users.get(b.getId()).game = game; ... } } ... }
|
实现游戏逻辑
一局游戏的操作,首先执行 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() { 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; private Integer nextStepB = null; 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); } catch (InterruptedException e) { throw new RuntimeException(e); } 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() { 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) { 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;
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) { 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", }, 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") { snake0.status = "die"; } if (data.loser === "all" || data.loser === "B") { snake1.status = "die"; } store.commit("updateLoser", data.loser); }
|
对局回放
乍一听很玄乎,其实就是把对局时,每个状态都保存在数据库里,使得前端能够根据这些状态复现游戏结果。
新建数据库
实现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() { 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(); }
|
最终成果