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

上一讲的习题参考答案

1)

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

2)

on_draw应为ondrawself.right()应为self:right()T34:new应为T34.new3m应为"3m"Mount:lighta应为mount:lighta

3)

变量t的定义出现过早, who == self的判断也是多余的。

4)

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

  local t = T34.new("1m")
  mount:lighta(t, 100)
end

5) 一种可能的实现:

function ondraw()
  local t = nil

  if who == self then
    t = T34.new("1m")
  else
    t = T34.new("1p")
  end

  mount:lighta(t, 1000)
end

6) 方法有很多,比如:

  • lighta的上一行打日志,确认每家摸牌前t的值
  • 三狗战后观察牌谱

7)

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

  local t = T34.new(game:getselfwind(self) .. "f")
  mount:lighta(t, 2333)
end

8)

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

  local dealer = game:getdealer()
  if dealer == self then
    mount:lighta(T34.new("1p"), 2333)
  else
    mount:lighta(T34.new(game:getroundwind() .. "f"), 2333)
  end
end

9)

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

  mount:lighta(T34.new(game:getselfwind(self:right()) .. "f"), 2333)
end

10)

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

  local hand = game:gethand(self)
  count2drag8(hand, mount, "m")
  count2drag8(hand, mount, "p")
  count2drag8(hand, mount, "s")
end

function count2drag8(hand, mount, suit)
  local two = T34.new(2 .. suit)
  if hand:ct(two) > 0 then
    local eight = T34.new(8 .. suit)
    mount:lighta(eight, 2333)
  end
end

11)

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

  local extra = game:getextraround()
  if extra < 9 then
    local begin = extra % 3 + 1
    drag(mount, begin)
  end
end

function drag(mount, begin)
  local head = T34.new(begin .. "m")
  local middle = T34.new((begin + 3) .. "m")
  local tail = T34.new((begin + 6) .. "m")

  mount:lighta(head, 2333)
  mount:lighta(middle, 2333)
  mount:lighta(tail, 2333)
end

分离出drag函数的目的是为了防止ondraw太长不易读。

12) 办法很多,比如:

  • 并非采用game:getextraround()获取真实的本场数, 而是直接「假装」目前为某本场,并测试后续的逻辑。
    • 例如在上面的答案中,可用local extra = 3测试三本场时的表现
  • 与容易连庄的角色同场测试


多级 if 嵌套

回顾一下第一讲中,自己摸牌输出「哇!」, 别人摸牌输出「emmm…」的那个例子:

function ondraw()
  if who == self then
    print("哇!")
  else
    print("emmm...")
  end
end

现在我们把这个例子扩展一下, 让它在下家摸牌的输出「鄙视下家」,其余的不变:

function ondraw()
  if who == self then
    print("哇!")
  else
    if who == self:right() then
      print("鄙视下家")
    else
      print("emmm...")
    end
  end
end

这段代码的意思是:

  • 如果摸牌的人是自己:
    • 输出「哇!」
  • 否则:
    • 如果摸牌的人是自己的下家:
      • 输出「鄙视下家」
    • 否则:
      • 输出「emmm…」

就像上面的例子所展示的一样,if里面可以嵌套if。 我们不妨再套一层,让它在对家摸牌时输出「嫌弃对家x」。

function ondraw()
  if who == self then
    print("哇!")
  else
    if who == self:right() then
      print("鄙视下家")
    else
      if who == self:cross() then
        print("嫌弃对家x")
      else
        print("emmm...")
      end
    end
  end
end

道理很简单,就是一级一级地判断 —— 但是这画风太鬼畜了,看着就晕,尤其是最后的end四连,简直洗脑x

好在 Lua 提供了一种方便的写法 —— elseif

function ondraw()
  if who == self then
    print("哇!")
  elseif who == self:right() then
    print("鄙视下定")
  elseif who == self:cross() then
    print("嫌弃对家x")
  else
    print("emmm...")
  end
end

