MeteorCat / H5游戏服务端(七)

Created Tue, 30 Jan 2024 13:49:55 +0800 Modified Wed, 29 Oct 2025 23:24:59 +0800

H5游戏服务端(七)

之前大概粗略处理了策划与客户端的基本对接流程, 回顾下基本流程:

  • 客户端对接: 主要针对协议数据格式推送
  • 策划对接: 主要针对 excel 导出 json 共享

初期这样配置就差不多打通 服务端 - 客户端 - 策划 三端的联调功能, 之后就可以准备以此当网络框架进行网络游戏开发.

这时候你也对 postman 直接调试开始感觉都厌烦了, 没有 GUI 所见即所得从而对开发兴致一下子降低了, 所以这时候需要提振自己信心.

这里可以考虑去找些开源的单机游戏仿品改造成在线版本, 从而测试下功能可行性并且学习下客户端方面的处理方式从而在服务端构建需求时候加入客户端理解.

推荐B站UP主 Hi小胡 做的 土豆兄弟样例

采用 Godot 内部已基本上已经实现具体的客户端相关所有功能, 剩下具体就是需要自己去补充或者挂载在服务端实现逻辑.

直接拉取源码测试下( 注意 Godot 切换成 兼容模式 ):

git clone https://gitee.com/hi_xiaohu/brotato-like-teach.git

如果对于 Godot 客户端有兴趣可以去学习怎么构建项目, 这里作为补充附加功能补充, 客户端目前已经完成以下 Scene 功能:

  • bg_map/bg_map.tscn: 游戏战斗界面
  • ui/game_ui.tscn: 游戏GUI界面
  • scenes/scene_update/scene_update_tscn: 关卡更新界面

但是在开始之前, 有些概念必须要清楚才能进行有效开发.

游戏同步

先说明下游戏的同步机制方式:

  • 帧同步: 把客户端的 update|_process 之类的更新方法移交到服务端定时执行, 运算全部在服务端执行并返回.
  • 状态同步: 把客户端负责全部运算, 最后场景|关卡结果已经确定, 服务端只需要负责最后结算验证写入玩家信息.
  • 存档同步: 客户端完全负责运算, 之后定时将本地存档推送到服务端, 让服务端做云存档功能

帧同步就要求请求响应速度越快越好, 普遍都是基于 TCP/UDP 甚至自研协议来加快数据包响应速度, 基本上在性能要求比较高的情况会采用这类.

状态同步则要求比较松散, TCP/Websocket 甚至随便 http 都行, 主要运算在客户端反应某个场景箱子打开告诉服务端需要获取打开后物品信息.

存档同步没什么好说的, 只要有网络环境就行了, 客户端负责游戏所有运算, 服务端仅仅作为同步和保存游戏存档功能, 后续游戏停运可以售卖离线版.

另外还有游戏模式需要说明:

  • 个人模式: 这类游戏模式没有和其他玩家交互, 大部分时间都是玩个人游戏场景(最多是世界BOSS打桩输出排行等), 数据都是基于玩家个人信息的修改.
  • 多人模式: 集中于与MMO和竞技类, 多人在线相互的数值会出现碰撞(常见多人联机副本情况,涉及物品交易|阵营对立等), 数据需要跨玩家修改.

现在比较多二次元品类集中于美术方面, 所以基本上都是采用体力关卡模式, 这类基本上都是个人游戏模式, 通过降低多人游戏开发复杂度把经费集中于其他; 而类似MMO则是集中于多人交互性, 主打社交类的游戏方式, 在这种情况下基本上要求大地图的多人交互, 所以游戏对于性能和效率也是要求及其严格的.

按照项目需求的游戏类型, 就可以对游戏服务端进行功能选型, 现在就分别实现不同玩家同步策略.

状态同步

这里实现个状态同步的游戏场景功能, 当玩家进入某个场景关卡此时奖励编码已经返回给客户端, 客户端只需要在自己本地运算接收到打开奖励的命令推送给服务端, 由服务端最后来识别并且修改玩家身上资源就行了.

/**
 * 游戏场景物品服务
 */
@EnableActor(owner = SceneLogic.class)
public class SceneLogic extends ActorConfigurer {

    /**
     * 日志句柄
     */
    final Logger logger = LoggerFactory.getLogger(SceneLogic.class);

    /**
     * 策划配置的场景奖励道具
     * 实际上这里应该是 道具ID+道具数量+奖励文本, 这里采用简约处理只记录道具ID
     */
    private final List<Integer> items = new ArrayList<>();


    /**
     * 进入内部场景之后的随机生成道具奖励id合集
     */
    private final Map<Long, List<Integer>> awards = new HashMap<>();


