anvilsign in

collin/mahjong

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 * Whether the player asked for the whole screen. Fullscreen itself never
40 * survives a reload, so this is what re-arms it on the next tap — see
41 * ui/fullscreen.ts. Not an audio setting: it is a choice, and it carries.
42 */
43 fullscreen: boolean;
44 /**
45 * Which languages the labels show. Not an audio setting either: somebody
46 * picked the language they can read, and it carries.
47 */
48 lang: Lang;
49 names: string[];
50 rules: Rules;
51}
52
53export const DEFAULT_PREFS: Prefs = {
54 audioGeneration: AUDIO_GENERATION,
55 sound: true,
56 volume: 0.7,
57 voice: true,
58 fullscreen: false,
59 lang: 'bi',
60 names: DEFAULT_NAMES,
61 rules: DEFAULT_RULES,
62};
63
64export function loadPrefs(): Prefs {
65 try {
66 const raw = localStorage.getItem(KEY);
67 if (!raw) return DEFAULT_PREFS;
68 const p = JSON.parse(raw) as Partial<Prefs>;
69 const stale = p.audioGeneration !== AUDIO_GENERATION;
70 return {
71 audioGeneration: AUDIO_GENERATION,
72 sound: !stale && typeof p.sound === 'boolean' ? p.sound : DEFAULT_PREFS.sound,
73 volume: clamp(typeof p.volume === 'number' ? p.volume : DEFAULT_PREFS.volume, 0, 1),
74 voice: !stale && typeof p.voice === 'boolean' ? p.voice : DEFAULT_PREFS.voice,
75 fullscreen: typeof p.fullscreen === 'boolean' ? p.fullscreen : DEFAULT_PREFS.fullscreen,
76 lang: LANGS.includes(p.lang as Lang) ? (p.lang as Lang) : DEFAULT_PREFS.lang,
77 names:
78 Array.isArray(p.names) && p.names.length === 4 && p.names.every((n) => typeof n === 'string')
79 ? p.names
80 : DEFAULT_NAMES,
81 rules: { ...DEFAULT_RULES, ...(p.rules ?? {}) },
82 };
83 } catch {
84 return DEFAULT_PREFS;
85 }
86}
87
88export function savePrefs(p: Prefs) {
89 try {
90 localStorage.setItem(KEY, JSON.stringify(p));
91 } catch {
92 // Storage unavailable — the settings just don't outlive the tab.
93 }
94}
95
96export const clamp = (n: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, n));