ue4 UCharacterMovementComponent

如果有个需求是:一个character  时而 接受input  ,charactermovement 网络同步 正常工作,时而就当一个普通的actor沿着固定的路线自动运动

分析一下,正常模式下是charactermovement来影响RootComponent的位移和旋转。

正常模式下的character的位移和rotator变化的流程是

第一 rootcomponent是关闭网络同步的,要不和charactermovement打架

第二charactermovement的网络同步,在不同端分别影响updatecomponent。

所以在沿着固定路线自动移动的时候,要解决最大的问题是,让charactermovement不能影响RootComponent。让charactermovement不工作。同时启用rootcomponent的网络同步,通过在服务器上直接设置setactortransform,通过RootComponent->MoveComponent自动网络同步。

那么如何动态的设置charactermovement不工作呢

第一把组件的tick关掉

第二把另外事件驱动的ServerMove_XXXX之类的关掉,经过查找都有IsActive()过滤,于是可以通过setactive来滤掉ServerMove等操作。

例子如下

void XXCharacter::SetMovementTickEnable(bool TickEnable)
{

	if (GetRootComponent() != nullptr)
	{
		GetRootComponent()->SetIsReplicated(!TickEnable);
	}
	if (UCharacterMovementComponent* Movement = GetCharacterMovement())
	{
		Movement->SetComponentTickEnabledAsync(TickEnable);
		Movement->SetActive(TickEnable);
	}

}

之后就可以在server端的tick里面每帧都设置character的位置,就可以自动同步了

------------------------------------------------------------------------------------------------------------

关于胶囊体的旋转

先 本地移动

然后 把移动保存为 save character 传给服务器

服务器 再移动

然后自动同步给模拟端

关于 compressedflag只同步到服务器 就不会往模拟端同步了

UpdateFromCompressedFlags 这里在服务器解压获得jump变量

可以在这里设置服务器上的 character的变量 然后把这个变量属性同步到模拟端

-----------------------------------------------------------------------------------------------------------------

角色 运动 停止 物理滑动 braking

 只有加速度为0 或者 速度超过最大速度了,才会起作用,看如下代码

	// Only apply braking if there is no acceleration, or we are over our max speed and need to slow down to it.
	if ((bZeroAcceleration && bZeroRequestedAcceleration) || bVelocityOverMax)
	{
		const FVector OldVelocity = Velocity;

		const float ActualBrakingFriction = (bUseSeparateBrakingFriction ? BrakingFriction : Friction);
		ApplyVelocityBraking(DeltaTime, ActualBrakingFriction, BrakingDeceleration);
	
		// Don't allow braking to lower us below max speed if we started above it.
		if (bVelocityOverMax && Velocity.SizeSquared() < FMath::Square(MaxSpeed) && FVector::DotProduct(Acceleration, OldVelocity) > 0.0f)
		{
			Velocity = OldVelocity.GetSafeNormal() * MaxSpeed;
		}
	}

 如下代码是根据速度和摩擦系数等预计算出要刹车多长距离

FVector UAnimCharacterMovementLibrary::PredictGroundMovementStopLocation(const FVector& Velocity, 
	bool bUseSeparateBrakingFriction, float BrakingFriction, float GroundFriction, float BrakingFrictionFactor, float BrakingDecelerationWalking)
{
	FVector PredictedStopLocation = FVector::ZeroVector;

	float ActualBrakingFriction = (bUseSeparateBrakingFriction ? BrakingFriction : GroundFriction);
	const float FrictionFactor = FMath::Max(0.f, BrakingFrictionFactor);
	ActualBrakingFriction = FMath::Max(0.f, ActualBrakingFriction * FrictionFactor);
	float BrakingDeceleration = FMath::Max(0.f, BrakingDecelerationWalking);

	const FVector Velocity2D = Velocity * FVector(1.f, 1.f, 0.f);
	FVector VelocityDir2D;
	float Speed2D;
	Velocity2D.ToDirectionAndLength(VelocityDir2D, Speed2D);

	const float Divisor = ActualBrakingFriction * Speed2D + BrakingDeceleration;
	if (Divisor > 0.f)
	{
		const float TimeToStop = Speed2D / Divisor;
		PredictedStopLocation = Velocity2D * TimeToStop + 0.5f * ((-ActualBrakingFriction) * Velocity2D - BrakingDeceleration * VelocityDir2D) * TimeToStop * TimeToStop;
	}

	return PredictedStopLocation;
}

 上面代码的蓝图

 然后在动画蓝图中根据预计算出的距离,计算出刹车动画的那一帧开始播放

 动画必须是把距离烘培到曲线的动画并且不能开启rootmotion

