anvilsign in

collin/mahjong

master / src / game / prefs.ts
1import { DEFAULT_NAMES, DEFAULT_RULES, type Rules } from './types';
2
3const KEY = 'taiwanese-mahjong/prefs';
4
5/**
6 * Bump when a new switch should start from its default rather than from
7 * whatever an older build happened to write. Preferences are saved on load, so
8 * a switch that shipped off and was then turned on by default would otherwise
9 * stay off forever for anyone who had already opened the game once.
10 *
11 * Only the audio settings are reset; names and house rules are things somebody
12 * actually chose, and they carry across.
13 */
14const AUDIO_GENERATION = 2;
15
16/**
17 * How the bilingual labels read: both languages stacked as the game has always
18 * shown them, Chinese alone, or English alone. See ui/lang.ts for the store
19 * that carries it to <Bi>.
20 */
21export type Lang = 'bi' | 'zh' | 'en';
22
23export const LANGS: Lang[] = ['bi', 'zh', 'en'];
24
25/**
26 * Preferences belong to the table, not to a hand: they outlive a save, survive
27 * 開新局, and are what a fresh `Game` is built from.
28 */
29export interface Prefs {
30 /** Which set of audio defaults this was last saved against. */
31 audioGeneration: number;
32 /** Master mute. */
33 sound: boolean;
34 /** 0..1 */
35 volume: number;
36 /** 報牌 — say the calls and the discarded tiles out loud. Needs `sound`. */
37 voice: boolean;
38 /**
39 * 方位音 — put each seat's noise at that seat's edge of the table, so what
40 * 上家 does comes from the left. Needs `sound`.
41 */
42 spatial: boolean;
43 /**
44 * Whether the player asked for the whole screen. Fullscreen itself never
45 * survives a reload, so this is what re-arms it on the next tap — see
46 * ui/fullscreen.ts. Not an audio setting: it is a choice, and it carries.
47 */
48 fullscreen: boolean;
49 /**
50 * Which languages the labels show. Not an audio setting either: somebody
51 * picked the language they can read, and it carries.
52 */
53 lang: Lang;
54 names: string[];
55 rules: Rules;
56}
57
58export const DEFAULT_PREFS: Prefs = {
59 audioGeneration: AUDIO_GENERATION,
60 sound: true,
61 volume: 0.7,
62 voice: true,
63 spatial: true,
64 fullscreen: false,
65 lang: 'bi',
66 names: DEFAULT_NAMES,
67 rules: DEFAULT_RULES,
68};
69
70export function loadPrefs(): Prefs {
71 try {
72 const raw = localStorage.getItem(KEY);
73 if (!raw) return DEFAULT_PREFS;
74 const p = JSON.parse(raw) as Partial<Prefs>;
75 const stale = p.audioGeneration !== AUDIO_GENERATION;
76 return {
77 audioGeneration: AUDIO_GENERATION,
78 sound: !stale && typeof p.sound === 'boolean' ? p.sound : DEFAULT_PREFS.sound,
79 volume: clamp(typeof p.volume === 'number' ? p.volume : DEFAULT_PREFS.volume, 0, 1),
80 voice: !stale && typeof p.voice === 'boolean' ? p.voice : DEFAULT_PREFS.voice,
81 spatial: !stale && typeof p.spatial === 'boolean' ? p.spatial : DEFAULT_PREFS.spatial,
82 fullscreen: typeof p.fullscreen === 'boolean' ? p.fullscreen : DEFAULT_PREFS.fullscreen,
83 lang: LANGS.includes(p.lang as Lang) ? (p.lang as Lang) : DEFAULT_PREFS.lang,
84 names:
85 Array.isArray(p.names) && p.names.length === 4 && p.names.every((n) => typeof n === 'string')
86 ? p.names
87 : DEFAULT_NAMES,
88 rules: { ...DEFAULT_RULES, ...(p.rules ?? {}) },
89 };
90 } catch {
91 return DEFAULT_PREFS;
92 }
93}
94
95export function savePrefs(p: Prefs) {
96 try {
97 localStorage.setItem(KEY, JSON.stringify(p));
98 } catch {
99 // Storage unavailable — the settings just don't outlive the tab.
100 }
101}
102
103export const clamp = (n: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, n));