4、Lua脚本入门:Lua语法基础、变量与表、函数定义、协程基础

好,咱们进入第四章节。前面聊了事件轮换的触发逻辑,现在得动手写脚本了。我选 Lua 作为教学语言,原因很简单——它在游戏开发里太常见了。Unity 的 xLua、ToLua,还有 Cocos2d-x,底层基本都是它。我自己最早接触 Lua 是在做一款 MMORPG 的 UI 系统时,那时候每天跟 .lua 文件打交道,说实话,一开始挺别扭的,但用顺手了就觉得真香。

4.1 Lua 语法基础:极简但够用

Lua 的设计哲学就是「小而美」。它的语法结构非常精简,你学完今天这一节,基本就能看懂大部分游戏脚本了。

先看一个完整的例子:

-- 这是单行注释
--[[
    这是多行注释
    可以写好几行
]]

-- 打印输出
print("Hello, Event System!")

-- 变量声明(默认全局)
health = 100
name = "Player"

-- 局部变量用 local
local damage = 50
local isAlive = true

嗯,这里有个坑我得提一下。Lua 的变量默认是全局的,哪怕你只是在函数里写了个 count = 1,它也会污染全局空间。我曾经在项目里遇到过因为忘记加 local 导致两个模块互相覆盖变量的 bug,查了整整一个下午。所以我的习惯是:所有变量一律加 local,除非你明确需要全局共享。

数据类型就八种:

