AcWing:SpringBoot 框架课 - 创建对战列表与排行榜页面

遗留操作

  • 游戏结束更新积分

将 userMapper 修改为 public,因为要在对局结束,修改玩家积分

1
2
3
4
5
6
7
8
9
10
11
12
13
14
backend/consumer/WebSocketServer.java

package com.kob.backend.consumer;

...

@Component
// url链接:ws://127.0.0.1:3000/websocket/**
@ServerEndpoint("/websocket/{token}") // 注意不要以'/'结尾
public class WebSocketServer {
public static UserMapper userMapper;

...
}
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
backend/consumer/utils/Game.java

package com.kob.backend.consumer.utils;

...

import com.kob.backend.pojo.User;

public class Game extends Thread {
...

private void updateUserRating(Player player, Integer rating) {
User user = WebSocketServer.userMapper.selectById(player.getId());
user.setRating(rating);
WebSocketServer.userMapper.updateById(user);
}

private void saveToDataBase() {
Integer ratingA = WebSocketServer.userMapper.selectById(playerA.getId()).getRating();
Integer ratingB = WebSocketServer.userMapper.selectById(playerB.getId()).getRating();

if("A".equals(loser)) {
ratingA -= 2;
ratingB += 5;
} else if("B".equals(loser)) {
ratingA += 5;
ratingB -= 2;
}

updateUserRating(playerA, ratingA);
updateUserRating(playerB, ratingB);

...
}

...
}

主要内容

  • 加上 mybatis 分页配置
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
backend/config/MybatisConfig.java

package com.kob.backend.config;

import com.baomidou.mybatisplus.annotation.DbType;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class MybatisConfig {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
return interceptor;
}
}

1.对局列表和回放

1.1 后端

  • 接口
1
2
3
4
5
6
7
8
9
10
backend/service/record/GetRecordListService.java

package com.kob.backend.service.record;

import com.alibaba.fastjson.JSONObject;

public interface GetRecordListService {

JSONObject getList(Integer page);
}
  • 接口实现
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
backend/service/impl/record/GetRecordListServiceImpl.java

package com.kob.backend.service.impl.record;

import com.alibaba.fastjson.JSONObject;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.kob.backend.mapper.RecordMapper;
import com.kob.backend.mapper.UserMapper;
import com.kob.backend.pojo.Record;
import com.kob.backend.pojo.User;
import com.kob.backend.service.record.GetRecordListService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.util.LinkedList;
import java.util.List;

@Service
public class GetRecordListServiceImpl implements GetRecordListService {
@Autowired
private RecordMapper recordMapper;
@Autowired
private UserMapper userMapper;

@Override
public JSONObject getList(Integer page) {
IPage<Record> recordIPage = new Page<>(page, 10);
QueryWrapper<Record> queryWrapper = new QueryWrapper<>();
queryWrapper.orderByDesc("id");
List<Record> records = recordMapper.selectPage(recordIPage, queryWrapper).getRecords();
JSONObject resp = new JSONObject();
List<JSONObject> items = new LinkedList<>();
for(Record record : records) {
User userA = userMapper.selectById(record.getAId());
User userB = userMapper.selectById(record.getBId());
JSONObject item = new JSONObject();
item.put("a_photo", userA.getPhoto());
item.put("a_username", userA.getUsername());
item.put("b_photo", userB.getPhoto());
item.put("b_username", userB.getUsername());
item.put("record", record);
String result = "平局";
if("A".equals(record.getLoser())) result = "B胜";
else if("B".equals(record.getLoser())) result = "A胜";
item.put("result", result);
items.add(item);
}
resp.put("records", items);
resp.put("records_count", recordMapper.selectCount(null));
return resp;
}
}
  • 控制器

在类内定义属性时,使用 spring 自动注入的话,若是在 service 或 controller 定义,则不用定义 static。翻译过来,意思就是,若当前类本身是单例,则属性本身就只会有一份,无论加不加静态变量,效果都一样;若是第三方类,也就是自己写的类,一般都是会定义多个对象,那么就要思考属性到底是属于类的,还是属于对象的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
backend/controller/record/GetRecordListController.java

package com.kob.backend.controller.record;

import com.alibaba.fastjson.JSONObject;
import com.kob.backend.service.record.GetRecordListService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import java.util.Map;

@RestController
public class GetRecordListController {
@Autowired
private GetRecordListService getRecordListService;

@GetMapping("/record/getlist/")
JSONObject getList(@RequestParam Map<String, String> data) {
Integer page = Integer.parseInt(data.get("page"));
return getRecordListService.getList(page);
}
}

1.2 前端

  • 对局列表页面
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
src/views/record/RecordIndexView.vue

<template>
<ContentField>
<table class="table table-striped table-hover" style="text-align: center;">
<thead>
<tr>
<th>A</th>
<th>B</th>
<th>对战结果</th>
<th>对战时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="record in records" :key="record.record.id">
<td>
<img :src="record.a_photo" alt="" class="record-user-photo">
&nbsp;
<span class="record-user-username">{{ record.a_username }}</span>
</td>
<td>
<img :src="record.b_photo" alt="" class="record-user-photo">
&nbsp;
<span class="record-user-username">{{ record.b_username }}</span>
</td>
<td>
{{ record.result }}
</td>
<td>{{ record.record.createtime }}</td>
<td>
<button type="button" class="btn btn-secondary">查看录像</button>
</td>
</tr>
</tbody>
</table>
</ContentField>
</template>

<script>
import ContentField from '../../components/ContentField.vue'
import { useStore } from 'vuex';
import { ref } from 'vue';
import $ from 'jquery';

export default {
components: {
ContentField
},
setup() {
const store = useStore();
let records = ref([]);
let current_page = 1;
let total_records = 0;

const pull_page = page => {
$.ajax({
url: "http://127.0.0.1:3000/record/getlist/",
data: {
page,
},
type: "get",
headers: {
'Authorization': "Bearer " + store.state.user.token,
},
success(resp) {
records.value = resp.records;
total_records = resp.records_count;
},
error(resp) {
console.log(resp);
}
})
};

pull_page(current_page);

return {
records,
}
}
}
</script>

<style scoped>
img.record-user-photo {
width: 4vh;
border-radius: 50%;
}
</style>
  • 新建 record store
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
src / store / record.js;

export default {
state: {
is_record: false,
a_step: "",
b_step: "",
record_loser: "", // 多个store文件的state数据名不能重复
},
getters: {},
mutations: {
updateIsRecord(state, is_record) {
state.is_record = is_record;
},
updateSteps(state, data) {
state.a_steps = data.a_steps;
state.b_steps = data.b_steps;
},
updateRecordLoser(state, loser) {
state.record_loser = loser;
},
},
actions: {},
modules: {},
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
src / store / index.js;

import { createStore } from "vuex";
import ModuleUser from "./user";
import ModulePk from "./pk";
import ModuleRecord from "./record";

export default createStore({
state: {},
getters: {},
mutations: {},
actions: {},
modules: {
user: ModuleUser,
pk: ModulePk,
record: ModuleRecord,
},
});
  • 跳转到对局回放页面
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
src/views/record/RecordIndexView.vue

<template>
<ContentField>
<table class="table table-striped table-hover" style="text-align: center;">
...
<tbody>
<tr v-for="record in records" :key="record.record.id">
...
<td>
<button @click="open_record_content(record.record.id)" type="button" class="btn btn-secondary">查看录像</button>
</td>
</tr>
</tbody>
</table>
</ContentField>
</template>

<script>
...
import router from '../../router/index';

export default {
...

setup() {
...

const stringTo2D = map => {
let g = [];
for(let i = 0, k = 0; i < 13; i++) {
let line = [];
for(let j = 0; j < 14; j++, k++) {
if(map[k] === '0') line.push(0);
else line.push(1);
}
g.push(line);
}
return g;
}

const open_record_content = recordId => {
for(const record of records.value) {
if(record.record.id === recordId) {
store.commit("updateIsRecord", true);
store.commit("updateGame", {
map: stringTo2D(record.record.map),
a_id: record.record.aid,
a_sx: record.record.asx,
a_sy: record.record.asy,
b_id: record.record.bid,
b_sx: record.record.bsx,
b_sy: record.record.bsy,
});
store.commit("updateSteps", {
a_steps: record.record.asteps,
b_steps: record.record.bsteps,
});
store.commit("updateRecordLoser", record.record.loser);
router.push({
name: 'record_content',
params: {
recordId,
}
});
break;
}
}
};

return {
records,
open_record_content,
}
}
}
</script>

...

当在 pk 页面时,要标记不是录像

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
src/views/pk/PkIndexView.vue

<template>
...
</template>

<script>
...

export default {
...

setup() {
...

store.commit("updateLoser", 'none');
store.commit("updateIsRecord", false);

...
}
}
</script>

<style scoped>

</style>
  • 对局回放页面
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
src/views/record/RecordContentView.vue

<template>
<PlayGround />
</template>

<script>
import PlayGround from '../../components/PlayGround.vue';

export default {
components: {
PlayGround,
},
setup() {

}
}
</script>

<style scoped>

</style>
  • 加进 router
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
src/router/index.js

...

import RecordIndexView from '../views/record/RecordIndexView'
import RecordContentView from '../views/record/RecordContentView'
...

const routes = [
...

{
path: "/record/:recordId/",
name: "record_content",
component: RecordContentView,
meta: {
requestAuth: true,
}
},

...
]

...
  • 修改 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
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
src/assets/scripts/GameMap.js

...

export class GameMap extends AcGameObject {
...

add_listening_events() {
if(this.store.state.record.is_record) {
let k = 0;
const a_steps = this.store.state.record.a_steps;
const b_steps = this.store.state.record.b_steps;
const loser = this.store.state.record.record_loser;
const [snake0, snake1] = this.snakes;
const interval_id = setInterval(() => {
// 将除死亡的操作每300ms泫渲染出来
if(k >= a_steps.length - 1) {
if(loser === "all" || loser === "A") {
snake0.status = "die";
}
if(loser === "all" || loser === "B") {
snake1.status = "die";
}
clearInterval(interval_id);
} else {
snake0.set_direction(parseInt(a_steps[k]));
snake1.set_direction(parseInt(b_steps[k]));
}
k++;
}, 300);
} else {
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,
}));
}
});
}
}

