/* ═══════════════════════════════════════════════════════
   games.jsx — 12 gamified cognitive tests
   Each game: backdrop + three.js stage + HUD + protocol strip
   Preserves original test logic, wrapped in Ghibli world
═══════════════════════════════════════════════════════ */

const { useEffect, useRef, useState } = React;

/* ─── Protocol text builder ───────────────────────────
   Renders a rich HTML protocol description from TASK_DOCS
   based on the current task id and selected level. Includes
   rule, manipulations, brain network, and references. */
function buildProtocolText(taskId, params){
  const docs = window.TASK_DOCS && window.TASK_DOCS[taskId];
  if (!docs) return '';
  const level = (params && params.level) || 'basic';
  const lvl = docs.levels[level] || docs.levels.basic;
  const refsHtml = (lvl.refs || []).map(r => `<i>${r}</i>`).join('<br>');
  const levelLabel = level === 'basic' ? '基礎' : level === 'intermediate' ? '進階' : '研究';
  return [
    `<b>${docs.title}</b> · <span style="color:#d4931a">${levelLabel}</span> — ${lvl.name}`,
    `<b>規則</b>　${lvl.rule}`,
    `<b>操弄</b>　${lvl.manipulations}`,
    `<b>測量</b>　${docs.purpose} ｜ <b>腦網路</b>　${docs.network}`,
    refsHtml ? `<b>Refs</b><br>${refsHtml}` : '',
  ].filter(Boolean).join('<br>');
}

/* ════════════ shared chrome ════════════ */
function HUD({chapter, title, subtitle, tags=[]}){
  return (
    <div className="hud">
      <div className="hud-c">
        <div className="chapter">{chapter}</div>
        <div className="title">{title}</div>
        <div className="subtitle">{subtitle}</div>
      </div>
      <div className="hud-r">
        {tags.map((t,i)=>(
          <div key={i} className="chip">
            <span className="dot" style={{background:t.color||'#d4931a'}}></span>
            {t.label}
          </div>
        ))}
      </div>
    </div>
  );
}

function Scroll({protocol, metrics}){
  return (
    <div className="scroll">
      <div className="protocol" dangerouslySetInnerHTML={{__html: protocol}} />
      <div className="metrics">
        {metrics.map((m,i)=><span key={i} className="m">{m}</span>)}
      </div>
    </div>
  );
}

function Instr({children}){ return <div className="instr">{children}</div>; }

function TrialDots({total, cur, hits=[]}){
  const arr = Array.from({length: total}, (_,i) => {
    if(i < cur) return hits[i] ? 'hit' : 'miss';
    if(i === cur) return 'on';
    return '';
  });
  return (
    <div className="trial-dots">
      <span>TRIAL</span>
      {arr.map((c,i)=><span key={i} className={'dot '+c}></span>)}
      <span>{cur+1}/{total}</span>
    </div>
  );
}

function Reward({stars}){
  return (
    <div className="reward">
      {Array.from({length: 3}).map((_,i)=>(
        <span key={i} className="star" style={{opacity:i<stars?1:.2}}>★</span>
      ))}
    </div>
  );
}

/* Reusable wrapper: holds 3d stage + chrome
   Uses IntersectionObserver to lazily mount the three.js renderer
   only when the artboard enters the viewport — avoids exhausting
   WebGL contexts when 12 scenes are on-canvas simultaneously. */
function Scene({chapter, title, subtitle, tags, protocol, metrics, instr, trialDots, reward, children, init}){
  const stageRef = useRef();
  const [visible, setVisible] = useState(false);

  // IntersectionObserver — mount renderer when stage scrolls into view
  useEffect(()=>{
    const el = stageRef.current;
    if(!el) return;
    const io = new IntersectionObserver((entries)=>{
      for(const e of entries){
        if(e.isIntersecting){ setVisible(true); io.disconnect(); return; }
      }
    }, { root:null, rootMargin:'200px', threshold:0.01 });
    io.observe(el);
    return ()=> io.disconnect();
  }, []);

  useEffect(()=>{
    if(!visible) return;
    const el = stageRef.current;
    if(!el || !init) return;
    const cleanup = init(el);
    return ()=> cleanup && cleanup();
  }, [visible]);
  return (
    <div className="game-scene paper-grain">
      <div className="stage" ref={stageRef}></div>
      <HUD chapter={chapter} title={title} subtitle={subtitle} tags={tags}/>
      {trialDots && <TrialDots {...trialDots}/>}
      {reward != null && <Reward stars={reward}/>}
      {instr && <Instr>{instr}</Instr>}
      <Scroll protocol={protocol} metrics={metrics}/>
      {children}
    </div>
  );
}

/* ════════════════════════════════════════════════════
   01 · 記憶燈籠 — N-Back (working memory)
   Lanterns drift across a twilight river.
   When current lantern matches the one N-ago (glyph),
   tap to catch it. Classic single-stimulus protocol.
═════════════════════════════════════════════════════ */
function Game_NBack({ params, onComplete, onAbort }) {
  const ctxRef = useRef(null);
  const runnerRef = useRef(null);
  const [trialIdx, setTrialIdx] = useState(-1);
  const [hits, setHits] = useState([]);
  const [stars, setStars] = useState(2);
  const [instrText, setInstrText] = useState('若此燈與 2 盞前相同 — 按【空白鍵】召回');
  return <Scene
    chapter="第一章 · 記憶之河 · Working Memory"
    title="記憶燈籠 — N-Back"
    subtitle="看見熟悉的光芒，輕輕喚醒它"
    tags={[{label:'2-BACK',color:'#4888c8'},{label:'~240 試次',color:'#8a7a60'}]}
    instr={instrText}
    trialDots={trialIdx >= 0 ? {total:10, cur:trialIdx%10, hits:hits.slice(-10)} : {total:10, cur:4, hits:[true,true,false,true]}}
    reward={trialIdx >= 0 ? stars : 2}
    protocol={(window.TASK_DOCS && window.TASK_DOCS[0])
      ? buildProtocolText(0, params)
      : '<b>Protocol · N-Back</b>  Kirchner 1958 / Jaeggi 2008 典範 · 詳細說明載入中…'}
    metrics={['Accuracy','d′ Sensitivity','RT (ms)','False Alarm']}
    init={(el)=>{
      const {renderer, scene, cam} = SK.mkScene(el, {z:8});
      // twilight sky backdrop
      SK.addBackdrop(scene, cam, (c,w,h)=>{
        SK.paintSky(c,w,h, ['#2b3a6c','#4a5d9a','#7494c8','#c4a884','#d98c7a'], 0.02);
        SK.paintStars(c,w,h,{count:60, color:'#f5f0e4'});
        // distant mountains
        SK.paintHills(c,w,h,[
          {base:.62, color:'#2a3548', amp:40, freq:.008, alpha:.85},
          {base:.72, color:'#1a2338', amp:30, freq:.012, alpha:.92},
        ]);
        // river reflection
        const rg = c.createLinearGradient(0,h*.72,0,h);
        rg.addColorStop(0, '#1a2338');
        rg.addColorStop(.5, '#2b3a6c');
        rg.addColorStop(1, '#4a5d9a');
        c.fillStyle = rg;
        c.fillRect(0, h*.72, w, h*.28);
        // reflected stars (blurred)
        for(let i=0;i<24;i++){
          c.fillStyle = 'rgba(240,200,120,'+(Math.random()*.3+.1)+')';
          c.fillRect(Math.random()*w, h*.73+Math.random()*h*.25, 1+Math.random()*2, .8);
        }
      }, -28);

      // sparkle particles
      const ff = SK.makeFireflies(scene, 30, {x:7, y:3.5, z:2});

      // lanterns — 5 visible drifting right to left
      const glyphs = ['◈','⊗','≡','⟳','◐','✦','☯','△'];
      const lanterns = [];
      const colors = ['#f0c858','#e89070','#c4758a','#8878b8','#88b8c8'];
      for(let i=0;i<5;i++){
        const g = SK.choice(glyphs);
        const col = colors[i%colors.length];
        const tex = SK.makeLantern(col);
        const lm = new THREE.MeshBasicMaterial({map:tex, transparent:true, depthWrite:false});
        const mesh = new THREE.Mesh(new THREE.PlaneGeometry(1.6, 2.0), lm);
        mesh.position.set(-4 + i*2, -.3 + Math.sin(i)*.3, 0);
        scene.add(mesh);
        // glyph label
        const gtex = SK.mkTex(128,128,(c,w,h)=>{
          c.clearRect(0,0,w,h);
          c.font = 'bold 72px "Noto Serif TC", serif';
          c.fillStyle = '#2c2416';
          c.textAlign='center'; c.textBaseline='middle';
          c.fillText(g, w/2, h/2);
        });
        const gm = SK.mkPlane(.7,.7,gtex);
        gm.position.set(0,.1,.01);
        mesh.add(gm);
        // glow behind
        const glow = new THREE.Mesh(new THREE.PlaneGeometry(3,3), new THREE.MeshBasicMaterial({
          map: SK.makeGlow(col, 128), transparent:true, depthWrite:false,
          blending: THREE.AdditiveBlending
        }));
        glow.position.z = -.05;
        mesh.add(glow);
        lanterns.push({mesh, phase:Math.random()*Math.PI*2, isTarget: i===2});
      }

      // target ring on the active lantern (i=2)
      const tgt = lanterns[2];
      const ring = new THREE.Mesh(
        new THREE.RingGeometry(1.2, 1.32, 48),
        new THREE.MeshBasicMaterial({color:0xf0c858, transparent:true, opacity:.85, side:THREE.DoubleSide})
      );
      ring.position.z = .02;
      tgt.mesh.add(ring);

      let raf;
      const render = (t)=>{
        t = t||0;
        ff.tick(t);
        lanterns.forEach((L,i)=>{
          L.mesh.position.y = -.3 + Math.sin(t*.001 + L.phase)*.14;
          L.mesh.rotation.z = Math.sin(t*.0008 + L.phase)*.04;
        });
        ring.rotation.z = t*.0008;
        ring.scale.setScalar(1 + Math.sin(t*.003)*.04);
        renderer.render(scene, cam);
        raf = requestAnimationFrame(render);
      };
      render(0);

      // Mutable glyph canvas for center lantern (slot 2) — replaces the static SK.mkTex texture
      const glyphCvs = document.createElement('canvas');
      glyphCvs.width = 128; glyphCvs.height = 128;
      const gctx = glyphCvs.getContext('2d');
      const glyphTex = new THREE.CanvasTexture(glyphCvs);
      const glyphChild = lanterns[2].mesh.children[0]; // first child is the glyph mesh
      glyphChild.material = new THREE.MeshBasicMaterial({map:glyphTex,transparent:true,depthWrite:false});

      const setPhase = (phase, trial) => {
        if (phase === 'fixation') {
          gctx.clearRect(0, 0, 128, 128);
          glyphTex.needsUpdate = true;
          lanterns[2].mesh.material.color.set(0xffffff);
        } else if (phase === 'stimulus') {
          gctx.clearRect(0, 0, 128, 128);
          gctx.font = 'bold 72px "Noto Serif TC", serif';
          gctx.fillStyle = '#2c2416';
          gctx.textAlign = 'center'; gctx.textBaseline = 'middle';
          gctx.fillText(trial.stim, 64, 64);
          glyphTex.needsUpdate = true;
          lanterns[2].mesh.material.color.set(0xffffff);
          setTrialIdx(i => i + 1);
        } else if (phase === 'feedback') {
          const ok = trial._correct && trial.responded;
          // Green for correct-hit; red for miss-on-target or false alarm; white otherwise
          const isRed = (trial.isTarget && !trial.responded) || (!trial._correct && trial.responded);
          lanterns[2].mesh.material.color.set(ok ? 0xaaffcc : (isRed ? 0xff9999 : 0xffffff));
          if (ok) setStars(s => Math.min(3, s + 1));
          setHits(h => [...h, ok]);
        }
      };

      // Move ring + glyph callbacks to a helper used by both paths
      function highlightLantern(idx, withGlyph){
        lanterns.forEach((L,i)=>{
          L.mesh.material.color.set(i === idx ? 0xfff0a8 : 0xffffff);
          L.mesh.scale.setScalar(i === idx ? 1.18 : 1.0);
        });
        if (withGlyph !== undefined){
          gctx.clearRect(0,0,128,128);
          gctx.font = 'bold 72px "Noto Serif TC", serif';
          gctx.fillStyle = '#2c2416';
          gctx.textAlign = 'center'; gctx.textBaseline = 'middle';
          gctx.fillText(withGlyph, 64, 64);
          glyphTex.needsUpdate = true;
          // move the glyph child to the highlighted lantern
          if (idx >= 0 && lanterns[idx]){
            const glyphMesh = glyphChild.parent === lanterns[idx].mesh ? glyphChild : null;
            if (!glyphMesh){
              glyphChild.parent && glyphChild.parent.remove(glyphChild);
              lanterns[idx].mesh.add(glyphChild);
            }
          }
        }
      }
      function clearLanterns(){
        lanterns.forEach(L => { L.mesh.material.color.set(0xffffff); L.mesh.scale.setScalar(1.0); });
        gctx.clearRect(0,0,128,128); glyphTex.needsUpdate = true;
      }

      if (params) {
        const isDual = params.level === 'research';

        // ── Research level: Dual N-Back ────────────────────────
        // Visual position (5 lantern positions) + auditory letter.
        // Two independent response channels: A = visual match, L = auditory match.
        // (Jaeggi et al. 2008, PNAS 105:6829)
        if (isDual) {
          setInstrText('視覺位置匹配按【A】 · 聽覺字母匹配按【L】');
          const trials = window.TASK_IMPL[0].makeTrials(params);
          let curIdx = -1;
          let phaseTimer = null;
          let trialActive = false;
          const synth = window.speechSynthesis;

          const speakLetter = (letter) => {
            if (!synth) return;
            try {
              synth.cancel();
              const u = new SpeechSynthesisUtterance(letter);
              u.lang = 'en-US'; u.rate = 1.1; u.volume = 1.0; u.pitch = 1.05;
              synth.speak(u);
            } catch(e) {}
          };

          const showStim = (trial) => {
            trialActive = true;
            // Highlight lantern at trial.pos and DON'T draw any glyph
            // (visual position is the visual stim; letter is auditory only).
            highlightLantern(trial.pos);
            speakLetter(trial.letter);
            setTrialIdx(i => i + 1);
            // Mark RT start
            trial._rtStart = performance.now();
            const dur = +params.dur || 1500;
            phaseTimer = setTimeout(() => endStim(trial), dur);
          };
          const endStim = (trial) => {
            trialActive = false;
            // brief feedback flash
            const ok = window.TASK_IMPL[0].isCorrect(trial);
            trial._correct = ok;
            const lan = lanterns[trial.pos];
            if (lan) lan.mesh.material.color.set(ok ? 0xaaffcc : 0xff9999);
            setHits(h => [...h, ok]);
            if (ok) setStars(s => Math.min(3, s + 1));
            phaseTimer = setTimeout(() => {
              clearLanterns();
              nextTrial();
            }, 500);
          };
          const nextTrial = () => {
            curIdx++;
            if (curIdx >= trials.length){
              const metrics = window.TASK_IMPL[0].computeMetrics(trials);
              onComplete && onComplete({ metrics, trials });
              return;
            }
            const t = trials[curIdx];
            // Fixation period (use ISI param)
            phaseTimer = setTimeout(() => showStim(t), +params.isi || 800);
          };

          const onDualKey = e => {
            if (!trialActive) return;
            const t = trials[curIdx];
            const rt = Math.round(performance.now() - (t._rtStart || performance.now()));
            if (e.key === 'a' || e.key === 'A'){
              if (!t.visResp){ t.visResp = true; t.visRT = rt; }
            } else if (e.key === 'l' || e.key === 'L'){
              if (!t.audResp){ t.audResp = true; t.audRT = rt; }
            }
          };
          document.addEventListener('keydown', onDualKey);
          // Start
          nextTrial();
          return () => {
            cancelAnimationFrame(raf);
            renderer.dispose();
            document.removeEventListener('keydown', onDualKey);
            clearTimeout(phaseTimer);
            try { synth && synth.cancel(); } catch(e) {}
          };
        }

        // ── Basic / Intermediate: standard N-back via TestRunner ─
        ctxRef.current = { renderer, scene, cam, lanterns, ring, setPhase };
        const runner = new window.TestRunner(0, params, ctxRef.current, {
          onComplete: (r) => onComplete && onComplete(r),
          setInstr: (txt) => setInstrText(txt),
        });
        runnerRef.current = runner;
        window.__themyndRunner = runner;
        const inputMode = params.input || 'keyboard';
        const onKey = e => {
          if (inputMode === 'mouse') return;
          runner.handleInput({type:'key', key:e.key});
        };
        const onClick = () => {
          if (inputMode === 'keyboard') return;
          runner.handleInput({type:'key', key:' '});
        };
        document.addEventListener('keydown', onKey);
        renderer.domElement.addEventListener('click', onClick);
        return () => {
          cancelAnimationFrame(raf);
          renderer.dispose();
          document.removeEventListener('keydown', onKey);
          renderer.domElement.removeEventListener('click', onClick);
          runner.abort();
        };
      }

      return ()=>{ cancelAnimationFrame(raf); renderer.dispose(); };
    }}
  />;
}

/* ════════════════════════════════════════════════════
   02 · 彩墨卷軸 — Stroop
   Brushstroke characters float on silk.
   Name the INK color, ignore the word.
═════════════════════════════════════════════════════ */
function Game_Stroop({ params, onComplete, onAbort }) {
  const ctxRef = useRef(null);
  const runnerRef = useRef(null);
  const [trialIdx, setTrialIdx] = useState(-1);
  const [hits, setHits] = useState([]);
  const [stars, setStars] = useState(3);
  const [instrText, setInstrText] = useState('這筆墨的顏色是？');
  return <Scene
    chapter="第二章 · 墨色森林 · Interference Control"
    title="彩墨卷軸 — Stroop"
    subtitle="看見顏色，忽略文字 · 指尖點墨"
    tags={[{label:'不一致 試次',color:'#c05570'}]}
    instr={instrText}
    trialDots={trialIdx >= 0 ? {total:10, cur:trialIdx%10, hits:hits.slice(-10)} : {total:10, cur:5, hits:[true,true,true,false,true]}}
    reward={trialIdx >= 0 ? stars : 3}
    protocol={(window.TASK_DOCS && window.TASK_DOCS[1])
      ? buildProtocolText(1, params)
      : '<b>Protocol · Stroop</b>  Stroop 1935 / MacLeod 1991 — 詳細說明載入中…'}
    metrics={['Interference (ms)','Consistent RT','Inconsistent RT','Accuracy']}
    init={(el)=>{
      const {renderer, scene, cam} = SK.mkScene(el, {z:7});
      SK.addBackdrop(scene, cam, (c,w,h)=>{
        // rice-paper / silk
        SK.paintSky(c,w,h, ['#f5eed8','#ede2c5','#d9c9a8'], 0.03);
        // bamboo silhouettes
        for(let i=0;i<8;i++){
          const x = Math.random()*w;
          c.strokeStyle = 'rgba(60,80,50,'+(.08+Math.random()*.14)+')';
          c.lineWidth = 3 + Math.random()*4;
          c.beginPath(); c.moveTo(x, h); c.lineTo(x+Math.random()*20-10, h*.2); c.stroke();
        }
        // ink wash splatters
        for(let i=0;i<12;i++){
          const x=Math.random()*w, y=Math.random()*h, r=40+Math.random()*60;
          const g=c.createRadialGradient(x,y,0,x,y,r);
          g.addColorStop(0,'rgba(30,40,30,.04)'); g.addColorStop(1,'transparent');
          c.fillStyle=g; c.fillRect(x-r,y-r,r*2,r*2);
        }
      }, -24);

      // big calligraphy character — WORD "綠" rendered in RED ink (incongruent)
      const word = '綠';
      const inkColor = '#c84030';
      const tex = SK.mkTex(512,512,(c,w,h)=>{
        c.clearRect(0,0,w,h);
        // splatter
        c.fillStyle = inkColor+'22';
        for(let i=0;i<18;i++){
          const x = w/2+(Math.random()-.5)*300;
          const y = h/2+(Math.random()-.5)*300;
          const r = 4+Math.random()*18;
          c.beginPath(); c.arc(x,y,r,0,Math.PI*2); c.fill();
        }
        // main character
        c.font = "800 360px 'Noto Serif TC', serif";
        c.fillStyle = inkColor;
        c.textAlign='center'; c.textBaseline='middle';
        c.fillText(word, w/2, h/2+12);
        // light wet edge
        c.strokeStyle = inkColor+'55';
        c.lineWidth = 3;
        c.strokeText(word, w/2, h/2+12);
      });
      const bigCh = SK.mkPlane(4.2, 4.2, tex);
      bigCh.position.set(-.3, .2, 0);
      scene.add(bigCh);

      // 4 paint-pot answer buttons at bottom
      const pots = [
        {label:'紅', color:'#c84030', x:-3.2},
        {label:'藍', color:'#3060b8', x:-1.05},
        {label:'綠', color:'#3aa858', x:1.05},
        {label:'黃', color:'#d4a218', x:3.2},
      ];
      const potMeshesArr = [];
      pots.forEach(p=>{
        const pt = SK.mkTex(256,160,(c,w,h)=>{
          c.clearRect(0,0,w,h);
          // pot shape
          c.fillStyle = 'rgba(60,40,20,.85)';
          c.beginPath();
          c.moveTo(20, h*.4);
          c.quadraticCurveTo(w/2, h*.25, w-20, h*.4);
          c.lineTo(w-30, h-20);
          c.quadraticCurveTo(w/2, h-5, 30, h-20);
          c.closePath(); c.fill();
          // paint surface
          c.fillStyle = p.color;
          c.beginPath();
          c.ellipse(w/2, h*.42, w*.42, h*.15, 0, 0, Math.PI*2);
          c.fill();
          // highlight
          c.fillStyle='rgba(255,255,255,.4)';
          c.beginPath();
          c.ellipse(w*.4, h*.38, w*.12, h*.04, 0, 0, Math.PI*2); c.fill();
          // label
          c.font = "700 36px 'Noto Serif TC', serif";
          c.fillStyle = '#f5f0e4';
          c.textAlign='center'; c.textBaseline='middle';
          c.fillText(p.label, w/2, h*.72);
        });
        const mesh = SK.mkPlane(1.6, 1.0, pt);
        mesh.position.set(p.x, -2.2, 0);
        scene.add(mesh);
        potMeshesArr.push(mesh);
      });

      // floating ink dots
      const petals = SK.makePetals(scene, 12, '#1a2030');

      let raf, t=0;
      const render = ()=>{
        t += 16;
        bigCh.rotation.z = Math.sin(t*.0008)*.02;
        bigCh.position.y = .2 + Math.sin(t*.001)*.08;
        petals.tick(t);
        renderer.render(scene, cam);
        raf = requestAnimationFrame(render);
      };
      render();

      // Mutable canvas for the big calligraphy character — replace `tex` drawing per trial
      const bigChCtx2d = tex.image.getContext('2d');

      function drawBigChar(word, inkColor) {
        const W = tex.image.width, H = tex.image.height;
        bigChCtx2d.clearRect(0, 0, W, H);
        // splatter
        bigChCtx2d.fillStyle = inkColor + '22';
        for (let i = 0; i < 18; i++) {
          const x = W/2 + (Math.random() - .5) * 300;
          const y = H/2 + (Math.random() - .5) * 300;
          const r = 4 + Math.random() * 18;
          bigChCtx2d.beginPath(); bigChCtx2d.arc(x, y, r, 0, Math.PI*2); bigChCtx2d.fill();
        }
        // Adapt font size to word length: 1 char → 360, 2 char → 240, 4 char → 160
        const len = (word || '').length;
        const size = len >= 4 ? 160 : (len >= 2 ? 240 : 360);
        bigChCtx2d.font = `800 ${size}px 'Noto Serif TC', serif`;
        bigChCtx2d.fillStyle = inkColor;
        bigChCtx2d.textAlign = 'center'; bigChCtx2d.textBaseline = 'middle';
        bigChCtx2d.fillText(word, W/2, H/2 + 12);
        // wet edge
        bigChCtx2d.strokeStyle = inkColor + '55';
        bigChCtx2d.lineWidth = 3;
        bigChCtx2d.strokeText(word, W/2, H/2 + 12);
        tex.needsUpdate = true;
      }

      const setPhase = (phase, trial) => {
        if (phase === 'fixation') {
          bigCh.visible = false;
        } else if (phase === 'stimulus') {
          // Render full word (basic = 1 char, intermediate neutral = 'XXXX', research = 2-char emotion words)
          drawBigChar(trial.word || '', trial.inkColor);
          bigCh.material.color.set(0xffffff);
          bigCh.visible = true;
          setTrialIdx(i => i + 1);
        } else if (phase === 'feedback') {
          const ok = trial._correct;
          bigCh.material.color.set(ok ? 0xaaffcc : 0xff9999);
          setTimeout(() => {
            if (bigCh.material) bigCh.material.color.set(0xffffff);
          }, 300);
          setHits(h => [...h, ok]);
          if (ok) setStars(s => Math.min(3, s + 1));
        }
      };

      if (params) {
        const rayCaster = new THREE.Raycaster();
        ctxRef.current = { renderer, scene, cam, bigCh, potMeshes: potMeshesArr, setPhase };
        const runner = new window.TestRunner(1, params, ctxRef.current, {
          onComplete: (r) => onComplete && onComplete(r),
          setInstr: (txt) => setInstrText(txt),
        });
        runnerRef.current = runner;
        window.__themyndRunner = runner;
        const inputMode = params.input || 'mouse';
        const canvas = renderer.domElement;
        const onClick = (e) => {
          if (inputMode === 'keyboard') return;
          const rect = canvas.getBoundingClientRect();
          const mx = ((e.clientX - rect.left) / rect.width) * 2 - 1;
          const my = -((e.clientY - rect.top) / rect.height) * 2 + 1;
          rayCaster.setFromCamera({x: mx, y: my}, cam);
          const intersections = rayCaster.intersectObjects(potMeshesArr, false);
          if (intersections.length) runner.handleInput({type: 'raycast', object: intersections[0].object});
        };
        // Keyboard mapping: 1=紅 2=藍 3=綠 4=黃 (matches pot order 0..3), also R/B/G/Y
        const keyToIdx = {
          '1':0,'2':1,'3':2,'4':3,
          'r':0,'R':0, 'b':1,'B':1, 'g':2,'G':2, 'y':3,'Y':3,
        };
        const onKey = (e) => {
          if (inputMode === 'mouse') return;
          const idx = keyToIdx[e.key];
          if (idx !== undefined && potMeshesArr[idx]) {
            runner.handleInput({type: 'raycast', object: potMeshesArr[idx]});
          }
        };
        canvas.addEventListener('click', onClick);
        document.addEventListener('keydown', onKey);
        return () => {
          cancelAnimationFrame(raf);
          renderer.dispose();
          canvas.removeEventListener('click', onClick);
          document.removeEventListener('keydown', onKey);
          runner.abort();
        };
      }

      return ()=>{ cancelAnimationFrame(raf); renderer.dispose(); };
    }}
  />;
}

