1.4 作业A*算法C++实现

demo_node.cpp

main函数解读

int main(int argc, char** argv)
{
    ros::init(argc, argv, "demo_node");
    ros::NodeHandle nh("~");

    //每秒接收一次点云数据,并调用rcvPointCloudCallBack回调函数进行处理
    _map_sub  = nh.subscribe( "map",       1, rcvPointCloudCallBack );
    //接收目标点,并进行路径搜索
    _pts_sub  = nh.subscribe( "waypoints", 1, rcvWaypointsCallback );

    //创建三个发布数据
    //发布网格地图可视化数据。
    _grid_map_vis_pub             = nh.advertise<sensor_msgs::PointCloud2>("grid_map_vis", 1);
    //发布寻路路径可视化数据
    _grid_path_vis_pub            = nh.advertise<visualization_msgs::Marker>("grid_path_vis", 1);
    //发布已访问节点的可视化数据
    _visited_nodes_vis_pub        = nh.advertise<visualization_msgs::Marker>("visited_nodes_vis",1);

    //配置参数
    //从接收的点云数据中读取地图相关参数
    nh.param("map/cloud_margin",  _cloud_margin, 0.0);
    //分辨率
    nh.param("map/resolution",    _resolution,   0.2);
    //尺寸
    nh.param("map/x_size",        _x_size, 50.0);
    nh.param("map/y_size",        _y_size, 50.0);
    nh.param("map/z_size",        _z_size, 5.0 );
    
    //起始点坐标
    nh.param("planning/start_x",  _start_pt(0),  0.0);
    nh.param("planning/start_y",  _start_pt(1),  0.0);
    nh.param("planning/start_z",  _start_pt(2),  0.0);

    //地图最小点,定义为正方体左下角
    _map_lower << - _x_size/2.0, - _y_size/2.0,     0.0;
    //地图最大点,定义为正方体右上角
    _map_upper << + _x_size/2.0, + _y_size/2.0, _z_size;
    //分辨率的倒数,_inv_resolution的值可以用于将像素坐标转换为世界坐标
    //例如,如果 _resolution 的值为 10,则 _inv_resolution 的值为 0.1
    //世界坐标为 (x坐标 * _inv_resolution, y坐标 * _inv_resolution)
    //如果有点的像素坐标为 (100, 200),则世界坐标或栅格地图id(10,20)
    _inv_resolution = 1.0 / _resolution;
    
    //像素坐标最大值转换为世界坐标。
    _max_x_id = (int)(_x_size * _inv_resolution);
    _max_y_id = (int)(_y_size * _inv_resolution);
    _max_z_id = (int)(_z_size * _inv_resolution);

    //初始化地图
    _astar_path_finder  = new AstarPathFinder();
    _astar_path_finder  -> initGridMap(_resolution, _map_lower, _map_upper, _max_x_id, _max_y_id, _max_z_id);

    _jps_path_finder    = new JPSPathFinder();
    _jps_path_finder    -> initGridMap(_resolution, _map_lower, _map_upper, _max_x_id, _max_y_id, _max_z_id);
    
    ros::Rate rate(100);
    bool status = ros::ok();
    while(status) 
    {
        ros::spinOnce();  
        //检查 ROS 系统是否应该继续运行
        status = ros::ok();
        rate.sleep();
    }

    delete _astar_path_finder;
    delete _jps_path_finder;
    return 0;
}

简要过程:程序接收地图基本信息后进行地图初始化,利用ROS subscriber回调函数进行障碍物设置(_map_sub)和路径查找(_pts_sub)。

rcvPointCloudCallBack填充障碍物

