From 479add7c89432b2196185d79dc1c5fbe219013a8 Mon Sep 17 00:00:00 2001 From: Oleksandr Karpovich Date: Fri, 14 Jan 2022 15:52:15 +0100 Subject: [PATCH] add BouncingBalls example to falling-balls-mpp (#1699) * add BouncingBalls example to falling-balls-mpp * add new bouncing balls at click position instead of a random coordinates * review improvements Co-authored-by: Oleksandr Karpovich --- examples/falling-balls-mpp/build.gradle.kts | 10 +- .../kotlin/bouncingBalls/BouncingBalls.kt | 191 ++++++++++++++++++ .../src/jsMain/kotlin/main.js.kt | 51 ++++- 3 files changed, 249 insertions(+), 3 deletions(-) create mode 100644 examples/falling-balls-mpp/src/commonMain/kotlin/bouncingBalls/BouncingBalls.kt diff --git a/examples/falling-balls-mpp/build.gradle.kts b/examples/falling-balls-mpp/build.gradle.kts index 83124b4250..bb14bae182 100644 --- a/examples/falling-balls-mpp/build.gradle.kts +++ b/examples/falling-balls-mpp/build.gradle.kts @@ -34,7 +34,7 @@ kotlin { binaries.executable() } macosX64 { - binaries { + binaries { executable { entryPoint = "main" freeCompilerArgs += listOf( @@ -174,3 +174,11 @@ afterEvaluate { nodeVersion = "16.0.0" } } + + +// TODO: remove when https://youtrack.jetbrains.com/issue/KT-50778 fixed +project.tasks.withType(org.jetbrains.kotlin.gradle.dsl.KotlinJsCompile::class.java).configureEach { + kotlinOptions.freeCompilerArgs += listOf( + "-Xir-dce-runtime-diagnostic=log" + ) +} diff --git a/examples/falling-balls-mpp/src/commonMain/kotlin/bouncingBalls/BouncingBalls.kt b/examples/falling-balls-mpp/src/commonMain/kotlin/bouncingBalls/BouncingBalls.kt new file mode 100644 index 0000000000..9ca392be3b --- /dev/null +++ b/examples/falling-balls-mpp/src/commonMain/kotlin/bouncingBalls/BouncingBalls.kt @@ -0,0 +1,191 @@ +/* + * Copyright 2020-2022 JetBrains s.r.o. and respective authors and developers. + * Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file. + */ + +package bouncingBalls + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.unit.dp +import kotlin.math.PI +import kotlin.math.cos +import kotlin.math.max +import kotlin.math.sin +import kotlin.random.Random + +private inline fun Modifier.noRippleClickable(crossinline onClick: (Offset) -> Unit): Modifier = + composed { + clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() }) { + }.pointerInput(Unit) { + detectTapGestures(onTap = { + println("tap offset = $it") + onClick(it) + }) + } + } + +private var areaWidth = 0 +private var areaHeight = 0 + +@Composable +fun BouncingBallsApp(initialBallsCount: Int = 5) { + val items = remember { + val list = mutableStateListOf() + list.addAll(generateSequence { + BouncingBall.createBouncingBall() + }.take(initialBallsCount)) + list + } + + Box( + modifier = Modifier.fillMaxWidth() + .fillMaxHeight() + .border(width = 1.dp, color = Color.Black) + .noRippleClickable { + items += BouncingBall.createBouncingBall(offset = it) + }.onSizeChanged { + areaWidth = it.width + areaHeight = it.height + } + ) { + Balls(items) + } + + LaunchedEffect(Unit) { + var lastTime = 0L + var dt = 0L + + while (true) { + withFrameNanos { time -> + dt = time - lastTime + if (lastTime == 0L) { + dt = 0 + } + lastTime = time + items.forEach { + it.recalculate(areaWidth, areaHeight, dt.toFloat()) + } + } + } + } +} + +@Composable +private fun Balls(items: List) { + items.forEachIndexed { ix, ball -> + key(ix) { + Box( + modifier = Modifier + .offset( + x = (ball.circle.x.value - ball.circle.r).dp, + y = (ball.circle.y.value - ball.circle.r).dp + ).size((2 * ball.circle.r).dp) + .background(ball.color, CircleShape) + ) + } + } +} + +private class Circle( + var x: MutableState, + var y: MutableState, + val r: Float +) { + constructor(x: Float, y: Float, r: Float) : this( + mutableStateOf(x), mutableStateOf(y), r + ) + + fun moveCircle(s: Float, angle: Float, width: Int, height: Int, r: Float) { + x.value = (x.value + s * sin(angle)).coerceAtLeast(r).coerceAtMost(width.toFloat() - r) + y.value = (y.value + s * cos(angle)).coerceAtLeast(r).coerceAtMost(height.toFloat() - r) + } +} + +private fun calculatePosition(circle: Circle, boundingWidth: Int, boundingHeight: Int): Position { + val southmost = circle.y.value + circle.r + val northmost = circle.y.value - circle.r + val westmost = circle.x.value - circle.r + val eastmost = circle.x.value + circle.r + + return when { + southmost >= boundingHeight -> Position.TOUCHES_SOUTH + northmost <= 0 -> Position.TOUCHES_NORTH + eastmost >= boundingWidth -> Position.TOUCHES_EAST + westmost <= 0 -> Position.TOUCHES_WEST + else -> Position.INSIDE + } +} + +private enum class Position { + INSIDE, + TOUCHES_SOUTH, + TOUCHES_NORTH, + TOUCHES_WEST, + TOUCHES_EAST +} + +private class BouncingBall( + val circle: Circle, + val velocity: Float, + var angle: Double, + val color: Color = Color.Red +) { + fun recalculate(width: Int, height: Int, dt: Float) { + val position = calculatePosition(circle, width, height) + + val dtMillis = dt / 1000000 + + when (position) { + Position.TOUCHES_SOUTH -> angle = PI - angle + Position.TOUCHES_EAST -> angle = -angle + Position.TOUCHES_WEST -> angle = -angle + Position.TOUCHES_NORTH -> angle = PI - angle + Position.INSIDE -> angle + } + + circle.moveCircle( + velocity * (dtMillis.coerceAtMost(500f) / 1000), + angle.toFloat(), + width, + height, + circle.r + ) + } + + companion object { + private val random = Random(100) + private val angles = listOf(PI / 4, -PI / 3, 3 * PI / 4, -PI / 6, -1.1 * PI) + private val colors = listOf(Color.Red, Color.Black, Color.Green, Color.Magenta) + + private fun randomOffset(): Offset { + return Offset( + x = random.nextInt(100, 700).toFloat(), + y = random.nextInt(100, 500).toFloat() + ) + } + + fun createBouncingBall(offset: Offset = randomOffset()): BouncingBall { + return BouncingBall( + circle = Circle(x = offset.x, y = offset.y, r = random.nextInt(10, 50).toFloat()), + velocity = random.nextInt(100, 200).toFloat(), + angle = angles.random(), + color = colors.random().copy(alpha = max(0.3f, random.nextFloat())) + ) + } + } +} diff --git a/examples/falling-balls-mpp/src/jsMain/kotlin/main.js.kt b/examples/falling-balls-mpp/src/jsMain/kotlin/main.js.kt index 3c5bf4425c..b76a5c5511 100644 --- a/examples/falling-balls-mpp/src/jsMain/kotlin/main.js.kt +++ b/examples/falling-balls-mpp/src/jsMain/kotlin/main.js.kt @@ -3,9 +3,19 @@ * Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file. */ +import androidx.compose.foundation.layout.* +import androidx.compose.material.RadioButton +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Window +import bouncingBalls.BouncingBallsApp import org.jetbrains.skiko.wasm.onWasmReady object JsTime : Time { @@ -15,9 +25,46 @@ object JsTime : Time { fun main() { onWasmReady { Window("Falling Balls") { - val game = remember { Game(JsTime) } - FallingBalls(game) + val selectedExample = remember { mutableStateOf(Examples.FallingBalls) } + + Column(modifier = Modifier.fillMaxSize()) { + ExamplesChooser(selectedExample) + Spacer(modifier = Modifier.height(24.dp)) + + when (selectedExample.value) { + Examples.FallingBalls -> { + val game = remember { Game(JsTime) } + FallingBalls(game) + } + Examples.BouncingBalls -> { + BouncingBallsApp(10) + } + } + } } } } +@Composable +private fun ExamplesChooser(selected: MutableState) { + Column { + Row(verticalAlignment = Alignment.CenterVertically) { + Text("Choose an example: ", fontSize = 16.sp) + + Examples.values().forEach { + Row(verticalAlignment = Alignment.CenterVertically) { + RadioButton(selected = selected.value == it, onClick = { + selected.value = it + }) + Text(it.name) + } + } + } + } +} + +private enum class Examples { + FallingBalls, + BouncingBalls +} +