Clamp keyframe interpolation to last value, add main menu scene/UI, battle HUD, and expanded test coverage
- keyframeGetValue now returns the last keyframe's value for times at or beyond it, fixes a missing util/math.h include, and asserts keyframes are sorted by time; adds test/animation/test_keyframe.c - Adds mainmenu scene/UI and a battle HUD UI frame - Adds save autosave-related fields and battle scene tweaks - Adds headless test coverage for cutscenes, entities, and map areas
This commit is contained in:
@@ -7,4 +7,5 @@ target_sources(${DUSK_LIBRARY_TARGET_NAME}
|
||||
PUBLIC
|
||||
easing.c
|
||||
animation.c
|
||||
keyframe.c
|
||||
)
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Copyright (c) 2026 Dominic Masters
|
||||
*
|
||||
* This software is released under the MIT License.
|
||||
* https://opensource.org/licenses/MIT
|
||||
*/
|
||||
|
||||
#include "keyframe.h"
|
||||
#include "assert/assert.h"
|
||||
#include "util/math.h"
|
||||
|
||||
float_t keyframeGetValue(
|
||||
const keyframe_t *keyframes,
|
||||
const uint32_t keyframeCount,
|
||||
const float_t time
|
||||
) {
|
||||
assertNotNull(keyframes, "Keyframes pointer cannot be null.");
|
||||
assertTrue(keyframeCount > 0, "Keyframe count must be more than 0.");
|
||||
assertTrue(time >= 0, "Time must be non-negative.");
|
||||
#ifdef DUSK_ASSERTIONS
|
||||
// Checks that the keyframes are sorted by time.
|
||||
for(uint32_t i = 1; i < keyframeCount; i++) {
|
||||
assertTrue(
|
||||
keyframes[i].time >= keyframes[i - 1].time,
|
||||
"Keyframes must be sorted by time."
|
||||
);
|
||||
}
|
||||
#endif
|
||||
|
||||
keyframe_t *start;
|
||||
keyframe_t *end;
|
||||
keyframe_t *last = (keyframe_t *)(keyframes + keyframeCount - 1);
|
||||
if(time >= last->time) return last->value;
|
||||
|
||||
keyframe_t *current = (keyframe_t *)keyframes;
|
||||
start = current;
|
||||
|
||||
do {
|
||||
if(current->time > time) {
|
||||
end = current;
|
||||
break;
|
||||
}
|
||||
start = current;
|
||||
current++;
|
||||
|
||||
if(current > last) {
|
||||
end = start;
|
||||
break;
|
||||
}
|
||||
} while(true);
|
||||
|
||||
float_t t = (time - start->time) / (end->time - start->time);
|
||||
return mathLerp(start->value, end->value, easingApply(start->easing, t));
|
||||
}
|
||||
@@ -10,4 +10,18 @@ typedef struct {
|
||||
float_t time;
|
||||
float_t value;
|
||||
easingtype_t easing;
|
||||
} keyframe_t;
|
||||
} keyframe_t;
|
||||
|
||||
/**
|
||||
* Gets the value of a keyframe at a given time.
|
||||
*
|
||||
* @param keyframes The keyframes to get the value from.
|
||||
* @param keyframeCount The number of keyframes in the array.
|
||||
* @param time The time at which to get the value, in seconds.
|
||||
* @return The value of the keyframe at the given time.
|
||||
*/
|
||||
float_t keyframeGetValue(
|
||||
const keyframe_t *keyframes,
|
||||
const uint32_t keyframeCount,
|
||||
const float_t time
|
||||
);
|
||||
@@ -22,6 +22,8 @@
|
||||
#endif
|
||||
|
||||
#ifndef DUSK_ASSERTIONS_FAKED
|
||||
#define DUSK_ASSERTIONS 1
|
||||
|
||||
/**
|
||||
* Initializes the assert system. Must be the very first call in engine
|
||||
* startup.
|
||||
|
||||
@@ -13,6 +13,16 @@
|
||||
|
||||
save_t SAVE;
|
||||
|
||||
const saveslot_t SAVE_DEFAULT = {
|
||||
.header = {
|
||||
SAVE_SLOT_HEADER[0], SAVE_SLOT_HEADER[1], SAVE_SLOT_HEADER[2]
|
||||
},
|
||||
.version = SAVE_SLOT_VERSION,
|
||||
.exists = true,
|
||||
.playerName = "Player"
|
||||
// globalItemCollected/storyFlags are left at their zero default.
|
||||
};
|
||||
|
||||
static void _saveEagerLoadComplete(errorret_t result, void *user) {
|
||||
if(errorIsNotOk(result)) errorCatch(errorPrint(result));
|
||||
}
|
||||
@@ -105,6 +115,11 @@ void saveLoadSlot(const uint8_t slot, savecallback_t onComplete, void *user) {
|
||||
#endif
|
||||
}
|
||||
|
||||
void saveLoadDefault(const uint8_t slot) {
|
||||
assertTrue(slot < SAVE_SLOT_COUNT_MAX, "slot exceeds SAVE_SLOT_COUNT_MAX");
|
||||
SAVE.slots[slot] = SAVE_DEFAULT;
|
||||
}
|
||||
|
||||
void saveWriteSlot(const uint8_t slot, savecallback_t onComplete, void *user) {
|
||||
assertTrue(slot < SAVE_SLOT_COUNT_MAX, "slot exceeds SAVE_SLOT_COUNT_MAX");
|
||||
assertNotNull(onComplete, "onComplete cannot be NULL");
|
||||
|
||||
@@ -137,6 +137,15 @@ bool_t saveIsBusy(void);
|
||||
*/
|
||||
void saveLoadSlot(const uint8_t slot, savecallback_t onComplete, void *user);
|
||||
|
||||
/**
|
||||
* Resets a save slot in memory to SAVE_DEFAULT, for starting a new game
|
||||
* without reading anything from persistent storage. Synchronous - no
|
||||
* disk I/O is involved, so there's no callback to wait on.
|
||||
*
|
||||
* @param slot The save slot index (0 to SAVE_SLOT_COUNT_MAX - 1) to reset.
|
||||
*/
|
||||
void saveLoadDefault(const uint8_t slot);
|
||||
|
||||
/**
|
||||
* Writes the save slot for a given index to persistent storage. Slow/
|
||||
* async on some platforms (PSP's native save dialog spans multiple
|
||||
|
||||
@@ -87,3 +87,14 @@ typedef struct {
|
||||
* @param user User data passed through from the original call.
|
||||
*/
|
||||
typedef void (*savecallback_t)(errorret_t result, void *user);
|
||||
|
||||
/**
|
||||
* The blank template used to (re)initialize a save slot for a brand new
|
||||
* game via saveLoadDefault(), rather than reading one from persistent
|
||||
* storage. Deliberately generic at this layer (empty collected-item
|
||||
* flags, no story flags set) - anything that needs CSV-defined story flag
|
||||
* defaults layers that on top afterward (see storyFlagInitDefaults()),
|
||||
* since saveslot.h stays a leaf header with no dependency on generated
|
||||
* story content (see SAVE_STORY_FLAG_COUNT_MAX's doc comment above).
|
||||
*/
|
||||
extern const saveslot_t SAVE_DEFAULT;
|
||||
|
||||
@@ -11,5 +11,6 @@ target_sources(${DUSK_LIBRARY_TARGET_NAME}
|
||||
|
||||
# Subdirs
|
||||
add_subdirectory(initial)
|
||||
add_subdirectory(mainmenu)
|
||||
add_subdirectory(overworld)
|
||||
add_subdirectory(battle)
|
||||
@@ -6,7 +6,14 @@
|
||||
*/
|
||||
|
||||
#include "scenebattle.h"
|
||||
#include "rpg/battle/battle.h"
|
||||
#include "display/display.h"
|
||||
#include "display/displaystate.h"
|
||||
#include "display/shader/shader.h"
|
||||
#include "display/shader/shaderunlit.h"
|
||||
#include "display/spritebatch/spritebatch.h"
|
||||
#include "display/screen/screen.h"
|
||||
#include "display/color.h"
|
||||
#include "scene/scene.h"
|
||||
|
||||
errorret_t sceneBattleInit(scenedata_t *sceneData) {
|
||||
errorOk();
|
||||
@@ -18,9 +25,127 @@ errorret_t sceneBattleUpdate(scenedata_t *sceneData) {
|
||||
}
|
||||
|
||||
errorret_t sceneBattleRender(scenedata_t *sceneData) {
|
||||
scenebattle_t *battle = &sceneData->battle;
|
||||
|
||||
sceneBattleCameraUpdateProjection(battle);
|
||||
sceneBattleCameraUpdateEye(battle);
|
||||
|
||||
errorChain(shaderBind(&SHADER_UNLIT));
|
||||
errorChain(shaderSetMatrix(
|
||||
&SHADER_UNLIT, SHADER_UNLIT_MODEL, SCENE.screenIdentity
|
||||
));
|
||||
errorChain(shaderSetMatrix(
|
||||
&SHADER_UNLIT, SHADER_UNLIT_PROJECTION, battle->projection
|
||||
));
|
||||
errorChain(shaderSetMatrix(
|
||||
&SHADER_UNLIT, SHADER_UNLIT_VIEW, battle->eye
|
||||
));
|
||||
|
||||
errorChain(displaySetState((displaystate_t){
|
||||
.flags = DISPLAY_STATE_FLAG_DEPTH_TEST | DISPLAY_STATE_FLAG_CULL
|
||||
}));
|
||||
|
||||
for(uint8_t i = 0; i < BATTLE_FIGHTER_COUNT_MAX; i++) {
|
||||
errorChain(sceneBattleDrawFighter(i));
|
||||
}
|
||||
|
||||
errorOk();
|
||||
}
|
||||
|
||||
errorret_t sceneBattleDispose(scenedata_t *sceneData) {
|
||||
errorOk();
|
||||
}
|
||||
|
||||
void sceneBattleCameraUpdateProjection(scenebattle_t *battle) {
|
||||
glm_perspective(
|
||||
glm_rad(SCENE_BATTLE_CAMERA_FOV),
|
||||
SCREEN.aspect,
|
||||
SCENE_BATTLE_CAMERA_NEAR,
|
||||
SCENE_BATTLE_CAMERA_FAR,
|
||||
battle->projection
|
||||
);
|
||||
}
|
||||
|
||||
void sceneBattleCameraUpdateEye(scenebattle_t *battle) {
|
||||
glm_lookat(
|
||||
(vec3){ 0.0f, 4.0f, 7.0f },
|
||||
(vec3){ 0.0f, 0.5f, 0.0f },
|
||||
(vec3){ 0.0f, 1.0f, 0.0f },
|
||||
battle->eye
|
||||
);
|
||||
}
|
||||
|
||||
void sceneBattleWorldToScreen(
|
||||
scenebattle_t *battle,
|
||||
vec3 renderPos,
|
||||
vec2 out
|
||||
) {
|
||||
mat4 viewProj;
|
||||
glm_mat4_mul(battle->projection, battle->eye, viewProj);
|
||||
|
||||
vec4 viewport = {
|
||||
0.0f, 0.0f, (float_t)SCREEN.width, (float_t)SCREEN.height
|
||||
};
|
||||
vec3 window;
|
||||
glm_project(renderPos, viewProj, viewport, window);
|
||||
|
||||
out[0] = window[0];
|
||||
out[1] = (float_t)SCREEN.height - window[1];
|
||||
}
|
||||
|
||||
void sceneBattleGetFighterPosition(const uint8_t fighterIndex, vec3 out) {
|
||||
const battlefighter_t *fighter = &BATTLE.fighters[fighterIndex];
|
||||
|
||||
// Ordinal (and count) of this fighter among its living-slot teammates,
|
||||
// in slot order, so each team lays out as an evenly spaced row.
|
||||
uint8_t ordinal = 0;
|
||||
uint8_t teamCount = 0;
|
||||
for(uint8_t i = 0; i < BATTLE_FIGHTER_COUNT_MAX; i++) {
|
||||
if(BATTLE.fighters[i].status == BATTLE_FIGHTER_STATUS_NULL) continue;
|
||||
if(BATTLE.fighters[i].team != fighter->team) continue;
|
||||
if(i < fighterIndex) ordinal++;
|
||||
teamCount++;
|
||||
}
|
||||
|
||||
const float_t x =
|
||||
((float_t)ordinal - ((float_t)(teamCount - 1) * 0.5f)) *
|
||||
SCENE_BATTLE_FIGHTER_SPACING;
|
||||
const float_t z = fighter->team == BATTLE_FIGHTER_TEAM_ALLY
|
||||
? SCENE_BATTLE_ALLY_Z : SCENE_BATTLE_ENEMY_Z;
|
||||
|
||||
out[0] = x;
|
||||
out[1] = 0.0f;
|
||||
out[2] = z;
|
||||
}
|
||||
|
||||
errorret_t sceneBattleDrawFighter(const uint8_t fighterIndex) {
|
||||
const battlefighter_t *fighter = &BATTLE.fighters[fighterIndex];
|
||||
if(fighter->status == BATTLE_FIGHTER_STATUS_NULL) errorOk();
|
||||
|
||||
vec3 position;
|
||||
sceneBattleGetFighterPosition(fighterIndex, position);
|
||||
|
||||
spritebatchsprite_t sprite;
|
||||
glm_vec3_copy(position, sprite.min);
|
||||
glm_vec3_copy(position, sprite.max);
|
||||
glm_vec3_add(sprite.max, (vec3){ 1.0f, 1.0f, 0.0f }, sprite.max);
|
||||
glm_vec2_copy((vec2){ 0.0f, 0.0f }, sprite.uvMin);
|
||||
glm_vec2_copy((vec2){ 1.0f, 1.0f }, sprite.uvMax);
|
||||
|
||||
color_t color;
|
||||
if(fighter->status == BATTLE_FIGHTER_STATUS_DEAD) {
|
||||
color = color4b(96, 96, 96, 255);
|
||||
} else if(fighter->team == BATTLE_FIGHTER_TEAM_ALLY) {
|
||||
color = COLOR_BLUE;
|
||||
} else {
|
||||
color = COLOR_RED;
|
||||
}
|
||||
|
||||
shadermaterial_t material = {
|
||||
.unlit = { .color = color, .texture = NULL }
|
||||
};
|
||||
errorChain(spriteBatchBuffer(&sprite, 1, &SHADER_UNLIT, material));
|
||||
errorChain(spriteBatchFlush());
|
||||
|
||||
errorOk();
|
||||
}
|
||||
|
||||
@@ -7,9 +7,24 @@
|
||||
|
||||
#pragma once
|
||||
#include "scene/scenebase.h"
|
||||
#include "rpg/battle/battle.h"
|
||||
|
||||
// Fixed mock camera, "to begin": looking down at the arena from above and
|
||||
// in front. Not yet driven by anything (no shake, no per-fighter framing).
|
||||
#define SCENE_BATTLE_CAMERA_FOV 45.0f
|
||||
#define SCENE_BATTLE_CAMERA_NEAR 0.1f
|
||||
#define SCENE_BATTLE_CAMERA_FAR 100.0f
|
||||
|
||||
// Fighter row layout, in render-space units. Allies stand closer to the
|
||||
// camera (+Z), enemies further away (-Z); each team is spread evenly
|
||||
// along X, centered on the origin.
|
||||
#define SCENE_BATTLE_FIGHTER_SPACING 2.0f
|
||||
#define SCENE_BATTLE_ALLY_Z 2.5f
|
||||
#define SCENE_BATTLE_ENEMY_Z (-2.5f)
|
||||
|
||||
typedef struct {
|
||||
|
||||
mat4 eye;
|
||||
mat4 projection;
|
||||
} scenebattle_t;
|
||||
|
||||
/**
|
||||
@@ -44,3 +59,52 @@ errorret_t sceneBattleRender(scenedata_t *sceneData);
|
||||
* @return An error if the dispose failed, or errorOk() if it succeeded.
|
||||
*/
|
||||
errorret_t sceneBattleDispose(scenedata_t *sceneData);
|
||||
|
||||
/**
|
||||
* Recomputes the battle camera's projection matrix from the current
|
||||
* screen aspect ratio, storing it in battle->projection.
|
||||
*
|
||||
* @param battle The battle scene data to update.
|
||||
*/
|
||||
void sceneBattleCameraUpdateProjection(scenebattle_t *battle);
|
||||
|
||||
/**
|
||||
* Recomputes the battle camera's eye/view matrix, storing it in
|
||||
* battle->eye. Fixed for now -- doesn't yet track anything.
|
||||
*
|
||||
* @param battle The battle scene data to update.
|
||||
*/
|
||||
void sceneBattleCameraUpdateEye(scenebattle_t *battle);
|
||||
|
||||
/**
|
||||
* Converts a render-space position to screen-space pixel coordinates,
|
||||
* using the battle camera's current eye and projection matrices.
|
||||
*
|
||||
* @param battle The battle scene data holding the camera matrices.
|
||||
* @param renderPos The render-space position to convert.
|
||||
* @param out Output vec2 filled with the screen-space pixel position.
|
||||
*/
|
||||
void sceneBattleWorldToScreen(
|
||||
scenebattle_t *battle,
|
||||
vec3 renderPos,
|
||||
vec2 out
|
||||
);
|
||||
|
||||
/**
|
||||
* Computes the render-space position of a fighter's feet, laying out
|
||||
* each team as an evenly spaced row facing the camera.
|
||||
*
|
||||
* @param fighterIndex Index into BATTLE.fighters.
|
||||
* @param out Output vec3 filled with the fighter's render-space position.
|
||||
*/
|
||||
void sceneBattleGetFighterPosition(const uint8_t fighterIndex, vec3 out);
|
||||
|
||||
/**
|
||||
* Draws a single fighter as a colored 1x1 quad, skipping empty slots.
|
||||
* Color differentiates team (ally/enemy) and status (dead fighters are
|
||||
* drawn dimmed).
|
||||
*
|
||||
* @param fighterIndex Index into BATTLE.fighters.
|
||||
* @return An error if drawing failed, or errorOk() if it succeeded.
|
||||
*/
|
||||
errorret_t sceneBattleDrawFighter(const uint8_t fighterIndex);
|
||||
|
||||
@@ -17,12 +17,12 @@ static void sceneInitialCheckSave(void);
|
||||
|
||||
static void sceneInitialCreateSaveWriteComplete(errorret_t result, void *user) {
|
||||
if(errorIsNotOk(result)) errorCatch(errorPrint(result));
|
||||
sceneSet(SCENE_TYPE_OVERWORLD);
|
||||
sceneSet(SCENE_TYPE_MAIN_MENU);
|
||||
}
|
||||
|
||||
static void sceneInitialCreateSaveResult(const bool_t create, void *user) {
|
||||
if(!create) {
|
||||
sceneSet(SCENE_TYPE_OVERWORLD);
|
||||
sceneSet(SCENE_TYPE_MAIN_MENU);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ static void sceneInitialNoCardResult(const bool_t retry, void *user) {
|
||||
// to proceed anyway - stick with that for the rest of the session (see
|
||||
// saveMarkTemporary()'s doc comment for why this can't be un-set later).
|
||||
saveMarkTemporary();
|
||||
sceneSet(SCENE_TYPE_OVERWORLD);
|
||||
sceneSet(SCENE_TYPE_MAIN_MENU);
|
||||
}
|
||||
|
||||
static void sceneInitialLoadComplete(errorret_t result, void *user) {
|
||||
@@ -50,7 +50,7 @@ static void sceneInitialLoadComplete(errorret_t result, void *user) {
|
||||
}
|
||||
|
||||
if(saveSlotExists(SAVE_ACTIVE_SLOT)) {
|
||||
sceneSet(SCENE_TYPE_OVERWORLD);
|
||||
sceneSet(SCENE_TYPE_MAIN_MENU);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ typedef struct {
|
||||
/**
|
||||
* Initialises the initial (boot) scene - kicks off the save
|
||||
* availability/existence check that decides which prompt, if any, to
|
||||
* show before proceeding to the overworld.
|
||||
* show before proceeding to the main menu.
|
||||
*
|
||||
* @param sceneData The scene data used for this scene.
|
||||
* @return An error if the init failed, or errorOk() if it succeeded.
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# Copyright (c) 2026 Dominic Masters
|
||||
#
|
||||
# This software is released under the MIT License.
|
||||
# https://opensource.org/licenses/MIT
|
||||
|
||||
target_sources(${DUSK_LIBRARY_TARGET_NAME}
|
||||
PUBLIC
|
||||
scenemainmenu.c
|
||||
)
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Copyright (c) 2026 Dominic Masters
|
||||
*
|
||||
* This software is released under the MIT License.
|
||||
* https://opensource.org/licenses/MIT
|
||||
*/
|
||||
|
||||
#include "scenemainmenu.h"
|
||||
|
||||
errorret_t sceneMainMenuInit(scenedata_t *sceneData) {
|
||||
errorOk();
|
||||
}
|
||||
|
||||
errorret_t sceneMainMenuUpdate(scenedata_t *sceneData) {
|
||||
errorOk();
|
||||
}
|
||||
|
||||
errorret_t sceneMainMenuRender(scenedata_t *sceneData) {
|
||||
errorOk();
|
||||
}
|
||||
|
||||
errorret_t sceneMainMenuDispose(scenedata_t *sceneData) {
|
||||
errorOk();
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Copyright (c) 2026 Dominic Masters
|
||||
*
|
||||
* This software is released under the MIT License.
|
||||
* https://opensource.org/licenses/MIT
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
#include "scene/scenebase.h"
|
||||
|
||||
// Empty for now -- the menu itself lives in ui/frame/mainmenu/uimainmenu.c,
|
||||
// driven by the global UI element pipeline (see uimainmenu.c's own
|
||||
// SCENE.current check for when it shows itself). A byte placeholder keeps
|
||||
// the struct non-empty for portability.
|
||||
typedef struct {
|
||||
uint8_t reserved;
|
||||
} scenemainmenu_t;
|
||||
|
||||
/**
|
||||
* Initializes the main menu scene.
|
||||
*
|
||||
* @param sceneData The scene data used for this scene.
|
||||
* @return An error if the init failed, or errorOk() if it succeeded.
|
||||
*/
|
||||
errorret_t sceneMainMenuInit(scenedata_t *sceneData);
|
||||
|
||||
/**
|
||||
* Updates the main menu scene. Currently a no-op -- the menu drives
|
||||
* itself via the global UI element pipeline.
|
||||
*
|
||||
* @param sceneData The scene data used for this scene.
|
||||
* @return An error if the update failed, or errorOk() if it succeeded.
|
||||
*/
|
||||
errorret_t sceneMainMenuUpdate(scenedata_t *sceneData);
|
||||
|
||||
/**
|
||||
* Renders the main menu scene. Currently a no-op -- the menu draws
|
||||
* itself via the global UI element pipeline.
|
||||
*
|
||||
* @param sceneData The scene data used for this scene.
|
||||
* @return An error if the render failed, or errorOk() if it succeeded.
|
||||
*/
|
||||
errorret_t sceneMainMenuRender(scenedata_t *sceneData);
|
||||
|
||||
/**
|
||||
* Disposes the main menu scene.
|
||||
*
|
||||
* @param sceneData The scene data used for this scene.
|
||||
* @return An error if the dispose failed, or errorOk() if it succeeded.
|
||||
*/
|
||||
errorret_t sceneMainMenuDispose(scenedata_t *sceneData);
|
||||
@@ -17,6 +17,13 @@ scenecallbacks_t SCENE_TYPES[SCENE_TYPE_COUNT] = {
|
||||
.dispose = sceneInitialDispose
|
||||
},
|
||||
|
||||
[SCENE_TYPE_MAIN_MENU] = {
|
||||
.init = sceneMainMenuInit,
|
||||
.update = sceneMainMenuUpdate,
|
||||
.render = sceneMainMenuRender,
|
||||
.dispose = sceneMainMenuDispose
|
||||
},
|
||||
|
||||
[SCENE_TYPE_OVERWORLD] = {
|
||||
.init = sceneOverworldInit,
|
||||
.update = sceneOverworldUpdate,
|
||||
|
||||
@@ -8,11 +8,13 @@
|
||||
#pragma once
|
||||
#include "scene/scenebase.h"
|
||||
#include "scene/initial/sceneinitial.h"
|
||||
#include "scene/mainmenu/scenemainmenu.h"
|
||||
#include "scene/overworld/sceneoverworld.h"
|
||||
#include "scene/battle/scenebattle.h"
|
||||
|
||||
typedef union scenedata_u {
|
||||
sceneinitial_t initial;
|
||||
scenemainmenu_t mainMenu;
|
||||
sceneoverworld_t overworld;
|
||||
scenebattle_t battle;
|
||||
} scenedata_t;
|
||||
@@ -30,6 +32,7 @@ typedef enum {
|
||||
SCENE_TYPE_NULL,
|
||||
|
||||
SCENE_TYPE_INITIAL,
|
||||
SCENE_TYPE_MAIN_MENU,
|
||||
SCENE_TYPE_OVERWORLD,
|
||||
SCENE_TYPE_BATTLE,
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ target_sources(${DUSK_LIBRARY_TARGET_NAME}
|
||||
)
|
||||
|
||||
add_subdirectory(game)
|
||||
add_subdirectory(mainmenu)
|
||||
add_subdirectory(settings)
|
||||
add_subdirectory(battle)
|
||||
add_subdirectory(backpack)
|
||||
|
||||
@@ -6,4 +6,5 @@
|
||||
target_sources(${DUSK_LIBRARY_TARGET_NAME}
|
||||
PUBLIC
|
||||
uibattlemenu.c
|
||||
uibattlehud.c
|
||||
)
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Copyright (c) 2026 Dominic Masters
|
||||
*
|
||||
* This software is released under the MIT License.
|
||||
* https://opensource.org/licenses/MIT
|
||||
*/
|
||||
|
||||
#include "uibattlehud.h"
|
||||
#include "rpg/battle/battle.h"
|
||||
#include "scene/battle/scenebattle.h"
|
||||
#include "scene/scene.h"
|
||||
#include "display/text/text.h"
|
||||
#include "display/color.h"
|
||||
#include "display/spritebatch/spritebatch.h"
|
||||
#include "util/string.h"
|
||||
|
||||
#define UI_BATTLE_HUD_TEXT_MAX 32
|
||||
#define UI_BATTLE_HUD_LINE_HEIGHT 12.0f
|
||||
|
||||
errorret_t uiBattleHudDraw(void) {
|
||||
if(BATTLE.state == BATTLE_STATE_NONE) errorOk();
|
||||
|
||||
scenebattle_t *battle = &SCENE.data.battle;
|
||||
|
||||
for(uint8_t i = 0; i < BATTLE_FIGHTER_COUNT_MAX; i++) {
|
||||
const battlefighter_t *fighter = &BATTLE.fighters[i];
|
||||
if(fighter->status == BATTLE_FIGHTER_STATUS_NULL) continue;
|
||||
|
||||
vec3 position;
|
||||
sceneBattleGetFighterPosition(i, position);
|
||||
glm_vec3_add(position, (vec3){ 0.5f, 1.3f, 0.0f }, position);
|
||||
|
||||
vec2 screenPos;
|
||||
sceneBattleWorldToScreen(battle, position, screenPos);
|
||||
|
||||
char_t hpText[UI_BATTLE_HUD_TEXT_MAX];
|
||||
stringFormat(
|
||||
hpText, UI_BATTLE_HUD_TEXT_MAX - 1, "HP %u/%u",
|
||||
fighter->health, fighter->healthMax
|
||||
);
|
||||
errorChain(textDraw(screenPos[0], screenPos[1], hpText, COLOR_WHITE, NULL));
|
||||
|
||||
char_t mpText[UI_BATTLE_HUD_TEXT_MAX];
|
||||
stringFormat(
|
||||
mpText, UI_BATTLE_HUD_TEXT_MAX - 1, "MP %u/%u",
|
||||
fighter->mp, fighter->mpMax
|
||||
);
|
||||
errorChain(textDraw(
|
||||
screenPos[0], screenPos[1] + UI_BATTLE_HUD_LINE_HEIGHT,
|
||||
mpText, COLOR_CYAN, NULL
|
||||
));
|
||||
}
|
||||
|
||||
errorChain(spriteBatchFlush());
|
||||
errorOk();
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Copyright (c) 2026 Dominic Masters
|
||||
*
|
||||
* This software is released under the MIT License.
|
||||
* https://opensource.org/licenses/MIT
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
#include "error/error.h"
|
||||
|
||||
/**
|
||||
* Draws each living battle fighter's HP/MP as text above its position in
|
||||
* the battle scene. No-op if no battle is active.
|
||||
*
|
||||
* @return An error if drawing failed, or errorOk() if it succeeded.
|
||||
*/
|
||||
errorret_t uiBattleHudDraw(void);
|
||||
@@ -0,0 +1,9 @@
|
||||
# Copyright (c) 2026 Dominic Masters
|
||||
#
|
||||
# This software is released under the MIT License.
|
||||
# https://opensource.org/licenses/MIT
|
||||
|
||||
target_sources(${DUSK_LIBRARY_TARGET_NAME}
|
||||
PUBLIC
|
||||
uimainmenu.c
|
||||
)
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Copyright (c) 2026 Dominic Masters
|
||||
*
|
||||
* This software is released under the MIT License.
|
||||
* https://opensource.org/licenses/MIT
|
||||
*/
|
||||
|
||||
#include "uimainmenu.h"
|
||||
#include "ui/frame/uiframe.h"
|
||||
#include "scene/scene.h"
|
||||
#include "engine/engine.h"
|
||||
#include "rpg/battle/battle.h"
|
||||
#include "save/save.h"
|
||||
#include "util/memory.h"
|
||||
#include "display/spritebatch/spritebatch.h"
|
||||
#include "display/screen/screen.h"
|
||||
#include "assert/assert.h"
|
||||
|
||||
#define UI_MAIN_MENU_INDEX_NEW_GAME 0
|
||||
#define UI_MAIN_MENU_INDEX_LOAD_GAME 1
|
||||
#define UI_MAIN_MENU_INDEX_OPTIONS 2
|
||||
#define UI_MAIN_MENU_INDEX_QUIT 3
|
||||
|
||||
uimainmenu_t UI_MAIN_MENU;
|
||||
|
||||
// TEMPORARY test hook: New Game starts a hardcoded mock battle instead of
|
||||
// the overworld, so the battle scene (camera/fighters/HUD) can be seen and
|
||||
// played without a real encounter trigger yet.
|
||||
void uiMainMenuStartTestBattle(void) {
|
||||
battleInit();
|
||||
|
||||
const battlefighterstats_t allyOneStats =
|
||||
{ .attack = 10, .defense = 5, .magic = 0, .speed = 10, .luck = 0 };
|
||||
const battlefighterstats_t allyTwoStats =
|
||||
{ .attack = 8, .defense = 4, .magic = 0, .speed = 8, .luck = 0 };
|
||||
const battlefighterstats_t enemyOneStats =
|
||||
{ .attack = 6, .defense = 3, .magic = 0, .speed = 6, .luck = 0 };
|
||||
const battlefighterstats_t enemyTwoStats =
|
||||
{ .attack = 7, .defense = 3, .magic = 0, .speed = 5, .luck = 0 };
|
||||
|
||||
battleAddFighter(
|
||||
BATTLE_FIGHTER_TEAM_ALLY, BATTLE_FIGHTER_CONTROLLER_PLAYER,
|
||||
allyOneStats, 30, 10
|
||||
);
|
||||
battleAddFighter(
|
||||
BATTLE_FIGHTER_TEAM_ALLY, BATTLE_FIGHTER_CONTROLLER_PLAYER,
|
||||
allyTwoStats, 25, 10
|
||||
);
|
||||
battleAddFighter(
|
||||
BATTLE_FIGHTER_TEAM_ENEMY, BATTLE_FIGHTER_CONTROLLER_AI,
|
||||
enemyOneStats, 20, 5
|
||||
);
|
||||
battleAddFighter(
|
||||
BATTLE_FIGHTER_TEAM_ENEMY, BATTLE_FIGHTER_CONTROLLER_AI,
|
||||
enemyTwoStats, 20, 5
|
||||
);
|
||||
|
||||
battleStart(BATTLE_ENCOUNTER_REGULAR, true);
|
||||
sceneSet(SCENE_TYPE_BATTLE);
|
||||
}
|
||||
|
||||
void uiMainMenuSelected(
|
||||
const uimenu_t *menu,
|
||||
const uint8_t index,
|
||||
const uimenuitem_t *item
|
||||
) {
|
||||
uiMenuClose(&UI_MAIN_MENU.menu);
|
||||
|
||||
switch(index) {
|
||||
case UI_MAIN_MENU_INDEX_NEW_GAME:
|
||||
// Resets the active slot to SAVE_DEFAULT in memory -- no disk I/O,
|
||||
// unlike Load Game (which will read a real slot via saveLoadSlot()).
|
||||
saveLoadDefault(SAVE_ACTIVE_SLOT);
|
||||
uiMainMenuStartTestBattle();
|
||||
break;
|
||||
|
||||
case UI_MAIN_MENU_INDEX_LOAD_GAME:
|
||||
// TODO: load game.
|
||||
break;
|
||||
|
||||
case UI_MAIN_MENU_INDEX_OPTIONS:
|
||||
// TODO: options.
|
||||
break;
|
||||
|
||||
case UI_MAIN_MENU_INDEX_QUIT:
|
||||
ENGINE.running = false;
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
errorret_t uiMainMenuInit(void) {
|
||||
memoryZero(&UI_MAIN_MENU, sizeof(uimainmenu_t));
|
||||
|
||||
MENU_BEGIN(
|
||||
&UI_MAIN_MENU.menu, UI_MAIN_MENU.items, uiMainMenuSelected, NULL, NULL
|
||||
);
|
||||
MENU_BUTTON("New Game");
|
||||
MENU_BUTTON("Load Game");
|
||||
MENU_BUTTON("Options");
|
||||
MENU_BUTTON("Quit Game");
|
||||
MENU_END(UI_MAIN_MENU.items, 1);
|
||||
|
||||
errorOk();
|
||||
}
|
||||
|
||||
errorret_t uiMainMenuUpdate(void) {
|
||||
if(SCENE.current != SCENE_TYPE_MAIN_MENU) errorOk();
|
||||
if(uiMenuIsActive(&UI_MAIN_MENU.menu)) errorOk();
|
||||
|
||||
uiMenuOpen(&UI_MAIN_MENU.menu);
|
||||
errorOk();
|
||||
}
|
||||
|
||||
errorret_t uiMainMenuDraw(void) {
|
||||
if(!uiMenuIsActive(&UI_MAIN_MENU.menu)) errorOk();
|
||||
|
||||
const float_t width = UI_MAIN_MENU_WIDTH;
|
||||
const float_t height = UI_MAIN_MENU_HEIGHT;
|
||||
const float_t x =
|
||||
(float_t)SCREEN.scanX + ((float_t)SCREEN.scanWidth - width) * 0.5f;
|
||||
const float_t y =
|
||||
(float_t)SCREEN.scanY + ((float_t)SCREEN.scanHeight - height) * 0.5f;
|
||||
|
||||
errorChain(uiFrameDraw(x, y, width, height));
|
||||
errorChain(uiMenuDraw(
|
||||
&UI_MAIN_MENU.menu,
|
||||
x + UI_FRAME_START_X,
|
||||
y + UI_FRAME_START_Y,
|
||||
width - (UI_FRAME_START_X * 2),
|
||||
height - (UI_FRAME_START_Y * 2)
|
||||
));
|
||||
|
||||
errorChain(spriteBatchFlush());
|
||||
errorOk();
|
||||
}
|
||||
|
||||
errorret_t uiMainMenuDispose(void) {
|
||||
errorOk();
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Copyright (c) 2026 Dominic Masters
|
||||
*
|
||||
* This software is released under the MIT License.
|
||||
* https://opensource.org/licenses/MIT
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
#include "error/error.h"
|
||||
#include "ui/widget/uimenu.h"
|
||||
|
||||
#define UI_MAIN_MENU_ITEM_COUNT 4
|
||||
#define UI_MAIN_MENU_WIDTH 200.0f
|
||||
#define UI_MAIN_MENU_HEIGHT 160.0f
|
||||
|
||||
typedef struct {
|
||||
uimenu_t menu;
|
||||
uimenuitem_t items[UI_MAIN_MENU_ITEM_COUNT];
|
||||
} uimainmenu_t;
|
||||
|
||||
extern uimainmenu_t UI_MAIN_MENU;
|
||||
|
||||
/**
|
||||
* Initializes the main menu panel.
|
||||
*
|
||||
* @return Any error that occurs.
|
||||
*/
|
||||
errorret_t uiMainMenuInit(void);
|
||||
|
||||
/**
|
||||
* Updates the main menu panel: opens it whenever SCENE_TYPE_MAIN_MENU
|
||||
* becomes active and it isn't already open.
|
||||
*
|
||||
* @return Any error that occurs.
|
||||
*/
|
||||
errorret_t uiMainMenuUpdate(void);
|
||||
|
||||
/**
|
||||
* Draws the main menu panel, centered on screen. No-op when not active.
|
||||
*
|
||||
* @return Any error that occurs.
|
||||
*/
|
||||
errorret_t uiMainMenuDraw(void);
|
||||
|
||||
/**
|
||||
* Disposes of the main menu panel.
|
||||
*
|
||||
* @return Any error that occurs.
|
||||
*/
|
||||
errorret_t uiMainMenuDispose(void);
|
||||
@@ -19,7 +19,9 @@
|
||||
#include "ui/debug/uiconsole.h"
|
||||
#include "ui/frame/settings/uisettings.h"
|
||||
#include "ui/frame/game/uigamemenu.h"
|
||||
#include "ui/frame/mainmenu/uimainmenu.h"
|
||||
#include "ui/frame/battle/uibattlemenu.h"
|
||||
#include "ui/frame/battle/uibattlehud.h"
|
||||
#include "ui/frame/backpack/uibackpack.h"
|
||||
#include "ui/frame/uiconfirm.h"
|
||||
#include "ui/frame/initial/uiinitialnocard.h"
|
||||
@@ -56,6 +58,15 @@ uielement_t UI_ELEMENTS[] = {
|
||||
.dispose = uiGameMenuDispose
|
||||
},
|
||||
|
||||
{
|
||||
.init = uiMainMenuInit,
|
||||
.update = uiMainMenuUpdate,
|
||||
.draw = uiMainMenuDraw,
|
||||
.dispose = uiMainMenuDispose
|
||||
},
|
||||
|
||||
{ .draw = uiBattleHudDraw },
|
||||
|
||||
{
|
||||
.init = uiBattleMenuInit,
|
||||
.update = uiBattleMenuUpdate,
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
# This software is released under the MIT License.
|
||||
# https://opensource.org/licenses/MIT
|
||||
|
||||
add_subdirectory(animation)
|
||||
add_subdirectory(assert)
|
||||
add_subdirectory(asset)
|
||||
add_subdirectory(error)
|
||||
|
||||
@@ -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_keyframe.c)
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* Copyright (c) 2026 Dominic Masters
|
||||
*
|
||||
* This software is released under the MIT License.
|
||||
* https://opensource.org/licenses/MIT
|
||||
*/
|
||||
|
||||
#include "dusktest.h"
|
||||
#include "animation/keyframe.h"
|
||||
|
||||
static void test_keyframeGetValueSingleSegmentLinear(void **state) {
|
||||
keyframe_t keyframes[2] = {
|
||||
{ .time = 0.0f, .value = 0.0f, .easing = EASING_LINEAR },
|
||||
{ .time = 1.0f, .value = 10.0f, .easing = EASING_LINEAR },
|
||||
};
|
||||
|
||||
assert_float_equal(keyframeGetValue(keyframes, 2, 0.0f), 0.0f, 0.0001f);
|
||||
assert_float_equal(keyframeGetValue(keyframes, 2, 0.25f), 2.5f, 0.0001f);
|
||||
assert_float_equal(keyframeGetValue(keyframes, 2, 0.5f), 5.0f, 0.0001f);
|
||||
assert_float_equal(keyframeGetValue(keyframes, 2, 0.75f), 7.5f, 0.0001f);
|
||||
}
|
||||
|
||||
static void test_keyframeGetValueMultiSegment(void **state) {
|
||||
keyframe_t keyframes[3] = {
|
||||
{ .time = 0.0f, .value = 0.0f, .easing = EASING_LINEAR },
|
||||
{ .time = 1.0f, .value = 10.0f, .easing = EASING_LINEAR },
|
||||
{ .time = 2.0f, .value = 20.0f, .easing = EASING_LINEAR },
|
||||
};
|
||||
|
||||
// Within the first segment.
|
||||
assert_float_equal(keyframeGetValue(keyframes, 3, 0.5f), 5.0f, 0.0001f);
|
||||
|
||||
// Exactly on the interior keyframe, resolved as the end of segment one.
|
||||
assert_float_equal(keyframeGetValue(keyframes, 3, 1.0f), 10.0f, 0.0001f);
|
||||
|
||||
// Within the second segment.
|
||||
assert_float_equal(keyframeGetValue(keyframes, 3, 1.5f), 15.0f, 0.0001f);
|
||||
}
|
||||
|
||||
static void test_keyframeGetValueDescendingValues(void **state) {
|
||||
keyframe_t keyframes[2] = {
|
||||
{ .time = 0.0f, .value = 100.0f, .easing = EASING_LINEAR },
|
||||
{ .time = 1.0f, .value = 0.0f, .easing = EASING_LINEAR },
|
||||
};
|
||||
|
||||
assert_float_equal(keyframeGetValue(keyframes, 2, 0.0f), 100.0f, 0.0001f);
|
||||
assert_float_equal(keyframeGetValue(keyframes, 2, 0.5f), 50.0f, 0.0001f);
|
||||
}
|
||||
|
||||
static void test_keyframeGetValueAppliesEasing(void **state) {
|
||||
keyframe_t keyframes[2] = {
|
||||
{ .time = 0.0f, .value = 0.0f, .easing = EASING_IN_QUAD },
|
||||
{ .time = 1.0f, .value = 10.0f, .easing = EASING_LINEAR },
|
||||
};
|
||||
|
||||
// EASING_IN_QUAD is t * t, so halfway through time should be a quarter of
|
||||
// the way through the value range, not half.
|
||||
assert_float_equal(keyframeGetValue(keyframes, 2, 0.5f), 2.5f, 0.0001f);
|
||||
}
|
||||
|
||||
static void test_keyframeGetValueNonZeroStartTime(void **state) {
|
||||
keyframe_t keyframes[2] = {
|
||||
{ .time = 5.0f, .value = 0.0f, .easing = EASING_LINEAR },
|
||||
{ .time = 10.0f, .value = 100.0f, .easing = EASING_LINEAR },
|
||||
};
|
||||
|
||||
assert_float_equal(keyframeGetValue(keyframes, 2, 5.0f), 0.0f, 0.0001f);
|
||||
assert_float_equal(keyframeGetValue(keyframes, 2, 7.5f), 50.0f, 0.0001f);
|
||||
}
|
||||
|
||||
// NOTE: keyframeGetValue does not clamp to the first keyframe's value when
|
||||
// queried before the first keyframe's time - the start and end pointers
|
||||
// collapse onto the same keyframe, so the interpolation divides by a zero
|
||||
// time delta. This test documents that current behavior rather than
|
||||
// asserting it is desirable; flag to the maintainer if this should instead
|
||||
// clamp like the last-keyframe case does.
|
||||
static void test_keyframeGetValueBeforeFirstKeyframeIsNaN(void **state) {
|
||||
keyframe_t keyframes[2] = {
|
||||
{ .time = 1.0f, .value = 10.0f, .easing = EASING_LINEAR },
|
||||
{ .time = 3.0f, .value = 30.0f, .easing = EASING_LINEAR },
|
||||
};
|
||||
|
||||
assert_true(isnan(keyframeGetValue(keyframes, 2, 0.0f)));
|
||||
}
|
||||
|
||||
static void test_keyframeGetValueAtOrAfterLastKeyframeClampsToLastValue(void **state) {
|
||||
keyframe_t keyframes[2] = {
|
||||
{ .time = 0.0f, .value = 0.0f, .easing = EASING_LINEAR },
|
||||
{ .time = 1.0f, .value = 10.0f, .easing = EASING_LINEAR },
|
||||
};
|
||||
|
||||
assert_float_equal(keyframeGetValue(keyframes, 2, 1.0f), 10.0f, 0.0001f);
|
||||
assert_float_equal(keyframeGetValue(keyframes, 2, 2.0f), 10.0f, 0.0001f);
|
||||
}
|
||||
|
||||
static void test_keyframeGetValueSingleKeyframeAtOrAfterClampsToValue(void **state) {
|
||||
keyframe_t keyframes[1] = {
|
||||
{ .time = 5.0f, .value = 42.0f, .easing = EASING_LINEAR },
|
||||
};
|
||||
|
||||
assert_float_equal(keyframeGetValue(keyframes, 1, 5.0f), 42.0f, 0.0001f);
|
||||
assert_float_equal(keyframeGetValue(keyframes, 1, 10.0f), 42.0f, 0.0001f);
|
||||
|
||||
// Before the single keyframe's time is still the documented NaN case.
|
||||
assert_true(isnan(keyframeGetValue(keyframes, 1, 0.0f)));
|
||||
}
|
||||
|
||||
static void test_keyframeGetValueNullKeyframesAsserts(void **state) {
|
||||
expect_assert_failure(keyframeGetValue(NULL, 1, 0.0f));
|
||||
}
|
||||
|
||||
static void test_keyframeGetValueZeroCountAsserts(void **state) {
|
||||
keyframe_t keyframes[1] = {
|
||||
{ .time = 0.0f, .value = 0.0f, .easing = EASING_LINEAR },
|
||||
};
|
||||
|
||||
expect_assert_failure(keyframeGetValue(keyframes, 0, 0.0f));
|
||||
}
|
||||
|
||||
static void test_keyframeGetValueNegativeTimeAsserts(void **state) {
|
||||
keyframe_t keyframes[2] = {
|
||||
{ .time = 0.0f, .value = 0.0f, .easing = EASING_LINEAR },
|
||||
{ .time = 1.0f, .value = 10.0f, .easing = EASING_LINEAR },
|
||||
};
|
||||
|
||||
expect_assert_failure(keyframeGetValue(keyframes, 2, -1.0f));
|
||||
}
|
||||
|
||||
static void test_keyframeGetValueUnsortedKeyframesAsserts(void **state) {
|
||||
keyframe_t keyframes[2] = {
|
||||
{ .time = 1.0f, .value = 10.0f, .easing = EASING_LINEAR },
|
||||
{ .time = 0.0f, .value = 0.0f, .easing = EASING_LINEAR },
|
||||
};
|
||||
|
||||
expect_assert_failure(keyframeGetValue(keyframes, 2, 0.0f));
|
||||
}
|
||||
|
||||
static void test_keyframeGetValueLaterKeyframeOutOfOrderAsserts(void **state) {
|
||||
// The first pair is sorted, but the third keyframe is out of order - the
|
||||
// check must walk the whole array, not just the first pair.
|
||||
keyframe_t keyframes[3] = {
|
||||
{ .time = 0.0f, .value = 0.0f, .easing = EASING_LINEAR },
|
||||
{ .time = 1.0f, .value = 10.0f, .easing = EASING_LINEAR },
|
||||
{ .time = 0.5f, .value = 5.0f, .easing = EASING_LINEAR },
|
||||
};
|
||||
|
||||
expect_assert_failure(keyframeGetValue(keyframes, 3, 0.0f));
|
||||
}
|
||||
|
||||
static void test_keyframeGetValueEqualConsecutiveTimesDoesNotAssert(void **state) {
|
||||
// Equal (non-decreasing) times are allowed - only strictly decreasing
|
||||
// times should trigger the sorted check.
|
||||
keyframe_t keyframes[3] = {
|
||||
{ .time = 0.0f, .value = 0.0f, .easing = EASING_LINEAR },
|
||||
{ .time = 0.0f, .value = 5.0f, .easing = EASING_LINEAR },
|
||||
{ .time = 1.0f, .value = 10.0f, .easing = EASING_LINEAR },
|
||||
};
|
||||
|
||||
assert_float_equal(keyframeGetValue(keyframes, 3, 0.0f), 5.0f, 0.0001f);
|
||||
}
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
const struct CMUnitTest tests[] = {
|
||||
cmocka_unit_test(test_keyframeGetValueSingleSegmentLinear),
|
||||
cmocka_unit_test(test_keyframeGetValueMultiSegment),
|
||||
cmocka_unit_test(test_keyframeGetValueDescendingValues),
|
||||
cmocka_unit_test(test_keyframeGetValueAppliesEasing),
|
||||
cmocka_unit_test(test_keyframeGetValueNonZeroStartTime),
|
||||
cmocka_unit_test(test_keyframeGetValueBeforeFirstKeyframeIsNaN),
|
||||
cmocka_unit_test(test_keyframeGetValueAtOrAfterLastKeyframeClampsToLastValue),
|
||||
cmocka_unit_test(test_keyframeGetValueSingleKeyframeAtOrAfterClampsToValue),
|
||||
cmocka_unit_test(test_keyframeGetValueNullKeyframesAsserts),
|
||||
cmocka_unit_test(test_keyframeGetValueZeroCountAsserts),
|
||||
cmocka_unit_test(test_keyframeGetValueNegativeTimeAsserts),
|
||||
cmocka_unit_test(test_keyframeGetValueUnsortedKeyframesAsserts),
|
||||
cmocka_unit_test(test_keyframeGetValueLaterKeyframeOutOfOrderAsserts),
|
||||
cmocka_unit_test(test_keyframeGetValueEqualConsecutiveTimesDoesNotAssert),
|
||||
};
|
||||
return cmocka_run_group_tests(tests, NULL, NULL);
|
||||
}
|
||||
@@ -10,4 +10,6 @@ dusktest(test_rpg.c)
|
||||
|
||||
# Subdirs
|
||||
add_subdirectory(overworld)
|
||||
add_subdirectory(battle)
|
||||
add_subdirectory(battle)
|
||||
add_subdirectory(entity)
|
||||
add_subdirectory(cutscene)
|
||||
@@ -0,0 +1,11 @@
|
||||
# Copyright (c) 2026 Dominic Masters
|
||||
#
|
||||
# This software is released under the MIT License.
|
||||
# https://opensource.org/licenses/MIT
|
||||
|
||||
include(dusktest)
|
||||
|
||||
# Tests
|
||||
dusktest(test_cutscenesystem.c)
|
||||
dusktest(test_cutscenecontrol.c)
|
||||
dusktest(test_cutscenemaparea.c)
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Copyright (c) 2026 Dominic Masters
|
||||
*
|
||||
* This software is released under the MIT License.
|
||||
* https://opensource.org/licenses/MIT
|
||||
*/
|
||||
|
||||
#include "dusktest.h"
|
||||
#include "rpg/cutscene/cutscenesystem.h"
|
||||
#include "time/time.h"
|
||||
#include "util/memory.h"
|
||||
|
||||
static void test_cutsceneWaitCompletesAfterItsDuration(void **state) {
|
||||
|
||||
cutsceneitem_t item = CUTSCENE_WAIT(1.0f);
|
||||
cutsceneitemdata_t data;
|
||||
memoryZero(&data, sizeof(data));
|
||||
|
||||
cutsceneWaitStart(&item, &data);
|
||||
|
||||
TIME.delta = 0.5f;
|
||||
assert_false(cutsceneWaitUpdate(&item, &data));
|
||||
|
||||
TIME.delta = 0.6f;
|
||||
assert_true(cutsceneWaitUpdate(&item, &data));
|
||||
}
|
||||
|
||||
static void *lastUserData;
|
||||
static uint8_t callbackCallCount;
|
||||
|
||||
static void recordCallback(void *userData) {
|
||||
lastUserData = userData;
|
||||
callbackCallCount++;
|
||||
}
|
||||
|
||||
static void test_cutsceneCallbackFiresOnStartWithUserData(void **state) {
|
||||
|
||||
callbackCallCount = 0;
|
||||
lastUserData = NULL;
|
||||
|
||||
cutsceneitem_t item = CUTSCENE_CALLBACK(recordCallback);
|
||||
cutsceneitemdata_t data;
|
||||
|
||||
cutsceneCallbackStart(&item, &data);
|
||||
assert_int_equal(callbackCallCount, 1);
|
||||
assert_ptr_equal(lastUserData, CUTSCENE_SYSTEM.userData);
|
||||
|
||||
// Callback items always complete immediately -- the effect already
|
||||
// happened in Start, not Update.
|
||||
assert_true(cutsceneCallbackUpdate(&item, &data));
|
||||
assert_int_equal(callbackCallCount, 1);// Update doesn't fire it again
|
||||
}
|
||||
|
||||
static void test_cutsceneCallbackNullIsNoop(void **state) {
|
||||
|
||||
callbackCallCount = 0;
|
||||
cutsceneitem_t item = { .type = CUTSCENE_ITEM_TYPE_CALLBACK, .callback = NULL };
|
||||
cutsceneitemdata_t data;
|
||||
|
||||
cutsceneCallbackStart(&item, &data);// should not crash
|
||||
assert_int_equal(callbackCallCount, 0);
|
||||
}
|
||||
|
||||
static void test_cutsceneSetPauseAppliesImmediately(void **state) {
|
||||
|
||||
CUTSCENE_SYSTEM.pause = CUTSCENE_PAUSE_NONE;
|
||||
cutsceneitem_t item =
|
||||
CUTSCENE_SET_PAUSE(CUTSCENE_PAUSE_NPC | CUTSCENE_PAUSE_BATTLE);
|
||||
cutsceneitemdata_t data;
|
||||
|
||||
cutsceneSetPauseStart(&item, &data);
|
||||
assert_int_equal(
|
||||
CUTSCENE_SYSTEM.pause, CUTSCENE_PAUSE_NPC | CUTSCENE_PAUSE_BATTLE
|
||||
);
|
||||
assert_true(cutsceneSetPauseUpdate(&item, &data));
|
||||
}
|
||||
|
||||
static void test_cutsceneConcurrentCompletesOnceAllChildrenDo(void **state) {
|
||||
|
||||
cutsceneitem_t item = CUTSCENE_CONCURRENT(
|
||||
CUTSCENE_WAIT(0.2f),
|
||||
CUTSCENE_WAIT(0.5f)
|
||||
);
|
||||
cutsceneitemdata_t data;
|
||||
memoryZero(&data, sizeof(data));
|
||||
|
||||
cutsceneConcurrentStart(&item, &data);
|
||||
|
||||
TIME.delta = 0.3f;
|
||||
// Child 0 (0.2s) elapses; child 1 (0.5s) still has 0.2s left.
|
||||
assert_false(cutsceneConcurrentUpdate(&item, &data));
|
||||
|
||||
TIME.delta = 0.3f;
|
||||
// Child 1 now elapses too.
|
||||
assert_true(cutsceneConcurrentUpdate(&item, &data));
|
||||
}
|
||||
|
||||
static void test_cutsceneConcurrentDoesNotReUpdateFinishedChildren(
|
||||
void **state
|
||||
) {
|
||||
|
||||
cutsceneitem_t item = CUTSCENE_CONCURRENT(
|
||||
CUTSCENE_WAIT(0.1f),
|
||||
CUTSCENE_WAIT(10.0f)
|
||||
);
|
||||
cutsceneitemdata_t data;
|
||||
memoryZero(&data, sizeof(data));
|
||||
|
||||
cutsceneConcurrentStart(&item, &data);
|
||||
|
||||
TIME.delta = 0.2f;
|
||||
cutsceneConcurrentUpdate(&item, &data);// child 0 finishes
|
||||
|
||||
// If child 0 were re-updated, its stored wait value would keep dropping
|
||||
// further below zero -- not observable directly, but the overall result
|
||||
// must not falsely report done just because child 0 keeps completing.
|
||||
TIME.delta = 0.2f;
|
||||
assert_false(cutsceneConcurrentUpdate(&item, &data));
|
||||
}
|
||||
|
||||
static void test_cutsceneConcurrentCannotNest(void **state) {
|
||||
|
||||
cutsceneitem_t outer =
|
||||
CUTSCENE_CONCURRENT(CUTSCENE_CONCURRENT(CUTSCENE_WAIT(1.0f)));
|
||||
cutsceneitemdata_t data;
|
||||
|
||||
expect_assert_failure(cutsceneConcurrentStart(&outer, &data));
|
||||
}
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
const struct CMUnitTest tests[] = {
|
||||
cmocka_unit_test(test_cutsceneWaitCompletesAfterItsDuration),
|
||||
cmocka_unit_test(test_cutsceneCallbackFiresOnStartWithUserData),
|
||||
cmocka_unit_test(test_cutsceneCallbackNullIsNoop),
|
||||
cmocka_unit_test(test_cutsceneSetPauseAppliesImmediately),
|
||||
cmocka_unit_test(test_cutsceneConcurrentCompletesOnceAllChildrenDo),
|
||||
cmocka_unit_test(test_cutsceneConcurrentDoesNotReUpdateFinishedChildren),
|
||||
cmocka_unit_test(test_cutsceneConcurrentCannotNest),
|
||||
};
|
||||
|
||||
return cmocka_run_group_tests(tests, NULL, NULL);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* Copyright (c) 2026 Dominic Masters
|
||||
*
|
||||
* This software is released under the MIT License.
|
||||
* https://opensource.org/licenses/MIT
|
||||
*/
|
||||
|
||||
#include "dusktest.h"
|
||||
#include "rpg/cutscene/cutscenesystem.h"
|
||||
#include "rpg/overworld/maparea.h"
|
||||
#include "util/memory.h"
|
||||
|
||||
static void resetMapAreas(void) {
|
||||
memoryZero(MAP_AREAS, sizeof(MAP_AREAS));
|
||||
}
|
||||
|
||||
static void test_cutsceneMapAreaAddCreatesAreaAndStoresLastCreated(
|
||||
void **state
|
||||
) {
|
||||
|
||||
resetMapAreas();
|
||||
cutsceneitem_t item = CUTSCENE_MAP_AREA_ADD(
|
||||
0, 0, 0, 5, 5, 0, mapAreaNoopCallback, MAP_AREA_NOTIFY_ALL, MAP_TRIGGER_ALL
|
||||
);
|
||||
cutsceneitemdata_t data;
|
||||
|
||||
cutsceneMapAreaAddStart(&item, &data);
|
||||
assert_true(cutsceneMapAreaAddUpdate(&item, &data));
|
||||
|
||||
uint8_t id = CUTSCENE_SYSTEM.areaLastCreated;
|
||||
assert_ptr_equal(MAP_AREAS[id].callback, mapAreaNoopCallback);
|
||||
assert_int_equal(MAP_AREAS[id].max.x, 5);
|
||||
}
|
||||
|
||||
static void test_cutsceneMapAreaRemoveClearsSlot(void **state) {
|
||||
|
||||
resetMapAreas();
|
||||
uint8_t id = mapAreaAdd(
|
||||
(worldpos_t){ 0, 0, 0 }, (worldpos_t){ 5, 5, 0 },
|
||||
mapAreaNoopCallback, MAP_AREA_NOTIFY_ALL, MAP_TRIGGER_ALL
|
||||
);
|
||||
|
||||
cutsceneitem_t item = CUTSCENE_MAP_AREA_REMOVE(id);
|
||||
cutsceneitemdata_t data;
|
||||
|
||||
cutsceneMapAreaRemoveStart(&item, &data);
|
||||
assert_true(cutsceneMapAreaRemoveUpdate(&item, &data));
|
||||
assert_null(MAP_AREAS[id].callback);
|
||||
}
|
||||
|
||||
static void test_cutsceneMapAreaRemoveResolvesLastCreatedSentinel(
|
||||
void **state
|
||||
) {
|
||||
|
||||
resetMapAreas();
|
||||
cutsceneitem_t addItem = CUTSCENE_MAP_AREA_ADD(
|
||||
0, 0, 0, 5, 5, 0, mapAreaNoopCallback, MAP_AREA_NOTIFY_ALL, MAP_TRIGGER_ALL
|
||||
);
|
||||
cutsceneitemdata_t data;
|
||||
cutsceneMapAreaAddStart(&addItem, &data);
|
||||
uint8_t id = CUTSCENE_SYSTEM.areaLastCreated;
|
||||
|
||||
cutsceneitem_t removeItem = CUTSCENE_MAP_AREA_REMOVE(CUTSCENE_AREA_LAST_CREATED);
|
||||
cutsceneMapAreaRemoveStart(&removeItem, &data);
|
||||
assert_null(MAP_AREAS[id].callback);
|
||||
}
|
||||
|
||||
static void test_cutsceneMapAreaWaitCompletesWhenTriggerCountChanges(
|
||||
void **state
|
||||
) {
|
||||
|
||||
resetMapAreas();
|
||||
uint8_t id = mapAreaAdd(
|
||||
(worldpos_t){ 0, 0, 0 }, (worldpos_t){ 5, 5, 0 },
|
||||
mapAreaNoopCallback, MAP_AREA_NOTIFY_ALL, MAP_TRIGGER_ALL
|
||||
);
|
||||
|
||||
cutsceneitem_t item = CUTSCENE_MAP_AREA_WAIT(id);
|
||||
cutsceneitemdata_t data;
|
||||
|
||||
cutsceneMapAreaWaitStart(&item, &data);
|
||||
assert_false(cutsceneMapAreaWaitUpdate(&item, &data));
|
||||
|
||||
MAP_AREAS[id].triggerCount++;// simulates the area's callback firing
|
||||
assert_true(cutsceneMapAreaWaitUpdate(&item, &data));
|
||||
}
|
||||
|
||||
static void test_cutsceneMapAreaWaitCompletesWhenAnyWatchedAreaChanges(
|
||||
void **state
|
||||
) {
|
||||
|
||||
resetMapAreas();
|
||||
uint8_t idA = mapAreaAdd(
|
||||
(worldpos_t){ 0, 0, 0 }, (worldpos_t){ 5, 5, 0 },
|
||||
mapAreaNoopCallback, MAP_AREA_NOTIFY_ALL, MAP_TRIGGER_ALL
|
||||
);
|
||||
uint8_t idB = mapAreaAdd(
|
||||
(worldpos_t){ 10, 10, 0 }, (worldpos_t){ 15, 15, 0 },
|
||||
mapAreaNoopCallback, MAP_AREA_NOTIFY_ALL, MAP_TRIGGER_ALL
|
||||
);
|
||||
|
||||
cutsceneitem_t item = CUTSCENE_MAP_AREA_WAIT(idA, idB);
|
||||
cutsceneitemdata_t data;
|
||||
|
||||
cutsceneMapAreaWaitStart(&item, &data);
|
||||
assert_false(cutsceneMapAreaWaitUpdate(&item, &data));
|
||||
|
||||
MAP_AREAS[idB].triggerCount++;// only the second watched area fires
|
||||
assert_true(cutsceneMapAreaWaitUpdate(&item, &data));
|
||||
}
|
||||
|
||||
static void test_cutsceneMapAreaWaitResolvesLastCreatedSentinel(
|
||||
void **state
|
||||
) {
|
||||
|
||||
resetMapAreas();
|
||||
cutsceneitem_t addItem = CUTSCENE_MAP_AREA_ADD(
|
||||
0, 0, 0, 5, 5, 0, mapAreaNoopCallback, MAP_AREA_NOTIFY_ALL, MAP_TRIGGER_ALL
|
||||
);
|
||||
cutsceneitemdata_t data;
|
||||
cutsceneMapAreaAddStart(&addItem, &data);
|
||||
uint8_t id = CUTSCENE_SYSTEM.areaLastCreated;
|
||||
|
||||
cutsceneitem_t waitItem = CUTSCENE_MAP_AREA_WAIT(CUTSCENE_AREA_LAST_CREATED);
|
||||
cutsceneMapAreaWaitStart(&waitItem, &data);
|
||||
assert_false(cutsceneMapAreaWaitUpdate(&waitItem, &data));
|
||||
|
||||
MAP_AREAS[id].triggerCount++;
|
||||
assert_true(cutsceneMapAreaWaitUpdate(&waitItem, &data));
|
||||
}
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
const struct CMUnitTest tests[] = {
|
||||
cmocka_unit_test(test_cutsceneMapAreaAddCreatesAreaAndStoresLastCreated),
|
||||
cmocka_unit_test(test_cutsceneMapAreaRemoveClearsSlot),
|
||||
cmocka_unit_test(test_cutsceneMapAreaRemoveResolvesLastCreatedSentinel),
|
||||
cmocka_unit_test(test_cutsceneMapAreaWaitCompletesWhenTriggerCountChanges),
|
||||
cmocka_unit_test(test_cutsceneMapAreaWaitCompletesWhenAnyWatchedAreaChanges),
|
||||
cmocka_unit_test(test_cutsceneMapAreaWaitResolvesLastCreatedSentinel),
|
||||
};
|
||||
|
||||
return cmocka_run_group_tests(tests, NULL, NULL);
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* Copyright (c) 2026 Dominic Masters
|
||||
*
|
||||
* This software is released under the MIT License.
|
||||
* https://opensource.org/licenses/MIT
|
||||
*/
|
||||
|
||||
#include "dusktest.h"
|
||||
#include "rpg/cutscene/cutscenesystem.h"
|
||||
#include "rpg/entity/entity.h"
|
||||
#include "time/time.h"
|
||||
|
||||
static uint8_t callbackFired;
|
||||
|
||||
static void recordCallback(void *userData) {
|
||||
callbackFired++;
|
||||
}
|
||||
|
||||
// INNER is jumped into by OUTER's second item, never returning -- this is
|
||||
// the "nested cutscene is a one-way jump" behavior: cutsceneCutsceneStart
|
||||
// replaces CUTSCENE_SYSTEM.scene outright, and the callback item's effect
|
||||
// fires in Start, not Update, so it fires the same frame the jump happens.
|
||||
CUTSCENE(TEST_INNER, 0, NONE,
|
||||
CUTSCENE_CALLBACK(recordCallback)
|
||||
);
|
||||
|
||||
CUTSCENE(TEST_OUTER, 0, DEFAULT,
|
||||
CUTSCENE_WAIT(0.5f),
|
||||
CUTSCENE_CUTSCENE(TEST_INNER)
|
||||
);
|
||||
|
||||
static void test_cutsceneSystemStartSetsUpInitialItem(void **state) {
|
||||
|
||||
cutsceneSystemInit();
|
||||
|
||||
cutsceneSystemStartCutscene(&CUTSCENE_TEST_OUTER);
|
||||
assert_ptr_equal(CUTSCENE_SYSTEM.scene, &CUTSCENE_TEST_OUTER);
|
||||
assert_int_equal(CUTSCENE_SYSTEM.currentItem, 0);
|
||||
assert_int_equal(CUTSCENE_SYSTEM.pause, CUTSCENE_PAUSE_DEFAULT);
|
||||
}
|
||||
|
||||
static void test_cutsceneSystemNestedCutsceneIsOneWayJump(void **state) {
|
||||
|
||||
cutsceneSystemInit();
|
||||
callbackFired = 0;
|
||||
cutsceneSystemStartCutscene(&CUTSCENE_TEST_OUTER);
|
||||
|
||||
TIME.delta = 0.1f;
|
||||
cutsceneSystemUpdate();// wait not elapsed yet
|
||||
assert_ptr_equal(CUTSCENE_SYSTEM.scene, &CUTSCENE_TEST_OUTER);
|
||||
assert_int_equal(callbackFired, 0);
|
||||
|
||||
// The wait elapses, advancing to the nested-cutscene item, which jumps
|
||||
// straight into INNER and starts its first item (the callback) -- all
|
||||
// within this single update call.
|
||||
TIME.delta = 1.0f;
|
||||
cutsceneSystemUpdate();
|
||||
assert_ptr_equal(CUTSCENE_SYSTEM.scene, &CUTSCENE_TEST_INNER);
|
||||
assert_int_equal(CUTSCENE_SYSTEM.currentItem, 0);
|
||||
assert_int_equal(callbackFired, 1);
|
||||
|
||||
// INNER's only item (the callback) always reports complete -- one more
|
||||
// update ends the whole cutscene.
|
||||
cutsceneSystemUpdate();
|
||||
assert_null(CUTSCENE_SYSTEM.scene);
|
||||
assert_int_equal(CUTSCENE_SYSTEM.currentItem, 0xFF);
|
||||
assert_int_equal(CUTSCENE_SYSTEM.pause, CUTSCENE_PAUSE_NONE);
|
||||
}
|
||||
|
||||
static void test_cutsceneSystemUpdateIsNoopWithNoActiveCutscene(void **state) {
|
||||
|
||||
cutsceneSystemInit();
|
||||
|
||||
cutsceneSystemUpdate();// should not crash
|
||||
assert_null(CUTSCENE_SYSTEM.scene);
|
||||
}
|
||||
|
||||
CUTSCENE(TEST_SINGLE_WAIT, 0, NONE,
|
||||
CUTSCENE_WAIT(1.0f)
|
||||
);
|
||||
|
||||
static void test_cutsceneSystemStartWithSetsInteractEntities(void **state) {
|
||||
|
||||
cutsceneSystemInit();
|
||||
entityInit(&ENTITIES[0], ENTITY_TYPE_PLAYER);
|
||||
entityInit(&ENTITIES[1], ENTITY_TYPE_NPC);
|
||||
|
||||
cutsceneSystemStartCutsceneWith(
|
||||
&CUTSCENE_TEST_SINGLE_WAIT, &ENTITIES[0], &ENTITIES[1]
|
||||
);
|
||||
|
||||
assert_ptr_equal(CUTSCENE_SYSTEM.entityInteract, &ENTITIES[0]);
|
||||
assert_ptr_equal(CUTSCENE_SYSTEM.entityInteracted, &ENTITIES[1]);
|
||||
assert_ptr_equal(
|
||||
cutsceneSystemGetEntity(CUTSCENE_ENTITY_INTERACT), &ENTITIES[0]
|
||||
);
|
||||
assert_ptr_equal(
|
||||
cutsceneSystemGetEntity(CUTSCENE_ENTITY_INTERACTED), &ENTITIES[1]
|
||||
);
|
||||
|
||||
TIME.delta = 2.0f;
|
||||
cutsceneSystemUpdate();// ends the cutscene
|
||||
assert_null(CUTSCENE_SYSTEM.entityInteract);// reset on end
|
||||
assert_null(CUTSCENE_SYSTEM.entityInteracted);
|
||||
}
|
||||
|
||||
static void test_cutsceneSystemGetEntitySentinelsRequireBeingSet(
|
||||
void **state
|
||||
) {
|
||||
|
||||
cutsceneSystemInit();
|
||||
|
||||
expect_assert_failure(cutsceneSystemGetEntity(CUTSCENE_ENTITY_INTERACT));
|
||||
expect_assert_failure(cutsceneSystemGetEntity(CUTSCENE_ENTITY_INTERACTED));
|
||||
expect_assert_failure(cutsceneSystemGetEntity(CUTSCENE_ENTITY_LAST_CREATED));
|
||||
expect_assert_failure(cutsceneSystemGetEntity(CUTSCENE_ENTITY_LAST_REF));
|
||||
}
|
||||
|
||||
static void test_cutsceneSystemGetEntityDirectIndexUpdatesLastRef(
|
||||
void **state
|
||||
) {
|
||||
|
||||
cutsceneSystemInit();
|
||||
entityInit(&ENTITIES[2], ENTITY_TYPE_NPC);
|
||||
|
||||
entity_t *resolved = cutsceneSystemGetEntity(2);
|
||||
assert_ptr_equal(resolved, &ENTITIES[2]);
|
||||
// Resolving by direct index also updates LAST_REF.
|
||||
assert_ptr_equal(cutsceneSystemGetEntity(CUTSCENE_ENTITY_LAST_REF), &ENTITIES[2]);
|
||||
}
|
||||
|
||||
static void test_cutsceneSystemGetAreaId(void **state) {
|
||||
|
||||
cutsceneSystemInit();
|
||||
// cutsceneSystemInit() zero-inits the field -- only actually starting a
|
||||
// cutscene sets it to the "nothing created yet" sentinel.
|
||||
CUTSCENE_SYSTEM.areaLastCreated = CUTSCENE_AREA_LAST_CREATED;
|
||||
|
||||
expect_assert_failure(cutsceneSystemGetAreaId(CUTSCENE_AREA_LAST_CREATED));
|
||||
assert_int_equal(cutsceneSystemGetAreaId(5), 5);// direct IDs pass through
|
||||
|
||||
CUTSCENE_SYSTEM.areaLastCreated = 3;
|
||||
assert_int_equal(cutsceneSystemGetAreaId(CUTSCENE_AREA_LAST_CREATED), 3);
|
||||
}
|
||||
|
||||
static void test_cutsceneSystemGetTextMiniId(void **state) {
|
||||
|
||||
cutsceneSystemInit();
|
||||
CUTSCENE_SYSTEM.textMiniLastCreated = CUTSCENE_TEXT_MINI_LAST_CREATED;
|
||||
|
||||
expect_assert_failure(
|
||||
cutsceneSystemGetTextMiniId(CUTSCENE_TEXT_MINI_LAST_CREATED)
|
||||
);
|
||||
assert_int_equal(cutsceneSystemGetTextMiniId(4), 4);
|
||||
|
||||
CUTSCENE_SYSTEM.textMiniLastCreated = 1;
|
||||
assert_int_equal(
|
||||
cutsceneSystemGetTextMiniId(CUTSCENE_TEXT_MINI_LAST_CREATED), 1
|
||||
);
|
||||
}
|
||||
|
||||
static void test_cutsceneSystemDisposeResetsState(void **state) {
|
||||
|
||||
cutsceneSystemInit();
|
||||
entityInit(&ENTITIES[0], ENTITY_TYPE_PLAYER);
|
||||
cutsceneSystemStartCutsceneWith(
|
||||
&CUTSCENE_TEST_SINGLE_WAIT, &ENTITIES[0], &ENTITIES[0]
|
||||
);
|
||||
|
||||
cutsceneSystemDispose();
|
||||
assert_null(CUTSCENE_SYSTEM.scene);
|
||||
assert_int_equal(CUTSCENE_SYSTEM.currentItem, 0xFF);
|
||||
assert_int_equal(CUTSCENE_SYSTEM.pause, CUTSCENE_PAUSE_NONE);
|
||||
assert_null(CUTSCENE_SYSTEM.entityInteract);
|
||||
assert_null(CUTSCENE_SYSTEM.entityInteracted);
|
||||
}
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
const struct CMUnitTest tests[] = {
|
||||
cmocka_unit_test(test_cutsceneSystemStartSetsUpInitialItem),
|
||||
cmocka_unit_test(test_cutsceneSystemNestedCutsceneIsOneWayJump),
|
||||
cmocka_unit_test(test_cutsceneSystemUpdateIsNoopWithNoActiveCutscene),
|
||||
cmocka_unit_test(test_cutsceneSystemStartWithSetsInteractEntities),
|
||||
cmocka_unit_test(test_cutsceneSystemGetEntitySentinelsRequireBeingSet),
|
||||
cmocka_unit_test(test_cutsceneSystemGetEntityDirectIndexUpdatesLastRef),
|
||||
cmocka_unit_test(test_cutsceneSystemGetAreaId),
|
||||
cmocka_unit_test(test_cutsceneSystemGetTextMiniId),
|
||||
cmocka_unit_test(test_cutsceneSystemDisposeResetsState),
|
||||
};
|
||||
|
||||
return cmocka_run_group_tests(tests, NULL, NULL);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
# Copyright (c) 2026 Dominic Masters
|
||||
#
|
||||
# This software is released under the MIT License.
|
||||
# https://opensource.org/licenses/MIT
|
||||
|
||||
include(dusktest)
|
||||
|
||||
# Tests
|
||||
dusktest(test_entity.c)
|
||||
target_sources(test_entity PRIVATE entitytestfixture.c)
|
||||
|
||||
dusktest(test_npc.c)
|
||||
target_sources(test_npc PRIVATE entitytestfixture.c)
|
||||
|
||||
dusktest(test_entityinteract.c)
|
||||
target_sources(test_entityinteract PRIVATE entitytestfixture.c)
|
||||
|
||||
dusktest(test_entityitem.c)
|
||||
target_sources(test_entityitem PRIVATE entitytestfixture.c)
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Copyright (c) 2026 Dominic Masters
|
||||
*
|
||||
* This software is released under the MIT License.
|
||||
* https://opensource.org/licenses/MIT
|
||||
*/
|
||||
|
||||
#include "entitytestfixture.h"
|
||||
#include "rpg/overworld/map.h"
|
||||
#include "rpg/overworld/tile.h"
|
||||
#include "util/memory.h"
|
||||
|
||||
void entityTestFixtureReset(void) {
|
||||
memoryZero(ENTITIES, sizeof(ENTITIES));
|
||||
|
||||
memoryZero(&MAP, sizeof(map_t));
|
||||
MAP.loaded = true;
|
||||
MAP.chunkPosition = (chunkpos_t){ 0, 0, 0 };
|
||||
|
||||
chunk_t *chunk = &MAP.chunks[0];
|
||||
chunk->position = (chunkpos_t){ 0, 0, 0 };
|
||||
for(uint32_t i = 0; i < CHUNK_TILE_COUNT; i++) {
|
||||
chunk->tiles[i] = (tile_t){ .shape = TILE_SHAPE_GROUND, .z = 0 };
|
||||
}
|
||||
for(uint8_t i = 0; i < CHUNK_ENTITY_COUNT_MAX; i++) {
|
||||
chunk->entities[i] = 0xFF;
|
||||
}
|
||||
|
||||
MAP.chunkOrder[0] = chunk;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Copyright (c) 2026 Dominic Masters
|
||||
*
|
||||
* This software is released under the MIT License.
|
||||
* https://opensource.org/licenses/MIT
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
#include "rpg/entity/entity.h"
|
||||
|
||||
/**
|
||||
* Resets ENTITIES[] to all-empty and builds a single minimal, synchronously
|
||||
* "loaded" chunk at chunk position (0, 0, 0) on the MAP global, entirely
|
||||
* TILE_SHAPE_GROUND at local Z 0. Sufficient for entityWalk/Turn/Run, chunk
|
||||
* bookkeeping, and NPC movement tests -- does not exercise the real async
|
||||
* chunk-loading pipeline (mapChunkLoad).
|
||||
*
|
||||
* World positions with x/y in [0, CHUNK_WIDTH-1]/[0, CHUNK_HEIGHT-1] and
|
||||
* z == 0 fall within the fixture chunk. Individual tests may override
|
||||
* specific columns afterward (e.g. to place a ramp).
|
||||
*/
|
||||
void entityTestFixtureReset(void);
|
||||
@@ -0,0 +1,298 @@
|
||||
/**
|
||||
* Copyright (c) 2026 Dominic Masters
|
||||
*
|
||||
* This software is released under the MIT License.
|
||||
* https://opensource.org/licenses/MIT
|
||||
*/
|
||||
|
||||
#include "dusktest.h"
|
||||
#include "entitytestfixture.h"
|
||||
#include "rpg/overworld/map.h"
|
||||
#include "rpg/overworld/worldpos.h"
|
||||
|
||||
static void setTile(const worldpos_t pos, const tileshape_t shape, const uint8_t z) {
|
||||
chunktileindex_t index = worldPosToChunkTileIndex(&pos);
|
||||
MAP.chunks[0].tiles[index] = (tile_t){ .shape = shape, .z = z };
|
||||
}
|
||||
|
||||
static void test_entityInit(void **state) {
|
||||
|
||||
entityTestFixtureReset();
|
||||
|
||||
entityInit(&ENTITIES[3], ENTITY_TYPE_ITEM);
|
||||
assert_int_equal(ENTITIES[3].id, 3);
|
||||
assert_int_equal(ENTITIES[3].type, ENTITY_TYPE_ITEM);
|
||||
assert_int_equal(ENTITIES[3].chunkIndex, 0xFF);
|
||||
assert_int_equal(ENTITIES[3].globalId, ENTITY_GLOBAL_ID_NULL);
|
||||
// entityItemInit wires the interact callback -- confirms init dispatch ran.
|
||||
assert_int_equal(ENTITIES[3].interact.type, ENTITY_INTERACT_CALLBACK);
|
||||
|
||||
expect_assert_failure(entityInit(NULL, ENTITY_TYPE_ITEM));
|
||||
expect_assert_failure(entityInit(&ENTITIES[0], ENTITY_TYPE_NULL));
|
||||
expect_assert_failure(entityInit(&ENTITIES[0], ENTITY_TYPE_COUNT));
|
||||
|
||||
entity_t offBounds;
|
||||
expect_assert_failure(entityInit(&offBounds, ENTITY_TYPE_ITEM));
|
||||
}
|
||||
|
||||
static void test_entityGetAvailable(void **state) {
|
||||
|
||||
entityTestFixtureReset();
|
||||
|
||||
assert_int_equal(entityGetAvailable(), 0);
|
||||
entityInit(&ENTITIES[0], ENTITY_TYPE_NPC);
|
||||
assert_int_equal(entityGetAvailable(), 1);
|
||||
entityInit(&ENTITIES[1], ENTITY_TYPE_NPC);
|
||||
assert_int_equal(entityGetAvailable(), 2);
|
||||
}
|
||||
|
||||
static void test_entityGetAt(void **state) {
|
||||
|
||||
entityTestFixtureReset();
|
||||
|
||||
entityInit(&ENTITIES[0], ENTITY_TYPE_NPC);
|
||||
ENTITIES[0].position = (worldpos_t){ 3, 4, 0 };
|
||||
|
||||
assert_ptr_equal(entityGetAt((worldpos_t){ 3, 4, 0 }), &ENTITIES[0]);
|
||||
assert_null(entityGetAt((worldpos_t){ 3, 4, 1 }));
|
||||
assert_null(entityGetAt((worldpos_t){ 0, 0, 0 }));
|
||||
}
|
||||
|
||||
static void test_entityGetByGlobalId(void **state) {
|
||||
|
||||
entityTestFixtureReset();
|
||||
|
||||
entityInit(&ENTITIES[2], ENTITY_TYPE_NPC);
|
||||
ENTITIES[2].globalId = 42;
|
||||
|
||||
assert_ptr_equal(entityGetByGlobalId(42), &ENTITIES[2]);
|
||||
assert_null(entityGetByGlobalId(43));
|
||||
assert_null(entityGetByGlobalId(ENTITY_GLOBAL_ID_NULL));
|
||||
}
|
||||
|
||||
static void test_entityCanUnload(void **state) {
|
||||
|
||||
entityTestFixtureReset();
|
||||
|
||||
entityInit(&ENTITIES[0], ENTITY_TYPE_NPC);
|
||||
ENTITIES[0].globalId = ENTITY_GLOBAL_ID_START - 1;
|
||||
assert_true(entityCanUnload(&ENTITIES[0]));
|
||||
|
||||
ENTITIES[0].globalId = ENTITY_GLOBAL_ID_START;
|
||||
assert_false(entityCanUnload(&ENTITIES[0]));
|
||||
}
|
||||
|
||||
static void test_entityCanTurnWalkRun(void **state) {
|
||||
|
||||
entityTestFixtureReset();
|
||||
|
||||
entityInit(&ENTITIES[0], ENTITY_TYPE_NPC);
|
||||
assert_true(entityCanTurn(&ENTITIES[0]));
|
||||
assert_true(entityCanWalk(&ENTITIES[0]));
|
||||
assert_true(entityCanRun(&ENTITIES[0]));
|
||||
|
||||
ENTITIES[0].animation = ENTITY_ANIM_WALK;
|
||||
assert_false(entityCanTurn(&ENTITIES[0]));
|
||||
assert_false(entityCanWalk(&ENTITIES[0]));
|
||||
assert_false(entityCanRun(&ENTITIES[0]));
|
||||
|
||||
ENTITIES[0].animation = ENTITY_ANIM_IDLE;
|
||||
ENTITIES[0].walkEndCooldown = 0.1f;
|
||||
assert_false(entityCanTurn(&ENTITIES[0]));// cooldown blocks turning only
|
||||
assert_true(entityCanWalk(&ENTITIES[0]));
|
||||
}
|
||||
|
||||
static void test_entityTurn(void **state) {
|
||||
|
||||
entityTestFixtureReset();
|
||||
|
||||
entityInit(&ENTITIES[0], ENTITY_TYPE_NPC);
|
||||
entityTurn(&ENTITIES[0], ENTITY_DIR_EAST);
|
||||
assert_int_equal(ENTITIES[0].direction, ENTITY_DIR_EAST);
|
||||
assert_int_equal(ENTITIES[0].animation, ENTITY_ANIM_TURN);
|
||||
|
||||
// Can't turn again mid-turn.
|
||||
ENTITIES[0].direction = ENTITY_DIR_NORTH;
|
||||
entityTurn(&ENTITIES[0], ENTITY_DIR_WEST);
|
||||
assert_int_equal(ENTITIES[0].direction, ENTITY_DIR_NORTH);
|
||||
}
|
||||
|
||||
static void test_entityWalkOnOpenGround(void **state) {
|
||||
|
||||
entityTestFixtureReset();
|
||||
|
||||
entityInit(&ENTITIES[0], ENTITY_TYPE_NPC);
|
||||
ENTITIES[0].position = (worldpos_t){ 5, 5, 0 };
|
||||
|
||||
entityWalk(&ENTITIES[0], ENTITY_DIR_NORTH);
|
||||
assert_int_equal(ENTITIES[0].position.x, 5);
|
||||
assert_int_equal(ENTITIES[0].position.y, 6);
|
||||
assert_int_equal(ENTITIES[0].position.z, 0);
|
||||
assert_int_equal(ENTITIES[0].animation, ENTITY_ANIM_WALK);
|
||||
assert_int_equal(ENTITIES[0].direction, ENTITY_DIR_NORTH);
|
||||
// entityUpdateChunk should have assigned it to the fixture chunk.
|
||||
assert_int_equal(ENTITIES[0].chunkIndex, 0);
|
||||
assert_int_equal(MAP.chunks[0].entities[0], 0);
|
||||
}
|
||||
|
||||
static void test_entityWalkBlockedAtChunkEdge(void **state) {
|
||||
|
||||
entityTestFixtureReset();
|
||||
|
||||
entityInit(&ENTITIES[0], ENTITY_TYPE_NPC);
|
||||
ENTITIES[0].position = (worldpos_t){ 0, 0, 0 };
|
||||
|
||||
// West/south of the loaded chunk is unloaded (TILE_NULL) -- blocked.
|
||||
entityWalk(&ENTITIES[0], ENTITY_DIR_WEST);
|
||||
assert_int_equal(ENTITIES[0].position.x, 0);
|
||||
assert_int_equal(ENTITIES[0].animation, ENTITY_ANIM_IDLE);
|
||||
}
|
||||
|
||||
static void test_entityWalkBlockedByOtherEntity(void **state) {
|
||||
|
||||
entityTestFixtureReset();
|
||||
|
||||
entityInit(&ENTITIES[0], ENTITY_TYPE_NPC);
|
||||
ENTITIES[0].position = (worldpos_t){ 5, 5, 0 };
|
||||
|
||||
entityInit(&ENTITIES[1], ENTITY_TYPE_NPC);
|
||||
ENTITIES[1].position = (worldpos_t){ 5, 6, 0 };// directly north
|
||||
|
||||
entityWalk(&ENTITIES[0], ENTITY_DIR_NORTH);
|
||||
assert_int_equal(ENTITIES[0].position.x, 5);
|
||||
assert_int_equal(ENTITIES[0].position.y, 5);
|
||||
assert_int_equal(ENTITIES[0].animation, ENTITY_ANIM_IDLE);
|
||||
}
|
||||
|
||||
static void test_entityWalkCannotWhileNotIdle(void **state) {
|
||||
|
||||
entityTestFixtureReset();
|
||||
|
||||
entityInit(&ENTITIES[0], ENTITY_TYPE_NPC);
|
||||
ENTITIES[0].position = (worldpos_t){ 5, 5, 0 };
|
||||
ENTITIES[0].animation = ENTITY_ANIM_WALK;
|
||||
|
||||
entityWalk(&ENTITIES[0], ENTITY_DIR_NORTH);
|
||||
assert_int_equal(ENTITIES[0].position.y, 5);// unchanged
|
||||
}
|
||||
|
||||
// Ramp at (5,5) facing north, raised GROUND at (5,6)/z1 -- walking north
|
||||
// off the ramp raises the entity by one Z layer, and walking back south
|
||||
// from the raised tile falls back down onto the ramp.
|
||||
static void setUpRamp(void) {
|
||||
setTile((worldpos_t){ 5, 5, 0 }, TILE_SHAPE_RAMP_NORTH, 0);
|
||||
setTile((worldpos_t){ 5, 6, 0 }, TILE_SHAPE_GROUND, 1);
|
||||
}
|
||||
|
||||
static void test_entityWalkUpRamp(void **state) {
|
||||
|
||||
entityTestFixtureReset();
|
||||
setUpRamp();
|
||||
|
||||
entityInit(&ENTITIES[0], ENTITY_TYPE_NPC);
|
||||
ENTITIES[0].position = (worldpos_t){ 5, 5, 0 };
|
||||
|
||||
entityWalk(&ENTITIES[0], ENTITY_DIR_NORTH);
|
||||
assert_int_equal(ENTITIES[0].position.x, 5);
|
||||
assert_int_equal(ENTITIES[0].position.y, 6);
|
||||
assert_int_equal(ENTITIES[0].position.z, 1);
|
||||
}
|
||||
|
||||
static void test_entityWalkFallDownRamp(void **state) {
|
||||
|
||||
entityTestFixtureReset();
|
||||
setUpRamp();
|
||||
|
||||
entityInit(&ENTITIES[0], ENTITY_TYPE_NPC);
|
||||
ENTITIES[0].position = (worldpos_t){ 5, 6, 1 };
|
||||
|
||||
entityWalk(&ENTITIES[0], ENTITY_DIR_SOUTH);
|
||||
assert_int_equal(ENTITIES[0].position.x, 5);
|
||||
assert_int_equal(ENTITIES[0].position.y, 5);
|
||||
assert_int_equal(ENTITIES[0].position.z, 0);
|
||||
}
|
||||
|
||||
static void test_entityWalkCannotClimbRampFromTheSide(void **state) {
|
||||
|
||||
entityTestFixtureReset();
|
||||
setUpRamp();
|
||||
|
||||
entityInit(&ENTITIES[0], ENTITY_TYPE_NPC);
|
||||
ENTITIES[0].position = (worldpos_t){ 5, 5, 0 };
|
||||
|
||||
// Only NORTH climbs this ramp -- EAST off the ramp tile is just blocked
|
||||
// ground movement, not a climb.
|
||||
entityWalk(&ENTITIES[0], ENTITY_DIR_EAST);
|
||||
assert_int_equal(ENTITIES[0].position.x, 6);
|
||||
assert_int_equal(ENTITIES[0].position.z, 0);
|
||||
}
|
||||
|
||||
static void test_entityRun(void **state) {
|
||||
|
||||
entityTestFixtureReset();
|
||||
|
||||
entityInit(&ENTITIES[0], ENTITY_TYPE_NPC);
|
||||
ENTITIES[0].position = (worldpos_t){ 5, 5, 0 };
|
||||
|
||||
entityRun(&ENTITIES[0], ENTITY_DIR_NORTH);
|
||||
assert_int_equal(ENTITIES[0].position.y, 6);
|
||||
assert_int_equal(ENTITIES[0].animation, ENTITY_ANIM_RUN);
|
||||
}
|
||||
|
||||
static void test_entitySetChunkAndUpdateChunk(void **state) {
|
||||
|
||||
entityTestFixtureReset();
|
||||
|
||||
entityInit(&ENTITIES[0], ENTITY_TYPE_NPC);
|
||||
entitySetChunk(&ENTITIES[0], 0);
|
||||
assert_int_equal(ENTITIES[0].chunkIndex, 0);
|
||||
assert_int_equal(MAP.chunks[0].entities[0], 0);
|
||||
|
||||
entitySetChunk(&ENTITIES[0], 0xFF);
|
||||
assert_int_equal(ENTITIES[0].chunkIndex, 0xFF);
|
||||
assert_int_equal(MAP.chunks[0].entities[0], 0xFF);
|
||||
|
||||
ENTITIES[0].position = (worldpos_t){ 3, 3, 0 };
|
||||
entityUpdateChunk(&ENTITIES[0]);
|
||||
assert_int_equal(ENTITIES[0].chunkIndex, 0);
|
||||
}
|
||||
|
||||
static void test_entityPositionSet(void **state) {
|
||||
|
||||
entityTestFixtureReset();
|
||||
|
||||
entityInit(&ENTITIES[0], ENTITY_TYPE_NPC);
|
||||
ENTITIES[0].animation = ENTITY_ANIM_WALK;
|
||||
ENTITIES[0].walkEndCooldown = 5;
|
||||
|
||||
entityPositionSet(&ENTITIES[0], (worldpos_t){ 7, 8, 0 });
|
||||
assert_int_equal(ENTITIES[0].position.x, 7);
|
||||
assert_int_equal(ENTITIES[0].position.y, 8);
|
||||
assert_int_equal(ENTITIES[0].animation, ENTITY_ANIM_IDLE);
|
||||
assert_int_equal(ENTITIES[0].walkEndCooldown, 0);
|
||||
assert_int_equal(ENTITIES[0].chunkIndex, 0);// updated as a side effect
|
||||
}
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
const struct CMUnitTest tests[] = {
|
||||
cmocka_unit_test(test_entityInit),
|
||||
cmocka_unit_test(test_entityGetAvailable),
|
||||
cmocka_unit_test(test_entityGetAt),
|
||||
cmocka_unit_test(test_entityGetByGlobalId),
|
||||
cmocka_unit_test(test_entityCanUnload),
|
||||
cmocka_unit_test(test_entityCanTurnWalkRun),
|
||||
cmocka_unit_test(test_entityTurn),
|
||||
cmocka_unit_test(test_entityWalkOnOpenGround),
|
||||
cmocka_unit_test(test_entityWalkBlockedAtChunkEdge),
|
||||
cmocka_unit_test(test_entityWalkBlockedByOtherEntity),
|
||||
cmocka_unit_test(test_entityWalkCannotWhileNotIdle),
|
||||
cmocka_unit_test(test_entityWalkUpRamp),
|
||||
cmocka_unit_test(test_entityWalkFallDownRamp),
|
||||
cmocka_unit_test(test_entityWalkCannotClimbRampFromTheSide),
|
||||
cmocka_unit_test(test_entityRun),
|
||||
cmocka_unit_test(test_entitySetChunkAndUpdateChunk),
|
||||
cmocka_unit_test(test_entityPositionSet),
|
||||
};
|
||||
|
||||
return cmocka_run_group_tests(tests, NULL, NULL);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Copyright (c) 2026 Dominic Masters
|
||||
*
|
||||
* This software is released under the MIT License.
|
||||
* https://opensource.org/licenses/MIT
|
||||
*/
|
||||
|
||||
#include "dusktest.h"
|
||||
#include "entitytestfixture.h"
|
||||
#include "ui/rpg/textbox/uitextboxmain.h"
|
||||
#include "ui/focus/uifocus.h"
|
||||
#include "rpg/cutscene/cutscenesystem.h"
|
||||
|
||||
static const cutsceneitem_t CUTSCENE_TEST_INTERACT_ITEMS[] = {
|
||||
CUTSCENE_WAIT(1.0f)
|
||||
};
|
||||
static const cutscene_t CUTSCENE_TEST_INTERACT = {
|
||||
.items = CUTSCENE_TEST_INTERACT_ITEMS,
|
||||
.itemCount = 1,
|
||||
.pause = CUTSCENE_PAUSE_NONE,
|
||||
.dataSize = 0
|
||||
};
|
||||
|
||||
static entity_t *player;
|
||||
static entity_t *target;
|
||||
|
||||
static void resetInteractFixture(void) {
|
||||
entityTestFixtureReset();
|
||||
uiFocusInit();
|
||||
uiTextboxMainInit();
|
||||
uiTextboxMainFocusClosed(NULL);// force-clear stale focus from a prior test
|
||||
cutsceneSystemInit();
|
||||
|
||||
entityInit(&ENTITIES[0], ENTITY_TYPE_PLAYER);
|
||||
entityInit(&ENTITIES[1], ENTITY_TYPE_NPC);
|
||||
player = &ENTITIES[0];
|
||||
target = &ENTITIES[1];
|
||||
player->direction = ENTITY_DIR_NORTH;
|
||||
}
|
||||
|
||||
static void test_entityInteractWithNull(void **state) {
|
||||
|
||||
resetInteractFixture();
|
||||
|
||||
target->interact.type = ENTITY_INTERACT_NULL;
|
||||
entityInteractWith(player, target);// should not crash or change anything
|
||||
assert_int_equal(target->interact.type, ENTITY_INTERACT_NULL);
|
||||
}
|
||||
|
||||
static void test_entityInteractWithPrintTurnsNpcToFacePlayer(void **state) {
|
||||
|
||||
resetInteractFixture();
|
||||
|
||||
target->interact.type = ENTITY_INTERACT_PRINT;
|
||||
target->interact.data.message = "Hello!";
|
||||
|
||||
entityInteractWith(player, target);
|
||||
|
||||
assert_true(uiTextboxMainIsActive());
|
||||
assert_int_equal(target->data.npc.interactState, NPC_INTERACT_STATE_CONVERSING);
|
||||
// NPC turns to face the opposite of the player's facing direction.
|
||||
assert_int_equal(target->direction, entityDirGetOpposite(player->direction));
|
||||
}
|
||||
|
||||
static entity_t *callbackPlayerArg;
|
||||
static entity_t *callbackTargetArg;
|
||||
static uint8_t callbackCount;
|
||||
|
||||
static void recordInteractCallback(entity_t *p, entity_t *t) {
|
||||
callbackPlayerArg = p;
|
||||
callbackTargetArg = t;
|
||||
callbackCount++;
|
||||
}
|
||||
|
||||
static void test_entityInteractWithCallback(void **state) {
|
||||
|
||||
resetInteractFixture();
|
||||
callbackCount = 0;
|
||||
|
||||
target->interact.type = ENTITY_INTERACT_CALLBACK;
|
||||
target->interact.data.callback = recordInteractCallback;
|
||||
|
||||
entityInteractWith(player, target);
|
||||
|
||||
assert_int_equal(callbackCount, 1);
|
||||
assert_ptr_equal(callbackPlayerArg, player);
|
||||
assert_ptr_equal(callbackTargetArg, target);
|
||||
}
|
||||
|
||||
static void test_entityInteractWithCallbackRequiresNonNull(void **state) {
|
||||
|
||||
resetInteractFixture();
|
||||
|
||||
target->interact.type = ENTITY_INTERACT_CALLBACK;
|
||||
target->interact.data.callback = NULL;
|
||||
|
||||
expect_assert_failure(entityInteractWith(player, target));
|
||||
}
|
||||
|
||||
static void test_entityInteractWithCutsceneStartsIt(void **state) {
|
||||
|
||||
resetInteractFixture();
|
||||
|
||||
target->interact.type = ENTITY_INTERACT_CUTSCENE;
|
||||
target->interact.data.cutscene = &CUTSCENE_TEST_INTERACT;
|
||||
|
||||
entityInteractWith(player, target);
|
||||
|
||||
assert_ptr_equal(CUTSCENE_SYSTEM.scene, &CUTSCENE_TEST_INTERACT);
|
||||
assert_ptr_equal(CUTSCENE_SYSTEM.entityInteract, player);
|
||||
assert_ptr_equal(CUTSCENE_SYSTEM.entityInteracted, target);
|
||||
}
|
||||
|
||||
static void test_entityInteractWithRequiresNonNullEntities(void **state) {
|
||||
|
||||
resetInteractFixture();
|
||||
|
||||
expect_assert_failure(entityInteractWith(NULL, target));
|
||||
expect_assert_failure(entityInteractWith(player, NULL));
|
||||
}
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
const struct CMUnitTest tests[] = {
|
||||
cmocka_unit_test(test_entityInteractWithNull),
|
||||
cmocka_unit_test(test_entityInteractWithPrintTurnsNpcToFacePlayer),
|
||||
cmocka_unit_test(test_entityInteractWithCallback),
|
||||
cmocka_unit_test(test_entityInteractWithCallbackRequiresNonNull),
|
||||
cmocka_unit_test(test_entityInteractWithCutsceneStartsIt),
|
||||
cmocka_unit_test(test_entityInteractWithRequiresNonNullEntities),
|
||||
};
|
||||
|
||||
return cmocka_run_group_tests(tests, NULL, NULL);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Copyright (c) 2026 Dominic Masters
|
||||
*
|
||||
* This software is released under the MIT License.
|
||||
* https://opensource.org/licenses/MIT
|
||||
*/
|
||||
|
||||
#include "dusktest.h"
|
||||
#include "entitytestfixture.h"
|
||||
#include "ui/rpg/textbox/uitextboxmain.h"
|
||||
#include "ui/focus/uifocus.h"
|
||||
|
||||
static entity_t *setUpItemEntity(void) {
|
||||
entityTestFixtureReset();
|
||||
uiFocusInit();
|
||||
uiTextboxMainInit();
|
||||
uiTextboxMainFocusClosed(NULL);// force-clear stale focus from a prior test
|
||||
|
||||
entityInit(&ENTITIES[0], ENTITY_TYPE_ITEM);
|
||||
return &ENTITIES[0];
|
||||
}
|
||||
|
||||
static void test_entityItemInit(void **state) {
|
||||
|
||||
entity_t *item = setUpItemEntity();
|
||||
|
||||
assert_int_equal(item->interact.type, ENTITY_INTERACT_CALLBACK);
|
||||
assert_ptr_equal(item->interact.data.callback, entityItemInteract);
|
||||
}
|
||||
|
||||
static void test_entityItemSet(void **state) {
|
||||
|
||||
entity_t *item = setUpItemEntity();
|
||||
|
||||
entityItemSet(item, ITEM_ID_POTION, 3);
|
||||
assert_int_equal(item->data.item.item, ITEM_ID_POTION);
|
||||
assert_int_equal(item->data.item.quantity, 3);
|
||||
assert_false(item->data.item.collected);
|
||||
}
|
||||
|
||||
// entityItemInteract itself is NOT covered here: it unconditionally calls
|
||||
// itemGive(), which calls itemGetName(), which dereferences
|
||||
// LOCALE.entry->data.locale -- a real locale asset populated only by the
|
||||
// async asset-loading pipeline (see localemanager.h/assetlocaleloader.h).
|
||||
// That's exactly the asset-loading dependency this test pass scoped out;
|
||||
// faking it cheaply isn't possible without hand-building the loader's
|
||||
// internal hash format, which is more coupling than it's worth. Backpack
|
||||
// bookkeeping itself is already covered by test/item/test_inventory.c-style
|
||||
// tests at the inventory/backpack layer.
|
||||
|
||||
static void test_entityItemMovementWaitsForCollectionAndTextbox(void **state) {
|
||||
|
||||
entity_t *item = setUpItemEntity();
|
||||
|
||||
// Not collected yet -- stays.
|
||||
entityItemMovement(item);
|
||||
assert_int_equal(item->type, ENTITY_TYPE_ITEM);
|
||||
|
||||
// Collected (set directly -- see note above on why entityItemInteract
|
||||
// itself isn't driven here), but a message is still showing -- stays.
|
||||
item->data.item.collected = true;
|
||||
uiTextboxMainSetText("Picked up!");
|
||||
entityItemMovement(item);
|
||||
assert_int_equal(item->type, ENTITY_TYPE_ITEM);
|
||||
|
||||
// Once the textbox is dismissed, the entity despawns.
|
||||
uiFocusPop();
|
||||
assert_false(uiTextboxMainIsActive());
|
||||
entityItemMovement(item);
|
||||
assert_int_equal(item->type, ENTITY_TYPE_NULL);
|
||||
}
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
const struct CMUnitTest tests[] = {
|
||||
cmocka_unit_test(test_entityItemInit),
|
||||
cmocka_unit_test(test_entityItemSet),
|
||||
cmocka_unit_test(test_entityItemMovementWaitsForCollectionAndTextbox),
|
||||
};
|
||||
|
||||
return cmocka_run_group_tests(tests, NULL, NULL);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* Copyright (c) 2026 Dominic Masters
|
||||
*
|
||||
* This software is released under the MIT License.
|
||||
* https://opensource.org/licenses/MIT
|
||||
*/
|
||||
|
||||
#include "dusktest.h"
|
||||
#include "entitytestfixture.h"
|
||||
#include "rpg/cutscene/cutscenesystem.h"
|
||||
#include "time/time.h"
|
||||
|
||||
static entity_t *setUpNpc(const worldpos_t position) {
|
||||
entityTestFixtureReset();
|
||||
entityInit(&ENTITIES[0], ENTITY_TYPE_NPC);
|
||||
ENTITIES[0].position = position;
|
||||
return &ENTITIES[0];
|
||||
}
|
||||
|
||||
static void test_npcSetMoveType(void **state) {
|
||||
|
||||
entity_t *npc = setUpNpc((worldpos_t){ 8, 8, 0 });
|
||||
|
||||
npcSetMoveType(npc, NPC_MOVE_TYPE_RANDOM_TURN);
|
||||
assert_int_equal(npc->data.npc.moveType, NPC_MOVE_TYPE_RANDOM_TURN);
|
||||
// Init ran: timer was seeded within the default frequency range.
|
||||
assert_true(npc->data.npc.moveData.randomTurn.timer > 0.0f);
|
||||
}
|
||||
|
||||
static void test_npcRandomTurnMovementFiresOnceTimerElapses(void **state) {
|
||||
|
||||
entity_t *npc = setUpNpc((worldpos_t){ 8, 8, 0 });
|
||||
npcSetMoveType(npc, NPC_MOVE_TYPE_RANDOM_TURN);
|
||||
npc->data.npc.moveData.randomTurn.timer = 0.05f;
|
||||
|
||||
TIME.delta = 0.01f;
|
||||
npcRandomTurnMovement(npc);
|
||||
assert_int_equal(npc->animation, ENTITY_ANIM_IDLE);// timer not elapsed yet
|
||||
|
||||
TIME.delta = 1.0f;
|
||||
npcRandomTurnMovement(npc);
|
||||
assert_int_equal(npc->animation, ENTITY_ANIM_TURN);
|
||||
// Timer is reseeded, not left at/below zero.
|
||||
assert_true(npc->data.npc.moveData.randomTurn.timer > 0.0f);
|
||||
}
|
||||
|
||||
static void test_npcRandomWalkMovementFiresOnceTimerElapses(void **state) {
|
||||
|
||||
entity_t *npc = setUpNpc((worldpos_t){ 8, 8, 0 });
|
||||
npcSetMoveType(npc, NPC_MOVE_TYPE_RANDOM_WALK);
|
||||
npc->data.npc.moveData.randomWalk.timer = 0.05f;
|
||||
|
||||
TIME.delta = 0.01f;
|
||||
npcRandomWalkMovement(npc);
|
||||
assert_int_equal(npc->animation, ENTITY_ANIM_IDLE);
|
||||
|
||||
TIME.delta = 1.0f;
|
||||
npcRandomWalkMovement(npc);
|
||||
// Open ground on all sides -- whichever direction is chosen, it moves.
|
||||
assert_int_equal(npc->animation, ENTITY_ANIM_WALK);
|
||||
}
|
||||
|
||||
static void test_npcRandomTurnAndWalkMovementRunsBothIndependently(
|
||||
void **state
|
||||
) {
|
||||
|
||||
entity_t *npc = setUpNpc((worldpos_t){ 8, 8, 0 });
|
||||
npcSetMoveType(npc, NPC_MOVE_TYPE_RANDOM_TURN_AND_WALK);
|
||||
npcrandomturnandwalk_t *tw = &npc->data.npc.moveData.randomTurnAndWalk;
|
||||
tw->turn.timer = 999.0f;// don't fire this tick
|
||||
tw->walk.timer = 0.05f;// fires this tick
|
||||
|
||||
TIME.delta = 1.0f;
|
||||
npcRandomTurnAndWalkMovement(npc);
|
||||
assert_int_equal(npc->animation, ENTITY_ANIM_WALK);
|
||||
assert_true(tw->turn.timer > 900.0f);// unaffected by the walk firing
|
||||
}
|
||||
|
||||
static void test_npcMovementGatedByCutscenePause(void **state) {
|
||||
|
||||
entity_t *npc = setUpNpc((worldpos_t){ 8, 8, 0 });
|
||||
npcSetMoveType(npc, NPC_MOVE_TYPE_RANDOM_WALK);
|
||||
npc->data.npc.moveData.randomWalk.timer = 0.0f;
|
||||
TIME.delta = 1.0f;
|
||||
|
||||
CUTSCENE_SYSTEM.pause = CUTSCENE_PAUSE_NPC;
|
||||
npcMovement(npc);
|
||||
assert_int_equal(npc->animation, ENTITY_ANIM_IDLE);// blocked by pause
|
||||
|
||||
CUTSCENE_SYSTEM.pause = CUTSCENE_PAUSE_NONE;
|
||||
npcMovement(npc);
|
||||
assert_int_equal(npc->animation, ENTITY_ANIM_WALK);
|
||||
}
|
||||
|
||||
static void test_npcMovementGatedByInteractState(void **state) {
|
||||
|
||||
entity_t *npc = setUpNpc((worldpos_t){ 8, 8, 0 });
|
||||
npcSetMoveType(npc, NPC_MOVE_TYPE_RANDOM_WALK);
|
||||
npc->data.npc.moveData.randomWalk.timer = 0.0f;
|
||||
TIME.delta = 1.0f;
|
||||
|
||||
npc->data.npc.interactState = NPC_INTERACT_STATE_CONVERSING;
|
||||
npcMovement(npc);
|
||||
assert_int_equal(npc->animation, ENTITY_ANIM_IDLE);// blocked while conversing
|
||||
|
||||
npc->data.npc.interactState = NPC_INTERACT_STATE_NONE;
|
||||
npcMovement(npc);
|
||||
assert_int_equal(npc->animation, ENTITY_ANIM_WALK);
|
||||
}
|
||||
|
||||
static void test_npcPathMovementFollowsAndLoopsWaypoints(void **state) {
|
||||
|
||||
entity_t *npc = setUpNpc((worldpos_t){ 5, 5, 0 });
|
||||
npcSetMoveType(npc, NPC_MOVE_TYPE_PATH);
|
||||
npcpath_t *path = &npc->data.npc.moveData.path;
|
||||
npcPathAddNode(&npc->data.npc, (worldpos_t){ 5, 6, 0 });
|
||||
npcPathAddNode(&npc->data.npc, (worldpos_t){ 5, 5, 0 });
|
||||
|
||||
npcPathMovement(npc);// steps toward waypoint 0
|
||||
assert_int_equal(npc->position.x, 5);
|
||||
assert_int_equal(npc->position.y, 6);
|
||||
assert_int_equal(path->index, 0);// not yet considered "arrived"
|
||||
|
||||
npc->animation = ENTITY_ANIM_IDLE;// simulate the walk animation finishing
|
||||
npcPathMovement(npc);// arrives at waypoint 0, advances, steps toward wp 1
|
||||
assert_int_equal(path->index, 1);
|
||||
assert_int_equal(npc->position.x, 5);
|
||||
assert_int_equal(npc->position.y, 5);
|
||||
}
|
||||
|
||||
static void test_npcPathMovementNoopWithEmptyPath(void **state) {
|
||||
|
||||
entity_t *npc = setUpNpc((worldpos_t){ 5, 5, 0 });
|
||||
npcSetMoveType(npc, NPC_MOVE_TYPE_PATH);
|
||||
|
||||
npcPathMovement(npc);
|
||||
assert_int_equal(npc->position.x, 5);
|
||||
assert_int_equal(npc->position.y, 5);
|
||||
assert_int_equal(npc->animation, ENTITY_ANIM_IDLE);
|
||||
}
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
const struct CMUnitTest tests[] = {
|
||||
cmocka_unit_test(test_npcSetMoveType),
|
||||
cmocka_unit_test(test_npcRandomTurnMovementFiresOnceTimerElapses),
|
||||
cmocka_unit_test(test_npcRandomWalkMovementFiresOnceTimerElapses),
|
||||
cmocka_unit_test(test_npcRandomTurnAndWalkMovementRunsBothIndependently),
|
||||
cmocka_unit_test(test_npcMovementGatedByCutscenePause),
|
||||
cmocka_unit_test(test_npcMovementGatedByInteractState),
|
||||
cmocka_unit_test(test_npcPathMovementFollowsAndLoopsWaypoints),
|
||||
cmocka_unit_test(test_npcPathMovementNoopWithEmptyPath),
|
||||
};
|
||||
|
||||
return cmocka_run_group_tests(tests, NULL, NULL);
|
||||
}
|
||||
@@ -6,5 +6,6 @@
|
||||
include(dusktest)
|
||||
|
||||
# Tests
|
||||
dusktest(test_maparea.c)
|
||||
|
||||
# Subdirs
|
||||
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* Copyright (c) 2026 Dominic Masters
|
||||
*
|
||||
* This software is released under the MIT License.
|
||||
* https://opensource.org/licenses/MIT
|
||||
*/
|
||||
|
||||
#include "dusktest.h"
|
||||
#include "rpg/overworld/maparea.h"
|
||||
#include "rpg/overworld/map.h"
|
||||
#include "rpg/entity/entity.h"
|
||||
#include "util/memory.h"
|
||||
|
||||
static uint8_t callbackCount;
|
||||
static uint8_t lastTrigger;
|
||||
|
||||
static void recordCallback(entity_t *entity, const uint8_t trigger) {
|
||||
callbackCount++;
|
||||
lastTrigger = trigger;
|
||||
}
|
||||
|
||||
static void resetMapAreas(void) {
|
||||
memoryZero(MAP_AREAS, sizeof(MAP_AREAS));
|
||||
callbackCount = 0;
|
||||
lastTrigger = 0;
|
||||
}
|
||||
|
||||
static void test_mapAreaInit(void **state) {
|
||||
|
||||
|
||||
maparea_t area;
|
||||
const worldpos_t min = { 5, 5, 0 };
|
||||
const worldpos_t max = { 0, 0, 0 };
|
||||
|
||||
// min/max should be normalized regardless of argument order.
|
||||
mapAreaInit(&area, min, max, recordCallback, MAP_AREA_NOTIFY_ALL, MAP_TRIGGER_ALL);
|
||||
assert_int_equal(area.min.x, 0);
|
||||
assert_int_equal(area.min.y, 0);
|
||||
assert_int_equal(area.max.x, 5);
|
||||
assert_int_equal(area.max.y, 5);
|
||||
assert_int_equal(area.triggerCount, 0);
|
||||
|
||||
expect_assert_failure(
|
||||
mapAreaInit(&area, min, max, NULL, MAP_AREA_NOTIFY_ALL, MAP_TRIGGER_ALL)
|
||||
);
|
||||
}
|
||||
|
||||
static void test_mapAreaIsInside(void **state) {
|
||||
|
||||
|
||||
maparea_t area;
|
||||
mapAreaInit(
|
||||
&area, (worldpos_t){ 0, 0, 0 }, (worldpos_t){ 10, 10, 0 },
|
||||
recordCallback, MAP_AREA_NOTIFY_ALL, MAP_TRIGGER_ALL
|
||||
);
|
||||
|
||||
assert_true(mapAreaIsInside(&area, (worldpos_t){ 5, 5, 0 }));
|
||||
assert_true(mapAreaIsInside(&area, (worldpos_t){ 0, 0, 0 }));// inclusive min
|
||||
assert_true(mapAreaIsInside(&area, (worldpos_t){ 10, 10, 0 }));// inclusive max
|
||||
assert_false(mapAreaIsInside(&area, (worldpos_t){ 11, 5, 0 }));
|
||||
assert_false(mapAreaIsInside(&area, (worldpos_t){ 5, 5, 1 }));
|
||||
}
|
||||
|
||||
static void test_mapAreaShouldNotify(void **state) {
|
||||
|
||||
|
||||
maparea_t area;
|
||||
mapAreaInit(
|
||||
&area, (worldpos_t){ 0, 0, 0 }, (worldpos_t){ 10, 10, 0 },
|
||||
recordCallback, MAP_AREA_NOTIFY_PLAYER, MAP_TRIGGER_ALL
|
||||
);
|
||||
|
||||
entityInit(&ENTITIES[0], ENTITY_TYPE_PLAYER);
|
||||
entityInit(&ENTITIES[1], ENTITY_TYPE_NPC);
|
||||
entityInit(&ENTITIES[2], ENTITY_TYPE_ITEM);
|
||||
|
||||
assert_true(mapAreaShouldNotify(&area, &ENTITIES[0]));
|
||||
assert_false(mapAreaShouldNotify(&area, &ENTITIES[1]));// not notified
|
||||
assert_false(mapAreaShouldNotify(&area, &ENTITIES[2]));// item never notifies
|
||||
|
||||
area.notify = MAP_AREA_NOTIFY_ALL;
|
||||
assert_true(mapAreaShouldNotify(&area, &ENTITIES[1]));
|
||||
}
|
||||
|
||||
static void test_mapAreaAddAndRemove(void **state) {
|
||||
|
||||
resetMapAreas();
|
||||
|
||||
uint8_t id = mapAreaAdd(
|
||||
(worldpos_t){ 0, 0, 0 }, (worldpos_t){ 5, 5, 0 },
|
||||
recordCallback, MAP_AREA_NOTIFY_ALL, MAP_TRIGGER_ALL
|
||||
);
|
||||
assert_int_equal(id, 0);
|
||||
assert_ptr_equal(MAP_AREAS[0].callback, recordCallback);
|
||||
|
||||
// Adding again should reuse the next free slot, not the same one.
|
||||
uint8_t id2 = mapAreaAdd(
|
||||
(worldpos_t){ 0, 0, 0 }, (worldpos_t){ 5, 5, 0 },
|
||||
recordCallback, MAP_AREA_NOTIFY_ALL, MAP_TRIGGER_ALL
|
||||
);
|
||||
assert_int_equal(id2, 1);
|
||||
|
||||
mapAreaRemove(id);
|
||||
assert_null(MAP_AREAS[0].callback);// slot freed
|
||||
|
||||
// Removing frees the slot for reuse.
|
||||
uint8_t id3 = mapAreaAdd(
|
||||
(worldpos_t){ 0, 0, 0 }, (worldpos_t){ 5, 5, 0 },
|
||||
recordCallback, MAP_AREA_NOTIFY_ALL, MAP_TRIGGER_ALL
|
||||
);
|
||||
assert_int_equal(id3, 0);
|
||||
}
|
||||
|
||||
static void test_mapAreaCheckEntityTriggersEnterStepExit(void **state) {
|
||||
|
||||
resetMapAreas();
|
||||
|
||||
mapAreaAdd(
|
||||
(worldpos_t){ 0, 0, 0 }, (worldpos_t){ 5, 5, 0 },
|
||||
recordCallback, MAP_AREA_NOTIFY_ALL, MAP_TRIGGER_ALL
|
||||
);
|
||||
|
||||
entityInit(&ENTITIES[0], ENTITY_TYPE_PLAYER);
|
||||
ENTITIES[0].position = (worldpos_t){ 10, 10, 0 };// outside
|
||||
|
||||
// Outside -> outside: no callback.
|
||||
mapAreaCheckEntity(&ENTITIES[0]);
|
||||
assert_int_equal(callbackCount, 0);
|
||||
assert_int_equal(MAP_AREAS[0].triggerCount, 0);
|
||||
|
||||
// Outside -> inside: ENTER.
|
||||
ENTITIES[0].position = (worldpos_t){ 2, 2, 0 };
|
||||
mapAreaCheckEntity(&ENTITIES[0]);
|
||||
assert_int_equal(callbackCount, 1);
|
||||
assert_int_equal(lastTrigger, MAP_TRIGGER_ENTER);
|
||||
assert_int_equal(MAP_AREAS[0].triggerCount, 1);
|
||||
|
||||
// Inside -> inside: STEP, every subsequent call.
|
||||
mapAreaCheckEntity(&ENTITIES[0]);
|
||||
assert_int_equal(callbackCount, 2);
|
||||
assert_int_equal(lastTrigger, MAP_TRIGGER_STEP);
|
||||
|
||||
mapAreaCheckEntity(&ENTITIES[0]);
|
||||
assert_int_equal(callbackCount, 3);
|
||||
assert_int_equal(lastTrigger, MAP_TRIGGER_STEP);
|
||||
|
||||
// Inside -> outside: EXIT.
|
||||
ENTITIES[0].position = (worldpos_t){ 10, 10, 0 };
|
||||
mapAreaCheckEntity(&ENTITIES[0]);
|
||||
assert_int_equal(callbackCount, 4);
|
||||
assert_int_equal(lastTrigger, MAP_TRIGGER_EXIT);
|
||||
}
|
||||
|
||||
static void test_mapAreaCheckEntityRespectsTriggerMask(void **state) {
|
||||
|
||||
resetMapAreas();
|
||||
|
||||
// Only interested in ENTER, not STEP or EXIT.
|
||||
mapAreaAdd(
|
||||
(worldpos_t){ 0, 0, 0 }, (worldpos_t){ 5, 5, 0 },
|
||||
recordCallback, MAP_AREA_NOTIFY_ALL, MAP_TRIGGER_ENTER
|
||||
);
|
||||
|
||||
entityInit(&ENTITIES[0], ENTITY_TYPE_PLAYER);
|
||||
ENTITIES[0].position = (worldpos_t){ 2, 2, 0 };
|
||||
|
||||
mapAreaCheckEntity(&ENTITIES[0]);// ENTER: fires
|
||||
assert_int_equal(callbackCount, 1);
|
||||
|
||||
mapAreaCheckEntity(&ENTITIES[0]);// STEP: masked out
|
||||
assert_int_equal(callbackCount, 1);
|
||||
|
||||
ENTITIES[0].position = (worldpos_t){ 10, 10, 0 };
|
||||
mapAreaCheckEntity(&ENTITIES[0]);// EXIT: masked out
|
||||
assert_int_equal(callbackCount, 1);
|
||||
}
|
||||
|
||||
static void test_mapAreaCanUnload(void **state) {
|
||||
|
||||
resetMapAreas();
|
||||
memoryZero(&MAP, sizeof(map_t));
|
||||
|
||||
maparea_t area;
|
||||
mapAreaInit(
|
||||
&area, (worldpos_t){ 0, 0, 0 }, (worldpos_t){ 5, 5, 0 },
|
||||
recordCallback, MAP_AREA_NOTIFY_ALL, MAP_TRIGGER_ALL
|
||||
);
|
||||
|
||||
// No chunk overlaps the area's bounds (all chunks default to position 0,
|
||||
// 0, 0 after memoryZero -- move them far away first).
|
||||
for(chunkindex_t i = 0; i < MAP_CHUNK_COUNT; i++) {
|
||||
MAP.chunks[i].position = (chunkpos_t){ 100, 100, 100 };
|
||||
}
|
||||
assert_true(mapAreaCanUnload(&area));
|
||||
|
||||
// One chunk overlapping the area's bounds blocks unload.
|
||||
MAP.chunks[0].position = (chunkpos_t){ 0, 0, 0 };
|
||||
assert_false(mapAreaCanUnload(&area));
|
||||
}
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
const struct CMUnitTest tests[] = {
|
||||
cmocka_unit_test(test_mapAreaInit),
|
||||
cmocka_unit_test(test_mapAreaIsInside),
|
||||
cmocka_unit_test(test_mapAreaShouldNotify),
|
||||
cmocka_unit_test(test_mapAreaAddAndRemove),
|
||||
cmocka_unit_test(test_mapAreaCheckEntityTriggersEnterStepExit),
|
||||
cmocka_unit_test(test_mapAreaCheckEntityRespectsTriggerMask),
|
||||
cmocka_unit_test(test_mapAreaCanUnload),
|
||||
};
|
||||
|
||||
return cmocka_run_group_tests(tests, NULL, NULL);
|
||||
}
|
||||
Reference in New Issue
Block a user