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 <[email protected]>
This commit is contained in:
2026-09-21 22:38:04 -05:00
co-authored by Claude Sonnet 5
parent 7a9a2b9832
commit 8dbd3e1dd2
13 changed files with 146 additions and 52 deletions
+7 -4
View File
@@ -1,13 +1,16 @@
{ {
// Starting party roster, loaded by partyInit into a fresh save slot. // 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, "attack": 10, "defense": 5, "magic": 0, "speed": 10, "luck": 0,
"healthMax": 30, "mpMax": 10 "healthMax": 30, "mpMax": 10
}, },
{ "companion": {
"attack": 8, "defense": 6, "magic": 4, "speed": 8, "luck": 1, "attack": 8, "defense": 6, "magic": 4, "speed": 8, "luck": 1,
"healthMax": 25, "mpMax": 15 "healthMax": 25, "mpMax": 15
} }
] }
} }
+39 -6
View File
@@ -8,6 +8,7 @@
#include "party.h" #include "party.h"
#include "assert/assert.h" #include "assert/assert.h"
#include "util/memory.h" #include "util/memory.h"
#include "util/string.h"
#include "save/save.h" #include "save/save.h"
#include "asset/asset.h" #include "asset/asset.h"
#include "asset/loader/assetloader.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 *root = yyjson_doc_get_root(entry->data.json);
yyjson_val *members = yyjson_obj_get(root, "members"); yyjson_val *members = yyjson_obj_get(root, "members");
if(!yyjson_is_arr(members)) { if(!yyjson_is_obj(members)) {
assetUnlockEntry(entry); 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) { if(memberCount > PARTY_MEMBER_COUNT_MAX) {
assetUnlockEntry(entry); assetUnlockEntry(entry);
errorThrow( 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_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; battlefighterstats_t stats;
errorret_t statsResult = partyParseStats(memberObj, &stats); errorret_t statsResult = partyParseStats(memberObj, &stats);
if(errorIsNotOk(statsResult)) { if(errorIsNotOk(statsResult)) {
@@ -90,12 +103,16 @@ errorret_t partyInit(party_t *party) {
errorChain(statsResult); errorChain(statsResult);
} }
stringCopy(party->keys[idx], yyjson_get_str(key), PARTY_KEY_LENGTH_MAX - 1);
battlefighter_t *member = &party->members[idx]; battlefighter_t *member = &party->members[idx];
member->id = (uint8_t)idx; member->id = idx;
battleFighterInit( battleFighterInit(
member, BATTLE_FIGHTER_TEAM_ALLY, BATTLE_FIGHTER_CONTROLLER_PLAYER, member, BATTLE_FIGHTER_TEAM_ALLY, BATTLE_FIGHTER_CONTROLLER_PLAYER,
stats stats
); );
idx++;
} }
assetUnlockEntry(entry); assetUnlockEntry(entry);
@@ -110,6 +127,22 @@ errorret_t partyInit(party_t *party) {
errorOk(); 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) { bool_t partyIsMemberActive(const uint8_t memberIndex) {
assertTrue(memberIndex < PARTY_MEMBER_COUNT_MAX, "Invalid party member index"); assertTrue(memberIndex < PARTY_MEMBER_COUNT_MAX, "Invalid party member index");
+35 -16
View File
@@ -9,36 +9,55 @@
#include "rpg/battle/fighter/battlefighter.h" #include "rpg/battle/fighter/battlefighter.h"
#include "yyjson.h" #include "yyjson.h"
#define PARTY_MEMBER_COUNT_MAX 4 #define PARTY_MEMBER_COUNT_MAX 10
#define PARTY_ACTIVE_SIZE_MAX 3 #define PARTY_ACTIVE_SIZE_MAX 4
#define PARTY_KEY_LENGTH_MAX 32
#define PARTY_ORDER_EMPTY 0xFF #define PARTY_ORDER_EMPTY 0xFF
#define PARTY_MEMBER_NOT_FOUND 0xFF
typedef struct { 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]; 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]; uint8_t order[PARTY_ACTIVE_SIZE_MAX];
// TODO: Whether party members are "front" or "back".
} party_t; } party_t;
/** /**
* Initializes party with an empty order, then seeds every roster slot * Initializes party with an empty order, then seeds every roster slot
* directly from party.jsonc's "members" array (index i of the array fills * directly from party.jsonc's "members" object (each key/value pair fills
* members[i] via partyParseStats/battleFighterInit) - unlike * the next members[]/keys[] slot in the object's iteration order, key
* partyAddMember, this never touches order itself except for one * copied into keys[] and value parsed via partyParseStats/
* placeholder default: if party.jsonc defines at least one member, * battleFighterInit) - unlike partyAddMember, this never touches order
* order[0] is set to 0 (member 0 starts active in the first party * itself except for one placeholder default: if party.jsonc defines at
* position). Replace this default once there's a real starting-party/ * least one member, order[0] is set to 0 (the first member starts active
* character-creation flow. * in the first party position). Replace this default once there's a real
* starting-party/character-creation flow.
* *
* @param party The party to initialize. * @param party The party to initialize.
* @return Error if party.jsonc fails to load/parse, or if it defines more * @return Error if party.jsonc fails to load/parse, defines more members
* members than PARTY_MEMBER_COUNT_MAX has room for. * than PARTY_MEMBER_COUNT_MAX has room for, or a key exceeds
* PARTY_KEY_LENGTH_MAX.
*/ */
errorret_t partyInit(party_t *party); 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/ * Parses a set of base combat stats (attack/defense/magic/speed/luck/
* healthMax/mpMax) out of a JSON object. Every field is required - there * healthMax/mpMax) out of a JSON object. Every field is required - there
@@ -27,13 +27,16 @@ errorret_t cutscenePartyAddMemberLoad(
yyjson_val *itemObj, yyjson_val *itemObj,
cutsceneitem_t *item cutsceneitem_t *item
) { ) {
const int32_t memberIndex = cutsceneJsonGetInt(itemObj, "memberIndex", -1); char_t key[PARTY_KEY_LENGTH_MAX];
if(memberIndex < 0 || memberIndex >= PARTY_MEMBER_COUNT_MAX) { if(!cutsceneJsonCopyString(itemObj, "key", NULL, key, PARTY_KEY_LENGTH_MAX)) {
errorThrow( errorThrow("Cutscene item (PARTY_ADD_MEMBER): missing/invalid 'key'");
"Cutscene item (PARTY_ADD_MEMBER): invalid/missing 'memberIndex'"
);
} }
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(); errorOk();
} }
@@ -9,6 +9,8 @@
#include "rpg/cutscene/item/cutsceneitembase.h" #include "rpg/cutscene/item/cutsceneitembase.h"
typedef struct { typedef struct {
// Resolved from the JSON 'key' field at load time (see
// partyGetMemberIndexByKey) - not a raw JSON field itself.
uint8_t memberIndex; uint8_t memberIndex;
} cutscenepartyaddmember_t; } cutscenepartyaddmember_t;
@@ -39,7 +41,8 @@ bool_t cutscenePartyAddMemberUpdate(
/** /**
* Parses a PARTY_ADD_MEMBER cutscene item's JSON fields into item: * 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 itemObj The item's JSON object.
* @param item Destination item, already zeroed by the caller. * @param item Destination item, already zeroed by the caller.
@@ -27,13 +27,16 @@ errorret_t cutscenePartyHealMemberLoad(
yyjson_val *itemObj, yyjson_val *itemObj,
cutsceneitem_t *item cutsceneitem_t *item
) { ) {
const int32_t memberIndex = cutsceneJsonGetInt(itemObj, "memberIndex", -1); char_t key[PARTY_KEY_LENGTH_MAX];
if(memberIndex < 0 || memberIndex >= PARTY_MEMBER_COUNT_MAX) { if(!cutsceneJsonCopyString(itemObj, "key", NULL, key, PARTY_KEY_LENGTH_MAX)) {
errorThrow( errorThrow("Cutscene item (PARTY_HEAL_MEMBER): missing/invalid 'key'");
"Cutscene item (PARTY_HEAL_MEMBER): invalid/missing 'memberIndex'"
);
} }
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(); errorOk();
} }
@@ -9,6 +9,8 @@
#include "rpg/cutscene/item/cutsceneitembase.h" #include "rpg/cutscene/item/cutsceneitembase.h"
typedef struct { typedef struct {
// Resolved from the JSON 'key' field at load time (see
// partyGetMemberIndexByKey) - not a raw JSON field itself.
uint8_t memberIndex; uint8_t memberIndex;
} cutscenepartyhealmember_t; } cutscenepartyhealmember_t;
@@ -38,7 +40,8 @@ bool_t cutscenePartyHealMemberUpdate(
/** /**
* Parses a PARTY_HEAL_MEMBER cutscene item's JSON fields into item: * 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 itemObj The item's JSON object.
* @param item Destination item, already zeroed by the caller. * @param item Destination item, already zeroed by the caller.
@@ -27,13 +27,18 @@ errorret_t cutscenePartyRemoveMemberLoad(
yyjson_val *itemObj, yyjson_val *itemObj,
cutsceneitem_t *item cutsceneitem_t *item
) { ) {
const int32_t memberIndex = cutsceneJsonGetInt(itemObj, "memberIndex", -1); char_t key[PARTY_KEY_LENGTH_MAX];
if(memberIndex < 0 || memberIndex >= PARTY_MEMBER_COUNT_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( 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(); errorOk();
} }
@@ -9,6 +9,8 @@
#include "rpg/cutscene/item/cutsceneitembase.h" #include "rpg/cutscene/item/cutsceneitembase.h"
typedef struct { typedef struct {
// Resolved from the JSON 'key' field at load time (see
// partyGetMemberIndexByKey) - not a raw JSON field itself.
uint8_t memberIndex; uint8_t memberIndex;
} cutscenepartyremovemember_t; } cutscenepartyremovemember_t;
@@ -39,7 +41,8 @@ bool_t cutscenePartyRemoveMemberUpdate(
/** /**
* Parses a PARTY_REMOVE_MEMBER cutscene item's JSON fields into item: * 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 itemObj The item's JSON object.
* @param item Destination item, already zeroed by the caller. * @param item Destination item, already zeroed by the caller.
+12 -5
View File
@@ -7,6 +7,7 @@
#include "uibattlehud.h" #include "uibattlehud.h"
#include "rpg/battle/battle.h" #include "rpg/battle/battle.h"
#include "rpg/battle/party.h"
#include "scene/battle/scenebattle.h" #include "scene/battle/scenebattle.h"
#include "scene/scene.h" #include "scene/scene.h"
#include "display/text/text.h" #include "display/text/text.h"
@@ -58,22 +59,28 @@ errorret_t uiBattleHudDraw(void) {
vec2 screenPos; vec2 screenPos;
sceneBattleWorldToScreen(battle, position, 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]; char_t hpText[UI_BATTLE_HUD_TEXT_MAX];
stringFormat( stringFormat(
hpText, UI_BATTLE_HUD_TEXT_MAX - 1, UI_BATTLE_HUD.hpFormat, hpText, UI_BATTLE_HUD_TEXT_MAX - 1, UI_BATTLE_HUD.hpFormat,
fighter->stats.health, fighter->stats.healthMax 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]; char_t mpText[UI_BATTLE_HUD_TEXT_MAX];
stringFormat( stringFormat(
mpText, UI_BATTLE_HUD_TEXT_MAX - 1, UI_BATTLE_HUD.mpFormat, mpText, UI_BATTLE_HUD_TEXT_MAX - 1, UI_BATTLE_HUD.mpFormat,
fighter->stats.mp, fighter->stats.mpMax fighter->stats.mp, fighter->stats.mpMax
); );
errorChain(textDraw( errorChain(textDraw(screenPos[0], lineY, mpText, COLOR_CYAN, NULL));
screenPos[0], screenPos[1] + UI_BATTLE_HUD_LINE_HEIGHT,
mpText, COLOR_CYAN, NULL
));
} }
errorChain(spriteBatchFlush()); errorChain(spriteBatchFlush());
+11
View File
@@ -12,8 +12,11 @@
#include "util/string.h" #include "util/string.h"
#include "display/spritebatch/spritebatch.h" #include "display/spritebatch/spritebatch.h"
#include "display/screen/screen.h" #include "display/screen/screen.h"
#include "display/text/text.h"
#include "display/color.h"
#include "locale/localemanager.h" #include "locale/localemanager.h"
#include "asset/loader/locale/assetlocaleloader.h" #include "asset/loader/locale/assetlocaleloader.h"
#include "rpg/battle/party.h"
uibattlemenu_t UI_BATTLE_MENU; 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 x = (float_t)(SCREEN.scanX + SCREEN.scanWidth) - width;
const float_t y = (float_t)(SCREEN.scanY + SCREEN.scanHeight) - height; 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(uiFrameDraw(x, y, width, height));
errorChain(uiMenuDraw( errorChain(uiMenuDraw(
&UI_BATTLE_MENU.actionMenu, &UI_BATTLE_MENU.actionMenu,
+1
View File
@@ -21,6 +21,7 @@
#define UI_BATTLE_MENU_WIDTH 160.0f #define UI_BATTLE_MENU_WIDTH 160.0f
#define UI_BATTLE_MENU_HEIGHT 96.0f #define UI_BATTLE_MENU_HEIGHT 96.0f
#define UI_BATTLE_MENU_NAME_LINE_HEIGHT 12.0f
typedef struct { typedef struct {
uimenu_t actionMenu; uimenu_t actionMenu;
+2 -2
View File
@@ -26,10 +26,10 @@ static char_t SAVE_TEST_FIXTURE_REAL_HOME[PATH_MAX];
static bool_t SAVE_TEST_FIXTURE_HAD_REAL_HOME; static bool_t SAVE_TEST_FIXTURE_HAD_REAL_HOME;
static const char_t *SAVE_TEST_FIXTURE_PARTY_JSON = static const char_t *SAVE_TEST_FIXTURE_PARTY_JSON =
"{\"members\":[{" "{\"members\":{\"hero\":{"
"\"attack\":10,\"defense\":5,\"magic\":0,\"speed\":10,\"luck\":0," "\"attack\":10,\"defense\":5,\"magic\":0,\"speed\":10,\"luck\":0,"
"\"healthMax\":30,\"mpMax\":10" "\"healthMax\":30,\"mpMax\":10"
"}]}"; "}}}";
static yyjson_doc *SAVE_TEST_FIXTURE_PARTY_DOC = NULL; static yyjson_doc *SAVE_TEST_FIXTURE_PARTY_DOC = NULL;