diff --git a/Justfile b/Justfile index c70d068..a425cb3 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 **/*.zig + zig fmt libs/combat/*.zig # Check formatting fmt-check: diff --git a/README.md b/README.md index 843330f..9d63b10 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,121 @@ -# rogged +# Rogged -I got 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 :/_ diff --git a/assets/sounds/levelcomplete.wav b/assets/sounds/levelcomplete.wav new file mode 100644 index 0000000..f3abfd0 Binary files /dev/null and b/assets/sounds/levelcomplete.wav differ diff --git a/src/audio.c b/src/audio.c index c6bcefd..73244ed 100644 --- a/src/audio.c +++ b/src/audio.c @@ -94,7 +94,29 @@ void audio_play_player_damage(void) { void audio_play_stairs(void) { // Ascending stairs sound - play_tone(400.0f, 0.1f, 0.3f); - play_tone(600.0f, 0.1f, 0.3f); - play_tone(800.0f, 0.15f, 0.3f); + 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); } diff --git a/src/audio.h b/src/audio.h index a574a9a..3c2cb36 100644 --- a/src/audio.h +++ b/src/audio.h @@ -25,4 +25,16 @@ 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 c015532..9af8771 100644 --- a/src/common.h +++ b/src/common.h @@ -108,6 +108,8 @@ 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 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.