    /**
     * 玩家已领取道具信息
     */
    private final Map<Long, List<Integer>> received = new HashMap<>();


    @Override
    public void init() throws Exception {
        // 初始化默认奖励, 一般是读取策划配置奖励道具和概率|数量等
        Random random = new Random();
        int count = 10;
        logger.debug("战斗奖励数量: {}", count);
        for (int i = 0; i < count; i++) {
            int total = 1 + random.nextInt(99);
            logger.debug("奖励ID: {}", total);
            items.add(total);
        }


    }

    @Override
    public void destroy() throws Exception {

    }


    /**
     * 进入触发场景
     * Example: { "value":600,"args":{}}
     *
     * @param runtime 运行时
     * @param session 会话
     * @param args    参数
     */
    @ActorMapping(value = 600, state = {LogicStatus.Authorized, LogicStatus.Gaming})
    public void enter(WebsocketApplication runtime, WebSocketSession session, JsonNode args) throws IOException {
        // 获取玩家ID, 并且生成该玩家在这次进入地图的奖励道具
        Long uid = runtime.getUid(session);

        // todo: 这里一般策划配置奖励道具|最低-最高数量, 目前采用固定值
        int count = 5;// 假设策划配置每次更新场景新增道具数量写死为5
        List<Integer> item = new ArrayList<>(count);
        int sz = items.size();
        Random random = new Random();
        for (int i = 0; i < count; i++) {
            int value = random.nextInt(sz - 1);
            Integer element = items.get(value);
            if (item.contains(element)) {
                i--;
            } else {
                item.add(element);
            }
        }

        // 写入进场景后道具信息, 暂时先进入场景之后更新
        // todo: 这里有个问题就是场景道具进入后会导致更新道具, 正确应该由策划配置地图场景物品刷新配置
        // todo: 进入之后如果没满足更新条件, 自动加载上次场地道具并设置定时器准备更新, 如果满足则重新更新场景道具
        awards.put(uid, item);
        logger.debug("玩家({})更新场景道具:{}", uid, item);

        // 如果更新的时候需要清空目前玩家在该场景的领取信息
        if (received.containsKey(uid)) {
            received.get(uid).clear();
        } else {
            received.put(uid, new ArrayList<>(item.size()));
        }

        // 响应返回道具信息
        runtime.push(session, 600, new HashMap<>(1) {{
            put("awards", item);
        }});
    }

    /**
     * 打开场景内部道具奖励
     * Example: { "value":601,"args":{ "award": 60 }}
     *
     * @param runtime 运行时
     * @param session 会话
     * @param args    参数
     */
    @ActorMapping(value = 601, state = {LogicStatus.Authorized, LogicStatus.Gaming})
    public void open(WebsocketApplication runtime, WebSocketSession session, JsonNode args) {
        // 判断是否带有打开的场景道具ID
        JsonNode award = args == null ? null : args.get("award");
        if (award == null || !award.isInt()) {
            // 没有直接推送奖励错误
            return;
        }

        // 获取奖励ID识别进入房间之后的道具奖励是否在其中
        Integer awardId = award.asInt();


        // 识别道具领取列表是否存在, 没有可领取的道具不用管
        Long uid = runtime.getUid(session);
        List<Integer> item = awards.get(uid);
        if (item == null || !item.contains(awardId)) {
            return;
        }

        // 已领取, 也不需要管他, 让客户端自己做表现处理
        List<Integer> receive = received.get(uid);
        if (receive.contains(awardId)) {
            return;
        }


        // 把金币加入到玩家身上, 通知玩家追加金额, 假设 PlayerLogic 追加 303 修改金额方法
        ActorConfigurer configurer = getContainer().get(303);
        if (configurer != null) {
            configurer.invoke(303, LogicStatus.Program, uid, awardId);

            // 切换状态为已领取
            logger.debug("追加金币: {}", awardId);
            receive.add(awardId);
        }
    }
}

编写好挂载 Actor 服务之后直接调用以下协议内容:

// 先授权登录到游戏
{
  "value": 100,
  "args": {
    "secret": "8b35a90ed5fe7296c8af827e585ea873",
    "uid": 1
  }
}

// 进入同步状态的游戏场景, 这里会返回具体的奖励ID
{
  "value": 600,
  "args": {}
}

// 最后客户端按照策划在场景里放置触发点, 点击之后唤醒奖励协议, 最后会在服务端终端看到数据落地情况
{
  "value": 601,
  "args": {
    "award": 61
  }
}

最常应用在状态更新就是 签到 功能, 玩家只需要登录或者定时器检测到跨天在线的玩家, 就会自动更新签到状态推送奖励了.

