PICO-8 教程 · 语言语法阅读版#

本书由《通过 PICO-8 从零学习 Lua》初学者教程的第 1~11 课改编而成,是把"需要上机操作"的部分剥离后得到的纯文字阅读版:不需要打开电脑,只靠阅读、纸笔和你的头脑,就能学完这门语言的基础语法。

怎样读这本书#

原教程的每道示例都要求"输入并运行"。在本书里,同样的示例换成了纸面读法:

  1. 遇到代码块,先在脑中或纸上逐行推演,写下你预测的输出;
  2. 继续往下读解释,把预测和解释核对,不一致的地方就是你要弄清楚的点;
  3. 遇到"执行追踪"表格,可以照它在纸上为每个变量画一列,每读到一次赋值就记一次值。

书里保留了原教程的全部讲解小节、示例代码和预期输出;删去了分层练习题、分级提示与答案的链接,以及编写校验记录。原教程中"改一个数字再运行"的验证动作,本书改为"按程序推演"。

内容范围#

全书 11 章依次覆盖:输出与执行顺序、数字与算式、变量、条件选择、组合条件、固定次数循环、循环结束、函数、作用域与多返回值、表管理一组数据、表描述角色和物品。学完这 11 章,你就掌握了原教程"编程基础"部分的全部语言知识。

原教程的第 12 课起(让角色动起来、游戏目标、推箱子项目、素材工具)本质上需要真实运行 PICO-8,本书不收录;读完本书后请回到动手版教程继续。

目录#

章节主题
第 1 章让电脑照着你的顺序做事(输出、执行顺序)
第 2 章用数字表达规则(算式、运算顺序、取整)
第 3 章用变量记住数据(赋值、更新、注释)
第 4 章让程序作出选择(if、elseif、else)
第 5 章把多个条件组合起来(and、or、not)
第 6 章让程序重复固定次数(数值 for)
第 7 章让循环在合适的时候结束(while、repeat、break)
第 8 章给一段工作起名字(函数定义与调用)
第 9 章弄清数据在哪里有效(作用域、多返回值)
第 10 章用表管理一组数据(列表、遍历、增删)
第 11 章用表描述角色和物品(字段、嵌套、引用)

各章代码写在 PICO-8 的 Lua 环境中运行(讲解沿用原教程约定:屏幕文字使用简单英文),但本书的任何一节都不需要你真的打开 PICO-8。

第 1 章:让电脑照着你的顺序做事#

你打开游戏时,屏幕可能先显示标题,再显示玩法和开始提示。这些内容不是自己出现的:程序告诉电脑要显示什么,以及先做什么、后做什么。

这一章先做一个很小的欢迎页面。它还不能响应按键,也没有角色移动,但你会经历“写代码、运行、观察、修改、保存”的整个过程——这一版只需在纸上和头脑中完成同样的思考。

读完后,你应能:说清代码应该写在什么位置;解释文字和一个简单计算结果的显示区别;解释三四行代码的执行顺序;找出引号、括号和函数名的简单错误;说明程序如何保存并重新打开。

不需要任何编程知识。本章不需要变量、循环、函数定义或游戏素材。

1.1 先分清两个地方#

PICO-8 打开后有两个主要的工作场所。控制台用来告诉 PICO-8 命令,例如“运行卡带”“保存卡带”“加载卡带”;卡带就是 PICO-8 保存程序和素材的文件,当前不必理解其内部结构。代码编辑器用来输入多行程序。本书的 Lua 代码都属于代码编辑器这一侧;只有注明“在控制台输入”的 saveload 等命令,才属于控制台。先记住这个分工:

你要做的事在哪里做
输入或修改程序代码编辑器
保存、加载卡带控制台
查看运行结果运行画面

代码编辑器里显示的是多行程序,而不是控制台的命令提示符。程序以后可以分到多张代码标签页,这些页仍属于同一个程序;本章只用第一页。

将来真正输入代码时使用英文输入法,尤其是英文半角括号 () 和直引号 ""。函数名和引号内的英文字母都按示例输入小写。PICO-8 默认字体可能显示为大写样式,这只是字形外观,不要求你输入大写字符。

1.2 你的第一段程序#

先把任务缩小:这一次只要求“清掉旧画面,再显示hello”。我们已经决定了两件事和先后顺序,接下来把它们写成代码。程序只有两行:

PICO-8 / Lua
cls()
print("hello")

先预测:这两行运行后,屏幕上会出现什么?想好答案再继续往下读,用后面的解释核对。答案是黑色画面上出现 hello。本教程的代码与输出文本统一按小写记录;PICO-8 默认字体的字形可能看起来像大写,这不要求你输入大写字符。

如果预期画面没有出现,通常要检查三件事:代码是否写进了代码编辑器;两行是否与上面相同;修改后是否重新运行过。如果出现报错,可以先看本章 1.7 节。

这两行分别要求电脑做一件事:

cls():清空绘图画面。这里不写参数,背景使用默认的 0 号颜色,也就是黑色。你暂时不必记住其他颜色编号。

print("hello"):把括号内的内容显示在屏幕上。这个函数叫 print,在本环境中它用于屏幕输出,不是把纸张送进打印机。

clsprint 都是 PICO-8 已经提供好的函数。现在可以先把“调用函数”理解为“请现成的工具完成一项工作”。你尚不需要会编写自己的函数。

括号表示这次调用。括号里可以放交给工具的内容,称为参数print("hello") 有一个参数;cls() 的括号里没有写参数,但括号仍然要保留。

这两个函数属于运行环境提供的工具;后面要学习的执行顺序、文字、数字等语言知识,并不只用于这两个函数。

1.3 引号里面是文字#

把整个程序改成下面这样:

PICO-8 / Lua
cls()
print("my first program")
print("i can change it")

运行结果是两行文字,第二次 print 接在下一行。这里使用的是 print 的默认输出位置;坐标参数以后再学。

用引号包住的一段文字叫字符串。在 "my first program" 中,两端引号告诉程序“中间这一段是文字”。引号是代码的一部分,不会作为这行文字的两端显示出来。

字符串里的空格也属于文字,所以 "my game""mygame" 显示的内容不同。先使用英文字母、数字、空格及简单标点;PICO-8 的默认字体不能直接承担普通中文段落的显示。

如果把 my first program 改成你想显示的英文标题,只动这一处、不动引号、括号和函数名,那么运行后应该只有第一行文字发生变化。这是第一次小修改:一次只改一个地方,结果更容易解释。

1.4 数字可以被计算,也可以成为文字#

换成下面这个完整程序。先预测:第二行代码和第三行代码分别会显示什么?想好答案再继续往下读,用后面的解释核对。开头的 cls() 不输出文字,所以屏幕上只有两行文字。

PICO-8 / Lua
cls()
print("2+3")
print(2+3)

结果是:

运行结果
2+3
5

"2+3" 带着引号,是文字。电脑按原样显示它,不执行其中的加法。

2+3 没有引号,这里是一个计算式。程序先算出 5,再把结果交给 print 显示。+ 在这个位置表示加法。

因此,同样看起来含有数字的一段代码,意义可能不同。本章只用简单的整数加法;下一章再系统学习运算顺序和其他运算。

你也可以写 print(8) 直接显示数字 8。注意:print("8")print(8) 在屏幕上可能看起来相同,但前者传入文字,后者传入数字。显示相同,不代表代码里的数据种类相同。

1.5 电脑按什么顺序做事#

本章这些小程序按从上到下的顺序执行,每个 print 只执行一次。先学会追踪这样的程序;以后学习条件和循环时,再研究执行顺序怎样改变。

先阅读:

PICO-8 / Lua
cls()
print("first")
print("second")

画面最后保留 firstsecond 两行。下面换一下顺序:

PICO-8 / Lua
print("first")
cls()
print("second")

执行过程变为:

  1. 先画出 first
  2. cls() 清空画面,刚画的 first 也被清掉。
  3. 再画出 second

最后只留下 second。程序执行得很快,运行者通常看不到被清掉的中间画面。这里没有“等读者看完第一行再清屏”的指令。

读代码时,试着在纸上记录“执行完这一行后,画面上还剩什么”,而不只把所有 print 的文字抄在一起。这能帮助你分清曾经执行过最终能看到。这个追踪方法不需要电脑,正是本版阅读学习的主要练习手段。

1.6 保存,再打开#

把两行程序扩展成欢迎页。先列出需要显示的内容:标题 my little game、欢迎语 welcome、结束语 have fun

我们已经会两项工具:清屏用 cls(),显示一行文字用 print(...)。因此实现步骤是:先清屏,再按想要的顺序写三次输出。先尝试根据这句话自己在纸上写出程序,再与下面的完整程序比较:

PICO-8 / Lua
cls()
print("my little game")
print("welcome")
print("have fun")

按程序推演,应得到三行文字。如果只把标题换成你自己想的简单英文标题,另两行不会变——它们各自是独立的 print 调用。这样你就完成了"决定内容—写出步骤—实现—检查—修改"的一次小实践。这一页还没有按键功能。

程序写好后,可以在控制台用 save lesson01 这样的命令把它保存为卡带文件(lesson01 是文件名,通常会保存为 lesson01.p8),以后用 load lesson01 重新加载并运行。保存与运行不是同一件事:修改后运行成功,不表示修改已经保存;关掉 PICO-8 前应保存最新版本。若加载时找不到文件,核对保存时使用的名字(例如 lesson01lesson01_mycopy 是两张不同的卡带)。

保存的是什么文件?

后缀保存的内容
.lua单独的代码文本;不能因此假定里面也有游戏图像和声音
.p8文本形式的卡带,可以包含代码和素材;本章保存的是这种文件
.p8.png图片形式的卡带,也可以包含代码和素材;普通图片查看器只展示封面

如果用记事本打开 .p8,开头可能看到 pico-8 cartridge ...version ...__lua__。这些是卡带文件头和分区标记,由 PICO-8 保存时处理,不需要输入代码编辑器。代码编辑器只显示代码,素材以后在对应编辑器里查看。本章无需手工编辑文件头。

1.7 出错时先做什么#

错误并不说明你不适合编程。输入错误是很常见的起点,关键是学会缩小检查范围——这套检查方法只靠阅读就能掌握。

下面这行故意漏掉了一个符号,它不是正确示例