------------------------------------------------------------------------

-----------------------------------------------------------------------

AActor中

	/**
	 * Return true if the given Pawn can be "based" on this actor (ie walk on it).
	 * @param Pawn - The pawn that wants to be based on this actor
	 */
	virtual bool CanBeBaseForCharacter(class APawn* Pawn) const;

决定movement行走的时候 ,能不能跨过或者压过这个actor过去

另外


	/**
	 * Return true if the given Pawn can step up onto this component.
	 * This controls whether they can try to step up on it when they bump in to it, not whether they can walk on it after landing on it.
	 * @param Pawn the Pawn that wants to step onto this component.
	 * @see CanCharacterStepUpOn
	 */
	UFUNCTION(BlueprintCallable, Category=Collision)
	virtual bool CanCharacterStepUp(class APawn* Pawn) const;


bool UPrimitiveComponent::CanCharacterStepUp(APawn* Pawn) const
{
	if ( CanCharacterStepUpOn != ECB_Owner )
	{
		return CanCharacterStepUpOn == ECB_Yes;
	}
	else
	{	
		const AActor* Owner = GetOwner();
		return Owner && Owner->CanBeBaseForCharacter(Pawn);
	}
}

----------------------------------------------------------

 true  能走下悬崖

false 不能向前走

--------------------------------------------------------------------------------

跳起来 在空中 响应玩家input

 跟 这三个参数有关,尤其是AirControl有关

//在计算速度的时候用的加速度是FallAcceleration
void UCharacterMovementComponent::PhysFalling(float deltaTime, int32 Iterations)
{
    ........
	FVector FallAcceleration = GetFallingLateralAcceleration(deltaTime);
	FallAcceleration.Z = 0.f;
	const bool bHasLimitedAirControl = ShouldLimitAirControl(deltaTime, FallAcceleration);


	while( (remainingTime >= MIN_TICK_TIME) && (Iterations < MaxSimulationIterations) )
	{

        ........

		const FVector OldVelocity = Velocity;

		// Apply input
		const float MaxDecel = GetMaxBrakingDeceleration();
		if (!HasAnimRootMotion() && !CurrentRootMotion.HasOverrideVelocity())
		{
			// Compute Velocity
			{
				// Acceleration = FallAcceleration for CalcVelocity(), but we restore it after using it.
				TGuardValue<FVector> RestoreAcceleration(Acceleration, FallAcceleration);
				Velocity.Z = 0.f;
				CalcVelocity(timeTick, FallingLateralFriction, false, MaxDecel);
				Velocity.Z = OldVelocity.Z;
			}
		}
    }
}

//FallAcceleration 用的是加速度的xy,本身包含input
//但是如果FallAcceleration.SizeSquared2D() > 0.f 就会对FallAcceleration 改变
//
FVector UCharacterMovementComponent::GetFallingLateralAcceleration(float DeltaTime)
{
	// No acceleration in Z
	FVector FallAcceleration = FVector(Acceleration.X, Acceleration.Y, 0.f);

	// bound acceleration, falling object has minimal ability to impact acceleration
	if (!HasAnimRootMotion() && FallAcceleration.SizeSquared2D() > 0.f)
	{
		FallAcceleration = GetAirControl(DeltaTime, AirControl, FallAcceleration);
		FallAcceleration = FallAcceleration.GetClampedToMaxSize(GetMaxAcceleration());
	}

	return FallAcceleration;
}

//TickAirControl 大于0 就改变TickAirControl 
FVector UCharacterMovementComponent::GetAirControl(float DeltaTime, float TickAirControl, const FVector& FallAcceleration)
{
	// Boost
	if (TickAirControl != 0.f)
	{
		TickAirControl = BoostAirControl(DeltaTime, TickAirControl, FallAcceleration);
	}

	return TickAirControl * FallAcceleration;
}

//如果速度小于AirControlBoostVelocityThreshold 就改变TickAirControl 
//AirControlBoostMultiplier * TickAirControl
//所以把TickAirControl=1就会完全用原始加速度的xy
float UCharacterMovementComponent::BoostAirControl(float DeltaTime, float TickAirControl, const FVector& FallAcceleration)
{
	// Allow a burst of initial acceleration
	if (AirControlBoostMultiplier > 0.f && Velocity.SizeSquared2D() < FMath::Square(AirControlBoostVelocityThreshold))
	{
		TickAirControl = FMath::Min(1.f, AirControlBoostMultiplier * TickAirControl);
	}

	return TickAirControl;
}

 所以把AirControl=1 并且 AirControlBoostMultiplier>1 就会用原始的加速度的xy,就会响应input

-----------------------------------------------------------------------------------------------

input

用户的input 在这里传输到movement里面

void UCharacterMovementComponent::TickComponent(float DeltaTime, enum ELevelTick TickType, FActorComponentTickFunction *ThisTickFunction)
{
	SCOPED_NAMED_EVENT(UCharacterMovementComponent_TickComponent, FColor::Yellow);
	SCOPE_CYCLE_COUNTER(STAT_CharacterMovement);
	SCOPE_CYCLE_COUNTER(STAT_CharacterMovementTick);
	CSV_SCOPED_TIMING_STAT_EXCLUSIVE(CharacterMovement);

	FVector InputVector = FVector::ZeroVector;
	bool bUsingAsyncTick = (CharacterMovementCVars::AsyncCharacterMovement == 1) && IsAsyncCallbackRegistered();
	if (!bUsingAsyncTick)
	{
		// Do not consume input if simulating asynchronously, we will consume input when filling out async inputs.
		InputVector = ConsumeInputVector();
	}
..........................................

		// Allow root motion to move characters that have no controller.
		if (CharacterOwner->IsLocallyControlled() || (!CharacterOwner->Controller && bRunPhysicsWithNoController) || (!CharacterOwner->Controller && CharacterOwner->IsPlayingRootMotion()))
		{
			ControlledCharacterMove(InputVector, DeltaTime);
		}
}
void UCharacterMovementComponent::ControlledCharacterMove(const FVector& InputVector, float DeltaSeconds)
{
	{
		SCOPE_CYCLE_COUNTER(STAT_CharUpdateAcceleration);

		// We need to check the jump state before adjusting input acceleration, to minimize latency
		// and to make sure acceleration respects our potentially new falling state.
		CharacterOwner->CheckJumpInput(DeltaSeconds);

		// apply input to acceleration
		Acceleration = ScaleInputAcceleration(ConstrainInputAcceleration(InputVector));
		AnalogInputModifier = ComputeAnalogInputModifier();
	}

	if (CharacterOwner->GetLocalRole() == ROLE_Authority)
	{
		PerformMovement(DeltaSeconds);
	}
	else if (CharacterOwner->GetLocalRole() == ROLE_AutonomousProxy && IsNetMode(NM_Client))
	{
		ReplicateMoveToServer(DeltaSeconds, Acceleration);
	}
}

FVector UCharacterMovementComponent::ConstrainInputAcceleration(const FVector& InputAcceleration) const
{
	// walking or falling pawns ignore up/down sliding
	if (InputAcceleration.Z != 0.f && (IsMovingOnGround() || IsFalling()))
	{
		return FVector(InputAcceleration.X, InputAcceleration.Y, 0.f);
	}

	return InputAcceleration;
}

 //

FVector UCharacterMovementComponent::ScaleInputAcceleration(const FVector& InputAcceleration) const
{
	return GetMaxAcceleration() * InputAcceleration.GetClampedToMaxSize(1.0f);
}
//input最大就是1  acc最大也就是maxacc 

