MeteorCat / JS-SDK设计

Created Sat, 19 Jul 2025 01:18:29 +0800 Modified Wed, 29 Oct 2025 23:24:53 +0800
4011 Words

对于H5游戏发行来说, 除了服务端接口对接之外, 还需要暴露给游戏客户端JS库来引入, 主要流程如下:

  1. H5 游戏客户端拿到 h5.js 引入到游戏项目的页面之中
  2. 客户端需要接入对应发行实现接口, 目前没办法直接调用到那些功能
  3. 需要要求CP搭建好测试网站, 用来渲染游戏H5游戏页面
  4. 需要在发行方把CP搭建好和接入好SDK的H5游戏地址填写到后台配置游戏地址字段里面
  5. 发行方会生成游戏容器地址, 内部采用 iframe 渲染CP的游戏界面
  6. 对于客户端的调用功能实际上采用 PostMessage 跨页面推送所有事件

这就是 H5 在发行当中的流程, 那么我们首先要准备我们自己的发行游戏容器入口网站, 还有自己编写 JS-SDK 库文件:

  • container.html: 发行的宿主网站, 也就是提供 iframe 功能的页面
  • game.html: CP游戏研发搭建并且接入我们发行的H5游戏主站
  • h5-sdk.js: 我们自己发行的JS功能集成库

注意: container 内部涉及到很多 iframe 调用机制和超时监听机制

简单用页面来梳理的话, 大概内部的结构如下:


<html>
<body>
<!-- 授权容器: 作为发行方首先需要渲染的容器, 提供常用的注册授权机制 -->
<div id="authority-container">
    <!-- 内部不采用 iframe, 直接渲染页面即可 -->
</div>

<!-- 页面浮动的角标工具栏容器, 登录完成提供负责账号切换和退出的边栏窗口功能 -->
<div id="toolbar-container">
    <!-- 内部不采用 iframe, 直接渲染页面即可 -->
</div>

<!-- OAuth容器: 刚进来默认隐藏的第三方OAuth页面容器 -->
<div id="oauth-container">
    <iframe id="game-container-iframe"
            src="oauth.html"></iframe>
</div>

<!-- 游戏容器: 刚进来默认隐藏的游戏页面容器 -->
<div id="game-container">
    <iframe id="game-container-iframe"
            src="game.html"></iframe>
</div>

<!-- 调用第三方支付的渲染容器: 第三方支付完成的跳转地址设为我们自己内部的页面 -->
<!-- 通过 postMessage 机制反向回调告诉我们订单完成可以关闭窗口 -->
<div id="pay-container">
    <iframe id="pay-container-iframe"
            src="wx.html"></iframe>
</div>


</body>
</html>

这里就是作为游戏容器需要负责处理的相关子容器功能, 主要核心还是在于 PostMessage 上, 我们需要做的就是负责搭建这方面通信桥梁:

const $GameURL = 'https://game.meteorcat.com/1001'; // H5游戏CP方启动页面地址
const $GameContainer = document.querySelector('#game-container'); // 游戏容器对象
window.addEventListener('load', function () {

    // 页面初始化的时候就要开始监听登录成功回调
    // 这里采用 login-ok-btn 按钮来说明玩家在页面中登录成功
    document.querySelector('#login-ok-btn').addEventListener('click', function () {

        // 这里模拟用户已经登录成功, 获取到会话凭证等信息
        const status = 200;
        const message = "登录成功";
        const data = {
            appid: "1001", // 游戏应用ID 
            uid: "10001", // 发行注册的玩家UID
            username: "meteorcat", // 发行注册的玩家账号
            channel: "1004", // 发行绑定的渠道ID, 0 = 代表直接官服非渠道分发
            token: "d56470b85aba4223414feec62a8b8f94", // 本次登录的授权
            time: 1752725096, // 秒级时间戳
            sdk_uid: "wx_xxxxxxxxxxxxxx", // 第三方返回账号唯一标识
            sdk_username: "xyz", // 第三方返回账号名标识
            new_account: 1, // 是否为一键完成注册的用户
            extra: {}, // 一些额外的扩展参数, 
        };

        // todo: 把授权登录的 authority-container 窗口隐藏掉

        // 把游戏 iframe 替换游戏地址并显示出来
        const $GameContainerFrame = $GameContainer.querySelector('#game-container-iframe');
        $GameContainerFrame.src = $GameURL;

        // 绑定渲染之后的回调事件, 当游戏容器执行完成之后才推送登录完成事件给游戏CP方
        $GameContainerFrame.addEventListener('load', function () {

            // 这里推送的是结构体类型, 后续CP那边SDK负责接收并且检验是否为该对象结构
            // 可以考虑把授权完成之后的数据结构原样放回
            $GameContainerFrame.contentWindow.postMessage({
                event: 'SDK_LOGIN',
                status: status,
                message: message,
                data: data,
            })
        });
    });


    // 在容器页面也要监听 iframe 传递过来的消息
    // CP方主动推送过来的消息一般都是只有数据上报和发起支付
    window.addEventListener('message', function (eventMessage) {
        const eventData = eventMessage.data;
        if (
            eventData !== null && typeof eventData === 'object'
            && eventData.hasOwnProperty('event')
            && eventData.hasOwnProperty('data')
        ) {
            const {event, data} = eventData;
            console.log("sdk event", event);
            console.log("sdk data", data);
        }
    });
});