PICO-8 / Lua
print("hello)

开头有引号,结尾却没有对应的引号,程序无法确定文字到哪里结束。应改为 print("hello")

遇到错误时,先看 PICO-8 给出的行号或所指代码位置,再查那一行和前一行。错误信息可能指向“终于发现不对”的地方,不一定正好指向漏符号的位置——所以要从第一处错误改起,改一处就重新核对一次。不必在第一章背下英文报错全文。

本章可以按这个顺序自查:

  1. 我用的是英文半角 () 和直引号 " 吗?
  2. 每个字符串左右都有引号吗?每次调用左右都有括号吗?
  3. 函数名是 printcls 吗?是否漏了一个字母?
  4. 我是不是把控制台命令写进了代码编辑器?
  5. 修改后有没有重新运行?是否不小心还保留了旧程序?

如果画面结果不对却没有报错,按 1.5 节的方法在纸上逐行追踪。先检查顺序,不要随意增加或删除代码。可以暂时退回到 1.2 节的两行程序,确认最小示例能工作后,再一行一行加回去。


下一节预告:下一章《用数字表达规则》将把计算式展开,系统学习运算顺序和其他运算,让你能表达更有用的数量关系。

第 2 章:用数字表达规则#

上一章你让电脑显示文字。这一章要让它算出文字背后的数量:还剩多少金币、几个相同物品一共多少钱、奖励应该怎样分配。先把规则写对,再让电脑执行,才是计算题变成程序的关键。

你需要会清屏、输出和运行程序。本章仍不使用变量、条件或循环。每个示例都是一段独立的小程序:阅读时先弄懂这一段在算什么,再进入下一段,前面的代码不必串联记忆。这里的“输入”就是修改代码中的数字,然后在纸上重新求值,不需要键盘交互。

2.1 从一次购买开始#

这一章先完成一个小任务:让程序算出买东西后还剩多少钱。我们先做计算部分,以后才给它加上按钮和商店画面。

你有20枚金币,买一个价值6枚金币的物品。先把需求拆成三项:

要说清的事这次的规则
已知什么原有20枚,花费6枚
怎样计算原有金币减去花费
显示什么提示文字,以及剩余金币数

先在纸上写出结果,再想清楚这个算式怎样放进上一章的输出程序,最后对照下面的完整程序。

PICO-8 / Lua
cls()
print("coins left")
print(20-6)

预期输出(从上到下):

运行结果
coins left
14

20-6 是一个表达式,意思是“算出这个值”。电脑先求得 14,再把它交给 print。括号中的表达式不需要用引号包起来;加了引号就会显示算式文字。

屏幕上先出现标题 coins left,下一行才是数量。我们暂时把标签和数字分行输出,这样不需要提前学习字符串连接。

现在设想只把购买价格 6 改成 9:先预测输出会变成什么,想好答案再继续往下读,应变为 11。若你算出还是 14,检查自己是否改了真正的计算式,而不是标题中的文字。

2.2 把生活规则写成算式#

编程时先选择符合故事的计算,再把它写成Lua的符号:

你要计算什么Lua写法例子
两笔数量合计+4+2
花掉后剩多少-20-6
几份相同数量*3*4
平均分成几份/12/3

键盘上不用数学符号×、÷来代替*/。数字和这些运算符属于Lua表达式;print()负责把表达式的结果显示在PICO-8屏幕上。

三件物品每件 4 枚金币,可以写成 3*4;12 枚金币平均分成 3 份,可以写成 12/3。在动手写算式之前,先说清两个数字各自表示什么,防止算式虽然合法,却写反了规则。

PICO-8 / Lua
cls()
print(3*4)
print(12/3)
print(7/2)

预期输出(从上到下):

运行结果
12
4
3.5

依次输出 12、4、3.5。最后一个结果提醒你:/ 并不表示“只取完整份数”,它可以产生小数。

负号还可以表示一个负数。例如 -3 表示比零小 3 的数量。在 5-8 中,结果也是 -3。游戏中位置的变化可以为负,金币数量是否允许为负则取决于游戏规则;计算工具不会替你决定。

2.3 先算哪一部分#

想买三件物品,每件 4 枚,手里有 20 枚。正确的剩余金币算式是 20-3*4:先得到总价 12,再相减得到 8。

PICO-8 / Lua
cls()
print(20-3*4)
print((20-3)*4)

预期输出(从上到下):

运行结果
8
68

为什么第二行得到 68?因为括号要求先计算 20-3,得到 17,再乘 4。这和购买规则不是一回事。

本章只需要掌握三个顺序:

  1. 有括号时,先计算括号里面。
  2. 没有括号时,先乘除,后加减。
  3. 同一优先级的运算从左向右计算:乘法和除法同级,加法和减法同级。

例如 18/3*2 是先得到 6,再乘 2,结果 12。它不是 18/(3*2)。减法也一样:10-3-2 得到 5,10-(3-2) 得到 9。

括号不是装饰。写之前先画出你要先计算的那一部分;不确定时把意图明确写出来。

2.4 完整份数与剩余数量#

有 17 枚金币,每件物品 5 枚,最多能买几件?17/5 的结果大于 3,但商店不卖半件物品。

PICO-8 提供 flr 函数,它返回不大于输入值的最大整数,也叫向下取整。例如 flr(3.5) 是 3。它是 PICO-8 的函数,本书不要求调用标准 Lua 的 math 库。

print(flr(17/5))时从里面向外看:先算17/5,再让flr取整,最后让print显示件数。这里是在使用工具提供的函数;第8章才学习怎样自己定义函数。

PICO-8 / Lua
cls()
print(flr(17/5))
print(17-flr(17/5)*5)

预期输出(从上到下):

运行结果
3
2

先算能买的完整件数 3,再用“原有金币-买到的件数×单价”得到余额 2。这里故意写了两遍 flr(17/5);下一章会学习把结果记住,减少重复。

向下取整不是删去小数部分:flr(-2.5) 是 -3,因为 -3 才是不大于 -2.5 的相邻整数。商店题中的数量和单价都为正,先不要把公式套进不符合故事的负价格。

2.5 小数结果要怎样理解#

print(1/3) 默认会显示0.3333。这是一个近似结果,不是无限精确的三分之一。先理解“屏幕上的小数位有限”就够了,不需要在这里学习数值存储原理。

先记住两个实际限制即可:数值大致在 -32768 到略小于 32768 的范围内;显示出来的小数位也有限。不要用 99999 当“特别大的数”,不要根据显示位数猜测实际精度。本章的练习使用小整数和简单小数,不要求计算原始存储位。

除数也应避免为零。PICO-8 的除零行为有特殊规定,不能靠“它一定报错”来发现自己把单价写成零。本章所有分配、购买题都明确使用正的单价或份数。

2.6 算错时拆开检查#

面对 30-(4+2)*3,不要反复盯着整行。先在纸上解释:每组花费是 4 加 2,总共三组,原有 30。然后用下面的小程序暴露中间过程。

PICO-8 / Lua
cls()
print(4+2)
print((4+2)*3)
print(30-(4+2)*3)

预期输出(从上到下):

运行结果
6
18
12

依次出现 6、18、12。若第一步就不符合规则,应修正括号内的计算;如果前两步正确,再检查最后的减法。这个方法以后同样适用于更长的程序。

自己在纸上改一次:把原有金币从30改成35,先预测哪些输出会改变,想好答案再继续往下读,用后面的结果核对。前两行仍是6、18,最后一行应是17。再把每组中的4改成5时,需要修改所有表示这项花费的地方。这种容易漏改的重复,正是下一章要解决的问题。

注意,“看见想要的数字”还不够。例如题目要求算出购买件数,你直接写 print(3) 也能显示 3,但修改金币后它不会重新计算。完成标准要同时检查算式与结果。

下一节预告:下一章学习变量——把计算结果记住,减少本章反复书写同一个算式的重复,解决容易漏改的问题。

第 3 章:用变量记住数据#

第二章最后的购买程序反复写同一个计算式。若金币改了,你必须找出每个相关数字一起修改;漏一处,程序可能照样运行,却算出互相矛盾的结果。

这一章用变量给数据取名字。你会学会记录分数、更新生命值、保存中间结果,也会知道“赋值”为什么不是数学等式。只需要前两章的输出和算式;暂时不用分支、循环或自己定义函数。

每个示例都完整替换当前代码。题目让你改变输入时,是修改程序顶部的初始值并重新推演,不是按游戏按键。

3.1 给金币一个名字#

这一章继续改进购买程序:希望改变初始金币时,只修改一处。先不急着写整套计算,先解决“给20这个数量起名字,并读出它”。

先预测:下面两个print会显示相同内容吗?把你的判断记下来,想好答案再继续往下读,用后面的解释核对。

PICO-8 / Lua
cls()
local coins = 20
print(coins)
print("coins")

预期输出(从上到下):

运行结果
20
coins

第一行清屏。第二行的 local coins = 20 建立一个名叫 coins 的局部变量,并把数值20交给它。接下来 print(coins) 读取这个名字当前对应的值,显示20。

你可以把变量想成一张有名字的数据卡:名字让你找到它,赋值让你填写或更换卡上的内容。这只是帮助理解的比喻,变量不是屏幕上真实出现的一张卡。

local 是语言的关键字,不是要调用的函数,所以它后面不写调用括号。我们从一开始就给这些学习程序的变量写 local,使它们属于当前这段代码的范围。第九章再详细研究范围与同名变量;现在先遵守“先建立,再使用”的顺序。

coins 不加引号才能读取变量。"coins" 则是五个字母组成的文字,不会自动变成变量的值。

如果把20改成35,按程序推演:第一项会变成35,第二项仍是coins。你修改的是这个名字保存的数据,名字本身没有变。local和赋值属于Lua语法,print()是这里使用的PICO-8显示函数。

3.2 先读右边,再写左边#

接着加入购买动作:显示原有金币,扣掉6枚,再显示余额。先用手指按行读程序,遇到赋值就记下当前金币数,再与后面的预期输出核对。

PICO-8 / Lua
cls()
local coins = 20
print(coins)
coins = coins - 6
print(coins)

预期输出(从上到下):

运行结果
20
14

依次输出20、14。运行到 coins = coins - 6 时:

  1. 读取右边 coins 当前的20。
  2. 计算20-6,得到14。
  3. 把14写回左边那个 coins。

所以 coins = coins - 6 并不是要求“某个数等于自己减6”。这里的等号是赋值,方向是把右边算出的值交给左边的变量。计算式可以写在右边,但不能写 coins - 6 = coins

后续更新用 coins = ...,不需要再次写 local。不要把“修改已有变量”与“重新声明同名变量”混起来。本章也先不用 PICO-8 的 -= 简写。

3.3 更新顺序会改变结果#

从10分开始,先奖励2分,再把当前分数翻倍:

PICO-8 / Lua
cls()
local score = 10
score = score + 2
score = score * 2
print(score)

预期输出(从上到下):

运行结果
24

最后得到24。若把两个更新语句交换,先翻倍得到20,再加2,最后变为22。程序没有自动的“最终公式”,每行都作用于那一刻的值。

在纸上给每个变量画一列,每执行一次赋值就记录一次:

执行后score
local score = 1010
score = score + 212
score = score * 224

这样的记录叫执行追踪。它比一次猜整个程序更可靠,也能帮助你发现自己在脑中跳过的更新。

3.4 复制当前值,不是建立自动联系#

假设你想保留奖励前的分数,用来和奖励后的分数对照。需要两个名字:score记录当前分数,saved保留当时的分数。先预测奖励后两个名字分别是多少,读完程序后核对。

PICO-8 / Lua
cls()
local score = 10
local saved = score
score = score + 2
print(score)
print(saved)

预期输出(从上到下):

运行结果
12
10

输出12、10。执行 local saved = score 时,saved获得的是当时的数值10。之后修改score,不会让saved跟着重新计算。

同样地,local total = price * count 会在这一行计算一次。以后price改变了,total不会自动刷新;若需要新的总价,要再执行一次 total = price * count

这里讨论的是数字与字符串等本章用到的数据。以后学习表时,会专门讨论另一种引用行为,不要把这里的结论推广成所有类型都独立复制。

3.5 把重复算式改写成步骤#

现在把前面的知识合起来完成购买程序。任务是:有50枚金币,先买8枚的门票,再尽量购买每件6枚的物品,显示买到的件数与最后余额。

先写出自己的计算步骤,再对照这张表。不要一开始就抄整段程序。

顺序要记录的数据名字与来源
1原有金币、门票、单价coinsticketprice,由题目给出
2买票后可用金币available,用原有金币减门票
3能买的完整件数count,用可用金币除以单价并向下取整
4最终余额left,从可用金币中减去物品总价
5给读者看的结果依次输出countleft

试着按这个顺序在纸上写一次。忘了赋值写法可以回看3.1;忘了完整份数可以回看第2章2.4节。

PICO-8 / Lua
cls()
local coins = 50
local ticket = 8
local price = 6
local available = coins - ticket
local count = flr(available/price)
local left = available - count*price
print(count)
print(left)

预期输出(从上到下):

运行结果
7
0

输出7、0。现在每个名字都解释了一件事:coins是原有金币,ticket是门票,price是单价,available是购票后余额,count是件数,left是最终余额。

如果把顶部coins改成53,按程序推演,应输出7、3。你不必在后面每处都改50,因为它们都通过变量读取数据。

再恢复50枚金币,把门票改成9枚。先自己算件数与余额,再按程序推演核对:应是6件、剩5枚。这样检查两种数据,可以确认你写的是会重新计算的规则,而不是碰巧显示正确的固定答案。

不过,如果你在算出available之后才修改coins,available仍然是旧结果。这里的便利来自“按照依赖顺序重新推演计算”,不是变量之间自动同步。

写变量的目的也不是把每个数字都变成很长的名字。优先给规则中会修改、会反复用、或有独立含义的数据命名。

3.6 变量也能保存文字,代码可以有注释#

PICO-8 / Lua
cls()
local message = "ready"
print(message)
-- change the message
message = "done"
print(message)

预期输出(从上到下):

运行结果
ready
done

先显示 ready,再显示 done。赋值可以更换内容,不会把旧文字接在新文字后面。

-- 开始到当前行结束的是注释,只用于帮助阅读,不执行。本章使用英文注释避免外部文件字符显示的干扰;你用中文在笔记中解释也可以。注意 print("--") 里面的两个减号属于字符串,并不是注释起点。

命名时使用英文字母、数字和下划线,先用字母开头,统一小写,例如 player_scorecoins2。名字中不要放空格或减号,不要以数字开头,也不要把 local、end 等关键字拿来当变量名。不要覆盖 print、cls、flr 这些现成函数的名字。

3.7 更新出了问题时怎样查#

如果你期待分数增加却一直不变,检查是否只计算而没有保存。例如 print(score+5) 会显示加5后的结果,却没有修改score;真正更新需要 score = score + 5

如果得到意外的非数字或无法计算,检查变量是否已经建立、是否拼错名字、是否误加了引号。不要通过到处重新写local碰运气。

用中间输出可以确定是哪次赋值与预期不同:先输出初始值,再输出第一次更新后的值,最后输出第二次更新后的值。排错完成后,可以删除这些辅助输出,保留对玩家有用的内容。

按症状选择检查位置:

看到的现象先检查什么
显示coins这几个字母是否写成了print("coins")
显示加分结果,原分数却没变是否只有print(score+5),缺少赋值
改了单价,总价还是旧结果是否在修改单价之后重新计算了总价
重新推演后分数回到初值程序又从顶部赋初值,这是本章程序的正常行为

这里的变量暂时记录一次运行中的数据;关闭后还能保存进度是另一个问题,不能仅靠给变量取名字来完成。

下一节预告:第四章将问:金币不够时还要买吗?生命值已经很低时还能继续扣吗?这需要让程序按条件选择,进入“条件与选择”。

第 4 章:让程序作出选择#

你已经能算出购买后的余额,但如果金币不足,减法仍然会照常进行。这时缺的不是另一个算式,而是一条决定:满足购买条件才扣款。

这一章学习比较和完整的 if 语句,先处理一条条件,再处理多个互斥结果。读完后,你应能解释哪个分支执行、检查边界输入,并区分“一组选一个”与“每条都检查”。

需要第三章的局部变量与赋值。仍然在程序顶部设置数据后代入推演,不引入按键、循环或函数定义。全部示例独立替换旧代码。

4.1 一个比较会得到什么#

先给上一章的购买程序补上一条规则:金币足够才允许购买,不足时保留原来的金币。把任务分成判断、动作和结果三部分:

要解决的问题本次的要求
怎样知道买得起比较金币与价格,刚好相等也算足够
买得起时做什么扣去价格,显示购买成功
买不起时做什么不扣钱,显示金币不足
两种情况结束后都显示当前金币

先只做第一步:让电脑回答“够不够”。下面的程序暂时不会购买。先预测两行输出:想好答案再继续往下读,用后面的解释核对。

PICO-8 / Lua
cls()
local coins = 8
local price = 5
print(coins >= price)
print(coins == price)

预期输出(从上到下):

运行结果
true
false

coins >= price 的含义是“金币是否大于或等于价格”。它不像减法那样得到余额,而是得到布尔值 truefalse,表示成立或不成立。输出字形可能随显示方式变化,本书写作true、false。

truefalse 是语言中的值,不要加引号把它们变成文字。可以把一次比较的结果存进变量,例如 local enough = coins >= price。这个变量同样只在赋值时计算一次,金币变动后不会自动更新它。

4.2 满足条件才做事#

上一节只是显示比较结果,这一节用比较结果决定是否扣钱。先尝试口头读出程序:“如果金币至少等于价格,那么……;这次判断结束后……”。

PICO-8 / Lua
cls()
local coins = 8
local price = 5
if coins >= price then
    coins = coins - price
    print("bought")
end
print(coins)

预期输出(从上到下):

运行结果
bought
3

程序先设置coins=8、价格5,比较成立,所以进入then与end之间的部分,扣去5并显示bought。end标记这一段条件语句的结束。最后的余额输出在end之后,无论买不买都会执行。

读这一段时,把它分成三个位置:ifthen之间放条件;then之后放条件成立才执行的动作;end之后继续执行后续程序。它们属于Lua的控制语法,不是需要添加调用括号的PICO-8函数。

把coins换成3再在纸上代入推演:不会显示bought,余额仍是3。不是先扣钱再“撤销”,而是扣款语句根本没执行。

缩进是给人看的,帮助识别属于分支的语句。我们统一把分支内部缩进四个空格;真正划定分支的是then、else、end等语法标记。先使用完整写法,不用单行简写。

4.3 不满足时也给出反馈#

金币不足时,上一节只显示一个余额,读者不一定知道为什么没买成。现在补上“不足时显示提示”的步骤。先自己在纸上尝试补写这几行,再对照完整示例。

PICO-8 / Lua
cls()
local coins = 3
local price = 5
if coins >= price then
    coins = coins - price
    print("bought")
else
    print("not enough")
end
print(coins)

预期输出(从上到下):

运行结果
not enough
3

coins=3时输出not enough和3。把coins换成5时,应能推演出输出bought和0。因为购买条件是“大于或等于”,刚好足够也是允许购买的情况。

else 后面不写另一个条件,它表示前面的条件不成立时执行这一部分。一对if/else只走其中一个分支,但分支之后的普通语句仍会继续执行。

检查规则时最有用的三个输入,常常是边界前、正好边界、边界后。单价5就用金币4、5、6,不要只挑100枚这种明显足够的情况。

保持price为5,只把coins换成下面各值,每次都在纸上代入推演;先遮住“预期”两列写下自己的预测,再对照解释核对:

初始coins预期提示预期最终coins
4not enough4
5bought0
6bought1

如果5枚买不了,检查是否把>=写成了>;如果4枚也被扣钱,检查扣款语句是否放到了end后面。此时不要同时修改比较和扣款,先找出是哪一步不符合规则。

4.4 把口头规则翻译成比较#

“大于”写 >,“小于”写 <,“至少”通常写 >=,“不超过”通常写 <=。相等用 ==,不等用 ~=

这里的一个等号 = 仍是赋值,两个等号 == 才是比较。写 if score = 10 then 不能表示“当分数等于10”。PICO-8还接受另一种不等写法,但本书先统一使用通用的 ~=

“至少10”与“超过10”的区别只发生在10这个边界上,却可能决定玩家是否能过关。先用自己的话重述规则,再选运算符。

不要把范围写成 0 <= x <= 10。它不是这里的正确范围判断方式;下一章会教怎样把两个比较组合起来。

4.5 多种结果只选一种#

分数至少20显示gold,至少10但不到20显示silver,其余显示bronze。

这里要求每次只显示一个等级。先在纸上给9、10、19、20分各写一个等级,再看代码如何从高门槛开始逐项判断。

PICO-8 / Lua
cls()
local score = 15
if score >= 20 then
    print("gold")
elseif score >= 10 then
    print("silver")
else
    print("bronze")
end
print("done")

预期输出(从上到下):

运行结果
silver
done

程序先检查第一项。score=15时第一项不成立,才检查elseif;第二项成立,输出silver,然后跳过else。整条链结束后输出done。

如果score=25,第一项已经成立,就不会继续检查后面的分支,即便 score >= 10 也成立。因此应该先写较高门槛。如果把至少10放在前面,25分会过早落入silver。

elseif 是一个完整关键字,不写成两个单词else if。多个elseif共享最后的一个end。本章先用少量分支,把每条路径追踪清楚。

4.6 两个独立的 if 不会互相排斥#

PICO-8 / Lua
cls()
local score = 25
if score >= 10 then
    print("bonus a")
end
if score >= 20 then
    print("bonus b")
end

预期输出(从上到下):

运行结果
bonus a
bonus b

两句奖励都出现,因为第二个if不属于第一个if的else链,它会独立检查。某些规则确实需要这样:达到10分可获得普通奖励,达到20分再获得额外奖励。

但如果你只想显示一个等级,就应该使用上一节的if/elseif/else。这不是“哪种写法更高级”,而是两种不同的规则。

4.7 分支改变数据时,先想清条件检查时刻#

PICO-8 / Lua
cls()
local hp = 2
local damage = 3
hp = hp - damage
if hp < 0 then
    hp = 0
end
print(hp)

预期输出(从上到下):

运行结果
0

生命值从2扣3变成-1,然后条件 hp < 0 成立,把hp改成0。最终输出0。这叫给结果设置下限;本章只处理上面明确的规则。

若把条件检查放在扣伤害之前,初始hp=2时条件不成立,随后仍会扣成-1。一次检查不会保护它后面所有未来变化。

排错时按三步记录:条件检查前变量是多少;比较结果是什么;按规则会走哪个分支。必要时想象把then或else内的语句换成不同的文字,确认走的是哪条路径,再检查更新算式。

4.8 怎样测试自己的判断#

从这一节开始,每次核查程序都同时看“走了哪条分支”和“数据变成多少”。购买提示正确,但没有扣款,仍然不算完成。

每次只修改顶部的测试数据,换几个边界值在纸上代入推演,并记下结果。一个购买程序至少核对:不足时不扣款、刚好时可以买、较多时余额正确。一个等级程序要把每个分支都推演到,而不是把第一条走通就算完成。

不要让答案替你选择测试输入。题目给出的一组输入用于验收,你也应该能解释“为什么恰好用这几个值”。第5章会继续处理同时满足多个条件时的检查。

可以在笔记里留下四列:输入、预期分支与余额、推演结果、需要修正的步骤。预期要在代入推演之前写;这样才能区分自己理解了规则,还是推完才接受了算出来的数字。

下一节预告:下一章将加入“有钱并且有库存”等组合条件,继续沿用本章的分支追踪与边界检查方法。

第 5 章:把多个条件组合起来#

金币足够,不一定就能购买:物品还必须有库存。玩家拿到钥匙,不一定能开门:也许游戏正暂停。上一章的一次比较还不足以表达这些规则。

这一章学习and、or、not,并继续用完整if语句执行决定。最重要的不是背符号,而是能把一句规则拆成独立的问题,再决定它们怎样组合。

需要掌握变量、比较和if/elseif/else。本章仍然只修改程序顶部的数据,然后在纸上推演结果;不使用按键、循环、表或自定义函数。每个示例都是独立的一段程序,阅读时不必和前面的示例连起来看。

5.1 “同时满足”用 and#

想购买物品,需要“金币足够”并且“库存大于0”。先分别写出 coins >= pricestock > 0,再用and连接。

第4章只检查钱,现在给购买任务增加库存规则:只有钱够且有货,才能同时扣钱、减少一件库存;失败时两项数据都不变。

先别急着看整行条件,分开回答两个问题:8枚金币买5枚的物品,钱够吗?库存为0,有货吗?只有两个回答都是“是”,这次购买才允许发生。下面的程序应走哪条分支?先记下你的判断,想好答案再继续往下读,用后面的解释核对。

PICO-8 / Lua
cls()
local coins = 8
local price = 5
local stock = 0
if (coins >= price) and (stock > 0) then
    coins = coins - price
    stock = stock - 1
    print("bought")
else
    print("wait")
end
print(coins)

预期输出(从上到下):

运行结果
wait
8

coins足够,但stock是0,所以整体条件不成立,输出wait,金币仍为8。在纸上把stock改为1再读一遍:两个条件都成立,才会扣款并减少库存,输出bought和3。

两个括号分别标出完整比较,方便阅读;并不是PICO-8单行简写。我们仍然写then和end。

可以把两个比较的结果列出来,检查有没有漏掉某种情况:

金币足够有库存允许购买
falsefalsefalse
falsetruefalse
truefalsefalse
truetruetrue

四行分别是一组测试,不是四种新语法。只测最后一行,会漏掉把and误写成or的错误。

把这四行落实成纸面上可以推演的数据。保持price=5,依次修改coins和stock,每次都从头重新读程序:

初始coins初始stock预期提示预期最终coins预期最终stock
40wait40
41wait41
50wait50
51bought00

示例只显示提示和金币。为了检查库存,你可以在程序末尾自己写下print(stock)这一行,按表核对两个值;核对后再决定是否保留这行显示。不能只看到bought就认为扣库存也一定写对了。

5.2 “至少满足一个”用 or#

有钥匙,或者门已经被打开过,都可以通过。

和购买规则对照:购买时缺一项就不行;这里缺钥匙仍可能通过已经打开的门。先说清这一区别,再选择and还是or。它们是Lua关键字,不是&|

PICO-8 / Lua
cls()
local has_key = false
local unlocked = true
if has_key or unlocked then
    print("open")
else
    print("locked")
end

预期输出(从上到下):

运行结果
open

has_key是false,但unlocked是true,整体仍成立。or也允许两边都成立;它不是“必须恰好一个成立”。两者都为false时才不允许通过。

不要只记住中文里的“或”字:有时规则要求“只能选一项”,那与这里至少满足一项不同。读到这类规则时,先明确它是包含还是排除同时成立的情况,再决定用哪个关键字。

5.3 “不成立”用 not#

暂停状态已经由变量paused记录。现在要表达的是“没有暂停才继续”,因此对paused取反。先预测:paused为true、false时分别会显示什么?想好答案再继续往下读,用后面的解释核对。

PICO-8 / Lua
cls()
local paused = false
if not paused then
    print("update")
else
    print("pause")
end

预期输出(从上到下):

运行结果
update

paused是false,所以not paused得到true,输出update。not会把真假意义反过来,并得到一个布尔值。

not (score >= 10) 表示“没有达到至少10分”,对本章的数值来说等价于score<10。括号让not作用于整个比较;不要写成 not score >= 10,那不是先完成比较再取反的意思。

对范围也要想清楚:在0到10之间包含两个端点,需要 (x >= 0) and (x <= 10);在范围外则是 (x < 0) or (x > 10)。左右两个越界方向不可能同时出现,不能误用and连接它们。

5.4 混合规则先分组#

“游戏没有暂停,并且有钥匙或者门已打开”容易产生歧义。我们真正想说的是:“先保证未暂停;钥匙和已开门两者再满足一个”。

按两层写在纸上:第一层问“有钥匙或门已开吗”,得到通行资格;第二层问“未暂停,并且有通行资格吗”。下面把第一层用括号包起来,再接到第二层中。先分清规则的层次,不必一次在脑中算完整行。

PICO-8 / Lua
cls()
local paused = true
local has_key = false
local unlocked = true
if (not paused) and (has_key or unlocked) then
    print("go")
else
    print("wait")
end

预期输出(从上到下):

运行结果
wait

这次paused=true,所以即使unlocked=true也显示wait。写成 ((not paused) and has_key) or unlocked,则已开门会绕过暂停限制,规则变了。

语言中not优先于and,and优先于or。但写游戏规则时,建议把意图直接用括号标出来,或者先用变量保存一个有意义的比较结果。不要期待代码自动理解你的中文。

只在需要提高可读性的地方分组;不是括号越多越正确。每对括号都应能对应你说出的一个子规则。

5.5 两层 if 也是一种拆分方法#

如果组合式暂时不好读,可以先查金币,再查库存:

PICO-8 / Lua
cls()
local coins = 8
local price = 5
local stock = 0
if coins >= price then
    if stock > 0 then
        print("buy")
    else
        print("no stock")
    end
else
    print("no money")
end

预期输出(从上到下):

运行结果
no stock

外层条件不成立时,直接显示no money;只有外层成立才进入里面那个if。stock=0时得到no stock。这段程序比一个and更长,但能区分两种失败原因。

缩进体现层次,两个if各有一个end。不要看到两个end就随便删掉一个;先确定哪个结束内层、哪个结束外层。这个拆分也有助于理解下一节的短路。

5.6 条件位置里的真假,与数值大小无关#

前面用的比较返回true或false。Lua还允许把别的值放在条件位置。这时规则是:只有false与nil视为假,其余值视为真,包括数字0和空字符串。

nil是一个特殊值,本章用它表示尚未提供有效数据,写作关键字nil,不加引号。可以显式写 local amount = nil,也可以通过 amount == nil 判断是否缺失。它不是数字0,也不是字符串"nil"。没有赋值的局部变量也会是nil;为清楚起见,本章例子都显式写出它。

PICO-8 / Lua
cls()
if 0 then
    print("zero is truthy")
end
if "" then
    print("empty is truthy")
end
local value = nil
if value then
    print("present")
else
    print("missing")
end

预期输出(从上到下):

运行结果
zero is truthy
empty is truthy
missing

因此不要用 if coins then 表示“有钱”,要根据规则写 coins > 0coins >= price。同样地,空字符串也不会自动被判为假。

遇到库存为0、生命值为0这样的数据,仍然先问“规则要求比较什么”,再写stock > 0hp > 0。数值表示数量,放在条件位置时的真假并不会替你判断数量是否足够。

5.7 短路:不需要的右边不会计算#

前面的购买程序默认金币和库存都已提供。现在处理另一个问题:数据尚未提供时,不能直接拿它和0比大小。先确认值存在,再检查大小,这两步有先后顺序。

and从左边开始:左边是假,整体已经不可能满足“同时成立”,右边便不再求值。or相反:左边是真,已经满足“至少一个”,右边不再求值。

PICO-8 / Lua
cls()
local amount = nil
if (amount ~= nil) and (amount > 0) then
    print("positive")
else
    print("empty or missing")
end

预期输出(从上到下):

运行结果
empty or missing

amount是nil,左边amount~=nil得到false,于是右边amount>0不会执行。程序显示empty or missing,而不会去比较缺失值与数字。

在纸上把amount改为3再读这段程序:左边成立,右边才继续比较,输出positive;改为0时,右边不成立。保护检查必须写在危险操作之前。把两个比较交换,nil时就会先执行无法进行的大小比较。

短路不是“永远只算左边”,而是左边已经足以决定时才跳过右边。之后写函数时,同样要注意被跳过的调用不会产生作用。

5.8 选读:and、or 会返回参与选择的值#

当两边都是布尔值时,你容易以为and、or只返回布尔值。实际规则是:and在左边为假时返回左值,否则返回右值;or在左边为真时返回左值,否则返回右值。

PICO-8 / Lua
cls()
print(nil or 5)
print(0 or 5)
print(true and 7)

预期输出(从上到下):

运行结果
5
0
7

结果是5、0、7。nil or 5选默认值5;0是真,所以0 or 5仍是0;true and 7返回右边的7。

这使 supplied or 3 可以表示一种默认值选择,但它同时把false和nil当作“没有提供”。如果false本身是有效设置,就应该用显式 if supplied == nil then 处理缺省,不要一概替换成or。

先把完整if写清楚就足够,不必为了简短而使用and/or模拟复杂的选择。读懂这项规则,是为了识别陷阱,不是要求初学者追求短代码。

5.9 组合条件怎样验收#

先完成一种规则,再逐项加限制。比如先确认“钱够且有货”正确,再接入“未暂停”;加入后专门用“钱够、有货、但暂停”的输入检查新限制是否生效。

对于两个布尔条件,先列四种组合。对于数值范围,检查左侧之外、左端点、内部、右端点、右侧之外。对于含nil的输入,还要单独检查缺失情形。

把失败原因分开:条件翻译错、括号分组错、分支内更新错、或检查使用了旧数据。先写出每个子比较的结果,再看组合式,就像第二章把长算式拆成中间计算。

对于缺失值保护,不要为了看中间结果而单独计算amount > 0;amount为nil时,这会绕过原来的保护。先确认amount存在,或只看安全的amount ~= nil。排错时也要保持检查顺序。

下一节预告:下一章解决“重复做事”的问题——让程序按固定次数重复执行。在那之前,先保留本章的习惯:解释规则、分开子条件、检查边界、再验证更新后的数据。

第 6 章:让程序重复固定次数#

假设要显示五个关卡编号,你已经会写五行 print。但关卡数改成八个时,要再添三行。接下来让程序保留一份工作说明,并自动换着编号执行它。

本章需要变量、算式与 if;学习数值 for、步长、累计和简单的重复排列。暂不处理等待按键、动画或一组独立对象。循环会在这次运行中很快做完,重复执行不等于出现动画。

阅读约定:正文中的每个示例都是完整程序,请逐行在脑中读它,一次只研读一个示例,不把本章代码拼在一起。预期输出框从上到下对应程序会在屏幕上显示的文字行。

6.1 从三次输出到一份重复说明#

先完成一个小任务:显示关卡编号1、2、3,全部显示完后再显示done。暂时只做编号页,不做关卡切换。

把需求分成三件事:

要确定的事本次的安排
哪些工作重复显示当前关卡编号
每次什么会变编号从1变成2,再变成3
哪些工作只做一次开始时清屏,结束后显示done

不用循环你已经能完成它。现在看下面怎样只写一份重复的工作。先预测:屏幕会有几行(注意最后的 done 不在循环内部)?想好答案再继续往下读,用后面的预期输出和解释核对。

PICO-8 / Lua
cls()
for i = 1, 3 do
    print(i)
end
print("done")

预期输出:

运行结果
1
2
3
done

读作:让 i 从 1 数到 3,每次执行 do 和 end 之间的语句。第一次 i 是 1,显示 1;接着 i 是 2,再执行同一行;然后是 3。下一次将超过终点,循环结束,才显示 done。

i 是本次循环的计数变量,名字可以改为 level,但内部引用也要一起改。默认每次增加 1,起点和能到达的终点都包含在内。不要手动给 i 加 1;推进由 for 负责。它只在循环内部有效,若要在外面保存结果,要另外准备变量。

试着在脑中把终点改成 1,应该预测出显示 1 和 done;再改成 0,循环内部一次也不执行,只剩 done。这个差别之后会帮助我们处理空列表。

再把终点在脑中改成 5:不用增加新的print,应当显示五个编号,最后只有一个done。能指出“改哪一处就改变次数”,才说明你知道循环负责什么。fordoend是Lua语法标记,不是PICO-8绘图函数。

6.2 步长决定怎样走#

起点、终点之后的第三个数叫步长。倒数要同时写对方向和负步长。

阅读下面的代码前,先手写候选序列:从起点出发,每次加步长;到下一项会越过终点时停下。不要先用“终点减起点”猜轮数,起点是否包含、步长是否跨过终点都会影响结果。想好后与预期输出和解释核对。

PICO-8 / Lua
cls()
for i = 1, 8, 3 do
    print(i)
end
print("back")
for i = 5, 1, -2 do
    print(i)
end

预期输出:

运行结果
1
4
7
back
5
3
1

第一段依次到 1、4、7;下一步是 10,越过 8,所以不会为了凑终点额外打印 8。第二段依次减 2,从 5 到 1。

正步长在计数值超过终点后结束;负步长在计数值小于终点后结束。for i = 5, 1 do 的默认步长仍然是正 1,所以一次也不执行。步长不要写 0;本章也只用小整数,避免把学习循环变成定点数与数值溢出的实验。

6.3 保留每一轮的成果#

三个关卡分别奖励 1、2、3 分。目标是得到总分,而不仅是分别看到奖励。

这次要保存两种不同的数据:reward表示这一轮的奖励,由循环依次给出;total表示到目前为止的总分,需要跨轮保留。先在循环外准备总分,再在循环内更新,最后报告结果。

PICO-8 / Lua
cls()
local total = 0
for reward = 1, 3 do
    total = total + reward
    print(total)
end
print("final")
print(total)

预期输出:

运行结果
1
3
6
final
6

total 放在循环前创建,表示“目前累计了多少”。每一轮读取旧总分,加本轮奖励,再存回 total。

本轮 reward更新前 total更新后 total
101
213
336

把初始化放进循环,就等于每轮都把记账本擦成零,不能保留上轮结果。把循环内的 print 删掉,则只显示 final 和最终的 6。这是“每轮观察”和“最终报告”的区别。

6.4 只累计符合规则的轮次#

并不是每个关卡都计入奖励。把已经学过的条件放进循环,看看执行顺序怎样连接。

PICO-8 / Lua
cls()
local count = 0
local total = 0
for level = 1, 5 do
    if level >= 3 then
        count = count + 1
        total = total + level
    end
end
print(count)
print(total)

预期输出:

运行结果
3
12

每一轮先取得 level,再判断条件。1 和 2 不进入分支;3、4、5 分别让 count 加 1,让 total 加上关卡编号。count 回答“几次符合”,total 回答“符合项的值加起来是多少”,两者不要混淆。

这里有两个 end:缩进较深的结束 if,最外面的结束 for。先对齐结构再排错,比随意补一个 end 更可靠。

自己在脑中把门槛3改成4,先预测再对照解释核对:符合的是4、5两关,因此count为2、total为9。再把门槛改成6,两者都应是0。循环仍执行五轮,只是没有任何一轮符合加分条件;“循环没执行”和“分支没执行”要分开看。

6.5 用公式排列文字#

把编号页做得更整齐:每个标签靠左对齐,相邻两行保持相同距离。这次循环仍然负责重复显示,新增的任务只是用本轮编号算出位置。

PICO-8 的 print(内容, x, y, 颜色) 可以指定文字位置;这里 x 向右增加,y 向下增加,颜色 7 是默认调色板中的白色。坐标从左上角 (0,0) 开始,屏幕是 128×128。我们只借这个接口观察间隔,游戏坐标会在后面系统学习。

先认识连接运算符 ..:它是两个英文句点,用来把文字连接起来。这里连接数字时,数字会转成文字;例如 "level "..1 得到 level 1,中间的空格来自引号内末尾的空格。row 每轮变化,因此下面会依次显示不同编号。

读完下面的示例后,试着在脑中修改:把 "level " 改成 "stage ",再试着用 (row+1) 替换连接右侧的 row。预测前缀和编号分别怎样变化,想好后回到原例。

PICO-8 / Lua
cls()
for row = 1, 4 do
    local y = 8 + (row - 1) * 10
    print("level "..row, 8, y, 7)
end

预期输出:

运行结果
level 1
level 2
level 3
level 4

四行文字的 x 都是 8,y 分别是 8、18、28、38。row - 1 让第一行偏移为 0;若直接用 row,第一行就会从 y=18 开始。

把公式读成“起始位置+已经跨过的间隔数×每段间隔”。第一行还没有跨过间隔,所以用row-1。修改位置前先算第一行和最后一行,既能检查公式,也能判断文字是否仍在屏幕内。

预期输出框只表示文字顺序;在实际屏幕上还应看到相邻行相隔 10 像素。在脑中把乘数 10 改成 12,文字内容不变,行间距变大。cls 放在循环前清屏一次;若每轮清屏,前面画的文字会消失。

6.6 两层循环:按行处理多个位置#

一次循环只改变一个计数。如果要列出两行、每行三个位置,可以在外层的每一轮中完整运行一遍内层。先跟着下面的六个位置在纸上走一遍。后面处理棋盘时还会用到这个顺序。

PICO-8 / Lua
cls()
for row = 1, 2 do
    for col = 1, 3 do
        print(row..","..col)
    end
end

预期输出:

运行结果
1,1
1,2
1,3
2,1
2,2
2,3

外层 row=1 时,内层 col 从 1 完整走到 3;然后 row 才变成 2,内层重新从 1 开始。总次数是 2×3=6。不能把它想成 row 和 col 一起加 1。

可以画一个两行三列的格子,在第一行从左到右依次标1、2、3,再在第二行标4、5、6。这些是处理顺序;代码输出的则是对应位置的行号、列号。现在只认识遍历顺序,不要求已经会地图编辑器或手绘素材。

6.7 循环结果不对时,分开检查#

数值 for 开始时会确定它的起点、终点和步长。不要指望在循环里修改原先提供终点的变量来提前结束循环;下一章会介绍 break。也不要修改计数变量去跳轮次。

排错时先写出应出现的计数序列,再区分三件事:循环做了几轮、每轮改变了什么、最终在哪里输出。检验固定次数规则时,至少在脑中试 0 次、1 次和几次这三组。循环次数和累计值保持在本章的小数值范围内。

现象优先检查
少了末项终点能否按步长正好到达
一轮也没执行起点、终点和步长方向是否匹配
总分只剩本轮奖励是否每轮都重新初始化total
done出现很多次是否误放在循环内部
画面只剩最后一行是否每轮都调用了cls

一次只修正一个原因,再对照解释用原来那组输入核对,避免同时改动次数、累计和输出位置。

下一节预告:本章的 for 循环都在一次运行中按固定次数做完。下一节《让循环在合适的时候结束》将介绍 while、repeat 与 break,让循环改在合适的时机停止。

第 7 章:让循环在合适的时候结束#

有些工作不是预先决定“做五次”,而是“金币够就继续买”“能量不足就继续充”。我们需要每轮重新看数据,再决定是否继续。

本章需要第 4~6 章的条件、更新与累计。学会 while、repeat/until、break,并能在不运行的情况下说明循环为什么会结束。这里用固定初值模拟规则,不等待真人输入,也不编写实时动画。

阅读约定:每个正文示例都是为在 PICO-8 中运行而写的完整程序。阅读时把每个示例单独理解,不把本章代码拼在一起。“预期输出”框从上到下对应程序在屏幕上输出的文字;预测时以这几行文字为准。

7.1 先检查,再决定做不做#

每件物品价格 3,手上有 8 枚金币。先写出自己会怎样一件一件买,然后比较下面的执行过程。

第4章判断一次购买,第5章组合购买条件,现在让购买动作重复进行。先拆步骤:

  1. 检查当前金币够不够买一件。
  2. 足够就扣3枚,并把购买数加1。
  3. 回到第一步,用新的余额再检查。
  4. 不足时停止,显示购买数和余额。

这里演示的是一次运行中自动完成结算,不是让玩家按一次按钮买一件。重点观察每次扣款以后,下一轮读取的是哪个余额。

PICO-8 / Lua
cls()
local coins = 8
local bought = 0
while coins >= 3 do
    coins = coins - 3
    bought = bought + 1
end
print(bought)
print(coins)

预期输出:

运行结果
2
2

while 后面是“继续做”的条件,do 与 end 之间是每轮工作。第一次检查 8>=3 成立,买一件后剩 5;第二次检查成立,剩 2;第三次 2>=3 不成立,跳到 end 后。最终买了 2 件,剩 2 枚。

检查时 coins能继续吗做完后的 coins / bought
85 / 1
52 / 2
2不再执行

把 coins 改成 2,循环一次也不执行,输出 0、2。while 不保证会执行一轮;它保证每轮之前都先检查。

再分别用3枚、6枚测试,应得到1件/0枚、2件/0枚。最后一次扣款达到0以后,还会再检查一次条件,只是不再购买。因此“检查了几次”和“购买了几次”不一定相同。

7.2 能结束的理由要写得出来#

先不用复杂购买规则,观察一个计数式 while。它与上一章的 for 做相同的工作,但推进要自己写。

PICO-8 / Lua
cls()
local n = 1
while n <= 3 do
    print(n)
    n = n + 1
end
print("done")
print(n)

预期输出:

运行结果
1
2
3
done
4

执行顺序是:检查 → 输出 → 更新 → 回到检查。显示 3 后,n 还要变成 4,下一次检查才会失败。循环后的 n 因而是 4,不是 3。

读 while 时必须能回答:开始是什么值?什么条件允许继续?哪一行让数据朝结束方向变化?什么时候条件会变假?如果 n 没有更新,就会反复显示 1;如果把 n=n+1 改成 n=n-1,在本题的合理小整数范围内也不会朝终点前进。

给本例写一句结束理由:“n从1开始,每轮增加1;显示3以后变成4,4不满足n<=3,所以停止。”先能写出这句话,再继续阅读后面的解释,对照核对。上一章的for替你推进计数,这里的while需要你自己写更新。

对故意缺少更新的程序要特别当心:条件永远不满足时,循环不会结束。正确的做法是先在纸上模拟两轮,修正后再按同样的方法核对,不必依赖实际运行来发现这一点。

7.3 条件要反复算,不能只保存第一次结果#

上一章已经知道布尔变量不会自动跟随数据变化。本例直接在 while 条件中写比较,这样每轮都会重新判断。若把结果存进布尔变量,也必须在数据变化后及时更新它。

PICO-8 / Lua
cls()
local energy = 1
local steps = 0
while energy < 7 do
    energy = energy + 2
    steps = steps + 1
end
print(energy)
print(steps)

预期输出:

运行结果
7
3

energy 依次变成 3、5、7,每次回到 while 都重新比较。若事先写 local low = energy < 7,后面只更新 energy 却一直检查 low,low 仍是第一次得到的 true。

循环终点也不一定正好命中。把初始 energy 改成 2,会得到 4、6、8,最后输出 8、3。这就是用 <7 表达“还不足”,而不用 ~=7 表达“还没正好相等”的原因。后一种写法在每次加 2、初值为偶数时可能一直跨过目标。

7.4 先做一轮,再检查是否完成#

有些规则要求至少进行一次操作。repeat 把检查放到循环体之后;until 后写的是“结束条件”。

PICO-8 / Lua
cls()
local energy = 5
local rounds = 0
repeat
    energy = energy + 1
    rounds = rounds + 1
until energy >= 5
print(energy)
print(rounds)

预期输出:

运行结果
6
1

即使开始已经有 5 点能量,仍先加一次,再检查 6>=5,结束。输出 6 和 1。

repeat 的结构是 repeat、工作、until 条件,末尾不用再补 end。while 的条件为真时继续;until 的条件为真时停止。把“还没完成”和“已经完成”说清楚再写,通常比背关键字可靠。

若需求是“不足 5 才充”,初值 5 应零次操作,应选择 while。若需求是“先做一次,再判断是否够”,才适合 repeat。两种循环不是随意互换的格式。

先用自然语言区分:while问“还要继续吗”,until问“已经完成了吗”。同样是加到目标,前者通常写“小于目标”,后者写“大于或等于目标”;不能只换关键字而照搬同一个条件。

7.5 已经找到答案,就提前离开#

在 1 到 8 中寻找第一个平方大于 20 的数。范围明确可以用 for,但一旦找到就不必查后面的数。

PICO-8 / Lua
cls()
local found = 0
for n = 1, 8 do
    if n * n > 20 then
        found = n
        break
    end
end
print(found)
print("done")

预期输出:

运行结果
5
done

1²、2²、3²、4² 都不满足,5²=25 满足,先记下 5,再执行 break。break 离开它所在的最内层循环,之后仍执行循环后面的 print。

这里用 0 表示“没找到”,因为候选数只有 1~8。它是本题约定,不是语言规定。先保存结果再退出,否则循环外不知道找到了什么。break 必须放在循环内;本书用完整 if/then/end 包住条件退出,不采用单行简写。

两层循环里,break 只结束内层,外层下一轮仍会启动。若需要结束更外层的工作,要给外层也安排判断。

自己在纸上把候选终点8改成4推演:没有合格候选,found保持0。恢复到8以后,再把门槛20改成8:第一个合格值应是3。每次先写出预期,对照上面的解释核对——程序报告的是“第一个”合格值,而不是最后一个合格值。

7.6 为尝试次数设一个明确上限#

有时在允许的次数内仍达不到目标。我们同时记录进度与次数,并在结束后报告成功还是未完成。

PICO-8 / Lua
cls()
local value = 0
local attempts = 0
local target = 10
local limit = 3
while value < target and attempts < limit do
    value = value + 3
    attempts = attempts + 1
end
print(value)
print(attempts)
if value >= target then
    print("ready")
else
    print("limit")
end

预期输出:

运行结果
9
3
limit

两个条件都成立才允许下一轮。三次后 value=9,尚未到目标,但次数已经达到 limit,不能再做。把 limit 改成 4,会输出 12、4、ready;把 target 改成 0,会输出 0、0、ready。

上限只是需求的一部分或排错保护,不能替代对更新方向的理解。先明确每轮应该发生什么,再选循环:次数已知用 for,可能零次且要先检查用 while,明确至少一次用 repeat。

阅读长一点的循环时,可手画三列表:轮前值、轮后值、下一轮条件。先试“开始就完成”“恰好达到”“越过目标”“次数耗尽”,无需盲目增加大量数据。

先用原例的value=0,每轮加3,只改顶部target和limit:先在纸上算出每行的结果,再逐项对照下表核对:

targetlimit预期最终value预期attempts结束报告
0300ready
9393ready
104124ready
10393limit

达到目标和用完次数可以同时发生。第二行两件事同时发生,但结果已达到目标,所以报告ready。不要仅凭“次数等于上限”就判断失败。

7.7 动笔前怎样选循环#

需求优先使用运行前解释什么
显示固定数量的编号for起点、终点、步长决定哪些编号
够钱才继续买,可能一件也不买while每轮扣款后,什么情况下不再足够
规则明确要求至少操作一次repeat/until做完一轮后,怎样判断已经结束
检查途中已找到需要的结果在循环内用break先保存什么结果,再离开哪个循环

选择后先模拟最短的情况:零次是否允许?一次后是否能停?没有找到结果时怎么办?再检查多轮。不要先写长循环,最后才寻找一个能让它停下来的条件。

下一节预告:下一章学习函数,也就是“给一段工作起名字”——把一段工作命名之后,就能直接按名字使用它。

第 8 章:给一段工作起名字#

你会用循环重复一项工作了。但如果同一种结算规则要在程序的不同位置使用,复制整段代码会越来越难修改。函数让你把一段工作命名,之后通过名字请求它执行。

本章学习定义、调用、参数和一个返回值。沿用已经认识的 local,并采用 local function 写自己的小工具;下一章再仔细研究名字的有效范围。暂不使用递归、匿名函数、回调或对象。

阅读约定:本章代码示例都按 PICO-8 中的完整程序编写。请在脑中逐行跟踪每个程序,每个代码块后的"预期输出"框从上到下对应屏幕文字。每次只研读一个示例,不把本章代码拼在一起。

8.1 写下工作说明,不等于已经执行#

先从一个小任务开始:程序准备好时显示一次问候,完成别的工作后再显示一次。显示问候的方法只写一份,使用它的位置可以不同。

上一章的循环决定"一段工作重复几轮";这一章的函数给工作取名字,让你在需要的位置调用。两者也可以配合:循环决定重复,函数负责每次具体做什么。

下面故意在两次调用之间显示一个标记。先预测:ready、hi、end 的先后顺序是什么?想好答案再继续往下读,用后面的解释核对。

PICO-8 / Lua
cls()
local function say_hi()
    print("hi")
end
print("ready")
say_hi()
print("end")
say_hi()

预期输出:

运行结果
ready
hi
end
hi

读到定义时,程序记下名为 say_hi 的工作,没有执行里面的 print。走到 say_hi() 才进入函数,显示 hi,遇到函数末尾的 end 后回到调用处之后,继续显示 end。最后一次调用再显示 hi。

local function 名字() 是本书常用的完整定义开头,end 结束函数体。函数名像变量名一样要合法,不能使用 if、end 这类关键字,也不要取名 print 或 cls 覆盖内置工具。本教程统一使用带括号的调用形式;这里无参数的调用写作say_hi()。只有say_hi这个名字并没有请求执行,也不能单独写成一条调用语句。Lua另有特定参数形式可省略括号,本章不使用,不应把本教程约定当成所有调用的语法限制。

本章把定义放在第一次调用之前。若写了函数却没有屏幕输出,先检查是否实际调用,而不是只检查定义内部。

再做一次纸面推演:删掉最后一次say_hi()调用,输出应只剩 ready、hi、end;保留定义但删掉两次调用,则只有 ready、end。对照解释核对:定义写在上面,不代表里面的动作会先执行。

8.2 把变化的数据交给参数#

问候的结构相同,名字不同。把变化部分写成参数,就不必定义 say_amy、say_bob 两个函数。

PICO-8 / Lua
cls()
local function greet(name)
    print("hi "..name)
end
greet("amy")
greet("bob")

预期输出:

运行结果
hi amy
hi bob

定义中的 name 是参数,表示"本次调用收到的名字"。调用中的 amy、bob 是实际提供的值。第一次进入函数时 name 对应 amy,第二次对应 bob。

可以按"调用提供值→参数接住值→函数使用值"来追踪。函数定义里的name是一个位置的名字,不是要求调用处也必须有一个叫name的变量。

参数不是要求你在控制台临时键入的输入;它来自调用括号里的代码。想象把第二次调用改成 greet("kai"),只有第二行变化。字符串要加引号,参数名 name 不加引号;写成 print("name") 只会显示固定文字 name。

8.3 两个参数要约定好顺序#

先使用一个单纯计算:单价乘数量。函数算出结果,再交给调用者决定如何使用。

写函数前先给它一份简单说明:

项目cost的约定
做什么计算商品总价
接收什么第一个参数为单价,第二个为数量
交回什么单价乘数量得到的数值
是否显示不显示,由调用者决定

先照这份约定在纸上尝试写出函数,再对照下面的程序核对。函数名不会让电脑自动懂得"计算总价";真正的规则仍由函数体里的算式决定。

PICO-8 / Lua
cls()
local function cost(price, count)
    return price * count
end
local total = cost(3, 4)
print(total)
print(cost(5, 2))

预期输出:

运行结果
12
10

return 表达式 的意思是把结果交回调用位置。第一次 cost(3,4) 得到 12,相当于这一位置产生一个值,再赋给 total。第二次把函数结果直接交给 print。

从左到右对应:price 接第一个值,count 接第二个值。为了避免乘法交换后看不出问题,稍后还会练习扣款函数:balance(coins, price) 的两者调换就会改变结果。

返回和显示是不同工作。只有 return 不会自动显示结果,只有 print 也不等于向调用者返回你想要的计算结果。PICO-8 的 print 本身有绘图相关返回值,本章不把它当作金额计算结果。写计算函数时明确 return 自己的数值。

8.4 计算与显示各有职责#

让计算函数返回余额,显示函数负责固定格式。函数之间通过数据连接,便于分别查错。

这次的完整任务分成三个步骤:算余额、保存余额、显示余额。先沿着这三个步骤读代码,找出谁把结果交给谁,不要把两份函数定义误当作两次购买。

PICO-8 / Lua
cls()
local function remaining(coins, price)
    return coins - price
end
local function show_balance(value)
    print("coins")
    print(value)
end
local balance = remaining(10, 3)
show_balance(balance)

预期输出:

运行结果
coins
7

先算 remaining(10,3),得到 7;balance 保存它;show_balance(7) 只负责显示。若金额错了,先检查减法与传入值;若标题错了,只看显示函数。

纸面试想:把调用中的10改成12,预期标题仍是coins、余额变成9;再只改显示函数里的标题,金额应不受影响。改完对照解释核对。这里的remaining只做减法,金币不足时也会相减;8.6才加入购买条件。

每个函数只承担一件容易说清楚的工作。没有 return 的显示函数仍然可以完成输出,但不能指望 local n=show_balance(7) 得到 7:函数本身没有返回结果,赋值时缺少的结果会补成nil。调用次数也要注意:想显示一次,就只调用一次,而不是为了"取得结果"又调用一次。

8.5 函数内部也可以选择和重复#

函数并不是只能写一行算式。给出关卡数,返回从 1 到它的奖励总和。

PICO-8 / Lua
cls()
local function reward_total(levels)
    local total = 0
    for level = 1, levels do
        total = total + level
    end
    return total
end
print(reward_total(3))
print(reward_total(1))
print(reward_total(0))

预期输出:

运行结果
6
1
0

每次调用都执行一次函数体,所以每次都会从 total=0 开始本次计算。循环结束后才返回总和。若把 return 放进循环,第一轮就会离开整个函数,后面的奖励没有机会累计。

回想第6章:total要放在循环外,才能跨轮累计。这里它同时放在函数内,因此每次调用重新从0开始。这两个位置并不矛盾:一次调用内保留各轮成果,不同调用之间各自计算。

输入范围也属于说明的一部分:本函数接受 0~10 的整数关卡数。0 对应空范围,结果为 0;不承诺处理任意字符串或非常大的数。先把可接受的输入写清楚,再设计测试,比让函数猜测所有用法更适合当前阶段。

8.6 提前返回一项结果#

若金币不够,直接返回原余额;够钱才返回扣款后的余额。每一条路径都需要明确结果。

PICO-8 / Lua
cls()
local function buy_once(coins, price)
    if coins < price then
        return coins
    end
    return coins - price
end
print(buy_once(8, 3))
print(buy_once(2, 3))
print(buy_once(3, 3))

预期输出:

运行结果
5
2
0

第一组不进入不足分支,返回 5。第二组进入分支,return 2 立即结束这次函数调用,不再执行下面的减法。第三组刚好够,返回 0。

先检查返回规则,再对照输出核对:

调用购买情况返回的余额
buy_once(8,3)足够5
buy_once(2,3)不足,保留金币2
buy_once(3,3)刚好足够0

返回0也是一个正常计算结果,不表示函数没有返回。这个函数只算出新余额;如果调用处有自己的coins变量,还需要接收结果,下一章会仔细追踪这个过程。

return 离开当前函数;上一章的 break 只离开最内层循环。这里 return 放在 if 块末尾或函数体末尾,用完整结构表达不同返回路径。本题只接受非负金币、正价格。

如果一个有条件的计算函数只在成功分支 return,失败时就没有约定的数值;把结果拿去相加可能报错。先写清楚成功与失败各返回什么,再写分支。

8.7 把输出留给调用者检查#

最后组合两次函数调用,检查输入与结果之间的关系。这里不引入新语法。

PICO-8 / Lua
cls()
local function double(value)
    return value * 2
end
local first = double(3)
local second = double(first)
print(first)
print(second)

预期输出:

运行结果
6
12

第一次返回 6,第二次收到的是 6,返回 12。拆成两个中间变量,你可以逐个输出,确认问题出在哪一步;理解之后也能写 double(double(3)),但不必追求嵌套得最短。

排错顺序:是否调用了函数 → 定义是否在调用之前 → 参数数量和顺序是否符合约定 → 每条需要结果的路径是否 return → 调用者是否接收并显示正确的值。

本章先要求按约定传齐参数。漏传的参数会是 nil,不会自动变成 0;如果函数使用它做算术就可能出错。给参数补默认值属于可另行设计的规则,不能想当然地依赖。

下一节预告:下一章将仔细研究名字的有效范围,追踪函数返回的结果如何被调用处接收,并开始接触一次交回多个返回值的情况。

第 9 章:弄清数据在哪里有效#

同样写着 score,为什么函数里得到 9,函数外还是 5?有时这是正确隔离,有时是误把新变量当作旧变量更新。本章让你看清一个名字到底指向哪份数据。

需要会定义函数、传参数、返回一个结果,并会条件与循环。本章细讲 local、代码块、遮蔽、外层数据、多个返回值,以及如何把小程序拆成能单独检查的函数。对象与表的共享修改留到后面,当前“数值传入后不改原值”的结论只针对数值等当前例子。

阅读约定:本章所有示例都按在 PICO-8 中运行的效果呈现,每个正文示例都是完整程序。请在纸上逐例阅读推演,每次只看一个示例,不把本章代码拼在一起。每段预期输出从上到下对应屏幕文字。

9.1 内外同名,不一定同一份#

这一章继续解决实际问题:调用了加分函数,为什么外面的分数没变?先不急着增加赋值语句,从最小例子里分清“创建新数据”和“更新已有数据”。

先把两处 score 圈出来,并标记是哪一处 local 创建的。

PICO-8 / Lua
cls()
local score = 5
if true then
    local score = 9
    print(score)
end
print(score)

预期输出:

运行结果
9
5

外层 local 创建 score=5。进入 if 后,里面的 local 又创建一份同名数据 score=9;在这个块里,较近的名字遮住外层名字。离开块后,内层名字不可见,外层仍是 5。这种同名遮住的现象叫遮蔽,不是把外层先改成 9 再自动恢复。

在纸上给它们标成“外层score”和“内层score”,这只是笔记标签,不要改成带中文的代码名字。每次遇到print,就连线到它实际读取的那次local声明;即使拼写相同,也不能直接当作同一份数据。

作用域回答“代码的哪些位置能通过这个名字访问这份数据”。if 的分支、循环体、函数体都能形成局部范围。缩进帮助阅读,真正的边界由语法结构确定。

本章用同名示例帮助观察;平时给不同角色起不同名字更易读,例如 base_score 与 bonus_score。

9.2 去掉 local,含义就改变#

保持其它地方不变,只看内层声明改成普通赋值后的差别。

PICO-8 / Lua
cls()
local score = 5
if true then
    score = 9
end
print(score)

预期输出:

运行结果
9

这次没有创建内层 score,赋值找到能访问到的外层 score,把它改成 9。local score=9 是新建局部名字,score=9 是给当前能找到的那个名字赋值,不能机械地认为每次更新都应加 local。

若一路找不到已有的局部名字,普通赋值可能创建或修改全局名字。写错字母有时不会立刻报错,反而悄悄产生另一个变量。例如想更新 score 却写 scroe=9,原 score 不会变。教程中以 local 创建自己的数据,并保持拼写一致。

不要把这一节总结成“删掉local就能修好”。先确定你要更新的外层变量确实已经创建且可见,再写普通赋值;如果本来是独立的临时计算,仍应创建自己的局部变量。

选读:local x = x + 2 的右侧会先在原先可见的范围中寻找 x,新建的局部 x 还没有在自己的初始化表达式中生效。若外层 x=5,这句会创建内层 x=7,外层仍为 5。不是先创建一个没有值的 x 再加 2。日常代码优先使用不同名字减少混淆。

9.3 参数属于本次调用#

先预测:函数收到一个数值参数后,给参数重新赋值,是否会直接改变调用者的数值变量?想好答案再继续往下读,用后面的解释核对。

PICO-8 / Lua
cls()
local function add_bonus(score)
    score = score + 3
    return score
end
local score = 5
local result = add_bonus(score)
print(result)
print(score)
score = add_bonus(score)
print(score)

预期输出:

运行结果
8
5
8

调用时,外面的 5 被作为数值传给本次参数 score。函数里修改的是这个参数,返回 8。第一次把 8 存到 result,外面的 score 仍是 5;第二次明确写 score=add_bonus(score),外面的 score 才保存新结果。

参数可以理解为本次调用拥有的局部名字;本章的数值重新赋值不会自动改写调用处变量。不要据此推断所有数据都是深复制:以后把表交给函数时,会讨论访问同一张表的情况。

这也是函数接口的价值:通过参数看输入,通过 return 看结果,调用者决定是否接受这次变化。

按两次调用分别记录:

执行语句返回值交给谁执行后外层score
local result = add_bonus(score)新变量result5
score = add_bonus(score)已有变量score8

先在纸上把奖励3改成4重新推演,应依次显示9、5、9。若第二项也变了,就检查是否额外改动了外层score。不要为了让分数变化,把所有局部变量都改成全局变量。

9.4 函数可以看见先前的外层局部数据#

并非函数只能看自己的参数。定义所在范围中,已经声明且能看见的局部名字也能被访问。

PICO-8 / Lua
cls()
local bonus = 2
local function reward(base)
    return base + bonus
end
print(reward(3))
bonus = 4
print(reward(3))

预期输出:

运行结果
5
7

两次传入的 base 都是 3,但第二次外层 bonus 已改成 4,所以结果改变。函数使用的是那份可访问的外层变量,并不是定义时永远记住数值 2。

这和上一节不冲突:上一节更新函数自己的参数,这一节读取定义处能访问的外层bonus。判断时先找名字来自哪个声明,再问它的值有没有被修改;不能仅凭“在函数里面”就认为所有数据都是独立的。

这种写法有时有用,但输入不全在括号里,读者容易漏看依赖。若希望结果只由调用时明确给的数据决定,可以定义 reward(base,bonus),然后调用 reward(3,2)。不是每个外层变量都必须改成参数;关键是你能解释数据来自哪里、谁负责修改。

声明位置也有影响。局部名字从声明之后才进入可见范围;后面才声明的 local 不会倒过来改变前面函数里名字的含义。因此先放需要共享的数据,再放使用它的函数;后定义的局部辅助函数也不能靠猜测获得。更复杂的前向声明与递归暂不讲。

9.5 循环计数与每轮临时值#

两个同名 i 用来观察循环计数变量的范围。正文只为解释作用域,实际项目通常换一个清楚的名字。

PICO-8 / Lua
cls()
local i = 99
local total = 0
for i = 1, 3 do
    local doubled = i * 2
    total = total + doubled
end
print(i)
print(total)

预期输出:

运行结果
99
12

for 的 i 属于循环,取 1、2、3,遮住外层 i=99;结束后外面的 i 仍为 99。doubled 每轮为 2、4、6,是只在这一轮需要的临时值。total 放在循环前,才能让所有轮次更新同一个累计结果。

不要在循环外直接依赖 doubled 或循环内部创建的其它名字。若想保留最后一个结果,应先在循环外创建 last,再在循环内更新它。想区分“没执行”和“结果恰好是零”,可以用之前学过的 nil 作未产生结果的标记。

9.6 一次计算交回两个结果#

购买后既想知道余额,也想知道是否成功。与其偷偷修改外面的变量,不如明确返回两项结果。

不能把“余额为0”当作购买失败:刚好够钱时,买成功后余额也是0。因此需要两个含义不同的结果:第一项记录余额,第二项明确记录是否买成。

PICO-8 / Lua
cls()
local function buy(coins, price)
    if coins < price then
        return coins, false
    end
    return coins - price, true
end
local balance, bought = buy(8, 3)
print(balance)
print(bought)
local other, ok = buy(2, 3)
print(other)
print(ok)

预期输出:

运行结果
5
true
2
false

return 后用逗号分开两个结果;调用处用两个变量按位置接收:第一个是余额,第二个是成功标记。成功标记回答“有没有买成”,余额回答“还剩多少”,不要反过来。

local balance,bought=... 一次创建两个局部名字。若之前已经创建变量,更新时用 balance,bought=...,不要再次加 local 意外遮蔽。每一条返回路径都应遵守相同顺序和含义。

多返回值不是一张表,也不是把两个值连接成字符串。先接收,再分别 print;不要写 print(buy(...)) 期待显示两行,因为 print 的其它位置参数有自己的含义。

9.7 接收数量与结果数量#

先把规则限制在最清楚的形式:赋值右侧只写一次函数调用。

PICO-8 / Lua
cls()
local function pair()
    return 4, 7
end
local first = pair()
local a, b, c = pair()
print(first)
print(a)
print(b)
print(c == nil)

预期输出:

运行结果
4
4
7
true

只接收一个变量时保留第一个结果,其余舍弃;接收三个而只有两个结果时,第三个补 nil。这里显示 c==nil,让缺失情况明确可见。

选读:表达式中的位置也会影响结果展开。如果写 local a,b=pair(),9,pair() 不在列表末尾,只贡献第一个结果,于是 a=4、b=9。若写 local a,b=(pair()),额外一层括号把调用调整成一个结果,a=4、b=nil。先掌握上一节的直接接收,多数当前练习都不需要混合表达式。

返回数量和调用规则要明确约定,不能把缺少第二项默认为 false。nil 和 false 虽然在条件里都算假,但表达的接口约定不同。

9.8 把购买报告拆成可单独检查的部分#

最后用一个计算函数和一个显示函数组织完整报告。金币只在主流程中保存,计算不偷偷修改它。

先把完整任务拆好,再往下看实现:

部分接收什么完成什么
buy当前金币、价格返回新余额、成功标记
主流程buy返回的两个结果把新余额保存回coins,保留成功标记
show_result已保存的余额、成功标记显示提示与余额

先单独检查buy返回的两项,再看show_result,最后接上主流程。不要一次修改三个部分后才观察最终画面。

PICO-8 / Lua
cls()
local function buy(coins, price)
    if coins < price then
        return coins, false
    end
    return coins - price, true
end
local function show_result(balance, bought)
    if bought then
        print("bought")
    else
        print("wait")
    end
    print(balance)
end
local coins = 8
local new_coins, bought = buy(coins, 3)
coins = new_coins
show_result(coins, bought)

预期输出:

运行结果
bought
5

先对 buy 的三类输入单独推演:不足 (2,3) 得 2,false;相等 (3,3) 得 0,true;足够 (8,3) 得 5,true。再检查 show_result 是否用标记选择文字。主流程负责把新余额保存回 coins。

分解程序时为每个函数写一句说明:“收到什么,返回什么,是否显示或修改外层数据”。先让这些接口清楚,再考虑缩短代码。若函数结果取决于未知的外层变量,或计算与显示混在一起,排错时就需要同时检查更多地方。

现在在纸上把主流程的初始coins依次改成2、3、8各推演一遍,应分别显示wait/2、bought/0、bought/5。若提示正确但金币没保存,检查coins = new_coins;若余额和标记混在一起,检查接收顺序。

9.9 数据不对时沿着名字查#

按这个顺序追踪,不要到处增删local碰运气:

  1. 找到结果错误的那次输出,看它读取哪个名字。
  2. 向前找这个名字对应的参数或声明,注意内层是否有同名local。
  3. 找最近一次赋值,看右边读取了什么数据。
  4. 如果右边是函数调用,检查参数、返回顺序和调用者接收位置。

第3章教你记录“值怎么变化”,这一章还要记录“哪一份数据在变化”。现在只需掌握这些数值例子;表的数据共享行为将在第10~11章继续展开。

下一节预告:下一章进入表,学习如何用表管理一组数据。届时会继续展开表的共享修改行为,以及把表交给函数时访问同一张表的情况。

第 10 章:用表管理一组数据#

只有三个分数时,可以写 score1、score2、score3。换成二十个分数后,逐个起名、逐个相加就很难维护。现在把同类数据按顺序放进一张表,让一个索引和一个循环访问它们。

本章学习数组式表:建立、读写、长度、遍历、查找、增删,以及把表传给统计函数。我们只管理数字和字符串组成的连续列表;带名字的字段、嵌套对象与完整的引用关系安排在第 11 章。

阅读约定:本章每个正文示例都是完整程序,输出框从上到下对应屏幕文字。先预测:每个示例动手分析前先想好输出会是什么,再读代码和解释核对。每次只研究一个示例,不把本章代码拼在一起。

10.1 先把三个值放在一起#

花括号创建 table(表),其中的逗号分隔不同条目。下面这张表按 1、2、3 给条目编号。

先完成一份成绩清单:保存三个关卡的分数,读出各项,再报告一共有几项。先在纸上写两行,上面写位置1、2、3,下面写分数3、7、5。位置和分数不相同,之后循环要用位置找到分数。

PICO-8 / Lua
cls()
local scores = {3, 7, 5}
print(scores[1])
print(scores[2])
print(scores[3])
print(#scores)

预期输出:

运行结果
3
7
5
3

scores[1] 表示第 1 个位置中的值 3,scores[2] 表示第 2 个位置中的值 7。方括号内的位置编号叫索引。索引回答“第几个”,值回答“里面放什么”,两者不是一回事。

#scores 得到本章这种连续数组式表的长度,这里是 3。空表写作 {},长度为 0。表不是打印出来的一段文字;print(scores) 不会自动逐行列出内部内容。

Lua 的表也能使用其它形式的键,但本章只使用从 1 开始、不含 nil 空洞的连续整数位置。这个限制尤其重要:本地手册明确把 # 和 PICO-8 数组操作限定在这种用法上。不要把这里的长度规则套到任意表。

10.2 索引可以来自变量和算式#

列表中某一项升级后,只修改那个位置。先预测:第二次读取会看到什么?想好答案再继续往下读,用后面的解释核对。

PICO-8 / Lua
cls()
local scores = {3, 7, 5}
local index = 2
scores[index] = scores[index] + 1
print(scores[index])
print(scores[1])
print(#scores)

预期输出:

运行结果
8
3
3

先用 index=2 找到旧值 7,加 1 后写回第 2 个位置。第 1 项仍是 3;没有增删条目,所以长度仍是 3。

local index=2 是创建变量,scores[index]=... 是更新表中的位置,不要写 local scores[index]。方括号里可以是已算出的数字,例如 scores[index+1];先算索引,再访问相应位置。

本章有效索引是 1 到 #scores。读取不存在的位置会得到 nil,不能直接拿它参与数值加法。若 index 来自变化的数据,先用 index>=1 and index<=#scores 检查;这里另外约定它是整数。

在纸上跟踪上面的例子,分别把 index 设为 1、2、3 各推演一遍,修改后的该项应是 4、8、6,对照解释核对。不要把 index 改成 4 后仍直接执行加法;先按范围判断再访问,是后面处理角色列表也要保留的习惯。

10.3 让循环逐项访问#

上章的数值 for 现在不只产生编号,还能把编号交给表,取出每一项。

PICO-8 / Lua
cls()
local scores = {3, 7, 5}
local total = 0
for i = 1, #scores do
    print(scores[i])
    total = total + scores[i]
end
print("total")
print(total)

预期输出:

运行结果
3
7
5
total
15

i 依次是 1、2、3;scores[i] 依次是 3、7、5。如果写 total=total+i,就会加位置编号,得到 6,而不是分数总和 15。

把表改成 {4,2},不用改循环头,程序会访问两项,最后总和为 6。改成 {},循环一次也不执行,最终显示 total、0。想好这两种改法的推演过程,再对照解释核对。

本章的基本遍历不在循环中增删表项,让长度和索引保持稳定。数值 for 的终点进入循环时确定,不会因为表后来变长就自动纳入新项。增删放在遍历前后;确有批量删除需求时,用本章最后介绍的专门顺序。

10.4 统计、搜索和处理空表#

把遍历封装成函数,只返回总和与数量。调用者根据数量决定能否计算平均值。

把上一章的函数约定用在这里:输入是连续数值列表;返回总和、数量;不修改列表;不在函数里显示。先按这四项说明在纸上尝试编写,再看完整程序。

PICO-8 / Lua
cls()
local function summarize(values)
    local total = 0
    for i = 1, #values do
        total = total + values[i]
    end
    return total, #values
end
local scores = {2, 4, 6}
local total, count = summarize(scores)
print(total)
print(count)
if count > 0 then
    print(total / count)
else
    print("empty")
end

预期输出:

运行结果
12
3
4

函数接收整张列表,通过参数 values 逐项读取。空表时返回 0、0,调用者显示 empty,不进行除法。PICO-8 对除以零有特殊结果,但那不是我们要的“平均值”;规则上先排除空列表。

找最大值也要先确认非空,然后从 values[1] 开始比较,不要随意以 0 为最大值初值,否则全为负数时会出错。找第一个符合项时,可以在外面设 found=0,循环中找到就保存索引并 break;0 是本章“没有找到”的约定。

传表给函数时,函数可以访问同一张表。若写 values[1]=9,调用者会看到该项改变。这里的 summarize 只读取;不要把第 9 章的数值参数例子推成“传表会自动复制整个列表”。引用的详细解释在下一章。

10.5 追加与插入:先区分两种意图#

add 是 PICO-8 内置表函数,不是标准 Lua 的通用标准库函数。只传表和值会追加;第三个参数给出插入位置。

先说清需求:“得到新道具,但原来的道具也要保留。”这时才需要增加条目。如果只是把旧道具换成另一件,应修改原位置。下面分别追加一件、再插入一件。先预测:每次操作后的清单是什么样?想好答案再继续往下读,用后面的解释核对。

PICO-8 / Lua
cls()
local items = {"key", "map"}
add(items, "gem")
add(items, "boot", 2)
for i = 1, #items do
    print(items[i])
end
print(#items)

预期输出:

运行结果
key
boot
map
gem
4

先追加 gem,得到 key、map、gem。再把 boot 插入第 2 位,原第 2、3 项向后移动,得到 key、boot、map、gem。长度由 2 变 3,再变 4。

与插入不同,items[2]="boot" 只替换第 2 项,不保留原 map,也不增加长度。若只想追加,还可以用通用表赋值 items[#items+1]="gem";add 让意图更清楚。本章插入位置只用 1 到 #items+1 的有效整数。

连续表不要在第 5 项都还不存在时直接写第 8 项,这会制造空洞,超出本章长度和数组操作的约定。

10.6 按值删除与按位置删除#

del 按值找第一个相等项;deli 按索引删除。它们都是 PICO-8 内置函数,删除后把后面的项前移,保持连续。先预测两次删除后各剩下什么,再读代码和分析核对。

PICO-8 / Lua
cls()
local items = {"key", "map", "key"}
del(items, "key")
for i = 1, #items do
    print(items[i])
end
print("next")
deli(items, 1)
for i = 1, #items do
    print(items[i])
end
print(#items)

预期输出:

运行结果
map
key
next
key
1

del(items,"key") 只删除第一个 key,剩下 map、key,并没有把两个 key 都删掉。接着 deli(items,1) 删除现在第 1 项 map,最后只剩 key。

你手里知道什么应选的操作操作后的重点检查
要替换第几项表项赋值长度不变
新道具及插入位置add原项后移,长度增加
要删除的值del只删第一个匹配项
要删除的位置deli后项前移,位置重新编号

删除前记住的索引未必还指向同一件物品。每次增删后重新写一遍清单,比只检查长度更容易发现删错对象。

数字列表尤其容易混淆:对于 {10,20,30},del(values,2) 会找数值 2,不表示删除第 2 项。要删第二项应用 deli(values,2)。deli(values) 省略索引会删末项,本章只在非空时这样做。

不要用 items[2]=nil 代替中间删除,它不会自动把后面条目前移,会留下空洞。del 找不到值时列表不变;有效索引删除之后,再读取位置时一定要按新的排列理解。

10.7 只要值时可以用 all#

你已经会数值 for,可以始终继续用它。PICO-8 另有 all,让循环依次取得值,不必自己索引;这是新的一种 for 写法。

PICO-8 / Lua
cls()
local items = {"key", "map"}
for item in all(items) do
    print(item)
end

预期输出:

运行结果
key
map

读作:依次把 items 中的每个值交给 item,再执行循环体。这里 item 是 key、map,不是 1、2,所以不能再把它误当数字索引。

for item in all(items) do ... end 使用迭代器。当前只需掌握这个固定完整结构,不必实现自己的迭代器。all 按当前数组索引从小到大给出值;需要显示“第几个”、修改某个位置时,数值 for 更直接。

本章不给普通正序循环加入删除。虽然本地手册有 all 配合 del 的数组例子,当前先掌握一种容易手算、不跳项的批量删除方法。pairs 的不保证顺序遍历、foreach 及更多表形式留到后续按需学习。

10.8 选读:从后往前删除#

要删除所有非正数,如果正序删除,后项会向前移动,下一轮索引却还在增加,容易跳过相邻项。从末尾往前检查可以避免这个问题。

PICO-8 / Lua
cls()
local values = {0, -1, 3, 0, 4}
for i = #values, 1, -1 do
    if values[i] <= 0 then
        deli(values, i)
    end
end
for i = 1, #values do
    print(values[i])
end

预期输出:

运行结果
3
4

先看第 5 项 4,保留;第 4 项 0,删除,原第 5 项前移,但它已经检查过。下一轮看第 3 项 3,保留,再删第 2 项 -1、第 1 项 0。每次影响的都是当前及后面的位置,尚未检查的前面位置不会变。

先在纸上画列表并划掉元素,再对照上面的解释核对。空表时起点 #values=0,负步长到 1 的循环不会执行;单项表也能按同一方法处理。

学完本章,你可以组织道具名称和分数列表。一个角色同时拥有 x、y、生命值这类不同字段,需要下一章的表结构,暂时不必把本章列表硬拼成角色系统。

下一节预告:读完本章你已经能管理连续列表;下一章将把每个角色的 x、y、生命值这类多个属性放进一张表,用表描述角色和物品。

第 11 章:用表描述角色和物品#

上一章的列表把许多同类值排在一起。现在想描述一个角色:它有名字、横坐标、纵坐标和生命值。这几项不是四个角色,而是同一个角色的不同属性。

本章学习命名字段、嵌套表、引用和对象列表。这里的“对象”只是组织在一起的一组相关数据,不要求类、元表或面向对象框架。本章代码示例仍是单次运行后报告结果的完整程序;让角色持续移动属于动手版教程的下一章。

阅读约定:本章每个正文示例都是一个完整程序,逐个阅读,不把本章代码拼在一起。读每个程序时先预测它会输出什么,想好答案再继续往下读,用后面的“预期输出”核对。预期输出文本块从上到下对应屏幕文字。

11.1 把相关数据放进同一个名字#

先建立一个角色,并读出它的名字和生命值。花括号里现在为每个值写上有意义的名字。

先决定一张角色记录需要哪些数据,而不是一开始就写移动代码:名字用于报告,x、y保存位置,hp保存生命值。上一章的清单回答“有几个”,这张记录回答“这个角色是什么状态”。

PICO-8 / Lua
cls()
local player = {name = "kai", x = 20, y = 30, hp = 3}
print(player.name)
print(player.hp)
print(player.x)
print(player.y)

预期输出:

运行结果
kai
3
20
30

name、x、y、hp 是字段名。player.hp 表示 player 表里名为 hp 的那一项,它与 player["hp"] 等价。花括号中的 hp=3 相当于为字符串键 "hp" 保存数值 3,并不是把外面的变量 hp 赋值为 3。

上一章用编号寻找条目,这里用名字寻找属性。先把四个字段的含义写在纸上:name 是名字,x、y 是位置,hp 是生命值。位置当前只是两个数,还不会自动在屏幕上画出角色。

本章命名字段使用合法标识符且避开保留字。若真的要使用 "end" 这样的字符串键,只能写方括号形式,不能写 player.end;当前练习无需这种命名。

11.2 读旧字段,写回新字段#

修改一个字段并不会顺便修改其它字段。把熟悉的变量更新写法用到表上。

PICO-8 / Lua
cls()
local player = {x = 20, y = 30, hp = 3}
player.x = player.x + 4
player.hp = player.hp - 1
print(player.x)
print(player.y)
print(player.hp)

预期输出:

运行结果
24
30
2

先读 player.x 的旧值 20,加 4,再保存回 x 字段;hp 同理。y 没有被赋值,仍是 30。不要在字段赋值前加 local:这里更新表项,不是在创建名为 player.x 的局部变量。

缺少的字段读出来是 nil,不会自动成为 0。如果错写 player.heath,却原本定义的是 health,后续加法就可能报错。先核对创建处与读取处的字段拼写。

命名字段不是连续数组,不能用 #player 数它有几个属性,也不拿 add/deli 来添加或删除 hp 字段。新增字段用 player.score=0;删除某个命名字段可以赋 nil。后一种做法与上一章禁止在数组中间制造 nil 空洞是两种场景。

11.3 用变量选择字段#

字段名本身也可以存在变量里。此时点号和方括号不再能随意替换。

PICO-8 / Lua
cls()
local player = {hp = 3, coins = 8}
local key = "hp"
print(player[key])
print(player.key == nil)
player[key] = player[key] + 1
print(player.hp)

预期输出:

运行结果
3
true
4

player[key] 会先读取变量 key,得到字符串 "hp",再访问 hp 字段。player.key 则固定访问名字为 "key" 的字段,不会替你读取变量。

当名字写死时,player.hp 易读;当要访问的名字由数据决定时,用 player[key]。这一节也说明,表并不是两种不同容器:数组的数字键、对象的字符串键,都通过键找到值。本章仍把列表和对象的用途分清,避免混用数组长度规则。

11.4 一张表里还可以放另一张表#

角色既有位置,也有物品列表。把每个部分作为一个字段,形成嵌套。

PICO-8 / Lua
cls()
local player = {
    pos = {x = 20, y = 30},
    items = {"key", "map"}
}
print(player.pos.x)
print(player.items[2])
add(player.items, "gem")
print(#player.items)

预期输出:

运行结果
20
map
3

分两步读 player.pos.x:先从 player 取得 pos 那张表,再从 pos 取得 x。player.items[2] 则先取得列表,再找第 2 项。

#player.items 可以使用,因为 items 是从 1 开始的连续列表;不是因为外面的 player 也有长度。如果 pos 不存在,不能继续读它的 x,所以先完整建立需要的结构。

读长一点的访问式时逐层停下来:player是哪张表,items是哪张列表,[2]才是该列表第二项。若显示结果不对,先确认取到的是哪一层,不要把外层角色的字段数当成背包物品数。

多行花括号只是排版:每个字段间仍用逗号隔开,每层开花括号都要有闭花括号。遇到报错时从最内层开始配对,比一次盯住整段更容易。

11.5 两个名字可能指向同一张表#

现在仔细比较表赋值和第 9 章的数值赋值。这是本章最需要逐格推演的例子。

PICO-8 / Lua
cls()
local player = {hp = 3}
local selected = player
selected.hp = 1
print(player.hp)
print(selected == player)
selected = {hp = 9}
print(player.hp)
print(selected.hp)

预期输出:

运行结果
1
true
1
9

local selected=player 没有复制整张表,而是让两个名字指向同一张表,这种联系叫引用。通过 selected 修改 hp,也就是在 player 指向的那张表上修改。

selected={hp=9} 创建新表,并让 selected 改为指向新表;player 仍指向原表,原表 hp 还是 1。区分“改表里的字段”和“让变量换一张表”,就能解释这四行输出。

对本章未设置元表的普通表,== 判断是否为同一张表,不是逐项比较内容。两个分别写出的 {hp=3} 是两张表,即使字段内容相同,也不相等。

可以画箭头帮助思考:两个名字的箭头都落在同一方框时,字段修改共享;某个名字换箭头,不会拖走其它名字。

执行后player指向selected指向
local selected=player原表,hp=3同一原表
selected.hp=1原表,hp=1同一原表
selected={hp=9}原表,hp=1新表,hp=9

再在纸上推演一步:把最后的新表 hp 改成 7,先预测结果——player.hp 仍应是 1,selected.hp 变成 7,对照上面的箭头画法和表格核对。这次改的是另一张表,不能沿用“两个名字一直共享”的印象。

11.6 函数收到表后能做什么#

这次让函数明确修改传入角色。函数名与说明都应告诉调用者存在这种修改。

PICO-8 / Lua
cls()
local function damage(target, amount)
    target.hp = target.hp - amount
    if target.hp < 0 then
        target.hp = 0
    end
end
local player = {hp = 3}
damage(player, 2)
print(player.hp)
damage(player, 5)
print(player.hp)

预期输出:

运行结果
1
0

参数 target 与 player 访问同一张表,因此不必接收返回值也能看到字段修改。数值参数重新赋值不会改调用者变量,表参数的字段赋值却会改共同访问的数据,两者并不矛盾。

本函数约定角色已有数值 hp,amount 是非负小整数,并将最低生命限制为 0。若只是写 target={hp=0},改的是参数自己的指向,调用者仍持有原表。

函数接口除了输入、返回值,还要说明是否修改传入表。只统计的函数不要顺手改生命,伤害函数则应把修改写得清楚。

把第9章和本节放在一起读:参数都是本次调用的局部名字;当参数保存的是表的引用时,字段赋值沿着引用修改共同的表。换掉参数自己保存的引用,仍不会换掉调用者的变量。不要把区别简化成“表参数的任何赋值都会影响外面”。

11.7 列表里每项都可以是角色#

外层用索引表示第几个对象,内层用字段表示对象的哪项属性。

PICO-8 / Lua
cls()
local enemies = {
    {name = "a", x = 10, hp = 2},
    {name = "b", x = 30, hp = 1}
}
for i = 1, #enemies do
    local enemy = enemies[i]
    enemy.x = enemy.x + 2
    print(enemy.name..":"..enemy.x)
end
print(enemies[1].x)

预期输出:

运行结果
a:12
b:32
12

第一次 enemies[i] 取出 a 的表,第二次取出 b 的表。临时名字 enemy 引用当前对象,所以 enemy.x 的更新会保留在列表里的对象上。

#enemies 是对象数量,不是某个对象的字段数。所有对象保持一致的字段约定,循环就能用同一段代码处理它们。若其中一个漏写 x,移动到它时会暴露问题。

创建多个独立对象时,每次都要创建新表。不要先创建一张模板,再反复 add(enemies,template),那会把同一张表放入多个位置,导致一项变化、多项都变。可以写返回新表的小函数;每次调用时花括号会创建新对象。

11.8 选读:独立副本与移除失效对象#

先用明确字段创建独立副本。对于嵌套表,仅复制外面一层仍会共享里面的表。

先明确目的:保留一份修改前的位置,用来与修改后比较。如果备份只是另一个指向原表的名字,原表变动时备份也跟着变,便失去这个作用。这里按已知结构创建两层新表,不要求编写通用复制函数。

PICO-8 / Lua
cls()
local original = {pos = {x = 10, y = 20}, hp = 3}
local copy = {
    pos = {x = original.pos.x, y = original.pos.y},
    hp = original.hp
}
copy.pos.x = 99
copy.hp = 1
print(original.pos.x)
print(original.hp)
print(copy.pos.x)

预期输出:

运行结果
10
3
99

副本外层有新花括号,pos 也有新花括号,因此两个位置表独立。若写 pos=original.pos,就仍然共享内层位置,这通常称作浅层复制的共享问题。当前只按已知结构明确复制,不引入通用深复制工具。

移除生命值为零的对象,可以沿用第 10 章从后往前的 deli:检查 enemies[i].hp,再删除索引 i。删除列表项只是移除这个位置,并不神奇地让其它保存过该对象的变量消失。

对象并不会因为叫 player 就自动获得游戏行为。我们只是把数据组织好了;后续动手章节的更新和绘制函数才会持续使用这些数据。

把伤害与清理分两步检查#

先对列表里的每个对象更新hp,再反向删除hp<=0的项。以生命1、3、2,各受2点伤害为例:第一步结果应是0、1、0;第二步只保留中间那个对象,生命为1。先核对伤害结果,再核对删除结果,不要把两种错误混在一起。

如果需要保存处理前的状态,应在伤害之前创建独立副本;如果只是想选中某个角色并修改它,则共享引用正是需要的行为。决定是否复制,要从任务要求出发。

全书小结#

读到这里,本书已经覆盖了变量、条件、循环、函数、作用域与表这些语言基础。原教程中需要上机的部分——让角色动起来、游戏目标、推箱子项目、素材工具——请回到动手版教程继续。