YGOPro AI脚本教程(译文)

本教程旨在教会大家如何为YGOPro编写或修改一个AI脚本。前几节是新手指引,而接下来的章节涵盖了一些更深入的知识,例如如何将自己的卡组集成到标准的AI。在此之前,要掌握一些Lua的基本知识和编程语言相关的知识,以及足够的耐心。AI脚本编程并不是那么容易的哦 :D

由于简书不支持目录的跳转,想用此功能的读者请到网盘下载相关文件的压缩包,里面包含此文的Markdown文件。本人用visual studio code编写此译文,你也可以使用其他支持Markdown的文本编辑器。

  本教程旨在教会大家如何为YGOPro编写或修改一个AI脚本。前几节是新手指引,而接下来的章节涵盖了一些更深入的知识,例如如何将自己的卡组集成到标准的AI。在此之前,要掌握一些Lua的基本知识和编程语言相关的知识,以及足够的耐心。AI脚本编程并不是那么容易的哦 :D

目录

  1. 概述
  2. 开始
  3. 详解ai-template.lua
  4. 编写我们的第一个AI
  5. 自定义函数
  6. 常见问题
  7. 使用卡片脚本系统的函数
  8. 添加新的卡组到已有的AI
  9. 写在最后

<h2 id='1'>1. 概述</h2>

  你可以通过点击“AI模式 (beta)”按钮,在YGOPro中与AI进行决斗。在接下来的屏幕中,你可以修改一些决斗的设置,然后单击“OK”。现在你可以选择一个卡组和一个AI使用的脚本文件。卡组必须放在YGOPro的"deck"文件夹下,脚本则位于"ai"目录下。默认的,你有3个ai可选:

  • "ai.lua",标准AI文件
  • "ai-cheating.lua",和"ai.lua"一样,只是这个AI进行了一些优化
  • "ai-exodialib.lua",AI_Exodia卡组的专用AI。AI脚本的版本为 0.26,这个文件已经被废弃可以删除,现在用标准AI来处理Exodia卡组。

  理论上,AI可以使用你为它组的任何卡组。然而,如果任意的卡没有在AI中搭配特定的脚本,它通常只是尽可能的发动。效果对象的选择也是随机的,所以AI在发动效果时往往检索或指向了错误的卡。
这也就是为什么有特定的卡组脚本在ai文件夹里面。这些卡组以"AI_"为前缀来标记,这样AI就可以使用它们。你可能选择了"Random Deck",那么AI会随机选用一个带"AI_"前缀的卡组。由于有可能重命名或移除AI卡组,所以随机卡组选项不会使用那些卡组。
就像Yugioh的卡片脚本一样,ai文件也是用Lua编写的——一门相当流行的脚本语言: http://www.lua.org/about.html

  这个教程有两个看起来相似的章节,采用不同的方式来实现相同的功能。前面一小节向你展示如何用基本的AI函数来实现,接下来的章节使用了让我的编程更加容易的自定义函数。使用它们可以缩减代码,并且可以预处理很多细节。当然,它们也有局限性,在你的特定问题中也不总是会显得那么好用。这也就是为什么我努力去解释它们的内部工作原理以及给你一小节内容关于如何利用“原生的”AI函数来编写他们的功能。

  如果你对那些原生的AI函数不感兴趣,又或者你已经很熟悉它们,随时跳到第5章:常用函数。如果你已经有一个可以工作的AI脚本,并且只想知道,如何集成你自己的卡组到标准AI脚本,第8章:添加新的卡组到已有的AI 就是你要的。

<h2 id='2'>2. 开始</h2>

  如果你想深入AI脚本编程,我建议你先去看看你的"ai"文件夹下的 ai-template.lua 文件。
当你选择AI来玩对战时它不会起作用,因为它只对开发有用。你可以用任意的文本编辑器来浏览和编辑 .lua 文件。我个人用的是 Notepad++: http://notepad-plus-plus.org/
也有一些其他的软件,比如 LuaEdit,但它已相当过时且在我的系统上会崩溃。如果你有编程经验,你当然也可以用你自己选择的IDE,只要它支持Lua。

  这个模板包含了很多重要的函数让你可以用来修改AI的行为。它也有注释和提示关于如何使用这些函数。关于函数,ai-template.lua 里面还有更详细的介绍。

  在编写AI文件的时候,运行测试脚本之前切记要用符号检查工具来排除一些错误。如果你IDE的已经支持这个,那就太好了。在我的Notepad++上,我用 这个文件。请按照readme的指引来添加符号检查功能到你的Notepad++。

<h2 id='3'>3. 详解ai-template.lua</h2>

  在我看来,深入AI脚本编程的最佳起点就是“ai-template.lua”文件。它位于你的YGOPro文件夹的“ai”文件夹下,这也是你可以获得的最基础的AI之一。它是一个完全可以自主工作的AI文件,它有友好的文档和包含了大部分你可以使用的AI函数。

  所以,我们会打开这个文件,来好好看看它。在文档的开头,我们可以看到一堆的注释,包括版本信息,更新日志,一些使用技巧。紧接着技巧,我们会有一份你可以在任意时刻调用的AI函数的列表:

AI.Chat(text) --text: a string

  这个函数用来让AI说话。你可以用它来输出信息,调试,调戏,辱骂或输出任何你想AI说的。

AI.GetPlayerLP(player) --1 = AI,2 = 玩家

  返回player当前的LP。

AI.GetCurrentPhase() --请参考 /script/constant.lua 里的阶段常量列表

  script文件夹下的“constant.lua”文件里列出了所有阶段常量,因此打开它然后搜索阶段常量。它们以 PHASE_为前缀,比如PHASE_MAIN2PHASE_BATTLE。你可以用这个函数的返回值与当前阶段进行比较就像下面这样:

If AI.GetCurrentPhase()==PHASE_BATTLE then -- 在战斗阶段进行某些操作
AI.GetOppMonsterZones()
AI.GetAIMonsterZones()
AI.GetOppSpellTrapZones()
AI.GetAISpellTrapZones()
AI.GetOppGraveyard()
AI.GetAIGraveyard()
AI.GetOppBanished()
AI.GetAIBanished()
AI.GetOppHand()
AI.GetAIHand()
AI.GetOppExtraDeck()
AI.GetAIExtraDeck()
AI.GetOppMainDeck()
AI.GetAIMainDeck()

  上述函数用来获取你想要的卡片列表。就像名字描述的那样,它们返回特定区域的卡片列表。如果那个区域没有卡,它会返回一个填满nil的列表。模板文件里面有使用样例。

  现在来到模板里面的第一行实际的脚本:

math.randomseed( require("os").time() )

  这行代码是必须的。我不知道它的确切作用,也许是设置一个随机数的种子并且与系统时间做同步。只管把这行放到你的AI文件就是了,这很重要。

接下来是:

function OnStartOfDuel()
  ...
end

  这个函数在每次决斗开始时会被系统自动调用。在模板中,它使用AI.Chat()函数来输出了一些信息。

  接下来是系统提供的一堆函数,用来给你在特定的时机使用AI来进行一些操作,它们包括:

