Skip to content

Commit 23c8b55

Browse files
authored
feat: add enemy health bar system (#36)
* feat: add enemy health bar system and configuration to play state * refactor: rename configure method to deserialize in EnemyHealthBarSystem * feat: implement UIRenderer for drawing UI elements and integrate with enemy health bar system * feat: enable enemy health bars and remove showWhenDamagedOnly option * feat: remove findActiveCamera method and use activeCamera pointer in Playstate * feat: add enemy health bar configuration with color options * fix: reorder include statements for consistency in enemy health bar system * fix: return mistaken removed code * fix: update visibility logic for enemy health bar based on maxDistance configuration
1 parent 172ca25 commit 23c8b55

6 files changed

Lines changed: 311 additions & 19 deletions

File tree

config/app.jsonc

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,18 @@
180180
}
181181
},
182182
"game": {
183+
"enemyHealthBars": {
184+
"enabled": true,
185+
"maxDistance": 22.0,
186+
"colors": {
187+
"low": [0.84, 0.18, 0.24, 0.96],
188+
"mid": [0.96, 0.76, 0.25, 0.96],
189+
"high": [0.28, 0.84, 0.45, 0.96],
190+
"border": [0.02, 0.03, 0.05, 0.92],
191+
"background": [0.12, 0.14, 0.18, 0.88],
192+
"highlight": [1.0, 1.0, 1.0, 0.12]
193+
}
194+
},
183195
"enemySpawner": {
184196
"spawnPoints": [
185197
[30.0, 0.0, 30.0],

src/common/systems/ui-renderer.hpp

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
#pragma once
2+
3+
#include <glad/gl.h>
4+
5+
#include <components/camera.hpp>
6+
#include <ecs/world.hpp>
7+
#include <glm/gtc/matrix_transform.hpp>
8+
#include <glm/vec2.hpp>
9+
#include <glm/vec4.hpp>
10+
#include <vector>
11+
12+
#include "../asset-loader.hpp"
13+
#include "../material/material.hpp"
14+
#include "../mesh/mesh.hpp"
15+
#include "../mesh/vertex.hpp"
16+
#include "../shader/shader.hpp"
17+
18+
namespace our {
19+
20+
struct ScreenPoint {
21+
glm::vec2 position;
22+
float depth;
23+
bool visible;
24+
};
25+
26+
class UIRenderer {
27+
Mesh* quad = nullptr;
28+
TintedMaterial* material = nullptr;
29+
30+
static Vertex makeVertex(const glm::vec3& position, const glm::vec2& texCoord) {
31+
Vertex vertex{};
32+
vertex.position = position;
33+
vertex.color = {255, 255, 255, 255};
34+
vertex.tex_coord = texCoord;
35+
vertex.normal = {0.0f, 0.0f, 1.0f};
36+
return vertex;
37+
}
38+
39+
public:
40+
void initialize() {
41+
material = new TintedMaterial();
42+
material->shader = AssetLoader<ShaderProgram>::get("tinted");
43+
material->pipelineState.blending.enabled = true;
44+
material->pipelineState.blending.sourceFactor = GL_SRC_ALPHA;
45+
material->pipelineState.blending.destinationFactor = GL_ONE_MINUS_SRC_ALPHA;
46+
material->pipelineState.depthTesting.enabled = false;
47+
material->pipelineState.faceCulling.enabled = false;
48+
material->pipelineState.depthMask = false;
49+
50+
std::vector<Vertex> vertices = {
51+
makeVertex({0.0f, 0.0f, 0.0f}, {0.0f, 1.0f}),
52+
makeVertex({1.0f, 0.0f, 0.0f}, {1.0f, 1.0f}),
53+
makeVertex({1.0f, 1.0f, 0.0f}, {1.0f, 0.0f}),
54+
makeVertex({0.0f, 1.0f, 0.0f}, {0.0f, 0.0f}),
55+
};
56+
std::vector<unsigned int> elements = {0, 1, 2, 2, 3, 0};
57+
quad = new Mesh(vertices, elements);
58+
}
59+
60+
void destroy() {
61+
delete quad;
62+
delete material;
63+
quad = nullptr;
64+
material = nullptr;
65+
}
66+
67+
glm::mat4 overlayProjection(glm::ivec2 framebufferSize) const {
68+
return glm::ortho(0.0f, static_cast<float>(framebufferSize.x), static_cast<float>(framebufferSize.y), 0.0f,
69+
1.0f, -1.0f);
70+
}
71+
72+
void drawRect(const glm::mat4& projection, const glm::vec2& position, const glm::vec2& size,
73+
const glm::vec4& color) const {
74+
if (!(quad && material && material->shader)) return;
75+
if (size.x <= 0.0f || size.y <= 0.0f || color.a <= 0.0f) return;
76+
77+
material->tint = color;
78+
material->setup();
79+
material->shader->set("transform", projection * glm::translate(glm::mat4(1.0f), glm::vec3(position, 0.0f)) *
80+
glm::scale(glm::mat4(1.0f), glm::vec3(size, 1.0f)));
81+
quad->draw();
82+
}
83+
84+
static ScreenPoint worldToScreen(const glm::vec3& worldPos, const glm::mat4& viewProj,
85+
glm::ivec2 framebufferSize, float frustumMargin = 1.1f) {
86+
ScreenPoint result{{0.0f, 0.0f}, 0.0f, false};
87+
88+
glm::vec4 clip = viewProj * glm::vec4(worldPos, 1.0f);
89+
if (clip.w <= 0.0f) return result; // behind camera
90+
91+
glm::vec3 ndc = glm::vec3(clip) / clip.w;
92+
if (ndc.z < -1.0f || ndc.z > 1.0f) return result;
93+
if (ndc.x < -frustumMargin || ndc.x > frustumMargin) return result;
94+
if (ndc.y < -frustumMargin || ndc.y > frustumMargin) return result;
95+
96+
result.position = {
97+
(ndc.x * 0.5f + 0.5f) * framebufferSize.x,
98+
(1.0f - (ndc.y * 0.5f + 0.5f)) * framebufferSize.y,
99+
};
100+
101+
result.depth = ndc.z;
102+
result.visible = true;
103+
return result;
104+
}
105+
};
106+
107+
} // namespace our

src/game/components/health.cpp

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,21 @@
11
#include "health.hpp"
22

3+
#include <algorithm>
4+
35
namespace gameplay {
46

57
void HealthComponent::deserialize(const nlohmann::json& data) {
68
if (!data.is_object()) return;
79
maxHealth = data.value("maxHealth", maxHealth);
810
currentHealth = data.contains("currentHealth") ? data["currentHealth"].get<float>() : maxHealth;
911
invulnerabilityTimer = data.value("invulnerabilityTimer", invulnerabilityTimer);
12+
damageRevealTimer = data.value("damageRevealTimer", damageRevealTimer);
1013
isDead = data.value("isDead", isDead);
1114
}
1215

16+
float HealthComponent::getHealthRatio() const {
17+
if (maxHealth <= 0.0f) return 0.0f;
18+
return std::clamp(currentHealth / maxHealth, 0.0f, 1.0f);
19+
}
20+
1321
} // namespace gameplay

src/game/components/health.hpp

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,11 @@ namespace gameplay {
99
float maxHealth = 100.0f;
1010
float currentHealth = 100.0f;
1111
float invulnerabilityTimer = 0.0f;
12+
float damageRevealTimer = 0.0f;
1213
bool isDead = false;
1314

15+
float getHealthRatio() const;
16+
1417
static std::string getID() {
1518
return "Health";
1619
}
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
#pragma once
2+
3+
#include <algorithm>
4+
#include <application.hpp>
5+
#include <cmath>
6+
#include <deserialize-utils.hpp>
7+
#include <ecs/world.hpp>
8+
#include <glm/common.hpp>
9+
#include <glm/geometric.hpp>
10+
#include <glm/vec2.hpp>
11+
#include <glm/vec3.hpp>
12+
#include <glm/vec4.hpp>
13+
#include <json/json.hpp>
14+
#include <systems/ui-renderer.hpp>
15+
16+
#include "../components/collider.hpp"
17+
#include "../components/enemy.hpp"
18+
#include "../components/health.hpp"
19+
20+
namespace gameplay {
21+
22+
class EnemyHealthBarSystem {
23+
struct Config {
24+
bool enabled = true;
25+
float maxDistance = 24.0f;
26+
glm::vec4 lowColor = {0.84f, 0.18f, 0.24f, 0.96f};
27+
glm::vec4 midColor = {0.96f, 0.76f, 0.25f, 0.96f};
28+
glm::vec4 highColor = {0.28f, 0.84f, 0.45f, 0.96f};
29+
glm::vec4 borderColor = {0.02f, 0.03f, 0.05f, 0.92f};
30+
glm::vec4 backgroundColor = {0.12f, 0.14f, 0.18f, 0.88f};
31+
glm::vec4 highlightColor = {1.0f, 1.0f, 1.0f, 0.12f};
32+
} config;
33+
34+
static void deserializeColor(const nlohmann::json& colorConfig, const char* key, glm::vec4& color) {
35+
if (!(colorConfig.contains(key) && colorConfig[key].is_array())) return;
36+
37+
const auto& value = colorConfig[key];
38+
if (value.size() == 3) {
39+
color = glm::vec4(value.get<glm::vec3>(), color.a);
40+
} else if (value.size() == 4) {
41+
color = value.get<glm::vec4>();
42+
}
43+
}
44+
45+
static float estimateBarHeight(const our::Entity* entity, const ColliderComponent* collider) {
46+
float scaleHeight = std::max(std::abs(entity->localTransform.scale.y) * 1.2f, 1.0f);
47+
if (!collider) return scaleHeight;
48+
49+
float colliderHeight = collider->radius * 2.0f;
50+
if (collider->shape == ColliderShape::Capsule) colliderHeight = std::max(colliderHeight, collider->height);
51+
52+
return std::max(colliderHeight, scaleHeight);
53+
}
54+
55+
public:
56+
void deserialize(const nlohmann::json& sceneConfig) {
57+
if (!(sceneConfig.contains("game") && sceneConfig["game"].contains("enemyHealthBars"))) return;
58+
59+
const auto& healthBarConfig = sceneConfig["game"]["enemyHealthBars"];
60+
if (!healthBarConfig.is_object()) return;
61+
62+
config = {};
63+
config.enabled = healthBarConfig.value("enabled", config.enabled);
64+
config.maxDistance = healthBarConfig.value("maxDistance", config.maxDistance);
65+
config.maxDistance = std::max(0.0f, config.maxDistance);
66+
67+
if (healthBarConfig.contains("colors") && healthBarConfig["colors"].is_object()) {
68+
const auto& colors = healthBarConfig["colors"];
69+
deserializeColor(colors, "low", config.lowColor);
70+
deserializeColor(colors, "mid", config.midColor);
71+
deserializeColor(colors, "high", config.highColor);
72+
deserializeColor(colors, "border", config.borderColor);
73+
deserializeColor(colors, "background", config.backgroundColor);
74+
deserializeColor(colors, "highlight", config.highlightColor);
75+
}
76+
}
77+
78+
void render(our::World* world, our::Application* app, const our::UIRenderer& ui,
79+
const our::CameraComponent* camera) const {
80+
if (!(config.enabled && world && app && camera)) return;
81+
82+
glm::ivec2 framebufferSize = app->getFrameBufferSize();
83+
if (framebufferSize.x <= 0 || framebufferSize.y <= 0) return;
84+
85+
glm::mat4 view = camera->getViewMatrix();
86+
glm::mat4 proj = camera->getProjectionMatrix(framebufferSize);
87+
glm::mat4 viewProj = proj * view;
88+
glm::mat4 overlayProj = ui.overlayProjection(framebufferSize);
89+
glm::vec3 cameraPos = glm::vec3(camera->getOwner()->getLocalToWorldMatrix() * glm::vec4(0, 0, 0, 1));
90+
91+
for (our::Entity* entity : world->getEntities()) {
92+
auto* health = entity->getComponent<HealthComponent>();
93+
if (!(entity->getComponent<EnemyComponent>() && health) || health->isDead || health->maxHealth <= 0.0f)
94+
continue;
95+
96+
float healthRatio = health->getHealthRatio();
97+
glm::mat4 localToWorld = entity->getLocalToWorldMatrix();
98+
glm::vec3 enemyPosition = glm::vec3(localToWorld * glm::vec4(0, 0, 0, 1));
99+
float distance = glm::distance(enemyPosition, cameraPos);
100+
101+
bool visibleWhenDamaged = health->damageRevealTimer > 0.0f;
102+
bool visibleWhenClose = distance <= config.maxDistance;
103+
104+
if (!(visibleWhenDamaged || visibleWhenClose)) continue;
105+
106+
ColliderComponent* collider = entity->getComponent<ColliderComponent>();
107+
float barHeightOffset = estimateBarHeight(entity, collider) + 0.35f;
108+
109+
glm::vec3 barWorldPos = glm::vec3(localToWorld * glm::vec4(0.0f, barHeightOffset, 0.0f, 1.0f));
110+
our::ScreenPoint screen = our::UIRenderer::worldToScreen(barWorldPos, viewProj, framebufferSize);
111+
if (!screen.visible) continue;
112+
113+
float dist = glm::clamp((distance - 6.0f) / glm::max(1.0f, config.maxDistance - 6.0f), 0.0f, 1.0f);
114+
float alpha = visibleWhenDamaged
115+
? 1.0f
116+
: 1.0f - glm::smoothstep(config.maxDistance * 0.7f, config.maxDistance, distance);
117+
if (alpha <= 0.0f) continue;
118+
119+
float barWidth = glm::mix(86.0f, 48.0f, dist);
120+
float barHeight = glm::mix(10.0f, 6.0f, dist);
121+
122+
glm::vec2 barPosition = {screen.position.x - barWidth * 0.5f, screen.position.y - barHeight};
123+
glm::vec2 outerSize = {barWidth + 2.0f, barHeight + 2.0f};
124+
glm::vec2 innerPosition = barPosition + glm::vec2(2.0f, 2.0f);
125+
glm::vec2 innerSize = {barWidth - 4.0f, barHeight - 4.0f};
126+
glm::vec2 fillSize = {innerSize.x * healthRatio, innerSize.y};
127+
128+
glm::vec4 borderColor = config.borderColor;
129+
borderColor.a *= alpha;
130+
ui.drawRect(overlayProj, barPosition - glm::vec2(1.0f), outerSize, borderColor);
131+
132+
glm::vec4 backgroundColor = config.backgroundColor;
133+
backgroundColor.a *= alpha;
134+
ui.drawRect(overlayProj, barPosition, {barWidth, barHeight}, backgroundColor);
135+
136+
float clampedHealthRatio = glm::clamp(healthRatio, 0.0f, 1.0f);
137+
glm::vec4 fillColor =
138+
clampedHealthRatio < 0.5f
139+
? glm::mix(config.lowColor, config.midColor, clampedHealthRatio * 2.0f)
140+
: glm::mix(config.midColor, config.highColor, (clampedHealthRatio - 0.5f) * 2.0f);
141+
fillColor.a *= alpha;
142+
ui.drawRect(overlayProj, innerPosition, fillSize, fillColor);
143+
144+
if (fillSize.x > 6.0f) {
145+
glm::vec4 highlightColor = config.highlightColor;
146+
highlightColor.a *= alpha;
147+
ui.drawRect(overlayProj, innerPosition + glm::vec2(0.0f, 1.0f),
148+
{fillSize.x, std::max(1.0f, innerSize.y * 0.28f)}, highlightColor);
149+
}
150+
}
151+
}
152+
};
153+
154+
} // namespace gameplay

0 commit comments

Comments
 (0)