← back to CelebritySignatures
mobile: port all 6 desktop games into GameScreen (was whose-only)
3d5785696f8bc0c0ac666320bd9bee603aac826d · 2026-08-05 17:08:48 -0700 · steve
Adds Who Signed This?, Match the Hand, Early or Late?, Date the Hand, and
Lightning Round alongside Whose Hand? — plus the game picker, 4 difficulty
tiers, category filter, per-game scoring/streak bonuses, results verdict +
misses, per-(game,diff) leaderboard submit, and native Share. Pure front-end
port; backend APIs (signatures/evolution/leaderboard) already supported it.
Validated vs live data: 1421 Artists, 7197 dated, 51 evolution people.
Files touched
M apps/mobile/screens/GameScreen.tsx
Diff
commit 3d5785696f8bc0c0ac666320bd9bee603aac826d
Author: steve <steve@designerwallcoverings.com>
Date: Wed Aug 5 17:08:48 2026 -0700
mobile: port all 6 desktop games into GameScreen (was whose-only)
Adds Who Signed This?, Match the Hand, Early or Late?, Date the Hand, and
Lightning Round alongside Whose Hand? — plus the game picker, 4 difficulty
tiers, category filter, per-game scoring/streak bonuses, results verdict +
misses, per-(game,diff) leaderboard submit, and native Share. Pure front-end
port; backend APIs (signatures/evolution/leaderboard) already supported it.
Validated vs live data: 1421 Artists, 7197 dated, 51 evolution people.
---
apps/mobile/screens/GameScreen.tsx | 1493 +++++++++++++++++++++---------------
1 file changed, 894 insertions(+), 599 deletions(-)
diff --git a/apps/mobile/screens/GameScreen.tsx b/apps/mobile/screens/GameScreen.tsx
index 6c35d16..cc74310 100644
--- a/apps/mobile/screens/GameScreen.tsx
+++ b/apps/mobile/screens/GameScreen.tsx
@@ -1,33 +1,34 @@
/**
- * GameScreen — "Whose signature is this?"
+ * GameScreen — "The Signature Games" (full 6-game suite, ported from the desktop
+ * web build at public/game.html).
*
- * Usage (in App/navigation):
- * import GameScreen from './screens/GameScreen';
- * // Rendered inside GlassScene by the app shell — do NOT wrap here.
- * <GameScreen />
+ * 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.
*
- * Rules:
- * • 10 questions per game, 4 choices each (1 correct + 3 wrong), shuffled.
- * • Correct tap → gold flash + haptic success + increment score.
- * • Wrong tap → haptic error + reveal correct name + end streak (next Q).
- * • Game-over card shows score, name TextInput, Submit button → rank display.
- * • Top-5 leaderboard rendered below at all times (fetched on mount + after submit).
+ * 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,
- useReducer,
- useRef,
- useState,
-} from 'react';
+import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
ActivityIndicator,
Animated,
Image,
+ Linking,
Pressable,
ScrollView,
+ Share,
StyleSheet,
Text,
TextInput,
@@ -35,34 +36,61 @@ import {
} from 'react-native';
import * as Haptics from 'expo-haptics';
-import { api, Signature, LeaderRow } from '../api';
+import { api, Signature, LeaderRow, Evolution, qidOf } from '../api';
import { GLASS, GlassPanel, GlassButton, GlassPill } from '../ui/glass';
// ---------------------------------------------------------------------------
-// Constants
-// ---------------------------------------------------------------------------
-
-const TOTAL_QUESTIONS = 10;
-const CHOICES = 4;
-const GAME_ID = 'whose';
-const DIFF_ID = 'icons';
-
-// ---------------------------------------------------------------------------
-// Types
+// Static game / option definitions (mirror public/game.html)
// ---------------------------------------------------------------------------
-type Question = {
- signature: Signature; // correct answer
- choices: string[]; // shuffled full_names (length === CHOICES)
- correctIndex: number; // index in choices that is correct
+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!',
};
-type FeedbackState = 'idle' | 'correct' | 'wrong';
+const TOTAL = 10;
+const LIGHTNING_MS = 5000;
-type GamePhase = 'loading' | 'error' | 'playing' | 'over';
+// module-level best-score cache (no AsyncStorage dep; resets on app restart)
+const BEST: Record<string, number> = {};
// ---------------------------------------------------------------------------
-// Helpers
+// Helpers (ported from game.html)
// ---------------------------------------------------------------------------
function shuffle<T>(arr: T[]): T[] {
@@ -74,99 +102,258 @@ function shuffle<T>(arr: T[]): T[] {
return a;
}
-/** Pick `n` distinct items from `pool`, excluding `exclude`. */
-function pickDistinct<T>(pool: T[], exclude: T, n: number): T[] {
- const candidates = pool.filter((x) => x !== exclude);
- return shuffle(candidates).slice(0, n);
+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;
}
-/**
- * Build a full 10-question deck from the available pool.
- * Each question needs a signature image URL; the pool is already filtered for that.
- */
-function buildDeck(pool: Signature[]): Question[] {
- const allNames = pool.map((s) => s.full_name);
- const picked = shuffle(pool).slice(0, TOTAL_QUESTIONS);
-
- return picked.map((sig) => {
- const wrong = pickDistinct(allNames, sig.full_name, CHOICES - 1);
- const mixed = shuffle([sig.full_name, ...wrong]);
- return {
- signature: sig,
- choices: mixed,
- correctIndex: mixed.indexOf(sig.full_name),
- };
- });
+/** 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;
}
// ---------------------------------------------------------------------------
-// Sub-components
+// Round model (discriminated union)
// ---------------------------------------------------------------------------
-function LoadingView() {
- return (
- <View style={styles.centeredFill}>
- <ActivityIndicator size="large" color={GLASS.accent} />
- <Text style={styles.dimText}>Loading signatures…</Text>
- </View>
- );
+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]);
}
-function ErrorView({ onRetry }: { onRetry: () => void }) {
- return (
- <View style={styles.centeredFill}>
- <Text style={styles.errorText}>Could not load signatures.</Text>
- <View style={styles.retryBtn}>
- <GlassButton label="Retry" onPress={onRetry} solid />
- </View>
- </View>
- );
-}
+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;
+ }
-// ---------------------------------------------------------------------------
-// Gold flash overlay (Animated) shown briefly on correct answer
-// ---------------------------------------------------------------------------
+ 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;
+ while (opts.size < 4) {
+ const cand = c + (Math.random() < 0.5 ? -step : step);
+ if (cand >= 6 && cand <= 21) opts.add(cand);
+ step = Math.min(step + 1, 6);
+ }
+ rounds.push({ kind: 'century', answer, century: c, options: shuffle([...opts]) });
+ }
+ return rounds;
+ }
-function CorrectFlash({ visible }: { visible: boolean }) {
- const opacity = useRef(new Animated.Value(0)).current;
+ if (gameKey === 'early') {
+ const people = 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);
+ 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;
+ }
- useEffect(() => {
- if (visible) {
- Animated.sequence([
- Animated.timing(opacity, { toValue: 0.35, duration: 80, useNativeDriver: true }),
- Animated.timing(opacity, { toValue: 0, duration: 320, useNativeDriver: true }),
- ]).start();
+ 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 });
+ }
}
- }, [visible, opacity]);
+ 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;
+ }
- return (
- <Animated.View
- pointerEvents="none"
- style={[StyleSheet.absoluteFill, { backgroundColor: GLASS.accent, opacity, borderRadius: 22 }]}
- />
- );
+ // 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);
}
// ---------------------------------------------------------------------------
-// Leaderboard panel
+// Small UI atoms
// ---------------------------------------------------------------------------
-function LeaderboardPanel({ rows, loading }: { rows: LeaderRow[]; loading: boolean }) {
+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}>Top Scores</Text>
+ <Text style={styles.sectionTitle}>🏆 Leaderboard{tag ? ` · ${tag}` : ''}</Text>
{loading ? (
<ActivityIndicator color={GLASS.accent} style={{ marginTop: 8 }} />
) : rows.length === 0 ? (
- <Text style={styles.dimText}>No scores yet — be the first!</Text>
+ <Text style={styles.dimText}>Be the first — post your score!</Text>
) : (
rows.slice(0, 5).map((row, i) => (
- <View key={row.id} style={styles.leaderRow}>
+ <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>
+ <GlassPill tintColor={GLASS.accent}><Text style={styles.leaderScore}>{row.score}</Text></GlassPill>
</View>
))
)}
@@ -175,566 +362,674 @@ function LeaderboardPanel({ rows, loading }: { rows: LeaderRow[]; loading: boole
}
// ---------------------------------------------------------------------------
-// Game-over card
+// Main
// ---------------------------------------------------------------------------
-type GameOverCardProps = {
- score: number;
- onPlayAgain: () => void;
-};
+type Phase = 'setup' | 'prep' | 'playing' | 'over';
-function GameOverCard({ score, onPlayAgain }: GameOverCardProps) {
- const [name, setName] = useState('');
- const [submitting, setSubmitting] = useState(false);
- const [rank, setRank] = useState<number | null>(null);
- const [submitError, setSubmitError] = useState('');
+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);
- const handleSubmit = useCallback(async () => {
- const trimmed = name.trim();
- if (!trimmed) return;
- setSubmitting(true);
- setSubmitError('');
+ // 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 res = await api.leaderboardSubmit(GAME_ID, DIFF_ID, score, trimmed);
- if (res.ok && res.rank != null) {
- setRank(res.rank);
- } else {
- setSubmitError('Submit failed — try again.');
- }
+ 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 {
- setSubmitError('Network error.');
- } finally {
- setSubmitting(false);
+ setDataError(true);
}
- }, [name, score]);
-
- return (
- <GlassPanel style={styles.gameOverPanel}>
- <Text style={styles.gameOverTitle}>Game Over</Text>
-
- <View style={styles.scoreRow}>
- <GlassPill tintColor={GLASS.accent}>
- <Text style={styles.finalScoreText}>
- {score} / {TOTAL_QUESTIONS}
- </Text>
- </GlassPill>
- </View>
+ }, []);
- {rank != null ? (
- <Text style={styles.rankText}>You ranked #{rank} — nice!</Text>
- ) : (
- <>
- <TextInput
- style={styles.nameInput}
- placeholder="Your name"
- placeholderTextColor={GLASS.textDim}
- value={name}
- onChangeText={setName}
- maxLength={32}
- returnKeyType="done"
- autoCorrect={false}
- />
- {submitError !== '' && (
- <Text style={styles.errorText}>{submitError}</Text>
- )}
- <View style={styles.submitBtn}>
- <GlassButton
- label="Submit score"
- onPress={handleSubmit}
- solid
- busy={submitting}
- disabled={name.trim().length === 0}
- />
- </View>
- </>
- )}
+ useEffect(() => { loadCatalog(); }, [loadCatalog]);
- <View style={styles.playAgainBtn}>
- <GlassButton label="Play again" onPress={onPlayAgain} />
- </View>
- </GlassPanel>
- );
-}
+ const clearTimer = useCallback(() => {
+ if (timerRef.current) { clearTimeout(timerRef.current); timerRef.current = null; }
+ barW.stopAnimation();
+ }, [barW]);
-// ---------------------------------------------------------------------------
-// Main GameScreen
-// ---------------------------------------------------------------------------
+ useEffect(() => () => clearTimer(), [clearTimer]);
-export default function GameScreen() {
- // --- Data loading ---
- const [phase, setPhase] = useState<GamePhase>('loading');
- const [pool, setPool] = useState<Signature[]>([]);
+ // --- 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]);
- // --- Leader board ---
- const [leaderRows, setLeaderRows] = useState<LeaderRow[]>([]);
- const [leaderLoading, setLeaderLoading] = useState(true);
+ // --- 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;
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [phase, i, answered, 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) });
+ }
- // --- Game state ---
- const [deck, setDeck] = useState<Question[]>([]);
- const [qIndex, setQIndex] = useState(0); // 0-based current question
- const [score, setScore] = useState(0);
- const [feedback, setFeedback] = useState<FeedbackState>('idle');
- const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
- const [correctFlash, setCorrectFlash] = useState(false);
+ void Haptics.notificationAsync(
+ right ? Haptics.NotificationFeedbackType.Success : Haptics.NotificationFeedbackType.Error,
+ );
- // Used to prevent double-tap race during feedback animation
- const lockedRef = useRef(false);
+ 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);
+ }
+ }
- // ---------------------------------------------------------------------------
- // Fetch signatures on mount
- // ---------------------------------------------------------------------------
+ function next() {
+ const last = i >= rounds.length - 1;
+ if (last) { finish(); return; }
+ setI((v) => v + 1);
+ setAnswered(false); setPick(null); lockRef.current = false;
+ }
- const fetchSignatures = useCallback(async () => {
- setPhase('loading');
- try {
- const all = await api.signatures();
- const withImg = all.filter((s) => !!s.signature_image_url);
- if (withImg.length < CHOICES) {
- // Not enough data to build a quiz
- setPhase('error');
- return;
- }
- setPool(withImg);
- setPhase('playing');
- startNewGame(withImg);
- } catch {
- setPhase('error');
+ 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);
}
- }, []); // eslint-disable-line react-hooks/exhaustive-deps
-
- // ---------------------------------------------------------------------------
- // Fetch leaderboard
- // ---------------------------------------------------------------------------
+ const key = `${gameKey}.${diff}.${cat}`;
+ BEST[key] = Math.max(finalScore, BEST[key] || 0);
+ setPhase('over');
+ loadLeaderboard();
+ }
- const fetchLeaderboard = useCallback(async () => {
+ const loadLeaderboard = useCallback(async () => {
setLeaderLoading(true);
try {
- const res = await api.leaderboardTop(GAME_ID, DIFF_ID);
+ const res = await api.leaderboardTop(gameKey, diff);
if (res.ok) setLeaderRows(res.top);
- } catch {
- // non-fatal — board just stays empty
- } finally {
- setLeaderLoading(false);
- }
- }, []);
+ } catch { /* non-fatal */ } finally { setLeaderLoading(false); }
+ }, [gameKey, diff]);
- useEffect(() => {
- fetchSignatures();
- fetchLeaderboard();
- }, [fetchSignatures, fetchLeaderboard]);
-
- // ---------------------------------------------------------------------------
- // Game logic
- // ---------------------------------------------------------------------------
-
- function startNewGame(sigPool: Signature[] = pool) {
- const newDeck = buildDeck(sigPool);
- setDeck(newDeck);
- setQIndex(0);
- setScore(0);
- setFeedback('idle');
- setSelectedIndex(null);
- setCorrectFlash(false);
- lockedRef.current = false;
- setPhase('playing');
- }
+ // -------------------------------------------------------------------------
+ // Renders
+ // -------------------------------------------------------------------------
- const handlePlayAgain = useCallback(() => {
- fetchLeaderboard(); // refresh board after game-over view
- startNewGame();
- }, [pool]); // eslint-disable-line react-hooks/exhaustive-deps
-
- const handleChoice = useCallback(
- (choiceIndex: number) => {
- if (lockedRef.current || feedback !== 'idle') return;
- lockedRef.current = true;
- setSelectedIndex(choiceIndex);
-
- const q = deck[qIndex];
- const isCorrect = choiceIndex === q.correctIndex;
-
- if (isCorrect) {
- void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
- setCorrectFlash(true);
- setFeedback('correct');
- const nextScore = score + 1;
-
- setTimeout(() => {
- setCorrectFlash(false);
- const nextIndex = qIndex + 1;
- if (nextIndex >= TOTAL_QUESTIONS) {
- setScore(nextScore);
- setPhase('over');
- } else {
- setScore(nextScore);
- setQIndex(nextIndex);
- setFeedback('idle');
- setSelectedIndex(null);
- lockedRef.current = false;
- }
- }, 520);
- } else {
- void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
- setFeedback('wrong');
-
- // Reveal answer for 1.4 s then advance
- setTimeout(() => {
- const nextIndex = qIndex + 1;
- if (nextIndex >= TOTAL_QUESTIONS) {
- setPhase('over');
- } else {
- setQIndex(nextIndex);
- setFeedback('idle');
- setSelectedIndex(null);
- lockedRef.current = false;
- }
- }, 1400);
- }
- },
- [deck, feedback, qIndex, score],
- );
-
- // ---------------------------------------------------------------------------
- // Derived values
- // ---------------------------------------------------------------------------
-
- const currentQuestion: Question | undefined = deck[qIndex];
-
- // Button color logic per choice:
- // idle → default glass
- // correct → gold background on the correct choice
- // wrong → red on selected, gold on correct
- function choiceStyle(choiceIndex: number): { tint?: string; textColor?: string } {
- if (feedback === 'idle') return {};
- const isCorrectChoice = currentQuestion && choiceIndex === currentQuestion.correctIndex;
- const isSelectedChoice = choiceIndex === selectedIndex;
- if (isCorrectChoice) return { tint: GLASS.accent, textColor: GLASS.ink };
- if (isSelectedChoice && feedback === 'wrong') return { tint: '#b03a2e', textColor: '#fff' };
- return {};
+ 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>
+ );
}
- // ---------------------------------------------------------------------------
- // Render helpers
- // ---------------------------------------------------------------------------
+ // ---- 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>
- function renderChoiceButtons() {
- if (!currentQuestion) return null;
- return currentQuestion.choices.map((name, idx) => {
- const { tint, textColor } = choiceStyle(idx);
- const isTinted = !!tint;
+ <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>
- // We need tinted buttons — GlassButton only has solid=accent-gold.
- // For correct/wrong states we render a custom GlassPill-based pressable
- // so the color override works without forking GlassButton.
- if (isTinted) {
- return (
- <View key={idx} style={styles.choiceOverrideWrap}>
- <View style={[styles.choiceOverridePill, { backgroundColor: tint }]}>
- <Text
- style={[styles.choiceOverrideText, { color: textColor ?? '#fff' }]}
- numberOfLines={2}
- >
- {name}
- </Text>
+ {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>
- </View>
- );
- }
+ </>
+ )}
+
+ {startError !== '' && <Text style={styles.errorText}>{startError}</Text>}
- return (
- <View key={idx} style={styles.choiceBtn}>
+ <View style={styles.beginBtn}>
<GlassButton
- label={name}
- onPress={() => handleChoice(idx)}
- disabled={feedback !== 'idle'}
+ 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>
+ );
}
- function renderProgress() {
+ // ---- 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.progressRow}>
- <GlassPill>
- <Text style={styles.progressText}>
- Q {qIndex + 1}/{TOTAL_QUESTIONS} · Score {score}
- </Text>
- </GlassPill>
+ <View style={styles.sigWrap}>
+ <GlassPanel style={styles.sigPanel} intensity={18} tint="light">
+ <Image source={{ uri: 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: 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;
+}
- // ---------------------------------------------------------------------------
- // Phase renders
- // ---------------------------------------------------------------------------
+// ---------------------------------------------------------------------------
+// Text-choice buttons (whose / art / century / early / lightning)
+// ---------------------------------------------------------------------------
- if (phase === 'loading') return <LoadingView />;
- if (phase === 'error') return <ErrorView onRetry={fetchSignatures} />;
+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 (
- <ScrollView
- style={styles.root}
- contentContainerStyle={styles.scroll}
- showsVerticalScrollIndicator={false}
- keyboardShouldPersistTaps="handled"
- >
- {/* ---- Game-over card (replaces quiz) ---- */}
- {phase === 'over' ? (
- <>
- <GameOverCard
- score={score}
- onPlayAgain={handlePlayAgain}
- />
- <LeaderboardPanel rows={leaderRows} loading={leaderLoading} />
- </>
- ) : (
- <>
- {/* ---- Progress pill ---- */}
- {renderProgress()}
-
- {/* ---- Signature image panel ---- */}
- {currentQuestion && (
- <View style={styles.sigWrap}>
- <GlassPanel
- style={styles.sigPanel}
- intensity={18}
- tint="light" // parchment-tinted (light tint over gold gradient)
- >
- <CorrectFlash visible={correctFlash} />
- <Image
- source={{ uri: currentQuestion.signature.signature_image_url }}
- style={styles.sigImage}
- resizeMode="contain"
- accessibilityLabel="A handwritten signature — whose is it?"
- />
- </GlassPanel>
- </View>
- )}
-
- {/* ---- Category hint ---- */}
- {currentQuestion && (
- <View style={styles.categoryRow}>
- <GlassPill tintColor="rgba(201,168,106,0.18)">
- <Text style={styles.categoryText}>
- {currentQuestion.signature.category}
- </Text>
- </GlassPill>
+ <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>
- )}
-
- {/* ---- Wrong-answer reveal label ---- */}
- {feedback === 'wrong' && currentQuestion && (
- <Text style={styles.revealText}>
- That's {currentQuestion.signature.full_name}
- </Text>
- )}
-
- {/* ---- Answer buttons ---- */}
- <View style={styles.choicesWrap}>
- {renderChoiceButtons()}
+ );
+ }
+ 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
+// ---------------------------------------------------------------------------
- {/* ---- Leaderboard ---- */}
- <LeaderboardPanel rows={leaderRows} loading={leaderLoading} />
- </>
+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>
)}
- </ScrollView>
+ </View>
);
}
// ---------------------------------------------------------------------------
-// Styles
+// Results view
// ---------------------------------------------------------------------------
-const styles = StyleSheet.create({
- root: {
- flex: 1,
- },
- scroll: {
- paddingHorizontal: 18,
- paddingTop: 20,
- paddingBottom: 48,
- gap: 16,
- },
+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('');
- // Loading / error
- centeredFill: {
- flex: 1,
- alignItems: 'center',
- justifyContent: 'center',
- gap: 16,
- padding: 32,
- },
- dimText: {
- color: GLASS.textDim,
- fontSize: 15,
- textAlign: 'center',
- },
- errorText: {
- color: '#e05c5c',
- fontSize: 14,
- textAlign: 'center',
- },
- retryBtn: {
- width: 160,
- },
+ 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]);
- // Progress
- progressRow: {
- alignItems: 'center',
- },
- progressText: {
- color: GLASS.accentSoft,
- fontSize: 14,
- fontWeight: '600',
- letterSpacing: 0.4,
- },
+ 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.';
- // Signature image
- sigWrap: {
- borderRadius: 22,
- // Warm parchment shadow to sell the physical-paper feel
- shadowColor: GLASS.accent,
- shadowOffset: { width: 0, height: 8 },
- shadowOpacity: 0.22,
- shadowRadius: 24,
- elevation: 10,
- },
- sigPanel: {
- // override panelBody padding is via children — the GlassPanel adds 18px padding internally
- },
- sigImage: {
- width: '100%',
- height: 200,
- // Slight warm tint is applied via the GlassPanel light tint
- },
+ const bonusBoth = gameKey === 'lightning'
+ ? Object.values(lightningRes).filter((x) => x.artist && x.title).length : 0;
+ const bonusTotal = gameKey === 'lightning' ? Object.keys(lightningRes).length : 0;
- // Category
- categoryRow: {
- alignItems: 'center',
- },
- categoryText: {
- color: GLASS.textDim,
- fontSize: 13,
- fontWeight: '500',
- letterSpacing: 0.6,
- textTransform: 'uppercase',
- },
+ const best = BEST[`${gameKey}.${diff}.${cat}`] || score;
- // Wrong reveal
- revealText: {
- color: GLASS.accent,
- fontSize: 16,
- fontWeight: '700',
- textAlign: 'center',
- letterSpacing: 0.2,
- marginVertical: 4,
- },
+ 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]);
- // Choices
- choicesWrap: {
- gap: 10,
- },
- choiceBtn: {
- // no extra style — GlassButton fills full width via its own styles
- },
- // Tinted override pill (correct / wrong state)
- choiceOverrideWrap: {
- borderRadius: 999,
- overflow: 'hidden',
- },
- choiceOverridePill: {
- borderRadius: 999,
- paddingVertical: 16,
- paddingHorizontal: 20,
- alignItems: 'center',
- justifyContent: 'center',
- },
- choiceOverrideText: {
- fontSize: 17,
- fontWeight: '700',
- letterSpacing: 0.2,
- textAlign: 'center',
- },
+ 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>
+ </>
+ )}
- // Game-over
- gameOverPanel: {
- gap: 14,
- },
- gameOverTitle: {
- color: GLASS.accentSoft,
- fontSize: 26,
- fontWeight: '800',
- textAlign: 'center',
- letterSpacing: 0.4,
- },
- scoreRow: {
- alignItems: 'center',
- },
- finalScoreText: {
- color: GLASS.ink,
- fontSize: 22,
- fontWeight: '800',
- letterSpacing: 0.5,
- },
- rankText: {
- color: GLASS.accent,
- fontSize: 18,
- fontWeight: '700',
- textAlign: 'center',
- marginTop: 4,
- },
- nameInput: {
- borderRadius: 14,
- borderWidth: StyleSheet.hairlineWidth,
- borderColor: GLASS.edge,
- backgroundColor: 'rgba(255,255,255,0.06)',
- color: GLASS.accentSoft,
- fontSize: 17,
- paddingHorizontal: 16,
- paddingVertical: 13,
- marginTop: 4,
- },
- submitBtn: {
- marginTop: 4,
- },
- playAgainBtn: {
- marginTop: 4,
- },
+ <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>
- // Leaderboard
- leaderPanel: {
- gap: 10,
- },
- sectionTitle: {
- color: GLASS.accent,
- fontSize: 16,
- fontWeight: '700',
- letterSpacing: 0.6,
- textTransform: 'uppercase',
- marginBottom: 2,
+ <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: 'rgba(255,255,255,0.04)', padding: 14,
},
- leaderRow: {
- flexDirection: 'row',
- alignItems: 'center',
- gap: 8,
+ 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: 'rgba(255,255,255,0.04)',
},
- leaderRank: {
- color: GLASS.textDim,
- fontSize: 14,
- fontWeight: '600',
- width: 22,
+ 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(255,255,255,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,
},
- leaderName: {
- flex: 1,
- color: GLASS.accentSoft,
- fontSize: 15,
- fontWeight: '500',
+ sigPanel: {},
+ sigImage: { width: '100%', height: 190 },
+ 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 },
+ 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: 'rgba(255,255,255,0.04)',
},
- leaderScore: {
- color: GLASS.ink,
- fontSize: 14,
- fontWeight: '700',
+ matchImg: { width: '100%', height: '100%' },
+ matchLabelWrap: { position: 'absolute', bottom: 0, left: 0, right: 0, backgroundColor: 'rgba(13,11,10,0.82)', 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: 'rgba(255,255,255,0.06)', 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' },
});
← 4b6b29e iOS: complete App Store screenshot set — all 4 tabs (Gallery
·
back to CelebritySignatures
·
mobile: harden games port per adversarial review 701c009 →