基於React 的高質量坦克大戰復刻版

blank

基於React 的高質量坦克大戰復刻版

坦克大戰當年紅遍大江南北,很多和我一樣的九零後應該都有著對這個遊戲的記憶。現在顯示器分辨率越來越高,使用矢量圖來實現像素風格遊戲,可以獲得非常高的展現質量。 該復刻版是我花了很長時間折騰的坦克大戰復刻版本,所有元素都使用矢量圖(SVG)進行渲染,針對網頁的交互方式重新設計了關卡編輯器,該復刻版新增了關卡選擇功能、自定義關卡管理功能等,另外它還包括了一個Gallery 頁面用於展示所有的遊戲元素,想必它一定可以勾起你的兒時回憶。

針對鼠標交互設計的關卡編輯器

點擊鼠標,選擇畫筆類型,在地圖中拖拽鼠標就即可完成關卡配置,再也不用擔心遊戲手柄按得手酸啦(●ˇ∀ˇ●)。

blank

方便的自定義關卡管理頁面

完成自定義關卡配置之後,可以將關卡訊息保存到瀏覽器緩存中(localStorage)。然後在關卡管理頁面編輯/刪除/下載這些關卡配置,當然你也點擊關卡縮略圖下方PLAY 按鈕直接開始自定義關卡。

blank

放大了很多倍的Gallery

瀏覽Gallery 頁面來更全面地了解遊戲中的各個元素。

blank

點擊這裡開始遊戲。歡迎大家在GitHub上star 本項目,如果發現遊戲的BUG也可以直接發起issue。本文後面的內容會大致介紹整個遊戲的開發過程,對React / Redux 感興趣的前端同學可以繼續往下看。


復刻版主要包括了下面五個方面的內容:素材、數據、展現、邏輯、AI。

一、素材

素材主要包括了以下幾個方面的內容:

  1. 圖片素材:例如坦克的形狀/顏色, 各種地形的形狀等;
  2. 關卡配置:每一關的戰場地形配置,一關內會出現的敵對坦克的數量和等級;
  3. 數值配置:例如子彈的速度,坦克的移動速度,道具鏟子的持續時間等;
  4. 遊戲場景:遊戲開始/結束場景,關卡結算場景等;
  5. 音效(該復刻版的音效部分尚未完成)。

圖片素材

圖片素材可以從網上下載得到, 該位圖圖片中包含了坦克大戰絕大部分的圖片素材。因為複刻版使用矢量圖來展現畫面,所以需要對位圖進行矢量化處理。位圖圖片中的每個像素點, 都需要被轉換為SVG 中1x1 的小矩形,這樣整個遊戲才會呈現像素風格。素材也可以適當地轉換為SVG 矩形元素或是SVG 路徑元素,以減少元素的數量,提升渲染性能。

大部分的素材,都是通過手工輸入的方式得到的。例如坦克生成時的閃光效果,其顏色為白色,形狀可認為是若干個矩形的疊加,我們使用若干個矩形元素就能表示閃光效果了。

對於一些比較複雜的素材,我將其分解為若干個部分,對每個部分進行矢量化,然後組裝起來得到整體素材。例如我們可以將坦克分解為以下幾個部分:

左侧的轮胎(left-tire): 轮胎的背景色 & 轮胎上的花纹
右侧的轮胎(right-tire):轮胎的背景色 & 轮胎上的花纹
坦克主体(tank-body): 坦克主体的轮廓 & 坦克主体上的装饰
坦克炮管(gun):一个矩形

一些素材的形狀具有一定的模式,此時可以採用循環/分支的方式來生成所需要的SVG 元素。例如一個完整的磚牆的大小為16x16,但是磚牆的左上/右上/左下/右下四個部分是完全一樣的,有了磚牆左上部分8x8 的SVG 之後,使用循環可以生成整個磚牆。

一些素材的形狀非常不規則,難以通過手工的方式進行輸入,例如子彈/坦克的爆炸效果,掉落道具的形狀。我使用腳本讀取原始素材中每個像素點的顏色值,然後將其轉換為一個字符,用於保存該點的顏色值。 React 渲染時, 根據字符渲染出對應顏色的矩形(1x1)。該方式可以方便地對素材進行矢量化,但是會導致React 組件數量大大增加,降低渲染效率。

素材矢量化的過程非常靈活,復刻版充分利用了循環/分支/組合簡化了矢量化過程。一些工具組件,例如<Pixel /><BitMap /> ,在矢量化的過程中提供了很大的便利。一項素材完成矢量化之後,可以將其放在Gallery 頁面進行查看,和原始素材進行對比,方便改正其中的錯誤。

關卡配置

坦克大戰WIKI上有完整的關卡配置表,根據配置表使用關卡編輯器生成關卡配置json文件即可,關卡列表中已經列出了所有遊戲自帶的關卡,玩家也可以使用關卡編輯器創建自定義關卡,自定義關卡數據將保存在localStorage 中。

數值配置

一部分數值配置比較明顯,多玩幾遍原版遊戲就可以找到規律,例如玩家的坦克數量、坦克升級過程、不同類型坦克子彈效果、擊毀不同類型坦克的得分等。其他數值配置的獲取較為繁瑣,例如子彈飛行速度、坦克移動速度、爆炸效果各幀的持續時間,這一部分大都從原版遊戲錄像中獲取。 該文件中記錄了一些我已經測量好的數值,可供參考。隨著遊戲的不斷完善,該文件也會不斷完善。

遊戲場景

坦克大戰中的不同場景的區分度很大,而同一場景的變化較小,對原版遊戲中不同場景進行截圖,復刻版根據這些截圖進行開發即可。

二、展現

經過第一步之後,遊戲的素材被封裝成了相應的React 組件或數據項,以方便後續使用,這一步將利用這些元素創建出遊戲界面。坦克大戰遊戲包括了若干頁面(遊戲頁面、標題頁面、關卡選擇頁面、關卡列表頁面、關卡編輯頁面等),文件app/App.tsx中使用了react-router來管理這些頁面組件,這樣一來玩家也可以通過手動在地址欄上輸入地址直接執行操作(例如直接開始某個關卡)。

React 的組件非常容易被組合,整個遊戲的展現過程也是react 組件不斷組合的過程。下圖是遊戲中主要場景BattleField的結構,可以看出BattleField組件由許多不同的組件組合而成,遊戲中的其他組件也類似,歸根結底由第一步中的素材組合而成。

exportclassBattleFieldextendsReact.PureComponent{render(){return(<gclassName="battle-field"><rectwidth={13*BLOCK_SIZE}height={13*BLOCK_SIZE}fill="#000000"/><RiverLayerrivers={rivers}/><SteelLayersteels={steels}/><BrickLayerbricks={bricks}/><SnowLayersnows={snows}/><Eaglex={eagle.x}y={eagle.y}broken={eagle.broken}/><gclassName="bullet-layer">{bullets.map((b,i)=><Bulletkey={i}bullet={b}/>)}</g><gclassName="tank-layer">{activeTanks.map(tank=><Tankkey={tank.tankId}tank={tank}/>)}</g><ForestLayerforests={forests}/><gclassName="power-up-layer">{powerUps.map(p=><PowerUpkey={p.powerUpId}powerUp={p}/>)}</g></g>)}}

React性能優化

復刻版中組件較多,組件變化頻率快(理想情況下是每秒60 幀),如果不對react 渲染進行優化,整個遊戲最終十分卡頓(5 幀/秒左右)。

優化一:應用React.PureComponent來過濾掉不需要的re-render。遊戲運行時,只有一小部分的組件會不斷更新(例如坦克和子彈),大部分的組件(例如游戲中的地形元素)保持不變。這些不怎麼變化的組件,在很多時候,前後兩次接收到的props 是相等的(shallow-equal)。讓組件繼承自React.PureComponent ,就可以跳過該組件大部分額外的re-render 。經過該步優化後,整個遊戲的幀率大幅上升,大部分情況下可以到達60 FPS。對於那些一直在變化的組件(例如子彈等),應用PureComponent 的收益很小,也許React.Component 更為合適。不過我在實際編程中也沒考慮那麼多,全盤使用了PureComponent。

