向 Marble Maze 添加输入和交互性示例

通用 Windows 平台 (UWP) 游戏可在广泛的设备上运行,如台式计算机、笔记本电脑和平板电脑。 一台设备可能拥有过多的输入和控制机制。 本文档介绍了在使用输入设备时要记住的重要实践,并展示了 Marble Maze 如何应用这些实践。

注意

与本文档对应的示例代码位于 DirectX Marble Maze 游戏示例中。

  本文档讨论了在游戏中使用输入时的一些重要事项:

  • 如果可能,支持多种输入设备,使你的游戏能够适应更广泛的客户偏好和能力。 尽管游戏控制器和传感器的使用是可选的,但我们强烈建议使用它来增强玩家体验。 我们设计了游戏控制器和传感器 API 来帮助你更轻松地集成这些输入设备。

  • 要初始化触摸,必须注册窗口事件,例如在指针被激活、释放和移动时。 要初始化加速计,可在初始化应用时创建一个 Windows::Devices::Sensors::Accelerometer 对象。 游戏控制器不需要初始化。

  • 对于单玩家游戏,请考虑是否合并来自所有可能的控制器的输入。 这样,你无需跟踪何种输入来自哪个控制器。 或者,只需跟踪仅来自最近添加的控制器的输入,如我们在此示例中所做的。

  • 在处理输入设备之前处理 Windows 事件。

  • 游戏控制器和加速计支持轮询。 也就是说,你可在需要数据时轮询它。 对于触摸,在可供你的输入处理代码访问的数据结构中记录各种触摸事件。

  • 考虑是否将输入值规范化为一种通用格式。 这么做可简化游戏的其他组件如何解释输入内容,例如力学模拟,还可以使编写适用于不同屏幕分辨率的游戏更容易。

Marble Maze 支持的输入设备

Marble Maze 支持通过游戏控制器、鼠标和触摸来选择菜单项,并支持通过游戏控制器、鼠标、触摸和加速计来控制游戏。 Marble Maze 使用 Windows::Gaming::Input API 在控制器中轮询输入。 触摸让应用程序能够跟踪和响应指尖输入。 加速计是一种度量力量的传感器,它沿 X、Y 和 Z 轴应用。 通过使用 Windows 运行时,可轮询加速计设备的当前状态,以及通过 Windows 运行时事件处理机制接收触摸事件。

注意

本文档使用触摸表示触摸和鼠标输入,使用指针表示任何使用指针事件的设备。 因为触摸和鼠标使用标准指针事件,所以你可使用任何一种设备来选择菜单项和控制游戏。

 

注意

程序包清单将 Landscape 设置为仅支持的旋转方向,以免在旋转设备来滚动弹珠时更改方向。 若要查看程序包清单,在 Visual Studio 中打开解决方案资源管理器中的 Package.appxmanifest

 

初始化输入设备

游戏控制器不需要初始化。 要初始化触摸,必须注册窗口事件,例如指针被激活(例如,玩家按下鼠标按钮或触摸屏幕)、释放和移动时。 若要初始化加速计,必须在初始化应用程序时创建一个 Windows::Devices::Sensors::Accelerometer 对象。

下面的示例展示了 App::SetWindow 方法如何注册 Windows::UI::Core::CoreWindow::PointerPressedWindows::UI::Core::CoreWindow::PointerReleasedWindows::UI::Core::CoreWindow::PointerMoved 指针事件。 这些事件在应用程序初始化期间和游戏循环之前注册。

这些事件在调用事件处理程序的单独线程中处理。

有关应用程序如何初始化的详细信息,请参阅 Marble Maze 应用程序结构

window->PointerPressed += ref new TypedEventHandler<CoreWindow^, PointerEventArgs^>(
    this, 
    &App::OnPointerPressed);

window->PointerReleased += ref new TypedEventHandler<CoreWindow^, PointerEventArgs^>(
    this, 
    &App::OnPointerReleased);

window->PointerMoved += ref new TypedEventHandler<CoreWindow^, PointerEventArgs^>(
    this, 
    &App::OnPointerMoved);

MarbleMazeMain 类还创建了一个 std::map 对象来持有触摸事件。 这个 map 对象的键是一个唯一表示输入指针的值。 每个键映射到每个触摸点与屏幕中心之间的距离。 Marble Maze 随后使用这些值计算迷宫倾斜的程度。

typedef std::map<int, XMFLOAT2> TouchMap;
TouchMap        m_touches;

MarbleMazeMain 类还持有一个 Accelerometer 对象。

Windows::Devices::Sensors::Accelerometer^           m_accelerometer;

Accelerometer 对象在 MarbleMazeMain 构造函数中初始化,如下面的示例中所示。 Windows::Devices::Sensors::Accelerometer::GetDefault 方法返回默认加速计的一个实例。 如果没有默认加速计,Accelerometer::GetDefault 将返回 nullptr

// Returns accelerometer ref if there is one; nullptr otherwise.
m_accelerometer = Windows::Devices::Sensors::Accelerometer::GetDefault();

可以使用鼠标、触摸或游戏控制器来导航菜单,如下所示:

  • 使用方向板更改活动菜单项。
  • 使用触摸、A 按钮或“菜单”按钮挑选一个菜单项或关闭当前菜单,例如高分表。
  • 使用“菜单”按钮暂停或恢复游戏。
  • 使用鼠标单击一个菜单项可选择该操作。

跟踪游戏控制器输入

为了跟踪当前连接到设备的游戏板,MarbleMazeMain 定义成员变量 m_myGamepads,这是 Windows::Gaming::Input::Gamepad 对象的集合。 这在构造函数中初始化,如下所示:

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

for (auto gamepad : Gamepad::Gamepads)
{
    m_myGamepads->Append(gamepad);
}

而且,MarbleMazeMain 构造函数在添加或删除游戏板时注册事件:

Gamepad::GamepadAdded += 
    ref new EventHandler<Gamepad^>([=](Platform::Object^, Gamepad^ args)
{
    m_myGamepads->Append(args);
    m_currentGamepadNeedsRefresh = true;
});

Gamepad::GamepadRemoved += 
    ref new EventHandler<Gamepad ^>([=](Platform::Object^, Gamepad^ args)
{
    unsigned int indexRemoved;

    if (m_myGamepads->IndexOf(args, &indexRemoved))
    {
        m_myGamepads->RemoveAt(indexRemoved);
        m_currentGamepadNeedsRefresh = true;
    }
});

添加游戏板时,它将被添加到 m_myGamepads;删除游戏板时,我们会检查游戏板是否位于 m_myGamepads 中,如果是,则会将其删除。 在这两种情况下,我们都会将 m_currentGamepadNeedsRefresh 设置为 true,指示我们需要重新分配 m_gamepad

最后,我们将游戏板分配到 m_gamepad 并将 m_currentGamepadNeedsRefresh 设置为 false

m_gamepad = GetLastGamepad();
m_currentGamepadNeedsRefresh = false;

更新方法中,我们会检查是否需要重新分配 m_gamepad

if (m_currentGamepadNeedsRefresh)
{
    auto mostRecentGamepad = GetLastGamepad();

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

    m_currentGamepadNeedsRefresh = false;
}

如果 m_gamepad 确实需要重新分配,我们会使用 GetLastGamepad 将它分配到最近添加的游戏板,并将其如下定义:

Gamepad^ MarbleMaze::MarbleMazeMain::GetLastGamepad()
{
    Gamepad^ gamepad = nullptr;

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

    return gamepad;
}

此方法仅在 m_myGamepads 中返回最后一个游戏板。

最多可以将四个游戏控制器连接到一台 Windows 10 设备。 若要避免确定哪个控制器是活动的,我们只跟踪最近添加的游戏板。 如果游戏支持多个玩家,你必须分别跟踪每个玩家的输入。

MarbleMazeMain::Update 方法轮询要进行输入的游戏板:

if (m_gamepad != nullptr)
{
    m_oldReading = m_newReading;
    m_newReading = m_gamepad->GetCurrentReading();
}

我们使用 m_oldReading 跟踪在最后一帧中获取的输入读数,并使用 m_newReading 跟踪最新输入读数,我们通过调用 Gamepad::GetCurrentReading 获取这些数据。 这将返回 GamepadReading 对象,其中包含游戏板当前状态的信息。

为了检查按钮是按下还是释放,我们定义 MarbleMazeMain::ButtonJustPressedMarbleMazeMain::ButtonJustReleased,这将比较此帧和最后一帧的按钮读数。 这样,我们可以仅在最初按下或释放按钮时执行操作,而不是在按住按钮时执行操作:

bool MarbleMaze::MarbleMazeMain::ButtonJustPressed(GamepadButtons selection)
{
    bool newSelectionPressed = (selection == (m_newReading.Buttons & selection));
    bool oldSelectionPressed = (selection == (m_oldReading.Buttons & selection));
    return newSelectionPressed && !oldSelectionPressed;
}

bool MarbleMaze::MarbleMazeMain::ButtonJustReleased(GamepadButtons selection)
{
    bool newSelectionReleased = 
        (GamepadButtons::None == (m_newReading.Buttons & selection));

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

    return newSelectionReleased && !oldSelectionReleased;
}

GamepadButtons 读数使用按位运算进行比较。我们检查是否使用按位与 (&) 按下按钮。 我们将确定是否通过比较旧读数和新读数按下或释放按钮。

我们使用上述方法检查是否按下了某些按钮,然后执行任何必须发生的相应操作。 例如,按下“菜单”按钮 (GamepadButtons::Menu) 时,游戏状态会从活动更改为暂停,或者从暂停更改为活动。

if (ButtonJustPressed(GamepadButtons::Menu) || m_pauseKeyPressed)
{
    m_pauseKeyPressed = false;

    if (m_gameState == GameState::InGameActive)
    {
        SetGameState(GameState::InGamePaused);
    }  
    else if (m_gameState == GameState::InGamePaused)
    {
        SetGameState(GameState::InGameActive);
    }
}

我们还会检查玩家是否按下“视图”按钮,在这种情况下,我们将重启该游戏或清除高分表:

if (ButtonJustPressed(GamepadButtons::View) || m_homeKeyPressed)
{
    m_homeKeyPressed = false;

    if (m_gameState == GameState::InGameActive ||
        m_gameState == GameState::InGamePaused ||
        m_gameState == GameState::PreGameCountdown)
    {
        SetGameState(GameState::MainMenu);
        m_inGameStopwatchTimer.SetVisible(false);
        m_preGameCountdownTimer.SetVisible(false);
    }
    else if (m_gameState == GameState::HighScoreDisplay)
    {
        m_highScoreTable.Reset();
    }
}

如果主菜单是活动的,活动的菜单项会在向上或向下按方向板时发生改变。 如果用户选择当前的选项,相应的 UI 元素将标记为已选择。

// Handle menu navigation.
bool chooseSelection = 
    (ButtonJustPressed(GamepadButtons::A) 
    || ButtonJustPressed(GamepadButtons::Menu));

bool moveUp = ButtonJustPressed(GamepadButtons::DPadUp);
bool moveDown = ButtonJustPressed(GamepadButtons::DPadDown);

switch (m_gameState)
{
case GameState::MainMenu:
    if (chooseSelection)
    {
        m_audio.PlaySoundEffect(MenuSelectedEvent);
        if (m_startGameButton.GetSelected())
        {
            m_startGameButton.SetPressed(true);
        }
        if (m_highScoreButton.GetSelected())
        {
            m_highScoreButton.SetPressed(true);
        }
    }
    if (moveUp || moveDown)
    {
        m_startGameButton.SetSelected(!m_startGameButton.GetSelected());
        m_highScoreButton.SetSelected(!m_startGameButton.GetSelected());
        m_audio.PlaySoundEffect(MenuChangeEvent);
    }
    break;

case GameState::HighScoreDisplay:
    if (chooseSelection || anyPoints)
    {
        SetGameState(GameState::MainMenu);
    }
    break;

case GameState::PostGameResults:
    if (chooseSelection || anyPoints)
    {
        SetGameState(GameState::HighScoreDisplay);
    }
    break;

case GameState::InGamePaused:
    if (m_pausedText.IsPressed())
    {
        m_pausedText.SetPressed(false);
        SetGameState(GameState::InGameActive);
    }
    break;
}

跟踪触摸和鼠标输入

