【quickhybrid】JS端的项目实现

2.4. 使用了什么游戏引擎/框架/库/技术

从思路上看的话,可以说使用了The-Best-JS-Game-Framework

最主要的,这个程序主要使用了co,使用co/yield/generator来改善异步开发的体验,让整个庞大的程序实现成为了可能——前言中说的去年的一次大重构就是干这个——这是一个非常重要的重构,过去的话一个异步的update/render
loop就可以让人抓狂,以至于我现在根本不想再写异步的JS了T_T,也许有机会我会再写一篇文章来介绍JS“同步”编程以及js-csp这个非常好玩的东西。但你知道co其实是一个非常非常简单的库,所以即使没有co的话,自己造一个堪堪一用的轮子也非常容易,所以想解除这个依赖是很简单的。

在这个坑之初,原生Promise还没普及,所以引入了q,但其实在整个项目中贯彻了co之后,很少用得着Promise,并且也可以很容易的向原生Promise迁移,当然因为懒我是没这么干的。

其他方面可以说几乎没有依赖第三方的库了,可能还有jQuery啊这类的东西,只是用了一丁丁点,非常容易解除依赖。

仙剑是一个很古老的游戏,使用现代游戏引擎重新实现仙剑的主程序并没有太直接的帮助。现代的2D游戏引擎围绕Sprite和场景管理为主,虽然在SDLPAL和h5pal中也有Sprite和场景模块,但具体到技术层面和现代游戏引擎里的还是差别比较大。再加上技(xīn)术(lǐ)洁(biàn)癖(tài)的原因,我没有用任何现代的游戏引擎,不过等到轮子造得差不多的时候,发现游戏引擎的思想果然是几十年没有太大变化……

由于音乐和音效系统彻底坑了(原因见后文),所以WebAudio暂时不涉及。图形方面只涉及到canvas
2D,并且因为仙剑本身的资源都是像素级的,所以图形这一层也基本上都是在getImageData/putImageData的层次直接操作像素,并没有使用任何canvas的绘图API。因此如果后续把绘图层迁移到WebGL也会很简单,不过目前看来完全没有这个必要。

h5pal使用GPLv3发布,我对开源协议几乎不懂,只知道GPL是比较严格的一种协议,而且SDLPAL是用GPLv3的,考虑到我抄了他很多代码,于是用了这个至少不比他宽松的协议,并且再次向SDLPAL表示敬意。

关于代码规范与单元测试

项目中采用的Airbnb代码规范并不是100%契合原版,而是基于项目的情况定制了下,但是总体上95%以上是符合的

还有一块就是单元测试,这是很容易忽视的一块,但是也挺难做好的。这个项目中,基于Karma + Mocha进行单元测试,而且并不是测试驱动,而是在确定好内容后,对核心部分的代码都进行单测。
内部对于API的调用基本都是靠JS来模拟,对于一些特殊的方法,还需Object.defineProperty(window.navigator, name, prop)来改变window本身的属性来模拟。
本项目中的核心代码已经达到了100%的代码覆盖率。

具体的代码这里不赘述,可以参考源码

2.1. 能玩吗?

。但在GitHub
repo里并不会包含游戏的资源文件,于是需要自己去找(嘿嘿mq2x)。由于不分发游戏资源文件,且考虑到体积,我也不会提供一个在线游玩的版本。所以基本上只有开发者或者动手能力强的同学才能玩上它了(如果你真的想玩……)

不考虑遇到BUG(无数个)造成游戏直接罢工的情况下(当然身为作者的我是可以驾轻就熟地避过这些BUG的233333),已经可以从新开游戏一直玩到大结局了,而且我已经通关两三遍了XD

代码架构

项目代中将核心代码和API实现代码分开,核心代码相当于一个处理引擎,而各个环境下的不同API实现可以单独挂载(这里是为了方便其它地方组合不同环境下的API所以才分开的,实际上可以将native和核心代码打包到一起)

quick.js
quick.h5.js
quick.native.js

这里需要注意,quick.xx环境.js中的代码是基于quick.js核心代码的(譬如里面需要用到一些特点的快速调用底层的方法)

而其中最核心的quick.js代码架构如下

