Unreal Engine 4 学习总动员读书笔记(2)

Unreal Engine 4 学习总动员读书笔记(2)

参考《Unreal Engine 4 学习总动员》,之前已经将视频的操作一步步的完成,但并没有对知识点进行深入理解和提取,本章针对之前的操作对知识点进行更加深入的总结。

本章以书本而非视频的章节进行扩展,更加深入全面的了解UE4,并且扩展阅读官网教程以及API的知识加以解释。

《快速入门》

快速入门主要是简单介绍了UE4蓝图和C++、材质、地形、物理、光照、AI,内容较为简单。

下载源代码

Unity和UE4对比

  • UE4音频只支持wav格式

  • UE4不仅支持使用继承的方式定义新物件,支持使用C++或蓝图定义组件

  • 在UE4中编写逻辑代码

    1. 在虚幻中,如果要创建 UObject 的继承类,是像下面这样的初始化:

      UMyObject* NewObj = NewObject<UMyObject>();
      
    2. NewObject 和 SpawnActor 函数也能通过给一个 “模板” 对象来工作。虚幻引擎会创建该对象的拷贝,而不是"从零创建一个新的对象"。这会拷贝该对象的所有属性(UPROPERTY)和组件。

      AMyActor* CreateCloneOfMyActor(AMyActor* ExistingActor, FVector SpawnLocation, FRotator SpawnRotation)
      {
          UWorld* World = ExistingActor->GetWorld();
          FActorSpawnParameters SpawnParams;
          SpawnParams.Template = ExistingActor;
          World->SpawnActor<AMyActor>(ExistingActor->GetClass(), SpawnLocation, SpawnRotation, SpawnParams);
      }
      
    3. 在虚幻 4 中,我们可以利用对象的构造函数达到同样的效果。

      UCLASS()
      class AMyActor : public AActor
      {
          GENERATED_BODY()
      
          UPROPERTY()
          int32 MyIntProp;
      
          UPROPERTY()
          USphereComponent* MyCollisionComp;
      
          AMyActor()
          {
              MyIntProp = 42;
      
              MyCollisionComp = CreateDefaultSubobject<USphereComponent>(FName(TEXT("CollisionComponent"));
              MyCollisionComp->RelativeLocation = FVector::ZeroVector;
              MyCollisionComp->SphereRadius = 20.0f;
          }
      };
      

      注意CreateDefaultSubobject的使用。

    4. 类型转换

      UPrimitiveComponent* Primitive = MyActor->GetComponentByClass(UPrimitiveComponent::StaticClass());
      USphereComponent* SphereCollider = Cast<USphereComponent>(Primitive);
      if (SphereCollider != nullptr)
      {
              // ...
      }
      

      注意GetComponentByClass和Cast的使用。

    5. 销毁:MyActor->Destroy();

    6. 销毁 GameObject / Actor (1 秒延迟):MyActor->SetLifeSpan(1);

    7. 禁用 GameObjects / Actors:

      MyActor->SetActorHiddenInGame(true);
      
      // Disables collision components
      MyActor->SetActorEnableCollision(false);
      
      // Stops the Actor from ticking
      MyActor->SetActorTickEnabled(false);
      
    8. 通过组件访问 GameObject / Actor: MyComponent->GetOwner()

    9. 通过 GameObject / Actor 访问组件:UMyComponent* MyComp = MyActor->FindComponentByClass();

    10. 查找 GameObjects / Actors:

      // Find Actor by name (also works on UObjects)
      AActor* MyActor = FindObject<AActor>(nullptr, TEXT("MyNamedActor"));
      
      // Find Actors by type (needs a UWorld object)
      for (TActorIterator<AMyActor> It(GetWorld()); It; ++It)
      {
              AMyActor* MyActor = *It;
              // ...
      }
      // Find UObjects by type
      for (TObjectIterator<UMyObject> It; It; ++It)
      {
          UMyObject* MyObject = *It;
          // ...
      }
      
      // Find Actors by tag (also works on ActorComponents, use TObjectIterator instead)
      for (TActorIterator<AActor> It(GetWorld()); It; ++It)
      {
          AActor* Actor = *It;
          if (Actor->ActorHasTag(FName(TEXT("Mytag"))))
          {
              // ...
          }
      }
      
    11. 为 GameObjects / Actors 添加标签:MyActor.Tags.AddUnique(TEXT(“MyTag”));

    12. 为 MonoBehaviours / ActorComponents 添加标签:MyComponent.ComponentTags.AddUnique(TEXT(“MyTag”));

    13. 比较 GameObjects / Actors 和 MonoBehaviours / ActorComponents 的标签:

      if (MyGameObject.CompareTag("MyTag"))
      {
          // ...
      }
      
      // Checks the tag on the GameObject it is attached to
      if (MyComponent.CompareTag("MyTag"))
      {
          // ...
      }
      
      // Checks if an Actor has this tag
      if (MyActor->ActorHasTag(FName(TEXT("MyTag"))))
      {
          // ...
      }
      
      // Checks if an ActorComponent has this tag
      if (MyComponent->ComponentHasTag(FName(TEXT("MyTag"))))
      {
          // ...
      }
      
    14. 物理:刚体 vs. 图元(Primitive)组件:在虚幻中,任何图元组件(C++ 中为 UPrimitiveComponent)都可以是物理对象。一些通用的图元组件,比如 ShapeComponent(胶囊形,球形,盒形),StaticMeshComponent,以及 SkeletalMeshComponent。

    15. 层 vs 通道:在 Unity 中,它们被称为"层(Layer)"。虚幻则称之为碰撞通道(Collision Channel)

    16. RayCast射线检测 vs RayTrace射线检测

      APawn* AMyPlayerController::FindPawnCameraIsLookingAt()
      {
          // You can use this to customize various properties about the trace
          FCollisionQueryParams Params;
          // Ignore the player's pawn
          Params.AddIgnoredActor(GetPawn());
      
          // The hit result gets populated by the line trace
          FHitResult Hit;
      
          // Raycast out from the camera, only collide with pawns (they are on the ECC_Pawn collision channel)
          FVector Start = PlayerCameraManager->GetCameraLocation();
          FVector End = Start + (PlayerCameraManager->GetCameraRotation().Vector() * 1000.0f);
          bool bHit = GetWorld()->LineTraceSingle(Hit, Start, End, ECC_Pawn, Params);
      
          if (bHit)
          {
              // Hit.Actor contains a weak pointer to the Actor that the trace hit
              return Cast<APawn>(Hit.Actor.Get());
          }
      
          return nullptr;
      }
      

      注意LineTraceSingle的使用

    17. 触发器

      UCLASS()class AMyActor : public AActor{    GENERATED_BODY()    // My trigger component    UPROPERTY()    UPrimitiveComponent* Trigger;    AMyActor()    {        Trigger = CreateDefaultSubobject<USphereComponent>(TEXT("TriggerCollider"));        // Both colliders need to have this set to true for events to fire        Trigger.bGenerateOverlapEvents = true;        // Set the collision mode for the collider        // This mode will only enable the collider for raycasts, sweeps, and overlaps        Trigger.SetCollisionEnabled(ECollisionEnabled::QueryOnly);    }    virtual void NotifyActorBeginOverlap(AActor* Other) override;    virtual void NotifyActorEndOverlap(AActor* Other) override;};
      

      注意NotifyActorBeginOverlap和NotifyActorEndOverlap两个虚函数

    18. 刚体运动(Kinematic Rigidbodies)

      UCLASS()class AMyActor : public AActor{    GENERATED_BODY()    UPROPERTY()    UPrimitiveComponent* PhysicalComp;    AMyActor()    {        PhysicalComp = CreateDefaultSubobject<USphereComponent>(TEXT("CollisionAndPhysics"));        PhysicalComp->SetSimulatePhysics(false);        PhysicalComp->SetPhysicsLinearVelocity(GetActorRotation().Vector() * 100.0f);    }};
      

      注意SetPhysicsLinearVelocity的使用

    19. 输入事件

      UCLASS()class AMyPlayerController : public APlayerController{    GENERATED_BODY()    void SetupInputComponent()    {        Super::SetupInputComponent();        InputComponent->BindAction("Fire", IE_Pressed, this, &AMyPlayerController::HandleFireInputEvent);        InputComponent->BindAxis("Horizontal", this, &AMyPlayerController::HandleHorizontalAxisInputEvent);        InputComponent->BindAxis("Vertical", this, &AMyPlayerController::HandleVerticalAxisInputEvent);    }    void HandleFireInputEvent();    void HandleHorizontalAxisInputEvent(float Value);    void HandleVerticalAxisInputEvent(float Value);};
      

      注意BindAction和BindAxis的使用,以及这里回调函数使用Handle开头的写法

    20. 虚幻 4 并不处理异常。取而代之的做法是,使用 ‘check()’ 函数来触发严重的断言错误。你可以传入一个错误信息提示。如果只是想报告一个错误,但不希望打断整个程序,你可以使用 'ensure()’。这会记录一个带有完整调用堆栈的错误信息,但程序会继续执行。如果当前附加了调试器,那么这两个函数都会暂定并进入调试器。

入门案例

  • 这里主要介绍UE4编辑器的基本构成,操作,以及自己搭建一个简单的场景。

  • 材质编辑器的使用

    1. 下载资源:https://docs.origin.unrealengine.com/latest/attachments/Engine/Content/QuickStart/1/2/QuickStartSampleAssets.zip,该资源无法在浏览器中下载,只能在迅雷中下载。
    2. 着色模型——次表面模型:光略过表面时,可为材质增添颜色,从而模拟颜色的变化。需求:在红色光环境下的岩石材质:要求材质要能体现出环境光对材质的影响。
  • 快速编程入门:这里简单介绍了AActor的使用

  • 关照快速入门:这里介绍了使用点光源、聚光灯、方向光的使用操作

  • 地形快速入门

    1. 创建地图:首先创建普通材质,只使用基本色和法线结点。使用地形层融合结点融合三种层的贴图和法线信息。
    2. 地形层融合结点应该不是一个普通的计算结点,地形文件可以通过材质信息找到对应的层融合结点的信息,并使用地形层融合数据文件给它赋值。
    3. LandscapeLayerCoords(地形图层坐标)应该不是必须的,当需要改变材质UV的时候才需要使用。
  • UMG快速入门:该Demo使用Widget制作了人物血量的显示,值得注意的是,在该案例中,使用了将控件UI绑定到子对象引用的属性上的方法。

    创建主菜单

    1. 在菜单中打开关卡:openlevel、removefromparent
    2. 在游戏中执行游戏控制台命令:Execute Console Command r.setRes 1280x720(跳转画面大小)
    3. 设置输入仅限游戏:Set Input Mode Game Only
    4. 暂停游戏
      • Set Game Pause 暂停游戏本身
      • Show Mouse Cursor 显示鼠标
      • Set Input Mode UI Only 只UI接收输入事件
  • 行为树入门

    • AIControler:执行行为树、更新黑板值
    • 行为树:定义AI行为方式
    • 黑板:AI行为树的数据参考,相当于结构体
    • 行为树合成结点:行为树的决策方式结点,分为选择器、序列、简单平行(可以使用服务代替)
    • 选择器:例如一个任务有三种解决方案,选择器相当于方案列表,选择器中的方案具有优先级,AI会尝试从第一种方案执行,只要一种成功,就结束执行。
    • 序列:例如一个任务执行要经过A,B,C三个步骤才算执行完成,执行序列中的任务具有顺序,AI会依次执行,只要一个失败,就执行失败并终止。
    • 简单平行:具有一个主人物和一个后台分支,当主任务执行时,会同时执行后台分支,直到后台分支结束。例如敌人发现玩家就向玩家移动,并开始射击,当无法发现玩家时,射击也会停止。
    • 导航网格体边界体积:定义AI的移动范围的体积
    • AI感知组件:通过定义AI听觉配置、视觉配置等,让AI发现玩家时,执行On Target Perception Updated回调函数。
    • 任务:AI执行的动作,可以自定义任务,内置常用任务有:Rotate to face(向指定的位置转向)、Move To(向指定位置移动)、Wait(等待指定时间)
    • 服务:相当于一个任务,附加在合成结点上,先与合成结点执行,主要用来更新黑板的数据,使用服务可以隐藏掉所有非叶子结点中的所有任务。
    • 装饰器:装饰器可以根据决定条件决定进入哪个分支,当装饰器定义的条件执行时,可以决定是否要终止低优先级的任务并执行该分支。
  • 常用的装饰器

    1. 自定义装饰器
    2. 黑板装饰器
    3. Timelimit装饰器(限制执行结点的执行时间)
    4. IsAtLocation装饰器(判断AIActor是否在指定位置范围)

《蓝图应用》

蓝图在UE4中承担了举足轻重的地位,蓝图相比于C++编程来说,具有以下优点:

  1. 简单易学。
  2. 蓝图的的很多函数是被简化后的,容易记忆和查询。
  3. 编译快,出错少。

但同样也要认识到,蓝图只是UE4内置的脚本语言,并非技术,无法直接解决具体问题。因此针对蓝图的学习不应过于钻研,只要能掌握常用的操作即可。

先熟悉重要的快捷键:Alt + Shift + O:定位文件。项目文件太多时,这个会帮上大忙,当然,你的文件名命名最好有个比较好的规范。

常规脚本

  1. 事件
    • EventLevelReset:关卡重启时执行,仅在服务端执行。例如在玩家死亡时,而关卡无需重新加载时比较有用。
    • Event Actor Begin Overlap:两个Actor发生重叠时。要求Generate Overlap Event为True。
    • Event Actor End Overlap:两个Actor停止重叠。
    • Event Hit:当Actor碰撞时。要求Generate Hit Events为True
    • Event Any Damage:仅在服务器执行:发生伤害时触发。
    • Event Point Damage:点伤害,如近战武器
    • Event Radial Damage:放射性伤害
    • Event Actor Begin Cursor Over:鼠标光标悬停时
    • Event Actor End Cursor Over:鼠标光标在Actor离开时
    • Event Begin Play:开始游戏
    • Event End Play
    • Event Destroyed
    • Event Tick
    • Event Receive Draw HUD:仅限继承自HUD的蓝图使用,用于绘制HUD
    • Custome Event…
  2. 结构体
    • 拆分&组合结构体引脚
    • 拆分结构体:break
    • 组成结构体:make
    • 设置结构体的成员:set members in struct

建造蓝图

  1. 宏和函数
    • 宏和合并结点一样,但宏可以复用,且宏可以具备多个输出执行引脚。
    • 只有合并结点、宏可以使用Delay,函数不可用使用Delay。
    • 函数可以定义局部变量,而宏不可定义局部变量。
    • 函数默认对变量类型使用值传递,也可以设置成引用传递;宏只能使用引用传递。
  2. 蓝图调试
    • 使用F9进行断点
    • 可以打开调试窗口,查看所有被watch的值
  3. 蓝图直接通信
    • 通过直接或变量赋值的方法获取对方蓝图的引用。
    • 直接调用对方蓝图函数。
  4. 事件分配器
    • 事件分发器可以定义参数,可以实现依赖倒置。
  5. 蓝图接口
    • 蓝图接口强制规定了该蓝图可以实现哪些功能。
    • 蓝图接口也可以将蓝图的发送和接收分离,但是要求发送端必须拥有待发送对象的引用。
    • 蓝图调用方无需知道是否能够正确转换成对应蓝图接口,如果不能转换,则函数不会被调用。
    • 蓝图接口只能添加到actor中,事件分发器可以添加到游戏框架,UMG等中。

蓝图技术指南

  1. 暴露游戏元素给蓝图

    UPROPERTY

    • BlueprintReadOnly
    • BlueprintReadWrite
    • BlueprintAssignable:多播委托可赋值
    • BlueprintCallable:多播委托可调用
    //1. 声明多播委托DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnKingDeathSignature, AKing*, DeadKing);//2. 定义多播委托变量UPROPERTY(BlueprintAssignable)  //  蓝图可以动态地为委托赋予事件  FOnKingDeathSignature OnKingDeath;// 多播委托实例,在Broadcast之后调用该委托上的方法//3. 调用多播委托OnKingDeath.Broadcast(this);//4. 在蓝图中绑定多播委托Bind Event to OnKingDeath
    

    UFUNCTION

    • BlueprintCallable
    • BlueprintPure
    • BlueprintImplementableEvent:在C++可以声明函数(不能定义,蓝图重写),在C++里调用该函数,蓝图重写实现该函数
    • BlueprintNativeEvent:在C++可以声明和定义函数(使用XXX__Implementation定义),在C++里调用该函数,蓝图重写实现该函数(蓝图可以重写或不重写C++父类函数)

    当使用BlueprintImplementableEvent或BlueprintNativeEvent没有返回值时,在蓝图中以事件的方式使用,当有返回值时,以函数的方式使用。

  2. 一些建议

    • ExpandEnumAsExecs:可以实现多引脚的函数。

      UENUM(BlueprintType)enum class BranchOutput : uint8{	Branch0,	Branch1,	Branch2,};UFUNCTION(BlueprintCallable, Category = "methods", Meta = (ExpandEnumAsExecs = "Branches"))		void FunExpandEnumAsExecs(int32 Input, BranchOutput& Branches);		void AMyActor::FunExpandEnumAsExecs(int32 Input, BranchOutput& Branches){	if (Input == 0)	{		Branches = BranchOutput::Branch0;	}	else if(Input == 1)	{		Branches = BranchOutput::Branch1;	}	else	{		Branches = BranchOutput::Branch2;	}}
      
    • FLatentActionInfo可以实现一些需要等待的操作:底层原理还是每一帧去查询状态,等到可以执行后续操作了再去进一步执行。

      /** 	 * Perform a latent action with a delay (specified in seconds).  Calling again while it is counting down will be ignored.	 * 	 * @param WorldContext	World context.	 * @param Duration 		length of delay (in seconds).	 * @param LatentInfo 	The latent action.	 */	UFUNCTION(BlueprintCallable, Category="Utilities|FlowControl", meta=(Latent, WorldContext="WorldContextObject", LatentInfo="LatentInfo", Duration="0.2", Keywords="sleep"))	static void	Delay(UObject* WorldContextObject, float Duration, struct FLatentActionInfo LatentInfo );void UKismetSystemLibrary::Delay(UObject* WorldContextObject, float Duration, FLatentActionInfo LatentInfo ){	if (UWorld* World = GEngine->GetWorldFromContextObject(WorldContextObject, EGetWorldErrorMode::LogAndReturnNull))	{		FLatentActionManager& LatentActionManager = World->GetLatentActionManager();		if (LatentActionManager.FindExistingAction<FDelayAction>(LatentInfo.CallbackTarget, LatentInfo.UUID) == NULL)		{			LatentActionManager.AddNewAction(LatentInfo.CallbackTarget, LatentInfo.UUID, new FDelayAction(Duration, LatentInfo));		}	}}// FDelayAction// A simple delay action; counts down and triggers it's output link when the time remaining falls to zeroclass FDelayAction : public FPendingLatentAction{public:	float TimeRemaining;	FName ExecutionFunction;	int32 OutputLink;	FWeakObjectPtr CallbackTarget; 	FDelayAction(float Duration, const FLatentActionInfo& LatentInfo)		: TimeRemaining(Duration)		, ExecutionFunction(LatentInfo.ExecutionFunction)		, OutputLink(LatentInfo.Linkage)		, CallbackTarget(LatentInfo.CallbackTarget)	{	} 	virtual void UpdateOperation(FLatentResponse& Response) override	{		TimeRemaining -= Response.ElapsedTime();		Response.FinishAndTriggerIf(TimeRemaining <= 0.0f, ExecutionFunction, OutputLink, CallbackTarget);	} #if WITH_EDITOR	// Returns a human readable description of the latent operation's current state	virtual FString GetDescription() const override	{		static const FNumberFormattingOptions DelayTimeFormatOptions = FNumberFormattingOptions()			.SetMinimumFractionalDigits(3)			.SetMaximumFractionalDigits(3);		return FText::Format(NSLOCTEXT("DelayAction", "DelayActionTimeFmt", "Delay ({0} seconds left)"), FText::AsNumber(TimeRemaining, &DelayTimeFormatOptions)).ToString();	}#endif};
      
    • 如果有可能,尽量将函数放入共享库:继承自UBluepintFunctionLibrary

    • 将函数标记为const也可以让蓝图结点不带引脚。

  3. 新版本支持蓝图原生化,以减少蓝图虚拟机的调用。

在线会话结点Session

  1. 创建会话:Create Session
  2. 寻找会话:Find Session
  3. 加入会话:Join Session
  4. 销毁会话:Destroy Session
  5. 网络错误:Event Network Error
  6. 其他错误:Event Travel Error

Bitmask蓝图变量

  1. 当定义枚举类型和整型类型都启用Bitmask掩码的时候,可以将整型指定到该枚举类型。
  2. 可以使用OR、AND、XOR、NOT操作。

蓝图应用实践

  1. 随机流和随机流函数
    • 随机流是一种伪随机数,使用同一种子在调用相同函数的时候会产生相同的随机值。
  2. Timeline
    • Timeline可以使用但数值、Vector、事件(离散的在特定时间得到一个脉冲信号)、颜色

《动画设计》

动画在游戏中非常重要,但UE4的一些动画比较缺少一些重点,目前只对常用的动画技术做一些了解。后面当需要实际调研的时候再进一步学习。

首先说明以下课程视频中的一些操作和意义

  • 编辑动画层:说明可以自己认为修改动画帧中的骨骼。

  • 曲线驱动动画:可以在动画帧中选取其中某些帧自己组成一个补间动画。

  • 使用子动画实例:目前新版本无法创建子动画蓝图,不做研究

  • 创建动态动画:使用Anim Dynamics结点可以实现部分骨骼的物理震动。

  • 创建混合空间动画:常用,3D中创建人物动作的过度,例如从走到跑的过程

  • 创建分层动画:一些枪战或战斗类型游戏会用到,使用了Layered blend per bone,从设置的那块骨骼开始,包括它的子骨骼都开始使用 “Blend Pose 0” 的动画来替代 “Base Pose ” 的动画。因为UE4默认的骨骼布置较为特殊,可以使用该方法很简单的就实现了脊柱上的骨骼被另外一个姿势控制,从而实现了普通枪战的动画。

  • 创建过场动画:常用在游戏开始、结束、或某些叙事片段中

具体在项目中常用到的有:

  • 骨骼插槽:在骨骼面板右键,添加插槽,之后可以AttachActorToComponent将模型绑定到插槽中。
  • 动画控制器:可以根据属性控制播放不同的动画。
  • 融合空间:可以根据一个参数在两个动画中获得过度动画。
  • 动画蒙太奇:
    1. 可以用于打断动画:PlayAnimMontage播放蒙太奇,将动画蒙太奇片段插入到状态机和输出中间,从而打断之前的动画输出。
    2. 添加事件:如在某帧播放声音、粒子特效或自定义事件通知给动画蓝图
  • 分层动画:主要在射击游戏中用到。
  • 过场动画:叙事片段中用到,制作好资源后可以直接播放,不清楚能否在过场动画中对场景中的物体做适时调整。
  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 2
    评论
Epic Games今天宣布,正式发布“虚幻引擎4”(Unreal Engine 4/UE4),相关所有资源也一并放出。# h9 u6 O( p! y& D) q4 K7 P9 ~# N   只要每个月花19美元,你就能到全部资源,包括拿过来就能用的虚幻编辑器(Unreal Editor),GitHub上的完整C++源代码(支持微软Visual Studio/苹果Xcode),还有完整的生态系统:论坛、维基、问答、协作。8 t8 q& _* ?7 K. D) S6 W 一大波新游戏要来了 虚幻4引擎正式发布, s! f+ v' w9 M j3 y: ^   而在虚幻引擎4游戏的销售中,Epic将抽取5%的毛收入提升,也就是你的游戏赚了100万美元,就要交给Epic 5万美元。   回到技术方面,Epic号称虚幻引擎4是他们多年来心血的结晶,而且现在只是第一版,才刚刚开始。C++代码里你可以看到大量的创新,比如说支持虚拟立体偷窥Oculus VR,支持Linux操作系统,以及支持Valve SteamWorks、StreamBox,而且还可以通过HTML5在网页浏览器内开发游戏(页游)。   平台支持PC、PS4、Xbox One、iOS、Google等等。   虚幻引擎4基于DirectX 11,拥有新的材料流水线、蓝图视觉化脚本、直观蓝图调试、内容浏览器、人物动画、Matinee影院级工具集、全新地形和植被、后期处理效果、热重载(Hot Reload)、模拟与沉浸式视角、即时游戏预览、AI人工智能、音频、中间件集成等一系列全新特性。   虚幻引擎4现已开始提供授权。 现在提供网盘下载地址,毕竟到官方下载很麻烦,还要$,呵呵,10分很值得,代码风格很好,注释很爽!

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值