不同的公司,项目,开发者使用的游戏引擎都不一样。大家会跟着市场的步伐会切换工作环境和工作方法。那么完全放弃之前积累的经验,重新通过看教程和文档来学习新的知识是不明智的。我会尽量找到变化和共通点,然后消化吸收它。

我快速整理一遍相关资料,尝试进行思维转换与适应。

UE4官方提供了《为Unity开发者准备的虚幻引擎4指南》的文档(感谢

的提醒,使我想起了这个文档,实际上这个文档已经存在在我的收藏夹中,但我犯了只收藏没有看的错误)。这个指南会反复提示你使用蓝图进行可视化编程。。。不知道Unity会不会也提供一个《为UE4开发者准备的Unity指南》,可能UE4用户不屑于使用Unity吧,笑。

官方对于Actor的解释是:在最最基本的层面上,任意可以被放置到关卡中的游戏物体都是一个 Actor。

所有的 Actor 都是继承于 AActor 类,该类是所有可生成的游戏对象的基类。在某种逻辑层面,Actor 可以被认为维系了一些特殊 Object 的容器,这些特殊 Object 被称为组件(Component)。

比如,一个 CameraActor 会包含一个摄像机组件(CameraComponent)。一个摄像机所具有的功能,比如视野,都是在 CameraComponent 中实现。同时也就是说其他 Actor 同样可以包含 CameraComponent ,比如 Character,也能具有同样的摄像机的功能。

指南中提出的GameObject就是Actor的概念我通过经验对比,依然还是觉得有点像是Prefab。GameObject更像实例化后的内容,Actor像是Prefab一样,可以在场景中实例化多个,当Actor进行了修改,那么场景中的物体会同样被修改。Prefab上的子母物体挂载的各种Component和Actor中的是一样的。

在蓝图中的Event就是MonoBehaviour里的Messages,链接为一串的节点可以视为一个函数,这些函数都是最开始在各种Event中被按顺序调用。

某些情况下会觉得蓝图实现起来比较快,但是依然是某些情况,有很大一部分操作还是写代码要快速简洁,否则就是链接一大片节点更难维护。

UE4能够提供的Unity转换的指南文档已经很不错了,可以吸引很多用户过度到UE4。 编写文档的人可能对Unity3D不是非常精通。某些地方和我的经验模型不太一样。 仔细看起来双方的功能都有,蓝图 vs Bolt,C++ vs C# 游戏开始时调用 Start Actor: BeginPlay;

Component: InitializeComponent;

每帧调用,用于更新此Actor Update Actor: Tick(float DeltaSeconds) ;

Component: TickComponent(float DeltaTime, enum ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction);

当此Actor销毁时调用 void OnDestroy Actor: EndPlay(const EEndPlayReason::Type EndPlayReason);

Component: UninitializeComponent;

UE4中对于继承自Actor或Component需要调用不同的函数实现同样的功能 学习UE4中的C++ 防劝退经验:

在游戏技术方面我平时最常用的语言是C#,HLSL和Python,或一些DCC的Max或Vex。UE4只搞过粒子系统,材质系统和过场动画,动画状态机和一部分使用蓝图制作3DUI的经验,在C++方面完全没接触过。

我觉得通过其它语言的经验,应该学起来没啥难度。虽然我一直推荐通过官方文档结合实例的方法进行学习,不过这次有点。。。

大概过了一遍虚幻引擎官方文档,发现里面的内容虽然非常细致,但是有一个致命缺陷就是学习路径规划的有很大问题。

如果你在官方文档里寻找教程,你会首先找到这个链接:编程和脚本编写

如果你继续向下学习,你会到这里:编程快速入门

恭喜你进入了完全错误的学习路线,我是这么认为的。

如果你按照这个官方标准目录结构去学习,它会教导你一步一步的无脑操作(请看下面的截图),最终实现效果。如果你不出错的话,确实可以照搬过来还原效果。但是原理方面完全没有讲解,还是要返回去学习C++基础教程,这样的浪费时间学习是完全无用的。

看了半天前面的教程,对照着做,也都实现了效果,但是觉得还是比较难仔细阅读代码。这是由于官方教程在编写时,假设了你已经懂了很多东西。这说明作者在编写教程时虚拟的教学对象是已经有一定这方面知识的中级用户,但这种用户反而完全不用学基础教程。

