分享回合制策略遊戲AI算法設計方法

原文:http://gamerboom.com/archives/45636

發表時間:2012-01-22 09:08:19

作者:Ed Welch


在動作類遊戲中,AI對手總是擁有完美的靈敏度和快速反應能力等天然優勢,所以這類遊戲的AI設計挑戰就是讓AI更爲人性化而非百戰不敗。

在回合制策略遊戲中,情況就會有所改變,速度和準確度並非重要致勝因素,聰明的人類玩家總有辦法輕鬆擊敗AI對手。實際上,我們根本不可能設計出能夠打敗骨灰級玩家的AI,但這並非問題的關鍵所在。

這裏的AI設計挑戰在於,讓AI的攻擊和防禦策略顯得機智而成熟,既要爲玩家創造挑戰,又要確保玩家最終總能獲勝。假如玩家深諳AI策略的套路,那麼他們很快就會感到無趣,所以必須爲他們創造一些不可預期的挑戰。

以典型的策略遊戲爲例

我們在此一款4X太空戰鬥遊戲爲例,玩家在遊戲中的任務是擴大並統治銀河系。每名玩家都有太空戰艦,殖民戰艦和自己的家園星球,並能夠殖民其他可居住的星球。

我們會先從最重要的任務開始,爲其編寫一個簡單的AI算法,令其分配靠近某項資源(例如星球或戰艦)的順序。保衛擁有生產隊列的星球是第一要務,因爲它們最有價值。

其次是保衛沒有生產隊列的殖民地,然後就是進攻敵人的家園星球,殖民可居住的星球,攻擊敵人戰艦,隨後修復破損的戰艦,最後就是探索未知的領域。所以我們要先着眼於首要任務,查看是否有敵軍試圖染指自己的殖民地。

從上圖中可以看出,敵人護衛艦X和Y正同時威脅AI的家園和殖民地。我們可以看到與敵軍最近的驅逐艦,並令其攻擊敵軍護衛艦。但這個算法存在一些瑕疵。如果要先對付Y,就得讓與其距離最近的驅逐般A採取攻擊行動。而當護衛艦X靠近時,我們就只剩下一個驅逐艦B可用,但B與其距離過遠,無法近身攻擊X,結果就讓X成功轟炸了我們的家園星球。很顯然,我們應該給B分配攻擊Y的任務,讓A去解決X。

這種簡單算法還會產生其他問題,讓我們看另一種更復雜的場景:

在這種新場景中,我們的驅逐艦A在上次攻擊中遭遇重創,無法再有效發揮戰鬥力。最明智的做法就令其返回基地進行修復。那麼我們就只剩下驅逐艦C和B可以保衛殖民地。但驅逐艦C因距離過遠,無法及時靠近敵軍護衛艦Y,它的位置更適合攻擊敵人的殖民地。與此同時,AI殖民者已經全副武裝,可以從其主要的殖民任務中脫身。

任務分值

爲了解決上述問題,我們首先得設計一個計分系統。爲每個任務分配以下的一般優先順序:

保衛殖民地 1
攻擊敵人殖民地 2
殖民星球 3
攻擊敵艦 4
維修受損的戰艦 5
探索未知領域 6

每個任務都有一個優先順序調節器,例如,保衛殖民地的任務會根據殖民地的價值而調節其優先順序(擁有生產隊列的殖民地價值最高)。與此類似,維修任務的優先順序也可根據戰艦受損的嚴重程度進行調整,殖民任務則根據星球“可居住程度”分出優先順序。

最後還要考慮負責執行任務的戰艦距離:

任務分值 = (6 - 一般優先順序 + 調節分值) / 戰艦與任務目標的距離

因此在前面的場景中,雖然防禦任務優先順序更靠前,但驅逐艦C進攻敵人殖民地的任務分值更高,因爲它與敵人大本營的距離更近。

除此之外,驅逐艦A因爲嚴重受損,其維修調節分值也會更高,再加上它與維修隊列的距離更近,所以它的維修任務分值明顯高於防禦任務。

算法大綱

整個算法可劃分爲4個步驟:

收集任務

AI會根據其傳感範圍列出一個敵軍戰艦和星球等目標,以及自己的資產列表。其任務內容如下:

當前目標 對應任務
靠近我方殖民地的敵艦 保衛殖民地
敵軍戰艦 攻擊敵軍
敵人殖民地 攻擊敵方星球
可居住的星球 殖民星球
破損的戰艦 維修戰艦
未知領域 探索未知領域

潛在任務

另一個問題就是,假如我們以錯誤的順序分配任務,可能就無法達到最佳資源利用效果。我們可以通過分階段分配任務解決問題。可以使用兩個不同的分類:潛在任務(PossibleAssignment)、任務(Task)。前者將爲潛在的“任務執行者”分配一個任務,並存儲“任務分值”。任務中還要存儲優先順序、順序調節器和目標。

現在我們要爲每個“任務執行者”分配一個潛在任務目標,但必須注意排除不可行的組合。例如,我們不能讓沒有武器的戰艦執行攻擊任務,也不能讓它在缺乏足夠燃料的情況下執行某些任務。算法代碼如下:

// listAsset contains a list of all assets (for instance ships)
for (n = 0; n < listTask.size(); n++) {
    for(f = 0; f < listAsset.size(); f++) {
        if (listAsset[f].isTaskSuitable(listTask[n])) {
            listPossAssignment.add(new PossibleAssignment(listTaskn]));
        }
    }
}

下一步我們就要計算每個潛在任務的分配分值,從最高分到最低分羅列出優先順序。最後,我們就能完成任務分配。任務分配完成之後,就要給任務執行者標註“忙碌”狀態,給該任務打上“已分配”標註,從而避免重複分配任務的情況。

以下是這種操作的部分代碼:

for (n = 0; n < listPossAssignment.size();n++) {
    listPossAssignment[n].assign();
}

public void PossibleAssignment::assign() {
    if (task.isAssigned()) return;
    possibleTaskDoer.assign(this);
}

public void Ship::assign(PossibleAssignment possAssign) {
    if (task != null) return;
    task = possAssign.getTask();
    possAssign.getTask().assign(this);
}

重用針對星球生產任務的算法

如果還有剩餘任務未分配,AI就得製造新的星際飛船進行補充。例如,我們已經發現有敵艦來襲,但卻沒有空餘的戰艦可擊退敵人,所以我們就得製造新戰艦來填補這個空缺。與之類似,如果我們探索到了一個可居住的星球,但手頭上卻沒有空閒的殖民者可供調遣,就需要創建新的殖民者。

生產隊列的創建優先順序分配與星際飛船的任務分配一樣,從分類圖表可以看出,各類飛船與星球都來源於SpaceObject,所以它們可以使用同樣的算法,僅需一些微小的調整即可。

簡化操作:拋棄舊任務

因爲這是一款回合制遊戲,每一個新回合開始之時,上一回合的分配的任務就要作廢。例如,你的驅逐艦打算攻擊的敵方護衛艦可能突然中途撤退,或者你發現自己打算殖民的星球已經被敵人捷足先登了,這樣你就不得不臨時改變策略。

最簡單的方法就是在每一回合開始時,拋棄所有的舊任務,重新分配資源。雖然這種做法看似缺乏效率,因爲並非所有的任務都需要調整,但它確實可以簡化AI代碼,這樣你就無需維持前幾個回合的任務。

對AI算法來說,保持代碼的簡潔性尤其重要,因爲這些代碼總是很容易就變得極爲複雜,增加調試和維護的難度。另外,我們必須在最後階段,即運算徹底完工時才能進行最優化任務分配。

中途意外狀況

在某個回合中,我們的戰艦有可能發現新的敵人殖民地或者敵艦。這時我們就要給自己的戰艦指派新的攻擊任務,但如果這些戰艦已經有任務在身,這就會造成其他問題。最簡單的的解決方法就是再次運行資源分配路徑,這樣纔能有效保證實現最優化的資源分配。

實際運用情況

這種AI算法是爲4X策略遊戲而設計,從上述例子中我們可以看出其控制敵人戰艦所體現出的智能特點。

戰艦可能會出乎意料地改變策略,假如敵艦彈藥耗盡,它就有可能突然撤離戰場,重返基地填充彈藥;如果它沒有可供其重返基地的燃料,可能就會轉向探索未知領域(這是它可執行的最後一個任務)。等到新戰艦出爐的時候,整個艦隊的任務順序可能都會重新調整。有些戰艦返回基地維修,讓新戰艦接手作戰任務。

這種相當簡單的算法不但容易執行和調試,而且還可以創造一個極具挑戰性的AI對手。

