anvilsign in

collin/mahjong · bcaeb04c

Lay the table out for a phone

Collin Richards · 2026-08-19 05:50 UTC · bcaeb04c1347dbfa9a05030ebefc14d48f7a122a · parent eaeb7859 · browse files

modifiedsrc/App.tsx+18 −3
⋯ 6 unchanged lines
77 import { WIND_EN, WIND_NAMES } from './game/tiles';
88 import type { SeatId } from './game/types';
99 import { Center } from './ui/Center';
10+import { useCompact } from './ui/compact';
1011 import { Help } from './ui/Help';
1112 import { Seat } from './ui/Seat';
1213 import { Settings } from './ui/Settings';
⋯ 6 unchanged lines
1920
2021 export default function App() {
2122 const [prefs, setPrefs] = useState<Prefs>(loadPrefs);
23+ // Phone-sized: everything reads upright for the one person holding it.
24+ const compact = useCompact();
2225 // Built once, from the preferences as they stood at startup; after that the
2326 // settings screen edits the live game rather than replacing it.
2427 const [game] = useState(() => new Game(prefs.names, prefs.rules));
⋯ 29 unchanged lines
5457 />
5558 );
5659
60+ // On <html> rather than passed around, so the panels that render outside the
61+ // table — settings, the rules sheet, the lobby — can style themselves off it.
62+ useEffect(() => {
63+ document.documentElement.classList.toggle('compact', compact);
64+ }, [compact]);
65+
5766 // Preferences are written straight through, so a mute survives a refresh.
5867 useEffect(() => {
5968 savePrefs(prefs);
⋯ 68 unchanged lines
128137 return (
129138 <div className="table">
130139 {SEATS.map((s) => (
131- <Seat key={s} game={game} seat={s} onHelp={() => setHelpSeat(s)} />
140+ <Seat key={s} game={game} seat={s} compact={compact} onHelp={() => setHelpSeat(s)} />
132141 ))}
133- <Center game={game} prefs={prefs} onPrefs={setPrefs} onSettings={() => setSettingsOpen(true)} />
134- {helpSeat !== null && <Help seat={helpSeat} onClose={() => setHelpSeat(null)} />}
142+ <Center
143+ game={game}
144+ prefs={prefs}
145+ compact={compact}
146+ onPrefs={setPrefs}
147+ onSettings={() => setSettingsOpen(true)}
148+ />
149+ {helpSeat !== null && <Help seat={helpSeat} compact={compact} onClose={() => setHelpSeat(null)} />}
135150 {settingsPanel}
136151 </div>
137152 );
⋯ 11 unchanged lines
modifiedsrc/styles.css+580 −0
⋯ 48 unchanged lines
4949 user-select: none;
5050 }
5151
52+/* Phone browsers hand out a `100%`/`100vh` viewport that includes the address
53+ bar they are about to slide away over the top of it; `dvh` is the height the
54+ table can actually have. */
55+@supports (height: 100dvh) {
56+ html,
57+ body,
58+ #root {
59+ height: 100dvh;
60+ }
61+}
62+
5263 body {
5364 font-family: 'Noto Sans TC', system-ui, sans-serif;
5465 color: var(--ivory);
⋯ 1218 unchanged lines
12731284 color: #ffb4a2;
12741285 opacity: 0.75;
12751286 }
1287+
1288+/* ============================================================================
1289+ compact — a phone
1290+
1291+ The table layout is four strips of --depth arranged around a centre square,
1292+ which wants about 700px each way; a phone has that in neither, and in
1293+ landscape the top and bottom strips simply overlap. It also assumes four
1294+ people sitting around a screen lying flat, which is not what a phone is.
1295+
1296+ Compact throws both assumptions out. Nothing is rotated — every label reads
1297+ upright for the one person holding the phone — the table becomes a grid, the
1298+ three other seats shrink to cards showing what is public about them, and the
1299+ depth that frees up goes to the hand, which wraps when it has to.
1300+
1301+ The `compact` class is put on <html> by useCompact(); the breakpoint itself
1302+ lives in ui/compact.ts, so it is written down once.
1303+ ============================================================================ */
1304+
1305+.compact .table {
1306+ display: grid;
1307+ gap: 2px;
1308+ /* Landscape is where a notch eats into the sides. */
1309+ padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom)
1310+ env(safe-area-inset-left);
1311+ grid-template-columns: 1fr 1fr 1fr;
1312+ grid-template-rows: minmax(0, 1fr) auto auto;
1313+ grid-template-areas:
1314+ 'left across right'
1315+ 'bar bar bar'
1316+ 'mine mine mine';
1317+}
1318+.compact .slot {
1319+ position: relative;
1320+ inset: auto;
1321+ width: auto;
1322+ height: auto;
1323+ min-width: 0;
1324+ min-height: 0;
1325+}
1326+/* `.slot` and not the edge class alone: the rules sheet carries an edge class
1327+ of its own, and would otherwise be parked in the grid area named here. */
1328+.compact .slot.slot-left {
1329+ grid-area: left;
1330+}
1331+.compact .slot.slot-top {
1332+ grid-area: across;
1333+}
1334+.compact .slot.slot-right {
1335+ grid-area: right;
1336+}
1337+.compact .slot.slot-bottom {
1338+ grid-area: mine;
1339+}
1340+.compact .center {
1341+ grid-area: bar;
1342+}
1343+/* The strip no longer floats in the middle of a slot — it is the slot. */
1344+.compact .seat {
1345+ position: relative;
1346+ top: auto;
1347+ left: auto;
1348+ width: auto;
1349+ height: 100%;
1350+ padding: 2px 4px 3px;
1351+}
1352+/* Off to one side rather than dead centre: a card is narrow enough that the
1353+ arrow would otherwise land on the name it is pointing out. */
1354+.compact .win-arrow {
1355+ font-size: 24px;
1356+ left: 14px;
1357+}
1358+
1359+/* ---------- the other three seats ---------- */
1360+/* Read top-down: who they are, how many tiles they hold, what they have laid
1361+ down, what they have thrown. The hand is the least interesting of those and
1362+ goes smallest; the discards get whatever room is left, since reading them is
1363+ the whole of playing against somebody. */
1364+.compact .opp .seat {
1365+ --tile-w: clamp(13px, calc((100vw / 3 - 20px) / 8.6), 32px);
1366+ justify-content: flex-start;
1367+ gap: 2px;
1368+ border: 1px solid rgba(246, 240, 226, 0.09);
1369+ border-radius: 8px;
1370+ background: rgba(0, 0, 0, 0.14);
1371+}
1372+.compact .opp .nameplate {
1373+ order: 0;
1374+}
1375+.compact .opp .hand {
1376+ order: 1;
1377+}
1378+.compact .opp .melds {
1379+ order: 2;
1380+}
1381+.compact .opp .discards {
1382+ order: 3;
1383+}
1384+.compact .opp .actionbar {
1385+ order: 4;
1386+}
1387+
1388+.compact .opp .nameplate {
1389+ flex-wrap: wrap;
1390+ justify-content: center;
1391+ gap: 3px 5px;
1392+ font-size: 10px;
1393+ padding-top: 0;
1394+ row-gap: 1px;
1395+}
1396+.compact .opp .nameplate .wind {
1397+ font-size: 13px;
1398+}
1399+.compact .opp .nameplate .wind em,
1400+.compact .opp .nameplate .helpbtn,
1401+.compact .opp .nameplate .sort {
1402+ display: none;
1403+}
1404+.compact .opp .nameplate .chips {
1405+ font-size: 13px;
1406+ margin-left: 0;
1407+}
1408+.compact .opp .nameplate .tag {
1409+ font-size: 9px;
1410+ padding: 0 4px;
1411+}
1412+/* Face-down backs: a count you can see at a glance, nothing more. */
1413+.compact .opp .hand {
1414+ flex-wrap: wrap;
1415+ justify-content: center;
1416+ height: auto;
1417+ padding-top: 0;
1418+ gap: 1px;
1419+ max-width: 100%;
1420+}
1421+.compact .opp .hand .tile {
1422+ --tw: calc(var(--tile-w) * 0.52);
1423+}
1424+.compact .opp .melds {
1425+ gap: 5px;
1426+ min-height: 0;
1427+ max-width: 100%;
1428+}
1429+.compact .opp .meld.flowers {
1430+ margin-left: 5px;
1431+ padding-left: 5px;
1432+}
1433+.compact .opp .discards {
1434+ flex: 1 1 auto;
1435+ max-width: 100%;
1436+ min-height: 0;
1437+ overflow-y: auto;
1438+ align-content: flex-start;
1439+}
1440+/* No height is reserved here: an opponent's bar is empty except in hotseat,
1441+ and a card that stays still matters less than the room it costs. */
1442+.compact .opp .actionbar {
1443+ min-height: 0;
1444+ padding-right: 0;
1445+ justify-content: center;
1446+ gap: 4px;
1447+}
1448+.compact .opp .actionbar .btn {
1449+ height: auto;
1450+ min-height: 34px;
1451+ padding: 2px 8px;
1452+}
1453+.compact .opp .actionbar .bi .zh {
1454+ font-size: 14px;
1455+}
1456+.compact .opp .delta {
1457+ font-size: 15px;
1458+}
1459+
1460+/* ---------- your own seat ---------- */
1461+/* A grid rather than the stack the strips use, so the melds and the discards
1462+ share one line and the hand gets the depth that saves. */
1463+.compact .mine .seat {
1464+ --tile-w: min(calc((100vw - 24px) / 18.3), 54px);
1465+ display: grid;
1466+ /* `minmax(0, auto)`, so a full set of melds wraps inside its column instead
1467+ of shouldering the discards off the end of the strip. */
1468+ grid-template-columns: minmax(0, auto) minmax(0, 1fr);
1469+ grid-template-areas:
1470+ 'melds discards'
1471+ 'bar bar'
1472+ 'hand hand'
1473+ 'name name';
1474+ align-content: end;
1475+ align-items: end;
1476+ justify-items: center;
1477+ gap: 2px 8px;
1478+ padding: 2px 6px 3px;
1479+}
1480+.compact .mine .melds {
1481+ grid-area: melds;
1482+ justify-content: flex-start;
1483+ min-height: 0;
1484+ gap: 6px;
1485+}
1486+.compact .mine .discards {
1487+ grid-area: discards;
1488+ /* One line that scrolls, kept pinned to its end by Seat.tsx — wrapping would
1489+ cost a second row of depth the hand wants more. Reserved even while empty,
1490+ since this row and the melds beside it share it: without that the strip
1491+ would jump the first time either one appeared. */
1492+ flex: none;
1493+ flex-wrap: nowrap;
1494+ justify-content: flex-start;
1495+ align-self: end;
1496+ max-width: 100%;
1497+ width: 100%;
1498+ min-height: calc(var(--tile-w) * 0.9);
1499+ margin-bottom: 0;
1500+ overflow-x: auto;
1501+ overflow-y: hidden;
1502+ /* Melds and discards end up shoulder to shoulder here, and both are rows of
1503+ tiles — the rule and the smaller face are what tell them apart. */
1504+ border-left: 1px solid rgba(246, 240, 226, 0.16);
1505+ padding-left: 8px;
1506+ opacity: 0.85;
1507+}
1508+.compact .mine .discards .tile {
1509+ --tw: calc(var(--tile-w) * 0.5);
1510+}
1511+.compact .mine .actionbar {
1512+ grid-area: bar;
1513+ --bar-h: 44px;
1514+ justify-content: center;
1515+ padding-right: 0;
1516+ gap: 6px;
1517+}
1518+.compact .mine .hand {
1519+ grid-area: hand;
1520+ flex-wrap: wrap;
1521+ align-content: flex-end;
1522+ justify-content: center;
1523+ height: auto;
1524+ max-width: 100%;
1525+ /* The row gap matches the padding for the same reason: the 摸 tag hangs above
1526+ its tile, and on a wrapped hand that is somebody else's row. */
1527+ gap: calc(var(--tile-w) * 0.34) 2px;
1528+ padding-top: calc(var(--tile-w) * 0.34);
1529+}
1530+.compact .mine .nameplate {
1531+ grid-area: name;
1532+ flex-wrap: wrap;
1533+ justify-content: center;
1534+ gap: 3px 7px;
1535+ font-size: 11px;
1536+ padding-top: 0;
1537+}
1538+.compact .mine .nameplate .wind {
1539+ font-size: 15px;
1540+}
1541+.compact .mine .nameplate .wind em {
1542+ display: none;
1543+}
1544+.compact .mine .nameplate .chips {
1545+ font-size: 14px;
1546+}
1547+.compact .mine .nameplate .btn.sort {
1548+ min-height: 26px;
1549+ padding: 1px 9px;
1550+ font-size: 12px;
1551+}
1552+.compact .mine .nameplate .helpbtn {
1553+ width: 26px;
1554+ height: 26px;
1555+ font-size: 13px;
1556+}
1557+
1558+/* ---------- the middle ---------- */
1559+/* One bar across the table rather than a square in the middle of it. */
1560+.compact .center {
1561+ position: relative;
1562+ inset: auto;
1563+ flex-direction: row;
1564+ align-items: center;
1565+ justify-content: space-between;
1566+ gap: 8px;
1567+ padding: 1px 6px;
1568+ min-height: 44px;
1569+}
1570+.compact .facing {
1571+ flex: 1 1 auto;
1572+ flex-direction: row;
1573+ align-items: center;
1574+ justify-content: center;
1575+ gap: 10px;
1576+ min-width: 0;
1577+}
1578+.compact .last-discard {
1579+ --tile-w: 30px;
1580+ flex-direction: row;
1581+ align-items: center;
1582+ gap: 6px;
1583+}
1584+.compact .ld-label {
1585+ font-size: 10px;
1586+}
1587+.compact .prompt {
1588+ font-size: 12px;
1589+ text-align: center;
1590+}
1591+.compact .table-controls {
1592+ position: static;
1593+ flex: none;
1594+}
1595+.compact .btn.undo {
1596+ max-width: 34vw;
1597+ min-height: 30px;
1598+ padding: 2px 10px;
1599+}
1600+.compact .undo-what .zh {
1601+ font-size: 11px;
1602+}
1603+.compact .undo-what .en {
1604+ display: none;
1605+}
1606+/* What the wall square was telling you, in the room a bar has. */
1607+.wall-pill {
1608+ display: flex;
1609+ align-items: baseline;
1610+ gap: 5px;
1611+ flex: none;
1612+ letter-spacing: 0.06em;
1613+ opacity: 0.75;
1614+}
1615+.wall-pill b {
1616+ font-size: 18px;
1617+ font-weight: 900;
1618+ color: var(--gold);
1619+}
1620+.wall-pill em {
1621+ font-style: normal;
1622+ font-size: 9px;
1623+ text-transform: uppercase;
1624+ opacity: 0.7;
1625+}
1626+
1627+/* The hand result covers the table instead of sitting in a square that is no
1628+ longer there. */
1629+.compact .result {
1630+ position: fixed;
1631+ top: 50%;
1632+ left: 50%;
1633+ transform: translate(-50%, -50%);
1634+ z-index: 25;
1635+ max-width: min(560px, 94vw);
1636+ max-height: 92dvh;
1637+ padding: 12px 16px;
1638+ gap: 8px;
1639+}
1640+.compact .result h2 {
1641+ font-size: 18px;
1642+}
1643+.compact .tai-list {
1644+ grid-template-columns: repeat(auto-fit, minmax(92px, 1fr));
1645+ gap: 1px 12px;
1646+ font-size: 12px;
1647+}
1648+.compact .ready-row {
1649+ font-size: 12px;
1650+}
1651+.compact .ready-name {
1652+ min-width: 4.5em;
1653+}
1654+.compact .ready-tiles {
1655+ --tile-w: 26px;
1656+}
1657+.compact .deltas {
1658+ flex-wrap: wrap;
1659+ justify-content: center;
1660+ gap: 6px 14px;
1661+ font-size: 12px;
1662+}
1663+.compact .big-btn {
1664+ font-size: 18px;
1665+ padding: 8px 22px;
1666+ min-height: 44px;
1667+}
1668+
1669+/* ---------- portrait ---------- */
1670+/* Held upright there is no room for three cards side by side, so they stack in
1671+ the order play reaches them, and the hand wraps to two rows. */
1672+@media (orientation: portrait) {
1673+ .compact .table {
1674+ grid-template-columns: 1fr;
1675+ grid-template-rows: repeat(3, minmax(0, 1fr)) auto auto;
1676+ grid-template-areas:
1677+ 'right'
1678+ 'across'
1679+ 'left'
1680+ 'bar'
1681+ 'mine';
1682+ }
1683+ /* A card gets the whole width here, so the tiles can be read rather than
1684+ merely counted; the discards wrap to a second line when they need it. */
1685+ .compact .opp .seat {
1686+ --tile-w: clamp(18px, calc((100vw - 26px) / 13), 34px);
1687+ }
1688+ .compact .mine .seat {
1689+ --tile-w: min(calc((100vw - 26px) / 9.4), 54px);
1690+ }
1691+ /* A phone's width will not hold the wall count, who discarded what, the claim
1692+ prompt and the table's own buttons on one line. The readout — the part that
1693+ changes — takes a line of its own underneath. The room comes out of the
1694+ other seats, which are the flexible rows, so the hand never moves. */
1695+ .compact .center {
1696+ flex-wrap: wrap;
1697+ row-gap: 2px;
1698+ }
1699+ .compact .wall-pill {
1700+ order: 0;
1701+ }
1702+ .compact .table-controls {
1703+ order: 1;
1704+ }
1705+ .compact .facing {
1706+ order: 2;
1707+ flex-basis: 100%;
1708+ }
1709+}
1710+
1711+/* ---------- the panels ---------- */
1712+.compact .lobby {
1713+ justify-content: flex-start;
1714+ overflow-y: auto;
1715+ gap: 0.25rem;
1716+ padding: 12px 14px calc(14px + env(safe-area-inset-bottom));
1717+}
1718+.compact .lobby > * {
1719+ flex: none;
1720+}
1721+/* Sized off both axes: a phone held sideways has width to spare and no height
1722+ at all, and the title was the first thing to push the buttons off. */
1723+.compact .lobby h1 {
1724+ font-size: clamp(1.9rem, min(9vw, 11vh), 3.2rem);
1725+ margin-bottom: 0.2rem;
1726+}
1727+.compact .lobby .sub {
1728+ font-size: 0.78rem;
1729+}
1730+/* The screen dividers this asks for are a four-round-a-table idea, and nobody
1731+ props cards against a phone. */
1732+.compact .lobby .hint {
1733+ display: none;
1734+}
1735+.compact .resume {
1736+ margin-top: 0.8rem;
1737+ padding: 8px 16px;
1738+}
1739+.compact .resume-scores {
1740+ flex-wrap: wrap;
1741+ gap: 4px 12px;
1742+ font-size: 12px;
1743+}
1744+.compact .lobby-buttons {
1745+ margin-top: 0.9rem;
1746+ gap: 8px;
1747+ width: min(460px, 100%);
1748+}
1749+.compact .lobby-buttons .big-btn {
1750+ min-width: 0;
1751+ width: 100%;
1752+ padding: 8px 16px;
1753+}
1754+.compact .lobby-extras {
1755+ margin-top: 0.9rem;
1756+}
1757+/* Sideways there is barely 390px of height for a title, a saved game, three
1758+ ways to start and the settings link. Everything gives a little so the last of
1759+ them lands above the fold, rather than behind a scroll nobody looks for. */
1760+@media (orientation: landscape) {
1761+ .compact .lobby h1 {
1762+ font-size: clamp(1.6rem, 9vh, 2.6rem);
1763+ }
1764+ .compact .lobby .sub,
1765+ .compact .resume-when {
1766+ display: none;
1767+ }
1768+ .compact .resume {
1769+ margin-top: 0.4rem;
1770+ padding: 6px 14px;
1771+ }
1772+ .compact .resume-line {
1773+ font-size: 12px;
1774+ }
1775+ .compact .lobby-buttons {
1776+ margin-top: 0.5rem;
1777+ }
1778+ .compact .lobby-buttons .big-btn {
1779+ min-height: 40px;
1780+ padding: 5px 16px;
1781+ font-size: 19px;
1782+ }
1783+ .compact .lobby-extras {
1784+ margin-top: 0.5rem;
1785+ }
1786+}
1787+
1788+.compact .help-slot .help {
1789+ width: calc(100vw - 16px);
1790+ --hd: calc(100dvh - 16px);
1791+ translate: none;
1792+ padding: 8px 12px 10px;
1793+}
1794+.compact .help-head h3 {
1795+ font-size: 13px;
1796+}
1797+/* The strip version fills four columns and scrolls sideways, which reads fine
1798+ across a laptop's width and not at all on a phone. One column, scrolled the
1799+ way every other sheet on the device is. */
1800+.compact .help-body {
1801+ columns: auto;
1802+ column-gap: normal;
1803+ overflow-x: hidden;
1804+}
1805+
1806+.compact .settings-scrim {
1807+ padding: 6px;
1808+}
1809+.compact .settings {
1810+ padding: 10px 14px 14px;
1811+}
1812+.compact .settings-head h3 {
1813+ font-size: 16px;
1814+}
1815+.compact .settings-head .btn {
1816+ min-height: 38px;
1817+ padding: 2px 14px;
1818+ font-size: 14px;
1819+}
1820+/* Two columns sideways, where there is width and no height; one held upright,
1821+ where it is the other way round. */
1822+.compact .settings-body {
1823+ columns: 2;
1824+ column-gap: 26px;
1825+ padding-top: 10px;
1826+}
1827+@media (orientation: portrait) {
1828+ .compact .settings-body {
1829+ columns: 1;
1830+ }
1831+}
1832+.compact .settings-body section {
1833+ margin-bottom: 14px;
1834+}
1835+.compact .settings-body h4 {
1836+ margin-bottom: 6px;
1837+ font-size: 14px;
1838+}
1839+.compact .settings .f-label {
1840+ min-width: 116px;
1841+ font-size: 14px;
1842+}
1843+.compact .settings input[type='text'],
1844+.compact .settings input:not([type]),
1845+.compact .settings input[type='number'] {
1846+ font-size: 15px;
1847+ min-height: 40px;
1848+ padding: 8px 10px;
1849+}
1850+.compact .settings .sw-name {
1851+ font-size: 14px;
1852+}
1853+.compact .settings .note {
1854+ font-size: 11px;
1855+}
modifiedsrc/ui/Center.tsx+19 −3
⋯ 10 unchanged lines
1111 interface Props {
1212 game: Game;
1313 prefs: Prefs;
14+ /** Phone-sized table: the centre becomes a single bar between the seats. */
15+ compact?: boolean;
1416 onPrefs: (p: Prefs) => void;
1517 onSettings: () => void;
1618 }
1719
18-export function Center({ game, prefs, onPrefs, onSettings }: Props) {
20+export function Center({ game, prefs, compact, onPrefs, onSettings }: Props) {
1921 const s = game.state;
2022 const r = s.result;
2123 // Point the live readout at whoever can move the game along.
⋯ 10 unchanged lines
3234
3335 return (
3436 <div className="center">
35- {s.phase !== 'gameEnd' && <WallRing state={s} />}
37+ {/* The wall square wants the whole middle of the table, which a phone does
38+ not have to give — there it shrinks to the one number it was telling
39+ you anyway. */}
40+ {s.phase !== 'gameEnd' &&
41+ (compact ? (
42+ <div className="wall-pill">
43+ <b>{game.wallLeft}</b>
44+ <em>剩牌 left</em>
45+ </div>
46+ ) : (
47+ <WallRing state={s} />
48+ ))}
3649
37- <div className="facing" style={{ transform: `rotate(${SEAT_ROT[facing]}deg)` }}>
50+ <div
51+ className="facing"
52+ style={compact ? undefined : { transform: `rotate(${SEAT_ROT[facing]}deg)` }}
53+ >
3854 {s.lastDiscard && s.phase !== 'handEnd' && (
3955 <div className="last-discard">
4056 <div className="ld-label">{s.players[s.lastDiscard.from].name} 打 discarded</div>
⋯ 130 unchanged lines
modifiedsrc/ui/Help.tsx+14 −2
⋯ 121 unchanged lines
122122 * seat strip so it can be deep enough to read, but still rotated to face only
123123 * that player.
124124 */
125-export function Help({ seat, onClose }: { seat: SeatId; onClose: () => void }) {
125+export function Help({
126+ seat,
127+ compact,
128+ onClose,
129+}: {
130+ seat: SeatId;
131+ compact?: boolean;
132+ onClose: () => void;
133+}) {
126134 return (
127135 <div className={`help-slot ${SEAT_SLOT[seat]}`} onClick={onClose}>
128136 <div
129137 className="help"
130- style={{ transform: `translate(-50%, -50%) rotate(${SEAT_ROT[seat]}deg)` }}
138+ style={{
139+ transform: compact
140+ ? 'translate(-50%, -50%)'
141+ : `translate(-50%, -50%) rotate(${SEAT_ROT[seat]}deg)`,
142+ }}
131143 onClick={(e) => e.stopPropagation()}
132144 >
133145 <div className="help-head">
⋯ 33 unchanged lines
modifiedsrc/ui/Seat.tsx+18 −5
1-import { useEffect, useState } from 'react';
1+import { useEffect, useRef, useState } from 'react';
22 import type { Game } from '../game/engine';
33 import { labelOf, WIND_EN, WIND_NAMES } from '../game/tiles';
44 import type { SeatId } from '../game/types';
⋯ 9 unchanged lines
1414 interface Props {
1515 game: Game;
1616 seat: SeatId;
17+ /** Phone-sized table: nothing is rotated and only seat 0 gets a full strip. */
18+ compact?: boolean;
1719 onHelp: () => void;
1820 }
1921
20-export function Seat({ game, seat, onHelp }: Props) {
22+export function Seat({ game, seat, compact, onHelp }: Props) {
2123 const s = game.state;
2224 const p = s.players[seat];
2325 const [sel, setSel] = useState<number | null>(null);
⋯ 26 unchanged lines
5052 // 一炮多響 can put more than one seat here, so this is a `some`, not a find.
5153 const won = s.phase === 'handEnd' && !!s.result?.winners.some((w) => w.seat === seat);
5254
55+ // Compact lays the discards out as one line that scrolls rather than a block
56+ // that wraps, so the newest tile — the only one anybody is looking for — has
57+ // to be kept in view. Harmless at full size, where nothing overflows.
58+ const discardsRef = useRef<HTMLDivElement>(null);
59+ useEffect(() => {
60+ const el = discardsRef.current;
61+ if (!el) return;
62+ el.scrollLeft = el.scrollWidth;
63+ el.scrollTop = el.scrollHeight;
64+ }, [p.discards.length]);
65+
5366 const doDiscard = (i: number) => {
5467 game.discard(seat, p.hand[i]);
5568 setSel(null);
⋯ 5 unchanged lines
6174 const selTile = sel === null ? null : p.hand[sel] ?? null;
6275
6376 return (
64- <div className={`slot ${SLOT[seat]}`}>
77+ <div className={`slot ${SLOT[seat]} ${seat === 0 ? 'mine' : 'opp'}`}>
6578 <div
6679 className={`seat ${myTurn ? 'active' : ''} ${claiming ? 'claiming' : ''}`}
67- style={{ transform: `translate(-50%, -50%) rotate(${ROT[seat]}deg)` }}
80+ style={compact ? undefined : { transform: `translate(-50%, -50%) rotate(${ROT[seat]}deg)` }}
6881 >
6982 {/* Points down the strip at whoever won. It is inside the seat, so it
7083 turns with it and lands on their edge of the table wherever they
⋯ 4 unchanged lines
7588 </div>
7689 )}
7790
78- <div className="discards">
91+ <div className="discards" ref={discardsRef}>
7992 {p.discards.map((t, i) => (
8093 <TileView
8194 key={i}
⋯ 134 unchanged lines
addedsrc/ui/compact.ts+28 −0
1+import { useSyncExternalStore } from 'react';
2+
3+/**
4+ * Too small for the table. Four strips of --depth around a centre square need
5+ * roughly 770px in *both* directions before the middle is worth looking at and
6+ * the tiles stop shrinking — a phone has that in neither direction, and a
7+ * tablet held upright has it in only one. Under this the table switches to the
8+ * compact layout: one viewer, nothing rotated, and the hand allowed to wrap.
9+ *
10+ * The class this drives (`compact`, set on <html>) is what styles.css keys off,
11+ * so this string is the only place the breakpoint is written down.
12+ */
13+export const COMPACT_QUERY = '(max-height: 620px), (max-width: 820px)';
14+
15+const mql = typeof window === 'undefined' ? null : window.matchMedia(COMPACT_QUERY);
16+
17+const subscribe = (cb: () => void) => {
18+ mql?.addEventListener('change', cb);
19+ return () => mql?.removeEventListener('change', cb);
20+};
21+
22+export function useCompact(): boolean {
23+ return useSyncExternalStore(
24+ subscribe,
25+ () => mql?.matches ?? false,
26+ () => false,
27+ );
28+}