我的步骤:

如果你完全没有编程经验,我建议你直接从 蓝图 - 可视化脚本 开始学习,可以快速实现效果。

如果你有其它语言的编写经验,我建议首先熟读代码规范,否则前面的教程中的莫名其妙的约定,会让你处于懵逼状态。

比如Unity中使用C#中没有,UE4代码会莫名其妙自动增加前缀:T,U,A,S,I,E。

编程规范 中有关于这方面约定的描述。前缀 "A" 代表它是 Actor 的子类,而前缀 "U" 代表它是 Object 的子类。还有其他一些前缀,比如 "F" 通常表示一个简单的数据结构类,或者其他非 Uboject 类。

然后看一遍这个文章:

看完后你已经大概的对C++有了一点了解,发现很多内容和C#是一样的。

然后熟悉C++命名规则。

C++正常的命名规则:

C++类、结构体、函数、变量等命名规则详解_水亦心的博客-CSDN博客_结构体命名规范

UE4 C++命名规则:

  • 命名(如类型或变量)中的每个单词需大写首字母,单词间通常无下划线。例如: Health 和 UPrimitiveComponent ,而非 lastMouseCoordinates 或 delta_coordinates 。( PS: C#中是类名首字母大写,变量名首字母小写或前面添加下划线。 )
  • 类型名前缀需使用额外的大写字母,用于区分其和变量命名。例如: FSkin 为类型名,而 Skin 则是 FSkin 的实例。( PS:C#里的类名确实可以添加前缀来进行区分,它这个前缀会在你创建类的时候自动给你在前面加上,这个我忍不了,给人一种强上的感觉。 )
  • 模板类的前缀为T。( T=Template, 模板类: C++类模板(模板类)详解 , C#模板方法模式(Template Method Pattern)实例教程 。 )
  • 继承自 UObject 的类前缀为U。( 前缀U不知道是什么意思, 大钊:《InsideUE4》UObject(一)开篇)
  • 继承自 AActor 的类前缀为A。( 前缀A不知道是什么意思, 大钊:《InsideUE4》GamePlay架构(一)Actor和Component)
  • 继承自 SWidget 的类前缀为S。( S=Slate Widgets, Slate基础)
  • 抽象界面类的前缀为I。( I=Abstract Interfaces,官方文档翻译有问题,应该是抽象接口类,看了半天还是要结合英文版对比着看。 )
  • 列举的前缀为E。( E=Enum,枚举器 )
  • 布尔变量必须以b为前缀(例如 bPendingDestruction 或 bHasFadedIn )。( 又小写了? )
  • 其他多数类均以F为前缀,而部分子系统则以其他字母为前缀。(为什么不是C为前缀,有知道的大佬可以在这里回答一下?)
  • Typedefs应以任何与其类型相符的字母为前缀:若为结构体的Typedefs,则使用F;若为 Uobject 的Typedefs,则使用U,以此类推。(Typedefs与#define的区别)
  • 特别模板实例化的Typedef不再是模板,并应加上相应前缀,例如: typedef TArray<FMytype> FArrayOfMyTypes;
  • C#中省略前缀。( PS: 我都转C++了为啥告诉我C# )
  • 多数情况下,UnrealHeaderTool需要正确的前缀,因此添加前缀至关重要。( 嗯知道了 )
  • 类型和变量的命名为名词。( PS:这个有道理,都是这样的 )
  • 方法名是动词,以描述方法的效果或未被方法影响的返回值。( PS:这个有道理,都是这样的 )
  • 变量、方法和类的命名应清楚、明了且进行描述。命名的范围越大,一个良好的描述性命名就越重要。避免过度缩写。( 那些前缀,已经过度缩写了。终究是我不熟悉导致的。 )

    所有变量应逐个声明,以便对变量的含义提供注释。其同样被JavaDocs格式需要。变量前可使用多行或单行注释,空白行为分组变量可选使用。

    所有返回布尔的函数应发起true/false的询问,如`IsVisible 或`ShouldClearBuffer 。

    程序(无返回值的函数)应在Object后使用强变化动词。一个例外是若方法的Object是其所在的Object;此时需以上下文来理解Object。避免以"Handle"和"Process"为开头;此类动词会引起歧义。

    若函数参数通过引用传递,同时该值会写入函数,建议以"Out"做为函数参数命名的前缀(非必需)。此操作将明确表明传入该参数的值将被函数替换。

    若In或Out参数同样为布尔,以b作为In/Out的前缀,如 bOutResult 。

    返回值的函数应描述返回的值.命名应说明函数将返回的值。此规则对布尔函数极为重要。请参考以下两个范例方法:

    // True的意义是什么?

    bool CheckTea(FTea Tea);

    // 命名明确说明茶是新鲜的

    bool IsTeaFresh(FTea Tea); 范例 float TeaWeight;

    int32 TeaCount;

    bool bDoesTeaStink;

    FName TeaName;

    FString TeaFriendlyName;

    UClass* TeaClass;

    USoundCue* TeaSound;

    UTexture* TeaTexture;

    或者新建一个类会分为 .h 头文件 和 .cpp 实现文件,这个在C#中也不需要。

    类创建基础知识 这个章节介绍了C++使用这两个文件的含义。

    掌握了上面的两个内容,基本就会可以大致的把C++代码翻译成C#代码。

    然后是 C++类向导,这个文章会告诉你之前进行的无脑教程中的操作的前置流程。

    然后再返回头,看 编程快速入门 才能看懂里面的代码。。。

    如果你有其它软件的多年编程经验,我相信迈过这套砍,后面会非常柔顺。

    后面长期在同一个文章内进行更新学习路径。

    代码翻译:

    摘自指南中的内容,但是这个指南貌似没人维护了,许多排版都错乱掉了

    Unity C#:

    using UnityEngine;

    using System.Collections;

    public class MyComponent : MonoBehaviour

    int Count;

    // 使用此函数来初始化。

    void Start

    Count = 0;

    // Update函数会每帧更新。

    void Update

    Count = Count + 1;

    Debug.Log(Count);

    虚幻4 C++:

    #pragma once

    #include "GameFramework/Actor.h"

    #include "MyActor.generated.h"

    UCLASS

    class AMyActor : public AActor

    GENERATED_BODY

    int Count;

    // 为此Actor的属性设置默认值。

    AMyActor

    // Allows Tick to be called

    PrimaryActorTick.bCanEverTick = true;

    // 当游戏开始或此Actor生成时调用。

    void BeginPlay

    Super::BeginPlay;

    Count = 0;

    // 每帧调用。

    void Tick(float DeltaSeconds)

    Super::Tick(DeltaSeconds);

    Count = Count + 1;

    GLog->Log(FString::FromInt(Count));

    从上面的案例可以得出,C++实现同样的效果要比C#麻烦一些。

    实例化 GameObject / 生成 Actor

    在 Unity 中,我们使用 Instantiate 函数来新建对象的实例。

    该函数可以获取任意一种 UnityEngine.Object 类型(GameObject,MonoBehaviour 等),并创建它的拷贝。

    public GameObject EnemyPrefab;

    public Vector3 SpawnPosition;

    public Quaternion SpawnRotation;

    void Start

    GameObject NewGO = (GameObject)Instantiate(EnemyPrefab, SpawnPosition, SpawnRotation);

    NewGO.name = "MyNewGameObject";

    在虚幻 4 中,根据不同的需要,你需要用一些不同的函数来实例化对象。NewObject 用于新建 UObject 类型的实例,而 SpawnActor 用于新建 AActor 类型的实例。

    首先我们简单说下 UObject 和 NewObject。在虚幻中, 子类化 UObject 很像在 Unity 中子类化 ableObject。它们适用于实现一些不需要在游戏中生成的类,或者不需要像Actor那样添加组件的类。

    在 Unity 中,如果要创建自己的 ableObject 子类,你可以这样实例化:

    MyableObject NewSO = ableObject.CreateInstance<MyableObject>;

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

    UMyObject* NewObj = NewObject<UMyObject>;

    那么 Actor 呢?Actor 在World(C++ 中的 UWorld)对象中生成是通过 SpawnActor 方法。如何获取 World 对象?有些 UObject 会提供一个 GetWorld 的方法,所有的 Actor 都具有这个方法。

    你会发现,相比传入另一个 Actor,我们会传入要生成的 Actor 的 "class"。在我们的范例中,"class" 可以是 AMyEnemy 类的任意子类。

    但如果你想创建某个对象的"拷贝",就像 Unity 的 Instantiate 函数那样,你该怎么做呢?

    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);

    你也许想知道"从零开始创建"在这里具体是什么意思。每个对象类在创建时都有一个默认模板,包含了它的默认属性和组件。在创建时如果你并不想修改这些默认属性,也没有提供你自己的模板,虚幻将使用这些默认值来创建该对象。为了更好的说明这个,我们先来看一下 MonoBehaviour 的例子:

    public class MyComponent : MonoBehaviour

    public int MyIntProp = 42;

    public SphereCollider MyCollisionComp = null;

    void Start

    // 创建碰撞组件(如果还未创建的话)

    if (MyCollisionComp == null)

    MyCollisionComp = gameObject.AddComponent<SphereCollider>;

    MyCollisionComp.center = Vector3.zero;

    MyCollisionComp.radius = 20.0f;

    在上面这个例子中,有一个 int 属性,默认是 42,还有一个 SphereCollider 组件,默认半径是 20。

    在虚幻 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;

    在 AMyActor 的构造函数中,我们为这个类设置了属性的默认值。请注意 CreateDefaultSubobject 函数。我们可以用它来创建组件并赋予组件默认值。我们使用这个函数创建的所有子对象都作为默认模板,所以我们可以在子类或蓝图中修改它们。

    在这个例子中,我们获取了一个已知的组件,将它转换为一个特定类型,然后判断能否执行一些操作。

    Unity C#:

    Collider collider = gameObject.GetComponent<Collider>;

    SphereCollider sphereCollider = collider as SphereCollider;

    if (sphereCollider != null)

    // ...

    虚幻 4 C++:

    UPrimitiveComponent* Primitive = MyActor->GetComponentByClass(UPrimitiveComponent::StaticClass);

    USphereComponent* SphereCollider = Cast<USphereComponent>(Primitive);

    if (SphereCollider != nullptr)

    // ...

    销毁 GameObject / Actor

    Unity C#:

    Destroy(MyGameObject);

    虚幻 4 C++:

    MyActor->Destroy;

    销毁 GameObject / Actor (1 秒延迟)

    Unity C#:

    Destroy(MyGameObject, 1);

    虚幻 4 C++:

    MyActor->SetLifeSpan(1);

    禁用 GameObjects / Actors

    Unity C#:

    MyGameObject.SetActive(false);

    虚幻 4 C++:

    // Hides visible components

    MyActor->SetActorHiddenInGame(true);

    // Disables collision components

    MyActor->SetActorEnableCollision(false);

    // Stops the Actor from ticking

    MyActor->SetActorTickEnabled(false);

    通过组件访问 GameObject / Actor

    Unity C#:

    GameObject ParentGO = MyComponent.gameObject;

    虚幻 4 C++:

    AActor* ParentActor = MyComponent->GetOwner;

    通过 GameObject / Actor 访问组件

    Unity C#:

    MyComponent MyComp = gameObject.GetComponent<MyComponent>;

    虚幻 4 C++:

    UMyComponent* MyComp = MyActor->FindComponentByClass<UMyComponent>;

    查找 GameObjects / Actors

    Unity C#:

    // Find GameObject by name

    GameObject MyGO = GameObject.Find("MyNamedGameObject");

    // Find Objects by type

    MyComponent[] Components = Object.FindObjectsOfType(typeof(MyComponent)) as MyComponent[];

    foreach (MyComponent Component in Components)

    // ...

    // Find GameObjects by tag

    GameObject[] GameObjects = GameObject.FindGameObjectsWithTag("MyTag");

    foreach (GameObject GO in GameObjects)

    // ...

    虚幻 4 C++:

    // 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"))))

    // ...

    为 GameObjects / Actors 添加标签

    Unity C#:

    MyGameObject.tag = "MyTag";

    虚幻 4 C++:

    // Actors can have multiple tags

    MyActor.Tags.AddUnique(TEXT("MyTag"));

    为 MonoBehaviours / ActorComponents 添加标签

    Unity C#:

    // This changes the tag on the GameObject it is attached to

    MyComponent.tag = "MyTag";

    虚幻 4 C++:

    // Components have their own array of tags

    MyComponent.ComponentTags.AddUnique(TEXT("MyTag"));

    比较 GameObjects / Actors 和 MonoBehaviours / ActorComponents 的标签

    Unity C#:

    if (MyGameObject.CompareTag("MyTag"))

    // ...

    // Checks the tag on the GameObject it is attached to

    if (MyComponent.CompareTag("MyTag"))

    // ...

    虚幻 4 C++:

    // 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"))))

    // ...

    RayCast射线检测 vs RayTrace射线检测

    Unity C#:

    GameObject FindGOCameraIsLookingAt

    Vector3 Start = Camera.main.transform.position;

    Vector3 Direction = Camera.main.transform.forward;

    float Distance = 100.0f;

    int LayerBitMask = 1 << LayerMask.NameToLayer("Pawn");

    RaycastHit Hit;

    bool bHit = Physics.Raycast(Start, Direction, out Hit, Distance, LayerBitMask);

    if (bHit)

    return Hit.collider.gameObject;

    return null;

    虚幻 4 C++:

    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;

    Unity C#:

    public class MyComponent : MonoBehaviour

    void Start

    collider.isTrigger = true;

    void OnTriggerEnter(Collider Other)

    // ...

    void OnTriggerExit(Collider Other)

    // ...

    虚幻 4 C++:

    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;

    刚体运动(Kinematic Rigidbodies)

    Unity C#:

    public class MyComponent : MonoBehaviour

    void Start

    rigidbody.isKinimatic = true;

    rigidbody.velocity = transform.forward * 10.0f;

    在虚幻 4 中,碰撞组件和刚体组件是同一个组件,它的基类是 UPrimitiveComponent,这个基类有很多不同的子类(USphereComponent,UCapsuleComponent 等)来配合不同的需求。

    虚幻 4 C++:

    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);

    }; 输入事件

    Unity C#:

    public class MyPlayerController : MonoBehaviour

    void Update

    if (Input.GetButtonDown("Fire"))

    // ...

    float Horiz = Input.GetAxis("Horizontal");

    float Vert = Input.GetAxis("Vertical");

    // ...

    虚幻 4 C++:

    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);

    编译与调试:

    我发现我的大部分问题出现在头文件改名,cpp里面没改名导致的。这是因为我在适配VS2019环境的时候,勾了某个XX选项,导致我编译错误依然会正常启动引擎,啥变化都没有。

    C#中的 aaa.bbb 到 C++中会改写为 aaa->bbb 或 aaa :: bbb 。

    //A.h文件

    class A

    public: A;

    void foo;

    :: 是作用域操作符。它被用来说明你在哪个范围:

    //A.cpp文件

    #include "A.h"

    A::A // 这个函数的作用域是A, 它是A的一个成员函数

    void A::foo // 和上面一样的

    void bar // 如前所述,这个函数在全局作用域中::bar

    . 操作符用于访问“堆栈”分配变量的成员。

    //A.cpp文件

    #include "A.h"

    A a; // 这是一个自动变量,通常被称为“栈上变量”。

    a.foo;

    -> 操作符用于访问被指向的对象(堆或堆栈)的成员。

    //A.cpp文件

    #include "A.h"

    A* pA = new A;

    a->foo;

    A* pA2 = &a;

    pa2->foo;

    它相当于(*pA).foo。c#没有自动变量的概念,所以它不需要解引用操作符(这是*pA和pA->正在做的事情)。由于c#几乎把所有东西都当作指针来处理,因此假定解引用。

    着实需要适应一段时间新的语言,以前用C#就很少出现这个问题。再一个就是指针不是很适应,C#中没有指针。

    目前已经切换了几天了,不知道一周后会是什么效果。

    来源知乎专栏:技术美术之技术知识库

    作者:腾讯游戏 高级技术美术 Freddy 返回搜狐,查看更多

    责任编辑:

    声明:该文观点仅代表作者本人,搜狐号系信息发布平台,搜狐仅提供信息存储空间服务。