/*  === monstrance-playful.jsx ===
    Playful rubric games — builder, memory, quiet light, shepherd, bread from heaven, GamesPage host.
    Loaded as <script type="text/babel" src="js/monstrance-playful.jsx"></script>.
    Cross-file references work because babel-standalone evaluates each script in
    the document's global scope after compile; top-level const/let/function decls
    are visible to subsequent scripts loaded in this order.
*/

const { useState, useEffect, useRef, useCallback } = React;

const MONSTRANCE_BUILD_ASSET_BASE =
  "../images/catholic-kids/games/monstrance-build";
const MONSTRANCE_BUILD_AUDIO =
  "../audio/catholic-kids/monstrance-build-complete.mp3";
const MONSTRANCE_BUILD_TOLERANCE = 24;
const MONSTRANCE_BUILD_STORAGE_KEY = "monstrance-build-complete";
const MONSTRANCE_BUILD_PIECES = [
  {
    id: "base",
    label: "Base",
    asset: `${MONSTRANCE_BUILD_ASSET_BASE}/monstrance-base-v1.png`,
    clue: "Set the wide foot at the bottom.",
    teach: "The base is wide and steady.",
    slot: { x: 50, y: 82, w: 46, h: 20 },
    art: { x: 50, y: 82, w: 48 },
  },
  {
    id: "stem",
    label: "Stem",
    asset: `${MONSTRANCE_BUILD_ASSET_BASE}/monstrance-stem-v1.png`,
    clue: "Lift the handle above the base.",
    teach: "The stem helps the monstrance stand tall.",
    slot: { x: 50, y: 60, w: 22, h: 42 },
    art: { x: 50, y: 60, w: 28 },
  },
  {
    id: "rays",
    label: "Lunette frame",
    asset: `${MONSTRANCE_BUILD_ASSET_BASE}/monstrance-rays-v1.png`,
    clue: "Place the golden rays near the top.",
    teach: "The rays remind us that Jesus is light.",
    slot: { x: 50, y: 34, w: 52, h: 46 },
    art: { x: 50, y: 35, w: 58 },
  },
  {
    id: "lunette",
    label: "Lunette glass",
    asset: `${MONSTRANCE_BUILD_ASSET_BASE}/monstrance-lunette-v1.png`,
    clue: "Set the glass in the middle.",
    teach: "The lunette is the little window where the Host is shown.",
    slot: { x: 50, y: 34, w: 22, h: 24 },
    art: { x: 50, y: 34, w: 25 },
  },
];

const MEMORY_PAIRS = [
  { id: "host", symbol: "○", label: "Host", mate: "Jesus is here" },
  { id: "light", symbol: "✺", label: "Rays", mate: "Light of the World" },
  { id: "quiet", symbol: "🤫", label: "Quiet", mate: "Adoration voice" },
  { id: "thanks", symbol: "♡", label: "Thank You", mate: "Prayer word" },
];

const LIGHT_PRAYERS = [
  { id: "hello", label: "Hello, Jesus", teach: "A small hello is a real prayer." },
  { id: "thanks", label: "Thank You", teach: "Gratitude helps children connect the screen to daily life." },
  { id: "sorry", label: "I am sorry", teach: "A gentle repair word builds social-emotional practice." },
  { id: "help", label: "Please help", teach: "Asking for help is brave and concrete." },
  { id: "love", label: "I love You", teach: "Short phrases keep cognitive load low for new readers." },
  { id: "amen", label: "Amen", teach: "Amen means, yes, I mean it." },
];

const GUADALUPE_ASSET_BASE = "../images/catholic-kids/games/guadalupe";
const GUADALUPE_AUDIO_BASE = "../audio/grok";
const GUADALUPE_APPARITION_AUDIO_BASE = "../audio/catholic-kids/guadalupe";
const GUADALUPE_CHARACTER_ATLASES = {
  mary: {
    label: "Our Lady of Guadalupe",
    walk: `${GUADALUPE_ASSET_BASE}/mary-walk-4x4-registered-v5.png`,
    talk: `${GUADALUPE_ASSET_BASE}/mary-dialogue-8-v2.png`,
  },
  juan: {
    label: "St. Juan Diego",
    walk: `${GUADALUPE_ASSET_BASE}/juan-diego-walk-4x4-registered-v5.png`,
    walkRoses: `${GUADALUPE_ASSET_BASE}/juan-diego-roses-walk-4x4-registered-v7.png`,
    talk: `${GUADALUPE_ASSET_BASE}/juan-dialogue-8-v2.png`,
  },
  priest: {
    label: "Bishop Zumarraga",
    walk: `${GUADALUPE_ASSET_BASE}/priest-walk-4x4-registered-v5.png`,
    talk: `${GUADALUPE_ASSET_BASE}/priest-dialogue-8-v2.png`,
  },
};
const GUADALUPE_DIRECTIONS = {
  front: 0,
  back: 1,
  left: 2,
  right: 3,
};
const GUADALUPE_WALK_FRAME_SEQUENCE = [1, 0, 2, 0];
const GUADALUPE_WALK_TIMING = {
  fieldSpeed: 17,
  roomSpeed: 24,
  minMs: 420,
  fieldMaxMs: 1500,
  roomMaxMs: 1160,
};
const GUADALUPE_CONTROL_FIRST_STEP_DT = 0.045;
const GUADALUPE_VISIT_SCENES = [
  {
    id: "tepeyac",
    label: "Tepeyac Hill",
    place: "December 9, 1531",
    backdrop: `${GUADALUPE_ASSET_BASE}/scene-01-tepeyac-call-v2.png`,
    speaker: "mary",
    line:
      "Juanito, my little son, do not be afraid. I am your mother, and I bring comfort from God.",
    note:
      "The Guadalupe story begins on December 9, 1531, on Tepeyac Hill near Mexico City.",
    actors: [
      { id: "juan", x: 33, y: 75, scale: 0.92, dir: "right", active: true },
      { id: "mary", x: 67, y: 57, scale: 1.08, dir: "front", active: true },
    ],
  },
  {
    id: "message",
    label: "A Chapel Request",
    place: "Tepeyac slope",
    backdrop: `${GUADALUPE_ASSET_BASE}/scene-02-chapel-request-v2.png`,
    speaker: "mary",
    line:
      "Go to the bishop. Ask that a little house of prayer be built here, where people can come to my Son.",
    note:
      "The chapel request gives Juan Diego a concrete mission to carry to Church leadership.",
    maryWaypoints: [
      { x: 63, y: 59, dir: "left" },
      { x: 66, y: 57, dir: "front" },
      { x: 60, y: 61, dir: "right" },
      { x: 63, y: 59, dir: "left" },
    ],
    actors: [
      { id: "juan", x: 42, y: 78, scale: 0.9, dir: "front", active: true },
      { id: "mary", x: 63, y: 59, scale: 1.02, dir: "left", active: true },
    ],
  },
  {
    id: "bishop",
    label: "Tell the Bishop",
    place: "Mexico City, 1531",
    backdrop: `${GUADALUPE_ASSET_BASE}/scene-03-bishop-room-v2.png`,
    speaker: "priest",
    line:
      "A holy request needs a sign. If this message is from heaven, bring what she gives you.",
    note:
      "Juan Diego carries the request to Bishop Juan de Zumarraga with humility and courage.",
    actors: [
      { id: "juan", x: 42, y: 86, scale: 2.12, mobileX: 36, mobileY: 84, mobileScale: 2.1, dir: "right", active: true },
      { id: "priest", x: 66, y: 85, scale: 2.24, mobileX: 68, mobileY: 83, mobileScale: 2.08, dir: "left", active: true },
    ],
    roomSpots: [
      {
        id: "door",
        label: "Enter",
        x: 55,
        y: 68,
        mobileX: 55,
        mobileY: 66,
        bgPosition: "48% 52%",
        bgScale: 1.06,
        speaker: "juan",
        audio: `${GUADALUPE_AUDIO_BASE}/guadalupe-bishop-door.mp3`,
        line:
          "I step inside with Mary's request: build a chapel where people can come to Jesus.",
        note:
          "Juan Diego first brings the message to Bishop Juan de Zumarraga in Mexico City.",
        find: {
          label: "You noticed",
          title: "Messenger's errand",
          text: "Juan Diego enters with a request from Mary, but he still has to speak humbly and be heard.",
        },
        actors: {
          juan: { x: 45, y: 84, scale: 2.12, mobileX: 38, mobileY: 83, mobileScale: 1.96, dir: "right" },
          priest: { x: 67, y: 87, scale: 2.22, mobileX: 69, mobileY: 84, mobileScale: 1.98, dir: "left" },
        },
      },
      {
        id: "desk",
        label: "Desk",
        x: 71,
        y: 66,
        mobileX: 73,
        mobileY: 62,
        bgPosition: "61% 52%",
        bgScale: 1.11,
        speaker: "priest",
        audio: `${GUADALUPE_AUDIO_BASE}/guadalupe-bishop-desk.mp3`,
        line:
          "Bring the message close. We will look carefully, pray, and ask for a sign.",
        note:
          "The bishop listens, but asks for a sign before approving the chapel request.",
        find: {
          label: "You noticed",
          title: "Careful listening",
          text: "The bishop does not approve the chapel right away. In the story, he asks for a sign.",
        },
        actors: {
          juan: { x: 52, y: 86, scale: 2.12, mobileX: 42, mobileY: 84, mobileScale: 2.06, dir: "right" },
          priest: { x: 69, y: 88, scale: 2.22, mobileX: 68, mobileY: 85, mobileScale: 2.02, dir: "left" },
        },
      },
      {
        id: "candles",
        label: "Candles",
        x: 22,
        y: 65,
        mobileX: 17,
        mobileY: 67,
        bgPosition: "35% 54%",
        bgScale: 1.1,
        speaker: "priest",
        audio: `${GUADALUPE_AUDIO_BASE}/guadalupe-bishop-candles.mp3`,
        line:
          "Before deciding, we pause by the candles and ask God to help us understand.",
        note:
          "The pause by the candles keeps the decision rooted in prayer, not only argument.",
        find: {
          label: "You noticed",
          title: "Prayer before judgment",
          text: "The candles make the room feel like a place for prayerful discernment, not a quick argument.",
        },
        actors: {
          juan: { x: 31, y: 86, scale: 2.12, mobileX: 30, mobileY: 84, mobileScale: 2, dir: "left" },
          priest: { x: 66, y: 87, scale: 2.22, mobileX: 68, mobileY: 84, mobileScale: 2, dir: "left" },
        },
      },
    ],
    goalSpots: ["door", "desk", "candles"],
  },
  {
    id: "roses",
    label: "Winter Roses",
    place: "Frosty Tepeyac",
    backdrop: `${GUADALUPE_ASSET_BASE}/scene-04-winter-roses-v2.png`,
    speaker: "mary",
    line:
      "Climb the hill and gather the roses. Carry them carefully in your tilma.",
    note:
      "The winter roses are remembered as the sign Juan Diego gathers before returning to the bishop.",
    actors: [
      { id: "juan", x: 45, y: 78, scale: 0.9, dir: "front", active: true, roses: true },
      { id: "mary", x: 69, y: 59, scale: 1.02, dir: "left", active: false },
    ],
  },
  {
    id: "tilma",
    label: "The Tilma Opens",
    place: "Mexico City, Dec. 12, 1531",
    backdrop: `${GUADALUPE_ASSET_BASE}/scene-05-tilma-sign-v2.png`,
    speaker: "priest",
    line:
      "The roses fall, and the tilma shows a sign that points us back to Mary's motherly care.",
    note:
      "Catholics remember the image on the tilma as the sign that confirmed the message.",
    actors: [
      { id: "juan", x: 43, y: 87, scale: 2.12, mobileX: 38, mobileY: 85, mobileScale: 2.12, dir: "front", active: true, roses: true },
      { id: "priest", x: 67, y: 85, scale: 2.24, mobileX: 70, mobileY: 83, mobileScale: 2.1, dir: "left", active: true },
    ],
    roomSpots: [
      {
        id: "roses",
        label: "Roses",
        x: 24,
        y: 61,
        mobileX: 22,
        mobileY: 66,
        bgPosition: "38% 52%",
        bgScale: 1.09,
        speaker: "juan",
        audio: `${GUADALUPE_AUDIO_BASE}/guadalupe-tilma-roses.mp3`,
        line:
          "I carried the roses carefully in my tilma, just as Mary asked.",
        note:
          "The roses are carried in the tilma, a simple cloak worn by Juan Diego.",
        find: {
          label: "You noticed",
          title: "Tilma cloak",
          text: "A tilma was a simple outer cloak. In the tradition, Juan Diego uses it to carry the roses.",
        },
        actors: {
          juan: { x: 39, y: 87, scale: 2.12, mobileX: 35, mobileY: 85, mobileScale: 2.08, dir: "front", roses: true },
          priest: { x: 66, y: 85, scale: 2.22, mobileX: 70, mobileY: 83, mobileScale: 2.08, dir: "left" },
        },
      },
      {
        id: "bishop",
        label: "Bishop",
        x: 65,
        y: 62,
        mobileX: 66,
        mobileY: 59,
        bgPosition: "58% 52%",
        bgScale: 1.1,
        speaker: "priest",
        audio: `${GUADALUPE_AUDIO_BASE}/guadalupe-tilma-bishop.mp3`,
        line:
          "Stand here, Juan Diego. Let us see the sign you were given.",
        note:
          "The bishop witnesses the roses before the tilma image is revealed.",
        find: {
          label: "You noticed",
          title: "A witnessed sign",
          text: "The bishop sees the roses first, then the tilma opens in the next moment.",
        },
        actors: {
          juan: { x: 48, y: 87, scale: 2.12, mobileX: 39, mobileY: 85, mobileScale: 2.1, dir: "front", roses: true },
          priest: { x: 69, y: 85, scale: 2.22, mobileX: 70, mobileY: 83, mobileScale: 2.1, dir: "left" },
        },
      },
      {
        id: "tilma",
        label: "Tilma",
        hidden: true,
        x: 63,
        y: 64,
        mobileX: 62,
        mobileY: 61,
        bgPosition: "72% 49%",
        bgScale: 1.12,
        speaker: "priest",
        audio: `${GUADALUPE_AUDIO_BASE}/guadalupe-tilma-sign.mp3`,
        line:
          "The tilma opens, the roses fall, and the image becomes the sign.",
        note:
          "The sign is remembered as both roses on the floor and an image on the cloth.",
        find: {
          label: "You noticed",
          title: "Roses and image",
          text: "Catholic tradition remembers both the Castilian roses and Mary's image on the tilma.",
        },
        actors: {
          juan: { x: 43, y: 87, scale: 2.9, mobileX: 36, mobileY: 85, mobileScale: 2.1, dir: "front", roses: true },
          priest: { x: 68, y: 85, scale: 2.78, mobileX: 70, mobileY: 83, mobileScale: 2.08, dir: "left" },
        },
      },
    ],
    goalSpots: ["roses", "bishop", "tilma"],
    tilmaRelic: {
      x: 80,
      y: 47,
      mobileX: 78,
      mobileY: 48,
    },
  },
  {
    id: "chapel",
    label: "A Place to Pray",
    place: "Tepeyac chapel",
    backdrop: `${GUADALUPE_ASSET_BASE}/scene-06-chapel-dawn-v2.png`,
    speaker: "juan",
    line:
      "The chapel rises on the hill, and everyone remembers: Mary leads us to Jesus.",
    note:
      "The requested chapel becomes a place of prayer on Tepeyac, pointing pilgrims to Jesus.",
    actors: [
      { id: "juan", x: 37, y: 78, scale: 0.88, dir: "right", active: true },
      { id: "mary", x: 67, y: 58, scale: 0.98, dir: "front", active: false },
      { id: "priest", x: 54, y: 79, scale: 0.82, dir: "front", active: false },
    ],
  },
  {
    id: "lourdes",
    label: "Lourdes Path",
    place: "Lourdes, 1858",
    backdrop: `${GUADALUPE_ASSET_BASE}/scene-07-lourdes-grotto-v1.png`,
    speaker: "mary",
    line:
      "Bernadette walks to the grotto and sees Mary in quiet prayer.",
    note:
      "In 1858, Bernadette Soubirous reported visits at Massabielle near Lourdes, France.",
    maryWaypoints: [
      { x: 69, y: 50, dir: "front" },
      { x: 72, y: 49, dir: "left" },
      { x: 66, y: 52, dir: "right" },
      { x: 69, y: 50, dir: "front" },
    ],
    actors: [
      { id: "juan", x: 31, y: 84, scale: 0.96, dir: "right", active: true },
      { id: "mary", x: 69, y: 50, scale: 0.98, dir: "front", active: true },
    ],
  },
  {
    id: "lourdes-ask",
    label: "The Spring Request",
    place: "Massabielle grotto",
    backdrop: `${GUADALUPE_ASSET_BASE}/scene-07-lourdes-grotto-v1.png`,
    speaker: "mary",
    speakerLabel: "Our Lady of Lourdes",
    line:
      "Go drink at the spring and wash there. God can bring help from a hidden place.",
    note:
      "Mary's request leads Bernadette to look for a spring that is not obvious yet.",
    maryWaypoints: [
      { x: 69, y: 50, dir: "front" },
      { x: 72, y: 49, dir: "left" },
      { x: 66, y: 52, dir: "right" },
      { x: 69, y: 50, dir: "front" },
    ],
    actors: [
      { id: "juan", x: 36, y: 84, scale: 0.98, dir: "right", active: true },
      { id: "mary", x: 69, y: 50, scale: 0.98, dir: "front", active: true },
    ],
  },
  {
    id: "lourdes-spring",
    label: "Find the Spring",
    place: "Lourdes grotto",
    backdrop: `${GUADALUPE_ASSET_BASE}/scene-07-lourdes-grotto-v1.png`,
    speaker: "mary",
    speakerLabel: "Our Lady of Lourdes",
    line:
      "Look by the rocks, touch the ground, and notice the water God gives.",
    note:
      "Bernadette digs in the grotto, and the spring begins to show.",
    actors: [
      { id: "juan", x: 32, y: 84, scale: 0.98, dir: "right", active: true },
      { id: "mary", x: 69, y: 50, scale: 0.98, dir: "front", active: true },
    ],
    roomSpots: [
      {
        id: "rocks",
        label: "Rocks",
        x: 35,
        y: 66,
        mobileX: 22,
        mobileY: 66,
        speaker: "mary",
        speakerLabel: "Our Lady of Lourdes",
        line: "The grotto rocks are rough, but God can work in simple places.",
        note: "The Lourdes story happens at a rocky grotto called Massabielle.",
        find: {
          label: "You noticed",
          title: "Rocky grotto",
          text: "A grotto is a cave-like place in rock. Bernadette prayed there.",
        },
        actors: {
          juan: { x: 34, y: 84, scale: 1.02, dir: "right" },
        },
      },
      {
        id: "ground",
        label: "Ground",
        x: 52,
        y: 74,
        mobileX: 51,
        mobileY: 72,
        speaker: "mary",
        speakerLabel: "Our Lady of Lourdes",
        line: "Dig gently here. The spring is hidden, but it will come.",
        note: "Bernadette follows the request even before the water is clear.",
        find: {
          label: "You noticed",
          title: "Hidden spring",
          text: "The spring starts small, then becomes a place where pilgrims pray.",
        },
        actors: {
          juan: { x: 48, y: 84, scale: 1.03, dir: "front" },
        },
      },
      {
        id: "water",
        label: "Water",
        x: 70,
        y: 70,
        mobileX: 78,
        mobileY: 68,
        speaker: "mary",
        speakerLabel: "Our Lady of Lourdes",
        line: "The water reminds pilgrims to pray, wash, and trust God's care.",
        note: "Lourdes became known as a place where people ask God for healing.",
        find: {
          label: "You noticed",
          title: "Healing prayers",
          text: "People still visit Lourdes to pray for healing and hope.",
        },
        actors: {
          juan: { x: 62, y: 83, scale: 1.02, dir: "right" },
        },
      },
    ],
    goalSpots: ["rocks", "ground", "water"],
  },
  {
    id: "lourdes-sign",
    label: "The Spring Flows",
    place: "Lourdes, 1858",
    backdrop: `${GUADALUPE_ASSET_BASE}/scene-07-lourdes-grotto-v1.png`,
    speaker: "mary",
    speakerLabel: "Our Lady of Lourdes",
    line:
      "The spring begins to flow, and people come to pray for healing and hope.",
    note:
      "The Lourdes spring is remembered as a sign that points people back to God.",
    actors: [
      { id: "juan", x: 34, y: 84, scale: 0.98, dir: "right", active: true },
      { id: "mary", x: 69, y: 50, scale: 0.98, dir: "front", active: true, apparition: true },
    ],
  },
  {
    id: "fatima",
    label: "Fatima Field Path",
    place: "Fatima, 1917",
    backdrop: `${GUADALUPE_ASSET_BASE}/scene-08-fatima-cova-v1.png`,
    speaker: "mary",
    line:
      "The children walk through the field and see Mary shining with peace.",
    note:
      "In 1917, Lucia, Francisco, and Jacinta reported Mary's visits at Cova da Iria in Portugal.",
    maryWaypoints: [
      { x: 59, y: 44, dir: "front" },
      { x: 62, y: 43, dir: "left" },
      { x: 56, y: 46, dir: "right" },
      { x: 59, y: 44, dir: "front" },
    ],
    actors: [
      { id: "juan", x: 24, y: 84, scale: 0.96, dir: "right", active: true },
      { id: "mary", x: 59, y: 44, scale: 1.02, dir: "front", active: true },
    ],
  },
  {
    id: "fatima-ask",
    label: "Pray for Peace",
    place: "Cova da Iria",
    backdrop: `${GUADALUPE_ASSET_BASE}/scene-08-fatima-cova-v1.png`,
    speaker: "mary",
    speakerLabel: "Our Lady of Fatima",
    line:
      "Pray the Rosary for peace, and stay close to Jesus with brave hearts.",
    note:
      "The Fatima message asks the children to pray for peace and trust God.",
    maryWaypoints: [
      { x: 59, y: 44, dir: "front" },
      { x: 62, y: 43, dir: "left" },
      { x: 56, y: 46, dir: "right" },
      { x: 59, y: 44, dir: "front" },
    ],
    actors: [
      { id: "juan", x: 30, y: 84, scale: 0.96, dir: "right", active: true },
      { id: "mary", x: 59, y: 44, scale: 1.02, dir: "front", active: true },
    ],
  },
  {
    id: "fatima-prayer",
    label: "Prayer in the Field",
    place: "Fatima field",
    backdrop: `${GUADALUPE_ASSET_BASE}/scene-08-fatima-cova-v1.png`,
    speaker: "mary",
    speakerLabel: "Our Lady of Fatima",
    line:
      "Notice the little oak, the beads, and the people waiting for peace.",
    note:
      "The children share Mary's message with people who gather in the field.",
    actors: [
      { id: "juan", x: 26, y: 84, scale: 0.96, dir: "right", active: true },
      { id: "mary", x: 59, y: 44, scale: 1.02, dir: "front", active: true },
    ],
    roomSpots: [
      {
        id: "oak",
        label: "Oak",
        x: 34,
        y: 66,
        mobileX: 21,
        mobileY: 66,
        speaker: "mary",
        speakerLabel: "Our Lady of Fatima",
        line: "The little oak marks the place where the children came to pray.",
        note: "The Fatima story is tied to a field and a small holm oak.",
        find: {
          label: "You noticed",
          title: "Little oak",
          text: "The children remembered the place and came back to pray.",
        },
        actors: {
          juan: { x: 34, y: 84, scale: 1, dir: "right" },
        },
      },
      {
        id: "rosary",
        label: "Rosary",
        x: 55,
        y: 73,
        mobileX: 50,
        mobileY: 72,
        speaker: "mary",
        speakerLabel: "Our Lady of Fatima",
        line: "The Rosary is a prayer children and families can say together.",
        note: "The Fatima message includes a call to pray the Rosary for peace.",
        find: {
          label: "You noticed",
          title: "Prayer beads",
          text: "Rosary beads help people walk through prayers with Mary and Jesus.",
        },
        actors: {
          juan: { x: 48, y: 84, scale: 1.01, dir: "front" },
        },
      },
      {
        id: "people",
        label: "People",
        x: 72,
        y: 68,
        mobileX: 79,
        mobileY: 67,
        speaker: "mary",
        speakerLabel: "Our Lady of Fatima",
        line: "People gather because they hope God will bring peace.",
        note: "Many people came to the field as the Fatima story spread.",
        find: {
          label: "You noticed",
          title: "A waiting crowd",
          text: "The crowd shows how a small prayer message can reach many people.",
        },
        actors: {
          juan: { x: 62, y: 83, scale: 1, dir: "right" },
        },
      },
    ],
    goalSpots: ["oak", "rosary", "people"],
  },
  {
    id: "fatima-sun",
    label: "The Sun Dances",
    place: "Fatima, Oct. 13, 1917",
    backdrop: `${GUADALUPE_ASSET_BASE}/scene-08-fatima-cova-v1.png`,
    speaker: "mary",
    speakerLabel: "Our Lady of Fatima",
    line:
      "The sun seems to dance in the sky, and the people remember Mary's call to pray.",
    note:
      "Catholics remember October 13, 1917, as the day of the Fatima sun sign.",
    actors: [
      { id: "juan", x: 32, y: 84, scale: 0.96, dir: "right", active: true },
      { id: "mary", x: 59, y: 44, scale: 1.02, dir: "front", active: true, apparition: true },
    ],
  },
];

const GAME_CARDS = [
  {
    id: "build",
    icon: "✺",
    title: "Build the Monstrance",
    tag: "drag + build",
    blurb: "Drag the base, stem, rays, and glass into place, then see the Host glow.",
  },
  {
    id: "memory",
    icon: "♡",
    title: "Holy Match",
    tag: "working memory",
    blurb: "Find picture-and-meaning pairs with calm feedback and no timer.",
  },
  {
    id: "light",
    icon: "☀",
    title: "Quiet Light",
    tag: "self-regulation",
    blurb: "Light six rays by tapping a short prayer sequence in order.",
  },
  {
    id: "guadalupe",
    icon: "🌹",
    title: "Marian Apparitions",
    tag: "learning quest",
    blurb: "Explore Guadalupe, Lourdes, and Fatima as Mary keeps leading people to Jesus.",
  },
  {
    id: "shepherd",
    icon: "♱",
    title: "Gather the Sheep",
    tag: "navigation + care",
    blurb: "Move Jesus through the pasture and bring every sheep safely home.",
  },
  {
    id: "bread",
    icon: "◌",
    title: "Bread from Heaven",
    tag: "timing + generosity",
    blurb: "Help Jesus gather bread and fish so everyone can be fed.",
  },
  {
    id: "families",
    icon: "🧺",
    title: "Word Families",
    tag: "drag + sort",
    blurb: "Sort holy words, saint friends, and church things into the right baskets.",
  },
];

const WORD_FAMILIES = [
  {
    id: "holy",
    title: "Holy Words",
    icon: "✦",
    hint: "Words we use for God, prayer, and sacred things.",
    success: "These words help us speak with reverence.",
  },
  {
    id: "saints",
    title: "Saints & Helpers",
    icon: "☼",
    hint: "People and heavenly friends who help us love Jesus.",
    success: "Saints and helpers show us how to follow Jesus.",
  },
  {
    id: "things",
    title: "Church Things",
    icon: "◌",
    hint: "Objects you might see at Mass, Adoration, or prayer time.",
    success: "These are things Catholics use to worship and pray.",
  },
];

const WORD_FAMILY_TOKENS = [
  { id: "holy", label: "Holy", family: "holy", sprite: 0, teach: "Holy means set apart for God." },
  { id: "blessed", label: "Blessed", family: "holy", sprite: 1, teach: "Blessed means touched by God's goodness." },
  { id: "amen", label: "Amen", family: "holy", sprite: 2, teach: "Amen means yes, I believe." },
  { id: "adoration", label: "Adoration", family: "holy", sprite: 3, teach: "Adoration is quiet love for Jesus." },
  { id: "mary", label: "Mary", family: "saints", sprite: 4, teach: "Mary is Jesus' mother and our mother too." },
  { id: "saints", label: "Saints", family: "saints", sprite: 5, teach: "Saints are friends of God in heaven." },
  { id: "angel", label: "Angel", family: "saints", sprite: 6, teach: "Angels are God's messengers and helpers." },
  { id: "rosary", label: "Rosary", family: "things", sprite: 7, teach: "A rosary helps us pray with Mary." },
  { id: "monstrance", label: "Monstrance", family: "things", sprite: 8, teach: "A monstrance shows Jesus in the Eucharist." },
  { id: "host", label: "Host", family: "things", sprite: 9, teach: "The Host is Jesus in the Eucharist." },
  { id: "cross", label: "Cross", family: "things", sprite: 10, teach: "The cross reminds us Jesus loves us." },
  { id: "bread", label: "Bread", family: "things", sprite: 11, teach: "At Mass, bread becomes Jesus in the Eucharist." },
];

const ARCADE_GAME_LINKS = [
  {
    icon: "🐑",
    title: "The Good Shepherd",
    tag: "sheep herding",
    href: "games/good-shepherd/",
    blurb: "Guide Jesus through the pasture and gather every lamb safely home.",
  },
  {
    icon: "🥖",
    title: "Galilee Picnic Dash",
    tag: "collect + share",
    href: "games/galilee-picnic/",
    blurb: "Gather loaves and fish, then deliver them to picnic blankets.",
  },
  {
    icon: "🎣",
    title: "Fishers of Galilee",
    tag: "casting timing",
    href: "games/fishers-of-galilee/",
    blurb: "Steer the boat, cast your line, avoid weeds, and fill the baskets.",
  },
  {
    icon: "🧺",
    title: "Manna Corridor Dash",
    tag: "lane runner",
    href: "games/manna-corridor/",
    blurb:
      "Move lanes as gifts rush toward the basket down a Galilee corridor.",
  },
  {
    icon: "👼",
    title: "Guardian Angel Flight",
    tag: "flight + escort",
    href: "games/guardian-angel-flight/",
    blurb: "Fly through clouds, collect prayer stars, and guide hearts home.",
  },
  {
    icon: "📿",
    title: "Rosary Garden",
    tag: "explore + bloom",
    href: "games/rosary-garden/",
    blurb:
      "Find the golden bead in each mystery to bloom the garden, decade by decade.",
  },
  {
    icon: "🌹",
    title: "Mary Visits",
    tag: "Marian quest",
    href: "#/games/guadalupe",
    blurb:
      "Walk through Guadalupe, Lourdes, and Fatima while Mary keeps pointing the story back to Jesus.",
  },
  {
    icon: "🪟",
    title: "Saint Window Workshop",
    tag: "matching virtues",
    href: "games/saint-window/",
    blurb:
      "Restore stained-glass windows by pairing each saint with the loving action they lived.",
  },
];

function GameProgress({ value, total }) {
  return (
    <div className="mini-progress" aria-label={`${value} of ${total} complete`}>
      {Array.from({ length: total }).map((_, i) => (
        <span key={i} className={i < value ? "is-on" : ""} />
      ))}
    </div>
  );
}

function WordFamilySprite({ token, small = false }) {
  const col = token.sprite % 4;
  const row = Math.floor(token.sprite / 4);
  return (
    <span
      className={`word-sprite ${small ? "is-small" : ""}`}
      style={{
        backgroundPosition: `${col * 33.333333}% ${row * 50}%`,
      }}
      aria-hidden="true"
    />
  );
}

