向 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 运行时事件处理机制接收触摸事件。

注释

本文档将“触摸”用于指代触摸和鼠标输入,将“指针”用于指代使用指针事件的任何设备。 由于触摸和鼠标使用标准指针事件,因此可以使用任一设备选择菜单项和控制游戏。

 

注释

程序包清单将 横向 设置为游戏唯一受支持的旋转,以防止在旋转设备以滚动弹珠时方向发生变化。 若要查看包清单,请在 Visual Studio 解决方案资源管理器 中打开 Package.appxmanifest

 

初始化输入设备

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

以下示例展示了App::SetWindow 方法如何为Windows::UI::Core::CoreWindow::PointerPressedWindows::UI::Core::CoreWindow::PointerReleased,以及Windows::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 对象来保存触摸事件。 此映射对象的键是一个值,该值唯一标识输入指针。 每个键对应于每个触摸点与屏幕中心的距离。 Marble Maze 稍后使用这些值计算迷宫倾斜的量。

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

MarbleMazeMain 类还保存一个 加速计 对象。

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;

Update 方法中,我们检查是否需要重新分配 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::P rocessEvents 方法来处理窗口事件。 在更新或呈现场景之前,请在游戏循环中调用此方法。 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 类通过更新保存触摸事件的映射对象来响应指针事件。 首次按下指针时,将调用 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 力加速度。

以下示例演示如何 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;
    }
}

由于无法确定用户的计算机上是否存在加速度计,因此在轮询加速度计之前,请始终确保拥有一个有效的 加速度计 对象。

处理游戏控制器输入

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.hPhysics.cpp

后续步骤

阅读 将音频添加到 Marble Maze 示例,了解在使用音频时要记住的一些关键做法。 本文档讨论 Marble Maze 如何使用 Microsoft Media Foundation 和 XAudio2 加载、混合和播放音频资源。