/* ════════════════════════════════════════════════════
   03 · 音符飛鳥 — Digit Span
═════════════════════════════════════════════════════ */
function Game_DigitSpan({ params, onComplete, onAbort }) {
  const ctxRef = useRef(null);
  const runnerRef = useRef(null);
  const currentDigitRef = useRef(null);
  const [currentDigit, setCurrentDigit] = useState(null);
  const [showKeypad, setShowKeypad] = useState(false);
  const [displayText, setDisplayText] = useState('_');
  const [instrText, setInstrText] = useState('聽完序列後，由後往前點擊');
  const [stars, setStars] = useState(2);

  return (
    <>
      <Scene
        chapter="第三章 · 晨曦山谷 · Short-term Memory"
        title="音符飛鳥 — Digit Span"
        subtitle="聽見星光節奏，依序回聲"
        tags={[{label:'逆向廣度',color:'#4888c8'}]}
        instr={instrText}
        reward={currentDigit !== null || showKeypad ? stars : 2}
        protocol={(window.TASK_DOCS && window.TASK_DOCS[2])
          ? buildProtocolText(2, params)
          : '<b>Protocol · Digit Span</b>  WAIS-IV / WMS — 詳細說明載入中…'}
        metrics={['Forward Span','Backward Span','Bwd−Fwd Diff','Sequence Acc']}
        init={(el)=>{
          const {renderer, scene, cam} = SK.mkScene(el, {z:8});
          SK.addBackdrop(scene, cam, (c,w,h)=>{
            SK.paintSky(c,w,h, ['#e8c99a','#e8d9bc','#c5d4e4','#b0cce0'], 0.02);
            SK.paintClouds(c,w,h,{count:5, color:'#f5f0e4'});
            SK.paintHills(c,w,h,[
              {base:.72, color:'#8bb2a0', amp:30, freq:.009, alpha:.8},
              {base:.84, color:'#6c9c82', amp:20, freq:.014, alpha:.95},
              {base:.95, color:'#4c8c5e', amp:12, freq:.02, alpha:1},
            ]);
            // sun glow
            const sg=c.createRadialGradient(w*.8,h*.3,0,w*.8,h*.3,200);
            sg.addColorStop(0,'rgba(240,200,120,.5)');
            sg.addColorStop(1,'transparent');
            c.fillStyle=sg; c.fillRect(0,0,w,h);
          }, -28);

          // 7 preview birds in an arc (ONLY shown when params is undefined — preview mode)
          const digits = [3,7,1,9,4,2,8];
          const birds = [];
          for(let i=0;i<digits.length;i++){
            const d = digits[i];
            const angle = -.8 + i*.27;
            const tex = SK.mkTex(200,200,(c,w,h)=>{
              c.clearRect(0,0,w,h);
              // glow
              const gg=c.createRadialGradient(w/2,h/2,0,w/2,h/2,w*.5);
              gg.addColorStop(0,'rgba(240,200,120,.55)');
              gg.addColorStop(.5,'rgba(240,200,120,.15)');
              gg.addColorStop(1,'transparent');
              c.fillStyle=gg; c.fillRect(0,0,w,h);
              // simple bird silhouette (two wings)
              c.fillStyle='#2c2416';
              c.beginPath();
              c.moveTo(w/2-40, h/2+5);
              c.quadraticCurveTo(w/2-20, h/2-25, w/2, h/2-5);
              c.quadraticCurveTo(w/2+20, h/2-25, w/2+40, h/2+5);
              c.quadraticCurveTo(w/2, h/2+2, w/2-40, h/2+5);
              c.fill();
              // digit below
              c.font = "800 48px 'Nunito', sans-serif";
              c.fillStyle='#2c2416';
              c.textAlign='center'; c.textBaseline='middle';
              c.fillText(d, w/2, h*.72);
              // circle
              c.strokeStyle='rgba(44,36,22,.4)';
              c.lineWidth=1.5;
              c.beginPath(); c.arc(w/2, h*.72, 22, 0, Math.PI*2); c.stroke();
            });
            const m = SK.mkPlane(1.3, 1.3, tex);
            m.position.set(Math.sin(angle)*3.8, 1 + Math.cos(angle)*1.1, 0);
            scene.add(m);
            m.visible = !params;  // hide static birds in test mode
            birds.push({m, phase:i*.5, baseX: m.position.x, baseY: m.position.y});
          }
          // tone wave at bottom
          const waveTex = SK.mkTex(800, 80, (c,w,h)=>{
            c.clearRect(0,0,w,h);
            c.strokeStyle='rgba(240,200,120,.55)';
            c.lineWidth=2.5;
            c.beginPath();
            for(let x=0;x<w;x++){
              const y = h/2 + Math.sin(x*.03)*20*Math.sin(x*.005)*Math.sin(x*.008+1);
              x===0?c.moveTo(x,y):c.lineTo(x,y);
            }
            c.stroke();
          });
          const wave = SK.mkPlane(6, .6, waveTex);
          wave.position.y = -2.4;
          scene.add(wave);

          // --- Test-mode: dynamic bird slots, sized to current span ---
          const dynamicBirds = [];
          function drawBirdTex(digit, isActive) {
            return SK.mkTex(220, 220, (c, w, h) => {
              c.clearRect(0, 0, w, h);
              const gg = c.createRadialGradient(w/2, h/2, 0, w/2, h/2, w*.5);
              const glowAlpha = isActive ? '.78' : '.35';
              gg.addColorStop(0, `rgba(240,200,120,${glowAlpha})`);
              gg.addColorStop(.5, 'rgba(240,200,120,.12)');
              gg.addColorStop(1, 'transparent');
              c.fillStyle = gg; c.fillRect(0, 0, w, h);
              // bird silhouette
              c.fillStyle = '#2c2416';
              c.beginPath();
              c.moveTo(w/2-44, h/2+4);
              c.quadraticCurveTo(w/2-22, h/2-28, w/2, h/2-5);
              c.quadraticCurveTo(w/2+22, h/2-28, w/2+44, h/2+4);
              c.quadraticCurveTo(w/2, h/2+2, w/2-44, h/2+4);
              c.fill();
              // digit below bird — only when a digit is supplied
              if (digit != null) {
                c.font = "800 56px 'Nunito', sans-serif";
                c.fillStyle = '#2c2416';
                c.textAlign = 'center'; c.textBaseline = 'middle';
                c.fillText(String(digit), w/2, h*.72);
                c.strokeStyle = 'rgba(44,36,22,.45)';
                c.lineWidth = 1.8;
                c.beginPath(); c.arc(w/2, h*.72, 26, 0, Math.PI*2); c.stroke();
              }
            });
          }
          function clearDynamicBirds() {
            dynamicBirds.forEach(b => { scene.remove(b.m); });
            dynamicBirds.length = 0;
          }
          function createDynamicBirds(n) {
            clearDynamicBirds();
            // Arc-layout: symmetric around x=0, total width capped
            const maxSpread = 4.8;          // world units from leftmost to rightmost center
            const spread = Math.min(maxSpread, n * 1.15);
            for (let i = 0; i < n; i++) {
              const tx = n === 1 ? 0 : -spread/2 + (spread * i / (n - 1));
              const arcY = 1.2 - Math.pow(tx / 3, 2) * 0.5; // gentle arc
              const tex = drawBirdTex(null, false);
              const m = SK.mkPlane(1.3, 1.3, tex);
              m.position.set(tx, arcY, 0);
              scene.add(m);
              dynamicBirds.push({ m, tex, phase: i * 0.55, baseX: tx, baseY: arcY, digit: null, activeUntil: 0 });
            }
          }
          function showDigitOnSlot(i, d) {
            const b = dynamicBirds[i];
            if (!b) return;
            b.m.material.map = drawBirdTex(d, true);
            b.m.material.needsUpdate = true;
            b.digit = d;
            b.activeUntil = performance.now() + 5000;
          }
          function clearSlotDigit(i) {
            const b = dynamicBirds[i];
            if (!b) return;
            b.m.material.map = drawBirdTex(null, false);
            b.m.material.needsUpdate = true;
            b.digit = null;
          }

          let raf, t=0;
          const render = ()=>{
            t += 16;
            // Preview mode: animate all 7 static birds, highlight bird 3 (demo)
            if (!params) {
              birds.forEach((b,i)=>{
                b.m.position.y = b.baseY + Math.sin(t*.0015 + b.phase)*.12;
                b.m.rotation.z = Math.sin(t*.0012 + b.phase)*.05;
                b.m.scale.setScalar(i === 3 ? 1.3 + Math.sin(t*.005)*.05 : 1);
              });
            } else {
              // Test mode: animate dynamic birds
              dynamicBirds.forEach((b) => {
                b.m.position.y = b.baseY + Math.sin(t*.0015 + b.phase)*.08;
                b.m.rotation.z = Math.sin(t*.0012 + b.phase)*.04;
                const isActive = b.digit != null;
                b.m.scale.setScalar(isActive ? 1.15 + Math.sin(t*.005)*.04 : 1);
              });
            }
            renderer.render(scene, cam);
            raf = requestAnimationFrame(render);
          };
          render();

          if (params) {
            let activeSlotIdx = -1;
            ctxRef.current = {
              renderer, scene, cam, birds: dynamicBirds,
              _level: params.level || 'basic',
              _direction: params.direction || 'both',
              _maxSpan: +params.maxSpan || 9,
              _isi: +params.isi || 1000,
              _seqIdx: 0,
              setPhase: () => {},
              onSpanStart: (n) => {
                activeSlotIdx = -1;
                createDynamicBirds(n);
              },
              onSpanEnd: () => {
                activeSlotIdx = -1;
                clearDynamicBirds();
              },
              onSetCurrentDigit: (d) => {
                currentDigitRef.current = d;
                setCurrentDigit(d);
                if (d != null) {
                  activeSlotIdx++;
                  if (activeSlotIdx >= 0 && activeSlotIdx < dynamicBirds.length) {
                    showDigitOnSlot(activeSlotIdx, d);
                  }
                } else {
                  if (activeSlotIdx >= 0 && activeSlotIdx < dynamicBirds.length) {
                    clearSlotDigit(activeSlotIdx);
                  }
                }
              },
              onShowKeypad: (show) => setShowKeypad(show),
              onSetDisplay: (txt) => setDisplayText(txt),
            };
            const runner = new window.TestRunner(2, params, ctxRef.current, {
              onComplete: (r) => {
                setStars(3);
                onComplete && onComplete(r);
              },
              setInstr: (txt) => setInstrText(txt),
            });
            runnerRef.current = runner;
            window.__themyndRunner = runner;
            const inputMode = params.input || 'onscreen';
            const onKey = (e) => {
              if (inputMode === 'onscreen') return;
              if (/^[1-9]$/.test(e.key)) runner.handleInput({type:'keypad', key: e.key});
              else if (e.key === 'Backspace') runner.handleInput({type:'keypad', key:'del'});
              else if (e.key === 'Enter') runner.handleInput({type:'keypad', key:'ok'});
            };
            document.addEventListener('keydown', onKey);
            return () => {
              cancelAnimationFrame(raf);
              renderer.dispose();
              document.removeEventListener('keydown', onKey);
              runner.abort();
            };
          }

          return ()=>{ cancelAnimationFrame(raf); renderer.dispose(); };
        }}
      />
      {showKeypad && (
        <div style={{
          position:'fixed', top:'50%', right:'5%', transform:'translateY(-50%)',
          zIndex:40, background:'rgba(245,240,228,.97)',
          border:'1px solid rgba(140,120,90,.35)', borderRadius:8,
          padding:'14px 16px 12px', display:'flex', flexDirection:'column', gap:8,
          boxShadow:'0 6px 24px rgba(30,40,20,.3)',
          width: params?.level === 'intermediate' ? 280 : 210,
        }}>
          <div style={{
            textAlign:'center', fontSize:'1.35rem', fontFamily:'var(--round)',
            fontWeight:800, color:'var(--inkw)', letterSpacing:'.14em',
            padding:'10px 18px', background:'var(--parch)',
            border:'1px solid var(--wcbdr)', borderRadius:4,
            minHeight:'2.1rem', marginBottom:6,
          }}>{displayText || ' '}</div>
          <div style={{display:'grid', gridTemplateColumns:'repeat(3,1fr)', gap:6}}>
            {[1,2,3,4,5,6,7,8,9].map(n => (
              <button key={n}
                onClick={() => runnerRef.current?.handleInput({type:'keypad', key:String(n)})}
                style={{
                  padding:'12px 0', fontFamily:'var(--round)', fontWeight:700,
                  fontSize:'1.1rem', background:'var(--cream)',
                  border:'1px solid var(--wcbdr)', cursor:'pointer',
                  color:'var(--inkw)', borderRadius:4,
                }}>{n}</button>
            ))}
          </div>
          {params?.level === 'intermediate' && (
            <div style={{display:'grid', gridTemplateColumns:'repeat(5,1fr)', gap:5, marginTop:4}}>
              {['甲','乙','丙','丁','戊','己','庚','辛','壬','癸'].map(s => (
                <button key={s}
                  onClick={() => runnerRef.current?.handleInput({type:'keypad', key:s})}
                  style={{
                    padding:'10px 0', fontFamily:'var(--round)', fontWeight:700,
                    fontSize:'1rem', background:'#efe6d0',
                    border:'1px solid var(--wcbdr)', cursor:'pointer',
                    color:'var(--inkw)', borderRadius:4,
                  }}>{s}</button>
              ))}
            </div>
          )}
          <div style={{display:'grid', gridTemplateColumns:'repeat(3,1fr)', gap:6, marginTop:4}}>
            <button onClick={() => runnerRef.current?.handleInput({type:'keypad', key:'del'})}
              style={{padding:'12px 0', fontFamily:'var(--round)', fontWeight:600,
                background:'var(--cream)', border:'1px solid var(--wcbdr)',
                cursor:'pointer', color:'var(--rose)', borderRadius:4, fontSize:'1rem'}}>⌫</button>
            <button onClick={() => runnerRef.current?.handleInput({type:'keypad', key:'0'})}
              style={{padding:'12px 0', fontFamily:'var(--round)', fontWeight:700,
                background:'var(--cream)', border:'1px solid var(--wcbdr)',
                cursor:'pointer', color:'var(--inkw)', borderRadius:4, fontSize:'1.1rem'}}>0</button>
            <button onClick={() => runnerRef.current?.handleInput({type:'keypad', key:'ok'})}
              style={{padding:'12px 0', fontFamily:'var(--round)', fontWeight:800,
                fontSize:'.88rem', background:'linear-gradient(135deg,#d4931a,#f0c858)',
                border:'none', cursor:'pointer', color:'#2c2416', borderRadius:4}}>確認</button>
          </div>
        </div>
      )}
    </>
  );
}

/* ════════════════════════════════════════════════════
   04 · 分類花園 — WCST
═════════════════════════════════════════════════════ */
function Game_WCST({ params, onComplete, onAbort }){
  const ctxRef = useRef(null);
  const runnerRef = useRef(null);
  const [instrText, setInstrText] = useState('把這株幼苗分到相配的花床');
  const [stars, setStars] = useState(2);
  return <Scene
    chapter="第四章 · 變幻花園 · Cognitive Flexibility"
    title="分類花園 — WCST"
    subtitle="規則悄悄改變 · 仔細觀察"
    tags={[{label:'規則：顏色',color:'#c8921c'}]}
    instr={instrText}
    reward={stars}
    protocol={(window.TASK_DOCS && window.TASK_DOCS[3])
      ? buildProtocolText(3, params)
      : '<b>Protocol · WCST</b>  Berg 1948 / Heaton 1981 — 詳細說明載入中…'}
    metrics={['Categories','Perseverative Err','Non-Persev Err','Failure to Maintain']}
    init={(el)=>{
      const {renderer, scene, cam} = SK.mkScene(el, {z:8});
      SK.addBackdrop(scene, cam, (c,w,h)=>{
        SK.paintSky(c,w,h, ['#dde5c8','#c6d6a8','#a0c088','#7ab88a'], 0.02);
        // soft shafts of light
        c.fillStyle='rgba(255,240,200,.18)';
        for(let i=0;i<4;i++){
          const x = Math.random()*w;
          c.save();
          c.translate(x,0); c.rotate(.2);
          c.fillRect(-40,0,80,h);
          c.restore();
        }
        SK.paintHills(c,w,h,[{base:.85, color:'#4c8c5e', amp:14, freq:.015, alpha:.9}]);
      }, -24);

      // 4 target flower-beds at top — each gets a distinct size 0..3
      // (size dimension is only "active" as a sorting rule when level=intermediate,
      //  but we always render it so cards are visually consistent).
      const targets = [
        {count:1, shape:'circle',   col:'#c84030', size:0},  // 极小 red
        {count:2, shape:'triangle', col:'#3060b8', size:1},  // 小 blue
        {count:3, shape:'square',   col:'#3aa858', size:2},  // 大 green
        {count:4, shape:'star',     col:'#d4a218', size:3},  // 极大 yellow
      ];
      // Size multipliers for shape radius
      const SIZE_MULT = [0.65, 0.85, 1.10, 1.40];
      function drawShape(c, shape, col, cx, cy, r){
        c.fillStyle = col;
        if(shape==='circle'){ c.beginPath(); c.arc(cx,cy,r,0,Math.PI*2); c.fill(); }
        else if(shape==='triangle'){
          c.beginPath(); c.moveTo(cx, cy-r); c.lineTo(cx+r*.87, cy+r*.5); c.lineTo(cx-r*.87, cy+r*.5); c.closePath(); c.fill();
        } else if(shape==='square'){ c.fillRect(cx-r*.78, cy-r*.78, r*1.56, r*1.56); }
        else { // star
          c.beginPath();
          for(let i=0;i<10;i++){
            const ang = -Math.PI/2 + i*Math.PI/5;
            const rr = i%2===0 ? r : r*.5;
            const px = cx+Math.cos(ang)*rr;
            const py = cy+Math.sin(ang)*rr;
            i===0?c.moveTo(px,py):c.lineTo(px,py);
          }
          c.closePath(); c.fill();
        }
      }
      const targetMeshesArr = [];
      targets.forEach((t,i)=>{
        const tex = SK.mkTex(280,360,(c,w,h)=>{
          c.clearRect(0,0,w,h);
          // parchment card
          c.fillStyle='rgba(60,40,20,.2)';
          c.fillRect(8,12,w-12,h-16);
          c.fillStyle='#f5f0e4';
          c.fillRect(4,4,w-12,h-16);
          c.strokeStyle='rgba(140,100,60,.5)';
          c.lineWidth=2;
          c.strokeRect(4,4,w-12,h-16);
          // flower pot base (shrunk so it doesn't cover the label)
          c.fillStyle='rgba(110,70,40,.7)';
          c.fillRect(w*.25, h*.80, w*.5, h*.07);
          // shapes (shifted up slightly to avoid pot overlap)
          const positions = {
            1: [[.5,.45]],
            2: [[.35,.45],[.65,.45]],
            3: [[.35,.36],[.65,.36],[.5,.60]],
            4: [[.35,.34],[.65,.34],[.35,.58],[.65,.58]],
          };
          const pos = positions[t.count];
          const r = 24 * (SIZE_MULT[t.size] || 1.0);
          pos.forEach(([px,py])=>drawShape(c, t.shape, t.col, w*px, h*py, r));
          // number mark (placed clearly below pot with enough baseline room)
          c.font = "700 22px 'Noto Serif TC', serif";
          c.fillStyle='#8a7a60';
          c.textAlign='center'; c.textBaseline='middle';
          c.fillText(t.count + (t.shape==='circle'?'朵':''), w/2, h*.935);
        });
        const mesh = SK.mkPlane(1.5, 1.92, tex);
        mesh.position.set(-3.6 + i*2.4, 1.2, 0);
        scene.add(mesh);
        targetMeshesArr.push(mesh);
      });

      // current card to sort (2 blue triangles)
      const handTex = SK.mkTex(320, 400,(c,w,h)=>{
        c.clearRect(0,0,w,h);
        c.fillStyle='rgba(60,40,20,.25)';
        c.fillRect(10,14,w-14,h-18);
        c.fillStyle='#fff8e6';
        c.fillRect(4,4,w-14,h-18);
        c.strokeStyle='rgba(212,147,26,.8)';
        c.lineWidth=3;
        c.strokeRect(4,4,w-14,h-18);
        drawShape(c,'triangle','#c84030',w*.35, h*.45, 32);
        drawShape(c,'triangle','#c84030',w*.65, h*.45, 32);
        c.font = "700 22px 'Noto Serif TC', serif";
        c.fillStyle='#2c2416';
        c.textAlign='center';
        c.fillText('我的幼苗', w/2, h*.88);
      });
      const hand = SK.mkPlane(1.7, 2.1, handTex);
      hand.position.set(0, -1.25, 0);
      scene.add(hand);

      // Feedback ring — hidden until player selects a card (flash green/red, then fade)
      const ring = new THREE.Mesh(
        new THREE.RingGeometry(.95, 1.04, 32),
        new THREE.MeshBasicMaterial({color:0xc84030, transparent:true, opacity:.8, side:THREE.DoubleSide})
      );
      ring.position.set(-3.6, 1.2, .02);
      ring.visible = !params;  // preview mode: show; test mode: hidden until feedback
      scene.add(ring);

      let raf, t=0;
      const render = ()=>{
        t+=16;
        ring.rotation.z = t*.001;
        ring.scale.setScalar(1 + Math.sin(t*.003)*.06);
        hand.position.y = -1.25 + Math.sin(t*.0015)*.05;
        renderer.render(scene, cam);
        raf = requestAnimationFrame(render);
      };
      render();

      // targetDefs — must match the visible target flower-beds above.
      // n=1-4 (count), color=0-3 (red, green, blue, yellow), shape=0-3 (circle, triangle, square, star), size=0-3 (XS, S, L, XL)
      const targetDefs = [
        {n:1, color:0, shape:0, size:0},  // 1 small red circle
        {n:2, color:2, shape:1, size:1},  // 2 small blue triangles
        {n:3, color:1, shape:2, size:2},  // 3 large green squares
        {n:4, color:3, shape:3, size:3},  // 4 huge yellow stars
      ];

      // drawHandCard — redraw the hand mesh texture for a given currentDef
      function drawHandCard(def) {
        const cols = ['#c84030', '#3aa858', '#3060b8', '#d4a218']; // by def.color index: red, green, blue, yellow
        const shapes = ['circle', 'triangle', 'square', 'star'];   // by def.shape index
        const handImg = handTex.image;
        const hc = handImg.getContext('2d');
        const W = handImg.width, H = handImg.height;
        hc.clearRect(0, 0, W, H);
        // parchment card background
        hc.fillStyle = 'rgba(60,40,20,.25)';
        hc.fillRect(10, 14, W-14, H-18);
        hc.fillStyle = '#fff8e6';
        hc.fillRect(4, 4, W-14, H-18);
        hc.strokeStyle = 'rgba(212,147,26,.8)';
        hc.lineWidth = 3;
        hc.strokeRect(4, 4, W-14, H-18);
        // shapes
        const positions = {
          1: [[.5,.5]],
          2: [[.35,.5],[.65,.5]],
          3: [[.35,.4],[.65,.4],[.5,.65]],
          4: [[.35,.38],[.65,.38],[.35,.62],[.65,.62]],
        };
        const ps = positions[def.n]; // def.n is 1-4
        const r = 28 * (SIZE_MULT[def.size ?? 1] || 1.0);
        ps.forEach(([px,py]) => drawShape(hc, shapes[def.shape], cols[def.color], W*px, H*py, r));
        // label
        hc.font = "700 22px 'Noto Serif TC', serif";
        hc.fillStyle = '#2c2416';
        hc.textAlign = 'center';
        hc.fillText('我的幼苗', W/2, H*.88);
        handTex.needsUpdate = true;
      }

      const setPhase = () => {};  // WCST freeform — no trial-phase callback needed

      if (params) {
        const rayCaster = new THREE.Raycaster();
        ctxRef.current = {
          renderer, scene, cam, ring,
          targetMeshes: targetMeshesArr,
          targetDefs,
          _level: params.level || 'basic',
          _feedbackReliability: +params.feedbackReliability || 1.0,
          _maxCat: +params.categories || 6,
          _maxTrials: +params.maxTrials || 128,
          setPhase,
          onSetHandDef: (def) => drawHandCard(def),
          onFeedback: (correct, clickedIdx) => {
            // Flash feedback ring on the chosen target, then hide.
            ring.position.set(-3.6 + clickedIdx * 2.4, 1.2, .02);
            ring.material.color.set(correct ? 0x3aa858 : 0xc84030);
            ring.material.opacity = 0.9;
            ring.visible = true;
            setTimeout(() => { if (ring) ring.visible = false; }, 650);
            if (correct) setStars(s => Math.min(3, s + 1));
          },
        };
        const runner = new window.TestRunner(3, params, ctxRef.current, {
          onComplete: (r) => onComplete && onComplete(r),
          setInstr: (txt) => setInstrText(txt),
        });
        runnerRef.current = runner;
        window.__themyndRunner = runner;

        const inputMode = params.input || 'mouse';
        const canvas = renderer.domElement;
        const onClick = (e) => {
          if (inputMode === 'keyboard') return;
          const rect = canvas.getBoundingClientRect();
          const mx = ((e.clientX - rect.left) / rect.width) * 2 - 1;
          const my = -((e.clientY - rect.top) / rect.height) * 2 + 1;
          rayCaster.setFromCamera({x: mx, y: my}, cam);
          const hits2 = rayCaster.intersectObjects(targetMeshesArr, false);
          if (hits2.length) runner.handleInput({type: 'raycast', object: hits2[0].object});
        };
        const onKey = (e) => {
          if (inputMode === 'mouse') return;
          const idx = '1234'.indexOf(e.key);
          if (idx >= 0 && targetMeshesArr[idx]) {
            runner.handleInput({type: 'raycast', object: targetMeshesArr[idx]});
          }
        };
        canvas.addEventListener('click', onClick);
        document.addEventListener('keydown', onKey);

        return () => {
          cancelAnimationFrame(raf);
          renderer.dispose();
          canvas.removeEventListener('click', onClick);
          document.removeEventListener('keydown', onKey);
          runner.abort();
        };
      }

      return ()=>{ cancelAnimationFrame(raf); renderer.dispose(); };
    }}
  />;
}