index
    |- os               // 系统判断相关
    |- promise          // promise支持,这里并没有重新定义,而是判断环境中是否已经支持来决定是否支持
    |- error            // 统一错误处理
    |- proxy            // API的代理对象,内部对进行统一预处理,如默认参数,promise支持等
    |- jsbridge         // 与native环境下原生交互的桥梁
    |- callinner        // API的默认实现,如果是标准的API,可以不传入runcode,内部默认采用这个实现
    |- defineapi        // API的定义,API多平台支撑的关键,也约定着该如何拓展
    |- callnative       // 定义一个调用通用native环境API的方法,拓展组件API(自定义)时需要这个方法调用
    |- init             // 里面定义config,ready,error的使用
    |- innerUtil        // 给核心文件绑定一些内部工具类,供不同API实现中使用

可以看到,核心代码已经被切割成很小的单元了,虽然说最终打包起来总共代码也没有多少,但是为了维护性,简洁性,这种拆分还是很有必要的

2.3. 为什么需要仙剑的原版资源文件

出于上面所说的只实现主程序的出发点,并且出于技(xīn)术(lǐ)洁(biàn)癖(tài),我选择不对资源文件进行任何预处理。如果按照现代游戏引擎的方式,先把资源文件里的位图、Sprite、数据等资料都解开成更适合HTML5/JS所需要的结构化数据,整个开发也许会变得容易很多。

但那样就不好玩了

图片 1

因此最终我选择了保留SDLPAL的味道,不对资源文件进行任何的预处理,而是直接读取原始资源文件。当然因为完成度和工作量的原因我只能支持一个固定版本的资源文件,而SDLPAL则有更强的兼容性(甚至支持民间MOD仙剑梦幻版)。并且SDLPAL实现了半即时制战斗的创新,我个人不太喜欢,也没有迁移这个。

项目的结构

在最初的版本中,其实整个前端库就只有一个文件,里面只规定着如何实现JSBridge和原生交互部分。但是到最新的版本中,由于功能逐步增加,单一文件难以满足要求和维护,因此重构成了一整个项目。

整个项目基于ES6Airbnb代码规范,使用gulp + rollup构建,部分重要代码进行了Karma + Mocha单元测试

整体目录结构如下:

quickhybrid
    |- dist             // 发布目录
    |   |- quick.js
    |   |- quick.h5.js
    |- build            // 构建项目的相关代码
    |   |- gulpfile.js
    |   |- rollupbuild.js
    |- src              // 核心源码
    |   |- api          // 各个环境下的api实现 
    |   |   |- h5       // h5下的api
    |   |   |- native   // quick下的api
    |   |- core         // 核心控制
    |   |   |- ...      // 将核心代码切割为多个文件
    |   |- inner        // 内部用到的代码
    |   |- util         // 用到的工具类
    |- test             // 单元测试相关
    |   |- unit         
    |   |   |- karma.xxx.config.js
    |   |- xxx.spec.js
    |   |- ...

图片 2

2. 自问自答的FAQ

API内部做了些什么

API内部只做与本身功能逻辑相关的操作,这里有几个示例

quick.extendModule('ui', [{
    namespace: 'toast',
    os: ['h5'],
    defaultParams: {
        message: '',
    },
    runCode(...rest) {
        // 兼容字符串形式
        const args = innerUtil.compatibleStringParamsToObject.call(this, rest, 'message', );
        const options = args[0];
        const resolve = args[1];

        // 实际的toast实现
        toast(options);
        options.success && options.success();
        resolve && resolve();
    },
}, ...]);

quick.extendModule('ui', [{
    namespace: 'toast',
    os: ['quick'],
    defaultParams: {
        message: '',
    },
    runCode(...rest) {
        // 兼容字符串形式
        const args = innerUtil.compatibleStringParamsToObject.call(this, rest, 'message');

        quick.callInner.apply(this, args);
    },
}, ...]);

以上是toast功能在h5和quick环境下的实现,其中,在quick环境下唯一做的就是兼容了一个字符串形式的调用,在h5环境下则是完全的实现了h5下对应的功能(promise也需自行兼容)

为什么h5中更复杂?因为quick环境中,只需要拼凑成一个JSBridge命令发送给原生即可,具体功能由原生实现,而h5的实现是需要自己完全实现的。

另外,其实在quick环境中,上述还不是最少的代码(上述加了一个兼容调用功能,所以多了几行),最少代码如下

quick.extendModule('ui', [{
    namespace: 'confirm',
    os: ['quick'],
    defaultParams: {
        title: '',
        message: '',
        buttonLabels: ['取消', '确定'],
    },
}, ...]);

可以看到,只要是符合标准的API定义,在quick环境下的实现只需要定义些默认参数就可以了,其它的框架自动帮助实现了(同样promise的实现也在内部默认处理掉了)

这样以来,就算是标准quick环境下的API数量多,实际上增加的代码也并不多。

