[OpenGL] OpenGL制作2D游戏客户端 - Breakout

  • 前言
  • 简单的框架
  • 渲染精灵
  • 碰撞检测
  • 粒子拖尾
  • 游戏音效

前言

本博客借鉴学习LearnOpenGL-CN的实战教程,深入了解从图形底层制作2D游戏基本过程,参考链接放在这里。整个工程的代码在github链接下载:https://github.com/ZeusYang/Breakout

框架

废话不多说,看图。
这里写图片描述
Shader类:编译、链接着色器程序,包括顶点着色器、片元着色器、几何着色器,也提供设置GPU中的uniform变量的接口。
Texture2D类:封装了OpenGL的纹理接口,用于从数据中创建纹理,指定纹理格式,并用于绑定。
ResourceManager类:资源管理器,用于管理着色器资源和纹理资源,统一给每个着色器命名进行管理,提供从文件中获取着色器代码进而传入Shader类进行编译以及读取图片数据生成纹理,保存所有着色器程序和纹理的副本。
SpriteRenderer类:渲染精灵,这里是一个2D的四边形,提供一个统一的四边形VAO接口,一个精灵有不同的位置、大小、纹理、旋转角度、颜色。
PostProcessor类:后期特效处理,主要使用OpenGL的帧缓冲技术,将游戏渲染后的画面进一步地处理,这里包括震荡、反相、边缘检测和混沌。
TextRenderer类:文本渲染,用于渲染文字。
GameObject类:游戏物品的高层抽象,每个游戏物品有位置、大小、速度、颜色、旋转度、是否实心、是否被破坏、纹理等属性,每次调用SpriteRenderer类的一个实例的Draw方法渲染GameObject。
BallObject类:继承自GameObject类,它是一个小球,Breakout游戏中打击砖块的媒介。
GameLevel类:表示游戏的一个关卡,在这里表示的是每个关卡砖块的分布情况,提供从文件加载关卡、渲染关卡、检测是否通关的接口。
PowerUp类:游戏道具,在这里主要是击打砖块带来的反馈,如震荡。
ISoundEngine:第三方库irrKlang的实例,用于播放游戏音效。

使用的库有:glew、glfw、irrKlang、freetype。

渲染精灵

2D的投影是正交投影,我们把窗口宽高全部投影到[-1,1]之间,使用如下的投影矩阵:

glm::mat4 projection = glm::ortho(0.0f, static_cast<GLfloat>(this->Width),
        static_cast<GLfloat>(this->Height), 0.0f, -1.0f, 1.0f);

渲染精灵就是我们创建的一个有纹理的四边形,这个四边形没有固定的纹理,全靠外部传入的参数决定精灵的纹理,如此便统一了一个简洁的接口。

class SpriteRenderer
{
public:
    SpriteRenderer(Shader &shader);
    ~SpriteRenderer();

    //绘制精灵,指定纹理、位置、大小、旋转度、颜色
    void DrawSprite(Texture2D &texture, glm::vec2 position,
        glm::vec2 size = glm::vec2(10, 10), GLfloat rotate = 0.0f,
        glm::vec3 color = glm::vec3(1.0f));
private:
    Shader shader;
    GLuint quadVAO;
    void initRenderData();
};
void SpriteRenderer::initRenderData()
{
    // 配置 VAO/VBO
    GLuint VBO;
    GLfloat vertices[] = {
        // 位置     // 纹理
        0.0f, 1.0f, 0.0f, 1.0f,
        1.0f, 0.0f, 1.0f, 0.0f,
        0.0f, 0.0f, 0.0f, 0.0f,

        0.0f, 1.0f, 0.0f, 1.0f,
        1.0f, 1.0f, 1.0f, 1.0f,
        1.0f, 0.0f, 1.0f, 0.0f
    };

    glGenVertexArrays(1, &this->quadVAO);
    glGenBuffers(1, &VBO);

    glBindBuffer(GL_ARRAY_BUFFER, VBO);
    glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);

    glBindVertexArray(this->quadVAO);
    glEnableVertexAttribArray(0);
    glVertexAttribPointer(0, 4, GL_FLOAT, GL_FALSE, 4 * sizeof(GLfloat), (GLvoid*)0);
    glBindBuffer(GL_ARRAY_BUFFER, 0);
    glBindVertexArray(0);
}