通过elseif,代码的逻辑没有变,但看起来舒服了很多,也避免了鬼畜的end四连。

虽然我们已经用过很过次if语句了, 但里面的很多东西其实都没讲清楚,下面详细补充一下。


if 的工作方式

最简单的if语句长这个样子:

if 条件 then
  语句1
  语句2
  语句3
  ...
end

「条件」的求值结果为「真」,则执行里面的那堆语句(称做「语句体」), 「条件」为「假」时则会跳过语句体,执行end后面的代码。

if 语句可以有 else 部分和/或 elseif 部分:

if 条件1 then
  语句体1
elseif 条件2 then
  语句体2
elseif 条件3 then
  语句体3
else
  语句体N
end

这种情况下,会先计算「条件1」。 「条件1」为「真」,则执行「语句体1」。 「条件1」为「假」,则继续计算「条件2」,以此类推。 这里需要注意的是计算的顺序。如果前面的条件为「真」, 后面的条件就不会被计算。

每一个语句体都会开启一个新的局部变量的作用域(即「块」)。

在 Lua 中,boolean 类型的false,和 nil 类型的nil是「假」, 其余的任何类型的任何值都是「真」。 这意味着 number 类型的0,string 类型的""也都是「真」。


逻辑运算 and 及 or

我们可以用and, or运算来做出一些更复杂的判断条件。 例如,下面的代码会在摸牌人是自己,「并且」自风为西时输出「哇!」:

function ondraw()
  if who == self and game:getselfwind(self) == 3 then
    print("哇!")
  end
end

再比如,下面的代码会在摸牌人是自己,「或者」摸牌人是对家时输出「哇!」:

function ondraw()
  if who == self or who == self:cross() then
    print("哇!")
  end
end

and的字面意思是「与」,or的字面意思是「或」, 但它们的具体求值方式有点特别。 and的求值方式是:左手边为假则返回左手边,否则返回右手边; or的求值方式是:左手边为真则返回左手边,否则返回右手边。 无论是and还是or,都会先计算左手边,再计算右手边; 如果计算左手边后发现结果就是左手边,那么右手边就不会被计算。 这种规则在实现了「与」和「或」的字面意义的同时, 给予了人们「皮一下」的空间。 例如,以下表达式可以在ab都是 number 类型的情况下, 求出ab之间的较大值:

a > b and a or b

要理解上面的表达式,首先要明白运算符之间的优先级。 在 Lua 运算符中,or的优先级最低,and排倒数第二。 因此,上面的表达式等同于:

((a > b) and a) or b

所以首先从a > b开始计算:

  • 如果a > b结果为true,表达式等同于(true and a) or b
    • 因为and左手边为真,and表达式的结果就是右手边,表达式等同于a or b
    • 接下来,or的左手边是真,因此or的结果为左手边a
      • 只有falsenil是假,所以 number 类型的a永远都是真
    • 于是,a大于b时,整个表达式的结果就是a
  • 如果a > b结果为false,表达式等同于(false and a) or b
    • 因为and左手边为假,and表达式的结果就是左手边,表达式等同于false or b
    • 接下来,or的左手边是假,因此or的结果为右手边b
    • 于是,a不大于b时,整个表达式的结果就是b

在 Lua 中,这种「条件and结果1or结果2」的写法是很常见的, 遇到的时候要马上反应过来。


逻辑运算 not

我们可以用not实现「非」语义。 例如,下面的技能会在当前手牌有副露时容易摸白板:

function ondraw()
  if who ~= self then
    return
  end
  
  if not game:gethand(self):ismenzen() then
    mount:lighta(T34.new("1y"), 1000)
  end
end

其中,game:gethand(self):ismenzen()用于判断当前手牌是否为门前清, 如果当前手牌为门前清,其值就是true;有副露则是false(详见 API 文档)。 由于我们在前面加了一个not,整个if条件就会在副路时为true, 门前清时为false

not的计算结果是 boolean 类型的。 被not的表达式值为假时(即falsenil),not的结果为true; 反之则为false

