Seat every player on the felt rail
Bruno and Dex floated on the felt interior while Ada, Cleo and Enzo straddled the rail, so the row looked inconsistent. The cause was that vertical lanes were hand-picked constants (0.30 / 0.15 / 0 / 0.15 / 0.30) with no relationship to the oval they were meant to sit on — any value would put some seats on the edge and others adrift, which is why tuning them kept trading one complaint for another. Seat placement now solves the felt ellipse for each seat's own horizontal position, and reads the same FELT_LEFT/RIGHT/TOP/BOTTOM constants the felt is drawn from. The rail and the seats can no longer disagree, and a test asserts every seat lands on the rail at every supported width rather than merely checking that lanes are ordered. At 384dp the solved fractions are 0.247 / 0.095 / 0.055 / 0.095 / 0.247 — a real curve rather than three seats abreast. Seat gap widened from 6dp to 10dp so the name plates have breathing room now that the shoulder seats sit nearer the crown. Verified on Galaxy S24+ geometry: nothing clipped at either edge. 261 tests, 0 failures. Lint 0 errors. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -63,6 +63,7 @@ import com.jsjdesigns.poker.game.DecisionOffer
|
|||||||
import com.jsjdesigns.poker.game.SeatSnapshot
|
import com.jsjdesigns.poker.game.SeatSnapshot
|
||||||
import com.jsjdesigns.poker.game.TableSnapshot
|
import com.jsjdesigns.poker.game.TableSnapshot
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
import kotlin.math.sqrt
|
||||||
|
|
||||||
private val Night = Color(0xFF04100C)
|
private val Night = Color(0xFF04100C)
|
||||||
private val TableBackground = Color(0xFF071D16)
|
private val TableBackground = Color(0xFF071D16)
|
||||||
@@ -111,15 +112,40 @@ internal fun visibleChipCount(amount: Int): Int = when {
|
|||||||
internal data class SeatPlacement(
|
internal data class SeatPlacement(
|
||||||
/** Distance in dp from the table's left edge to this seat's left edge. */
|
/** Distance in dp from the table's left edge to this seat's left edge. */
|
||||||
val leftDp: Float,
|
val leftDp: Float,
|
||||||
/** Vertical lane as a fraction of table height. */
|
/** Where this seat's avatar centre belongs, as a fraction of table height. */
|
||||||
val topFraction: Float,
|
val centerYFraction: Float,
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Space kept clear at the table's left and right edges. */
|
/** Space kept clear at the table's left and right edges. */
|
||||||
private const val SEAT_EDGE_MARGIN_DP = 6f
|
private const val SEAT_EDGE_MARGIN_DP = 6f
|
||||||
|
|
||||||
/** Smallest horizontal gap permitted between two neighbouring seats. */
|
/** Smallest horizontal gap permitted between two neighbouring seats. */
|
||||||
private const val SEAT_MIN_GAP_DP = 6f
|
private const val SEAT_MIN_GAP_DP = 10f
|
||||||
|
|
||||||
|
// The felt oval, as fractions of the table box. Seat placement reads the same
|
||||||
|
// constants the felt is drawn from, so seats cannot drift off the rail when the
|
||||||
|
// table shape changes — the two were previously unrelated numbers.
|
||||||
|
private const val FELT_LEFT = 0.012f
|
||||||
|
private const val FELT_RIGHT = 0.988f
|
||||||
|
private const val FELT_TOP = 0.055f
|
||||||
|
private const val FELT_BOTTOM = 0.965f
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Height fraction where the felt's upper rail sits at a given horizontal
|
||||||
|
* position.
|
||||||
|
*
|
||||||
|
* Seats were previously assigned hand-picked vertical lanes, which left the
|
||||||
|
* shoulder seats floating on the felt interior while the crown and side seats
|
||||||
|
* straddled the rail. Solving the ellipse puts every seat on the same edge.
|
||||||
|
*/
|
||||||
|
internal fun railCenterYFraction(centerXFraction: Float): Float {
|
||||||
|
val cx = (FELT_LEFT + FELT_RIGHT) / 2f
|
||||||
|
val rx = (FELT_RIGHT - FELT_LEFT) / 2f
|
||||||
|
val cy = (FELT_TOP + FELT_BOTTOM) / 2f
|
||||||
|
val ry = (FELT_BOTTOM - FELT_TOP) / 2f
|
||||||
|
val u = ((centerXFraction - cx) / rx).coerceIn(-1f, 1f)
|
||||||
|
return cy - ry * sqrt(1f - u * u)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Width one opponent seat may occupy, derived from the table's real width.
|
* Width one opponent seat may occupy, derived from the table's real width.
|
||||||
@@ -143,13 +169,14 @@ internal fun opponentSeatPlacements(
|
|||||||
availableWidthDp: Float,
|
availableWidthDp: Float,
|
||||||
seatWidthDp: Float = opponentSeatWidthDp(availableWidthDp),
|
seatWidthDp: Float = opponentSeatWidthDp(availableWidthDp),
|
||||||
): List<SeatPlacement> {
|
): List<SeatPlacement> {
|
||||||
val lanes = listOf(0.30f, 0.15f, 0f, 0.15f, 0.30f)
|
|
||||||
val span = availableWidthDp - 2f * SEAT_EDGE_MARGIN_DP - seatWidthDp
|
val span = availableWidthDp - 2f * SEAT_EDGE_MARGIN_DP - seatWidthDp
|
||||||
val step = span / 4f
|
val step = span / 4f
|
||||||
return List(5) { index ->
|
return List(5) { index ->
|
||||||
|
val left = SEAT_EDGE_MARGIN_DP + step * index
|
||||||
|
val centerXFraction = (left + seatWidthDp / 2f) / availableWidthDp
|
||||||
SeatPlacement(
|
SeatPlacement(
|
||||||
leftDp = SEAT_EDGE_MARGIN_DP + step * index,
|
leftDp = left,
|
||||||
topFraction = lanes[index],
|
centerYFraction = railCenterYFraction(centerXFraction),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -284,7 +311,10 @@ private fun PokerTable(
|
|||||||
width = seatWidth.dp,
|
width = seatWidth.dp,
|
||||||
modifier = Modifier.offset(
|
modifier = Modifier.offset(
|
||||||
x = placement.leftDp.dp,
|
x = placement.leftDp.dp,
|
||||||
y = maxHeight * placement.topFraction,
|
// Placement gives where the avatar centre belongs on the
|
||||||
|
// rail; the seat's top is half an avatar above it.
|
||||||
|
y = maxHeight * placement.centerYFraction -
|
||||||
|
(seatWidth.dp * 0.58f).coerceIn(34.dp, 48.dp) / 2,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -329,12 +359,11 @@ private fun TableFelt(modifier: Modifier = Modifier) {
|
|||||||
// An oval sized to the container, so the felt grows into whatever height
|
// An oval sized to the container, so the felt grows into whatever height
|
||||||
// the table is given rather than staying a width-sized circle.
|
// the table is given rather than staying a width-sized circle.
|
||||||
val inset = 9.dp.toPx()
|
val inset = 9.dp.toPx()
|
||||||
val railTop = size.height * 0.055f
|
|
||||||
val railRect = androidx.compose.ui.geometry.Rect(
|
val railRect = androidx.compose.ui.geometry.Rect(
|
||||||
left = size.width * 0.012f,
|
left = size.width * FELT_LEFT,
|
||||||
top = railTop,
|
top = size.height * FELT_TOP,
|
||||||
right = size.width * 0.988f,
|
right = size.width * FELT_RIGHT,
|
||||||
bottom = size.height * 0.965f,
|
bottom = size.height * FELT_BOTTOM,
|
||||||
)
|
)
|
||||||
drawOval(
|
drawOval(
|
||||||
brush = Brush.linearGradient(
|
brush = Brush.linearGradient(
|
||||||
|
|||||||
@@ -60,30 +60,41 @@ class TableOrbitTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `seats are staggered into an arc rather than one straight row`() {
|
fun `seats are staggered into an arc rather than one straight row`() {
|
||||||
val lanes = opponentSeatPlacements(384f).map { it.topFraction }
|
val lanes = opponentSeatPlacements(384f).map { it.centerYFraction }
|
||||||
assertTrue("outer seats should sit lower than the crown", lanes[0] > lanes[2])
|
assertTrue("outer seats should sit lower than the crown", lanes[0] > lanes[2])
|
||||||
assertTrue("the arc should be symmetric", lanes[0] == lanes[4] && lanes[1] == lanes[3])
|
assertEquals("the arc should be symmetric", lanes[0], lanes[4], 0.0005f)
|
||||||
assertEquals("the middle seat crowns the arc", 0f, lanes[2], 0.001f)
|
assertEquals("the arc should be symmetric", lanes[1], lanes[3], 0.0005f)
|
||||||
|
assertTrue("the middle seat crowns the arc", lanes[2] < lanes[1])
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The shoulder seats sat at 0.06 against a crown of 0.0, so three seats read
|
* Every seat must land on the felt's edge. Hand-picked lanes left the
|
||||||
* as one crowded row instead of a curve. They belong between the crown and
|
* shoulder seats floating on the felt interior while the crown and side
|
||||||
* the side seats.
|
* seats straddled the rail, so the row looked inconsistent.
|
||||||
*/
|
*/
|
||||||
@Test
|
@Test
|
||||||
fun `shoulder seats sit between the crown and the side seats`() {
|
fun `every seat sits on the felt rail at its own horizontal position`() {
|
||||||
val lanes = opponentSeatPlacements(384f).map { it.topFraction }
|
for (width in widths) {
|
||||||
val crown = lanes[2]
|
val seatWidth = opponentSeatWidthDp(width)
|
||||||
val side = lanes[0]
|
opponentSeatPlacements(width, seatWidth).forEach { placement ->
|
||||||
val shoulder = lanes[1]
|
val centerX = (placement.leftDp + seatWidth / 2f) / width
|
||||||
assertTrue("shoulder must be below the crown", shoulder > crown)
|
assertEquals(
|
||||||
assertTrue("shoulder must be above the side seats", shoulder < side)
|
"at ${width}dp a seat drifted off the rail",
|
||||||
val midpoint = (crown + side) / 2f
|
railCenterYFraction(centerX),
|
||||||
assertTrue(
|
placement.centerYFraction,
|
||||||
"shoulder $shoulder should be near the midpoint $midpoint, not hugging the crown",
|
0.0001f,
|
||||||
kotlin.math.abs(shoulder - midpoint) <= 0.05f,
|
)
|
||||||
)
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the rail solution is a real ellipse, not a flat line`() {
|
||||||
|
val crown = railCenterYFraction(0.5f)
|
||||||
|
val shoulder = railCenterYFraction(0.30f)
|
||||||
|
val side = railCenterYFraction(0.06f)
|
||||||
|
assertTrue("the crown is the highest point", crown < shoulder)
|
||||||
|
assertTrue("the rail keeps dropping toward the sides", shoulder < side)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
Reference in New Issue
Block a user