需要注意脚本语言大部分都有 BigInt 问题, 所以对于要评估好未来某个数值可能很大的情况必须采用 string 而非 number

而作为游戏CP方来说, 只需要很简单初始化一下并且设置回调和触发功能就行:

<!-- 引入渠道发布的远程JS -->
<!-- 不要下载到本地引用, 会导致无法正确更新JS-SDK版本 -->
<script src="https://game.meteorcat.com/static/sdk.js"></script>
<script>
    window.addEventListener('load', function () {

        /**
         * 绑定初始化
         */
        MySDK.init(1001, "testkey", function (response) {
            if (response.status === 200) {
                // 一般内部都携带会话uid,token和渠道ID
                const {channel, uid, token} = response.data;
                MySDK.message("init", response);
            } else {
                MySDK.message("init", response.message);
            }
        });


        /**
         * 绑定登录事件
         */
        MySDK.login(function (response) {
            if (response.status === 200) {
                MySDK.message("login", MySDK.session('uid'));
            } else {
                MySDK.message("login", response);
            }
        });

        /**
         * 绑定注销事件
         */
        MySDK.logout(function () {
            if (response.status === 200) {
                MySDK.message("logout", "退出成功");
            } else {
                MySDK.message("logout", response.message);
            }
        });


        /**
         * 发起支付
         * 支付是主动触发 - 异步回调的功能
         */
        document.querySelector('#sdk-pay-btn').addEventListener('click', function () {
            MySDK.pay({
                sid: "110",
                sname: "测试服务器110",
                role_id: "100001",
                role_name: "测试角色1",
                role_level: "1",
                item_id: "1001",
                item_name: "钻石 * 20",
                item_count: "20",
                item_price: 1,
                currency: "CNY",
                country: "CN",
                notify: "", // 不走平台默认的回调地址而采用自定义回调的地址
            }, function (response) {
                if (response.status === 200) {
                    MySDK.message('pay', response);
                } else {
                    MySDK.message('pay', response);
                }
            });
        });


        /**
         * 发起上报
         * 上报是主动触发 - 异步回调的功能
         */
        document.querySelector('#sdk-report-btn').addEventListener('click', function () {
            MySDK.report({
                action: 1, // 假设目前玩家处于选择服务器之后上报数据
                sid: "1", // 玩家选中服务器ID
                sname: "测试一服", // 玩家选中的服务器名称

                // 注: 玩家选服的时候是没有角色信息的, 所以有的必填字段要留空, 非必填则直接不需要填写
                role_id: "", // 玩家在服务器当中的角色ID
                role_name: "", // 玩家在服务器当中的角色名称
                role_vip: "", // 玩家在服务器当中的角色VIP等级
                role_money: "",// 玩家在服务器当中的角色游戏币数量
                role_gender: 0, // 玩家在服务器当中的角色性别
                role_power: "", // 玩家在服务器当中的角色战力
                role_level: "", // 玩家的等级信息, 这里有个坑点就是不要假定等级是数值, 有的渠道等级是特殊标识
                role_create_time: 0, // 玩家角色创建时间
                role_level_up_time: 0, // 玩家角色等级变动事件


                // 以下字段就是属于可传可不传的对象, 且长度不能超过64位字符
                profession_id: "", // 角色职位ID
                profession_name: "", // 角色职位名称
                guild_id: "", // 角色所在工会/帮派ID
                guild_name: "", // 角色所在工会/帮派名称
                guild_master_id: "", // 角色所在工会/帮派的最高管理者ID
                guild_master_name: "", // 角色所在工会/帮派的最高管理者名称
            }, function (response) {
                // 上报接口都是高并发问题大户, 所以响应结果都比较简单
                if (response.status === 200) {
                    MySDK.message('report', response);
                } else {
                    MySDK.message('report', response);
                }
            });
        });

    });
</script>

注意: 客户端只是把需要的参数发给我们上级的容器监控者, 之后在我们的页面之中进行签名发送给游戏SDK服务器.

游戏CP方只需要把数据传入, 内部就会通过 PostMessage 提交到我们的容器之中, 至此CP方的联调工作流程就完成了, 剩下就是作为发行方需要关心的界面角标和浮窗支付等处理问题.

