《Defold游戏开发入门》2.1 Lua 脚本编程

从 Lua 的数据模型和函数出发,掌握 Defold 脚本回调、对象状态、输入处理和消息协作的可维护写法。

2.1 Lua 脚本编程

Lua 是 Defold 中连接资源与规则的语言。它足够小,因此很适合快速试验;它也足够自由,因此没有边界的脚本会比复杂的语法更早成为问题。本章的目标不是把 Lua 变成一门需要背诵的学科,而是让你能把游戏行为写成清楚、可测试、可改动的代码。

先掌握 Lua 的数据与控制流

Lua 的基本值包括 nil、布尔值、数字、字符串、函数和 table。最重要的是 table:它既可以按序号保存列表,也可以按键保存字段。角色配置可以写成 local PLAYER = { speed = 260, jump_speed = 560 };待生成的敌人类型可以写成数组。table 很灵活,但也因此需要约定。配置表应尽量视为只读;运行时对象状态应放在 self;模块内部缓存应避免无意共享给每个对象。

Lua 中只有 falsenil 为假,数字 0 和空字符串都为真。这个规则会影响条件判断:if self.hp then 无法区分生命值为零和未设置,应该明确写成 if self.hp > 0 then。同样,andor 经常用于默认值,但它们返回操作数本身而不总是布尔值。理解这些细节可以避免很多看起来“随机”的状态错误。

函数是一等值,可以作为参数传递,也可以局部定义。为了避免污染全局命名空间,脚本里的辅助函数应使用 local function。循环则要谨慎:游戏的每一帧都会调用更新回调,任何大数组遍历、递归或临时 table 分配都会被放大。先写清晰的代码,再在 profiler 证明需要时优化;但从一开始就不要在 update() 里反复创建不变的配置数据。

Defold 脚本的生命周期

附着在游戏对象上的脚本会在合适的时机接收回调。init(self) 用于建立对象状态、缓存组件地址、注册输入焦点或发送初始化消息;final(self) 用于清理需要主动释放的外部状态;update(self, dt) 每帧运行,适合连续运动、计时和状态推进。不要假定同一集合中不同对象的 update() 顺序固定;需要顺序的协作应通过明确消息或集中协调完成。

dt 是本帧经过的秒数。连续移动必须使用它:go.set_position(go.get_position() + vmath.vector3(self.vx * dt, 0, 0))。如果省略 dt,高帧率设备会跑得更快。与物理步长相关的稳定计算可使用引擎提供的固定更新回调,但不要把所有玩法都塞进固定更新;先区分哪些行为确实需要固定时间步,哪些只需普通帧更新。

状态应属于对象实例,也就是 self。以下是一个更可维护的移动脚本骨架:

local SPEED = 260

function init(self)
    self.direction = 0
    self.velocity = vmath.vector3()
    msg.post(".", "acquire_input_focus")
end

function update(self, dt)
    self.velocity.x = self.direction * SPEED
    go.set_position(go.get_position() + self.velocity * dt)
end

function on_input(self, action_id, action)
    if action_id == hash("left") then self.direction = action.pressed and -1 or 0 end
    if action_id == hash("right") then self.direction = action.pressed and 1 or 0 end
    return true
end

真实项目还要处理同时按下左右键、失去焦点、死亡和暂停等情况,但这段代码已经展示了关键分工:输入回调只改变意图,更新回调才把意图转换为运动。这样键盘、触屏虚拟按钮和 AI 都能通过同一份状态驱动角色。

输入是动作,不是设备

Defold 的 input binding 把硬件输入映射为动作名。脚本收到的是 hash("jump")hash("pause"),而不是“第几个键盘按键”。这让同一套游戏逻辑可以同时支持键盘、手柄和触摸。建立动作名时应使用玩家意图,例如 move_leftconfirmshoot,避免用 key_amouse_1 这样的设备名称。一个动作可以绑定多个设备,界面也可以根据当前输入方式显示不同提示。

action.pressed 表示刚按下的一瞬,适合跳跃、确认和菜单切换;action.released 适合蓄力结束或开火停止;持续移动则应在按下时设置标志、松开时取消,由 update() 读取。对触摸输入还要记录位置和触点身份,不能简单把所有触摸都当成同一个按钮。UI 层优先消费点击,玩法层再处理未被界面占用的动作,可以防止玩家点击暂停按钮时角色同时发射子弹。

消息、地址与解耦

Defold 使用消息让对象协作。msg.post() 的第一个参数是地址,第二个参数是消息 id,第三个可选 table 是数据。地址可写成当前对象、当前组件、绝对路径或构造后的 URL;初学阶段应优先使用可读的显式地址,并把常用地址集中定义,避免在几十个地方散落字符串。消息是异步传递的,发送后不要立刻假设接收方已经完成状态改变。

接收方在 on_message(self, message_id, message, sender) 中判断消息。例如玩家死亡时发送 player_died,游戏控制器决定暂停计时、显示结算或扣除生命。玩家不知道 UI 的节点 id,控制器也不必知道玩家如何播放死亡动画。事件名应描述事实,例如 coin_collectedlevel_finished,而不是描述某个接收者的实现细节,例如 update_score_label

