From e14af1f9f0ed28be5eca61c749a1bce155392715 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sun, 5 Apr 2026 22:35:07 +0300 Subject: [PATCH] combat: nicer UI with floating labels, HP bar colors, world shake & audio Signed-off-by: NotAShelf Change-Id: I9e1720b112a0a5ceab64da56735f4fb36a6a6964 --- src/main.c | 105 ++++++++++++++++++++++++++++++++++++++------------- src/render.c | 71 +++++++++++++++++++++++++++------- 2 files changed, 136 insertions(+), 40 deletions(-) diff --git a/src/main.c b/src/main.c index 651c7f0..8313f81 100644 --- a/src/main.c +++ b/src/main.c @@ -23,21 +23,20 @@ static void add_log(GameState *gs, const char *msg) { } } +// Reuse an expired float slot, or claim the next free one +static int float_slot(GameState *gs) { + if (gs->floating_count < 8) + return gs->floating_count++; + for (int i = 0; i < 8; i++) { + if (gs->floating_texts[i].lifetime <= 0) + return i; + } + return -1; +} + // spawn floating damage text static void spawn_floating_text(GameState *gs, int x, int y, int value, int is_critical) { - // Reuse an expired slot if all slots are taken - int slot = -1; - if (gs->floating_count < 8) { - slot = gs->floating_count; - gs->floating_count++; - } else { - for (int i = 0; i < 8; i++) { - if (gs->floating_texts[i].lifetime <= 0) { - slot = i; - break; - } - } - } + int slot = float_slot(gs); if (slot < 0) return; gs->floating_texts[slot].x = x; @@ -45,6 +44,34 @@ static void spawn_floating_text(GameState *gs, int x, int y, int value, int is_c gs->floating_texts[slot].value = value; gs->floating_texts[slot].lifetime = 60; gs->floating_texts[slot].is_critical = is_critical; + gs->floating_texts[slot].label[0] = '\0'; // numeric, no label + gs->floating_texts[slot].effect_type = EFFECT_NONE; +} + +// spawn floating label text (DODGE, BLOCK, CRIT!, proc names, SLAIN) +static void spawn_floating_label(GameState *gs, int x, int y, const char *label, StatusEffectType effect_type) { + int slot = float_slot(gs); + if (slot < 0) + return; + gs->floating_texts[slot].x = x; + gs->floating_texts[slot].y = y; + gs->floating_texts[slot].value = 0; + gs->floating_texts[slot].lifetime = 60; + gs->floating_texts[slot].is_critical = 0; + gs->floating_texts[slot].effect_type = effect_type; + strncpy(gs->floating_texts[slot].label, label, 7); + gs->floating_texts[slot].label[7] = '\0'; +} + +static const char *proc_label_for(StatusEffectType effect) { + switch (effect) { + case EFFECT_POISON: return "POISON!"; + case EFFECT_BLEED: return "BLEED!"; + case EFFECT_BURN: return "BURN!"; + case EFFECT_STUN: return "STUN!"; + case EFFECT_WEAKEN: return "WEAKEN!"; + default: return ""; + } } // update floating texts and screen shake @@ -130,7 +157,8 @@ static void tick_all_effects(GameState *gs) { } } -static void post_action(GameState *gs) { +// attacked_enemy: the enemy the player attacked this turn, or NULL if player only moved +static void post_action(GameState *gs, Enemy *attacked_enemy) { gs->turn_count++; // Tick status effects at the start of this turn @@ -146,13 +174,33 @@ static void post_action(GameState *gs) { return; } - // combat feedback - player attacked enemy - if (combat_get_last_damage() > 0 && !combat_was_player_damage()) { - for (int i = 0; i < gs->enemy_count; i++) { - if (!gs->enemies[i].alive) { - spawn_floating_text(gs, gs->enemies[i].x * TILE_SIZE + 8, gs->enemies[i].y * TILE_SIZE, - combat_get_last_damage(), combat_was_critical()); - break; + // combat feedback - player attacked an enemy this turn + if (attacked_enemy != NULL) { + int ex = attacked_enemy->x * TILE_SIZE + 8; + int ey = attacked_enemy->y * TILE_SIZE; + + if (combat_was_dodged()) { + spawn_floating_label(gs, ex, ey, "DODGE", EFFECT_NONE); + audio_play_dodge(); + } else { + if (combat_get_last_damage() > 0) + spawn_floating_text(gs, ex, ey, combat_get_last_damage(), combat_was_critical()); + if (combat_was_blocked()) { + spawn_floating_label(gs, ex, ey - 10, "BLOCK", EFFECT_NONE); + audio_play_block(); + } + if (combat_was_critical()) { + spawn_floating_label(gs, ex + 8, ey - 10, "CRIT!", EFFECT_NONE); + audio_play_crit(); + } + StatusEffectType applied = combat_get_applied_effect(); + if (applied != EFFECT_NONE) { + spawn_floating_label(gs, ex, ey - 20, proc_label_for(applied), applied); + audio_play_proc(); + } + if (!attacked_enemy->alive) { + spawn_floating_label(gs, ex, ey - 20, "SLAIN", EFFECT_NONE); + audio_play_enemy_death(); } } } @@ -383,7 +431,7 @@ static int handle_movement_input(GameState *gs) { } if (action) - post_action(gs); + post_action(gs, target); // target is NULL on move, enemy ptr on attack return action; } @@ -456,15 +504,18 @@ static void game_loop(void) { BeginDrawing(); ClearBackground(BLACK); - // Draw game elements (with screen shake offset) - if (gs.screen_shake > 0) { - // Apply shake offset to drawing - } - + // Draw game world with screen shake applied via camera offset + Camera2D cam = {0}; + cam.zoom = 1.0f; + cam.offset = (Vector2){(float)gs.shake_x, (float)gs.shake_y}; + BeginMode2D(cam); render_map(&gs.map); render_items(gs.items, gs.item_count); render_enemies(gs.enemies, gs.enemy_count); render_player(&gs.player); + EndMode2D(); + + // Floating texts follow world shake render_floating_texts(gs.floating_texts, gs.floating_count, gs.shake_x, gs.shake_y); render_ui(&gs.player); diff --git a/src/render.c b/src/render.c index b8f32ab..1993447 100644 --- a/src/render.c +++ b/src/render.c @@ -61,12 +61,20 @@ void render_enemies(const Enemy *enemies, int count) { DrawRectangleRec(rect, enemy_color); - // Draw hp bar above enemy - int hp_percent = (enemies[i].hp * TILE_SIZE) / enemies[i].max_hp; - if (hp_percent > 0) { - Rectangle hp_bar = {(float)(enemies[i].x * TILE_SIZE), (float)(enemies[i].y * TILE_SIZE - 4), (float)hp_percent, + // Draw hp bar above enemy, color-coded by health remaining + int hp_pixels = (enemies[i].hp * TILE_SIZE) / enemies[i].max_hp; + if (hp_pixels > 0) { + float hp_ratio = (float)enemies[i].hp / (float)enemies[i].max_hp; + Color bar_color; + if (hp_ratio > 0.5f) + bar_color = (Color){60, 180, 60, 255}; // green + else if (hp_ratio > 0.25f) + bar_color = (Color){200, 180, 40, 255}; // yellow + else + bar_color = (Color){200, 60, 60, 255}; // red + Rectangle hp_bar = {(float)(enemies[i].x * TILE_SIZE), (float)(enemies[i].y * TILE_SIZE - 4), (float)hp_pixels, 3}; - DrawRectangleRec(hp_bar, GREEN); + DrawRectangleRec(hp_bar, bar_color); } } } @@ -287,6 +295,35 @@ void render_inventory_overlay(const Player *p, int selected) { (Color){65, 65, 65, 255}); } +static Color label_color(FloatingText *ft, int alpha) { + if (ft->label[0] == '\0') + return (Color){255, 100, 100, alpha}; // numeric damage default + if (strcmp(ft->label, "DODGE") == 0) + return (Color){160, 160, 160, alpha}; + if (strcmp(ft->label, "BLOCK") == 0) + return (Color){80, 130, 220, alpha}; + if (strcmp(ft->label, "CRIT!") == 0) + return (Color){255, 200, 50, alpha}; + if (strcmp(ft->label, "SLAIN") == 0) + return (Color){220, 50, 50, alpha}; + + // Proc label, color driven by effect_type stored in the struct + switch (ft->effect_type) { + case EFFECT_POISON: + return (Color){50, 200, 50, alpha}; + case EFFECT_BLEED: + return (Color){200, 50, 50, alpha}; + case EFFECT_BURN: + return (Color){230, 130, 30, alpha}; + case EFFECT_STUN: + return (Color){200, 200, 50, alpha}; + case EFFECT_WEAKEN: + return (Color){120, 120, 120, alpha}; + default: + return (Color){200, 200, 200, alpha}; + } +} + void render_floating_texts(FloatingText *texts, int count, int shake_x, int shake_y) { for (int i = 0; i < count; i++) { if (texts[i].lifetime <= 0) @@ -294,15 +331,23 @@ void render_floating_texts(FloatingText *texts, int count, int shake_x, int shak int x = texts[i].x + shake_x; int y = texts[i].y + shake_y - (60 - texts[i].lifetime); // rise over time - float alpha = (float)texts[i].lifetime / 60.0f; - Color color = - texts[i].is_critical ? (Color){255, 200, 50, (int)(255 * alpha)} : (Color){255, 100, 100, (int)(255 * alpha)}; + int a = (int)(255 * alpha); - char text[16]; - snprintf(text, sizeof(text), "%d", texts[i].value); - int text_w = MeasureText(text, 18); - DrawText(text, x - text_w / 2, y, 18, color); + if (texts[i].label[0] != '\0') { + // Label text (DODGE, BLOCK, CRIT!, proc name, SLAIN) + int font_size = (texts[i].label[0] == 'C') ? 16 : 14; // CRIT! slightly larger + Color color = label_color(&texts[i], a); + int text_w = MeasureText(texts[i].label, font_size); + DrawText(texts[i].label, x - text_w / 2, y, font_size, color); + } else { + // Numeric damage + Color color = texts[i].is_critical ? (Color){255, 200, 50, a} : (Color){255, 100, 100, a}; + char text[16]; + snprintf(text, sizeof(text), "%d", texts[i].value); + int text_w = MeasureText(text, 18); + DrawText(text, x - text_w / 2, y, 18, color); + } } } @@ -327,8 +372,8 @@ void render_message(const char *message) { int msg_len = strlen(message); float msg_ratio = 13.5; - // Draw message box + // Draw message box // TODO: Separate out the calculation of the x/y and width/height so that if a message takes up more than, say, // 75% of the screen width, we add a line break and increase the height. That would then require calculating the // width based on the longest line.