前端 : HTML + CSS + JavaScript + Jquery + AJAX
后端 : Spring MVC + Spring Boot + MyBatis
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
mapper下添加 目录 **.xml 并添加代码
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>
这里使用数据库存储每一个用户的信息, 初始的时候, 天梯分和场次都是默认的.
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
);
登录功能
请求
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: ""
}
通过这个类, 方便前端接收内容
@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;
}
}
在 pom.xml中添加依赖
<dependency>
<groupId>org.springframework.securitygroupId>
<artifactId>spring-security-webartifactId>
dependency>
<dependency>
<groupId>org.springframework.securitygroupId>
<artifactId>spring-security-configartifactId>
dependency>
在启动类中添加注解
@SpringBootApplication(exclude = {org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration.class})
在 cofig 包下, 创建一个类 AppConfig.
@Configuration
public class AppConfig implements WebMvcConfigurer {
@Bean
public BCryptPasswordEncoder getBCryptPasswordEncoder() {
return new BCryptPasswordEncoder();
}
}
在 model 包中, 创建 User 实体类
@Data
public class User {
private int userId;
private String username;
private String password;
private int score;
private int totalCount;
private int winCount;
}
在 mapper 包中, 创建 UserMapper 接口
这个接口中 主要是完成
- 注册, 插入一个用户
- 登录的时候, 通过名字查询当前用户是否存在.
@Mapper
public interface UserMapper {
// 注册一个用户, 初始的天梯积分默认为1000, 场次默认为0
int insert(User user);
// 通过username查询当前用户是否存在
User selectByName(String username);
}
在 resources 目录下, 创建一个目录 mapper, 在目录下创建 UserMapper.xml
在 UserMapper.xml 中写对应UserMapper接口中对应的操作
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 包, 在包下创建 UserService 类, 这个类调用 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);
}
}
创建 controller包, 在包下创建一个 UserController 类
这个类是实现登录模块的功能的
- 这里需要注入 UserService, 调用数据库中的方法
- 还需要注入 BCryptPasswordEncoder, 对密码进行加密和比较
注意这里的登录.
- 首先去数据库根据用户名查询是否存在当前用户.
- 如果不存在, 登录失败.
- 如果存在, 用输入的密码, 和数据库中的密码进行比较, 看是否相等. (注: 数据中的密码是加密的)
- 如果不相等, 登录失败.
- 如果相等, 创建 session, 并登录成功.
@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);
}
}
- 首先查看是否该用户是否存在
- 存在, 就注册失败
- 不存在, 就进行注册, 首先对当前密码进行加密.
- 加密之后对这个用户添加到数据库中.
@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);
}
}
直接删除对应session 为
Constant.USER_SESSION_KEY
, 然后跳转到login.html
@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 类中.
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();
}
}
})
}
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();
}
}
})
}
LoginIntercepter
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;
}
}
AppConfig
@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");
}
}
这里客户端1, 点击匹配发送消息给服务器, 客户端2, 也点击匹配发送消息给服务器, 当服务器收到两个人的请求之后, 就需要服务器主动向客户端发送消息, 这里就需要用到 websocket
URL: ws://127.0.0.1:8080/findMatch
匹配请求
{
message: ' startMatch ' / ' stopMatch'
}
匹配响应1 这个响应是点击匹配之后, 立刻返回的响应
{
status: '1' / '-1'
message: ' startMatch ' / ' stopMatch '
}
匹配响应2 这个响应是匹配成功之后的响应
{
status: '1' / '-1'
message: 'matchSuccess'
}
请求
GET /user/userInfo HTTP/1.1
响应
{
status: 1/-1 (1 为成功, -1 为失败),
message: "对应信息",
data: "内容", (用户信息)
}
根据当前存储的session对象, 来查找对应的用户
@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);
}
}
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");
}
}
})
}
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");
}
}
这里是触发url的响应地址
@Configuration
@EnableWebSocket
public class AppConfig implements WebSocketConfigurer{
@Autowired
private MatchController matchController;
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry.addHandler(matchController,"/findMatch")
.addInterceptors(new HttpSessionHandshakeInterceptor());
}
}
当用户登录的时候, 就让用户状态添加到哈希表中
由于这里是 多线程的状态下, 很多用户访问同一个哈希表就会出现线程安全的问题, 所以这里就使用 ConcurrentHashMap, 确保了线程安全问题.
- 这里存储的, key是用户的Id, value是对应的WebSocketSession的信息.
- 提供三个方法
- 进入房间的时候, 将用户的状态存入哈希表中
- 退出房间的时候, 将用户的状态从哈希表中删除
- 获取当前用户的 WebSocketSession 信息
@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);
}
}
房间对象, 每一房间中, 会有RoomId, 和2个用户信息.
所以这里需要有一个完全不可重复的RoomId, 这里就使用Java中的 UUID来解决
// 游戏房间
@Data
public class Room {
private String roomId;
private User user1;
private User user2;
public Room() {
this.roomId = UUID.randomUUID().toString();
}
}
按理 也是使用哈希表存储, 也有线程安全问题, 所以也使用ConcurrentHashMap
提供3个方法
- 添加用户进入到房间
- 删除房间中的用户
- 提供房间Id得到房间对象
// 房间管理器
@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);
}
匹配队列, 首先按照分数将用户分为三个等级.
<2000
, 属于简单用户>= 2000 && < 3000
, 属于普通用户>=3000
, 属于高级用户
// 创建匹配队列 按等级划分
// 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<>();
这里就通过队列来分为为三个等级, 来完成匹配和退出
- 点击匹配的时候, 按照用户当前的等级, 将用户入队
- 取消匹配的时候, 按照用户当前的等级, 将用户从队列中删除
- 创建三个线程, 一直循环的去对应等级队列中进行获取用户, 如果当前队列中的用户有2个以上的时候, 就进行匹配.
这里也有线程安全的问题, 这里同一个队列中, 用户并发的入队, 和删除用户操作, 就会产生线程安全的问题. 如果是不同的队列, 就不涉及线程安全的问题
解决办法: 对于同一个队列中的操作进行加锁.
问题2: 这里的三个线程, 是循环的去等待, 如果当前队列中迟迟没有人进来, 而线程还是循环的执行下去, 这样的资源消耗就非常的大.
所以在进行判断当前用户是否有2个以上的时候, 如果当前用户小于2个, 就将当前的队列进行wait(), 直到再次有用户加入进来的时候,就解锁, 再去判断当前用户是否有2个以上的用户.
// 匹配器, 这个类是用来完成匹配功能的
@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();
}
}
}
}
websocket 有4个方法.
- 连接成功的时候调用的方法, 这里需要去判断多开的问题, 由于用户同时登录一个账号的时候, 就会出现多开, 解决办法就是查询当前用户的在线状态, 如果当前用户在线, 就退出当前登录. 如果没有多开就设置登陆状态
- 异常关闭的情况, 获取用户的信息, 然后设置在线状态为不在线, 然后删除匹配队列中的用户
- 退出的时候调用的方法, 获取用户的信息, 然后设置在线状态为不在线, 然后删除匹配队列中的用户
- 处理收到请求的方法, 通过前端发来的请求, 判断是否是开始匹配还是停止匹配.
@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);
}
}
连接URL
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)
}
这里的 canvas 是用来绘制棋盘的,
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 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;
}
- 这里的
setScreenText
这个方法是用来将显示框中的内容, 根据当前是谁下棋来改变内容.- 这里的
initGame
这个方法是用来初始画棋盘的, 棋盘大小为 15 * 15- 内部的
oneStep
是当点击下子之后, 会绘制对应颜色的棋子.- 注意这里的棋盘数组, 为0是没有落子, 为1是落子了.
- 这里的gameInfo, 内部内容是全局的.用来接收传过来的响应
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();
- 在服务器传过来请求的时候, 两个用户都已经准备好了, 首先判断是否是正确的请求.
- 在请求是正确的时候, 将传过来的信息存入到
gameInfo
中, 注意这里的isWhite 是判断是否是先手方.- 注意只有2个人都建立连接了, 才初始画棋盘, 所以在这里初始化棋盘为好.
- 棋盘绘制好之后, 在显示框中, 显示对应的信息, 调用对应的
setScreenText
方法
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);
}
在初始化棋盘之后, 在点击的时候, 发送落子请求
注意发送的对应的格式
function send(row,col) {
let req = {
message: 'putChess',
userId: gameInfo.thisUserId,
row: row,
col: col
};
websocket.send(JSON.stringify(req));
}
- 注意这里的响应是在落子之后, 所以要写在initGame() 中
- 在接收的时候, 首先将JSON格式响应转成可以接收的格式
- 判断响应是否正常, 排除响应错误的情况
- 判断当前是自己落子还是对方落子, 然后根据落子绘制棋子
- 落子之后, 交换落子的权利, 然后将显示的内容改变.
- 再次去判断是否游戏结束. 结束的时候,在显示框显示获胜信息, 并添加一个返回大厅的按钮, 以免直接返回了(用户看不到失败的信息.
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);
}
@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());
}
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 {
}
}
双方进入房间准备就绪的响应
// 客户端链接成功后, 返回的响应
@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;
}
在之前的 OnlineUserManager 中添加代码
- enterGameRoom, 进入房间添加到哈希表中(上线)
- exitGameRoom, 退出房间从哈希表中删除(下线)
- getRoomState, 获取当前用户的websocketsession信息
// 这个哈希表是表示当前用户在游戏房间的在线状态
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);
}
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);
}
- 首先获取用户的信息
- 判断当前是否已经进入房间了, 防止未匹配成功
- 判断是否多开, 这里要查询房间在线情况, 和大厅在线情况.
- 然后让用户房间的在线状态处于在线.
- 首先判断用户1是否上线, 上线就添加到当前房间来, 用户2再上线的时候也添加房间来, 这里可以设置谁是先手方, 根据自己设定的规则.我这里是随机取0~9的数字, 如果是偶数用户1就是先手, 如果是奇数用户2就是先手
- 当用户都进入房间的时候, 通知玩家准备就绪了
- 注意这里的线程安全问题. 多个用户进入同一个方法,就有可能出现线程安全问题, 由于是同一个房间的用户进行, 只需要对房间对象加锁就可以了.
@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)));
}
- 首先获取用户的信息
- 然后设置用户房间状态为下线
- 注意这里掉线了, 就需要判断对方赢了.
- 判断对方是否掉线, 如果对方也掉线了, 就无需通知谁赢了
- 如果对方没有掉线, 就通知对方赢了
- 获胜之后, 要对玩家的信息, 场次, 胜场进行更新. 然后关闭房间
@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());
}
- 添加哈希表, 管理用户对应的房间号
- key为用户的Id, value为用户的对应房间号
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);
}
- 这里的 Constant.ROW 和 Constant.COL 都是不变的常量. 放到 Constant类中. 这里初始化的棋盘数组也是15 * 15的
- 这里Room要注入Spring对象, 不能使用@Autowired @Resource注解. 需要使用context
修改启动类
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();
}
落子请求
@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());
}
- 注意这里的落子, 与前端不同, 这里的棋盘数组, 为0就是没落子, 为1就是用户1落得子, 为2就是用户2落得子
- 每次落子都要进行胜负判断, 使用checkWinner方法来实现
- 给房间中的用户返回响应
- 注意这里的玩家掉线的情况
- 如果胜负已分, 更新玩家获胜的信息, 并销毁房间
// 这个方法是用来处理一次落子的操作
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());
}
}
这里要判断四种情况
- 一行有五个子连珠
- 一列有五个子连珠
- 从左到右的斜着的五子连珠
- 从右到左的斜着的五子连珠
完成 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;
}