phantasia

Phantasia - 2D SDL3 RPG prototype.
git clone git://git.beep.wimdupont.com/phantasia.git
Log | Files | Refs | README | LICENSE

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:
MREADME.adoc | 5+++--
Mconfig.def.h | 40+++++++++++++++++++++++++++++++++++-----
Mphantasia.6 | 2++
Msrc/engine/world.c | 50++++++++++++++++++++++++++++++++++++++++++++++++--
Msrc/engine/world.h | 24+++++++++++++++++++++---
Msrc/game/main.c | 125++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
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;