For our enemies we wanted to replicate the mimics from Prey (2017), which are a sort of alien creature that can hide as normal objects and jump out at you when you're close. It was decided that the best way to do their movement was to have a node graph. The level designers designed node graphs in Unreal Engine, where they could also set model IDs for nodes and if a mimic should spawn there. I then wrote the code for exporting the node graphs to binary files and importing the binary files into our game.
myNodes.Init(nodeCount); myNodes.Resize(nodeCount); for (int index = 0; index < nodes.Size(); ++index) { const Jazz::NodeData& node = nodes[index]; CU::Vector3f position(node.position.x, node.position.y, node.position.z); myNodes[index].myPosition = position; myNodes[index].myType = node.type; myNodes[index].myIndex = index; myNodes[index].myNeighbors.Init(4); //arbitrary number myNodes[index].myIsTaken = false; if (node.isMimic) { myMimicSpawnPositions.Add(position + CU::Vector3f(1.0f, 0.0f, 0.0f)); } if (node.type >= 0) { myHidingNodeIndices.Add(index); } } for (int index = 0; index < connections.Size(); ++index) { const Jazz::ConnectionData& connection = connections[index]; myNodes[connection.firstNode].myNeighbors.Add(connection.secondNode); myNodes[connection.secondNode].myNeighbors.Add(connection.firstNode); }
I wrote the AI for the mimics using a behaviour tree. Specifically, I used a single header library called BrainTree, but I replaced the blackboard with an opaque dictionary to allow for more flexibility. When mimics spawned they would hide immediately, and then wait for the player to come close enough, before transforming back to chase and attack them. After attacking there was a 30% chance the mimics would try to hide, with the other 70% leading to chasing and attacking the player again.
myTree = BrainTree::Builder() .composite<BrainTree::Selector>() .decorator<MimicAI::FailFirst>() .composite<BrainTree::Sequence>() .leaf<MimicAI::ChangeAnimation>(blackboard, myRunAnimationName) .leaf<MimicAI::ChasePlayer>(blackboard, viewDistance, attackDistance, speed) .leaf<MimicAI::LookAtPlayer>(blackboard) .leaf<MimicAI::PlayAnimation>(blackboard, jumpStartAnimation) .leaf<MimicAI::ChangeAnimation>(blackboard, jumpAnimation) .leaf<MimicAI::AttackPlayer>(blackboard, std::bind(&MimicControllerComponent::Damage, this, _1, _2, _3, _4), jumpHeight) .leaf<MimicAI::PlayAnimation>(blackboard, jumpEndAnimation) .leaf<MimicAI::ChangeAnimation>(blackboard, myIdleAnimationName) .decorator<MimicAI::RandomlyFail>(hideAfterAttackPercentage) .leaf<MimicAI::Wait>(blackboard, waitAfterAttackTime) .end() .end() .end() .composite<BrainTree::Sequence>() .leaf<MimicAI::ChangeAnimation>(blackboard, myRunAnimationName) .leaf<MimicAI::WalkToHidingPlace>(blackboard, aggroDistance, viewDistance, speed) .leaf<MimicAI::ChangeAnimation>(blackboard, myIdleAnimationName) .leaf<MimicAI::ChangeToHidingModel>(blackboard, transfromToAnimation, myObjectID, transformParticleOffset) .leaf<MimicAI::WaitUntilPlayerIsClose>(blackboard, aggroDistance) .leaf<MimicAI::ChangeToNormalModel>(blackboard, normalModelID, transfromFromAnimation, transformParticleOffset) .end() .end() .build();
When mimics hid they would first ask the node graph for a path to a node with a model ID, which was as close as possible while being farther from the player than the mimic could see. They would then tell the node graph that no-one else could use that node, and to avoid mimics hiding in the same place twice in a row, they didn't tell the node graph that a node was free again until they got a new hiding node (or when dying). After getting a hiding node they would walk to the node and transform to the correct model on arrival. Their transformation would also include replacing their normal dynamic collider with a static collider matching their new model. The half extents for this collider could be fetched from the model, due to it being calculated for culling purposes.
void MimicAI::WalkToHidingPlace::initialize() { CU::Vector3f mimicPosition = *blackboard->GetValue<CU::Vector3f>(static_cast<short>(Key::MimicPosition)); CU::Vector3f playerPosition = Jazz::AI::PollingStation::GetInstance()->GetPlayerPosition(); int hideModelID; int hideNodeIndex; if (!Navigation::GetInstance()->GetPathToClosestHidingNode(mimicPosition, playerPosition, myViewDistanceSquared, myPath, hideModelID, hideNodeIndex) || (playerPosition - myPath[0]).LengthSqr() < myAggroDistanceSquared) { myPath.RemoveAll(); blackboard->SetValue(static_cast<short>(Key::HideModelID), -1); return; } Navigation::GetInstance()->SetNodeIsTaken(*blackboard->GetValue<int>(static_cast<short>(Key::HideNodeIndex)), false); Navigation::GetInstance()->SetNodeIsTaken(hideNodeIndex, true); blackboard->SetValue(static_cast<short>(Key::HideModelID), hideModelID); blackboard->SetValue(static_cast<short>(Key::HideNodeIndex), hideNodeIndex); }
The mimics also used the node graph when chasing the player, but chose a straight path when they could. If the player left the mimics view distance while being chased by them, the mimics would continue running to where they last the player, and if they still couldn't see them, they would hide. When the player got close enough for a mimic to attack them, the mimic would jump up while doing damage. The initial velocity of the jump and the gravity was calculated using the length of the animation as well as the jump height, which was specified in a JSON-file.
myCountdown = (*blackboard->GetValue<ModelComponent*>(static_cast<short>(Key::ModelPtr)))->GetCurrentAnimationDuration(); mySpeed = 4.0f * myJumpHeight / myCountdown; myGravity = 8.0f * myJumpHeight / (myCountdown * myCountdown);