【wxWidgets实战】跳动的泡泡

先上效果图——
跳动的泡泡

一、功能描述

  1. 有一个 panelpanel 中有一个会移动的泡泡(至于为什么叫泡泡,是因为我一开始起名没考虑清楚……);
  2. 当泡泡撞到 panel 的边缘时,会反弹;
  3. 可以在界面中更改泡泡的颜色、位置、移动步长和泡泡移动的方向;
  4. 界面会实时显示当前泡泡的一系列状态;
  5. 界面中有可以控制泡泡跳动开始和结束的按钮。

二、思路分析

  1. 要有一个会跳动的泡泡,泡泡要在一个范围内跳动。
    • 有一个可以装泡泡的容器。
    • 泡泡被绘制在这个容器内。
  2. 软件界面要可以设置泡泡的一些基本信息,还要能显示泡泡的实时动态信息。
    • 使用编辑框来设置和显示泡泡的基本信息。
      • 通过编辑框的 enabledisable 来控制编辑框当前的状态。
  3. 软件界面要可以控制泡泡跳动的开始和停止。
    • 创建一个按钮用于启动和停止泡泡的跳动。

三、详细分解

1. 项目结构

以下是我的项目结构:

.
├── bubblespanel.cpp
├── bubblespanel.h
├── cmake
│   ├── Add_wxWidgets.cmake
│   └── CPM.cmake
├── CMakeLists.txt
├── main.cpp
├── mainwin.cpp
├── MainWin.fbp
├── mainwin.h
├── ui_mainwin.cpp
└── ui_mainwin.h

先说一下,我的项目是在 Linux 平台下进行开发的,理论上是可以在 Windows 上面跑的,因为我并没有用到系统级别的 API。

简单介绍一下每个文件的意义:

  • bubblespanel.cppbubblespanel.h:泡泡面板控件的声明文件和实现文件,如一开始的 GIF 所示,泡泡所在的那个框框正是这个控件。

  • Add_wxWidgets.cmake:CMake 脚本,用于添加 wxWidgets 库到项目中,这里用到了下面所说的 CPM.cmake 包管理工具。

  • CPM.cmake:一个比较好用的 CMake 的包管理工具,点这里快速跳转到它的 GitHub 主页

  • CMakeLists.txt:CMake 项目入口脚本。

  • main.cpp:程序入口源文件。

  • mainwin.cpp:程序主窗口源文件。

  • MainWin.fbp:程序主窗口的界面设计项目文件,我为了偷懒,直接使用 wxFormBuilder 工具进行界面设计。

  • mainwin.h:程序主窗口头文件。

  • ui_mainwin.cppui_mainwin.h:使用 wxFormBuilder 工具生成的界面 C++ 代码文件(这个工具还可以生成相应的 Python、PHP、Lua 等语言的代码文件)。

2. 入口代码

有看了我以往的教程以及项目的小伙伴应该很清楚这个入口代码应该怎么写,这里就直接贴上来了,不再进行详细的介绍:

#include <wx/wx.h>
#include "mainwin.h"

class MyApp : public wxApp
{
public:
    bool OnInit() override
    {
        MainWin *frame = new MainWin();
        frame->Show();
        frame->Center();
        return true;
    }
};

wxIMPLEMENT_APP(MyApp); // NOLINT

可以看到,我们创建了一个基于 MainWin 类的主窗口,并且把这个主窗口居中显示了。

3. 主窗口功能分析

(1) MainWin 类声明

namespace UI
{
    class MainWin;
}

class MainWin : public wxFrame
{
public:
    explicit MainWin();
    ~MainWin() override;

protected:
    /// 事件:圆动画控件中的圆移动了一步
    void BubblesPanelMovedStepEvent(wxCommandEvent &e);
    /// 绑定所有事件
    void BindAll();
    /// 刷新控件的值
    void FlushCtrlsValue();

private:
    UI::MainWin *ui;

wxDECLARE_EVENT_TABLE();
};
① MainWin 类:
  • 继承:继承自 wxFrame,意味着这是一个GUI窗口。
  • 成员函数:
    • 事件处理:如 BubblesPanelMovedStepEvent,处理某种图形动画事件。
    • 功能函数:如 BindAllFlushCtrlsValue,分别用于事件绑定和刷新界面控件值。
  • 成员变量UI::MainWin *ui 是界面相关的私有指针。UI::MainWin 的声明和实现就在 ui_mainwin.cppui_mainwin.h 中。
② 事件表:

通过 wxDECLARE_EVENT_TABLE() 宏声明了一个事件表,说明该类可以处理特定的事件。

(2) 界面设计

跳动的泡泡

这个项目使用了 wxFormBuilder 进行界面的快速设计,这里就简单说一下:

跳动的泡泡
可以看出,界面一共分成两大模块,一个是泡泡面板,一个是操作面板。


在这里插入图片描述
泡泡面板是以上红色矩形标注的位置,它就是泡泡跳动的平台。

跳动的泡泡
以上标注的是操作面板,它可以设置泡泡的颜色位置步长方向,还有一个 开始/停止 按钮。

至于对 wxFormBuilder 的详细使用介绍,这里就不陈述了,想知道怎么用的小伙伴,可以自行上网百度。当然,我也会考虑看看要不要出 wxFormBuilder 的教程专栏,大家可在评论区给点建议和意见~~

这里给大家简单预览一下 wxFormBuilder 生成的的代码:

跳动的泡泡

跳动的泡泡

(3) MainWin 事件表绑定

wxBEGIN_EVENT_TABLE(MainWin, wxFrame)
    EVT_COMMAND(wxID_ANY, BubblesPanel_MovedStep, MainWin::BubblesPanelMovedStepEvent)
wxEND_EVENT_TABLE()

以上这段代码放在了源文件(cpp文件)中,它主要绑定了一个泡泡移动一步后就会被触发的事件。

① 事件表开始和结束:
  • wxBEGIN_EVENT_TABLE(MainWin, wxFrame)wxEND_EVENT_TABLE() 分别标记事件表的开始和结束。
② 事件绑定:

EVT_COMMAND(wxID_ANY, BubblesPanel_MovedStep, MainWin::BubblesPanelMovedStepEvent) 是事件绑定指令:

  • wxID_ANY 表示该事件绑定不关心特定的控件ID,即它将匹配所有控件发出的事件。
  • BubblesPanel_MovedStep 是我们要捕获的事件类型,即“圆移动了一步”事件。
  • MainWin::BubblesPanelMovedStepEvent 是当上述事件被触发时需要调用的处理函数。

(4) MainWin::BubblesPanelMovedStepEvent 函数

紧接着我们先来看看这个被绑定的函数的实现:

void MainWin::BubblesPanelMovedStepEvent(wxCommandEvent &e)
{
    FlushCtrlsValue();
}

非常简单,也就是当圆每移动一步,在主窗口中都刷新一下窗口控件的内容。

(5) MainWin 类的构造函数

MainWin::MainWin() :
    wxFrame(nullptr, wxID_ANY, wxT("泡泡"), wxDefaultPosition, {1024, 768}),
    ui(new UI::MainWin(this))
{
    SetClientSize(ui->GetSize());
    FlushCtrlsValue();
    BindAll();
}
① 初始化列表:
  • 使用 wxFrame 的构造函数来创建一个窗口。其标题为“泡泡”,默认位置,大小为1024x768。
  • ui 成员变量初始化一个新的 UI::MainWin 对象,并将当前窗口(this)作为参数传递。
② 函数体:
  • 将窗口的客户区大小设置为 ui 的大小。
  • 调用 FlushCtrlsValue() 来初始化或更新窗口上的控件值。
  • 调用 BindAll() 来绑定事件处理器。

简而言之,这个构造函数创建了一个标题为“泡泡”的窗口,初始化界面,并设置事件绑定。

(6) 事件绑定函数 BindAll

BindAll函数是MainWin类的一个成员函数,其主要功能是为各个控件绑定事件处理器。先来看看实现:

void MainWin::BindAll()
{
    ui->m_clr->Bind(wxEVT_COLOURPICKER_CHANGED, [this](wxColourPickerEvent &e) {
        ui->m_bubblesPanel->SetCircleColor(e.GetColour());
    });
    ui->m_textX->Bind(wxEVT_TEXT, [this](wxCommandEvent &e) {
        if (ui->m_textX->IsEnabled())
            ui->m_bubblesPanel->SetCirclePosition({wxAtoi(ui->m_textX->GetValue()), wxAtoi(ui->m_textX->GetValue())});
    });
    ui->m_textY->Bind(wxEVT_TEXT, [this](wxCommandEvent &e) {
        if (ui->m_textY->IsEnabled())
            ui->m_bubblesPanel->SetCirclePosition({wxAtoi(ui->m_textX->GetValue()), wxAtoi(ui->m_textX->GetValue())});
    });
    ui->m_textStep->Bind(wxEVT_TEXT, [this](wxCommandEvent &e) {
        if (ui->m_textStep->IsEnabled())
            ui->m_bubblesPanel->SetCircleStep(wxAtoi(ui->m_textStep->GetValue()));
    });
    ui->m_textDirect->Bind(wxEVT_TEXT, [this](wxCommandEvent &e) {
        if (ui->m_textDirect->IsEnabled())
            ui->m_bubblesPanel->SetCircleDirection(wxAtoi(ui->m_textDirect->GetValue()));
    });
    ui->m_btnAction->Bind(wxEVT_BUTTON, [this](wxCommandEvent &e) {
        if (ui->m_btnAction->GetLabel() == wxT("开始")) {
            ui->m_textX->Disable();
            ui->m_textY->Disable();
            ui->m_textStep->Disable();
            ui->m_textDirect->Disable();
            ui->m_bubblesPanel->StartAnimation(10);
            ui->m_btnAction->SetLabel(wxT("停止"));
        }
        else {
            ui->m_bubblesPanel->StopAnimation();
            ui->m_textX->Enable();
            ui->m_textY->Enable();
            ui->m_textStep->Enable();
            ui->m_textDirect->Enable();
            ui->m_btnAction->SetLabel(wxT("开始"));
        }
    });
}

这咋一看,感觉非常复杂,其实实际上也就做了三件事情:颜色更改文本更改以及按钮绑定

① 颜色选择器绑定:
  • 当颜色选择器(m_clr)的颜色发生变化时,会更新泡泡面板(m_bubblesPanel)上的圆的颜色。
② 文本控件绑定:
  • m_textXm_textY:当它们的文本内容变化时,并且当它们是启用状态,它们会更新m_bubblesPanel上的圆的位置。
  • m_textStep:当其文本内容变化时,如果启用,它会更新圆的步进值。
  • m_textDirect:当其文本内容变化时,如果启用,它会设置圆的方向。
③ 按钮绑定:
  • m_btnAction:当按下时,它会切换动画的开始和停止状态。如果标签为"开始",它会禁用所有文本框,并启动泡泡面板的动画;否则,它会停止动画,启用文本框,并将标签设置回"开始"。

也就是说,这个函数为界面上的控件绑定了一系列事件处理器,使得用户的操作(如更改文本或点击按钮)会导致界面上的某些动作或更改。

这里我们可以关注一下 if (ui->...->IsEnabled()) 着部分的代码,我就是通过判断控件是否enable,来决定是否出发相应的操作,否则,就会导致一个严重问题——主窗口接收到泡泡移动了一步事件,然后进行控件内容的刷新,而刷新时又因为控件内容的改变,以至于触发了此处控件绑定的操作,这个操作又会去更改泡泡的属性。这样一来泡泡每移动一步,它自身的属性都要被更改两次,这样非常不合理

这里我给大家总结三点使用 if (ui->...->IsEnabled()) 判断的意义:

  1. 避免不必要的操作:如果一个控件被禁用,那么基于这个控件的值进行的任何操作可能是不恰当或不必要的。例如,如果一个文本框被禁用,它可能包含过时或无效的数据(当然这个项目中并不是因为这个原因)。
  2. 用户交互指示:控件的启用/禁用状态通常为用户提供了某种形式的反馈。一个禁用的控件通常表示其当前值不应更改或不应用于当前的操作或配置。
  3. 增加代码的健壮性:确保仅在控件启用时执行特定操作可以防止可能的错误或不预期的行为,尤其是在复杂的GUI应用程序中。

(7) 刷新控件内容

void MainWin::FlushCtrlsValue()
{
    ui->m_clr->SetColour(ui->m_bubblesPanel->GetCircleColor());
    ui->m_textX->SetValue(wxString::Format("%d", ui->m_bubblesPanel->GetCirclePosition().x));
    ui->m_textY->SetValue(wxString::Format("%d", ui->m_bubblesPanel->GetCirclePosition().y));
    ui->m_textStep->SetValue(wxString::Format("%d", ui->m_bubblesPanel->GetCircleStep()));
    ui->m_textDirect->SetValue(wxString::Format("%d", ui->m_bubblesPanel->GetCircleDirection()));
}

这个函数就非常简单易懂了,也就是刷新界面上一些与泡泡信息有关的控件的值。

4. 泡泡面板功能分析

(1) 声明

① 事件声明
wxDECLARE_EVENT(BubblesPanel_MovedStep, wxCommandEvent);

字面意思,声明了一个名为 BubblesPanel_MovedStep 的事件被声明,表示圆移动了一步。

② 类成员
wxTimer m_timer;         ///< 定时器,用于动画
wxBitmap *m_pbmpCircle;  ///< 圆的位图
wxColour m_clrCircle;    ///< 圆的颜色
wxPoint m_ptCircle;      ///< 圆的位置
int m_nStep;             ///< 圆的步长
int m_nCircleDirection;  ///< 圆下一步的方向

这些变量用于存储和控制圆的属性和行为,这里没什么好说的。

③ 事件函数以及内部函数
void PaintEvent(wxPaintEvent &e);
void TimerEvent(wxTimerEvent &e);
/// 圆移动一步
void MoveCircle();
/// 生成圆的位图
void GenerateCircleBitmap(int w, int h);
/// 重新绘制圆的位图
void RepaintCircleBmp();
  • PaintEventTimerEvent 是处理绘制和定时器事件的方法。
  • 圆的移动和绘制:有 MoveCircle 方法来控制圆的移动逻辑,以及使用 PaintEvent 来处理绘图事件。
  • GenerateCircleBitmap RepaintCircleBmp 用于创建和更新圆的位图。
④ 主要功能
/// 构造函数
explicit BubblesPanel(wxWindow *parent, wxWindowID id = wxID_ANY,
                      const wxPoint &pos = wxDefaultPosition, const wxSize &size = wxDefaultSize,
                      long style = wxTAB_TRAVERSAL, const wxString &name = wxPanelNameStr);
/// 析构函数
~BubblesPanel() override;
/// 开始动画
void StartAnimation(int nInterval);
/// 停止动画
void StopAnimation();
/// 设置圆的位置
void SetCirclePosition(const wxPoint &pt);
/// 设置圆的大小
void SetCircleSize(int w, int h);
/// 设置圆的颜色
void SetCircleColor(const wxColour &clr);
/// 设置圆的步长
void SetCircleStep(int nStep);
/// 设置圆的方向
void SetCircleDirection(int nDirection);
/// 获取圆的位置
wxPoint GetCirclePosition() const;
/// 获取圆的大小
wxSize GetCircleSize() const;
/// 获取圆的颜色
wxColour GetCircleColor() const;
/// 获取圆的步长
int GetCircleStep() const;
/// 获取圆的方向
int GetCircleDirection() const;
  • 构造和析构:BubblesPanel 有其自定义的构造函数和析构函数。
  • 动画控制:通过 StartAnimationStopAnimation 方法可以启动和停止圆的动画。
  • 设置和获取圆属性:包括位置(SetCirclePositionGetCirclePosition)、大小(SetCircleSizeGetCircleSize)、颜色(SetCircleColorGetCircleColor)等。

整个泡泡面板声明就这些内容了,并不复杂,大家应该是还能接受的。

(2) 定义

① 事件定义
wxDEFINE_EVENT(BubblesPanel_MovedStep, wxCommandEvent);

这里定义了之前在头文件中声明的事件 BubblesPanel_MovedStep,它必须放在源文件(cpp文件)中定义,不能重复定义,详细请看【wxWidgets 教程】事件篇Ⅳ(六)

② 事件表定义
wxBEGIN_EVENT_TABLE(BubblesPanel, wxWindow)
    EVT_PAINT(BubblesPanel::PaintEvent)
    EVT_TIMER(wxID_ANY, BubblesPanel::TimerEvent)
wxEND_EVENT_TABLE()

EVT_PAINT 用于处理绘图事件,将其绑定到 PaintEvent 函数;EVT_TIMER 用于处理定时器事件,将其绑定到 TimerEvent 函数。

③ 构造和析构
BubblesPanel::BubblesPanel(wxWindow *parent, wxWindowID id, const wxPoint &pos,
                           const wxSize &size, long style, const wxString &name) :
    wxWindow(parent, id, pos, size, style, name),
    m_timer(this, wxID_ANY),
    m_pbmpCircle(nullptr),
    m_clrCircle(123, 209, 234),
    m_ptCircle(100, 100),
    m_nStep(5),
    m_nCircleDirection(73)
{
    GenerateCircleBitmap(100, 100);
}


BubblesPanel::~BubblesPanel()
{
    delete m_pbmpCircle;
    m_pbmpCircle = nullptr;
}
  • 在构造函数中,初始化了一些成员变量,并调用 GenerateCircleBitmap 生成圆的位图。
  • 析构函数释放了 m_pbmpCircle
④ 动画控制
void BubblesPanel::StartAnimation(int nInterval)
{
    m_timer.Start(nInterval);
}

void BubblesPanel::StopAnimation()
{
    m_timer.Stop();
}
  • StartAnimation:开始动画,启动定时器。
  • StopAnimation:停止动画,关闭定时器。
⑤ 圆属性的设置与绘制
void BubblesPanel::SetCirclePosition(const wxPoint &pt)
{
    m_ptCircle = pt;
    Refresh();
}

void BubblesPanel::SetCircleSize(int w, int h)
{
    GenerateCircleBitmap(w, h);
    Refresh();
}

void BubblesPanel::SetCircleColor(const wxColour &clr)
{
    m_clrCircle = clr;
    RepaintCircleBmp();
    Refresh();
}

void BubblesPanel::SetCircleStep(int nStep)
{
    m_nStep = nStep;
}

void BubblesPanel::SetCircleDirection(int nDirection)
{
    m_nCircleDirection = nDirection;
}

void BubblesPanel::PaintEvent(wxPaintEvent &e)
{
    wxPaintDC dc(this);
    dc.SetBackground(*wxTRANSPARENT_BRUSH);
    dc.Clear();
    dc.DrawBitmap(*m_pbmpCircle, m_ptCircle);
}

void BubblesPanel::TimerEvent(wxTimerEvent &e)
{
    MoveCircle();
}
  • SetCirclePositionSetCircleSize 等:用于设置圆的不同属性,并重新绘制。
  • PaintEvent:处理绘图事件。
    • SetBackground 即设置背景颜色,这里设置为透明背景色;
    • Clear 用于清空整个客户区域,并且使用背景色填充客户区域的背景;
    • DrawBitmap 即绘制一张位图,而这张位图正是通过圆的属性绘制出来的,这个绘制圆的位图的函数下面有介绍。
  • TimerEvent:处理定时器事件,使圆移动。
⑥ 圆的移动
void BubblesPanel::MoveCircle()
{
    // 如果球已经超出边缘,则直接回到边缘
    if (m_ptCircle.x < 0)
        m_ptCircle.x = 0;
    if (m_ptCircle.x > GetClientSize().GetWidth() - m_pbmpCircle->GetWidth())
        m_ptCircle.x = GetClientSize().GetWidth() - m_pbmpCircle->GetWidth();
    if (m_ptCircle.y < 0)
        m_ptCircle.y = 0;
    if (m_ptCircle.y > GetClientSize().GetHeight() - m_pbmpCircle->GetHeight())
        m_ptCircle.y = GetClientSize().GetHeight() - m_pbmpCircle->GetHeight();

    double dXNext = m_ptCircle.x + m_nStep * std::cos(m_nCircleDirection * M_PI / 180); 
    double dYNext = m_ptCircle.y + m_nStep * std::sin(m_nCircleDirection * M_PI / 180); 

    // 判断圆的下一步是否已经到达控件边界,如果是,则会反弹,改变方向
    if (dXNext < 0 || dXNext > GetClientSize().GetWidth() - m_pbmpCircle->GetWidth())
        m_nCircleDirection = 180 - m_nCircleDirection;
    if (dYNext < 0 || dYNext > GetClientSize().GetHeight() - m_pbmpCircle->GetHeight())
        m_nCircleDirection = 360 - m_nCircleDirection;

    // 根据圆的位置、步长、方向,计算圆下一步的位置(四舍五入)
    dXNext       = m_ptCircle.x + m_nStep * std::cos(m_nCircleDirection * M_PI / 180);
    dYNext       = m_ptCircle.y + m_nStep * std::sin(m_nCircleDirection * M_PI / 180);
    m_ptCircle.x = static_cast<int>(lround(dXNext));
    m_ptCircle.y = static_cast<int>(lround(dYNext));

    wxCommandEvent event(BubblesPanel_MovedStep);
    event.SetEventObject(this);
    GetEventHandler()->ProcessEvent(event);

    Refresh();
}

控制圆的移动逻辑。首先,检查圆是否已经超出控件的边界。接着,使用三角函数计算圆的下一个位置。如果圆即将超出控件的边界,则更改其方向(反弹)。最后,发送一个 BubblesPanel_MovedStep 事件并重新绘制控件。

  • 检查超出边界
    1. 首先,函数检查圆的位置是否已经超出控件的边界,如果是,就会把圆置于边界。
    2. 这是一个基本的边界处理,确保圆始终在控件内。
  • 计算下一步的位置
    1. 使用三角函数计算圆的下一个位置。在这里,使用的是 std::cosstd::sin 函数。
    2. 通过乘以步长(m_nStep)来确定圆应该移动的距离。
    3. m_nCircleDirection 表示圆的移动方向,以度为单位。
  • 处理边界反弹
    1. 如果计算出的下一个位置使圆超出控件的边界,函数将更改圆的方向使其反弹。
    2. 这是通过调整 m_nCircleDirection 实现的。
  • 设置新位置
    1. 再次使用三角函数计算并确定圆的实际下一步的位置。
    2. 使用 lround 四舍五入函数来确保坐标为整数。
  • 发送事件
    1. 创建一个新的 BubblesPanel_MovedStep 事件。
    2. 使用 GetEventHandler()->ProcessEvent(event) 发送事件,通知外部代码圆已经移动。
  • 刷新控件
    1. 使用 Refresh() 函数告诉wxWidgets,该控件需要重绘。
⑦ 圆位图的生成与绘制
void BubblesPanel::GenerateCircleBitmap(int w, int h)
{
    if (m_pbmpCircle && m_pbmpCircle->GetSize() != wxSize(w, h)
        || !m_pbmpCircle)
    {
        delete m_pbmpCircle;
        m_pbmpCircle = new wxBitmap(w, h, 32);
    }

    RepaintCircleBmp();
}

void BubblesPanel::RepaintCircleBmp()
{
    wxMemoryDC dcBmp(*m_pbmpCircle);
    dcBmp.SetBackground(*wxTRANSPARENT_BRUSH);
    dcBmp.Clear();
    dcBmp.SetPen(*wxTRANSPARENT_PEN);
    dcBmp.SetBrush(m_clrCircle);

    int w = m_pbmpCircle->GetWidth();
    int h = m_pbmpCircle->GetHeight();
    dcBmp.DrawCircle(w / 2, h / 2, w / 2);
}

  • GenerateCircleBitmap:根据给定的宽度和高度生成或重新生成圆的位图。
    • 检查位图的尺寸:
      如果当前位图的大小与给定的不符,或者位图尚未创建,则删除现有的位图并创建一个新的位图。
    • 绘制圆:
      调用 RepaintCircleBmp 函数重新绘制圆。
  • RepaintCircleBmp:使用给定的颜色在内存中重新绘制圆。
    • 设置内存设备上下文(DC):
      1. 使用 wxMemoryDC,这允许您在位图上绘图,而不是直接在屏幕上。
      2. 设置背景为透明,并清除任何先前的绘图。
    • 设置画笔和刷子:
      1. 使用 wxTRANSPARENT_PEN 使圆没有边框。
      2. 设置刷子的颜色为 m_clrCircle,这是圆的颜色。
    • 绘制圆:
      1. 使用 DrawCircle 函数绘制圆。
      2. 圆的中心是位图的中心,半径是位图宽度的一半。

四、完整代码(包含完整注释)

1. CMakeLists.txt

cmake_minimum_required(VERSION 3.26)
project(bubbles)

set(CMAKE_CXX_STANDARD 20)
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY bin)
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY bin)

include(cmake/Add_wxWidgets.cmake)

find_package(wxWidgets REQUIRED COMPONENTS core base)

add_executable(bubbles main.cpp mainwin.cpp ui_mainwin.cpp bubblespanel.cpp)
target_link_libraries(bubbles PRIVATE wx::core wx::base)

2. cmake/Add_wxWidgets.cmake

include(cmake/CPM.cmake)
CPMAddPackage(
  NAME wxWidgets
  GITHUB_REPOSITORY wxWidgets/wxWidgets
  VERSION 3.2.2.1
  OPTIONS "wxBUILD_SHARED ON"  #"wxBUILD_TOOLKIT gtk3"
)

3. cmake/CPM.cmake

点这里下载我使用的版本(GitHub链接)~~

4. main.cpp

#include <wx/wx.h>
#include "mainwin.h"

class MyApp : public wxApp
{
public:
   bool OnInit() override  /*此函数为入口函数,必须重写*/
   {
       MainWin *frame = new MainWin();
       frame->Show();
       frame->Center();  // 主窗口居中显示
       return true;
   }
};

wxIMPLEMENT_APP(MyApp); // NOLINT

5. ui_mainwin.h(wxFormBuilder 生成)

///
// C++ code generated with wxFormBuilder (version 3.10.1-370-gc831f1f7-dirty)
// http://www.wxformbuilder.org/
//
// PLEASE DO *NOT* EDIT THIS FILE!
///

#pragma once

#include <wx/artprov.h>
#include <wx/xrc/xmlres.h>
#include "bubblespanel.h"
#include <wx/gdicmn.h>
#include <wx/font.h>
#include <wx/colour.h>
#include <wx/settings.h>
#include <wx/string.h>
#include <wx/stattext.h>
#include <wx/clrpicker.h>
#include <wx/sizer.h>
#include <wx/textctrl.h>
#include <wx/valtext.h>
#include <wx/button.h>
#include <wx/bitmap.h>
#include <wx/image.h>
#include <wx/icon.h>
#include <wx/panel.h>

///

namespace UI
{

    ///
    /// Class MainWin
    ///
    class MainWin : public wxPanel
    {
    private:

    protected:
        wxPanel *ctrlPanel;
        wxStaticText *m_staticText1;
        wxStaticText *m_staticText11;
        wxStaticText *m_staticText8;
        wxStaticText *m_staticText9;
        wxStaticText *m_staticText12;
        wxStaticText *m_staticText13;

    public:
        BubblesPanel *m_bubblesPanel;
        wxColourPickerCtrl *m_clr;
        wxTextCtrl *m_textX;
        wxTextCtrl *m_textY;
        wxTextCtrl *m_textStep;
        wxTextCtrl *m_textDirect;
        wxButton *m_btnAction;
        wxString m_strVtrX;
        wxString m_strVtrY;
        wxString m_strVtrStep;
        wxString m_strVtrDirect;

        MainWin(wxWindow *parent, wxWindowID id = wxID_ANY, const wxPoint &pos = wxDefaultPosition, const wxSize &size = wxSize(1024, 768), long style = wxTAB_TRAVERSAL, const wxString &name = wxEmptyString);

        ~MainWin();
    };

}  // namespace UI

6. ui_mainwin.cpp(wxFormBuilder 生成)

///
// C++ code generated with wxFormBuilder (version 3.10.1-370-gc831f1f7-dirty)
// http://www.wxformbuilder.org/
//
// PLEASE DO *NOT* EDIT THIS FILE!
///

#include "ui_mainwin.h"

///
using namespace UI;

