游戏输入实践

本主题描述了有效使用通用 Windows 平台 (UWP) 游戏中的输入设备的模式和技术。

在本主题中,你将了解如下内容:

  • 如何跟踪玩家及其当前正在使用的输入和导航设备
  • 如何检测按钮转换(按下到释放、释放到按下)
  • 如何使用单个测试检测复杂按钮排列

选择输入设备类

有许多不同类型的输入 API 可供你使用,例如 ArcadeStick、FlightStickGamepad。 如何确定要用于游戏的 API?

应选择哪个 API 为游戏提供最合适的输入。 例如,如果要制作 2D 平台游戏,可能只需使用 Gamepad 类,而不用其他类提供的额外功能。 这将限制游戏仅支持游戏板,并提供一致的界面,可在许多不同的游戏板中工作,而无需其他代码。

另一方面,对于复杂的飞行和赛车模拟,你可能希望将所有 RawGameController 对象枚举为基线,以确保它们支持爱好者玩家可能拥有的任何利基设备,包括单个玩家仍使用的单独踏板或限制等设备。

在此处,可以使用输入类的 FromGameController 方法(如 Gamepad.FromGameController)来查看每个设备是否具有更特选的视图。 例如,如果设备也是 游戏板,则可能需要调整按钮映射 UI 以反映这一点,并提供一些合理的默认按钮映射可供选择。 (这与要求玩家仅使用 游戏板输入时手动配置游戏板输入相反RawGameController.)

