Gameplay / System Programmer
The Witch & The King - Battle of Lund
Historical third-person action-adventure prototype built in Unreal Engine, designed as a foundation for future development around exploration, combat, wildlife AI, and atmospheric storytelling.
The Witch and The King is a historical third-person action-adventure prototype built in Unreal Engine 5.7. The project is designed as a foundation for future development, with a focus on exploration, lock-on combat, reactive wildlife AI, environmental interaction, and atmospheric storytelling.
My work focused on building and integrating runtime gameplay systems, including combat camera behavior, player control integration, crow and BOID systems, environmental hazards, and diegetic narrative presentation. The game is still in active development, so the systems presented here represent portfolio-ready snapshots of tested and playable features.
Role: Gameplay Programmer - AI - Systems
Team Size: 6
5 weeks
UE (C++)
Dynamic Combat Camera with modular lock-on system
Built a lock-on and dynamic combat camera system on top of Unreal Engine 5.7’s GASP / Mover locomotion stack. The system uses reusable C++ components for target acquisition, target cycling, camera zoom, FOV interpolation, shoulder swapping, and auto-unlock behavior, while Blueprint bridges keep the implementation compatible with the production SandboxCharacter_Mover pawn.
BOID / Crow Systems
Built a modular crow / raven BOID system in Unreal Engine 5.7 using C++ managers, shared bird agents, Animation Blueprint integration, and data-driven flock profiles. The system supports ambient sky flocks, diegetic guide birds, reactive perched clusters, and narrative crow bursts, all using a shared ABoidAgent visual layer while behavior is controlled by specialized managers. I also implemented performance work such as spatial grid neighbor search, tick LOD, far-flock simplification, and cooked-build animation hardening.
World-Space Narrative System
Built a world-space narrative text system for cinematic text, tutorial prompts, internal monologue, and Boar Hunt mission beats. The system uses a C++ base actor, ANarrativeTextBase, to control visual stability, distance fade, billboarding, overlap filtering, and runtime-spawn safety, while Blueprint handles the dissolve sequence with VFX, audio, and crow burst integration. The result is narrative text that feels placed inside the world instead of appearing as standard HUD UI.
Dynamic Combat Camera & Lock-On System
The Lock-On & Dynamic Combat Camera system adds a combat-ready control layer on top of Unreal Engine 5.7’s GASP / Mover locomotion stack. Instead of replacing the existing character framework, I implemented reusable C++ components and lightweight Blueprint bridge points that allow the production SandboxCharacter_Mover pawn to support target acquisition, target cycling, lock-on rotation, dynamic camera zoom, smooth shoulder swapping, and controlled input ownership. The system was first validated on SandboxCharacter_Mover_LockOnTest and later promoted into the production pawn.
Problem
The main challenge was that the player character was built on GASP / Mover, which already controls locomotion, rotation modes, camera setup, and input flow. Adding a traditional lock-on system directly on top of it created conflicts instead of clean combat behavior.
Several issues appeared during implementation. The character could enter constant strafe mode because GASP’s internal TwinStickMode path still influenced the rotation mode near enemies. The camera also suffered from readability problems at different target distances, and the first shoulder-swap implementation could ping-pong at close range. Another major issue was input ownership: both the normal look input and the lock-on rotation update were trying to control yaw at the same time, creating visible jitter.
Solution
I solved this by separating the system into reusable pieces. ULockOnComponent owns only lock-on state, target acquisition, target cycling, rotation toward the locked target, and auto-unlock. It does not directly control the camera or input, which keeps the logic modular and easier to reuse.
The camera behavior was moved into UCombatCameraComponent, which drives the pawn-owned SpringArm and Camera. This component handles distance-based camera zoom, FOV interpolation, target offset, shoulder swapping, and smooth restoration when lock-on ends.
To make the system compatible with GASP / Mover, I used Blueprint bridge points instead of rewriting the character template. Functions such as Get_RotationMode, Update_TargetedActor, OnPreSimulateTick, SetupCamera, and the input event graph route into the C++ components. This allowed the system to work with the existing locomotion stack while keeping the implementation focused and maintainable.
Snippet 1 - Tag-based target scoring (Lock-On Component)
AActor* ULockOnComponent::FindBestLockOnTarget() const
{
UWorld* World = GetWorld();
if (!World) { return nullptr; }
FVector ViewOrigin, ViewForward;
GetViewOriginAndForward(ViewOrigin, ViewForward);
TArray<AActor*> Candidates;
UGameplayStatics::GetAllActorsWithTag(World, LockOnTargetTag, Candidates);
AActor* Best = nullptr;
float BestScore = -1e9f;
for (AActor* Candidate : Candidates)
{
if (!IsValid(Candidate) || Candidate == GetOwner()) { continue; }
const float Dist = FVector::Dist(Candidate->GetActorLocation(), ViewOrigin);
if (Dist > LockOnMaxRange) { continue; }
FVector Dir = Candidate->GetActorLocation() - ViewOrigin;
Dir.Z = 0.f;
if (Dir.IsNearlyZero()) { continue; }
Dir.Normalize();
const float Dot = FVector::DotProduct(ViewForward.GetSafeNormal2D(), Dir);
if (Dot < LockOnMinDot) { continue; }
// Score: facing-alignment dominates, proximity is a tiebreaker.
const float DistNorm = 1.f - (Dist / LockOnMaxRange);
const float Score = (Dot * 2.0f) + (DistNorm * 1.0f);
if (Score > BestScore) { BestScore = Score; Best = Candidate; }
}
return Best;
}
Snippet 2 - Distance-driven dynamic combat zoom (Dynamic Combat Camera)
void UCombatCameraComponent::UpdateCombatCamera(float DeltaTime)
{
if (!CameraBoom || !FollowCamera) { return; }
float DesiredArmLength = DefaultCameraArmLength;
float DesiredFOV = DefaultCameraFOV;
FVector DesiredTargetOffset = DefaultCameraTargetOffset;
AActor* LockTarget = LockOnComp ? LockOnComp->GetLockOnTarget() : nullptr;
const bool bHasValidCombatTarget =
LockOnComp && LockOnComp->IsCombatMode() && IsValid(LockTarget);
// Priority: 1) Crossbow aim, 2) Lock-on combat, 3) Exploration default.
if (bCrossbowIsAiming)
{
DesiredArmLength = CrossbowAimArmLength;
DesiredFOV = CrossbowAimFOV;
}
else if (bHasValidCombatTarget)
{
DesiredTargetOffset = CombatCameraTargetOffset;
FVector Me = GetOwner()->GetActorLocation(); Me.Z = 0.0f;
FVector Tg = LockTarget->GetActorLocation(); Tg.Z = 0.0f;
const float Dist2D = FVector::Dist(Me, Tg);
if (bUseCombatCameraZoom && bUseDynamicCombatZoomByDistance)
{
DesiredArmLength = FMath::GetMappedRangeValueClamped(
FVector2D(CombatZoomDistanceMin, CombatZoomDistanceMax),
FVector2D(CombatArmLengthNear, CombatArmLengthFar),
Dist2D);
}
// trimmed: mirrored FOV branch using CombatFOVNear/Far + same Dist2D
}
CameraBoom->TargetArmLength = FMath::FInterpTo(
CameraBoom->TargetArmLength, DesiredArmLength, DeltaTime, CombatCameraInterpSpeed);
CameraBoom->TargetOffset = FMath::VInterpTo(
CameraBoom->TargetOffset, DesiredTargetOffset, DeltaTime, CombatCameraInterpSpeed);
FollowCamera->SetFieldOfView(FMath::FInterpTo(
FollowCamera->FieldOfView, DesiredFOV, DeltaTime, CombatCameraFOVInterpSpeed));
}
Snippet 3 - Auto shoulder swap with four stability guards
void UCombatCameraComponent::UpdateAutoShoulderSwap(float DeltaTime)
{
if (bLockAutoShoulderSwap || !bAutoShoulderSwapEnabled) { return; }
if (!CameraBoom || !LockOnComp || !LockOnComp->IsCombatMode()) { return; }
if (bShoulderSwapInProgress) { return; } // already easing
AActor* TargetActor = AutoShoulderSwapTarget.Get();
if (!IsValid(TargetActor)) { return; }
const float Dist2D = FVector::Dist2D(
GetOwner()->GetActorLocation(), TargetActor->GetActorLocation());
// Guard 1 — close-range suppression: kills the shoulder ping-pong.
if (bDisableAutoShoulderSwapAtCloseRange && Dist2D < AutoShoulderSwapMinDistance)
{ PendingAutoShoulderDirection = 0; return; }
// Guard 2 — cooldown after a previous swap.
const float Now = GetWorld()->GetTimeSeconds();
if (Now < NextAutoShoulderSwapTime) { return; }
// Project the target into screen space, decide desired shoulder.
APlayerController* PC = Cast<APlayerController>(Cast<APawn>(GetOwner())->GetController());
FVector2D Screen; int32 ViewX = 0, ViewY = 0;
if (!PC || !PC->ProjectWorldLocationToScreen(TargetActor->GetActorLocation(), Screen, false)) { return; }
PC->GetViewportSize(ViewX, ViewY);
const float Nx = Screen.X / static_cast<float>(ViewX);
int32 Desired = 0;
if (Nx <= 0.5f - AutoShoulderSwapTriggerDeadZone) { Desired = -1; }
else if (Nx >= 0.5f + AutoShoulderSwapTriggerDeadZone) { Desired = +1; }
// Guard 3 — hysteresis: skip if the target is already on our side.
const bool bWantsRight = (Desired > 0);
if (Desired == 0 || bWantsRight == bRightShoulder) { PendingAutoShoulderDirection = 0; return; }
// Guard 4 — hold time: target must stay past trigger before commit.
if (PendingAutoShoulderDirection != Desired)
{ PendingAutoShoulderDirection = Desired; PendingAutoShoulderStartTime = Now; return; }
if ((Now - PendingAutoShoulderStartTime) < AutoShoulderSwapHoldTime) { return; }
bRightShoulder = bWantsRight;
ApplyShoulderSocketOffset();
NextAutoShoulderSwapTime = Now + AutoShoulderSwapCooldown;
PendingAutoShoulderDirection = 0;
}
Snippet 4 - Mover-compatible yaw steering with preserved pitch (GASP / Mover Compatibility)
void ULockOnComponent::UpdateLockOnRotation(float DeltaTime)
{
if (!bIsCombatMode) { return; }
if (ShouldAutoUnlockCurrentTarget())
{
ExitCombatMode();
return;
}
if (!IsValid(LockOnTarget)) { return; }
APawn* OwningPawn = GetOwningPawn();
if (!OwningPawn) { return; }
FVector ToTarget = LockOnTarget->GetActorLocation() - OwningPawn->GetActorLocation();
ToTarget.Z = 0.f;
if (ToTarget.IsNearlyZero()) { return; }
const FRotator DesiredYawRot = ToTarget.Rotation();
if (AController* Controller = GetOwningController())
{
FRotator Current = Controller->GetControlRotation();
const float PreservedPitch = Current.Pitch;
Current.Pitch = 0.f; Current.Roll = 0.f;
FRotator Desired = DesiredYawRot;
Desired.Pitch = 0.f; Desired.Roll = 0.f;
FRotator NewRot = FMath::RInterpTo(Current, Desired, DeltaTime, LockOnTurnSpeed);
NewRot.Pitch = PreservedPitch; // player keeps vertical aim freedom
Controller->SetControlRotation(NewRot);
}
else
{
// Fallback for un-controlled pawns: steer the actor directly.
const FRotator NewActorRot = FMath::RInterpTo(
OwningPawn->GetActorRotation(), DesiredYawRot, DeltaTime, LockOnTurnSpeed);
OwningPawn->SetActorRotation(NewActorRot);
}
}
Technical Breakdown
Lock-On Component handles target selection through a tag-based candidate search. Valid targets are filtered by range and view direction, then scored using a weighted formula where facing direction is more important than distance. Target cycling uses screen-space ordering, so switching left or right follows the player’s visual layout instead of arbitrary actor order.
Dynamic Combat Camera reacts to the locked target by interpolating arm length and FOV based on target distance. Close enemies pull the camera into a tighter combat framing, while distant enemies push the camera wider so the player can still read the encounter. Unlocking restores the cached default camera values smoothly, using the same interpolation path instead of snapping back.
Auto Shoulder Swap projects the locked target into screen space and changes shoulder side when needed. To avoid unstable camera behavior, I added several stability guards: close-range suppression, hysteresis, hold time, and cooldown. This fixed the close-range shoulder ping-pong problem and made the camera feel more stable in combat.
Input Ownership was another key part of the implementation. While locked on, the lock-on component becomes the only system allowed to control yaw. The player can still control pitch, but yaw input is suppressed through the IA_Look Blueprint path. This removed the two-yaw-writers jitter while preserving normal free-look behavior when the player unlocks.
BOID SYSTEM
Problem
The game needed birds to be more than background decoration. Crows and ravens had to support atmosphere, player guidance, world reactions, and narrative moments without feeling like traditional UI or scripted props.
The challenge was to make the system flexible enough for different gameplay situations: large ambient flocks in the sky, smaller guide flocks that lead the player, perched birds that react to the player, and one-shot crow bursts used during narrative dissolves. At the same time, the system needed to stay performant, tunable, and reliable in both PIE and packaged builds.
Solution
I built the system around a shared dumb bird agent, ABoidAgent, and moved all behavior logic into dedicated manager classes. This kept the bird actor simple and reusable, while each manager could focus on a specific gameplay role.
The Ambient Flock handles continuous sky movement and environmental atmosphere. The Guide Flock leads the player through the world without using UI markers. The Reactive Perched Cluster creates authored disturbance moments when the player enters an area. The Narrative Burst Flock creates one-shot crow bursts for cinematic text dissolves.
To make the system designer-friendly, I added Data Asset profiles for flock personalities, such as forest drifting, path crossing, ridge circling, disturbed pockets, and distant sky flocks. This allowed flock behavior to be tuned through assets instead of hard-coded values.
Technical Breakdown
The system uses a clear manager-owned behavior structure. ABoidAgent is responsible for visual state and animation variables, while managers such as ABoidManager, ABoidGuideManager, and ABoidReactivePerchedCluster control movement, state transitions, spawning, and cleanup.
The Ambient Flock uses flocking forces such as separation, alignment, and cohesion, combined with profile-driven movement patterns, flight bands, edge behavior, and environmental stimuli. A uniform spatial grid replaces the original naive neighbor search, improving performance as flock sizes increase.
The Guide Flock works as an authored state machine: Dormant → Summon → DriftAhead → CirclePoint → FadeOut. This lets birds guide the player toward an objective in a natural way, using movement and flock behavior instead of a quest marker.
The Reactive Perched Cluster is a one-shot environmental reaction system. Birds start perched on authored anchors, become alerted, burst into the air, scatter, and then clean up. This makes the world feel responsive to the player’s presence.
The system also includes animation and performance hardening. Bird agents use skeletal meshes, Animation Blueprint states, takeoff animation support, and optimized tick policies. For larger flocks, I added distance-based LOD, far simplification, and a practical actor-based target of around 300 distant birds, with a future Niagara GPU approach documented for much larger flocks.
Snippet 1 - Data-driven profile resolution with local overrides
void ABoidManager::ResolveProfile()
{
// 1. Default fallback = current manager flat properties.
bUsingResolvedAmbientProfile = false;
ResolvedMaxSpeed = MaxSpeed;
ResolvedMaxForce = MaxForce;
ResolvedNeighborRadius = NeighborRadius;
ResolvedSeparationRadius = SeparationRadius;
ResolvedSeparationWeight = SeparationWeight;
ResolvedAlignmentWeight = AlignmentWeight;
ResolvedCohesionWeight = CohesionWeight;
// trimmed: stimulus, flight-band, edge-behavior, tick-LOD defaults
// 2. Profile data asset overrides defaults wholesale.
if (AmbientProfile)
{
bUsingResolvedAmbientProfile = true;
ResolvedMaxSpeed = AmbientProfile->MaxSpeed;
ResolvedMaxForce = AmbientProfile->MaxForce;
ResolvedNeighborRadius = AmbientProfile->NeighborRadius;
ResolvedSeparationWeight = AmbientProfile->SeparationWeight;
ResolvedAlignmentWeight = AmbientProfile->AlignmentWeight;
ResolvedCohesionWeight = AmbientProfile->CohesionWeight;
// trimmed: full profile copy (~25 fields)
}
// 3. Per-manager local overrides win over the profile.
if (bOverrideMaxSpeed) { ResolvedMaxSpeed = MaxSpeedOverride; }
if (bOverrideMaxForce) { ResolvedMaxForce = MaxForceOverride; }
if (bOverridePatternStrength) { ResolvedPatternStrength = PatternStrengthOverride; }
if (bOverrideEdgeBehaviorMode) { ResolvedEdgeBehaviorMode = EdgeBehaviorModeOverride; }
// trimmed: flight-band overrides
// 4. Safety clamps — negative weights / inverted ranges silently corrected.
ResolvedMaxSpeed = FMath::Max(0.0f, ResolvedMaxSpeed);
ResolvedNeighborRadius = FMath::Max(0.0f, ResolvedNeighborRadius);
if (ResolvedFlightBandMinOffsetZ > ResolvedFlightBandMaxOffsetZ)
{
Swap(ResolvedFlightBandMinOffsetZ, ResolvedFlightBandMaxOffsetZ);
}
}
Snippet 2 - Spatial-grid neighbor search replacing naive O(N²)
void ABoidManager::RebuildSpatialGrid()
{
SpatialGrid.Reset();
SpatialGridCellSize = FMath::Max(ResolvedNeighborRadius, 1.0f);
for (int32 i = 0; i < Boids.Num(); ++i)
{
const ABoidAgent* Agent = Boids[i];
if (!IsValid(Agent)) { continue; }
const FIntPoint Cell = WorldToCell(Agent->GetActorLocation());
SpatialGrid.FindOrAdd(Cell).Agents.Add(i);
}
}
void ABoidManager::GetNeighborsSpatialGrid(int32 AgentIndex, TArray<int32>& OutNeighbors) const
{
OutNeighbors.Reset();
const ABoidAgent* Self = Boids.IsValidIndex(AgentIndex) ? Boids[AgentIndex] : nullptr;
if (!IsValid(Self)) { return; }
const FVector SelfPos = Self->GetActorLocation();
const FIntPoint Center = WorldToCell(SelfPos);
const float R2 = ResolvedNeighborRadius * ResolvedNeighborRadius;
// 3x3 cell sweep — cell size = NeighborRadius, so this is provably complete.
for (int32 dy = -1; dy <= 1; ++dy)
for (int32 dx = -1; dx <= 1; ++dx)
{
const FIntPoint NeighborCell(Center.X + dx, Center.Y + dy);
const FSpatialGridCell* Cell = SpatialGrid.Find(NeighborCell);
if (!Cell) { continue; }
for (const int32 j : Cell->Agents)
{
if (j == AgentIndex) { continue; }
const ABoidAgent* Other = Boids[j];
if (!IsValid(Other)) { continue; }
// 2D ok: agents are XY-locked or flight-band constrained.
const float D2 = FVector::DistSquared2D(SelfPos, Other->GetActorLocation());
if (D2 <= R2) { OutNeighbors.Add(j); }
}
}
}
Snippet 3 - Deterministic per-bird takeoff burst (Reactive Perched Cluster)
FVector ABoidReactivePerchedCluster::ComputeBurstVelocityForBird(
const FVector& BirdLocation, int32 BirdIndex) const
{
const FVector BurstOrigin = IsValid(PerchAnchorRoot)
? PerchAnchorRoot->GetComponentLocation()
: GetActorLocation();
// Outward in XY only — vertical lift comes from BurstUpSpeed.
FVector OutwardDirection = BirdLocation - BurstOrigin;
OutwardDirection.Z = 0.0f;
OutwardDirection = OutwardDirection.GetSafeNormal();
if (OutwardDirection.IsNearlyZero())
{
// Bird sat exactly on the cluster origin — fall back to actor forward.
FVector Fallback = GetActorForwardVector(); Fallback.Z = 0.0f;
OutwardDirection = Fallback.GetSafeNormal();
if (OutwardDirection.IsNearlyZero()) { OutwardDirection = FVector::ForwardVector; }
}
// Deterministic seed: same cluster + same bird index = same direction every reload.
const uint32 Seed = HashCombine(GetTypeHash(GetFName()), ::GetTypeHash(BirdIndex));
FRandomStream RandomStream(static_cast<int32>(Seed));
const float RandomYaw = RandomStream.FRandRange(-BurstRandomYawDegrees, BurstRandomYawDegrees);
const float RandomPitch = RandomStream.FRandRange(-BurstRandomPitchDegrees, BurstRandomPitchDegrees);
FVector JitteredDirection = OutwardDirection.RotateAngleAxis(RandomYaw, FVector::UpVector);
FVector RightAxis = FVector::CrossProduct(FVector::UpVector, JitteredDirection).GetSafeNormal();
if (RightAxis.IsNearlyZero()) { RightAxis = FVector::RightVector; }
JitteredDirection = JitteredDirection.RotateAngleAxis(RandomPitch, RightAxis).GetSafeNormal();
const FVector UpwardVelocity = FVector::UpVector * BurstUpSpeed;
const FVector OutwardVelocity = JitteredDirection * BurstOutwardSpeed;
return UpwardVelocity + OutwardVelocity;
}
World-Space Narrative System
Problem
The game needed narrative and tutorial text to feel diegetic, meaning it had to exist inside the world rather than as a normal screen overlay. These text moments needed to support cinematic intro lines, environmental storytelling, tutorial prompts, and mission-specific beats during the Boar Hunt.
The original challenge was stability. Visual authoring in Blueprint Construction Script could become fragile, especially when changing fonts, outlines, shadows, widget size, or placed-instance values. Runtime-spawned text also needed protection from instantly triggering its own dissolve when spawned near the player. For the Boar Hunt, the narrative text also needed to react to AI mission states without relying on constant Event Tick polling.
Solution
I implemented ANarrativeTextBase as the C++ foundation for the system. C++ owns the unstable and technical parts: visual setup, widget refresh, distance fade, player-facing rotation, overlap filtering, spawn grace period, and Boar state binding.
Blueprint is kept focused on the presentation chain. BP_WorldNarrativeText owns the dissolve event: spawning VFX, playing narrative audio, triggering a crow burst through the BOID system, waiting briefly, and destroying the actor.
For the Boar Hunt sequence, the Boar can act as a director. When it enters a specific mission state, it can spawn narrative text at runtime, seed the correct line before BeginPlay, and bind the text to its own OnMissionStateChanged delegate. This makes the narrative react to gameplay events without per-frame polling.
Technical Breakdown
Snippet 1 - One-shot visual baseline (font, outline, shadow, slot)
void ANarrativeTextBase::ApplyNarrativeVisuals()
{
UWidgetComponent* WC = ResolveTextWidgetComponent();
if (WC == nullptr) { return; }
WC->SetDrawSize(ProjectedDrawSize);
WC->SetDrawAtDesiredSize(false);
WC->SetManuallyRedraw(true);
WC->SetTintColorAndOpacity(
FLinearColor(NarrativeTint.R, NarrativeTint.G, NarrativeTint.B, 1.0f));
UUserWidget* UW = WC->GetUserWidgetObject();
if (UW == nullptr) { WC->InitWidget(); UW = WC->GetUserWidgetObject(); }
if (UW == nullptr) { return; }
UTextBlock* TB = ResolveNarrativeLabel(UW);
if (TB == nullptr) { return; }
// Centre the TextBlock inside the fixed render target so VFX spawned at
// TextWidget location emerge from the *visual* text centre, not the corner.
if (UCanvasPanelSlot* CanvasSlot = Cast<UCanvasPanelSlot>(TB->Slot))
{
CanvasSlot->SetAnchors(FAnchors(0.5f, 0.5f));
CanvasSlot->SetAlignment(FVector2D(0.5f, 0.5f));
CanvasSlot->SetPosition(FVector2D::ZeroVector);
CanvasSlot->SetAutoSize(true);
}
TB->SetText(DisplayText);
TB->SetJustification(ETextJustify::Center);
TB->SetAutoWrapText(false);
TB->SetLineHeightPercentage(LineHeightPercentage); // 0.75 = Fraktur sweet spot
FSlateFontInfo FontInfo = TB->GetFont();
if (NarrativeFontObject != nullptr) { FontInfo.FontObject = NarrativeFontObject; }
FontInfo.Size = FontSize;
FontInfo.OutlineSettings.OutlineSize = NarrativeOutlineSize;
FontInfo.OutlineSettings.OutlineColor = NarrativeOutlineColor;
FontInfo.OutlineSettings.bMiteredCorners = false;
TB->SetFont(FontInfo);
TB->SetShadowOffset(NarrativeShadowOffset);
TB->SetShadowColorAndOpacity(NarrativeShadowColor);
WC->RequestRedraw(); WC->RequestRenderUpdate(); WC->MarkRenderStateDirty();
// trimmed: WITH_EDITOR Modify() block
}
Snippet 2 - Boar-driven dissolve binding (gameplay-coupled narrative)
// --- BoarCharacter.cpp: spawn the narrative beat on FakeDead ---
if (NewState == EBoarMissionState::FakeDead && DeathNarrativeClass != nullptr)
{
ANarrativeTextBase* Spawned = ANarrativeTextBase::SpawnAtRuntime(
this, DeathNarrativeClass,
GetActorLocation() + DeathNarrativeSpawnOffset,
GetActorRotation(),
DeathNarrativeText,
ENarrativeTrigger::PassThrough);
if (Spawned != nullptr)
{
Spawned->BindToBoarStateChange(this, DeathNarrativeDismissOnState);
}
}
// --- NarrativeTextBase.cpp: deferred spawn pre-sets DisplayText + TriggerMode ---
ANarrativeTextBase* ANarrativeTextBase::SpawnAtRuntime(
UObject* WorldContextObject, TSubclassOf<ANarrativeTextBase> Class,
FVector Location, FRotator Rotation,
FText InDisplayText, ENarrativeTrigger TriggerMode)
{
if (Class == nullptr || WorldContextObject == nullptr) { return nullptr; }
UWorld* World = GEngine->GetWorldFromContextObject(WorldContextObject, EGetWorldErrorMode::ReturnNull);
if (World == nullptr) { return nullptr; }
const FTransform SpawnTransform(Rotation, Location);
ANarrativeTextBase* Actor = World->SpawnActorDeferred<ANarrativeTextBase>(
Class, SpawnTransform, nullptr, nullptr,
ESpawnActorCollisionHandlingMethod::AlwaysSpawn);
if (Actor == nullptr) { return nullptr; }
Actor->DisplayText = InDisplayText;
// Reflected so BP-only TriggerMode property still gets seeded pre-construction.
if (FByteProperty* TM = FindFProperty<FByteProperty>(Actor->GetClass(), TEXT("TriggerMode")))
{
TM->SetPropertyValue_InContainer(Actor, static_cast<uint8>(TriggerMode));
}
UGameplayStatics::FinishSpawningActor(Actor, SpawnTransform);
return Actor;
}
// --- HandleBoundBoarStateChanged: dissolve when bound state arrives ---
void ANarrativeTextBase::HandleBoundBoarStateChanged(EBoarMissionState NewState)
{
if (NewState != CachedDismissState) { return; }
if (UFunction* Fn = FindFunction(TEXT("TriggerDissolve"))) { ProcessEvent(Fn, nullptr); }
}
Snippet 3 - Spawn safety: grace period + player-only overlap filter
// --- BeginPlay: disable collision and arm the grace timer ---
void ANarrativeTextBase::BeginPlay()
{
Super::BeginPlay();
ApplyNarrativeVisuals();
if (OverlapGracePeriod > 0.0f)
{
SetActorEnableCollision(false);
if (UWorld* World = GetWorld())
{
World->GetTimerManager().SetTimer(OverlapGraceTimerHandle, this,
&ANarrativeTextBase::HandleOverlapGraceExpired, OverlapGracePeriod, false);
}
}
}
// --- Grace expires: re-enable collision + force-recompute the TriggerBox.
// UE only fires NotifyActorBeginOverlap on a no-overlap -> overlap transition,
// so a player already inside the volume gets no begin event. UpdateOverlaps fixes it.
void ANarrativeTextBase::HandleOverlapGraceExpired()
{
SetActorEnableCollision(true);
UPrimitiveComponent* TriggerComp = nullptr;
TArray<UPrimitiveComponent*> PrimitiveComponents;
GetComponents<UPrimitiveComponent>(PrimitiveComponents);
for (UPrimitiveComponent* Candidate : PrimitiveComponents)
{
if (Candidate && Candidate->GetName() == TEXT("TriggerBox"))
{ TriggerComp = Candidate; break; }
}
if (TriggerComp) { TriggerComp->UpdateOverlaps(); }
else { UpdateOverlaps(true); }
}
// --- Player-only overlap filter: ignore everything that isn't the player pawn. ---
void ANarrativeTextBase::NotifyActorBeginOverlap(AActor* OtherActor)
{
if (OtherActor == nullptr) { return; }
APawn* PlayerPawn = UGameplayStatics::GetPlayerPawn(this, 0);
if (OtherActor != PlayerPawn) { return; }
Super::NotifyActorBeginOverlap(OtherActor);
}
Native visual authority is handled through ApplyNarrativeVisuals, which centralizes text, font, outline, shadow, tint, draw size, and widget layout. This makes placed narrative actors more reliable in the editor and keeps designer-facing properties consistent.
World-space behavior includes distance-based fade, yaw-only billboarding toward the player camera, and an “always-awake” widget opacity setup so faded text can appear and disappear reliably.
Spawn safety prevents runtime-spawned text from dissolving immediately. The actor disables collision briefly on spawn, then re-enables overlap checks after a short grace period and filters overlaps so only the player can trigger the dissolve.
Boar-driven narrative uses an event-driven delegate connection. The Boar spawns the text and the text listens for a specific mission-state transition, such as the Boar waking up, before triggering the dissolve.
Dissolve presentation is handled in Blueprint: the text can dissolve into SnowBurst-style VFX, narrative audio, and a one-shot crow burst using the BOID system, then cleanly destroy itself.