//加速度直接就影响速度了
void UCharacterMovementComponent::CalcVelocity(float DeltaTime, float Friction, bool bFluid, float BrakingDeceleration)
{
	// Do not update velocity when using root motion or when SimulatedProxy and not simulating root motion - SimulatedProxy are repped their Velocity
	if (!HasValidData() || HasAnimRootMotion() || DeltaTime < MIN_TICK_TIME || (CharacterOwner && CharacterOwner->GetLocalRole() == ROLE_SimulatedProxy && !bWasSimulatingRootMotion))
	{
		return;
	}

	Friction = FMath::Max(0.f, Friction);
	const float MaxAccel = GetMaxAcceleration();
	float MaxSpeed = GetMaxSpeed();
	
	// Check if path following requested movement
	bool bZeroRequestedAcceleration = true;
	FVector RequestedAcceleration = FVector::ZeroVector;
	float RequestedSpeed = 0.0f;
	if (ApplyRequestedMove(DeltaTime, MaxAccel, MaxSpeed, Friction, BrakingDeceleration, RequestedAcceleration, RequestedSpeed))
	{
		bZeroRequestedAcceleration = false;
	}

	if (bForceMaxAccel)
	{
		// Force acceleration at full speed.
		// In consideration order for direction: Acceleration, then Velocity, then Pawn's rotation.
		if (Acceleration.SizeSquared() > UE_SMALL_NUMBER)
		{
			Acceleration = Acceleration.GetSafeNormal() * MaxAccel;
		}
		else 
		{
			Acceleration = MaxAccel * (Velocity.SizeSquared() < UE_SMALL_NUMBER ? UpdatedComponent->GetForwardVector() : Velocity.GetSafeNormal());
		}

		AnalogInputModifier = 1.f;
	}

	// Path following above didn't care about the analog modifier, but we do for everything else below, so get the fully modified value.
	// Use max of requested speed and max speed if we modified the speed in ApplyRequestedMove above.
	const float MaxInputSpeed = FMath::Max(MaxSpeed * AnalogInputModifier, GetMinAnalogSpeed());
	MaxSpeed = FMath::Max(RequestedSpeed, MaxInputSpeed);

	// Apply braking or deceleration
	const bool bZeroAcceleration = Acceleration.IsZero();
	const bool bVelocityOverMax = IsExceedingMaxSpeed(MaxSpeed);
	
	// Only apply braking if there is no acceleration, or we are over our max speed and need to slow down to it.
	if ((bZeroAcceleration && bZeroRequestedAcceleration) || bVelocityOverMax)
	{
		const FVector OldVelocity = Velocity;

		const float ActualBrakingFriction = (bUseSeparateBrakingFriction ? BrakingFriction : Friction);
		ApplyVelocityBraking(DeltaTime, ActualBrakingFriction, BrakingDeceleration);
	
		// Don't allow braking to lower us below max speed if we started above it.
		if (bVelocityOverMax && Velocity.SizeSquared() < FMath::Square(MaxSpeed) && FVector::DotProduct(Acceleration, OldVelocity) > 0.0f)
		{
			Velocity = OldVelocity.GetSafeNormal() * MaxSpeed;
		}
	}
	else if (!bZeroAcceleration)
	{
		// Friction affects our ability to change direction. This is only done for input acceleration, not path following.
		const FVector AccelDir = Acceleration.GetSafeNormal();
		const float VelSize = Velocity.Size();
		Velocity = Velocity - (Velocity - AccelDir * VelSize) * FMath::Min(DeltaTime * Friction, 1.f);
	}

	// Apply fluid friction
	if (bFluid)
	{
		Velocity = Velocity * (1.f - FMath::Min(Friction * DeltaTime, 1.f));
	}

	// Apply input acceleration
	if (!bZeroAcceleration)
	{
		const float NewMaxInputSpeed = IsExceedingMaxSpeed(MaxInputSpeed) ? Velocity.Size() : MaxInputSpeed;
		Velocity += Acceleration * DeltaTime;
		Velocity = Velocity.GetClampedToMaxSize(NewMaxInputSpeed);
	}

	// Apply additional requested acceleration
	if (!bZeroRequestedAcceleration)
	{
		const float NewMaxRequestedSpeed = IsExceedingMaxSpeed(RequestedSpeed) ? Velocity.Size() : RequestedSpeed;
		Velocity += RequestedAcceleration * DeltaTime;
		Velocity = Velocity.GetClampedToMaxSize(NewMaxRequestedSpeed);
	}

	if (bUseRVOAvoidance)
	{
		CalcAvoidanceVelocity(DeltaTime);
	}
}

值得注意的是 input 跟角色质量没有关系。如果input要考虑质量就得重写ScaleInputAcceleration方法

---------------------------------------------------------------------

   charactermovement里面有几个方法是跟质量有关的

void UCharacterMovementComponent::AddImpulse( FVector Impulse, bool bVelocityChange )
{
	if (!Impulse.IsZero() && (MovementMode != MOVE_None) && IsActive() && HasValidData())
	{
		// handle scaling by mass
		FVector FinalImpulse = Impulse;
		if ( !bVelocityChange )
		{
			if (Mass > UE_SMALL_NUMBER)
			{
				FinalImpulse = FinalImpulse / Mass;
			}
			else
			{
				UE_LOG(LogCharacterMovement, Warning, TEXT("Attempt to apply impulse to zero or negative Mass in CharacterMovement"));
			}
		}

		PendingImpulseToApply += FinalImpulse;
	}
}

void UCharacterMovementComponent::AddForce( FVector Force )
{
	if (!Force.IsZero() && (MovementMode != MOVE_None) && IsActive() && HasValidData())
	{
		if (Mass > UE_SMALL_NUMBER)
		{
			PendingForceToApply += Force / Mass;
		}
		else
		{
			UE_LOG(LogCharacterMovement, Warning, TEXT("Attempt to apply force to zero or negative Mass in CharacterMovement"));
		}
	}
}

//PendingImpulseToApply 和 PendingForceToApply 直接影响速度Velocity ,注意force是要乘上帧时间的
void UCharacterMovementComponent::ApplyAccumulatedForces(float DeltaSeconds)
{
	if (PendingImpulseToApply.Z != 0.f || PendingForceToApply.Z != 0.f)
	{
		// check to see if applied momentum is enough to overcome gravity
		if ( IsMovingOnGround() && (PendingImpulseToApply.Z + (PendingForceToApply.Z * DeltaSeconds) + (GetGravityZ() * DeltaSeconds) > UE_SMALL_NUMBER))
		{
			SetMovementMode(MOVE_Falling);
		}
	}

	Velocity += PendingImpulseToApply + (PendingForceToApply * DeltaSeconds);
	
	// Don't call ClearAccumulatedForces() because it could affect launch velocity
	PendingImpulseToApply = FVector::ZeroVector;
	PendingForceToApply = FVector::ZeroVector;
}

ApplyAccumulatedForces(DeltaSeconds);和质量有关

ApplyAccumulatedForces函数是在Physxxx函数之前调用的。

input和质量无关

UPrimitiveComponent下有这个,但是movement的物理并不走UpdatedComponent的物理

	UPROPERTY(EditAnywhere, BlueprintReadOnly, Category=Collision, meta=(ShowOnlyInnerProperties, SkipUCSModifiedProperties))
	FBodyInstance BodyInstance;

从UpdatedComponent的类型是 USceneComponent类型 就可以看到,movement只是生硬的操纵USceneComponent的位置和朝向而已。

UPROPERTY(BlueprintReadOnly, Transient, DuplicateTransient, Category=MovementComponent)
	TObjectPtr<USceneComponent> UpdatedComponent;

-----------------------------------------------------------------------------------------------

UCharacterMovementComponent管理角色的移动和空间状态(比如飞翔,游泳,攀爬)

UCharacterMovementComponent是Acharacter的一个组件。它最终操纵的是USceneComponent,如图中的CapsuleComponent。UCharacterMovementComponent::UpdateComponent。

------------------------------------------------------------------------------------------------------------------------------------------------------------

同类的移动组件不止一个如图(盗图)

 这里写图片描述

详解上图四类移动组件

1、

 UMovementComponent,实现了最基本的移动功能。调用顺序如下

UMovementComponent::SafeMoveUpdatedComponent()

UMovementComponent::MoveUpdatedComponentImpl()

UpdatedComponent->MoveComponent()

这里的UpdatedComponent就是UScenceComponent。

2、UNavMovementComponent,用于ai,能自动寻路。

3、UPawnMovementComponent,增加了被玩家控制的功能。

void UPawnMovementComponent::AddInputVector(FVector WorldAccel, bool bForce /*=false*/)
{
	if (PawnOwner)
	{
		PawnOwner->Internal_AddMovementInput(WorldAccel, bForce);
	}
}

 流程是,玩家通过InputComponent组件绑定一个按键操作,当按下按键的时候,调用pawn的AddMovementInput方法。

