BLK-105Blocks

懐中電灯エフェクトで遊ぶ隠しキーワード探し|React・CSSコード付き

店舗・商店街の「サイト内で合言葉を探す」キャンペーンに差し込める探索UIです。暗い場面の上をマウスや指でなぞると半径110pxの光の輪がついてきて、隠れた文字の上で400ms止まると進捗リングが満ち、文字が上部の5マスへ入ります。文字は8か所の候補から毎回5か所を選ぶので、位置を覚えても解けません。5文字そろうと計時が止まり、秒数・もう一度のボタン・共有用の画像(1200×630・合言葉は伏せ字)とコピー文が出ます。画像は端末の中で作り、どこにも送信しません。既定の場面はハロウィンの夜の駄菓子屋を描いた自作SVGで、場面・文字・候補位置をpropsで渡せば年末やクリスマスの企画にも使えます。矢印キーでも遊べて、prefers-reduced-motion では追従の補間と文字の飛ぶ動きを止めます。依存ライブラリなし。

プレビュー

BLK-105動き・季節キャンペーン

夜の駄菓子屋に隠れた5文字を、懐中電灯で探す

  1. 未発見
  2. 未発見
  3. 未発見
  4. 未発見
  5. 未発見

経過0.0秒

なぞると計時が始まります

なぞると懐中電灯が照らします。まずは軒下の灯りの横から

店舗・商店街の「サイト内で合言葉を探す」キャンペーンに差し込める探索UIです。場面の上をなぞると光の輪がついてきて、文字の上で少し止まると上のマスに入ります。文字の場所は毎回変わります。

マウス・指: 場面の上をなぞる(指を離すと判定が外れます)/キーボード: 場面を選んで矢印キー、Shift で大きく動かす

結果を共有する

5文字そろうと、秒数と共有用の画像(1200×630)がここに出ます。画像は端末の中で作り、どこにも送信しません。

共有用の画像(暗い場面の一部・秒数・作品名・URL)

年末やクリスマスの企画に差し替える

場面の SVG・文字・候補位置を渡せば、同じ仕組みで別の季節に使えます。

keyword探させる文字列。既定は5文字
scene場面の SVG(viewBox 960×640)
spots候補位置(%)。文字数より多く
lantern最初から灯る明かりと、毎回使う候補
radius光の輪の半径。既定 110px
dwellMs取得までの時間。既定 400ms
title / shareUrl共有画像とコピー文に載せる作品名と URL
year-end-campaign.tsx
import FlashlightReveal from "@/components/halloween-flashlight-reveal";
import { WinterArcadeScene } from "./winter-arcade-scene"; // viewBox 960×640 の SVG

export default function YearEndCampaign() {
  return (
    <FlashlightReveal
      keyword="ふくびきけん"
      scene={<WinterArcadeScene />}
      spots={[
        { x: 22, y: 48 }, { x: 34, y: 72, rotate: 4 }, { x: 51, y: 40 },
        { x: 63, y: 76 }, { x: 78, y: 52 }, { x: 88, y: 30, rotate: -6 },
        { x: 12, y: 80 }, { x: 45, y: 20 }, { x: 70, y: 22 },
      ]}
      lantern={{ x: 14, y: 40, spot: 0, radius: 0.11 }}
      radius={110}
      dwellMs={400}
      title="年末の商店街で合言葉さがし"
      shareUrl="https://example.com/campaign/year-end"
    />
  );
}
X でシェア

説明の全文を読む

店舗・商店街の「サイト内で合言葉を探す」キャンペーンに差し込める探索UIです。暗い場面の上をマウスや指でなぞると半径110pxの光の輪がついてきて、隠れた文字の上で400ms止まると進捗リングが満ち、文字が上部の5マスへ入ります。文字は8か所の候補から毎回5か所を選ぶので、位置を覚えても解けません。5文字そろうと計時が止まり、秒数・もう一度のボタン・共有用の画像(1200×630・合言葉は伏せ字)とコピー文が出ます。画像は端末の中で作り、どこにも送信しません。既定の場面はハロウィンの夜の駄菓子屋を描いた自作SVGで、場面・文字・候補位置をpropsで渡せば年末やクリスマスの企画にも使えます。矢印キーでも遊べて、prefers-reduced-motion では追従の補間と文字の飛ぶ動きを止めます。依存ライブラリなし。

追加日:
2026-10-27
依存:
なし
tags
#block #motion #spotlight #flashlight #hidden-word #campaign #halloween #seasonal #svg-mask #canvas #share-image #pointer-events #keyboard #reduced-motion #hairline #no-dependency
"use client";

import {
  useCallback,
  useEffect,
  useId,
  useRef,
  useState,
  useSyncExternalStore,
  type KeyboardEvent,
  type PointerEvent,
  type ReactNode,
} from "react";

/**
 * 懐中電灯エフェクトの隠しキーワード探し
 *
 * 差別化: 既存の円形の演出(theme-switch-reveal の配色切り替え)は一度きりの画面遷移で、3D や検索の部品(product-configurator-3d・command-palette-search)は眺める・選ぶための道具。
 * 本ブロックは光の輪で場面を探る遊びとして、判定・進捗リング・計時・結果画像までを「なぞる」1操作で完結させた。
 * 英字の小見出し・見出しの「。」止め・末尾の矢印リンク・角丸・読み込み時の出現アニメは置かず、hairline 罫線と余白で組んでいる。
 *
 * 汎用技法メモ:
 * 1. **暗闇は SVG の mask で抜く**。場面の上に紺の矩形(不透明度 0.92 = 明るさ 8%)を重ね、mask の中の円を transform(translate)だけで動かす。
 *    円の塗りは radialGradient で、中心から (半径 − 24px) までは黒の不透明、縁で透明へ落とす。stop-opacity で抜くので、
 *    懐中電灯と軒下の灯りの2つの円が重なっても互いの縁で塗りつぶし合わない(白→黒の色の補間で作ると後から描いた円の縁が前の円を消す)。
 * 2. **追従は時間基準の補間**。毎フレーム 1 − exp(−dt/60ms) の割合で目標へ寄せるので、フレームが落ちる端末でも同じ速さで追いつく。
 *    動き終わって判定中の文字も無ければ requestAnimationFrame を止め、一覧の小さなプレビューでも回り続けない。
 * 3. **判定は文字の中心との距離**。輪の半径の半分以内(小さな画面では文字の大きさ以上)に入った瞬間に輪郭を灯し、
 *    400ms 留まると取得。進捗リングの stroke-dashoffset は React の state を通さず ref で直接書き換える(毎フレームの再描画を避ける)。
 * 4. **飛ぶ動きは Web Animations の transform だけ**。場面の文字と上部のマスの getBoundingClientRect の差を translate と scale にする。
 * 5. **共有画像は端末の中で作る**。場面の SVG を XMLSerializer で文字列にして Blob URL から canvas に描き、暗くした上で丸く抜く。
 *    隠し文字は SVG の外(HTML)に置いているので画像に写らない。合言葉は「?」で伏せる。どこへも送信しない。
 */

const AMBER = "#d97706";
const NAVY = "#151d36";
const NAVY_DEEP = "#0d1328";
const WALL = "#1c2647";
const ORANGE = "#e8873a";
const CREAM = "#efe6d2";
const GREEN = "#7d9078";
const DARK = "#05070f";

const SCENE_W = 960;
const SCENE_H = 640;

export type FlashlightSpot = { x: number; y: number; rotate?: number };
export type FlashlightLantern = { x: number; y: number; spot: number; radius?: number };

export type FlashlightRevealProps = {
  /** 探させる文字列。1文字ずつ候補位置へ散らす */
  keyword?: string;
  /** 場面の SVG。viewBox は 960×640(3:2)で描く */
  scene?: ReactNode;
  /** 文字の候補位置(場面に対する %)。keyword の文字数より多く用意する */
  spots?: FlashlightSpot[];
  /** 最初から灯っている明かり(%)。spot に指定した候補は毎回かならず使い、その文字の端だけを照らす */
  lantern?: FlashlightLantern;
  /** 光の輪の半径(px)。狭い画面では場面の幅に合わせて小さくなる */
  radius?: number;
  /** 文字の上に留まって取得するまでの時間(ms) */
  dwellMs?: number;
  /** 共有画像に載せる作品名 */
  title?: string;
  /** 共有画像とコピー文に載せる作品ページの URL */
  shareUrl?: string;
  /** 見出しの要素。ページの主見出しにするときだけ "h1" */
  headingTag?: "h1" | "h2" | "p";
};

const DEFAULT_SPOTS: FlashlightSpot[] = [
  { x: 26.9, y: 46.3, rotate: -4 },
  { x: 35.9, y: 70.6, rotate: 3 },
  { x: 55.2, y: 46.9, rotate: -2 },
  { x: 67.2, y: 76.3, rotate: 2 },
  { x: 86.3, y: 72.5, rotate: -6 },
  { x: 7.3, y: 81.6, rotate: 5 },
  { x: 92.1, y: 41.4, rotate: 8 },
  { x: 56.3, y: 22, rotate: 0 },
];

const DEFAULT_LANTERN: FlashlightLantern = { x: 17.7, y: 44.1, spot: 0, radius: 0.11 };
const START_LIGHT = { x: 40, y: 23 };

const DEFAULT_URL = "https://designs.first-ch.com/d/halloween-flashlight-reveal";

/** 既定の場面: ハロウィンの夜の駄菓子屋。線幅 2px・紺地に橙・生成り・くすんだ緑 */
export function DagashiNightScene() {
  const line = { stroke: CREAM, strokeWidth: 2, vectorEffect: "non-scaling-stroke" as const, strokeLinejoin: "round" as const, strokeLinecap: "round" as const };
  const jars = (y: number, xs: number[], w = 46) =>
    xs.map((x, i) => (
      <g key={`${y}-${x}`}>
        <rect x={x} y={y - 54} width={w} height={54} rx={8} fill={NAVY} {...line} />
        <rect x={x + 6} y={y - 62} width={w - 12} height={9} fill={GREEN} {...line} />
        <circle cx={x + w * 0.3} cy={y - 16} r={6} fill={i % 2 ? ORANGE : GREEN} />
        <circle cx={x + w * 0.62} cy={y - 22} r={6} fill={i % 2 ? CREAM : ORANGE} />
        <circle cx={x + w * 0.45} cy={y - 34} r={5} fill={i % 2 ? GREEN : CREAM} />
      </g>
    ));
  return (
    <svg
      data-scene=""
      xmlns="http://www.w3.org/2000/svg"
      viewBox={`0 0 ${SCENE_W} ${SCENE_H}`}
      width={SCENE_W}
      height={SCENE_H}
      preserveAspectRatio="xMidYMid slice"
      style={{ display: "block", width: "100%", height: "100%" }}
    >
      <rect width={SCENE_W} height={SCENE_H} fill={NAVY} />
      {[
        [60, 60], [140, 34], [250, 70], [560, 40], [700, 22], [910, 160], [780, 190], [40, 210], [940, 40],
      ].map(([x, y]) => (
        <circle key={`${x}-${y}`} cx={x} cy={y} r={2} fill={CREAM} opacity={0.7} />
      ))}
      <circle cx={830} cy={92} r={36} fill={CREAM} />
      <circle cx={846} cy={82} r={32} fill={NAVY} />
      {[
        [596, 66, 1],
        [636, 44, 0.8],
      ].map(([x, y, s]) => (
        <path
          key={x}
          transform={`translate(${x} ${y}) scale(${s})`}
          d="M0 6 Q8 -6 16 4 Q20 -2 24 4 Q28 -2 32 4 Q40 -6 48 6 Q38 4 32 14 Q28 8 24 12 Q20 8 16 14 Q10 4 0 6 Z"
          fill={NAVY_DEEP}
          {...line}
        />
      ))}

      <rect x={0} y={560} width={SCENE_W} height={80} fill={NAVY_DEEP} />
      <line x1={0} y1={560} x2={SCENE_W} y2={560} {...line} stroke={GREEN} />
      {[80, 220, 380, 540, 700, 860].map((x) => (
        <line key={x} x1={x} y1={600} x2={x + 60} y2={600} {...line} stroke={GREEN} />
      ))}

      {/* 建物 */}
      <rect x={120} y={176} width={640} height={384} fill={WALL} {...line} />
      <path d="M96 182 L162 108 L718 108 L784 182 Z" fill={NAVY_DEEP} {...line} />
      <line x1={140} y1={134} x2={740} y2={134} {...line} stroke={GREEN} />
      <line x1={118} y1={158} x2={762} y2={158} {...line} stroke={GREEN} />
      <rect x={322} y={116} width={284} height={50} fill={WALL} {...line} stroke={ORANGE} />
      <g transform="translate(384 141)">
        <ellipse rx={26} ry={13} fill={ORANGE} {...line} />
        <path d="M-26 0 L-42 -12 L-42 12 Z M26 0 L42 -12 L42 12 Z" fill={ORANGE} {...line} />
        <line x1={-8} y1={-12} x2={-8} y2={12} {...line} />
        <line x1={8} y1={-12} x2={8} y2={12} {...line} />
      </g>
      {/* 屋根の猫 */}
      <g transform="translate(690 108)">
        <path d="M-18 0 Q-22 -26 -6 -30 L6 -30 Q20 -26 16 0 Z" fill={NAVY_DEEP} {...line} />
        <path d="M-12 -30 L-14 -46 L-4 -38 L4 -38 L12 -46 L10 -30" fill={NAVY_DEEP} {...line} />
        <path d="M16 -4 Q34 -2 30 -22" fill="none" {...line} />
        <circle cx={-4} cy={-34} r={2} fill={ORANGE} />
        <circle cx={4} cy={-34} r={2} fill={ORANGE} />
      </g>

      {/* 縞の庇 */}
      {Array.from({ length: 14 }, (_, i) => (
        <rect key={i} x={104 + i * 48} y={194} width={48} height={34} fill={i % 2 ? NAVY_DEEP : ORANGE} />
      ))}
      <rect x={104} y={194} width={672} height={34} fill="none" {...line} />
      <path
        d={`M104 228 ${Array.from({ length: 14 }, (_, i) => `Q${128 + i * 48} 246 ${152 + i * 48} 228`).join(" ")}`}
        fill={NAVY_DEEP}
        {...line}
      />

      {/* 左の飾り棚 */}
      <rect x={150} y={252} width={290} height={270} fill={NAVY_DEEP} {...line} />
      {[330, 410, 488].map((y) => (
        <line key={y} x1={150} y1={y} x2={440} y2={y} {...line} />
      ))}
      {jars(330, [168, 316, 382])}
      {jars(410, [168, 234, 300, 366], 44)}
      {jars(488, [168, 230, 392], 40)}
      <line x1={295} y1={496} x2={295} y2={516} {...line} />

      {/* 入口と暖簾 */}
      <rect x={470} y={252} width={270} height={308} fill={NAVY_DEEP} {...line} />
      {[0, 1, 2].map((i) => (
        <rect key={i} x={478 + i * 86} y={252} width={80} height={92} fill={GREEN} {...line} />
      ))}
      <circle cx={605} cy={300} r={18} fill="none" {...line} />
      <rect x={570} y={430} width={160} height={130} fill={WALL} {...line} />
      <line x1={560} y1={430} x2={740} y2={430} {...line} stroke={ORANGE} />
      <rect x={590} y={404} width={34} height={26} fill={ORANGE} {...line} />
      <rect x={682} y={410} width={30} height={20} fill={GREEN} {...line} />

      {/* 軒下の提灯 */}
      <line x1={170} y1={240} x2={170} y2={252} {...line} />
      <rect x={156} y={250} width={28} height={8} fill={NAVY_DEEP} {...line} />
      <ellipse cx={170} cy={282} rx={22} ry={26} fill={ORANGE} {...line} />
      {[-10, 0, 10].map((d) => (
        <line key={d} x1={150} y1={282 + d} x2={190} y2={282 + d} {...line} stroke={NAVY_DEEP} />
      ))}
      <rect x={156} y={306} width={28} height={8} fill={NAVY_DEEP} {...line} />

      {/* 外の木箱 */}
      <rect x={28} y={478} width={86} height={82} fill={NAVY_DEEP} {...line} />
      <line x1={28} y1={498} x2={114} y2={498} {...line} />
      <line x1={28} y1={544} x2={114} y2={544} {...line} />

      {/* かぼちゃ */}
      {[
        [470, 538, 40, 26],
        [910, 542, 30, 20],
      ].map(([x, y, rx, ry]) => (
        <g key={x}>
          <ellipse cx={x} cy={y} rx={rx} ry={ry} fill={ORANGE} {...line} />
          <ellipse cx={x} cy={y} rx={rx * 0.45} ry={ry} fill="none" {...line} stroke={NAVY_DEEP} />
          <path d={`M${x} ${y - ry} q2 -10 8 -12`} fill="none" {...line} stroke={GREEN} />
          <path
            d={`M${x - rx * 0.5} ${y - 4} l${rx * 0.18} ${-ry * 0.4} l${rx * 0.18} ${ry * 0.4} Z M${x + rx * 0.14} ${y - 4} l${rx * 0.18} ${-ry * 0.4} l${rx * 0.18} ${ry * 0.4} Z M${x - rx * 0.45} ${y + ry * 0.3} q${rx * 0.45} ${ry * 0.4} ${rx * 0.9} 0`}
            fill={NAVY_DEEP}
            {...line}
            stroke={NAVY_DEEP}
          />
        </g>
      ))}

      {/* ガチャの機械 */}
      <rect x={792} y={500} width={72} height={60} fill={ORANGE} {...line} />
      <circle cx={828} cy={530} r={9} fill={NAVY_DEEP} {...line} />
      <circle cx={828} cy={464} r={38} fill={NAVY_DEEP} {...line} />
      {[
        [808, 488, ORANGE],
        [826, 492, GREEN],
        [846, 486, CREAM],
      ].map(([x, y, c]) => (
        <circle key={x as number} cx={x as number} cy={y as number} r={7} fill={c as string} />
      ))}

      {/* おばけ */}
      <path
        d="M856 300 Q852 220 884 214 Q916 220 912 300 L902 290 L892 302 L882 290 L872 302 L864 290 Z"
        fill={NAVY_DEEP}
        {...line}
      />
      <circle cx={872} cy={236} r={3} fill={CREAM} />
      <circle cx={894} cy={236} r={3} fill={CREAM} />
    </svg>
  );
}

const reduceStore = {
  subscribe: (cb: () => void) => {
    const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
    mq.addEventListener("change", cb);
    return () => mq.removeEventListener("change", cb);
  },
  get: () => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
};
const noopSubscribe = () => () => {};
const serverFalse = () => false;
const clientTrue = () => true;

type Round = number[]; // 文字の順番 → 候補位置の番号

function makeRound(count: number, spotCount: number, fixed: number): Round {
  const others = Array.from({ length: spotCount }, (_, i) => i).filter((i) => i !== fixed);
  for (let i = others.length - 1; i > 0; i--) {
    const j = Math.floor(Math.random() * (i + 1));
    [others[i], others[j]] = [others[j], others[i]];
  }
  const chosen = [fixed, ...others.slice(0, count - 1)];
  for (let i = chosen.length - 1; i > 0; i--) {
    const j = Math.floor(Math.random() * (i + 1));
    [chosen[i], chosen[j]] = [chosen[j], chosen[i]];
  }
  return chosen;
}

type Flyer = { id: number; index: number; char: string; x: number; y: number; dx: number; dy: number; scale: number; size: number };

const loadImage = (src: string) =>
  new Promise<HTMLImageElement>((resolve, reject) => {
    const img = new Image();
    img.onload = () => resolve(img);
    img.onerror = reject;
    img.src = src;
  });

async function copyText(text: string) {
  try {
    await navigator.clipboard.writeText(text);
    return true;
  } catch {
    const ta = document.createElement("textarea");
    ta.value = text;
    ta.setAttribute("readonly", "");
    ta.style.position = "fixed";
    ta.style.opacity = "0";
    document.body.appendChild(ta);
    ta.select();
    const ok = document.execCommand("copy");
    ta.remove();
    return ok;
  }
}

const PROPS_EXAMPLE = `import FlashlightReveal from "@/components/halloween-flashlight-reveal";
import { WinterArcadeScene } from "./winter-arcade-scene"; // viewBox 960×640 の SVG

export default function YearEndCampaign() {
  return (
    <FlashlightReveal
      keyword="ふくびきけん"
      scene={<WinterArcadeScene />}
      spots={[
        { x: 22, y: 48 }, { x: 34, y: 72, rotate: 4 }, { x: 51, y: 40 },
        { x: 63, y: 76 }, { x: 78, y: 52 }, { x: 88, y: 30, rotate: -6 },
        { x: 12, y: 80 }, { x: 45, y: 20 }, { x: 70, y: 22 },
      ]}
      lantern={{ x: 14, y: 40, spot: 0, radius: 0.11 }}
      radius={110}
      dwellMs={400}
      title="年末の商店街で合言葉さがし"
      shareUrl="https://example.com/campaign/year-end"
    />
  );
}`;

const PROPS_ROWS: [string, string, string][] = [
  ["keyword", "string", "探させる文字列。既定は5文字"],
  ["scene", "ReactNode", "場面の SVG(viewBox 960×640)"],
  ["spots", "{ x, y, rotate? }[]", "候補位置(%)。文字数より多く"],
  ["lantern", "{ x, y, spot, radius? }", "最初から灯る明かりと、毎回使う候補"],
  ["radius", "number", "光の輪の半径。既定 110px"],
  ["dwellMs", "number", "取得までの時間。既定 400ms"],
  ["title / shareUrl", "string", "共有画像とコピー文に載せる作品名と URL"],
];

export default function FlashlightReveal({
  keyword = "おばけだぞ",
  scene,
  spots = DEFAULT_SPOTS,
  lantern = DEFAULT_LANTERN,
  radius = 110,
  dwellMs = 400,
  title = "夜の駄菓子屋の隠しキーワード探し",
  shareUrl = DEFAULT_URL,
  headingTag,
}: FlashlightRevealProps) {
  const Heading = headingTag === "h1" ? "h1" : "h2";
  const chars = Array.from(keyword);
  const count = chars.length;
  const uid = useId().replace(/[^a-zA-Z0-9_-]/g, "");
  const reduce = useSyncExternalStore(reduceStore.subscribe, reduceStore.get, serverFalse);
  const hydrated = useSyncExternalStore(noopSubscribe, clientTrue, serverFalse);

  const [size, setSize] = useState({ w: 0, h: 0 });
  const [round, setRound] = useState<Round>(() => makeRound(count, spots.length, lantern.spot));
  const [found, setFound] = useState<boolean[]>(() => chars.map(() => false));
  const [landed, setLanded] = useState<boolean[]>(() => chars.map(() => false));
  const [phase, setPhase] = useState<"idle" | "playing" | "done">("idle");
  const [elapsed, setElapsed] = useState(0);
  const [active, setActive] = useState<number | null>(null);
  const [flyers, setFlyers] = useState<Flyer[]>([]);
  const [share, setShare] = useState<{ url: string; sec: string } | null>(null);
  const [copied, setCopied] = useState<"text" | "code" | null>(null);
  const [message, setMessage] = useState("");

  const rootRef = useRef<HTMLElement>(null);
  const sceneRef = useRef<HTMLDivElement>(null);
  const artRef = useRef<HTMLDivElement>(null);
  const timeRef = useRef<HTMLSpanElement>(null);
  const maskLightRef = useRef<SVGCircleElement>(null);
  const ringRef = useRef<SVGGElement>(null);
  const arcRef = useRef<SVGCircleElement | null>(null);
  const letterRefs = useRef<(SVGTextElement | null)[]>([]);
  const maskRevealRef = useRef<SVGCircleElement>(null);
  const slotRefs = useRef<(HTMLSpanElement | null)[]>([]);

  const live = useRef({
    target: { x: 0, y: 0 },
    cur: { x: 0, y: 0 },
    placed: false,
    raf: 0,
    last: 0,
    progress: 0,
    active: null as number | null,
    judge: false,
    start: 0,
    phase: "idle" as "idle" | "playing" | "done",
    found: [] as boolean[],
    round: [] as Round,
    size: { w: 0, h: 0 },
    reduce: false,
    flyerId: 0,
  });

  const w = size.w;
  const R = w ? Math.round(Math.min(radius, Math.max(52, w * 0.15))) : radius;
  const edge = Math.min(24, R * 0.3);
  const letterSize = w ? Math.max(15, Math.round(w * 0.032)) : 15;
  const ringBox = Math.round(letterSize * 2.4);
  const arcR = ringBox / 2 - 3;
  const arcLen = 2 * Math.PI * arcR;
  const lanternR = (lantern.radius ?? 0.11) * w;

  // 最新の値を rAF・イベントから読めるように写す(描画中には ref を読まない)
  useEffect(() => {
    const s = live.current;
    s.found = found;
    s.round = round;
    s.phase = phase;
    s.reduce = reduce;
  }, [found, round, phase, reduce]);

  const applyLight = useCallback(() => {
    const { cur } = live.current;
    const t = `translate(${cur.x.toFixed(1)} ${cur.y.toFixed(1)})`;
    maskLightRef.current?.setAttribute("transform", t);
    maskRevealRef.current?.setAttribute("transform", t);
    ringRef.current?.setAttribute("transform", t);
  }, []);

  useEffect(() => {
    const el = sceneRef.current;
    if (!el) return;
    const ro = new ResizeObserver(() => {
      const next = { w: el.offsetWidth, h: el.offsetHeight };
      const s = live.current;
      if (!s.placed) {
        s.target = { x: (START_LIGHT.x / 100) * next.w, y: (START_LIGHT.y / 100) * next.h };
        s.cur = { ...s.target };
      } else if (s.size.w) {
        const k = next.w / s.size.w;
        s.target = { x: s.target.x * k, y: s.target.y * k };
        s.cur = { x: s.cur.x * k, y: s.cur.y * k };
      }
      s.size = next;
      setSize(next);
      applyLight();
    });
    ro.observe(el);
    return () => ro.disconnect();
  }, [applyLight]);

  useEffect(() => applyLight(), [applyLight, R, w, hydrated]);

  // 計時(表示は 0.1 秒刻み)
  useEffect(() => {
    if (phase !== "playing") return;
    const id = window.setInterval(() => setElapsed(performance.now() - live.current.start), 100);
    return () => window.clearInterval(id);
  }, [phase]);

  useEffect(() => () => cancelAnimationFrame(live.current.raf), []);

  useEffect(
    () => () => {
      if (share) URL.revokeObjectURL(share.url);
    },
    [share],
  );

  const setArc = useCallback(
    (p: number) => {
      if (arcRef.current) arcRef.current.style.strokeDashoffset = String(arcLen * (1 - Math.min(1, p)));
    },
    [arcLen],
  );
  const tickRef = useRef<(t: number) => void>(() => {});

  const makeShare = useCallback(
    async (sec: string) => {
      const art = artRef.current?.querySelector("svg");
      const root = rootRef.current;
      if (!art || !root) return;
      const clone = art.cloneNode(true) as SVGSVGElement;
      clone.setAttribute("xmlns", "http://www.w3.org/2000/svg");
      clone.setAttribute("width", String(SCENE_W));
      clone.setAttribute("height", String(SCENE_H));
      clone.removeAttribute("style");
      const svgUrl = URL.createObjectURL(
        new Blob([new XMLSerializer().serializeToString(clone)], { type: "image/svg+xml;charset=utf-8" }),
      );
      try {
        const img = await loadImage(svgUrl);
        await document.fonts?.ready;
        const sans = getComputedStyle(root).fontFamily;
        const display = timeRef.current ? getComputedStyle(timeRef.current).fontFamily : sans;
        const cv = document.createElement("canvas");
        cv.width = 1200;
        cv.height = 630;
        const ctx = cv.getContext("2d");
        if (!ctx) return;
        ctx.fillStyle = "#ffffff";
        ctx.fillRect(0, 0, 1200, 630);

        // 暗い場面の一部(左 700×520)
        const sw = (700 / 520) * 400;
        ctx.drawImage(img, 70, 200, sw, 400, 0, 0, 700, 520);
        const dark = document.createElement("canvas");
        dark.width = 700;
        dark.height = 520;
        const dctx = dark.getContext("2d");
        if (dctx) {
          dctx.fillStyle = "rgba(5,7,15,0.92)";
          dctx.fillRect(0, 0, 700, 520);
          dctx.globalCompositeOperation = "destination-out";
          const hole = (x: number, y: number, r: number, a: number) => {
            const g = dctx.createRadialGradient(x, y, 0, x, y, r);
            g.addColorStop(0, `rgba(0,0,0,${a})`);
            g.addColorStop((r - 30) / r, `rgba(0,0,0,${a})`);
            g.addColorStop(1, "rgba(0,0,0,0)");
            dctx.fillStyle = g;
            dctx.beginPath();
            dctx.arc(x, y, r, 0, Math.PI * 2);
            dctx.fill();
          };
          hole(500, 400, 150, 1);
          hole(129, 107, 90, 0.85);
          ctx.drawImage(dark, 0, 0);
        }
        ctx.strokeStyle = "rgba(217,119,6,0.55)";
        ctx.lineWidth = 2;
        ctx.save();
        ctx.beginPath();
        ctx.rect(0, 0, 700, 520);
        ctx.clip();
        ctx.beginPath();
        ctx.arc(500, 400, 136, 0, Math.PI * 2);
        ctx.stroke();
        ctx.restore();

        // 右の結果
        ctx.fillStyle = AMBER;
        ctx.fillRect(700, 0, 2, 520);
        ctx.textBaseline = "alphabetic";
        ctx.fillStyle = "#57534e";
        ctx.font = `500 24px ${sans}`;
        ctx.fillText(`隠れた${count}文字を`, 748, 110);
        ctx.fillStyle = AMBER;
        ctx.font = `700 112px ${display}`;
        ctx.fillText(sec, 744, 250);
        const numW = ctx.measureText(sec).width;
        ctx.fillStyle = "#1c1917";
        ctx.font = `700 40px ${sans}`;
        ctx.fillText("秒", 752 + numW, 250);
        ctx.font = `700 32px ${sans}`;
        ctx.fillText("で見つけた", 748, 310);
        const box = Math.min(64, Math.floor((404 - (count - 1) * 10) / count));
        for (let i = 0; i < count; i++) {
          const x = 748 + i * (box + 10);
          ctx.strokeStyle = "#d6d3d1";
          ctx.lineWidth = 1;
          ctx.strokeRect(x + 0.5, 370.5, box, box);
          ctx.fillStyle = "#a8a29e";
          ctx.font = `700 ${Math.round(box * 0.5)}px ${sans}`;
          ctx.textAlign = "center";
          ctx.fillText("?", x + box / 2, 370 + box * 0.68);
          ctx.textAlign = "left";
        }
        ctx.fillStyle = "#78716c";
        ctx.font = `500 20px ${sans}`;
        ctx.fillText("あなたは何秒で見つけられますか", 748, 480);

        // 下の帯: 作品名と URL
        ctx.fillStyle = "#e7e5e4";
        ctx.fillRect(0, 520, 1200, 1);
        ctx.fillStyle = "#1c1917";
        ctx.font = `700 28px ${sans}`;
        ctx.fillText(title, 48, 566);
        ctx.fillStyle = "#57534e";
        ctx.font = `500 20px ${display}`;
        ctx.fillText(shareUrl.replace(/^https?:\/\//, ""), 48, 600);

        const blob = await new Promise<Blob | null>((r) => cv.toBlob(r, "image/png"));
        if (blob) setShare({ url: URL.createObjectURL(blob), sec });
      } finally {
        URL.revokeObjectURL(svgUrl);
      }
    },
    [count, shareUrl, title],
  );

  const finish = useCallback(() => {
    const s = live.current;
    const ms = performance.now() - s.start;
    s.phase = "done";
    s.judge = false;
    setPhase("done");
    setElapsed(ms);
    const sec = (ms / 1000).toFixed(1);
    setMessage(`${count}文字そろいました。${sec}秒でした`);
    void makeShare(sec);
  }, [count, makeShare]);

  const capture = useCallback(
    (index: number) => {
      const s = live.current;
      const nextFound = s.found.map((f, i) => f || i === index);
      s.found = nextFound;
      s.active = null;
      s.progress = 0;
      setFound(nextFound);
      setActive(null);
      const left = nextFound.filter((f) => !f).length;
      setMessage(left ? `「${chars[index]}」を見つけました。残り${left}文字` : "");

      const root = rootRef.current?.getBoundingClientRect();
      const from = letterRefs.current[s.round[index]]?.getBoundingClientRect();
      const to = slotRefs.current[index]?.getBoundingClientRect();
      if (s.reduce || !root || !from || !to) {
        setLanded((l) => l.map((v, i) => v || i === index));
      } else {
        const fx = from.left + from.width / 2 - root.left;
        const fy = from.top + from.height / 2 - root.top;
        const tx = to.left + to.width / 2 - root.left;
        const ty = to.top + to.height / 2 - root.top;
        const fs = from.height;
        setFlyers((f) => [
          ...f,
          { id: ++s.flyerId, index, char: chars[index], x: fx, y: fy, dx: tx - fx, dy: ty - fy, scale: (to.height * 0.5) / Math.max(1, fs * 0.82), size: fs },
        ]);
      }
      if (!left) finish();
    },
    [chars, finish],
  );

  const tick = useCallback(
    (t: number) => {
      const s = live.current;
      const dt = Math.min(500, t - s.last);
      s.last = t;
      const k = s.reduce ? 1 : 1 - Math.exp(-dt / 60);
      s.cur.x += (s.target.x - s.cur.x) * k;
      s.cur.y += (s.target.y - s.cur.y) * k;
      applyLight();

      let cand: number | null = null;
      if (s.judge && s.phase === "playing") {
        const thr = Math.max(R * 0.5, letterSize);
        let best = thr;
        s.round.forEach((spot, i) => {
          if (s.found[i]) return;
          const p = spots[spot];
          const d = Math.hypot((p.x / 100) * s.size.w - s.cur.x, (p.y / 100) * s.size.h - s.cur.y);
          if (d < best) {
            best = d;
            cand = i;
          }
        });
      }
      if (cand !== s.active) {
        s.active = cand;
        s.progress = 0;
        setActive(cand);
        setArc(0);
      } else if (cand !== null) {
        s.progress += dt;
        setArc(s.progress / dwellMs);
        if (s.progress >= dwellMs) capture(cand);
      }

      const moving = Math.abs(s.target.x - s.cur.x) + Math.abs(s.target.y - s.cur.y) > 0.4;
      s.raf = moving || s.active !== null ? requestAnimationFrame((n) => tickRef.current(n)) : 0;
    },
    [R, letterSize, spots, dwellMs, capture, applyLight, setArc],
  );

  useEffect(() => {
    tickRef.current = tick;
  }, [tick]);

  const kick = () => {
    const s = live.current;
    if (s.raf) return;
    s.last = performance.now();
    s.raf = requestAnimationFrame((n) => tickRef.current(n));
  };

  const begin = () => {
    const s = live.current;
    s.placed = true;
    s.judge = true;
    if (s.phase === "idle") {
      s.phase = "playing";
      s.start = performance.now();
      setPhase("playing");
      setMessage("探索を始めました");
    }
  };

  const onPointer = (e: PointerEvent<HTMLDivElement>) => {
    const el = sceneRef.current;
    if (!el) return;
    const r = el.getBoundingClientRect();
    const scale = r.width / el.offsetWidth || 1;
    live.current.target = { x: (e.clientX - r.left) / scale, y: (e.clientY - r.top) / scale };
    begin();
    kick();
  };

  const release = () => {
    const s = live.current;
    s.judge = false;
    if (s.active !== null) {
      s.active = null;
      s.progress = 0;
      setActive(null);
    }
  };

  const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
    const dir: Record<string, [number, number]> = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] };
    const d = dir[e.key];
    if (!d) return;
    e.preventDefault();
    const s = live.current;
    const step = Math.max(16, s.size.w * 0.035) * (e.shiftKey ? 3 : 1);
    s.target = {
      x: Math.min(s.size.w, Math.max(0, s.target.x + d[0] * step)),
      y: Math.min(s.size.h, Math.max(0, s.target.y + d[1] * step)),
    };
    begin();
    kick();
  };

  const reset = () => {
    const s = live.current;
    cancelAnimationFrame(s.raf);
    s.raf = 0;
    const next = makeRound(count, spots.length, lantern.spot);
    const empty = chars.map(() => false);
    Object.assign(s, {
      round: next,
      found: empty,
      phase: "idle",
      judge: false,
      active: null,
      progress: 0,
      placed: false,
      target: { x: (START_LIGHT.x / 100) * s.size.w, y: (START_LIGHT.y / 100) * s.size.h },
    });
    s.cur = { ...s.target };
    applyLight();
    setRound(next);
    setFound(empty);
    setLanded(empty);
    setFlyers([]);
    setPhase("idle");
    setElapsed(0);
    setActive(null);
    setShare(null);
    setCopied(null);
    setMessage("新しい配置で始めます。文字の場所は前回と変わります");
    sceneRef.current?.focus({ preventScroll: true });
  };

  const flyerRef = (f: Flyer) => (el: HTMLSpanElement | null) => {
    if (!el || el.dataset.go) return;
    el.dataset.go = "1";
    const anim = el.animate(
      [
        { transform: "translate(0px, 0px) scale(1)", opacity: 1 },
        { transform: `translate(${f.dx}px, ${f.dy - 24}px) scale(${(1 + f.scale) / 1.6})`, opacity: 1, offset: 0.55 },
        { transform: `translate(${f.dx}px, ${f.dy}px) scale(${f.scale})`, opacity: 1 },
      ],
      { duration: 560, easing: "cubic-bezier(.2,.7,.2,1)", fill: "forwards" },
    );
    anim.onfinish = () => {
      setLanded((l) => l.map((v, i) => v || i === f.index));
      setFlyers((list) => list.filter((x) => x.id !== f.id));
    };
  };

  const sec = (elapsed / 1000).toFixed(1);
  const foundCount = found.filter(Boolean).length;
  const shareText = `隠れた${count}文字を${share?.sec ?? sec}秒で見つけた。あなたは? ${shareUrl}`;
  const doCopy = async (kind: "text" | "code") => {
    const ok = await copyText(kind === "text" ? shareText : PROPS_EXAMPLE);
    setCopied(ok ? kind : null);
    if (ok) setMessage(kind === "text" ? "コピー文をコピーしました" : "コードをコピーしました");
  };

  const btn =
    "inline-flex min-h-11 items-center justify-center border px-4 text-sm font-bold transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#d97706] outline-none";

  return (
    <section
      ref={rootRef}
      className="relative mx-auto w-full max-w-6xl border border-stone-200 bg-white text-stone-900"
      aria-labelledby={`${uid}-title`}
    >
      {/* 見出しと状況 */}
      <div className="grid gap-5 px-5 py-6 sm:px-8 sm:py-7 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-end lg:gap-10">
        <div className="max-w-2xl">
          <p className="font-display text-xs font-semibold tracking-[0.18em] text-stone-500">
            BLK-105<span className="mx-2 inline-block h-px w-5 bg-stone-300 align-middle" />
            <span className="font-sans tracking-normal">動き・季節キャンペーン</span>
          </p>
          <Heading id={`${uid}-title`} className="mt-3 text-[clamp(1.35rem,3.2vw,2rem)] font-bold leading-[1.4] tracking-tight text-balance [word-break:auto-phrase]">
            夜の駄菓子屋に隠れた{count}文字を、懐中電灯で探す
          </Heading>
        </div>

        <div className="flex flex-col gap-3 lg:items-end">
          <ol className="flex gap-2" aria-label={`見つけた文字 ${foundCount}/${count}`}>
            {chars.map((c, i) => (
              <li key={i}>
                <span
                  ref={(el) => {
                    slotRefs.current[i] = el;
                  }}
                  className={`flex h-11 w-11 items-center justify-center border text-xl font-bold sm:h-12 sm:w-12 sm:text-2xl ${
                    found[i] ? "border-[#d97706] bg-amber-50 text-stone-900" : "border-stone-300 bg-white text-stone-300"
                  }`}
                >
                  {found[i] && landed[i] ? c : <span aria-hidden="true" className="font-display text-xs">{i + 1}</span>}
                  <span className="sr-only">{found[i] ? c : "未発見"}</span>
                </span>
              </li>
            ))}
          </ol>
          <div className="flex min-h-11 items-center gap-4">
            <p className="text-sm text-stone-500">
              経過
              <span ref={timeRef} className="ml-2 font-display text-2xl font-bold tabular-nums text-stone-900">
                {sec}
              </span>
              <span className="ml-0.5 text-sm text-stone-900">秒</span>
            </p>
            {phase === "done" ? (
              <button type="button" onClick={reset} className={`${btn} restart border-[#d97706] bg-[#d97706] text-white hover:bg-amber-700`}>
                もう一度
              </button>
            ) : (
              <p className="text-sm text-stone-500">
                {phase === "idle" ? "なぞると計時が始まります" : `残り${count - foundCount}文字`}
              </p>
            )}
          </div>
        </div>
      </div>

      {/* 場面 */}
      <div
        ref={sceneRef}
        tabIndex={0}
        role="group"
        aria-roledescription="探索の場面"
        aria-label={`夜の駄菓子屋の場面。矢印キーで懐中電灯を動かします。Shift と一緒に押すと大きく動きます`}
        onPointerMove={onPointer}
        onPointerDown={onPointer}
        onPointerLeave={release}
        onPointerCancel={release}
        onKeyDown={onKeyDown}
        className="relative aspect-[3/2] w-full cursor-crosshair touch-none select-none overflow-hidden border-y border-stone-200 outline-none focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[#d97706]"
        style={{ background: NAVY }}
      >
        <div ref={artRef} className="absolute inset-0" aria-hidden="true">
          {scene ?? <DagashiNightScene />}
        </div>

        {/* 暗闇(明るさ 8%)と光の輪 */}
        <svg
          className={`pointer-events-none absolute inset-0 z-20 h-full w-full transition-opacity duration-700 motion-reduce:transition-none ${
            phase === "done" ? "opacity-30" : "opacity-100"
          }`}
          aria-hidden="true"
        >
          <defs>
            <radialGradient id={`${uid}-hole`}>
              <stop offset="0" stopColor="#000" stopOpacity="1" />
              <stop offset={(R - edge) / R} stopColor="#000" stopOpacity="1" />
              <stop offset="1" stopColor="#000" stopOpacity="0" />
            </radialGradient>
            <radialGradient id={`${uid}-lamp`}>
              <stop offset="0" stopColor="#000" stopOpacity="0.92" />
              <stop offset="0.6" stopColor="#000" stopOpacity="0.8" />
              <stop offset="1" stopColor="#000" stopOpacity="0" />
            </radialGradient>
            <radialGradient id={`${uid}-warm`}>
              <stop offset="0" stopColor={ORANGE} stopOpacity="0.32" />
              <stop offset="1" stopColor={ORANGE} stopOpacity="0" />
            </radialGradient>
            <mask id={`${uid}-mask`} maskUnits="userSpaceOnUse" x="0" y="0" width="100%" height="100%">
              <rect width="100%" height="100%" fill="#fff" />
              {w > 0 && (
                <circle cx={(lantern.x / 100) * w} cy={(lantern.y / 100) * size.h} r={lanternR} fill={`url(#${uid}-lamp)`} />
              )}
              <circle ref={maskLightRef} r={R} fill={`url(#${uid}-hole)`} />
            </mask>
          </defs>
          <rect width="100%" height="100%" fill={DARK} fillOpacity="0.92" mask={`url(#${uid}-mask)`} />
          {w > 0 && <circle cx={(lantern.x / 100) * w} cy={(lantern.y / 100) * size.h} r={lanternR * 1.1} fill={`url(#${uid}-warm)`} />}
        </svg>

        {/* 隠し文字は暗闇の上に置き、光の届く範囲だけ mask で見せる(暗闇の下に置くと 8% の明るさでも輪郭が読めてしまう) */}
        {hydrated && w > 0 && (
          <svg className="pointer-events-none absolute inset-0 z-[25] h-full w-full" aria-hidden="true">
            <defs>
              <radialGradient id={`${uid}-see`}>
                <stop offset="0" stopColor="#fff" stopOpacity="1" />
                <stop offset={(R - edge) / R} stopColor="#fff" stopOpacity="1" />
                <stop offset="1" stopColor="#fff" stopOpacity="0" />
              </radialGradient>
              <radialGradient id={`${uid}-seelamp`}>
                <stop offset="0" stopColor="#fff" stopOpacity="0.9" />
                <stop offset="0.6" stopColor="#fff" stopOpacity="0.75" />
                <stop offset="1" stopColor="#fff" stopOpacity="0" />
              </radialGradient>
              <mask id={`${uid}-reveal`} maskUnits="userSpaceOnUse" x="0" y="0" width="100%" height="100%">
                <rect width="100%" height="100%" fill="#000" />
                <circle cx={(lantern.x / 100) * w} cy={(lantern.y / 100) * size.h} r={lanternR} fill={`url(#${uid}-seelamp)`} />
                <circle ref={maskRevealRef} r={R} fill={`url(#${uid}-see)`} />
              </mask>
            </defs>
            {round.map((spot, i) => {
              const p = spots[spot];
              const on = active === i;
              const x = (p.x / 100) * w;
              const y = (p.y / 100) * size.h;
              const shown = found[i] || phase === "done";
              return (
                <g key={`${spot}-${i}`} mask={shown ? undefined : `url(#${uid}-reveal)`}>
                  <text
                    ref={(el) => {
                      letterRefs.current[spot] = el;
                    }}
                    x={x}
                    y={y}
                    transform={`rotate(${p.rotate ?? 0} ${x} ${y})`}
                    textAnchor="middle"
                    dominantBaseline="central"
                    fontSize={letterSize}
                    fontWeight={900}
                    fill={shown || on ? "#fbbf24" : CREAM}
                    fillOpacity={found[i] && phase !== "done" ? 0.7 : 1}
                    stroke={on ? AMBER : "none"}
                    strokeWidth={on ? 2 : 0}
                    paintOrder="stroke"
                  >
                    {chars[i]}
                  </text>
                </g>
              );
            })}
          </svg>
        )}

        <svg className="pointer-events-none absolute inset-0 z-30 h-full w-full overflow-visible" aria-hidden="true">
          <g ref={ringRef} className={phase === "done" ? "opacity-0" : "opacity-100"}>
            <circle r={R - edge / 2} fill="none" stroke={AMBER} strokeOpacity="0.45" strokeWidth="1.5" />
            <circle r={3} fill={AMBER} fillOpacity="0.8" />
          </g>
        </svg>

        {active !== null && (
          <div
            className="pointer-events-none absolute z-30 -translate-x-1/2 -translate-y-1/2"
            style={{ left: `${spots[round[active]].x}%`, top: `${spots[round[active]].y}%`, width: ringBox, height: ringBox }}
            aria-hidden="true"
          >
            <svg width={ringBox} height={ringBox} className="-rotate-90">
              <circle cx={ringBox / 2} cy={ringBox / 2} r={arcR} fill="none" stroke={CREAM} strokeOpacity="0.25" strokeWidth="2" />
              <circle
                ref={(el) => {
                  arcRef.current = el;
                  if (el) el.style.strokeDashoffset = String(arcLen);
                }}
                cx={ringBox / 2}
                cy={ringBox / 2}
                r={arcR}
                fill="none"
                stroke="#f59e0b"
                strokeWidth="3"
                strokeDasharray={arcLen}
              />
            </svg>
          </div>
        )}

        {/* 自動テスト・案内用の候補位置の目印(見えない) */}
        {spots.map((p, i) => (
          <span
            key={i}
            data-spot={i}
            aria-hidden="true"
            className="absolute z-40 h-2 w-2 -translate-x-1/2 -translate-y-1/2 opacity-0"
            style={{ left: `${p.x}%`, top: `${p.y}%` }}
          />
        ))}

        {phase === "idle" && (
          <p className="pointer-events-none absolute left-3 top-3 z-30 max-w-[62%] bg-[#05070f]/80 px-3 py-2 text-xs leading-relaxed text-[#efe6d2] sm:left-5 sm:top-5 sm:text-sm">
            なぞると懐中電灯が照らします。まずは軒下の灯りの横から
          </p>
        )}
      </div>

      {flyers.map((f) => (
        <span
          key={f.id}
          ref={flyerRef(f)}
          aria-hidden="true"
          className="pointer-events-none absolute z-50 flex items-center justify-center font-black leading-none text-[#d97706]"
          style={{ left: f.x - f.size, top: f.y - f.size, width: f.size * 2, height: f.size * 2, fontSize: f.size * 0.82 }}
        >
          {f.char}
        </span>
      ))}

      <div className="grid gap-2 px-5 pt-5 sm:px-8 lg:grid-cols-[minmax(0,5fr)_minmax(0,6fr)] lg:gap-10">
        <p className="text-[15px] leading-[1.85] text-stone-600">
          店舗・商店街の「サイト内で合言葉を探す」キャンペーンに差し込める探索UIです。場面の上をなぞると光の輪がついてきて、文字の上で少し止まると上のマスに入ります。文字の場所は毎回変わります。
        </p>
        <p className="text-xs leading-relaxed text-stone-500 lg:pt-1">
          マウス・指: 場面の上をなぞる(指を離すと判定が外れます)/キーボード: 場面を選んで矢印キー、Shift で大きく動かす
        </p>
      </div>

      <p className="sr-only" aria-live="polite">
        {message}
      </p>

      {/* 結果と共有 */}
      <div className="mt-6 grid gap-6 border-t border-stone-200 px-5 py-7 sm:px-8 lg:grid-cols-[minmax(0,5fr)_minmax(0,6fr)] lg:gap-10">
        <div>
          <h3 className="text-base font-bold">結果を共有する</h3>
          {phase === "done" ? (
            <>
              <p className="mt-3 text-sm leading-relaxed text-stone-600">
                {count}文字そろうまで
                <span className="mx-1 font-display text-3xl font-bold tabular-nums text-[#d97706]">{sec}</span>秒でした。共有の画像では合言葉を伏せています。
              </p>
              <label className="mt-4 block text-xs font-bold text-stone-500" htmlFor={`${uid}-text`}>
                コピー文
              </label>
              <textarea
                id={`${uid}-text`}
                readOnly
                value={shareText}
                rows={3}
                className="mt-1 w-full resize-none border border-stone-300 bg-stone-50 p-3 text-sm leading-relaxed text-stone-800 outline-none focus-visible:outline-2 focus-visible:outline-[#d97706]"
              />
              <div className="mt-3 flex flex-wrap gap-2">
                <button type="button" onClick={() => doCopy("text")} className={`${btn} copy-text border-stone-900 bg-stone-900 text-white hover:bg-stone-700`}>
                  {copied === "text" ? "コピーしました" : "コピー文をコピー"}
                </button>
                {share && (
                  <a href={share.url} download={`flashlight-${share.sec}s.png`} className={`${btn} border-stone-300 bg-white text-stone-900 hover:border-[#d97706]`}>
                    画像を保存
                  </a>
                )}
                <button type="button" onClick={reset} className={`${btn} border-stone-300 bg-white text-stone-900 hover:border-[#d97706]`}>
                  もう一度
                </button>
              </div>
            </>
          ) : (
            <p className="mt-3 text-sm leading-relaxed text-stone-600">
              {count}文字そろうと、秒数と共有用の画像(1200×630)がここに出ます。画像は端末の中で作り、どこにも送信しません。
            </p>
          )}
        </div>
        <div className="self-start border border-stone-200 bg-stone-50">
          {share ? (
            // eslint-disable-next-line @next/next/no-img-element
            <img src={share.url} alt={`共有用の画像。隠れた${count}文字を${share.sec}秒で見つけた結果`} width={1200} height={630} className="block h-auto w-full" />
          ) : (
            <div className="flex aspect-[1200/630] items-center justify-center px-6 text-center text-sm text-stone-400">
              共有用の画像(暗い場面の一部・秒数・作品名・URL)
            </div>
          )}
        </div>
      </div>

      {/* 組み込み */}
      <div className="grid gap-6 border-t border-stone-200 px-5 py-7 sm:px-8 lg:grid-cols-[minmax(0,5fr)_minmax(0,6fr)] lg:gap-10">
        <div>
          <h3 className="text-base font-bold">年末やクリスマスの企画に差し替える</h3>
          <p className="mt-2 text-sm leading-relaxed text-stone-600">場面の SVG・文字・候補位置を渡せば、同じ仕組みで別の季節に使えます。</p>
          <table className="mt-4 w-full border-t border-stone-200 text-left text-[13px]">
            <tbody>
              {PROPS_ROWS.map(([name, type, note]) => (
                <tr key={name} className="border-b border-stone-200 align-top">
                  <th scope="row" className="py-2 pr-3 font-mono text-[12px] font-medium text-stone-900">
                    {name}
                  </th>
                  <td className="hidden py-2 pr-3 font-mono text-[12px] text-stone-500 sm:table-cell">{type}</td>
                  <td className="py-2 text-stone-600">{note}</td>
                </tr>
              ))}
            </tbody>
          </table>
        </div>
        <div className="min-w-0">
          <div className="flex items-center justify-between border border-b-0 border-stone-200 px-3 py-2">
            <span className="font-mono text-xs text-stone-500">year-end-campaign.tsx</span>
            <button type="button" onClick={() => doCopy("code")} className={`${btn} min-h-9 border-stone-300 bg-white px-3 text-xs text-stone-900 hover:border-[#d97706]`}>
              {copied === "code" ? "コピーしました" : "コードをコピー"}
            </button>
          </div>
          <pre className="max-h-80 overflow-auto border border-stone-200 bg-stone-950 p-4 font-mono text-[12px] leading-relaxed text-stone-100">
            <code>{PROPS_EXAMPLE}</code>
          </pre>
        </div>
      </div>
    </section>
  );
}

shadcn CLI でプロジェクトに追加

npx shadcn@latest add https://designs.first-ch.com/r/halloween-flashlight-reveal.json