跳转至

按键

有一些mod会使用按键触发一些效果,这是很容易实现的。

按键的定义

点击鼠标、按下按键、移动手柄等操作,在游戏里被视作一种输入,它们都会用一个整数表示,参考 constants.lua

constants.lua
-- 81行
CONTROL_PRIMARY = 0 -- 主动作,默认绑定在鼠标左键
CONTROL_SECONDARY = 1  -- 副动作,默认绑定在鼠标右键
CONTROL_ATTACK = 2 -- 攻击,默认绑定在F
-- ...
-- 246行
KEY_A = 97 -- 按下或松开A
KEY_B = 98 -- 按下或松开B
KEY_C = 99
KEY_D = 100
KEY_E = 101
KEY_F = 102
KEY_G = 103
KEY_H = 104

注意CONTROL_开头的变量和KEY_开头的变量有一些微妙的区别。

  • KEY_F 只会在按下和松开F键的时候触发。
  • CONTROL_ATTACK 会在按下和松开攻击键的时候触发,默认F,但如果玩家在设置面板中把攻击键改成了J,那么按下J才会触发 CONTROL_ATTACK

在本章节,我们只关注KEY_开头的变量。

监听器

在 input.lua 中定义了一些函数,可以用来监听按键的按下和松开。

TheInput:AddKeyHandler(fn) -> hander:table

  • 添加一个通用按键监听器,当任意按键被按下或松开时,fn就会被调用,传入两个参数:key:int(按键值),down:boolean(是否被按下)。
  • 该函数会返回一个监听器对象。

试一试

启动饥荒,进入游戏主界面(不需要进世界),在控制台运行如下代码:

TheInput:AddKeyHandler(print)

  • 按ctrl+L打开输出面板,然后随意的敲击键盘按键,仔细观察输出面板打印的内容。
  • 长按某个按键(比如F),看看输出面板会打印什么?
  • 再次运行一遍上面的代码,看看发生了什么变化?

TheInput:AddKeyDownHandler(key:int, fn) -> handler:table

  • 添加一个特定按键的监听器,当key被按下的时候,fn就会被调用,无参数。
  • 该函数也会返回一个监听器对象。

试一试

这里简单监听一下A按键。

启动饥荒,如果已经启动过了,在控制台运行如下代码:

c_reset()
等待游戏重启后,运行如下代码:
TheInput:AddKeyDownHandler(KEY_A, function() print("A 被按下了", GetTick()) end)

  • 按下或者松开A,看看输出面板打印了什么?
  • 再次运行一遍上面的代码,看看发生了什么变化,和你的预期一致吗?

Warning

GetTick() 函数返回当前的帧时间,执行这个函数是为了区分每一次的打印,并没有其他效果。

TheInput:AddKeyUpHandler(key:int, fn) -> handler:table

  • 添加一个特定按键的监听器,当key被松开的时候,fn就会被调用,无参数。
  • 该函数还是会返回一个监听器对象。

这个函数和 TheInput:AddKeyDownHandler 非常类似,只不过在按键松开时才触发。

试一试

启动饥荒,如果已经启动过了,在控制台运行如下代码:

c_reset()
等待游戏重启后,运行如下代码:
TheInput:AddKeyUpHandler(KEY_A, function() print("A 被松开了", GetTick()) end)

  • 按下或者松开A,看看输出面板打印了什么?
  • 再次运行一遍上面的代码,看看发生了什么变化,和你的预期一致吗?

上面这些知识都不重要!

按键监听器是一个比较底层的东西,在入门级的mod开发中并不会使用它,因为,当监听器识别到A键被按下时:

  • 游戏可能在主界面或者是其他配置界面(玩家还未创建)
  • 玩家可能在聊天框打字,输入了一个A
  • 玩家可能在控制台敲指令,输入了一个A
  • 玩家可能按下A来向左移动
  • 可能还有其他骚操作...

按键监听器并不能完美的区分这些不同的情形,所以并不常用。

把上面这些暂时忘掉吧。


游戏内监听器

这里实现一个简单的功能:当按键A被按下时,当前玩家的生命值扣10

标准

一个合格的游戏内按键监听器应该符合以下标准:

  1. 当玩家加入世界时才生效,当玩家离开时自动移除。
  2. 只在进入世界后才生效,在游戏主界面无效果。
  3. 当玩家在暂停页面或设置页面操作时,不会有反应。
  4. 当玩家在聊天框或控制台等输入框中打字时,不会有反应。

为了实现1/2/3,我们将按键监听器绑定到玩家界面: screens/playerhud。

为了实现4,通过修改 OnRawKey() 函数排除输入框。

原理

玩家界面是显示玩家信息和一些操作的面板,它包含了右上角三围图标、时钟图标,左侧制造栏,下方物品栏,右侧背包栏,左下聊天框,右下暂停键、地图按钮,以及过热、过冷、烧伤、受伤等时机下触发的全屏特效提示。

  1. 玩家界面仅在玩家加入世界后才会被创建。
  2. 主界面上没有玩家界面,进入世界后才有。
  3. 当暂停页面或者设置页面显示的时候,玩家界面会被盖住,一切输入控制会被上方的界面阻断。

