anvilsign in

collin/mahjong · d42faae9

Seven seconds of gold, and a button drawn from the tile sheet

Collin Richards · 2026-08-25 09:16 UTC · d42faae9e0fc0c500475684a64400b49427c7a84 · parent c2c28d83 · browse files

modifiedREADME.md+15 −0
⋯ 142 unchanged lines
143143 goes. 打出 names no tile: the one it would throw is lifted out of the row
144144 beside it wearing a gold ring, so saying 五萬 on the button as well only
145145 crowds the face.
146+- **Whose go it is** — a screen with four people round it is the one place a
147+ game can go quiet without anybody noticing: three of them are watching the
148+ table and the fourth is looking at their phone. So the table says it loudly.
149+ The strip of the seat being waited on lights from its inner edge, their name
150+ goes gold at the far end of it, and seven seconds of gold drain along the
151+ edge that faces the middle — from both ends towards the centre, because two
152+ of the four strips are upside down from wherever you are sitting. **Nothing
153+ happens when it reaches zero.** No tile is thrown for you and no seat is
154+ skipped; the line the bar leaves behind breathes, and the table goes on
155+ waiting. It is a nudge, not a rule. Said on a covered hand too — whose turn
156+ it is was never one of the things the card is hiding — and only where the
157+ screen is shared: solo has nobody to tell, and online everybody is already
158+ looking at their own device. A phone at the table gets the same seven seconds
159+ across its top, a gold rim edge to edge (red for a claim window), and one
160+ short buzz as the game comes round to it.
146161 - **On the keys** — ← and → walk the selection along your hand and space throws
147162 the tile that is lifted. How long you *hold* space is how hard it goes: the
148163 tile draws back out of the hand while the key is down and leaves when you let
⋯ 703 unchanged lines
modifiedsrc/styles.css+191 −15
⋯ 228 unchanged lines
229229 .seat.active {
230230 background: linear-gradient(to top, rgba(217, 180, 92, 0.22), transparent 70%);
231231 }
232+/* Whose go it is, on a screen with four people round it. `active` is the same
233+ thing said to a player who is already looking at their own tiles; this is the
234+ version the *room* can read — it goes on a covered hand too, since whose turn
235+ it is was never one of the things the card over a hand is hiding, and the
236+ seat that has not noticed is exactly the one it is for.
237+
238+ Twice the gold `active` has and up twice as far into the strip: at a party
239+ table the person being waited on may be looking at their phone, across the
240+ room, or at somebody else's joke. */
241+.seat.awaited {
242+ background: linear-gradient(to top, rgba(217, 180, 92, 0.34), transparent 78%);
243+}
244+/* And the name at the outer end of the strip, since that is what somebody
245+ glancing up reads to find out who — the felt says *where*, the plate says
246+ *who*, and at a table of people who have known each other five minutes those
247+ are not the same question. */
248+.seat.awaited .nameplate {
249+ opacity: 1;
250+}
251+.seat.awaited .nameplate .pname {
252+ color: var(--gold);
253+ text-shadow: 0 0 10px rgba(217, 180, 92, 0.55);
254+}
232255 .seat.claiming {
233256 background: linear-gradient(to top, rgba(179, 38, 30, 0.25), transparent 70%);
234257 animation: pulse 1.1s ease-in-out infinite;
⋯ 4 unchanged lines
239262 }
240263 }
241264
265+/* ---------- the seven seconds ---------- */
266+/* A bar of gold along the inner edge of the strip — the edge that faces the
267+ middle of the table, which is the one everybody at the screen is looking
268+ over. Every seat is rotated so that its own top is the inner edge, so `top`
269+ is the right answer at all four of them without asking which.
270+
271+ It drains from both ends towards the middle rather than from one end to the
272+ other, because two of the four strips are upside down from wherever you are
273+ sitting and a bar that empties leftwards for you empties rightwards for the
274+ person opposite. Draining symmetrically reads the same from every chair.
275+
276+ Nothing happens when it reaches zero. The bar is gone and the line it leaves
277+ breathes, and the table goes on waiting — see ui/TurnClock. */
278+.turn-clock {
279+ position: absolute;
280+ top: 0;
281+ left: 8px;
282+ right: 8px;
283+ height: 5px;
284+ border-radius: 999px;
285+ overflow: hidden;
286+ pointer-events: none;
287+ z-index: 3;
288+}
289+.turn-clock i {
290+ display: block;
291+ height: 100%;
292+ border-radius: inherit;
293+ background: linear-gradient(
294+ 90deg,
295+ rgba(217, 180, 92, 0.45),
296+ var(--gold) 28%,
297+ var(--gold) 72%,
298+ rgba(217, 180, 92, 0.45)
299+ );
300+ box-shadow: 0 0 9px rgba(217, 180, 92, 0.65);
301+ animation: turn-drain var(--turn-secs, 7s) linear forwards;
302+}
303+@keyframes turn-drain {
304+ from {
305+ transform: scaleX(1);
306+ }
307+ to {
308+ transform: scaleX(0);
309+ }
310+}
311+/* What is left at zero: the same line, dim, breathing. Delayed by exactly the
312+ seconds the bar took, so it starts as the gold runs out. */
313+.turn-clock::after {
314+ content: '';
315+ position: absolute;
316+ inset: 1px 0;
317+ border-radius: inherit;
318+ background: rgba(217, 180, 92, 0.5);
319+ opacity: 0;
320+ animation: turn-empty 1.5s ease-in-out var(--turn-secs, 7s) infinite;
321+}
322+@keyframes turn-empty {
323+ 0%,
324+ 100% {
325+ opacity: 0.12;
326+ }
327+ 50% {
328+ opacity: 0.55;
329+ }
330+}
331+
242332 /* ---------- tiles ---------- */
243333 /* Faces come from a 43x2 sprite sheet; the second row is the highlighted
244334 variant, which we use for the tile a player has lifted to discard. */
⋯ 475 unchanged lines
720810
721811 Full size only. A phone's bar is a row of its own with room to write on, and
722812 a thumb wants a button it can hit. */
813+/* Not a white rectangle standing in for a tile: the tile itself. The sheet the
814+ hand is drawn from has a forty-third cell with no character on it — a blank
815+ tile — and that is what a button is drawn with, at the same size, off the
816+ same file, through the same `background-size` arithmetic as `.tile.face`. So
817+ a button has the bevel the tiles have, the two grey sides they stand on
818+ (`sideLight` down the left, `sideDark` along the bottom), the black hairline
819+ round the outside, and the shadow the drop-shadow filter takes from the art's
820+ own alpha rather than from a rectangle. It cannot drift from the tiles beside
821+ it, because it is one of them.
822+
823+ Pressed, it turns over to the sheet's second row — the highlighted variant,
824+ which is what a tile lifted out of the hand switches to. The same gesture
825+ lights the same way whether it is a tile or a call. */
723826 :root:not(.compact) .actionbar .btn {
827+ position: relative;
724828 flex-direction: column;
725829 width: var(--tile-w);
726830 height: var(--bar-h);
727831 min-height: 0;
728- padding: 2px 1px;
832+ padding: 0;
729833 gap: 0;
834+ border: none;
730835 border-radius: calc(var(--tile-w) * 0.1);
731- /* The white the tiles themselves are, not the bone the panels are: a tile's
732- face is white and its sides are the two greys in `LOOK` (ui/tileArt.ts),
733- and the bottom stop here is that same `sideLight`, so a button standing in
734- the row reads as one more tile rather than as a card lying beside them. */
735- background: linear-gradient(180deg, #ffffff, #d7d7d7);
836+ background: url('./assets/tiles.svg') no-repeat;
837+ background-size: calc(var(--tile-w) * 43) calc(var(--tile-w) * 2.75);
838+ background-position: calc(var(--tile-w) * -42) 0;
839+ box-shadow: none;
840+ filter: drop-shadow(0 2px 3px rgba(0, 0, 0, 0.45));
736841 color: var(--ink);
737- border: none;
738- box-shadow: 0 2px 3px rgba(0, 0, 0, 0.45), inset 0 calc(var(--tile-w) * -0.06) 0 rgba(0, 0, 0, 0.09);
739- overflow: hidden;
842+ transition: transform 0.12s ease, filter 0.12s ease;
843+}
844+/* Under the finger it lifts, the way a tile does — a good deal less far, since
845+ a button that jumped a quarter of its own height would be a button that moved
846+ out from under the click. */
847+:root:not(.compact) .actionbar .btn:hover {
848+ transform: translateY(calc(var(--tile-w) * -0.07));
849+ filter: drop-shadow(0 5px 7px rgba(0, 0, 0, 0.5));
740850 }
741851 :root:not(.compact) .actionbar .btn:active {
742852 transform: translateY(1px);
743- box-shadow: 0 1px 2px rgba(0, 0, 0, 0.45);
853+ background-position: calc(var(--tile-w) * -42) calc(var(--tile-w) * -1.375);
854+ filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.45));
744855 }
745856 /* The call is written down the tile, the way the character on a tile's face is
746857 written: one on top of the next, in the middle of the face, with the English
⋯ 9 unchanged lines
756867 and centres what is in it, one row that is exactly the English. The reading
757868 (打出) is the right-hand column, and the tile it is about (五萬) the column to
758869 its left, because vertical Chinese reads right to left. */
870+/* Written on the face, which is not the middle of the tile: the art stands the
871+ tile up on `LOOK.depth` of thickness towards the bottom left, so the face is
872+ inset by the left side's width and the bottom side's height and hardly at all
873+ on the other two. Measured off the sheet rather than guessed — the blank cell
874+ at column 42 is white from 14% of its width to 98%, and from 1% of its height
875+ to 91%. As padding rather than a nudge, so the grid inside centres on the
876+ face and the English sits along the face's own bottom edge. */
759877 :root:not(.compact) .actionbar .btn .bi {
760878 display: grid;
761879 grid-template-columns: auto auto;
⋯ 4 unchanged lines
766884 line-height: 1;
767885 height: 100%;
768886 width: 100%;
887+ padding: calc(var(--tile-w) * 0.015) calc(var(--tile-w) * 0.02)
888+ calc(var(--tile-w) * 0.125) calc(var(--tile-w) * 0.14);
769889 }
770890 :root:not(.compact) .actionbar .btn .bi .zh {
771891 grid-row: 1;
⋯ 71 unchanged lines
843963 :root:not(.compact) .actionbar .btn.reveal.hot {
844964 outline: 2px solid var(--gold);
845965 outline-offset: -2px;
846- box-shadow: 0 2px 3px rgba(0, 0, 0, 0.45), 0 0 10px rgba(217, 180, 92, 0.45);
966+ /* Gold off the tile's own outline rather than off a rectangle behind it: the
967+ shadow already comes from the art's alpha, and a second glow drawn as a box
968+ would put a corner where the tile has none. */
969+ filter: drop-shadow(0 2px 3px rgba(0, 0, 0, 0.45)) drop-shadow(0 0 6px rgba(217, 180, 92, 0.75));
847970 }
848971 /* 過 and 摸牌 are not calls, so they are the plain face — and a pass reads as
849972 the quiet one it is. */
⋯ 2 unchanged lines
852975 color: rgba(33, 38, 43, 0.65);
853976 }
854977 /* The one that plays a tile: the gold edge every other gold button has, kept
855- as a rim rather than a face. */
856-:root:not(.compact) .actionbar .btn.discard,
857-:root:not(.compact) .actionbar .btn.proceed {
858- box-shadow: 0 2px 3px rgba(0, 0, 0, 0.45), inset 0 0 0 2px var(--gold);
978+ as a rim rather than a face — and drawn round the *face* rather than round
979+ the whole tile, since a rectangle of gold across the two grey sides would be
980+ a rectangle lying on top of a tile rather than a line drawn on one. Same
981+ insets as the writing, off the same measurement of the art. */
982+:root:not(.compact) .actionbar .btn.discard::after,
983+:root:not(.compact) .actionbar .btn.proceed::after {
984+ content: '';
985+ position: absolute;
986+ top: calc(var(--tile-w) * 0.015);
987+ right: calc(var(--tile-w) * 0.02);
988+ bottom: calc(var(--tile-w) * 0.125);
989+ left: calc(var(--tile-w) * 0.14);
990+ border: 2px solid var(--gold);
991+ border-radius: calc(var(--tile-w) * 0.07);
992+ pointer-events: none;
859993 }
860994 .big-btn {
861995 font-size: 22px;
⋯ 1667 unchanged lines
25292663 color: var(--gold);
25302664 font-weight: 600;
25312665 }
2666+/* The table has come round to this phone. Four people at one screen and a
2667+ phone each is a game that stalls on whoever is not looking at either, so the
2668+ whole screen answers rather than a line of text at the top of it: a gold rim
2669+ edge to edge, breathing, with the seven seconds running along the top. It
2670+ buzzes once as well — see `calling` in ui/Controller.
2671+
2672+ Red for a claim, because a claim window is somebody else's tile going past
2673+ and it is the one thing here with a clock on it that is not yours. */
2674+.ctl-turn::before,
2675+.ctl-claim::before {
2676+ content: '';
2677+ position: fixed;
2678+ inset: 0;
2679+ pointer-events: none;
2680+ z-index: 2;
2681+ animation: ctl-breathe 2.1s ease-in-out infinite;
2682+}
2683+.ctl-turn::before {
2684+ box-shadow: inset 0 0 0 3px var(--gold), inset 0 0 36px rgba(217, 180, 92, 0.3);
2685+}
2686+.ctl-claim::before {
2687+ box-shadow: inset 0 0 0 3px #e0574a, inset 0 0 36px rgba(179, 38, 30, 0.32);
2688+}
2689+@keyframes ctl-breathe {
2690+ 50% {
2691+ opacity: 0.45;
2692+ }
2693+}
2694+.ctl-turn .ctl-status,
2695+.ctl-claim .ctl-status {
2696+ font-size: 16px;
2697+}
2698+/* Across the top of the phone, edge to edge — there is no strip to sit inside
2699+ here, the screen is the strip. */
2700+.ctl .turn-clock {
2701+ position: fixed;
2702+ left: 0;
2703+ right: 0;
2704+ height: 6px;
2705+ border-radius: 0;
2706+ z-index: 4;
2707+}
25322708 .ctl-last {
25332709 display: inline-flex;
25342710 align-items: center;
⋯ 258 unchanged lines
modifiedsrc/ui/Controller.tsx+22 −1
⋯ 9 unchanged lines
1010 import { FullscreenToggle } from './FullscreenToggle';
1111 import { Hand, type Throw } from './Hand';
1212 import { SoundToggle } from './SoundToggle';
13+import { TurnClock, turnId } from './TurnClock';
1314 import { TileView } from './TileView';
1415
1516 const SEATS: SeatId[] = [0, 1, 2, 3];
⋯ 153 unchanged lines
169170 hot: false,
170171 };
171172
173+ /**
174+ * Four people at one table and one screen in the middle of it: the phone in
175+ * a pocket, or face down beside a drink, is the one that has not noticed the
176+ * game has come round. So when it does, this phone says so with everything
177+ * it has — it goes gold edge to edge, and it buzzes once.
178+ *
179+ * The buzz is per turn, not per render: the string changes when the table
180+ * moves, and only then. Phones that will not vibrate (a desk browser, one
181+ * that has not been tapped yet) simply do not, and the gold is still there.
182+ */
183+ const calling = myTurn ? 'turn' : opts ? 'claim' : null;
184+ const callKey = calling && `${calling}:${turnId(s)}`;
185+ useEffect(() => {
186+ if (!callKey) return;
187+ navigator.vibrate?.(callKey.startsWith('turn') ? [90, 70, 90] : [40, 60, 40]);
188+ }, [callKey]);
189+
172190 return (
173- <div className="ctl">
191+ <div className={`ctl ${calling ? `ctl-${calling}` : ''}`}>
192+ {/* The same seven seconds the table is showing everyone else, on the
193+ screen of the person they are all waiting for. */}
194+ {myTurn && <TurnClock key={turnId(s)} />}
174195 <div className="ctl-top">
175196 <span className="wind">
176197 <BiEm zh={WIND_NAMES[seatWind]} en={WIND_EN[seatWind]} />
⋯ 143 unchanged lines
modifiedsrc/ui/Seat.tsx+24 −1
⋯ 7 unchanged lines
88 import { useT } from './lang';
99 import { Hand, type Carry } from './Hand';
1010 import { SEAT_ROT, SEAT_SLOT } from './rotation';
11+import { TurnClock, turnId } from './TurnClock';
1112 import { TileView } from './TileView';
1213
1314 /**
⋯ 120 unchanged lines
134135 * anywhere reads as stuck rather than as waiting for somebody to look.
135136 */
136137 const awaited = plays && (playing || game.nextDrawer() === seat);
138+ /**
139+ * Whose go it is, said out loud on a screen several people are watching —
140+ * the strip lights and the clock runs. Public, so a hand still face down
141+ * under its card lights the same as one being read: whose turn it is has
142+ * never been a secret, and the seat that has not noticed is exactly the one
143+ * this is for.
144+ *
145+ * Only where the screen is shared. On a solo table there is nobody to tell,
146+ * and online everybody is looking at their own device, where their own turn
147+ * is the one thing the screen is already about.
148+ */
149+ const onTheClock = !!shared && playing && !bot;
137150 useEffect(() => {
138151 if (wasPlaying.current && !playing) setShown(false);
139152 wasPlaying.current = playing;
⋯ 97 unchanged lines
237250 // edge it is drawn at: the physics is about who threw the tile.
238251 <div className={`slot ${SEAT_SLOT[pos]} ${pos === 0 ? 'mine' : 'opp'}`} data-seat={seat}>
239252 <div
240- className={`seat ${myTurn ? 'active' : ''} ${claiming ? 'claiming' : ''}`}
253+ className={`seat ${myTurn ? 'active' : ''} ${claiming ? 'claiming' : ''} ${
254+ onTheClock ? 'awaited' : ''
255+ }`}
241256 style={compact ? undefined : { transform: `translate(-50%, -50%) rotate(${SEAT_ROT[pos]}deg)` }}
242257 >
258+ {/* Seven seconds of gold along this seat's inner edge. A screen with
259+ four people at it is the one place the game can go quiet without
260+ anybody noticing — three of them are looking at the table and the
261+ fourth is looking at their phone — so the table says whose go it is
262+ loudly enough to be read from the other side of it. Keyed on the
263+ turn, which is what starts it over; nothing at all happens when it
264+ runs out. */}
265+ {onTheClock && <TurnClock key={turnId(s)} />}
243266 {/* Points down the strip at whoever won. It is inside the seat, so it
244267 turns with it and lands on their edge of the table wherever they
245268 are sitting — no working out which way is which. */}
⋯ 416 unchanged lines
addedsrc/ui/TurnClock.tsx+46 −0
1+import type { CSSProperties } from 'react';
2+import type { GameState } from '../game/engine';
3+
4+/**
5+ * How long the gold takes to run out. Nothing happens when it does — no tile is
6+ * thrown for you, no seat is skipped, no move is forced. It is a table telling
7+ * three people who are watching and one who is not that the game is on them,
8+ * and seven seconds is about as long as a turn takes when somebody is paying
9+ * attention to it.
10+ */
11+export const TURN_SECONDS = 7;
12+
13+/**
14+ * One turn, told apart from the next. The clock is a CSS animation, and the
15+ * only way to start one over is to hand React a different element — so this
16+ * goes in the `key` and a new turn gets a full seven seconds.
17+ *
18+ * A turn begins in one of two ways: somebody drew (`drawnFront`/`drawnBack`
19+ * move) or somebody claimed the tile on the table (the claimer's melds grow).
20+ * The hand number covers the deal, and the seat covers the pass around the
21+ * table — four seats drawing in turn are four different ids.
22+ */
23+export function turnId(s: GameState): string {
24+ return `${s.handNumber}:${s.turn}:${s.drawnFront}:${s.drawnBack}:${s.players[s.turn].melds.length}`;
25+}
26+
27+/**
28+ * Seven seconds of gold, draining from both ends towards the middle — the same
29+ * shape whichever edge of the table it is drawn along, since a strip that is
30+ * upside down from where you are sitting should still read as running out.
31+ *
32+ * When it is gone the line it left behind breathes instead, which is the whole
33+ * of what happens at zero: the table keeps waiting, and goes on saying so.
34+ *
35+ * Give it a `key` of `turnId(state)` wherever it is used, or it will keep
36+ * counting down the turn before last.
37+ */
38+export function TurnClock() {
39+ return (
40+ // Both the drain and the breathing after it are timed off this, so the
41+ // seconds are written down once and in one place.
42+ <div className="turn-clock" style={{ '--turn-secs': `${TURN_SECONDS}s` } as CSSProperties}>
43+ <i />
44+ </div>
45+ );
46+}