由于要区分玩家,所以要在之前Game.java
添加一个Player
类存储玩家信息,
包括:
玩家Id
,
玩家起始位置(sx,sy)
记录每个玩家走过的路径steps
,即每个玩家历史上执行过的操作序列,用List
存
consumer/utils/Player.java
package com.popgame.backend.consumer.utils;
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;
}
在consumer/utils/Game.java
里添加Player
类,playerA
表示左下角的玩家,playerB
表示右上角的玩家,同时添加获取A,B player的函数,方便外部调用。
private 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.mark = new boolean[rows][cols];
playerA = new Player(idA, this.rows - 2, 1, new ArrayList<>());
playerB = new Player(idB, 1, this.cols - 2, new ArrayList<>());
}
public Player getPlayerA() {
return playerA;
}
public Player getPlayerB() {
return playerB;
}
注意在consumer/WebSocketServer.java
里传参的时候也要修改
...
Game game = new Game(13, 14, 36,a.getId(),b.getId());
...
为了方便,我们可以把与游戏相关的信息封装成一个JSONObject
consumer/WebSocketServer.java
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.getMark());
...
//直接传游戏信息给玩家A和玩家B
respA.put("game", respGame);
...
respB.put("game", respGame);
store/pk.js
state: {
socket: null, //ws链接
opponent_username: "",
opponent_photo: "",
status: "matching", //matching表示匹配界面,playing表示对战界面
game_map: null,
a_id: 0,
a_sx: 0,
a_sy: 0,
b_id: 0,
b_sx: 0,
b_sy: 0,
},
getters: {
},
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.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;
},
...
},
在PKindex.vue
里面直接把整个数据传进去就好了
store.commit("updateGame",data.game);
这样我们就解决了上一part
遗留下来的问题,使其全部实现前后端交互了。
实际上我们在游戏对战的时候存在三个棋盘,两个是对战双方客户端里存在的棋盘,一个是云端存在的棋盘,我们要求实现云端与两个客户端之间的同步。
玩家每一次操作都会上传至云端服务器,当服务器接收到两个玩家的操作后,就会将两个玩家的蛇的移动信息同步给两个玩家。
游戏总流程示意图:
为了优化游戏体验度,我们的Game
不能作为单线程去处理,每一个Game
要另起一个新线程来做。
从Next Step
开始的操作可以当成一个线程,获取用户操作可以当成另一个线程。
这里我们涉及到两个线程之间进行通信的问题,以及线程开锁解锁的问题。
每一局单独的游戏都会new 一个新的Game
类,都是一个单独的线程。
继承一个 Thread
类,并且ALT + INS
重写run()方法
我们开始进行线程的执行的时候,线程的入口函数就是这个run()
函数
consumer/utils/Game.java
public class Game extends Thread{
...
@Override
public void run() {
super.run();
}
}
在consumer/WebSocketServer.java
里面通过start()
开始执行(是 Thread类的一个API)
game.createMap();
users.get(a.getId()).game = game; //需要在前面新建一个game属性
users.get(b.getId()).game = game;
game.start();
将用户的操作nextStep
存起来,方便外面的线程调用,
在Game
线程里面会读取两个玩家的操作nextStepA/B
的值,
在外面Client
线程里面则会修改这两个变量的值,
这里涉及到了线程的读写同步问题!
需要加上进程同步锁
一般来说就是先上锁再读写,后解锁
try{} finally {lock.unlock();}
可以保证报异常的情况下也可以解锁而不会产生死锁
简单总结一下就是:先上锁再操作,具体可以参考OS相关的内容o(╯□╰)o
所以以下涉及到nextStepA
和 nextStepB
的,不管是读还是写,只要出现了的话就要考虑到上锁和解锁方面的问题了,Be careful~~
consumer/utils/Game.java
//两名玩家的下一步操作,0123表示上右下左(与前端一致)
private Integer nextStepA = null;
private Integer nextStepB = null;
//进程同步锁
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() {
//等待玩家的下一步操作
}
后端接受前端两名玩家输入的操作后,才开始进行下一步操作。为了游戏的流畅性,提高玩家的游戏体验感,我们规定,如果超过一定的时间后,另一名玩家仍然未能给予操作,我们就判定这个玩家lose了。
可以用sleep函数来实现等待效果,定最长等待时间为5s。
这里可以按照自己的情况合理地规定等待时间,可以通过增加循环次数,减少sleep
时间优化玩家操作手感,以牺牲服务器的计算量换取玩家的操作的流畅性。
**tips:**要在循环里面上锁,在外面上锁会死锁!
还需要注意的是,我们前端设置1s走5步,200ms走一步,所以为了操作顺利,不会因为操作太快而读入多个操作,我们每一次读取前都要先sleep 200ms,规范一下。
因为后面要在外面调用每名玩家操作对应的ws链接,且需要向前端传递信息,需要先将下面两段代码改成
public
consumer/WebSocketServer.java
final public static ConcurrentHashMap<Integer, WebSocketServer> users = new ConcurrentHashMap<>();
public void sendMessage(String message) {
//异步通信要加上锁
synchronized (this.session) {
try {
this.session.getBasicRemote().sendText(message);
} catch (IOException e) {
e.printStackTrace();
}
}
}
编写后端逻辑
consumer/utils/Game.java
private boolean nextStep() {
//等待玩家的下一步操作
try {
Thread.sleep(200); //前端1s走5步,200ms走一步,因此为了操作顺利,每一次读取都要先sleep 200ms
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
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;
}
private void judge() {
//判断两名玩家下一步操作是否合法
}
private void sendAllMessage(String message) {
//向每一个人广播信息 后端->前端
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);
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()); //将JSON转化为字符串
}
@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 = "over";
lock.lock();
try {
if (nextStepA == null && nextStepB == null) {
loser = "all";
} else if (nextStepA == null) {
loser = "A";
} else {//nextStep() = false会有卡超时边界依然输入的情况,但是为了规则合理性,在这里全部判输了
loser = "B";
}
} finally {
lock.unlock();
}
sendResult();
break;
}
}
}
scripts/GameMap.js
add_events() {
this.ctx.canvas.focus();
const [snake0, snake1] = this.snakes;
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,
}));
}
});
}
同时在后端接受消息,并编写移动函数move()
consumer/WebSocketServer.java
public void move(int direction) {
if (game.getPlayerA().getId().equals(user.getId())) {
//蛇A
game.setNextStepA(direction);
} else if (game.getPlayerB().getId().equals(user.getId())) { //蛇B
game.setNextStepB(direction);
}
}
@OnMessage
public void onMessage(String message, Session session) {
// 从Client接收消息
System.out.println("receive 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)) {
int d = data.getInteger("direction");
move(d);
}
}
在前端编写move
和result
的逻辑函数,让小蛇动起来(?ω?)
同时,为了分别取出两条蛇可以将GameObject
在store/pk.js
里先存下来,记得写对应的update函数哦!
然后我们再在components/GameMap.vue
里修改
components/GameMap.vue
onMounted(() => {
store.commit("updateGameObject",new GameMap(canvas.value.getContext('2d'),parent.value,store));
});
蛇的去世判断要从前端搬到后端判断
先在前端写好情况分支选择
views/pk/PKindex.vue
onMounted(() => { //当当前页面打开时调用
...
socket.onmessage = msg => { //前端接收到信息时调用的函数
...
} 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);
} 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 = "dead";
}
if (data.loser === "all" || data.loser === "B") {
snake1.status = "dead";
}
}
}
...
});
在后端写judge逻辑
注意:要先添加一个Cell
类存储蛇的全部身体部分,在Player
类里面把蛇的身体都存储下来,
然后在Game
类里判断的时候再循环一遍两个Player
,各自取出自己的每一节cell
逐个判断。
判断逻辑包括:撞墙、撞到自己、撞到他人,这些都会导致自己lose
掉比赛
consumer/utils/Player.java
...
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<>(); //存放蛇的身体
int[][] fx = {{-1, 0}, {0, 1}, {1, 0}, {0, -1}};
int x = sx, y = sy;
res.add(new Cell(x, y));
int step = 0; //回合数
for (int d : steps) {
x += fx[d][0];
y += fx[d][1];
res.add(new Cell(x, y));
if (!check_tail_increasing(++step)) {
res.remove(0);
}
}
return res;
}
...
consumer/views/Game.java
...
private boolean check_valid(List<Cell> cellsA, List<Cell> cellsB) {
int n = cellsA.size();
Cell cell = cellsA.get(n - 1); //取出最后一位(蛇头)
if (mark[cell.x][cell.y]) { //如果最后一位是墙的话
return false;
}
for (int i = 0; i < n - 1; i++) {
if (cellsA.get(i).x == cell.x && cellsA.get(i).y == cell.y) { //如果自己碰到自己就算输
return false;
}
}
for (int i = 0; i < n - 1; i++) {
if (cellsB.get(i).x == cell.x && cellsB.get(i).y == cell.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 = "over";
if (!validA && !validB) {
loser = "all";
} else if (!validA) {
loser = "A";
} else {
loser = "B";
}
}
}
...
至此游戏的大部分逻辑已经写完了
首先在views/pk/PKindex.vue
里面添加游戏胜负显示逻辑
...
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 = "dead";
}
if (data.loser === "all" || data.loser === "B") {
snake1.status = "dead";
}
store.commit("updateLoser",data.loser);
}
...
在前端写一个组件components/ResultBoard.vue
这就是游戏结束后显示的结果版面,把谁是loser
存在store
里面就可以全局调用来判断了
components/ResultBoard.vue
<template>
<div class="result-board">
<div class="result-board-text draw" v-if="$store.state.pk.loser == 'all'" >
Draw
</div>
<div class="result-board-text lose" v-else-if="$store.state.pk.loser =='A' && $store.state.pk.a_id == $store.state.user.id" >
Lose
</div>
<div class="result-board-text lose" v-else-if="$store.state.pk.loser =='B' && $store.state.pk.b_id == $store.state.user.id" >
Lose
</div>
<div class="result-board-text win" v-else >
WIN
</div>
<div class="result-board-btn">
<button type="button" class="btn">Try again</button>
</div>
</div>
</template>
CSS 样式自己设计一下就好了
接下来我们把Try again
按钮实现一下,玩家可以在游戏结束后点击这个按钮再来一局游戏。
实现逻辑也比较简单,每次点击按钮,把游戏页面展示状态status
从playing
改成 matching
即可,这样整个游戏页面就返回到匹配页面了。
不要忘记了要updateLoser
改成none
,即重新开始游戏前还没有loser
还有把对手头像upd ateOpponent
成默认的灰头像。
为了后期存储对战录像,我们需要先设计一个存储对象的数据库。
数据库内容包括
然后像前面一样,建立相应的pojo
,mapper
层
准备工作完成后,我们就可以开始写将数据写入数据库的逻辑了
consumer/utils/Game.java
private String getMapString() {
StringBuilder res = new StringBuilder();
for (int i = 0; i < rows; i++) {
for (int j = 0; j < cols; j++) {
if (mark[i][j]) res.append(1);
else res.append(0);
}
}
return res.toString();
}
private void saveRecord() {
Record record = new Record(
null, //因为之前创建数据库时是把id定义为自动递增,所以这里不用手动传id
playerA.getId(),
playerA.getSx(),
playerA.getSy(),
playerB.getId(),
playerB.getSx(),
playerB.getSy(),
playerA.getStepsString(),
playerB.getStepsString(),
getMapString(),
loser,
new Date()
);
WebSocketServer.recordMapper.insert(record); //ws里数据库的注入
}
END
至此,我们就完成了联机匹配和存储游戏对局数据的大部分内容了,辛苦大家了QAQ