//点云数据处理
void rcvPointCloudCallBack(const sensor_msgs::PointCloud2 & pointcloud_map)
{   
    //检查地图状态:首先检查变量_has_map的值,如果地图已经建立完毕(_has_map为true),则直接返回,不再处理新的点云数据。
    if(_has_map ) return;

    // cloud存储点云数据,用于建立网格地图的障碍物信息
    pcl::PointCloud<pcl::PointXYZ> cloud;
    //cloud_vis存储经过处理的点云数据,用于可视化网格地图中的障碍物
    pcl::PointCloud<pcl::PointXYZ> cloud_vis;
    sensor_msgs::PointCloud2 map_vis;

    //使用pcl::fromROSMsg将ROS点云消息pointcloud_map转换为PCL点云cloud
    pcl::fromROSMsg(pointcloud_map, cloud);
    
    //检查cloud.points.size(),如果点云为空,则直接返回,不进行后续处理。
    if( (int)cloud.points.size() == 0 ) return;

    //遍历点云数据:使用循环遍历点云中的每个点pt
    pcl::PointXYZ pt;
    for (int idx = 0; idx < (int)cloud.points.size(); idx++)
    {    
        pt = cloud.points[idx];        

        //将点pt设置为障碍物,调用A*和JPS路径规划算法的setObs函数将其添加到对应的网格地图中,标记为不可通行区域。
        _astar_path_finder->setObs(pt.x, pt.y, pt.z);
        _jps_path_finder->setObs(pt.x, pt.y, pt.z);

        //将原始点云中的点在网格中进行四舍五入,保证落在网格整数坐标上
        //coordRounding 函数,该函数接受一个三维坐标 Vector3d 作为输入,并返回一个四舍五入后的坐标 Vector3d
        Vector3d cor_round = _astar_path_finder->coordRounding(Vector3d(pt.x, pt.y, pt.z));
        pt.x = cor_round(0);
        pt.y = cor_round(1);
        pt.z = cor_round(2);
        //将元素添加到向量末尾
        cloud_vis.points.push_back(pt);
    }
    //更新点云信息,设置cloud_vis的宽度、高度和深度
    cloud_vis.width    = cloud_vis.points.size();
    cloud_vis.height   = 1;
    cloud_vis.is_dense = true;

    //转换点云格式,将处理后的点云cloud_vis转换为ROS点云消息map_vis
    pcl::toROSMsg(cloud_vis, map_vis);

    //设置map_vis的头文件frame_id为"/world"
    map_vis.header.frame_id = "/world";
    _grid_map_vis_pub.publish(map_vis);

    _has_map = true;
}

rcvWaypointsCallback回调函数发现路径

void rcvWaypointsCallback(const nav_msgs::Path & wp)
{     
    if( wp.poses[0].pose.position.z < 0.0 || _has_map == false )
        return;

    Vector3d target_pt;
    //目标点坐标变换
    target_pt << wp.poses[0].pose.position.x,
                 wp.poses[0].pose.position.y,
                 wp.poses[0].pose.position.z;

    ROS_INFO("[node] receive the planning target");
    //路径搜索
    pathFinding(_start_pt, target_pt); 
}

pathFinding函数

void pathFinding(const Vector3d start_pt, const Vector3d target_pt)
{
    //调用A*算法,找到路径
    _astar_path_finder->AstarGraphSearch(start_pt, target_pt);

    //从终点开始回溯获得路径点集合
    auto grid_path     = _astar_path_finder->getPath();
    auto visited_nodes = _astar_path_finder->getVisitedNodes();

    //路径可视化
    visGridPath (grid_path, false);
    visVisitedNode(visited_nodes);

    //Reset map for next call
    _astar_path_finder->resetUsedGrids();

    //_use_jps = 0 -> Do not use JPS
    //_use_jps = 1 -> Use JPS
    //是否使用JPS算法
#define _use_jps 1
#if _use_jps
    {
        //Call JPS to search for a path
        _jps_path_finder -> JPSGraphSearch(start_pt, target_pt);

        //Retrieve the path
        auto grid_path     = _jps_path_finder->getPath();
        auto visited_nodes = _jps_path_finder->getVisitedNodes();

        //Visualize the result
        visGridPath   (grid_path, _use_jps);
        visVisitedNode(visited_nodes);

        //Reset map for next call
        _jps_path_finder->resetUsedGrids();
    }
#endif
}

Astar_searcher.cpp

地图初始化