void SpriteRenderer::DrawSprite(Texture2D &texture, glm::vec2 position,
    glm::vec2 size, GLfloat rotate, glm::vec3 color)
{
    // 准备变换,先缩放、旋转然后平移
    this->shader.Use();
    glm::mat4 model(1.0f);
    //平移到目的地
    model = glm::translate(model, glm::vec3(position, 0.0f));

    //旋转,绕着z轴
    model = glm::translate(model, glm::vec3(0.5f * size.x, 0.5f * size.y, 0.0f));
    model = glm::rotate(model, rotate, glm::vec3(0.0f, 0.0f, 1.0f));
    model = glm::translate(model, glm::vec3(-0.5f * size.x, -0.5f * size.y, 0.0f));

    //缩放
    model = glm::scale(model, glm::vec3(size, 1.0f));
    //传至gpu中
    this->shader.SetMatrix4("model", model);
    this->shader.SetVector3f("spriteColor", color);
    //绑定纹理
    glActiveTexture(GL_TEXTURE0);
    texture.Bind();

    glBindVertexArray(this->quadVAO);
    glDrawArrays(GL_TRIANGLES, 0, 6);
    glBindVertexArray(0);
}

SpriteRenderer::SpriteRenderer(Shader &shader)
{
    this->shader = shader;
    this->initRenderData();
}

SpriteRenderer::~SpriteRenderer()
{
    glDeleteVertexArrays(1, &this->quadVAO);
}

着色器代码

#version 330 core
layout (location = 0) in vec4 vertex; 
// <vec2 position, vec2 texCoords>

out vec2 TexCoords;

uniform mat4 model;
uniform mat4 projection;

void main()
{
    TexCoords = vertex.zw;
    gl_Position = projection * model * vec4(vertex.xy, 0.0, 1.0);
}
#version 330 core
in vec2 TexCoords;
out vec4 color;

uniform sampler2D image;
uniform vec3 spriteColor;

void main()
{
    color = vec4(spriteColor, 1.0) * texture(image, TexCoords);
}

碰撞检测

碰撞是游戏制作的难点,它提供了玩家一个直观的物理反馈。
1、边界碰撞:已知窗口的四个边界,我们可以直接写出的边界的碰撞检测。小球碰到边界之后速度取反,同时要将位置复原。这里的Position是小球精灵的左上角而非中心。

glm::vec2 BallObject::Move(GLfloat dt, GLuint window_width)
{
    // 如果没有被固定在挡板上
    if (!this->Stuck){
        // 移动球
        this->Position += this->Velocity * dt;
        // 检查是否在窗口边界以外,如果是的话反转速度并恢复到正确的位置
        if (this->Position.x <= 0.0f){//左
            this->Velocity.x = -this->Velocity.x;
            this->Position.x = 0.0f;
        }
        else if (this->Position.x + this->Size.x >= window_width){//右
            this->Velocity.x = -this->Velocity.x;
            this->Position.x = window_width - this->Size.x;
        }
        if (this->Position.y <= 0.0f){//上
            this->Velocity.y = -this->Velocity.y;
            this->Position.y = 0.0f;
        }
    }
    return this->Position;
}

2、AABB包围盒碰撞检测:当两个碰撞外形进入对方的区域时就会发生碰撞,例如定义了第一个物体的碰撞外形以某种形式进入了第二个物体的碰撞外形。对于AABB来说很容易判断,因为它们是与坐标轴对齐的:对于每个轴我们要检测两个物体的边界在此轴向是否有重叠。因此我们只是简单地检查两个物体的水平边界是否重合以及垂直边界是否重合。如果水平边界和垂直边界都有重叠那么我们就检测到一次碰撞。
这里写图片描述

GLboolean Game::CheckCollisionAABB(GameObject &one, GameObject &two)//AABB包围盒碰撞检测
{   //我们检查第一个物体的最右侧是否大于第二个物体的最左侧
    //并且第二个物体的最右侧是否大于第一个物体的最左侧;
    //垂直的轴向与此相似。
    bool collisionX = one.Position.x + one.Size.x >= two.Position.x
        && two.Position.x + two.Size.x >= one.Position.x;
    bool collisionY = one.Position.y + one.Size.y >= two.Position.y
        && two.Position.y + two.Size.y >= one.Position.y;
    return collisionX && collisionY;
}

