Created
February 4, 2025 14:58
-
-
Save zivkesten/2423b111fce6c8687f12161e64e380ad to your computer and use it in GitHub Desktop.
A ball bouncing inside a spinning hexagon
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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