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.
"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
}
]
}
}
+39 -6
View File
@@ -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");
+35 -16
View File
@@ -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
@@ -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();
}
@@ -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.
@@ -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();
}
@@ -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.
@@ -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();
}
@@ -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.
+12 -5
View File
@@ -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());
+11
View File
@@ -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,
+1
View File
@@ -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;
+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 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;