浅谈多人游戏原理和简单实现

一、我的游戏史

我最开始接触游戏要从一盘300游戏的光碟说起,那是家里买DVD送的,《魂斗罗》、《超级马里奥》天天玩。自从买回来后,我就经常和姐姐因为抢电视机使用权而大打出手。有次她把遥控器藏到了沙发的夹层里,被我妈一屁股做成了两半,我和我姐喜提一顿暴打。那顿是我挨得最狠的,以至于现在回想起来,屁股还条件反射的隐隐作痛。

后来我骗我妈说我要学习英语、练习打字以后成为祖国的栋梁之才!让她给我买台小霸王学习机(游戏机),在我一哭二闹三上吊胡搅蛮缠的攻势下,我妈妥协了。就此我接触到了FC游戏。现在还能记得我和朋友玩激龟快打,满屋子的小朋友在看的场景。经常有家长在我家门口喊他家小孩吃饭。那时候我们县城里面有商店卖游戏卡,小卡一张5块钱,一张传奇卡25-40块不等(所谓传奇卡就是角色扮演,带有存档的游戏),每天放学都要去商店去看看,有没有新的游戏卡,买不起,就看下封面过过瘾。我记得我省吃俭用一个多月,买了两张卡:《哪吒传奇》和《重装机兵》那是真的上瘾,没日没夜的玩。

再然后我接触到了手机游戏,记得那时候有个软件叫做冒泡游戏(我心目的中的Stream),里面好多游戏,太吸引我了。一个游戏一般都是几百KB,最大也就是几MB,不过那时候流量很贵,1块钱1MB,并且!一个月只有30Mb。我姑父是收手机的,我在他那里搞到了一部半智能手机,牌子我现在还记得:诺基亚N70,那时候我打开游戏就会显示一杯冒着热气的咖啡,我很喜欢这个图标,因为看见它意味着我的游戏快加载完成了,没想到,十几年后我们会再次相遇,哈哈哈哈。我当时玩了一款网游叫做:《幻想三国》,第一回接触网游简直惊呆了,里面好多人都是其他玩的家,这太有趣了。并且我能在我的手机上看到其他玩家,能够看到他们的行为动作,这太神奇了!!!我也一直思考这到底是怎么实现的!

最后是电脑游戏,单机:《侠盗飞车》、《植物大战僵尸》、《虐杀原型》;网游:《DNF》、《CF》、《LOL》、《梦幻西游》我都玩过。

不过那个疑问一直没有解决,也一值留在我心中 —— 在网络游戏中,是如何实时更新其他玩家行为的呢?

二、解惑

在我进入大学后,我选择了软件开发专业,真巧!再次遇到了那个冒着热气的咖啡图标,这时我才知道它叫做——Java。我很认真的去学,希望有一天能够做一款游戏!

参加工作后,我并没有如愿以偿,我成为了一名Java开发程序员,但是我在日常的开发的都是web应用,接触到大多是HTTP请求,它是种请求-响应协议模式。这个问题也还是想不明白,难道每当其他玩家做一个动作都需要发送一次HTTP请求?然后响应给其他玩家。这样未免效率也太低了吧,如果一个服务器中有几千几万人,那么服务器得承受多大压力呀!一定不是这样的!!!

直到我遇到了Websocket,这是一种长连接,而HTTP是一种短连接,顿时这个问题我就想明白了。在此二者的区别我就不过多赘述了。详细请看我的另一篇文章

知道了这个知识后,我终于能够大致明白了网络游戏的基本原理。原来网络游戏是由客户端服务器端组成的,客户端就是我们下载到电脑或者手机上的应用,而服务器端就是把其他玩家连接起来的中转站,还有一点需要说明的是,网络游戏是分房间的,这个房间就相当于一台服务器。首先,在玩家登陆客户端并选择房间建立长连接后,A玩家做出移动的动作,随即会把这个动作指令上传给服务器,然后服务器再将指令广播到房间中的其他玩家的客户端来操作A的角色,这样就可以实现实时更新其他玩家行为。

三、简单实现

客户端服务端在处理指令时,方法必须是配套的。比如说,有新的玩家连接到服务器,那么服务器就应当向其它客户端广播创建一个新角色的指令,客户端在接收到该指令后,执行客户端创建角色的方法。为了方便演示,这里需要定义两个HTML来表示两个不同的客户端不同的玩家,这两套客户端代码除了玩家的信息不一样,其它完全一致!!!

