diff --git a/Justfile b/Justfile index a425cb3..c70d068 100644 --- a/Justfile +++ b/Justfile @@ -13,7 +13,7 @@ clean: # Format all C source files fmt: clang-format -i src/*.c src/*.h - zig fmt libs/combat/*.zig + zig fmt **/*.zig # Check formatting fmt-check: diff --git a/README.md b/README.md index 9d63b10..843330f 100644 --- a/README.md +++ b/README.md @@ -1,121 +1,3 @@ -# Rogged +# rogged -Turn-based roguelike dungeon crawler, built in C99 and with a dash of Zig to -serve as a learning opportunity. Rogged is basically a classic roguelike where -you descend through floors of a procedurally generated dungeon, fighting -enemies, managing inventory, and trying to reach the bottom alive. - -A non-exhaustive list of its (current) features: - -- Turn-based combat with damage variance, critical hits, dodge, and block - mechanics -- Damage classes (Slash, Impact, Pierce, Fire, and Poison) -- Status effects (Poison, Bleed, Stun, Weaken, and Burn) -- Various enemy classes (Goblin, Skeleton, Orc) with distinct resistance - profiles -- Procedural dungeon generation with rooms and corridors, seeded per floor -- Inventory and equipment system (weapons, armor, potions) -- Procedural audio via raylib -- ASCII-inspired tile rendering, with HP bars and floating damage text - -**Controls:** - -| Key | Action | -| ------------- | ----------------------------------- | -| WASD / Arrows | Move or attack | -| G | Pick up item | -| I | Open inventory | -| U | Use a potion | -| E | Equip item from inventory | -| D | Drop item | -| Y / N | Confirm / decline descending stairs | -| R | Restart (on game over) | -| Q | Quit | - -## Build Instructions - -Rogged is built with C99 and Zig. Besides `raylib` and `pkg-config` you will -need a C compiler and basic Zig tooling. For now, we use C99 and Zig 0.15.2. -Those might change in the future. - -Additionally you will need `clang-format` and `just` for the developer workflow -if you plan to contribute. - -### Using Nix (Recommended) - -The recommended developer tooling is [Nix](https://nixos.org). This provides a -pure, reproducible devshell across all machines. - -```sh -# Enter the development shell -$ nix develop - -# Build and run -$ just dev -``` - -### Manual Build - -```sh -# Full build -$ zig build - -# Build and run -$ zig build run - -# or -$ just dev -``` - -### Task Runner Commands - -There's a `Justfile` designed to make common tasks somewhat easier. For now, -they are as follows: - -```sh -just build # zig build -just dev # zig build run -just clean # remove zig-out/ and .zig-cache/ -just fmt # format all C and Zig source files -just fmt-check # check formatting -``` - -If the project gets more complicated, new tasks might be added. - -## Future Plans - -The game is currently **playable end-to-end** but it lacks _serious_ polish to -claim its place as a fun roguelike. Some of the features I'd like to introduce, -in no particular order, are as follows: - -- [ ] **Save / Load system** - Persist and restore game state between sessions -- [ ] **More enemy variety** - Additional enemy types with unique abilities -- [ ] **More item variety** - Rings, wands, scrolls, and cursed items -- [ ] **Multiple floors beyond 5** - Endless or configurable depth -- [ ] **Test suite** - Unit tests for combat math, dungeon generation, and RNG -- [ ] **Field of view** - Fog of war and line-of-sight mechanics -- [ ] **Level transitions** - Upward stairs and floor teleportation -- [ ] **Achievements / death log** - Track runs and causes of death with a - leaderboard -- [ ] **UI polish** - Better message log history, item descriptions, death - screen - -In addition, it might be interesting to allow customizing the "world state" by -as scripting API. Though, that is for much later. - -## Attributions - -[Shattered Pixel Dungeon]: https://github.com/00-Evan/shattered-pixel-dungeon -[Raylib]: https://www.raylib.com - -This project draws a fair bit of inspiration from [Shattered Pixel Dungeon]. -While the mechanics are generally different, and commit to remain as such, I -cannot deny the amount of inspiration & ideas Shattered Pixel Dungeon has given -me. It's a GPL licensed project, and no code was borrowed. Still, some -resemblance may occur. - -Additionally, _huge_ thanks to [Raylib] for how easy it made graphics and audio. -This was perhaps my best experience in developing a graphical application, and -CERTAINLY the most ergonomic when it comes to writing a game. - -_I got rogged :/_ +I got rogged :/ diff --git a/assets/sounds/levelcomplete.wav b/assets/sounds/levelcomplete.wav deleted file mode 100644 index f3abfd0..0000000 Binary files a/assets/sounds/levelcomplete.wav and /dev/null differ diff --git a/src/audio.c b/src/audio.c index 73244ed..c6bcefd 100644 --- a/src/audio.c +++ b/src/audio.c @@ -94,29 +94,7 @@ void audio_play_player_damage(void) { void audio_play_stairs(void) { // Ascending stairs sound - Sound staircase = LoadSound("./assets/sounds/levelcomplete.wav"); - PlaySound(staircase); -} - -void audio_play_dodge(void) { - // High-pitched whoosh - play_tone(900.0f, 0.08f, 0.3f); -} - -void audio_play_block(void) { - // Low-then-mid metallic clang - play_tone(250.0f, 0.06f, 0.5f); - play_tone(350.0f, 0.04f, 0.3f); -} - -void audio_play_crit(void) { - // Sharp crack with high-pitched follow - play_tone(600.0f, 0.05f, 0.7f); - play_tone(900.0f, 0.1f, 0.5f); -} - -void audio_play_proc(void) { - // Ascending two-tone proc chime - play_tone(500.0f, 0.08f, 0.4f); - play_tone(700.0f, 0.1f, 0.35f); + play_tone(400.0f, 0.1f, 0.3f); + play_tone(600.0f, 0.1f, 0.3f); + play_tone(800.0f, 0.15f, 0.3f); } diff --git a/src/audio.h b/src/audio.h index 3c2cb36..a574a9a 100644 --- a/src/audio.h +++ b/src/audio.h @@ -25,16 +25,4 @@ void audio_play_player_damage(void); // Play stairs/level change sound void audio_play_stairs(void); -// Play dodge sound -void audio_play_dodge(void); - -// Play block sound -void audio_play_block(void); - -// Play critical hit sound -void audio_play_crit(void); - -// Play status effect proc sound -void audio_play_proc(void); - #endif // AUDIO_H diff --git a/src/common.h b/src/common.h index 9af8771..c015532 100644 --- a/src/common.h +++ b/src/common.h @@ -108,8 +108,6 @@ typedef struct { int value; int lifetime; // frames remaining int is_critical; - char label[8]; // non-empty -> show label instead of numeric value - StatusEffectType effect_type; // used to pick color for proc labels } FloatingText; // GameState - encapsulates all game state for testability and save/load diff --git a/src/main.c b/src/main.c index 8313f81..651c7f0 100644 --- a/src/main.c +++ b/src/main.c @@ -23,20 +23,21 @@ 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) { - int slot = float_slot(gs); + // 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; + } + } + } if (slot < 0) return; gs->floating_texts[slot].x = x; @@ -44,34 +45,6 @@ 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 @@ -157,8 +130,7 @@ static void tick_all_effects(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) { +static void post_action(GameState *gs) { gs->turn_count++; // Tick status effects at the start of this turn @@ -174,33 +146,13 @@ static void post_action(GameState *gs, Enemy *attacked_enemy) { return; } - // 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(); + // 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; } } } @@ -431,7 +383,7 @@ static int handle_movement_input(GameState *gs) { } if (action) - post_action(gs, target); // target is NULL on move, enemy ptr on attack + post_action(gs); return action; } @@ -504,18 +456,15 @@ static void game_loop(void) { BeginDrawing(); ClearBackground(BLACK); - // 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); + // Draw game elements (with screen shake offset) + if (gs.screen_shake > 0) { + // Apply shake offset to drawing + } + 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 1993447..b8f32ab 100644 --- a/src/render.c +++ b/src/render.c @@ -61,20 +61,12 @@ void render_enemies(const Enemy *enemies, int count) { DrawRectangleRec(rect, enemy_color); - // 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, + // 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, 3}; - DrawRectangleRec(hp_bar, bar_color); + DrawRectangleRec(hp_bar, GREEN); } } } @@ -295,35 +287,6 @@ 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) @@ -331,23 +294,15 @@ 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; - int a = (int)(255 * alpha); - 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); - } + 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)}; + + 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); } } @@ -372,8 +327,8 @@ void render_message(const char *message) { int msg_len = strlen(message); float msg_ratio = 13.5; - // 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.