← back to CelebritySignatures
apps/mobile/screens/GameScreen.tsx
1091 lines
/**
* GameScreen — "The Signature Games" (full 6-game suite, ported from the desktop
* web build at public/game.html).
*
* Games:
* whose Whose Hand? A mystery signature, four names.
* art Who Signed This? A real museum artwork — which artist's hand made it?
* match Match the Hand One signature, four artworks. Pick the one they signed.
* early Early or Late? Two signatures, same person, years apart. Which came first?
* century Date the Hand Read the ink — which century did this hand belong to?
* lightning ⚡ Lightning Round A painting flashes up. Name the ARTIST, then the TITLE
* — 5 seconds each. Both right = bonus.
*
* Flow: setup (pick game + difficulty + category) → prep (build rounds, fetch
* artworks for art/match/lightning) → play (10 rounds, tap to answer, Next to
* advance) → results (score, verdict, misses, best, leaderboard submit + share).
*
* Backend: all data comes from the existing celebsignatures.com JSON APIs via
* `api` (signatures, evolution, leaderboard). Public-domain artworks are fetched
* client-side from the Art Institute of Chicago (keyless, no CORS issue on native).
*/
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
ActivityIndicator,
Animated,
Image,
Linking,
Pressable,
ScrollView,
Share,
StyleSheet,
Text,
TextInput,
View,
} from 'react-native';
import * as Haptics from 'expo-haptics';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { api, Signature, LeaderRow, Evolution, qidOf, sigImg } from '../api';
import { GLASS, GlassPanel, GlassButton, GlassPill } from '../ui/glass';
// ---------------------------------------------------------------------------
// Static game / option definitions (mirror public/game.html)
// ---------------------------------------------------------------------------
type GameKey = 'whose' | 'art' | 'match' | 'early' | 'century' | 'lightning';
const GAMES: { key: GameKey; title: string; desc: string }[] = [
{ key: 'whose', title: 'Whose Hand?', desc: 'A mystery signature, four names. The classic.' },
{ key: 'art', title: 'Who Signed This?', desc: "A real museum artwork — which artist's hand made it?" },
{ key: 'match', title: 'Match the Hand', desc: 'One signature, four artworks. Pick the piece they signed.' },
{ key: 'early', title: 'Early or Late?', desc: 'Two signatures, same person, years apart. Which came first?' },
{ key: 'century', title: 'Date the Hand', desc: 'Read the ink — which century did this hand belong to?' },
{ key: 'lightning', title: '⚡ Lightning Round', desc: 'A painting flashes up. Name the ARTIST, then the TITLE — 5s each. Both right = bonus.' },
];
const DIFFS: { key: string; label: string; note: string }[] = [
{ key: 'icons', label: 'Icons', note: 'top 100' },
{ key: 'scholar', label: 'Scholar', note: 'top 500' },
{ key: 'curator', label: 'Curator', note: 'top 2,000' },
{ key: 'deep', label: 'Deep Cuts', note: 'everyone' },
];
const DIFF_N: Record<string, number> = { icons: 100, scholar: 500, curator: 2000, deep: Infinity };
const CATS: { key: string; label: string }[] = [
{ key: 'All', label: 'Everything' },
{ key: 'Artists', label: 'Artists' },
{ key: 'Authors', label: 'Authors' },
{ key: 'Politics', label: 'Politics' },
{ key: 'Declaration of Independence', label: 'Declaration' },
];
const GAME_NAMES: Record<GameKey, string> = {
whose: 'Whose Hand?', art: 'Who Signed This?', match: 'Match the Hand',
early: 'Early or Late?', century: 'Date the Hand', lightning: '⚡ Lightning Round',
};
const PROMPTS: Record<GameKey, string> = {
whose: 'Whose hand wrote this?',
art: 'Who signed this piece of art?',
match: 'Which of these did they sign?',
early: 'Same hand, years apart — which is the EARLIER signature?',
century: 'Which century did this hand belong to?',
lightning: '⚡ Quick!',
};
const TOTAL = 10;
const LIGHTNING_MS = 5000;
// Personal-best cache. Kept in a synchronous module-level map for instant reads
// during render, and mirrored to AsyncStorage so bests survive an app restart.
const BEST: Record<string, number> = {};
const BEST_STORE_KEY = 'signatureGames.best.v1';
/** Hydrate the in-memory BEST cache from disk (merging, never lowering a value). */
async function loadBest(): Promise<void> {
try {
const raw = await AsyncStorage.getItem(BEST_STORE_KEY);
if (!raw) return;
const saved = JSON.parse(raw) as Record<string, number>;
for (const [k, v] of Object.entries(saved)) {
if (typeof v === 'number') BEST[k] = Math.max(v, BEST[k] || 0);
}
} catch {
/* storage unavailable (e.g. module not linked) — stay in-memory only */
}
}
/** Persist the current BEST map. Non-fatal on failure. */
async function saveBest(): Promise<void> {
try {
await AsyncStorage.setItem(BEST_STORE_KEY, JSON.stringify(BEST));
} catch {
/* non-fatal */
}
}
// ---------------------------------------------------------------------------
// Helpers (ported from game.html)
// ---------------------------------------------------------------------------
function shuffle<T>(arr: T[]): T[] {
const a = [...arr];
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
return a;
}
const notability = (r: Signature): number => {
const m = (r.reason_for_ranking || '').match(/(\d+) language/);
return m ? +m[1] : 0;
};
const centuryOf = (r: Signature): number | null => {
const y = parseInt(String(r.death_date).slice(0, 4), 10);
return isNaN(y) ? null : Math.ceil(y / 100);
};
const centuryLabel = (c: number): string => {
const suf = c % 10 === 1 && c !== 11 ? 'st'
: c % 10 === 2 && c !== 12 ? 'nd'
: c % 10 === 3 && c !== 13 ? 'rd' : 'th';
return `${c}${suf} century`;
};
type Artwork = { img: string; title: string; date: string; url: string };
const ART_CACHE: Record<string, Artwork | null> = {};
/** One public-domain artwork per artist via the Art Institute of Chicago. */
async function artworkFor(r: Signature): Promise<Artwork | null> {
const qid = qidOf(r) || r.full_name;
if (qid in ART_CACHE) return ART_CACHE[qid];
let out: Artwork | null = null;
try {
const last = r.full_name.toLowerCase().split(' ').pop() || '';
const res = await fetch(
`https://api.artic.edu/api/v1/artworks/search?q=${encodeURIComponent(r.full_name)}` +
`&query[term][is_public_domain]=true&fields=id,title,image_id,artist_title,date_display&limit=8`,
);
const j = await res.json();
const hits = (j.data || []).filter(
(a: any) => a.image_id && (a.artist_title || '').toLowerCase().includes(last),
);
if (hits.length) {
const a = hits[Math.floor(Math.random() * Math.min(hits.length, 4))];
out = {
img: `https://www.artic.edu/iiif/2/${a.image_id}/full/600,/0/default.jpg`,
title: a.title || 'Untitled',
date: a.date_display || '',
url: `https://www.artic.edu/artworks/${a.id}`,
};
}
} catch {
/* leave out=null */
}
ART_CACHE[qid] = out;
return out;
}
/** Near-category, notability-ranked wrong answers. */
function distractors(answer: Signature, p: Signature[], n = 3): Signature[] {
const near = p.filter((r) => r !== answer && r.category === answer.category);
const ranked = [...near].sort(
(a, b) => Math.abs(notability(a) - notability(answer)) - Math.abs(notability(b) - notability(answer)),
);
const ds = shuffle(ranked.slice(0, 24)).slice(0, n);
while (ds.length < n) {
const x = shuffle(p).find((r) => r !== answer && !ds.includes(r));
if (!x) break;
ds.push(x);
}
return ds;
}
// ---------------------------------------------------------------------------
// Round model (discriminated union)
// ---------------------------------------------------------------------------
type WhoseRound = { kind: 'whose'; answer: Signature; options: Signature[] };
type ArtRound = { kind: 'art'; answer: Signature; artwork: Artwork; options: Signature[] };
type MatchRound = { kind: 'match'; answer: Signature; artwork: Artwork; options: { owner: Signature; art: Artwork }[] };
type EarlyRound = { kind: 'early'; person: string; qid: string; leftUrl: string; rightUrl: string; earlier: 'left' | 'right'; years: [number, number] };
type CenturyRound = { kind: 'century'; answer: Signature; century: number; options: number[] };
type LightningRound = { kind: 'lightning'; q: 'artist' | 'title'; pid: string; painting: { artist: Signature; title: string; img: string; url: string }; correct: string; options: string[] };
type Round = WhoseRound | ArtRound | MatchRound | EarlyRound | CenturyRound | LightningRound;
function poolFor(gameKey: GameKey, diff: string, cat: string, ALL: Signature[]): Signature[] {
const c = gameKey === 'whose' || gameKey === 'century' ? cat : 'Artists';
const p = c === 'All' ? ALL : ALL.filter((r) => r.category === c);
return [...p].sort((a, b) => notability(b) - notability(a)).slice(0, DIFF_N[diff]);
}
async function buildRounds(
gameKey: GameKey,
diff: string,
cat: string,
ALL: Signature[],
EVO: Evolution,
setPrep: (s: string) => void,
): Promise<Round[]> {
const p = poolFor(gameKey, diff, cat, ALL);
const rounds: Round[] = [];
if (gameKey === 'whose') {
if (p.length < 8) throw new Error('Not enough signatures in this tier.');
for (const answer of shuffle(p).slice(0, TOTAL)) {
rounds.push({ kind: 'whose', answer, options: shuffle([answer, ...distractors(answer, p)]) });
}
return rounds;
}
if (gameKey === 'century') {
const dated = p.filter((r) => centuryOf(r));
if (dated.length < TOTAL) throw new Error('Not enough dated signatures in this tier.');
for (const answer of shuffle(dated).slice(0, TOTAL)) {
const c = centuryOf(answer)!;
const opts = new Set<number>([c]);
let step = 1;
let guard = 0;
while (opts.size < 4 && guard++ < 200) {
const cand = c + (Math.random() < 0.5 ? -step : step);
if (cand >= 6 && cand <= 21) opts.add(cand);
step = Math.min(step + 1, 6);
}
// boundary fallback: fill any shortfall deterministically from the valid range
for (let d = 1; opts.size < 4 && d <= 15; d++) {
if (c - d >= 6) opts.add(c - d);
if (opts.size < 4 && c + d <= 21) opts.add(c + d);
}
rounds.push({ kind: 'century', answer, century: c, options: shuffle([...opts]) });
}
return rounds;
}
if (gameKey === 'early') {
const allPeople = Object.entries(EVO)
.map(([qid, e]) => ({ qid, e, dated: (e.sigs || []).filter((s) => s.year) }))
.filter((x) => new Set(x.dated.map((s) => s.year)).size >= 2);
// Honor the Difficulty tier: keep only people inside the notability-ranked
// top-N slice of the catalog, so Icons/Scholar/Curator/Deep Cuts actually
// change the pool (previously 'early' sampled EVO directly and ignored diff).
// Category is intentionally NOT applied here — the Category selector is hidden
// for 'early' (catRelevant), so this stays catalog-wide. Fall back to the full
// set if the tier is too thin to build a round, so the game never regresses
// into an unstartable "still loading" state.
const tierQids = new Set(
[...ALL]
.sort((a, b) => notability(b) - notability(a))
.slice(0, DIFF_N[diff])
.map((r) => qidOf(r))
.filter((q): q is string => !!q),
);
const tiered = allPeople.filter((x) => tierQids.has(x.qid));
const people = tiered.length >= 5 ? tiered : allPeople;
if (people.length < 5) throw new Error('Evolution data still loading — try again in a moment.');
for (let i = 0; i < TOTAL; i++) {
const P = people[Math.floor(Math.random() * people.length)];
const years = shuffle([...new Set(P.dated.map((s) => s.year as number))]);
const [y1, y2] = [years[0], years[1]].sort((a, b) => a - b);
const sigA = P.dated.find((s) => s.year === y1)!;
const sigB = P.dated.find((s) => s.year === y2)!;
const flip = Math.random() < 0.5;
rounds.push({
kind: 'early',
person: P.e.name,
qid: P.qid,
leftUrl: flip ? sigB.url : sigA.url,
rightUrl: flip ? sigA.url : sigB.url,
earlier: flip ? 'right' : 'left',
years: [y1, y2],
});
}
return rounds;
}
if (gameKey === 'lightning') {
const cands = shuffle(p);
let idx = 0;
const paintings: { artist: Signature; title: string; img: string; url: string }[] = [];
while (paintings.length < 8 && idx < cands.length) {
const batch = cands.slice(idx, idx + 6);
idx += 6;
setPrep(`Loading the gallery… ${paintings.length}/8 paintings ready`);
const arts = await Promise.all(batch.map(artworkFor));
for (let i = 0; i < batch.length && paintings.length < 8; i++) {
if (arts[i]) paintings.push({ artist: batch[i], title: arts[i]!.title, img: arts[i]!.img, url: arts[i]!.url });
}
}
if (paintings.length < 5) throw new Error('Not enough museum paintings at this tier — try Icons or Scholar.');
const nameOf = (x: typeof paintings[number]) => x.artist.full_name;
const titleOf = (x: typeof paintings[number]) => x.title;
for (const pt of paintings) {
const otherA = shuffle(paintings.filter((x) => x !== pt && nameOf(x) !== nameOf(pt))).slice(0, 3);
const otherT = shuffle(paintings.filter((x) => x !== pt && titleOf(x) !== titleOf(pt))).slice(0, 3);
if (otherA.length < 3 || otherT.length < 3) continue;
rounds.push({ kind: 'lightning', q: 'artist', pid: pt.url, painting: pt, correct: nameOf(pt), options: shuffle([nameOf(pt), ...otherA.map(nameOf)]) });
rounds.push({ kind: 'lightning', q: 'title', pid: pt.url, painting: pt, correct: titleOf(pt), options: shuffle([titleOf(pt), ...otherT.map(titleOf)]) });
}
if (rounds.length < 8) throw new Error('Not enough distinct paintings — try Icons or Scholar.');
return rounds;
}
// art + match — both need real artworks
const cands = shuffle(p);
let idx = 0;
const staged: { answer: Signature; artwork: Artwork }[] = [];
while (staged.length < TOTAL && idx < cands.length) {
const batch = cands.slice(idx, idx + 6);
idx += 6;
setPrep(`Hanging the gallery… ${staged.length}/${TOTAL} rooms ready`);
const arts = await Promise.all(batch.map(artworkFor));
for (let i = 0; i < batch.length && staged.length < TOTAL; i++) {
if (arts[i]) staged.push({ answer: batch[i], artwork: arts[i]! });
}
}
if (gameKey === 'art') {
for (const s of staged) {
rounds.push({ kind: 'art', answer: s.answer, artwork: s.artwork, options: shuffle([s.answer, ...distractors(s.answer, p)]) });
}
if (rounds.length < 6) throw new Error('Not enough museum artworks at this tier — try Icons or Scholar.');
return rounds;
}
// match — each round needs 3 distractor ARTWORKS too
for (const s of staged) {
setPrep(`Matching hands… ${rounds.length}/${staged.length} ready`);
const ds = distractors(s.answer, p, 8);
const arts: { owner: Signature; art: Artwork }[] = [];
for (const d of ds) {
if (arts.length >= 3) break;
const a = await artworkFor(d);
if (a) arts.push({ owner: d, art: a });
}
if (arts.length < 3) continue;
rounds.push({
kind: 'match',
answer: s.answer,
artwork: s.artwork,
options: shuffle([{ owner: s.answer, art: s.artwork }, ...arts.slice(0, 3)]),
});
}
if (rounds.length < 6) throw new Error('Not enough museum artworks at this tier — try Icons or Scholar.');
return rounds.slice(0, TOTAL);
}
// ---------------------------------------------------------------------------
// Small UI atoms
// ---------------------------------------------------------------------------
function OptionChip({
label, sub, active, onPress,
}: { label: string; sub?: string; active: boolean; onPress: () => void }) {
return (
<Pressable onPress={onPress} style={[styles.chip, active && styles.chipActive]}>
<Text style={[styles.chipText, active && styles.chipTextActive]}>{label}</Text>
{sub ? <Text style={[styles.chipSub, active && styles.chipTextActive]}> {sub}</Text> : null}
</Pressable>
);
}
function LoadingView({ label }: { label: string }) {
return (
<View style={styles.centeredFill}>
<ActivityIndicator size="large" color={GLASS.accent} />
<Text style={styles.dimText}>{label}</Text>
</View>
);
}
function LeaderboardPanel({ rows, loading, tag }: { rows: LeaderRow[]; loading: boolean; tag?: string }) {
return (
<GlassPanel style={styles.leaderPanel}>
<Text style={styles.sectionTitle}>🏆 Leaderboard{tag ? ` · ${tag}` : ''}</Text>
{loading ? (
<ActivityIndicator color={GLASS.accent} style={{ marginTop: 8 }} />
) : rows.length === 0 ? (
<Text style={styles.dimText}>Be the first — post your score!</Text>
) : (
rows.slice(0, 5).map((row, i) => (
<View key={row.id ?? `${row.name}-${i}`} style={styles.leaderRow}>
<Text style={styles.leaderRank}>{i + 1}.</Text>
<Text style={styles.leaderName} numberOfLines={1}>{row.name}</Text>
<GlassPill tintColor={GLASS.accent}><Text style={styles.leaderScore}>{row.score}</Text></GlassPill>
</View>
))
)}
</GlassPanel>
);
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
type Phase = 'setup' | 'prep' | 'playing' | 'over';
export default function GameScreen() {
// catalog
const [ALL, setALL] = useState<Signature[]>([]);
const [EVO, setEVO] = useState<Evolution>({});
const [dataError, setDataError] = useState(false);
// selections
const [gameKey, setGameKey] = useState<GameKey>('whose');
const [diff, setDiff] = useState('icons');
const [cat, setCat] = useState('All');
// run state
const [phase, setPhase] = useState<Phase>('setup');
const [prepMsg, setPrepMsg] = useState('Hanging the gallery…');
const [startError, setStartError] = useState('');
const [rounds, setRounds] = useState<Round[]>([]);
const [i, setI] = useState(0);
const [score, setScore] = useState(0);
const [streak, setStreak] = useState(0);
const [misses, setMisses] = useState<{ full_name: string; qid: string | null }[]>([]);
const [answered, setAnswered] = useState(false);
const [pick, setPick] = useState<number | 'left' | 'right' | 'timeout' | null>(null);
const lightningRes = useRef<Record<string, { artist: boolean; title: boolean }>>({});
const lockRef = useRef(false);
// leaderboard
const [leaderRows, setLeaderRows] = useState<LeaderRow[]>([]);
const [leaderLoading, setLeaderLoading] = useState(false);
// lightning timer
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const barW = useRef(new Animated.Value(1)).current;
const catRelevant = gameKey === 'whose' || gameKey === 'century';
// --- load catalog (on mount + on retry) ---
const loadCatalog = useCallback(async () => {
setDataError(false);
try {
const [sigs, evo] = await Promise.all([api.signatures(), api.evolution().catch(() => ({} as Evolution))]);
setALL(sigs.filter((r) => r.signature_image_url && /wikimedia|upload\./.test(r.signature_image_url)));
setEVO(evo || {});
} catch {
setDataError(true);
}
}, []);
useEffect(() => { loadCatalog(); }, [loadCatalog]);
// hydrate personal bests from disk once, so they survive an app restart
useEffect(() => { void loadBest(); }, []);
const clearTimer = useCallback(() => {
if (timerRef.current) { clearTimeout(timerRef.current); timerRef.current = null; }
barW.stopAnimation();
}, [barW]);
useEffect(() => () => clearTimer(), [clearTimer]);
// --- start a game ---
const begin = useCallback(async () => {
if (!ALL.length) return;
setPhase('prep');
setPrepMsg('Hanging the gallery…');
setStartError('');
try {
const r = await buildRounds(gameKey, diff, cat, ALL, EVO, setPrepMsg);
lightningRes.current = {};
setRounds(r);
setI(0); setScore(0); setStreak(0); setMisses([]);
setAnswered(false); setPick(null); lockRef.current = false;
setPhase('playing');
} catch (e: any) {
// surface the reason on the setup screen
setStartError(e?.message || 'Could not start this game.');
setPrepMsg('Hanging the gallery…');
setPhase('setup');
}
}, [ALL, EVO, gameKey, diff, cat]);
// --- lightning timer per round ---
const round = rounds[i];
useEffect(() => {
clearTimer();
if (phase === 'playing' && round?.kind === 'lightning' && !answered) {
barW.setValue(1);
Animated.timing(barW, { toValue: 0, duration: LIGHTNING_MS, useNativeDriver: false }).start();
timerRef.current = setTimeout(() => answer('timeout'), LIGHTNING_MS);
}
return clearTimer;
// answered is intentionally NOT a dep: answering calls clearTimer() directly,
// so re-running this effect on answer would only redundantly cancel the timer.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [phase, i, round?.kind]);
// --- answer a round ---
function answer(choice: number | 'left' | 'right' | 'timeout') {
if (lockRef.current || answered || !round) return;
lockRef.current = true;
clearTimer();
setPick(choice);
setAnswered(true);
let right = false;
const addMiss = (m: { full_name: string; qid: string | null }) => setMisses((prev) => [...prev, m]);
if (round.kind === 'whose' || round.kind === 'art') {
const chosen = round.options[choice as number];
right = chosen === round.answer;
if (!right) addMiss({ full_name: round.answer.full_name, qid: qidOf(round.answer) });
} else if (round.kind === 'century') {
right = round.options[choice as number] === round.century;
if (!right) addMiss({ full_name: round.answer.full_name, qid: qidOf(round.answer) });
} else if (round.kind === 'match') {
right = round.options[choice as number].owner === round.answer;
if (!right) addMiss({ full_name: round.answer.full_name, qid: qidOf(round.answer) });
} else if (round.kind === 'early') {
right = choice === round.earlier;
if (!right) addMiss({ full_name: round.person, qid: round.qid });
} else if (round.kind === 'lightning') {
const chosen = choice === 'timeout' ? null : round.options[choice as number];
right = chosen === round.correct;
const rec = (lightningRes.current[round.pid] = lightningRes.current[round.pid] || { artist: false, title: false });
rec[round.q] = right;
if (!right && round.q === 'artist') addMiss({ full_name: round.painting.artist.full_name, qid: qidOf(round.painting.artist) });
}
void Haptics.notificationAsync(
right ? Haptics.NotificationFeedbackType.Success : Haptics.NotificationFeedbackType.Error,
);
if (right) {
setStreak((s) => s + 1);
const bonus = Math.min(streak, 5);
setScore((v) => v + (round.kind === 'lightning' ? 5 + bonus : 10 + bonus * 2));
} else {
setStreak(0);
}
}
function next() {
clearTimer(); // defensive: a lightning timeout can't fire into the next round
const last = i >= rounds.length - 1;
if (last) { finish(); return; }
setI((v) => v + 1);
setAnswered(false); setPick(null); lockRef.current = false;
}
function finish() {
clearTimer();
let finalScore = score;
if (gameKey === 'lightning') {
const both = Object.values(lightningRes.current).filter((x) => x.artist && x.title).length;
finalScore += both * 5;
setScore(finalScore);
}
const key = `${gameKey}.${diff}.${cat}`;
BEST[key] = Math.max(finalScore, BEST[key] || 0);
void saveBest();
setPhase('over');
loadLeaderboard();
}
const loadLeaderboard = useCallback(async () => {
setLeaderLoading(true);
try {
const res = await api.leaderboardTop(gameKey, diff);
if (res.ok) setLeaderRows(res.top);
} catch { /* non-fatal */ } finally { setLeaderLoading(false); }
}, [gameKey, diff]);
// -------------------------------------------------------------------------
// Renders
// -------------------------------------------------------------------------
if (dataError) {
return (
<View style={styles.centeredFill}>
<Text style={styles.errorText}>Could not load the signature catalog.</Text>
<View style={{ width: 160 }}>
<GlassButton label="Retry" solid onPress={loadCatalog} />
</View>
</View>
);
}
// ---- SETUP ----
if (phase === 'setup' || phase === 'prep') {
return (
<ScrollView style={styles.root} contentContainerStyle={styles.scroll} showsVerticalScrollIndicator={false}>
<Text style={styles.h1}>The Signature Games</Text>
<Text style={styles.sub}>Choose your game</Text>
<View style={styles.gameGrid}>
{GAMES.map((g) => {
const on = g.key === gameKey;
return (
<Pressable key={g.key} onPress={() => { setGameKey(g.key); setStartError(''); }} style={[styles.gcard, on && styles.gcardOn]}>
<Text style={[styles.gcardTitle, on && styles.gcardTitleOn]}>{g.title}</Text>
<Text style={styles.gcardDesc}>{g.desc}</Text>
</Pressable>
);
})}
</View>
<Text style={styles.label}>Difficulty</Text>
<View style={styles.optRow}>
{DIFFS.map((d) => (
<OptionChip key={d.key} label={d.label} sub={`(${d.note})`} active={diff === d.key} onPress={() => setDiff(d.key)} />
))}
</View>
{catRelevant && (
<>
<Text style={styles.label}>Category</Text>
<View style={styles.optRow}>
{CATS.map((c) => (
<OptionChip key={c.key} label={c.label} active={cat === c.key} onPress={() => setCat(c.key)} />
))}
</View>
</>
)}
{startError !== '' && <Text style={styles.errorText}>{startError}</Text>}
<View style={styles.beginBtn}>
<GlassButton
label="Begin"
solid
busy={phase === 'prep'}
disabled={!ALL.length || phase === 'prep'}
onPress={begin}
/>
</View>
{phase === 'prep' && <Text style={styles.dimText}>{prepMsg}</Text>}
{!ALL.length && phase !== 'prep' && <Text style={styles.dimText}>Loading signatures…</Text>}
</ScrollView>
);
}
// ---- OVER ----
if (phase === 'over') {
return <ResultsView
gameKey={gameKey} diff={diff} cat={cat}
score={score} misses={misses} total={rounds.length}
lightningRes={lightningRes.current}
leaderRows={leaderRows} leaderLoading={leaderLoading}
onReloadLeaderboard={loadLeaderboard}
onAgain={() => { setPhase('setup'); }}
/>;
}
// ---- PLAYING ----
return (
<ScrollView style={styles.root} contentContainerStyle={styles.scroll} showsVerticalScrollIndicator={false} keyboardShouldPersistTaps="handled">
<View style={styles.hud}>
<GlassPill><Text style={styles.hudText}>Round {i + 1}/{rounds.length}</Text></GlassPill>
{streak >= 2 && <GlassPill tintColor="rgba(201,168,106,0.22)"><Text style={styles.hudText}>🔥 {streak} streak</Text></GlassPill>}
<GlassPill><Text style={styles.hudText}>Score {score}</Text></GlassPill>
</View>
<Text style={styles.prompt}>{round?.kind === 'early' ? `${(round as EarlyRound).person} — which is the EARLIER signature?` : PROMPTS[gameKey]}</Text>
{round?.kind === 'lightning' && !answered && (
<View style={styles.timerTrack}>
<Animated.View style={[styles.timerFill, { width: barW.interpolate({ inputRange: [0, 1], outputRange: ['0%', '100%'] }) }]} />
</View>
)}
{round?.kind === 'lightning' && (
<Text style={styles.tag}>{round.q === 'artist' ? 'Name the ARTIST' : 'Name the PAINTING'}</Text>
)}
<Stage round={round} />
{round?.kind === 'match'
? <MatchChoices round={round as MatchRound} answered={answered} pick={pick} onPick={answer} />
: <TextChoices round={round} answered={answered} pick={pick} onPick={answer} />}
{answered && <Reveal round={round} pick={pick} />}
{answered && (
<View style={styles.nextBtn}>
<GlassButton label={i >= rounds.length - 1 ? 'See results' : 'Next →'} solid onPress={next} />
</View>
)}
</ScrollView>
);
}
// ---------------------------------------------------------------------------
// Stage (the image(s) shown for the round)
// ---------------------------------------------------------------------------
function Stage({ round }: { round: Round | undefined }) {
if (!round) return null;
if (round.kind === 'whose' || round.kind === 'century' || round.kind === 'match') {
return (
<View style={styles.sigWrap}>
<GlassPanel style={styles.sigPanel} intensity={18} tint="light">
<Image source={{ uri: sigImg(round.answer.signature_image_url) }} style={styles.sigImage} resizeMode="contain" accessibilityLabel="A handwritten signature" />
</GlassPanel>
</View>
);
}
if (round.kind === 'art' || round.kind === 'lightning') {
const img = round.kind === 'art' ? round.artwork.img : round.painting.img;
return (
<View style={styles.sigWrap}>
<GlassPanel style={styles.sigPanel} intensity={18} tint="light">
<Image source={{ uri: img }} style={styles.artImage} resizeMode="cover" accessibilityLabel="A museum artwork" />
</GlassPanel>
</View>
);
}
if (round.kind === 'early') {
return (
<View style={styles.duo}>
{(['leftUrl', 'rightUrl'] as const).map((k, idx) => (
<View key={k} style={styles.duoCell}>
<GlassPanel style={styles.sigPanel} intensity={18} tint="light">
<Image source={{ uri: sigImg(round[k]) }} style={styles.duoImg} resizeMode="contain" accessibilityLabel={`Signature ${idx === 0 ? 'A' : 'B'}`} />
</GlassPanel>
<Text style={styles.duoLab}>{idx === 0 ? 'A' : 'B'}</Text>
</View>
))}
</View>
);
}
return null;
}
// ---------------------------------------------------------------------------
// Text-choice buttons (whose / art / century / early / lightning)
// ---------------------------------------------------------------------------
function TextChoices({
round, answered, pick, onPick,
}: { round: Round | undefined; answered: boolean; pick: any; onPick: (c: any) => void }) {
if (!round) return null;
// build the [label, isCorrect, value] list per game
let items: { label: string; correct: boolean; value: number | 'left' | 'right' }[] = [];
if (round.kind === 'whose' || round.kind === 'art') {
items = round.options.map((o, idx) => ({ label: o.full_name, correct: o === round.answer, value: idx }));
} else if (round.kind === 'century') {
items = round.options.map((c, idx) => ({ label: centuryLabel(c), correct: c === round.century, value: idx }));
} else if (round.kind === 'lightning') {
items = round.options.map((o, idx) => ({ label: o, correct: o === round.correct, value: idx }));
} else if (round.kind === 'early') {
items = [
{ label: 'A is earlier', correct: round.earlier === 'left', value: 'left' },
{ label: 'B is earlier', correct: round.earlier === 'right', value: 'right' },
];
} else {
return null;
}
return (
<View style={styles.choicesWrap}>
{items.map((it, idx) => {
let tint: string | undefined;
let textColor: string | undefined;
if (answered) {
const isPicked = pick === it.value;
if (it.correct) { tint = GLASS.accent; textColor = GLASS.ink; }
else if (isPicked) { tint = '#b03a2e'; textColor = '#fff'; }
}
if (tint) {
return (
<View key={idx} style={styles.choiceOverrideWrap}>
<View style={[styles.choiceOverridePill, { backgroundColor: tint }]}>
<Text style={[styles.choiceOverrideText, { color: textColor }]} numberOfLines={2}>{it.label}</Text>
</View>
</View>
);
}
return (
<View key={idx} style={styles.choiceBtn}>
<GlassButton label={it.label} disabled={answered} onPress={() => onPick(it.value)} />
</View>
);
})}
</View>
);
}
// ---------------------------------------------------------------------------
// Match choices — 2×2 grid of artwork images
// ---------------------------------------------------------------------------
function MatchChoices({
round, answered, pick, onPick,
}: { round: MatchRound; answered: boolean; pick: any; onPick: (c: number) => void }) {
return (
<View style={styles.matchGrid}>
{round.options.map((o, idx) => {
const correct = o.owner === round.answer;
const isPicked = pick === idx;
const border = answered ? (correct ? GLASS.accent : isPicked ? '#b03a2e' : GLASS.edgeFaint) : GLASS.edgeFaint;
return (
<Pressable key={idx} disabled={answered} onPress={() => onPick(idx)} style={[styles.matchCell, { borderColor: border }]}>
<Image source={{ uri: o.art.img }} style={styles.matchImg} resizeMode="cover" accessibilityLabel="Artwork option" />
{answered && (
<View style={styles.matchLabelWrap}>
<Text style={styles.matchLabel} numberOfLines={2}>{o.owner.full_name}</Text>
</View>
)}
</Pressable>
);
})}
</View>
);
}
// ---------------------------------------------------------------------------
// Reveal line under the choices after answering
// ---------------------------------------------------------------------------
function Reveal({ round, pick }: { round: Round | undefined; pick: any }) {
if (!round) return null;
let right = false;
let text = '';
let link: { label: string; url: string } | null = null;
if (round.kind === 'whose') {
right = round.options[pick as number] === round.answer;
text = `${right ? '✓ Correct —' : '✗ It was'} ${round.answer.full_name}`;
} else if (round.kind === 'art') {
right = round.options[pick as number] === round.answer;
text = `${right ? '✓ Correct —' : '✗ It was'} ${round.answer.full_name}`;
link = { label: `“${round.artwork.title}”${round.artwork.date ? ', ' + round.artwork.date : ''} →`, url: round.artwork.url };
} else if (round.kind === 'century') {
right = round.options[pick as number] === round.century;
text = `${right ? '✓ Correct —' : '✗'} ${round.answer.full_name} died ${String(round.answer.death_date).slice(0, 4)} (${centuryLabel(round.century)})`;
} else if (round.kind === 'match') {
right = round.options[pick as number]?.owner === round.answer;
const ownPiece = round.options.find((o) => o.owner === round.answer)!;
text = `${right ? '✓ Correct —' : '✗ Their piece was'} “${ownPiece.art.title}” by ${round.answer.full_name}`;
link = { label: 'View artwork →', url: ownPiece.art.url };
} else if (round.kind === 'early') {
right = pick === round.earlier;
text = `${right ? '✓ Correct —' : '✗'} ${round.person}: ${round.earlier === 'left' ? 'A' : 'B'} is from ${round.years[0]}, the other from ${round.years[1]}`;
} else if (round.kind === 'lightning') {
const chosen = pick === 'timeout' ? null : round.options[pick as number];
right = chosen === round.correct;
const lead = right ? '✓' : pick === 'timeout' ? '⏱ Time!' : '✗';
text = `${lead} ${round.q === 'artist' ? 'Artist' : 'Painting'}: ${round.correct}`;
if (round.q === 'title') link = { label: 'see it →', url: round.painting.url };
}
return (
<View style={styles.reveal}>
<Text style={styles.revealText}>{text}</Text>
{link && (
<Text style={styles.revealLink} onPress={() => Linking.openURL(link!.url)}>{link.label}</Text>
)}
</View>
);
}
// ---------------------------------------------------------------------------
// Results view
// ---------------------------------------------------------------------------
function ResultsView({
gameKey, diff, cat, score, misses, total, lightningRes,
leaderRows, leaderLoading, onReloadLeaderboard, onAgain,
}: {
gameKey: GameKey; diff: string; cat: string; score: number; total: number;
misses: { full_name: string; qid: string | null }[];
lightningRes: Record<string, { artist: boolean; title: boolean }>;
leaderRows: LeaderRow[]; leaderLoading: boolean;
onReloadLeaderboard: () => void; onAgain: () => void;
}) {
const [name, setName] = useState('');
const [submitting, setSubmitting] = useState(false);
const [rank, setRank] = useState<number | null>(null);
const [submitError, setSubmitError] = useState('');
const uniqueMisses = useMemo(() => {
const m = new Map<string, { full_name: string; qid: string | null }>();
misses.forEach((x) => m.set(x.qid || x.full_name, x));
return [...m.values()];
}, [misses]);
const rightN = total - misses.length;
const verdict =
rightN === total ? 'Master archivist. The auction houses should be calling.'
: rightN >= total * 0.8 ? "A connoisseur's eye."
: rightN >= total * 0.6 ? 'A promising apprentice.'
: rightN >= total * 0.4 ? 'The gallery guide is that way…'
: 'Everyone starts somewhere.';
const bonusBoth = gameKey === 'lightning'
? Object.values(lightningRes).filter((x) => x.artist && x.title).length : 0;
const bonusTotal = gameKey === 'lightning' ? Object.keys(lightningRes).length : 0;
const best = BEST[`${gameKey}.${diff}.${cat}`] || score;
const submit = useCallback(async () => {
const trimmed = name.trim();
if (!trimmed) return;
setSubmitting(true); setSubmitError('');
try {
const res = await api.leaderboardSubmit(gameKey, diff, score, trimmed);
if (res.ok && res.rank != null) { setRank(res.rank); onReloadLeaderboard(); }
else setSubmitError('Submit failed — try again.');
} catch { setSubmitError('Network error.'); }
finally { setSubmitting(false); }
}, [name, score, gameKey, diff, onReloadLeaderboard]);
const share = useCallback(() => {
const msg = `I scored ${score} on ${GAME_NAMES[gameKey]} (${diff}) at The Signature Games — can you beat me? https://celebsignatures.com/game`;
Share.share({ message: msg }).catch(() => {});
}, [score, gameKey, diff]);
return (
<ScrollView style={styles.root} contentContainerStyle={styles.scroll} showsVerticalScrollIndicator={false} keyboardShouldPersistTaps="handled">
<GlassPanel style={styles.gameOverPanel}>
<Text style={styles.scoreBig}>{score}</Text>
<Text style={styles.verdict}>{verdict}</Text>
{bonusTotal > 0 && (
<Text style={styles.dimText}>⚡ Named BOTH on {bonusBoth} of {bonusTotal} paintings — +{bonusBoth * 5} bonus</Text>
)}
<Text style={styles.bestText}>Best ({gameKey} · {diff}): {best}</Text>
{uniqueMisses.length > 0 ? (
<Text style={styles.missText}>
Worth a second look: {uniqueMisses.map((m) => m.full_name).join(' · ')}
</Text>
) : (
<Text style={styles.missText}>A perfect run.</Text>
)}
{rank != null ? (
<Text style={styles.rankText}>You ranked #{rank} — nice!</Text>
) : (
<>
<TextInput
style={styles.nameInput}
placeholder="Your name (optional for board)"
placeholderTextColor={GLASS.textDim}
value={name}
onChangeText={setName}
maxLength={24}
returnKeyType="done"
autoCorrect={false}
/>
{submitError !== '' && <Text style={styles.errorText}>{submitError}</Text>}
<View style={styles.submitBtn}>
<GlassButton label="Post my score" solid busy={submitting} disabled={name.trim().length === 0} onPress={submit} />
</View>
</>
)}
<View style={styles.shareRow}>
<View style={{ flex: 1 }}><GlassButton label="🔗 Share result" onPress={share} /></View>
</View>
<View style={styles.playAgainBtn}><GlassButton label="Play again" onPress={onAgain} /></View>
</GlassPanel>
<LeaderboardPanel rows={leaderRows} loading={leaderLoading} tag={`${GAME_NAMES[gameKey]} · ${diff}`} />
</ScrollView>
);
}
// ---------------------------------------------------------------------------
// Styles
// ---------------------------------------------------------------------------
const styles = StyleSheet.create({
root: { flex: 1 },
scroll: { paddingHorizontal: 18, paddingTop: 20, paddingBottom: 48, gap: 14 },
centeredFill: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: 16, padding: 32 },
dimText: { color: GLASS.textDim, fontSize: 14, textAlign: 'center' },
errorText: { color: '#e05c5c', fontSize: 14, textAlign: 'center' },
// setup
h1: { color: GLASS.accentSoft, fontSize: 28, fontWeight: '800', textAlign: 'center', letterSpacing: 0.4 },
sub: { color: GLASS.textDim, fontSize: 15, textAlign: 'center', marginTop: -6 },
gameGrid: { gap: 10, marginTop: 4 },
gcard: {
borderRadius: 16, borderWidth: StyleSheet.hairlineWidth, borderColor: GLASS.edgeFaint,
backgroundColor: '#ffffff', padding: 14,
},
gcardOn: { borderColor: GLASS.accent, backgroundColor: 'rgba(201,168,106,0.14)' },
gcardTitle: { color: GLASS.accentSoft, fontSize: 17, fontWeight: '800', letterSpacing: 0.2 },
gcardTitleOn: { color: GLASS.accent },
gcardDesc: { color: GLASS.textDim, fontSize: 13, marginTop: 3, lineHeight: 18 },
label: { color: GLASS.accent, fontSize: 13, fontWeight: '700', letterSpacing: 0.6, textTransform: 'uppercase', marginTop: 6 },
optRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 },
chip: {
flexDirection: 'row', alignItems: 'center',
borderRadius: 999, borderWidth: StyleSheet.hairlineWidth, borderColor: GLASS.edgeFaint,
paddingHorizontal: 14, paddingVertical: 9, backgroundColor: '#ffffff',
},
chipActive: { borderColor: GLASS.accent, backgroundColor: 'rgba(201,168,106,0.2)' },
chipText: { color: GLASS.accentSoft, fontSize: 14, fontWeight: '600' },
chipSub: { color: GLASS.textDim, fontSize: 12 },
chipTextActive: { color: GLASS.accent },
beginBtn: { marginTop: 12 },
// hud / play
hud: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 10 },
hudText: { color: GLASS.accentSoft, fontSize: 13, fontWeight: '700', letterSpacing: 0.3 },
prompt: { color: GLASS.accentSoft, fontSize: 18, fontWeight: '700', textAlign: 'center', letterSpacing: 0.2, lineHeight: 24 },
tag: { color: GLASS.accent, fontSize: 13, fontWeight: '800', letterSpacing: 0.8, textAlign: 'center', textTransform: 'uppercase' },
timerTrack: { height: 6, borderRadius: 3, backgroundColor: 'rgba(0,0,0,0.08)', overflow: 'hidden' },
timerFill: { height: 6, backgroundColor: GLASS.accent },
sigWrap: {
borderRadius: 22, shadowColor: GLASS.accent, shadowOffset: { width: 0, height: 8 },
shadowOpacity: 0.22, shadowRadius: 24, elevation: 10,
},
sigPanel: {},
sigImage: { width: '100%', height: 190, backgroundColor: '#ffffff', borderRadius: 8 },
artImage: { width: '100%', height: 260, borderRadius: 8 },
// early duo
duo: { flexDirection: 'row', gap: 12 },
duoCell: { flex: 1, alignItems: 'center', gap: 6 },
duoImg: { width: '100%', height: 150, backgroundColor: '#ffffff', borderRadius: 8 },
duoLab: { color: GLASS.accent, fontSize: 16, fontWeight: '800' },
// choices
choicesWrap: { gap: 10 },
choiceBtn: {},
choiceOverrideWrap: { borderRadius: 999, overflow: 'hidden' },
choiceOverridePill: { borderRadius: 999, paddingVertical: 16, paddingHorizontal: 20, alignItems: 'center', justifyContent: 'center' },
choiceOverrideText: { fontSize: 16, fontWeight: '700', letterSpacing: 0.2, textAlign: 'center' },
// match grid
matchGrid: { flexDirection: 'row', flexWrap: 'wrap', justifyContent: 'space-between', gap: 10 },
matchCell: {
width: '48%', aspectRatio: 1, borderRadius: 14, overflow: 'hidden',
borderWidth: 2, backgroundColor: '#ffffff',
},
matchImg: { width: '100%', height: '100%' },
matchLabelWrap: { position: 'absolute', bottom: 0, left: 0, right: 0, backgroundColor: 'rgba(255,255,254,0.92)', paddingVertical: 4, paddingHorizontal: 6 },
matchLabel: { color: GLASS.accentSoft, fontSize: 11, fontWeight: '600', textAlign: 'center' },
// reveal
reveal: { alignItems: 'center', gap: 4, marginTop: 2 },
revealText: { color: GLASS.accent, fontSize: 15, fontWeight: '700', textAlign: 'center', lineHeight: 20 },
revealLink: { color: GLASS.accentSoft, fontSize: 14, textDecorationLine: 'underline' },
nextBtn: { marginTop: 4 },
// results
gameOverPanel: { gap: 12, alignItems: 'stretch' },
scoreBig: { color: GLASS.accent, fontSize: 52, fontWeight: '900', textAlign: 'center', letterSpacing: 0.5 },
verdict: { color: GLASS.accentSoft, fontSize: 17, fontWeight: '700', textAlign: 'center', lineHeight: 23 },
bestText: { color: GLASS.textDim, fontSize: 13, textAlign: 'center' },
missText: { color: GLASS.textDim, fontSize: 13, textAlign: 'center', lineHeight: 19 },
rankText: { color: GLASS.accent, fontSize: 18, fontWeight: '700', textAlign: 'center' },
nameInput: {
borderRadius: 14, borderWidth: StyleSheet.hairlineWidth, borderColor: GLASS.edge,
backgroundColor: '#ffffff', color: GLASS.accentSoft, fontSize: 16,
paddingHorizontal: 16, paddingVertical: 13,
},
submitBtn: { marginTop: 2 },
shareRow: { flexDirection: 'row', marginTop: 2 },
playAgainBtn: { marginTop: 2 },
// leaderboard
leaderPanel: { gap: 10 },
sectionTitle: { color: GLASS.accent, fontSize: 15, fontWeight: '700', letterSpacing: 0.5, marginBottom: 2 },
leaderRow: { flexDirection: 'row', alignItems: 'center', gap: 8 },
leaderRank: { color: GLASS.textDim, fontSize: 14, fontWeight: '600', width: 22 },
leaderName: { flex: 1, color: GLASS.accentSoft, fontSize: 15, fontWeight: '500' },
leaderScore: { color: GLASS.ink, fontSize: 14, fontWeight: '700' },
});