diff --git a/web/src/main/kotlin/world/phantasmal/web/core/rendering/conversion/NinjaAnimation.kt b/web/src/main/kotlin/world/phantasmal/web/core/rendering/conversion/NinjaAnimation.kt index fad02a61..5e55cb35 100644 --- a/web/src/main/kotlin/world/phantasmal/web/core/rendering/conversion/NinjaAnimation.kt +++ b/web/src/main/kotlin/world/phantasmal/web/core/rendering/conversion/NinjaAnimation.kt @@ -8,7 +8,8 @@ import world.phantasmal.lib.fileFormats.ninja.NjKeyframeTrack import world.phantasmal.lib.fileFormats.ninja.NjMotion import world.phantasmal.web.externals.three.* -const val PSO_FRAME_RATE: Double = 30.0 +const val PSO_FRAME_RATE: Int = 30 +const val PSO_FRAME_RATE_DOUBLE: Double = PSO_FRAME_RATE.toDouble() fun createAnimationClip(njObject: NinjaObject<*>, njMotion: NjMotion): AnimationClip { val interpolation = @@ -27,7 +28,7 @@ fun createAnimationClip(njObject: NinjaObject<*>, njMotion: NjMotion): Animation val times = jsArrayOf() for (keyframe in track.keyframes) { - times.push(keyframe.frame / PSO_FRAME_RATE) + times.push(keyframe.frame / PSO_FRAME_RATE_DOUBLE) } val values = jsArrayOf() @@ -114,7 +115,7 @@ fun createAnimationClip(njObject: NinjaObject<*>, njMotion: NjMotion): Animation return AnimationClip( "Animation", - (njMotion.frameCount - 1) / PSO_FRAME_RATE, + (njMotion.frameCount - 1) / PSO_FRAME_RATE_DOUBLE, tracks.asArray(), ).optimize() } diff --git a/web/src/main/kotlin/world/phantasmal/web/viewer/controllers/ViewerToolbarController.kt b/web/src/main/kotlin/world/phantasmal/web/viewer/controllers/ViewerToolbarController.kt index 18aef405..ac78734e 100644 --- a/web/src/main/kotlin/world/phantasmal/web/viewer/controllers/ViewerToolbarController.kt +++ b/web/src/main/kotlin/world/phantasmal/web/viewer/controllers/ViewerToolbarController.kt @@ -24,7 +24,10 @@ class ViewerToolbarController(private val store: ViewerStore) : Controller() { val showSkeleton: Val = store.showSkeleton val playAnimation: Val = store.animationPlaying - val clearCurrentAnimationButtonEnabled = store.currentNinjaMotion.isNotNull() + val frameRate: Val = store.frameRate + val frame: Val = store.frame + val animationControlsEnabled: Val = store.currentNinjaMotion.isNotNull() + val maxFrame: Val = store.currentNinjaMotion.map { "/ ${it?.frameCount ?: 0}" } val resultDialogVisible: Val = _resultDialogVisible val result: Val?> = _result val resultMessage: Val = result.map { @@ -42,6 +45,14 @@ class ViewerToolbarController(private val store: ViewerStore) : Controller() { store.setAnimationPlaying(play) } + fun setFrameRate(frameRate: Int) { + store.setFrameRate(frameRate) + } + + fun setFrame(frame: Int) { + store.setFrame(frame) + } + suspend fun clearCurrentAnimation() { store.setCurrentAnimation(null) } diff --git a/web/src/main/kotlin/world/phantasmal/web/viewer/rendering/MeshRenderer.kt b/web/src/main/kotlin/world/phantasmal/web/viewer/rendering/MeshRenderer.kt index 39316f8b..7fa41813 100644 --- a/web/src/main/kotlin/world/phantasmal/web/viewer/rendering/MeshRenderer.kt +++ b/web/src/main/kotlin/world/phantasmal/web/viewer/rendering/MeshRenderer.kt @@ -5,11 +5,13 @@ import world.phantasmal.core.math.degToRad import world.phantasmal.lib.fileFormats.ninja.NjMotion import world.phantasmal.web.core.rendering.* import world.phantasmal.web.core.rendering.Renderer +import world.phantasmal.web.core.rendering.conversion.PSO_FRAME_RATE_DOUBLE import world.phantasmal.web.core.rendering.conversion.createAnimationClip import world.phantasmal.web.core.rendering.conversion.ninjaObjectToSkinnedMesh import world.phantasmal.web.core.times import world.phantasmal.web.externals.three.* import world.phantasmal.web.viewer.stores.ViewerStore +import kotlin.math.roundToInt import kotlin.math.tan class MeshRenderer( @@ -21,6 +23,7 @@ class MeshRenderer( private var mesh: Mesh? = null private var skeletonHelper: SkeletonHelper? = null private var animation: Animation? = null + private var updateAnimationTime = true private var charClassActive = false override val context = addDisposable(RenderContext( @@ -48,6 +51,8 @@ class MeshRenderer( observe(viewerStore.currentNinjaMotion, ::ninjaMotionChanged) observe(viewerStore.showSkeleton) { skeletonHelper?.visible = it } observe(viewerStore.animationPlaying, ::animationPlayingChanged) + observe(viewerStore.frameRate, ::frameRateChanged) + observe(viewerStore.frame, ::frameChanged) } override fun render() { @@ -58,11 +63,13 @@ class MeshRenderer( super.render() animation?.let { - // TODO: Update current animation frame in store. -// val action = it.mixer.clipAction(it.clip) -// -// if (!action.paused) { -// } + val action = it.mixer.clipAction(it.clip) + + if (!action.paused) { + updateAnimationTime = false + viewerStore.setFrame((action.time * PSO_FRAME_RATE_DOUBLE + 1).roundToInt()) + updateAnimationTime = true + } } } @@ -126,8 +133,7 @@ class MeshRenderer( // Create a new animation mixer and clip. viewerStore.currentNinjaMotion.value?.let { nj_motion -> val mixer = AnimationMixer(mesh) - // TODO: Set time scale. -// mixer.timeScale = this.store.animation_frame_rate.val / PSO_FRAME_RATE; + mixer.timeScale = viewerStore.frameRate.value / PSO_FRAME_RATE_DOUBLE val clip = createAnimationClip(ninjaObject, nj_motion) @@ -170,8 +176,8 @@ class MeshRenderer( } private fun animationPlayingChanged(playing: Boolean) { - animation?.let { animation -> - animation.mixer.clipAction(animation.clip).paused = !playing + animation?.let { + it.mixer.clipAction(it.clip).paused = !playing if (playing) { clock.start() @@ -181,6 +187,20 @@ class MeshRenderer( } } + private fun frameRateChanged(frameRate: Int) { + animation?.let { + it.mixer.timeScale = frameRate / PSO_FRAME_RATE_DOUBLE + } + } + + private fun frameChanged(frame: Int) { + if (updateAnimationTime) { + animation?.let { + it.mixer.clipAction(it.clip).time = (frame - 1) / PSO_FRAME_RATE_DOUBLE + } + } + } + private class Animation(val mixer: AnimationMixer, val clip: AnimationClip) companion object { diff --git a/web/src/main/kotlin/world/phantasmal/web/viewer/stores/ViewerStore.kt b/web/src/main/kotlin/world/phantasmal/web/viewer/stores/ViewerStore.kt index c14d5393..6ffb03e3 100644 --- a/web/src/main/kotlin/world/phantasmal/web/viewer/stores/ViewerStore.kt +++ b/web/src/main/kotlin/world/phantasmal/web/viewer/stores/ViewerStore.kt @@ -11,6 +11,7 @@ import world.phantasmal.observable.value.list.ListVal import world.phantasmal.observable.value.list.mutableListVal import world.phantasmal.observable.value.mutableVal import world.phantasmal.web.core.PwToolType +import world.phantasmal.web.core.rendering.conversion.PSO_FRAME_RATE import world.phantasmal.web.core.stores.UiStore import world.phantasmal.web.shared.dto.SectionId import world.phantasmal.web.viewer.ViewerUrls @@ -42,6 +43,8 @@ class ViewerStore( // Settings. private val _showSkeleton = mutableVal(false) private val _animationPlaying = mutableVal(true) + private val _frameRate = mutableVal(PSO_FRAME_RATE) + private val _frame = mutableVal(0) // Ninja concepts. val currentNinjaObject: Val?> = _currentNinjaObject @@ -63,6 +66,8 @@ class ViewerStore( // Settings. val showSkeleton: Val = _showSkeleton val animationPlaying: Val = _animationPlaying + val frameRate: Val = _frameRate + val frame: Val = _frame init { for (path in listOf(ViewerUrls.mesh, ViewerUrls.texture)) { @@ -198,6 +203,20 @@ class ViewerStore( _animationPlaying.value = playing } + fun setFrameRate(frameRate: Int) { + _frameRate.value = frameRate + } + + fun setFrame(frame: Int) { + val maxFrame = currentNinjaMotion.value?.frameCount ?: Int.MAX_VALUE + + _frame.value = when { + frame > maxFrame -> 1 + frame < 1 -> maxFrame + else -> frame + } + } + private suspend fun loadCharacterClassNinjaObject(clearAnimation: Boolean) { val char = currentCharacterClass.value ?: return diff --git a/web/src/main/kotlin/world/phantasmal/web/viewer/widgets/ViewerToolbar.kt b/web/src/main/kotlin/world/phantasmal/web/viewer/widgets/ViewerToolbar.kt index f8508df4..0c419179 100644 --- a/web/src/main/kotlin/world/phantasmal/web/viewer/widgets/ViewerToolbar.kt +++ b/web/src/main/kotlin/world/phantasmal/web/viewer/widgets/ViewerToolbar.kt @@ -28,12 +28,34 @@ class ViewerToolbar(private val ctrl: ViewerToolbarController) : Widget() { ), Checkbox( label = "Play animation", + enabled = ctrl.animationControlsEnabled, checked = ctrl.playAnimation, onChange = ctrl::setPlayAnimation, ), + IntInput( + label = "Frame rate:", + enabled = ctrl.animationControlsEnabled, + value = ctrl.frameRate, + onChange = ctrl::setFrameRate, + min = 1, + max = 240, + step = 1, + ), + IntInput( + label = "Frame:", + enabled = ctrl.animationControlsEnabled, + value = ctrl.frame, + onChange = ctrl::setFrame, + step = 1, + ), + Label( + enabled = ctrl.animationControlsEnabled, + textVal = ctrl.maxFrame, + ), Button( + className = "pw-viewer-toolbar-clear-animation", text = "Clear animation", - enabled = ctrl.clearCurrentAnimationButtonEnabled, + enabled = ctrl.animationControlsEnabled, onClick = { scope.launch { ctrl.clearCurrentAnimation() } }, ), ) @@ -45,4 +67,16 @@ class ViewerToolbar(private val ctrl: ViewerToolbarController) : Widget() { onDismiss = ctrl::dismissResultDialog, )) } + + companion object { + init { + @Suppress("CssUnusedSymbol") + // language=css + style(""" + .pw-viewer-toolbar > .pw-toolbar > .pw-viewer-toolbar-clear-animation { + margin-left: 6px; + } + """.trimIndent()) + } + } } diff --git a/webui/src/main/kotlin/world/phantasmal/webui/widgets/Button.kt b/webui/src/main/kotlin/world/phantasmal/webui/widgets/Button.kt index 5b41e0c6..49a6d2e9 100644 --- a/webui/src/main/kotlin/world/phantasmal/webui/widgets/Button.kt +++ b/webui/src/main/kotlin/world/phantasmal/webui/widgets/Button.kt @@ -15,6 +15,7 @@ open class Button( visible: Val = trueVal(), enabled: Val = trueVal(), tooltip: Val = nullVal(), + private val className: String? = null, private val text: String? = null, private val textVal: Val? = null, private val iconLeft: Icon? = null, @@ -29,6 +30,9 @@ open class Button( override fun Node.createElement() = button { className = "pw-button" + + this@Button.className?.let { classList.add(it) } + onmousedown = onMouseDown onmouseup = onMouseUp onclick = onClick diff --git a/webui/src/main/kotlin/world/phantasmal/webui/widgets/FileButton.kt b/webui/src/main/kotlin/world/phantasmal/webui/widgets/FileButton.kt index 3417c774..d84ebb58 100644 --- a/webui/src/main/kotlin/world/phantasmal/webui/widgets/FileButton.kt +++ b/webui/src/main/kotlin/world/phantasmal/webui/widgets/FileButton.kt @@ -13,6 +13,7 @@ class FileButton( visible: Val = trueVal(), enabled: Val = trueVal(), tooltip: Val = nullVal(), + className: String? = null, text: String? = null, textVal: Val? = null, iconLeft: Icon? = null, @@ -20,7 +21,7 @@ class FileButton( private val accept: String = "", private val multiple: Boolean = false, private val filesSelected: ((List) -> Unit)? = null, -) : Button(visible, enabled, tooltip, text, textVal, iconLeft, iconRight) { +) : Button(visible, enabled, tooltip, className, text, textVal, iconLeft, iconRight) { override fun interceptElement(element: HTMLElement) { element.classList.add("pw-file-button")