...
}
  • 分页
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
src/views/record/RecordIndexView.vue

<template>
<ContentField>
...

<nav aria-label="...">
<ul class="pagination" style="float: right;">
<li class="page-item" @click="click_page(-2)">
<a class="page-link" href="#">前一页</a>
</li>
<li :class="'page-item ' + page.is_active" v-for="page in pages" :key="page.number" @click="click_page(page.number)">
<a class="page-link" href="#">{{ page.number }}</a>
</li>
<li class="page-item" @click="click_page(-1)">
<a class="page-link" href="#">后一页</a>
</li>
</ul>
</nav>
</ContentField>
</template>

<script>
...

export default {
components: {
...
},
setup() {
...

let current_page = 1;
let total_records = 0;
let pages = ref([]);

const click_page = page => {
if(page === -2) page = current_page - 1;
else if(page === -1) page = current_page + 1;
let max_pages = parseInt(Math.ceil(total_records / 10));

if(page >= 1 && page <= max_pages) {
pull_page(page);
}
}

const update_pages = () => {
let max_pages = parseInt(Math.ceil(total_records / 10));
let new_pages = [];
for(let i = current_page - 2; i <= current_page + 2; i++) {
if(i >= 1 && i <= max_pages) {
new_pages.push({
number: i,
is_active: i === current_page ? "active" : "",
});
}
}
pages.value = new_pages;
};

const pull_page = page => {
current_page = page;
$.ajax({
...

success(resp) {
...

update_pages();
},

...
})
};

...

return {
...

pages,
click_page,
}
}
}
</script>

<style scoped>
...
</style>

2.排行榜

2.1 后端

  • 接口
1
2
3
4
5
6
7
8
9
10
backend/service/ranklist/GetRanklistService.java

package com.kob.backend.service.ranklist;

import com.alibaba.fastjson.JSONObject;

public interface GetRanklistService {

JSONObject getList(Integer page);
}
  • 接口实现
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
backend/service/impl/ranklist/GetRanklistServiceImpl.java

package com.kob.backend.service.impl.ranklist;

import com.alibaba.fastjson.JSONObject;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.kob.backend.mapper.UserMapper;
import com.kob.backend.pojo.User;
import com.kob.backend.service.ranklist.GetRanklistService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.util.List;

@Service
public class GetRanklistServiceImpl implements GetRanklistService {
@Autowired
private UserMapper userMapper;

@Override
public JSONObject getList(Integer page) {
IPage<User> userIPage = new Page<>(page, 3);
QueryWrapper<User> queryWrapper = new QueryWrapper<>();
queryWrapper.orderByDesc("rating");
List<User> users = userMapper.selectPage(userIPage, queryWrapper).getRecords();
JSONObject resp = new JSONObject();
for(User user : users)
user.setPassword("");
resp.put("users", users);
resp.put("users_count", userMapper.selectCount(null));
return resp;
}
}
  • 控制器
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
backend/controller/ranklist/GetRanklistController.java

package com.kob.backend.controller.ranklist;

import com.alibaba.fastjson.JSONObject;
import com.kob.backend.service.ranklist.GetRanklistService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import java.util.Map;

@RestController
public class GetRanklistController {
@Autowired
private GetRanklistService getRanklistService;

@GetMapping("/ranklist/getlist/")
public JSONObject getList(@RequestParam Map<String, String> data) {
Integer page = Integer.parseInt(data.get("page"));
return getRanklistService.getList(page);
}
}

2.2 前端

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
src/views/ranklist/RanklistIndexView.vue