OnSelectOption
OnSelectEffectYesNo
OnSelectYesNo
OnSelectPosition
OnSelectTribute
OnDeclareMonsterType
OnDeclareAttribute
OnDeclareCard
OnSelectNumber
OnSelectChain
OnSelectSum
OnSelectCard
OnSelectBattleCommand
OnSelectInitCommand

  这里的每一个函数都会在AI进行某种选择操作的时候由系统自动调用。它们大部分的名字都很好理解,又或者在模板中有注释来说明。我稍后会讲解当中重要的几个。

  在讲OnSelectInitCommand函数之前,你先看看下面这一些关于卡片的函数。

card.id
card.original_id
card.cardid
card.description
card.type
card.attack
card.defense
card.base_attack
card.base_defense
card.level
card.base_level
card.rank
card.race
card.attribute
card.position
card.setcode
card.location
card.xyz_material_count
card.xyz_materials
card.owner
card.status
card:is_affected_by(effect_type)
card:get_counter(counter_type)
card.previous_location
card.summon_type
card.lscale
card.rscale
card.equip_count
card:is_affectable_by_chain(index)
card:can_be_targeted_by_chain(index)
card:get_equipped_cards()
card:get_equip_target()
card.text_attack
card.text_defense

  它们大部分的意思都很明显了,其他的都有注释来说明。如果你有一个卡片对象,你可以通过这些函数获取它的各种信息。

<h2 id='4'>4. 编写我们的第一个AI</h2>

  太好了,现在我们已经看了一遍模板。但我们如何才能另外编写一个实际的AI呢?

  在这一章,我们会创建一个新的AI文件。我们会学习如何用AI脚本处理卡片,以及如何用函数来对卡片进行简单操作。注意,最终的结果看起来会有些混乱,并且写起来也不是那么的舒服。这章是为了展示AI内部是怎么工作的。如果你已经熟悉了那些AI函数,你可以跳到下一章去看那些用起来更加舒服的自定义函数。

  首先,我们会复制并重命名一份ai-template.lua。在本教程,我们会命名为 ai-tutorial.lua。随时删除一些不必要的内容,你没必要保留所有的注释,你可以随时在原本的模板里查阅它们。我已经准备了一份清理好的版本,你可以从这里下载。现在,你已经可以用这个文件来玩了。只要它放在ai文件夹里并且名称不是 ai-template.lua,它应该就是可用的。如果你尝试使用它并且给AI一些随机卡组,你会发现,AI不会用LOT,它只会尽可能的发动每一张单卡,甚至炸掉自己的卡。

  那么,我们如何来改进它呢?在这个例子中,我们会使用「魔导战士 破坏者」。我相信,你已经熟悉他的效果了,他可以摘除1个魔力指示物来破坏场上存在的1张魔法或陷阱卡。让我们组一个测试卡组来看看我们的AI如何处理它。这个卡组应该包含「破坏者」,1张发动后不会立刻离场的魔法卡(比如「光之护封剑」),以及一堆不用的填充卡。我喜欢用/爆裂模式怪兽来填充,但也可以是不能够被召唤或从手卡发动的任意卡。象这样的卡组可以作为一个测试卡组。开始一场游戏,确认勾选了“不切洗卡组”复选框(和“不检查卡组”——如果你用了一个非法的测试卡组),为AI玩家选择那个tutorial AI文件和测试卡组。现在你会看到问题了:AI发动了「护封剑」然后用「破坏者」破坏了它。因为AI自己的魔陷是可用的效果对象,「破坏者」可以发动,所以AI就这么做了。我们如何阻止它这么做呢?

<h3 id='4_1'>OnSelectInitCommand</h3>

  「破坏者」的召唤与发动是在OnSelectInitCommand函数里面处理的。OnSelectInit总是会被调用,如果AI可以在它的主要阶段执行动作, 它负责处理各种各样的东西,从通召到特召和操作怪兽和发动魔法卡还有发动或盖放陷阱卡以及进入下一个阶段。「破坏者」的效果是发动型效果,且只能在主要阶段发动,所以要在这里处理他。

  特别的,这段代码处理「破坏者」的发动:

if #cards.activatable_cards > 0 then
  return COMMAND_ACTIVATE,1
end

  cards.activatable_cards获取所有可以在此时发动的卡片的列表。那个“#”操作符统计列表里卡片的数量。return返回一个指令常量和一个索引。你可以在ai-template.lua里面查找那个常量,那个索引总是定位到列表中被发动的卡。因为这个函数只是单纯的全部发动,所以我们可以只返回1,这样它就只会发动列表中的第一张卡。说得更直白点,这段代码可以解读为“如果可以发动的卡片列表的长度大于0,发动列表当中的第一张卡,否则什么都不做”。

  注意,对OnSelectInit每次调用最终都只会执行一次指令。如果一个发动指令返回了,那张卡片就会被发动,然后,等到所有连锁处理完之后,OnSelectInit会以一个新的可以发动的卡片的列表再次被调用。

  那么,现在我们如何处理「破坏者」呢?首先,我们不得不限制AI的随意发动,我们想要自定义它的发动。所以我们要改变发动函数来避免总是发动所有东西,除了「破坏者」例外。为此,我们会循环遍历所有可以发动的卡,然后单独发动他们,如果他们不是「破坏者」。

因此我们改写这个:

if #cards.activatable_cards > 0 then
  return COMMAND_ACTIVATE,1
end

改成这样:

for i=1,#cards.activatable_cards do
  local c = cards.activatable_cards[i]
  if c.id ~= 71413901 then -- 破坏者
    return COMMAND_ACTIVATE,i
  end
end

  71413901是「破坏者」的卡片密码,这个特有的数字与卡片相互关联。我们会经常用到这些ID,它们是在脚本中用来区分卡片的方法。小心,在脚本中很容易会弄错ID。现在这段代码做了什么呢?这是lua当中的一个标准的for循环,它会循环遍历可以发动的卡,并且只有当卡片没有「破坏者」的ID的时候(~= 在lua中是“不等于”的意思),他才会被发动。所以现在,「破坏者」不会再被发动了。

  但这不是我们真正想要的,我们需要添加某种条件,来让「破坏者」可以被发动。这里要怎么写才有意义呢?可能发动「破坏者」的情况是,玩家控制着至少1张魔法卡或者陷阱卡。

所以我们可以修改我们的发动代码为:

for i=1,#cards.activatable_cards do
  local c = cards.activatable_cards[i]
  if c.id == 71413901 then -- 破坏者
    for j=1,#AI.GetOppSpellTrapZones() do
      if AI.GetOppSpellTrapZones()[j] then
        return COMMAND_ACTIVATE,i
      end
    end
  end
  if c.id ~= 71413901 then
    return COMMAND_ACTIVATE,i
  end
end

  AI.GetOppSpellTrapZones()是在模板中提到的其中一个函数,它返回玩家魔陷区的所有卡片的列表。然而,我们还是需要去检查一下,这些区域是否都有卡。这意味着要循环遍历一次。如果一个区域没有被占用,它就是false,否则它就有卡。那这里我们为什么不用“#”来判断呢?原因是,它会把空的魔陷区也计算在内,所以AI.GetOppSpellTrapZones()的长度总是8。(5个魔陷区,2个灵摆区,1个场地魔法区)。

  所以这段代码解读为“如果可以发动的卡是「破坏者」,并且有一张卡存在于玩家的魔法与陷阱区域,就发动卡片”,然后是“如果可以发动的卡片不是「破坏者」,直接发动它”。

<h3 id='4_2'>OnSelectCard</h3>

  现在来测试一下。「破坏者」应该不会发动他的效果,除非你控制着一张魔法卡。然而,如果你试了,你会发现另外一个问题:
「破坏者」也许会控制住他的效果发动,但是当他发动的时候,他还是会破坏AI自己的魔法卡!那么我们如何修复这个问题呢?

  对于大部分效果的对象选择是在OnSelectCard函数里面处理的。一般来说,看起来如下:

function OnSelectCard(cards, minTargets, maxTargets, triggeringID, triggeringCard)
  local result = {}
    for i=1,minTargets do
      result[i]=i
    end
  return result
end

  你会发现,它有一堆参数可用。cards 是一个可以被选为对象的卡片的列表。minTargets 和 maxTargets 定义你要选择的卡片数量。triggeringID 和 triggeringCard 定位到发效果卡。当你的效果要选卡的时候,它负责发动那个效果。

为了让「破坏者」选择正确的效果对象,我们可以把函数改成这样:

local result = {}
if triggeringID == 71413901 then -- 破坏者
  for i=1,#cards do
    if cards[i].owner == 2 then
      return {i}
    end
  end
end
for i=1,minTargets do
  result[i]=i
end
return result

  我们将发动效果的卡的ID与「破坏者」的进行比较,如果吻合,我们就循环遍历所有可选的对象,并且返回属于玩家的第一个。注意,返回值必须是一个索引列表,而不是一个索引,因为有一些卡片会有多个效果对象。所以在这里你不能返回i,你只能返回{i}。否则游戏会崩溃!

  现在再试一下。现在「破坏者」应该只会破坏玩家的卡了。这个是你现在的AI的样子。注意,这是一种非常啰嗦的实现。如果你添加了上百张卡到AI,你可能会想去写一些函数来帮你缩短和组织代码,这样你就不用为每一张卡都写一个新的循环。在后续的教程中我会给出我写来解决此问题的这些函数的易用版。

<h3 id='4_3'>OnSelectChain和OnSelectEffectYesNo</h3>

  现在我们已经学习了两个非常重要的知识点:OnSelectInitCommand 和 OnSelectCard 的使用。下一步:OnSelectChain 和 OnSelectEffectYesNo。它们两个处理大部分可以连锁的卡片和效果的发动,它们的工作原理是一样的,尽管它们用起来有很大差异。OnSelectChain 有一个可连锁的卡片列表,就像 OnSelectInit,并且当任意卡片能够被连锁的时候它都会被调用。OnSelectEffectYesNo通常在只允许一张卡被发动的特定情形下被调用。

  许多可以连锁的卡片或效果能够在其中一个函数中处理,具体取决于场上的情况。举个例子,假设AI控制着一张「雷王」,然后你的对手特召了一只怪兽。现在 OnSelectEffectYesNo 会被调用来处理“是否发动「雷王」的效果无效这次特召?”这个问题。人类玩家在这个时候会看到一个选择对话框。然而,如果你已经盖了一张「升天之黑角笛」,代替对话框的是,你会被问到是否连锁卡片或效果的发动来发动。所以在这个时候,AI会在 OnSelectChain 而非 OnSelectEffectYesNothe 中来处理。注意,这只是一个例子,「雷王」实际上不仅仅通过 OnSelectEffectYesNo 来处理效果。你只要理解就好,对于一些卡来说这是一个确定的例子。

  那么,这对我们来说意味着什么呢?一般情况下,我们不得不添加所有能够被其中一个函数处理的效果到两个函数中。有一些卡永远不会被其中一个函数处理,为了让事情变得简单些,除非你确切地知道,是否一张卡会只被其中的一个处理,否则建议你在两个函数中都添加。

  让我们拿提到的「雷王」来做个例子。他是如何配合我们当前的测试AI来工作的呢?如你所料,他只会连锁发动他的效果来无效我们的每一次特殊召唤。如果你组了一个像之前那样,但是AI只能用「雷王」的测试卡组,然后在测试决斗中让你的对手召唤他紧接着特殊召唤一只很弱的怪兽,比如「糖果小丑」,AI还是会连锁发动它的效果,尽管「糖果小丑」只有0的攻击力和守备力并且通常来说不会单独使用。

  我们如何改进它呢? 首先,我们来看看我们相关的函数:

function OnSelectEffectYesNo(id, triggeringCard)
  return 1 -- 这代表总是返回 yes
end

function OnSelectChain(cards, only_chains_by_player, forced)
  return 1,1 -- 这代表总是连锁第一张可能的卡片
end

  它们看起来足够简单。现在,我们不得不先做一些确认,因为它们不总是由「雷王」调用,所以我们会添加一些判断,就像之前那样:

function OnSelectEffectYesNo(id, triggeringCard)
  if id == 71564252 then -- 雷王
    return 0
  end
  return 1
end

function OnSelectChain(cards, only_chains_by_player, forced)
  for i=1,#cards do
    local c = cards[i]
    if c.id ~= 71564252 then -- 雷王
      return 1,i
    end
  end
  return 0,0
end

  两个函数看起来很不一样,因为 YesNo 只会在一个特定情形或被一张特定卡片调用,所以你可以这样判断,如果卡片是「雷王」,那么返回 no,如果不是,让函数照常返回 yes。Chain 就复杂些,因为它有一个卡片列表。你要遍历这些卡,连锁发动所有不是「雷王」的,并且如果只剩下「雷王」,则不再做进一步的连锁。试一下,改完这些以后「雷王」应该不会再发动了。

  好吧。现在,我们怎么确定他的发动时机呢?这是相当棘手的问题,但是现在,我们将采用一个非常简单的解决方案:如果被召唤的怪兽可以战斗破坏「雷王」就无效他的召唤。现在,我们该怎么做呢?不幸的是,我们没有直接的方法来使用「雷王」的效果来进行破坏。所以在这里我们需要一些创造力。在YGOPro中,对于正在召唤的怪兽会有一个特定的状态常量——STATUS_SUMMONING。这个状态常量只会在无效召唤的时点被激活,此时「雷王」可以发动,并且我们可以检测它。那怎么做呢?当然是遍历,我们遍历对手所有可用的怪兽并检查是否包含这个状态:

for i=1,#AI.GetOppMonsterZones() do
  local c = AI.GetOppMonsterZones()[i]
  if c and bit32.band(c.status,STATUS_SUMMONING)>0 then
    --c 是我们召唤的怪兽
  end
end

  用bit32.band来检测状态是必须的,因为卡片可能同时处于多种状态,所以用c.status==STATUS_SUMMONING来检测可能会导致错误,因为它实际的状态可能是STATUS_SUMMONING+STATUS_SOMETHING_ELSEbit32.band执行按位与操作并且可以通过这种方法将状态信息分离出来。注意,它返回一个数字而不是 true 或 false。返回0意味着卡片不包含这种状态,>0表示包含这种状态。

  用bit32.band检测状态的原理:查看constant.lua可以知道,所有的状态常量转为二进制表示之后是:1,10,100,1000...,因此,每添加一种状态只需要简单地将其数值加上,因为每一个二进制位都不会相互干扰。然后使用bit32.band按位与操作就可以提取出要检测的状态的数值,这样可以高效而简单地判断一种单独的状态是否包含在一个复杂的叠加态中。同样的功能用数组或对象属性的设计也能实现(可读性会更好),但是执行效率都比不上这种。

  现在我们已经有了被召唤出来的怪兽,我们能够检测它所处的状态,那么「雷王」该不该被发动。我们会用一个函数简单地处理这个问题,就把它叫做ChainTKRO()然后添加到脚本当中不在其它函数里面的的任意位置:

function ChainTKRO()
  for i=1,#AI.GetOppMonsterZones() do
    local c = AI.GetOppMonsterZones()[i]
    if c and bit32.band(c.status,STATUS_SUMMONING)>0 then
      if c.attack>=1900 then
        return true
      end
    end
  end
  return false
end

  这个函数十分的灵活,我们可以从任何地方调用它,如果有任何正被特殊召唤的怪兽的攻击力大于1900,它就返回 true,如果怪兽更弱,就返回 false。我们可以用它代替完整的召唤检测来放到 Chain 和 EffectYesNo 两个函数里面:

function ChainTKRO()
  for i=1,#AI.GetOppMonsterZones() do
    local c = AI.GetOppMonsterZones()[i]
    if c and bit32.band(c.status,STATUS_SUMMONING)>0 then
      if c.attack>=1900 then
        return true
      end
    end
  end
  return false
end

function OnSelectEffectYesNo(id, triggeringCard)
  if id == 71564252 then -- 雷王
    if ChainTKRO() then
      return 1
    else
      return 0
    end
  end
  return 1
end

function OnSelectChain(cards, only_chains_by_player, forced)
  for i=1,#cards do
    local c = cards[i]
    if c.id == 71564252 and ChainTKRO() then -- 雷王
      return 1,i
    end
    if c.id ~= 71564252 then
      return 1,i
    end
  end
  return 0,0
end

  试一下。让你的对手召唤「雷王」,然后特殊召唤一只弱鸡怪兽,再试着特召一只强力怪兽(比如「光子斩击者」)。仅供参考,你的AI现在看起来会像这样

  我个人喜欢为每一张卡编写一个像这样的独立函数而不是只在chains中处理。我为卡片编写了诸如SummonX()ActivateX()ChainX()等等的函数。

  好了,以上这些就是要为我们的第一个AI做的。这些只是最基本的,但我们学会了如何使用 OnSelectInitCommandOnSelectCardOnSelectEffectYesNoOnSelectChain函数。这些都是最重要的,我想整个AI中大约有80%的处理都是在这些函数当中进行的。基本原理都是一样的,循环遍历卡片,检查特定的条件,返回正确的索引

<h2 id='5'>5. 自定义函数</h2>

  在上一章,我们为AI添加了两张卡,现在那两张卡可以处理简单的对战了。太好了,还有8829张卡要处理 :D

  注意,添加大量的卡片或一个复杂的卡组到YGOPro是一件工作量很大的事情,所以你肯定会想找到一些方法来优化这个过程。而且我们添加的两张卡也还不是很好用。「破坏者」会随机选择卡片作为对象,他可能会发效果去破坏一张不会被效果破坏的卡片,或者破坏一张没有对象关联的「活死人的呼声」而不是更具威胁性的「大宇宙」。「雷王」也许会放过有危险效果的怪兽的特殊召唤,因为它的攻击力足够低。

  为了提升添加新卡的速度好让加入的时候不用为每张卡添加上百项检查,我在编写AI的时候开发了几个实用的自定义函数。

  特此声明:我不是一个专业的程序员并且我也不是非常的熟悉lua,刚开始编写AI脚本的时候我甚至对其一无所知。所以大部分的函数都有改进的空间,又或者说它们不遵循常规的的编程标准。

  还有,请记住,任何你在这章中使用的自定义函数在你之前构建的AI里面可能会用不了,至少在把对它们的请求添加到AI里面之前是不可用的。添加下面这行代码:

require("ai.ai")

到你的ai-tutorial.lua以确保你可以使用这些函数当中的大部分。这行代码连接到标准AI文件(ai.lua),标准AI会依次请求所有mod和desks文件夹下的文件并加载里面的函数。此外,你还可以跳过一些函数来让默认的AI函数进行处理。不想写你自己的攻击逻辑?只需要从你的测试AI中删掉OnSelectBattleCommand,标准AI的攻击逻辑就会接手。

<h3 id='5_0'>短名版函数</h3>

  你用的LOT应该尽可能的简单,这样你就不用写一大堆代码。AIMon()AI.GetAIMonsterZones()的短名版且自带空区过滤,这样可以简化程序的后续处理。类似的函数还有OppMon()AIGrave()AIHand()等等。在接下来的教程中我会列出一份实用自定义函数的API。

  形如cards.activatable_cards的函数的短名版看起来就像这样:cards.activatable_cards => Actcards.summonable_cards => SumSpSum,... 你理解即可。

<h3 id='5_1'>HasID</h3>

  这个函数可能是我用得最多的了:HasID函数是一个非常简单的循环函数。给它传入一个卡片列表和一个ID,它会遍历所有卡片,如果当中有对应ID的卡则返回true。它也会更新一个全局变量——CurrentIndex,这个变量用来存储匹配到的卡在传入的卡片组中的索引。

所以代替:

for i=1,#cards do
  local c = cards()[i]
  if c.id == 71564252 and ChainTKRO() then
    return 1,i
  end
end

我可以这样写:

if HasID(cards,71564252) and ChainTKRO() then
  return 1,CurrentIndex
end

  它还有一些其他的参数,是我设计用来解决一些特殊问题的。但这个基本的函数可以只传两个参数。

  忠告:如果确实要在全局中使用CurrentIndex那再次使用HasID的时候就要小心了,因为它会修改这个索引。如果你想用它来检查场上的其他卡并且还是需要用到全局索引,用它的时候把第3个参数设置为true,这会让它不修改这个索引:

if HasID(cards,71564252) and not HasID(AIMon(),71564252) then -- 不要这样做!

  当你没有「雷王」,想召唤一只上场的时候,你可能会像上面这样来检查。然而,这会产生错误,甚至可能造成崩溃。你要这样做:

if HasID(cards,71564252) and not HasID(AIMon(),71564252,true) then -- 这样才对

  实现相同功能的还有HasIDNotNegated函数,但它多了一个无效化检查,这样卡片在被效果无效化时就不会尝试发动效果。

<h3 id='5_2'>BestTargets</h3>

  试图去概括性地处理所有离场效果,可能会导致效果不能正常工作(所以用一系列常量分情况处理)。BestTargets传入一个卡片列表,一个数量和一个用来定义当前使用的效果类型的自定义常量。它基于卡片类型,表示形式,所在区域,战场形势,效果类型和免疫类型来给可选对象划定优先级。它还会检查黑名单,因为一些卡不会成为效果对象或不受某些特定卡的影响。它把破坏效果做为默认目标,但你也可以用下列常量来代替:

TARGET_OTHER
TARGET_DESTROY
TARGET_TOGRAVE
TARGET_BANISH
TARGET_TOHAND
TARGET_TODECK
TARGET_FACEDOWN
TARGET_CONTROL -- 比如 强夺
TARGET_BATTLE  -- 变更攻击对象
TARGET_DISCARD
TARGET_PROTECT -- 这可以是任何有益的效果

  它返回一个基于卡片列表输入的索引列表,显然它被设计来在OnSelectCard中工作。在「破坏者」的例子当中,它看起来如下:

function OnSelectCard(cards, minTargets, maxTargets, triggeringID, triggeringCard)
if id == 71413901 then -- 破坏者
  return BestTargets(cards) -- 或 BestTargets(cards,1,TARGET_DESTROY)
