输入系统

从表面上看,游戏和视频的最大区别就是游戏是交互性的,而输入系统就是交互的基础。MonoGame的输入API延续了XNA的朴素风格,和WPF等传统GUI框架中有封装好的事件机制不同,MonoGame里我们需要主动轮询设备状态,得到键盘、鼠标、手柄等输入设备的状态快照,然后自行判断玩家的输入。

键盘输入

获取键盘输入状态

键盘输入通过静态类Keyboard的GetState方法获取,它返回一个KeyboardState类型状态快照,其中我们可以通过IsKeyDown、IsKeyDown方法判断键盘按键是否出于按下或弹起状态。

在第一章我们使用MonoGame项目模板创建项目后,其实Game1.cs中就已经有下面这样一段代码了,它的功能是检测到键盘ESC键或手柄View键(这里以Xbox Series手柄为例)时,调用基类Game的Exit方法退出程序。

protected override void Update(GameTime gameTime)
{
    if (GamePad.GetState(PlayerIndex.One).Buttons.Back == ButtonState.Pressed || Keyboard.GetState().IsKeyDown(Keys.Escape))
    {
        Exit();
    }
    base.Update(gameTime);
}

关于手柄我们后面再说,这里主要看键盘输入。代码中我们可以看到,键盘的按键被封装为了Keys枚举类型,我们可以查看其中具体的按键定义,绝大多数通用键盘上能找到的按键都已经在其中定义了,实际使用时,我们用到哪个按键就现去Keys中查看即可。

组合键判断

对于组合键也是类似的,我们判断多个按键是否都被按下就行了。下面例子中我们判断Ctrl + C是否被按下,如果按下则退出程序,不过由于键盘上的Ctrl键有两个,因此我们总共要检测的其实是3个按键,即(左Ctrl or 右Ctrl) and C。

protected override void Update(GameTime gameTime)
{
    KeyboardState keyboardState = Keyboard.GetState();

    bool isCtrlPressed = keyboardState.IsKeyDown(Keys.LeftControl) || keyboardState.IsKeyDown(Keys.RightControl);
    bool isCPressed = keyboardState.IsKeyDown(Keys.C);

    if (isCtrlPressed && isCPressed)
    {
        Exit();
    }
    base.Update(gameTime);
}

这里要注意一个最佳实践,我们将keyboardState提取为了一个局部变量,而非每次用到时都重新写Keyboard.GetState()。这样做有两个原因,一是出于性能考虑的防御性编程,Keyboard.GetState()返回的是状态快照,将其提取出来可以避免每次Keyboard.GetState()时底层可能存在的拷贝数据开销;二是保证状态的一致性,虽然一次Update内多次调用Keyboard.GetState()理论上得到的状态结果是一致的,但许多地方都有类似的逻辑,我们不想赌所有地方都不会出问题,编写游戏逻辑时,将状态先统一提取出来然后再执行其它逻辑是一种最佳实践,这样做能够确定性保证这一帧内所有输入判断都基于同一份状态快照,避免不同子系统拿到略有差异的状态。

边沿状态检测

对于键盘输入还有一个问题,KeyboardState解决的是获取按键是否处于弹起、按下状态的问题,但实际开发中我们可能需要按下按键触发一次,而非一直触发某个代码分支,这属于状态的边沿检测问题,MonoGame中没有提供直接的API,但我们可以自己实现。

下面例子代码中,我们判断的不是ESC处于按下状态,而是ESC键在上一帧是弹起状态但这一帧是按下状态,这样其内部逻辑就会在再次弹起前只触发一次。这套「当前帧 vs 上一帧」的对比模式也是轮询式输入的核心技巧,键盘、鼠标、手柄都是通用的。

using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using Microsoft.Xna.Framework.Input;

namespace Gacfox.Demo.DemoGame;

public class Game1 : Game
{
    private GraphicsDeviceManager _graphics;
    private SpriteBatch _spriteBatch;

    private KeyboardState? _prevKeyboardState;

    public Game1()
    {
        _graphics = new GraphicsDeviceManager(this);
        Content.RootDirectory = "Content";
        IsMouseVisible = true;
    }

    protected override void Initialize()
    {
        base.Initialize();
    }

    protected override void LoadContent()
    {
        _spriteBatch = new SpriteBatch(GraphicsDevice);
    }

    protected override void Update(GameTime gameTime)
    {
        KeyboardState currentKeyboardState = Keyboard.GetState();
        if (currentKeyboardState.IsKeyDown(Keys.Escape) && _prevKeyboardState?.IsKeyUp(Keys.Escape) == true)
        {
            Exit();
        }
        _prevKeyboardState = currentKeyboardState;
        base.Update(gameTime);
    }

    protected override void Draw(GameTime gameTime)
    {
        GraphicsDevice.Clear(Color.CornflowerBlue);
        base.Draw(gameTime);
    }
}

文本输入

文本输入(例如聊天框打字)不能用轮询Keyboard.GetState()实现,这样做难以拿到输入法组合、大小写、符号的实际字符,对于文本输入需要使用GameWindow.TextInput事件。下面例子中,我们简单实现了一个输入文字渲染到界面的功能,字体渲染方案使用了前面章节介绍过的FontStashSharp。

using FontStashSharp;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using Microsoft.Xna.Framework.Input;
using System.IO;

namespace Gacfox.Demo.DemoGame;

public class Game1 : Game
{
    private GraphicsDeviceManager _graphics;
    private SpriteBatch _spriteBatch;
    private FontSystem _fontSystem;
    private SpriteFontBase _font32;

    private string _inputText = "";

    public Game1()
    {
        _graphics = new GraphicsDeviceManager(this);
        Content.RootDirectory = "Content";
        IsMouseVisible = true;
    }

    protected override void Initialize()
    {
        // 注册TextInput事件处理程序
        Window.TextInput += (s, e) =>
        {
            if (e.Character == '\b' && _inputText.Length > 0)
            {
                // 退格键处理逻辑
                _inputText = _inputText[..^1];
            }
            else
            {
                _inputText += e.Character;
            }
        };
        base.Initialize();
    }

    protected override void LoadContent()
    {
        _spriteBatch = new SpriteBatch(GraphicsDevice);

        _fontSystem = new FontSystem();
        _fontSystem.AddFont(File.ReadAllBytes(Path.Combine(Content.RootDirectory, "Fonts", "simhei.ttf")));

        _font32 = _fontSystem.GetFont(32);
    }

    protected override void Update(GameTime gameTime)
    {
        if (GamePad.GetState(PlayerIndex.One).Buttons.Back == ButtonState.Pressed || Keyboard.GetState().IsKeyDown(Keys.Escape))
        {
            Exit();
        }
        base.Update(gameTime);
    }

    protected override void Draw(GameTime gameTime)
    {
        GraphicsDevice.Clear(Color.CornflowerBlue);

        _spriteBatch.Begin();
        _spriteBatch.DrawString(_font32, _inputText, new Vector2(100, 160), Color.White);
        _spriteBatch.End();

        base.Draw(gameTime);
    }
}

注意:这个输入Demo编写的其实非常简陋,基本不能用于实际开发中,它最明显的缺陷就是无法输入中文(假设我们使用的是最新版本微软拼音输入法)。中文输入是个比中文字体渲染还大的坑,因为中文输入涉及输入法,而Windows操作系统中又有IMM32和TSF两套和输入法相关的接口,其中充满了历史遗留问题,有关这部分内容的详细讨论可以参考Windows编程相关章节,这里就不展开介绍了。

鼠标输入

鼠标状态需要轮询Mouse.GetState()获取,它返回MouseState类型的鼠标状态快照。

MouseState mouse = Mouse.GetState();

Point position = mouse.Position;                            // 鼠标位置,采用窗口客户区像素坐标
bool leftDown = mouse.LeftButton == ButtonState.Pressed;    // 左键状态
bool rightDown = mouse.RightButton == ButtonState.Pressed;  // 右键状态
int scroll = mouse.ScrollWheelValue;                        // 滚轮累计值(每格120)

如果需要边沿检测,和之前的例子写法类似,这里就不赘述了。此外鼠标的滚轮比较特殊,ScrollWheelValue是一个累计状态值,每次滚动会增加或减少120的倍数,直接读取这个值是没有意义的,判断本帧是否滚动还是要记录_prevScrollWheelValue,取前后帧差值。

手柄输入

手柄输入使用GamePad.GetState()获取GamePadState类型的状态快照,对Xbox Series / One手柄的主要基础按键MonoGame都有内置支持。至于精英手柄的额外按键与背板拨片,它们默认并不是独立的新按键,而是通过微软官方的Xbox Accessories应用重映射为现有按键,如果你希望在游戏中把背片作为完全独立于基础按键的新增输入,需要自己使用原生API实现。

GamePadState pad = GamePad.GetState(PlayerIndex.One);

if (!pad.IsConnected) return;   // 手柄未连接

// 按键状态
bool aPressed = pad.Buttons.A == ButtonState.Pressed;
bool startPressed = pad.Buttons.Start == ButtonState.Pressed;

// 摇杆状态:-1~1的Vector2
Vector2 leftStick = pad.ThumbSticks.Left;

// 扳机状态:0~1的浮点值
float rightTrigger = pad.Triggers.Right;

MonoGame支持最多4只手柄,使用PlayerIndex.One到PlayerIndex.Four表示,GetState还有一个可选的GamePadDeadZone参数控制摇杆死区处理,默认值是IndependentAxes(各轴独立应用圆形死区),一般保持默认即可。

摇杆值的ThumbSticks.Left返回的向量长度天然代表了推动幅度,直接当作移动输入使用可以获得轻推慢走、推满快跑的线性手感。

Vector2 moveInput = pad.ThumbSticks.Left;
position += new Vector2(moveInput.X, -moveInput.Y) * speed * dt;

注意:摇杆的Y轴向上为正,和屏幕坐标Y轴向下正好相反,乘到位移上时别忘了取反。

MonoGame.Extended的输入封装

MonoGame.Extended是一个针对MonoGame的开源工具扩展库,它虽然不是MonoGame框架的核心官方组件,但在MonoGame官方的生态版图中,它被视为极其关键的“三方标准补充库”。除了输入模块之外,它还提供了相机、Tiled地图、精灵动画、碰撞、粒子、视口适配(ViewportAdapter)等一系列游戏开发中高频使用的功能,并且各模块按需取用,不会强制引入整套框架式的开发范式。

工程中,我们可以使用Visual Studio的包管理器图形界面或dotnet命令安装MonoGame.Extended库。

dotnet add package MonoGame.Extended

MonoGame.Extended的Input模块对原版中的底层API进行了封装,直接提供了边沿检测等能力,在实际开发中非常实用。它提供了两套不同风格的API:

轮询式:KeyboardExtended和MouseExtended是对原版轮询API的增强,内部自动维护上一帧状态,直接支持边沿检测。

事件驱动式:KeyboardListener、MouseListener和GamePadListener以事件回调方式处理输入,适合UI绑定、全局快捷键等场景。

KeyboardExtended

下面例子代码我们重写了之前的一个示例,按ESC触发退出逻辑。

using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using Microsoft.Xna.Framework.Input;
using MonoGame.Extended.Input;

namespace Gacfox.Demo.DemoGame;

public class Game1 : Game
{
    private GraphicsDeviceManager _graphics;
    private SpriteBatch _spriteBatch;

    public Game1()
    {
        _graphics = new GraphicsDeviceManager(this);
        Content.RootDirectory = "Content";
        IsMouseVisible = true;
    }

    protected override void Initialize()
    {
        base.Initialize();
    }

    protected override void LoadContent()
    {
        _spriteBatch = new SpriteBatch(GraphicsDevice);
    }

    protected override void Update(GameTime gameTime)
    {
        // 必须每帧调用更新状态
        KeyboardExtended.Update();

        KeyboardStateExtended keyboardState = KeyboardExtended.GetState();
        if (keyboardState.WasKeyPressed(Keys.Escape))
        {
            Exit();
        }

        base.Update(gameTime);
    }

    protected override void Draw(GameTime gameTime)
    {
        GraphicsDevice.Clear(Color.CornflowerBlue);
        base.Draw(gameTime);
    }
}

KeyboardExtended是一个静态类,我们必须每帧调用KeyboardExtended.Update()以更新其内部状态,KeyboardExtended.GetState()可以获取状态快照,状态是KeyboardStateExtended类型,它的WasKeyPressed可以直接进行边沿状态判断,这里if (keyboardState.WasKeyPressed(Keys.Escape))即使持续按下ESC也只触发一次。

KeyboardStateExtended的常用API如下。

成员 说明
IsKeyDown(Keys) / IsKeyUp(Keys) 判断按键当前是否按下或弹起,与原版API语义一致
WasKeyPressed(Keys) 边沿检测,上一帧弹起、当前帧按下(按下瞬间触发一次)
WasKeyReleased(Keys) 边沿检测,上一帧按下、当前帧弹起(松开瞬间触发一次)
WasAnyKeyJustDown() 当前帧是否有任意按键刚被按下
IsShiftDown() / IsControlDown() / IsAltDown() 组合键辅助判断,内部已处理左右两侧按键
GetPressedKeys() 获取当前所有按下的按键数组
CapsLock / NumLock 大写锁定、数字键盘锁定状态

MouseExtended

对于鼠标也是类似的,MonoGame.Extended提供了恰当的封装。代码中,我们需要每帧更新鼠标输入管理器的状态。

MouseExtended.Update();

然后就可以获取并使用鼠标状态快照了。

MouseStateExtended mouseState = MouseExtended.GetState();

此外,MouseStateExtended比较实用的能力是直接提供DeltaPosition和DeltaScrollWheelValue,原版API中需要我们手动记录上一帧再求差值的逻辑就被封装掉了。

MouseStateExtended的常用API如下。