void AstarPathFinder::initGridMap(double _resolution, Vector3d global_xyz_l, Vector3d global_xyz_u, int max_x_id, int max_y_id, int max_z_id)
{   
    //地图最小边界坐标
    gl_xl = global_xyz_l(0);
    gl_yl = global_xyz_l(1);
    gl_zl = global_xyz_l(2);

    地图最大边界坐标
    gl_xu = global_xyz_u(0);
    gl_yu = global_xyz_u(1);
    gl_zu = global_xyz_u(2);
    
    //方向网格单元编号的最大值
    GLX_SIZE = max_x_id;
    GLY_SIZE = max_y_id;
    GLZ_SIZE = max_z_id;
    //地图在yz平面的网格单元数
    GLYZ_SIZE  = GLY_SIZE * GLZ_SIZE;
    //地图的总网格单元数
    GLXYZ_SIZE = GLX_SIZE * GLYZ_SIZE;

    //分辨率
    resolution = _resolution;
    inv_resolution = 1.0 / _resolution;    

    //初始化地图数据存储内存
    data = new uint8_t[GLXYZ_SIZE];
    //data: 这是要被初始化的内存块的指针
    //0: 这是要设置的字节值
    //GLXYZ_SIZE * sizeof(uint8_t): 这是要被初始化的字节数
    memset(data, 0, GLXYZ_SIZE * sizeof(uint8_t));

    //地图使用三重指针,存储地图信息
    //分配一个长度为GLX_SIZE的指针数组
    GridNodeMap = new GridNodePtr ** [GLX_SIZE];
    for(int i = 0; i < GLX_SIZE; i++){
        GridNodeMap[i] = new GridNodePtr * [GLY_SIZE];
        for(int j = 0; j < GLY_SIZE; j++){
            GridNodeMap[i][j] = new GridNodePtr [GLZ_SIZE];
            for( int k = 0; k < GLZ_SIZE;k++){
                //Vector3i是Eigen库中一个三维整型向量类型,可以存储三个整数值,即栅格id
                Vector3i tmpIdx(i,j,k);
                //gridIndex2coord函数的功能是将网格单元索引转换为实际坐标
                //pos是一个Vector3d类型的变量,存储了该网格单元的真实世界坐标
                Vector3d pos = gridIndex2coord(tmpIdx);
                GridNodeMap[i][j][k] = new GridNode(tmpIdx, pos);
            }
        }
    }
}

地图信息存储类型

typedef GridNode* GridNodePtr;

struct GridNode
{   
    //表示网格单元的状态  
    int id;        // 1--> open set, -1 --> closed set
    //存储网格单元的真实世界坐标
    Eigen::Vector3d coord; 
    //存储当前网格单元扩展方向的索引。
    Eigen::Vector3i dir;   // direction of expanding
    //存储网格单元在三维地图中的索引
    Eigen::Vector3i index;
	
	//代价函数
    double gScore, fScore;
    //父节点
    GridNodePtr cameFrom;
    std::multimap<double, GridNodePtr>::iterator nodeMapIt;

    GridNode(Eigen::Vector3i _index, Eigen::Vector3d _coord){  
		id = 0;
		index = _index;
		coord = _coord;
		dir   = Eigen::Vector3i::Zero();

		gScore = inf;
		fScore = inf;
		cameFrom = NULL;
    }

    GridNode(){};
    ~GridNode(){};
};

障碍物设置函数setObs

void AstarPathFinder::setObs(const double coord_x, const double coord_y, const double coord_z)
{
    //检查传入的障碍物坐标 (coord_x, coord_y, coord_z) 是否位于地图范围内
    //地图最小边界 (gl_xl, gl_yl, gl_zl) 或大于地图最大边界 (gl_xu, gl_yu, gl_zu)
    if( coord_x < gl_xl  || coord_y < gl_yl  || coord_z <  gl_zl || 
        coord_x >= gl_xu || coord_y >= gl_yu || coord_z >= gl_zu )
        return;

    int idx_x = static_cast<int>( (coord_x - gl_xl) * inv_resolution);
    int idx_y = static_cast<int>( (coord_y - gl_yl) * inv_resolution);
    int idx_z = static_cast<int>( (coord_z - gl_zl) * inv_resolution);      

    //内存中地图存储方式为切分存储,从z轴开始,再看y轴,最后看x轴
    data[idx_x * GLYZ_SIZE + idx_y * GLZ_SIZE + idx_z] = 1;
}

网格索引与世界坐标相互转换

