javascript高仿熱血傳奇遊戲

前言php

遊戲的第一個版本開發於14年,瀏覽器端使用html+css+js,服務端使用asp+php,通信採用ajax,數據存儲使用access+mySql。不過因爲一些問題(當時還不會用node,用asp寫複雜的邏輯真的會寫吐;當時對canvas寫的也少,dom渲染很容易達到性能瓶頸),已經廢棄。後來用canvas重製了一版。本文寫於18年。css

demo

資料彙總html

1.開發前的準備

爲何要用Javascript來實現一款比較複雜的PC端遊戲

1.js實現PC端網遊是可行的。隨着PC、手機硬件配置的升級和瀏覽器的更新換代,以及H5各類庫的發展,js實現一款網遊的難度愈來愈低。這裏的難度主要是兩方面:瀏覽器的性能;js代碼是否足夠易於擴展,以知足於一款邏輯極其複雜的遊戲的迭代。算法

2.現階段的js遊戲裏,不多有規模較大的可供參考。涉及到多人聯機、服務端數據存儲、複雜交互的遊戲,大多數(幾乎所有)都是用flash開發的。可是flash畢竟在衰落,而js發展迅速,而且只要有瀏覽器就能夠運行。canvas

爲何選擇了一款2001年的熱血傳奇遊戲

第一個緣由是對老遊戲的情懷; 固然更重要的另外一個緣由是,別的遊戲要麼我不會玩、要麼我會玩但沒有素材(圖片、音效等)。花很大精力去收集一個遊戲的地圖、人物怪物模型、物品和裝備圖,而後去處理、解析一遍再用於js開發,我以爲是浪費時間。

因爲我之前蒐集了一些傳奇遊戲的素材,而且幸運地找到了提取熱血傳奇客戶端資源文件的方法,因此能夠直接開始寫碼,省去了一些準備時間。

可能的困難

1.瀏覽器的運行性能:這個應該是最困難的一點。假如遊戲要保持40幀,那麼每幀只有25ms的時間留給js計算。而且因爲渲染一般比計算耗性能,實際上留給js的時間只有10毫秒左右。

2.防做弊:如何避免用戶直接調用接口或者篡改網絡請求數據?因爲目標是用js實現比較複雜的遊戲,而且任何網絡遊戲都須要考慮這一點,必定會有相對成熟的方案。此處不是本文重點。

2.總體設計