<template>
<ContentField>
<table class="table table-striped table-hover" style="text-align: center;">
<thead>
<tr>
<th>玩家</th>
<th>天梯分</th>
</tr>
</thead>
<tbody>
<tr v-for="user in users" :key="user.id">
<td>
<img :src="user.photo" alt="" class="user-photo">
&nbsp;
<span class="user-username">{{ user.username }}</span>
</td>
<td>
{{ user.rating }}
</td>
</tr>
</tbody>
</table>
<nav aria-label="...">
<ul class="pagination" style="float: right;">
<li class="page-item" @click="click_page(-2)">
<a class="page-link" href="#">前一页</a>
</li>
<li :class="'page-item ' + page.is_active" v-for="page in pages" :key="page.number" @click="click_page(page.number)">
<a class="page-link" href="#">{{ page.number }}</a>
</li>
<li class="page-item" @click="click_page(-1)">
<a class="page-link" href="#">后一页</a>
</li>
</ul>
</nav>
</ContentField>
</template>

<script>
import ContentField from '../../components/ContentField.vue'
import { useStore } from 'vuex';
import { ref } from 'vue';
import $ from 'jquery';

export default {
components: {
ContentField
},
setup() {
const store = useStore();
let users = ref([]);
let current_page = 1;
let total_users = 0;
let pages = ref([]);

const click_page = page => {
if(page === -2) page = current_page - 1;
else if(page === -1) page = current_page + 1;
let max_pages = parseInt(Math.ceil(total_users / 3));

if(page >= 1 && page <= max_pages) {
pull_page(page);
}
}

const update_pages = () => {
let max_pages = parseInt(Math.ceil(total_users / 3));
let new_pages = [];
for(let i = current_page - 2; i <= current_page + 2; i++) {
if(i >= 1 && i <= max_pages) {
new_pages.push({
number: i,
is_active: i === current_page ? "active" : "",
});
}
}
pages.value = new_pages;
};

const pull_page = page => {
current_page = page;
$.ajax({
url: "http://127.0.0.1:3000/ranklist/getlist/",
data: {
page,
},
type: "get",
headers: {
'Authorization': "Bearer " + store.state.user.token,
},
success(resp) {
users.value = resp.users;
total_users = resp.users_count;
update_pages();
},
error(resp) {
console.log(resp);
}
})
};

pull_page(current_page);

return {
users,
pages,
click_page,
}
}
}
</script>

<style scoped>
img.user-photo {
width: 4vh;
border-radius: 50%;
}
</style>

3.修改创建 bot 最多数量

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
backend/service/impl/user/bot/AddServiceIMpl

package com.kob.backend.service.impl.user.bot;

import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;

...

@Service
public class AddServiceImpl implements AddService {
@Autowired
private BotMapper botMapper;

@Override
public Map<String, String> add(Map<String, String> data) {
...

if(content.length() > 10000) {
map.put("error_message", "代码长度不能超过10000");
return map;
}

QueryWrapper<Bot> queryWrapper = new QueryWrapper<>();
queryWrapper.eq("user_id", user.getId());
if(botMapper.selectCount(queryWrapper) >= 10) {
map.put("error_message", "每个用户最多只能创建10个Bot!");
return map;
}

...
}
}

补充

部分玩家在未输入完操作就结束了游戏。这是由于有些玩家在前端是 2000ms 再开始游戏,而后端的 nextstep 是 200ms,导致后端没接收到输入,就提前结束了游戏。以下是修改是为了给游戏初始化充足的时间,并尽量写清晰逻辑。

  • 在匹配成功后 2000ms 后再进入游戏
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
src/views/pk/PkIndexView.vue

<template>
...
</template>

<script>
...

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

onMounted(() => {
...

// 回调函数:接收到后端信息调用
socket.onmessage = msg => {
// 返回的信息格式由后端框架定义,django与spring定义的不一样
const data = JSON.parse(msg.data);
if(data.event === "start-matching") {
...
setTimeout(() => {
store.commit("updateStatus", "playing");
}, 2000);
...
}

...
}

...
});

...
}
}
</script>

<style scoped>

</style>
  • 在第一次接收输入时特判,等待 2000ms,之后每次接收输入是在 200ms 后判断
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
backend/consumer/utils/Game.java

package com.kob.backend.consumer.utils;

...

public class Game extends Thread {
...
private boolean isStart = true;

// 接收玩家的下一步操作
private boolean nextStep() {
// 每秒五步操作,因此第一步操作是在200ms后判断是否接收到输入。并给地图初始化时间
try {
if(isStart) {
Thread.sleep(2000);
isStart = false;
} else {
Thread.sleep(200);
}
} catch (InterruptedException e) {
throw new RuntimeException(e);
}

...
}

...
}