…and other things.
Opening a door (or window, or crate, or locker etc.) is a very simple concept: Character approaches door, character triggers door, door opens. Where it gets interesting is when we consider different types of door. A hinged door that rotates about a pivot point needs a different implementation to, say, a sliding door that moves into the door frame or an iris style door that opens/closes from all sides simultaneously.
As a game developer I wanted a simple, lightweight solution that could be extended to any kind of door I could conceive and did not present a major run-time performance hit. Doors in a level represent a partition between one space and another. Most of the time they’re passive devices and only really have a role in the scene when the player (or NPC) wants to pass through them.
In an attempt to discover the ‘best’ way to implement the humble door I want to explore them in several different ways.
Design and Coding
A door is a very simple thing. It’s either open, closed, locked, jammed or broken. There are only certain things we can do to a door: open it, close it, lock/unlock it, and break it down. I’m assuming here that a jammed door is permanently closed and, for all intents and purposes in our game world, is semantically equivalent to a wall. Similarly, a broken door is semantically equivalent to a hole in the wall. For this particular post I am not going to consider the case of broken nor jammed doors.
Figure 1 presents a simplified view of the door states identified above.
In our game world, unless the door is a force field it is unlikely to open or close instantaneously. We can therefore add two imtermediate states to the model to represent the door opening and door closing. It is in these two states that the on-screen model moves through some form of animation or effect. (See Animation below). Figure 2 extends our model to include these two transient states.
In code, ths is very easy to implement. In the code snippet given in Figure 3, the triggered
flag is set when the payer walks over the trigger to open the door. Having passed through, when the player steps off the trigger the triggered
flag is set to false. This code fragment also uses an enum to represent the different states and improve the overall readability of the code (Figure 4).
bool triggered = PressurePlate->IsOverlappingActor(player);
switch (doorState)
{
case Open:
if (!triggered)
{
doorState = Closing;
}
break;
case Opening:
if (openDoor(DeltaTime))
{
doorState = Open;
}
break;
case Closing:
if (closeDoor(DeltaTime))
{
doorState = Close;
}
break;
case Closed:
if (triggered)
{
doorState = Opening;
}
break;
default:
break;
}
Fig 3. THORN, 2021 Door control state machine – code snippet
(Please note that in Figure 3, door locking and unlocking have been ommited for clarity.)
class CALICHE_API UOpenDoor : public UActorComponent
{
GENERATED_BODY()
private:
enum State
{
Open,
Opening,
Closing,
Closed,
Locked,
NumStates,
};
// Rest of class declaration
...
Fig 4. THORN, 2021 Door state declaration
The two functions, openDoor()
and closeDoor()
are the main workhorses as they are responsible for controlling the motion of the door on-screen. The switch statement is there to control when they get called.
In this implementation, openDoor()
and closeDoor()
are defined within the same class as the state machine in Figure 3. But they needn’t be, which brings me on to…
Extensibility
One advantage of the state machine approach discussed above is that it decouples (separates) the control of the door from the motion of the door. The code fragment shown in Figure 3 can be used with any type of door whether it be a sliding door, hinged door, crate, locker, barrier or anything else that opens and closes. All the game developer needs to do is write the implementation for the openDoor()
and closeDoor()
functions and plug them into the above code.
Separating door state from animation
There are several ways we can go about this.
Figure 5 show the traditional IS-A style of inheritance model where we have a generic door, ADoor
, that implements the state machine within its Tick()
function. Every door we create is derived from ADoor
so as to use its state machine and provides its own implementation for openDoor()
and closeDoor()
class ADoor
{
public:
void Tick(float DeltaTime);
protected:
virtual bool openDoor (float DeltaTime) = 0;
virtual bool closeDoor(float DeltaTime) = 0;
};
Fig 6. THORN, 2021 Simplified ADoor
base class declaration
class AHingedDoor: public ADoor
{
protected:
virtual bool openDoor (float DeltaTime);
virtual bool closeDoor(float DeltaTime);
};
Fig 7. THORN, 2021 Example class declaration for a hinged door
Figure 6 and Figure 7 are given as examples of how one would declare two of the door classes shown in Figure 5. They are for illustrative purposes only and not representative a real implementation for a game engine.
The disadvantage with this design is that whilst we have separated the state machine from the animation logic, they are still very tightly coupled. With this arrangement it is not possible to modify the state machine behaviour for, say, a hinged door without affecting the behaviour of the sliding door (and all other classes derived from the ADoor
base class). The architecture discussed in the next section addresses this by switching the relation ship from IS-A to HAS-A.
Decoupling door state from animation
Figure 8 is slight variation on our previous design. Here, we have split the OpenDoor()
and CloseDoor()
animation functions into completely separate classes, each of which present the same interface, IDoorAnimation
, for our door class, ADoor
.
Each door, ADoor, has an animation to play when the door opens and closes. It doesn’t need to know what the animation is or does, just that the animation implementation provides the functions declared in the interface class, IDoorAnimation
.
From a coding perspective, apart from the addition of the interface class declaration, the code changes are very subtle:
class ADoor
{
public:
void Tick(float DeltaTime);
private:
IDoorAnimation* doorAnimation;
}
Fig 9. THORN, 2021 Modified ADoor
class declaration to incorporate animation interface
// ADoor::Tick() snippet (see Figure 3)
case Opening:
if (doorAnimation->OpenDoor(DeltaTime))
{
doorState = Open;
}
break;
case Closing:
if (doorAnimation->CloseDoor(DeltaTime))
{
doorState = Close;
}
break;
Fig 10. THORN, 2021 Modified code fragment of ADoor::Tick()
showing how to call animation functions
class IDoorAnimation
{
public:
virtual bool OpenDoor (float DeltaTime) = 0;
virtual bool CloseDoor(float DeltaTime) = 0;
};
Fig 11. THORN, 2021 IDoorAnimation
abstract interface declaration
class AHingedDoorAnimation: public IDoorAnimation
{
protected:
virtual bool OpenDoor (float DeltaTime);
virtual bool CloseDoor(float DeltaTime);
};
Fig 12 THORN, 2021 Declaration of hinged door animation class
We now have the ability to vary the state machine in ADoor
independently from the door animations. This makes the design significantly more flexible and far easier to test as test cases can work with each class in isolation through the use of mock objects.
So far in this post we have only looked at the state machine and how the state machine runs the on-screen animation to show the door opening and closing. What the animation does and how it’s implemented is the subject of the next section.
Animation
How we animate a door will depend entirely on the type of door it is. A sliding door will require a different animation to a hinged door which will require a different animation to a western saloon door which will require a different animation to a crate lid. Rather than attempt to provide solutions for all of these here, I want to instead focus on what we need to consider.
For example, if we take the case of a simple sliding door. It will have two pieces, each needing to slide horizontally into the door frame/wall to give the player the impression the door is opening.
If we assume +Y is forwards, -X is left and +X is right, we could write an OpenDoor()
function that simply moves the door panels along the x-axis to open the door (Figure 14). Here, both door panels are assumed to be the same width and move at a rate set by the member variable, Speed
. When we detect the end of the motion, we return true
to indicate to the state machine the animation has finished (see Figure 10).
bool USlidingDoorAnimation::OpenDoor(const float DeltaTime)
{
float distance = Speed * DeltaTime;
doorLeft.Current.X -= distance;
DoorLeft->SetActorLocation(doorLeft.Current);
doorRight.Current.X += distance;
DoorRight->SetActorLocation(doorRight.Current);
if (doorLeft.Current.X <= doorLeft.Open.X)
{
return true;
}
return false;
}
Fig 14. THORN, 2021 OpenDoor()
animation
This works, and works very well but only for the case where the door slides along the x-axis. What if the level designer rotated the door by 90° or 180°? What if the scene needed the door to be set at an angle, say 45° or 22.638°? Suddenly, our working door open animation no longer works.
We need a better solution.
Figure 15 shows a plan view of the door, represented here as a wedge shape with the insertion/pivot point denited by the dot. We can use the orientation of the door panel to create a motion vector for the animation that we can the use to show the door opening and closing.
With this technique, the animation will be correct irrespective of the rotation of the door around the vertical axis.
void USlidingDoorAnimation::BeginPlay()
{
Super::BeginPlay();
// ...
player = GetWorld()->GetFirstPlayerController()->GetPawn();
FRotator rotation = DoorLeft->GetActorRotation();
FVector motion = FVector::ForwardVector;
// Match door orientation
motion = rotation.RotateVector(motion);
// We need to move the door perpendicular to it's orientation
motion = FRotator(0.0f, 90.0f, 0.0f).RotateVector(motion);
doorLeft.Closed = DoorLeft->GetActorLocation();
doorRight.Closed = DoorRight->GetActorLocation();
doorLeft.Open = doorLeft.Closed - (motion * OpenAmount);
doorRight.Open = doorRight.Closed + (motion * OpenAmount);
distance = 0.0f;
}
Fig 16. THORN, 2021 One time setup code to determe the direction of motion
void USlidingDoorAnimation::OpenDoor(const float DeltaTime)
{
distance += OpenSpeed * DeltaTime;
FVector current = FMath::Lerp(doorLeft.Closed, doorLeft.Open, distance);
DoorLeft->SetActorLocation(current);
current = FMath::Lerp(doorRight.Closed, doorRight.Open, distance);
DoorRight->SetActorLocation(current);
if (distance >= 1.0f)
{
doorState = Open;
distance = 0.0f;
}
}
Fig 17. THORN, 2021 Finished OpenDoor()
animation code
Conclusion
To many, this may seem trivial and obvious. Possibly a waste of time documenting such a fundamental item such as opening a door in a game engine. For me, I found it invaluable to reinforce my knowledge and really drive home an important lesson.
My original door opening code did exactly as I described here: it simply pushed the door along a particular axis. In face, Figure 14 is the code I wrote to do this (slightly modified for clarity and consistency). Whilst it worked is, it only worked in a very specific case.
The big learning point for me was to really drive home how important it is to stop thinking of game geometry in cartesian coordinates but to think in vectors, specifically magnitude and direction. Only then is it possible to really create code to move an on-screen avatar, whether it be a door, or an NPC or the player, that works in all cases.
The final modification I made to the code (not shown above) was to add an easing function to ‘juice up’ the door motion which immeditely made it feel much heavier. To discover more about that, I’ve discussed it here: Juicy Lerps – The Art of Spicing up Motion
List of Figures
Figure 0. THORN, 2021 Heavy Doors — Greybox Prototype Walk Through
Figure 1. THORN, 2021 Simplified door states
Figure 2. THORN, 2021 Door state machine, including motion states
Figure 3. THORN, 2021 Door control state machine – code snippet
Figure 4. THORN, 2021 Door state declaration
Figure 5. THORN, 2021 Traditional IS-A class hierarchy
Figure 6. THORN, 2021 Simplified ADoor
base class declaration
Figure 7. THORN, 2021 Example class declaration for a hinged door
Figure 8. THORN, 2021 Decoupling door state from animation
Figure 9. THORN, 2021 Modified ADoor
class declaration to incorporate animation interface
Figure 10. THORN, 2021 Modified code fragment of ADoor::Tick()
showing how to call animation functions
Figure 11. THORN, 2021 IDoorAnimation
abstract interface declaration
Figure 12 THORN, 2021 Declaration of hinged door animation class
Figure 13. THORN, 2021 Double sliding door
Figure 14. THORN, 2021 OpenDoor()
animation
Figure 15. THORN 2021 Improved door opening animation strategy
Figure 16. THORN, 2021 One time setup code to determe the direction of motion
Figure 17. THORN, 2021 Finished OpenDoor()
animation code
Photo by Jeremy Bishop on Unsplash