MainWin::MainWin(wxWindow *parent, wxWindowID id, const wxPoint &pos, const wxSize &size, long style, const wxString &name) :
    wxPanel(parent, id, pos, size, style, name)
{
    wxBoxSizer *bSizer1;
    bSizer1 = new wxBoxSizer(wxVERTICAL);

    m_bubblesPanel = new BubblesPanel(this, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxBORDER_THEME);
    bSizer1->Add(m_bubblesPanel, 1, wxALL | wxEXPAND, 5);

    ctrlPanel = new wxPanel(this, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxTAB_TRAVERSAL);
    wxBoxSizer *bSizer3;
    bSizer3 = new wxBoxSizer(wxHORIZONTAL);

    bSizer3->SetMinSize(wxSize(-1, 150));
    wxBoxSizer *bSizer7;
    bSizer7 = new wxBoxSizer(wxVERTICAL);

    wxBoxSizer *bSizer31;
    bSizer31 = new wxBoxSizer(wxHORIZONTAL);

    m_staticText1 = new wxStaticText(ctrlPanel, wxID_ANY, wxT("颜色"), wxDefaultPosition, wxDefaultSize, wxALIGN_RIGHT);
    m_staticText1->Wrap(-1);
    m_staticText1->SetMinSize(wxSize(120, -1));

    bSizer31->Add(m_staticText1, 0, wxALL, 5);

    m_clr = new wxColourPickerCtrl(ctrlPanel, wxID_ANY, wxSystemSettings::GetColour(wxSYS_COLOUR_WINDOW), wxDefaultPosition, wxDefaultSize, wxCLRP_DEFAULT_STYLE);
    bSizer31->Add(m_clr, 0, wxALL, 5);


    bSizer7->Add(bSizer31, 0, wxBOTTOM | wxEXPAND, 5);

    wxBoxSizer *bSizer311;
    bSizer311 = new wxBoxSizer(wxHORIZONTAL);

    m_staticText11 = new wxStaticText(ctrlPanel, wxID_ANY, wxT("位置"), wxDefaultPosition, wxDefaultSize, wxALIGN_RIGHT);
    m_staticText11->Wrap(-1);
    m_staticText11->SetMinSize(wxSize(120, -1));

    bSizer311->Add(m_staticText11, 0, wxALL, 5);

    m_staticText8 = new wxStaticText(ctrlPanel, wxID_ANY, wxT("X"), wxDefaultPosition, wxDefaultSize, 0);
    m_staticText8->Wrap(-1);
    bSizer311->Add(m_staticText8, 0, wxALL, 5);

    m_textX = new wxTextCtrl(ctrlPanel, wxID_ANY, wxEmptyString, wxDefaultPosition, wxDefaultSize, 0);
    m_textX->SetValidator(wxTextValidator(wxFILTER_NUMERIC, &m_strVtrX));

    bSizer311->Add(m_textX, 0, wxEXPAND, 5);


    bSizer311->Add(20, 0, 0, wxEXPAND, 5);

    m_staticText9 = new wxStaticText(ctrlPanel, wxID_ANY, wxT("Y"), wxDefaultPosition, wxDefaultSize, 0);
    m_staticText9->Wrap(-1);
    bSizer311->Add(m_staticText9, 0, wxALL, 5);

    m_textY = new wxTextCtrl(ctrlPanel, wxID_ANY, wxEmptyString, wxDefaultPosition, wxDefaultSize, 0);
    bSizer311->Add(m_textY, 0, wxEXPAND, 5);


    bSizer311->Add(0, 0, 3, wxEXPAND, 5);


    bSizer7->Add(bSizer311, 0, wxBOTTOM | wxEXPAND, 5);

    wxBoxSizer *bSizer312;
    bSizer312 = new wxBoxSizer(wxHORIZONTAL);

    m_staticText12 = new wxStaticText(ctrlPanel, wxID_ANY, wxT("步长"), wxDefaultPosition, wxDefaultSize, wxALIGN_RIGHT);
    m_staticText12->Wrap(-1);
    m_staticText12->SetMinSize(wxSize(120, -1));

    bSizer312->Add(m_staticText12, 0, wxALL, 5);

    m_textStep = new wxTextCtrl(ctrlPanel, wxID_ANY, wxEmptyString, wxDefaultPosition, wxDefaultSize, 0);
    m_textStep->SetValidator(wxTextValidator(wxFILTER_NUMERIC, &m_strVtrStep));

    bSizer312->Add(m_textStep, 0, wxEXPAND, 5);


    bSizer7->Add(bSizer312, 0, wxBOTTOM | wxEXPAND, 5);

    wxBoxSizer *bSizer313;
    bSizer313 = new wxBoxSizer(wxHORIZONTAL);

    m_staticText13 = new wxStaticText(ctrlPanel, wxID_ANY, wxT("方向"), wxDefaultPosition, wxDefaultSize, wxALIGN_RIGHT);
    m_staticText13->Wrap(-1);
    m_staticText13->SetMinSize(wxSize(120, -1));

    bSizer313->Add(m_staticText13, 0, wxALL, 5);

    m_textDirect = new wxTextCtrl(ctrlPanel, wxID_ANY, wxEmptyString, wxDefaultPosition, wxDefaultSize, 0);
    m_textDirect->SetValidator(wxTextValidator(wxFILTER_NUMERIC, &m_strVtrDirect));

    bSizer313->Add(m_textDirect, 0, wxEXPAND, 5);


    bSizer7->Add(bSizer313, 0, wxBOTTOM | wxEXPAND, 5);


    bSizer3->Add(bSizer7, 1, wxEXPAND, 5);

    m_btnAction = new wxButton(ctrlPanel, wxID_ANY, wxT("开始"), wxDefaultPosition, wxDefaultSize, 0);
    m_btnAction->SetMinSize(wxSize(-1, 50));

    bSizer3->Add(m_btnAction, 1, wxALIGN_CENTER | wxALL, 5);


    ctrlPanel->SetSizer(bSizer3);
    ctrlPanel->Layout();
    bSizer3->Fit(ctrlPanel);
    bSizer1->Add(ctrlPanel, 0, wxEXPAND | wxALL, 5);


    this->SetSizer(bSizer1);
    this->Layout();
}

MainWin::~MainWin()
{
}

7. mainwin.h

#ifndef BUBBLES_MAINWIN_H
#define BUBBLES_MAINWIN_H

#include <wx/wx.h>

namespace UI
{
    class MainWin;
}

class MainWin : public wxFrame
{
public:
    explicit MainWin();
    ~MainWin() override;

protected:
    /// 事件:圆动画控件中的圆移动了一步
    void BubblesPanelMovedStepEvent(wxCommandEvent &e);

    /// 绑定所有事件
    void BindAll();

    /// 刷新控件的值
    void FlushCtrlsValue();

private:
    UI::MainWin *ui;

    wxDECLARE_EVENT_TABLE();
};

#endif  //! BUBBLES_MAINWIN_H

8. mainwin.cpp

#include "mainwin.h"
#include "ui_mainwin.h"


wxBEGIN_EVENT_TABLE(MainWin, wxFrame)
    EVT_COMMAND(wxID_ANY, BubblesPanel_MovedStep, MainWin::BubblesPanelMovedStepEvent)
wxEND_EVENT_TABLE()


MainWin::MainWin() :
    wxFrame(nullptr, wxID_ANY, wxT("泡泡"), wxDefaultPosition, {1024, 768}),
    ui(new UI::MainWin(this))
{
    SetClientSize(ui->GetSize());
    FlushCtrlsValue();
    BindAll();
}


MainWin::~MainWin()
{
    delete ui;
}


void MainWin::BubblesPanelMovedStepEvent(wxCommandEvent &e)
{
    FlushCtrlsValue();
}


