目录

多人TPS-b站教程

项目代码-github

前言

这门课程简直无敌,内容非常全,事无巨细,老师讲的也非常认真仔细,让我受益贼多

局域网连接

蓝图方式

  1. 创建大厅地图 File->New Level,然后Save Level,命名为Lobby

  2. 创建联机房间

通过Open Level打开Lobby关卡,listen表明该关卡配置为ListenServer,可以接受多个玩家的连接

  1. 加入联机房间

Open ip地址就会加入这个ip计算机上的关卡

  1. 编译保存,并打包游戏

创建一个Build文件夹,然后选择该文件夹打包

  1. 局域网联机测试

将打包的程序,分别运行在两个电脑上,一名玩家按1创建房间,另一名玩家按2加入房间,即可完成联机

C++方式

  1. 首先定义蓝图可调用函数,在任意类中(如MPTestingCharacter.h)

image

  1. 实现创建联系房间,OpenLobby

传入的参数是Lobby.map相对Content的路径,然后前面加上/Game/

image

  1. 实现加入联机房间,CallOpenLevelCallClientTravel

image

  1. 在蓝图中测试这些函数

image

在线子系统 (UE Online Sussystem)

多人游戏需要知道其他玩家的ip,通过Server或者Service得到,UE提供了一个抽象层Online Subsystem,只需要使用这个子系统并对服务进行配置即可

image

image

创建工程并配置Steam子系统

  1. 添加Online Subsystem Steam插件

  2. 在工程的Build.cs中添加OnlineSubsystemSteamOnlineSubsystem依赖

OnlineSubsystem依赖是完整的子系统模块;OnlineSubsystemSteam是特定平台的子系统模块

  1. 使用Online Subsystem Steam还需要配置一些东西,可以在官方文档查看;打开项目目录下Config/DefaultEngine.ini添加配置信息

image

  1. 重新编译,删除Saved,Intermediate, Binaries,重新生成代码

访问在线子系统

  1. 定义IOnlineSessionPtr并获取
// MenuSystemCharacter.h

public:
	// class IOnlineSessionPtr OnlineSessionInterface;
	TSharedPtr<class IOnlineSession, ESPMode::ThreadSafe> OnlineSessionInterface;
// MenuSystemCharacter.cpp

AMenuSystemCharacter::AMenuSystemCharacter()
{
	...
	IOnlineSubsystem* OnlineSubsystem = IOnlineSubsystem::Get();
	if(OnlineSubsystem) 
	{
		OnlineSessionInterface = OnlineSubsystem->GetSessionInterface();
		
		if(GEngine) 
		{
			GEngine->AddOnScreenDebugMessage(
				-1,
				15.f,
				FColor::Blue,
				FString::Printf(TEXT("Found subsystem %s"), *OnlineSubsystem->GetSubsystemName->ToString())
			);
		}
	}
}
  1. 测试是否访问成功

在编辑器下,无论是什么网络模式Standalone, listenserver, client,都无法连接在线子系统

只能先打包,然后运行打包程序才可以!

创建会话

  1. 定义函数和委托
MenuSystemCharacter.h

protected:
	UFUNCTION(BlueprintCallable)
	void CreateGameSession();
	
	void OnCreateSessionComplete(FName SessionName, bool bWasSuccessful);

private:
	FOnCreateSessionCompleteDelegate CreateSessionCompleteDelegate;
  1. 初始化和绑定函数到委托
MenuSystemCharacter.cpp

AMenuSystemCharacter::AMenuSystemCharacter() :
	CreateSessionCompleteDelegate(FOnCreateSessionCompleteDelegate::CreateUObject(this, &ThisClass::OnCreateSessionComplete))
{}
  1. 实现创建会话函数
void AMenuSystemCharacter::CreateGameSession()
{
	// 测试函数,蓝图中按键调用
	if(!OnlineSystemInterface.IsValid()) 
	{
		return;
	}
	// NAME_GameSession: 存在的全局命名
	auto ExistingSession = OnlineSessionInterface->GetNameSession(NAME_GameSession);
	if(ExistSession != nullptr)
	{
		OnlineSystemInterface->DestorySession(NAME_GameSession);
	}
	
	// 委托添加到事件中
	OnlineSessionInterface->AddOnCreateSessionCompleteDelegate_Handle(CreateSessionCompleteDelegate);
	
	// 创建Session设置
	TSharedPtr<FOnlineSessionSettings> SessionSettings = MakeShareable(new FOnlineSessionSettings);
	SessionSettings->bIsLANMatch = false; //是否是局域网连接
	SessionSettings->NumPublicConnections = 4; // 会话最多存在多少玩家
	SessionSettings->bAllowJoinInProgress = true; // 是否允许中途加入
	SessionSettings->bAlloJoinViaPresence = true; // 根据所在地区加入
	SessionSettings->bShouldAdvertise = true; // 是否发布(其他人可以查找该会话)
	SessionSettings->bUsedPresence = true; // 是否使用所在地区
	SessionSettings->bUseLobbiesIfAvailable = true; // 某些版本查找会话报错,就需要加上是否使用大厅
	
	const ULocalPlayer* LocalPlayer = GetWorld()->GetFirstLocalPlayerFromController();
	OnlineSessionInterface->CreateSession(*LocalPlayer->GetPreferredUniqueNetId(), NAME_GameSession, *SessionSettings);
}
  1. 会话完成回调,测试会话是否创建成功
void AMenuSystemCharacter::OnCreateSessionComplete(FName SessionName, bool bWasSuccessful)
{
	if(bWasSuccessful) 
	{
		if(GEngine)
		{
			GEngine->AddOnScreenDebugMessage(
				-1,
				15.f,
				FColor::Blue,
				FString::Printf(TEXT("Created session: %s"), *SessionName.ToString())
			);
		}
	}
	else
	{
		if(GEngine)
		{
			GEngine->AddOnScreenDebugMessage(
				-1,
				15.f,
				FColor::Blue,
				FString::Printf(TEXT("Failed to create session!"))
			);
		}
	}
}
  1. 接着在蓝图中调用CreateGameSession测试

无法创建会话,修改配置文件bInitServerOnClient=true

加入会话

查找会话

  1. 定义函数和委托
MenuSystemCharacter.h

protected:
	UNFUNCTION(BlueprintCallable)
	void JoinGameSession();
	
	void OnFindSessionComplete(bool bWasSuccessful);

private:
	
	FOnFindSessionCompleteDelegate FindSessionCompleteDelegate;
	TSharedPtr<FOnlineSessionSearch> SessionSearch;
  1. 委托绑定函数
AMenuSystemCharacter::AMenuSystemCharacter()
	FindSessionCompleteDelegate(FOnFindSessionCompleteDelegate::CreateUObject(this, &ThisClass::OnFindSessionComplete))
{}
  1. 实现加入游戏会话
void AMenuSystemCharacter::JoinGameSession()
{
	// 寻找游戏会话
	if(!OnlineSessionInterface.IsValid())
	{
		return;
	}
	
	// 绑定委托
	OnlineSessionInterface->AddOnFindSessionCompleteDelegate_Handle(FindSessionCompleteDelegate);
	
	SessionSearch = MakeShareable(new FOnlineSessionSearch);
	SessionSearch->MaxSearchResults = 10000; // 最大搜索数量
	SessionSearch->bIsLanQuery = false; // 不适用局域网
	SessionSearch->QuerySettings.Set(SEARCH_PRESENCE, true, EOnlineComparionOp::Equals); // 是否使用presence,以及比较方式
	
	const ULocalPlayer* LocalPlayer = GetWorld()->GetFirstLocalPlayerFromController();
	OnlineSessionInterface->FindSessions(*LocalPlayer->GetPreferredUniqueNetId(), SessionSearch.ToSharedRef());
}

void AMenuSystemCharacter::OnFindSessionComplete(bool bWasSuccessful)
{
	for(auto Result : SessionSearch->SearchResults)
	{
		FString Id = Result.GetSessionIdStr();
		FString User = Result.Session.OwningUserName;
		if(GEngine)
		{
			GEngine.AddOnScreenDebugMessage(
				-1,
				15.f,
				FColor::Cyan,
				FString::Printf(TEXT("Id: %s, User: %s"), *Id, *User)
			);
		}
	}
}

加入会话二

image

大厅关卡

  1. 创建大厅关卡

File->New Level->Default, 调整后,File->Save Current Level,命名为Lobby

  1. 创建会话,就进入大厅关卡
MenuSystemCharacter.cpp

void AMenuSystemCharacter::OnCreateSessionComplelte(FName SessionName, bool bWasSuccessful)
{
	if(bWasSuccessful)
	{
		...
		
		UWorld* World = GetWorld();
		if(World)
		{
			// 复制路径,保留content之后的,前面加上/Game/
			World->ServerTravel(FString("/Game/ThirdPersonCPP/Maps/Lobby?listen"));
		}
	}
}

指定匹配类型

可能有多种游戏模式,比如团队竞技、爆破模式,所以需要根据类型来匹配对话

  1. 在创建会话时指定类型
MenuSystemCharacter.cpp

void AMenuSystemCharacter::CreateGameSession()
{
	...
	// 创建Session设置
	TSharedPtr<FOnlineSessionSettings> SessionSettings = MakeShareable(new FOnlineSessionSettings);
	...
	// 指定匹配键值对,和访问类型
	SessionSettings.Set(FName("MatchType"), FString("FreeForAll"), EOnlineDataAdvertisementType::ViaOnlineServiceAndPing);
	
	const ULocalPlayer* LocalPlayer = GetWorld()->GetFirstLocalPlayerFromController();
	OnlineSessionInterface->CreateSession(*LocalPlayer->GetPreferredUniqueNetId(), NAME_GameSession, *SessionSettings);
}
  1. 在查找会话时,检查是否匹配指定类型
void AMenuSystemCharacter::OnFindSessionComplete(bool bWasSuccessful)
{
	for(auto Result : SessionSearch->SearchResults)
	{
		FString Id = Result.GetSessionIdStr();
		FString User = Result.Session.OwningUserName;
		
		FString MatchType;
		Result.Session.SessionSettings.Get(FName("MatchType"), MatchType);
		...
		if(MatchType == FString("FreeForAll")) 
		{
			if(GEngine)
			{
				GEngine.AddOnScreenDebugMessage(
					-1,
					15.f,
					FColor::Cyan,
					FString::Printf(TEXT("Joining Match Type: %s"), *MatchType);
				);
			}
		}
	}
}

加入会话 并 获得地址

  1. 创建加入会话完成委托回调
MenuSystemCharacter.h

protected:
	void OnJoinSessionComplete(FName SessionName, EOnJoinSessionCompleteResult::Type Result);

private:
	FOnJoinSessionCompleteDelegate JoinSessionCompleteDelegate;
  1. 绑定委托函数 并 加入会话
MenuSystemCharacter.cpp

AMenuSystemCharacter::AMenuSystemCharacter():
	JoinSessionCompleteDelegate(FOnJoinSessionCompleteDelegate::CreateUObject(this, &ThisClass::OnJoinSessionComplete))
{}

void AMenuSystemCharacter::OnFindSessionComplete(bool bWasSuccessful)
{
	if(!OnlineSessionInterface.IsValid)
	{
		return;
	}
	...
	for(auto Result : SessionSearch->SearchResults)
	{
		...
		if(MatchType == FString("FreeForAll"))
		{
			...
			OnlineSessionInterface->AddOnJoinSessionCompleteDelegate_Handle(JoinSessionCompleteDelegate);
			
			const ULocalPlayer* LocalPlayer = GetWorld()->GetFirstLocalPlayerFromController();
			OnlineSessionInterface->JoinSession(*LocalPlayer->GetPreferredUniqueNetId(), NAME_GameSession, Result);
		}
	}
}
  1. 加入会话完成回调中获取地址 并 进入大厅
void AMenuSystemCharacter::OnJoinSessionComplete(FName SessionName, EOnJoinSessionCompleteResult::Type Result)
{
	if(!OnlineSessionInterface.IsValid)
	{
		return;
	}
	
	FString Address;
	if(OnlineSessionInterface->GetResolvedConnectString(NAME_GameSession, Address))
	{
		if(GEngine)
		{
			GEngine.AddOnScreenDebugMessage(
				-1,
				15.f,
				FColor::Yellow,
				FString::Printf(TEXT("Connect String: %s"), *Address);
			);
		}
		
		APlayerContoller* PlayerController = GetGameInstance()->GetFirstLocalPlayerController();
		if(PlayerController)
		{
			PlayerController->ClientTravel(Address, ETravelType::TRAVEL_Absolute);
		}
	}
}

Multiplayer Plugin

多人会话插件

  1. 创建插件Edit->Plugin->右下角Create Plugin,创建一个空插件MultiplayerSessions

  2. 添加对在线子系统的插件依赖

MultiplayerSessions.uplugin

"Modules": [
	...
],
"Plugins": [
	{
		"Name": "OnlineSubsystem",
		"Enabled": true
	},
	{
		"Name": "OnlineSubsystemSteam",
		"Enabled": true
	}
]
  1. 添加模块依赖
MultiplayerSessions.Build.cs

PublicDependencyModuleNames.AddRange(
	new string[]
	{
		"Core",
		"OnlineSubsystem",
		"OnlineSubsystemSteam",
	}
);

多人会话子系统 : Game Instance Subsystem

image

创建和定义基础内容

  1. 创建MultiplayerSessionsSubsytem继承Game Instance Subsystem,并属于MultiplayerSessions模块

image

  1. 定义基础内容
MultiplayerSessionsSubsytem.h

pbulic:
	UMultiplayerSessionsSubsystem();
	
private:
	IOnlineSessionPtr SessionInterface;
MultiplayerSessionsSubsytem.cpp

UMultiplayerSessionsSubsystem::UMultiplayerSessionsSubsystem()
{
	IOnlineSubsystem* Subsystem = IOnlineSubsystem::Get();
	if(Subsystem)
	{
		SessionInterface = Subsystem->GetSessionInterface();
	}
}

初始化 委托和回调

MultiplayerSessionsSubsystem.h

public:
	//
	// 提供处理会话功能的函数
	//
	void CreateSession(int32 NumPublicConnections, FString MatchType);
	void FindSession(int32 MaxSearchResults);
	void JoinSession(const FOnlineSessionSearchResult& SessionResult);
	void DestorySession();
	void StartSession();

protected:
	//
	// 添加到内部委托的回调函数
	//
	void OnCreateSessionComplete(FName SessionName, bool bWWasSuccessful);
	void OnFindSessionComplete(bool bWWasSuccessful);
	void OnJoinSessionComplete(FName SessionName, EOnJoinSessionCompleteResult::Type Result);
	void OnDestorySessionComplete(FName SessionName, bool bWWasSuccessful);
	void OnStartSessionComplete(FName SessionName, bool bWWasSuccessful);

private:
	//
	// 添加到OnlineSessionInterface回调的委托列表 及 句柄
	//
	FOnCreateSessionCompleteDelegate CreateSessionCompleteDelegate;
	FDelegateHandle CreateSessionCompleteDelegateHandle;
	FOnFindSessionCompleteDelegate FindSessionCompleteDelegate;
	FDelegateHandle FindSessionCompleteDelegateHandle;
	FOnJoinSessionCompleteDelegate JoinSessionCompleteDelegate;
	FDelegateHandle JoinSessionCompleteDelegateHandle;
	FOnDestorySessionCompleteDelegate DestorySessionCompleteDelegate;
	FDelegateHandle DestorySessionCompleteDelegateHandle;
	FOnStartSessionCompleteDelegate StartSessionCompleteDelegate;
	FDelegateHandle StartSessionCompleteDelegateHandle;
UMultiplayerSessionsSubsystem.cpp

UMultiplayerSessionsSubsystem::UMultiplayerSessionsSubsystem():
	CreateSessionCompleteDelegate(FOnCreateSessionCompleteDelegate::CreateUObject(this, &ThisClass::OnCreateSessionComplete)),
	FindSessionCompleteDelegate(FOnFindSessionCompleteDelegate::CreateUObject(this, &ThisClass::OnFindSessionComplete)),
	JoinSessionCompleteDelegate(FOnJoinSessionCompleteDelegate::CreateUObject(this, &ThisClass::OnJoinSessionComplete)),
	DestorySessionCompleteDelegate(FOnDestorySessionCompleteDelegate::CreateUObject(this, &ThisClass::OnDestorySessionComplete)),
	StartSessionCompleteDelegate(FOnStartSessionCompleteDelegate::CreateUObject(this, &ThisClass::OnStartSessionComplete))
{}

	void CreateSession(int32 NumPublicConnections, FString MatchType);
	void FindSession(int32 MaxSearchResults);
	void JoinSession(const FOnlineSessionSearchResult& SessionResult);
	void DestorySession();
	void StartSession();

创建菜单控件(可视化配置和调试)

准备工作

Build.cs中添加UMG相关模块

MultiplayerSessions.Build.cs

PublicDependencyModuleNames.AddRange(
	new string[]
	{
		...
		"UMG",
		"Slate",
		"SlateCore"
	}
);

创建Menu:UserWidget

  1. 创建UserWidget子类Menu,属于MultiplayerSessions模块

  2. 编写Menu代码

Menu.h

public:
	UFUNCTION(BlueprintCallable)
	void MenuSetup(int32 NumberOfPublicConnections = 4, FString TypeOfMatch = FString(TEXT("FreeForAll")));
	
private:
	int32 NumPublicConnections{4};
	FString MatchType{TEXT("FreeForAll")};
Menu.cpp

void UMenu::UMenu(int32 NumberOfPublicConnections = 4, FString TypeOfMatch = FString(TEXT("FreeForAll")))
{
	NumPublicConnections = NumberOfPublicConnections;
	MatchType = TypeOfMatch;
	AddToViewport(); // 添加到视口
	SetVisibility(ESlateVisibility::Visible); // 可见
	bIsFocusable = true; // 聚焦到当前控件
	
	UWorld* World = GetWorld();
	if(World)
	{
		APlayerController PlayerController = World->GetFirstPlayerController();
		if(PlayerController)
		{
			// 需求是只输入应用到UI,不应用到Pawn人物
			FInputModeUIOnly InputModeData;
			InputModeData.SetWidgetToFoucus(TakeWidget()); // 聚焦到任一个SWidget
			InputModeData.SetLockMouseToViewportBehavior(EMouseLockMode::DoNotLock); // 鼠标锁定模式
			
			PlayerController->SetInputMode(InputModeData); //设置输入模式
			PlayerController->SetShowMouseCursor(true); // 显示鼠标光标
		}
	}
}

