From b49808a21678ab26535c12a968fd3730de3bc757 Mon Sep 17 00:00:00 2001 From: "A.M. Rowsell" Date: Sat, 4 Apr 2026 00:01:22 -0400 Subject: [PATCH 01/16] ui: experimental message box width auto-sizing --- src/render.c | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/render.c b/src/render.c index 4ecacdf..c0349b4 100644 --- a/src/render.c +++ b/src/render.c @@ -5,6 +5,7 @@ #include "settings.h" #include #include +#include void render_map(const Map *map) { for (int y = 0; y < MAP_HEIGHT; y++) { @@ -285,9 +286,13 @@ void render_game_over(void) { void render_message(const char *message) { if (message == NULL) return; - + int msg_len = strlen(message); + float msg_ratio = 13.5; // Draw message box - Rectangle msg_bg = {(float)(SCREEN_WIDTH / 2 - 150), (float)(SCREEN_HEIGHT / 2 - 30), 300, 60}; + // Rectangle msg_bg = {(float)(SCREEN_WIDTH / 2.0f - 150.0f), (float)(SCREEN_HEIGHT / 2.0f - 30.0f), 300, 60}; + + Rectangle msg_bg = {(float)(SCREEN_WIDTH / 2.0f - ((msg_ratio / 2.03f) * msg_len)), + (float)(SCREEN_HEIGHT / 2.0f - 30.0f), msg_ratio * msg_len, 60}; DrawRectangleRec(msg_bg, (Color){45, 45, 45, 235}); DrawRectangleLines((int)msg_bg.x, (int)msg_bg.y, (int)msg_bg.width, (int)msg_bg.height, (Color){180, 180, 180, 255}); From 39b7e01119c5c5aa13aaae87a3eec5f83e90c030 Mon Sep 17 00:00:00 2001 From: "A.M. Rowsell" Date: Sat, 4 Apr 2026 00:23:24 -0400 Subject: [PATCH 02/16] formatting: cleaned up formatting of this change, added TODO --- src/render.c | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/render.c b/src/render.c index c0349b4..de0c254 100644 --- a/src/render.c +++ b/src/render.c @@ -286,11 +286,14 @@ void render_game_over(void) { void render_message(const char *message) { if (message == NULL) return; + int msg_len = strlen(message); float msg_ratio = 13.5; // Draw message box - // Rectangle msg_bg = {(float)(SCREEN_WIDTH / 2.0f - 150.0f), (float)(SCREEN_HEIGHT / 2.0f - 30.0f), 300, 60}; + // 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. Rectangle msg_bg = {(float)(SCREEN_WIDTH / 2.0f - ((msg_ratio / 2.03f) * msg_len)), (float)(SCREEN_HEIGHT / 2.0f - 30.0f), msg_ratio * msg_len, 60}; DrawRectangleRec(msg_bg, (Color){45, 45, 45, 235}); From f8b63aafac693831042769e1e822cd5f14b4b730 Mon Sep 17 00:00:00 2001 From: "A.M. Rowsell" Date: Sat, 4 Apr 2026 00:30:29 -0400 Subject: [PATCH 03/16] chore: update .editorconfig to match indentation size in .clang-format --- .editorconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.editorconfig b/.editorconfig index f267605..5b9877a 100644 --- a/.editorconfig +++ b/.editorconfig @@ -8,7 +8,7 @@ trim_trailing_whitespace = true [*.c] ident_style = space -ident_size = 4 +ident_size = 2 [Makefile*] ident_style = tab From f637bbf5beb8f7f5cc5f2275819204aaec5baf4a Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Fri, 3 Apr 2026 16:37:06 +0300 Subject: [PATCH 04/16] nix: add zig to build inputs Signed-off-by: NotAShelf Change-Id: I87b657bbfa7ca420ba7786f6dcc1acd46a6a6964 --- nix/shell.nix | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/nix/shell.nix b/nix/shell.nix index e51be03..23ce860 100644 --- a/nix/shell.nix +++ b/nix/shell.nix @@ -4,19 +4,17 @@ raylib, gnumake, pkg-config, + zig, }: mkShell { strictDeps = true; - packages = [ - clang-tools - gnumake - ]; - buildInputs = [ - raylib - ]; + buildInputs = [raylib]; nativeBuildInputs = [ pkg-config + clang-tools + gnumake + zig ]; } From 6fee9b37c905fde39eb32daf381e6fa1df964c7e Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sat, 4 Apr 2026 22:48:49 +0300 Subject: [PATCH 05/16] meta: ignore zig cache files Signed-off-by: NotAShelf Change-Id: Id4fce83201c4c7d717379c09a33b223b6a6a6964 --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 6746cd7..a5869bb 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ result build obj roguelike +.zig-cache From 62c030527f1772bd3ed70a9437a59060cbcd57ca Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sat, 4 Apr 2026 22:49:03 +0300 Subject: [PATCH 06/16] nix: bump nixpkgs Signed-off-by: NotAShelf Change-Id: I0183e42e1ee52e9e68150b1b42371cf36a6a6964 --- flake.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/flake.lock b/flake.lock index 9bb7806..4b75299 100644 --- a/flake.lock +++ b/flake.lock @@ -2,11 +2,11 @@ "nodes": { "nixpkgs": { "locked": { - "lastModified": 1773646010, - "narHash": "sha256-iYrs97hS7p5u4lQzuNWzuALGIOdkPXvjz7bviiBjUu8=", + "lastModified": 1775036866, + "narHash": "sha256-ZojAnPuCdy657PbTq5V0Y+AHKhZAIwSIT2cb8UgAz/U=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "5b2c2d84341b2afb5647081c1386a80d7a8d8605", + "rev": "6201e203d09599479a3b3450ed24fa81537ebc4e", "type": "github" }, "original": { From 7af642612b4aea6a8760e2554d5c5d4a67b169c9 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sun, 5 Apr 2026 17:00:17 +0300 Subject: [PATCH 07/16] build: migrate from Make to Zig build system + Just Signed-off-by: NotAShelf Change-Id: I7585121a5ec8e797adc43ba8e30d4ac86a6a6964 --- .editorconfig | 2 ++ .gitignore | 10 +++++-- Justfile | 20 ++++++++++++++ Makefile | 45 ------------------------------ build.zig | 73 +++++++++++++++++++++++++++++++++++++++++++++++++ nix/package.nix | 39 ++++++++++++++++++++++++-- nix/shell.nix | 7 +++-- 7 files changed, 144 insertions(+), 52 deletions(-) create mode 100644 Justfile delete mode 100644 Makefile create mode 100644 build.zig diff --git a/.editorconfig b/.editorconfig index 5b9877a..368d066 100644 --- a/.editorconfig +++ b/.editorconfig @@ -5,6 +5,8 @@ charset = utf-8 end_of_line = lf insert_final_newline = true trim_trailing_whitespace = true +indent_size = 2 +tab_width = 2 [*.c] ident_style = space diff --git a/.gitignore b/.gitignore index a5869bb..a53104b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,12 @@ -# ignore build artifacts -result +# Nix +/.direnv/ +result* + +# Build artifacts build obj roguelike + +# Zig .zig-cache +.zig-out diff --git a/Justfile b/Justfile new file mode 100644 index 0000000..c70d068 --- /dev/null +++ b/Justfile @@ -0,0 +1,20 @@ +# Build the project +build: + zig build + +# Build and run +dev: + zig build run + +# Clean build artifacts +clean: + rm -rf zig-out .zig-cache + +# Format all C source files +fmt: + clang-format -i src/*.c src/*.h + zig fmt **/*.zig + +# Check formatting +fmt-check: + clang-format --dry-run --Werror src/*.c src/*.h diff --git a/Makefile b/Makefile deleted file mode 100644 index 607167e..0000000 --- a/Makefile +++ /dev/null @@ -1,45 +0,0 @@ -# Makefile for Roguelike Game -# Requires raylib, pkg-config - -CC := cc -CFLAGS := -Wall -Wextra -O2 -std=c99 -Isrc -LDFLAGS := -lraylib -lm -lpthread -ldl -lrt - -TARGET := roguelike -SRCDIR := src -OBJDIR := obj - -SOURCES := $(wildcard $(SRCDIR)/*.c) -OBJECTS := $(patsubst $(SRCDIR)/%.c,$(OBJDIR)/%.o,$(SOURCES)) - -.PHONY: all clean format format-check - -all: $(TARGET) - -$(TARGET): $(OBJECTS) - $(CC) $^ -o $@ $(LDFLAGS) - -$(OBJDIR)/%.o: $(SRCDIR)/%.c - @mkdir -p $(dir $@) - $(CC) $(CFLAGS) -c $< -o $@ - -clean: - rm -rf $(OBJDIR) $(TARGET) - -# Alias for development -dev: all - ./$(TARGET) - -# Format all source files with clang-format -fmt: - @command -v clang-format >/dev/null 2>&1 || { echo "Error: clang-format is missing"; exit 1; } - @echo "Formatting source files..." - @clang-format -i $(SOURCES) $(wildcard $(SRCDIR)/*.h) - @echo "Done formatting." - -# Check formatting without modifying files -fmt-check: - @command -v clang-format >/dev/null 2>&1 || { echo "Error: clang-format is missing"; exit 1; } - @echo "Checking formatting..." - @clang-format --dry-run --Werror \ - $(SOURCES) $(wildcard $(SRCDIR)/*.h) && echo "All files properly formatted." || { echo "Formatting issues found. Run 'make fmt' to fix."; exit 1; } diff --git a/build.zig b/build.zig new file mode 100644 index 0000000..eb3ff5d --- /dev/null +++ b/build.zig @@ -0,0 +1,73 @@ +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + // Zig combat library + const combat_lib = b.addLibrary(.{ + .name = "combat", + .root_module = b.createModule(.{ + .root_source_file = b.path("libs/combat/combat.zig"), + .target = target, + .optimize = optimize, + .link_libc = true, + }), + }); + combat_lib.addIncludePath(b.path("src")); + + // C sources (everything except combat, which is now Zig) + const c_sources = [_][]const u8{ + "src/audio.c", + "src/enemy.c", + "src/items.c", + "src/main.c", + "src/map.c", + "src/player.c", + "src/render.c", + "src/rng.c", + "src/settings.c", + "src/utils.c", + }; + + const c_flags = [_][]const u8{ + "-std=c99", + "-Wall", + "-Wextra", + "-O2", + }; + + // Main executable + const exe = b.addExecutable(.{ + .name = "roguelike", + .root_module = b.createModule(.{ + .target = target, + .optimize = optimize, + .link_libc = true, + }), + }); + + exe.addCSourceFiles(.{ + .files = &c_sources, + .flags = &c_flags, + }); + + exe.addIncludePath(b.path("src")); + exe.linkLibrary(combat_lib); + exe.linkSystemLibrary("raylib"); + exe.linkSystemLibrary("m"); + exe.linkSystemLibrary("pthread"); + exe.linkSystemLibrary("dl"); + exe.linkSystemLibrary("rt"); + + b.installArtifact(exe); + + // Run step + const run_cmd = b.addRunArtifact(exe); + run_cmd.step.dependOn(b.getInstallStep()); + if (b.args) |args| { + run_cmd.addArgs(args); + } + const run_step = b.step("run", "Build and run the roguelike"); + run_step.dependOn(&run_cmd.step); +} diff --git a/nix/package.nix b/nix/package.nix index a097561..8fb5c18 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -1,6 +1,12 @@ -{stdenv}: +{ + lib, + stdenv, + zig, + raylib, + pkg-config, +}: stdenv.mkDerivation (finalAttrs: { - pname = "sample-c-cpp"; + pname = "rogged"; version = "0.0.1"; src = builtins.path { @@ -8,5 +14,32 @@ stdenv.mkDerivation (finalAttrs: { name = finalAttrs.pname; }; - makeFlags = ["PREFIX=$(out)"]; + nativeBuildInputs = [ + zig + pkg-config + ]; + + buildInputs = [raylib]; + + dontConfigure = true; + + buildPhase = '' + runHook preBuild + export ZIG_GLOBAL_CACHE_DIR="$TMPDIR/zig-cache" + zig build --release=fast + runHook postBuild + ''; + + installPhase = '' + runHook preInstall + mkdir -p $out/bin + cp zig-out/bin/roguelike $out/bin/ + runHook postInstall + ''; + + meta = { + description = "A turn-based roguelike game"; + license = lib.licenses.mit; + mainProgram = "roguelike"; + }; }) diff --git a/nix/shell.nix b/nix/shell.nix index 23ce860..663f55b 100644 --- a/nix/shell.nix +++ b/nix/shell.nix @@ -2,9 +2,10 @@ mkShell, clang-tools, raylib, - gnumake, pkg-config, + just, zig, + zig-zlint, }: mkShell { strictDeps = true; @@ -14,7 +15,9 @@ mkShell { nativeBuildInputs = [ pkg-config clang-tools - gnumake + just + zig + zig-zlint ]; } From 22ab6fc6eb3a38bce066a107dfe506331bb25c04 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sun, 5 Apr 2026 20:11:06 +0300 Subject: [PATCH 08/16] combat: rewrite in Zig; add basic damage types and weapon archetypes Signed-off-by: NotAShelf Change-Id: Ic8055a1cf6bdad1aca13673ea171b4b46a6a6964 --- libs/combat/attack.zig | 185 ++++++++++++++++++++++++++++++++++++++++ libs/combat/c.zig | 43 ++++++++++ libs/combat/combat.zig | 84 ++++++++++++++++++ libs/combat/effects.zig | 151 ++++++++++++++++++++++++++++++++ libs/combat/event.zig | 18 ++++ src/combat.c | 123 -------------------------- src/combat.h | 26 ++++++ src/common.h | 33 ++++++- src/enemy.c | 39 +++++++++ src/items.c | 68 +++++++++++++-- src/items.h | 3 + src/main.c | 79 +++++++++++++++-- src/player.c | 27 +++--- src/render.c | 54 ++++++++++-- src/settings.h | 27 ++++++ 15 files changed, 802 insertions(+), 158 deletions(-) create mode 100644 libs/combat/attack.zig create mode 100644 libs/combat/c.zig create mode 100644 libs/combat/combat.zig create mode 100644 libs/combat/effects.zig create mode 100644 libs/combat/event.zig delete mode 100644 src/combat.c diff --git a/libs/combat/attack.zig b/libs/combat/attack.zig new file mode 100644 index 0000000..5de90d4 --- /dev/null +++ b/libs/combat/attack.zig @@ -0,0 +1,185 @@ +const c = @import("c.zig"); +const event = @import("event.zig"); +const effects = @import("effects.zig"); + +fn rng(min: c_int, max: c_int) c_int { + return c.rng_int(min, max); +} + +fn applyResistance(damage: c_int, resistance: c_int) c_int { + if (resistance >= 100) return 0; + const factor = 100 - resistance; + var result = @divTrunc(damage * factor, 100); + if (result < 1) result = 1; + return result; +} + +fn rollProc( + target_effects: [*c]c.StatusEffect, + target_count: [*c]c_int, + dmg_class: c.DamageClass, + status_chance: c_int, +) c.StatusEffectType { + if (status_chance <= 0 or rng(0, 99) >= status_chance) + return c.EFFECT_NONE; + + const eff_type = effects.procForClass(dmg_class); + if (eff_type == c.EFFECT_NONE) + return c.EFFECT_NONE; + + const params = effects.paramsFor(eff_type); + effects.apply(target_effects, target_count, eff_type, params.duration, params.intensity); + return eff_type; +} + +pub fn playerAttack(p: [*c]c.Player, e: [*c]c.Enemy) void { + if (p == null or e == null) return; + if (e[0].alive == 0) return; + + event.reset(); + + if (e[0].dodge > 0 and rng(0, 99) < e[0].dodge) { + event.last.is_player_damage = 0; + event.last.was_dodged = 1; + event.last.message = "Enemy dodged!"; + return; + } + + // Read weapon stats or unarmed defaults + var dmg_class: c.DamageClass = c.DMG_IMPACT; + var crit_chance: c_int = c.UNARMED_CRIT_CHANCE; + var crit_mult: c_int = c.UNARMED_CRIT_MULT; + var status_chance: c_int = c.UNARMED_STATUS_CHANCE; + + if (p[0].has_weapon != 0) { + dmg_class = p[0].equipped_weapon.dmg_class; + crit_chance = p[0].equipped_weapon.crit_chance; + crit_mult = p[0].equipped_weapon.crit_multiplier; + status_chance = p[0].equipped_weapon.status_chance; + } + + var base_attack = p[0].attack; + if (effects.has(&p[0].effects, p[0].effect_count, c.EFFECT_WEAKEN)) + base_attack -= c.WEAKEN_ATTACK_REDUCTION; + if (base_attack < 1) base_attack = 1; + + const variance = rng(80, 120); + var damage = @divTrunc(base_attack * variance, 100); + if (damage < 1) damage = 1; + + if (rng(0, 99) < crit_chance) { + damage = @divTrunc(damage * crit_mult, 100); + event.last.is_critical = 1; + } + + const res_index: usize = @intCast(dmg_class); + if (res_index < c.NUM_DMG_CLASSES) { + damage = applyResistance(damage, e[0].resistance[res_index]); + } + + if (damage == 0) { + event.last.damage = 0; + event.last.is_player_damage = 0; + event.last.message = "No effect!"; + return; + } + + if (e[0].block > 0 and rng(0, 99) < 30) { + var blocked = e[0].block; + if (blocked > damage) blocked = damage; + damage -= blocked; + if (damage < 1) damage = 1; + event.last.was_blocked = 1; + event.last.block_amount = blocked; + } + + e[0].hp -= damage; + event.last.damage = damage; + event.last.is_player_damage = 0; + + const applied = rollProc(&e[0].effects, &e[0].effect_count, dmg_class, status_chance); + event.last.applied_effect = applied; + + if (e[0].hp <= 0) { + e[0].hp = 0; + e[0].alive = 0; + event.last.message = "Enemy killed!"; + } else if (applied != c.EFFECT_NONE) { + event.last.message = switch (applied) { + c.EFFECT_BLEED => "Hit! Bleeding!", + c.EFFECT_STUN => "Hit! Stunned!", + c.EFFECT_WEAKEN => "Hit! Weakened!", + c.EFFECT_BURN => "Hit! Burning!", + c.EFFECT_POISON => "Hit! Poisoned!", + else => "You hit", + }; + } else if (event.last.is_critical != 0) { + event.last.message = "Critical hit!"; + } else { + event.last.message = "You hit"; + } +} + +pub fn enemyAttack(e: [*c]c.Enemy, p: [*c]c.Player) void { + if (e == null or p == null) return; + if (e[0].alive == 0) return; + + event.reset(); + event.last.is_player_damage = 1; + + if (p[0].dodge > 0 and rng(0, 99) < p[0].dodge) { + event.last.was_dodged = 1; + event.last.message = "You dodged!"; + return; + } + + var base_damage = e[0].attack; + if (effects.has(&e[0].effects, e[0].effect_count, c.EFFECT_WEAKEN)) + base_damage -= c.WEAKEN_ATTACK_REDUCTION; + base_damage -= p[0].defense; + if (base_damage < 1) base_damage = 1; + + const variance = rng(80, 120); + var damage = @divTrunc(base_damage * variance, 100); + if (damage < 1) damage = 1; + + if (rng(0, 99) < c.ENEMY_CRIT_CHANCE) { + damage = @divTrunc(damage * c.ENEMY_CRIT_MULT, 100); + event.last.is_critical = 1; + } + + if (p[0].block > 0 and rng(0, 99) < 30) { + var blocked = p[0].block; + if (blocked > damage) blocked = damage; + damage -= blocked; + if (damage < 1) damage = 1; + event.last.was_blocked = 1; + event.last.block_amount = blocked; + } + + p[0].hp -= damage; + event.last.damage = damage; + + const applied = rollProc(&p[0].effects, &p[0].effect_count, e[0].dmg_class, e[0].status_chance); + event.last.applied_effect = applied; + + if (p[0].hp <= 0) { + p[0].hp = 0; + event.last.message = "You died!"; + } else if (applied != c.EFFECT_NONE) { + event.last.message = switch (applied) { + c.EFFECT_POISON => "Hit! Poisoned!", + c.EFFECT_BLEED => "Hit! Bleeding!", + c.EFFECT_STUN => "Hit! Stunned!", + c.EFFECT_BURN => "Hit! Burning!", + c.EFFECT_WEAKEN => "Hit! Weakened!", + else => "Hit", + }; + } else if (event.last.was_blocked != 0) { + event.last.message = "Blocked some damage"; + } else if (event.last.is_critical != 0) { + event.last.message = "Critical!"; + } else { + event.last.message = "Hit"; + } +} diff --git a/libs/combat/c.zig b/libs/combat/c.zig new file mode 100644 index 0000000..2890d81 --- /dev/null +++ b/libs/combat/c.zig @@ -0,0 +1,43 @@ +pub const raw = @cImport({ + @cInclude("common.h"); + @cInclude("rng.h"); +}); + +pub const StatusEffectType = raw.StatusEffectType; +pub const StatusEffect = raw.StatusEffect; +pub const Player = raw.Player; +pub const Enemy = raw.Enemy; +pub const DamageClass = raw.DamageClass; + +pub const EFFECT_NONE = raw.EFFECT_NONE; +pub const EFFECT_POISON = raw.EFFECT_POISON; +pub const EFFECT_STUN = raw.EFFECT_STUN; +pub const EFFECT_BLEED = raw.EFFECT_BLEED; +pub const EFFECT_WEAKEN = raw.EFFECT_WEAKEN; +pub const EFFECT_BURN = raw.EFFECT_BURN; + +pub const DMG_SLASH = raw.DMG_SLASH; +pub const DMG_IMPACT = raw.DMG_IMPACT; +pub const DMG_PIERCE = raw.DMG_PIERCE; +pub const DMG_FIRE = raw.DMG_FIRE; +pub const DMG_POISON = raw.DMG_POISON; + +pub const ENEMY_GOBLIN = raw.ENEMY_GOBLIN; +pub const ENEMY_SKELETON = raw.ENEMY_SKELETON; +pub const ENEMY_ORC = raw.ENEMY_ORC; + +pub const MAX_EFFECTS = raw.MAX_EFFECTS; +pub const NUM_DMG_CLASSES = raw.NUM_DMG_CLASSES; +pub const WEAKEN_ATTACK_REDUCTION = raw.WEAKEN_ATTACK_REDUCTION; +pub const POISON_BASE_DAMAGE = raw.POISON_BASE_DAMAGE; +pub const BLEED_STACK_DAMAGE = raw.BLEED_STACK_DAMAGE; +pub const BURN_BASE_DAMAGE = raw.BURN_BASE_DAMAGE; + +pub const UNARMED_CRIT_CHANCE = raw.UNARMED_CRIT_CHANCE; +pub const UNARMED_CRIT_MULT = raw.UNARMED_CRIT_MULT; +pub const UNARMED_STATUS_CHANCE = raw.UNARMED_STATUS_CHANCE; + +pub const ENEMY_CRIT_CHANCE = raw.ENEMY_CRIT_CHANCE; +pub const ENEMY_CRIT_MULT = raw.ENEMY_CRIT_MULT; + +pub const rng_int = raw.rng_int; diff --git a/libs/combat/combat.zig b/libs/combat/combat.zig new file mode 100644 index 0000000..870a277 --- /dev/null +++ b/libs/combat/combat.zig @@ -0,0 +1,84 @@ +const c = @import("c.zig"); +const event = @import("event.zig"); +const fx = @import("effects.zig"); +const atk = @import("attack.zig"); + +comptime { + _ = @import("c.zig"); + _ = @import("event.zig"); + _ = @import("effects.zig"); + _ = @import("attack.zig"); +} + +export fn combat_get_last_message() [*c]const u8 { + return event.last.message; +} + +export fn combat_get_last_damage() c_int { + return event.last.damage; +} + +export fn combat_was_player_damage() c_int { + return event.last.is_player_damage; +} + +export fn combat_was_critical() c_int { + return event.last.is_critical; +} + +export fn combat_was_dodged() c_int { + return event.last.was_dodged; +} + +export fn combat_was_blocked() c_int { + return event.last.was_blocked; +} + +export fn combat_get_block_amount() c_int { + return event.last.block_amount; +} + +export fn combat_get_applied_effect() c.StatusEffectType { + return event.last.applied_effect; +} + +export fn combat_reset_event() void { + event.reset(); +} + +export fn combat_has_effect( + effects: [*c]const c.StatusEffect, + count: c_int, + effect_type: c.StatusEffectType, +) c_int { + if (effects == null) return 0; + return if (fx.has(effects, count, effect_type)) 1 else 0; +} + +export fn combat_apply_effect( + effects: [*c]c.StatusEffect, + count: [*c]c_int, + effect_type: c.StatusEffectType, + duration: c_int, + intensity: c_int, +) void { + if (effects == null or count == null) return; + if (effect_type == c.EFFECT_NONE) return; + fx.apply(effects, count, effect_type, duration, intensity); +} + +export fn combat_tick_effects(p: [*c]c.Player) c_int { + return fx.tickPlayer(p); +} + +export fn combat_tick_enemy_effects(e: [*c]c.Enemy) c_int { + return fx.tickEnemy(e); +} + +export fn combat_player_attack(p: [*c]c.Player, e: [*c]c.Enemy) void { + atk.playerAttack(p, e); +} + +export fn combat_enemy_attack(e: [*c]c.Enemy, p: [*c]c.Player) void { + atk.enemyAttack(e, p); +} diff --git a/libs/combat/effects.zig b/libs/combat/effects.zig new file mode 100644 index 0000000..f4a0fa1 --- /dev/null +++ b/libs/combat/effects.zig @@ -0,0 +1,151 @@ +const c = @import("c.zig"); + +pub const EffectParams = struct { + duration: c_int, + intensity: c_int, +}; + +pub fn procForClass(dmg_class: c.DamageClass) c.StatusEffectType { + return switch (dmg_class) { + c.DMG_SLASH => c.EFFECT_BLEED, + c.DMG_IMPACT => c.EFFECT_STUN, + c.DMG_PIERCE => c.EFFECT_WEAKEN, + c.DMG_FIRE => c.EFFECT_BURN, + c.DMG_POISON => c.EFFECT_POISON, + else => c.EFFECT_NONE, + }; +} + +pub fn name(effect: c.StatusEffectType) ?[*:0]const u8 { + return switch (effect) { + c.EFFECT_POISON => "Poisoned", + c.EFFECT_BLEED => "Bleeding", + c.EFFECT_STUN => "Stunned", + c.EFFECT_BURN => "Burning", + c.EFFECT_WEAKEN => "Weakened", + else => null, + }; +} + +pub fn paramsFor(effect: c.StatusEffectType) EffectParams { + return switch (effect) { + c.EFFECT_BLEED => .{ .duration = 4, .intensity = c.BLEED_STACK_DAMAGE }, + c.EFFECT_STUN => .{ .duration = 1, .intensity = 0 }, + c.EFFECT_WEAKEN => .{ .duration = 3, .intensity = c.WEAKEN_ATTACK_REDUCTION }, + c.EFFECT_BURN => .{ .duration = 2, .intensity = c.BURN_BASE_DAMAGE }, + c.EFFECT_POISON => .{ .duration = 5, .intensity = c.POISON_BASE_DAMAGE }, + else => .{ .duration = 0, .intensity = 0 }, + }; +} + +pub fn clampCount(count: c_int) usize { + if (count < 0) return 0; + if (count > c.MAX_EFFECTS) return @intCast(c.MAX_EFFECTS); + return @intCast(count); +} + +pub fn has(effects: [*c]const c.StatusEffect, count: c_int, effect_type: c.StatusEffectType) bool { + const safe_count = clampCount(count); + for (0..safe_count) |i| { + if (effects[i].type == effect_type and effects[i].duration > 0) + return true; + } + return false; +} + +pub fn apply( + effects: [*c]c.StatusEffect, + count: [*c]c_int, + effect_type: c.StatusEffectType, + duration: c_int, + intensity: c_int, +) void { + const safe_count = clampCount(count[0]); + + for (0..safe_count) |i| { + if (effects[i].type == effect_type and effects[i].duration > 0) { + if (effect_type == c.EFFECT_BLEED) { + effects[i].intensity += intensity; + if (effects[i].duration < duration) + effects[i].duration = duration; + } else { + effects[i].duration = duration; + if (intensity > effects[i].intensity) + effects[i].intensity = intensity; + } + return; + } + } + + if (safe_count < @as(usize, @intCast(c.MAX_EFFECTS))) { + effects[safe_count] = .{ + .type = effect_type, + .duration = duration, + .intensity = intensity, + }; + count[0] = @intCast(safe_count + 1); + } +} + +fn compact(effects: [*c]c.StatusEffect, count: [*c]c_int) void { + var write: usize = 0; + const safe_count = clampCount(count[0]); + + for (0..safe_count) |read| { + if (effects[read].duration > 0) { + if (write != read) + effects[write] = effects[read]; + write += 1; + } + } + count[0] = @intCast(write); +} + +fn tickOne(eff: *c.StatusEffect, hp: *c_int) c_int { + if (eff.duration <= 0) return 0; + + var dmg: c_int = 0; + switch (eff.type) { + c.EFFECT_POISON, c.EFFECT_BLEED, c.EFFECT_BURN => { + dmg = eff.intensity; + hp.* -= dmg; + }, + else => {}, + } + eff.duration -= 1; + return dmg; +} + +pub fn tickPlayer(p: [*c]c.Player) c_int { + if (p == null) return 0; + + var total: c_int = 0; + const safe_count = clampCount(p[0].effect_count); + + for (0..safe_count) |i| { + total += tickOne(&p[0].effects[i], &p[0].hp); + } + + compact(&p[0].effects, &p[0].effect_count); + return total; +} + +pub fn tickEnemy(e: [*c]c.Enemy) c_int { + if (e == null) return 0; + if (e[0].alive == 0) return 0; + + var total: c_int = 0; + const safe_count = clampCount(e[0].effect_count); + + for (0..safe_count) |i| { + total += tickOne(&e[0].effects[i], &e[0].hp); + } + + if (e[0].hp <= 0) { + e[0].hp = 0; + e[0].alive = 0; + } + + compact(&e[0].effects, &e[0].effect_count); + return total; +} diff --git a/libs/combat/event.zig b/libs/combat/event.zig new file mode 100644 index 0000000..1084e25 --- /dev/null +++ b/libs/combat/event.zig @@ -0,0 +1,18 @@ +const c = @import("c.zig"); + +pub const CombatEvent = struct { + message: [*c]const u8 = null, + damage: c_int = 0, + is_player_damage: c_int = 0, + is_critical: c_int = 0, + was_dodged: c_int = 0, + was_blocked: c_int = 0, + block_amount: c_int = 0, + applied_effect: c.StatusEffectType = c.EFFECT_NONE, +}; + +pub var last: CombatEvent = .{}; + +pub fn reset() void { + last = .{}; +} diff --git a/src/combat.c b/src/combat.c deleted file mode 100644 index fbb66cc..0000000 --- a/src/combat.c +++ /dev/null @@ -1,123 +0,0 @@ -#include "combat.h" -#include "common.h" -#include "rng.h" -#include - -// Track combat events for feedback -typedef struct { - const char *message; - int damage; - int is_player_damage; - int is_critical; -} CombatEvent; - -static CombatEvent last_event = {NULL, 0, 0, 0}; - -const char *combat_get_last_message(void) { - return last_event.message; -} - -int combat_get_last_damage(void) { - return last_event.damage; -} - -int combat_was_player_damage(void) { - return last_event.is_player_damage; -} - -int combat_was_critical(void) { - return last_event.is_critical; -} - -void combat_player_attack(Player *p, Enemy *e) { - if (e == NULL || !e->alive) - return; - - last_event.is_critical = 0; - - // 90% hit chance - if (rng_int(0, 99) < 90) { - // calculate damage with variance from player stats - int base_damage = p->attack; - int variance = rng_int(p->dmg_variance_min, p->dmg_variance_max); - int damage = (base_damage * variance) / 100; - if (damage < 1) - damage = 1; - - // 10% critical hit chance for 1.5x - if (rng_int(0, 9) == 0) { - damage = (damage * 3) / 2; - last_event.is_critical = 1; - } - - e->hp -= damage; - last_event.damage = damage; - last_event.is_player_damage = 0; - - if (e->hp <= 0) { - e->hp = 0; - e->alive = 0; - last_event.message = "Enemy killed!"; - } else if (last_event.is_critical) { - last_event.message = "Critical hit!"; - } else { - last_event.message = "You hit"; - } - } else { - last_event.damage = 0; - last_event.is_player_damage = 0; - last_event.message = "You missed"; - } -} - -void combat_enemy_attack(Enemy *e, Player *p) { - if (e == NULL || !e->alive) - return; - if (p == NULL) - return; - - last_event.is_critical = 0; - - // 85% hit chance for enemies - if (rng_int(0, 99) < 85) { - // calculate damage with variance - int base_damage = e->attack - p->defense; - if (base_damage < 1) - base_damage = 1; - - int variance = rng_int(80, 120); - int damage = (base_damage * variance) / 100; - if (damage < 1) - damage = 1; - - // 5% critical hit chance for enemies - if (rng_int(0, 19) == 0) { - damage = (damage * 3) / 2; - last_event.is_critical = 1; - } - - p->hp -= damage; - last_event.damage = damage; - last_event.is_player_damage = 1; - - if (p->hp <= 0) { - p->hp = 0; - last_event.message = "You died!"; - } else if (last_event.is_critical) { - last_event.message = "Critical!"; - } else { - last_event.message = "Hit"; - } - } else { - last_event.damage = 0; - last_event.is_player_damage = 1; - last_event.message = "Missed"; - } -} - -void combat_reset_event(void) { - last_event.message = NULL; - last_event.damage = 0; - last_event.is_player_damage = 0; - last_event.is_critical = 0; -} diff --git a/src/combat.h b/src/combat.h index c53f6a7..62295d3 100644 --- a/src/combat.h +++ b/src/combat.h @@ -15,6 +15,18 @@ int combat_was_player_damage(void); // Was it a critical hit? int combat_was_critical(void); +// Was the attack dodged? +int combat_was_dodged(void); + +// Was the attack blocked? +int combat_was_blocked(void); + +// Get block amount from last event +int combat_get_block_amount(void); + +// Get the status effect applied in last event +StatusEffectType combat_get_applied_effect(void); + // Reset combat event void combat_reset_event(void); @@ -24,4 +36,18 @@ void combat_player_attack(Player *p, Enemy *e); // Enemy attacks player void combat_enemy_attack(Enemy *e, Player *p); +// Tick status effects on the player (call at start of turn) +// Returns total damage dealt by effects this tick +int combat_tick_effects(Player *p); + +// Tick status effects on an enemy (call at start of turn) +// Returns total damage dealt by effects this tick +int combat_tick_enemy_effects(Enemy *e); + +// Apply a status effect to an effect array, stacking/refreshing if already present +void combat_apply_effect(StatusEffect effects[], int *count, StatusEffectType type, int duration, int intensity); + +// Check if an entity has a specific effect active +int combat_has_effect(const StatusEffect effects[], int count, StatusEffectType type); + #endif // COMBAT_H diff --git a/src/common.h b/src/common.h index b963c3b..70d1ba3 100644 --- a/src/common.h +++ b/src/common.h @@ -6,6 +6,19 @@ // Tile types typedef enum { TILE_WALL, TILE_FLOOR, TILE_STAIRS } TileType; +// Status effect types +typedef enum { EFFECT_NONE, EFFECT_POISON, EFFECT_STUN, EFFECT_BLEED, EFFECT_WEAKEN, EFFECT_BURN } StatusEffectType; + +// Damage classes +typedef enum { DMG_SLASH, DMG_IMPACT, DMG_PIERCE, DMG_FIRE, DMG_POISON } DamageClass; + +// Status effect instance +typedef struct { + StatusEffectType type; + int duration; // turns remaining + int intensity; // damage per tick or stat reduction amount +} StatusEffect; + // Room typedef struct { int x, y, w, h; @@ -35,6 +48,10 @@ typedef struct { int power; int floor; int picked_up; + DamageClass dmg_class; + int crit_chance; + int crit_multiplier; + int status_chance; } Item; // Player @@ -47,15 +64,17 @@ typedef struct { int step_count; int speed; // actions per 100 ticks (100 = 1 action per turn) int cooldown; // countdown to next action (0 = can act) + int dodge; // dodge chance percentage + int block; // flat damage reduction on successful block roll Item equipped_weapon; int has_weapon; Item equipped_armor; int has_armor; Item inventory[MAX_INVENTORY]; int inventory_count; - // damage variance range (0.8 to 1.2 = 80 to 120) - int dmg_variance_min; // minimum damage multiplier (80 = 0.8x) - int dmg_variance_max; // maximum damage multiplier (120 = 1.2x) + // status effects + StatusEffect effects[MAX_EFFECTS]; + int effect_count; } Player; // Enemy types @@ -71,6 +90,14 @@ typedef struct { EnemyType type; int speed; // actions per 100 ticks int cooldown; // countdown to next action + int dodge; // dodge chance percentage + int block; // flat damage reduction + int resistance[NUM_DMG_CLASSES]; + DamageClass dmg_class; + int status_chance; + // status effects + StatusEffect effects[MAX_EFFECTS]; + int effect_count; } Enemy; // Floating damage text diff --git a/src/enemy.c b/src/enemy.c index f6ca10f..f90415c 100644 --- a/src/enemy.c +++ b/src/enemy.c @@ -3,6 +3,7 @@ #include "common.h" #include "map.h" #include "rng.h" +#include // Forward declaration int is_enemy_at(const Enemy *enemies, int count, int x, int y); @@ -39,10 +40,12 @@ void enemy_spawn(Enemy enemies[], int *count, Map *map, Player *p, int floor) { // Create enemy Enemy e; + memset(&e, 0, sizeof(Enemy)); e.x = ex; e.y = ey; e.alive = 1; e.type = rng_int(ENEMY_GOBLIN, max_type); + e.effect_count = 0; // Stats based on type and floor switch (e.type) { @@ -51,24 +54,56 @@ void enemy_spawn(Enemy enemies[], int *count, Map *map, Player *p, int floor) { e.hp = e.max_hp; e.attack = ENEMY_BASE_ATTACK; e.speed = 55 + rng_int(0, 10); + e.dodge = 15; + e.block = 0; + e.dmg_class = DMG_POISON; + e.status_chance = 15; + e.resistance[DMG_SLASH] = 0; + e.resistance[DMG_IMPACT] = 0; + e.resistance[DMG_PIERCE] = 0; + e.resistance[DMG_FIRE] = -25; + e.resistance[DMG_POISON] = 50; break; case ENEMY_SKELETON: e.max_hp = ENEMY_BASE_HP + floor + 2; e.hp = e.max_hp; e.attack = ENEMY_BASE_ATTACK + 1; e.speed = 70 + rng_int(0, 10); + e.dodge = 5; + e.block = 0; + e.dmg_class = DMG_SLASH; + e.status_chance = 10; + e.resistance[DMG_SLASH] = -25; + e.resistance[DMG_IMPACT] = -50; + e.resistance[DMG_PIERCE] = 50; + e.resistance[DMG_FIRE] = 25; + e.resistance[DMG_POISON] = 100; break; case ENEMY_ORC: e.max_hp = ENEMY_BASE_HP + floor + 4; e.hp = e.max_hp; e.attack = ENEMY_BASE_ATTACK + 2; e.speed = 85 + rng_int(0, 10); + e.dodge = 0; + e.block = 3; + e.dmg_class = DMG_IMPACT; + e.status_chance = 20; + e.resistance[DMG_SLASH] = 0; + e.resistance[DMG_IMPACT] = 25; + e.resistance[DMG_PIERCE] = -25; + e.resistance[DMG_FIRE] = 0; + e.resistance[DMG_POISON] = 0; break; default: e.max_hp = ENEMY_BASE_HP; e.hp = e.max_hp; e.attack = ENEMY_BASE_ATTACK; e.speed = 60; + e.dodge = 0; + e.block = 0; + e.dmg_class = DMG_IMPACT; + e.status_chance = 0; + memset(e.resistance, 0, sizeof(e.resistance)); break; } e.cooldown = e.speed; @@ -137,6 +172,10 @@ void enemy_act(Enemy *e, Player *p, Map *map, Enemy *all_enemies, int enemy_coun if (!e->alive) return; + // Stunned enemies skip their action + if (combat_has_effect(e->effects, e->effect_count, EFFECT_STUN)) + return; + // Check if adjacent to player - attack if (can_see_player(e, p)) { combat_enemy_attack(e, p); diff --git a/src/items.c b/src/items.c index c758955..e5bf212 100644 --- a/src/items.c +++ b/src/items.c @@ -1,8 +1,24 @@ #include "common.h" #include "map.h" #include "rng.h" +#include "settings.h" #include +typedef struct { + const char *name; + DamageClass dmg_class; + int base_power; + int crit_chance; + int crit_multiplier; + int status_chance; +} WeaponTemplate; + +static const WeaponTemplate weapon_templates[NUM_WEAPON_TEMPLATES] = { + {"Dagger", DMG_SLASH, 1, 25, 200, 20}, {"Mace", DMG_IMPACT, 2, 10, 150, 30}, + {"Spear", DMG_PIERCE, 2, 15, 175, 25}, {"Torch", DMG_FIRE, 1, 5, 150, 40}, + {"Venom Blade", DMG_POISON, 1, 15, 175, 35}, +}; + void item_spawn(Item items[], int *count, Map *map, int floor) { *count = 0; @@ -33,6 +49,10 @@ void item_spawn(Item items[], int *count, Map *map, int floor) { item.y = iy; item.floor = floor; item.picked_up = 0; + item.dmg_class = DMG_SLASH; + item.crit_chance = 0; + item.crit_multiplier = 100; + item.status_chance = 0; // Item type distribution int type_roll = rng_int(0, 99); @@ -40,18 +60,24 @@ void item_spawn(Item items[], int *count, Map *map, int floor) { if (type_roll < 50) { // 50% chance for potion item.type = ITEM_POTION; - item.power = 5 + rng_int(0, floor * 2); // healing: 5 + 0-2*floor + item.power = 5 + rng_int(0, floor * 2); } else if (type_roll < 80) { - // 30% chance for weapon + // 30% chance for weapon, pick a random template item.type = ITEM_WEAPON; - item.power = 1 + rng_int(0, floor); // attack bonus: 1 + 0-floor + int tmpl_idx = rng_int(0, NUM_WEAPON_TEMPLATES - 1); + const WeaponTemplate *tmpl = &weapon_templates[tmpl_idx]; + item.power = tmpl->base_power + rng_int(0, floor); + item.dmg_class = tmpl->dmg_class; + item.crit_chance = tmpl->crit_chance; + item.crit_multiplier = tmpl->crit_multiplier; + item.status_chance = tmpl->status_chance; } else { // 20% chance for armor item.type = ITEM_ARMOR; - item.power = 1 + rng_int(0, floor / 2); // defense bonus + item.power = 1 + rng_int(0, floor / 2); } - items[i] = item; + items[*count] = item; (*count)++; } } @@ -65,7 +91,20 @@ const char *item_get_name(const Item *i) { case ITEM_POTION: return "Potion"; case ITEM_WEAPON: - return "Weapon"; + switch (i->dmg_class) { + case DMG_SLASH: + return "Dagger"; + case DMG_IMPACT: + return "Mace"; + case DMG_PIERCE: + return "Spear"; + case DMG_FIRE: + return "Torch"; + case DMG_POISON: + return "Venom Blade"; + default: + return "Weapon"; + } case ITEM_ARMOR: return "Armor"; default: @@ -124,3 +163,20 @@ void item_use(Player *p, Item *i) { break; } } + +const char *dmg_class_get_short(DamageClass dc) { + switch (dc) { + case DMG_SLASH: + return "SLA"; + case DMG_IMPACT: + return "IMP"; + case DMG_PIERCE: + return "PRC"; + case DMG_FIRE: + return "FIR"; + case DMG_POISON: + return "PSN"; + default: + return "???"; + } +} diff --git a/src/items.h b/src/items.h index fa913f2..86e40e2 100644 --- a/src/items.h +++ b/src/items.h @@ -20,4 +20,7 @@ const char *item_get_description(const Item *i); // Get item power value int item_get_power(const Item *i); +// Get short label for a damage class (SLA/IMP/PRC/FIR/PSN) +const char *dmg_class_get_short(DamageClass dc); + #endif // ITEMS_H diff --git a/src/main.c b/src/main.c index cf0e9d1..c1e5f1c 100644 --- a/src/main.c +++ b/src/main.c @@ -25,14 +25,26 @@ static void add_log(GameState *gs, const char *msg) { // 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) { - gs->floating_texts[gs->floating_count].x = x; - gs->floating_texts[gs->floating_count].y = y; - gs->floating_texts[gs->floating_count].value = value; - gs->floating_texts[gs->floating_count].lifetime = 60; - gs->floating_texts[gs->floating_count].is_critical = is_critical; + 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; + gs->floating_texts[slot].y = y; + gs->floating_texts[slot].value = value; + gs->floating_texts[slot].lifetime = 60; + gs->floating_texts[slot].is_critical = is_critical; } // update floating texts and screen shake @@ -87,6 +99,37 @@ static void init_floor(GameState *gs, int floor_num) { gs->turn_count = 0; } +// Tick all status effects at the start of a turn +static void tick_all_effects(GameState *gs) { + // Player effects + int player_effect_dmg = combat_tick_effects(&gs->player); + if (player_effect_dmg > 0) { + spawn_floating_text(gs, gs->player.x * TILE_SIZE + 8, gs->player.y * TILE_SIZE, player_effect_dmg, 0); + gs->screen_shake = 4; + } + + // Check if player died from effects + if (gs->player.hp <= 0) { + gs->player.hp = 0; + gs->game_over = 1; + return; + } + + // Enemy effects + for (int i = 0; i < gs->enemy_count; i++) { + Enemy *e = &gs->enemies[i]; + if (!e->alive) + continue; + int enemy_effect_dmg = combat_tick_enemy_effects(e); + if (enemy_effect_dmg > 0) { + spawn_floating_text(gs, e->x * TILE_SIZE + 8, e->y * TILE_SIZE, enemy_effect_dmg, 0); + } + if (!e->alive) { + add_log(gs, "Enemy died from effects!"); + } + } +} + // Handle player input - returns: 0=continue, 1=acted, -1=quit static int handle_input(GameState *gs) { int dx = 0, dy = 0; @@ -103,6 +146,25 @@ static int handle_input(GameState *gs) { return 0; } + // If player is stunned, wait for any key then consume the turn + if (combat_has_effect(gs->player.effects, gs->player.effect_count, EFFECT_STUN)) { + if (!(IsKeyDown(KEY_W) || IsKeyDown(KEY_UP) || IsKeyDown(KEY_S) || IsKeyDown(KEY_DOWN) || IsKeyDown(KEY_A) || + IsKeyDown(KEY_LEFT) || IsKeyDown(KEY_D) || IsKeyDown(KEY_RIGHT))) + return 0; + gs->turn_count++; + tick_all_effects(gs); + if (gs->game_over) + return 1; + enemy_update_all(gs->enemies, gs->enemy_count, &gs->player, &gs->map); + if (gs->player.hp <= 0) { + gs->game_over = 1; + } + gs->last_message = "You are stunned!"; + gs->message_timer = 60; + add_log(gs, "Stunned! Lost a turn."); + return 1; + } + if (gs->show_inventory) { if (IsKeyPressed(KEY_I) || IsKeyPressed(KEY_ESCAPE)) { gs->show_inventory = 0; @@ -298,6 +360,11 @@ static int handle_input(GameState *gs) { // Increment turn counter gs->turn_count++; + // Tick status effects at the start of this turn + tick_all_effects(gs); + if (gs->game_over) + return 1; + // Check if stepped on stairs if (gs->map.tiles[gs->player.y][gs->player.x] == TILE_STAIRS) { gs->awaiting_descend = 1; @@ -358,6 +425,8 @@ static void game_loop(void) { while (!WindowShouldClose()) { // Handle input if (!gs.game_over) { + // Tick status effects at the start of each frame where input is checked + // (effects tick once per player action via the acted flag below) int quit = handle_input(&gs); if (quit == -1) break; diff --git a/src/player.c b/src/player.c index 218d855..9dbadf1 100644 --- a/src/player.c +++ b/src/player.c @@ -3,6 +3,7 @@ #include "common.h" #include "items.h" #include "map.h" +#include "settings.h" #include "utils.h" #include #include @@ -18,6 +19,8 @@ void player_init(Player *p, int x, int y) { p->step_count = 0; p->speed = 100; p->cooldown = 0; + p->dodge = PLAYER_BASE_DODGE; + p->block = PLAYER_BASE_BLOCK; p->has_weapon = 0; p->has_armor = 0; memset(&p->equipped_weapon, 0, sizeof(Item)); @@ -25,8 +28,8 @@ void player_init(Player *p, int x, int y) { p->equipped_weapon.picked_up = 1; p->equipped_armor.picked_up = 1; // mark as invalid p->inventory_count = 0; - p->dmg_variance_min = 80; - p->dmg_variance_max = 120; + p->effect_count = 0; + memset(p->effects, 0, sizeof(p->effects)); // Initialize inventory to empty for (int i = 0; i < MAX_INVENTORY; i++) { @@ -70,7 +73,11 @@ int player_move(Player *p, int dx, int dy, Map *map, Enemy *enemies, int enemy_c p->x = new_x; p->y = new_y; p->step_count += 1; - if (p->step_count % 15 == 0 && p->hp < p->max_hp) { + // Regen suppressed while poisoned, bleeding, or burning + if (p->step_count % REGEN_STEP_INTERVAL == 0 && p->hp < p->max_hp && + !combat_has_effect(p->effects, p->effect_count, EFFECT_POISON) && + !combat_has_effect(p->effects, p->effect_count, EFFECT_BLEED) && + !combat_has_effect(p->effects, p->effect_count, EFFECT_BURN)) { p->hp += 1; } return 1; @@ -188,16 +195,6 @@ int player_equip_item(Player *p, int inv_index) { p->equipped_weapon = *item; p->has_weapon = 1; p->attack += item->power; - // Adjust damage variance based on weapon power - // Higher power = wider range (more swingy but higher potential) - int min_var = 100 - (item->power * 3); - int max_var = 100 + (item->power * 5); - if (min_var < 60) - min_var = 60; - if (max_var > 150) - max_var = 150; - p->dmg_variance_min = min_var; - p->dmg_variance_max = max_var; // Remove from inventory player_remove_inventory_item(p, inv_index); return 1; @@ -207,11 +204,15 @@ int player_equip_item(Player *p, int inv_index) { // Unequip current armor first if (p->has_armor) { p->defense -= p->equipped_armor.power; + p->block -= p->equipped_armor.power / 2; + if (p->block < PLAYER_BASE_BLOCK) + p->block = PLAYER_BASE_BLOCK; } // Equip new armor p->equipped_armor = *item; p->has_armor = 1; p->defense += item->power; + p->block += item->power / 2; // armor grants block bonus // Remove from inventory player_remove_inventory_item(p, inv_index); return 1; diff --git a/src/render.c b/src/render.c index de0c254..b8f32ab 100644 --- a/src/render.c +++ b/src/render.c @@ -138,24 +138,62 @@ void render_ui(const Player *p) { int hp_text_w = MeasureText(hp_text, 14); DrawText(hp_text, bar_x + (bar_width - hp_text_w) / 2, bar_y + 2, 14, WHITE); + // Status effect indicators next to HP bar + int effect_x = bar_x + bar_width + 5; + for (int i = 0; i < p->effect_count && i < MAX_EFFECTS; i++) { + Color eff_color; + const char *eff_label = ""; + switch (p->effects[i].type) { + case EFFECT_POISON: + eff_color = (Color){50, 200, 50, 255}; + eff_label = "PSN"; + break; + case EFFECT_BLEED: + eff_color = (Color){200, 50, 50, 255}; + eff_label = "BLD"; + break; + case EFFECT_STUN: + eff_color = (Color){200, 200, 50, 255}; + eff_label = "STN"; + break; + case EFFECT_WEAKEN: + eff_color = (Color){120, 120, 120, 255}; + eff_label = "WKN"; + break; + case EFFECT_BURN: + eff_color = (Color){230, 130, 30, 255}; + eff_label = "BRN"; + break; + default: + continue; + } + if (p->effects[i].duration > 0) { + char eff_text[16]; + snprintf(eff_text, sizeof(eff_text), "%s%d", eff_label, p->effects[i].duration); + DrawText(eff_text, effect_x, bar_y, 12, eff_color); + effect_x += 40; + } + } + // Stats row 1: Floor, ATK, DEF, Inv + int stats_x_start = (effect_x > bar_x + bar_width + 15) ? effect_x + 10 : bar_x + bar_width + 15; int stats_y = bar_y; - DrawText("F1", bar_x + bar_width + 15, stats_y, 14, WHITE); - DrawText("ATK", bar_x + bar_width + 50, stats_y, 14, YELLOW); - DrawText("DEF", bar_x + bar_width + 100, stats_y, 14, BLUE); - DrawText("INV", bar_x + bar_width + 145, stats_y, 14, GREEN); + DrawText("F1", stats_x_start, stats_y, 14, WHITE); + DrawText("ATK", stats_x_start + 35, stats_y, 14, YELLOW); + DrawText("DEF", stats_x_start + 85, stats_y, 14, BLUE); + DrawText("INV", stats_x_start + 130, stats_y, 14, GREEN); // Row 2: equipment slots and controls int row2_y = stats_y + 24; // Equipment (left side of row 2) if (p->has_weapon) { - char weapon_text[48]; - snprintf(weapon_text, sizeof(weapon_text), "Wpn:%s +%d", item_get_name(&p->equipped_weapon), - p->equipped_weapon.power); + char weapon_text[64]; + snprintf(weapon_text, sizeof(weapon_text), "Wpn:%s +%d [%s]", item_get_name(&p->equipped_weapon), + p->equipped_weapon.power, dmg_class_get_short(p->equipped_weapon.dmg_class)); DrawText(weapon_text, 10, row2_y, 12, YELLOW); } else { - DrawText("Wpn:---", 10, row2_y, 12, (Color){60, 60, 60, 255}); + DrawText("Wpn:--- [IMP]", 10, row2_y, 12, (Color){60, 60, 60, 255}); } if (p->has_armor) { diff --git a/src/settings.h b/src/settings.h index 47f3c12..5b6b39b 100644 --- a/src/settings.h +++ b/src/settings.h @@ -25,4 +25,31 @@ #define NUM_FLOORS 5 #define MAX_INVENTORY 10 +// Damage Classes +#define NUM_DMG_CLASSES 5 + +// Status Effects +#define MAX_EFFECTS 4 +#define POISON_BASE_DAMAGE 1 +#define BLEED_STACK_DAMAGE 1 +#define BURN_BASE_DAMAGE 2 +#define WEAKEN_ATTACK_REDUCTION 2 +#define REGEN_STEP_INTERVAL 15 + +// Unarmed combat defaults +#define UNARMED_CRIT_CHANCE 5 +#define UNARMED_CRIT_MULT 150 +#define UNARMED_STATUS_CHANCE 0 + +// Weapon templates +#define NUM_WEAPON_TEMPLATES 5 + +// Enemy combat defaults +#define ENEMY_CRIT_CHANCE 5 +#define ENEMY_CRIT_MULT 150 + +// Dodge/Block defaults +#define PLAYER_BASE_DODGE 5 +#define PLAYER_BASE_BLOCK 0 + #endif // SETTINGS_H From ee116ef33f7d5e673fd7a75f78fc613ee26f1d86 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sun, 5 Apr 2026 20:11:37 +0300 Subject: [PATCH 09/16] refactor: split `player_move` and decompose `handle_input` Signed-off-by: NotAShelf Change-Id: Iaac0cda778dd541eb34980f3e902ca726a6a6964 --- src/main.c | 479 ++++++++++++++++++++++++++------------------------- src/player.c | 26 +-- src/player.h | 7 +- 3 files changed, 255 insertions(+), 257 deletions(-) diff --git a/src/main.c b/src/main.c index c1e5f1c..651c7f0 100644 --- a/src/main.c +++ b/src/main.c @@ -130,184 +130,201 @@ static void tick_all_effects(GameState *gs) { } } -// Handle player input - returns: 0=continue, 1=acted, -1=quit -static int handle_input(GameState *gs) { - int dx = 0, dy = 0; +static void post_action(GameState *gs) { + gs->turn_count++; - // Check for quit first (always works) - if (IsKeyPressed(KEY_Q)) { - return -1; + // Tick status effects at the start of this turn + tick_all_effects(gs); + if (gs->game_over) + return; + + // Check if stepped on stairs + if (gs->map.tiles[gs->player.y][gs->player.x] == TILE_STAIRS) { + gs->awaiting_descend = 1; + gs->last_message = "Descend to next floor? (Y/N)"; + gs->message_timer = 120; + return; } - // Check for restart (works during game over) - if (IsKeyPressed(KEY_R) && gs->game_over) { - memset(gs, 0, sizeof(GameState)); - init_floor(gs, 1); + // 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; + } + } + } + + // Enemy turns - uses speed/cooldown system + enemy_update_all(gs->enemies, gs->enemy_count, &gs->player, &gs->map); + + // Check if player took damage + if (combat_was_player_damage() && combat_get_last_damage() > 0) { + audio_play_player_damage(); + gs->screen_shake = 8; + spawn_floating_text(gs, gs->player.x * TILE_SIZE + 8, gs->player.y * TILE_SIZE, combat_get_last_damage(), + combat_was_critical()); + } + + // Set message and check game over + gs->last_message = combat_get_last_message(); + gs->message_timer = 60; + + if (gs->player.hp <= 0) + gs->game_over = 1; +} + +// If player is stunned, wait for any key then consume the turn +static int handle_stun_turn(GameState *gs) { + if (!(IsKeyDown(KEY_W) || IsKeyDown(KEY_UP) || IsKeyDown(KEY_S) || IsKeyDown(KEY_DOWN) || IsKeyDown(KEY_A) || + IsKeyDown(KEY_LEFT) || IsKeyDown(KEY_D) || IsKeyDown(KEY_RIGHT))) + return 0; + gs->turn_count++; + tick_all_effects(gs); + if (gs->game_over) + return 1; + enemy_update_all(gs->enemies, gs->enemy_count, &gs->player, &gs->map); + if (gs->player.hp <= 0) + gs->game_over = 1; + gs->last_message = "You are stunned!"; + gs->message_timer = 60; + add_log(gs, "Stunned! Lost a turn."); + return 1; +} + +static int handle_inventory_input(GameState *gs) { + if (IsKeyPressed(KEY_I) || IsKeyPressed(KEY_ESCAPE)) { + gs->show_inventory = 0; return 0; } - // If player is stunned, wait for any key then consume the turn - if (combat_has_effect(gs->player.effects, gs->player.effect_count, EFFECT_STUN)) { - if (!(IsKeyDown(KEY_W) || IsKeyDown(KEY_UP) || IsKeyDown(KEY_S) || IsKeyDown(KEY_DOWN) || IsKeyDown(KEY_A) || - IsKeyDown(KEY_LEFT) || IsKeyDown(KEY_D) || IsKeyDown(KEY_RIGHT))) - return 0; - gs->turn_count++; - tick_all_effects(gs); - if (gs->game_over) + if (IsKeyPressed(KEY_DOWN) || IsKeyPressed(KEY_S)) { + gs->inv_selected++; + if (gs->inv_selected >= gs->player.inventory_count) + gs->inv_selected = 0; + return 0; + } + if (IsKeyPressed(KEY_UP) || IsKeyPressed(KEY_W)) { + gs->inv_selected = (gs->inv_selected == 0) ? (gs->player.inventory_count > 0 ? gs->player.inventory_count - 1 : 0) + : gs->inv_selected - 1; + return 0; + } + + if (IsKeyPressed(KEY_ONE)) + gs->inv_selected = 0; + if (IsKeyPressed(KEY_TWO)) + gs->inv_selected = 1; + if (IsKeyPressed(KEY_THREE)) + gs->inv_selected = 2; + if (IsKeyPressed(KEY_FOUR)) + gs->inv_selected = 3; + if (IsKeyPressed(KEY_FIVE)) + gs->inv_selected = 4; + if (IsKeyPressed(KEY_SIX)) + gs->inv_selected = 5; + if (IsKeyPressed(KEY_SEVEN)) + gs->inv_selected = 6; + if (IsKeyPressed(KEY_EIGHT)) + gs->inv_selected = 7; + if (IsKeyPressed(KEY_NINE)) + gs->inv_selected = 8; + if (IsKeyPressed(KEY_ZERO)) + gs->inv_selected = 9; + + // E to equip selected item + if (IsKeyPressed(KEY_E)) { + if (player_equip_item(&gs->player, gs->inv_selected)) { + gs->last_message = "Item equipped!"; + gs->message_timer = 60; + add_log(gs, "Equipped item"); + if (gs->inv_selected >= gs->player.inventory_count && gs->inv_selected > 0) + gs->inv_selected--; return 1; - enemy_update_all(gs->enemies, gs->enemy_count, &gs->player, &gs->map); - if (gs->player.hp <= 0) { + } + gs->last_message = "Cannot equip that!"; + gs->message_timer = 60; + } + + // U or Enter to use selected item + if (IsKeyPressed(KEY_U) || IsKeyPressed(KEY_ENTER)) { + if (gs->player.inventory_count > 0) { + Item *item = player_get_inventory_item(&gs->player, gs->inv_selected); + if (item != NULL) { + if (item->type == ITEM_POTION) { + player_use_item(&gs->player, item); + player_remove_inventory_item(&gs->player, gs->inv_selected); + gs->last_message = "Used potion!"; + gs->message_timer = 60; + add_log(gs, "Used potion"); + gs->show_inventory = 0; + return 1; + } + gs->last_message = "Equip weapons/armor with E!"; + gs->message_timer = 60; + } + } + } + + // D to drop selected item + if (IsKeyPressed(KEY_D)) { + if (gs->player.inventory_count > 0) { + Item *item = player_get_inventory_item(&gs->player, gs->inv_selected); + if (item != NULL) { + char drop_msg[64]; + snprintf(drop_msg, sizeof(drop_msg), "Dropped %s", item_get_name(item)); + if (player_drop_item(&gs->player, gs->inv_selected, gs->items, gs->item_count)) { + add_log(gs, drop_msg); + gs->last_message = "Item dropped!"; + gs->message_timer = 60; + if (gs->inv_selected >= gs->player.inventory_count && gs->inv_selected > 0) + gs->inv_selected--; + return 1; + } + gs->last_message = "Cannot drop!"; + gs->message_timer = 60; + } + } + } + + return 0; +} + +static int handle_descend_input(GameState *gs) { + if (IsKeyPressed(KEY_Y)) { + if (gs->player.floor < NUM_FLOORS) { + audio_play_stairs(); + init_floor(gs, gs->player.floor + 1); + gs->last_message = "Descended to next floor!"; + gs->message_timer = 60; + add_log(gs, "Descended stairs"); + } else { + gs->game_won = 1; gs->game_over = 1; } - gs->last_message = "You are stunned!"; - gs->message_timer = 60; - add_log(gs, "Stunned! Lost a turn."); + gs->awaiting_descend = 0; return 1; } - - if (gs->show_inventory) { - if (IsKeyPressed(KEY_I) || IsKeyPressed(KEY_ESCAPE)) { - gs->show_inventory = 0; - return 0; - } - - if (IsKeyPressed(KEY_DOWN) || IsKeyPressed(KEY_S)) { - gs->inv_selected++; - if (gs->inv_selected >= gs->player.inventory_count) { - gs->inv_selected = 0; - } - return 0; - } - if (IsKeyPressed(KEY_UP) || IsKeyPressed(KEY_W)) { - if (gs->inv_selected == 0) { - gs->inv_selected = (gs->player.inventory_count > 0) ? gs->player.inventory_count - 1 : 0; - } else { - gs->inv_selected--; - } - return 0; - } - - if (IsKeyPressed(KEY_ONE)) - gs->inv_selected = 0; - if (IsKeyPressed(KEY_TWO)) - gs->inv_selected = 1; - if (IsKeyPressed(KEY_THREE)) - gs->inv_selected = 2; - if (IsKeyPressed(KEY_FOUR)) - gs->inv_selected = 3; - if (IsKeyPressed(KEY_FIVE)) - gs->inv_selected = 4; - if (IsKeyPressed(KEY_SIX)) - gs->inv_selected = 5; - if (IsKeyPressed(KEY_SEVEN)) - gs->inv_selected = 6; - if (IsKeyPressed(KEY_EIGHT)) - gs->inv_selected = 7; - if (IsKeyPressed(KEY_NINE)) - gs->inv_selected = 8; - if (IsKeyPressed(KEY_ZERO)) - gs->inv_selected = 9; - - // E to equip selected item - if (IsKeyPressed(KEY_E)) { - if (player_equip_item(&gs->player, gs->inv_selected)) { - gs->last_message = "Item equipped!"; - gs->message_timer = 60; - add_log(gs, "Equipped item"); - if (gs->inv_selected >= gs->player.inventory_count && gs->inv_selected > 0) { - gs->inv_selected--; - } - return 1; - } else { - gs->last_message = "Cannot equip that!"; - gs->message_timer = 60; - } - } - - // U or Enter to use selected item - if (IsKeyPressed(KEY_U) || IsKeyPressed(KEY_ENTER)) { - if (gs->player.inventory_count > 0) { - Item *item = player_get_inventory_item(&gs->player, gs->inv_selected); - if (item != NULL) { - if (item->type == ITEM_POTION) { - player_use_item(&gs->player, item); - player_remove_inventory_item(&gs->player, gs->inv_selected); - gs->last_message = "Used potion!"; - gs->message_timer = 60; - add_log(gs, "Used potion"); - gs->show_inventory = 0; - return 1; - } else { - gs->last_message = "Equip weapons/armor with E!"; - gs->message_timer = 60; - } - } - } - } - - // D to drop selected item - if (IsKeyPressed(KEY_D)) { - if (gs->player.inventory_count > 0) { - Item *item = player_get_inventory_item(&gs->player, gs->inv_selected); - if (item != NULL) { - char drop_msg[64]; - snprintf(drop_msg, sizeof(drop_msg), "Dropped %s", item_get_name(item)); - if (player_drop_item(&gs->player, gs->inv_selected, gs->items, gs->item_count)) { - add_log(gs, drop_msg); - gs->last_message = "Item dropped!"; - gs->message_timer = 60; - if (gs->inv_selected >= gs->player.inventory_count && gs->inv_selected > 0) { - gs->inv_selected--; - } - return 1; - } else { - gs->last_message = "Cannot drop!"; - gs->message_timer = 60; - } - } - } - } - - return 0; - } - - // Handle descend confirmation - if (gs->awaiting_descend) { - if (IsKeyPressed(KEY_Y)) { - // Descend - if (gs->player.floor < NUM_FLOORS) { - audio_play_stairs(); - init_floor(gs, gs->player.floor + 1); - gs->last_message = "Descended to next floor!"; - gs->message_timer = 60; - add_log(gs, "Descended stairs"); - gs->awaiting_descend = 0; - return 1; - } else { - gs->game_won = 1; - gs->game_over = 1; - gs->awaiting_descend = 0; - return 1; - } - } - if (IsKeyPressed(KEY_N)) { - gs->awaiting_descend = 0; - gs->last_message = "Stayed on floor."; - gs->message_timer = 60; - return 1; - } - return 0; + if (IsKeyPressed(KEY_N)) { + gs->awaiting_descend = 0; + gs->last_message = "Stayed on floor."; + gs->message_timer = 60; + return 1; } + return 0; +} +static int handle_movement_input(GameState *gs) { // Check for inventory toggle (I key) - if (IsKeyPressed(KEY_I) && !gs->game_over) { + if (IsKeyPressed(KEY_I)) { gs->show_inventory = 1; gs->inv_selected = 0; - return 0; // don't consume turn + return 0; } // Check for manual item pickup (G key) - if (IsKeyPressed(KEY_G) && !gs->game_over) { + if (IsKeyPressed(KEY_G)) { Item *item = get_item_at_floor(gs->items, gs->item_count, gs->player.x, gs->player.y); if (item != NULL) { if (player_pickup(&gs->player, item)) { @@ -317,97 +334,83 @@ static int handle_input(GameState *gs) { gs->last_message = "Picked up item!"; gs->message_timer = 60; audio_play_item_pickup(); - return 1; } else { gs->last_message = "Inventory full!"; gs->message_timer = 60; - return 1; } + return 1; } } // Check for item usage (U key - use first potion) - if (IsKeyPressed(KEY_U) && !gs->game_over) { - if (gs->player.inventory_count > 0) { - if (player_use_first_item(&gs->player)) { - gs->last_message = "Used potion!"; - gs->message_timer = 60; - audio_play_item_pickup(); - return 1; // consume a turn - } - } - } - - // Movement, use iskeydown for held key repeat, with delay - if (IsKeyDown(KEY_W) || IsKeyDown(KEY_UP)) { - dy = -1; - } else if (IsKeyDown(KEY_S) || IsKeyDown(KEY_DOWN)) { - dy = 1; - } else if (IsKeyDown(KEY_A) || IsKeyDown(KEY_LEFT)) { - dx = -1; - } else if (IsKeyDown(KEY_D) || IsKeyDown(KEY_RIGHT)) { - dx = 1; - } - - if (dx != 0 || dy != 0) { - // Reset combat message - combat_reset_event(); - - // Player action - int action = player_move(&gs->player, dx, dy, &gs->map, gs->enemies, gs->enemy_count); - - if (action) { - // Increment turn counter - gs->turn_count++; - - // Tick status effects at the start of this turn - tick_all_effects(gs); - if (gs->game_over) - return 1; - - // Check if stepped on stairs - if (gs->map.tiles[gs->player.y][gs->player.x] == TILE_STAIRS) { - gs->awaiting_descend = 1; - gs->last_message = "Descend to next floor? (Y/N)"; - gs->message_timer = 120; - return 1; - } - - // combat feedback - player attacked enemy - if (combat_get_last_damage() > 0 && !combat_was_player_damage()) { - // find the enemy we attacked - for (int i = 0; i < gs->enemy_count; i++) { - if (!gs->enemies[i].alive && combat_get_last_damage() > 0) { - 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; - } - } - } - - // Enemy turns - now uses speed/cooldown system (no more % 2 hack) - enemy_update_all(gs->enemies, gs->enemy_count, &gs->player, &gs->map); - - // Check if player took damage - if (combat_was_player_damage() && combat_get_last_damage() > 0) { - audio_play_player_damage(); - gs->screen_shake = 8; - spawn_floating_text(gs, gs->player.x * TILE_SIZE + 8, gs->player.y * TILE_SIZE, combat_get_last_damage(), - combat_was_critical()); - } - - // Set message - gs->last_message = combat_get_last_message(); + if (IsKeyPressed(KEY_U)) { + if (gs->player.inventory_count > 0 && player_use_first_item(&gs->player)) { + gs->last_message = "Used potion!"; gs->message_timer = 60; - - // Check game over - if (gs->player.hp <= 0) { - gs->game_over = 1; - } + audio_play_item_pickup(); + return 1; } } - return 0; + // Movement: use IsKeyDown for held-key repeat + int dx = 0, dy = 0; + if (IsKeyDown(KEY_W) || IsKeyDown(KEY_UP)) + dy = -1; + else if (IsKeyDown(KEY_S) || IsKeyDown(KEY_DOWN)) + dy = 1; + else if (IsKeyDown(KEY_A) || IsKeyDown(KEY_LEFT)) + dx = -1; + else if (IsKeyDown(KEY_D) || IsKeyDown(KEY_RIGHT)) + dx = 1; + + if (dx == 0 && dy == 0) + return 0; + + // Reset combat event before player acts + combat_reset_event(); + + int new_x = gs->player.x + dx; + int new_y = gs->player.y + dy; + int action = 0; + + // Attack enemy at target tile, or move into it + Enemy *target = player_find_enemy_at(gs->enemies, gs->enemy_count, new_x, new_y); + if (target != NULL) { + player_attack(&gs->player, target); + action = 1; + } else { + action = player_move(&gs->player, dx, dy, &gs->map); + } + + if (action) + post_action(gs); + + return action; +} + +// Handle player input - returns: 0=continue, 1=acted, -1=quit +static int handle_input(GameState *gs) { + // Check for quit first (always works) + if (IsKeyPressed(KEY_Q)) + return -1; + + // Check for restart (works during game over) + if (IsKeyPressed(KEY_R) && gs->game_over) { + memset(gs, 0, sizeof(GameState)); + init_floor(gs, 1); + return 0; + } + + if (combat_has_effect(gs->player.effects, gs->player.effect_count, EFFECT_STUN)) + return handle_stun_turn(gs); + + if (gs->show_inventory) + return handle_inventory_input(gs); + + if (gs->awaiting_descend) + return handle_descend_input(gs); + + return handle_movement_input(gs); } // Main game loop diff --git a/src/player.c b/src/player.c index 9dbadf1..3bb4c4d 100644 --- a/src/player.c +++ b/src/player.c @@ -37,37 +37,29 @@ void player_init(Player *p, int x, int y) { } } -// Check if position has an enemy -static Enemy *get_enemy_at(Enemy *enemies, int count, int x, int y) { +Enemy *player_find_enemy_at(Enemy *enemies, int count, int x, int y) { + if (enemies == NULL || count <= 0) + return NULL; + if (count > MAX_ENEMIES) + count = MAX_ENEMIES; for (int i = 0; i < count; i++) { - if (enemies[i].alive && enemies[i].x == x && enemies[i].y == y) { + if (enemies[i].alive && enemies[i].x == x && enemies[i].y == y) return &enemies[i]; - } } return NULL; } -int player_move(Player *p, int dx, int dy, Map *map, Enemy *enemies, int enemy_count) { +int player_move(Player *p, int dx, int dy, Map *map) { int new_x = p->x + dx; int new_y = p->y + dy; // Check bounds - if (!in_bounds(new_x, new_y, MAP_WIDTH, MAP_HEIGHT)) { + if (!in_bounds(new_x, new_y, MAP_WIDTH, MAP_HEIGHT)) return 0; - } // Check if walkable - if (!is_floor(map, new_x, new_y)) { + if (!is_floor(map, new_x, new_y)) return 0; - } - - // Check for enemy at target position - Enemy *enemy = get_enemy_at(enemies, enemy_count, new_x, new_y); - if (enemy != NULL) { - // Attack the enemy - player_attack(p, enemy); - return 1; - } // Move player p->x = new_x; diff --git a/src/player.h b/src/player.h index 7d6c151..ca91abf 100644 --- a/src/player.h +++ b/src/player.h @@ -6,8 +6,11 @@ // Initialize player at position void player_init(Player *p, int x, int y); -// Move player, return 1 if moved/attacked, 0 if blocked -int player_move(Player *p, int dx, int dy, Map *map, Enemy *enemies, int enemy_count); +// Move player to (x+dx, y+dy); returns 1 if moved, 0 if blocked +int player_move(Player *p, int dx, int dy, Map *map); + +// Find a living enemy at tile (x, y); returns NULL if none +Enemy *player_find_enemy_at(Enemy *enemies, int count, int x, int y); // Player attacks enemy (deal damage) void player_attack(Player *p, Enemy *e); From 1875d94e440457c27ed354840c3e7d19a05dc754 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sun, 5 Apr 2026 22:24:02 +0300 Subject: [PATCH 10/16] combat: tune damage math and enemy scaling Signed-off-by: NotAShelf Change-Id: I983d5980f8d14ccebc6b681100af8a146a6a6964 --- libs/combat/attack.zig | 27 +++++++++++++++++---------- src/common.h | 2 ++ src/enemy.c | 19 +++++++++++++++---- 3 files changed, 34 insertions(+), 14 deletions(-) diff --git a/libs/combat/attack.zig b/libs/combat/attack.zig index 5de90d4..38109c2 100644 --- a/libs/combat/attack.zig +++ b/libs/combat/attack.zig @@ -7,8 +7,9 @@ fn rng(min: c_int, max: c_int) c_int { } fn applyResistance(damage: c_int, resistance: c_int) c_int { - if (resistance >= 100) return 0; - const factor = 100 - resistance; + var r = resistance; + if (r > 75) r = 75; + const factor = 100 - r; var result = @divTrunc(damage * factor, 100); if (result < 1) result = 1; return result; @@ -63,15 +64,17 @@ pub fn playerAttack(p: [*c]c.Player, e: [*c]c.Enemy) void { base_attack -= c.WEAKEN_ATTACK_REDUCTION; if (base_attack < 1) base_attack = 1; - const variance = rng(80, 120); - var damage = @divTrunc(base_attack * variance, 100); - if (damage < 1) damage = 1; + var damage = base_attack; if (rng(0, 99) < crit_chance) { damage = @divTrunc(damage * crit_mult, 100); event.last.is_critical = 1; } + const variance = rng(80, 120); + damage = @divTrunc(damage * variance, 100); + if (damage < 1) damage = 1; + const res_index: usize = @intCast(dmg_class); if (res_index < c.NUM_DMG_CLASSES) { damage = applyResistance(damage, e[0].resistance[res_index]); @@ -139,15 +142,19 @@ pub fn enemyAttack(e: [*c]c.Enemy, p: [*c]c.Player) void { base_damage -= p[0].defense; if (base_damage < 1) base_damage = 1; - const variance = rng(80, 120); - var damage = @divTrunc(base_damage * variance, 100); - if (damage < 1) damage = 1; + var damage = base_damage; - if (rng(0, 99) < c.ENEMY_CRIT_CHANCE) { - damage = @divTrunc(damage * c.ENEMY_CRIT_MULT, 100); + const e_crit_chance = if (e[0].crit_chance > 0) e[0].crit_chance else c.ENEMY_CRIT_CHANCE; + const e_crit_mult = if (e[0].crit_mult > 0) e[0].crit_mult else c.ENEMY_CRIT_MULT; + if (rng(0, 99) < e_crit_chance) { + damage = @divTrunc(damage * e_crit_mult, 100); event.last.is_critical = 1; } + const variance = rng(80, 120); + damage = @divTrunc(damage * variance, 100); + if (damage < 1) damage = 1; + if (p[0].block > 0 and rng(0, 99) < 30) { var blocked = p[0].block; if (blocked > damage) blocked = damage; diff --git a/src/common.h b/src/common.h index 70d1ba3..c015532 100644 --- a/src/common.h +++ b/src/common.h @@ -95,6 +95,8 @@ typedef struct { int resistance[NUM_DMG_CLASSES]; DamageClass dmg_class; int status_chance; + int crit_chance; // crit chance percentage (0-100) + int crit_mult; // crit damage multiplier percentage (e.g. 150 = 1.5x) // status effects StatusEffect effects[MAX_EFFECTS]; int effect_count; diff --git a/src/enemy.c b/src/enemy.c index f90415c..9f72670 100644 --- a/src/enemy.c +++ b/src/enemy.c @@ -48,16 +48,21 @@ void enemy_spawn(Enemy enemies[], int *count, Map *map, Player *p, int floor) { e.effect_count = 0; // Stats based on type and floor + // Attack scales with floor: +1 per 2 floors so deeper enemies hit harder + int floor_atk = floor / 2; + switch (e.type) { case ENEMY_GOBLIN: e.max_hp = ENEMY_BASE_HP + floor; e.hp = e.max_hp; - e.attack = ENEMY_BASE_ATTACK; + e.attack = ENEMY_BASE_ATTACK + floor_atk; e.speed = 55 + rng_int(0, 10); e.dodge = 15; e.block = 0; e.dmg_class = DMG_POISON; e.status_chance = 15; + e.crit_chance = 8; + e.crit_mult = 150; e.resistance[DMG_SLASH] = 0; e.resistance[DMG_IMPACT] = 0; e.resistance[DMG_PIERCE] = 0; @@ -67,27 +72,31 @@ void enemy_spawn(Enemy enemies[], int *count, Map *map, Player *p, int floor) { case ENEMY_SKELETON: e.max_hp = ENEMY_BASE_HP + floor + 2; e.hp = e.max_hp; - e.attack = ENEMY_BASE_ATTACK + 1; + e.attack = ENEMY_BASE_ATTACK + 1 + floor_atk; e.speed = 70 + rng_int(0, 10); e.dodge = 5; e.block = 0; e.dmg_class = DMG_SLASH; e.status_chance = 10; + e.crit_chance = 6; + e.crit_mult = 150; e.resistance[DMG_SLASH] = -25; e.resistance[DMG_IMPACT] = -50; e.resistance[DMG_PIERCE] = 50; e.resistance[DMG_FIRE] = 25; - e.resistance[DMG_POISON] = 100; + e.resistance[DMG_POISON] = 75; break; case ENEMY_ORC: e.max_hp = ENEMY_BASE_HP + floor + 4; e.hp = e.max_hp; - e.attack = ENEMY_BASE_ATTACK + 2; + e.attack = ENEMY_BASE_ATTACK + 2 + floor_atk; e.speed = 85 + rng_int(0, 10); e.dodge = 0; e.block = 3; e.dmg_class = DMG_IMPACT; e.status_chance = 20; + e.crit_chance = 5; + e.crit_mult = 175; e.resistance[DMG_SLASH] = 0; e.resistance[DMG_IMPACT] = 25; e.resistance[DMG_PIERCE] = -25; @@ -103,6 +112,8 @@ void enemy_spawn(Enemy enemies[], int *count, Map *map, Player *p, int floor) { e.block = 0; e.dmg_class = DMG_IMPACT; e.status_chance = 0; + e.crit_chance = ENEMY_CRIT_CHANCE; + e.crit_mult = ENEMY_CRIT_MULT; memset(e.resistance, 0, sizeof(e.resistance)); break; } From 4a718b968528c94be54ba4b833d25194411964e6 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sun, 5 Apr 2026 22:24:12 +0300 Subject: [PATCH 11/16] combat: buff status effects Signed-off-by: NotAShelf Change-Id: Ie2f5af13ad3c0392a5e873b15170b0226a6a6964 --- libs/combat/effects.zig | 2 +- src/settings.h | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/libs/combat/effects.zig b/libs/combat/effects.zig index f4a0fa1..a665422 100644 --- a/libs/combat/effects.zig +++ b/libs/combat/effects.zig @@ -30,7 +30,7 @@ pub fn name(effect: c.StatusEffectType) ?[*:0]const u8 { pub fn paramsFor(effect: c.StatusEffectType) EffectParams { return switch (effect) { c.EFFECT_BLEED => .{ .duration = 4, .intensity = c.BLEED_STACK_DAMAGE }, - c.EFFECT_STUN => .{ .duration = 1, .intensity = 0 }, + c.EFFECT_STUN => .{ .duration = 2, .intensity = 0 }, c.EFFECT_WEAKEN => .{ .duration = 3, .intensity = c.WEAKEN_ATTACK_REDUCTION }, c.EFFECT_BURN => .{ .duration = 2, .intensity = c.BURN_BASE_DAMAGE }, c.EFFECT_POISON => .{ .duration = 5, .intensity = c.POISON_BASE_DAMAGE }, diff --git a/src/settings.h b/src/settings.h index 5b6b39b..e87c082 100644 --- a/src/settings.h +++ b/src/settings.h @@ -30,9 +30,9 @@ // Status Effects #define MAX_EFFECTS 4 -#define POISON_BASE_DAMAGE 1 -#define BLEED_STACK_DAMAGE 1 -#define BURN_BASE_DAMAGE 2 +#define POISON_BASE_DAMAGE 3 +#define BLEED_STACK_DAMAGE 3 +#define BURN_BASE_DAMAGE 7 #define WEAKEN_ATTACK_REDUCTION 2 #define REGEN_STEP_INTERVAL 15 From 0830aaa128fd8e3e9ec9fae876bf194b69ac5e98 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sun, 5 Apr 2026 22:24:38 +0300 Subject: [PATCH 12/16] meta: look for zig files only in libs/ The core game is, and will remain, C Signed-off-by: NotAShelf Change-Id: I568a9d71f55774e195eaa3a75eff80136a6a6964 --- Justfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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: From 6c541bcacc17f44125f35ef42f463966b763447e Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sun, 5 Apr 2026 22:25:16 +0300 Subject: [PATCH 13/16] combat: extend FloatingText with label and effect_type fields Signed-off-by: NotAShelf Change-Id: Iad085ea5d8007257d77d606ab69e57a26a6a6964 --- src/common.h | 2 ++ 1 file changed, 2 insertions(+) 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 From c495dc1d7e8cf116e4c7ab5275f02ab9b833dfb1 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sun, 5 Apr 2026 22:35:03 +0300 Subject: [PATCH 14/16] audio: add dodge, block, crit, and proc sounds Signed-off-by: NotAShelf Change-Id: I860551db6ca06a34785e9129b64d8fc56a6a6964 --- src/audio.c | 23 +++++++++++++++++++++++ src/audio.h | 12 ++++++++++++ 2 files changed, 35 insertions(+) diff --git a/src/audio.c b/src/audio.c index c6bcefd..b273369 100644 --- a/src/audio.c +++ b/src/audio.c @@ -98,3 +98,26 @@ void audio_play_stairs(void) { play_tone(600.0f, 0.1f, 0.3f); play_tone(800.0f, 0.15f, 0.3f); } + +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 From e14af1f9f0ed28be5eca61c749a1bce155392715 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sun, 5 Apr 2026 22:35:07 +0300 Subject: [PATCH 15/16] 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. From cc2e06fffbfcb2a38b65bfb102bca53173653bdd Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sun, 5 Apr 2026 22:36:18 +0300 Subject: [PATCH 16/16] docs: add a proper README Signed-off-by: NotAShelf Change-Id: Ibd89741c4e6ce51ba139b6dbe8fa0fcf6a6a6964 --- README.md | 122 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 120 insertions(+), 2 deletions(-) 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 :/_