diff --git a/app/src/main/java/com/jsjdesigns/poker/TableScreen.kt b/app/src/main/java/com/jsjdesigns/poker/TableScreen.kt index 0880765..05601f3 100644 --- a/app/src/main/java/com/jsjdesigns/poker/TableScreen.kt +++ b/app/src/main/java/com/jsjdesigns/poker/TableScreen.kt @@ -44,6 +44,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.rotate import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color @@ -142,7 +143,7 @@ internal fun opponentSeatPlacements( availableWidthDp: Float, seatWidthDp: Float = opponentSeatWidthDp(availableWidthDp), ): List { - val lanes = listOf(0.30f, 0.06f, 0f, 0.06f, 0.30f) + val lanes = listOf(0.30f, 0.15f, 0f, 0.15f, 0.30f) val span = availableWidthDp - 2f * SEAT_EDGE_MARGIN_DP - seatWidthDp val step = span / 4f return List(5) { index -> @@ -419,18 +420,19 @@ private fun OpponentSeat( } } } else { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(0.dp), + // One slot-width box: the fan is drawn first so the avatar + // sits on top of it, and neither can push past the seat. + Box( + modifier = Modifier.width(width - 6.dp), + contentAlignment = Alignment.CenterStart, ) { - OpponentAvatar(seat) if (!seat.folded) { - Row(horizontalArrangement = Arrangement.spacedBy((-5).dp)) { - repeat(2) { - CardImage(null, Modifier.size(18.dp, 27.dp)) - } - } + HoleCardFan( + cardWidth = (width * 0.27f).coerceIn(18.dp, 27.dp), + modifier = Modifier.align(Alignment.CenterEnd), + ) } + OpponentAvatar(seat, size = (width * 0.58f).coerceIn(34.dp, 48.dp)) } } } @@ -486,11 +488,44 @@ private fun OpponentSeat( } } +/** + * Two face-down cards fanned like a real hand. + * + * Drawn flat and barely overlapped they merged into one red slab; opposing + * rotation and a real offset make the pair legible at seat scale. The left card + * tucks behind the avatar, which is why this is drawn after it in the row. + */ @Composable -private fun OpponentAvatar(seat: SeatSnapshot) { +private fun HoleCardFan(cardWidth: Dp, modifier: Modifier = Modifier) { + val cardHeight = cardWidth * 1.5f + Box( + modifier = modifier + .width(cardWidth * 1.40f) + .height(cardHeight * 1.16f), + contentAlignment = Alignment.Center, + ) { + CardImage( + null, + Modifier + .size(cardWidth, cardHeight) + .offset(x = (-4).dp) + .rotate(-11f), + ) + CardImage( + null, + Modifier + .size(cardWidth, cardHeight) + .offset(x = 5.dp) + .rotate(10f), + ) + } +} + +@Composable +private fun OpponentAvatar(seat: SeatSnapshot, size: Dp = 48.dp) { Box( modifier = Modifier - .size(48.dp) + .size(size) .clip(CircleShape) .background( Brush.linearGradient( diff --git a/app/src/test/java/com/jsjdesigns/poker/TableOrbitTest.kt b/app/src/test/java/com/jsjdesigns/poker/TableOrbitTest.kt index 1f19092..ac0aca5 100644 --- a/app/src/test/java/com/jsjdesigns/poker/TableOrbitTest.kt +++ b/app/src/test/java/com/jsjdesigns/poker/TableOrbitTest.kt @@ -66,6 +66,26 @@ class TableOrbitTest { assertEquals("the middle seat crowns the arc", 0f, lanes[2], 0.001f) } + /** + * The shoulder seats sat at 0.06 against a crown of 0.0, so three seats read + * as one crowded row instead of a curve. They belong between the crown and + * the side seats. + */ + @Test + fun `shoulder seats sit between the crown and the side seats`() { + val lanes = opponentSeatPlacements(384f).map { it.topFraction } + val crown = lanes[2] + val side = lanes[0] + val shoulder = lanes[1] + assertTrue("shoulder must be below the crown", shoulder > crown) + assertTrue("shoulder must be above the side seats", shoulder < side) + val midpoint = (crown + side) / 2f + assertTrue( + "shoulder $shoulder should be near the midpoint $midpoint, not hugging the crown", + kotlin.math.abs(shoulder - midpoint) <= 0.05f, + ) + } + @Test fun `spacing is even so the row reads as a deliberate arrangement`() { val width = 384f