瀏覽器端

  1. 畫面渲染使用canvas。

    相比dom(div)+css,canvas能夠處理比較複雜的場景渲染和事件管理,例以下面這個場景,涉及了四張圖片:玩家、動物、地上的物品、最下層的地圖圖片。(實際還有地上的影子,鼠標指向人物、動物、物品時出現的相應名稱,以及地面上的陰影。爲了方便讀懂,先不考慮這麼多內容。)

    復瑣事件demo

    這時,若是但願實現「點擊動物、攻擊動物;點擊物品、撿起物品」的效果,那麼須要對動物和物品進行事件監聽。若是採用dom的方式,那麼會出現幾點難於處理的問題:

    • 渲染的順序和事件處理的順序不一樣(有時候z-index小的須要先處理事件),須要額外處理。例如這個上面的例子裏:點擊怪物、點擊物品的時候也容易點到人物,那麼須要給人物作「點擊事件穿透」的處理。並且事件處理的順序不固定:假如我有一個技能(例如遊戲裏的治療)須要點人物才能夠釋放,那麼這時人物又須要有事件監聽。因此一個元素是否須要處理事件、處理事件的前後,是隨着遊戲狀態的不一樣而變化的,而dom的事件綁定已經不能知足須要

    • 有關聯的元素難以放在同一個dom節點中:例如玩家的模型、玩家的名字和玩家身上的技能畫效,理想狀況下是放在一個<div>或者<section>容器裏,便於管理(這樣幾個元素的定位就能夠繼承父元素,不用分別處理位置了)。可是這樣,z-index會很難處理。例如玩家A在玩家B的上面,那麼A會被B遮擋,所以須要A的z-index小一些,可是又須要讓玩家A的名字不會被B的名字或者影子遮擋,就沒法實現。簡單點說,dom結構的可維護性會犧牲畫面展現的效果,反之亦然

    • 性能問題。即便犧牲了效果,用dom渲染,勢必出現不少嵌套關係,全部元素的style都在頻繁變化,連續觸發瀏覽器的repaint甚至reflow。

  2. canvas渲染邏輯與項目邏輯分離

    若是將canvas的各類渲染操做(如drawImagefillText等)與項目代碼放在一塊兒,那麼勢必致使項目後期沒法維護。翻了一下幾款現有的canvas庫,結合vue的數據綁定+調試工具的方式,搞了一個全新的canvas庫Easycanvas(github地址),而且像vue同樣支持經過一個插件來調試canvas中的元素。

    這樣,整個遊戲的渲染部分就容易不少,只須要管理遊戲當前的狀態、而且根據服務端從socket傳回來的數據去更新數據就能夠。「數據的變化引發視圖的變化」這個環節由Easycanvas負責。例以下圖的玩家包裹物品的實現,咱們只須要給出包裹容器的位置、揹包裏每一個元素的排布規則,而後將每一個包裹的物品綁定到一個array上,而後去管理這個array便可(數據映射到畫面的過程由Easycanvas負責)。

    包裹demo

    例如,5行8列共計40個物品的樣式能夠經過以下的形式傳遞給Easycanvas(index爲物品索引,物品x方向間距36,y方向間距32)。而這個邏輯是一成不變的,不管物品的數組怎樣變化、包裹被拖拽到什麼位置,每一個物品的相對位置都是固定的。至於canvas上的渲染則徹底不須要項目自己來考慮,因此可維護性較好。

    style: {
        tw: 30, th: 30,
        tx: function () {
            return 40 + index % 8 * 36;
        },
        ty: function () {
            return 31 + Math.floor(index / 8) * 32;
        }
    }
    複製代碼
  3. canvas分層渲染

    假設:遊戲須要保持40幀,瀏覽器寬800高600,面積48萬(後面稱48萬爲1個屏幕面積)。

    若是用同一個canvas來呈現,那麼這個canvas的幀數40,每秒至少須要繪製40個屏幕面積。可是同一個座標點極可能出現多個元素重疊的狀況,例如底部的UI、血條、按鈕就是重疊放置,他們又共同遮擋了場景地圖。因此這些加在一塊兒,每秒瀏覽器的繪製量很容易達到100個屏幕面積以上。

    這個繪製是很難優化的,由於整個canvas畫布的任何一處都在進行視圖的更新:多是玩家和動物的移動、多是按鈕的特效、多是某個技能效果的變化。這樣的話,即便玩家不動,因爲衣服「隨風飄飄」的效果(實際上是精靈動畫播放到下一張圖),或者是地面上出現了一瓶藥水,都要引發整個canvas的重繪。由於遊戲中幾乎不可能出現某一幀的畫面與上一幀毫無區別的狀況,即便是遊戲畫面的一個局部,也很難保持不變。整個遊戲的畫面永遠在更新。

    由於遊戲中幾乎不可能出現某一幀的畫面與上一幀毫無區別的狀況,畫面永遠在更新。

    所以,此次我採用了3個canvas重疊排布的方式。因爲Easycanvas的事件處理支持傳遞,所以即便點到了最上面的canvas,若是沒有任何元素結束了某一次點擊,後面的canvas也能夠接到此次事件。3個canvas分別負責UI、地面(地圖)、精靈(人物、動物、技能特效等):

    layers

    這樣分層的好處是,每層最大幀數能夠根據須要來調整:

    • 例如UI層,由於不少UI平時是不動的,即便動也不會須要太精密的繪製,因此能夠適當下降幀數,例如下降到20。這樣假如玩家的體力從100下降到20,那麼能夠在50ms內更新視圖,而50ms的切換是玩家感受不出來的。由於像體力這種UI層數據的變化很難在很短的時間內連續變化屢次,而50ms的延遲是人很難感知的,因此不須要頻繁的繪製。假如咱們每秒節約了20幀,那麼極可能能夠節約10個屏幕面積的繪製。

    • 再如地面,只有玩家移動的時候,地圖纔會變化。這樣,若是玩家不動,那麼每幀能夠省去1個屏幕面積。因爲須要保證玩家移動時的流暢感,地面的最大幀數不宜過低。假如地面爲30幀,那麼玩家不動時,每秒就能夠節約30個屏幕面積的繪製(這個項目中,地圖是幾乎繪滿屏幕的)。並且其它玩家、動物的移動不會改變地面,也不須要重繪地面這一層。

    • 精靈層最大幀數不能下降,這層會展現遊戲的人物動做等核心部分,因此最大幀數設置爲40.

    這樣,每秒繪製的面積,玩家移動時多是80~100個屏幕面積,而玩家不移動時可能只有50個屏幕面積。遊戲中,玩家停下來打怪、打字、整理物品、釋放技能都是站立不動的,所以大量的時間裏都不會觸發地面的繪製,對性能的節約很大

服務器端

  1. 因爲目標是js實現一款多人網遊,因此服務端使用Node,使用socket與瀏覽器通信。這樣作還有一個好處,就是一些公用的邏輯能夠在兩端複用,例如判斷地圖上某個座標點是否存在障礙物。

  2. Node端的玩家、場景等遊戲相關數據所有存儲與內存中,按期同步至文件。每次Node服務啓動時,將數據從文件讀取至內存。這樣能夠玩家較多時,文件讀寫的頻率成指數級上升,從而引起的性能問題。(後來爲了提升穩定,爲文件讀寫增長了一個緩衝,「內存-文件-備份」的方式,以避免讀寫過程當中服務器重啓致使的文件損壞)。

  3. Node端分接口、數據、實例等多層。「接口」負責和瀏覽器端交互。「數據」是一些靜態數據,例如某個藥品的名稱和效果、某個怪物的速度和體力,是遊戲規則的一部分。「實例」是遊戲中的當前狀態,例如某個玩家身上的一個藥品,就是「藥品數據」的一個實例。再舉個例子,「鹿的實例」擁有「當前血量」這個屬性,鹿A多是10,鹿B多是14,而「鹿」自己只有「初始血量」。

