Cooperative Online Shooter

October 22, 2019

Project Details

  • Platform: PC
  • Genre: Cooperative Third Person Shooter
  • Engine: Unreal Engine 4
  • Development Time: 3 weeks
  • Team Size: Personal project

Concept



A simple online cooperative third-person shooter prototype. This prototype was focused on experimenting with different tools for scripting in Unreal Engine (Angelscript), online networking and gameplay scripting.


UnrealEngine-Angelscript is a set of engine modifications and a plugin for UE4 that integrates a full-featured scripting language. It is actively developed and used by Hazelight, creators of A Way Out.


I wanted to give a try in this tool to learn more about it and also to prove that I can adapt to different toolsets.


I used a simple prototype polygon asset pack to blockout the level.


Roles and Responsibilities

  • Gameplay scripting

    I scripted the entire gameplay from scratch using Angelscript and Blueprints


  • Online Multiplayer

    I set up a dedicated server online multiplayer


  • Level Design

    I designed and blocked out the level



Rapid Prototyping



It took me 3 weeks to build this prototype, during this time I learned how to script world events and characters abilities using Angelsrcipt + Online Networking.



Level Design


The level is inspired by Stockholm subway stations Medborgarplatsen and Södra. I used a simple polygon asset pack to block out the entire level and train wagons. I chose 4 colors to categorize the block out assets.
White: Floors
Yellow: Walls
Blue: Interactable or destroyable
Red: Enemies or explosives





Character and Networking


I decide to develop the character in third-person, it was a good challenge on learning how to replicate not only the character actions but also animations. The character class, character components and weapon were 90% scripted in Angelscript and 10% in blueprints. The game UI was entirely scripted in blueprints.

Special thanks to Emanuel Axelsson for making the UI art assets :)




Character Class



import Character.COOPHealthComponent;
import Weapons.COOPWeapon;
import UI.COOPHUDWidget;

event void FOnDisarm();

class ACoopCharacter : ACharacter
{

    default CapsuleComponent.SetCollisionResponseToChannel(ECollisionChannel::ECC_GameTraceChannel1, ECollisionResponse::ECR_Ignore);

	//Components
    UPROPERTY(DefaultComponent, BlueprintReadOnly, Category = "Components")
    USpringArmComponent SpringArm;
    default SpringArm.bUsePawnControlRotation = true;
    
    UPROPERTY(DefaultComponent, BlueprintReadOnly, Category = "Components", Attach = SpringArm)
    UCameraComponent CharacterCamera;

    UPROPERTY(DefaultComponent, Category = "Components")
    UInputComponent PlayerInputComponent;

    UPROPERTY(DefaultComponent, Category = "Components")
    UCOOPHealthComponent HealthComponent;
    default HealthComponent.SetActive(false);

	UPROPERTY(Replicated)
	FOnDisarm DisarmSignature;

	//Character
	UPROPERTY()
	float WalkingSpeed = 430.0f;

	UPROPERTY()
	float RunningSpeed = 550.0f;

	UPROPERTY(Replicated)
	float NewSpeed;

	UPROPERTY(Replicated, Category = "Character")
	bool bIsCrouch;

	UPROPERTY(ReplicatedUsing=OnRep_PlayerIsDead, Category = "Character")
	bool bIsDead;

	UPROPERTY(Replicated, Category = "Character")
	bool bIsZooming;

	UPROPERTY(Replicated, Category = "Character")
	bool bIsFiring;

	UPROPERTY(Replicated, Category = "Character")
	float CharacterDeathLifeSpan = 3.0f;

	UPROPERTY(Replicated, Category = "Character")
	bool bIsReloading;

	//Weapon
    UPROPERTY(Replicated, AdvancedDisplay, Category = "Weapons")
    TSubclassOf MyWeaponClass;

    UPROPERTY(Replicated, Category = "Weapons")
    ACoopWeapon MyWeaponReference;

    UPROPERTY(Category = "Weapons")
    TSubclassOf FireCamShake;

    UPROPERTY(Category = "Weapons")
    bool bShakeCamera;

	UPROPERTY(Replicated)
	float AimYaw;

	UPROPERTY(Replicated)
	float AimPitch;

	UPROPERTY(Replicated)
	bool bCanDisarm = false;

	UPROPERTY()
	TSubclassOf CrosshairRef;

	UPROPERTY()
	TSubclassOf HudRef;

	UPROPERTY(DefaultComponent)
	UWidgetComponent PlayerHUD;

	TSubclassOf MyDamageType;

	default bUseControllerRotationYaw = false;
	default SetReplicates(true);
	default bReplicateMovement = true;


