import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.layout.onSizeChanged import kotlinx.coroutines.android.awaitFrame import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.IntSize import kotlin.math.* // ─── EXTENSION FUNCTIONS FOR 2D VECTOR MATH ─────────────────────────────── /** Dot product of two Offsets. */ fun Offset.dot(other: Offset): Float = this.x * other.x + this.y * other.y /** Returns the length (magnitude) of this Offset. */ fun Offset.length(): Float = sqrt(this.x * this.x + this.y * this.y) /** Returns this Offset normalized to unit length (or Offset.Zero if length is zero). */ fun Offset.normalize(): Offset { val len = length() return if (len != 0f) this / len else Offset.Zero } // ─── THE COMPOSABLE ─────────────────────────────────────────────────────────── @Composable fun BouncingBallInSpinningHexagon() { // --- Parameters and state --- val ballRadius = 20f // Ball’s state (position and velocity). var ballPos by remember { mutableStateOf(Offset(0f, 0f)) } var ballVel by remember { mutableStateOf(Offset(200f, -300f)) } // try tweaking initial speed // The current rotation (in radians) of the hexagon. var hexagonAngle by remember { mutableStateOf(0f) } // The size of our drawing canvas (set by onSizeChanged). var canvasSize by remember { mutableStateOf(IntSize(0, 0)) } // Physical constants: val angularSpeed = 1f // hexagon rotates at 1 radian per second val gravity = 980f // gravity (px/s²) val restitution = 0.8f // how “bouncy” collisions are (1 = perfectly elastic) val frictionCoefficient = 0.2f // friction/damping applied continuously // --- Initialize ball position to center once we know our canvas size --- LaunchedEffect(canvasSize) { if (canvasSize.width > 0 && canvasSize.height > 0 && ballPos == Offset(0f, 0f)) { ballPos = Offset(canvasSize.width / 2f, canvasSize.height / 2f) } } // --- The physics simulation loop: update the ball and hexagon on each frame --- LaunchedEffect(Unit) { var lastTime = withFrameNanos { it } while (true) { // Wait for the next frame and compute elapsed time (in seconds) val frameTime = withFrameNanos { it } val dt = (frameTime - lastTime) / 1_000_000_000f lastTime = frameTime // Update the hexagon’s rotation. hexagonAngle += angularSpeed * dt // Apply gravity and friction (damping) to the ball’s velocity. ballVel += Offset(0f, gravity * dt) ballVel *= (1 - frictionCoefficient * dt) // Advance the ball’s position. ballPos += ballVel * dt // --- Collision detection & response --- if (canvasSize.width > 0 && canvasSize.height > 0) { val center = Offset(canvasSize.width / 2f, canvasSize.height / 2f) // Let the hexagon radius be 40% of the smallest canvas dimension. val hexRadius = 0.4f * min(canvasSize.width, canvasSize.height) // Compute the six vertices of the rotating hexagon. val vertices = List(6) { i -> // The vertex angle = current rotation + i * 60°. val angle = hexagonAngle + Math.toRadians((i * 60).toDouble()).toFloat() center + Offset(hexRadius * cos(angle), hexRadius * sin(angle)) } // For each hexagon edge... for (i in vertices.indices) { val v1 = vertices[i] val v2 = vertices[(i + 1) % vertices.size] val edge = v2 - v1 val edgeLength = edge.length() if (edgeLength == 0f) continue val edgeDir = edge / edgeLength // Compute a normal pointing _into_ the hexagon. val mid = (v1 + v2) * 0.5f val inwardNormal = (center - mid).normalize() // Compute the (signed) perpendicular distance from ball center to the line. val distance = (ballPos - v1).dot(inwardNormal) // If the ball’s circle is overlapping this wall... if (distance < ballRadius) { // Determine where (along the edge) the ball’s center projects. val projection = (ballPos - v1).dot(edgeDir) if (projection in 0f..edgeLength) { // --- Collision with an edge --- val collisionPoint = v1 + edgeDir * projection // Because the hexagon is spinning, its walls have a linear velocity. // (For a rotation, wall velocity = ω × r, where r is from the center.) val r = collisionPoint - center val wallVel = Offset(-angularSpeed * r.y, angularSpeed * r.x) // Compute the ball’s velocity relative to the moving wall. val relativeVel = ballVel - wallVel if (relativeVel.dot(inwardNormal) < 0f) { // Reflect the relative velocity against the wall. val reflected = relativeVel - inwardNormal * (2f * relativeVel.dot(inwardNormal)) ballVel = wallVel + reflected * restitution // Move the ball just out of penetration. ballPos += inwardNormal * (ballRadius - distance) } } else { // --- Otherwise, check for a collision with one of the two vertices --- for (vertex in listOf(v1, v2)) { val diff = ballPos - vertex val distVertex = diff.length() if (distVertex < ballRadius) { val normal = diff.normalize() val r = vertex - center val wallVel = Offset(-angularSpeed * r.y, angularSpeed * r.x) val relativeVel = ballVel - wallVel if (relativeVel.dot(normal) < 0f) { val reflected = relativeVel - normal * (2f * relativeVel.dot(normal)) ballVel = wallVel + reflected * restitution ballPos = vertex + normal * ballRadius } } } } } } } // Loop automatically awaits the next frame. } } // --- Draw the scene --- Canvas(modifier = Modifier .fillMaxSize() .background(Color.White) .onSizeChanged { canvasSize = it } ) { val center = Offset(size.width / 2f, size.height / 2f) val hexRadius = 0.4f * min(size.width, size.height) // Compute hexagon vertices with current rotation. val vertices = List(6) { i -> val angle = hexagonAngle + Math.toRadians((i * 60).toDouble()).toFloat() center + Offset(hexRadius * cos(angle), hexRadius * sin(angle)) } // Build the hexagon Path. val hexPath = Path().apply { if (vertices.isNotEmpty()) { moveTo(vertices[0].x, vertices[0].y) vertices.drop(1).forEach { vertex -> lineTo(vertex.x, vertex.y) } close() } } // Draw the hexagon’s outline. drawPath( path = hexPath, color = Color.Black, style = Stroke(width = 4f) ) // Draw the ball. drawCircle( color = Color.Red, radius = ballRadius, center = ballPos ) } } @Preview(showBackground = true) @Composable fun PreviewBouncingBallInSpinningHexagon() { BouncingBallInSpinningHexagon() }