3.場景地圖的實現

地圖場景

下面開始介紹地圖場景部分,仍然是依賴Easycanvas進行渲染。

思考

因爲玩家是始終固定在屏幕中心的,因此玩家的移動,其實是地圖的移動。例如玩家像左跑,地圖就向右平移便可。剛纔已經提到,玩家處於3個canvas中的中間一層,而地圖屬於底層,所以玩家必定遮擋地圖。

這樣看起來是合理的,可是假如地圖中有一棵樹,那麼「玩家的層次始終高於樹」就不對了。這時,有2種大的解決方案:

  • 地圖分層,「地面」與「地上」拆開。將玩家處於兩層之間,例以下圖,左側是地上、右側是地面,而後重疊繪製,把人物夾在中間:

    bround

    這樣看似解決了問題,其實引入了2個新的問題:第一個是,玩家有時可能會被「地上」的東西遮擋(例如一棵樹),有時又須要可以遮擋「地上」的東西(例如站在這棵樹的下方,頭部會遮擋住樹)。另外一個問題是渲染的性能消耗會增長。因爲玩家是時刻在變的,「地上」這一層須要頻繁重繪。這樣作也打破了最初的設計——儘可能節約地面大地圖的渲染,從而致使canvas的分層更加複雜。

  • 地圖不分層,「地面」與「地上」在一塊兒繪製。當玩家處於樹後的時候,將玩家的透明度設置爲0.5,例以下圖:

    opacity

    這樣作只有一個壞處:玩家的身體要麼都不透明、要麼都半透明(怪物在地圖上行走也會有這個效果),不會徹底真實。由於理想的效果是存在玩家的身體被遮擋住一部分的場景的。可是這樣作對性能友好,而且代碼易於維護,目前我也採用了這個方案。

那麼如何判斷「地圖」這張圖片哪些地方是樹呢?遊戲一般會有一個大的地圖描述文件(其實就是一個Array),經過0、一、2這樣的數字來標識哪些地方能夠經過、哪些地方存在障礙物、哪些地方是傳送點等等。熱血傳奇中的這個「描述文件」就是48x32爲最小單位進行描述的,因此玩家在傳奇中的行動會有一種「棋盤」的感受。單位越小越流暢,可是佔用的體積越大、生成這個描述的過程也就越耗時。

下面開始正題。

實現

我找了一個朋友幫我導出熱血傳奇客戶端中「比奇省」的地圖,寬33600、高22400,是我電腦的幾百倍大。爲了不電腦爆炸,須要拆分紅多塊加載。因爲傳奇的最小單元是48x32,咱們以480x320將地圖拆成了4900(70x70)個圖片文件。

canvas的尺寸咱們設定爲800x600,這樣玩家只須要加載3x3共計9張圖片就能夠鋪滿整個畫布。800/480=1.67,那麼爲何不是2x2?由於有可能玩家當前的位置正好致使有的圖片只展現了一部分。我畫了一張美輪美奐的示意圖:

tile

因此,至少須要3x3排列9張圖片就能夠「鋪滿」畫布。可是這樣作有一個隱患,那就是每一個480x320的地圖碎片文件的體積至少要幾十KB以上,若是須要的時候纔拿來繪製,那麼將致使人物跑動的時候能夠看到區塊是一個一個加載出來的,影響體驗。因此我採用了4x4共計16個區塊來填充畫布。這樣爲地圖平移的效果預留一些冗餘的面積,將圖片文件的加載時機提早,起到了預加載的效果。這裏不須要考慮是否浪費了渲染的性能,由於canvas的大小是800x600,當咱們向外部(例如某個區塊的橫座標爲900~1380)繪製的時候,不會真的「繪製」,也就不會有性能浪費。(這裏囉嗦一下,使用canvas原生的drawImage方法向canvas的外部繪製的時候,我測試的結果是耗費的性能極低。而我在Easycanvas庫裏封裝了canvas的原生方法:當判斷繪製區域部分超過canvas的時候,會對繪製進行裁剪;當繪製區域徹底超過canvas的時候,就再也不執行drawImage方法。)

咱們經過Easycanvas向畫布添加一個地圖容器(用來裝載這16張區塊)。容器的左上角頂點位於瀏覽器(0,0)點的左上方,以保證容器徹底覆蓋畫布。須要注意的一點是:地圖容器只會在1個區塊內小幅移動,橫、縱向的最大移動距離爲480和320。以水平方向爲例,假如容器裏第一行的4個區塊分別爲T1五、T1六、T1七、T18,那麼玩家向右跑的時候,4個區塊開始向左平移。當玩家跑夠了480的距離(實際上是容器跑了480的距離),就能夠當即將容器放回去(向回移動480,回到原點),而後4個區塊變爲T1六、T1七、T1八、T19.這樣,容器的樣式就是對480和320進行取餘,而後再加上適當的修正:

var $bgBox = PaintBG.add({
    name: 'backgroundBox',
    style: {
        tx: function () {
            return - ((global.pX - 240) % 480) - 320; // 這裏的算法不惟一,對480取餘纔是重點
        },
        ty: function () {
            return - ((global.pY - 160) % 320) - 175;
        },
        tw: 480 * 4, // 做爲容器,寬高能夠省略不寫,這裏寫出是便於理解
        th: 320 * 4,
        locate: 'lt', // tx、ty做爲左上角頂點傳給Easycanvas
    }
});
複製代碼

而後向容器增長16個區塊便可,增長區塊的代碼比較簡單,這裏列出每一個區塊的號碼算法(假設每一個區塊對應的圖片的文件名爲15x16.jpg這種格式):

content: {
    img: function () {
        var layer1 = Math.floor((global.pX - 240) / 480) + i - 1;
        var layer2 = Math.floor((global.pY - 160) / 320) + j - 1;
        var block = `${layer1}x${layer2}`;
        return Easycanvas.imgLoader(`${block}.jpg`);
    }
}
複製代碼

其中,i和j表明區塊的序號(0-4)。layer的計算方法也不是惟一的,根據容器的算法進行調整便可。

這樣,當玩家的座標pX和pY變化的時候,地圖就會進行平移。玩家向右跑、地圖向左平移(因此上面的tx須要加負號,這裏的tx相似vue語法中的computed),地圖容器的位置由玩家座標決定,也只跟隨玩家座標的變化而重繪,不能由任何其它的數據來干預。這樣,一方面數據和視圖進行了綁定,另外一方面也保證了數據流是單向的,不會受到其它模塊的干擾,也不須要其它模塊來干擾

4.UI層的實現

接下來開始UI層(因爲精靈層比較複雜,放到最後)。

底部UI的實現

熱血傳奇的底部UI是比較大的圖片:

bottom

如下稱這張圖爲「底UI」。底UI的尺寸是800x251,至關於半個遊戲屏幕面積。因此一開始設計的時候提到,將UI獨立出來放在單獨的canvas,而後進行低頻繪製。那麼按鈕、聊天框、血球要不要單獨切出來呢?

好比右側的4個藍色小按鈕,是否應該從底UI抽離出來,單獨寫渲染邏輯呢?

底UI中的按鈕

咱們判斷一個局部是否須要從總體抽離出來的關鍵是,看它存不存在「總體和局部不一樣時渲染」的狀況。例如某一個時刻底UI存在,而按鈕不見了,那麼按鈕必定須要切出來。也許會問:這個局部是須要變化的,例如鼠標按下按鈕時,按鈕發光,那麼是否是應該切出來?答案是不該該。咱們徹底能夠把一個「發光按鈕」放在按鈕所在的位置,而後讓它的透明度爲0,而且當鼠標按下時,透明度改成1:

UI.add({
    name: 'buttomUI_button1',
    content: {
        img: './button1_hover.png'
    },
    style: {
        opacity: 0 // 寬高、位置不是重點,此處省略
    },
    events: {
        mousedown () {
            this.style.opacity = 1;
        },
        mouseup () {
            this.style.opacity = 0;
        },
        click () {
            // ...
        }
    }
});
複製代碼

並且,因爲大部分狀況下按鈕是正常狀態,因此這樣作也是對性能最友好的方式。同時,這種設計也可讓底UI只負責渲染,而底UI的一個個子元素去對應各自的點擊事件,也便於代碼的維護。

球形血條

熱血傳奇中的球形血條看起來是個立體的東西,其實只是圖片的切換。假設空狀態的球對應的圖片爲empty.png、滿狀態對應full.png。

例如玩家擁有100的最大法力值,當前還剩30,那麼能夠理解爲底部30%繪製full.png這張圖片、而頂部70%繪製empty.png.不過,出於邏輯簡化和性能的考慮,能夠將empty.png放到底UI上(參考上一張底UI的圖),而後根據當前血量去用full.png來蓋在上面。這樣至關於不存在「空狀態」對應的圖層,只是把它做爲背景,在上面根據當前狀態來覆蓋各類長度的「滿狀態」圖。

下圖展現了是怎樣經過將滿狀態的貼圖覆蓋上去,來實現「血條」的:

ball

能夠看到,若是血量是充滿的,咱們能夠將充滿狀態的圖徹底覆蓋上去;當血量不滿時,咱們能夠從滿狀態的圖片中裁取一部分蓋在空球上。咱們將他們的裁剪範圍(Easycanvas裏的sx、sy、sw、sh參數,其中s表明source,指源圖片)與數據層綁定在一塊兒,傳遞給Easycanvas(滿狀態的半球的尺寸爲46x90)。涉及的變量計算較多,下面一一闡述。

var $redBall = UI.add({
    content: {
        img: 'full_red.png'
    },
    style: {
        sx: 0,
        sw: 46,
        sy: function () {
            return (1 - hpRatio) * 90;
        },
        sh: function () {
            return hpRatio * 90;
        },
        tx: CONSTANTS.ballStartX,
        ty: function () {
            return CONSTANTS.ballStartY + (1 - hpRatio) * 90;
        },
        tw: 46,
        th: function () {
            return 90 * hpRatio;
        },
        opacity: Easycanvas.transition.pendulum(0.8, 1, 1000).loop(),
        locate: 'lt',
    },
});
複製代碼

因爲無論血量如何變化,球距離左側的位置是固定的,因此tx、sx是定值。tx的值是根據底UI測量出來的常量,sx是0是爲了從源圖片的最左側開始繪製。

咱們讓當前血量與最大血量的比值爲hpRatio,那麼hpRatio爲1的時候,血量充滿。這時,不須要對源圖片進行裁剪,咱們繪製完整高度的血球。所以繪製的高度與hpRatio成正比。

而血量少的時候,咱們應該從源圖片的中間開始,將中部至底部的部分繪製上去。因此hpRatio越小,裁剪起點sy越大。而且y方向裁剪的起點sy與裁剪的高度sh存在關係:sy+sh=90。一樣,hpRatio越小表明血量越少,這時繪製起點越向下。

至於opacity,咱們讓他從0.8到1進行緩慢的循環好了。這樣能夠給玩家一種血球「流淌」的感受。(假如咱們有多張圖片組成的動畫,讓他們輪播會更加逼真。)

至此,完成了球形血條的開發。視圖徹底由數據驅動,每當血量更改時,咱們算出新的hpRatio,血球就會隨之更新。仍然是從數據到視圖的單向數據流,這樣能夠保證視圖展現效果只由數值驅動,便於後續的擴展。例如「玩家喝藥補充血量」就不須要關心這個球形血條應該如何變化,只須要和數據進行關聯便可。

揹包(玩家身上物品)

揹包涉及了極其複雜的交互,主要的幾點:

  • 視圖與物品Array的綁定。物品數據更新時,視圖須要更新。這是最基礎的功能。

  • 每個物品有很是複雜的事件。雙擊物品可使用。單擊物品後,物品跟隨鼠標移動,此時:

    若是點擊地面,須要將物品丟棄到地上(實際上是向服務端發送丟棄物品請求);若是點擊人物裝備欄的一個槽,那麼能夠穿戴或者替換裝備;若是點擊的是倉庫裏的一個槽,事件又變成了存儲物品;若是點擊揹包,那麼多是放回物品,也多是交換兩個物品的位置……還有不少不少狀況。

  • 揹包是能夠拖動到任何地方的、能夠和其它相似揹包同樣的「對話框UI」共存的。那麼勢必出現多個相似揹包這樣的對話框之間的層級計算的關係。我把揹包對話框拖拽到人物對話框上,那麼揹包的z-index大一些。若是這時點了一下人物對話框,那麼確定人物對話框的z-index要更高一些。假如這時又彈出了一個NPC對話框呢?

  • 在熱血傳奇遊戲中,我把揹包拖到任何地方,這時打開倉庫,那麼系統會自動進行排列:倉庫在左出現,揹包馬上移動到右側,方便玩家操做。涉及到一些算法,讓玩家感到這些對話框是「智能」的。

Warning,前方高能預警。

玩家可能還會這麼操做:

  • 打開揹包,而後左鍵點擊地面,人物開始奔跑。玩家的鼠標動來動去,控制人物在地圖上奔跑。而後鼠標就動到揹包裏了,停留在某一個物品上,這時擡起左鍵,(@*(#)¥……@(#@#!

  • 假如數字1對應了一個技能,玩家拖拽揹包的時候,忽然對着揹包裏的某瓶無辜的藥水按了一下技能(就算玩家傻,至少要保證咱們的js不報錯)。

  • 某個幾百字也沒法描述清楚的case,此處省略。

開始寫碼。首先確定要有一個揹包容器:

var $dialogPack = UI.add({
    name: 'pack',
    content: {
        img: pack,
    },
    style: {
        tw: pack.width, th: pack.height,
        locate: 'lt',
        zIndex: 1,
    },
    drag: {
        dragable: true,
    },
    events: {
        eIndex: function () {
            return this.style.zIndex;
        },
        mousedown: function () {
            $this.style.zIndex = ++dialogs.currentMaxZIndex;
            return true;
        },
    }
});
複製代碼

style沒什麼好多說的,zIndex咱們先隨便寫個1上去.後面的drag是Easycanvas提供的拖拽API,也沒什麼好多說的。事件的eIndex(Easycanvas用來管理事件觸發順序的索引,event-zIndex)須要和zIndex同步,畢竟玩家看到哪一個對話框在上面,哪一個對話框確定先捕獲到事件。

可是,咱們須要給mousedown綁定一個事件:當玩家點擊了這個對話框時,把它的zIndex提到當前全部對話框中的最高。咱們讓全部對話框都從一個公共的dialogs模塊裏獲取「當前最大zIndex」。每次設置以後,最大zIndex自增1,以供下一個對話框使用。

容器先這樣,下面開始填充內容。咱們讓揹包的Array爲global.pack,用一個for循環來爲40個格子填充物品,索引爲i:

$dialogPack.add({
    name: 'pack_' + i,
    content: {
        img: function () {
            if (!global.pack[i]) {
                return; // 第i個格子沒有物品,就不渲染
            }
            return Easycanvas.imgLoader(global.pack[i].image);
        },
    },
    style: {
        tw: 30, th: 30,
        tx: function () {
            return 40 + i % 8 * 36;
        },
        ty: function () {
            return 31 + Math.floor(i / 8) * 32;
        },
        locate: 'center',
    },
    events: {
        mousemove: function (e) {
            if (global.pack[i]) {
                // equipDetail模塊負責展現鼠標指向物品的浮層
                equipDetail.show(global.pack[i], e);
                return !global.hanging.active;
            }
            return false;
        },
        mouseout: function () {
            // 關閉浮層
            equipDetail.hide();
            return true;
        },
        click: function () {
            // 把點了什麼物品告訴hang模塊
            hang.start({
                $sprite: this,
                type: 'pack',
                index: i,
            });
            return true;
        },
        dblclick: function (e) {
            bottomHang.cancel();
            equipDetail.hide();

            useItem(i);

            return true;
        }
    }
});
複製代碼

因爲每時每刻揹包均可能發生變化,這裏的img是一個function,動態return出結果。注:我寫demo測試了一下,執行1(function () {return 1;})()消耗性能的差別很小,能夠忽略。

style裏對40個物品進行8x5的排列,40、3一、32這些數字是從揹包的素材圖裏量出來的。每一個格子的大小爲30x30,熱血傳奇還有6個快捷物品欄(掛在底UI上),也用相似的方法添加,此處省略。可是須要注意:不能省去每一個格子的style裏的寬高,由於當img爲空時,也須要有一個對象存在面積,這樣才能捕捉到事件。若是不寫明寬高,那麼點擊沒有物品的格子將不觸發任何事件。咱們把一個物品放到空格子上,是須要這個空格子來捕獲事件的。

對每一個格子,當鼠標移入的時候,若是這個格子存在物品,那麼須要展現物品的信息浮層。若是點擊了物品,須要讓物品的圖片跟隨鼠標移動(玩家拿起了物品)。這兩塊邏輯比較複雜,咱們寫單獨的模塊來負責。

雙擊一個格子,那麼要作3件事:隱藏信息浮層、取消拿起物品、使用物品(發送請求給服務端)。在熱血傳奇遊戲中,是容許玩家手裏拿着物品A,而後雙擊物品B的(可是不能拿着A使用A,由於拿起A以後就點不到A了)。若是要作到徹底一致的話,能夠去掉bottomHang.cancel這一句,同時增長「點擊格子時,若是格子裏的物品已經拿在手上,那麼沒法使用這個物品」的邏輯。

這塊沒有太多的技術含量,只要模塊抽離乾淨,就只剩下碼代碼寫邏輯,再也不贅述。

接下來咱們開始hang模塊,實現「玩家單擊拿起揹包裏的物品A、單擊另外一個物品B,交換兩個物品的位置」。首先要明確一點,從代碼的角度說,「把一個物品放到一個空格子」和「交換兩個物品的位置」沒有任何區別,由於前者能夠當作物品和空格子的交換。咱們只須要把兩個物品格子的索引i和j傳遞給服務端就好。

大概的邏輯以下:

// hang.js

const hang = {};

hang.isHanging = false;
hang.index = -1;
hang.lastType = '';
hang.$view = UI.add({
    name: 'hangView',
    style: {},
    zIndex: Number.MAX_SAFE_INTEGER // 多寫幾個9也行 
});

hang.start = function ({$sprite, type, index}) {
    if (!this.isHanging) {
        this.isHanging = true;
        this.index = index;
        this.lastType = type;
        this.$view.content.img = $sprite.content.img;
        this.$view.style = {
            tx: () => global.mouse.x, // 把鼠標座標記錄到這裏的邏輯不贅述
            ty: () => global.mouse.y,
        };
    } else {
        // 這裏只列出上一次點擊和本次點擊都來自揹包的場景
        if (type === 'pack' && this.lastType === 'pack') {
            this.isHanging = false;
            // 假設toServer是發送socket消息給服務端的一個方法
            toServer('PACK_CHANGE', hang.index, index);
        }
    }
};

hang.cancel = function () {
    this.isHanging = false;
    delete this.$view.content.img;
};

export default hang;
複製代碼

首先,hang模塊擁有一個掛在UI層的對象$view。當點擊了揹包中的一個物品時,把這個物品的img傳遞過來展現,同時讓這個$view跟隨鼠標指針。(固然,這時還須要隱藏揹包中的那個物品,此處不贅述。)

當調用了cancel後,幹掉這個$view裏面的img便可(同時也幹掉剛纔說的「隱藏揹包中的那個物品」的沒有贅述的邏輯)。這樣就實現了點擊左鍵,「拾起物品」的功能。若是已經拾起了一個物品,就會調用toServer方法,向服務端發送2個物品的索引。

而服務端要作的是,校驗玩家登陸態,而後對揹包的array作一下array[i]=[array[j], array[j]=array[i]][0](其實就是第i和第j的元素交換,以前看到別人的寫法比較巧妙,拿來用了)。(固然,若是是對快捷欄進行操做,還要判斷一下物品類型,由於只有藥品和卷軸能夠放到這幾個位置。此處再也不贅述。)

最後,服務端將新的array推送給客戶端,客戶端更新一下便可。看起來大功告成了?

並無!若是存在網絡延遲,那麼極可能出現這樣的狀況:玩家想要交換物品A和B的位置,而後丟棄物品B。可是因爲網絡問題,交換還沒完成,丟棄指令已經發出了。因而玩家把物品A扔了出去。也許物品A是一個價值連城的寶物。

如何避免這樣的case呢?首先,玩家要丟什麼東西,是根據「揹包中物品的圖片」來進行識別的。玩家必定不能接受的是,選擇一個物品B,丟出去以後,就變成物品A了。哪怕丟棄失敗,從新丟一次,也比錯誤的執行要好。

因此,咱們須要經過物品的ID來解決這個問題。玩家丟棄物品的時候,咱們記錄下「跟隨鼠標運動的那個物品的ID」併發給服務端,這樣才能夠確保即便客戶端渲染物品列表的時候,即便因爲延遲致使了索引順序錯誤,玩家也不會誤操做到另外一個物品。固然,更保險的作法是帶着索引和物品ID,服務端再作一次校驗。

這樣,咱們能夠在玩家操做了以後,馬上更新客戶端的array,當服務端響應成功以後,再返回新的array給客戶端(固然也能夠只返回變化的部分或者操做的結果,來節約傳輸數據的大小)。固然理想狀況下這2個array就是相同的,若是不一樣的話,咱們用服務端的array去替換客戶端的array。一些遊戲中因爲網絡較差,致使用戶的行爲被撤銷,也是一樣的緣由。

這樣,hang模塊就實現了揹包中2個物品的交換。至於揹包和其它對話框的聯動,例如把揹包中的圖頻放到人物的裝備槽,能夠經過對hang進行邏輯的補充實現。

至於展現物品信息的那個浮層,邏輯和上面相似,此處也再也不贅述。而剛纔提到的一些問題,例如對着揹包放技能,將在後續專門的部分介紹。

人物UI

弄懂了揹包以後,人物的實現就比較簡單。

人物UI的左側有上下兩個箭頭,能夠切換展現裝備、狀態、技能等。咱們要作的就是,把UI的輪廓圖切出來,而後再把每一個面板也切出來,進行拼接組合。以下:

role拆分

而後用Easycanvas庫來add一個父元素做爲框架,再向父元素填充幾個children就能夠了。咱們經過一個變量來控制當前展現到了第幾個面板:

var $role = UI.add({
    name: 'role', // role是角色的意思
    content: {
        img: 'role.png'
    },
    // 事件後面再提
});

$role.add({
    name: 'role-equip', // 第一頁是人物裝備
    content: {
        img: 'roleEquip.jpg'
    },
    style: {
        // 箭頭函數看不習慣的話,也能夠寫function,當role.index爲0是可見
        visible: () => role.index === 0
    }
});

$role.add({
    name: 'role-state', // 第二頁是人物狀態
    ……
複製代碼

而後,相似咱們向背包中增長格子的方式那樣,把人物裝備的幾個格子綁定到一個array或者object類型的數據上就能夠了。第二頁的屬性能夠採用在圖片上寫字符串的形式。乾貨很少,此處也再也不贅述了。

那麼,如何監聽「玩家把揹包UI中的一個裝備,拿到人物UI的裝備槽」呢?

在遊戲的第一個版本,我只給揹包物品綁定了「雙擊時,發送使用物品的請求到服務端」的事件,而玩家佩戴裝備也使用雙擊揹包中裝備的方式來進行(是的,官方也能夠這樣作)。我原本打算偷個懶,不作兩個UI對話框的聯動邏輯,可是後來發現這個躲不開,由於後面還會有倉庫UI,玩家確定會手動來移動物品的。若是讓玩家雙擊物品來進行存取操做,我想確定會被扣上「反人類」的帽子。

因此,我給人物裝備的每個格子也綁定了一個點擊事件。還記得揹包UI中的hang模塊嗎?點擊人物裝備的格子,一樣調用hang模塊。當咱們發現hang模塊中有一個來自於揹包的物品了,那麼點擊人物裝備就直接調用「使用裝備」指令。

So,人物裝備裏每個格子須要綁定的單擊事件的處理邏輯就是:

  • 若是此時hang模塊已經有一個活躍的「來自揹包UI的物品」,嘗試佩戴此物品。(服務端發現這個位置已經有一個裝備了,那麼會先執行「卸下裝備」。)

  • 若是此時hang模塊是閒置的,而這個格子已經穿戴了裝備,那麼把它丟進hang(用戶拿起了身上穿着的裝備)。而且,爲點擊揹包格子補充一個事件:若是發現hang裏有一個來自於人物UI的物品,那麼執行「卸下裝備」。

  • 若是此時hang模塊已經有一個活躍的「來自人物UI物品」,那麼告訴服務端,我要交換2個身上的裝備(例如左、右兩個手套)。固然服務端會check一下是否能夠交換,好比不能把鞋子套在頭上。

一樣,每次服務端處理完畢後,將角色UI用到的數據以及揹包UI裏更新的數據推到客戶端瀏覽器,進行更新。固然,人物UI的裝備格子也須要綁定鼠標的移入,喚起浮層,展現裝備信息。整我的物UI的代碼量較大,可是都是邏輯代碼,沒什麼亮點,本文省略。只要作好模塊的封裝,將通用邏輯寫到公用模塊便可。

5.精靈層的實現

精靈層包括人物、動物(NPC、怪物、場景裝飾)、技能等核心要素。開篇提到,這層的FPS至少須要40.下面開始逐一介紹:

人物移動

人物移動的數據邏輯

首先,人物的跑動會和地面聯動。人物跑動修改global數據中的x和y座標,觸發地面的平移效果。這裏涉及到如下兩個點:

  • 玩家操做人物移動時,是正常通行仍是被障礙物擋住,這個判斷要在客戶端作。若是在服務端作,那麼每跑一步就要發送請求給服務端,而後服務端返回是否成功,先不說網絡延遲會不會致使用戶感受操做不流暢,單單是這個觸發的頻率就足以擠爆服務器。客戶端遊戲一般的作法是,將地圖中哪些地方能夠通行儲存在文件中,玩家安裝遊戲時下載到本地解析。而網頁遊戲的話,用戶每次進入一個地圖或者區塊,服務端發送當前地圖或者區塊的數據(大數組)。固然,這個數據最好作一下瀏覽器緩存(localStorage),畢竟一個遊戲不可能常常改地圖。

  • 客戶端連續上報座標給服務端,服務端進行處理,再連續分發給其它玩家。這個上報的時間間隔不宜太長。假如1秒上報一次,那麼玩家A看到的玩家B,將永遠是1秒鐘以前的玩家B。通常來講,間隔0.5秒已經不太能被接受了。我十幾年前和朋友去網吧聯機玩,我倆一塊兒跑步,在他的屏幕中他跑在我前面一點,在個人屏幕中我跑在前面一點,就是客戶端上報間隔和服務器下發間隔一塊兒形成的。固然,只要差的很少,就不會有問題。(多少能夠稱爲「很少」呢?這個取決於這段距離的偏差,是否影響了釋放技能的結果斷定。後面會提到。)

那麼如何防止玩家篡改數據,從而實現「水中漂」的做弊手法呢?好比(200, 300)是一個水池,誰也跑不到這裏。可是我在網絡請求中告訴服務端:「我如今就位於(200, 300),你來咬我啊~」。

比較簡單的作法是,咱們在服務端判斷一下這個座標點是否能夠抵達,若是不是,推一個消息給客戶端,讓客戶端刷新一下位置(玩家會感到卡了一下而後人物彈了回去)。同時,咱們不把這個無效的數據存下來,其它用戶也就不會看到(其它玩家不必看到我跑到水池中,而後再彈回去的過程)。服務端要作的就是記錄事實、陳述事實,而不是接受玩家上報的全部信息。假設有人對岸邊的我發起攻擊,那麼在服務端的眼中,攻擊有效。至於做弊的人看到「本身在水池裏,竟然還能被砍到」,無、所、謂!沒有必要爲一個做弊的用戶寫太多兼容邏輯,由於不須要爲這樣的用戶提供良好的遊戲體驗

更高級一點的作法是,咱們在服務端先判斷這個點是否能夠經過,而後判斷玩家在時間內是否有可能到達這個點。好比有人上一秒上報本身在(100,100),下一秒上報本身在(900,900),那麼必定是有問題的。咱們用距離除以上報時間間隔,和玩家的速度比對一下便可。固然,要留有必定的冗餘,由於玩家可能網絡不穩定,上報的頻率有些抖動,這樣計算下來個別時間段的速度偏快一些,是正常的。由此,咱們也知道了,在某款網絡遊戲的外掛中,爲何開1.1倍速通常沒問題,開1.5倍速就會頻繁掉線。由於服務端設置了10%的冗餘。固然,能夠經過判斷連續N秒內玩家一共走的距離,來識別這些「每秒鐘都悄悄多走了一小段距離」的玩家。

或者,咱們能夠把上報的座標加密,或者上報時額外上報用戶的鼠標移動軌跡等信息,來識別操做是否合法。不過這樣作只是提升了做弊的門檻,沒法防住全部狀況,即便咱們動態地生成密鑰。畢竟不少網絡遊戲都有自動跑步的掛,只要不損害其餘玩家的利益就好。

人物移動的視圖邏輯

(因爲內容過長,其它內容暫時放在github的wiki中)

相關文章
相關標籤/搜索