Wire up battle AI/scene-return, reclaim cutscene item slots, fix GOTO

- battleStateSelectionAiDecide: AI-controlled fighters now actually
  queue a basic attack against the first living enemy instead of doing
  nothing every round.
- battleStateEndingInit: disposes the battle and switches back to
  SCENE_TYPE_OVERWORLD instead of leaving the game stuck in the battle
  scene forever.
- cutsceneSystemNext now pops each finished item off the front of the
  running cutscene (cutsceneRemoveFront) instead of leaving it in place
  forever, so a long-running cutscene (many battle rounds splicing
  items in repeatedly) no longer grows scene->items unboundedly.
- cutsceneGoTo reworked to match: since consumed items are evicted, a
  marker behind the current position can't be found in the live queue
  anymore, so it now re-parses the cutscene fresh from its source file
  (cutsceneLoadParse, into a persistent scratch buffer, not the stack)
  and replaces the live queue with marker-onward from that fresh parse.
  Only supported when the running cutscene is the one
  cutsceneSystemLoad/cutsceneCutsceneResolve populated.
- Fixed a real bug this surfaced: cutsceneSystemPrepare unconditionally
  cleared loadedFile, which ran *after* cutsceneCutsceneResolve had
  just stamped it but *before* the cutscene started - wiping it out
  immediately and breaking cutsceneGoTo for any cutscene entered via a
  CUTSCENE item or cutsceneCutsceneResolve (e.g. the main menu's
  NEW_GAME/OPTIONS/QUIT navigation). Now only cleared when switching
  away from loadedScene entirely.
- Added cutsceneLoadParse, the shared "lock asset, require loaded,
  parse items, unlock" sequence, replacing four near-identical copies
  across cutsceneSystemLoad/cutsceneCutsceneResolve/
  cutsceneInsertResolveAndSplice/battleStateExecutingInit. Each call
  site handles its own fatal-error display; cutsceneLoadParse itself
  just propagates.
- Added test_cutscene.c (cutsceneRemoveFront) and a regression test for
  the loadedFile bug. Writing the zero-count RemoveFront test caught a
  real self-move bug (count == 0 passed dest == src into memoryMove).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
