GameMaker: Studio 中文教程 #8: 惡魔行者與AI

引言

indienova 會員青銅的幻想為希望了解學習 GameMaker: Studio 的中文讀者專門撰寫了本系列教程。由於他目前正在參與的《冰杖秘聞》開發工作日益繁重,本次教材將是正篇教材的最後一期,之後會以特別篇的形式進行小主題更新,大家對這個系列有什麼建議也歡迎留言說明。

教程文件

本次教程共享至 GitHub 的資源文件分為兩部分:

  • 其一位於GMS_TUT_07_BASE目錄中,是對上一次教程的項目文件進行重構後的結果,作為此次教程後續內容的基礎。
  • 其二位於GMS_TUT_07目錄中,包含此次教程完成後的全部內容。

教程目標

在上一次教程里,我們加入了惡魔行者這個敵方角色,但只是作為一個傀儡的存在。這一次,我們要將他變成疾行在黑暗中的冷血殺手!

再次重構

如果你看過上一次的教程,應該會還記得惡魔行者的腳本 DevilCreate、DevilStep 等最初是從伊瑟拉的腳本複製過來,然後進行修改的。當時的出發點是為了能夠複製一些必要的功能過來,從而快速的添加一個簡單的惡魔行者。我們對比一下現在這兩者腳本的差異,以 YseraCreate 腳本和 DevilCreate 腳本為例:

除了前面聲明枚舉變數 PlayerDirection 的地方之外,唯一的差別就是在後面添加了這幾行代碼:

m_attachedHitbox = instance_create(x, y, obj_hitbox);m_attachedHitbox.m_attachedParent = id;m_hp = 2;m_isDead = false;

主要作用是為角色添加碰撞盒、增加生命值變數和標誌角色死亡的變數,但我們想想看,其實這部分的代碼正是伊瑟拉也需要的功能。這說明兩者的創建腳本是完全一樣的,這樣的重複就是我們需要為這兩個人物提取出一個共同的父類的強烈信號。

於是我們新建一個Object類別叫做obj_character(人物類別)作為他們的父類,它在這個遊戲的類別繼承關係中是這樣的,在加入它之前:

在插入人物類別以後:

具體的重構過程不再贅述,主要的思路就是將兩者共有的代碼移入父類的對應腳本中,而具體取值的差異,例如伊瑟拉的站立動畫是spr_ysera_idle,惡魔行者的站立動畫是spr_devil_idle。對於這種情況可以新建一個變數spr_character_idle,然後對兩者分別賦值即可。這裡給出一個重構前後的代碼行數對比:

重構前共計 252 行代碼:

YseraCreate:14YseraStep:107YseraAnimationEnd:11DevilCreate:12DevilStep:90DevilAnimationEnd:17DevilOnDamage:1

重構後變動如下:

YseraCreate:12YseraStep:64YseraAnimationEnd:該腳本刪除DevilCreate:12DevilStep:38DevilAnimationEnd:該腳本刪除DevilOnDamage:該腳本刪除CharacterCreate:33CharacterStep:52CharacterAnimationEnd:17CharacterOnDamage:1

重構之後的代碼總行數變為 229 行。減少的部分主要來自於從兩個子類移入父類的代碼,而增加的部分來自於新增變數的初始化和在子類中賦值的部分,但總的代碼量還是減少了。重構後的項目文件已放入 GitHub 項目的 GMS_TUT_07_BASE 文件夾中,作為本次教材的基礎供讀者參考。

AI行為模式

說起 AI,常常會給人一種高深、神秘的感覺。但現實情況是,高深而神秘的 AI 一般只存在於學術界的前沿課題中,而遊戲里需要具體實現的 AI 系統通常只是運用了簡單的「規則」。

由於知乎圖片大小限制,下面幾段的圖片請移步這裡查看。

為了讓我觀點更具有說服力,我特意挑選了一款最近玩過的遊戲《挺進地牢》來作為驗證。首先讓我們來看看以下場景。

