2.2. 这是什么程度的移植?

原汁原味移植。h5pal从SDLPAL里平移(就是抄啦)了大量的代码。SDLPAL是一个基于SDL的跨平台版仙剑,它已经能顺利的运行在Windows、Linux、OS
X、Symbian、PSP、Android等很多种平台上面。

h5pal与SDLPAL有着相同的出发点,就是实现仙剑的主程序,你只需要有仙剑的资源文件就可以运行整个游戏。

代码架构

项目代中将核心代码和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.6. 为什么没有实现存档?

其实是实现了(隐藏功能哦),但因为存档到资源文件的话,需要向服务端POST,这样需要CGI支持了,麻烦……然后我为了方便自己玩就用了很猥琐的办法实现(其实还是堪堪一用的)。

项目的结构

在最初的版本中,其实整个前端库就只有一个文件,里面只规定着如何实现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
    |   |- ...

威尼斯人线上娱乐 1

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

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

但那样就不好玩了

威尼斯人线上娱乐 2

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

前言

API实现阶段之JS端的实现,重点描述这个项目的JS端都有些什么内容,是如何实现的。

不同于一般混合框架的只包含JSBridge部分的前端实现,本框架的前端实现包括JSBridge部分、多平台支持,统一预处理等等。

2.1. 能玩吗?

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

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

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中添加关键性的标识位。

2. 自问自答的FAQ

统一的预处理

在上一篇API多平台的支撑中有提到如何基于Object.defineProperty实现一个支持多平台调用的API,实现起来的API大致是这样子的

Object.defineProperty(apiParent, apiName, {
    configurable: true,
    enumerable: true,
    get: function proxyGetter() {
        // 确保get得到的函数一定是能执行的
        const nameSpaceApi = proxysApis[finalNameSpace];

        // 得到当前是哪一个环境,获得对应环境下的代理对象
        return nameSpaceApi[getCurrProxyApiOs(quick.os)] || nameSpaceApi.h5;
    },
    set: function proxySetter() {
        alert('不允许修改quick API');
    },
});

...

quick.extendModule('ui', [{
    namespace: 'alert',
    os: ['h5'],
    defaultParams: {
        message: '',
    },
    runCode(message) {
        alert('h5-' + message);
    },
}]);

其中nameSpaceApi.h5的值是api.runCode,也就是说直接执行runCode(...)中的代码

仅仅这样是不够的,我们需要对调用方法的输入等做统一预处理,因此在这里,我们基于实际的情况,在此基础上进一步完善,加上统一预处理机制,也就是

const newProxy = new Proxy(api, apiRuncode);

Object.defineProperty(apiParent, apiName, {
    ...
    get: function proxyGetter() {
        ...
        return newProxy.walk();
    }
});

我们将新的运行代码变为一个代理对象Proxy,代理api.runCode,然后在get时返回代理过后的实际方法(.walk()方法代表代理对象内部会进行一次统一的预处理)

代理对象的代码如下

function Proxy(api, callback) {
    this.api = api;
    this.callback = callback;
}

Proxy.prototype.walk = function walk() {
    // 实时获取promise
    const Promise = hybridJs.getPromise();

    // 返回一个闭包函数
    return (...rest) = >{
        let args = rest;

        args[0] = args[0] || {};
        // 默认参数的处理
        if (this.api.defaultParams && (args[0] instanceof Object)) {
            Object.keys(this.api.defaultParams).forEach((item) = >{
                if (args[0][item] === undefined) {
                    args[0][item] = this.api.defaultParams[item];
                }
            });
        }

        // 决定是否使用Promise
        let finallyCallback;

        if (this.callback) {
            // 将this指针修正为proxy内部,方便直接使用一些api关键参数
            finallyCallback = this.callback;
        }

        if (Promise) {
            return finallyCallback && new Promise((resolve, reject) = >{
                // 拓展 args
                args = args.concat([resolve, reject]);
                finallyCallback.apply(this, args);
            });
        }

        return finallyCallback && finallyCallback.apply(this, args);
    };
};

从源码中可以看到,这个代理对象统一预处理了两件事情:

  • 1.对于合法的输入参数,进行默认参数的匹配

  • 2.如果环境中支持Promise,那么返回Promise对象并且参数的最后加上resolvereject