3.1 客户端实现步骤

我在这里客户端使用HTML+JQ实现

客户端——1代码:

(1)创建画布

html

<!DOCTYPE html><html lang="en"><head>    <meta charset="UTF-8">    <meta name="viewport" content="width=device-width, initial-scale=1.0">    <title>Canvas Game</title>    <style>        canvas {            border: 1px solid black;        }</style>    <script src="https://code.jquery.com/jquery-3.6.0.min.js"></script></head><body><canvas id="gameCanvas" width="800" height="800"></canvas></body></html>

(2)设置1s60帧更新页面

javascript


   
const canvas = document.getElementById('gameCanvas');const ctx = canvas.getContext('2d');function clearCanvas() {   ctx.clearRect(0, 0, canvas.width, canvas.height);}function gameLoop() {      clearCanvas();      players.forEach(player => {          player.draw();      });  } setInterval(gameLoop, 1000 / 60);//清除画布方法function clearCanvas() {        ctx.clearRect(0, 0, canvas.width, canvas.height);    }

(3)连接游戏服务器并处理指令

这里使用websocket链接游戏服务器

javascript

 //连接服务器const websocket = new WebSocket("ws://192.168.31.136:7070/websocket?userId=" + userId + "&userName=" + userName); //向服务器发送消息 function sendMessage(userId,keyCode){      const messageData = {          playerId: userId,          keyCode: keyCode      };      websocket.send(JSON.stringify(messageData));  }  //接收服务器消息,并根据不同的指令,做出不同的动作  websocket.onmessage = event => {        const data = JSON.parse(event.data);        // 处理服务器发送过来的消息        console.log('Received message:', data);        //创建游戏对象        if(data.type == 1){            console.log("玩家信息:" +  data.players.length)            for (let i = 0; i < data.players.length; i++) {                console.log("玩家id:"+playerOfIds);                createPlayer(data.players[i].playerId,data.players[i].pointX, data.players[i].pointY, data.players[i].color);            }        }        //销毁游戏对象        if(data.type == 2){            console.log("玩家信息:" +  data.players.length)            for (let i = 0; i < data.players.length; i++) {                destroyPlayer(data.players[i].playerId)            }        }        //移动游戏对象        if(data.type == 3){            console.log("移动;玩家信息:" +  data.players.length)            for (let i = 0; i < data.players.length; i++) {                players.filter(player => player.id === data.players[i].playerId)[0].move(data.players[i].keyCode)            }        }    };

(4)创建玩家对象

javascript


   
//存放游戏对象let players = [];//playerId在此写死,正常情况下应该是用户登录获取的const userId = "1"; // 用户的 idconst userName = "逆风笑"; // 用户的名称//玩家对象class Player {        constructor(id,x, y, color) {            this.id = id;            this.x = x;            this.y = y;            this.size = 30;            this.color = color;        }    //绘制游戏角色方法        draw() {            ctx.fillStyle = this.color;            ctx.fillRect(this.x, this.y, this.size, this.size);        }    //游戏角色移动方法          move(keyCode) {            switch (keyCode) {                case 37: // Left                    this.x = Math.max(0, this.x - 10);                    break;                case 38: // Up                    this.y = Math.max(0, this.y - 10);                    break;                case 39: // Right                    this.x = Math.min(canvas.width - this.size, this.x + 10);                    break;                case 40: // Down                    this.y = Math.min(canvas.height - this.size, this.y + 10);                    break;            }            this.draw();        }    }

(5)客户端创建角色方法

javascript

//创建游戏对象方法function createPlayer(id,x, y, color) {   const player = new Player(id,x, y, color);    players.push(player);    playerOfIds.push(id);    return player;}

(6)客户端销毁角色方法

在玩家推出客户端后,其它玩家的客户端应当销毁对应的角色。

javascript

//角色销毁function destroyPlayer(playId){   players = players.filter(player => player.id !== playId);}

客户端——2代码:

客户端2的代码只有玩家信息不一致:

javascript


const userId = "2"; // 用户的 id const userName = "逆风哭"; // 用户的名称

3.2 服务器端

服务器端使用Java+websocket来实现!

(1)引入依赖:

xml


