From 08269080680fd66683f83c0792c9262ceda60ee3 Mon Sep 17 00:00:00 2001 From: Dominic Masters Date: Mon, 20 Jul 2026 16:07:38 -0500 Subject: [PATCH] First round of animation improvements --- src/dusk/animation/CMakeLists.txt | 2 + src/dusk/animation/animation.c | 59 +++-- src/dusk/animation/animation.h | 59 +++-- src/dusk/animation/keyframe.c | 43 ++++ src/dusk/animation/keyframe.h | 16 +- src/dusk/animation/keyframeset.c | 60 +++++ src/dusk/animation/keyframeset.h | 74 +++++++ src/dusk/entity/component/CMakeLists.txt | 1 + .../entity/component/animation/CMakeLists.txt | 10 + .../component/animation/entityanimation.c | 114 ++++++++++ .../component/animation/entityanimation.h | 176 +++++++++++++++ src/dusk/entity/componentlist.h | 6 + .../entity/component/overworld/entityplayer.c | 2 +- test/CMakeLists.txt | 1 + test/animation/CMakeLists.txt | 11 + test/animation/test_animation.c | 111 ++++++++++ test/animation/test_keyframe.c | 66 ++++++ test/animation/test_keyframeset.c | 77 +++++++ test/entity/CMakeLists.txt | 1 + test/entity/test_entityanimation.c | 209 ++++++++++++++++++ 20 files changed, 1049 insertions(+), 49 deletions(-) create mode 100644 src/dusk/animation/keyframe.c create mode 100644 src/dusk/animation/keyframeset.c create mode 100644 src/dusk/animation/keyframeset.h create mode 100644 src/dusk/entity/component/animation/CMakeLists.txt create mode 100644 src/dusk/entity/component/animation/entityanimation.c create mode 100644 src/dusk/entity/component/animation/entityanimation.h create mode 100644 test/animation/CMakeLists.txt create mode 100644 test/animation/test_animation.c create mode 100644 test/animation/test_keyframe.c create mode 100644 test/animation/test_keyframeset.c create mode 100644 test/entity/test_entityanimation.c diff --git a/src/dusk/animation/CMakeLists.txt b/src/dusk/animation/CMakeLists.txt index 7696f9c4..e2377da6 100644 --- a/src/dusk/animation/CMakeLists.txt +++ b/src/dusk/animation/CMakeLists.txt @@ -6,5 +6,7 @@ target_sources(${DUSK_LIBRARY_TARGET_NAME} PUBLIC easing.c + keyframe.c + keyframeset.c animation.c ) diff --git a/src/dusk/animation/animation.c b/src/dusk/animation/animation.c index 1380bd6b..1dc7a38e 100644 --- a/src/dusk/animation/animation.c +++ b/src/dusk/animation/animation.c @@ -5,48 +5,43 @@ #include "animation.h" #include "assert/assert.h" -#include "util/memory.h" #include "util/math.h" +#include "time/time.h" void animationInit( animation_t *anim, - keyframe_t *keyframes, - uint16_t keyframeCount + keyframe_t **tracks, + uint16_t *trackCounts, + uint16_t trackCount ) { assertNotNull(anim, "Animation pointer cannot be null."); - assertNotNull(keyframes, "Keyframes pointer cannot be null."); - assertTrue(keyframeCount > 0, "Keyframe count must be more than 0."); - anim->keyframes = keyframes; - anim->keyframeCount = keyframeCount; + keyframeSetInit(&anim->keyframes, tracks, trackCounts, trackCount); + anim->time = 0.0f; + anim->speed = 1.0f; + anim->loop = false; + anim->playing = false; } -float_t animationGetValue(animation_t *anim, const float_t time) { +void animationUpdate(animation_t *anim) { assertNotNull(anim, "Animation pointer cannot be null."); - assertNotNull(anim->keyframes, "Keyframes pointer cannot be null."); - assertTrue(anim->keyframeCount > 0, "Keyframe count invalid."); - assertTrue(time >= 0, "Time must be non-negative."); - - keyframe_t *start; - keyframe_t *end; - keyframe_t *last = anim->keyframes + anim->keyframeCount - 1; - keyframe_t *current = anim->keyframes; - start = current; + if(!anim->playing) return; - do { - if(current->time > time) { - end = current; - break; - } - start = current; - current++; + float_t duration = keyframeSetGetDuration(&anim->keyframes); + anim->time += TIME.delta * anim->speed; - if(current > last) { - end = start; - break; - } - } while(true); + if(anim->loop) { + if(duration > 0.0f) anim->time = mathModFloat(anim->time, duration); + return; + } - 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 + if(anim->time >= duration) { + anim->time = duration; + anim->playing = false; + } +} + +float_t animationGetValue(animation_t *anim, const uint16_t trackIndex) { + assertNotNull(anim, "Animation pointer cannot be null."); + return keyframeSetGetValue(&anim->keyframes, trackIndex, anim->time); +} diff --git a/src/dusk/animation/animation.h b/src/dusk/animation/animation.h index 5a76bdaf..dd0a740d 100644 --- a/src/dusk/animation/animation.h +++ b/src/dusk/animation/animation.h @@ -4,31 +4,60 @@ // https://opensource.org/licenses/MIT #pragma once -#include "keyframe.h" +#include "keyframeset.h" typedef struct { - keyframe_t *keyframes; - uint16_t keyframeCount; + /** The animation's tracks/channels and their raw keyframe data. */ + keyframeset_t keyframes; + + /** Current playback position, in seconds. */ + float_t time; + + /** Playback rate multiplier; 1.0 = normal speed. */ + float_t speed; + + /** True if animationUpdate() should wrap time back to 0 on reaching the + * final keyframe, rather than holding there and clearing playing. */ + bool_t loop; + + /** True while animationUpdate() should advance time each call. */ + bool_t playing; } animation_t; /** - * Initializes an animation. - * + * Initializes an animation: time 0, speed 1.0, not looping, not playing. + * See keyframeSetInit() -- tracks/trackCounts and the keyframe_t arrays + * they point to are not copied, and must outlive this animation. + * * @param anim The animation to initialize. - * @param keyframes The keyframes to use for the animation. - * @param keyframeCount The number of keyframes in the animation. + * @param tracks Array of trackCount keyframe_t arrays. + * @param trackCounts Array of trackCount keyframe counts, matching tracks. + * @param trackCount The number of tracks/channels in this animation. */ void animationInit( animation_t *anim, - keyframe_t *keyframes, - uint16_t keyframeCount + keyframe_t **tracks, + uint16_t *trackCounts, + uint16_t trackCount ); /** - * Gets the value of the animation at a given time. - * - * @param anim The animation to get the value from. - * @param time The time at which to get the value, in seconds. - * @return The value of the animation at the given time. + * Advances an animation's time by TIME.delta * speed. No-op if not + * playing. Duration is the latest of every track's final keyframe time + * (see keyframeSetGetDuration()). If loop is set, time wraps back into + * [0, duration) on reaching it; otherwise time is clamped there and + * playing is cleared. + * + * @param anim The animation to update. */ -float_t animationGetValue(animation_t *anim, const float_t time); \ No newline at end of file +void animationUpdate(animation_t *anim); + +/** + * Gets the value of one of the animation's tracks at its current + * playback time. + * + * @param anim The animation to get the value from. + * @param trackIndex The track to evaluate, in [0, trackCount). + * @return The interpolated value of that track at the current time. + */ +float_t animationGetValue(animation_t *anim, const uint16_t trackIndex); diff --git a/src/dusk/animation/keyframe.c b/src/dusk/animation/keyframe.c new file mode 100644 index 00000000..7efcbe02 --- /dev/null +++ b/src/dusk/animation/keyframe.c @@ -0,0 +1,43 @@ +// 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( + keyframe_t *keyframes, + const uint16_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."); + + keyframe_t *first = keyframes; + keyframe_t *last = keyframes + keyframeCount - 1; + + // Clamp to the boundary keyframes' values directly rather than + // interpolating -- start == end at either boundary would otherwise + // divide by zero. + if(time <= first->time) return first->value; + if(time >= last->time) return last->value; + + keyframe_t *start = first; + keyframe_t *end; + keyframe_t *current = first; + + do { + if(current->time > time) { + end = current; + break; + } + start = current; + current++; + } while(true); + + float_t t = (time - start->time) / (end->time - start->time); + return mathLerp(start->value, end->value, easingApply(start->easing, t)); +} diff --git a/src/dusk/animation/keyframe.h b/src/dusk/animation/keyframe.h index 6ea03984..daca585c 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 eased, interpolated value of a keyframe array at a given time. + * + * @param keyframes The keyframes to evaluate, ascending by time. + * @param keyframeCount The number of keyframes. + * @param time The time at which to get the value, in seconds. + * @return The interpolated value at the given time. + */ +float_t keyframeGetValue( + keyframe_t *keyframes, + const uint16_t keyframeCount, + const float_t time +); \ No newline at end of file diff --git a/src/dusk/animation/keyframeset.c b/src/dusk/animation/keyframeset.c new file mode 100644 index 00000000..50abbd59 --- /dev/null +++ b/src/dusk/animation/keyframeset.c @@ -0,0 +1,60 @@ +// Copyright (c) 2026 Dominic Masters +// +// This software is released under the MIT License. +// https://opensource.org/licenses/MIT + +#include "keyframeset.h" +#include "assert/assert.h" + +void keyframeSetInit( + keyframeset_t *set, + keyframe_t **tracks, + uint16_t *trackCounts, + uint16_t trackCount +) { + assertNotNull(set, "Keyframe set pointer cannot be null."); + assertNotNull(tracks, "Tracks pointer cannot be null."); + assertNotNull(trackCounts, "Track counts pointer cannot be null."); + assertTrue(trackCount > 0, "Track count must be more than 0."); + + set->tracks = tracks; + set->trackCounts = trackCounts; + set->trackCount = trackCount; +} + +float_t keyframeSetGetValue( + keyframeset_t *set, + const uint16_t trackIndex, + const float_t time +) { + assertNotNull(set, "Keyframe set pointer cannot be null."); + assertTrue(trackIndex < set->trackCount, "Track index out of bounds."); + + return keyframeGetValue( + set->tracks[trackIndex], set->trackCounts[trackIndex], time + ); +} + +void keyframeSetGetValues( + keyframeset_t *set, + const float_t time, + float_t *outValues +) { + assertNotNull(set, "Keyframe set pointer cannot be null."); + assertNotNull(outValues, "Output values pointer cannot be null."); + + for(uint16_t i = 0; i < set->trackCount; i++) { + outValues[i] = keyframeGetValue(set->tracks[i], set->trackCounts[i], time); + } +} + +float_t keyframeSetGetDuration(keyframeset_t *set) { + assertNotNull(set, "Keyframe set pointer cannot be null."); + + float_t duration = 0.0f; + for(uint16_t i = 0; i < set->trackCount; i++) { + float_t trackDuration = set->tracks[i][set->trackCounts[i] - 1].time; + if(trackDuration > duration) duration = trackDuration; + } + return duration; +} diff --git a/src/dusk/animation/keyframeset.h b/src/dusk/animation/keyframeset.h new file mode 100644 index 00000000..c9a19b38 --- /dev/null +++ b/src/dusk/animation/keyframeset.h @@ -0,0 +1,74 @@ +// Copyright (c) 2026 Dominic Masters +// +// This software is released under the MIT License. +// https://opensource.org/licenses/MIT + +#pragma once +#include "keyframe.h" + +/** + * A group of N parallel keyframe tracks (e.g. one per animated channel -- + * position.x/y/z, bone rotations, etc.) sharing a single timeline. Each + * track is independent: its own keyframe array and count, evaluated at + * whatever time is asked for. + */ +typedef struct { + /** Caller-owned array of trackCount keyframe_t arrays. */ + keyframe_t **tracks; + /** Caller-owned array of trackCount counts, one per entry in tracks. */ + uint16_t *trackCounts; + uint16_t trackCount; +} keyframeset_t; + +/** + * Initializes a keyframe set. Neither tracks, trackCounts, nor the + * keyframe_t arrays they point to are copied -- they must outlive this + * keyframeset_t. + * + * @param set The keyframe set to initialize. + * @param tracks Array of trackCount keyframe_t arrays. + * @param trackCounts Array of trackCount keyframe counts, matching tracks. + * @param trackCount The number of tracks. + */ +void keyframeSetInit( + keyframeset_t *set, + keyframe_t **tracks, + uint16_t *trackCounts, + uint16_t trackCount +); + +/** + * Gets the value of a single track at a given time. + * + * @param set The keyframe set to evaluate. + * @param trackIndex The track to evaluate, in [0, set->trackCount). + * @param time The time at which to get the value, in seconds. + * @return The interpolated value of that track at the given time. + */ +float_t keyframeSetGetValue( + keyframeset_t *set, + const uint16_t trackIndex, + const float_t time +); + +/** + * Gets the value of every track at a given time. + * + * @param set The keyframe set to evaluate. + * @param time The time at which to get the values, in seconds. + * @param outValues Destination array of at least set->trackCount floats. + */ +void keyframeSetGetValues( + keyframeset_t *set, + const float_t time, + float_t *outValues +); + +/** + * Gets the set's duration: the latest final-keyframe time across every + * track, i.e. how long it takes for every track to finish. + * + * @param set The keyframe set to measure. + * @return The set's duration, in seconds. + */ +float_t keyframeSetGetDuration(keyframeset_t *set); diff --git a/src/dusk/entity/component/CMakeLists.txt b/src/dusk/entity/component/CMakeLists.txt index b2795eba..c97bb6ee 100644 --- a/src/dusk/entity/component/CMakeLists.txt +++ b/src/dusk/entity/component/CMakeLists.txt @@ -7,3 +7,4 @@ add_subdirectory(display) add_subdirectory(physics) add_subdirectory(trigger) +add_subdirectory(animation) diff --git a/src/dusk/entity/component/animation/CMakeLists.txt b/src/dusk/entity/component/animation/CMakeLists.txt new file mode 100644 index 00000000..f0d4d756 --- /dev/null +++ b/src/dusk/entity/component/animation/CMakeLists.txt @@ -0,0 +1,10 @@ +# Copyright (c) 2026 Dominic Masters +# +# This software is released under the MIT License. +# https://opensource.org/licenses/MIT + +# Sources +target_sources(${DUSK_LIBRARY_TARGET_NAME} + PUBLIC + entityanimation.c +) diff --git a/src/dusk/entity/component/animation/entityanimation.c b/src/dusk/entity/component/animation/entityanimation.c new file mode 100644 index 00000000..29e1b6b5 --- /dev/null +++ b/src/dusk/entity/component/animation/entityanimation.c @@ -0,0 +1,114 @@ +/** + * Copyright (c) 2026 Dominic Masters + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +#include "entityanimation.h" +#include "entity/entitymanager.h" +#include "util/memory.h" + +void entityAnimationInit( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId +) { + entityanimation_t *animComp = entityAnimationGet(mgr, entityId, componentId); + + memoryZero(animComp, sizeof(entityanimation_t)); + + entityUpdateAdd(mgr, entityId, entityAnimationUpdate, componentId, NULL); +} + +entityanimation_t *entityAnimationGet( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId +) { + return componentGetData( + mgr, entityId, componentId, COMPONENT_TYPE_ANIMATION + ); +} + +void entityAnimationSetKeyframes( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId, + keyframe_t **channelTracks, + uint16_t *channelTrackCounts, + const uint16_t channelCount +) { + entityanimation_t *animComp = entityAnimationGet(mgr, entityId, componentId); + animationInit( + &animComp->anim, channelTracks, channelTrackCounts, channelCount + ); +} + +void entityAnimationPlay( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId +) { + entityanimation_t *animComp = entityAnimationGet(mgr, entityId, componentId); + animComp->anim.time = 0.0f; + animComp->anim.playing = true; +} + +void entityAnimationStop( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId +) { + entityanimation_t *animComp = entityAnimationGet(mgr, entityId, componentId); + animComp->anim.playing = false; +} + +bool_t entityAnimationIsPlaying( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId +) { + entityanimation_t *animComp = entityAnimationGet(mgr, entityId, componentId); + return animComp->anim.playing; +} + +void entityAnimationSetLoop( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId, + const bool_t loop +) { + entityanimation_t *animComp = entityAnimationGet(mgr, entityId, componentId); + animComp->anim.loop = loop; +} + +void entityAnimationSetSpeed( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId, + const float_t speed +) { + entityanimation_t *animComp = entityAnimationGet(mgr, entityId, componentId); + animComp->anim.speed = speed; +} + +float_t entityAnimationGetValue( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId, + const uint16_t channelIndex +) { + entityanimation_t *animComp = entityAnimationGet(mgr, entityId, componentId); + return animationGetValue(&animComp->anim, channelIndex); +} + +void entityAnimationUpdate( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId, + void *user +) { + entityanimation_t *animComp = entityAnimationGet(mgr, entityId, componentId); + animationUpdate(&animComp->anim); +} diff --git a/src/dusk/entity/component/animation/entityanimation.h b/src/dusk/entity/component/animation/entityanimation.h new file mode 100644 index 00000000..e04d2c1e --- /dev/null +++ b/src/dusk/entity/component/animation/entityanimation.h @@ -0,0 +1,176 @@ +/** + * Copyright (c) 2026 Dominic Masters + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +#pragma once +#include "entity/entitybase.h" +#include "animation/animation.h" + +typedef struct { + animation_t anim; +} entityanimation_t; + +/** + * Initializes the animation component: no keyframes set, and registers + * entityAnimationUpdate as an update callback. Call + * entityAnimationSetKeyframes() before entityAnimationPlay(). + * + * @param mgr The entity manager that owns the entity. + * @param entityId The entity ID. + * @param componentId The component ID. + */ +void entityAnimationInit( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId +); + +/** + * Gets the underlying animation structure (temporarily) for the given + * entity. Prefer the dedicated getters/setters where possible. + * + * @param mgr The entity manager that owns the entity. + * @param entityId The entity ID. + * @param componentId The component ID. + * @return The animation component data for the given entity and + * component ID. + */ +entityanimation_t *entityAnimationGet( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId +); + +/** + * Sets the entity's keyframes, stopped, at speed 1.0, non-looping. See + * keyframeSetInit() -- channelTracks/channelTrackCounts and the + * keyframe_t arrays they point to are not copied, and must outlive this + * component (e.g. static/const arrays owned by the caller). + * + * @param mgr The entity manager that owns the entity. + * @param entityId The entity ID. + * @param componentId The component ID. + * @param channelTracks Array of channelCount keyframe_t arrays -- one per + * animated channel (e.g. position.x/y/z), sharing this animation's + * timeline. + * @param channelTrackCounts Array of channelCount keyframe counts, + * matching channelTracks. + * @param channelCount The number of channels. + */ +void entityAnimationSetKeyframes( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId, + keyframe_t **channelTracks, + uint16_t *channelTrackCounts, + const uint16_t channelCount +); + +/** + * Starts (or restarts) playback from time 0. + * + * @param mgr The entity manager that owns the entity. + * @param entityId The entity ID. + * @param componentId The component ID. + */ +void entityAnimationPlay( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId +); + +/** + * Stops playback without resetting the current time. + * + * @param mgr The entity manager that owns the entity. + * @param entityId The entity ID. + * @param componentId The component ID. + */ +void entityAnimationStop( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId +); + +/** + * Checks whether the animation is currently playing. + * + * @param mgr The entity manager that owns the entity. + * @param entityId The entity ID. + * @param componentId The component ID. + * @return True if playing. + */ +bool_t entityAnimationIsPlaying( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId +); + +/** + * Sets whether the animation loops on reaching its final keyframe, rather + * than stopping there. + * + * @param mgr The entity manager that owns the entity. + * @param entityId The entity ID. + * @param componentId The component ID. + * @param loop True to loop, false to stop at the end. + */ +void entityAnimationSetLoop( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId, + const bool_t loop +); + +/** + * Sets the animation's playback rate multiplier. + * + * @param mgr The entity manager that owns the entity. + * @param entityId The entity ID. + * @param componentId The component ID. + * @param speed The new playback rate; 1.0 = normal speed. + */ +void entityAnimationSetSpeed( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId, + const float_t speed +); + +/** + * Evaluates one of the animation's channels at its current playback + * time, regardless of whether it's currently playing. + * + * @param mgr The entity manager that owns the entity. + * @param entityId The entity ID. + * @param componentId The component ID. + * @param channelIndex The channel to evaluate, in [0, channelCount) as + * passed to entityAnimationSetKeyframes(). + * @return That channel's value at the current time. + */ +float_t entityAnimationGetValue( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId, + const uint16_t channelIndex +); + +/** + * Per-tick update for the animation component: calls animationUpdate() + * (a no-op if not playing). Registered automatically as an update + * callback by entityAnimationInit. + * + * @param mgr The entity manager that owns the entity. + * @param entityId The entity ID. + * @param componentId The component ID. + * @param user Unused. + */ +void entityAnimationUpdate( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId, + void *user +); diff --git a/src/dusk/entity/componentlist.h b/src/dusk/entity/componentlist.h index 4f7d8bcd..3e7036c1 100644 --- a/src/dusk/entity/componentlist.h +++ b/src/dusk/entity/componentlist.h @@ -10,6 +10,7 @@ #include "entity/component/display/entityrenderable.h" #include "entity/component/physics/entityphysics.h" #include "entity/component/trigger/entitytrigger.h" +#include "entity/component/animation/entityanimation.h" // Name (Uppercase) // Structure @@ -31,6 +32,11 @@ X(PHYSICS, entityphysics_t, physics, entityPhysicsInit, NULL, NULL, entityPhysicsSerialize, entityPhysicsDeserialize) X(TRIGGER, entitytrigger_t, trigger, entityTriggerInit, NULL, NULL, entityTriggerSerialize, entityTriggerDeserialize) +// No serialize/deserialize: the animation's keyframes are caller-owned +// (see animationInit()), not data this component owns, so there's +// nothing meaningful to persist to/from JSON. +X(ANIMATION, entityanimation_t, animation, entityAnimationInit, NULL, NULL, + NULL, NULL) // Game-specific components #include "entity/gamecomponentlist.h" diff --git a/src/duskrpg/entity/component/overworld/entityplayer.c b/src/duskrpg/entity/component/overworld/entityplayer.c index 8aa13a91..ce2d6baa 100644 --- a/src/duskrpg/entity/component/overworld/entityplayer.c +++ b/src/duskrpg/entity/component/overworld/entityplayer.c @@ -17,7 +17,7 @@ #define ENTITY_PLAYER_MOVE_SPEED_DEFAULT 4.0f #define ENTITY_PLAYER_JUMP_IMPULSE_DEFAULT 6.0f -#define ENTITY_PLAYER_TURN_SPEED_DEFAULT 10.0f +#define ENTITY_PLAYER_TURN_SPEED_DEFAULT 15.0f // Below this squared magnitude, movement input is treated as "not moving" // and the player keeps facing whichever way it last faced, rather than diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 4b5ce181..de2439a2 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..3b81bf60 --- /dev/null +++ b/test/animation/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_keyframe.c) +dusktest(test_keyframeset.c) +dusktest(test_animation.c) diff --git a/test/animation/test_animation.c b/test/animation/test_animation.c new file mode 100644 index 00000000..41e58893 --- /dev/null +++ b/test/animation/test_animation.c @@ -0,0 +1,111 @@ +/** + * Copyright (c) 2026 Dominic Masters + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +#include "dusktest.h" +#include "time/time.h" +#include "animation/animation.h" + +static void test_animationInitDefaults(void **state) { + keyframe_t keyframes[] = { + { .time = 0.0f, .value = 0.0f, .easing = EASING_LINEAR }, + { .time = 1.0f, .value = 10.0f, .easing = EASING_LINEAR } + }; + keyframe_t *tracks[] = { keyframes }; + uint16_t trackCounts[] = { 2 }; + + animation_t anim; + animationInit(&anim, tracks, trackCounts, 1); + + assert_float_equal(anim.time, 0.0f, 0.0001f); + assert_float_equal(anim.speed, 1.0f, 0.0001f); + assert_false(anim.loop); + assert_false(anim.playing); + assert_float_equal(animationGetValue(&anim, 0), 0.0f, 0.0001f); +} + +static void test_animationUpdateNoopWhenNotPlaying(void **state) { + keyframe_t keyframes[] = { + { .time = 0.0f, .value = 0.0f, .easing = EASING_LINEAR }, + { .time = 1.0f, .value = 10.0f, .easing = EASING_LINEAR } + }; + keyframe_t *tracks[] = { keyframes }; + uint16_t trackCounts[] = { 2 }; + + animation_t anim; + animationInit(&anim, tracks, trackCounts, 1); + + TIME.delta = 1.0f; + animationUpdate(&anim); + assert_float_equal(anim.time, 0.0f, 0.0001f); +} + +static void test_animationUpdateStopsAtEndWhenNotLooping(void **state) { + keyframe_t keyframes[] = { + { .time = 0.0f, .value = 0.0f, .easing = EASING_LINEAR }, + { .time = 1.0f, .value = 10.0f, .easing = EASING_LINEAR } + }; + keyframe_t *tracks[] = { keyframes }; + uint16_t trackCounts[] = { 2 }; + + animation_t anim; + animationInit(&anim, tracks, trackCounts, 1); + anim.playing = true; + + TIME.delta = 0.5f; + animationUpdate(&anim); + assert_true(anim.playing); + assert_float_equal(animationGetValue(&anim, 0), 5.0f, 0.0001f); + + animationUpdate(&anim); + assert_false(anim.playing); + assert_float_equal(animationGetValue(&anim, 0), 10.0f, 0.0001f); + + // Stays clamped/held once stopped, even if updated again. + animationUpdate(&anim); + assert_float_equal(anim.time, 1.0f, 0.0001f); +} + +static void test_animationUpdateLoopsWithMultipleChannels(void **state) { + keyframe_t xKeyframes[] = { + { .time = 0.0f, .value = 0.0f, .easing = EASING_LINEAR }, + { .time = 1.0f, .value = 10.0f, .easing = EASING_LINEAR } + }; + keyframe_t yKeyframes[] = { + { .time = 0.0f, .value = 0.0f, .easing = EASING_LINEAR }, + { .time = 2.0f, .value = 100.0f, .easing = EASING_LINEAR } + }; + keyframe_t *tracks[] = { xKeyframes, yKeyframes }; + uint16_t trackCounts[] = { 2, 2 }; + + animation_t anim; + animationInit(&anim, tracks, trackCounts, 2); + anim.loop = true; + anim.playing = true; + + // Duration is the longer (Y) channel's 2s -- looping wraps against + // that, not X's shorter 1s. + TIME.delta = 1.5f; + animationUpdate(&anim); + assert_true(anim.playing); + assert_float_equal(anim.time, 1.5f, 0.0001f); + + animationUpdate(&anim); + // 1.5 + 1.5 = 3.0 -> wraps to 1.0 against a 2.0s duration. + assert_true(anim.playing); + assert_float_equal(anim.time, 1.0f, 0.0001f); +} + +int main(int argc, char **argv) { + const struct CMUnitTest tests[] = { + cmocka_unit_test(test_animationInitDefaults), + cmocka_unit_test(test_animationUpdateNoopWhenNotPlaying), + cmocka_unit_test(test_animationUpdateStopsAtEndWhenNotLooping), + cmocka_unit_test(test_animationUpdateLoopsWithMultipleChannels), + }; + + return cmocka_run_group_tests(tests, NULL, NULL); +} diff --git a/test/animation/test_keyframe.c b/test/animation/test_keyframe.c new file mode 100644 index 00000000..38f58abb --- /dev/null +++ b/test/animation/test_keyframe.c @@ -0,0 +1,66 @@ +/** + * 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_keyframeGetValueInterpolatesLinear(void **state) { + keyframe_t keyframes[] = { + { .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.5f), 5.0f, 0.0001f); +} + +static void test_keyframeGetValueClampsAtBoundaries(void **state) { + keyframe_t keyframes[] = { + { .time = 0.0f, .value = 0.0f, .easing = EASING_LINEAR }, + { .time = 1.0f, .value = 10.0f, .easing = EASING_LINEAR } + }; + + // Exactly on, and beyond, the final keyframe: holds its value rather + // than dividing by zero (start == end at that boundary). + assert_float_equal(keyframeGetValue(keyframes, 2, 1.0f), 10.0f, 0.0001f); + assert_float_equal(keyframeGetValue(keyframes, 2, 5.0f), 10.0f, 0.0001f); + + // Exactly on the first keyframe: same boundary case at the start. + assert_float_equal(keyframeGetValue(keyframes, 2, 0.0f), 0.0f, 0.0001f); +} + +static void test_keyframeGetValueRespectsEasing(void **state) { + keyframe_t keyframes[] = { + { .time = 0.0f, .value = 0.0f, .easing = EASING_IN_QUAD }, + { .time = 1.0f, .value = 10.0f, .easing = EASING_IN_QUAD } + }; + + // easingInQuad(0.5) = 0.25 -> lerp(0, 10, 0.25) = 2.5, not the linear + // midpoint (5.0). + assert_float_equal(keyframeGetValue(keyframes, 2, 0.5f), 2.5f, 0.0001f); +} + +static void test_keyframeGetValueWithThreeKeyframes(void **state) { + keyframe_t keyframes[] = { + { .time = 0.0f, .value = 0.0f, .easing = EASING_LINEAR }, + { .time = 1.0f, .value = 10.0f, .easing = EASING_LINEAR }, + { .time = 2.0f, .value = 0.0f, .easing = EASING_LINEAR } + }; + + assert_float_equal(keyframeGetValue(keyframes, 3, 1.0f), 10.0f, 0.0001f); + assert_float_equal(keyframeGetValue(keyframes, 3, 1.5f), 5.0f, 0.0001f); +} + +int main(int argc, char **argv) { + const struct CMUnitTest tests[] = { + cmocka_unit_test(test_keyframeGetValueInterpolatesLinear), + cmocka_unit_test(test_keyframeGetValueClampsAtBoundaries), + cmocka_unit_test(test_keyframeGetValueRespectsEasing), + cmocka_unit_test(test_keyframeGetValueWithThreeKeyframes), + }; + + return cmocka_run_group_tests(tests, NULL, NULL); +} diff --git a/test/animation/test_keyframeset.c b/test/animation/test_keyframeset.c new file mode 100644 index 00000000..6963a4c5 --- /dev/null +++ b/test/animation/test_keyframeset.c @@ -0,0 +1,77 @@ +/** + * Copyright (c) 2026 Dominic Masters + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +#include "dusktest.h" +#include "animation/keyframeset.h" + +static void test_keyframeSetGetValuePerTrack(void **state) { + keyframe_t xKeyframes[] = { + { .time = 0.0f, .value = 0.0f, .easing = EASING_LINEAR }, + { .time = 1.0f, .value = 10.0f, .easing = EASING_LINEAR } + }; + keyframe_t yKeyframes[] = { + { .time = 0.0f, .value = 100.0f, .easing = EASING_LINEAR }, + { .time = 1.0f, .value = 200.0f, .easing = EASING_LINEAR } + }; + keyframe_t *tracks[] = { xKeyframes, yKeyframes }; + uint16_t trackCounts[] = { 2, 2 }; + + keyframeset_t set; + keyframeSetInit(&set, tracks, trackCounts, 2); + + assert_float_equal(keyframeSetGetValue(&set, 0, 0.5f), 5.0f, 0.0001f); + assert_float_equal(keyframeSetGetValue(&set, 1, 0.5f), 150.0f, 0.0001f); +} + +static void test_keyframeSetGetValuesFillsAllTracks(void **state) { + keyframe_t xKeyframes[] = { + { .time = 0.0f, .value = 0.0f, .easing = EASING_LINEAR }, + { .time = 1.0f, .value = 10.0f, .easing = EASING_LINEAR } + }; + keyframe_t yKeyframes[] = { + { .time = 0.0f, .value = 100.0f, .easing = EASING_LINEAR }, + { .time = 1.0f, .value = 200.0f, .easing = EASING_LINEAR } + }; + keyframe_t *tracks[] = { xKeyframes, yKeyframes }; + uint16_t trackCounts[] = { 2, 2 }; + + keyframeset_t set; + keyframeSetInit(&set, tracks, trackCounts, 2); + + float_t values[2]; + keyframeSetGetValues(&set, 0.5f, values); + assert_float_equal(values[0], 5.0f, 0.0001f); + assert_float_equal(values[1], 150.0f, 0.0001f); +} + +static void test_keyframeSetGetDurationIsLongestTrack(void **state) { + keyframe_t shortKeyframes[] = { + { .time = 0.0f, .value = 0.0f, .easing = EASING_LINEAR }, + { .time = 1.0f, .value = 10.0f, .easing = EASING_LINEAR } + }; + keyframe_t longKeyframes[] = { + { .time = 0.0f, .value = 0.0f, .easing = EASING_LINEAR }, + { .time = 3.5f, .value = 10.0f, .easing = EASING_LINEAR } + }; + keyframe_t *tracks[] = { shortKeyframes, longKeyframes }; + uint16_t trackCounts[] = { 2, 2 }; + + keyframeset_t set; + keyframeSetInit(&set, tracks, trackCounts, 2); + + assert_float_equal(keyframeSetGetDuration(&set), 3.5f, 0.0001f); +} + +int main(int argc, char **argv) { + const struct CMUnitTest tests[] = { + cmocka_unit_test(test_keyframeSetGetValuePerTrack), + cmocka_unit_test(test_keyframeSetGetValuesFillsAllTracks), + cmocka_unit_test(test_keyframeSetGetDurationIsLongestTrack), + }; + + return cmocka_run_group_tests(tests, NULL, NULL); +} diff --git a/test/entity/CMakeLists.txt b/test/entity/CMakeLists.txt index b0a616a1..257eea11 100644 --- a/test/entity/CMakeLists.txt +++ b/test/entity/CMakeLists.txt @@ -9,3 +9,4 @@ include(dusktest) dusktest(test_entitymanager.c) dusktest(test_entityposition.c) dusktest(test_entitytrigger.c) +dusktest(test_entityanimation.c) diff --git a/test/entity/test_entityanimation.c b/test/entity/test_entityanimation.c new file mode 100644 index 00000000..16ae92cd --- /dev/null +++ b/test/entity/test_entityanimation.c @@ -0,0 +1,209 @@ +/** + * Copyright (c) 2026 Dominic Masters + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +#include "dusktest.h" +#include "util/memory.h" +#include "time/time.h" +#include "entity/entitymanager.h" +#include "entity/component/animation/entityanimation.h" + +static void test_entityAnimationSetKeyframesDefaults(void **state) { + entitymanager_t mgr; + entityManagerInit(&mgr); + + entityid_t entity = entityManagerAdd(&mgr); + componentid_t anim = entityAddComponent( + &mgr, entity, COMPONENT_TYPE_ANIMATION + ); + + static keyframe_t keyframes[] = { + { .time = 0.0f, .value = 0.0f, .easing = EASING_LINEAR }, + { .time = 1.0f, .value = 10.0f, .easing = EASING_LINEAR } + }; + keyframe_t *channels[] = { keyframes }; + uint16_t channelCounts[] = { 2 }; + entityAnimationSetKeyframes(&mgr, entity, anim, channels, channelCounts, 1); + + assert_false(entityAnimationIsPlaying(&mgr, entity, anim)); + assert_float_equal( + entityAnimationGetValue(&mgr, entity, anim, 0), 0.0f, 0.0001f + ); + + entityManagerDispose(&mgr); + assert_int_equal(memoryGetAllocatedCount(), 0); +} + +static void test_entityAnimationPlayAdvancesAndStopsNonLooping(void **state) { + entitymanager_t mgr; + entityManagerInit(&mgr); + + entityid_t entity = entityManagerAdd(&mgr); + componentid_t anim = entityAddComponent( + &mgr, entity, COMPONENT_TYPE_ANIMATION + ); + + static keyframe_t keyframes[] = { + { .time = 0.0f, .value = 0.0f, .easing = EASING_LINEAR }, + { .time = 1.0f, .value = 10.0f, .easing = EASING_LINEAR } + }; + keyframe_t *channels[] = { keyframes }; + uint16_t channelCounts[] = { 2 }; + entityAnimationSetKeyframes(&mgr, entity, anim, channels, channelCounts, 1); + entityAnimationPlay(&mgr, entity, anim); + assert_true(entityAnimationIsPlaying(&mgr, entity, anim)); + + TIME.delta = 0.5f; + entityUpdate(&mgr, entity); + assert_true(entityAnimationIsPlaying(&mgr, entity, anim)); + assert_float_equal( + entityAnimationGetValue(&mgr, entity, anim, 0), 5.0f, 0.0001f + ); + + // Reaches the final keyframe exactly: stops, holds the final value + // (rather than dividing by zero evaluating exactly at the last + // keyframe -- see keyframeGetValue()). + entityUpdate(&mgr, entity); + assert_false(entityAnimationIsPlaying(&mgr, entity, anim)); + assert_float_equal( + entityAnimationGetValue(&mgr, entity, anim, 0), 10.0f, 0.0001f + ); + + entityManagerDispose(&mgr); + assert_int_equal(memoryGetAllocatedCount(), 0); +} + +static void test_entityAnimationLoopsAndWrapsTime(void **state) { + entitymanager_t mgr; + entityManagerInit(&mgr); + + entityid_t entity = entityManagerAdd(&mgr); + componentid_t anim = entityAddComponent( + &mgr, entity, COMPONENT_TYPE_ANIMATION + ); + + static keyframe_t keyframes[] = { + { .time = 0.0f, .value = 0.0f, .easing = EASING_LINEAR }, + { .time = 1.0f, .value = 10.0f, .easing = EASING_LINEAR } + }; + keyframe_t *channels[] = { keyframes }; + uint16_t channelCounts[] = { 2 }; + entityAnimationSetKeyframes(&mgr, entity, anim, channels, channelCounts, 1); + entityAnimationSetLoop(&mgr, entity, anim, true); + entityAnimationPlay(&mgr, entity, anim); + + TIME.delta = 0.7f; + entityUpdate(&mgr, entity); + assert_true(entityAnimationIsPlaying(&mgr, entity, anim)); + assert_float_equal( + entityAnimationGetValue(&mgr, entity, anim, 0), 7.0f, 0.0001f + ); + + // Wraps back around instead of stopping: 0.7 + 0.7 = 1.4 -> 0.4. + entityUpdate(&mgr, entity); + assert_true(entityAnimationIsPlaying(&mgr, entity, anim)); + assert_float_equal( + entityAnimationGetValue(&mgr, entity, anim, 0), 4.0f, 0.0001f + ); + + entityManagerDispose(&mgr); + assert_int_equal(memoryGetAllocatedCount(), 0); +} + +static void test_entityAnimationStopAndSpeed(void **state) { + entitymanager_t mgr; + entityManagerInit(&mgr); + + entityid_t entity = entityManagerAdd(&mgr); + componentid_t anim = entityAddComponent( + &mgr, entity, COMPONENT_TYPE_ANIMATION + ); + + static keyframe_t keyframes[] = { + { .time = 0.0f, .value = 0.0f, .easing = EASING_LINEAR }, + { .time = 1.0f, .value = 10.0f, .easing = EASING_LINEAR } + }; + keyframe_t *channels[] = { keyframes }; + uint16_t channelCounts[] = { 2 }; + entityAnimationSetKeyframes(&mgr, entity, anim, channels, channelCounts, 1); + entityAnimationSetSpeed(&mgr, entity, anim, 2.0f); + entityAnimationPlay(&mgr, entity, anim); + + TIME.delta = 0.25f; + entityUpdate(&mgr, entity); + // 0.25s of real time at 2x speed = 0.5 track-seconds -> halfway. + assert_float_equal( + entityAnimationGetValue(&mgr, entity, anim, 0), 5.0f, 0.0001f + ); + + entityAnimationStop(&mgr, entity, anim); + assert_false(entityAnimationIsPlaying(&mgr, entity, anim)); + // Stopping doesn't reset time -- value is held where it was. + assert_float_equal( + entityAnimationGetValue(&mgr, entity, anim, 0), 5.0f, 0.0001f + ); + + entityUpdate(&mgr, entity); + assert_float_equal( + entityAnimationGetValue(&mgr, entity, anim, 0), 5.0f, 0.0001f + ); + + entityManagerDispose(&mgr); + assert_int_equal(memoryGetAllocatedCount(), 0); +} + +static void test_entityAnimationMultipleChannels(void **state) { + entitymanager_t mgr; + entityManagerInit(&mgr); + + entityid_t entity = entityManagerAdd(&mgr); + componentid_t anim = entityAddComponent( + &mgr, entity, COMPONENT_TYPE_ANIMATION + ); + + // X goes 0 -> 10 over 1s; Y goes 0 -> 100 over 2s -- different lengths, + // sharing one timeline. + static keyframe_t xKeyframes[] = { + { .time = 0.0f, .value = 0.0f, .easing = EASING_LINEAR }, + { .time = 1.0f, .value = 10.0f, .easing = EASING_LINEAR } + }; + static keyframe_t yKeyframes[] = { + { .time = 0.0f, .value = 0.0f, .easing = EASING_LINEAR }, + { .time = 2.0f, .value = 100.0f, .easing = EASING_LINEAR } + }; + keyframe_t *channels[] = { xKeyframes, yKeyframes }; + uint16_t channelCounts[] = { 2, 2 }; + entityAnimationSetKeyframes(&mgr, entity, anim, channels, channelCounts, 2); + entityAnimationPlay(&mgr, entity, anim); + + TIME.delta = 1.0f; + entityUpdate(&mgr, entity); + // Still playing: the set's duration is the longer (Y's) 2s. + assert_true(entityAnimationIsPlaying(&mgr, entity, anim)); + // X reached/passed its own end -- held at its final value. + assert_float_equal( + entityAnimationGetValue(&mgr, entity, anim, 0), 10.0f, 0.0001f + ); + // Y is halfway through its own 2s span. + assert_float_equal( + entityAnimationGetValue(&mgr, entity, anim, 1), 50.0f, 0.0001f + ); + + entityManagerDispose(&mgr); + assert_int_equal(memoryGetAllocatedCount(), 0); +} + +int main(int argc, char **argv) { + const struct CMUnitTest tests[] = { + cmocka_unit_test(test_entityAnimationSetKeyframesDefaults), + cmocka_unit_test(test_entityAnimationPlayAdvancesAndStopsNonLooping), + cmocka_unit_test(test_entityAnimationLoopsAndWrapsTime), + cmocka_unit_test(test_entityAnimationStopAndSpeed), + cmocka_unit_test(test_entityAnimationMultipleChannels), + }; + + return cmocka_run_group_tests(tests, NULL, NULL); +}