PHASMOPHOBIA CLONE

TYPE

Game clone


ROLES

Scripter & Level Design

 

YEAR

2024

 

TIME

~2 months

 

ENGINE

Unreal Engine 5

As a side project, me and a friend decided to try and recreate the game Phasmophobia in Unreal Engine 5 using Angelscript. We played this game together a lot, and wanted to try our hands at making some of the mechanics. Since this game is originally built in Unity, we wanted to attempt to do it in Unreal Engine.


While my friend focused on the AI and the ghost, I spent most of the time working on the different gadgets used in the game, the UI, and the audio.


Here are some examples of the gadgets I made.


PHOTO CAMERA

One of the gadgets in the game is a photo camera, with which you can take photos of interactions and even snap a photo of the ghost. You earn money from the quality of the photos you take.



class APhotoCamera : AGadget
{
    UPROPERTY(DefaultComponent)
    USceneCaptureComponent2D SceneCaptureComponent;
    default SceneCaptureComponent.CaptureSource = ESceneCaptureSource::SCS_FinalColorLDR;
    default SceneCaptureComponent.bCaptureEveryFrame = false;

    UPROPERTY(DefaultComponent)
    USpotLightComponent Flash;

    default Flash.AttenuationRadius = 500;

    UPROPERTY(DefaultComponent)
    UAudioComponent UseSound;

    default UseSound.AutoActivate = false;

    UPROPERTY()
    int ResolutionX = 376;

    UPROPERTY()
    int ResolutionY = 221;

    UPROPERTY(ReplicatedUsing = OnRep_bIsActive)
    bool bIsActive;

    UPROPERTY()
    bool bUsing;

    UPROPERTY()
    float CooldownTime;

    private UAlbumSubsystem AlbumSubsystem;

    UFUNCTION(BlueprintOverride)
    void BeginPlay()
    {
        Super::BeginPlay();
        AlbumSubsystem = UAlbumSubsystem::Get();
    }

    UFUNCTION()
    void OnSecondaryInteractEnd(AActor InteractingActor) override
    {
        Super::OnSecondaryInteractEnd(InteractingActor);
        if(bIsActive && !bUsing)
        {
            bUsing = true;
            System::SetTimer(this, n"ResetUse", CooldownTime, false);
            ClientSnapPicture();
        }
    }

    UFUNCTION(NetMulticast)
    private void ClientSnapPicture()
    {
        if (System::IsServer())
        {
            return;
        }

        ActivateFlash();
        float Percentage = 0;
        APhotoEvidence Evidence;
        EPhotoEvidenceType PhotoEvidenceType = EPhotoEvidenceType::Interaction;

        GetClosestPhotoEvidencePercentage(Percentage, Evidence);

        if(Evidence != nullptr)
        {
            PhotoEvidenceType = Evidence.PhotoEvidenceType;
        }

        SceneCaptureComponent.TextureTarget = Rendering::CreateRenderTarget2D(ResolutionX, ResolutionY);     
        SceneCaptureComponent.CaptureScene();

        AlbumSubsystem.BroadcastPictureTaken(SceneCaptureComponent.TextureTarget, Percentage, PhotoEvidenceType);

        SceneCaptureComponent.TextureTarget = nullptr;
        
        System::SetTimer(this, n"DeactivateFlash", 0.1f, false);

        PlaySound();
    }

    UFUNCTION(BlueprintEvent)
    void GetClosestPhotoEvidencePercentage(float& OutPercentage, APhotoEvidence& OutEvidence) const
    {
        TArray PhotoEvidences;
        GetAllActorsOfClass(PhotoEvidences);

        APhotoEvidence ClosestEvidence = nullptr;
        float ClosestDist = MAX_flt;
        FVector2D ClosestScreenLocation;
        for(auto Evidence : PhotoEvidences)
        {
            if(Evidence.bPhotographed)
            {
                continue;
            }

            FVector2D ThisScreenLocation;
            bool bThisVisible = false;

            GetIsVisible(Evidence.GetActorLocation(), ThisScreenLocation, bThisVisible);
            
            if(bThisVisible)
            {
                TArray ActorsToIgnore;
                ActorsToIgnore.Add(Evidence.SpawningActor);
                ActorsToIgnore.Add(Owner);
                ActorsToIgnore.Add(Evidence);
                FHitResult HitResult;
                if(!System::LineTraceSingle(Evidence.GetActorLocation(), GetActorLocation(), ETraceTypeQuery::Visibility, false, ActorsToIgnore, EDrawDebugTrace::None, HitResult, true))
                {
                    float Dist = ActorLocation.Distance(Evidence.ActorLocation);
                    if (Dist < ClosestDist)
                    {
                        ClosestDist = Dist;
                        ClosestEvidence = Evidence;
                        ClosestScreenLocation = ThisScreenLocation;
                    }
                }   
            }
        }

        OutEvidence = ClosestEvidence;

        if (ClosestEvidence != nullptr)
        {
            ClosestEvidence.bPhotographed = true;
            OutPercentage = CalculatePercentage(ClosestScreenLocation);
        }
        else
        {
            OutPercentage = 0.0f;
        }
    }

    float CalculatePercentage(FVector2D ScreenLocation) const
    {
        float WidgetX = WidgetLayout::GetViewportSize().X;
        float WidgetY = WidgetLayout::GetViewportSize().Y;

        float CenterX = WidgetX / 2;
        float CenterY = WidgetY / 2; 

        float ScreenX = ScreenLocation.X;
        float ScreenY = ScreenLocation.Y;

        float DistanceX = Math::Abs(CenterX - ScreenX);
        float DistanceY = Math::Abs(CenterY - ScreenY);

        float PercentDistX = 1.0f - (DistanceX / CenterX);
        float PercentDistY = 1.0f - (DistanceY / CenterY);

        return Math::Min(PercentDistX, PercentDistY);
    }