Vector3d AstarPathFinder::gridIndex2coord(const Vector3i & index) 
{
    Vector3d pt;
    //索引的值加上0.5,再将网格单元格索引转换回坐标,就会将坐标向下取整到网格单元格的中心位置
    pt(0) = ((double)index(0) + 0.5) * resolution + gl_xl;
    pt(1) = ((double)index(1) + 0.5) * resolution + gl_yl;
    pt(2) = ((double)index(2) + 0.5) * resolution + gl_zl;

    return pt;
}

Vector3i AstarPathFinder::coord2gridIndex(const Vector3d & pt) 
{
    Vector3i idx;
    //将地图内的真实坐标pt转换为网格单元格索引
    idx <<  min( max( int( (pt(0) - gl_xl) * inv_resolution), 0), GLX_SIZE - 1),
            min( max( int( (pt(1) - gl_yl) * inv_resolution), 0), GLY_SIZE - 1),
            min( max( int( (pt(2) - gl_zl) * inv_resolution), 0), GLZ_SIZE - 1);                  
  
    return idx;
}

Eigen::Vector3d AstarPathFinder::coordRounding(const Eigen::Vector3d & coord)
{
    //先将坐标转换为网格单元格索引。然后,再将将网格单元格索引转换成坐标,达到四舍五入的效果。
    return gridIndex2coord(coord2gridIndex(coord));
}

障碍物检测

//是否在地图边界内,且是障碍物
inline bool AstarPathFinder::isOccupied(const int & idx_x, const int & idx_y, const int & idx_z) const 
{
    return  (idx_x >= 0 && idx_x < GLX_SIZE && idx_y >= 0 && idx_y < GLY_SIZE && idx_z >= 0 && idx_z < GLZ_SIZE && 
            (data[idx_x * GLYZ_SIZE + idx_y * GLZ_SIZE + idx_z] == 1));
}

//是否在地图边界内,且不是障碍物
inline bool AstarPathFinder::isFree(const int & idx_x, const int & idx_y, const int & idx_z) const 
{
    return (idx_x >= 0 && idx_x < GLX_SIZE && idx_y >= 0 && idx_y < GLY_SIZE && idx_z >= 0 && idx_z < GLZ_SIZE && 
           (data[idx_x * GLYZ_SIZE + idx_y * GLZ_SIZE + idx_z] < 1));
}

//函数重载
inline bool AstarPathFinder::isOccupied(const Eigen::Vector3i & index) const
{
    return isOccupied(index(0), index(1), index(2));
}

inline bool AstarPathFinder::isFree(const Eigen::Vector3i & index) const
{
    return isFree(index(0), index(1), index(2));
}

算法主体函数

