「松饼人物编辑器:从入门到欧耶」系列教程(五)

上一讲的习题参考答案

1) 狗带(你慢慢记忆力良好x)
2) 清晰你妹啊x
3) 一种可能的实现:

function make(unit)
  local sum = unit
  return function()
    print(sum)
    sum = sum .. unit
  end
end

ao = make("嗷")
ao()
ao()
ao()


使用松饼 userdata

自从第二讲以来,好久没做人物技能了,一直都在写诗什么的x

为了尽快切入我们的核心主题 —— 人物技能, 这里我们要掌握基本的松饼 userdata 的用法。

先回忆一下 Lua 中的七种数据类型:

  • number
  • string
  • boolean
  • table
  • function
  • userdata
  • nil

其中我们没有直接用过的就剩 table 和 userdata 了。 table 的用法以后再说,这里先看 userdata。

userdata 与其它的类型有一点区别。 其它的类型,都是 Lua 语言自带的, 而 userdata 则是由应用(大环境)添加的。 在松饼大环境下,userdata 就是麻将牌、牌山、牌桌这些东西。 尽管同样是 Lua 语言,由于应用不同,使用 userdata 的方式也略有差异。 为方便说明,下文中的 userdata 将特指松饼中的 userdata。

松饼对 userdata 类型进行了细化, 将其分为T34, Mount, Who, Game等具体的类型, 每个类型都有不同的用处,比如T34代表麻将牌,Mount代表牌山等等。 我们先了解一下T34的用法。


麻将牌 T34

松饼中有两种代表麻将牌的类型:T34T37。 区别在于后者将赤宝牌与普通的数牌 5 视为不同的类型。 这里我们先看更常用的T34

写下local i = 1,就创建了一个 number 变量i; 写下local s = "xxx",就创建了一个 string 类型的s; 写下local b = true,就创建了一个 boolean 类型的变量b。 类似地,我们可以通过如下写法,创建一个「麻将牌」类型的变量t

local t = T34.new("1s")

你会发现,创建麻将牌的写法, 比创建 number, string, boolean 的写法要麻烦得多。 这是理所当然的,毕竟麻将牌不是 Lua 的亲儿子, 而是由松饼加进去的 userdata 类型。

上面的例子中,t是一个T34类型的变量,其值为「一索」。 T34.new其实就是一个函数, 通过一个 string 类型的参数(上面的例子中的"1s")指定想要创建的牌。 这个 string 应该由两个字符组成, 第一个字符代表牌的数值,第二个字符代表牌的花色, 比如"1s", "7p", "5m"等等。 在松饼中,万子、饼子、索子、风牌、三元牌分别用 m, p, s, f, y 表示。 风牌的数值为东1、南2、西3、北4;三元牌的数值为白1、发2、中3。 比如,下面的代码创建了代表二饼和白板的变量t1t2

local t1 = T34.new("2p")
local t2 = T34.new("1y")

至于T34new中间的那个点「.」是个什么鬼,我们以后再解释。


身份 Who

几乎所有的技能都是「对人」的。 比如「谁听牌我坑谁」,「自己加速、他家减速」,「专坑下家二十年」等等。 要想在代码中表达这个「谁」的概念,就要用到Who这个 userdata 类型。

WhoT34有一点区别: T34可以通过T34.new函数来创建, 而Who无法在 Lua 里凭空创建, 只能使用已经由系统创建好的的变量。 其实我们已经用过好几次who类型的变量了 —— 之前在ondraw函数中常用的whoself变量就都是Who类型的。 每当系统在调用ondraw之前,都会设置好这两个全局变量:

function ondraw()
  if who ~= self then
    return
  end

  print("哇!")
end

上面的写法是一种很常见的摸牌挂套路。 首先,通过who ~= self判断whoself是否「不相等」。 ~===的作用刚好相反,在两侧不相等时得true,相等时得false。 若who不等于self,则提前返回 —— 这就导致了ondraw后半部分的代码只会在whoself的情况下执行, 使得该技能只会影响自己的进张。

self表示自己,那自己的下家、对家、上家又怎么表示呢?

松饼提供了三个函数:Who.right, Who.cross, Who.left, 分别可用于求出一个Who类型变量的下家、对家、或上家 —— 只要把作为基准的Who变量作为参数传入即可:

function ondraw()
  if who ~= Who.right(self) then
    return
  end

  print("坑坑坑坑坑")
end

三狗测试知以上人物在下家摸牌时输出「坑坑坑坑坑」。


方法调用

下面的两种写法作用是相同的:

Who.right(self)    -- 写法1
self:right()       -- 写法2

这两种写法都表示「自己的下家」。 「写法 2」只是「写法 1」的简略版本,没什么深奥的区别。 这种带冒号的写法, 等同于把冒号前面的 userdata 作为第一个参数传入冒号后面的函数。 冒号后面的函数名,指代的是与这种 userdata 配套使用的那个函数 —— 在上面的例子中,self:right()中的right实际代表Who.right, 因为selfWho类型的。

与某种 userdata 类型配套使用的函数,也叫作「方法」。 不同的类型,有不同的方法。 可以说,正是因为方法的不同,才使得类型得以不同。 食物可以吃,可以煮;衣服可以穿,可以脱。 因为可以吃,可以煮,所以才称得上是食物; 因为可以穿,可以脱,所以才称得上是衣服。

至于每一种类型具体都有哪些方法, 可在需要时查找 API 文档,无须刻意记忆。


基本牌山干涉

进张挂、喂牌挂,本质上都是牌山挂。 一个简单的自家进张挂栗子:

function ondraw()
  if who ~= self then
    return
  end
  
  local t = T34.new("1s")
  mount:lighta(t, 1000)
end

三狗测试,见该角色似乎很容易摸到 1s。

这里只出现了一样新东西mount:lighta(t, 1000)。 正如前面所介绍的,这是一个mount上的方法调用。 mountself, who一样,也是由系统塞进来的全局变量, 不过它的类型是Mount,代表牌山。 lightaMount的最常用的方法,可以改变下一张牌的概率分布。 在自己摸牌前(也就是ondrawwho == self时)调用该方法, 可实现进张挂; 在他家揭牌前(who ~= self)调用该方法, 可实现塞牌挂。

本例中我们写了mount:lighta(t, 1000)。 其中t为要修改出现概率的牌(1s),后面的整数1000为修改的力度。 这里的 1000 的单位并不是百分点之类的东西,而是「毫兔」。 简单来讲,使用的毫兔值越大,挂的效果就越明显。 此处我们为了演示,用了 1000 毫兔这么个非常大的数值。

毫兔的来源说来话长,若想深入理解毫兔, 可阅读「双空间混合牌山系统」一文。


通过日志确认执行流程

怜曾经说过,「活着真累啊」。 编写一套人物技能以后,实际测试时经常会发现表现与期望不符。 造成这种现象的原因有三种:

  1. 你的锅:你的代码有问题
  2. 喵打的锅:松饼的系统有问题
  3. 世界的锅:其它二不起的因素

其中 1 占绝大多数,2 和 3 都是相对罕见的。 新手在写代码要养成优先怀疑自己的习惯。 错怪别人的次数越多,就越会变得不被信任。

那么,当代码的表现与期望不符时,如何确认到底是谁的问题呢? 一个简单的办法是「打日志」。 这里的「日志」,指的就是print大法。 在代码中到处插♂入print,就可以确认它的执行流程 —— 哪些行执行了,哪些行没被执行,它们又是按照什么顺序执行的。

举个栗子:

function ondraw()
  if who ~= selv then
    return
  end
  
  local t = T34.new("1s")
  mount:lighta(t, 1000)
end

本来是想做个容易摸到 1s 的技能, 但三狗测试后,发现好像没有什么明显的效果。 这是怎么回事呢? 为了查明原因,我们在几处关键的地方插♂入了print

function ondraw()
  print("进入 ondraw")
  if who ~= selv then
    print("忽略他家进张")
    return
  end
  
  print("干涉自家进张")
  local t = T34.new("1s")
  mount:lighta(t, 1000)
end

一共插了三处。 假如一切正常,自己摸牌前应该先后输出 「进入 ondraw」,「干涉自家进张」; 而他家摸牌前应该输出 「进入 ondraw」,「忽略他家进张」。

三狗测试,见每家摸牌前都输出了 「进入 ondraw」,「忽略他家进张」。 这个结果意味着:

  1. 能看到输出「进入 ondraw」,代表ondraw的确被执行了,问题出在ondraw内部;
  2. 无论是自己还是他家都输出「忽略他家进张」,代表if条件并没能区分自家与他家。

所以我们接下来要看who ~= selv这个条件到底有什么问题。 这里就比较明显了 —— self被打成了selvselv是一个不存在的全局变量,所以值为nil, 而who又不是nil,所以这个条件的计算结果一直都是true

上面的错误比较明显,所以仅用几个print就把问题找出来了。 现实中我们很可能会碰到复杂得多的问题。 但不管问题有多复杂,基本思路都是一样的 —— 通过种种手段,一步步缩小范围,最终确认问题出在哪里。