雖然這種算法是爲回合制策略遊戲而設計,但經過適當調整後也應該可以爲其他類型的策略遊戲所用。


遊戲邦注:原文發表於2007年7月27日,所涉事件及數據以當時爲準。(本文爲遊戲邦/gamerboom.com編譯,拒絕任何不保留版權的轉載,如需轉載請聯繫:遊戲邦)


Designing AI Algorithms For Turn-Based Strategy Games

by Ed Welch [Game Design]

In action games the AI opponent always has the natural advantage: perfect accuracy and lightning fast reflexes, so the challenge in designing the AI for those games is making it act more human and to be beatable.

In turn-based strategy games the tables are turned. Speed and accuracy are no longer important factors and the cunning and intuition of the human player will easily out match any AI opponent. In fact, it's nearly impossible to design a AI that can beat an experienced player, but that is not really the point anyway.

The challenge is to make the AI's attack and defense strategy to appear intelligent and thought out, providing a challenge but letting the player win in the end. Once the player has familiarized himself/herself with the tactics of the AI the game rapidly gets boring, so a certain amount of unpredictability is desirable.

Challenges Involved: Looking At A Typical Strategy Game

The AI design problem is easiest understood by taking a real life example, in this case we take a space based war game.

Our example is what's called a 4X game, where you must expand and dominate the galaxy. Each player has war ships and colony ships and starts with a home planet and can colonize habitable planets.

A first attempt at writing the AI would be a simple algorithm to assign orders to each resource (i.e. a planet, or ship), starting with the most important first. Defending planets with production queues has the highest priority, because they are the most valuable.

The next highest, is defending colonies without production queues, then attacking enemy home planets, then colonizing habitable planets, then attacking enemy ships, then repairing damaged ships and lastly exploring uncharted territory. So, we take the highest priority task first, and check for any enemy ships that are close to our colonies.

As you can see in the image above enemy frigates X & Y threaten both the AI's home world and colony. So, we find the closest warships and assign them to attack. You might see here the flaw here in our algorithm. If by chance, frigate Y is handled first, destroyer A will be assigned because it's the closest. Then, when Frigate X is processed, the only ship left to attack is destroyer B, which is too far away to reach it and Frigate X succeeds in bombing our home planet. It's obvious that Destroyer B should be assigned to Frigate Y and Destroyer A to Frigate X.

Also, other problems can occur with this simple algorithm. Have a look at a more complex scenario:

In this new scenario we have Destroyer A badly damaged from a previous attack. It would be a futile sacrifice sending it into battle again. It's wiser to send it back to the home planet for repair. So, that leaves Destroyers C and B to defend our colonies. But destroyer C is too far away to reach frigate Y in time and would be better served to bomb the enemy colony, seeing as it's so close (not to mention, that fuel conversation is important too). Meanwhile, the AI colonizer is armed, and could be diverted from its primary colonization mission.

The Solution: The Resource Assignment Algorithm

Assignment Scoring

In order to solve the problems detailed above, firstly we design a scoring system. Each task is assigned a general priority as follows:

Defending our colonies 1
Attacking enemy colonies 2
Colonizing planets 3
Attacking enemy ships 4
Repairing damaged ships 5
Exploring uncharted territory 6

Each task also has a priority modifier, for instance the defense task gets a modifier for the value of the colony (colonies with production queues get very high modifier). Likewise, the repair task gets a modifier depending on the amount of damage and the colonize task gets a modifier depending on the "habitability" of the planet.

Finally the distance of the assigned ship is taken into account, as follows:

assignment score = (6 – general priority + modifier) / distance to ship that is assigned

Therefore, in the previous scenario destroyer C would get a higher score for attacking the enemy colony, even though the defense task has a higher priority, just because it was so close to the enemy planet.

Also, the priority modifier of the repair task for Destroyer A is quite high because it's so badly damaged. Coupled with that it is close to a repair queue, and that means that it scores higher than the defense task.

Algorithm Outline

The overall algorithm is broken into 4 parts:

Gathering Tasks

The AI has a list of enemy ships and planets within sensor range, as well as a list of its own assets. Tasks that need to be done are generated as follows:

Object present Task generated
Enemy ship near colony Defend colony task
Enemy ship Attack ship task
Enemy colony Attack planet task
Habitable planet Colonize planet task
Damaged ship Repair ship task
Uncharted territory Explore task