    UFUNCTION()
    void GetIsVisible(FVector LocationToCheck, FVector2D& ScreenLocation, bool& bVisible) const
    {
        if(Gameplay::GetPlayerController(0).ProjectWorldLocationToScreen(LocationToCheck, ScreenLocation))
        {
            FVector2D ScreenSize = WidgetLayout::GetViewportSize();
            bool bIsXVisible = ScreenLocation.X > 0 && ScreenLocation.X < WidgetLayout::GetViewportSize().X;
            bool bIsYVisible = ScreenLocation.Y > 0 && ScreenLocation.Y < WidgetLayout::GetViewportSize().Y;
            
            bVisible = bIsXVisible && bIsYVisible;
            return;
        }

        bVisible = false;
    }

    UFUNCTION()
    private void ActivateFlash()
    {
        Flash.SetIntensity(5000);
    }

    UFUNCTION()
    private void DeactivateFlash()
    {
        Flash.SetIntensity(0);
    }

    UFUNCTION()
    void ResetUse()
    {
        bUsing = false;
    }

    UFUNCTION()
    void SetSceneCaptureActive(bool State)
    {
        SceneCaptureComponent.SetActive(State);
    }

    UFUNCTION(BlueprintEvent)
    void UpdateCameraVisualsActive()
    {
        //Client feedback implemented in Blueprint
    }

    UFUNCTION(BlueprintEvent)
    void UpdateCameraVisualsInactive()
    {
        //Client feedback implemented in Blueprint
    }

    UFUNCTION()
    void PlaySound()
    {
        UseSound.Play();
    }

    UFUNCTION()
    void OnRep_bIsActive()
    {
        if(bIsActive)
        {
            UpdateCameraVisualsActive();
        }
        else if(!bIsActive)
        {
            UpdateCameraVisualsInactive();
        }
    }
}

class APhotoCamera : AGadget
{
    UPROPERTY(DefaultComponent)
    USceneCaptureComponent2D SceneCaptureComponent;
    default SceneCaptureComponent.CaptureSource = ESceneCaptureSource::SCS_FinalColorLDR;
    default SceneCaptureComponent.bCaptureEveryFrame = false;

    UPROPERTY(DefaultComponent)
    USpotLightComponent Flash;

    default Flash.AttenuationRadius = 500;

    UPROPERTY(DefaultComponent)
    UAudioComponent UseSound;

    default UseSound.AutoActivate = false;

    UPROPERTY()
    int ResolutionX = 376;

    UPROPERTY()
    int ResolutionY = 221;

    UPROPERTY(ReplicatedUsing = OnRep_bIsActive)
    bool bIsActive;

    UPROPERTY()
    bool bUsing;

    UPROPERTY()
    float CooldownTime;

    private UAlbumSubsystem AlbumSubsystem;

    UFUNCTION(BlueprintOverride)
    void BeginPlay()
    {
        Super::BeginPlay();
        AlbumSubsystem = UAlbumSubsystem::Get();
    }

    UFUNCTION()
    void OnSecondaryInteractEnd(AActor InteractingActor) override
    {
        Super::OnSecondaryInteractEnd(InteractingActor);
        if(bIsActive && !bUsing)
        {
            bUsing = true;
            System::SetTimer(this, n"ResetUse", CooldownTime, false);
            ClientSnapPicture();
        }
    }

    UFUNCTION(NetMulticast)
    private void ClientSnapPicture()
    {
        if (System::IsServer())
        {
            return;
        }

        ActivateFlash();
        float Percentage = 0;
        APhotoEvidence Evidence;
        EPhotoEvidenceType PhotoEvidenceType = EPhotoEvidenceType::Interaction;

        GetClosestPhotoEvidencePercentage(Percentage, Evidence);

        if(Evidence != nullptr)
        {
            PhotoEvidenceType = Evidence.PhotoEvidenceType;
        }

        SceneCaptureComponent.TextureTarget = Rendering::CreateRenderTarget2D(ResolutionX, ResolutionY);     
        SceneCaptureComponent.CaptureScene();

        AlbumSubsystem.BroadcastPictureTaken(SceneCaptureComponent.TextureTarget, Percentage, PhotoEvidenceType);

        SceneCaptureComponent.TextureTarget = nullptr;
        
        System::SetTimer(this, n"DeactivateFlash", 0.1f, false);

        PlaySound();
    }

    UFUNCTION(BlueprintEvent)
    void GetClosestPhotoEvidencePercentage(float& OutPercentage, APhotoEvidence& OutEvidence) const
    {
        TArray PhotoEvidences;
        GetAllActorsOfClass(PhotoEvidences);

        APhotoEvidence ClosestEvidence = nullptr;
        float ClosestDist = MAX_flt;
        FVector2D ClosestScreenLocation;
        for(auto Evidence : PhotoEvidences)
        {
            if(Evidence.bPhotographed)
            {
                continue;
            }

            FVector2D ThisScreenLocation;
            bool bThisVisible = false;

            GetIsVisible(Evidence.GetActorLocation(), ThisScreenLocation, bThisVisible);
            
            if(bThisVisible)
            {
                TArray ActorsToIgnore;
                ActorsToIgnore.Add(Evidence.SpawningActor);
                ActorsToIgnore.Add(Owner);
                ActorsToIgnore.Add(Evidence);
                FHitResult HitResult;
                if(!System::LineTraceSingle(Evidence.GetActorLocation(), GetActorLocation(), ETraceTypeQuery::Visibility, false, ActorsToIgnore, EDrawDebugTrace::None, HitResult, true))
                {
                    float Dist = ActorLocation.Distance(Evidence.ActorLocation);
                    if (Dist < ClosestDist)
                    {
                        ClosestDist = Dist;
                        ClosestEvidence = Evidence;
                        ClosestScreenLocation = ThisScreenLocation;
                    }
                }   
            }
        }

        OutEvidence = ClosestEvidence;

        if (ClosestEvidence != nullptr)
        {
            ClosestEvidence.bPhotographed = true;
            OutPercentage = CalculatePercentage(ClosestScreenLocation);
        }
        else
        {
            OutPercentage = 0.0f;
        }
    }