這裡你看到了什麼?只是一群瘋狂追逐著你的敵人和彈幕,你很難察覺到規則的存在。但如果我們將這個場景中三種敵人的行為分解開來看,你就會發現第一種敵人是一個一次射擊一發子彈的雜兵。

在這個動圖裡可以清楚的看到它的所有邏輯可以很簡單的描述成:靠近至主角到一定距離後以一定的頻率向主角射出一發子彈。

第二種敵人和前者類似,唯一的區別是發出一圈扇形的子彈。

第三種敵人同樣是嘗試接近遊戲主角,只是到一個更近的距離,然後在一段時間蓄力後衝撞玩家人物。

如果單看每種敵人,邏輯都很簡單,但當策劃把不同種類的敵人放在一起,他們之間的各種組合以及玩家自發的反應結合起來,就奇妙的形成了一個生機勃勃的戰鬥場景。

因此對於我們要賦予惡魔行者的AI,也並不需要多麼複雜,根據我們的策劃案,惡魔行者是一個敏捷的近戰,他的行為描述如下:

  • 向玩家方向移動
  • 當與玩家間小於一定距離後對玩家發起衝刺,衝刺的目標是玩家角色當前位置兩側,該目標確定後不再隨玩家角色移動而改變
  • 衝刺到達目標位置後向玩家角色所在的方向發起一次攻擊
  • 攻擊完成後向遠離玩家的方向後退2秒鐘,然後重複第1步

有限狀態機模型

「有限狀態機」這個詞同樣源自計算機科學,如果你去谷歌搜索一下,你可能又會迷失在一大堆有的沒的專業術語之中。以上面提到的惡魔行者的行為為例,我這裡簡單介紹一下有限狀態機在遊戲開發中的具體應用:

  • 狀態的定義。如果按照以上的描述,可以為惡魔行者建立4個狀態——追蹤、衝刺、攻擊、撤退。
  • 每種狀態下的行為。例如「追蹤」狀態下,他的行為是想玩家角色所在的方向移動。
  • 狀態的切換。在「追蹤」狀態下,當與玩家角色的距離小於一定數值時,切換至「衝刺」狀態。
  • 狀態切換時的行為。例如從「追蹤」狀態切換到「衝刺」狀態時,是不是需要給角色播放一個大喝一聲的音效呢?

我們為他建立的狀態機模型可以按下圖來描述:

在我看來,把這樣的狀態模型用圖的形式描述出來的最大好處是方便你思考現有模型是否合理,狀態切換有沒有其他的可能性。例如,如果主角有瞬間移動的功能可以突然出現在惡魔行者的面前,那麼他有沒可能直接在追蹤狀態下發起攻擊,跳過衝刺的過程呢?如果在追蹤的時候,惡魔行者受到了攻擊生命值很低,我們要不要設定他直接從追蹤狀態切換到撤退狀態呢?這些都是在開始具體實現之前需要考慮的問題。但在這個教程中,我們將完全按照這張圖中所描述的模型來實現。

有限狀態機在 GMS 中的實現

有限狀態機的實現方式非常靈活,最簡單的一種就是將枚舉類型與 if-else 語句配合使用。

首先在腳本 DevilCreate中聲明枚舉變數,並將初始狀態設置為「追蹤」:

enum DevilState{ DEVIL_FOLLOW, //追蹤 DEVIL_DASH, //衝刺 DEVIL_ATTACK, //攻擊 DEVIL_RETREAT //撤退}m_devilState = DevilState.DEVIL_FOLLOW;

然後就是整個AI行為控制的主循環:

if(m_devilState == DevilState.DEVIL_FOLLOW){ DevilUpdateFollow();}else if(m_devilState == DevilState.DEVIL_DASH){ DevilUpdateDash();}else if(m_devilState == DevilState.DEVIL_ATTACK){ DevilUpdateAttack();}else if(m_devilState == DevilState.DEVIL_RETREAT){ DevilUpdateRetreat();}

這些 DevilUpdateXXX函數,簡單來說就是在哪個狀態就干哪個狀態該乾的事,同時輔助狀態的切換。

但值得注意的是,由於 GML 中並不支持一個腳本文件中定義多個函數,因此需要對每個 DevilUpdateXXX 函數定義一個同名腳本,即新建 DevilUpdateFollow、DevilUpdateDash、DevilUpdateAtttack 和 DevilUpdateRetreat 四個新的腳本。

在明確了代碼的結構和功能後,剩下的工作就是具體的編碼實現了,這可能反倒是遊戲開發中較為容易的部分。

追蹤狀態

首先從 DevilUpdateFollow 開始,這部分其實就是在上一次的教程里惡魔行者的行動代碼,只需要從 DevilStep 搬運至 DevilUpdateFollow 中即可,但需要在這裡加入狀態切換的條件。

DevilCreate腳本中新增變數有:

//用於定義從追蹤狀態切換至衝刺狀態的距離範圍m_dashDistance = 200;//用於定義衝刺至玩家角色兩側的距離,這個距離應當與惡魔行者的攻擊動畫匹配。若玩家角色保持靜止不動,那麼惡魔行者衝刺到這裡時應當正好能夠攻擊到她m_dashDelta = 40;//衝刺的終點目標m_dashTargetX = 0;m_dashTargetY = 0;DevilUpdateFollow腳本中狀態切換的代碼:if (distance_to_point(player.x, player.y) < m_dashDistance){ m_devilState = DevilState.DEVIL_DASH; if(x < player.x){//衝刺至玩家左側 m_dashTargetX = player.x - m_dashDelta; } else{//衝刺至玩家右側 m_dashTargetX = player.x + m_dashDelta; } m_dashTargetY = player.y;}

這段代碼應該也很好理解,就是當惡魔行者距玩家的距離小於設定數值時,切換至衝刺狀態,並設置衝刺的終點。如果當前他的位置在玩家左側,那麼就向左側衝刺,反之亦然。

寫了這麼多代碼,來測試一下:

惡魔行者在行至玩家一定距離處就停住了,停住我們就放心了,說明他進入了衝刺狀態,而衝刺狀態我們還什麼都沒有做。

衝刺狀態

接下來我們來添加 DevilUpdateDash 腳本中的代碼,它所做的事情與追蹤狀態類似,主要的差別是目標點換成了m_dashTargetX 和 m_dashTargetY所定義的坐標,但這次我們嘗試用與之前追蹤狀態里不同的方式來實現:

var distance = distance_to_point(m_dashTargetX, m_dashTargetY);var deltaX = (m_dashTargetX - phy_position_x)/distance * m_dashSpeed;var deltaY = (m_dashTargetY - phy_position_y)/distance * m_dashSpeed; if(distance < m_dashSpeed){ phy_position_x = m_dashTargetX; phy_position_y = m_dashTargetY; m_devilState = DevilState.DEVIL_ATTACK; m_isAttacking = true; sprite_index = spr_devil_attack; image_index = 0; m_fired = false;}else{ phy_position_x += deltaX; phy_position_y += deltaY;}if(deltaX > 0){ image_xscale = -1;}else if(deltaX < 0){ image_xscale = 1;}

在 DevilUpdateFollow 腳本中,x 軸方向與 y 軸方向的運動距離是獨立計算的,這樣的做法會導致當人物在沿斜 45 度角移動時,速度要比沿水平或垂直方向移動的速度要快,因為它是這兩個方向移動的疊加。而目前在 DevilUpdateDash 腳本中,移動的距離是按照目標點與自身的連線方向計算的,因此能夠保證各個方向同樣的移動速度。

然後在達到衝刺目標後,設置為攻擊狀態,並播放攻擊動畫。好,那麼在添加了衝刺的代碼後再次進行測試:

很好,現在惡魔行者開始瘋狂的輸出了!

攻擊狀態與撤退狀態

在從衝刺狀態切換至攻擊狀態時,實際已經開始播放了攻擊動畫。所以在這個狀態里所要做的事情僅僅是等待攻擊動畫播放完畢時,然後切換至下一個狀態——撤退,並取消攻擊動畫和重置撤退時間。在 DevilUpdateAttack 腳本中:

if(m_isAttacking == false){ m_devilState = DevilState.DEVIL_RETREAT; sprite_index = spr_devil_walk; m_retreatCurrentTime = 0;}

在撤退的腳本 DevilUpdateRetreat 里,所做的事情和在追蹤與衝刺里的相反,惡魔行者向遠離玩家的方向前進,我們在DevilCreate中增加兩個變數:

m_retreatCurrentTime = 0;m_retreatTime = 2;

用於跟蹤撤退的時間,具體實現如下:

if(m_retreatCurrentTime < m_retreatTime){ var player = instance_find(obj_ysera, 0); var distance = distance_to_point(player.x, player.y); if(distance > 0){ var deltaX = (phy_position_x - player.x)/distance * m_retreatSpeed; var deltaY = (phy_position_y - player.y)/distance * m_retreatSpeed; phy_position_x += deltaX; phy_position_y += deltaY; } m_retreatCurrentTime += 1/30.0;}else{ m_devilState = DevilState.DEVIL_FOLLOW;}

運行測試一下吧。是不是看起來差不多像樣了:)

