combat: rewrite in Zig; add basic damage types and weapon archetypes

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Ic8055a1cf6bdad1aca13673ea171b4b46a6a6964
This commit is contained in:
raf 2026-04-05 20:11:06 +03:00
commit 22ab6fc6eb
Signed by: NotAShelf
GPG key ID: 29D95B64378DB4BF
15 changed files with 802 additions and 158 deletions

185
libs/combat/attack.zig Normal file
View file

@ -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";
}
}

43
libs/combat/c.zig Normal file
View file

@ -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;

84
libs/combat/combat.zig Normal file
View file

@ -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);
}

151
libs/combat/effects.zig Normal file
View file

@ -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;
}

18
libs/combat/event.zig Normal file
View file

@ -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 = .{};
}