commit 9d6768ea94eeea7ccb242b77a35e7f82cbc6c21e
parent d50f4af83f3a4c083c743eb3e78b9a10b053ac15
Author: beep <beep@wimdupont.com>
Date: Wed, 29 Jul 2026 11:43:07 +0000
Add character sheet and equipment stats
Diffstat:
6 files changed, 226 insertions(+), 20 deletions(-)
diff --git a/README.adoc b/README.adoc
@@ -43,8 +43,9 @@ normal later builds. `config.def.h` contains the distributed defaults.
Configuration currently owns the initial world definitions, entity spawns,
item drops, display dimensions, asset paths and color keys, map tile choices,
-and controls. Adding content should normally extend these configured arrays
-without adding another special case to the core initialization code.
+starting equipment, and controls. Adding content should normally extend these
+configured arrays without adding another special case to the core initialization
+code.
== Build
diff --git a/config.def.h b/config.def.h
@@ -21,6 +21,10 @@
#define PH_NPC_COLOR 220, 180, 90, 255
#define PH_NOTICE_COLOR 20, 24, 34, 255
#define PH_NOTICE_TEXT_COLOR 232, 236, 244, 255
+#define PH_SHEET_COLOR 20, 24, 34, 255
+#define PH_SHEET_BORDER_COLOR 116, 126, 148, 255
+#define PH_SHEET_TEXT_COLOR 232, 236, 244, 255
+#define PH_SHEET_BONUS_COLOR 126, 220, 146, 255
enum {
PH_ASSET_NONE,
@@ -52,14 +56,15 @@ enum {
enum {
PH_ITEM_TALENT_SHARD = 1,
+ PH_ITEM_WAYFARER_BLADE,
+ PH_ITEM_TRAVELER_COAT,
};
static const PhEntityDef ph_entity_defs[] = {
{
.id = PH_DEF_PLAYER,
.name = "Wayfarer",
- .max_hp = 40,
- .move_speed = 90,
+ .stats = { .max_hp = 40, .attack = 5, .defense = 3, .speed = 90 },
.asset_id = PH_ASSET_HERO,
.sprite_tiles = { { 4, 0 }, { 0, 0 }, { 0, 1 } },
.blocks_movement = 0,
@@ -68,8 +73,7 @@ static const PhEntityDef ph_entity_defs[] = {
{
.id = PH_DEF_SLIME,
.name = "Mire Slime",
- .max_hp = 12,
- .move_speed = 45,
+ .stats = { .max_hp = 12, .attack = 3, .defense = 1, .speed = 45 },
.asset_id = PH_ASSET_CRITTERS,
.sprite_tiles = { { 12, 0 }, { 12, 0 }, { 12, 0 } },
.blocks_movement = 1,
@@ -82,11 +86,36 @@ static const PhItemDef ph_item_defs[] = {
.id = PH_ITEM_TALENT_SHARD,
.name = "Talent Shard",
.value = 30,
- .power = 0,
+ .equip_slot = PH_EQUIP_NONE,
.asset_id = PH_ASSET_ITEMS,
.sprite_tile_x = 2,
.sprite_tile_y = 6,
},
+ {
+ .id = PH_ITEM_WAYFARER_BLADE,
+ .name = "Wayfarer Blade",
+ .value = 20,
+ .bonuses = { .attack = 3 },
+ .equip_slot = PH_EQUIP_WEAPON,
+ .asset_id = PH_ASSET_ITEMS,
+ .sprite_tile_x = 0,
+ .sprite_tile_y = 0,
+ },
+ {
+ .id = PH_ITEM_TRAVELER_COAT,
+ .name = "Traveler Coat",
+ .value = 18,
+ .bonuses = { .max_hp = 5, .defense = 2 },
+ .equip_slot = PH_EQUIP_ARMOR,
+ .asset_id = PH_ASSET_ITEMS,
+ .sprite_tile_x = 4,
+ .sprite_tile_y = 6,
+ },
+};
+
+static const int ph_player_equipment[PH_EQUIP_COUNT] = {
+ [PH_EQUIP_WEAPON] = PH_ITEM_WAYFARER_BLADE,
+ [PH_EQUIP_ARMOR] = PH_ITEM_TRAVELER_COAT,
};
static const struct {
@@ -118,6 +147,7 @@ static const struct {
#define PH_KEY_DOWN_2 SDL_SCANCODE_S
#define PH_KEY_INTERACT_1 SDL_SCANCODE_E
#define PH_KEY_INTERACT_2 SDL_SCANCODE_SPACE
+#define PH_KEY_CHARACTER SDL_SCANCODE_C
#endif
#endif
diff --git a/phantasia.6 b/phantasia.6
@@ -18,6 +18,8 @@ in-world entities.
Move the player.
.It Space / E
Interact and pick up nearby loot.
+.It C
+Open or close the character sheet.
.It Window close button
Quit the game.
.El
diff --git a/src/engine/world.c b/src/engine/world.c
@@ -311,9 +311,10 @@ ph_world_spawn_entity(PhWorld *world, int type_id, PhVec2 pos)
}
entity = &world->entities[world->entity_count];
+ memset(entity, 0, sizeof(*entity));
entity->type_id = type_id;
entity->pos = pos;
- entity->hp = def->max_hp;
+ entity->hp = def->stats.max_hp;
entity->facing_x = 0;
entity->facing_y = 1;
entity->active = 1;
@@ -351,6 +352,30 @@ ph_world_set_player(PhWorld *world, int entity_index)
}
}
+int
+ph_world_equip_item(PhWorld *world, int entity_index, int item_id)
+{
+ PhEntity *entity;
+ const PhItemDef *item;
+ PhStats before;
+ PhStats after;
+ int slot;
+
+ if (entity_index < 0 || entity_index >= world->entity_count) return -1;
+ item = ph_world_item_def(world, item_id);
+ if (!item) return -1;
+ slot = (int)item->equip_slot;
+ if (slot <= PH_EQUIP_NONE || slot >= PH_EQUIP_COUNT) return -1;
+ entity = &world->entities[entity_index];
+ before = ph_world_entity_stats(world, entity);
+ entity->equipment[slot] = item_id;
+ after = ph_world_entity_stats(world, entity);
+ entity->hp += after.max_hp - before.max_hp;
+ if (entity->hp < 0) entity->hp = 0;
+ if (entity->hp > after.max_hp) entity->hp = after.max_hp;
+ return 0;
+}
+
void
ph_world_tick(PhWorld *world, PhInput input, float dt)
{
@@ -379,7 +404,7 @@ ph_world_tick(PhWorld *world, PhInput input, float dt)
input = ph_orthogonal_input(input);
next = player->pos;
if (input.move_x != 0 || input.move_y != 0) {
- float speed = (float)def->move_speed;
+ float speed = (float)ph_world_entity_stats(world, player).speed;
player->facing_x = input.move_x;
player->facing_y = input.move_y;
@@ -433,6 +458,27 @@ ph_world_item_amount(const PhWorld *world, int item_id)
return i >= 0 ? world->inventory[i] : 0;
}
+PhStats
+ph_world_entity_stats(const PhWorld *world, const PhEntity *entity)
+{
+ const PhEntityDef *def;
+ PhStats stats = { 0 };
+ int slot;
+
+ if (!entity || !(def = ph_world_entity_def(world, entity->type_id))) return stats;
+ stats = def->stats;
+ for (slot = PH_EQUIP_WEAPON; slot < PH_EQUIP_COUNT; ++slot) {
+ const PhItemDef *item = ph_world_item_def(world, entity->equipment[slot]);
+
+ if (!item) continue;
+ stats.max_hp += item->bonuses.max_hp;
+ stats.attack += item->bonuses.attack;
+ stats.defense += item->bonuses.defense;
+ stats.speed += item->bonuses.speed;
+ }
+ return stats;
+}
+
const PhTileDef *
ph_area_tile_def(const PhArea *area, int tx, int ty)
{
diff --git a/src/engine/world.h b/src/engine/world.h
@@ -16,6 +16,14 @@ enum {
};
typedef enum {
+ PH_EQUIP_NONE,
+ PH_EQUIP_WEAPON,
+ PH_EQUIP_ARMOR,
+ PH_EQUIP_CHARM,
+ PH_EQUIP_COUNT,
+} PhEquipSlot;
+
+typedef enum {
PH_ENTITY_PLAYER = 1,
PH_ENTITY_MONSTER,
PH_ENTITY_NPC,
@@ -34,6 +42,13 @@ typedef struct {
} PhVec2;
typedef struct {
+ int max_hp;
+ int attack;
+ int defense;
+ int speed;
+} PhStats;
+
+typedef struct {
unsigned char symbol;
int blocks_movement;
int asset_id;
@@ -44,8 +59,7 @@ typedef struct {
typedef struct {
int id;
const char *name;
- int max_hp;
- int move_speed;
+ PhStats stats;
int asset_id;
int sprite_tiles[PH_SPRITE_DIRECTIONS][2];
int blocks_movement;
@@ -56,7 +70,8 @@ typedef struct {
int id;
const char *name;
int value;
- int power;
+ PhStats bonuses;
+ PhEquipSlot equip_slot;
int asset_id;
int sprite_tile_x;
int sprite_tile_y;
@@ -68,6 +83,7 @@ typedef struct {
int hp;
int facing_x;
int facing_y;
+ int equipment[PH_EQUIP_COUNT];
int active;
} PhEntity;
@@ -124,12 +140,14 @@ int ph_world_add_item_def(PhWorld *world, PhItemDef def);
int ph_world_spawn_entity(PhWorld *world, int type_id, PhVec2 pos);
int ph_world_drop_item(PhWorld *world, int item_id, PhVec2 pos, int amount);
void ph_world_set_player(PhWorld *world, int entity_index);
+int ph_world_equip_item(PhWorld *world, int entity_index, int item_id);
void ph_world_tick(PhWorld *world, PhInput input, float dt);
const PhEntity *ph_world_player(const PhWorld *world);
const PhEntityDef *ph_world_entity_def(const PhWorld *world, int type_id);
const PhItemDef *ph_world_item_def(const PhWorld *world, int item_id);
int ph_world_item_amount(const PhWorld *world, int item_id);
+PhStats ph_world_entity_stats(const PhWorld *world, const PhEntity *entity);
const PhTileDef *ph_area_tile_def(const PhArea *area, int tx, int ty);
int ph_area_tile_blocked(const PhArea *area, int tx, int ty);
diff --git a/src/game/main.c b/src/game/main.c
@@ -49,6 +49,9 @@ ph_make_world(PhWorld *world)
for (i = 0; i < LEN(ph_item_drops); ++i)
if (ph_world_drop_item(world, ph_item_drops[i].item_id,
ph_item_drops[i].pos, ph_item_drops[i].amount) < 0) goto fail;
+ for (i = 0; i < LEN(ph_player_equipment); ++i)
+ if (ph_player_equipment[i] &&
+ ph_world_equip_item(world, player, ph_player_equipment[i]) < 0) goto fail;
ph_world_set_player(world, player);
return 0;
@@ -67,6 +70,7 @@ ph_run_smoke_test(void)
PhArea area = { "Smoke", 4, 3, tiles, 0 };
PhWorld world;
const PhEntity *player;
+ PhStats stats;
int open = -1;
int blocked = -1;
size_t tile;
@@ -85,9 +89,11 @@ ph_run_smoke_test(void)
tiles[5] = tiles[6] = (unsigned char)open;
ph_world_init(&world, area, 64.0f, 48.0f);
if (ph_world_add_entity_def(&world, (PhEntityDef){
- .id = 1, .name = "Tester", .max_hp = 1, .move_speed = 1,
+ .id = 1, .name = "Tester", .stats = { .max_hp = 1, .speed = 1 },
.kind = PH_ENTITY_PLAYER }) < 0 ||
- ph_world_add_item_def(&world, (PhItemDef){ .id = 1 }) < 0 ||
+ ph_world_add_item_def(&world, (PhItemDef){
+ .id = 1, .bonuses = { .max_hp = 2, .attack = 2 },
+ .equip_slot = PH_EQUIP_WEAPON }) < 0 ||
(player_index = ph_world_spawn_entity(&world, 1,
(PhVec2){ 24.0f, 24.0f })) < 0 ||
ph_world_drop_item(&world, 1, (PhVec2){ 24.0f, 24.0f }, 1) < 0) {
@@ -95,6 +101,10 @@ ph_run_smoke_test(void)
return 1;
}
ph_world_set_player(&world, player_index);
+ if (ph_world_equip_item(&world, player_index, 1) < 0) {
+ fprintf(stderr, "smoke test failed: equipment\n");
+ return 1;
+ }
ph_world_tick(&world, (PhInput){ .interact = 1 }, 0.0f);
for (i = 0; i < 30; ++i) {
@@ -103,9 +113,12 @@ ph_run_smoke_test(void)
ph_world_tick(&world, (PhInput){ .move_x = 1, .move_y = 1 }, 1.0f);
player = ph_world_player(&world);
+ stats = ph_world_entity_stats(&world, player);
if (!player || player->pos.x != 47.0f || player->pos.y != 25.0f ||
world.camera.pos.x != 0.0f || world.camera.pos.y != 0.0f ||
- ph_world_item_amount(&world, 1) != 1) {
+ ph_world_item_amount(&world, 1) != 1 || player->hp != 3 ||
+ stats.max_hp != 3 || stats.attack != 2 ||
+ player->equipment[PH_EQUIP_WEAPON] != 1) {
fprintf(stderr, "smoke test failed: unexpected world state\n");
return 1;
}
@@ -314,16 +327,107 @@ ph_draw_entities(SDL_Renderer *renderer, const PhAssets *assets, const PhWorld *
}
static void
-ph_render_frame(SDL_Renderer *renderer, const PhAssets *assets, PhWorld *world, PhInput input, float dt)
+ph_append_bonus(char *text, size_t size, size_t *used, int value, const char *label)
+{
+ int written;
+
+ if (!value || *used >= size) return;
+ written = snprintf(text + *used, size - *used, "%s%+d %s",
+ *used ? " " : "", value, label);
+ if (written > 0) *used += (size_t)written;
+}
+
+static void
+ph_format_bonuses(char *text, size_t size, PhStats bonuses)
+{
+ size_t used = 0;
+
+ text[0] = '\0';
+ ph_append_bonus(text, size, &used, bonuses.max_hp, "HP");
+ ph_append_bonus(text, size, &used, bonuses.attack, "ATK");
+ ph_append_bonus(text, size, &used, bonuses.defense, "DEF");
+ ph_append_bonus(text, size, &used, bonuses.speed, "SPD");
+}
+
+static void
+ph_draw_character_sheet(SDL_Renderer *renderer, const PhAssets *assets,
+ const PhWorld *world)
+{
+ static const char *slot_names[PH_EQUIP_COUNT] = {
+ [PH_EQUIP_WEAPON] = "WEAPON",
+ [PH_EQUIP_ARMOR] = "ARMOR",
+ [PH_EQUIP_CHARM] = "CHARM",
+ };
+ const PhEntity *player = ph_world_player(world);
+ const PhEntityDef *def;
+ PhStats stats;
+ SDL_FRect panel = { 12.0f, 12.0f, PH_VIEW_W - 24.0f, PH_VIEW_H - 24.0f };
+ SDL_FRect avatar = { 32.0f, 46.0f, 48.0f, 48.0f };
+ char text[128];
+ int slot;
+
+ if (!player || !(def = ph_world_entity_def(world, player->type_id))) return;
+ stats = ph_world_entity_stats(world, player);
+ SDL_SetRenderDrawColor(renderer, PH_SHEET_COLOR);
+ SDL_RenderFillRect(renderer, &panel);
+ SDL_SetRenderDrawColor(renderer, PH_SHEET_BORDER_COLOR);
+ SDL_RenderRect(renderer, &panel);
+ SDL_SetRenderDrawColor(renderer, PH_SHEET_TEXT_COLOR);
+ SDL_RenderDebugText(renderer, 24.0f, 22.0f, "CHARACTER");
+ SDL_RenderDebugText(renderer, 104.0f, 38.0f, def->name);
+
+ if (def->asset_id > PH_ASSET_NONE && (size_t)def->asset_id < LEN(assets->textures))
+ ph_draw_sprite(renderer, assets->textures[def->asset_id],
+ def->sprite_tiles[PH_SPRITE_DOWN][0], def->sprite_tiles[PH_SPRITE_DOWN][1],
+ avatar, SDL_FLIP_NONE);
+
+ snprintf(text, sizeof(text), "HP %d / %d", player->hp, stats.max_hp);
+ SDL_RenderDebugText(renderer, 104.0f, 54.0f, text);
+ snprintf(text, sizeof(text), "ATTACK %d", stats.attack);
+ SDL_RenderDebugText(renderer, 104.0f, 66.0f, text);
+ snprintf(text, sizeof(text), "DEFENSE %d", stats.defense);
+ SDL_RenderDebugText(renderer, 104.0f, 78.0f, text);
+ snprintf(text, sizeof(text), "SPEED %d", stats.speed);
+ SDL_RenderDebugText(renderer, 104.0f, 90.0f, text);
+ SDL_RenderDebugText(renderer, 24.0f, 112.0f, "EQUIPPED");
+
+ for (slot = PH_EQUIP_WEAPON; slot < PH_EQUIP_COUNT; ++slot) {
+ const PhItemDef *item = ph_world_item_def(world, player->equipment[slot]);
+ float y = 130.0f + (float)(slot - PH_EQUIP_WEAPON) * 27.0f;
+
+ snprintf(text, sizeof(text), "%s %s", slot_names[slot], item ? item->name : "-");
+ SDL_SetRenderDrawColor(renderer, PH_SHEET_TEXT_COLOR);
+ SDL_RenderDebugText(renderer, 48.0f, y, text);
+ if (!item) continue;
+ if (item->asset_id > PH_ASSET_NONE &&
+ (size_t)item->asset_id < LEN(assets->textures)) {
+ SDL_FRect icon = { 24.0f, y - 4.0f, 16.0f, 16.0f };
+ ph_draw_sprite(renderer, assets->textures[item->asset_id],
+ item->sprite_tile_x, item->sprite_tile_y, icon, SDL_FLIP_NONE);
+ }
+ ph_format_bonuses(text, sizeof(text), item->bonuses);
+ SDL_SetRenderDrawColor(renderer, PH_SHEET_BONUS_COLOR);
+ SDL_RenderDebugText(renderer, 48.0f, y + 10.0f, text);
+ }
+ SDL_SetRenderDrawColor(renderer, PH_SHEET_TEXT_COLOR);
+ snprintf(text, sizeof(text), "CLOSE: %s", SDL_GetScancodeName(PH_KEY_CHARACTER));
+ SDL_RenderDebugText(renderer, (float)(PH_VIEW_W - 88), (float)(PH_VIEW_H - 22), text);
+}
+
+static void
+ph_render_frame(SDL_Renderer *renderer, const PhAssets *assets, PhWorld *world,
+ PhInput input, float dt, int show_character_sheet)
{
- ph_world_tick(world, input, dt);
+ if (!show_character_sheet) ph_world_tick(world, input, dt);
SDL_SetRenderDrawColor(renderer, PH_CLEAR_COLOR);
SDL_RenderClear(renderer);
ph_draw_area(renderer, assets, world);
ph_draw_items(renderer, assets, world);
ph_draw_entities(renderer, assets, world);
- if (world->notice_seconds > 0.0f && world->notice[0] != '\0') {
+ if (show_character_sheet) {
+ ph_draw_character_sheet(renderer, assets, world);
+ } else if (world->notice_seconds > 0.0f && world->notice[0] != '\0') {
SDL_FRect notice_bg = {
.x = 8.0f,
.y = (float)(PH_VIEW_H - 28),
@@ -436,7 +540,8 @@ ph_run_render_smoke_test(void)
}
for (i = 0; i < 8; ++i) {
- ph_render_frame(renderer, &assets, &world, (PhInput){ 0 }, 1.0f / 60.0f);
+ ph_render_frame(renderer, &assets, &world, (PhInput){ 0 },
+ 1.0f / 60.0f, i == 7);
}
if (!SDL_SaveBMP(surface, PH_RENDER_SMOKE_PATH)) {
@@ -462,6 +567,7 @@ ph_run_game(int frame_limit)
Uint64 last_ticks;
int frame_count = 0;
int running = 1;
+ int show_character_sheet = 0;
if (!SDL_Init(SDL_INIT_VIDEO)) {
fprintf(stderr, "SDL_Init failed: %s\n", SDL_GetError());
@@ -502,6 +608,9 @@ ph_run_game(int frame_limit)
while (SDL_PollEvent(&event)) {
if (event.type == SDL_EVENT_QUIT) {
running = 0;
+ } else if (event.type == SDL_EVENT_KEY_DOWN && !event.key.repeat &&
+ event.key.scancode == PH_KEY_CHARACTER) {
+ show_character_sheet = !show_character_sheet;
}
}
@@ -513,7 +622,7 @@ ph_run_game(int frame_limit)
last_ticks = now_ticks;
input = ph_read_input(SDL_GetKeyboardState(NULL));
- ph_render_frame(renderer, &assets, &world, input, dt);
+ ph_render_frame(renderer, &assets, &world, input, dt, show_character_sheet);
++frame_count;
if (frame_limit > 0 && frame_count >= frame_limit) {
running = 0;