diff --git a/assets/cutscenes/MoveCubeCutscene.js b/assets/cutscenes/MoveCubeCutscene.js index 71c1f00d..a7fe1c63 100644 --- a/assets/cutscenes/MoveCubeCutscene.js +++ b/assets/cutscenes/MoveCubeCutscene.js @@ -1,31 +1,32 @@ -var PHASE_LEFT = 0; -var PHASE_RIGHT = 1; - var SPEED = 3.0; var DURATION = 2.0; function MoveCubeCutscene(params) { Cutscene.call(this); this.cube = params.cube; - this.phase = PHASE_LEFT; - this.timer = 0.0; + + var startX = this.cube.position.position.x; + + this.animLeft = new Animation([ + { time: 0.0, value: startX, easing: Easing.outQuad }, + { time: DURATION, value: startX - SPEED * DURATION } + ]); + + this.animRight = new Animation([ + { time: 0.0, value: startX - SPEED * DURATION, easing: Easing.inOutQuad }, + { time: DURATION, value: startX } + ]); } MoveCubeCutscene.prototype = Object.create(Cutscene.prototype); MoveCubeCutscene.prototype.constructor = MoveCubeCutscene; MoveCubeCutscene.prototype.update = function() { - this.timer += TIME.delta; - - if(this.phase === PHASE_LEFT) { - this.cube.position.position.x -= SPEED * TIME.delta; - if(this.timer >= DURATION) { - this.phase = PHASE_RIGHT; - this.timer = 0.0; - } - } else if(this.phase === PHASE_RIGHT) { - this.cube.position.position.x += SPEED * TIME.delta; - if(this.timer >= DURATION) { + if(!this.animLeft.complete) { + this.cube.position.position.x = this.animLeft.update(TIME.delta); + } else { + this.cube.position.position.x = this.animRight.update(TIME.delta); + if(this.animRight.complete) { Cutscene.finish(); } } diff --git a/src/dusk/CMakeLists.txt b/src/dusk/CMakeLists.txt index 69440679..1512d200 100644 --- a/src/dusk/CMakeLists.txt +++ b/src/dusk/CMakeLists.txt @@ -54,6 +54,7 @@ target_sources(${DUSK_BINARY_TARGET_NAME} ) # Subdirs +add_subdirectory(animation) add_subdirectory(assert) add_subdirectory(asset) add_subdirectory(cutscene) diff --git a/src/dusk/animation/CMakeLists.txt b/src/dusk/animation/CMakeLists.txt new file mode 100644 index 00000000..7696f9c4 --- /dev/null +++ b/src/dusk/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 + +target_sources(${DUSK_LIBRARY_TARGET_NAME} + PUBLIC + easing.c + animation.c +) diff --git a/src/dusk/animation/animation.c b/src/dusk/animation/animation.c new file mode 100644 index 00000000..f9cca2b2 --- /dev/null +++ b/src/dusk/animation/animation.c @@ -0,0 +1,75 @@ +// Copyright (c) 2026 Dominic Masters +// +// This software is released under the MIT License. +// https://opensource.org/licenses/MIT + +#include "animation.h" +#include "assert/assert.h" +#include "util/memory.h" +#include + +void animationInit(animation_t *anim) { + memoryZero(anim, sizeof(animation_t)); +} + +void animationAddKeyframe( + animation_t *anim, + float_t time, + float_t value, + easingtype_t easing +) { + assertTrue( + anim->keyframeCount < ANIMATION_KEYFRAME_COUNT_MAX, + "Keyframe count exceeds ANIMATION_KEYFRAME_COUNT_MAX" + ); + uint8_t i = anim->keyframeCount++; + anim->keyframes[i].time = time; + anim->keyframes[i].value = value; + anim->keyframes[i].easing = easing; + if(time > anim->duration) anim->duration = time; +} + +float_t animationGetValue(const animation_t *anim) { + if(anim->keyframeCount == 0) return 0.0f; + + uint8_t last = anim->keyframeCount - 1; + + if(anim->keyframeCount == 1) return anim->keyframes[0].value; + if(anim->time <= anim->keyframes[0].time) return anim->keyframes[0].value; + if(anim->time >= anim->keyframes[last].time) { + return anim->keyframes[last].value; + } + + for(uint8_t i = 0; i < last; i++) { + const keyframe_t *a = &anim->keyframes[i]; + const keyframe_t *b = &anim->keyframes[i + 1]; + if(anim->time < a->time || anim->time >= b->time) continue; + float_t t = (anim->time - a->time) / (b->time - a->time); + t = easingApply(a->easing, t); + return a->value + (b->value - a->value) * t; + } + + return anim->keyframes[last].value; +} + +float_t animationUpdate(animation_t *anim, float_t delta) { + if(anim->complete && !anim->loop) return animationGetValue(anim); + + anim->time += delta; + + if(anim->duration > 0.0f && anim->time >= anim->duration) { + if(anim->loop) { + anim->time = fmodf(anim->time, anim->duration); + } else { + anim->time = anim->duration; + anim->complete = true; + } + } + + return animationGetValue(anim); +} + +void animationReset(animation_t *anim) { + anim->time = 0.0f; + anim->complete = false; +} diff --git a/src/dusk/script/module/animation/moduleanimation.h b/src/dusk/script/module/animation/moduleanimation.h new file mode 100644 index 00000000..10825035 --- /dev/null +++ b/src/dusk/script/module/animation/moduleanimation.h @@ -0,0 +1,293 @@ +// Copyright (c) 2026 Dominic Masters +// +// This software is released under the MIT License. +// https://opensource.org/licenses/MIT + +#pragma once +#include "script/module/modulebase.h" +#include "script/scriptproto.h" +#include "animation/animation.h" +#include "util/memory.h" + +static scriptproto_t MODULE_ANIMATION_PROTO; + +static inline animation_t *moduleAnimationGet( + const jerry_call_info_t *callInfo +) { + return (animation_t *)scriptProtoGetValue( + &MODULE_ANIMATION_PROTO, callInfo->this_value + ); +} + +// Extracts an easingtype_t from a JS value. Accepts either a plain number +// or an Easing.xxx function (which has a .type numeric property). +static easingtype_t moduleAnimationReadEasing( + const jerry_value_t val +) { + if(jerry_value_is_number(val)) { + return (easingtype_t)(uint32_t)jerry_value_as_number(val); + } + if(jerry_value_is_object(val) || jerry_value_is_function(val)) { + jerry_value_t key = jerry_string_sz("type"); + jerry_value_t typeVal = jerry_object_get(val, key); + jerry_value_free(key); + easingtype_t result = EASING_LINEAR; + if(jerry_value_is_number(typeVal)) { + result = (easingtype_t)(uint32_t)jerry_value_as_number(typeVal); + } + jerry_value_free(typeVal); + return result; + } + return EASING_LINEAR; +} + +// Fires onReach callbacks for any keyframes crossed between prevTime and +// newTime. Returns an exception value on error, or jerry_undefined(). +static jerry_value_t moduleAnimationFireKeyframes( + const jerry_value_t thisVal, + float_t prevTime, + float_t newTime +) { + jerry_value_t kfKey = jerry_string_sz("_keyframes"); + jerry_value_t kfArr = jerry_object_get(thisVal, kfKey); + jerry_value_free(kfKey); + + if(!jerry_value_is_array(kfArr)) { + jerry_value_free(kfArr); + return jerry_undefined(); + } + + jerry_length_t len = jerry_array_length(kfArr); + for(jerry_length_t i = 0; i < len; i++) { + jerry_value_t kfObj = jerry_object_get_index(kfArr, i); + + jerry_value_t tKey = jerry_string_sz("time"); + jerry_value_t tVal = jerry_object_get(kfObj, tKey); + jerry_value_free(tKey); + float_t kfTime = (float_t)jerry_value_as_number(tVal); + jerry_value_free(tVal); + + if(prevTime < kfTime && newTime >= kfTime) { + jerry_value_t cbKey = jerry_string_sz("onReach"); + jerry_value_t cb = jerry_object_get(kfObj, cbKey); + jerry_value_free(cbKey); + + if(jerry_value_is_function(cb)) { + jerry_value_t r = jerry_call(cb, thisVal, NULL, 0); + jerry_value_free(cb); + if(jerry_value_is_exception(r)) { + jerry_value_free(kfObj); + jerry_value_free(kfArr); + return r; + } + jerry_value_free(r); + } else { + jerry_value_free(cb); + } + } + + jerry_value_free(kfObj); + } + + jerry_value_free(kfArr); + return jerry_undefined(); +} + +// Fires onComplete once. Returns an exception on error or jerry_undefined(). +static jerry_value_t moduleAnimationFireComplete( + const jerry_value_t thisVal +) { + jerry_value_t firedKey = jerry_string_sz("_completeFired"); + jerry_value_t firedVal = jerry_object_get(thisVal, firedKey); + bool_t alreadyFired = jerry_value_is_true(firedVal); + jerry_value_free(firedVal); + + if(alreadyFired) { + jerry_value_free(firedKey); + return jerry_undefined(); + } + + jerry_value_t trueVal = jerry_boolean(true); + jerry_object_set(thisVal, firedKey, trueVal); + jerry_value_free(firedKey); + jerry_value_free(trueVal); + + jerry_value_t cbKey = jerry_string_sz("onComplete"); + jerry_value_t cb = jerry_object_get(thisVal, cbKey); + jerry_value_free(cbKey); + + if(jerry_value_is_function(cb)) { + jerry_value_t r = jerry_call(cb, thisVal, NULL, 0); + jerry_value_free(cb); + if(jerry_value_is_exception(r)) return r; + jerry_value_free(r); + } else { + jerry_value_free(cb); + } + + return jerry_undefined(); +} + +moduleBaseFunction(moduleAnimationConstructor) { + animation_t *anim = (animation_t *)memoryAllocate(sizeof(animation_t)); + animationInit(anim); + + if(argc > 0 && jerry_value_is_boolean(args[argc - 1])) { + anim->loop = jerry_value_is_true(args[argc - 1]); + } + + if(argc > 0 && jerry_value_is_array(args[0])) { + jerry_length_t len = jerry_array_length(args[0]); + for(jerry_length_t i = 0; i < len; i++) { + jerry_value_t kf = jerry_object_get_index(args[0], i); + + jerry_value_t tKey = jerry_string_sz("time"); + jerry_value_t vKey = jerry_string_sz("value"); + jerry_value_t eKey = jerry_string_sz("easing"); + + float_t t = (float_t)jerry_value_as_number( + jerry_object_get(kf, tKey) + ); + float_t v = (float_t)jerry_value_as_number( + jerry_object_get(kf, vKey) + ); + jerry_value_t eVal = jerry_object_get(kf, eKey); + easingtype_t e = moduleAnimationReadEasing(eVal); + + jerry_value_free(tKey); + jerry_value_free(vKey); + jerry_value_free(eKey); + jerry_value_free(eVal); + jerry_value_free(kf); + + animationAddKeyframe(anim, t, v, e); + } + + // Store the JS keyframes array for onReach callback detection. + jerry_value_t kfKey = jerry_string_sz("_keyframes"); + jerry_object_set(callInfo->this_value, kfKey, args[0]); + jerry_value_free(kfKey); + } + + jerry_value_t firedKey = jerry_string_sz("_completeFired"); + jerry_value_t falseVal = jerry_boolean(false); + jerry_object_set(callInfo->this_value, firedKey, falseVal); + jerry_value_free(firedKey); + jerry_value_free(falseVal); + + jerry_object_set_native_ptr( + callInfo->this_value, &MODULE_ANIMATION_PROTO.info, anim + ); + return jerry_undefined(); +} + +moduleBaseFunction(moduleAnimationUpdate) { + animation_t *anim = moduleAnimationGet(callInfo); + if(!anim) return moduleBaseThrow("Invalid Animation instance"); + if(argc < 1 || !jerry_value_is_number(args[0])) { + return moduleBaseThrow("update() expects a number delta"); + } + + float_t prevTime = anim->time; + float_t delta = (float_t)jerry_value_as_number(args[0]); + float_t value = animationUpdate(anim, delta); + + jerry_value_t kfResult = moduleAnimationFireKeyframes( + callInfo->this_value, prevTime, anim->time + ); + if(jerry_value_is_exception(kfResult)) return kfResult; + jerry_value_free(kfResult); + + if(anim->complete) { + jerry_value_t cResult = moduleAnimationFireComplete( + callInfo->this_value + ); + if(jerry_value_is_exception(cResult)) return cResult; + jerry_value_free(cResult); + } + + return jerry_number((double)value); +} + +moduleBaseFunction(moduleAnimationGetValue) { + animation_t *anim = moduleAnimationGet(callInfo); + if(!anim) return moduleBaseThrow("Invalid Animation instance"); + return jerry_number((double)animationGetValue(anim)); +} + +moduleBaseFunction(moduleAnimationReset) { + animation_t *anim = moduleAnimationGet(callInfo); + if(!anim) return moduleBaseThrow("Invalid Animation instance"); + animationReset(anim); + + jerry_value_t key = jerry_string_sz("_completeFired"); + jerry_value_t falseVal = jerry_boolean(false); + jerry_object_set(callInfo->this_value, key, falseVal); + jerry_value_free(key); + jerry_value_free(falseVal); + + return jerry_undefined(); +} + +moduleBaseFunction(moduleAnimationGetComplete) { + animation_t *anim = moduleAnimationGet(callInfo); + return anim ? jerry_boolean(anim->complete) : jerry_boolean(false); +} + +moduleBaseFunction(moduleAnimationGetLoop) { + animation_t *anim = moduleAnimationGet(callInfo); + return anim ? jerry_boolean(anim->loop) : jerry_boolean(false); +} + +moduleBaseFunction(moduleAnimationSetLoop) { + animation_t *anim = moduleAnimationGet(callInfo); + if(!anim) return moduleBaseThrow("Invalid Animation instance"); + if(argc < 1) return moduleBaseThrow("Expected boolean"); + anim->loop = jerry_value_is_true(args[0]); + return jerry_undefined(); +} + +moduleBaseFunction(moduleAnimationGetTime) { + animation_t *anim = moduleAnimationGet(callInfo); + return anim ? jerry_number((double)anim->time) : jerry_number(0.0); +} + +moduleBaseFunction(moduleAnimationGetDuration) { + animation_t *anim = moduleAnimationGet(callInfo); + return anim ? jerry_number((double)anim->duration) : jerry_number(0.0); +} + +static void moduleAnimation(void) { + scriptProtoInit( + &MODULE_ANIMATION_PROTO, + "Animation", + sizeof(animation_t), + moduleAnimationConstructor + ); + + scriptProtoDefineFunc( + &MODULE_ANIMATION_PROTO, "update", moduleAnimationUpdate + ); + scriptProtoDefineFunc( + &MODULE_ANIMATION_PROTO, "getValue", moduleAnimationGetValue + ); + scriptProtoDefineFunc( + &MODULE_ANIMATION_PROTO, "reset", moduleAnimationReset + ); + scriptProtoDefineProp( + &MODULE_ANIMATION_PROTO, "complete", + moduleAnimationGetComplete, NULL + ); + scriptProtoDefineProp( + &MODULE_ANIMATION_PROTO, "loop", + moduleAnimationGetLoop, moduleAnimationSetLoop + ); + scriptProtoDefineProp( + &MODULE_ANIMATION_PROTO, "time", + moduleAnimationGetTime, NULL + ); + scriptProtoDefineProp( + &MODULE_ANIMATION_PROTO, "duration", + moduleAnimationGetDuration, NULL + ); +} diff --git a/src/dusk/script/module/animation/moduleeasing.h b/src/dusk/script/module/animation/moduleeasing.h new file mode 100644 index 00000000..f0f171b0 --- /dev/null +++ b/src/dusk/script/module/animation/moduleeasing.h @@ -0,0 +1,70 @@ +// Copyright (c) 2026 Dominic Masters +// +// This software is released under the MIT License. +// https://opensource.org/licenses/MIT + +#pragma once +#include "script/module/modulebase.h" +#include "script/scriptproto.h" +#include "animation/easing.h" + +static scriptproto_t MODULE_EASING_PROTO; + +// Generate one handler per easing curve. +#define EASING_MODULE_TABLE \ + X(Linear, "linear", EASING_LINEAR, easingLinear) \ + X(InSine, "inSine", EASING_IN_SINE, easingInSine) \ + X(OutSine, "outSine", EASING_OUT_SINE, easingOutSine) \ + X(InOutSine, "inOutSine", EASING_IN_OUT_SINE, easingInOutSine) \ + X(InQuad, "inQuad", EASING_IN_QUAD, easingInQuad) \ + X(OutQuad, "outQuad", EASING_OUT_QUAD, easingOutQuad) \ + X(InOutQuad, "inOutQuad", EASING_IN_OUT_QUAD, easingInOutQuad) \ + X(InCubic, "inCubic", EASING_IN_CUBIC, easingInCubic) \ + X(OutCubic, "outCubic", EASING_OUT_CUBIC, easingOutCubic) \ + X(InOutCubic, "inOutCubic", EASING_IN_OUT_CUBIC, easingInOutCubic) \ + X(InQuart, "inQuart", EASING_IN_QUART, easingInQuart) \ + X(OutQuart, "outQuart", EASING_OUT_QUART, easingOutQuart) \ + X(InOutQuart, "inOutQuart", EASING_IN_OUT_QUART, easingInOutQuart) \ + X(InBack, "inBack", EASING_IN_BACK, easingInBack) \ + X(OutBack, "outBack", EASING_OUT_BACK, easingOutBack) \ + X(InOutBack, "inOutBack", EASING_IN_OUT_BACK, easingInOutBack) + +#define X(CName, jsName, type, fn) \ + moduleBaseFunction(moduleEasing##CName) { \ + if(argc < 1 || !jerry_value_is_number(args[0])) { \ + return moduleBaseThrow("Expected number t"); \ + } \ + float_t t = (float_t)jerry_value_as_number(args[0]); \ + return jerry_number((double)fn(t)); \ + } +EASING_MODULE_TABLE +#undef X + +// Adds a callable easing function with a .type property to the Easing object. +static void moduleEasingRegister( + const char_t *jsName, + uint8_t type, + jerry_external_handler_t fn +) { + jerry_value_t fnVal = jerry_function_external(fn); + + jerry_value_t typeKey = jerry_string_sz("type"); + jerry_value_t typeVal = jerry_number((double)type); + jerry_object_set(fnVal, typeKey, typeVal); + jerry_value_free(typeKey); + jerry_value_free(typeVal); + + jerry_value_t nameKey = jerry_string_sz(jsName); + jerry_object_set(MODULE_EASING_PROTO.prototype, nameKey, fnVal); + jerry_value_free(nameKey); + jerry_value_free(fnVal); +} + +static void moduleEasing(void) { + scriptProtoInit(&MODULE_EASING_PROTO, "Easing", sizeof(uint8_t), NULL); + + #define X(CName, jsName, type, fn) \ + moduleEasingRegister(jsName, type, moduleEasing##CName); + EASING_MODULE_TABLE + #undef X +} diff --git a/src/dusk/script/module/module.h b/src/dusk/script/module/module.h index aed09d4d..8f6273bb 100644 --- a/src/dusk/script/module/module.h +++ b/src/dusk/script/module/module.h @@ -17,6 +17,8 @@ #include "script/module/display/modulescreen.h" #include "script/module/display/modulespritebatch.h" #include "script/module/display/moduletext.h" +#include "script/module/animation/moduleeasing.h" +#include "script/module/animation/moduleanimation.h" #include "script/module/scene/modulescene.h" #include "script/module/cutscene/modulecutscene.h" #include "script/module/console/moduleconsole.h" @@ -36,6 +38,8 @@ static void moduleRegister(void) { moduleScreen(); moduleSpriteBatch(); moduleText(); + moduleEasing(); + moduleAnimation(); moduleScene(); moduleCutscene(); moduleConsole();