Springboot创建排行榜和录像回放
增加rating机制
我采用了Elo Rating System
来作为我的rating变化机制
Elo Rating System是由匈牙利裔美国物理学家Arpad Elo创建的一个衡量各类对弈活动水平的评价方法,是当今对弈水平评估的公认的权威方法。被广泛用于国际象棋、围棋、足球、篮球等运动。网络游戏英雄联盟、魔兽世界内的竞技对战系统也采用此分级制度。
计算公式:
EA=11+10(RB−RA)/400
EB=11+10(RA−RB)/400
R′A=RA+K(SA−EA)
其中:
-
EA:玩家A的胜率期望值
-
EB:玩家B的胜率期望值(可以看出,EA+EB = 1)
-
RA:玩家A当前积分
-
RB:玩家B当前积分
-
R’A:玩家A游戏后积分
-
K:常量系数,后文会说明具体作用
-
SA/SB:实际结果胜负分,胜 = 1,负= 0
显然 EA+EB=1
分母中的400:
为什么是400,而不是100、200或者1000呢?
从积分差上看,这个值影响着对战双方的获胜期望。当双方积分差相同时,这个值越大,双方的获胜概率越接近。当这个值等于400时,若双方分差为100,积分较高的一方获胜期望约为64%。
简单地说,这个值等于400,能够让多数玩家的积分保持标准正态分布,(但是实践显明玩家的表现并非呈正态分布,所以改进后的Elo Rating System
通常使用的是逻辑斯谛分布。)也符合多数游戏“易于上手,难于精通”的设计规则。
K常量:
不难看出K值越大,单次评价的积分变化幅度越大。那么K值的设定应该遵循什么规则?
一般而言,分段越高,K值越小。如此设计,能够令玩家的积分在前期快速趋近其真实水平,同时避免少数的几场对局就改变顶尖玩家的排名。
所以K值的选择取决于,这个游戏需要以什么样的方式来统计选手的积分,并根据玩家、玩家数量之类的参数微调。
总结:
从Elo的工作模式中我们可以得出以下几点:
- Elo会给出玩家一场对局的获胜概率。Elo积分相差越大,积分高的一方获胜概率就越大;
- 每一场对局后,对阵双方都会进行一部分积分交换,胜者得分,败者失分;
- 如果两名玩家的积分相差很大,代表高分方获胜的概率极大,因此即便赢了也涨不了多少分,败方也掉不了多少分。但倘若被低分方爆出冷门,那高分方将失去大量分数。
实现
这里我采用了Codeforces
的分段排名来定义K的大小。
题外话:如果有志同道合的朋友也玩cf,欢迎follow一起玩一起刷题(^▽^) ID:YumeMinami 是个菜狗好久没刷了QAQ
代码很容易写,这里就不展示了。。写的很拉
实现对局列表页面
后端返回对局列表list
老样子,要实现Service
层,Controller
层
先写service接口,写完写service接口的实现ServiceImpl,在重写方法之前,先创建对应的Controller,@Autowired
注入刚刚写的service接口,完善Controller,最后再完善Impl里的内容。
由于我们的对局情况可能会有很多,我们把所有对局情况都展示出来显然是不合适的,因此我们需要添加分页功能。
添加分页配置
在Config.MybatisConfig
中添加分页配置,如果像我一样用SQLServer的话就定义成Config.SQLServerConfig
:
@Configuration
public class MybatisConfig {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.SQL_SERVER));
return interceptor;
}
}
注意:这里我用的是SQLserver,要稍微修改一下!!
实现分页功能
由于我们不可能一页展示所有对局信息,因此我们要把所有信息分成多个页展示,这里就需要直到当前的页是第几页的。
要传入参数:当前页的编号;
Mybatis
里有API
可以实现以下页面展示效果,超出范围的话返回空即可。
1: 0~9
2: 10~19
3: 20~29
…
API
Ipage:IPage<Record> recordIPage = new Page<>(page,10);
(当前是第几页,每一页展示多少个)
Mybatis-Plus
关于分页查询的API
:
getRecords()
:获取查询数据getCurrent()
:获取当前页getSize()
:获取当前分页大小
Service层
backend\service\record\GetRecordListService.java
import com.alibaba.fastjson2.JSONObject;
public interface GetRecordListService {
JSONObject getList(Integer page);
}
backend\service\impl\record\GetRecordListServiceImpl.java
@Service
public class GetRecordListServiceImpl implements GetRecordListService {
@Autowired
private RecordMapper recordMapper;
@Autowired
private UsersMapper usersMapper;
@Override
public JSONObject getList(Integer page) {
/*
分页逻辑:
配合selectPage使用
先由QueryWrapper把所有record按id降序排序,然后由recordIpage返回第page页数目为10的内容
*/
IPage<Record> recordIPage = new Page<>(page, 10); //(当前页码,每页展示数目)
//排序展示最新的条目
QueryWrapper<Record> queryWrapper = new QueryWrapper<>();
//若设置成只能看自己则在后面.eq(自己的id)
queryWrapper.orderByDesc("id");//降序排序
List<Record> records = recordMapper.selectPage(recordIPage, queryWrapper).getRecords();
JSONObject resp = new JSONObject();
List<JSONObject> items = new LinkedList<>();
for (Record record : records) {
Users userA = usersMapper.selectById(record.getAId());
Users userB = usersMapper.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);
items.add(item);
}
resp.put("records", items);
resp.put("records_count", recordMapper.selectCount(null));
return resp;
}
}
Controller 层
backend\controller\record\GetRecordListController.java
import com.alibaba.fastjson2.JSONObject;
import com.gameforces.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("/api/record/getlist/")
public JSONObject getList(@RequestParam Map<String, String> data) {
Integer page = Integer.parseInt(data.get("page"));
return getRecordListService.getList(page);
}
}
补充:如果在
@Service
或@Controller
里面@Autowired
注入类就不需要像前面RestTemplate
一样定义成static
静态变量,再写个set
函数绑定注入。只有在其他第三方类(简单理解成不是@Controller
或@Service
的类)注入其他类(Bean
)的时候才需要像RestTemplate
那样做,注意要先在要注入Bean
的类中先@Component
,才能在类中注入Bean
这样后端就写完了
前端接收后端数据
前端实现相对比较简单,用$ajax
接收后端的数据后,用表格把对战双方的头像、用户名、对战结果、对战时间、录像回放选项用一个表格展示出来了
具体表格样式下面给出参考:
<table class="table table-striped table-hover">
<thead>
<tr class="table-dark">
<th>A</th>
<th>B</th>
<th>PK Result</th>
<th>PK Time</th>
<th>Operation</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">
<span class="record-user-username"> {{ record.a_username }} </span>
</td>
<td>
<img :src="record.b_photo" alt="" class="record-user-photo">
<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">Watch the Record </button>
</td>
</tr>
</tbody>
</table>
展示录像
首先我们要写一个record
的store
作为全局变量存储我们有关录像页面的信息,存储的信息包括:
- 是否为录像页面 is_record
- 玩家A的步骤:a_steps
- 玩家B的步骤:b_steps
其次,写一个录像展示页面RecordContent.vue
在RecordIndex.vue
界面点击展示录像
按钮就要弹出录像展示页面,因此要绑定事件,用@click="open_record_content(record.record.id)"
,record.record.id
是当前录像的id
。
在此函数,我们还要更新对局信息,可以用console.log(record)
看看我们传进来的record
数据格式是怎么样的,方便我们在后面写update
函数。
这里建议不要想当然地觉得后端自己写了什么格式的参数就一定是传你这名字的参数,可能传送过程中会出现把大写压成小写等各种奇奇怪怪的转化,保险一点,还是console
输出一下看看里面的参数是什么!
如上图所示,虽然我们在后端的pojo
层将Record.java
定义为
package com.gameforces.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
@AllArgsConstructor
@NoArgsConstructor
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;
}
这里bId,bSx,bSy,aSteps,bSteps
都是驼峰命名法,对应的数据库列名如下:
然而前端收到的相对应数据名全都变小写了QAQ.
一开始自以为和后端传来的变量名一致,没仔细想导致踩坑了,现在回来看感觉是GetRecordListServiceImpl.java
里面的selectPage
的问题,也就是Mybatis-plus
对数据库的默认操作。
仔细发现,我的createTime
变量名并没有改变,而其他包含下划线的变量名全都舍弃掉了下划线,并全部小写处理。因此我们可以合理推测就是Mybatis-plus
对数据库的操作搞的鬼。
其实问题不大,只是由强迫症想搞明白hh~~
注意:我们传入的map
是以一维数组的存储格式存储的,我们下面更新的时候需要把它转化为二维的格式,详情见下面的stringTo2D
函数!
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 (let record of records.value) {
if (record.record.id === recordId) {
store.commit("updateIsRecord",true); //标记成是录像页面
console.log(record);
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,
})
router.push({
name: "record_content",
params: {
recordId: recordId, //可以简写成一个recordId
}
});
break;
}
}
}
别忘了要添加路由
router\index.js
{
path: "/record/:recordId/", /*路由添加参数用:*/
name: "record_content",
component: RecordContent,
meta: {
requestAuth: true,
}
//requestAuth: true,
},
在GameMap.js
里的监听事件函数里稍微修改一下:
如果是放录像的话我们就放录像:根据玩家历史的操作步骤将步骤回放出来;
如果不是放录像的话,我们就接收用户的输入。
回放录像
因为我们的蛇是每秒中走5个格子,所以相当于200ms一格,我们放录像的时候可以设每300ms走一格,并且确定下一步的方向。
setInterval();
可以帮我们实现计时函数。
scripts\GameMap.js
...
add_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;
console.log(this.store.state.record);
const interval_id = setInterval(() => {
if (k >= a_steps.length - 1) { //最后一步是撞墙的一步,不需要复现,直接在后面把state设为dead即可
if (loser === "all" || loser === "A") {
snake0.status = "dead";
}
if (loser === "all" || loser === "B") {
snake1.status = "dead";
}
clearInterval(interval_id); //结束id为interval_id的setInterval()函数
} else {
snake0.set_direction(parseInt(a_steps[k]));
snake1.set_direction(parseInt(b_steps[k]));
}
k ++;
},300); //计时函数,每300ms做一次函数
} 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;//左
if (d >= 0) { //一个合法的操作
//前端向后端发消息: 前端 -> 后端
this.store.state.pk.socket.send(JSON.stringify({
event: "move",
direction: d,
}));
}
});
}
}
...
至此,我们就完成了录像回放功能了
分页功能
前端
加上Page条组件,我们设定一次展示出来的分页有五页
click_page
实现点击页面就跳转到目标页面,-2表示上一页,-1表示下一页
我们用循环展示数据库里的所有页面
为了方便实现高亮、循环等功能,pages作为一个队列存储的是对象,
包括页面id:number
、是否高亮:is_active
。
<nav aria-label="...">
<ul class="pagination" style="float:right">
<li class="page-item">
<a class="page-link" @click="click_page(-2)" href="#">Previous</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="#">Next</a>
</li>
</ul>
</nav>
实现按哪一页就返回哪一页的内容:
看看当前页面的前后两页是否存在
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;
}
至此,我们就完成了录像回放功能了(^-^)V
实现排行榜
与前面的操作流程类似,这里就不赘述啦!各位大佬一定都融会贯通了ヾ(◍°∇°◍)ノ゙
添加限制
由于bot列表设置分页比较麻烦,我们可以给用户添加限制,省去这些麻烦。。。
QueryWrapper<Bots> queryWrapper = new QueryWrapper<>();
queryWrapper.eq("user_id", user.getId());
if (botsMapper.selectCount(queryWrapper) > 10) {
map.put("message", "bot数量不能超过10个");
return map;
}
END
这样我们的项目就完成得差不多啦~~
能坚持到这里真的不容易呀QAQ
# 牛逼
QAQ
orz
orz
我是菜狗QAQ