游戏容器

目前CP方面的工作已经完成, 我们就需要重新审视下作为发行方在登录之后 toolbar-container 需要提供的功能:

  • 退出游戏(必须): 用于注销登录会话凭证
  • 切换账号(非必须): 高级功能, 用于提供多账号管理功能, 大部分情况下和退出游戏功能一致, 可以和退出游戏二选一
  • 修改密码(必须): 用于修改账号密码并且注销掉当前游戏凭证
  • 绑定邮箱(非必须): 海外比较多采用邮箱机制而非国内常用的的手机验证码绑定
  • 绑定手机(非必须): 国内比较多采用的手机关联账号体系
  • 修改头像(非必须): 有时候需要构建比较大的账号体系, 会有头像和昵称等修改信息, 实际上不一定会用到
  • 修改昵称(非必须): 同上

不过在以上功能实现之前, 首先要实现角标的功能; 所谓的角标就是可以屏幕拖拽移动的小图标, 并且点击时候还有侧边菜单弹出, 这里先在页面设计个图标:

<!doctype html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport"
          content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
    <meta http-equiv="X-UA-Compatible"
          content="ie=edge">
    <title>
        角标设计</title>


    <style>
        /* 角标浮动化 */
        #toolbar-container-icon {
            position: absolute;
            right: 0; /* 右撇子比较多, 所以建议角标采用靠右设计 */
            top: 80vh; /* 角标按比例偏下位置 */
            cursor: pointer;
            z-index: 998;

            /* 初始化透明度需要自定义 */
            opacity: 0.5;

            /* 必须赋予强制宽高比 */
            width: 50px;
            height: 50px;
        }
    </style>
</head>
<body style="background-color: #0a0a0a">


<!-- 页面浮动的角标工具栏容器, 登录完成提供负责账号切换和退出的边栏窗口功能 -->
<div id="toolbar-container">

    <!-- 随便找个图片, 需要宽高保持比例 -->
    <div id="toolbar-container-icon">
        <img src="./logo.png"
             alt="sdk icon"
             style="width: 50px;height:50px;">
    </div>

</div>

