From 1bd73d69feacd8414e5fd29bebacb3b070fadcae Mon Sep 17 00:00:00 2001 From: Dominic Masters Date: Thu, 6 Aug 2026 12:58:07 -0500 Subject: [PATCH] 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 --- src/dusk/animation/CMakeLists.txt | 1 + src/dusk/animation/keyframe.c | 54 ++++ src/dusk/animation/keyframe.h | 16 +- src/dusk/assert/assert.h | 2 + src/dusk/save/save.c | 15 ++ src/dusk/save/save.h | 9 + src/dusk/save/saveslot.h | 11 + src/dusk/scene/CMakeLists.txt | 1 + src/dusk/scene/battle/scenebattle.c | 127 ++++++++- src/dusk/scene/battle/scenebattle.h | 66 ++++- src/dusk/scene/initial/sceneinitial.c | 8 +- src/dusk/scene/initial/sceneinitial.h | 2 +- src/dusk/scene/mainmenu/CMakeLists.txt | 9 + src/dusk/scene/mainmenu/scenemainmenu.c | 24 ++ src/dusk/scene/mainmenu/scenemainmenu.h | 51 ++++ src/dusk/scene/scenetype.c | 7 + src/dusk/scene/scenetype.h | 3 + src/dusk/ui/frame/CMakeLists.txt | 1 + src/dusk/ui/frame/battle/CMakeLists.txt | 1 + src/dusk/ui/frame/battle/uibattlehud.c | 56 ++++ src/dusk/ui/frame/battle/uibattlehud.h | 17 ++ src/dusk/ui/frame/mainmenu/CMakeLists.txt | 9 + src/dusk/ui/frame/mainmenu/uimainmenu.c | 142 +++++++++++ src/dusk/ui/frame/mainmenu/uimainmenu.h | 50 ++++ src/dusk/ui/uielement.c | 11 + test/CMakeLists.txt | 1 + test/animation/CMakeLists.txt | 9 + test/animation/test_keyframe.c | 180 +++++++++++++ test/rpg/CMakeLists.txt | 4 +- test/rpg/cutscene/CMakeLists.txt | 11 + test/rpg/cutscene/test_cutscenecontrol.c | 142 +++++++++++ test/rpg/cutscene/test_cutscenemaparea.c | 143 +++++++++++ test/rpg/cutscene/test_cutscenesystem.c | 192 ++++++++++++++ test/rpg/entity/CMakeLists.txt | 19 ++ test/rpg/entity/entitytestfixture.c | 30 +++ test/rpg/entity/entitytestfixture.h | 22 ++ test/rpg/entity/test_entity.c | 298 ++++++++++++++++++++++ test/rpg/entity/test_entityinteract.c | 133 ++++++++++ test/rpg/entity/test_entityitem.c | 81 ++++++ test/rpg/entity/test_npc.c | 155 +++++++++++ test/rpg/overworld/CMakeLists.txt | 1 + test/rpg/overworld/test_maparea.c | 213 ++++++++++++++++ 42 files changed, 2318 insertions(+), 9 deletions(-) create mode 100644 src/dusk/animation/keyframe.c create mode 100644 src/dusk/scene/mainmenu/CMakeLists.txt create mode 100644 src/dusk/scene/mainmenu/scenemainmenu.c create mode 100644 src/dusk/scene/mainmenu/scenemainmenu.h create mode 100644 src/dusk/ui/frame/battle/uibattlehud.c create mode 100644 src/dusk/ui/frame/battle/uibattlehud.h create mode 100644 src/dusk/ui/frame/mainmenu/CMakeLists.txt create mode 100644 src/dusk/ui/frame/mainmenu/uimainmenu.c create mode 100644 src/dusk/ui/frame/mainmenu/uimainmenu.h create mode 100644 test/animation/CMakeLists.txt create mode 100644 test/animation/test_keyframe.c create mode 100644 test/rpg/cutscene/CMakeLists.txt create mode 100644 test/rpg/cutscene/test_cutscenecontrol.c create mode 100644 test/rpg/cutscene/test_cutscenemaparea.c create mode 100644 test/rpg/cutscene/test_cutscenesystem.c create mode 100644 test/rpg/entity/CMakeLists.txt create mode 100644 test/rpg/entity/entitytestfixture.c create mode 100644 test/rpg/entity/entitytestfixture.h create mode 100644 test/rpg/entity/test_entity.c create mode 100644 test/rpg/entity/test_entityinteract.c create mode 100644 test/rpg/entity/test_entityitem.c create mode 100644 test/rpg/entity/test_npc.c create mode 100644 test/rpg/overworld/test_maparea.c diff --git a/src/dusk/animation/CMakeLists.txt b/src/dusk/animation/CMakeLists.txt index 7696f9c4..a03d91ad 100644 --- a/src/dusk/animation/CMakeLists.txt +++ b/src/dusk/animation/CMakeLists.txt @@ -7,4 +7,5 @@ target_sources(${DUSK_LIBRARY_TARGET_NAME} PUBLIC easing.c animation.c + keyframe.c ) diff --git a/src/dusk/animation/keyframe.c b/src/dusk/animation/keyframe.c new file mode 100644 index 00000000..c1b77d1a --- /dev/null +++ b/src/dusk/animation/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)); +} \ No newline at end of file diff --git a/src/dusk/animation/keyframe.h b/src/dusk/animation/keyframe.h index 6ea03984..96a0a4f6 100644 --- a/src/dusk/animation/keyframe.h +++ b/src/dusk/animation/keyframe.h @@ -10,4 +10,18 @@ typedef struct { float_t time; float_t value; easingtype_t easing; -} keyframe_t; \ No newline at end of file +} 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 +); \ No newline at end of file diff --git a/src/dusk/assert/assert.h b/src/dusk/assert/assert.h index 3db4dc62..b3fb2a21 100644 --- a/src/dusk/assert/assert.h +++ b/src/dusk/assert/assert.h @@ -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. diff --git a/src/dusk/save/save.c b/src/dusk/save/save.c index 202579ff..07bfa51d 100644 --- a/src/dusk/save/save.c +++ b/src/dusk/save/save.c @@ -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"); diff --git a/src/dusk/save/save.h b/src/dusk/save/save.h index 19809b80..483dcf43 100644 --- a/src/dusk/save/save.h +++ b/src/dusk/save/save.h @@ -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 diff --git a/src/dusk/save/saveslot.h b/src/dusk/save/saveslot.h index 8d0b9246..abc306cb 100644 --- a/src/dusk/save/saveslot.h +++ b/src/dusk/save/saveslot.h @@ -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; diff --git a/src/dusk/scene/CMakeLists.txt b/src/dusk/scene/CMakeLists.txt index 8273e118..ea994c4a 100644 --- a/src/dusk/scene/CMakeLists.txt +++ b/src/dusk/scene/CMakeLists.txt @@ -11,5 +11,6 @@ target_sources(${DUSK_LIBRARY_TARGET_NAME} # Subdirs add_subdirectory(initial) +add_subdirectory(mainmenu) add_subdirectory(overworld) add_subdirectory(battle) \ No newline at end of file diff --git a/src/dusk/scene/battle/scenebattle.c b/src/dusk/scene/battle/scenebattle.c index 0fabef58..5c08319e 100644 --- a/src/dusk/scene/battle/scenebattle.c +++ b/src/dusk/scene/battle/scenebattle.c @@ -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(); +} diff --git a/src/dusk/scene/battle/scenebattle.h b/src/dusk/scene/battle/scenebattle.h index 17046a35..bfe0e98e 100644 --- a/src/dusk/scene/battle/scenebattle.h +++ b/src/dusk/scene/battle/scenebattle.h @@ -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); diff --git a/src/dusk/scene/initial/sceneinitial.c b/src/dusk/scene/initial/sceneinitial.c index 9c3c5afb..395b070c 100644 --- a/src/dusk/scene/initial/sceneinitial.c +++ b/src/dusk/scene/initial/sceneinitial.c @@ -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; } diff --git a/src/dusk/scene/initial/sceneinitial.h b/src/dusk/scene/initial/sceneinitial.h index 73b875e7..d184b62d 100644 --- a/src/dusk/scene/initial/sceneinitial.h +++ b/src/dusk/scene/initial/sceneinitial.h @@ -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. diff --git a/src/dusk/scene/mainmenu/CMakeLists.txt b/src/dusk/scene/mainmenu/CMakeLists.txt new file mode 100644 index 00000000..3f71bfdc --- /dev/null +++ b/src/dusk/scene/mainmenu/CMakeLists.txt @@ -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 +) diff --git a/src/dusk/scene/mainmenu/scenemainmenu.c b/src/dusk/scene/mainmenu/scenemainmenu.c new file mode 100644 index 00000000..b14d2868 --- /dev/null +++ b/src/dusk/scene/mainmenu/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(); +} diff --git a/src/dusk/scene/mainmenu/scenemainmenu.h b/src/dusk/scene/mainmenu/scenemainmenu.h new file mode 100644 index 00000000..1b578815 --- /dev/null +++ b/src/dusk/scene/mainmenu/scenemainmenu.h @@ -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); diff --git a/src/dusk/scene/scenetype.c b/src/dusk/scene/scenetype.c index 3dad6627..15c4543e 100644 --- a/src/dusk/scene/scenetype.c +++ b/src/dusk/scene/scenetype.c @@ -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, diff --git a/src/dusk/scene/scenetype.h b/src/dusk/scene/scenetype.h index 677959ee..eb3dfc2b 100644 --- a/src/dusk/scene/scenetype.h +++ b/src/dusk/scene/scenetype.h @@ -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, diff --git a/src/dusk/ui/frame/CMakeLists.txt b/src/dusk/ui/frame/CMakeLists.txt index 6fe291b6..106541ae 100644 --- a/src/dusk/ui/frame/CMakeLists.txt +++ b/src/dusk/ui/frame/CMakeLists.txt @@ -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) diff --git a/src/dusk/ui/frame/battle/CMakeLists.txt b/src/dusk/ui/frame/battle/CMakeLists.txt index 4b6bd9e2..1cdef8a0 100644 --- a/src/dusk/ui/frame/battle/CMakeLists.txt +++ b/src/dusk/ui/frame/battle/CMakeLists.txt @@ -6,4 +6,5 @@ target_sources(${DUSK_LIBRARY_TARGET_NAME} PUBLIC uibattlemenu.c + uibattlehud.c ) diff --git a/src/dusk/ui/frame/battle/uibattlehud.c b/src/dusk/ui/frame/battle/uibattlehud.c new file mode 100644 index 00000000..e2fc08a7 --- /dev/null +++ b/src/dusk/ui/frame/battle/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(); +} diff --git a/src/dusk/ui/frame/battle/uibattlehud.h b/src/dusk/ui/frame/battle/uibattlehud.h new file mode 100644 index 00000000..a03b51b1 --- /dev/null +++ b/src/dusk/ui/frame/battle/uibattlehud.h @@ -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); diff --git a/src/dusk/ui/frame/mainmenu/CMakeLists.txt b/src/dusk/ui/frame/mainmenu/CMakeLists.txt new file mode 100644 index 00000000..2231cb11 --- /dev/null +++ b/src/dusk/ui/frame/mainmenu/CMakeLists.txt @@ -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 +) diff --git a/src/dusk/ui/frame/mainmenu/uimainmenu.c b/src/dusk/ui/frame/mainmenu/uimainmenu.c new file mode 100644 index 00000000..63918af9 --- /dev/null +++ b/src/dusk/ui/frame/mainmenu/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(); +} diff --git a/src/dusk/ui/frame/mainmenu/uimainmenu.h b/src/dusk/ui/frame/mainmenu/uimainmenu.h new file mode 100644 index 00000000..8e1eca7d --- /dev/null +++ b/src/dusk/ui/frame/mainmenu/uimainmenu.h @@ -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); diff --git a/src/dusk/ui/uielement.c b/src/dusk/ui/uielement.c index 7194973c..91b929b6 100644 --- a/src/dusk/ui/uielement.c +++ b/src/dusk/ui/uielement.c @@ -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, diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index e512e723..d5816717 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -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) diff --git a/test/animation/CMakeLists.txt b/test/animation/CMakeLists.txt new file mode 100644 index 00000000..5f580139 --- /dev/null +++ b/test/animation/CMakeLists.txt @@ -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) diff --git a/test/animation/test_keyframe.c b/test/animation/test_keyframe.c new file mode 100644 index 00000000..4e31fb6b --- /dev/null +++ b/test/animation/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); +} diff --git a/test/rpg/CMakeLists.txt b/test/rpg/CMakeLists.txt index 97a798ee..bd2d8fb2 100644 --- a/test/rpg/CMakeLists.txt +++ b/test/rpg/CMakeLists.txt @@ -10,4 +10,6 @@ dusktest(test_rpg.c) # Subdirs add_subdirectory(overworld) -add_subdirectory(battle) \ No newline at end of file +add_subdirectory(battle) +add_subdirectory(entity) +add_subdirectory(cutscene) \ No newline at end of file diff --git a/test/rpg/cutscene/CMakeLists.txt b/test/rpg/cutscene/CMakeLists.txt new file mode 100644 index 00000000..d6f0804e --- /dev/null +++ b/test/rpg/cutscene/CMakeLists.txt @@ -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) diff --git a/test/rpg/cutscene/test_cutscenecontrol.c b/test/rpg/cutscene/test_cutscenecontrol.c new file mode 100644 index 00000000..2241911a --- /dev/null +++ b/test/rpg/cutscene/test_cutscenecontrol.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); +} diff --git a/test/rpg/cutscene/test_cutscenemaparea.c b/test/rpg/cutscene/test_cutscenemaparea.c new file mode 100644 index 00000000..174394a6 --- /dev/null +++ b/test/rpg/cutscene/test_cutscenemaparea.c @@ -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); +} diff --git a/test/rpg/cutscene/test_cutscenesystem.c b/test/rpg/cutscene/test_cutscenesystem.c new file mode 100644 index 00000000..a6ebcdf6 --- /dev/null +++ b/test/rpg/cutscene/test_cutscenesystem.c @@ -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); +} diff --git a/test/rpg/entity/CMakeLists.txt b/test/rpg/entity/CMakeLists.txt new file mode 100644 index 00000000..453ff3da --- /dev/null +++ b/test/rpg/entity/CMakeLists.txt @@ -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) diff --git a/test/rpg/entity/entitytestfixture.c b/test/rpg/entity/entitytestfixture.c new file mode 100644 index 00000000..acbbe5f7 --- /dev/null +++ b/test/rpg/entity/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; +} diff --git a/test/rpg/entity/entitytestfixture.h b/test/rpg/entity/entitytestfixture.h new file mode 100644 index 00000000..89e591e4 --- /dev/null +++ b/test/rpg/entity/entitytestfixture.h @@ -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); diff --git a/test/rpg/entity/test_entity.c b/test/rpg/entity/test_entity.c new file mode 100644 index 00000000..ac2e1626 --- /dev/null +++ b/test/rpg/entity/test_entity.c @@ -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); +} diff --git a/test/rpg/entity/test_entityinteract.c b/test/rpg/entity/test_entityinteract.c new file mode 100644 index 00000000..4d2f092f --- /dev/null +++ b/test/rpg/entity/test_entityinteract.c @@ -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); +} diff --git a/test/rpg/entity/test_entityitem.c b/test/rpg/entity/test_entityitem.c new file mode 100644 index 00000000..13b5ecbd --- /dev/null +++ b/test/rpg/entity/test_entityitem.c @@ -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); +} diff --git a/test/rpg/entity/test_npc.c b/test/rpg/entity/test_npc.c new file mode 100644 index 00000000..aaa5174b --- /dev/null +++ b/test/rpg/entity/test_npc.c @@ -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); +} diff --git a/test/rpg/overworld/CMakeLists.txt b/test/rpg/overworld/CMakeLists.txt index a888f729..7f8ecc94 100644 --- a/test/rpg/overworld/CMakeLists.txt +++ b/test/rpg/overworld/CMakeLists.txt @@ -6,5 +6,6 @@ include(dusktest) # Tests +dusktest(test_maparea.c) # Subdirs \ No newline at end of file diff --git a/test/rpg/overworld/test_maparea.c b/test/rpg/overworld/test_maparea.c new file mode 100644 index 00000000..4f4f9239 --- /dev/null +++ b/test/rpg/overworld/test_maparea.c @@ -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); +}