When we swap weapons, we first need to check if we have more than one weapon or if we are
locked out of animation, which just checks if an animation is currently playing or if the weapon is aimed.
Then, we turn off the ability to shoot and make the current weapon invisible. After that we have the actual
swap logic, and if we exceed the last position of our inventory we reach a pointer to the first slot. Then
we set the current weapon to be visible and update our hud sprite on the bottom right to match the current weapon.
NotifyWeaponChange() locks us out of other animations by setting bIsSwapping to true, then broadcasts a custom FAnimationRequest
delegate that plays our weapon equip animation based on the gun, and finally broadcasts a delegate that updates
the IK system with required data based on the gun's position.
When we pick up a weapon much of the same logic occurs,
with some necessary initialization setup like enabling the weapon's mesh to simulate collision.
This is the bulk of our context class for the strategy pattern. We have logic for picking up and dropping
weapons, and notifying the HUD through the combat component.
Our AGun class is heavier. This is where we have IK logic, hitscan logic, as well as specific behaviors
like adding/subtracting ammo and reloading, as well as an overridden HUD update that displays ammo
remaining.
AMeleeWeapon contains a collider, and at it's core all it needs to do is call
OtherActor->TakeDamage() and make sure that it blacklists the player wielding the
weapon or any actors that it has currently hit during the swing, which is stored in HitActors.
If the OtherActor->TakeDamage() is an Unreal thing, and the beauty of it is if the other
actor does not have health then nothing happens. We don't need to do the checks for that in
our melee weapon class, leaving it simple.
The Problem:
There are many objects in Project Nova that need to be interacted with by
the player. As a space engineer, there are always buttons that need pressing
or fuseboxes that need fixing. Thus, we needed a system where the player can scan the
environment around them and be aware of what they can and can't interact with.
But there is a problem. If you look at the image above, we have two different interactive objects,
one shotgun and one lever. If you were to look at one of those objects you might think to do this:
However, the more interactive objects you have in the world the heavier and heavier the system gets.
And what if you change your interact button to something else? What if AShotgun is a REALLY heavy class?
Then everytime you look at the shotgun you need to know everything public about the shotgun.
That's really problematic!
The Solution:
The IInteractiveObject interface solves the problem of having to check for each scanned object for
each possible interactive item. All it needs to know is that it's an interactive object, and as long
as each of those objects have some ground similarities, the scanning of objects logic could care less
about what it's looking at! As you can see above, IInteractiveObject is slim. The key components of
each interactive object are:
- A HUD prompt with a prefix, key, suffix and name. i.e. "Press Space to Vault"
- A method that does something when the player looks at it
- A method that does something when the player looks away
-
A method that does something when the player presses the desired key to interact with the object,
and a delegate that fires with it as well
-
A method that does something in blueprint when the player interacts
- A bool that determines whether or not we can interact with that object.
-
A desired binding index (an index in Unreal's list of inputs in Project Settings->Engine->Input)
i.e. 0 is jump, 1 is shoot, 2 is interact
That's all the shared logic we need! Let's see the base implementation of this interface in InteractiveObject.cpp::
IInteractiveObject::ReceiveLookedAt(APawn* EventSender) is a method that is called
once whenever an interactive object is looked at. In order for them to each have a custom
key for interaction, we need to get a binding of that specific key, which is done in line 6.
then we use the FInputActionHandler named Signature as a delegate to hook up the key press
to the interactive object's InteractionEvent() function. We add this binding to EventSender which
is the pawn who interacted with the object, and store a handle of that binding so we can remove it later.
Whenever the shooter looks away from the object, the binding is removed. This way, we don't have any clutter of
extra bindings adding up and up causing the list of inputs to swell. This way also makes sure that you don't trigger
the interaction event of a pistol when you pull a lever because you just looked at a pistol and
the interact buttons are both bound to the same key.
By default, our binding name is "Interact." This will return an input binding that is hooked
up with the key "E" as shown below:
AInteractableGeneratorPiece is an example of a very barebones interactive object. It has a BeginPlay()
and its interaction event. Line 17 triggers any logic we would want the designers to be able to modify
through blueprint, shown below:
The obvious limitation to this approach is that we are binding to these input bindings based on strings.
For example, FInputActionBinding("Interact", IE_Pressed) works, but if I misspell interact I'm going to
be very confused why it's not working. Or, if we change the name of the binding in the future we would need
to change every string binding in our code.
There are always decisions to be
made about the level of abstraction for gameplay systems - abstract it too much and your code
can run slow, and can become hard for others to follow. Abstract too little and you won't be
getting as much performance as you should be. Although it's not perfect, I think the interactive object
system I created is balanced in this way. Not too hard to read, not too slow. I learned a lot about
SOC by doing this.
I learned a lot about SOC by doing this. There are pros and cons to both Unity and Unreal,
but one thing that certainly is a plus in my book with Unreal C++ is the enforcement of OOP.
In Unity, anything is a GameObject and anything can run any kind of script. It's much more
relaxed and thus easier to write messy code.
Like any other game, we created a state system to manage things like animation and movement.
A benefit to breaking down each condition that the character is in into states is that you
can specify exactly what limitations and what adaptations the player currently has.
An example of what I mean is if the player has less than 50% health remaining,
then they enter the crippled state.
Their walking animation may be different,
they walk 30% slower, maybe they can no longer jump. The point is, separating those limitations
out into their own states enforces organization of those player conditions. More importantly, we can
enforce which states the player can and can't enter from that state, which means we don't have to
have 100 bools and if statements spread throughout the player class determining what specific behavior
is enabled or disabled.
Prototype vent interaction
For an example of our state system, we are going to look at the venting state I wrote.
The process works like this:
- Player walks up to fusebox
- Player interacts with fusebox, animation plays
- Vent grates turn off and become interactable after anim is done
- Player interacts with vent
- Player lerps down to vent spline position and rotation
- Player crawls to end of vent spline
- Player lerps to standing position
while the player is in the vent phase, we have to make sure they can't just stand up
and leave. The idea is that their movement is restricted to a 1D axis on that spline
and they cannot turn around, because crawling in a vent should feel restrictive.
When the player interacts with the fusebox, the vent becomes interactable. When the player
interacts with the vent the method above fires. If the player is the correct type of pawn we
are looking for, they enter the vent state, then we make sure the vent is no longer interactable,
turning off the grate collision as that happens.
USVentState inherits from USProneState because we need it to do very similar things
as being prone. However, we don't want the player to move around horizontally for example,
so we don't want to use everything. We do however benefit from the same player input reference that
prone uses to transform the players position and rotation, as well as set new states for the player
to be in.
USVentState has a lot of connected parts. This would normally mean that entering the vent
state is slugglish, and we would be limited if we have a scenario where we want the player to
enter and exit several states very quickly. The good news is that we don't have to inherit all
of the functionality we don't need. We simply just override the base class functions and don't
call Super::FunctionName().
The first thing we do is call Super::OnEnter(), which triggers the OnEnter() of USProneState, USTuckstate and
USMovementState. What this does essentially is sets the limitations of our player's movement component.
For example, USTuckState calls Movement->bIsInTuckTransition = true; Which stops the player from being
able to run.
Next, as long as the thing we are looking at is a vent and it's valid, we get the spline that's
connected to the vent then set up some variables for lerping position. After that we set the
current crawl direction based on which vent was entered. To represent the position along the spline,
we use a float called progress. If we enter the right vent, progress is equal to the length of the spline.
Otherwise, it's 0. We set our lerp rotation then set the location where we need to lerp into a standing
position.
The delayed action manager is essentially a custom lerp system we created
that takes in a float amount of time, then a delegate function with any parameters
the function takes in. USVentState::LerpToCrawl() changes the player's position and rotation
over time. Over the duration of LerpToCrawlSpeed's value, which in this case is 2.f.
Finally, this is how the player moves along the spline. Input->MoveY is the float between 0 and 1
that captures the amount of input on the W and S keys, or forward and backward as we percieve as the player.
we add that to progress then get a point along the spline with that value, and set the player's position
to that. If we are going left to right, forward means increasing progress so we add,
going right to left forward means decreasing progress so we subtract.
Common Area
Lower Level
Generator Room
Project Nova was a great learning experience for me. Not only about learning more Unreal and C++, but
about working as a team. We did weekly stand up meetings and had two week build sprints to keep things
organized. I also learned a lot from the other programmer on the project,
Brendan Lienau who had a lot of prior experience with Unreal.
That completes my code demonstrations for Project Nova. If you have the time, please check out my other
works. Also, feel free to reach out
to me at any time for questions. Thank you for reading!