function MonstranceBuilderGame() {
  const allPieceIds = MONSTRANCE_BUILD_PIECES.map((piece) => piece.id);
  const storedComplete = () => {
    try {
      return sessionStorage.getItem(MONSTRANCE_BUILD_STORAGE_KEY) === "true";
    } catch (e) {
      return false;
    }
  };
  const [placed, setPlaced] = useState(() =>
    storedComplete() ? allPieceIds : [],
  );
  const [selected, setSelected] = useState(null);
  const [drag, setDrag] = useState(null);
  const [bouncing, setBouncing] = useState(null);
  const [confirmReset, setConfirmReset] = useState(false);
  const [feedback, setFeedback] = useState(() =>
    storedComplete()
      ? "The monstrance is built. The Host is shown for prayer."
      : "Drag the base to the glowing bottom space.",
  );
  const playfieldRef = useRef(null);
  const slotRefs = useRef({});
  const bounceTimerRef = useRef(null);
  const audioRef = useRef(null);
  const nextPiece = MONSTRANCE_BUILD_PIECES[placed.length];
  const done = placed.length === MONSTRANCE_BUILD_PIECES.length;

  useEffect(() => {
    try {
      if (done) {
        sessionStorage.setItem(MONSTRANCE_BUILD_STORAGE_KEY, "true");
      } else {
        sessionStorage.removeItem(MONSTRANCE_BUILD_STORAGE_KEY);
      }
    } catch (e) {}
  }, [done]);

  useEffect(
    () => () => {
      if (bounceTimerRef.current) window.clearTimeout(bounceTimerRef.current);
    },
    [],
  );

  const pieceById = (id) =>
    MONSTRANCE_BUILD_PIECES.find((piece) => piece.id === id);

  const playCompletionLine = () => {
    const audio = audioRef.current;
    if (!audio) return;
    audio.currentTime = 0;
    audio.play().catch(() => {
      setFeedback("The monstrance is built. Tap Listen to hear the prayer line.");
    });
  };

  const showBounce = (pieceId, message) => {
    setBouncing(pieceId);
    setFeedback(message);
    if (bounceTimerRef.current) window.clearTimeout(bounceTimerRef.current);
    bounceTimerRef.current = window.setTimeout(() => setBouncing(null), 520);
  };

  const slotAtPoint = (clientX, clientY, draggedId) => {
    const matches = MONSTRANCE_BUILD_PIECES.map((piece) => {
      const node = slotRefs.current[piece.id];
      if (!node) return null;
      const rect = node.getBoundingClientRect();
      const inside =
        clientX >= rect.left - MONSTRANCE_BUILD_TOLERANCE &&
        clientX <= rect.right + MONSTRANCE_BUILD_TOLERANCE &&
        clientY >= rect.top - MONSTRANCE_BUILD_TOLERANCE &&
        clientY <= rect.bottom + MONSTRANCE_BUILD_TOLERANCE;
      if (!inside) return null;
      return {
        id: piece.id,
        area: rect.width * rect.height,
      };
    }).filter(Boolean);
    const exact = matches.find((match) => match.id === draggedId);
    if (exact) return exact.id;
    return matches.sort((a, b) => a.area - b.area)[0]?.id ?? null;
  };

  const tryPlace = (pieceId, slotId) => {
    const piece = pieceById(pieceId);
    if (!piece || placed.includes(pieceId) || done) return;
    if (nextPiece && pieceId === nextPiece.id && slotId === pieceId) {
      const nextPlaced = [...placed, pieceId];
      const finished = nextPlaced.length === MONSTRANCE_BUILD_PIECES.length;
      setPlaced(nextPlaced);
      setSelected(null);
      setFeedback(
        finished
          ? "Now the Host is shown. We adore Jesus, truly present."
          : piece.teach,
      );
      if (finished) {
        window.setTimeout(playCompletionLine, 120);
      }
      return;
    }
    showBounce(pieceId, nextPiece ? `The piece floats back. ${nextPiece.clue}` : "The piece floats back to the shelf.");
  };

  const beginDrag = (event, pieceId) => {
    if (placed.includes(pieceId) || done) return;
    const rect = playfieldRef.current?.getBoundingClientRect();
    if (!rect) return;
    event.preventDefault();
    event.currentTarget.setPointerCapture?.(event.pointerId);
    setSelected(pieceId);
    setDrag({
      id: pieceId,
      pointerId: event.pointerId,
      x: event.clientX - rect.left,
      y: event.clientY - rect.top,
    });
  };

  const moveDrag = (event) => {
    if (!drag || drag.pointerId !== event.pointerId) return;
    const rect = playfieldRef.current?.getBoundingClientRect();
    if (!rect) return;
    event.preventDefault();
    setDrag((value) =>
      value
        ? {
            ...value,
            x: event.clientX - rect.left,
            y: event.clientY - rect.top,
          }
        : value,
    );
  };

  const endDrag = (event) => {
    if (!drag || drag.pointerId !== event.pointerId) return;
    event.preventDefault();
    event.currentTarget.releasePointerCapture?.(event.pointerId);
    const slotId = slotAtPoint(event.clientX, event.clientY, drag.id);
    if (slotId) {
      tryPlace(drag.id, slotId);
    } else {
      showBounce(drag.id, nextPiece ? `Bring it close to the glowing space. ${nextPiece.clue}` : "Bring it close to the glowing space.");
    }
    setDrag(null);
  };

  const cancelDrag = () => {
    if (drag) showBounce(drag.id, "The piece floats back to the shelf.");
    setDrag(null);
  };

  const reset = () => {
    setPlaced([]);
    setSelected(null);
    setDrag(null);
    setBouncing(null);
    setConfirmReset(false);
    setFeedback("Drag the base to the glowing bottom space.");
    try {
      sessionStorage.removeItem(MONSTRANCE_BUILD_STORAGE_KEY);
    } catch (e) {}
    if (audioRef.current) {
      audioRef.current.pause();
      audioRef.current.currentTime = 0;
    }
  };

  return (
    <article className="game-card monstrance-build-card" data-testid="monstrance-build-game">
      <div className="game-card-head">
        <div>
          <h2 className="game-card-title">Build the Monstrance</h2>
          <p className="game-card-copy">Drag each piece to its glowing place. No timer, no score, just careful hands.</p>
        </div>
        <div className="game-badges">
          <span className="game-badge">Ages 4-8</span>
          <span className="game-badge">{placed.length}/4</span>
        </div>
      </div>
      <GameProgress value={placed.length} total={MONSTRANCE_BUILD_PIECES.length} />
      <div className="game-area monstrance-build-board">
        <div
          className={`monstrance-build-playfield ${done ? "is-complete" : ""}`}
          data-testid="monstrance-build-stage"
          ref={playfieldRef}
          style={{
            "--build-tolerance": `${MONSTRANCE_BUILD_TOLERANCE}px`,
          }}
        >
          <div className="monstrance-build-backdrop" aria-hidden="true" />
          {MONSTRANCE_BUILD_PIECES.map((piece, index) => {
            const filled = placed.includes(piece.id);
            const isNext = nextPiece?.id === piece.id;
            return (
              <button
                key={piece.id}
                type="button"
                ref={(node) => {
                  slotRefs.current[piece.id] = node;
                }}
                className={`monstrance-build-slot ${filled ? "is-filled" : ""} ${isNext ? "is-next" : ""}`}
                data-testid={`monstrance-slot-${piece.id}`}
                style={{
                  left: `${piece.slot.x}%`,
                  top: `${piece.slot.y}%`,
                  width: `${piece.slot.w}%`,
                  height: `${piece.slot.h}%`,
                  zIndex: index + 1,
                }}
                onClick={() => selected && tryPlace(selected, piece.id)}
                aria-label={`${piece.label} slot`}
              >
                <span>{isNext && !filled ? piece.clue : ""}</span>
              </button>
            );
          })}
          <div className="monstrance-build-art" aria-hidden="true">
            {MONSTRANCE_BUILD_PIECES.map((piece, index) =>
              placed.includes(piece.id) ? (
                <img
                  key={piece.id}
                  src={piece.asset}
                  alt=""
                  draggable="false"
                  className={`monstrance-build-piece is-${piece.id}`}
                  style={{
                    left: `${piece.art.x}%`,
                    top: `${piece.art.y}%`,
                    width: `${piece.art.w}%`,
                    zIndex: index + 4,
                  }}
                />
              ) : null,
            )}
            {done && <span className="monstrance-build-host" />}
            {done && (
              <span className="monstrance-build-sparkles">
                <span />
                <span />
                <span />
                <span />
                <span />
              </span>
            )}
          </div>
          {drag && (
            <img
              src={pieceById(drag.id)?.asset}
              alt=""
              draggable="false"
              className={`monstrance-build-drag is-${drag.id}`}
              style={{
                left: `${drag.x}px`,
                top: `${drag.y}px`,
              }}
            />
          )}
          {done && (
            <button
              type="button"
              className="monstrance-build-reset"
              data-testid="monstrance-build-reset"
              onClick={() => setConfirmReset(true)}
            >
              Start over
            </button>
          )}
        </div>
        <div className="monstrance-build-shelf" aria-label="Monstrance pieces">
          {MONSTRANCE_BUILD_PIECES.map((piece) => (
            <button
              key={piece.id}
              type="button"
              data-testid={`monstrance-piece-${piece.id}`}
              className={`monstrance-build-shelf-piece ${selected === piece.id ? "is-selected" : ""} ${nextPiece?.id === piece.id ? "is-next" : ""} ${bouncing === piece.id ? "is-bouncing" : ""}`}
              onPointerDown={(event) => beginDrag(event, piece.id)}
              onPointerMove={moveDrag}
              onPointerUp={endDrag}
              onPointerCancel={cancelDrag}
              disabled={placed.includes(piece.id) || done}
            >
              <span className="monstrance-build-thumb" aria-hidden="true">
                <img src={piece.asset} alt="" draggable="false" />
              </span>
              <span><strong>{piece.label}</strong><span>{piece.clue}</span></span>
            </button>
          ))}
        </div>
      </div>
      <div className="game-feedback" aria-live="polite">{feedback}</div>
      <div className="game-actions">
        {done ? (
          <button className="btn btn-secondary" onClick={playCompletionLine}>Listen</button>
        ) : (
          <span className="game-card-copy">Next: {nextPiece?.clue}</span>
        )}
        <span className="game-card-copy">Grown-up bridge: point to these parts on the big monstrance above.</span>
      </div>
      <audio ref={audioRef} preload="auto" src={MONSTRANCE_BUILD_AUDIO} />
      {confirmReset && (
        <div className="monstrance-build-confirm" role="dialog" aria-modal="true" aria-labelledby="monstrance-reset-title">
          <div className="monstrance-build-confirm-card">
            <h3 id="monstrance-reset-title">Start over?</h3>
            <p>The finished monstrance will return to the shelf pieces.</p>
            <div className="guadalupe-controls">
              <button className="btn btn-secondary" onClick={() => setConfirmReset(false)}>Keep building</button>
              <button className="btn btn-primary" onClick={reset}>Start over</button>
            </div>
          </div>
        </div>
      )}
    </article>
  );
}

function makeMemoryDeck() {
  const cards = MEMORY_PAIRS.flatMap((pair) => [
    { key: `${pair.id}-symbol`, pair: pair.id, symbol: pair.symbol, label: pair.label },
    { key: `${pair.id}-mate`, pair: pair.id, symbol: "?", label: pair.mate },
  ]);
  for (let i = cards.length - 1; i > 0; i--) {
    const j = Math.floor(Math.random() * (i + 1));
    [cards[i], cards[j]] = [cards[j], cards[i]];
  }
  return cards;
}

function MemoryGame() {
  const [deck, setDeck] = useState(makeMemoryDeck);
  const [open, setOpen] = useState([]);
  const [matched, setMatched] = useState([]);
  const [wrong, setWrong] = useState([]);
  const [feedback, setFeedback] = useState("Open two cards. A picture matches what it means.");
  const done = matched.length === MEMORY_PAIRS.length;
  const reset = () => {
    setDeck(makeMemoryDeck());
    setOpen([]);
    setMatched([]);
    setWrong([]);
    setFeedback("Open two cards. A picture matches what it means.");
  };
  const tap = (index) => {
    const card = deck[index];
    if (open.includes(index) || matched.includes(card.pair) || open.length === 2) return;
    const nextOpen = [...open, index];
    setOpen(nextOpen);
    if (nextOpen.length === 2) {
      const [a, b] = nextOpen.map((i) => deck[i]);
      if (a.pair === b.pair) {
        const pair = MEMORY_PAIRS.find((p) => p.id === a.pair);
        setMatched([...matched, a.pair]);
        setFeedback(`${pair.label}: ${pair.mate}.`);
        setTimeout(() => setOpen([]), 420);
      } else {
        setWrong(nextOpen);
        setFeedback("Not the pair yet. Look, remember, and try again.");
        setTimeout(() => {
          setOpen([]);
          setWrong([]);
        }, 760);
      }
    }
  };
  return (
    <article className="game-card">
      <div className="game-card-head">
        <div>
          <h2 className="game-card-title">Holy Match</h2>
          <p className="game-card-copy">A gentle memory game that pairs concrete symbols with simple meanings.</p>
        </div>
        <div className="game-badges">
          <span className="game-badge">No timer</span>
          <span className="game-badge">{matched.length}/4</span>
        </div>
      </div>
      <GameProgress value={matched.length} total={MEMORY_PAIRS.length} />
      <div className="memory-grid" aria-label="Memory cards">
        {deck.map((card, i) => {
          const isOpen = open.includes(i) || matched.includes(card.pair);
          return (
            <button
              key={card.key}
              className={`memory-card ${isOpen ? "is-open" : ""} ${matched.includes(card.pair) ? "is-done" : ""} ${wrong.includes(i) ? "is-wrong" : ""}`}
              onClick={() => tap(i)}
              disabled={matched.includes(card.pair)}
            >
              {isOpen ? (
                <span><span className="memory-symbol" aria-hidden="true">{card.symbol}</span>{card.label}</span>
              ) : (
                <span><span className="memory-symbol" aria-hidden="true">✦</span>Tap</span>
              )}
            </button>
          );
        })}
      </div>
      <div className="game-feedback" aria-live="polite">{done ? "You matched every meaning. Pick one pair and tell a grown-up about it." : feedback}</div>
      <div className="game-actions">
        <button className="btn btn-secondary" onClick={reset}>Shuffle</button>
        <span className="game-card-copy">Grown-up bridge: name one symbol, then find it at church or in a picture book.</span>
      </div>
    </article>
  );
}

function QuietLightGame() {
  const [lit, setLit] = useState([]);
  const [wrong, setWrong] = useState(null);
  const [feedback, setFeedback] = useState("Tap the prayer rays in order. Slow hands, quiet heart.");
  const next = lit.length;
  const done = lit.length === LIGHT_PRAYERS.length;
  const tap = (id, index) => {
    if (lit.includes(id) || done) return;
    if (index === next) {
      setLit([...lit, id]);
      setFeedback(LIGHT_PRAYERS[index].teach);
      setWrong(null);
    } else {
      setWrong(id);
      setFeedback(`Try the next ray: ${LIGHT_PRAYERS[next].label}.`);
      setTimeout(() => setWrong(null), 520);
    }
  };
  const reset = () => {
    setLit([]);
    setWrong(null);
    setFeedback("Tap the prayer rays in order. Slow hands, quiet heart.");
  };
  return (
    <article className="game-card">
      <div className="game-card-head">
        <div>
          <h2 className="game-card-title">Quiet Light</h2>
          <p className="game-card-copy">A self-regulation loop: pause, choose the next short prayer, and watch the light grow.</p>
        </div>
        <div className="game-badges">
          <span className="game-badge">Calm play</span>
          <span className="game-badge">{lit.length}/6</span>
        </div>
      </div>
      <GameProgress value={lit.length} total={LIGHT_PRAYERS.length} />
      <div className="light-grid" aria-label="Prayer rays">
        {LIGHT_PRAYERS.map((ray, i) => (
          <button
            key={ray.id}
            className={`light-ray ${lit.includes(ray.id) ? "is-lit" : ""} ${wrong === ray.id ? "is-wrong" : ""}`}
            onClick={() => tap(ray.id, i)}
            disabled={lit.includes(ray.id)}
          >
            <span className="light-ray-number">{i + 1}</span>
            <strong>{ray.label}</strong>
          </button>
        ))}
      </div>
      <div className="game-feedback" aria-live="polite">{done ? "The whole prayer is lit. Take one quiet breath and say Amen." : feedback}</div>
      <div className="game-actions">
        <button className="btn btn-secondary" onClick={reset}>Dim the rays</button>
        <span className="game-card-copy">Offline bridge: say the same six tiny prayers with fingers instead of a screen.</span>
      </div>
    </article>
  );
}

const guadalupeSheetPosition = (col, row, cols, rows) =>
  `${cols <= 1 ? 0 : (col / (cols - 1)) * 100}% ${rows <= 1 ? 0 : (row / (rows - 1)) * 100}%`;