3、AABB包围球碰撞检测:由于小球是一个圆形的物体,AABB或许不是球的最佳碰撞外形。碰撞的代码中将球视为长方形框,因此常常会出现球碰撞了砖块但此时球精灵还没有接触到砖块。使用圆形碰撞外形而不是AABB来代表球会更合乎常理。因此我们在球对象中包含了Radius变量,为了定义圆形碰撞外形,我们需要的是一个位置矢量和一个半径。

检测圆和AABB碰撞的算法会稍稍复杂,关键点如下:我们会找到AABB上距离圆最近的一个点,如果圆到这一点的距离小于它的半径,那么就产生了碰撞

难点在于获取AABB上的最近点P¯。下图展示了对于任意的AABB和圆我们如何计算该点:
这里写图片描述
首先我们要获取球心C¯与AABB中心B¯的矢量差D¯。接下来用AABB的半边长(half-extents)w和h¯来限制(clamp)矢量D¯。这一过程返回的是一个总是位于AABB的边上的位置矢量(除非圆心在AABB内部)。有了矢量D′¯,我们就可以比较它的长度和圆的半径以判断是否发生了碰撞。

Collision  Game::CheckCollisionSphere(BallObject &one, GameObject &two){//包围球碰撞检测
    // 获取圆的中心 
    glm::vec2 center(one.Position + one.Radius);//Position是左上角
    // 计算AABB的信息(中心、半边长)
    glm::vec2 aabb_half_extents(two.Size.x / 2, two.Size.y / 2);
    glm::vec2 aabb_center(
        two.Position.x + aabb_half_extents.x,
        two.Position.y + aabb_half_extents.y
    );
    // 获取两个中心的差矢量
    glm::vec2 difference = center - aabb_center;
    glm::vec2 clamped = glm::clamp(difference, -aabb_half_extents, aabb_half_extents);
    // AABB_center加上clamped这样就得到了碰撞箱上距离圆最近的点closest
    glm::vec2 closest = aabb_center + clamped;
    // 获得圆心center和最近点closest的矢量并判断是否 length <= radius
    difference = closest - center;
    if (glm::length(difference) <= one.Radius)
        return std::make_tuple(GL_TRUE, VectorDirection(difference), difference);
    else
        return std::make_tuple(GL_FALSE, UP, glm::vec2(0, 0));
}

4、碰撞反馈:
(1)、碰撞重定位:
为了把球对象定位到碰撞的AABB的外部,我们必须明确球侵入碰撞框的距离。
这里写图片描述
此时球少量进入了AABB,所以检测到了碰撞。我们现在希望将球从移出AABB的外形使其仅仅碰触到AABB,像是没有碰撞一样。为了确定需要将球从AABB中移出多少距离,我们需要找回矢量R¯,它代表的是侵入AABB的程度。为得到R¯我们用球的半径减去V¯。矢量V¯是最近点P¯和球心C¯的差矢量。有了R¯之后我们将球的位置偏移R¯就将球直接放置在与AABB紧邻的位置;此时球已经被重定位到合适的位置。
(2)、碰撞方向:
于Breakout我们使用以下规则来改变球的速度:
如果球撞击AABB的右侧或左侧,它的水平速度(x)将会反转。
如果球撞击AABB的上侧或下侧,它的垂直速度(y)将会反转。
但是如何判断球撞击AABB的方向呢?我们定义指向北、南、西和东的四个矢量,然后计算它们和给定矢量的夹角。由这四个方向矢量和给定的矢量点乘积的结果中的最高值(点乘积的最大值为1.0f,代表0度角)即是矢量的方向。

enum Direction {
    UP,
    RIGHT,
    DOWN,
    LEFT
};

