javascript高仿熱血傳奇遊戲

C92發表於2018-02-21

前言

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

demo

資料彙總

1.開發前的準備

為什麼要用Javascript來實現一款比較複雜的PC端遊戲

1.js實現PC端網遊是可行的。隨著PC、手機硬體配置的升級和瀏覽器的更新換代,以及H5各種庫的發展,js實現一款網遊的難度越來越低。這裡的難度主要是兩方面:瀏覽器的效能;js程式碼是否足夠易於擴充套件,以滿足於一款邏輯極其複雜的遊戲的迭代。

2.現階段的js遊戲裡,很少有規模較大的可供參考。涉及到多人聯機、服務端資料儲存、複雜互動的遊戲,大多數(幾乎全部)都是用flash開發的。但是flash畢竟在衰落,而js發展迅速,並且只要有瀏覽器就可以執行。

為什麼選擇了一款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、1、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個區塊分別為T15、T16、T17、T18,那麼玩家向右跑的時候,4個區塊開始向左平移。當玩家跑夠了480的距離(其實是容器跑了480的距離),就可以立即將容器放回去(向回移動480,回到原點),然後4個區塊變為T16、T17、T18、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、31、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中)

相關文章