2026-09-21 15:04:41 -05:00
co-authored by Claude Sonnet 5
parent 7aeffac9fc
commit c9f098693c
13 changed files with 430 additions and 123 deletions
@@ -6,9 +6,17 @@
*/ */
#include "battlestateending.h" #include "battlestateending.h"
#include "rpg/battle/battle.h"
#include "scene/scene.h"
void battleStateEndingInit(void) { void battleStateEndingInit(void) {
// Tear down the battle and hand control back to the overworld. The
// overworld scene keeps no state of its own in scenedata_t (player/map
// state lives in the separate global entity/map systems, untouched by
// switching to/from SCENE_TYPE_BATTLE), so there's nothing else to
// restore here.
battleDispose();
sceneSet(SCENE_TYPE_OVERWORLD);
} }
void battleStateEndingUpdate(void) { void battleStateEndingUpdate(void) {
@@ -13,7 +13,8 @@ typedef struct {
} battlestateending_t; } battlestateending_t;
/** /**
* Called when the battle enters BATTLE_STATE_ENDING. * Called when the battle enters BATTLE_STATE_ENDING. Disposes the battle
* (see battleDispose) and switches back to SCENE_TYPE_OVERWORLD.
*/ */
void battleStateEndingInit(void); void battleStateEndingInit(void);
@@ -10,8 +10,6 @@
#include "rpg/cutscene/item/cutsceneitem.h" #include "rpg/cutscene/item/cutsceneitem.h"
#include "rpg/cutscene/cutscenesystem.h" #include "rpg/cutscene/cutscenesystem.h"
#include "rpg/cutscene/cutscene.h" #include "rpg/cutscene/cutscene.h"
#include "asset/asset.h"
#include "asset/loader/assetloader.h"
#include "asset/assetfile.h" #include "asset/assetfile.h"
#include "ui/overlay/uifatalerror.h" #include "ui/overlay/uifatalerror.h"
#include "assert/assert.h" #include "assert/assert.h"
@@ -61,33 +59,20 @@ void battleStateExecutingInit(void) {
"cutscenes/battle/moves/%s.jsonc", ability->name "cutscenes/battle/moves/%s.jsonc", ability->name
); );
// Load cutscene asset. // Load and parse the move's cutscene straight into the tail of items.
assetentry_t *entry = assetLock(path, ASSET_LOADER_TYPE_JSON, NULL); const uint8_t moveItemsStart = itemsCount;
errorret_t result = assetRequireLoaded(entry); cutscene_t scene;
errorret_t result = cutsceneLoadParse(
path, &scene, &items[itemsCount],
BATTLE_STATE_EXECUTING_ITEMS_MAX - itemsCount
);
if(errorIsNotOk(result)) { if(errorIsNotOk(result)) {
assetUnlockEntry(entry);
errorCatch(errorPrint(result)); errorCatch(errorPrint(result));
uiFatalErrorOpen(NULL); uiFatalErrorOpen(NULL);
continue; continue;
} }
// Parse out cutscene items and append to the list.
const uint8_t moveItemsStart = itemsCount;
cutscene_t scene;
errorret_t parseResult = cutsceneParseDoc(
entry->data.json, &scene, &items[itemsCount],
BATTLE_STATE_EXECUTING_ITEMS_MAX - itemsCount
);
itemsCount += scene.itemCount; itemsCount += scene.itemCount;
// Release asset
assetUnlockEntry(entry);
if(errorIsNotOk(parseResult)) {
errorCatch(errorPrint(parseResult));
uiFatalErrorOpen(NULL);
continue;
}
// Resolve BATTLE_FIGHTER_POS_TARGET/USER sentinels against this // Resolve BATTLE_FIGHTER_POS_TARGET/USER sentinels against this
// action, now that its user/target fighters are known. // action, now that its user/target fighters are known.
for(uint8_t j = moveItemsStart; j < itemsCount; j++) { for(uint8_t j = moveItemsStart; j < itemsCount; j++) {
@@ -32,6 +32,25 @@ bool_t battleStateSelectionIsComplete(void) {
return BATTLE.stateData.selection.selectionIndex >= BATTLE.fighterOrderCount; return BATTLE.stateData.selection.selectionIndex >= BATTLE.fighterOrderCount;
} }
void battleStateSelectionAiDecide(const uint8_t fighterIndex) {
battlefighter_t *fighter = BATTLE.fighters[fighterIndex];
const battlefighterteam_t enemyTeam =
fighter->team == BATTLE_FIGHTER_TEAM_ALLY ?
BATTLE_FIGHTER_TEAM_ENEMY : BATTLE_FIGHTER_TEAM_ALLY;
// Simple placeholder AI: attack the first living fighter on the
// opposing team. No move variety or target scoring yet.
for(uint8_t i = 0; i < BATTLE_FIGHTER_POS_COUNT; i++) {
battlefighter_t *candidate = BATTLE.fighters[i];
if(candidate == NULL) continue;
if(candidate->team != enemyTeam) continue;
if(!battleFighterIsAlive(candidate)) continue;
battleStateSelectionQueueAttack(fighterIndex, i);
return;
}
}
void battleStateSelectionUpdate(void) { void battleStateSelectionUpdate(void) {
battlestateselection_t *state = &BATTLE.stateData.selection; battlestateselection_t *state = &BATTLE.stateData.selection;
@@ -49,8 +68,7 @@ void battleStateSelectionUpdate(void) {
battlefighter_t *fighter = BATTLE.fighters[fighterIndex]; battlefighter_t *fighter = BATTLE.fighters[fighterIndex];
if(fighter->controller != BATTLE_FIGHTER_CONTROLLER_AI) return; if(fighter->controller != BATTLE_FIGHTER_CONTROLLER_AI) return;
// This is AI, in future I'll have proper AI here, for now I'm actually not battleStateSelectionAiDecide(fighterIndex);
// going to do anything.
state->selectionIndex++; state->selectionIndex++;
} }
} }
@@ -20,11 +20,13 @@ typedef struct {
void battleStateSelectionInit(void); void battleStateSelectionInit(void);
/** /**
* Updates BATTLE_STATE_SELECTION for one frame: auto-decides for every * Updates BATTLE_STATE_SELECTION for one frame: auto-decides (see
* AI-controlled fighter the cursor passes, stopping once it reaches a * battleStateSelectionAiDecide) for every AI-controlled fighter the
* player-controlled fighter awaiting input (see * cursor passes, stopping once it reaches a player-controlled fighter
* battleStateSelectionGetCurrentFighter), or transitions to * awaiting input (see battleStateSelectionGetCurrentFighter). Once every
* BATTLE_STATE_EXECUTING once every position has decided. * position has decided, BATTLE_WAIT_SELECTION (see
* cutsceneBattleWaitSelectionUpdate) queues the move to
* BATTLE_STATE_EXECUTING.
*/ */
void battleStateSelectionUpdate(void); void battleStateSelectionUpdate(void);
@@ -52,6 +54,17 @@ bool_t battleStateSelectionFighterHasDecided(const uint8_t fighterIndex);
*/ */
bool_t battleStateSelectionFighterNeedsDecision(const uint8_t fighterIndex); bool_t battleStateSelectionFighterNeedsDecision(const uint8_t fighterIndex);
/**
* Decides and queues a move for an AI-controlled fighter: currently
* always a basic attack against the first living fighter on the
* opposing team (see battleStateSelectionQueueAttack). No-ops if there's
* no living target (the battle should already be over by then via
* BATTLE_POST_MOVE).
*
* @param fighterIndex Index into BATTLE.fighters of the deciding fighter.
*/
void battleStateSelectionAiDecide(const uint8_t fighterIndex);
/** /**
* Checks whether every fighter in BATTLE.fighterOrder has decided a move * Checks whether every fighter in BATTLE.fighterOrder has decided a move
* this round. * this round.
+50
View File
@@ -9,6 +9,8 @@
#include "item/json/cutscenejsonpauseflags.h" #include "item/json/cutscenejsonpauseflags.h"
#include "util/memory.h" #include "util/memory.h"
#include "assert/assert.h" #include "assert/assert.h"
#include "asset/asset.h"
#include "asset/loader/assetloader.h"
errorret_t cutsceneParseItem( errorret_t cutsceneParseItem(
yyjson_val *itemObj, yyjson_val *itemObj,
@@ -145,3 +147,51 @@ void cutsceneAppendNext(
) { ) {
cutsceneAppendAt(scene, (uint8_t)(currentIndex + 1), items, count); cutsceneAppendAt(scene, (uint8_t)(currentIndex + 1), items, count);
} }
void cutsceneRemoveFront(
cutscene_t *scene,
const uint8_t count
) {
assertNotNull(scene, "Scene cannot be NULL");
assertTrue(count <= scene->itemCount, "count exceeds itemCount");
const size_t tailCount = (size_t)scene->itemCount - count;
// count == 0 has nothing to shift (and would pass dest == src into
// memoryMove, which asserts against that as a no-op call).
if(count > 0 && tailCount > 0) {
memoryMove(
&scene->items[0],
&scene->items[count],
tailCount * sizeof(cutsceneitem_t)
);
}
scene->itemCount -= count;
}
errorret_t cutsceneLoadParse(
const char_t *path,
cutscene_t *scene,
cutsceneitem_t *items,
const size_t itemsMax
) {
assertNotNull(path, "Path cannot be NULL");
assertNotNull(scene, "Scene cannot be NULL");
assertNotNull(items, "Items cannot be NULL");
assetentry_t *entry = assetLock(path, ASSET_LOADER_TYPE_JSON, NULL);
errorret_t result = assetRequireLoaded(entry);
if(errorIsNotOk(result)) {
assetUnlockEntry(entry);
errorChain(result);
}
// Parse straight out of the entry's own doc while still locked - every
// cutsceneitem_t field that could reference it owns its string data by
// value, so nothing needs to survive past this call and the entry can
// just be unlocked like any other asset once parsing finishes.
errorret_t parseResult = cutsceneParseDoc(entry->data.json, scene, items, itemsMax);
assetUnlockEntry(entry);
errorChain(parseResult);
errorOk();
}
+42
View File
@@ -127,3 +127,45 @@ void cutsceneAppendNext(
const cutsceneitem_t *items, const cutsceneitem_t *items,
const uint8_t count const uint8_t count
); );
/**
* Removes count items from the front of scene->items, shifting the
* remainder down to index 0 and shrinking scene->itemCount to match. Used
* by cutsceneSystemNext/cutsceneGoTo to reclaim already-run items' slots
* instead of leaving them as dead weight forever - see
* CUTSCENE_SYSTEM.currentItem's doc comment, since the currently-running
* item is always scene->items[0] once a cutscene has started.
*
* @param scene Cutscene to remove from.
* @param count Number of items to remove from the front - must be
* <= scene->itemCount.
*/
void cutsceneRemoveFront(
cutscene_t *scene,
const uint8_t count
);
/**
* Loads a cutscene JSON asset by path and parses it into scene/items (see
* cutsceneParseDoc) - the common "lock the asset, require it loaded,
* parse its 'items' array, unlock it again" sequence shared by every
* place that resolves a cutscene by file path (cutsceneSystemLoad,
* cutsceneCutsceneResolve, cutsceneInsertResolveAndSplice,
* battleStateExecutingInit, cutsceneGoTo's reload). Purely propagates the
* error on failure (like cutsceneParseDoc) - this is core cutscene logic,
* not UI, so it doesn't reach for uiFatalErrorOpen itself; each caller
* decides how to surface the failure (typically errorCatch(errorPrint())
* + uiFatalErrorOpen(NULL), same as any other asset load failure).
*
* @param path Asset path of the cutscene JSON file to load.
* @param scene Destination scene - itemCount/itemsMax/pause overwritten.
* @param items Destination item array, capacity itemsMax.
* @param itemsMax Capacity of items.
* @return Error code indicating success or failure of the load/parse.
*/
errorret_t cutsceneLoadParse(
const char_t *path,
cutscene_t *scene,
cutsceneitem_t *items,
const size_t itemsMax
);
+76 -35
View File
@@ -10,8 +10,6 @@
#include "util/memory.h" #include "util/memory.h"
#include "util/string.h" #include "util/string.h"
#include "assert/assert.h" #include "assert/assert.h"
#include "asset/asset.h"
#include "asset/loader/assetloader.h"
#include "ui/overlay/uifatalerror.h" #include "ui/overlay/uifatalerror.h"
cutscenesystem_t CUTSCENE_SYSTEM; cutscenesystem_t CUTSCENE_SYSTEM;
@@ -55,8 +53,20 @@ void cutsceneSystemPrepare(
CUTSCENE_SYSTEM.textCache[0] = '\0'; CUTSCENE_SYSTEM.textCache[0] = '\0';
CUTSCENE_SYSTEM.currentItem = 0xFF;// Set to 0xFF so Next wraps to 0. CUTSCENE_SYSTEM.currentItem = 0xFF;// Set to 0xFF so Next wraps to 0.
CUTSCENE_SYSTEM.onComplete = NULL; CUTSCENE_SYSTEM.onComplete = NULL;
// Only invalidate the known source file when switching away from
// loadedScene entirely. Starting loadedScene itself means
// cutsceneSystemLoad/cutsceneCutsceneResolve just populated it - for
// cutsceneCutsceneResolve (see cutsceneCutsceneStart), that already
// happened before this call, so clearing loadedFile here would
// immediately undo it. cutsceneGoTo needs loadedFile intact to reload
// a marker behind the current position; cutsceneSystemLoad re-stamps
// it itself right after this call anyway, so it's unaffected either
// way.
if(cutscene != &CUTSCENE_SYSTEM.loadedScene) {
CUTSCENE_SYSTEM.loadedFile[0] = '\0'; CUTSCENE_SYSTEM.loadedFile[0] = '\0';
} }
}
void cutsceneSystemStartCutscene(cutscene_t *cutscene) { void cutsceneSystemStartCutscene(cutscene_t *cutscene) {
cutsceneSystemStartCutsceneWith(cutscene, NULL, NULL); cutsceneSystemStartCutsceneWith(cutscene, NULL, NULL);
@@ -118,27 +128,12 @@ void cutsceneSystemInsertCutscene(const cutscene_t *cutscene) {
void cutsceneSystemLoad(const char_t *file) { void cutsceneSystemLoad(const char_t *file) {
assertNotNull(file, "File cannot be NULL"); assertNotNull(file, "File cannot be NULL");
assetentry_t *entry = assetLock(file, ASSET_LOADER_TYPE_JSON, NULL); errorret_t result = cutsceneLoadParse(
errorret_t result = assetRequireLoaded(entry); file, &CUTSCENE_SYSTEM.loadedScene, CUTSCENE_SYSTEM.loadedItems,
if(errorIsNotOk(result)) {
assetUnlockEntry(entry);
errorCatch(errorPrint(result));
uiFatalErrorOpen(NULL);
return;
}
// Parse straight out of the entry's own doc while still locked - every
// cutsceneitem_t field that could reference it owns its string data by
// value (see cutscenecutsceneref_t/cutscenemarker_t's doc comments), so
// nothing needs to survive past this call and the entry can just be
// unlocked like any other asset once parsing finishes.
errorret_t parseResult = cutsceneParseDoc(
entry->data.json, &CUTSCENE_SYSTEM.loadedScene, CUTSCENE_SYSTEM.loadedItems,
CUTSCENE_LOADED_ITEMS_MAX CUTSCENE_LOADED_ITEMS_MAX
); );
assetUnlockEntry(entry); if(errorIsNotOk(result)) {
if(errorIsNotOk(parseResult)) { errorCatch(errorPrint(result));
errorCatch(errorPrint(parseResult));
uiFatalErrorOpen(NULL); uiFatalErrorOpen(NULL);
return; return;
} }
@@ -256,15 +251,21 @@ void cutsceneSystemSetTextCache(const char_t *text) {
void cutsceneSystemNext() { void cutsceneSystemNext() {
if(CUTSCENE_SYSTEM.scene == NULL) return; if(CUTSCENE_SYSTEM.scene == NULL) return;
CUTSCENE_SYSTEM.currentItem++; // Pop the just-finished item off the front rather than advancing an
// index further into the array - see CUTSCENE_SYSTEM.currentItem's doc
// comment. The very first call (currentItem still 0xFF) has nothing to
// pop yet; it just marks the cutscene as started.
if(CUTSCENE_SYSTEM.currentItem == 0xFF) {
CUTSCENE_SYSTEM.currentItem = 0;
} else {
cutsceneRemoveFront(CUTSCENE_SYSTEM.scene, 1);
}
// End of the cutscene? Note that a CUTSCENE_ITEM_TYPE_INSERT item's // End of the cutscene? Note that a CUTSCENE_ITEM_TYPE_INSERT item's
// spliced-in items are physically part of this same array (see // spliced-in items are physically part of this same array (see
// cutsceneSystemInsertCutscene), so there's no separate "parent scene" // cutsceneSystemInsertCutscene), so there's no separate "parent scene"
// to fall back into here - itemCount already accounts for them. // to fall back into here - itemCount already accounts for them.
if( if(CUTSCENE_SYSTEM.scene->itemCount == 0) {
CUTSCENE_SYSTEM.currentItem >= CUTSCENE_SYSTEM.scene->itemCount
) {
// Saved and cleared before firing so a callback that immediately // Saved and cleared before firing so a callback that immediately
// starts another cutscene (or sets its own onComplete) isn't clobbered // starts another cutscene (or sets its own onComplete) isn't clobbered
// by this function's own cleanup running after it - same reentrancy // by this function's own cleanup running after it - same reentrancy
@@ -298,18 +299,57 @@ void cutsceneGoTo(const char_t *name) {
assertNotNull( assertNotNull(
CUTSCENE_SYSTEM.scene, "cutsceneGoTo called with no cutscene running" CUTSCENE_SYSTEM.scene, "cutsceneGoTo called with no cutscene running"
); );
// A marker behind the current position can't be found in the live
// queue - already-run items are evicted from its front as the
// cutscene advances (see cutsceneSystemNext/cutsceneRemoveFront), so
// the untouched original has to be re-parsed from source instead. Only
// supported when the running cutscene is the one cutsceneSystemLoad/
// cutsceneCutsceneResolve populated - a C-authored cutscene_t, or
// content spliced in via INSERT/battleStateExecutingInit, has no
// source to reload from, so a backward jump into those isn't supported
// yet.
assertTrue(
CUTSCENE_SYSTEM.scene == &CUTSCENE_SYSTEM.loadedScene &&
CUTSCENE_SYSTEM.loadedFile[0] != '\0',
"cutsceneGoTo requires the running cutscene to have come from "
"cutsceneSystemLoad/cutsceneCutsceneResolve"
);
for(uint8_t i = 0; i < CUTSCENE_SYSTEM.scene->itemCount; i++) { cutscene_t freshScene = { .items = CUTSCENE_SYSTEM.gotoScratchItems };
const cutsceneitem_t *item = &CUTSCENE_SYSTEM.scene->items[i]; errorret_t result = cutsceneLoadParse(
if( CUTSCENE_SYSTEM.loadedFile, &freshScene, CUTSCENE_SYSTEM.gotoScratchItems,
item->type == CUTSCENE_ITEM_TYPE_MARKER && CUTSCENE_LOADED_ITEMS_MAX
stringEquals(item->marker.name, name) );
) { if(errorIsNotOk(result)) {
CUTSCENE_SYSTEM.currentItem = i; errorCatch(errorPrint(result));
memoryZero(&CUTSCENE_SYSTEM.data, sizeof(CUTSCENE_SYSTEM.data)); uiFatalErrorOpen(NULL);
cutsceneItemStart(item, &CUTSCENE_SYSTEM.data);
return; return;
} }
for(uint8_t i = 0; i < freshScene.itemCount; i++) {
const cutsceneitem_t *item = &freshScene.items[i];
if(
item->type != CUTSCENE_ITEM_TYPE_MARKER ||
!stringEquals(item->marker.name, name)
) continue;
// Commit: replace the live queue outright with marker-onward from
// the fresh parse. Nothing before the marker (including whatever was
// running) gets Started - only the marker item itself does, same as
// the old in-place jump's semantics.
const uint8_t remaining = freshScene.itemCount - i;
memoryCopy(
CUTSCENE_SYSTEM.loadedItems, &freshScene.items[i],
remaining * sizeof(cutsceneitem_t)
);
CUTSCENE_SYSTEM.loadedScene.itemCount = remaining;
CUTSCENE_SYSTEM.currentItem = 0;
memoryZero(&CUTSCENE_SYSTEM.data, sizeof(CUTSCENE_SYSTEM.data));
cutsceneItemStart(
&CUTSCENE_SYSTEM.loadedItems[0], &CUTSCENE_SYSTEM.data
);
return;
} }
assertTrue(false, "cutsceneGoTo: no marker found with that name"); assertTrue(false, "cutsceneGoTo: no marker found with that name");
@@ -325,5 +365,6 @@ void cutsceneSystemUpdate() {
const cutsceneitem_t * cutsceneSystemGetCurrentItem() { const cutsceneitem_t * cutsceneSystemGetCurrentItem() {
if(CUTSCENE_SYSTEM.scene == NULL) return NULL; if(CUTSCENE_SYSTEM.scene == NULL) return NULL;
return &CUTSCENE_SYSTEM.scene->items[CUTSCENE_SYSTEM.currentItem]; // Always index 0 - see CUTSCENE_SYSTEM.currentItem's doc comment.
return &CUTSCENE_SYSTEM.scene->items[0];
} }
+28 -2
View File
@@ -23,6 +23,13 @@ typedef struct entity_s entity_t;
typedef struct { typedef struct {
cutscene_t *scene; cutscene_t *scene;
// 0xFF if nothing has started running yet, otherwise always 0 - the
// currently-running item is always scene->items[0]; cutsceneSystemNext
// removes it from the front (see cutsceneRemoveFront) rather than
// advancing an index further into the array, so an item's slot is
// reclaimed the moment it finishes instead of sitting there as dead
// weight for the rest of the cutscene's run.
uint8_t currentItem; uint8_t currentItem;
cutscenepause_t pause; cutscenepause_t pause;
entity_t *entityInteract; entity_t *entityInteract;
@@ -47,6 +54,13 @@ typedef struct {
// Filename last passed to cutsceneSystemLoad - see cutsceneRestart. // Filename last passed to cutsceneSystemLoad - see cutsceneRestart.
char_t loadedFile[ASSET_FILE_NAME_MAX]; char_t loadedFile[ASSET_FILE_NAME_MAX];
// Scratch space for cutsceneGoTo's re-parse of loadedFile - a fixed,
// persistent buffer rather than a local one since cutsceneitem_t is
// large (e.g. cutscenemodal_t alone carries a 256-byte message buffer),
// making a CUTSCENE_LOADED_ITEMS_MAX-sized array too big to put on the
// stack on constrained platforms (PSP/GameCube/Wii).
cutsceneitem_t gotoScratchItems[CUTSCENE_LOADED_ITEMS_MAX];
} cutscenesystem_t; } cutscenesystem_t;
extern cutscenesystem_t CUTSCENE_SYSTEM; extern cutscenesystem_t CUTSCENE_SYSTEM;
@@ -211,8 +225,20 @@ void cutsceneSystemNext();
/** /**
* Jumps the running cutscene directly to the marker with the given name * Jumps the running cutscene directly to the marker with the given name
* and starts it immediately. Asserts if no cutscene is running or no * and starts it immediately. Re-parses the cutscene fresh from its
* marker with that name exists. * source file (see cutsceneLoadParse) and replaces the live queue with
* marker-onward from that fresh parse - items already run may have been
* evicted from the front of the live queue (see
* cutsceneSystemNext/cutsceneRemoveFront), so a marker behind the
* current position can only be found in the untouched original. Nothing
* before the marker gets Started, only the marker item itself.
*
* Requires the running cutscene to be the one cutsceneSystemLoad/
* cutsceneCutsceneResolve populated (i.e. CUTSCENE_SYSTEM.scene ==
* &CUTSCENE_SYSTEM.loadedScene) - asserts otherwise. A C-authored
* cutscene_t, or content spliced in via INSERT/battleStateExecutingInit,
* has no source file to reload from, so backward jumps into those
* aren't supported yet.
* *
* @param name Marker name to search for. * @param name Marker name to search for.
*/ */
+13 -40
View File
@@ -7,9 +7,6 @@
#include "rpg/cutscene/cutscenesystem.h" #include "rpg/cutscene/cutscenesystem.h"
#include "rpg/cutscene/item/cutsceneitembase.h" #include "rpg/cutscene/item/cutsceneitembase.h"
#include "asset/asset.h"
#include "asset/assetfile.h"
#include "asset/loader/assetloader.h"
#include "util/memory.h" #include "util/memory.h"
#include "util/string.h" #include "util/string.h"
#include "ui/overlay/uifatalerror.h" #include "ui/overlay/uifatalerror.h"
@@ -433,30 +430,21 @@ bool_t cutsceneCutsceneUpdate(
} }
cutscene_t * cutsceneCutsceneResolve(const char_t *name) { cutscene_t * cutsceneCutsceneResolve(const char_t *name) {
assetentry_t *entry = assetLock(name, ASSET_LOADER_TYPE_JSON, NULL); errorret_t result = cutsceneLoadParse(
errorret_t result = assetRequireLoaded(entry); name, &CUTSCENE_SYSTEM.loadedScene, CUTSCENE_SYSTEM.loadedItems,
CUTSCENE_LOADED_ITEMS_MAX
);
if(errorIsNotOk(result)) { if(errorIsNotOk(result)) {
assetUnlockEntry(entry);
errorCatch(errorPrint(result)); errorCatch(errorPrint(result));
uiFatalErrorOpen(NULL); uiFatalErrorOpen(NULL);
return NULL; return NULL;
} }
// Parse straight out of the entry's own doc while still locked - every // Shares loadedScene/loadedItems with cutsceneSystemLoad, so track the
// cutsceneitem_t field that could reference it owns its string data by // source the same way it does - cutsceneGoTo needs this to reload the
// value (see cutscenecutsceneref_t/cutscenemarker_t's doc comments), so // untouched original when jumping to a marker behind the current
// nothing needs to survive past this call and the entry can just be // position (see cutsceneGoTo's doc comment).
// unlocked like any other asset once parsing finishes. stringCopy(CUTSCENE_SYSTEM.loadedFile, name, ASSET_FILE_NAME_MAX - 1);
errorret_t parseResult = cutsceneParseDoc(
entry->data.json, &CUTSCENE_SYSTEM.loadedScene, CUTSCENE_SYSTEM.loadedItems,
CUTSCENE_LOADED_ITEMS_MAX
);
assetUnlockEntry(entry);
if(errorIsNotOk(parseResult)) {
errorCatch(errorPrint(parseResult));
uiFatalErrorOpen(NULL);
return NULL;
}
return &CUTSCENE_SYSTEM.loadedScene; return &CUTSCENE_SYSTEM.loadedScene;
} }
@@ -511,29 +499,14 @@ bool_t cutsceneInsertUpdate(
} }
void cutsceneInsertResolveAndSplice(const char_t *name) { void cutsceneInsertResolveAndSplice(const char_t *name) {
assetentry_t *entry = assetLock(name, ASSET_LOADER_TYPE_JSON, NULL);
errorret_t result = assetRequireLoaded(entry);
if(errorIsNotOk(result)) {
assetUnlockEntry(entry);
errorCatch(errorPrint(result));
uiFatalErrorOpen(NULL);
return;
}
// Local scratch, not a shared buffer - this target is spliced straight // Local scratch, not a shared buffer - this target is spliced straight
// into the running scene below and never referenced again afterward, so // into the running scene below and never referenced again afterward, so
// it only needs to live for the rest of this call. Parsed straight out // it only needs to live for the rest of this call.
// of the entry's own doc while still locked, then unlocked like any
// other asset - see cutsceneCutsceneResolve's matching comment for why
// nothing needs to keep the doc itself resident past the parse.
cutsceneitem_t items[CUTSCENE_INSERT_ITEMS_MAX]; cutsceneitem_t items[CUTSCENE_INSERT_ITEMS_MAX];
cutscene_t scene = { .items = items }; cutscene_t scene = { .items = items };
errorret_t parseResult = cutsceneParseDoc( errorret_t result = cutsceneLoadParse(name, &scene, items, CUTSCENE_INSERT_ITEMS_MAX);
entry->data.json, &scene, items, CUTSCENE_INSERT_ITEMS_MAX if(errorIsNotOk(result)) {
); errorCatch(errorPrint(result));
assetUnlockEntry(entry);
if(errorIsNotOk(parseResult)) {
errorCatch(errorPrint(parseResult));
uiFatalErrorOpen(NULL); uiFatalErrorOpen(NULL);
return; return;
} }
+1
View File
@@ -6,6 +6,7 @@
include(dusktest) include(dusktest)
# Tests # Tests
dusktest(test_cutscene.c)
dusktest(test_cutscenesystem.c) dusktest(test_cutscenesystem.c)
dusktest(test_cutscenecontrol.c) dusktest(test_cutscenecontrol.c)
dusktest(test_cutscenemaparea.c) dusktest(test_cutscenemaparea.c)
+84
View File
@@ -0,0 +1,84 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "dusktest.h"
#include "rpg/cutscene/cutscene.h"
#include "util/memory.h"
#include "util/string.h"
// ============================================================
// cutsceneRemoveFront
// ============================================================
static cutsceneitem_t MARKER_ITEM(const char_t *name) {
cutsceneitem_t item;
memoryZero(&item, sizeof(item));
item.type = CUTSCENE_ITEM_TYPE_MARKER;
stringCopy(item.marker.name, name, CUTSCENE_MARKER_NAME_MAX - 1);
return item;
}
static void test_cutsceneRemoveFrontShiftsRemainderToIndexZero(
void **state
) {
cutsceneitem_t items[4] = {
MARKER_ITEM("a"), MARKER_ITEM("b"), MARKER_ITEM("c"), MARKER_ITEM("d")
};
cutscene_t scene = { .items = items, .itemCount = 4, .itemsMax = 4 };
cutsceneRemoveFront(&scene, 2);
assert_int_equal(scene.itemCount, 2);
assert_string_equal(scene.items[0].marker.name, "c");
assert_string_equal(scene.items[1].marker.name, "d");
}
static void test_cutsceneRemoveFrontAllItemsLeavesEmptyScene(void **state) {
cutsceneitem_t items[3] = {
MARKER_ITEM("a"), MARKER_ITEM("b"), MARKER_ITEM("c")
};
cutscene_t scene = { .items = items, .itemCount = 3, .itemsMax = 3 };
cutsceneRemoveFront(&scene, 3);
assert_int_equal(scene.itemCount, 0);
}
static void test_cutsceneRemoveFrontZeroIsNoop(void **state) {
cutsceneitem_t items[2] = { MARKER_ITEM("a"), MARKER_ITEM("b") };
cutscene_t scene = { .items = items, .itemCount = 2, .itemsMax = 2 };
cutsceneRemoveFront(&scene, 0);
assert_int_equal(scene.itemCount, 2);
assert_string_equal(scene.items[0].marker.name, "a");
assert_string_equal(scene.items[1].marker.name, "b");
}
static void test_cutsceneRemoveFrontAssertsWhenCountExceedsItemCount(
void **state
) {
cutsceneitem_t items[2] = { MARKER_ITEM("a"), MARKER_ITEM("b") };
cutscene_t scene = { .items = items, .itemCount = 2, .itemsMax = 2 };
expect_assert_failure(cutsceneRemoveFront(&scene, 3));
}
int main(int argc, char** argv) {
const struct CMUnitTest tests[] = {
cmocka_unit_test(test_cutsceneRemoveFrontShiftsRemainderToIndexZero),
cmocka_unit_test(test_cutsceneRemoveFrontAllItemsLeavesEmptyScene),
cmocka_unit_test(test_cutsceneRemoveFrontZeroIsNoop),
cmocka_unit_test(test_cutsceneRemoveFrontAssertsWhenCountExceedsItemCount),
};
return cmocka_run_group_tests(tests, NULL, NULL);
}
+78 -13
View File
@@ -9,6 +9,7 @@
#include "rpg/cutscene/cutscenesystem.h" #include "rpg/cutscene/cutscenesystem.h"
#include "rpg/entity/entity.h" #include "rpg/entity/entity.h"
#include "time/time.h" #include "time/time.h"
#include "util/string.h"
static uint8_t callbackFired; static uint8_t callbackFired;
@@ -198,18 +199,72 @@ static void test_cutsceneSystemDisposeResetsState(void **state) {
assert_null(CUTSCENE_SYSTEM.entityInteracted); assert_null(CUTSCENE_SYSTEM.entityInteracted);
} }
// --- loadedFile survival across cutsceneSystemPrepare -------------------
//
// cutsceneCutsceneResolve (and cutsceneSystemLoad) stamp
// CUTSCENE_SYSTEM.loadedFile with what they just parsed loadedScene from
// - cutsceneGoTo needs that to survive so it can reload the same source
// later. cutsceneCutsceneResolve's caller stamps it BEFORE calling
// cutsceneSystemStartCutscene(&CUTSCENE_SYSTEM.loadedScene), so
// cutsceneSystemPrepare must not blindly clear loadedFile on every
// start - only when switching away from loadedScene to some other
// cutscene_t entirely (see cutsceneSystemPrepare's doc comment). This
// regression-tests the exact bug hit via initial.jsonc's CUTSCENE item
// into main_menu.jsonc: cutsceneCutsceneResolve stamped loadedFile, then
// the immediately following cutsceneSystemStartCutscene call wiped it
// straight back out, so a later cutsceneGoTo on the still-running
// cutscene had nothing to reload from.
static void test_cutsceneSystemPrepareKeepsLoadedFileWhenReenteringLoadedScene(
void **state
) {
cutsceneSystemInit();
// Simulate cutsceneCutsceneResolve: populate loadedScene/loadedItems
// and stamp loadedFile with its source, before the cutscene has
// actually started running.
CUTSCENE_SYSTEM.loadedItems[0] =
(cutsceneitem_t){ .type = CUTSCENE_ITEM_TYPE_WAIT, .wait = 1.0f };
CUTSCENE_SYSTEM.loadedScene = (cutscene_t){
.items = CUTSCENE_SYSTEM.loadedItems,
.itemCount = 1,
.itemsMax = CUTSCENE_LOADED_ITEMS_MAX,
.pause = CUTSCENE_PAUSE_NONE
};
stringCopy(
CUTSCENE_SYSTEM.loadedFile, "cutscenes/main_menu.jsonc",
ASSET_FILE_NAME_MAX - 1
);
cutsceneSystemStartCutscene(&CUTSCENE_SYSTEM.loadedScene);
assert_string_equal(
CUTSCENE_SYSTEM.loadedFile, "cutscenes/main_menu.jsonc"
);
// Starting a genuinely different cutscene, though, must still
// invalidate it - there's nothing left to reload it from.
cutsceneSystemStartCutscene(&CUTSCENE_TEST_SINGLE_WAIT);
assert_string_equal(CUTSCENE_SYSTEM.loadedFile, "");
}
// --- CUTSCENE_INSERT --------------------------------------------------- // --- CUTSCENE_INSERT ---------------------------------------------------
// //
// INSERT now splices its target's items directly into the running // INSERT now splices its target's items directly into the running
// scene's own array (see cutsceneSystemInsertCutscene) rather than // scene's own array (see cutsceneSystemInsertCutscene) rather than
// swapping CUTSCENE_SYSTEM.scene to a different cutscene_t and pushing a // swapping CUTSCENE_SYSTEM.scene to a different cutscene_t and pushing a
// return frame - CUTSCENE_SYSTEM.scene therefore stays the same outer // return frame - CUTSCENE_SYSTEM.scene therefore stays the same outer
// cutscene throughout every test below; itemCount growing is what shows // cutscene throughout every test below. cutsceneSystemNext pops each
// the splice actually happened. Since the running scene's own array is // already-run item off the front of the array as it advances (see
// what gets appended into now, any outer cutscene used with INSERT must // CUTSCENE_SYSTEM.currentItem's doc comment), so itemCount reflects only
// declare its items array (and .itemsMax) with enough spare capacity for // what's left to run at any given point, not a running total of
// whatever ends up spliced into it, including transitively through any // everything ever spliced in - the itemCount assertions below are sized
// nested INSERT. // accordingly. Since the running scene's own array is what gets appended
// into, any outer cutscene used with INSERT must still declare its items
// array (and .itemsMax) with enough spare capacity for whatever ends up
// spliced into it at once, including transitively through any nested
// INSERT.
static uint8_t insertOrderLog[8]; static uint8_t insertOrderLog[8];
static uint8_t insertOrderLogCount; static uint8_t insertOrderLogCount;
@@ -263,10 +318,12 @@ static void test_cutsceneInsertSplicesItemsInPlaceThenResumes(void **state) {
cutsceneSystemUpdate();// the WAIT elapses -> starts order1 cutsceneSystemUpdate();// the WAIT elapses -> starts order1
cutsceneSystemUpdate();// order1 done -> starts the INSERT, cascading into orderA cutsceneSystemUpdate();// order1 done -> starts the INSERT, cascading into orderA
// The splice happened in place - CUTSCENE_SYSTEM.scene never changed, // The splice happened in place - CUTSCENE_SYSTEM.scene never changed.
// but it grew by the snippet's 3 items. // order0/the WAIT/order1/the INSERT item itself have all since been
// popped off the front, leaving the snippet's 3 items (orderA now
// running, its WAIT, orderB) plus order2.
assert_ptr_equal(CUTSCENE_SYSTEM.scene, &CUTSCENE_TEST_INSERT_OUTER); assert_ptr_equal(CUTSCENE_SYSTEM.scene, &CUTSCENE_TEST_INSERT_OUTER);
assert_int_equal(CUTSCENE_SYSTEM.scene->itemCount, 8); assert_int_equal(CUTSCENE_SYSTEM.scene->itemCount, 4);
TIME.delta = 2.5f;// elapses the snippet's WAIT(2.0) TIME.delta = 2.5f;// elapses the snippet's WAIT(2.0)
cutsceneSystemUpdate();// orderA done -> starts snippet's WAIT cutsceneSystemUpdate();// orderA done -> starts snippet's WAIT
@@ -323,7 +380,9 @@ static void test_cutsceneInsertAsLastItemEndsCutsceneNaturally(
cutsceneSystemUpdate();// order0 done -> starts INSERT, cascades to orderA cutsceneSystemUpdate();// order0 done -> starts INSERT, cascades to orderA
assert_ptr_equal(CUTSCENE_SYSTEM.scene, &CUTSCENE_TEST_INSERT_AS_LAST_ITEM_OUTER); assert_ptr_equal(CUTSCENE_SYSTEM.scene, &CUTSCENE_TEST_INSERT_AS_LAST_ITEM_OUTER);
assert_int_equal(CUTSCENE_SYSTEM.scene->itemCount, 3); // order0 and the INSERT item itself have both been popped off the
// front by now, leaving just orderA (currently running).
assert_int_equal(CUTSCENE_SYSTEM.scene->itemCount, 1);
// orderA is the snippet's only item, and the snippet was spliced in as // orderA is the snippet's only item, and the snippet was spliced in as
// the outer's last item -- finishing it should reach the natural end // the outer's last item -- finishing it should reach the natural end
@@ -398,7 +457,9 @@ static void test_cutsceneInsertNestsThroughMultipleLevels(void **state) {
// land in OUTER's own array. // land in OUTER's own array.
cutsceneSystemUpdate(); cutsceneSystemUpdate();
assert_ptr_equal(CUTSCENE_SYSTEM.scene, &CUTSCENE_TEST_INSERT_NESTED_OUTER); assert_ptr_equal(CUTSCENE_SYSTEM.scene, &CUTSCENE_TEST_INSERT_NESTED_OUTER);
assert_int_equal(CUTSCENE_SYSTEM.scene->itemCount, 6); // order0 and both INSERT items themselves have all been popped off the
// front by now, leaving orderA (currently running), orderB, order2.
assert_int_equal(CUTSCENE_SYSTEM.scene->itemCount, 3);
cutsceneSystemUpdate();// orderA done -> continues on to orderB cutsceneSystemUpdate();// orderA done -> continues on to orderB
cutsceneSystemUpdate();// orderB done -> continues on to order2 cutsceneSystemUpdate();// orderB done -> continues on to order2
@@ -486,9 +547,10 @@ static void test_cutsceneInsertDoesNotResetPauseOrInteractEntities(
); );
// The INSERT item's Start already spliced the snippet into place and // The INSERT item's Start already spliced the snippet into place and
// cascaded into it by now. // cascaded into it by now - and since the INSERT item itself has since
// been popped off the front, only the snippet's one item is left.
assert_ptr_equal(CUTSCENE_SYSTEM.scene, &CUTSCENE_TEST_INSERT_PERSIST_OUTER); assert_ptr_equal(CUTSCENE_SYSTEM.scene, &CUTSCENE_TEST_INSERT_PERSIST_OUTER);
assert_int_equal(CUTSCENE_SYSTEM.scene->itemCount, 2); assert_int_equal(CUTSCENE_SYSTEM.scene->itemCount, 1);
assert_ptr_equal(CUTSCENE_SYSTEM.entityInteract, &ENTITIES[3]); assert_ptr_equal(CUTSCENE_SYSTEM.entityInteract, &ENTITIES[3]);
assert_ptr_equal(CUTSCENE_SYSTEM.entityInteracted, &ENTITIES[4]); assert_ptr_equal(CUTSCENE_SYSTEM.entityInteracted, &ENTITIES[4]);
// The snippet declares ALL, but INSERT doesn't apply it -- the outer // The snippet declares ALL, but INSERT doesn't apply it -- the outer
@@ -507,6 +569,9 @@ int main(int argc, char** argv) {
cmocka_unit_test(test_cutsceneSystemGetAreaId), cmocka_unit_test(test_cutsceneSystemGetAreaId),
cmocka_unit_test(test_cutsceneSystemGetTextMiniId), cmocka_unit_test(test_cutsceneSystemGetTextMiniId),
cmocka_unit_test(test_cutsceneSystemDisposeResetsState), cmocka_unit_test(test_cutsceneSystemDisposeResetsState),
cmocka_unit_test(
test_cutsceneSystemPrepareKeepsLoadedFileWhenReenteringLoadedScene
),
cmocka_unit_test(test_cutsceneInsertSplicesItemsInPlaceThenResumes), cmocka_unit_test(test_cutsceneInsertSplicesItemsInPlaceThenResumes),
cmocka_unit_test(test_cutsceneInsertAsLastItemEndsCutsceneNaturally), cmocka_unit_test(test_cutsceneInsertAsLastItemEndsCutsceneNaturally),
cmocka_unit_test(test_cutsceneInsertNestsThroughMultipleLevels), cmocka_unit_test(test_cutsceneInsertNestsThroughMultipleLevels),