Possible Assignments

The other part of the problem is that if we assign tasks in the wrong order the resource utilization will not be optimal. This can be resolved by assigning the tasks in phases. We use two special classes to help us: PossibleAssignment and Task. PossibleAssignment links a potential "task doer" (e.g. a ship) with a task and stores the "assignment score". Task stores the priority, priority modifier and objective.

Let's just take a quick look at our class hierarchy to make things clearer:

We generate a PossibleAssignment object for each combination of "task doer" to task. However, we eliminate impossible combinations. For instance, an unarmed ship cannot carry out an attack task, nor can it do a task if it doesn't have enough fuel to reach it. This is how the code looks:

// listAsset contains a list of all assets (for instance ships)
for (n = 0; n < listTask.size(); n++) {
    for(f = 0; f < listAsset.size(); f++) {
        if (listAsset[f].isTaskSuitable(listTask[n])) {
            listPossAssignment.add(new PossibleAssignment(listTaskn]));
        }
    }
}

Next, we calculate assignment scoring for each PossibleAssignment and sort the list in order, highest scores first. And finally, in the last stage, we physically make the assignments. Because the list has been sorted, the most effective assignments occur first. Once an assignment is made the task doer is marked as busy and also the task is marked as assigned, preventing double assignments.

Here is part of the code:

for (n = 0; n < listPossAssignment.size();n++) {
    listPossAssignment[n].assign();
}

public void PossibleAssignment::assign() {
    if (task.isAssigned()) return;
    possibleTaskDoer.assign(this);
}

public void Ship::assign(PossibleAssignment possAssign) {
    if (task != null) return;
    task = possAssign.getTask();
    possAssign.getTask().assign(this);
}

Reusing The Algorithm For Planet Production Assignment

The AI should manufacture new star ships if there are any leftover tasks that couldn't be taken care of by the existing fleet. For example, if we have spotted an enemy ship and there are no available warships to attack, then we need to build a new warship. Similarly, if there is a habitable planet and no available colonizers, then we need to build a new colonizer.

In fact, the build priorities for production queues are exactly the same as the task priorities for star ships. As you can see in the class diagram, both the classes Ship and Planet are derived from SpaceObject, so they both can be used in the same algorithm with little modification. This is a good example of code reuse in object oriented design.

The below diagram shows this in action:

Keeping Things Simple: Discarding Old Tasks

As this is a turn based game, at the start of each new turn all the tasks from the last turn become out-of-date. For instance, that enemy frigate that your destroyer was about to attack could suddenly retreat, or you could discover – to your horror – that the planet you were about to colonize has already been occupied by the enemy.

The easiest thing to do is just discard all tasks and call the resource assignment routine at the start of each turn. This may seem inefficient, because not all tasks need to be updated, however it does makes the AI code considerably less complicated, as you don't need to maintain tasks from previous turns.

Keeping the code uncomplicated is especially important in the case of AI algorithms, because these have a tendency grow overly complex very quickly, making debugging and maintenance very difficult. As well as that, all optimization tasks should be done at the final stage, after the algorithm has been completely finished, and then only if there is real evidence that the algorithm is to slow in the first place.

Mid-turn Surprises

During the course of our turn one of our ships may discover a new enemy colony, or ship. We could just assign a new attack task to the ship, but that would cause problems if it already had an existing task that was important. Again, the simplest and most fool-proof thing to do is just run the resource allocation routine again, as this guarantees the most optimal resource assignment.

Conclusion: How Does The Algorithm Work In Real Life?

This AI algorithm was designed during the development of a 4x strategy game (as you may have guessed from the example). In practice one got the impression that there was some sort of real intelligence behind the control of the enemy fleet.

Ships would change tactics unexpectedly. If an enemy ship ran out of ammo, it would suddenly break off battle and go back to base to re-arm. If it didn't have enough fuel to make it to base, then it would try to explore uncharted territory (the only useful task left). As new ships came out of the shipyards, the orders could change for the whole fleet. Some ships would return for repair and leave the fresh warships take up the attack.

Basically the algorithm provides good "bang for buck" ratio, a fairly uncomplicated algorithm that's easy to implement and debug, but yet provides a challenging AI opponent.

Even though, the algorithm was designed for one specific type of game it should be easily adaptable to other types of strategy game. (source:gamasutra)

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章