游戏输入实践

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

通过阅读本主题,你将了解:

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

选择输入设备类

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

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

另一方面,对于复杂的飞行和赛车模拟,你可能希望枚举所有 RawGameController 对象作为基线,以确保它们支持爱好者玩家可能拥有的任何特殊设备,包括单独的踏板或油门这样的设备,这些通常仍由单个玩家使用。

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

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

跟踪连接的控制器

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

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

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

另一个要考虑的事情是,连接的控制器列表最初将为空,需要一两秒钟才能填充。 因此,如果仅在开始方法中分配当前游戏手柄,它将是 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).
}

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

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%时,请发出警告。

另请参阅