Direction  Game::VectorDirection(glm::vec2 target) {
    glm::vec2 compass[] = {
        glm::vec2(0.0f, 1.0f),  // 上
        glm::vec2(1.0f, 0.0f),  // 右
        glm::vec2(0.0f, -1.0f), // 下
        glm::vec2(-1.0f, 0.0f)  // 左
    };
    GLfloat max = 0.0f;
    GLuint best_match = -1;
    for (GLuint i = 0; i < 4; i++){
        GLfloat dot_product = glm::dot(glm::normalize(target), compass[i]);
        if (dot_product > max){
            max = dot_product;
            best_match = i;
        }
    }
    return (Direction)best_match;
}
void Game::DoCollisions()
{
    for (GameObject &box : this->Levels[this->Level].Bricks)
    {
        if (!box.Destroyed)
        {
            Collision collision = CheckCollision(*Ball, box);
            if (std::get<0>(collision)) // 如果collision 是 true
            {
                // 如果砖块不是实心就销毁砖块
                if (!box.IsSolid)
                    box.Destroyed = GL_TRUE;
                // 碰撞处理
                Direction dir = std::get<1>(collision);
                glm::vec2 diff_vector = std::get<2>(collision);
                if (dir == LEFT || dir == RIGHT) // 水平方向碰撞
                {
                    Ball->Velocity.x = -Ball->Velocity.x; // 反转水平速度
                    // 重定位
                    GLfloat penetration = Ball->Radius - std::abs(diff_vector.x);
                    if (dir == LEFT)
                        Ball->Position.x += penetration; // 将球右移
                    else
                        Ball->Position.x -= penetration; // 将球左移
                }
                else // 垂直方向碰撞
                {
                    Ball->Velocity.y = -Ball->Velocity.y; // 反转垂直速度
                    // 重定位
                    GLfloat penetration = Ball->Radius - std::abs(diff_vector.y);
                    if (dir == UP)
                        Ball->Position.y -= penetration; // 将球上移
                    else
                        Ball->Position.y += penetration; // 将球下移
                }
            }
        }
    }
}    

粒子拖尾

单个粒子

struct Particle {
    glm::vec2 Position, Velocity;
    glm::vec4 Color;
    GLfloat Life;

    Particle() : Position(0.0f), Velocity(0.0f), Color(1.0f), Life(0.0f) { }
};

着色器

#version 330 core
layout (location = 0) in vec4 vertex; // <vec2 position, vec2 texCoords>

out vec2 TexCoords;
out vec4 ParticleColor;

uniform mat4 projection;
uniform vec2 offset;
uniform vec4 color;

void main()
{
    float scale = 20.0f;
    TexCoords = vertex.zw;
    ParticleColor = color;
    gl_Position = projection * vec4((vertex.xy * scale) + offset, 0.0, 1.0);
}
#version 330 core
in vec2 TexCoords;
in vec4 ParticleColor;
out vec4 color;

uniform sampler2D sprite;

void main()
{
    color = (texture(sprite, TexCoords) * ParticleColor);
}

我们创建一个vector容器来存储粒子,粒子数量固定,我们每次在死亡的粒子上创建新的粒子。

class ParticleGenerator
{
public:
    // Constructor
    ParticleGenerator(Shader shader, Texture2D texture, GLuint amount);
    // Update all particles
    void Update(GLfloat dt, GameObject &object, GLuint newParticles, glm::vec2 offset = glm::vec2(0.0f, 0.0f));
    // Render all particles
    void Draw();
private:
    // State
    std::vector<Particle> particles;
    GLuint amount;
    // Render state
    Shader shader;
    Texture2D texture;
    GLuint VAO;
    // Initializes buffer and vertex attributes
    void init();
    // Returns the first Particle index that's currently unused e.g. Life <= 0.0f or 0 if no particle is currently inactive
    GLuint firstUnusedParticle();
    // Respawns particle
    void respawnParticle(Particle &particle, GameObject &object, glm::vec2 offset = glm::vec2(0.0f, 0.0f));
};
ParticleGenerator::ParticleGenerator(Shader shader, Texture2D texture, GLuint amount)
    : shader(shader), texture(texture), amount(amount)
{
    this->init();
}

void ParticleGenerator::Update(GLfloat dt, GameObject &object, GLuint newParticles, glm::vec2 offset)
{
    // Add new particles 
    for (GLuint i = 0; i < newParticles; ++i)
    {
        int unusedParticle = this->firstUnusedParticle();//找到第一个未使用的粒子位置
        this->respawnParticle(this->particles[unusedParticle], object, offset);
    }
    // Update all particles
    for (GLuint i = 0; i < this->amount; ++i)
    {
        Particle &p = this->particles[i];
        p.Life -= dt; // reduce life
        if (p.Life > 0.0f)
        {   // particle is alive, thus update
            p.Position -= p.Velocity * dt;
            p.Color.a -= dt * 2.5;
        }
    }
}

// Render all particles
void ParticleGenerator::Draw()
{
    // Use additive blending to give it a 'glow' effect
    glBlendFunc(GL_SRC_ALPHA, GL_ONE);
    this->shader.Use();
    for (Particle particle : this->particles)
    {
        if (particle.Life > 0.0f)
        {
            this->shader.SetVector2f("offset", particle.Position);
            this->shader.SetVector4f("color", particle.Color);
            this->texture.Bind();
            glBindVertexArray(this->VAO);
            glDrawArrays(GL_TRIANGLES, 0, 6);
            glBindVertexArray(0);
        }
    }
    // Don't forget to reset to default blending mode
    glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
}

void ParticleGenerator::init()
{
    // Set up mesh and attribute properties
    GLuint VBO;
    GLfloat particle_quad[] = {
        0.0f, 1.0f, 0.0f, 1.0f,
        1.0f, 0.0f, 1.0f, 0.0f,
        0.0f, 0.0f, 0.0f, 0.0f,

        0.0f, 1.0f, 0.0f, 1.0f,
        1.0f, 1.0f, 1.0f, 1.0f,
        1.0f, 0.0f, 1.0f, 0.0f
    };
    glGenVertexArrays(1, &this->VAO);
    glGenBuffers(1, &VBO);
    glBindVertexArray(this->VAO);
    // Fill mesh buffer
    glBindBuffer(GL_ARRAY_BUFFER, VBO);
    glBufferData(GL_ARRAY_BUFFER, sizeof(particle_quad), particle_quad, GL_STATIC_DRAW);
    // Set mesh attributes
    glEnableVertexAttribArray(0);
    glVertexAttribPointer(0, 4, GL_FLOAT, GL_FALSE, 4 * sizeof(GLfloat), (GLvoid*)0);
    glBindVertexArray(0);

    // Create this->amount default particle instances
    for (GLuint i = 0; i < this->amount; ++i)
        this->particles.push_back(Particle());
}

// Stores the index of the last particle used (for quick access to next dead particle)
GLuint lastUsedParticle = 0;
GLuint ParticleGenerator::firstUnusedParticle()
{
    // First search from last used particle, this will usually return almost instantly
    for (GLuint i = lastUsedParticle; i < this->amount; ++i) {
        if (this->particles[i].Life <= 0.0f) {
            lastUsedParticle = i;
            return i;
        }
    }
    // Otherwise, do a linear search,在Last之后未找到有被杀死的粒子
    for (GLuint i = 0; i < lastUsedParticle; ++i) {
        if (this->particles[i].Life <= 0.0f) {
            lastUsedParticle = i;
            return i;
        }
    }
    // All particles are taken, override the first one
    //(note that if it repeatedly hits this case, more particles should be reserved)
    lastUsedParticle = 0;
    return 0;
}

void ParticleGenerator::respawnParticle(Particle &particle, GameObject &object, glm::vec2 offset)
{
    //随机产生一个粒子
    //-5到+5的随机数
    GLfloat random = ((rand() % 100) - 50) / 10.0f;
    //srand(time(NULL));
    //随机颜色
    GLfloat rColor1 = 0.5 + ((rand() % 100) / 100.0f);
    GLfloat rColor2 = 0.5 + ((rand() % 100) / 100.0f);
    GLfloat rColor3 = 0.5 + ((rand() % 100) / 100.0f);
    particle.Position = object.Position + random + offset;
    particle.Color = glm::vec4(rColor1, rColor2, rColor3, 1.0f);
    particle.Life = 1.0f;
    particle.Velocity = object.Velocity * 0.2f;
}

游戏音效

我们使用第三方库irrKlang来为游戏增添有去的音效。IrrKlang是一个可以播放WAV,MP3,OGG和FLAC文件的高级二维和三维(Windows,Mac OS X,Linux)声音引擎和音频库。它还有一些可以自由调整的音频效果,如混响、延迟和失真。IrrKlang是一个易于使用的音频库,只需几行代码便可播放大多数音频文件。
irrKlang可以从这里下载,下载之后引入irrKlang的头文件,将他们的库文件(irrKlang.lib)添加到链接器设置中,并将他们的dll文件复制到适当的目录下(通常和.exe在同一目录下)。需要注意的是,如果你想要加载MP3文件,则还需要引入ikpMP3.dll文件。

#include <irrKlang.h>
using namespace irrklang;
...
//音效
ISoundEngine *SoundEngine = createIrrKlangDevice();
//背景音乐
SoundEngine->play2D("../res/Audio/breakout.mp3", GL_TRUE);

如此类似使用,非常简单,当然游戏里我们还添加了一些其他碰撞音效,不再赘述。

总结

这里仅仅叙述了一部分,整个工程的代码在github链接下载:
https://github.com/ZeusYang/Breakout

这里写图片描述
一些后期特效处理:
这里写图片描述
这里写图片描述

作者对游戏的说明: 首先,您应当以一种批判的眼光来看待本程序。这个游戏是我制作 的第一部RPG游戏,无任何经验可谈,完全按照自己对游戏的理解进 行设计的。当我参照了《圣剑英雄2》的源码之后,才体会到专业游 戏引擎的博大精深。 该程序的内核大约有2000余行,能够处理人物的行走、对话、战斗, 等等。由于该程序的结构并不适于这种规模的程序,故不推荐您详 细研究该程序。所附地图编辑器的源程序我已经添加了详细的注释, 其程序结构也比较合理,可以作为初学VC的例子。 该程序在VC的程序向导所生成的SDI框架的基础上修改而成。它没有 使用任何关于VC底层的东西。程序的绝大部分都是在CgameView类中 制作的,只有修改窗口特征的一段代码在CMainFrm类中。其他的类 统统没有用到。另外添加的一个类是CEnemy类。 整个游戏的故事情节分成8段,分别由Para1.h ~ Para8.h八个文件 实现。由于程序仅仅能够被动的处理各种各样的消息,所以情节的 实现也只能根据系统的一些参数来判断当前应当做什么。在程序中 使用了冗长的if……else if……结构来实现这种判断。 当然,在我的记录本上,详细的记录了每个事件的判断条件。这种 笨拙的设计当然是不可取的。成都金点所作《圣剑英雄II》采用了 剧本解读的方式,这才是正统的做法。但这也需要更多的编程经验 和熟练的code功夫。 下面列举的是程序编制过程中总结出来的经验和教训。 第一,对话方式应该采用《圣剑英雄II》的剧本方式。 现在的方式把一个段落中所有的对话都混在一个文件中,然后给每 句话一个号码相对应。这样做虽然降低了引擎的难度,却导致剧情的 编写极其繁琐。 第二,运动和显示应当完全分开。 现在的程序中,运动和显示是完全同步的。即:在定时器中调用所有 敌人的运动函数,然后将主角的动画向前推一帧,接着绘制地图,调 用所有敌人的显示函数、重绘主角。这样的好处是不会掉帧,但带来 的问题是,如果要提高敌人的运动速度,那么帧数也跟着上去了。所 以当DEMO版反馈说速度太慢的时候,我修改起来非常困难。而这个问 题到最后也仅仅是将4步一格该成了2步一格。 第三,VC中数组存在上限。如果用“int aaa[1000000000]”定义一个 数组,编译器肯定不会给分配那么大的内存空间。而在这个程序中, 地图矩阵、NPC矩阵都超过了VC中数组的上限。但这一点知道的太晚了。 在1.0版本中已经发现地图最右端缺少了几行,但不知道是什么原因 造成的。(地图编辑器中未出现此问题,因为地图编辑器是用“序列 化”的方式存盘读盘的。)解决这个问题的方法是用“new”来分配 内存空间。 第四,由于不知道应该如何使用“new”和“delete”,几乎所有的DC 都使用了全局变量。这是完全没有必要的。程序运行期大约会耗用20 多M的内存空间,相当于一个大型游戏所使用的内存空间了。 另外,在游戏的剧情、美工方面也有许多问题,总之一个词“业余”。 我就不总结了。下一部作品,我将争取在程序上有一个质的飞跃。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值