对于触摸和鼠标输入,用户可通过触摸或单击一个菜单项来选择它。 下面的示例展示了 MarbleMazeMain::Update 方法如何处理指针输入来选择菜单项。 m_pointQueue 成员变量跟踪用户在屏幕上触摸或单击的位置。 Marble Maze 收集指针输入的方式将在本文档后面处理指针输入一节中详细介绍。

// Check whether the user chose a button from the UI. 
bool anyPoints = !m_pointQueue.empty();
while (!m_pointQueue.empty())
{
    UserInterface::GetInstance().HitTest(m_pointQueue.front());
    m_pointQueue.pop();
}

UserInterface::HitTest 方法确定所提供的点是否位于任何 UI 元素边界内。 任何通过此测试的 UI 元素都会标记为已触摸。 此方法使用 PointInRect 帮助程序函数确定所提供的点是否位于每个 UI 元素的边界内。

void UserInterface::HitTest(D2D1_POINT_2F point)
{
    for (auto iter = m_elements.begin(); iter != m_elements.end(); ++iter)
    {
        if (!(*iter)->IsVisible())
            continue;

        TextButton* textButton = dynamic_cast<TextButton*>(*iter);
        if (textButton != nullptr)
        {
            D2D1_RECT_F bounds = (*iter)->GetBounds();
            textButton->SetPressed(PointInRect(point, bounds));
        }
    }
}

更新游戏状态

MarbleMazeMain::Update 方法处理控制器和触摸输入后,如果按下任何按钮,它会更新游戏状态。

// Update the game state if the user chose a menu option. 
if (m_startGameButton.IsPressed())
{
    SetGameState(GameState::PreGameCountdown);
    m_startGameButton.SetPressed(false);
}
if (m_highScoreButton.IsPressed())
{
    SetGameState(GameState::HighScoreDisplay);
    m_highScoreButton.SetPressed(false);
}

控制游戏

游戏循环和 MarbleMazeMain::Update 方法一起更新游戏对象的状态。 如果游戏接受来自多台设备的输入,你可将来自所有设备的输入累积到一个变量集中,以便能编写更易于维护的代码。 MarbleMazeMain::Update 方法定义一个可累积来自所有设备的运动的变量集。

float combinedTiltX = 0.0f;
float combinedTiltY = 0.0f;

不同输入设备的输入机制可能不同。 例如,指针输入使用 Windows 运行时事件处理模型来处理。 相反,必要时应该轮询来自游戏控制器的输入数据。 我们建议你始终遵循为给定设备规定的输入机制。 本节介绍 Marble Maze 如何从每个设备读取输入,它如何更新组合的输入值,以及它如何使用组合的输入值更新游戏的状态。

处理指针输入

处理指针输入时,可调用 Windows::UI::Core::CoreDispatcher::ProcessEvents 方法来处理窗口事件。 在更新或呈现场景之前,在游戏循环中调用此方法。 Marble Maze 在 App::Run 方法中执行此调用:

while (!m_windowClosed)
{
    if (m_windowVisible)
    {
        CoreWindow::GetForCurrentThread()->
            Dispatcher->ProcessEvents(CoreProcessEventsOption::ProcessAllIfPresent);

        m_main->Update();

        if (m_main->Render())
        {
            m_deviceResources->Present();
        }
    }
    else
    {
        CoreWindow::GetForCurrentThread()->
            Dispatcher->ProcessEvents(CoreProcessEventsOption::ProcessOneAndAllPending);
    }
}

如果窗口可见,我们将 CoreProcessEventsOption::ProcessAllIfPresent 传递到 ProcessEvents 来处理所有排队的事件并立即返回;否则,我们传递 CoreProcessEventsOption::ProcessOneAndAllPending 来处理所有排队的事件,并等待下一个新事件。 在处理事件后,Marble Maze 呈现并显示下一帧。

Windows 运行时调用为每个发生的事件所注册的处理程序。 App::SetWindow 方法注册事件并将指针信息转发到 MarbleMazeMain 类。

void App::OnPointerPressed(
    Windows::UI::Core::CoreWindow^ sender, 
    Windows::UI::Core::PointerEventArgs^ args)
{
    m_main->AddTouch(args->CurrentPoint->PointerId, args->CurrentPoint->Position);
}

