自定义背包功能组件
1.自定义组件类的创建
需要继承UActorComponent类
UCLASS( ClassGroup=(Custom), meta=(BlueprintSpawnableComponent) ) class QIANGGEDAIWO_API UPackageComponent : public UActorComponent
2.角色基类构造函数中进行创建
角色基类构造函数中创造背包组件
//ALGCharacterBase.h UCLASS() class QIANGGEDAIWO_API ALGCharacterBase : public ACharacter,public IISkinInterface, public IGameplayTagAssetInterface { GENERATED_BODY() protected: UPROPERTY() UPackageComponent* PackageComponent; public: ALGCharacterBase(); }
//ALGCharacterBase.cpp ALGCharacterBase::ALGCharacterBase() { PrimaryActorTick.bCanEverTick = true; PackageComponent = CreateDefaultSubobject<UPackageComponent>(TEXT("PackageComponent")); }
3.添加球体检测组件
角色接近场景中的物体时需要能检测到它们。
所以在角色衍生类中添加检测球,创建球体组件,设置碰撞预设OverlapAll,设置半径
//ALGPlayerCharacter.h UCLASS() class QIANGGEDAIWO_API ALGPlayerCharacter : public ALGCharacterBase { GENERATED_BODY() protected: //球形检测 UPROPERTY(EditAnywhere) class USphereComponent* SphereComponent; public: ALGPlayerCharacter(); }
//ALGPlayerCharacter.cpp ALGPlayerCharacter::ALGPlayerCharacter() { SphereComponent = CreateDefaultSubobject<USphereComponent>(TEXT("SphereComponent")); SphereComponent->SetupAttachment(RootComponent); //设置碰撞预设 SphereComponent->SetCollisionProfileName(TEXT("OverlapAll")); SphereComponent->SetSphereRadius(100.f); //绑定通知,回调函数见下方附近道具列表的维护 SphereComponent->OnComponentBeginOverlap.AddDynamic(this,&ALGPlayerCharacter::OnSphereBeginOverlap); SphereComponent->OnComponentEndOverlap.AddDynamic(this,&ALGPlayerCharacter::OnSphereEndOverlap); }
4.创建场景物品类,并设置碰撞预设
场景物品类可见另一篇文章:[UEC++]自定义组件(背包组件)
我们为场景物品创建一个对象通道如下
并为其设置一个碰撞预设如下
这样就可以为我们的场景物品添加自定义的SceneItemAction碰撞预设,方便我们角色身上的球体检测到场景物品。
UStaticMeshComponent* ASceneItem::GetStaticMeshComponent() { if(!StaticMeshComponent) { //动态创建组件 StaticMeshComponent = NewObject<UStaticMeshComponent>(this); //注册组件到世界 StaticMeshComponent->RegisterComponentWithWorld(GetWorld()); //设置组件依附关系 StaticMeshComponent->AttachToComponent(RootComponent,FAttachmentTransformRules::SnapToTargetNotIncludingScale); //设置碰撞预设 StaticMeshComponent->SetCollisionProfileName(TEXT("SceneItemAction")); } return StaticMeshComponent; }
5.维护附近道具列表,背包列表
视图层维护
物品单UI
同样继承自UUserWidget
class QIANGGEDAIWO_API UPackageItemWidget : public UUserWidget
并为其创建蓝图子类,UMG布局如下
代码如下
//UPackageItemWidget.h UCLASS() class QIANGGEDAIWO_API UPackageItemWidget : public UUserWidget { GENERATED_BODY() protected: //图标 UPROPERTY(meta=(BindWidget)) UImage* ItemImage; //名称 UPROPERTY(meta=(BindWidget)) UTextBlock* ItemTextBlock; //对应的物品指针 UPROPERTY() ASceneItem* BindSceneItem; //背包中所在位置的序号下标 int32 BindSite; public: void Initpanel(ASceneItem* Item); void Initpanel(int32 ID); void Initpanel(int32 Site, int32 ID); ASceneItem* GetBindSceneItem(){return BindSceneItem;} int32 GetBindSite(){return BindSite;} };
//UPackageItemWidget.cpp void UPackageItemWidget::Initpanel(ASceneItem* Item) { if(Item) { FPropsBase* PropsBase = GetWorld()->GetGameInstance()->GetSubsystem<UProsSubsystem>()->GetPropsByID(Item->GetID()); if(PropsBase) { if(ItemImage)ItemImage->SetBrushFromTexture(PropsBase->Texture); if(ItemTextBlock)ItemTextBlock->SetText(PropsBase->Name); } //为UI绑定一个他对应地图上哪个Item的指针 BindSceneItem = Item; } } void UPackageItemWidget::Initpanel(int32 ID) { if(FPropsBase* Props = GetWorld()->GetGameInstance()->GetSubsystem<UProsSubsystem>()->GetPropsByID(ID)) { ItemImage->SetBrushFromTexture(Props->Texture); ItemTextBlock->SetText(Props->Name); } } void UPackageItemWidget::Initpanel(int32 Site, int32 ID) { Initpanel(ID); BindSite = Site; }
背包UI
为背包UI创建一个C++父类继承UUserWidget类
class QIANGGEDAIWO_API UPackageWidget : public UUserWidget
创建蓝图子类的UMG布局大致如下
打开背包加载附近物品UI
//UPackageWidget.h protected: //NativeConstruct函数在每次加入视口时调用 virtual void NativeConstruct() override;
//UPackageWidget.cpp void UPackageWidget::NativeConstruct() { Super::NativeConstruct(); if (ALGPlayerCharacter* Player = Cast<ALGPlayerCharacter>(GetOwningPlayerPawn())) { //获取背包组件中的附近物品列表,每有一个成员创建一个物品单UMG TArray<ASceneItem*> SceneItems = Player->GetPackageComponent()->GetItemArray(); TSubclassOf<UPackageItemWidget> Class = LoadClass<UPackageItemWidget>(nullptr,TEXT("/Script/UMGEditor.WidgetBlueprint'/Game/UMG/Package/WBP_Packageitem.WBP_Packageitem_C'")); for (auto Item :SceneItems) { UPackageItemWidget* PackageItemWidget = CreateWidget<UPackageItemWidget>(GetOwningPlayer(),Class); //初始化物品面板 PackageItemWidget->Initpanel(Item); //加入ScrollBox容器 NearItemScrollBox->AddChild(PackageItemWidget); } } }
关闭背包时记得清除容器
void UPackageWidget::RemoveFromParent() { if(NearItemScrollBox) { NearItemScrollBox->ClearChildren(); } Super::RemoveFromParent(); }
数据层维护
场景中的物品则直接拖拽ASceneItem类进场景中实例化即可,
角色附件的道具信息和背包中的物品信息则在背包组件中维护,背包使用映射来存物品(键是位置索引,值是物品ID),如下
数据结构
//UPackageComponent.h protected: //附近物品列表 UPROPERTY() TArray<ASceneItem*> ItemArray; //玩家背包内容 TMap<int32, int32> PackageMap;
场景物品进入与离开检测球(附近物品数据列表维护)
进入时将物品指针存入TArray<ASceneItem*> ItemArray,离开时删除,如下
ALGPlayerCharacter::ALGPlayerCharacter() { SphereComponent = CreateDefaultSubobject<USphereComponent>(TEXT("SphereComponent")); SphereComponent->SetupAttachment(RootComponent); //设置碰撞预设 SphereComponent->SetCollisionProfileName(TEXT("OverlapAll")); SphereComponent->SetSphereRadius(100.f); //绑定通知 SphereComponent->OnComponentBeginOverlap.AddDynamic(this,&ALGPlayerCharacter::OnSphereBeginOverlap); SphereComponent->OnComponentEndOverlap.AddDynamic(this,&ALGPlayerCharacter::OnSphereEndOverlap); } void ALGPlayerCharacter::OnSphereBeginOverlap(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor, UPrimitiveComponent* OtherComp, int32 OtherBodyIndex, bool bFromSweep, const FHitResult& SweepResult) { if(PackageComponent) { if(ASceneItem* SceneItem = Cast<ASceneItem>(OtherActor)) { PackageComponent->AddToItemArray(SceneItem); } } } void ALGPlayerCharacter::OnSphereEndOverlap(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor, UPrimitiveComponent* OtherComp, int32 OtherBodyIndex) { if(PackageComponent) { if(ASceneItem* SceneItem = Cast<ASceneItem>(OtherActor)) { PackageComponent->RemoveFromItemArray(SceneItem); } } }
背包物品(道具加入背包、道具移出背包)
//UPackageComponent.h public: //场景物品进入背包 void AddSceneItemToPackage(ASceneItem* Item); //单纯加进背包 void AddToPackage(int32 ID); //获取背包空位置 int32 GetFreeSite(); //背包道具扔进场景 void RemovePakcageItemToScene(int32 Site); //在玩家周围生成物品 void SpawnSceneItem(int32 ID); //删除背包内物品逻辑 void RemovePackageMapItem(int32 Site,bool bBroadcast = false);
//UPackageComponent.cpp void UPackageComponent::AddSceneItemToPackage(ASceneItem* Item) { if (!Item) { return; } //判断服务端,联机功能代码,与业务功能无关 if(!GetOwner()->HasAuthority()) { Server_AddSceneItemToPackage(Item); return; } //之后的代码会在服务器端执行 AddToPackage(Item->GetID());//调用加入背包函数 Item->Destroy();//删除场景中物品对象 } void UPackageComponent::AddToPackage(int32 ID) { FPropsBase* PropsByID = GetWorld()->GetGameInstance()->GetSubsystem<UProsSubsystem>()->GetPropsByID(ID); if(!PropsByID)return; int32 Site = GetFreeSite();//获取没有使用的背包位置索引 PackageMap.Add(Site,ID); Client_OnAddToPackeage(Site,ID);//向UI广播背包数据变更事件 } int32 UPackageComponent::GetFreeSite() { int32 Site = 0; while (PackageMap.Contains(Site)) { ++Site; } return Site; } void UPackageComponent::RemovePakcageItemToScene(int32 Site) { if(PackageMap.Contains(Site)) { //判断服务端,联机功能代码,与业务功能无关 if (!GetOwner()->HasAuthority()) { Server_RemovePackageItemToScene(Site); return; } //之后的代码会在服务器端执行 SpawnSceneItem(PackageMap[Site]); RemovePackageMapItem(Site,true); } } void UPackageComponent::SpawnSceneItem(int32 ID) { FVector NewDirection = GetOwner()->GetActorForwardVector().RotateAngleAxis(FMath::FRandRange(-180.f,180.f),FVector::UpVector); FVector NewLocation = GetOwner()->GetActorLocation() + NewDirection*FMath::FRandRange(80.f,120.f); FTransform NewTrans; NewTrans.SetLocation(NewLocation); //滞后生成 ASceneItem* SpawnActor = GetWorld()->SpawnActorDeferred<ASceneItem>(ASceneItem::StaticClass(),NewTrans); if(SpawnActor) { SpawnActor->SetID(ID); SpawnActor->FinishSpawning(NewTrans);//完成生成(执行Begin Play) } } void UPackageComponent::RemovePackageMapItem(int32 Site, bool bBroadcast) { if(PackageMap.Contains(Site)) { int32 ItemID = PackageMap[Site]; PackageMap.Remove(Site); if(bBroadcast) { Client_RemovePackageMapItem(Site,ItemID);//广播背包数据变化事件,供UI界面更新 } } }
事件委托更新UI列表
打开背包UI时,物品进出附近,需要完成UI的实时更新。
如果时线性逻辑代码来处理的话,背包组件中修改附近物品列表后需要获取UI对象然后为其处理UI逻辑,这样背包组件对象与背包UI对象耦合度太高,换句话说就是背包组件只管背包数据就行,不要再去管UI了,UI的变化交给UI类对象来做。
添加事件委托可以让背包组件与UI类进行松耦合。
使用时需要先声明一个事件委托,然后创建事件委托对象,在需要的地方进行广播事件,因此这3个步骤都写在触发事件的类中,比如我们的背包组件中,并且是在附近物品列表ItemArray发生变化时广播,
背包组件类如下
//UPackageComponent.h //声明了事件委托,用于广播附近道具增加或是移除 DECLARE_MULTICAST_DELEGATE_OneParam(DelegateNearItemChanged,ASceneItem*) UCLASS( ClassGroup=(Custom), meta=(BlueprintSpawnableComponent) ) class QIANGGEDAIWO_API UPackageComponent : public UActorComponent { GENERATED_BODY() public: //创建两个事件委托对象,一个用于物品进入附近时,一个用于物品离开附近时 DelegateNearItemChanged OnNearItemEnter; DelegateNearItemChanged OnNearItemLeave; }
//UPackageComponent.cpp void UPackageComponent::AddToItemArray(ASceneItem* Item) { ItemArray.AddUnique(Item); if(OnNearItemEnter.IsBound())//有存在有效的代理绑定 { //事件委托进行广播 OnNearItemEnter.Broadcast(Item); } } void UPackageComponent::RemoveFromItemArray(ASceneItem* Item) { ItemArray.Remove(Item); if(OnNearItemLeave.IsBound()) { OnNearItemLeave.Broadcast(Item); } }
背包UI类如下
//UPackageWidget.h UCLASS() class QIANGGEDAIWO_API UPackageWidget : public UUserWidget { GENERATED_BODY() protected: UPROPERTY(meta=(BindWidget)) UScrollBox* NearItemScrollBox;//附近物品列表 protected: //每次加入视口都会调用一次 virtual void NativeConstruct() override; //附近物品列表 增 减 void OnNearItemEnter(ASceneItem* Item); void OnNearItemLeave(ASceneItem* Item); public: //每次移出视口都会调用一次 virtual void RemoveFromParent() override; };
//.cpp void UPackageWidget::NativeConstruct() { Super::NativeConstruct(); if (ALGPlayerCharacter* Player = Cast<ALGPlayerCharacter>(GetOwningPlayerPawn())) { TArray<ASceneItem*> SceneItems = Player->GetPackageComponent()->GetItemArray(); TSubclassOf<UPackageItemWidget> Class = LoadClass<UPackageItemWidget>(nullptr,TEXT("/Script/UMGEditor.WidgetBlueprint'/Game/UMG/Package/WBP_Packageitem.WBP_Packageitem_C'")); for (auto Item :SceneItems) { UPackageItemWidget* PackageItemWidget = CreateWidget<UPackageItemWidget>(GetOwningPlayer(),Class); //初始化物品面板 PackageItemWidget->Initpanel(Item); NearItemScrollBox->AddChild(PackageItemWidget); } //打开UI时绑定物品进入附近事件委托的回调函数 Player->GetPackageComponent()->OnNearItemEnter.AddUObject(this,&UPackageWidget::OnNearItemEnter); //绑定物品离开附近事件委托的回调函数 Player->GetPackageComponent()->OnNearItemLeave.AddUObject(this,&UPackageWidget::OnNearItemLeave); } } void UPackageWidget::RemoveFromParent() { if(NearItemScrollBox) { NearItemScrollBox->ClearChildren(); } if (ALGPlayerCharacter* Player = Cast<ALGPlayerCharacter>(GetOwningPlayerPawn())) { //关闭UI时解除代理绑定。如果不解绑的话,在多次打开UI会进行多次绑定 Player->GetPackageComponent()->OnNearItemEnter.RemoveAll(this); Player->GetPackageComponent()->OnNearItemLeave.RemoveAll(this); } Super::RemoveFromParent(); } void UPackageWidget::OnNearItemEnter(ASceneItem* Item) { TSubclassOf<UPackageItemWidget> MyClass = LoadClass<UPackageItemWidget>(nullptr,TEXT("/Script/UMGEditor.WidgetBlueprint'/Game/UMG/Package/WBP_Packageitem.WBP_Packageitem_C'")); if(MyClass) { UPackageItemWidget* ItemWidget = CreateWidget<UPackageItemWidget>(GetOwningPlayer(),MyClass); ItemWidget->Initpanel(Item); NearItemScrollBox->AddChild(ItemWidget); } } void UPackageWidget::OnNearItemLeave(ASceneItem* Item) { for (int32 i=0;i<NearItemScrollBox->GetChildrenCount();++i) { if(UPackageItemWidget* ItemWidget = Cast<UPackageItemWidget>(NearItemScrollBox->GetChildAt(i))) { if(ItemWidget->GetBindSceneItem() && ItemWidget->GetBindSceneItem() == Item) { ItemWidget->RemoveFromParent(); break; } } } }
6.制作背包中的物品拖拽功能
拖拽是“背包加入道具”与“背包移出道具”的触发事件,附近物品UI被拖入背包区域触发后续“背包加入道具”逻辑,背包物品UI被拖到背包区域外部触发后续“背包移出道具”逻辑。
想要一个UMG被允许拖拽,
1.NativeOnMouseButtonDown开始拖拽的鼠标事件
需要在拖动的对象物品UI的C++类中重写它的虚函数NativeOnMouseButtonDown并在里面开启拖拽的响应鼠标事件
//UPackageItemWidget.h protected: //鼠标事件 virtual FReply NativeOnMouseButtonDown(const FGeometry& InGeometry, const FPointerEvent& InMouseEvent) override;
//UPackageItemWidget.cpp FReply UPackageItemWidget::NativeOnMouseButtonDown(const FGeometry& InGeometry, const FPointerEvent& InMouseEvent) { //开启拖拽检测 //参数一传递InMouseEvent参数, //参数二哪个widget检测拖拽,一般是this, //参数三鼠标的哪个事件开启拖拽 //DetectDragIfPressed函数的NativeReply方法返回FReply数据 return UWidgetBlueprintLibrary::DetectDragIfPressed(InMouseEvent,this,EKeys::LeftMouseButton).NativeReply; }
2.NativeOnDragDetected拖拽时处理
继承并重写父类的NativeOnDragDetected函数
//UPackageItemWidget.h protected: virtual void NativeOnDragDetected(const FGeometry& InGeometry, const FPointerEvent& InMouseEvent, UDragDropOperation*& OutOperation) override;
void UPackageItemWidget::NativeOnDragDetected(const FGeometry& InGeometry, const FPointerEvent& InMouseEvent, UDragDropOperation*& OutOperation) { //创建拖拽时的临时对象 OutOperation = UWidgetBlueprintLibrary::CreateDragDropOperation(UDragDropOperation::StaticClass()); //设置拖拽显示的虚拟体 OutOperation->DefaultDragVisual = CopySelf(); //设置Payload(拖拽的实际对象) OutOperation->Payload = this; //生成的临时UI并附着于鼠标时,鼠标作为其锚点,这个锚点所在的位置 OutOperation->Pivot = EDragPivot::MouseDown; } UPackageItemWidget* UPackageItemWidget::CopySelf() { if(ALGHUD* Alghud = Cast<ALGHUD>(GetOwningPlayer()->GetHUD())) { //为了方便复用,在HUD中维护一个映射成员来存储频繁创建的UMG UPackageItemWidget* SingleUserWidgetObject = Alghud->GetSingleUserWidgetObject<UPackageItemWidget>(GetClass()); if(SingleUserWidgetObject) { SingleUserWidgetObject->ItemImage->SetBrush(ItemImage->GetBrush()); SingleUserWidgetObject->ItemTextBlock->SetText(ItemTextBlock->GetText()); } return SingleUserWidgetObject; } return nullptr; }
简易的映射管理复用的视图类
解释一下以上代码中的GetSingleUserWidgetObject方法,为了方便复用,在HUD中维护一个映射成员来存储频繁创建的UMG,开放GetSingleUserWidgetObject方法,传入一个UMG类的参数,返回这个UMG类的实例指针,如下
//ALGHUD.h protected: TMap<UClass*,UUserWidget*> SingleUserWidgetObjects; public: template <typename T> T* GetSingleUserWidgetObject(UClass* TemplateClass); }; template <typename T> T* ALGHUD::GetSingleUserWidgetObject(UClass* TemplateClass) { UUserWidget* UserWidget = nullptr; if(SingleUserWidgetObjects.Contains(T::StaticClass())) { UserWidget = SingleUserWidgetObjects[T::StaticClass()]; } else { UserWidget = CreateWidget<UUserWidget>(GetOwningPlayerController(),TemplateClass); SingleUserWidgetObjects.Add(T::StaticClass(),UserWidget); } return Cast<T>(UserWidget); }
3.NativeOnDrop拖拽释放时处理
物品的拖拽一般是拖入某个容器中进行处理,那么我们就要获取这个容器的C++父类,然后继承并重写方法NativeOnDrop,它的最后一个参数UDragDropOperation* InOperation中具有第二步设置的PayLoad,通过InOperation->Payload获取。
“背包加入道具”事件的触发
//UPackageScrollWidget.h UCLASS() class QIANGGEDAIWO_API UPackageScrollWidget : public UUserWidget { GENERATED_BODY() protected: UPROPERTY(meta=(BindWidget)) class UScrollBox* ItemsScrollBox; protected: //拖拽释放事件的回调函数 virtual bool NativeOnDrop(const FGeometry& InGeometry, const FDragDropEvent& InDragDropEvent, UDragDropOperation* InOperation) override; public: void AddPackageItem(int32 Site, int32 ID); void RemovePackageItem(int32 Site, int32 ID); };
bool UPackageScrollWidget::NativeOnDrop(const FGeometry& InGeometry, const FDragDropEvent& InDragDropEvent, UDragDropOperation* InOperation) { if(ItemsScrollBox) { if(ALGCharacterBase* player = Cast<ALGCharacterBase>(GetOwningPlayerPawn())) { if(UPackageComponent* PackageComponent = player->GetPackageComponent()) { //判断拖拽的UI是否为场景中的道具 if(UPackageItemWidget* ItemWidget = Cast<UPackageItemWidget>(InOperation->Payload)) { PackageComponent->AddSceneItemToPackage(ItemWidget->GetBindSceneItem()); return true; } //判断拖拽的UI是否为已装备的道具 else if(USkinFrameWidget* SkinFrameWidget = Cast<USkinFrameWidget>(InOperation->Payload)) { PackageComponent->TakeOffSkinToPackage(SkinFrameWidget->GetSkinType(),true); return true; } //判断拖拽的UI是否为已装备的武器 else if(UWeaponFrameWidget* WeaponFrameWidget = Cast<UWeaponFrameWidget>(InOperation->Payload)) { PackageComponent->UnEquipWeaponToPackage(WeaponFrameWidget->GetWeaponID()); return true; } } } } return false; }
我们注意到返回值是一个bool类型,返回ture则消化掉本次事件,返回false时事件会向下穿透,由下面的对象来处理本次事件。
并且要注意UMG面板的“行为-visibility”属性,如下
“背包移出道具”事件的触发
//UPackageWidget.h protected: virtual bool NativeOnDrop(const FGeometry& InGeometry, const FDragDropEvent& InDragDropEvent, UDragDropOperation* InOperation) override;
bool UPackageWidget::NativeOnDrop(const FGeometry& InGeometry, const FDragDropEvent& InDragDropEvent, UDragDropOperation* InOperation) { if(ALGCharacterBase* Player = Cast<ALGCharacterBase>(GetOwningPlayerPawn())) { if(UPackageComponent* PackageComponent = Player->GetPackageComponent()) { //判断是否为背包中的物品 if(UPackageItemWidget* PackageItemWidget = Cast<UPackageItemWidget>(InOperation->Payload)) { if(!PackageItemWidget->GetBindSceneItem()) { PackageComponent->RemovePakcageItemToScene(PackageItemWidget->GetBindSite()); } } //判断是否为装备的物品 else if(USkinFrameWidget* SkinFrameWidget = Cast<USkinFrameWidget>(InOperation->Payload)) { PackageComponent->TakeOffSkinToScene(SkinFrameWidget->GetSkinType(),true); } //判断是否为装备的武器 else if(UWeaponFrameWidget* WeaponFrameWidget = Cast<UWeaponFrameWidget>(InOperation->Payload)) { PackageComponent->UnEquipWeaponToScene(WeaponFrameWidget->GetWeaponID()); } } } return true; }
滞后生成SpawnActorDeferred
//滞后生成 ASceneItem* SpawnActor = GetWorld()->SpawnActorDeferred<ASceneItem>(ASceneItem::StaticClass(),NewTrans); if(SpawnActor) { SpawnActor->SetID(ID);//滞后生成的目的就是在生成Actor后我们需要初始化ID后在执行与世界交互的逻辑 SpawnActor->FinishSpawning(NewTrans);//完成生成(执行Begin Play) }
今天的文章
[UEC++]自定义组件(背包组件)分享到此就结束了,感谢您的阅读。
版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。
如需转载请保留出处:https://bianchenghao.cn/bian-cheng-ji-chu/100311.html