....

  对于只有一个效果对象的简单的破坏效果,你只需传入卡片列表,因为数量默认为1,目标常量默认为破坏,这也让「破坏者」的使用变得简单。并且它还会自动地处理那些不可破坏的卡和不会成为效果对象的卡,在可以知道的前提下(比如古遗物系列)。然而,它只处理对象的选择,如果只有不理想的对象可选,它还是会挑一个。你需要在OnSelectInit中添加一个额外的检测来避免效果的发动,如果不存在感兴趣的破坏对象。

<h3 id='5_3'>Add</h3>

  尝试了不同卡片对象的选择,这一回轮到了各种类型的选卡效果。然而这里有一个坑:优先级系统。来看看我说的是什么,看一下AI目录下“mod”文件夹里的“AIOnDeckSelect.lua”文件。看到那一堆多到令人生畏的数字了吗?是的,这些就是你需要为Add函数准备的。

  这个系统已经开发了很多版。对于任何严肃的程序员来说,它可能过于臃肿,而且很可能有更好的方法来处理这个问题,但到目前为止,它一直在为我工作。每行代表一张卡片,而所有的数字列都是优先级值。最后一列是一个条件函数,可以为nil。列代表卡片所处的区域。第1列是手牌,第3列是场上,第5列是墓地,第7列是灵摆,第9列是除外。偶数列与前一个奇数列代表相同的位置,但是是在有条件函数且条件不成立的情况下使用的。例如你有一个条件,还有卡片在手牌,检查条件。条件成立?返回第1列。不成立?返回第2列。如果没有条件函数,它总是返回第1列。

  所以添加一张卡的时候,你要去想想如何使用这张卡。你是想把它放在手牌中使用多一点?那么第1列的数值就应该大一点(我常用1到10的数值)。你需要它在墓地里?就改第5列的数。如果你有另一张卡的时候你只想把它留在手牌?高优先级用1,低一点用2,(这里作者可能写错了,实际是数值越大优先级越高,最小为0)再写一个条件函数,当你有另一张卡的时候让函数返回true。

  这听起来很复杂,它是如何协助我们的,我们又为什么需要它呢?现在很多卡片系列都有大量检索卡片的效果。如果你想去对所有这类效果逐个编码,你就会有一大堆重复的用来查找特定卡的循环。如果你为你卡组里面的所有卡设置了一个优先级,并且定义了适当的条件函数,那现在你就能用Add函数来执行你的选卡效果了。

if id == 32807846 then -- 增援
  return Add(cards) -- 或 Add(cards,PRIO_TOHAND,1)
....

你还可以使用下列自定义常量:

PRIO_TOHAND
PRIO_TOFIELD
PRIO_TOGRAVE
PRIO_DISCARD=PRIO_TODECK=PRIO_EXTRA
PRIO_BANISH

  第4个常量取决于我所使用的卡组。对于水精鳞卡组或暗黑界卡组,我用它处理送墓类操作(注意从手卡丢弃和从手卡送墓是不一样的操作)。还能用它来做放回卡组切洗,例如「大薰风骑士 翠玉」或「星因士 天狼星」。

  现在,如果你定义了适当的优先级和条件,你就能在那些以某种方式搜索或使用你的卡的函数中使用Add函数。召唤一只混沌怪兽?Add(cards,PRIO_BANISH)会把你希望被除外的卡片从墓地除外。「愚蠢的埋葬/副葬」?Add(cards,PRIO_TOGRAVE)会把放在墓地更好用的卡送墓。然而,它可能会导致代码变得复杂,尤其是说书卡组。只要看一下圣骑士AI的条件函数就知道了,就在NobleKnight.lua文件的开头。相当的混乱 :)

  你需要在决斗的一开始的时候使用AddPriority()函数来设置你的优先级。

AddPriority函数的用法可以查看AIOnDeckSelect.lua

<h3 id='5_4'>CardsMatchingFilter</h3>

  另一个简单的循环函数,需要传入一个卡片组和一个过滤器,然后返回在这个卡片组中有多少张卡满足过滤器的条件。过滤器可以是任何函数,只要它传入一张卡并返回一个布尔值,例如:

function FilterLevel4(c)
  return c.level==4
end
local count = CardsMatchingFilter(cards,FilterLevel4) -- 统计所有卡片列表中等级为4的怪兽

  我的很多自定义函数都支持可选的过滤器,并且它们当中的大部分能把一个额外的可选参数作为第二个参数传入需要两个参数的过滤器。它允许使用一些类型常量来过滤,如下:

local count = CardsMatchingFilter(cards,FilterType,TYPE_MONSTER) -- 统计所有怪兽卡

  FilterType是一个有两个参数的过滤器,它把不被过滤的类型与卡的类型进行比较。还有诸如FilterRace,FilterAttribute,FilterPosition,FilterLocation等等的用法类似。之前提到的那些函数都可以添加过滤器为参数,比如HasID,Add,BestTargets,所以你要注意过滤器在参数列表中的具体位置。认真看看API列表,记住正确的参数顺序。

  • HasID,HasIDNotNegated和BestTargets函数的定义在AIHelperFunctions2.lua
  • Add函数的定义在AIOnDeckSelect.lua

<h3 id='5_5'>FieldCheck</h3>

  一种简单的方式来统计AI控制的一个特定等级的卡有多少张,常被用于超量召唤检查。FieldCheck(4)返回当前AI控制着多少只等级为4的怪兽。这个函数也支持一个过滤器,FieldCheck(4,FilterRace,RACE_WINDBEAST)返回AI控制的等级为4的鸟兽族怪兽的数量。

<h3 id='5_6'>DestroyCheck</h3>

  还记得「破坏者」吗?还记得我说过的,当对手只有我们不想破坏的卡时候,我们需要一个额外的检查来避免发动卡的效果。DestroyCheck就是为此而生。它遍历一个卡片列表然后返回可以被破坏的对象的数量。所以「破坏者」的效果发动检查看起来如下:

function UseBreaker()
  return DestroyCheck(OppST())>0
end

if HasID(Act,71413901) and UseBreaker() then
  return COMMAND_ACTIVATE,CurrentIndex
end

<h3 id='5_7'>Affected 和 Targetable</h3>

  有很多的怪兽,它们通过不能被选为效果对象或不受特定卡的效果影响来实现保护。这是很难察觉的,由于在AI脚本中不受其他卡的效果影响和只是不受魔法·陷阱卡的效果影响(比如「禁忌的圣枪」)之间是没有区别的。还有一些卡不是不能被选为效果对象,但可以无效取对象效果甚至当它们被选为对象时会返伤。

  Affected传入要检查的卡,一个类型常量和一个等级或阶级,它能检查出机壳怪兽是否对效果免疫象使用了「禁忌的圣枪」。然而,这可能不会100%准确,比如「禁忌的圣枪」的例子中只能检查出是否有不受效果影响的Buff和攻击力的下降。它不能确切地检查出,这些是不是由「禁忌的圣枪」导致的。Targetable传入那张卡和一个类型。

  对于像「凤翼的爆风」这样的卡,你可能会这样来检查对象:

if Targetable(c,TYPE_TRAP) and Affected(c,TYPE_TRAP) then
  return true

  对于「鸟铳士 卡斯泰尔」,你则可以这样用:

if Targetable(c,TYPE_MONSTER) and Affected(c,TYPE_MONSTER,4) then
  return true