2.7. 现在看起来都是dev状态,什么时候会成为成品游戏?

也许永远不会,因为没动力再把各种BUG还有音频部分的坑填了……

如果有生之年真的能填,那么也许可以用node-webkit这类的东西打包成成品游戏,不过……有意思么……

JSBridge解析规则

前面的文章中有提到JSBridge的实现,但那时其实更多的是关注原理层面,那么实际上,定义的交互解析规则是什么样的呢?如下

// 以ui.toast实际调用的示例
// `${CUSTOM_PROTOCOL_SCHEME}://${module}:${callbackId}/${method}?${params}`
const uri = 'QuickHybridJSBridge://ui:9527/toast?{"message":"hello"}';

if (os.quick) {
    // 依赖于os判断
    if (os.ios) {
        // ios采用
        window.webkit.messageHandlers.WKWebViewJavascriptBridge.postMessage(uri);
    } else {
        window.top.prompt(uri, '');
    }
} else {
    // 浏览器
    warn(`浏览器中jsbridge无效, 对应scheme: ${uri}`);
}

原生容器中接收到对于的uri后反解析即可知道调用了些什么,上述中:

  • QuickHybridJSBridge是本框架交互的scheme标识

  • modulemethod分别代表API的模块名和方法名

  • params是对于方法传递的额外参数,原生容器会解析成JSONObject

  • callbackId是本次API调用在H5端的回调id,原生容器执行完后,通知H5时会传递回调id,然后H5端找到对应的回调函数并执行

为什么要用uri的方式,因为这种方式可以兼容以前的scheme方式,如果方案切换,变动代价下(本身就是这样升级上来的,所以没有替换的必要)

2.5. 为什么没实现音乐/音效部分,不是有Audio和WebAudio了吗?

音效部分仙剑用的是voc格式,这个格式太古老了以至于Audio和WebAudio都不可能直接支持它。为了不对资源文件做预处理的原则,在这里就让它坑了。

音乐部分仙剑用的是MIDI,目前在Web里有MIDI.js可以处理(P.S.这个项目相当之屌!)。但是懂MIDI的人都知道,MIDI格式本身并不复杂,难的在于实现音色库。这样一来会引入很大一堆东西,甚至上百MB的音色库,这非常不现实,所以我选择先(forever)把它坑了。

源码

github上这个框架的实现

quickhybrid/quickhybrid

0. 前言

这是一个坑了太久太久的项目,久到我已经不记得挖这个坑是什么时候了。大概是13年的夏天吧,我挖了这个坑,然后信心满满的在当年十一长假宅了N天(我还比较清楚的记得那时候正是WOW开荒围攻奥格瑞玛副本的阶段),写下了整个框架,以及最核心的一部分代码,然后,就没有然后了。

大概一年后,我又翻出来了这个坑,重构了大量的代码,但是进度几乎没有实质性的进步,甚至因为重构而有所倒退-
-“,不过因为读了《游戏引擎架构》这本书,我对这个坑又有了新的认识,对于这个程序到底要怎么写心里有谱多了。

本来计划是在今年夏天搞出来,这样可以赶上仙剑20周年(1995年7月)发布,不过不用想也知道毫无疑问是继续坑了。

磕磕绊绊到如今,总算是把游戏的总体完成度拉到了一个比较能见人的程度,于是我觉得还是赶紧发布的好,免得又变有生之年了。

UA约定

混合开发容器中,需要有一个UA标识位来判断当前系统。

这里Android和iOS原生容器统一在webview中加上如下UA标识(也就是说,如果容器UA中有这个标识位,就代表是quick环境-这也是os判断的实现原理)

String ua = webview.getSettings().getUserAgentString();

ua += " QuickHybridJs/" + getVersion();

// 设置浏览器UA,JS端通过UA判断是否属于quick环境
webview.getSettings().setUserAgentString(ua);

// 获取默认UA
NSString *defaultUA = [[UIWebView new] stringByEvaluatingJavaScriptFromString:@"navigator.userAgent"];

NSString *version = [[NSBundle mainBundle].infoDictionary objectForKey:@"CFBundleShortVersionString"];

NSString *customerUA = [defaultUA stringByAppendingString:[NSString stringWithFormat:@" QuickHybridJs/%@", version]];

[[NSUserDefaults standardUserDefaults] registerDefaults:@{@"UserAgent":customerUA}];

如上述代码中分别在Android和iOS容器的UA中添加关键性的标识位。

发表评论

电子邮件地址不会被公开。 必填项已用*标注