    float CalculatePercentage(FVector2D ScreenLocation) const
    {
        float WidgetX = WidgetLayout::GetViewportSize().X;
        float WidgetY = WidgetLayout::GetViewportSize().Y;

        float CenterX = WidgetX / 2;
        float CenterY = WidgetY / 2; 

        float ScreenX = ScreenLocation.X;
        float ScreenY = ScreenLocation.Y;

        float DistanceX = Math::Abs(CenterX - ScreenX);
        float DistanceY = Math::Abs(CenterY - ScreenY);

        float PercentDistX = 1.0f - (DistanceX / CenterX);
        float PercentDistY = 1.0f - (DistanceY / CenterY);

        return Math::Min(PercentDistX, PercentDistY);
    }

    UFUNCTION()
    void GetIsVisible(FVector LocationToCheck, FVector2D& ScreenLocation, bool& bVisible) const
    {
        if(Gameplay::GetPlayerController(0).ProjectWorldLocationToScreen(LocationToCheck, ScreenLocation))
        {
            FVector2D ScreenSize = WidgetLayout::GetViewportSize();
            bool bIsXVisible = ScreenLocation.X > 0 && ScreenLocation.X < WidgetLayout::GetViewportSize().X;
            bool bIsYVisible = ScreenLocation.Y > 0 && ScreenLocation.Y < WidgetLayout::GetViewportSize().Y;
            
            bVisible = bIsXVisible && bIsYVisible;
            return;
        }

        bVisible = false;
    }

    UFUNCTION()
    private void ActivateFlash()
    {
        Flash.SetIntensity(5000);
    }

    UFUNCTION()
    private void DeactivateFlash()
    {
        Flash.SetIntensity(0);
    }

    UFUNCTION()
    void ResetUse()
    {
        bUsing = false;
    }

    UFUNCTION()
    void SetSceneCaptureActive(bool State)
    {
        SceneCaptureComponent.SetActive(State);
    }

    UFUNCTION(BlueprintEvent)
    void UpdateCameraVisualsActive()
    {
        //Client feedback implemented in Blueprint
    }

    UFUNCTION(BlueprintEvent)
    void UpdateCameraVisualsInactive()
    {
        //Client feedback implemented in Blueprint
    }

    UFUNCTION()
    void PlaySound()
    {
        UseSound.Play();
    }

    UFUNCTION()
    void OnRep_bIsActive()
    {
        if(bIsActive)
        {
            UpdateCameraVisualsActive();
        }
        else if(!bIsActive)
        {
            UpdateCameraVisualsInactive();
        }
    }
}

class APhotoCameraTier2 : APhotoCamera
{
    UPROPERTY(DefaultComponent)
    UStaticMeshComponent ScreenRender;

    UPROPERTY(DefaultComponent)
    USceneCaptureComponent2D ScreenRenderCapture;

    UPROPERTY(DefaultComponent)
    UWidgetComponent CameraOverlay;

    UPROPERTY(Category = "Camera Settings")
    UMaterialInstanceDynamic MID;

    UPROPERTY(Category = "Camera Settings")
    UMaterial CameraMaterial;

    default CooldownTime  = 2; 

    UFUNCTION(BlueprintOverride)
    void ConstructionScript()
    {
        Super::ConstructionScript();
        
        if(ScreenRenderCapture.TextureTarget == nullptr || ScreenRenderCapture.TextureTarget.SizeX != ResolutionX || ScreenRenderCapture.TextureTarget.SizeY != ResolutionY)
        {
            ScreenRenderCapture.TextureTarget = Rendering::CreateRenderTarget2D(ResolutionX, ResolutionY);     
        }

        MID = ScreenRender.CreateDynamicMaterialInstance(0, CameraMaterial);
        if (MID != nullptr)
        {
            ScreenRender.SetMaterial(0, MID);
            MID.SetTextureParameterValue(n"RenderTexture", ScreenRenderCapture.TextureTarget);
        }
    }

    UFUNCTION(BlueprintOverride)
    void Tick(float DeltaSeconds)
    {
        if(bIsActive)
        {
            ScreenRenderCapture.CaptureScene();
        }
    }

    void UpdateRenderVisibility() override
    {
        ScreenRender.SetVisibility(bIsActive);
        CameraOverlay.SetVisibility(bIsActive);
    }
}



class APhotoCameraTier2 : APhotoCamera
{
    UPROPERTY(DefaultComponent)
    UStaticMeshComponent ScreenRender;

    UPROPERTY(DefaultComponent)
    USceneCaptureComponent2D ScreenRenderCapture;

    UPROPERTY(DefaultComponent)
    UWidgetComponent CameraOverlay;

    UPROPERTY(Category = "Camera Settings")
    UMaterialInstanceDynamic MID;

    UPROPERTY(Category = "Camera Settings")
    UMaterial CameraMaterial;

    default CooldownTime  = 2; 

    UFUNCTION(BlueprintOverride)
    void ConstructionScript()
    {
        Super::ConstructionScript();
        
        if(ScreenRenderCapture.TextureTarget == nullptr || ScreenRenderCapture.TextureTarget.SizeX != ResolutionX || ScreenRenderCapture.TextureTarget.SizeY != ResolutionY)
        {
            ScreenRenderCapture.TextureTarget = Rendering::CreateRenderTarget2D(ResolutionX, ResolutionY);     
        }

        MID = ScreenRender.CreateDynamicMaterialInstance(0, CameraMaterial);
        if (MID != nullptr)
        {
            ScreenRender.SetMaterial(0, MID);
            MID.SetTextureParameterValue(n"RenderTexture", ScreenRenderCapture.TextureTarget);
        }
    }

    UFUNCTION(BlueprintOverride)
    void Tick(float DeltaSeconds)
    {
        if(bIsActive)
        {
            ScreenRenderCapture.CaptureScene();
        }
    }

    void UpdateRenderVisibility() override
    {
        ScreenRender.SetVisibility(bIsActive);
        CameraOverlay.SetVisibility(bIsActive);
    }
}

EMF READER

The EMF reader is a gadget used to measure ghost activity in the house that you are investigating. It shows different strengths of EMF signals depending on the interactions and ghost types.


The higher tiered reader can show three interactions on its display at the same time.



struct FTrackedEMFSpot
{
    UPROPERTY()
    AEMFSpot Spot;

