Merge branch 'feature/Camera_System' into develop

This commit is contained in:
Kevin Poretti 2022-01-17 14:59:56 -05:00
commit 69c000de6f
19 changed files with 470 additions and 63 deletions

1
.gitignore vendored
View File

@ -68,3 +68,4 @@ Build/*/**
# Cache files for the editor to use # Cache files for the editor to use
**/DerivedDataCache/* **/DerivedDataCache/*
Scratch

View File

@ -1,7 +1,7 @@
[/Script/EngineSettings.GameMapsSettings] [/Script/EngineSettings.GameMapsSettings]
EditorStartupMap=/Game/Maps/TestMap.TestMap EditorStartupMap=/Game/Maps/DebugStage.DebugStage
LocalMapOptions= LocalMapOptions=
TransitionMap= TransitionMap=
bUseSplitscreen=True bUseSplitscreen=True

BIN
KOFForever/Content/Core/BP_DefaultCamera.uasset (Stored with Git LFS) Normal file

Binary file not shown.

Binary file not shown.

BIN
KOFForever/Content/Debug/BP_DebugGameMode.uasset (Stored with Git LFS) Normal file

Binary file not shown.

BIN
KOFForever/Content/Maps/DebugStage.umap (Stored with Git LFS) Normal file

Binary file not shown.

BIN
KOFForever/Content/Maps/TestMap.umap (Stored with Git LFS)

Binary file not shown.

BIN
KOFForever/Content/Maps/TrainingStage.umap (Stored with Git LFS) Normal file

Binary file not shown.

View File

@ -9,7 +9,9 @@
"Type": "Runtime", "Type": "Runtime",
"LoadingPhase": "Default", "LoadingPhase": "Default",
"AdditionalDependencies": [ "AdditionalDependencies": [
"Paper2D" "Paper2D",
"Engine",
"CoreUObject"
] ]
} }
] ]

View File

@ -0,0 +1,98 @@
#include "Camera/KOFDefaultCamera.h"
#include "DrawDebugHelpers.h"
#include "Camera/CameraComponent.h"
#include "Character/KOFBaseCharacter.h"
#include "GameModes/KOFDefaultGameMode.h"
#include "Kismet/GameplayStatics.h"
AKOFDefaultCamera::AKOFDefaultCamera()
{
PrimaryActorTick.bCanEverTick = true;
MinFOV = 80.0f;
MaxFOV = 85.0f;
GetCameraComponent()->SetFieldOfView(MinFOV);
HeightOffset = 60.0f;
ZAveragingFactor = 2.0f;
MovementYDistanceThreshold = 500.0f;
}
void AKOFDefaultCamera::Tick(float DeltaSeconds)
{
Super::Tick(DeltaSeconds);
// NOTE(kevin): This will only return the game mode if we are the server. Since this game is local only we should always
// be both server and client
AKOFDefaultGameMode* GameMode = Cast<AKOFDefaultGameMode>(GetWorld()->GetAuthGameMode());
if(GameMode)
{
AKOFBaseCharacter* P1 = GameMode->GetP1CurrentCharacter();
AKOFBaseCharacter* P2 = GameMode->GetP2CurrentCharacter();
check(P1);
check(P2);
FVector P1Loc = P1->GetActorLocation();
FVector P2Loc = P2->GetActorLocation();
FVector CameraLoc = GetActorLocation();
float HalfStageLength = GameMode->GetStageLength() / 2.0f;
FVector MidPoint = FVector(CameraLoc.X, FMath::Clamp((P1Loc.Y + P2Loc.Y) / 2.0f, -HalfStageLength, HalfStageLength), ((P1Loc.Z + P2Loc.Z) / ZAveragingFactor) + HeightOffset);
SetActorLocation(MidPoint);
// set FOV
}
}
void AKOFDefaultCamera::UpdateCamera()
{
// NOTE(kevin): This will only return the game mode if we are the server. Since this game is local only we should always
// be both server and client
AKOFDefaultGameMode* GameMode = Cast<AKOFDefaultGameMode>(GetWorld()->GetAuthGameMode());
if(GameMode)
{
AKOFBaseCharacter* P1 = GameMode->GetP1CurrentCharacter();
AKOFBaseCharacter* P2 = GameMode->GetP2CurrentCharacter();
FVector P1Loc = P1->GetActorLocation();
FVector P2Loc = P2->GetActorLocation();
FVector CameraLoc = GetActorLocation();
float HalfMoveYThreshold = MovementYDistanceThreshold / 2.0f;
float YDistance = FMath::Abs(P1Loc.Y - P2Loc.Y);
/**
* If one of the characters starts to move far enough to either side of the camera (determined by Movement Y distance threshold),
* then start pushing the camera in that same direction
*/
/*
if((P1Loc.Y < CameraLoc.Y - HalfMoveYThreshold || P2Loc.Y < CameraLoc.Y - HalfMoveYThreshold) ||
(P1Loc.Y > CameraLoc.Y + HalfMoveYThreshold || P2Loc.Y > CameraLoc.Y + HalfMoveYThreshold))
{
//FVector MidPoint = FVector(CameraLoc.X, (P1Loc.Y + P2Loc.Y) / 2.0f, ((P1Loc.Z + P2Loc.Z) / ZAveragingFactor) + HeightOffset);
FVector MidPoint = FVector(CameraLoc.X, (P1Loc.Y + P2Loc.Y) / 2.0f, CameraLoc.Z);
SetActorLocation(MidPoint);
}
*/
// leftest = MaxF(float32(Min(s.stage.p[0].startx, s.stage.p[1].startx))*s.stage.localscl,-(float32(s.gameWidth)/2)/s.cam.BaseScale()+s.screenleft) - ox
// rightest = MinF(float32(Max(s.stage.p[0].startx, s.stage.p[1].startx))*s.stage.localscl, (float32(s.gameWidth)/2)/s.cam.BaseScale()-s.screenright) - ox
float MoveRightVal = P1Loc.Y > CameraLoc.Y + HalfMoveYThreshold ? P1Loc.Y - (CameraLoc.Y + HalfMoveYThreshold) : 0.0f;
float MoveLeftVal = P1Loc.Y < CameraLoc.Y - HalfMoveYThreshold ? P1Loc.Y - (CameraLoc.Y - HalfMoveYThreshold) : 0.0f;
FVector NewLocation = FVector(CameraLoc.X, (CameraLoc.Y + MoveRightVal - MoveLeftVal), CameraLoc.Z);
}
}

View File

@ -8,45 +8,31 @@
#include "GameFramework/CharacterMovementComponent.h" #include "GameFramework/CharacterMovementComponent.h"
#include "GameFramework/SpringArmComponent.h" #include "GameFramework/SpringArmComponent.h"
#include "PaperFlipbookComponent.h" #include "PaperFlipbookComponent.h"
#include "Componenets/KOFCharacterMovementComponent.h"
#include "GameModes/KOFDefaultGameMode.h"
DEFINE_LOG_CATEGORY_STATIC(SideScrollerCharacter, Log, All); DEFINE_LOG_CATEGORY_STATIC(SideScrollerCharacter, Log, All);
////////////////////////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////////////////
// ASideScroller2DCharacter // ASideScroller2DCharacter
AKOFBaseCharacter::AKOFBaseCharacter() AKOFBaseCharacter::AKOFBaseCharacter(const FObjectInitializer& ObjectInitializer)
: Super(ObjectInitializer.SetDefaultSubobjectClass<UKOFCharacterMovementComponent>(ACharacter::CharacterMovementComponentName))
{ {
// Use only Yaw from the controller and ignore the rest of the rotation. // Use only Yaw from the controller and ignore the rest of the rotation.
bUseControllerRotationPitch = false; bUseControllerRotationPitch = false;
bUseControllerRotationYaw = true; bUseControllerRotationYaw = false;
bUseControllerRotationRoll = false; bUseControllerRotationRoll = false;
// Set the size of our collision capsule. // Set the size of our collision capsule.
GetCapsuleComponent()->SetCapsuleHalfHeight(96.0f); GetCapsuleComponent()->SetCapsuleHalfHeight(96.0f);
GetCapsuleComponent()->SetCapsuleRadius(40.0f); GetCapsuleComponent()->SetCapsuleRadius(40.0f);
// Create a camera boom attached to the root (capsule)
CameraBoom = CreateDefaultSubobject<USpringArmComponent>(TEXT("CameraBoom"));
CameraBoom->SetupAttachment(RootComponent);
CameraBoom->TargetArmLength = 500.0f;
CameraBoom->SocketOffset = FVector(0.0f, 0.0f, 0.0f);
CameraBoom->bDoCollisionTest = false;
CameraBoom->SetRelativeRotation(FRotator(0.0f, 180.f, 0.0f));
// Create an orthographic camera (no perspective) and attach it to the boom
SideViewCameraComponent = CreateDefaultSubobject<UCameraComponent>(TEXT("SideViewCamera"));
SideViewCameraComponent->SetupAttachment(CameraBoom, USpringArmComponent::SocketName);
// Prevent all automatic rotation behavior on the camera, character, and camera component
CameraBoom->SetUsingAbsoluteRotation(true);
SideViewCameraComponent->bUsePawnControlRotation = false;
SideViewCameraComponent->bAutoActivate = true;
GetCharacterMovement()->bOrientRotationToMovement = false; GetCharacterMovement()->bOrientRotationToMovement = false;
// Configure character movement // Configure character movement
GetCharacterMovement()->GravityScale = 2.0f; GetCharacterMovement()->GravityScale = 2.0f;
GetCharacterMovement()->AirControl = 0.80f; GetCharacterMovement()->AirControl = 0.0f;
GetCharacterMovement()->JumpZVelocity = 1000.f; GetCharacterMovement()->JumpZVelocity = 1000.f;
GetCharacterMovement()->GroundFriction = 3.0f; GetCharacterMovement()->GroundFriction = 3.0f;
GetCharacterMovement()->MaxWalkSpeed = 600.0f; GetCharacterMovement()->MaxWalkSpeed = 600.0f;
@ -102,11 +88,11 @@ void AKOFBaseCharacter::UpdateAnimation()
UPaperFlipbook* DesiredAnimation = IdleAnimation; UPaperFlipbook* DesiredAnimation = IdleAnimation;
if(PlayerSpeedSqr > 0.0f) if(PlayerSpeedSqr > 0.0f)
{ {
if (TravelDirection < 0.0f) if (TravelDirection > 0.0f)
{ {
DesiredAnimation = WalkFwdAnimation; DesiredAnimation = WalkFwdAnimation;
} }
else if (TravelDirection > 0.0f) else if (TravelDirection < 0.0f)
{ {
DesiredAnimation = WalkBackAnimation; DesiredAnimation = WalkBackAnimation;
} }
@ -167,31 +153,14 @@ void AKOFBaseCharacter::SetupPlayerInputComponent(class UInputComponent* PlayerI
void AKOFBaseCharacter::MoveRight(float Value) void AKOFBaseCharacter::MoveRight(float Value)
{ {
/*UpdateChar();*/ /*UpdateChar();*/
// Apply the input to the character motion // Apply the input to the character motion
AddMovementInput(FVector(0.0f, -1.0f, 0.0f), Value); AddMovementInput(FVector(0.0f, 1.0f, 0.0f), Value);
} }
void AKOFBaseCharacter::UpdateCharacter() void AKOFBaseCharacter::UpdateCharacter()
{ {
// Update animation to match the motion // Update animation to match the motion
UpdateAnimation(); UpdateAnimation();
}
/*
// Now setup the rotation of the controller based on the direction we are travelling
const FVector PlayerVelocity = GetVelocity();
float TravelDirection = PlayerVelocity.Y;
// Set the rotation so that the character faces his direction of travel.
if (Controller != nullptr)
{
if (TravelDirection < 0.0f)
{
Controller->SetControlRotation(FRotator(0.0, -90.0f, 0.0f));
}
else if (TravelDirection > 0.0f)
{
Controller->SetControlRotation(FRotator(0.0f, 90.0f, 0.0));
}
}
*/
}

View File

@ -0,0 +1,50 @@
#include "Componenets/KOFCharacterMovementComponent.h"
#include "GameModes/KOFDefaultGameMode.h"
UKOFCharacterMovementComponent::UKOFCharacterMovementComponent()
{
PrimaryComponentTick.bCanEverTick = true;
}
void UKOFCharacterMovementComponent::TickComponent(float DeltaTime, ELevelTick TickType,
FActorComponentTickFunction* ThisTickFunction)
{
Super::TickComponent(DeltaTime, TickType, ThisTickFunction);
AKOFBaseCharacter* Owner = Cast<AKOFBaseCharacter>(GetOwner());
if(!Owner)
{
UE_LOG(LogTemp, Error, TEXT("AKOFCharacterMovementComponent::PostPhysicsTickComponent - could not get the owner"));
return;
}
AKOFDefaultGameMode* GameMode = Cast<AKOFDefaultGameMode>(GetWorld()->GetAuthGameMode());
if(!GameMode)
{
UE_LOG(LogTemp, Error, TEXT("AKOFCharacterMovementComponent::PostPhysicsTickComponent - could not get the game mode"));
return;
}
AKOFBaseCharacter* Opponent = GameMode->GetCurrentOpponent(Owner->IsPossessedByPlayer1());
if(Opponent)
{
float HalfYDistance = GameMode->GetMaxAllowedDistanceFromOpponent() / 2.0f;
float YMidpoint = (Owner->GetActorLocation().Y + Opponent->GetActorLocation().Y) / 2.0f;
float AdjustedY = FMath::Clamp(Owner->GetActorLocation().Y, YMidpoint - HalfYDistance, YMidpoint + HalfYDistance);
FVector CurrentLoc = Owner->GetActorLocation();
Owner->SetActorLocation(FVector(CurrentLoc.X, AdjustedY, CurrentLoc.Z));
}
// clamp to stage bounds
FVector CurrentLoc = Owner->GetActorLocation();
float HalfStageLength = GameMode->GetStageLength() / 2.0f;
Owner->SetActorLocation(FVector(CurrentLoc.X,
FMath::Clamp(CurrentLoc.Y, -HalfStageLength, HalfStageLength),
CurrentLoc.Z));
}

View File

@ -0,0 +1,66 @@
#include "GameModes/KOFDefaultGameMode.h"
#include "Kismet/GameplayStatics.h"
AKOFDefaultGameMode::AKOFDefaultGameMode()
{
NumCharactersPerTeam = 3;
MaxAllowedDistanceFromOpponent = 700.0f;
StageLength = 2400.0f;
StartY = 250.0f;
}
void AKOFDefaultGameMode::InitTeams()
{
for (TSubclassOf<AKOFBaseCharacter> CharacterTemplate : P1TeamTemplate)
{
AKOFBaseCharacter* Temp = GetWorld()->SpawnActor<AKOFBaseCharacter>(CharacterTemplate, FVector(0.0f, -StartY, 204.6241f), FRotator(0.0f, 90.0f, 0.0f));
Temp->SetIsPossesedByPlayer1(true);
if(Temp)
{
P1Team.Add(Temp);
}
}
for (TSubclassOf<AKOFBaseCharacter> CharacterTemplate : P2TeamTemplate)
{
AKOFBaseCharacter* Temp = GetWorld()->SpawnActor<AKOFBaseCharacter>(CharacterTemplate, FVector(0.0f, StartY, 204.6241f), FRotator(0.0f, -90.0f, 0.0f));
Temp->SetIsPossesedByPlayer1(false);
if(Temp)
{
P2Team.Add(Temp);
}
}
}
void AKOFDefaultGameMode::StartPlay()
{
Super::StartPlay();
InitTeams();
// is this the right place to do this?
APlayerController* PC1 = UGameplayStatics::GetPlayerController(GetWorld(), 0);
if(PC1)
{
AKOFBaseCharacter* P1 = GetP1CurrentCharacter();
if(P1)
{
PC1->Possess(P1);
}
}
APlayerController* PC2 = UGameplayStatics::GetPlayerController(GetWorld(), 1);
if(PC2)
{
AKOFBaseCharacter* P2 = GetP1CurrentCharacter();
if(P2)
{
PC2->Possess(P2);
}
}
}

View File

@ -0,0 +1,23 @@
#include "GameModes/KOFTeam.h"
#include "Character/KOFBaseCharacter.h"
AKOFBaseCharacter* UKOFTeam::GetCurrentCharacter()
{
return Characters.Num() > 0 ? Characters[0] : nullptr;
}
void UKOFTeam::InitTeam()
{
for (TSubclassOf<AKOFBaseCharacter> CharacterTemplate : CharacterTemplates)
{
AKOFBaseCharacter* Temp = NewObject<AKOFBaseCharacter>(CharacterTemplate);
if(Temp)
{
Characters.Add(Temp);
}
}
}

View File

@ -0,0 +1,53 @@
#pragma once
#include "CoreMinimal.h"
#include "Camera/CameraActor.h"
#include "KOFDefaultCamera.generated.h"
/**
*
*/
UCLASS()
class KOFFOREVER_API AKOFDefaultCamera : public ACameraActor
{
GENERATED_BODY()
public:
AKOFDefaultCamera();
virtual void Tick(float DeltaSeconds) override;
protected:
/**
* Min FOV camera will zoom in to as character's walk farther apart from each other.
*
*
*/
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = CameraSettings, meta = (UIMin = "5.0", UIMax = "170", ClampMin = "0.001", ClampMax = "360.0", Units = deg))
float MinFOV;
/** Max FOV camera will zoom out to as character's walk farther apart from each other */
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = CameraSettings, meta = (UIMin = "5.0", UIMax = "170", ClampMin = "0.001", ClampMax = "360.0", Units = deg))
float MaxFOV;
/** Offset added to the camera's Z value after performing the character mid point calculation */
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = CameraSettings)
float HeightOffset;
/**
* Value used when averaging both character's Z positions to find the camera's final Z position.
*
* A value of 2.0 will set the camera's Z position to the midpoint between both character's Z positions.
* A value less than 2.0 will keep the camera closer to the ground even as another player jumps.
*/
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = CameraSettings)
float ZAveragingFactor;
/**
* How far the character's have to be apart before the camera moves to keep both in frame
*/
float MovementYDistanceThreshold;
void UpdateCamera();
};

View File

@ -21,14 +21,6 @@ class KOFFOREVER_API AKOFBaseCharacter : public APaperCharacter
{ {
GENERATED_BODY() GENERATED_BODY()
/** Side view camera */
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category=Camera, meta=(AllowPrivateAccess="true"))
class UCameraComponent* SideViewCameraComponent;
/** Camera boom positioning the camera beside the character */
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = Camera, meta = (AllowPrivateAccess = "true"))
class USpringArmComponent* CameraBoom;
UTextRenderComponent* TextComponent; UTextRenderComponent* TextComponent;
virtual void Tick(float DeltaSeconds) override; virtual void Tick(float DeltaSeconds) override;
@ -59,6 +51,9 @@ protected:
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = Animations) UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = Animations)
class UPaperFlipbook* IdleAnimation; class UPaperFlipbook* IdleAnimation;
UPROPERTY(BlueprintReadOnly)
bool bIsPossesedByPlayer1;
/** Called to choose the correct animation to play based on the character's movement state */ /** Called to choose the correct animation to play based on the character's movement state */
void UpdateAnimation(); void UpdateAnimation();
@ -72,12 +67,21 @@ protected:
// End of APawn interface // End of APawn interface
public: public:
AKOFBaseCharacter(); AKOFBaseCharacter(const FObjectInitializer& ObjectInitializer);
/** Returns SideViewCameraComponent subobject **/
FORCEINLINE class UCameraComponent* GetSideViewCameraComponent() const { return SideViewCameraComponent; }
/** Returns CameraBoom subobject **/
FORCEINLINE class USpringArmComponent* GetCameraBoom() const { return CameraBoom; }
FORCEINLINE class UPaperFlipbookComponent* GetShadow() const { return Shadow; } FORCEINLINE class UPaperFlipbookComponent* GetShadow() const { return Shadow; }
/**
* Returns whether or not this player is possessed by player 1
*
* @returns true is possessed by player 1, false if possessed by player 2
*/
FORCEINLINE bool IsPossessedByPlayer1() const { return bIsPossesedByPlayer1; }
/**
* Sets which player this character is possesed by.
*
* @param bIsPlayer1 true is possessed by player 1, false if possessed by player 2
*/
FORCEINLINE void SetIsPossesedByPlayer1(bool bIsPlayer1) { bIsPossesedByPlayer1 = bIsPlayer1; }
}; };

View File

@ -0,0 +1,20 @@
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/CharacterMovementComponent.h"
#include "KOFCharacterMovementComponent.generated.h"
/**
*
*/
UCLASS()
class KOFFOREVER_API UKOFCharacterMovementComponent : public UCharacterMovementComponent
{
GENERATED_BODY()
UKOFCharacterMovementComponent();
virtual void TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction) override;
};

View File

@ -0,0 +1,71 @@
#pragma once
#include "CoreMinimal.h"
#include "KOFTeam.h"
#include "Character/KOFBaseCharacter.h"
#include "GameFramework/GameModeBase.h"
#include "KOFDefaultGameMode.generated.h"
/**
*
*/
UCLASS()
class KOFFOREVER_API AKOFDefaultGameMode : public AGameModeBase
{
GENERATED_BODY()
public:
AKOFDefaultGameMode();
TArray<AKOFBaseCharacter*>& GetP1Team() { return P1Team; }
TArray<AKOFBaseCharacter*>& GetP2Team() { return P2Team; }
// this will need to change when we actually have real time character switching
AKOFBaseCharacter* GetP1CurrentCharacter() { return P1Team.Num() > 0 ? P1Team[0] : nullptr; }
// this will need to change when we actually have real time character switching
AKOFBaseCharacter* GetP2CurrentCharacter() { return P2Team.Num() > 0 ? P2Team[0] : nullptr; }
AKOFBaseCharacter* GetCurrentOpponent(bool bIsPlayer1) { return bIsPlayer1 ? GetP2CurrentCharacter() : GetP1CurrentCharacter(); }
FORCEINLINE float GetMaxAllowedDistanceFromOpponent() { return MaxAllowedDistanceFromOpponent; }
FORCEINLINE float GetStageLength() { return StageLength; }
protected:
/** Number of characters on a single player's team */
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category="Settings|Team")
int32 NumCharactersPerTeam;
/** Max distance the currently controlled player characters can be from one another */
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category="Settings|Stage")
float MaxAllowedDistanceFromOpponent;
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category="Settings|Stage")
float StageLength;
/** Y offset from origin where character's will be spawned */
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category="Settings|Stage")
float StartY;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Settings|Team")
TArray<TSubclassOf<AKOFBaseCharacter>> P1TeamTemplate;
/** List of references to the player's team members */
UPROPERTY(BlueprintReadOnly)
TArray<AKOFBaseCharacter*> P1Team;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Settings|Team")
TArray<TSubclassOf<AKOFBaseCharacter>> P2TeamTemplate;
/** List of references to the player's team members */
UPROPERTY(BlueprintReadOnly)
TArray<AKOFBaseCharacter*> P2Team;
void InitTeams();
virtual void StartPlay() override;
};

View File

@ -0,0 +1,38 @@
#pragma once
#include "CoreMinimal.h"
#include "UObject/NoExportTypes.h"
#include "KOFTeam.generated.h"
class AKOFBaseCharacter;
/**
*
*/
UCLASS(BlueprintType, Blueprintable)
class KOFFOREVER_API UKOFTeam : public UObject
{
GENERATED_BODY()
public:
/**
* Returns the character on this team that is currently on stage being controlled by the player
*/
AKOFBaseCharacter* GetCurrentCharacter();
void InitTeam();
protected:
/**
* List of character templates to spawn the player's team when the match begins.
*
* This will eventually get set by the player's choices in the character select menu
*/
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Characters")
TArray<TSubclassOf<AKOFBaseCharacter>> CharacterTemplates;
/** List of references to the player's team members */
UPROPERTY(BlueprintReadOnly, Category="Characters")
TArray<AKOFBaseCharacter*> Characters;
};