/* ════════════════════════════════════════════════════
   05 · 螢火蟲小徑 — Trail Making A/B
═════════════════════════════════════════════════════ */
function Game_Trail({ params, onComplete, onAbort }) {
  const ctxRef = useRef(null);
  const runnerRef = useRef(null);
  const [instrText, setInstrText] = useState('1 → 甲 → 2 → 乙 → 3 ...');
  const [stars, setStars] = useState(1);
  return <Scene
    chapter="第五章 · 夜霧森林 · Processing Speed"
    title="螢火蟲小徑 — Trail Making"
    subtitle="按順序輕觸螢火 · 畫出森林小徑"
    tags={[{label:'PART B',color:'#c8921c'}]}
    instr={instrText}
    reward={stars}
    protocol={(window.TASK_DOCS && window.TASK_DOCS[4])
      ? buildProtocolText(4, params)
      : '<b>Protocol · Trail Making</b>  Reitan 1958 / D\'Elia 1996 / D-KEFS 2001 — 詳細說明載入中…'}
    metrics={['Part A Time','Part B Time','B−A Flex','Errors']}
    init={(el)=>{
      const {renderer, scene, cam} = SK.mkScene(el, {z:8});
      SK.addBackdrop(scene, cam, (c,w,h)=>{
        SK.paintSky(c,w,h, ['#1c2540','#2b3a6c','#3a5582','#5276a0'], 0.02);
        SK.paintStars(c,w,h,{count:120});
        SK.paintHills(c,w,h,[
          {base:.72, color:'#1e3c28', amp:28, freq:.009, alpha:.9},
          {base:.85, color:'#0f2818', amp:18, freq:.014, alpha:1},
        ]);
        SK.paintTrees(c,w,h,{count:10, yMin:.55, yMax:.78, colors:['#0f2818','#1e3c28']});
      }, -24);

      // scatter of nodes with labels
      const nodes = [
        {l:'1',x:-3.6,y:1.8, col:'#f0c858'},
        {l:'甲',x:-1.8,y:.9, col:'#aed0ff'},
        {l:'2',x:-.5,y:2.0, col:'#f0c858'},
        {l:'乙',x:1.2,y:1.1, col:'#aed0ff'},
        {l:'3',x:2.8,y:2.3, col:'#f0c858'},
        {l:'丙',x:3.6,y:.4, col:'#aed0ff'},
        {l:'4',x:2.1,y:-.6, col:'#f0c858'},
        {l:'丁',x:.4,y:-.3, col:'#aed0ff'},
        {l:'5',x:-1.2,y:-1.1, col:'#f0c858'},
        {l:'戊',x:-3.0,y:-.4, col:'#aed0ff'},
        {l:'6',x:-2.0,y:-2.0, col:'#f0c858'},
      ];
      nodes.forEach((n,i)=>{
        const tex = SK.mkTex(128,128,(c,w,h)=>{
          c.clearRect(0,0,w,h);
          const g=c.createRadialGradient(w/2,h/2,0,w/2,h/2,w*.45);
          g.addColorStop(0, n.col+'ff');
          g.addColorStop(.5, n.col+'55');
          g.addColorStop(1, n.col+'00');
          c.fillStyle=g; c.fillRect(0,0,w,h);
          c.fillStyle=n.col;
          c.beginPath(); c.arc(w/2,h/2,w*.2,0,Math.PI*2); c.fill();
          c.fillStyle='#1a2030';
          c.font="700 38px 'Noto Serif TC', sans-serif";
          c.textAlign='center'; c.textBaseline='middle';
          c.fillText(n.l, w/2, h/2);
        });
        const m = SK.mkPlane(1, 1, tex);
        m.position.set(n.x, n.y, 0);
        scene.add(m);
        n.mesh = m;
        n.phase = i*.3;
      });

      // drawn trail through first 3 nodes
      const trailPts = nodes.slice(0,3).map(n=>new THREE.Vector3(n.x, n.y, .01));
      const curve = new THREE.CatmullRomCurve3(trailPts);
      const curvePts = curve.getPoints(60);
      const trailGeom = new THREE.BufferGeometry().setFromPoints(curvePts);
      const trail = new THREE.Line(trailGeom, new THREE.LineBasicMaterial({color:0xf0c858, transparent:true, opacity:.7}));
      scene.add(trail);

      // fireflies
      const ff = SK.makeFireflies(scene, 50, {x:6, y:3, z:1});

      let raf, t=0;
      const render = ()=>{
        t+=16;
        ff.tick(t);
        nodes.forEach((n,i)=>{
          n.mesh.scale.setScalar(1 + Math.sin(t*.002 + n.phase)*.06);
        });
        renderer.render(scene, cam);
        raf = requestAnimationFrame(render);
      };
      render();

      if (params) {
        // Remove the static preview nodes and demo trail
        nodes.forEach(n => scene.remove(n.mesh));
        scene.remove(trail);

        const rayCaster = new THREE.Raycaster();
        const nodeMeshes = [];     // dynamic array; will be populated by onBuildNodes
        const connectorLines = []; // track drawn connector lines for removal on part transition
        const lineMat = new THREE.LineBasicMaterial({color: 0xf0c858, transparent: true, opacity: .75});

        ctxRef.current = {
          renderer, scene, cam, nodeMeshes,
          _level: params.level || 'basic',
          _nextIdx: 0, _startTime: 0, _errors: 0,
          setPhase: () => {},
          onBuildNodes: (labels, part) => {
            // Remove previous nodes
            nodeMeshes.forEach(n => scene.remove(n.mesh));
            nodeMeshes.length = 0;
            connectorLines.forEach(l => scene.remove(l));
            connectorLines.length = 0;

            // Scatter positions with adaptive min-spacing sized to label count.
            // Each node renders at ~1 world unit (mkPlane(1,1)), so nodes need
            // at least 1.1 units center-to-center to avoid visual overlap.
            const W = 8.4, H = 5.4, margin = 0.55;
            const nodeSize = 1.0;
            // Geometric lower bound: sqrt(area / count) * 0.9; clamp to [1.15, 1.6]
            const areaPerNode = (W - margin * 2) * (H - margin * 2) / labels.length;
            const geomSep = Math.sqrt(areaPerNode) * 0.9;
            const minSep = Math.max(nodeSize + 0.15, Math.min(1.6, geomSep));
            const positions = [];
            labels.forEach((_, i) => {
              let pos, ok, tries = 0;
              let sep = minSep;
              do {
                pos = {
                  x: (Math.random() - .5) * (W - margin * 2),
                  y: (Math.random() - .5) * (H - margin * 2) - 0.2,
                };
                ok = positions.every(p => Math.hypot(p.x - pos.x, p.y - pos.y) > sep);
                tries++;
                // Relax spacing slightly if packing is tight
                if (tries > 0 && tries % 80 === 0) sep *= 0.95;
              } while (!ok && tries < 400);
              positions.push(pos);
            });

            // Color palette for CTT / D-KEFS modes (set by task-impl via ctx._labelColors)
            const COLOR_MAP = {
              cream:  '#f5e8c0',
              pink:   '#e890b0',
              yellow: '#f0c858',
              green:  '#5ad080',
              gray:   '#8090a0',
            };
            labels.forEach((lbl, i) => {
              const {x, y} = positions[i];
              let col;
              const colorTag = ctxRef.current && ctxRef.current._labelColors && ctxRef.current._labelColors[i];
              if (colorTag && COLOR_MAP[colorTag]) {
                col = COLOR_MAP[colorTag];
              } else {
                // basic Part B fallback: digits yellow, stems blue
                const isNumeric = /^\d/.test(lbl);
                col = isNumeric ? '#f0c858' : '#aed0ff';
              }
              const tex = SK.mkTex(128, 128, (c, w, h) => {
                c.clearRect(0, 0, w, h);
                const gg = c.createRadialGradient(w/2, h/2, 0, w/2, h/2, w*.45);
                gg.addColorStop(0, col + 'ff');
                gg.addColorStop(.5, col + '55');
                gg.addColorStop(1, col + '00');
                c.fillStyle = gg; c.fillRect(0, 0, w, h);
                c.fillStyle = col;
                c.beginPath(); c.arc(w/2, h/2, w*.2, 0, Math.PI*2); c.fill();
                c.fillStyle = '#1a2030';
                c.font = "700 38px 'Noto Serif TC', sans-serif";
                c.textAlign = 'center'; c.textBaseline = 'middle';
                c.fillText(lbl, w/2, h/2);
              });
              const m = SK.mkPlane(1, 1, tex);
              m.position.set(x, y, 0);
              scene.add(m);
              nodeMeshes.push({mesh: m, label: lbl, idx: i, x, y, phase: i * .3});
            });
          },
          onNodeError: (idx) => {
            const n = nodeMeshes[idx];
            if (!n) return;
            n.mesh.material.color.set(0xff4444);
            setTimeout(() => {
              if (n.mesh.material) n.mesh.material.color.set(0xffffff);
            }, 250);
          },
          onNodeConnect: (idx) => {
            const n = nodeMeshes[idx];
            if (!n) return;
            // Tint the connected node green
            n.mesh.material.color.set(0x3aa858);
            // Draw connector from previous node
            if (idx > 0) {
              const prev = nodeMeshes[idx - 1];
              const pts = [new THREE.Vector3(prev.x, prev.y, .01), new THREE.Vector3(n.x, n.y, .01)];
              const line = new THREE.Line(new THREE.BufferGeometry().setFromPoints(pts), lineMat);
              scene.add(line);
              connectorLines.push(line);
            }
            setStars(s => Math.min(3, s + (idx % 4 === 0 ? 1 : 0)));
          },
        };

        const runner = new window.TestRunner(4, params, ctxRef.current, {
          onComplete: (r) => onComplete && onComplete(r),
          setInstr: (txt) => setInstrText(txt),
        });
        runnerRef.current = runner;
        window.__themyndRunner = runner;

        const canvas = renderer.domElement;
        const onClick = (e) => {
          const rect = canvas.getBoundingClientRect();
          const mx = ((e.clientX - rect.left) / rect.width) * 2 - 1;
          const my = -((e.clientY - rect.top) / rect.height) * 2 + 1;
          rayCaster.setFromCamera({x: mx, y: my}, cam);
          const meshes = nodeMeshes.map(n => n.mesh);
          const hits2 = rayCaster.intersectObjects(meshes, false);
          if (hits2.length) runner.handleInput({type: 'raycast', object: hits2[0].object});
        };
        canvas.addEventListener('click', onClick);

        return () => {
          cancelAnimationFrame(raf);
          renderer.dispose();
          canvas.removeEventListener('click', onClick);
          runner.abort();
        };
      }

      return ()=>{ cancelAnimationFrame(raf); renderer.dispose(); };
    }}
  />;
}

/* ════════════════════════════════════════════════════
   13 · 星辰堆疊 — Corsi Block-Tapping
   Visual: 月池荷花 (moon pond lily pads) — 9 lily pads at
   Kessels et al. 2000 canonical positions, moon + willow
   fronds. Pads glow gold + ripple when lit.
═════════════════════════════════════════════════════ */
function Game_Corsi({ params, onComplete, onAbort }) {
  const ctxRef = useRef(null);
  const runnerRef = useRef(null);
  const [spanUI, setSpanUI] = useState(2);
  const [hits, setHits] = useState([]);
  const [stars, setStars] = useState(2);
  const [instrText, setInstrText] = useState('依序輕觸剛才亮起的荷葉');

  // Kessels et al. 2000 canonical positions (normalized 0..1 in board coords)
  const KL = [
    [.20,.20],[.82,.18],[.48,.30],
    [.28,.50],[.66,.44],[.14,.64],
    [.54,.66],[.84,.62],[.38,.82],
  ];

  return <Scene
    chapter="第十三章 · 月池荷花 · Visuospatial Short-term Memory"
    title="星辰堆疊 — Corsi Block-Tapping"
    subtitle="看月光依序點亮荷葉 · 依序輕觸喚回"
    tags={[
      {label:'FORWARD SPAN', color:'#4888c8'},
      {label:'~8 min', color:'#8a7a60'},
    ]}
    instr={instrText}
    reward={spanUI > 2 ? stars : 2}
    trialDots={{total:10, cur:Math.min(9, spanUI-2), hits:hits.slice(-10)}}
    protocol={(window.TASK_DOCS && window.TASK_DOCS[5])
      ? buildProtocolText(5, params)
      : '<b>Protocol · Corsi</b>  Corsi 1972 / Kessels 2000 — 詳細說明載入中…'}
    metrics={['Corsi 廣度','正確試次','總分 (Kessels)','反應時間 RT']}
    init={(el) => {
      const {renderer, scene, cam} = SK.mkScene(el, {z:9});

      SK.addBackdrop(scene, cam, (c,w,h)=>{
        SK.paintSky(c,w,h, ['#3a4a78','#6a7aa0','#a898b8','#d4a490','#e8c5a0'], 0.02);
        const mx = w*.78, my = h*.28, mr = 54;
        const mg = c.createRadialGradient(mx,my,0,mx,my,mr*2.4);
        mg.addColorStop(0,'rgba(255,248,220,.85)');
        mg.addColorStop(.3,'rgba(255,240,200,.35)');
        mg.addColorStop(1,'rgba(255,240,200,0)');
        c.fillStyle=mg; c.fillRect(mx-mr*3,my-mr*3,mr*6,mr*6);
        c.fillStyle='#fdf4da';
        c.beginPath(); c.arc(mx,my,mr,0,Math.PI*2); c.fill();
        SK.paintStars(c,w,h,{count:30,color:'#f8f0d8'});
        SK.paintHills(c,w,h,[
          {base:.58, color:'#3a4566', amp:32, freq:.007, alpha:.75},
          {base:.66, color:'#28304e', amp:24, freq:.011, alpha:.88},
        ]);
        const wg=c.createLinearGradient(0,h*.68,0,h);
        wg.addColorStop(0,'#28304e');
        wg.addColorStop(.4,'#3a4566');
        wg.addColorStop(1,'#5a6686');
        c.fillStyle=wg; c.fillRect(0,h*.68,w,h*.32);
        c.fillStyle='rgba(253,244,218,.28)';
        for(let i=0;i<12;i++){
          const y = h*.72 + i*6;
          const fade = 1 - i/12;
          c.globalAlpha = .35*fade;
          c.fillRect(mx - 22 - i*1.2, y, 44+i*2.4, 2);
        }
        c.globalAlpha=1;
        c.strokeStyle='rgba(30,50,40,.55)';
        c.lineWidth=2;
        for(let i=0;i<14;i++){
          c.beginPath();
          c.moveTo(w*.06 + i*6, 0);
          c.quadraticCurveTo(w*.1 + i*5, h*.15, w*.04 + i*4, h*.38);
          c.stroke();
        }
      }, -26);

      // 9 lotus pads at Kessels positions
      const pads = KL.map((p,i)=>{
        const x = (p[0]-.5)*6.8;
        const y = -(p[1]-.5)*4.0 - .4;
        const tex = SK.mkTex(256,256,(c,w,h)=>{
          c.clearRect(0,0,w,h);
          const sh = c.createRadialGradient(w/2+6, h/2+10, 10, w/2+6, h/2+10, w/2);
          sh.addColorStop(0,'rgba(0,10,20,.45)');
          sh.addColorStop(1,'rgba(0,10,20,0)');
          c.fillStyle=sh; c.fillRect(0,0,w,h);
          const lg = c.createRadialGradient(w/2-14,h/2-18,6,w/2,h/2,w/2-8);
          lg.addColorStop(0,'#6aa070');
          lg.addColorStop(.5,'#4a8055');
          lg.addColorStop(1,'#2d5c38');
          c.fillStyle=lg;
          c.beginPath();
          c.moveTo(w/2, 20);
          for(let a=-Math.PI/2+.18; a<Math.PI*1.5-.18; a+=.06){
            const rr = (w/2-10) * (.95 + Math.sin(a*5)*.02);
            c.lineTo(w/2+Math.cos(a)*rr, h/2+Math.sin(a)*rr);
          }
          c.closePath(); c.fill();
          c.fillStyle='rgba(20,30,40,.6)';
          c.beginPath();
          c.moveTo(w/2, h/2);
          c.lineTo(w/2-10, 20);
          c.lineTo(w/2+10, 20);
          c.closePath(); c.fill();
          c.strokeStyle='rgba(30,60,40,.5)';
          c.lineWidth=1.5;
          for(let a=0;a<Math.PI*2;a+=Math.PI/8){
            c.beginPath();
            c.moveTo(w/2,h/2);
            c.lineTo(w/2+Math.cos(a)*(w/2-16), h/2+Math.sin(a)*(h/2-16));
            c.stroke();
          }
          c.fillStyle='rgba(200,230,200,.22)';
          c.beginPath(); c.ellipse(w/2-28,h/2-22,20,8,-.4,0,Math.PI*2); c.fill();
        });
        const mesh = SK.mkPlane(1.4, 1.4, tex);
        mesh.position.set(x, y, 0);
        scene.add(mesh);

        const glowTex = SK.makeGlow('#f0c858', 128);
        const glow = new THREE.Mesh(
          new THREE.PlaneGeometry(2.4, 2.4),
          new THREE.MeshBasicMaterial({
            map: glowTex, transparent:true, opacity:0,
            depthWrite:false, blending: THREE.AdditiveBlending
          })
        );
        glow.position.set(x, y, .01);
        scene.add(glow);

        return { i, mesh, glow, phase: Math.random()*Math.PI*2, baseX:x, baseY:y };
      });

      const ripples = [];
      function addRipple(pad, colorHex = '#f0c858'){
        const t = SK.mkTex(128,128,(c,w,h)=>{
          c.clearRect(0,0,w,h);
          c.strokeStyle = colorHex;
          c.lineWidth=3;
          c.beginPath(); c.arc(w/2,h/2,w/2-6,0,Math.PI*2); c.stroke();
        });
        const m = SK.mkPlane(1.6,1.6,t);
        m.position.set(pad.baseX, pad.baseY, .005);
        m.material.blending = THREE.AdditiveBlending;
        m.material.opacity = .7;
        scene.add(m);
        ripples.push({m, born: performance.now()});
      }

      const ff = SK.makeFireflies(scene, 22, {x:5, y:3, z:1.5});

      // state: which pad is currently lit (if any)
      let activePadIdx = null;
      let padFeedback = null; // {idx, color, until}

      let raf, t0 = performance.now();
      const render=()=>{
        const t = performance.now() - t0;
        ff.tick(t);
        pads.forEach((p,i)=>{
          p.mesh.position.y = p.baseY + Math.sin(t*.001 + p.phase)*.05;
          p.mesh.rotation.z = Math.sin(t*.0007 + p.phase)*.02;
          p.glow.position.y = p.mesh.position.y;
          // baseline: if not active, no glow
          if (i === activePadIdx) {
            const phase = ((t % 900) / 900);
            p.glow.material.opacity = .55 + Math.sin(phase*Math.PI)*.4;
          } else {
            p.glow.material.opacity = 0;
          }
        });
        // feedback tint
        if (padFeedback && performance.now() < padFeedback.until) {
          const p = pads[padFeedback.idx];
          p.glow.material.opacity = 0.85;
        } else if (padFeedback) {
          padFeedback = null;
        }
        // ripples
        for(let i=ripples.length-1;i>=0;i--){
          const r = ripples[i];
          const age = performance.now() - r.born;
          const s = 1 + age/700 * 1.5;
          r.m.scale.set(s,s,1);
          r.m.material.opacity = Math.max(0, .7 * (1 - age/900));
          if(age>900){ scene.remove(r.m); ripples.splice(i,1); }
        }
        renderer.render(scene, cam);
        raf = requestAnimationFrame(render);
      };
      render();

      if (params) {
        const rayCaster = new THREE.Raycaster();
        ctxRef.current = {
          renderer, scene, cam,
          starMeshes: pads.map(p => ({mesh: p.mesh})),  // TASK_IMPL talks to starMeshes
          _level: params.level || 'basic',
          _direction: params.direction || (params.level === 'basic' ? 'forward' : 'both'),
          _tempoTolerance: +params.tempoTolerance || 200,
          _maxSpan: +params.maxSpan || 9,
          _stimDur: +params.stimDur || 1000,
          _isi: +params.isi || 500,
          _phase: 'idle',
          setPhase: () => {},
          onFlashBlock: (idx) => {
            activePadIdx = idx;
            if (idx != null) addRipple(pads[idx]);
          },
          onCueInput: (on) => { /* no-op; click always active in test mode */ },
          onFeedback: (correct, expectedIdx, gotIdx) => {
            // flash gold for correct, red for wrong
            addRipple(pads[gotIdx], correct ? '#3ae890' : '#e85868');
            padFeedback = {idx: gotIdx, color: correct ? '#3ae890' : '#e85868', until: performance.now() + 420};
            setHits(h => [...h, correct]);
            if (correct) setStars(s => Math.min(3, s + 1));
          },
        };
        Object.defineProperty(ctxRef.current, '_span', {
          get() { return this.__span || 2; },
          set(v) { this.__span = v; setSpanUI(v); },
        });

        const runner = new window.TestRunner(5, params, ctxRef.current, {
          onComplete: (r) => onComplete && onComplete(r),
          setInstr: (txt) => setInstrText(txt),
        });
        runnerRef.current = runner;
        window.__themyndRunner = runner;

        const canvas = renderer.domElement;
        const onClick = (e) => {
          const rect = canvas.getBoundingClientRect();
          const mx = ((e.clientX - rect.left) / rect.width) * 2 - 1;
          const my = -((e.clientY - rect.top) / rect.height) * 2 + 1;
          rayCaster.setFromCamera({x:mx, y:my}, cam);
          const meshes = pads.map(p => p.mesh);
          const hits2 = rayCaster.intersectObjects(meshes, false);
          if (hits2.length) {
            const idx = meshes.indexOf(hits2[0].object);
            runner.handleInput({type:'block', idx});
          }
        };
        canvas.addEventListener('click', onClick);

        return () => {
          cancelAnimationFrame(raf);
          renderer.dispose();
          canvas.removeEventListener('click', onClick);
          runner.abort();
        };
      }
      return () => { cancelAnimationFrame(raf); renderer.dispose(); };
    }}
  />;
}