const GUADALUPE_PLAYER_STARTS = {
  tepeyac: { x: 25, y: 82, scale: 0.98, dir: "right" },
  message: { x: 31, y: 82, scale: 0.94, dir: "right" },
  bishop: { x: 55, y: 66, scale: 1.5, mobileX: 55, mobileY: 68, mobileScale: 1.36, dir: "front" },
  roses: { x: 28, y: 83, scale: 0.98, dir: "right", roses: true },
  tilma: { x: 37, y: 87, scale: 2.84, dir: "front", roses: true },
  chapel: { x: 29, y: 82, scale: 0.92, dir: "right" },
  lourdes: { x: 31, y: 84, scale: 0.96, dir: "right" },
  "lourdes-ask": { x: 36, y: 84, scale: 0.98, dir: "right" },
  "lourdes-spring": { x: 32, y: 84, scale: 0.98, dir: "right" },
  "lourdes-sign": { x: 34, y: 84, scale: 0.98, dir: "right" },
  fatima: { x: 24, y: 84, scale: 0.96, dir: "right" },
  "fatima-ask": { x: 30, y: 84, scale: 0.96, dir: "right" },
  "fatima-prayer": { x: 26, y: 84, scale: 0.96, dir: "right" },
  "fatima-sun": { x: 32, y: 84, scale: 0.96, dir: "right" },
};
const GUADALUPE_OBJECTIVES = {
  tepeyac: "Walk Tepeyac's path and listen for Mary's call.",
  message: "Stay near Mary long enough to receive the chapel mission.",
  bishop: "Enter the room, visit the desk, then pause by the candles.",
  roses: "Climb the cold hill and gather roses for Juan's tilma.",
  tilma: "Bring the roses to the bishop, then inspect the tilma sign.",
  chapel: "Walk to the chapel site where the prayer request takes shape.",
  lourdes: "Follow the grotto path to Bernadette's prayer place.",
  "lourdes-ask": "Walk close to Mary and listen for the spring request.",
  "lourdes-spring": "Tap the rocks, ground, and water to discover the hidden spring.",
  "lourdes-sign": "Walk to the spring and notice the water flowing.",
  fatima: "Walk toward the oak field and listen for Mary's peace message.",
  "fatima-ask": "Walk close to Mary and listen for the peace prayer.",
  "fatima-prayer": "Tap the oak, Rosary, and people to discover the prayer message.",
  "fatima-sun": "Walk under the sky and notice the sun sign.",
};
const GUADALUPE_SCENE_TOKENS = {
  tepeyac: { mark: "CALL", label: "Mary's call" },
  message: { mark: "ASK", label: "Chapel request" },
  bishop: { mark: "ROOM", label: "Bishop's room" },
  roses: { mark: "ROSE", label: "Winter roses" },
  tilma: { mark: "SIGN", label: "Tilma sign" },
  chapel: { mark: "PRAY", label: "Prayer place" },
  lourdes: { mark: "GROTTO", label: "Lourdes grotto" },
  "lourdes-ask": { mark: "ASK", label: "Spring request" },
  "lourdes-spring": { mark: "TASK", label: "Hidden spring" },
  "lourdes-sign": { mark: "WATER", label: "Spring flows" },
  fatima: { mark: "PEACE", label: "Fatima peace" },
  "fatima-ask": { mark: "ASK", label: "Peace request" },
  "fatima-prayer": { mark: "PRAY", label: "Prayer field" },
  "fatima-sun": { mark: "SUN", label: "Sun sign" },
};
const GUADALUPE_DISCOVERIES = {
  tepeyac: {
    target: { x: 71, y: 58 },
    radius: 7,
    speaker: "mary",
    audio: `${GUADALUPE_AUDIO_BASE}/guadalupe-tepeyac.mp3`,
    line:
      "Juanito, my little son, do not be afraid. I am your mother, and I bring comfort from God.",
    note:
      "December 9, 1531: the Guadalupe story begins with Juan Diego on Tepeyac Hill.",
    revealMary: true,
  },
  message: {
    target: { x: 63, y: 59 },
    radius: 8,
    speaker: "mary",
    audio: `${GUADALUPE_AUDIO_BASE}/guadalupe-message.mp3`,
    line:
      "Ask the bishop for a little house of prayer here, where people can come to my Son.",
    note:
      "The message becomes a task: Juan Diego must carry the chapel request to the bishop.",
  },
  roses: {
    target: { x: 58, y: 72 },
    radius: 10,
    speaker: "mary",
    audio: `${GUADALUPE_AUDIO_BASE}/guadalupe-roses.mp3`,
    line:
      "Gather the roses blooming on the cold hill. Carry them carefully in your tilma.",
    note:
      "The winter roses become the sign Juan Diego carries back in his tilma.",
  },
  chapel: {
    target: { x: 58, y: 76 },
    radius: 10,
    speaker: "juan",
    audio: `${GUADALUPE_AUDIO_BASE}/guadalupe-chapel.mp3`,
    line:
      "The chapel rises on the hill, and everyone remembers: Mary leads us to Jesus.",
    note:
      "The chapel request points the story forward to prayer, comfort, and love for Jesus.",
  },
  lourdes: {
    target: { x: 69, y: 50 },
    radius: 8,
    speaker: "mary",
    speakerLabel: "Our Lady of Lourdes",
    audio: `${GUADALUPE_AUDIO_BASE}/guadalupe-lourdes.mp3`,
    line:
      "I am here to help you pray and trust God.",
    note:
      "Lourdes is remembered as a place of prayer where many people ask God for healing.",
    revealMary: true,
  },
  "lourdes-ask": {
    target: { x: 69, y: 50 },
    radius: 8,
    speaker: "mary",
    speakerLabel: "Our Lady of Lourdes",
    audio: `${GUADALUPE_APPARITION_AUDIO_BASE}/lourdes-ask.mp3`,
    line:
      "Go drink at the spring and wash there. God can bring help from a hidden place.",
    note:
      "Mary's request gives Bernadette a simple action at the grotto.",
  },
  "lourdes-sign": {
    target: { x: 52, y: 74 },
    radius: 10,
    speaker: "mary",
    speakerLabel: "Our Lady of Lourdes",
    audio: `${GUADALUPE_APPARITION_AUDIO_BASE}/lourdes-sign.mp3`,
    line:
      "The spring begins to flow, and people come to pray for healing and hope.",
    note:
      "The Lourdes spring is remembered as a sign that points people back to God.",
  },
  fatima: {
    target: { x: 59, y: 44 },
    radius: 8,
    speaker: "mary",
    speakerLabel: "Our Lady of Fatima",
    audio: `${GUADALUPE_AUDIO_BASE}/guadalupe-fatima.mp3`,
    line:
      "Pray for peace, and stay close to Jesus.",
    note:
      "At Fatima, the children shared Mary's call to pray the Rosary and trust God.",
    revealMary: true,
  },
  "fatima-ask": {
    target: { x: 59, y: 44 },
    radius: 8,
    speaker: "mary",
    speakerLabel: "Our Lady of Fatima",
    audio: `${GUADALUPE_APPARITION_AUDIO_BASE}/fatima-ask.mp3`,
    line:
      "Pray the Rosary for peace, and stay close to Jesus with brave hearts.",
    note:
      "The Fatima message asks the children to pray for peace and trust God.",
  },
  "fatima-sun": {
    target: { x: 59, y: 44 },
    radius: 10,
    speaker: "mary",
    speakerLabel: "Our Lady of Fatima",
    audio: `${GUADALUPE_APPARITION_AUDIO_BASE}/fatima-sun.mp3`,
    line:
      "The sun seems to dance in the sky, and the people remember Mary's call to pray.",
    note:
      "Catholics remember October 13, 1917, as the day of the Fatima sun sign.",
  },
};
const GUADALUPE_SCENE_D_SIGNS = {
  tilma: {
    type: "tilma-drag",
    date: "December 12, 1531",
    prompt: "Drag the tilma corner downward to reveal the image.",
    caption: "The roses fall, and the image on the tilma confirms the chapel request.",
    dragDistance: 78,
  },
  "lourdes-sign": {
    type: "ground-taps",
    target: { x: 52, y: 74, mobileX: 51, mobileY: 72 },
    requiredTaps: 3,
    date: "February 11, 1858",
    prompt: "Tap the ground three times where Bernadette dug.",
    caption: "The spring flows at Lourdes and pilgrims come to pray.",
  },
  "fatima-sun": {
    type: "sky-drag",
    target: { x: 77, y: 24, mobileX: 83, mobileY: 30 },
    date: "October 13, 1917",
    prompt: "Drag the sun across the sky to make it whirl.",
    caption: "The sun sign helps people remember Mary's call to prayer.",
    dragDistance: 84,
  },
};
const GUADALUPE_MARY_WAYPOINTS = [
  { x: 72, y: 56, dir: "front" },
  { x: 76, y: 53, dir: "left" },
  { x: 66, y: 60, dir: "right" },
  { x: 70, y: 57, dir: "front" },
];
const GUADALUPE_EMPTY_MOVE = { up: false, down: false, left: false, right: false };
const GUADALUPE_DEPTH_PROFILES = {
  tepeyac: { farY: 52, nearY: 86, min: 0.94, max: 1.02, mobileMin: 0.94, mobileMax: 1.02 },
  message: { farY: 54, nearY: 86, min: 0.9, max: 1, mobileMin: 0.9, mobileMax: 1 },
  roses: { farY: 56, nearY: 86, min: 0.92, max: 1.02, mobileMin: 0.92, mobileMax: 1.02 },
  chapel: { farY: 58, nearY: 86, min: 0.88, max: 0.98, mobileMin: 0.88, mobileMax: 0.98 },
  lourdes: { farY: 50, nearY: 88, min: 0.92, max: 1.06, mobileMin: 0.92, mobileMax: 1.06 },
  fatima: { farY: 45, nearY: 88, min: 0.9, max: 1.04, mobileMin: 0.9, mobileMax: 1.04 },
  bishop: { farY: 64, nearY: 89, min: 1.46, max: 2.6, mobileMin: 1.34, mobileMax: 2.0 },
  tilma: { farY: 78, nearY: 89, min: 2.1, max: 2.9, mobileMin: 1.8, mobileMax: 2.12 },
};
Object.assign(GUADALUPE_DEPTH_PROFILES, {
  "lourdes-ask": GUADALUPE_DEPTH_PROFILES.lourdes,
  "lourdes-spring": GUADALUPE_DEPTH_PROFILES.lourdes,
  "lourdes-sign": GUADALUPE_DEPTH_PROFILES.lourdes,
  "fatima-ask": GUADALUPE_DEPTH_PROFILES.fatima,
  "fatima-prayer": GUADALUPE_DEPTH_PROFILES.fatima,
  "fatima-sun": GUADALUPE_DEPTH_PROFILES.fatima,
});
const GUADALUPE_WALKABLE_LANES = {
  tepeyac: [
    { x: 14, minY: 82, maxY: 89 },
    { x: 28, minY: 77, maxY: 89 },
    { x: 44, minY: 72, maxY: 89 },
    { x: 58, minY: 64, maxY: 87 },
    { x: 68, minY: 58, maxY: 84 },
    { x: 76, minY: 55, maxY: 81 },
    { x: 86, minY: 58, maxY: 78 },
  ],
  lourdes: [
    { x: 14, minY: 82, maxY: 89 },
    { x: 30, minY: 76, maxY: 89 },
    { x: 46, minY: 67, maxY: 88 },
    { x: 60, minY: 56, maxY: 84 },
    { x: 72, minY: 49, maxY: 78 },
    { x: 86, minY: 52, maxY: 76 },
  ],
  fatima: [
    { x: 14, minY: 80, maxY: 89 },
    { x: 30, minY: 75, maxY: 89 },
    { x: 46, minY: 64, maxY: 87 },
    { x: 60, minY: 44, maxY: 82 },
    { x: 74, minY: 49, maxY: 79 },
    { x: 86, minY: 56, maxY: 80 },
  ],
};
Object.assign(GUADALUPE_WALKABLE_LANES, {
  "lourdes-ask": GUADALUPE_WALKABLE_LANES.lourdes,
  "lourdes-spring": GUADALUPE_WALKABLE_LANES.lourdes,
  "lourdes-sign": GUADALUPE_WALKABLE_LANES.lourdes,
  "fatima-ask": GUADALUPE_WALKABLE_LANES.fatima,
  "fatima-prayer": GUADALUPE_WALKABLE_LANES.fatima,
  "fatima-sun": GUADALUPE_WALKABLE_LANES.fatima,
});
const guadalupeClamp = (value, min, max) => Math.min(max, Math.max(min, value));
const guadalupeDistance = (a, b) => Math.hypot(a.x - b.x, a.y - b.y);
const guadalupeDirectionFromDelta = (dx, dy, fallback = "front") => {
  if (Math.abs(dx) < 0.2 && Math.abs(dy) < 0.2) return fallback;
  if (Math.abs(dx) >= Math.abs(dy)) return dx >= 0 ? "right" : "left";
  return dy >= 0 ? "front" : "back";
};
const guadalupeWalkDuration = (distance, mode = "field") => {
  const speed = mode === "room" ? GUADALUPE_WALK_TIMING.roomSpeed : GUADALUPE_WALK_TIMING.fieldSpeed;
  const maxMs = mode === "room" ? GUADALUPE_WALK_TIMING.roomMaxMs : GUADALUPE_WALK_TIMING.fieldMaxMs;
  return Math.round(guadalupeClamp((distance / speed) * 1000, GUADALUPE_WALK_TIMING.minMs, maxMs));
};
const guadalupeInterpolateLane = (points, x, key) => {
  if (x <= points[0].x) return points[0][key];
  for (let index = 1; index < points.length; index += 1) {
    const previous = points[index - 1];
    const next = points[index];
    if (x <= next.x) {
      const amount = (x - previous.x) / (next.x - previous.x);
      return previous[key] + (next[key] - previous[key]) * amount;
    }
  }
  return points[points.length - 1][key];
};
const guadalupeDepthScale = (sceneId, y, fallback, mobile = false) => {
  const profile = GUADALUPE_DEPTH_PROFILES[sceneId];
  if (!profile) return fallback;
  const amount = guadalupeClamp((y - profile.farY) / (profile.nearY - profile.farY), 0, 1);
  const min = mobile ? profile.mobileMin : profile.min;
  const max = mobile ? profile.mobileMax : profile.max;
  return min + (max - min) * amount;
};
const guadalupeWalkablePoint = (sceneId, x, y) => {
  const lane = GUADALUPE_WALKABLE_LANES[sceneId];
  if (lane) {
    const laneX = guadalupeClamp(x, lane[0].x, lane[lane.length - 1].x);
    const minY = guadalupeInterpolateLane(lane, laneX, "minY");
    const maxY = guadalupeInterpolateLane(lane, laneX, "maxY");
    return { x: laneX, y: guadalupeClamp(y, minY, maxY) };
  }
  if (!["bishop", "tilma"].includes(sceneId)) return { x, y };
  const floorX = guadalupeClamp(x, 18, 76);
  let floorY = guadalupeClamp(y, sceneId === "bishop" ? 64 : 78, 89);
  if (sceneId === "bishop" && floorY < 79) {
    return { x: guadalupeClamp(floorX, 49, 58), y: floorY };
  }
  if (floorX > 58 && floorY < 84) floorY = 84;
  return { x: floorX, y: floorY };
};
const guadalupeRoomRouteWaypoint = (sceneId, from, targetPoint, targetPatch) => {
  if (sceneId !== "bishop" || !targetPatch) return null;
  const fromMobileX = from.mobileX ?? from.x;
  const fromMobileY = from.mobileY ?? from.y;
  const targetMobileX = targetPatch.mobileX ?? targetPoint.x;
  const targetMobileY = targetPatch.mobileY ?? targetPoint.y;
  const crossesLeftCorner =
    fromMobileY < 79 &&
    targetMobileY >= 79 &&
    targetMobileX < 49;
  if (!crossesLeftCorner) return null;
  const waypointX = guadalupeClamp(from.x, 49, 58);
  const waypointY = 84;
  const waypointMobileX = guadalupeClamp(fromMobileX, 49, 58);
  const waypointMobileY = 84;
  return {
    point: guadalupeWalkablePoint(sceneId, waypointX, waypointY),
    patch: {
      mobileX: waypointMobileX,
      mobileY: waypointMobileY,
      scale: guadalupeDepthScale(sceneId, waypointY, from.scale),
      mobileScale: guadalupeDepthScale(sceneId, waypointMobileY, from.mobileScale ?? from.scale, true),
      dir: guadalupeDirectionFromDelta(waypointX - from.x, waypointY - from.y, from.dir),
    },
  };
};
const guadalupeActorStart = (sceneId, actor) => {
  const nextScale = guadalupeDepthScale(sceneId, actor.y, actor.scale);
  const nextMobileScale = guadalupeDepthScale(sceneId, actor.mobileY ?? actor.y, actor.mobileScale ?? actor.scale, true);
  return { ...actor, scale: nextScale, mobileScale: nextMobileScale, sceneId, walkMs: 0 };
};