创建WBP_Menu控件蓝图,并完善Menu

  1. MultiplayerSessions插件下创建WBP_Menu

image

  1. 设置父类为Menu,并添加两个功能按钮HoldJoin

  2. 关卡蓝图中创建蓝图,并调用MenuSetup函数

image

添加按钮回调
Menu.h

protected:
	// 自动初始化控件时调用,MenuSetup初始化菜单,然后Initialize初始化菜单内控件
	virtual bool Initialize() override;
	// 在切换关卡时调用
	virtual void OnLevelRemovedFromWorld(ULevel* InLevel, UWorld* InWorld) override;

private:
	UPROPERTY(meta = (BindWidget))
	class UButton* HostButton; // 命名和蓝图中保持一致
	
	UPROPERTY(meta = (BindWidget))
	UButton* JoinButton; // 命名和蓝图中保持一致
	
	UFUNCTION()
	void HostButtonClicked();
	
	UFUNCTION()
	void JoinButtonClicked();
	
	// 清除Setup的输入设置
	UFUNCTION(BlueprintCallable)
	void MenuTearDown();
Menu.cpp

//
//	初始化菜单内控件
//
bool Menu::Initialize() 
{
	if(!Super::Initialize())
	{
		return false;
	}

	if(HostButton)
	{
		HostButton->OnClicked.AddDynamic(this, &ThisClass::HostButtonClicked);
	}
	if(JoinButton)
	{
		JoinButton->OnClicked.AddDynamic(this, &ThisClass::JoinButtonClicked);
	}
	
	return true;
}

void Menu::HostButtonClicked()
{
	if(GEngine)
	{
		GEngine->AddOnScreenDebugMessage(
			-1,
			15.f,
			FColor::Yellow,
			FString(TEXT("Host Button Clicked"))
		);
	}
}

void Menu::JoinButtonClicked()
{
	if(GEngine)
	{
		GEngine->AddOnScreenDebugMessage(
			-1,
			15.f,
			FColor::Yellow,
			FString(TEXT("Join Button Clicked"))
		);
	}
}

void UMenu::OnLevelRemovedFromWorld(ULevel* InLevel, UWorld* InWorld)
{
	// 切换关卡,清除输入模式
	MenuTearDown();
	Super::OnLevelRemovedFromWorld(InLevel, InWorld);
}

//
// 清除Setup中的输入设置
//
void UMenu::MenuTearDown()
{
	RemoveFromParent(); // 从视口移除
	UWorld* World = GetWorld();
	if(World)
	{
		APlayerController PlayerController = World->GetFirstLocalPlayerController();
		if(PlayerConntroller)
		{
			FInputModeGameOnly InputModeData; // 仅处理游戏数据, 不接受ui
			PlayerController->SetInputMode(InputModeData);
			PlayerController->SetShowMouseCursor(false); // 不显示鼠标指针
		}
	}
}
Menu.h

private:
	...
	class UMultiplayerSessionsSubsystem* MultiplayerSessionsSubsystem;
Menu.cpp

void Menu::MenuSetup()
{
	...
	UGameInstance GameInstance = GetGameInstance();
	if(GameInstance)
	{
		MultiplayerSessionsSubsystem = GameIntance->GetSubsystem<UMultiplayerSessionsSubsystem>();
	}
}

void Menu::HostButtonClicked()
{
	...
	if(MultiplayerSessionsSubsystem)
	{
		// 参数分别是 连接数量(几人房间)、匹配类型(游戏模式)
		MultiplayerSessionsSubsystem->CreateSession(NumPublicConnections, MatchType);
		UWorld* World = GetWorld();
		if(World)
		{
			World->ServerTravel("/Game/ThirdPersonCPP/Maps/Lobby?listen");
		}
	}
}

实现HostButtonClicked

CreateSession

MultiplayerSessionSubsystem.h

private:
	TSharedPtr<FOnlineSessionSettings> LastSessionSettings;
MultiplayerSessionsSubsystem.cpp

void MultiplayerSessionsSubsystem::CreateSession(int32 NumPublicConnections, FString MatchType)
{
	if(!SessionInterface.IsValid())
	{
		return;
	}
	
	auto ExistingSession = SessionInterface->GetNameSession(NAME_GameSession);
	if(ExistingSession != nullptr)
	{
		SessionInterface->DestorySession(NAME_GameSession);
	}
	
	// 存储委托句柄
	CreateSessionCompleteDelegateHandle = SessionInterface->AddOnCreateSessionCompleteDelegate_Handle(CreateSessionCompleteDelegate);
	
	// Session设置
	LastSessionSettings = MakeShareable(new FOnlineSessionSettings());
	LastSessionSettings->bIsLANMatch = IOnlineSubsystem::Get()->GetSubsystemName() == "NULL" ? true : false; // 如果连接到在线服务(如steam)就返回false,否则就是true 局域网匹配
	LastSessionSettings->NumPublicConnections = NumPublicConnections; // 最大连接数
	LastSessionSettings->bAllowJoinInProgress = true; // 中途加入
	LastSessionSettings->bAllowJoinViaPresence = true;
	LastSessionSettings->bShouldAdvertise = true; // 是否公布,允许查找
	LastSessionSettings->bUsePresence = true;
	LastSessionSettings->Set(FName("MatchType"), FString("FreeForAll"), EOnlineDataAdvertisementType::ViaOnlineServiceAndPing); // 匹配类型
	
	auto ULocalPlayer* LocalPlayer = GetWorld()->GetFirstLocalPlayerFromController();
	if(SessionInterface->CreateSession(LocalPlayer.GetPreferedUniqueNetId(), NAME_GameSession, *LastSessionSettings.))
	{
		SessionInterface->ClearOnCreateSessionCompleteDelegate_Handle(CreateSessionCompleteDelegateHandle);
	}
}

功能测试

image

按键退出游戏

image

模块之间的交互关系

Menu菜单 <-> MultiplayerSessions子系统 <-> OnlineSubsystem->OnlineSessionInterface 之间的交互关系

image

MultiplayerSessions子系统中定义委托,然后Menu菜单中绑定回调函数

  • MultiplayerSessions子系统中定义委托
MultiplayeSessionsSubsystem.h

// 定义通知Menu的委托,Menu中绑定回调函数
//
// 注意:动态多播的输入,类型后有逗号,普通多播则没有; 回调函数必须有UFUNCTION()
//
// DYNAMIC: 可以序列化、在蓝图中存储和加载;MULTICAST: 可以绑定多个类的函数;
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FMultiplayerOnCreateSessionComplete, bool, bWasSuccessful);
// 因为FOnlineSessionSearchResult不是新类,与蓝图不兼容,所以不能加入动态委托中
DECLARE_MULTICAST_DELEGATE_TwoParams(FMultiplayerOnFindSessionComplete, const TArray<FOnlineSessionSearchResult>& SessionResult, bool bWasSuccessful);
// EOnJoinSessionCompleteResult与蓝图不兼容
DECLARE_MULTICAST_DELEGATE_OneParam(FMultiplayerOnJoinSessionComplete, EOnJoinSessionCompleteResult::Type Result);
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FMultiplayerOnDestroySessionComplete, bool, bWasSuccessful);
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FMultiplayerOnStartSessionComplete, bool, bWasSuccessful);

public:
	FMultiplayerOnCreateSessionComplete MultiplayerOnCreateSessionComplete;
	FMultiplayerOnFindSessionComplete MultiplayerOnFindSessionComplete;
	FMultiplayerOnJoinSessionComplete MultiplayerOnJoinSessionComplete;
	FMultiplayerOnDestroySessionComplete MultiplayerOnDestroySessionComplete;
	FMultiplayerOnStartSessionComplete MultiplayerOnStartSessionComplete;
MultiplayerSessionsSubsystem.cpp

void UMultiplayerSessionsSubsystem::CreateSession(int32 NumPublicConnections, FString MatchType)
{
	...
	if(SessionInterface->CreateSession(LocalPlayer.GetPreferedUniqueNetId(), NAME_GameSession, *LastSessionSettings.))
	{
		...
		// 通知Menu菜单
		MultiplayerOnCreateSessionComplete.Broadcast(false);
	}
}

void UMultiplayerSessionsSubsystem::OnCreateSessionComplete(FName SessionName, bool bWasSuccessful)
{
	// 清除绑定委托,并通知Menu
	if(SessionInterface)
	{
		SessionInterface->ClearOnCreateSessionCompleteDelegate_Handle(CreateSessionCompleteDelegateHandle);
	}
	
	MultiplayerOnCreateSessionComplete.Broadcast(bWasSuccessful);
}
  • Menu菜单中绑定回调函数
Menu.h

protected:
	//
	// MultiplayerSessionsSubsystem委托的回调函数
	//
	UFUNCTION()
	void OnCreateSession(bool bWasSuccessful);
	UFUNCTION()
	void OnFindSessions(const TArray<FOnlineSessionSearchResult>& SessionResult, bool bWasSuccessful);
	UFUNCTION()
	void OnJoinSession(EOnJoinSessionCompleteResult::Type Result);
	UFUNCTION()
	void OnDestroySession(bool bWasSuccessful);
	UFUNCTION()
	void OnStartSession(bool bWasSuccessful);
Menu.cpp

void Menu::MenuSetup()
{
	...
	if(MultiplayerSessionsSubsystem)
	{
		MultiplayerSessionsSubsystem->MultiplayerOnCreateSessionComplete.AddDynamic(this, &ThisClass::OnCreateSession);
		MultiplayerSessionsSubsystem->MultiplayerOnFindSessionComplete.AddUObject(this, &ThisClass::OnFindSession);
		MultiplayerSessionsSubsystem->MultiplayerOnJoinSessionComplete.AddUObject(this, &ThisClass::OnJoinSession);
		MultiplayerSessionsSubsystem->MultiplayerOnDestroySessionComplete.AddDynamic(this, &ThisClass::OnDestroySession);
		MultiplayerSessionsSubsystem->MultiplayerOnStartSessionComplete.AddDynamic(this, &ThisClass::OnStartSession);
	}
}

void Menu::OnCreateSession(bool bWasSuccessful)
{
	if(bWasSuccessful)
	{
		...
		UWorld* World = GetWorld();
		if(World)
		{
			World->ServerTravel("/Game/ThirdPersonCPP/Maps/Lobby?listen");
		}
	}
	else
	{
		if(GEngine)
		{
			GEngine.AddOnScreenDebugMessage(
				-1,
				15.f,
				FColor::Red,
				FString(TEXT("Failed to create session!"))
			);
		}
	}
}

void Menu::HostButtonClicked()
{
	...
	/*
	UWorld* World = GetWorld();
	if(World)
	{
		World->ServerTravel("/Game/ThirdPersonCPP/Maps/Lobby?listen");
	}
	*/
}

实现JoinButtonClicked

  1. 实现点击按钮,执行FindSessions
Menu.cpp

void UMenu::JoinButtonClicked()
{
	if(MultiplayerSessionSubsystem)
	{
		MultiplayerSessionSubsystem->FindSessions(10000);
	}
}
  1. 子系统中实现FindSessions并通知Menu
MultiplayerSessionsSubsystem.h

private:
	...
	TSharedPtr<FOnlineSessionSearch> LastSessionSearch;
MultiplayerSessionsSubsystem.cpp

void UMultiplayerSessionsSubsystem::FindSessions(int32 MaxSearchResults)
{
	if(!SessionInterface.IsValid())
	{
		return;
	}
	
	FindSessionsCompleteDelegateHandle = SessionInterface->AddOnFindSessionsCompleteDelegate_Handle(FindSessionsCompleteDelegate);
	
	LastSessionSearch = MakeShareable(new FOnlineSessionSearch());
	LastSessionSearch->MaxSearchResults = MaxSearchResults;
	LastSessionSearch->bIsLanQuery = IOnlineSubsystem::Get()->GetSubsystemName() == "NULL" ? true : false;
	LastSessionSearch->QuerySettings.Set(SEARCH_PRESENCE, true, EOnlineComparion::Equals);
	
	const ULocalPlayer* LocalPlayer = GetWorld()->GetFirstLocalPlayerFromController();
	if(!SessionInterface->FindSessions(*LocalPlayer->GetPreferredUniqueNetId(), LastSessionSearch.ToShareRef()))
	{
		SessionInterface->ClearOnFindSessionsCompleteDelegate_Handle(FindSessionsCompleteDelegateHandle);
		
		MultiplayerOnFindSessionsComplete.Broadcast(TArray<FOnlineSessionSearchResult>(), false);
	}
}

void UMultiplayerSessionsSubsystem::OnFindSessionsComplete(bool bWasSuccessful)
{
	if(SessionInterface)
	{
		SessionInterface->ClearOnFindSessionsCompleteDelegate_Handle(FindSessionsCompleteDelegateHandle);
	}
	
	if(LastSessionSearch->SearchResults.Num() <= 0)
	{
		MultiplayerOnFindSessionsComplete.Broadcast(TArray<FOnlineSessionSearchResult>(), false);
		return;
	}
	
	MultiplayerOnFindSessionsComplete.Broadcast(LastSessionSearch->SearchResults, bWasSuccessful);
}
  1. Menu收到通知,调用JoinSession
Menu.cpp

void UMenu::OnFindSessions(const TArray<FOnlineSessionSearchResult>& SessionResults, bool bWasSuccessful>)
{
	for(auto Result : SessionResults)
	{
		FString SettingsValue;
		Result.Session.SessionSettings.Get(FName("MatchType"), SettingsValue);
		if(SettingsValue == MatchType)
		{
			MultiplayerSessionsSubsystem->JoinSession(Result);
			return;
		}
	}
}
  1. 子系统实现JoinSession,并在OnJoinSessionComplete回调时通知Menu
MultiplayerSessionsSubsystem.cpp

void UMultiplayerSessionsSubsystem::JoinSession(const FOnlineSessionSearchResult& SessionResult)
{
	if(!SessionInterface.IsValid())
	{
		MultiplayerOnJoinSessionComplete.Broadcast(EOnJoinSessionCompleteResult::UnknownError);
		return;
	}
	
	JoinSessionCompleteDelegateHandle = SessionInterface->AddOnJoinSessionCompleteDelegate_Handle(JoinSessionCompleteDelegate);
	
	const ULocalPlayer* LocalPlayer = GetWorld()->GetFirstLocalPlayerFromController();
	if(!SessionInterface->JoinSession(*LocalPlayer->GetPreferredUniqueNetId(), NAME_GameSession, SessionResult))
	{
		SessionInterface->ClearOnJoinSessionCompleteDelegate_Handle(JoinSessionCompleteDelegateHandle);
		
		MultiplayerOnJoinSessionComplete.Broadcast(EOnJoinSessionCompleteResult::UnknownError);
	}
}

void MultiplayerSessionsSubsystem::OnJoinSessionComplete(FName SessionName, EOnJoinSessionCompleteResult::Type Result)
{
	if(SessionInterface)
	{
		SessionInterface->ClearOnJoinSessionCompleteDelegate_Handle(JoinSessionCompleteDelegateHandle);
	}
	
	MultiplayerOnJoinSessionComplete.Broadcast(Result);
}
  1. 消息回调到Menu处理,并ClientTravel
Menu.cpp

void UMenu::OnJoinSession(EOnJoinSessionCompleteResult::Type Result)
{
	IOnlineSubsystem* Subsystem = IOnlineSubsystem::Get();
	if(Subsystem)
	{
		IOnlineSessionPtr SessionInterface = Subsystem->GetSessionInterface();
		if(SessionInterface)
		{
			FString Address;
			SessionInterface->GetResolvedConnectString(NAME_GameSession, Address);
			
			APlayerController* PlayerController = GetGameIntance()->GetFirstLocalPlayerFromController();
			if(PlayerController)
			{
				PlayerController->ClientTravel(Address, ETravelType::TRAVEL_Absolute);
			}
		}
	}
}

跟踪游戏状态 (GameMode & GameState)

跟踪玩家数量、进入和退出游戏

  1. 创建LobbyGameMode继承GameModeBase,属于MenuSystem模块即项目模块,非插件
LobbyGameMode.h

public:
	// 玩家进入游戏,并为其创建Player Controller时调用
	virtual void PostLogin(APlayerController* NewPlayer) override;
	// 玩家退出前调用
	virtual void Logout(AController* Exiting) override;
LobbyGameMode.cpp

void ALobbyGameMode::PostLogin(APlayerController* NewPlayer)
{
	Super::PostLogin(NewPlayer);
	
	if(GameState)
	{
		int32 NumberOfPlayers = GameState.Get()->PlayerArray.Num();
		
		if(GEngine)
		{
			GEngine->AddOnScreenDebugMessage(
				1, // 键变,则清除已显示消息
				60.f,
				FColor::Yellow,
				FString::Printf(TEXT("Players in game: %d"), NumberOfPlayers)
			);
			
			APlayerState* PlayerState = NewPlayer->GetPlayerState<APlayerState>();
			if(PlayerState)
			{
				FString PlayerName = PlayerState->GetPlayerName();
				GEngine->AddOnScreenDebugMessage(
					2,
					60.f,
					FColor::Cyan,
					FString::Printf(TEXT("%s has joined the game!"), *PlayerName)
				);
			}
		}
	}
}

void ALobbyGameMode::Logout(AController* Exiting)
{
	Super::Logout(Exiting);

	if(GEngine)
	{
		APlayerState* PlayerState = Exiting->GetPlayerState<APlayerState>();
		if(PlayerState)
		{
			int32 NumberOfPlayers = GameState.Get()->PlayerArray.Num();
			GEngine->AddOnScreenDebugMessage(
				1, // 键变,则清除已显示消息
				60.f,
				FColor::Yellow,
				FString::Printf(TEXT("Players in game: %d"), NumberOfPlayers - 1) // 退出前调用,需要减一
			);
		
			FString PlayerName = PlayerState->GetPlayerName();
			GEngine->AddOnScreenDebugMessage(
				2,
				60.f,
				FColor::Cyan,
				FString::Printf(TEXT("%s has exited the game!"), *PlayerName)
			);
		}
	}
}

