对于H5游戏发行来说, 除了服务端接口对接之外, 还需要暴露给游戏客户端JS库来引入, 主要流程如下:
H5游戏客户端拿到h5.js引入到游戏项目的页面之中- 客户端需要接入对应发行实现接口, 目前没办法直接调用到那些功能
- 需要要求CP搭建好测试网站, 用来渲染游戏H5游戏页面
- 需要在发行方把CP搭建好和接入好SDK的H5游戏地址填写到后台配置游戏地址字段里面
- 发行方会生成游戏容器地址, 内部采用 iframe 渲染CP的游戏界面
- 对于客户端的调用功能实际上采用
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, 点击默认另开窗口弹出支付页面, 从而避免影响当前的网页内容.