const mergeGuadalupeRoomActor = (actor, roomSpot) => {
  const roomActor = roomSpot?.actors?.[actor.id];
  return roomActor ? { ...actor, ...roomActor } : actor;
};

function GuadalupeSceneActor({ actor, frame }) {
  const atlas = GUADALUPE_CHARACTER_ATLASES[actor.id];
  const col = GUADALUPE_DIRECTIONS[actor.dir] ?? GUADALUPE_DIRECTIONS.front;
  const row = actor.walking ? GUADALUPE_WALK_FRAME_SEQUENCE[frame % GUADALUPE_WALK_FRAME_SEQUENCE.length] : 0;
  const walkAtlas = actor.roses && atlas.walkRoses ? atlas.walkRoses : atlas.walk;
  return (
    <span
      className={`guadalupe-actor is-${actor.id} ${actor.walking ? "is-walking" : ""} ${actor.controlled ? "is-controlled-walking" : ""} ${actor.apparition ? "is-apparition" : ""} ${actor.revealed === false ? "is-veiled" : ""}`}
      style={{
        "--actor-x": `${actor.x}%`,
        "--actor-y": `${actor.y}%`,
        "--actor-mobile-x": `${actor.mobileX ?? actor.x}%`,
        "--actor-mobile-y": `${actor.mobileY ?? actor.y}%`,
        "--actor-scale": actor.scale,
        "--actor-mobile-scale": actor.mobileScale ?? actor.scale,
        "--actor-opacity": actor.opacity ?? 1,
        "--actor-depth": actor.y,
        "--actor-move-ms": `${actor.walkMs ?? (actor.controlled ? 90 : 520)}ms`,
        backgroundImage: `url("${walkAtlas}")`,
        backgroundPosition: guadalupeSheetPosition(col, row, 4, 4),
        zIndex: Math.round(actor.y),
      }}
      role="img"
      aria-label={atlas.label}
    />
  );
}

function GuadalupeTilmaCloak({ large = false, progress = null }) {
  const reveal = progress === null ? null : guadalupeClamp(progress, 0, 1);
  const revealStyle = reveal === null
    ? undefined
    : {
        "--tilma-reveal-clip": `${Math.round((1 - reveal) * 72)}%`,
        "--tilma-reveal-opacity": (0.16 + reveal * 0.84).toFixed(2),
        "--tilma-fold-shift": `${Math.round(reveal * -24)}px`,
      };
  return (
    <span
      className={`guadalupe-tilma-cloak ${large ? "is-large" : ""} ${reveal !== null ? "is-revealing" : ""}`}
      style={revealStyle}
      role={large ? "img" : undefined}
      aria-label={large ? "Tilma cloak with the image of Mary" : undefined}
      aria-hidden={large ? undefined : "true"}
    >
      <span className="guadalupe-tilma-fold is-left" />
      <span className="guadalupe-tilma-fold is-right" />
      <span className="guadalupe-tilma-image">
        <span className="guadalupe-tilma-halo" />
        <span className="guadalupe-tilma-robe" />
        <span className="guadalupe-tilma-mantle" />
        <span className="guadalupe-tilma-face" />
        <span className="guadalupe-tilma-hair" />
        <span className="guadalupe-tilma-hands" />
        <span className="guadalupe-tilma-sash" />
        <span className="guadalupe-tilma-moon" />
        <span className="guadalupe-tilma-angel" />
        <span className="guadalupe-tilma-rays" />
      </span>
    </span>
  );
}

function GuadalupeVisitGame() {
  const [sceneIndex, setSceneIndex] = useState(0);
  const [roomSpotId, setRoomSpotId] = useState(null);
  const [tilmaModalOpen, setTilmaModalOpen] = useState(false);
  const [walkFrame, setWalkFrame] = useState(0);
  const [player, setPlayer] = useState(guadalupeActorStart("tepeyac", GUADALUPE_PLAYER_STARTS.tepeyac));
  const [playerMoving, setPlayerMoving] = useState(false);
  const [controlMoving, setControlMoving] = useState(false);
  const [moveInput, setMoveInput] = useState(GUADALUPE_EMPTY_MOVE);
  const [priestMoving, setPriestMoving] = useState(false);
  const [priestWalkMs, setPriestWalkMs] = useState(null);
  const [message, setMessage] = useState(null);
  const [foundScenes, setFoundScenes] = useState({});
  const [roomFinds, setRoomFinds] = useState({});
  const [maryFound, setMaryFound] = useState(false);
  const [maryStep, setMaryStep] = useState(0);
  const [signProgress, setSignProgress] = useState({});
  const [activeSignDrag, setActiveSignDrag] = useState(null);
  const worldRef = useRef(null);
  const discoveryAudioRef = useRef(null);
  const tilmaAudioRef = useRef(null);
  const discoveryTimer = useRef(null);
  const playerTimer = useRef(null);
  const priestTimer = useRef(null);
  const walkLatencyRef = useRef(null);
  const activeSignDragRef = useRef(null);
  const scene = GUADALUPE_VISIT_SCENES[sceneIndex];
  const directedSign = GUADALUPE_SCENE_D_SIGNS[scene.id];
  const rawSignProgress = signProgress[scene.id] ?? 0;
  const signProgressRatio = directedSign
    ? directedSign.type === "ground-taps"
      ? guadalupeClamp(rawSignProgress / (directedSign.requiredTaps ?? 1), 0, 1)
      : guadalupeClamp(rawSignProgress, 0, 1)
    : 0;
  const signGestureStyle = directedSign?.target
    ? {
        "--sign-x": `${directedSign.target.x}%`,
        "--sign-y": `${directedSign.target.y}%`,
        "--sign-mobile-x": `${directedSign.target.mobileX ?? directedSign.target.x}%`,
        "--sign-mobile-y": `${directedSign.target.mobileY ?? directedSign.target.y}%`,
        "--sign-progress": signProgressRatio,
        "--sign-opacity": (0.28 + signProgressRatio * 0.72).toFixed(2),
        "--water-width": `${Math.round(24 + signProgressRatio * 58)}px`,
        "--water-height": `${Math.round(10 + signProgressRatio * 30)}px`,
        "--sun-rotation": `${Math.round(signProgressRatio * 760)}deg`,
      }
    : null;
  const roomSpots = scene.roomSpots ?? [];
  const roomSpot = roomSpotId ? roomSpots.find((spot) => spot.id === roomSpotId) ?? null : null;
  const roomGoalSpots = scene.goalSpots ?? [];
  const completedRoomSpots = roomFinds[scene.id] ?? [];
  const roomGoalSpotItems = roomGoalSpots.map((id) => roomSpots.find((spot) => spot.id === id)).filter(Boolean);
  const completedRoomGoalCount = roomGoalSpots.filter((id) => completedRoomSpots.includes(id)).length;
  const pendingRoomSpot = roomGoalSpotItems.find((spot) => !completedRoomSpots.includes(spot.id));
  const sceneDone = !!foundScenes[scene.id];
  const isFirst = sceneIndex === 0;
  const isLast = sceneIndex === GUADALUPE_VISIT_SCENES.length - 1;
  const basePlayer = scene.actors.find((actor) => actor.id === "juan") ?? { id: "juan" };
  const playerWalking = playerMoving || controlMoving;
  const playerActor = {
    ...basePlayer,
    ...player,
    id: "juan",
    walking: playerWalking,
    controlled: controlMoving,
  };
  const sideActors = scene.actors
    .filter((actor) => actor.id !== "juan" && actor.id !== "mary")
    .map((actor) => {
      const merged = mergeGuadalupeRoomActor(actor, roomSpot);
      return {
        ...merged,
        walking: priestMoving && merged.id === "priest",
        walkMs: priestMoving && merged.id === "priest" ? priestWalkMs : merged.walkMs,
      };
    });
  const maryBase = scene.actors.find((actor) => actor.id === "mary");
  const discovery = GUADALUPE_DISCOVERIES[scene.id];
  const autoDiscovery = directedSign ? null : discovery;
  const hasMaryWaypoints = !!scene.maryWaypoints;
  const usesDefaultMaryWaypoints = ["tepeyac", "message", "roses"].includes(scene.id);
  const maryWaypoints = hasMaryWaypoints
    ? scene.maryWaypoints
    : usesDefaultMaryWaypoints
      ? GUADALUPE_MARY_WAYPOINTS
      : null;
  const maryWaypoint = maryWaypoints ? maryWaypoints[maryStep % maryWaypoints.length] : maryBase;
  const maryScene = maryBase && (maryBase.apparition || hasMaryWaypoints || usesDefaultMaryWaypoints);
  const maryRevealed = !discovery?.revealMary || sceneDone || (scene.id === "tepeyac" && maryFound);
  const maryActor = maryScene
    ? {
        ...maryBase,
        ...maryWaypoint,
        scale: maryBase.scale,
        mobileScale: maryBase.mobileScale ?? maryBase.scale,
        apparition: true,
        revealed: maryRevealed,
        opacity: maryRevealed ? 1 : 0.16,
        walking: false,
      }
    : null;
  const discoveryTarget = directedSign ? null : autoDiscovery?.target ?? (maryActor ? { x: maryActor.x, y: maryActor.y } : null);
  const messageSpeaker = message ? GUADALUPE_CHARACTER_ATLASES[message.speaker] : null;
  const messageSpeakerLabel = message?.speakerLabel ?? messageSpeaker?.label;
  const foundCount = GUADALUPE_VISIT_SCENES.filter((visitScene) => foundScenes[visitScene.id]).length;
  const distanceToDiscovery = discoveryTarget ? guadalupeDistance(player, discoveryTarget) : null;
  const discoveryRadius = autoDiscovery?.radius ?? 8;
  const roomProgress = roomGoalSpots.length ? completedRoomGoalCount / roomGoalSpots.length : 0;
  const trailSignal = sceneDone
    ? 1
    : roomGoalSpots.length
      ? roomProgress
      : distanceToDiscovery === null
        ? 0
        : guadalupeClamp(1 - Math.max(0, distanceToDiscovery - discoveryRadius) / 42, 0, 1);
  const trailSignalLabel = sceneDone
    ? "Clue secured"
    : roomGoalSpots.length
      ? pendingRoomSpot
        ? `Next: ${pendingRoomSpot.label}`
        : "Actions complete"
      : trailSignal > 0.84
        ? "Hold still"
        : trailSignal > 0.58
          ? "Very warm"
          : trailSignal > 0.28
            ? "Warmer"
            : "Faint trail";
  const canAdvance = sceneDone && !message && !tilmaModalOpen;
  const showTilmaRelic = scene.tilmaRelic && (!roomGoalSpots.length || completedRoomSpots.includes("bishop") || sceneDone);
  const directedSignReady = directedSign && (!roomGoalSpots.length || showTilmaRelic);
  const isRoomSpotLocked = (spot) => {
    if (!roomGoalSpots.length || !roomGoalSpots.includes(spot.id) || completedRoomSpots.includes(spot.id)) return false;
    return pendingRoomSpot?.id !== spot.id;
  };
  const questFeedback = (() => {
    if (message) {
      return {
        label: sceneDone ? "Token found" : "Story moment",
        line: sceneDone ? "Tap Close to unlock the next area." : "Listen, then tap Close to keep walking.",
      };
    }
    if (sceneDone) {
      return {
        label: "Area clear",
        line: isLast ? "All story tokens are found." : "Next area unlocked.",
      };
    }
    if (roomGoalSpots.length) {
      if (pendingRoomSpot?.hidden && showTilmaRelic) {
        return {
          label: `${completedRoomGoalCount}/${roomGoalSpots.length} actions`,
          line: directedSign?.prompt ?? "Open the tilma sign to finish the room.",
        };
      }
      return {
        label: `${completedRoomGoalCount}/${roomGoalSpots.length} actions`,
        line: pendingRoomSpot ? `Next: ${pendingRoomSpot.label}.` : "All actions found.",
      };
    }
    if (directedSignReady) {
      if (directedSign.type === "ground-taps") {
        const tapsLeft = Math.max(0, (directedSign.requiredTaps ?? 3) - rawSignProgress);
        return {
          label: `${rawSignProgress}/${directedSign.requiredTaps ?? 3} taps`,
          line: tapsLeft > 1 ? `Tap the ground ${tapsLeft} more times.` : "Tap the ground one more time.",
        };
      }
      return {
        label: "Sign gesture",
        line: directedSign.prompt,
      };
    }
    if (playerWalking) {
      return {
        label: "Walking",
        line: trailSignal > 0.58 ? "The trail is warm. Slow near the light." : "Keep following the path.",
      };
    }
    if (trailSignal > 0.84) {
      return { label: "Close", line: "Stand still in the warm light." };
    }
    if (trailSignal > 0.28) {
      return { label: "Signal rising", line: "You are getting closer." };
    }
    return {
      label: "Explore",
      line: GUADALUPE_OBJECTIVES[scene.id],
    };
  })();
  const gamebarPrompt = message
    ? (sceneDone ? "Close the message to claim the token." : "Listen, then keep exploring.")
    : tilmaModalOpen
      ? "Close the tilma sign to claim the token."
    : sceneDone
      ? (isLast ? "Tell the route again from the start." : "Token secured. Next area is ready.")
    : directedSignReady && directedSign.type === "ground-taps"
      ? `${rawSignProgress}/${directedSign.requiredTaps ?? 3} ground taps complete.`
    : directedSignReady
      ? directedSign.prompt
    : roomGoalSpots.length
      ? `${completedRoomGoalCount}/${roomGoalSpots.length} actions complete. ${pendingRoomSpot ? `Find ${pendingRoomSpot.label}.` : directedSign?.prompt ?? "Finish the sign."}`
      : "Follow the signal and pause near the clue.";

  const markSceneDone = () => {
    setFoundScenes((value) => ({ ...value, [scene.id]: true }));
  };

  const markRoomSpotFound = (spotId) => {
    const nextIds = Array.from(new Set([...completedRoomSpots, spotId]));
    setRoomFinds((value) => ({
      ...value,
      [scene.id]: Array.from(new Set([...(value[scene.id] ?? []), spotId])),
    }));
    const roomComplete = roomGoalSpots.length && roomGoalSpots.every((id) => nextIds.includes(id));
    if (roomComplete) {
      markSceneDone();
    }
    return roomComplete;
  };

  const beginWalkLatencySample = useCallback((source) => {
    const actorNode = worldRef.current?.querySelector(".guadalupe-actor.is-juan");
    const rect = actorNode?.getBoundingClientRect();
    if (!rect) return;
    walkLatencyRef.current = {
      source,
      sceneId: scene.id,
      start: window.performance.now(),
      x: rect.left,
      y: rect.top,
    };
  }, [scene.id]);

  const setDirectionActive = useCallback((direction, active) => {
    setMoveInput((value) => {
      if (value[direction] === active) return value;
      return { ...value, [direction]: active };
    });
  }, []);

  const stopMoveInput = useCallback(() => {
    setMoveInput(GUADALUPE_EMPTY_MOVE);
  }, []);

  const directionButtonProps = (direction) => ({
    onPointerDown: (event) => {
      event.preventDefault();
      event.stopPropagation();
      if (message) return;
      event.currentTarget.setPointerCapture?.(event.pointerId);
      if (!Object.values(moveInput).some(Boolean)) beginWalkLatencySample(`pointer:${direction}`);
      setDirectionActive(direction, true);
    },
    onPointerUp: (event) => {
      event.preventDefault();
      event.stopPropagation();
      setDirectionActive(direction, false);
    },
    onPointerCancel: () => setDirectionActive(direction, false),
    onPointerLeave: () => setDirectionActive(direction, false),
    onContextMenu: (event) => event.preventDefault(),
    disabled: !!message,
  });

  useEffect(() => {
    if (!playerWalking && !priestMoving) {
      setWalkFrame(0);
      return undefined;
    }
    const id = window.setInterval(() => {
      setWalkFrame((frame) => (frame + 1) % 4);
    }, 190);
    return () => window.clearInterval(id);
  }, [playerWalking, priestMoving]);

  useEffect(() => {
    if (!controlMoving || !walkLatencyRef.current) return undefined;
    let frameId = 0;
    const tick = () => {
      const sample = walkLatencyRef.current;
      if (!sample) return;
      const actorNode = worldRef.current?.querySelector(".guadalupe-actor.is-juan");
      const rect = actorNode?.getBoundingClientRect();
      if (rect && (Math.abs(rect.left - sample.x) >= 1 || Math.abs(rect.top - sample.y) >= 1)) {
        const entry = {
          source: sample.source,
          sceneId: sample.sceneId,
          latencyMs: Math.round((window.performance.now() - sample.start) * 10) / 10,
          from: { x: Math.round(sample.x * 10) / 10, y: Math.round(sample.y * 10) / 10 },
          to: { x: Math.round(rect.left * 10) / 10, y: Math.round(rect.top * 10) / 10 },
          walkMs: 90,
        };
        window.__guadalupeWalkLatency = window.__guadalupeWalkLatency || [];
        window.__guadalupeWalkLatency.push(entry);
        window.dispatchEvent(new CustomEvent("guadalupe-walk-latency", { detail: entry }));
        console.info("[guadalupe-walk-latency]", entry);
        walkLatencyRef.current = null;
        return;
      }
      frameId = window.requestAnimationFrame(tick);
    };
    frameId = window.requestAnimationFrame(tick);
    return () => window.cancelAnimationFrame(frameId);
  }, [controlMoving]);

  useEffect(() => {
    setRoomSpotId(null);
    setTilmaModalOpen(false);
    setMessage(null);
    setMaryStep(0);
    setActiveSignDrag(null);
    activeSignDragRef.current = null;
    setPlayer(guadalupeActorStart(scene.id, GUADALUPE_PLAYER_STARTS[scene.id] ?? basePlayer));
    setPlayerMoving(false);
    setControlMoving(false);
    stopMoveInput();
    setPriestMoving(false);
    setPriestWalkMs(null);
    window.clearTimeout(playerTimer.current);
    window.clearTimeout(priestTimer.current);
  }, [sceneIndex]);

  useEffect(() => {
    if (!maryScene) return undefined;
    const id = window.setInterval(() => {
      setMaryStep((value) => value + 1);
    }, 2600);
    return () => window.clearInterval(id);
  }, [maryScene, scene.id]);

  useEffect(() => {
    window.clearTimeout(discoveryTimer.current);
    if (!autoDiscovery || !discoveryTarget || sceneDone || playerWalking || player.sceneId !== scene.id || message) return undefined;
    if (guadalupeDistance(player, discoveryTarget) > autoDiscovery.radius) return undefined;

    discoveryTimer.current = window.setTimeout(() => {
      if (autoDiscovery.revealMary) setMaryFound(true);
      stopMoveInput();
      markSceneDone();
      setMessage({
        speaker: autoDiscovery.speaker,
        speakerLabel: autoDiscovery.speakerLabel,
        line: autoDiscovery.line,
        note: autoDiscovery.note,
        audio: autoDiscovery.audio,
      });
    }, 520);

    return () => window.clearTimeout(discoveryTimer.current);
  }, [player.x, player.y, playerWalking, scene.id, discoveryTarget?.x, discoveryTarget?.y, sceneDone, message, stopMoveInput]);

  useEffect(() => {
    if (!message?.audio || !discoveryAudioRef.current) return undefined;
    discoveryAudioRef.current.currentTime = 0;
    discoveryAudioRef.current.play?.().catch(() => {});
    return undefined;
  }, [message?.audio]);

  useEffect(() => {
    if (!tilmaModalOpen || !roomSpot?.audio || !tilmaAudioRef.current)
      return undefined;
    tilmaAudioRef.current.currentTime = 0;
    tilmaAudioRef.current.play?.().catch(() => {});
    return undefined;
  }, [tilmaModalOpen, roomSpot?.audio]);

  useEffect(() => {
    const keyMap = {
      ArrowUp: "up",
      ArrowDown: "down",
      ArrowLeft: "left",
      ArrowRight: "right",
    };
    const shouldIgnoreTarget = (target) => {
      const tag = target?.tagName;
      return (
        tag === "INPUT" ||
        tag === "TEXTAREA" ||
        tag === "SELECT" ||
        target?.isContentEditable
      );
    };
    const handleKeyDown = (event) => {
      const direction = keyMap[event.key];
      if (
        !direction ||
        message ||
        tilmaModalOpen ||
        shouldIgnoreTarget(event.target)
      )
        return;
      event.preventDefault();
      if (!Object.values(moveInput).some(Boolean)) beginWalkLatencySample(`key:${direction}`);
      setDirectionActive(direction, true);
    };
    const handleKeyUp = (event) => {
      const direction = keyMap[event.key];
      if (!direction) return;
      event.preventDefault();
      setDirectionActive(direction, false);
    };
    window.addEventListener("keydown", handleKeyDown);
    window.addEventListener("keyup", handleKeyUp);
    return () => {
      window.removeEventListener("keydown", handleKeyDown);
      window.removeEventListener("keyup", handleKeyUp);
    };
  }, [beginWalkLatencySample, message, moveInput, setDirectionActive, tilmaModalOpen]);

  useEffect(() => {
    if (tilmaModalOpen) {
      stopMoveInput();
      setControlMoving(false);
      return undefined;
    }
    const horizontal = (moveInput.right ? 1 : 0) - (moveInput.left ? 1 : 0);
    const vertical = (moveInput.down ? 1 : 0) - (moveInput.up ? 1 : 0);
    if (!horizontal && !vertical) {
      setControlMoving(false);
      return undefined;
    }

    setControlMoving(true);
    let frameId = 0;
    let lastTime = window.performance.now() - GUADALUPE_CONTROL_FIRST_STEP_DT * 1000;
    const tick = (time) => {
      const dt = guadalupeClamp((time - lastTime) / 1000, 0, 0.045);
      lastTime = time;
      const length = Math.hypot(horizontal, vertical) || 1;
      setPlayer((value) => {
        if (value.sceneId !== scene.id) return value;
        const profile = GUADALUPE_DEPTH_PROFILES[scene.id];
        const depthAmount = profile
          ? guadalupeClamp((value.y - profile.farY) / (profile.nearY - profile.farY), 0, 1)
          : 0.5;
        const stepX = (horizontal / length) * (10.8 + depthAmount * 4.4) * dt;
        const stepY = (vertical / length) * (8.2 + depthAmount * 3.5) * dt;
        const rawX = guadalupeClamp(value.x + stepX, 14, 86);
        const rawY = guadalupeClamp(value.y + stepY, 48, 89);
        const { x: nextX, y: nextY } = guadalupeWalkablePoint(scene.id, rawX, rawY);
        const nextDir = guadalupeDirectionFromDelta(horizontal, vertical, value.dir);
        return {
          ...value,
          x: nextX,
          y: nextY,
          scale: guadalupeDepthScale(scene.id, nextY, value.scale),
          mobileScale: guadalupeDepthScale(scene.id, nextY, value.mobileScale ?? value.scale, true),
          dir: nextDir,
          walkMs: 90,
        };
      });
      frameId = window.requestAnimationFrame(tick);
    };
    frameId = window.requestAnimationFrame(tick);
    return () => {
      window.cancelAnimationFrame(frameId);
      setControlMoving(false);
    };
  }, [moveInput, scene.id, tilmaModalOpen, stopMoveInput]);

  useEffect(() => {
    if (!tilmaModalOpen) return undefined;
    const closeOnEscape = (event) => {
      if (event.key === "Escape") setTilmaModalOpen(false);
    };
    window.addEventListener("keydown", closeOnEscape);
    return () => window.removeEventListener("keydown", closeOnEscape);
  }, [tilmaModalOpen]);

  const movePlayerTo = (x, y, options = {}) => {
    const rawTargetX = guadalupeClamp(options.playerPatch?.x ?? x, 14, 86);
    const rawTargetY = guadalupeClamp(options.playerPatch?.y ?? y, 48, 89);
    const targetPoint = guadalupeWalkablePoint(scene.id, rawTargetX, rawTargetY);
    const distance = Math.hypot(targetPoint.x - player.x, targetPoint.y - player.y);
    const duration = options.duration ?? guadalupeWalkDuration(distance, options.playerPatch ? "room" : "field");
    const applyPlayerMove = (point, patch, moveMs) => {
      setPlayer((value) => {
        const { x: nextX, y: nextY } = guadalupeWalkablePoint(scene.id, point.x, point.y);
        return {
          ...value,
          ...(patch ?? {}),
          sceneId: scene.id,
          x: nextX,
          y: nextY,
          scale: patch?.scale ?? guadalupeDepthScale(scene.id, nextY, value.scale),
          mobileScale: patch?.mobileScale ?? guadalupeDepthScale(scene.id, patch?.mobileY ?? nextY, value.mobileScale ?? value.scale, true),
          dir: patch?.dir ?? guadalupeDirectionFromDelta(nextX - value.x, nextY - value.y, value.dir),
          walkMs: moveMs,
        };
      });
    };
    const completeMove = () => {
      setPlayerMoving(false);
      if (options.message) {
        if (options.completeScene !== false) markSceneDone();
        setMessage(options.message);
      }
    };
    const roomWaypoint = guadalupeRoomRouteWaypoint(scene.id, player, targetPoint, options.playerPatch);
    if (roomWaypoint) {
      const firstDistance = Math.hypot(roomWaypoint.point.x - player.x, roomWaypoint.point.y - player.y);
      const firstDuration = guadalupeWalkDuration(firstDistance, "room");
      const secondDistance = Math.hypot(targetPoint.x - roomWaypoint.point.x, targetPoint.y - roomWaypoint.point.y);
      const secondDuration = guadalupeWalkDuration(secondDistance, "room");
      const totalDuration = firstDuration + secondDuration;
      applyPlayerMove(roomWaypoint.point, roomWaypoint.patch, firstDuration);
      setPlayerMoving(true);
      window.clearTimeout(playerTimer.current);
      playerTimer.current = window.setTimeout(() => {
        applyPlayerMove(targetPoint, options.playerPatch, secondDuration);
        playerTimer.current = window.setTimeout(completeMove, secondDuration);
      }, firstDuration);
      return totalDuration;
    }
    applyPlayerMove(targetPoint, options.playerPatch, duration);
    setPlayerMoving(true);
    window.clearTimeout(playerTimer.current);
    playerTimer.current = window.setTimeout(completeMove, duration);
    return duration;
  };

  const chooseRoomSpot = (spot) => {
    if (isRoomSpotLocked(spot)) return;
    setRoomSpotId(spot.id);
    const roomComplete = roomGoalSpots.length ? markRoomSpotFound(spot.id) : true;
    const roomJuan = spot.actors?.juan ?? {};
    const roomPriest = spot.actors?.priest;
    const walkDuration = movePlayerTo(roomJuan.x ?? spot.x, roomJuan.y ?? spot.y, {
      playerPatch: roomJuan,
      message: {
        speaker: spot.speaker,
        speakerLabel: spot.speakerLabel,
        line: spot.line,
        note: spot.note,
        find: spot.find,
        audio: spot.audio,
      },
      completeScene: roomComplete,
    });
    if (roomPriest) {
      setPriestMoving(true);
      setPriestWalkMs(walkDuration);
      window.clearTimeout(priestTimer.current);
      priestTimer.current = window.setTimeout(() => {
        setPriestMoving(false);
        setPriestWalkMs(null);
      }, walkDuration);
    }
  };

  const openTilmaModal = () => {
    const tilmaSpot = roomSpots.find((spot) => spot.id === "tilma");
    if (tilmaSpot) {
      setRoomSpotId(tilmaSpot.id);
      const roomComplete = roomGoalSpots.length ? markRoomSpotFound(tilmaSpot.id) : true;
      if (roomComplete || !roomGoalSpots.length) markSceneDone();
    }
    setTilmaModalOpen(true);
  };

  const completeDirectedSign = () => {
    if (!directedSign || sceneDone) return;
    stopMoveInput();
    setSignProgress((value) => ({
      ...value,
      [scene.id]: directedSign.type === "ground-taps" ? directedSign.requiredTaps ?? 3 : 1,
    }));
    if (directedSign.type === "tilma-drag") {
      openTilmaModal();
      return;
    }
    markSceneDone();
    setMessage({
      speaker: discovery?.speaker ?? scene.speaker,
      speakerLabel: discovery?.speakerLabel ?? scene.speakerLabel,
      line: discovery?.line ?? scene.line,
      note: discovery?.note ?? scene.note,
      audio: discovery?.audio,
      find: {
        label: "Date",
        title: directedSign.date,
        text: directedSign.caption,
      },
    });
  };

  const dragProgressFromEvent = (gesture, event) => {
    const dx = event.clientX - gesture.startX;
    const dy = event.clientY - gesture.startY;
    const distance = directedSign?.type === "tilma-drag" ? Math.max(0, dy) : Math.hypot(dx, dy);
    return guadalupeClamp(distance / (directedSign?.dragDistance ?? 78), 0, 1);
  };

  const beginSignDrag = (event) => {
    if (!directedSign || sceneDone || message || playerMoving) return;
    event.preventDefault();
    event.stopPropagation();
    stopMoveInput();
    event.currentTarget.setPointerCapture?.(event.pointerId);
    const gesture = {
      sceneId: scene.id,
      type: directedSign.type,
      startX: event.clientX,
      startY: event.clientY,
    };
    activeSignDragRef.current = gesture;
    setActiveSignDrag(gesture);
    setSignProgress((value) => ({ ...value, [scene.id]: Math.max(value[scene.id] ?? 0, 0.05) }));
  };

  const updateSignDrag = (event) => {
    const gesture = activeSignDragRef.current ?? activeSignDrag;
    if (!directedSign || !gesture || gesture.sceneId !== scene.id) return;
    event.preventDefault();
    event.stopPropagation();
    const nextProgress = dragProgressFromEvent(gesture, event);
    setSignProgress((value) => ({
      ...value,
      [scene.id]: Math.max(value[scene.id] ?? 0, nextProgress),
    }));
  };

  const finishSignDrag = (event) => {
    const gesture = activeSignDragRef.current ?? activeSignDrag;
    if (!directedSign || !gesture || gesture.sceneId !== scene.id) return;
    event.preventDefault();
    event.stopPropagation();
    const nextProgress = dragProgressFromEvent(gesture, event);
    activeSignDragRef.current = null;
    setActiveSignDrag(null);
    setSignProgress((value) => ({
      ...value,
      [scene.id]: Math.max(value[scene.id] ?? 0, nextProgress),
    }));
    if (nextProgress >= 0.96) completeDirectedSign();
  };

  const handleGroundSignTap = (event) => {
    if (!directedSign || directedSign.type !== "ground-taps" || sceneDone || message || playerMoving) return;
    event.preventDefault();
    event.stopPropagation();
    stopMoveInput();
    const requiredTaps = directedSign.requiredTaps ?? 3;
    const nextTaps = Math.min(requiredTaps, rawSignProgress + 1);
    setSignProgress((value) => ({ ...value, [scene.id]: nextTaps }));
    if (nextTaps >= requiredTaps) completeDirectedSign();
  };

  useEffect(() => {
    if (!activeSignDrag) return undefined;
    const handleMove = (event) => updateSignDrag(event);
    const handleUp = (event) => finishSignDrag(event);
    window.addEventListener("pointermove", handleMove);
    window.addEventListener("pointerup", handleUp);
    window.addEventListener("mousemove", handleMove);
    window.addEventListener("mouseup", handleUp);
    return () => {
      window.removeEventListener("pointermove", handleMove);
      window.removeEventListener("pointerup", handleUp);
      window.removeEventListener("mousemove", handleMove);
      window.removeEventListener("mouseup", handleUp);
    };
  }, [activeSignDrag]);

  const handleWorldPointerDown = (event) => {
    if (message || tilmaModalOpen || event.target.closest("button")) return;
    const rect = worldRef.current?.getBoundingClientRect();
    if (!rect) return;
    const x = ((event.clientX - rect.left) / rect.width) * 100;
    const y = ((event.clientY - rect.top) / rect.height) * 100;
    stopMoveInput();
    movePlayerTo(x, y);
  };

  const goNext = () => {
    setSceneIndex((value) => (value + 1) % GUADALUPE_VISIT_SCENES.length);
  };
  const goBack = () => {
    setSceneIndex((value) => Math.max(0, value - 1));
  };
  const restart = () => {
    setSceneIndex(0);
    setFoundScenes({});
    setRoomFinds({});
    setMaryFound(false);
    setMessage(null);
    setSignProgress({});
    setActiveSignDrag(null);
    activeSignDragRef.current = null;
  };

  return (
    <article
      className="guadalupe-mystery-shell"
      style={{
        "--guadalupe-bg": `url("${scene.backdrop}")`,
        "--guadalupe-bg-position": roomSpot?.bgPosition ?? "center",
        "--guadalupe-bg-scale": Math.min(roomSpot?.bgScale ?? 1, 1.04),
      }}
    >
      <header className="guadalupe-hud">
        <div>
          <p className="guadalupe-kicker">Marian Apparitions</p>
          <h2>{scene.label}</h2>
        </div>
        <div className="guadalupe-count">
          <strong>{sceneIndex + 1}</strong>
          <span>/ {GUADALUPE_VISIT_SCENES.length}</span>
        </div>
      </header>

      <div
        ref={worldRef}
        className="guadalupe-world"
        aria-label={`${scene.label} exploration scene`}
        onPointerDown={handleWorldPointerDown}
      >
        <div className="guadalupe-backdrop" aria-hidden="true" />
        <div className="guadalupe-atmosphere" aria-hidden="true" />
        <div className="guadalupe-game-hud">
          <span>{questFeedback.label}</span>
          <strong>{questFeedback.line}</strong>
          {!!roomGoalSpotItems.length && (
            <div
              className="guadalupe-quest-steps"
              aria-label="Scene action progress"
            >
              {roomGoalSpotItems.map((spot) => {
                const complete = completedRoomSpots.includes(spot.id);
                const next = pendingRoomSpot?.id === spot.id;
                return (
                  <span
                    key={spot.id}
                    className={`guadalupe-quest-step ${complete ? "is-complete" : ""} ${next ? "is-next" : ""}`}
                  >
                    {complete ? "OK" : next ? "Next" : "Wait"} {spot.label}
                  </span>
                );
              })}
            </div>
          )}
        </div>
        <div
          className="guadalupe-trail-meter"
          aria-label={`Trail signal: ${trailSignalLabel}`}
        >
          <span>Trail Signal</span>
          <strong>{trailSignalLabel}</strong>
          <i aria-hidden="true">
            <b style={{ width: `${Math.round(trailSignal * 100)}%` }} />
          </i>
        </div>
        {discoveryTarget && !sceneDone && trailSignal > 0.12 && (
          <span
            className={`guadalupe-search-glow is-${scene.id}`}
            style={{
              "--glow-x": `${discoveryTarget.x}%`,
              "--glow-y": `${discoveryTarget.y}%`,
              "--glow-opacity": (0.18 + trailSignal * 0.66).toFixed(2),
            }}
            aria-hidden="true"
          />
        )}
        {directedSignReady && !sceneDone && directedSign.type === "ground-taps" && (
          <button
            type="button"
            className="guadalupe-sign-gesture is-ground-taps"
            style={signGestureStyle}
            onPointerDown={handleGroundSignTap}
            disabled={!!message || playerMoving}
            aria-label={`${directedSign.prompt} ${rawSignProgress} of ${directedSign.requiredTaps ?? 3} taps complete`}
          >
            <span className="guadalupe-sign-water" aria-hidden="true" />
            <strong>{rawSignProgress}/{directedSign.requiredTaps ?? 3}</strong>
            <em>Tap ground</em>
          </button>
        )}
        {directedSignReady && !sceneDone && directedSign.type === "sky-drag" && (
          <button
            type="button"
            className="guadalupe-sign-gesture is-sky-drag"
            style={signGestureStyle}
            onPointerDown={beginSignDrag}
            onPointerMove={updateSignDrag}
            onPointerUp={finishSignDrag}
            onPointerCancel={() => {
              activeSignDragRef.current = null;
              setActiveSignDrag(null);
            }}
            onMouseDown={beginSignDrag}
            onMouseMove={updateSignDrag}
            onMouseUp={finishSignDrag}
            disabled={!!message || playerMoving}
            aria-label={directedSign.prompt}
          >
            <span className="guadalupe-sign-sun" aria-hidden="true" />
            <strong>Drag sun</strong>
          </button>
        )}
        {directedSign?.date && sceneDone && !message && !tilmaModalOpen && (
          <div className="guadalupe-sign-caption" aria-live="polite">
            <span>{directedSign.date}</span>
            <strong>{directedSign.caption}</strong>
          </div>
        )}
        <GuadalupeSceneActor actor={playerActor} frame={walkFrame} />
        {sideActors.map((actor) => (
          <GuadalupeSceneActor
            key={`${scene.id}-${actor.id}`}
            actor={actor}
            frame={walkFrame}
          />
        ))}
        {maryActor && (
          <GuadalupeSceneActor actor={maryActor} frame={walkFrame} />
        )}
        {roomSpots
          .filter((spot) => !spot.hidden)
          .map((spot) => {
            const spotOrder = roomGoalSpots.indexOf(spot.id);
            const spotLocked = isRoomSpotLocked(spot);
            const spotNext = pendingRoomSpot?.id === spot.id;
            return (
              <button
                key={spot.id}
                className={`guadalupe-room-hotspot ${roomSpot?.id === spot.id ? "is-active" : ""} ${completedRoomSpots.includes(spot.id) ? "is-complete" : ""} ${spotNext ? "is-next" : ""} ${spotLocked ? "is-locked" : ""}`}
                style={{
                  "--spot-x": `${spot.x}%`,
                  "--spot-y": `${spot.y}%`,
                  "--spot-mobile-x": `${spot.mobileX ?? spot.x}%`,
                  "--spot-mobile-y": `${spot.mobileY ?? spot.y}%`,
                }}
                onClick={() => chooseRoomSpot(spot)}
                aria-pressed={roomSpot?.id === spot.id}
                aria-label={
                  spotLocked
                    ? `${spot.label} locked until the previous action`
                    : `${spot.label}${spot.find?.title ? `: ${spot.find.title}` : ""}`
                }
                disabled={
                  spotLocked || !!message || playerMoving || tilmaModalOpen
                }
              >
                {spotOrder >= 0 && (
                  <small aria-hidden="true">{spotOrder + 1}</small>
                )}
                <span>{spot.label}</span>
              </button>
            );
          })}
        {showTilmaRelic && !sceneDone && (
          <button
            type="button"
            className="guadalupe-tilma-find is-drag-gesture"
            style={{
              "--tilma-x": `${scene.tilmaRelic.x}%`,
              "--tilma-y": `${scene.tilmaRelic.y}%`,
              "--tilma-mobile-x": `${scene.tilmaRelic.mobileX ?? scene.tilmaRelic.x}%`,
              "--tilma-mobile-y": `${scene.tilmaRelic.mobileY ?? scene.tilmaRelic.y}%`,
              "--sign-progress": signProgressRatio,
              "--tilma-drag-height": `${Math.round(24 + signProgressRatio * 36)}px`,
            }}
            onPointerDown={beginSignDrag}
            onPointerMove={updateSignDrag}
            onPointerUp={finishSignDrag}
            onPointerCancel={() => {
              activeSignDragRef.current = null;
              setActiveSignDrag(null);
            }}
            onMouseDown={beginSignDrag}
            onMouseMove={updateSignDrag}
            onMouseUp={finishSignDrag}
            onDragStart={(event) => event.preventDefault()}
            aria-label={directedSign?.prompt ?? "Drag the tilma corner downward"}
            disabled={!!message || playerMoving || tilmaModalOpen}
          >
            <GuadalupeTilmaCloak progress={signProgressRatio} />
            <span>{signProgressRatio >= 0.96 ? "Open" : "Drag down"}</span>
          </button>
        )}
        <div
          className="guadalupe-dpad"
          role="group"
          aria-label="Move Juan Diego"
        >
          <button
            type="button"
            className="is-up"
            title="Move up"
            aria-label="Move up"
            {...directionButtonProps("up")}
          >
            ↑
          </button>
          <button
            type="button"
            className="is-left"
            title="Move left"
            aria-label="Move left"
            {...directionButtonProps("left")}
          >
            ←
          </button>
          <button
            type="button"
            className="is-down"
            title="Move down"
            aria-label="Move down"
            {...directionButtonProps("down")}
          >
            ↓
          </button>
          <button
            type="button"
            className="is-right"
            title="Move right"
            aria-label="Move right"
            {...directionButtonProps("right")}
          >
            →
          </button>
        </div>
        {message && messageSpeaker && (
          <section
            className="guadalupe-dialogue is-popup"
            role="dialog"
            aria-modal="true"
            aria-live="polite"
            aria-label={`${messageSpeakerLabel} message`}
          >
            <span
              className={`guadalupe-dialogue-portrait is-${message.speaker}`}
              style={{
                backgroundImage: `url("${messageSpeaker.talk}")`,
                backgroundPosition: guadalupeSheetPosition(0, 0, 8, 1),
              }}
              role="img"
              aria-label={`${messageSpeakerLabel} speaking`}
            />
            <div className="guadalupe-dialogue-copy">
              <span>{messageSpeakerLabel}</span>
              <p>{message.line}</p>
              <em>{message.note}</em>
              {message.find && (
                <div className="guadalupe-dialogue-find">
                  <span>{message.find.label ?? "You noticed"}</span>
                  <strong>{message.find.title}</strong>
                  <p>{message.find.text}</p>
                </div>
              )}
              {message.audio && (
                <audio
                  ref={discoveryAudioRef}
                  className="guadalupe-voice"
                  controls
                  preload="auto"
                  src={message.audio}
                >
                  Grok voice line
                </audio>
              )}
            </div>
            <div className="guadalupe-controls">
              <button
                className="btn btn-primary guadalupe-popup-close"
                aria-label="Close message and keep walking"
                onClick={() => setMessage(null)}
              >
                Close
              </button>
            </div>
          </section>
        )}
        <div className="guadalupe-place-card">
          <span>{scene.place}</span>
          <strong>{scene.label}</strong>
        </div>
      </div>

      <section className="guadalupe-gamebar">
        <div className="guadalupe-gamebar-main">
          <span>
            {message
              ? "Listen"
              : playerWalking
                ? "Walking"
                : sceneDone
                  ? "Unlocked"
                  : "Explore"}
          </span>
          <strong>{gamebarPrompt}</strong>
          <div
            className="guadalupe-token-tray"
            aria-label={`${foundCount} of ${GUADALUPE_VISIT_SCENES.length} scene clues found`}
          >
            {GUADALUPE_VISIT_SCENES.map((visitScene) => {
              const token = GUADALUPE_SCENE_TOKENS[visitScene.id];
              const found = !!foundScenes[visitScene.id];
              const active = visitScene.id === scene.id;
              return (
                <span
                  key={visitScene.id}
                  className={`guadalupe-token ${found ? "is-found" : ""} ${active ? "is-active" : ""}`}
                  title={token.label}
                >
                  {found ? token.mark : active ? "..." : ""}
                </span>
              );
            })}
          </div>
        </div>
        <nav
          className="guadalupe-controls"
          aria-label="Guadalupe game controls"
        >
          <button
            className="btn btn-secondary"
            onClick={goBack}
            disabled={isFirst || !!message}
          >
            Back
          </button>
          <button
            className="btn btn-primary"
            onClick={goNext}
            disabled={!canAdvance}
          >
            {isLast ? "Tell again" : "Next area"}
          </button>
          {!isFirst && (
            <button
              className="btn btn-secondary"
              onClick={restart}
              disabled={!!message}
            >
              Start
            </button>
          )}
        </nav>
      </section>
      {tilmaModalOpen && (
        <div
          className="guadalupe-tilma-modal"
          role="dialog"
          aria-modal="true"
          aria-labelledby="guadalupe-tilma-title"
        >
          <div className="guadalupe-tilma-modal-card">
            <div className="guadalupe-tilma-modal-art">
              <GuadalupeTilmaCloak large />
            </div>
            <div className="guadalupe-tilma-modal-copy">
              <span>Tilma sign</span>
              <h3 id="guadalupe-tilma-title">The cloak opens with the image</h3>
              {directedSign?.date && (
                <div className="guadalupe-sign-caption is-modal">
                  <span>{directedSign.date}</span>
                  <strong>{directedSign.caption}</strong>
                </div>
              )}
              <p>
                Mexico City, December 12, 1531: Juan Diego opens his tilma
                before Bishop Zumarraga; roses fall, and Catholic tradition
                remembers Mary's image on the cloth.
              </p>
              <p>
                The sign confirms the Tepeyac chapel request and points people
                toward Jesus.
              </p>
              {roomSpot?.find && (
                <div className="guadalupe-dialogue-find is-tilma">
                  <span>{roomSpot.find.label ?? "You noticed"}</span>
                  <strong>{roomSpot.find.title}</strong>
                  <p>{roomSpot.find.text}</p>
                </div>
              )}
              {roomSpot?.audio && (
                <audio
                  ref={tilmaAudioRef}
                  className="guadalupe-voice"
                  controls
                  preload="auto"
                  src={roomSpot.audio}
                >
                  Grok voice line
                </audio>
              )}
              <div className="guadalupe-controls">
                <button
                  className="btn btn-primary"
                  onClick={() => setTilmaModalOpen(false)}
                >
                  OK
                </button>
              </div>
            </div>
          </div>
        </div>
      )}
    </article>
  );
}

