Rebuild battle flow as an explicit state machine with cutscene hooks
Replace the one-fighter-at-a-time turn model with an OPENING/PRE_ROUND/ PLAYER_SELECTION/AI_SELECTION/MOVES_EXECUTING/POST_ROUND/ENDED state machine and a per-fighter action queue, so actions are decided before any of them execute (needed for speed-ordered resolution) and so a cutscene can pause the battle, wait for a specific state, and force a fighter's action -- enabling automated, fully-scripted, and partially-scripted battles. Adds CUTSCENE_PAUSE_BATTLE plus CUTSCENE_BATTLE_WAIT_STATE and CUTSCENE_BATTLE_FORCE_ACTION cutscene items, and generic onStateChanged/ onActionDecided callbacks on battle_t. Re-enables the long-dormant test/rpg suite and adds test/rpg/battle covering the state machine and the new cutscene hooks end-to-end. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+161
-58
@@ -8,6 +8,7 @@
|
||||
#include "battle.h"
|
||||
#include "assert/assert.h"
|
||||
#include "util/memory.h"
|
||||
#include "rpg/cutscene/cutscenesystem.h"
|
||||
|
||||
battle_t BATTLE;
|
||||
|
||||
@@ -51,10 +52,8 @@ void battleStart(
|
||||
BATTLE.fleeAvailable = fleeAvailable;
|
||||
BATTLE.result = BATTLE_RESULT_NONE;
|
||||
BATTLE.round = 1;
|
||||
BATTLE.turnIndex = 0;
|
||||
battleBuildTurnOrder(true);
|
||||
|
||||
BATTLE.active = true;
|
||||
battleSetState(BATTLE_STATE_OPENING);
|
||||
}
|
||||
|
||||
void battleDispose(void) {
|
||||
@@ -62,9 +61,9 @@ void battleDispose(void) {
|
||||
}
|
||||
|
||||
battlefighter_t *battleGetCurrentFighter(void) {
|
||||
if(!BATTLE.active) return NULL;
|
||||
if(BATTLE.turnIndex >= BATTLE.turnCount) return NULL;
|
||||
return &BATTLE.fighters[BATTLE.turnOrder[BATTLE.turnIndex]];
|
||||
if(BATTLE.state != BATTLE_STATE_PLAYER_SELECTION) return NULL;
|
||||
if(BATTLE.selectionIndex >= BATTLE.executionCount) return NULL;
|
||||
return &BATTLE.fighters[BATTLE.executionOrder[BATTLE.selectionIndex]];
|
||||
}
|
||||
|
||||
uint8_t battleGetAliveCount(const battlefighterteam_t team) {
|
||||
@@ -92,91 +91,105 @@ void battleResolveAttack(
|
||||
if(defender->health == 0) defender->status = BATTLE_FIGHTER_STATUS_DEAD;
|
||||
}
|
||||
|
||||
void battleNextTurn(void) {
|
||||
BATTLE.turnIndex++;
|
||||
if(BATTLE.turnIndex < BATTLE.turnCount) return;
|
||||
|
||||
BATTLE.round++;
|
||||
BATTLE.turnIndex = 0;
|
||||
battleBuildTurnOrder(false);
|
||||
}
|
||||
|
||||
battleresult_t battleCheckResult(void) {
|
||||
if(BATTLE.result != BATTLE_RESULT_NONE) return BATTLE.result;
|
||||
|
||||
if(battleGetAliveCount(BATTLE_FIGHTER_TEAM_ALLY) == 0) {
|
||||
BATTLE.result = BATTLE_RESULT_LOSS;
|
||||
battleSetResult(BATTLE_RESULT_LOSS);
|
||||
} else if(battleGetAliveCount(BATTLE_FIGHTER_TEAM_ENEMY) == 0) {
|
||||
BATTLE.result = BATTLE_RESULT_WIN;
|
||||
battleSetResult(BATTLE_RESULT_WIN);
|
||||
}
|
||||
|
||||
return BATTLE.result;
|
||||
}
|
||||
|
||||
void battleQueueAction(
|
||||
const uint8_t fighterIndex,
|
||||
const battleactiontype_t type,
|
||||
const uint8_t targetIndex
|
||||
) {
|
||||
battleaction_t *action = &BATTLE.actions[fighterIndex];
|
||||
action->type = type;
|
||||
action->targetIndex = targetIndex;
|
||||
|
||||
if(BATTLE.onActionDecided != NULL) {
|
||||
BATTLE.onActionDecided(&BATTLE.fighters[fighterIndex], action);
|
||||
}
|
||||
}
|
||||
|
||||
void battlePlayerAttack(const uint8_t targetIndex) {
|
||||
battlefighter_t *attacker = battleGetCurrentFighter();
|
||||
if(attacker == NULL) return;
|
||||
if(attacker->controller != BATTLE_FIGHTER_CONTROLLER_PLAYER) return;
|
||||
battlefighter_t *fighter = battleGetCurrentFighter();
|
||||
if(fighter == NULL) return;
|
||||
if(targetIndex >= BATTLE_FIGHTER_COUNT_MAX) return;
|
||||
if(!battleFighterIsAlive(&BATTLE.fighters[targetIndex])) return;
|
||||
|
||||
battlefighter_t *defender = &BATTLE.fighters[targetIndex];
|
||||
if(!battleFighterIsAlive(defender)) return;
|
||||
|
||||
battleResolveAttack(attacker, defender);
|
||||
battleCheckResult();
|
||||
if(BATTLE.result == BATTLE_RESULT_NONE) battleNextTurn();
|
||||
battleQueueAction(fighter->id, BATTLE_ACTION_ATTACK, targetIndex);
|
||||
BATTLE.selectionIndex++;
|
||||
battleAdvanceSelection();
|
||||
}
|
||||
|
||||
void battlePlayerFlee(void) {
|
||||
battlefighter_t *fighter = battleGetCurrentFighter();
|
||||
if(fighter == NULL) return;
|
||||
if(fighter->controller != BATTLE_FIGHTER_CONTROLLER_PLAYER) return;
|
||||
if(!BATTLE.fleeAvailable) return;
|
||||
|
||||
BATTLE.result = BATTLE_RESULT_FLED;
|
||||
battleSetResult(BATTLE_RESULT_FLED);
|
||||
}
|
||||
|
||||
void battleUpdate(void) {
|
||||
if(!BATTLE.active) return;
|
||||
if(BATTLE.result != BATTLE_RESULT_NONE) return;
|
||||
if(BATTLE.state == BATTLE_STATE_NONE) return;
|
||||
if(BATTLE.state == BATTLE_STATE_ENDED) return;
|
||||
if(CUTSCENE_SYSTEM.pause & CUTSCENE_PAUSE_BATTLE) return;
|
||||
|
||||
battlefighter_t *current = battleGetCurrentFighter();
|
||||
if(current == NULL) return;
|
||||
switch(BATTLE.state) {
|
||||
case BATTLE_STATE_OPENING:
|
||||
battleSetState(BATTLE_STATE_PRE_ROUND);
|
||||
break;
|
||||
|
||||
if(!battleFighterIsAlive(current)) {
|
||||
battleNextTurn();
|
||||
return;
|
||||
case BATTLE_STATE_PRE_ROUND:
|
||||
battleUpdatePreRound();
|
||||
break;
|
||||
|
||||
case BATTLE_STATE_AI_SELECTION:
|
||||
battleUpdateAiSelection();
|
||||
break;
|
||||
|
||||
case BATTLE_STATE_MOVES_EXECUTING:
|
||||
battleUpdateMovesExecuting();
|
||||
break;
|
||||
|
||||
case BATTLE_STATE_POST_ROUND:
|
||||
battleUpdatePostRound();
|
||||
break;
|
||||
|
||||
default:
|
||||
// BATTLE_STATE_PLAYER_SELECTION: waits on battlePlayerAttack/Flee.
|
||||
// BATTLE_STATE_NONE/ENDED: handled above.
|
||||
break;
|
||||
}
|
||||
|
||||
if(current->controller != BATTLE_FIGHTER_CONTROLLER_AI) return;
|
||||
|
||||
battlefighter_t *target = battleAIChooseTarget(current);
|
||||
if(target != NULL) battleResolveAttack(current, target);
|
||||
|
||||
battleCheckResult();
|
||||
if(BATTLE.result == BATTLE_RESULT_NONE) battleNextTurn();
|
||||
}
|
||||
|
||||
void battleBuildTurnOrder(const bool_t applyEncounterBias) {
|
||||
BATTLE.turnCount = 0;
|
||||
void battleBuildExecutionOrder(const bool_t applyEncounterBias) {
|
||||
BATTLE.executionCount = 0;
|
||||
for(uint8_t i = 0; i < BATTLE_FIGHTER_COUNT_MAX; i++) {
|
||||
if(!battleFighterIsAlive(&BATTLE.fighters[i])) continue;
|
||||
BATTLE.turnOrder[BATTLE.turnCount++] = i;
|
||||
BATTLE.executionOrder[BATTLE.executionCount++] = i;
|
||||
}
|
||||
|
||||
// Insertion sort by speed descending -- fine for BATTLE_FIGHTER_COUNT_MAX.
|
||||
for(uint8_t i = 1; i < BATTLE.turnCount; i++) {
|
||||
const uint8_t key = BATTLE.turnOrder[i];
|
||||
for(uint8_t i = 1; i < BATTLE.executionCount; i++) {
|
||||
const uint8_t key = BATTLE.executionOrder[i];
|
||||
const uint16_t keySpeed = BATTLE.fighters[key].stats.speed;
|
||||
|
||||
int8_t j = (int8_t)i - 1;
|
||||
while(
|
||||
j >= 0 && BATTLE.fighters[BATTLE.turnOrder[j]].stats.speed < keySpeed
|
||||
j >= 0 &&
|
||||
BATTLE.fighters[BATTLE.executionOrder[j]].stats.speed < keySpeed
|
||||
) {
|
||||
BATTLE.turnOrder[j + 1] = BATTLE.turnOrder[j];
|
||||
BATTLE.executionOrder[j + 1] = BATTLE.executionOrder[j];
|
||||
j--;
|
||||
}
|
||||
BATTLE.turnOrder[j + 1] = key;
|
||||
BATTLE.executionOrder[j + 1] = key;
|
||||
}
|
||||
|
||||
if(!applyEncounterBias) return;
|
||||
@@ -192,16 +205,16 @@ void battleMoveTeamFirst(const battlefighterteam_t team) {
|
||||
uint8_t sorted[BATTLE_FIGHTER_COUNT_MAX];
|
||||
uint8_t count = 0;
|
||||
|
||||
for(uint8_t i = 0; i < BATTLE.turnCount; i++) {
|
||||
if(BATTLE.fighters[BATTLE.turnOrder[i]].team != team) continue;
|
||||
sorted[count++] = BATTLE.turnOrder[i];
|
||||
for(uint8_t i = 0; i < BATTLE.executionCount; i++) {
|
||||
if(BATTLE.fighters[BATTLE.executionOrder[i]].team != team) continue;
|
||||
sorted[count++] = BATTLE.executionOrder[i];
|
||||
}
|
||||
for(uint8_t i = 0; i < BATTLE.turnCount; i++) {
|
||||
if(BATTLE.fighters[BATTLE.turnOrder[i]].team == team) continue;
|
||||
sorted[count++] = BATTLE.turnOrder[i];
|
||||
for(uint8_t i = 0; i < BATTLE.executionCount; i++) {
|
||||
if(BATTLE.fighters[BATTLE.executionOrder[i]].team == team) continue;
|
||||
sorted[count++] = BATTLE.executionOrder[i];
|
||||
}
|
||||
|
||||
memoryCopy(BATTLE.turnOrder, sorted, sizeof(uint8_t) * BATTLE.turnCount);
|
||||
memoryCopy(BATTLE.executionOrder, sorted, sizeof(uint8_t) * BATTLE.executionCount);
|
||||
}
|
||||
|
||||
battlefighter_t *battleAIChooseTarget(const battlefighter_t *fighter) {
|
||||
@@ -221,3 +234,93 @@ battlefighter_t *battleAIChooseTarget(const battlefighter_t *fighter) {
|
||||
|
||||
return weakest;
|
||||
}
|
||||
|
||||
void battleSetState(const battlestate_t next) {
|
||||
const battlestate_t previous = BATTLE.state;
|
||||
BATTLE.state = next;
|
||||
if(BATTLE.onStateChanged != NULL) BATTLE.onStateChanged(previous, next);
|
||||
}
|
||||
|
||||
void battleSetResult(const battleresult_t result) {
|
||||
BATTLE.result = result;
|
||||
battleSetState(BATTLE_STATE_ENDED);
|
||||
}
|
||||
|
||||
bool_t battleFighterNeedsDecision(
|
||||
const uint8_t fighterIndex,
|
||||
const battlefightercontroller_t controller
|
||||
) {
|
||||
return battleFighterIsAlive(&BATTLE.fighters[fighterIndex])
|
||||
&& BATTLE.fighters[fighterIndex].controller == controller
|
||||
&& BATTLE.actions[fighterIndex].type == BATTLE_ACTION_NONE;
|
||||
}
|
||||
|
||||
void battleAdvanceSelection(void) {
|
||||
while(BATTLE.selectionIndex < BATTLE.executionCount) {
|
||||
const uint8_t fighterIndex = BATTLE.executionOrder[BATTLE.selectionIndex];
|
||||
if(
|
||||
battleFighterNeedsDecision(fighterIndex, BATTLE_FIGHTER_CONTROLLER_PLAYER)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
BATTLE.selectionIndex++;
|
||||
}
|
||||
|
||||
battleSetState(BATTLE_STATE_AI_SELECTION);
|
||||
}
|
||||
|
||||
void battleUpdatePreRound(void) {
|
||||
// No need to clear BATTLE.actions here: every living fighter's action is
|
||||
// unconditionally reset to BATTLE_ACTION_NONE as it's processed in
|
||||
// battleUpdateMovesExecuting, and round 1 starts pre-zeroed by
|
||||
// battleInit(). Clearing it here would also wipe out any action a
|
||||
// cutscene force-queued while parked at BATTLE_STATE_PRE_ROUND.
|
||||
battleBuildExecutionOrder(BATTLE.round == 1);
|
||||
|
||||
BATTLE.selectionIndex = 0;
|
||||
battleSetState(BATTLE_STATE_PLAYER_SELECTION);
|
||||
battleAdvanceSelection();
|
||||
}
|
||||
|
||||
void battleUpdateAiSelection(void) {
|
||||
for(uint8_t i = 0; i < BATTLE.executionCount; i++) {
|
||||
const uint8_t fighterIndex = BATTLE.executionOrder[i];
|
||||
if(
|
||||
!battleFighterNeedsDecision(fighterIndex, BATTLE_FIGHTER_CONTROLLER_AI)
|
||||
) continue;
|
||||
|
||||
battlefighter_t *target =
|
||||
battleAIChooseTarget(&BATTLE.fighters[fighterIndex]);
|
||||
if(target == NULL) continue;
|
||||
|
||||
battleQueueAction(fighterIndex, BATTLE_ACTION_ATTACK, target->id);
|
||||
}
|
||||
|
||||
BATTLE.executionIndex = 0;
|
||||
battleSetState(BATTLE_STATE_MOVES_EXECUTING);
|
||||
}
|
||||
|
||||
void battleUpdateMovesExecuting(void) {
|
||||
if(BATTLE.executionIndex >= BATTLE.executionCount) {
|
||||
battleSetState(BATTLE_STATE_POST_ROUND);
|
||||
return;
|
||||
}
|
||||
|
||||
const uint8_t fighterIndex = BATTLE.executionOrder[BATTLE.executionIndex++];
|
||||
battlefighter_t *fighter = &BATTLE.fighters[fighterIndex];
|
||||
if(!battleFighterIsAlive(fighter)) return;
|
||||
|
||||
battleaction_t *action = &BATTLE.actions[fighterIndex];
|
||||
if(action->type == BATTLE_ACTION_ATTACK) {
|
||||
battlefighter_t *target = &BATTLE.fighters[action->targetIndex];
|
||||
if(battleFighterIsAlive(target)) battleResolveAttack(fighter, target);
|
||||
}
|
||||
action->type = BATTLE_ACTION_NONE;
|
||||
|
||||
battleCheckResult();
|
||||
}
|
||||
|
||||
void battleUpdatePostRound(void) {
|
||||
BATTLE.round++;
|
||||
battleSetState(BATTLE_STATE_PRE_ROUND);
|
||||
}
|
||||
|
||||
+155
-36
@@ -27,19 +27,68 @@ typedef enum {
|
||||
BATTLE_RESULT_COUNT
|
||||
} battleresult_t;
|
||||
|
||||
// Where BATTLE currently is within a round. A cutscene can pause progression
|
||||
// (CUTSCENE_PAUSE_BATTLE) and use CUTSCENE_BATTLE_WAIT_STATE to synchronize
|
||||
// with any of these, or CUTSCENE_BATTLE_FORCE_ACTION to decide a fighter's
|
||||
// action ahead of PLAYER_SELECTION/AI_SELECTION reaching them.
|
||||
typedef enum {
|
||||
BATTLE_STATE_NONE, // Battle inactive.
|
||||
|
||||
BATTLE_STATE_OPENING, // Entered once by battleStart().
|
||||
BATTLE_STATE_PRE_ROUND, // Rebuilds execution order, clears the action queue.
|
||||
BATTLE_STATE_PLAYER_SELECTION, // Waits on battlePlayerAttack/Flee.
|
||||
BATTLE_STATE_AI_SELECTION, // Auto-queues every undecided AI fighter.
|
||||
BATTLE_STATE_MOVES_EXECUTING, // Resolves one queued action per update.
|
||||
BATTLE_STATE_POST_ROUND, // Round wrap-up; loops back to PRE_ROUND.
|
||||
|
||||
BATTLE_STATE_ENDED, // Terminal for WIN/LOSS/FLED alike -- see BATTLE.result.
|
||||
|
||||
BATTLE_STATE_COUNT
|
||||
} battlestate_t;
|
||||
|
||||
typedef enum {
|
||||
BATTLE_ACTION_NONE, // No action decided yet for this fighter this round.
|
||||
BATTLE_ACTION_ATTACK,
|
||||
|
||||
BATTLE_ACTION_COUNT
|
||||
} battleactiontype_t;
|
||||
|
||||
typedef struct {
|
||||
bool_t active;
|
||||
battleactiontype_t type;
|
||||
uint8_t targetIndex; // Meaningful for BATTLE_ACTION_ATTACK.
|
||||
} battleaction_t;
|
||||
|
||||
typedef void (*battlestatechangedcallback_t)(
|
||||
const battlestate_t previous,
|
||||
const battlestate_t next
|
||||
);
|
||||
|
||||
typedef void (*battleactiondecidedcallback_t)(
|
||||
const battlefighter_t *fighter,
|
||||
const battleaction_t *action
|
||||
);
|
||||
|
||||
typedef struct {
|
||||
battlestate_t state;
|
||||
battlefighter_t fighters[BATTLE_FIGHTER_COUNT_MAX];
|
||||
battleaction_t actions[BATTLE_FIGHTER_COUNT_MAX];
|
||||
|
||||
battleencountertype_t encounterType;
|
||||
bool_t fleeAvailable;
|
||||
battleresult_t result;
|
||||
|
||||
// Fighter indices (into fighters[]), sorted for the current round.
|
||||
uint8_t turnOrder[BATTLE_FIGHTER_COUNT_MAX];
|
||||
uint8_t turnCount;
|
||||
uint8_t turnIndex;
|
||||
uint8_t executionOrder[BATTLE_FIGHTER_COUNT_MAX];
|
||||
uint8_t executionCount;
|
||||
uint8_t executionIndex;
|
||||
uint16_t round;
|
||||
|
||||
// Cursor into executionOrder used by BATTLE_STATE_PLAYER_SELECTION to find
|
||||
// the next player-controlled fighter that still needs a decision.
|
||||
uint8_t selectionIndex;
|
||||
|
||||
battlestatechangedcallback_t onStateChanged;
|
||||
battleactiondecidedcallback_t onActionDecided;
|
||||
} battle_t;
|
||||
|
||||
extern battle_t BATTLE;
|
||||
@@ -77,11 +126,10 @@ battlefighter_t *battleAddFighter(
|
||||
);
|
||||
|
||||
/**
|
||||
* Starts the battle: builds the opening turn order (biased by
|
||||
* encounterType for the first round only) and marks the battle active.
|
||||
* Call once every fighter has been added via battleAddFighter.
|
||||
* Starts the battle: enters BATTLE_STATE_OPENING and marks the battle
|
||||
* active. Call once every fighter has been added via battleAddFighter.
|
||||
*
|
||||
* @param encounterType Determines the opening round's turn order.
|
||||
* @param encounterType Determines the opening round's execution order.
|
||||
* @param fleeAvailable Whether the party may attempt to flee this battle.
|
||||
*/
|
||||
void battleStart(
|
||||
@@ -95,10 +143,11 @@ void battleStart(
|
||||
void battleDispose(void);
|
||||
|
||||
/**
|
||||
* Returns the fighter whose turn it currently is.
|
||||
* Returns the fighter currently awaiting a player decision.
|
||||
*
|
||||
* @return Pointer to the active fighter, or NULL if the battle isn't
|
||||
* active or has no living fighters left to act.
|
||||
* @return Pointer to the fighter awaiting a decision, or NULL if the battle
|
||||
* isn't in BATTLE_STATE_PLAYER_SELECTION or every player-controlled
|
||||
* fighter has already decided.
|
||||
*/
|
||||
battlefighter_t *battleGetCurrentFighter(void);
|
||||
|
||||
@@ -125,61 +174,68 @@ void battleResolveAttack(
|
||||
);
|
||||
|
||||
/**
|
||||
* Ends the current fighter's turn and advances to the next fighter in
|
||||
* the turn order, starting a new round (rebuilding turn order purely by
|
||||
* speed) once every fighter in the current round has acted.
|
||||
*/
|
||||
void battleNextTurn(void);
|
||||
|
||||
/**
|
||||
* Checks whether the battle has been won or lost, updating and
|
||||
* returning BATTLE.result. Does nothing if a result has already been
|
||||
* set (e.g. by a successful flee).
|
||||
* Checks whether the battle has been won or lost, transitioning to
|
||||
* BATTLE_STATE_ENDED and updating BATTLE.result if so. Does nothing if a
|
||||
* result has already been set (e.g. by a successful flee).
|
||||
*
|
||||
* @return The battle's current result.
|
||||
*/
|
||||
battleresult_t battleCheckResult(void);
|
||||
|
||||
/**
|
||||
* Submits the current fighter's attack against a target, if it is
|
||||
* currently a player-controlled fighter's turn. Resolves the attack,
|
||||
* checks for a battle result, and advances the turn.
|
||||
* Queues an action for a fighter to perform once BATTLE_STATE_MOVES_EXECUTING
|
||||
* reaches them this round, overwriting any action already queued for that
|
||||
* fighter. Fires BATTLE.onActionDecided.
|
||||
*
|
||||
* @param fighterIndex Index into BATTLE.fighters of the deciding fighter.
|
||||
* @param type The type of action to perform.
|
||||
* @param targetIndex Index into BATTLE.fighters of the target, meaningful
|
||||
* for BATTLE_ACTION_ATTACK.
|
||||
*/
|
||||
void battleQueueAction(
|
||||
const uint8_t fighterIndex,
|
||||
const battleactiontype_t type,
|
||||
const uint8_t targetIndex
|
||||
);
|
||||
|
||||
/**
|
||||
* Submits the currently-selecting fighter's attack against a target, if the
|
||||
* battle is in BATTLE_STATE_PLAYER_SELECTION and awaiting a decision.
|
||||
* Queues the action and advances the selection cursor.
|
||||
*
|
||||
* @param targetIndex Index into BATTLE.fighters of the target.
|
||||
*/
|
||||
void battlePlayerAttack(const uint8_t targetIndex);
|
||||
|
||||
/**
|
||||
* Submits a flee attempt for the current fighter's turn, if it is
|
||||
* currently a player-controlled fighter's turn and fleeing is
|
||||
* available for this battle. Always succeeds, ending the battle with
|
||||
* BATTLE_RESULT_FLED.
|
||||
* Submits a flee attempt for the currently-selecting fighter, if the battle
|
||||
* is in BATTLE_STATE_PLAYER_SELECTION and fleeing is available for this
|
||||
* battle. Always succeeds, ending the battle with BATTLE_RESULT_FLED.
|
||||
*/
|
||||
void battlePlayerFlee(void);
|
||||
|
||||
/**
|
||||
* Updates the battle simulation for one frame: resolves the current
|
||||
* fighter's turn automatically if AI-controlled, otherwise waits for a
|
||||
* player action via battlePlayerAttack/battlePlayerFlee. No-op if the
|
||||
* battle isn't active or already has a result.
|
||||
* Updates the battle simulation for one frame, dispatching on BATTLE.state.
|
||||
* No-op if the battle isn't active, has already ended, or
|
||||
* CUTSCENE_PAUSE_BATTLE is set.
|
||||
*/
|
||||
void battleUpdate(void);
|
||||
|
||||
/**
|
||||
* Rebuilds BATTLE.turnOrder/turnCount from every currently living
|
||||
* Rebuilds BATTLE.executionOrder/executionCount from every currently living
|
||||
* fighter, sorted by speed descending.
|
||||
*
|
||||
* @param applyEncounterBias If true, reorders the freshly speed-sorted
|
||||
* queue so BATTLE.encounterType's favoured team goes first (used only
|
||||
* for the opening round).
|
||||
*/
|
||||
void battleBuildTurnOrder(const bool_t applyEncounterBias);
|
||||
void battleBuildExecutionOrder(const bool_t applyEncounterBias);
|
||||
|
||||
/**
|
||||
* Stably partitions BATTLE.turnOrder so every fighter on the given team
|
||||
* Stably partitions BATTLE.executionOrder so every fighter on the given team
|
||||
* comes first, preserving each side's relative (speed-sorted) order.
|
||||
*
|
||||
* @param team The team to move to the front of the turn order.
|
||||
* @param team The team to move to the front of the execution order.
|
||||
*/
|
||||
void battleMoveTeamFirst(const battlefighterteam_t team);
|
||||
|
||||
@@ -192,3 +248,66 @@ void battleMoveTeamFirst(const battlefighterteam_t team);
|
||||
* living fighters.
|
||||
*/
|
||||
battlefighter_t *battleAIChooseTarget(const battlefighter_t *fighter);
|
||||
|
||||
/**
|
||||
* Sets BATTLE.state and fires BATTLE.onStateChanged with the previous and
|
||||
* new state.
|
||||
*
|
||||
* @param next The state to transition to.
|
||||
*/
|
||||
void battleSetState(const battlestate_t next);
|
||||
|
||||
/**
|
||||
* Sets BATTLE.result and transitions to BATTLE_STATE_ENDED.
|
||||
*
|
||||
* @param result The result to end the battle with.
|
||||
*/
|
||||
void battleSetResult(const battleresult_t result);
|
||||
|
||||
/**
|
||||
* Checks whether a fighter is a live, undecided candidate for the given
|
||||
* controller -- i.e. whether PLAYER_SELECTION or AI_SELECTION should still
|
||||
* be deciding an action for it this round.
|
||||
*
|
||||
* @param fighterIndex Index into BATTLE.fighters to check.
|
||||
* @param controller The controller PLAYER_SELECTION/AI_SELECTION is
|
||||
* currently deciding for.
|
||||
* @return True if the fighter is alive, matches controller, and has no
|
||||
* action queued yet.
|
||||
*/
|
||||
bool_t battleFighterNeedsDecision(
|
||||
const uint8_t fighterIndex,
|
||||
const battlefightercontroller_t controller
|
||||
);
|
||||
|
||||
/**
|
||||
* Advances BATTLE.selectionIndex to the next player-controlled fighter that
|
||||
* still needs a decision, or transitions to BATTLE_STATE_AI_SELECTION once
|
||||
* none remain.
|
||||
*/
|
||||
void battleAdvanceSelection(void);
|
||||
|
||||
/**
|
||||
* Handles BATTLE_STATE_PRE_ROUND: rebuilds the execution order and moves on
|
||||
* to BATTLE_STATE_PLAYER_SELECTION, positioning the selection cursor.
|
||||
*/
|
||||
void battleUpdatePreRound(void);
|
||||
|
||||
/**
|
||||
* Handles BATTLE_STATE_AI_SELECTION: queues an attack for every undecided
|
||||
* AI-controlled fighter, then moves on to BATTLE_STATE_MOVES_EXECUTING.
|
||||
*/
|
||||
void battleUpdateAiSelection(void);
|
||||
|
||||
/**
|
||||
* Handles BATTLE_STATE_MOVES_EXECUTING: resolves one queued action from
|
||||
* BATTLE.executionOrder per call, or transitions to BATTLE_STATE_POST_ROUND
|
||||
* once every fighter this round has been processed.
|
||||
*/
|
||||
void battleUpdateMovesExecuting(void);
|
||||
|
||||
/**
|
||||
* Handles BATTLE_STATE_POST_ROUND: advances BATTLE.round and transitions
|
||||
* back to BATTLE_STATE_PRE_ROUND.
|
||||
*/
|
||||
void battleUpdatePostRound(void);
|
||||
|
||||
@@ -162,6 +162,28 @@ typedef struct cutscene_s {
|
||||
.shake = { .amount = AMOUNT, .duration = DURATION } \
|
||||
}
|
||||
|
||||
// Waits until BATTLE.state reaches STATE. Put this BEFORE
|
||||
// CUTSCENE_SET_PAUSE(CUTSCENE_PAUSE_BATTLE), not after -- pausing first
|
||||
// freezes BATTLE.state wherever it already is, so it would never reach
|
||||
// STATE on its own to satisfy the wait. Waiting unpaused, then pausing the
|
||||
// moment it's satisfied, catches the battle right at STATE before it can
|
||||
// advance further.
|
||||
#define CUTSCENE_BATTLE_WAIT_STATE(STATE) \
|
||||
{ \
|
||||
.type = CUTSCENE_ITEM_TYPE_BATTLE_WAIT_STATE, \
|
||||
.battleWaitState = { .state = STATE } \
|
||||
}
|
||||
|
||||
// Immediately queues an attack for FIGHTER_INDEX against TARGET_INDEX,
|
||||
// bypassing normal player/AI selection for that fighter this round.
|
||||
#define CUTSCENE_BATTLE_FORCE_ACTION(FIGHTER_INDEX, TARGET_INDEX) \
|
||||
{ \
|
||||
.type = CUTSCENE_ITEM_TYPE_BATTLE_FORCE_ACTION, \
|
||||
.battleForceAction = { \
|
||||
.fighterIndex = FIGHTER_INDEX, .targetIndex = TARGET_INDEX \
|
||||
} \
|
||||
}
|
||||
|
||||
#define CUTSCENE_SET_PAUSE(FLAGS) \
|
||||
{ .type = CUTSCENE_ITEM_TYPE_SET_PAUSE, .setPause = (FLAGS) }
|
||||
|
||||
|
||||
@@ -14,11 +14,13 @@ typedef uint8_t cutscenepause_t;
|
||||
#define CUTSCENE_PAUSE_NPC ((cutscenepause_t)(1 << 0))
|
||||
#define CUTSCENE_PAUSE_PLAYER ((cutscenepause_t)(1 << 1))
|
||||
#define CUTSCENE_PAUSE_WORLD ((cutscenepause_t)(1 << 2))
|
||||
#define CUTSCENE_PAUSE_BATTLE ((cutscenepause_t)(1 << 3))
|
||||
|
||||
#define CUTSCENE_PAUSE_DEFAULT ((cutscenepause_t)( \
|
||||
CUTSCENE_PAUSE_NPC | CUTSCENE_PAUSE_PLAYER \
|
||||
))
|
||||
|
||||
#define CUTSCENE_PAUSE_ALL ((cutscenepause_t)( \
|
||||
CUTSCENE_PAUSE_NPC | CUTSCENE_PAUSE_PLAYER | CUTSCENE_PAUSE_WORLD \
|
||||
CUTSCENE_PAUSE_NPC | CUTSCENE_PAUSE_PLAYER | CUTSCENE_PAUSE_WORLD | \
|
||||
CUTSCENE_PAUSE_BATTLE \
|
||||
))
|
||||
|
||||
@@ -6,4 +6,6 @@
|
||||
target_sources(${DUSK_LIBRARY_TARGET_NAME}
|
||||
PUBLIC
|
||||
cutscenestartbattle.c
|
||||
cutscenebattlewaitstate.c
|
||||
cutscenebattleforceaction.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"
|
||||
|
||||
void cutsceneBattleForceActionStart(
|
||||
const cutsceneitem_t *item,
|
||||
cutsceneitemdata_t *data
|
||||
) {
|
||||
battleQueueAction(
|
||||
item->battleForceAction.fighterIndex, BATTLE_ACTION_ATTACK,
|
||||
item->battleForceAction.targetIndex
|
||||
);
|
||||
}
|
||||
|
||||
bool_t cutsceneBattleForceActionUpdate(
|
||||
const cutsceneitem_t *item,
|
||||
cutsceneitemdata_t *data
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Copyright (c) 2026 Dominic Masters
|
||||
*
|
||||
* This software is released under the MIT License.
|
||||
* https://opensource.org/licenses/MIT
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
#include "rpg/battle/battle.h"
|
||||
|
||||
typedef struct {
|
||||
uint8_t fighterIndex;
|
||||
uint8_t targetIndex;
|
||||
} cutscenebattleforceaction_t;
|
||||
|
||||
typedef struct cutsceneitem_s cutsceneitem_t;
|
||||
typedef union cutsceneitemdata_u cutsceneitemdata_t;
|
||||
|
||||
/**
|
||||
* Starts a battle force-action step: immediately queues an attack for the
|
||||
* given fighter against the given target, bypassing normal player/AI
|
||||
* selection for that fighter this round.
|
||||
*
|
||||
* @param item The cutscene item.
|
||||
* @param data Runtime data storage.
|
||||
*/
|
||||
void cutsceneBattleForceActionStart(
|
||||
const cutsceneitem_t *item,
|
||||
cutsceneitemdata_t *data
|
||||
);
|
||||
|
||||
/**
|
||||
* Updates a battle force-action step (always completes immediately).
|
||||
*
|
||||
* @param item The cutscene item.
|
||||
* @param data Runtime data storage.
|
||||
* @returns true always.
|
||||
*/
|
||||
bool_t cutsceneBattleForceActionUpdate(
|
||||
const cutsceneitem_t *item,
|
||||
cutsceneitemdata_t *data
|
||||
);
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Copyright (c) 2026 Dominic Masters
|
||||
*
|
||||
* This software is released under the MIT License.
|
||||
* https://opensource.org/licenses/MIT
|
||||
*/
|
||||
|
||||
#include "rpg/cutscene/item/cutsceneitem.h"
|
||||
|
||||
bool_t cutsceneBattleWaitStateUpdate(
|
||||
const cutsceneitem_t *item,
|
||||
cutsceneitemdata_t *data
|
||||
) {
|
||||
return BATTLE.state == item->battleWaitState.state;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Copyright (c) 2026 Dominic Masters
|
||||
*
|
||||
* This software is released under the MIT License.
|
||||
* https://opensource.org/licenses/MIT
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
#include "rpg/battle/battle.h"
|
||||
|
||||
typedef struct {
|
||||
battlestate_t state;
|
||||
} cutscenebattlewaitstate_t;
|
||||
|
||||
typedef struct cutsceneitem_s cutsceneitem_t;
|
||||
typedef union cutsceneitemdata_u cutsceneitemdata_t;
|
||||
|
||||
/**
|
||||
* Updates a battle wait-state step, completing once BATTLE.state reaches
|
||||
* the watched state. Has no Start callback -- there's nothing to do until
|
||||
* the state is actually reached.
|
||||
*
|
||||
* @param item The cutscene item.
|
||||
* @param data Runtime data storage.
|
||||
* @returns true once BATTLE.state equals the watched state.
|
||||
*/
|
||||
bool_t cutsceneBattleWaitStateUpdate(
|
||||
const cutsceneitem_t *item,
|
||||
cutsceneitemdata_t *data
|
||||
);
|
||||
@@ -118,6 +118,15 @@ cutsceneitemcallbacks_t CUTSCENE_ITEM_CALLBACKS[CUTSCENE_ITEM_TYPE_COUNT] = {
|
||||
[CUTSCENE_ITEM_TYPE_SHAKE] = {
|
||||
.init = cutsceneShakeStart,
|
||||
.update = cutsceneShakeUpdate
|
||||
},
|
||||
|
||||
[CUTSCENE_ITEM_TYPE_BATTLE_WAIT_STATE] = {
|
||||
.update = cutsceneBattleWaitStateUpdate
|
||||
},
|
||||
|
||||
[CUTSCENE_ITEM_TYPE_BATTLE_FORCE_ACTION] = {
|
||||
.init = cutsceneBattleForceActionStart,
|
||||
.update = cutsceneBattleForceActionUpdate
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -27,6 +27,8 @@
|
||||
#include "maparea/cutscenemaparearemove.h"
|
||||
#include "maparea/cutscenemapareawait.h"
|
||||
#include "battle/cutscenestartbattle.h"
|
||||
#include "battle/cutscenebattlewaitstate.h"
|
||||
#include "battle/cutscenebattleforceaction.h"
|
||||
|
||||
typedef struct cutscene_s cutscene_t;
|
||||
|
||||
@@ -55,6 +57,8 @@ typedef enum {
|
||||
CUTSCENE_ITEM_TYPE_START_BATTLE,
|
||||
CUTSCENE_ITEM_TYPE_EMOJI,
|
||||
CUTSCENE_ITEM_TYPE_SHAKE,
|
||||
CUTSCENE_ITEM_TYPE_BATTLE_WAIT_STATE,
|
||||
CUTSCENE_ITEM_TYPE_BATTLE_FORCE_ACTION,
|
||||
|
||||
CUTSCENE_ITEM_TYPE_COUNT
|
||||
} cutsceneitemtype_t;
|
||||
@@ -85,6 +89,8 @@ struct cutsceneitem_s {
|
||||
cutscenestartbattle_t startBattle;
|
||||
cutsceneemoji_t emoji;
|
||||
cutsceneshake_t shake;
|
||||
cutscenebattlewaitstate_t battleWaitState;
|
||||
cutscenebattleforceaction_t battleForceAction;
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@ add_subdirectory(asset)
|
||||
add_subdirectory(error)
|
||||
add_subdirectory(thread)
|
||||
add_subdirectory(display)
|
||||
# add_subdirectory(rpg)
|
||||
add_subdirectory(rpg)
|
||||
# add_subdirectory(item)
|
||||
add_subdirectory(time)
|
||||
add_subdirectory(util)
|
||||
@@ -9,4 +9,5 @@ include(dusktest)
|
||||
dusktest(test_rpg.c)
|
||||
|
||||
# Subdirs
|
||||
add_subdirectory(overworld)
|
||||
add_subdirectory(overworld)
|
||||
add_subdirectory(battle)
|
||||
@@ -0,0 +1,9 @@
|
||||
# Copyright (c) 2026 Dominic Masters
|
||||
#
|
||||
# This software is released under the MIT License.
|
||||
# https://opensource.org/licenses/MIT
|
||||
|
||||
include(dusktest)
|
||||
|
||||
# Tests
|
||||
dusktest(test_battle.c)
|
||||
@@ -0,0 +1,443 @@
|
||||
/**
|
||||
* Copyright (c) 2026 Dominic Masters
|
||||
*
|
||||
* This software is released under the MIT License.
|
||||
* https://opensource.org/licenses/MIT
|
||||
*/
|
||||
|
||||
#include "dusktest.h"
|
||||
#include "rpg/battle/battle.h"
|
||||
#include "rpg/cutscene/cutscenesystem.h"
|
||||
|
||||
// Fighter slots as added by test_battleCutsceneForceActionOverridesTarget:
|
||||
// 0 = allyA, 1 = allyB, 2 = enemy.
|
||||
static const cutsceneitem_t CUTSCENE_TEST_SCRATCH_ITEMS[] = {
|
||||
// Waiting BEFORE pausing is the correct order: pausing first would
|
||||
// freeze BATTLE.state wherever it happened to be and it would never
|
||||
// reach PRE_ROUND on its own to satisfy the wait.
|
||||
CUTSCENE_BATTLE_WAIT_STATE(BATTLE_STATE_PRE_ROUND),
|
||||
CUTSCENE_SET_PAUSE(CUTSCENE_PAUSE_BATTLE),
|
||||
CUTSCENE_BATTLE_FORCE_ACTION(2, 0),// enemy (slot 2) forced onto allyA (slot 0)
|
||||
CUTSCENE_SET_PAUSE(CUTSCENE_PAUSE_NONE)
|
||||
};
|
||||
static const cutscene_t CUTSCENE_TEST_SCRATCH = {
|
||||
.items = CUTSCENE_TEST_SCRATCH_ITEMS,
|
||||
.itemCount = sizeof(CUTSCENE_TEST_SCRATCH_ITEMS) / sizeof(cutsceneitem_t),
|
||||
.pause = CUTSCENE_PAUSE_NONE,
|
||||
.dataSize = 0
|
||||
};
|
||||
|
||||
static battlefighter_t *addFighter(
|
||||
const battlefighterteam_t team,
|
||||
const battlefightercontroller_t controller,
|
||||
const uint16_t attack,
|
||||
const uint16_t defense,
|
||||
const uint16_t speed,
|
||||
const uint16_t healthMax
|
||||
) {
|
||||
const battlefighterstats_t stats = {
|
||||
.attack = attack, .defense = defense, .speed = speed
|
||||
};
|
||||
return battleAddFighter(team, controller, stats, healthMax, 0);
|
||||
}
|
||||
|
||||
static void test_battleStartEntersOpeningThenPreRound(void **state) {
|
||||
|
||||
|
||||
battleInit();
|
||||
addFighter(BATTLE_FIGHTER_TEAM_ALLY, BATTLE_FIGHTER_CONTROLLER_PLAYER, 10, 0, 10, 20);
|
||||
addFighter(BATTLE_FIGHTER_TEAM_ENEMY, BATTLE_FIGHTER_CONTROLLER_AI, 10, 0, 5, 20);
|
||||
|
||||
battleStart(BATTLE_ENCOUNTER_REGULAR, true);
|
||||
assert_int_equal(BATTLE.state, BATTLE_STATE_OPENING);
|
||||
|
||||
// OPENING only transitions to PRE_ROUND; it doesn't run PRE_ROUND's logic
|
||||
// in the same call.
|
||||
battleUpdate();
|
||||
assert_int_equal(BATTLE.state, BATTLE_STATE_PRE_ROUND);
|
||||
assert_int_equal(BATTLE.executionCount, 0);
|
||||
|
||||
// PRE_ROUND builds the execution order and moves on to selection.
|
||||
battleUpdate();
|
||||
assert_int_equal(BATTLE.executionCount, 2);
|
||||
assert_true(
|
||||
BATTLE.state == BATTLE_STATE_PLAYER_SELECTION ||
|
||||
BATTLE.state == BATTLE_STATE_AI_SELECTION
|
||||
);
|
||||
}
|
||||
|
||||
static void test_battlePlayerSelectionAdvancesAndSkipsDecidedFighters(
|
||||
void **state
|
||||
) {
|
||||
|
||||
|
||||
battleInit();
|
||||
battlefighter_t *ally1 = addFighter(
|
||||
BATTLE_FIGHTER_TEAM_ALLY, BATTLE_FIGHTER_CONTROLLER_PLAYER, 5, 0, 100, 20
|
||||
);
|
||||
battlefighter_t *ally2 = addFighter(
|
||||
BATTLE_FIGHTER_TEAM_ALLY, BATTLE_FIGHTER_CONTROLLER_PLAYER, 5, 0, 10, 20
|
||||
);
|
||||
battlefighter_t *enemy = addFighter(
|
||||
BATTLE_FIGHTER_TEAM_ENEMY, BATTLE_FIGHTER_CONTROLLER_AI, 5, 0, 50, 20
|
||||
);
|
||||
|
||||
battleStart(BATTLE_ENCOUNTER_REGULAR, true);
|
||||
battleUpdate();// OPENING -> PRE_ROUND
|
||||
battleUpdate();// PRE_ROUND -> PLAYER_SELECTION, positions the cursor
|
||||
|
||||
// ally1 is fastest, so it's first to decide.
|
||||
assert_int_equal(BATTLE.state, BATTLE_STATE_PLAYER_SELECTION);
|
||||
assert_ptr_equal(battleGetCurrentFighter(), ally1);
|
||||
|
||||
// A forced action (as a scripted-battle cutscene item would push) marks
|
||||
// ally2 as already decided, so PLAYER_SELECTION must skip it.
|
||||
battleQueueAction(ally2->id, BATTLE_ACTION_ATTACK, enemy->id);
|
||||
|
||||
battlePlayerAttack(enemy->id);
|
||||
assert_int_equal(BATTLE.actions[ally1->id].type, BATTLE_ACTION_ATTACK);
|
||||
assert_int_equal(BATTLE.actions[ally1->id].targetIndex, enemy->id);
|
||||
|
||||
// ally2 already had a decision queued, and enemy is AI-controlled, so
|
||||
// there's nothing left for PLAYER_SELECTION -- it should have moved on.
|
||||
assert_int_equal(BATTLE.state, BATTLE_STATE_AI_SELECTION);
|
||||
}
|
||||
|
||||
static void test_battleAiSelectionAutoQueuesUndecidedAiFighters(void **state) {
|
||||
|
||||
|
||||
battleInit();
|
||||
battlefighter_t *ally = addFighter(
|
||||
BATTLE_FIGHTER_TEAM_ALLY, BATTLE_FIGHTER_CONTROLLER_PLAYER, 5, 0, 10, 20
|
||||
);
|
||||
battlefighter_t *enemy = addFighter(
|
||||
BATTLE_FIGHTER_TEAM_ENEMY, BATTLE_FIGHTER_CONTROLLER_AI, 5, 0, 5, 20
|
||||
);
|
||||
|
||||
battleStart(BATTLE_ENCOUNTER_REGULAR, true);
|
||||
battleUpdate();// OPENING -> PRE_ROUND
|
||||
battleUpdate();// PRE_ROUND -> PLAYER_SELECTION
|
||||
|
||||
battlePlayerAttack(enemy->id);// ally decides, selection moves to AI
|
||||
assert_int_equal(BATTLE.state, BATTLE_STATE_AI_SELECTION);
|
||||
|
||||
battleUpdate();// AI_SELECTION auto-decides for enemy
|
||||
assert_int_equal(BATTLE.state, BATTLE_STATE_MOVES_EXECUTING);
|
||||
assert_int_equal(BATTLE.actions[enemy->id].type, BATTLE_ACTION_ATTACK);
|
||||
assert_int_equal(BATTLE.actions[enemy->id].targetIndex, ally->id);
|
||||
}
|
||||
|
||||
static void test_battleMovesExecutingResolvesInSpeedOrderAndFizzles(
|
||||
void **state
|
||||
) {
|
||||
|
||||
|
||||
battleInit();
|
||||
// ally1 is fast enough to kill enemy1 before enemy1 or ally2 act. enemy2
|
||||
// keeps the battle alive so execution continues to ally2's now-moot
|
||||
// attack against the already-dead enemy1.
|
||||
battlefighter_t *ally1 = addFighter(
|
||||
BATTLE_FIGHTER_TEAM_ALLY, BATTLE_FIGHTER_CONTROLLER_PLAYER, 50, 0, 100, 20
|
||||
);
|
||||
battlefighter_t *ally2 = addFighter(
|
||||
BATTLE_FIGHTER_TEAM_ALLY, BATTLE_FIGHTER_CONTROLLER_PLAYER, 50, 0, 10, 20
|
||||
);
|
||||
battlefighter_t *enemy1 = addFighter(
|
||||
BATTLE_FIGHTER_TEAM_ENEMY, BATTLE_FIGHTER_CONTROLLER_AI, 1, 0, 50, 10
|
||||
);
|
||||
battlefighter_t *enemy2 = addFighter(
|
||||
BATTLE_FIGHTER_TEAM_ENEMY, BATTLE_FIGHTER_CONTROLLER_AI, 1, 0, 1, 100
|
||||
);
|
||||
|
||||
battleStart(BATTLE_ENCOUNTER_REGULAR, true);
|
||||
battleUpdate();// OPENING -> PRE_ROUND
|
||||
battleUpdate();// PRE_ROUND -> PLAYER_SELECTION (ally1 first, fastest)
|
||||
|
||||
assert_ptr_equal(battleGetCurrentFighter(), ally1);
|
||||
battlePlayerAttack(enemy1->id);
|
||||
assert_ptr_equal(battleGetCurrentFighter(), ally2);
|
||||
battlePlayerAttack(enemy1->id);// both allies target enemy1
|
||||
assert_int_equal(BATTLE.state, BATTLE_STATE_AI_SELECTION);
|
||||
|
||||
battleUpdate();// AI_SELECTION
|
||||
assert_int_equal(BATTLE.state, BATTLE_STATE_MOVES_EXECUTING);
|
||||
|
||||
// Execution order: ally1 (100), enemy1 (50), ally2 (10), enemy2 (1).
|
||||
battleUpdate();// ally1 kills enemy1
|
||||
assert_false(battleFighterIsAlive(enemy1));
|
||||
assert_int_equal(BATTLE.state, BATTLE_STATE_MOVES_EXECUTING);// enemy2 alive
|
||||
|
||||
battleUpdate();// enemy1's own (now dead) turn is skipped
|
||||
battleUpdate();// ally2's attack against the dead enemy1 fizzles silently
|
||||
assert_int_equal(enemy1->health, 0);// no double-kill, no underflow
|
||||
|
||||
battleUpdate();// enemy2 acts
|
||||
battleUpdate();// all 4 slots processed -> POST_ROUND
|
||||
assert_int_equal(BATTLE.state, BATTLE_STATE_POST_ROUND);
|
||||
|
||||
battleUpdate();// POST_ROUND -> PRE_ROUND, round advances
|
||||
assert_int_equal(BATTLE.round, 2);
|
||||
assert_int_equal(BATTLE.state, BATTLE_STATE_PRE_ROUND);
|
||||
}
|
||||
|
||||
static void test_battleWinEndsInStateEnded(void **state) {
|
||||
|
||||
|
||||
battleInit();
|
||||
battlefighter_t *ally = addFighter(
|
||||
BATTLE_FIGHTER_TEAM_ALLY, BATTLE_FIGHTER_CONTROLLER_PLAYER, 50, 0, 10, 20
|
||||
);
|
||||
battlefighter_t *enemy = addFighter(
|
||||
BATTLE_FIGHTER_TEAM_ENEMY, BATTLE_FIGHTER_CONTROLLER_AI, 1, 0, 5, 10
|
||||
);
|
||||
|
||||
battleStart(BATTLE_ENCOUNTER_REGULAR, true);
|
||||
battleUpdate();// OPENING -> PRE_ROUND
|
||||
battleUpdate();// PRE_ROUND -> PLAYER_SELECTION
|
||||
|
||||
battlePlayerAttack(enemy->id);// selection -> AI_SELECTION
|
||||
battleUpdate();// AI_SELECTION -> MOVES_EXECUTING
|
||||
battleUpdate();// ally kills the only enemy
|
||||
|
||||
assert_int_equal(BATTLE.state, BATTLE_STATE_ENDED);
|
||||
assert_int_equal(BATTLE.result, BATTLE_RESULT_WIN);
|
||||
}
|
||||
|
||||
static void test_battleLossEndsInStateEnded(void **state) {
|
||||
|
||||
|
||||
battleInit();
|
||||
battlefighter_t *ally = addFighter(
|
||||
BATTLE_FIGHTER_TEAM_ALLY, BATTLE_FIGHTER_CONTROLLER_PLAYER, 1, 0, 5, 10
|
||||
);
|
||||
battlefighter_t *enemy = addFighter(
|
||||
BATTLE_FIGHTER_TEAM_ENEMY, BATTLE_FIGHTER_CONTROLLER_AI, 50, 0, 10, 20
|
||||
);
|
||||
|
||||
battleStart(BATTLE_ENCOUNTER_REGULAR, true);
|
||||
battleUpdate();// OPENING -> PRE_ROUND
|
||||
battleUpdate();// PRE_ROUND -> PLAYER_SELECTION
|
||||
|
||||
battlePlayerAttack(enemy->id);// selection -> AI_SELECTION
|
||||
battleUpdate();// AI_SELECTION -> MOVES_EXECUTING
|
||||
battleUpdate();// ally attacks first (irrelevant to outcome)
|
||||
battleUpdate();// enemy kills the only ally
|
||||
|
||||
assert_int_equal(BATTLE.state, BATTLE_STATE_ENDED);
|
||||
assert_int_equal(BATTLE.result, BATTLE_RESULT_LOSS);
|
||||
}
|
||||
|
||||
static void test_battleFleeEndsInStateEndedImmediately(void **state) {
|
||||
|
||||
|
||||
battleInit();
|
||||
addFighter(BATTLE_FIGHTER_TEAM_ALLY, BATTLE_FIGHTER_CONTROLLER_PLAYER, 5, 0, 10, 20);
|
||||
addFighter(BATTLE_FIGHTER_TEAM_ENEMY, BATTLE_FIGHTER_CONTROLLER_AI, 5, 0, 5, 20);
|
||||
|
||||
battleStart(BATTLE_ENCOUNTER_REGULAR, true);
|
||||
battleUpdate();// OPENING -> PRE_ROUND
|
||||
battleUpdate();// PRE_ROUND -> PLAYER_SELECTION
|
||||
|
||||
battlePlayerFlee();
|
||||
assert_int_equal(BATTLE.state, BATTLE_STATE_ENDED);
|
||||
assert_int_equal(BATTLE.result, BATTLE_RESULT_FLED);
|
||||
}
|
||||
|
||||
static void test_battleFleeUnavailableIsIgnored(void **state) {
|
||||
|
||||
|
||||
battleInit();
|
||||
addFighter(BATTLE_FIGHTER_TEAM_ALLY, BATTLE_FIGHTER_CONTROLLER_PLAYER, 5, 0, 10, 20);
|
||||
addFighter(BATTLE_FIGHTER_TEAM_ENEMY, BATTLE_FIGHTER_CONTROLLER_AI, 5, 0, 5, 20);
|
||||
|
||||
battleStart(BATTLE_ENCOUNTER_REGULAR, false);
|
||||
battleUpdate();// OPENING -> PRE_ROUND
|
||||
battleUpdate();// PRE_ROUND -> PLAYER_SELECTION
|
||||
|
||||
battlePlayerFlee();
|
||||
assert_int_equal(BATTLE.state, BATTLE_STATE_PLAYER_SELECTION);
|
||||
assert_int_equal(BATTLE.result, BATTLE_RESULT_NONE);
|
||||
}
|
||||
|
||||
static uint8_t stateChangedCount;
|
||||
static battlestate_t stateChangedLog[32][2];
|
||||
|
||||
static void recordStateChanged(
|
||||
const battlestate_t previous,
|
||||
const battlestate_t next
|
||||
) {
|
||||
if(stateChangedCount >= 32) return;
|
||||
stateChangedLog[stateChangedCount][0] = previous;
|
||||
stateChangedLog[stateChangedCount][1] = next;
|
||||
stateChangedCount++;
|
||||
}
|
||||
|
||||
static void test_battleOnStateChangedFiresAcrossFullRound(void **state) {
|
||||
|
||||
|
||||
battleInit();
|
||||
stateChangedCount = 0;
|
||||
BATTLE.onStateChanged = recordStateChanged;
|
||||
|
||||
addFighter(BATTLE_FIGHTER_TEAM_ALLY, BATTLE_FIGHTER_CONTROLLER_PLAYER, 1, 0, 10, 100);
|
||||
battlefighter_t *enemy = addFighter(
|
||||
BATTLE_FIGHTER_TEAM_ENEMY, BATTLE_FIGHTER_CONTROLLER_AI, 1, 0, 5, 100
|
||||
);
|
||||
|
||||
battleStart(BATTLE_ENCOUNTER_REGULAR, true);// fires NONE -> OPENING
|
||||
battleUpdate();// OPENING -> PRE_ROUND
|
||||
battleUpdate();// PRE_ROUND -> PLAYER_SELECTION
|
||||
battlePlayerAttack(enemy->id);// PLAYER_SELECTION -> AI_SELECTION
|
||||
battleUpdate();// AI_SELECTION -> MOVES_EXECUTING
|
||||
battleUpdate();// MOVES_EXECUTING: ally's attack resolves (still executing)
|
||||
battleUpdate();// MOVES_EXECUTING: enemy's attack resolves (still executing)
|
||||
battleUpdate();// both slots processed -> POST_ROUND
|
||||
battleUpdate();// POST_ROUND -> PRE_ROUND, round advances
|
||||
|
||||
assert_int_equal(stateChangedLog[0][0], BATTLE_STATE_NONE);
|
||||
assert_int_equal(stateChangedLog[0][1], BATTLE_STATE_OPENING);
|
||||
assert_int_equal(stateChangedLog[1][1], BATTLE_STATE_PRE_ROUND);
|
||||
assert_int_equal(stateChangedLog[2][1], BATTLE_STATE_PLAYER_SELECTION);
|
||||
assert_int_equal(stateChangedLog[3][1], BATTLE_STATE_AI_SELECTION);
|
||||
assert_int_equal(stateChangedLog[4][1], BATTLE_STATE_MOVES_EXECUTING);
|
||||
assert_int_equal(stateChangedLog[5][1], BATTLE_STATE_POST_ROUND);
|
||||
assert_int_equal(stateChangedLog[6][1], BATTLE_STATE_PRE_ROUND);
|
||||
|
||||
BATTLE.onStateChanged = NULL;
|
||||
}
|
||||
|
||||
static uint8_t actionDecidedCount;
|
||||
|
||||
static void recordActionDecided(
|
||||
const battlefighter_t *fighter,
|
||||
const battleaction_t *action
|
||||
) {
|
||||
actionDecidedCount++;
|
||||
}
|
||||
|
||||
static void test_battleOnActionDecidedFiresPerFighterRegardlessOfSource(
|
||||
void **state
|
||||
) {
|
||||
|
||||
|
||||
battleInit();
|
||||
actionDecidedCount = 0;
|
||||
BATTLE.onActionDecided = recordActionDecided;
|
||||
|
||||
battlefighter_t *ally1 = addFighter(
|
||||
BATTLE_FIGHTER_TEAM_ALLY, BATTLE_FIGHTER_CONTROLLER_PLAYER, 1, 0, 10, 100
|
||||
);
|
||||
battlefighter_t *ally2 = addFighter(
|
||||
BATTLE_FIGHTER_TEAM_ALLY, BATTLE_FIGHTER_CONTROLLER_PLAYER, 1, 0, 9, 100
|
||||
);
|
||||
battlefighter_t *enemy = addFighter(
|
||||
BATTLE_FIGHTER_TEAM_ENEMY, BATTLE_FIGHTER_CONTROLLER_AI, 1, 0, 5, 100
|
||||
);
|
||||
|
||||
battleStart(BATTLE_ENCOUNTER_REGULAR, true);
|
||||
battleUpdate();// OPENING -> PRE_ROUND
|
||||
battleUpdate();// PRE_ROUND -> PLAYER_SELECTION
|
||||
|
||||
// A forced action, as CUTSCENE_BATTLE_FORCE_ACTION would push, counts too.
|
||||
battleQueueAction(ally2->id, BATTLE_ACTION_ATTACK, enemy->id);
|
||||
assert_int_equal(actionDecidedCount, 1);
|
||||
|
||||
battlePlayerAttack(enemy->id);// ally1's real decision
|
||||
assert_int_equal(actionDecidedCount, 2);
|
||||
assert_int_equal(BATTLE.state, BATTLE_STATE_AI_SELECTION);
|
||||
|
||||
battleUpdate();// AI_SELECTION decides for enemy
|
||||
assert_int_equal(actionDecidedCount, 3);
|
||||
|
||||
BATTLE.onActionDecided = NULL;
|
||||
}
|
||||
|
||||
// Exercises CUTSCENE_BATTLE_WAIT_STATE + CUTSCENE_SET_PAUSE(BATTLE) +
|
||||
// CUTSCENE_BATTLE_FORCE_ACTION together, driving the cutscene and battle
|
||||
// systems side by side the way rpg.c's main loop does, to prove a
|
||||
// partially-scripted round actually works end-to-end (not just that each
|
||||
// item type compiles).
|
||||
static void test_battleCutsceneForceActionOverridesTarget(void **state) {
|
||||
|
||||
|
||||
battleInit();
|
||||
cutsceneSystemInit();
|
||||
|
||||
battlefighter_t *allyA = addFighter(
|
||||
BATTLE_FIGHTER_TEAM_ALLY, BATTLE_FIGHTER_CONTROLLER_PLAYER, 5, 0, 10, 20
|
||||
);
|
||||
battlefighter_t *allyB = addFighter(
|
||||
BATTLE_FIGHTER_TEAM_ALLY, BATTLE_FIGHTER_CONTROLLER_PLAYER, 5, 0, 8, 20
|
||||
);
|
||||
battlefighter_t *enemy = addFighter(
|
||||
BATTLE_FIGHTER_TEAM_ENEMY, BATTLE_FIGHTER_CONTROLLER_AI, 3, 0, 5, 100
|
||||
);
|
||||
assert_int_equal(allyA->id, 0);
|
||||
assert_int_equal(allyB->id, 1);
|
||||
assert_int_equal(enemy->id, 2);
|
||||
|
||||
battleStart(BATTLE_ENCOUNTER_REGULAR, true);
|
||||
cutsceneSystemStartCutscene(&CUTSCENE_TEST_SCRATCH);
|
||||
|
||||
// Drive both systems together until BATTLE.state first reaches PRE_ROUND:
|
||||
// cutscene sees it via CUTSCENE_BATTLE_WAIT_STATE and immediately pauses
|
||||
// the battle before it can cascade on to PLAYER_SELECTION.
|
||||
cutsceneSystemUpdate();// WAIT_STATE: still OPENING, not satisfied yet
|
||||
battleUpdate();// OPENING -> PRE_ROUND
|
||||
cutsceneSystemUpdate();// WAIT_STATE satisfied -> SET_PAUSE(BATTLE) applied
|
||||
battleUpdate();// paused: no-op
|
||||
|
||||
assert_int_equal(BATTLE.state, BATTLE_STATE_PRE_ROUND);
|
||||
assert_true(CUTSCENE_SYSTEM.pause & CUTSCENE_PAUSE_BATTLE);
|
||||
|
||||
cutsceneSystemUpdate();// SET_PAUSE done -> FORCE_ACTION queues enemy's move
|
||||
battleUpdate();// still paused: no-op
|
||||
|
||||
assert_int_equal(BATTLE.actions[enemy->id].type, BATTLE_ACTION_ATTACK);
|
||||
assert_int_equal(BATTLE.actions[enemy->id].targetIndex, allyA->id);
|
||||
assert_int_equal(BATTLE.state, BATTLE_STATE_PRE_ROUND);// still frozen
|
||||
|
||||
cutsceneSystemUpdate();// FORCE_ACTION done -> SET_PAUSE(NONE) lifts it
|
||||
battleUpdate();// unpaused: PRE_ROUND -> PLAYER_SELECTION, allyA first
|
||||
|
||||
assert_false(CUTSCENE_SYSTEM.pause & CUTSCENE_PAUSE_BATTLE);
|
||||
assert_int_equal(BATTLE.state, BATTLE_STATE_PLAYER_SELECTION);
|
||||
assert_ptr_equal(battleGetCurrentFighter(), allyA);
|
||||
// The forced action from three frames ago survived untouched.
|
||||
assert_int_equal(BATTLE.actions[enemy->id].type, BATTLE_ACTION_ATTACK);
|
||||
assert_int_equal(BATTLE.actions[enemy->id].targetIndex, allyA->id);
|
||||
|
||||
battlePlayerAttack(enemy->id);// allyA -> AI_SELECTION would be next...
|
||||
assert_ptr_equal(battleGetCurrentFighter(), allyB);
|
||||
battlePlayerAttack(enemy->id);// ...but AI_SELECTION must skip the enemy,
|
||||
assert_int_equal(BATTLE.state, BATTLE_STATE_AI_SELECTION);// since it's decided
|
||||
|
||||
battleUpdate();// AI_SELECTION: enemy already decided, skipped -> MOVES_EXECUTING
|
||||
assert_int_equal(BATTLE.actions[enemy->id].targetIndex, allyA->id);// untouched
|
||||
|
||||
battleUpdate();// allyA attacks enemy
|
||||
battleUpdate();// allyB attacks enemy
|
||||
battleUpdate();// enemy attacks its forced target: allyA, not allyB
|
||||
|
||||
assert_int_equal(enemy->health, 90);// 100 - 5 (allyA) - 5 (allyB)
|
||||
assert_int_equal(allyA->health, 17);// 20 - 3 (enemy's forced attack)
|
||||
assert_int_equal(allyB->health, 20);// never targeted -- proves the override
|
||||
}
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
const struct CMUnitTest tests[] = {
|
||||
cmocka_unit_test(test_battleStartEntersOpeningThenPreRound),
|
||||
cmocka_unit_test(test_battlePlayerSelectionAdvancesAndSkipsDecidedFighters),
|
||||
cmocka_unit_test(test_battleAiSelectionAutoQueuesUndecidedAiFighters),
|
||||
cmocka_unit_test(test_battleMovesExecutingResolvesInSpeedOrderAndFizzles),
|
||||
cmocka_unit_test(test_battleWinEndsInStateEnded),
|
||||
cmocka_unit_test(test_battleLossEndsInStateEnded),
|
||||
cmocka_unit_test(test_battleFleeEndsInStateEndedImmediately),
|
||||
cmocka_unit_test(test_battleFleeUnavailableIsIgnored),
|
||||
cmocka_unit_test(test_battleOnStateChangedFiresAcrossFullRound),
|
||||
cmocka_unit_test(test_battleOnActionDecidedFiresPerFighterRegardlessOfSource),
|
||||
cmocka_unit_test(test_battleCutsceneForceActionOverridesTarget),
|
||||
};
|
||||
|
||||
return cmocka_run_group_tests(tests, NULL, NULL);
|
||||
}
|
||||
Reference in New Issue
Block a user