Resolve moves into per-move cutscenes during battle execution

Each queued action now resolves to its move's cutscene
(cutscenes/battle/moves/<name>.jsonc), spliced into the executing state
with a BATTLE_POST_MOVE checkpoint after each one. Fighter turn order is
tracked via fighterOrder/fighterOrderCount, and resolved actions/pending
counts are cleared so fighters aren't skipped in later rounds. Also fixes
a BATTLE_WAIT_SELECTION deadlock and adds an optional "blocking" flag to
MODAL cutscene items.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
2026-09-20 17:49:06 -05:00
co-authored by Claude Sonnet 5
parent e3f56c4d12
commit 37e7e95ace
19 changed files with 320 additions and 87 deletions
+11 -4
View File
@@ -1,9 +1,16 @@
{
"items": [
{
{
"type": "MODAL",
"title": "BATTLE TEST",
"message": "BATTLE TEST"
"title": "Fire",
"message": "A burst of flame scorches the target."
},
{
"type": "WAIT",
"seconds": 2
},
{
"type": "MODAL_CLOSE"
}
]
}
}
+11 -4
View File
@@ -1,9 +1,16 @@
{
"items": [
{
{
"type": "MODAL",
"title": "BATTLE TEST",
"message": "BATTLE TEST"
"title": "Slash",
"message": "A quick blade strike cuts across the target."
},
{
"type": "WAIT",
"seconds": 2
},
{
"type": "MODAL_CLOSE"
}
]
}
}
+3 -1
View File
@@ -25,9 +25,11 @@ typedef struct {
// Order of who gets to act.
uint8_t fighterOrder[BATTLE_FIGHTER_POS_COUNT];
uint8_t fighterOrderCount;
// Actions to be executed.
battlemoveaction_t actionsPending[BATTLE_FIGHTER_POS_COUNT];
battlemoveaction_t actionsPending[BATTLE_FIGHTER_POS_COUNT];
uint8_t actionsPendingCount;
battlestate_t state;
battlestatedata_t stateData;
@@ -6,9 +6,92 @@
*/
#include "battlestateexecuting.h"
#include "rpg/battle/battle.h"
#include "rpg/cutscene/item/cutsceneitem.h"
#include "rpg/cutscene/cutscenesystem.h"
#include "rpg/cutscene/cutscene.h"
#include "asset/asset.h"
#include "asset/loader/assetloader.h"
#include "asset/assetfile.h"
#include "ui/overlay/uifatalerror.h"
#include "assert/assert.h"
#include "util/string.h"
void battleStateExecutingInit(void) {
cutsceneitem_t items[BATTLE_STATE_EXECUTING_ITEMS_MAX];
uint8_t itemsCount = 0;
// For each action (just queued during selection). actionsPending is
// indexed by fighter position, not sequentially, so scan every position
// and stop early once every pending action has been processed.
uint8_t processed = 0;
for(
uint8_t i = 0;
i < BATTLE_FIGHTER_POS_COUNT && processed < BATTLE.actionsPendingCount;
i++
) {
// Get action.
battlemoveaction_t *action = &BATTLE.actionsPending[i];
if(action->move == BATTLE_MOVE_ID_NULL) continue;
processed++;
assertTrue(action->move < BATTLE_MOVE_COUNT, "Invalid move id");
// Get ability.
battleability_t *ability = &BATTLE_MOVES[action->move];
assertStrLenMin(ability->name, 1, "Invalid move name");
// Determine cutscene path
char_t path[ASSET_FILE_NAME_MAX];
stringFormat(
path, ASSET_FILE_NAME_MAX - 1,
"cutscenes/battle/moves/%s.jsonc", ability->name
);
// Load cutscene asset.
assetentry_t *entry = assetLock(path, ASSET_LOADER_TYPE_JSON, NULL);
errorret_t result = assetRequireLoaded(entry);
if(errorIsNotOk(result)) {
assetUnlockEntry(entry);
errorCatch(errorPrint(result));
uiFatalErrorOpen(NULL);
continue;
}
// Parse out cutscene items and append to the list.
cutscene_t scene;
errorret_t parseResult = cutsceneParseDoc(
entry->data.json, &scene, &items[itemsCount],
BATTLE_STATE_EXECUTING_ITEMS_MAX - itemsCount
);
itemsCount += scene.itemCount;
// Release asset
assetUnlockEntry(entry);
if(errorIsNotOk(parseResult)) {
errorCatch(errorPrint(parseResult));
uiFatalErrorOpen(NULL);
continue;
}
// Insert end of attack cutscene item.
items[itemsCount++] = (cutsceneitem_t){
.type = CUTSCENE_ITEM_TYPE_BATTLE_POST_MOVE
};
// This action has now been resolved - clear it so it isn't
// re-executed (and doesn't block re-selection) next round.
action->move = BATTLE_MOVE_ID_NULL;
}
BATTLE.actionsPendingCount = 0;
// After all moves completed, transition to pre-round for next round.
items[itemsCount++] = (cutsceneitem_t){
.type = CUTSCENE_ITEM_TYPE_BATTLE_SET_STATE,
.battleSetState = { .state = BATTLE_STATE_PRE_ROUND }
};
cutsceneSystemInsertItemsNext(items, itemsCount);
}
void battleStateExecutingUpdate(void) {
@@ -12,6 +12,8 @@ typedef struct {
} battlestateexecuting_t;
#define BATTLE_STATE_EXECUTING_ITEMS_MAX 64
/**
* Called when the battle enters BATTLE_STATE_EXECUTING.
*/
@@ -6,11 +6,21 @@
*/
#include "battlestatepreround.h"
#include "rpg/battle/battle.h"
#include "rpg/cutscene/item/cutsceneitem.h"
#include "rpg/cutscene/cutscenesystem.h"
void battleStatePreRoundInit(void) {
const cutsceneitem_t items[] = {
// TODO: Determine selection order and who gets to act.
BATTLE.fighterOrderCount = 0;
for(uint8_t i = 0; i < BATTLE_FIGHTER_POS_COUNT; i++) {
if(BATTLE.fighters[i] == NULL) continue;
if(!battleFighterIsAlive(BATTLE.fighters[i])) continue;
BATTLE.fighterOrder[BATTLE.fighterOrderCount++] = i;
}
// Queue up the set state and wait for selection to stop cutscene ending.
cutsceneitem_t items[] = {
{
.type = CUTSCENE_ITEM_TYPE_BATTLE_SET_STATE,
.battleSetState = { .state = BATTLE_STATE_SELECTION }
@@ -7,87 +7,73 @@
#include "battlestateselection.h"
#include "rpg/battle/battle.h"
#include "rpg/cutscene/item/cutsceneitem.h"
#include "rpg/cutscene/cutscenesystem.h"
#include "assert/assert.h"
// TEMPORARY: hardcoded pending a real moves.jsonc-backed registry lookup.
#define BATTLE_MOVE_ID_ATTACK ((battlemoveid_t)1)
void battleStateSelectionInit(void) {
BATTLE.stateData.selection.selectionIndex = 0;
}
bool_t battleStateSelectionFighterHasDecided(const uint8_t fighterIndex) {
assertTrue(fighterIndex < BATTLE_FIGHTER_POS_COUNT, "Fighter OOB");
return BATTLE.actionsPending[fighterIndex].move != BATTLE_MOVE_ID_NULL;
}
bool_t battleStateSelectionFighterNeedsDecision(const uint8_t fighterIndex) {
assertTrue(fighterIndex < BATTLE_FIGHTER_POS_COUNT, "Fighter OOB");
battlefighter_t *fighter = BATTLE.fighters[fighterIndex];
if(fighter == NULL) return false;
if(!battleFighterIsAlive(fighter)) return false;
return !battleStateSelectionFighterHasDecided(fighterIndex);
}
bool_t battleStateSelectionIsComplete(void) {
return BATTLE.stateData.selection.selectionIndex >= BATTLE.fighterOrderCount;
}
void battleStateSelectionUpdate(void) {
battlestateselection_t *state = &BATTLE.stateData.selection;
while(state->selectionIndex < BATTLE_FIGHTER_POS_COUNT) {
const uint8_t fighterIndex = state->selectionIndex;
// Are we still waiting for a selection?
while(state->selectionIndex < BATTLE.fighterOrderCount) {
const uint8_t fighterIndex = BATTLE.fighterOrder[state->selectionIndex];
// Is this fighter still owed a decision?
if(!battleStateSelectionFighterNeedsDecision(fighterIndex)) {
state->selectionIndex++;
continue;
}
// Players will act on their own.
battlefighter_t *fighter = BATTLE.fighters[fighterIndex];
if(fighter->controller != BATTLE_FIGHTER_CONTROLLER_AI) {
// Player-controlled: wait for battleStateSelectionPlayerAttack/Flee.
return;
}
if(fighter->controller != BATTLE_FIGHTER_CONTROLLER_AI) return;
const battlefighterteam_t enemyTeam =
fighter->team == BATTLE_FIGHTER_TEAM_ALLY ?
BATTLE_FIGHTER_TEAM_ENEMY : BATTLE_FIGHTER_TEAM_ALLY;
uint8_t targetIndex = 0xFF;
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;
targetIndex = i;
break;
}
if(targetIndex != 0xFF) {
battleStateSelectionQueueAttack(fighterIndex, targetIndex);
}
// This is AI, in future I'll have proper AI here, for now I'm actually not
// going to do anything.
state->selectionIndex++;
}
const cutsceneitem_t items[] = {
{
.type = CUTSCENE_ITEM_TYPE_BATTLE_SET_STATE,
.battleSetState = { .state = BATTLE_STATE_EXECUTING }
}
};
cutsceneSystemInsertItems(items, sizeof(items) / sizeof(cutsceneitem_t));
}
void battleStateSelectionDispose(void) {
}
bool_t battleStateSelectionFighterNeedsDecision(const uint8_t fighterIndex) {
assertTrue(fighterIndex < BATTLE_FIGHTER_POS_COUNT, "fighterIndex out of bounds");
battlefighter_t *fighter = BATTLE.fighters[fighterIndex];
if(fighter == NULL) return false;
if(!battleFighterIsAlive(fighter)) return false;
return BATTLE.actionsPending[fighterIndex].move == BATTLE_MOVE_ID_NULL;
}
void battleStateSelectionQueueAttack(
const uint8_t fighterIndex,
const uint8_t targetIndex
) {
assertTrue(fighterIndex < BATTLE_FIGHTER_POS_COUNT, "fighterIndex out of bounds");
assertTrue(targetIndex < BATTLE_FIGHTER_POS_COUNT, "targetIndex out of bounds");
assertTrue(fighterIndex < BATTLE_FIGHTER_POS_COUNT, "Fighter OOB");
assertTrue(targetIndex < BATTLE_FIGHTER_POS_COUNT, "Target OOB");
assertNotNull(BATTLE.fighters[fighterIndex], "Fighter cannot be NULL");
assertNotNull(BATTLE.fighters[targetIndex], "Target cannot be NULL");
const battlemoveid_t move = battleMoveGetIdByName("slash");
assertTrue(move != BATTLE_MOVE_ID_NULL, "Missing 'slash' battle move");
battlemoveaction_t *action = &BATTLE.actionsPending[fighterIndex];
action->move = BATTLE_MOVE_ID_ATTACK;
if(action->move == BATTLE_MOVE_ID_NULL) BATTLE.actionsPendingCount++;
action->move = move;
action->targets[0] = targetIndex;
action->user = BATTLE.fighters[fighterIndex];
}
@@ -96,18 +82,18 @@ battlefighter_t *battleStateSelectionGetCurrentFighter(void) {
if(BATTLE.state != BATTLE_STATE_SELECTION) return NULL;
const uint8_t index = BATTLE.stateData.selection.selectionIndex;
if(index >= BATTLE_FIGHTER_POS_COUNT) return NULL;
if(index >= BATTLE.fighterOrderCount) return NULL;
return BATTLE.fighters[index];
return BATTLE.fighters[BATTLE.fighterOrder[index]];
}
void battleStateSelectionPlayerAttack(const uint8_t targetIndex) {
battlefighter_t *fighter = battleStateSelectionGetCurrentFighter();
if(fighter == NULL) return;
battleStateSelectionQueueAttack(
BATTLE.stateData.selection.selectionIndex, targetIndex
);
const uint8_t selectionIndex = BATTLE.stateData.selection.selectionIndex;
const uint8_t fighterIndex = BATTLE.fighterOrder[selectionIndex];
battleStateSelectionQueueAttack(fighterIndex, targetIndex);
BATTLE.stateData.selection.selectionIndex++;
}
@@ -116,4 +102,4 @@ void battleStateSelectionPlayerFlee(void) {
if(fighter == NULL) return;
battleSetState(BATTLE_STATE_FLED);
}
}
@@ -33,6 +33,15 @@ void battleStateSelectionUpdate(void);
*/
void battleStateSelectionDispose(void);
/**
* Checks whether the fighter at fighterIndex has already queued a move
* this round.
*
* @param fighterIndex Index into BATTLE.fighters to check.
* @return True if an action is already pending for this fighter.
*/
bool_t battleStateSelectionFighterHasDecided(const uint8_t fighterIndex);
/**
* Checks whether the fighter at fighterIndex is a live, undecided
* candidate that BATTLE_STATE_SELECTION should still be deciding a move
@@ -44,7 +53,16 @@ void battleStateSelectionDispose(void);
bool_t battleStateSelectionFighterNeedsDecision(const uint8_t fighterIndex);
/**
* Queues a basic attack from fighterIndex against targetIndex.
* Checks whether every fighter in BATTLE.fighterOrder has decided a move
* this round.
*
* @return True once the selection cursor has passed every fighter.
*/
bool_t battleStateSelectionIsComplete(void);
/**
* Queues a basic attack (the "slash" move) from fighterIndex against
* targetIndex.
*
* @param fighterIndex Index into BATTLE.fighters of the deciding fighter.
* @param targetIndex Index into BATTLE.fighters of the target.
@@ -77,4 +95,4 @@ void battleStateSelectionPlayerAttack(const uint8_t targetIndex);
* Ends the battle immediately with BATTLE_STATE_FLED, if the battle is in
* BATTLE_STATE_SELECTION and awaiting a decision.
*/
void battleStateSelectionPlayerFlee(void);
void battleStateSelectionPlayerFlee(void);
@@ -8,4 +8,5 @@ target_sources(${DUSK_LIBRARY_TARGET_NAME}
cutscenebattlestart.c
cutscenebattlesetstate.c
cutscenebattlewaitselection.c
cutscenebattlepostmove.c
)
@@ -0,0 +1,25 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "rpg/cutscene/item/cutsceneitem.h"
#include "console/console.h"
void cutsceneBattlePostMoveStart(
const cutsceneitem_t *item,
cutsceneitemdata_t *data
) {
// TODO: check fighter health etc. here to short-circuit into a
// win/loss (BATTLE_STATE_WON/LOST) once a side has been wiped out.
consolePrint("BATTLE_POST_MOVE\n");
}
bool_t cutsceneBattlePostMoveUpdate(
const cutsceneitem_t *item,
cutsceneitemdata_t *data
) {
return true;
}
@@ -0,0 +1,36 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "rpg/cutscene/item/cutsceneitembase.h"
/**
* Starts a BATTLE_POST_MOVE item. Runs after each queued move's cutscene
* finishes - eventually this is where we'll check things like fighter
* health to short-circuit into a win/loss, but for now it just prints to
* the console. Not JSON-authorable -- constructed dynamically by
* battleStateExecutingInit.
*
* @param item The cutscene item.
* @param data Runtime data storage.
*/
void cutsceneBattlePostMoveStart(
const cutsceneitem_t *item,
cutsceneitemdata_t *data
);
/**
* Updates a BATTLE_POST_MOVE item (always completes immediately).
*
* @param item The cutscene item.
* @param data Runtime data storage.
* @returns true always.
*/
bool_t cutsceneBattlePostMoveUpdate(
const cutsceneitem_t *item,
cutsceneitemdata_t *data
);
@@ -6,11 +6,24 @@
*/
#include "rpg/cutscene/item/cutsceneitem.h"
#include "rpg/cutscene/cutscenesystem.h"
#include "rpg/battle/battle.h"
#include "rpg/battle/state/battlestateselection.h"
bool_t cutsceneBattleWaitSelectionUpdate(
const cutsceneitem_t *item,
cutsceneitemdata_t *data
) {
return BATTLE.state != BATTLE_STATE_SELECTION;
if(BATTLE.state != BATTLE_STATE_SELECTION) return true;
if(!battleStateSelectionIsComplete()) return false;
// Every fighter has decided - queue the move to BATTLE_STATE_EXECUTING.
const cutsceneitem_t items[] = {
{
.type = CUTSCENE_ITEM_TYPE_BATTLE_SET_STATE,
.battleSetState = { .state = BATTLE_STATE_EXECUTING }
}
};
cutsceneSystemInsertItemsNext(items, sizeof(items) / sizeof(cutsceneitem_t));
return true;
}
@@ -9,15 +9,17 @@
#include "rpg/cutscene/item/cutsceneitembase.h"
/**
* Updates a BATTLE_WAIT_SELECTION step, completing once the battle is no
* longer in BATTLE_STATE_SELECTION -- i.e. every fighter has decided a
* move (moving on to BATTLE_STATE_EXECUTING) or the selecting fighter has
* fled (moving on to BATTLE_STATE_FLED). Has no Start callback -- there's
* nothing to do until selection actually finishes.
* Updates a BATTLE_WAIT_SELECTION step, completing once every fighter has
* decided a move this round (letting the queued BATTLE_SET_STATE step
* that follows move the battle on to BATTLE_STATE_EXECUTING) or once the
* selecting fighter has fled (moving on to BATTLE_STATE_FLED). Has no
* Start callback -- there's nothing to do until selection actually
* finishes.
*
* @param item The cutscene item.
* @param data Runtime data storage.
* @returns true once BATTLE.state is no longer BATTLE_STATE_SELECTION.
* @returns true once selection has completed or the battle has left
* BATTLE_STATE_SELECTION.
*/
bool_t cutsceneBattleWaitSelectionUpdate(
const cutsceneitem_t *item,
@@ -265,6 +265,11 @@ cutsceneitemcallbacks_t CUTSCENE_ITEM_CALLBACKS[CUTSCENE_ITEM_TYPE_COUNT] = {
.load = cutsceneItemLoadNoop
},
[CUTSCENE_ITEM_TYPE_BATTLE_POST_MOVE] = {
.init = cutsceneBattlePostMoveStart,
.update = cutsceneBattlePostMoveUpdate
},
// Save
[CUTSCENE_ITEM_TYPE_SAVE_DEVICE_CHECK] = {
.init = cutsceneSaveDeviceCheckStart,
@@ -43,6 +43,7 @@
#include "battle/cutscenebattlestart.h"
#include "battle/cutscenebattlesetstate.h"
#include "battle/cutscenebattlewaitselection.h"
#include "battle/cutscenebattlepostmove.h"
#include "save/cutscenesavedevicecheck.h"
#include "save/cutscenesaveloadallslots.h"
#include "audio/cutsceneaudioplay.h"
@@ -63,6 +63,7 @@ typedef enum {
CUTSCENE_ITEM_TYPE_BATTLE_START,
CUTSCENE_ITEM_TYPE_BATTLE_SET_STATE,
CUTSCENE_ITEM_TYPE_BATTLE_WAIT_SELECTION,
CUTSCENE_ITEM_TYPE_BATTLE_POST_MOVE,
// Save
CUTSCENE_ITEM_TYPE_SAVE_DEVICE_CHECK,
@@ -36,7 +36,8 @@ bool_t cutsceneModalUpdate(
const cutsceneitem_t *item,
cutsceneitemdata_t *data
) {
return item->modal.optionCount == 0;
if(item->modal.optionCount == 0 && !item->modal.blocking) return true;
return !uiModalIsOpen();
}
void cutsceneModalCloseStart(
@@ -72,6 +73,7 @@ errorret_t cutsceneModalLoad(
)) {
errorThrow("Cutscene item (MODAL): missing/too-long 'message'");
}
item->modal.blocking = cutsceneJsonGetBool(itemObj, "blocking", false);
errorOk();
}
+19 -12
View File
@@ -40,6 +40,12 @@ typedef struct {
uint8_t optionCount;
cutscenemodaloptioncallback_t callback;
// Whether the cutscene should wait for this dialog to close before
// continuing. Always treated as true when optionCount > 0 (there's no
// other way to move on). Ignored/false by default for a message-only
// dialog, e.g. from CUTSCENE_MODAL - see cutsceneModalUpdate.
bool_t blocking;
} cutscenemodal_t;
/**
@@ -57,23 +63,24 @@ void cutsceneModalStart(
);
/**
* Updates a modal item. A message-only modal (optionCount == 0, e.g.
* from CUTSCENE_MODAL) always completes immediately - the cutscene
* continues on to whatever follows this item right away, it does not
* wait for the dialog to be dismissed. Script what should happen while
* it's up, and how it gets closed, as later items in the same cutscene
* (e.g. CUTSCENE_CALLBACK, CUTSCENE_WAIT, CUTSCENE_MODAL_CLOSE).
* Updates a modal item.
*
* A message-only modal (optionCount == 0, e.g. from CUTSCENE_MODAL)
* completes immediately unless item->modal.blocking is set, in which
* case it waits for the dialog to close - either the player pressing
* accept/cancel (see uiModalMessageFocusSelected) or an explicit
* CUTSCENE_MODAL_CLOSE item elsewhere in the cutscene.
*
* A modal with options (optionCount > 0, e.g. from
* CUTSCENE_MODAL_OPTIONS) never completes on its own - the cutscene
* blocks here indefinitely. The option callback fires once the user
* picks something, and it alone is responsible for moving the cutscene
* on from there (typically via cutsceneGoTo or cutsceneRestart).
* CUTSCENE_MODAL_OPTIONS) always blocks regardless of the blocking
* field - it's dismissed by the player picking one of them, and the
* option callback is responsible for moving the cutscene on from there
* (typically via cutsceneGoTo or cutsceneRestart).
*
* @param item The cutscene item.
* @param data Runtime data storage.
* @returns true once immediately for a message-only modal; false
* always for a modal with options.
* @returns true once the modal has closed, or immediately for a
* non-blocking message-only modal.
*/
bool_t cutsceneModalUpdate(
const cutsceneitem_t *item,
+25
View File
@@ -7,6 +7,7 @@
#include "uimodal.h"
#include "ui/widget/uiframe.h"
#include "ui/focus/uifocus.h"
#include "assert/assert.h"
#include "util/memory.h"
#include "util/string.h"
@@ -24,6 +25,11 @@
uimodal_t UI_MODAL;
// Focus item backing a message-only modal (no options) - lets a plain
// MODAL still block on player confirmation. NULL whenever a
// message-only modal isn't open. Mirrors uitextboxmain.c's pattern.
static uifocusitem_t *messageFocus = NULL;
void uiModalSelected(
const uimenu_t *menu,
const uint8_t index,
@@ -39,6 +45,18 @@ void uiModalClosed(const uimenu_t *menu) {
}
}
bool_t uiModalMessageFocusSelected(const uifocusitem_t *item) {
uiFocusPopItem(messageFocus);
return true;
}
bool_t uiModalMessageFocusClosed(const uifocusitem_t *item) {
messageFocus = NULL;
UI_MODAL.open = false;
uiModalClosed(NULL);
return true;
}
errorret_t uiModalInit(void) {
memoryZero(&UI_MODAL, sizeof(uimodal_t));
@@ -212,6 +230,11 @@ void uiModalOpen(
uiMenuOpen(&UI_MODAL.menu);
} else {
uiMenuInit(&UI_MODAL.menu, uiModalSelected, uiModalClosed, NULL);
messageFocus = uiFocusPush(
1, 1,
uiModalMessageFocusSelected, NULL, uiModalMessageFocusClosed,
NULL, NULL
);
}
if(UI_MODAL.onOpen != NULL) {
@@ -225,6 +248,8 @@ void uiModalClose(uimodalclosedcallback_t callback) {
if(uiMenuIsActive(&UI_MODAL.menu)) {
uiMenuClose(&UI_MODAL.menu);
} else if(messageFocus != NULL) {
uiFocusPopItem(messageFocus);
} else {
uiModalClosed(NULL);
}