const SHEPHERD_START = { x: 2, y: 4 };
const SHEPHERD_GATE = { x: 2, y: 0 };
const SHEEP_START = [
  { id: "lamb-1", x: 0, y: 1, pose: 1 },
  { id: "lamb-2", x: 4, y: 1, pose: 2 },
  { id: "lamb-3", x: 1, y: 3, pose: 3 },
  { id: "lamb-4", x: 3, y: 3, pose: 4 },
];
const sameSpot = (a, b) => a.x === b.x && a.y === b.y;
const directionFromMove = (dx, dy) => {
  if (dx < 0) return "left";
  if (dx > 0) return "right";
  if (dy < 0) return "up";
  return "down";
};

function ShepherdGame() {
  const [jesus, setJesus] = useState(SHEPHERD_START);
  const [direction, setDirection] = useState("down");
  const [sheep, setSheep] = useState(SHEEP_START);
  const [steps, setSteps] = useState(0);
  const [feedback, setFeedback] = useState("Move Jesus one space at a time. Gather each sheep, then go to the little gate.");
  const gathered = SHEEP_START.length - sheep.length;
  const atGate = sameSpot(jesus, SHEPHERD_GATE);
  const done = sheep.length === 0 && atGate;
  const canMove = (dx, dy) => {
    const next = { x: jesus.x + dx, y: jesus.y + dy };
    return next.x >= 0 && next.x < 5 && next.y >= 0 && next.y < 5;
  };
  const move = (dx, dy) => {
    if (!canMove(dx, dy) || done) return;
    const next = { x: jesus.x + dx, y: jesus.y + dy };
    const found = sheep.find((item) => sameSpot(item, next));
    setDirection(directionFromMove(dx, dy));
    setJesus(next);
    setSteps((value) => value + 1);
    if (found) {
      setSheep((items) => items.filter((item) => item.id !== found.id));
      setFeedback("Jesus found one. The Good Shepherd knows every sheep by name.");
    } else if (sheep.length === 0 && sameSpot(next, SHEPHERD_GATE)) {
      setFeedback("All the sheep are safe at the gate.");
    } else if (sheep.length === 0) {
      setFeedback("All gathered. Now bring them home to the gate at the top.");
    } else {
      setFeedback("Keep looking. Choose the next nearest sheep.");
    }
  };
  const moveToCell = (x, y) => {
    const dx = x - jesus.x;
    const dy = y - jesus.y;
    if (Math.abs(dx) + Math.abs(dy) === 1) move(dx, dy);
  };
  const reset = () => {
    setJesus(SHEPHERD_START);
    setDirection("down");
    setSheep(SHEEP_START);
    setSteps(0);
    setFeedback("Move Jesus one space at a time. Gather each sheep, then go to the little gate.");
  };
  useEffect(() => {
    const h = (e) => {
      if (e.key === "ArrowUp" || e.key.toLowerCase() === "w") move(0, -1);
      if (e.key === "ArrowDown" || e.key.toLowerCase() === "s") move(0, 1);
      if (e.key === "ArrowLeft" || e.key.toLowerCase() === "a") move(-1, 0);
      if (e.key === "ArrowRight" || e.key.toLowerCase() === "d") move(1, 0);
    };
    window.addEventListener("keydown", h);
    return () => window.removeEventListener("keydown", h);
  }, [jesus, sheep, done]);
  return (
    <article className="game-card">
      <div className="game-card-head">
        <div>
          <h2 className="game-card-title">Gather the Sheep</h2>
          <p className="game-card-copy">Jesus is the main character. Move Him through the pasture, collect every sheep, and bring them home.</p>
        </div>
        <div className="game-badges">
          <span className="game-badge">{gathered}/4 sheep</span>
          <span className="game-badge">{steps} steps</span>
        </div>
      </div>
      <GameProgress value={gathered + (done ? 1 : 0)} total={SHEEP_START.length + 1} />
      <div className="game-area shepherd-layout">
        <div className="shepherd-board" aria-label="Pasture board">
          {Array.from({ length: 25 }).map((_, index) => {
            const x = index % 5;
            const y = Math.floor(index / 5);
            const hasJesus = jesus.x === x && jesus.y === y;
            const hasGate = SHEPHERD_GATE.x === x && SHEPHERD_GATE.y === y;
            const lamb = sheep.find((item) => item.x === x && item.y === y);
            const isStep = Math.abs(x - jesus.x) + Math.abs(y - jesus.y) === 1;
            return (
              <button
                key={`${x}-${y}`}
                className={`pasture-cell ${isStep ? "is-step" : ""}`}
                onClick={() => moveToCell(x, y)}
                aria-label={`Pasture space ${x + 1}, ${y + 1}`}
              >
                {hasJesus && <span className={`jesus-player dir-${direction}`} aria-label="Jesus" />}
                {!hasJesus && lamb && <span className={`sheep-token pose-${lamb.pose}`} aria-label="Sheep" />}
                {!hasJesus && !lamb && hasGate && <span className="gate-token" aria-label="Gate">⌂</span>}
              </button>
            );
          })}
        </div>
        <div className="shepherd-controls">
          <strong>Move Jesus</strong>
          <div className="move-pad" aria-label="Movement controls">
            <span />
            <button className="move-btn" onClick={() => move(0, -1)} disabled={!canMove(0, -1)}>↑</button>
            <span />
            <button className="move-btn" onClick={() => move(-1, 0)} disabled={!canMove(-1, 0)}>←</button>
            <button className="move-btn" onClick={reset}>↺</button>
            <button className="move-btn" onClick={() => move(1, 0)} disabled={!canMove(1, 0)}>→</button>
            <span />
            <button className="move-btn" onClick={() => move(0, 1)} disabled={!canMove(0, 1)}>↓</button>
            <span />
          </div>
          <p className="game-card-copy">Tap a glowing neighbor space, use arrows, or use W A S D.</p>
        </div>
      </div>
      <div className="game-feedback" aria-live="polite">{done ? "Home safe. Jesus says the Good Shepherd goes looking for the one who is lost." : feedback}</div>
      <div className="game-actions">
        <button className="btn btn-secondary" onClick={reset}>Start pasture over</button>
        <span className="game-card-copy">Grown-up bridge: read Luke 15:4-7 later and ask who Jesus goes looking for.</span>
      </div>
    </article>
  );
}

function BreadFromHeavenGame() {
  const [playerX, setPlayerX] = useState(50);
  const playerRef = useRef(50);
  const [gifts, setGifts] = useState([]);
  const [caught, setCaught] = useState(0);
  const [running, setRunning] = useState(true);
  const [feedback, setFeedback] = useState("Move Jesus left and right. Catch 12 gifts of bread and fish.");
  const done = caught >= 12;
  const movePlayer = (delta) => {
    setPlayerX((value) => Math.max(8, Math.min(92, value + delta)));
  };
  useEffect(() => {
    playerRef.current = playerX;
  }, [playerX]);
  const reset = () => {
    setPlayerX(50);
    setGifts([]);
    setCaught(0);
    setRunning(true);
    setFeedback("Move Jesus left and right. Catch 12 gifts of bread and fish.");
  };
  useEffect(() => {
    const h = (e) => {
      if (e.key === "ArrowLeft" || e.key.toLowerCase() === "a") movePlayer(-8);
      if (e.key === "ArrowRight" || e.key.toLowerCase() === "d") movePlayer(8);
      if (e.key === " ") setRunning((v) => !v);
    };
    window.addEventListener("keydown", h);
    return () => window.removeEventListener("keydown", h);
  }, []);
  useEffect(() => {
    if (!running || done) return;
    const tick = window.setInterval(() => {
      setGifts((current) => {
        let gained = 0;
        const moved = current
          .map((gift) => ({ ...gift, y: gift.y + gift.speed }))
          .filter((gift) => {
            const isCaught = gift.y > 78 && Math.abs(gift.x - playerRef.current) < 11;
            if (isCaught) {
              gained += 1;
              return false;
            }
            return gift.y < 106;
          });
        if (gained > 0) {
          setCaught((value) => Math.min(12, value + gained));
          setFeedback(gained > 1 ? "Good catch. More than enough can be shared." : "Caught one gift. Jesus gives what people need.");
        }
        if (Math.random() < 0.58) {
          moved.push({
            id: `${Date.now()}-${Math.random()}`,
            x: 10 + Math.random() * 80,
            y: -6,
            speed: 5 + Math.random() * 2.5,
            kind: Math.floor(Math.random() * 6),
          });
        }
        return moved;
      });
    }, 360);
    return () => window.clearInterval(tick);
  }, [running, done]);
  useEffect(() => {
    if (done) {
      setRunning(false);
      setFeedback("The baskets are full. Jesus feeds the hungry crowd with love.");
    }
  }, [done]);
  return (
    <article className="game-card">
      <div className="game-card-head">
        <div>
          <h2 className="game-card-title">Bread from Heaven</h2>
          <p className="game-card-copy">Catch falling bread and fish in a calm collection game inspired by Jesus feeding the 5000.</p>
        </div>
        <div className="game-badges">
          <span className="game-badge">{caught}/12 gifts</span>
          <span className="game-badge">{running ? "Playing" : "Paused"}</span>
        </div>
      </div>
      <GameProgress value={caught} total={12} />
      <div className="bread-catch-area" style={{ "--player-x": `${playerX}%` }} aria-label="Bread from Heaven play area">
        {gifts.map((gift) => (
          <span
            key={gift.id}
            className={`falling-gift kind-${gift.kind}`}
            style={{ "--x": `${gift.x}%`, "--y": `${gift.y}%` }}
            aria-hidden="true"
          />
        ))}
        <span className="bread-catcher" aria-label="Jesus" />
        <span className="bread-basket" aria-label="Basket" />
      </div>
      <div className="game-feedback" aria-live="polite">{feedback}</div>
      <div className="bread-controls">
        <button className="move-btn" onClick={() => movePlayer(-8)} aria-label="Move left">←</button>
        <button className="btn btn-secondary" onClick={() => setRunning((v) => !v)}>{running ? "Pause" : "Play"}</button>
        <button className="move-btn" onClick={() => movePlayer(8)} aria-label="Move right">→</button>
        <button className="btn btn-secondary" onClick={reset}>Reset</button>
        <span className="game-card-copy">Use arrows or A/D. Space pauses.</span>
      </div>
    </article>
  );
}

function WordFamilyQuiz() {
  const [placed, setPlaced] = useState({});
  const [selected, setSelected] = useState(null);
  const [wrong, setWrong] = useState(null);
  const [feedback, setFeedback] = useState("Drag a card into a basket, or tap a card and then tap its word family.");
  const sortedCount = Object.keys(placed).length;
  const done = sortedCount === WORD_FAMILY_TOKENS.length;
  const remaining = WORD_FAMILY_TOKENS.filter((token) => !placed[token.id]);
  const tokensForFamily = (familyId) =>
    WORD_FAMILY_TOKENS.filter((token) => placed[token.id] === familyId);

  const placeToken = (tokenId, familyId) => {
    const token = WORD_FAMILY_TOKENS.find((item) => item.id === tokenId);
    if (!token || placed[token.id]) return;
    if (token.family === familyId) {
      setPlaced((current) => ({ ...current, [token.id]: familyId }));
      setFeedback(token.teach);
      setSelected(null);
      setWrong(null);
    } else {
      const family = WORD_FAMILIES.find((item) => item.id === token.family);
      setWrong(`${token.id}-${familyId}`);
      setFeedback(`Almost. "${token.label}" belongs with ${family.title}.`);
      setTimeout(() => setWrong(null), 620);
    }
  };

  const reset = () => {
    setPlaced({});
    setSelected(null);
    setWrong(null);
    setFeedback("Drag a card into a basket, or tap a card and then tap its word family.");
  };

  return (
    <article className="game-card">
      <div className="game-card-head">
        <div>
          <h2 className="game-card-title">Word Families</h2>
          <p className="game-card-copy">Sort pictures and words into Catholic vocabulary families: holy words, saint friends, and church things.</p>
        </div>
        <div className="game-badges">
          <span className="game-badge">Drag or tap</span>
          <span className="game-badge">{sortedCount}/{WORD_FAMILY_TOKENS.length}</span>
        </div>
      </div>
      <GameProgress value={sortedCount} total={WORD_FAMILY_TOKENS.length} />
      <div className="word-family-layout">
        <div className="word-token-bank" aria-label="Cards to sort">
          {remaining.map((token) => (
            <button
              key={token.id}
              type="button"
              data-testid={`word-token-${token.id}`}
              className={`word-token ${selected === token.id ? "is-selected" : ""}`}
              draggable="true"
              onClick={() => {
                setSelected(selected === token.id ? null : token.id);
                setFeedback(selected === token.id ? "Choose another card to sort." : `Now tap the basket for "${token.label}".`);
              }}
              onDragStart={(event) => {
                event.dataTransfer.setData("text/plain", token.id);
                event.dataTransfer.effectAllowed = "move";
                setSelected(token.id);
              }}
            >
              <span className="word-token-art" aria-hidden="true">
                <WordFamilySprite token={token} />
              </span>
              <span className="word-token-label">{token.label}</span>
            </button>
          ))}
          {remaining.length === 0 && (
            <div className="word-bank-empty">All cards are sorted.</div>
          )}
        </div>
        <div className="word-family-zones">
          {WORD_FAMILIES.map((family) => {
            const familyTokens = tokensForFamily(family.id);
            const isWrong = wrong && wrong.endsWith(`-${family.id}`);
            return (
              <button
                key={family.id}
                type="button"
                data-testid={`word-family-${family.id}`}
                className={`word-family-zone ${selected ? "is-ready" : ""} ${isWrong ? "is-wrong" : ""}`}
                onClick={() => selected && placeToken(selected, family.id)}
                onDragOver={(event) => {
                  event.preventDefault();
                  event.dataTransfer.dropEffect = "move";
                }}
                onDrop={(event) => {
                  event.preventDefault();
                  placeToken(event.dataTransfer.getData("text/plain"), family.id);
                }}
              >
                <span className="word-family-head">
                  <span className="word-family-icon" aria-hidden="true">{family.icon}</span>
                  <span>
                    <strong>{family.title}</strong>
                    <em>{family.hint}</em>
                  </span>
                </span>
                <span className="word-family-sorted" aria-label={`${familyTokens.length} cards sorted into ${family.title}`}>
                  {familyTokens.length === 0 ? (
                    <span className="word-family-placeholder">Drop cards here</span>
                  ) : familyTokens.map((token) => (
                    <span key={token.id} className="word-family-pill">
                      <WordFamilySprite token={token} small={true} />
                      <span>{token.label}</span>
                    </span>
                  ))}
                </span>
              </button>
            );
          })}
        </div>
      </div>
      <div className="game-feedback" aria-live="polite">{done ? "All sorted. Try making one new family together, like prayer words or Mass things." : feedback}</div>
      <div className="game-actions">
        <button className="btn btn-secondary" onClick={reset}>Mix again</button>
        <span className="game-card-copy">Grown-up bridge: ask, "Which family would this word join at church?"</span>
      </div>
    </article>
  );
}

function GamesPage({ palette, onCyclePalette, initialGame }) {
  const initialGameId = GAME_CARDS.some((game) => game.id === initialGame)
    ? initialGame
    : "build";
  const [active, setActive] = useState(initialGameId);
  useEffect(() => {
    setActive(initialGameId);
  }, [initialGameId]);
  const activeCard = GAME_CARDS.find((game) => game.id === active);
  const selectGame = (id) => {
    setActive(id);
    if ((window.location.hash || "").startsWith("#/games")) {
      window.history.replaceState(null, "", `#/games/${id}`);
    }
  };
  const isGuadalupe = active === "guadalupe";
  return (
    <div className={`sky-scene faith-page-bg ${isGuadalupe ? "is-guadalupe-scene" : ""}`}>
      <CloudComposition showSparkles={true} />
      <div className={`games-page ${isGuadalupe ? "is-guadalupe-immersive" : ""}`}>
        {!isGuadalupe && (
          <TopBar
            readAloud={false}
            onToggleReadAloud={() => {}}
            onShowAudioSetup={() => {}}
            palette={palette}
            onCyclePalette={onCyclePalette}
          />
        )}
        <main className={`games-shell ${isGuadalupe ? "is-guadalupe-shell" : ""}`}>
          {!isGuadalupe && (
            <section className="games-hero">
              <div className="games-copy">
                <div className="stop-kicker">Rubric-built play</div>
                <h1 className="stop-title">Catholic Mini Games</h1>
                <p className="stop-copy">
                  Short symbol and story games plus full playable arcade games for ages 4-8: concrete visuals, large taps, immediate feedback, no ads, and a tiny grown-up bridge after play.
                </p>
                <div className="rubric-strip" aria-label="Rubric notes">
                  <div className="rubric-chip"><strong>Active</strong>Tap, remember, build.</div>
                  <div className="rubric-chip"><strong>Scaffolded</strong>Hints answer the next step.</div>
                  <div className="rubric-chip"><strong>Meaningful</strong>Every symbol teaches one idea.</div>
                  <div className="rubric-chip"><strong>Social</strong>Each game ends with a co-play prompt.</div>
                </div>
              </div>
              <div className="game-picker" aria-label="Choose a game">
                {GAME_CARDS.map((game) => (
                  <button
                    key={game.id}
                    className={`game-pick ${active === game.id ? "is-active" : ""}`}
                    onClick={() => selectGame(game.id)}
                  >
                    <span className="game-pick-icon" aria-hidden="true">{game.icon}</span>
                    <span><strong>{game.title}</strong><span>{game.blurb}</span></span>
                    <span className="game-pick-tag">{game.tag}</span>
                  </button>
                ))}
              </div>
            </section>
          )}
          <section className={`games-stage ${isGuadalupe ? "is-guadalupe-stage" : ""}`} aria-label={activeCard.title}>
            {active === "build" && <MonstranceBuilderGame />}
            {active === "memory" && <MemoryGame />}
            {active === "light" && <QuietLightGame />}
            {active === "guadalupe" && <GuadalupeVisitGame />}
            {active === "shepherd" && <ShepherdGame />}
            {active === "bread" && <BreadFromHeavenGame />}
            {active === "families" && <WordFamilyQuiz />}
          </section>
          {!isGuadalupe && (
            <>
              <section className="arcade-link-section" aria-label="Full playable Catholic games">
                <div>
                  <div className="stop-kicker">Full playable games</div>
                  <h2 className="arcade-link-title">Catholic Kids Arcade</h2>
                  <p className="stop-copy">
                    These open as their own game pages, so the controls and canvas can use the whole screen.
                  </p>
                </div>
                <div className="arcade-link-grid">
                  {ARCADE_GAME_LINKS.map((game) => (
                    <a key={game.href} className="arcade-link-card" href={game.href}>
                      <span className="arcade-link-icon" aria-hidden="true">{game.icon}</span>
                      <span className="arcade-link-copy">
                        <strong>{game.title}</strong>
                        <em>{game.blurb}</em>
                      </span>
                      <span className="arcade-link-tag">{game.tag}</span>
                    </a>
                  ))}
                </div>
              </section>
              <nav className="faith-nav" aria-label="Game navigation">
                <a className="btn btn-secondary" href="#top">← Monstrance home</a>
                <a className="btn btn-secondary" href="#journey">Faith Journey</a>
              </nav>
            </>
          )}
        </main>
      </div>
    </div>
  );
}