补充修改(LastSessionSettings->BuildUniqueId = 1; DefaultEngine.ini)

MultiplayerSessionsSubsystem.cpp

...
LastSessionSettings->BuildUniqueId = 1; // 保证每个会话有独一无二的ID可以区分

image

  1. 创建BP_LobbyGameMode蓝图类,指定DefaultPawnClass->ThirdPersonCharacter,在网络同步时会把这个类信息复制给其他人,从而同步位置、动画等信息

  2. 打开Lobby关卡,在World Settings->GameMode Override->BP_LobbyGameMode

指定大厅路径

把大厅路径以参数形式传入

Menu.h

public:
	MenuSetup(int32 NumberOfPublicConnections = 4, FString TypeOfMatch = FString(TEXT("FreeForAll")), FString LobbyPath = FString(TEXT("/Game/ThirdPersonCPP/Maps/Lobby")))

private:
	...
	FString PathToLobby{TEXT("")};
Menu.cpp

void UMenu:MenuSetup(int32 NumberOfPublicConnections, FString TypeOfMatch, FString LobbyPath)
{
	PathToLobby = FString.Printf(TEXT("%s?listen"), *LobbyPath);
}

void UMenu::OnCreateSession(bool bWasSuccessful)
{
	...
	World->ServerTravel(PathToLobby);
}

bug:刚从一个会话离开,再点Host会创建会话失败

是因为再UMultiplayerSessionSubsystem::CreateSession中,如果存在会话则销毁,然后创建新会话

但由于销毁会话需要通知所有玩家,存在网络时延,此时创建会话会失败

所以需要确保销毁后,才可以创建新会话

DestroySession

MultiplayerSessionsSubsystem.h

public:
	...
	// 销毁完成是否需要建立新会话
	bool bCreateSessionOnDestroy{false};
	// 记录建立新会话的数据
	int32 LastNumPublicConnections;
	FString LastMatchType;
	
MultiplayerSessionsSubsystem.cpp

void UMultiplayerSessionSubsystem::CreateSession(int32 NumPublicConnections, FString MatchType)
{
	...
	if(ExistingSession != nullptr)
	{
		bCreateSessionOnDestroy = true;
		LastNumPublicConnections = NumPublicConnections;
		LastMatchType = MatchType;
		
		// 调用函数
		DestroySession();
	} else {
		...
	}
}

void UMultiplayerSessionSubsystem::DestroySession()
{
	if(!SessionInterface.IsValid())
	{
		MultiplayerOnDestroySessionComplete.Broadcast(false);
		return;
	}
	
	// 监听销毁完成
	DestroySessionCompleteDelegateHandle = SessionInterface->AddOnDestroySessionCompleteDelegate_Handle(DestroySessionCompleteDelegate);
	
	if(!SessionInterface->DestroySession(NAME_GameSession))
	{
		SessionInterface->ClearOnDestroySessionCompleteDelegate_Handle(DestroySessionCompleteDelegateHandle);
		MultiplayerOnDestroySessionComplete.Broadcast(false);
	}
}

void UMultiplayerSessionSubsystem::OnDestroySessionComplete(FName SessionName, bool bWasSuccessful)
{
	if(SessionInterface)
	{
		SessionInterface->ClearOnDestroySessionCompleteDelegate_Handle(DestroySessionCompleteDelegateHandle);
	}
	if(bWasSuccessful && bCreateSessionOnDestroy)
	{
		// 销毁后再建立新会话
		bCreateSessionOnDestroy = false;
		CreateSession(LastNumPublicConnections, LastMatchType);
	}
	MultiplayerOnDestroySessionComplete.Broadcast(bWasSuccessful);
}

完善菜单

退出游戏按钮

WBP_Menu中添加一个QuitButton,添加OnClick事件->QuitGame即可

禁用和启动按钮

  • 不可以连续多次点击,点击后禁用
void UMenu::HostButtonClicked()
{
	HostButton->SetIsEnabled(false);
	...
}
void UMenu::JoinButtonClicked()
{
	JoinButton->SetIsEnabled(false);
	...
}
  • 建立或加入会话失败后,应重新启动按钮
void UMenu::OnCreateSession(bool bWasSuccessful)
{
	...
	else
	{
		...
		HostButton->SetIsEnabled(true);
	}
}

void UMenu::OnFindSessions(...)
{
	...
	if(!bWasSuccessfule || SessionResults.Num() == 0)
	{
		JoinButton.SetIsEnabled(true);
	}
}

void UMenu::OnJoinSession(...)
{
	...
	if(Result != EOnJoinSessionCompleteResult::Success)
	{
		JoinButton->SetIsEnabled(true);
	}
}

Game Project

准备工作

  1. 新建工程Blaster,将MultiplayerSessionsPlugin添加到项目中,启动Online Subsystem Steam插件

MultiplayerSessions文件夹,放到项目目录的Plugins下(没有就新建)

image

  1. 更改配置文件

DefaultEngine.ini中添加需要的配置

image

DefaultGame.ini中添加玩家数量配置

image

  1. 插件生成代码

  2. 保存当前关卡为GameStartupMap,然后新建Lobby关卡

指定起始地图

image

  1. 关卡蓝图中创建菜单控件,并指定LobbyPath

image

补充修改,LastSessionSettings->bUseLobbiesIfAvailable=true

MultiplayerSessionsSubsystem.cpp->Find相关中补充

关卡地图打包

image

  1. 创建Build文件夹,然后打包程序到Build

测试插件 & 使用ThirdPerson

现状:能够进入共同大厅,但是没有网络复制,看不到对方的操作;(默认使用的是空工程,没有人物)

如果使用Third Person,会自动同步动作

LobbyGameMode设置为ThirdPersonGameMode,此时就会同步动作了

导入资源

武器资源

资源商店Military Weapon免费,不支持UE5.0
解决办法:先添加到支持的UE版本,如Ue4.21,然后右键文件夹Migrate迁移到工程的Content

角色资源

Unreal Learning Kit

动画资源

Animation Starter Pack

存在问题

  • 缺少动画(如站立和蹲下的原位动画)
  • 骨骼和要使用的人物骨骼不匹配

从Maximo获取动画

  1. 找到一个接近UE骨骼的人物模型,老师选择这个,下载并导入UE

image

image

骨骼层次类似UE骨骼,但是命名还是不一样,后续动画需要调整

  1. 下载动画资源

image

  • Turn right/left crouching: 原地,循环往右移动
  • Turn right/left standing: 原地,循环右移
  • Jump Up/Down/Loop: 跳跃

导入UE,不需要Import Mesh

image

image

如果出现重复动画资源,就删掉

image

Miximo重定位到UE(retarget)

  1. 打开Retarget Manager

image

  1. 选择Humanoid人形骨骼,修改Maximo骨骼到UE骨骼

image

  1. 使UE骨骼和Maximo骨骼姿势相同,也摆成T-Pose

image

  1. Maximo骨骼点击Apply To Asset

image

  1. 重定向动画资源

image

image

bug:动画重定向出现骨头错位

image

image

image

image

这样一般就能够修复该问题

最后整理一下动画

image

UE重定向到EpicCharacter

因为骨骼本身就与UE骨骼一致,所以不需要重定向骨骼

  1. 调整姿势为T-Pose,并Modify Pose

image

  1. 重定向所有动画到EpicCharacter

游戏实现一

创建角色

  1. 创建BlasterCharacter:Character类,和对应蓝图类

image

image

  1. BP_BlasterCharacter初始化
  • 选择skeletal mesh->SK_EpicCharacter
  • Rotation-z->-90
  • Location-z->-88 (和胶囊体高度一致)

摄像机

先把人物拖到关卡中,并设置Auto Possess->Player 0,这样运行游戏就会自动持有了

  1. 添加摄像机和弹簧臂组件
BlasterCharacter.h

private:
	UPROPERTY(VisibleAnywhere, Category = Camera)
	class USpringArmComponent* CameraBoom;
	
	UPROPERTY(VisibleAnywhere, Category = Camera)
	class UCameraComponent* FollowCamera;
BlasterCharacter.cpp

ABlasterCharacter::ABlasterCharacter()
{
	...
	
	CameraBoom = CreateDefaultSubobject<USpringArmComponent>(TEXT("CameraBoom"));
	CameraBoom->SetupAttachment(GetMesh());
	CameraBoom->TargetArmLength = 600.f;
	CameraBoom->bUsePawnControlRotation = true;
	
	FollowCamera = CreateDefaultSubobject<UCameraComponent>(TEXT("FollowCamera"));
	FollowCamera->SetupAttachment(CameraBoom, USpringArmComponent::SocketName);
	FollowCamera->bUsePawnControlRotation = false;
}
  1. BP_BlasterCharacter中做些调整
  • CameraBoomLocation-z->88

人物移动

  1. Project Settings->Input

image

image

  1. 代码中实现绑定
BlasterCharacter.h

protected:
	void MoveForward(float Value);
	void MoveRight(float Value);
	void Turn(float Value);
	void LookUp(float Value);
BlasterCharacter.cpp

void ABlasterCharacter::SetupPlayerInputComponent(UInputComponent* PlayerInputComponent)
{
	Super::SetupPlayerInputComponent(PlayerInputComponent);
	
	// 从Character继承的Jump函数
	PlayerInputComponent->BindAction("Jump", IE_Pressed, this, &ThisClass::Jump);
	
	PlayerInputComponent->BindAxis("MoveForward", this, &ThisClass::MoveForward);
	PlayerInputComponent->BindAxis("MoveRight", this, &ThisClass::MoveRight);
	PlayerInputComponent->BindAxis("Turn", this, &ThisClass::Turn);
	PlayerInputComponent->BindAxis("LookUp", this, &ThisClass::LookUp);
}

void ABlasterCharacter::MoveForward(float Value)
{
	if(Controller() != nullptr && Value != 0.f)
	{
		// 得到控制器旋转(随鼠标x轴旋转),也是摄像机旋转
		const FRotator YawRotation(0.f, Controller->GetControllerRotation().Yaw, 0.f);
		// 得到旋转的forward方向,即沿着摄像机正前方移动
		const FVector Direction(FRotationMatrix(YawRotation).GetUnitAxis(EAxis::X));
		// 移动速度和加速度,在MovementComponent中设置
		AddMovementInput(Direction, Value);
	}
}
void ABlasterCharacter::MoveRight(float Value)
{
	if(Controller() != nullptr && Value != 0.f)
	{
		// 得到控制器旋转(随鼠标x轴旋转)
		const FRotator YawRotation(0.f, Controller->GetControllerRotation().Yaw, 0.f);
		// 得到旋转的Right方向
		const FVector Direction(FRotationMatrix(YawRotation).GetUnitAxis(EAxis::Y));
		// 移动速度和加速度,在MovementComponent中设置
		AddMovementInput(Direction, Value);
	}
}
void ABlasterCharacter::Turn(float Value)
{
	AddControllerYawInput(Value);
}

void ABlasterCharacter::LookUp(float Value)
{
	AddControllerPitchInput(Value);
}

移动动画

  1. 先创建一个BlasterAnimInstance:AnimInstance
BlasterAnimInstance.h

public:
	virtual void NativeInitializeAnimation() override;
	virtual void NativeUpdateAnimation(float DeltaTime) override;
	
private:
	UPROPERTY(BlueprintReadOnly, Category = Character, meta = (AllowPrivateAccess = "true"))
	class ABlasterCharacter BlasterCharacter;
	
	UPROPERTY(BlueprintReadOnly, Category = Movement, meta = (AllowPrivateAccess = "true"))
	float speed;
	
	UPROPERTY(BlueprintReadOnly, Category = Movement, meta = (AllowPrivateAccess = "true"))
	bool bIsInAir;
	
	UPROPERTY(BlueprintReadOnly, Category = Movement, meta = (AllowPrivateAccess = "true"))
	bool bIsAccelerating;
BlasterAnimInstance.cpp

void UBlasterAnimInstance::NativeInitializeAnimation()
{
	Super::NativeInitializeAnimation();
	
	BlasterCharacter = Cast<ABlasterCharacter>(TryGetPawnOwner());
}
void UBlasterAnimInstance::NativeUpdateAnimation(float DeltaTime)
{
	Super::NativeUpdateAnimation(float DeltaTime);
	
	if(BlasterCharacter == nullptr)
	{
		BlasterCharacter = Cast<ABlasterCharacter>(TryGetPawnOwner());
	}
	if(BlasterCharacter == nullptr) return;
	
	FVector Velocity = BlasterCharacter->GetVelocity();
	Velocity.Z = 0.f;
	Speed = Velocity.Size();
	
	bIsInAir = BlasterCharacter->GetCharacterMovement()->IsFalling();
	bIsAccelerating = BlasterCharacter->GetCharacterMovement()->GetCurrentAcceleration().Size() > 0.f ? true : false;
}
  1. 创建动画蓝图ABP_Blaster:AnimInstance,打开后在Class Setting中设置父类为BlasterAnimInstance
Unequipped状态机

image

  1. 内部状态,添加对应输出姿势

image

动作后摇太大,就早点结束规则

image

  1. 对于IdleWalkRun使用1D混合空间,BS_UnequippedIdleWalkRun

image

image

  1. 最后在BP_BlasterCharacter中设置使用动画蓝图ABP_Blaster
bug: 人物并没有朝视角方向移动,原因Actor随控制器旋转,没有朝向运动方向
BlasterCharacter.cpp

ABlasterCharacter::ABlasterCharacter()
{
	...
	bUseControllerRotationYaw = false; // 不随控制器旋转
	GetCharacterMovement()->bOrientRotationToMovement = true; // 朝向运动方向
}

同时注意,c++修改,蓝图中还需要恢复默认值!!!

无缝切换 (Seamless Travel)

大厅关卡

监听玩家数量,数量够了就进入游戏

  1. 创建LobbyGameMode:GameMode
LobbyGameMode.h

public:
	virtual void PostLogin(APlayerController* New Player) override;
注意:GameMode只存在于服务器,客户端会随着服务端改变关卡而改变
LobbyGameMode.cpp

void ALobbyGameMode::PostLogin(APlayerController* NewPlayer)
{
	Super::PostLogin(NewPlayer);
	
	int32 NumberOfPlayers = GameState.Get()->PlayerArray.Num();
	if(NumberOfPlayers == 2)
	{
		UWorld* World = GetWorld();
		if(World)
		{
			// 使用无缝切换
			bUseSeamlessTravel = true;
			// 只存在于服务器,客户端会随着切换
			World()->ServerTravel(FString("/Game/Maps/BlasterMap?listen"));
		}
	}
}
  1. 创建BP_LobbyGameMode:LobbyGameMode蓝图类,设置Default Pawn Class->BP_BlasterCharacter

  2. Lobby关卡World Settings->BP_LobbyGameMode

过渡关卡

  1. 创建Empty Level(越简单越好),命名为TransitionMap

  2. Project Settings->Mpas & Modes

  • Transition Map->TransitionMap
  • Map Package->TransitionMap

关卡润色

  1. GameStartup放置一个骨骼网格,Animation Mode->Use Animation Asset

image

游戏关卡模式

  1. 创建BP_BlasterGameMode:GameMode, Default Pawn Class->BP_BlasterCharacter

  2. BlasterMap选择World Settings->GmaeMode->BP_BlasterGameMode

Network Role

  1. 创建UW_Overhead:UserWidget
UW_Overhead.h

public:
	UPROPERTY(meta = (BindWidget)) // 与蓝图中的控件绑定
	class UTextBlock* DisplayText;
  1. 创建WBP_OverheadClass Settings->UW_Overhead
  • 删除Canvas,添加Text->命名为DisplayText, 屏幕大小拖拽到仅文本大小
  1. 继续丰富UW_Overhead
UW_Overhead.h

public:
	void SetDisplayText(FString TextToDisplay);
	
	UFUNCTION(BlueprintCallable)
	void ShowPlayerNetRole(APawn* InPawn);

protected:
	// virtual void NativeDestrct() override;
	virtual void OnLevelRemoveFromWorld(ULevel* InLevel, UWorld* InWorld) override;
UW_Overhead.cpp

void UUW_Overhead::SetDisplayText(FString TextToDisplay)
{
	if(DisplayText)
	{
		DisplayText->SetText(FText::FromString(TextToDisplay));
	}
}

void UUW_Overhead::ShowPlayerNetRole(APawn* InPawn)
{
	ENetRole LocalRole = InPawn->GetLocalRole();
	FString Role;
	switth(LocalRole)
	{
		case ENetRole::ROLE_Authority:
			Role = FString("Authority");
			break;
		case ENetRole::ROLE_AutonomousProxy:
			Role = FString("Autonomous Proxy");
			break;
		case ENetRole::ROLE_SimulatedProxy:
			Role = FString("Simulated Proxy");
			break;
		case ENetRole::ROLE_None:
			Role = FString("None");
			break;
	}
	FString LocalRoleString = FString::Printf(TEXT("Local Role: %s"), *Role);
	SetDisplayText(LocalRoleString);
}

void UUW_Overhead::OnLevelRemoveFromWorld(ULevel* InLevel, UWorld* InWorld)
{
	RemoveFromParent(); // 先移除视口
	Super::OnLevelRemoveFromWorld(InLevel, InWorld);
	
	
}
  1. BlasterCharacter中持有该组件
BlasterCharacter.h

private:
	UPROPERTY(EditAnywhere, BlueprintReadOnly, meta = (AllowPrivateAccess = "true"))
	class UWidgetComponent* OverheadWidget;
BlasterCharacter.cpp

ABlasterCharacter::ABlasterCharacter()
{
	...
	OverheadWidget = CreateDefaultSubobject<<UWidgetComponent>(TEXT("OverheadWidget"));
	OverheadWidget->SetupAttachment(RootComponent);
}
  1. BP_BlasterCharacter中设置OverheadWidget

拖拽到头上方

image

BeginPlay

image

Local & Remote Net Role

  • Listen Server 3人
  • Local Net Role
    服务端:都是Authority
    客户端:当前控制的角色是AutonomousProxy,其他角色是SimulatedProxy
    • Remote Net Role
      服务端:当前控制的角色是AutomousProxy,其他角色是SimulateProxy
      客户端:都是Authority

武器