<script>
    const ToolbarContainer = document.querySelector('#toolbar-container');
    const ToolbarContainerIcon = ToolbarContainer.querySelector('#toolbar-container-icon');


    /**
     * 记录全局角标坐标信息
     * @type {{StartX: number, StartY: number, StartTop: number, MoveX: number, MoveY: number, StartLeft: number}}
     */
    let ToolbarContainerIconPosition = {
        StartX: 0, // 起始X轴坐标
        StartY: 0, // 起始Y轴坐标
        StartLeft: 0, // 角标左边距
        StartTop: 0, // 角标上边距

        MoveX: 0, // 移动X轴坐标
        MoveY: 0, // 移动Y轴坐标
    }


    /**
     * 角标位移更新
     */
    function ToolbarContainerUpdate() {

        /**
         * 角标动画定时器
         * @type {null|number}
         */
        let ToolbarContainerIconTimer = null;

        /**
         * 角标待机定时器
         * @param width
         * @param sec
         */
        function ToolbarContainerIconCreateTimer(width, sec) {
            clearInterval(ToolbarContainerIconTimer);
            ToolbarContainerIconTimer = setTimeout(() => {
                ToolbarContainerIcon.style.left = width;
                ToolbarContainerIcon.style.opacity = 0.5;
            }, sec);
        }


        /**
         * 注册移动事件
         */
        ToolbarContainerIcon.addEventListener('touchmove', function (e) {
            clearInterval(ToolbarContainerIconTimer);
            e.preventDefault();

            // 移动的偏移值
            ToolbarContainerIconPosition.MoveX = e.touches[0].pageX;
            ToolbarContainerIconPosition.MoveY = e.touches[0].pageY;

            // 计算角标的左边距 = 当前触摸点 - 鼠标起始点击位置 + 起始左边距
            const left =
                    ToolbarContainerIconPosition.MoveX -
                    ToolbarContainerIconPosition.StartX +
                    ToolbarContainerIconPosition.StartLeft;
            ToolbarContainerIcon.style.left = `${left}px`;

            // 计算角标的上边距 = 当前触摸点 - 鼠标起始点击位置 + 起始上边距
            const top =
                    ToolbarContainerIconPosition.MoveY -
                    ToolbarContainerIconPosition.StartY +
                    ToolbarContainerIconPosition.StartTop;
            ToolbarContainerIcon.style.top = `${top}px`;
        });

        /**
         * 注册松手事件
         */
        ToolbarContainerIcon.addEventListener('touchend', function (e) {
            clearInterval(ToolbarContainerIconTimer);
            ToolbarContainerIcon.style.transition = 'all .2s';

            // 不要设置以下配置, 会干扰到正常点击事件的冒泡弹出
            //e.preventDefault();


            // 获取屏幕宽高
            const bodyWidth = window.innerWidth / 2; // 屏幕宽一半
            const bodyHeight = window.innerHeight; // 屏幕高

            // 获取角标宽高
            const rect = ToolbarContainerIcon.getBoundingClientRect();
            console.log(rect);


            // 计算松开的位置
            const posX = e.changedTouches[0].pageX; // 松开的X位置
            const posY = e.changedTouches[0].pageY; // 松开的Y位置

            // 确认被移动到左边屏幕还是右边屏幕
            if (posX < bodyWidth) {
                // 左边屏幕
                ToolbarContainerIcon.style.left = "0px";
                ToolbarContainerIconCreateTimer(-rect.width / 2, 2500);
            } else if (posX >= bodyWidth) {
                // 右边屏幕
                const offset = window.innerWidth - rect.width;
                ToolbarContainerIcon.style.left = `${offset}px`;
                ToolbarContainerIconCreateTimer(offset / 2, 2500);
            }


            // 当移动到顶部时候需要保持高度贴边
            if (rect.top < 0) {
                ToolbarContainerIcon.style.top = "0px";
            }


            // 当移动到底部时候需要保持高度
            if ((bodyHeight - posY) < (rect.width / 2)) {
                const offset = bodyHeight - rect.width;
                ToolbarContainerIcon.style.top = `${offset}px`;
            }

        });

        // 注册按下事件
        ToolbarContainer.addEventListener('touchstart', function (e) {
            clearInterval(ToolbarContainerIconTimer);
            // 不要设置以下配置, 会干扰到正常点击事件的冒泡弹出
            // e.preventDefault();


            // 按下的时候需要让图标不再透明
            ToolbarContainerIcon.style.transition = 'none';
            ToolbarContainerIcon.style.opacity = '1';

            // 记录目前角标的起始XY位置
            ToolbarContainerIconPosition.StartX = e.touches[0].pageX;
            ToolbarContainerIconPosition.StartY = e.touches[0].pageY;

            // 记录目前角标的起始边距位置
            ToolbarContainerIconPosition.StartLeft = ToolbarContainerIcon.offsetLeft;
            ToolbarContainerIconPosition.StartTop = ToolbarContainerIcon.offsetTop;
        });
    }

    window.addEventListener('load', function () {
        console.log("rendered");

        // 注册更新角标
        ToolbarContainerUpdate();

        // 剩下就是点击角标点击触发事件
        ToolbarContainerIcon.addEventListener('click', function () {
            console.log("打开发行商提供的工具栏目");
        });

    });
</script>

</body>
</html>

另外需要说明的是 touchXXX 相关事件只有在移动端才会生效的API, 对于桌面PC端最好直接渲染在默认位置不动即可; 因为桌面端宽高比本身不缺这点 50px 空间, 所以直接固定位置让用户自己点击触发弹窗即可.

侧边栏需要按照前端工程项目的 UI 框架来触发即可, 唯一需要注意的点就是 z-index 图层优先级防止被二级菜单被挡住.

支付渲染

H5 支付是问题高发地, 需要涉及到委托监听和 iframe 通知, 唤起支付的流程如下:

  • CP 确认接入功能已经完成, 可以进行正常支付
  • 游戏唤起支付功能, 向上级游戏容器转发支付参数
  • 渠道接收到支付参数向SDK服务器推送请求预下单
  • SDK服务器返回支付参数, 这里就是问题所在, 有的支付是返回 form 内容, 有的返回 scheme 链接
  • 绝对要保持游戏界面不刷新, 通过 iframe 渲染出支付界面, 还是直接弹窗让用户手动点击 a 标签外部跳转等
  • 一般H5支付界面有个设置是支付完成跳转地址 redirect_url 用来确认支付完成(注: 不是支付成功)
  • 这个地址需要跳转到发行服务器的指定页面, 让页面渲染完成通过 PostMessage 推送到上级容器告诉订单完成

这就是支付的总体流程, 里面还涉及到海外支付的问题, 所以也就更加繁琐:

  • html: 有的第三方支付考虑到这种情况会默认弹出渲染界面的节点, 让玩家去选择支付方式之后利用a标签另外启动支付窗口
  • form: 有的第三方直接返回 form 节点内容, 后续的按钮式 submit 直接让整个网页发出 POST 请求
  • scheme: 私有的APP唤起链接, 也是需要直接点击另开页面来触发

所以综合上来, SDK 预下单最后统一返回保存的订单URL地址, 然后游戏容器弹窗询问是否确认支付, 这个支付链接是由 a 标签实际第三方支付URL, 点击默认另开窗口弹出支付页面, 从而避免影响当前的网页内容.