而且,后续如果有新的统一预处理(调用API前的预处理),只需在这个代理对象的这个方法中增加即可

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

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

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

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数量多,实际上增加的代码也并不多。

3. 后记

(呃,这个真的是流水账了,可能就长了)

其实一开始让我发布h5pal的时候,我是拒绝的。因为我只想把它当做一个情怀的玩具,烂在自己的硬盘里面算了。而且心理洁癖造成我觉得没完成的东西就不要发布了吧。后来在@licstar的鞭策之下一点点推进,断断续续改了很多没头绪的BUG。突然有一天似乎流程能走通了(那时候还没实现战斗),而他竟然磕磕绊绊的就玩到通关了,我特么真是惊了,瞬间有种拨云见日的感觉。

我知道即使发布了也估计没有人会用这个版本来玩,不过如标题所说,情怀之作。今年的仙剑6让很多玩家非常失望,而身为老仙剑迷的我其实从4代过后就已经弃坑了。尽管如此,我一直都认为如果想做一名合格的RPG玩家,从游戏评论的角度出发的话,仙剑1一定是必玩之作,因为在那个时候它是中文RPG游戏当中能和同期日系RPG有一战的一作,代表了当年RPG的最高水平,可以称为游戏发展史上的一个标志。选择仙剑很大一部分原因当然是有SDLPAL这个现成的对象可以抄,不过情怀满分这一点也是其他游戏不可取代的。

我是一名游戏爱好者,也一直想着能做游戏,并且是想做出版级的“大”游戏。不过因为各种原因,似乎离这个目标越来越远了。其实游戏是一个非常大也非常复杂的软件工程,甚至有人说游戏是软件工程当中最难的一个分支。我一直非常佩服各种3A大厂,能够集结上千人,几千万美元的资金做出一部部牛逼的作品(每打通一个游戏我都要把制作群字幕看完),也非常佩服各路独立游戏神人,能在那么有限的资源下做出精彩的作品。虽然仙剑不是新IP,我想我也不太有可能做新IP,甚至说没有SDLPAL和PalResearch的基础的话也不可能做出h5pal,不过这也已经在很大程度上满足了我做游戏的梦想吧,能做到现在这个程度我还是很开心的。

至于为什么是用HTML5/JS来实现呢?首先我本职是做前端的,对JS是非常熟悉,也可以当练手用呗(虽然整个h5pal的JS代码几乎没有任何技术难度可言吧……)其次就是因为SDLPAL本身已经做到跨很多很多平台了,惟独web这个炙手可热的平台还是个空缺。我在网上也没有找到仙剑1的完整web移植。另一方面,因为有别的一些老游戏的web移植中有很多(比如Diablo、星际)只是伪移植,也就是用原版游戏资源解包以后在web上做一个demo,根本没法玩的,这一点坚定了我做完整移植和资源文件不进行预处理的目标。

最大的遗憾也是留下了音频这个无底天坑,因为仙剑1的经典的配乐很得人心,没有音乐的伴随,即使体验剧情也会觉得少了太多味道,可惜可惜。

h5pal里面实现了一个用来读取C结构体指针的库,C里面通过指针转换,从文件里读取一段字节直接“铺开内存”就能转成一个结构体,这一点非常好用。这个JS库能把ArrayBuffer直接转成JS对象,利用getter/setter可以把对字段的操作落在ArrayBuffer(JS里的字节数组)上,这样一来还可以让不同对象共享内存(比如实现一个union什么的),在h5pal里是一个很核心的库了(重构的时候也是血虐啊)。我觉得还挺方便的,也许用在nodejs里的话实现一些native互访以及网络协议的时候会用得着吧。以后有时间的话可能会考虑把它重构一下,API弄弄更易用了单独发布一个库吧(有生之年

最后感谢@licstar的鞭策(催)和积极的帮忙测试,如果不是这么催的话估计早就烂硬盘里了。

最后的最后,我才发现仙剑里的女生都很积极主动啊,有的地方甚至还挺毁三观的……

1 赞 收藏 1
评论

威尼斯人线上娱乐 3

关于代码规范与单元测试

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

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

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

网站地图xml地图