Weapon类及蓝图

  1. 创建Weapon:Actor
Weapon.h

private:
	UPROPERTY(VisiableAnywhere, Category = "Weapon Properties")
	USkeletalMeshComponent* WeaponMesh; // 武器的骨骼网格
	
	UPROPERTY(VisiableAnywhere, Category = "Weapon Properties")
	class USphereComponent* AreaSphere; // 摄取范围触发
Weapon.cpp

AWeapon::AWeapon()
{
	PrimaryActorTick.bCanEverTick = false;
	
	// 因为只在服务器见检测碰撞,所以状态变化需要复制
	// Actor使可复制的,其中变量才可以设置Replicated
	bReplicates = true;
	
	WeaponMesh = CreateDefaultSubobject<USkeletalMesh>(TEXT("WeaponMesh"));
	SetRootComponent(WeaponMesh);
	
	// 没被持有时,枪支会和任何物体碰撞,但不会和人物碰撞
	Weapon->SetCollisionResponseToAllChannels(ECollisionResponse::ECR_Block);
	Weapon->SetCollisionResponToChannel(ECollisionChnanel::ECC_Pawn, ECollisionResponse::ECR_Ignore);
	
	Weapon.SetCollisionEnabled(ECollisionEnabled::NoCollision); // 持有就没有碰撞,丢下后才开启
	
	AreaSphere = CreateDefaultSubobject<USphereComponent>(TEXT("AreaSphere"));
	AreaSphere->SetupAttachment(RootComponent);
	
	// 重叠检测应该在服务器上做,所以默认关闭,在服务器上才开启
	AreaSphere->SetCollisionResponseToAllChannels(ECollisionResponse::ECR_Ignore);
	AreaSphere->SetCollisionEnabled(ECollisionEnabled::NoCollision);
	
}

void AWeapon::BeginPlay()
{
	Super::BeginPlay();
	
	if(HasAuthority)
	{
		AreaSphere->SetCollisionEnabled(ECollisionEnabled::QueryAndPhysics);
		AreaSphere->SetCollisionResponseToChannel(ECollisionChannel::ECC_Pawn, ECollisionResponse::ECR_Overlap);
	}
}
  1. 武器状态枚举
Weapon.h

UENUM(BlueprintType)
enum class EWeaponState : uint8
{
	// DisplayName是在蓝图中显示的枚举名字
	EWS_Initial UMETA(DisplayName = "Initial State"),
	EWS_Equipped UMETA(DisplayName = "Equipped"),
	EWS_Dropped UMETA(DisplayName = "Dropped"),
	
	EWS_MAX UMETA(DisplayName = "DefaultMAX")
};

UCLASS()
class BLASTER_API AWeapon : public AActor
{
private:
	UPROPERTY(VisibleAnyWhere, Category = "Weapon Properties")
	EWeaponState WeaponState;
}
  1. 创建BP_Weapon:Weapon蓝图类,调整一下AreaSphere的位置和半径

Widget组件

显示文字指示,按E拾取武器

  1. 创建WBP_PickupWidget,添加Text命名PickupText,初始文本E-Pick Up,调整屏幕大小仅包含文本

  2. Weapon类中添加WidgetComponent

Weapon.h

private:
	UPROPERTY(VisibleAnywhere, Category = "Weapon Properties")
	class UWidgetComponent* PickupWidget;
Weapon.cpp

AWeapon::AWeapon()
{
	...
	PickupWidget = CreateDefaultSubobject<UWidgetComponent>(TEXT("PickupWidget"));
	PickupWidget.SetupAttachment(RootComponent);
}
  1. 然后打开BP_Weapon调整Pickup Widget位置并设置

image

  1. 隐藏与显示
  • 显示
Weapon.h

protected:
	// 绑定到overlap委托中,需要UFUNCTION
	UFUNCTION()
	virtual void OnSphereOverlap(
		UPrimitiveComponent* OverlappedComponent,
		AActor* OtherActor,
		UPrimitiveComponent* OtherComp,
		int32 OtherBodyIndex,
		bool bFromSweep,
		const FHitResult& SweepResult
	);
Weapon.cpp

void AWeapon::BeginPlay()
{
	if(HasAuthority)
	{
		...
		AreaSphere->OnComponentBeginOverlap.AddDynamic(this, &ThisClass::OnSphereOverlap);
	}
	
	if(PickupWidget)
	{
		PickupWidget->SetVisibility(false);
	}
}

void AWeapon::OnSphereOverlap(
		UPrimitiveComponent* OverlappedComponent,
		AActor* OtherActor,
		UPrimitiveComponent* OtherComp,
		int32 OtherBodyIndex,
		bool bFromSweep,
		const FHitResult& SweepResult
	)
{
	ABlasterCharacter BlasterCharacter = Cast<ABlasterCharacter>(OtherActor);
	if(BlasterCharacter && PickupWidget)
	{
		// 玩家靠近,就显示提示
		PickupWidget->SetVisibility(true);
	}
}
bug: 客户端/服务端靠近武器,提示仅在服务端上显示

这是因为BeginOverlap函数仅在服务端进行绑定,解决思路:

  • BlasterCharacter新增一个变量OverlapWeapon用来描述是否靠近武器,同时复制这个OverlapWeapon,在OnRep_OverlapWeaon中显示PickupWidget
  • 注意这种情况下,服务端也不会显示,因为OnRep_只会在客户端复制时调用
BlasterCharacter.h

protected:
	void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override;
	
private:
	PROPERTY(ReplicatedUsing=OnRep_OverlapingWeapon)
	AWeapon* OverlapWeapon;
	
	UFUNCTION()
	void OnRep_OverlapingWeapon();

public:
	FORCEINLINE void SetOverlapingWeapon(AWeapon* Weapon) {OverlappingWeapon = Weapon;}
BlasterCharacter.cpp

void BlasterCharacter::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
	Super::GetLifetimeReplicatedProps(OutLifetimeProps);
	
	
	// DEREPLIFETIME(ABlasterCharacter, OverlappingWeapon); // 复制给所有客户端
	DEREPLIFETIME_CONDITION(ABlasterCharacter, OverlappingWeapon, COND_OwerOnly); // 只复制给拥有者,如客户端1靠近武器,导致服务端OverlapWeapon改变并显示提示,则只复制给客户端1
}

void AWeapon::OnRep_OverlapWeapon()
{
	if(OverlapWeapon)
	{
		OverlapWeapon->ShowPickupWidget(true);
	}
}
Weapon.h

public:
	void ShowPickupWidget(bool bShowWidget);
Weapon.cpp

void AWeapon::OnSphereOverlap(...)
{
	...
	if(BlasterCharacter)
	{
		// 替换为更改状态
		BlasterCharacter->SetOverlapWeapon(this);
	}
}

void AWeapon::ShowPickupWidget(bool bShowWidget)
{
	if(PickupWidget)
	{
		PickupWidget.SetVisibility(bShowWidget);
	}
}
bug: OnRep只在客户端通知,导致ListenServer靠近武器不会显示提示

解决思路:服务器在处理Overlap时间时,判断是否是本地控制,即服务器自己控制的角色,是的话直接调用OnRep_OverlappingWeapon即可

BlasterCharacter.cpp

void ABlasterCharacter::SetOverlappingWeapon(...)
{
	...
	if(IsLocallyControlled())
	{
		// 服务器自己控制的角色
		OverlappingWeapon->ShowPickupWidget(true);
	}
}
  • 隐藏

如果离开武器,就隐藏提示

bug: 服务端离开不会隐藏
Weapon.h

protected:
	...
	UFUNCTION()
	virtual void OnSphereEndOverlap(
		UPrimitiveComponent* OverlappedComponent,
		AActor* OtherActor,
		UPrimitiveComponent* OtherComp,
		int32 OtherBodyIndex
	);
	
private:
	UFUNCTION()
	void OnRep_OverlapingWeapon(AWeapon* LastWeapon); // 只能有一个参数,就是上一次复制的值
Weapon.cpp

void AWeapon::AWeapon()
{
	...
	if(HasAuthority())
	{
		...
		AreaSphere->OnComponentEndOverlap.AddDynamic(this, &AWeapon::OnSphereEndOverlap);
	}
}

void AWeapon::OnSphereEndOverlap(...)
{
	ABlasterCharacter BlasterCharacter = Cast<ABlasterCharacter>(OtherActor);
	if(BlasterCharacter)
	{
		BlasterCharacter->SetOverlappingWeapon(nullptr);
	}
}

void ABlaster::OnRep_OverlapingWeapon(AWeapon* LastWeapon)
{
	if(OverlappingWeapon)
	{
		OverlappingWeapon->ShowPickupWidget(true); // 如果新值不为空,就显示提示
	}
	if(LastWeapon)
	{
		LastWeapon->ShowPickupWidget(false); // 如果旧值不为空,就隐藏提示
	}
}

void ABlasterCharacter::SetOverlappingWeapon(AWeapon* Weapon)
{
	// IsLocallyControlled() 不需要这个条件
	// 因为服务端只有LocalControlled角色才需要看到提示,所以非locallyControlled无论如何都需要隐藏
	// 相当于让非LocallyContolled角色多隐藏了一遍,尽管原本就不会显示
	if(OverlappingWeapon)
	{
		OverlappingWeapon->ShowPickupWidget(false);
	}
	OverlappingWeapon = Weapon;
	...
}

装备武器

  1. 创建BlasterComponents/CombatComponent:ActorComponent

  2. BlasterCharacter中持有该组件

BlasterCharacter.h

private:
	UPROPERTY(VisibleAnywhere)
	class UCombatComponent* Combat;
BlasterCharacter.cpp

ABlasterCharacter::ABlasterCharacter()
{
	...
	Combat = CreateDefaultSubobject<UCombatComponent>(TEXT("CombatComponent"));
	// 组件内肯定含有变量,所以也需要复制
	// 组件比较特殊:不需要设置LifetimeProps
	Combat->SetIsReplicated(true);
}
  1. 添加持枪Socket

找到SK_EpicCharacter_SkeletalMesh->hand_r->Add the Socket->RightHandSocket->Add Preview Asset

image

  1. 装备输入绑定

image

BlasterCharacter.h

public:
	...
	// 最早能够访问到组件的生命周期
	virtual void PostInitializeComponents() override;
BlasterCharacter.cpp

void ABlasterCharacter::PostInitializeComponents()
{
	Super::PostInitializeComponents();
}

void ABlasterCharacter::SetupPlayerInputComponent(...)
{
	...
	PlayerInputComponent->BindAction("Equip", IE_Pressed, this, &ThisClass::EquipButtonPressed);
}

void ABlasterCharacter::EquipButtonPressed()
{
	// 装备武器,也应该由服务器操作
	if(Combat && HasAuthority)
	{
		Combat->EquipWeapon(OverlappingWeapon);
	}
}
CombatComponent.h

public:
	// 声明Character是组件的友元类,则Character中而可以随意访问protected和private的成员
	friend class ABlasterCharacter;
	
	void EquipWeapon(class AWeapon* WeaponToEquip);

private:
	// 需要访问Character中的成员
	class ABlasterCharacter* Character; // Owner应该也能获取
	class AWeapon* EquippedWeapon;
CombatComponent.cpp

void ACombatComponent::EquipWeapon(class AWeapon* WeaponToEquip)
{
	if(Character == nullptr || WeaponToEquip == nullptr) return;
	
	EuippedWeapon = WeapnToEquip;
	EquippedWeapon.SetWeaponState(EWeaponState::EWS_Equipped);
	const USkeletalMeshSocket* HandSocket = Charcter->GetMesh()->GetSocketByName(FName("RightHandSocket"));
	if(HandSocket)
	{
		HandSocket->AttachActor(EquippedWeapon, Character->GetMesh());
	}
	EquippedWeapon->SetOwner(Character);
	EquippedWeapon->ShowPickupWidget(false);
}
Weapon.h

public:
	FORCEINLINE void SetWeaponState(EWeaponState State) {WeaponState = State;}
bug: 装备武器只允许在服务端进行,所以客户端按E无响应->RPC解决

客户端要装备则通知服务端装备,服务端再同步到客户端

BlasterCharacter.h

protected:
	UFUNCTION(Server, Reliable)
	void Server_EquipButtonPressed();
BlasterCharacter.cpp

void ABlasterCharacter::EquipButtonPressed()
{
	if(Combat)
	{
		if(HasAuthority)
		{
			// 服务端就直接装备
			Combat->EquipWeapon(OverlappingWeapon);
		}
		else
		{
			// 客户端要通知服务端装备
			// 因为Conbat组件是Replicated,所以只要让服务器装备,就会同步到所有的客户端(Conbat->EquippedWeapon)
			Server_EquipButtonPressed();
		}
	}
}

void ABlasterCharacter::Server_EquipButtonPressed_Implementation()
{
	// 装备武器,也应该由服务器操作
	if(Combat)
	{
		Combat->EquipWeapon(OverlappingWeapon);
	}
}
bug: 武器被装备后,服务端上不显示提示了,但是客户端还是会显示

因为装备武器时隐藏的提示组件,同样只在服务端执行

CombatComponent.cpp

// 这个函数只会在服务端执行
void ACombatComponent::EquipWeapon(class AWeapon* WeaponToEquip)
{
	if(Character == nullptr || WeaponToEquip == nullptr) return;
	
	EuippedWeapon = WeapnToEquip;
	EquippedWeapon.SetWeaponState(EWeaponState::EWS_Equipped);
	const USkeletalMeshSocket* HandSocket = Charcter->GetMesh()->GetSocketByName(FName("RightHandSocket"));
	if(HandSocket)
	{
		HandSocket->AttachActor(EquippedWeapon, Character->GetMesh());
	}
	EquippedWeapon->SetOwner(Character);
	EquippedWeapon->ShowPickupWidget(false); // 在这里隐藏
}

解决办法:服务端执行时应该通知客户端(1)是否显示提示控件(2)是否还需要sphere重叠检测

  • 可以通过RPC,通知所有客户端
  • 这两个条件都和EWeaponState相关,所以复制EWeaponState通知即可
Weapon.h

public:
	virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override;

private:
	UPROPERTY(VisibleAnywhere, Category = "Weapon Properties", ReplicatedUsingt=OnRep_WeaponState)
	EWeaponState WeaponState;
	
	UFUNCTION()
	void OnRep_WeaponState();

public:
	void SetWeaponState(EWeaponState State);
	FORCEINLINE USphereComponent* GetAreaSphere() const { return AreaSphere; }
Weapon.cpp

AWeapon::AWeapon()
{
	...
	// Actor是可复制的,其成员才可以设置Replicated
	bReplicates = true;
	...
}

void AWeapon::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
	Super::GetLifetimeReplicatedProps(OutLifetimeProps);
	
	DOREPLIFETIME(AWeapon, WeaponState);
}

void OnRep_WeaponState()
{
	// 根据当前的武器状态,调整检测和提示显示
	switch(WeaponState)
	{
		case EWeaponState::EWS_Equipped:
			ShowPickupWidget(false);
			AreaSphere->SetCollisionEnabled(ECollisionEnabled::NoCollision);
			break;
	}
}

void AWeapon::SetWeaponState(EWeaponState State)
{
	WeaponState = State;
	// 这个函数只在服务器上调用,需要手动调用`OnRep`
	OnRep_WeaponState();
}

Combat中调用OnRep_WeaponState_Implementation

CombatComponent.cpp

void ACombatComponent::EquipWeapon(class AWeapon* WeaponToEquip)
{
	if(Character == nullptr || WeaponToEquip == nullptr) return;
	
	//
	// AWeapon设置是可复制的,但是这俩变量好像还不能复制
	// 找到Owner声明,可以看到UPRPERTY(ReplicatedUsing=OnRep_Owner),所以Owner本身就会复制
	// EquippedWeapon没有设置Replicated,目前课程还没考虑复制
	//
	EuippedWeapon = WeapnToEquip;
	EquippedWeapon->SetOwner(Character);
	
	//
	// Character会复制,所以改变骨骼网格也不需要考虑同步问题
	//
	const USkeletalMeshSocket* HandSocket = Charcter->GetMesh()->GetSocketByName(FName("RightHandSocket"));
	if(HandSocket)
	{
		HandSocket->AttachActor(EquippedWeapon, Character->GetMesh());
	}
	
	// 改变Weapon状态需要处理
	EquippedWeapon->SetWeaponState(EWeaponState::EWS_Equipped);
}

角色动画

装备武器

复制EquippedWeapon,保持bIsWeaponEquipped一致

  1. 复制EquippedWeapon
CombatComponent.h

public:
	virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override;

private:
	UPROPERTY(Replicated)
	AWeapon* EquippedWeapon; //装备武器的代码只在服务器上执行,所以需要复制
CombatComponent.cpp

void UCombatComponent::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
	Super::GetLifetimeReplicatedProps(OutLifetimeProps);
	
	DOREPLIFETIME(UCombatComponent, EquippedWeapon);
}
  1. 动画类中增加是否装备武器的变量
BlasterAnimInstance.h

private:
	UPROPERTY(BlueprintReadOnly, Category = Weapon, meta = (AllowPrivateAccess = "true"))
	bool bWeaponEquipped;
BlasterAnimInstance.cpp

void ABlasterAnimInstance::NativeUpdateAnimation(float DeltaTime)
{
	...
	bWeaponEquipped = BlasterCharacter->IsWeaponEquipped();
}
BlasterCharacter.h