想脱离新手村,查错能力就必须过硬。 查错能力上不去,就永远只能做弱鸡; 查错能力差却又总怀疑别人有错,那就是弱鸡中的战逗鸡。


练习题(第一组)

1) 预测各家摸牌前的输出:

function ondraw()
  print(who:right():right() == self:left())
end

自己摸牌前输出( ), 下家摸牌前输出( ), 对家摸牌前输出( ), 上家摸牌前输出( )。

2) 指出以下代码中存在的五处错误。 这段代码的目的是「容易让下家摸到 3m」。

function on_draw()
  if who ~= self.right() then
    return
  end

  local t = T34:new(3m)
  Mount:lighta(t, 1000)
end

3) 以下代码可以正常工作,但可维护性很差。 请指出这段代码具体存在哪些问题。

function ondraw()
  local t = T34.new("1m")

  if who ~= self then
    return
  end

  if who == self then
    mount:lighta(t, 100)
  end
end

4) 修改前一题中的代码,提高其可维护性。

5) 制作一个人物,其技能使自己容易摸到 1m,他家容易摸到 1p。

6) 如何验证前一题中的人物技能能否正常工作?


场况 Game

很多能力的发动条件都是看场况的。 为了获取各类场况信息,我们要用到game

functino ondraw()
  if who ~= self then
    return
  end
  
  local t = T34.new(game:getroundwind() .. "f")
  mount:lighta(t, 1000)
end

以上代码的作用是「让自己容易摸到场风」。 其中起关键作用的是t的构造:

local t = T34.new(game:getroundwind() .. "f")

game也是一个由系统塞进来的全局变量, 其类型为Game,用于读取各种牌桌上的数据。 此处我们调用了它的getroundwind方法。 getroundwind用于获取当前场风, 其返回值为 1, 2, 3, 4 之一,分别对应东南西北。 也就是说,在东场时game:getroundwind()就是 1, 在南场时game:getroundwind()就是 2。

随后,我们通过表达式game:getroundwind() .. "f", 将这个代表场风的数字与"f"进行拼接, 得到代表一张风牌的字符串("1f", "2f", "3f", "4f"之一)。 这么做是为了满足T34.new对参数的要求 —— 首先得是 string 类型,其次也要符合「数值 + 花色」的格式。

最后,我们以这个代表场风牌的t为参数,用mount:lighta(t, 1000), 达到了吸引场风的目的。

除了getroundwind以外,Game这个类型还有许多其它的方法, 可以用来读取各种场况相关的数据。 这些方法不需要一个一个地刻意记住, 在需要时查找 API 文档 即可。 在编写松饼人物时,API 文档最好也一直开着,以备随时查阅。


练习题(第二组)

本组习题中的技能都是自家进张挂,不干涉他家进张。

7) 查找 API 文档,弄清如何获取当前自风, 并创建一个容易摸到自风的角色。

8) 查找 API 文档,弄清如何确认当前庄家,并实现以下角色:

  • 自已是庄家时,容易摸到 1p
  • 对家为庄家时,容易摸到场风

9) 实现以下角色:

  • 自风为东时,容易摸到南
  • 自风为南时,容易摸到西
  • 自风为西时,容易摸到北
  • 自风为北时,容易摸到东

先思考再动手,避免写多余的代码。

10) 查找 API 文档,弄清如何获取当前手牌, 以及如何得到当前手牌中某种牌的张数(含副露)。 然后实现以下角色:

  • 手里有某花色数牌 2 时,容易摸到同花色的数牌 8
    • 手里有 2m 时,容易摸到 8m
    • 手里有 2p 时,容易摸到 8p
    • 手里有 2s 时,容易摸到 8s

Lua 中可通过>, <, >=, <=等运算符比较两个 number 的大小。

11) 查找 API 文档,弄清如何获取本场数,并实现以下角色:

  • 「N 本场」时,容易摸到「N+1 万」所在筋线上的万子
    • 零本场时,容易摸 1m, 4m, 7m
    • 一本场时,容易摸 2m, 5m, 8m
    • 二本场时,容易摸 3m, 6m, 9m
    • 三本场时,容易摸 1m, 4m, 7m
    • 四本场时,容易摸 2m, 5m, 8m
    • ……
    • 九本场或以上时,技能无效果

尽量避免写多余的代码。

Lua 中可以通过%做「取余数」运算,例如7 % 3计算结果为1

12) 多次连庄的情况较为罕见,如何有效率地测试上题中的角色?


下一讲:控制流