void App::OnPointerReleased(
    Windows::UI::Core::CoreWindow^ sender, 
    Windows::UI::Core::PointerEventArgs^ args)
{
    m_main->RemoveTouch(args->CurrentPoint->PointerId);
}

void App::OnPointerMoved(
    Windows::UI::Core::CoreWindow^ sender, 
    Windows::UI::Core::PointerEventArgs^ args)
{
    m_main->UpdateTouch(args->CurrentPoint->PointerId, args->CurrentPoint->Position);
}

MarbleMazeMain 类通过对保留触摸事件的 map 对象进行更新来对指针事件做出反应。 MarbleMazeMain::AddTouch 方法在首次按下指针时调用,例如当用户最初在支持触摸的设备上触摸屏幕时。 MarbleMazeMain::UpdateTouch 方法在指针位置移动时调用。 MarbleMazeMain::RemoveTouch 方法在指针释放时调用,例如当用户停止触摸屏幕时。

void MarbleMazeMain::AddTouch(int id, Windows::Foundation::Point point)
{
    m_touches[id] = PointToTouch(point, m_deviceResources->GetLogicalSize());

    m_pointQueue.push(D2D1::Point2F(point.X, point.Y));
}

void MarbleMazeMain::UpdateTouch(int id, Windows::Foundation::Point point)
{
    if (m_touches.find(id) != m_touches.end())
        m_touches[id] = PointToTouch(point, m_deviceResources->GetLogicalSize());
}

void MarbleMazeMain::RemoveTouch(int id)
{
    m_touches.erase(id);
}

PointToTouch 函数转换当前的指针位置,以便原点位于屏幕中心,然后按比例缩放坐标,以便它们大约在 -1.0 与 +1.0 之间。 这使跨不同输入方法以一致方式计算迷宫倾斜程度变得更容易。

inline XMFLOAT2 PointToTouch(Windows::Foundation::Point point, Windows::Foundation::Size bounds)
{
    float touchRadius = min(bounds.Width, bounds.Height);
    float dx = (point.X - (bounds.Width / 2.0f)) / touchRadius;
    float dy = ((bounds.Height / 2.0f) - point.Y) / touchRadius;

    return XMFLOAT2(dx, dy);
}

MarbleMazeMain::Update 方法通过在倾斜系数上加上一个固定刻度值来更新组合的输入值。 这个刻度值通过试验多个不同的值来确定。

// Account for touch input.
for (TouchMap::const_iterator iter = m_touches.cbegin(); 
    iter != m_touches.cend(); 
    ++iter)
{
    combinedTiltX += iter->second.x * m_touchScaleFactor;
    combinedTiltY += iter->second.y * m_touchScaleFactor;
}

处理加速计输入

若要处理加速计输入,MarbleMazeMain::Update 方法会调用 Windows::Devices::Sensors::Accelerometer::GetCurrentReading 方法。 此方法返回一个 Windows::Devices::Sensors::AccelerometerReading 对象,它表示一个加速计读数。 Windows::Devices::Sensors::AccelerometerReading::AccelerationXWindows::Devices::Sensors::AccelerometerReading::AccelerationY 属性具有分别沿 X 和 Y 轴的 G-Force 加速。

下面的示例展示了 MarbleMazeMain::Update 方法如何轮询加速计并更新组合的输入值。 倾斜设备时,重力会导致弹珠移动速度更快。

// Account for sensors.
if (m_accelerometer != nullptr)
{
    Windows::Devices::Sensors::AccelerometerReading^ reading =
        m_accelerometer->GetCurrentReading();

    if (reading != nullptr)
    {
        combinedTiltX += 
            static_cast<float>(reading->AccelerationX) * m_accelerometerScaleFactor;

        combinedTiltY += 
            static_cast<float>(reading->AccelerationY) * m_accelerometerScaleFactor;
    }
}

因为无法确保用户计算机上是否有加速计,所以始终要在轮询加速计之前确保你有一个有效的 Accelerometer 对象。

处理游戏控制器输入

MarbleMazeMain::Update 方法中,我们使用 m_newReading 处理左摇杆的输入:

float leftStickX = static_cast<float>(m_newReading.LeftThumbstickX);
float leftStickY = static_cast<float>(m_newReading.LeftThumbstickY);