这里状态同步适用于简单的业务处理, 因为本身场景里面的奖励固定而且关卡消耗体力|疲劳值, 所以道具基本在策划配表控制之内.

状态同步仅仅只能做相对简单的业务, 因为复杂业务被触发是有大量的状态需要同步; 比如击杀敌人可能需要联动同步分数状态/敌人的血量状态/枪支子弹击中状态/击中特效部位状态等等, 在这个情况下就需要自己客户端提交这堆状态到服务端并转发到其他联网客户端, 所有的状态都要客户端来通知.

以塔防游戏为例, 按照状态同步就需要处理:

  1. 怪物推进位置需要按照时间不断提交服务端同步, 比如每秒怪物推进1格
  2. 怪物到达攻击范围内, 需要客户端同步告诉服务端目前怪物攻击了你
  3. 怪物脱离了攻击访问逃逸, 这时候又要告诉服务端怪物脱离攻击圈
  4. 最后游戏结束, 成功|失败需要客户端来告诉你从而同步奖励数据

可以看到, 实际服务端都需要客户端信息从而将游戏数据同步, 这种情况下服务端带有极大不确定性; 客户端并不一定完全可信, 可能通过 hacker 方式来手动编写状态同步给服务端从而达成作弊行为, 最简单的是伪造数据包告诉服务端怪物刚在地图冒头就到了可攻击的访问, 直接玩家在怪物还没出门口就能完全击杀全部怪物.

扩展性思考: 如果单靠状态同步的情况, 怎么把这个塔防游戏高效实现还不怕客户端数据篡改作弊.

帧同步

回过头玩家实时联网的需求发现了状态同步的方案问题颇多, 所以才引进了游戏帧同步概念.

其实这不是什么高深的技术, 而是把客户端常见的更新帧移动到服务端处理, 然后客户端只需要推送对应移动指令由服务端执行.

也因为状态都集中在服务端, 所以在多人游戏当中客户端只需要推送简单指令, 就可以主动推送不同玩家客户端, 而且数据集中服务端所以也不存在客户端直接修改游戏数据状态作弊的情况.

这里设计竞速游戏来测试服务端模拟客户端帧更新的情况:

/**
 * 竞速游戏 Actor
 */
@EnableActor(owner = RunLogic.class)
public class RunLogic extends ActorConfigurer {


    /**
     * 日志记录
     */
    final Logger logger = LoggerFactory.getLogger(RunLogic.class);


    /**
     * 线程安全的同步哈希表
     */
    final Map<Long, ScheduledFuture<?>> frames = new HashMap<>();


    /**
     * 暂停状态合集, 提供给玩家触发暂停游戏选项
     */
    final Map<Long, Boolean> pauses = new HashMap<>();

    /**
     * 退出游戏状态
     */
    final Map<Long, Boolean> cancel = new HashMap<>();


    /**
     * 这里设定该赛道长度, 超过长度表示到达终点可以结算并准备退出赛道场景了.
     */
    final static int length = 8000;

    /**
     * 目前玩家所在的位置
     */
    final Map<Long, Integer> positions = new HashMap<>();


    /**
     * 启动调用
     */
    @Override
    public void init() {

    }

    /**
     * 退出调用
     */
    @Override
    public void destroy() {

    }


    /**
     * 进入竞速场景
     * Example: { "value":700,"args":{}}
     *
     * @param runtime 运行时
     * @param session 会话
     * @param args    参数
     */
    @ActorMapping(value = 700, state = {LogicStatus.Authorized, LogicStatus.Gaming})
    public void enter(WebsocketApplication runtime, WebSocketSession session, JsonNode args) {
        // 获取玩家ID和切换成游戏中状态
        Long uid = runtime.getUid(session);
        runtime.setState(session, LogicStatus.Gaming);


        // 判断之前是否有没有需要取消的任务
        ScheduledFuture<?> task = frames.get(uid);
        if (task != null && !task.isCancelled()) {
            // todo: 取消并结算之前的帧任务并结算
            task.cancel(false);
        }


        // 初始化对应帧状态
        pauses.put(uid, false);
        cancel.put(uid, false);
        positions.put(uid, 0);


        // 进去竞速场景需要知道玩家速度并换算每帧调用移动到所在位置
        // 这里本来是玩家身上属性决定, 这里采用随机处理
        Random random = new Random();
        int increment = random.nextInt(10);
        long start = System.currentTimeMillis();


        // 创建帧同步更新
        // 注意这里同步帧参照 Unity3d 的更新帧数周期
        // Unity默认 update 方法默认每秒执行 60 次
        // 1s=1000ms, 折算 1000/60 ≈ 17ms 就需要调用1次
        // 注意这里仅仅做示范, 具体情况帧提交更新是需要优化
        long frameMill = 17L;
        long startMill = 1000L; // 一般需要等待触发时间, 这里设定为 1s 之后就开始起跑
        ActorEventContainer container = getContainer();
        frames.put(uid, container.scheduleAtFixedRate(() -> {
            try {
                update(runtime, uid, session, increment, start);
            } catch (IOException e) {
                logger.error(e.getMessage());
            }
        }, startMill, frameMill, TimeUnit.MILLISECONDS));
    }