/* ════════════════════════════════════════════════════
   14 · 追光之舞 — Pursuit Rotor
   Visual: golden-hour meadow + parametric path. 4 shapes
   (circle / lemniscate / rose / sine) × 2 game modes:
     follow  — narrow path, chase the moving firefly
     center  — wide road; click to start; keep cursor on
               the road's centerline as the firefly sweeps
               one full revolution. Wider visual tolerance.
═════════════════════════════════════════════════════ */
function Game_PursuitRotor({ params, onComplete, onAbort }) {
  const ctxRef = useRef(null);
  const runnerRef = useRef(null);
  const [trialUI, setTrialUI] = useState(-1);
  const [onTargetPct, setOnTargetPct] = useState(0);
  const [stars, setStars] = useState(2);
  const [instrText, setInstrText] = useState(
    params?.gameMode === 'center'
      ? '將光點保持於加寬道路的中心線'
      : '將指尖保持在移動的螢火蟲上'
  );
  const [awaitingClick, setAwaitingClick] = useState(false);

  // Resolve path function in world coordinates (2.4 world-unit scale)
  function makePathFn(pathType) {
    const A = 2.4;
    switch (pathType) {
      case 'circle':
        return (t) => [A * Math.cos(t), A * Math.sin(t) * 0.65 + .2];
      case 'rose': {
        // 4-petal rose: r = A*cos(2t)
        return (t) => {
          const r = A * 0.9 * Math.abs(Math.cos(2 * t));
          return [r * Math.cos(t), r * Math.sin(t) + .2];
        };
      }
      case 'sine': {
        // Mountain sine wrapping across width; the parameter t runs 0..2π
        return (t) => {
          const x = (t - Math.PI) * (A * 0.85) / Math.PI;
          const y = Math.sin(t * 2) * (A * 0.55) + .2;
          return [x, y];
        };
      }
      case 'lemniscate':
      default: {
        return (t) => {
          const ct = Math.cos(t), st = Math.sin(t);
          const d = 1 + st * st;
          return [A * ct / d, A * st * ct / d + .2];
        };
      }
    }
  }

  // Texture renderer for the visual track (narrow for 'follow', wide road for 'center')
  function makeTrackTex(pathType, wide) {
    return SK.mkTex(1024, 560, (c, w, h) => {
      c.clearRect(0, 0, w, h);
      const path = [];
      // Sample the parametric path mapped to canvas coordinates
      const Ax = Math.min(w, h) * .42;
      const cx = w / 2, cy = h / 2;
      const proj = (x, y) => [cx + x / 2.4 * Ax, cy - y / 2.4 * Ax + 10];

      for (let t = 0; t <= Math.PI * 2 + .01; t += .01) {
        let px, py;
        if (pathType === 'circle') {
          px = 2.4 * Math.cos(t); py = 2.4 * Math.sin(t) * 0.65 + .2;
        } else if (pathType === 'rose') {
          const r = 2.4 * 0.9 * Math.abs(Math.cos(2 * t));
          px = r * Math.cos(t); py = r * Math.sin(t) + .2;
        } else if (pathType === 'sine') {
          px = (t - Math.PI) * (2.4 * 0.85) / Math.PI;
          py = Math.sin(t * 2) * (2.4 * 0.55) + .2;
        } else {
          const ct = Math.cos(t), st = Math.sin(t), d = 1 + st * st;
          px = 2.4 * ct / d; py = 2.4 * st * ct / d + .2;
        }
        path.push(proj(px, py));
      }

      const baseLine = wide ? 54 : 34;
      const midLine  = wide ? 30 : 16;
      const coreLine = wide ? 6  : 4;

      c.lineCap = 'round'; c.lineJoin = 'round';
      // Outer road
      c.strokeStyle = wide ? 'rgba(88,72,48,.55)' : 'rgba(240,200,120,.25)';
      c.lineWidth = baseLine;
      c.beginPath(); c.moveTo(path[0][0], path[0][1]);
      path.forEach(p => c.lineTo(p[0], p[1]));
      c.stroke();
      // Road fill
      c.strokeStyle = wide ? 'rgba(220,200,170,.9)' : 'rgba(252,232,170,.5)';
      c.lineWidth = midLine;
      c.beginPath(); c.moveTo(path[0][0], path[0][1]);
      path.forEach(p => c.lineTo(p[0], p[1]));
      c.stroke();
      // Centerline
      c.strokeStyle = wide ? 'rgba(210,170,90,.9)' : 'rgba(255,248,220,.85)';
      c.lineWidth = coreLine;
      if (wide) c.setLineDash([18, 12]);  // dashed centerline for road mode
      c.beginPath(); c.moveTo(path[0][0], path[0][1]);
      path.forEach(p => c.lineTo(p[0], p[1]));
      c.stroke();
      c.setLineDash([]);
      // Flow ticks (only in narrow mode)
      if (!wide) {
        c.strokeStyle = 'rgba(100,70,40,.35)';
        c.lineWidth = 1.5;
        for (let i = 0; i < path.length; i += 24) {
          const p = path[i], np = path[Math.min(i + 3, path.length - 1)];
          const dx = np[0] - p[0], dy = np[1] - p[1];
          const L = Math.hypot(dx, dy) || 1;
          const nx = -dy / L, ny = dx / L;
          c.beginPath();
          c.moveTo(p[0] + nx * 6, p[1] + ny * 6);
          c.lineTo(p[0] - nx * 6, p[1] - ny * 6);
          c.stroke();
        }
      }
    });
  }

  return <Scene
    chapter="第十四章 · 追光之舞 · Visuomotor Tracking"
    title="追光之舞 — Pursuit Rotor"
    subtitle={params?.gameMode === 'center'
      ? '加寬道路 · 點擊開始 · 光點沿中心線移動 · 你須保持於中心'
      : '追隨螢火蟲 · 保持指尖於光中'}
    tags={[
      {label: ({circle:'○ CIRCLE',lemniscate:'∞ LEMNISCATE',rose:'✿ ROSE',sine:'〰 SINE'})[params?.pathType || 'lemniscate'], color:'#7050c8'},
      {label: params?.gameMode === 'center' ? 'CENTER MODE' : 'FOLLOW MODE', color:'#8a7a60'},
    ]}
    instr={instrText}
    reward={trialUI >= 0 ? stars : 2}
    trialDots={trialUI >= 0 ? {total: Math.max(5, (ctxRef.current?._trials?.length || 5)), cur: trialUI, hits:[]} : undefined}
    protocol={(window.TASK_DOCS && window.TASK_DOCS[6])
      ? buildProtocolText(6, params)
      : '<b>Protocol · Pursuit Rotor</b>  Koerth 1922 / Adams 1952 — 詳細說明載入中…'}
    metrics={['在靶時間 (ms)','平均偏差 (px)','學習曲線斜率','路徑複雜度係數']}
    init={(el) => {
      const {renderer, scene, cam} = SK.mkScene(el, {z:8});

      SK.addBackdrop(scene, cam, (c,w,h)=>{
        SK.paintSky(c,w,h, ['#f0e0b8','#f0c088','#e09878','#b87098','#6a5a90'], 0.02);
        const sx=w*.22, sy=h*.34, sr=45;
        const sg=c.createRadialGradient(sx,sy,0,sx,sy,sr*3);
        sg.addColorStop(0,'rgba(255,230,170,.7)');
        sg.addColorStop(1,'rgba(255,230,170,0)');
        c.fillStyle=sg; c.fillRect(sx-sr*3,sy-sr*3,sr*6,sr*6);
        c.fillStyle='#fff0c0';
        c.beginPath(); c.arc(sx,sy,sr*.7,0,Math.PI*2); c.fill();
        SK.paintHills(c,w,h,[
          {base:.55, color:'#b88078', amp:24, freq:.008, alpha:.55},
          {base:.62, color:'#7d6a8a', amp:28, freq:.006, alpha:.75},
          {base:.70, color:'#4a5a6a', amp:22, freq:.011, alpha:.88},
        ]);
        const mg=c.createLinearGradient(0,h*.70,0,h);
        mg.addColorStop(0,'#4a5a6a');
        mg.addColorStop(.4,'#5a7050');
        mg.addColorStop(1,'#6a8058');
        c.fillStyle=mg; c.fillRect(0,h*.70,w,h*.30);
        c.strokeStyle='rgba(50,70,40,.55)'; c.lineWidth=1;
        for(let i=0;i<90;i++){
          const x=Math.random()*w, y=h*.72+Math.random()*h*.26;
          const hh=3+Math.random()*8;
          c.beginPath(); c.moveTo(x,y); c.lineTo(x+Math.random()*3-1.5,y-hh); c.stroke();
        }
        for(let i=0;i<20;i++){
          const x=Math.random()*w, y=h*.74+Math.random()*h*.22;
          c.fillStyle=SK.choice(['#e8b8a0','#f0d858','#c878a0','#f0e0b8']);
          c.beginPath(); c.arc(x,y,1.5,0,Math.PI*2); c.fill();
        }
      }, -26);

      // Path function + visual track — pickable per trial
      const initialPathType = params?.pathType || 'lemniscate';
      const initialGameMode = params?.gameMode || 'follow';
      let pathAt = makePathFn(initialPathType);
      let currentPathType = initialPathType;
      let currentGameMode = initialGameMode;

      const trackPlane = SK.mkPlane(6.6, 3.6, makeTrackTex(initialPathType, initialGameMode === 'center'));
      trackPlane.position.set(0, .2, -.1);
      scene.add(trackPlane);

      function rebuildTrack(pathType, gameMode) {
        pathAt = makePathFn(pathType);
        currentPathType = pathType;
        currentGameMode = gameMode;
        trackPlane.material.map = makeTrackTex(pathType, gameMode === 'center');
        trackPlane.material.needsUpdate = true;
      }

      // Moving firefly target
      const flyTex = SK.makeGlow('#f0d060', 128);
      const fly = new THREE.Mesh(
        new THREE.PlaneGeometry(1.0, 1.0),
        new THREE.MeshBasicMaterial({ map: flyTex, transparent:true, depthWrite:false, blending: THREE.AdditiveBlending })
      );
      fly.position.set(0, .2, .02);
      scene.add(fly);

      const coreTex = SK.mkTex(64,64,(c,w,h)=>{
        c.clearRect(0,0,w,h);
        const g=c.createRadialGradient(w/2,h/2,0,w/2,h/2,w/2);
        g.addColorStop(0,'#fff8dc'); g.addColorStop(.4,'#f0c858'); g.addColorStop(1,'rgba(240,200,88,0)');
        c.fillStyle=g; c.fillRect(0,0,w,h);
      });
      const core = SK.mkPlane(.22,.22, coreTex);
      core.material.blending = THREE.AdditiveBlending;
      scene.add(core);

      const ring = new THREE.Mesh(
        new THREE.RingGeometry(.38, .44, 48),
        new THREE.MeshBasicMaterial({color:0xf0d060, transparent:true, opacity:.55, side:THREE.DoubleSide})
      );
      scene.add(ring);

      const ghostTex = SK.makeGlow('#8fb2d0', 96);
      const ghost = new THREE.Mesh(
        new THREE.PlaneGeometry(.5,.5),
        new THREE.MeshBasicMaterial({map:ghostTex, transparent:true, opacity:.7, depthWrite:false, blending:THREE.AdditiveBlending})
      );
      scene.add(ghost);

      // On-target feedback layers
      const hitGlowTex = SK.makeGlow('#88ff8c', 128);
      const hitGlow = new THREE.Mesh(
        new THREE.PlaneGeometry(1.6, 1.6),
        new THREE.MeshBasicMaterial({ map: hitGlowTex, transparent:true, opacity:0, depthWrite:false, blending: THREE.AdditiveBlending })
      );
      scene.add(hitGlow);
      const hitRing = new THREE.Mesh(
        new THREE.RingGeometry(.46, .54, 64),
        new THREE.MeshBasicMaterial({color:0x88ff8c, transparent:true, opacity:0, side:THREE.DoubleSide})
      );
      scene.add(hitRing);

      const sparkPool = [];
      function emitSpark(x, y) {
        const tex = SK.mkTex(64, 64, (c, w, h) => {
          c.clearRect(0, 0, w, h);
          const g = c.createRadialGradient(w/2, h/2, 0, w/2, h/2, w/2);
          g.addColorStop(0, '#ffffe0');
          g.addColorStop(.4, 'rgba(248,232,160,.7)');
          g.addColorStop(1, 'rgba(248,232,160,0)');
          c.fillStyle = g; c.fillRect(0, 0, w, h);
        });
        const m = SK.mkPlane(.18, .18, tex);
        m.material.blending = THREE.AdditiveBlending;
        const ang = Math.random() * Math.PI * 2;
        const speed = 0.4 + Math.random() * 0.6;
        m.position.set(x, y, .03);
        scene.add(m);
        sparkPool.push({ mesh:m, born: performance.now(), vx: Math.cos(ang)*speed*0.001, vy: Math.sin(ang)*speed*0.001 });
        if (sparkPool.length > 30) { const old = sparkPool.shift(); scene.remove(old.mesh); }
      }

      const ff = SK.makeFireflies(scene, 18, {x:6, y:3, z:1});

      // Sample N path points once; used for center-mode distance-to-line computation
      function sampleCenterline(pathType) {
        const N = 400;
        const arr = [];
        const fn = makePathFn(pathType);
        for (let i = 0; i < N; i++) {
          const t = (i / N) * Math.PI * 2;
          const [x, y] = fn(t);
          arr.push({x, y});
        }
        return arr;
      }
      let centerlineSamples = sampleCenterline(currentPathType);

      // Runtime state
      let angle = 0;
      let rpm = 5;
      let pointer = { x: 0, y: .2 };
      let running = false;
      let flyRadiusWorld = 0.40;  // set from targetR each trial
      let flyScale = 1.0;
      let pendingStartTimer = null;
      let lastSparkAt = 0;
      let onTarget = false;

      function distToCenterline(px, py) {
        let best = 1e9;
        for (let i = 0; i < centerlineSamples.length; i++) {
          const s = centerlineSamples[i];
          const dx = px - s.x, dy = py - s.y;
          const d2 = dx*dx + dy*dy;
          if (d2 < best) best = d2;
        }
        return Math.sqrt(best);
      }

      // Per-trial dynamics (intermediate/research)
      let trialStartedAt = 0;
      let trialDurMs = 0;
      let speedVar = 0;       // intermediate: 0..0.8 (peak ± deviation)
      let occludeMs = 0;      // research: occlusion duration
      let occludeInterval = 0; // research: time between occlusions
      let occludeVisible = true;
      let nextOccludeAt = 0;
      let occludeEndsAt = 0;

      let raf, t0 = performance.now(), lastT = t0;
      const render=()=>{
        const now = performance.now();
        const dt = now - lastT;
        lastT = now;
        const t = now - t0;
        ff.tick(t);

        if (running) {
          // Variable-speed (intermediate): rpm modulated by sigmoid based on
          // normalized trial progress in [0,1]. Speed swings smoothly between
          // (1−speedVar)·rpm and (1+speedVar)·rpm.
          let effectiveRpm = rpm;
          if (speedVar > 0 && trialDurMs > 0){
            const u = Math.min(1, (now - trialStartedAt) / trialDurMs);
            // Triangle wave between 0 and 1, smoothed via sigmoid → 0..1
            const tri = 1 - Math.abs(2 * ((u * 3) % 1) - 1);  // 3 cycles per trial
            const smooth = 1 / (1 + Math.exp(-12 * (tri - 0.5)));   // sharper sigmoid
            const factor = (1 - speedVar) + 2 * speedVar * smooth;
            effectiveRpm = rpm * factor;
          }
          // Occlusion (research): toggle visibility on a schedule
          if (occludeInterval > 0){
            if (occludeVisible && now >= nextOccludeAt){
              occludeVisible = false;
              occludeEndsAt = now + occludeMs;
            } else if (!occludeVisible && now >= occludeEndsAt){
              occludeVisible = true;
              nextOccludeAt = now + occludeInterval;
            }
          }
          const radPerMs = (effectiveRpm * 2 * Math.PI) / 60000;
          angle += radPerMs * dt;
        } else {
          angle += .0008 * dt;
        }
        const u = angle % (Math.PI*2);
        const [x,y] = pathAt(u);
        fly.position.set(x,y,.02);
        fly.scale.setScalar(flyScale);
        core.position.set(x,y,.03);
        core.scale.setScalar(flyScale);
        ring.position.set(x,y,.015);
        ring.scale.setScalar(flyScale * (1 + Math.sin(t*.004)*.08));

        if (running) {
          ghost.position.set(pointer.x, pointer.y, .025);
          ghost.material.opacity = 0.7;
        } else {
          const [gx,gy] = pathAt(angle - .12);
          ghost.position.set(gx + Math.sin(t*.003)*.12, gy + Math.cos(t*.0025)*.08, .025);
          ghost.material.opacity = 0.4;
        }
        fly.material.opacity = .75 + Math.sin(t*.006)*.2;

        // On-target determination
        let hit = false;
        if (running) {
          if (currentGameMode === 'center') {
            const d = distToCenterline(pointer.x, pointer.y);
            hit = d < flyRadiusWorld;
          } else {
            const dx = pointer.x - x, dy = pointer.y - y;
            hit = Math.hypot(dx, dy) < flyRadiusWorld;
          }
          // Pass visibility flag for research-mode occlusion stats
          if (ctxRef.current?.pursuitSample) ctxRef.current.pursuitSample(hit, dt, occludeVisible);
        }
        onTarget = hit;
        // Hide fly mesh during occlusion (research mode)
        if (!occludeVisible){
          fly.material.opacity = 0;
          core.material.opacity = 0;
          ring.material.opacity = 0;
        } else {
          core.material.opacity = 1;
        }

        // Visual feedback for on-target
        hitGlow.material.opacity = onTarget ? .65 + Math.sin(t*.012)*.15 : 0;
        hitGlow.position.set(currentGameMode === 'center' ? pointer.x : x, currentGameMode === 'center' ? pointer.y : y, .024);
        hitGlow.scale.setScalar((flyScale * 1.1) + Math.sin(t*.012)*.08);
        hitRing.position.copy(hitGlow.position);
        hitRing.material.opacity = onTarget ? .85 : 0;
        hitRing.scale.setScalar((flyScale * 1.05) + Math.sin(t*.018)*.06);
        fly.material.opacity = onTarget ? Math.min(1, .9 + Math.sin(t*.012)*.1) : (.75 + Math.sin(t*.006)*.2);
        ring.material.color.set(onTarget ? 0x88ff8c : 0xf0d060);
        ring.material.opacity = onTarget ? .95 : .55;

        if (onTarget && now - lastSparkAt > 60) {
          const fx = currentGameMode === 'center' ? pointer.x : x;
          const fy = currentGameMode === 'center' ? pointer.y : y;
          emitSpark(fx + (Math.random()-.5)*.2, fy + (Math.random()-.5)*.2);
          lastSparkAt = now;
        }
        for (let i = sparkPool.length - 1; i >= 0; i--) {
          const s = sparkPool[i];
          const age = now - s.born;
          s.mesh.position.x += s.vx * dt;
          s.mesh.position.y += s.vy * dt;
          s.mesh.material.opacity = Math.max(0, 1 - age/700);
          s.mesh.scale.setScalar(1 + age/700 * 1.5);
          if (age > 700) { scene.remove(s.mesh); sparkPool.splice(i, 1); }
        }

        renderer.render(scene, cam);
        raf = requestAnimationFrame(render);
      };
      render();

      if (params) {
        ctxRef.current = {
          renderer, scene, cam,
          __params: params,
          _trials: null, _currentTrial: 0,
          _currentOnTarget: 0, _currentTotal: 0,
          setPhase: () => {},
          pursuitSample: null,
          onTrialStart: (tr, startTimer) => {
            setTrialUI(tr.trialIdx);
            setOnTargetPct(0);
            rpm = tr.rpm;
            const targetRpx = Math.max(4, +tr.targetR || 28);
            flyRadiusWorld = targetRpx / 80;
            flyScale = targetRpx / 36;
            if (tr.pathType !== currentPathType || tr.gameMode !== currentGameMode) {
              rebuildTrack(tr.pathType, tr.gameMode);
              centerlineSamples = sampleCenterline(tr.pathType);
            }
            angle = 0;
            // Set per-trial level dynamics
            speedVar = +tr.speedVar || 0;
            occludeMs = +tr.occludeMs || 0;
            occludeInterval = +tr.occludeIntervalMs || 0;
            occludeVisible = true;
            trialDurMs = +tr.trialDur || 20000;
            trialStartedAt = performance.now();
            nextOccludeAt = trialStartedAt + occludeInterval;

            if (tr.gameMode === 'center') {
              setAwaitingClick(true);
              setInstrText('點擊任意處開始 · 光點沿道路中心移動，請保持於中心線');
              pendingStartTimer = startTimer;
              running = false;
            } else {
              running = true;
            }
          },
          onTrialEnd: (tr) => {
            running = false;
            setAwaitingClick(false);
            pendingStartTimer = null;
            const pct = Math.round((tr.pct || 0) * 100);
            setOnTargetPct(pct);
            if (pct >= 60) setStars(s => Math.min(3, s + 1));
          },
        };

        const runner = new window.TestRunner(6, params, ctxRef.current, {
          onComplete: (r) => onComplete && onComplete(r),
          setInstr: (txt) => setInstrText(txt),
        });
        runnerRef.current = runner;
        window.__themyndRunner = runner;

        const canvas = renderer.domElement;
        const updatePointer = (ev) => {
          const rect = canvas.getBoundingClientRect();
          const mx = ((ev.clientX - rect.left) / rect.width) * 2 - 1;
          const my = -((ev.clientY - rect.top) / rect.height) * 2 + 1;
          const vec = new THREE.Vector3(mx, my, 0.5).unproject(cam);
          const dir = vec.sub(cam.position).normalize();
          const dist = -cam.position.z / dir.z;
          const pos = cam.position.clone().add(dir.multiplyScalar(dist));
          pointer.x = pos.x; pointer.y = pos.y;
        };
        const onClick = () => {
          if (pendingStartTimer) {
            setAwaitingClick(false);
            running = true;
            angle = 0;
            const fn = pendingStartTimer;
            pendingStartTimer = null;
            fn();
            setInstrText('保持於中心線上 — 光點正沿著道路前進');
          }
        };
        canvas.addEventListener('mousemove', updatePointer);
        canvas.addEventListener('touchmove', (ev) => {
          if (ev.touches.length) updatePointer(ev.touches[0]);
        });
        canvas.addEventListener('click', onClick);

        return () => {
          cancelAnimationFrame(raf);
          renderer.dispose();
          canvas.removeEventListener('mousemove', updatePointer);
          canvas.removeEventListener('click', onClick);
          runner.abort();
        };
      }
      return () => { cancelAnimationFrame(raf); renderer.dispose(); };
    }}
  >
    {awaitingClick && (
      <div style={{
        position:'absolute', inset:0, zIndex:12,
        display:'flex', alignItems:'center', justifyContent:'center',
        pointerEvents:'none',
      }}>
        <div style={{
          background:'rgba(245,238,216,.92)',
          border:'2px solid rgba(120,80,40,.55)',
          borderRadius:12,
          padding:'1.4rem 2.4rem',
          fontFamily:'var(--serif)', color:'var(--inkw)',
          fontSize:'1.1rem', letterSpacing:'.12em',
          textAlign:'center',
          boxShadow:'0 6px 22px rgba(30,40,20,.3)',
        }}>
          <div style={{fontSize:'1.3rem', fontWeight:700, color:'#d4931a', marginBottom:6}}>
            點擊任意處開始
          </div>
          <div style={{fontSize:'.85rem', color:'var(--dim)', fontFamily:'var(--italic)', fontStyle:'italic'}}>
            光點將沿道路中心移動 · 請將游標保持於道路中心線
          </div>
        </div>
      </div>
    )}
  </Scene>;
}

/* ════════════════════════════════════════════════════
   08 · 魔法石塔 — Tower of London
═════════════════════════════════════════════════════ */
function Game_Tower({ params, onComplete, onAbort }) {
  const ctxRef = useRef(null);
  const runnerRef = useRef(null);
  const [instrText, setInstrText] = useState('拖動石珠 · 達成上方目標排列');
  const [stars, setStars] = useState(2);
  const [problemIdx, setProblemIdx] = useState(0);
  return <Scene
    chapter="第八章 · 月光古塔 · Planning"
    title="魔法石塔 — Tower of London"
    subtitle="最少步數內重現月光魔陣"
    tags={[{label:'目標：5 步',color:'#3aa858'}]}
    instr={instrText}
    reward={stars}
    protocol={(window.TASK_DOCS && window.TASK_DOCS[7])
      ? buildProtocolText(7, params)
      : '<b>Protocol · Tower of London</b>  Shallice 1982 / Krikorian 1994 — 詳細說明載入中…'}
    metrics={['Total Score','Optimal %','Planning Latency','Execution Time']}
    init={(el)=>{
      const {renderer, scene, cam} = SK.mkScene(el, {z:8});
      SK.addBackdrop(scene, cam, (c,w,h)=>{
        SK.paintSky(c,w,h, ['#1c2540','#2b3a6c','#4a5d9a'], 0.02);
        SK.paintStars(c,w,h,{count:150});
        // big moon
        const mg=c.createRadialGradient(w*.78,h*.25,0,w*.78,h*.25,150);
        mg.addColorStop(0,'rgba(245,240,224,.95)');
        mg.addColorStop(.3,'rgba(245,240,224,.7)');
        mg.addColorStop(.6,'rgba(245,240,224,.2)');
        mg.addColorStop(1,'transparent');
        c.fillStyle=mg; c.fillRect(0,0,w,h);
        c.fillStyle='#f5f0e4';
        c.beginPath(); c.arc(w*.78, h*.25, 70, 0, Math.PI*2); c.fill();
        SK.paintHills(c,w,h,[
          {base:.78, color:'#0f1828', amp:28, freq:.01, alpha:.9},
          {base:.92, color:'#050812', amp:14, freq:.015, alpha:1},
        ]);
      }, -25);

      // 3 poles on a stone base
      const baseTex = SK.mkTex(600, 120, (c,w,h)=>{
        c.clearRect(0,0,w,h);
        c.fillStyle='rgba(60,50,70,.9)';
        c.fillRect(0, h*.3, w, h*.5);
        // stone texture
        for(let i=0;i<20;i++){
          c.fillStyle='rgba(80,70,100,'+(.4+Math.random()*.4)+')';
          c.fillRect(Math.random()*w, h*.3+Math.random()*h*.5, 20+Math.random()*40, 6+Math.random()*10);
        }
      });
      const base = SK.mkPlane(6, 1.1, baseTex);
      base.position.y = -2.2;
      scene.add(base);

      const poleXs = [-2.2, 0, 2.2];
      const poleMeshes = [];
      poleXs.forEach(x=>{
        const pole = new THREE.Mesh(
          new THREE.CylinderGeometry(.08,.08,3.2,12),
          new THREE.MeshBasicMaterial({color:0x3a3050})
        );
        pole.position.set(x, -.6, 0);
        scene.add(pole);
        poleMeshes.push(pole);
      });

      // beads
      function addBead(x, y, color){
        const tex = SK.mkTex(128,128,(c,w,h)=>{
          c.clearRect(0,0,w,h);
          // glow
          const gg=c.createRadialGradient(w/2,h/2,0,w/2,h/2,w*.55);
          gg.addColorStop(0,color+'88');
          gg.addColorStop(1,'transparent');
          c.fillStyle=gg; c.fillRect(0,0,w,h);
          // bead
          const bg=c.createRadialGradient(w*.4,h*.4,0,w/2,h/2,w*.4);
          bg.addColorStop(0,'#fff');
          bg.addColorStop(.3,color);
          bg.addColorStop(1,'#3a2030');
          c.fillStyle=bg;
          c.beginPath(); c.arc(w/2,h/2, w*.4, 0, Math.PI*2); c.fill();
          // highlight
          c.fillStyle='rgba(255,255,255,.6)';
          c.beginPath(); c.ellipse(w*.38, h*.36, w*.1, w*.06, -.3, 0, Math.PI*2); c.fill();
        });
        const m = SK.mkPlane(.9, .9, tex);
        m.position.set(x, y, 0);
        scene.add(m);
        return m;
      }
      // preview-only: show 3 demo beads. In test mode we'll build from prob.start.
      const previewBeads = !params ? [
        addBead(-2.2, -1.8, '#c84030'),
        addBead(-2.2, -1.0, '#3060b8'),
        addBead(0, -1.8, '#3aa858'),
      ] : [];

      // target panel (top-right) — target: red-green-blue stacked left pole
      const tpTex = SK.mkTex(360,460,(c,w,h)=>{
        c.clearRect(0,0,w,h);
        c.fillStyle='rgba(60,40,20,.25)';
        c.fillRect(8,12,w-12,h-16);
        c.fillStyle='#f5f0e4';
        c.fillRect(4,4,w-12,h-16);
        c.strokeStyle='rgba(140,100,60,.5)';
        c.lineWidth=2;
        c.strokeRect(4,4,w-12,h-16);
        c.font="700 18px 'Noto Serif TC', serif";
        c.fillStyle='#8a7a60';
        c.textAlign='center';
        c.fillText('目標 · 5 步', w/2, 32);
        // mini 3 poles
        const polY = h-50, polTop = 80;
        c.fillStyle='#5a4a60';
        c.fillRect(30, polY, w-60, 10);
        [w*.25, w*.5, w*.75].forEach(x=>{ c.fillRect(x-3, polTop, 6, polY-polTop); });
        // target: all 3 on pole 1
        const tg=['#c84030','#3aa858','#3060b8'];
        tg.forEach((cc,i)=>{
          c.fillStyle=cc;
          c.beginPath(); c.arc(w*.25, polY-15-i*26, 13, 0, Math.PI*2); c.fill();
        });
      });
      const tp = SK.mkPlane(1.6, 2.1, tpTex);
      tp.position.set(3.2, 1.3, 0);
      scene.add(tp);

      // step counter
      const stepsTex = SK.mkTex(220,80,(c,w,h)=>{
        c.clearRect(0,0,w,h);
        c.fillStyle='rgba(245,240,228,.9)';
        c.fillRect(4,4,w-8,h-8);
        c.strokeStyle='rgba(140,100,60,.5)';
        c.strokeRect(4,4,w-8,h-8);
        c.font="600 22px 'Noto Serif TC', serif";
        c.fillStyle='#2c2416';
        c.textAlign='center'; c.textBaseline='middle';
        c.fillText('已用 2 / 5 步', w/2, h/2);
      });
      const st = SK.mkPlane(1.4, .5, stepsTex);
      st.position.set(-3.2, 2.1, 0);
      scene.add(st);

      let raf, t=0;
      const render=()=>{
        t+=16;
        tp.rotation.z = Math.sin(t*.001)*.01;
        renderer.render(scene, cam);
        raf = requestAnimationFrame(render);
      };
      render();

      if (params) {
        const rayCaster = new THREE.Raycaster();
        // Bead color lookup (matches TASK_IMPL[7]._COLORS: red=0, blue=1, green=2)
        const beadColors = ['#c84030', '#3060b8', '#3aa858'];
        const beadMeshes = []; // {mesh, peg, pos, colorIdx}

        function setBeadState(pegs) {
          // Remove old beads
          beadMeshes.forEach(b => scene.remove(b.mesh));
          beadMeshes.length = 0;
          pegs.forEach((peg, pegIdx) => {
            peg.forEach((colorIdx, slotIdx) => {
              const x = poleXs[pegIdx];
              const y = -1.8 + slotIdx * 0.85;
              const m = addBead(x, y, beadColors[colorIdx]);
              beadMeshes.push({mesh: m, peg: pegIdx, pos: slotIdx, colorIdx});
            });
          });
        }

        function drawGoal(goal) {
          const tpImg = tpTex.image;
          const tpc = tpImg.getContext('2d');
          const w = tpImg.width, h = tpImg.height;
          tpc.clearRect(0, 0, w, h);
          tpc.fillStyle = 'rgba(60,40,20,.25)';
          tpc.fillRect(8, 12, w-12, h-16);
          tpc.fillStyle = '#f5f0e4';
          tpc.fillRect(4, 4, w-12, h-16);
          tpc.strokeStyle = 'rgba(140,100,60,.5)';
          tpc.lineWidth = 2;
          tpc.strokeRect(4, 4, w-12, h-16);
          tpc.font = "700 18px 'Noto Serif TC', serif";
          tpc.fillStyle = '#8a7a60';
          tpc.textAlign = 'center';
          tpc.fillText('目標', w/2, 32);
          // three vertical poles in small
          const polY = h - 50, polTop = 80;
          tpc.fillStyle = '#5a4a60';
          tpc.fillRect(30, polY, w - 60, 10);
          [w*.25, w*.5, w*.75].forEach(x => tpc.fillRect(x - 3, polTop, 6, polY - polTop));
          // beads per goal peg
          goal.forEach((peg, pegIdx) => {
            const x = [w*.25, w*.5, w*.75][pegIdx];
            peg.forEach((colorIdx, slotIdx) => {
              tpc.fillStyle = beadColors[colorIdx];
              tpc.beginPath();
              tpc.arc(x, polY - 15 - slotIdx * 26, 13, 0, Math.PI*2);
              tpc.fill();
            });
          });
          tpTex.needsUpdate = true;
        }

        function updateStepCounter(moves, minMoves, probIdx, totalProbs) {
          const stImg = stepsTex.image;
          const stc = stImg.getContext('2d');
          const w = stImg.width, h = stImg.height;
          stc.clearRect(0, 0, w, h);
          stc.fillStyle = 'rgba(245,240,228,.9)';
          stc.fillRect(4, 4, w-8, h-8);
          stc.strokeStyle = 'rgba(140,100,60,.5)';
          stc.strokeRect(4, 4, w-8, h-8);
          stc.font = "600 18px 'Noto Serif TC', serif";
          stc.fillStyle = '#2c2416';
          stc.textAlign = 'center';
          stc.textBaseline = 'middle';
          stc.fillText(`題 ${probIdx + 1}/${totalProbs} · 已用 ${moves}/${minMoves}`, w/2, h/2);
          stepsTex.needsUpdate = true;
        }

        ctxRef.current = {
          renderer, scene, cam,
          ballMeshes: beadMeshes,
          pegMeshes: poleMeshes,
          _pegs: [[],[],[]],
          _selected: null,
          _problemIdx: 0,
          _score: 0, _moves: 0, _planStart: 0,
          _trials: null, // set by TASK_IMPL[7].start
          _level: params.level || 'basic',
          _difficulty: params.difficulty || 'medium',
          _moveLimitExtra: +params.moveLimitExtra || 2,
          _timeLimitSec: +params.timeLimitSec || 0,
          setPhase: () => {},
          onSelectBall: (ball) => {
            if (ball && ball.mesh && ball.mesh.material) {
              ball.mesh.material.emissive && ball.mesh.material.emissive.setHex(0x4a4a18);
              if (ball.mesh.material.emissiveIntensity !== undefined)
                ball.mesh.material.emissiveIntensity = 1;
            }
          },
          onDeselectBall: (ball) => {
            if (ball && ball.mesh && ball.mesh.material) {
              ball.mesh.material.emissive && ball.mesh.material.emissive.setHex(0x000000);
              if (ball.mesh.material.emissiveIntensity !== undefined)
                ball.mesh.material.emissiveIntensity = 0;
            }
          },
          _setState: (pegs) => {
            ctxRef.current._pegs = pegs.map(p => [...p]);
            setBeadState(pegs);
          },
          _drawGoal: (goal) => {
            drawGoal(goal);
            // also refresh step counter for the new problem
            const prob = ctxRef.current._trials?.[ctxRef.current._problemIdx];
            if (prob) {
              updateStepCounter(0, prob.min, ctxRef.current._problemIdx, ctxRef.current._trials.length);
              setProblemIdx(ctxRef.current._problemIdx);
            }
          },
        };

        const runner = new window.TestRunner(7, params, ctxRef.current, {
          onComplete: (r) => {
            setStars(3);
            onComplete && onComplete(r);
          },
          setInstr: (txt) => setInstrText(txt),
        });
        runnerRef.current = runner;
        window.__themyndRunner = runner;

        const canvas = renderer.domElement;
        const onClick = (e) => {
          const rect = canvas.getBoundingClientRect();
          const mx = ((e.clientX - rect.left) / rect.width) * 2 - 1;
          const my = -((e.clientY - rect.top) / rect.height) * 2 + 1;
          rayCaster.setFromCamera({x: mx, y: my}, cam);
          const candidates = [
            ...beadMeshes.map(b => b.mesh),
            ...poleMeshes,
          ];
          const hits2 = rayCaster.intersectObjects(candidates, false);
          if (hits2.length) {
            runner.handleInput({type: 'raycast', object: hits2[0].object});
            // After every successful input, refresh step counter if we have a current problem
            const prob = ctxRef.current._trials?.[ctxRef.current._problemIdx];
            if (prob) {
              updateStepCounter(ctxRef.current._moves, prob.min, ctxRef.current._problemIdx, ctxRef.current._trials.length);
            }
          }
        };
        canvas.addEventListener('click', onClick);

        return () => {
          cancelAnimationFrame(raf);
          renderer.dispose();
          canvas.removeEventListener('click', onClick);
          runner.abort();
        };
      }

      return ()=>{ cancelAnimationFrame(raf); renderer.dispose(); };
    }}
  />;
}

/* ════════════════════════════════════════════════════
   09 · 水晶雙生 — Mental Rotation
═════════════════════════════════════════════════════ */
function Game_MentalRot({ params, onComplete, onAbort }) {
  const ctxRef = useRef(null);
  const runnerRef = useRef(null);
  const [trialIdx, setTrialIdx] = useState(-1);
  const [hits, setHits] = useState([]);
  const [stars, setStars] = useState(3);
  const [instrText, setInstrText] = useState('【S】相同 / 【D】不同');
  return <Scene
    chapter="第九章 · 雲海水晶 · Visuospatial"
    title="水晶雙生 — Mental Rotation"
    subtitle="在心中旋轉左方水晶 · 它們相同嗎？"
    tags={[{label:'120°',color:'#c87030'}]}
    instr={instrText}
    reward={trialIdx >= 0 ? stars : 3}
    trialDots={trialIdx >= 0 ? {total:10, cur:trialIdx%10, hits:hits.slice(-10)} : undefined}
    protocol={(window.TASK_DOCS && window.TASK_DOCS[8])
      ? buildProtocolText(8, params)
      : '<b>Protocol · Mental Rotation</b>  Shepard & Metzler 1971 — 詳細說明載入中…'}
    metrics={['Accuracy','Angular Velocity','RT-angle Slope','Mirror Acc']}
    init={(el)=>{
      const {renderer, scene, cam} = SK.mkScene(el, {z:9, fov:40});
      SK.addBackdrop(scene, cam, (c,w,h)=>{
        SK.paintSky(c,w,h, ['#c5d4e4','#f0d8c0','#e8b8a0','#c898b8'], 0.02);
        SK.paintClouds(c,w,h,{count:8, color:'#f5eacf'});
      }, -26);

      const dl1 = new THREE.DirectionalLight(0xffffff, 1.2); dl1.position.set(3,5,4); scene.add(dl1);
      const dl2 = new THREE.DirectionalLight(0xc898d8, .6); dl2.position.set(-3,-2,4); scene.add(dl2);

      // Shepard-Metzler 1971: each trial uses a different 3D shape so
      // subjects can't memorize one profile and must perform genuine
      // mental rotation each trial. All shapes must be CHIRAL — i.e.,
      // mirror image cannot be reproduced by 3D rotation. A planar
      // shape (all z=0) is achiral, so every variant has at least one
      // arm extending into +z to break the planar mirror symmetry.
      const SHAPE_LIBRARY = [
        // 0 — L with depth arm (right-handed corner)
        [[0,0,0],[1,0,0],[2,0,0],[0,1,0],[0,2,0],[2,0,1],[2,0,2]],
        // 1 — Z/S with depth tail
        [[0,0,0],[1,0,0],[1,1,0],[2,1,0],[2,2,0],[2,2,1]],
        // 2 — T-rotated with off-axis depth (breaks T's mirror symmetry)
        [[0,0,0],[1,0,0],[2,0,0],[1,1,0],[1,2,0],[2,2,1]],
        // 3 — Twisted staircase
        [[0,0,0],[1,0,0],[1,1,0],[1,1,1],[2,1,1],[2,2,1]],
        // 4 — F-shape with depth arm
        [[0,0,0],[0,1,0],[0,2,0],[1,2,0],[2,2,0],[1,1,0],[2,2,1]],
        // 5 — 3D zigzag (path bends through all three axes)
        [[0,0,0],[0,1,0],[0,2,0],[1,2,0],[1,2,1],[1,2,2],[2,2,2]],
      ];

      // Build a shape group from a blocks list. mirror=true negates x.
      // Centers the shape on its bounding-box midpoint so rotation pivots
      // around the visual center regardless of which variant is used.
      function makeShape(blocks, isMirror, color){
        const xs = blocks.map(b => b[0]);
        const ys = blocks.map(b => b[1]);
        const zs = blocks.map(b => b[2]);
        const cx = (Math.min(...xs) + Math.max(...xs)) / 2;
        const cy = (Math.min(...ys) + Math.max(...ys)) / 2;
        const cz = (Math.min(...zs) + Math.max(...zs)) / 2;
        const g = new THREE.Group();
        const mat = new THREE.MeshPhongMaterial({
          color, shininess:80, specular:0x6898c8, transparent:true, opacity:.85
        });
        blocks.forEach(([x,y,z])=>{
          const box = new THREE.Mesh(new THREE.BoxGeometry(.9,.9,.9), mat.clone());
          const px = x - cx;
          box.position.set(isMirror ? -px : px, y - cy, z - cz);
          const edges = new THREE.LineSegments(
            new THREE.EdgesGeometry(box.geometry),
            new THREE.LineBasicMaterial({color:0x3060b8, transparent:true, opacity:.8})
          );
          box.add(edges);
          g.add(box);
        });
        return g;
      }

      // Initial display shapes (idle / pre-trial) — use shape 0
      let currentSA = makeShape(SHAPE_LIBRARY[0], false, 0xaed8e8);
      currentSA.position.set(-2.5, -.3, 0);
      scene.add(currentSA);
      let currentSB = makeShape(SHAPE_LIBRARY[0], false, 0xe0873a);
      currentSB.position.set(2.5, -.3, 0);
      currentSB.rotation.y = Math.PI * .7;
      currentSB.rotation.x = .3;
      scene.add(currentSB);

      // platforms (floating cloud islands)
      function platform(x){
        const tex = SK.mkTex(400,100,(c,w,h)=>{
          c.clearRect(0,0,w,h);
          const g=c.createRadialGradient(w/2,h*.45,0,w/2,h*.45,w*.45);
          g.addColorStop(0,'rgba(245,240,224,.8)');
          g.addColorStop(.7,'rgba(245,240,224,.3)');
          g.addColorStop(1,'transparent');
          c.fillStyle=g; c.fillRect(0,0,w,h);
        });
        const m = SK.mkPlane(4, 1, tex);
        m.position.set(x, -1.8, -.1);
        scene.add(m);
      }
      platform(-2.5); platform(2.5);

      // Idle wobble for the pre-trial display only — paused during
      // 'stimulus' phase so the comparison is on static stimuli
      // (Shepard-Metzler standard requires no animation during judgment).
      let raf, t=0, idleWobble = true;
      const render=()=>{
        t+=16;
        if (idleWobble) {
          currentSA.rotation.y += .006;
          currentSA.rotation.x = Math.sin(t*.0008)*.15;
          currentSB.rotation.y += .006;
          currentSB.rotation.z = Math.sin(t*.001)*.1;
        }
        renderer.render(scene, cam);
        raf = requestAnimationFrame(render);
      };
      render();

      let testModeActive = false;

      // ─── Letter rendering helper (intermediate level) ───
      const LETTERS = ['R','F','G','J'];
      function makeLetter(letterIdx, isMirror, color){
        const tex = SK.mkTex(256, 256, (c, w, h) => {
          c.clearRect(0, 0, w, h);
          c.save();
          if (isMirror){ c.translate(w, 0); c.scale(-1, 1); }
          c.font = "900 200px 'Noto Serif TC', serif";
          c.fillStyle = '#' + color.toString(16).padStart(6,'0');
          c.textAlign = 'center'; c.textBaseline = 'middle';
          c.fillText(LETTERS[letterIdx % LETTERS.length], w/2, h/2 + 12);
          c.restore();
        });
        return SK.mkPlane(2.4, 2.4, tex);
      }

      // ─── Embedded MRT layout (research level): 1 target + 4 candidates ───
      // Track the embedded scene meshes here so we can clean them up between trials.
      let embeddedMeshes = [];
      let embeddedClickTargets = [];
      function clearEmbedded(){
        embeddedMeshes.forEach(m => scene.remove(m));
        embeddedMeshes = [];
        embeddedClickTargets = [];
      }
      function buildEmbedded(trial){
        clearEmbedded();
        // Target on top
        const tgtBlocks = SHAPE_LIBRARY[trial.targetShape % SHAPE_LIBRARY.length];
        const tgt = makeShape(tgtBlocks, false, 0xaed8e8);
        tgt.position.set(0, 1.8, 0);
        tgt.scale.setScalar(0.55);
        scene.add(tgt);
        embeddedMeshes.push(tgt);
        // 4 candidates in a row at the bottom, with click hit-areas
        const xs = [-3.6, -1.2, 1.2, 3.6];
        trial.candidates.forEach((c, i) => {
          const blocks = SHAPE_LIBRARY[c.shapeIdx % SHAPE_LIBRARY.length];
          const m = makeShape(blocks, c.isMirror, 0xe0873a);
          m.position.set(xs[i], -1.2, 0);
          m.scale.setScalar(0.5);
          if (c.axis === 'x') m.rotation.x = c.angle * Math.PI / 180;
          else if (c.axis === 'z') m.rotation.z = c.angle * Math.PI / 180;
          else m.rotation.y = c.angle * Math.PI / 180;
          scene.add(m);
          embeddedMeshes.push(m);
          // Invisible hit plane for raycasting (covers candidate area)
          const hit = SK.mkPlane(2.0, 2.4, null);
          hit.material = new THREE.MeshBasicMaterial({color:0xffffff, opacity:0, transparent:true, side:THREE.DoubleSide});
          hit.position.set(xs[i], -1.2, 0.5);
          hit.userData.candidateIdx = i;
          scene.add(hit);
          embeddedMeshes.push(hit);
          embeddedClickTargets.push(hit);
        });
      }
      function highlightEmbedded(selected){
        // Outline selected candidates with a yellow tint
        embeddedMeshes.forEach((m, mi) => {
          if (m.userData && m.userData.candidateIdx !== undefined){
            const idx = m.userData.candidateIdx;
            m.material.opacity = selected.includes(idx) ? 0.25 : 0;
          }
        });
      }

      const setPhase = (phase, trial) => {
        if (phase === 'fixation') {
          currentSA.visible = false;
          currentSB.visible = false;
          clearEmbedded();
          idleWobble = false;
        } else if (phase === 'stimulus') {
          testModeActive = true;
          idleWobble = false;

          // Research level — embedded MRT
          if (trial.embedded){
            currentSA.visible = false;
            currentSB.visible = false;
            buildEmbedded(trial);
            setTrialIdx(i => i + 1);
            return;
          }

          // Basic / Intermediate — pair display with axis + stim type
          scene.remove(currentSA);
          scene.remove(currentSB);
          if (trial.stim === 'letter'){
            currentSA = makeLetter(trial.shapeIdx, false, 0xaed8e8);
            currentSB = makeLetter(trial.shapeIdx, trial.isMirror, 0xe0873a);
          } else {
            const blocks = SHAPE_LIBRARY[(trial.shapeIdx ?? 0) % SHAPE_LIBRARY.length];
            currentSA = makeShape(blocks, false, 0xaed8e8);
            currentSB = makeShape(blocks, trial.isMirror, 0xe0873a);
          }
          currentSA.position.set(-2.5, -.3, 0);
          currentSA.rotation.set(0, 0, 0);
          scene.add(currentSA);
          currentSB.position.set(2.5, -.3, 0);
          // Apply rotation to the chosen axis
          const axis = trial.axis || 'y';
          const rad = trial.angle * Math.PI / 180;
          currentSB.rotation.set(0, 0, 0);
          if (axis === 'x') currentSB.rotation.x = rad;
          else if (axis === 'z') currentSB.rotation.z = rad;
          else currentSB.rotation.y = rad;
          scene.add(currentSB);
          if (ctxRef.current) { ctxRef.current.sA = currentSA; ctxRef.current.sB = currentSB; }
          setTrialIdx(i => i + 1);
        } else if (phase === 'feedback') {
          const ok = trial._correct;
          const color = ok ? 0xaaffcc : 0xff9999;
          if (trial.embedded){
            // Color all candidates briefly
            embeddedMeshes.forEach(m => {
              if (m.userData && m.userData.candidateIdx !== undefined){
                const inCorrect = trial.correctIdxs.includes(m.userData.candidateIdx);
                m.material.color = new THREE.Color(inCorrect ? 0x88ff88 : 0xff8888);
                m.material.opacity = 0.4;
              }
            });
          } else {
            currentSB?.traverse(c => { if (c.isMesh) c.material.color.set(color); });
            setTimeout(() => {
              currentSB?.traverse(c => { if (c.isMesh) c.material.color.set(0xe0873a); });
            }, 350);
          }
          setHits(h => [...h, ok]);
          if (ok) setStars(s => Math.min(3, s+1));
        }
      };

      if (params) {
        const isResearch = params.level === 'research';
        ctxRef.current = {
          renderer, scene, cam,
          sA: currentSA, sB: currentSB,
          setPhase,
          onMRTPickUpdate: (sel) => highlightEmbedded(sel),
        };
        const runner = new window.TestRunner(8, params, ctxRef.current, {
          onComplete: (r) => onComplete && onComplete(r),
          setInstr: (txt) => setInstrText(txt),
        });
        runnerRef.current = runner;
        window.__themyndRunner = runner;
        const inputMode = params.input || 'both';
        const onKey = e => {
          const k = e.key;
          const useArrows = inputMode === 'arrows' || inputMode === 'both';
          const useSD = inputMode === 'sd' || inputMode === 'both';
          const isArrow = k === 'ArrowLeft' || k === 'ArrowRight';
          const isSD = k === 's' || k === 'S' || k === 'd' || k === 'D';
          if ((isArrow && useArrows) || (isSD && useSD)) {
            runner.handleInput({type:'key', key:k});
          }
        };
        // Click handler for embedded MRT (research)
        const rayCaster = new THREE.Raycaster();
        const canvas = renderer.domElement;
        const onClick = (e) => {
          if (!isResearch) return;
          if (!embeddedClickTargets.length) return;
          const rect = canvas.getBoundingClientRect();
          const mx = ((e.clientX - rect.left) / rect.width) * 2 - 1;
          const my = -((e.clientY - rect.top) / rect.height) * 2 + 1;
          rayCaster.setFromCamera({x:mx, y:my}, cam);
          const hits = rayCaster.intersectObjects(embeddedClickTargets, false);
          if (hits.length){
            const idx = hits[0].object.userData.candidateIdx;
            if (typeof idx === 'number') runner.handleInput({type:'mrt_pick', idx});
          }
        };
        canvas.addEventListener('click', onClick);
        document.addEventListener('keydown', onKey);
        return () => {
          cancelAnimationFrame(raf);
          renderer.dispose();
          document.removeEventListener('keydown', onKey);
          canvas.removeEventListener('click', onClick);
          runner.abort();
        };
      }
      return ()=>{ cancelAnimationFrame(raf); renderer.dispose(); };
    }}
  />;
}

