From 8dbd3e1dd2f23c349f4a1b7c44eea8903cbbcdbb Mon Sep 17 00:00:00 2001 From: Dominic Masters Date: Mon, 21 Sep 2026 22:38:04 -0500 Subject: [PATCH] Key-based party roster, show party member names in battle UI - party.jsonc's "members" is now a key/value object (e.g. "hero", "companion") instead of an array - partyInit seeds party_t.keys alongside members, and partyGetMemberIndexByKey/partyGetMemberKey do the lookups. Cutscene items (PARTY_ADD_MEMBER/PARTY_REMOVE_MEMBER/ PARTY_HEAL_MEMBER) now take a "key" JSON field, resolved to a roster index at load time, instead of a raw index. - Battle UI shows who's acting: uibattlemenu.c draws the current fighter's party key above the action menu, and uibattlehud.c draws each ally fighter's key as an extra line above their HP/MP. Co-Authored-By: Claude Sonnet 5 --- assets/party.jsonc | 11 ++-- src/dusk/rpg/battle/party.c | 45 +++++++++++++--- src/dusk/rpg/battle/party.h | 51 +++++++++++++------ .../item/party/cutscenepartyaddmember.c | 15 +++--- .../item/party/cutscenepartyaddmember.h | 5 +- .../item/party/cutscenepartyhealmember.c | 15 +++--- .../item/party/cutscenepartyhealmember.h | 5 +- .../item/party/cutscenepartyremovemember.c | 13 +++-- .../item/party/cutscenepartyremovemember.h | 5 +- src/dusk/ui/screen/battle/uibattlehud.c | 17 +++++-- src/dusk/ui/screen/battle/uibattlemenu.c | 11 ++++ src/dusk/ui/screen/battle/uibattlemenu.h | 1 + test/save/savetestfixture.c | 4 +- 13 files changed, 146 insertions(+), 52 deletions(-) diff --git a/assets/party.jsonc b/assets/party.jsonc index c68ff649..32c82a22 100644 --- a/assets/party.jsonc +++ b/assets/party.jsonc @@ -1,13 +1,16 @@ { // Starting party roster, loaded by partyInit into a fresh save slot. - "members": [ - { + // Each key is a stable identifier cutscene content can reference + // directly (see PARTY_ADD_MEMBER/PARTY_REMOVE_MEMBER/PARTY_HEAL_MEMBER's + // "key" field) instead of a fragile positional index. + "members": { + "hero": { "attack": 10, "defense": 5, "magic": 0, "speed": 10, "luck": 0, "healthMax": 30, "mpMax": 10 }, - { + "companion": { "attack": 8, "defense": 6, "magic": 4, "speed": 8, "luck": 1, "healthMax": 25, "mpMax": 15 } - ] + } } diff --git a/src/dusk/rpg/battle/party.c b/src/dusk/rpg/battle/party.c index 1f2eeb72..ab36b60f 100644 --- a/src/dusk/rpg/battle/party.c +++ b/src/dusk/rpg/battle/party.c @@ -8,6 +8,7 @@ #include "party.h" #include "assert/assert.h" #include "util/memory.h" +#include "util/string.h" #include "save/save.h" #include "asset/asset.h" #include "asset/loader/assetloader.h" @@ -66,12 +67,12 @@ errorret_t partyInit(party_t *party) { yyjson_val *root = yyjson_doc_get_root(entry->data.json); yyjson_val *members = yyjson_obj_get(root, "members"); - if(!yyjson_is_arr(members)) { + if(!yyjson_is_obj(members)) { assetUnlockEntry(entry); - errorThrow("party.jsonc: missing/invalid 'members' array"); + errorThrow("party.jsonc: missing/invalid 'members' object"); } - const size_t memberCount = yyjson_arr_size(members); + const size_t memberCount = yyjson_obj_size(members); if(memberCount > PARTY_MEMBER_COUNT_MAX) { assetUnlockEntry(entry); errorThrow( @@ -80,9 +81,21 @@ errorret_t partyInit(party_t *party) { ); } - size_t idx, max; + uint8_t idx = 0; + yyjson_val *key; yyjson_val *memberObj; - yyjson_arr_foreach(members, idx, max, memberObj) { + yyjson_obj_iter iter = yyjson_obj_iter_with(members); + while((key = yyjson_obj_iter_next(&iter))) { + memberObj = yyjson_obj_iter_get_val(key); + + if(yyjson_get_len(key) >= PARTY_KEY_LENGTH_MAX) { + assetUnlockEntry(entry); + errorThrow( + "party.jsonc: member key '%s' exceeds PARTY_KEY_LENGTH_MAX (%u)", + yyjson_get_str(key), PARTY_KEY_LENGTH_MAX + ); + } + battlefighterstats_t stats; errorret_t statsResult = partyParseStats(memberObj, &stats); if(errorIsNotOk(statsResult)) { @@ -90,12 +103,16 @@ errorret_t partyInit(party_t *party) { errorChain(statsResult); } + stringCopy(party->keys[idx], yyjson_get_str(key), PARTY_KEY_LENGTH_MAX - 1); + battlefighter_t *member = &party->members[idx]; - member->id = (uint8_t)idx; + member->id = idx; battleFighterInit( member, BATTLE_FIGHTER_TEAM_ALLY, BATTLE_FIGHTER_CONTROLLER_PLAYER, stats ); + + idx++; } assetUnlockEntry(entry); @@ -110,6 +127,22 @@ errorret_t partyInit(party_t *party) { errorOk(); } +uint8_t partyGetMemberIndexByKey(const char_t *key) { + assertNotNull(key, "Key cannot be NULL"); + + const party_t *party = &SAVE.slot.party; + for(uint8_t i = 0; i < PARTY_MEMBER_COUNT_MAX; i++) { + if(stringEquals(party->keys[i], key)) return i; + } + + return PARTY_MEMBER_NOT_FOUND; +} + +const char_t *partyGetMemberKey(const uint8_t memberIndex) { + assertTrue(memberIndex < PARTY_MEMBER_COUNT_MAX, "Invalid party member index"); + return SAVE.slot.party.keys[memberIndex]; +} + bool_t partyIsMemberActive(const uint8_t memberIndex) { assertTrue(memberIndex < PARTY_MEMBER_COUNT_MAX, "Invalid party member index"); diff --git a/src/dusk/rpg/battle/party.h b/src/dusk/rpg/battle/party.h index 9393f748..873f41bb 100644 --- a/src/dusk/rpg/battle/party.h +++ b/src/dusk/rpg/battle/party.h @@ -9,36 +9,55 @@ #include "rpg/battle/fighter/battlefighter.h" #include "yyjson.h" -#define PARTY_MEMBER_COUNT_MAX 4 -#define PARTY_ACTIVE_SIZE_MAX 3 +#define PARTY_MEMBER_COUNT_MAX 10 +#define PARTY_ACTIVE_SIZE_MAX 4 +#define PARTY_KEY_LENGTH_MAX 32 #define PARTY_ORDER_EMPTY 0xFF +#define PARTY_MEMBER_NOT_FOUND 0xFF typedef struct { - // Every member party.jsonc defines always exists here (seeded by - // partyInit), regardless of whether it's currently active - order (not - // this array, and not a member's status) is what determines who's - // currently in the party. battlefighter_t members[PARTY_MEMBER_COUNT_MAX]; + char_t keys[PARTY_MEMBER_COUNT_MAX][PARTY_KEY_LENGTH_MAX]; uint8_t order[PARTY_ACTIVE_SIZE_MAX]; - // TODO: Whether party members are "front" or "back". } party_t; /** * Initializes party with an empty order, then seeds every roster slot - * directly from party.jsonc's "members" array (index i of the array fills - * members[i] via partyParseStats/battleFighterInit) - unlike - * partyAddMember, this never touches order itself except for one - * placeholder default: if party.jsonc defines at least one member, - * order[0] is set to 0 (member 0 starts active in the first party - * position). Replace this default once there's a real starting-party/ - * character-creation flow. + * directly from party.jsonc's "members" object (each key/value pair fills + * the next members[]/keys[] slot in the object's iteration order, key + * copied into keys[] and value parsed via partyParseStats/ + * battleFighterInit) - unlike partyAddMember, this never touches order + * itself except for one placeholder default: if party.jsonc defines at + * least one member, order[0] is set to 0 (the first member starts active + * in the first party position). Replace this default once there's a real + * starting-party/character-creation flow. * * @param party The party to initialize. - * @return Error if party.jsonc fails to load/parse, or if it defines more - * members than PARTY_MEMBER_COUNT_MAX has room for. + * @return Error if party.jsonc fails to load/parse, defines more members + * than PARTY_MEMBER_COUNT_MAX has room for, or a key exceeds + * PARTY_KEY_LENGTH_MAX. */ errorret_t partyInit(party_t *party); +/** + * Looks up a roster member's index by its party.jsonc key (see + * party_t.keys). + * + * @param key The member's key to search for. + * @return The matching roster index, or PARTY_MEMBER_NOT_FOUND if no + * member has that key. + */ +uint8_t partyGetMemberIndexByKey(const char_t *key); + +/** + * Gets a roster member's party.jsonc key by index (see party_t.keys). + * + * @param memberIndex The roster member index to query. + * @return The member's key - "" if memberIndex is past however many + * members party.jsonc actually defines. + */ +const char_t *partyGetMemberKey(const uint8_t memberIndex); + /** * Parses a set of base combat stats (attack/defense/magic/speed/luck/ * healthMax/mpMax) out of a JSON object. Every field is required - there diff --git a/src/dusk/rpg/cutscene/item/party/cutscenepartyaddmember.c b/src/dusk/rpg/cutscene/item/party/cutscenepartyaddmember.c index 528453f5..9612132e 100644 --- a/src/dusk/rpg/cutscene/item/party/cutscenepartyaddmember.c +++ b/src/dusk/rpg/cutscene/item/party/cutscenepartyaddmember.c @@ -27,13 +27,16 @@ errorret_t cutscenePartyAddMemberLoad( yyjson_val *itemObj, cutsceneitem_t *item ) { - const int32_t memberIndex = cutsceneJsonGetInt(itemObj, "memberIndex", -1); - if(memberIndex < 0 || memberIndex >= PARTY_MEMBER_COUNT_MAX) { - errorThrow( - "Cutscene item (PARTY_ADD_MEMBER): invalid/missing 'memberIndex'" - ); + char_t key[PARTY_KEY_LENGTH_MAX]; + if(!cutsceneJsonCopyString(itemObj, "key", NULL, key, PARTY_KEY_LENGTH_MAX)) { + errorThrow("Cutscene item (PARTY_ADD_MEMBER): missing/invalid 'key'"); } - item->partyAddMember.memberIndex = (uint8_t)memberIndex; + + const uint8_t memberIndex = partyGetMemberIndexByKey(key); + if(memberIndex == PARTY_MEMBER_NOT_FOUND) { + errorThrow("Cutscene item (PARTY_ADD_MEMBER): no member with key '%s'", key); + } + item->partyAddMember.memberIndex = memberIndex; errorOk(); } diff --git a/src/dusk/rpg/cutscene/item/party/cutscenepartyaddmember.h b/src/dusk/rpg/cutscene/item/party/cutscenepartyaddmember.h index 384d4fe7..28768ef1 100644 --- a/src/dusk/rpg/cutscene/item/party/cutscenepartyaddmember.h +++ b/src/dusk/rpg/cutscene/item/party/cutscenepartyaddmember.h @@ -9,6 +9,8 @@ #include "rpg/cutscene/item/cutsceneitembase.h" typedef struct { + // Resolved from the JSON 'key' field at load time (see + // partyGetMemberIndexByKey) - not a raw JSON field itself. uint8_t memberIndex; } cutscenepartyaddmember_t; @@ -39,7 +41,8 @@ bool_t cutscenePartyAddMemberUpdate( /** * Parses a PARTY_ADD_MEMBER cutscene item's JSON fields into item: - * 'memberIndex', a required integer roster index. + * 'key', a required string matching a party.jsonc member key, resolved to + * its roster index via partyGetMemberIndexByKey. * * @param itemObj The item's JSON object. * @param item Destination item, already zeroed by the caller. diff --git a/src/dusk/rpg/cutscene/item/party/cutscenepartyhealmember.c b/src/dusk/rpg/cutscene/item/party/cutscenepartyhealmember.c index 5ec84707..a4091e4e 100644 --- a/src/dusk/rpg/cutscene/item/party/cutscenepartyhealmember.c +++ b/src/dusk/rpg/cutscene/item/party/cutscenepartyhealmember.c @@ -27,13 +27,16 @@ errorret_t cutscenePartyHealMemberLoad( yyjson_val *itemObj, cutsceneitem_t *item ) { - const int32_t memberIndex = cutsceneJsonGetInt(itemObj, "memberIndex", -1); - if(memberIndex < 0 || memberIndex >= PARTY_MEMBER_COUNT_MAX) { - errorThrow( - "Cutscene item (PARTY_HEAL_MEMBER): invalid/missing 'memberIndex'" - ); + char_t key[PARTY_KEY_LENGTH_MAX]; + if(!cutsceneJsonCopyString(itemObj, "key", NULL, key, PARTY_KEY_LENGTH_MAX)) { + errorThrow("Cutscene item (PARTY_HEAL_MEMBER): missing/invalid 'key'"); } - item->partyHealMember.memberIndex = (uint8_t)memberIndex; + + const uint8_t memberIndex = partyGetMemberIndexByKey(key); + if(memberIndex == PARTY_MEMBER_NOT_FOUND) { + errorThrow("Cutscene item (PARTY_HEAL_MEMBER): no member with key '%s'", key); + } + item->partyHealMember.memberIndex = memberIndex; errorOk(); } diff --git a/src/dusk/rpg/cutscene/item/party/cutscenepartyhealmember.h b/src/dusk/rpg/cutscene/item/party/cutscenepartyhealmember.h index e9b386d1..3a36a86c 100644 --- a/src/dusk/rpg/cutscene/item/party/cutscenepartyhealmember.h +++ b/src/dusk/rpg/cutscene/item/party/cutscenepartyhealmember.h @@ -9,6 +9,8 @@ #include "rpg/cutscene/item/cutsceneitembase.h" typedef struct { + // Resolved from the JSON 'key' field at load time (see + // partyGetMemberIndexByKey) - not a raw JSON field itself. uint8_t memberIndex; } cutscenepartyhealmember_t; @@ -38,7 +40,8 @@ bool_t cutscenePartyHealMemberUpdate( /** * Parses a PARTY_HEAL_MEMBER cutscene item's JSON fields into item: - * 'memberIndex', a required integer roster index. + * 'key', a required string matching a party.jsonc member key, resolved to + * its roster index via partyGetMemberIndexByKey. * * @param itemObj The item's JSON object. * @param item Destination item, already zeroed by the caller. diff --git a/src/dusk/rpg/cutscene/item/party/cutscenepartyremovemember.c b/src/dusk/rpg/cutscene/item/party/cutscenepartyremovemember.c index 4e444b20..e636d75e 100644 --- a/src/dusk/rpg/cutscene/item/party/cutscenepartyremovemember.c +++ b/src/dusk/rpg/cutscene/item/party/cutscenepartyremovemember.c @@ -27,13 +27,18 @@ errorret_t cutscenePartyRemoveMemberLoad( yyjson_val *itemObj, cutsceneitem_t *item ) { - const int32_t memberIndex = cutsceneJsonGetInt(itemObj, "memberIndex", -1); - if(memberIndex < 0 || memberIndex >= PARTY_MEMBER_COUNT_MAX) { + char_t key[PARTY_KEY_LENGTH_MAX]; + if(!cutsceneJsonCopyString(itemObj, "key", NULL, key, PARTY_KEY_LENGTH_MAX)) { + errorThrow("Cutscene item (PARTY_REMOVE_MEMBER): missing/invalid 'key'"); + } + + const uint8_t memberIndex = partyGetMemberIndexByKey(key); + if(memberIndex == PARTY_MEMBER_NOT_FOUND) { errorThrow( - "Cutscene item (PARTY_REMOVE_MEMBER): invalid/missing 'memberIndex'" + "Cutscene item (PARTY_REMOVE_MEMBER): no member with key '%s'", key ); } - item->partyRemoveMember.memberIndex = (uint8_t)memberIndex; + item->partyRemoveMember.memberIndex = memberIndex; errorOk(); } diff --git a/src/dusk/rpg/cutscene/item/party/cutscenepartyremovemember.h b/src/dusk/rpg/cutscene/item/party/cutscenepartyremovemember.h index 4ebeb38d..91809aea 100644 --- a/src/dusk/rpg/cutscene/item/party/cutscenepartyremovemember.h +++ b/src/dusk/rpg/cutscene/item/party/cutscenepartyremovemember.h @@ -9,6 +9,8 @@ #include "rpg/cutscene/item/cutsceneitembase.h" typedef struct { + // Resolved from the JSON 'key' field at load time (see + // partyGetMemberIndexByKey) - not a raw JSON field itself. uint8_t memberIndex; } cutscenepartyremovemember_t; @@ -39,7 +41,8 @@ bool_t cutscenePartyRemoveMemberUpdate( /** * Parses a PARTY_REMOVE_MEMBER cutscene item's JSON fields into item: - * 'memberIndex', a required integer roster index. + * 'key', a required string matching a party.jsonc member key, resolved to + * its roster index via partyGetMemberIndexByKey. * * @param itemObj The item's JSON object. * @param item Destination item, already zeroed by the caller. diff --git a/src/dusk/ui/screen/battle/uibattlehud.c b/src/dusk/ui/screen/battle/uibattlehud.c index 9126c1d8..8793a0ac 100644 --- a/src/dusk/ui/screen/battle/uibattlehud.c +++ b/src/dusk/ui/screen/battle/uibattlehud.c @@ -7,6 +7,7 @@ #include "uibattlehud.h" #include "rpg/battle/battle.h" +#include "rpg/battle/party.h" #include "scene/battle/scenebattle.h" #include "scene/scene.h" #include "display/text/text.h" @@ -58,22 +59,28 @@ errorret_t uiBattleHudDraw(void) { vec2 screenPos; sceneBattleWorldToScreen(battle, position, screenPos); + float_t lineY = screenPos[1]; + if(fighter->team == BATTLE_FIGHTER_TEAM_ALLY) { + errorChain(textDraw( + screenPos[0], lineY, partyGetMemberKey(fighter->id), COLOR_WHITE, NULL + )); + lineY += UI_BATTLE_HUD_LINE_HEIGHT; + } + char_t hpText[UI_BATTLE_HUD_TEXT_MAX]; stringFormat( hpText, UI_BATTLE_HUD_TEXT_MAX - 1, UI_BATTLE_HUD.hpFormat, fighter->stats.health, fighter->stats.healthMax ); - errorChain(textDraw(screenPos[0], screenPos[1], hpText, COLOR_WHITE, NULL)); + errorChain(textDraw(screenPos[0], lineY, hpText, COLOR_WHITE, NULL)); + lineY += UI_BATTLE_HUD_LINE_HEIGHT; char_t mpText[UI_BATTLE_HUD_TEXT_MAX]; stringFormat( mpText, UI_BATTLE_HUD_TEXT_MAX - 1, UI_BATTLE_HUD.mpFormat, fighter->stats.mp, fighter->stats.mpMax ); - errorChain(textDraw( - screenPos[0], screenPos[1] + UI_BATTLE_HUD_LINE_HEIGHT, - mpText, COLOR_CYAN, NULL - )); + errorChain(textDraw(screenPos[0], lineY, mpText, COLOR_CYAN, NULL)); } errorChain(spriteBatchFlush()); diff --git a/src/dusk/ui/screen/battle/uibattlemenu.c b/src/dusk/ui/screen/battle/uibattlemenu.c index 1b18942c..bc7bc077 100644 --- a/src/dusk/ui/screen/battle/uibattlemenu.c +++ b/src/dusk/ui/screen/battle/uibattlemenu.c @@ -12,8 +12,11 @@ #include "util/string.h" #include "display/spritebatch/spritebatch.h" #include "display/screen/screen.h" +#include "display/text/text.h" +#include "display/color.h" #include "locale/localemanager.h" #include "asset/loader/locale/assetlocaleloader.h" +#include "rpg/battle/party.h" uibattlemenu_t UI_BATTLE_MENU; @@ -141,6 +144,14 @@ errorret_t uiBattleMenuDraw(void) { const float_t x = (float_t)(SCREEN.scanX + SCREEN.scanWidth) - width; const float_t y = (float_t)(SCREEN.scanY + SCREEN.scanHeight) - height; + battlefighter_t *current = battleStateSelectionGetCurrentFighter(); + if(current != NULL) { + errorChain(textDraw( + x, y - UI_BATTLE_MENU_NAME_LINE_HEIGHT, + partyGetMemberKey(current->id), COLOR_WHITE, NULL + )); + } + errorChain(uiFrameDraw(x, y, width, height)); errorChain(uiMenuDraw( &UI_BATTLE_MENU.actionMenu, diff --git a/src/dusk/ui/screen/battle/uibattlemenu.h b/src/dusk/ui/screen/battle/uibattlemenu.h index 8849fdaf..6d531a74 100644 --- a/src/dusk/ui/screen/battle/uibattlemenu.h +++ b/src/dusk/ui/screen/battle/uibattlemenu.h @@ -21,6 +21,7 @@ #define UI_BATTLE_MENU_WIDTH 160.0f #define UI_BATTLE_MENU_HEIGHT 96.0f +#define UI_BATTLE_MENU_NAME_LINE_HEIGHT 12.0f typedef struct { uimenu_t actionMenu; diff --git a/test/save/savetestfixture.c b/test/save/savetestfixture.c index ff1df739..eb1aee01 100644 --- a/test/save/savetestfixture.c +++ b/test/save/savetestfixture.c @@ -26,10 +26,10 @@ static char_t SAVE_TEST_FIXTURE_REAL_HOME[PATH_MAX]; static bool_t SAVE_TEST_FIXTURE_HAD_REAL_HOME; static const char_t *SAVE_TEST_FIXTURE_PARTY_JSON = - "{\"members\":[{" + "{\"members\":{\"hero\":{" "\"attack\":10,\"defense\":5,\"magic\":0,\"speed\":10,\"luck\":0," "\"healthMax\":30,\"mpMax\":10" - "}]}"; + "}}}"; static yyjson_doc *SAVE_TEST_FIXTURE_PARTY_DOC = NULL;