<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> <version>2.1.2.RELEASE</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-websocket</artifactId> <version>2.3.7.RELEASE</version> </dependency> <dependency> <groupId>org.apache.commons</groupId> <artifactId>commons-lang3</artifactId> <version>3.11</version> </dependency> <dependency> <groupId>com.alibaba</groupId> <artifactId>fastjson</artifactId> <version>1.2.75</version> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <version>1.16.16</version> </dependency> <dependency> <groupId>cn.hutool</groupId> <artifactId>hutool-all</artifactId> <version>5.6.3</version> </dependency>

(2)创建服务器

@Component@ServerEndpoint("/websocket")@Slf4jpublic class Server {    /**     * 服务器玩家池     * 解释:这里使用 ConcurrentHashMap为了保证线程安全,不会出现同一个玩家存在多条记录问题     * 使用 static fina修饰 是为了保证 playerPool 全局唯一     */    private static final ConcurrentHashMap<String, Server> playerPool = new ConcurrentHashMap<>();    /**     * 存储玩家信息     */    private static final ConcurrentHashMap<String, Player> playerInfo = new ConcurrentHashMap<>();    /**     * 已经被创建了的玩家id     */    private static ConcurrentHashMap<String, Server> createdPlayer = new ConcurrentHashMap<>();
private Session session;
private Player player;
/** * 连接成功后调用的方法 */ @OnOpen public void webSocketOpen(Session session) throws IOException { Map<String, List<String>> requestParameterMap = session.getRequestParameterMap(); String userId = requestParameterMap.get("userId").get(0); String userName = requestParameterMap.get("userName").get(0); this.session = session; if (!playerPool.containsKey(userId)) { int locationX = getLocation(151); int locationY = getLocation(151); String color = PlayerColorEnum.getValueByCode(getLocation(1) + 1); Player newPlayer = new Player(userId, userName, locationX, locationY,color,null); playerPool.put(userId, this); this.player = newPlayer; //存放玩家信息 playerInfo.put(userId,newPlayer); } log.info("玩家:{}|{}连接了服务器", userId, userName); // 创建游戏对象 this.createPlayer(userId); }
/** * 接收到消息调用的方法 */ @OnMessage public void onMessage(String message, Session session) throws IOException, InterruptedException { log.info("用户:{},消息{}:",this.player.getPlayerId(),message); PlayerDTO playerDTO = new PlayerDTO(); Player player = JSONObject.parseObject(message, Player.class); List<Player> players = new ArrayList<>(); players.add(player); playerDTO.setPlayers(players); playerDTO.setType(OperationType.MOVE_OBJECT.getCode()); String returnMessage = JSONObject.toJSONString(playerDTO); //广播所有玩家 for (String key : playerPool.keySet()) { synchronized (session){ String playerId = playerPool.get(key).player.getPlayerId(); if(!playerId.equals(this.player.getPlayerId())){ playerPool.get(key).session.getBasicRemote().sendText(returnMessage); } } } }
/** * 关闭连接调用方法 */ @OnClose public void onClose() throws IOException { String playerId = this.player.getPlayerId(); log.info("玩家{}退出!", playerId); Player playerBaseInfo = playerInfo.get(playerId); //移除玩家 for (String key : playerPool.keySet()) { playerPool.remove(playerId); playerInfo.remove(playerId); createdPlayer.remove(playerId); } //通知客户端销毁对象 destroyPlayer(playerBaseInfo); }
/** * 出现错误时调用的方法 */ @OnError public void onError(Throwable error) { log.info("服务器错误,玩家id:{},原因:{}",this.player.getPlayerId(),error.getMessage()); } /** * 获取随即位置 * @param seed * @return */ private int getLocation(Integer seed){ Random random = new Random(); return random.nextInt(seed); }}

websocket配置:

java

@Configurationpublic class ServerConfig {    @Bean    public ServerEndpointExporter serverEndpointExporter(){        return new ServerEndpointExporter();    }}

(3)创建玩家对象

玩家对象:

java

@Data@AllArgsConstructor@NoArgsConstructorpublic class Player {    /**     * 玩家id     */    private String playerId;    /**     * 玩家名称     */    private String playerName;    /**     * 玩家生成的x坐标     */    private Integer pointX;    /**     * 玩家生成的y坐标     */    private Integer pointY;    /**     * 玩家生成颜色     */    private String color;    /**     * 玩家动作指令     */    private Integer keyCode;}

创建玩家对象返回给客户端DTO:

java

@Data@AllArgsConstructor@NoArgsConstructorpublic class PlayerDTO {    private Integer type;    private List<Player> players;}

玩家移动指令返回给客户端DTO:

java


@Data@AllArgsConstructor@NoArgsConstructorpublic class PlayerMoveDTO { private Integer type; private List<Player> players;}

(4)动作指令java

public enum OperationType {    CREATE_OBJECT(1,"创建游戏对象"),    DESTROY_OBJECT(2,"销毁游戏对象"),    MOVE_OBJECT(3,"移动游戏对象"),    ;    private Integer code;    private String value;
OperationType(Integer code, String value) { this.code = code; this.value = value; }
public Integer getCode() { return code; }
public String getValue() { return value; }}

(5)创建对象方法

  /**     * 创建对象方法     * @param playerId     * @throws IOException     */    private void createPlayer(String playerId) throws IOException {        if (!createdPlayer.containsKey(playerId)) {            List<Player> players = new ArrayList<>();            for (String key : playerInfo.keySet()) {                Player playerBaseInfo = playerInfo.get(key);                players.add(playerBaseInfo);            }            PlayerDTO playerDTO = new PlayerDTO();            playerDTO.setType(OperationType.CREATE_OBJECT.getCode());            playerDTO.setPlayers(players);            String syncInfo = JSONObject.toJSONString(playerDTO);            for (String key :                    playerPool.keySet()) {                playerPool.get(key).session.getBasicRemote().sendText(syncInfo);            }            // 存放            createdPlayer.put(playerId, this);        }    }

(6)销毁对象方法

 /**     * 销毁对象方法     * @param playerBaseInfo     * @throws IOException     */    private void destroyPlayer(Player playerBaseInfo) throws IOException {        PlayerDTO playerDTO = new PlayerDTO();        playerDTO.setType(OperationType.DESTROY_OBJECT.getCode());        List<Player> players = new ArrayList<>();        players.add(playerBaseInfo);        playerDTO.setPlayers(players);        String syncInfo = JSONObject.toJSONString(playerDTO);        for (String key :                playerPool.keySet()) {            playerPool.get(key).session.getBasicRemote().sendText(syncInfo);        }    }

四、演示

4.1 客户端1登陆服务器

4.2 客户端2登陆服务器

4.3 客户端2移动

4.4 客户端1移动

4.5 客户端1退出

完结撒花

完整代码传送门https://gitee.com/hu_jiangdi/game_server

五、总结

以上就是我对网络游戏如何实现玩家实时同步的理解与实现,我实现后心里也释然了,哈哈哈,真的好有趣!!!我希望大家也是,不要失去好奇心,遇到自己感兴趣的事情,一定要多思考呀~

后来随着我经验的不断积累,我又去了解了一下Java作为游戏服务器的相关内容,发现Netty更适合做这个并且更容易入门,比如《我的世界》一些现有的服务器就是使用Netty实现的。有空也实现下,玩玩~

最后给大家推荐几个优秀的职位,详情请查看链接地址:【HBLOG10月内推】让你的职业生涯腾飞!

相关推荐

  • 【深度学习】激光雷达分割与测距SOTA算法!已开源!
  • 【深度学习】NIPS 2022 表格数据还需要深度学习吗?
  • 【学术相关】教育部:研究生,可以换导师!
  • 文末福利|即将开始!3分钟带你揭晓稀土掘金创新论坛四大亮点,一起探讨AI时代下的管理变革
  • Nodejs 已发布 21.1.0 版本
  • 一文搞懂“支付·清结算·账务”全局
  • 导入个Excel页面直接卡死,看我如何处理T0生产事故~
  • 一篇文章让你搞懂到底什么是 CDN
  • select...for update 锁表了?
  • 建议前端开发者学习下色彩心理学,提升用户体验
  • 开发过程中,建议使用 VSCode 的 Thunder Client 插件替代 Postman, 让你显得更专业
  • Mybatis的一级缓存与二级缓存
  • 丰富的模板与插件,构建你心中的理想站点
  • 陈怡然力荐《关于我博士毕业的这件小事》,Waymo研究员2年半心路分享火了
  • 大华股份发布星汉大模型;苹果AI服务器支出明年或达47.5亿美元;英伟达H100成新型债务资产丨AIGC大事日报
  • 净利润暴涨1763%,世界第三大软件公司如何靠AIGC逆风翻盘?
  • 提升时间序列聚类的表现的秘密方法。
  • 语雀停服八小时,P0级事故!故障原因和补偿来了!!
  • SpringBoot 快速实现 api 加密,一个轮子搞定!
  • [开源]一套企业级微服务框架、微服务能力开放平台,功能强大