这个函数的意思是:如果怪兽可以被选为怪兽效果对象且受等级或阶级为4的怪兽的效果影响,则返回true

  还会有更多的函数。现在还在考虑,哪个足够重要到要在这里提及。

<h2 id='6'>6. 常见问题</h2>

在写AI的时候,很多代码可能是有问题的。我会把我遇到的一些问题拿出来讲讲,以及告诉你们如何解决它们或围绕它们展开工作。

<h3 id='6_1'>索引系统</h3>

YGOPro的AI使用一个复杂的卡片列表和索引系统,很多函数都是提供给你可使用的卡片列表然后要求你返回正确的索引。一个问题是,首先一个很难理解的问题是,即使技术上指向同一张卡片,卡片之间也不能兼容。

  例如,你有一张卡,它能把所有对手的怪兽选为对象,而OnSelectCard提供一个可选对象的卡片列表。现在你也能在OnSelectCard中用OppMon(),虽然理论上它返回存在相同怪兽的卡片列表。然而,这两个列表的卡是不一样的,你不能简单地通过像if cards[i]==OppMon[i]的代码来比较它们。即使它们就是指同一张卡,它还是会返回false。幸好,卡片有一个唯一属性card.cardid,它在决斗中为每一张卡片提供一个唯一ID。所以,你可以用它来比较不同列表的两张卡是否为同一张,例如:if cards[i].cardid == OppMon[i].cardid

card.id精确到相同卡密,card.cardid精确到单卡

  索引也有它的故事。很多函数要求你去返回一个索引。这个索引是基于卡片所在的由函数提供的列表的原本位置。我的很多自定义函数实际上改变了卡片在列表中的位置,通过攻击力或优先级等的因素对他们排序。如果你也想这样做,请确保在此之前复制一份列表,这样你就还有途径去获得原始索引,或者你以某种方式存储那个索引。我通常就把那个索引存在一个额外的地方,card.index=i。像这样的额外之地都是临时的,它们只存在于当前的卡片列表。如果相同的卡出现在一个不同的列表当中,任何像这样的地方都会消失。但是因为我们只需要那个索引存在于当前列表,这样处理就很好了。

<h3 id='6_2'>一卡多效</h3>

  如果一张卡能同时发动多个效果,它会分别在 OnSelectInit 或 OnSelectChain 的可以发动/连锁的卡片的列表中多次出现。一个常见的例子是「熔岩谷锁链龙」。如果你可以选择,那么送墓和抓卡效果会以不同的卡出现在可以发动的卡片的列表当中。就这个例子而言,卡片会有一个"description"属性来区分它们。对于「熔岩谷锁链龙」,它的description是545382497对应送墓或545382498对应抓卡。

  那我们怎么用它呢?你能写一个循环来检查:

for i=1,#cards do
  local c = cards[i]
  if c.id == 34086406 and c.description == 545382497 then --熔岩谷锁链龙 送墓效果
  if c.id == 34086406 and c.description == 545382498 then --熔岩谷锁链龙 抓卡效果

  或者使用HasID的第四个参数:

if HasID(cards,34086406,nil,545382497) then --熔岩谷锁链龙 送墓效果

  你能使用 print 函数来查询你的卡使用的description值。

for i=1,#cards do
  local c = cards[i]
  if c.id == 34086406 then
    print (c.description)
  end
end

  如果AI只控制着一只「熔岩谷锁链龙」并且两个效果都能发动,它应该打印出:
545382497 545382498

感谢OhnkytaBlabdey的补充:
卡片效果的序号 = c.description - c.id * 16

可能的用法如下:

local c = cards[i], effectID = c.description - c.id * 16
if effectID == 1 then
-- 这里写具体的处理逻辑
return COMMAND_ACTIVATE,i
elseif effectID == 2 then
...

<h3 id='6_3'>一效选多卡</h3>

  一些卡会在 OnSelectCard 中多次发动不同的效果,有时甚至是相同效果。特别是超量怪兽几乎总是需要先选一个超量素材对象,在他们选择一个场上的对象之前。

  那我们如何处理它呢?OnSelectCard只提供发动效果的卡的id,它没有任何关于当前所用效果的信息。如果我不能用一行代码处理卡片的选择,我通常在外面写一个独立函数来处理。在我们的「熔岩谷锁链龙」的例子中我们有同样的问题,因为它有两个不同的效果且是一只超量怪兽。我常常使用两种方式来决定正确的对象的选择:检查可选对象的与效果相关的一些特殊信息,或在此之前设置一个全局变量。

  我们可以写一个如下的函数:

function LavalvalChainTarget(cards)
  if LocCheck(cards,LOCATION_OVERLAY) then -- 检查第一个可选对象是否为一个超量素材
    return Add(cards,PRIO_TOGRAVE) -- 是超量素材时的操作
  end
  if GlobalCardMode==1 then -- 用全局变量来标记效果的类型
    GlobalCardMode = nil -- 记得重置
    return Add(cards)
  end
  return Add(cards,PRIO_TOGRAVE)