消息数据要小而明确。{ value = 10, source = sender } 比传递一个不断变形的大 table 更容易维护。重要输入需要校验,例如分数不应由任意对象随意增加;在复杂项目里,可以让控制器只接受来自特定分组或工厂生成对象的事件。消息并不天然等于安全边界,但它是建立边界的好起点。

模块、数据与错误处理

当一段代码不需要访问某个游戏对象的 self 时,应考虑放入模块。例如 scripts/lib/math_util.lua 可提供距离或插值函数,scripts/lib/constants.lua 可集中枚举状态。模块通过 require 加载,应避免模块加载时直接执行依赖场景的副作用。把“纯计算”与“调用引擎 API”分开,会使核心规则更容易单独验证。

Lua 不会阻止你访问拼错的字段,因此断言和日志很有价值。开发期间可以在关键入口检查配置:assert(self.speed, "player requires speed")。对可恢复的外部数据错误,给玩家一个安全默认值并记录上下文;对不可恢复的程序错误,尽早失败比悄悄产生错误状态更好。日志应写出对象 id、状态和关键数值,而不是只打印“error”。

一个小型状态机

角色常见的 bug 来自布尔值爆炸:is_jumpingis_hurtis_deadis_paused 可以互相矛盾。更可靠的方法是使用状态机。用 self.state = "idle""running""jumping""dead" 表示互斥状态,并集中处理转移条件。状态进入时播放动画,状态更新时处理运动,状态退出时清理临时效果。状态机不必一开始就做成抽象框架;一个 set_state(self, next_state) 函数已经能消除大部分重复。

local function set_state(self, next_state)
    if self.state == next_state then return end
    self.state = next_state
    sprite.play_flipbook("#sprite", hash(next_state))
end

注意动画名和状态名不一定永远相同。项目变复杂后,可以使用映射表把规则状态转换成表现动画。保持这两个概念分离,能让受伤、无敌、装备变化等表现层扩展不破坏玩法逻辑。

调试与代码审查清单

每写完一个脚本,检查以下问题:状态是否都存于正确实例;连续运动是否乘了 dt;输入是否用动作名;消息是否有明确的发送者和接收者;每帧回调中是否创建了不必要对象;脚本禁用或场景切换后是否会留下计时器、声音或输入焦点;失败路径是否有日志。把这些问题变成固定的自查流程,能让 Lua 的灵活性成为优势,而不是隐患。

本章的练习是为玩家实现“待机、跑步、跳跃、死亡”四个状态,并用消息通知控制器死亡事件。要求:按住左右能连续移动;跳跃只在按下时触发;死亡后输入不再影响移动;控制器只打印一次结算信息。完成后再进入资源管理章节,你会更清楚每一份图像和声音应如何为脚本状态服务。

进一步练习:把逻辑写成可替换的规则

当一个功能开始出现多种变化时,把变化点做成数据或小函数,而不是复制整段回调。例如角色的速度计算可以接收当前状态、地面标记和输入方向,返回目标速度;不同角色只替换配置表中的最大速度、加速度和跳跃力。这样“普通角色、冰面角色、受伤角色”共享一条清楚的计算路径,测试也能针对函数的输入输出进行。

脚本之间的契约要像 API 一样维护。新增消息时,写明它在哪个状态可发送、字段是否可选、接收方忽略它时是否安全;修改字段时,搜索所有发送者和接收者。对高频消息尤其要小心:如果每一帧向多个对象广播位置,不仅有性能成本,也会让调试日志淹没真正事件。能由对象本地状态推导出的内容,不一定需要消息同步。

Lua 的动态特性也要求你主动保持一致性。一个 table 既可被当作数组又可被当作字典,短期方便,长期却会使迭代、序列化和调试难以预测。给数据结构写出形状说明,例如 enemies 是按实例 id 索引的表,spawn_points 是连续数组,settings 是只读键值表;在边界处校验。清晰的数据形状会让脚本在功能增长后仍有可靠地基。

每次完成一个功能,都刻意删除一条日志、一次临时分支或一份不再需要的状态,确认系统仍能解释自身。脚本的成熟不是回调越来越多,而是每一个入口、状态和消息都有可说明的理由。

这也是代码评审最有效的标准:读者是否能从名称、数据与事件关系推导出行为,而不必猜测隐藏前提。

每个脚本还应有一个可回答的问题:它在对象被创建、正常运行、收到外部事件、被销毁时分别负责什么。若回答需要引用多个不相干系统,便是继续拆分或重命名的信号。清楚的责任比技巧更能抵抗需求变化。

把这个问题写在脚本顶部的简短注释中,尤其能帮助未来维护者快速确认边界和不变量。

当函数需要超过三个不相关参数时,也应停下来检查:它也许应该接收一个明确的配置结构,或拆成由拥有者协调的两次调用。参数的形状往往暴露了脚本职责是否已经混杂。

简短、单一目的的函数也更容易以固定输入验证,降低以后重构状态机或输入层的风险。

当逻辑变复杂时,优先增加一个命名清楚的小函数,而不是继续拉长嵌套条件;读者能看懂的代码才有可靠的修改空间。

持续重构小处,能避免脚本在项目后期突然失去可读性。

可读性本身就是长期效率。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「defold」更多文章