auto oppositeSquared = leftStickY * leftStickY;
auto adjacentSquared = leftStickX * leftStickX;

if ((oppositeSquared + adjacentSquared) > m_deadzoneSquared)
{
    combinedTiltX += leftStickX * m_controllerScaleFactor;
    combinedTiltY += leftStickY * m_controllerScaleFactor;
}

我们检查左摇杆的输入是否在死区之外,如果是,我们会将它添加到 combinedTiltXcombinedTiltY(乘以比例系数)使舞台倾斜。

重要

使用游戏控制器时,请始终考虑死区。 死区指各个游戏板在对初始移动的敏感性上的差异。 在一些控制器中,较小的运动可能不会生成读数,但在其他控制器中,它可能生成明显的读数。 若要在游戏中考虑此情形,可为初始操纵杆运动创建一个不运动区域。 有关死区的详细信息,请参阅读取操纵杆

 

将输入应用到游戏状态

设备以不同方式报告输入值。 例如,指针输入可能采用屏幕坐标格式,而控制器输入可能采用完全不同的格式。 将来自多台设备的输入组合到一组输入值中的一项挑战是标准化,或者将值转换为通用格式。 Marble Maze 通过在 [-1.0, 1.0] 区间内缩放这些值来规范化它们。 PointToTouch 函数(本节前面已介绍)将屏幕坐标转换为大体在 -1.0 与 +1.0 之间的规范化值。

提示

即使你的应用程序仅使用一种输入方法,我们也建议你始终规范输入值。 执行此操作可简化游戏的其他组件如何解释输入(例如力学模拟),以及使编写适用于不同屏幕分辨率的游戏更容易。

 

MarbleMazeMain::Update 方法处理输入后,将会创建一个矢量来表示在倾斜迷宫时对弹珠的影响。 下面的示例展示了 Marble Maze 如何使用 XMVector3Normalize 函数创建一个规范化的重力矢量。 maxTilt 变量约束迷宫倾斜的程度并阻止迷宫朝一侧倾斜。

const float maxTilt = 1.0f / 8.0f;

XMVECTOR gravity = XMVectorSet(
    combinedTiltX * maxTilt, 
    combinedTiltY * maxTilt, 
    1.0f, 
    0.0f);

gravity = XMVector3Normalize(gravity);

若要完成场景对象的更新,Marble Maze 将更新的重力矢量传递给力学模拟,在力学模拟中更新自前一帧以来经历的时间,并更新弹珠的位置和方向。 如果弹珠从迷宫落下,MarbleMazeMain::Update 方法将弹珠放回它最后接触的检查点,并重置力学模拟的状态。

XMFLOAT3A g;
XMStoreFloat3(&g, gravity);
m_physics.SetGravity(g);

if (m_gameState == GameState::InGameActive)
{
    // Only update physics when gameplay is active.
    m_physics.UpdatePhysicsSimulation(static_cast<float>(m_timer.GetElapsedSeconds()));

    // ...Code omitted for simplicity...

}

// ...Code omitted for simplicity...

// Check whether the marble fell off of the maze. 
const float fadeOutDepth = 0.0f;
const float resetDepth = 80.0f;
if (marblePosition.z >= fadeOutDepth)
{
    m_targetLightStrength = 0.0f;
}
if (marblePosition.z >= resetDepth)
{
    // Reset marble.
    memcpy(&marblePosition, &m_checkpoints[m_currentCheckpoint], sizeof(XMFLOAT3));
    oldMarblePosition = marblePosition;
    m_physics.SetPosition((const XMFLOAT3&)marblePosition);
    m_physics.SetVelocity(XMFLOAT3(0, 0, 0));
    m_lightStrength = 0.0f;
    m_targetLightStrength = 1.0f;

    m_resetCamera = true;
    m_resetMarbleRotation = true;
    m_audio.PlaySoundEffect(FallingEvent);
}

本节没有介绍力学模拟的工作原理。 有关这方面的细节,请参阅 Marble Maze 源代码中的 Physics.h 和 Physics.cpp。

后续步骤

查阅向 Marble Maze 添加音频示例,了解在使用音频时要记住的一些重要实践。 本文档讨论了 Marble Maze 如何使用 Microsoft 媒体基础和 XAudio2 来加载、混合和播放音频资源。