    UFUNCTION(BlueprintOverride)
    void BeginPlay()
    {
        BindInput();
		HealthComponent.OnDealDamageSignature.AddUFunction(this, n"DamageImpactAnimation");
		if(LocalRole == ENetRole::ROLE_Authority)
		{
			bIsDead = false;
			bIsReloading = false;
			HealthComponent.OnDeadSignature.AddUFunction(this, n"OnPlayerDeath");
			SpawnWeapon(MyWeaponClass);
			NewSpeed = WalkingSpeed;
		}
    }

	UFUNCTION(BlueprintOverride)
	void Tick(float DeltaSeconds)
	{
		CharacterMovement.MaxWalkSpeed = FMath::FInterpTo(CharacterMovement.MaxWalkSpeed, NewSpeed, DeltaSeconds, 5.0f);
		if(LocalRole == ENetRole::ROLE_Authority)
		{
			UpdateYaw();
		}
	}

	UFUNCTION(Server)
	void UpdateYaw()
	{
		AimYaw = FMath::ClampAngle(FRotator(FRotator(GetControlRotation() - GetActorRotation()).Normalized).Yaw, -90.0f, 90.0f);
		AimPitch = FMath::ClampAngle(FRotator(FRotator(GetControlRotation() - GetActorRotation()).Normalized).Pitch, -90.0f, 90.0f);
	}

    UFUNCTION()
    void BindInput()
    {
        if(PlayerInputComponent != nullptr)
        {           
            PlayerInputComponent.BindAxis(n"GoForward", FInputAxisHandlerDynamicSignature(this, n"MoveForward"));
            PlayerInputComponent.BindAxis(n"GoRight", FInputAxisHandlerDynamicSignature(this, n"MoveRight"));
            PlayerInputComponent.BindAxis(n"MousePitch", FInputAxisHandlerDynamicSignature(this, n"LookUp"));
            PlayerInputComponent.BindAxis(n"MouseYaw", FInputAxisHandlerDynamicSignature(this, n"Turn"));

            PlayerInputComponent.BindAction(n"Sprint", EInputEvent::IE_Pressed, FInputActionHandlerDynamicSignature(this, n"BeginSprint"));
            PlayerInputComponent.BindAction(n"Sprint", EInputEvent::IE_Released, FInputActionHandlerDynamicSignature(this, n"EndSprint"));

            PlayerInputComponent.BindAction(n"Crouch", EInputEvent::IE_Pressed, FInputActionHandlerDynamicSignature(this, n"BeginCrouch"));
            PlayerInputComponent.BindAction(n"Crouch", EInputEvent::IE_Released, FInputActionHandlerDynamicSignature(this, n"EndCrouch"));

            PlayerInputComponent.BindAction(n"Jump", EInputEvent::IE_Released, FInputActionHandlerDynamicSignature(this, n"BeginJump"));

			PlayerInputComponent.BindAction(n"Disarm", EInputEvent::IE_Pressed, FInputActionHandlerDynamicSignature(this, n"Disarm"));

			PlayerInputComponent.BindAction(n"Reload", EInputEvent::IE_Pressed, FInputActionHandlerDynamicSignature(this, n"StartReloadWeapon"));

            PlayerInputComponent.BindAction(n"Fire", EInputEvent::IE_Pressed, FInputActionHandlerDynamicSignature(this, n"StartTrigger"));
            PlayerInputComponent.BindAction(n"Fire", EInputEvent::IE_Released, FInputActionHandlerDynamicSignature(this, n"EndTrigger"));   
        }
    }

	/*
	 * Input Binds
	 */

    UFUNCTION()
    void MoveForward(float AxisValue)
    {
        AddMovementInput(GetActorForwardVector() * AxisValue);
        GetMovementComponent().NavAgentProps.bCanCrouch = true;
    }

    UFUNCTION()
    void MoveRight(float Value)
    {
        AddMovementInput(GetActorRightVector() * Value);
    }

    UFUNCTION()
    void LookUp(float Value)
    {
        AddControllerPitchInput(-Value);
    }

    UFUNCTION()
    void Turn(float Value)
    {
        AddControllerYawInput(Value);
    }

    UFUNCTION()
    void BeginCrouch(FKey Key)
    {
		Crouch();
		if(LocalRole < ENetRole::ROLE_Authority)
		{
			ServerCrouch(true);
		}
    }

    UFUNCTION()
    void EndCrouch(FKey Key)
    {
		UnCrouch();
		if(LocalRole < ENetRole::ROLE_Authority)
		{
			ServerCrouch(false);
		}
    }

