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 | |||
| 4 | 4 | <meta charset="UTF-8" /> | |
| 5 | 5 | <link rel="icon" type="image/svg+xml" href="/favicon.svg" /> | |
| 6 | 6 | <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" /> | |
| 7 | 13 | <title>台灣麻將</title> | |
| 8 | 14 | </head> | |
| 9 | 15 | <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'; | |
| 2 | 2 | import { AutoPlay } from './game/autoplay'; | |
| 3 | 3 | import { Game } from './game/engine'; | |
| 4 | 4 | import { loadPrefs, savePrefs, type Prefs } from './game/prefs'; | |
| ⋯ 3 unchanged lines | |||
| 8 | 8 | import type { SeatId } from './game/types'; | |
| 9 | 9 | import { Center } from './ui/Center'; | |
| 10 | 10 | import { useCompact } from './ui/compact'; | |
| 11 | + | import { FullscreenToggle } from './ui/FullscreenToggle'; | |
| 12 | + | import { enterFullscreen, useFullscreen } from './ui/fullscreen'; | |
| 11 | 13 | import { Help } from './ui/Help'; | |
| 12 | 14 | import { Seat } from './ui/Seat'; | |
| 13 | 15 | import { Settings } from './ui/Settings'; | |
| ⋯ 8 unchanged lines | |||
| 22 | 24 | const [prefs, setPrefs] = useState<Prefs>(loadPrefs); | |
| 23 | 25 | // Phone-sized: everything reads upright for the one person holding it. | |
| 24 | 26 | const compact = useCompact(); | |
| 27 | + | const fullscreen = useFullscreen(); | |
| 25 | 28 | // Built once, from the preferences as they stood at startup; after that the | |
| 26 | 29 | // settings screen edits the live game rather than replacing it. | |
| 27 | 30 | const [game] = useState(() => new Game(prefs.names, prefs.rules)); | |
| ⋯ 35 unchanged lines | |||
| 63 | 66 | document.documentElement.classList.toggle('compact', compact); | |
| 64 | 67 | }, [compact]); | |
| 65 | 68 | ||
| 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 | + | ||
| 66 | 95 | // Preferences are written straight through, so a mute survives a refresh. | |
| 67 | 96 | useEffect(() => { | |
| 68 | 97 | savePrefs(prefs); | |
| ⋯ 38 unchanged lines | |||
| 107 | 136 | ||
| 108 | 137 | <div className="lobby-buttons"> | |
| 109 | 138 | {saved && ( | |
| 110 | - | <button className="btn big-btn" onClick={() => game.restore(saved.state)}> | |
| 139 | + | <button className="btn big-btn" onClick={resumeGame}> | |
| 111 | 140 | 繼續對局 Resume | |
| 112 | 141 | </button> | |
| 113 | 142 | )} | |
| 114 | 143 | <button | |
| 115 | 144 | className={`btn big-btn ${saved ? 'ghost' : ''}`} | |
| 116 | - | onClick={() => game.newGame(SOLO_SEATS, prefs.names)} | |
| 145 | + | onClick={() => startGame(SOLO_SEATS)} | |
| 117 | 146 | > | |
| 118 | 147 | 單人對局 Single player | |
| 119 | 148 | <em>你坐東家,其餘三家由電腦代打 · you at 東, three computer players</em> | |
| 120 | 149 | </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)}> | |
| 122 | 151 | 四人同桌 Hotseat | |
| 123 | 152 | <em>四個人共用一個螢幕 · four people, one screen</em> | |
| 124 | 153 | </button> | |
| ⋯ 3 unchanged lines | |||
| 128 | 157 | <button className="btn ghost pill" onClick={() => setSettingsOpen(true)}> | |
| 129 | 158 | ⚙ 設定 Settings | |
| 130 | 159 | </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} />} | |
| 131 | 163 | </div> | |
| 132 | 164 | {settingsPanel} | |
| 133 | 165 | </div> | |
| ⋯ 30 unchanged lines | |||
modifiedsrc/game/prefs.ts+8 −0
| ⋯ 25 unchanged lines | |||
| 26 | 26 | volume: number; | |
| 27 | 27 | /** 報牌 — say the calls and the discarded tiles out loud. Needs `sound`. */ | |
| 28 | 28 | 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; | |
| 29 | 35 | names: string[]; | |
| 30 | 36 | rules: Rules; | |
| 31 | 37 | } | |
| ⋯ 3 unchanged lines | |||
| 35 | 41 | sound: true, | |
| 36 | 42 | volume: 0.7, | |
| 37 | 43 | voice: true, | |
| 44 | + | fullscreen: false, | |
| 38 | 45 | names: DEFAULT_NAMES, | |
| 39 | 46 | rules: DEFAULT_RULES, | |
| 40 | 47 | }; | |
| ⋯ 9 unchanged lines | |||
| 50 | 57 | sound: !stale && typeof p.sound === 'boolean' ? p.sound : DEFAULT_PREFS.sound, | |
| 51 | 58 | volume: clamp(typeof p.volume === 'number' ? p.volume : DEFAULT_PREFS.volume, 0, 1), | |
| 52 | 59 | voice: !stale && typeof p.voice === 'boolean' ? p.voice : DEFAULT_PREFS.voice, | |
| 60 | + | fullscreen: typeof p.fullscreen === 'boolean' ? p.fullscreen : DEFAULT_PREFS.fullscreen, | |
| 53 | 61 | names: | |
| 54 | 62 | Array.isArray(p.names) && p.names.length === 4 && p.names.every((n) => typeof n === 'string') | |
| 55 | 63 | ? p.names | |
| ⋯ 17 unchanged lines | |||
modifiedsrc/styles.css+19 −0
| ⋯ 1852 unchanged lines | |||
| 1853 | 1853 | .compact .settings .note { | |
| 1854 | 1854 | font-size: 11px; | |
| 1855 | 1855 | } | |
| 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 | |||
| 3 | 3 | import type { SeatId } from '../game/types'; | |
| 4 | 4 | import { labelOf } from '../game/tiles'; | |
| 5 | 5 | import { Bi } from './Bi'; | |
| 6 | + | import { FullscreenToggle } from './FullscreenToggle'; | |
| 6 | 7 | import { SoundToggle } from './SoundToggle'; | |
| 7 | 8 | import { TileView } from './TileView'; | |
| 8 | 9 | import { SEAT_ROT } from './rotation'; | |
| ⋯ 170 unchanged lines | |||
| 179 | 180 | </span> | |
| 180 | 181 | </button> | |
| 181 | 182 | )} | |
| 183 | + | {/* Only on the layout the address bar actually crowds. */} | |
| 184 | + | {compact && <FullscreenToggle prefs={prefs} onPrefs={onPrefs} compact />} | |
| 182 | 185 | <SoundToggle prefs={prefs} onPrefs={onPrefs} compact /> | |
| 183 | 186 | </div> | |
| 184 | 187 | </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 | + | } |