Skip to content

如何编写你自己的WindBot AI

Mercury233 edited this page Nov 13, 2017 · 2 revisions

WindBot用C#开发。C#比较易于使用,因此编写一个AI并不困难。本文以编写一个光道卡组为例,介绍编写WindBot AI的方法。

English version:

How to create your WindBot AI

准备工作

  • Visual Studio
    在VS2015上测试通过,VS2010理论上可用.
  • 基本的编程基础
    变量、函数、类、对象、数组、if、for、while之类的基础知识。
  • 基本的YGOPro知识
    不会打牌也想教AI打牌?

开始吧

1. 组一个卡组

image

(我不会玩光道,这是我乱组的)

卡组以易用为优先。一张卡的用法越多,写AI就越难。

把ydk文件命名为AI_Lightsworn,放到windbot的decks文件夹里。

2. 创建Executor

Executor(与Java的那个无关)是执行者的意思,用来给每个卡组规定每张卡片的用法等。

在Game\AI\Decks下新建代码文件,命名为LightswornExecutor
(你操作时,LightswornExecutor可能已经被写好了,所以换个NewLightswornExecutor之类的名字)

在其中写以下代码:

using YGOSharp.OCGWrapper.Enums;
using System.Collections.Generic;
using WindBot;
using WindBot.Game;
using WindBot.Game.AI;

namespace WindBot.Game.AI.Decks
{
    [Deck("Lightsworn", "AI_Lightsworn")]
    public class LightswornExecutor : DefaultExecutor
    {

        public LightswornExecutor(GameAI ai, Duel duel)
            : base(ai, duel)
        {

        }

    }
}

可以看到,在WindBot.Game.AI.Decks下新建的LightswornExecutor继承了DefaultExecutor
Deck属性的第一个参数是卡组名,第二个是卡组文件名。

3. 跑一下看看

在YGOPro中建立主机后,使用以下参数在VS中启动

Deck=Lightsworn

image

如果一切正常,你可以看到AI加入房间并且准备了。

  • 如果AI没有出现,请确定是先建立主机才启动的WindBot。
  • 如果AI不准备,请确定卡组文件放到了正确的位置,并且WindBot有正确的cards.cdb。

但是开始游戏后我们会发现AI什么牌都不会出,这是因为我们没有给AI指定卡的使用方法。

WindBot只会使用卡组的Executor中指定了的卡,其他卡一律不做任何操作,除了攻击和必发效果。

所以我们要做的事就是为每张卡写用法。

(当然dalao可以尝试写一个所有卡都会用的Executor)

4. 建立卡名类

为便于在代码中指定卡名,我们建立一个CardId类,把每个卡名写成常量。

在LightswornExecutor下(不是构造函数里)创建CardId类,定义卡组里的每张卡的ID:

        public class CardId
        {
            public const int JudgmentDragon = 57774843;
            public const int Wulf = 58996430;
            public const int Garoth = 59019082;
            public const int Raiden = 77558536;
            public const int Lyla = 22624373;
            public const int Felis = 73176465;
            public const int Lumina = 95503687;
            public const int Minerva = 40164421;
            public const int Ryko = 21502796;
            public const int PerformageTrickClown = 67696066;
            public const int Goblindbergh = 25259669;
            public const int ThousandBlades = 1833916;
            public const int Honest = 37742478;
            public const int GlowUpBulb = 67441435;

            public const int SolarRecharge = 691925;
            public const int GalaxyCyclone = 5133471;
            public const int HarpiesFeatherDuster = 18144506;
            public const int ReinforcementOfTheArmy = 32807846;
            public const int MetalfoesFusion = 73594093;
            public const int ChargeOfTheLightBrigade = 94886282;

            public const int Michael = 4779823;
            public const int MinervaTheExalted = 30100551;
            public const int TrishulaDragonOfTheIceBarrier = 52687916;
            public const int ScarlightRedDragonArchfiend = 80666118;
            public const int PSYFramelordOmega = 74586817;
            public const int PSYFramelordZeta = 37192109;
            public const int NumberS39UtopiatheLightning = 56832966;
            public const int Number39Utopia = 84013237;
            public const int CastelTheSkyblasterMusketeer = 82633039;
            public const int EvilswarmExcitonKnight = 46772449;
            public const int DanteTravelerOfTheBurningAbyss = 83531441;
            public const int DecodeTalker = 1861629;
            public const int MissusRadiant = 3987233;
        }