因此,完美符合标准。

接着,看一下 PlayerHud:OnRawKey() 函数的定义:

screens/playerhud.lua
function PlayerHud:OnRawKey(key, down)
    if PlayerHud._base.OnRawKey(self, key, down) then
        return true
    elseif down and self.shown and key == KEY_SEMICOLON and TheInput:IsKeyDown(KEY_SHIFT) then
        local chat_input_screen = ChatInputScreen(false)
        chat_input_screen.chat_edit:SetString(":")
        TheFrontEnd:PushScreen(chat_input_screen)
        return true
    end
end
不需要深入了解这个函数的细节,只需要注意一点:当 OnRawKey() 函数“干了什么事情”的时候,它会返回true

显然,在输入框打字也算“干了什么事情”,所以为了排除这种情况,当 OnRawKey() 函数返回true时,不触发按键监听器,否则触发。了解原理后,我们进入实战部分。

实战

设置监听器

创建一个新mod,或者在老mod中添加如下代码:

modmain.lua
1
2
3
4
5
6
7
8
9
AddClassPostConstruct("screens/playerhud", function(self)
    local old_OnRawKey = self.OnRawKey
    function self:OnRawKey(key, down, ...)
        if old_OnRawKey(self, key, down, ...) then return true end
        if key == KEY_A and down then
            print("在这里修改玩家血量")
        end
    end
end)

注意第4行,如果函数执行的返回值为真,就直接返回 true,跳过下面“修改血量”的代码。

开启mod,进入游戏后,按A键,看看有没有打印出什么东西?

Warning

mod类型是服务器mod。

如果加载mod后崩了,请在modmain的第一行添加:

GLOBAL.setmetatable(env,{__index=function(t,k) return GLOBAL.rawget(GLOBAL,k) end})

排除连击

值得注意的是,长按A键会触发多次打印,这和平时电脑键盘的输入逻辑是一致的:

  • 敲一下A键,输入一个A
  • 按住A键不松,快速输入一大堆的A

为了排除连击,可以用一个变量记录按键的状态:

modmain.lua
AddClassPostConstruct("screens/playerhud", function(self)
    local isdown = false

    local old_OnRawKey = self.OnRawKey
    function self:OnRawKey(key, down, ...)
        if old_OnRawKey(self, key, down, ...) then return true end
        if key == KEY_A then
            if isdown ~= down then
                isdown = down
                if isdown then
                    print("在这里修改玩家血量(长按不会连击)")
                end
            end
        end
    end
end)

判定修饰键

如果需要监听的键位是ctrl+A(只按A不触发),那么加一句判定即可。

modmain.lua
AddClassPostConstruct("screens/playerhud", function(self)
    local isdown = false

    local old_OnRawKey = self.OnRawKey
    function self:OnRawKey(key, down, ...)
        if old_OnRawKey(self, key, down, ...) then return true end
        if key == KEY_A then
            if isdown ~= down then
                isdown = down
                if isdown and TheInput:IsKeyDown(KEY_CTRL) then
                    print("在这里修改玩家血量(按了ctrl)")
                end
            end
        end
    end
end)

更多的判定

上面的代码有很强的扩展性,通过一些修改就可以实现更多更复杂的判定,思考一下如何实现下面的功能:

  • 按下A时扣血,松开A时加血。
  • 按下A或者B时都触发扣血。
  • 按下A时扣血,按下B时加血。
  • 按下某个按键时扣血,这个按键可以在mod配置界面修改。
  • 快速连按两下A才扣血。(提示:GetTime()函数可以获取当前游戏运行时间)

如何扣血?

这并不是本章会详细介绍的内容,以下只简单提一下思路:

  • 按下按键时发送 mod rpc。
  • 扣血使用 health:DoDelta() 函数。

完整示例

modmain.lua
GLOBAL.setmetatable(env,{__index=function(t,k) return GLOBAL.rawget(GLOBAL,k) end})

-- 键盘监听 完整示例
AddClassPostConstruct("screens/playerhud", function(self)
    local isdown = false

    local old_OnRawKey = self.OnRawKey
    function self:OnRawKey(key, down, ...)
        if old_OnRawKey(self, key, down, ...) then return true end
        if key == KEY_A then
            if isdown ~= down then
                isdown = down
                if isdown then
                    SendModRPCToServer(MOD_RPC["testmod"]["health"])
                end
            end
        end
    end
end)

AddModRPCHandler("testmod", "health", function(inst)
    inst.components.health:DoDelta(-10)
end)

注意,按下A的同时,角色也会向左移动。作为玩家,当按下A时,究竟想要向左走还是触发按键,是有歧义的。因此,在监听按键时,最好不要使用会和原版操作冲突的按键,如WASD。

另外,你可能想实现比扣血更炫酷的功能,比如释放某个技能,这些只需要修改RPC的触发函数即可,按键监听的思路是相通的。

总结

按键监听是一件非常容易的事情,可以看到最终的代码很简洁,只有20行,希望你能完全理解这些代码背后的思路,根据自己的需求修改这些代码,而不是只会copy/paste。

如果要实现非常高级的功能,你可以再回去研究一下通用的监听器,比如 TheInput:AddKeyHandler(fn)。


扩展

以下内容适合有钻研精神的开发者。

OnRawKey() 函数的细节

我们重新看一下 PlayerHud:OnRawKey() 函数的定义:

screens/playerhud.lua, 1083
function PlayerHud:OnRawKey(key, down)
    if PlayerHud._base.OnRawKey(self, key, down) then
        return true
    elseif down and self.shown and key == KEY_SEMICOLON and TheInput:IsKeyDown(KEY_SHIFT) then
        local chat_input_screen = ChatInputScreen(false)
        chat_input_screen.chat_edit:SetString(":")
        TheFrontEnd:PushScreen(chat_input_screen)
        return true
    end
end

注意,在第二行,该函数调用了其基类(._base)的 OnRawKey() 函数。一个类的基类应该查看其构造函数。往上翻源代码,在第45行可以看见:

screens/playerhud.lua, 45
1
2
3
4
5
local PlayerHud = Class(Screen, function(self)
    Screen._ctor(self, "HUD")

    self.overlayroot = self:AddChild(Widget("overlays"))
-- 其他内容...
表明 PlayerHud 的基类是 Screen。

接着去看看 Screen 类的定义位置 widgets/screen.lua,会发现里面并没有函数 OnRawKey()。所以我们接着找 Screen 类的基类 Widget,思路和上面一样。

widgets/screen.lua, 4
1
2
3
4
5
6
7
8
local Screen = Class(Widget, function(self, name)
    Widget._ctor(self, name)
    --self.focusstack = {}
    --self.focusindex = 0
    self.handlers = {}
    --self.inst:Hide()
    self.is_screen = true
end)
widgets/widget.lua, 105
1
2
3
4
5
6
function Widget:OnRawKey(key, down)
    if not self.focus then return false end
    for k,v in pairs (self.children) do
        if v.focus and v:OnRawKey(key, down) then return true end
    end
end

至此,OnRawKey() 函数的运行细节才被揭开。具体来说,当玩家按下或者松开键盘时,界面会遍历自己的子ui,当遇到任何一个成功执行的 OnRawKey() 函数时,就立刻返回true,并跳过其他的ui。注意这里有类似递归的现象。

你可能已经猜到为什么 OnRawKey() 可以排除文本输入框了,我把代码贴在下面,尝试分析一下吧。(文本输入ui被定义在 widgets/textedit)

widgets/textedit.lua, 266
function TextEdit:OnRawKey(key, down)
    if self.editing and self.prediction_widget ~= nil and self.prediction_widget:OnRawKey(key, down) then
        self.editing_enter_down = false
        return true
    end

    if TextEdit._base.OnRawKey(self, key, down) then
        self.editing_enter_down = false
        return true
    end

    if self.editing then
        if down then
            if TheInput:IsPasteKey(key) then
                self.pasting = true
                local clipboard = TheSim:GetClipboardData()
                for i = 1, #clipboard do
                    local success, overflow = self:OnTextInput(clipboard:sub(i, i))
                    if overflow then
                        break
                    end
                end
                self.pasting = false
            elseif self.allow_newline and key == KEY_ENTER and down then
                self:OnTextInput("\n")
            else
                self.inst.TextEditWidget:OnKeyDown(key)
            end
            self.editing_enter_down = key == KEY_ENTER
        elseif key == KEY_ENTER and not self.focus then
                -- this is a fail safe incase the mouse changes the focus widget while editing the text field. We could look into FrontEnd:LockFocus but some screens require focus to be soft (eg: lobbyscreen's chat)
                if self.editing_enter_down then
                    self.editing_enter_down = false
                    if not self.allow_newline then
                        self:OnProcess()
                    end
                end
                return true
        elseif key == KEY_TAB and self.nextTextEditWidget ~= nil then
            self.editing_enter_down = false
            local nextWidg = self.nextTextEditWidget
            if type(nextWidg) == "function" then
                nextWidg = nextWidg()
            end
            if nextWidg ~= nil and type(nextWidg) == "table" and nextWidg.inst.TextEditWidget ~= nil then
                self:SetEditing(false)
                nextWidg:SetEditing(true)
            end
            -- self.nextTextEditWidget:OnControl(CONTROL_ACCEPT, false)
        else
            self.editing_enter_down = false
            self.inst.TextEditWidget:OnKeyUp(key)
        end

        if self.OnTextInputted ~= nil then
            self.OnTextInputted()
        end
    end

    --gobble up unregistered valid raw keys, or we will engage debug keys!
    return not self.validrawkeys[key]
end