diff --git a/api/Elementa.api b/api/Elementa.api index bf091ad1..e77bd466 100644 --- a/api/Elementa.api +++ b/api/Elementa.api @@ -24,6 +24,7 @@ public abstract class gg/essential/elementa/UIComponent : java/util/Observable, public fun ()V public fun addChild (Lgg/essential/elementa/UIComponent;)Lgg/essential/elementa/UIComponent; public fun addChildren ([Lgg/essential/elementa/UIComponent;)Lgg/essential/elementa/UIComponent; + public final fun addUpdateFunc (Lkotlin/jvm/functions/Function2;)V public fun afterDraw ()V public fun afterDraw (Lgg/essential/universal/UMatrixStack;)V public final fun afterDrawCompat (Lgg/essential/universal/UMatrixStack;)V @@ -134,6 +135,7 @@ public abstract class gg/essential/elementa/UIComponent : java/util/Observable, public fun removeChild (Lgg/essential/elementa/UIComponent;)Lgg/essential/elementa/UIComponent; public final fun removeEffect (Lgg/essential/elementa/effects/Effect;)V public final fun removeEffect (Ljava/lang/Class;)V + public final fun removeUpdateFunc (Lkotlin/jvm/functions/Function2;)V public fun replaceChild (Lgg/essential/elementa/UIComponent;Lgg/essential/elementa/UIComponent;)Lgg/essential/elementa/UIComponent; protected final fun requireChildrenUnlocked ()V public final fun setChildOf (Lgg/essential/elementa/UIComponent;)Lgg/essential/elementa/UIComponent; @@ -2583,6 +2585,7 @@ public final class gg/essential/elementa/dsl/UtilitiesKt { public abstract class gg/essential/elementa/effects/Effect { protected field boundComponent Lgg/essential/elementa/UIComponent; public fun ()V + protected final fun addUpdateFunc (Lkotlin/jvm/functions/Function2;)V public fun afterDraw ()V public fun afterDraw (Lgg/essential/universal/UMatrixStack;)V public final fun afterDrawCompat (Lgg/essential/universal/UMatrixStack;)V @@ -2595,6 +2598,7 @@ public abstract class gg/essential/elementa/effects/Effect { public final fun beforeDrawCompat (Lgg/essential/universal/UMatrixStack;)V public final fun bindComponent (Lgg/essential/elementa/UIComponent;)V protected final fun getBoundComponent ()Lgg/essential/elementa/UIComponent; + protected final fun removeUpdateFunc (Lkotlin/jvm/functions/Function2;)V protected final fun setBoundComponent (Lgg/essential/elementa/UIComponent;)V public fun setup ()V } diff --git a/src/main/kotlin/gg/essential/elementa/UIComponent.kt b/src/main/kotlin/gg/essential/elementa/UIComponent.kt index e0b92e7e..292a6d57 100644 --- a/src/main/kotlin/gg/essential/elementa/UIComponent.kt +++ b/src/main/kotlin/gg/essential/elementa/UIComponent.kt @@ -1,7 +1,10 @@ package gg.essential.elementa +import gg.essential.elementa.components.NOP_UPDATE_FUNC +import gg.essential.elementa.components.NopUpdateFuncList import gg.essential.elementa.components.UIBlock import gg.essential.elementa.components.UIContainer +import gg.essential.elementa.components.UpdateFunc import gg.essential.elementa.components.Window import gg.essential.elementa.constraints.* import gg.essential.elementa.constraints.animation.* @@ -49,7 +52,11 @@ abstract class UIComponent : Observable(), ReferenceHolder { return field } open val children = CopyOnWriteArrayList().observable() - val effects = mutableListOf() + val effects: MutableList = mutableListOf().observable().apply { + addObserver { _, event -> + updateUpdateFuncsOnChangedEffect(event) + } + } private var childrenLocked = 0 init { @@ -57,6 +64,7 @@ abstract class UIComponent : Observable(), ReferenceHolder { requireChildrenUnlocked() setWindowCacheOnChangedChild(event) updateFloatingComponentsOnChangedChild(event) + updateUpdateFuncsOnChangedChild(event) } } @@ -1050,6 +1058,258 @@ abstract class UIComponent : Observable(), ReferenceHolder { } } + //region Public UpdateFunc API + fun addUpdateFunc(func: UpdateFunc) { + val updateFuncs = updateFuncs ?: mutableListOf().also { updateFuncs = it } + val index = updateFuncs.size + updateFuncs.add(func) + + val indexInWindow = allocUpdateFuncs(index, 1) + if (indexInWindow != -1) { + cachedWindow!!.allUpdateFuncs[indexInWindow] = func + assertUpdateFuncInvariants() + } + } + + fun removeUpdateFunc(func: UpdateFunc) { + val updateFuncs = updateFuncs ?: return + val index = updateFuncs.indexOf(func) + if (index == -1) return + updateFuncs.removeAt(index) + + freeUpdateFuncs(index, 1) + } + //endregion + + //region Internal UpdateFunc tracking + private var updateFuncParent: UIComponent? = null + private var updateFuncs: MutableList? = null // only allocated if used + private var effectUpdateFuncs = 0 // count of effect funcs + private var totalUpdateFuncs = 0 // count of own funcs + effect funcs + children total funcs + + private fun localUpdateFuncIndexForEffect(effectIndex: Int, indexInEffect: Int): Int { + var localIndex = updateFuncs?.size ?: 0 + for ((otherEffectIndex, otherEffect) in effects.withIndex()) { + if (otherEffectIndex >= effectIndex) { + break + } else { + if (otherEffect.updateFuncParent != this) continue // can happen if added to two components at the same time + localIndex += otherEffect.updateFuncs?.size ?: 0 + } + } + localIndex += indexInEffect + return localIndex + } + + private fun localUpdateFuncIndexForChild(childIndex: Int, indexInChild: Int): Int { + var localIndex = (updateFuncs?.size ?: 0) + effectUpdateFuncs + for ((otherChildIndex, otherChild) in children.withIndex()) { + if (otherChildIndex >= childIndex) { + break + } else { + if (otherChild.updateFuncParent != this) continue // can happen if added to two components at the same time + localIndex += otherChild.totalUpdateFuncs + } + } + localIndex += indexInChild + return localIndex + } + + internal fun addUpdateFunc(effect: Effect, indexInEffect: Int, func: UpdateFunc) { + effectUpdateFuncs++ + val indexInWindow = allocUpdateFuncs(localUpdateFuncIndexForEffect(effects.indexOf(effect), indexInEffect), 1) + if (indexInWindow != -1) { + cachedWindow!!.allUpdateFuncs[indexInWindow] = func + assertUpdateFuncInvariants() + } + } + + internal fun removeUpdateFunc(effect: Effect, indexInEffect: Int) { + effectUpdateFuncs-- + freeUpdateFuncs(localUpdateFuncIndexForEffect(effects.indexOf(effect), indexInEffect), 1) + } + + private fun allocUpdateFuncs(childIndex: Int, indexInChild: Int, count: Int): Int { + return allocUpdateFuncs(localUpdateFuncIndexForChild(childIndex, indexInChild), count) + } + + private fun freeUpdateFuncs(childIndex: Int, indexInChild: Int, count: Int) { + freeUpdateFuncs(localUpdateFuncIndexForChild(childIndex, indexInChild), count) + } + + private fun allocUpdateFuncs(localIndex: Int, count: Int): Int { + totalUpdateFuncs += count + if (this is Window) { + if (nextUpdateFuncIndex > localIndex) { + nextUpdateFuncIndex += count + } + if (count == 1) { + allUpdateFuncs.add(localIndex, NOP_UPDATE_FUNC) + } else { + allUpdateFuncs.addAll(localIndex, NopUpdateFuncList(count)) + } + return localIndex + } else { + val parent = updateFuncParent ?: return -1 + return parent.allocUpdateFuncs(parent.children.indexOf(this), localIndex, count) + } + } + + private fun freeUpdateFuncs(localIndex: Int, count: Int) { + totalUpdateFuncs -= count + if (this is Window) { + if (nextUpdateFuncIndex > localIndex) { + nextUpdateFuncIndex -= min(count, nextUpdateFuncIndex - localIndex) + } + if (count == 1) { + allUpdateFuncs.removeAt(localIndex) + } else { + allUpdateFuncs.subList(localIndex, localIndex + count).clear() + } + assertUpdateFuncInvariants() + } else { + val parent = updateFuncParent ?: return + parent.freeUpdateFuncs(parent.children.indexOf(this), localIndex, count) + } + } + + private fun updateUpdateFuncsOnChangedChild(possibleEvent: Any) { + @Suppress("UNCHECKED_CAST") + when (val event = possibleEvent as? ObservableListEvent ?: return) { + is ObservableAddEvent -> { + val (childIndex, child) = event.element + child.updateFuncParent?.let { oldParent -> + oldParent.updateUpdateFuncsOnChangedChild(ObservableRemoveEvent( + IndexedValue(oldParent.children.indexOf(child), child))) + } + assert(child.updateFuncParent == null) + child.updateFuncParent = this + + if (child.totalUpdateFuncs == 0) return + var indexInWindow = allocUpdateFuncs(childIndex, 0, child.totalUpdateFuncs) + if (indexInWindow == -1) return + val allUpdateFuncs = cachedWindow!!.allUpdateFuncs + fun register(component: UIComponent) { + component.updateFuncs?.let { funcs -> + for (func in funcs) { + allUpdateFuncs[indexInWindow++] = func + } + } + component.effects.forEach { effect -> + if (effect.updateFuncParent != component) return@forEach // can happen if added to two components at the same time + effect.updateFuncs?.let { funcs -> + for (func in funcs) { + allUpdateFuncs[indexInWindow++] = func + } + } + } + component.children.forEach { child -> + if (child.updateFuncParent != component) return@forEach // can happen if added to two components at the same time + register(child) + } + } + register(child) + assertUpdateFuncInvariants() + } + is ObservableRemoveEvent -> { + val (childIndex, child) = event.element + if (child.updateFuncParent != this) return // double remove can happen if added to two component at once + child.updateFuncParent = null + + if (child.totalUpdateFuncs == 0) return + freeUpdateFuncs(childIndex, 0, child.totalUpdateFuncs) + } + is ObservableClearEvent -> { + event.oldChildren.forEach { if (it.updateFuncParent == this) it.updateFuncParent = null } + + val remainingFuncs = (updateFuncs?.size ?: 0) + effectUpdateFuncs + val removedFuncs = totalUpdateFuncs - remainingFuncs + freeUpdateFuncs(remainingFuncs, removedFuncs) + } + } + } + + private fun updateUpdateFuncsOnChangedEffect(possibleEvent: Any) { + @Suppress("UNCHECKED_CAST") + when (val event = possibleEvent as? ObservableListEvent ?: return) { + is ObservableAddEvent -> { + val (effectIndex, effect) = event.element + effect.updateFuncParent?.let { oldParent -> + oldParent.updateUpdateFuncsOnChangedEffect(ObservableRemoveEvent( + IndexedValue(oldParent.effects.indexOf(effect), effect))) + } + assert(effect.updateFuncParent == null) + effect.updateFuncParent = this + + val funcs = effect.updateFuncs ?: return + if (funcs.isEmpty()) return + effectUpdateFuncs += funcs.size + var indexInWindow = allocUpdateFuncs(localUpdateFuncIndexForEffect(effectIndex, 0), funcs.size) + if (indexInWindow == -1) return + val allUpdateFuncs = cachedWindow!!.allUpdateFuncs + for (func in funcs) { + allUpdateFuncs[indexInWindow++] = func + } + assertUpdateFuncInvariants() + } + is ObservableRemoveEvent -> { + val (effectIndex, effect) = event.element + if (effect.updateFuncParent != this) return // double remove can happen if added to two component at once + effect.updateFuncParent = null + + val funcs = effect.updateFuncs?.size ?: 0 + if (funcs == 0) return + effectUpdateFuncs -= funcs + freeUpdateFuncs(localUpdateFuncIndexForEffect(effectIndex, 0), funcs) + } + is ObservableClearEvent -> { + event.oldChildren.forEach { if (it.updateFuncParent == this) it.updateFuncParent = null } + + val removedFuncs = effectUpdateFuncs + effectUpdateFuncs = 0 + freeUpdateFuncs(updateFuncs?.size ?: 0, removedFuncs) + } + } + } + + internal fun assertUpdateFuncInvariants() { + if (!ASSERT_UPDATE_FUNC_INVARINTS) return + + val window = cachedWindow ?: return + val allUpdateFuncs = window.allUpdateFuncs + + var indexInWindow = 0 + + fun visit(component: UIComponent) { + val effectUpdateFuncs = component.effects.sumOf { if (it.updateFuncParent == component) it.updateFuncs?.size ?: 0 else 0 } + val childUpdateFuncs = component.children.sumOf { if (it.updateFuncParent == component) it.totalUpdateFuncs else 0 } + assert(component.effectUpdateFuncs == effectUpdateFuncs) + assert(component.totalUpdateFuncs == (component.updateFuncs?.size ?: 0) + effectUpdateFuncs + childUpdateFuncs) + + component.updateFuncs?.let { funcs -> + for (func in funcs) { + assert(func == allUpdateFuncs[indexInWindow++]) + } + } + component.effects.forEach { effect -> + if (effect.updateFuncParent != component) return@forEach // can happen if added to two components at the same time + effect.updateFuncs?.let { funcs -> + for (func in funcs) { + assert(func == allUpdateFuncs[indexInWindow++]) + } + } + } + component.children.forEach { child -> + if (child.updateFuncParent != component) return@forEach // can happen if added to two components at the same time + visit(child) + } + } + visit(window) + + assert(indexInWindow == allUpdateFuncs.size) + } + //endregion + /** * Field animation API */ @@ -1264,6 +1524,8 @@ abstract class UIComponent : Observable(), ReferenceHolder { // Default value for componentName used as marker for lazy init. private val defaultComponentName = String() + private val ASSERT_UPDATE_FUNC_INVARINTS = System.getProperty("elementa.debug.assertUpdateFuncInvariants").toBoolean() + val DEBUG_OUTLINE_WIDTH = System.getProperty("elementa.debug.width")?.toDoubleOrNull() ?: 2.0 /** diff --git a/src/main/kotlin/gg/essential/elementa/components/Window.kt b/src/main/kotlin/gg/essential/elementa/components/Window.kt index d7293a51..902495fc 100644 --- a/src/main/kotlin/gg/essential/elementa/components/Window.kt +++ b/src/main/kotlin/gg/essential/elementa/components/Window.kt @@ -23,6 +23,12 @@ class Window @JvmOverloads constructor( val animationFPS: Int = 244 ) : UIComponent() { private var systemTime = -1L + + private var lastDrawTime: Long = -1 + + internal var allUpdateFuncs: MutableList = mutableListOf() + internal var nextUpdateFuncIndex = 0 + private var currentMouseButton = -1 private var legacyFloatingComponents = mutableListOf() @@ -43,6 +49,7 @@ class Window @JvmOverloads constructor( init { super.parent = this + cachedWindow = this } override fun afterInitialization() { @@ -74,9 +81,23 @@ class Window @JvmOverloads constructor( if (systemTime == -1L) systemTime = System.currentTimeMillis() + if (lastDrawTime == -1L) + lastDrawTime = System.currentTimeMillis() + + val now = System.currentTimeMillis() + val dtMs = now - lastDrawTime + lastDrawTime = now try { + assertUpdateFuncInvariants() + nextUpdateFuncIndex = 0 + while (true) { + val func = allUpdateFuncs.getOrNull(nextUpdateFuncIndex) ?: break + nextUpdateFuncIndex++ + func(dtMs / 1000f, dtMs.toInt()) + } + //If this Window is more than 5 seconds behind, reset it be only 5 seconds. //This will drop missed frames but avoid the game freezing as the Window tries //to catch after a period of inactivity diff --git a/src/main/kotlin/gg/essential/elementa/components/updateFunc.kt b/src/main/kotlin/gg/essential/elementa/components/updateFunc.kt new file mode 100644 index 00000000..7c6482fa --- /dev/null +++ b/src/main/kotlin/gg/essential/elementa/components/updateFunc.kt @@ -0,0 +1,19 @@ +package gg.essential.elementa.components + +/** + * Called once at the start of every frame to update any animations and miscellaneous state. + * + * @param dt Time (in seconds) since last frame + * @param dtMs Time (in milliseconds) since last frame + * + * This differs from `(dt / 1000).toInt()` in that it will account for the fractional milliseconds which would + * otherwise be lost to rounding. E.g. if there are three frames each lasting 16.4ms, + * `(dt / 1000).toInt()` would be 16 each time, but `dtMs` will be 16 on the first two frames and 17 on the third. + */ +typealias UpdateFunc = (dt: Float, dtMs: Int) -> Unit + +internal val NOP_UPDATE_FUNC: UpdateFunc = { _, _ -> } + +internal class NopUpdateFuncList(override val size: Int) : AbstractList() { + override fun get(index: Int): UpdateFunc = NOP_UPDATE_FUNC +} diff --git a/src/main/kotlin/gg/essential/elementa/effects/Effect.kt b/src/main/kotlin/gg/essential/elementa/effects/Effect.kt index 6db5e5ce..c8cfc1e0 100644 --- a/src/main/kotlin/gg/essential/elementa/effects/Effect.kt +++ b/src/main/kotlin/gg/essential/elementa/effects/Effect.kt @@ -1,6 +1,7 @@ package gg.essential.elementa.effects import gg.essential.elementa.UIComponent +import gg.essential.elementa.components.UpdateFunc import gg.essential.universal.UMatrixStack /** @@ -17,6 +18,26 @@ abstract class Effect { "which already has a bound component") boundComponent = component } + + internal var updateFuncParent: UIComponent? = null + internal var updateFuncs: MutableList? = null // only allocated if used + + protected fun addUpdateFunc(func: UpdateFunc) { + val updateFuncs = updateFuncs ?: mutableListOf().also { updateFuncs = it } + updateFuncs.add(func) + + updateFuncParent?.addUpdateFunc(this, updateFuncs.lastIndex, func) + } + + protected fun removeUpdateFunc(func: UpdateFunc) { + val updateFuncs = updateFuncs ?: return + val index = updateFuncs.indexOf(func) + if (index == -1) return + updateFuncs.removeAt(index) + + updateFuncParent?.removeUpdateFunc(this, index) + } + /** * Called once inside of the component's afterInitialization function */