    UFUNCTION()
    void BeginSprint(FKey Key)
    {
		
		if(bIsCrouched == false)
		{
			ServerSprint(RunningSpeed);
		}
		else
		{
			ServerSprint(WalkingSpeed);
		}
    }

    UFUNCTION()
    void EndSprint(FKey Key)
    {
		if(LocalRole < ENetRole::ROLE_Authority)
		{
			ServerSprint(WalkingSpeed);
		}
    }

    UFUNCTION()
    void BeginJump(FKey Key)
    {
        if(!bWasJumping)
        {
            Jump();
        }
    }

    UFUNCTION()
    void StartTrigger(FKey Key)
    {
        MyWeaponReference.StartFire();
        return;
    }

    UFUNCTION(BlueprintEvent)
    void EndTrigger(FKey Key)
    {
        MyWeaponReference.EndFire();
		bIsFiring = false;
        return;
    }

	UFUNCTION(BlueprintEvent)
	void StartReloadWeapon(FKey Key)
	{
		if(LocalRole < ENetRole::ROLE_Authority)
		{
			ServerReload();
		}

		if(!bIsReloading && !MyWeaponReference.CanReload())
		{
			MyWeaponReference.bIsCurrentReloading = true;
			bIsReloading = true;
		}
	}

	UFUNCTION()
	void Disarm(FKey Key)
	{
		
		if(LocalRole < ENetRole::ROLE_Authority)
		{
			ServerDisarm();
			return;
		}

		if(bCanDisarm)
		{
			P();
			DisarmSignature.Broadcast();
		}

	}

    UFUNCTION(BlueprintEvent)
    void OnPlayerDeath()
    {
        if(!bIsDead)
		{
			//DEBUG
			if(CVar_DebugWeaponDrawing.GetInt() > 0)
			{
				Print("I'm dead", 1.0f, FLinearColor::LucBlue);
			}

			if(LocalRole == ENetRole::ROLE_Authority)
			{
				bIsDead = true;
			}


			//Detach Weapon
			if(MyWeaponReference != nullptr)
			{
				MyWeaponReference.ServerDetachWeapon();
				MyWeaponReference.FireSignature.Clear();
				MyWeaponReference = nullptr;
			}
			
			//Disable Input + Destroy Actor			
			DetachFromControllerPendingDestroy();
			SetLifeSpan(CharacterDeathLifeSpan);
		}
        return;
    }

	/*
	 * Weapon
	 */

    UFUNCTION()
    void SpawnWeapon(TSubclassOf WeaponClass)
    {
		if(WeaponClass.IsValid())
		{
			AActor MySpawnedWeapon = SpawnActor(WeaponClass, FVector::ZeroVector, FRotator::ZeroRotator);
			MySpawnedWeapon.AttachToComponent(Mesh, n"WeaponSocket", EAttachmentRule::SnapToTarget);
			MySpawnedWeapon.SetOwner(this);
			MyWeaponReference = Cast(MySpawnedWeapon);
			MyWeaponReference.GetOwnerProperties(CharacterCamera);
			MyWeaponReference.FireSignature.AddUFunction(this, n"WeaponFired");
			MyWeaponReference.MagazineSignature.AddUFunction(this, n"StartReloadWeapon");
		}
    }

	UFUNCTION(BlueprintEvent)
    void WeaponFired(ACharacter OwnerCharacter)
    {

		if(bShakeCamera == true)
        {        
            APlayerController PC = Cast(OwnerCharacter.GetController());
            if(PC != nullptr)
            {
                PC.ClientPlayCameraShake(FireCamShake);
            }
            else
            {
                Print("Camera will not shake. Player Controller is NULL", 2.0f);
            }
        }
		bIsFiring = true;
		ServerFiringAnimation();
    }

	UFUNCTION(BlueprintEvent)
	void FinishReloadWeapon()
	{
		MyWeaponReference.ReloadClip();
		MyWeaponReference.bIsCurrentReloading = false;
		bIsReloading = false;
	}

	/*
	 * Damage
	 */

	UFUNCTION(BlueprintOverride)
	void RadialDamage(float DamageReceived, const UDamageType DamageType, FVector Origin, FHitResult HitInfo, AController InstigatedBy, AActor DamageCauser)
	{
		//DEBUG
		if(CVar_DebugWeaponDrawing.GetInt() > 0)
        {
			System::DrawDebugString(Origin, FString("Radial Damage: " + DamageReceived) , nullptr, FLinearColor::White, 1.0f);
		}
	}

	UFUNCTION(BlueprintOverride)
	void PointDamage(float DamageReceived, const UDamageType DamageType, FVector HitLocation, FVector HitNormal, UPrimitiveComponent HitComponent, FName BoneName, FVector ShotFromDirection, AController InstigatedBy, AActor DamageCauser, FHitResult HitInfo)
	{
		//DEBUG
		if(CVar_DebugWeaponDrawing.GetInt() > 0)
        {
			System::DrawDebugString(HitLocation, FString("PointDamage: " + DamageReceived) , nullptr, FLinearColor::White, 1.0f);
		}

		Gameplay::ApplyDamage(this, DamageReceived, InstigatedBy, DamageCauser, MyDamageType);
	}

	UFUNCTION(BlueprintEvent)
	void DamageImpactAnimation(float Damage, float Health, const UDamageType DamageType, AController InstigatedBy, AActor DamageCauser)
	{
		return;
	}

	/*
	 * Networking 
	 */

	UFUNCTION(Server)
	void ServerSprint(float Value)
	{
		NewSpeed = Value;
	}

	UFUNCTION(Server)
	void ServerCrouch(bool Crouching)
	{
		if(Crouching)
		{
			bIsCrouch = true;
		}
		else
		{
			bIsCrouch = false;
		}
	}

	UFUNCTION(Server)
	void ServerReload()
	{
		FKey Key;
		StartReloadWeapon(Key);
	}

	UFUNCTION(Server)
	void ServerFiringAnimation()
	{
		PlayFiringAnimation();
	}

	UFUNCTION(NetMulticast, BlueprintEvent)
	void PlayFiringAnimation()
	{
		return;
	}

	UFUNCTION()
	void OnRep_PlayerIsDead()
	{
		RemovePlayerWidgets();
	}

	UFUNCTION(BlueprintEvent)
	void RemovePlayerWidgets()
	{
		return;
	}

	UFUNCTION(Server)
	void ServerDisarm()
	{
		FKey Key;
		Disarm(Key);
	}
}

Health Component



import Core.COOPStatics;

event void FOnDealDamage(float Damage, float Health, const UDamageType DamageType, AController InstigatedBy, AActor DamageCauser);
event void FOnDead();

class UCOOPHealthComponent : UActorComponent
{

    UPROPERTY(ReplicatedUsing=OnRep_Health, BlueprintReadOnly)
    float Health;

    UPROPERTY()
    float MaxHealth = 500.0f;

	UPROPERTY(Replicated)
	bool bIsDead;

    UPROPERTY()
    FOnDealDamage OnDealDamageSignature;

    UPROPERTY()
    FOnDead OnDeadSignature;

	default SetIsReplicated(true);


    UFUNCTION(BlueprintOverride)
    void BeginPlay()
    {
		if(GetOwner().LocalRole == ENetRole::ROLE_Authority)
		{
			AActor MyOwner = GetOwner();
			if(MyOwner != nullptr)
			{
				MyOwner.OnTakeAnyDamage.AddUFunction(this, n"DealDamage");
			}
		}
        Health = MaxHealth;
		bIsDead = false;      
    }

    UFUNCTION()
    void DealDamage(AActor DamagedActor, float Damage, const UDamageType DamageType, AController InstigatedBy, AActor DamageCauser)
    {
		Health = Health - Damage;
		if(Health > 0 && !bIsDead)
        {
            OnDealDamageSignature.Broadcast(Damage, Health, DamageType, InstigatedBy, DamageCauser);
			
			//DEBUG
			if(CVar_DebugHealthComponents.GetInt() > 0)
			{
				Print(FString(DamagedActor.GetName() + " Damage Taken: " + Damage), 1.0f, FLinearColor::Yellow);
			}
		}
        else if(!bIsDead)
        {
            //DEBUG
			if(CVar_DebugHealthComponents.GetInt() > 0)
			{
				Print(FString(DamagedActor.GetName() + " HealthComponent: Health is below 0"), 1.0f, FLinearColor::Yellow);
			}
			bIsDead = true;
			OnDeadSignature.Broadcast();
        }
    }

	UFUNCTION()
	void OnRep_Health(float OldHealth)
	{
		float Damage = Health - OldHealth;
		const UDamageType DamageType;
		OnDealDamageSignature.Broadcast(Damage, Health, DamageType, GetOwner().GetInstigatorController(), GetOwner());
	}
}





Gameplay and Mission


Players start the level in station A and the objective is to reach station B and deactivate two computers. In this level a developed a loopable subway system, allowing players to travel between stations.

To add more action while traveling we trigger a world event spawning another train convoy full of enemies. The train wagons follow a spline path and wiggle using a simple cosine math function.