或者,可以查看 RawGameController(分别使用 HardwareVendorId 和 HardwareProductId的供应商 ID(VID)和产品 ID(PID),并为常用设备提供建议的按钮映射,同时仍与将来通过玩家手动映射出现的未知设备保持兼容。

跟踪连接的控制器

虽然每个控制器类型都包含连接的控制器列表(如 Gamepad.Gamepads),但最好维护自己的控制器列表。 有关详细信息,请参阅 游戏板列表 (每个控制器类型在其自己的主题上具有类似的命名部分)。

但是,当玩家拔出控制器或插入新控制器时,会发生什么情况? 需要处理这些事件,并相应地更新列表。 有关详细信息,请参阅 “添加和删除游戏板 ”(同样,每个控制器类型在其自己的主题上都有类似的命名部分)。

由于添加和删除的事件是异步引发的,因此处理控制器列表时可能会得到不正确的结果。 因此,只要访问控制器列表,就应该将锁放在它周围,以便一次只能有一个线程访问它。 这可以通过 ppl.h> 中的<并发运行时(特别是 critical_section 类)来完成。

另一个要考虑的事情是,连接的控制器列表最初将为空,需要一两秒钟才能填充。 因此,如果仅在开始方法中分配当前游戏板,它将为 null

为了纠正这一点,你应该有一种方法来“刷新”主游戏板(在单玩家游戏中;多人游戏将需要更复杂的解决方案)。 然后,应在添加的控制器和控制器删除事件处理程序或更新方法中调用此方法。

以下方法仅返回列表中的第一个游戏板(如果列表为空),或 nullptr 。 然后,只需记住在控制器上执行任何操作时检查 nullptr 。 无论是在没有控制器连接(例如暂停游戏)还是只是继续玩游戏时,是否要阻止游戏,同时忽略输入。

#include <ppl.h>

using namespace Platform::Collections;
using namespace Windows::Gaming::Input;
using namespace concurrency;

Vector<Gamepad^>^ m_myGamepads = ref new Vector<Gamepad^>();

Gamepad^ GetFirstGamepad()
{
    Gamepad^ gamepad = nullptr;
    critical_section::scoped_lock{ m_lock };

    if (m_myGamepads->Size > 0)
    {
        gamepad = m_myGamepads->GetAt(0);
    }

    return gamepad;
}

下面举例说明如何处理游戏板的输入:

#include <algorithm>
#include <ppl.h>

using namespace Platform::Collections;
using namespace Windows::Foundation;
using namespace Windows::Gaming::Input;
using namespace concurrency;

static Vector<Gamepad^>^ m_myGamepads = ref new Vector<Gamepad^>();
static Gamepad^          m_gamepad = nullptr;
static critical_section  m_lock{};

void Start()
{
    // Register for gamepad added and removed events.
    Gamepad::GamepadAdded += ref new EventHandler<Gamepad^>(&OnGamepadAdded);
    Gamepad::GamepadRemoved += ref new EventHandler<Gamepad^>(&OnGamepadRemoved);

    // Add connected gamepads to m_myGamepads.
    for (auto gamepad : Gamepad::Gamepads)
    {
        OnGamepadAdded(nullptr, gamepad);
    }
}

void Update()
{
    // Update the current gamepad if necessary.
    if (m_gamepad == nullptr)
    {
        auto gamepad = GetFirstGamepad();

        if (m_gamepad != gamepad)
        {
            m_gamepad = gamepad;
        }
    }

    if (m_gamepad != nullptr)
    {
        // Gather gamepad reading.
    }
}

// Get the first gamepad in the list.
Gamepad^ GetFirstGamepad()
{
    Gamepad^ gamepad = nullptr;
    critical_section::scoped_lock{ m_lock };

    if (m_myGamepads->Size > 0)
    {
        gamepad = m_myGamepads->GetAt(0);
    }

    return gamepad;
}

void OnGamepadAdded(Platform::Object^ sender, Gamepad^ args)
{
    // Check if the just-added gamepad is already in m_myGamepads; if it isn't, 
    // add it.
    critical_section::scoped_lock lock{ m_lock };
    auto it = std::find(begin(m_myGamepads), end(m_myGamepads), args);

    if (it == end(m_myGamepads))
    {
        m_myGamepads->Append(args);
    }
}

void OnGamepadRemoved(Platform::Object^ sender, Gamepad^ args)
{
    // Remove the gamepad that was just disconnected from m_myGamepads.
    unsigned int indexRemoved;
    critical_section::scoped_lock lock{ m_lock };

    if (m_myGamepads->IndexOf(args, &indexRemoved))
    {
        if (m_gamepad == m_myGamepads->GetAt(indexRemoved))
        {
            m_gamepad = nullptr;
        }

        m_myGamepads->RemoveAt(indexRemoved);
    }
}

跟踪用户及其设备

所有输入设备都与 用户 相关联,以便其标识可以链接到其游戏、成就、设置更改和其他活动。 用户可以立即登录或注销,并且其他用户通常会登录在上一个用户注销后仍连接到系统的输入设备。当用户登录或注销时, 将引发 IGameController.UserChanged 事件。 可以为此事件注册事件处理程序,以跟踪玩家及其正在使用的设备。

输入设备与其相应的 UI 导航控制器也是通过用户身份这一方式关联的。

出于这些原因,应跟踪玩家输入并与设备类的用户属性(继承自 IGameController 接口)相关联。

GitHub 上的 UserGamepadPairingUWP 示例应用演示了如何跟踪用户及其使用的设备。

检测按钮切换

有时,你想知道何时首次按下或释放按钮;也就是说,当按钮状态从释放转换到按下或从按下到释放时。 若要确定这一点,需要记住以前的设备读取并将当前读数与它进行比较,以查看更改的内容。

以下示例演示了一种用于记住上一个阅读的基本方法:游戏板在此处显示,但街机摇杆、赛车方向盘和其他输入设备类型的原则相同。

Gamepad gamepad;
GamepadReading newReading();
GamepadReading oldReading();

// Called at the start of the game.
void Game::Start()
{
    gamepad = Gamepad::Gamepads[0];
}

// Game::Loop represents one iteration of a typical game loop
void Game::Loop()
{
    // move previous newReading into oldReading before getting next newReading
    oldReading = newReading, newReading = gamepad.GetCurrentReading();

    // process device readings using buttonJustPressed/buttonJustReleased (see below)
}

在执行任何其他操作之前, Game::Loop 将现有值 newReading (游戏板读取从上一循环迭代)移动到 oldReading其中,然后填充 newReading 当前迭代的全新游戏板读取。 这为你提供了检测按钮转换所需的信息。

以下示例演示了检测按钮转换的基本方法:

bool ButtonJustPressed(const GamepadButtons selection)
{
    bool newSelectionPressed = (selection == (newReading.Buttons & selection));
    bool oldSelectionPressed = (selection == (oldReading.Buttons & selection));

    return newSelectionPressed && !oldSelectionPressed;
}

bool ButtonJustReleased(GamepadButtons selection)
{
    bool newSelectionReleased =
        (GamepadButtons.None == (newReading.Buttons & selection));

    bool oldSelectionReleased =
        (GamepadButtons.None == (oldReading.Buttons & selection));

    return newSelectionReleased && !oldSelectionReleased;
}

这两个函数首先派生按钮选择newReadingoldReading的布尔状态,然后执行布尔逻辑来确定目标转换是否已发生。 仅当新读取包含目标状态(分别按下或释放)且旧读取不包含目标状态时,这些函数才返回 true;否则返回 false

检测复杂按钮排列

输入设备的每个按钮都提供一个数字读数,指示是按下(向下)还是释放(向上)。 为了提高效率,按钮读取不表示为单个布尔值;它们全部打包到由特定于设备的枚举(如 GamepadButtons) 表示的位字段。 若要读取特定按钮,使用按位掩码来隔离你感兴趣的值。 设置相应的位时,按下按钮(向下);否则,它被释放(上)。

回顾一下如何确定要按下或释放单个按钮;游戏板在此处显示,但街机摇杆、赛车方向盘和其他输入设备类型的原则相同。

GamepadReading reading = gamepad.GetCurrentReading();

// Determines whether gamepad button A is pressed.
if (GamepadButtons::A == (reading.Buttons & GamepadButtons::A))
{
    // The A button is pressed.
}

// Determines whether gamepad button A is released.
if (GamepadButtons::None == (reading.Buttons & GamepadButtons::A))
{
    // The A button is released (not pressed).
}

如你所见,确定单个按钮的状态很简单,但有时你可能需要确定:是按下还是释放多个按钮,或者是否按特定方式安排一组按钮(按下一些按钮,释放一些按钮)。 测试多个按钮比测试单个按钮更复杂,特别是可能存在混合按钮状态,但是对于这些适用于单个和多个相似的按钮测试有一个简单的公式。

以下示例确定是否同时按下游戏板按钮 A 和 B:

if ((GamepadButtons::A | GamepadButtons::B) == (reading.Buttons & (GamepadButtons::A | GamepadButtons::B))
{
    // The A and B buttons are both pressed.
}

以下示例确定游戏板按钮 A 和 B 是否均已释放:

if ((GamepadButtons::None == (reading.Buttons & GamepadButtons::A | GamepadButtons::B))
{
    // The A and B buttons are both released (not pressed).
}

以下示例确定释放按钮 B 时是否按下游戏板按钮 A:

if (GamepadButtons::A == (reading.Buttons & (GamepadButtons::A | GamepadButtons::B))
{
    // The A button is pressed and the B button is released (B is not pressed).
}

这五个示例共有的公式是,要测试的按钮排列是由相等运算符左侧的表达式指定的,而要考虑的按钮由右侧的掩码表达式选择。

以下示例通过重写前面的示例来更清楚地演示此公式:

auto buttonArrangement = GamepadButtons::A;
auto buttonSelection = (reading.Buttons & (GamepadButtons::A | GamepadButtons::B));

if (buttonArrangement == buttonSelection)
{
    // The A button is pressed and the B button is released (B is not pressed).
}

此公式可用于测试其状态的任何排列中的任意数量的按钮。

获取电池的状态

对于实现 IGameControllerBatteryInfo 接口的任何游戏控制器,可以在控制器实例上调用 TryGetBatteryReport 以获取一个 BatteryReport 对象,该对象提供有关控制器中的电池的信息。 你可以获取诸如电池充电速率(ChargeRateInMilliwatts)、新电池(DesignCapacityInMilliwattHours)的估计能量容量和当前电池(FullChargeCapacityInMilliwattHours)的完全充电能量容量等属性。

对于支持详细电池报告的游戏控制器,可以获取有关电池的详细信息,如获取电池信息的详细信息。 但是,大多数游戏控制器不支持该级别的电池报告,而是使用低成本硬件。 对于这些控制器,需要牢记以下注意事项:

  • ChargeRateInMilliwattsDesignCapacityInMilliwattHours 将始终为 NULL

  • 可以通过计算 RemainingCapacityInMilliwattHours / FullChargeCapacityInMilliwattHours 来获取电池百分比。 应忽略这些属性的值,并且只处理计算的百分比。

  • 上一项目符号点的百分比始终为下列项之一:

    • 100% (完整)
    • 70% (中)
    • 40% (低)
    • 10% (严重)

如果代码根据剩余电量的百分比执行某些操作(如绘制 UI),请确保它符合上述值。 例如,如果要在控制器的电池电量不足时警告播放器,当控制器的电池达到 10% 时,请发出警告。

另请参阅