但實際上現在惡魔行者的攻擊還只是一個動畫而已,並沒有實際的碰撞檢測和減少主角生命值的功能。

惡魔行者的攻擊實現

想要讓他的攻擊造成傷害,其實現原理與伊瑟拉發出的魔法球實際上相當近似,我們可以想像成在惡魔行者揮出那一刀時發出了一陣劍氣,所有與劍氣碰撞的目標都會受到傷害。唯一所不同的是魔法球是可以持續飛行的,而劍氣在出現後立刻消失。

因此在具體編碼實現上,兩者也十分類似。對於伊瑟拉,我們有一個 Object 是 obj_ysera_magic_bullet,同樣我們也為惡魔行者建立一個用於進行碰撞檢測造成傷害的 Object,叫做 obj_devil_attack_area。但記住這個劍氣其實只是我所做的比喻,實際上你並不想讓玩家看到它,不過為了調試你可以在開發的初期給這個形狀一個半透明的顏色用來觀察它是否出現在了你想要的位置:

比如這個就是我初始設置的形狀顏色,當功能全部調試完成後,只要簡單的把這個圖形的不透明度(Opacity)設置成0就好了。

這個 Object 的具體實現首先需要設定碰撞形狀,其次是需要兩個與之關聯的腳本,一個用於在創建時利用 GMS 的 alarm 系統設定一個鬧鐘,另一個在鬧鐘到期時刪除它:

腳本 DevilAttackAreaCreate:

alarm[0] = 10;

這裡為了調試查看碰撞形狀,暫把鬧鐘時間設定為 10 幀以後。由於 GMS 默認遊戲是 30 幀,因此這個時間是三分之一秒鐘。在10幀過後,由於我們設定的是鬧鐘 0(alarm[0]),因此 Alarm 0 事件對應的行為會被調用,添加該事件是在事件窗口的以下選項:

在這個事件對應的行為中我們編寫代碼調用腳本 DevilAttackAreaAlarm,該腳本內容為:

instance_destroy();

最後我們把DevilCreate腳本中的這一行:

obj_character_bullet = noone;

改為:

obj_character_bullet = obj_devil_attack_area;

這樣,每次惡魔行者在做完攻擊動作後,都會生成一個obj_devil_attack_area作為碰撞檢測的物體,並在10幀後去除。最後再加上碰撞形狀在人物兩側進行攻擊時的偏移量(具體實現可參考源代碼及教程六中的相關部分),測試如下:

最後,如果你還記的我們在上次教程中為每個人物添加的全身碰撞盒,我們現在需要再為它加上與obj_devil_attack_area的碰撞,並設定一些碰撞條件:

if((m_attachedParent.object_index == obj_ysera && other.object_index == obj_devil_attack_area)|| (m_attachedParent.object_index == obj_devil && other.object_index == obj_ysera_magic_bullet)){ with(m_attachedParent) { CharacterOnDamage(); }}

這裡的含義是讓惡魔行者與伊瑟拉的魔法球進行碰撞,以及伊瑟拉與惡魔行者的攻擊判定碰撞。這裡在以後添加更多種類的敵人或者己方隊友時可擴展為將子彈類物品和人物分為「敵方」和「我方」兩類,然後令不屬同一方的子彈能與人物發生碰撞。

在這個改動後,伊瑟拉和惡魔行者之間相互傷害的流程就完整了:

上期教程的錯誤更正

感謝細心的網友小囧(821096877)在上期教程發現的一處錯誤,魔法球飛行尾跡的方向問題。問題產生的原因是當魔法球具有物理屬性後,不再能通過設置物體的 image_angle 屬性來控制它的旋轉方向,而是應該用 phy_rotation 來控制。這一點與 phy_position_x 和 phy_position_y類似,具有物理屬性的物體,你同樣無法直接設置它們的x和y坐標。

另外值得注意的一點是 phy_rotation 與 image_angle的旋轉方向是相反的,一個是順時針,一個是逆時針。

結束語

這個教程的初衷是在參與《冰杖秘聞》製作的過程中萌發的,我驚訝地發現 Game Maker:Studio 這款已經擁有了十多年歷史的遊戲引擎竟然可以如此完美地達到易用性與靈活性間的平衡。所以想要把製作過程中的一些經驗心得整理出來做成一個系列教程,能夠讓有心製作遊戲但對於編程又不那麼有信心的玩家有多一個選擇。此外,我也坦承希望通過這篇教材宣傳一下我們正在開發中的獨立遊戲《冰杖秘聞》。

因此,我最初的想法是希望這個教程是一個讓從來沒有接觸過 GMS 的新手都能跟著做遊戲的教程,最開始的規劃是至少做 10 期。但隨著教程項目的進展,內容豐富度與日俱增,我感覺教程的難度在飛速提升。不僅僅超過了新手的接受範疇,對我來說寫作難度也逐步提高。隨著《冰杖秘聞》開發工作日益繁重,這個教材系列從早期能夠把每個遇到的概念都講透徹,到後期很多地方只能遺憾地簡單帶過,以致最近兩期自我感覺並不滿意,風格有些近乎流水賬了。為了保證教材的質量,加之我也需要將精力重心轉移到《冰杖秘聞》的開發工作之中,因此,這篇教材可能是本系列正篇的最後一節了。

不過,這個系列並不會就此宣告終結,由於對這個教程我還非常依依不捨,在與 indienova 溝通協商後,我們計劃在正篇內容外再追加特別篇的教材 —— 下一期我們會特別教授配合版本控制工具與 Git 的使用來輔助進行多人協作的遊戲開發。比起某些具體的技術細節來說,是否使用版本控制工具絕對是玩一票和認真做的獨立遊戲開發者之間更大的差距。無論是個人開發還是多人合作,版本控制都是必不可少的。

正如前文提到的一些原因,特別篇部分的 GMS 教材可能不會完全都由我來撰寫,想參與本系列教材撰寫工作的開發者也可以站內給我們私信。

所以呢~ 老時間,下周再會!

推薦閱讀:

GDC2017: 《Lone Echo》中的VR動畫
如何開發一款老陰逼射擊遊戲?
【Unity】UGUI系列教程————UGUI基礎!界面拼接!
爐石製作人主講 - 以《爐石戰記》為例,探討跨平台遊戲的製作思維與設計
偏激地推薦一本書:《DOOM啟示錄》

TAG:GMS | 游戏开发 | 独立游戏 |