anvilsign in

collin/mahjong · 95617081

Let the player take the whole screen

Collin Richards · 2026-08-19 06:09 UTC · 956170818e64f7869cf3d8d35b5e65f65656d935 · parent bcaeb04c · browse files

modifiedindex.html+6 −0
⋯ 3 unchanged lines
44 <meta charset="UTF-8" />
55 <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
66 <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
7+ <!-- Added to the home screen, the game launches without the browser's own
8+ chrome at all — which is the address bar problem solved for good, and
9+ better than reaching for the fullscreen button every session. -->
10+ <link rel="manifest" href="/manifest.webmanifest" />
11+ <meta name="mobile-web-app-capable" content="yes" />
12+ <meta name="theme-color" content="#0a3126" />
713 <title>台灣麻將</title>
814 </head>
915 <body>
⋯ 4 unchanged lines
addedpublic/manifest.webmanifest+20 −0
1+{
2+ "name": "台灣麻將 Taiwanese Mahjong",
3+ "short_name": "台灣麻將",
4+ "description": "十六張台灣麻將,四家一screen · Taiwanese 16-tile mahjong, four players on one screen.",
5+ "start_url": "/",
6+ "scope": "/",
7+ "display": "fullscreen",
8+ "display_override": ["fullscreen", "standalone"],
9+ "orientation": "any",
10+ "background_color": "#0a3126",
11+ "theme_color": "#0a3126",
12+ "icons": [
13+ {
14+ "src": "/favicon.svg",
15+ "sizes": "any",
16+ "type": "image/svg+xml",
17+ "purpose": "any"
18+ }
19+ ]
20+}
modifiedsrc/App.tsx+36 −4
1-import { useEffect, useState, useSyncExternalStore } from 'react';
1+import { useEffect, useRef, useState, useSyncExternalStore } from 'react';
22 import { AutoPlay } from './game/autoplay';
33 import { Game } from './game/engine';
44 import { loadPrefs, savePrefs, type Prefs } from './game/prefs';
⋯ 3 unchanged lines
88 import type { SeatId } from './game/types';
99 import { Center } from './ui/Center';
1010 import { useCompact } from './ui/compact';
11+import { FullscreenToggle } from './ui/FullscreenToggle';
12+import { enterFullscreen, useFullscreen } from './ui/fullscreen';
1113 import { Help } from './ui/Help';
1214 import { Seat } from './ui/Seat';
1315 import { Settings } from './ui/Settings';
⋯ 8 unchanged lines
2224 const [prefs, setPrefs] = useState<Prefs>(loadPrefs);
2325 // Phone-sized: everything reads upright for the one person holding it.
2426 const compact = useCompact();
27+ const fullscreen = useFullscreen();
2528 // Built once, from the preferences as they stood at startup; after that the
2629 // settings screen edits the live game rather than replacing it.
2730 const [game] = useState(() => new Game(prefs.names, prefs.rules));
⋯ 35 unchanged lines
6366 document.documentElement.classList.toggle('compact', compact);
6467 }, [compact]);
6568
69+ // Leaving fullscreen by the phone's own gesture is a decision too, and should
70+ // not be undone the next time a hand starts. A reload never reaches here:
71+ // there is no true-to-false to watch on a fresh mount, which is exactly what
72+ // lets the preference survive one.
73+ const wasFullscreen = useRef(false);
74+ useEffect(() => {
75+ // Same object back when it is already off, so the toggle's own click does
76+ // not get written through a second time.
77+ if (wasFullscreen.current && !fullscreen) {
78+ setPrefs((p) => (p.fullscreen ? { ...p, fullscreen: false } : p));
79+ }
80+ wasFullscreen.current = fullscreen;
81+ }, [fullscreen]);
82+
83+ // The browser hands the screen back on every reload, and this game is built
84+ // to be put down and picked up again. Starting a hand is a tap, and a tap is
85+ // the only place the request is allowed — so that is where it goes back on.
86+ const startGame = (bots: boolean[]) => {
87+ if (compact && prefs.fullscreen && !fullscreen) enterFullscreen();
88+ game.newGame(bots, prefs.names);
89+ };
90+ const resumeGame = () => {
91+ if (compact && prefs.fullscreen && !fullscreen) enterFullscreen();
92+ game.restore(saved!.state);
93+ };
94+
6695 // Preferences are written straight through, so a mute survives a refresh.
6796 useEffect(() => {
6897 savePrefs(prefs);
⋯ 38 unchanged lines
107136
108137 <div className="lobby-buttons">
109138 {saved && (
110- <button className="btn big-btn" onClick={() => game.restore(saved.state)}>
139+ <button className="btn big-btn" onClick={resumeGame}>
111140 繼續對局 Resume
112141 </button>
113142 )}
114143 <button
115144 className={`btn big-btn ${saved ? 'ghost' : ''}`}
116- onClick={() => game.newGame(SOLO_SEATS, prefs.names)}
145+ onClick={() => startGame(SOLO_SEATS)}
117146 >
118147 單人對局 Single player
119148 <em>你坐東家,其餘三家由電腦代打 · you at 東, three computer players</em>
120149 </button>
121- <button className="btn big-btn ghost" onClick={() => game.newGame(HOTSEAT_SEATS, prefs.names)}>
150+ <button className="btn big-btn ghost" onClick={() => startGame(HOTSEAT_SEATS)}>
122151 四人同桌 Hotseat
123152 <em>四個人共用一個螢幕 · four people, one screen</em>
124153 </button>
⋯ 3 unchanged lines
128157 <button className="btn ghost pill" onClick={() => setSettingsOpen(true)}>
129158 ⚙ 設定 Settings
130159 </button>
160+ {/* Only where the address bar is actually in the way. A desktop has
161+ F11, and the lobby there does not need another button. */}
162+ {compact && <FullscreenToggle prefs={prefs} onPrefs={setPrefs} />}
131163 </div>
132164 {settingsPanel}
133165 </div>
⋯ 30 unchanged lines
modifiedsrc/game/prefs.ts+8 −0
⋯ 25 unchanged lines
2626 volume: number;
2727 /** 報牌 — say the calls and the discarded tiles out loud. Needs `sound`. */
2828 voice: boolean;
29+ /**
30+ * Whether the player asked for the whole screen. Fullscreen itself never
31+ * survives a reload, so this is what re-arms it on the next tap — see
32+ * ui/fullscreen.ts. Not an audio setting: it is a choice, and it carries.
33+ */
34+ fullscreen: boolean;
2935 names: string[];
3036 rules: Rules;
3137 }
⋯ 3 unchanged lines
3541 sound: true,
3642 volume: 0.7,
3743 voice: true,
44+ fullscreen: false,
3845 names: DEFAULT_NAMES,
3946 rules: DEFAULT_RULES,
4047 };
⋯ 9 unchanged lines
5057 sound: !stale && typeof p.sound === 'boolean' ? p.sound : DEFAULT_PREFS.sound,
5158 volume: clamp(typeof p.volume === 'number' ? p.volume : DEFAULT_PREFS.volume, 0, 1),
5259 voice: !stale && typeof p.voice === 'boolean' ? p.voice : DEFAULT_PREFS.voice,
60+ fullscreen: typeof p.fullscreen === 'boolean' ? p.fullscreen : DEFAULT_PREFS.fullscreen,
5361 names:
5462 Array.isArray(p.names) && p.names.length === 4 && p.names.every((n) => typeof n === 'string')
5563 ? p.names
⋯ 17 unchanged lines
modifiedsrc/styles.css+19 −0
⋯ 1852 unchanged lines
18531853 .compact .settings .note {
18541854 font-size: 11px;
18551855 }
1856+
1857+/* ---------- fullscreen ---------- */
1858+/* Sized to match the sound toggle it stands beside, in both its forms. */
1859+.fs-toggle {
1860+ min-height: 34px;
1861+ padding: 2px 14px;
1862+}
1863+.fs-toggle.compact {
1864+ min-height: 28px;
1865+ padding: 3px 9px;
1866+}
1867+.fs-toggle.on {
1868+ border-color: rgba(217, 180, 92, 0.7);
1869+ color: var(--gold);
1870+}
1871+.fs-toggle .fs-icon {
1872+ display: block;
1873+ flex: none;
1874+}
modifiedsrc/ui/Center.tsx+3 −0
⋯ 2 unchanged lines
33 import type { SeatId } from '../game/types';
44 import { labelOf } from '../game/tiles';
55 import { Bi } from './Bi';
6+import { FullscreenToggle } from './FullscreenToggle';
67 import { SoundToggle } from './SoundToggle';
78 import { TileView } from './TileView';
89 import { SEAT_ROT } from './rotation';
⋯ 170 unchanged lines
179180 </span>
180181 </button>
181182 )}
183+ {/* Only on the layout the address bar actually crowds. */}
184+ {compact && <FullscreenToggle prefs={prefs} onPrefs={onPrefs} compact />}
182185 <SoundToggle prefs={prefs} onPrefs={onPrefs} compact />
183186 </div>
184187 </div>
⋯ 2 unchanged lines
addedsrc/ui/FullscreenToggle.tsx+59 −0
1+import type { Prefs } from '../game/prefs';
2+import { enterFullscreen, exitFullscreen, fullscreenSupported, useFullscreen } from './fullscreen';
3+
4+interface Props {
5+ prefs: Prefs;
6+ onPrefs: (p: Prefs) => void;
7+ /** Icon only, for the cramped row of controls in the middle of the table. */
8+ compact?: boolean;
9+}
10+
11+/* Corner brackets rather than a glyph: this has to read on whatever font a
12+ phone happens to have, and the symbols for it are thin on the ground. */
13+const Corners = ({ out }: { out: boolean }) => (
14+ <svg
15+ className="fs-icon"
16+ viewBox="0 0 20 20"
17+ width="15"
18+ height="15"
19+ aria-hidden
20+ fill="none"
21+ stroke="currentColor"
22+ strokeWidth="2"
23+ strokeLinecap="round"
24+ strokeLinejoin="round"
25+ >
26+ {out ? (
27+ <path d="M2 7V2h5M13 2h5v5M18 13v5h-5M7 18H2v-5" />
28+ ) : (
29+ <path d="M7 2v5H2M18 7h-5V2M13 18v-5h5M2 13h5v5" />
30+ )}
31+ </svg>
32+);
33+
34+/**
35+ * Takes the screen and gives it back — and remembers which the player wanted.
36+ * The browser drops fullscreen on every reload, and this game is built to be
37+ * put down and resumed, so without that memory you would be reaching for this
38+ * button every single time.
39+ */
40+export function FullscreenToggle({ prefs, onPrefs, compact }: Props) {
41+ const active = useFullscreen();
42+ if (!fullscreenSupported()) return null;
43+
44+ return (
45+ <button
46+ className={`btn ghost pill fs-toggle ${compact ? 'compact' : ''} ${active ? 'on' : ''}`}
47+ aria-pressed={active}
48+ onClick={() => {
49+ // Made here, inside the click: the same call from an effect is refused.
50+ if (active) exitFullscreen();
51+ else enterFullscreen();
52+ onPrefs({ ...prefs, fullscreen: !active });
53+ }}
54+ >
55+ <Corners out={!active} />
56+ {!compact && <span>{active ? '離開全螢幕 Exit full screen' : '全螢幕 Full screen'}</span>}
57+ </button>
58+ );
59+}
addedsrc/ui/fullscreen.ts+43 −0
1+import { useSyncExternalStore } from 'react';
2+
3+/**
4+ * Firefox on Android keeps its address bar above the page, and on a phone held
5+ * sideways that bar is a sixth of the height the whole table has to work with.
6+ * The Fullscreen API is the only thing that takes it back, and browsers grant
7+ * it only from inside a click — so this is always a button somebody presses,
8+ * never something the table asks for on its own.
9+ *
10+ * Installing the page to the home screen does the same thing for good; see
11+ * public/manifest.webmanifest.
12+ */
13+
14+const subscribe = (cb: () => void) => {
15+ document.addEventListener('fullscreenchange', cb);
16+ return () => document.removeEventListener('fullscreenchange', cb);
17+};
18+
19+/** Absent on iPhone, where only a <video> may go fullscreen. */
20+export const fullscreenSupported = () =>
21+ typeof document !== 'undefined' &&
22+ document.fullscreenEnabled &&
23+ typeof document.documentElement.requestFullscreen === 'function';
24+
25+export function enterFullscreen() {
26+ // `navigationUI` is a hint at the browser's own chrome; the ones that don't
27+ // take it ignore it. Refusals — no gesture behind the call, or a frame that
28+ // disallows it — are nothing to report: the page is simply not fullscreen.
29+ document.documentElement.requestFullscreen({ navigationUI: 'hide' }).catch(() => {});
30+}
31+
32+export function exitFullscreen() {
33+ if (document.fullscreenElement) document.exitFullscreen().catch(() => {});
34+}
35+
36+/** Whether the page holds the screen right now. */
37+export function useFullscreen(): boolean {
38+ return useSyncExternalStore(
39+ subscribe,
40+ () => document.fullscreenElement !== null,
41+ () => false,
42+ );
43+}