image

如果你感觉英文卡名看不懂,也可以用中文,不过这会导致其他人看不懂,自行权衡吧。

5. 最简单的Executor,让AI会发羽毛扫,会通招莱登

除了卡组的Executor,每张卡片都应该有一个或多个Executor。

下面我们在LightswornExecutor的构造函数里注册两个Executor:

AddExecutor(ExecutorType.Activate, CardId.HarpiesFeatherDuster);

这让AI在HarpiesFeatherDuster可以发动时发动它。

AddExecutor(ExecutorType.Summon, CardId.Raiden);

这让AI在Raiden可以通常召唤时召唤它。

image

在AI的主要阶段,手里同时有莱登和羽毛扫,如何决定先后顺序呢?

答案是根据注册Executor的顺序,先注册的先操作。

每当AI可以发动效果或召唤,它会检查当前的卡组的Executor,依次判断里面的操作是否可用,如果可用则进行操作。
然后会重新从头开始,因为YGOPro不支持一次进行2个操作,而是在进行完一个操作之后重新发给客户端可进行的操作的列表。

6. 稍微复杂一点,满足条件才发增援,并且选择指定的卡

现在我们看下一张卡,假如我们想要增援在手里没有莱登时拿莱登,没有小飞机时拿小飞机,都有则不发动,怎么办?

在LightswornExecutor下(不是构造函数里)创建一个函数:

        private bool ReinforcementOfTheArmyEffect()
        {
            if (!Bot.HasInHand(CardId.Raiden))
            {
                AI.SelectCard(CardId.Raiden);
                return true;
            }
            else if (!Bot.HasInHand(CardId.Goblindbergh))
            {
                AI.SelectCard(CardId.Goblindbergh);
                return true;
            }
            return false;
        }

然后在LightswornExecutor的构造函数里注册增援的Executor:

AddExecutor(ExecutorType.Activate, CardId.ReinforcementOfTheArmy, ReinforcementOfTheArmyEffect);

这样,当ReinforcementOfTheArmyEffect返回true时,就会发动增援了。

BotClientField的一个实例,具有GetMonsters, HasInHand等方法。

AI.SelectCard的作用是预先选择卡片。在决定发动某个效果前调用,然后要选择卡片时就会按设定的目标选择了。

7. 使用默认的卡片Executor,以及一个技巧

DefaultExecutor里已经为很多卡片写了默认的Executor。因为我们的LightswornExecutor继承自它,我们可以直接使用。

比如银河旋风。

AddExecutor(ExecutorType.Activate, CardId.GalaxyCyclone, DefaultGalaxyCyclone);

DefaultGalaxyCyclone的功能是这卡在墓地时对方有表侧的魔陷就以它为对象发动,这卡在其他地方就以对方里侧的魔陷为对象发动。

现在有个小问题,如果手里同时有羽毛扫和银河旋风,对方盖了2张魔陷时,我们希望先发羽毛扫,但对方只有1张魔陷时就先发银河旋风,怎么办?

我们用的办法是为羽毛扫注册2个Executor,一个是当对方有2张以上魔陷时发动,一个是能发就发。

image

DefaultHarpiesFeatherDusterFirst就是对方有2张以上魔陷才发动。利用AI按顺序判断是否发动的特点,来达成我们的目的。

8. ExecutorType介绍

ExecutorType有如下几种:

  • Summon
    通常召唤,包括上级召唤
  • SpSummon
    特殊召唤,包括同调超量等,不包括仪式和融合,也不包括入连锁的特殊召唤。
  • Repos
    改变攻守
  • MonsterSet
    怪兽卡放置
  • SpellSet
    魔陷卡放置,包括古遗物等卡的作为魔陷放置
  • Activate
    发动,包括卡的发动和效果的发动,和诱发效果的发动
  • SummonOrSet
    对方怪兽比较强则放置,否则召唤

9. 一些“全局”变量