public:
	bool IsWeaponEquipped() { return (Combat && Combat->EquippedWeapon); }
  1. 调整动画蓝图`ABP_BlasterCharacter

    1. 根据是否装备切换状态机

    image

蹲伏
  1. 输入绑定

image

  1. 角色类中添加绑定事件
BlasterCharacter.h

protected:
	void CrouchButtonPressed();
BlasterCharacter.cpp

void ABlasterCharacter::SetupPlayerInputComponent(UInputComponent* PlayerInputComponent)
{
	...
	PlayerInputComponent->BindAction("Crouch", IE_Pressed,, this, &ThisClass::CrouchButtonPressed);
}

void ABlasterCharacter::CrouchButtonPressed()
{
	// bIsCrouched=true;
	if(bIsCrouched)
		UnCrouch();
	else
		Crouch();
}
注意:Crouch()函数会进行同步处理,包括Replicate:bIsCrouched以及通知服务器广播,和Jump一样,不需要考虑同步问题

image

  1. 动画实例中添加变量
BlasterAnimInstance.h

private:
	UPROPERTY(BlueprintReadOnly, Category = Movement, meta = (AllowPrivateAccess = "true"))
	bool bIsCrouched;
BlasterAnimInstance.cpp

void UBlasterAnimInstance::NativeUpdateAnimation(float DeltaTime)
{
	...
	bIsCrouched = BlasterCharacter->bIsCrouched;
}
  1. 调整动画蓝图

image

bug: 不能蹲,需要设置NavAgentProps.bCanCrouch=true, Crouch()函数中会检查
BlasterCharacter.cpp

ABlasterCharacter::ABlasterCharacter()
{
	...
	GetCharacterMovement()->NavAgentProps.bCanCrouch = true;
}

瞄准

  1. 动作映射

image

  1. 类中绑定输入
BlasterCharacter.h

protected:
	void AimButtonPressed();
	void AimButtonReleased();

public:
	bool IsAiming() { return Combat && Combat->bAiming; }
BlasterCharacter.cpp

void SetupPlayerComponent(...)
{
	...
	PlayerInputComponent->BindAction("Aim", IE_Pressed, this, &ThisClass::AimButtonPressed);
	PlayerInputComponent->BindAction("Aim", IE_Released, this, &ThisClass::AimButtonReleased);
}

void ABlasterCharacter::AimButtonPressed()
{
	if(Conmbat)
	{
		Conmbat.SetAiming(true);
	}
}

void ABlasterCharacter::AimButtonReleased()
{
	if(Conmbat)
	{
		Conmbat.SetAiming(false);
	}
}
CombatComponent.h

private:
	UPROPERTY(Replicated)
	bool bAiming;
	
	UFUNCTION(Server, Reliable)
	void Server_SetAiming(bool bIsAiming);
public:
	void SetAiming(bool bIsAiming) { bAiming = bIsAiming; }
CombatComponent.cpp

void UCombatComponent::SetAiming(bool bIsAiming)
{
	bAiming = bIsAiming; // 客户端先变
	Setver_SetAiming(bIsAiming); // 然后通知服务器变并广播
}

void UCombatComponent::Server_SetAiming_Implementation(bool bIsAiming)
{
	bAiming = bIsAiming;
}

void UCombatComponent::GetLifetimeReplicatedProps(...) const
{
	...
	DOREPLIFETIME(UCombatComponent, bAiming);
}
  1. 动画实例类中添加变量
BlasterAnimInstance.h

private:
	UPROPERTY(BlueprintReadOnly, Category = Combat, meta = (AlloPrivateAccess = "true"))
	bool bAiming;
BlasterAnimInstance.cpp

void ABlasterAnimInstance::NativeUpdateAnimation(...)
{
	...
	bAiming = BlasterCharacter->IsAiming();
}
  1. 动画蓝图中设置状态

image

  1. 瞄准放大Zoom
Weapon.h
    
private:
	// Zoomed FOV while aiming
	UPROPERTY(EditAnywhere)
	float ZoomedFOV = 30.f;

	UPROPERTY(EditAnywhere)
	float ZoomInterpSpeed = 20.f;
public:
	FORCEINLINE float GetZoomedFOV() const { return ZoomedFOV; }
	FORCEINLINE float GetZoomInterpSpeed() const { return ZoomInterpSpeed; }
CombatComponent.h
    
private:
	// Aiming and FOV
	float DefaultFOV;

	float CurrentFOV;

	float ZoomEndInterpSpeed = 30.f; // 控制恢复FOV的速度

	void InterpFOV(float DeltaTime);
CombatComponent.cpp
    
void UCombatComponent::BeginPlay()
{
    ...
    if(Character && Character->GetFollowCamera())
    {
        DefaultFOV = Character->GetFollowCamera()->FieldOfView;
        CurrentFOV = DefaultFOV;
    }
}

void UCombatComponent::TickComponent(float DeltaTime)
{
    ...
    if(Character && Character->IsLocallyContolled)
    {
        // 本地控制的才需要调整FOV
        InterpFOV(DeltaTime);
    }
}
    
void UCombatComponent::InterpFOV(float DeltaTime)
{
    if(EquippedWeapon == nullptr) return;

    if(bAiming)
    {
        CurrentFOV = FMath::FInterpTo(CurrentFOV, EquippedWeapon->GetZoomedFOV(), DeltaTime, EquippedWeapon->GetZoomInterpSpeed());
    }
    else 
    {
        CurrentFOV = FMath::FInterpTo(CurrentFOV, DefaultFOV, DeltaTime, ZoomEndInterpSpeed);
    }
    if(Character && Character->GetFollowCamera())
    {
        Character->GetFollowCamera()->SetFieldOfView(CurrentFOV);
    }
}
BlasterCharacter.h
    
public:
	FORCEINLINE UCameraComponent* GetFollowCamera() const { return FollowCamera; }
bug: zoom放大场景变糊

调整相机参数

  • Focal Distance: 看远处物体不会糊了,但看近处会糊
  • Aperture: 使看近处也不糊了

image-20240320142526195

装备武器移动

未装备时移动,可以Actor朝着移动方向,所以只需要1D Blend Space即可
而携带武器时,需要始终朝向鼠标瞄准位置,所以人物移动变为八向移动,使用2D Blend Space

  1. 创建EquippedRun混合空间

image

image

  • 制作倾斜动画

  • Fwd

    1. 找到Jog_Fwd_Rifle动画,右键Duplicate,打开,选择root骨骼,朝右边旋转20度

    image

    1. 添加Key,并出另存为Jog_Fwd_Lean_R资源

    image

    image

    1. 删除刚才DuplicateJog_Fwd_Rifle1

    2. 同理,制作Jog_Fwd_Lean_L资源

    3. 添加到BS_EquippedRun

    image

    • Right
    1. 找到Jog_Rt_Rifle,同Fwd,先沿往右倾斜十度,存为Jog_Rt_Lean_R

    2. 再往左十度,记为Jog_Rt_Lean_L

    3. 添加到BS_EquippedRun

    image

    • Lefe: 10度

    • Bwd: 10度

最终效果

image

  1. 使用BS_EquippedRun

    1. 动画实例类中添加变量

    注意,为什么需要在代码中插值过渡动画

    如果在混合空间中过渡,向后移动,会出现-180<->180的抽象过渡

    BlasterAnimInstance.h
    
    private:
    	UPROPERTY(BlueprintReadOnly, Category = RifleMovement, meta = (AllowPrivateAccess = "true"))
    	float YawOffset;
    
    	UPROPERTY(BlueprintReadOnly, Category = RifleMovement, meta = (AllowPrivateAccess = "true"))
    	float Lean;
    
    	FRotator CharacterRotationLastFrame;
    	FRotator CharacterRotation;
    	FRotator DeltaRotation;
    
    BlasterAnimInstance.cpp
    
    void UBlasterAnimInstance::NativeUpdateAnimation(float DeltaTime)
    {
    	...
    	// UE已经为人物速度和旋转实现了同步, 所以计算的结果(YawOffset,Lean)都一致
    	//
    	// 都是世界坐标旋转
    	FRotator AimRotation = BlasterCharacter->GetBaseAimRotation(); // 视角方向
    	FRotator MovementRotation = UKismetMathLibrary::MakeRotFromX(BlasterCharacter->GetVelocity()); // 移动方向
    	FRotator DeltaRot = UKismetMathLibrary::NormalizeDeltaRotator(MovementRotation, AimRotation);
    	DeltaRotation = FMath::RInterpTo(DeltaRotation, DeltaRot, DeltaTime, 6.f);
    	YawOffset = DeltaRotation.Yaw;
    
    	// 两帧之间人物旋转做插值,作为倾斜
    	CharacterRotationLastFrame = CharacterRotation;
    	CharacterRotation = BlasterCharacter->GetActorRotation();
    	const FRotator Delta = UKimetMathLibrary::NormalizedDeltaRotator(CharacterRotation, CharacterRotationLastFrame);
    	const float Target = Delta.Yaw / DeltaTime;
    	const float Interp = FMath::FInterpTo(Lean, Target, DeltaTime, 6.f);
    	Lean = FMath::Clamp(Interp, -90.f, 90.f);
    }
    
    1. 装备武器下,人物不该朝着运动方向,而应该随控制器旋转
    CombatComponent.h
    
    protected:
    	UFUNCTION()
    	void OnRep_EquippedWeapon();
    
    private:
    	UPROPERTY(ReplicatedUsing = OnRep_EquippedWeapon)
    	AWeapon* EquippedWeapon;
    
    CombatComponent.cpp
    
    void UCombatComponent::EquipWeapon(...)
    {
    	...
    	// 这个函数只会在服务器调用!!!
    	OnRep_EquippedWeapon();
    }
    
    void OnRep_EquippedWeapon()
    {
    	if(EquippedWeapon && Character)
    	{
    		// 必须保证状态变化后,才附加到character
    		// 因为状态变化可能导致SimulatePhysics,无法附加
    		const USkeletalMeshSocket* HandSocket = Chracter->GetMesh()->GetSocketByName(FName("RightHandSocket"));
    		if(HandSocket)
    		{
    			HandSocket->AttachActor(EquippedWeapon, Character->GetMesh());
    		}
    	
    		Character->GetCharacterMovement()->bOrientRotationToMovement = false;
    		Character->bUseControllerRotationYaw = true;
    	}
    }
    
    1. 更改BP_BlasterAnim

    image

    image

    image

    image

    image

    image

  2. 创建BS_CrouchWalking:1D Blend Space

image

image

  1. 修改BP_BlasterAnim

image

image

image

image

image

瞄准移动

  1. 创建BS_AimWalk:1D Blend Space

image

  1. 创建BS_CrouchAimWalk:1D Blend Space

image

  1. 修改BP_BlasterAnim

Run的时候瞄准,才会进入AimWalking

image

image

CrouchWalking的时候瞄准,进入CrouchAimWalking

image

image

image

  1. 修改CombatComponent,瞄准后应该更新移动速度
CombatComponent.h

private:
	UPROPERTY(EditAnywhere)
	float BaseWalkSpeed;
	
	UPROPERTY(EditAnywhere)
	float AimWalkSpeed;
CombatComponent.cpp

UCombatComponent::UCombatComponent()
{
	...
	BaseWalkSpeed = 600.f;
	AimWalkSpeed = 450.f;
}

void UCombatComponent::BeginPlay()
{
	...
	if(Character)
	{
		Character->GetCharacterMovement()->MaxWalkSpeed = BaseWalkSpeed;
	}
}

void UCombatComponent::Server_SetAiming_Implementation(bool bIsAiming)
{
	...
	if(Character)
	{
		Character->GetCharacterMovement()->MaxWalkSpeed = bIsAiming ? AimWalkSpeed : BaseWalkSpeed;
	}
}

瞄准偏移

  1. 获取瞄准偏移动画
  • HipAO: 非瞄准状态下的持枪瞄准偏移

    1. 找到Aim_Space_Hip动画,需要从中截取瞄准偏移动画,复制并重命名

    2. 依次找到对应帧,右键删除前面喝后面的,只保留一帧

    3. 复制AO_CC命名为Zero_Pose,用作Additive基础

    image

  • AimAO:找到Aim_Space_Ironsight,操作同上

image

  1. 将动画都设置 Additive,通过批量属性矩阵修改

image

  1. 创建HipAimOffset

视角旋转角色朝向的差值

image

设置基础姿势

image

image

  1. 创建AimAimOffset同上

  2. 应用AimOffset

    1. BlasterAnimInstance中获取数据
    BlasterAnimInstance.h
    
    private:
    	UPROPERTY(BlueprintReadOnly, Category = Aim, meta = (AllowPrivateAccess = "true"))
    	float AO_Yaw;
    	
    	UPROPERTY(BlueprintReadOnly, Category = Aim, meta = (AllowPrivateAccess = "true"))
    	float AO_Pitch;
    
    BlasterAnimInstance.cpp
    
    void UBlasterAnimInstance::NativeUpdateAnimation(...)
    {
    	...
    	AO_Yaw = BlasterCharacter->GetAO_Yaw();
    	AO_Pitch = BlasterCharacter->GetAO_Pitch();
    }
    
    BlasterCharacter.h
    
    protected:
    	void AimOffset(float DeltaTime);
    
    private:
    	float AO_Yaw;
    	float AO_Pitch;
    	FRotator StartingAimRotation;
    
    public:
    	FORCEINLINE float GetAO_Yaw() const { return AO_Yaw; }
    	FORCEINLINE float GetAO_Pitch() const { return AO_Pitch; }
    
    BlasterCharacter.cpp
    
    void ABlasterCharacter::Tick(float DeltaTime)
    {
    	...
    	AimOffset(DeltaTime);
    }
    
    void ABlasterCharacter::AimOffset(float DeltaTime)
    {
    	if(Combat && Combat->EnquippedWeapon == nullptr) return;
    	
    	FVector Velocity = GetVelocity();
    	Velocity.Z = 0.f;
    	float Speed = Velocity.Size();
    	bool bIsInAir = GetCharacterMovement()->IsFalling();
    	
    	if(Speed == 0.f && !bIsInAir) // 静止 && 没跳
    	{
    		bUseControllerRotationYaw = false; //静止不随控制器旋转, 控制器旋转用于瞄准偏移
    		
    		FRotator CurrentAimRotation = FRotator(0.f, GetBaseAimRotation().Yaw, 0.f);
    		FRotator DeltaAimRotation = UKismetMathLibrary::NormalizedDeltaRotator(CurrentAimRotation, StartingAimRotation);
    		AO_Yaw = DeltaAimRotation.Yaw; // 当前AimRotation和起始Rotaion的插值
    	}
    	
    	if(Speed > 0.f || bIsInAir) // 跑 或 跳
    	{
    		bUseControllerRotationYaw = true; // 控制器旋转控制人物旋转
    		
    		StartingAimRotation = FRotator(0.f, GetBaseAimRotation().Yaw, 0.f);
    		AO_Yaw = 0.f; // 此时不需要瞄准偏移,并且会更改起始Rotation
    	}
    	
    	AO_Pitch = GetBaseAnimRotation().Pitch;
    	if(AO_Pitch > 90.f && !IsLocallyControlled())
    	{
    		FVector2D InRange(270.f, 360.f);
    		FVector2D OutRange(-90.f, 0.f);
    		AO_Pitch = FMath::GetMappedRangeValueClamped(InRange, OutRange, AO_Pitch);
    	}
    }
    
    1. BP_BlasterAnim中混合

    image

    image

bug: 控制端俯仰角正常,模拟端出现问题,原因是UE会压缩角度信息到无符号整数,[-90, 0]->[270, 360]

AO_Pitch = GetBaseAnimRotation().Pitch;
if(AO_Pitch > 90.f && !IsLocallyControlled())
{
	FVector2D InRange(270.f, 360.f);
	FVector2D OutRange(-90.f, 0.f);
	AO_Pitch = FMath::GetMappedRangeValueClamped(InRange, OutRange, AO_Pitch);
}

摄像机碰撞

其他玩家从摄像机面前走过时,摄像机会拉近到角色,存在摄像机碰撞

  1. 类中修改
BlasterCharacter.cpp

ABlasterCharacter::ABlasterCharacter()
{
	...
	GetCapsuleComponent()->SetCollisionResponseToChannel(ECollisionChannel::ECC_Camera, ECollisionResponse::ECR_Ignore);
	GetMesh()->GetCollisionResponseToChannel(ECollisionChannel::ECC_Camera, ECollisionResponse::ECR_Ignore);
}
  1. 蓝图中修改

image

反向动力学

目前问题:武器和手并没有贴合,并且不同的武器,手放置的位置也不同

  1. 先给武器模型添加插槽

image-20240318124320786

  1. 在动画类中获取变量
BlasterAinmInstance.h
    
private:
	class AWeapon* EquippedWeapon;

	UPROPERTY(BlueprintReadOnly, Category = IK, meta = (AllowPrivateAccess = "true"))
	FTransfrom LeftHandTransform;
BlasterAnimInstance.cpp
    
void ABlasterAnimInstance::NativeUpdateAnimation(float DeltaTime)
{
    ...
    EquippedWeapon = BlasterCharacter->GetEquippedWeapon();
    ...
    if(bWeaponEquipped && EquippedWeapon && EquippedWeapon->GetWeaponMesh() && BlasterCharacter->GetMesh())
    {
        // 武器左手的世界位置
        LeftHandTransfrom = EquippedWeapon->GetWeaponMesh()->GetSocketTransform(FName("LeftHandSocket"), ERelativeTransformSpace::RTS_World);
        FVector OutPosition;
        FRotator OutRotation;
        // 与人物的右手骨骼位置对齐
        BlasterCharacter->GetMesh()->TransformToBoneSpace(FName("hand_r"), LeftHandTransform.GetLocation(), FRotator::ZeroRotator);
        // 更新为对齐的位置
        LeftHandTransform.SetLocation(OutPosition);
        LeftHandTransform.SetRotation(FQuat(OutRotation));
    }
}
  1. 动画蓝图中使用FABRIK(Foward and Backward Reaching IK)

创建新的状态机FABRIK

image-20240318134633667

image-20240318134459564

  1. 运行游戏,并调整LeftHandSocket的位置

根据游戏运行结果,调整位置

image-20240318134751820

原地转身 (Turn in Place) & 旋转根骨骼

原地静止时,旋转鼠标会瞄准偏移

P54和P55

网络更新频率

  1. 配置网络速率
BlasterCharacter.cpp

ABlasterCharacter::ABlasterCharacter()
{
    ...
    NetUpdateFrequency = 66.f;
    MinNetUpdateFrequency = 33.f;
}
  1. 配置文件DefalutEngine.ini
[/Script/OnlineSubsystemUtils.IpNetDriver]
NetServerMaxTickRate=60

脚步和跳跃音效

  1. 创建Sounds->Sound Attenuation,给音效添加距离衰减

image-20240318152622866

  1. 打开音效如SCue_FS_Tile,选择新建的Sound Attenuation

image-20240318152744092

  1. 在需要播放声音的动画中添加通知

image-20240318152824648

image-20240318152839282

武器攻击

动画

角色动画(会自动同步)
  1. 添加开火输入
BlasterCharacter.h
    
public:
	void PlayFireMontage(bool bAiming);
protected:
	void FireButtonPressed();
	void FireButtonReleased();

private:
	UPROPERTY(EditAnywhere, Category = Combat)
	class UAnimMontage* FireWeaponMontage; // 在动画蓝图中赋值 FireWeapon
BlasterCharacter.cpp

void ABlasterCharacter::PlayFireMontage(bool bAiming)
{
    if(Combat == nullptr || Combat->EquippedWeapon == nullptr) return;
    
    UAnimInstance* AnimInstance = GetMesh()->GetAnimInstance();
    if(AnimInstance && FireWeaponMontage)
    {
        AnimIntance->Montage_Play(FireWeaponMontage);
        FName SectionName = bAiming ? FName("RifleAim") : FName("RifleHip");
        AnimInstance->Montage_JumpToSection(SectionName);
    }
}

void ABlasterCharacter::SetupPlayerInputComponent(...)
{
    ...
    PlayerInputComponent->BindAction("Fire", IE_Pressed, this, &ThisClass::FireButtonPressed);
    PlayerInputComponent->BindAction("Fire", IE_Pressed, this, &ThisClass::FireButtonReleased);
}

void ABlasterCharacter::FireButtonPressed()
{
    if(Combat)
    {
        Combat->FireButtonPressed(true);
    }
}

void ABlasterCharacter::FireButtonReleased()
{
    if(Combat)
    {
        Combat->FireButtonPressed(false);
    }
}
CombatComponent.h
    
protected:
	void FireButtonPressed(bool bPressed);

private:
	bool bFireButtonPressed;
CombatComponent.cpp
    
void UCombatComponent::FireButtonPressed(bool bPressed)
{
    bFireButtonPressed = bPressed;
    if(Character && bFireButtonPressed)
    {
        Character->PlayFireMontage(bAiming);
    }
}
  1. 把开火动画调整成Additive

Fire_Rifle_Hip:持枪非瞄准的开火动画

Fire_Rifle_Ironsight:持枪瞄准的开火动画

image-20240318201309629

image-20240318201326258

  1. 创建蒙太奇动画,右键Fire_Rifle_Hip->Create->Create AnimMontage,命名为FireWeapon
    1. 右键新建RifleHip,并删除Default部分

image-20240318203312583

​ 2. Anim Slot Manager->Add Slot->WeaponSlot,并选择使用

image-20240318203534993

​ 3. 把Fire_Rifle_Ironsights也拖进来,并创建Montage Section

image-20240318203710718

  1. Montage SessionsClear

image-20240318203918073

武器动画(包含开火特效)(多播RPC)
  1. 添加开火动画播放

PixPin_2023-12-15_19-53-08

Weapon.h
    
public:
	void Fire();	

private:
	UPROPERTY(EditAnywhere, Category = "Weapon Properties")
	class UAnimationAsset* FireAnimation;
Weapon.cpp
    
void AWeapon::Fire()
{
    if(FireAnimation)
    {
		WeaponMesh->PlayAnimation(FireAnimation, false); // 是否循环
    }
}
CombatComponent.h
    
CombatComponent.cpp
   
void UCombatComponent::FireButtonPressed(bool bPressed)
{
    ...
    if(Character && EquippedWeapon && bFireButtonPressed)
    {
        Character->PlayFireMontage(bAiming);
        EquippedWeapon->Fire();
    }
}
  1. 创建武器蓝图BP_AssaultRifle:ProjectileWeapon,给组件赋值并指定开火动画

image-20240319103658503

bug: 武器开火动画没有同步

不管是客户端还是服务端开火,都只在本地播放特效及动画,其他端没有反应;

说明开火特效及动画并没有复制,所以服务端行为不会广播到其他客户端,通过RPC解决问题。

CombatComponent.h
    
protected:
	UFUNCTION(Server, Reliable)
	void Server_Fire();

	UFUNCTION(NetMulticast, Reliable)
	void Multicast_Fire();
CombatComponent.cpp
    
void UCombatComponent::FireButtonPressed(bool bPressed)
{
	bFireButtonPressed = bPressed;
    
    if(bFireButtonPressed)
    {
        Server_Fire();
    }
}

void UCombatComponent::Server_Fire_Implementation()
{
    Multicast_Fire();
}

void UCombatComponent::Multicast_Fire_Implementation()
{
    f(Character && EquippedWeapon)
    {
        Character->PlayFireMontage(bAiming);
        EquippedWeapon->Fire();
    }
}

射击方向

  1. 得到十字准信的目标
CombatComponent.h
    
protected:
	// 获取当前十字准信的瞄准结果,以引用参数的方式返回
	void TraceUnderCrosshairs(FHitResult& TraceHitResult);
CombatComponent.cpp

#define TRANCE_LENGTH 80000

void UCombatComponent::TranceUnderCrosshairs(FHitResult& TraceHitResult)
{
    // 获取准心屏幕坐标
    FVector2D ViewportSize();
    if(GEngine && GEngine->GetViewport())
    {
        GEngine->GetViewport()->GetViewportSize(ViewportSize);
    }
    FVector2D CrosshairLocation(ViewportSize.X / 2.f, ViewportSize.Y / 2.f);
    FVector CrosshairWorldPosition, CrosshairWorldRotation; // 屏幕坐标转世界坐标, 变换矩阵的逆
    bool bScreenToWorld = UGameplayStatics::DeprojectScreenToWorld(
        UGameplayStatics::GetPlayerController(this, 0),
    	CrosshairLocation,
        CrosshairWorldPosition,
        CrosshairWorldRotation
    );
    
    if(bScreenToWorld)
    {
        // 射线检测
        FVector Start = CrosshairWorldPosition; // 不应该从摄像机开始,会被自己人物/身后人物遮挡
		if(Character)
        {
            // 应该从自己人物身前打射线
            float DistanceToCharacter = (Character->GetActorLocation - Start).size();
            Start += (DistanceToCharacter + 100.f) * CrosshairWorldDirection;
        }
        
        
        FVector End = Start + CrosshairWorldDirection * TRACE_LENGTH;
        
        GetWorld()->LineTraceSingleByChannel(
        	TraceHitResult,
            Start,
            End,
            ECollisionChannel::ECC_Visibility
        );
        if(!TraceHitResult.bBlockingHit)
        {
            // 如果没打到物体,就设置到终点
            TraceHitResult.ImpactPoint = End;
        }
        else
        {
            DrawDebugSphere(
            	GetWorld(),
                TraceHitResult.ImpactPoint,
                12.f,
                12,
                FColor::Red
            );
        }
    }
}
bug: 打不到任何场景物体

因为被人物模型遮挡,一般摄像机都会在人物的右上方,设置弹簧臂

image-20240319112041953

武器朝向射击方向

P75

生成炮弹

  1. 新建Projectile:Actor,作为炮弹,并给与初始速度
Projectile.h
    
protected:
	UFUNCTION()
	virtual void OnHit(UPrimitiveComponent* HitComp, AActor* OtherActor, UPrimitiveComponent* OtherComp, FVector NormalImpluse, const FHitResult& HitResult); // 碰撞检测函数

	// 会自动广播给所有客户端,可以利用来生成特效及声音,不必使用PRC
	virtual void Destroyed() override; 

	UPROPERTY(EditAnywhere)
	float Damage = 20.f;

private:
	UPROPERTY(EditAnywhere)
	class UBoxComponent* CollisionBox; // 碰撞检测

	UPROPERTY(VisibleAnywhere)
	class UProjectileMovementComponent* ProjectileMovementComponent; // 投射运动

	UPROPERTY(EditAnywhere)
	UParticleSystem* ImpactParticles; // 炮弹爆炸特效

	UPROPERTY(EditAnywhere)
	USoundCue* ImpactSound; // 炮弹爆炸音效
Projectile.cpp
    
AProjectile::AProjectile()
{
    PrimaryActorTick.bCanEverTick = true;
    bReplicates = true; // 网络同步
    
    //
    // 碰撞盒初始化
    //
    CollisionBox = CreateDefaultSubobject<UBoxComponent>(TEXT("CollisionBox"));
    SetRootComponent(CollisionBOx);
    CollisionBox->SetCollisionEnabled(ECollisionEnabled::QueryAndPhysics);
    
    // 设置碰撞对象
    CollisionBox->SetCollisionObjectType(ECollisionChannel::ECC_WorldDynamic);
    // 设置碰撞通道
    CollisionBox->SetCollisionResponseToAllChannels(ECollisionResponse::ECR_Ignore);
    CollisionBox->SetCollisionResponseToChannel(ECollisionChannel::ECC_Visibility, ECollisionResponse::ECR_Block);
    CollisionBox->SetCollisionResponseToChannel(ECollisionChannel::ECC_WorldStatic, ECollisionResponse::ECR_Block);
	CollisionBox->SetCollisionResponseToChannel(ECollisionChannel::ECC_Pawn, ECollisionResponse::ECR_Block);
    
    //
    // 抛射运动组件
    //
    ProjectileMovementComponent = CreateDefaultSubobject<UProjectileMovementComponent>(TEXT("ProjectileMovementComponent"));
    ProjectileMovementComponent->bRotationFollowVelocity = true; // 弹头朝着运动方向
}

void AProjectile::BeginPlay()
{
    ...
    if(HasAuthority())
    {
         CollisionBox->OnComponentHit.AddDynamic(this, &ThisClass::OnHit);   
    }
}

void AProjectile::OnHit(UPrimitiveComponent* HitComp, AActor* OtherActor, UPrimitiveComponent* OtherComp, FVector NormalImpluse, const FHitResult& HitResult)
{
    Destroy(); // 销毁会自动广播给所有客户端,所以没必要用rpc
}

void AProjectile::OnDestroy()
{
    Super::Destroyed();
    
    if(ImpaceParticles)
    {
        UGameplayStatics::SpawnEmitterAtLocation(GetWorld(), ImpactParticles, GetActorTransform());
    }
    if(ImpactSound)
    {
        UGameplayStatics::PlaySoundAtLocation(this, ImpactSound, GetActorLocation());
    }
}
  1. 创建BP_Projectile:Projectile

image-20240319135739862

image-20240319135817946

image-20240319144127312

image-20240319165409269

  1. 给枪支模型添加开火点

确保x轴朝前方,这样炮弹生成就会往前方发射

image-20240319140806056

  1. 新建ProjectileWeapon:Weapon
ProjectileWeapon.h
    
public:
	virtual void Fire(const FVector& HitTarget) override;

private:
	UPROPERTY(Editwhere)
	TSubclassOf<class AProjectile> ProjectileClass;
ProjectileWeapon.cpp
    
void AProjectileWeapon::Fire(const FVector& HitTarget)
{
    Super::Fire(HitTarget);
    
    if(!HasAuthority()) return; // 服务器才可以生成炮弹
    
    const USkeletalMeshSocket* MuzzleFlashSocket = GetWeaponMesh()->GetSocketByName(FName("MuzzleFlash"));
    if(MuzzleFlashSocket)
    {
        FTransform SocketTransform = MuzzleFlashSocket->GetSocketTransform(GetWeaponMesh());
        FVector ToTarget = HitTarget - SocketTransform.GetLocation();
        FRotator TargetRotation = ToTarget.Rotation(); // 得到发射方向
        
        APawn* InstigatorPawn = Cast<APawn>(GetOwner());
        if(ProjectileClass && InstigatorPawn)
        {
            FActorSpawnParameters SpawnParams;
            SpawnParams.Owner = GetOwner(); // 拥有者
            SpawnParams.Instigator = InstigatorPawn; // 始作俑者
            UWorld* World = GetWorld();
            if(World)
            {
                World->SpawnActor<AProjectile>(
                	ProjectileClass,
                    SocketTransform.GetLocation(), // 发射点
                    TargetRotation,
                    SpawnParams
                );
            }
        }
    }
}
Weapon.h
    
public:
	virtual void Fire(const FVector& HitTarget);
Weapon.cpp
    
void AWeapon::Fire(const FVector& HitTarget)
{
    if(FireAnimation)
    {
        WeaponMesh->PlayAnimation(FireAnimation, false);
    }
}
CombatComponent.h
    
protected:
    UFUNCTION(Server, Reliable) // 网络量化版的vector,最多存储2^20大小的整数范围,节省带宽
	void Server_Fire(const FVector_NetQuantize& HitTarget);

	UFUNCTION(NetMulticast, Reliable)
	void Multicast_Fire(const FVector_NetQuantize& HitTarget);
CombatComponent.cpp

void UCombatComponent::FireButtonPressed(bool bPressed)
{
    ...
   	if(bFireButtonPressed)
    {
		// 只在服务器调用,适合射线检测
        FHitResult& HitResult;
    	TranceUnderCrosshairs(HitResult);
        ServerFire(HitTarget.ImpactPoint); // 注意:ImpacePoint本来就被优化为FVector_NetQuantize
    }
}

void UCombatComponent::Server_Fire_Implementation(const FVector_NetQuantize& HitTarget)
{
    Multicast_Fire(HitTarget);
}

void UCombatComponent::Multicast_Fire_Implementation(const FVector_NetQuantize& HitTarget)
{
    f(Character && EquippedWeapon)
    {
        Character->PlayFireMontage(bAiming);
        EquippedWeapon->Fire(HitTarget);
    }
}
  1. 打开BP_AssaultRifle,选择炮弹蓝图

image-20240319140018533

  1. 直接造成伤害的炮弹,创建ProjectileBullet:Projectile
ProjectileBullet.h
    
protected:
	virtual void OnHit(UPrimitiveComponent* HitComp, AActor* OtherActor, UPrimitiveComponent* OtherComp, FVector NormalImpluse, const FHitResult& HitResult) override;
ProjectileBullet.cpp
    
void AProjectileBullet::OnHit(UPrimitiveComponent* HitComp, AActor* OtherActor, UPrimitiveComponent* OtherComp, FVector NormalImpluse, const FHitResult& HitResult)
{
	ACharacter* OwnerCharacter = Cast<ACharacter>(GetOwner());
    if(OwnerCharacter)
    {
		AController* OwnerController = OwnerCharacter->Controller;
        if(OwnerController)
        {
            UGameplayStatics::ApplyDamage(OtherActor, Damage, OwnerController, this, UDamageType::StaticClass());
        }
    }
    
    // 父类要放到最后!! 因为父类中调用了Destroy()
    Super::OnHit(HitComp, OtherActor, OtherComp, NormalImpluse, HitResult);
}
  1. 创建BP_ProjectileBullet:ProjectileBullet

image-20240321153810676

image-20240321153956720

image-20240321153825123

  1. BP_AssaultRifle中的子弹类型调整为BP_ProjectileBullet

image-20240321153924859

子弹踪迹

给子弹添加Attach Socket

Projectile.h
    
private:
	UPROPERTY(EditAnywhere)
	UParticleSystem* Tracer; // 蓝图中指定对应特效

	UPROPERTY(EditAnywhere)
	UParticleSystemComponent* TracerComponent;
Projectile.cpp
    
void AProjetile::BeginPlay()
{
    ...
    if(Tracer)
    {
        TracerComponent = UGameplay::SpawnEmitterAttached(
        	Tracer,
            CollisionBox,
            FName(), // 如果需要附加到某个骨骼上就添加,不需要就置空
            GetActorLocation(),
            GetActorRotation(),
            EAttachLocation::KeepWorldPosition
        );
    }
}

子弹壳

image-20240319182659337

  1. 创建Casing:Actor
Casing.h
    
private:
	UPROPERTY(VisibleAnywhere)
	UStaticMeshComponent* CasingMesh;

	UPROPERTY(EditAnywhere)
	float ShellEjectionImpluse;
Casing.cpp
    
ACasing::ACasing()
{
    CasingMesh = CreateDefaultSubobject<UStaticMeshComponent>("CasingMesh");
    SetRootComponent(CasingMesh);
    CasingMesh->SetCollisionResponseToChannel(ECollisionChannel::ECC_Camera, ECollisionResponse::ECR_Ignore); // 忽略摄像机碰撞
    CasingMesh->SetSimulatePhysics(true); // 模拟物理
    CasingMesh->SetEnabledGravity(true); // 应用重力
    
    ShellEjectionImpluse = 10.f;  
}

void ACasing::BeginPlay()
{
    ...
    CasingMesh->AddImpluse(GetActorForwardVector() * ShellEjectionImpluse);
}
  1. 创建BP_Casing蓝图,指定StaticMesh

  2. 枪支中持有弹壳

Weapon.h
    
private:
	UPROPERTY(EditAnywhere)
	TSubclassOf<class ACasing> CasingClass;
Weapon.cpp
    
void AWeapon::Fire(...)
{
    ...
    if(CasingClass)
    {
        const USkeletalMeshSocket* AmmoEjectSocket = WeaponMesh->GetSocketByName(FName("AmmoEject"));
        if(AmmoEjectSocket)
        {
            FTransform SocketTransform = AmmoEjectSocket->GetSocketTransform(WeaponMesh);
            
            FWorld* World = GetWorld();
            if(World)
            {
                World->SpawnActor<ACasing>(
                	CasingClass,
                    SocketTransform.GetLocation(),
                    SocketTransform.GetRotation().Rotator()
                );
            }
        }
    }
}
给弹壳添加光晕
  1. 找到弹壳材质的父类image-20240319185612982

  2. 按住4+鼠标左键创建4维向量,连接到Emissive Color,然后右键Convert to Parameter,命名为Emissive

  3. 接着把弹壳材质复制一份命名为M_Ammo,进入调整EMissive

image-20240319185931466

弹壳落地音效及销毁
Casing.h
    
private:
	UFUNCTION()
	virtual void OnHit(UPrimitiveComponent* HitComp, AActor* OtherActor, UPrimitiveComponent* OtherComp, FVector NormalImpluse, const FHitResult& HitResult); // 碰撞检测函数

	URPOPERTY(EditAnywhere)
	SoundCue* ShellSound;
Casing.cpp
    
ACasing::ACasing()
{
    ....
	CasingMesh->SetSimulatePhysics(true); // 模拟物理
    CasingMesh->SetEnabledGravity(true); // 应用重力    
    CasingMesh->SetNotifyRigidBodyCollision(true); // 通知刚体碰撞
    ....
}

void ACasing::BeginPlay()
{
    ...
    CasingMesh->OnComponentHit.AddDynamic(this, &ThisClass::OnHit);
    ...
}

void ACasing::OnHit(...)
{
    if(ShellSound)
    {
        UGameplayStatics::PlaySoundAtLocation(this, ShellSound, GetActorLocation());
    }
    // Destroy();
    SetLifeSpan(3.f);
}

自动开火

CombatComponent.h

private:
	UPROPERTY(EditAnywhere, Category = Combat)
	bool bCanFire = true;

	FTimerHandle FireTimer;

	void StartFireTimer();
	void FireTimerFinished();
CombatComponent.cpp
 
void UCombatComponent::FireButtonPressed(bool bPressed)
{
    ...
    if(bFireButtonPressed && EquippedWeapon)
    {
        Fire();
    }
}

void UCombatComponent::Fire()
{
    if(bCanFire)
    {
        bCanFire = false;
        Server_Fire();
        CrosshairShootingFactor = 0.75f;
        StartFireTimer(); // 开火就开启下一次Timer
    }
    
}

void UCombatComponent::StartFireTimer()
{
    if(Character == nullptr || EquippedWeapon == nullptr) return;
    
    Character->GetTimerManager()->SetTimer(
    	FireTimer,
        this,
        &UCombatComponent::FireTimerFinished,
        EquippedWeapon->FireDelay
    );
}

void UCombatComponent::FireTimerFinished()
{
    if(EquippedWeapon == nullptr) return;
    bCanFire = true;
    if(bFireButtonPressed && EquippedWeapon->bAutomatic)
    {	
        // 如果还在开火,就继续Fire
        Fire(); // 每次进入bCanFire都会->false,如果没进Fire,就肯定为true了
    }
}
Weapon.h
    
public:
	UPROPERTY(EditAnywhere, Category = Combat)
	float FireDelay = .15f;

	UPROPERTY(EditAnywhere, Category = Combat)
	bool bAutomatic = true;

HUD (十字准心)

Heah-Up Display,和UMG(UI)很类似,不同之处在于,HUD是在角色前方显示的和游戏内容相关的信息,比如准星、枪械信息、血量等,这些信息一般是不需要交互的。

image-20240319195914321

- 移动和跳跃,准心扩散

- 瞄准准心收缩

- 射击准心扩散

  1. 创建BlasterPlayerController:PlayerController

  2. 创建BP_BlasterPlayerController:BlasterPlayerController

  3. 创建BlasterHUD:HUD

BlasterHUD.h

USTRUCT(BlueprintType)
struct FHUDPackage
{
    GENERATED_BODY()
public:
    UTexture2D* CrosshairsCenter;
    UTexture2D* CrosshairsLeft;
    UTexture2D* CrosshairsRight;
    UTexture2D* CrosshairsTop;
    UTexture2D* CrosshairsBottom;
    float CrosshairSpread; // 准心扩散
    FLinearColor CrosshairsColor;
}
class BlasterHUD ...
{
    ...
public:
	virtual void DrawHUD() override;   
private:
    FHUDPackage HUDPackage;
    
    void DrawCrosshair(UTexture2D* Texture, FVector2D ViewportCenter, FVector2D Spread);
    
    UPROPERTY(EditAnywhere)
    flaot CrosshairSpreadMax = 16.f;
public:
    FORCEINLINE void SetHUDPackage(const FHUDPackage& Package) { HUDPackage = Package; }
}
BlasterHUD.cpp

// 每帧调用
void ABlasterHUD::DrawHUD()
{
    Super::DrawHUD();
    
    // 绘制准心
    FVector2D ViewportSize;
    if(GEngine)
    {
        GEngine->GameViewport->GetViewportSize(ViewportSize);
        const FVector2D ViewportCenter(ViewportSize.X / 2.f, ViewportSize.Y / 2.f);
        
        // 扩散强度乘上基数
        float SpreadScaled = CrosshairSpreadMax * HUDPackage.CrosshairSpread;
        
        if(HUDPackage.CrosshairsCenter) 
        {
            FVector2D Spread(0.f, 0.f); // 不需要扩散
            DrawCrosshair(CrosshairsCenter, ViewportCenter, Spread);
        }
        if(HUDPackage.CrosshairsLeft) 
        {
            FVector2D Spread(-SpreadScaled, 0.f); // 往左扩散
            DrawCrosshair(CrosshairsLeft, ViewportCenter, Spread);
        }
        if(HUDPackage.CrosshairsRight) 
        {
            FVector2D Spread(SpreadScaled, 0.f); // 往右扩散
            DrawCrosshair(CrosshairsRight, ViewportCenter, Spread);
        }
        if(HUDPackage.CrosshairsTop) 
        {
            FVector2D Spread(0.f, -SpreadScaled); // 往上扩散
            DrawCrosshair(CrosshairsTop, ViewportCenter, Spread);
        }
        if(HUDPackage.CrosshairsBottom) 
        {
			FVector2D Spread(0.f, SpreadScaled); // 往下扩散
            DrawCrosshair(CrosshairsBottom, ViewportCenter, Spread);
        }
    }
}

void ABlasterHUD::DrawCrosshair(UTexture2D* Texture, FVector2D ViewportCenter, FVector2D Spread)
{
	const float TextureWidth = Texture->GetSizeX();
    const float TextureHeight = Texture->GetSizeY();
    // 屏幕坐标从左上角开始,往右x正轴,往下y正轴,所有纹理要往左上方平移
    const FVector2D TextureDrawPoint(
    	ViewportCenter.X - TextureWidth / 2.f + Spread.X, // 扩散方向
        ViewportCenter.Y - TextureHeight / 2.f + Spread.Y // 扩散方向
    );
    
    DrawTexture(
    	Texture,
        TextureDrawPoint.X,
        TextureDrawPoint.Y,
        TextureWidth,
        TextureHeight,
        0.f,
        0.f,
        1.f,
        1.f,
        HUDPackage.CrosshairColor // 颜色
    );
}
  1. 枪上添加准心纹理
Weapon.h
    
public:
	UPROPERTY(EditAnywhere, Category = Crosshairs)
	UTexture2D* CrosshairsCenter;

	UPROPERTY(EditAnywhere, Category = Crosshairs)
	UTexture2D* CrosshairsLeft;

	UPROPERTY(EditAnywhere, Category = Crosshairs)
	UTexture2D* CrosshairsRight;

	UPROPERTY(EditAnywhere, Category = Crosshairs)
	UTexture2D* CrosshairsTop;

	UPROPERTY(EditAnywhere, Category = Crosshairs)
	UTexture2D* CrosshairsBottom;
  1. 获取纹理资源
  • github仓库
  • 右键属性矩阵,统一压缩格式改为UserInterface2D(RGBA)
  1. 创建BP_BlasterHUD:BlasterHUD,将BP_BlasterGameMode->HUD Class->BP_BlasterHUD

  2. 在战斗组件中设置HUD中的准心数据

ComabatComponent.h
    
protected:
	// 准心可能随角色速度、后坐力等发生变化,涉及到插值,输入参数需要DeltaTime
	void SetHUDCrosshairs(float DeltaTime);
private:
	ABlasterPlayerController* Controller;
	ABlasterHUD* HUD;

	FHUDPackage HUDPackage;

	float CrosshairVelocityFactor; // 速度扩散因子
	float CrosshairInAirFactor; // 空中扩散因子
	float CrosshairAimFactor; // 瞄准扩散因子
	float CrosshairShootingFactor; // 射击扩散因子
CombatComponent.cpp
    
void UCombatComponent::SetHUDCrosshairs(float DeltaTime)
{
    if(Character == nullptr || Character->Controller) return;
    
    Controller = Controller == nullptr ? Cast<ABlasterPlayerController>(Character->Controller) : Controller;
    if(Controller)
    {
        HUD = HUD == nullptr ? Cast<ABlasterHUD>(Controller->GetHUD()) : HUD;
        if(HUD)
        {
            // 根据是否装备武器改变
            if(EquippedWeapon)
            {
                HUDPackage.CrosshairsCenter = EquippedWeapon->CrosshairsCenter;
                HUDPackage.CrosshairsLeft = EquippedWeapon->CrosshairsLeft;
                HUDPackage.CrosshairsRight = EquippedWeapon->CrosshairsRight;
                HUDPackage.CrosshairsTop = EquippedWeapon->CrosshairsTop;
                HUDPackage.CrosshairsBottom = EquippedWeapon->CrosshairsBottom;
            }
            else
            {
                HUDPackage.CrosshairsCenter = nullptr;
                HUDPackage.CrosshairsLeft = nullptr;
                HUDPackage.CrosshairsRight = nullptr;
                HUDPackage.CrosshairsTop = nullptr;
                HUDPackage.CrosshairsBottom = nullptr;
            }
            // 水平速度的准心扩散
            FVector2D WalkSpeedRange(0.f, Character->GetCharacterMovement()->MaxWalkSpeed);
            FVector2D VelocityMultiplierRange(0.f, 1.f);
            FVector Velocity = Character->GetVelocity();
            Velocity.Z = 0.f;
            
            CrosshairVelocityFactor = FMath::GetMappedRangeValueClamped(WalkSpeedRange, VelocityMultiplierRange, Velocity.Size());
            
            // 垂直速度的准心扩散
            if(Character->GetCharacterMovement()->IsFailling())
            {
                // 上升就逐渐增大扩散因子
                CrosshairInAirFactor = FMath::FInterpTo(CrosshairInAirFactor, 2.25f, DeltaTime, 2.25f);
            }
            else
            {
                // 下降就逐渐减小扩散因子
                CrosshairInAirFactor = FMath::FInterpTo(CrosshairInAirFactor, 0.f, DeltaTime, 30f);
            }
            
            if(bAiming)
            {
            	CrosshairAimFactor = FMath::FInterpTo(CrosshairAimFactor, 0.58f, DeltaTime, 30.f);
            }
            else 
            {
      			CrosshairAimFactor = FMath::FInterpTo(CrosshairAimFactor, 0.f, DeltaTime, 30.f);          
            }
            
            // 射击时才增加,平时都一直减少
            CrosshairShootingFactor = FMath::FInterpTo(CrosshairShootingFactor, 0.f, DeltaTime, 40.f);
            
            HUDPackage.CrosshairSpread = 
                0.5f + // 起始扩散量,避免-AimFactor后过小
                CrosshairVelocityFactor + 
                CrosshairInAirFactor -
                CrosshairAimFactor +
                CrosshairShootingFactor;
            
            HUD->SetHUDPackage(HUDPackage);
        }
    }
}

void UCombatComponent::TickComponent(...)
{
    ...
    if(Character && Character->IsLocallyControlled)
    {
        // 本地控制才需要绘制准心
         SetHUDCrosshairs(DeltaTime);   
    }
}

void UCombatComponent::FireButtonPressed(bool bPressed)
{
    ...
        if(EquippedWeapon)
        {
            CrosshairShootingFactor = 0.75f; // 射击就固定增加扩散
            // CrosshairShootingFactor += 0.2f; // 射击就累加扩散
        }
}

void UCombatComponent::TraceUnderCrosshairs(FHitResult& TraceHitResult)
{
    ...
        if(TranceHitResult.GetActor() && TraceHitResult.GetActor()->Implements<InteractWithCrosshairsInterface>()) // 目标实现了准心变化接口
        {
            HUDPackage.CrosshairsColor = FLinearColor:Red;
        }
	    else
        {
            HUDPackage.CrosshairsColor = FLinearColor:White;
        }
}

准星颜色变化

  1. 创建接口InteractWithCrosshairsInterface:Unreal Interface

虚幻接口包含两个类

image-20240320153329055

  1. 人物阻挡可见性
BlasterCharacter.cpp
    
ABlasterCharacter::ABlasterCharacter()
{
    ...
    GetMesh()->SetCollisionResponseToChannel(ECollisionChannel::ECC_Visibility, ECollisionResponse::ECR_Block); // 人物可以遮挡准心,记得蓝图中检查
}

靠近墙壁,隐藏人物

BlasterCharacter.h
    
private:
	void HideCameraIfCharacterClose();
	
	UPROPERTY(EditAnywhere)
	float CameraThreshold = 200.f;
BlasterCharacter.cpp

void ABlasterCharacter::Tick(float DeltaTime)
{
	...
    HideCameraIfCharacterClose();
}
    
    
void ABlasterCharacter::HideCameraIfCharacterClose()
{
    // 只在隐藏本地角色
    if(!IsLocallyControlled()) return;
    
    // 如果摄像机和人物离太近了
    if((FollowCamera->GetComponentLocation() - GetActorLocation()).Size() < CameraThreshold)
    {
        GetMesh()->SetVisibility(false);
        if(Combat && Combat->EquippedWeapon && Combat->EquippedWeapon->GetWeaponMesh())
        {
            // 对拥有者不可见
            Combat->EquippedWeapon->GetWeaponMesh()->bOwnerNoSee = true;
        }
    }
    else
    {
        GetMesh()->SetVisibility(true);
        if(Combat && Combat->EquippedWeapon && Combat->EquippedWeapon->GetWeaponMesh())
        {
            // 对拥有者不可见
            Combat->EquippedWeapon->GetWeaponMesh()->bOwnerNoSee = false;
        }
    }
}

受击动画

  1. 处理受击动画,复制一份并改为Additive,四种受击动画都作此处理

image-20240320170223405

image-20240320170303945

  1. 创建HitReact蒙太奇,把四种受击动画都拖进去,设置Preview Base Pose,根据受击的方向创建Montage Section

image-20240320170514960

image-20240320170546233

image-20240320170649864

image-20240320170729326

  1. 创建HitReactSlot

image-20240320170836915

  1. 触发蒙太奇
BlasterCharacter.h
    
private:
	UPROPERTY(EditAnywhere, Category=Combat)
	UAnimMontage* HitReactMontage;

protected:
	void PlayHitReactMontage();

public:
	UFUNCTION(NetMulticast, Unreliable)
	void Multicast_Hit();
BlasterCharacter.cpp
    
void ABlasterCharacter::PlayHitReactMontage()
{
	UAnimInstance AnimInstance = GetMesh()->GetAnimInstance();
    if(AnimInstance && HitReactMontage)
    {
        AnimInstance->Montage_Play(HitReactMontage);
        AnimInstance->Montage_JumpToSection(FName("FromFront"));
    }
}

void ABlasterCharacter::Multicast_Hit_Implementation()
{
    PlayHitReactMontage();
}
Projectile.cpp

// 只在服务器绑定
void AProjectile::OnHit(UPrimitiveComponent* HitComp, AActor* OtherActor, UPrimitiveComponent* OtherComp, FVector NormalImpluse, const FHitResult& HitResult)
{
	ABlasterCharacter Character = Cast<ABlasterCharacter>(OtherActor);
    if(Character)
    {
        Character->Multicast_Hit();
    }
    
    Destroy();
}
击中胶囊体不够准确,希望击中骨骼网格
Projectile.cpp

AProjectile::AProjectile()
{
    // // ECC_Pawn表示会打中胶囊体
	CollisionBox->SetCollisionResponseToChannel(ECollisionChannel::ECC_Pawn, ECollisionResponse::ECR_Block); 
}

想要打中人物,而不打中胶囊体,就需要自定义碰撞通道

image-20240320191022127

Blaster.h
    
#define ECC_SkeletalMesh ECollisionChannel::ECC_GameTraceChannel1
Projectile.cpp

AProjectile::AProjectile()
{
    ...
    // ECollisionChannel::ECC_Pawn, 蓝图中记得取消碰撞
	CollisionBox->SetCollisionResponseToChannel(ECC_SkeletalMesh, ECollisionResponse::ECR_Block);
    ...
}
BlasterCharacter.cpp
    
ABlasterCharacter::ABlasterCharacter()
{
    ...
	GetMesh()->SetCollisionObjectType(ECC_SkeletalMesh);
}

代理角色旋转有误,颤动

P81

游戏框架

image-20240321105916112

image-20240321105942343

image-20240321110003045

image-20240321110026534

游戏实现二

人物血量

为什么将血量放到Character中,而不是放在Player State

image-20240321110355506

Player State网络更新稍慢,没有在Character直接处理快,血量往往要求足够实时

  1. 添加血量
BlasterCharacter.h
    
protected:
	UFUNCTION()
	void ReceiveDamage(AActor* DamagedActor, float Damage, const UDamageType* DamageType, class AController* InstigatorController, AActor* DamageCauser);

	void UpdateHUDHealth();
    
private:
	UPROPERTY(EditAnywhere, Category = "Player Stats")
	float MaxHealth = 100.f;

	UPROPERTY(ReplicatedUsing = OnRep_Health, VisibleAnywhere, Category = "Player Stats")
	float Health = 100.f;

	UFUNCTION()
	void OnRep_Health();

	class ABlasterPlayerController* BlasterPlayerController;
BlasterCharacter.cpp
 
void ABlasterCharacter::BeginPlay()
{
    ...
    UpdateHUDHealth();
    if(HasAuthority())
    {
        OnTakeDamage.AddDynamic(this, &ABlasterCharacter::ReceiveDamage);
    }
}
    
void ABlasterCharacter::OnRep_Health()
{
    PlayHitReactMontage();
    UpdateHUDHealth();
}

void ABlasterCharacter::UpdateHUDHealth()
{
    BlasterPlayerController = BlasterPlayerController == nullptr ? Cast<ABlasterPlayerController>(Controller) : BlasterPlayerController;
    if(BlasterPlayerController)
    {
        BlasterPlayerController->SetHUDHealth(Health, MaxHealth);
    }
}

void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const 
{
    ...
    DOREPLIFETIME(ABlasterCharacter, Health);
}

void ABlasterCharacter::ReceiveDamage(AActor* DamagedActor, float Damage, const UDamageType* DamageType, class AController* InstigatorController, AActor* DamageCauser)
{
    Health = FMath::Clamp(Health - Damage, 0.f, MaxHealth);
    OnRep_Health(); // 更新HUD按理说服务器不需要更新的
    
    
    if(Health == 0.f)
    {
		// 触发淘汰
        ABlasterGameMode* BlasterGameMode = GetWorld()->GetAuthGameMode<ABlasterGameMode>();
        if(BlasterGameMode)
        {
            BlasterPlayerController = BlasterPlayerController == nullptr ? Cast<ABlasterPlayerController>(Controller) : BlasterPlayerController;
            ABlasterPlayerController* AttackerController = Cast<ABlasterPlayerController>(InstigatorController);
            // 核心代码
            BlaseterGameMode->PlayerElimminated(this, BlasterPlayerController, AttackerController);
        }
    }
}
  1. 创建WBP_CharacterOverlay:Widget Blueprint

image-20240321112000393

  1. 创建CharacterOverlay:User Widget,并设置为WBP_CharacterOverlap的父类
CharacterOverlay.h
    
public
   	UPROPERTY(meta = (BindWidget))
	class UProgressBar* HealthBar;

	UPROPERTY(meta = (BindWidget))
	class UTextBlock* HealthText;
BlasterHUD.h
    
public:
	UPROPERTY(EditAnywhere, Category = "Player Stats")
	TSubclassOf<class UUserWidget> CharacterOverlayClass; // 蓝图中指定为WBP_CharacterOverlay

	class UCharacterOverlay* CharacterOverlay;

protected:
	virtual void BeginPlay() override;
	void AddCharacterOverlay();
BlasterHUD.cpp
    
void ABlasterHUD::BeginPlay()
{
    Super::BeginPlay();
    
    AddCharacterOverlay();
}

void ABlasterHUD::AddCharacterOverlay()
{
    APlayerController* PlayerController = GetOwningPlayerController();
    if(PlayerController && CharacterOverlayClass)
    {
        CharacterOverlay = CreateWidget<UCharacterOverlay>(PlayerController, CharacterOverlayClass);
        CharacterOverlay->AddToViewport();
    }
}
  1. 更新血量,更新``HUD,最好通过PlayerController`来控制
BlasterPlayerController.h
    
public:
	void SetHUDHealth(float Health, float MaxHealth);
    
protected:
	virtual void BeginPlay() override

private:
	class ABlasterHUD* BlasterHUD;
BlasterPlayerController.cpp
    
void ABlasterPlayerController::BeginPlay()
{
 	Super::BeginPlay();
    
    BlasterHUD = Cast<ABlasterHUD>(GetHUD());
}

void ABlasterPlayerController::SetHUDHealth(float Health, float MaxHealth)
{
    // 安全检查
    BlasterHUD = BlasterHUD == nullptr ? Cast<ABlasterHUD>(GetHUD()) : BlasterHUD;
    
    bool bHUDValid = BlasterHUD &&
        BlasterHUD->CharacterOverlay &&
        BlasterHUD->CharacterOverlay->HealthBar &&
        BlasterHUD->CharacterOverlay->HealthText;
    if(bHUDValid)
    {
        const float HealthPercent = Health / MaxHealth;
        BlasterHUD->CharacterOverlay->HealthBar->SetPercent(HealthPercent);
        FString HealthText = FString::Printf(TEXT("%d/%d"), FMath::CeilToInt(Health), FMath::CeilToInt(MaxHealth));
        BlasterHUD->CharacterOverlay->HealthText->SetText(FText::FromString(HealthText));
    }
}