void APawn::AddMovementInput(FVector WorldDirection, float ScaleValue, bool bForce /*=false*/)
{
	UPawnMovementComponent* MovementComponent = GetMovementComponent();
	if (MovementComponent)
	{
		MovementComponent->AddInputVector(WorldDirection * ScaleValue, bForce);
	}
	else
	{
		Internal_AddMovementInput(WorldDirection * ScaleValue, bForce);
	}
}

调用结束后会通过ConsumeMovementInputVector()接口消耗掉该次操作的输入数值,完成一次移动操作。

 4、UCharacterMovementComponent,此组件就是能操纵人型角色的移动组件了,里面非常精准的处理了各种常见的移动状态细节,实现了比较流畅的同步解决方案,各种位置矫正,平滑处理。我们不需要写很多自己的代码就能做出动作复杂的真实的FPS和第三人称的RPG游戏。

5、UProjectileMovementComponent ,一般用来模拟弓箭,子弹等抛射物的运动状态。

----------------------------------------------------------------------------------------------------------------------------------------------------------------

一个总的框架图(盗图)

 来看看UCharacterMovementComponent进化历史:

在一个普通的三维空间里,最简单的移动就是直接修改角色的坐标。所以,我们的角色只要有一个包含坐标信息的组件,就可以通过基本的移动组件完成移动。但是随着游戏世界的复杂程度加深,我们在游戏里面添加了可行走的地面,可以探索的海洋。我们发现移动就变得复杂起来,玩家的脚下有地面才能行走,那就需要不停的检测地面碰撞信息(FFindFloorResult,FBasedMovementInfo);玩家想进入水中游泳,那就需要检测到水的体积(GetPhysicsVolume(),Overlap事件,同样需要物理);水中的速度与效果与陆地上差别很大,那就把两个状态分开写(PhysSwimming,PhysWalking);移动的时候动画动作得匹配上啊,那就在更新位置的时候,更新动画(TickCharacterPose);移动的时候碰到障碍物怎么办,被其他玩家推怎么处理(MoveAlongFloor里有相关处理);游戏内容太少,想增加一些可以自己寻路的NPC,又需要设置导航网格(涉及到FNavAgentProperties);一个玩家太无聊,那就让大家一起联机玩(模拟移动同步FRepMovement,客户端移动修正ClientUpdatePositionAfterServerUpdate)。

-------------------------------------------------------------------------------------------------------------------------------------------------------------------

UCharacterMovementComponent是如何处理各种移动状态下的玩家呢?首先从tick开始,因为每帧都要检测、判断、处理各种状态,状态通过MovementMode来区分,在合适的时候修改为正确的移动模式。移动模式默认几种:

/** Movement modes for Characters. */
UENUM(BlueprintType)
enum EMovementMode
{
	/** None (movement is disabled). */
	MOVE_None		UMETA(DisplayName="None"),

	/** Walking on a surface. */
	MOVE_Walking	UMETA(DisplayName="Walking"),

	/** 
	 * Simplified walking on navigation data (e.g. navmesh). 
	 * If GetGenerateOverlapEvents() is true, then we will perform sweeps with each navmesh move.
	 * If GetGenerateOverlapEvents() is false then movement is cheaper but characters can overlap other objects without some extra process to repel/resolve their collisions.
	 */
	MOVE_NavWalking	UMETA(DisplayName="Navmesh Walking"),

	/** Falling under the effects of gravity, such as after jumping or walking off the edge of a surface. */
	MOVE_Falling	UMETA(DisplayName="Falling"),

	/** Swimming through a fluid volume, under the effects of gravity and buoyancy. */
	MOVE_Swimming	UMETA(DisplayName="Swimming"),

	/** Flying, ignoring the effects of gravity. Affected by the current physics volume's fluid friction. */
	MOVE_Flying		UMETA(DisplayName="Flying"),

	/** User-defined custom movement mode, including many possible sub-modes. */
	MOVE_Custom		UMETA(DisplayName="Custom"),

	MOVE_MAX		UMETA(Hidden),
};

单机模式下移动处理流程如下:

 未完待续

  • 1
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值