/* ════════════════════════════════════════════════════
   15 · 靈鹿之徑 — Perspective Taking (SOT)
   Two modes (selected in settings):
     Mode A · 俯瞰模式 (God View): parchment-map overhead
       array of seven spirit objects; subject imagines standing
       at one, facing another, and points to a third.
     Mode B · 親歷模式 (First-Person Route): forest interior
       with mini route-map showing turns; subject stands at the
       end of a mental walk and points back to start.
   Both share the same trial generation (S/F/T angles) and the
   same arrow-dial response — only the surrounding scene differs.
═════════════════════════════════════════════════════ */
function Game_SOT({ params, onComplete, onAbort }) {
  const ctxRef = useRef(null);
  const runnerRef = useRef(null);
  const [trialIdx, setTrialIdx] = useState(-1);
  const [hits, setHits] = useState([]);
  const [stars, setStars] = useState(2);
  const [currentTrial, setCurrentTrial] = useState(null);
  const [dialAngle, setDialAngle] = useState(null);
  const mode = params?.mode || 'A';
  const [instrText, setInstrText] = useState(
    mode === 'A'
      ? '想像你站於 A 物，面朝 B 物，指出 C 物的方向'
      : '沿路徑心智漫步至終點 · 抵達後指回出發地'
  );
  // Mode B walk-animation state
  const [walkActive, setWalkActive] = useState(false);
  const [walkMsg, setWalkMsg] = useState(null);
  const [walkPos, setWalkPos] = useState(null);       // {x, y} in world coords
  const [walkHeading, setWalkHeading] = useState(0);  // degrees (bearing, 0=north)
  const [walkLegIdx, setWalkLegIdx] = useState(0);
  const [arrivedEnd, setArrivedEnd] = useState(false);
  const walkAbortRef = useRef({abort: false});

  // 7 base + 5 extended (12-object set used by intermediate / research levels).
  // Matches TASK_IMPL[9]._OBJECTS_7 / _OBJECTS_12 indices.
  const OBJS = [
    {key:'cherry',  label:'櫻花樹', glyph:'❀', col:'#e089a8'},
    {key:'lantern', label:'石燈',   glyph:'⏣', col:'#b87030'},
    {key:'deer',    label:'靈鹿',   glyph:'✦', col:'#3e7e58'},
    {key:'stone',   label:'苔石',   glyph:'●', col:'#6a7a68'},
    {key:'pond',    label:'月池',   glyph:'○', col:'#6898c8'},
    {key:'torii',   label:'鳥居',   glyph:'冂', col:'#c05050'},
    {key:'moss',    label:'苔徑',   glyph:'∿', col:'#4a7048'},
    {key:'pine',    label:'松樹',   glyph:'♣', col:'#3a6038'},
    {key:'bridge',  label:'木橋',   glyph:'═', col:'#8c6840'},
    {key:'fox',     label:'狐影',   glyph:'⌬', col:'#d8884a'},
    {key:'well',    label:'古井',   glyph:'◯', col:'#487098'},
    {key:'shrine',  label:'神社',   glyph:'⛩', col:'#a8506e'},
  ];

  // Pull positions from the level-appropriate object set in window.TASK_IMPL[9]
  function objAt(i) {
    const level = (params && params.level) || 'basic';
    const setName = (level === 'intermediate' || level === 'research') ? '_OBJECTS_12' : '_OBJECTS_7';
    const T = window.TASK_IMPL?.[9]?.[setName]?.[i];
    return T ? { ...T, ...OBJS[i] } : { x:0, y:0, ...OBJS[i] };
  }

  return <Scene
    chapter={mode === 'A' ? '第十五章 · 靈鹿之徑 · Spatial Orientation' : '第十五章 · 靈鹿之徑 · 親歷模式 · Route Perspective'}
    title={mode === 'A' ? '靈鹿之徑 — Perspective Taking' : '靈鹿之徑 — First-Person Route'}
    subtitle={mode === 'A' ? '想像站於一物·面向另一物 · 為第三物指方向' : '沿路徑心智漫步 · 抵終點後指回出發點'}
    tags={mode === 'A'
      ? [{label:'SOT · GOD VIEW', color:'#c87030'}, {label:'12 trials', color:'#8a7a60'}]
      : [{label:'JRD · FIRST PERSON', color:'#8060b0'}, {label:'12 trials', color:'#8a7a60'}]
    }
    instr={instrText}
    reward={trialIdx >= 0 ? stars : 2}
    trialDots={trialIdx >= 0 ? {total:12, cur:trialIdx%12, hits:hits.slice(-12)} : undefined}
    protocol={(window.TASK_DOCS && window.TASK_DOCS[9])
      ? buildProtocolText(9, params)
      : '<b>Protocol · SOT</b>  Hegarty & Waller 2004 / Kozhevnikov 2001 — 詳細說明載入中…'}
    metrics={['平均絕對誤差 (°)','反應時間','迷向率 (>90°)','完成率']}
    init={(el)=>{
      const {renderer, scene, cam} = SK.mkScene(el, {z:7.5});

      // ── BACKDROP — different per mode ──────────────
      if (mode === 'A') {
        // parchment forest-map
        SK.addBackdrop(scene, cam, (c,w,h)=>{
          const pg = c.createLinearGradient(0,0,0,h);
          pg.addColorStop(0,'#f5eed8');
          pg.addColorStop(.5,'#ede4c8');
          pg.addColorStop(1,'#d9c9a8');
          c.fillStyle=pg; c.fillRect(0,0,w,h);
          for(let i=0;i<30;i++){
            const x=Math.random()*w, y=Math.random()*h, r=14+Math.random()*40;
            const g=c.createRadialGradient(x,y,0,x,y,r);
            g.addColorStop(0,'rgba(140,100,60,.08)');
            g.addColorStop(1,'rgba(140,100,60,0)');
            c.fillStyle=g; c.fillRect(x-r,y-r,r*2,r*2);
          }
          // compass rose
          c.save();
          c.translate(w*.88, h*.16);
          c.strokeStyle='rgba(120,80,40,.35)';
          c.fillStyle='rgba(120,80,40,.35)';
          c.lineWidth=1.4;
          c.beginPath(); c.arc(0,0,48,0,Math.PI*2); c.stroke();
          c.beginPath(); c.arc(0,0,42,0,Math.PI*2); c.stroke();
          for(let i=0;i<8;i++){
            const a = i*Math.PI/4;
            c.beginPath();
            c.moveTo(Math.cos(a)*12, Math.sin(a)*12);
            c.lineTo(Math.cos(a)*46, Math.sin(a)*46);
            c.stroke();
          }
          c.font = "600 14px 'Noto Serif TC',serif";
          c.textAlign='center'; c.textBaseline='middle';
          c.fillText('北', 0, -56);
          c.fillText('南', 0,  56);
          c.fillText('東',  56, 0);
          c.fillText('西', -56, 0);
          c.restore();
          c.strokeStyle='rgba(100,70,30,.04)';
          for(let i=0;i<120;i++){
            c.beginPath();
            c.moveTo(Math.random()*w, Math.random()*h);
            c.lineTo(Math.random()*w, Math.random()*h);
            c.stroke();
          }
        }, -28);
      } else {
        // forest interior
        SK.addBackdrop(scene, cam, (c,w,h)=>{
          const sg = c.createLinearGradient(0,0,0,h);
          sg.addColorStop(0,'#c8d8b8');
          sg.addColorStop(.35,'#a8c098');
          sg.addColorStop(.55,'#8ab090');
          sg.addColorStop(.75,'#5c8268');
          sg.addColorStop(1,'#3a5e40');
          c.fillStyle=sg; c.fillRect(0,0,w,h);
          for(let i=0;i<5;i++){
            const x = w*(.15+i*.18);
            const g = c.createLinearGradient(x, 0, x+40, h);
            g.addColorStop(0,'rgba(255,250,220,.25)');
            g.addColorStop(.6,'rgba(255,250,220,.08)');
            g.addColorStop(1,'rgba(255,250,220,0)');
            c.fillStyle=g;
            c.beginPath();
            c.moveTo(x-18,0); c.lineTo(x+18,0);
            c.lineTo(x+60,h); c.lineTo(x-30,h); c.closePath(); c.fill();
          }
          for(let i=0;i<14;i++){
            const x = Math.random()*w, hh = h*(.45+Math.random()*.35);
            c.fillStyle = 'rgba(40,50,35,'+(.18+Math.random()*.22)+')';
            c.fillRect(x, h-hh, 6+Math.random()*14, hh);
          }
          const gg = c.createLinearGradient(0,h*.75,0,h);
          gg.addColorStop(0,'#3a5e40');
          gg.addColorStop(1,'#5a7048');
          c.fillStyle=gg; c.fillRect(0,h*.75,w,h*.25);
          for(let i=0;i<80;i++){
            const x=Math.random()*w, y=h*.78+Math.random()*h*.2;
            c.fillStyle=SK.choice(['#8a6838','#6a7a48','#a08050','#4a5a30']);
            c.fillRect(x,y,1.5+Math.random()*2,1+Math.random());
          }
        }, -30);
      }

      // ── OBJECT MARKERS (Mode A only — visible on map) ──
      // Render only the level-appropriate count (7 for basic, 12 for intermediate/research)
      const objCount = (params?.level === 'intermediate' || params?.level === 'research') ? 12 : 7;
      const objMeshes = [];
      if (mode === 'A') {
        OBJS.slice(0, objCount).forEach((o,i)=>{
          const pos = objAt(i);
          const tex = SK.mkTex(192,256,(c,w,h)=>{
            c.clearRect(0,0,w,h);
            c.fillStyle = o.col;
            c.beginPath(); c.arc(w/2, h*.46, 20, 0, Math.PI*2); c.fill();
            c.strokeStyle='#2c2416'; c.lineWidth=2;
            c.beginPath(); c.arc(w/2, h*.46, 20, 0, Math.PI*2); c.stroke();
            c.font="700 28px 'Noto Serif TC',serif";
            c.fillStyle = '#f5f0e4';
            c.textAlign='center'; c.textBaseline='middle';
            c.fillText(o.glyph, w/2, h*.46+1);
            c.font="600 22px 'Noto Serif TC',serif";
            c.fillStyle='#2c2416';
            c.fillText(o.label, w/2, h*.76);
          });
          const m = SK.mkPlane(.9, 1.2, tex);
          m.position.set(-1.2 + pos.x*.55, pos.y*.55 + .15, .01);
          m.visible = false;
          scene.add(m);
          objMeshes.push(m);
        });
      }

      // Frame disc (Mode A)
      let frame = null;
      if (mode === 'A') {
        const frameTex = SK.mkTex(1024,768,(c,w,h)=>{
          c.clearRect(0,0,w,h);
          const cx = w*.35, cy = h/2, R = 280;
          const dg = c.createRadialGradient(cx,cy,10,cx,cy,R);
          dg.addColorStop(0,'#f0e4cc');
          dg.addColorStop(.7,'#e5d5b2');
          dg.addColorStop(1,'rgba(229,213,178,0)');
          c.fillStyle=dg;
          c.beginPath(); c.arc(cx,cy,R,0,Math.PI*2); c.fill();
          c.strokeStyle='rgba(120,80,40,.45)';
          c.setLineDash([3,6]); c.lineWidth=1.5;
          c.beginPath(); c.arc(cx,cy,R-6,0,Math.PI*2); c.stroke();
          c.setLineDash([]);
        });
        frame = SK.mkPlane(8.5, 6.4, frameTex);
        frame.position.set(-1.2, 0, -.05);
        scene.add(frame);
      }

      // ── Mode B: proper FPV scene (smooth curve traversal + lush forest) ──
      let fpvDecor = [], fpvGround = null, fpvCanopy = null, fpvPathTiles = [];
      let fpvTreeTextures = [], fpvBushTextures = [];
      let fpvLandmarkTexCache = {};
      let fpvBuildForestForCurve = null;
      if (mode === 'B') {
        // Reconfigure camera for FPV — lower eye-level, wider FOV
        cam.fov = 78;
        cam.near = 0.05;
        cam.far = 220;
        cam.position.set(0, 0.55, 0);
        cam.lookAt(0, 0.55, -1);
        cam.updateProjectionMatrix();

        // Hide the original Scene backdrop plane.
        scene.children.forEach(ch => {
          if (ch.isMesh && ch.position.z < -20 && ch.geometry?.type === 'PlaneGeometry') {
            ch.visible = false;
          }
        });
        scene.background = new THREE.Color('#2a4838');
        // Path is ~56u long with 14u legs; extend fog far so distant trees fade
        // gradually instead of clipping into a wall.
        scene.fog = new THREE.Fog('#3e5a48', 18, 110);
        // Force opaque clear so the page's cream body bg can't leak through
        renderer.setClearColor(new THREE.Color('#2a4838'), 1);

        // Canopy dome — upper hemisphere giving "leaves overhead" feel
        const canopyTex = SK.mkTex(1024, 512, (c, w, h) => {
          c.clearRect(0, 0, w, h);
          const g = c.createLinearGradient(0, 0, 0, h);
          g.addColorStop(0, '#2a4030');
          g.addColorStop(.4, '#3e5a44');
          g.addColorStop(.8, '#5a7858');
          g.addColorStop(1, '#7a9272');
          c.fillStyle = g; c.fillRect(0, 0, w, h);
          // dense leaf clumps
          for (let i = 0; i < 260; i++) {
            const x = Math.random() * w;
            const y = Math.random() * h * .7;
            const r = 14 + Math.random() * 38;
            const cg = c.createRadialGradient(x, y, 1, x, y, r);
            cg.addColorStop(0, SK.choice(['rgba(40,70,40,.7)','rgba(80,120,70,.55)','rgba(60,90,55,.6)','rgba(100,140,90,.45)']));
            cg.addColorStop(1, 'rgba(0,0,0,0)');
            c.fillStyle = cg; c.fillRect(x - r, y - r, r * 2, r * 2);
          }
          // light shafts breaking through
          for (let i = 0; i < 8; i++) {
            const x = Math.random() * w;
            const cg = c.createLinearGradient(x, 0, x + 30, h * .9);
            cg.addColorStop(0, 'rgba(255,250,210,.22)');
            cg.addColorStop(1, 'rgba(255,250,210,0)');
            c.fillStyle = cg;
            c.beginPath();
            c.moveTo(x - 18, 0); c.lineTo(x + 18, 0);
            c.lineTo(x + 50, h * .9); c.lineTo(x - 30, h * .9); c.closePath(); c.fill();
          }
        });
        // Use upper hemisphere with the equator BELOW ground level so the dome
        // wraps the camera's full horizon — never reveals an "edge" gap between
        // the canopy and the ground that lets clear-color leak through.
        const canopyGeom = new THREE.SphereGeometry(110, 36, 18, 0, Math.PI * 2, 0, Math.PI * 0.62);
        fpvCanopy = new THREE.Mesh(canopyGeom, new THREE.MeshBasicMaterial({
          map: canopyTex, side: THREE.BackSide, fog: false, depthWrite: false,
        }));
        fpvCanopy.position.set(0, -8, 0);   // drop dome 8 units so the rim lies below visible horizon
        scene.add(fpvCanopy);

        // Lush ground with mossy patches + vignette
        const groundTex = SK.mkTex(1024, 1024, (c, w, h) => {
          c.clearRect(0, 0, w, h);
          const g = c.createRadialGradient(w/2, h/2, 30, w/2, h/2, w/2);
          g.addColorStop(0, '#5a7448');
          g.addColorStop(.6, '#3a5232');
          g.addColorStop(1, '#26361e');
          c.fillStyle = g; c.fillRect(0, 0, w, h);
          // dense leaf litter
          for (let i = 0; i < 2000; i++) {
            c.fillStyle = SK.choice([
              'rgba(140,100,50,.45)','rgba(180,150,60,.35)','rgba(80,60,30,.5)',
              'rgba(60,80,40,.4)','rgba(100,80,40,.4)','rgba(180,120,60,.35)'
            ]);
            const x = Math.random()*w, y = Math.random()*h;
            const r = 1 + Math.random()*4;
            c.beginPath(); c.ellipse(x, y, r, r*0.6, Math.random()*Math.PI, 0, Math.PI*2); c.fill();
          }
          // moss patches
          for (let i = 0; i < 80; i++) {
            const x = Math.random()*w, y = Math.random()*h;
            const r = 12 + Math.random()*25;
            const cg = c.createRadialGradient(x, y, 1, x, y, r);
            cg.addColorStop(0, 'rgba(120,160,90,.45)');
            cg.addColorStop(1, 'rgba(60,90,40,0)');
            c.fillStyle = cg; c.fillRect(x - r, y - r, r * 2, r * 2);
          }
        });
        // Make ground much larger than the canopy dome so the area just below
        // horizon always shows ground (not the green dome wall leaking through).
        fpvGround = SK.mkPlane(500, 500, groundTex);
        fpvGround.rotation.x = -Math.PI / 2;
        fpvGround.position.set(0, -0.5, 0);
        scene.add(fpvGround);

        // 4 tree texture variants for variety
        function makeTrunkTex(canopyCols, trunkCols) {
          return SK.mkTex(256, 768, (c, w, h) => {
            c.clearRect(0, 0, w, h);
            // soft canopy top — multiple blob clusters
            for (let i = 0; i < 22; i++) {
              const cx = w/2 + (Math.random()-.5) * w*.7;
              const cy = h*.06 + Math.random()*h*.22;
              const r = 24 + Math.random() * 50;
              const cg = c.createRadialGradient(cx, cy, 2, cx, cy, r);
              cg.addColorStop(0, canopyCols[0]);
              cg.addColorStop(.45, canopyCols[1]);
              cg.addColorStop(1, 'rgba(60,90,55,0)');
              c.fillStyle = cg; c.fillRect(cx-r, cy-r, r*2, r*2);
            }
            // trunk centred
            const tx = w * .38, tw = w * .24;
            const grad = c.createLinearGradient(tx, 0, tx + tw, 0);
            grad.addColorStop(0, 'rgba(20,12,8,0)');
            grad.addColorStop(.2, trunkCols[0]);
            grad.addColorStop(.5, trunkCols[1]);
            grad.addColorStop(.8, trunkCols[0]);
            grad.addColorStop(1, 'rgba(20,12,8,0)');
            c.fillStyle = grad;
            c.fillRect(tx, h*.20, tw, h*.78);
            // bark striations
            c.strokeStyle = 'rgba(18,10,6,.6)';
            c.lineWidth = 1.4;
            for (let i = 0; i < 24; i++) {
              const x = tx + Math.random() * tw;
              c.beginPath();
              c.moveTo(x, h*.20);
              c.bezierCurveTo(
                x + (Math.random()-.5)*14, h*.4,
                x + (Math.random()-.5)*18, h*.7,
                x + (Math.random()-.5)*10, h
              );
              c.stroke();
            }
            // moss at base
            c.fillStyle = 'rgba(90,140,80,.55)';
            for (let i = 0; i < 35; i++) {
              c.beginPath();
              c.arc(tx + Math.random()*tw, h*.84 + Math.random()*h*.14, 2 + Math.random()*3, 0, Math.PI*2);
              c.fill();
            }
          });
        }
        fpvTreeTextures = [
          makeTrunkTex(['rgba(120,170,100,.85)','rgba(70,120,75,.55)'], ['#4a3020','#7a5a38']),
          makeTrunkTex(['rgba(100,150,90,.85)','rgba(60,100,65,.55)'], ['#3a2a1e','#6a4a30']),
          makeTrunkTex(['rgba(140,180,110,.85)','rgba(80,130,80,.55)'], ['#5a4030','#8a6a48']),
          makeTrunkTex(['rgba(110,160,100,.8)' ,'rgba(70,110,70,.55)'], ['#3a2a18','#5a4030']),
        ];

        // Bush texture (foreground filler)
        function makeBushTex() {
          return SK.mkTex(256, 192, (c, w, h) => {
            c.clearRect(0, 0, w, h);
            for (let i = 0; i < 16; i++) {
              const cx = w*.5 + (Math.random()-.5) * w*.7;
              const cy = h*.55 + (Math.random()-.5) * h*.5;
              const r = 14 + Math.random() * 32;
              const cg = c.createRadialGradient(cx, cy, 1, cx, cy, r);
              cg.addColorStop(0, SK.choice(['rgba(90,140,80,.85)','rgba(70,110,60,.85)','rgba(120,160,100,.75)','rgba(100,150,90,.8)']));
              cg.addColorStop(1, 'rgba(50,80,50,0)');
              c.fillStyle = cg; c.fillRect(cx-r, cy-r, r*2, r*2);
            }
          });
        }
        fpvBushTextures = [makeBushTex(), makeBushTex(), makeBushTex()];

        // Build forest along a Catmull-Rom curve (called per trial).
        // Constructs a small "world" of paths around the walked curve:
        //   - the walked curve itself
        //   - extensions beyond start and end (so view recedes past either end)
        //   - decoy forks at each interior waypoint
        //   - a couple of distant isolated paths visible through the trees
        // Then places a layered forest with corridor-clear rejection so no
        // tree blocks any of these paths.
        fpvBuildForestForCurve = (curve, waypointsW, landmarksW, opts = {}) => {
          fpvDecor.forEach(d => scene.remove(d));
          fpvDecor = [];
          fpvPathTiles.forEach(p => scene.remove(p));
          fpvPathTiles = [];

          const PATH_HALF = 1.4;
          const totalLen = curve.getLength();
          const startIdx = opts.startIdx ?? -1;
          const endIdx   = opts.endIdx   ?? -1;

          // ─── Build the path network (main + extensions + branches + isolates) ───
          const decorCurves = [];
          const mkCurve = (pts) => new THREE.CatmullRomCurve3(pts.map(p => p.clone ? p.clone() : new THREE.Vector3(p.x, 0.55, p.z)), false, 'catmullrom', 0.5);

          // Pre-extension: extend ~14u backward from the start in the start tangent direction
          {
            const p0 = curve.getPointAt(0);
            const t0 = curve.getTangentAt(0);
            const p1 = new THREE.Vector3().copy(p0).addScaledVector(t0, -6);
            const p2 = new THREE.Vector3().copy(p0).addScaledVector(t0, -13)
              .add(new THREE.Vector3((Math.random()-.5)*4, 0, (Math.random()-.5)*4));
            decorCurves.push(mkCurve([p2, p1, p0]));
          }
          // Post-extension: extend ~28u forward beyond the end. The path
          // continues into the woods so the arrival frame still sees a road
          // receding into fog rather than a dead end.
          let postExtend = null;
          {
            const p0 = curve.getPointAt(1);
            const t0 = curve.getTangentAt(1);
            const p1 = new THREE.Vector3().copy(p0).addScaledVector(t0, 8)
              .add(new THREE.Vector3((Math.random()-.5)*2, 0, (Math.random()-.5)*2));
            const p2 = new THREE.Vector3().copy(p0).addScaledVector(t0, 17)
              .add(new THREE.Vector3((Math.random()-.5)*4, 0, (Math.random()-.5)*4));
            const p3 = new THREE.Vector3().copy(p0).addScaledVector(t0, 28)
              .add(new THREE.Vector3((Math.random()-.5)*6, 0, (Math.random()-.5)*6));
            postExtend = mkCurve([p0, p1, p2, p3]);
            decorCurves.push(postExtend);
          }
          // Branch forks: at each interior waypoint, send a decoy off perpendicular
          if (waypointsW && waypointsW.length >= 3) {
            for (let i = 1; i < waypointsW.length - 1; i++) {
              const wp = waypointsW[i];
              const prev = waypointsW[i-1];
              const next = waypointsW[i+1];
              const incoming = new THREE.Vector3().subVectors(wp, prev).normalize();
              const outgoing = new THREE.Vector3().subVectors(next, wp).normalize();
              // Perpendicular to the bisector — branch shoots out the "outside" of the turn
              const bisector = new THREE.Vector3().addVectors(incoming, outgoing).normalize();
              const lat = new THREE.Vector3(-bisector.z, 0, bisector.x);
              const side = Math.random() < .5 ? -1 : 1;
              const len = 9 + Math.random() * 6;
              const start = new THREE.Vector3().copy(wp);
              const end = new THREE.Vector3().copy(wp).addScaledVector(lat, side * len)
                .addScaledVector(bisector, (Math.random()-.5) * 3);
              const mid = new THREE.Vector3().addVectors(start, end).multiplyScalar(0.5)
                .addScaledVector(bisector, (Math.random()-.5) * 2);
              decorCurves.push(mkCurve([start, mid, end]));
            }
          }
          // Distant isolated paths: 2 short segments 22-34u from main curve, random orientation
          for (let i = 0; i < 2; i++) {
            const u = 0.2 + Math.random() * 0.6;
            const p = curve.getPointAt(u);
            const tan = curve.getTangentAt(u);
            const lat = new THREE.Vector3(-tan.z, 0, tan.x);
            const side = Math.random() < .5 ? -1 : 1;
            const off = 22 + Math.random() * 12;
            const ctr = new THREE.Vector3().copy(p).addScaledVector(lat, side * off).setY(0.55);
            const angle = Math.random() * Math.PI * 2;
            const dir = new THREE.Vector3(Math.cos(angle), 0, Math.sin(angle));
            const a = new THREE.Vector3().copy(ctr).addScaledVector(dir, -7);
            const b = new THREE.Vector3().copy(ctr).addScaledVector(dir, 7);
            decorCurves.push(mkCurve([a, ctr, b]));
          }

          // Stash post-extension on the curve so the camera lookahead can use it
          curve.userData = curve.userData || {};
          curve.userData.postExtend = postExtend;

          // ─── Pre-sample all paths for the corridor-clear test ───
          const corridorPts = [];
          const sampleInto = (cv, density) => {
            const len = cv.getLength();
            const n = Math.max(20, Math.round(len * density));
            for (let k = 0; k <= n; k++) corridorPts.push(cv.getPointAt(k / n));
          };
          sampleInto(curve, 1.5);
          for (const dc of decorCurves) sampleInto(dc, 1.5);
          // Treat each landmark as a corridor obstacle so trees/bushes don't
          // grow inside the landmark area.
          if (Array.isArray(landmarksW)) {
            for (const lm of landmarksW) corridorPts.push(new THREE.Vector3(lm.wx, 0.55, lm.wz));
          }
          const farFromCurve = (px, pz, minDist) => {
            const md2 = minDist * minDist;
            for (const cp of corridorPts) {
              const dx = px - cp.x, dz = pz - cp.z;
              if (dx*dx + dz*dz < md2) return false;
            }
            return true;
          };

          // ─── Path tiles — one shared texture, tiles laid along all path curves ───
          // Build a small set of variants so the path doesn't look uniform
          const tileTexVariants = [];
          for (let v = 0; v < 4; v++) {
            tileTexVariants.push(SK.mkTex(128, 128, (c,w,h)=>{
              c.clearRect(0, 0, w, h);
              const cg = c.createRadialGradient(w/2, h/2, 4, w/2, h/2, w/2);
              cg.addColorStop(0, 'rgba(190,175,108,.88)');
              cg.addColorStop(.6, 'rgba(135,140,75,.55)');
              cg.addColorStop(1, 'rgba(80,90,50,0)');
              c.fillStyle = cg; c.fillRect(0, 0, w, h);
              for (let k=0;k<32;k++){
                c.fillStyle = SK.choice(['rgba(70,90,40,.5)','rgba(150,130,60,.4)','rgba(180,150,80,.4)','rgba(60,80,30,.45)']);
                c.beginPath(); c.arc(Math.random()*w, Math.random()*h, 1+Math.random()*3, 0, Math.PI*2); c.fill();
              }
            }));
          }
          const layTilesAlong = (cv, halfW) => {
            const len = cv.getLength();
            const n = Math.max(30, Math.round(len * 2));
            for (let i = 0; i <= n; i++) {
              const p = cv.getPointAt(i / n);
              const tile = SK.mkPlane(2 * halfW, 2 * halfW, SK.choice(tileTexVariants));
              tile.rotation.x = -Math.PI/2;
              tile.position.set(p.x, -0.42, p.z);
              tile.material.transparent = true;
              tile.material.depthWrite = false;
              tile.material.polygonOffset = true;
              tile.material.polygonOffsetFactor = -2;
              tile.material.polygonOffsetUnits = -2;
              tile.renderOrder = 1;
              scene.add(tile);
              fpvPathTiles.push(tile);
            }
          };
          layTilesAlong(curve, PATH_HALF);                  // main walked path
          // Decor paths — branches/extensions slightly narrower for visual hierarchy
          for (const dc of decorCurves) layTilesAlong(dc, PATH_HALF * 0.85);

          // Helper: place a billboard tree given a curve point + lateral.
          // Rejects placements that fall within the path corridor — prevents
          // trees from blocking forward sight on the inside of curve bends.
          // Returns true if placed.
          const addTree = (p, lat, side, offset, opts={}) => {
            const minDist = opts.minCorridor ?? 3.0;
            const jitter = opts.jitter ?? 0.5;
            // Try a few jitter samples before giving up
            for (let attempt = 0; attempt < 4; attempt++) {
              const px = p.x + lat.x * side * offset + (Math.random()-.5) * jitter;
              const pz = p.z + lat.z * side * offset + (Math.random()-.5) * jitter;
              if (!farFromCurve(px, pz, minDist)) continue;
              const treeTex = SK.choice(fpvTreeTextures);
              const scale = (opts.scaleMin ?? 0.95) + Math.random() * ((opts.scaleMax ?? 1.45) - (opts.scaleMin ?? 0.95));
              const tw = (opts.baseW ?? 1.6) * scale;
              const th = (opts.baseH ?? 5.0) * scale;
              const m = SK.mkPlane(tw, th, treeTex);
              m.position.set(px, th/2 - 0.5, pz);
              m.material.transparent = true;
              m.material.alphaTest = opts.alphaTest ?? 0.5;
              m.material.depthWrite = opts.depthWrite ?? true;
              m.userData.billboard = true;
              scene.add(m);
              fpvDecor.push(m);
              return true;
            }
            return false;
          };

          // ─── Helper: line trees along a path (near + mid layers) ───
          // density factor scales tree count down for decor paths so we don't
          // overfill the world with sprites.
          const lineTreesAlong = (cv, densityFactor = 1) => {
            const len = cv.getLength();
            const nearPerSide = Math.max(6, Math.round((len / 1.6) * densityFactor));
            for (let side of [-1, 1]) {
              for (let i = 0; i < nearPerSide; i++) {
                const u = (i + Math.random() * 0.7) / nearPerSide;
                if (u > 1) continue;
                const p = cv.getPointAt(u);
                const tan = cv.getTangentAt(u);
                const lat = new THREE.Vector3(-tan.z, 0, tan.x).normalize();
                const offset = 3.5 + Math.random() * 2.5;
                addTree(p, lat, side, offset, {
                  baseW: 1.5, baseH: 4.8,
                  scaleMin: 0.9, scaleMax: 1.45,
                  jitter: 0.7,
                });
              }
            }
            const midPerSide = Math.max(8, Math.round((len / 1.2) * densityFactor));
            for (let side of [-1, 1]) {
              for (let i = 0; i < midPerSide; i++) {
                const u = Math.random();
                const p = cv.getPointAt(u);
                const tan = cv.getTangentAt(u);
                const lat = new THREE.Vector3(-tan.z, 0, tan.x).normalize();
                const offset = 6.5 + Math.random() * 6.0;
                addTree(p, lat, side, offset, {
                  baseW: 1.6, baseH: 5.2,
                  scaleMin: 0.85, scaleMax: 1.35,
                  jitter: 1.2,
                });
              }
            }
          };
          // Main walked path — full density.
          lineTreesAlong(curve, 1.0);
          // Decor paths (extensions, branches, isolates) — somewhat lighter
          // density to keep total tree count manageable while still framing
          // every path as a corridor.
          for (const dc of decorCurves) lineTreesAlong(dc, 0.7);

          // ─── Layer 3: FAR — distant silhouettes that fade into fog ───
          const farCount = Math.max(28, Math.round(totalLen * 1.0));
          for (let i = 0; i < farCount; i++) {
            const u = Math.random();
            const p = curve.getPointAt(u);
            const tan = curve.getTangentAt(u);
            const lat = new THREE.Vector3(-tan.z, 0, tan.x).normalize();
            const side = Math.random() < .5 ? -1 : 1;
            const offset = 14 + Math.random() * 22;          // 14–36
            const px = p.x + lat.x * side * offset + (Math.random()-.5)*2;
            const pz = p.z + lat.z * side * offset + (Math.random()-.5)*2;
            // Even far trees must be off-path; rejection avoids "wall" effect
            if (!farFromCurve(px, pz, 4.5)) continue;
            const treeTex = SK.choice(fpvTreeTextures);
            const scale = 0.65 + Math.random() * 0.45;
            const tw = 1.5 * scale, th = 4.6 * scale;
            const m = SK.mkPlane(tw, th, treeTex);
            m.position.set(px, th/2 - 0.5, pz);
            m.material.transparent = true;
            m.material.alphaTest = 0.4;
            m.material.depthWrite = true;
            m.userData.billboard = true;
            scene.add(m);
            fpvDecor.push(m);
          }

          // ─── Bushes — low foreground filler just outside the path edge ───
          const bushPerSide = Math.max(8, Math.round(totalLen / 1.5));
          for (let side of [-1, 1]) {
            for (let i = 0; i < bushPerSide; i++) {
              const u = Math.random();
              const p = curve.getPointAt(u);
              const tan = curve.getTangentAt(u);
              const lat = new THREE.Vector3(-tan.z, 0, tan.x).normalize();
              const scale = 0.8 + Math.random() * 0.7;
              const bushW = 1.7 * scale;
              const bushH = 1.1 * scale;
              const offset = PATH_HALF + bushW * 0.5 + Math.random() * 1.2;
              const bx = p.x + lat.x * side * offset;
              const bz = p.z + lat.z * side * offset;
              // Skip bushes that would pop into the path corridor
              if (!farFromCurve(bx, bz, PATH_HALF + 0.2)) continue;
              const bt = SK.choice(fpvBushTextures);
              const m = SK.mkPlane(bushW, bushH, bt);
              m.position.set(bx, bushH/2 - 0.5, bz);
              m.material.transparent = true;
              m.material.alphaTest = 0.5;
              m.material.depthWrite = true;
              m.userData.billboard = true;
              scene.add(m);
              fpvDecor.push(m);
            }
          }

          // ─── Landmark signposts — one wooden sign-on-stake per named place ───
          // No 3D models of the landmarks themselves; just a hand-painted plank
          // signpost (matching the parchment / wood-and-ink visual language of
          // the rest of the suite). Each sign carries its landmark name.
          const makeSignTex = (name) => SK.mkTex(256, 384, (c,w,h)=>{
            c.clearRect(0, 0, w, h);
            // ── Wooden plank at top ──
            const plankTop = h * 0.06;
            const plankBot = h * 0.46;
            const plankH = plankBot - plankTop;
            const plankPad = w * 0.04;
            const grad = c.createLinearGradient(0, plankTop, 0, plankBot);
            grad.addColorStop(0,    '#8a6238');
            grad.addColorStop(0.5,  '#6a4628');
            grad.addColorStop(1,    '#4a3220');
            c.fillStyle = grad;
            c.fillRect(plankPad, plankTop, w - 2*plankPad, plankH);
            // grain lines
            c.strokeStyle = 'rgba(40,28,18,.45)'; c.lineWidth = 1.2;
            for (let k = 0; k < 8; k++) {
              const yy = plankTop + 6 + Math.random() * (plankH - 12);
              c.beginPath();
              c.moveTo(plankPad, yy);
              c.lineTo(w - plankPad, yy + (Math.random() - .5) * 5);
              c.stroke();
            }
            // dark border
            c.strokeStyle = '#241410'; c.lineWidth = 3;
            c.strokeRect(plankPad + 1.5, plankTop + 1.5, w - 2*plankPad - 3, plankH - 3);
            // metal corner studs
            c.fillStyle = '#2a1a10';
            for (const [sx, sy] of [[plankPad + 8, plankTop + 8], [w - plankPad - 8, plankTop + 8],
                                    [plankPad + 8, plankBot - 8], [w - plankPad - 8, plankBot - 8]]) {
              c.beginPath(); c.arc(sx, sy, 3.2, 0, Math.PI * 2); c.fill();
            }
            // landmark name
            c.fillStyle = '#f5ecd0';
            c.font = "bold 56px 'Noto Serif TC', serif";
            c.textAlign = 'center';
            c.textBaseline = 'middle';
            c.fillText(name || '?', w / 2, (plankTop + plankBot) / 2 + 2);
            // ── Stake going down to ground ──
            const stakeW = w * 0.10;
            const stakeTop = plankBot - 2;
            const stakeBot = h - 2;
            const stakeGrad = c.createLinearGradient(w/2 - stakeW/2, 0, w/2 + stakeW/2, 0);
            stakeGrad.addColorStop(0,   '#3a2818');
            stakeGrad.addColorStop(0.5, '#5a3e22');
            stakeGrad.addColorStop(1,   '#3a2818');
            c.fillStyle = stakeGrad;
            c.fillRect(w/2 - stakeW/2, stakeTop, stakeW, stakeBot - stakeTop);
            c.strokeStyle = '#1a0e08'; c.lineWidth = 1.8;
            c.strokeRect(w/2 - stakeW/2, stakeTop, stakeW, stakeBot - stakeTop);
            // a horizontal nail/bolt where the plank meets the stake
            c.fillStyle = '#2a1a10';
            c.beginPath(); c.arc(w/2, plankBot - 2, 4, 0, Math.PI * 2); c.fill();
          });

          if (Array.isArray(landmarksW)) {
            // Sign dimensions in world units. Tall enough to read from a
            // distance (plank ~1.2u tall, total ~2.6u including stake).
            const SIGN_W = 1.7;
            const SIGN_H = 2.6;
            for (const lm of landmarksW) {
              if (!fpvLandmarkTexCache[lm.id]) {
                fpvLandmarkTexCache[lm.id] = makeSignTex(lm.name || '?');
              }
              const tex = fpvLandmarkTexCache[lm.id];
              const sign = SK.mkPlane(SIGN_W, SIGN_H, tex);
              // Position so the stake bottom touches the ground (y = -0.5).
              sign.position.set(lm.wx, SIGN_H / 2 - 0.5, lm.wz);
              sign.material.transparent = true;
              sign.material.alphaTest = 0.4;
              sign.material.depthWrite = true;
              sign.userData.billboard = true;
              scene.add(sign);
              fpvDecor.push(sign);
            }
          }
        };
      }

      // Highlight aux: ring (S), arrow (S→F), halo+star (T) — Mode A only
      let ringA, arrowAB, haloC, starC;
      if (mode === 'A') {
        ringA = new THREE.Mesh(
          new THREE.RingGeometry(.55,.65,48),
          new THREE.MeshBasicMaterial({color:0x4888c8, transparent:true, opacity:.0, side:THREE.DoubleSide})
        );
        ringA.rotation.x = 0;
        scene.add(ringA);

        const arrTex = SK.mkTex(512,64,(c,w,h)=>{
          c.clearRect(0,0,w,h);
          c.strokeStyle='rgba(72,136,200,.85)';
          c.lineWidth=3;
          c.setLineDash([8,6]);
          c.beginPath(); c.moveTo(20,h/2); c.lineTo(w-40,h/2); c.stroke();
          c.setLineDash([]);
          c.fillStyle='rgba(72,136,200,.95)';
          c.beginPath();
          c.moveTo(w-40,h/2-12); c.lineTo(w-8,h/2); c.lineTo(w-40,h/2+12); c.closePath();
          c.fill();
        });
        arrowAB = SK.mkPlane(2, .22, arrTex);
        arrowAB.material.opacity = 0;
        arrowAB.material.transparent = true;
        scene.add(arrowAB);

        const haloTex = SK.makeGlow('#f0c858', 128);
        haloC = new THREE.Mesh(
          new THREE.PlaneGeometry(1.6,1.6),
          new THREE.MeshBasicMaterial({map:haloTex, transparent:true, opacity:0, depthWrite:false, blending:THREE.AdditiveBlending})
        );
        scene.add(haloC);

        const starTex = SK.mkTex(128,128,(c,w,h)=>{
          c.clearRect(0,0,w,h);
          c.fillStyle='#f0c858'; c.strokeStyle='#6b4a18'; c.lineWidth=2;
          c.beginPath();
          for(let i=0;i<10;i++){
            const a = -Math.PI/2 + i*Math.PI/5;
            const r = i%2===0 ? w*.45 : w*.18;
            const x = w/2+Math.cos(a)*r, y = h/2+Math.sin(a)*r;
            if(i===0) c.moveTo(x,y); else c.lineTo(x,y);
          }
          c.closePath(); c.fill(); c.stroke();
        });
        starC = SK.mkPlane(.4,.4,starTex);
        starC.material.opacity = 0;
        starC.material.transparent = true;
        scene.add(starC);
      }

      const ff = SK.makeFireflies(scene, mode === 'A' ? 10 : 14, {x:6, y:3, z:1});

      // ── Mode B FPV camera state machine — smooth curve traversal ──
      // playWalk builds a Catmull-Rom curve from the trial waypoints; the
      // camera traverses it at constant speed with rotation set by the
      // tangent direction (gives natural rounded corners, no hard pivots).
      const fpvMotion = { active: false, start: 0, dur: 0, curve: null, len: 1 };
      function fpvStartCurve(curve, durMs) {
        fpvMotion.active = true;
        fpvMotion.curve = curve;
        fpvMotion.start = performance.now();
        fpvMotion.dur = Math.max(1, durMs);
        fpvMotion.len = Math.max(0.001, curve.getLength());
      }
      function fpvStop() { fpvMotion.active = false; }
      function fpvResetCamera() {
        cam.position.set(0, 0.55, 0);
        cam.rotation.set(0, 0, 0);
        cam.lookAt(0, 0.55, -1);
        fpvMotion.active = false;
      }
      function fpvTickCurve() {
        if (!fpvMotion.active || !fpvMotion.curve) return;
        const t = Math.min(1, (performance.now() - fpvMotion.start) / fpvMotion.dur);
        // Mild ease in/out so we don't snap at start/end
        const e = t < 0.08 ? (t / 0.08) * 0.08
                : t > 0.92 ? 0.92 + (t - 0.92) / 0.08 * 0.08
                : t;
        const p = fpvMotion.curve.getPointAt(e);
        cam.position.set(p.x, 0.55, p.z);
        // Look ~3.5u further along the curve in world units (not normalized).
        // If the lookahead point would fall past the curve end, extrapolate onto
        // the post-extension path so the gaze stays on path receding into woods.
        const lookDist = 3.5;
        const remainOnCurve = (1 - e) * fpvMotion.len;
        let lookP;
        if (lookDist <= remainOnCurve) {
          lookP = fpvMotion.curve.getPointAt(e + lookDist / fpvMotion.len);
        } else {
          const post = fpvMotion.curve.userData?.postExtend;
          const overshoot = lookDist - remainOnCurve;
          if (post) {
            const postLen = post.getLength();
            lookP = post.getPointAt(Math.min(1, overshoot / postLen));
          } else {
            const endP = fpvMotion.curve.getPointAt(1);
            const endTan = fpvMotion.curve.getTangentAt(1);
            lookP = endP.clone().addScaledVector(endTan, overshoot);
          }
        }
        cam.lookAt(lookP.x, 0.55, lookP.z);
        if (t >= 1) fpvMotion.active = false;
      }
      function fpvBillboardDecor() {
        // Decor sprites lookAt camera (y-axis only — keep them upright)
        for (const m of fpvDecor) {
          if (!m.userData.billboard) continue;
          m.lookAt(cam.position.x, m.position.y, cam.position.z);
        }
        if (fpvGround) {
          fpvGround.position.x = cam.position.x;
          fpvGround.position.z = cam.position.z;
        }
        if (fpvCanopy) {
          fpvCanopy.position.x = cam.position.x;
          fpvCanopy.position.z = cam.position.z;
        }
      }

      let raf, t0=performance.now();
      const render=()=>{
        const now = performance.now();
        const t = now - t0;
        ff.tick(t);
        if (haloC && haloC.material.opacity > 0) {
          haloC.material.opacity = .55 + Math.sin(t*.003)*.25;
          haloC.scale.setScalar(1 + Math.sin(t*.003)*.06);
        }
        if (ringA && ringA.material.opacity > 0) {
          ringA.material.opacity = .55 + Math.sin(t*.002)*.25;
        }
        if (starC && starC.material.opacity > 0) {
          starC.rotation.z = Math.sin(t*.0012)*.2;
        }
        if (mode === 'B') {
          fpvTickCurve();
          fpvBillboardDecor();
        }
        renderer.render(scene, cam);
        raf = requestAnimationFrame(render);
      };
      render();

      // Track memory-fade timer so we can clear on hide
      let fadeTimer = null;
      // Show trial: place highlights at S/F/T in Mode A; in Mode B just keep scene
      function showTrial(trial) {
        if (mode === 'A') {
          // Reveal the relevant subset of objects (or all, for Hegarty-Waller fidelity)
          objMeshes.forEach((m, i) => {
            m.visible = true;
            m.material.opacity = 1;
            m.material.transparent = true;
            const role = i === trial.idxS ? 'S' : i === trial.idxF ? 'F' : i === trial.idxT ? 'T' : null;
            m.material.color.set(role ? 0xffffff : 0xb0a890);
          });
          // Research level: fade objects after memoryFadeMs (subject must point from memory)
          if (fadeTimer) clearTimeout(fadeTimer);
          if (trial.memoryFadeMs && trial.memoryFadeMs > 0){
            fadeTimer = setTimeout(() => {
              objMeshes.forEach(m => {
                if (m.material) m.material.opacity = 0.0;
              });
              if (haloC) haloC.material.opacity = 0;
              if (starC) starC.material.opacity = 0;
              if (arrowAB) arrowAB.material.opacity = 0;
            }, trial.memoryFadeMs);
          }
          const S = objMeshes[trial.idxS];
          const F = objMeshes[trial.idxF];
          const T = objMeshes[trial.idxT];
          ringA.position.set(S.position.x, S.position.y - .35, .005);
          ringA.material.opacity = .8;

          const dx = F.position.x - S.position.x, dy = F.position.y - S.position.y;
          const L = Math.hypot(dx, dy);
          const mid = {x:(S.position.x+F.position.x)/2, y:(S.position.y+F.position.y)/2};
          arrowAB.position.set(mid.x, mid.y - .12, .02);
          arrowAB.rotation.z = Math.atan2(dy, dx);
          arrowAB.scale.set(L * 0.92, 1, 1);
          arrowAB.material.opacity = 0.85;

          haloC.position.set(T.position.x, T.position.y - .05, .008);
          haloC.material.opacity = 0.85;
          starC.position.set(T.position.x, T.position.y + .25, .015);
          starC.material.opacity = 1;
        }
        // Mode B: visual is mostly the static path scene; the route/turn info
        // is conveyed in the instruction text + (optional) mini-map overlay
        // (to be added in a future iteration)
        setCurrentTrial(trial);
        setDialAngle(null);
      }
      function hideTrial() {
        if (mode === 'A') {
          objMeshes.forEach(m => m.visible = false);
          if (ringA) ringA.material.opacity = 0;
          if (arrowAB) arrowAB.material.opacity = 0;
          if (haloC) haloC.material.opacity = 0;
          if (starC) starC.material.opacity = 0;
        }
        setCurrentTrial(null);
        setDialAngle(null);
      }

      // ── Mode B route-walk playback ─────────────────────────────────
      function wait(ms) { return new Promise(r => setTimeout(r, ms)); }
      function animate(duration, onFrame) {
        return new Promise(resolve => {
          const start = performance.now();
          const tick = (now) => {
            if (walkAbortRef.current.abort) { resolve(); return; }
            const t = Math.min(1, (now - start) / duration);
            // Ease-in-out for natural motion
            const eased = t < 0.5 ? 2*t*t : 1 - Math.pow(-2*t + 2, 2) / 2;
            onFrame(eased, t);
            if (t < 1) requestAnimationFrame(tick);
            else resolve();
          };
          requestAnimationFrame(tick);
        });
      }
      async function playWalk(trial) {
        walkAbortRef.current.abort = false;
        setArrivedEnd(false);
        setDialAngle(null);
        // Keep the HUD clean during the walk — no segment / turn banners.
        // The first-person view itself carries all the spatial information.
        setWalkMsg(null);
        setInstrText('觀察第一人稱視角 · 記住整條路徑');
        setWalkActive(true);

        const legDur = +trial.legDur || 1600;
        const turnDur = +trial.turnDur || 800;
        const totalDur = (trial.waypoints.length - 1) * legDur + (trial.turnAngles?.length || 0) * (turnDur * 0.4);

        // Build Catmull-Rom curve through the trial's waypoints, mapping
        // (trial.x, trial.y) → (world.x, -world.z) so +y on the map = "ahead".
        // Anchor curve at origin: subtract starting waypoint so camera starts at (0,0).
        const w0 = trial.waypoints[0];
        const pts3 = trial.waypoints.map(w => new THREE.Vector3(w.x - w0.x, 0.55, -(w.y - w0.y)));
        const curve = new THREE.CatmullRomCurve3(pts3, false, 'catmullrom', 0.5);

        // Reset camera to start of curve, facing first leg's tangent
        fpvResetCamera();
        const startTan = curve.getTangentAt(0);
        cam.position.set(pts3[0].x, 0.55, pts3[0].z);
        cam.lookAt(pts3[0].x + startTan.x, 0.55, pts3[0].z + startTan.z);

        // Build forest + decoy paths + landmark sprites around this walked curve.
        // Pass the landmark array so we can place visible markers (torii / lantern
        // / cherry tree etc.) at the start, end, and other waypoints in the world.
        if (fpvBuildForestForCurve) {
          // Convert trial landmarks (map coords) into world coords using the
          // same mapping as waypoints: (lm.x, lm.y) → (x, -y).
          const lmW = (trial.landmarks || []).map((lm, idx) => ({
            ...lm, idx,
            wx: lm.x - w0.x,
            wz: -(lm.y - w0.y),
          }));
          fpvBuildForestForCurve(curve, pts3, lmW, {
            startIdx: trial.idxS, endIdx: trial.idxE,
          });
        }

        // Curve traversal is one continuous animation. The HUD intentionally
        // stays empty during the walk so the first-person view stays immersive
        // — no segment counters, no turn-angle banners.
        fpvStartCurve(curve, totalDur);

        await wait(500);  // brief pause before motion begins
        if (walkAbortRef.current.abort) { fpvStop(); return; }

        // Wait for curve animation to finish
        while (fpvMotion.active && !walkAbortRef.current.abort) {
          await wait(60);
        }
        if (walkAbortRef.current.abort) return;

        await wait(400);
        if (walkAbortRef.current.abort) return;
        setWalkActive(false);
        setArrivedEnd(true);
        setCurrentTrial(trial);
        setInstrText('');
      }

      const setPhase = (phase, trial) => {
        if (phase === 'fixation') {
          walkAbortRef.current.abort = true;
          setWalkActive(false);
          setArrivedEnd(false);
          hideTrial();
          setInstrText('準備下一題 …');
        } else if (phase === 'stimulus') {
          setTrialIdx(i => i + 1);
          if (mode === 'A') {
            showTrial(trial);
            setInstrText(`俯瞰圖 · 站在「${trial.nameS}」面向「${trial.nameF}」，「${trial.nameT}」在哪個方向？`);
            setCurrentTrial(trial);
          } else {
            // Mode B: play the walk animation, then show compass
            playWalk(trial).catch(() => {});
          }
        } else if (phase === 'feedback') {
          const ok = trial._correct;
          setInstrText(
            ok
              ? `✓ 角度誤差 ${Math.round(trial.angleErr || 0)}°`
              : `✗ 誤差 ${Math.round(trial.angleErr || 0)}° (正確：${Math.round(trial.relAngle)}°)`
          );
          setHits(h => [...h, ok]);
          if (ok) setStars(s => Math.min(3, s + 1));
          setTimeout(() => {
            walkAbortRef.current.abort = true;
            setWalkActive(false);
            setArrivedEnd(false);
            hideTrial();
          }, 800);
        }
      };

      if (params) {
        ctxRef.current = { renderer, scene, cam, setPhase };
        const runner = new window.TestRunner(9, params, ctxRef.current, {
          onComplete: (r) => onComplete && onComplete(r),
          setInstr: (txt) => setInstrText(txt),
        });
        runnerRef.current = runner;
        window.__themyndRunner = runner;
        ctxRef.current.runner = runner;
        return () => {
          cancelAnimationFrame(raf);
          renderer.dispose();
          runner.abort();
        };
      }
      return () => { cancelAnimationFrame(raf); renderer.dispose(); };
    }}
  >
    {/* Mode B during walk: small top indicator of current action (forward/turn) */}
    {mode === 'B' && walkActive && walkMsg && (
      <div style={{
        position:'absolute', top:24, left:'50%', transform:'translateX(-50%)',
        zIndex:14, pointerEvents:'none',
        padding:'.5rem 1.2rem',
        background:'rgba(30,40,30,.78)',
        border:'1px solid rgba(245,236,208,.35)',
        borderRadius:28,
        color:'#f5ecd0',
        fontFamily:'var(--serif)',
        fontSize:'.95rem', fontWeight:700, letterSpacing:'.08em',
      }}>{walkMsg}</div>
    )}
    {/* Mode B after arrival: big centered question prompt */}
    {mode === 'B' && arrivedEnd && currentTrial && (
      <div style={{
        position:'absolute', top:'18%', left:'50%', transform:'translateX(-50%)',
        zIndex:14, pointerEvents:'none',
        padding:'1.1rem 1.8rem',
        background:'rgba(245,236,208,.95)',
        border:'2px solid rgba(120,80,40,.6)',
        borderRadius:10,
        boxShadow:'0 6px 22px rgba(30,40,20,.35)',
        fontFamily:'var(--serif)', color:'var(--inkw)',
        textAlign:'center', maxWidth:560,
      }}>
        <div style={{fontSize:'.78rem', color:'var(--dim)', fontFamily:'var(--italic)', fontStyle:'italic', marginBottom:6}}>
          你已抵達「{currentTrial.nameE}」
        </div>
        <div style={{fontSize:'1.25rem', fontWeight:700, letterSpacing:'.1em', color:'#4a3420'}}>
          出發點「<span style={{color:'#c05570'}}>{currentTrial.nameS}</span>」在哪個方向？
        </div>
        <div style={{fontSize:'.76rem', color:'var(--dim)', marginTop:6, fontFamily:'var(--italic)', fontStyle:'italic'}}>
          點右下羅盤指出方向 · 0°=正前
        </div>
      </div>
    )}
    {/* Compass: Mode A whenever a trial is set; Mode B only after arrival */}
    {((mode === 'A' && currentTrial) || (mode === 'B' && arrivedEnd && currentTrial)) && (
      <SOTCompass
        mode={mode}
        trial={currentTrial}
        dialAngle={dialAngle}
        onSetDialAngle={setDialAngle}
        onSubmit={(angle) => runnerRef.current?.handleInput({type:'sot_angle', angle})}
      />
    )}
  </Scene>;
}