    UPROPERTY()
    int Strength;

    UPROPERTY()
    FVector Location;
}

struct FTrackedSpotChange
{
    UPROPERTY()
    bool bAdded;

    UPROPERTY()
    FTrackedEMFSpot Spot;

    FTrackedSpotChange(bool bInAdded, FTrackedEMFSpot InSpot)
    {
        Spot = InSpot;
        bAdded = bInAdded;
    }
}

class AEMF : AGadget
{
    UPROPERTY(DefaultComponent)
    USphereComponent DetectionSphere;
    default DetectionSphere.CollisionProfileName = CollisionProfiles::EMFDetect;
    default DetectionSphere.GenerateOverlapEvents = true;

    UPROPERTY(DefaultComponent)
    UHearableAudioComponent OnSound;
    default OnSound.bAutoActivate = false;

    UPROPERTY(DefaultComponent)
    UHearableAudioComponent OffSound;
    default OffSound.bAutoActivate = false;
    
    UPROPERTY(ReplicatedUsing = OnRep_bIsOn)
    bool bIsOn = false;
    
    TArray TrackedSpots;

    UPROPERTY(ReplicatedUsing = OnRep_SpotChanges)
    TArray SpotChanges;

    UMaterialInstanceDynamic MID;

    UFUNCTION(BlueprintOverride)
    void BeginPlay()
    {
        Super::BeginPlay();

        DetectionSphere.OnComponentBeginOverlap.AddUFunction(this, n"OnDetectedEMF");
        DetectionSphere.OnComponentEndOverlap.AddUFunction(this, n"OnStoppedDetectingEMF");
    }

    UFUNCTION(BlueprintOverride)
    void Tick(float DeltaSeconds)
    {
        SortSpots();
    }

    UFUNCTION()
    void OnDetectedEMF(UPrimitiveComponent OverlappedComponent, AActor OtherActor,
                               UPrimitiveComponent OtherComp, int OtherBodyIndex, bool bFromSweep,
                               const FHitResult&in SweepResult)
    {
        if (System::IsServer())
        {
            if (OtherActor.IsA(AEMFSpot::StaticClass()))
            {
                FTrackedEMFSpot Tracked;
                Tracked.Spot = Cast(OtherActor);
                Tracked.Strength = Tracked.Spot.Strength;
                Tracked.Location = Tracked.Spot.ActorLocation;

                OtherActor.OnDestroyed.AddUFunction(this, n"OnEMFSpotDestroyed");

                AddSpot(Tracked, true);
            }
        }
    }

    UFUNCTION()
    void OnStoppedDetectingEMF(UPrimitiveComponent OverlappedComponent, AActor OtherActor,
                                       UPrimitiveComponent OtherComp, int OtherBodyIndex)
    {
        if (System::IsServer())
        {
            RemoveSpot(OtherActor, true);
        }
    }

    UFUNCTION()
    void OnEMFSpotDestroyed(AActor DestroyedActor)
    {
        RemoveSpot(DestroyedActor, true);
    }

    private void AddSpot(FTrackedEMFSpot Tracked, bool bRecordChanges)
    {
        if(TrackedSpots.Num() == 0)
        {
            RecordedAdd(Tracked, 0, bRecordChanges);
            return;
        }

        float DistanceToNewSpot = Tracked.Location.Distance(ActorLocation); 

        bool bAdded = false;

        for(int i = 0; i < TrackedSpots.Num(); i++)
        {
            float ThisDistance = TrackedSpots[i].Location.Distance(ActorLocation);

            if(DistanceToNewSpot < ThisDistance)
            {
                RecordedAdd(Tracked, i, bRecordChanges);
                bAdded = true;
                break;
            }
        }

        if(!bAdded)
        {
            RecordedAdd(Tracked, TrackedSpots.Num(), bRecordChanges);
        }
    }

    private void RemoveSpot(AActor Actor, bool bRecordChanges)
    {
        for (int i = TrackedSpots.Num() - 1; i >= 0; i--)
        {
            if (TrackedSpots[i].Spot == Actor)
            {
                RecordedRemove(i, bRecordChanges);
                break;
            }
        }
    }

    private void RecordedAdd(FTrackedEMFSpot Spot, int Index, bool bRecordChange)
    {
        TrackedSpots.Insert(Spot, Index);
        if (bRecordChange)
        {
            SpotChanges.Add(FTrackedSpotChange(true, Spot));
        }
        OnAddedSpot(Spot, Index);
    }

    private void RecordedRemove(int Index, bool bRecordChange)
    {
        if (bRecordChange)
        {
            SpotChanges.Add(FTrackedSpotChange(false, TrackedSpots[Index]));
        }
        OnRemovedSpot(TrackedSpots[Index], Index);
        TrackedSpots.RemoveAt(Index);
    }

    void OnAddedSpot(FTrackedEMFSpot TrackedSpot, int Index) { }
    void OnRemovedSpot(FTrackedEMFSpot TrackedSpot, int Index) { }

    private void SortSpots()
    {
        int Num = TrackedSpots.Num();
        bool bSwapped = false;

        for (int i = 0; i < Num; i++)
        {
            bSwapped = false;

            for (int j = 0; j < Num - i - 1; j++)
            {
                if (TrackedSpots[j].Location.Distance(ActorLocation) > TrackedSpots[j + 1].Location.Distance(ActorLocation))
                {
                    TrackedSpots.Swap(j, j + 1);
                    bSwapped = true;
                }
            }

            if (!bSwapped)
            {
                break;
            }
        }
    }

    void OnSecondaryInteractEnd(AActor InteractingActor) override
    {
        Super::OnSecondaryInteractEnd(InteractingActor);
        bIsOn = !bIsOn;
    }

    void NewOnState(){}
    
    void PlayActivateSound()
    {
        if(bIsOn)
        {
            OnSound.Play();
            OnSound.OnPlaying.Broadcast(OnSound);
        }
        else
        {
            OffSound.Play();
            OffSound.OnPlaying.Broadcast(OffSound);
        }
    }

    UFUNCTION()
    private void OnRep_SpotChanges(TArray PreviousChanges)
    {
        if (SpotChanges.Num() > PreviousChanges.Num())
        {
            TArray AddedSpots;
            TArray RemovedSpots;
            
            
            for (int i = PreviousChanges.Num(); i < SpotChanges.Num(); i++)
            {
                if (SpotChanges[i].bAdded)
                {
                    AddedSpots.Add(SpotChanges[i].Spot);
                }
                else
                {
                    RemovedSpots.Add(SpotChanges[i].Spot.Spot);
                }
            }

            for(auto Spot : AddedSpots)
            {
                AddSpot(Spot, false);
            }

            for(auto Spot : RemovedSpots)
            {
                RemoveSpot(Spot, false);
            }
        }
    }

    UFUNCTION()
    void OnRep_bIsOn()
    {
        NewOnState();
        PlayActivateSound();
    }
}

struct FTrackedEMFSpot
{
    UPROPERTY()
    AEMFSpot Spot;

    UPROPERTY()
    int Strength;

    UPROPERTY()
    FVector Location;
}

struct FTrackedSpotChange
{
    UPROPERTY()
    bool bAdded;

    UPROPERTY()
    FTrackedEMFSpot Spot;

    FTrackedSpotChange(bool bInAdded, FTrackedEMFSpot InSpot)
    {
        Spot = InSpot;
        bAdded = bInAdded;
    }
}

class AEMF : AGadget
{
    UPROPERTY(DefaultComponent)
    USphereComponent DetectionSphere;
    default DetectionSphere.CollisionProfileName = CollisionProfiles::EMFDetect;
    default DetectionSphere.GenerateOverlapEvents = true;

    UPROPERTY(DefaultComponent)
    UHearableAudioComponent OnSound;
    default OnSound.bAutoActivate = false;

    UPROPERTY(DefaultComponent)
    UHearableAudioComponent OffSound;
    default OffSound.bAutoActivate = false;
    
    UPROPERTY(ReplicatedUsing = OnRep_bIsOn)
    bool bIsOn = false;
    
    TArray TrackedSpots;

    UPROPERTY(ReplicatedUsing = OnRep_SpotChanges)
    TArray SpotChanges;

    UMaterialInstanceDynamic MID;

    UFUNCTION(BlueprintOverride)
    void BeginPlay()
    {
        Super::BeginPlay();

        DetectionSphere.OnComponentBeginOverlap.AddUFunction(this, n"OnDetectedEMF");
        DetectionSphere.OnComponentEndOverlap.AddUFunction(this, n"OnStoppedDetectingEMF");
    }

    UFUNCTION(BlueprintOverride)
    void Tick(float DeltaSeconds)
    {
        SortSpots();
    }

    UFUNCTION()
    void OnDetectedEMF(UPrimitiveComponent OverlappedComponent, AActor OtherActor,
                               UPrimitiveComponent OtherComp, int OtherBodyIndex, bool bFromSweep,
                               const FHitResult&in SweepResult)
    {
        if (System::IsServer())
        {
            if (OtherActor.IsA(AEMFSpot::StaticClass()))
            {
                FTrackedEMFSpot Tracked;
                Tracked.Spot = Cast(OtherActor);
                Tracked.Strength = Tracked.Spot.Strength;
                Tracked.Location = Tracked.Spot.ActorLocation;

                OtherActor.OnDestroyed.AddUFunction(this, n"OnEMFSpotDestroyed");

                AddSpot(Tracked, true);
            }
        }
    }

    UFUNCTION()
    void OnStoppedDetectingEMF(UPrimitiveComponent OverlappedComponent, AActor OtherActor,
                                       UPrimitiveComponent OtherComp, int OtherBodyIndex)
    {
        if (System::IsServer())
        {
            RemoveSpot(OtherActor, true);
        }
    }

    UFUNCTION()
    void OnEMFSpotDestroyed(AActor DestroyedActor)
    {
        RemoveSpot(DestroyedActor, true);
    }

    private void AddSpot(FTrackedEMFSpot Tracked, bool bRecordChanges)
    {
        if(TrackedSpots.Num() == 0)
        {
            RecordedAdd(Tracked, 0, bRecordChanges);
            return;
        }

        float DistanceToNewSpot = Tracked.Location.Distance(ActorLocation); 

        bool bAdded = false;

        for(int i = 0; i < TrackedSpots.Num(); i++)
        {
            float ThisDistance = TrackedSpots[i].Location.Distance(ActorLocation);

            if(DistanceToNewSpot < ThisDistance)
            {
                RecordedAdd(Tracked, i, bRecordChanges);
                bAdded = true;
                break;
            }
        }

        if(!bAdded)
        {
            RecordedAdd(Tracked, TrackedSpots.Num(), bRecordChanges);
        }
    }

    private void RemoveSpot(AActor Actor, bool bRecordChanges)
    {
        for (int i = TrackedSpots.Num() - 1; i >= 0; i--)
        {
            if (TrackedSpots[i].Spot == Actor)
            {
                RecordedRemove(i, bRecordChanges);
                break;
            }
        }
    }

    private void RecordedAdd(FTrackedEMFSpot Spot, int Index, bool bRecordChange)
    {
        TrackedSpots.Insert(Spot, Index);
        if (bRecordChange)
        {
            SpotChanges.Add(FTrackedSpotChange(true, Spot));
        }
        OnAddedSpot(Spot, Index);
    }

    private void RecordedRemove(int Index, bool bRecordChange)
    {
        if (bRecordChange)
        {
            SpotChanges.Add(FTrackedSpotChange(false, TrackedSpots[Index]));
        }
        OnRemovedSpot(TrackedSpots[Index], Index);
        TrackedSpots.RemoveAt(Index);
    }

    void OnAddedSpot(FTrackedEMFSpot TrackedSpot, int Index) { }
    void OnRemovedSpot(FTrackedEMFSpot TrackedSpot, int Index) { }

    private void SortSpots()
    {
        int Num = TrackedSpots.Num();
        bool bSwapped = false;

        for (int i = 0; i < Num; i++)
        {
            bSwapped = false;

            for (int j = 0; j < Num - i - 1; j++)
            {
                if (TrackedSpots[j].Location.Distance(ActorLocation) > TrackedSpots[j + 1].Location.Distance(ActorLocation))
                {
                    TrackedSpots.Swap(j, j + 1);
                    bSwapped = true;
                }
            }

            if (!bSwapped)
            {
                break;
            }
        }
    }

    void OnSecondaryInteractEnd(AActor InteractingActor) override
    {
        Super::OnSecondaryInteractEnd(InteractingActor);
        bIsOn = !bIsOn;
    }

    void NewOnState(){}
    
    void PlayActivateSound()
    {
        if(bIsOn)
        {
            OnSound.Play();
            OnSound.OnPlaying.Broadcast(OnSound);
        }
        else
        {
            OffSound.Play();
            OffSound.OnPlaying.Broadcast(OffSound);
        }
    }

    UFUNCTION()
    private void OnRep_SpotChanges(TArray PreviousChanges)
    {
        if (SpotChanges.Num() > PreviousChanges.Num())
        {
            TArray AddedSpots;
            TArray RemovedSpots;
            
            
            for (int i = PreviousChanges.Num(); i < SpotChanges.Num(); i++)
            {
                if (SpotChanges[i].bAdded)
                {
                    AddedSpots.Add(SpotChanges[i].Spot);
                }
                else
                {
                    RemovedSpots.Add(SpotChanges[i].Spot.Spot);
                }
            }

            for(auto Spot : AddedSpots)
            {
                AddSpot(Spot, false);
            }

            for(auto Spot : RemovedSpots)
            {
                RemoveSpot(Spot, false);
            }
        }
    }

    UFUNCTION()
    void OnRep_bIsOn()
    {
        NewOnState();
        PlayActivateSound();
    }
}

class AEMFTier1 : AEMF
{
    UPROPERTY(DefaultComponent, Attach = "Mesh")
    UStaticMeshComponent Glass;
    default Glass.CollisionProfileName = CollisionProfiles::Gadget;

    UPROPERTY(DefaultComponent, Attach = "Mesh")
    UStaticMeshComponent Arm;
    default Arm.CollisionProfileName = CollisionProfiles::Gadget;
    
    UPROPERTY(DefaultComponent, Attach = "Mesh")
    UStaticMeshComponent Dial;
    default Dial.CollisionProfileName = CollisionProfiles::Gadget;

    UPROPERTY(DefaultComponent)
    UHearableAudioComponent Crackle;
    default Crackle.bAutoActivate = false;

    UPROPERTY(Category = "EMF Settings")
    USoundCue EMFLow;

    UPROPERTY(Category = "EMF Settings")
    USoundCue EMFHigh;
  
    UPROPERTY(Category = "EMF Settings")
    TMap ArmPositionMap;

    int ClosestStrength = 0;
    float TargetRotation;
    float RotationSpeed = 200;
    float UncertaintyMargin = 1;
    float UncertaintyFrequency = 50;

    UFUNCTION(BlueprintOverride)
    void BeginPlay()
    {
        Super::BeginPlay();
        if (!System::IsServer())
        {
            MID = Mesh.CreateDynamicMaterialInstance(0, Mesh.GetMaterial(0));
        }
    }

    UFUNCTION(BlueprintOverride)
    void Tick(float DeltaSeconds)
    {
        Super::Tick(DeltaSeconds);

        if (!System::IsServer())
        {
            if (UpdateClosestStrength())
            {
                UpdateEMFSound();
            }

            if(bIsOn)
            {
                UpdateArm(DeltaSeconds);
            }
        }
    }

    private bool UpdateClosestStrength()
    {
        int NewStrength = 0;
        if (TrackedSpots.Num() > 0)
        {
            NewStrength = TrackedSpots[0].Strength;
        }

        if (NewStrength != ClosestStrength)
        {
            ClosestStrength = NewStrength;
            return true;
        }

        return false;
    }

    void NewOnState() override
    {
        Super::NewOnState();
        UpdateEMFMaterialValue();
        UpdateMeshState();
        UpdateEMFSound();
    }

    void UpdateEMFMaterialValue()
    {
        MID.SetScalarParameterValue(n"Emissive Intensity", bIsOn ? 10 : 0);
    }

    void UpdateMeshState()
    {
        float Roll = bIsOn ? -55 : 85;

        FRotator NewRotation = FRotator(0, 0, Roll);
        Dial.SetRelativeRotation(NewRotation);
    }

    void UpdateArm(float DeltaSeconds)
    {
        TargetRotation = ArmPositionMap[ClosestStrength];
        float CurrentRotation = Arm.GetRelativeRotation().Roll;

        if(CurrentRotation < TargetRotation)
        {
            CurrentRotation += DeltaSeconds * RotationSpeed;

            CurrentRotation = Math::Min(CurrentRotation, TargetRotation);
        }
        else if(CurrentRotation > TargetRotation)
        {
            CurrentRotation -= DeltaSeconds * RotationSpeed;

            CurrentRotation = Math::Max(CurrentRotation, TargetRotation);
        }

        CurrentRotation += Math::Sin(Gameplay::TimeSeconds * UncertaintyFrequency) * UncertaintyMargin;

        FRotator NewRotation = FRotator(0, 0, CurrentRotation);
        Arm.SetRelativeRotation(NewRotation);
    }

    void UpdateEMFSound()
    {
        if(bIsOn)
        {
            if(ClosestStrength > 1)
            {
                if(ClosestStrength <= 4)
                {
                    Crackle.SetSound(EMFLow);
                }
                else
                {
                    Crackle.SetSound(EMFHigh);
                }

                Crackle.Play();
                Crackle.OnPlaying.Broadcast(Crackle);
            }
            else
            {
                Crackle.Stop();
                Crackle.OnStopping.Broadcast(Crackle);
            }
        }
        else
        {
            Crackle.Stop();
            Crackle.OnStopping.Broadcast(Crackle);
        }
    }
}

class AEMFTier1 : AEMF
{
    UPROPERTY(DefaultComponent, Attach = "Mesh")
    UStaticMeshComponent Glass;
    default Glass.CollisionProfileName = CollisionProfiles::Gadget;

