diff --git a/JoshHeaps.Net/wwwroot/css/casino/holdem.css b/JoshHeaps.Net/wwwroot/css/casino/holdem.css index 017b86e..4d9912e 100644 --- a/JoshHeaps.Net/wwwroot/css/casino/holdem.css +++ b/JoshHeaps.Net/wwwroot/css/casino/holdem.css @@ -848,8 +848,13 @@ button, font-size: 0.55rem; } +/* The hole cards give up their line here. A puck this close to its neighbours is 64px + tall and the seats at the ends of a landscape felt are 55px apart, so the line has to + come from somewhere — and of the four things on a puck, the cards are the one the + dealer is holding in their other hand. Name, bet and stack stay. The seat editor + still shows the cards in full. */ .seatsTight .seatPuckCards { - font-size: 0.58rem; + display: none; } /* ---------- Seat editor panel (sidebar form for the selected seat) ---------- */ @@ -3038,10 +3043,18 @@ body.hasActionBar #toastHost { } /* Keeps every puck inside the viewport: the ring is drawn within this box, - so half a puck of padding is exactly enough to stop the overhang. */ + so half a puck of padding is exactly enough to stop the overhang. + + Half a puck is the right answer when the felt is tall enough to spare it. On a + phone held sideways it isn't: the felt is 96px tall there once the action bar has + grown, and 26px off the top and bottom left the ring 44px of height to work with + — less than half the table. That is what kept the seats at the ends stacked no + matter what shape the rim was. + + Above about 216px of felt the flat 26px still wins, so nothing upright changes. */ #seatsLayer, #betsLayer { - inset: 26px 44px; + inset: min(26px, 12%) 44px; } /* Narrower than the desktop puck: at a full ten-handed table the ring is @@ -3410,6 +3423,16 @@ body.hasActionBar #toastHost { min-height: 40px; } + /* The same inset the phone layout uses, which this branch never had because it is + matched on height rather than width — so an 844px-wide phone on its side drew its + ring against the felt's full box. With the rim a stadium, the top rail is flat, + which put the button seat's dealer chip over the Resolve Pot button in the header + above. Percentages of a short felt, so the ring keeps the height it needs. */ + #seatsLayer, + #betsLayer { + inset: min(26px, 12%) 44px; + } + } /* ---------- Short phones, upright ---------- diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js index e5a76ad..566bb15 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js @@ -117,8 +117,11 @@ const Render = { // anything visible — it is just the honest way to say "on that player's spoke", // and it saves the second walk. betPosition(index, count, aspect = this.feltAspect()) { - const angle = this.ringAngles(count, 44, 42, aspect)[index]; - return { left: 50 + 27 * Math.cos(angle), top: 50 + 25 * Math.sin(angle) }; + const seat = this.seatPosition(index, count, aspect); + return { + left: 50 + (seat.left - 50) * (27 / 44), + top: 50 + (seat.top - 50) * (25 / 42), + }; }, // Seats spaced evenly around the rim — by the distance between them, not by the @@ -136,11 +139,71 @@ const Render = { // an ellipse's has no closed form — 720 steps is far more than enough for a rim a // few hundred pixels long, and it runs once per render. positionOnRing(index, count, rx, ry, aspect = 1) { - const target = this.ringAngles(count, rx, ry, aspect)[index]; - return { - left: 50 + rx * Math.cos(target), - top: 50 + ry * Math.sin(target), - }; + return this.ringPositions(count, rx, ry, aspect)[index]; + }, + + // Past this much wider than tall, the rim stops being an ellipse and becomes a + // stadium — two straight rails with a semicircular cap at each end. + // + // An ellipse that flat has nowhere to put the seats near its ends. At 640x360 with + // the bar grown by a side pot the felt is 616x96, and six seats came out with two + // of them stacked 25px apart at each end, pucks 64px tall. No puck size fixes 25px: + // the shape is wrong. A stadium's cap is a half-circle of the felt's own height, so + // an end holds one seat and the rest of the table is rail — which is how a real + // poker table is built, and why it is shaped that way. + // + // Only where it is needed. Anything rounder than this keeps the ellipse it has + // always had: measured, the cut-off lands between a landscape phone (4.4 and up) + // and every other layout the tool has (1.45 at 1280x900, 1.0 to 1.4 on a phone + // upright), so no currently-working table changes shape. + STADIUM_RATIO: 2.5, + + // Seats spaced evenly around whichever rim this table has, as percentages. + ringPositions(count, rx, ry, aspect = 1) { + if (count <= 0) return []; + const halfWidth = rx * aspect; + const halfHeight = ry; + if (halfWidth <= halfHeight * this.STADIUM_RATIO) { + return this.ringAngles(count, rx, ry, aspect).map(angle => ({ + left: 50 + rx * Math.cos(angle), + top: 50 + ry * Math.sin(angle), + })); + } + return this.stadiumPositions(count, halfWidth, halfHeight, aspect); + }, + + // The same walk, around a stadium instead. Its perimeter has a closed form, so + // this needs no sampling: two rails of 2(A-B) and two caps of piB. + stadiumPositions(count, halfWidth, halfHeight, aspect) { + const rail = halfWidth - halfHeight; + const cap = Math.PI * halfHeight; + const perimeter = 4 * rail + 2 * cap; + + return Array.from({ length: count }, (_, seat) => { + // Clockwise from the top centre, so seat one sits where it always has. + let walked = (perimeter * seat) / count; + let x; + let y; + if (walked <= rail) { // top rail, going right + x = walked; + y = -halfHeight; + } else if (walked <= rail + cap) { // right cap + const turned = (walked - rail) / halfHeight; + x = rail + halfHeight * Math.sin(turned); + y = -halfHeight * Math.cos(turned); + } else if (walked <= 3 * rail + cap) { // bottom rail, going left + x = rail - (walked - rail - cap); + y = halfHeight; + } else if (walked <= 3 * rail + 2 * cap) { // left cap + const turned = (walked - 3 * rail - cap) / halfHeight; + x = -rail - halfHeight * Math.sin(turned); + y = halfHeight * Math.cos(turned); + } else { // top rail, back to the start + x = -rail + (walked - 3 * rail - 2 * cap); + y = -halfHeight; + } + return { left: 50 + x / aspect, top: 50 + y }; + }); }, RING_STEPS: 720, @@ -156,9 +219,9 @@ const Render = { closestSeatGapPx(count, box = this.ringBox()) { if (count < 2 || !box || !box.width || !box.height) return Infinity; - const points = this.ringAngles(count, 44, 42, box.width / box.height).map(angle => ({ - x: ((50 + 44 * Math.cos(angle)) / 100) * box.width, - y: ((50 + 42 * Math.sin(angle)) / 100) * box.height, + const points = this.ringPositions(count, 44, 42, box.width / box.height).map(seat => ({ + x: (seat.left / 100) * box.width, + y: (seat.top / 100) * box.height, })); return points.reduce((closest, point, i) => { const next = points[(i + 1) % points.length]; diff --git a/tests/holdem/seatRing.test.js b/tests/holdem/seatRing.test.js index 72f5f94..ad9f62a 100644 --- a/tests/holdem/seatRing.test.js +++ b/tests/holdem/seatRing.test.js @@ -99,10 +99,27 @@ describe("how close the closest two seats come", () => { // apart end up stacked one above the other. const gap = (count, width, height) => Render.closestSeatGapPx(count, { width, height }); - test("a flat table's ends are far closer than its rim suggests", () => { - // 616x96: the landscape felt on a 640x360 phone once the bar has grown. - const rim = Math.PI * (3 * (271 + 40) - Math.sqrt((3 * 271 + 40) * (271 + 3 * 40))) / 6; - assert.ok(gap(6, 616, 96) < rim / 3, `${gap(6, 616, 96).toFixed(0)}px apart, ${rim.toFixed(0)}px of rim each`); + test("a flat table no longer stacks two seats at each end", () => { + // 616x96: the landscape felt on a 640x360 phone once the bar has grown. On an + // ellipse that flat the end pair came out 25px apart with 64px pucks. The rim + // is a stadium at this shape, so an end holds one seat. + assert.ok(gap(6, 616, 96) > 70, `${gap(6, 616, 96).toFixed(0)}px apart`); + assert.ok(gap(12, 616, 96) > 40, `${gap(12, 616, 96).toFixed(0)}px apart at twelve seats`); + }); + + test("an ellipse's ends are still closer than its rim suggests, which is why the shape changes", () => { + // Same felt, forced through the ellipse the tool uses for rounder tables. + const asEllipse = Render.ringAngles(6, 44, 42, 616 / 96).map(angle => ({ + x: ((50 + 44 * Math.cos(angle)) / 100) * 616, + y: ((50 + 42 * Math.sin(angle)) / 100) * 96, + })); + const closest = asEllipse.reduce((least, point, i) => { + const next = asEllipse[(i + 1) % asEllipse.length]; + return Math.min(least, Math.hypot(point.x - next.x, point.y - next.y)); + }, Infinity); + assert.ok(closest < 70, `${closest.toFixed(0)}px`); + assert.ok(gap(6, 616, 96) > closest * 1.3, + `stadium ${gap(6, 616, 96).toFixed(0)}px against ellipse ${closest.toFixed(0)}px`); }); test("a round table's neighbours are about as far apart as the rim says", () => { @@ -122,6 +139,53 @@ describe("how close the closest two seats come", () => { }); }); +describe("which rim a table gets", () => { + // An ellipse until the table is much wider than tall, then a stadium: two rails + // and a half-round cap at each end, which is how a real poker table is built. + const shape = (width, height) => { + const aspect = width / height; + return 44 * aspect > 42 * Render.STADIUM_RATIO ? "stadium" : "ellipse"; + }; + + test("every layout the tool had keeps the ellipse", () => { + assert.equal(shape(844, 608), "ellipse", "desktop"); + assert.equal(shape(600, 600), "ellipse", "tablet"); + assert.equal(shape(278, 314), "ellipse", "phone upright"); + assert.equal(shape(336, 240), "ellipse", "short phone upright"); + }); + + test("a phone on its side gets the stadium", () => { + assert.equal(shape(528, 73), "stadium"); + assert.equal(shape(780, 113), "stadium"); + }); + + test("the cap holds one seat instead of stacking two", () => { + // Six seats on a 616x96 rim: the walk between neighbours has to be longer than + // the cap, or two of them end up on the same half-circle. + const halfWidth = 44 * (616 / 96), halfHeight = 42; + const perimeter = 4 * (halfWidth - halfHeight) + 2 * Math.PI * halfHeight; + assert.ok(perimeter / 6 > Math.PI * halfHeight, "one seat per cap at six-handed"); + }); + + test("seat one is still at the top centre, and the ring still mirrors", () => { + const seats = Array.from({ length: 8 }, (_, i) => Render.positionOnRing(i, 8, 44, 42, 616 / 96)); + assert.ok(Math.abs(seats[0].left - 50) < 0.01); + assert.ok(seats[0].top < 50); + for (let seat = 1; seat < 4; seat++) { + const mirrored = seats[8 - seat]; + assert.ok(Math.abs((seats[seat].left - 50) + (mirrored.left - 50)) < 0.01, `seat ${seat} across`); + assert.ok(Math.abs(seats[seat].top - mirrored.top) < 0.01, `seat ${seat} down`); + } + }); + + test("nobody is drawn off the felt", () => { + Array.from({ length: 12 }, (_, i) => Render.positionOnRing(i, 12, 44, 42, 616 / 96)).forEach((seat, i) => { + assert.ok(seat.left >= 5 && seat.left <= 95, `seat ${i + 1} across at ${seat.left.toFixed(1)}%`); + assert.ok(seat.top >= 7 && seat.top <= 93, `seat ${i + 1} down at ${seat.top.toFixed(1)}%`); + }); + }); +}); + describe("the shape of the ring", () => { test("is a mirror image left to right", () => { const seats = ringPixels(10, 601, 126);