成员 说明
Position / X / Y 鼠标当前位置
PositionChanged 本帧位置是否发生变化
DeltaPosition / DeltaX / DeltaY 与上一帧相比的位置偏移量
ScrollWheelValue 滚轮累计值(与原版一致,每格120)
DeltaScrollWheelValue 与上一帧相比的滚轮差值,判断本帧是否滚动直接用它
LeftButton / MiddleButton / RightButton 等 各按键的ButtonState状态
IsButtonDown(MouseButton) / IsButtonUp(MouseButton) 判断按键当前是否按下或弹起
WasButtonPressed(MouseButton) 边沿检测:按键按下瞬间触发一次
WasButtonReleased(MouseButton) 边沿检测:按键松开瞬间触发一次

InputListener

InputListener适用于需要用观察者和事件模式来处理输入的场景(例如UI绑定、全局快捷键、解耦的按键配置等)。我们将监听器注册到InputListenerComponent并添加到游戏组件集合Components中,之后每帧的状态轮询由Game框架自动驱动组件完成,无需在Update中手动调用,输入事件会直接回调我们订阅的处理程序。

using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using Microsoft.Xna.Framework.Input;
using MonoGame.Extended.Input.InputListeners;

namespace Gacfox.Demo.DemoGame;

public class Game1 : Game
{
    private GraphicsDeviceManager _graphics;
    private SpriteBatch _spriteBatch;

    public Game1()
    {
        _graphics = new GraphicsDeviceManager(this);
        Content.RootDirectory = "Content";
        IsMouseVisible = true;
    }

    protected override void Initialize()
    {
        var keyboardListener = new KeyboardListener(
            new KeyboardListenerSettings { RepeatPress = false }
        );
        keyboardListener.KeyPressed += (sender, args) =>
        {
            if (args.Key == Keys.Escape)
            {
                Exit();
            }
        };
        Components.Add(new InputListenerComponent(this, keyboardListener));

        base.Initialize();
    }

    protected override void LoadContent()
    {
        _spriteBatch = new SpriteBatch(GraphicsDevice);
    }

    protected override void Update(GameTime gameTime)
    {
        base.Update(gameTime);
    }

    protected override void Draw(GameTime gameTime)
    {
        GraphicsDevice.Clear(Color.CornflowerBlue);
        base.Draw(gameTime);
    }
}

InputListenerComponent本身也是一个GameComponent,每帧由Game框架自动更新,它再去驱动注册的各个监听器进行状态轮询和事件派发。可以看到,轮询并没有消失,只是被封装进了组件化的生命周期里,我们的Update方法中不再有任何输入判断代码。另外InputListenerComponent只在游戏窗口处于激活状态时才驱动监听器,天然避免了窗口失焦时输入仍然生效的问题。

示例中我们通过KeyboardListenerSettings { RepeatPress = false }关闭了按住重复触发,注意RepeatPress的默认值是true(带800毫秒初始延迟、50毫秒重复间隔,模拟系统文本输入行为),处理游戏操作时一般需要关闭。

KeyboardListener的常用API如下。

成员 说明
KeyPressed 事件:按键按下时触发(RepeatPress为true时按住会重复触发)
KeyReleased 事件:按键松开时触发
KeyTyped 事件:按键产生实际字符时触发,可从事件参数Character获取字符,适合做文本输入
KeyboardListenerSettings.RepeatPress 设置:按住时是否重复触发,默认true
KeyboardListenerSettings.InitialDelayMilliseconds 设置:重复触发前的初始延迟,默认800
KeyboardListenerSettings.RepeatDelayMilliseconds 设置:重复触发的间隔,默认50

鼠标对应的事件监听器是MouseListener,常用事件如下。

成员 说明
MouseDown / MouseUp 事件:按键按下/松开
MouseClicked / MouseDoubleClicked 事件:单击/双击
MouseMoved 事件:鼠标移动
MouseWheelMoved 事件:滚轮滚动,可从事件参数获取滚动增量
MouseDragStart / MouseDrag / MouseDragEnd 事件:拖拽开始/拖拽中/拖拽结束
MouseListenerSettings.DragThreshold 设置:判定为拖拽的最小位移(像素),默认2
MouseListenerSettings.DoubleClickMilliseconds 设置:双击判定时间窗(毫秒),默认500

手柄则对应GamePadListener,提供ButtonDown、ButtonUp、ThumbStickMoved、TriggerMoved等事件,还支持Vibrate方法直接控制手柄震动。KeyboardListener、MouseListener、GamePadListener可以同时注册到同一个InputListenerComponent中统一管理,这些Listener的用法都是类似的,这里就不展开介绍了。

作者:Gacfox
版权声明:本网站为非盈利性质,文章如非特殊说明均为原创,版权遵循知识共享协议CC BY-NC-ND 4.0进行授权,转载必须署名,禁止用于商业目的或演绎修改后转载。