    UPROPERTY(DefaultComponent, Attach = "Mesh")
    UStaticMeshComponent Arm;
    default Arm.CollisionProfileName = CollisionProfiles::Gadget;
    
    UPROPERTY(DefaultComponent, Attach = "Mesh")
    UStaticMeshComponent Dial;
    default Dial.CollisionProfileName = CollisionProfiles::Gadget;

    UPROPERTY(DefaultComponent)
    UHearableAudioComponent Crackle;
    default Crackle.bAutoActivate = false;

    UPROPERTY(Category = "EMF Settings")
    USoundCue EMFLow;

    UPROPERTY(Category = "EMF Settings")
    USoundCue EMFHigh;
  
    UPROPERTY(Category = "EMF Settings")
    TMap ArmPositionMap;

    int ClosestStrength = 0;
    float TargetRotation;
    float RotationSpeed = 200;
    float UncertaintyMargin = 1;
    float UncertaintyFrequency = 50;

    UFUNCTION(BlueprintOverride)
    void BeginPlay()
    {
        Super::BeginPlay();
        if (!System::IsServer())
        {
            MID = Mesh.CreateDynamicMaterialInstance(0, Mesh.GetMaterial(0));
        }
    }

    UFUNCTION(BlueprintOverride)
    void Tick(float DeltaSeconds)
    {
        Super::Tick(DeltaSeconds);

        if (!System::IsServer())
        {
            if (UpdateClosestStrength())
            {
                UpdateEMFSound();
            }

            if(bIsOn)
            {
                UpdateArm(DeltaSeconds);
            }
        }
    }

    private bool UpdateClosestStrength()
    {
        int NewStrength = 0;
        if (TrackedSpots.Num() > 0)
        {
            NewStrength = TrackedSpots[0].Strength;
        }

        if (NewStrength != ClosestStrength)
        {
            ClosestStrength = NewStrength;
            return true;
        }

        return false;
    }

    void NewOnState() override
    {
        Super::NewOnState();
        UpdateEMFMaterialValue();
        UpdateMeshState();
        UpdateEMFSound();
    }

    void UpdateEMFMaterialValue()
    {
        MID.SetScalarParameterValue(n"Emissive Intensity", bIsOn ? 10 : 0);
    }

    void UpdateMeshState()
    {
        float Roll = bIsOn ? -55 : 85;

        FRotator NewRotation = FRotator(0, 0, Roll);
        Dial.SetRelativeRotation(NewRotation);
    }

    void UpdateArm(float DeltaSeconds)
    {
        TargetRotation = ArmPositionMap[ClosestStrength];
        float CurrentRotation = Arm.GetRelativeRotation().Roll;

        if(CurrentRotation < TargetRotation)
        {
            CurrentRotation += DeltaSeconds * RotationSpeed;

            CurrentRotation = Math::Min(CurrentRotation, TargetRotation);
        }
        else if(CurrentRotation > TargetRotation)
        {
            CurrentRotation -= DeltaSeconds * RotationSpeed;

            CurrentRotation = Math::Max(CurrentRotation, TargetRotation);
        }

        CurrentRotation += Math::Sin(Gameplay::TimeSeconds * UncertaintyFrequency) * UncertaintyMargin;

        FRotator NewRotation = FRotator(0, 0, CurrentRotation);
        Arm.SetRelativeRotation(NewRotation);
    }

    void UpdateEMFSound()
    {
        if(bIsOn)
        {
            if(ClosestStrength > 1)
            {
                if(ClosestStrength <= 4)
                {
                    Crackle.SetSound(EMFLow);
                }
                else
                {
                    Crackle.SetSound(EMFHigh);
                }

                Crackle.Play();
                Crackle.OnPlaying.Broadcast(Crackle);
            }
            else
            {
                Crackle.Stop();
                Crackle.OnStopping.Broadcast(Crackle);
            }
        }
        else
        {
            Crackle.Stop();
            Crackle.OnStopping.Broadcast(Crackle);
        }
    }
}

class AEMFTier3 : AEMF
{
    UPROPERTY(DefaultComponent)
    UWidgetComponent ScreenUI;

    UPROPERTY(DefaultComponent)
    UHearableAudioComponent Beep0;
    default Beep0.bAutoActivate = false;

    UPROPERTY(DefaultComponent)
    UHearableAudioComponent Beep1;
    default Beep1.bAutoActivate = false;

    UPROPERTY(DefaultComponent)
    UHearableAudioComponent Beep2;
    default Beep2.bAutoActivate = false;

    private TArray TakenMarkers;
    private TArray Slots;

    private TArray Beeps;

    UFUNCTION(BlueprintOverride)
    void BeginPlay()
    {
        Super::BeginPlay();
        if (!System::IsServer())
        {
            MID = Mesh.CreateDynamicMaterialInstance(0, Mesh.GetMaterial(0));
        }

        const int NumSlots = 3;

        Slots.SetNum(NumSlots);
        TakenMarkers.SetNum(NumSlots);
        Beeps.SetNum(NumSlots);

        Beeps[0] = Beep0;
        Beeps[1] = Beep1;
        Beeps[2] = Beep2;

        for (int i = 0; i < NumSlots; i++)
        {
            Slots[i] = FTrackedEMFSpot();
            TakenMarkers[i] = false;
        }
    }

    void OnAddedSpot(FTrackedEMFSpot TrackedSpot, int Index) override
    {
        if(!System::IsServer())
        {
            if(Index == 0 || Index == 1 || Index == 2)
            {
                AddToFirstAvailableSlot(TrackedSpot);
            }
        }
    }

    void OnRemovedSpot(FTrackedEMFSpot TrackedSpot, int Index) override
    {
        if(!System::IsServer())
        {     
            if(Index == 0 || Index == 1 || Index == 2)
            {
                RemoveFromSlots(TrackedSpot);
            } 
        }
    }

    private void AddToFirstAvailableSlot(FTrackedEMFSpot Spot)
    {
        int Index = FindFirstAvailableSlot();
        
        if (Index >= 0)
        {
            Slots[Index] = Spot;
            TakenMarkers[Index] = true;
            Print(f"{Spot.Spot.Name} inserted into {Index}");
            SetPitchAndStartPlaying(Beeps[Index], Spot.Strength);
        }
    }