类型 示例 说明
nil nil 空值,未赋值的变量默认是 nil
boolean true / false 布尔值,注意只有 false 和 nil 为假
number 3.14 / 100 双精度浮点数,整数也是它
string "hello" / 'world' 字符串,单引号双引号都行
table {} 表,Lua 唯一的数据结构
function function() end 函数,一等公民
userdata C 对象 宿主语言(如 C#)传过来的数据
thread 协程 独立执行流

控制流也很直白:

-- if 判断
if health <= 0 then
    print("角色死亡")
elseif health < 30 then
    print("血量危险")
else
    print("状态安全")
end

-- for 循环(数字型)
for i = 1, 5 do
    print("第 " .. i .. " 次轮询")
end

-- for 循环(泛型)
local items = {"剑", "盾", "药水"}
for index, value in ipairs(items) do
    print(index, value)
end

-- while 循环
local count = 0
while count < 3 do
    print("重试次数: " .. count)
    count = count + 1
end
小技巧: Lua 的 .. 是字符串拼接操作符。如果你在游戏里看到 "玩家" .. name .. " 造成了 " .. damage .. " 点伤害",那就是在拼字符串。我个人更推荐用 string.format(),可读性更好。

4.2 变量与表:Lua 的灵魂

表(table)是 Lua 里唯一的数据结构。数组、字典、对象、模块……全是它。说白了,Lua 就是用一张表打天下。

表的两种用法:

-- 1. 当作数组(索引从 1 开始!)
local skills = {"火球术", "冰冻", "闪电链"}
print(skills[1])  -- 输出 "火球术",不是 0

-- 2. 当作字典
local player = {
    name = "勇者",
    level = 10,
    hp = 100,
    mp = 50
}
print(player.name)   -- 输出 "勇者"
print(player["hp"])  -- 也可以这样访问

这里有个新手必踩的坑:Lua 的数组索引从 1 开始。我记得第一次用 Lua 写循环时,习惯性地从 0 开始,结果第一个元素死活取不到。你想想看,如果你在事件系统里用 events[0] 去拿第一个事件,拿到的永远是 nil,然后整个逻辑就崩了。

表的增删改查:

local eventQueue = {}

-- 添加元素
eventQueue[1] = "登录事件"
eventQueue[2] = "点击事件"
table.insert(eventQueue, "移动事件")  -- 自动追加到末尾

-- 修改
eventQueue[1] = "登出事件"

-- 删除
eventQueue[2] = nil          -- 直接赋 nil 就是删除
table.remove(eventQueue, 1)  -- 删除并自动重排

-- 遍历
for k, v in pairs(eventQueue) do
    print(k, v)
end
注意: 用 pairs 遍历时,顺序是不保证的。如果你需要按插入顺序遍历,用 ipairs 只遍历连续整数键,或者自己维护一个索引列表。我在做事件轮询系统时,就因为这个顺序问题导致事件触发顺序错乱,后来改用数组 + 独立索引才解决。

4.3 函数定义:灵活到有点任性

Lua 的函数非常灵活。它支持多返回值、可变参数,还能作为变量传递。说白了,函数在 Lua 里就是「可以执行的变量」。

基本定义方式:

-- 标准定义
local function onDamage(target, damage)
    print(target .. " 受到了 " .. damage .. " 点伤害")
end

-- 匿名函数(赋值给变量)
local onHeal = function(target, amount)
    print(target .. " 恢复了 " .. amount .. " 点生命")
end

-- 多返回值
local function getPlayerInfo()
    return "勇者", 100, 50  -- 返回名字、血量、蓝量
end

local name, hp, mp = getPlayerInfo()

可变参数:

local function logEvent(...)
    local args = {...}  -- 把可变参数收集到表里
    for i, v in ipairs(args) do
        print("参数" .. i .. ": " .. tostring(v))
    end
end

logEvent("点击", "坐标(10,20)", "左键")

我在项目中经常用多返回来传递事件数据。比如一个事件处理函数返回 isHandled, resultData,调用方根据第一个布尔值决定是否继续传递。这种模式在事件轮换系统里特别实用。

核心概念: Lua 的函数是「闭包」。这意味着函数可以捕获外部变量,即使外部变量已经离开了作用域。这在写事件回调时非常有用——你可以把上下文信息「关」在函数里。

4.4 协程基础:轻量级并发

协程(coroutine)是 Lua 最强大的特性之一。它不是真正的多线程,而是一种「可暂停/可恢复」的函数。你想想看,在游戏里,一个动画播放到一半要等用户输入,或者一个 AI 行为要分几步执行——用协程就特别自然。

协程的三种状态:

状态 说明
suspended 挂起(刚创建或刚 yield 回来)
running 运行中
dead 结束(函数执行完毕或出错)

基本用法:

-- 创建一个协程函数
local function enemyAI()
    print("AI: 开始巡逻")
    coroutine.yield()  -- 暂停,让出控制权

    print("AI: 发现玩家,进入战斗")
    coroutine.yield()

    print("AI: 战斗结束,返回巡逻")
end

-- 创建协程
local co = coroutine.create(enemyAI)

-- 启动/恢复协程
coroutine.resume(co)  -- 输出 "AI: 开始巡逻"
coroutine.resume(co)  -- 输出 "AI: 发现玩家,进入战斗"
coroutine.resume(co)  -- 输出 "AI: 战斗结束,返回巡逻"
coroutine.resume(co)  -- 什么也不输出,协程已 dead

传递数据:

local function eventProcessor()
    while true do
        local eventType, eventData = coroutine.yield()
        print("处理事件: " .. eventType .. " 数据: " .. eventData)
    end
end

local co = coroutine.create(eventProcessor)

-- 第一次 resume 启动协程,执行到 yield 暂停
coroutine.resume(co)

-- 后续 resume 传入数据
coroutine.resume(co, "点击", "按钮A")
coroutine.resume(co, "移动", "坐标(5,10)")
避坑指南: 我曾经在事件轮换系统里大量使用协程,结果发现一个问题——协程里如果发生了未捕获的错误,整个协程会直接 dead,而且不会抛出异常到外部。所以我的建议是:在协程内部一定要用 pcall 包裹可能出错的代码。否则你的事件处理会悄无声息地「消失」。

协程 vs 普通函数:

特性 普通函数 协程
执行方式 一次性执行到底 可暂停、可恢复
状态保持 不保持(局部变量销毁) 保持所有局部变量
适用场景 简单计算、一次性处理 分步 AI、动画序列、事件队列
性能开销 低 略高(需要维护协程状态)

下面这张图展示了协程在事件轮换系统中的典型工作流程:

协程在事件轮换系统中的工作流程 主循环 事件队列 协程调度器 (resume/yield) 协程A: AI行为 协程B: 动画序列 协程C: 网络请求 每个协程独立执行,通过 yield 让出控制权,主循环继续处理其他事件

嗯,协程这块内容不少,但核心就一句话:协程让你能用同步的方式写异步逻辑。在事件轮换系统里,你可以把每个事件的处理流程写成一个协程,需要等待时就 yield,主循环继续处理其他事件。等条件满足了再 resume 回来继续执行。这样代码可读性比回调嵌套好太多了。

个人经验: 我建议你在项目初期就定好协程的使用规范。比如:协程里不要做耗时计算(会阻塞主循环),协程之间不要共享可变数据(容易出竞态条件),每个协程要有超时保护(防止 yield 后永远不回来)。这些坑我都踩过,提前定好规矩能省很多事。