diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6afaa34e..268a88fc 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -19,9 +19,3 @@ jobs: - uses: gradle/gradle-build-action@v2 - name: Build run: ./gradlew :desktopApp:packageReleaseStripArchitecture - - name: Upload distributable - uses: actions/upload-artifact@v3 - with: - name: Distributable - path: | - desktopApp/build/distributable/ diff --git a/build.gradle.kts b/build.gradle.kts index 9f937cc0..9e28989c 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -6,7 +6,3 @@ plugins { id("org.jetbrains.compose") version libs.versions.composePlugin.get() apply false id("com.google.devtools.ksp") version libs.versions.ksp.get() apply false } - -tasks.register("clean", Delete::class) { - delete(rootProject.buildDir) -} diff --git a/gradle.properties b/gradle.properties index 870edd9d..38b0e9ce 100644 --- a/gradle.properties +++ b/gradle.properties @@ -14,3 +14,4 @@ kotlin.mpp.androidSourceSetLayoutVersion=2 #Compose org.jetbrains.compose.experimental.uikit.enabled=true +org.jetbrains.compose.experimental.jscanvas.enabled=true diff --git a/settings.gradle.kts b/settings.gradle.kts index d771ac77..5cfccdf1 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -25,4 +25,5 @@ rootProject.name = "puzzyx" include(":androidApp") include(":desktopApp") +include(":webApp") include(":shared") diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index 85bf1be0..64510117 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -7,7 +7,7 @@ plugins { } kotlin { - android { + androidTarget { compilations.all { kotlinOptions.jvmTarget = libs.versions.jvmTarget.get() } @@ -19,6 +19,12 @@ kotlin { } } + js(IR) { + // Adding moduleName as a workaround for this issue: https://youtrack.jetbrains.com/issue/KT-51942 + moduleName = "puzzyx-common" + browser() + } + sourceSets { val commonMain by getting { dependencies { @@ -57,4 +63,5 @@ dependencies { add("kspCommonMainMetadata", libs.appyx.mutable.ui.processor) add("kspAndroid", libs.appyx.mutable.ui.processor) add("kspDesktop", libs.appyx.mutable.ui.processor) + add("kspJs", libs.appyx.mutable.ui.processor) } diff --git a/shared/src/androidMain/kotlin/com/bumble/puzzyx/imageloader/ResourceImage.kt b/shared/src/androidMain/kotlin/com/bumble/puzzyx/imageloader/ResourceImage.kt new file mode 100644 index 00000000..89b97be0 --- /dev/null +++ b/shared/src/androidMain/kotlin/com/bumble/puzzyx/imageloader/ResourceImage.kt @@ -0,0 +1,20 @@ +package com.bumble.puzzyx.imageloader + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale + +@Composable +actual fun EmbeddableResourceImage( + path: String, + modifier: Modifier, + contentDescription: String?, + contentScale: ContentScale +) { + ResourceImage( + path = path, + modifier = modifier, + contentScale = contentScale, + contentDescription = contentDescription, + ) +} diff --git a/shared/src/commonMain/kotlin/com/bumble/puzzyx/appyx/component/backstackclipper/ClipShapeProgress.kt b/shared/src/commonMain/kotlin/com/bumble/puzzyx/appyx/component/backstackclipper/ClipShapeProgress.kt index 7402d079..67419e1f 100644 --- a/shared/src/commonMain/kotlin/com/bumble/puzzyx/appyx/component/backstackclipper/ClipShapeProgress.kt +++ b/shared/src/commonMain/kotlin/com/bumble/puzzyx/appyx/component/backstackclipper/ClipShapeProgress.kt @@ -28,7 +28,8 @@ class ClipShapeProgress( coroutineScope: CoroutineScope, target: Target, displacement: StateFlow = MutableStateFlow(0f), - private val shape: @Composable (progress: Float) -> Shape = { RectangleShape }, + // web-target doesn't like a lambda here for some reason + private val shape: @Composable ((progress: Float) -> Shape)? = null, ) : MotionProperty( coroutineScope = coroutineScope, animatable = Animatable(target.value), @@ -47,10 +48,9 @@ class ClipShapeProgress( get() = Modifier.composed { val progress = renderValueFlow.collectAsState().value if (progress == 0f) this - else this.clip(shape.invoke(progress)) + else this.clip(shape?.invoke(progress) ?: RectangleShape) } - override suspend fun lerpTo(start: Target, end: Target, fraction: Float) { snapTo( lerpFloat( diff --git a/shared/src/commonMain/kotlin/com/bumble/puzzyx/appyx/component/messages/LinesOfMessagesVisualisation.kt b/shared/src/commonMain/kotlin/com/bumble/puzzyx/appyx/component/messages/LinesOfMessagesVisualisation.kt index 62441b2d..b3c4b459 100644 --- a/shared/src/commonMain/kotlin/com/bumble/puzzyx/appyx/component/messages/LinesOfMessagesVisualisation.kt +++ b/shared/src/commonMain/kotlin/com/bumble/puzzyx/appyx/component/messages/LinesOfMessagesVisualisation.kt @@ -1,22 +1,23 @@ package com.bumble.puzzyx.appyx.component.messages import androidx.compose.animation.core.SpringSpec -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.DpSize import com.bumble.appyx.interactions.core.ui.context.UiContext import com.bumble.appyx.interactions.core.ui.property.impl.Alpha import com.bumble.appyx.interactions.core.ui.property.impl.RotationX import com.bumble.appyx.interactions.core.ui.property.impl.Scale -import com.bumble.appyx.interactions.core.ui.property.impl.position.PositionOffset +import com.bumble.appyx.interactions.core.ui.property.impl.position.BiasAlignment +import com.bumble.appyx.interactions.core.ui.property.impl.position.PositionAlignment import com.bumble.appyx.interactions.core.ui.state.MatchedTargetUiState import com.bumble.appyx.transitionmodel.BaseVisualisation import com.bumble.puzzyx.appyx.component.messages.MessagesModel.ElementState.CREATED import com.bumble.puzzyx.appyx.component.messages.MessagesModel.ElementState.FLIPPED import com.bumble.puzzyx.appyx.component.messages.MessagesModel.ElementState.REVEALED import com.bumble.puzzyx.appyx.component.messages.MessagesModel.State +import com.bumble.puzzyx.math.mapValueRange import com.bumble.puzzyx.model.MessageId import kotlin.math.nextUp +import kotlin.math.roundToInt class LinesOfMessagesVisualisation( uiContext: UiContext, @@ -30,24 +31,30 @@ class LinesOfMessagesVisualisation( ) { private val created = TargetUiState( + linePosition = PositionAlignment.Target( + PositionAlignment.Value(insideAlignment = BiasAlignment.InsideAlignment.Center) + ), rotationX = RotationX.Target(0f), scale = Scale.Target(1.2f), alpha = Alpha.Target(0f), - position = PositionOffset.Target(), ) private val revealed = TargetUiState( + linePosition = PositionAlignment.Target( + PositionAlignment.Value(insideAlignment = BiasAlignment.InsideAlignment.Center) + ), rotationX = RotationX.Target(0f), scale = Scale.Target(1f), alpha = Alpha.Target(1f), - position = PositionOffset.Target(), ) private val flipped = TargetUiState( + linePosition = PositionAlignment.Target( + PositionAlignment.Value(insideAlignment = BiasAlignment.InsideAlignment.Center) + ), rotationX = RotationX.Target(-90f), scale = Scale.Target(0.8f), alpha = Alpha.Target(0f), - position = PositionOffset.Target(), ) override fun mutableUiStateFor( @@ -77,30 +84,40 @@ class LinesOfMessagesVisualisation( * Row 1: 0 2 4 6 * Row 2: 1 3 5 */ - val effectiveMaxWidth = (effectiveEntrySize.width * (elements.size / 2f).nextUp()) / 2f - val horizontalOffset = -effectiveMaxWidth + halfEffectiveEntrySize.width * index - val verticalOffset = if (index % 2 == parity) { - halfEffectiveEntrySize.height + val mappedHalfEntryHeight = + effectiveEntrySize.height / (transitionBounds.heightDp - effectiveEntrySize.height) + val horizontalBias = mapValueRange( + value = transitionBounds.widthDp.value / 2f - halfEffectiveEntrySize.width.value * ((elements.size / 2f).nextUp() + .roundToInt()) + index * halfEffectiveEntrySize.width.value, + fromRangeMin = 0f, + fromRangeMax = transitionBounds.widthDp.value - effectiveEntrySize.width.value, + destRangeMin = -1f, + destRangeMax = 1f, + ) + val verticalBias = if (index % 2 == parity) { + -mappedHalfEntryHeight } else { - -halfEffectiveEntrySize.height + mappedHalfEntryHeight } MatchedTargetUiState( element = entry.key, targetUiState = when (entry.value) { - CREATED -> created.withUpdatedPosition(horizontalOffset, verticalOffset) - REVEALED -> revealed.withUpdatedPosition(horizontalOffset, verticalOffset) - FLIPPED -> flipped.withUpdatedPosition(horizontalOffset, verticalOffset) + CREATED -> created.withUpdatedPosition(horizontalBias, verticalBias) + REVEALED -> revealed.withUpdatedPosition(horizontalBias, verticalBias) + FLIPPED -> flipped.withUpdatedPosition(horizontalBias, verticalBias) }, ) } } - private fun TargetUiState.withUpdatedPosition(horizontalBias: Dp, verticalBias: Dp) = + private fun TargetUiState.withUpdatedPosition(horizontalBias: Float, verticalBias: Float) = copy( - position = PositionOffset.Target( - offset = DpOffset( - x = transitionBounds.widthDp / 2f + horizontalBias, - y = transitionBounds.heightDp / 2f + verticalBias, + linePosition = PositionAlignment.Target( + PositionAlignment.Value( + insideAlignment = BiasAlignment.InsideAlignment( + horizontalBias, + verticalBias + ) ) ) ) diff --git a/shared/src/commonMain/kotlin/com/bumble/puzzyx/appyx/component/messages/TargetUiState.kt b/shared/src/commonMain/kotlin/com/bumble/puzzyx/appyx/component/messages/TargetUiState.kt index c79d8ec6..25a3a420 100644 --- a/shared/src/commonMain/kotlin/com/bumble/puzzyx/appyx/component/messages/TargetUiState.kt +++ b/shared/src/commonMain/kotlin/com/bumble/puzzyx/appyx/component/messages/TargetUiState.kt @@ -3,12 +3,12 @@ package com.bumble.puzzyx.appyx.component.messages import com.bumble.appyx.interactions.core.ui.property.impl.Alpha import com.bumble.appyx.interactions.core.ui.property.impl.RotationX import com.bumble.appyx.interactions.core.ui.property.impl.Scale -import com.bumble.appyx.interactions.core.ui.property.impl.position.PositionOffset +import com.bumble.appyx.interactions.core.ui.property.impl.position.PositionAlignment import com.bumble.appyx.interactions.core.ui.state.MutableUiStateSpecs @MutableUiStateSpecs data class TargetUiState( - val position: PositionOffset.Target, + val linePosition: PositionAlignment.Target, val rotationX: RotationX.Target, val scale: Scale.Target, val alpha: Alpha.Target, diff --git a/shared/src/commonMain/kotlin/com/bumble/puzzyx/composable/EntryCard.kt b/shared/src/commonMain/kotlin/com/bumble/puzzyx/composable/EntryCard.kt index 6105ad35..63ffbc99 100644 --- a/shared/src/commonMain/kotlin/com/bumble/puzzyx/composable/EntryCard.kt +++ b/shared/src/commonMain/kotlin/com/bumble/puzzyx/composable/EntryCard.kt @@ -25,9 +25,9 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.bumble.puzzyx.imageloader.EmbeddableResourceImage import com.bumble.appyx.navigation.integration.LocalScreenSize import com.bumble.appyx.navigation.integration.ScreenSize.WindowSizeClass -import com.bumble.puzzyx.imageloader.ResourceImage import com.bumble.puzzyx.model.Entry import com.bumble.puzzyx.ui.colors import kotlinx.coroutines.isActive @@ -48,7 +48,7 @@ fun EntryCard( paddingTop = size ) - is Entry.Image -> ResourceImage( + is Entry.Image -> EmbeddableResourceImage( path = "participant/${entry.path}", contentDescription = entry.contentDescription, contentScale = entry.contentScale, @@ -76,7 +76,7 @@ fun GitHubHeader( verticalAlignment = Alignment.CenterVertically, modifier = modifier ) { - ResourceImage( + EmbeddableResourceImage( path = "github.png", contentScale = ContentScale.Inside, modifier = Modifier diff --git a/shared/src/commonMain/kotlin/com/bumble/puzzyx/composable/StarFieldMessageBoard.kt b/shared/src/commonMain/kotlin/com/bumble/puzzyx/composable/StarFieldMessageBoard.kt index 441db447..d9ad673a 100644 --- a/shared/src/commonMain/kotlin/com/bumble/puzzyx/composable/StarFieldMessageBoard.kt +++ b/shared/src/commonMain/kotlin/com/bumble/puzzyx/composable/StarFieldMessageBoard.kt @@ -32,10 +32,8 @@ import com.bumble.appyx.navigation.collections.ImmutableList import com.bumble.appyx.navigation.collections.toImmutableList import com.bumble.puzzyx.composable.StarField.Companion.generateStars import com.bumble.puzzyx.model.Entry -import com.bumble.puzzyx.model.entries import com.bumble.puzzyx.ui.appyx_dark import kotlinx.coroutines.isActive -import kotlin.math.max import kotlin.math.roundToInt import kotlin.random.Random @@ -77,12 +75,6 @@ private sealed class StarType { const val aspectRatio: Float = 1.5f } } - - fun calcZNewCoord(zFadeInStart: Float, zOffset: Float, maxEntries: Int): Float = - when (this) { - is RegularType -> zFadeInStart - is EntryType -> zFadeInStart - zOffset * max(0, entries.size - maxEntries) - } } @Immutable @@ -91,11 +83,14 @@ private data class StarField( val stars: ImmutableList, ) { companion object { - fun generateStars(starFieldSpecs: StarFieldSpecs): StarField = + fun generateStars( + starFieldSpecs: StarFieldSpecs, + entries: ImmutableList + ): StarField = StarField( specs = starFieldSpecs, stars = (regularStars(starFieldSpecs) - + entryStars(starFieldSpecs) + + entryStars(starFieldSpecs, entries) ).toImmutableList() ) @@ -117,8 +112,8 @@ private data class StarField( ) }.toList() - private fun entryStars(starFieldSpecs: StarFieldSpecs) = - entries.reversed().mapIndexed { index, entry -> + private fun entryStars(starFieldSpecs: StarFieldSpecs, entries: ImmutableList) = + entries.mapIndexed { index, entry -> Star( zCoord = starFieldSpecs.zFadeInStart - index * starFieldSpecs.zOffset, sizeDp = StarType.EntryType.sizeDp * starFieldSpecs.scaleFactor, @@ -144,28 +139,29 @@ private fun StarField.update( star.copy( xCoord = Random.nextDouble(-0.5, 0.5).toFloat(), yCoord = Random.nextDouble(-0.5, 0.5).toFloat(), - zCoord = star.type.calcZNewCoord( - specs.zFadeInStart, - specs.zOffset, - specs.maxEntries - ), + zCoord = specs.zFadeInStart, ) } - }.toImmutableList() + }.toImmutableList(), ) @Composable fun StarFieldMessageBoard( + entries: ImmutableList, modifier: Modifier = Modifier, ) { val scaleFactor = scaleFactor() - val starFieldSpecs = remember(scaleFactor) { - StarFieldSpecs(scaleFactor = scaleFactor) + val starFieldSpecs = remember(scaleFactor, entries) { + StarFieldSpecs( + maxEntries = entries.size, + scaleFactor = scaleFactor + ) } - var starField by remember { mutableStateOf(generateStars(starFieldSpecs)) } + var starField by remember { mutableStateOf(generateStars(starFieldSpecs, entries)) } + var running by remember { mutableStateOf(true) } LaunchedEffect(Unit) { var lastFrame = 0L - while (isActive) { + while (isActive && running) { withFrameMillis { if (lastFrame == 0L) { lastFrame = it @@ -203,8 +199,9 @@ private fun StarFieldContent( val yPos = star.yCoord * zPos val alpha = starField.specs.calcAlpha(zPos) if (alpha > 0f) { - OptimisingLayout( - optimalWidth = star.sizeDp, + StarContent( + star.type, + star.sizeDp, modifier = Modifier .scale(zPos) .size(star.sizeDp) @@ -218,11 +215,7 @@ private fun StarFieldContent( } .alpha(alpha) .zIndex(zPos) - ) { - StarContent( - star.type, - ) - } + ) } } } @@ -235,33 +228,39 @@ private fun StarFieldSpecs.calcAlpha(zPos: Float) = @Composable private fun StarContent( type: StarType, + sizeDp: Dp, modifier: Modifier = Modifier, ) { when (type) { is StarType.RegularType -> RegularStarContent(type.color, modifier) - is StarType.EntryType -> EntryStarContent(type.entry, modifier) + is StarType.EntryType -> EntryStarContent(type.entry, sizeDp, modifier) } } @Composable -private fun EntryStarContent( - entry: Entry, +private fun RegularStarContent( + color: Color, modifier: Modifier = Modifier, ) { - EntryCard( - entry = entry, + Canvas( modifier = modifier - ) + ) { + drawCircle(color = color, radius = density * 2f) + } } @Composable -private fun RegularStarContent( - color: Color, +private fun EntryStarContent( + entry: Entry, + sizeDp: Dp, modifier: Modifier = Modifier, ) { - Canvas( - modifier = modifier + OptimisingLayout( + optimalWidth = sizeDp, + modifier = modifier, ) { - drawCircle(color = color, radius = density * 2f) + EntryCard( + entry = entry, + ) } } diff --git a/shared/src/commonMain/kotlin/com/bumble/puzzyx/imageloader/ResourceImage.kt b/shared/src/commonMain/kotlin/com/bumble/puzzyx/imageloader/ResourceImage.kt index 871eeb95..ff9c72eb 100644 --- a/shared/src/commonMain/kotlin/com/bumble/puzzyx/imageloader/ResourceImage.kt +++ b/shared/src/commonMain/kotlin/com/bumble/puzzyx/imageloader/ResourceImage.kt @@ -10,23 +10,45 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.layout.ContentScale +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import org.jetbrains.compose.resources.ExperimentalResourceApi import org.jetbrains.compose.resources.resource +@Composable +expect fun EmbeddableResourceImage( + path: String, + modifier: Modifier = Modifier, + contentDescription: String? = null, + contentScale: ContentScale = ContentScale.Fit, +) + @OptIn(ExperimentalResourceApi::class) @Composable -fun ResourceImage( +internal fun ResourceImage( path: String, + fallbackUrl: String = path, modifier: Modifier = Modifier, contentDescription: String? = null, contentScale: ContentScale = ContentScale.Fit ) { var image: ImageBitmap? by remember { mutableStateOf(null) } LaunchedEffect(Unit) { - image = - resource(path) - .readBytes() - .toImageBitmap() + image = withContext(Dispatchers.Default) { + try { + resource(path) + .readBytes() + .toImageBitmap() + } catch (e: Throwable) { + try { + resource(fallbackUrl) + .readBytes() + .toImageBitmap() + } catch (e: Throwable) { + null + } + } + } } image?.let { Image( diff --git a/shared/src/commonMain/kotlin/com/bumble/puzzyx/model/EntriesExt.kt b/shared/src/commonMain/kotlin/com/bumble/puzzyx/model/EntriesExt.kt new file mode 100644 index 00000000..6a5c6eaf --- /dev/null +++ b/shared/src/commonMain/kotlin/com/bumble/puzzyx/model/EntriesExt.kt @@ -0,0 +1,26 @@ +package com.bumble.puzzyx.model + +import com.bumble.appyx.navigation.collections.ImmutableList +import com.bumble.appyx.navigation.collections.toImmutableList + +fun List.getFeaturedEntries( + entriesCount: Int, + newestEntriesCount: Int +): ImmutableList { + require(entriesCount >= newestEntriesCount) + val newestEntries = this + .takeLast(newestEntriesCount) + .reversed() + + val remainingEntries = this + .dropLast(newestEntriesCount) + .shuffled() + .take(entriesCount - newestEntriesCount) + val temporaryEntries = newestEntries + remainingEntries + return if (temporaryEntries.size < entriesCount) { + val repeatEntries = this.shuffled().take(entriesCount - temporaryEntries.size) + (newestEntries + remainingEntries + repeatEntries).toImmutableList() + } else { + (newestEntries + remainingEntries).toImmutableList() + } +} diff --git a/shared/src/commonMain/kotlin/com/bumble/puzzyx/node/app/PuzzyxAppNode.kt b/shared/src/commonMain/kotlin/com/bumble/puzzyx/node/app/PuzzyxAppNode.kt index 7c8256bc..8e001290 100644 --- a/shared/src/commonMain/kotlin/com/bumble/puzzyx/node/app/PuzzyxAppNode.kt +++ b/shared/src/commonMain/kotlin/com/bumble/puzzyx/node/app/PuzzyxAppNode.kt @@ -26,30 +26,26 @@ import androidx.compose.ui.graphics.Shape import com.bumble.appyx.components.backstack.BackStack import com.bumble.appyx.components.backstack.BackStackModel import com.bumble.appyx.components.backstack.operation.replace -import com.bumble.appyx.navigation.collections.toImmutableList import com.bumble.appyx.navigation.composable.AppyxComponent import com.bumble.appyx.navigation.integration.LocalScreenSize import com.bumble.appyx.navigation.modality.BuildContext import com.bumble.appyx.navigation.node.Node import com.bumble.appyx.navigation.node.ParentNode -import com.bumble.appyx.navigation.node.children import com.bumble.appyx.navigation.node.node import com.bumble.appyx.utils.multiplatform.Parcelable import com.bumble.appyx.utils.multiplatform.Parcelize import com.bumble.puzzyx.appyx.component.backstackclipper.BackStackClipper import com.bumble.puzzyx.composable.AutoPlayScript import com.bumble.puzzyx.composable.CallToActionScreen -import com.bumble.puzzyx.composable.StarFieldMessageBoard -import com.bumble.puzzyx.model.MessageId import com.bumble.puzzyx.model.Puzzle.PUZZLE1 -import com.bumble.puzzyx.model.entries import com.bumble.puzzyx.node.app.PuzzyxAppNode.NavTarget import com.bumble.puzzyx.node.app.PuzzyxAppNode.NavTarget.CallToAction import com.bumble.puzzyx.node.app.PuzzyxAppNode.NavTarget.Puzzle1 import com.bumble.puzzyx.node.app.PuzzyxAppNode.NavTarget.StackedMessages -import com.bumble.puzzyx.node.app.PuzzyxAppNode.NavTarget.StarFieldMessageBoard +import com.bumble.puzzyx.node.app.PuzzyxAppNode.NavTarget.StarField import com.bumble.puzzyx.node.messages.StackedMessagesNode import com.bumble.puzzyx.node.puzzle1.Puzzle1Node +import com.bumble.puzzyx.node.starfield.StarFieldNode import com.bumble.puzzyx.ui.DottedMeshShape import com.bumble.puzzyx.ui.LocalAutoPlayFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -58,7 +54,7 @@ import kotlinx.coroutines.flow.update private val screens = listOf( Puzzle1, CallToAction, - StarFieldMessageBoard, + StarField, StackedMessages, ) @@ -88,7 +84,7 @@ class PuzzyxAppNode( object CallToAction : NavTarget() @Parcelize - object StarFieldMessageBoard : NavTarget() + object StarField : NavTarget() } @@ -103,11 +99,8 @@ class PuzzyxAppNode( AutoPlayScript(initialDelayMs = 5000) { nextScreen() } CallToActionScreen(modifier) } - is StarFieldMessageBoard -> node(buildContext) { modifier -> - AutoPlayScript(initialDelayMs = 15000) { nextScreen() } - StarFieldMessageBoard(modifier) - children() - } + + is StarField -> StarFieldNode(buildContext) is StackedMessages -> StackedMessagesNode(buildContext) } diff --git a/shared/src/commonMain/kotlin/com/bumble/puzzyx/node/messages/MessagesNode.kt b/shared/src/commonMain/kotlin/com/bumble/puzzyx/node/messages/MessagesNode.kt index 8affe18b..5bf058ad 100644 --- a/shared/src/commonMain/kotlin/com/bumble/puzzyx/node/messages/MessagesNode.kt +++ b/shared/src/commonMain/kotlin/com/bumble/puzzyx/node/messages/MessagesNode.kt @@ -7,7 +7,6 @@ import androidx.compose.animation.core.Spring import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable @@ -16,14 +15,12 @@ import androidx.compose.runtime.key import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.drawBehind -import androidx.compose.ui.geometry.Size -import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.CompositingStrategy import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp +import com.bumble.appyx.interactions.core.ui.LocalBoxScope import com.bumble.appyx.navigation.composable.AppyxComponent import com.bumble.appyx.navigation.integration.LocalScreenSize import com.bumble.appyx.navigation.modality.BuildContext @@ -37,9 +34,9 @@ import com.bumble.puzzyx.appyx.component.messages.operation.reveal import com.bumble.puzzyx.composable.AutoPlayScript import com.bumble.puzzyx.composable.EntryCard import com.bumble.puzzyx.composable.OptimisingLayout +import com.bumble.puzzyx.model.Entry import com.bumble.puzzyx.model.MessageId -import com.bumble.puzzyx.model.entries -import kotlinx.coroutines.async +import kotlinx.coroutines.launch import kotlin.random.Random private val animationSpec = spring( @@ -51,6 +48,7 @@ class MessagesNode( buildContext: BuildContext, private val index: Int, private val messages: List, + private val localEntries: List, private val component: Messages = Messages( messages = messages, visualisation = { @@ -68,7 +66,7 @@ class MessagesNode( savedStateMap = buildContext.savedStateMap, defaultAnimationSpec = animationSpec ), - private val onFinished: (Long) -> Unit, + private val onFinished: (Int) -> Unit, ) : ParentNode( buildContext = buildContext, appyxComponent = component @@ -76,54 +74,55 @@ class MessagesNode( override fun resolve(interactionTarget: MessageId, buildContext: BuildContext): Node = node(buildContext) { modifier -> - EntryCard( - modifier = modifier - .size(ENTRY_WIDTH.dp) - .aspectRatio(ENTRY_ASPECT_RATIO), - entry = entries[interactionTarget.entryId], - ) + LocalBoxScope.current?.run { + EntryCard( + entry = localEntries[interactionTarget.entryId], + modifier = modifier + .align(Alignment.Center) + .size(ENTRY_WIDTH.dp, ENTRY_WIDTH.dp / ENTRY_ASPECT_RATIO) + ) + } } @Composable override fun View(modifier: Modifier) { key(index) { - val initialDelay = 5000L * index + val initialDelay = INITIAL_DELAY * index AutoPlayScript( steps = buildList { val reorderedMessages = messages.shuffled() revealMessages(reorderedMessages) flipMessages(reorderedMessages) }, - initialDelayMs = 4000 + initialDelay, - onFinish = { onFinished(initialDelay) } + initialDelayMs = (INITIAL_EXTRA_DELAY + initialDelay).toLong(), + onFinish = { onFinished(index) } ) Box( modifier = modifier - .fillMaxSize() ) { - val verticalBias = remember { Animatable(-0.4f) } - val sign = remember { if (Random.nextBoolean()) 1f else -1f } - val targetRotationXY = remember { -sign * (2f + 4f * Random.nextFloat()) } - val rotationZ = remember { sign * (1.5f + 1.5f * Random.nextFloat()) } + val verticalBias = remember { Animatable(-0.3f) } + val sign = remember { if (index % 2 == 0) 1f else -1f } + val targetRotationXY = remember { -sign * (2f + 1f * Random.nextFloat()) } + val rotationZ = remember { sign * (1.5f + 0.5f * Random.nextFloat()) } val rotationXY = remember { Animatable(0f) } LaunchedEffect(Unit) { - async { + launch { verticalBias.animateTo( targetValue = 0.2f, animationSpec = tween( - delayMillis = 5000 * index, - durationMillis = 10000, + delayMillis = INITIAL_DELAY * index, + durationMillis = VERTICAL_BIAS_DURATION, easing = LinearEasing ), ) } - async { + launch { rotationXY.animateTo( targetValue = targetRotationXY, animationSpec = tween( - durationMillis = 6000, - delayMillis = (4000 + initialDelay).toInt(), + delayMillis = INITIAL_DELAY * index, + durationMillis = ROTATION_DURATION, easing = FastOutSlowInEasing, ), ) @@ -132,7 +131,6 @@ class MessagesNode( OptimisingLayout( optimalWidth = 1500.dp, paddingFraction = 0f, - modifier = Modifier.align(Alignment.Center) ) { val translationY = verticalBias.value * with(LocalDensity.current) { LocalScreenSize.current.heightDp.toPx() @@ -147,7 +145,7 @@ class MessagesNode( this.rotationY = rotationXY.value this.rotationZ = rotationZ this.translationY = translationY - }, + } ) } } @@ -166,8 +164,9 @@ class MessagesNode( messages: List, operation: Messages.(Int) -> Unit, ) { + val lastDelay = OPERATIONS_BLOCK_DURATION - SINGLE_OPERATION_DELAY * (messages.size - 1) messages.forEachIndexed { index, messageId -> - val duration = if (index != messages.size - 1) 200L else 2000L + val duration = if (index != messages.size - 1) SINGLE_OPERATION_DELAY else lastDelay add({ component.operation(messageId.entryId) } to duration) } } @@ -176,5 +175,12 @@ class MessagesNode( const val ENTRY_WIDTH = 240f const val ENTRY_ASPECT_RATIO = 1.5f const val ENTRY_PADDING = 8f + + const val INITIAL_DELAY = 8500 + const val INITIAL_EXTRA_DELAY = 500 + const val VERTICAL_BIAS_DURATION = 14000 + const val ROTATION_DURATION = 7000 + const val SINGLE_OPERATION_DELAY = 200L + const val OPERATIONS_BLOCK_DURATION = 7000L } } diff --git a/shared/src/commonMain/kotlin/com/bumble/puzzyx/node/messages/StackedMessagesNode.kt b/shared/src/commonMain/kotlin/com/bumble/puzzyx/node/messages/StackedMessagesNode.kt index 54880495..b7f2df8f 100644 --- a/shared/src/commonMain/kotlin/com/bumble/puzzyx/node/messages/StackedMessagesNode.kt +++ b/shared/src/commonMain/kotlin/com/bumble/puzzyx/node/messages/StackedMessagesNode.kt @@ -5,36 +5,30 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import com.bumble.appyx.interactions.permanent.PermanentAppyxComponent -import com.bumble.appyx.navigation.collections.ImmutableList -import com.bumble.appyx.navigation.collections.toImmutableList import com.bumble.appyx.navigation.composable.PermanentChild import com.bumble.appyx.navigation.modality.BuildContext import com.bumble.appyx.navigation.node.Node import com.bumble.appyx.navigation.node.ParentNode import com.bumble.appyx.utils.multiplatform.Parcelable import com.bumble.appyx.utils.multiplatform.Parcelize +import com.bumble.puzzyx.model.Entry import com.bumble.puzzyx.model.MessageId import com.bumble.puzzyx.model.entries +import com.bumble.puzzyx.model.getFeaturedEntries class StackedMessagesNode( buildContext: BuildContext, + private val groupCount: Int = DEFAULT_GROUP_COUNT, private val groupSize: Int = DEFAULT_GROUP_SIZE, - private val stackOfMessages: List> = buildList { - val messageIds = - entries.indices - .map { MessageId(it) } - - // Take groups of groupSize messages. - messageIds.windowed(groupSize, groupSize, false) - .map { add(it.toImmutableList()) } - - // If there is still missing messages, take groupSize from the tail, potentially - // repeating some of them. - add(messageIds.takeLast(groupSize).toImmutableList()) + private val groupedMessages: List> = buildList { + entries.getFeaturedEntries( + entriesCount = groupCount * groupSize, + newestEntriesCount = groupCount * (groupSize - 1), + ).windowed(groupSize, groupSize, false).toMutableList().also { addAll(it) } }, private val permanentAppyxComponent: PermanentAppyxComponent = PermanentAppyxComponent( savedStateMap = buildContext.savedStateMap, - initialTargets = List(stackOfMessages.size) { index -> InteractionTarget.Messages(index) }, + initialTargets = List(groupCount) { index -> InteractionTarget.Messages(index) }, ), ) : ParentNode( buildContext = buildContext, @@ -51,9 +45,9 @@ class StackedMessagesNode( is InteractionTarget.Messages -> MessagesNode( buildContext = buildContext, index = interactionTarget.index, - messages = stackOfMessages[interactionTarget.index], - onFinished = { if (it == (stackOfMessages.size - 1) * 5000L) finish() } - ) + messages = groupedMessages[interactionTarget.index].indices.map { MessageId(it) }, + localEntries = groupedMessages[interactionTarget.index], + ) { if (it == groupCount - 1) finish() } } @Composable @@ -61,7 +55,7 @@ class StackedMessagesNode( Box( modifier = modifier.fillMaxSize() ) { - stackOfMessages.forEachIndexed { index, _ -> + groupedMessages.forEachIndexed { index, _ -> PermanentChild( permanentAppyxComponent = permanentAppyxComponent, interactionTarget = InteractionTarget.Messages( @@ -74,6 +68,7 @@ class StackedMessagesNode( } private companion object { + const val DEFAULT_GROUP_COUNT = 3 const val DEFAULT_GROUP_SIZE = 7 } } diff --git a/shared/src/commonMain/kotlin/com/bumble/puzzyx/node/puzzle1/Puzzle1Node.kt b/shared/src/commonMain/kotlin/com/bumble/puzzyx/node/puzzle1/Puzzle1Node.kt index 591c5b9d..d9fee9ec 100644 --- a/shared/src/commonMain/kotlin/com/bumble/puzzyx/node/puzzle1/Puzzle1Node.kt +++ b/shared/src/commonMain/kotlin/com/bumble/puzzyx/node/puzzle1/Puzzle1Node.kt @@ -38,7 +38,7 @@ import com.bumble.puzzyx.appyx.component.gridpuzzle.operation.scatter import com.bumble.puzzyx.composable.AutoPlayScript import com.bumble.puzzyx.composable.EntryCardSmall import com.bumble.puzzyx.composable.FlashCard -import com.bumble.puzzyx.imageloader.ResourceImage +import com.bumble.puzzyx.imageloader.EmbeddableResourceImage import com.bumble.puzzyx.model.Entry import com.bumble.puzzyx.model.Puzzle import com.bumble.puzzyx.model.PuzzlePiece @@ -90,7 +90,7 @@ class Puzzle1Node( FlashCard( flash = Color.White, front = { modifier -> - ResourceImage( + EmbeddableResourceImage( path = "${puzzle.imagesDir}/slice_${puzzlePiece.j}_${puzzlePiece.i}.png", contentScale = ContentScale.FillBounds, modifier = modifier diff --git a/shared/src/commonMain/kotlin/com/bumble/puzzyx/node/starfield/StarFieldNode.kt b/shared/src/commonMain/kotlin/com/bumble/puzzyx/node/starfield/StarFieldNode.kt new file mode 100644 index 00000000..8701c963 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/bumble/puzzyx/node/starfield/StarFieldNode.kt @@ -0,0 +1,40 @@ +package com.bumble.puzzyx.node.starfield + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import com.bumble.appyx.navigation.collections.immutableListOf +import com.bumble.appyx.navigation.modality.BuildContext +import com.bumble.appyx.navigation.node.Node +import com.bumble.puzzyx.composable.AutoPlayScript +import com.bumble.puzzyx.composable.StarFieldMessageBoard +import com.bumble.puzzyx.model.Entry +import com.bumble.puzzyx.model.entries +import com.bumble.puzzyx.model.getFeaturedEntries + +class StarFieldNode( + buildContext: BuildContext, +) : Node(buildContext) { + + @Composable + override fun View(modifier: Modifier) { + var entriesForStarField by remember { mutableStateOf(immutableListOf()) } + AutoPlayScript(initialDelayMs = 20000) { finish() } + LaunchedEffect(Unit) { + entriesForStarField = entries.getFeaturedEntries( + entriesCount = 20, + newestEntriesCount = 12, + ) + } + if (entriesForStarField.isNotEmpty()) { + StarFieldMessageBoard( + entries = entriesForStarField, + modifier = modifier, + ) + } + } +} diff --git a/shared/src/commonMain/kotlin/com/bumble/puzzyx/ui/DottedMeshShape.kt b/shared/src/commonMain/kotlin/com/bumble/puzzyx/ui/DottedMeshShape.kt index 97c2bac7..7da878b4 100644 --- a/shared/src/commonMain/kotlin/com/bumble/puzzyx/ui/DottedMeshShape.kt +++ b/shared/src/commonMain/kotlin/com/bumble/puzzyx/ui/DottedMeshShape.kt @@ -12,8 +12,8 @@ import androidx.compose.ui.unit.LayoutDirection import com.bumble.appyx.interactions.core.annotations.FloatRange import com.bumble.appyx.interactions.core.ui.math.lerpFloat import com.bumble.puzzyx.math.mapValueRange -import java.lang.Integer.max import kotlin.math.abs +import kotlin.math.max import kotlin.math.sqrt /** diff --git a/shared/src/desktopMain/kotlin/com/bumble/puzzyx/imageloader/EmbeddableResourceImage.kt b/shared/src/desktopMain/kotlin/com/bumble/puzzyx/imageloader/EmbeddableResourceImage.kt new file mode 100644 index 00000000..89b97be0 --- /dev/null +++ b/shared/src/desktopMain/kotlin/com/bumble/puzzyx/imageloader/EmbeddableResourceImage.kt @@ -0,0 +1,20 @@ +package com.bumble.puzzyx.imageloader + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale + +@Composable +actual fun EmbeddableResourceImage( + path: String, + modifier: Modifier, + contentDescription: String?, + contentScale: ContentScale +) { + ResourceImage( + path = path, + modifier = modifier, + contentScale = contentScale, + contentDescription = contentDescription, + ) +} diff --git a/shared/src/jsMain/kotlin/com/bumble/puzzyx/imageloader/EmbeddableResourceImage.kt b/shared/src/jsMain/kotlin/com/bumble/puzzyx/imageloader/EmbeddableResourceImage.kt new file mode 100644 index 00000000..6d0d2888 --- /dev/null +++ b/shared/src/jsMain/kotlin/com/bumble/puzzyx/imageloader/EmbeddableResourceImage.kt @@ -0,0 +1,23 @@ +package com.bumble.puzzyx.imageloader + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale + +private const val EMBED_URL = "appyx/where/hosted/sample" + +@Composable +actual fun EmbeddableResourceImage( + path: String, + modifier: Modifier, + contentDescription: String?, + contentScale: ContentScale +) { + ResourceImage( + path = EMBED_URL + path, + fallbackUrl = path, + modifier = modifier, + contentScale = contentScale, + contentDescription = contentDescription, + ) +} diff --git a/shared/src/jsMain/kotlin/com/bumble/puzzyx/imageloader/toImageBitmap.kt b/shared/src/jsMain/kotlin/com/bumble/puzzyx/imageloader/toImageBitmap.kt new file mode 100644 index 00000000..8af036f6 --- /dev/null +++ b/shared/src/jsMain/kotlin/com/bumble/puzzyx/imageloader/toImageBitmap.kt @@ -0,0 +1,8 @@ +package com.bumble.puzzyx.imageloader + +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.toComposeImageBitmap + +actual fun ByteArray.toImageBitmap(): ImageBitmap { + return org.jetbrains.skia.Image.makeFromEncoded(this).toComposeImageBitmap() +} diff --git a/webApp/build.gradle.kts b/webApp/build.gradle.kts new file mode 100644 index 00000000..bbf6d1df --- /dev/null +++ b/webApp/build.gradle.kts @@ -0,0 +1,48 @@ +import org.jetbrains.compose.desktop.application.dsl.TargetFormat + +plugins { + kotlin("multiplatform") + id("org.jetbrains.compose") +} + +kotlin { + js(IR) { + moduleName = "puzzyx-web" + browser() + binaries.executable() + } + sourceSets { + val commonMain by getting { + dependencies { + implementation(compose.runtime) + implementation(compose.foundation) + implementation(compose.material3) + implementation(project(":shared")) + implementation(libs.appyx.navigation) + implementation(libs.appyx.components.backstack) + } + } + } +} + +compose.experimental { + web.application {} +} + +tasks.register("copyResources") { + // Dirs containing files we want to copy + from("../shared/src/commonMain/resources") + + // Output for web resources + into("$buildDir/processedResources/js/main") + + include("**/*") +} + +tasks.named("jsBrowserProductionExecutableDistributeResources") { + dependsOn("copyResources") +} + +tasks.named("compileKotlinJs") { + dependsOn("copyResources") +} diff --git a/webApp/src/jsMain/kotlin/Main.kt b/webApp/src/jsMain/kotlin/Main.kt new file mode 100644 index 00000000..99704166 --- /dev/null +++ b/webApp/src/jsMain/kotlin/Main.kt @@ -0,0 +1,93 @@ +import androidx.compose.foundation.focusable +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material.Surface +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEvent +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.unit.dp +import com.bumble.appyx.navigation.integration.ScreenSize +import com.bumble.appyx.navigation.integration.WebNodeHost +import com.bumble.puzzyx.node.app.PuzzyxAppNode +import com.bumble.puzzyx.ui.PuzzyxTheme +import com.bumble.puzzyx.ui.appyx_dark +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch +import org.jetbrains.skiko.wasm.onWasmReady + +fun main() { + val events: Channel = Channel() + onWasmReady { + BrowserViewportWindow() { + PuzzyxTheme { + val requester = remember { FocusRequester() } + var hasFocus by remember { mutableStateOf(false) } + + var screenSize by remember { mutableStateOf(ScreenSize(0.dp, 0.dp)) } + val eventScope = remember { CoroutineScope(SupervisorJob() + Dispatchers.Main) } + + Surface( + modifier = Modifier + .fillMaxSize() + .onSizeChanged { screenSize = ScreenSize(it.width.dp, it.height.dp) } + .onKeyEvent { event -> + onKeyEvent(event, events, eventScope) + } + .focusRequester(requester) + .focusable() + .onFocusChanged { hasFocus = it.hasFocus }, + color = appyx_dark, + ) { + WebNodeHost( + screenSize = screenSize, + onBackPressedEvents = events.receiveAsFlow(), + ) { buildContext -> + PuzzyxAppNode( + buildContext = buildContext, + ) + } + } + + + if (!hasFocus) { + LaunchedEffect(Unit) { + requester.requestFocus() + } + } + } + } + } +} + + +@OptIn(ExperimentalComposeUiApi::class) +private fun onKeyEvent( + keyEvent: KeyEvent, + events: Channel, + coroutineScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main), +): Boolean = + when { + keyEvent.type == KeyEventType.KeyUp && keyEvent.key == Key.Backspace -> { + coroutineScope.launch { events.send(Unit) } + true + } + + else -> false + } diff --git a/webApp/src/jsMain/resources/index.html b/webApp/src/jsMain/resources/index.html new file mode 100644 index 00000000..aa9114da --- /dev/null +++ b/webApp/src/jsMain/resources/index.html @@ -0,0 +1,15 @@ + + + + + Puzzyx + + + + +
+ +
+ + + diff --git a/webApp/src/jsMain/resources/styles.css b/webApp/src/jsMain/resources/styles.css new file mode 100644 index 00000000..8655f2e7 --- /dev/null +++ b/webApp/src/jsMain/resources/styles.css @@ -0,0 +1,12 @@ +#root { + width: 100%; + height: 100vh; +} + +body { + margin: 0; +} + +#root > .compose-web-column > div { + position: relative; +}