    private void RemoveFromSlots(FTrackedEMFSpot Spot)
    {
        int Index = FindSlotOfSpot(Spot);

        if (Index >= 0)
        {
            Slots[Index] = FTrackedEMFSpot();
            TakenMarkers[Index] = false;
            Print(f"{Spot.Spot.Name} removed from {Index}");

            CompactFrom(Index);
        }
    }

    private void CompactFrom(int Index)
    {
        for (int i = Index + 1; i < Slots.Num(); i++)
        {
            Slots[i - 1] = Slots[i];
            TakenMarkers[i - 1] = TakenMarkers[i];
        }
    }

    private int FindSlotOfSpot(FTrackedEMFSpot Spot) const
    {
        for (int i = 0; i < Slots.Num(); i++)
        {
            if (Slots[i].Spot == Spot.Spot)
            {
                return i;
            }
        }
        return -1;
    }

    private int FindFirstAvailableSlot() const
    {
        for (int i = 0; i < TakenMarkers.Num(); i++)
        {
            if (!TakenMarkers[i])
            {
                return i;
            }
        }
        return -1;
    }

    void SetPitchAndStartPlaying(UHearableAudioComponent Component, int Strength)
    {
        float Pitch = 0.5 + 0.1 * Strength;
        Component.PitchMultiplier = Pitch;
    }

    void NewOnState() override
    {
        Super::NewOnState();
        UpdateEMFMaterialValue();
    }

    void UpdateEMFMaterialValue()
    {
        MID.SetScalarParameterValue(n"Emissive Intensity", bIsOn ? 1 : 0);
        ScreenUI.SetVisibility(bIsOn);
    }
}

class AEMFTier3 : AEMF
{
    UPROPERTY(DefaultComponent)
    UWidgetComponent ScreenUI;

    UPROPERTY(DefaultComponent)
    UHearableAudioComponent Beep0;
    default Beep0.bAutoActivate = false;

    UPROPERTY(DefaultComponent)
    UHearableAudioComponent Beep1;
    default Beep1.bAutoActivate = false;

    UPROPERTY(DefaultComponent)
    UHearableAudioComponent Beep2;
    default Beep2.bAutoActivate = false;

    private TArray TakenMarkers;
    private TArray Slots;

    private TArray Beeps;

    UFUNCTION(BlueprintOverride)
    void BeginPlay()
    {
        Super::BeginPlay();
        if (!System::IsServer())
        {
            MID = Mesh.CreateDynamicMaterialInstance(0, Mesh.GetMaterial(0));
        }

        const int NumSlots = 3;

        Slots.SetNum(NumSlots);
        TakenMarkers.SetNum(NumSlots);
        Beeps.SetNum(NumSlots);

        Beeps[0] = Beep0;
        Beeps[1] = Beep1;
        Beeps[2] = Beep2;

        for (int i = 0; i < NumSlots; i++)
        {
            Slots[i] = FTrackedEMFSpot();
            TakenMarkers[i] = false;
        }
    }

    void OnAddedSpot(FTrackedEMFSpot TrackedSpot, int Index) override
    {
        if(!System::IsServer())
        {
            if(Index == 0 || Index == 1 || Index == 2)
            {
                AddToFirstAvailableSlot(TrackedSpot);
            }
        }
    }

    void OnRemovedSpot(FTrackedEMFSpot TrackedSpot, int Index) override
    {
        if(!System::IsServer())
        {     
            if(Index == 0 || Index == 1 || Index == 2)
            {
                RemoveFromSlots(TrackedSpot);
            } 
        }
    }

    private void AddToFirstAvailableSlot(FTrackedEMFSpot Spot)
    {
        int Index = FindFirstAvailableSlot();
        
        if (Index >= 0)
        {
            Slots[Index] = Spot;
            TakenMarkers[Index] = true;
            Print(f"{Spot.Spot.Name} inserted into {Index}");
            SetPitchAndStartPlaying(Beeps[Index], Spot.Strength);
        }
    }

    private void RemoveFromSlots(FTrackedEMFSpot Spot)
    {
        int Index = FindSlotOfSpot(Spot);

        if (Index >= 0)
        {
            Slots[Index] = FTrackedEMFSpot();
            TakenMarkers[Index] = false;
            Print(f"{Spot.Spot.Name} removed from {Index}");

            CompactFrom(Index);
        }
    }

    private void CompactFrom(int Index)
    {
        for (int i = Index + 1; i < Slots.Num(); i++)
        {
            Slots[i - 1] = Slots[i];
            TakenMarkers[i - 1] = TakenMarkers[i];
        }
    }

    private int FindSlotOfSpot(FTrackedEMFSpot Spot) const
    {
        for (int i = 0; i < Slots.Num(); i++)
        {
            if (Slots[i].Spot == Spot.Spot)
            {
                return i;
            }
        }
        return -1;
    }

    private int FindFirstAvailableSlot() const
    {
        for (int i = 0; i < TakenMarkers.Num(); i++)
        {
            if (!TakenMarkers[i])
            {
                return i;
            }
        }
        return -1;
    }

    void SetPitchAndStartPlaying(UHearableAudioComponent Component, int Strength)
    {
        float Pitch = 0.5 + 0.1 * Strength;
        Component.PitchMultiplier = Pitch;
    }

    void NewOnState() override
    {
        Super::NewOnState();
        UpdateEMFMaterialValue();
    }

    void UpdateEMFMaterialValue()
    {
        MID.SetScalarParameterValue(n"Emissive Intensity", bIsOn ? 1 : 0);
        ScreenUI.SetVisibility(bIsOn);
    }
}

JOURNAL UI

One important element in Phasmophobia is the Journal where the players can track which evidence they've found, look up info about the ghosts, and see their objectives. 


I wanted to learn more about UI in Unreal Engine, so I added this journal to our remake. I made sure to add all the ghost info, the ability to deduce ghosts based on evidence, and also added audio feedback.