end
function OnSelectCard(cards,...
  if id == 34086406 then
    return LavalvalChainTarget(cards)
  end
...

  LocCheck是又一个自定义函数,它检查第一个参数对象的所在区域,像这样:bit32.band(cards[1].location,LOCATION_OVERLAY)>0

  在发动效果之前我们还要去设置全局变量:

if HasID(cards,34086406,nil,545382497) then --熔岩谷锁链龙 送墓效果
  return COMMAND_ACTIVATE,CurrentIndex
end
if HasID(cards,34086406,nil,545382498) then --熔岩谷锁链龙 抓卡效果
  GlobalCardMode = 1 -- 设置全局变量为1来标记抓卡效果使用中
  return COMMAND_ACTIVATE,CurrentIndex
end

  为什么在这里我们同时使用了 LocCheck 和全局变量呢?我们要能正确的分辨出卡片因何被选。如果对象是一个超量素材,这能明显地被检查出来。但是对于两个效果的选择,如果仅仅通过接受效果的对象卡片,我们没有一个简单的方式来知道,当前它被使用了哪一个效果。对于这两个效果,效果对象都是在卡组,我们不能检查出效果会把它们送去哪里。当对象是魔法卡的时候我们能推测出来(用了哪个效果),因为抓卡效果只能抓怪兽卡但送墓也能送魔法卡或陷阱卡,所以这种推测对于纯怪兽卡组或在魔法陷阱卡都用光了的决斗后期可能会无用。所以为了正确的知道用了哪个效果,我们需要全局变量,至少我是这么认为的。

  要是我想起更多问题我还会加进来。

<h2 id='7'>7. 使用卡片脚本系统的函数</h2>

  你也许熟悉YGOPro的卡片脚本。有很多的卡片脚本函数可用,它们提供各种信息或功能虽然AI用不了。但这些还是很有用的,比如在战斗阶段帮助决定攻击者和攻击对象,或在连锁效果中获取关于当前连锁的信息和据此作出回应。

<h3 id='7_1'>侦测离场效果</h3>

  提供对离场效果的保护,或有卡要离场的时候能连锁发动效果对卡片来说会很有用。你能通过使用一个叫Duel.GetOperationInfo的函数来实现这个,如下:

local ex,cg = Duel.GetOperationInfo(Duel.GetCurrentChain(),CATEGORY_DESTROY)
if ex then
  return cg -- 返回将要被效果破坏的卡的列表
end

  这个例子中cg是一个卡片组。不幸的是,它们与AI脚本所能直接使用的卡片组是不同的,还需要另行处理一下。你不能用c.id,你需要用c:GetCode()代替。一个组和一个表也是不同的,你不能通过索引访问组的成员就像cg[i],你要使用组的相关函数来实现。

组和表分别是卡片脚本系统和AI系统使用的两种不同的数据结构,尽管他们都负责存储若干张卡片,之前提到的卡片列表都是表数据类型。因为数据类型的差异,对这两种数据的操作所用的方法也会不同。

  但你很走运,我写了一些自定义函数来处理这些,并且它们很易用。例如,如果我们想AI用「旋风」连锁发动它的破坏效果,我们能这样做:

function ChainMST(card)
  local targets = DestroyCheck(OppST())
  if targets>0 and RemovalCheckCard(card) then
    return true
  end
  return false
end
function OnSelectChain(cards...
  if HasID(cards,05318639,ChainMST) then -- HasID会检查第三个参数是否为函数,如果是就把它作为过滤器
    return true
  end
end

  RemovalCheckCard 检查传入的卡在当前的连锁中是否即将以某种方式离场。它检查大部分常见的离场方式,如 破坏,除外,返回手卡或卡组,送去墓地等等。可选参数允许指定连锁的效果类型,连锁序号,发动离场效果的卡的类型(还支持过滤器)。

  这样一来,AI的「旋风」应该能连锁发动一个离场效果了,如果对手控制着一张可选对象那它也会被破坏。

<h3 id='7_2'>效果无效化</h3>

  与侦测离场效果十分的相似,我们能在连锁中检查当前的效果来知道它是否会被像「星尘龙」这样的卡无效化。或者我们能检查整个连锁,看看是否有什么需要用像「技能突破」这样的卡来无效化。

  这些能通过结合Duel.GetChainInfo函数来实现,如下:

local e = Duel.GetChainInfo(Duel.GetCurrentChain(),CHAININFO_TRIGGERING_EFFECT)
if e then -- 通过检查 e(e包含发效果卡片)

  当然,我们也有一些易用的自定义函数来实现它,函数能在内部优雅地处理所有这些且有一些额外的好处。

  例如,很多卡能通过发动效果或反击陷阱或类似的东西来无效上一个连锁,这些能通过 ChainNegation 函数来处理。让我们拿「反查」来做个例子,你能在 OnSelectChain 里像这样用它:

if HasIDNotNegated(cards,34507039,ChainNegation) then -- 反查
  return {1,CurrentIndex}
end

  如果你恰巧用到它,它不仅会无效你的对手的卡片效果,它还会检查是否效果在永不无效的黑名单中(比如「哥布林暴发户」),然后他会把这个连锁标记为无效,这样像 RemovalCheck 之类的就会被忽略。所以如果你连锁无效了某些炸场效果,AI会意识到这一点且不会连锁所有魔陷来对付炸场。然而,现阶段,AI不能检查出是否无效的效果被无效。所以如果对手企图用「黑蔷薇龙」炸场,你用像「星尘龙」这样的卡来无效她,但他又连锁了一张「天罚」,AI将不会意识到还是会被炸场并且不会再连锁发动魔陷,甚至明明它有机会反击(假设你没有什么能在「天罚」之后连锁发动的 :D)

  还有一个类似的函数来处理像「技能突破」这样的卡,尽管用法有些许不同。它返回应该被无效的卡片,相应的你需要确保处理对象的选择:

function ChainBTS(card)
  local c = ChainCardNegation(card,true) -- 第二个参数用于对象检查。像技能突破那样取对象的无效用true,像技能抽取那样不取对象的用false
  if c then
    GlobalTargetSet(c)
    return true
  end
end

  GlobalTargetSet 找到场上的对象并将它存进一个全局变量。它是设计用来配合 GlobalTargetGet 工作的,后者用来从卡片的列表中恢复对象并在 OnSelectCard 里使用:

function BTSTarget(cards)
  return GlobalTargetGet(cards,true) -- 第二个参数决定是返回一张卡还是卡的索引。true = index,false = card
end

  这会通过要被无效的卡把连锁标记为发动。(这里的翻译有疑问)

<h2 id='8'>8. 添加新的卡组到已有的AI</h2>

  到目前为止,我们所学的一切都可以用来制作我们自己的AI脚本。然而,一个单独的AI脚本文件确实带来了一些问题。你必须重复很多已经由标准AI处理了的代码,每次你想再玩你的卡组,你都需要选择一个不同的AI文件,且不能使用“随机卡组”选项,因为程序不能自动记忆使用过的AI文件。将新代码集成到现有的AI中也是相当麻烦的,你需要处理我的大量代码,也可能会干扰已经存在的卡组等等。

  0.28版AI提供了解决这个问题的方案。我写了一系列自定义函数来帮助集成自定义AI卡组到已有的AI,仅通过简单几步即可。它允许你的卡组和函数100%独立于其他卡组,并且依旧给予你利用标准AI来处理大部分卡的选择,如果你想。我们会使用之前的「魔导战士 破坏者」的例子来看看我们如何能把它集成到标准AI。

<h3 id='8_1'>第一步:一个空AI的最低要求</h3>

  把你自己的卡组添加到AI的最低要求是什么?

  • (有你的卡组文件.ydk。本例中,它应该有至少一张「魔导战士 破坏者」)
  • 在你的YGOPro的安装目录下的ai/decks目录下创建一个新的LUA文件,起一个你喜欢的名字。我们会用 Breaker.lua
  • 添加一行代码到你的文件,如下:
DECK_BREAKER = NewDeck("Breaker",71413901,nil)

  从现在开始DECK_BREAKER就是存储你的卡组的变量。
NewDeck 有3个参数:

  1. 你的卡组名。主要用于调试模式下显示当前所用的卡组。名字随你起,但它应该有区分度,比如用你的卡组的系列名(Nekroz)或常用简称(HAT)。
    因此本例中我们起名为Breaker。
  2. 卡组的识别码。它可以是卡片密码,或一个卡片密码的列表。如果你想只用一个卡密,要确保它能把你的卡组与其他卡组区分开。例如,用「炎舞-天玑」来标识炎星卡组也许是一个坏主意,因为「天玑」能被用于任何兽战士族为主的卡组。
    对于我们的例子,我们会用「破坏者」的卡片密码,71413901。
  3. 启动函数。它会在决斗一开始时被调用,如果AI侦测到你的卡组。不是必须的,但你可能会用到。
    我们之后会添加,现在暂且用nil

  现在你有一个除了一行代码之外完全是空的文件。下一步你要做的就是打开你的ai.lua文件,然后添加你的文件到请求列表:

...
require("ai.decks.Constellar")
require("ai.decks.Blackwing")
require("ai.decks.Harpie")
require("ai.decks.Breaker") <--

  就这样,你添加了你的第一个卡组文件。现在来试一下,以调试模式启动YGOPro。对于Windows用户,通过你的安装目录下的第二个exe文件,名字叫“ygopro_vs_ai_debug.exe”来启动。游戏应该会启动,还会有一个命令行窗口,显示了一些文字。使用其他操作系统的用户需要通过console来启动YGOPro来获得调试信息。开始一个游戏与你的AI卡组对战。看看console,它应该会在游戏开始的时候显示一个信息(在rock/paper/scissors之后):“AI deck is Breaker.”

  如果这条信息出现了,这就意味着,AI已经利用你指定的识别卡识别出了你的卡组。显然这个做法不是十分的准确,它也有可能会匹配到你的其他卡组,这个稍后再谈。如果这条信息没现,肯定是哪里错了。检查之前的步骤。请确认AI使用了正确的卡组,你指定了正确的识别码和添加了请求。

<h3 id='8_2'>添加你的代码到你的AI</h3>

  现在我们已经设置好了,我们可以开始用我们的文件做一些事情了。就目前而言,它只不过是侦测卡组而已。一切仍然由标准AI处理。一旦设置了启动函数,情况就会发生变化:

function BreakerStartup(deck)
end

DECK_BREAKER = NewDeck("Breaker",71413901,BreakerStartup)

  注意,启动函数必须放在之前那行代码的前面。参数是我们的卡组,同样放到DECK_BREAKER里。

  现在我们能用启动函数来修改卡组的参数,例如调用各种AI函数或添加卡片到各种AI的黑名单:

function MyDeckStartup(deck)
  deck.Init = MyDeckInit

  deck.SummonBlacklist = MyDeckSummonBlacklist
end

function MyDeckInit(cards, to_bp_allowed, to_ep_allowed)
  local Act = cards.activatable_cards
  local Sum = cards.summonable_cards
  local SpSum = cards.spsummonable_cards
  local Rep = cards.repositionable_cards
  local SetMon = cards.monster_setable_cards
  local SetST = cards.st_setable_cards
  return nil
end

MyDeckSummonBlacklist = {71413901}

  在一个独立的AI文件中,如果你给deck.Init赋值为一个函数,AI会像你的标准 OnSelectInitCommand 函数那样调用它。你能在里面以相同的方式处理所有卡片。

  deck.SummonBlacklist是一个卡密的列表。里面的卡不会被标准AI召唤或特殊召唤,如果这些卡存在于你的卡组。

  对于函数和各种名单的完整列表,请参考文档模板. 比较重要的几个是:

deck.Init -- OnSelectInit
deck.Card -- OnSelectCard
deck.Chain -- OnSelectChain
deck.EffectYesNo -- OnSelectEffectYesNo

deck.ActivateBlacklist -- 此列表中的卡不会被发动或连锁
deck.SummonBlacklist -- 此列表中的卡不会被召唤,特殊召唤,反转召唤,盖放
deck.PriorityList -- 设置优先级列表。参考 AIOnDeckSelect.lua 和 Add 函数

  注意,如果你用了黑名单,在游戏中除非你用你的脚本处理了这些卡。否则,它们就不会被使用。

  从现在开始,如有需要我们就能添加更多的卡和函数。好处是,你不需要把你的AI做的很完整。你完全可以只添加一个Init函数来处理几张卡的召唤,剩下的全交给AI处理。如果这就是AI要的,那就更完美了。你甚至可以什么都不管,只是创建个文件,添加请求代码和启动函数,注册init函数,然后收工。所有你没有指定的东西都会由默认AI处理。如果标准AI已经能适当的处理那些主要的陷阱,主要的额外卡组的怪兽等等卡,你就可以轻松忽略这些卡。但是如果想AI不用某张卡,只需加入黑名单然后编写你自己的逻辑来代替,这不会影响到其他卡组,因为黑名单只适用于你自己的卡组。

  注意,你不需要使用我的大多数自定义函数。你可以自由发挥来设置你的卡组和卡组函数。你可以使用第4章中讲到的原生的脚本函数,或者如果你对我的函数不满意,也可以编写自己的自定义函数。

<h3 id='8_3'>让卡组工作起来</h3>

  在这一节,我们整合前面章节的所有内容用「破坏者」和「雷王」为AI添加一个新卡组。我们通过使用卡组模板文件来开始。

  • 第一步是把模板用一个新名字另存一份。存为Breaker.lua放在你的“ai/decks”下。
  • 打开模板,把MyDeck全部替换为Breaker。大部分文本或代码编辑器都有“全部替换”功能。
  • 把卡组名从My Deck改为Breaker:
DECK_BREAKER = NewDeck("Breaker",BreakerIdentifier,BreakerStartup)
  • 变更卡组识别码。你也能用多张卡来识别卡组。用这段代码,任何同时包含「破坏者」和「雷王」的卡组会被识别为Breaker卡组:
BreakerIdentifier = {71413901,71564252}
  • 添加卡组请求到ai.lua,如果之前没做:
...
require("ai.decks.Constellar")
require("ai.decks.Blackwing")
require("ai.decks.Harpie")
require("ai.decks.Breaker") <--

  好的,基本步骤完成。现在我们整合之前添加的功能。

  • 添加「破坏者」和「雷王」到发动黑名单:
BreakerActivateBlacklist={ -- 添加永不发动/连锁的卡到这里
71413901, -- 破坏者
71564252, -- 雷王
}
  • 添加先前为「破坏者」和「雷王」编写的代码到各自的函数。我运用前面章节讲到的知识修改了一些函数。
    使用「破坏者」:
function UseBreaker()
  return DestroyCheck(OppST())>0
end
function BreakerInit(cards)
  local Act = cards.activatable_cards
  -- 在这里添加 OnSelectInit 的逻辑
  if HasID(Act,71413901,UseBreaker) then
    return COMMAND_ACTIVATE,CurrentIndex
  end
  return nil
end

对象选择:

function BreakerTarget(cards)
  return BestTargets(cards,1,TARGET_DESTROY)
end
function BreakerCard(cards,min,max,id,c)
  -- 在这里添加 OnSelectCard 的逻辑
  if id == 71413901 then
    return BreakerTarget(cards)
  end
  return nil
end

连锁「雷王」:

function TKROFilter(c)
  -- 一个对象是否应该被雷王破坏的过滤检查
  return FilterStatus(c,STATUS_SUMMONING)
  and c.attack>=1900
end
function ChainTKRO()
  return CardsMatchingFilter(OppMon(),TKROFilter)>0
end
function BreakerChain(cards)
  -- 这里添加 OnSelectChain 的逻辑
  if HasID(cards,71564252,ChainTKRO) then
    return 1,CurrentIndex
  end
  return nil
end
function BreakerEffectYesNo(id,card)
  -- 这里添加 OnSelectEffectYesNo 的逻辑
  if id == 71564252 and ChainTKRO() then
    return true
  end
  return nil
end

  你能在这个文件检查最终结果。注意,你可以随意扩展。目前为止,卡的通常召唤是由默认逻辑处理的,因为我们没有限制它们。你可以把它们添加到召唤黑名单中,并增加召唤条件,比如只召唤「破坏者」,如果对手控制着任何能被他的效果破坏的对象。或者只召唤「雷王」,如果对手没有攻击力大于1900的怪兽。

<h2 id='9'>9. 写在最后</h2>

  这是AI脚本教程的总结,我希望,你能使用它。这里有很多知识需要吸收。为YGOPro AI编写脚本不是一件简单的事,在前进的路上有很多障碍。我期待着测试你所有新的定制卡组,并最终集成到正式版AI:)(1.0才是正式版,路漫漫其修远兮...)

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,117评论 4 360
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 66,963评论 1 290
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 107,897评论 0 240
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,805评论 0 203
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,208评论 3 286
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,535评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,797评论 2 311
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,493评论 0 197
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,215评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,477评论 2 244
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,988评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,325评论 2 252
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,971评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,055评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,807评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,544评论 2 271
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,455评论 2 266

推荐阅读更多精彩内容