// ── Mode B · Mini-map showing start landmark, current position, and path so far.
//    Renders a parchment disc with all 7 objects as small dots; the start has a
//    pink ring; current position is a pulsing green dot with a heading arrow.
//    Destination is deliberately NOT highlighted (subject is supposed to remember).
function WalkMiniMap({ objects, startIdx, pos, heading, waypoints, legIdx }) {
  if (!pos || !objects?.length) return null;
  const size = 160, cx = size/2, cy = size/2;
  // World bounds: objects roughly ±2.5 in x, ±2 in y. Map to SVG viewport.
  const worldR = 3;
  const toSvg = (wx, wy) => [
    cx + (wx / worldR) * (size * 0.38),
    cy - (wy / worldR) * (size * 0.38),
  ];
  const [px, py] = toSvg(pos.x, pos.y);
  // Traced polyline = waypoints[0..legIdx] + current pos
  const tracePts = [];
  if (waypoints) {
    for (let i = 0; i <= legIdx; i++) {
      if (waypoints[i]) {
        const [x,y] = toSvg(waypoints[i].x, waypoints[i].y);
        tracePts.push(`${x},${y}`);
      }
    }
    tracePts.push(`${px},${py}`);
  }
  // Heading arrow: in bearing (0=north/up, + clockwise)
  const ax = px + Math.sin(heading * Math.PI / 180) * 14;
  const ay = py - Math.cos(heading * Math.PI / 180) * 14;

  return (
    <div style={{
      position:'absolute', top:16, left:16, zIndex:14,
      width: size + 20, padding:10,
      background:'rgba(245,236,208,.96)',
      borderRadius:10, border:'1px solid rgba(120,80,40,.6)',
      boxShadow:'0 4px 14px rgba(30,40,20,.25)',
      fontFamily:'var(--serif)', color:'var(--inkw)',
    }}>
      <div style={{fontSize:'.72rem', fontWeight:700, letterSpacing:'.1em', color:'#4a3420', textAlign:'center', marginBottom:4}}>
        地圖 · MAP
      </div>
      <svg width={size} height={size} style={{display:'block', margin:'0 auto'}}>
        {/* disc */}
        <circle cx={cx} cy={cy} r={size * 0.45} fill="#f0e4cc" stroke="#b8a478" strokeWidth="1.5" strokeDasharray="3 5"/>
        {/* N indicator */}
        <text x={cx} y={cy - size * 0.44} textAnchor="middle" fontSize="10" fill="#8a7a60" fontFamily="'Noto Serif TC', serif" fontWeight="700">北</text>
        {/* objects */}
        {objects.map((o, i) => {
          const [ox, oy] = toSvg(o.x, o.y);
          const isStart = i === startIdx;
          return (
            <g key={i}>
              {isStart && (
                <circle cx={ox} cy={oy} r="9" fill="none" stroke="#e089a8" strokeWidth="2"/>
              )}
              <circle cx={ox} cy={oy} r="4.5" fill={o.col} stroke="#2c2416" strokeWidth="1"/>
            </g>
          );
        })}
        {/* traced path */}
        {tracePts.length > 1 && (
          <polyline
            points={tracePts.join(' ')}
            fill="none"
            stroke="#4a7048"
            strokeWidth="2"
            strokeDasharray="4 3"
            strokeLinecap="round"
          />
        )}
        {/* current position + heading arrow */}
        <circle cx={px} cy={py} r="6" fill="#4a7048" stroke="#fff" strokeWidth="2"/>
        <line x1={px} y1={py} x2={ax} y2={ay} stroke="#3aa878" strokeWidth="2.5" strokeLinecap="round"/>
      </svg>
      {startIdx != null && objects[startIdx] && (
        <div style={{fontSize:'.68rem', color:'#8a7a60', textAlign:'center', marginTop:4, fontFamily:'var(--italic)', fontStyle:'italic'}}>
          起點 · <span style={{color:'#c05570', fontWeight:700}}>{objects[startIdx].label}</span>
        </div>
      )}
    </div>
  );
}

// ── Mode B · centred status card showing current walk action (forward / turn / arrive)
function WalkStatusCard({ msg }) {
  if (!msg) return null;
  return (
    <div style={{
      position:'absolute', top: '40%', left: '50%', transform: 'translate(-50%, -50%)',
      zIndex: 15, pointerEvents:'none',
      padding:'1rem 1.8rem',
      background:'rgba(30,40,30,.82)',
      border:'1.5px solid rgba(245,236,208,.5)',
      borderRadius:10,
      color:'#f5ecd0',
      fontFamily:'var(--serif)',
      fontSize:'1.25rem', fontWeight:700,
      letterSpacing:'.08em',
      textAlign:'center',
      boxShadow:'0 6px 22px rgba(30,40,20,.4)',
      minWidth:240,
    }}>{msg}</div>
  );
}

// Compass dial — same component for both A and B
function SOTCompass({ mode, trial, dialAngle, onSetDialAngle, onSubmit }) {
  const size = 200;
  const cx = size / 2, cy = size / 2, r = size * 0.42;
  const center = mode === 'A' ? '#4888c8' : '#3aa878';
  const facingLabel = mode === 'A' ? `面向 · ${trial?.nameF || ''} ▲` : '面朝來時路 ▲';
  const standingLabel = mode === 'A' ? `站於 · ${trial?.nameS || ''}` : '你現在於 · 終點';
  const promptLabel = mode === 'A' ? `指出 · ${trial?.nameT || ''}` : `指回 · ${trial?.nameS || ''}`;
  const handle = (ev) => {
    const el = ev.currentTarget;
    const rect = el.getBoundingClientRect();
    const x = ev.clientX - rect.left - cx;
    const y = ev.clientY - rect.top - cy;
    const ang = Math.atan2(x, -y) * 180 / Math.PI;
    onSetDialAngle(ang);
  };
  const handleClick = (ev) => {
    handle(ev);
    const el = ev.currentTarget;
    const rect = el.getBoundingClientRect();
    const x = ev.clientX - rect.left - cx;
    const y = ev.clientY - rect.top - cy;
    const ang = Math.atan2(x, -y) * 180 / Math.PI;
    onSubmit(ang);
  };
  const knobX = dialAngle != null ? cx + Math.sin(dialAngle * Math.PI/180) * r : null;
  const knobY = dialAngle != null ? cy - Math.cos(dialAngle * Math.PI/180) * r : null;
  return (
    <div style={{
      position:'absolute', right:24, bottom:96, zIndex:20,
      width: size + 24, padding:12,
      background:'rgba(245,238,216,.96)',
      borderRadius:12, border:'1px solid rgba(120,80,40,.55)',
      boxShadow:'0 4px 18px rgba(30,40,20,.3)',
      fontFamily:'var(--serif)', color:'var(--inkw)',
    }}>
      <div style={{fontSize:'.76rem', color:'#5a4a30', textAlign:'center', marginBottom:6, fontFamily:'var(--italic)', fontStyle:'italic'}}>
        {standingLabel}
      </div>
      <svg
        width={size} height={size}
        style={{display:'block', margin:'0 auto', cursor:'crosshair'}}
        onMouseMove={handle}
        onClick={handleClick}
      >
        <circle cx={cx} cy={cy} r={r} fill="#f5eed8" stroke="#b8a478" strokeWidth="2" strokeDasharray="5 7"/>
        {Array.from({length:12}).map((_,i)=>{
          const a = i*Math.PI/6;
          const x1 = cx + Math.sin(a) * (r - 8);
          const y1 = cy - Math.cos(a) * (r - 8);
          const x2 = cx + Math.sin(a) * r;
          const y2 = cy - Math.cos(a) * r;
          return <line key={i} x1={x1} y1={y1} x2={x2} y2={y2} stroke="#8a7a60" strokeWidth="1.5"/>;
        })}
        <text x={cx} y={cy - r - 6} textAnchor="middle" fontSize="11" fill={center} fontWeight="700">
          {facingLabel}
        </text>
        <text x={cx} y={cy + r + 16} textAnchor="middle" fontSize="11" fill="#8a7a60">背</text>
        <circle cx={cx} cy={cy} r="9" fill={center} stroke="#2c2416" strokeWidth="2"/>
        <text x={cx} y={cy + 1} textAnchor="middle" fontSize="10" fill="#fff" fontWeight="700" dominantBaseline="middle">你</text>
        <line x1={cx} y1={cy} x2={cx} y2={cy - r * 0.85} stroke={center} strokeWidth="2.5" strokeLinecap="round" opacity="0.6"/>
        {dialAngle != null && (
          <>
            <line x1={cx} y1={cy} x2={knobX} y2={knobY} stroke="#c84030" strokeWidth="3.5" strokeLinecap="round"/>
            <polygon
              points={`${knobX},${knobY}`}
              transform={`rotate(${dialAngle} ${cx} ${cy})`}
              fill="#c84030"
            />
            <circle cx={knobX} cy={knobY} r="9" fill="#c84030" stroke="#fff" strokeWidth="2"/>
            <text x={cx} y={cy + r + 32} textAnchor="middle" fontSize="11" fill="#2c2416" fontWeight="700">
              {Math.round(dialAngle)}°
            </text>
          </>
        )}
      </svg>
      <div style={{fontSize:'.72rem', color:center, textAlign:'center', marginTop:4, fontWeight:700}}>
        {promptLabel}
      </div>
      <div style={{fontSize:'.62rem', color:'#8a7a60', textAlign:'center', marginTop:2, fontStyle:'italic'}}>
        點圓盤確認方向 · 0°=正前
      </div>
    </div>
  );
}

