MeteorCat / 网络游戏的场景同步

Created Sun, 07 Apr 2024 13:27:59 +0800 Modified Wed, 29 Oct 2025 23:24:59 +0800

网络游戏的场景同步

回归到游戏本质就是对于游戏客户端角色的操控, 单机游戏客户端通过操作指令来操作让其位移; 如果架构在网络游戏该怎么规划? 怎么把这方面的指令操作移交到服务端执行?

对于网络游戏来说, 客户端传上来的有可能是伪造数据, 最常见开启 变速齿轮 让游戏环境速度加速的情况, 这时候客户端肆无忌惮手动数据封包推送大量数据打乱游戏内部所有平衡.

但还有种情况是不需要在服务端频繁维护玩家实体状态, 场景中位移频繁都要提交到服务端对于CPU消耗比较庞大, 而有的场景实际上不具有太大的意义.

最常见的某些场景约等于材料本( 游戏中负责材料产出的副本 ), 这种玩家支付体力实际上只需最后结算奖励发放, 所以没必要同步太多细节.

需要明确几种游戏环境情况, 根据这些游戏环境才需要判断是否是否需要同步客户端场景同步:

  • 多人同步: 这种是最常见需要同步操作场景, 因为场景内状态并不是单个玩家拥有的, 需要实时推送给同场景的不同玩家客户端
  • 对战同步: 双人对战竞技场这种也是比较广泛场景, 常见用于双方竞技排名的情况, 最后决出的就是竞技分数和输赢情况
  • 大逃杀模式: 最近兴起的多玩家在大地图互相猎杀的游戏, 这里面设计区域分块设计和多玩家状态同步设计, 这种中小厂目前没有驾驭的能力
  • 塔防游戏: 塔防需要有具体的出怪逻辑和建筑攻击不断算血, 所以需要定时模拟计算攻击范围和血量变动.

上面就是比较常见的情况, 其实能够看出具体大部分都是强交互的情况, 这些情况也十分消耗CPU资源; 因为本身作为单机调用 Update 的情况就十分频繁, 而现在需要上万同时在线请求来维持游戏运转, 可以想象你上万人同时在线游玩来执行服务器的 Update 的 CPU 消耗也是非常惊人的.

偏轻度弱交互的移动端游戏, 则尽可能避免这种频繁需要消耗服务端 CPU 情况, 所以对于明确平台是移动端大部分采用相对简单的状态同步方式而非频繁帧同步.

建议如果对游戏服务端没有概念的, 可以先学习 skynet 试着学习 agent 概念设计和实现; 其中最主要概念就是对于单人在场景和玩法之中其实在服务端看来就是对自己所在数据的 自娱自乐, 轻度弱交互其实就是游玩游戏策划提供的玩法并且写入到服务端数据库.

这里按照最常见游戏帧( 60帧=Update每1/60s执行,30帧=Update每1/30s执行 ), 这种情况下需要在服务端动态创建 Update 定时器来处理.

高频率定时器 1/60s=16.7ms, 所以需要构建出 16.7 毫秒的定时器; 但日常不需要高频率, 1/30s=33.3ms 对于一般游戏够用了.

如果你接触到游戏客户端( 以 Unity|Godot 为例 ), 就可以看到现代游戏引擎的脚本最基础的函数:

  • Start|Awake|Init: 脚本初始化方法
  • Update|FixedUpdate|Process|StaticProcess: 脚本更新方法

这些方法是基本游戏脚本会用到的, 现在主要就是要将其移交到服务端管理.

这里已经自己编写简单的服务端, 大致思路也是差不多和客户端一样, 只是需要同步提交服务端然后等待服务端下发指令.

这里参照之前 Java 服务端编写的 Actor 服务来构建:

/**
 * 简单的场景帧同步
 */
@EnableActor(owner = SimpleSceneActor.class)
public class SimpleSceneActor extends ActorConfigurer {

    /**
     * 会话日志
     */
    final Logger logger = LoggerFactory.getLogger(getClass());

    /**
     * 程序初始化
     * 这里有两种处理方式:
     * - 动态单玩家生成的帧执行方法: 针对玩家个人的帧同步, 需要客户端推送创建加入帧订阅
     * - 全局共享场景的帧执行方法: 共享场景的帧同步, 相当于游戏场景在启动的时候就开始执行
     */
    @Override
    public void init() {
        logger.info("启动简单的场景帧同步");
    }

    /**
     * 程序退出
     * - 如果是单用户的帧执行, 需要检查所有执行线程来退出回收处理
     * - 如果是场景的帧执行, 需要保存当前场景环境数据到数据库等待启动导入
     */
    @Override
    public void destroy() {
        logger.info("退出简单的场景帧同步");
    }


    /*
     * =============================================================================
     * 这里示范采用单玩家动态加入游戏服务场景帧并且在移动场景
     * =============================================================================
     */


    /**
     * 2d位置的属性类
     */
    public static class Vector2f {
        public float x;
        public float y;

        public Vector2f(float x, float y) {
            this.x = x;
            this.y = y;
        }

        @Override
        public String toString() {
            return "Vector2f{" +
                    "x=" + x +
                    ", y=" + y +
                    '}';
        }
    }


    /**
     * 正在执行的程序同步任务集合
     */
    private final Map<Long, ScheduledFuture<?>> events = new HashMap<>();


    /**
     * 目前记录在服务端关于玩家位置的属性集合
     */
    private final Map<Long, Vector2f> position = new HashMap<>();


    /**
     * 直接挂起服务帧线程来参与调用Update
     * 示例: { "value": 700, "args": { } }
     *
     * @param app     WebSocket服务句柄
     * @param session 会话对象
     * @param data    传入数据
     */
    @ActorMapping(value = 700, state = ActorStatus.Authorized)
    public void join(WebsocketApplication app, WebSocketSession session, JsonNode data) {
        // 获取玩家登录授权的UID
        Long uid = app.getSessionUid(session);
        if (uid == null) {
            return;
        }


        // 确认出目前在线玩家位置, 没有的话将其初始化位置
        if (!position.containsKey(uid)) {
            position.put(uid, new Vector2f(1.0f, 1.0f));
        }


        // 确认之前是否有参与执行方法, 直接关闭执行方法
        if (events.containsKey(uid)) {
            events.get(uid).cancel(false);
        }


        // 创建执行线程任务, 按照常规 1/30s=33.3ms 的唤醒方式
        ActorEventContainer eventContainer = getContainer();
        events.put(uid, eventContainer.scheduleWithFixedDelay(
                () -> update(app, eventContainer, session, uid), // 执行更新方法
                33, // 33毫秒起始延迟
                33, // 33毫秒重复延迟
                TimeUnit.MILLISECONDS // 时间单位
        ));
    }


    /**
     * 等待客户端传入服务帧的位移向量指令
     * 示例: { "value": 701, "args": { "x":1,"y":0 } }
     *
     * @param app     WebSocket服务句柄
     * @param session 会话对象
     * @param data    传入数据
     */
    @ActorMapping(value = 701, state = ActorStatus.Authorized)
    public void move(WebsocketApplication app, WebSocketSession session, JsonNode data) {
        // 获取玩家登录授权的UID
        Long uid = app.getSessionUid(session);
        if (uid == null) {
            return;
        }

        // 确认参数 { x, y }向量
        JsonNode xNode = data.get("x");
        if (xNode == null || !xNode.isNumber()) {
            return;
        }
        JsonNode yNode = data.get("y");
        if (yNode == null || !yNode.isNumber()) {
            return;
        }

        // 确认上报的X|Y位置参数
        int x = xNode.asInt();
        int y = yNode.asInt();

        // 将 x|y 归一化
        x = Math.min(x, 1);
        x = Math.max(x, -1);
        y = Math.min(y, 1);
        y = Math.max(y, -1);


        // 获取位置参数
        Vector2f pos = position.get(uid);

        // 确认位移方向 1(右|下)/0(不动)/-1(左|上)
        // 比较简单的方向位置如下:
        //  { x:1, y:0 }, 往下走
        //  { x:1, y:1 }, 往右下走
        // 向量 * 速度 = 具体位移位置, 这里先设定假设速度值
        float speed = 10f;


        // 更新位移的位置, 将向量 * 速度得出目前位移数值, 并且等待服务端推送给客户端位移
        pos.x = pos.x + x * speed;
        pos.y = pos.y + y * speed;
        logger.debug("玩家移动位置指令:({})", pos);
        position.put(uid, pos);
    }

    /**
     * 表示玩家退出当前场景
     * 示例: { "value": 702, "args": { } }
     *
     * @param app     WebSocket服务句柄
     * @param session 会话对象
     * @param data    传入数据
     */
    @ActorMapping(value = 702, state = ActorStatus.Authorized)
    public void quit(WebsocketApplication app, WebSocketSession session, JsonNode data) {
        // 获取玩家登录授权的UID
        Long uid = app.getSessionUid(session);
        if (uid == null) {
            return;
        }

        // 确认执行Update方法让其退出
        if (events.containsKey(uid)) {
            events.get(uid).cancel(false);
            logger.debug("玩家退出场景指令");
        }
    }


    /**
     * 这里就是模拟客户端的 FixedUpdate 方法, 用于在服务端的帧同步更新
     * 响应数据: { "value": 703, "args": { x:100,y:200 } }
     *
     * @param app       WebSocket服务句柄
     * @param container 事件管理器
     * @param session   会话对象
     * @param uid       玩家UID
     */
    public void update(WebsocketApplication app, ActorEventContainer container, WebSocketSession session, Long uid) {
        // 确认是否目前会话是否可用在线, 不可用直接关闭执行对象
        if (!session.isOpen()) {
            events.get(uid).cancel(false);// 取消任务
            return;
        }

        // 获取目前位置参数, 直接下发给客户端位移
        Vector2f pos = position.get(uid);

        // 下发目前的玩家在线位置让客户端同步
        app.push(session, 703, new HashMap<>(1) {{
            put("pos", pos);
        }});
    }

}

这里测试跑下请求样例, 这种就是最粗浅基础的服务端同步帧方法:

// 登录授权
{
  "value": 100,
  "args": {
    "uid": 1,
    "secret": "c38dae640b8415388d38423e3cb23d95"
  }
}

// 生成实体
{
  "value": 200,
  "args": {
    "nickname": "MeteorCat"
  }
}

// 请求动态服务端的帧同步
{
  "value": 700,
  "args": {}
}

// 客户端提交的位移向量
{
  "value": 701,
  "args": {
    "x": 1,
    "y": 0
  }
}

// 取消当前玩家帧同步
{
  "value": 702,
  "args": {}
}

这里就是采用 30帧/s 在服务端同步数据, 把客户端的 Update 方法移交给服务端处理; 但是需要留意这种同步方式及其消耗服务器性能, 所以尽可能要做好优化审慎处理.