userdata
「松饼人物编辑器:从入门到欧耶」系列教程(五)
上一讲的习题参考答案
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
松饼中有两种代表麻将牌的类型:T34
与T37
。
区别在于后者将赤宝牌与普通的数牌 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。
比如,下面的代码创建了代表二饼和白板的变量t1
与t2
:
local t1 = T34.new("2p")
local t2 = T34.new("1y")
至于T34
和new
中间的那个点「.
」是个什么鬼,我们以后再解释。
身份 Who
几乎所有的技能都是「对人」的。
比如「谁听牌我坑谁」,「自己加速、他家减速」,「专坑下家二十年」等等。
要想在代码中表达这个「谁」的概念,就要用到Who
这个 userdata 类型。
Who
和T34
有一点区别:
T34
可以通过T34.new
函数来创建,
而Who
无法在 Lua 里凭空创建,
只能使用已经由系统创建好的的变量。
其实我们已经用过好几次who
类型的变量了 ——
之前在ondraw
函数中常用的who
和self
变量就都是Who
类型的。
每当系统在调用ondraw
之前,都会设置好这两个全局变量:
function ondraw()
if who ~= self then
return
end
print("哇!")
end
上面的写法是一种很常见的摸牌挂套路。
首先,通过who ~= self
判断who
与self
是否「不相等」。
~=
与==
的作用刚好相反,在两侧不相等时得true
,相等时得false
。
若who
不等于self
,则提前返回 ——
这就导致了ondraw
后半部分的代码只会在who
为self
的情况下执行,
使得该技能只会影响自己的进张。
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
,
因为self
是Who
类型的。
与某种 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
上的方法调用。
mount
和self
, who
一样,也是由系统塞进来的全局变量,
不过它的类型是Mount
,代表牌山。
lighta
是Mount
的最常用的方法,可以改变下一张牌的概率分布。
在自己摸牌前(也就是ondraw
中who == self
时)调用该方法,
可实现进张挂;
在他家揭牌前(who ~= self
)调用该方法,
可实现塞牌挂。
本例中我们写了mount:lighta(t, 1000)
。
其中t
为要修改出现概率的牌(1s),后面的整数1000
为修改的力度。
这里的 1000 的单位并不是百分点之类的东西,而是「毫兔」。
简单来讲,使用的毫兔值越大,挂的效果就越明显。
此处我们为了演示,用了 1000 毫兔这么个非常大的数值。
毫兔的来源说来话长,若想深入理解毫兔, 可阅读「双空间混合牌山系统」一文。
通过日志确认执行流程
怜曾经说过,「活着真累啊」。 编写一套人物技能以后,实际测试时经常会发现表现与期望不符。 造成这种现象的原因有三种:
- 你的锅:你的代码有问题
- 喵打的锅:松饼的系统有问题
- 世界的锅:其它二不起的因素
其中 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」,「忽略他家进张」。 这个结果意味着:
- 能看到输出「进入 ondraw」,代表
ondraw
的确被执行了,问题出在ondraw
内部; - 无论是自己还是他家都输出「忽略他家进张」,代表
if
条件并没能区分自家与他家。
所以我们接下来要看who ~= selv
这个条件到底有什么问题。
这里就比较明显了 —— self
被打成了selv
。
selv
是一个不存在的全局变量,所以值为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) 多次连庄的情况较为罕见,如何有效率地测试上题中的角色?
下一讲:控制流