void MainWin::BindAll()
{
    ui->m_clr->Bind(wxEVT_COLOURPICKER_CHANGED, [this](wxColourPickerEvent &e) {
        ui->m_bubblesPanel->SetCircleColor(e.GetColour());
    });
    ui->m_textX->Bind(wxEVT_TEXT, [this](wxCommandEvent &e) {
        if (ui->m_textX->IsEnabled())
            ui->m_bubblesPanel->SetCirclePosition({wxAtoi(ui->m_textX->GetValue()), wxAtoi(ui->m_textX->GetValue())});
    });
    ui->m_textY->Bind(wxEVT_TEXT, [this](wxCommandEvent &e) {
        if (ui->m_textY->IsEnabled())
            ui->m_bubblesPanel->SetCirclePosition({wxAtoi(ui->m_textX->GetValue()), wxAtoi(ui->m_textX->GetValue())});
    });
    ui->m_textStep->Bind(wxEVT_TEXT, [this](wxCommandEvent &e) {
        if (ui->m_textStep->IsEnabled())
            ui->m_bubblesPanel->SetCircleStep(wxAtoi(ui->m_textStep->GetValue()));
    });
    ui->m_textDirect->Bind(wxEVT_TEXT, [this](wxCommandEvent &e) {
        if (ui->m_textDirect->IsEnabled())
            ui->m_bubblesPanel->SetCircleDirection(wxAtoi(ui->m_textDirect->GetValue()));
    });
    ui->m_btnAction->Bind(wxEVT_BUTTON, [this](wxCommandEvent &e) {
        if (ui->m_btnAction->GetLabel() == wxT("开始")) {
            ui->m_textX->Disable();
            ui->m_textY->Disable();
            ui->m_textStep->Disable();
            ui->m_textDirect->Disable();
            ui->m_bubblesPanel->StartAnimation(10);
            ui->m_btnAction->SetLabel(wxT("停止"));
        }
        else {
            ui->m_bubblesPanel->StopAnimation();
            ui->m_textX->Enable();
            ui->m_textY->Enable();
            ui->m_textStep->Enable();
            ui->m_textDirect->Enable();
            ui->m_btnAction->SetLabel(wxT("开始"));
        }
    });
}


void MainWin::FlushCtrlsValue()
{
    ui->m_clr->SetColour(ui->m_bubblesPanel->GetCircleColor());
    ui->m_textX->SetValue(wxString::Format("%d", ui->m_bubblesPanel->GetCirclePosition().x));
    ui->m_textY->SetValue(wxString::Format("%d", ui->m_bubblesPanel->GetCirclePosition().y));
    ui->m_textStep->SetValue(wxString::Format("%d", ui->m_bubblesPanel->GetCircleStep()));
    ui->m_textDirect->SetValue(wxString::Format("%d", ui->m_bubblesPanel->GetCircleDirection()));
}

9. bubblespanel.h

#ifndef BUBBLES_BUBBLESPANEL_H
#define BUBBLES_BUBBLESPANEL_H

#include <wx/wx.h>

/// 事件声明:圆移动了一步
wxDECLARE_EVENT(BubblesPanel_MovedStep, wxCommandEvent);

class BubblesPanel : public wxWindow
{
public:
    explicit BubblesPanel(wxWindow *parent, wxWindowID id = wxID_ANY,
                          const wxPoint &pos = wxDefaultPosition, const wxSize &size = wxDefaultSize,
                          long style = wxTAB_TRAVERSAL, const wxString &name = wxPanelNameStr);
    ~BubblesPanel() override;

    /**
     * @brief 开始动画
     * @param[in] nInterval 动画的时间间隔,单位为毫秒
     */
    void StartAnimation(int nInterval);

    /// 停止动画
    void StopAnimation();

    /// 设置圆的位置
    void SetCirclePosition(const wxPoint &pt);

    /// 设置圆的大小
    void SetCircleSize(int w, int h);

    /// 设置圆的颜色
    void SetCircleColor(const wxColour &clr);

    /// 设置圆的步长
    void SetCircleStep(int nStep);

    /// 设置圆的方向
    void SetCircleDirection(int nDirection);

    /// 获取圆的位置
    [[nodiscard]] wxPoint GetCirclePosition() const { return m_ptCircle; }

    /// 获取圆的大小
    [[nodiscard]] wxSize GetCircleSize() const { return m_pbmpCircle->GetSize(); }

    /// 获取圆的颜色
    [[nodiscard]] wxColour GetCircleColor() const { return m_clrCircle; }

    /// 获取圆的步长
    [[nodiscard]] int GetCircleStep() const { return m_nStep; }

    /// 获取圆的方向
    [[nodiscard]] int GetCircleDirection() const { return m_nCircleDirection; }

protected:
    void PaintEvent(wxPaintEvent &e);
    void TimerEvent(wxTimerEvent &e);

    /**
     * @brief 圆移动一步
     * @note
     * 1. 判断圆的下一步是否已经到达控件边界,如果是,则会反弹,改变方向
     * 2. 根据圆的位置、步长、方向,计算圆下一步的位置
     */
    void MoveCircle();

    /// 生成圆的位图
    void GenerateCircleBitmap(int w, int h);

    /// 重新绘制圆的位图
    void RepaintCircleBmp();

    wxTimer m_timer;         ///< 定时器,用于动画
    wxBitmap *m_pbmpCircle;  ///< 圆的位图
    wxColour m_clrCircle;    ///< 圆的颜色
    wxPoint m_ptCircle;      ///< 圆的位置
    int m_nStep;             ///< 圆的步长,每次移动的距离,单位像素
    int m_nCircleDirection;  ///< 圆下一步的方向,360度,0度为正右方,顺时针增加

    wxDECLARE_EVENT_TABLE();
};

#endif  //! BUBBLES_BUBBLESPANEL_H

10. bubblespanel.cpp

#include <wx/dcbuffer.h>
#include "bubblespanel.h"

/// 事件定义:圆移动了一步
wxDEFINE_EVENT(BubblesPanel_MovedStep, wxCommandEvent);

wxBEGIN_EVENT_TABLE(BubblesPanel, wxWindow)
    EVT_PAINT(BubblesPanel::PaintEvent)
    EVT_TIMER(wxID_ANY, BubblesPanel::TimerEvent)
wxEND_EVENT_TABLE()


BubblesPanel::BubblesPanel(wxWindow *parent, wxWindowID id, const wxPoint &pos,
                           const wxSize &size, long style, const wxString &name) :
    wxWindow(parent, id, pos, size, style, name),
    m_timer(this, wxID_ANY),
    m_pbmpCircle(nullptr),
    m_clrCircle(123, 209, 234),
    m_ptCircle(100, 100),
    m_nStep(5),
    m_nCircleDirection(73)
{
    GenerateCircleBitmap(100, 100);
}


BubblesPanel::~BubblesPanel()
{
    delete m_pbmpCircle;
    m_pbmpCircle = nullptr;
}


void BubblesPanel::StartAnimation(int nInterval)
{
    m_timer.Start(nInterval);
}


void BubblesPanel::StopAnimation()
{
    m_timer.Stop();
}


void BubblesPanel::SetCirclePosition(const wxPoint &pt)
{
    m_ptCircle = pt;
    Refresh();
}


