Weex 開發小遊戲是件很 high 的事兒

木_羽_發表於2017-10-20

前言(廢話)

自上一篇 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物理引擎的剛體、軟體等建立與使用;綜合案例—《極地大作戰》部分,通過一個具體的遊戲向讀者較為全面地介紹了遊戲專案的開發流程以及運用各種技術解決具體問題的思路,案例中綜合運用了前面章節中講解的知識,讓讀者儘快進入實戰角色。

相關文章