void AstarPathFinder::AstarGraphSearch(Vector3d start_pt, Vector3d end_pt)
{   
    ros::Time time_1 = ros::Time::now();    

    //起始点和目标点的索引
    Vector3i start_idx = coord2gridIndex(start_pt);
    Vector3i end_idx   = coord2gridIndex(end_pt);
    goalIdx = end_idx;

    //起始点和目标点的坐标
    start_pt = gridIndex2coord(start_idx);
    end_pt   = gridIndex2coord(end_idx);

    //初始化GridNode结构体的指针,分别代表起始节点和目标节点
    GridNodePtr startPtr = new GridNode(start_idx, start_pt);
    GridNodePtr endPtr   = new GridNode(end_idx,   end_pt);

    //openSet是STL库中通过multimap实现的open_list
    openSet.clear();
    // currentPtr represents the node with lowest f(n) in the open_list
    GridNodePtr currentPtr  = NULL;
    GridNodePtr neighborPtr = NULL;

    //将起始节点放入open list
    startPtr -> gScore = 0; //g(n)
    startPtr -> fScore = getHeu(startPtr,endPtr); //f(n)
    startPtr -> id = 1; // 1--> open set, -1 --> closed set
    startPtr -> coord = start_pt; //起始点坐标
    openSet.insert( make_pair(startPtr -> fScore, startPtr) );
    /*
    *
    STEP 2 :  some else preparatory works which should be done before while loop
    please write your code below
    *
    *
    */
    vector<GridNodePtr> neighborPtrSets;
    vector<double> edgeCostSets;
    //初始化地图
    //resetUsedGrids();
    //将地图初始节点指向开始节点
    GridNodeMap[start_idx[0]][start_idx[1]][start_idx[2]] = startPtr;


    // this is the main loop
    while ( !openSet.empty() ){
        /*
        *
        *
        step 3: Remove the node with lowest cost function from open set to closed set
        please write your code below
        
        IMPORTANT NOTE!!!
        This part you should use the C++ STL: multimap, more details can be find in Homework description
        *
        *
        */
		//弹出f(n)最小的节点
        auto iter = openSet.begin();
        currentPtr = iter->second;
        openSet.erase(iter->first);
        Vector3i idx = currentPtr -> index;
        GridNodeMap[idx(0)][idx(1)][idx(2)] -> id = -1;

        // 检查是否到达目标
        if( currentPtr->index == goalIdx ){
            ros::Time time_2 = ros::Time::now();
            terminatePtr = currentPtr;
            //terminatePtr = GridNodeMap[idx(0)][idx(1)][idx(2)];
            ROS_WARN("[A*]{sucess}  Time in A*  is %f ms, path cost if %f m", (time_2 - time_1).toSec() * 1000.0, currentPtr->gScore * resolution );            
            return;
        }
        //拓展节点函数
        AstarGetSucc(currentPtr, neighborPtrSets, edgeCostSets);  //STEP 4: finish AstarPathFinder::AstarGetSucc yourself     

        /*
        *
        *
        STEP 5:  For all unexpanded neigbors "m" of node "n", please finish this for loop
        please write your code below
        *        
        */         
        for(int i = 0; i < (int)neighborPtrSets.size(); i++){
            /*
            *
            *
            Judge if the neigbors have been expanded
            please write your code below
            
            IMPORTANT NOTE!!!
            neighborPtrSets[i]->id = -1 : expanded, equal to this node is in close set
            neighborPtrSets[i]->id = 1 : unexpanded, equal to this node is in open set
            *        
            */
            //获取一个另据节点
            neighborPtr = neighborPtrSets[i];
            Vector3i neighbor_idx = neighborPtr -> index;
            double cross = getCross(startPtr, neighborPtr, endPtr);
            //如果该节点未被拓展
            if(neighborPtr -> id == 0){ //discover a new node, which is not in the closed set and open set
                /*
                *
                *
                STEP 6:  As for a new node, do what you need do ,and then put neighbor in open set and record it
                please write your code below
                *        
                */
                // 该节点不是障碍物
                if(isFree(neighbor_idx)){
                	//节点更新并塞入open list
                    neighborPtr -> id = 1;
                    neighborPtr -> cameFrom = GridNodeMap[currentPtr -> index(0)][currentPtr -> index(1)][currentPtr -> index(2)];
                    neighborPtr -> gScore = edgeCostSets[i] + currentPtr -> gScore;
                    neighborPtr -> fScore = neighborPtr -> gScore + getHeu(neighborPtr, endPtr);
                    neighborPtr -> fScore = neighborPtr -> fScore * (1 + cross);
                    openSet.insert( make_pair(neighborPtr->fScore, neighborPtr) );
                }else{
                	// 放入close list作为障碍物
                    neighborPtr -> id = -1;
                }
            }
            //如果节点另一条路径累计成本更小
            else if(neighborPtr -> id == 1){ //this node is in open set and need to judge if it needs to update, the "0" should be deleted when you are coding
                /*
                *
                *
                STEP 7:  As for a node in open set, update it , maintain the openset ,and then put neighbor in open set and record it
                please write your code below
                *        
                */
                //覆盖节点信息
                if(edgeCostSets[i] + currentPtr -> gScore < neighborPtr -> gScore){  
                    neighborPtr -> cameFrom = GridNodeMap[currentPtr -> index(0)][currentPtr -> index(1)][currentPtr -> index(2)];                  
                    neighborPtr -> gScore = edgeCostSets[i] +currentPtr -> gScore;
                    neighborPtr -> fScore = neighborPtr -> gScore + getHeu(neighborPtr, endPtr);
                }
                continue;
            }
            else{//this node is in closed set
                /*
                *
                please write your code below
                *        
                */
                continue;
            }
        }      
    }
    //if search fails
    ros::Time time_2 = ros::Time::now();
    if((time_2 - time_1).toSec() > 0.1)
        ROS_WARN("Time consume in Astar path finding is %f", (time_2 - time_1).toSec() );
}