void BubblesPanel::SetCircleSize(int w, int h)
{
    GenerateCircleBitmap(w, h);
    Refresh();
}


void BubblesPanel::SetCircleColor(const wxColour &clr)
{
    m_clrCircle = clr;
    RepaintCircleBmp();
    Refresh();
}


void BubblesPanel::SetCircleStep(int nStep)
{
    m_nStep = nStep;
}


void BubblesPanel::SetCircleDirection(int nDirection)
{
    m_nCircleDirection = nDirection;
}


void BubblesPanel::PaintEvent(wxPaintEvent &e)
{
    wxPaintDC dc(this);
    dc.SetBackground(*wxTRANSPARENT_BRUSH);
    dc.Clear();
    dc.DrawBitmap(*m_pbmpCircle, m_ptCircle);
}


void BubblesPanel::TimerEvent(wxTimerEvent &e)
{
    MoveCircle();
}


void BubblesPanel::MoveCircle()
{
    // 如果球已经超出边缘,则直接回到边缘
    if (m_ptCircle.x < 0)
        m_ptCircle.x = 0;
    if (m_ptCircle.x > GetClientSize().GetWidth() - m_pbmpCircle->GetWidth())
        m_ptCircle.x = GetClientSize().GetWidth() - m_pbmpCircle->GetWidth();
    if (m_ptCircle.y < 0)
        m_ptCircle.y = 0;
    if (m_ptCircle.y > GetClientSize().GetHeight() - m_pbmpCircle->GetHeight())
        m_ptCircle.y = GetClientSize().GetHeight() - m_pbmpCircle->GetHeight();

    double dXNext = m_ptCircle.x + m_nStep * std::cos(m_nCircleDirection * M_PI / 180);  // 圆的下一步的x坐标
    double dYNext = m_ptCircle.y + m_nStep * std::sin(m_nCircleDirection * M_PI / 180);  // 圆的下一步的y坐标

    // 判断圆的下一步是否已经到达控件边界,如果是,则会反弹,改变方向
    if (dXNext < 0 || dXNext > GetClientSize().GetWidth() - m_pbmpCircle->GetWidth())
        m_nCircleDirection = 180 - m_nCircleDirection;
    if (dYNext < 0 || dYNext > GetClientSize().GetHeight() - m_pbmpCircle->GetHeight())
        m_nCircleDirection = 360 - m_nCircleDirection;

    // 根据圆的位置、步长、方向,计算圆下一步的位置(四舍五入)
    dXNext       = m_ptCircle.x + m_nStep * std::cos(m_nCircleDirection * M_PI / 180);
    dYNext       = m_ptCircle.y + m_nStep * std::sin(m_nCircleDirection * M_PI / 180);
    m_ptCircle.x = static_cast<int>(lround(dXNext));
    m_ptCircle.y = static_cast<int>(lround(dYNext));

    wxCommandEvent event(BubblesPanel_MovedStep);
    event.SetEventObject(this);
    GetEventHandler()->ProcessEvent(event);  // 发送事件

    // 重绘控件
    Refresh();
}


void BubblesPanel::GenerateCircleBitmap(int w, int h)
{
    if (m_pbmpCircle && m_pbmpCircle->GetSize() != wxSize(w, h)
        || !m_pbmpCircle)
    {
        delete m_pbmpCircle;
        m_pbmpCircle = new wxBitmap(w, h, 32);
    }

    // 重新绘制圆
    RepaintCircleBmp();
}


void BubblesPanel::RepaintCircleBmp()
{
    wxMemoryDC dcBmp(*m_pbmpCircle);
    dcBmp.SetBackground(*wxTRANSPARENT_BRUSH);
    dcBmp.Clear();
    dcBmp.SetPen(*wxTRANSPARENT_PEN);
    dcBmp.SetBrush(m_clrCircle);

    int w = m_pbmpCircle->GetWidth();
    int h = m_pbmpCircle->GetHeight();
    dcBmp.DrawCircle(w / 2, h / 2, w / 2);
}

五、要点分析

1. 泡泡移动与反弹逻辑

  • 使用了三角函数来计算泡泡的移动方向和步长。这使得泡泡能够按照指定的方向和速度进行移动。
  • 当泡泡接触到 panel 的边界时,使用条件判断和方向调整来实现反弹效果。这是通过修改 m_nCircleDirection 来达到的。

2. 泡泡的动态属性修改

  • 通过函数如 SetCirclePositionSetCircleSizeSetCircleColorSetCircleStepSetCircleDirection,我们可以实时更改泡泡的属性。每当修改了泡泡的属性,都会调用 Refresh() 来重新绘制 panel,以反映更改。

3. 使用wxWidgets绘制功能

  • 使用 wxBitmapwxMemoryDC 来在内存中绘制泡泡,然后再将其绘制到 panel 上。这为实时渲染提供了很好的效率。
  • 绘制操作是在 RepaintCircleBmp 函数中完成的,其中涉及设置适当的画笔和刷子,以及实际的绘图操作。

4. 事件驱动的动画

  • 使用 wxTimer 来驱动泡泡的移动动画。定时器会定期触发 TimerEvent,该事件则调用 MoveCircle 来移动泡泡。
  • 当泡泡移动时,会触发 BubblesPanel_MovedStep 事件,通知其他部分泡泡的移动状态。

5. 界面交互

  • 使用一组控制元素(按钮和文本框的组合),允许用户更改泡泡的颜色、位置、移动步长和方向。
  • 控制泡泡跳动的开始和结束按钮分别连接到 StartAnimationStopAnimation 函数,这两个函数分别开始和停止 wxTimer,控制动画的播放和暂停。

6. 实时状态显示

  • 通过改变相应文本框的内容来实时显示泡泡的当前属性,如位置、颜色、移动步长和方向等。

结论

这个项目是一个基于 wxWidgets 的动态图形应用,它展示了如何使用该库的绘图、定时器和事件处理功能来实现具有用户交互的动画效果。其关键点包括泡泡的移动与反弹逻辑、动态属性修改、内存绘图、事件驱动的动画和用户界面交互。


【wxWidgets实战】跳动的泡泡 已经告一段落,感谢大家的关注与支持!如有不当之处,敬请指正。在此,我衷心祝愿大家工作顺利,事业蒸蒸日上,每一天都充满新的收获与喜悦!

评论 5
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Xiao_Ley

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值