/* ════════════════════════════════════════════════════
   11 · 星之三光 — ANT
═════════════════════════════════════════════════════ */
function Game_ANT({ params, onComplete, onAbort }) {
  const ctxRef = useRef(null);
  const runnerRef = useRef(null);
  const [trialIdx, setTrialIdx] = useState(-1);
  const [hits, setHits] = useState([]);
  const [starCount, setStarCount] = useState(2);
  const [instrText, setInstrText] = useState('中央箭頭方向？(上線索已亮)');
  return <Scene
    chapter="第十一章 · 星光驛站 · Attention Networks"
    title="星之三光 — ANT"
    subtitle="三顆星引導你的注意 · 警覺 · 定向 · 執行"
    tags={[{label:'CUE: SPATIAL',color:'#7050c8'}]}
    instr={instrText}
    reward={trialIdx >= 0 ? starCount : 2}
    trialDots={trialIdx >= 0 ? {total:10, cur:trialIdx%10, hits:hits.slice(-10)} : undefined}
    protocol={'<b>Protocol · Attention Network Test</b>  Fan et al. 2002 典範 · 4 線索條件 (無／中央／雙／空間) 平衡呈現 · cue 100ms + cue-target SOA 400ms · Alerting=No−Double · Orienting=Center−Spatial · Executive=Inc−Con'}
    metrics={['Alerting','Orienting','Executive Control','Overall RT']}
    init={(el)=>{
      const {renderer, scene, cam} = SK.mkScene(el, {z:8});
      SK.addBackdrop(scene, cam, (c,w,h)=>{
        SK.paintSky(c,w,h, ['#0a0e1c','#1c2540','#3a4878'], 0.03);
        SK.paintStars(c,w,h,{count:200});
      }, -26);

      // three guiding stars in triangle
      const stars = [
        {x:-2.8, y:1.8, col:'#aed0ff', label:'警覺', on:false},
        {x: 0,   y:2.2, col:'#f0c858', label:'定向', on:true},
        {x: 2.8, y:1.8, col:'#c898d8', label:'執行', on:false},
      ];
      stars.forEach(s=>{
        const tex = SK.mkTex(220, 260, (c,w,h)=>{
          c.clearRect(0,0,w,h);
          if(s.on){
            const gg=c.createRadialGradient(w/2,h*.4,0,w/2,h*.4,w*.6);
            gg.addColorStop(0,s.col+'ff');
            gg.addColorStop(.3,s.col+'77');
            gg.addColorStop(1,s.col+'00');
            c.fillStyle=gg; c.fillRect(0,0,w,h);
          }
          // star shape
          c.fillStyle=s.on?s.col:s.col+'55';
          c.beginPath();
          const cx=w/2, cy=h*.4, rO=42, rI=18;
          for(let i=0;i<10;i++){
            const ang=-Math.PI/2 + i*Math.PI/5;
            const r=i%2===0?rO:rI;
            const px=cx+Math.cos(ang)*r, py=cy+Math.sin(ang)*r;
            i===0?c.moveTo(px,py):c.lineTo(px,py);
          }
          c.closePath(); c.fill();
          if(s.on){
            c.strokeStyle='#fff';
            c.lineWidth=2;
            c.stroke();
          }
          // label
          c.font="600 20px 'Noto Serif TC', serif";
          c.fillStyle=s.on ? '#f5f0e4' : 'rgba(245,240,228,.5)';
          c.textAlign='center';
          c.fillText(s.label, w/2, h*.85);
        });
        const m = SK.mkPlane(1.3, 1.55, tex);
        m.position.set(s.x, s.y, 0);
        scene.add(m);
        s.mesh = m;
      });

      // 5-arrow flanker row (incongruent)
      const arrowMeshes = [];
      const pattern = ['L','L','R','L','L'];
      pattern.forEach((d,i)=>{
        const tex = SK.mkTex(140,100,(c,w,h)=>{
          c.clearRect(0,0,w,h);
          const isTarget=i===2;
          c.save();
          c.translate(w/2,h/2);
          if(d==='R')c.scale(-1,1);
          c.fillStyle=isTarget?'#f0c858':'#8090b8';
          c.beginPath();
          c.moveTo(-40,0); c.lineTo(20,-22); c.lineTo(20,-8); c.lineTo(40,-8);
          c.lineTo(40,8); c.lineTo(20,8); c.lineTo(20,22); c.closePath();
          c.fill();
          c.restore();
        });
        const m = SK.mkPlane(.9, .65, tex);
        m.position.set(-2 + i, -.4, 0);
        scene.add(m);
        arrowMeshes.push(m);
      });
      // spatial cue: mark above flanker
      const cueTex = SK.mkTex(120,120,(c,w,h)=>{
        c.clearRect(0,0,w,h);
        const gg=c.createRadialGradient(w/2,h/2,0,w/2,h/2,w*.4);
        gg.addColorStop(0,'rgba(240,200,120,.85)');
        gg.addColorStop(1,'transparent');
        c.fillStyle=gg; c.fillRect(0,0,w,h);
        c.fillStyle='#f0c858';
        c.beginPath(); c.arc(w/2,h/2, w*.14, 0, Math.PI*2); c.fill();
      });
      const cue = SK.mkPlane(.8,.8,cueTex);
      cue.position.set(0, .6, 0);
      scene.add(cue);

      let raf, t=0;
      const render=()=>{
        t+=16;
        stars.forEach((s,i)=>{
          s.mesh.position.y = s.y + Math.sin(t*.0015 + i)*.08;
          s.mesh.rotation.z = Math.sin(t*.001 + i)*.05;
        });
        cue.scale.setScalar(1+Math.sin(t*.005)*.1);
        renderer.render(scene, cam);
        raf = requestAnimationFrame(render);
      };
      render();

      // Helper to (re)paint a flanker arrow texture for a given direction.
      // Optional emoColor overrides the flanker tint (Emotional ANT).
      function makeFlankerArrowTex(dir, isTarget, emoColor) {
        return SK.mkTex(140, 100, (c, w, h) => {
          c.clearRect(0, 0, w, h);
          c.save();
          c.translate(w/2, h/2);
          if (dir === 'R') c.scale(-1, 1);
          // Target keeps gold; flankers get emotion tint if provided
          c.fillStyle = isTarget ? '#f0c858' : (emoColor || '#8090b8');
          c.beginPath();
          c.moveTo(-40, 0); c.lineTo(20, -22); c.lineTo(20, -8); c.lineTo(40, -8);
          c.lineTo(40, 8); c.lineTo(20, 8); c.lineTo(20, 22);
          c.closePath();
          c.fill();
          c.restore();
        });
      }

      function setFlankerRow(dir, congruent, emotion) {
        const d = dir === 'right' ? 'R' : 'L';
        const fk = congruent ? d : (d === 'L' ? 'R' : 'L');
        const pat = [fk, fk, d, fk, fk];
        // Emotional ANT: tint flanker arrows by emotion
        const emoColor = emotion === 'angry' ? '#c84030'
                       : emotion === 'happy' ? '#3aa858'
                       : null;
        arrowMeshes.forEach((m, i) => {
          const newTex = makeFlankerArrowTex(pat[i], i === 2, emoColor);
          m.material.map = newTex;
          m.material.needsUpdate = true;
          m.visible = true;
        });
      }

      const setPhase = (phase, trial) => {
        if (phase === 'fixation') {
          arrowMeshes.forEach(m => m.visible = false);
          cue.visible = false;
          // dim all guide stars
          stars.forEach(s => { s.mesh.scale.setScalar(1); });
        } else if (phase === 'cue') {
          if (trial.cue === 'center') {
            // single central cue (orienting baseline) — light center star only
            stars[1].mesh.scale.setScalar(1.4);
          } else if (trial.cue === 'double') {
            // double cue (alerting baseline) — light BOTH possible target stars
            stars[0].mesh.scale.setScalar(1.4);
            stars[2].mesh.scale.setScalar(1.4);
          } else if (trial.cue === 'spatial') {
            // spatial cue at target position — show cue dot + light matching star
            cue.visible = true;
            cue.position.y = trial.targetPos === 'up' ? 1.6 : -.8;
            const starIdx = trial.targetPos === 'up' ? 0 : 2;
            stars[starIdx].mesh.scale.setScalar(1.4);
          }
          // 'none' cue: nothing lights up
        } else if (phase === 'cue_off') {
          // Cue has been shown for cueDur; hide it but keep target hidden too.
          cue.visible = false;
          stars.forEach(s => s.mesh.scale.setScalar(1));
        } else if (phase === 'stimulus') {
          // clear cue, show flanker row (unless catch trial)
          cue.visible = false;
          stars.forEach(s => s.mesh.scale.setScalar(1));
          if (!trial.isCatch){
            setFlankerRow(trial.dir, trial.congruent, trial.emotion);
          } else {
            // Catch: hide all arrows; subject should NOT respond
            arrowMeshes.forEach(m => m.visible = false);
          }
          setTrialIdx(i => i + 1);
        } else if (phase === 'feedback') {
          arrowMeshes.forEach(m => m.visible = false);
          const ok = trial._correct;
          setHits(h => [...h, ok]);
          if (ok) setStarCount(s => Math.min(3, s + 1));
        }
      };

      if (params) {
        ctxRef.current = { renderer, scene, cam, stars, cue, arrowMeshes, setPhase };
        const runner = new window.TestRunner(10, params, ctxRef.current, {
          onComplete: (r) => onComplete && onComplete(r),
          setInstr: (txt) => setInstrText(txt),
        });
        runnerRef.current = runner;
        window.__themyndRunner = runner;
        const inputMode = params.input || 'arrows';
        const canvas = renderer.domElement;
        const onKey = e => {
          if (inputMode === 'click') return;
          runner.handleInput({type:'key', key:e.key});
        };
        const onClick = (ev) => {
          if (inputMode === 'arrows') return;
          const rect = canvas.getBoundingClientRect();
          const half = (ev.clientX - rect.left) < rect.width/2 ? 'ArrowLeft' : 'ArrowRight';
          runner.handleInput({type:'key', key: half});
        };
        document.addEventListener('keydown', onKey);
        canvas.addEventListener('click', onClick);
        return () => {
          cancelAnimationFrame(raf);
          renderer.dispose();
          document.removeEventListener('keydown', onKey);
          canvas.removeEventListener('click', onClick);
          runner.abort();
        };
      }
      return ()=>{ cancelAnimationFrame(raf); renderer.dispose(); };
    }}
  />;
}

/* ════════════════════════════════════════════════════
   12 · 商人寶匣 — Iowa Gambling Task
═════════════════════════════════════════════════════ */
function Game_IGT({ params, onComplete, onAbort }){
  const ctxRef = useRef(null);
  const runnerRef = useRef(null);
  const [instrText, setInstrText] = useState('選一個寶匣 — 長期才見真章');
  const [trialCount, setTrialCount] = useState(0);
  const [balance, setBalance] = useState(2000);
  const [stars, setStars] = useState(1);
  const [flashText, setFlashText] = useState(null); // null or {net, key}
  return (
  <>
  <Scene
    chapter="第十二章 · 神秘市集 · Decision Making"
    title="商人寶匣 — Iowa Gambling"
    subtitle="四個寶匣 · 有的誘人卻暗藏損失"
    tags={[
      {label: trialCount > 0 ? `第 ${trialCount} / ${params?.trials || 100}` : `第 0 / ${params?.trials || 100}`, color:'#4888c8'},
      {label: `金幣 ${balance.toLocaleString()}`, color:'#d4931a'},
    ]}
    instr={instrText}
    reward={trialCount > 0 ? stars : 1}
    protocol={(window.TASK_DOCS && window.TASK_DOCS[11])
      ? buildProtocolText(11, params)
      : '<b>Protocol · IGT</b>  Bechara 1994 / Chiu 2008 (Soochow) — 詳細說明載入中…'}
    metrics={['Net Score','Advantageous %','Late-block %','Final Balance']}
    init={(el)=>{
      const {renderer, scene, cam} = SK.mkScene(el, {z:8});
      SK.addBackdrop(scene, cam, (c,w,h)=>{
        SK.paintSky(c,w,h, ['#3a2848','#5a3860','#7a5078','#c86870'], 0.02);
        SK.paintStars(c,w,h,{count:100, color:'#f8e8c0'});
        // market tents / lanterns silhouette
        SK.paintHills(c,w,h,[
          {base:.78, color:'#2a1838', amp:22, freq:.011, alpha:.9},
        ]);
        // hanging lanterns
        for(let i=0;i<10;i++){
          const x=Math.random()*w, y=Math.random()*h*.4 + 30;
          const col = ['#f0c858','#c84878','#d4931a'][SK.randInt(0,2)];
          const gg=c.createRadialGradient(x,y,0,x,y,24);
          gg.addColorStop(0,col+'cc');
          gg.addColorStop(1,'transparent');
          c.fillStyle=gg; c.fillRect(x-24,y-24,48,48);
          c.strokeStyle='rgba(40,30,20,.4)';
          c.lineWidth=1;
          c.beginPath(); c.moveTo(x, 0); c.lineTo(x, y-6); c.stroke();
          c.fillStyle=col;
          c.beginPath(); c.arc(x,y, 8, 0, Math.PI*2); c.fill();
        }
      }, -22);

      // 4 treasure chests
      const chests = [
        {l:'A', col:'#c84030', x:-3.6},
        {l:'B', col:'#d4931a', x:-1.2},
        {l:'C', col:'#3aa858', x:1.2},
        {l:'D', col:'#4888c8', x:3.6},
      ];
      chests.forEach((ch,i)=>{
        const tex = SK.mkTex(260,340,(c,w,h)=>{
          c.clearRect(0,0,w,h);
          // soft glow
          const gg=c.createRadialGradient(w/2,h*.55,0,w/2,h*.55,w*.6);
          gg.addColorStop(0, ch.col+'55');
          gg.addColorStop(1,'transparent');
          c.fillStyle=gg; c.fillRect(0,0,w,h);
          // chest body
          c.fillStyle='#5a3820';
          c.fillRect(w*.15, h*.45, w*.7, h*.4);
          c.fillStyle=ch.col;
          c.fillRect(w*.15, h*.45, w*.7, 14);
          // lid (curved)
          c.fillStyle='#8a5830';
          c.beginPath();
          c.moveTo(w*.15, h*.48);
          c.quadraticCurveTo(w*.5, h*.22, w*.85, h*.48);
          c.lineTo(w*.85, h*.52);
          c.lineTo(w*.15, h*.52);
          c.closePath(); c.fill();
          // top band
          c.fillStyle=ch.col;
          c.beginPath();
          c.moveTo(w*.15, h*.48);
          c.quadraticCurveTo(w*.5, h*.22, w*.85, h*.48);
          c.lineTo(w*.85, h*.44);
          c.quadraticCurveTo(w*.5, h*.18, w*.15, h*.44);
          c.closePath(); c.fill();
          // lock
          c.fillStyle='#f0c858';
          c.fillRect(w*.47, h*.44, 16, 16);
          // letter label on front
          c.font="800 52px 'Noto Serif TC', serif";
          c.fillStyle='#f5f0e4';
          c.textAlign='center'; c.textBaseline='middle';
          c.fillText(ch.l, w/2, h*.65);
          // gold coins peeking from top
          for(let k=0;k<4;k++){
            c.fillStyle='#f0c858';
            c.beginPath(); c.arc(w*.3+k*w*.13, h*.32, 7, 0, Math.PI*2); c.fill();
            c.strokeStyle='#a07820';
            c.lineWidth=1; c.stroke();
          }
        });
        const m = SK.mkPlane(1.7, 2.2, tex);
        m.position.set(ch.x, -.6, 0);
        scene.add(m);
        ch.mesh = m;
        ch.phase = i*.5;
      });

      // wise merchant silhouette behind
      const mt = SK.mkTex(200,360,(c,w,h)=>{
        c.clearRect(0,0,w,h);
        // hood
        c.fillStyle='rgba(40,24,40,.9)';
        c.beginPath();
        c.ellipse(w/2, h*.22, 42, 48, 0, 0, Math.PI*2);
        c.fill();
        // body
        c.beginPath();
        c.moveTo(w*.15, h*.35);
        c.quadraticCurveTo(w/2, h*.25, w*.85, h*.35);
        c.lineTo(w*.95, h*.95);
        c.lineTo(w*.05, h*.95);
        c.closePath(); c.fill();
        // glow eyes
        c.fillStyle='#f0c858';
        c.beginPath(); c.arc(w*.42, h*.24, 3, 0, Math.PI*2); c.fill();
        c.beginPath(); c.arc(w*.58, h*.24, 3, 0, Math.PI*2); c.fill();
      });
      const merchant = SK.mkPlane(1.3, 2.3, mt);
      merchant.position.set(0, 1.8, -.2);
      scene.add(merchant);

      // floating gold
      const petals = SK.makePetals(scene, 14, '#f0c858');

      let raf, t=0;
      const render=()=>{
        t+=16;
        chests.forEach(ch=>{
          ch.mesh.position.y = -.6 + Math.sin(t*.0015 + ch.phase)*.06;
        });
        merchant.position.y = 1.8 + Math.sin(t*.001)*.05;
        petals.tick(t);
        renderer.render(scene, cam);
        raf = requestAnimationFrame(render);
      };
      render();

      if (params) {
        const rayCaster = new THREE.Raycaster();
        const chestMeshes = chests.map(ch => ch.mesh);
        ctxRef.current = {
          renderer, scene, cam,
          chestMeshes,
          _balance: 2000, _totalTrials: 0,
          _picks: {A:0, B:0, C:0, D:0}, _history: [],
          setPhase: () => {},
          onBalanceUpdate: (bal) => {
            setBalance(bal);
            if (ctxRef.current) ctxRef.current._balance = bal;
          },
          onFlash: (net) => {
            setFlashText({net, key: Date.now()});
            setTimeout(() => setFlashText(null), 700);
            setStars(s => net > 0 ? Math.min(3, s + 1) : Math.max(0, s - 1));
            if (ctxRef.current) setTrialCount(ctxRef.current._totalTrials);
          },
        };
        const runner = new window.TestRunner(11, params, ctxRef.current, {
          onComplete: (r) => onComplete && onComplete(r),
          setInstr: (txt) => setInstrText(txt),
        });
        runnerRef.current = runner;
        window.__themyndRunner = runner;

        const canvas = renderer.domElement;
        const onClick = (e) => {
          const rect = canvas.getBoundingClientRect();
          const mx = ((e.clientX - rect.left) / rect.width) * 2 - 1;
          const my = -((e.clientY - rect.top) / rect.height) * 2 + 1;
          rayCaster.setFromCamera({x: mx, y: my}, cam);
          const hits2 = rayCaster.intersectObjects(chestMeshes, false);
          if (hits2.length) runner.handleInput({type: 'raycast', object: hits2[0].object});
        };
        canvas.addEventListener('click', onClick);

        return () => {
          cancelAnimationFrame(raf);
          renderer.dispose();
          canvas.removeEventListener('click', onClick);
          runner.abort();
        };
      }
      return ()=>{ cancelAnimationFrame(raf); renderer.dispose(); };
    }}
  />
  {flashText && (
    <div style={{
      position:'absolute', top:'42%', left:'50%', transform:'translate(-50%,-50%)',
      zIndex:12, pointerEvents:'none',
      padding:'10px 22px',
      background: flashText.net >= 0 ? 'rgba(58,168,88,.85)' : 'rgba(192,85,112,.85)',
      color:'#fff', fontFamily:'var(--round)', fontWeight:800,
      fontSize:'1.4rem', borderRadius:4,
      boxShadow:'0 4px 18px rgba(30,40,20,.35)',
      animation: 'none',
    }} key={flashText.key}>
      {flashText.net >= 0 ? '+' : ''}{flashText.net}
    </div>
  )}
  </>
  );
}

/* ════════════════════════════════════════════════════
   ActiveGame — routes quest.id → Game_* component
═════════════════════════════════════════════════════ */
const GAME_MAP = {
  0: Game_NBack, 1: Game_Stroop, 2: Game_DigitSpan, 3: Game_WCST,
  4: Game_Trail, 5: Game_Corsi, 6: Game_PursuitRotor, 7: Game_Tower,
  8: Game_MentalRot, 9: Game_SOT, 10: Game_ANT, 11: Game_IGT,
};

function ActiveGame({ quest, params, onComplete, onAbort }) {
  const G = GAME_MAP[quest.id];
  const [paused, setPaused] = useState(false);
  const [restartKey, setRestartKey] = useState(0);

  const doPause = () => {
    if (!window.__themyndRunner) return;
    window.__themyndRunner.pause();
    setPaused(true);
  };
  const doResume = () => {
    window.__themyndRunner?.resume();
    setPaused(false);
  };
  const doRestart = () => {
    window.__themyndRunner?.abort();
    window.__themyndRunner = null;
    setPaused(false);
    setRestartKey(k => k + 1);
  };
  const doHome = () => {
    window.__themyndRunner?.abort();
    window.__themyndRunner = null;
    setPaused(false);
    onAbort && onAbort();
  };

  useEffect(() => {
    const onKey = (e) => {
      if (e.key === 'Escape') {
        e.preventDefault();
        if (paused) doResume(); else doPause();
      }
    };
    document.addEventListener('keydown', onKey);
    return () => document.removeEventListener('keydown', onKey);
  }, [paused]);

  if (!G) return <div style={{color:'#c05570',padding:'2rem'}}>未知測驗 id={quest.id}</div>;

  const amberBtn = {
    background:'linear-gradient(135deg,#d4931a,#f0c858)', border:'none',
    color:'#2c2416', fontFamily:'var(--serif)', fontWeight:700,
    fontSize:'.88rem', padding:'.55rem 0', cursor:'pointer',
    letterSpacing:'.08em', boxShadow:'0 2px 8px rgba(212,147,26,.4)',
    borderRadius:3,
  };
  const softBtn = {
    background:'var(--parch)', border:'1px solid var(--wcbdr)',
    color:'var(--inkw)', fontFamily:'var(--serif)', fontSize:'.84rem',
    padding:'.5rem 0', cursor:'pointer', letterSpacing:'.06em', borderRadius:3,
  };
  const textBtn = {
    background:'none', border:'1px solid transparent', color:'var(--dim)',
    fontFamily:'var(--italic)', fontStyle:'italic', fontSize:'.8rem',
    padding:'.4rem 0', cursor:'pointer', letterSpacing:'.04em',
  };

  return (
    <>
      <G key={restartKey} params={params} onComplete={onComplete} onAbort={doHome} />

      {/* pause button — top-right of screen */}
      <button
        onClick={doPause}
        aria-label="暫停"
        style={{
          position:'fixed', top:14, right:14, zIndex:60,
          width:36, height:36, display:'flex', alignItems:'center', justifyContent:'center',
          background:'rgba(245,240,228,.88)', border:'1px solid rgba(140,120,90,.35)',
          borderRadius:'50%', cursor:'pointer', backdropFilter:'blur(4px)',
          color:'var(--inkw)', fontSize:'.95rem', lineHeight:1,
          boxShadow:'0 2px 8px rgba(30,40,20,.18)',
        }}
      >⏸</button>

      {paused && (
        <div
          onClick={doResume}
          style={{
            position:'fixed', inset:0, zIndex:120,
            background:'rgba(30,40,20,.72)', backdropFilter:'blur(8px)',
            display:'flex', alignItems:'center', justifyContent:'center',
          }}
        >
          <div
            onClick={e => e.stopPropagation()}
            style={{
              width:320,
              background:'var(--cream)', borderTop:'3px solid var(--amber)',
              borderRadius:4, padding:'1.7rem 1.8rem 1.3rem',
              boxShadow:'0 8px 40px rgba(30,40,20,.35)',
              fontFamily:'var(--serif)', color:'var(--inkw)',
              display:'flex', flexDirection:'column', gap:'.75rem',
            }}
          >
            <div style={{textAlign:'center', fontSize:'1.15rem', fontWeight:700, letterSpacing:'.12em'}}>暫停</div>
            <div style={{
              textAlign:'center', fontSize:'.78rem', color:'var(--dim)',
              fontFamily:'var(--italic)', fontStyle:'italic', marginBottom:'.4rem',
            }}>{quest?.name || '—'}</div>
            <button style={amberBtn} onClick={doResume}>繼續測驗</button>
            <button style={softBtn} onClick={doRestart}>重新開始</button>
            <button style={textBtn} onClick={doHome}>跳回主選單</button>
            <div style={{
              textAlign:'center', fontSize:'.68rem', color:'var(--dim)',
              letterSpacing:'.1em', marginTop:'.3rem',
            }}>ESC 繼續 / 暫停</div>
          </div>
        </div>
      )}
    </>
  );
}

window.Games = {
  Game_NBack, Game_Stroop, Game_DigitSpan, Game_WCST, Game_Trail, Game_Corsi,
  Game_PursuitRotor, Game_Tower, Game_MentalRot, Game_SOT, Game_ANT, Game_IGT,
  ActiveGame,
};
