文章目录
- 1. 项目设计
- 2. 效果图展示
- 3. 创建项目以及配置文件
- 4. 数据库设计与实现
- 5. 登录注册模块
- 6. 大厅界面
- 7. 房间界面
1. 项目设计
前端 : HTML + CSS + JavaScript + Jquery + AJAX
后端 : Spring MVC + Spring Boot + MyBatis
2. 效果图展示
3. 创建项目以及配置文件
3.1 创建项目
3.2 配置文件
3.2.1 在 application.properties 中添加配置文件
spring.datasource.url=jdbc:mysql://localhost:3306/onlineGobang?characterEncoding=utf8&useSSL=true
spring.datasource.username=root
spring.datasource.password=0000
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
mybatis.mapper-locations=classpath:mapper/**Mapper.xml
3.2.2 在 resources 目录下创建mapper
mapper下添加 目录 **.xml 并添加代码
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.onlinemusicserver.mapper."对应的Mapper"">
</mapper>
4. 数据库设计与实现
这里使用数据库存储每一个用户的信息, 初始的时候, 天梯分和场次都是默认的.
create database if not exists onlineGobang;
use onlineGobang;
drop table if exists user;
create table user(
userId int primary key auto_increment,
username varchar(20) unique,
password varchar(255) not null,
score int,
totalCount int,
winCount int
);
5. 登录注册模块
5.1 设计登录注册交互接口
请求
POST /user/login HTTP/1.1
{username: "",password: ""}
响应
{
status: 1/-1,
message: "",
data: ""
}
请求
GET /user/logout HTTP/1.1
响应
HTTP/1.1 200
请求
POST /user/register HTTP/1.1
{username: "",password: ""}
响应
{
status: 1/-1,
message: "",
data: ""
}
5.2 设置登录注册功能返回的响应类
@Data
public class ResponseBodyMessage<T> {
private int status;
private String message;
private T data;
public ResponseBodyMessage(int status, String message, T data) {
this.status = status;
this.message = message;
this.data = data;
}
}
5.3 使用 BCrypt 对密码进行加密
<!-- security依赖包 (加密)-->
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-config</artifactId>
</dependency>
@SpringBootApplication(exclude = {org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration.class})
@Configuration
public class AppConfig implements WebMvcConfigurer {
@Bean
public BCryptPasswordEncoder getBCryptPasswordEncoder() {
return new BCryptPasswordEncoder();
}
}
5.4 完成 MyBatis 操作
@Data
public class User {
private int userId;
private String username;
private String password;
private int score;
private int totalCount;
private int winCount;
}
@Mapper
public interface UserMapper {
// 注册一个用户, 初始的天梯积分默认为1000, 场次默认为0
int insert(User user);
// 通过username查询当前用户是否存在
User selectByName(String username);
}
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.gobang.mapper.UserMapper">
<insert id="insert">
insert into user values(null,#{username},#{password},1000,0,0)
</insert>
<select id="selectByName" resultType="com.example.gobang.model.User">
select * from user where username = #{username}
</select>
</mapper>
@Service
public class UserService {
@Autowired
private UserMapper userMapper;
public int insert(User user){
return userMapper.insert(user);
}
public User selectByName(String username){
return userMapper.selectByName(username);
}
}
5.5 后端的实现
5.5.1 登录功能后端实现
@RequestMapping("/login")
public ResponseBodyMessage<User> login(@RequestBody User user, HttpServletRequest request) {
User truUser = userService.selectByName(user.getUsername());
if (truUser == null) {
System.out.println("登录失败!");
return new ResponseBodyMessage<>(-1,"用户名密码错误!",user);
}else {
boolean flg = bCryptPasswordEncoder.matches(user.getPassword(),truUser.getPassword());
if (!flg) {
return new ResponseBodyMessage<>(-1,"用户名密码错误!",user);
}
System.out.println("登录成功!");
HttpSession session = request.getSession(true);
session.setAttribute(Constant.USER_SESSION_KEY,truUser);
return new ResponseBodyMessage<>(1,"登录成功!",truUser);
}
}
5.5.2 注册功能后端实现
@RequestMapping("/register")
public ResponseBodyMessage<User> register(@RequestBody User user) {
User truUser = userService.selectByName(user.getUsername());
if (truUser != null) {
return new ResponseBodyMessage<>(-1,"当前用户名已经存在!",user);
} else{
String password = bCryptPasswordEncoder.encode(user.getPassword());
user.setPassword(password);
userService.insert(user);
return new ResponseBodyMessage<>(1,"注册成功!",user);
}
}
5.5.3 注销功能
@RequestMapping("/logout")
public void userLogout(HttpServletRequest request, HttpServletResponse response) throws IOException, IOException {
HttpSession session = request.getSession(false);
// 拦截器的拦截, 所以不可能出现session为空的情况
session.removeAttribute(Constant.USER_SESSION_KEY);
response.sendRedirect("login.html");
}
注意: 这里的Constant.USER_SESSION_KEY
是存储的 session 字符串, 由于该 字符串是不变的, 所以存入 Constant 类中.
5.6 前端的实现
5.6.1 登录前端实现
let loginButton = document.querySelector('#loginButton');
loginButton.onclick = function() {
let username = document.querySelector('#loginUsername');
let password = document.querySelector('#loginPassword');
if (username.value.trim() == ""){
alert('请先输入用户名!');
username.focus();
return;
}
if (password.value.trim() == ""){
alert('请先输入密码!');
password.focus();
return;
}
$.ajax({
url: "user/login",
method: "POST",
data: JSON.stringify({username: username.value.trim(), password: password.value.trim()}),
contentType: "application/json;charset=utf-8",
success: function(data, status) {
if(data.status == 1) {
location.assign("index.html");
}else{
alert(data.message);
username.value="";
password.value="";
username.focus();
}
}
})
}
5.6.2 注册前端实现
let Reg = document.querySelector('#Reg');
Reg.onclick = function() {
let username = document.querySelector('#RegUsername');
let password1 = document.querySelector('#RegPassword1');
let password2 = document.querySelector('#RegPassword2');
if(!$('#checkbox').is(':checked')) {
alert("请勾选条款");
return;
}
if(username.value.trim() == ""){
alert("请先输入用户名!");
username.focus();
return;
}
if(password1.value.trim() == ""){
alert('请先输入密码!');
password1.focus();
return;
}
if(password2.value.trim() == ""){
alert('请再次输入密码!');
password2.focus();
return;
}
if(username.value.trim().length > 20) {
alert("用户名长度过长");
username.value="";
username.focus();
return;
}
if(password1.value.trim() != password2.value.trim()) {
alert('两次输入的密码不同!');
passwrod1.value="";
password2.value="";
return;
}
if(password1.value.trim().length > 255) {
alert("当前密码长度过长!");
password1.value="";
password2.value="";
password1.focus();
return;
}
$.ajax({
url: "user/register",
method: "POST",
data: JSON.stringify({username: username.value.trim(), password: password1.value.trim()}),
contentType: "application/json;charset=utf-8",
success: function(data,status){
if(data.status == 1) {
alert(data.message);
location.assign("login.html");
}else{
alert(data.message);
username.value="";
password1.value="";
password2.value="";
username.focus();
}
}
})
}
5.7 添加拦截器
public class LoginInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
HttpSession session = request.getSession(false);
if (session != null && session.getAttribute(Constant.USER_SESSION_KEY) != null){
return true;
}
response.sendRedirect("/login.html");
return false;
}
}
@Configuration
@EnableWebSocket
public class AppConfig implements WebSocketConfigurer,WebMvcConfigurer{
@Override
public void addInterceptors(InterceptorRegistry registry) {
LoginInterceptor loginInterceptor = new LoginInterceptor();
registry.addInterceptor(loginInterceptor)
.addPathPatterns("/**")
.excludePathPatterns("/**/login.html")
.excludePathPatterns("/**/css/**.css")
.excludePathPatterns("/**/images/**")
.excludePathPatterns("/**/fonts/**")
.excludePathPatterns("/**/js/**.js")
.excludePathPatterns("/**/scss/**")
.excludePathPatterns("/**/user/login")
.excludePathPatterns("/**/user/register")
.excludePathPatterns("/**/user/logout");
}
}
6. 大厅界面
6.1 交互接口设计
{
message: ' startMatch ' / ' stopMatch'
}
{
status: '1' / '-1'
message: ' startMatch ' / ' stopMatch '
}
{
status: '1' / '-1'
message: 'matchSuccess'
}
6.2 用户加载前后交互接口
请求
GET /user/userInfo HTTP/1.1
响应
{
status: 1/-1 (1 为成功, -1 为失败),
message: "对应信息",
data: "内容", (用户信息)
}
6.3 前端和后端实现用户信息加载
6.3.1 后端的实现
@RequestMapping("/userInfo")
public ResponseBodyMessage<User> getUserInfo(HttpServletRequest request) {
HttpSession session = request.getSession(false);
User user = (User) session.getAttribute(Constant.USER_SESSION_KEY);
if (user == null) {
return new ResponseBodyMessage<>(-1,"当前用户不存在",null);
}else{
return new ResponseBodyMessage<>(1,"查找成功!", newUser);
}
}
6.3.2 前端的实现
load();
function load() {
$.ajax({
url: "user/userInfo",
method: "GET",
success:function(data) {
if(data.status == 1) {
let h2 = document.querySelector('#myname');
h2.innerHTML = "你好! " + data.data.username;
let game = document.querySelector('#gameMes');
game.innerHTML = "天梯分数: " + data.data.score + " | " + "场数: " + data.data.totalCount + " | " + "获胜场数: "+ data.data.winCount;
}else{
alert(data.message);
location.assign("login.html");
}
}
})
}
6.4 实现匹配功能的前端代码
let websocketUrl = 'ws://'+ location.host +'/findMatch';
let websocket = new WebSocket(websocketUrl);
// 连接成功的时候调用的方法
websocket.onopen = function() {
console.log("onopen");
}
// 连接关闭的时候调用的方法
websocket.onclose = function() {
console.log("onclose");
}
// 连接异常的时候调用的方法
websocket.onerror = function() {
console.log("onerrot");
}
// 监听整个窗口关闭的事件, 当窗口关闭, 主动的去关闭websocket连接
window.onbeforeunload = function() {
websocket.close();
}
// 连接成功收到的响应
websocket.onmessage = function(e) {
// 先将Json格式 e 化为 响应对象
let resp = JSON.parse(e.data);
// 获取到 匹配按钮
let play = document.querySelector('#beginPlay');
// 等于-1是错误的起来, 打印错误的信息, 并跳转到登录页面
if (resp.status == -1) {
alert(resp.message);
location.assign("login.html");
return;
}
// 这里就都是正常的响应, 那么就判断是开始匹配, 还是结束匹配
if (resp.message == 'startMatch') {
//开始匹配
console.log("开始匹配");
play.innerHTML = '匹配中...(点击停止)';
}else if(resp.message == 'stopMatch') {
//结束匹配
console.log("结束匹配");
play.innerHTML = '开始匹配';
}else if(resp.message == 'matchSuccess') {
//匹配成功
console.log("匹配成功");
location.assign('room.html');
}else{
// 按理不会触发这个else
alert(resp.message);
console.log("收到非法响应");
}
}
// 获取到匹配按钮
let play = document.querySelector('#beginPlay');
// 匹配按钮点击事件
play.onclick = function() {
// 判断当前 readyState 是否是OPEN状态的
if (websocket.readyState == websocket.OPEN) {
// 当前 readyState 处于OPEN 状态, 说明链接是好的
if (play.innerHTML == '开始匹配') {
// 发送开始匹配的请求
websocket.send(JSON.stringify(
{
message: 'startMatch',
}
))
}else if(play.innerHTML == '匹配中...(点击停止)'){
// 发送停止匹配的请求
websocket.send(JSON.stringify(
{
message: 'stopMatch',
}
))
}
}else{
// 这里就是链接异常的情况
alert('当前您的链接已经断开, 请重新登录');
location.assign("login.html");
}
}
6.5 实现匹配功能的后端代码
@Configuration
@EnableWebSocket
public class AppConfig implements WebSocketConfigurer{
@Autowired
private MatchController matchController;
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry.addHandler(matchController,"/findMatch")
.addInterceptors(new HttpSessionHandshakeInterceptor());
}
}
6.5.1 创建在线状态
@Component
public class OnlineUserManager {
// 这个哈希表是表示当前用户在游戏大厅的在线状态
private ConcurrentHashMap<Integer, WebSocketSession> gameState = new ConcurrentHashMap<>();
public void enterGameIndex(int userId, WebSocketSession webSocketSession) {
gameState.put(userId,webSocketSession);
}
public void exitGameHall(int userId) {
gameState.remove(userId);
}
public WebSocketSession getState(int userId) {
return gameState.get(userId);
}
}
6.5.2 创建房间对象
// 游戏房间
@Data
public class Room {
private String roomId;
private User user1;
private User user2;
public Room() {
this.roomId = UUID.randomUUID().toString();
}
}
6.5.3 创建房间管理器
// 房间管理器
@Component
public class RoomManager {
private ConcurrentHashMap<String,Room> rooms = new ConcurrentHashMap<>();
public void insert(Room room) {
rooms.put(room.getRoomId(),room);
}
public void remove(String roomId) {
rooms.remove(roomId);
}
public Room findRoomByRoomId(String roomId) {
return rooms.get(roomId);
}
6.5.4 创建匹配队列
// 创建匹配队列 按等级划分
// 1. < 2000
private Queue<User> simpleQueue = new LinkedList<>();
// 2. >= 2000 && < 3000
private Queue<User> normalQueue = new LinkedList<>();
// 3. >= 3000
private Queue<User> highQueue = new LinkedList<>();
// 匹配器, 这个类是用来完成匹配功能的
@Component
public class Matcher {
// 创建匹配队列 按等级划分
// 1. < 2000
private Queue<User> simpleQueue = new LinkedList<>();
// 2. >= 2000 && < 3000
private Queue<User> normalQueue = new LinkedList<>();
// 3. >= 3000
private Queue<User> highQueue = new LinkedList<>();
@Autowired
private OnlineUserManager onlineUserManager;
@Autowired
private ObjectMapper objectMapper;
@Autowired
private RoomManager roomManager;
/**
* 将当前玩家添加到匹配队列中
* @param user
*/
public void insert(User user) {
// 按等级加入队列中
if (user.getScore() < 2000) {
synchronized (simpleQueue) {
simpleQueue.offer(user);
// 只要有用户进入了, 就进行唤醒
simpleQueue.notify();
}
}else if (user.getScore() >= 2000 && user.getScore() < 3000) {
synchronized (normalQueue) {
normalQueue.offer(user);
normalQueue.notify();
}
}else {
synchronized (highQueue) {
highQueue.offer(user);
highQueue.notify();
}
}
}
/**
* 将当前玩家匹配队列中删除
* @param user
*/
public void remove(User user) {
// 按照当前等级去对应匹配队列中删除
if (user.getScore() < 2000) {
synchronized (simpleQueue){
simpleQueue.remove(user);
}
}else if (user.getScore() >= 2000 && user.getScore() < 3000) {
synchronized (normalQueue) {
normalQueue.remove(user);
}
}else {
synchronized (highQueue) {
highQueue.remove(user);
}
}
}
/**
* 这里使用3个线程去一直的进行查看是否有2个以上的人, 如果有进行匹配
*/
public Matcher() {
// 创建三个线程, 操作三个匹配队列
Thread t1 = new Thread() {
@Override
public void run() {
while (true) {
handlerMatch(simpleQueue);
}
}
};
t1.start();
Thread t2 = new Thread() {
@Override
public void run() {
while (true) {
handlerMatch(normalQueue);
}
}
};
t2.start();
Thread t3 = new Thread() {
@Override
public void run() {
while (true) {
handlerMatch(highQueue);
}
}
};
t3.start();
}
private void handlerMatch(Queue<User> matchQueue) {
synchronized (matchQueue) {
try{
// 1. 先查看当前队列中的元素个数, 是否满足两个
// 这里使用while, 以防为0的时候, 被唤醒,然后没有再次判断导致进入下面操作.
while (matchQueue.size() < 2) {
// 用户小于2个的时候, 就进行等待, 以免浪费资源
matchQueue.wait();
}
// 2. 尝试从队列中取出两个玩家
User player1 = matchQueue.poll();
User player2 = matchQueue.poll();
// 打印日志
System.out.println("匹配到的两个玩家: " + player1.getUsername()+ " , " + player2.getUsername());
// 3. 获取到玩家的 websocket 的会话.
WebSocketSession session1 = onlineUserManager.getState(player1.getUserId());
WebSocketSession session2 = onlineUserManager.getState(player2.getUserId());
// 再次判断是否为空
if (session1 == null && session2 != null) {
matchQueue.offer(player2);
return;
}
if (session1 != null && session2 == null) {
matchQueue.offer(player1);
return;
}
if (session1 == null && session2 == null) {
return;
}
if (session1 == session2) {
matchQueue.offer(player1);
return;
}
// 4. 把两个玩家放入一个游戏房间中
Room room = new Room();
roomManager.insert(room,player1.getUserId(),player2.getUserId());
// 5. 给玩家反馈信息, 通知匹配到了对手
MatchResponse response1 = new MatchResponse();
response1.setStatus(1);
response1.setMessage("matchSuccess");
String json1 = objectMapper.writeValueAsString(response1);
session1.sendMessage(new TextMessage(json1));
MatchResponse response2 = new MatchResponse();
response2.setMessage("matchSuccess");
response2.setStatus(1);
String json2 = objectMapper.writeValueAsString(response2);
session2.sendMessage(new TextMessage(json2));
} catch (IOException | InterruptedException e) {
e.printStackTrace();
}
}
}
}
6.5.5 写完 MatchController
@Component
public class MatchController extends TextWebSocketHandler {
private ObjectMapper objectMapper = new ObjectMapper();
@Autowired
private OnlineUserManager onlineUserManager;
@Autowired
private Matcher matcher;
// 连接成功的时候就会调用该方法
@Override
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
// 玩家上线
// 1. 获取用户信息
User user = (User) session.getAttributes().get(Constant.USER_SESSION_KEY);
// 2. 判断当前用户是否已经登录
if (onlineUserManager.getState(user.getUserId()) != null ) {
// 当前用户已经登录
MatchResponse message = new MatchResponse();
message.setMessage("当前用户已经登录!");
message.setStatus(-1);
session.sendMessage(new TextMessage(objectMapper.writeValueAsBytes(message)));
session.close();
return;
}
// 3. 设置在线状态
onlineUserManager.enterGameIndex(user.getUserId(),session);
}
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
// 处理开始匹配 和 停止匹配
User user = (User) session.getAttributes().get(Constant.USER_SESSION_KEY);
String payload = message.getPayload();
MatchRequest matchRequest = objectMapper.readValue(payload, MatchRequest.class);
MatchResponse matchResponse = new MatchResponse();
if (matchRequest.getMessage().equals("startMatch")) {
// 进入匹配队列
// 创建匹配队列, 加入用户
matcher.insert(user);
// 返回响应给前端
matchResponse.setStatus(1);
matchResponse.setMessage("startMatch");
}else if(matchRequest.getMessage().equals("stopMatch")) {
// 退出匹配队列
// 创建匹配队列, 将用户移除
matcher.remove(user);
matchResponse.setMessage("stopMatch");
matchResponse.setStatus(1);
}else{
matchResponse.setStatus(-1);
matchRequest.setMessage("非法匹配");
// 非法情况
}
session.sendMessage(new TextMessage(objectMapper.writeValueAsBytes(matchResponse)));
}
// 异常情况
@Override
public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {
// 玩家下线
// 1. 获取用户信息
User user = (User) session.getAttributes().get(Constant.USER_SESSION_KEY);
WebSocketSession webSocketSession = onlineUserManager.getState(user.getUserId());
if(webSocketSession == session) {
// 2. 设置在线状态
onlineUserManager.exitGameHall(user.getUserId());
}
matcher.remove(user);
}
// 关闭情况
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
// 玩家下线
// 1. 获取用户信息
User user = (User) session.getAttributes().get(Constant.USER_SESSION_KEY);
WebSocketSession webSocketSession = onlineUserManager.getState(user.getUserId());
if(webSocketSession == session) {
// 2. 设置在线状态
onlineUserManager.exitGameHall(user.getUserId());
}
matcher.remove(user);
}
}
6.6 大厅界面总结
- 这里要注意多线程环境下, 多个用户同时使用同一个哈希表的时候, 进行添加和删除的时候, 会有线程安全的问题, 那么这里就需要使用 ConcurrentHashMap
- 在多线程环境下, 按照等级分的队列, 在多线程环境下, 并发的进行入队的时候, 删除的队列中用户的时候, 也会有线程安全问题, 这里针对同一个队列就可以进行加锁.
- 由于创建3个线程循环的进入队列中查看是否满足2个用户, 如果当前的环境下, 用户特别少, 一直去循环的进入, 会造成CPU占用率特别高, 所以这里就使用wai()等待, 在有用户进入匹配队列的时候,再去唤醒notify().
- 防止多开, 多个地方登录同一个账号就会出现很多问题, 这里在进行连接的时候判断, 如果用户已经在线, 就不让该地方用户登录.
- 要想让 房间是第一无二, 就需要使用 UUID, 那么roomId也要使用 字符串的格式.
7. 房间界面
7.1 交互接口设计
ws://127.0.0.1:8080/game
{
message: 'gameReady'
status: '1 / -1' (1是正常响应, -1 是错误响应)
roomId: ' '
thisUserId: ' ' (自己用户Id)
thatUserId: ' ' (对方用户Id)
whiteUser: ' ' (先手方)
}
{
message: ' putChess '
userId: ' ' (落子的用户Id)
row: ' ' (落子的第几行)
col: ' ' (落子的第几列)
}
{
message: 'putChess;
userId: ' '
row: ' '
col: ' '
winner: ' ' (获胜者, 和用户Id一致, 如果没有获胜, 就是0)
}
7.2 实现房间界面前端代码
7.2.1 设置棋盘界面, 以及显示框.
room.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>游戏房间</title>
<link href="css/game_room.css" rel="stylesheet" type="text/css" media="all" />
</head>
<body>
<div class="container">
<div class="one">
<!-- 棋盘区域, 需要基于 canvas 进行实现 -->
<canvas id="chess" width="450px" height="450px">
</canvas>
<!-- 显示区域 -->
<div id="screen"> 等待玩家连接中... </div>
</div>
</div>
<script src="js/script.js"></script>
</body>
</html>
game_room.css
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body {
height: 100%;
background-image: url(../images/bg.jpg);
background-repeat: no-repeat;
background-position: center;
background-size: cover;
}
.container {
height: 100%;
display: flex;
justify-content: center;
align-items: center;
}
#screen {
width: 450px;
height: 50px;
margin-top: 10px;
background-color: #fff;
font-size: 22px;
line-height: 50px;
text-align: center;
}
.backButton {
width: 450px;
height: 50px;
font-size: 20px;
color: white;
background-color: orange;
border: none;
outline: none;
border-radius: 10px;
text-align: center;
line-height: 50px;
margin-top: 20px;
}
.backButton:active {
background-color: gray;
}
7.2.2 对应的js文件
let gameInfo = {
roomId: null,
thisUserId: null,
thatUserId: null,
isWhite: true,
}
//
// 设定界面显示相关操作
//
function setScreenText(me) {
let screen = document.querySelector('#screen');
if (me) {
screen.innerHTML = "轮到你落子了!";
} else {
screen.innerHTML = "轮到对方落子了!";
}
}
//
// 初始化 websocket
//
// TODO
//
// 初始化一局游戏
//
function initGame() {
// 是我下还是对方下. 根据服务器分配的先后手情况决定
let me = gameInfo.isWhite;
// 游戏是否结束
let over = false;
let chessBoard = [];
//初始化chessBord数组(表示棋盘的数组)
for (let i = 0; i < 15; i++) {
chessBoard[i] = [];
for (let j = 0; j < 15; j++) {
chessBoard[i][j] = 0;
}
}
let chess = document.querySelector('#chess');
let context = chess.getContext('2d');
context.strokeStyle = "#BFBFBF";
// 背景图片
let logo = new Image();
logo.src = "image/sky.jpeg";
logo.onload = function () {
context.drawImage(logo, 0, 0, 450, 450);
initChessBoard();
}
// 绘制棋盘网格
function initChessBoard() {
for (let i = 0; i < 15; i++) {
context.moveTo(15 + i * 30, 15);
context.lineTo(15 + i * 30, 430);
context.stroke();
context.moveTo(15, 15 + i * 30);
context.lineTo(435, 15 + i * 30);
context.stroke();
}
}
// 绘制一个棋子, me 为 true
function oneStep(i, j, isWhite) {
context.beginPath();
context.arc(15 + i * 30, 15 + j * 30, 13, 0, 2 * Math.PI);
context.closePath();
var gradient = context.createRadialGradient(15 + i * 30 + 2, 15 + j * 30 - 2, 13, 15 + i * 30 + 2, 15 + j * 30 - 2, 0);
if (!isWhite) {
gradient.addColorStop(0, "#0A0A0A");
gradient.addColorStop(1, "#636766");
} else {
gradient.addColorStop(0, "#D1D1D1");
gradient.addColorStop(1, "#F9F9F9");
}
context.fillStyle = gradient;
context.fill();
}
chess.onclick = function (e) {
if (over) {
return;
}
if (!me) {
return;
}
let x = e.offsetX;
let y = e.offsetY;
// 注意, 横坐标是列, 纵坐标是行
let col = Math.floor(x / 30);
let row = Math.floor(y / 30);
if (chessBoard[row][col] == 0) {
// TODO 发送坐标给服务器, 服务器要返回结果
oneStep(col, row, gameInfo.isWhite);
chessBoard[row][col] = 1;
}
}
// TODO 实现发送落子请求逻辑, 和处理落子响应逻辑.
}
initGame();
7.2.3 初始化 websocket
let websocketUrl = 'ws://'+ location.host +'/game';
let websocket = new WebSocket(websocketUrl);
websocket.onopen = function() {
console.log("房间链接成功!");
}
websocket.onclose = function() {
console.log("房间断开链接");
}
websocket.onerror = function() {
console.log("房间出现异常");
}
window.onbeforeunload = function() {
websocket.close();
}
websocket.onmessage = function(e) {
console.log(e.data);
let resp = JSON.parse(e.data);
if(resp.message != 'gameReady') {
console.log("响应类型错误");
location.assign("index.html");
return;
}
if(resp.status == -1) {
alert("游戏链接失败!");
location.assign("index.html");
return;
}
gameInfo.roomId == resp.roomId;
gameInfo.thisUserId = resp.thisUserId;
gameInfo.thatUserId = resp.thatUserId;
gameInfo.isWhite = resp.whiteUser == resp.thisUserId;
// 初始化棋盘
initGame();
// 设置显示内容
setScreenText(gameInfo.isWhite);
}
7.2.4 落子时,发送落子请求
function send(row,col) {
let req = {
message: 'putChess',
userId: gameInfo.thisUserId,
row: row,
col: col
};
websocket.send(JSON.stringify(req));
}
7.2.5 落子时, 发送落子响应
websocket.onmessage = function(e) {
console.log(e.data);
let resp = JSON.parse(e.data);
if (resp.message != 'putChess') {
console.log("响应类型错误!");
location.assign("index.html")
return;
}
if (resp.userId == gameInfo.thisUserId) {
// 自己落子
oneStep(resp.col, resp.row, gameInfo.isWhite);
chessBoard[resp.row][resp.col] = 1;
} else if (resp.userId == gameInfo.thatUserId) {
// 别人落子
oneStep(resp.col, resp.row, !gameInfo.isWhite);
chessBoard[resp.row][resp.col] = 1;
}else{
// 落子异常
console.log("userId 异常");
return;
}
// 交换落子
me = !me;
setScreenText(me);
// 判断游戏是否结束
let screenDiv = document.querySelector('#screen');
if (resp.winner != 0) {
console.log(resp.winner+" " + gameInfo.thisUserId+" " + gameInfo.thatUserId);
if (resp.winner == gameInfo.thisUserId) {
screenDiv.innerHTML = "恭喜你, 获胜了!";
}else if(resp.winner == gameInfo.thatUserId) {
screenDiv.innerHTML = "游戏结束, 失败了!";
}else {
console.log("winner 错误");
alert("当前 winner字段错误 winner = "+ resp.winner);
}
// location.assign('index.html');
// 增加一个按钮, 返回游戏大厅
let backBtn = document.createElement('button');
backBtn.innerHTML = "返回游戏大厅";
backBtn.className = "backButton";
let one = document.querySelector('.one');
backBtn.onclick = function() {
location.assign("index.html");
}
one.appendChild(backBtn);
}
7.3 实现房间界面后端代码
7.3.1 注册GameController
@Configuration
@EnableWebSocket
public class AppConfig implements WebSocketConfigurer,WebMvcConfigurer{
@Autowired
private GameController gameController;
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry.addHandler(gameController,"/game")
.addInterceptors(new HttpSessionHandshakeInterceptor());
}
7.3.2 创建GameController
afterConnectionEstablished
这个方法是在建立连接时候的方法.handleTextMessage
这个方法是接收发送的响应handleTransportError
这个方法是出现异常的时候执行的afterConnectionClosed
这个方法是关闭websocket的时候执行的
@Component
public class GameController extends TextWebSocketHandler {
@Override
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
}
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
}
@Override
public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {
}
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
}
}
7.3.3 创建对应的响应类和请求类
// 客户端链接成功后, 返回的响应
@Data
public class GameReadyResponse {
private String message;
private int status;
private String roomId;
private int thisUserId;
private int thatUserId;
private int whiteUser;
}
// 落子的请求
@Data
public class GameRequest {
private String message;
private int userId;
private int row;
private int col;
}
//落子响应
@Data
public class GameResponse {
private String message;
private int userId;
private int row;
private int col;
private int winner;
}
7.3.4 完成用户房间在线状态管理
// 这个哈希表是表示当前用户在游戏房间的在线状态
private ConcurrentHashMap<Integer, WebSocketSession> roomState = new ConcurrentHashMap<>();
public void enterGameRoom(int userId, WebSocketSession webSocketSession){
roomState.put(userId,webSocketSession);
}
public void exitGameRoom(int userId) {
roomState.remove(userId);
}
public WebSocketSession getRoomState(int userId) {
return roomState.get(userId);
}
7.3.5 添加 MyBatis 用来更新玩家积分
UserMapper
// 总场数 + 1, 获胜场数+1, 天梯分数 + 50
void userWin(int userId);
// 总场数 + 1, 天梯分数 -50
void userLose(int userId);
UserMapper.xml
<update id="userWin">
update user set totalCount = totalCount+1 , winCount = winCount+1, score = score + 50 where userId = #{userId}
</update>
<update id="userLose">
update user set totalCount = totalCount+1, score = score - 50 where userId = #{userId}
</update>
UserService
// 总场数 + 1, 获胜场数+1, 天梯分数 + 50
public void userWin(int userId){
userMapper.userWin(userId);
}
// 总场数 + 1, 天梯分数 -50
public void userLose(int userId) {
userMapper.userLose(userId);
}
7.3.6 完成处理连接方法
@Override
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
GameReadyResponse readyResponse = new GameReadyResponse();
// 获取用户信息
User user = (User) session.getAttributes().get(Constant.USER_SESSION_KEY);
// 判断当前是否已经进入房间
Room room = roomManager.findRoomByUserId(user.getUserId());
if (room == null) {
readyResponse.setStatus(-1);
readyResponse.setMessage("用户尚未匹配到!");
session.sendMessage(new TextMessage(objectMapper.writeValueAsBytes(readyResponse)));
return;
}
// 判断当前是否多开
if (onlineUserManager.getRoomState(user.getUserId()) != null || onlineUserManager.getState(user.getUserId()) != null) {
readyResponse.setMessage("当前用户已经登录!");
readyResponse.setStatus(-1);
session.sendMessage(new TextMessage(objectMapper.writeValueAsBytes(readyResponse)));
return;
}
// 上线
onlineUserManager.enterGameRoom(user.getUserId(), session);
synchronized (room) {
if (room.getUser1() == null) {
room.setUser1(user);
System.out.println("玩家1 " + user.getUsername() + " 已经准备好了");
return;
}
if (room.getUser2() == null) {
room.setUser2(user);
System.out.println("玩家2 " + user.getUsername() + " 已经准备好了");
Random random = new Random();
int num = random.nextInt(10);
if (num % 2 == 0) {
room.setWhiteUser(room.getUser1().getUserId());
} else{
room.setWhiteUser(room.getUser2().getUserId());
}
// 通知玩家1
noticeGameReady(room,room.getUser1(),room.getUser2());
// 通知玩家2
noticeGameReady(room,room.getUser2(),room.getUser1());
return;
}
}
readyResponse.setStatus(-1);
readyResponse.setMessage("房间已经满了");
session.sendMessage(new TextMessage(objectMapper.writeValueAsBytes(readyResponse)));
}
private void noticeGameReady(Room room, User user1, User user2) throws IOException {
GameReadyResponse resp = new GameReadyResponse();
resp.setStatus(1);
resp.setMessage("gameReady");
resp.setRoomId(room.getRoomId());
resp.setThisUserId(user1.getUserId());
resp.setThatUserId(user2.getUserId());
resp.setWhiteUser(room.getWhiteUser());
WebSocketSession webSocketSession = onlineUserManager.getRoomState(user1.getUserId());
webSocketSession.sendMessage(new TextMessage(objectMapper.writeValueAsString(resp)));
}
7.3.7 完成处理连接断开的方法和连接异常的方法
@Override
public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {
// 异常下线
// 下线
User user = (User) session.getAttributes().get(Constant.USER_SESSION_KEY);
WebSocketSession exitSession = onlineUserManager.getRoomState(user.getUserId());
if(exitSession == session) {
onlineUserManager.exitGameRoom(user.getUserId());
}
System.out.println("当前用户: " + user.getUsername()+" 异常下线了");
noticeThatUserWin(user);
}
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
// 下线
User user = (User) session.getAttributes().get(Constant.USER_SESSION_KEY);
WebSocketSession exitSession = onlineUserManager.getRoomState(user.getUserId());
if(exitSession == session) {
onlineUserManager.exitGameRoom(user.getUserId());
}
System.out.println("当前用户: " + user.getUsername()+" 离开房间");
noticeThatUserWin(user);
}
private void noticeThatUserWin(User user) throws IOException {
Room room = roomManager.findRoomByUserId(user.getUserId());
if (room == null) {
System.out.println("房间已经关闭");
return;
}
// 找到对手
User thatUser = (user == room.getUser1()) ? room.getUser2() : room.getUser1();
// 找到对手的状态
WebSocketSession session = onlineUserManager.getRoomState(thatUser.getUserId());
if (session == null) {
// 都掉线了
System.out.println("都掉线了, 无需通知");
return;
}
// 这里通知对手获胜
GameResponse gameResponse = new GameResponse();
gameResponse.setMessage("putChess");
gameResponse.setUserId(thatUser.getUserId());
gameResponse.setWinner(thatUser.getUserId());
session.sendMessage(new TextMessage(objectMapper.writeValueAsBytes(gameResponse)));
// 更新玩家分数信息
int winId = thatUser.getUserId();
int loseId = user.getUserId();
userService.userWin(winId);
userService.userLose(loseId);
// 释放房间对象
roomManager.remove(room.getRoomId(),room.getUser1().getUserId(),room.getUser2().getUserId());
}
7.3.8 在房间管理器中添加代码
private ConcurrentHashMap<Integer,String> Ids = new ConcurrentHashMap<>();
public void insert(Room room,int userId1, int userId2) {
Ids.put(userId1,room.getRoomId());
Ids.put(userId2,room.getRoomId());
}
public void remove(String roomId,int userId1, int userId2) {
Ids.remove(userId1);
Ids.remove(userId2);
}
public Room findRoomByUserId(int userId) {
String roomId = Ids.get(userId);
if (roomId == null) {
return null;
}
return rooms.get(roomId);
}
7.3.9 Room类添加棋盘代码
修改启动类
public class GobangApplication {
public static ConfigurableApplicationContext context;
public static void main(String[] args) {
context = SpringApplication.run(GobangApplication.class, args);
}
}
// 游戏房间
@Data
public class Room {
private String roomId;
private User user1;
private User user2;
private int whiteUser;
private OnlineUserManager onlineUserManager;
private RoomManager roomManager;
private UserService userService;
public Room() {
this.roomId = UUID.randomUUID().toString();
onlineUserManager = GobangApplication.context.getBean(OnlineUserManager.class);
roomManager = GobangApplication.context.getBean(RoomManager.class);
userService = GobangApplication.context.getBean(UserService.class);
}
// 为0就是为落子, 为1就是用户1落子, 为2就是用户2落子
private int[][] board= new int[Constant.ROW][Constant.COL];
private ObjectMapper objectMapper = new ObjectMapper();
}
7.3.10 实现handleTextMessage方法
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
// 获取用户对象
User user = (User) session.getAttributes().get(Constant.USER_SESSION_KEY);
// 根据 玩家 Id 获取房间对象
Room room = roomManager.findRoomByUserId(user.getUserId());
// 通过room对象处理这次请求
room.putChess(message.getPayload());
}
7.3.11 实现putChess方法
// 这个方法是用来处理一次落子的操作
public void putChess(String reqJson) throws IOException {
// 1. 记录当前落子的位子
GameRequest request = objectMapper.readValue(reqJson,GameRequest.class);
GameResponse response = new GameResponse();
// 1.1 判断当前落子是谁
int chess = request.getUserId() == user1.getUserId() ? 1 : 2;
int row = request.getRow();
int col = request.getCol();
if (board[row][col] != 0) {
System.out.println("当前位置: ("+row+" ," + col+" )" +"已经有子了");
return;
}
board[row][col] = chess;
// 2. 进行胜负判定
int winner = checkWinner(row,col,chess);
// 3. 给房间中所有的客户端返回响应
response.setMessage("putChess");
response.setRow(row);
response.setCol(col);
response.setWinner(winner);
response.setUserId(request.getUserId());
WebSocketSession session1 = onlineUserManager.getRoomState(user1.getUserId());
WebSocketSession session2 = onlineUserManager.getRoomState(user2.getUserId());
// 这里对下线进行判断
if (session1 == null) {
// 玩家1下线
response.setWinner(user2.getUserId());
System.out.println("玩家1掉线");
}
if (session2 == null) {
// 玩家2下线, 就认为玩家1获胜
System.out.println("玩家2掉线");
}
String respJson = objectMapper.writeValueAsString(response);
if (session1 != null) {
session1.sendMessage(new TextMessage(respJson));
}
if (session2 != null) {
session2.sendMessage(new TextMessage(respJson));
}
// 4. 如果当前获胜, 销毁房间
if (response.getWinner() != 0) {
System.out.println("游戏结束, 房间即将销毁");
// 更新获胜方的信息
int winId = response.getWinner();
int LoseId = response.getWinner() == user1.getUserId() ? user2.getUserId() : user1.getUserId();
userService.userLose(LoseId);
userService.userWin(winId);
// 销毁房间
roomManager.remove(roomId,user1.getUserId(),user2.getUserId());
}
}
7.3.12 完成用户胜负判断
完成 checkWinner
方法
// 谁获胜就返回谁的Id, 如果还没有获胜者, 就返回0
private int checkWinner(int row, int col, int chess) {
// 判断当前是谁获胜
// 1. 一行五子连珠
for (int i = col -4 ;i >= 0 && i <= col && i <= Constant.COL-5; i++) {
if (board[row][i] == chess
&& board[row][i+1] == chess
&& board[row][i+2] == chess
&& board[row][i+3] == chess
&& board[row][i+4] == chess) {
return chess == 1 ? user1.getUserId() : user2.getUserId();
}
}
// 2. 一列五子连珠
for (int i = row - 4; i >= 0 && i <= row && i <= Constant.ROW-5; i++) {
if (board[i][col] == chess
&& board[i+1][col] == chess
&& board[i+2][col] == chess
&& board[i+3][col] == chess
&& board[i+4][col] == chess) {
return chess == 1 ? user1.getUserId() : user2.getUserId();
}
}
// 3. 斜着五子连珠 -> 左上到右下
for (int i = row - 4, j = col - 4; i <= row && j <= col;j++,i++){
try {
if (board[i][j] == chess
&& board[i+1][j+1] == chess
&& board[i+2][j+2] == chess
&& board[i+3][j+3] == chess
&& board[i+4][j+4] == chess) {
return chess == 1 ? user1.getUserId() : user2.getUserId();
}
}catch (ArrayIndexOutOfBoundsException e) {
continue;
}
}
// 4. 斜着五子连珠 -> 右上到左下
for (int i = row+4,j=col-4; i>=row && j <= col; i--,j++) {
try {
if (board[i][j] == chess
&& board[i-1][j+1] == chess
&& board[i-2][j+2] == chess
&& board[i-3][j+3] == chess
&& board[i-4][j+4] == chess) {
return chess == 1 ? user1.getUserId() : user2.getUserId();
}
}catch (ArrayIndexOutOfBoundsException e) {
continue;
}
}
return 0;
}