From 22ab6fc6eb3a38bce066a107dfe506331bb25c04 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sun, 5 Apr 2026 20:11:06 +0300 Subject: [PATCH] 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