需要注意的是,not的优先级高于目前我们见过的所有运算符。 这意味着,对一长串表达式取not时,要加上括号。例如:

not (game:gethand():ismenzen() and game:getround() == 0)   -- 表达式 1
not game:gethand():ismenzen() and game:getround() == 0     -- 表达式 2
(not game:gethand():ismenzen()) and game:getround() == 0   -- 表达式 3

上面的三个表达式中,1 和另外两个的意思是完全不同的, 而 2 和 3 的意思则是完全相同的。


练习题(第一组)

1) 表达「摸牌人是自己或对家」的条件能否写成下面的样子?为什么?

function ondraw()
  if who == (self or self:cross()) then
    print("哇!")
  end
end

2) 判断以下表达式真假:

nil == "nil" or nil
nil == ("nil" and nil)
1 + 1 == 2 or nil
1 + 1 ~= (2 or nil)
(0 and true) and T34.new("3y") or false

3) 实现技能:

  • 如果谁的手里都没有场风,技能无效果
  • 如果刚好有一家的手里有场风,就给他塞白板
  • 如果多家手里有场风,给按照摸牌顺序离自己最近的一家塞白板
  • 上述逻辑中,塞白板的目标不排除自己

上面的需求写得很啰嗦, 但需求啰嗦不代表代码也一定要复杂。 我们需要尽可能把同样的事情理解得更精辟一些。


for 循环

下面我们要做一个万子染手挂。 原理很简单,就是容易摸到各种万子。

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

  mount:lighta(T34.new("1m"), 100)
  mount:lighta(T34.new("2m"), 100)
  mount:lighta(T34.new("3m"), 100)
  mount:lighta(T34.new("4m"), 100)
  mount:lighta(T34.new("5m"), 100)
  mount:lighta(T34.new("6m"), 100)
  mount:lighta(T34.new("7m"), 100)
  mount:lighta(T34.new("8m"), 100)
  mount:lighta(T34.new("9m"), 100)
end

太蠢了。这是病,得治。所以,循环大法好。

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

  for i = 1, 9 do
    mount:lighta(T34.new(i .. "m"), 100)
  end
end

如此一改,所做的事情没有变,但代码质量很高了。

这里的i是一个变量。 变量名本是可以随便起的,不叫i,叫cat, dog都可以, 但习惯上循环里面一般都用i。 后面的1, 9i的循环范围, 每循环一次,i都会自增 1。

循环变量i虽然不带local字样,但也是一个局部变量, 只在for循环内部有效。

除了for循环外,Lua 还有whilerepeat循环, 可支持更多样的循环条件。 因为在人物技能里用得不多,就不介绍了。


练习题(第二组)

4) 实现技能:

  • 当手里的万子数量处于 3~6 张之间时,容易摸到白板
    • 包括刚好 3 张及刚好 6 张的情况
    • 计数时不考虑副露及暗杠

5) 实现技能:

  • 容易摸到现有手牌中最多的花色
    • 现有手牌中万子最多,则容易摸到万子;饼子、索子同理
    • 风牌最多,则容易摸风牌;三元牌最多,则容易摸三元牌
    • 计数时不考虑副露及暗杠

6) 实现技能:

  • 若手里有 (K)m 且无 (K+1)m,则容易摸到 (K+1)m
    • 即:有 1m 时招 2m,有 2m 时招 3m,以此类推
    • 0 < K < 9
  • 若手里有 9m 且无 1m,则容易摸到 1m

7) 实现技能:

  • 容易在第 N 巡摸到数值为 N 的数牌
    • 第 1 巡容易摸 1m, 1p, 1s;第 2 巡容易摸 2m, 2p, 2s……
  • N < 1 或 N > 9 时,技能无效果
  • 为方便起见,此处「巡数」单指摸牌次数,不考虑鸣牌
    • 摸牌次数可通过ondraw被调用的次数间接统计
  • 庄家的跳牌视为第 0 巡,闲家的第一次摸牌为第 1 巡

下一讲: