Weex 开发小游戏是件很 high 的事儿

3,653 阅读11分钟

前言(废话)

自上一篇 Weex 体验文章《网易严选 App 感受 Weex 开发》发布以来,朋友们的反馈还是不错的,github 也意外得到了400+的 star,18%(有实践精神)的朋友们选择了 fork 下来试一把。

从数据上看,大家对 Weex 的热情还是有的。但是,在同朋友们沟通的过程中我发现,Weex社区目前并不是很活跃,导致初学者无从取经,很多人也止步于此。依赖性强的朋友就需要动用各种关系网来找到 Weex 的开发团队,耗费了太多的时间。希望@Weex 团队后期能发力推动社区的健壮发展。

前言(真)

作为一个移动端初学者、爱好者,能使用前端技术开发原生游戏一直是一件渴望而不可及的事情,暂且不说游戏逻辑的复杂度,算法的健壮性,单单是场景、画布、布局就让我们无处下手。

几年前曾经参与 Appcan 技术的技术孵化和推广,尝试使用 Hybrid 技术写过一个小游戏,《Hybrid混合实现app小游戏》,由于此游戏结构场景比较简单,所以未使用大型的游戏引擎,Cocos2d-x游戏引擎,所有逻辑全部手工。同样也是可「三端同构」,但本质上还是一个 H5小游戏,只是在真机上,执行环境是一个 UIWebview,所以,H5可以做的,他都可以做,H5不能做到,他未必不能做,如摄像头、陀螺仪等。但缺点也很致命,执行效率完全受限于原生控件 UIWebview,要知道对于一个游戏来讲,流畅度是第一要义。

总的来讲,使用 Hybrid 技术开发游戏的方案虽然可行,但是,效果并不是我想要的。

自从 ReactNative 开源以来,一直想着要使用 ReactNative 开发游戏。个人原因,一直未付诸实践。直到上周有网友问我,「Weex是否能拿来做游戏开发」,试试就知道,那就先拿 Weex 开刀,来挑战下 game app 同构的能力,给还没上车的朋友带波节奏。

准备工作

如果你还未入门,没关系,就当看个热闹了,知道 Weex 能不能快速开发游戏就可以了。

如果你想先入门,以下几篇文章你可以当作是导读。

扫雷游戏 Demo

官方提供的 WeexPlayground 中也提供了一个游戏 demo 扫雷,如下图

此 demo 是为了实践以下三件事:

  1. 界面指示器
  2. 动态数据绑定
  3. 更复杂的事件

总体表现还是不错的。更多细节,可详读《Weex版扫雷游戏开发》

我的小游戏

别人的东西再炫酷也始终是别人的,不自己动手码一个说话都不硬气!

没有实践就没有发言权,此处献上源码的 Github 链接:github.com/zwwill/just…,欢迎「Star」「Fork」,支持瞎搞 ψ(`∇´)ψ

先来感受下最终的效果

界面

体验

IOS已上线 itunes.apple.com/cn/app/id12…

也可以直接使用 Weex Playground 扫码体验 Weex Playground下载地址

近期将发布到应用市场,届时还望大家多多支持。

规则

规则很简单,会玩「俄罗斯方块」和「2048」就一定会玩这款小游戏

一期功能

由于要快速产出,界面随便就别太在意了,另外很多功能还没有开发,如,全球排名、分享、游戏设置等,这些都放在后面慢慢迭代吧(如果有第二版的话( ̄. ̄))

源码分析

接下来是一大波源码分析,不感冒?那就直接跳过。
由于篇幅有限,此处只做简要介绍,详细请见工程源码,地址请爬楼

项目结构

只有三个文件(一个场景两个组件)。我来逐一讲解下每个文件的职能。

index.vue

【index.vue】是一个场景文件,用于根据状态切换场景,以及监听处理所有的手势

【模版 | 简码】

<template>
  <div class="wrapper" @swipe="onSwipe" @click="onClick" @panstart="onPanstart" @panend="onPanend" @horizontalpan="onHorizontalpan">
    <!-- 此处省略一堆代码 -->
    <stoneMap v-if="stoneMapShow" ref="rStoneMap" class="stone-map" @screenLock="onScreenLock" @screenUnlock="onScreenUnlock" @over="onGameover" @win="onGameWin"></stoneMap>
    <!-- 此处省略一堆代码 -->
  </div>
</template>

我们监听了 Weex 的一堆事件来「合成」我们需要的【切换】【左右滑动】【下降】等主要游戏操作。如@swipe@click@panstart@panend@horizontalpan,同时给<stoneMap />组件注册@screenLock@screenUnlock@over@win等事件,用于游戏场景切换。

  • @swipeswipe的属性direction提供在屏幕上滑动时触发的方向,本项目用到updown,官方给的说法是『direction的值可能为upleftbottomright』但实际上我得到的却是down而不是bottom,具体请客还在和Weex的开发团队进行沟通,确认后会更新上来。另外要注意的是@swipe@click@panstart@panend@horizontalpan这些事件同时使用时会出现冲突问题,Android 平台下问题比较多,具体大家在做的时候需要做好兼容
  • @click:常规的click事件
  • @panstart、@panend、@horizontalpan:用于计算左右滑动距离,每滑动40个显示像素就向<stoneMap />组件发起滑块左右滑动的指令

具体事件的使用姿势,大家可以详读官方文档

每一个事件方法的功能实现和视觉此处就略去了。

stoneMap.vue

【stoneMap.vue】就像是「大内总管」,一切闲杂喽啰的事都归他管。主要管理的数字块的布局、状态、游戏分值等

【简码】

<template>
    <div class="u-slider">
        <!-- 此处省略一些记录分值等无关紧要的代码 -->
        <template v-for="i in stones">
            <stone :ref="i.id" :id="i.id" :p0="i.p0" :num0="i.s"></stone>
        </template>
    </div>
</template>
<script>
   export default {
        components: {
            stone: stone
        },
        data() {
            return {
                MAX_H: 9,
                stones: [],
                map: [],
                // 此处省略一些无关紧要的data
            }
        },
        mounted() {
            // 绘制画布矩阵
            for (let _i = 0; _i < this.MAX_H; _i++) {
                this.map.push(['', '', '', '', '', '']);
            }
            // 开始游戏
            this.pushStones();
        },
        methods: {
            /**
             * 事件控制
             * */
            action(_action) { /* ... */ },
            /**
             * 新增三个单元数字块
             * */
            pushStones() { /* ... */ },
            /**
             * 滑块切换
             * */
            actionChange() { /* ... */ },
            /**
             * 滑块左右滚动
             * */
            actionSliderMove(_d) { /* ... */ },
            /**
             * 单元块位置移动+权重加码
             * */
            actionDown() { /* ... */ },
            /**
             * 重新计算map并更新
             * */
            mapUpdate() { /* ... */ },
            /**
             * 计算map
             * */
            mapCalculator: (function () { /* ... */ })(),
            /**
             * 整理数字块,堆积下降
             * */
            stonesTrim() { /* ... */ },
            /**
             * 单元块位置移动+权重加码
             * */
            sChange(_id, _p, _score) { /* ... */ }
        }
    }
</script>
  • this.stones:用于管理所有实例进来的数字块,将他们投影到界面上
  • this.map:是一个6*9的逻辑网,标记 this.stones 中的的数字块的逻辑位置

此处主要介绍下事件的控制分发和逻辑网的计算,讲解在注释中

【action() | 简码】

/**
 * 事件的控制分发
 * */
action(_action) {
    if (!!this.actionLock) return;
    switch (_action) {
        case 'click':
        case 'up':
            // click 和 up 触发上方三个活动数字块的互相切换
            this.actionChange();
            break;
        case 'left':
        case 'right':
            // left 和 right 触发上方三个活动数字块的的整体平移
            this.actionSliderMove(_action);
            break;
        case 'down':
        case 'bottom':
            // down 触发上方三个活动数字块进场
            // bottom 起到兼容的作用
            this.actionDown();
            break;
        default:
            break;
    }
}

【mapCalculator() | 全码】

/**
 * 计算map
 * */
mapCalculator: (function () {
    var updateStone = function (_stones, _id, _s) {
        /** 
         * 此方法控制得分规则
         * 横竖对角线+1分
         * 十字、X型+2分
         * 8字型、9宫格分别+3分、+4分,当然,不可能存在这两种情况
         * */
        if (_stones[_id]) {
            _s != 0 && _s < 8 && (_stones[_id]['score'] == 0 ? _stones[_id]['score'] = _s : _stones[_id]['score']++);
        } else {
            _stones[_id] = {
                id: _id,
                score: _s
            }
        }
    };

    return function (_map) {
        let hasChange = false,
            activeStones = {},
            height = _map.length - 1,
            width = _map[0].length - 1,
            _tp_id, _s;
        // 全逻辑网遍历
        for (let y = height; y >= 0; y--) {
            for (let x = 0; x <= width; x++) {
                _tp_id = _map[y][x] || "";
                // 排除四角
                if (!_tp_id || (x == 0 || x == width) && (y == 0 || y == height)) continue; 

                _s = parseInt(this.$refs[_tp_id][0].num);
                let _p1, _p2;
                if (x == 0 || x == width || y == 0 || y == height) {
                    // 侧边,将其单独提炼出来是为了减少计算量三分之一的计算量
                    if (x == 0 || x == width) {
                        // 竖排
                        if (!_map[y - 1][x] || !_map[y + 1][x]) continue;
                        _p1 = this.$refs[_map[y - 1][x]][0];
                        _p2 = this.$refs[_map[y + 1][x]][0];
                    } else if (y == 0 || y == height) {
                        // 横排
                        if (!_map[y][x - 1] || !_map[y][x + 1]) continue;
                        _p1 = this.$refs[_map[y][x - 1]][0];
                        _p2 = this.$refs[_map[y][x + 1]][0];
                    }
                    if (_p1 && _p2 && _p1.num == _s && _p2.num == _s) {
                        hasChange = true;
                        updateStone(activeStones, _tp_id, ++_s);
                        updateStone(activeStones, _p1.id, 0);
                        updateStone(activeStones, _p2.id, 0);
                    }
                } else {
                    // 中间可形成九宫格区域
                    const _map_matrix = [
                        [[0, 1], [0, -1]],
                        [[-1, 1], [1, -1]],
                        [[-1, 0], [1, 0]],
                        [[-1, -1], [1, 1]]
                    ];
                    for (let _i = 0, _mm; _i < _map_matrix.length; _i++) {
                        _mm = _map_matrix[_i];
                        if (!_map[y + _mm[0][0]][x + _mm[0][1]] || !_map[y + _mm[1][0]][x + _mm[1][1]]) continue;
                        _p1 = this.$refs[_map[y + _mm[0][0]][x + _mm[0][1]]][0];
                        _p2 = this.$refs[_map[y + _mm[1][0]][x + _mm[1][1]]][0];
                        if (_p1 && _p2 && _p1.num == _s && _p2.num == _s) {
                            hasChange = true;
                            updateStone(activeStones, _tp_id, _s + 1);
                            updateStone(activeStones, _p1.id, 0);
                            updateStone(activeStones, _p2.id, 0);
                        }
                    }
                }
            }
        }

        // 存在更新块
        if (hasChange) {
            setTimeout(() => {
                for (let s in activeStones) {
                    this.sChange(s, undefined, activeStones[s].score);
                }
                // 数字块整理
                setTimeout(() => {
                    this.stonesTrim();
                }, 100)
            }, 400)
        } else {
            let _errorStone = "";
            for (let _i = 0; _i < this.map[0].length; _i++) {
                if (this.map[0][_i]) {
                    _errorStone = this.$refs[this.map[0][_i]][0].$refs['stone'];
                    break;
                }
            }
            if (!!_errorStone) {
                this.$emit('over', this.totalScore, this.highScore, _errorStone);
                if (this.totalScore > this.highScore) {
                    storage.setItem('H-SCORE', this.totalScore)
                }
            } else {
                this.$emit('screenUnlock');
                setTimeout(() => {
                    this.pushStones();
                }, 100);
            }
        }
    }
})()

【stonesTrim | 全码】

/**
 * 整理数字块,堆积下降
 * */
stonesTrim() {
    let hasChange = false,
        height = this.map.length - 1,
        width = this.map[0].length - 1,
        _tp_id, _step = 0;
    for (let x = 0; x <= width; x++) {
        _step = 0;
        for (let y = height; y >= 0; y--) {
            _tp_id = this.map[y][x] || "";
            if (!_tp_id) {
                _step++;
                continue;
            } else if (_step > 0) {
                hasChange = true;
                this.sChange(_tp_id, {y: _step});
                this.map[y + _step][x] = _tp_id;
                this.map[y][x] = "";
            }
        }
    }
    setTimeout(() => {
        this.mapUpdate();
    }, hasChange ? 200 : 0);
}

stone.vue

【stone.vue】就像被「大内总管」管理着的「小太监」(数字块),「小太监」的一举一动都是被「总管」支配的,包括其长相(颜色)、品级(数字)以及生死(生命周期),但状态的改变都是由自己执行,直接自己整容,自己升级,还要。。自杀。底层人民好无奈 ╮(╯_╰)╭

【简码】

<template>
    <text ref="stone" class="u-stone" :style="{color:color,visibility:visibility,backgroundColor:backgroundColor0}" v-if="show" >{{score}}</text>
</template>

<script>
    const animation = weex.requireModule('animation');
    export default {
        props: ['id', 'p0', 'num0'],
        data(){
            return {
                show: true,
                p: '0,8',
                visibility: '',
                num: -1,
                colors: ["#333","#666","#eee","#b9e3ee","#ebe94b","#46cafb","#eca48f","#decb3d","#8d1894"],
                backgroundColors: ["#222","#ddd","#999","#379dc3","#36be0d","#001cc6","#da4324","#56125a","#ffffff"]
            }
        },
        computed: {
            color: function () {
                return this.colors[this.num];
            },
            score: function () {
                this.num<0 && (this.num = this.num0 || 1);
                return this.num<9&&this.num>0?this.num:0
            },
            backgroundColor0: function () {
                return this.backgroundColors[this.num];
            }
        },
        watch: {
            p: function (val) {
                // 移动数字块
                var _x = 125*val.charAt(0)+"px",
                    _y = 125*val.charAt(2)+"px";
                // 使用animation库实现过度动画
                animation.transition(this.$refs['stone'],{
                    styles: {
                        transform: 'translate('+_x +',-'+_y+')'
                    },
                    duration: 200,
                    timingFunction: 'ease-in',
                    delay: 0
                });
            }
        },
        mounted(){
            this.initState(this.p0);
        },
        methods: {
            /**
             * 移动数字块
             * */
            move(_x, _y){ /* ... */ },
            /**
             * 更新数字块的分值,即显示数字
             * */
            scoreChange(_num){ /* ... */ },
            /**
             * 初始化数字块的位置
             * */
            initState(_p){ /* ... */ }
        }
    }
</script>

好了,辣么乐色的代码我都不好意思再唠叨了。换个话题,来讲讲这个小游戏从无到有中间的一些方案的变更吧。

各种尝试

由于对 Weex 的过高期望,导致很多最初的方案都被「阉割」或者「整容」。

动画

想让元素动起来,传统前端一般有两种方式

1、CSS 动画
2、JS 动画
在 Weex 上由多了一个
3、animation 内建模块,可执行原生动画

由于 css3 的 transition 在 Weex 的 0.16.0+ 版本才能使用,官方提供的 demo 框架引用的 SDK 版本低于此版本,方案1,无效!

Weex 上的视觉是通过解析 VDom,在调用原生控件渲染成的,完全没有 DOM ,所以 JS 动画的方案,无效!

看了只剩下 Weex 的 animation 内建动画模块了。

虽然不太喜欢,用起来也很别扭,但是没办法,有总比没有强。知促常乐吧。

来看一下 animation 的使用姿势

animation.transition(this.$refs.test, {
        styles: {
            color: '#000',
            transform: 'translate(100px, 100px) sacle(1.3)',
            backgroundColor: '#CCC'
        },
            duration: 800, // ms
            timingFunction: 'ease',
            needLayout:false,
            delay: 0 // ms
        }, function () {
            // animation finished.
        })

想实现一个多态循环的动画,还要写一个方法,想想就难受

音乐

没有声音还能算是游戏吗?!

嗯 ~ ~ ~ 好像可以算

无所谓啦~ 开心最重要 ︿( ̄︶ ̄)︿

尴尬的是 Weex 官方压根就没给咱们提供这样的 API,好在有三方的插件可用,Nat, 刚好可以用上。

Weex 提倡使用网络资源,所有我把音频文件上传到了 CDN 上,为了能快一点。。

当然不可能一路顺风!

我们来看看 Nat Audio 模块的使用方式

Nat.audio.play('http://cdn.instapp.io/nat/samples/audio.mp3')

然而 Nat.audio 只提供了 play() | pause() | stop() 三个 API。

为什么没有 replay() 重放?我想用的就是重放。这都不是事儿,使用 play() 硬着头皮上吧!

由于 Nat.audio 不支持 Web 端,每次修改都是真机调试,那个速度,唉~~~我终于理解原生小伙伴们的痛苦了。。

这也不是事儿,最气愤的就是,Nat.audio.play() 每次播放相同的音频竟然不是走的缓存!难道缓存机制还要自己做?!?!ヽ(`⌒´)ノ 我的天!

最后还是乖乖的用背地文件吧。还要写平台路径适配。。

没想到音频的槽点这么多!还要我没用 Weex 做网易云音乐。

手势指令

前文也有讲过,小游戏用到了@swipe@click@panstart@panend@horizontalpan这么多事件监听。官方也有友情提醒「horizontalpan 手势在 Android 下会与 click 事件冲突」,但实际上 ios 平台上也会有冲突。

具体的我就不再描述了。此处只想说明,Weex 在手势指令上虽然可以满足游戏的基础指令要求,但细节上还是不太理想。

总结

总的来讲,Weex 算是满足了我做小游戏的要求。如果想做大游戏,就不建议使用 Weex 了,Weex 确实做不了,但者也不是 Weex 诞生的意义。

好了,此次尝试就到这吧。为了不让思路断掉,我又通宵了,罪过,罪过 ~ ~ ~,希望此文对感兴趣的小伙伴有所帮助。

mark: 03:05

友情推荐

《H5和WebGL 3D开发实战详解》

作者: 吴亚峰 , 于复兴 , 索依娜
责编: 张涛
分类: 软件开发 游戏设计与开发


本书共分为14章,由浅入深地进行讲解,主要内容包括:开发基础部分,介绍了初识WebGL,实现WebGL可编程渲染管线着色器的着色语言,投影及各种变换;光照效果部分,介绍了WebGL中光照的基本原理与实现、点法向量与面法向量的区别以及光照的每顶点计算与每片元计算的差别;纹理映射部分,介绍了纹理映射的基本原理与使用,同时还介绍了不同的纹理拉伸与采样方式、多重过程纹理技术以及压缩纹理;3D模型加载部分,介绍了如何使用自定义的加载工具类直接加载使用3ds Max创建的3D立体物体;混合与雾部分,主要介绍了混合以及雾的基本原理与使用;标志板、天空盒部分,主要介绍了一些常见的3D开发技巧,包括标志板、天空盒与天空穹、镜像技术等;Three.js引擎部分,主要介绍了对WebGL封装比较好的Three.js引擎,包括创建场景、摄像机、基本形状物体、加载模型,以及一些较高级的内容;Egret 3D游戏引擎应用开发部分,介绍Egret 3D在3D游戏开发中的功能;Ammo物理引擎部分,介绍Ammo物理引擎的刚体、软体等创建与使用;综合案例—《极地大作战》部分,通过一个具体的游戏向读者较为全面地介绍了游戏项目的开发流程以及运用各种技术解决具体问题的思路,案例中综合运用了前面章节中讲解的知识,让读者尽快进入实战角色。