启发式函数

double AstarPathFinder::getHeu(GridNodePtr node1, GridNodePtr node2)
{
    double node1_x = node1 -> coord(0);
    double node1_y = node1 -> coord(1);
    double node1_z = node1 -> coord(2);
    double node2_x = node2 -> coord(0);
    double node2_y = node2 -> coord(1);
    double node2_z = node2 -> coord(2);
    double dx = abs(node2_x - node1_x);
    double dy = abs(node2_y - node1_y);
    double dz = abs(node2_z - node1_z);
    
    // 三种启发式函数
	// Euclidean
    double distance = sqrt(dx * dx + dy * dy + dz * dz);
    /// Manhattan
    // double distance = dx + dy + dz;
    // Diagonal
    // double distance = (dx + dy + dz) + (sqrt(3) - 3) * min(min(dx, dy), dz);
    return distance;
}

邻居节点搜索

inline void AstarPathFinder::AstarGetSucc(GridNodePtr currentPtr, vector<GridNodePtr> & neighborPtrSets, vector<double> & edgeCostSets)
{   
    neighborPtrSets.clear();
    edgeCostSets.clear();

    GridNodePtr neighbor = NULL;
    Vector3i neighbor_idx;
    for (int i = 1; i >= -1; i--){
        for (int j = 1; j >= -1; j--){
            for (int k = 1; k >= -1; k--){
                if (i != j || i != k || i != 0){
                    neighbor_idx << currentPtr -> index(0) + i,
                                    currentPtr -> index(1) + j,
                                    currentPtr -> index(2) + k;
                    if(neighbor_idx(0) >= 0 && neighbor_idx(0) < GLX_SIZE && 
                       neighbor_idx(1) >= 0 && neighbor_idx(1) < GLY_SIZE && 
                       neighbor_idx(2) >= 0 && neighbor_idx(2) < GLZ_SIZE)
                    {
                        neighbor = GridNodeMap[neighbor_idx(0)][neighbor_idx(1)][neighbor_idx(2)];
                        neighbor -> dir = Vector3i(i,j,k);
                        neighborPtrSets.push_back(neighbor);
                        edgeCostSets.push_back(getHeu(currentPtr, neighbor));  
                    }else continue;
                }
            }
        }
    }
}

节点回溯

vector<Vector3d> AstarPathFinder::getPath() 
{   
    vector<Vector3d> path;
    vector<GridNodePtr> gridPath;

    GridNodePtr gridNode = terminatePtr -> cameFrom;
    gridPath.push_back(terminatePtr);
    while ( gridNode != NULL ){
        gridPath.push_back(gridNode);
        gridNode = gridNode->cameFrom;
    }

    for (auto ptr: gridPath)
        path.push_back(ptr->coord);
        
    reverse(path.begin(),path.end());

    return path;
}

Tie Breaker函数

double AstarPathFinder::getCross(GridNodePtr startNode, GridNodePtr currentNode, GridNodePtr endNode)
{
    double node_x = currentNode -> coord(0);
    double node_y = currentNode -> coord(1);
    double node_z = currentNode -> coord(2);
    double start_x = startNode -> coord(0);
    double start_y = startNode -> coord(1);
    double start_z = startNode -> coord(2);
    double end_x = endNode -> coord(0);
    double end_y = endNode -> coord(1);
    double end_z = endNode -> coord(2);
    double dx1 = abs(node_x - end_x);
    double dy1 = abs(node_y - end_y);
    double dz1 = abs(node_z - end_z);
    double dx2 = abs(start_x - node_x);
    double dy2 = abs(start_y - node_y);
    double dz2 = abs(start_z - node_z);
    double C_x = abs(dy1*dz2 - dy2*dz1);
    double C_y = abs(dz1*dx2 - dz2*dx1);
    double C_z = abs(dx1*dy2 - dx2*dy1);
    double cross = sqrt(C_x * C_x + C_y * C_y + C_z * C_z);
    
    return cross;
}

使用了叉积公式:

在这里插入图片描述

代码运行结果

在这里插入图片描述

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值