From 4903ee361f0d9c7aae320ffa95f1bfdd4609a556 Mon Sep 17 00:00:00 2001 From: Denis Andrasec Date: Thu, 4 Jul 2024 15:10:48 +0200 Subject: [PATCH] Add IKFollowing sample and extend `SpineController` functionality --- .../com/esotericsoftware/spine/IKFollowing.kt | 110 ++++++++++++++++++ .../esotericsoftware/spine/MainActivity.kt | 10 +- .../com/esotericsoftware/spine/PlayPause.kt | 9 +- .../esotericsoftware/spine/SimpleAnimation.kt | 8 +- .../spine/android/SpineController.java | 76 ++++++++++-- .../spine/android/SpineView.java | 4 + 6 files changed, 203 insertions(+), 14 deletions(-) create mode 100644 spine-android/app/src/main/java/com/esotericsoftware/spine/IKFollowing.kt diff --git a/spine-android/app/src/main/java/com/esotericsoftware/spine/IKFollowing.kt b/spine-android/app/src/main/java/com/esotericsoftware/spine/IKFollowing.kt new file mode 100644 index 000000000..3c9d5ea98 --- /dev/null +++ b/spine-android/app/src/main/java/com/esotericsoftware/spine/IKFollowing.kt @@ -0,0 +1,110 @@ +package com.esotericsoftware.spine + +import android.graphics.Point +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.ArrowBack +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.viewinterop.AndroidView +import androidx.navigation.NavHostController +import com.badlogic.gdx.math.Vector2 +import com.esotericsoftware.spine.android.SpineController +import com.esotericsoftware.spine.android.SpineView + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun IKFollowing(nav: NavHostController) { + + val containerHeight = remember { mutableIntStateOf(0) } + val dragPosition = remember { mutableStateOf(Point(0, 0)) } + val crossHairPosition = remember { mutableStateOf(null) } + + val controller = remember { + SpineController.Builder() + .setOnInitialized { + it.animationState.setAnimation(0, "walk", true) + it.animationState.setAnimation(1, "aim", true) + } + .setOnAfterUpdateWorldTransforms { + val worldPosition = crossHairPosition.value ?: return@setOnAfterUpdateWorldTransforms + val skeleton = it.skeleton + val bone = skeleton.findBone("crosshair") ?: return@setOnAfterUpdateWorldTransforms + val parent = bone.parent ?: return@setOnAfterUpdateWorldTransforms + val position = parent.worldToLocal(Vector2(worldPosition.x.toFloat(), worldPosition.y.toFloat())) + bone.x = position.x + bone.y = position.y + } + .build() + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(text = Destination.SimpleAnimation.title) }, + navigationIcon = { + IconButton({ nav.navigateUp() }) { + Icon( + Icons.Rounded.ArrowBack, + null, + ) + } + } + ) + } + ) { paddingValues -> + Box(modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .onGloballyPositioned { coordinates -> + containerHeight.intValue = coordinates.size.height + } + .pointerInput(Unit) { + detectDragGestures( + onDragStart = { offset -> + dragPosition.value = Point(offset.x.toInt(), offset.y.toInt()) + }, + onDrag = { _, dragAmount -> + dragPosition.value = Point( + (dragPosition.value.x + dragAmount.x).toInt(), + (dragPosition.value.y + dragAmount.y).toInt() + ) + val invertedYDragPosition = Point( + dragPosition.value.x, + containerHeight.intValue - dragPosition.value.y, + ) + crossHairPosition.value = controller.toSkeletonCoordinates( + invertedYDragPosition + ) + }, + ) + } + ) { + AndroidView( + factory = { ctx -> + SpineView(ctx).apply { + loadFromAsset( + "spineboy.atlas", + "spineboy-pro.json", + controller + ) + } + } + ) + } + } +} diff --git a/spine-android/app/src/main/java/com/esotericsoftware/spine/MainActivity.kt b/spine-android/app/src/main/java/com/esotericsoftware/spine/MainActivity.kt index c053e1a02..2ed7ee709 100644 --- a/spine-android/app/src/main/java/com/esotericsoftware/spine/MainActivity.kt +++ b/spine-android/app/src/main/java/com/esotericsoftware/spine/MainActivity.kt @@ -59,7 +59,8 @@ fun AppContent() { navController, listOf( Destination.SimpleAnimation, - Destination.PlayPause + Destination.PlayPause, + Destination.IKFollowing ), paddingValues ) @@ -77,6 +78,12 @@ fun AppContent() { ) { PlayPause(navController) } + + composable( + Destination.IKFollowing.route + ) { + IKFollowing(navController) + } } } } @@ -113,4 +120,5 @@ sealed class Destination(val route: String, val title: String) { data object Samples: Destination("samples", "Spine Android Examples") data object SimpleAnimation : Destination("simpleAnimation", "Simple Animation") data object PlayPause : Destination("playPause", "Play/Pause") + data object IKFollowing : Destination("ikFollowing", "IK Following") } diff --git a/spine-android/app/src/main/java/com/esotericsoftware/spine/PlayPause.kt b/spine-android/app/src/main/java/com/esotericsoftware/spine/PlayPause.kt index adc78f689..9dce90886 100644 --- a/spine-android/app/src/main/java/com/esotericsoftware/spine/PlayPause.kt +++ b/spine-android/app/src/main/java/com/esotericsoftware/spine/PlayPause.kt @@ -26,9 +26,12 @@ fun PlayPause( nav: NavHostController ) { val controller = remember { - SpineController { - it.animationState.setAnimation(0, "flying", true) - } + SpineController.Builder() + .setOnInitialized { + it.animationState.setAnimation(0, "flying", true) + } + + .build() } val isPlaying = remember { mutableStateOf(controller.isPlaying) } diff --git a/spine-android/app/src/main/java/com/esotericsoftware/spine/SimpleAnimation.kt b/spine-android/app/src/main/java/com/esotericsoftware/spine/SimpleAnimation.kt index 9876fef2c..60ab18427 100644 --- a/spine-android/app/src/main/java/com/esotericsoftware/spine/SimpleAnimation.kt +++ b/spine-android/app/src/main/java/com/esotericsoftware/spine/SimpleAnimation.kt @@ -40,9 +40,11 @@ fun SimpleAnimation(nav: NavHostController) { loadFromAsset( "spineboy.atlas", "spineboy-pro.json", - SpineController { - it.animationState.setAnimation(0, "walk", true) - } + SpineController.Builder() + .setOnInitialized { + it.animationState.setAnimation(0, "walk", true) + } + .build() ) } }, diff --git a/spine-android/spine-android/src/main/java/com/esotericsoftware/spine/android/SpineController.java b/spine-android/spine-android/src/main/java/com/esotericsoftware/spine/android/SpineController.java index 86b9de47a..bb15a8bd0 100644 --- a/spine-android/spine-android/src/main/java/com/esotericsoftware/spine/android/SpineController.java +++ b/spine-android/spine-android/src/main/java/com/esotericsoftware/spine/android/SpineController.java @@ -1,5 +1,9 @@ package com.esotericsoftware.spine.android; +import android.graphics.Point; + +import androidx.annotation.Nullable; + import com.esotericsoftware.spine.AnimationState; import com.esotericsoftware.spine.AnimationStateData; import com.esotericsoftware.spine.Skeleton; @@ -7,18 +11,51 @@ import com.esotericsoftware.spine.SkeletonData; import com.esotericsoftware.spine.android.utils.SpineControllerCallback; public class SpineController { - private final SpineControllerCallback onInitialized; - private AndroidSkeletonDrawable drawable; - private boolean playing = true; + public static class Builder { + private SpineControllerCallback onInitialized; + private SpineControllerCallback onBeforeUpdateWorldTransforms; + private SpineControllerCallback onAfterUpdateWorldTransforms; - public SpineController(SpineControllerCallback onInitialized) { - this.onInitialized = onInitialized; + public Builder setOnInitialized(SpineControllerCallback onInitialized) { + this.onInitialized = onInitialized; + return this; + } + + public Builder setOnBeforeUpdateWorldTransforms(SpineControllerCallback onBeforeUpdateWorldTransforms) { + this.onBeforeUpdateWorldTransforms = onBeforeUpdateWorldTransforms; + return this; + } + + public Builder setOnAfterUpdateWorldTransforms(SpineControllerCallback onAfterUpdateWorldTransforms) { + this.onAfterUpdateWorldTransforms = onAfterUpdateWorldTransforms; + return this; + } + + public SpineController build() { + SpineController spineController = new SpineController(); + spineController.onInitialized = onInitialized; + spineController.onBeforeUpdateWorldTransforms = onBeforeUpdateWorldTransforms; + spineController.onAfterUpdateWorldTransforms = onAfterUpdateWorldTransforms; + return spineController; + } } + private @Nullable SpineControllerCallback onInitialized; + private @Nullable SpineControllerCallback onBeforeUpdateWorldTransforms; + private @Nullable SpineControllerCallback onAfterUpdateWorldTransforms; + private AndroidSkeletonDrawable drawable; + private boolean playing = true; + private double offsetX = 0; + private double offsetY = 0; + private double scaleX = 1; + private double scaleY = 1; + protected void init(AndroidSkeletonDrawable drawable) { this.drawable = drawable; - onInitialized.execute(this); + if (onInitialized != null) { + onInitialized.execute(this); + } } public AndroidTextureAtlas getAtlas() { @@ -53,7 +90,7 @@ public class SpineController { public boolean isInitialized() { return drawable != null; - }; + } public boolean isPlaying() { return playing; @@ -66,4 +103,29 @@ public class SpineController { public void resume() { playing = true; } + + public Point toSkeletonCoordinates(Point position) { + int x = position.x; + int y = position.y; + return new Point((int) (x / scaleX - offsetX), (int) (y / scaleY - offsetY)); + } + + protected void setCoordinateTransform(double offsetX, double offsetY, double scaleX, double scaleY) { + this.offsetX = offsetX; + this.offsetY = offsetY; + this.scaleX = scaleX; + this.scaleY = scaleY; + } + + protected void callOnBeforeUpdateWorldTransforms() { + if (onBeforeUpdateWorldTransforms != null) { + onBeforeUpdateWorldTransforms.execute(this); + } + } + + protected void callOnAfterUpdateWorldTransforms() { + if (onAfterUpdateWorldTransforms != null) { + onAfterUpdateWorldTransforms.execute(this); + } + } } diff --git a/spine-android/spine-android/src/main/java/com/esotericsoftware/spine/android/SpineView.java b/spine-android/spine-android/src/main/java/com/esotericsoftware/spine/android/SpineView.java index 615f4a08a..f7f89bbed 100644 --- a/spine-android/spine-android/src/main/java/com/esotericsoftware/spine/android/SpineView.java +++ b/spine-android/spine-android/src/main/java/com/esotericsoftware/spine/android/SpineView.java @@ -147,7 +147,9 @@ public class SpineView extends View implements Choreographer.FrameCallback { } if (controller.isPlaying()) { + controller.callOnBeforeUpdateWorldTransforms(); controller.getDrawable().update(delta); + controller.callOnAfterUpdateWorldTransforms(); } canvas.save(); @@ -175,6 +177,8 @@ public class SpineView extends View implements Choreographer.FrameCallback { offsetX = (float) (getWidth() / 2.0 + (alignment.getX() * getWidth() / 2.0)); offsetY = (float) (getHeight() / 2.0 + (alignment.getY() * getHeight() / 2.0)); + + controller.setCoordinateTransform(x + offsetX / scaleX, y + offsetY / scaleY, scaleX, scaleY); } // Choreographer.FrameCallback