Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions config/app.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,18 @@
}
},
"game": {
"enemyHealthBars": {
"enabled": true,
"maxDistance": 22.0,
"colors": {
"low": [0.84, 0.18, 0.24, 0.96],
"mid": [0.96, 0.76, 0.25, 0.96],
"high": [0.28, 0.84, 0.45, 0.96],
"border": [0.02, 0.03, 0.05, 0.92],
"background": [0.12, 0.14, 0.18, 0.88],
"highlight": [1.0, 1.0, 1.0, 0.12]
}
},
"enemySpawner": {
"spawnPoints": [
[30.0, 0.0, 30.0],
Expand Down
107 changes: 107 additions & 0 deletions src/common/systems/ui-renderer.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
#pragma once

#include <glad/gl.h>

#include <components/camera.hpp>
#include <ecs/world.hpp>
#include <glm/gtc/matrix_transform.hpp>
#include <glm/vec2.hpp>
#include <glm/vec4.hpp>
#include <vector>

#include "../asset-loader.hpp"
#include "../material/material.hpp"
#include "../mesh/mesh.hpp"
#include "../mesh/vertex.hpp"
#include "../shader/shader.hpp"

namespace our {

struct ScreenPoint {
glm::vec2 position;
float depth;
bool visible;
};

class UIRenderer {
Mesh* quad = nullptr;
TintedMaterial* material = nullptr;

static Vertex makeVertex(const glm::vec3& position, const glm::vec2& texCoord) {
Vertex vertex{};
vertex.position = position;
vertex.color = {255, 255, 255, 255};
vertex.tex_coord = texCoord;
vertex.normal = {0.0f, 0.0f, 1.0f};
return vertex;
}

public:
void initialize() {
material = new TintedMaterial();
material->shader = AssetLoader<ShaderProgram>::get("tinted");
material->pipelineState.blending.enabled = true;
material->pipelineState.blending.sourceFactor = GL_SRC_ALPHA;
material->pipelineState.blending.destinationFactor = GL_ONE_MINUS_SRC_ALPHA;
material->pipelineState.depthTesting.enabled = false;
material->pipelineState.faceCulling.enabled = false;
material->pipelineState.depthMask = false;

std::vector<Vertex> vertices = {
makeVertex({0.0f, 0.0f, 0.0f}, {0.0f, 1.0f}),
makeVertex({1.0f, 0.0f, 0.0f}, {1.0f, 1.0f}),
makeVertex({1.0f, 1.0f, 0.0f}, {1.0f, 0.0f}),
makeVertex({0.0f, 1.0f, 0.0f}, {0.0f, 0.0f}),
};
std::vector<unsigned int> elements = {0, 1, 2, 2, 3, 0};
quad = new Mesh(vertices, elements);
}

void destroy() {
delete quad;
delete material;
quad = nullptr;
material = nullptr;
}

glm::mat4 overlayProjection(glm::ivec2 framebufferSize) const {
return glm::ortho(0.0f, static_cast<float>(framebufferSize.x), static_cast<float>(framebufferSize.y), 0.0f,
1.0f, -1.0f);
}
Comment thread
AhmedSobhy01 marked this conversation as resolved.

void drawRect(const glm::mat4& projection, const glm::vec2& position, const glm::vec2& size,
const glm::vec4& color) const {
if (!(quad && material && material->shader)) return;
if (size.x <= 0.0f || size.y <= 0.0f || color.a <= 0.0f) return;

material->tint = color;
material->setup();
material->shader->set("transform", projection * glm::translate(glm::mat4(1.0f), glm::vec3(position, 0.0f)) *
glm::scale(glm::mat4(1.0f), glm::vec3(size, 1.0f)));
quad->draw();
}

static ScreenPoint worldToScreen(const glm::vec3& worldPos, const glm::mat4& viewProj,
glm::ivec2 framebufferSize, float frustumMargin = 1.1f) {
ScreenPoint result{{0.0f, 0.0f}, 0.0f, false};

glm::vec4 clip = viewProj * glm::vec4(worldPos, 1.0f);
if (clip.w <= 0.0f) return result; // behind camera

glm::vec3 ndc = glm::vec3(clip) / clip.w;
if (ndc.z < -1.0f || ndc.z > 1.0f) return result;
if (ndc.x < -frustumMargin || ndc.x > frustumMargin) return result;
if (ndc.y < -frustumMargin || ndc.y > frustumMargin) return result;

result.position = {
(ndc.x * 0.5f + 0.5f) * framebufferSize.x,
(1.0f - (ndc.y * 0.5f + 0.5f)) * framebufferSize.y,
};

result.depth = ndc.z;
result.visible = true;
return result;
}
};

} // namespace our
8 changes: 8 additions & 0 deletions src/game/components/health.cpp
Original file line number Diff line number Diff line change
@@ -1,13 +1,21 @@
#include "health.hpp"

#include <algorithm>

namespace gameplay {

void HealthComponent::deserialize(const nlohmann::json& data) {
if (!data.is_object()) return;
maxHealth = data.value("maxHealth", maxHealth);
currentHealth = data.contains("currentHealth") ? data["currentHealth"].get<float>() : maxHealth;
invulnerabilityTimer = data.value("invulnerabilityTimer", invulnerabilityTimer);
damageRevealTimer = data.value("damageRevealTimer", damageRevealTimer);
isDead = data.value("isDead", isDead);
}

float HealthComponent::getHealthRatio() const {
if (maxHealth <= 0.0f) return 0.0f;
return std::clamp(currentHealth / maxHealth, 0.0f, 1.0f);
}

} // namespace gameplay
3 changes: 3 additions & 0 deletions src/game/components/health.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,11 @@ namespace gameplay {
float maxHealth = 100.0f;
float currentHealth = 100.0f;
float invulnerabilityTimer = 0.0f;
float damageRevealTimer = 0.0f;
bool isDead = false;

float getHealthRatio() const;

static std::string getID() {
return "Health";
}
Expand Down
154 changes: 154 additions & 0 deletions src/game/systems/enemy-health-bar.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
#pragma once

#include <algorithm>
#include <application.hpp>
#include <cmath>
#include <deserialize-utils.hpp>
#include <ecs/world.hpp>
#include <glm/common.hpp>
#include <glm/geometric.hpp>
#include <glm/vec2.hpp>
#include <glm/vec3.hpp>
#include <glm/vec4.hpp>
#include <json/json.hpp>
#include <systems/ui-renderer.hpp>

#include "../components/collider.hpp"
#include "../components/enemy.hpp"
#include "../components/health.hpp"

namespace gameplay {

class EnemyHealthBarSystem {
struct Config {
bool enabled = true;
float maxDistance = 24.0f;
glm::vec4 lowColor = {0.84f, 0.18f, 0.24f, 0.96f};
glm::vec4 midColor = {0.96f, 0.76f, 0.25f, 0.96f};
glm::vec4 highColor = {0.28f, 0.84f, 0.45f, 0.96f};
glm::vec4 borderColor = {0.02f, 0.03f, 0.05f, 0.92f};
glm::vec4 backgroundColor = {0.12f, 0.14f, 0.18f, 0.88f};
glm::vec4 highlightColor = {1.0f, 1.0f, 1.0f, 0.12f};
} config;

static void deserializeColor(const nlohmann::json& colorConfig, const char* key, glm::vec4& color) {
if (!(colorConfig.contains(key) && colorConfig[key].is_array())) return;

const auto& value = colorConfig[key];
if (value.size() == 3) {
color = glm::vec4(value.get<glm::vec3>(), color.a);
} else if (value.size() == 4) {
color = value.get<glm::vec4>();
}
}

static float estimateBarHeight(const our::Entity* entity, const ColliderComponent* collider) {
float scaleHeight = std::max(std::abs(entity->localTransform.scale.y) * 1.2f, 1.0f);
if (!collider) return scaleHeight;

float colliderHeight = collider->radius * 2.0f;
if (collider->shape == ColliderShape::Capsule) colliderHeight = std::max(colliderHeight, collider->height);

return std::max(colliderHeight, scaleHeight);
}

public:
void deserialize(const nlohmann::json& sceneConfig) {
if (!(sceneConfig.contains("game") && sceneConfig["game"].contains("enemyHealthBars"))) return;

const auto& healthBarConfig = sceneConfig["game"]["enemyHealthBars"];
if (!healthBarConfig.is_object()) return;

config = {};
config.enabled = healthBarConfig.value("enabled", config.enabled);
config.maxDistance = healthBarConfig.value("maxDistance", config.maxDistance);
config.maxDistance = std::max(0.0f, config.maxDistance);

if (healthBarConfig.contains("colors") && healthBarConfig["colors"].is_object()) {
const auto& colors = healthBarConfig["colors"];
deserializeColor(colors, "low", config.lowColor);
deserializeColor(colors, "mid", config.midColor);
deserializeColor(colors, "high", config.highColor);
deserializeColor(colors, "border", config.borderColor);
deserializeColor(colors, "background", config.backgroundColor);
deserializeColor(colors, "highlight", config.highlightColor);
}
}

void render(our::World* world, our::Application* app, const our::UIRenderer& ui,
const our::CameraComponent* camera) const {
if (!(config.enabled && world && app && camera)) return;

glm::ivec2 framebufferSize = app->getFrameBufferSize();
if (framebufferSize.x <= 0 || framebufferSize.y <= 0) return;

glm::mat4 view = camera->getViewMatrix();
glm::mat4 proj = camera->getProjectionMatrix(framebufferSize);
glm::mat4 viewProj = proj * view;
glm::mat4 overlayProj = ui.overlayProjection(framebufferSize);
glm::vec3 cameraPos = glm::vec3(camera->getOwner()->getLocalToWorldMatrix() * glm::vec4(0, 0, 0, 1));

for (our::Entity* entity : world->getEntities()) {
auto* health = entity->getComponent<HealthComponent>();
if (!(entity->getComponent<EnemyComponent>() && health) || health->isDead || health->maxHealth <= 0.0f)
continue;

float healthRatio = health->getHealthRatio();
glm::mat4 localToWorld = entity->getLocalToWorldMatrix();
glm::vec3 enemyPosition = glm::vec3(localToWorld * glm::vec4(0, 0, 0, 1));
float distance = glm::distance(enemyPosition, cameraPos);

bool visibleWhenDamaged = health->damageRevealTimer > 0.0f;
bool visibleWhenClose = distance <= config.maxDistance;

if (!(visibleWhenDamaged || visibleWhenClose)) continue;

ColliderComponent* collider = entity->getComponent<ColliderComponent>();
float barHeightOffset = estimateBarHeight(entity, collider) + 0.35f;

glm::vec3 barWorldPos = glm::vec3(localToWorld * glm::vec4(0.0f, barHeightOffset, 0.0f, 1.0f));
our::ScreenPoint screen = our::UIRenderer::worldToScreen(barWorldPos, viewProj, framebufferSize);
if (!screen.visible) continue;

float dist = glm::clamp((distance - 6.0f) / glm::max(1.0f, config.maxDistance - 6.0f), 0.0f, 1.0f);
float alpha = visibleWhenDamaged
? 1.0f
: 1.0f - glm::smoothstep(config.maxDistance * 0.7f, config.maxDistance, distance);
if (alpha <= 0.0f) continue;

float barWidth = glm::mix(86.0f, 48.0f, dist);
float barHeight = glm::mix(10.0f, 6.0f, dist);

glm::vec2 barPosition = {screen.position.x - barWidth * 0.5f, screen.position.y - barHeight};
glm::vec2 outerSize = {barWidth + 2.0f, barHeight + 2.0f};
glm::vec2 innerPosition = barPosition + glm::vec2(2.0f, 2.0f);
glm::vec2 innerSize = {barWidth - 4.0f, barHeight - 4.0f};
glm::vec2 fillSize = {innerSize.x * healthRatio, innerSize.y};

glm::vec4 borderColor = config.borderColor;
borderColor.a *= alpha;
ui.drawRect(overlayProj, barPosition - glm::vec2(1.0f), outerSize, borderColor);

glm::vec4 backgroundColor = config.backgroundColor;
backgroundColor.a *= alpha;
ui.drawRect(overlayProj, barPosition, {barWidth, barHeight}, backgroundColor);

float clampedHealthRatio = glm::clamp(healthRatio, 0.0f, 1.0f);
glm::vec4 fillColor =
clampedHealthRatio < 0.5f
? glm::mix(config.lowColor, config.midColor, clampedHealthRatio * 2.0f)
: glm::mix(config.midColor, config.highColor, (clampedHealthRatio - 0.5f) * 2.0f);
fillColor.a *= alpha;
ui.drawRect(overlayProj, innerPosition, fillSize, fillColor);

if (fillSize.x > 6.0f) {
glm::vec4 highlightColor = config.highlightColor;
highlightColor.a *= alpha;
ui.drawRect(overlayProj, innerPosition + glm::vec2(0.0f, 1.0f),
{fillSize.x, std::max(1.0f, innerSize.y * 0.28f)}, highlightColor);
}
}
}
};

} // namespace gameplay
Loading
Loading