游戏模式

  1. 删除原始游戏模式,并重新生成项目代码

image-20240321154315607

玩家死亡

  1. 创建BlasterGameMode:GameMode
BlasterGameMode.h
    
public:
	// 淘汰玩家
	virtual void PlayerEliminated(class ABlasterCharacter* ElimmedCharacter, class ABlasterController* VictimController, ABlasterController* AttackerController);
BlasterGameMode.cpp
    
void ABlasterGameMode::PlayerEliminated(class ABlasterCharacter* ElimmedCharacter, class ABlasterController* VictimController, ABlasterController* AttackerController)
{
    if(ElimmedCharacter)
    {
        ElimmedCharacter->Elim();
    }
}
  1. 玩家实现自己的淘汰方式
BlasterCharacter.h
    
private:
	bool bElimmed = false;

public:
	void Elim();
	UFUNCTION(NetMulticast, Reliable)
	void Multicast_Elim();
	void PlayElimMontage();
	FORCEINLINE bool IsElimmed() const { return bElimmed; }
BlasterCharacter.cpp
 
// 只在服务器调用
void ABlasterCharacter::Elim()
{
    // 丢弃武器
    if(Combat && Combat->EquippedWeapon)
    {
        Combat->EquippedWeapn->Dropped();
    }
    // 玩家死亡
    Multicast_Elim();
}

void ABlasterCharacter::Multicast_Elim_Implementation()
{
	bElimmed = true;
    PlayElimMontage();
    
    // Disable character movement
    GetCharacterMovement()->DisableMovement(); // 不能移动,但能原地旋转
    GetCharacterMovement()->StopMovementImmediately(); // 什么移动都不可以
    if(BlasterPlayerController)
    {
        DisableInput(BlasterPlayerController); // 禁止输入
    }
    // Disable collision
    GetCapsuleComponent()->SetCollisionEnabled(ECollisionEnabled::NoCollision);
    GetMesh()->SetCollisionEnabled(ECollisionEnabled::NoCollision
                                   
	// Spawn elim bot
	if(ElimBotEffect)
	{
        if(ElimBotSound)
        {
            UGameplayStatics::SpawnSoundAtLocation(
            	this,
                ElimBotSound,
                GetActorLocation()
            );
        }
        FVector ElimBotSpawnPoint(GetActorLocation.X, GetActorLocation.Y, GetActorLocation.Z + 200.f);
        ElimBotComponent = UGameplayStatics::SpawnEmitterAtLocation(
        	GetWorld(),
            ElimBotEffect,
            ElimBotSpawnPoint,
            GetActorRotation()
        );
    }
}

void ABlasterCharacter::PlayElimMontage()
{
	UAnimInstance* AnimInstance = GetMesh()->GetAnimInstance();
    if(AnimInstance && ElimMontage)
    {
        AnimInstance->Montage_Play(ElimMontage);
    }
}
  1. 创建BP_BlasterGameMode:GameMode,指定父类BlasterGameMode

image-20240321170752911

  1. BlasterMap->World Settings->BP_BlasterGameMode

  2. 玩家死亡动画

  • 复制EpikCharacter_FlySmooth->命名为ElimAnimation
  • 调整盆骨pelvis旋转,朝上

image-20240321171340677

  • 添加上升的Key,并烘焙到一个新的动画Elim

image-20240321171631813

  • 制作蒙太奇Elim,添加ElimSlot,拖入Elim动画
  • ElimSlot添加到动画蓝图中
  • BlasterAnimInstance中获取数据
BlasterAnimInstance.h
    
private:
	bool IsElimmed;
BlasterAnimInstance.cpp
    
void UBlasterAnimInstance::NativeUpdateAnimation(float DeltaTime)
{
    ...
    bElimmed = BlasterCharacter->IsElimmed();
}
Bot
  1. 资源商店添加Paragon:Dekker,使用其中的P_Bot_Cage特效
BlasterCharacter.h
    
protected:
	virtual void Destroyed() override;
    
private:
	UPROPERTY(EditAnywhere)
    UParticleSystem* ElimBotEffect;

	UPROPERTY(VisibleAnywhere)
	UParticleSystemComponent* ElimBotComponent;

	UPROPERTY(EditAnywhere)
	USoundCue* ElimBotSound;
BlasterCharacter.cpp
    
void ABlasterCharcter::Destroyed()
{
    Super::Destroyed();
    
    if(ElimBotComponent)
    {
        ElimBotComponent->DestroyComponent();
    }
}
人物溶解
  1. 创建材质M_DissolveEffect,随便找一个Noise纹理

image-20240321201737119

  1. 接着创建材质实例

image-20240321201807472

  • Dissolve:溶解程度

  • Glow:发光亮度

P91-P93

丢弃武器
Weapon.h
    
public:
	void Dropped();
Weapon.cpp
    
void OnRep_WeaponState()
{
	// 根据当前的武器状态,调整检测和提示显示
	switch(WeaponState)
	{
		case EWeaponState::EWS_Equipped:
			ShowPickupWidget(false);
			AreaSphere->SetCollisionEnabled(ECollisionEnabled::NoCollision);
            WeaponMesh->SetSimulatePhysics(false);
            WeaponMesh->SetEnableGravity(false);
            WeaponMesh->SetCollisionEnabled(ECollisionEnabled::NoCollision);
			break;
     	case EWeaponState::EWS_Dropped:
			ShowPickupWidget(true);
			AreaSphere->SetCollisionEnabled(ECollisionEnabled::QueryOnly);
            WeaponMesh->SetSimulatePhysics(true);
            WeaponMesh->SetEnableGravity(true);
            WeaponMesh->SetCollisionEnabled(ECollisionEnabled::QueryAndPhysics);
			break;
	}
}

void AWeapon::SetWeaponState(EWeaponState State)
{
	WeaponState = State;
	// 这个函数只在服务器上调用,需要手动调用`OnRep`
	OnRep_WeaponState();
}
    
void AWeapon::Dropped()
{
    SetWeaponState(EWeaponState::EWS_Dropped);
    
    FDetachmentTransformRules DetachRules(EDetachmentRule::KeepWorld, true);
    WeaponMesh->DetachFromComponent(DetachRules);
    
    SetOwner(nullptr);
}
BlasterCharacter.cpp
    
void ABlasterCharacter::Elim()
{
    
}
玩家重生
BlasterCharacter.h
    
private:
	FTimerHandler ElimHandler;
	
	float ElimDelay = 3.f;

	void ElimTimerFinished();
BlasterCharacter.cpp

// 在GameMode中调用,即只在服务器执行
void ABlasterCharacter::Elim()
{
    Multicast_Elim();
    GetWorldTimerManager().SetTimer(
    	ElimTimer,
        this,
        &ABlasterCharacter::ElimTimerFinished,
        ElimDelay
    ); // 只需要执行一次
}

void ABlasterCharacter::ElimTimerFinished()
{
    ABlasterGameMode* BlasterGameMode = GetWorld()->GetAuthGameMode<ABlasterGameMode>();
    if(BlasterGameMode)
    {
        BlasterGameMode->RequestRespawn(this, Controller);
    }
}
BlasterGameMode.h
    
public:
	virtual void RequestRespawn(ACharacter* ElimmedCharacter, AController* ElimmedController);
BlasterGameMode.cpp
    
void ABlasterGameMode::RequestRespawn(ACharacter* ElimmedCharacter, AController* ElimmedController)
{
    if(ElimmedCharacter)
    {
        ElimmedCharacter->Reset(); // 释放控制器等,看源码
        ElimmedCharacter->Destroy();
    }
    if(ElimmedController)
    {
        // 随机选一个PlayerStart重生
        TArray<AActor*> PlayerStarts;
        UGameplayStatics::GetAllActorsOfClass(this, APlayerStart::StaticClass(), PlayerStates);
        int32 Selection = FMath::RandRange(0, PlayerStarts.Num()-1);
        RestartPlayerAtPlayerStart(ElimmedController, PlayerStarts[Selection]);
    }
}
bug: 有时不会重生,报错说spawn at location xx error

这是因为该出生点有人物了,发生碰撞,所以没有生成,调整BP_BlasterCharacter的生成规则

image-20240321194603068

或者在代码中调整

image-20240321194755786

重生后重置人物属性(OnPossess)
ABlasterPlayerController.h
    
public:
	virtual void OnPossess(APawn* InPawn) override;
ABlasterPlayerController.cpp
    
void ABlasterPlayerController::OnPossess(APawn* InPawn)
{
    Super::OnPossess(InPawn);
    
    // OnPosses 和 Character->BeginPlay 都需要设置
    ABlasterCharacter* BlasterCharacter = Cast<ABlasterCharacter>(InPawn);
    if(BlasterCharacter)
    {
        SetHUDHealth(BlasterCharacter->GetHealth(), BlasterCharacter->GetMaxHealth());
    }
}

玩家状态

即使玩家死亡,也会依然存在,内置了一些属性

玩家分数
  1. 创建BlasterPlayerState:PlayerState,内置了Score,并实现了Replicated
BlasterPlayerState.h
    
public:
	UFCTION(Server, Reliable)
	void AddToScore(float ScoreAmount);
	virtual void OnRep_Score() override;
private:
	class ABlasterCharacter Character;
	class ABlasterPlayerController Controller;
BlasterPlayerState.cpp
 
void BlasterPlayerState::AddToScore(float ScoreAmount)
{
    // 在服务器上更改分数
    SetScore(Score + ScoreAmount);
    
    OnRep_Score();
}

// 同步到客户端,同时更新HUD
void BlasterPlayerState::OnRep_Score()
{
    Super::OnRep_Score();
    
    Character = Character == nullptr ? Cast<ABlasterCharacter>(GetPawn()) : Character;
    if(Character)
    {
        Controller = Controller == nullptr ? Cast<ABlasterPlayerController>(Character->Controller) : Controller;
        if(Controller)
        {
            Controller->SetHUDScore(Score);
        }
    }
}
  1. HUD-WBP_CharacterOveraly添加Score,并绑定控件

image-20240406193006842

CharacterOverlay.h
    
public:
	UPROPERTY(meta = (BindWidget))
	UTextBlock* ScoreAmount;
  1. 通过PlayerController控制HUD,并更新UI
BlasterPlayerController.h
    
public:
	void SetHUDScore(float Score);
BlasterPlayerController.cpp
    
void BlasterPlayerController::SetHUDScore(float Score)
{
	BlasterHUD = BlasterHUD == nullptr ? Cast<ABlasterHUD>(GetHUD()) : BlasterHUD;
    bool bHUDValid = BlaterHUD &&
        BlasterHUD->CharacterOverlay &&
        BlasterHUD->CharacterOverlay->ScoreAmount;
    if(bHUDValid)
    {
        FString ScoreText = FString::Printf(TEXT("%d"), FMath::FloorToInt(Score));
        BlasterHUD->CharacterOverlay->ScoreAmount->SetText(FText::FromString(ScoreText));
    }
}
  1. 击杀得分
BlasetrGameMode.cpp
    
void ABlasterGameMode::PlayerEliminated(class ABlasterCharacter* ElimmedCharacter, class ABlasterPlayerController* VictimController,...)
{
    ABlasterPlayerState* AttackerPlayerState = AttackerController ? Cast<ABlasterPlayerState>(AttackerController->PlayerState) : nullptr;
    ABlasterPlayerState* VictimPlayerState = VictimController ? Cast<ABlasterPlayerState>(VictimController->PlayerState) : nullptr;
    
    if(AttackerPlayerState && AttackerPlayerState != VictimPlayerState)
    {
        AttackerPlayerState->AddToScore(1.0f);
    }
    
    ...
}
  1. 接着把PlayerState和角色绑定到一起,创建BP_BlasterPlayerState:BlasterPlayerState
  2. BP_BlasterGameMode->Player State Class->BP_BlasterPlayerState
初始化分数
BlasterCharacter.h
    
public:
	class ABlasterPlayerState* BlasterPlayerState;
	void PollInit();
BlasterCharacter.cpp
    
void ABlasterCharacter::PollInit()
{
    if(BlasterPlayerState == nullptr)
    {
        BlasterPlayerState = GetPlayerState<ABlasterPlayerState>();
        if(BlasterPlayerState)
        {
            BlasterPlayerState->AddToScore(0.f);
        }
    }
}

void ABlasterCharacter::Tick(float DeltaTime)
{
    ...
    PollInit(); // 每帧检查
}
失败数
  1. 修改WBP_CharacterOverlay,添加Defeats

image-20240407194128596

  1. CharacterOverlay中控件绑定
CharacterOverlay.h
    
public:
	UPROPERTY(meta = (BindWidget))
	UTextBlock* DefeatsAmount;
  1. BlasterPlayerController更新头显
BlasterPlayerController.h
    
public:
	void SetHUDDefeats(int32 Defeats);
BlasterPlayerController.cpp
    
void BlasterPlayerController::SetHUDDefeats(int32 Defeats)
{
	BlasterHUD = BlasterHUD == nullptr ? Cast<ABlasterHUD>(GetHUD()) : BlasterHUD;
    bool bHUDValid = BlaterHUD &&
        BlasterHUD->CharacterOverlay &&
        BlasterHUD->CharacterOverlay->DefeatsAmount;
    if(bHUDValid)
    {
        FString DefeatsText = FString::Printf(TEXT("%d"), Defeats);
        BlasterHUD->CharacterOverlay->DefeatsAmount->SetText(FText::FromString(DefeatsText));
    }
}
  1. 添加玩家状态
BlasterPlayerState.h
    
private:
	void AddToDefeats(int32 DefeatsAmount);

	virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override;
	
	UFUNCTION()
	virtual void OnRep_Defeats() override;

	UPROPERTY(ReplicatedUsing = OnRep_Defeats)
	int32 Defeats;

public:
	void SetDefeats(int32 NewDefeats) { Defeats = NewDefeats; }
BlasterPlayerState.cpp
    
void ABlasterPlayerState::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const 
{
    Super::GetLifetimeReplicatedProps(OutLifetimeProps);
    
    DOREPLIFETIME(ABlasterPlayerState, Defeats)
}

void ABlasterPlayerState::AddToDefeats(int32 DefeatsAmount)
{
    SetDefeats(DefeatsAmount);
    OnRep_Defeats();
}

void ABlasterPlayerState::OnRep_Defeats()
{
    Super::OnRep_Defeats();
    
    Character = Character == nullptr ? Cast<ABlasterCharacter>(GetPawn()) : Character;
    if(Character)
    {
        Controller = Controller == nullptr ? Cast<ABlasterPlayerController>(Character->Controller) : Controller;
        if(Controller)
        {
            Controller->SetHUDDefeats(Defeats);
        }
    }
}
  1. 在玩家阵亡中,添加失败数逻辑
BlasterGameMode.cpp
    
void ABlasterGameMode::PlayerEliminated(...)
{
    ...
    if(VictimPlayerState)
    {
        VictimPlayerState->AddToDefeats(1);
    }
}
  1. PollInit中初始化
BlasterCharacter.cpp
    
void ABlasterCharacter::PollInit()
{
    ...
        BlasterPlayerState->AddToDefeats(0);
}

bug: 所有指针类型,需要初始化为nullptr

if(Character),判断的可能是NULL,初始化为0,并不一定等于nullptr

所以要么初始化PlayerCharacter* Character = nullptr;

要么加上注解UPROPERTY,也会初始化为nullptr