forked from NotAShelf/rogged
Compare commits
22 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1f65d406cd | |||
| 43867417f9 | |||
| 3e1239959a | |||
| 91491b53d8 | |||
|
cc2e06fffb |
|||
|
e14af1f9f0 |
|||
|
c495dc1d7e |
|||
|
6c541bcacc |
|||
|
0830aaa128 |
|||
|
4a718b9685 |
|||
|
1875d94e44 |
|||
|
ee116ef33f |
|||
|
22ab6fc6eb |
|||
|
7af642612b |
|||
|
62c030527f |
|||
|
6fee9b37c9 |
|||
|
f637bbf5be |
|||
| beb905a054 | |||
| e086ca53e8 | |||
|
f8b63aafac |
|||
|
39b7e01119 |
|||
|
b49808a216 |
27 changed files with 1465 additions and 453 deletions
|
|
@ -5,10 +5,12 @@ 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
|
||||
ident_size = 4
|
||||
ident_size = 2
|
||||
|
||||
[Makefile*]
|
||||
ident_style = tab
|
||||
|
|
|
|||
11
.gitignore
vendored
11
.gitignore
vendored
|
|
@ -1,5 +1,12 @@
|
|||
# ignore build artifacts
|
||||
result
|
||||
# Nix
|
||||
/.direnv/
|
||||
result*
|
||||
|
||||
# Build artifacts
|
||||
build
|
||||
obj
|
||||
roguelike
|
||||
|
||||
# Zig
|
||||
.zig-cache
|
||||
.zig-out
|
||||
|
|
|
|||
20
Justfile
Normal file
20
Justfile
Normal file
|
|
@ -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 libs/combat/*.zig
|
||||
|
||||
# Check formatting
|
||||
fmt-check:
|
||||
clang-format --dry-run --Werror src/*.c src/*.h
|
||||
45
Makefile
45
Makefile
|
|
@ -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; }
|
||||
122
README.md
122
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 :/_
|
||||
|
|
|
|||
73
build.zig
Normal file
73
build.zig
Normal file
|
|
@ -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);
|
||||
}
|
||||
6
flake.lock
generated
6
flake.lock
generated
|
|
@ -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": {
|
||||
|
|
|
|||
192
libs/combat/attack.zig
Normal file
192
libs/combat/attack.zig
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
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 {
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
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]);
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
var damage = base_damage;
|
||||
|
||||
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;
|
||||
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
43
libs/combat/c.zig
Normal 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
84
libs/combat/combat.zig
Normal 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
151
libs/combat/effects.zig
Normal 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 = 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 },
|
||||
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
18
libs/combat/event.zig
Normal 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 = .{};
|
||||
}
|
||||
|
|
@ -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";
|
||||
};
|
||||
})
|
||||
|
|
|
|||
|
|
@ -2,21 +2,22 @@
|
|||
mkShell,
|
||||
clang-tools,
|
||||
raylib,
|
||||
gnumake,
|
||||
pkg-config,
|
||||
just,
|
||||
zig,
|
||||
zig-zlint,
|
||||
}:
|
||||
mkShell {
|
||||
strictDeps = true;
|
||||
packages = [
|
||||
clang-tools
|
||||
gnumake
|
||||
];
|
||||
|
||||
buildInputs = [
|
||||
raylib
|
||||
];
|
||||
buildInputs = [raylib];
|
||||
|
||||
nativeBuildInputs = [
|
||||
pkg-config
|
||||
clang-tools
|
||||
just
|
||||
|
||||
zig
|
||||
zig-zlint
|
||||
];
|
||||
}
|
||||
|
|
|
|||
23
src/audio.c
23
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);
|
||||
}
|
||||
|
|
|
|||
12
src/audio.h
12
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
|
||||
|
|
|
|||
123
src/combat.c
123
src/combat.c
|
|
@ -1,123 +0,0 @@
|
|||
#include "combat.h"
|
||||
#include "common.h"
|
||||
#include "rng.h"
|
||||
#include <stddef.h>
|
||||
|
||||
// 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;
|
||||
}
|
||||
26
src/combat.h
26
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
|
||||
|
|
|
|||
37
src/common.h
37
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,16 @@ 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;
|
||||
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;
|
||||
} Enemy;
|
||||
|
||||
// Floating damage text
|
||||
|
|
@ -79,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
|
||||
|
|
|
|||
56
src/enemy.c
56
src/enemy.c
|
|
@ -3,6 +3,7 @@
|
|||
#include "common.h"
|
||||
#include "map.h"
|
||||
#include "rng.h"
|
||||
#include <string.h>
|
||||
|
||||
// Forward declaration
|
||||
int is_enemy_at(const Enemy *enemies, int count, int x, int y);
|
||||
|
|
@ -39,36 +40,81 @@ 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
|
||||
// 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;
|
||||
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.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] = 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;
|
||||
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;
|
||||
e.crit_chance = ENEMY_CRIT_CHANCE;
|
||||
e.crit_mult = ENEMY_CRIT_MULT;
|
||||
memset(e.resistance, 0, sizeof(e.resistance));
|
||||
break;
|
||||
}
|
||||
e.cooldown = e.speed;
|
||||
|
|
@ -137,6 +183,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);
|
||||
|
|
|
|||
68
src/items.c
68
src/items.c
|
|
@ -1,8 +1,24 @@
|
|||
#include "common.h"
|
||||
#include "map.h"
|
||||
#include "rng.h"
|
||||
#include "settings.h"
|
||||
#include <stddef.h>
|
||||
|
||||
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 "???";
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
573
src/main.c
573
src/main.c
|
|
@ -23,15 +23,54 @@ 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) {
|
||||
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;
|
||||
gs->floating_count++;
|
||||
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 = 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 "";
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -87,165 +126,253 @@ static void init_floor(GameState *gs, int floor_num) {
|
|||
gs->turn_count = 0;
|
||||
}
|
||||
|
||||
// Handle player input - returns: 0=continue, 1=acted, -1=quit
|
||||
static int handle_input(GameState *gs) {
|
||||
int dx = 0, dy = 0;
|
||||
|
||||
// Check for quit first (always works)
|
||||
if (IsKeyPressed(KEY_Q)) {
|
||||
return -1;
|
||||
// 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 for restart (works during game over)
|
||||
if (IsKeyPressed(KEY_R) && gs->game_over) {
|
||||
memset(gs, 0, sizeof(GameState));
|
||||
init_floor(gs, 1);
|
||||
// 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!");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
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;
|
||||
}
|
||||
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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 (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))
|
||||
if (IsKeyPressed(KEY_DOWN) || IsKeyPressed(KEY_S)) {
|
||||
gs->inv_selected++;
|
||||
if (gs->inv_selected >= gs->player.inventory_count)
|
||||
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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
// 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.";
|
||||
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;
|
||||
}
|
||||
return 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->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;
|
||||
}
|
||||
|
||||
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)) {
|
||||
|
|
@ -255,92 +382,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++;
|
||||
|
||||
// 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, target); // target is NULL on move, enemy ptr on attack
|
||||
|
||||
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
|
||||
|
|
@ -358,6 +476,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;
|
||||
|
|
@ -384,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);
|
||||
|
||||
|
|
|
|||
53
src/player.c
53
src/player.c
|
|
@ -3,6 +3,7 @@
|
|||
#include "common.h"
|
||||
#include "items.h"
|
||||
#include "map.h"
|
||||
#include "settings.h"
|
||||
#include "utils.h"
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
|
@ -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++) {
|
||||
|
|
@ -34,43 +37,39 @@ 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;
|
||||
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 +187,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 +196,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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
133
src/render.c
133
src/render.c
|
|
@ -5,6 +5,7 @@
|
|||
#include "settings.h"
|
||||
#include <stddef.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
void render_map(const Map *map) {
|
||||
for (int y = 0; y < MAP_HEIGHT; y++) {
|
||||
|
|
@ -60,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -137,24 +146,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) {
|
||||
|
|
@ -248,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)
|
||||
|
|
@ -255,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -286,8 +370,15 @@ 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};
|
||||
// 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});
|
||||
DrawRectangleLines((int)msg_bg.x, (int)msg_bg.y, (int)msg_bg.width, (int)msg_bg.height, (Color){180, 180, 180, 255});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 3
|
||||
#define BLEED_STACK_DAMAGE 3
|
||||
#define BURN_BASE_DAMAGE 7
|
||||
#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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue