anvilsign in

collin/mahjong · a62ae023

The phone gets the middle of the screen, and the table says who is at it

Collin Richards · 2026-08-25 11:00 UTC · a62ae0233a00c01e05418a8e909ba210f1ec6a9d · parent 0d844b66 · browse files

modifiedsrc/App.tsx+37 −5
⋯ 23 unchanged lines
2424 import { Center } from './ui/Center';
2525 import { exposeGame, showingColliders } from './ui/colliders';
2626 import { useCompact } from './ui/compact';
27-import { Controller, SeatPick } from './ui/Controller';
27+import { Adrift, Controller, SeatPick } from './ui/Controller';
2828 import { FullscreenToggle } from './ui/FullscreenToggle';
2929 import { enterFullscreen, useFullscreen } from './ui/fullscreen';
3030 import { Help } from './ui/Help';
⋯ 406 unchanged lines
437437 // A device in somebody else's room holds a hand, not a table: a phone shows
438438 // the hand and nothing else, and anything bigger shows the whole table
439439 // turned round to that seat's edge. Either way it picks a chair first.
440- if (session && !isTable && session.open) {
441- if (compact) return <Controller session={session} prefs={prefs} onPrefs={setPrefs} />;
442- if (mine.length === 0) {
443- return <SeatPick session={session} prefs={prefs} onPrefs={setPrefs} />;
440+ if (session && !isTable) {
441+ if (session.open) {
442+ if (compact) return <Controller session={session} prefs={prefs} onPrefs={setPrefs} />;
443+ if (mine.length === 0) {
444+ return <SeatPick session={session} prefs={prefs} onPrefs={setPrefs} />;
445+ }
446+ // A room with no table in it, on a screen that could not show one anyway.
447+ // The lobby underneath would offer to deal, which is not this device's to
448+ // do and not what has gone wrong — see Adrift.
449+ } else if (compact) {
450+ return <Adrift session={session} />;
444451 }
445452 }
446453
⋯ 159 unchanged lines
606613 * business inviting anybody, and a screen turned round to somebody else's
607614 * seat is not the table either.
608615 */
616+ /**
617+ * Who is at this table besides the people in front of it, in one line: the
618+ * devices somewhere else, and the hands the computer is playing.
619+ *
620+ * A table with phones on it has no other way of saying so out loud — a hand
621+ * that went to somebody's phone shows a 📱 on its nameplate, but a phone
622+ * still on the seat picker, or a friend watching from a laptop, is invisible.
623+ * It is also the first thing to look at when a QR does not seem to be
624+ * working: scan it and the number goes up.
625+ */
626+ const others = session ? Math.max(0, session.playerCount - 1) : 0;
627+ const botCount = SEATS.filter((q) => ctl.state.bots[q]).length;
628+ const tableLine = [
629+ others > 0 && t(`${others} 台裝置連線中`, `${others} client${others === 1 ? '' : 's'} connected`, ' · '),
630+ botCount > 0 && t(`${botCount} 家電腦`, `${botCount} bot${botCount === 1 ? '' : 's'}`, ' · '),
631+ ]
632+ .filter(Boolean)
633+ .join(' · ');
609634 const joinUrl =
610635 session && isTable && session.invitable
611636 ? roomLink(session.room.code, session.room.publicBase)
⋯ 1 unchanged line
613638
614639 return (
615640 <div className="table">
641+ {/* How many other devices are in this room, in the corner. A table with
642+ phones on it has no other way of saying so out loud — a hand that went
643+ to somebody's phone shows a 📱 on its nameplate, but a phone still on
644+ the seat picker, or a friend watching from a laptop, is invisible. It
645+ is also the first thing to look at when a QR does not seem to be
646+ working: scan it and the number goes up. */}
647+ {tableLine && <div className="net-count">{tableLine}</div>}
616648 {/* The wire has gone quiet. Said out loud on the table too, not only on
617649 the lobby it is easy to have left behind: a hand that has stopped
618650 answering should say why rather than looking like a game that has
⋯ 66 unchanged lines
modifiedsrc/styles.css+174 −34
⋯ 239 unchanged lines
240240 room, or at somebody else's joke. */
241241 .seat.awaited {
242242 background: linear-gradient(to top, rgba(217, 180, 92, 0.34), transparent 78%);
243+ animation: spotlight 2.6s ease-in-out infinite;
244+}
245+/* And it breathes. A gold wash that simply sits there is read once and then
246+ stops being read — it becomes part of the felt, which is exactly what this
247+ must not be, because the person it is for is the one who has not looked up.
248+ Movement is the thing peripheral vision answers to.
249+
250+ Slow, and never all the way off: two and a half seconds is a breath rather
251+ than a blink, and the low end still says clearly whose go it is to anybody
252+ who glances at that moment. The red on `claiming` runs at half this and is
253+ meant to: a claim window closes and this does not. */
254+@keyframes spotlight {
255+ 50% {
256+ background: linear-gradient(to top, rgba(217, 180, 92, 0.62), transparent 86%);
257+ }
243258 }
244259 /* And the name at the outer end of the strip, since that is what somebody
245260 glancing up reads to find out who — the felt says *where*, the plate says
⋯ 571 unchanged lines
817832 Ivory, not coloured: a mahjong tile is ivory and it is the *character* that
818833 is red or green. So the call takes the colour and the face never does.
819834
820- Full size only. A phone's bar is a row of its own with room to write on, and
821- a thumb wants a button it can hit. */
835+ The phone gets them too. Its bar is a row of its own rather than the end of
836+ the hand row, but a call is still a thing you do with a tile and it should
837+ still look like one — so `.ctl-actions` is scoped in alongside, and sizes its
838+ tiles off a `--tile-w` of its own that is what a thumb needs rather than what
839+ seventeen across a hand allows. Not the compact *table*, where four hands
840+ share one phone and a tile is too small to write a call on. */
822841 /* Not a white rectangle standing in for a tile: the tile itself. The sheet the
823842 hand is drawn from has a forty-third cell with no character on it — a blank
824843 tile — and that is what a button is drawn with, at the same size, off the
⋯ 7 unchanged lines
832851 Pressed, it turns over to the sheet's second row — the highlighted variant,
833852 which is what a tile lifted out of the hand switches to. The same gesture
834853 lights the same way whether it is a tile or a call. */
835-:root:not(.compact) .actionbar .btn {
854+:is(:root:not(.compact) .actionbar, .ctl-actions) .btn {
836855 position: relative;
837856 flex-direction: column;
838857 width: var(--tile-w);
⋯ 14 unchanged lines
853872 /* Under the finger it lifts, the way a tile does — a good deal less far, since
854873 a button that jumped a quarter of its own height would be a button that moved
855874 out from under the click. */
856-:root:not(.compact) .actionbar .btn:hover {
875+:is(:root:not(.compact) .actionbar, .ctl-actions) .btn:hover {
857876 transform: translateY(calc(var(--tile-w) * -0.07));
858877 filter: drop-shadow(0 5px 7px rgba(0, 0, 0, 0.5));
859878 }
860-:root:not(.compact) .actionbar .btn:active {
879+:is(:root:not(.compact) .actionbar, .ctl-actions) .btn:active {
861880 transform: translateY(1px);
862881 background-position: calc(var(--tile-w) * -42) calc(var(--tile-w) * -1.375);
863882 filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.45));
⋯ 19 unchanged lines
883902 at column 42 is white from 14% of its width to 98%, and from 1% of its height
884903 to 91%. As padding rather than a nudge, so the grid inside centres on the
885904 face and the English sits along the face's own bottom edge. */
886-:root:not(.compact) .actionbar .btn .bi {
905+:is(:root:not(.compact) .actionbar, .ctl-actions) .btn .bi {
887906 display: grid;
888907 grid-template-columns: auto auto;
889908 grid-template-rows: 1fr auto;
⋯ 6 unchanged lines
896915 padding: calc(var(--tile-w) * 0.015) calc(var(--tile-w) * 0.02)
897916 calc(var(--tile-w) * 0.125) calc(var(--tile-w) * 0.14);
898917 }
899-:root:not(.compact) .actionbar .btn .bi .zh {
918+:is(:root:not(.compact) .actionbar, .ctl-actions) .btn .bi .zh {
900919 grid-row: 1;
901920 grid-column: 2;
902921 /* Its own width, centred in the column, rather than stretched to fill it —
⋯ 13 unchanged lines
916935 needs out across every track it covers, so the empty 五萬 column took half of
917936 the difference and carried the reading right along with it. In one column it
918937 sizes that column and nothing else, and the pair of them centre together. */
919-:root:not(.compact) .actionbar .btn .bi .en {
938+:is(:root:not(.compact) .actionbar, .ctl-actions) .btn .bi .en {
920939 grid-row: 2;
921940 grid-column: 2;
922941 justify-self: center;
⋯ 5 unchanged lines
928947 }
929948 /* An English-only table has nothing standing above it, so the name is the face
930949 rather than the caption under one. */
931-:root:not(.compact) .actionbar .btn .bi .en:first-child {
950+:is(:root:not(.compact) .actionbar, .ctl-actions) .btn .bi .en:first-child {
932951 grid-row: 1 / -1;
933952 font-size: calc(var(--tile-w) * 0.22);
934953 opacity: 0.85;
⋯ 1 unchanged line
936955 /* Which tile it is about — 打出 五萬, 吃 三萬 四萬. Small, because the tile it
937956 names is lifted out of the row right beside it, and written the same way
938957 down the face as the call it belongs to. */
939-:root:not(.compact) .actionbar .btn .bi .bi-sub {
958+:is(:root:not(.compact) .actionbar, .ctl-actions) .btn .bi .bi-sub {
940959 grid-row: 1;
941960 grid-column: 1;
942961 justify-self: center;
⋯ 4 unchanged lines
947966 opacity: 0.75;
948967 }
949968 /* The character carries the colour, the way the dragons do. */
950-:root:not(.compact) .actionbar .btn.claim.hu {
969+:is(:root:not(.compact) .actionbar, .ctl-actions) .btn.claim.hu {
951970 color: #b3261e;
952971 }
953-:root:not(.compact) .actionbar .btn.claim.pung,
954-:root:not(.compact) .actionbar .btn.claim.kong {
972+:is(:root:not(.compact) .actionbar, .ctl-actions) .btn.claim.pung,
973+:is(:root:not(.compact) .actionbar, .ctl-actions) .btn.claim.kong {
955974 color: #1c527d;
956975 }
957-:root:not(.compact) .actionbar .btn.claim.chow {
976+:is(:root:not(.compact) .actionbar, .ctl-actions) .btn.claim.chow {
958977 color: #1d6640;
959978 }
960979 /* 看牌 is the only button a covered hand has, and it is the one button here
961980 that is not done *with* a tile — it is done to the card lying over them. So
962981 it wears the back rather than the face, and reads as the thing beside it. */
963-:root:not(.compact) .actionbar .btn.reveal {
982+:is(:root:not(.compact) .actionbar, .ctl-actions) .btn.reveal {
964983 background: url('./assets/back.png') center / 100% 100%;
965984 color: var(--ivory);
966985 text-shadow: 0 1px 2px rgba(0, 0, 0, 0.6);
⋯ 2 unchanged lines
969988 the ring says so — which is what stops four face-down hands and no buttons
970989 reading as a table that has stopped rather than one waiting for somebody to
971990 look at their tiles. */
972-:root:not(.compact) .actionbar .btn.reveal.hot {
991+:is(:root:not(.compact) .actionbar, .ctl-actions) .btn.reveal.hot {
973992 outline: 2px solid var(--gold);
974993 outline-offset: -2px;
975994 /* Gold off the tile's own outline rather than off a rectangle behind it: the
⋯ 3 unchanged lines
979998 }
980999 /* 過 and 摸牌 are not calls, so they are the plain face — and a pass reads as
9811000 the quiet one it is. */
982-:root:not(.compact) .actionbar .btn.pass,
983-:root:not(.compact) .actionbar .btn.ghost {
1001+:is(:root:not(.compact) .actionbar, .ctl-actions) .btn.pass,
1002+:is(:root:not(.compact) .actionbar, .ctl-actions) .btn.ghost {
9841003 color: rgba(33, 38, 43, 0.65);
9851004 }
9861005 /* The one that plays a tile wore a gold rim drawn round the face. The face is
⋯ 1595 unchanged lines
25822601 screen, with the common screen doing everything else. Tiles get the room —
25832602 they are the only thing this screen is for. */
25842603 .ctl {
2585- /* The whole hand in one row: 17 tiles and their gaps must fit the width. */
2586- --tile-w: clamp(30px, calc((100vw - 56px) / 17.6), 76px);
2604+ /* What a *button* is here: the calls are drawn as tiles, and a tile a thumb
2605+ has to hit is not the same size as a tile in a row of seventeen. The hand
2606+ sizes itself off how many tiles have to fit — see `.ctl-hand .hand` — so
2607+ the two no longer have to be the same number. */
2608+ --tile-w: clamp(46px, 13vmin, 74px);
2609+ --bar-h: calc(var(--tile-w) * 1.375);
25872610 position: fixed;
25882611 inset: 0;
25892612 display: flex;
⋯ 100 unchanged lines
26902713 font-weight: 600;
26912714 color: var(--gold);
26922715 }
2716+/* A row of tiles, so it is laid out as one: they stand on a common line and
2717+ the gap between them is the gap between tiles. */
26932718 .ctl-actions {
26942719 display: flex;
2695- align-items: center;
2720+ align-items: flex-end;
26962721 justify-content: center;
2697- gap: 8px;
2722+ gap: var(--hand-gap);
26982723 flex-wrap: wrap;
2724+ row-gap: 6px;
26992725 min-height: var(--bar-h);
2700- padding: 2px 10px;
2726+ padding: 4px 10px;
27012727 }
27022728 .ctl-hint {
27032729 font-size: 11px;
27042730 opacity: 0.55;
27052731 }
2732+/* The tiles get the middle of the screen. This is the one thing the phone is
2733+ for, and a row pinned to the bottom edge left the hand sitting under the
2734+ thumb holding the phone with the whole screen empty above it. */
27062735 .ctl-hand {
2707- margin-top: auto;
2736+ flex: 1;
27082737 display: flex;
2738+ align-items: center;
27092739 justify-content: center;
2740+ min-height: 0;
27102741 padding: 0 8px calc(10px + env(safe-area-inset-bottom, 0px));
27112742 }
27122743 .ctl-hand .hand {
2744+ /* The whole hand in one row: 17 tiles and their gaps must fit the width. */
2745+ --tile-w: min(76px, calc((100vw - 56px) / 17.6));
27132746 flex-wrap: nowrap;
27142747 }
2715-/* Held upright, seventeen tiles will never make one row. Break into two and
2716- size the tiles off half the count instead of shrinking them past reading.
2717- The row gap leaves room for the 摸 tag over a tile in the lower row. */
2748+/* Held upright, seventeen tiles will never make one row.
2749+
2750+ So the hand is laid out as a grid rather than left to wrap: wrapping fills
2751+ the first line and drops whatever is left onto the second, which for a
2752+ sixteen-tile hand on a narrow screen is nine and seven, or ten and six as
2753+ the width changes under it. A grid of `--hand-cols` — half the hand, rounded
2754+ up, counted in ui/Hand — is two lines of the same length whatever the screen
2755+ is doing, and the tiles are then sized off that count rather than shrunk to
2756+ fit a row that was never going to hold them.
2757+
2758+ The tile just drawn takes a line of its own above both, which is the same
2759+ thing the daylight beside it says at a full-size table: this one is not part
2760+ of the sorted hand yet. At the top rather than the bottom because that is
2761+ where the eye starts, and because the 摸 tag over it needs air above it. */
27182762 @media (orientation: portrait) {
2719- .ctl {
2720- --tile-w: clamp(34px, calc((100vw - 44px) / 9.4), 76px);
2721- }
27222763 .ctl-hand .hand {
2723- flex-wrap: wrap;
2764+ --tile-w: min(76px, calc((100vw - 40px) / (var(--hand-cols, 9) + 0.4)));
2765+ display: grid;
2766+ grid-template-columns: repeat(var(--hand-cols, 9), var(--tile-w));
27242767 justify-content: center;
2725- /* Bounded, or it stays one long row: a `flex: none` item is happy to
2726- overflow its container rather than wrap inside it. */
2768+ gap: var(--hand-gap);
2769+ row-gap: calc(var(--tile-w) * 0.5);
27272770 width: 100%;
27282771 height: auto;
2729- row-gap: calc(var(--tile-w) * 0.4);
2772+ /* Room over the top line for the 摸 tag, which hangs above its tile. */
2773+ padding-top: calc(var(--tile-w) * 0.42);
2774+ }
2775+ .ctl-hand .hand .drawn-slot {
2776+ order: -1;
2777+ grid-column: 1 / -1;
2778+ justify-self: center;
2779+ /* Its own line — the gap that sets it apart in a row is what the line is
2780+ doing here, and left in it would push it off the middle. */
2781+ margin-left: 0;
27302782 }
27312783 }
2784+/* The wire has gone, on a phone holding a live hand. A bar across the top,
2785+ because what is underneath it has stopped working even though it still looks
2786+ as though it has not: everything is dimmed a little and nothing takes a tap
2787+ while this is up. */
2788+.ctl-cut-bar {
2789+ position: fixed;
2790+ top: 0;
2791+ left: 0;
2792+ right: 0;
2793+ z-index: 6;
2794+ padding: 6px 12px calc(6px + env(safe-area-inset-top, 0px));
2795+ text-align: center;
2796+ font-size: 13px;
2797+ background: rgba(120, 44, 34, 0.94);
2798+ color: var(--ivory);
2799+ box-shadow: 0 2px 10px rgba(0, 0, 0, 0.45);
2800+}
2801+.ctl-cut-bar em {
2802+ font-style: normal;
2803+ opacity: 0.7;
2804+ margin-left: 6px;
2805+}
2806+.ctl-cut .ctl-actions,
2807+.ctl-cut .ctl-hand {
2808+ opacity: 0.45;
2809+ pointer-events: none;
2810+}
2811+/* A phone in a room with no table in it. Says which of the two it is and then
2812+ gets out of the way — there is nothing here to press. */
2813+.ctl-adrift {
2814+ align-items: center;
2815+ justify-content: center;
2816+ text-align: center;
2817+ gap: 6px;
2818+ padding: 24px;
2819+}
2820+.ctl-adrift h1 {
2821+ font-size: 26px;
2822+ margin: 0;
2823+}
2824+.ctl-adrift .sub {
2825+ letter-spacing: 0.28em;
2826+ color: var(--gold);
2827+ margin: 0;
2828+}
2829+.ctl-adrift .adrift-line {
2830+ font-size: 17px;
2831+ font-weight: 600;
2832+ margin: 10px 0 0;
2833+}
2834+.ctl-adrift .hint {
2835+ max-width: 30ch;
2836+ opacity: 0.7;
2837+ font-size: 13px;
2838+ line-height: 1.5;
2839+ margin: 0;
2840+}
2841+
27322842 /* The tile in the fingers once it has left the row, and its exit: flung, it
27332843 keeps going up off the screen — the common screen takes it from here. */
27342844 .ctl-held {
⋯ 142 unchanged lines
28772987 opacity: 0.75;
28782988 }
28792989
2990+/* How many other devices are in the room, in the top-left corner of the felt.
2991+ Small and dim: it is a thing to glance at when you are wondering whether a
2992+ scan worked, not a thing to read while playing. Out of the way of all four
2993+ strips, which sit against their own edges. */
2994+.net-count {
2995+ position: absolute;
2996+ top: 6px;
2997+ left: 8px;
2998+ z-index: 55;
2999+ font-size: 11px;
3000+ letter-spacing: 0.02em;
3001+ color: rgba(246, 240, 226, 0.42);
3002+ pointer-events: none;
3003+ user-select: none;
3004+}
3005+
28803006 /* ---------- the wire, when it goes ---------- */
28813007 /* One line across the top of the felt. The room is already reconnecting on its
28823008 own, so this says what is happening rather than asking for anything, and it
⋯ 15 unchanged lines
28983024 text-align: center;
28993025 pointer-events: none;
29003026 }
3027+
3028+/* ---------- movement, for anyone who would rather not ---------- */
3029+/* The table says things twice — the gold is also a name in gold, the clock is
3030+ also a bar — so nothing here is the only way anything is said, and the
3031+ breathing can simply stop. What is left is the bright end of each animation
3032+ held still, which is the state they were all built around. */
3033+@media (prefers-reduced-motion: reduce) {
3034+ .seat.awaited,
3035+ .seat.claiming,
3036+ .ctl-turn::before,
3037+ .ctl-claim::before {
3038+ animation: none;
3039+ }
3040+}
modifiedsrc/table/pool.ts+65 −2
⋯ 697 unchanged lines
698698 )}x${Math.round(table.clientHeight)}`;
699699 if (stamp === this.measuredAt) return;
700700
701+ const was = this.geo;
701702 const geo = measure(table);
702703 this.measuredAt = geo ? stamp : '';
703704 this.geo = geo;
⋯ 6 unchanged lines
710711 // What keeps a tile in is the tiles: the stacks still standing, the sets
711712 // laid down, and the hands beyond them. `felt` is the table's own edge and
712713 // is only there so a tile that got past all of that is still on the table.
713- if (!this.world) this.world = createWorld(geo.felt, geo.barriers);
714- else reshape(this.world, geo.felt, geo.barriers);
714+ if (!this.world) {
715+ this.world = createWorld(geo.felt, geo.barriers);
716+ } else {
717+ // The table changed size under the pile, so the pile changes with it.
718+ // Before it moves anything about.
719+ this.rescale(was, geo);
720+ reshape(this.world, geo.felt, geo.barriers);
721+ }
715722 // Only here, never per frame: a hundred-odd ray casts is nothing once a
716723 // draw, and a great deal every sixtieth of a second.
717724 this.setOpenness(SEATS.map((s) => (canThrow(geo, s) ? 'y' : '-')).join(''));
718725 }
719726
727+ /**
728+ * The window was resized, so every tile in the middle is the wrong size and
729+ * in the wrong place.
730+ *
731+ * A tile in the pool is drawn the size the tiles in the hands are, and those
732+ * are sized off the window — so a pile dealt into a small window and then
733+ * looked at full-screen was a pile of small tiles among big ones, which is a
734+ * mistake you can see rather than measure. The same goes for where they are
735+ * lying: the square they were thrown into has moved and stretched, and tiles
736+ * left at their old coordinates bunch into whichever corner the square used
737+ * to reach. `reshape` clamps them back inside, which stops them being lost
738+ * but stacks them along an edge.
739+ *
740+ * So the pile is mapped from the old square into the new one and scaled by
741+ * the same ratio the tiles are — the pile you had, at the size the table is
742+ * now. Velocities go with it, so a tile still skidding when the window moves
743+ * carries on skidding at the same speed relative to the felt rather than
744+ * suddenly crossing it faster or slower than it was.
745+ */
746+ private rescale(was: TableGeometry | null, geo: TableGeometry) {
747+ const world = this.world;
748+ if (!world || !was || was.pool.w <= 0 || was.pool.h <= 0 || was.tile.w <= 0) return;
749+ const k = geo.tile.w / was.tile.w;
750+ const sx = geo.pool.w / was.pool.w;
751+ const sy = geo.pool.h / was.pool.h;
752+ // A discard thrown does not resize the table; only a resize does.
753+ if (Math.abs(k - 1) < 0.001 && Math.abs(sx - 1) < 0.001 && Math.abs(sy - 1) < 0.001) return;
754+ const map = (b: { x: number; y: number }) => {
755+ b.x = geo.pool.x + (b.x - was.pool.x) * sx;
756+ b.y = geo.pool.y + (b.y - was.pool.y) * sy;
757+ };
758+ for (const b of world.bodies) {
759+ map(b);
760+ b.w *= k;
761+ b.h *= k;
762+ b.z *= k;
763+ b.vx *= sx;
764+ b.vy *= sy;
765+ b.vz *= k;
766+ }
767+ // On their way to whoever claimed them: the seat they are flying at has
768+ // moved too, and `geo.launch` is where it moved to.
769+ for (const l of this.leaving) {
770+ const to = geo.launch[l.body.seat];
771+ if (!to) continue;
772+ l.tx = to.x;
773+ l.ty = to.y;
774+ }
775+ // And the tile in somebody's fingers, which the canvas is drawing at the
776+ // size it was lifted at.
777+ if (this.held) {
778+ this.held.w *= k;
779+ this.held.h *= k;
780+ }
781+ }
782+
720783 private setOpenness(next: string) {
721784 if (next === this.openness) return;
722785 this.openness = next;
⋯ 169 unchanged lines
modifiedsrc/ui/Controller.tsx+61 −2
⋯ 4 unchanged lines
55 import type { SeatId } from '../game/types';
66 import type { NetSession } from '../net/session';
77 import { seatFromUrl } from '../net/protocol';
8-import { Bi, BiEm, CLAIM_LABEL } from './Bi';
8+import { Bi, BiEm, BiText, CLAIM_LABEL } from './Bi';
99 import { useLang, useT } from './lang';
1010 import { FullscreenToggle } from './FullscreenToggle';
1111 import { Hand, type Throw } from './Hand';
⋯ 31 unchanged lines
4343 }
4444
4545 /**
46+ * A phone with nothing to hold: it is in a room, and the room has no table in
47+ * it.
48+ *
49+ * Which is a real thing that happens rather than an error — the laptop closed
50+ * its lid, the server restarted and the room came back empty, or somebody was
51+ * sent the link before the tiles were dealt. From the phone all three look the
52+ * same and none of them is anything it can fix, so it says which of the two
53+ * situations it is in and waits, rather than offering to deal a table of its
54+ * own on a screen that could never show one.
55+ *
56+ * The room code is on it because that is the one thing worth reading out loud
57+ * to whoever is holding the laptop.
58+ */
59+export function Adrift({ session }: { session: NetSession }) {
60+ const t = useT();
61+ const lang = useLang();
62+ return (
63+ <div className="ctl ctl-adrift">
64+ <h1>{lang === 'en' ? 'Taiwanese Mahjong' : '台灣麻將'}</h1>
65+ <p className="sub">{session.room.code}</p>
66+ {session.err ? (
67+ <>
68+ <p className="adrift-line">{t('連線中斷', 'Connection lost', ' · ')}</p>
69+ <p className="hint">
70+ <BiText
71+ zh="正在重新連線,回來的時候你還坐在原位。"
72+ en="Reconnecting — your seat is still yours when it comes back."
73+ />
74+ </p>
75+ </>
76+ ) : (
77+ <>
78+ <p className="adrift-line">{t('這桌還沒開', 'No table here yet', ' · ')}</p>
79+ <p className="hint">
80+ <BiText
81+ zh="這個房間裡還沒有牌局。等開桌的那台按下開始,這頁就會自己接上。"
82+ en="Nothing has been dealt in this room. When whoever has the table starts a game, this page joins it by itself."
83+ />
84+ </p>
85+ </>
86+ )}
87+ <p className="hint">
88+ {t(`${session.playerCount} 人在房`, `${session.playerCount} here`, ' · ')}
89+ </p>
90+ </div>
91+ );
92+}
93+
94+/**
4695 * Which hand you are taking. Shown to any device that has walked into a room
4796 * and is not the table it was opened around — a phone off a QR, a friend off
4897 * the invite link.
⋯ 148 unchanged lines
197246 }, [callKey]);
198247
199248 return (
200- <div className={`ctl ${calling ? `ctl-${calling}` : ''}`}>
249+ <div className={`ctl ${calling ? `ctl-${calling}` : ''} ${session.err ? 'ctl-cut' : ''}`}>
250+ {/* The wire has gone. Said across the top of the hand rather than in a
251+ corner, because everything below it has quietly stopped working: a
252+ call tapped now is dropped on the floor, and a hand that looks live
253+ and does nothing is worse than one that says why. The room reconnects
254+ on its own and this clears when it does. */}
255+ {session.err && (
256+ <div className="ctl-cut-bar">
257+ <BiEm zh="連線中斷,重新連線中" en="Connection lost — reconnecting" />
258+ </div>
259+ )}
201260 {/* The same seven seconds the table is showing everyone else, on the
202261 screen of the person they are all waiting for. */}
203262 {myTurn && <TurnClock key={turnId(s)} />}
⋯ 146 unchanged lines
modifiedsrc/ui/Hand.tsx+34 −6
⋯ 85 unchanged lines
8686 function nearestIndex(container: HTMLElement, x: number, y: number): number | null {
8787 const centers = rowCenters(container);
8888 if (!centers) return null;
89- const { points, horizontal } = centers;
90- const along = (p: { x: number; y: number }) => (horizontal ? p.x : p.y);
91- const p = along({ x, y });
89+ const { points, horizontal, lines } = centers;
90+
91+ // One line of tiles is a line: the only question a finger is asking is how
92+ // far along it, and measuring the other axis as well would fight a drag that
93+ // strays off the row on its way past. Broken into lines — the phone held
94+ // upright — that is exactly the wrong answer, because the tile above the
95+ // finger and the tile below it are the same distance along and only one of
96+ // them is being pointed at.
97+ const dist = horizontal
98+ ? (p: { x: number; y: number }) => Math.abs(p.x - x)
99+ : (p: { x: number; y: number }) => Math.abs(p.y - y);
100+ const measure = lines > 1 ? (p: { x: number; y: number }) => Math.hypot(p.x - x, p.y - y) : dist;
92101
93102 let best = 0;
94103 let bestD = Infinity;
95104 points.forEach((c, i) => {
96- const d = Math.abs(along(c) - p);
105+ const d = measure(c);
97106 if (d < bestD) {
98107 bestD = d;
99108 best = i;
⋯ 10 unchanged lines
110119 return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
111120 });
112121 const horizontal = Math.abs(points[1].x - points[0].x) >= Math.abs(points[1].y - points[0].y);
113- return { points, horizontal };
122+ // How many rows the tiles are laid out in — one at a table, two on a phone
123+ // held upright. Counted off the elements rather than off the layout that put
124+ // them there, so it is right whoever moves them next.
125+ const across = horizontal ? points.map((p) => p.y) : points.map((p) => p.x);
126+ const lines = new Set(across.map((v) => Math.round(v / 8))).size;
127+ return { points, horizontal, lines };
114128 }
115129
116130 /**
⋯ 198 unchanged lines
315329 );
316330 }
317331
332+ // How many tiles go in a row when the row has to become two — a phone held
333+ // upright. Half the sorted hand, rounded up, so the two lines come out as
334+ // even as an odd hand allows; the tile just drawn is not counted, because it
335+ // is given a line of its own above them. Read by the portrait rules in
336+ // styles.css and ignored everywhere else.
337+ const cols = Math.max(1, Math.ceil((tiles.length - (drawnIndex === null ? 0 : 1)) / 2));
338+
318339 return (
319- <div className="hand" ref={ref} onPointerMove={move} onPointerUp={up} onPointerCancel={up}>
340+ <div
341+ className="hand"
342+ ref={ref}
343+ style={{ '--hand-cols': cols } as React.CSSProperties}
344+ onPointerMove={move}
345+ onPointerUp={up}
346+ onPointerCancel={up}
347+ >
320348 {tiles.map((tile, i) => (
321349 <div
322350 key={i}
⋯ 18 unchanged lines