/* ═══════════════════════════════════════════════════════
   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;

/* ════════════ 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={'<b>Protocol · 2-Back</b>  刺激池 8 符號，30% 目標率，刺激時長 500ms / ISI 2000ms · n=2 單刺激典範，無歷史顯示，純憑工作記憶維持'}
    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]);
        }
      };

      if (params) {
        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={'<b>Protocol · Stroop Color-Word</b>  顏色詞「紅／藍／綠／黃」以 4 色墨水呈現 · 50% 不一致 (干擾) · 命名墨水色 · 按鍵 R/B/G/Y · Stroop 效應量 = 不一致 RT − 一致 RT'}
    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();
        }
        // main character
        bigChCtx2d.font = "800 360px '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') {
          // `trial.word` is a 2-char label like '紅色'; render just the first char for the calligraphy
          const wordChar = (trial.word || '')[0] || trial.word;
          drawBigChar(wordChar, 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={'<b>Protocol · Backward Digit Span</b>  數字 1–9 逐一播放 (1000ms/stim) · 起始長度 3 · 兩次失敗停止 · 聲音+視覺雙通道 · 逆向需工作記憶操控'}
        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,
              _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: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>
            ))}
            <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={'<b>Protocol · Wisconsin Card Sort</b>  目標花床 4 張 (顏色／形狀／數量 維度) · 手牌 128 張 · 連對 10 次後規則無預告切換 · 堅持錯誤反映前額葉彈性'}
    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
      const targets = [
        {count:1, shape:'circle', col:'#c84030'},
        {count:2, shape:'triangle', col:'#3060b8'},
        {count:3, shape:'square', col:'#3aa858'},
        {count:4, shape:'star', col:'#d4a218'},
      ];
      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];
          pos.forEach(([px,py])=>drawShape(c, t.shape, t.col, w*px, h*py, 24));
          // 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 — each entry maps to a Ghibli target flower-bed
      // n=1-4 (count), color=0-3 (red, green, blue, yellow), shape=0-3 (circle, triangle, square, star)
      const targetDefs = [
        {n:1, color:0, shape:0},  // 1 red circle
        {n:2, color:2, shape:1},  // 2 blue triangles
        {n:3, color:1, shape:2},  // 3 green squares
        {n:4, color:3, shape:3},  // 4 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
        ps.forEach(([px,py]) => drawShape(hc, shapes[def.shape], cols[def.color], W*px, H*py, 28));
        // 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,
          _maxCat: +params.categories || 6,
          _maxErr: +params.maxErrors || 64,
          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={'<b>Protocol · Trail Making A/B</b>  Part A 連接 1→25；Part B 交替數字—漢字 1→甲→2→乙... · 計時為主要指標 · B−A 差值獨立反映集合切換'}
    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,
          _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);
            });

            labels.forEach((lbl, i) => {
              const {x, y} = positions[i];
              // Numeric labels are digits; non-numeric are Chinese (Part B)
              const isNumeric = /^\d/.test(lbl);
              const 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(); };
    }}
  />;
}

/* ════════════════════════════════════════════════════
   06 · 森林精靈 — Go/No-Go
═════════════════════════════════════════════════════ */
function Game_GoNoGo({ 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="第六章 · 精靈之森 · Response Inhibition"
    title="森林精靈 — Go / No-Go"
    subtitle="綠色精靈出現按空白鍵（或點擊精靈）· 紅色精靈請勿按"
    tags={[{label:'GO 80%',color:'#3aa858'}, {label:'NOGO 20%',color:'#c05570'}]}
    instr={instrText}
    reward={trialIdx >= 0 ? stars : 3}
    trialDots={trialIdx >= 0 ? {total:10, cur:trialIdx%10, hits:hits.slice(-10)} : undefined}
    protocol={'<b>Protocol · Go/No-Go</b>  Go 刺激 80% (綠) / No-Go 20% (紅) · 刺激 500ms / ISI 1200ms · 建立反應優勢後測抑制 · 委任錯誤 (Commission) 為主要指標'}
    metrics={['Commission Err','Omission Err','Go RT ± SD','d′ Signal Detection']}
    init={(el)=>{
      const {renderer, scene, cam} = SK.mkScene(el, {z:7});
      SK.addBackdrop(scene, cam, (c,w,h)=>{
        SK.paintSky(c,w,h, ['#c5d4e4','#aacfaa','#7ab88a','#3a6848'], 0.02);
        SK.paintHills(c,w,h,[
          {base:.62, color:'#4c8c5e', amp:24, freq:.008, alpha:.85},
          {base:.78, color:'#2e5238', amp:18, freq:.013, alpha:.95},
        ]);
        SK.paintTrees(c,w,h,{count:14, yMin:.45, yMax:.72, colors:['#2e5238','#3a6848','#4c8c5e']});
      }, -22);

      // central stump with sprite
      function makeSprite(col, happy=true){
        return SK.mkTex(320,320,(c,w,h)=>{
          c.clearRect(0,0,w,h);
          // big glow
          const gg=c.createRadialGradient(w/2,h*.55,0,w/2,h*.55,w*.55);
          gg.addColorStop(0,col+'bb');
          gg.addColorStop(.4,col+'55');
          gg.addColorStop(1,col+'00');
          c.fillStyle=gg; c.fillRect(0,0,w,h);
          // body blob
          c.fillStyle=col;
          c.beginPath();
          c.ellipse(w/2, h*.58, 80, 95, 0, 0, Math.PI*2);
          c.fill();
          // little leaf ears
          c.fillStyle = '#3a6848';
          c.beginPath();
          c.ellipse(w*.38, h*.32, 16, 28, -.4, 0, Math.PI*2);
          c.ellipse(w*.62, h*.32, 16, 28, .4, 0, Math.PI*2);
          c.fill();
          // big eyes
          c.fillStyle='#1a2030';
          c.beginPath(); c.arc(w*.42, h*.55, 8, 0, Math.PI*2); c.fill();
          c.beginPath(); c.arc(w*.58, h*.55, 8, 0, Math.PI*2); c.fill();
          // eye highlights
          c.fillStyle='#fff';
          c.beginPath(); c.arc(w*.44, h*.53, 2.5, 0, Math.PI*2); c.fill();
          c.beginPath(); c.arc(w*.60, h*.53, 2.5, 0, Math.PI*2); c.fill();
          // mouth
          c.strokeStyle='#1a2030'; c.lineWidth=3; c.lineCap='round';
          c.beginPath();
          if(happy){
            c.arc(w*.5, h*.66, 10, 0.1, Math.PI-.1);
          } else {
            c.moveTo(w*.45, h*.69); c.lineTo(w*.55, h*.69);
          }
          c.stroke();
        });
      }
      const tex = makeSprite('#aed66e', true);
      const sprite = SK.mkPlane(2.5, 2.5, tex);
      sprite.position.set(0, .3, 0);
      scene.add(sprite);

      // stump below
      const stumpTex = SK.mkTex(400,160,(c,w,h)=>{
        c.clearRect(0,0,w,h);
        c.fillStyle='#5a3a20';
        c.beginPath();
        c.ellipse(w/2, h*.45, w*.35, h*.35, 0, 0, Math.PI*2);
        c.fill();
        c.fillStyle='#8a6a40';
        c.beginPath();
        c.ellipse(w/2, h*.35, w*.32, h*.12, 0, 0, Math.PI*2);
        c.fill();
        // rings
        c.strokeStyle='#5a3a20';
        c.lineWidth=1.5;
        for(let r=8;r<w*.3;r+=8){
          c.beginPath(); c.ellipse(w/2,h*.35, r, r*.35, 0, 0, Math.PI*2); c.stroke();
        }
      });
      const stump = SK.mkPlane(3, 1.2, stumpTex);
      stump.position.set(0, -1.3, 0);
      scene.add(stump);

      // petals
      const petals = SK.makePetals(scene, 16, '#f8e8a0');
      const ff = SK.makeFireflies(scene, 20, {x:6, y:2.5, z:1.5});

      let raf, t=0;
      const render = ()=>{
        t+=16;
        sprite.position.y = .3 + Math.sin(t*.002)*.1;
        sprite.rotation.z = Math.sin(t*.0015)*.04;
        petals.tick(t); ff.tick(t);
        renderer.render(scene, cam);
        raf = requestAnimationFrame(render);
      };
      render();

      const setPhase = (phase, trial) => {
        if (phase === 'fixation') {
          sprite.visible = false;
          setInstrText('準備…');
        } else if (phase === 'stimulus') {
          const newTex = makeSprite(trial.isGo ? '#aed66e' : '#e08080', trial.isGo);
          sprite.material.map = newTex;
          sprite.material.needsUpdate = true;
          sprite.material.color.set(0xffffff);
          sprite.visible = true;
          setTrialIdx(i => i + 1);
          setInstrText(trial.isGo ? '🟢 Go — 按【空白鍵】或點精靈' : '🔴 No-Go — 請勿反應');
        } else if (phase === 'feedback') {
          const ok = trial._correct;
          // Clear visual: green flash for correct, red flash for wrong, sprite hides immediately after
          sprite.material.color.set(ok ? 0x88ff88 : 0xff6666);
          setTimeout(() => {
            if (sprite.material) sprite.material.color.set(0xffffff);
          }, 260);
          setInstrText(ok ? '✓ 反應正確' : (trial.isGo ? '✗ 太慢了' : '✗ 請忍住！'));
          setHits(h => [...h, ok && trial.isGo && trial.responded]);
          if (ok) setStars(s => Math.min(3, s + 1));
        }
      };

      if (params) {
        const rayCaster = new THREE.Raycaster();
        ctxRef.current = { renderer, scene, cam, sprite, setPhase };
        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 inputMode = params.input || 'both';
        const onKey = e => {
          if (inputMode === 'mouse') return;
          if (e.key === ' ') e.preventDefault();
          runner.handleInput({type:'key', key:e.key});
        };
        const canvas = renderer.domElement;
        const onClick = (ev) => {
          if (inputMode === 'keyboard') return;
          const rect = canvas.getBoundingClientRect();
          const mx = ((ev.clientX - rect.left) / rect.width) * 2 - 1;
          const my = -((ev.clientY - rect.top) / rect.height) * 2 + 1;
          rayCaster.setFromCamera({x: mx, y: my}, cam);
          const hits2 = rayCaster.intersectObjects([sprite], false);
          if (hits2.length) runner.handleInput({type: 'key', key: ' '});
        };
        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(); };
    }}
  />;
}

/* ════════════════════════════════════════════════════
   07 · 候鳥箭矢 — Flanker
═════════════════════════════════════════════════════ */
function Game_Flanker({ 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('中央鳥飛向 ← 還是 →？');
  return <Scene
    chapter="第七章 · 天際候鳥 · Selective Attention"
    title="候鳥箭矢 — Flanker"
    subtitle="只看中央那隻鳥的方向"
    tags={[{label:'不一致',color:'#7050c8'}]}
    instr={instrText}
    reward={trialIdx >= 0 ? stars : 2}
    trialDots={trialIdx >= 0 ? {total:10, cur:trialIdx%10, hits:hits.slice(-10)} : undefined}
    protocol={'<b>Protocol · Eriksen Flanker</b>  5 隻水平排列 · 中央目標 · 一致／不一致比例 50:50 · ISI 1500ms · 效應量 = 不一致 RT − 一致 RT · 反映 pre-SMA 衝突監控'}
    metrics={['Conflict Effect (ms)','Congruent RT','Incongruent RT','Accuracy']}
    init={(el)=>{
      const {renderer, scene, cam} = SK.mkScene(el, {z:8});
      SK.addBackdrop(scene, cam, (c,w,h)=>{
        SK.paintSky(c,w,h, ['#eadbb8','#f0c898','#e89080','#c87098','#6878a8'], 0.02);
        // sun disc
        const sg=c.createRadialGradient(w*.5,h*.45,0,w*.5,h*.45,160);
        sg.addColorStop(0,'rgba(255,220,150,.9)');
        sg.addColorStop(.6,'rgba(255,180,100,.3)');
        sg.addColorStop(1,'transparent');
        c.fillStyle=sg; c.fillRect(0,0,w,h);
        c.fillStyle='#f8e0a0';
        c.beginPath(); c.arc(w*.5,h*.45,70,0,Math.PI*2); c.fill();
        SK.paintClouds(c,w,h,{count:3, color:'#f8d8b0'});
        SK.paintHills(c,w,h,[
          {base:.82, color:'#4a4060', amp:20, freq:.01, alpha:.85},
          {base:.92, color:'#2a2540', amp:14, freq:.015, alpha:1},
        ]);
      }, -22);

      // 5 birds — flanker array: < < > < <  (incongruent)
      const pattern = ['L','L','R','L','L'];
      const birds = [];
      pattern.forEach((dir,i)=>{
        const tex = SK.mkTex(200,140,(c,w,h)=>{
          c.clearRect(0,0,w,h);
          const isTarget = i===2;
          const col = isTarget ? '#1a2030' : '#6a5070';
          // glow if target
          if(isTarget){
            const gg=c.createRadialGradient(w/2,h/2,0,w/2,h/2,w*.5);
            gg.addColorStop(0,'rgba(240,200,120,.5)');
            gg.addColorStop(1,'transparent');
            c.fillStyle=gg; c.fillRect(0,0,w,h);
          }
          c.save();
          c.translate(w/2, h/2);
          if(dir==='R') c.scale(-1,1);
          c.fillStyle=col;
          c.beginPath();
          c.moveTo(-60, 0);
          c.quadraticCurveTo(-30, -30, 0, -8);
          c.quadraticCurveTo(30, -30, 60, 0);
          c.quadraticCurveTo(30, -5, 0, 3);
          c.quadraticCurveTo(-30, -5, -60, 0);
          c.closePath(); c.fill();
          // head dot
          c.beginPath(); c.arc(-45, -6, 5, 0, Math.PI*2); c.fill();
          c.restore();
        });
        const m = SK.mkPlane(1.5, 1.05, tex);
        m.position.set(-3.2 + i*1.6, .2, 0);
        scene.add(m);
        birds.push({m, phase:i*.3});
      });

      let raf, t=0;
      const render=()=>{
        t+=16;
        birds.forEach((b,i)=>{
          b.m.position.y = .2 + Math.sin(t*.002 + b.phase)*.06;
          if(i===2) b.m.scale.setScalar(1 + Math.sin(t*.005)*.04);
        });
        renderer.render(scene, cam);
        raf = requestAnimationFrame(render);
      };
      render();

      // Helper to redraw a bird's canvas texture with given direction
      function makeBirdTex(dir, isTarget) {
        return SK.mkTex(200,140,(c,w,h) => {
          c.clearRect(0,0,w,h);
          if (isTarget) {
            const gg = c.createRadialGradient(w/2,h/2,0,w/2,h/2,w*.5);
            gg.addColorStop(0,'rgba(240,200,120,.5)');
            gg.addColorStop(1,'transparent');
            c.fillStyle=gg; c.fillRect(0,0,w,h);
          }
          c.save();
          c.translate(w/2, h/2);
          if (dir === 'R') c.scale(-1,1);
          c.fillStyle = isTarget ? '#1a2030' : '#6a5070';
          c.beginPath();
          c.moveTo(-60, 0);
          c.quadraticCurveTo(-30, -30, 0, -8);
          c.quadraticCurveTo(30, -30, 60, 0);
          c.quadraticCurveTo(30, -5, 0, 3);
          c.quadraticCurveTo(-30, -5, -60, 0);
          c.closePath(); c.fill();
          c.beginPath(); c.arc(-45, -6, 5, 0, Math.PI*2); c.fill();
          c.restore();
        });
      }

      function setBirdRow(pat) {
        pat.forEach((dir, i) => {
          const newTex = makeBirdTex(dir, i === 2);
          birds[i].m.material.map = newTex;
          birds[i].m.material.needsUpdate = true;
        });
      }

      const setPhase = (phase, trial) => {
        if (phase === 'fixation') {
          birds.forEach(b => b.m.visible = false);
        } else if (phase === 'stimulus') {
          birds.forEach(b => b.m.visible = true);
          const d = trial.dir === 'right' ? 'R' : 'L';
          const flankerDir = trial.congruent ? d : (d === 'L' ? 'R' : 'L');
          const pat = [flankerDir, flankerDir, d, flankerDir, flankerDir];
          setBirdRow(pat);
          setTrialIdx(i => i + 1);
        } else if (phase === 'feedback') {
          const ok = trial._correct;
          birds[2].m.material.color.set(ok ? 0xaaffcc : 0xff9999);
          setTimeout(() => { if (birds[2].m.material) birds[2].m.material.color.set(0xffffff); }, 300);
          setHits(h => [...h, ok]);
          if (ok) setStars(s => Math.min(3, s+1));
        }
      };

      if (params) {
        ctxRef.current = { renderer, scene, cam, birds, setPhase };
        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 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(); };
    }}
  />;
}

/* ════════════════════════════════════════════════════
   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={'<b>Protocol · Tower of London</b>  三柱三色珠 · 給定起始與目標排列 · 要求最少步數內完成 · 20 題由易至難 · 計畫潛時＝第一步前延遲'}
    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
          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={'<b>Protocol · Shepard-Metzler 3D</b>  呈現兩個不同角度的 3D 塊體 · 判斷是否為同一物件 (50% 鏡像) · 角度差 0°/60°/120°/180° · RT 與角度呈線性'}
    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);

      // Build a Shepard-style L tetris
      function buildShape(){
        const g = new THREE.Group();
        const blocks = [[0,0,0],[1,0,0],[2,0,0],[0,1,0],[0,2,0]];
        const mat = new THREE.MeshPhongMaterial({color:0xaed8e8, 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);
          box.position.set(x-.5, y-.5, z);
          // edges
          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;
      }
      const sA = buildShape();
      sA.position.set(-2.5, -.3, 0);
      scene.add(sA);
      const sB = buildShape();
      sB.position.set(2.5, -.3, 0);
      sB.rotation.y = Math.PI * .7;
      sB.rotation.x = .3;
      scene.add(sB);

      // 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);

      let raf, t=0;
      const render=()=>{
        t+=16;
        sA.rotation.y += .006;
        sA.rotation.x = Math.sin(t*.0008)*.15;
        sB.rotation.y += .006;
        sB.rotation.z = Math.sin(t*.001)*.1;
        renderer.render(scene, cam);
        raf = requestAnimationFrame(render);
      };
      render();

      // Track current sB (may be replaced each trial)
      let currentSB = sB;
      let testModeActive = false;

      function rebuildShapeForTrial(isMirror) {
        const g = new THREE.Group();
        const mat = new THREE.MeshPhongMaterial({
          color: 0xe0873a, shininess: 80, specular: 0x6898c8,
          transparent: true, opacity: .85
        });
        const blocks = [[0,0,0],[1,0,0],[2,0,0],[0,1,0],[0,2,0]];
        blocks.forEach(([x,y,z]) => {
          const box = new THREE.Mesh(new THREE.BoxGeometry(.9,.9,.9), mat.clone());
          box.position.set(isMirror ? -(x-.5) : (x-.5), y-.5, z);
          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;
      }

      const setPhase = (phase, trial) => {
        if (phase === 'fixation') {
          sA.visible = false;
          if (currentSB) currentSB.visible = false;
        } else if (phase === 'stimulus') {
          testModeActive = true;
          sA.visible = true;
          // Remove old sB and build a new one for this trial
          if (currentSB) scene.remove(currentSB);
          currentSB = rebuildShapeForTrial(trial.isMirror);
          currentSB.position.set(2.5, -.3, 0);
          currentSB.rotation.y = trial.angle * Math.PI / 180;
          scene.add(currentSB);
          // Store ref in ctx so feedback coloring can access it
          if (ctxRef.current) ctxRef.current.sB = currentSB;
          setTrialIdx(i => i + 1);
        } else if (phase === 'feedback') {
          const ok = trial._correct;
          const color = ok ? 0xaaffcc : 0xff9999;
          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) {
        ctxRef.current = { renderer, scene, cam, sA, sB: currentSB, setPhase };
        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});
          }
        };
        document.addEventListener('keydown', onKey);
        return () => {
          cancelAnimationFrame(raf);
          renderer.dispose();
          document.removeEventListener('keydown', onKey);
          runner.abort();
        };
      }
      return ()=>{ cancelAnimationFrame(raf); renderer.dispose(); };
    }}
  />;
}

/* ════════════════════════════════════════════════════
   10 · 雷霆止息 — Stop-Signal
═════════════════════════════════════════════════════ */
function Game_StopSignal({ 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('↑ 箭頭指向：奔跑！');
  return <Scene
    chapter="第十章 · 雨雲驛道 · Response Inhibition"
    title="雷霆止息 — Stop-Signal"
    subtitle="看見箭頭就跑 · 聽見雷鳴立刻停"
    tags={[{label:'STOP SIGNAL',color:'#c05570'},{label:'SSD 250ms',color:'#8a7a60'}]}
    instr={instrText}
    reward={trialIdx >= 0 ? stars : 2}
    trialDots={trialIdx >= 0 ? {total:10, cur:trialIdx%10, hits:hits.slice(-10)} : undefined}
    protocol={'<b>Protocol · Stop-Signal Task</b>  Go 方向判別 · 25% 試次加入 Stop 訊號 (雷聲) · 階梯追蹤 SSD · 估算 SSRT = Go RT 中位數 − 平均 SSD · 比 Go/No-Go 更精確量化抑制'}
    metrics={['SSRT (ms)','Stop Fail %','Go RT ± SD','SSD Track']}
    init={(el)=>{
      const {renderer, scene, cam} = SK.mkScene(el, {z:7});
      SK.addBackdrop(scene, cam, (c,w,h)=>{
        SK.paintSky(c,w,h, ['#4a5268','#6a7288','#9098a8','#b8b0a8'], 0.03);
        // rain streaks
        c.strokeStyle='rgba(200,210,230,.25)';
        c.lineWidth=1;
        for(let i=0;i<80;i++){
          const x = Math.random()*w, y = Math.random()*h*.8;
          c.beginPath(); c.moveTo(x,y); c.lineTo(x+3, y+20); c.stroke();
        }
        SK.paintHills(c,w,h,[
          {base:.82, color:'#2a3048', amp:18, freq:.01, alpha:.9},
        ]);
      }, -22);

      // big arrow
      const arrowTex = SK.mkTex(400,400,(c,w,h)=>{
        c.clearRect(0,0,w,h);
        const gg=c.createRadialGradient(w/2,h/2,0,w/2,h/2,w*.5);
        gg.addColorStop(0,'rgba(240,200,120,.6)');
        gg.addColorStop(1,'transparent');
        c.fillStyle=gg; c.fillRect(0,0,w,h);
        c.save();
        c.translate(w/2, h/2);
        c.rotate(-Math.PI/2);
        c.fillStyle='#f0d898';
        c.beginPath();
        c.moveTo(-90, -30); c.lineTo(40, -30); c.lineTo(40, -70);
        c.lineTo(120, 0); c.lineTo(40, 70); c.lineTo(40, 30);
        c.lineTo(-90, 30); c.closePath(); c.fill();
        c.strokeStyle='#2c2416'; c.lineWidth=5;
        c.stroke();
        c.restore();
      });
      const arrow = SK.mkPlane(3, 3, arrowTex);
      scene.add(arrow);

      // lightning bolt (STOP signal) flashes
      const lightTex = SK.mkTex(300,400,(c,w,h)=>{
        c.clearRect(0,0,w,h);
        const gg=c.createRadialGradient(w/2,h/2,0,w/2,h/2,w*.6);
        gg.addColorStop(0,'rgba(255,255,255,.7)');
        gg.addColorStop(.4,'rgba(240,200,120,.3)');
        gg.addColorStop(1,'transparent');
        c.fillStyle=gg; c.fillRect(0,0,w,h);
        c.fillStyle='#fff8d0';
        c.strokeStyle='#c8602a';
        c.lineWidth=3;
        c.beginPath();
        c.moveTo(w*.55, 30);
        c.lineTo(w*.25, h*.5);
        c.lineTo(w*.45, h*.55);
        c.lineTo(w*.2, h-20);
        c.lineTo(w*.6, h*.55);
        c.lineTo(w*.4, h*.5);
        c.lineTo(w*.75, 30);
        c.closePath();
        c.fill(); c.stroke();
      });
      const lightning = SK.mkPlane(2.2, 2.9, lightTex);
      lightning.position.set(1.8, .4, .1);
      scene.add(lightning);

      let raf, t=0;
      const render=()=>{
        t+=16;
        arrow.rotation.z = Math.sin(t*.003)*.04;
        arrow.scale.setScalar(1 + Math.sin(t*.005)*.03);
        // flash effect
        lightning.material.opacity = .3 + (Math.sin(t*.008)>0 ? Math.sin(t*.02)*.5+.5 : .1);
        lightning.material.transparent = true;
        renderer.render(scene, cam);
        raf = requestAnimationFrame(render);
      };
      render();

      // Mutable arrow canvas for direction changes
      const arrowCvs = arrowTex.image;
      const arrowCtx2d = arrowCvs.getContext('2d');
      const W0 = arrowCvs.width, H0 = arrowCvs.height;

      function drawArrowDir(dir) {
        arrowCtx2d.clearRect(0, 0, W0, H0);
        const gg = arrowCtx2d.createRadialGradient(W0/2, H0/2, 0, W0/2, H0/2, W0*.5);
        gg.addColorStop(0, 'rgba(240,200,120,.6)');
        gg.addColorStop(1, 'transparent');
        arrowCtx2d.fillStyle = gg;
        arrowCtx2d.fillRect(0, 0, W0, H0);
        arrowCtx2d.save();
        arrowCtx2d.translate(W0/2, H0/2);
        // default shape points right (+x); rotate 180 if dir='left'
        if (dir === 'left') arrowCtx2d.rotate(Math.PI);
        arrowCtx2d.fillStyle = '#f0d898';
        arrowCtx2d.beginPath();
        arrowCtx2d.moveTo(-90, -30);
        arrowCtx2d.lineTo(40, -30);
        arrowCtx2d.lineTo(40, -70);
        arrowCtx2d.lineTo(120, 0);
        arrowCtx2d.lineTo(40, 70);
        arrowCtx2d.lineTo(40, 30);
        arrowCtx2d.lineTo(-90, 30);
        arrowCtx2d.closePath();
        arrowCtx2d.fill();
        arrowCtx2d.strokeStyle = '#2c2416';
        arrowCtx2d.lineWidth = 5;
        arrowCtx2d.stroke();
        arrowCtx2d.restore();
        arrowTex.needsUpdate = true;
      }

      const setPhase = (phase, trial) => {
        if (phase === 'fixation') {
          arrow.visible = false;
          lightning.visible = false;
          if (ctxRef.current && ctxRef.current._stopTimer) {
            clearTimeout(ctxRef.current._stopTimer);
            ctxRef.current._stopTimer = null;
          }
        } else if (phase === 'stimulus') {
          drawArrowDir(trial.dir);
          arrow.visible = true;
          lightning.visible = false;
          setTrialIdx(i => i + 1);
          if (trial.hasStop && ctxRef.current) {
            trial._ssd = ctxRef.current._ssd;
            ctxRef.current._stopTimer = setTimeout(() => {
              lightning.visible = true;
            }, trial._ssd);
          }
        } else if (phase === 'feedback') {
          if (ctxRef.current && ctxRef.current._stopTimer) {
            clearTimeout(ctxRef.current._stopTimer);
            ctxRef.current._stopTimer = null;
          }
          arrow.visible = false;
          lightning.visible = false;
          // SSD tracking: if stop trial, adjust SSD based on response
          if (trial.hasStop && ctxRef.current) {
            ctxRef.current._ssd = trial.responded
              ? Math.max(50, ctxRef.current._ssd - 50)
              : Math.min(800, ctxRef.current._ssd + 50);
          }
          const ok = trial._correct;
          setHits(h => [...h, ok]);
          if (ok) setStars(s => Math.min(3, s+1));
        }
      };

      if (params) {
        ctxRef.current = { renderer, scene, cam, arrow, lightning, setPhase, _stopTimer: null };
        const runner = new window.TestRunner(9, 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 () => {
          canvas.removeEventListener('click', onClick);
          cancelAnimationFrame(raf);
          renderer.dispose();
          document.removeEventListener('keydown', onKey);
          runner.abort();
        };
      }

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

/* ════════════════════════════════════════════════════
   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>  Posner 線索 × Flanker 整合典範 · 4 線索條件 (無／中央／雙／空間) × 3 Flanker 條件 · 288 試次 · 解離警覺／定向／執行三網路'}
    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
      function makeFlankerArrowTex(dir, isTarget) {
        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);
          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();
        });
      }

      function setFlankerRow(dir, congruent) {
        const d = dir === 'right' ? 'R' : 'L';
        const fk = congruent ? d : (d === 'L' ? 'R' : 'L');
        const pat = [fk, fk, d, fk, fk];
        arrowMeshes.forEach((m, i) => {
          const newTex = makeFlankerArrowTex(pat[i], i === 2);
          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') {
            // light up 中央 star (stars[1] which is 定向/center)
            stars[1].mesh.scale.setScalar(1.4);
          } else if (trial.cue === 'spatial') {
            // spatial cue at target position — show cue dot at top or bottom
            cue.visible = true;
            cue.position.y = trial.targetPos === 'up' ? 1.6 : -.8;
            // also light up corresponding guide star (警覺 left for 'up', 執行 right for 'down')
            const starIdx = trial.targetPos === 'up' ? 0 : 2;
            stars[starIdx].mesh.scale.setScalar(1.4);
          }
          // for 'none' cue, no star lights and no cue dot
        } else if (phase === 'stimulus') {
          // clear cue, show flanker row
          cue.visible = false;
          stars.forEach(s => s.mesh.scale.setScalar(1));
          setFlankerRow(trial.dir, trial.congruent);
          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} / 100` : '第 0 / 100', color:'#4888c8'},
      {label: `金幣 ${balance.toLocaleString()}`, color:'#d4931a'},
    ]}
    instr={instrText}
    reward={trialCount > 0 ? stars : 1}
    protocol={'<b>Protocol · Iowa Gambling Task</b>  4 副牌 A/B/C/D · A/B 高即時回報但長期淨損 · C/D 低即時回報但長期淨益 · 100 試次 · 淨得分 (C+D)−(A+B) · OFC/vmPFC 功能指標'}
    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_GoNoGo, 6: Game_Flanker, 7: Game_Tower,
  8: Game_MentalRot, 9: Game_StopSignal, 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_GoNoGo,
  Game_Flanker, Game_Tower, Game_MentalRot, Game_StopSignal, Game_ANT, Game_IGT,
  ActiveGame,
};