每个Executor设定的函数被执行前,都会将变量Card设置为当前被判断是否要发动或召唤的那张卡。

比如我们想让哥布林德伯格只在手里有这张卡以外的4星怪兽时才召唤:

        private bool GoblindberghSummon()
        {
            foreach (ClientCard card in Bot.Hand.GetMonsters())
            {
                if (!card.Equals(Card) && card.Level == 4)
                    return true;
            }
            return false;
        }

类似的变量还有:

  • ActivateDescription
    用于判断发动的是哪个效果(参考鸟铳士的DefaultCastelTheSkyblasterMusketeerEffect
  • LastChainPlayer
    上次发动效果的玩家,0表示自己,1表示对面,-1表示没有人(针对不入连锁的召唤的神宣等)
  • Duel的Player, ChainTargets, LastSummonPlayer, LifePoints 等属性

10. 一次发动选择多次卡

对于带COST的效果,或者废铁龙之类的效果,我们需要在一次发动中进行多次选择。而AI.SelectCard只能设定一个选择。

这时我们可以使用AI.SelectNextCardAI.SelectThirdCard来实现。

以露米娜丝的效果为例,我们想让她在墓地没有莱登时优先丢弃莱登,否则丢弃沃尔夫等卡,然后特殊召唤莱登等调整。

        private bool LuminaEffect()
        {
            if (!Bot.HasInGraveyard(CardId.Raiden) && Bot.HasInHand(CardId.Raiden))
            {
                AI.SelectCard(CardId.Raiden);
            }
            else
            {
                AI.SelectCard(new[] {
                    CardId.Wulf,
                    CardId.Felis,
                    CardId.Minerva,
                    CardId.ThousandBlades
                });
            }
            AI.SelectNextCard(new[] {
                    CardId.Raiden,
                    CardId.Felis
                });
            return true;
        }

当然,实际上的判断应该比这更详细,比如下面要讲到的小丑的判断……

11. 一张卡的多个效果

一张卡可能有多个效果,比如鸟铳士和光道圣女。这些效果可以被写到同一个Executor里,也可以写多个。

要判断发动的哪个效果,可以根据卡片的位置判断,同一个位置的多个效果可以根据效果的描述判断。

(比如青眼精灵龙的墓地效果发动无效和解放自己特殊召唤)

image

以光道圣女和鸟铳士的Executor为例:

        private bool MinervaTheExaltedEffect()
        {
            if (Card.Location == CardLocation.MonsterZone)
            {
                return true;
            }
            else
            {
                IList<ClientCard> targets = new List<ClientCard>();

                ClientCard target1 = AI.Utils.GetBestEnemyMonster();
                if (target1 != null)
                    targets.Add(target1);
                ClientCard target2 = AI.Utils.GetBestEnemySpell();
                if (target2 != null)
                    targets.Add(target2);

                foreach (ClientCard target in Enemy.GetMonsters())
                {
                    if (targets.Count >= 3)
                        break;
                    if (!targets.Contains(target))
                        targets.Add(target);
                }
                foreach (ClientCard target in Enemy.GetSpells())
                {
                    if (targets.Count >= 3)
                        break;
                    if (!targets.Contains(target))
                        targets.Add(target);
                }
                if (targets.Count == 0)
                    return false;

                AI.SelectNextCard(targets);
                return true;
            }
        }

光道圣女如果是场上的效果,直接发动,否则预先选择要破坏的3张卡再发动。

        protected bool DefaultCastelTheSkyblasterMusketeerEffect()
        {
            if (ActivateDescription == AI.Utils.GetStringId(_CardId.CastelTheSkyblasterMusketeer, 0))
                return false;
            ClientCard target = AI.Utils.GetProblematicEnemyCard();
            if (target != null)
            {
                AI.SelectNextCard(target);
                return true;
            }
            return false;
        }

鸟铳士如果是第一个效果,不发动。第二个效果选择需要解决的对方的卡发动。

值得注意的是,ActivateDescription为-1时,表示没有其他效果可选。

12. 如何进入战斗阶段?

答案是,主阶段能干的所有事情干完后,自动进入战斗阶段。

也就是说,注册的所有Executor都检查若干遍,没有返回true后,就会进入战斗阶段了。

需要注意的是,这个过程不是一次性的,发动或召唤任意卡之后,会重新从头开始检查。

具体到YGOPro的实现,主要阶段时,发送一个MSG_SELECT_IDLECMD给客户端,包含了所有能召唤/发动/覆盖的卡的列表。任意操作处理完成后,重新发送MSG_SELECT_IDLECMD

WindBot收到MSG_SELECT_IDLECMD后,依次检查注册的Executor,看是否可以发动或召唤,可以则调用Executor的条件来判断是否需要执行。执行完成后重新收到MSG_SELECT_IDLECMD,重复这一步骤。

选择连锁同理。

那么,有些操作想只在主要阶段2进行,怎么办?

判断Duel.Phase == DuelPhase.Main2就可以了。或者使用AI.Utils.IsTurn1OrMain2()

        public bool IsTurn1OrMain2()
        {
            return Duel.Turn == 1 || Duel.Phase == DuelPhase.Main2;
        }

13. 重载OnNewTurn实现回合开始时重置的变量

如果这个回合没有使用过戏法小丑的效果,我们会想把它丢到墓地,但如果已经使用过,就不必了。

这时我们可以在LightswornExecutor下注册一个ClownUsed的变量,当判断小丑是否发动效果时将它设置成true再发动小丑,然后在相关卡发动效果时检查它。

bool ClownUsed = false;
        private bool PerformageTrickClownEffect()
        {
            ClownUsed = true;
            AI.SelectPosition(CardPosition.FaceUpDefence);
            return true;
        }

我们还需要在回合开始时重置ClownUsed

Executor提供了OnNewTurn,默认回合开始时什么都不做,我们可以重载它。

        public override void OnNewTurn()
        {
            ClownUsed = false;
        }

这样,露米娜丝的判断就可以多一个else:

        private bool LuminaEffect()
        {
            if (!Bot.HasInGraveyard(CardId.Raiden) && Bot.HasInHand(CardId.Raiden))
            {
                AI.SelectCard(CardId.Raiden);
            }
            else if (!ClownUsed && Bot.HasInHand(CardId.PerformageTrickClown))
            {
                AI.SelectCard(CardId.PerformageTrickClown);
            }
            else
            {
...

14. 更多重载

除了OnNewTurn之外,Executor还有很多可以重载的方法。一些常用的如下:

  • OnSelectHand
    猜拳赢时决定先后攻
  • OnSelectCard
    处理复杂的选择卡片情况,参考青眼AI的龙觉醒旋律
  • OnSelectPendulumSummon
    选择要被灵摆召唤的怪兽
  • OnPreBattleBetween
    决定攻击是否不能进行

详细介绍一下OnPreBattleBetween

AI判断能否攻击之前,会以自己的attacker和对方的defender为参数调用一次OnPreBattleBetween,如果返回值为false,则表示不能攻击。但OnPreBattleBetween不直接比较攻击力,而是更新RealPower,由AI的其他部分来比较RealPower

所有AI继承的DefaultExecutor已经重载了OnPreBattleBetween,写了电光皇和水晶翼等常见卡的RealPower

我们的光道卡组投入了欧尼斯特,所以需要再重载一次,更新RealPower

        public override bool OnPreBattleBetween(ClientCard attacker, ClientCard defender)
        {
            if (!defender.IsMonsterHasPreventActivationEffectInBattle())
            {
                if (attacker.Attribute == (int)CardAttribute.Light && Bot.HasInHand(CardId.Honest))
                    attacker.RealPower = attacker.RealPower + defender.Attack;
            }
            return base.OnPreBattleBetween(attacker, defender);
        }

如果对方的defender不是电光皇等阻止欧尼斯特发动效果的卡,attacker是光属性,手里有欧尼斯特,就更新attacker的RealPower。然后返回被继承的OnPreBattleBetween继续处理其他情况。

15. 继续编写AI的一些提示

  • 看一遍已经写好的其他卡组AI
  • 有问题开Issue问
  • WindBot的许多功能并不完善,欢迎提建议
  • 太过遥远的就算了,比如山寨AlphaGo,你行你上,我反正不行