Skip to content

Instantly share code, notes, and snippets.

@zivkesten
Created February 4, 2025 14:58
Show Gist options
  • Save zivkesten/2423b111fce6c8687f12161e64e380ad to your computer and use it in GitHub Desktop.
Save zivkesten/2423b111fce6c8687f12161e64e380ad to your computer and use it in GitHub Desktop.
A ball bouncing inside a spinning hexagon
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()
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment