This project is a third-person action game developed in a custom C++ engine as part of a team project.
My primary focus was implementing AI behavior and navigation systems, enabling enemies and the player to move and interact within the game world.
The navigation system is implemented as a pipeline from editor-based generation to runtime usage.
In the editor:
Scene objects (TGOs) are processed to extract geometry. Obstacle volumes define areas where navmesh generation is blocked. Geometry is passed into Recast, which rasterizes it into voxels and generates a navmesh. The resulting navmesh is exported as an OBJ file per scene
At runtime:
The navmesh is loaded through a navigation manager. Entities query the navmesh to find paths using A*. Paths are refined using funneling for smoother movement
This system is used by both enemies and the player.
Material Editor
Navmesh Generation & Tools
Enemy AI
Player Interactions
Enemy behaviour was split into the behaviour component and behaviour system. The component contained all data needed by the behaviour such as current state, attack info and health. The system checks what type of behaviour the entity is and runs corresponding main behaviour function. The main behaviour function updates basic data and does state checks aswell as executes their current state.
Using the navmesh and simple steering behaviour the enemies avoid walking the same paths and instead travel in a group, walking around the others for open areas to attack the player.
The navmesh generation allows for navmesh to be created along most terrain and with the ability to restrict areas the level designers could create a traversable world for both AI and player, The navmesh was used by both player and enemies for movement so making sure the navmesh generated correctly was very important and took alot of fine tuning.
The last big main thing I worked on was the material editor which renders and allows the user to edit the engine's custom materials containing most rendering options that could be needed for a material.
I also worked on a lot of minor implementations and varius editor features to help the level designers during the project such as different properties that can be added to objects and quality of life fixes.
4 different enemies with different behaviours and attacks
1 Ranged/ 1 AOE/ 2 melee
Navmesh generation with editor obstacles to restrict generation
Create and edit materials to place on game objects
void BigJumpingEnemyBehaviour::RunBehaviour(const float aDeltaTime, const WASD::UniqueID aID)
{
//---------------------Get entity and its data----------------------------
const WASD::Entity entity(aID);
auto* behaviourComponent = entity.AccessComponent<WASD::BehaviourComponent>();
//auto* transformComponent = entity.GetComponent<WASD::TransformComponent>();
auto* behaviourData = std::any_cast<JumpingEnemyBehaviourData>(&behaviourComponent->AccessEntityBehaviourData());
//---------------------Init data if it has no value-----------------------%
if (!behaviourData->isInitialized)
{
Initialize(aID, *behaviourData);
}
//---------------------Run enemy state-----------------------
Update(aDeltaTime, *behaviourData, aID);
BehaviourHelpers::UpdateHealthBar(aID, behaviourData->health, behaviourData->maxHealth);
JumpingEnemyBehaviourState newState = behaviourData->myCurrentState;
switch (behaviourData->myCurrentState)
{
case JumpingEnemyBehaviourState::Idle:
{
newState = Idle(aDeltaTime, *behaviourData, aID);
break;
}
case JumpingEnemyBehaviourState::Move:
{
newState = Move(aDeltaTime, *behaviourData, aID);
break;
}
case JumpingEnemyBehaviourState::Attack:
{
newState = Attack(aDeltaTime, *behaviourData, aID);
break;
}
case JumpingEnemyBehaviourState::Windup:
{
newState = Windup(aDeltaTime, *behaviourData, aID);
break;
}
case JumpingEnemyBehaviourState::Jumping:
{
newState = Jumping(aDeltaTime, *behaviourData, aID);
break;
}
case JumpingEnemyBehaviourState::Dead:
{
newState = Death(aDeltaTime, *behaviourData, aID);
return;
}
}
//---------------------Update State--------------------------
if (newState != behaviourData->myCurrentState)
{
ExitState(aID, *behaviourData, behaviourData->myCurrentState);
EnterState(aID, *behaviourData, newState);
behaviourData->myCurrentState = newState;
}
}
auto attackCallback = [aID]()
{
const WASD::Entity callbackEntity(aID);
auto* callbackBehaviourComponent = callbackEntity.AccessComponent<WASD::BehaviourComponent>();
auto* callbackBehaviourData = std::any_cast<JumpingEnemyBehaviourData>(&callbackBehaviourComponent->AccessEntityBehaviourData());
DoAttack(aID, *callbackBehaviourData, callbackBehaviourData->myCurrentState);
};
void DoAttack(const WASD::UniqueID aID, BigJumpingEnemyBehaviour::JumpingEnemyBehaviourData& aBehaviourData,
const BigJumpingEnemyBehaviour::JumpingEnemyBehaviourState aAttackType)
{
const WASD::Entity entity(aID);
const auto& trs = entity.GetComponent<WASD::TransformComponent>()->GetTRS();
FVector hits = MAKE_FVECTOR(WASD::UniqueID);
switch (aAttackType)
{
case BigJumpingEnemyBehaviour::JumpingEnemyBehaviourState::Attack:
{
hits = Locator::GetPhysicsManager()->ShapeCast(WASD::OBB{
Tga::Vector3f{0.f, 100.f, 250.f},
Tga::Vector3f{50.f, 50.f, 200.f}
}, trs, WASD::Player);
break;
}
case BigJumpingEnemyBehaviour::JumpingEnemyBehaviourState::Jumping:
{
hits = Locator::GetPhysicsManager()->ShapeCast(WASD::OBB{Tga::Vector3f{0.f, 100.f, 0.f}, Tga::Vector3f{200.f, 50.f, 200.f}}, trs, WASD::Player);
if (!aBehaviourData.splashEffect.has_value())
{
aBehaviourData.splashEffect.emplace(SceneLoading::CreateRunTimeObject("O_JumpImpact"_tgaid, WASD::SceneObjectData{ .trs = trs, .isStatic = false }));
aBehaviourData.jumpSplashEffectTimer.Start();
}
break;
}
}
for (const WASD::UniqueID hit : hits)
{
BehaviourHelpers::DecreaseHealth(hit, aBehaviourData.damage);
LOG_DEBUG("'{}' Attacked: '{}'", entity.GetEntityID(), hit);
}
if (aBehaviourData.alertEffect.has_value())
{
const WASD::Entity alertEntity(aBehaviourData.alertEffect.value());
alertEntity.Destroy();
aBehaviourData.alertEffect.reset();
}
}
void Tga::MaterialDocument::Update(const float aDeltaTime, InputManager& aInputManager)
{
aInputManager;
if (myIsAppearing)
{
Locator::GetRenderManager()->SetShaderFlags(static_cast<WASD::ShaderFlag>(0u));
myViewport.HandleResize();
const StringId sceneTag = myMaterial.GetMaterialID();
WASD::Entity entity;
if (myEntityID == INVALID_ID)
{
myEntityID = entity.Create();
entity.AddTagComponent(sceneTag);
constexpr ScaleRotationTranslationf trs = {};
auto* transformComponent = entity.AddComponent<WASD::TransformComponent>(trs, Vector3f{});
transformComponent->AccessTRS().SetScale(100);
auto* modelComponent = entity.AddComponent<WASD::ModelComponent>();
const std::string_view modelDir = WASD::FileSystem::GetModelDirectory();
const std::string path = WASD::CombineStrings(modelDir, "debug/Sphere.fbx");
auto modelId = WASD::FileSystem::ResolveAssetPath(path, WASD::FileType::Model, true, true);
std::array matBallMaterials = {myMaterial.GetMaterialID()};
WASD::InitModelComponent(*modelComponent, modelId, matBallMaterials);
}
else
{
entity.Possess(myEntityID);
}
WASD::RenderManager* renderManager = Locator::GetRenderManager();
renderManager->SetAmbientLight(locPreviewSettings.ambientLight);
renderManager->SetDirLight(locPreviewSettings.dirLight);
const Camera& vpCamera = myViewport.GetCamera();
renderManager->UpdateCamera(WASD::CAMERA_ID_WORLD, vpCamera);
//Ensures meshes using the UI camera are rendered in world space instead when using the editor
renderManager->UpdateCamera(WASD::CAMERA_ID_UI, vpCamera);
renderManager->PrepareDataForNextFrameForEditor(sceneTag, myViewport.GetObjectIDIndex());
myViewport.BeginDraw();
renderManager->SwapBuffers();
renderManager->PreRender();
{
myViewport.SetupIdPass();
renderManager->RenderID();
}
{
renderManager->BeginRenderEditor();
myViewport.SetupColorPass();
renderManager->EndRenderEditor();
}
myViewport.EndDraw();
}
SetupDockSpace();
{
BeginDrawDocument();
const ImVec2 docSpaceSize = ImGui::GetContentRegionAvail();
const ImGuiID dockSpaceId = ImGui::GetID("Document Dockspace");
// todo: ImGui::GetContentRegionAvail() returns wrong result first time it seems. What to do instead?
ImGui::DockSpace(dockSpaceId, docSpaceSize, ImGuiDockNodeFlags_None, &myDocumentWindowClass);
if (!myIsDockingInitialized)
{
ImGuiID center = 0, left = 0, right = 0;
ImGui::DockBuilderRemoveNode(dockSpaceId); // clear any previous layout
ImGui::DockBuilderAddNode(dockSpaceId, ImGuiDockNodeFlags_DockSpace);
ImGui::DockBuilderSetNodeSize(dockSpaceId, docSpaceSize);
center = dockSpaceId;
ImGui::DockBuilderSplitNode(center, ImGuiDir_Left, 0.2f, &left, ¢er);
ImGui::DockBuilderSplitNode(center, ImGuiDir_Right, 0.55f, &right, ¢er);
ImGui::DockBuilderDockWindow(myPanelWindowNames[static_cast<size_t>(Panels::Properties)].c_str(), right);
ImGui::DockBuilderDockWindow(myPanelWindowNames[static_cast<size_t>(Panels::Viewport)].c_str(), center);
ImGui::DockBuilderFinish(dockSpaceId);
myIsDockingInitialized = true;
}
EndDrawDocument();
}
const Color color = Engine::GetInstance()->GetClearColor();
ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(color.r, color.g, color.b, color.a));
ImGui::SetNextWindowClass(&myDocumentWindowClass);
bool isViewportOrPropertiesFocused = false;
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0, 0));
myWasAppearing = myIsAppearing;
myIsAppearing = ImGui::Begin(myPanelWindowNames[static_cast<size_t>(Panels::Viewport)].c_str());
ImGui::PopStyleVar(1);
isViewportOrPropertiesFocused = isViewportOrPropertiesFocused || ImGui::IsWindowFocused();
if (myIsAppearing)
{
myViewport.DrawAndUpdateViewportWindow(aDeltaTime, *this);
}
ImGui::End();
ImGui::PopStyleColor();
ImGui::SetNextWindowClass(&myDocumentWindowClass);
isViewportOrPropertiesFocused = isViewportOrPropertiesFocused || ImGui::IsWindowFocused();
ImGui::Begin(myPanelWindowNames[static_cast<size_t>(Panels::Properties)].c_str());
DrawPropertyPanel();
ImGui::End();
}