/** * Il timer della partita. Il conto alla rovescia si ricava dall'ora di fine: * se l'app va in background il JavaScript si ferma, ma al ritorno il tempo è * giusto lo stesso. Nel frattempo la fine la segnala una notifica locale. * * Fasi: 'corre'; 'accusa' (pausa per votare una spia); 'scaduto'; 'finita'. */ import { useEffect, useRef, useState } from 'react'; import { AppState, Modal, StyleSheet, Text, View } from 'react-native'; import { Image } from 'expo-image'; import { useKeepAwake } from 'expo-keep-awake'; import { SafeAreaProvider, SafeAreaView } from 'react-native-safe-area-context'; import type { NativeStackScreenProps } from '@react-navigation/native-stack'; import { esitoTentativo, mmss, secondiRimasti } from '../../gioco.ts'; import { FOTO_LUOGHI, FOTO_SPIA, MASCHERINA } from '../../immagini.ts'; import { annullaFineTempo, programmaFineTempo } from '../../notifica.ts'; import { suona } from '../../suoni.ts'; import { COLORI, Dialogo, FONT, Pulsante, Riquadro, testi, useBloccaIndietro } from '../componenti.tsx'; import { GrigliaLuoghi } from '../GrigliaLuoghi.tsx'; import type { Schermate } from '../navigazione.ts'; import { useStato } from '../stato.tsx'; type Fase = 'corre' | 'accusa' | 'scaduto' | 'finita'; export function Timer({ navigation }: NativeStackScreenProps) { const { t, imp, partita } = useStato(); useKeepAwake(); useBloccaIndietro(); const totale = (partita?.minuti ?? 0) * 60; const [fase, setFase] = useState('corre'); const [rimasti, setRimasti] = useState(totale); const [fine, setFine] = useState(() => (partita ? Date.now() + totale * 1000 : null)); const [foto, setFoto] = useState<'spia' | 'luogo'>('spia'); const [scelta, setScelta] = useState(false); const [dialogo, setDialogo] = useState void }[]; }>(null); const ultimo = useRef(totale); // Ref, non state: due tap veloci sulla griglia devono vedersi l'uno la decrementazione // dell'altro, cosa che con lo state (aggiornato in modo asincrono) non è garantita. const tentativi = useRef(partita?.spie.length ?? 1); // Se il tempo scade mentre l'app è in background, il paparapa lo suona già // la notifica: al rientro non va suonato di nuovo quando l'intervallo arriva a 0. const suonatoFuori = useRef(false); useEffect(() => { if (fine === null) return; const id = setInterval(() => { const r = secondiRimasti(fine, Date.now()); if (r !== ultimo.current) { ultimo.current = r; setRimasti(r); if (r > 0) suona('tick'); } if (r === 0) { clearInterval(id); setFine(null); setFase('scaduto'); if (!suonatoFuori.current) suona('paparapa'); } }, 250); return () => clearInterval(id); }, [fine]); useEffect(() => { const sub = AppState.addEventListener('change', (stato) => { if (stato === 'background') { if (fine !== null) { programmaFineTempo(secondiRimasti(fine, Date.now()), t('timer.scaduto'), t('notifica.testo')).catch(() => {}); } else { annullaFineTempo().catch(() => {}); } } if (stato === 'active') { suonatoFuori.current = fine !== null && secondiRimasti(fine, Date.now()) === 0; annullaFineTempo().catch(() => {}); } }); return () => sub.remove(); }, [fine, t]); useEffect(() => () => { annullaFineTempo().catch(() => {}); }, []); if (!partita) return null; /** Ferma il tempo e restituisce i secondi rimasti, per poterlo far ripartire. */ const pausa = (): number => { const r = fine === null ? rimasti : secondiRimasti(fine, Date.now()); ultimo.current = r; setRimasti(r); setFine(null); return r; }; const riprendi = (r: number) => { setFase('corre'); setFine(Date.now() + r * 1000); }; const finisci = () => { setFine(null); setFase('finita'); }; const nuova = () => { finisci(); suona('suspense'); navigation.goBack(); }; // Primo pulsante: «Accusa una spia», poi «Spia smascherata». const accusa = () => { if (fase === 'corre') { pausa(); setFase('accusa'); } else { suona('vittoria'); finisci(); } }; // Secondo pulsante: «Indovina il luogo», poi «Spia scappata». const indovina = () => { if (fase !== 'corre') { suona('failure'); finisci(); return; } const r = pausa(); setDialogo({ titolo: t('dialogo.attenzione'), messaggio: t('dialogo.indovina'), pulsanti: [ { titolo: t('dialogo.annulla'), onPress: () => { setDialogo(null); riprendi(r); } }, { titolo: t('timer.indovina'), onPress: () => { setDialogo(null); finisci(); setScelta(true); } }, ], }); }; // Terzo pulsante: «Concludi», «Riprendi» durante l'accusa, «Nuova partita» a fine partita. const terzo = () => { if (fase === 'corre') { setDialogo({ titolo: t('dialogo.attenzione'), messaggio: t('dialogo.concludi'), pulsanti: [ { titolo: t('dialogo.annulla'), onPress: () => setDialogo(null) }, { titolo: t('timer.concludi'), onPress: () => { setDialogo(null); nuova(); } }, ], }); } else if (fase === 'accusa') { riprendi(rimasti); } else { nuova(); } }; const scegli = (id: number) => { const esito = esitoTentativo(id, partita.luogo, tentativi.current); if (esito === 'riprova') { tentativi.current -= 1; setDialogo({ titolo: t('dialogo.sbagliato'), messaggio: t('dialogo.altroTentativo'), pulsanti: [{ titolo: t('dialogo.ok'), onPress: () => setDialogo(null) }], }); return; } suona(esito === 'vinto' ? 'vittoria' : 'failure'); setFoto('luogo'); setScelta(false); }; const inPausa = fase === 'accusa' || fase === 'scaduto'; return ( {rimasti === 0 ? t('timer.scaduto') : mmss(rimasti)} {fase !== 'finita' && ( )} {fase !== 'scaduto' && ( )} setDialogo({ titolo: t('dialogo.attenzione'), messaggio: t('dialogo.seleziona'), pulsanti: [{ titolo: t('dialogo.ok'), onPress: () => setDialogo(null) }], })} > {t('dialogo.seleziona')} ); } const st = StyleSheet.create({ pieno: { flex: 1, backgroundColor: '#000' }, colonna: { flex: 1, width: '100%', maxWidth: 520, alignSelf: 'center', padding: 16, gap: 12 }, orologio: { ...testi.grande, fontSize: 56 }, centro: { flex: 1, justifyContent: 'center' }, cornice: { width: '100%', aspectRatio: 4 / 3 }, coppia: { flexDirection: 'row', gap: 12 }, meta: { flex: 1 }, avvisoScelta: { fontFamily: FONT, fontSize: 22, color: COLORI.arancio, textAlign: 'center', padding: 12 }, });