    /**
     * 定时更新帧数函数
     *
     * @param runtime 运行时
     * @param uid     玩家ID
     * @param session 会话
     */
    private void update(WebsocketApplication runtime, Long uid, WebSocketSession session, int increment, long start) throws IOException {
        // 如果链接关闭直接算作退出游戏计算
        if (!session.isOpen() || cancel.get(uid)) {
            runtime.push(session, 703, new HashMap<>(1) {{
                put("message", "竞速失败");
            }});
            clear(uid);
            return;
        }

        // 暂停时候不需要执行逻辑
        if (pauses.get(uid)) {
            return;
        }


        // 游戏进行中
        Integer pos = positions.get(uid);
        pos += increment;
        positions.put(uid, pos);


        // 确定是否到达终点
        if (pos > length) {
            runtime.push(session, 704, new HashMap<>(3) {{
                put("message", "竞速完成");
                put("start", start); // 比赛起始时间戳
                put("end", System.currentTimeMillis());// 比赛结束时间戳
            }});
            clear(uid);
            return;
        }


        // 还在移动, 返回给客户端目前位置
        Integer finalPos = pos;

        runtime.push(session, 705, new HashMap<>(1) {{
            put("position", finalPos);
        }});
    }


    /**
     * 暂停游戏
     * Example: { "value":701,"args":{}}
     *
     * @param runtime 运行时
     * @param session 会话
     * @param args    参数
     */
    @ActorMapping(value = 701, state = {LogicStatus.Authorized, LogicStatus.Gaming})
    public void pause(WebsocketApplication runtime, WebSocketSession session, JsonNode args) {
        Long uid = runtime.getUid(session);
        if (pauses.containsKey(uid)) {
            // 如果暂停就切换继续游戏, 如果游戏中切换成暂停
            pauses.put(uid, !pauses.get(uid));
        }
    }


    /**
     * 退出游戏
     * Example: { "value":702,"args":{}}
     *
     * @param runtime 运行时
     * @param session 会话
     * @param args    参数
     */
    @ActorMapping(value = 702, state = {LogicStatus.Authorized, LogicStatus.Gaming})
    public void quit(WebsocketApplication runtime, WebSocketSession session, JsonNode args) {
        Long uid = runtime.getUid(session);
        if (cancel.containsKey(uid)) {
            cancel.put(uid, true);
        }
    }

    /**
     * 清空状态
     *
     * @param uid 玩家ID
     */
    private void clear(Long uid) {
        ScheduledFuture<?> task = frames.get(uid);
        if (task != null) {
            // 清空状态
            task.cancel(false);
            frames.remove(uid);
            cancel.remove(uid);
            pauses.remove(uid);
            positions.remove(uid);
        }
    }
}

之后在模拟进入关卡开始在服务端进行帧更新, 模拟竞速游戏流程:

// 先授权登录到游戏
{
  "value": 100,
  "args": {
    "secret": "8b35a90ed5fe7296c8af827e585ea873",
    "uid": 1
  }
}

// 开始竞速
{
  "value": 700,
  "args": {}
}

// 游戏暂停
{
  "value": 701,
  "args": {}
}

// 退出游戏
{
  "value": 702,
  "args": {}
}

这里就是简单的帧同步样例, 如果多人游戏则是需要管理同个场景推送给其他人这一帧的数据.

一般来说都会把多人联机会把大地图分为不同 Actor, 然后进入该地图之后推送给整片区域并且把所有人的帧数据同步.

可以想象之前篇章把 Actor 形容为 ‘城堡’, 这就是为了形成各自并行不干扰概念, 每个 Actor 都负责自己对应区域任务.

存档同步

这种方式早年比较多看到, 基本上就是把本地存档数据移交给服务端处理; 这里不需要太多赘述, 主要早年单机起家的公司习惯工作流尝试转型的, 如果项目运营下架的时候可以直接转化成 ‘离线版’ 直接做新版本卖, 而 DLC 附加在线存档初始化追加资源.