優化二:經過優化一之後遊戲在短時間內生成大量組件的情況下仍會出現掉幀的現象,例如坦克爆炸效果出現的時候。優化一避免了組件更新時重複渲染,但無法優化組件加載時的初次渲染過程,當組件複雜的時候,組件初次渲染就會有較大的開銷。 這篇文章中提到了使用離屏畫布提升canvas性能,優化二的思路也是類似:將組件渲染的內容保存到SVG圖片中,下次渲染時直接使用準備好的圖片。遊戲中的爆炸效果、地形元素等組件的內容較為固定,其內容保存為圖片後可以被多次復用。該優化實現代碼如下:( 完整版代碼

import{renderToString}from'react-dom/server'constsvgns='http://www.w3.org/2000/svg'// imageKey到object-url的映射,一個imageKey對應了一張保存好的圖片constcache=newMap()classImageextendsReact.PureComponent{render(){const{imageKey,width,height,transform,children}=this.propsif(!cache.has(imageKey)){constopen=`<svg xmlns="${svgns}" width="${width}" height="${height}">`conststring=renderToString(<g>children</g>)constclose='</svg>'constmarkup=open+string+close//使用react-dom/server來生成SVG圖片constblob=newBlob([markup],{type:'image/svg+xml'})consturl=URL.createObjectURL(blob)cache.set(imageKey,url)}return<imagetransform={transform}href={cache.get(imageKey)}/>}}//其他地方像這樣使用<Image />classOtherComponentUsingImageextendsReact.PureComponent{render(){return(<ImageimageKey="Forest"transform="translate(32, 0)"width="16"height="16">{imageContent}</Image>)}}

應用優化二之後,即使在坦克爆炸、關卡地圖加載等情況下,遊戲依然能保持流暢。

三、數據

該復刻版使用Redux 來管理數據,數據結構使用來自Immutable.js 的Map、List 等。 reducer 層級整體較為扁平,不同方面的數據由各自的reducer 進行維護,root reducer 將多個子reducer 合併起來。下面是整個遊戲大致的數據結構, time是整個遊戲的時鐘, game記錄了若干遊戲狀態(當前關卡名稱、是否暫停、玩家的擊殺統計等), players記錄了遊戲中所有的玩家。除了上述三個字段,其他各個字段存放的數據都直接對應了遊戲場景中出現的內容,這點從字段名稱中應該也能看出來。

// 整个游戏的数据结构interface State { time : number game : GameRecord players : PlayersMap // 以下每个字段都对应了「场景中显示的内容」 bullets : BulletsMap explosions : ExplosionsMap map : MapRecord tanks : TanksMap flickers : FlickersMap texts : TextsMap powerUps : PowerUpsMap scores : ScoresMap // other reducers... }

該復刻版使用TypeScript 來進行開發,所有數據結構都包含了靜態類型,在VSCode 中將鼠標懸停在變量上方就可以直接看到變量的類型。遊戲還包含了許許多多其他類型的數據,這裡不進行展開解釋,感興趣的同學可以直接查看源代碼。 Redux中,我們需要使用Action來封裝「對state的修改」,復刻版中的action列表可以查看該文件,大部分action的含義可以直接從其命名中看出來,該文件中的action的抽象層級比較低,描述的內容較為簡單,遊戲中的某個事件往往需要使用多個action 才能描述,這也是使用redux-saga 的原因之一。

四、邏輯

從上面BattleField的渲染代碼中也可以看出,遊戲的展現取決於遊戲的數據,數據發生變化時,遊戲會更新視圖來反映最新的數據,而遊戲邏輯的目標就是根據用戶輸入和遊戲規則維護Store中的遊戲數據。遊戲的邏輯完全基於redux-saga實現,入口位於app/sagas/index.ts 。遊戲邏輯較為複雜,代碼量很大,我畫了一張saga 的結構圖用來幫助理解遊戲邏輯,在這裡也就不用文字來說明遊戲邏輯了。

blank

在這裡查看上圖的高清版本。上圖中淺紅色背景的saga在另一張圖片中

實現遊戲邏輯比較重要的一點是,每一個saga 實例都需要有明確的生命週期,這意味著我們需要回答下面這些問題:

  • 一個運行中的saga 實例代表著什麼?
    • 例如一個humanPlayerSaga實例代表「一個正在遊戲中的人類玩家」;
    • 一個humanController實例代表「一個人類玩家的控制器」。
  • 什麼時候創建saga 實例?什麼時候結束saga 運行?
  • 一個saga 實例運行之後可能會管理一些遊戲元素(子彈/地形/坦克等),如果該saga 實例被cancel,那麼需要執行哪些清理操作? (cancel 時的清理操作一般放在finally block 中)
  • 一個saga 實例運行時,可以保證哪些條件一定滿足?
    • 例如AIWorkerSaga實例在運行時,可以保證該電腦玩家的坦克一直處於活躍狀態(因為一旦電腦玩家的坦克被擊毀, AIWorkerSaga實例會立馬被cancel),這樣在該saga內就不再需要判斷坦克是否被擊毀了。

五、電腦玩家邏輯

本複刻版中的AI 目前還不是特別完善,和原版差別比較大,可能目前AI 有些過強了。 AI 仍然是基於redux-saga 進行實現,大致模型可以認為是一個簡單的分層狀態機。復刻版對坦克控制(包括方向控制和開火控制)進行了抽象,人類玩家和電腦玩家擁有統一的控制器接口。該統一接口的抽象級別較低,只有三個指令:前進/ 轉向/ 開火。類AITankCtx基於該統一接口實現了moveTo指令,上層邏輯使用moveTo指令可以使坦克移動到指定位置。不過moveTo 指令只支持橫向和縱向的移動,且無法判斷障礙物。函數followPath基於moveTo實現了坦克路徑跟隨功能,方便上層邏輯實現AI。總體來說,隨著不斷的封裝,指令的抽象級別越來越高。

為了實現AI 邏輯,復刻版還提供了很多計算環境訊息的函數,例如「坦克可以移動到哪些位置?」、「哪些位置可以擊中老鷹,如果可以的話,需要多少次開火?」,「電腦玩家坦克和人類玩家坦克的相對位置,如果一方開火的話是否能夠直接擊中另一方?」。當然復刻版還實現了基於BFS 的尋路算法,用來尋找一條當前位置到目標位置的路徑。

有了上述的準備工作之後,AI 邏輯的實現就輕鬆多了。本複刻版中定義了兩種AI 模式:

  1. wanderMode瞎逛模式:在此模式下,AI坦克會隨機選取一個目標位置,使用尋路算法計算一條到目標位置的路徑,然後使用followPath一直沿著該條路徑進行移動。在路徑跟隨過程中,AI 坦克會根據坦克前方的遊戲元素和一個隨機值來決定是否開火。
  2. attachEagleMode 攻擊老鷹模式:在此模式下,AI 坦克會選取一個能擊中老鷹的目標位置,然後計算一條路徑並進行「路徑跟隨」,到達目標位置之後坦克會向老鷹進行開火。

大部分時候AI 坦克都會處於瞎逛模式,瞎逛久了的話會隨機進入攻擊老鷹模式,實際玩下來,我發現這樣的AI 效果還不錯。

總結

有趣、折騰、把redux-saga 用了個遍。希望你能喜歡。另外感謝@花森豆幾@影-奕當我的測試工程師,感謝@mwindson幫我完成了一部分的素材。

PS再給自己寫的開源工具打個廣告吧,如果你在用Node.js寫爬蟲,並覺得從HTML文檔中抓取JSON很麻煩的話,可以試試temme選擇器,該選擇器用法詳見這篇文章

What do you think?

Written by marketer

blank

Redux-Saga 漫談

blank

從源碼全面剖析React 組件更新機制